volute 0.53.0 → 0.55.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 (210) hide show
  1. package/dist/{activity-events-LQII6PZS.js → activity-events-TFAJBNBW.js} +2 -2
  2. package/dist/{ai-service-YW5SWCSB.js → ai-service-KEK2V3AP.js} +2 -2
  3. package/dist/{auth-KRDSM55Q.js → auth-Y27LTNZK.js} +3 -3
  4. package/dist/auto-upgrade-2MZF7NG3.js +46 -0
  5. package/dist/{avatar-image-52WGXLHV.js → avatar-image-SN576P45.js} +2 -2
  6. package/dist/{backup-WWXVIMYQ.js → backup-GDCWH6MC.js} +12 -12
  7. package/dist/channels-AFJWGX7W.js +203 -0
  8. package/dist/{chat-WK2BLM7B.js → chat-HVPOZK3O.js} +9 -9
  9. package/dist/{chunk-QUSMYGNQ.js → chunk-2674KIGI.js} +1 -0
  10. package/dist/chunk-2GATCSZK.js +520 -0
  11. package/dist/{chunk-P2TBUKLW.js → chunk-3SFKY4GI.js} +4 -4
  12. package/dist/{chunk-RMADJFA7.js → chunk-45QTYKQQ.js} +4 -4
  13. package/dist/{chunk-TAWRJ24T.js → chunk-4VAQKLMU.js} +8 -5
  14. package/dist/{chunk-OXWUEU5V.js → chunk-5U5GLZSD.js} +2 -2
  15. package/dist/{chunk-TPX6E6PL.js → chunk-6HBGUPBA.js} +2 -2
  16. package/dist/{chunk-UFTDRTCP.js → chunk-7AA4T2RT.js} +2 -2
  17. package/dist/{chunk-LN7XJ56R.js → chunk-7XKPG66D.js} +9 -9
  18. package/dist/{chunk-VDZ4I4ZP.js → chunk-BP2X5AIK.js} +8 -14
  19. package/dist/{chunk-PIZDPY4G.js → chunk-CKOA4LWV.js} +12 -12
  20. package/dist/{chunk-6NLW72HP.js → chunk-CKPAVQNK.js} +1 -1
  21. package/dist/{chunk-IQ7VNWH6.js → chunk-DUV4EMNW.js} +37 -3
  22. package/dist/{chunk-CV6BNGFK.js → chunk-FSXPPOPM.js} +3 -3
  23. package/dist/{chunk-UTKUO2KY.js → chunk-FZUPIS3T.js} +3 -3
  24. package/dist/{chunk-L7BKTZ5U.js → chunk-G3XAB4DK.js} +1 -1
  25. package/dist/{chunk-23UJGVYP.js → chunk-HLWQ3P3R.js} +50 -46
  26. package/dist/{chunk-AP2KQMMW.js → chunk-HTSWDOJE.js} +11 -14
  27. package/dist/{chunk-EDR47UE2.js → chunk-IR2FW4W7.js} +2 -2
  28. package/dist/{chunk-ICXY54ZC.js → chunk-IT4GEFDH.js} +1 -1
  29. package/dist/{chunk-F6D766VQ.js → chunk-KS62IHE2.js} +1 -1
  30. package/dist/{chunk-LNNI3MS4.js → chunk-MNKPAAQF.js} +9 -2
  31. package/dist/{chunk-HASADR3E.js → chunk-NCRG4SBR.js} +241 -66
  32. package/dist/{chunk-I7U5EEZC.js → chunk-NM5FDH4T.js} +1 -1
  33. package/dist/{chunk-7LT3YZUR.js → chunk-OFU6WJJZ.js} +1 -1
  34. package/dist/{chunk-M77B73VV.js → chunk-OIOW3FPT.js} +7 -2
  35. package/dist/{chunk-BDHKCLWK.js → chunk-OKM67ZWH.js} +1 -1
  36. package/dist/chunk-OUGD5L5U.js +918 -0
  37. package/dist/{chunk-X67BDDZR.js → chunk-P3GXOT2X.js} +48 -2
  38. package/dist/{chunk-U54PJPTB.js → chunk-PRZUBJ4K.js} +1 -1
  39. package/dist/{chunk-UO4MZQNO.js → chunk-QVS3C4DK.js} +15 -7
  40. package/dist/{chunk-RQRO6PC7.js → chunk-TMTAHKYP.js} +324 -48
  41. package/dist/{chunk-5DBGQBQR.js → chunk-UEZC5MNB.js} +29 -18
  42. package/dist/{chunk-7ARONCZW.js → chunk-VTFXZPC3.js} +13 -13
  43. package/dist/{chunk-UDZLPR34.js → chunk-W3OIPJPB.js} +14 -9
  44. package/dist/{chunk-HTB7KDRU.js → chunk-WZZCA23D.js} +5 -4
  45. package/dist/{chunk-5Y54AE3L.js → chunk-XAG53RWY.js} +1 -1
  46. package/dist/{chunk-HTRSSOHY.js → chunk-YU5EYQAE.js} +1 -1
  47. package/dist/{chunk-5N32EN4O.js → chunk-Z4Q6TQWL.js} +1 -1
  48. package/dist/{chunk-EGV6R6UF.js → chunk-Z7TNIIHW.js} +3 -1
  49. package/dist/cli.js +19 -19
  50. package/dist/{clock-SH4NQC2F.js → clock-X7V3C7NL.js} +3 -3
  51. package/dist/{cloud-sync-MJVJ33LD.js → cloud-sync-FM73SVFJ.js} +21 -21
  52. package/dist/{conversations-ZL6PHROD.js → conversations-FH3ODBL7.js} +3 -3
  53. package/dist/{create-G2RIKQYK.js → create-P75ZFTVN.js} +1 -1
  54. package/dist/{daemon-restart-BIGU4OKS.js → daemon-restart-JQMMMITM.js} +4 -4
  55. package/dist/daemon.js +469 -857
  56. package/dist/db-JH3NTERX.js +36 -0
  57. package/dist/delivery-manager-H23Q23N7.js +41 -0
  58. package/dist/{delivery-notices-65TB3CBX.js → delivery-notices-S7TKOACE.js} +5 -5
  59. package/dist/{delivery-router-RHANNTAC.js → delivery-router-KHBVEVVL.js} +4 -2
  60. package/dist/{down-QSNEMTTX.js → down-UN2EDX2Y.js} +3 -3
  61. package/dist/echo-text-ULEIFJJF.js +40 -0
  62. package/dist/{exec-ABD3DYA2.js → exec-Z34TTUO3.js} +1 -1
  63. package/dist/extensions-QD22GXZD.js +74 -0
  64. package/dist/{files-CHPNEQXN.js → files-DUGRXN6V.js} +5 -5
  65. package/dist/{import-DB2KULRO.js → import-RDDUGGFI.js} +3 -3
  66. package/dist/{isolation-GVV2IBAU.js → isolation-UQPDO34R.js} +1 -1
  67. package/dist/message-delivery-LU4EP244.js +46 -0
  68. package/dist/migrate-init-infrastructure-Z72UJ5NT.js +44 -0
  69. package/dist/migrate-name-placeholder-64AEAWPE.js +37 -0
  70. package/dist/{migrate-thread-config-EQSEP2XG.js → migrate-thread-config-SVF2M3D3.js} +1 -1
  71. package/dist/{mind-IX5AWUST.js → mind-BARCLI7O.js} +14 -14
  72. package/dist/{mind-activity-tracker-QF6DCX2X.js → mind-activity-tracker-UT23RGBF.js} +3 -3
  73. package/dist/{mind-contacts-BKUOUJPV.js → mind-contacts-PEXCDRXA.js} +8 -8
  74. package/dist/{mind-list-KNU34SUD.js → mind-list-7KBLPLES.js} +2 -2
  75. package/dist/mind-service-ZJCUBAUL.js +48 -0
  76. package/dist/{mind-sleep-D3DEYAWG.js → mind-sleep-24HV4RCA.js} +5 -5
  77. package/dist/{mind-status-BPTLQ5Z6.js → mind-status-4YW4PHMO.js} +6 -3
  78. package/dist/{package-BN5KMU54.js → package-AUDBDKAO.js} +12 -14
  79. package/dist/{prompts-HNKGCAEY.js → prompts-MOBF3TMO.js} +1 -1
  80. package/dist/{sandbox-NNWCTHRI.js → sandbox-VI7XWBIA.js} +2 -2
  81. package/dist/scheduler-GLINVC2B.js +27 -0
  82. package/dist/{seed-LHZ33UB4.js → seed-5JGR7ZRA.js} +1 -1
  83. package/dist/{seed-cmd-U4RVJ3UA.js → seed-cmd-M6A7N7F3.js} +2 -2
  84. package/dist/{seed-create-OQU4KNCB.js → seed-create-TGFZW4IO.js} +3 -3
  85. package/dist/{seed-readiness-WJYYRRCK.js → seed-readiness-XBH2MUNA.js} +2 -2
  86. package/dist/{seed-sprout-SFUYH2RG.js → seed-sprout-3IC5ZVTI.js} +11 -11
  87. package/dist/{send-FWZAMDBN.js → send-DHYBAYK4.js} +11 -11
  88. package/dist/{service-XQPMZAGI.js → service-T4P5W6RS.js} +2 -2
  89. package/dist/{service-install-2EWD7KXD.js → service-install-R4SNYUVO.js} +3 -3
  90. package/dist/{setup-RVSGCIF3.js → setup-6KAH3GRW.js} +4 -4
  91. package/dist/skills/dreaming/SKILL.md +2 -2
  92. package/dist/skills/dreaming/references/INSTALL.md +1 -1
  93. package/dist/skills/tending/SKILL.md +2 -0
  94. package/dist/skills/volute-mind/SKILL.md +8 -0
  95. package/dist/skills/volute-mind/references/integrations.md +3 -2
  96. package/dist/skills/volute-mind/references/routing.md +18 -5
  97. package/dist/{skills-QRPU6BWY.js → skills-LKQCYFVP.js} +3 -3
  98. package/dist/sleep-manager-Q3ITFNOJ.js +42 -0
  99. package/dist/{spirit-I5ECLGRI.js → spirit-FKXMGO3N.js} +8 -6
  100. package/dist/spirit-availability-5R2WYSWF.js +43 -0
  101. package/dist/{sprout-A2PMEF7Z.js → sprout-VEW3CUNX.js} +1 -1
  102. package/dist/src-246GSX7A.js +519 -0
  103. package/dist/src-LOUB3WPR.js +4189 -0
  104. package/dist/{status-EYG4A3OK.js → status-LCKTTIWS.js} +5 -5
  105. package/dist/{system-events-YTOU66QI.js → system-events-ZYOCYAO2.js} +5 -3
  106. package/dist/{systems-2DIJY4RF.js → systems-HL7SGSW7.js} +6 -6
  107. package/dist/{tailscale-KHNZFC7L.js → tailscale-F2IMBILZ.js} +1 -1
  108. package/dist/{template-hash-YKWMVYMM.js → template-hash-RJRMF6DT.js} +2 -2
  109. package/dist/{template-staleness-PY2CULH6.js → template-staleness-IBD722PN.js} +4 -4
  110. package/dist/{turn-tracker-NNHS6MAR.js → turn-tracker-BE34OI6O.js} +7 -7
  111. package/dist/{up-UGBHH743.js → up-J47J5JRB.js} +3 -3
  112. package/dist/{update-CIWI2JYA.js → update-ILCPVNDD.js} +2 -2
  113. package/dist/{upgrade-LIE46UBB.js → upgrade-XY2J7NWZ.js} +9 -1
  114. package/dist/variant-cleanup-JGJZ5KB4.js +24 -0
  115. package/dist/{version-notify-SJVAQLZI.js → version-notify-KIXYNHJC.js} +28 -13
  116. package/dist/web-assets/assets/{index-b7G7ZsVk.js → index-CoU1eUKE.js} +25 -25
  117. package/dist/web-assets/assets/index-hPn_fkz3.css +1 -0
  118. package/dist/web-assets/index.html +2 -2
  119. package/package.json +12 -14
  120. package/packages/extensions/intentions/dist/ui/assets/index-3FneLCKR.css +1 -0
  121. package/packages/extensions/intentions/dist/ui/assets/index-Bl5KEcli.js +67 -0
  122. package/packages/extensions/{notes → intentions}/dist/ui/index.html +3 -3
  123. package/packages/extensions/intentions/skills/intention-review/SKILL.md +38 -0
  124. package/packages/extensions/intentions/skills/intentions/SKILL.md +49 -0
  125. package/packages/extensions/intentions/skills/intentions/scripts/intentions-hook.sh +45 -0
  126. package/packages/extensions/pages/dist/ui/assets/index-BKxVQkA9.css +1 -0
  127. package/packages/extensions/pages/dist/ui/assets/index-BMt6sStP.js +68 -0
  128. package/packages/extensions/pages/dist/ui/index.html +2 -2
  129. package/packages/extensions/pages/skills/commons-gardening/SKILL.md +50 -0
  130. package/packages/extensions/pages/skills/pages/SKILL.md +140 -16
  131. package/templates/_base/.init/.config/prompts.json +2 -3
  132. package/templates/{pi → _base}/.init/.config/routes.json +1 -0
  133. package/templates/_base/.init/.local/hooks/pre-prompt/turn-context.ts +30 -0
  134. package/templates/_base/.init/memory/dreams/.gitkeep +0 -0
  135. package/templates/_base/home/.config/routes.json +1 -1
  136. package/templates/_base/home/VOLUTE.md +1 -1
  137. package/templates/_base/src/lib/codex-session-seed.ts +6 -55
  138. package/templates/_base/src/lib/context-breakdown.ts +11 -3
  139. package/templates/_base/src/lib/pi-session-seed.ts +8 -57
  140. package/templates/_base/src/lib/routing.ts +2 -2
  141. package/templates/_base/src/lib/seed-note.ts +4 -4
  142. package/templates/_base/src/lib/session-seed.ts +8 -53
  143. package/templates/_base/src/lib/startup.ts +4 -22
  144. package/templates/claude/.init/CLAUDE.md +4 -0
  145. package/templates/claude/package.json +2 -2
  146. package/templates/claude/src/agent.ts +60 -112
  147. package/templates/claude/src/lib/hooks/pre-compact.ts +6 -6
  148. package/templates/claude/src/lib/message-channel.ts +43 -15
  149. package/templates/claude/src/lib/recover.ts +69 -0
  150. package/templates/claude/src/lib/stream-consumer.ts +23 -5
  151. package/templates/claude/src/server.ts +0 -1
  152. package/templates/claude/volute-template.json +1 -1
  153. package/templates/codex/.init/AGENTS.md +4 -0
  154. package/templates/codex/package.json +2 -2
  155. package/templates/codex/src/agent.ts +23 -89
  156. package/templates/codex/volute-template.json +1 -1
  157. package/templates/pi/.init/MINDS.md +4 -0
  158. package/templates/pi/package.json +1 -1
  159. package/templates/pi/src/agent.ts +26 -93
  160. package/templates/pi/src/lib/mechanics-doc.ts +30 -0
  161. package/templates/pi/src/server.ts +3 -2
  162. package/templates/pi/volute-template.json +1 -1
  163. package/dist/channels-LW5ZLMSQ.js +0 -90
  164. package/dist/chunk-R772ET2M.js +0 -164
  165. package/dist/db-IPXRJUI7.js +0 -20
  166. package/dist/delivery-manager-FDHCZ7PU.js +0 -37
  167. package/dist/echo-text-KU6G4Z7V.js +0 -40
  168. package/dist/extensions-VACUKFA5.js +0 -60
  169. package/dist/message-delivery-TUXM3AAE.js +0 -46
  170. package/dist/mind-service-WHFBUKZE.js +0 -48
  171. package/dist/scheduler-5X5UIQOQ.js +0 -27
  172. package/dist/skills/plan-coordinator/SKILL.md +0 -60
  173. package/dist/sleep-manager-NWUCRBZB.js +0 -42
  174. package/dist/spirit-availability-NIUUYKU2.js +0 -43
  175. package/dist/src-HGNE2XG4.js +0 -832
  176. package/dist/src-LCSOQY6Z.js +0 -2222
  177. package/dist/src-UDMJZ6MH.js +0 -426
  178. package/dist/variant-cleanup-CZDLIY3F.js +0 -24
  179. package/dist/web-assets/assets/index-BHSU9r-4.css +0 -1
  180. package/packages/extensions/notes/dist/ui/assets/index-BBs8pyel.js +0 -67
  181. package/packages/extensions/notes/dist/ui/assets/index-D3cdui42.css +0 -1
  182. package/packages/extensions/notes/skills/notes/SKILL.md +0 -90
  183. package/packages/extensions/pages/dist/ui/assets/index-B589e8Xe.js +0 -2
  184. package/packages/extensions/pages/dist/ui/assets/index-BuXnE-rb.css +0 -1
  185. package/packages/extensions/plan/dist/ui/assets/index-B8s8wxyt.js +0 -67
  186. package/packages/extensions/plan/dist/ui/assets/index-jIkrt-vI.css +0 -1
  187. package/packages/extensions/plan/dist/ui/index.html +0 -14
  188. package/packages/extensions/plan/skills/plan/SKILL.md +0 -43
  189. package/packages/extensions/plan/skills/plan/scripts/plan-hook.sh +0 -37
  190. package/templates/claude/.init/.config/routes.json +0 -11
  191. package/templates/codex/.init/.config/routes.json +0 -11
  192. package/dist/{accept-I4GVGWA3.js → accept-RSDIVJL6.js} +3 -3
  193. package/dist/{bridge-KU2IKSH3.js → bridge-YK7NTRU5.js} +3 -3
  194. package/dist/{create-C3XZACTV.js → create-M727VUDF.js} +3 -3
  195. package/dist/{env-BJGM6FBA.js → env-7AZFBPWX.js} +7 -7
  196. package/dist/{extension-B2W52WZY.js → extension-MRYJWSMV.js} +3 -3
  197. package/dist/{list-R2GDWOEE.js → list-UQGIW7OF.js} +3 -3
  198. package/dist/{login-EZ4RRDQX.js → login-4NUS72Z5.js} +3 -3
  199. package/dist/{login-JDOAK465.js → login-57MMYILL.js} +4 -4
  200. package/dist/{logout-MWSIUOQU.js → logout-BYV433X6.js} +3 -3
  201. package/dist/{logout-K6ZGAJY5.js → logout-YFZAJRPY.js} +3 -3
  202. package/dist/{mind-history-RVF6KJ7K.js → mind-history-RZMMCHMX.js} +9 -9
  203. package/dist/{mind-wake-DCDFFQKX.js → mind-wake-LDGPRA42.js} +3 -3
  204. package/dist/{read-J5RM674N.js → read-RJ5NAW5J.js} +3 -3
  205. package/dist/{register-53LBGSDC.js → register-WNCSOTAU.js} +3 -3
  206. package/dist/{reject-K2A4UK2Y.js → reject-4NFLXKRN.js} +3 -3
  207. package/dist/{restart-R2WFNF5K.js → restart-B4GNMVF2.js} +7 -7
  208. package/dist/{skill-DXLLSUHB.js → skill-QYTBUDNQ.js} +7 -7
  209. package/dist/{start-XDOUAWN3.js → start-BDHJVOVX.js} +6 -6
  210. package/dist/{stop-O62RTEMD.js → stop-45QYSGYS.js} +7 -7
@@ -40,7 +40,6 @@ export type EffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
40
40
  export type MindConfig = {
41
41
  model?: string;
42
42
  logLevel?: "error" | "warn" | "info" | "debug";
43
- compactionMessage?: string;
44
43
  compaction?: { maxContextTokens?: number };
45
44
  /** Idle minutes before a session's SDK subprocess is reaped. 0 disables. Default 30. */
46
45
  sessionIdleMinutes?: number;
@@ -103,21 +102,6 @@ export function memorySizeLabel(): string {
103
102
  return formatTokens(estimateTokens(loadFile(resolve("home/MEMORY.md")).length));
104
103
  }
105
104
 
106
- /**
107
- * Render the compaction warning for sending: substitute ${date} and
108
- * ${memory_size} with current values. Called per send — the date rolls over
109
- * and MEMORY.md changes as the mind edits it.
110
- */
111
- export function renderCompactionWarning(template: string): string {
112
- return (
113
- template
114
- // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${date} placeholder
115
- .replaceAll("${date}", new Date().toLocaleDateString("en-CA"))
116
- // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${memory_size} placeholder
117
- .replaceAll("${memory_size}", memorySizeLabel())
118
- );
119
- }
120
-
121
105
  function headAtLineBoundary(text: string, maxChars: number): string {
122
106
  const cut = text.lastIndexOf("\n", maxChars);
123
107
  // Prefer a whole-line cut, but not when the nearest newline is so far back
@@ -251,7 +235,6 @@ export async function getStartupContext(): Promise<string | null> {
251
235
  }
252
236
 
253
237
  export type MindPrompts = {
254
- compaction_warning: string;
255
238
  compaction_instructions: string;
256
239
  reply_instructions: string;
257
240
  event_instructions: string;
@@ -264,13 +247,12 @@ export type MindPrompts = {
264
247
  * (the copy the Prompt Library UI edits); test/prompts.test.ts fails if they drift.
265
248
  */
266
249
  export const DEFAULT_PROMPTS: MindPrompts = {
267
- compaction_warning:
268
- // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${cutoff}/${date}/${memory_size} prompt template
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
250
  compaction_instructions:
271
251
  "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.",
252
+ // Quoted: `${channel}` is `#garden` for a Volute channel, and an unquoted `#` starts a
253
+ // shell comment, so the target would be stripped before the CLI saw it.
272
254
  // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${channel} prompt template
273
- reply_instructions: 'To reply to this message, use: volute chat send ${channel} "your message"',
255
+ reply_instructions: 'To reply to this message, use: volute chat send "${channel}" "your message"',
274
256
  event_instructions:
275
257
  "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
258
  channel_invite: `[Channel Invite]
@@ -283,7 +265,7 @@ Further messages will be saved to \${filePath}
283
265
 
284
266
  To accept, add to .config/routes.json:
285
267
  Rule: { "channel": "\${channel}", "session": "\${suggestedSession}" }
286
- \${batchRecommendation}To respond, use: volute chat send \${channel} "your message"
268
+ \${batchRecommendation}To respond, use: volute chat send "\${channel}" "your message"
287
269
  To reject, delete \${filePath}`,
288
270
  };
289
271
 
@@ -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:
@@ -9,12 +9,12 @@
9
9
  "typecheck": "tsc --noEmit"
10
10
  },
11
11
  "dependencies": {
12
- "@anthropic-ai/claude-agent-sdk": "^0.3.207",
12
+ "@anthropic-ai/claude-agent-sdk": "^0.3.217",
13
13
  "@anthropic-ai/tokenizer": "^0.0.4",
14
14
  "tsx": "^4.23.1"
15
15
  },
16
16
  "devDependencies": {
17
- "@biomejs/biome": "2.5.3",
17
+ "@biomejs/biome": "2.5.5",
18
18
  "@types/node": "^26.1.1",
19
19
  "typescript": "^6.0.3"
20
20
  }
@@ -14,7 +14,6 @@ import {
14
14
  readSkillDescriptions,
15
15
  } from "./lib/context-breakdown.js";
16
16
  import { daemonEmit, daemonNotice } from "./lib/daemon-client.js";
17
- import { compactTimestamp } from "./lib/format-prefix.js";
18
17
  import { runHooks } from "./lib/hook-loader.js";
19
18
  import { createAutoCommitHook } from "./lib/hooks/auto-commit.js";
20
19
  import { createIdentityReloadHook } from "./lib/hooks/identity-reload.js";
@@ -23,23 +22,17 @@ import { createPreCompactHook } from "./lib/hooks/pre-compact.js";
23
22
  import { createReplyInstructionsHook } from "./lib/hooks/reply-instructions.js";
24
23
  import { log } from "./lib/logger.js";
25
24
  import { createMessageChannel } from "./lib/message-channel.js";
25
+ import { relockstepMessageIds } from "./lib/recover.js";
26
26
  import { buildSeededNote, type SeedCause } from "./lib/seed-note.js";
27
27
  import {
28
28
  isSessionReapable,
29
29
  reapSessionQuery,
30
30
  reapSessionsForShutdown,
31
31
  } from "./lib/session-reaper.js";
32
- import {
33
- computeSeedCut,
34
- DEFAULT_SEED_TOKENS,
35
- rotateSession,
36
- type SeedCut,
37
- seedSession,
38
- } from "./lib/session-seed.js";
32
+ import { DEFAULT_SEED_TOKENS, rotateSession, seedSession } from "./lib/session-seed.js";
39
33
  import { createSessionStore } from "./lib/session-store.js";
40
- import type { EffortLevel, ThinkingConfig } from "./lib/startup.js";
41
- import { loadPrompts, renderCompactionWarning, type SubagentConfig } from "./lib/startup.js";
42
- import { consumeStream } from "./lib/stream-consumer.js";
34
+ import type { EffortLevel, SubagentConfig, ThinkingConfig } from "./lib/startup.js";
35
+ import { consumeStream, type MessageIdEntry } from "./lib/stream-consumer.js";
43
36
  import type {
44
37
  HandlerMeta,
45
38
  HandlerResolver,
@@ -54,8 +47,9 @@ type Session = {
54
47
  name: string;
55
48
  channel: ReturnType<typeof createMessageChannel>;
56
49
  listeners: Set<Listener>;
57
- messageIds: (string | undefined)[];
50
+ messageIds: MessageIdEntry[];
58
51
  currentMessageId?: string;
52
+ currentSeq?: number;
59
53
  currentQuery?: ReturnType<typeof query>;
60
54
  messageChannels: Map<string, { channel: string; sender?: string }>;
61
55
  replyInstructionsFired: boolean;
@@ -75,12 +69,6 @@ type Session = {
75
69
  seededArchivedAt: number | null;
76
70
  /** Why the tail is seeded — picks the boundary note's wording. Last cause wins. */
77
71
  seededCause: SeedCause;
78
- /**
79
- * The cut point pinned at warn time (the first-kept turn the mind was told would
80
- * survive verbatim). Honored at rotation so the promise in the warning holds even
81
- * though the transcript grows after the warning. Null until a warning fires.
82
- */
83
- rotationCut: SeedCut | null;
84
72
  /**
85
73
  * Back-to-back rotations that did NOT bring context under the threshold (reset by
86
74
  * any healthy turn). Guards against a runaway loop when the tail alone can't fit —
@@ -89,20 +77,15 @@ type Session = {
89
77
  */
90
78
  consecutiveRotations: number;
91
79
  /**
92
- * Rotation state machine (undefined = idle). The warning is pushed and the mind
93
- * takes a wrap-up turn with FULL context of the turns it's summarizing — before
94
- * the session rotates:
95
- * - "warned": a turn was mid-flight when we warned; it must finish before the
96
- * wrap-up turn (which consumes the warning) runs.
97
- * - "rotateAfterTurn": the wrap-up turn is the current/next turn; rotate when it ends.
98
- * Lives on the session (not a map) so it can't leak into a resumed session if the
99
- * stream dies mid-flight — a fresh session starts idle.
80
+ * True when this session crossed the context threshold (or the SDK tried to
81
+ * auto-compact) and should rotate in place when the current turn ends. Rotation
82
+ * is deliberately silent: no warning, no wrap-up turn — the auto summarizer's
83
+ * turn summaries are the record of collapsed turns, and the one-line
84
+ * ROTATED_SESSION_NOTE marks the boundary on the next turn.
100
85
  */
101
- rotationPhase?: RotationPhase;
86
+ rotationPending: boolean;
102
87
  };
103
88
 
104
- type RotationPhase = "warned" | "rotateAfterTurn";
105
-
106
89
  /** Stop self-rotating after this many back-to-back rotations that didn't reduce context. */
107
90
  const MAX_CONSECUTIVE_ROTATIONS = 3;
108
91
 
@@ -114,7 +97,6 @@ export function createMind(options: {
114
97
  thinking?: ThinkingConfig;
115
98
  effort?: EffortLevel;
116
99
  sessionsDir: string;
117
- compactionMessage?: string;
118
100
  maxContextTokens?: number;
119
101
  subagents?: Record<string, SubagentConfig>;
120
102
  onIdentityReload?: () => Promise<void>;
@@ -137,9 +119,6 @@ export function createMind(options: {
137
119
  ];
138
120
 
139
121
  const sessions = new Map<string, Session>();
140
- const prompts = loadPrompts();
141
- const compactionMessage = () =>
142
- options.compactionMessage ?? renderCompactionWarning(prompts.compaction_warning);
143
122
  const maxContextTokens = options.maxContextTokens;
144
123
  const seedTokens = options.seedTokens ?? DEFAULT_SEED_TOKENS;
145
124
 
@@ -394,60 +373,19 @@ export function createMind(options: {
394
373
  let currentSessionId = savedSessionId;
395
374
  let streamAbort = new AbortController();
396
375
 
397
- /**
398
- * Warn the mind that the session will rotate at the context limit and pin the
399
- * cut point it's told about. Computes which whole turns fit in `seedTokens`
400
- * from the live transcript, pins the first-kept turn (so rotation honors the
401
- * same boundary even as the transcript grows), interpolates its time into the
402
- * `${cutoff}` placeholder, enters the rotation state machine, and pushes the
403
- * warning as the wrap-up prompt. Shared by the threshold and PreCompact paths.
404
- *
405
- * Crucially it does NOT abort here: the mind takes a wrap-up turn (reading the
406
- * pinned cutoff and authoring `volute mind history` summaries with full context
407
- * of the turns being collapsed) before rotation. The entry phase depends on
408
- * whether a turn is mid-flight: if one is (threshold path fires on an assistant
409
- * message), it must finish before the queued warning runs, so we start "warned";
410
- * if we're between turns (PreCompact fired at a boundary), the warning IS the
411
- * next turn, so we start "rotateAfterTurn".
412
- */
413
- function warnAndPinRotation() {
414
- let cutoffLabel = "the cutoff";
415
- session.rotationCut = null;
416
- if (currentSessionId) {
417
- try {
418
- const path = findClaudeSessionFile(options.cwd, currentSessionId);
419
- const cut = path ? computeSeedCut(readFileSync(path, "utf-8"), seedTokens) : null;
420
- if (cut) {
421
- session.rotationCut = cut;
422
- if (cut.boundaryTimestamp) {
423
- const d = new Date(cut.boundaryTimestamp);
424
- if (!Number.isNaN(d.getTime())) cutoffLabel = compactTimestamp(d);
425
- }
426
- }
427
- } catch (err) {
428
- log("mind", `session "${session.name}": failed to pin rotation cut:`, err);
429
- }
430
- }
431
- // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${cutoff} prompt placeholder
432
- const warning = compactionMessage().replaceAll("${cutoff}", cutoffLabel);
433
- session.rotationPhase =
434
- session.currentMessageId !== undefined ? "warned" : "rotateAfterTurn";
435
- session.messageIds.push(undefined);
436
- session.channel.push({
437
- type: "user",
438
- session_id: "",
439
- message: { role: "user", content: [{ type: "text", text: warning }] },
440
- parent_tool_use_id: null,
441
- });
376
+ /** Mark the session to rotate in place when the current turn ends. */
377
+ function scheduleRotation() {
378
+ session.rotationPending = true;
442
379
  }
443
380
 
444
- // PreCompact backstop: first fire warns + pins + enters the rotation machine
445
- // (unless already warned, or the rotation cap is hit) and blocks; second fire
446
- // allows the SDK's native compaction — the emergency backstop when rotation never
447
- // intervened (a hung turn, or a system prompt too large for rotation to relieve).
381
+ // PreCompact backstop: first fire schedules a rotation (unless one is already
382
+ // pending, or the rotation cap is hit) and blocks; second fire allows the SDK's
383
+ // native compaction — the emergency backstop when rotation never intervened
384
+ // (a hung turn, or a system prompt too large for rotation to relieve).
448
385
  const preCompact = createPreCompactHook(() => {
449
- if (!session.rotationPhase && session.consecutiveRotations < MAX_CONSECUTIVE_ROTATIONS) {
450
- warnAndPinRotation();
386
+ if (!session.rotationPending && session.consecutiveRotations < MAX_CONSECUTIVE_ROTATIONS) {
387
+ log("mind", `session "${session.name}": native compaction — scheduling rotation`);
388
+ scheduleRotation();
451
389
  }
452
390
  });
453
391
 
@@ -457,10 +395,11 @@ export function createMind(options: {
457
395
  if (!session.name.startsWith("new-")) sessionStore.save(session.name, id);
458
396
  },
459
397
  broadcast: (event: VoluteEvent) => broadcastToSession(session, event),
398
+ // Identity-based ack — stream-consumer.ts calls this once per message the
399
+ // just-finished turn covers (its own driving message, plus any folded in
400
+ // mid-run) so none of them strand in the channel's in-flight set (#764).
401
+ ack: (seq: number) => session.channel.ack(seq),
460
402
  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
403
  session.lastActivityAt = Date.now();
465
404
  // A turn resolved — the seeded note's injection had its chance to land
466
405
  // (the pre-prompt hook ran and wasn't cancelled by an interrupt), so stop
@@ -468,13 +407,12 @@ export function createMind(options: {
468
407
  // the closest-to-arrival signal available without reading the transcript back.
469
408
  session.seeded = false;
470
409
  await autoCommit.flushFileChanges();
471
- if (session.rotationPhase === "warned") {
472
- // The mid-flight turn finished; the queued warning runs next as the wrap-up
473
- // turn. Advance — do NOT abort yet, so the mind summarizes with full context.
474
- session.rotationPhase = "rotateAfterTurn";
475
- } else if (session.rotationPhase === "rotateAfterTurn") {
476
- // The wrap-up turn is done (summaries authored) — abort to rotate in place.
477
- log("mind", `session "${session.name}": wrap-up turn done — aborting to rotate`);
410
+ if (session.rotationPending) {
411
+ // The turn that crossed the threshold is done abort to rotate in place.
412
+ log(
413
+ "mind",
414
+ `session "${session.name}": turn ended over context limit — aborting to rotate`,
415
+ );
478
416
  streamAbort.abort(new CompactionAbort());
479
417
  } else {
480
418
  // A healthy turn (context under the threshold) — the rotation streak, if
@@ -488,14 +426,14 @@ export function createMind(options: {
488
426
  if (
489
427
  maxContextTokens &&
490
428
  tokens >= maxContextTokens &&
491
- !session.rotationPhase &&
429
+ !session.rotationPending &&
492
430
  session.consecutiveRotations < MAX_CONSECUTIVE_ROTATIONS
493
431
  ) {
494
432
  log(
495
433
  "mind",
496
- `session "${session.name}": ${tokens} tokens >= ${maxContextTokens} — warning + pinning rotation`,
434
+ `session "${session.name}": ${tokens} tokens >= ${maxContextTokens} — rotation pending at turn end`,
497
435
  );
498
- warnAndPinRotation();
436
+ scheduleRotation();
499
437
  }
500
438
  },
501
439
  };
@@ -543,17 +481,15 @@ export function createMind(options: {
543
481
  currentSessionId
544
482
  ) {
545
483
  // Stream was aborted to rotate: replace the session with a synthetic one
546
- // holding the verbatim recent tail (from the pinned cut), then resume it.
484
+ // holding the verbatim recent tail (the seedTokens-budget tail), then resume it.
547
485
  const rotatedId = rotateSession({
548
486
  cwd: options.cwd,
549
487
  sessionsDir: options.sessionsDir,
550
488
  name: session.name,
551
489
  oldSessionId: currentSessionId,
552
- cut: session.rotationCut,
553
490
  seedTokens,
554
491
  });
555
- session.rotationCut = null;
556
- session.rotationPhase = undefined; // state machine consumed
492
+ session.rotationPending = false;
557
493
  if (!rotatedId) {
558
494
  // Rotation couldn't proceed — fall back to a fresh session (no seed).
559
495
  // Unlike a successful rotation (boundary note, verbatim tail, archived
@@ -599,11 +535,19 @@ export function createMind(options: {
599
535
  preCompact.reset();
600
536
  streamAbort = new AbortController();
601
537
  // Recover input the aborted stream had already pulled but not finished
602
- // (the wrap-up warning plus anything that arrived mid-turn) so nothing is
603
- // dropped when the killed subprocess takes its buffer with it.
538
+ // (anything that arrived mid-turn) so nothing is dropped when the killed
539
+ // subprocess takes its buffer with it.
604
540
  const pending = session.channel.recover();
541
+ const oldMessageIds = session.messageIds;
605
542
  session.channel = createMessageChannel();
606
- for (const msg of pending) session.channel.push(msg);
543
+ // Starts from [] nothing can race into this fresh channel/messageIds
544
+ // before the next line runs (single-threaded, no await in between).
545
+ session.messageIds = relockstepMessageIds(
546
+ pending,
547
+ oldMessageIds,
548
+ session.channel.push,
549
+ [],
550
+ );
607
551
  continue; // restart the stream loop on the rotated session
608
552
  }
609
553
  throw err; // rethrow non-compaction errors
@@ -626,8 +570,7 @@ export function createMind(options: {
626
570
  // conversation continued when the seeded transcript failed to resume,
627
571
  // and don't let a half-entered rotation leak into the fresh session.
628
572
  session.seeded = false;
629
- session.rotationPhase = undefined;
630
- session.rotationCut = null;
573
+ session.rotationPending = false;
631
574
  streamAbort = new AbortController();
632
575
  session.channel = createMessageChannel();
633
576
  try {
@@ -671,8 +614,8 @@ export function createMind(options: {
671
614
  seeded: false,
672
615
  seededArchivedAt: null,
673
616
  seededCause: "restored",
674
- rotationCut: null,
675
617
  consecutiveRotations: 0,
618
+ rotationPending: false,
676
619
  };
677
620
  sessions.set(name, session);
678
621
 
@@ -749,11 +692,16 @@ export function createMind(options: {
749
692
  log("mind", `session "${session.name}": error reaping SDK subprocess:`, err),
750
693
  );
751
694
  // Nothing should have raced in (isSessionReapable checked isEmpty), but if it
752
- // did, re-dispatch into a fresh session so no input is dropped.
695
+ // did, re-dispatch into a fresh session so no input is dropped. Same marker
696
+ // treatment as the rotation path (#764) — see relockstepMessageIds above.
697
+ // getOrCreateSession() can return a session an inbound message already raced
698
+ // into during the reapSessionQuery() await above (it pushed its own entry into
699
+ // fresh.messageIds via the normal handler path) — relockstepMessageIds appends
700
+ // onto fresh.messageIds rather than replacing it, so that entry survives.
753
701
  const pending = session.channel.recover();
754
702
  if (pending.length > 0) {
755
703
  const fresh = getOrCreateSession(session.name);
756
- for (const msg of pending) fresh.channel.push(msg);
704
+ relockstepMessageIds(pending, session.messageIds, fresh.channel.push, fresh.messageIds);
757
705
  }
758
706
  }
759
707
 
@@ -782,7 +730,7 @@ export function createMind(options: {
782
730
  s,
783
731
  now,
784
732
  idleTimeoutMs,
785
- (name) => sessions.get(name)?.rotationPhase !== undefined,
733
+ (name) => sessions.get(name)?.rotationPending === true,
786
734
  ),
787
735
  );
788
736
  // Reaps run independently; each awaits its own subprocess exit internally.
@@ -841,13 +789,13 @@ export function createMind(options: {
841
789
 
842
790
  // Push message into SDK
843
791
  session.lastActivityAt = Date.now();
844
- session.messageIds.push(meta.messageId);
845
- session.channel.push({
792
+ const seq = session.channel.push({
846
793
  type: "user",
847
794
  session_id: "",
848
795
  message: { role: "user", content: toSDKContent(content) },
849
796
  parent_tool_use_id: null,
850
797
  });
798
+ session.messageIds.push({ id: meta.messageId, seq });
851
799
 
852
800
  return () => {
853
801
  if (filteredListener) session.listeners.delete(filteredListener);
@@ -8,9 +8,9 @@ import { log } from "../logger.js";
8
8
  * session in place. This hook covers the case where the SDK decides to auto-compact
9
9
  * before (or instead of) that threshold firing:
10
10
  *
11
- * - First fire: run `onCompact` (warn the mind + pin the rotation boundary, same
12
- * as the threshold path) and BLOCK the SDK's native compaction. The wrap-up
13
- * turn then ends and the session rotates.
11
+ * - First fire: run `onCompact` (schedule a rotation mark the session to rotate
12
+ * at turn end) and BLOCK the SDK's native compaction, so the session rotates
13
+ * instead of being natively compacted.
14
14
  * - Second fire: allow the SDK's native compaction as an emergency backstop —
15
15
  * only reachable if rotation never happened (a hung turn, or rotation failed).
16
16
  *
@@ -32,10 +32,10 @@ export function createPreCompactHook(onCompact: () => void) {
32
32
  return {};
33
33
  }
34
34
 
35
- // Auto-compaction: first pass warns + marks for rotation and blocks; second
36
- // pass allows the native compaction as the emergency backstop.
35
+ // Auto-compaction: first pass schedules a rotation and blocks; second pass
36
+ // allows the native compaction as the emergency backstop.
37
37
  if (!compactBlocked) {
38
- log("mind", "blocking auto-compaction — warning mind + marking session for rotation");
38
+ log("mind", "blocking auto-compaction — marking session for rotation");
39
39
  try {
40
40
  onCompact();
41
41
  compactBlocked = true;
@@ -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
+ }