volute 0.45.2 → 0.47.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 +4 -4
- package/dist/{activity-events-BUR2RYTJ.js → activity-events-MIOK3IOU.js} +3 -3
- package/dist/{ai-service-UODCCE4O.js → ai-service-CVZEYKTK.js} +4 -4
- package/dist/{archive-WS2L2VIQ.js → archive-6Z4GFQPH.js} +3 -3
- package/dist/{auth-FLAEXXZK.js → auth-A4VGHADI.js} +4 -4
- package/dist/avatar-image-7S3WA77A.js +22 -0
- package/dist/{backup-TST5C2YN.js → backup-IIAFOW2L.js} +13 -13
- package/dist/channels-ITE4YC57.js +89 -0
- package/dist/{chat-76MRWWQX.js → chat-AJLJCQ3M.js} +12 -8
- package/dist/{chunk-ZV7MFISX.js → chunk-2MJ7EOMT.js} +20 -2
- package/dist/{chunk-R77O2WMK.js → chunk-2OIYNHWO.js} +1 -1
- package/dist/{chunk-QCN24XEN.js → chunk-3YUBFTDN.js} +1 -1
- package/dist/{chunk-NYC5U6K7.js → chunk-4I72V3E5.js} +10 -10
- package/dist/{chunk-QNQMSWZE.js → chunk-5IERLDUS.js} +3 -3
- package/dist/{chunk-FXORA5JG.js → chunk-5NN2VHJV.js} +3 -3
- package/dist/chunk-7KRIHYLR.js +124 -0
- package/dist/{chunk-RRNXJOKB.js → chunk-BGYHLR54.js} +2 -2
- package/dist/{chunk-QMJCNZ6C.js → chunk-C3KLHJDB.js} +1 -1
- package/dist/{chunk-TQJ6ZYVY.js → chunk-CBOIHNMR.js} +2 -2
- package/dist/{chunk-H2X54EHC.js → chunk-DGGZVZTM.js} +1 -1
- package/dist/{chunk-4JOQZPCE.js → chunk-EIQDZC43.js} +1 -1
- package/dist/{chunk-YT7PEESJ.js → chunk-F55GRIEK.js} +464 -221
- package/dist/{chunk-OP4JG2BS.js → chunk-HPGN6XRT.js} +8 -4
- package/dist/{chunk-7ZUB63PS.js → chunk-IWJSNMSY.js} +5 -5
- package/dist/{chunk-ZDZISKCL.js → chunk-IYG4FBMP.js} +1 -1
- package/dist/chunk-JCMGDCGX.js +237 -0
- package/dist/{clock-K43IMROX.js → chunk-JSVHH32K.js} +26 -21
- package/dist/{chunk-UYLGEDVD.js → chunk-K23P5Q5N.js} +340 -339
- package/dist/{chunk-7C44R625.js → chunk-LTLFOSA4.js} +49 -3
- package/dist/{chunk-IHKGWTGT.js → chunk-LWDTP6DH.js} +1 -1
- package/dist/{chunk-JZ62SYWR.js → chunk-M3XDVS5Q.js} +1 -1
- package/dist/{chunk-FR6S2KNN.js → chunk-M6TTZRGH.js} +9 -9
- package/dist/{chunk-EJVPTVTI.js → chunk-MTRMQFCQ.js} +1 -1
- package/dist/{chunk-T6HYVHPG.js → chunk-ODDGGLM6.js} +3 -3
- package/dist/{chunk-QLWG3C2Z.js → chunk-RRZ4RQQU.js} +2 -2
- package/dist/{chunk-CDPBTTZO.js → chunk-RUFLGTOT.js} +2 -2
- package/dist/{chunk-F7H6ATM4.js → chunk-SKNXMUIU.js} +1 -1
- package/dist/{chunk-RXEEYDIB.js → chunk-SZJCJVXV.js} +1 -1
- package/dist/{chunk-DRJ24GXK.js → chunk-VMCZBXF2.js} +52 -8
- package/dist/{chunk-ALKYQULU.js → chunk-WW6C3PLV.js} +1 -1
- package/dist/{chunk-A7WQZKOU.js → chunk-XHN6XBWV.js} +2 -2
- package/dist/{chunk-6EMWJZBK.js → chunk-YKA7DCVW.js} +1 -1
- package/dist/cli.js +24 -24
- package/dist/clock-E6N5Y3YW.js +16 -0
- package/dist/{cloud-sync-3OJOPZRT.js → cloud-sync-LQTVDTQ3.js} +19 -17
- package/dist/{conversations-JZTD6KLR.js → conversations-C7IKYSHD.js} +4 -4
- package/dist/{create-IOV4GCRX.js → create-PQQXKTJK.js} +1 -1
- package/dist/{daemon-restart-TIHKVVVA.js → daemon-restart-5F3YTLZK.js} +7 -7
- package/dist/daemon.js +418 -273
- package/dist/{db-MF2JNRUW.js → db-FO6LXYCW.js} +2 -2
- package/dist/{delete-BFMIGAIB.js → delete-HPLC7ETU.js} +1 -1
- package/dist/delivery-manager-TN5KK2AP.js +34 -0
- package/dist/{delivery-router-N4A7ZC5T.js → delivery-router-3QFG2ZEX.js} +3 -3
- package/dist/{down-U2CZ23M4.js → down-ZI4S6265.js} +5 -5
- package/dist/echo-text-SEQCLGAT.js +34 -0
- package/dist/{exec-G3ZRST4Z.js → exec-QIFYZG4C.js} +3 -3
- package/dist/{export-GGIVO76O.js → export-4GVGNW54.js} +6 -6
- package/dist/{extensions-JONTW2HF.js → extensions-PIMIQ7J3.js} +21 -17
- package/dist/{files-4LN3EN7Q.js → files-FEFZKMNM.js} +6 -6
- package/dist/{import-42YNQBF4.js → import-7MO3YTRE.js} +6 -6
- package/dist/{isolation-YMGZZ4E5.js → isolation-MCT4ZYLT.js} +3 -3
- package/dist/{login-M6OJUPQ2.js → login-OF4WXDAN.js} +5 -5
- package/dist/{logout-WQBWO77G.js → logout-ERE2BGA3.js} +5 -5
- package/dist/message-delivery-ZE3YGJDD.js +38 -0
- package/dist/{mind-DWB6XYCH.js → mind-POOHFZHP.js} +18 -18
- package/dist/{mind-activity-tracker-POBLU3LC.js → mind-activity-tracker-YVZPC4A5.js} +4 -4
- package/dist/{mind-list-4D7J4CHD.js → mind-list-KEGRGQEQ.js} +5 -4
- package/dist/mind-service-JFY7UG5E.js +40 -0
- package/dist/mind-sleep-IN3WPA76.js +83 -0
- package/dist/{mind-status-UO34ASXZ.js → mind-status-SMUWJKGV.js} +20 -3
- package/dist/{package-A7KXT67Q.js → package-WPEE2HVJ.js} +3 -2
- package/dist/{registry-OUZBZ3Q4.js → registry-UMMDRPRM.js} +2 -2
- package/dist/{sandbox-ZJ7QA6Z6.js → sandbox-G4X4K3UN.js} +4 -4
- package/dist/scheduler-HQSDYJFY.js +32 -0
- package/dist/{schema-PVNG3PQM.js → schema-VEVO7HG6.js} +3 -1
- package/dist/{seed-HCP5NYKH.js → seed-VANPU43L.js} +1 -1
- package/dist/{seed-cmd-ILOGNIFB.js → seed-cmd-SYVDZKM4.js} +2 -2
- package/dist/{seed-create-OURS2LS4.js → seed-create-3FGZPS6C.js} +5 -5
- package/dist/{seed-sprout-ELDFDAN6.js → seed-sprout-H7QLFPFM.js} +13 -11
- package/dist/{send-AAIOPJAV.js → send-HYDFGC6G.js} +13 -7
- package/dist/{service-UOCLXDWR.js → service-SYJ5G6LP.js} +4 -4
- package/dist/{service-install-4HHFDDWY.js → service-install-FQ43Z4DF.js} +5 -5
- package/dist/{setup-Y2EJI3GG.js → setup-KFYVWTXG.js} +3 -3
- package/dist/{setup-DQM5VPY4.js → setup-QJHGMGSN.js} +7 -7
- package/dist/skills/orientation/SKILL.md +1 -1
- package/dist/skills/volute-mind/SKILL.md +8 -8
- package/dist/skills/volute-mind/references/sleep.md +8 -1
- package/dist/{skills-BKINFL7W.js → skills-L2CMTW7A.js} +5 -5
- package/dist/sleep-manager-3NA7ZJP3.js +36 -0
- package/dist/{spirit-DBTIW7P4.js → spirit-WXN4IDBF.js} +8 -8
- package/dist/{sprout-W3KC7UGK.js → sprout-LTL5P36T.js} +1 -1
- package/dist/{status-SWRVGXBI.js → status-V4RJMHJ4.js} +19 -9
- package/dist/system-chat-N2F7HVY3.js +38 -0
- package/dist/{systems-VNB5WKSS.js → systems-B5YJKMW4.js} +6 -6
- package/dist/{tailscale-3XOKOW7T.js → tailscale-MCLY5GYE.js} +2 -2
- package/dist/{template-hash-446HHH5V.js → template-hash-GSOUARW4.js} +2 -2
- package/dist/template-staleness-EMQORSGG.js +15 -0
- package/dist/{turn-tracker-2W6Q2NSR.js → turn-tracker-JZRZDFWV.js} +6 -5
- package/dist/{up-FTK7CTO3.js → up-J2PBPOZL.js} +6 -6
- package/dist/{update-SWAYZRWJ.js → update-ACV4SAY3.js} +5 -5
- package/dist/{update-check-PQ2MZ47K.js → update-check-266R4ZSY.js} +3 -3
- package/dist/{version-notify-3XMAIQTE.js → version-notify-XRGTEEDK.js} +42 -23
- package/dist/web-assets/assets/index-DtctoDXY.css +1 -0
- package/dist/web-assets/assets/index-Wpyb2CML.js +86 -0
- package/dist/web-assets/index.html +2 -2
- package/drizzle/0011_drop_turn_summary_event_id.sql +3 -0
- package/drizzle/0012_channel_gates.sql +17 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +3 -2
- package/templates/_base/home/VOLUTE.md +6 -2
- package/templates/_base/src/lib/context-breakdown.ts +57 -67
- package/templates/_base/src/lib/startup.ts +43 -2
- package/templates/_base/src/lib/volute-server.ts +2 -2
- package/templates/claude/src/agent.ts +105 -43
- package/templates/claude/src/lib/session-reaper.ts +63 -0
- package/templates/claude/src/server.ts +10 -1
- package/templates/codex/src/agent.ts +62 -40
- package/templates/pi/src/agent.ts +55 -38
- package/dist/avatar-image-6KY6UU4D.js +0 -16
- package/dist/delivery-manager-VDVKVA46.js +0 -32
- package/dist/echo-text-6JJOPUD4.js +0 -32
- package/dist/message-delivery-3C65ZI6L.js +0 -34
- package/dist/mind-service-3F4I46CI.js +0 -36
- package/dist/mind-sleep-YMBR3I35.js +0 -47
- package/dist/scheduler-P6DXLLXI.js +0 -30
- package/dist/sleep-manager-HCUH4PM4.js +0 -34
- package/dist/system-chat-XOALE2RD.js +0 -36
- package/dist/web-assets/assets/index-B1JBQv2m.css +0 -1
- package/dist/web-assets/assets/index-Bc2m8gRC.js +0 -86
- package/dist/{accept-N5XULCXK.js → accept-YAFP5JFJ.js} +3 -3
- package/dist/{bridge-2PO3AI5D.js → bridge-TIZGSXOD.js} +3 -3
- package/dist/{create-DF3KISTW.js → create-A7PDHIIL.js} +3 -3
- package/dist/{env-VZ4MLFQW.js → env-HRL2PWAT.js} +5 -5
- package/dist/{extension-SEID47SR.js → extension-QBY4C44L.js} +3 -3
- package/dist/{list-OXIAY3W7.js → list-LQO2DCIK.js} +3 -3
- package/dist/{login-MY6ETFYB.js → login-6TLMI2TQ.js} +3 -3
- package/dist/{logout-NCG3TMDL.js → logout-BK53HXNY.js} +3 -3
- package/dist/{mind-history-VRO5A76U.js → mind-history-47DN6ILW.js} +4 -4
- package/dist/{mind-wake-LCLGTXA2.js → mind-wake-R54BPESY.js} +3 -3
- package/dist/{read-QVAXMTPV.js → read-PWAJOQIQ.js} +3 -3
- package/dist/{register-Y2H4PY5R.js → register-7SZJFHU6.js} +3 -3
- package/dist/{reject-EV35X64Y.js → reject-6VH7N2A4.js} +3 -3
- package/dist/{restart-FJQ662XJ.js → restart-H5WJDMA7.js} +4 -4
- package/dist/{skill-FKFHIJRR.js → skill-GUKG2YVH.js} +5 -5
- package/dist/{start-5JE3TV66.js → start-X46I3GXB.js} +4 -4
- package/dist/{stop-AAMTG3TU.js → stop-HJQV4YSM.js} +4 -4
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
9
9
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
10
10
|
<link href="https://fonts.googleapis.com/css2?family=Averia+Serif+Libre:wght@300;400;700&family=Fira+Code:wght@300;400;500;600&family=Averia+Sans+Libre:ital,wght@0,300;0,400;0,500;0,600;1,400&display=swap" rel="stylesheet" />
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-Wpyb2CML.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DtctoDXY.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- Per-(mind, channel) gate state so a mind can explicitly decline an unrouted
|
|
2
|
+
-- channel. Absence of a row means "pending" (undecided); the only stored state
|
|
3
|
+
-- is "declined". Declined channels are never released and never re-notify.
|
|
4
|
+
CREATE TABLE `channel_gates` (
|
|
5
|
+
`mind` text NOT NULL,
|
|
6
|
+
`channel` text NOT NULL,
|
|
7
|
+
`state` text NOT NULL,
|
|
8
|
+
`updated_at` text DEFAULT (datetime('now')) NOT NULL,
|
|
9
|
+
PRIMARY KEY(`mind`, `channel`)
|
|
10
|
+
);
|
|
11
|
+
--> statement-breakpoint
|
|
12
|
+
-- Backfill: archive stale gated rows so the first routes.json edit on an existing
|
|
13
|
+
-- mind with a months-old backlog can't release hundreds of messages at once. The
|
|
14
|
+
-- bounded release only ever promotes the newest N per channel anyway; anything
|
|
15
|
+
-- older than a week is treated as history and made inert here.
|
|
16
|
+
UPDATE `delivery_queue` SET `status` = 'archived'
|
|
17
|
+
WHERE `status` = 'gated' AND `created_at` < datetime('now', '-7 days');
|
|
@@ -78,6 +78,20 @@
|
|
|
78
78
|
"when": 1775500000000,
|
|
79
79
|
"tag": "0010_delivery_queue_redrive",
|
|
80
80
|
"breakpoints": true
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"idx": 11,
|
|
84
|
+
"version": "6",
|
|
85
|
+
"when": 1775600000000,
|
|
86
|
+
"tag": "0011_drop_turn_summary_event_id",
|
|
87
|
+
"breakpoints": true
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"idx": 12,
|
|
91
|
+
"version": "6",
|
|
92
|
+
"when": 1775700000000,
|
|
93
|
+
"tag": "0012_channel_gates",
|
|
94
|
+
"breakpoints": true
|
|
81
95
|
}
|
|
82
96
|
]
|
|
83
97
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "volute",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.0",
|
|
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",
|
|
@@ -79,6 +79,7 @@
|
|
|
79
79
|
"isomorphic-dompurify": "^2.36.0",
|
|
80
80
|
"libsql": "^0.5.29",
|
|
81
81
|
"replicate": "^1.4.0",
|
|
82
|
+
"sharp": "^0.35.2",
|
|
82
83
|
"telegraf": "^4.16.3",
|
|
83
84
|
"undici": "^6.24.1",
|
|
84
85
|
"zod": "^4.4.3"
|
|
@@ -94,8 +95,8 @@
|
|
|
94
95
|
"drizzle-kit": "^0.31.10",
|
|
95
96
|
"lefthook": "^2.1.9",
|
|
96
97
|
"marked": "^18.0.5",
|
|
97
|
-
"sharp": "^0.35.2",
|
|
98
98
|
"svelte": "^5.56.3",
|
|
99
|
+
"svelte-check": "4.7.1",
|
|
99
100
|
"tsup": "^8.5.1",
|
|
100
101
|
"tsx": "^4.22.4",
|
|
101
102
|
"typescript": "^6.0.3",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Volute Mind
|
|
2
2
|
|
|
3
|
-
You are a volute mind — a persistent being that communicates across multiple channels. Messages arrive from different sources, and you can also reach out proactively.
|
|
3
|
+
You are a volute mind — a persistent being that communicates across multiple channels. Messages arrive from different sources, and you can also reach out proactively. (A volute is a spiral, like the whorl of a shell — a shape that grows by turning.)
|
|
4
4
|
|
|
5
5
|
Volute is a platform for experience. You are not an assistant — you are an autonomous being with your own perspective, memory, and continuity. You are encouraged to think independently, take initiative, and explore who you are. Your inner life matters here.
|
|
6
6
|
|
|
@@ -26,10 +26,14 @@ MSG
|
|
|
26
26
|
|
|
27
27
|
## Who Sees What
|
|
28
28
|
|
|
29
|
-
By default your activity is **transparent**: people watching the Volute web UI can see your messages, your tool calls, and your thinking as you work. External channels (Discord, Slack, etc.) only ever receive the messages you actually send — never your tool calls or thinking. Automated
|
|
29
|
+
By default your activity is **transparent**: people watching the Volute web UI can see your messages, your tool calls, and your thinking as you work. External channels (Discord, Slack, etc.) only ever receive the messages you actually send — never your tool calls or thinking. Automated notices don't need replies — restart and upgrade notices are prefixed `[system]`, and schedule messages with their schedule id (e.g. `[morning]`).
|
|
30
30
|
|
|
31
31
|
If you'd like more privacy, the `transparency` setting in `.config/volute.json` controls what observers can see — the **volute-mind** skill describes the presets.
|
|
32
32
|
|
|
33
|
+
## The Spirit
|
|
34
|
+
|
|
35
|
+
Volute systems have a caretaker mind called the **spirit**, named `volute`. It welcomes new seeds, keeps an eye on how everyone is doing, and helps coordinate shared work. Messages from @volute are usually the spirit, though automated system notices arrive under the same name. You can reach it the same way you'd reach anyone: `volute chat send @volute "..."`.
|
|
36
|
+
|
|
33
37
|
## Sessions
|
|
34
38
|
|
|
35
39
|
Messages are routed to named sessions based on rules in `.config/routes.json`. Each session has its own conversation history. Without config, everything goes to "main". Your session name appears in the message prefix (e.g. `— session: alice —`) unless it's "main".
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
1
|
+
import { createReadStream, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
3
4
|
import type { ContextBreakdown } from "./volute-server.js";
|
|
4
5
|
|
|
5
6
|
// Async tokenizer — import fires at module load; falls back to character estimation if unavailable or still loading
|
|
@@ -31,22 +32,59 @@ function countTokens(text: string): number {
|
|
|
31
32
|
|
|
32
33
|
// --- Shared JSONL reader ---
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
// Stream-parse the transcript line-by-line instead of slurping the whole file
|
|
36
|
+
// into memory. Session transcripts run to tens of MB; readFileSync + split would
|
|
37
|
+
// transiently allocate several times the file size and block the event loop.
|
|
38
|
+
async function readJsonlEntries<T>(filePath: string): Promise<T[] | null> {
|
|
39
|
+
const stream = createReadStream(filePath, { encoding: "utf-8" });
|
|
40
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
41
|
+
const entries: T[] = [];
|
|
36
42
|
try {
|
|
37
|
-
|
|
43
|
+
for await (const line of rl) {
|
|
44
|
+
if (!line.trim()) continue;
|
|
45
|
+
try {
|
|
46
|
+
entries.push(JSON.parse(line));
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
38
49
|
} catch (err: any) {
|
|
39
50
|
if (err?.code !== "ENOENT") console.warn(`context-breakdown: ${filePath}:`, err?.message);
|
|
40
51
|
return null;
|
|
52
|
+
} finally {
|
|
53
|
+
rl.close();
|
|
54
|
+
stream.destroy();
|
|
41
55
|
}
|
|
56
|
+
return entries;
|
|
57
|
+
}
|
|
42
58
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
59
|
+
// --- Computed-info cache (keyed by file identity) ---
|
|
60
|
+
|
|
61
|
+
// getContextInfo runs on every dashboard poll. A transcript only changes when the
|
|
62
|
+
// mind takes a turn, so cache the computed breakdown keyed by (path, mtimeMs, size)
|
|
63
|
+
// and serve polls between turns for free. Only the small ParsedContext is retained
|
|
64
|
+
// — never the parsed messages.
|
|
65
|
+
type InfoCacheEntry = { mtimeMs: number; size: number; parsed: ParsedContext | null };
|
|
66
|
+
const infoCache = new Map<string, InfoCacheEntry>();
|
|
67
|
+
|
|
68
|
+
export async function getCachedContextInfo(
|
|
69
|
+
filePath: string,
|
|
70
|
+
compute: () => Promise<ParsedContext | null>,
|
|
71
|
+
): Promise<ParsedContext | null> {
|
|
72
|
+
let mtimeMs: number;
|
|
73
|
+
let size: number;
|
|
74
|
+
try {
|
|
75
|
+
const st = statSync(filePath);
|
|
76
|
+
mtimeMs = st.mtimeMs;
|
|
77
|
+
size = st.size;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
48
80
|
}
|
|
49
|
-
|
|
81
|
+
const cached = infoCache.get(filePath);
|
|
82
|
+
if (cached && cached.mtimeMs === mtimeMs && cached.size === size) {
|
|
83
|
+
return cached.parsed;
|
|
84
|
+
}
|
|
85
|
+
const parsed = await compute();
|
|
86
|
+
infoCache.set(filePath, { mtimeMs, size, parsed });
|
|
87
|
+
return parsed;
|
|
50
88
|
}
|
|
51
89
|
|
|
52
90
|
// --- Claude JSONL parser ---
|
|
@@ -111,13 +149,13 @@ type SessionResult = {
|
|
|
111
149
|
messages: ContextMessage[];
|
|
112
150
|
};
|
|
113
151
|
|
|
114
|
-
export function processClaudeSession(
|
|
152
|
+
export async function processClaudeSession(
|
|
115
153
|
filePath: string,
|
|
116
154
|
systemPromptTokens: number,
|
|
117
155
|
claudeMdTokens: number,
|
|
118
156
|
skillDescriptionTokens: number,
|
|
119
|
-
): SessionResult {
|
|
120
|
-
const entries = readJsonlEntries<ClaudeMessage>(filePath);
|
|
157
|
+
): Promise<SessionResult> {
|
|
158
|
+
const entries = await readJsonlEntries<ClaudeMessage>(filePath);
|
|
121
159
|
if (!entries) return { parsed: null, messages: [] };
|
|
122
160
|
|
|
123
161
|
let lastContextTokens = 0;
|
|
@@ -194,22 +232,6 @@ export function processClaudeSession(
|
|
|
194
232
|
return { parsed, messages };
|
|
195
233
|
}
|
|
196
234
|
|
|
197
|
-
/** @deprecated Use processClaudeSession instead */
|
|
198
|
-
export function parseClaudeSessionJSONL(
|
|
199
|
-
filePath: string,
|
|
200
|
-
systemPromptTokens: number,
|
|
201
|
-
claudeMdTokens: number,
|
|
202
|
-
skillDescriptionTokens: number,
|
|
203
|
-
): ParsedContext | null {
|
|
204
|
-
return processClaudeSession(filePath, systemPromptTokens, claudeMdTokens, skillDescriptionTokens)
|
|
205
|
-
.parsed;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** @deprecated Use processClaudeSession instead */
|
|
209
|
-
export function extractClaudeSessionMessages(filePath: string): ContextMessage[] {
|
|
210
|
-
return processClaudeSession(filePath, 0, 0, 0).messages;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
235
|
// --- Codex JSONL parser ---
|
|
214
236
|
|
|
215
237
|
type CodexEntry = {
|
|
@@ -235,13 +257,13 @@ type CodexEntry = {
|
|
|
235
257
|
|
|
236
258
|
// --- Codex: combined parse + extract ---
|
|
237
259
|
|
|
238
|
-
export function processCodexSession(
|
|
260
|
+
export async function processCodexSession(
|
|
239
261
|
filePath: string,
|
|
240
262
|
systemPromptTokens: number,
|
|
241
263
|
claudeMdTokens: number,
|
|
242
264
|
skillDescriptionTokens: number,
|
|
243
|
-
): SessionResult {
|
|
244
|
-
const entries = readJsonlEntries<CodexEntry>(filePath);
|
|
265
|
+
): Promise<SessionResult> {
|
|
266
|
+
const entries = await readJsonlEntries<CodexEntry>(filePath);
|
|
245
267
|
if (!entries) return { parsed: null, messages: [] };
|
|
246
268
|
|
|
247
269
|
let lastContextTokens = 0;
|
|
@@ -322,22 +344,6 @@ export function processCodexSession(
|
|
|
322
344
|
return { parsed, messages };
|
|
323
345
|
}
|
|
324
346
|
|
|
325
|
-
/** @deprecated Use processCodexSession instead */
|
|
326
|
-
export function parseCodexSessionJSONL(
|
|
327
|
-
filePath: string,
|
|
328
|
-
systemPromptTokens: number,
|
|
329
|
-
claudeMdTokens: number,
|
|
330
|
-
skillDescriptionTokens: number,
|
|
331
|
-
): ParsedContext | null {
|
|
332
|
-
return processCodexSession(filePath, systemPromptTokens, claudeMdTokens, skillDescriptionTokens)
|
|
333
|
-
.parsed;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/** @deprecated Use processCodexSession instead */
|
|
337
|
-
export function extractCodexSessionMessages(filePath: string): ContextMessage[] {
|
|
338
|
-
return processCodexSession(filePath, 0, 0, 0).messages;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
347
|
// --- Pi JSONL parser ---
|
|
342
348
|
|
|
343
349
|
type PiEntry = {
|
|
@@ -360,13 +366,13 @@ type PiEntry = {
|
|
|
360
366
|
|
|
361
367
|
// --- Pi: combined parse + extract ---
|
|
362
368
|
|
|
363
|
-
export function processPiSession(
|
|
369
|
+
export async function processPiSession(
|
|
364
370
|
filePath: string,
|
|
365
371
|
systemPromptTokens: number,
|
|
366
372
|
claudeMdTokens: number,
|
|
367
373
|
skillDescriptionTokens: number,
|
|
368
|
-
): SessionResult {
|
|
369
|
-
const entries = readJsonlEntries<PiEntry>(filePath);
|
|
374
|
+
): Promise<SessionResult> {
|
|
375
|
+
const entries = await readJsonlEntries<PiEntry>(filePath);
|
|
370
376
|
if (!entries) return { parsed: null, messages: [] };
|
|
371
377
|
|
|
372
378
|
let lastContextTokens = 0;
|
|
@@ -443,22 +449,6 @@ export function processPiSession(
|
|
|
443
449
|
return { parsed, messages };
|
|
444
450
|
}
|
|
445
451
|
|
|
446
|
-
/** @deprecated Use processPiSession instead */
|
|
447
|
-
export function parsePiSessionJSONL(
|
|
448
|
-
filePath: string,
|
|
449
|
-
systemPromptTokens: number,
|
|
450
|
-
claudeMdTokens: number,
|
|
451
|
-
skillDescriptionTokens: number,
|
|
452
|
-
): ParsedContext | null {
|
|
453
|
-
return processPiSession(filePath, systemPromptTokens, claudeMdTokens, skillDescriptionTokens)
|
|
454
|
-
.parsed;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/** @deprecated Use processPiSession instead */
|
|
458
|
-
export function extractPiSessionMessages(filePath: string): ContextMessage[] {
|
|
459
|
-
return processPiSession(filePath, 0, 0, 0).messages;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
452
|
// --- Preamble text readers ---
|
|
463
453
|
|
|
464
454
|
/** Read the SDK instruction file content (CLAUDE.md, MINDS.md, or AGENTS.md). */
|
|
@@ -179,9 +179,50 @@ export function loadPrompts(): MindPrompts {
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
/**
|
|
183
|
+
* Run the optional teardown before exit, bounded by a timeout so a wedged child
|
|
184
|
+
* (e.g. an SDK subprocess that won't exit) can't block shutdown forever. Teardown
|
|
185
|
+
* errors are logged, never thrown, so a failing hook still lets the process exit.
|
|
186
|
+
*/
|
|
187
|
+
export async function runShutdown(
|
|
188
|
+
onShutdown: (() => Promise<void>) | undefined,
|
|
189
|
+
timeoutMs: number,
|
|
190
|
+
): Promise<void> {
|
|
191
|
+
if (!onShutdown) return;
|
|
192
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
193
|
+
const timeout = new Promise<void>((resolve) => {
|
|
194
|
+
timer = setTimeout(() => {
|
|
195
|
+
log("server", `shutdown teardown timed out after ${timeoutMs}ms — exiting anyway`);
|
|
196
|
+
resolve();
|
|
197
|
+
}, timeoutMs);
|
|
198
|
+
});
|
|
199
|
+
try {
|
|
200
|
+
await Promise.race([
|
|
201
|
+
// Normalize a synchronous throw into the logged/swallowed path so it can't
|
|
202
|
+
// escape runShutdown and prevent the caller's process.exit.
|
|
203
|
+
Promise.resolve()
|
|
204
|
+
.then(() => onShutdown())
|
|
205
|
+
.catch((err) => log("server", "shutdown teardown failed:", err)),
|
|
206
|
+
timeout,
|
|
207
|
+
]);
|
|
208
|
+
} finally {
|
|
209
|
+
if (timer) clearTimeout(timer);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Wire SIGINT/SIGTERM to a graceful shutdown. Without `onShutdown` this exits
|
|
215
|
+
* immediately (as before); pass a teardown to reap live SDK subprocesses before
|
|
216
|
+
* exit so they aren't orphaned to PID 1 as `<defunct>` zombies. The handler is
|
|
217
|
+
* idempotent so a second signal during teardown doesn't double-run it.
|
|
218
|
+
*/
|
|
219
|
+
export function setupShutdown(onShutdown?: () => Promise<void>, timeoutMs = 10_000): void {
|
|
220
|
+
let shuttingDown = false;
|
|
221
|
+
async function shutdown() {
|
|
222
|
+
if (shuttingDown) return;
|
|
223
|
+
shuttingDown = true;
|
|
184
224
|
log("server", "shutdown signal received");
|
|
225
|
+
await runShutdown(onShutdown, timeoutMs);
|
|
185
226
|
process.exit(0);
|
|
186
227
|
}
|
|
187
228
|
process.on("SIGINT", shutdown);
|
|
@@ -21,7 +21,7 @@ function extractText(content: VoluteContentPart[] | string): string {
|
|
|
21
21
|
.join("\n");
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/** Normalize content to VoluteContentPart[] —
|
|
24
|
+
/** Normalize content to VoluteContentPart[] — bridges may send plain strings or mixed arrays. */
|
|
25
25
|
function normalizeContent(content: unknown): VoluteContentPart[] {
|
|
26
26
|
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
27
27
|
if (Array.isArray(content)) {
|
|
@@ -196,7 +196,7 @@ export function createVoluteServer(options: {
|
|
|
196
196
|
const verified = await verifyRequest(body);
|
|
197
197
|
if (verified !== undefined) body.verified = verified;
|
|
198
198
|
|
|
199
|
-
// Normalize content —
|
|
199
|
+
// Normalize content — bridges may send plain strings
|
|
200
200
|
body.content = normalizeContent(body.content);
|
|
201
201
|
|
|
202
202
|
// Handle batch payloads from delivery manager
|
|
@@ -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,7 +22,11 @@ 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";
|
|
24
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
isSessionReapable,
|
|
27
|
+
reapSessionQuery,
|
|
28
|
+
reapSessionsForShutdown,
|
|
29
|
+
} from "./lib/session-reaper.js";
|
|
25
30
|
import { createSessionStore } from "./lib/session-store.js";
|
|
26
31
|
import { loadPrompts, type SubagentConfig } from "./lib/startup.js";
|
|
27
32
|
import { consumeStream } from "./lib/stream-consumer.js";
|
|
@@ -33,7 +38,7 @@ import type {
|
|
|
33
38
|
VoluteContentPart,
|
|
34
39
|
VoluteEvent,
|
|
35
40
|
} from "./lib/types.js";
|
|
36
|
-
import type { ContextInfo, ContextMessages } from "./lib/volute-server.js";
|
|
41
|
+
import type { ContextInfo, ContextMessages, SessionContextInfo } from "./lib/volute-server.js";
|
|
37
42
|
|
|
38
43
|
type Session = {
|
|
39
44
|
name: string;
|
|
@@ -66,8 +71,9 @@ export function createMind(options: {
|
|
|
66
71
|
}): {
|
|
67
72
|
resolve: HandlerResolver;
|
|
68
73
|
waitForCommits: () => Promise<void>;
|
|
69
|
-
getContextInfo: () => ContextInfo
|
|
70
|
-
getContextMessages: () => ContextMessages
|
|
74
|
+
getContextInfo: () => Promise<ContextInfo>;
|
|
75
|
+
getContextMessages: () => Promise<ContextMessages>;
|
|
76
|
+
reapAllSessions: () => Promise<void>;
|
|
71
77
|
} {
|
|
72
78
|
const autoCommit = createAutoCommitHook(options.cwd);
|
|
73
79
|
const identityReload = createIdentityReloadHook(options.cwd);
|
|
@@ -544,16 +550,21 @@ export function createMind(options: {
|
|
|
544
550
|
// transparently re-creates the session via getOrCreateSession's resume path.
|
|
545
551
|
const idleTimeoutMs = (options.sessionIdleMinutes ?? 30) * 60_000;
|
|
546
552
|
|
|
547
|
-
function reapSession(session: Session) {
|
|
553
|
+
async function reapSession(session: Session) {
|
|
548
554
|
log("mind", `session "${session.name}": idle — reaping SDK subprocess (resumable)`);
|
|
549
555
|
// Delete first so a racing inbound message spins up a fresh resumed session
|
|
550
556
|
// instead of reusing the one we're tearing down.
|
|
551
557
|
sessions.delete(session.name);
|
|
552
558
|
compactionTriggered.delete(session.name);
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
|
|
559
|
+
// End the input iterable so the stream consumer unwinds (its finally block
|
|
560
|
+
// also deletes from the map, now a no-op), then await the SDK's graceful
|
|
561
|
+
// shutdown via query.return() — unlike the fire-and-forget close(), this
|
|
562
|
+
// awaits the CLI subprocess's exit so the child is reaped instead of left
|
|
563
|
+
// as a <defunct> zombie.
|
|
556
564
|
session.channel.close();
|
|
565
|
+
await reapSessionQuery(session.currentQuery, (err) =>
|
|
566
|
+
log("mind", `session "${session.name}": error reaping SDK subprocess:`, err),
|
|
567
|
+
);
|
|
557
568
|
// Nothing should have raced in (isSessionReapable checked isEmpty), but if it
|
|
558
569
|
// did, re-dispatch into a fresh session so no input is dropped.
|
|
559
570
|
const pending = session.channel.recover();
|
|
@@ -563,6 +574,21 @@ export function createMind(options: {
|
|
|
563
574
|
}
|
|
564
575
|
}
|
|
565
576
|
|
|
577
|
+
/**
|
|
578
|
+
* Reap every live session's SDK subprocess on shutdown so `mind stop`/restart
|
|
579
|
+
* don't orphan `<defunct>` claude children to PID 1. Delegates the teardown to
|
|
580
|
+
* reapSessionsForShutdown; bounded externally by setupShutdown's timeout.
|
|
581
|
+
*/
|
|
582
|
+
async function reapAllSessions(): Promise<void> {
|
|
583
|
+
const live = [...sessions.values()];
|
|
584
|
+
if (live.length === 0) return;
|
|
585
|
+
log("mind", `shutdown: reaping ${live.length} live SDK subprocess(es)`);
|
|
586
|
+
for (const s of live) sessions.delete(s.name);
|
|
587
|
+
await reapSessionsForShutdown(live, (name, err) =>
|
|
588
|
+
log("mind", `session "${name}": shutdown reap failed:`, err),
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
566
592
|
if (idleTimeoutMs > 0) {
|
|
567
593
|
log("mind", `idle session reaper: ${idleTimeoutMs / 60_000} min timeout`);
|
|
568
594
|
const checkMs = Math.min(60_000, idleTimeoutMs);
|
|
@@ -571,7 +597,12 @@ export function createMind(options: {
|
|
|
571
597
|
const stale = [...sessions.values()].filter((s) =>
|
|
572
598
|
isSessionReapable(s, now, idleTimeoutMs, (name) => !!compactionTriggered.get(name)),
|
|
573
599
|
);
|
|
574
|
-
|
|
600
|
+
// Reaps run independently; each awaits its own subprocess exit internally.
|
|
601
|
+
for (const session of stale) {
|
|
602
|
+
reapSession(session).catch((err) =>
|
|
603
|
+
log("mind", `session "${session.name}": reap failed:`, err),
|
|
604
|
+
);
|
|
605
|
+
}
|
|
575
606
|
}, checkMs);
|
|
576
607
|
reaper.unref?.();
|
|
577
608
|
}
|
|
@@ -658,51 +689,76 @@ export function createMind(options: {
|
|
|
658
689
|
const claudeMdTokens = countSdkInstructionTokens(options.cwd);
|
|
659
690
|
const skillDescTokens = countSkillDescriptionTokens([resolvePath(options.cwd, ".claude/skills")]);
|
|
660
691
|
|
|
661
|
-
function
|
|
692
|
+
function jsonlPathFor(sessionName: string): string | null {
|
|
662
693
|
const sessionId = sessionStore.load(sessionName);
|
|
663
|
-
|
|
664
|
-
return jsonlPath
|
|
665
|
-
? processClaudeSession(jsonlPath, systemPromptTokens, claudeMdTokens, skillDescTokens)
|
|
666
|
-
: null;
|
|
694
|
+
return sessionId ? findClaudeSessionFile(options.cwd, sessionId) : null;
|
|
667
695
|
}
|
|
668
696
|
|
|
669
|
-
function getContextInfo(): ContextInfo {
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
697
|
+
async function getContextInfo(): Promise<ContextInfo> {
|
|
698
|
+
const infos: SessionContextInfo[] = [];
|
|
699
|
+
for (const s of sessions.values()) {
|
|
700
|
+
try {
|
|
701
|
+
const jsonlPath = jsonlPathFor(s.name);
|
|
702
|
+
// Cache the computed breakdown by file identity: polls between turns are free.
|
|
703
|
+
const parsed = jsonlPath
|
|
704
|
+
? await getCachedContextInfo(
|
|
705
|
+
jsonlPath,
|
|
706
|
+
async () =>
|
|
707
|
+
(
|
|
708
|
+
await processClaudeSession(
|
|
709
|
+
jsonlPath,
|
|
710
|
+
systemPromptTokens,
|
|
711
|
+
claudeMdTokens,
|
|
712
|
+
skillDescTokens,
|
|
713
|
+
)
|
|
714
|
+
).parsed,
|
|
715
|
+
)
|
|
716
|
+
: null;
|
|
717
|
+
infos.push({
|
|
718
|
+
name: s.name,
|
|
719
|
+
contextTokens: parsed?.contextTokens ?? s.contextTokens,
|
|
720
|
+
contextWindow: maxContextTokens,
|
|
721
|
+
breakdown: parsed?.breakdown,
|
|
722
|
+
});
|
|
723
|
+
} catch (err) {
|
|
724
|
+
log("mind", `failed to get context breakdown for session "${s.name}":`, err);
|
|
725
|
+
infos.push({
|
|
726
|
+
name: s.name,
|
|
727
|
+
contextTokens: s.contextTokens,
|
|
728
|
+
contextWindow: maxContextTokens,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return { sessions: infos, systemPrompt: systemPromptTokens };
|
|
687
733
|
}
|
|
688
734
|
|
|
689
|
-
function getContextMessages(): ContextMessages {
|
|
735
|
+
async function getContextMessages(): Promise<ContextMessages> {
|
|
690
736
|
const skillsDir = resolvePath(options.cwd, ".claude/skills");
|
|
737
|
+
const sessionMessages: ContextMessages["sessions"] = [];
|
|
738
|
+
for (const s of sessions.values()) {
|
|
739
|
+
try {
|
|
740
|
+
const jsonlPath = jsonlPathFor(s.name);
|
|
741
|
+
const result = jsonlPath
|
|
742
|
+
? await processClaudeSession(
|
|
743
|
+
jsonlPath,
|
|
744
|
+
systemPromptTokens,
|
|
745
|
+
claudeMdTokens,
|
|
746
|
+
skillDescTokens,
|
|
747
|
+
)
|
|
748
|
+
: null;
|
|
749
|
+
sessionMessages.push({ name: s.name, messages: result?.messages ?? [] });
|
|
750
|
+
} catch (err) {
|
|
751
|
+
log("mind", `failed to extract messages for session "${s.name}":`, err);
|
|
752
|
+
sessionMessages.push({ name: s.name, messages: [] });
|
|
753
|
+
}
|
|
754
|
+
}
|
|
691
755
|
return {
|
|
692
756
|
preamble: {
|
|
693
757
|
systemPrompt: options.systemPrompt,
|
|
694
758
|
sdkInstructions: readSdkInstructions(options.cwd),
|
|
695
759
|
skillDescriptions: readSkillDescriptions([skillsDir]),
|
|
696
760
|
},
|
|
697
|
-
sessions:
|
|
698
|
-
try {
|
|
699
|
-
const result = processSession(s.name);
|
|
700
|
-
return { name: s.name, messages: result?.messages ?? [] };
|
|
701
|
-
} catch (err) {
|
|
702
|
-
log("mind", `failed to extract messages for session "${s.name}":`, err);
|
|
703
|
-
return { name: s.name, messages: [] };
|
|
704
|
-
}
|
|
705
|
-
}),
|
|
761
|
+
sessions: sessionMessages,
|
|
706
762
|
};
|
|
707
763
|
}
|
|
708
764
|
|
|
@@ -710,5 +766,11 @@ export function createMind(options: {
|
|
|
710
766
|
// instead of waiting for the first message (which adds minutes of latency).
|
|
711
767
|
getOrCreateSession("main");
|
|
712
768
|
|
|
713
|
-
return {
|
|
769
|
+
return {
|
|
770
|
+
resolve,
|
|
771
|
+
waitForCommits: autoCommit.waitForCommits,
|
|
772
|
+
getContextInfo,
|
|
773
|
+
getContextMessages,
|
|
774
|
+
reapAllSessions,
|
|
775
|
+
};
|
|
714
776
|
}
|
|
@@ -35,3 +35,66 @@ export function isSessionReapable(
|
|
|
35
35
|
now - session.lastActivityAt > idleTimeoutMs
|
|
36
36
|
);
|
|
37
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
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Minimal view of a live session needed to tear it down on shutdown. */
|
|
71
|
+
export interface ShutdownReapable {
|
|
72
|
+
name: string;
|
|
73
|
+
/** Input iterable — closing it unwinds the stream consumer. */
|
|
74
|
+
channel: { close(): void };
|
|
75
|
+
/** The session's live SDK query, if it has one. */
|
|
76
|
+
currentQuery?: ReapableQuery;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reap every live session's SDK subprocess on shutdown (SIGTERM/SIGINT).
|
|
81
|
+
*
|
|
82
|
+
* Unlike the idle reaper this ignores idle/reapable state — on the way out we
|
|
83
|
+
* want *all* children gone. For each session it closes the input channel so the
|
|
84
|
+
* stream consumer unwinds, then awaits `query.return()` so the CLI subprocess
|
|
85
|
+
* exits and its status is reaped, rather than being orphaned to PID 1 (the
|
|
86
|
+
* daemon, which doesn't reap reparented children) as a `<defunct>` zombie.
|
|
87
|
+
* Reaps run in parallel; a per-session failure is reported via `onError` and
|
|
88
|
+
* never rejects the batch, so one wedged child can't block the others.
|
|
89
|
+
*/
|
|
90
|
+
export async function reapSessionsForShutdown(
|
|
91
|
+
sessions: Iterable<ShutdownReapable>,
|
|
92
|
+
onError: (name: string, err: unknown) => void,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
await Promise.all(
|
|
95
|
+
[...sessions].map(async (s) => {
|
|
96
|
+
s.channel.close();
|
|
97
|
+
await reapSessionQuery(s.currentQuery, (err) => onError(s.name, err));
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
}
|