mixdog 0.9.19 → 0.9.21
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/LICENSE +21 -0
- package/README.md +100 -34
- package/package.json +3 -2
- package/scripts/build-tui.mjs +6 -0
- package/scripts/hook-bus-test.mjs +8 -0
- package/scripts/log-writer-guard-smoke.mjs +131 -0
- package/scripts/reactive-compact-persist-smoke.mjs +22 -6
- package/scripts/recall-bench-cases.json +0 -1
- package/scripts/recall-quality-cases.json +1 -2
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/tool-smoke.mjs +150 -45
- package/src/defaults/skills/setup/SKILL.md +327 -0
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3328
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
- package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
- package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
- package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
- package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
- package/src/runtime/channels/index.mjs +6 -2183
- package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
- package/src/runtime/channels/lib/network-retry.mjs +23 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
- package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
- package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
- package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
- package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
- package/src/runtime/channels/lib/worker-main.mjs +777 -0
- package/src/runtime/memory/index.mjs +163 -1725
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
- package/src/runtime/memory/lib/http-router.mjs +811 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
- package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
- package/src/runtime/memory/lib/memory-embed.mjs +28 -7
- package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
- package/src/runtime/memory/lib/memory.mjs +39 -0
- package/src/runtime/memory/lib/query-handlers.mjs +2 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- package/src/runtime/shared/atomic-file.mjs +138 -80
- package/src/runtime/shared/child-guardian.mjs +61 -3
- package/src/session-runtime/boot-profile.mjs +36 -0
- package/src/session-runtime/channel-config-api.mjs +70 -0
- package/src/session-runtime/context-status.mjs +181 -0
- package/src/session-runtime/env.mjs +17 -0
- package/src/session-runtime/lifecycle-api.mjs +242 -0
- package/src/session-runtime/model-route-api.mjs +198 -0
- package/src/session-runtime/provider-auth-api.mjs +135 -0
- package/src/session-runtime/resource-api.mjs +282 -0
- package/src/session-runtime/runtime-core.mjs +2104 -0
- package/src/session-runtime/session-turn-api.mjs +274 -0
- package/src/session-runtime/tool-catalog.mjs +18 -264
- package/src/session-runtime/tool-defs.mjs +2 -2
- package/src/session-runtime/workflow-agents-api.mjs +238 -0
- package/src/standalone/agent-tool.mjs +2 -2
- package/src/standalone/channel-worker.mjs +67 -7
- package/src/standalone/folder-dialog.mjs +4 -1
- package/src/standalone/memory-runtime-proxy.mjs +154 -17
- package/src/standalone/seeds.mjs +28 -1
- package/src/tui/App.jsx +40 -28
- package/src/tui/app/core-memory-picker.mjs +1 -1
- package/src/tui/app/doctor.mjs +175 -0
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +6 -0
- package/src/tui/app/use-transcript-scroll.mjs +77 -3
- package/src/tui/dist/index.mjs +2851 -2162
- package/src/tui/engine/context-state.mjs +145 -0
- package/src/tui/engine/prompt-history.mjs +27 -0
- package/src/tui/engine/session-api-ext.mjs +478 -0
- package/src/tui/engine/session-api.mjs +564 -0
- package/src/tui/engine/session-flow.mjs +485 -0
- package/src/tui/engine/turn.mjs +1078 -0
- package/src/tui/engine.mjs +68 -2620
- package/src/tui/index.jsx +7 -0
- package/vendor/ink/build/ink.js +16 -1
- package/vendor/ink/build/output.js +30 -4
- package/vendor/ink/build/render.js +5 -0
- package/src/workflows/sequential/WORKFLOW.md +0 -51
|
@@ -1,98 +1,44 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
} from './manager/
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
_buildLeadMetaContext,
|
|
43
|
-
_buildAgentSpecific,
|
|
44
|
-
} from './manager/rules-cache.mjs';
|
|
45
|
-
import {
|
|
46
|
-
applyToolPermissionNarrowing,
|
|
47
|
-
finalizeSessionToolList,
|
|
48
|
-
resolveSessionTools,
|
|
49
|
-
previewSessionTools,
|
|
50
|
-
permissionFromToolSpec,
|
|
51
|
-
} from './manager/tool-resolution.mjs';
|
|
52
|
-
import {
|
|
53
|
-
guessContextWindow,
|
|
54
|
-
positiveContextWindow,
|
|
55
|
-
preserveBufferConfigFields,
|
|
56
|
-
resolveSessionContextMeta,
|
|
57
|
-
compactTriggerForSession,
|
|
58
|
-
} from './manager/context-meta.mjs';
|
|
59
|
-
import {
|
|
60
|
-
promptContentText,
|
|
61
|
-
hasModelVisiblePromptContent,
|
|
62
|
-
promptContentBytes,
|
|
63
|
-
prefixUserTurnContent,
|
|
64
|
-
prefixSessionStartContent,
|
|
65
|
-
buildCurrentTimeBlock,
|
|
66
|
-
buildSessionStartBlock,
|
|
67
|
-
hasUserConversationMessage,
|
|
68
|
-
isInternalRuntimeNotificationText,
|
|
69
|
-
} from './manager/prompt-utils.mjs';
|
|
70
|
-
import {
|
|
71
|
-
_mergePendingMessageEntries,
|
|
72
|
-
enqueuePendingMessage,
|
|
73
|
-
drainPendingMessages,
|
|
74
|
-
_dropPendingMessageState,
|
|
75
|
-
sweepOrphanedPendingMessages,
|
|
76
|
-
} from './manager/pending-messages.mjs';
|
|
77
|
-
import {
|
|
78
|
-
bumpUsageMetricsTurnId,
|
|
79
|
-
resolveUsageMetricsTurnId,
|
|
80
|
-
bumpUsageMetricsEpoch,
|
|
81
|
-
resolveUsageMetricsEpoch,
|
|
82
|
-
usageMetricsSourceKey,
|
|
83
|
-
usageMetricsIdempotencyKey,
|
|
84
|
-
uncachedInputTokensForProvider,
|
|
85
|
-
applyAskTerminalUsageTotals,
|
|
86
|
-
persistIterationMetrics,
|
|
87
|
-
} from './manager/usage-metrics.mjs';
|
|
88
|
-
// Runtime-liveness map + accessors — extracted (pass-3) to
|
|
89
|
-
// manager/runtime-liveness.mjs. State is a module-level singleton there,
|
|
90
|
-
// preserving the baseline single-process shape. manager.mjs keeps askSession's
|
|
91
|
-
// controller/generation lifecycle and calls these via imports. The two
|
|
92
|
-
// _get/_entries accessors expose the private Map for in-place mutation from
|
|
93
|
-
// closeSession / idle-sweep / askSession.
|
|
94
|
-
import {
|
|
95
|
-
configureRuntimeLiveness,
|
|
1
|
+
// src/runtime/agent/orchestrator/session/manager.mjs
|
|
2
|
+
// Thin facade over the split session-manager modules in ./manager/. This file
|
|
3
|
+
// re-exports the EXACT public surface the previous monolith exposed so every
|
|
4
|
+
// importer (loop.mjs, loop/tool-exec.mjs, agent-dispatch.mjs, session-builder,
|
|
5
|
+
// abort-lookup, statusline-agents, headless-role, session-runtime, and the
|
|
6
|
+
// smoke scripts) keeps resolving its symbols through manager.mjs unchanged.
|
|
7
|
+
//
|
|
8
|
+
// Module map:
|
|
9
|
+
// manager/runtime-liveness.mjs — in-memory stage/heartbeat + accessors
|
|
10
|
+
// manager/tool-resolution.mjs — tool policy resolution/narrowing
|
|
11
|
+
// manager/context-meta.mjs — context-window / compact-trigger meta
|
|
12
|
+
// manager/prompt-utils.mjs — prompt content shaping helpers
|
|
13
|
+
// manager/pending-messages.mjs — pending-send queue (enqueue/drain)
|
|
14
|
+
// manager/usage-metrics.mjs — usage accounting / persistence
|
|
15
|
+
// manager/rules-cache.mjs — cached rule builders
|
|
16
|
+
// manager/status-telemetry.mjs — standalone status telemetry
|
|
17
|
+
// manager/compaction-runner.mjs — session compaction runner
|
|
18
|
+
// manager/session-errors.mjs — SessionClosedError
|
|
19
|
+
// manager/runtime-loaders.mjs — lazy code_graph/agent-loop/bash bridges
|
|
20
|
+
// manager/agent-runtime-singleton.mjs— injected Agent Runtime singleton
|
|
21
|
+
// manager/session-id.mjs — monotonic session-id minting
|
|
22
|
+
// manager/provider-cache-key.mjs — provider-scoped cache key
|
|
23
|
+
// manager/prefetch-bridge.mjs — explicit-prefetch bridge
|
|
24
|
+
// manager/message-sanitize.mjs — model-message sanitize + fail persist
|
|
25
|
+
// manager/session-lock.mjs — per-session ask mutex
|
|
26
|
+
// manager/session-lifecycle.mjs — createSession/updateRoute/resume
|
|
27
|
+
// manager/ask-session.mjs — askSession + abort-aware call wrapper
|
|
28
|
+
// manager/session-crud.mjs — read/clear/compact/status/flush
|
|
29
|
+
// manager/session-close.mjs — closeSession/abortSessionTurn
|
|
30
|
+
// manager/idle-cleanup.mjs — periodic idle/tombstone sweep
|
|
31
|
+
import { configureRuntimeLiveness } from './manager/runtime-liveness.mjs';
|
|
32
|
+
import { loadSession, saveSessionAsync } from './store.mjs';
|
|
33
|
+
|
|
34
|
+
// Wire the store deps the liveness module needs without importing store.mjs
|
|
35
|
+
// back into it via manager.mjs (avoids re-entry / keeps one store contract).
|
|
36
|
+
configureRuntimeLiveness({ loadSession, saveSessionAsync });
|
|
37
|
+
|
|
38
|
+
// ── Runtime-liveness surface ──────────────────────────────────────────────
|
|
39
|
+
// External importers (loop.mjs, loop/tool-exec.mjs, agent-dispatch.mjs,
|
|
40
|
+
// abort-lookup.mjs, statusline-agents.mjs) resolve these through manager.mjs.
|
|
41
|
+
export {
|
|
96
42
|
updateSessionStage,
|
|
97
43
|
markSessionAskStart,
|
|
98
44
|
markSessionStreamDelta,
|
|
@@ -109,44 +55,19 @@ import {
|
|
|
109
55
|
getSessionAbortSignal,
|
|
110
56
|
getSessionLastProgressAt,
|
|
111
57
|
linkParentSignalToSession,
|
|
112
|
-
_touchRuntime,
|
|
113
|
-
_stopToolActivityHeartbeat,
|
|
114
|
-
_unlinkParentAbortListener,
|
|
115
|
-
_clearSessionRuntime,
|
|
116
|
-
_getRuntimeEntry,
|
|
117
|
-
_runtimeEntries,
|
|
118
58
|
} from './manager/runtime-liveness.mjs';
|
|
119
|
-
|
|
120
|
-
//
|
|
121
|
-
|
|
59
|
+
|
|
60
|
+
// ── Tool resolution / pending messages / prompt utils ─────────────────────
|
|
61
|
+
export { previewSessionTools } from './manager/tool-resolution.mjs';
|
|
122
62
|
export {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
getSessionRuntime,
|
|
132
|
-
isSessionCompactionBlocked,
|
|
133
|
-
getSessionProgressSnapshot,
|
|
134
|
-
forEachSessionRuntime,
|
|
135
|
-
hideSessionFromList,
|
|
136
|
-
getSessionAbortSignal,
|
|
137
|
-
getSessionLastProgressAt,
|
|
138
|
-
linkParentSignalToSession,
|
|
139
|
-
};
|
|
140
|
-
// Wire the store deps the liveness module needs without importing store.mjs
|
|
141
|
-
// back into it via manager.mjs (avoids re-entry / keeps one store contract).
|
|
142
|
-
configureRuntimeLiveness({ loadSession, saveSessionAsync });
|
|
143
|
-
// Re-export split-module public/test surface unchanged so every importer of the
|
|
144
|
-
// old symbol names keeps resolving through the facade.
|
|
145
|
-
export { previewSessionTools };
|
|
146
|
-
export { _mergePendingMessageEntries, enqueuePendingMessage, drainPendingMessages };
|
|
147
|
-
export { isInternalRuntimeNotificationText as _isInternalRuntimeNotificationText };
|
|
148
|
-
// Usage-metrics surface — re-exported unchanged so loop.mjs / smoke scripts keep
|
|
149
|
-
// resolving these through the facade.
|
|
63
|
+
_mergePendingMessageEntries,
|
|
64
|
+
enqueuePendingMessage,
|
|
65
|
+
drainPendingMessages,
|
|
66
|
+
} from './manager/pending-messages.mjs';
|
|
67
|
+
export { isInternalRuntimeNotificationText as _isInternalRuntimeNotificationText } from './manager/prompt-utils.mjs';
|
|
68
|
+
|
|
69
|
+
// ── Usage-metrics surface — re-exported unchanged so loop.mjs / smoke scripts
|
|
70
|
+
// keep resolving these through the facade. ────────────────────────────────
|
|
150
71
|
export {
|
|
151
72
|
bumpUsageMetricsTurnId,
|
|
152
73
|
resolveUsageMetricsTurnId,
|
|
@@ -156,2163 +77,45 @@ export {
|
|
|
156
77
|
usageMetricsIdempotencyKey,
|
|
157
78
|
applyAskTerminalUsageTotals,
|
|
158
79
|
persistIterationMetrics,
|
|
159
|
-
};
|
|
160
|
-
let _codeGraphRuntimePromise = null;
|
|
161
|
-
let _agentLoopPromise = null;
|
|
162
|
-
let _bashSessionRuntimePromise = null;
|
|
163
|
-
async function _executeCodeGraphToolLazy(name, args, cwd, signal = null, options = {}) {
|
|
164
|
-
_codeGraphRuntimePromise ??= import('../tools/code-graph.mjs');
|
|
165
|
-
const mod = await _codeGraphRuntimePromise;
|
|
166
|
-
if (typeof mod.executeCodeGraphTool !== 'function') throw new Error('code_graph runtime is not available');
|
|
167
|
-
return mod.executeCodeGraphTool(name, args, cwd, signal, options);
|
|
168
|
-
}
|
|
169
|
-
async function _getAgentLoop() {
|
|
170
|
-
_agentLoopPromise ??= import('./loop.mjs');
|
|
171
|
-
const mod = await _agentLoopPromise;
|
|
172
|
-
if (typeof mod.agentLoop !== 'function') throw new Error('agent loop runtime is not available');
|
|
173
|
-
return mod.agentLoop;
|
|
174
|
-
}
|
|
175
|
-
function _closeBashSessionLazy(sessionId, reason) {
|
|
176
|
-
if (!sessionId) return;
|
|
177
|
-
_bashSessionRuntimePromise ??= import('../tools/bash-session.mjs');
|
|
178
|
-
_bashSessionRuntimePromise
|
|
179
|
-
.then((mod) => { if (typeof mod.closeBashSession === 'function') mod.closeBashSession(sessionId, reason); })
|
|
180
|
-
.catch(() => {});
|
|
181
|
-
}
|
|
182
|
-
// Re-export the rules builders so any deep importer of the old symbol names
|
|
183
|
-
// keeps working through the facade.
|
|
184
|
-
export { _buildSharedRules, _buildAgentRules, _buildLeadRules, _buildLeadMetaContext, _buildAgentSpecific };
|
|
185
|
-
|
|
186
|
-
// Agent Runtime is optional — injected via setAgentRuntime() during plugin init
|
|
187
|
-
// so session creation never depends on a circular import. If never injected,
|
|
188
|
-
// createSession simply falls back to classic preset-only behavior.
|
|
189
|
-
let _agentRuntimeApi = null;
|
|
190
|
-
let _agentRuntimeWarned = false;
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Inject the Agent Runtime singleton. Called once by agent/index.mjs init()
|
|
194
|
-
* after initAgentRuntime(). Safe to call multiple times — later calls
|
|
195
|
-
* replace the previous reference.
|
|
196
|
-
*/
|
|
197
|
-
export function setAgentRuntime(api) {
|
|
198
|
-
_agentRuntimeApi = api || null;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function getAgentRuntimeSync() {
|
|
202
|
-
return _agentRuntimeApi;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Thrown when a session is closed while a call is in-flight. Callers (agent
|
|
207
|
-
* handler, CLI) should render this as "cancelled" rather than a hard error.
|
|
208
|
-
*/
|
|
209
|
-
export class SessionClosedError extends Error {
|
|
210
|
-
constructor(sessionId, reason, closeReason) {
|
|
211
|
-
super(reason ? `Session "${sessionId}" closed: ${reason}` : `Session "${sessionId}" closed`);
|
|
212
|
-
this.name = 'SessionClosedError';
|
|
213
|
-
this.sessionId = sessionId;
|
|
214
|
-
this.cancelled = true;
|
|
215
|
-
// closeReason is the diagnostic enum (request-abort / manual /
|
|
216
|
-
// idle-sweep / runner-crash). Kept separate from `reason` (the free
|
|
217
|
-
// -form message) so consumers can branch on it without regex parsing.
|
|
218
|
-
this.reason = closeReason || null;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
// Cap how long the terminal unwind blocks on the post-result session save.
|
|
222
|
-
// The result is already produced (and relayed for agent surfaces) before this
|
|
223
|
-
// save, so a stalled disk write must not hold askSession() open — otherwise the
|
|
224
|
-
// owning background task is stranded in `running` and its completion
|
|
225
|
-
// notification never fires. A slow write finishes in the background.
|
|
226
|
-
const TERMINAL_SAVE_TIMEOUT_MS = nonNegativeIntEnv('MIXDOG_TERMINAL_SAVE_TIMEOUT_MS', 5_000);
|
|
80
|
+
} from './manager/usage-metrics.mjs';
|
|
227
81
|
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
// "tools:git", "tools:mcp", "tools:search",
|
|
237
|
-
// "tools:readonly", or the literal "full"
|
|
238
|
-
// • 'full' / 'readonly' / 'mcp' — legacy preset.tools strings
|
|
239
|
-
// • null / undefined — same as 'full' (historical default)
|
|
240
|
-
//
|
|
241
|
-
// Array form is the Phase B/D target: each profile declares its tool surface
|
|
242
|
-
// explicitly, BP_1 hash differs across profiles with different tool subsets
|
|
243
|
-
// (by design — sub-task profile cannot see bash; worker-full can), and
|
|
244
|
-
// adding a new toolset id here is a localised change.
|
|
245
|
-
//
|
|
246
|
-
// Unified-shard policy — the session's tool array normally never narrows
|
|
247
|
-
// with permission or role. Agent sessions share the same schema so BP_1
|
|
248
|
-
// stays bit-identical and the provider-side cache shard is shared
|
|
249
|
-
// workspace-wide. Rare specialist roles may pass schemaAllowedTools from a
|
|
250
|
-
// declarative hidden-role toolSchemaProfile to keep their first-turn routing
|
|
251
|
-
// surface intentionally tiny; runtime permission guards in loop.mjs remain
|
|
252
|
-
// the fail-safe either way.
|
|
82
|
+
// ── Rules builders — deep importers of the old symbol names resolve here. ──
|
|
83
|
+
export {
|
|
84
|
+
_buildSharedRules,
|
|
85
|
+
_buildAgentRules,
|
|
86
|
+
_buildLeadRules,
|
|
87
|
+
_buildLeadMetaContext,
|
|
88
|
+
_buildAgentSpecific,
|
|
89
|
+
} from './manager/rules-cache.mjs';
|
|
253
90
|
|
|
254
|
-
|
|
255
|
-
//
|
|
256
|
-
|
|
91
|
+
// ── Test-only aliases for the legacy auto-compact-limit migration +
|
|
92
|
+
// buffer-config preservation (scripts/compact-trigger-migration-smoke.mjs). ─
|
|
93
|
+
import {
|
|
94
|
+
resolveSessionContextMeta,
|
|
95
|
+
compactTriggerForSession,
|
|
96
|
+
preserveBufferConfigFields,
|
|
97
|
+
} from './manager/context-meta.mjs';
|
|
257
98
|
export const _resolveSessionContextMeta = resolveSessionContextMeta;
|
|
258
99
|
export const _compactTriggerForSession = compactTriggerForSession;
|
|
259
100
|
export const _preserveBufferConfigFields = preserveBufferConfigFields;
|
|
260
|
-
// Provider-scoped unified cache key. Goal: all orchestrator-internal
|
|
261
|
-
// dispatches (agent/maintenance/mcp/scheduler/webhook) targeting the
|
|
262
|
-
// same provider land in a single server-side cache shard, so the
|
|
263
|
-
// shared prefix (tools + system + pool system prompt) is reused
|
|
264
|
-
// regardless of role. Per-role / per-session differentiation lives after the
|
|
265
|
-
// system prefix (BP3 sessionMarker system block / later messages), which is
|
|
266
|
-
// naturally separated by provider-side content hashing.
|
|
267
|
-
const PROVIDER_ALIAS = {
|
|
268
|
-
'openai-oauth': 'codex', // ChatGPT subscription (OpenAI OAuth backend)
|
|
269
|
-
'anthropic-oauth': 'claude', // Claude Max subscription
|
|
270
|
-
'openai': 'openai',
|
|
271
|
-
'anthropic': 'anthropic',
|
|
272
|
-
'gemini': 'gemini',
|
|
273
|
-
'deepseek': 'deepseek',
|
|
274
|
-
'xai': 'xai',
|
|
275
|
-
};
|
|
276
|
-
function providerCacheKey(provider, override) {
|
|
277
|
-
if (override) return String(override);
|
|
278
|
-
if (!provider) return 'mixdog-default';
|
|
279
|
-
return `mixdog-${PROVIDER_ALIAS[provider] || provider}`;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// ── Prefetch permission guard ─────────────────────────────────────────────────
|
|
283
|
-
// Runs the shared permission evaluator for tool calls that originate in the
|
|
284
|
-
// prefetch path (outside the agent loop). Permission enforcement is disabled
|
|
285
|
-
// (the evaluator always returns allow), so this is effectively a pass-through
|
|
286
|
-
// kept for API compatibility. Returns an error string if blocked, or null.
|
|
287
|
-
const _permEvalForPrefetch = (() => {
|
|
288
|
-
const _req = createRequire(import.meta.url);
|
|
289
|
-
try {
|
|
290
|
-
const { dirname: _pdir, resolve: _pres } = _req('path');
|
|
291
|
-
const _hooksLib = _pres(_pdir(fileURLToPath(import.meta.url)), '../../../../hooks/lib/permission-evaluator.cjs');
|
|
292
|
-
return _req(_hooksLib).evaluatePermission;
|
|
293
|
-
} catch { return null; }
|
|
294
|
-
})();
|
|
295
|
-
function _guardedPrefetchTool(toolName, toolArgs, session) {
|
|
296
|
-
if (!_permEvalForPrefetch) return null;
|
|
297
|
-
// When no explicit mode is attached to the session, run the evaluator
|
|
298
|
-
// under 'default'. The evaluator now always allows, so this never blocks.
|
|
299
|
-
const permissionMode = session?.permissionMode || 'default';
|
|
300
|
-
const projectDir = session?.cwd || undefined;
|
|
301
|
-
const userCwd = session?.cwd || undefined;
|
|
302
|
-
const MCP_PFX = 'mcp__plugin_mixdog_mixdog__';
|
|
303
|
-
const fullName = toolName.startsWith(MCP_PFX) || toolName.startsWith('mcp__') ? toolName : `${MCP_PFX}${toolName}`;
|
|
304
|
-
try {
|
|
305
|
-
const { decision, reason } = _permEvalForPrefetch({ toolName: fullName, toolInput: toolArgs || {}, permissionMode, projectDir, userCwd });
|
|
306
|
-
if (decision === 'deny' || decision === 'ask') {
|
|
307
|
-
return `Error: prefetch tool "${toolName}" blocked (decision=${decision}): ${reason}`;
|
|
308
|
-
}
|
|
309
|
-
} catch (e) {
|
|
310
|
-
process.stderr.write(`[prefetch-guard] evaluator error: ${e?.message}\n`);
|
|
311
|
-
}
|
|
312
|
-
return null;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
|
|
316
|
-
if (!explicitPrefetch || typeof explicitPrefetch !== 'object') return null;
|
|
317
|
-
if (!isAgentOwner(session)) return null;
|
|
318
|
-
const parts = [];
|
|
319
|
-
const failed = [];
|
|
320
|
-
const totalEntries = [];
|
|
321
|
-
// files[] — string entries use the default head excerpt; object entries
|
|
322
|
-
// {path, n?, full?} let the caller widen the window or pull the full file
|
|
323
|
-
// so worker doesn't have to re-read deep ranges of an already-prefetched
|
|
324
|
-
// file (a recurring iter burner observed in baseline session telemetry).
|
|
325
|
-
const _rawFilesIn = Array.isArray(explicitPrefetch.files) ? explicitPrefetch.files : [];
|
|
326
|
-
const _readOptsByFile = new Map();
|
|
327
|
-
const files = [];
|
|
328
|
-
const _seenFiles = new Set();
|
|
329
|
-
const _addPrefetchFile = (file, opts = null) => {
|
|
330
|
-
if (typeof file !== 'string' || !file) return;
|
|
331
|
-
if (!_seenFiles.has(file)) {
|
|
332
|
-
_seenFiles.add(file);
|
|
333
|
-
files.push(file);
|
|
334
|
-
}
|
|
335
|
-
if (!opts || Object.keys(opts).length === 0) return;
|
|
336
|
-
const prev = _readOptsByFile.get(file) || {};
|
|
337
|
-
const merged = { ...prev };
|
|
338
|
-
if (opts.mode === 'full') {
|
|
339
|
-
merged.mode = 'full';
|
|
340
|
-
delete merged.n;
|
|
341
|
-
} else if (merged.mode !== 'full' && Number.isFinite(opts.n) && opts.n > 0) {
|
|
342
|
-
merged.n = Math.max(Number(merged.n) || 0, opts.n);
|
|
343
|
-
}
|
|
344
|
-
if (Object.keys(merged).length > 0) _readOptsByFile.set(file, merged);
|
|
345
|
-
};
|
|
346
|
-
for (const entry of _rawFilesIn) {
|
|
347
|
-
if (typeof entry === 'string' && entry) {
|
|
348
|
-
_addPrefetchFile(entry);
|
|
349
|
-
} else if (entry && typeof entry === 'object' && typeof entry.path === 'string' && entry.path) {
|
|
350
|
-
const opts = {};
|
|
351
|
-
if (entry.full === true) opts.mode = 'full';
|
|
352
|
-
else if (Number.isFinite(entry.n) && entry.n > 0) opts.n = entry.n;
|
|
353
|
-
_addPrefetchFile(entry.path, opts);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
if (files.length > 0) {
|
|
357
|
-
const _pfGuard = _guardedPrefetchTool('read', { path: files }, session);
|
|
358
|
-
if (_pfGuard) {
|
|
359
|
-
process.stderr.write(`[agent-prefetch] files read blocked: ${_pfGuard}\n`);
|
|
360
|
-
failed.push(...files);
|
|
361
|
-
totalEntries.push(...files);
|
|
362
|
-
} else {
|
|
363
|
-
totalEntries.push(...files);
|
|
364
|
-
// R20: per-file prefetch cache (cross-dispatch, process-local).
|
|
365
|
-
// Try each file from cache first; batch misses into one disk read.
|
|
366
|
-
const { resolve: _pfResolve, isAbsolute: _pfIsAbs, normalize: _pfNorm } = await import('path');
|
|
367
|
-
const _pfCwd = session.cwd || null;
|
|
368
|
-
function _pfAbsPath(f) {
|
|
369
|
-
const abs = _pfIsAbs(f) ? f : _pfResolve(_pfCwd || process.cwd(), f);
|
|
370
|
-
return _pfNorm(abs);
|
|
371
|
-
}
|
|
372
|
-
const fileHits = []; // { file, abs, content } — satisfied from cache
|
|
373
|
-
const fileMisses = []; // { file, abs } — need disk read
|
|
374
|
-
for (const f of files) {
|
|
375
|
-
const abs = _pfAbsPath(f);
|
|
376
|
-
// Skip the cross-dispatch cache when the caller asked for a
|
|
377
|
-
// non-default window (custom n or full-file). Cache key is the
|
|
378
|
-
// path alone, so a default-window cache hit would silently feed
|
|
379
|
-
// the wrong slice back to the next caller.
|
|
380
|
-
const hit = _readOptsByFile.has(f) ? null : tryPrefetchCached(abs);
|
|
381
|
-
if (hit) {
|
|
382
|
-
fileHits.push({ file: f, abs, content: hit.content });
|
|
383
|
-
} else {
|
|
384
|
-
fileMisses.push({ file: f, abs });
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
// Disk read for misses (single batch call).
|
|
388
|
-
const missFiles = fileMisses.map(m => m.file);
|
|
389
|
-
const missResults = {}; // file → content string
|
|
390
|
-
if (missFiles.length > 0) {
|
|
391
|
-
// Read each miss file individually so we can cache per-file.
|
|
392
|
-
// The files list is small (typically 2-5), so N awaits is fine.
|
|
393
|
-
await Promise.all(missFiles.map(async (f) => {
|
|
394
|
-
const opts = _readOptsByFile.get(f) || {};
|
|
395
|
-
const readArgs = { path: f };
|
|
396
|
-
if (opts.mode === 'full') {
|
|
397
|
-
readArgs.mode = 'full';
|
|
398
|
-
} else {
|
|
399
|
-
readArgs.mode = 'head';
|
|
400
|
-
readArgs.n = Number.isFinite(opts.n) ? opts.n : 120;
|
|
401
|
-
}
|
|
402
|
-
const out = await executeInternalTool('read', readArgs).catch((e) => {
|
|
403
|
-
process.stderr.write(`[agent-prefetch] file read failed (${f}): ${e && e.message || e}\n`);
|
|
404
|
-
return null;
|
|
405
|
-
});
|
|
406
|
-
if (out !== null) {
|
|
407
|
-
missResults[f] = String(out);
|
|
408
|
-
}
|
|
409
|
-
}));
|
|
410
|
-
// Cache successful miss results.
|
|
411
|
-
for (const { file, abs } of fileMisses) {
|
|
412
|
-
const content = missResults[file];
|
|
413
|
-
if (content && classifyResultKind(content) !== 'error') {
|
|
414
|
-
// Only cache default-window reads; custom-window results
|
|
415
|
-
// would poison the shared cross-dispatch cache.
|
|
416
|
-
if (!_readOptsByFile.has(file)) setPrefetchCached(abs, content);
|
|
417
|
-
} else if (content === undefined || classifyResultKind(content) === 'error') {
|
|
418
|
-
failed.push(file);
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
// Assemble combined output preserving original file order.
|
|
423
|
-
const readParts = [];
|
|
424
|
-
const hitByFile = new Map(fileHits.map((h) => [h.file, h]));
|
|
425
|
-
for (const f of files) {
|
|
426
|
-
const hitEntry = hitByFile.get(f);
|
|
427
|
-
if (hitEntry) {
|
|
428
|
-
readParts.push(hitEntry.content);
|
|
429
|
-
continue;
|
|
430
|
-
}
|
|
431
|
-
const content = missResults[f];
|
|
432
|
-
if (content && classifyResultKind(content) !== 'error') {
|
|
433
|
-
readParts.push(content);
|
|
434
|
-
}
|
|
435
|
-
// else: already pushed to failed above
|
|
436
|
-
}
|
|
437
|
-
if (readParts.length > 0) {
|
|
438
|
-
parts.push(`### prefetch files\nread ${readParts.length}\n\n${readParts.join('\n\n')}`);
|
|
439
|
-
}
|
|
440
|
-
// Log hit/miss counters so dispatch telemetry shows prefetch effectiveness.
|
|
441
|
-
if (process.env.MIXDOG_DEBUG_SESSION_LOG) {
|
|
442
|
-
process.stderr.write(
|
|
443
|
-
`[prefetch] files=${files.length} cached=${fileHits.length} miss=${fileMisses.length} failed=${failed.length}\n`
|
|
444
|
-
);
|
|
445
|
-
}
|
|
446
|
-
// Attach stats to session so post-hoc analyzers (inspect-session.mjs)
|
|
447
|
-
// can see prefetch effectiveness without parsing stderr logs.
|
|
448
|
-
if (session && typeof session === 'object') {
|
|
449
|
-
if (!session.prefetchStats) session.prefetchStats = { files: 0, cached: 0, miss: 0, failed: 0 };
|
|
450
|
-
session.prefetchStats.files += files.length;
|
|
451
|
-
session.prefetchStats.cached += fileHits.length;
|
|
452
|
-
session.prefetchStats.miss += fileMisses.length;
|
|
453
|
-
session.prefetchStats.failed += failed.length;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
// callers[]
|
|
458
|
-
const callers = Array.isArray(explicitPrefetch.callers) ? explicitPrefetch.callers.filter(c => c && typeof c.symbol === 'string') : [];
|
|
459
|
-
{
|
|
460
|
-
const callerTasks = callers.map(({ symbol, file }) => {
|
|
461
|
-
const cgArgs = { mode: 'callers', symbol };
|
|
462
|
-
if (file) cgArgs.file = file;
|
|
463
|
-
if (session?.cwd) cgArgs.cwd = session.cwd;
|
|
464
|
-
totalEntries.push(symbol);
|
|
465
|
-
const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
|
|
466
|
-
if (blocked) {
|
|
467
|
-
process.stderr.write(`[agent-prefetch] callers(${symbol}) blocked: ${blocked}\n`);
|
|
468
|
-
return Promise.resolve({ symbol, out: null, blocked: true });
|
|
469
|
-
}
|
|
470
|
-
return _executeCodeGraphToolLazy('code_graph', cgArgs, session?.cwd)
|
|
471
|
-
.then(out => ({ symbol, out }))
|
|
472
|
-
.catch(e => {
|
|
473
|
-
process.stderr.write(`[agent-prefetch] callers(${symbol}) failed: ${e && e.message || e}\n`);
|
|
474
|
-
return { symbol, out: null };
|
|
475
|
-
});
|
|
476
|
-
});
|
|
477
|
-
const callerResults = await Promise.allSettled(callerTasks);
|
|
478
|
-
for (const r of callerResults) {
|
|
479
|
-
const { symbol, out, blocked } = r.status === 'fulfilled' ? r.value : { symbol: '?', out: null };
|
|
480
|
-
if (blocked) { failed.push(symbol); continue; }
|
|
481
|
-
if (out && classifyResultKind(String(out)) !== 'error') {
|
|
482
|
-
parts.push(`### prefetch callers ${symbol}\n${out}`);
|
|
483
|
-
} else {
|
|
484
|
-
failed.push(symbol);
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
// references[]
|
|
489
|
-
const references = Array.isArray(explicitPrefetch.references) ? explicitPrefetch.references.filter(r => r && typeof r.symbol === 'string') : [];
|
|
490
|
-
{
|
|
491
|
-
const refTasks = references.map(({ symbol, file }) => {
|
|
492
|
-
const cgArgs = { mode: 'references', symbol };
|
|
493
|
-
if (file) cgArgs.file = file;
|
|
494
|
-
if (session?.cwd) cgArgs.cwd = session.cwd;
|
|
495
|
-
totalEntries.push(symbol);
|
|
496
|
-
const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
|
|
497
|
-
if (blocked) {
|
|
498
|
-
process.stderr.write(`[agent-prefetch] references(${symbol}) blocked: ${blocked}\n`);
|
|
499
|
-
return Promise.resolve({ symbol, out: null, blocked: true });
|
|
500
|
-
}
|
|
501
|
-
return _executeCodeGraphToolLazy('code_graph', cgArgs, session?.cwd)
|
|
502
|
-
.then(out => ({ symbol, out }))
|
|
503
|
-
.catch(e => {
|
|
504
|
-
process.stderr.write(`[agent-prefetch] references(${symbol}) failed: ${e && e.message || e}\n`);
|
|
505
|
-
return { symbol, out: null };
|
|
506
|
-
});
|
|
507
|
-
});
|
|
508
|
-
const refResults = await Promise.allSettled(refTasks);
|
|
509
|
-
for (const r of refResults) {
|
|
510
|
-
const { symbol, out, blocked } = r.status === 'fulfilled' ? r.value : { symbol: '?', out: null };
|
|
511
|
-
if (blocked) { failed.push(symbol); continue; }
|
|
512
|
-
if (out && classifyResultKind(String(out)) !== 'error') {
|
|
513
|
-
parts.push(`### prefetch references ${symbol}\n${out}`);
|
|
514
|
-
} else {
|
|
515
|
-
failed.push(symbol);
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
if (session && typeof session === 'object' && (callers.length > 0 || references.length > 0)) {
|
|
520
|
-
if (!session.prefetchStats) session.prefetchStats = { files: 0, cached: 0, miss: 0, failed: 0, callers: 0, references: 0 };
|
|
521
|
-
session.prefetchStats.callers = (session.prefetchStats.callers || 0) + callers.length;
|
|
522
|
-
session.prefetchStats.references = (session.prefetchStats.references || 0) + references.length;
|
|
523
|
-
}
|
|
524
|
-
if (parts.length === 0) {
|
|
525
|
-
// All entries failed but Lead presence must still be signalled — emit
|
|
526
|
-
// warn-only so the gate logic can distinguish "prefetch was requested"
|
|
527
|
-
// from "no prefetch at all".
|
|
528
|
-
if (totalEntries.length > 0 && failed.length > 0) {
|
|
529
|
-
return `<prefetch-warn>${failed.length} of ${totalEntries.length} prefetch entries failed: ${[...new Set(failed)].join(', ')}</prefetch-warn>`;
|
|
530
|
-
}
|
|
531
|
-
return null;
|
|
532
|
-
}
|
|
533
|
-
const warnLine = failed.length > 0
|
|
534
|
-
? `<prefetch-warn>${failed.length} of ${totalEntries.length} prefetch entries failed: ${[...new Set(failed)].join(', ')}</prefetch-warn>\n`
|
|
535
|
-
: '';
|
|
536
|
-
return `${warnLine}<prefetch>\n${parts.join('\n\n')}\n</prefetch>`;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// --- agent spawn (createSession) ---
|
|
540
|
-
// opts can pass either a `preset` object (from config.presets) or raw provider/model.
|
|
541
|
-
// Preset shape: { name, provider, model, effort?, fast?, tools? }
|
|
542
|
-
//
|
|
543
|
-
// Agent Runtime integration:
|
|
544
|
-
// opts.taskType / opts.agent / opts.profileId — enables profile-aware routing.
|
|
545
|
-
// Rule-based SmartRouter resolves these synchronously; the resolved
|
|
546
|
-
// profile controls context filtering (skip.skills/memory/etc) and cache
|
|
547
|
-
// strategy. If no rule matches, falls back to classic preset behavior.
|
|
548
|
-
// opts.profile — pre-resolved profile (bypasses router; used by async
|
|
549
|
-
// callers who already ran AgentRuntime.resolve()).
|
|
550
|
-
// opts.providerCacheOpts — pre-resolved cache options merged into ask() sendOpts.
|
|
551
|
-
export function createSession(opts) {
|
|
552
|
-
const presetObj = opts.preset && typeof opts.preset === 'object' ? opts.preset : null;
|
|
553
|
-
|
|
554
|
-
// --- Agent Runtime profile resolution (best-effort, sync) ---
|
|
555
|
-
let profile = opts.profile || null;
|
|
556
|
-
let providerCacheOpts = opts.providerCacheOpts || null;
|
|
557
|
-
if (!profile && (opts.taskType || opts.agent || opts.profileId)) {
|
|
558
|
-
const agentRuntime = getAgentRuntimeSync();
|
|
559
|
-
if (agentRuntime) {
|
|
560
|
-
try {
|
|
561
|
-
const resolved = agentRuntime.resolveSync({
|
|
562
|
-
taskType: opts.taskType,
|
|
563
|
-
agent: opts.agent,
|
|
564
|
-
profileId: opts.profileId,
|
|
565
|
-
preset: presetObj?.name || (typeof opts.preset === 'string' ? opts.preset : null),
|
|
566
|
-
provider: opts.provider || presetObj?.provider,
|
|
567
|
-
});
|
|
568
|
-
if (resolved) {
|
|
569
|
-
profile = resolved.profile;
|
|
570
|
-
providerCacheOpts = resolved.providerCacheOpts;
|
|
571
|
-
}
|
|
572
|
-
} catch (e) {
|
|
573
|
-
// Agent Runtime error — log once, fall back to classic behavior.
|
|
574
|
-
if (!_agentRuntimeWarned) {
|
|
575
|
-
_agentRuntimeWarned = true;
|
|
576
|
-
process.stderr.write(`[session] agent runtime resolve failed: ${e.message}\n`);
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
const providerName = opts.provider || presetObj?.provider
|
|
583
|
-
|| (profile?.preferredProviders?.[0]);
|
|
584
|
-
const modelName = opts.model || presetObj?.model;
|
|
585
|
-
// opts.tools (caller-supplied) wins over presetObj.tools — caller
|
|
586
|
-
// intent ('tools:readonly' from Pool C, etc.) must override the
|
|
587
|
-
// preset's default 'full'. Previous priority let HAIKU's tools='full'
|
|
588
|
-
// shadow Pool C's explicit readonly request, leaking write tools and
|
|
589
|
-
// bash into a read-only agent.
|
|
590
|
-
const toolPreset = opts.tools || presetObj?.tools || (typeof opts.preset === 'string' ? opts.preset : null) || 'full';
|
|
591
|
-
const effort = Object.prototype.hasOwnProperty.call(opts, 'effort')
|
|
592
|
-
? (opts.effort || null)
|
|
593
|
-
: (presetObj?.effort || null);
|
|
594
|
-
const fast = presetObj?.fast === true || opts.fast === true;
|
|
595
|
-
if (!providerName)
|
|
596
|
-
throw new Error('createSession: provider is required');
|
|
597
|
-
if (!modelName)
|
|
598
|
-
throw new Error('createSession: model is required');
|
|
599
|
-
const provider = getProvider(providerName);
|
|
600
|
-
if (!provider)
|
|
601
|
-
throw new Error(`Provider "${providerName}" not found or not enabled`);
|
|
602
|
-
const id = `sess_${process.pid}_${nextId++}_${Date.now()}_${randomBytes(16).toString('hex')}`;
|
|
603
|
-
// Provider cache strategy — agentRuntime.resolveSync() above is a
|
|
604
|
-
// best-effort injection point (setAgentRuntime() has no live caller
|
|
605
|
-
// today, so that branch never fires); build it directly here so every
|
|
606
|
-
// session still gets a cache strategy. Lead sessions (opts.agent ===
|
|
607
|
-
// 'lead', or no agent at all — raw/CLI callers) get their BP4 message
|
|
608
|
-
// tail TTL linked to the user's autoClear idle-sweep config; hidden and
|
|
609
|
-
// public agents keep the flat 5m default (see cache-strategy.mjs docs).
|
|
610
|
-
// Scoped to explicit-breakpoint (Anthropic-family) providers only — the
|
|
611
|
-
// non-Anthropic branches of buildProviderCacheOpts (e.g. the 'openai'
|
|
612
|
-
// cacheRetention:'24h' shape) were never exercised by createSession
|
|
613
|
-
// before this change, and are left untouched to avoid altering live
|
|
614
|
-
// OpenAI/other-provider request shape as a side effect of this fix.
|
|
615
|
-
if (!providerCacheOpts && cacheCapabilityForProvider(providerName) === 'explicit-breakpoint') {
|
|
616
|
-
try {
|
|
617
|
-
let autoClear = null;
|
|
618
|
-
if (!opts.agent || opts.agent === 'lead') {
|
|
619
|
-
const loadedConfig = loadConfig({ secrets: false });
|
|
620
|
-
const normalizedAutoClear = normalizeAutoClearConfig(loadedConfig?.autoClear);
|
|
621
|
-
autoClear = {
|
|
622
|
-
...normalizedAutoClear,
|
|
623
|
-
idleMs: resolveAutoClearIdleMs(loadedConfig, providerName),
|
|
624
|
-
};
|
|
625
|
-
}
|
|
626
|
-
providerCacheOpts = buildProviderCacheOpts(providerName, id, opts.agent, { autoClear });
|
|
627
|
-
} catch {
|
|
628
|
-
providerCacheOpts = null;
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
const messages = [];
|
|
632
|
-
const ownerIsAgent = isAgentOwner(opts.owner);
|
|
633
|
-
const resolvedAgent = opts.agent || opts.role || profile?.taskType || null;
|
|
634
|
-
const hiddenAgent = getHiddenAgent(resolvedAgent);
|
|
635
|
-
const isRetrievalAgent = hiddenAgent?.kind === 'retrieval';
|
|
636
|
-
// Skill schema is fixed for public agent sessions, but hidden retrieval /
|
|
637
|
-
// maintenance roles are deliberately narrowed away from the Skill tool.
|
|
638
|
-
// Do not leak a Skill manifest into those hidden prompts when no Skill()
|
|
639
|
-
// loader is available.
|
|
640
|
-
const skills = (opts.skipSkills || hiddenAgent) ? [] : collectPromptSkillsCached(opts.cwd);
|
|
641
|
-
|
|
642
|
-
// BP1 is shared tool policy (+ compact skill manifest in compose). BP2 is
|
|
643
|
-
// role/system rules. User-defined schedules/webhooks ride as normal user
|
|
644
|
-
// context below so event data does not rewrite BP3 memory/meta.
|
|
645
|
-
const agentRulesAgent = opts.agent || opts.role || profile?.taskType || null;
|
|
646
|
-
const agentRulesProfile = isRetrievalAgent ? 'retrieval' : 'full';
|
|
647
|
-
const skipAgentRules = opts.skipAgentRules === true;
|
|
648
|
-
// BP1 shared tool policy ships to EVERY role (Lead, workers, retrieval,
|
|
649
|
-
// maintenance): its anti-spiral clauses (one anchor is enough, never
|
|
650
|
-
// repeat equivalent patterns/scopes, plausible hit → stop) are exactly
|
|
651
|
-
// what narrow retrieval roles need. Role docs (e.g. 30-explorer.md)
|
|
652
|
-
// override role-inapplicable entries such as the explore routing row.
|
|
653
|
-
const injectedRules = skipAgentRules ? '' : _buildSharedRules();
|
|
654
|
-
const roleRules = skipAgentRules ? '' : (ownerIsAgent ? _buildAgentRules(agentRulesProfile) : _buildLeadRules());
|
|
655
|
-
const metaContext = skipAgentRules ? '' : (ownerIsAgent ? '' : _buildLeadMetaContext());
|
|
656
|
-
const roleSpecific = ownerIsAgent && !skipAgentRules ? _buildAgentSpecific(agentRulesAgent) : '';
|
|
657
|
-
// Prompt permission is metadata for the write bundle, but a read-only role
|
|
658
|
-
// is stamped BEFORE the toolSpec decision so its schema ships the narrowed
|
|
659
|
-
// bundle. Resolve toolPermission (with profile/preset fallbacks) first, and
|
|
660
|
-
// let the stored/logged `permission` reflect that resolved value — not just
|
|
661
|
-
// opts.permission — so diagnostics show the effective read/write class.
|
|
662
|
-
const toolPermission = opts.permission
|
|
663
|
-
|| profile?.permission
|
|
664
|
-
|| permissionFromToolSpec(toolPreset)
|
|
665
|
-
|| null;
|
|
666
|
-
const permission = toolPermission;
|
|
667
|
-
|
|
668
|
-
// Agent sessions do not inherit arbitrary role/profile/preset tool
|
|
669
|
-
// narrowing — that would shatter provider prefix reuse into one shard per
|
|
670
|
-
// role. Instead they collapse onto exactly TWO stable, bit-identical
|
|
671
|
-
// bundles, one cache group each:
|
|
672
|
-
// - read-only roles (reviewer / debugger / hidden retrieval, i.e. any
|
|
673
|
-
// session resolving to permission 'read') -> 'readonly' bundle:
|
|
674
|
-
// read builtins (code_graph/find/glob/list/grep/read) + retrieval
|
|
675
|
-
// (explore/search/web_fetch/Skill), no apply_patch/shell/task, no
|
|
676
|
-
// MCP-write. applyToolPermissionNarrowing('read') below trims the
|
|
677
|
-
// bundle to AGENT_STRING_PERMISSION_READ_ALLOW so the final surface is
|
|
678
|
-
// bit-identical across these roles regardless of MCP registry state.
|
|
679
|
-
// - write roles (worker / heavy-worker / maintainer / …) -> 'full'
|
|
680
|
-
// bundle: the historical full schema.
|
|
681
|
-
// Call-time permission enforcement below is UNCHANGED (defense in depth):
|
|
682
|
-
// applyToolPermissionNarrowing still runs so the bundle choice never
|
|
683
|
-
// widens effective access.
|
|
684
|
-
const isReadOnlyAgentBundle = ownerIsAgent && toolPermission === 'read';
|
|
685
|
-
const toolSpec = ownerIsAgent
|
|
686
|
-
? (isReadOnlyAgentBundle ? 'readonly' : 'full')
|
|
687
|
-
: (Array.isArray(profile?.tools) ? profile.tools : toolPreset);
|
|
688
|
-
let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsAgentSession: ownerIsAgent });
|
|
689
|
-
// Fail-closed permission intersection: when a session declares an explicit
|
|
690
|
-
// object-form permission, intersect the
|
|
691
|
-
// resolved tool list with the permission's allow/deny lists. If the
|
|
692
|
-
// intersection produces an empty set the permission config is broken —
|
|
693
|
-
// fail closed (zero tools) rather than silently falling back to the full
|
|
694
|
-
// preset, which would grant the role more surface than declared.
|
|
695
|
-
if (ownerIsAgent) {
|
|
696
|
-
toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, toolPermission, opts.agent || null);
|
|
697
|
-
}
|
|
698
101
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
// 4-BP layout (see composeSystemPrompt docs):
|
|
720
|
-
// system block #1 = baseRules — BP1 (1h) shared tool policy + skills
|
|
721
|
-
// system block #2 = stableSystemContext — BP2 (1h) role/system rules
|
|
722
|
-
// system block #3 = sessionMarker — BP3 (1h) memory/meta + Profile
|
|
723
|
-
// Preferences (language/name). It rides as a real `system` block so
|
|
724
|
-
// locale/name directives are pinned firmly and do not drift to English
|
|
725
|
-
// after a few turns the way a `user <system-reminder>` reminder did.
|
|
726
|
-
// later normal messages = BP4/tail (task, role data, tool history)
|
|
727
|
-
// Anthropic multi-block system pins each block with cache_control (BP3 is
|
|
728
|
-
// the 3rd system block and carries the tier3 1h marker). OpenAI/xAI get
|
|
729
|
-
// stable provider cache keys/session prefixes. Gemini manages explicit
|
|
730
|
-
// cachedContents inside its provider.
|
|
731
|
-
if (baseRules) {
|
|
732
|
-
messages.push({ role: 'system', content: baseRules });
|
|
733
|
-
}
|
|
734
|
-
if (stableSystemContext) {
|
|
735
|
-
messages.push({ role: 'system', content: stableSystemContext });
|
|
736
|
-
}
|
|
737
|
-
if (sessionMarker) {
|
|
738
|
-
// cacheTier:'tier3' tells the Anthropic providers to pin THIS system
|
|
739
|
-
// block with the tier3 1h cache_control (BP3) — distinct from the
|
|
740
|
-
// BP1/BP2 system TTL. Harmless on non-Anthropic providers (they ignore
|
|
741
|
-
// the field and serialize content as a normal system instruction).
|
|
742
|
-
messages.push({ role: 'system', content: sessionMarker, cacheTier: 'tier3' });
|
|
743
|
-
}
|
|
744
|
-
if (volatileTail) {
|
|
745
|
-
messages.push({ role: 'user', content: `<system-reminder>\n${volatileTail}\n</system-reminder>` });
|
|
746
|
-
messages.push({ role: 'assistant', content: '.' });
|
|
747
|
-
}
|
|
748
|
-
if (roleSpecific) {
|
|
749
|
-
messages.push({ role: 'user', content: `<system-reminder>\n${roleSpecific}\n</system-reminder>` });
|
|
750
|
-
messages.push({ role: 'assistant', content: '.' });
|
|
751
|
-
}
|
|
752
|
-
if (opts.files?.length) {
|
|
753
|
-
const fileContext = opts.files
|
|
754
|
-
.map(f => `### ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
|
|
755
|
-
.join('\n\n');
|
|
756
|
-
messages.push({ role: 'user', content: `Reference files:\n\n${fileContext}` });
|
|
757
|
-
messages.push({ role: 'assistant', content: '.' });
|
|
758
|
-
}
|
|
759
|
-
const hasCallerAllow = Array.isArray(opts.schemaAllowedTools);
|
|
760
|
-
const tools = finalizeSessionToolList(toolsForRouting, {
|
|
761
|
-
schemaAllowedTools: hasCallerAllow ? opts.schemaAllowedTools : null,
|
|
762
|
-
disallowedTools: hiddenAgent ? [...(Array.isArray(opts.disallowedTools) ? opts.disallowedTools : []), 'Skill'] : opts.disallowedTools,
|
|
763
|
-
ownerIsAgent,
|
|
764
|
-
resolvedAgent,
|
|
765
|
-
});
|
|
766
|
-
|
|
767
|
-
// Unified-shard policy — no broad role-specific schema filter. Keep
|
|
768
|
-
// agent schemas shared unless a hidden-role schema profile explicitly
|
|
769
|
-
// passes schemaAllowedTools for a small specialist; broad role
|
|
770
|
-
// whitelists would fragment the cache shard.
|
|
771
|
-
if (resolvedAgent && process.env.MIXDOG_DEBUG_SESSION_LOG) {
|
|
772
|
-
process.stderr.write(`[session] agent=${resolvedAgent} permission=${permission || 'full'} toolPermission=${toolPermission || 'full'} tools=${tools.length}\n`);
|
|
773
|
-
}
|
|
774
|
-
const contextMeta = resolveSessionContextMeta(provider, modelName);
|
|
775
|
-
const workflowMeta = opts.workflow && typeof opts.workflow === 'object' && String(opts.workflow.id || '').trim()
|
|
776
|
-
? {
|
|
777
|
-
id: String(opts.workflow.id || '').trim(),
|
|
778
|
-
name: String(opts.workflow.name || opts.workflow.id || '').trim(),
|
|
779
|
-
description: String(opts.workflow.description || '').trim(),
|
|
780
|
-
source: String(opts.workflow.source || '').trim(),
|
|
781
|
-
}
|
|
782
|
-
: null;
|
|
783
|
-
const session = {
|
|
784
|
-
id,
|
|
785
|
-
provider: providerName,
|
|
786
|
-
model: modelName,
|
|
787
|
-
messages,
|
|
788
|
-
contextWindow: contextMeta.contextWindow,
|
|
789
|
-
rawContextWindow: contextMeta.rawContextWindow,
|
|
790
|
-
effectiveContextWindowPercent: contextMeta.effectiveContextWindowPercent,
|
|
791
|
-
autoCompactTokenLimit: contextMeta.autoCompactTokenLimit,
|
|
792
|
-
compactBoundaryTokens: contextMeta.compactBoundaryTokens,
|
|
793
|
-
compaction: {
|
|
794
|
-
auto: opts.compaction?.auto !== false,
|
|
795
|
-
prune: opts.compaction?.prune === true,
|
|
796
|
-
semantic: opts.compaction?.semantic ?? 'auto',
|
|
797
|
-
type: normalizeCompactType(opts.compaction?.type ?? opts.compaction?.compactType ?? opts.compaction?.compact_type, DEFAULT_COMPACT_TYPE),
|
|
798
|
-
compactType: normalizeCompactType(opts.compaction?.type ?? opts.compaction?.compactType ?? opts.compaction?.compact_type, DEFAULT_COMPACT_TYPE),
|
|
799
|
-
model: opts.compaction?.model || null,
|
|
800
|
-
timeoutMs: positiveContextWindow(opts.compaction?.timeoutMs),
|
|
801
|
-
tailTurns: positiveContextWindow(opts.compaction?.tailTurns),
|
|
802
|
-
bufferTokens: positiveContextWindow(opts.compaction?.bufferTokens ?? opts.compaction?.buffer),
|
|
803
|
-
// Preserve percent/ratio-named buffer config so the manager/loop/
|
|
804
|
-
// contextStatus parsers (which read bufferPercent/bufferPct/
|
|
805
|
-
// bufferRatio/bufferFraction) can honor it. createSession previously
|
|
806
|
-
// only stored bufferTokens, silently dropping a configured percent.
|
|
807
|
-
...preserveBufferConfigFields(opts.compaction),
|
|
808
|
-
keepTokens: positiveContextWindow(opts.compaction?.keepTokens ?? opts.compaction?.keep?.tokens),
|
|
809
|
-
preserveRecentTokens: positiveContextWindow(opts.compaction?.preserveRecentTokens),
|
|
810
|
-
reservedTokens: positiveContextWindow(opts.compaction?.reservedTokens),
|
|
811
|
-
recallIngestLimit: positiveContextWindow(opts.compaction?.recallIngestLimit),
|
|
812
|
-
recallChunkLimit: positiveContextWindow(opts.compaction?.recallChunkLimit ?? opts.compaction?.recallLimit),
|
|
813
|
-
recallCycle1BatchSize: positiveContextWindow(opts.compaction?.recallCycle1BatchSize),
|
|
814
|
-
recallRowsPerSession: positiveContextWindow(opts.compaction?.recallRowsPerSession),
|
|
815
|
-
recallWindowSize: positiveContextWindow(opts.compaction?.recallWindowSize),
|
|
816
|
-
recallConcurrency: positiveContextWindow(opts.compaction?.recallConcurrency),
|
|
817
|
-
recallCycle1DeadlineMs: positiveContextWindow(opts.compaction?.recallCycle1DeadlineMs),
|
|
818
|
-
boundaryTokens: contextMeta.compactBoundaryTokens,
|
|
819
|
-
},
|
|
820
|
-
tools,
|
|
821
|
-
preset: toolPreset,
|
|
822
|
-
presetName: presetObj?.name || null,
|
|
823
|
-
effort,
|
|
824
|
-
fast,
|
|
825
|
-
agent: opts.agent,
|
|
826
|
-
owner: opts.owner || 'user',
|
|
827
|
-
mcpPid: process.pid,
|
|
828
|
-
scopeKey: opts.scopeKey || null,
|
|
829
|
-
lane: opts.lane || 'agent',
|
|
830
|
-
cwd: opts.cwd,
|
|
831
|
-
workflow: workflowMeta,
|
|
832
|
-
createdAt: Date.now(),
|
|
833
|
-
updatedAt: Date.now(),
|
|
834
|
-
lastHeartbeatAt: null,
|
|
835
|
-
totalInputTokens: 0,
|
|
836
|
-
totalOutputTokens: 0,
|
|
837
|
-
// Refreshed on each completed ask() — surfaced by agent type=list for
|
|
838
|
-
// debugging + consumed by store.mjs's idle-sweep to reclaim stalled
|
|
839
|
-
// agent sessions past RUNNING_STALL_MS.
|
|
840
|
-
lastUsedAt: Date.now(),
|
|
841
|
-
tokensCumulative: 0,
|
|
842
|
-
taskType: opts.taskType || null,
|
|
843
|
-
maxLoopIterations: Number.isFinite(opts.maxLoopIterations) ? opts.maxLoopIterations : null,
|
|
844
|
-
// Agent tag (auto worker{n} on spawn) persisted so the forked status
|
|
845
|
-
// process (statusline) + aggregator can read it from the session JSON.
|
|
846
|
-
// In-process send/close still resolve via _tagSessionRegistry.
|
|
847
|
-
agentTag: opts.agentTag || null,
|
|
848
|
-
// Prompt permission is separate from runtime toolPermission so preset
|
|
849
|
-
// restrictions do not fragment the agent cache prefix.
|
|
850
|
-
permission: permission || null,
|
|
851
|
-
toolPermission: toolPermission || null,
|
|
852
|
-
schemaAllowedTools: hasCallerAllow ? opts.schemaAllowedTools.map((n) => String(n)) : null,
|
|
853
|
-
// Origin tag written into every agent-trace usage row so analytics
|
|
854
|
-
// can slice by (sourceType, sourceName) — e.g. maintenance/cycle1,
|
|
855
|
-
// scheduler/daily-standup, webhook/github-push, lead/worker.
|
|
856
|
-
sourceType: opts.sourceType || null,
|
|
857
|
-
sourceName: opts.sourceName || null,
|
|
858
|
-
// Provider-scoped unified cache key — one shard per provider,
|
|
859
|
-
// shared across all roles / sources (agent/maintenance/mcp/
|
|
860
|
-
// scheduler/webhook). Role or source-specific context must be
|
|
861
|
-
// injected into the message tail, not the shared prefix.
|
|
862
|
-
promptCacheKey: providerCacheKey(presetObj?.provider || opts.provider, opts.cacheKeyOverride),
|
|
863
|
-
// Agent shell continuity: when an agent session explicitly opts into
|
|
864
|
-
// persistent shell state (`bash` with `persistent:true`, or direct
|
|
865
|
-
// `bash_session`), the minted bash_session id is stored here so later
|
|
866
|
-
// opted-in `bash` calls can reuse the same shell state.
|
|
867
|
-
implicitBashSessionId: null,
|
|
868
|
-
// Tracks every persistent bash session id minted during this
|
|
869
|
-
// orchestrator session so closeSession can kill them all, not just
|
|
870
|
-
// the most recently recorded one.
|
|
871
|
-
allBashSessionIds: [],
|
|
872
|
-
// Agent Runtime metadata — optional. Applied on every ask() to merge
|
|
873
|
-
// profile-driven cache settings into provider sendOpts.
|
|
874
|
-
profileId: profile?.id || null,
|
|
875
|
-
permissionMode: opts.permissionMode ?? null,
|
|
876
|
-
providerCacheOpts: providerCacheOpts || null,
|
|
877
|
-
ownerSessionId: opts.ownerSessionId || null,
|
|
878
|
-
clientHostPid: opts.clientHostPid || null,
|
|
879
|
-
};
|
|
880
|
-
// In-process registry + async debounced save: same-process create → load
|
|
881
|
-
// reads live memory; disk flush is for cross-process / restart durability.
|
|
882
|
-
setLiveSession(session);
|
|
883
|
-
saveSession(session);
|
|
884
|
-
return session;
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
// Runtime liveness map + its accessors moved to manager/runtime-liveness.mjs
|
|
888
|
-
// (imported/re-exported above). _runtimeState is now private to that module;
|
|
889
|
-
// manager.mjs internals that mutate entries in place use _getRuntimeEntry /
|
|
890
|
-
// _runtimeEntries. updateSessionRoute stays here (it's a persistence helper on
|
|
891
|
-
// the session store, not a liveness accessor).
|
|
892
|
-
export function updateSessionRoute(id, route = {}) {
|
|
893
|
-
if (!id) return null;
|
|
894
|
-
const session = loadSession(id);
|
|
895
|
-
if (!session || session.closed === true) return null;
|
|
896
|
-
const previousProvider = session.provider || null;
|
|
897
|
-
const previousModel = session.model || null;
|
|
898
|
-
if (route.provider) session.provider = route.provider;
|
|
899
|
-
if (route.model) session.model = route.model;
|
|
900
|
-
if (Object.prototype.hasOwnProperty.call(route, 'fast')) session.fast = route.fast === true;
|
|
901
|
-
if (Object.prototype.hasOwnProperty.call(route, 'effort')) session.effort = route.effort || null;
|
|
902
|
-
const provider = session.provider ? getProvider(session.provider) : null;
|
|
903
|
-
if (provider && session.model) {
|
|
904
|
-
const contextMeta = resolveSessionContextMeta(provider, session.model);
|
|
905
|
-
session.contextWindow = contextMeta.contextWindow;
|
|
906
|
-
session.rawContextWindow = contextMeta.rawContextWindow;
|
|
907
|
-
session.effectiveContextWindowPercent = contextMeta.effectiveContextWindowPercent;
|
|
908
|
-
session.autoCompactTokenLimit = contextMeta.autoCompactTokenLimit;
|
|
909
|
-
session.compactBoundaryTokens = contextMeta.compactBoundaryTokens;
|
|
910
|
-
session.compaction = {
|
|
911
|
-
...(session.compaction || {}),
|
|
912
|
-
boundaryTokens: contextMeta.compactBoundaryTokens,
|
|
913
|
-
contextWindow: contextMeta.contextWindow,
|
|
914
|
-
rawContextWindow: contextMeta.rawContextWindow,
|
|
915
|
-
effectiveContextWindowPercent: contextMeta.effectiveContextWindowPercent,
|
|
916
|
-
autoCompactTokenLimit: contextMeta.autoCompactTokenLimit,
|
|
917
|
-
};
|
|
918
|
-
} else {
|
|
919
|
-
delete session.contextWindow;
|
|
920
|
-
delete session.rawContextWindow;
|
|
921
|
-
delete session.effectiveContextWindowPercent;
|
|
922
|
-
delete session.autoCompactTokenLimit;
|
|
923
|
-
delete session.compactBoundaryTokens;
|
|
924
|
-
}
|
|
925
|
-
const routeChanged = (route.provider && route.provider !== previousProvider)
|
|
926
|
-
|| (route.model && route.model !== previousModel);
|
|
927
|
-
if (routeChanged) {
|
|
928
|
-
const now = Date.now();
|
|
929
|
-
session.lastInputTokens = 0;
|
|
930
|
-
session.lastOutputTokens = 0;
|
|
931
|
-
session.lastCachedReadTokens = 0;
|
|
932
|
-
session.lastCacheWriteTokens = 0;
|
|
933
|
-
session.lastContextTokens = 0;
|
|
934
|
-
session.lastContextTokensUpdatedAt = now;
|
|
935
|
-
session.lastContextTokensStaleAfterCompact = false;
|
|
936
|
-
session.providerState = undefined;
|
|
937
|
-
}
|
|
938
|
-
session.updatedAt = Date.now();
|
|
939
|
-
setLiveSession(session);
|
|
940
|
-
void saveSessionAsync(session, { expectedGeneration: session.generation })
|
|
941
|
-
.catch((err) => {
|
|
942
|
-
try { process.stderr.write(`[session] route update save failed: ${err?.message || err}\n`); } catch {}
|
|
943
|
-
});
|
|
944
|
-
return session;
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
// --- Incremental metric persistence (fix A) ---
|
|
948
|
-
// Usage-metrics accounting (idempotency Set, epoch/turn helpers, terminal +
|
|
949
|
-
// incremental persistence) moved to manager/usage-metrics.mjs. Its runtime
|
|
950
|
-
// accessor is now wired inside manager/runtime-liveness.mjs (which owns the
|
|
951
|
-
// _runtimeState Map), so persistIterationMetrics can read the live in-memory
|
|
952
|
-
// session and flag usageMetricsTurnIncremental.
|
|
953
|
-
//
|
|
954
|
-
// standaloneStatusRouteInfo + recordStandaloneStatusTelemetry moved to
|
|
955
|
-
// manager/status-telemetry.mjs (imported above).
|
|
956
|
-
|
|
957
|
-
/** Force-flush session metrics to disk. Used by watchdog terminal-reap (fix B). */
|
|
958
|
-
export async function flushSessionMetrics(sessionId) {
|
|
959
|
-
if (!sessionId) return;
|
|
960
|
-
const session = loadSession(sessionId);
|
|
961
|
-
if (!session) return;
|
|
962
|
-
session.updatedAt = Date.now();
|
|
963
|
-
await saveSessionAsync(session, { expectedGeneration: session.generation });
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
/**
|
|
967
|
-
* Wrap an async call so that if the session's controller aborts mid-flight,
|
|
968
|
-
* the wrapper settles with a SessionClosedError even if the underlying promise
|
|
969
|
-
* hasn't returned yet. The original promise is kept alive with a detached
|
|
970
|
-
* `.catch()` to prevent unhandled-rejection warnings once it eventually
|
|
971
|
-
* settles. Callers still must check generation/closed after await returns
|
|
972
|
-
* to handle providers that ignore the AbortSignal entirely.
|
|
973
|
-
*/
|
|
974
|
-
export async function _api_call_with_interrupt(sessionId, fn) {
|
|
975
|
-
const entry = _touchRuntime(sessionId);
|
|
976
|
-
if (!entry.controller) entry.controller = createAbortController();
|
|
977
|
-
const signal = entry.controller.signal;
|
|
978
|
-
const closedFromAbort = (phase) => {
|
|
979
|
-
const reason = signal.reason;
|
|
980
|
-
if (reason instanceof SessionClosedError) return reason;
|
|
981
|
-
const detail = reason instanceof Error
|
|
982
|
-
? reason.message
|
|
983
|
-
: (reason !== undefined && reason !== null && reason !== '' ? String(reason) : '');
|
|
984
|
-
return new SessionClosedError(sessionId, detail ? `${phase}: ${detail}` : phase);
|
|
985
|
-
};
|
|
986
|
-
if (signal.aborted) throw closedFromAbort('aborted before call');
|
|
987
|
-
const underlying = fn(signal);
|
|
988
|
-
underlying.catch(() => {}); // prevent unhandled rejection if we race ahead
|
|
989
|
-
let onAbort = null;
|
|
990
|
-
const aborted = new Promise((_, reject) => {
|
|
991
|
-
onAbort = () => reject(closedFromAbort('aborted during call'));
|
|
992
|
-
if (signal.aborted) onAbort();
|
|
993
|
-
else signal.addEventListener('abort', onAbort, { once: true });
|
|
994
|
-
});
|
|
995
|
-
try {
|
|
996
|
-
return await Promise.race([underlying, aborted]);
|
|
997
|
-
} finally {
|
|
998
|
-
// If the underlying promise settled first, the abort listener is
|
|
999
|
-
// still attached. Remove it to avoid accumulating listeners across
|
|
1000
|
-
// many asks on the same session.
|
|
1001
|
-
if (onAbort && !signal.aborted) {
|
|
1002
|
-
try { signal.removeEventListener('abort', onAbort); } catch { /* ignore */ }
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
// Per-session mutex: queues concurrent askSession calls to prevent message loss
|
|
1008
|
-
const _sessionLocks = new Map();
|
|
1009
|
-
// Per-session pending-message queue (in-flight send enqueue pattern).
|
|
1010
|
-
// A `agent type=send` to a worker whose turn is still in flight ENQUEUES the
|
|
1011
|
-
// message here instead of rejecting; askSession drains the queue after each
|
|
1012
|
-
// turn and runs the messages as the next user turn(s), preserving order — the
|
|
1013
|
-
// queued send runs AFTER the in-flight prompt, which also closes the spawn
|
|
1014
|
-
// startup race (a send landing before the initial turn settles no longer
|
|
1015
|
-
// jumps ahead of the original prompt).
|
|
1016
|
-
//
|
|
1017
|
-
// The in-memory map is mirrored to disk. Without that, a compact/API error or
|
|
1018
|
-
// daemon restart after returning queued:true can strand or lose a follow-up:
|
|
1019
|
-
// the original ask never reaches its drain point, and no new ask is scheduled.
|
|
1020
|
-
// Keeping the queue outside the session JSON avoids racing session saves that
|
|
1021
|
-
// loaded before the send arrived.
|
|
1022
|
-
//
|
|
1023
|
-
// Map<sessionId, Array<string|{text?:string,content:any}>>. Shared with
|
|
1024
|
-
// index.mjs's agent send handler via the enqueue/drain accessors below — one
|
|
1025
|
-
// queue contract, two call sites. Rich content is kept in memory for the live
|
|
1026
|
-
// relay path; the disk mirror stores only a text fallback so image bytes do not
|
|
1027
|
-
// leak into the pending-message JSON.
|
|
1028
|
-
function isInternalCancelledAssistantMessage(message) {
|
|
1029
|
-
if (!message || message.role !== 'assistant') return false;
|
|
1030
|
-
if (message.cancelled === true) return true;
|
|
1031
|
-
const text = promptContentText(message.content).trim();
|
|
1032
|
-
return /^\[cancelled\]\s+This turn was interrupted before completion\./i.test(text)
|
|
1033
|
-
|| /Preserve the user request above as the active task context/i.test(text);
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
function sanitizeSessionMessagesForModel(messages) {
|
|
1037
|
-
// Drop internal runtime-notification turns and cancelled-assistant stubs so
|
|
1038
|
-
// they never reach the model, but KEEP image content intact. Reference-agent
|
|
1039
|
-
// parity: the live transcript and every model request retain attached
|
|
1040
|
-
// images across turns; only the compaction-summary call strips them. The
|
|
1041
|
-
// disk-stored session JSON replaces image bytes with a text placeholder at
|
|
1042
|
-
// serialization time (see store.mjs), so this no longer touches images.
|
|
1043
|
-
if (!Array.isArray(messages) || messages.length === 0) return [];
|
|
1044
|
-
const out = [];
|
|
1045
|
-
let droppingInternalTurn = false;
|
|
1046
|
-
for (const message of messages) {
|
|
1047
|
-
if (isInternalCancelledAssistantMessage(message)) {
|
|
1048
|
-
droppingInternalTurn = false;
|
|
1049
|
-
continue;
|
|
1050
|
-
}
|
|
1051
|
-
if (message?.role === 'user' && isInternalRuntimeNotificationText(message.content)) {
|
|
1052
|
-
droppingInternalTurn = true;
|
|
1053
|
-
continue;
|
|
1054
|
-
}
|
|
1055
|
-
if (droppingInternalTurn) {
|
|
1056
|
-
if (message?.role === 'user') {
|
|
1057
|
-
droppingInternalTurn = false;
|
|
1058
|
-
} else {
|
|
1059
|
-
continue;
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1062
|
-
out.push(message);
|
|
1063
|
-
}
|
|
1064
|
-
return out;
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
function acquireSessionLock(sessionId) {
|
|
1068
|
-
let entry = _sessionLocks.get(sessionId);
|
|
1069
|
-
if (!entry) {
|
|
1070
|
-
entry = { promise: Promise.resolve(), count: 0 };
|
|
1071
|
-
_sessionLocks.set(sessionId, entry);
|
|
1072
|
-
}
|
|
1073
|
-
entry.count++;
|
|
1074
|
-
const prev = entry.promise;
|
|
1075
|
-
let release;
|
|
1076
|
-
entry.promise = new Promise(r => { release = r; });
|
|
1077
|
-
// Self-heal: if the previous holder rejected, swallow so subsequent
|
|
1078
|
-
// queued waiters don't propagate that rejection and brick the lock chain.
|
|
1079
|
-
return prev.catch(() => {}).then(() => () => {
|
|
1080
|
-
entry.count--;
|
|
1081
|
-
if (entry.count === 0) _sessionLocks.delete(sessionId);
|
|
1082
|
-
release();
|
|
1083
|
-
});
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
function sessionMessagesSnapshotChanged(before, after) {
|
|
1087
|
-
if (!Array.isArray(before) || !Array.isArray(after)) return before !== after;
|
|
1088
|
-
if (before.length !== after.length) return true;
|
|
1089
|
-
for (let i = 0; i < before.length; i += 1) {
|
|
1090
|
-
if (before[i] !== after[i]) return true;
|
|
1091
|
-
try {
|
|
1092
|
-
if (JSON.stringify(before[i]) !== JSON.stringify(after[i])) return true;
|
|
1093
|
-
} catch {
|
|
1094
|
-
return true;
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
return false;
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
function isCompactedOutgoingFinalAssistantMessage(message) {
|
|
1101
|
-
if (!message || message.role !== 'assistant') return false;
|
|
1102
|
-
if (message.emptyFinal === true) return true;
|
|
1103
|
-
return true;
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
function sessionMessagesAdvancedBeyondCompactedOutgoing(currentSanitized, compactedSanitized) {
|
|
1107
|
-
if (!Array.isArray(currentSanitized) || !Array.isArray(compactedSanitized)) return false;
|
|
1108
|
-
if (currentSanitized.length !== compactedSanitized.length + 1) return false;
|
|
1109
|
-
const prefix = currentSanitized.slice(0, compactedSanitized.length);
|
|
1110
|
-
if (sessionMessagesSnapshotChanged(compactedSanitized, prefix)) return false;
|
|
1111
|
-
return isCompactedOutgoingFinalAssistantMessage(currentSanitized[currentSanitized.length - 1]);
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
export const _sessionMessagesAdvancedBeyondCompactedOutgoing = sessionMessagesAdvancedBeyondCompactedOutgoing;
|
|
1115
|
-
|
|
1116
|
-
function applyCompactFailurePersistToSession(activeSession, {
|
|
1117
|
-
priorSanitized,
|
|
1118
|
-
sanitized,
|
|
1119
|
-
messagesAdvanced,
|
|
1120
|
-
error = null,
|
|
1121
|
-
}) {
|
|
1122
|
-
if (!messagesAdvanced && !sessionMessagesSnapshotChanged(priorSanitized, sanitized)) return false;
|
|
1123
|
-
if (!messagesAdvanced) {
|
|
1124
|
-
activeSession.messages = sanitized;
|
|
1125
|
-
activeSession.providerState = undefined;
|
|
1126
|
-
}
|
|
1127
|
-
activeSession.updatedAt = Date.now();
|
|
1128
|
-
activeSession.lastUsedAt = Date.now();
|
|
1129
|
-
if (activeSession.compaction && typeof activeSession.compaction === 'object'
|
|
1130
|
-
&& (activeSession.compaction.lastStage === 'compacting'
|
|
1131
|
-
|| activeSession.compaction.lastStage === 'overflow_failed')) {
|
|
1132
|
-
const prev = activeSession.compaction;
|
|
1133
|
-
const cause = error?.cause;
|
|
1134
|
-
const overflow = error?.code === 'AGENT_CONTEXT_OVERFLOW';
|
|
1135
|
-
activeSession.compaction = {
|
|
1136
|
-
...prev,
|
|
1137
|
-
lastStage: prev.lastStage === 'overflow_failed'
|
|
1138
|
-
? 'overflow_failed'
|
|
1139
|
-
: (overflow ? 'overflow_failed' : 'failed'),
|
|
1140
|
-
lastCheckedAt: Date.now(),
|
|
1141
|
-
lastError: prev.lastError || cause?.message || error?.message || null,
|
|
1142
|
-
lastSemanticError: prev.lastSemanticError || cause?.message || null,
|
|
1143
|
-
lastRecallFastTrackError: prev.lastRecallFastTrackError
|
|
1144
|
-
|| (cause?.message && String(cause?.name || '').includes('Recall') ? cause.message : null),
|
|
1145
|
-
};
|
|
1146
|
-
}
|
|
1147
|
-
return true;
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
export const _applyCompactFailurePersistToSession = applyCompactFailurePersistToSession;
|
|
1151
|
-
|
|
1152
|
-
async function persistCompactedOutgoingAfterAskFailure({
|
|
1153
|
-
sessionId,
|
|
1154
|
-
activeSession,
|
|
1155
|
-
askGeneration,
|
|
1156
|
-
turnOutgoing,
|
|
1157
|
-
error = null,
|
|
1158
|
-
}) {
|
|
1159
|
-
if (!activeSession || activeSession.closed === true) return;
|
|
1160
|
-
if (!Array.isArray(turnOutgoing) || turnOutgoing.length === 0) return;
|
|
1161
|
-
const currentRuntime = _getRuntimeEntry(sessionId);
|
|
1162
|
-
if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) return;
|
|
1163
|
-
const sanitized = sanitizeSessionMessagesForModel(turnOutgoing);
|
|
1164
|
-
const priorSanitized = sanitizeSessionMessagesForModel(
|
|
1165
|
-
Array.isArray(activeSession.messages) ? activeSession.messages : [],
|
|
1166
|
-
);
|
|
1167
|
-
const messagesAdvanced = sessionMessagesAdvancedBeyondCompactedOutgoing(priorSanitized, sanitized);
|
|
1168
|
-
const applied = applyCompactFailurePersistToSession(activeSession, {
|
|
1169
|
-
priorSanitized,
|
|
1170
|
-
sanitized,
|
|
1171
|
-
messagesAdvanced,
|
|
1172
|
-
error,
|
|
1173
|
-
});
|
|
1174
|
-
if (!applied) return;
|
|
1175
|
-
try {
|
|
1176
|
-
await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
|
|
1177
|
-
} catch { /* best-effort: preserve in-memory compaction even if disk is slow */ }
|
|
1178
|
-
if (currentRuntime) currentRuntime.session = activeSession;
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
export async function askSession(sessionId, prompt, context, onToolCall, cwdOverride, explicitPrefetch, askOpts = {}) {
|
|
1182
|
-
const _askStartedAt = Date.now();
|
|
1183
|
-
const _promptSrc = 'prompt';
|
|
1184
|
-
const _prefetchFiles = (explicitPrefetch?.files?.length) || 0;
|
|
1185
|
-
const _prefetchCallers = (explicitPrefetch?.callers?.length) || 0;
|
|
1186
|
-
const _prefetchRefs = (explicitPrefetch?.references?.length) || 0;
|
|
1187
|
-
if (process.env.MIXDOG_DEBUG_AGENT) {
|
|
1188
|
-
process.stderr.write(`[agent-trace] t0-ask-start sessionHash=${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 8)} role=? iteration=0 promptSrc=${_promptSrc} prefetchFiles=${_prefetchFiles} callers=${_prefetchCallers} references=${_prefetchRefs}\n`);
|
|
1189
|
-
}
|
|
1190
|
-
const unlock = await acquireSessionLock(sessionId);
|
|
1191
|
-
const _lockWaitedMs = Date.now() - _askStartedAt;
|
|
1192
|
-
if (process.env.MIXDOG_DEBUG_AGENT) {
|
|
1193
|
-
process.stderr.write(`[agent-trace] lock-acquired waitedMs=${_lockWaitedMs}\n`);
|
|
1194
|
-
}
|
|
1195
|
-
// The mutex is held for the WHOLE askSession call, including any follow-up
|
|
1196
|
-
// turns drained from the pending-message queue below — the single outer
|
|
1197
|
-
// try/finally releases it exactly once. _result holds the last turn's
|
|
1198
|
-
// return value (the queued tail turns supersede the original prompt's
|
|
1199
|
-
// result, mirroring how a live chat returns the latest turn).
|
|
1200
|
-
let _result;
|
|
1201
|
-
// Local FIFO of follow-up prompts drained from the pending-message queue
|
|
1202
|
-
// after each turn — keeps queued `agent type=send` messages in order.
|
|
1203
|
-
const _pendingTail = [];
|
|
1204
|
-
// Hoisted so the outer finally (which runs once after the whole turn loop)
|
|
1205
|
-
// can compare against the last turn's generation.
|
|
1206
|
-
let askGeneration = 0;
|
|
1207
|
-
try {
|
|
1208
|
-
// Turn loop (pendingMessages pattern): run the current prompt, then drain
|
|
1209
|
-
// any `agent type=send` messages that were queued while this turn was in
|
|
1210
|
-
// flight and run them — in order — as the next user turn(s). Because the
|
|
1211
|
-
// queued send always lands AFTER the in-flight prompt here, ordering is
|
|
1212
|
-
// preserved and the spawn/connecting startup race disappears.
|
|
1213
|
-
for (;;) {
|
|
1214
|
-
let _pwstTurnDrained = null;
|
|
1215
|
-
// After the first turn, the next prompt comes from the drained queue.
|
|
1216
|
-
// (On the first iteration _pendingTail is empty and `prompt` is the
|
|
1217
|
-
// caller's original message.)
|
|
1218
|
-
if (_pendingTail.length > 0) {
|
|
1219
|
-
prompt = _pendingTail.shift();
|
|
1220
|
-
// Queued follow-ups are plain user turns — no caller context /
|
|
1221
|
-
// prefetch is re-applied (those belonged to the original ask).
|
|
1222
|
-
context = null;
|
|
1223
|
-
explicitPrefetch = null;
|
|
1224
|
-
} else if (!hasModelVisiblePromptContent(prompt)) {
|
|
1225
|
-
// Idle resume: TUI kicks an empty ask() after execution completions
|
|
1226
|
-
// mirror model-visible bodies into session pending. Drain that queue
|
|
1227
|
-
// here so we never synthesize an empty user turn for the model.
|
|
1228
|
-
const _preDrained = drainPendingMessages(sessionId);
|
|
1229
|
-
if (_preDrained.length > 0) {
|
|
1230
|
-
const _mergedPre = _mergePendingMessageEntries(_preDrained);
|
|
1231
|
-
if (_mergedPre?.content) {
|
|
1232
|
-
prompt = _mergedPre.content;
|
|
1233
|
-
context = null;
|
|
1234
|
-
explicitPrefetch = null;
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
}
|
|
1238
|
-
if (!hasModelVisiblePromptContent(prompt)) {
|
|
1239
|
-
_unlinkParentAbortListener(_getRuntimeEntry(sessionId));
|
|
1240
|
-
return _result;
|
|
1241
|
-
}
|
|
1242
|
-
// ── Synchronous pre-await setup (must happen before any await so
|
|
1243
|
-
// closeSession() can't interleave between load and registration) ──
|
|
1244
|
-
const preSession = loadSession(sessionId);
|
|
1245
|
-
if (!preSession) {
|
|
1246
|
-
throw new Error(`Session "${sessionId}" not found`);
|
|
1247
|
-
}
|
|
1248
|
-
if (preSession.closed === true) {
|
|
1249
|
-
throw new SessionClosedError(sessionId, 'session already closed');
|
|
1250
|
-
}
|
|
1251
|
-
// A prior crash/partial-save during compaction may have pinned
|
|
1252
|
-
// compaction.lastStage='compacting'. This ask is starting fresh, so
|
|
1253
|
-
// recover the stale transient stage before the loop's pre-send compact
|
|
1254
|
-
// path runs (it will overwrite lastStage with real telemetry).
|
|
1255
|
-
normalizeStaleCompactingStage(preSession);
|
|
1256
|
-
askGeneration = typeof preSession.generation === 'number' ? preSession.generation : 0;
|
|
1257
|
-
const runtime = _touchRuntime(sessionId);
|
|
1258
|
-
// Fresh controller per ask — the previous ask's controller may have aborted.
|
|
1259
|
-
runtime.controller = createAbortController();
|
|
1260
|
-
runtime.generation = askGeneration;
|
|
1261
|
-
runtime.closed = false;
|
|
1262
|
-
runtime.session = preSession;
|
|
1263
|
-
markSessionAskStart(sessionId);
|
|
1264
|
-
// Preprocessing is inside try so provider-not-available / trim failures
|
|
1265
|
-
// fall into the catch and mark the session as errored rather than
|
|
1266
|
-
// leaving stage='connecting' forever.
|
|
1267
|
-
let activeSession = preSession;
|
|
1268
|
-
let cancelledUserTurnContent = '';
|
|
1269
|
-
let _turnOutgoing = null;
|
|
1270
|
-
try {
|
|
1271
|
-
const session = activeSession;
|
|
1272
|
-
const provider = getProvider(session.provider);
|
|
1273
|
-
// Register the live session object into runtime so closeSession()
|
|
1274
|
-
// can read allBashSessionIds that loop.mjs appends mid-turn.
|
|
1275
|
-
runtime.session = session;
|
|
1276
|
-
if (!provider)
|
|
1277
|
-
throw new Error(`Provider "${session.provider}" not available`);
|
|
1278
|
-
const contextMeta = resolveSessionContextMeta(provider, session.model, session);
|
|
1279
|
-
session.contextWindow = contextMeta.contextWindow;
|
|
1280
|
-
session.rawContextWindow = contextMeta.rawContextWindow;
|
|
1281
|
-
session.effectiveContextWindowPercent = contextMeta.effectiveContextWindowPercent;
|
|
1282
|
-
session.autoCompactTokenLimit = contextMeta.autoCompactTokenLimit;
|
|
1283
|
-
session.compactBoundaryTokens = contextMeta.compactBoundaryTokens;
|
|
1284
|
-
session.compaction = {
|
|
1285
|
-
...(session.compaction || {}),
|
|
1286
|
-
auto: session.compaction?.auto !== false,
|
|
1287
|
-
semantic: session.compaction?.semantic ?? 'auto',
|
|
1288
|
-
type: normalizeCompactType(session.compaction?.type ?? session.compaction?.compactType ?? session.compaction?.compact_type, DEFAULT_COMPACT_TYPE),
|
|
1289
|
-
compactType: normalizeCompactType(session.compaction?.type ?? session.compaction?.compactType ?? session.compaction?.compact_type, DEFAULT_COMPACT_TYPE),
|
|
1290
|
-
boundaryTokens: contextMeta.compactBoundaryTokens,
|
|
1291
|
-
bufferTokens: positiveContextWindow(session.compaction?.bufferTokens ?? session.compaction?.buffer) || session.compaction?.bufferTokens || null,
|
|
1292
|
-
keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens) || session.compaction?.keepTokens || null,
|
|
1293
|
-
contextWindow: contextMeta.contextWindow,
|
|
1294
|
-
rawContextWindow: contextMeta.rawContextWindow,
|
|
1295
|
-
effectiveContextWindowPercent: contextMeta.effectiveContextWindowPercent,
|
|
1296
|
-
autoCompactTokenLimit: contextMeta.autoCompactTokenLimit,
|
|
1297
|
-
};
|
|
1298
|
-
// Cap caller-supplied / prefetched context so an oversized
|
|
1299
|
-
// payload can't blow the session token budget before the
|
|
1300
|
-
// first model call. 32 KB ~ 8k tokens at the 4 B/tok
|
|
1301
|
-
// working average; longer is silently truncated with a
|
|
1302
|
-
// visible marker so the model still sees the prefix and
|
|
1303
|
-
// a hint about the cut.
|
|
1304
|
-
const _CTX_CHAR_CAP = 32 * 1024;
|
|
1305
|
-
const _capCtx = (text) => {
|
|
1306
|
-
if (typeof text !== 'string') return '';
|
|
1307
|
-
if (text.length <= _CTX_CHAR_CAP) return text;
|
|
1308
|
-
return `${text.slice(0, _CTX_CHAR_CAP)}\n\n... [context truncated; original ${text.length} chars]`;
|
|
1309
|
-
};
|
|
1310
|
-
// Inline context + prefetch INTO the prompt as a single user turn,
|
|
1311
|
-
// marked with explicit section headers. The previous design pushed
|
|
1312
|
-
// context as separate user messages with pre-injected assistant
|
|
1313
|
-
// "Noted." acks; that conversational pattern taught some models a
|
|
1314
|
-
// low-effort rhythm and they responded with "Noted." / empty tags
|
|
1315
|
-
// even to the real task. Single-turn structure with a labelled
|
|
1316
|
-
// `# Task` block forces the model to treat the brief as the work
|
|
1317
|
-
// unit, not as another piece of context to ack.
|
|
1318
|
-
const explicitPrefetchResult = await _tryBridgeExplicitPrefetch(session, explicitPrefetch);
|
|
1319
|
-
let _contextBlock = '';
|
|
1320
|
-
if (context) {
|
|
1321
|
-
_contextBlock += `# Additional context\n${_capCtx(context)}\n\n`;
|
|
1322
|
-
}
|
|
1323
|
-
if (explicitPrefetchResult) {
|
|
1324
|
-
_contextBlock += `# Prefetch\n${_capCtx(explicitPrefetchResult)}\n\n`;
|
|
1325
|
-
}
|
|
1326
|
-
const historyMessages = sanitizeSessionMessagesForModel(session.messages);
|
|
1327
|
-
const beforeCount = historyMessages.length + 1;
|
|
1328
|
-
const promptTextForMetrics = promptContentText(prompt);
|
|
1329
|
-
// Soft warning only; real size management (compaction primary,
|
|
1330
|
-
// byte-budget trim as safety net) lives in agentLoop. Selecting a
|
|
1331
|
-
// 25% pre-trim here would starve compaction's 50% threshold.
|
|
1332
|
-
const softBudget = Math.floor(session.contextWindow * 0.25);
|
|
1333
|
-
const promptTokenEstimate = promptTextForMetrics.length * 0.5; // conservative for CJK
|
|
1334
|
-
if (promptTokenEstimate > softBudget * 0.7) {
|
|
1335
|
-
process.stderr.write(`[session] Warning: prompt is very large (est. ${Math.round(promptTokenEstimate)} tokens vs ${softBudget} soft budget)\n`);
|
|
1336
|
-
}
|
|
1337
|
-
const effectiveCwd = cwdOverride || session.cwd;
|
|
1338
|
-
const shouldInjectSessionStart = session.sessionStartMetaInjected !== true
|
|
1339
|
-
&& !hasUserConversationMessage(historyMessages);
|
|
1340
|
-
const _sessionStartBlock = shouldInjectSessionStart
|
|
1341
|
-
? buildSessionStartBlock(session, effectiveCwd)
|
|
1342
|
-
: '';
|
|
1343
|
-
const _currentTimeBlock = buildCurrentTimeBlock(prompt);
|
|
1344
|
-
const _turnReminderBlock = _currentTimeBlock
|
|
1345
|
-
? `<system-reminder>\n# Current Time\n${_currentTimeBlock}\n</system-reminder>`
|
|
1346
|
-
: '';
|
|
1347
|
-
const _turnPrefixBlock = [_sessionStartBlock, _turnReminderBlock].filter(Boolean).join('\n\n');
|
|
1348
|
-
const _baseUserTurnContent = prefixUserTurnContent(prompt, _contextBlock);
|
|
1349
|
-
const _userTurnContent = prefixSessionStartContent(_baseUserTurnContent, _turnPrefixBlock);
|
|
1350
|
-
if (shouldInjectSessionStart && _sessionStartBlock) {
|
|
1351
|
-
session.sessionStartMetaInjected = true;
|
|
1352
|
-
}
|
|
1353
|
-
cancelledUserTurnContent = _userTurnContent;
|
|
1354
|
-
const outgoing = [...historyMessages, { role: 'user', content: _userTurnContent }];
|
|
1355
|
-
_turnOutgoing = outgoing;
|
|
1356
|
-
// Expose the in-flight working transcript so contextStatus() can
|
|
1357
|
-
// estimate the LIVE context footprint mid-turn. agentLoop mutates
|
|
1358
|
-
// `outgoing` in place (user turn + tool calls/results + compaction),
|
|
1359
|
-
// so the statusline context gauge climbs as the turn accumulates
|
|
1360
|
-
// tool output instead of freezing at the pre-turn snapshot. Cleared
|
|
1361
|
-
// on turn commit (below) and in the ask finally.
|
|
1362
|
-
session.liveTurnMessages = outgoing;
|
|
1363
|
-
// Per-turn injected-context trace row (complements kind:"usage").
|
|
1364
|
-
// Cheap byte-length accounting — no hashing, no payload bodies.
|
|
1365
|
-
// Honors the same MIXDOG_AGENT_TRACE_DISABLE gate as usage rows;
|
|
1366
|
-
// appendAgentTrace is a no-op when that env is set.
|
|
1367
|
-
try {
|
|
1368
|
-
const _ctxBytes = Buffer.byteLength(context || '', 'utf8');
|
|
1369
|
-
const _prefetchBytes = Buffer.byteLength(explicitPrefetchResult || '', 'utf8');
|
|
1370
|
-
const _promptBytes = promptContentBytes(prompt);
|
|
1371
|
-
const _userTurnBytes = promptContentBytes(_userTurnContent);
|
|
1372
|
-
const _messagesBytes = Buffer.byteLength(JSON.stringify(historyMessages || []), 'utf8');
|
|
1373
|
-
const _totalBytes = _userTurnBytes + _messagesBytes;
|
|
1374
|
-
appendAgentTrace({
|
|
1375
|
-
kind: 'context',
|
|
1376
|
-
sessionId,
|
|
1377
|
-
model: session.model,
|
|
1378
|
-
provider: session.provider,
|
|
1379
|
-
totalBytes: _totalBytes,
|
|
1380
|
-
breakdown: {
|
|
1381
|
-
contextBytes: _ctxBytes,
|
|
1382
|
-
prefetchBytes: _prefetchBytes,
|
|
1383
|
-
promptBytes: _promptBytes,
|
|
1384
|
-
userTurnBytes: _userTurnBytes,
|
|
1385
|
-
messagesBytes: _messagesBytes,
|
|
1386
|
-
messagesCount: historyMessages.length,
|
|
1387
|
-
},
|
|
1388
|
-
});
|
|
1389
|
-
} catch { /* trace must never break the ask path */ }
|
|
1390
|
-
const agentLoop = await _getAgentLoop();
|
|
1391
|
-
const priorToolApprovalHook = session.toolApprovalHook;
|
|
1392
|
-
if (typeof askOpts?.onToolApproval === 'function') {
|
|
1393
|
-
session.toolApprovalHook = askOpts.onToolApproval;
|
|
1394
|
-
}
|
|
1395
|
-
let result;
|
|
1396
|
-
try {
|
|
1397
|
-
result = await _api_call_with_interrupt(sessionId, (signal) =>
|
|
1398
|
-
agentLoop(provider, outgoing, session.model, session.tools, onToolCall, effectiveCwd, {
|
|
1399
|
-
effort: session.effort || null,
|
|
1400
|
-
fast: session.fast === true,
|
|
1401
|
-
sessionId,
|
|
1402
|
-
onTextDelta: typeof askOpts?.onTextDelta === 'function' ? askOpts.onTextDelta : undefined,
|
|
1403
|
-
onReasoningDelta: typeof askOpts?.onReasoningDelta === 'function' ? askOpts.onReasoningDelta : undefined,
|
|
1404
|
-
onAssistantText: typeof askOpts?.onAssistantText === 'function' ? askOpts.onAssistantText : undefined,
|
|
1405
|
-
onUsageDelta: (d) => {
|
|
1406
|
-
persistIterationMetrics(d).catch(() => {});
|
|
1407
|
-
try { askOpts?.onUsageDelta?.(d); } catch {}
|
|
1408
|
-
},
|
|
1409
|
-
onToolResult: typeof askOpts?.onToolResult === 'function' ? askOpts.onToolResult : undefined,
|
|
1410
|
-
onToolApproval: typeof askOpts?.onToolApproval === 'function' ? askOpts.onToolApproval : undefined,
|
|
1411
|
-
onCompactEvent: typeof askOpts?.onCompactEvent === 'function' ? askOpts.onCompactEvent : undefined,
|
|
1412
|
-
// Mid-turn steering drain. agentLoop calls this at every
|
|
1413
|
-
// tool-batch boundary (before the next provider.send) and
|
|
1414
|
-
// injects any returned strings as user turns — so input
|
|
1415
|
-
// (user typing, `agent type=send`) that arrives WHILE a
|
|
1416
|
-
// long multi-tool turn is in flight is picked up on the
|
|
1417
|
-
// model's very next iteration instead of waiting for the
|
|
1418
|
-
// whole task to finish. The post-turn _pendingTail drain
|
|
1419
|
-
// below still handles "followUp" input that lands after the
|
|
1420
|
-
// agent would otherwise stop. Same queue, two drain points.
|
|
1421
|
-
drainSteering: (sid) => {
|
|
1422
|
-
const out = [];
|
|
1423
|
-
if (typeof askOpts?.drainSteering === 'function') {
|
|
1424
|
-
try {
|
|
1425
|
-
const drained = askOpts.drainSteering(sid || sessionId);
|
|
1426
|
-
if (Array.isArray(drained)) out.push(...drained);
|
|
1427
|
-
} catch { /* best-effort steering drain */ }
|
|
1428
|
-
}
|
|
1429
|
-
try { out.push(...drainPendingMessages(sid || sessionId)); }
|
|
1430
|
-
catch { /* best-effort pending drain */ }
|
|
1431
|
-
return out;
|
|
1432
|
-
},
|
|
1433
|
-
onSteerMessage: typeof askOpts?.onSteerMessage === 'function' ? askOpts.onSteerMessage : undefined,
|
|
1434
|
-
notifyFn: typeof askOpts?.notifyFn === 'function' ? askOpts.notifyFn : undefined,
|
|
1435
|
-
promptCacheKey: session.promptCacheKey || sessionId,
|
|
1436
|
-
// Provider-scoped cache key (mixdog-codex, mixdog-claude…).
|
|
1437
|
-
// Distinct from sessionId — providers that pool sockets
|
|
1438
|
-
// per-session (openai-oauth WS) use sessionId as the
|
|
1439
|
-
// pool bucket and providerCacheKey as the server-side
|
|
1440
|
-
// prompt-cache shard so parallel callers don't collide
|
|
1441
|
-
// on a mid-turn socket while still sharing prefix cache.
|
|
1442
|
-
providerCacheKey: session.promptCacheKey || null,
|
|
1443
|
-
signal,
|
|
1444
|
-
providerState: session.providerState ?? undefined,
|
|
1445
|
-
session,
|
|
1446
|
-
// Agent Runtime cache settings — merged last so session overrides
|
|
1447
|
-
// don't get overridden by defaults. When session has no profile,
|
|
1448
|
-
// providerCacheOpts is null and this spread is a no-op.
|
|
1449
|
-
...(session.providerCacheOpts || {}),
|
|
1450
|
-
onStageChange: (stage) => {
|
|
1451
|
-
updateSessionStage(sessionId, stage);
|
|
1452
|
-
try { askOpts?.onStageChange?.(stage); } catch {}
|
|
1453
|
-
},
|
|
1454
|
-
onStreamDelta: () => {
|
|
1455
|
-
markSessionStreamDelta(sessionId).catch(() => {});
|
|
1456
|
-
try { askOpts?.onStreamDelta?.(); } catch {}
|
|
1457
|
-
},
|
|
1458
|
-
}),
|
|
1459
|
-
);
|
|
1460
|
-
} finally {
|
|
1461
|
-
if (priorToolApprovalHook === undefined) {
|
|
1462
|
-
delete session.toolApprovalHook;
|
|
1463
|
-
} else {
|
|
1464
|
-
session.toolApprovalHook = priorToolApprovalHook;
|
|
1465
|
-
}
|
|
1466
|
-
}
|
|
1467
|
-
// Post-loop validation: if closeSession() landed while we were awaiting,
|
|
1468
|
-
// drop the save so the tombstone on disk isn't overwritten.
|
|
1469
|
-
const currentRuntime = _getRuntimeEntry(sessionId);
|
|
1470
|
-
if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) {
|
|
1471
|
-
const reason = currentRuntime?.closedReason;
|
|
1472
|
-
throw new SessionClosedError(sessionId, `closed during call (reason=${reason || 'unknown'})`, reason || null);
|
|
1473
|
-
}
|
|
1474
|
-
// Update and save. outgoing is mutated in place by agentLoop
|
|
1475
|
-
// (compaction + safety trim), so its length reflects post-loop state.
|
|
1476
|
-
const messagesDropped = Math.max(0, beforeCount - outgoing.length);
|
|
1477
|
-
session.messages = sanitizeSessionMessagesForModel(outgoing);
|
|
1478
|
-
// Turn committed into session.messages; drop the live-turn alias so
|
|
1479
|
-
// contextStatus() reverts to the authoritative committed transcript.
|
|
1480
|
-
session.liveTurnMessages = null;
|
|
1481
|
-
if (result.content || result.reasoningContent) {
|
|
1482
|
-
session.messages.push({
|
|
1483
|
-
role: 'assistant',
|
|
1484
|
-
// Keep content as-is in memory (model-visible). Image bytes,
|
|
1485
|
-
// if any, are swapped for a placeholder only at disk write
|
|
1486
|
-
// time inside the session store (store.mjs _sessionForDisk).
|
|
1487
|
-
content: result.content || '',
|
|
1488
|
-
...(typeof result.reasoningContent === 'string' && result.reasoningContent
|
|
1489
|
-
? { reasoningContent: result.reasoningContent }
|
|
1490
|
-
: {}),
|
|
1491
|
-
});
|
|
1492
|
-
} else {
|
|
1493
|
-
// Empty terminal turn: still persist a forensic record so
|
|
1494
|
-
// post-mortem inspection can distinguish "work landed but
|
|
1495
|
-
// synthesis missing" from "session never ran". Stop reason,
|
|
1496
|
-
// usage, iterations, and tool-call totals survive even when
|
|
1497
|
-
// the assistant produced no content/reasoning.
|
|
1498
|
-
const _emptyStop = result?.stopReason ?? result?.stop_reason ?? null;
|
|
1499
|
-
const _emptyUsage = result?.usage ? {
|
|
1500
|
-
inputTokens: result.usage.inputTokens || 0,
|
|
1501
|
-
outputTokens: result.usage.outputTokens || 0,
|
|
1502
|
-
cachedTokens: result.usage.cachedTokens || 0,
|
|
1503
|
-
cacheWriteTokens: result.usage.cacheWriteTokens || 0,
|
|
1504
|
-
} : null;
|
|
1505
|
-
// Provider content-block classification — distinguishes a
|
|
1506
|
-
// thinking-only stall (model emitted reasoning blocks but no
|
|
1507
|
-
// text/tool_use) from a true silent empty turn. Anthropic
|
|
1508
|
-
// providers (anthropic.mjs, anthropic-oauth.mjs) set these
|
|
1509
|
-
// fields on the result; other providers may omit them.
|
|
1510
|
-
const _emptyHasThinking = typeof result?.hasThinkingContent === 'boolean'
|
|
1511
|
-
? result.hasThinkingContent
|
|
1512
|
-
: null;
|
|
1513
|
-
const _emptyBlockTypes = Array.isArray(result?.contentBlockTypes)
|
|
1514
|
-
? result.contentBlockTypes.slice()
|
|
1515
|
-
: null;
|
|
1516
|
-
session.messages.push({
|
|
1517
|
-
role: 'assistant',
|
|
1518
|
-
content: '',
|
|
1519
|
-
emptyFinal: true,
|
|
1520
|
-
stopReason: _emptyStop,
|
|
1521
|
-
iterations: result?.iterations ?? null,
|
|
1522
|
-
toolCallsTotal: result?.toolCallsTotal ?? null,
|
|
1523
|
-
usage: _emptyUsage,
|
|
1524
|
-
...(_emptyHasThinking !== null ? { hasThinkingContent: _emptyHasThinking } : {}),
|
|
1525
|
-
...(_emptyBlockTypes !== null ? { contentBlockTypes: _emptyBlockTypes } : {}),
|
|
1526
|
-
ts: Date.now(),
|
|
1527
|
-
});
|
|
1528
|
-
try {
|
|
1529
|
-
const _blockTypesStr = _emptyBlockTypes ? _emptyBlockTypes.join(',') || 'none' : 'unknown';
|
|
1530
|
-
const _thinkingStr = _emptyHasThinking === null ? 'unknown' : String(_emptyHasThinking);
|
|
1531
|
-
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`);
|
|
1532
|
-
} catch {}
|
|
1533
|
-
}
|
|
1534
|
-
session.updatedAt = Date.now();
|
|
1535
|
-
session.lastUsedAt = Date.now();
|
|
1536
|
-
applyAskTerminalUsageTotals(session, result, {
|
|
1537
|
-
skipTotalsIfIncremental: runtime?.usageMetricsTurnIncremental === true,
|
|
1538
|
-
});
|
|
1539
|
-
// Agent Runtime cache stats — record hit/miss after every successful
|
|
1540
|
-
// ask so the registry reflects all agent traffic, not just
|
|
1541
|
-
// maintenance cycles. Guarded against any agent-runtime error so
|
|
1542
|
-
// metric recording never breaks the ask itself.
|
|
1543
|
-
let prefixHashForLog = null;
|
|
1544
|
-
if (session.profileId && result.usage && _agentRuntimeApi) {
|
|
1545
|
-
try {
|
|
1546
|
-
const profile = _agentRuntimeApi.getProfile(session.profileId);
|
|
1547
|
-
if (profile) {
|
|
1548
|
-
// Collect every leading system-role message (BP1, BP2, ...)
|
|
1549
|
-
// until the first non-system message so the registry hash
|
|
1550
|
-
// captures the full ordered provider prefix, not just BP1.
|
|
1551
|
-
const systemMsgs = [];
|
|
1552
|
-
for (const m of session.messages) {
|
|
1553
|
-
if (m?.role !== 'system') break;
|
|
1554
|
-
systemMsgs.push(typeof m.content === 'string' ? m.content : '');
|
|
1555
|
-
}
|
|
1556
|
-
_agentRuntimeApi.recordCall(profile, session.provider, {
|
|
1557
|
-
systemPrompt: systemMsgs,
|
|
1558
|
-
tools: session.tools || [],
|
|
1559
|
-
usage: result.usage,
|
|
1560
|
-
});
|
|
1561
|
-
const entry = _agentRuntimeApi.registry?.data?.profiles?.[session.profileId]?.[session.provider];
|
|
1562
|
-
prefixHashForLog = entry?.prefixHash || null;
|
|
1563
|
-
}
|
|
1564
|
-
} catch {}
|
|
1565
|
-
}
|
|
1566
|
-
// Append to the agent trace store with rich usage fields.
|
|
1567
|
-
if (result.usage) {
|
|
1568
|
-
const inputTokens = result.usage.inputTokens || 0;
|
|
1569
|
-
const outputTokens = result.usage.outputTokens || 0;
|
|
1570
|
-
const cacheReadTokens = result.usage.cachedTokens || 0;
|
|
1571
|
-
const cacheWriteTokens = result.usage.cacheWriteTokens || 0;
|
|
1572
|
-
// Unified total-prompt field. Anthropic = input+cache_read+cache_write
|
|
1573
|
-
// (additive); OpenAI OAuth/API/Gemini = input_tokens already includes the
|
|
1574
|
-
// cached portion (inclusive), so the fallback must not double-count.
|
|
1575
|
-
const { isInclusiveProvider, computeCostUsd } = await import('../../../shared/llm/cost.mjs');
|
|
1576
|
-
const inclusive = isInclusiveProvider(session.provider);
|
|
1577
|
-
const promptTokens = typeof result.usage.promptTokens === 'number'
|
|
1578
|
-
? result.usage.promptTokens
|
|
1579
|
-
: (inclusive
|
|
1580
|
-
? Math.max(inputTokens, cacheReadTokens + cacheWriteTokens)
|
|
1581
|
-
: inputTokens + cacheReadTokens + cacheWriteTokens);
|
|
1582
|
-
let costUsd = result.usage.costUsd || 0;
|
|
1583
|
-
if (!costUsd) {
|
|
1584
|
-
try {
|
|
1585
|
-
costUsd = computeCostUsd({
|
|
1586
|
-
model: session.model,
|
|
1587
|
-
provider: session.provider,
|
|
1588
|
-
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
|
|
1589
|
-
});
|
|
1590
|
-
} catch { /* best-effort */ }
|
|
1591
|
-
}
|
|
1592
|
-
logLlmCall({
|
|
1593
|
-
ts: new Date().toISOString(),
|
|
1594
|
-
sourceType: session.sourceType || 'lead',
|
|
1595
|
-
sourceName: session.sourceName || session.agent || null,
|
|
1596
|
-
preset: session.presetName || null,
|
|
1597
|
-
model: session.model,
|
|
1598
|
-
provider: session.provider,
|
|
1599
|
-
duration: Date.now() - _askStartedAt,
|
|
1600
|
-
profileId: session.profileId || null,
|
|
1601
|
-
sessionId: session.id,
|
|
1602
|
-
inputTokens,
|
|
1603
|
-
outputTokens,
|
|
1604
|
-
cacheReadTokens,
|
|
1605
|
-
cacheWriteTokens,
|
|
1606
|
-
promptTokens,
|
|
1607
|
-
prefixHash: prefixHashForLog,
|
|
1608
|
-
costUsd,
|
|
1609
|
-
});
|
|
1610
|
-
recordStandaloneStatusTelemetry(session, result, Date.now() - _askStartedAt);
|
|
1611
|
-
}
|
|
1612
|
-
// Persist opaque providerState for future stateful providers.
|
|
1613
|
-
// No provider currently emits it (openai-oauth is stateless per
|
|
1614
|
-
// contract), so this branch is dormant — kept so a future
|
|
1615
|
-
// Responses-API provider with stable continuation can plug in
|
|
1616
|
-
// without reworking the session shape.
|
|
1617
|
-
if (result.providerState !== undefined) {
|
|
1618
|
-
session.providerState = result.providerState;
|
|
1619
|
-
}
|
|
1620
|
-
const terminalResultPreview = {
|
|
1621
|
-
...result,
|
|
1622
|
-
trimmed: messagesDropped > 0,
|
|
1623
|
-
messagesDropped,
|
|
1624
|
-
};
|
|
1625
|
-
_pwstTurnDrained = drainPendingMessages(sessionId);
|
|
1626
|
-
if (_pwstTurnDrained.length === 0 && typeof askOpts?.onTerminalResult === 'function') {
|
|
1627
|
-
try {
|
|
1628
|
-
askOpts.onTerminalResult(terminalResultPreview, {
|
|
1629
|
-
sessionId,
|
|
1630
|
-
beforeSave: true,
|
|
1631
|
-
durationMs: Date.now() - _askStartedAt,
|
|
1632
|
-
});
|
|
1633
|
-
} catch { /* best-effort early completion relay */ }
|
|
1634
|
-
}
|
|
1635
|
-
// Auto-compact runs at the start of the next
|
|
1636
|
-
// query/provider send (agentLoop pre-send), not after the previous
|
|
1637
|
-
// answer. This lets queued follow-up prompts resume immediately;
|
|
1638
|
-
// if they need compaction, their own spinner shows compacting first.
|
|
1639
|
-
// Bounded, best-effort terminal save. The result is already produced
|
|
1640
|
-
// and (for agent surfaces) relayed via onTerminalResult above. If the
|
|
1641
|
-
// disk write stalls, blocking the terminal unwind here would strand the
|
|
1642
|
-
// owning background task in `running` and suppress its completion
|
|
1643
|
-
// notification. Cap the wait; let a slow write finish in the
|
|
1644
|
-
// background instead of holding askSession() open indefinitely.
|
|
1645
|
-
{
|
|
1646
|
-
const savePromise = saveSessionAsync(session, { expectedGeneration: askGeneration });
|
|
1647
|
-
let saveTimer = null;
|
|
1648
|
-
const saveTimeout = new Promise((resolveTimeout) => {
|
|
1649
|
-
saveTimer = setTimeout(() => resolveTimeout('__save_timeout__'), TERMINAL_SAVE_TIMEOUT_MS);
|
|
1650
|
-
saveTimer.unref?.();
|
|
1651
|
-
});
|
|
1652
|
-
try {
|
|
1653
|
-
const outcome = await Promise.race([
|
|
1654
|
-
savePromise.then(() => '__save_ok__', (err) => { throw err; }),
|
|
1655
|
-
saveTimeout,
|
|
1656
|
-
]);
|
|
1657
|
-
if (outcome === '__save_timeout__') {
|
|
1658
|
-
try { process.stderr.write(`[session] terminal save exceeded ${TERMINAL_SAVE_TIMEOUT_MS}ms; continuing best-effort (${sessionId})\n`); } catch {}
|
|
1659
|
-
// Don't drop the write — let it settle in the background.
|
|
1660
|
-
savePromise.catch((err) => {
|
|
1661
|
-
try { process.stderr.write(`[session] deferred terminal save failed: ${err?.message || err}\n`); } catch {}
|
|
1662
|
-
});
|
|
1663
|
-
}
|
|
1664
|
-
} finally {
|
|
1665
|
-
if (saveTimer) { try { clearTimeout(saveTimer); } catch {} }
|
|
1666
|
-
}
|
|
1667
|
-
}
|
|
1668
|
-
activeSession = session;
|
|
1669
|
-
runtime.session = session;
|
|
1670
|
-
// Tag empty-synthesis BEFORE markSessionDone so the watchdog
|
|
1671
|
-
// (which inspects entry.emptyFinal first) classifies the
|
|
1672
|
-
// terminal state correctly even if it ticks during unwind.
|
|
1673
|
-
const isEmptyFinal = !result.content && !result.reasoningContent;
|
|
1674
|
-
if (isEmptyFinal) {
|
|
1675
|
-
markSessionEmptyFinal(sessionId);
|
|
1676
|
-
}
|
|
1677
|
-
markSessionDone(sessionId, { empty: isEmptyFinal });
|
|
1678
|
-
_result = terminalResultPreview;
|
|
1679
|
-
} catch (err) {
|
|
1680
|
-
// Cancellation/error paths bypass the commit point above; drop the
|
|
1681
|
-
// live-turn alias so contextStatus() stops estimating from the
|
|
1682
|
-
// stale in-flight array once the turn unwinds.
|
|
1683
|
-
if (activeSession) activeSession.liveTurnMessages = null;
|
|
1684
|
-
if (err instanceof SessionClosedError) {
|
|
1685
|
-
const currentRuntime = _getRuntimeEntry(sessionId);
|
|
1686
|
-
if (!currentRuntime?.closed) {
|
|
1687
|
-
if (activeSession) {
|
|
1688
|
-
const originalMessages = Array.isArray(activeSession.messages) ? activeSession.messages : [];
|
|
1689
|
-
const cleanedMessages = sanitizeSessionMessagesForModel(originalMessages);
|
|
1690
|
-
const nextMessages = cleanedMessages.slice();
|
|
1691
|
-
// In-memory cancelled turn keeps its original content
|
|
1692
|
-
// (images intact for the next model send); the store
|
|
1693
|
-
// layer placeholders image bytes on disk serialization.
|
|
1694
|
-
const cancelledStoredContent = cancelledUserTurnContent;
|
|
1695
|
-
const shouldPreserveUserTurn = cancelledStoredContent && !isInternalRuntimeNotificationText(cancelledStoredContent);
|
|
1696
|
-
const lastMessage = nextMessages[nextMessages.length - 1];
|
|
1697
|
-
if (shouldPreserveUserTurn && !(lastMessage?.role === 'user' && promptContentText(lastMessage.content) === promptContentText(cancelledStoredContent))) {
|
|
1698
|
-
nextMessages.push({ role: 'user', content: cancelledStoredContent });
|
|
1699
|
-
}
|
|
1700
|
-
const messagesChanged = nextMessages.length !== originalMessages.length
|
|
1701
|
-
|| nextMessages.some((message, index) => message !== originalMessages[index]);
|
|
1702
|
-
if (messagesChanged) {
|
|
1703
|
-
activeSession.messages = nextMessages;
|
|
1704
|
-
activeSession.updatedAt = Date.now();
|
|
1705
|
-
activeSession.lastUsedAt = Date.now();
|
|
1706
|
-
try {
|
|
1707
|
-
await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
|
|
1708
|
-
} catch { /* cancellation cleanup is best-effort */ }
|
|
1709
|
-
}
|
|
1710
|
-
}
|
|
1711
|
-
markSessionCancelled(sessionId);
|
|
1712
|
-
}
|
|
1713
|
-
// Cancellation is not an error; propagate silently so callers
|
|
1714
|
-
// can render it as "cancelled" rather than a red failure.
|
|
1715
|
-
throw err;
|
|
1716
|
-
}
|
|
1717
|
-
await persistCompactedOutgoingAfterAskFailure({
|
|
1718
|
-
sessionId,
|
|
1719
|
-
activeSession,
|
|
1720
|
-
askGeneration,
|
|
1721
|
-
turnOutgoing: _turnOutgoing,
|
|
1722
|
-
error: err,
|
|
1723
|
-
});
|
|
1724
|
-
markSessionError(sessionId, err && err.message ? err.message : String(err));
|
|
1725
|
-
throw err;
|
|
1726
|
-
}
|
|
1727
|
-
// ── Turn complete. Drain the pending-message queue: any `agent type=send` that arrived while this
|
|
1728
|
-
// turn was in flight runs next, in order, as a follow-up user turn.
|
|
1729
|
-
// The mutex is still held, so a send racing this drain either landed
|
|
1730
|
-
// before (picked up here) or enqueues for the next loop. When the
|
|
1731
|
-
// queue is empty we return the latest turn's result. ──
|
|
1732
|
-
const _drained = (_pwstTurnDrained && _pwstTurnDrained.length > 0)
|
|
1733
|
-
? _pwstTurnDrained
|
|
1734
|
-
: drainPendingMessages(sessionId);
|
|
1735
|
-
if (_drained.length > 0) {
|
|
1736
|
-
// Same merge rule as the mid-turn steering drain (loop.mjs) and
|
|
1737
|
-
// the TUI engine.mjs drain(): a single drain batch is joined with
|
|
1738
|
-
// "\n" and delivered as ONE follow-up turn, not N isolated turns.
|
|
1739
|
-
// Keeps every steering/follow-up path on identical
|
|
1740
|
-
// merge-then-deliver semantics. Anything that arrives AFTER this
|
|
1741
|
-
// drain enqueues for the next loop pass and is merged there.
|
|
1742
|
-
const _mergedTail = _mergePendingMessageEntries(_drained);
|
|
1743
|
-
if (_mergedTail?.content) {
|
|
1744
|
-
_pendingTail.push(_mergedTail.content);
|
|
1745
|
-
const refreshed = loadSession(sessionId);
|
|
1746
|
-
if (refreshed && refreshed.closed !== true) {
|
|
1747
|
-
activeSession = refreshed;
|
|
1748
|
-
runtime.session = refreshed;
|
|
1749
|
-
}
|
|
1750
|
-
continue;
|
|
1751
|
-
}
|
|
1752
|
-
}
|
|
1753
|
-
_unlinkParentAbortListener(_getRuntimeEntry(sessionId));
|
|
1754
|
-
return _result;
|
|
1755
|
-
}
|
|
1756
|
-
} finally {
|
|
1757
|
-
// Clear the controller only if it's still ours (closeSession may have
|
|
1758
|
-
// swapped it). Leave the rest of the runtime entry intact so agent type=list
|
|
1759
|
-
// can still surface the final stage (done/error/cancelling).
|
|
1760
|
-
const entry = _getRuntimeEntry(sessionId);
|
|
1761
|
-
if (entry && entry.generation === askGeneration) {
|
|
1762
|
-
_unlinkParentAbortListener(entry);
|
|
1763
|
-
entry.controller = null;
|
|
1764
|
-
// Detach the live session reference; ask is over.
|
|
1765
|
-
entry.session = null;
|
|
1766
|
-
}
|
|
1767
|
-
unlock();
|
|
1768
|
-
}
|
|
1769
|
-
}
|
|
1770
|
-
// Session lookup by scopeKey — used by CLI agent to resume a pinned
|
|
1771
|
-
// scope session when the caller passes --scope (agent/<name>).
|
|
1772
|
-
export function findSessionByScopeKey(scopeKey) {
|
|
1773
|
-
if (!scopeKey) return null;
|
|
1774
|
-
const summaries = listStoredSessionSummaries();
|
|
1775
|
-
// Exclude tombstoned sessions (`closed === true`) so callers never receive
|
|
1776
|
-
// a session whose controller was aborted by closeSession(). The `closed`
|
|
1777
|
-
// bit is the authoritative tombstone flag; `status === 'error'` is not,
|
|
1778
|
-
// since transient-error sessions remain resumable.
|
|
1779
|
-
const summary = summaries.find(s => s.scopeKey === scopeKey && s.closed !== true) || null;
|
|
1780
|
-
return summary?.id ? loadSession(summary.id) : null;
|
|
1781
|
-
}
|
|
1782
|
-
// --- resume (reload tools for a stored session) ---
|
|
1783
|
-
export async function resumeSession(sessionId, preset) {
|
|
1784
|
-
const session = loadSession(sessionId);
|
|
1785
|
-
if (!session)
|
|
1786
|
-
return null;
|
|
1787
|
-
// Resuming a closed session is a resurrection attempt — refuse. The guarded
|
|
1788
|
-
// save below would also block the write, but failing fast here is cleaner
|
|
1789
|
-
// than silently dropping the tool-refresh side effects.
|
|
1790
|
-
if (session.closed === true) return null;
|
|
1791
|
-
if (!session.owner) session.owner = 'user';
|
|
1792
|
-
// A crash / partial save during compaction can leave compaction.lastStage
|
|
1793
|
-
// pinned at 'compacting'. The TUI (App.jsx openContextPicker) reads that as
|
|
1794
|
-
// an in-progress compaction and would show "Compacting conversation"
|
|
1795
|
-
// forever on resume. No compaction is actually running for a freshly loaded
|
|
1796
|
-
// session, so normalize the stale transient stage back to an interrupted
|
|
1797
|
-
// marker. Successful auto/manual compact persistence (lastStage post_turn /
|
|
1798
|
-
// manual / auto_clear) is untouched.
|
|
1799
|
-
normalizeStaleCompactingStage(session);
|
|
1800
|
-
// Refresh tools (MCP connections may have changed).
|
|
1801
|
-
// Re-resolve from profile.tools when the session stored a profileId —
|
|
1802
|
-
// otherwise fall back to preset.tools. Same resolution order as
|
|
1803
|
-
// createSession so resume and spawn produce identical BP_1 shapes.
|
|
1804
|
-
const oldTools = session.tools || [];
|
|
1805
|
-
const ownerIsAgent = isAgentOwner(session);
|
|
1806
|
-
const skills = ownerIsAgent ? [] : collectPromptSkillsCached(session.cwd);
|
|
1807
|
-
let toolSpec = ownerIsAgent ? 'full' : (preset || session.preset || 'full');
|
|
1808
|
-
if (session.profileId && _agentRuntimeApi?.getProfile) {
|
|
1809
|
-
try {
|
|
1810
|
-
const profile = _agentRuntimeApi.getProfile(session.profileId);
|
|
1811
|
-
if (!ownerIsAgent && Array.isArray(profile?.tools)) toolSpec = profile.tools;
|
|
1812
|
-
} catch { /* ignore lookup failures, keep preset fallback */ }
|
|
1813
|
-
}
|
|
1814
|
-
let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsAgentSession: ownerIsAgent });
|
|
1815
|
-
if (ownerIsAgent) {
|
|
1816
|
-
toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, session.toolPermission, session.agent || null);
|
|
1817
|
-
}
|
|
1818
|
-
session.tools = finalizeSessionToolList(toolsForRouting, {
|
|
1819
|
-
schemaAllowedTools: Array.isArray(session.schemaAllowedTools) ? session.schemaAllowedTools : null,
|
|
1820
|
-
disallowedTools: getHiddenAgent(session.agent || null) ? ['Skill'] : null,
|
|
1821
|
-
ownerIsAgent,
|
|
1822
|
-
resolvedAgent: session.agent || null,
|
|
1823
|
-
});
|
|
1824
|
-
const newTools = session.tools;
|
|
1825
|
-
const missing = oldTools.filter(t => !newTools.find(n => n.name === t.name));
|
|
1826
|
-
if (missing.length) {
|
|
1827
|
-
process.stderr.write(`[session] Warning: ${missing.length} tools no longer available: ${missing.map(t => t.name).join(', ')}\n`);
|
|
1828
|
-
}
|
|
1829
|
-
await saveSessionAsync(session, { expectedGeneration: session.generation });
|
|
1830
|
-
return session;
|
|
1831
|
-
}
|
|
1832
|
-
// --- CRUD ---
|
|
1833
|
-
export function getSession(id) {
|
|
1834
|
-
return loadSession(id);
|
|
1835
|
-
}
|
|
1836
|
-
export function listSessions(opts = {}) {
|
|
1837
|
-
const includeClosed = opts.includeClosed === true;
|
|
1838
|
-
const sessions = listStoredSessionSummaries();
|
|
1839
|
-
const hiddenIds = new Set([..._runtimeEntries()].filter(([, e]) => e.listHidden).map(([id]) => id));
|
|
1840
|
-
// Tombstoned sessions (closed===true) are excluded unless the caller opts in
|
|
1841
|
-
// (e.g. agent list includeClosed:true).
|
|
1842
|
-
return sessions.filter(s => !hiddenIds.has(s.id) && (includeClosed || s.closed !== true));
|
|
1843
|
-
}
|
|
1844
|
-
// --- Clear messages (keep system prompt + provider/model/cwd) ---
|
|
1845
|
-
export async function clearSessionMessages(sessionId, options = {}) {
|
|
1846
|
-
const session = loadSession(sessionId);
|
|
1847
|
-
if (!session)
|
|
1848
|
-
return false;
|
|
1849
|
-
// Don't resurrect a closed session just to clear its messages.
|
|
1850
|
-
if (session.closed === true) return false;
|
|
1851
|
-
const clearOptions = options && typeof options === 'object' ? options : {};
|
|
1852
|
-
const requestedCompactType = clearOptions.compactType ?? clearOptions.compact_type ?? clearOptions.type;
|
|
1853
|
-
const compactBeforeClear = requestedCompactType != null && requestedCompactType !== false && String(requestedCompactType).trim() !== '';
|
|
1854
|
-
const keep = [];
|
|
1855
|
-
let messages = Array.isArray(session.messages) ? session.messages : [];
|
|
1856
|
-
const beforeMessageTokens = estimateMessagesTokens(messages);
|
|
1857
|
-
let clearCompactType = null;
|
|
1858
|
-
let clearCompactError = null;
|
|
1859
|
-
if (compactBeforeClear && messages.length >= 3) {
|
|
1860
|
-
clearCompactType = normalizeCompactType(requestedCompactType, DEFAULT_COMPACT_TYPE);
|
|
1861
|
-
session.compaction = {
|
|
1862
|
-
...(session.compaction || {}),
|
|
1863
|
-
type: clearCompactType,
|
|
1864
|
-
compactType: clearCompactType,
|
|
1865
|
-
};
|
|
1866
|
-
try {
|
|
1867
|
-
const compactResult = await runSessionCompaction(session, { mode: 'manual', force: true, sessionId });
|
|
1868
|
-
if (compactResult?.error) {
|
|
1869
|
-
clearCompactError = new Error(compactResult.error);
|
|
1870
|
-
}
|
|
1871
|
-
} catch (err) {
|
|
1872
|
-
clearCompactError = err;
|
|
1873
|
-
try { process.stderr.write(`[session] auto-clear pre-compact failed (sess=${sessionId}): ${err?.message || err}\n`); } catch { /* best-effort */ }
|
|
1874
|
-
}
|
|
1875
|
-
messages = Array.isArray(session.messages) ? session.messages : [];
|
|
1876
|
-
}
|
|
1877
|
-
if (compactBeforeClear && clearOptions.requireCompactSuccess === true) {
|
|
1878
|
-
const hasRetainedSummary = messages.some((m) => (
|
|
1879
|
-
m?.role === 'user'
|
|
1880
|
-
&& typeof m.content === 'string'
|
|
1881
|
-
&& m.content.startsWith(SUMMARY_PREFIX)
|
|
1882
|
-
));
|
|
1883
|
-
if (!hasRetainedSummary && !clearCompactError) {
|
|
1884
|
-
clearCompactError = new Error('compact produced no retained summary');
|
|
1885
|
-
}
|
|
1886
|
-
}
|
|
1887
|
-
if (clearCompactError && clearOptions.requireCompactSuccess === true) {
|
|
1888
|
-
const now = Date.now();
|
|
1889
|
-
session.compaction = {
|
|
1890
|
-
...(session.compaction || {}),
|
|
1891
|
-
lastStage: 'auto_clear_failed',
|
|
1892
|
-
lastCheckedAt: now,
|
|
1893
|
-
lastChanged: false,
|
|
1894
|
-
lastClearAt: session.compaction?.lastClearAt || null,
|
|
1895
|
-
lastClearCompactType: clearCompactType || session.compaction?.compactType || null,
|
|
1896
|
-
lastClearCompactError: clearCompactError?.message || String(clearCompactError),
|
|
1897
|
-
};
|
|
1898
|
-
session.updatedAt = now;
|
|
1899
|
-
await saveSessionAsync(session, { expectedGeneration: session.generation });
|
|
1900
|
-
throw new Error(`auto-clear compact failed; conversation kept: ${session.compaction.lastClearCompactError}`);
|
|
1901
|
-
}
|
|
1902
|
-
const preserveCompactSummary = compactBeforeClear && clearOptions.keepCompactSummary !== false;
|
|
1903
|
-
for (let i = 0; i < messages.length; i += 1) {
|
|
1904
|
-
const m = messages[i];
|
|
1905
|
-
if (!m) continue;
|
|
1906
|
-
if (m.role === 'system') {
|
|
1907
|
-
// BP1/BP2/BP3 all ride `role:'system'` blocks now (BP3 sessionMarker
|
|
1908
|
-
// moved off the `<system-reminder>` user wrapper), so the stable
|
|
1909
|
-
// memory/meta layer is preserved here unconditionally — no sentinel
|
|
1910
|
-
// scan / dummy-assistant pairing needed anymore.
|
|
1911
|
-
keep.push(m);
|
|
1912
|
-
continue;
|
|
1913
|
-
}
|
|
1914
|
-
if (preserveCompactSummary
|
|
1915
|
-
&& m.role === 'user'
|
|
1916
|
-
&& typeof m.content === 'string'
|
|
1917
|
-
&& m.content.startsWith(SUMMARY_PREFIX)) {
|
|
1918
|
-
keep.push(m);
|
|
1919
|
-
}
|
|
1920
|
-
}
|
|
1921
|
-
const afterMessageTokens = estimateMessagesTokens(keep);
|
|
1922
|
-
const beforeTokens = estimateTranscriptContextUsage(messages, session.tools || []);
|
|
1923
|
-
const afterTokens = estimateTranscriptContextUsage(keep, session.tools || []);
|
|
1924
|
-
const now = Date.now();
|
|
1925
|
-
// --- Fork the outgoing transcript to a separate resumable session BEFORE
|
|
1926
|
-
// the wipe below. Runs for every clear path (plain /clear, auto-clear,
|
|
1927
|
-
// compact_clear) using the ORIGINAL `messages` (post-compact-gating, i.e.
|
|
1928
|
-
// whatever survived the requireCompactSuccess throw above), so the
|
|
1929
|
-
// conversation about to be discarded stays reachable via /resume under a
|
|
1930
|
-
// fresh id. Skipped for scratch sessions with no real user turn — nothing
|
|
1931
|
-
// worth resuming. Best-effort: any failure here must never block the
|
|
1932
|
-
// clear itself (mirrors the pre-compact failure handling above).
|
|
1933
|
-
if (hasUserConversationMessage(messages)) {
|
|
1934
|
-
try {
|
|
1935
|
-
const forkId = `sess_${process.pid}_${nextId++}_${Date.now()}_${randomBytes(16).toString('hex')}`;
|
|
1936
|
-
const fork = {
|
|
1937
|
-
...session,
|
|
1938
|
-
id: forkId,
|
|
1939
|
-
messages: messages.map((m) => (m && typeof m === 'object' ? { ...m } : m)),
|
|
1940
|
-
closed: false,
|
|
1941
|
-
status: 'idle',
|
|
1942
|
-
generation: 0,
|
|
1943
|
-
createdAt: now,
|
|
1944
|
-
updatedAt: now,
|
|
1945
|
-
lastUsedAt: now,
|
|
1946
|
-
lastHeartbeatAt: null,
|
|
1947
|
-
mcpPid: process.pid,
|
|
1948
|
-
// Strip runtime/liveness/routing state — the fork is a cold
|
|
1949
|
-
// snapshot, not a live process-owned session.
|
|
1950
|
-
clientHostPid: null,
|
|
1951
|
-
providerState: undefined,
|
|
1952
|
-
totalInputTokens: 0,
|
|
1953
|
-
totalOutputTokens: 0,
|
|
1954
|
-
totalCachedReadTokens: 0,
|
|
1955
|
-
totalCacheWriteTokens: 0,
|
|
1956
|
-
lastInputTokens: 0,
|
|
1957
|
-
lastOutputTokens: 0,
|
|
1958
|
-
lastCachedReadTokens: 0,
|
|
1959
|
-
lastCacheWriteTokens: 0,
|
|
1960
|
-
lastContextTokens: 0,
|
|
1961
|
-
lastContextTokensUpdatedAt: now,
|
|
1962
|
-
lastContextTokensStaleAfterCompact: false,
|
|
1963
|
-
// Shell state must not alias the live session: resuming the
|
|
1964
|
-
// fork would otherwise reuse/close the original session's
|
|
1965
|
-
// persistent bash shells.
|
|
1966
|
-
implicitBashSessionId: null,
|
|
1967
|
-
allBashSessionIds: undefined,
|
|
1968
|
-
};
|
|
1969
|
-
delete fork.liveTurnMessages;
|
|
1970
|
-
setLiveSession(fork);
|
|
1971
|
-
void saveSessionAsync(fork).catch((err) => {
|
|
1972
|
-
try { process.stderr.write(`[session] clear-fork save failed (sess=${forkId}): ${err?.message || err}\n`); } catch { /* best-effort */ }
|
|
1973
|
-
});
|
|
1974
|
-
} catch (err) {
|
|
1975
|
-
try { process.stderr.write(`[session] clear-fork failed (sess=${sessionId}): ${err?.message || err}\n`); } catch { /* best-effort */ }
|
|
1976
|
-
}
|
|
1977
|
-
}
|
|
1978
|
-
session.messages = keep;
|
|
1979
|
-
session.sessionStartMetaInjected = false;
|
|
1980
|
-
session.totalInputTokens = 0;
|
|
1981
|
-
session.totalOutputTokens = 0;
|
|
1982
|
-
session.totalCachedReadTokens = 0;
|
|
1983
|
-
session.totalCacheWriteTokens = 0;
|
|
1984
|
-
session.lastInputTokens = 0;
|
|
1985
|
-
session.lastOutputTokens = 0;
|
|
1986
|
-
session.lastCachedReadTokens = 0;
|
|
1987
|
-
session.lastCacheWriteTokens = 0;
|
|
1988
|
-
session.lastContextTokens = 0;
|
|
1989
|
-
session.lastContextTokensUpdatedAt = now;
|
|
1990
|
-
session.lastContextTokensStaleAfterCompact = false;
|
|
1991
|
-
session.providerState = undefined;
|
|
1992
|
-
session.compaction = {
|
|
1993
|
-
...(session.compaction || {}),
|
|
1994
|
-
lastStage: 'auto_clear',
|
|
1995
|
-
lastBeforeTokens: beforeTokens,
|
|
1996
|
-
lastAfterTokens: afterTokens,
|
|
1997
|
-
lastBeforeMessageTokens: beforeMessageTokens,
|
|
1998
|
-
lastAfterMessageTokens: afterMessageTokens,
|
|
1999
|
-
lastPressureTokens: beforeTokens,
|
|
2000
|
-
lastCheckedAt: now,
|
|
2001
|
-
lastChanged: beforeTokens !== afterTokens,
|
|
2002
|
-
lastClearAt: now,
|
|
2003
|
-
lastClearBeforeTokens: beforeTokens,
|
|
2004
|
-
lastClearAfterTokens: afterTokens,
|
|
2005
|
-
lastClearBeforeMessageTokens: beforeMessageTokens,
|
|
2006
|
-
lastClearAfterMessageTokens: afterMessageTokens,
|
|
2007
|
-
lastClearCompactType: clearCompactType || session.compaction?.compactType || null,
|
|
2008
|
-
lastClearCompactError: clearCompactError?.message || null,
|
|
2009
|
-
};
|
|
2010
|
-
session.updatedAt = now;
|
|
2011
|
-
await saveSessionAsync(session, { expectedGeneration: session.generation });
|
|
2012
|
-
return session;
|
|
2013
|
-
}
|
|
2014
|
-
export async function compactSessionMessages(sessionId) {
|
|
2015
|
-
const session = loadSession(sessionId);
|
|
2016
|
-
if (!session) return null;
|
|
2017
|
-
if (session.closed === true) return null;
|
|
2018
|
-
if (isSessionCompactionBlocked(sessionId)) {
|
|
2019
|
-
return { changed: false, reason: 'compact skipped: turn in progress' };
|
|
2020
|
-
}
|
|
2021
|
-
const result = await runSessionCompaction(session, {
|
|
2022
|
-
mode: 'manual',
|
|
2023
|
-
force: true,
|
|
2024
|
-
provider: getProvider(session.provider),
|
|
2025
|
-
model: session.model,
|
|
2026
|
-
sessionId,
|
|
2027
|
-
signal: getSessionAbortSignal(sessionId),
|
|
2028
|
-
});
|
|
2029
|
-
if (!result) return null;
|
|
2030
|
-
const now = Date.now();
|
|
2031
|
-
if (!result.error) {
|
|
2032
|
-
session.lastInputTokens = 0;
|
|
2033
|
-
session.lastOutputTokens = 0;
|
|
2034
|
-
session.lastCachedReadTokens = 0;
|
|
2035
|
-
session.lastCacheWriteTokens = 0;
|
|
2036
|
-
session.lastContextTokens = 0;
|
|
2037
|
-
session.lastContextTokensUpdatedAt = now;
|
|
2038
|
-
session.lastContextTokensStaleAfterCompact = false;
|
|
2039
|
-
}
|
|
2040
|
-
session.updatedAt = Date.now();
|
|
2041
|
-
await saveSessionAsync(session, { expectedGeneration: session.generation });
|
|
2042
|
-
return result;
|
|
2043
|
-
}
|
|
2044
|
-
export async function updateSessionStatus(id, status) {
|
|
2045
|
-
const session = loadSession(id);
|
|
2046
|
-
if (!session) return false;
|
|
2047
|
-
// Respect tombstones — don't resurrect a closed session just to update a
|
|
2048
|
-
// status label (agent handler emits running→idle/error around askSession).
|
|
2049
|
-
if (session.closed === true) return false;
|
|
2050
|
-
session.status = status;
|
|
2051
|
-
session.updatedAt = Date.now();
|
|
2052
|
-
await saveSessionAsync(session, { expectedGeneration: session.generation });
|
|
2053
|
-
return true;
|
|
2054
|
-
}
|
|
2055
|
-
/**
|
|
2056
|
-
* Close a session. Plants a `closed=true` tombstone on disk with a bumped
|
|
2057
|
-
* generation (so any racing saveSession() drops its write), aborts the
|
|
2058
|
-
* in-flight controller if one exists, and clears the in-memory runtime entry.
|
|
2059
|
-
*
|
|
2060
|
-
* IMPORTANT: we deliberately do NOT unlink the session file here. The tombstone
|
|
2061
|
-
* on disk is the authoritative signal that blocks resurrection — a late
|
|
2062
|
-
* saveSession() re-reads disk via _shouldDrop() and will find the tombstone.
|
|
2063
|
-
* If we delete the file, a late save sees no file, decides nothing to drop,
|
|
2064
|
-
* and recreates the session in its pre-close state.
|
|
2065
|
-
*
|
|
2066
|
-
* Long-term cleanup: `sweepTombstones()` below unlinks tombstones older than
|
|
2067
|
-
* TOMBSTONE_MAX_AGE_MS (24h — vastly longer than any realistic in-flight race).
|
|
2068
|
-
*/
|
|
2069
|
-
export function closeSession(id, reason = 'manual', opts = {}) {
|
|
2070
|
-
// tombstone=false: detach runtime resources (heartbeat, bash shells,
|
|
2071
|
-
// controller abort, runtime-map clear) WITHOUT planting the disk
|
|
2072
|
-
// tombstone. Used for non-empty sessions on /resume-away, /new, and
|
|
2073
|
-
// TUI exit — previously every one of those paths unconditionally
|
|
2074
|
-
// tombstoned the outgoing session, which made it vanish from the
|
|
2075
|
-
// Resume list immediately and get hard-deleted by sweepTombstones()
|
|
2076
|
-
// after 24h even though it had real conversation content worth
|
|
2077
|
-
// resuming. Only truly-empty scratch sessions should still tombstone.
|
|
2078
|
-
const tombstone = opts.tombstone !== false;
|
|
2079
|
-
if (!id) return false;
|
|
2080
|
-
_stopToolActivityHeartbeat(id);
|
|
2081
|
-
// Prefer in-memory runtime session — allBashSessionIds may not be persisted
|
|
2082
|
-
// yet for shells opened in the current turn (BL-bash-disk-sync).
|
|
2083
|
-
const inMemory = _getRuntimeEntry(id)?.session;
|
|
2084
|
-
const persisted = inMemory || loadSession(id);
|
|
2085
|
-
const bashSessionId = persisted?.implicitBashSessionId || null;
|
|
2086
|
-
// Collect all persistent bash shells created during this session.
|
|
2087
|
-
const allBashIds = Array.isArray(persisted?.allBashSessionIds)
|
|
2088
|
-
? persisted.allBashSessionIds.filter(Boolean)
|
|
2089
|
-
: (bashSessionId ? [bashSessionId] : []);
|
|
2090
|
-
// Deduplicate: allBashIds already covers implicitBashSessionId, but guard
|
|
2091
|
-
// against old session records that only have implicitBashSessionId.
|
|
2092
|
-
if (bashSessionId && !allBashIds.includes(bashSessionId)) allBashIds.push(bashSessionId);
|
|
2093
|
-
// 1. Tombstone first — this wins the race against saveSession().
|
|
2094
|
-
// Skipped when tombstone=false: no closed:true marker is planted, so
|
|
2095
|
-
// the session file stays intact and resumeSession() will accept it.
|
|
2096
|
-
// We still bump the on-disk generation via bumpSessionGeneration() —
|
|
2097
|
-
// that alone is what protects the session from a late save race: any
|
|
2098
|
-
// saveSession() still in flight from this detached turn (e.g. the
|
|
2099
|
-
// cancel-cleanup save below) carries the OLD generation as its
|
|
2100
|
-
// expectedGeneration, so _shouldDrop()'s ownership-counter rule drops
|
|
2101
|
-
// it once disk generation moves past that. Without this bump the late
|
|
2102
|
-
// write could silently overwrite the session after the user resumes
|
|
2103
|
-
// it back (BL: burned-session late-save clobber).
|
|
2104
|
-
const newGen = tombstone ? markSessionClosed(id, reason) : bumpSessionGeneration(id, reason);
|
|
2105
|
-
// 2. Mark runtime as closed so post-await validation in askSession fires.
|
|
2106
|
-
const entry = _getRuntimeEntry(id);
|
|
2107
|
-
if (entry) {
|
|
2108
|
-
entry.closed = true;
|
|
2109
|
-
entry.closedReason = reason;
|
|
2110
|
-
if (typeof newGen === 'number') entry.generation = newGen;
|
|
2111
|
-
entry.stage = 'cancelling';
|
|
2112
|
-
entry.updatedAt = Date.now();
|
|
2113
|
-
// 3. Abort the in-flight controller. Providers that honour the signal
|
|
2114
|
-
// unwind immediately; providers that don't will still be caught by
|
|
2115
|
-
// the generation check after their await eventually returns.
|
|
2116
|
-
try { entry.controller?.abort(new SessionClosedError(id, `closeSession (reason=${reason})`, reason)); } catch { /* ignore */ }
|
|
2117
|
-
}
|
|
2118
|
-
// Diagnostic: one-line stderr so operators can distinguish the four close
|
|
2119
|
-
// pathways (request-abort / manual / idle-sweep / runner-crash). iterCount
|
|
2120
|
-
// is not currently tracked on runtime state; askStartedAt is — derive
|
|
2121
|
-
// duration from it when present.
|
|
2122
|
-
try {
|
|
2123
|
-
const askStartedAt = entry?.askStartedAt;
|
|
2124
|
-
const durationMs = (typeof askStartedAt === 'number') ? (Date.now() - askStartedAt) : null;
|
|
2125
|
-
const parts = [`session=${id}`, `reason=${reason}`, `tombstone=${tombstone}`];
|
|
2126
|
-
if (durationMs != null) parts.push(`duration=${durationMs}ms`);
|
|
2127
|
-
if (process.env.MIXDOG_DEBUG_SESSION_LOG) process.stderr.write(`[agent-close] ${parts.join(' ')}\n`);
|
|
2128
|
-
} catch { /* best-effort */ }
|
|
2129
|
-
for (const bsid of allBashIds) {
|
|
2130
|
-
try { _closeBashSessionLazy(bsid, `agent-close:${id}`); } catch { /* ignore */ }
|
|
2131
|
-
}
|
|
2132
|
-
// Drop session-scoped read dedup cache so the Map doesn't accumulate
|
|
2133
|
-
// entries across mcp-server lifetime.
|
|
2134
|
-
try { clearReadDedupSession(id); } catch { /* ignore */ }
|
|
2135
|
-
// Drop offload sidecars + module-level counter for this session so a
|
|
2136
|
-
// long-running mcp-server doesn't leak disk (tool-results/<id>/*.txt)
|
|
2137
|
-
// or Map entries across session lifetime. Fire-and-forget — close path
|
|
2138
|
-
// should not await disk IO; errors are swallowed inside.
|
|
2139
|
-
try { clearOffloadSession(id); } catch { /* ignore */ }
|
|
2140
|
-
// Drop the in-memory pending-message queue and any buffered-persist entry
|
|
2141
|
-
// for this session — otherwise both Maps accumulate one entry per closed
|
|
2142
|
-
// session for the life of the mcp-server.
|
|
2143
|
-
_dropPendingMessageState(id, { clearPersisted: tombstone });
|
|
2144
|
-
// 4. Defer runtime map clear to next tick so any settling askSession can
|
|
2145
|
-
// observe `closed=true` / bumped generation before we yank the entry.
|
|
2146
|
-
// Disk tombstone remains — that's what blocks resurrection.
|
|
2147
|
-
setImmediate(() => {
|
|
2148
|
-
_clearSessionRuntime(id);
|
|
2149
|
-
});
|
|
2150
|
-
return true;
|
|
2151
|
-
}
|
|
2152
|
-
export function abortSessionTurn(id, reason = 'turn-abort') {
|
|
2153
|
-
if (!id) return false;
|
|
2154
|
-
_stopToolActivityHeartbeat(id);
|
|
2155
|
-
const entry = _getRuntimeEntry(id);
|
|
2156
|
-
if (!entry || entry.closed) return false;
|
|
2157
|
-
entry.stage = 'cancelling';
|
|
2158
|
-
entry.closedReason = reason;
|
|
2159
|
-
entry.updatedAt = Date.now();
|
|
2160
|
-
try {
|
|
2161
|
-
entry.controller?.abort(new SessionClosedError(id, `abortSessionTurn (reason=${reason})`, reason));
|
|
2162
|
-
} catch { /* ignore */ }
|
|
2163
|
-
return true;
|
|
2164
|
-
}
|
|
2165
|
-
|
|
2166
|
-
// --- Periodic idle session cleanup ---
|
|
2167
|
-
const CLEANUP_INTERVAL_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INTERVAL_MS', 5 * 60 * 1000); // check every 5 minutes
|
|
2168
|
-
const CLEANUP_INITIAL_DELAY_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INITIAL_DELAY_MS', CLEANUP_INTERVAL_MS > 0 ? CLEANUP_INTERVAL_MS : 0);
|
|
2169
|
-
const CLEANUP_SLOW_LOG_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_SLOW_LOG_MS', 250);
|
|
2170
|
-
const TOMBSTONE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h — far longer than any realistic ask race window
|
|
2171
|
-
let _cleanupTimer = null;
|
|
2172
|
-
let _cleanupInitialTimer = null;
|
|
2173
|
-
|
|
2174
|
-
function nonNegativeIntEnv(name, fallback) {
|
|
2175
|
-
const value = Number(process.env[name]);
|
|
2176
|
-
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback;
|
|
2177
|
-
}
|
|
2178
|
-
|
|
2179
|
-
function _previewIds(items, limit = 5) {
|
|
2180
|
-
const ids = (items || []).slice(0, limit).map((item) => item.id).filter(Boolean);
|
|
2181
|
-
if (ids.length === 0) return '';
|
|
2182
|
-
const more = items.length > limit ? `, +${items.length - limit} more` : '';
|
|
2183
|
-
return ` (${ids.join(', ')}${more})`;
|
|
2184
|
-
}
|
|
2185
|
-
|
|
2186
|
-
function sweepIdleSessions({ includeTombstones = true } = {}) {
|
|
2187
|
-
const startedAt = Date.now();
|
|
2188
|
-
try {
|
|
2189
|
-
const result = sweepStaleSessions({
|
|
2190
|
-
tombstoneMaxAgeMs: includeTombstones ? TOMBSTONE_MAX_AGE_MS : 0,
|
|
2191
|
-
});
|
|
2192
|
-
const {
|
|
2193
|
-
cleaned,
|
|
2194
|
-
remaining,
|
|
2195
|
-
details,
|
|
2196
|
-
tombstonesCleaned = 0,
|
|
2197
|
-
tombstoneDetails = [],
|
|
2198
|
-
tombstoneErrors = [],
|
|
2199
|
-
} = result;
|
|
2200
|
-
if (cleaned > 0) {
|
|
2201
|
-
for (const d of details) {
|
|
2202
|
-
// Skip entries with an active in-flight controller — aborting
|
|
2203
|
-
// them via closeSession() is the safe path; clearing the runtime
|
|
2204
|
-
// without signalling the controller leaves orphan provider work.
|
|
2205
|
-
const rtEntry = _getRuntimeEntry(d.id);
|
|
2206
|
-
if (rtEntry && rtEntry.controller && !rtEntry.controller.signal?.aborted) {
|
|
2207
|
-
try { closeSession(d.id, 'idle-sweep'); } catch { /* ignore */ }
|
|
2208
|
-
} else {
|
|
2209
|
-
_clearSessionRuntime(d.id);
|
|
2210
|
-
if (d.bashSessionId) {
|
|
2211
|
-
try { _closeBashSessionLazy(d.bashSessionId, `idle-sweep:${d.id}`); } catch { /* ignore */ }
|
|
2212
|
-
}
|
|
2213
|
-
}
|
|
2214
|
-
process.stderr.write(`[agent-session] idle cleanup: closed ${d.id} (idle ${d.idleMinutes}m, owner=${d.owner})\n`);
|
|
2215
|
-
}
|
|
2216
|
-
process.stderr.write(`[agent-session] idle sweep: cleaned ${cleaned} session(s), ${remaining} remaining\n`);
|
|
2217
|
-
}
|
|
2218
|
-
if (tombstonesCleaned > 0) {
|
|
2219
|
-
for (const d of tombstoneDetails) {
|
|
2220
|
-
if (d?.id) _clearSessionRuntime(d.id);
|
|
2221
|
-
}
|
|
2222
|
-
process.stderr.write(`[session-sweep] unlinked ${tombstonesCleaned} tombstone(s)${_previewIds(tombstoneDetails)}\n`);
|
|
2223
|
-
}
|
|
2224
|
-
if (tombstoneErrors.length > 0) {
|
|
2225
|
-
const first = tombstoneErrors[0];
|
|
2226
|
-
process.stderr.write(`[session-sweep] tombstone unlink failed for ${tombstoneErrors.length} session(s): ${first?.id || 'unknown'} ${first?.message || ''}\n`);
|
|
2227
|
-
}
|
|
2228
|
-
const elapsed = Date.now() - startedAt;
|
|
2229
|
-
if (elapsed >= CLEANUP_SLOW_LOG_MS) {
|
|
2230
|
-
process.stderr.write(`[session-sweep] cleanup took ${elapsed}ms (idle=${cleaned}, tombstones=${tombstonesCleaned}, remaining=${remaining})\n`);
|
|
2231
|
-
}
|
|
2232
|
-
} catch (e) {
|
|
2233
|
-
process.stderr.write(`[agent-session] idle sweep error: ${e && e.message || e}\n`);
|
|
2234
|
-
}
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2237
|
-
/**
|
|
2238
|
-
* Unlink tombstone session files (closed=true) older than TOMBSTONE_MAX_AGE_MS.
|
|
2239
|
-
*
|
|
2240
|
-
* Rationale: closeSession() leaves the tombstone on disk as the authoritative
|
|
2241
|
-
* resurrection-blocker for racing saveSession() calls. That race resolves in
|
|
2242
|
-
* microseconds (the window inside _doSave between temp write and rename), so
|
|
2243
|
-
* 24h is vastly safe. After the TTL expires we reclaim the disk slot.
|
|
2244
|
-
*
|
|
2245
|
-
* Uses `getStoredSessionsRaw()` rather than `listStoredSessions()` because the
|
|
2246
|
-
* latter's inline 30-min idle cleanup would race-unlink tombstones before we
|
|
2247
|
-
* get to log them — we want to own the unlink decision and stderr line here.
|
|
2248
|
-
*/
|
|
2249
|
-
export function sweepTombstones() {
|
|
2250
|
-
try {
|
|
2251
|
-
const { tombstonesCleaned = 0, tombstoneDetails = [], tombstoneErrors = [] } = sweepStaleSessions({
|
|
2252
|
-
sweepIdle: false,
|
|
2253
|
-
tombstoneMaxAgeMs: TOMBSTONE_MAX_AGE_MS,
|
|
2254
|
-
});
|
|
2255
|
-
for (const d of tombstoneDetails) {
|
|
2256
|
-
if (d?.id) _clearSessionRuntime(d.id);
|
|
2257
|
-
}
|
|
2258
|
-
if (tombstonesCleaned > 0) {
|
|
2259
|
-
process.stderr.write(`[session-sweep] unlinked ${tombstonesCleaned} tombstone(s)${_previewIds(tombstoneDetails)}\n`);
|
|
2260
|
-
}
|
|
2261
|
-
if (tombstoneErrors.length > 0) {
|
|
2262
|
-
const first = tombstoneErrors[0];
|
|
2263
|
-
process.stderr.write(`[session-sweep] tombstone unlink failed for ${tombstoneErrors.length} session(s): ${first?.id || 'unknown'} ${first?.message || ''}\n`);
|
|
2264
|
-
}
|
|
2265
|
-
return tombstonesCleaned;
|
|
2266
|
-
} catch (e) {
|
|
2267
|
-
process.stderr.write(`[session-sweep] tombstone sweep error: ${e && e.message || e}\n`);
|
|
2268
|
-
return 0;
|
|
2269
|
-
}
|
|
2270
|
-
}
|
|
2271
|
-
|
|
2272
|
-
function hasActiveRuntimeWork() {
|
|
2273
|
-
for (const [, entry] of _runtimeEntries()) {
|
|
2274
|
-
if (!entry || entry.closed === true) continue;
|
|
2275
|
-
if (entry.controller && !entry.controller.signal?.aborted) return true;
|
|
2276
|
-
if (['connecting', 'requesting', 'streaming', 'tool_running', 'cancelling'].includes(entry.stage)) return true;
|
|
2277
|
-
}
|
|
2278
|
-
return false;
|
|
2279
|
-
}
|
|
2280
|
-
|
|
2281
|
-
function _runCleanupCycle() {
|
|
2282
|
-
if (hasActiveRuntimeWork()) return;
|
|
2283
|
-
sweepOrphanedPendingMessages();
|
|
2284
|
-
sweepIdleSessions({ includeTombstones: true });
|
|
2285
|
-
}
|
|
2286
|
-
|
|
2287
|
-
function _startCleanupInterval() {
|
|
2288
|
-
if (_cleanupTimer) return;
|
|
2289
|
-
if (CLEANUP_INTERVAL_MS <= 0) return;
|
|
2290
|
-
_cleanupTimer = setInterval(_runCleanupCycle, CLEANUP_INTERVAL_MS);
|
|
2291
|
-
if (_cleanupTimer.unref) _cleanupTimer.unref(); // don't block process exit
|
|
2292
|
-
}
|
|
2293
|
-
|
|
2294
|
-
export function startIdleCleanup() {
|
|
2295
|
-
if (_cleanupTimer || _cleanupInitialTimer) return;
|
|
2296
|
-
if (CLEANUP_INITIAL_DELAY_MS <= 0) {
|
|
2297
|
-
_runCleanupCycle();
|
|
2298
|
-
_startCleanupInterval();
|
|
2299
|
-
return;
|
|
2300
|
-
}
|
|
2301
|
-
_cleanupInitialTimer = setTimeout(() => {
|
|
2302
|
-
_cleanupInitialTimer = null;
|
|
2303
|
-
_runCleanupCycle();
|
|
2304
|
-
_startCleanupInterval();
|
|
2305
|
-
}, CLEANUP_INITIAL_DELAY_MS);
|
|
2306
|
-
if (_cleanupInitialTimer.unref) _cleanupInitialTimer.unref();
|
|
2307
|
-
}
|
|
2308
|
-
|
|
2309
|
-
export function stopIdleCleanup() {
|
|
2310
|
-
if (_cleanupInitialTimer) {
|
|
2311
|
-
clearTimeout(_cleanupInitialTimer);
|
|
2312
|
-
_cleanupInitialTimer = null;
|
|
2313
|
-
}
|
|
2314
|
-
if (_cleanupTimer) {
|
|
2315
|
-
clearInterval(_cleanupTimer);
|
|
2316
|
-
_cleanupTimer = null;
|
|
2317
|
-
}
|
|
2318
|
-
}
|
|
102
|
+
// ── Session lifecycle / ask / crud / close / cleanup ──────────────────────
|
|
103
|
+
export { SessionClosedError } from './manager/session-errors.mjs';
|
|
104
|
+
export { setAgentRuntime } from './manager/agent-runtime-singleton.mjs';
|
|
105
|
+
export { createSession, updateSessionRoute, resumeSession } from './manager/session-lifecycle.mjs';
|
|
106
|
+
export { askSession, _api_call_with_interrupt } from './manager/ask-session.mjs';
|
|
107
|
+
export {
|
|
108
|
+
_sessionMessagesAdvancedBeyondCompactedOutgoing,
|
|
109
|
+
_applyCompactFailurePersistToSession,
|
|
110
|
+
} from './manager/message-sanitize.mjs';
|
|
111
|
+
export {
|
|
112
|
+
getSession,
|
|
113
|
+
listSessions,
|
|
114
|
+
findSessionByScopeKey,
|
|
115
|
+
clearSessionMessages,
|
|
116
|
+
compactSessionMessages,
|
|
117
|
+
updateSessionStatus,
|
|
118
|
+
flushSessionMetrics,
|
|
119
|
+
} from './manager/session-crud.mjs';
|
|
120
|
+
export { closeSession, abortSessionTurn } from './manager/session-close.mjs';
|
|
121
|
+
export { sweepTombstones, startIdleCleanup, stopIdleCleanup } from './manager/idle-cleanup.mjs';
|