volute 0.45.1 → 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 (108) hide show
  1. package/dist/{activity-events-OMCSM7XW.js → activity-events-BUR2RYTJ.js} +2 -2
  2. package/dist/{ai-service-534WUSDL.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-OZXHL3JS.js → backup-TST5C2YN.js} +7 -7
  7. package/dist/{chat-I4EDDIQC.js → chat-76MRWWQX.js} +2 -2
  8. package/dist/{chunk-ZIR7HSAX.js → chunk-4JOQZPCE.js} +1 -1
  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-Y6OPHHGM.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-322KE457.js → chunk-F7H6ATM4.js} +1 -1
  18. package/dist/{chunk-5AUKCNPE.js → chunk-FR6S2KNN.js} +8 -8
  19. package/dist/{chunk-KVKP6VQ5.js → chunk-FXORA5JG.js} +12 -3
  20. package/dist/{chunk-3ERIL673.js → chunk-H2X54EHC.js} +1 -1
  21. package/dist/{chunk-TSK5PI4L.js → chunk-IHKGWTGT.js} +1 -1
  22. package/dist/{chunk-OXXTPILE.js → chunk-JZ62SYWR.js} +1 -1
  23. package/dist/{chunk-77BVGCTZ.js → chunk-NYC5U6K7.js} +9 -9
  24. package/dist/{chunk-JSRMEY52.js → chunk-OP4JG2BS.js} +5 -4
  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-IUXTYXFI.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-V4GYPD5L.js → chunk-UYLGEDVD.js} +4 -4
  34. package/dist/{chunk-U4FNXDEJ.js → chunk-YT7PEESJ.js} +231 -98
  35. package/dist/{chunk-5GT7URXJ.js → chunk-ZDZISKCL.js} +1 -1
  36. package/dist/cli.js +16 -16
  37. package/dist/{cloud-sync-Y7LQPRE3.js → cloud-sync-3OJOPZRT.js} +16 -16
  38. package/dist/{conversations-G4LYMHAA.js → conversations-JZTD6KLR.js} +3 -3
  39. package/dist/{create-2YY7IPMS.js → create-IOV4GCRX.js} +1 -1
  40. package/dist/{daemon-restart-ABP54YN6.js → daemon-restart-TIHKVVVA.js} +6 -6
  41. package/dist/daemon.js +180 -137
  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-6DE3BPHO.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-AYSPKE33.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-KLHC7XYR.js → mind-DWB6XYCH.js} +6 -6
  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-BMHV7UHN.js → package-A7KXT67Q.js} +1 -1
  61. package/dist/{registry-NUJAGLQJ.js → registry-OUZBZ3Q4.js} +5 -1
  62. package/dist/{sandbox-72MWV4JL.js → sandbox-ZJ7QA6Z6.js} +3 -3
  63. package/dist/scheduler-P6DXLLXI.js +30 -0
  64. package/dist/{seed-4MKQ5QKG.js → seed-HCP5NYKH.js} +1 -1
  65. package/dist/{seed-cmd-LCXMR7VV.js → seed-cmd-ILOGNIFB.js} +2 -2
  66. package/dist/{seed-create-N2BF5LQX.js → seed-create-OURS2LS4.js} +3 -3
  67. package/dist/{seed-sprout-6ZDP23YI.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-3O6O2JZ7.js → setup-DQM5VPY4.js} +6 -6
  72. package/dist/{setup-73YBJZQL.js → setup-Y2EJI3GG.js} +2 -2
  73. package/dist/skills/imagegen/scripts/imagegen.ts +100 -40
  74. package/dist/skills/volute-mind/SKILL.md +5 -1
  75. package/dist/{skills-ND4ZFYUW.js → skills-BKINFL7W.js} +4 -4
  76. package/dist/sleep-manager-HCUH4PM4.js +34 -0
  77. package/dist/{spirit-II3HOERM.js → spirit-DBTIW7P4.js} +7 -7
  78. package/dist/{sprout-4KUG76AQ.js → sprout-W3KC7UGK.js} +1 -1
  79. package/dist/{src-XFY3XDD6.js → src-OJHARHTF.js} +28 -17
  80. package/dist/{status-E5DQNCAO.js → status-SWRVGXBI.js} +4 -4
  81. package/dist/system-chat-XOALE2RD.js +36 -0
  82. package/dist/{tailscale-QAWVKQKU.js → tailscale-3XOKOW7T.js} +1 -1
  83. package/dist/{turn-tracker-QQIVVXBI.js → turn-tracker-2W6Q2NSR.js} +4 -4
  84. package/dist/{up-LG56MPBI.js → up-FTK7CTO3.js} +5 -5
  85. package/dist/{update-7TYVH5FM.js → update-SWAYZRWJ.js} +4 -4
  86. package/dist/{update-check-VSCSJ7KX.js → update-check-PQ2MZ47K.js} +2 -2
  87. package/dist/{version-notify-CODJOUW2.js → version-notify-3XMAIQTE.js} +17 -17
  88. package/dist/{volute-config-TS62GS6A.js → volute-config-A52FHWKM.js} +5 -1
  89. package/dist/web-assets/assets/{index-Dac11sKB.js → index-Bc2m8gRC.js} +15 -15
  90. package/dist/web-assets/index.html +1 -1
  91. package/package.json +1 -1
  92. package/templates/_base/src/lib/file-handler.ts +3 -3
  93. package/templates/_base/src/lib/router.ts +21 -25
  94. package/templates/_base/src/lib/startup.ts +2 -0
  95. package/templates/_base/src/lib/types.ts +2 -2
  96. package/templates/claude/src/agent.ts +75 -10
  97. package/templates/claude/src/lib/message-channel.ts +23 -0
  98. package/templates/claude/src/lib/session-reaper.ts +37 -0
  99. package/templates/claude/src/server.ts +1 -0
  100. package/templates/codex/src/agent.ts +18 -6
  101. package/templates/pi/src/agent.ts +18 -7
  102. package/dist/delivery-manager-R5STGLXB.js +0 -32
  103. package/dist/echo-text-NQXYYYCU.js +0 -32
  104. package/dist/message-delivery-IUAXRPFE.js +0 -34
  105. package/dist/mind-service-BF2LUQMH.js +0 -36
  106. package/dist/scheduler-YHJRRV6E.js +0 -30
  107. package/dist/sleep-manager-YAHDOIU5.js +0 -34
  108. package/dist/system-chat-E65T2HSW.js +0 -34
@@ -8,7 +8,7 @@
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-Dac11sKB.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-Bc2m8gRC.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/assets/index-B1JBQv2m.css">
13
13
  </head>
14
14
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volute",
3
- "version": "0.45.1",
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",
@@ -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;
@@ -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. */
@@ -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>;
@@ -320,6 +325,7 @@ export function createMind(options: {
320
325
  // This turn's message is fully processed — drop it from the channel's
321
326
  // in-flight set so a later compaction abort won't re-feed it.
322
327
  session.channel.ack();
328
+ session.lastActivityAt = Date.now();
323
329
  await autoCommit.flushFileChanges();
324
330
  const wasCompacting = compactionTriggered.get(session.name);
325
331
  compactionTriggered.set(session.name, false);
@@ -478,16 +484,20 @@ export function createMind(options: {
478
484
  log("mind", `session "${session.name}": stream consumer error:`, retryErr);
479
485
  await emitError(retryErr);
480
486
  emitDone();
481
- sessions.delete(session.name);
482
487
  }
483
488
  } else {
484
489
  log("mind", `session "${session.name}": stream consumer error:`, err);
485
490
  await emitError(err);
486
491
  emitDone();
487
- sessions.delete(session.name);
488
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`);
489
500
  }
490
- log("mind", `session "${session.name}": stream consumer ended`);
491
501
  })();
492
502
  }
493
503
 
@@ -504,6 +514,7 @@ export function createMind(options: {
504
514
  replyInstructionsFired: false,
505
515
  replyInstructionsMode: "once",
506
516
  contextTokens: 0,
517
+ lastActivityAt: Date.now(),
507
518
  };
508
519
  sessions.set(name, session);
509
520
 
@@ -526,18 +537,69 @@ export function createMind(options: {
526
537
  return session;
527
538
  }
528
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
+
529
579
  // --- MessageHandler implementation ---
530
580
 
531
581
  function createSessionHandler(sessionName: string): MessageHandler {
532
582
  return {
533
- handle(content: VoluteContentPart[], meta: HandlerMeta, listener: Listener): () => void {
583
+ handle(content: VoluteContentPart[], meta: HandlerMeta, listener?: Listener): () => void {
534
584
  const session = getOrCreateSession(sessionName);
535
585
 
536
- // Filter listener to only receive events for this messageId
537
- const filteredListener: Listener = (event) => {
538
- if (event.messageId === meta.messageId) listener(event);
539
- };
540
- 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
+ }
541
603
 
542
604
  // Track channel/sender for reply instructions
543
605
  if (meta.channel) {
@@ -559,6 +621,7 @@ export function createMind(options: {
559
621
  }
560
622
 
561
623
  // Push message into SDK
624
+ session.lastActivityAt = Date.now();
562
625
  session.messageIds.push(meta.messageId);
563
626
  session.channel.push({
564
627
  type: "user",
@@ -567,7 +630,9 @@ export function createMind(options: {
567
630
  parent_tool_use_id: null,
568
631
  });
569
632
 
570
- return () => session.listeners.delete(filteredListener);
633
+ return () => {
634
+ if (filteredListener) session.listeners.delete(filteredListener);
635
+ };
571
636
  },
572
637
  };
573
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
+ }
@@ -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");
@@ -558,13 +558,23 @@ export function createMind(options: {
558
558
 
559
559
  function createSessionHandler(sessionName: string): MessageHandler {
560
560
  return {
561
- handle(content: VoluteContentPart[], meta: HandlerMeta, listener: Listener): () => void {
561
+ handle(content: VoluteContentPart[], meta: HandlerMeta, listener?: Listener): () => void {
562
562
  const session = getOrCreateSession(sessionName);
563
563
 
564
- const filteredListener: Listener = (event) => {
565
- if (event.messageId === meta.messageId) listener(event);
566
- };
567
- session.listeners.add(filteredListener);
564
+ // Only register a listener when a caller wants events. A per-message listener
565
+ // that's never removed grows session.listeners without bound and makes broadcast
566
+ // O(messages-ever-received). The live dispatch path passes no listener.
567
+ let filteredListener: Listener | undefined;
568
+ if (listener) {
569
+ filteredListener = (event) => {
570
+ if (event.messageId !== meta.messageId) return;
571
+ listener(event);
572
+ if (event.type === "done" && filteredListener) {
573
+ session.listeners.delete(filteredListener);
574
+ }
575
+ };
576
+ session.listeners.add(filteredListener);
577
+ }
568
578
 
569
579
  // Track channel for reply instructions
570
580
  if (meta.channel) {
@@ -586,7 +596,9 @@ export function createMind(options: {
586
596
  broadcast(session, { type: "done" });
587
597
  });
588
598
 
589
- return () => session.listeners.delete(filteredListener);
599
+ return () => {
600
+ if (filteredListener) session.listeners.delete(filteredListener);
601
+ };
590
602
  },
591
603
  };
592
604
  }
@@ -447,14 +447,23 @@ export function createMind(options: {
447
447
 
448
448
  function createSessionHandler(sessionName: string): MessageHandler {
449
449
  return {
450
- handle(content: VoluteContentPart[], meta: HandlerMeta, listener: Listener): () => void {
450
+ handle(content: VoluteContentPart[], meta: HandlerMeta, listener?: Listener): () => void {
451
451
  const session = getOrCreateSession(sessionName);
452
452
 
453
- // Filter listener to only receive events for this messageId
454
- const filteredListener: Listener = (event) => {
455
- if (event.messageId === meta.messageId) listener(event);
456
- };
457
- session.listeners.add(filteredListener);
453
+ // Only register a listener when a caller wants events. A per-message listener
454
+ // that's never removed grows session.listeners without bound and makes broadcast
455
+ // O(messages-ever-received). The live dispatch path passes no listener.
456
+ let filteredListener: Listener | undefined;
457
+ if (listener) {
458
+ filteredListener = (event) => {
459
+ if (event.messageId !== meta.messageId) return;
460
+ listener(event);
461
+ if (event.type === "done" && filteredListener) {
462
+ session.listeners.delete(filteredListener);
463
+ }
464
+ };
465
+ session.listeners.add(filteredListener);
466
+ }
458
467
 
459
468
  // Track channel for reply instructions
460
469
  if (meta.channel) {
@@ -500,7 +509,9 @@ export function createMind(options: {
500
509
  broadcast(session, { type: "done" });
501
510
  });
502
511
 
503
- return () => session.listeners.delete(filteredListener);
512
+ return () => {
513
+ if (filteredListener) session.listeners.delete(filteredListener);
514
+ };
504
515
  },
505
516
  };
506
517
  }
@@ -1,32 +0,0 @@
1
- #!/usr/bin/env node
2
- import {
3
- DeliveryManager,
4
- getDeliveryManager,
5
- initDeliveryManager,
6
- tryGetDeliveryManager
7
- } from "./chunk-U4FNXDEJ.js";
8
- import "./chunk-5GT7URXJ.js";
9
- import "./chunk-5AUKCNPE.js";
10
- import "./chunk-QLWG3C2Z.js";
11
- import "./chunk-Y6OPHHGM.js";
12
- import "./chunk-JSRMEY52.js";
13
- import "./chunk-CU6OFXMM.js";
14
- import "./chunk-OXXTPILE.js";
15
- import "./chunk-HVU45COX.js";
16
- import "./chunk-ZIR7HSAX.js";
17
- import "./chunk-V4GYPD5L.js";
18
- import "./chunk-3ERIL673.js";
19
- import "./chunk-TSK5PI4L.js";
20
- import "./chunk-322KE457.js";
21
- import "./chunk-CYI534DJ.js";
22
- import "./chunk-ZMIMSIIT.js";
23
- import "./chunk-T2TP6ZC6.js";
24
- import "./chunk-H7IYFRAL.js";
25
- import "./chunk-ZV7MFISX.js";
26
- import "./chunk-K3NQKI34.js";
27
- export {
28
- DeliveryManager,
29
- getDeliveryManager,
30
- initDeliveryManager,
31
- tryGetDeliveryManager
32
- };
@@ -1,32 +0,0 @@
1
- #!/usr/bin/env node
2
- import {
3
- clearEchoTextCache,
4
- echoTextToChannel
5
- } from "./chunk-77BVGCTZ.js";
6
- import "./chunk-FHWUQZXE.js";
7
- import "./chunk-U4FNXDEJ.js";
8
- import "./chunk-5GT7URXJ.js";
9
- import "./chunk-5AUKCNPE.js";
10
- import "./chunk-QLWG3C2Z.js";
11
- import "./chunk-Y6OPHHGM.js";
12
- import "./chunk-JSRMEY52.js";
13
- import "./chunk-CU6OFXMM.js";
14
- import "./chunk-OXXTPILE.js";
15
- import "./chunk-HVU45COX.js";
16
- import "./chunk-ZIR7HSAX.js";
17
- import "./chunk-V4GYPD5L.js";
18
- import "./chunk-Q2XBKQ2D.js";
19
- import "./chunk-3ERIL673.js";
20
- import "./chunk-TSK5PI4L.js";
21
- import "./chunk-322KE457.js";
22
- import "./chunk-CYI534DJ.js";
23
- import "./chunk-ZMIMSIIT.js";
24
- import "./chunk-T2TP6ZC6.js";
25
- import "./chunk-LGNUFVMR.js";
26
- import "./chunk-H7IYFRAL.js";
27
- import "./chunk-ZV7MFISX.js";
28
- import "./chunk-K3NQKI34.js";
29
- export {
30
- clearEchoTextCache,
31
- echoTextToChannel
32
- };
@@ -1,34 +0,0 @@
1
- #!/usr/bin/env node
2
- import {
3
- deliverMessage,
4
- linkToolResultToTurn,
5
- recordInbound,
6
- recordOutbound,
7
- resolveSleepAction
8
- } from "./chunk-U4FNXDEJ.js";
9
- import "./chunk-5GT7URXJ.js";
10
- import "./chunk-5AUKCNPE.js";
11
- import "./chunk-QLWG3C2Z.js";
12
- import "./chunk-Y6OPHHGM.js";
13
- import "./chunk-JSRMEY52.js";
14
- import "./chunk-CU6OFXMM.js";
15
- import "./chunk-OXXTPILE.js";
16
- import "./chunk-HVU45COX.js";
17
- import "./chunk-ZIR7HSAX.js";
18
- import "./chunk-V4GYPD5L.js";
19
- import "./chunk-3ERIL673.js";
20
- import "./chunk-TSK5PI4L.js";
21
- import "./chunk-322KE457.js";
22
- import "./chunk-CYI534DJ.js";
23
- import "./chunk-ZMIMSIIT.js";
24
- import "./chunk-T2TP6ZC6.js";
25
- import "./chunk-H7IYFRAL.js";
26
- import "./chunk-ZV7MFISX.js";
27
- import "./chunk-K3NQKI34.js";
28
- export {
29
- deliverMessage,
30
- linkToolResultToTurn,
31
- recordInbound,
32
- recordOutbound,
33
- resolveSleepAction
34
- };