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.
- package/README.md +3 -3
- package/dist/{activity-events-OMCSM7XW.js → activity-events-NF6VWTJQ.js} +2 -2
- package/dist/{ai-service-534WUSDL.js → ai-service-UODCCE4O.js} +3 -3
- package/dist/{archive-6R4TZMYI.js → archive-WS2L2VIQ.js} +2 -2
- package/dist/{auth-FRZJVQCS.js → auth-CCFAU4GQ.js} +3 -3
- package/dist/{avatar-image-BNWEXSA2.js → avatar-image-6KY6UU4D.js} +3 -3
- package/dist/{backup-OZXHL3JS.js → backup-TST5C2YN.js} +7 -7
- package/dist/{chat-I4EDDIQC.js → chat-ZIQQS25U.js} +2 -2
- package/dist/{chunk-ZIR7HSAX.js → chunk-4JOQZPCE.js} +1 -1
- package/dist/chunk-4YU7HF4L.js +237 -0
- package/dist/{chunk-JSRMEY52.js → chunk-5ZQZDDIK.js} +10 -5
- package/dist/{chunk-2BONPXAF.js → chunk-6EMWJZBK.js} +1 -1
- package/dist/{chunk-LAXUUY7O.js → chunk-A7WQZKOU.js} +2 -2
- package/dist/{chunk-H7IYFRAL.js → chunk-ALKYQULU.js} +10 -5
- package/dist/{chunk-5AUKCNPE.js → chunk-BJZ4PV7R.js} +8 -8
- package/dist/{chunk-KP3KWNG6.js → chunk-CDPBTTZO.js} +2 -2
- package/dist/{chunk-SQL7M4HJ.js → chunk-DRJ24GXK.js} +2 -2
- package/dist/{chunk-77BVGCTZ.js → chunk-E3DA4HYV.js} +9 -9
- package/dist/{chunk-322KE457.js → chunk-F7H6ATM4.js} +1 -1
- package/dist/{chunk-KVKP6VQ5.js → chunk-FXORA5JG.js} +12 -3
- package/dist/{chunk-3ERIL673.js → chunk-H2X54EHC.js} +1 -1
- package/dist/{chunk-TSK5PI4L.js → chunk-IHKGWTGT.js} +1 -1
- package/dist/{chunk-U4FNXDEJ.js → chunk-JO5IO4FH.js} +422 -168
- package/dist/{chunk-OXXTPILE.js → chunk-JZ62SYWR.js} +1 -1
- package/dist/{chunk-IUXTYXFI.js → chunk-K6T43YBD.js} +3 -3
- package/dist/{chunk-Y6OPHHGM.js → chunk-KGIXX4NM.js} +4 -4
- package/dist/{chunk-HVU45COX.js → chunk-KQSFTPIM.js} +1 -1
- package/dist/{chunk-CYI534DJ.js → chunk-OV7ZQ2GH.js} +2 -2
- package/dist/{chunk-CU6OFXMM.js → chunk-PFZBNKUH.js} +9 -0
- package/dist/{clock-K43IMROX.js → chunk-PJ45FQPT.js} +21 -16
- package/dist/{chunk-QMJJWTNL.js → chunk-QCN24XEN.js} +1 -1
- package/dist/{chunk-UMI4UIJG.js → chunk-R77O2WMK.js} +1 -1
- package/dist/{chunk-IEWZ7VD7.js → chunk-RRNXJOKB.js} +2 -2
- package/dist/{chunk-FHWUQZXE.js → chunk-RXEEYDIB.js} +1 -1
- package/dist/{chunk-Q2XBKQ2D.js → chunk-TQJ6ZYVY.js} +2 -2
- package/dist/{chunk-V4GYPD5L.js → chunk-YXSUMLTC.js} +46 -269
- package/dist/{chunk-5GT7URXJ.js → chunk-ZDZISKCL.js} +1 -1
- package/dist/{chunk-ZMIMSIIT.js → chunk-ZNMCCHCP.js} +48 -2
- package/dist/cli.js +20 -20
- package/dist/clock-RHS2JEDL.js +16 -0
- package/dist/{cloud-sync-Y7LQPRE3.js → cloud-sync-46RS4SDA.js} +17 -16
- package/dist/{conversations-G4LYMHAA.js → conversations-CJOOCGNW.js} +3 -3
- package/dist/{create-2YY7IPMS.js → create-IOV4GCRX.js} +1 -1
- package/dist/{daemon-restart-ABP54YN6.js → daemon-restart-VFEI57UO.js} +6 -6
- package/dist/daemon.js +392 -270
- package/dist/{db-JFLCJCUG.js → db-MF2JNRUW.js} +1 -1
- package/dist/{delete-BFMIGAIB.js → delete-HPLC7ETU.js} +1 -1
- package/dist/delivery-manager-JINZRG2W.js +33 -0
- package/dist/{delivery-router-6DE3BPHO.js → delivery-router-N4A7ZC5T.js} +2 -2
- package/dist/{down-XCAZXWLB.js → down-U2CZ23M4.js} +4 -4
- package/dist/echo-text-VQPB5L2X.js +33 -0
- package/dist/{exec-4GWHAHS3.js → exec-G3ZRST4Z.js} +2 -2
- package/dist/{export-ID7YUSLZ.js → export-LY723I3Z.js} +5 -5
- package/dist/{extensions-AYSPKE33.js → extensions-ZELQLTSR.js} +17 -16
- package/dist/{files-BQ2WB5KC.js → files-4LN3EN7Q.js} +2 -2
- package/dist/{import-SKID37QX.js → import-42YNQBF4.js} +5 -5
- package/dist/{isolation-5MB3J73F.js → isolation-YMGZZ4E5.js} +2 -2
- package/dist/{join-RXPXGV4L.js → join-ZJV7W6MO.js} +4 -0
- package/dist/{login-LM4NF4ZB.js → login-M6OJUPQ2.js} +1 -1
- package/dist/{logout-CTJVIZGA.js → logout-WQBWO77G.js} +1 -1
- package/dist/message-delivery-R7JSQGYX.js +37 -0
- package/dist/{mind-KLHC7XYR.js → mind-LHKS55DI.js} +12 -12
- package/dist/{mind-activity-tracker-BGHJE7XA.js → mind-activity-tracker-3QRNJVR4.js} +3 -3
- package/dist/mind-service-NB2JMWQZ.js +39 -0
- package/dist/mind-sleep-74TGOQBC.js +83 -0
- package/dist/{package-BMHV7UHN.js → package-DOD6CPO6.js} +2 -1
- package/dist/{registry-NUJAGLQJ.js → registry-OUZBZ3Q4.js} +5 -1
- package/dist/{sandbox-72MWV4JL.js → sandbox-ZJ7QA6Z6.js} +3 -3
- package/dist/scheduler-WVKII3LA.js +31 -0
- package/dist/{seed-4MKQ5QKG.js → seed-CNYIHPOH.js} +1 -1
- package/dist/{seed-cmd-LCXMR7VV.js → seed-cmd-AVNUNLZV.js} +2 -2
- package/dist/{seed-create-N2BF5LQX.js → seed-create-ANXGYSCC.js} +5 -5
- package/dist/{seed-sprout-6ZDP23YI.js → seed-sprout-NIUFTX32.js} +10 -8
- package/dist/{send-O6W53VBM.js → send-SPE6DVNJ.js} +8 -2
- package/dist/{service-AQVPUANO.js → service-UOCLXDWR.js} +3 -3
- package/dist/{service-install-4QNUDKLM.js → service-install-4HHFDDWY.js} +4 -4
- package/dist/{setup-3O6O2JZ7.js → setup-QT2A25SY.js} +6 -6
- package/dist/{setup-73YBJZQL.js → setup-Y2EJI3GG.js} +2 -2
- package/dist/skills/imagegen/scripts/imagegen.ts +100 -40
- package/dist/skills/orientation/SKILL.md +1 -1
- package/dist/skills/volute-mind/SKILL.md +13 -9
- package/dist/skills/volute-mind/references/sleep.md +8 -1
- package/dist/{skills-ND4ZFYUW.js → skills-AKCFA7BN.js} +4 -4
- package/dist/sleep-manager-G6TXSDP7.js +35 -0
- package/dist/{spirit-II3HOERM.js → spirit-RPBLLUQ5.js} +7 -7
- package/dist/{sprout-4KUG76AQ.js → sprout-SMX42ZBP.js} +1 -1
- package/dist/{src-XFY3XDD6.js → src-OJHARHTF.js} +28 -17
- package/dist/{status-E5DQNCAO.js → status-SWRVGXBI.js} +4 -4
- package/dist/system-chat-NDAW6YP7.js +37 -0
- package/dist/{tailscale-QAWVKQKU.js → tailscale-3XOKOW7T.js} +1 -1
- package/dist/{turn-tracker-QQIVVXBI.js → turn-tracker-FOIBNUUA.js} +5 -4
- package/dist/{up-LG56MPBI.js → up-PS6PFS75.js} +5 -5
- package/dist/{update-7TYVH5FM.js → update-SWAYZRWJ.js} +4 -4
- package/dist/{update-check-VSCSJ7KX.js → update-check-PQ2MZ47K.js} +2 -2
- package/dist/{version-notify-CODJOUW2.js → version-notify-C5CH7CVP.js} +24 -19
- package/dist/{volute-config-TS62GS6A.js → volute-config-A52FHWKM.js} +5 -1
- package/dist/web-assets/assets/{index-Dac11sKB.js → index-CU6XWICM.js} +15 -15
- package/dist/web-assets/index.html +1 -1
- package/package.json +2 -1
- package/templates/_base/home/VOLUTE.md +6 -2
- package/templates/_base/src/lib/context-breakdown.ts +57 -67
- package/templates/_base/src/lib/file-handler.ts +3 -3
- package/templates/_base/src/lib/router.ts +21 -25
- package/templates/_base/src/lib/startup.ts +2 -0
- package/templates/_base/src/lib/types.ts +2 -2
- package/templates/_base/src/lib/volute-server.ts +2 -2
- package/templates/claude/src/agent.ts +147 -46
- package/templates/claude/src/lib/message-channel.ts +23 -0
- package/templates/claude/src/lib/session-reaper.ts +68 -0
- package/templates/claude/src/server.ts +1 -0
- package/templates/codex/src/agent.ts +80 -46
- package/templates/pi/src/agent.ts +73 -45
- package/dist/delivery-manager-R5STGLXB.js +0 -32
- package/dist/echo-text-NQXYYYCU.js +0 -32
- package/dist/message-delivery-IUAXRPFE.js +0 -34
- package/dist/mind-service-BF2LUQMH.js +0 -36
- package/dist/mind-sleep-YMBR3I35.js +0 -47
- package/dist/scheduler-YHJRRV6E.js +0 -30
- package/dist/sleep-manager-YAHDOIU5.js +0 -34
- 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
|
|
594
|
+
handle(content: VoluteContentPart[], meta: HandlerMeta, listener?: Listener): () => void {
|
|
534
595
|
const session = getOrCreateSession(sessionName);
|
|
535
596
|
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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 () =>
|
|
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
|
|
672
|
+
function jsonlPathFor(sessionName: string): string | null {
|
|
597
673
|
const sessionId = sessionStore.load(sessionName);
|
|
598
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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:
|
|
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
|
|
562
|
+
handle(content: VoluteContentPart[], meta: HandlerMeta, listener?: Listener): () => void {
|
|
562
563
|
const session = getOrCreateSession(sessionName);
|
|
563
564
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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 () =>
|
|
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
|
|
627
|
+
function jsonlPathFor(sessionName: string): string | null {
|
|
615
628
|
const threadId = sessionStore.load(sessionName);
|
|
616
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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:
|
|
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
|
|