volute 0.45.1 → 0.46.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 (120) hide show
  1. package/README.md +3 -3
  2. package/dist/{activity-events-OMCSM7XW.js → activity-events-NF6VWTJQ.js} +2 -2
  3. package/dist/{ai-service-534WUSDL.js → ai-service-UODCCE4O.js} +3 -3
  4. package/dist/{archive-6R4TZMYI.js → archive-WS2L2VIQ.js} +2 -2
  5. package/dist/{auth-FRZJVQCS.js → auth-CCFAU4GQ.js} +3 -3
  6. package/dist/{avatar-image-BNWEXSA2.js → avatar-image-6KY6UU4D.js} +3 -3
  7. package/dist/{backup-OZXHL3JS.js → backup-TST5C2YN.js} +7 -7
  8. package/dist/{chat-I4EDDIQC.js → chat-ZIQQS25U.js} +2 -2
  9. package/dist/{chunk-ZIR7HSAX.js → chunk-4JOQZPCE.js} +1 -1
  10. package/dist/chunk-4YU7HF4L.js +237 -0
  11. package/dist/{chunk-JSRMEY52.js → chunk-5ZQZDDIK.js} +10 -5
  12. package/dist/{chunk-2BONPXAF.js → chunk-6EMWJZBK.js} +1 -1
  13. package/dist/{chunk-LAXUUY7O.js → chunk-A7WQZKOU.js} +2 -2
  14. package/dist/{chunk-H7IYFRAL.js → chunk-ALKYQULU.js} +10 -5
  15. package/dist/{chunk-5AUKCNPE.js → chunk-BJZ4PV7R.js} +8 -8
  16. package/dist/{chunk-KP3KWNG6.js → chunk-CDPBTTZO.js} +2 -2
  17. package/dist/{chunk-SQL7M4HJ.js → chunk-DRJ24GXK.js} +2 -2
  18. package/dist/{chunk-77BVGCTZ.js → chunk-E3DA4HYV.js} +9 -9
  19. package/dist/{chunk-322KE457.js → chunk-F7H6ATM4.js} +1 -1
  20. package/dist/{chunk-KVKP6VQ5.js → chunk-FXORA5JG.js} +12 -3
  21. package/dist/{chunk-3ERIL673.js → chunk-H2X54EHC.js} +1 -1
  22. package/dist/{chunk-TSK5PI4L.js → chunk-IHKGWTGT.js} +1 -1
  23. package/dist/{chunk-U4FNXDEJ.js → chunk-JO5IO4FH.js} +422 -168
  24. package/dist/{chunk-OXXTPILE.js → chunk-JZ62SYWR.js} +1 -1
  25. package/dist/{chunk-IUXTYXFI.js → chunk-K6T43YBD.js} +3 -3
  26. package/dist/{chunk-Y6OPHHGM.js → chunk-KGIXX4NM.js} +4 -4
  27. package/dist/{chunk-HVU45COX.js → chunk-KQSFTPIM.js} +1 -1
  28. package/dist/{chunk-CYI534DJ.js → chunk-OV7ZQ2GH.js} +2 -2
  29. package/dist/{chunk-CU6OFXMM.js → chunk-PFZBNKUH.js} +9 -0
  30. package/dist/{clock-K43IMROX.js → chunk-PJ45FQPT.js} +21 -16
  31. package/dist/{chunk-QMJJWTNL.js → chunk-QCN24XEN.js} +1 -1
  32. package/dist/{chunk-UMI4UIJG.js → chunk-R77O2WMK.js} +1 -1
  33. package/dist/{chunk-IEWZ7VD7.js → chunk-RRNXJOKB.js} +2 -2
  34. package/dist/{chunk-FHWUQZXE.js → chunk-RXEEYDIB.js} +1 -1
  35. package/dist/{chunk-Q2XBKQ2D.js → chunk-TQJ6ZYVY.js} +2 -2
  36. package/dist/{chunk-V4GYPD5L.js → chunk-YXSUMLTC.js} +46 -269
  37. package/dist/{chunk-5GT7URXJ.js → chunk-ZDZISKCL.js} +1 -1
  38. package/dist/{chunk-ZMIMSIIT.js → chunk-ZNMCCHCP.js} +48 -2
  39. package/dist/cli.js +20 -20
  40. package/dist/clock-RHS2JEDL.js +16 -0
  41. package/dist/{cloud-sync-Y7LQPRE3.js → cloud-sync-46RS4SDA.js} +17 -16
  42. package/dist/{conversations-G4LYMHAA.js → conversations-CJOOCGNW.js} +3 -3
  43. package/dist/{create-2YY7IPMS.js → create-IOV4GCRX.js} +1 -1
  44. package/dist/{daemon-restart-ABP54YN6.js → daemon-restart-VFEI57UO.js} +6 -6
  45. package/dist/daemon.js +392 -270
  46. package/dist/{db-JFLCJCUG.js → db-MF2JNRUW.js} +1 -1
  47. package/dist/{delete-BFMIGAIB.js → delete-HPLC7ETU.js} +1 -1
  48. package/dist/delivery-manager-JINZRG2W.js +33 -0
  49. package/dist/{delivery-router-6DE3BPHO.js → delivery-router-N4A7ZC5T.js} +2 -2
  50. package/dist/{down-XCAZXWLB.js → down-U2CZ23M4.js} +4 -4
  51. package/dist/echo-text-VQPB5L2X.js +33 -0
  52. package/dist/{exec-4GWHAHS3.js → exec-G3ZRST4Z.js} +2 -2
  53. package/dist/{export-ID7YUSLZ.js → export-LY723I3Z.js} +5 -5
  54. package/dist/{extensions-AYSPKE33.js → extensions-ZELQLTSR.js} +17 -16
  55. package/dist/{files-BQ2WB5KC.js → files-4LN3EN7Q.js} +2 -2
  56. package/dist/{import-SKID37QX.js → import-42YNQBF4.js} +5 -5
  57. package/dist/{isolation-5MB3J73F.js → isolation-YMGZZ4E5.js} +2 -2
  58. package/dist/{join-RXPXGV4L.js → join-ZJV7W6MO.js} +4 -0
  59. package/dist/{login-LM4NF4ZB.js → login-M6OJUPQ2.js} +1 -1
  60. package/dist/{logout-CTJVIZGA.js → logout-WQBWO77G.js} +1 -1
  61. package/dist/message-delivery-R7JSQGYX.js +37 -0
  62. package/dist/{mind-KLHC7XYR.js → mind-LHKS55DI.js} +12 -12
  63. package/dist/{mind-activity-tracker-BGHJE7XA.js → mind-activity-tracker-3QRNJVR4.js} +3 -3
  64. package/dist/mind-service-NB2JMWQZ.js +39 -0
  65. package/dist/mind-sleep-74TGOQBC.js +83 -0
  66. package/dist/{package-BMHV7UHN.js → package-DOD6CPO6.js} +2 -1
  67. package/dist/{registry-NUJAGLQJ.js → registry-OUZBZ3Q4.js} +5 -1
  68. package/dist/{sandbox-72MWV4JL.js → sandbox-ZJ7QA6Z6.js} +3 -3
  69. package/dist/scheduler-WVKII3LA.js +31 -0
  70. package/dist/{seed-4MKQ5QKG.js → seed-CNYIHPOH.js} +1 -1
  71. package/dist/{seed-cmd-LCXMR7VV.js → seed-cmd-AVNUNLZV.js} +2 -2
  72. package/dist/{seed-create-N2BF5LQX.js → seed-create-ANXGYSCC.js} +5 -5
  73. package/dist/{seed-sprout-6ZDP23YI.js → seed-sprout-NIUFTX32.js} +10 -8
  74. package/dist/{send-O6W53VBM.js → send-SPE6DVNJ.js} +8 -2
  75. package/dist/{service-AQVPUANO.js → service-UOCLXDWR.js} +3 -3
  76. package/dist/{service-install-4QNUDKLM.js → service-install-4HHFDDWY.js} +4 -4
  77. package/dist/{setup-3O6O2JZ7.js → setup-QT2A25SY.js} +6 -6
  78. package/dist/{setup-73YBJZQL.js → setup-Y2EJI3GG.js} +2 -2
  79. package/dist/skills/imagegen/scripts/imagegen.ts +100 -40
  80. package/dist/skills/orientation/SKILL.md +1 -1
  81. package/dist/skills/volute-mind/SKILL.md +13 -9
  82. package/dist/skills/volute-mind/references/sleep.md +8 -1
  83. package/dist/{skills-ND4ZFYUW.js → skills-AKCFA7BN.js} +4 -4
  84. package/dist/sleep-manager-G6TXSDP7.js +35 -0
  85. package/dist/{spirit-II3HOERM.js → spirit-RPBLLUQ5.js} +7 -7
  86. package/dist/{sprout-4KUG76AQ.js → sprout-SMX42ZBP.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-NDAW6YP7.js +37 -0
  90. package/dist/{tailscale-QAWVKQKU.js → tailscale-3XOKOW7T.js} +1 -1
  91. package/dist/{turn-tracker-QQIVVXBI.js → turn-tracker-FOIBNUUA.js} +5 -4
  92. package/dist/{up-LG56MPBI.js → up-PS6PFS75.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/{version-notify-CODJOUW2.js → version-notify-C5CH7CVP.js} +24 -19
  96. package/dist/{volute-config-TS62GS6A.js → volute-config-A52FHWKM.js} +5 -1
  97. package/dist/web-assets/assets/{index-Dac11sKB.js → index-CU6XWICM.js} +15 -15
  98. package/dist/web-assets/index.html +1 -1
  99. package/package.json +2 -1
  100. package/templates/_base/home/VOLUTE.md +6 -2
  101. package/templates/_base/src/lib/context-breakdown.ts +57 -67
  102. package/templates/_base/src/lib/file-handler.ts +3 -3
  103. package/templates/_base/src/lib/router.ts +21 -25
  104. package/templates/_base/src/lib/startup.ts +2 -0
  105. package/templates/_base/src/lib/types.ts +2 -2
  106. package/templates/_base/src/lib/volute-server.ts +2 -2
  107. package/templates/claude/src/agent.ts +147 -46
  108. package/templates/claude/src/lib/message-channel.ts +23 -0
  109. package/templates/claude/src/lib/session-reaper.ts +68 -0
  110. package/templates/claude/src/server.ts +1 -0
  111. package/templates/codex/src/agent.ts +80 -46
  112. package/templates/pi/src/agent.ts +73 -45
  113. package/dist/delivery-manager-R5STGLXB.js +0 -32
  114. package/dist/echo-text-NQXYYYCU.js +0 -32
  115. package/dist/message-delivery-IUAXRPFE.js +0 -34
  116. package/dist/mind-service-BF2LUQMH.js +0 -36
  117. package/dist/mind-sleep-YMBR3I35.js +0 -47
  118. package/dist/scheduler-YHJRRV6E.js +0 -30
  119. package/dist/sleep-manager-YAHDOIU5.js +0 -34
  120. package/dist/system-chat-E65T2HSW.js +0 -34
@@ -8,6 +8,7 @@ import {
8
8
  countSkillDescriptionTokens,
9
9
  countSystemPromptTokens,
10
10
  findClaudeSessionFile,
11
+ getCachedContextInfo,
11
12
  processClaudeSession,
12
13
  readSdkInstructions,
13
14
  readSkillDescriptions,
@@ -21,6 +22,7 @@ import { createPreCompactHook } from "./lib/hooks/pre-compact.js";
21
22
  import { createReplyInstructionsHook } from "./lib/hooks/reply-instructions.js";
22
23
  import { log } from "./lib/logger.js";
23
24
  import { createMessageChannel } from "./lib/message-channel.js";
25
+ import { isSessionReapable, reapSessionQuery } from "./lib/session-reaper.js";
24
26
  import { createSessionStore } from "./lib/session-store.js";
25
27
  import { loadPrompts, type SubagentConfig } from "./lib/startup.js";
26
28
  import { consumeStream } from "./lib/stream-consumer.js";
@@ -32,7 +34,7 @@ import type {
32
34
  VoluteContentPart,
33
35
  VoluteEvent,
34
36
  } from "./lib/types.js";
35
- import type { ContextInfo, ContextMessages } from "./lib/volute-server.js";
37
+ import type { ContextInfo, ContextMessages, SessionContextInfo } from "./lib/volute-server.js";
36
38
 
37
39
  type Session = {
38
40
  name: string;
@@ -45,6 +47,8 @@ type Session = {
45
47
  replyInstructionsFired: boolean;
46
48
  replyInstructionsMode: "once" | "always" | "never";
47
49
  contextTokens: number;
50
+ /** Last inbound message or completed turn — drives idle reaping. */
51
+ lastActivityAt: number;
48
52
  };
49
53
 
50
54
  export function createMind(options: {
@@ -58,11 +62,13 @@ export function createMind(options: {
58
62
  maxContextTokens?: number;
59
63
  subagents?: Record<string, SubagentConfig>;
60
64
  onIdentityReload?: () => Promise<void>;
65
+ /** Idle minutes before a session's SDK subprocess is reaped. 0 disables. Default 30. */
66
+ sessionIdleMinutes?: number;
61
67
  }): {
62
68
  resolve: HandlerResolver;
63
69
  waitForCommits: () => Promise<void>;
64
- getContextInfo: () => ContextInfo;
65
- getContextMessages: () => ContextMessages;
70
+ getContextInfo: () => Promise<ContextInfo>;
71
+ getContextMessages: () => Promise<ContextMessages>;
66
72
  } {
67
73
  const autoCommit = createAutoCommitHook(options.cwd);
68
74
  const identityReload = createIdentityReloadHook(options.cwd);
@@ -320,6 +326,7 @@ export function createMind(options: {
320
326
  // This turn's message is fully processed — drop it from the channel's
321
327
  // in-flight set so a later compaction abort won't re-feed it.
322
328
  session.channel.ack();
329
+ session.lastActivityAt = Date.now();
323
330
  await autoCommit.flushFileChanges();
324
331
  const wasCompacting = compactionTriggered.get(session.name);
325
332
  compactionTriggered.set(session.name, false);
@@ -478,16 +485,20 @@ export function createMind(options: {
478
485
  log("mind", `session "${session.name}": stream consumer error:`, retryErr);
479
486
  await emitError(retryErr);
480
487
  emitDone();
481
- sessions.delete(session.name);
482
488
  }
483
489
  } else {
484
490
  log("mind", `session "${session.name}": stream consumer error:`, err);
485
491
  await emitError(err);
486
492
  emitDone();
487
- sessions.delete(session.name);
488
493
  }
494
+ } finally {
495
+ // The stream consumer has ended (subprocess exit, error, or fresh-start
496
+ // abandonment) — drop the session so the sessions map doesn't retain dead
497
+ // entries. Matters most for ephemeral $new sessions once the idle reaper
498
+ // (#458) kills their subprocess, but also for any named session that ends.
499
+ sessions.delete(session.name);
500
+ log("mind", `session "${session.name}": stream consumer ended`);
489
501
  }
490
- log("mind", `session "${session.name}": stream consumer ended`);
491
502
  })();
492
503
  }
493
504
 
@@ -504,6 +515,7 @@ export function createMind(options: {
504
515
  replyInstructionsFired: false,
505
516
  replyInstructionsMode: "once",
506
517
  contextTokens: 0,
518
+ lastActivityAt: Date.now(),
507
519
  };
508
520
  sessions.set(name, session);
509
521
 
@@ -526,18 +538,79 @@ export function createMind(options: {
526
538
  return session;
527
539
  }
528
540
 
541
+ // --- Idle session reaping ---
542
+ // Each session holds a resident SDK subprocess (~250MB) for its whole life.
543
+ // After the idle timeout, shut the subprocess down while keeping the session
544
+ // resumable: the session id is persisted, so the next inbound message
545
+ // transparently re-creates the session via getOrCreateSession's resume path.
546
+ const idleTimeoutMs = (options.sessionIdleMinutes ?? 30) * 60_000;
547
+
548
+ async function reapSession(session: Session) {
549
+ log("mind", `session "${session.name}": idle — reaping SDK subprocess (resumable)`);
550
+ // Delete first so a racing inbound message spins up a fresh resumed session
551
+ // instead of reusing the one we're tearing down.
552
+ sessions.delete(session.name);
553
+ compactionTriggered.delete(session.name);
554
+ // End the input iterable so the stream consumer unwinds (its finally block
555
+ // also deletes from the map, now a no-op), then await the SDK's graceful
556
+ // shutdown via query.return() — unlike the fire-and-forget close(), this
557
+ // awaits the CLI subprocess's exit so the child is reaped instead of left
558
+ // as a <defunct> zombie.
559
+ session.channel.close();
560
+ await reapSessionQuery(session.currentQuery, (err) =>
561
+ log("mind", `session "${session.name}": error reaping SDK subprocess:`, err),
562
+ );
563
+ // Nothing should have raced in (isSessionReapable checked isEmpty), but if it
564
+ // did, re-dispatch into a fresh session so no input is dropped.
565
+ const pending = session.channel.recover();
566
+ if (pending.length > 0) {
567
+ const fresh = getOrCreateSession(session.name);
568
+ for (const msg of pending) fresh.channel.push(msg);
569
+ }
570
+ }
571
+
572
+ if (idleTimeoutMs > 0) {
573
+ log("mind", `idle session reaper: ${idleTimeoutMs / 60_000} min timeout`);
574
+ const checkMs = Math.min(60_000, idleTimeoutMs);
575
+ const reaper = setInterval(() => {
576
+ const now = Date.now();
577
+ const stale = [...sessions.values()].filter((s) =>
578
+ isSessionReapable(s, now, idleTimeoutMs, (name) => !!compactionTriggered.get(name)),
579
+ );
580
+ // Reaps run independently; each awaits its own subprocess exit internally.
581
+ for (const session of stale) {
582
+ reapSession(session).catch((err) =>
583
+ log("mind", `session "${session.name}": reap failed:`, err),
584
+ );
585
+ }
586
+ }, checkMs);
587
+ reaper.unref?.();
588
+ }
589
+
529
590
  // --- MessageHandler implementation ---
530
591
 
531
592
  function createSessionHandler(sessionName: string): MessageHandler {
532
593
  return {
533
- handle(content: VoluteContentPart[], meta: HandlerMeta, listener: Listener): () => void {
594
+ handle(content: VoluteContentPart[], meta: HandlerMeta, listener?: Listener): () => void {
534
595
  const session = getOrCreateSession(sessionName);
535
596
 
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);
597
+ // Only register a listener when a caller actually wants events. A per-message
598
+ // listener that's never removed would grow session.listeners without bound and
599
+ // make broadcastToSession O(messages-ever-received). The live dispatch path
600
+ // passes no listener, so this is usually a no-op.
601
+ let filteredListener: Listener | undefined;
602
+ if (listener) {
603
+ // Filter to only this messageId, and self-remove on the matching done so a
604
+ // caller that forgets to unsubscribe can't reintroduce the leak.
605
+ filteredListener = (event) => {
606
+ if (event.messageId !== meta.messageId) return;
607
+ listener(event);
608
+ if (event.type === "done" && filteredListener) {
609
+ session.listeners.delete(filteredListener);
610
+ }
611
+ };
612
+ session.listeners.add(filteredListener);
613
+ }
541
614
 
542
615
  // Track channel/sender for reply instructions
543
616
  if (meta.channel) {
@@ -559,6 +632,7 @@ export function createMind(options: {
559
632
  }
560
633
 
561
634
  // Push message into SDK
635
+ session.lastActivityAt = Date.now();
562
636
  session.messageIds.push(meta.messageId);
563
637
  session.channel.push({
564
638
  type: "user",
@@ -567,7 +641,9 @@ export function createMind(options: {
567
641
  parent_tool_use_id: null,
568
642
  });
569
643
 
570
- return () => session.listeners.delete(filteredListener);
644
+ return () => {
645
+ if (filteredListener) session.listeners.delete(filteredListener);
646
+ };
571
647
  },
572
648
  };
573
649
  }
@@ -593,51 +669,76 @@ export function createMind(options: {
593
669
  const claudeMdTokens = countSdkInstructionTokens(options.cwd);
594
670
  const skillDescTokens = countSkillDescriptionTokens([resolvePath(options.cwd, ".claude/skills")]);
595
671
 
596
- function processSession(sessionName: string) {
672
+ function jsonlPathFor(sessionName: string): string | null {
597
673
  const sessionId = sessionStore.load(sessionName);
598
- const jsonlPath = sessionId ? findClaudeSessionFile(options.cwd, sessionId) : null;
599
- return jsonlPath
600
- ? processClaudeSession(jsonlPath, systemPromptTokens, claudeMdTokens, skillDescTokens)
601
- : null;
674
+ return sessionId ? findClaudeSessionFile(options.cwd, sessionId) : null;
602
675
  }
603
676
 
604
- function getContextInfo(): ContextInfo {
605
- return {
606
- sessions: Array.from(sessions.values()).map((s) => {
607
- try {
608
- const result = processSession(s.name);
609
- return {
610
- name: s.name,
611
- contextTokens: result?.parsed?.contextTokens ?? s.contextTokens,
612
- contextWindow: maxContextTokens,
613
- breakdown: result?.parsed?.breakdown,
614
- };
615
- } catch (err) {
616
- log("mind", `failed to get context breakdown for session "${s.name}":`, err);
617
- return { name: s.name, contextTokens: s.contextTokens, contextWindow: maxContextTokens };
618
- }
619
- }),
620
- systemPrompt: systemPromptTokens,
621
- };
677
+ async function getContextInfo(): Promise<ContextInfo> {
678
+ const infos: SessionContextInfo[] = [];
679
+ for (const s of sessions.values()) {
680
+ try {
681
+ const jsonlPath = jsonlPathFor(s.name);
682
+ // Cache the computed breakdown by file identity: polls between turns are free.
683
+ const parsed = jsonlPath
684
+ ? await getCachedContextInfo(
685
+ jsonlPath,
686
+ async () =>
687
+ (
688
+ await processClaudeSession(
689
+ jsonlPath,
690
+ systemPromptTokens,
691
+ claudeMdTokens,
692
+ skillDescTokens,
693
+ )
694
+ ).parsed,
695
+ )
696
+ : null;
697
+ infos.push({
698
+ name: s.name,
699
+ contextTokens: parsed?.contextTokens ?? s.contextTokens,
700
+ contextWindow: maxContextTokens,
701
+ breakdown: parsed?.breakdown,
702
+ });
703
+ } catch (err) {
704
+ log("mind", `failed to get context breakdown for session "${s.name}":`, err);
705
+ infos.push({
706
+ name: s.name,
707
+ contextTokens: s.contextTokens,
708
+ contextWindow: maxContextTokens,
709
+ });
710
+ }
711
+ }
712
+ return { sessions: infos, systemPrompt: systemPromptTokens };
622
713
  }
623
714
 
624
- function getContextMessages(): ContextMessages {
715
+ async function getContextMessages(): Promise<ContextMessages> {
625
716
  const skillsDir = resolvePath(options.cwd, ".claude/skills");
717
+ const sessionMessages: ContextMessages["sessions"] = [];
718
+ for (const s of sessions.values()) {
719
+ try {
720
+ const jsonlPath = jsonlPathFor(s.name);
721
+ const result = jsonlPath
722
+ ? await processClaudeSession(
723
+ jsonlPath,
724
+ systemPromptTokens,
725
+ claudeMdTokens,
726
+ skillDescTokens,
727
+ )
728
+ : null;
729
+ sessionMessages.push({ name: s.name, messages: result?.messages ?? [] });
730
+ } catch (err) {
731
+ log("mind", `failed to extract messages for session "${s.name}":`, err);
732
+ sessionMessages.push({ name: s.name, messages: [] });
733
+ }
734
+ }
626
735
  return {
627
736
  preamble: {
628
737
  systemPrompt: options.systemPrompt,
629
738
  sdkInstructions: readSdkInstructions(options.cwd),
630
739
  skillDescriptions: readSkillDescriptions([skillsDir]),
631
740
  },
632
- sessions: Array.from(sessions.values()).map((s) => {
633
- try {
634
- const result = processSession(s.name);
635
- return { name: s.name, messages: result?.messages ?? [] };
636
- } catch (err) {
637
- log("mind", `failed to extract messages for session "${s.name}":`, err);
638
- return { name: s.name, messages: [] };
639
- }
640
- }),
741
+ sessions: sessionMessages,
641
742
  };
642
743
  }
643
744
 
@@ -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,68 @@
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
+ }
38
+
39
+ /** Minimal view of the SDK query needed to shut its subprocess down. */
40
+ export interface ReapableQuery {
41
+ /**
42
+ * AsyncGenerator.return — the SDK overrides it to await its internal cleanup,
43
+ * which waits (bounded) for the CLI subprocess to exit.
44
+ */
45
+ return(value?: unknown): Promise<unknown>;
46
+ }
47
+
48
+ /**
49
+ * Tear down a reaped session's SDK subprocess and reap its exit status.
50
+ *
51
+ * `query.close()` is fire-and-forget: it kicks off cleanup but does not await the
52
+ * CLI subprocess's exit, so the child is left `<defunct>` (a zombie) until the
53
+ * mind process itself exits — PID-table growth on long-running minds with short
54
+ * idle timeouts. `query.return()` awaits the SDK's cleanup, which waits for the
55
+ * subprocess to exit, so the child's exit status is reaped. Errors are reported
56
+ * via `onError` (never thrown) so a teardown failure can't wedge the reaper.
57
+ */
58
+ export async function reapSessionQuery(
59
+ query: ReapableQuery | undefined,
60
+ onError: (err: unknown) => void,
61
+ ): Promise<void> {
62
+ if (!query) return;
63
+ try {
64
+ await query.return();
65
+ } catch (err) {
66
+ onError(err);
67
+ }
68
+ }
@@ -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");
@@ -8,6 +8,7 @@ import {
8
8
  countSkillDescriptionTokens,
9
9
  countSystemPromptTokens,
10
10
  findCodexSessionFile,
11
+ getCachedContextInfo,
11
12
  processCodexSession,
12
13
  readSdkInstructions,
13
14
  readSkillDescriptions,
@@ -26,7 +27,7 @@ import type {
26
27
  VoluteContentPart,
27
28
  VoluteEvent,
28
29
  } from "./lib/types.js";
29
- import type { ContextInfo, ContextMessages } from "./lib/volute-server.js";
30
+ import type { ContextInfo, ContextMessages, SessionContextInfo } from "./lib/volute-server.js";
30
31
 
31
32
  /** Minimal interface for a Codex SDK thread — typed to the methods we actually use */
32
33
  type CodexThread = {
@@ -77,8 +78,8 @@ export function createMind(options: {
77
78
  maxContextTokens?: number;
78
79
  }): {
79
80
  resolve: HandlerResolver;
80
- getContextInfo: () => ContextInfo;
81
- getContextMessages: () => ContextMessages;
81
+ getContextInfo: () => Promise<ContextInfo>;
82
+ getContextMessages: () => Promise<ContextMessages>;
82
83
  } {
83
84
  const sessions = new Map<string, CodexSession>();
84
85
  const prompts = loadPrompts();
@@ -558,13 +559,23 @@ export function createMind(options: {
558
559
 
559
560
  function createSessionHandler(sessionName: string): MessageHandler {
560
561
  return {
561
- handle(content: VoluteContentPart[], meta: HandlerMeta, listener: Listener): () => void {
562
+ handle(content: VoluteContentPart[], meta: HandlerMeta, listener?: Listener): () => void {
562
563
  const session = getOrCreateSession(sessionName);
563
564
 
564
- const filteredListener: Listener = (event) => {
565
- if (event.messageId === meta.messageId) listener(event);
566
- };
567
- session.listeners.add(filteredListener);
565
+ // Only register a listener when a caller wants events. A per-message listener
566
+ // that's never removed grows session.listeners without bound and makes broadcast
567
+ // O(messages-ever-received). The live dispatch path passes no listener.
568
+ let filteredListener: Listener | undefined;
569
+ if (listener) {
570
+ filteredListener = (event) => {
571
+ if (event.messageId !== meta.messageId) return;
572
+ listener(event);
573
+ if (event.type === "done" && filteredListener) {
574
+ session.listeners.delete(filteredListener);
575
+ }
576
+ };
577
+ session.listeners.add(filteredListener);
578
+ }
568
579
 
569
580
  // Track channel for reply instructions
570
581
  if (meta.channel) {
@@ -586,7 +597,9 @@ export function createMind(options: {
586
597
  broadcast(session, { type: "done" });
587
598
  });
588
599
 
589
- return () => session.listeners.delete(filteredListener);
600
+ return () => {
601
+ if (filteredListener) session.listeners.delete(filteredListener);
602
+ };
590
603
  },
591
604
  };
592
605
  }
@@ -611,55 +624,76 @@ export function createMind(options: {
611
624
  const claudeMdTokens = countSdkInstructionTokens(options.cwd);
612
625
  const skillDescTokens = countSkillDescriptionTokens([resolvePath(options.cwd, ".agents/skills")]);
613
626
 
614
- function processSession(sessionName: string) {
627
+ function jsonlPathFor(sessionName: string): string | null {
615
628
  const threadId = sessionStore.load(sessionName);
616
- const jsonlPath = threadId ? findCodexSessionFile(threadId, options.mindDir) : null;
617
- return jsonlPath
618
- ? processCodexSession(jsonlPath, systemPromptTokens, claudeMdTokens, skillDescTokens)
619
- : null;
629
+ return threadId ? findCodexSessionFile(threadId, options.mindDir) : null;
620
630
  }
621
631
 
622
- function getContextInfo(): ContextInfo {
623
- return {
624
- sessions: Array.from(sessions.values()).map((s) => {
625
- try {
626
- const result = processSession(s.name);
627
- return {
628
- name: s.name,
629
- contextTokens: result?.parsed?.contextTokens ?? s.cumulativeInputTokens,
630
- contextWindow: maxContextTokens,
631
- breakdown: result?.parsed?.breakdown,
632
- };
633
- } catch (err) {
634
- log("mind", `failed to get context breakdown for session "${s.name}":`, err);
635
- return {
636
- name: s.name,
637
- contextTokens: s.cumulativeInputTokens,
638
- contextWindow: maxContextTokens,
639
- };
640
- }
641
- }),
642
- systemPrompt: systemPromptTokens,
643
- };
632
+ async function getContextInfo(): Promise<ContextInfo> {
633
+ const infos: SessionContextInfo[] = [];
634
+ for (const s of sessions.values()) {
635
+ try {
636
+ const jsonlPath = jsonlPathFor(s.name);
637
+ // Cache the computed breakdown by file identity: polls between turns are free.
638
+ const parsed = jsonlPath
639
+ ? await getCachedContextInfo(
640
+ jsonlPath,
641
+ async () =>
642
+ (
643
+ await processCodexSession(
644
+ jsonlPath,
645
+ systemPromptTokens,
646
+ claudeMdTokens,
647
+ skillDescTokens,
648
+ )
649
+ ).parsed,
650
+ )
651
+ : null;
652
+ infos.push({
653
+ name: s.name,
654
+ contextTokens: parsed?.contextTokens ?? s.cumulativeInputTokens,
655
+ contextWindow: maxContextTokens,
656
+ breakdown: parsed?.breakdown,
657
+ });
658
+ } catch (err) {
659
+ log("mind", `failed to get context breakdown for session "${s.name}":`, err);
660
+ infos.push({
661
+ name: s.name,
662
+ contextTokens: s.cumulativeInputTokens,
663
+ contextWindow: maxContextTokens,
664
+ });
665
+ }
666
+ }
667
+ return { sessions: infos, systemPrompt: systemPromptTokens };
644
668
  }
645
669
 
646
- function getContextMessages(): ContextMessages {
670
+ async function getContextMessages(): Promise<ContextMessages> {
647
671
  const skillsDir = resolvePath(options.cwd, ".agents/skills");
672
+ const sessionMessages: ContextMessages["sessions"] = [];
673
+ for (const s of sessions.values()) {
674
+ try {
675
+ const jsonlPath = jsonlPathFor(s.name);
676
+ const result = jsonlPath
677
+ ? await processCodexSession(
678
+ jsonlPath,
679
+ systemPromptTokens,
680
+ claudeMdTokens,
681
+ skillDescTokens,
682
+ )
683
+ : null;
684
+ sessionMessages.push({ name: s.name, messages: result?.messages ?? [] });
685
+ } catch (err) {
686
+ log("mind", `failed to extract messages for session "${s.name}":`, err);
687
+ sessionMessages.push({ name: s.name, messages: [] });
688
+ }
689
+ }
648
690
  return {
649
691
  preamble: {
650
692
  systemPrompt: options.systemPrompt,
651
693
  sdkInstructions: readSdkInstructions(options.cwd),
652
694
  skillDescriptions: readSkillDescriptions([skillsDir]),
653
695
  },
654
- sessions: Array.from(sessions.values()).map((s) => {
655
- try {
656
- const result = processSession(s.name);
657
- return { name: s.name, messages: result?.messages ?? [] };
658
- } catch (err) {
659
- log("mind", `failed to extract messages for session "${s.name}":`, err);
660
- return { name: s.name, messages: [] };
661
- }
662
- }),
696
+ sessions: sessionMessages,
663
697
  };
664
698
  }
665
699