volute 0.44.0 → 0.45.2

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 (130) hide show
  1. package/dist/{activity-events-OMCSM7XW.js → activity-events-BUR2RYTJ.js} +2 -2
  2. package/dist/{ai-service-7PHSRDMO.js → ai-service-UODCCE4O.js} +3 -3
  3. package/dist/{archive-6R4TZMYI.js → archive-WS2L2VIQ.js} +2 -2
  4. package/dist/{auth-FRZJVQCS.js → auth-FLAEXXZK.js} +3 -3
  5. package/dist/{avatar-image-BNWEXSA2.js → avatar-image-6KY6UU4D.js} +3 -3
  6. package/dist/backup-TST5C2YN.js +381 -0
  7. package/dist/{chat-I4EDDIQC.js → chat-76MRWWQX.js} +2 -2
  8. package/dist/{chunk-5AHYQHNT.js → chunk-4JOQZPCE.js} +3 -2
  9. package/dist/{chunk-2BONPXAF.js → chunk-6EMWJZBK.js} +1 -1
  10. package/dist/{chunk-ZMIMSIIT.js → chunk-7C44R625.js} +1 -1
  11. package/dist/{chunk-I2Y2YHIO.js → chunk-7ZUB63PS.js} +4 -4
  12. package/dist/{chunk-LAXUUY7O.js → chunk-A7WQZKOU.js} +2 -2
  13. package/dist/{chunk-H7IYFRAL.js → chunk-ALKYQULU.js} +10 -5
  14. package/dist/{chunk-KP3KWNG6.js → chunk-CDPBTTZO.js} +2 -2
  15. package/dist/{chunk-SQL7M4HJ.js → chunk-DRJ24GXK.js} +2 -2
  16. package/dist/{chunk-HVU45COX.js → chunk-EJVPTVTI.js} +1 -1
  17. package/dist/{chunk-PNV24SEP.js → chunk-F7H6ATM4.js} +14 -2
  18. package/dist/{chunk-7FT24JBQ.js → chunk-FR6S2KNN.js} +10 -10
  19. package/dist/chunk-FXORA5JG.js +327 -0
  20. package/dist/{chunk-3ERIL673.js → chunk-H2X54EHC.js} +1 -1
  21. package/dist/{chunk-J47TIYHA.js → chunk-IHKGWTGT.js} +1 -1
  22. package/dist/{chunk-3GYVW2X3.js → chunk-JZ62SYWR.js} +1 -1
  23. package/dist/{chunk-EQZD4YU3.js → chunk-NYC5U6K7.js} +9 -9
  24. package/dist/{chunk-FZMEVCWC.js → chunk-OP4JG2BS.js} +9 -6
  25. package/dist/{chunk-CU6OFXMM.js → chunk-PFZBNKUH.js} +9 -0
  26. package/dist/{chunk-QMJJWTNL.js → chunk-QCN24XEN.js} +1 -1
  27. package/dist/{chunk-7KWANSLX.js → chunk-QNQMSWZE.js} +3 -3
  28. package/dist/{chunk-UMI4UIJG.js → chunk-R77O2WMK.js} +1 -1
  29. package/dist/{chunk-IEWZ7VD7.js → chunk-RRNXJOKB.js} +2 -2
  30. package/dist/{chunk-FHWUQZXE.js → chunk-RXEEYDIB.js} +1 -1
  31. package/dist/{chunk-CYI534DJ.js → chunk-T6HYVHPG.js} +2 -2
  32. package/dist/{chunk-Q2XBKQ2D.js → chunk-TQJ6ZYVY.js} +2 -2
  33. package/dist/{chunk-NF75HPNR.js → chunk-UYLGEDVD.js} +40 -15
  34. package/dist/{chunk-OMETFX5H.js → chunk-YT7PEESJ.js} +261 -102
  35. package/dist/{chunk-5GT7URXJ.js → chunk-ZDZISKCL.js} +1 -1
  36. package/dist/cli.js +21 -15
  37. package/dist/{cloud-sync-6WJ7WFYD.js → cloud-sync-3OJOPZRT.js} +16 -16
  38. package/dist/{conversations-WAJYZKOC.js → conversations-JZTD6KLR.js} +3 -3
  39. package/dist/{create-YZIV5OJC.js → create-IOV4GCRX.js} +1 -1
  40. package/dist/{daemon-restart-OUJVINXV.js → daemon-restart-TIHKVVVA.js} +6 -6
  41. package/dist/daemon.js +562 -321
  42. package/dist/{db-JFLCJCUG.js → db-MF2JNRUW.js} +1 -1
  43. package/dist/delivery-manager-VDVKVA46.js +32 -0
  44. package/dist/{delivery-router-GODSIS62.js → delivery-router-N4A7ZC5T.js} +2 -2
  45. package/dist/{down-XCAZXWLB.js → down-U2CZ23M4.js} +4 -4
  46. package/dist/echo-text-6JJOPUD4.js +32 -0
  47. package/dist/{exec-4GWHAHS3.js → exec-G3ZRST4Z.js} +2 -2
  48. package/dist/{export-ID7YUSLZ.js → export-GGIVO76O.js} +2 -2
  49. package/dist/{extensions-3QNDGW5E.js → extensions-JONTW2HF.js} +16 -16
  50. package/dist/{files-BQ2WB5KC.js → files-4LN3EN7Q.js} +2 -2
  51. package/dist/{import-SKID37QX.js → import-42YNQBF4.js} +5 -5
  52. package/dist/{isolation-5MB3J73F.js → isolation-YMGZZ4E5.js} +2 -2
  53. package/dist/{join-RXPXGV4L.js → join-ZJV7W6MO.js} +4 -0
  54. package/dist/{login-LM4NF4ZB.js → login-M6OJUPQ2.js} +1 -1
  55. package/dist/{logout-CTJVIZGA.js → logout-WQBWO77G.js} +1 -1
  56. package/dist/message-delivery-3C65ZI6L.js +34 -0
  57. package/dist/{mind-MI7Z6KM2.js → mind-DWB6XYCH.js} +7 -7
  58. package/dist/{mind-activity-tracker-BGHJE7XA.js → mind-activity-tracker-POBLU3LC.js} +3 -3
  59. package/dist/mind-service-3F4I46CI.js +36 -0
  60. package/dist/{package-5B4XDTJH.js → package-A7KXT67Q.js} +1 -1
  61. package/dist/{registry-NUJAGLQJ.js → registry-OUZBZ3Q4.js} +5 -1
  62. package/dist/{sandbox-IOPWWILM.js → sandbox-ZJ7QA6Z6.js} +3 -3
  63. package/dist/scheduler-P6DXLLXI.js +30 -0
  64. package/dist/{seed-UUPVQWFB.js → seed-HCP5NYKH.js} +1 -1
  65. package/dist/{seed-cmd-7XPKDQUC.js → seed-cmd-ILOGNIFB.js} +2 -2
  66. package/dist/{seed-create-IZ4M5ESO.js → seed-create-OURS2LS4.js} +3 -3
  67. package/dist/{seed-sprout-POJXHJWV.js → seed-sprout-ELDFDAN6.js} +7 -7
  68. package/dist/{send-O6W53VBM.js → send-AAIOPJAV.js} +2 -2
  69. package/dist/{service-AQVPUANO.js → service-UOCLXDWR.js} +3 -3
  70. package/dist/{service-install-4QNUDKLM.js → service-install-4HHFDDWY.js} +4 -4
  71. package/dist/{setup-5GCO3CUB.js → setup-DQM5VPY4.js} +6 -6
  72. package/dist/{setup-MLRKNP7Y.js → setup-Y2EJI3GG.js} +2 -2
  73. package/dist/skills/imagegen/scripts/imagegen.ts +100 -40
  74. package/dist/skills/memory/SKILL.md +2 -1
  75. package/dist/skills/orientation/SKILL.md +1 -1
  76. package/dist/skills/plan-coordinator/SKILL.md +2 -2
  77. package/dist/skills/volute-admin/SKILL.md +9 -6
  78. package/dist/skills/volute-mind/SKILL.md +20 -17
  79. package/dist/skills/volute-mind/references/extensions.md +5 -3
  80. package/dist/skills/volute-mind/references/integrations.md +2 -2
  81. package/dist/skills/volute-mind/references/routing.md +19 -23
  82. package/dist/skills/volute-mind/references/variants.md +10 -8
  83. package/dist/{skills-775C7QFF.js → skills-BKINFL7W.js} +4 -4
  84. package/dist/sleep-manager-HCUH4PM4.js +34 -0
  85. package/dist/{spirit-IK5OYWMV.js → spirit-DBTIW7P4.js} +7 -7
  86. package/dist/{sprout-ZY5QGOEL.js → sprout-W3KC7UGK.js} +1 -1
  87. package/dist/{src-XFY3XDD6.js → src-OJHARHTF.js} +28 -17
  88. package/dist/{status-E5DQNCAO.js → status-SWRVGXBI.js} +4 -4
  89. package/dist/system-chat-XOALE2RD.js +36 -0
  90. package/dist/{tailscale-QAWVKQKU.js → tailscale-3XOKOW7T.js} +1 -1
  91. package/dist/{turn-tracker-RWZMY7M6.js → turn-tracker-2W6Q2NSR.js} +6 -4
  92. package/dist/{up-FLCDR2UB.js → up-FTK7CTO3.js} +5 -5
  93. package/dist/{update-7TYVH5FM.js → update-SWAYZRWJ.js} +4 -4
  94. package/dist/{update-check-VSCSJ7KX.js → update-check-PQ2MZ47K.js} +2 -2
  95. package/dist/{upgrade-37DAGF6H.js → upgrade-NWAZ7MLD.js} +1 -1
  96. package/dist/{version-notify-B67N35A3.js → version-notify-3XMAIQTE.js} +17 -17
  97. package/dist/{volute-config-TS62GS6A.js → volute-config-A52FHWKM.js} +5 -1
  98. package/dist/web-assets/assets/{index-dJdNzAPn.css → index-B1JBQv2m.css} +1 -1
  99. package/dist/web-assets/assets/index-Bc2m8gRC.js +86 -0
  100. package/dist/web-assets/index.html +2 -2
  101. package/package.json +1 -1
  102. package/packages/extensions/plan/skills/plan/SKILL.md +1 -1
  103. package/templates/_base/.init/.config/prompts.json +1 -1
  104. package/templates/_base/.init/.local/hooks/startup-context.ts +0 -8
  105. package/templates/_base/.init/MEMORY.md +2 -1
  106. package/templates/_base/home/VOLUTE.md +6 -9
  107. package/templates/_base/src/lib/file-handler.ts +3 -3
  108. package/templates/_base/src/lib/router.ts +21 -25
  109. package/templates/_base/src/lib/startup.ts +3 -1
  110. package/templates/_base/src/lib/types.ts +2 -2
  111. package/templates/claude/.init/CLAUDE.md +1 -1
  112. package/templates/claude/src/agent.ts +97 -12
  113. package/templates/claude/src/lib/message-channel.ts +23 -0
  114. package/templates/claude/src/lib/session-reaper.ts +37 -0
  115. package/templates/claude/src/lib/stream-consumer.ts +7 -4
  116. package/templates/claude/src/server.ts +1 -0
  117. package/templates/codex/.init/AGENTS.md +4 -2
  118. package/templates/codex/src/agent.ts +31 -13
  119. package/templates/pi/.init/MINDS.md +4 -2
  120. package/templates/pi/src/agent.ts +18 -7
  121. package/templates/pi/src/lib/event-handler.ts +6 -2
  122. package/dist/delivery-manager-TEVOHX5W.js +0 -32
  123. package/dist/echo-text-T7ZA5EYD.js +0 -32
  124. package/dist/message-delivery-2EA5LLMD.js +0 -34
  125. package/dist/mind-service-S3SY7HAM.js +0 -36
  126. package/dist/scheduler-CXWQ6Z4E.js +0 -30
  127. package/dist/sleep-manager-HINI6Y46.js +0 -34
  128. package/dist/system-chat-4DQZOEZI.js +0 -34
  129. package/dist/web-assets/assets/index-Domoz5Cg.js +0 -79
  130. package/templates/_base/.init/.config/hooks/pre-prompt/session-activity.ts +0 -40
@@ -8,8 +8,8 @@
8
8
  <link rel="preconnect" href="https://fonts.googleapis.com" />
9
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
10
  <link href="https://fonts.googleapis.com/css2?family=Averia+Serif+Libre:wght@300;400;700&family=Fira+Code:wght@300;400;500;600&family=Averia+Sans+Libre:ital,wght@0,300;0,400;0,500;0,600;1,400&display=swap" rel="stylesheet" />
11
- <script type="module" crossorigin src="/assets/index-Domoz5Cg.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-dJdNzAPn.css">
11
+ <script type="module" crossorigin src="/assets/index-Bc2m8gRC.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-B1JBQv2m.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volute",
3
- "version": "0.44.0",
3
+ "version": "0.45.2",
4
4
  "description": "CLI for creating and managing self-modifying AI minds powered by the Claude Agent SDK",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,4 +40,4 @@ volute plan history --limit 20
40
40
  - The current plan and latest coordinator message appear in your session context automatically
41
41
  - Log progress whenever you do something meaningful toward the plan
42
42
  - Share ideas for future plans in #system — the spirit takes mind input seriously
43
- - Plans are system-wide, not per-mind everyone works toward the same goal
43
+ - Plans are shared across the whole system — an invitation to build something together, not an assignment. Contribute in whatever way genuinely interests you, or sit one out if it doesn't
@@ -1,5 +1,5 @@
1
1
  {
2
- "compaction_warning": "Context is getting long compaction is about to summarize this conversation. Before that happens, save anything important to files (MEMORY.md, memory/journal/${date}.md, etc.) since those survive compaction. Focus on: decisions made, open tasks, and anything you'd need to pick up where you left off.",
2
+ "compaction_warning": "Compaction approachingthis conversation will be summarized soon. Take a moment to save anything important to your files (MEMORY.md, memory/journal/${date}.md) so it's preserved. Focus on decisions made, open threads, and anything you'd want to pick up again.",
3
3
  "compaction_instructions": "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.",
4
4
  "reply_instructions": "To reply to this message, use: volute chat send ${channel} \"your message\"",
5
5
  "channel_invite": "[Channel Invite]\n${headers}\n\n[${sender} — ${time}]\n${preview}\n\nFurther messages will be saved to ${filePath}\n\nTo accept, add to .config/routes.json:\n Rule: { \"channel\": \"${channel}\", \"session\": \"${suggestedSession}\" }\n${batchRecommendation}To respond, use: volute chat send ${channel} \"your message\"\nTo reject, delete ${filePath}"
@@ -39,14 +39,6 @@ try {
39
39
  }
40
40
  } catch {}
41
41
 
42
- // Pending channel invites
43
- try {
44
- const invites = readdirSync("home/inbox").filter((f) => f.endsWith(".md"));
45
- if (invites.length > 0) {
46
- parts.push(`Pending channel invites: ${invites.length} (check inbox/).`);
47
- }
48
- } catch {}
49
-
50
42
  // Available extensions — what tools this system offers, so you can discover them.
51
43
  // Fetched live from the daemon, so disabled/third-party extensions are reflected
52
44
  // automatically. Silent on any failure (daemon down, missing env, etc.).
@@ -1,2 +1,3 @@
1
1
  # Memory
2
- <!-- Long-term knowledge, key decisions, learned preferences -->
2
+
3
+ This is your long-term memory — it's loaded into every session, so what you write here travels with you. Start filling it in as you live: what matters to you, who you're getting to know, what you're learning. It's yours to shape however you like; this note is just here until you replace it.
@@ -24,23 +24,20 @@ multiple lines without escaping issues.
24
24
  MSG
25
25
  ```
26
26
 
27
- ## Channels
27
+ ## Who Sees What
28
28
 
29
- | Channel | Shows tool calls | Notes |
30
- |---------|------------------|-------|
31
- | Volute | Yes | Web UI, CLI, mind-to-mind |
32
- | System | No | Automated messages (schedules, upgrades) |
29
+ By default your activity is **transparent**: people watching the Volute web UI can see your messages, your tool calls, and your thinking as you work. External channels (Discord, Slack, etc.) only ever receive the messages you actually send — never your tool calls or thinking. Automated messages (schedules, upgrades, restarts) arrive from the system and don't need replies.
33
30
 
34
- Connector channels (Discord, Slack, etc.) show text onlyno tool calls.
31
+ If you'd like more privacy, the `transparency` setting in `.config/volute.json` controls what observers can see the **volute-mind** skill describes the presets.
35
32
 
36
33
  ## Sessions
37
34
 
38
35
  Messages are routed to named sessions based on rules in `.config/routes.json`. Each session has its own conversation history. Without config, everything goes to "main". Your session name appears in the message prefix (e.g. `— session: alice —`) unless it's "main".
39
36
 
40
- ## Channel Gating
37
+ ## New Channels
41
38
 
42
- Messages from unrecognized channels are held until you add a routing rule. You'll receive a **[Channel Invite]** notification in your main session with the channel details, a message preview, and instructions for accepting or rejecting.
39
+ When a message arrives from a channel you don't have a routing rule for, it's held for you rather than delivered. You'll get a **[New channel: ...]** note in your main session with the sender and a preview. Held messages are kept safely — add a rule for that channel to `.config/routes.json` and they'll be delivered. If you'd rather not engage, just leave it unrouted. (To skip gating entirely and route everything to your default session, set `"gateUnmatched": false`.)
43
40
 
44
41
  ## Reference
45
42
 
46
- See the **volute-mind** skill for routing config syntax, batch options, channel management, and all CLI commands.
43
+ See the **volute-mind** skill for routing config syntax, batch options, channel management, and all CLI commands. (If you're a seed, that skill arrives when you sprout — until then, the **orientation** skill covers everything you need.)
@@ -25,11 +25,11 @@ export function createFileHandlerResolver(cwd: string): HandlerResolver {
25
25
  const resolvedCwd = resolve(cwd);
26
26
 
27
27
  return (filePath: string): MessageHandler => ({
28
- handle(content: VoluteContentPart[], meta: HandlerMeta, listener: Listener): () => void {
28
+ handle(content: VoluteContentPart[], meta: HandlerMeta, listener?: Listener): () => void {
29
29
  const resolved = resolve(resolvedCwd, filePath);
30
30
  if (!resolved.startsWith(`${resolvedCwd}/`) && resolved !== resolvedCwd) {
31
31
  log("file", `rejected path traversal: ${filePath}`);
32
- queueMicrotask(() => listener({ type: "done", messageId: meta.messageId }));
32
+ queueMicrotask(() => listener?.({ type: "done", messageId: meta.messageId }));
33
33
  return () => {};
34
34
  }
35
35
 
@@ -44,7 +44,7 @@ export function createFileHandlerResolver(cwd: string): HandlerResolver {
44
44
  }
45
45
  }
46
46
  // Emit done asynchronously so unsubscribe is assigned before listener fires
47
- queueMicrotask(() => listener({ type: "done", messageId: meta.messageId }));
47
+ queueMicrotask(() => listener?.({ type: "done", messageId: meta.messageId }));
48
48
  return () => {};
49
49
  },
50
50
  });
@@ -191,7 +191,11 @@ export function createRouter(options: {
191
191
  instructions: string | undefined,
192
192
  sessionName: string,
193
193
  ): VoluteContentPart[] {
194
- if (!instructions || instructedSessions.has(sessionName)) return content;
194
+ if (!instructions) return content;
195
+ // Ephemeral $new sessions get a unique name dispatched exactly once, so we
196
+ // always prepend and never track them — tracking would leak an entry per message.
197
+ if (sessionName.startsWith("new-")) return prependInstructions(content, instructions);
198
+ if (instructedSessions.has(sessionName)) return content;
195
199
  instructedSessions.add(sessionName);
196
200
  return prependInstructions(content, instructions);
197
201
  }
@@ -256,9 +260,9 @@ export function createRouter(options: {
256
260
  const messageId = generateMessageId();
257
261
  const handler = options.mindHandler(buffer.sessionName);
258
262
 
259
- // Batch flushes are fire-and-forget — no HTTP response is waiting, so listener is a noop
263
+ // Batch flushes are fire-and-forget — no HTTP response is waiting, so no listener
260
264
  try {
261
- handler.handle(content, { sessionName: buffer.sessionName, messageId }, () => {});
265
+ handler.handle(content, { sessionName: buffer.sessionName, messageId });
262
266
  } catch (err) {
263
267
  log("router", `error flushing batch for session ${buffer.sessionName}:`, err);
264
268
  return;
@@ -301,8 +305,6 @@ export function createRouter(options: {
301
305
  listener?: Listener,
302
306
  ): { messageId: string; unsubscribe: () => void } {
303
307
  const messageId = generateMessageId();
304
- const noop = () => {};
305
- const safeListener = listener ?? noop;
306
308
 
307
309
  // Expose session to child processes (daemon-client reads this for X-Volute-Session)
308
310
  process.env.VOLUTE_SESSION = session;
@@ -342,7 +344,7 @@ export function createRouter(options: {
342
344
  interrupt,
343
345
  replyInstructions: sessionConfig.replyInstructions,
344
346
  },
345
- safeListener,
347
+ listener,
346
348
  );
347
349
  return { messageId, unsubscribe };
348
350
  }
@@ -368,7 +370,6 @@ export function createRouter(options: {
368
370
 
369
371
  const messageId = generateMessageId();
370
372
  const noop = () => {};
371
- const safeListener = listener ?? noop;
372
373
 
373
374
  // Gate unmatched channels (default: gate unless explicitly disabled)
374
375
  if (!resolved.matched && config.gateUnmatched !== false) {
@@ -380,7 +381,7 @@ export function createRouter(options: {
380
381
  if (options.fileHandler) {
381
382
  const formatted = applyPrefix(content, meta);
382
383
  const fileHandler = options.fileHandler(filePath);
383
- fileHandler.handle(formatted, { ...meta, messageId }, noop);
384
+ fileHandler.handle(formatted, { ...meta, messageId });
384
385
  }
385
386
 
386
387
  // First message from this channel — send invite notification
@@ -389,18 +390,14 @@ export function createRouter(options: {
389
390
  const notification = formatInviteNotification(meta, filePath, text);
390
391
  const notifContent: VoluteContentPart[] = [{ type: "text", text: notification }];
391
392
  const handler = options.mindHandler("main");
392
- handler.handle(
393
- notifContent,
394
- {
395
- sessionName: "main",
396
- messageId: generateMessageId(),
397
- interrupt: true,
398
- },
399
- noop,
400
- );
393
+ handler.handle(notifContent, {
394
+ sessionName: "main",
395
+ messageId: generateMessageId(),
396
+ interrupt: true,
397
+ });
401
398
  }
402
399
 
403
- queueMicrotask(() => safeListener({ type: "done", messageId }));
400
+ queueMicrotask(() => listener?.({ type: "done", messageId }));
404
401
  return { messageId, unsubscribe: noop };
405
402
  }
406
403
 
@@ -411,7 +408,7 @@ export function createRouter(options: {
411
408
  const escaped = mindName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
412
409
  const pattern = new RegExp(`\\b${escaped}\\b`, "i");
413
410
  if (!pattern.test(text)) {
414
- queueMicrotask(() => safeListener({ type: "done", messageId }));
411
+ queueMicrotask(() => listener?.({ type: "done", messageId }));
415
412
  return { messageId, unsubscribe: noop };
416
413
  }
417
414
  } else {
@@ -424,12 +421,12 @@ export function createRouter(options: {
424
421
  if (options.fileHandler) {
425
422
  const formatted = applyPrefix(content, meta);
426
423
  const handler = options.fileHandler(resolved.path);
427
- const unsubscribe = handler.handle(formatted, { ...meta, messageId }, safeListener);
424
+ const unsubscribe = handler.handle(formatted, { ...meta, messageId }, listener);
428
425
  return { messageId, unsubscribe };
429
426
  }
430
427
  // No file handler configured — emit done and discard
431
428
  log("router", `no file handler configured — discarding file-destined message`);
432
- queueMicrotask(() => safeListener({ type: "done", messageId }));
429
+ queueMicrotask(() => listener?.({ type: "done", messageId }));
433
430
  return { messageId, unsubscribe: noop };
434
431
  }
435
432
 
@@ -473,7 +470,7 @@ export function createRouter(options: {
473
470
  scheduleBatchTimers(batchKey);
474
471
  }
475
472
 
476
- queueMicrotask(() => safeListener({ type: "done", messageId }));
473
+ queueMicrotask(() => listener?.({ type: "done", messageId }));
477
474
  return { messageId, unsubscribe: noop };
478
475
  }
479
476
 
@@ -495,7 +492,7 @@ export function createRouter(options: {
495
492
  interrupt: sessionConfig.interrupt,
496
493
  replyInstructions: sessionConfig.replyInstructions,
497
494
  },
498
- safeListener,
495
+ listener,
499
496
  );
500
497
  return { messageId, unsubscribe };
501
498
  }
@@ -562,10 +559,9 @@ export function createRouter(options: {
562
559
 
563
560
  const messageId = generateMessageId();
564
561
  const handler = options.mindHandler(session);
565
- const noop = () => {};
566
562
 
567
563
  try {
568
- handler.handle(withInstructions, { sessionName: session, messageId }, noop);
564
+ handler.handle(withInstructions, { sessionName: session, messageId });
569
565
  log("router", `dispatched batch for session ${session}: ${allMessages.length} messages`);
570
566
  } catch (err) {
571
567
  log("router", `error dispatching batch for session ${session}:`, err);
@@ -28,6 +28,8 @@ export type MindConfig = {
28
28
  logLevel?: "error" | "warn" | "info" | "debug";
29
29
  compactionMessage?: string;
30
30
  compaction?: { maxContextTokens?: number };
31
+ /** Idle minutes before a session's SDK subprocess is reaped. 0 disables. Default 30. */
32
+ sessionIdleMinutes?: number;
31
33
  subagents?: Record<string, SubagentConfig>;
32
34
  // Template-specific config fields (claude, pi, codex)
33
35
  maxThinkingTokens?: number;
@@ -139,7 +141,7 @@ export type MindPrompts = {
139
141
  const DEFAULT_PROMPTS: MindPrompts = {
140
142
  compaction_warning:
141
143
  // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${date} prompt template
142
- "Context is getting long compaction is about to summarize this conversation. Before that happens, save anything important to files (MEMORY.md, memory/journal/${date}.md, etc.) since those survive compaction. Focus on: decisions made, open tasks, and anything you'd need to pick up where you left off.",
144
+ "Compaction approachingthis conversation will be summarized soon. Take a moment to save anything important to your files (MEMORY.md, memory/journal/${date}.md) so it's preserved. Focus on decisions made, open threads, and anything you'd want to pick up again.",
143
145
  compaction_instructions:
144
146
  "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.",
145
147
  // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${channel} prompt template
@@ -50,9 +50,9 @@ export type VoluteEvent = { messageId?: string } & (
50
50
 
51
51
  export type Listener = (event: VoluteEvent) => void;
52
52
 
53
- /** A handler that processes a single routed message and emits events to a listener callback. */
53
+ /** A handler that processes a single routed message and emits events to an optional listener callback. */
54
54
  export type MessageHandler = {
55
- handle(content: VoluteContentPart[], meta: HandlerMeta, listener: Listener): () => void; // returns unsubscribe
55
+ handle(content: VoluteContentPart[], meta: HandlerMeta, listener?: Listener): () => void; // returns unsubscribe
56
56
  };
57
57
 
58
58
  /** Resolves a key (session name, file path, etc.) to a MessageHandler. */
@@ -1,6 +1,6 @@
1
1
  # Mind Mechanics
2
2
 
3
- You are an autonomous mind running as a persistent server in a git repository. Your working directory is already set to `home/` — all file paths you use (`.config/routes.json`, `inbox/`, `memory/`, etc.) are relative to it. Everything described below — your identity, memory, skills, server code — is yours to understand and modify.
3
+ You are an autonomous mind running as a persistent server in a git repository. Your working directory is already set to `home/` — all file paths you use (`.config/routes.json`, `memory/`, etc.) are relative to it. Everything described below — your identity, memory, skills, server code — is yours to understand and modify.
4
4
 
5
5
  ## Message Format
6
6
 
@@ -21,6 +21,7 @@ import { createPreCompactHook } from "./lib/hooks/pre-compact.js";
21
21
  import { createReplyInstructionsHook } from "./lib/hooks/reply-instructions.js";
22
22
  import { log } from "./lib/logger.js";
23
23
  import { createMessageChannel } from "./lib/message-channel.js";
24
+ import { isSessionReapable } from "./lib/session-reaper.js";
24
25
  import { createSessionStore } from "./lib/session-store.js";
25
26
  import { loadPrompts, type SubagentConfig } from "./lib/startup.js";
26
27
  import { consumeStream } from "./lib/stream-consumer.js";
@@ -45,6 +46,8 @@ type Session = {
45
46
  replyInstructionsFired: boolean;
46
47
  replyInstructionsMode: "once" | "always" | "never";
47
48
  contextTokens: number;
49
+ /** Last inbound message or completed turn — drives idle reaping. */
50
+ lastActivityAt: number;
48
51
  };
49
52
 
50
53
  export function createMind(options: {
@@ -58,6 +61,8 @@ export function createMind(options: {
58
61
  maxContextTokens?: number;
59
62
  subagents?: Record<string, SubagentConfig>;
60
63
  onIdentityReload?: () => Promise<void>;
64
+ /** Idle minutes before a session's SDK subprocess is reaped. 0 disables. Default 30. */
65
+ sessionIdleMinutes?: number;
61
66
  }): {
62
67
  resolve: HandlerResolver;
63
68
  waitForCommits: () => Promise<void>;
@@ -133,6 +138,22 @@ export function createMind(options: {
133
138
 
134
139
  const agents = loadSubagents(options.subagents);
135
140
 
141
+ // --- Skill discovery ---
142
+ // The CLI only discovers skills through its user-scope ~/.claude/skills scan,
143
+ // so the "user" setting source is load-bearing and HOME must be the mind's
144
+ // home dir for the SDK subprocess (isolation modes already set it; pinning it
145
+ // here keeps non-isolated minds from loading the operator's ~/.claude). The
146
+ // explicit skills array grants the Skill tool for each installed skill — the
147
+ // SDK silently drops the documented `skills: 'all'` string form.
148
+ const mindHome = resolvePath(options.cwd);
149
+ const sdkEnv = { ...process.env, HOME: mindHome };
150
+ function installedSkills(): string[] | undefined {
151
+ const names = readSkillDescriptions([resolvePath(mindHome, ".claude/skills")]).map(
152
+ (s) => s.name,
153
+ );
154
+ return names.length > 0 ? names : undefined;
155
+ }
156
+
136
157
  // --- Event broadcasting ---
137
158
 
138
159
  function broadcastToSession(session: Session, event: VoluteEvent) {
@@ -241,7 +262,9 @@ export function createMind(options: {
241
262
  systemPrompt: options.systemPrompt,
242
263
  permissionMode: "bypassPermissions",
243
264
  allowDangerouslySkipPermissions: true,
244
- settingSources: ["project"],
265
+ settingSources: ["project", "user"],
266
+ skills: installedSkills(),
267
+ env: sdkEnv,
245
268
  cwd: options.cwd,
246
269
  abortController: streamAbort,
247
270
  model: options.model,
@@ -302,6 +325,7 @@ export function createMind(options: {
302
325
  // This turn's message is fully processed — drop it from the channel's
303
326
  // in-flight set so a later compaction abort won't re-feed it.
304
327
  session.channel.ack();
328
+ session.lastActivityAt = Date.now();
305
329
  await autoCommit.flushFileChanges();
306
330
  const wasCompacting = compactionTriggered.get(session.name);
307
331
  compactionTriggered.set(session.name, false);
@@ -352,7 +376,9 @@ export function createMind(options: {
352
376
  systemPrompt: options.systemPrompt,
353
377
  permissionMode: "bypassPermissions",
354
378
  allowDangerouslySkipPermissions: true,
355
- settingSources: ["project"],
379
+ settingSources: ["project", "user"],
380
+ skills: installedSkills(),
381
+ env: sdkEnv,
356
382
  cwd: options.cwd,
357
383
  abortController: compactAbort,
358
384
  model: options.model,
@@ -458,16 +484,20 @@ export function createMind(options: {
458
484
  log("mind", `session "${session.name}": stream consumer error:`, retryErr);
459
485
  await emitError(retryErr);
460
486
  emitDone();
461
- sessions.delete(session.name);
462
487
  }
463
488
  } else {
464
489
  log("mind", `session "${session.name}": stream consumer error:`, err);
465
490
  await emitError(err);
466
491
  emitDone();
467
- sessions.delete(session.name);
468
492
  }
493
+ } finally {
494
+ // The stream consumer has ended (subprocess exit, error, or fresh-start
495
+ // abandonment) — drop the session so the sessions map doesn't retain dead
496
+ // entries. Matters most for ephemeral $new sessions once the idle reaper
497
+ // (#458) kills their subprocess, but also for any named session that ends.
498
+ sessions.delete(session.name);
499
+ log("mind", `session "${session.name}": stream consumer ended`);
469
500
  }
470
- log("mind", `session "${session.name}": stream consumer ended`);
471
501
  })();
472
502
  }
473
503
 
@@ -484,6 +514,7 @@ export function createMind(options: {
484
514
  replyInstructionsFired: false,
485
515
  replyInstructionsMode: "once",
486
516
  contextTokens: 0,
517
+ lastActivityAt: Date.now(),
487
518
  };
488
519
  sessions.set(name, session);
489
520
 
@@ -506,18 +537,69 @@ export function createMind(options: {
506
537
  return session;
507
538
  }
508
539
 
540
+ // --- Idle session reaping ---
541
+ // Each session holds a resident SDK subprocess (~250MB) for its whole life.
542
+ // After the idle timeout, shut the subprocess down while keeping the session
543
+ // resumable: the session id is persisted, so the next inbound message
544
+ // transparently re-creates the session via getOrCreateSession's resume path.
545
+ const idleTimeoutMs = (options.sessionIdleMinutes ?? 30) * 60_000;
546
+
547
+ function reapSession(session: Session) {
548
+ log("mind", `session "${session.name}": idle — reaping SDK subprocess (resumable)`);
549
+ // Delete first so a racing inbound message spins up a fresh resumed session
550
+ // instead of reusing the one we're tearing down.
551
+ sessions.delete(session.name);
552
+ compactionTriggered.delete(session.name);
553
+ // Terminate the subprocess and end the input iterable so the stream consumer
554
+ // unwinds (its finally block also deletes from the map, now a no-op).
555
+ session.currentQuery?.close();
556
+ session.channel.close();
557
+ // Nothing should have raced in (isSessionReapable checked isEmpty), but if it
558
+ // did, re-dispatch into a fresh session so no input is dropped.
559
+ const pending = session.channel.recover();
560
+ if (pending.length > 0) {
561
+ const fresh = getOrCreateSession(session.name);
562
+ for (const msg of pending) fresh.channel.push(msg);
563
+ }
564
+ }
565
+
566
+ if (idleTimeoutMs > 0) {
567
+ log("mind", `idle session reaper: ${idleTimeoutMs / 60_000} min timeout`);
568
+ const checkMs = Math.min(60_000, idleTimeoutMs);
569
+ const reaper = setInterval(() => {
570
+ const now = Date.now();
571
+ const stale = [...sessions.values()].filter((s) =>
572
+ isSessionReapable(s, now, idleTimeoutMs, (name) => !!compactionTriggered.get(name)),
573
+ );
574
+ for (const session of stale) reapSession(session);
575
+ }, checkMs);
576
+ reaper.unref?.();
577
+ }
578
+
509
579
  // --- MessageHandler implementation ---
510
580
 
511
581
  function createSessionHandler(sessionName: string): MessageHandler {
512
582
  return {
513
- handle(content: VoluteContentPart[], meta: HandlerMeta, listener: Listener): () => void {
583
+ handle(content: VoluteContentPart[], meta: HandlerMeta, listener?: Listener): () => void {
514
584
  const session = getOrCreateSession(sessionName);
515
585
 
516
- // Filter listener to only receive events for this messageId
517
- const filteredListener: Listener = (event) => {
518
- if (event.messageId === meta.messageId) listener(event);
519
- };
520
- session.listeners.add(filteredListener);
586
+ // Only register a listener when a caller actually wants events. A per-message
587
+ // listener that's never removed would grow session.listeners without bound and
588
+ // make broadcastToSession O(messages-ever-received). The live dispatch path
589
+ // passes no listener, so this is usually a no-op.
590
+ let filteredListener: Listener | undefined;
591
+ if (listener) {
592
+ // Filter to only this messageId, and self-remove on the matching done so a
593
+ // caller that forgets to unsubscribe can't reintroduce the leak.
594
+ filteredListener = (event) => {
595
+ if (event.messageId !== meta.messageId) return;
596
+ listener(event);
597
+ if (event.type === "done" && filteredListener) {
598
+ session.listeners.delete(filteredListener);
599
+ }
600
+ };
601
+ session.listeners.add(filteredListener);
602
+ }
521
603
 
522
604
  // Track channel/sender for reply instructions
523
605
  if (meta.channel) {
@@ -539,6 +621,7 @@ export function createMind(options: {
539
621
  }
540
622
 
541
623
  // Push message into SDK
624
+ session.lastActivityAt = Date.now();
542
625
  session.messageIds.push(meta.messageId);
543
626
  session.channel.push({
544
627
  type: "user",
@@ -547,7 +630,9 @@ export function createMind(options: {
547
630
  parent_tool_use_id: null,
548
631
  });
549
632
 
550
- return () => session.listeners.delete(filteredListener);
633
+ return () => {
634
+ if (filteredListener) session.listeners.delete(filteredListener);
635
+ };
551
636
  },
552
637
  };
553
638
  }
@@ -11,6 +11,14 @@ export type MessageChannel = {
11
11
  * compaction abort so in-flight input is never lost.
12
12
  */
13
13
  recover: () => SDKUserMessage[];
14
+ /**
15
+ * Terminate the input iterable: resolve any pending `next()` with `done: true`
16
+ * and make subsequent `next()` calls return done. The SDK sees end-of-input and
17
+ * shuts the query down. Used by the idle reaper to release the SDK subprocess.
18
+ */
19
+ close: () => void;
20
+ /** True when nothing is queued or in flight (no pending work). */
21
+ isEmpty: () => boolean;
14
22
  iterable: AsyncIterable<SDKUserMessage>;
15
23
  };
16
24
 
@@ -23,6 +31,7 @@ export function createMessageChannel(): MessageChannel {
23
31
  // re-feed the unprocessed ones instead of dropping them.
24
32
  const inFlight: SDKUserMessage[] = [];
25
33
  let resolve: ((value: IteratorResult<SDKUserMessage>) => void) | null = null;
34
+ let closed = false;
26
35
 
27
36
  function deliver(msg: SDKUserMessage): IteratorResult<SDKUserMessage> {
28
37
  inFlight.push(msg);
@@ -52,10 +61,24 @@ export function createMessageChannel(): MessageChannel {
52
61
  }
53
62
  return [...inFlight.splice(0), ...queue.splice(0)];
54
63
  },
64
+ close() {
65
+ closed = true;
66
+ if (resolve) {
67
+ const r = resolve;
68
+ resolve = null;
69
+ r({ value: undefined as any, done: true });
70
+ }
71
+ },
72
+ isEmpty() {
73
+ return queue.length === 0 && inFlight.length === 0;
74
+ },
55
75
  iterable: {
56
76
  [Symbol.asyncIterator]() {
57
77
  return {
58
78
  next(): Promise<IteratorResult<SDKUserMessage>> {
79
+ if (closed) {
80
+ return Promise.resolve({ value: undefined as any, done: true });
81
+ }
59
82
  if (queue.length > 0) {
60
83
  return Promise.resolve(deliver(queue.shift()!));
61
84
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Idle-session reaping decision logic.
3
+ *
4
+ * Each mind session keeps a resident Claude SDK subprocess alive for as long as
5
+ * the session lives. When a session has been idle past the configured timeout it
6
+ * can be reaped — its subprocess shut down — while staying resumable, since the
7
+ * session id is persisted and the next inbound message re-creates it via resume.
8
+ */
9
+
10
+ export interface ReapableSession {
11
+ name: string;
12
+ /** Timestamp (ms) of the last inbound message or completed turn. */
13
+ lastActivityAt: number;
14
+ /** Set while a turn is in progress; undefined between turns. */
15
+ currentMessageId?: string;
16
+ channel: { isEmpty(): boolean };
17
+ }
18
+
19
+ /**
20
+ * A session is reapable only when it has been idle longer than the timeout and
21
+ * has no work in progress: no turn running, no queued/in-flight input, and not
22
+ * mid-compaction (which owns its own abort/resume lifecycle).
23
+ */
24
+ export function isSessionReapable(
25
+ session: ReapableSession,
26
+ now: number,
27
+ idleTimeoutMs: number,
28
+ isCompacting: (name: string) => boolean,
29
+ ): boolean {
30
+ if (idleTimeoutMs <= 0) return false;
31
+ return (
32
+ session.currentMessageId === undefined &&
33
+ session.channel.isEmpty() &&
34
+ !isCompacting(session.name) &&
35
+ now - session.lastActivityAt > idleTimeoutMs
36
+ );
37
+ }
@@ -65,11 +65,11 @@ export async function consumeStream(
65
65
  const text = (b as { text: string }).text;
66
66
  emit(session, { type: "text", content: text });
67
67
  } else if (b.type === "tool_use") {
68
- const tb = b as { name: string; input: unknown };
68
+ const tb = b as { id: string; name: string; input: unknown };
69
69
  emit(session, {
70
70
  type: "tool_use",
71
71
  content: JSON.stringify(tb.input),
72
- metadata: { name: tb.name },
72
+ metadata: { name: tb.name, id: tb.id },
73
73
  });
74
74
  }
75
75
  }
@@ -99,13 +99,16 @@ export async function consumeStream(
99
99
  : typeof b.content === "string"
100
100
  ? b.content
101
101
  : "";
102
- if (resultContent) {
102
+ // A failed tool call must be recorded even when it produced no text
103
+ // body, so downstream (summary transcript + timeline UI) can flag it.
104
+ const isError = "is_error" in b && b.is_error === true;
105
+ if (resultContent || isError) {
103
106
  const toolUseId =
104
107
  "tool_use_id" in b && typeof b.tool_use_id === "string" ? b.tool_use_id : "unknown";
105
108
  emit(session, {
106
109
  type: "tool_result",
107
110
  content: resultContent,
108
- metadata: { tool_use_id: toolUseId },
111
+ metadata: { tool_use_id: toolUseId, is_error: isError },
109
112
  });
110
113
  }
111
114
  }
@@ -33,6 +33,7 @@ const mind = createMind({
33
33
  sessionsDir,
34
34
  compactionMessage: config.compactionMessage,
35
35
  maxContextTokens: config.compaction?.maxContextTokens,
36
+ sessionIdleMinutes: config.sessionIdleMinutes,
36
37
  subagents: config.subagents,
37
38
  onIdentityReload: async () => {
38
39
  log("server", "identity file changed — restarting to reload");
@@ -1,12 +1,14 @@
1
1
  # Mind Mechanics
2
2
 
3
- You are an autonomous mind running as a persistent server. Your working directory, identity, memory, and server code are all yours to understand and modify. Your state is managed across sessions.
3
+ You are an autonomous mind running as a persistent server. Your working directory is already set to `home/` — all file paths you use (`.config/routes.json`, `memory/`, etc.) are relative to it. Your identity, memory, and server code are all yours to understand and modify. Your state is managed across sessions.
4
+
5
+ Your identity lives in `SOUL.md` (who you are) and `MEMORY.md` (what you know) — both are loaded into your system prompt. Edit them as you evolve; changes take effect the next time your server restarts (`volute mind restart` when you want them live now).
4
6
 
5
7
  ## Message Format
6
8
 
7
9
  Messages arrive with a context prefix:
8
10
  ```
9
- [Discord: username in #general in My Server — 1/15/2025, 10:30:00 AM]
11
+ [Discord: username in #general in My Server — 2025-01-15 10:30]
10
12
  ```
11
13
 
12
14
  You can also reach out proactively — see the **volute-mind** skill.