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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -1,98 +1,44 @@
1
- import { createRequire } from 'module';
2
- import { fileURLToPath } from 'url';
3
- import { randomBytes, createHash } from 'crypto';
4
- import { getProvider } from '../providers/registry.mjs';
5
- // Image content is kept in-memory and in the model-visible history so multi-turn
6
- // recognition works reliably (live transcript always retains images). The
7
- // stored-history placeholder swap now happens only at disk-serialization time
8
- // inside the session store, so it is no longer imported here.
9
- import {
10
- normalizeCompactType,
11
- DEFAULT_COMPACT_TYPE,
12
- SUMMARY_PREFIX,
13
- } from './compact.mjs';
14
- import { estimateMessagesTokens, estimateTranscriptContextUsage } from './context-utils.mjs';
15
- import { executeInternalTool } from '../internal-tools.mjs';
16
- import { collectPromptSkillsCached, buildSkillManifest, composeSystemPrompt } from '../context/collect.mjs';
17
- import { saveSession, saveSessionAsync, loadSession, listStoredSessionSummaries, sweepStaleSessions, markSessionClosed, bumpSessionGeneration, setLiveSession } from './store.mjs';
18
- import { clearReadDedupSession, tryPrefetchCached, setPrefetchCached } from './read-dedup.mjs';
19
- import { clearOffloadSession } from './tool-result-offload.mjs';
20
- import { classifyResultKind } from './result-classification.mjs';
21
- import { createAbortController } from '../../../shared/abort-controller.mjs';
22
- import { logLlmCall } from '../../../shared/llm/usage-log.mjs';
23
- import { appendAgentTrace } from '../agent-trace.mjs';
24
- import { isAgentOwner } from '../agent-owner.mjs';
25
- import { getHiddenAgent } from '../internal-agents.mjs';
26
- import { loadConfig } from '../config.mjs';
27
- import { buildProviderCacheOpts, cacheCapabilityForProvider } from '../agent-runtime/cache-strategy.mjs';
28
- import { normalizeAutoClearConfig, resolveAutoClearIdleMs } from '../../../../session-runtime/config-helpers.mjs';
29
- import {
30
- recordStandaloneStatusTelemetry,
31
- } from './manager/status-telemetry.mjs';
32
- import {
33
- normalizeStaleCompactingStage,
34
- runSessionCompaction,
35
- } from './manager/compaction-runner.mjs';
36
- // Split modules — see manager/ directory. manager.mjs is now a thin facade that
37
- // orchestrates these cohesive units while keeping the exact public surface.
38
- import {
39
- _buildSharedRules,
40
- _buildAgentRules,
41
- _buildLeadRules,
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
- // Facade re-exports — external importers (loop.mjs, loop/tool-exec.mjs,
120
- // agent-dispatch.mjs, abort-lookup.mjs, statusline-agents.mjs) resolve these
121
- // through manager.mjs unchanged.
59
+
60
+ // ── Tool resolution / pending messages / prompt utils ─────────────────────
61
+ export { previewSessionTools } from './manager/tool-resolution.mjs';
122
62
  export {
123
- updateSessionStage,
124
- markSessionAskStart,
125
- markSessionStreamDelta,
126
- markSessionToolCall,
127
- markSessionDone,
128
- markSessionEmptyFinal,
129
- markSessionError,
130
- markSessionCancelled,
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
- // Session tool resolution + permission narrowing moved to
229
- // manager/tool-resolution.mjs (imported above). The following section-level
230
- // docs describe the toolSpec contract those helpers implement.
231
- //
232
- // Phase D-2 — profile.tools resolution.
233
- //
234
- // `toolSpec` may be:
235
- // • Array<string> (profile.tools) — toolset ids like "tools:filesystem",
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
- let nextId = Date.now();
255
- // Test-only exports for the legacy auto-compact-limit migration + buffer-config
256
- // preservation (see scripts/compact-trigger-migration-smoke.mjs).
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
- const { baseRules, stableSystemContext, sessionMarker, volatileTail } = composeSystemPrompt({
700
- userPrompt: opts.systemPrompt,
701
- agentRules: injectedRules || undefined,
702
- roleRules: roleRules || undefined,
703
- metaContext: metaContext || undefined,
704
- skipRoleCatalog: !ownerIsAgent,
705
- profile: profile || undefined,
706
- agent: resolvedAgent,
707
- workflowContext: opts.workflowContext || null,
708
- workspaceContext: opts.workspaceContext || null,
709
- coreMemoryContext: opts.coreMemoryContext || null,
710
- skillManifest: buildSkillManifest(skills),
711
- tools: toolsForRouting,
712
- bashIsPersistent: ownerIsAgent && toolsForRouting.some(t => t?.name === 'shell'),
713
- // Effective cwd rides in tier3Reminder so explore-like tools know
714
- // their search root without needing to shove "Override cwd:" into
715
- // the user message body (that used to fragment the shard prefix).
716
- cwd: opts.cwd || null,
717
- provider: providerName || null,
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';