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
@@ -0,0 +1,686 @@
1
+ // manager/ask-session.mjs
2
+ // The core ask pipeline extracted verbatim from manager.mjs: the per-session
3
+ // mutex-guarded turn loop (askSession) plus the abort-aware call wrapper
4
+ // (_api_call_with_interrupt). Behavior-preserving move — every runtime
5
+ // helper it used in manager.mjs is now imported from its split module.
6
+ import { createHash } from 'crypto';
7
+ import { getProvider } from '../../providers/registry.mjs';
8
+ import { normalizeCompactType, DEFAULT_COMPACT_TYPE } from '../compact.mjs';
9
+ import { loadSession, saveSessionAsync } from '../store.mjs';
10
+ import { createAbortController } from '../../../../shared/abort-controller.mjs';
11
+ import { logLlmCall } from '../../../../shared/llm/usage-log.mjs';
12
+ import { appendAgentTrace } from '../../agent-trace.mjs';
13
+ import { recordStandaloneStatusTelemetry } from './status-telemetry.mjs';
14
+ import { normalizeStaleCompactingStage } from './compaction-runner.mjs';
15
+ import { resolveSessionContextMeta, positiveContextWindow } from './context-meta.mjs';
16
+ import {
17
+ promptContentText,
18
+ hasModelVisiblePromptContent,
19
+ promptContentBytes,
20
+ prefixUserTurnContent,
21
+ prefixSessionStartContent,
22
+ buildCurrentTimeBlock,
23
+ buildSessionStartBlock,
24
+ hasUserConversationMessage,
25
+ isInternalRuntimeNotificationText,
26
+ } from './prompt-utils.mjs';
27
+ import { _mergePendingMessageEntries, drainPendingMessages } from './pending-messages.mjs';
28
+ import { persistIterationMetrics, applyAskTerminalUsageTotals } from './usage-metrics.mjs';
29
+ import {
30
+ updateSessionStage,
31
+ markSessionAskStart,
32
+ markSessionStreamDelta,
33
+ markSessionDone,
34
+ markSessionEmptyFinal,
35
+ markSessionError,
36
+ markSessionCancelled,
37
+ _touchRuntime,
38
+ _unlinkParentAbortListener,
39
+ _getRuntimeEntry,
40
+ } from './runtime-liveness.mjs';
41
+ import { SessionClosedError } from './session-errors.mjs';
42
+ import { acquireSessionLock } from './session-lock.mjs';
43
+ import { _tryBridgeExplicitPrefetch } from './prefetch-bridge.mjs';
44
+ import { sanitizeSessionMessagesForModel, persistCompactedOutgoingAfterAskFailure } from './message-sanitize.mjs';
45
+ import { _getAgentLoop } from './runtime-loaders.mjs';
46
+ import { getAgentRuntimeSync } from './agent-runtime-singleton.mjs';
47
+ import { nonNegativeIntEnv } from './env-utils.mjs';
48
+
49
+ // Cap how long the terminal unwind blocks on the post-result session save.
50
+ // The result is already produced (and relayed for agent surfaces) before this
51
+ // save, so a stalled disk write must not hold askSession() open — otherwise the
52
+ // owning background task is stranded in `running` and its completion
53
+ // notification never fires. A slow write finishes in the background.
54
+ const TERMINAL_SAVE_TIMEOUT_MS = nonNegativeIntEnv('MIXDOG_TERMINAL_SAVE_TIMEOUT_MS', 5_000);
55
+
56
+ /**
57
+ * Wrap an async call so that if the session's controller aborts mid-flight,
58
+ * the wrapper settles with a SessionClosedError even if the underlying promise
59
+ * hasn't returned yet. The original promise is kept alive with a detached
60
+ * `.catch()` to prevent unhandled-rejection warnings once it eventually
61
+ * settles. Callers still must check generation/closed after await returns
62
+ * to handle providers that ignore the AbortSignal entirely.
63
+ */
64
+ export async function _api_call_with_interrupt(sessionId, fn) {
65
+ const entry = _touchRuntime(sessionId);
66
+ if (!entry.controller) entry.controller = createAbortController();
67
+ const signal = entry.controller.signal;
68
+ const closedFromAbort = (phase) => {
69
+ const reason = signal.reason;
70
+ if (reason instanceof SessionClosedError) return reason;
71
+ const detail = reason instanceof Error
72
+ ? reason.message
73
+ : (reason !== undefined && reason !== null && reason !== '' ? String(reason) : '');
74
+ return new SessionClosedError(sessionId, detail ? `${phase}: ${detail}` : phase);
75
+ };
76
+ if (signal.aborted) throw closedFromAbort('aborted before call');
77
+ const underlying = fn(signal);
78
+ underlying.catch(() => {}); // prevent unhandled rejection if we race ahead
79
+ let onAbort = null;
80
+ const aborted = new Promise((_, reject) => {
81
+ onAbort = () => reject(closedFromAbort('aborted during call'));
82
+ if (signal.aborted) onAbort();
83
+ else signal.addEventListener('abort', onAbort, { once: true });
84
+ });
85
+ try {
86
+ return await Promise.race([underlying, aborted]);
87
+ } finally {
88
+ // If the underlying promise settled first, the abort listener is
89
+ // still attached. Remove it to avoid accumulating listeners across
90
+ // many asks on the same session.
91
+ if (onAbort && !signal.aborted) {
92
+ try { signal.removeEventListener('abort', onAbort); } catch { /* ignore */ }
93
+ }
94
+ }
95
+ }
96
+
97
+ export async function askSession(sessionId, prompt, context, onToolCall, cwdOverride, explicitPrefetch, askOpts = {}) {
98
+ const _askStartedAt = Date.now();
99
+ const _promptSrc = 'prompt';
100
+ const _prefetchFiles = (explicitPrefetch?.files?.length) || 0;
101
+ const _prefetchCallers = (explicitPrefetch?.callers?.length) || 0;
102
+ const _prefetchRefs = (explicitPrefetch?.references?.length) || 0;
103
+ if (process.env.MIXDOG_DEBUG_AGENT) {
104
+ 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`);
105
+ }
106
+ const unlock = await acquireSessionLock(sessionId);
107
+ const _lockWaitedMs = Date.now() - _askStartedAt;
108
+ if (process.env.MIXDOG_DEBUG_AGENT) {
109
+ process.stderr.write(`[agent-trace] lock-acquired waitedMs=${_lockWaitedMs}\n`);
110
+ }
111
+ // The mutex is held for the WHOLE askSession call, including any follow-up
112
+ // turns drained from the pending-message queue below — the single outer
113
+ // try/finally releases it exactly once. _result holds the last turn's
114
+ // return value (the queued tail turns supersede the original prompt's
115
+ // result, mirroring how a live chat returns the latest turn).
116
+ let _result;
117
+ // Local FIFO of follow-up prompts drained from the pending-message queue
118
+ // after each turn — keeps queued `agent type=send` messages in order.
119
+ const _pendingTail = [];
120
+ // Hoisted so the outer finally (which runs once after the whole turn loop)
121
+ // can compare against the last turn's generation.
122
+ let askGeneration = 0;
123
+ try {
124
+ // Turn loop (pendingMessages pattern): run the current prompt, then drain
125
+ // any `agent type=send` messages that were queued while this turn was in
126
+ // flight and run them — in order — as the next user turn(s). Because the
127
+ // queued send always lands AFTER the in-flight prompt here, ordering is
128
+ // preserved and the spawn/connecting startup race disappears.
129
+ for (;;) {
130
+ let _pwstTurnDrained = null;
131
+ // After the first turn, the next prompt comes from the drained queue.
132
+ // (On the first iteration _pendingTail is empty and `prompt` is the
133
+ // caller's original message.)
134
+ if (_pendingTail.length > 0) {
135
+ prompt = _pendingTail.shift();
136
+ // Queued follow-ups are plain user turns — no caller context /
137
+ // prefetch is re-applied (those belonged to the original ask).
138
+ context = null;
139
+ explicitPrefetch = null;
140
+ } else if (!hasModelVisiblePromptContent(prompt)) {
141
+ // Idle resume: TUI kicks an empty ask() after execution completions
142
+ // mirror model-visible bodies into session pending. Drain that queue
143
+ // here so we never synthesize an empty user turn for the model.
144
+ const _preDrained = drainPendingMessages(sessionId);
145
+ if (_preDrained.length > 0) {
146
+ const _mergedPre = _mergePendingMessageEntries(_preDrained);
147
+ if (_mergedPre?.content) {
148
+ prompt = _mergedPre.content;
149
+ context = null;
150
+ explicitPrefetch = null;
151
+ }
152
+ }
153
+ }
154
+ if (!hasModelVisiblePromptContent(prompt)) {
155
+ _unlinkParentAbortListener(_getRuntimeEntry(sessionId));
156
+ return _result;
157
+ }
158
+ // ── Synchronous pre-await setup (must happen before any await so
159
+ // closeSession() can't interleave between load and registration) ──
160
+ const preSession = loadSession(sessionId);
161
+ if (!preSession) {
162
+ throw new Error(`Session "${sessionId}" not found`);
163
+ }
164
+ if (preSession.closed === true) {
165
+ throw new SessionClosedError(sessionId, 'session already closed');
166
+ }
167
+ // A prior crash/partial-save during compaction may have pinned
168
+ // compaction.lastStage='compacting'. This ask is starting fresh, so
169
+ // recover the stale transient stage before the loop's pre-send compact
170
+ // path runs (it will overwrite lastStage with real telemetry).
171
+ normalizeStaleCompactingStage(preSession);
172
+ askGeneration = typeof preSession.generation === 'number' ? preSession.generation : 0;
173
+ const runtime = _touchRuntime(sessionId);
174
+ // Fresh controller per ask — the previous ask's controller may have aborted.
175
+ runtime.controller = createAbortController();
176
+ runtime.generation = askGeneration;
177
+ runtime.closed = false;
178
+ runtime.session = preSession;
179
+ markSessionAskStart(sessionId);
180
+ // Preprocessing is inside try so provider-not-available / trim failures
181
+ // fall into the catch and mark the session as errored rather than
182
+ // leaving stage='connecting' forever.
183
+ let activeSession = preSession;
184
+ let cancelledUserTurnContent = '';
185
+ let _turnOutgoing = null;
186
+ try {
187
+ const session = activeSession;
188
+ const provider = getProvider(session.provider);
189
+ // Register the live session object into runtime so closeSession()
190
+ // can read allBashSessionIds that loop.mjs appends mid-turn.
191
+ runtime.session = session;
192
+ if (!provider)
193
+ throw new Error(`Provider "${session.provider}" not available`);
194
+ const contextMeta = resolveSessionContextMeta(provider, session.model, session);
195
+ session.contextWindow = contextMeta.contextWindow;
196
+ session.rawContextWindow = contextMeta.rawContextWindow;
197
+ session.effectiveContextWindowPercent = contextMeta.effectiveContextWindowPercent;
198
+ session.autoCompactTokenLimit = contextMeta.autoCompactTokenLimit;
199
+ session.compactBoundaryTokens = contextMeta.compactBoundaryTokens;
200
+ session.compaction = {
201
+ ...(session.compaction || {}),
202
+ auto: session.compaction?.auto !== false,
203
+ semantic: session.compaction?.semantic ?? 'auto',
204
+ type: normalizeCompactType(session.compaction?.type ?? session.compaction?.compactType ?? session.compaction?.compact_type, DEFAULT_COMPACT_TYPE),
205
+ compactType: normalizeCompactType(session.compaction?.type ?? session.compaction?.compactType ?? session.compaction?.compact_type, DEFAULT_COMPACT_TYPE),
206
+ boundaryTokens: contextMeta.compactBoundaryTokens,
207
+ bufferTokens: positiveContextWindow(session.compaction?.bufferTokens ?? session.compaction?.buffer) || session.compaction?.bufferTokens || null,
208
+ keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens) || session.compaction?.keepTokens || null,
209
+ contextWindow: contextMeta.contextWindow,
210
+ rawContextWindow: contextMeta.rawContextWindow,
211
+ effectiveContextWindowPercent: contextMeta.effectiveContextWindowPercent,
212
+ autoCompactTokenLimit: contextMeta.autoCompactTokenLimit,
213
+ };
214
+ // Cap caller-supplied / prefetched context so an oversized
215
+ // payload can't blow the session token budget before the
216
+ // first model call. 32 KB ~ 8k tokens at the 4 B/tok
217
+ // working average; longer is silently truncated with a
218
+ // visible marker so the model still sees the prefix and
219
+ // a hint about the cut.
220
+ const _CTX_CHAR_CAP = 32 * 1024;
221
+ const _capCtx = (text) => {
222
+ if (typeof text !== 'string') return '';
223
+ if (text.length <= _CTX_CHAR_CAP) return text;
224
+ return `${text.slice(0, _CTX_CHAR_CAP)}\n\n... [context truncated; original ${text.length} chars]`;
225
+ };
226
+ // Inline context + prefetch INTO the prompt as a single user turn,
227
+ // marked with explicit section headers. The previous design pushed
228
+ // context as separate user messages with pre-injected assistant
229
+ // "Noted." acks; that conversational pattern taught some models a
230
+ // low-effort rhythm and they responded with "Noted." / empty tags
231
+ // even to the real task. Single-turn structure with a labelled
232
+ // `# Task` block forces the model to treat the brief as the work
233
+ // unit, not as another piece of context to ack.
234
+ const explicitPrefetchResult = await _tryBridgeExplicitPrefetch(session, explicitPrefetch);
235
+ let _contextBlock = '';
236
+ if (context) {
237
+ _contextBlock += `# Additional context\n${_capCtx(context)}\n\n`;
238
+ }
239
+ if (explicitPrefetchResult) {
240
+ _contextBlock += `# Prefetch\n${_capCtx(explicitPrefetchResult)}\n\n`;
241
+ }
242
+ const historyMessages = sanitizeSessionMessagesForModel(session.messages);
243
+ const beforeCount = historyMessages.length + 1;
244
+ const promptTextForMetrics = promptContentText(prompt);
245
+ // Soft warning only; real size management (compaction primary,
246
+ // byte-budget trim as safety net) lives in agentLoop. Selecting a
247
+ // 25% pre-trim here would starve compaction's 50% threshold.
248
+ const softBudget = Math.floor(session.contextWindow * 0.25);
249
+ const promptTokenEstimate = promptTextForMetrics.length * 0.5; // conservative for CJK
250
+ if (promptTokenEstimate > softBudget * 0.7) {
251
+ process.stderr.write(`[session] Warning: prompt is very large (est. ${Math.round(promptTokenEstimate)} tokens vs ${softBudget} soft budget)\n`);
252
+ }
253
+ const effectiveCwd = cwdOverride || session.cwd;
254
+ const shouldInjectSessionStart = session.sessionStartMetaInjected !== true
255
+ && !hasUserConversationMessage(historyMessages);
256
+ const _sessionStartBlock = shouldInjectSessionStart
257
+ ? buildSessionStartBlock(session, effectiveCwd)
258
+ : '';
259
+ const _currentTimeBlock = buildCurrentTimeBlock(prompt);
260
+ const _turnReminderBlock = _currentTimeBlock
261
+ ? `<system-reminder>\n# Current Time\n${_currentTimeBlock}\n</system-reminder>`
262
+ : '';
263
+ const _turnPrefixBlock = [_sessionStartBlock, _turnReminderBlock].filter(Boolean).join('\n\n');
264
+ const _baseUserTurnContent = prefixUserTurnContent(prompt, _contextBlock);
265
+ const _userTurnContent = prefixSessionStartContent(_baseUserTurnContent, _turnPrefixBlock);
266
+ if (shouldInjectSessionStart && _sessionStartBlock) {
267
+ session.sessionStartMetaInjected = true;
268
+ }
269
+ cancelledUserTurnContent = _userTurnContent;
270
+ const outgoing = [...historyMessages, { role: 'user', content: _userTurnContent }];
271
+ _turnOutgoing = outgoing;
272
+ // Expose the in-flight working transcript so contextStatus() can
273
+ // estimate the LIVE context footprint mid-turn. agentLoop mutates
274
+ // `outgoing` in place (user turn + tool calls/results + compaction),
275
+ // so the statusline context gauge climbs as the turn accumulates
276
+ // tool output instead of freezing at the pre-turn snapshot. Cleared
277
+ // on turn commit (below) and in the ask finally.
278
+ session.liveTurnMessages = outgoing;
279
+ // Per-turn injected-context trace row (complements kind:"usage").
280
+ // Cheap byte-length accounting — no hashing, no payload bodies.
281
+ // Honors the same MIXDOG_AGENT_TRACE_DISABLE gate as usage rows;
282
+ // appendAgentTrace is a no-op when that env is set.
283
+ try {
284
+ const _ctxBytes = Buffer.byteLength(context || '', 'utf8');
285
+ const _prefetchBytes = Buffer.byteLength(explicitPrefetchResult || '', 'utf8');
286
+ const _promptBytes = promptContentBytes(prompt);
287
+ const _userTurnBytes = promptContentBytes(_userTurnContent);
288
+ const _messagesBytes = Buffer.byteLength(JSON.stringify(historyMessages || []), 'utf8');
289
+ const _totalBytes = _userTurnBytes + _messagesBytes;
290
+ appendAgentTrace({
291
+ kind: 'context',
292
+ sessionId,
293
+ model: session.model,
294
+ provider: session.provider,
295
+ totalBytes: _totalBytes,
296
+ breakdown: {
297
+ contextBytes: _ctxBytes,
298
+ prefetchBytes: _prefetchBytes,
299
+ promptBytes: _promptBytes,
300
+ userTurnBytes: _userTurnBytes,
301
+ messagesBytes: _messagesBytes,
302
+ messagesCount: historyMessages.length,
303
+ },
304
+ });
305
+ } catch { /* trace must never break the ask path */ }
306
+ const agentLoop = await _getAgentLoop();
307
+ const priorToolApprovalHook = session.toolApprovalHook;
308
+ if (typeof askOpts?.onToolApproval === 'function') {
309
+ session.toolApprovalHook = askOpts.onToolApproval;
310
+ }
311
+ let result;
312
+ try {
313
+ result = await _api_call_with_interrupt(sessionId, (signal) =>
314
+ agentLoop(provider, outgoing, session.model, session.tools, onToolCall, effectiveCwd, {
315
+ effort: session.effort || null,
316
+ fast: session.fast === true,
317
+ sessionId,
318
+ onTextDelta: typeof askOpts?.onTextDelta === 'function' ? askOpts.onTextDelta : undefined,
319
+ onReasoningDelta: typeof askOpts?.onReasoningDelta === 'function' ? askOpts.onReasoningDelta : undefined,
320
+ onAssistantText: typeof askOpts?.onAssistantText === 'function' ? askOpts.onAssistantText : undefined,
321
+ onUsageDelta: (d) => {
322
+ persistIterationMetrics(d).catch(() => {});
323
+ try { askOpts?.onUsageDelta?.(d); } catch {}
324
+ },
325
+ onToolResult: typeof askOpts?.onToolResult === 'function' ? askOpts.onToolResult : undefined,
326
+ onToolApproval: typeof askOpts?.onToolApproval === 'function' ? askOpts.onToolApproval : undefined,
327
+ onCompactEvent: typeof askOpts?.onCompactEvent === 'function' ? askOpts.onCompactEvent : undefined,
328
+ // Mid-turn steering drain. agentLoop calls this at every
329
+ // tool-batch boundary (before the next provider.send) and
330
+ // injects any returned strings as user turns — so input
331
+ // (user typing, `agent type=send`) that arrives WHILE a
332
+ // long multi-tool turn is in flight is picked up on the
333
+ // model's very next iteration instead of waiting for the
334
+ // whole task to finish. The post-turn _pendingTail drain
335
+ // below still handles "followUp" input that lands after the
336
+ // agent would otherwise stop. Same queue, two drain points.
337
+ drainSteering: (sid) => {
338
+ const out = [];
339
+ if (typeof askOpts?.drainSteering === 'function') {
340
+ try {
341
+ const drained = askOpts.drainSteering(sid || sessionId);
342
+ if (Array.isArray(drained)) out.push(...drained);
343
+ } catch { /* best-effort steering drain */ }
344
+ }
345
+ try { out.push(...drainPendingMessages(sid || sessionId)); }
346
+ catch { /* best-effort pending drain */ }
347
+ return out;
348
+ },
349
+ onSteerMessage: typeof askOpts?.onSteerMessage === 'function' ? askOpts.onSteerMessage : undefined,
350
+ notifyFn: typeof askOpts?.notifyFn === 'function' ? askOpts.notifyFn : undefined,
351
+ promptCacheKey: session.promptCacheKey || sessionId,
352
+ // Provider-scoped cache key (mixdog-codex, mixdog-claude…).
353
+ // Distinct from sessionId — providers that pool sockets
354
+ // per-session (openai-oauth WS) use sessionId as the
355
+ // pool bucket and providerCacheKey as the server-side
356
+ // prompt-cache shard so parallel callers don't collide
357
+ // on a mid-turn socket while still sharing prefix cache.
358
+ providerCacheKey: session.promptCacheKey || null,
359
+ signal,
360
+ providerState: session.providerState ?? undefined,
361
+ session,
362
+ // Agent Runtime cache settings — merged last so session overrides
363
+ // don't get overridden by defaults. When session has no profile,
364
+ // providerCacheOpts is null and this spread is a no-op.
365
+ ...(session.providerCacheOpts || {}),
366
+ onStageChange: (stage) => {
367
+ updateSessionStage(sessionId, stage);
368
+ try { askOpts?.onStageChange?.(stage); } catch {}
369
+ },
370
+ onStreamDelta: () => {
371
+ markSessionStreamDelta(sessionId).catch(() => {});
372
+ try { askOpts?.onStreamDelta?.(); } catch {}
373
+ },
374
+ }),
375
+ );
376
+ } finally {
377
+ if (priorToolApprovalHook === undefined) {
378
+ delete session.toolApprovalHook;
379
+ } else {
380
+ session.toolApprovalHook = priorToolApprovalHook;
381
+ }
382
+ }
383
+ // Post-loop validation: if closeSession() landed while we were awaiting,
384
+ // drop the save so the tombstone on disk isn't overwritten.
385
+ const currentRuntime = _getRuntimeEntry(sessionId);
386
+ if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) {
387
+ const reason = currentRuntime?.closedReason;
388
+ throw new SessionClosedError(sessionId, `closed during call (reason=${reason || 'unknown'})`, reason || null);
389
+ }
390
+ // Update and save. outgoing is mutated in place by agentLoop
391
+ // (compaction + safety trim), so its length reflects post-loop state.
392
+ const messagesDropped = Math.max(0, beforeCount - outgoing.length);
393
+ session.messages = sanitizeSessionMessagesForModel(outgoing);
394
+ // Turn committed into session.messages; drop the live-turn alias so
395
+ // contextStatus() reverts to the authoritative committed transcript.
396
+ session.liveTurnMessages = null;
397
+ if (result.content || result.reasoningContent) {
398
+ session.messages.push({
399
+ role: 'assistant',
400
+ // Keep content as-is in memory (model-visible). Image bytes,
401
+ // if any, are swapped for a placeholder only at disk write
402
+ // time inside the session store (store.mjs _sessionForDisk).
403
+ content: result.content || '',
404
+ ...(typeof result.reasoningContent === 'string' && result.reasoningContent
405
+ ? { reasoningContent: result.reasoningContent }
406
+ : {}),
407
+ });
408
+ } else {
409
+ // Empty terminal turn: still persist a forensic record so
410
+ // post-mortem inspection can distinguish "work landed but
411
+ // synthesis missing" from "session never ran". Stop reason,
412
+ // usage, iterations, and tool-call totals survive even when
413
+ // the assistant produced no content/reasoning.
414
+ const _emptyStop = result?.stopReason ?? result?.stop_reason ?? null;
415
+ const _emptyUsage = result?.usage ? {
416
+ inputTokens: result.usage.inputTokens || 0,
417
+ outputTokens: result.usage.outputTokens || 0,
418
+ cachedTokens: result.usage.cachedTokens || 0,
419
+ cacheWriteTokens: result.usage.cacheWriteTokens || 0,
420
+ } : null;
421
+ // Provider content-block classification — distinguishes a
422
+ // thinking-only stall (model emitted reasoning blocks but no
423
+ // text/tool_use) from a true silent empty turn. Anthropic
424
+ // providers (anthropic.mjs, anthropic-oauth.mjs) set these
425
+ // fields on the result; other providers may omit them.
426
+ const _emptyHasThinking = typeof result?.hasThinkingContent === 'boolean'
427
+ ? result.hasThinkingContent
428
+ : null;
429
+ const _emptyBlockTypes = Array.isArray(result?.contentBlockTypes)
430
+ ? result.contentBlockTypes.slice()
431
+ : null;
432
+ session.messages.push({
433
+ role: 'assistant',
434
+ content: '',
435
+ emptyFinal: true,
436
+ stopReason: _emptyStop,
437
+ iterations: result?.iterations ?? null,
438
+ toolCallsTotal: result?.toolCallsTotal ?? null,
439
+ usage: _emptyUsage,
440
+ ...(_emptyHasThinking !== null ? { hasThinkingContent: _emptyHasThinking } : {}),
441
+ ...(_emptyBlockTypes !== null ? { contentBlockTypes: _emptyBlockTypes } : {}),
442
+ ts: Date.now(),
443
+ });
444
+ try {
445
+ const _blockTypesStr = _emptyBlockTypes ? _emptyBlockTypes.join(',') || 'none' : 'unknown';
446
+ const _thinkingStr = _emptyHasThinking === null ? 'unknown' : String(_emptyHasThinking);
447
+ 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`);
448
+ } catch {}
449
+ }
450
+ session.updatedAt = Date.now();
451
+ session.lastUsedAt = Date.now();
452
+ applyAskTerminalUsageTotals(session, result, {
453
+ skipTotalsIfIncremental: runtime?.usageMetricsTurnIncremental === true,
454
+ });
455
+ // Agent Runtime cache stats — record hit/miss after every successful
456
+ // ask so the registry reflects all agent traffic, not just
457
+ // maintenance cycles. Guarded against any agent-runtime error so
458
+ // metric recording never breaks the ask itself.
459
+ let prefixHashForLog = null;
460
+ const _agentRuntimeApi = getAgentRuntimeSync();
461
+ if (session.profileId && result.usage && _agentRuntimeApi) {
462
+ try {
463
+ const profile = _agentRuntimeApi.getProfile(session.profileId);
464
+ if (profile) {
465
+ // Collect every leading system-role message (BP1, BP2, ...)
466
+ // until the first non-system message so the registry hash
467
+ // captures the full ordered provider prefix, not just BP1.
468
+ const systemMsgs = [];
469
+ for (const m of session.messages) {
470
+ if (m?.role !== 'system') break;
471
+ systemMsgs.push(typeof m.content === 'string' ? m.content : '');
472
+ }
473
+ _agentRuntimeApi.recordCall(profile, session.provider, {
474
+ systemPrompt: systemMsgs,
475
+ tools: session.tools || [],
476
+ usage: result.usage,
477
+ });
478
+ const entry = _agentRuntimeApi.registry?.data?.profiles?.[session.profileId]?.[session.provider];
479
+ prefixHashForLog = entry?.prefixHash || null;
480
+ }
481
+ } catch {}
482
+ }
483
+ // Append to the agent trace store with rich usage fields.
484
+ if (result.usage) {
485
+ const inputTokens = result.usage.inputTokens || 0;
486
+ const outputTokens = result.usage.outputTokens || 0;
487
+ const cacheReadTokens = result.usage.cachedTokens || 0;
488
+ const cacheWriteTokens = result.usage.cacheWriteTokens || 0;
489
+ // Unified total-prompt field. Anthropic = input+cache_read+cache_write
490
+ // (additive); OpenAI OAuth/API/Gemini = input_tokens already includes the
491
+ // cached portion (inclusive), so the fallback must not double-count.
492
+ const { isInclusiveProvider, computeCostUsd } = await import('../../../../shared/llm/cost.mjs');
493
+ const inclusive = isInclusiveProvider(session.provider);
494
+ const promptTokens = typeof result.usage.promptTokens === 'number'
495
+ ? result.usage.promptTokens
496
+ : (inclusive
497
+ ? Math.max(inputTokens, cacheReadTokens + cacheWriteTokens)
498
+ : inputTokens + cacheReadTokens + cacheWriteTokens);
499
+ let costUsd = result.usage.costUsd || 0;
500
+ if (!costUsd) {
501
+ try {
502
+ costUsd = computeCostUsd({
503
+ model: session.model,
504
+ provider: session.provider,
505
+ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
506
+ });
507
+ } catch { /* best-effort */ }
508
+ }
509
+ logLlmCall({
510
+ ts: new Date().toISOString(),
511
+ sourceType: session.sourceType || 'lead',
512
+ sourceName: session.sourceName || session.agent || null,
513
+ preset: session.presetName || null,
514
+ model: session.model,
515
+ provider: session.provider,
516
+ duration: Date.now() - _askStartedAt,
517
+ profileId: session.profileId || null,
518
+ sessionId: session.id,
519
+ inputTokens,
520
+ outputTokens,
521
+ cacheReadTokens,
522
+ cacheWriteTokens,
523
+ promptTokens,
524
+ prefixHash: prefixHashForLog,
525
+ costUsd,
526
+ });
527
+ recordStandaloneStatusTelemetry(session, result, Date.now() - _askStartedAt);
528
+ }
529
+ // Persist opaque providerState for future stateful providers.
530
+ // No provider currently emits it (openai-oauth is stateless per
531
+ // contract), so this branch is dormant — kept so a future
532
+ // Responses-API provider with stable continuation can plug in
533
+ // without reworking the session shape.
534
+ if (result.providerState !== undefined) {
535
+ session.providerState = result.providerState;
536
+ }
537
+ const terminalResultPreview = {
538
+ ...result,
539
+ trimmed: messagesDropped > 0,
540
+ messagesDropped,
541
+ };
542
+ _pwstTurnDrained = drainPendingMessages(sessionId);
543
+ if (_pwstTurnDrained.length === 0 && typeof askOpts?.onTerminalResult === 'function') {
544
+ try {
545
+ askOpts.onTerminalResult(terminalResultPreview, {
546
+ sessionId,
547
+ beforeSave: true,
548
+ durationMs: Date.now() - _askStartedAt,
549
+ });
550
+ } catch { /* best-effort early completion relay */ }
551
+ }
552
+ // Auto-compact runs at the start of the next
553
+ // query/provider send (agentLoop pre-send), not after the previous
554
+ // answer. This lets queued follow-up prompts resume immediately;
555
+ // if they need compaction, their own spinner shows compacting first.
556
+ // Bounded, best-effort terminal save. The result is already produced
557
+ // and (for agent surfaces) relayed via onTerminalResult above. If the
558
+ // disk write stalls, blocking the terminal unwind here would strand the
559
+ // owning background task in `running` and suppress its completion
560
+ // notification. Cap the wait; let a slow write finish in the
561
+ // background instead of holding askSession() open indefinitely.
562
+ {
563
+ const savePromise = saveSessionAsync(session, { expectedGeneration: askGeneration });
564
+ let saveTimer = null;
565
+ const saveTimeout = new Promise((resolveTimeout) => {
566
+ saveTimer = setTimeout(() => resolveTimeout('__save_timeout__'), TERMINAL_SAVE_TIMEOUT_MS);
567
+ saveTimer.unref?.();
568
+ });
569
+ try {
570
+ const outcome = await Promise.race([
571
+ savePromise.then(() => '__save_ok__', (err) => { throw err; }),
572
+ saveTimeout,
573
+ ]);
574
+ if (outcome === '__save_timeout__') {
575
+ try { process.stderr.write(`[session] terminal save exceeded ${TERMINAL_SAVE_TIMEOUT_MS}ms; continuing best-effort (${sessionId})\n`); } catch {}
576
+ // Don't drop the write — let it settle in the background.
577
+ savePromise.catch((err) => {
578
+ try { process.stderr.write(`[session] deferred terminal save failed: ${err?.message || err}\n`); } catch {}
579
+ });
580
+ }
581
+ } finally {
582
+ if (saveTimer) { try { clearTimeout(saveTimer); } catch {} }
583
+ }
584
+ }
585
+ activeSession = session;
586
+ runtime.session = session;
587
+ // Tag empty-synthesis BEFORE markSessionDone so the watchdog
588
+ // (which inspects entry.emptyFinal first) classifies the
589
+ // terminal state correctly even if it ticks during unwind.
590
+ const isEmptyFinal = !result.content && !result.reasoningContent;
591
+ if (isEmptyFinal) {
592
+ markSessionEmptyFinal(sessionId);
593
+ }
594
+ markSessionDone(sessionId, { empty: isEmptyFinal });
595
+ _result = terminalResultPreview;
596
+ } catch (err) {
597
+ // Cancellation/error paths bypass the commit point above; drop the
598
+ // live-turn alias so contextStatus() stops estimating from the
599
+ // stale in-flight array once the turn unwinds.
600
+ if (activeSession) activeSession.liveTurnMessages = null;
601
+ if (err instanceof SessionClosedError) {
602
+ const currentRuntime = _getRuntimeEntry(sessionId);
603
+ if (!currentRuntime?.closed) {
604
+ if (activeSession) {
605
+ const originalMessages = Array.isArray(activeSession.messages) ? activeSession.messages : [];
606
+ const cleanedMessages = sanitizeSessionMessagesForModel(originalMessages);
607
+ const nextMessages = cleanedMessages.slice();
608
+ // In-memory cancelled turn keeps its original content
609
+ // (images intact for the next model send); the store
610
+ // layer placeholders image bytes on disk serialization.
611
+ const cancelledStoredContent = cancelledUserTurnContent;
612
+ const shouldPreserveUserTurn = cancelledStoredContent && !isInternalRuntimeNotificationText(cancelledStoredContent);
613
+ const lastMessage = nextMessages[nextMessages.length - 1];
614
+ if (shouldPreserveUserTurn && !(lastMessage?.role === 'user' && promptContentText(lastMessage.content) === promptContentText(cancelledStoredContent))) {
615
+ nextMessages.push({ role: 'user', content: cancelledStoredContent });
616
+ }
617
+ const messagesChanged = nextMessages.length !== originalMessages.length
618
+ || nextMessages.some((message, index) => message !== originalMessages[index]);
619
+ if (messagesChanged) {
620
+ activeSession.messages = nextMessages;
621
+ activeSession.updatedAt = Date.now();
622
+ activeSession.lastUsedAt = Date.now();
623
+ try {
624
+ await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
625
+ } catch { /* cancellation cleanup is best-effort */ }
626
+ }
627
+ }
628
+ markSessionCancelled(sessionId);
629
+ }
630
+ // Cancellation is not an error; propagate silently so callers
631
+ // can render it as "cancelled" rather than a red failure.
632
+ throw err;
633
+ }
634
+ await persistCompactedOutgoingAfterAskFailure({
635
+ sessionId,
636
+ activeSession,
637
+ askGeneration,
638
+ turnOutgoing: _turnOutgoing,
639
+ error: err,
640
+ });
641
+ markSessionError(sessionId, err && err.message ? err.message : String(err));
642
+ throw err;
643
+ }
644
+ // ── Turn complete. Drain the pending-message queue: any `agent type=send` that arrived while this
645
+ // turn was in flight runs next, in order, as a follow-up user turn.
646
+ // The mutex is still held, so a send racing this drain either landed
647
+ // before (picked up here) or enqueues for the next loop. When the
648
+ // queue is empty we return the latest turn's result. ──
649
+ const _drained = (_pwstTurnDrained && _pwstTurnDrained.length > 0)
650
+ ? _pwstTurnDrained
651
+ : drainPendingMessages(sessionId);
652
+ if (_drained.length > 0) {
653
+ // Same merge rule as the mid-turn steering drain (loop.mjs) and
654
+ // the TUI engine.mjs drain(): a single drain batch is joined with
655
+ // "\n" and delivered as ONE follow-up turn, not N isolated turns.
656
+ // Keeps every steering/follow-up path on identical
657
+ // merge-then-deliver semantics. Anything that arrives AFTER this
658
+ // drain enqueues for the next loop pass and is merged there.
659
+ const _mergedTail = _mergePendingMessageEntries(_drained);
660
+ if (_mergedTail?.content) {
661
+ _pendingTail.push(_mergedTail.content);
662
+ const refreshed = loadSession(sessionId);
663
+ if (refreshed && refreshed.closed !== true) {
664
+ activeSession = refreshed;
665
+ runtime.session = refreshed;
666
+ }
667
+ continue;
668
+ }
669
+ }
670
+ _unlinkParentAbortListener(_getRuntimeEntry(sessionId));
671
+ return _result;
672
+ }
673
+ } finally {
674
+ // Clear the controller only if it's still ours (closeSession may have
675
+ // swapped it). Leave the rest of the runtime entry intact so agent type=list
676
+ // can still surface the final stage (done/error/cancelling).
677
+ const entry = _getRuntimeEntry(sessionId);
678
+ if (entry && entry.generation === askGeneration) {
679
+ _unlinkParentAbortListener(entry);
680
+ entry.controller = null;
681
+ // Detach the live session reference; ask is over.
682
+ entry.session = null;
683
+ }
684
+ unlock();
685
+ }
686
+ }