mixdog 0.9.83 → 0.9.85
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/package.json +1 -1
- package/scripts/channel-daemon-stub.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +30 -3
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +38 -4
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +48 -32
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +25 -14
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +8 -2
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +38 -1
- package/src/runtime/agent/orchestrator/session/store.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +44 -73
- package/src/runtime/channels/lib/owned-runtime.mjs +6 -8
- package/src/runtime/channels/lib/parent-bridge.mjs +27 -15
- package/src/runtime/channels/lib/worker-ipc.mjs +17 -4
- package/src/runtime/memory/index.mjs +14 -5
- package/src/runtime/memory/lib/agent-ipc.mjs +11 -12
- package/src/runtime/shared/atomic-file.mjs +51 -24
- package/src/runtime/shared/err-text.mjs +1 -0
- package/src/runtime/shared/safe-ipc-send.mjs +34 -0
- package/src/session-runtime/lifecycle-api.mjs +9 -3
- package/src/session-runtime/runtime-core.mjs +5 -1
- package/src/standalone/channel-daemon.mjs +2 -1
- package/src/standalone/channel-worker.mjs +21 -19
- package/src/standalone/memory-runtime-proxy.mjs +98 -23
- package/src/tui/app/slash-dispatch.mjs +23 -13
- package/src/tui/app/theme-effort-pickers.mjs +3 -1
- package/src/tui/dist/index.mjs +115 -57
- package/src/tui/engine/live-share.mjs +2 -2
- package/src/tui/engine/session-flow.mjs +20 -10
- package/src/tui/engine/turn.mjs +28 -16
- package/src/tui/engine.mjs +17 -1
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import os from 'node:os';
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { claimSingletonOwner, releaseSingletonOwner } from '../src/runtime/shared/singleton-owner.mjs';
|
|
13
|
+
import { safeIpcSend } from '../src/runtime/shared/safe-ipc-send.mjs';
|
|
13
14
|
import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
|
|
14
15
|
|
|
15
16
|
function runtimeRoot() {
|
|
@@ -69,7 +70,7 @@ async function main() {
|
|
|
69
70
|
onClientsEmpty: () => { void shutdown('no live clients'); },
|
|
70
71
|
});
|
|
71
72
|
const { port, token } = await transport.start();
|
|
72
|
-
|
|
73
|
+
safeIpcSend(process, { type: 'ready', port, token });
|
|
73
74
|
log(`ready port=${port} pid=${process.pid}`);
|
|
74
75
|
}
|
|
75
76
|
|
|
@@ -230,19 +230,46 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
230
230
|
}
|
|
231
231
|
let totalCount = 0;
|
|
232
232
|
let totalTextLen = 0;
|
|
233
|
+
let maxQueueWaitMs = 0;
|
|
233
234
|
for (const merged of mergedMessages) {
|
|
235
|
+
const submissionIds = Array.isArray(merged.ids) ? merged.ids : [];
|
|
236
|
+
const submittedAt = Number(merged.submittedAt);
|
|
237
|
+
const injectedAt = Date.now();
|
|
238
|
+
if (Number.isFinite(submittedAt) && submittedAt > 0) {
|
|
239
|
+
maxQueueWaitMs = Math.max(maxQueueWaitMs, injectedAt - submittedAt);
|
|
240
|
+
}
|
|
234
241
|
// Tag steering-origin user messages so provider lowering keeps them
|
|
235
242
|
// distinct from preceding tool results. Keep each queued command as
|
|
236
243
|
// its own user turn, matching Claude Code queued_command attachment
|
|
237
244
|
// semantics instead of collapsing priority/mode buckets together.
|
|
238
|
-
messages.push({
|
|
245
|
+
messages.push({
|
|
246
|
+
role: 'user',
|
|
247
|
+
content: merged.content,
|
|
248
|
+
meta: {
|
|
249
|
+
source: 'steering',
|
|
250
|
+
...(submissionIds.length ? { submissionIds } : {}),
|
|
251
|
+
},
|
|
252
|
+
});
|
|
239
253
|
const text = merged.text || steeringContentText(merged.content);
|
|
240
254
|
totalCount += Number(merged.count) || 1;
|
|
241
255
|
totalTextLen += String(text || '').length;
|
|
242
|
-
try {
|
|
256
|
+
try {
|
|
257
|
+
opts.onSteerMessage?.(text, {
|
|
258
|
+
ids: submissionIds,
|
|
259
|
+
submittedAt: Number.isFinite(submittedAt) && submittedAt > 0 ? submittedAt : undefined,
|
|
260
|
+
injectedAt,
|
|
261
|
+
stage,
|
|
262
|
+
...(Array.isArray(merged.images) && merged.images.length ? { images: merged.images } : {}),
|
|
263
|
+
});
|
|
264
|
+
} catch {}
|
|
243
265
|
}
|
|
244
266
|
if (sessionId) {
|
|
245
|
-
try {
|
|
267
|
+
try {
|
|
268
|
+
process.stderr.write(
|
|
269
|
+
`[steer] sess=${sessionId} injected ${stage} user message(s)`
|
|
270
|
+
+ ` (merged=${totalCount} len=${totalTextLen} waitMs=${Math.max(0, maxQueueWaitMs)})\n`,
|
|
271
|
+
);
|
|
272
|
+
} catch {}
|
|
246
273
|
}
|
|
247
274
|
return true;
|
|
248
275
|
};
|
|
@@ -14,21 +14,48 @@ export function steeringContentText(content) {
|
|
|
14
14
|
return String(content ?? '');
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function steeringEntryMetadata(entry) {
|
|
18
|
+
if (!entry || typeof entry !== 'object') return {};
|
|
19
|
+
const sourceIds = Array.isArray(entry.ids)
|
|
20
|
+
? entry.ids
|
|
21
|
+
: (entry.id !== undefined && entry.id !== null ? [entry.id] : []);
|
|
22
|
+
const ids = [...new Set(sourceIds.filter((id) => id !== undefined && id !== null))];
|
|
23
|
+
const submittedAt = Number(entry.submittedAt);
|
|
24
|
+
return {
|
|
25
|
+
...(ids.length ? { ids } : {}),
|
|
26
|
+
...(Number.isFinite(submittedAt) && submittedAt > 0 ? { submittedAt } : {}),
|
|
27
|
+
...(Array.isArray(entry.images) && entry.images.length ? { images: entry.images } : {}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
function normalizeSteeringEntry(entry) {
|
|
18
32
|
if (typeof entry === 'string') {
|
|
19
33
|
const text = entry.trim();
|
|
20
34
|
return text ? { content: text, text } : null;
|
|
21
35
|
}
|
|
22
36
|
if (!entry || typeof entry !== 'object') return null;
|
|
37
|
+
const metadata = steeringEntryMetadata(entry);
|
|
23
38
|
const content = Object.prototype.hasOwnProperty.call(entry, 'content') ? entry.content : entry;
|
|
24
39
|
const text = typeof entry.text === 'string' ? entry.text.trim() : steeringContentText(content).trim();
|
|
25
|
-
if (Array.isArray(content)) return content.length > 0 ? { content, text } : null;
|
|
40
|
+
if (Array.isArray(content)) return content.length > 0 ? { content, text, ...metadata } : null;
|
|
26
41
|
if (typeof content === 'string') {
|
|
27
42
|
const value = content.trim();
|
|
28
|
-
return value ? { content: value, text: text || value } : null;
|
|
43
|
+
return value ? { content: value, text: text || value, ...metadata } : null;
|
|
29
44
|
}
|
|
30
45
|
const fallback = steeringContentText(content).trim();
|
|
31
|
-
return fallback ? { content: fallback, text: text || fallback } : null;
|
|
46
|
+
return fallback ? { content: fallback, text: text || fallback, ...metadata } : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function mergeSteeringMetadata(entries) {
|
|
50
|
+
const ids = [...new Set(entries.flatMap((entry) => Array.isArray(entry.ids) ? entry.ids : []))];
|
|
51
|
+
const submittedTimes = entries.map((entry) => Number(entry.submittedAt))
|
|
52
|
+
.filter((value) => Number.isFinite(value) && value > 0);
|
|
53
|
+
const images = entries.flatMap((entry) => Array.isArray(entry.images) ? entry.images : []);
|
|
54
|
+
return {
|
|
55
|
+
...(ids.length ? { ids } : {}),
|
|
56
|
+
...(submittedTimes.length ? { submittedAt: Math.min(...submittedTimes) } : {}),
|
|
57
|
+
...(images.length ? { images } : {}),
|
|
58
|
+
};
|
|
32
59
|
}
|
|
33
60
|
|
|
34
61
|
export function mergeSteeringEntries(entries) {
|
|
@@ -36,6 +63,7 @@ export function mergeSteeringEntries(entries) {
|
|
|
36
63
|
.map(normalizeSteeringEntry)
|
|
37
64
|
.filter(Boolean);
|
|
38
65
|
if (normalized.length === 0) return null;
|
|
66
|
+
const metadata = mergeSteeringMetadata(normalized);
|
|
39
67
|
const displayText = normalized.map((entry) => entry.text || steeringContentText(entry.content))
|
|
40
68
|
.filter((text) => String(text || '').trim())
|
|
41
69
|
.join('\n');
|
|
@@ -44,6 +72,7 @@ export function mergeSteeringEntries(entries) {
|
|
|
44
72
|
content: normalized.map((entry) => entry.content).filter(Boolean).join('\n'),
|
|
45
73
|
text: displayText,
|
|
46
74
|
count: normalized.length,
|
|
75
|
+
...metadata,
|
|
47
76
|
};
|
|
48
77
|
}
|
|
49
78
|
const parts = [];
|
|
@@ -59,5 +88,10 @@ export function mergeSteeringEntries(entries) {
|
|
|
59
88
|
parts.push({ type: 'text', text: '\n' });
|
|
60
89
|
}
|
|
61
90
|
while (parts.length && parts[parts.length - 1]?.type === 'text' && parts[parts.length - 1]?.text === '\n') parts.pop();
|
|
62
|
-
return {
|
|
91
|
+
return {
|
|
92
|
+
content: parts,
|
|
93
|
+
text: displayText || steeringContentText(parts),
|
|
94
|
+
count: normalized.length,
|
|
95
|
+
...metadata,
|
|
96
|
+
};
|
|
63
97
|
}
|
|
@@ -307,6 +307,40 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
307
307
|
let _turnOutgoing = null;
|
|
308
308
|
const _turnInterruption = createTurnInterruptionTracker();
|
|
309
309
|
const _sessionStartMetaInjectedBeforeTurn = preSession.sessionStartMetaInjected === true;
|
|
310
|
+
let _interruptionSnapshot = null;
|
|
311
|
+
const _prepareCloseSnapshot = (abortReason) => {
|
|
312
|
+
if (_interruptionSnapshot) return _interruptionSnapshot;
|
|
313
|
+
if (!activeSession) return null;
|
|
314
|
+
activeSession.liveTurnMessages = null;
|
|
315
|
+
_turnInterruption.restoreTombstonedText();
|
|
316
|
+
const finalized = _turnInterruption.finalize({
|
|
317
|
+
turnOutgoing: _turnOutgoing || activeSession.messages,
|
|
318
|
+
currentUserContent: cancelledUserTurnContent,
|
|
319
|
+
abortReason,
|
|
320
|
+
});
|
|
321
|
+
activeSession.messages = finalized.messages;
|
|
322
|
+
if (!finalized.responsePreserved) {
|
|
323
|
+
if (finalized.userTurnPreserved) {
|
|
324
|
+
// A non-user detach keeps the provisional prompt but makes
|
|
325
|
+
// the opaque provider continuation unsafe to reuse.
|
|
326
|
+
activeSession.providerState = undefined;
|
|
327
|
+
} else {
|
|
328
|
+
activeSession.sessionStartMetaInjected = _sessionStartMetaInjectedBeforeTurn;
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
activeSession.providerState = undefined;
|
|
332
|
+
}
|
|
333
|
+
activeSession.updatedAt = Date.now();
|
|
334
|
+
activeSession.lastUsedAt = Date.now();
|
|
335
|
+
runtime.session = activeSession;
|
|
336
|
+
_interruptionSnapshot = finalized;
|
|
337
|
+
return finalized;
|
|
338
|
+
};
|
|
339
|
+
// closeSession is synchronous and generation-first by design. Expose a
|
|
340
|
+
// turn-local hook so it can canonicalize the in-flight transcript
|
|
341
|
+
// before bumpSessionGeneration()/markSessionClosed() writes the disk
|
|
342
|
+
// snapshot and invalidates the ordinary cancellation cleanup save.
|
|
343
|
+
runtime.prepareCloseSnapshot = _prepareCloseSnapshot;
|
|
310
344
|
try {
|
|
311
345
|
const session = activeSession;
|
|
312
346
|
const provider = getProvider(session.provider);
|
|
@@ -672,6 +706,12 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
672
706
|
process.stderr.write(`[session] empty-final persisted sessionId=${sessionId} stopReason=${_emptyStop ?? 'unknown'} iterations=${result?.iterations ?? 0} toolCallsTotal=${result?.toolCallsTotal ?? 0} outTokens=${_emptyUsage?.outputTokens ?? 0} hasThinking=${_thinkingStr} blockTypes=${_blockTypesStr}\n`);
|
|
673
707
|
} catch {}
|
|
674
708
|
}
|
|
709
|
+
// The terminal assistant message is now canonical. A close racing
|
|
710
|
+
// any later await should persist this committed session as-is,
|
|
711
|
+
// never re-finalize the pre-terminal outgoing array.
|
|
712
|
+
if (runtime.prepareCloseSnapshot === _prepareCloseSnapshot) {
|
|
713
|
+
runtime.prepareCloseSnapshot = null;
|
|
714
|
+
}
|
|
675
715
|
session.updatedAt = Date.now();
|
|
676
716
|
session.lastUsedAt = Date.now();
|
|
677
717
|
applyAskTerminalUsageTotals(session, result, {
|
|
@@ -840,34 +880,15 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
840
880
|
const currentRuntime = _getRuntimeEntry(sessionId);
|
|
841
881
|
if (!currentRuntime?.closed) {
|
|
842
882
|
if (activeSession) {
|
|
843
|
-
const finalized =
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
});
|
|
848
|
-
activeSession.messages = finalized.messages;
|
|
883
|
+
const finalized = _prepareCloseSnapshot(err.reason);
|
|
884
|
+
if (currentRuntime?.prepareCloseSnapshot === _prepareCloseSnapshot) {
|
|
885
|
+
currentRuntime.prepareCloseSnapshot = null;
|
|
886
|
+
}
|
|
849
887
|
if (!finalized.responsePreserved) {
|
|
850
888
|
releasePendingMessages(sessionId, _turnPendingEntries);
|
|
851
|
-
if (finalized.userTurnPreserved) {
|
|
852
|
-
// Non-user abort (app quit / engine dispose /
|
|
853
|
-
// watchdog): the just-sent user turn stays in
|
|
854
|
-
// history, so the session-start meta it carries
|
|
855
|
-
// remains consumed, and the opaque provider
|
|
856
|
-
// continuation no longer matches — force full
|
|
857
|
-
// transcript replay on the next send.
|
|
858
|
-
activeSession.providerState = undefined;
|
|
859
|
-
} else {
|
|
860
|
-
activeSession.sessionStartMetaInjected = _sessionStartMetaInjectedBeforeTurn;
|
|
861
|
-
}
|
|
862
889
|
} else {
|
|
863
890
|
recordPendingMessageDelivery(activeSession, _turnPendingEntries);
|
|
864
|
-
// The opaque provider continuation now points at a
|
|
865
|
-
// request that ended mid-turn. Force full transcript
|
|
866
|
-
// replay on the next send instead of reusing it.
|
|
867
|
-
activeSession.providerState = undefined;
|
|
868
891
|
}
|
|
869
|
-
activeSession.updatedAt = Date.now();
|
|
870
|
-
activeSession.lastUsedAt = Date.now();
|
|
871
892
|
try {
|
|
872
893
|
const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
|
|
873
894
|
if (finalized.responsePreserved) {
|
|
@@ -887,6 +908,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
887
908
|
// can render it as "cancelled" rather than a red failure.
|
|
888
909
|
throw err;
|
|
889
910
|
}
|
|
911
|
+
if (runtime.prepareCloseSnapshot === _prepareCloseSnapshot) {
|
|
912
|
+
runtime.prepareCloseSnapshot = null;
|
|
913
|
+
}
|
|
890
914
|
// A reset acknowledgement removes the live partial before the
|
|
891
915
|
// non-streaming request starts. If that restart fails, restore the
|
|
892
916
|
// tombstone and persist the one exposed partial as interruption
|
|
@@ -895,17 +919,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
895
919
|
|| err?.liveTextEmitted === true
|
|
896
920
|
|| err?.unsafeToRetry === true;
|
|
897
921
|
if (preserveProviderPartial && activeSession && _turnInterruption.hasResponseStarted()) {
|
|
898
|
-
const finalized =
|
|
899
|
-
turnOutgoing: _turnOutgoing || activeSession.messages,
|
|
900
|
-
currentUserContent: cancelledUserTurnContent,
|
|
901
|
-
abortReason: 'provider-error',
|
|
902
|
-
});
|
|
903
|
-
activeSession.messages = finalized.messages;
|
|
922
|
+
const finalized = _prepareCloseSnapshot('provider-error');
|
|
904
923
|
if (finalized.responsePreserved) recordPendingMessageDelivery(activeSession, _turnPendingEntries);
|
|
905
924
|
else releasePendingMessages(sessionId, _turnPendingEntries);
|
|
906
|
-
activeSession.providerState = undefined;
|
|
907
|
-
activeSession.updatedAt = Date.now();
|
|
908
|
-
activeSession.lastUsedAt = Date.now();
|
|
909
925
|
try {
|
|
910
926
|
const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
|
|
911
927
|
if (finalized.responsePreserved) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Periodic idle-session + tombstone sweep extracted verbatim from manager.mjs.
|
|
3
3
|
// Drives sweepStaleSessions on an unref'd interval; closeSession is imported
|
|
4
4
|
// from session-close.mjs (one-way dependency, no cycle).
|
|
5
|
-
import { sweepStaleSessions, evictIdleLiveSessions } from '../store.mjs';
|
|
5
|
+
import { sweepStaleSessions, sweepStaleSessionsCooperative, evictIdleLiveSessions } from '../store.mjs';
|
|
6
6
|
import { sweepOrphanedPendingMessages } from './pending-messages.mjs';
|
|
7
7
|
import {
|
|
8
8
|
_getRuntimeEntry,
|
|
@@ -23,6 +23,7 @@ const CLEANUP_SLOW_LOG_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_SLOW_LOG_M
|
|
|
23
23
|
const TOMBSTONE_MAX_AGE_MS = 60 * 60 * 1000; // 1h
|
|
24
24
|
let _cleanupTimer = null;
|
|
25
25
|
let _cleanupInitialTimer = null;
|
|
26
|
+
let _cleanupRun = null;
|
|
26
27
|
|
|
27
28
|
// A session is "live" when it still owns a non-closed runtime entry. Passed to
|
|
28
29
|
// the retention cap so the active/current and any in-flight session is never
|
|
@@ -65,10 +66,10 @@ const _sweepLog = (line) => {
|
|
|
65
66
|
if (process.env.MIXDOG_DEBUG_SESSION_LOG) process.stderr.write(line);
|
|
66
67
|
};
|
|
67
68
|
|
|
68
|
-
function sweepIdleSessions({ includeTombstones = true, sweepIdle = true } = {}) {
|
|
69
|
+
async function sweepIdleSessions({ includeTombstones = true, sweepIdle = true } = {}) {
|
|
69
70
|
const startedAt = Date.now();
|
|
70
71
|
try {
|
|
71
|
-
const result =
|
|
72
|
+
const result = await sweepStaleSessionsCooperative({
|
|
72
73
|
sweepIdle,
|
|
73
74
|
tombstoneMaxAgeMs: includeTombstones ? TOMBSTONE_MAX_AGE_MS : 0,
|
|
74
75
|
isSessionLive: _isSessionLive,
|
|
@@ -144,33 +145,43 @@ export function sweepTombstones() {
|
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
export function _runCleanupCycle() {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
148
|
+
if (_cleanupRun) return _cleanupRun;
|
|
149
|
+
const run = (async () => {
|
|
150
|
+
// Drain every settled runtime entry on each pass, not just the one or two
|
|
151
|
+
// sessions whose on-disk idle TTL happened to expire in this interval.
|
|
152
|
+
_sweepTerminalSessionRuntimes();
|
|
153
|
+
sweepOrphanedPendingMessages();
|
|
154
|
+
await sweepIdleSessions({ includeTombstones: true });
|
|
155
|
+
// Reclaim same-process session snapshots whose state is durable on disk
|
|
156
|
+
// (memory-leak guard: _liveSessions used to grow for process lifetime).
|
|
157
|
+
try { evictIdleLiveSessions({ isSessionLive: _isSessionLive }); } catch { /* best-effort */ }
|
|
158
|
+
})().catch((error) => {
|
|
159
|
+
try { process.stderr.write(`[agent-session] cleanup cycle failed: ${error?.message || error}\n`); } catch {}
|
|
160
|
+
});
|
|
161
|
+
const tracked = run.finally(() => {
|
|
162
|
+
if (_cleanupRun === tracked) _cleanupRun = null;
|
|
163
|
+
});
|
|
164
|
+
_cleanupRun = tracked;
|
|
165
|
+
return tracked;
|
|
155
166
|
}
|
|
156
167
|
|
|
157
168
|
function _startCleanupInterval() {
|
|
158
169
|
if (_cleanupTimer) return;
|
|
159
170
|
if (CLEANUP_INTERVAL_MS <= 0) return;
|
|
160
|
-
_cleanupTimer = setInterval(_runCleanupCycle, CLEANUP_INTERVAL_MS);
|
|
171
|
+
_cleanupTimer = setInterval(() => { void _runCleanupCycle(); }, CLEANUP_INTERVAL_MS);
|
|
161
172
|
if (_cleanupTimer.unref) _cleanupTimer.unref(); // don't block process exit
|
|
162
173
|
}
|
|
163
174
|
|
|
164
175
|
export function startIdleCleanup() {
|
|
165
176
|
if (_cleanupTimer || _cleanupInitialTimer) return;
|
|
166
177
|
if (CLEANUP_INITIAL_DELAY_MS <= 0) {
|
|
167
|
-
_runCleanupCycle();
|
|
178
|
+
void _runCleanupCycle();
|
|
168
179
|
_startCleanupInterval();
|
|
169
180
|
return;
|
|
170
181
|
}
|
|
171
182
|
_cleanupInitialTimer = setTimeout(() => {
|
|
172
183
|
_cleanupInitialTimer = null;
|
|
173
|
-
_runCleanupCycle();
|
|
184
|
+
void _runCleanupCycle();
|
|
174
185
|
_startCleanupInterval();
|
|
175
186
|
}, CLEANUP_INITIAL_DELAY_MS);
|
|
176
187
|
if (_cleanupInitialTimer.unref) _cleanupInitialTimer.unref();
|
|
@@ -37,10 +37,17 @@ export function closeSession(id, reason = 'manual', opts = {}) {
|
|
|
37
37
|
// resuming. Only truly-empty scratch sessions should still tombstone.
|
|
38
38
|
const tombstone = opts.tombstone !== false;
|
|
39
39
|
if (!id) return false;
|
|
40
|
+
const entry = _getRuntimeEntry(id);
|
|
41
|
+
// askSession owns the interruption tracker that can merge committed
|
|
42
|
+
// iterations, the current partial response, and observed tool results into
|
|
43
|
+
// one canonical transcript. Finalize that in-memory snapshot synchronously
|
|
44
|
+
// BEFORE the lifecycle generation changes; once generation is bumped, the
|
|
45
|
+
// normal cancellation cleanup save is intentionally rejected as stale.
|
|
46
|
+
try { entry?.prepareCloseSnapshot?.(reason); } catch { /* best-effort */ }
|
|
40
47
|
_stopToolActivityHeartbeat(id);
|
|
41
48
|
// Prefer in-memory runtime session — allBashSessionIds may not be persisted
|
|
42
49
|
// yet for shells opened in the current turn (BL-bash-disk-sync).
|
|
43
|
-
const inMemory =
|
|
50
|
+
const inMemory = entry?.session;
|
|
44
51
|
const persisted = inMemory || loadSession(id);
|
|
45
52
|
const bashSessionId = persisted?.implicitBashSessionId || null;
|
|
46
53
|
// Collect all persistent bash shells created during this session.
|
|
@@ -63,7 +70,6 @@ export function closeSession(id, reason = 'manual', opts = {}) {
|
|
|
63
70
|
// it back (BL: burned-session late-save clobber).
|
|
64
71
|
const newGen = tombstone ? markSessionClosed(id, reason) : bumpSessionGeneration(id, reason);
|
|
65
72
|
// 2. Mark runtime as closed so post-await validation in askSession fires.
|
|
66
|
-
const entry = _getRuntimeEntry(id);
|
|
67
73
|
if (entry) {
|
|
68
74
|
entry.closed = true;
|
|
69
75
|
entry.closedReason = reason;
|
|
@@ -244,7 +244,7 @@ export function getStoredSessionsRaw() {
|
|
|
244
244
|
* Background sweep: delete session files idle longer than ttlMs.
|
|
245
245
|
* Returns { cleaned, remaining, details } for logging.
|
|
246
246
|
*/
|
|
247
|
-
|
|
247
|
+
function* sweepStaleSessionSteps(ttlMs, options = {}) {
|
|
248
248
|
if (ttlMs && typeof ttlMs === 'object') {
|
|
249
249
|
options = ttlMs;
|
|
250
250
|
ttlMs = options.ttlMs;
|
|
@@ -302,6 +302,9 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
302
302
|
let openPruned = 0;
|
|
303
303
|
const openPrunedDetails = [];
|
|
304
304
|
for (const row of summaries) {
|
|
305
|
+
// Cooperative callers pause between records so large stores never hold
|
|
306
|
+
// an interactive host's event loop for the full directory scan.
|
|
307
|
+
yield undefined;
|
|
305
308
|
try {
|
|
306
309
|
if (!row?.id) continue;
|
|
307
310
|
const jsonPath = sessionPath(row.id);
|
|
@@ -593,6 +596,7 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
593
596
|
// session mid-create whose .json write has not landed yet.
|
|
594
597
|
try {
|
|
595
598
|
for (const h of readdirSync(dir).filter(f => f.endsWith('.hb') || f.endsWith('.own'))) {
|
|
599
|
+
yield undefined;
|
|
596
600
|
if (existsSync(join(dir, h.replace(/\.(hb|own)$/, '.json')))) continue;
|
|
597
601
|
let hbMtime = 0;
|
|
598
602
|
try { hbMtime = statSync(join(dir, h)).mtimeMs; } catch { continue; }
|
|
@@ -613,3 +617,36 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
613
617
|
}
|
|
614
618
|
return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors, openPruned, openPrunedDetails };
|
|
615
619
|
}
|
|
620
|
+
|
|
621
|
+
/** Synchronous compatibility surface for explicit maintenance commands/tests. */
|
|
622
|
+
export function sweepStaleSessions(ttlMs, options = {}) {
|
|
623
|
+
const steps = sweepStaleSessionSteps(ttlMs, options);
|
|
624
|
+
let next = steps.next();
|
|
625
|
+
while (!next.done) next = steps.next();
|
|
626
|
+
return next.value;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Interactive-host sweep: preserve the exact synchronous lifecycle decisions
|
|
631
|
+
* while yielding between records. A single large session remains atomic, but a
|
|
632
|
+
* directory worth of reads/parses can no longer become one multi-second task.
|
|
633
|
+
*/
|
|
634
|
+
export async function sweepStaleSessionsCooperative(ttlMs, options = {}) {
|
|
635
|
+
const cooperativeOptions = ttlMs && typeof ttlMs === 'object' ? ttlMs : options;
|
|
636
|
+
const configuredSliceMs = Number(cooperativeOptions?.cooperativeSliceMs);
|
|
637
|
+
const sliceMs = Number.isFinite(configuredSliceMs)
|
|
638
|
+
? Math.min(50, Math.max(0, configuredSliceMs))
|
|
639
|
+
: 8;
|
|
640
|
+
const steps = sweepStaleSessionSteps(ttlMs, options);
|
|
641
|
+
let next = steps.next();
|
|
642
|
+
while (!next.done) {
|
|
643
|
+
const sliceStartedAt = performance.now();
|
|
644
|
+
do {
|
|
645
|
+
next = steps.next();
|
|
646
|
+
} while (!next.done && performance.now() - sliceStartedAt < sliceMs);
|
|
647
|
+
if (!next.done) {
|
|
648
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return next.value;
|
|
652
|
+
}
|
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
import { spawnSync } from 'child_process';
|
|
2
1
|
import { existsSync } from 'fs';
|
|
3
|
-
import { basename, dirname, join } from 'path';
|
|
2
|
+
import { basename, delimiter, dirname, join } from 'path';
|
|
4
3
|
|
|
5
4
|
let _resolvedShell = null;
|
|
6
5
|
let _configuredShell = null;
|
|
7
6
|
// Per-kind cache for resolveShellFor(). 'default' aliases resolveShell()'s
|
|
8
|
-
// singleton; 'bash'/'powershell' get their own memoized slots.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// be installed mid-session, and PATH can change for a long-lived host).
|
|
7
|
+
// singleton; 'bash'/'powershell' get their own memoized slots. Misses receive
|
|
8
|
+
// a short TTL so a missing optional shell cannot rescan PATH for every tool
|
|
9
|
+
// call while a long-lived host can still discover a later installation.
|
|
12
10
|
const _resolvedShellByKind = new Map();
|
|
11
|
+
const SHELL_RESOLUTION_MISS_TTL_MS = 30_000;
|
|
13
12
|
|
|
14
13
|
export function setConfiguredShell(value = '') {
|
|
15
14
|
const next = String(value || '').trim();
|
|
@@ -43,30 +42,39 @@ function shellSpec(shell, shellType = shellTypeFor(shell)) {
|
|
|
43
42
|
return { shell, shellArg: '-c', shellArgs: ['-c'], shellType };
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
45
|
+
function allExistingPathsFromPath(commandName) {
|
|
46
|
+
const rawPath = String(process.env.PATH || '');
|
|
47
|
+
let cwd = '';
|
|
48
|
+
try { cwd = process.cwd(); } catch {}
|
|
49
|
+
const entries = [cwd, ...rawPath.split(delimiter)]
|
|
50
|
+
.map((entry) => entry.trim().replace(/^"(.*)"$/, '$1'))
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const matches = [];
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const candidate = join(entry, commandName);
|
|
56
|
+
const key = process.platform === 'win32' ? candidate.toLowerCase() : candidate;
|
|
57
|
+
if (seen.has(key)) continue;
|
|
58
|
+
seen.add(key);
|
|
59
|
+
if (existsSync(candidate)) matches.push(candidate);
|
|
58
60
|
}
|
|
61
|
+
return matches;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function firstExistingPathFromPath(commandName, excludeRe = null) {
|
|
65
|
+
return allExistingPathsFromPath(commandName)
|
|
66
|
+
.find((candidate) => !excludeRe || !excludeRe.test(candidate)) || null;
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
function resolveWindowsPowerShell() {
|
|
62
|
-
const pwsh =
|
|
70
|
+
const pwsh = firstExistingPathFromPath('pwsh.exe');
|
|
63
71
|
if (pwsh) return shellSpec(pwsh, 'powershell');
|
|
64
72
|
|
|
65
73
|
const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
|
|
66
74
|
const bundled = join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
67
75
|
if (existsSync(bundled)) return shellSpec(bundled, 'powershell');
|
|
68
76
|
|
|
69
|
-
const powershell =
|
|
77
|
+
const powershell = firstExistingPathFromPath('powershell.exe');
|
|
70
78
|
if (powershell) return shellSpec(powershell, 'powershell');
|
|
71
79
|
|
|
72
80
|
return shellSpec('powershell.exe', 'powershell');
|
|
@@ -109,12 +117,12 @@ function _isWindows() {
|
|
|
109
117
|
// not Git-for-Windows bash).
|
|
110
118
|
// Returns a posix shellSpec, or null when Git Bash is genuinely not installed.
|
|
111
119
|
function resolveWindowsGitBash() {
|
|
112
|
-
for (const git of
|
|
120
|
+
for (const git of allExistingPathsFromPath('git.exe')) {
|
|
113
121
|
const bash = probeGitBashFromGitExe(git);
|
|
114
122
|
if (bash) return shellSpec(bash, 'posix');
|
|
115
123
|
}
|
|
116
124
|
// Fallback: a bare `bash.exe` on PATH, but never the System32 WSL launcher.
|
|
117
|
-
const bash =
|
|
125
|
+
const bash = firstExistingPathFromPath('bash.exe', /\\system32\\/i);
|
|
118
126
|
if (bash) return shellSpec(bash, 'posix');
|
|
119
127
|
// Final fallback: probe well-known Git-for-Windows install roots on the
|
|
120
128
|
// filesystem directly. PATH-independent and spawn-free, so it still resolves
|
|
@@ -164,36 +172,6 @@ function probeGitBashFromGitExe(gitExe) {
|
|
|
164
172
|
return null;
|
|
165
173
|
}
|
|
166
174
|
|
|
167
|
-
function allExistingPathsFromWhere(commandName) {
|
|
168
|
-
try {
|
|
169
|
-
const r = spawnSync('cmd.exe', ['/d', '/s', '/c', `where ${commandName}`], {
|
|
170
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
171
|
-
windowsHide: true,
|
|
172
|
-
timeout: 1000,
|
|
173
|
-
});
|
|
174
|
-
if (r.status !== 0 || !r.stdout) return [];
|
|
175
|
-
return r.stdout.toString('utf8').split(/\r?\n/).map(s => s.trim())
|
|
176
|
-
.filter(Boolean).filter(p => existsSync(p));
|
|
177
|
-
} catch {
|
|
178
|
-
return [];
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function firstExistingPathFromWhereExcluding(commandName, excludeRe) {
|
|
183
|
-
try {
|
|
184
|
-
const r = spawnSync('cmd.exe', ['/d', '/s', '/c', `where ${commandName}`], {
|
|
185
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
186
|
-
windowsHide: true,
|
|
187
|
-
timeout: 1000,
|
|
188
|
-
});
|
|
189
|
-
if (r.status !== 0 || !r.stdout) return null;
|
|
190
|
-
const lines = r.stdout.toString('utf8').split(/\r?\n/).map(s => s.trim()).filter(Boolean);
|
|
191
|
-
return lines.find(p => existsSync(p) && !excludeRe.test(p)) || null;
|
|
192
|
-
} catch {
|
|
193
|
-
return null;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
175
|
// Kind-aware shell resolution. kind:
|
|
198
176
|
//
|
|
199
177
|
// Resolve a real bash on macOS/Linux. When 'bash' is explicitly requested we
|
|
@@ -204,13 +182,8 @@ function resolvePosixBash() {
|
|
|
204
182
|
for (const p of ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash', '/opt/homebrew/bin/bash']) {
|
|
205
183
|
if (existsSync(p)) return shellSpec(p, 'posix');
|
|
206
184
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (r.status === 0 && r.stdout) {
|
|
210
|
-
const p = r.stdout.toString('utf8').split(/\r?\n/).map(s => s.trim()).find(Boolean);
|
|
211
|
-
if (p && existsSync(p)) return shellSpec(p, 'posix');
|
|
212
|
-
}
|
|
213
|
-
} catch { /* fall through to /bin/sh */ }
|
|
185
|
+
const fromPath = firstExistingPathFromPath('bash');
|
|
186
|
+
if (fromPath) return shellSpec(fromPath, 'posix');
|
|
214
187
|
return shellSpec('/bin/sh', 'posix');
|
|
215
188
|
}
|
|
216
189
|
|
|
@@ -220,12 +193,15 @@ function resolvePosixBash() {
|
|
|
220
193
|
// binary (/bin/bash, /usr/bin/bash, or `bash` on PATH), falling back to
|
|
221
194
|
// /bin/sh only when no bash exists (dash/ash distros break on bash syntax).
|
|
222
195
|
// 'powershell' → on Windows, resolveShell(); elsewhere pwsh if present, else null.
|
|
223
|
-
// Each kind is memoized independently
|
|
224
|
-
//
|
|
225
|
-
// next call rather than pinned for the life of the process.
|
|
196
|
+
// Each kind is memoized independently. Successes remain stable for the process;
|
|
197
|
+
// misses expire quickly so installs/PATH changes remain discoverable.
|
|
226
198
|
export function resolveShellFor(kind = 'default') {
|
|
227
199
|
if (kind == null || kind === 'default') return resolveShell();
|
|
228
|
-
|
|
200
|
+
const cached = _resolvedShellByKind.get(kind);
|
|
201
|
+
if (cached) {
|
|
202
|
+
if (cached.spec || cached.expiresAt > Date.now()) return cached.spec;
|
|
203
|
+
_resolvedShellByKind.delete(kind);
|
|
204
|
+
}
|
|
229
205
|
|
|
230
206
|
let spec = null;
|
|
231
207
|
if (kind === 'bash') {
|
|
@@ -234,21 +210,16 @@ export function resolveShellFor(kind = 'default') {
|
|
|
234
210
|
if (_isWindows()) {
|
|
235
211
|
spec = resolveShell();
|
|
236
212
|
} else {
|
|
237
|
-
const pwsh = (
|
|
238
|
-
try {
|
|
239
|
-
const r = spawnSync('which', ['pwsh'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000 });
|
|
240
|
-
if (r.status !== 0 || !r.stdout) return null;
|
|
241
|
-
const p = r.stdout.toString('utf8').split(/\r?\n/).map(s => s.trim()).find(Boolean);
|
|
242
|
-
return p && existsSync(p) ? p : null;
|
|
243
|
-
} catch { return null; }
|
|
244
|
-
})();
|
|
213
|
+
const pwsh = firstExistingPathFromPath('pwsh');
|
|
245
214
|
spec = pwsh ? shellSpec(pwsh, 'powershell') : null;
|
|
246
215
|
}
|
|
247
216
|
} else {
|
|
248
217
|
spec = resolveShell();
|
|
249
218
|
}
|
|
250
219
|
|
|
251
|
-
|
|
252
|
-
|
|
220
|
+
_resolvedShellByKind.set(kind, {
|
|
221
|
+
spec,
|
|
222
|
+
expiresAt: spec ? Number.POSITIVE_INFINITY : Date.now() + SHELL_RESOLUTION_MISS_TTL_MS,
|
|
223
|
+
});
|
|
253
224
|
return spec;
|
|
254
225
|
}
|