mixdog 0.9.95 → 0.9.96

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 (178) hide show
  1. package/LICENSES/Apache-2.0.txt +201 -0
  2. package/LICENSES/MIT.txt +56 -0
  3. package/LICENSES/codex-NOTICE.txt +6 -0
  4. package/NOTICE.md +46 -9
  5. package/package.json +12 -9
  6. package/scripts/.tmp-coverage-report.mjs +96 -0
  7. package/scripts/build-tui.mjs +17 -1
  8. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  9. package/src/defaults/mixdog-config.template.json +5 -8
  10. package/src/rules/agent/30-explorer.md +7 -4
  11. package/src/rules/shared/01-tool.md +20 -29
  12. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  13. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  14. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  16. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  17. package/src/runtime/agent/orchestrator/mcp/client.mjs +26 -11
  18. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  19. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  20. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  21. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  22. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  23. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  25. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  26. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  28. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  29. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  30. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  31. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  32. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +28 -4
  34. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -33
  35. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  36. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  37. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  38. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
  39. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
  40. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +5 -0
  41. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  42. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  44. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  45. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  46. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  47. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  48. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
  49. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -33
  50. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  51. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  54. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +118 -18
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  56. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  58. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  59. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
  63. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +23 -9
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  66. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  70. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +87 -15
  73. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
  74. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  76. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  77. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +29 -1
  78. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  79. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  80. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +10 -10
  81. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -8
  82. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  83. package/src/runtime/channels/lib/config.mjs +2 -2
  84. package/src/runtime/channels/lib/inbound-handler.mjs +1 -1
  85. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  86. package/src/runtime/memory/index.mjs +5 -38
  87. package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
  88. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  89. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  90. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
  91. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  92. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  93. package/src/runtime/memory/lib/http-router.mjs +8 -0
  94. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  95. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  96. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  97. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  98. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  99. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  100. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  101. package/src/runtime/memory/tool-defs.mjs +1 -5
  102. package/src/runtime/search/tool-defs.mjs +2 -2
  103. package/src/runtime/shared/agent-route-config.mjs +119 -0
  104. package/src/runtime/shared/atomic-file.mjs +80 -4
  105. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  106. package/src/runtime/shared/child-guardian.mjs +204 -108
  107. package/src/runtime/shared/child-spawn-gate.mjs +60 -31
  108. package/src/runtime/shared/config.mjs +130 -44
  109. package/src/runtime/shared/resource-admission.mjs +146 -97
  110. package/src/runtime/shared/stream-progress.mjs +6 -0
  111. package/src/runtime/shared/tool-surface.mjs +0 -5
  112. package/src/runtime/shared/turn-snapshot.mjs +31 -5
  113. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  114. package/src/session-runtime/config-helpers.mjs +39 -37
  115. package/src/session-runtime/model-route-api.mjs +7 -5
  116. package/src/session-runtime/provider-auth-api.mjs +0 -7
  117. package/src/session-runtime/runtime-core.mjs +11 -11
  118. package/src/session-runtime/runtime-tunables.mjs +4 -4
  119. package/src/session-runtime/session-lifecycle.mjs +9 -2
  120. package/src/session-runtime/session-turn-api.mjs +41 -9
  121. package/src/session-runtime/settings-api.mjs +5 -17
  122. package/src/session-runtime/tool-defs.mjs +3 -3
  123. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  124. package/src/session-runtime/workflow.mjs +14 -24
  125. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  126. package/src/standalone/agent-tool/helpers.mjs +2 -2
  127. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  128. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  129. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  130. package/src/standalone/backend-daemon.mjs +97 -36
  131. package/src/standalone/channel-admin.mjs +12 -12
  132. package/src/standalone/channel-daemon-client.mjs +17 -1
  133. package/src/standalone/channel-daemon-transport.mjs +90 -8
  134. package/src/standalone/engine-daemon-client.mjs +380 -205
  135. package/src/standalone/engine-daemon-protocol.mjs +3 -4
  136. package/src/standalone/engine-daemon-service.mjs +378 -134
  137. package/src/standalone/engine-daemon-transport.mjs +268 -77
  138. package/src/standalone/explore-tool.mjs +187 -70
  139. package/src/standalone/fair-call-scheduler.mjs +264 -0
  140. package/src/standalone/session-protocol.mjs +188 -0
  141. package/src/tui/App.jsx +30 -17
  142. package/src/tui/app/app-view.jsx +5 -2
  143. package/src/tui/app/core-memory-picker.mjs +19 -19
  144. package/src/tui/app/doctor.mjs +5 -11
  145. package/src/tui/app/message-selector.mjs +103 -0
  146. package/src/tui/app/onboarding-steps.mjs +1 -2
  147. package/src/tui/app/project-picker.mjs +80 -56
  148. package/src/tui/app/prompt-submit.mjs +46 -41
  149. package/src/tui/app/resume-picker.mjs +2 -2
  150. package/src/tui/app/route-pickers.mjs +3 -1
  151. package/src/tui/app/settings-picker.mjs +6 -8
  152. package/src/tui/app/transcript-window.mjs +38 -0
  153. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  154. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  155. package/src/tui/app/use-prompt-hint.mjs +3 -2
  156. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  157. package/src/tui/app/use-transcript-scroll.mjs +9 -2
  158. package/src/tui/app/use-transcript-window.mjs +50 -17
  159. package/src/tui/components/PromptInput.jsx +71 -38
  160. package/src/tui/components/Spinner.jsx +1 -1
  161. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  162. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  163. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  164. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  165. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  166. package/src/tui/dist/index.mjs +920 -1592
  167. package/src/tui/engine/live-share.mjs +34 -0
  168. package/src/tui/engine/session-api-ext.mjs +1 -4
  169. package/src/tui/engine/session-api.mjs +103 -33
  170. package/src/tui/engine/session-flow.mjs +7 -1
  171. package/src/tui/engine/tool-card-results.mjs +4 -2
  172. package/src/tui/engine/tool-result-text.mjs +10 -0
  173. package/src/tui/engine/turn.mjs +9 -69
  174. package/src/tui/engine-local-session.mjs +47 -31
  175. package/src/tui/index.jsx +1 -2
  176. package/src/tui/spinner-meta.mjs +4 -4
  177. package/src/tui/spinner-verbs.mjs +51 -3
  178. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
@@ -0,0 +1,291 @@
1
+ import { availableParallelism } from 'node:os';
2
+ import { Worker } from 'node:worker_threads';
3
+ import { currentProviderAdmissionOwner } from './admission-scheduler.mjs';
4
+
5
+ const DEFAULT_MIN_BATCH_BYTES = 32 * 1024;
6
+
7
+ function positiveInt(value, fallback) {
8
+ const parsed = Math.floor(Number(value));
9
+ return Number.isFinite(parsed) && parsed >= 1 ? parsed : fallback;
10
+ }
11
+
12
+ function configuredWorkerCount(env = process.env) {
13
+ const configured = Number(env.MIXDOG_PROVIDER_STREAM_WORKERS);
14
+ if (configured === 0) return 0;
15
+ if (Number.isFinite(configured) && configured > 0) return Math.floor(configured);
16
+ return Math.max(1, Math.min(4, availableParallelism() - 1));
17
+ }
18
+
19
+ function abortError(signal) {
20
+ return signal?.reason instanceof Error
21
+ ? signal.reason
22
+ : new Error(String(signal?.reason || 'provider stream parse canceled'));
23
+ }
24
+
25
+ function syntaxError(details) {
26
+ const error = new SyntaxError(String(details?.message || 'invalid JSON'));
27
+ error.name = String(details?.name || 'SyntaxError');
28
+ return error;
29
+ }
30
+
31
+ /**
32
+ * Reusable worker pool for the CPU part of provider SSE handling.
33
+ *
34
+ * Small deltas stay inline because a Worker round-trip costs more than parsing
35
+ * them. Large events and multi-event network chunks are parsed off the daemon
36
+ * event loop. Calls are never admission-capped: every request is posted
37
+ * immediately and workers consume their independent message queues.
38
+ */
39
+ export function createStreamJsonPool({
40
+ maxWorkers = configuredWorkerCount(),
41
+ minBatchBytes = Number(process.env.MIXDOG_PROVIDER_STREAM_WORKER_MIN_BYTES)
42
+ || DEFAULT_MIN_BATCH_BYTES,
43
+ maxPendingBytes = (Number(process.env.MIXDOG_PROVIDER_STREAM_PENDING_MB) || 32)
44
+ * 1024 * 1024,
45
+ WorkerImpl = Worker,
46
+ } = {}) {
47
+ const workerMax = Math.max(0, Math.floor(Number(maxWorkers) || 0));
48
+ const inlineBelowBytes = Math.max(0, Math.floor(Number(minBatchBytes) || 0));
49
+ const slots = [];
50
+ const ownerAffinities = new Map();
51
+ const waiting = [];
52
+ const pendingByteMax = Math.max(1024 * 1024, Math.floor(Number(maxPendingBytes) || 0));
53
+ let pendingBytes = 0;
54
+ let waitingBytes = 0;
55
+ let sequence = 0;
56
+ let closed = false;
57
+ const stats = {
58
+ inlineBatches: 0,
59
+ offloadedBatches: 0,
60
+ fallbackBatches: 0,
61
+ parsedBytes: 0,
62
+ };
63
+
64
+ function parseInline(payloads) {
65
+ return payloads.map((payload) => JSON.parse(payload));
66
+ }
67
+
68
+ function removeSlot(slot) {
69
+ const index = slots.indexOf(slot);
70
+ if (index >= 0) slots.splice(index, 1);
71
+ for (const [owner, assigned] of ownerAffinities) {
72
+ if (assigned === slot) ownerAffinities.delete(owner);
73
+ }
74
+ }
75
+
76
+ function failSlot(slot) {
77
+ if (slot.failed) return;
78
+ slot.failed = true;
79
+ removeSlot(slot);
80
+ for (const task of slot.tasks.values()) {
81
+ pendingBytes = Math.max(0, pendingBytes - task.bytes);
82
+ task.detach();
83
+ if (task.aborted) continue;
84
+ stats.fallbackBatches += 1;
85
+ try { task.resolve(parseInline(task.payloads)); }
86
+ catch (error) { task.reject(error); }
87
+ }
88
+ slot.tasks.clear();
89
+ try { slot.worker.terminate(); } catch {}
90
+ drainWaiting();
91
+ }
92
+
93
+ function createSlot() {
94
+ const worker = new WorkerImpl(new URL('./stream-json-worker.mjs', import.meta.url), {
95
+ execArgv: [],
96
+ });
97
+ worker.unref?.();
98
+ const slot = { worker, tasks: new Map(), failed: false };
99
+ worker.on('message', (message) => {
100
+ const task = slot.tasks.get(Number(message?.id));
101
+ if (!task) return;
102
+ slot.tasks.delete(Number(message.id));
103
+ pendingBytes = Math.max(0, pendingBytes - task.bytes);
104
+ task.detach();
105
+ if (task.aborted) return;
106
+ if (message?.ok === true) task.resolve(message.values);
107
+ else task.reject(syntaxError(message?.error));
108
+ drainWaiting();
109
+ });
110
+ worker.on('error', () => failSlot(slot));
111
+ worker.on('exit', () => {
112
+ if (!closed) failSlot(slot);
113
+ else removeSlot(slot);
114
+ });
115
+ slots.push(slot);
116
+ return slot;
117
+ }
118
+
119
+ function pickSlot(ownerKey) {
120
+ const ready = slots.filter((slot) => !slot.failed);
121
+ const owner = String(ownerKey || '').trim().slice(0, 240);
122
+ const assigned = owner ? ownerAffinities.get(owner) : null;
123
+ if (assigned && !assigned.failed) return assigned;
124
+ const least = ready.sort((left, right) => left.tasks.size - right.tasks.size)[0] || null;
125
+ const slot = ready.length < workerMax && (!least || least.tasks.size > 0)
126
+ ? createSlot()
127
+ : least || createSlot();
128
+ if (owner) {
129
+ ownerAffinities.set(owner, slot);
130
+ while (ownerAffinities.size > 4096) {
131
+ ownerAffinities.delete(ownerAffinities.keys().next().value);
132
+ }
133
+ }
134
+ return slot;
135
+ }
136
+
137
+ function removeWaiting(task) {
138
+ const index = waiting.indexOf(task);
139
+ if (index < 0) return false;
140
+ waiting.splice(index, 1);
141
+ waitingBytes = Math.max(0, waitingBytes - task.bytes);
142
+ return true;
143
+ }
144
+
145
+ function postTask(task) {
146
+ const slot = pickSlot(task.ownerKey);
147
+ task.slot = slot;
148
+ slot.tasks.set(task.id, task);
149
+ pendingBytes += task.bytes;
150
+ try {
151
+ slot.worker.postMessage({ id: task.id, payloads: task.payloads });
152
+ } catch {
153
+ slot.tasks.delete(task.id);
154
+ pendingBytes = Math.max(0, pendingBytes - task.bytes);
155
+ task.detach();
156
+ stats.fallbackBatches += 1;
157
+ try { task.resolve(parseInline(task.payloads)); }
158
+ catch (error) { task.reject(error); }
159
+ drainWaiting();
160
+ }
161
+ }
162
+
163
+ function drainWaiting() {
164
+ while (waiting.length > 0) {
165
+ const task = waiting[0];
166
+ if (pendingBytes > 0 && pendingBytes + task.bytes > pendingByteMax) return;
167
+ waiting.shift();
168
+ waitingBytes = Math.max(0, waitingBytes - task.bytes);
169
+ if (task.aborted) continue;
170
+ postTask(task);
171
+ }
172
+ }
173
+
174
+ function parseBatch(values, {
175
+ signal = null,
176
+ ownerKey = currentProviderAdmissionOwner(),
177
+ } = {}) {
178
+ const payloads = (Array.isArray(values) ? values : [values]).map((value) => String(value));
179
+ if (signal?.aborted) return Promise.reject(abortError(signal));
180
+ const bytes = payloads.reduce((sum, payload) => sum + Buffer.byteLength(payload), 0);
181
+ stats.parsedBytes += bytes;
182
+ if (workerMax === 0 || bytes < inlineBelowBytes) {
183
+ stats.inlineBatches += 1;
184
+ try { return Promise.resolve(parseInline(payloads)); }
185
+ catch (error) { return Promise.reject(error); }
186
+ }
187
+ if (closed) return Promise.reject(new Error('provider stream JSON pool is closed'));
188
+ if (bytes > pendingByteMax) {
189
+ const error = new Error(
190
+ `provider stream JSON batch exceeded ${pendingByteMax} bytes`,
191
+ );
192
+ error.code = 'ERESOURCEPRESSURE';
193
+ return Promise.reject(error);
194
+ }
195
+
196
+ const id = ++sequence;
197
+ stats.offloadedBatches += 1;
198
+ return new Promise((resolve, reject) => {
199
+ const task = {
200
+ id,
201
+ ownerKey,
202
+ bytes,
203
+ payloads,
204
+ resolve,
205
+ reject,
206
+ aborted: false,
207
+ onAbort: null,
208
+ detach() {
209
+ if (!task.onAbort || !signal) return;
210
+ try { signal.removeEventListener('abort', task.onAbort); } catch {}
211
+ task.onAbort = null;
212
+ },
213
+ };
214
+ if (signal) {
215
+ task.onAbort = () => {
216
+ if (task.aborted) return;
217
+ task.aborted = true;
218
+ task.detach();
219
+ if (removeWaiting(task)) {
220
+ reject(abortError(signal));
221
+ return;
222
+ }
223
+ reject(abortError(signal));
224
+ };
225
+ signal.addEventListener('abort', task.onAbort, { once: true });
226
+ }
227
+ if (pendingBytes > 0 && pendingBytes + bytes > pendingByteMax) {
228
+ if (waitingBytes + bytes > pendingByteMax) {
229
+ task.detach();
230
+ const error = new Error(
231
+ `provider stream JSON backlog exceeded ${pendingByteMax} bytes`,
232
+ );
233
+ error.code = 'ERESOURCEPRESSURE';
234
+ reject(error);
235
+ return;
236
+ }
237
+ waiting.push(task);
238
+ waitingBytes += bytes;
239
+ return;
240
+ }
241
+ postTask(task);
242
+ });
243
+ }
244
+
245
+ async function close(reason = 'provider stream JSON pool closed') {
246
+ if (closed) return;
247
+ closed = true;
248
+ const error = new Error(reason);
249
+ for (const task of waiting.splice(0)) {
250
+ task.detach();
251
+ if (!task.aborted) task.reject(error);
252
+ }
253
+ waitingBytes = 0;
254
+ const workers = [];
255
+ for (const slot of slots.splice(0)) {
256
+ for (const task of slot.tasks.values()) {
257
+ task.detach();
258
+ if (!task.aborted) task.reject(error);
259
+ }
260
+ slot.tasks.clear();
261
+ workers.push(Promise.resolve(slot.worker.terminate()).catch(() => {}));
262
+ }
263
+ ownerAffinities.clear();
264
+ await Promise.all(workers);
265
+ }
266
+
267
+ function snapshot() {
268
+ return {
269
+ workers: slots.filter((slot) => !slot.failed).length,
270
+ workerMax,
271
+ activeBatches: slots.reduce((sum, slot) => sum + slot.tasks.size, 0),
272
+ pendingBytes,
273
+ waitingBatches: waiting.length,
274
+ waitingBytes,
275
+ maxPendingBytes: pendingByteMax,
276
+ ownerAffinities: ownerAffinities.size,
277
+ inlineBatches: stats.inlineBatches,
278
+ offloadedBatches: stats.offloadedBatches,
279
+ fallbackBatches: stats.fallbackBatches,
280
+ parsedBytes: stats.parsedBytes,
281
+ };
282
+ }
283
+
284
+ return { parseBatch, close, snapshot };
285
+ }
286
+
287
+ export const providerStreamJsonPool = createStreamJsonPool();
288
+ export const parseProviderJsonBatch = (payloads, options) =>
289
+ providerStreamJsonPool.parseBatch(payloads, options);
290
+ export const providerStreamJsonSnapshot = () => providerStreamJsonPool.snapshot();
291
+ export const closeProviderStreamJsonPool = (reason) => providerStreamJsonPool.close(reason);
@@ -0,0 +1,21 @@
1
+ import { parentPort } from 'node:worker_threads';
2
+
3
+ if (!parentPort) throw new Error('provider stream JSON worker requires parentPort');
4
+
5
+ parentPort.on('message', (message) => {
6
+ const id = Number(message?.id);
7
+ const payloads = Array.isArray(message?.payloads) ? message.payloads : [];
8
+ try {
9
+ const values = payloads.map((payload) => JSON.parse(String(payload)));
10
+ parentPort.postMessage({ id, ok: true, values });
11
+ } catch (error) {
12
+ parentPort.postMessage({
13
+ id,
14
+ ok: false,
15
+ error: {
16
+ name: String(error?.name || 'SyntaxError'),
17
+ message: String(error?.message || error || 'invalid JSON'),
18
+ },
19
+ });
20
+ }
21
+ });
@@ -403,13 +403,18 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
403
403
  // Continuations since the last executed tool batch — bounds the text-only
404
404
  // continuation runaway (see PROVIDER_CONTINUATION_NO_TOOL_LIMIT).
405
405
  let _continuationsSinceToolBatch = 0;
406
- // Loop-level transport replays consumed this ask (see send-with-recovery
407
- // TRANSPORT_RETRY_MAX): bounded per turn, reset only with a fresh ask.
406
+ // Loop-level transport replays consumed since the last SUCCESSFUL send
407
+ // (see send-with-recovery TRANSPORT_RETRY_MAX). Reference parity: codex
408
+ // starts each sampling request at retries=0 (and resets on transport
409
+ // fallback), cc's withRetry budget is per request, opencode schedules
410
+ // retries per stream call. A per-ask budget instead let one early blip in
411
+ // a long turn leave every later iteration with zero replays.
408
412
  let _transportRetriesUsed = 0;
409
413
  // Queued prompt/task notifications are attached after a
410
414
  // tool batch, before the continuation provider send. Normal batches drain
411
415
  // up to 'next'; a Sleep-like tool grants a 'later' flush.
412
416
  let _toolBatchJustCompleted = false;
417
+ let _lastToolBatchEndedAt = 0;
413
418
  let _lastToolBatchHadSleep = false;
414
419
  const isSleepLikeToolCall = (call) => {
415
420
  const name = String(call?.name || call?.toolName || call?.function?.name || '').toLowerCase();
@@ -457,7 +462,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
457
462
  // non-empty final so callers never see an empty response.
458
463
  if (response && !String(response.content || '').trim()) {
459
464
  response.content = sessionAgent === 'explorer'
460
- ? 'EXPLORATION_FAILED'
465
+ ? 'EXPLORATION_FAILED [iteration-cap] — explorer hit its tool-turn cap with zero anchors; retry once with changed tokens or a narrower root'
461
466
  : '[iteration cap reached before final text]';
462
467
  if (Array.isArray(response.toolCalls)) response.toolCalls = [];
463
468
  }
@@ -632,6 +637,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
632
637
  return result;
633
638
  };
634
639
  const sendStartedAt = Date.now();
640
+ const preSendMs = sendStartedAt - _iterT0;
641
+ const toolResumeMs = _lastToolBatchEndedAt
642
+ ? sendStartedAt - _lastToolBatchEndedAt
643
+ : null;
635
644
  try { opts.onProviderSendStarted?.(); } catch {}
636
645
  const _sendResult = await runWithProviderRequestToolsScope(
637
646
  requestToolScope,
@@ -659,6 +668,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
659
668
  opts.onToolCall = undefined;
660
669
  delete opts.cacheBreakIntent;
661
670
  contextOverflowRetryUsed = false;
671
+ // A completed send ends the outage this budget was covering; the next
672
+ // iteration is a fresh request and must get the full replay budget
673
+ // again (mirrors contextOverflowRetryUsed above).
674
+ _transportRetriesUsed = 0;
662
675
  // Capture opaque state for the next turn only when the provider
663
676
  // explicitly returned the field. Absence means "no update"; an own
664
677
  // property with null/undefined means "clear".
@@ -679,6 +692,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
679
692
  sessionId,
680
693
  iteration: iterations,
681
694
  sendMs: Date.now() - sendStartedAt,
695
+ preSendMs,
696
+ toolResumeMs,
682
697
  messageCount: Array.isArray(messages) ? messages.length : 0,
683
698
  bodyBytesEst: _traceVerbose
684
699
  ? estimateProviderPayloadBytes(messages, model, sendTools)
@@ -1050,15 +1065,24 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1050
1065
  }));
1051
1066
  // Settle the stop hook on the batch that actually executed.
1052
1067
  _toolFailureStopHook.endBatch(_callsToExecute);
1068
+ const _toolsEndedAt = Date.now();
1069
+ try {
1070
+ opts.onToolPhaseCompleted?.({
1071
+ iteration: nextIteration,
1072
+ calls: _callsToExecute.length,
1073
+ elapsedMs: _toolsEndedAt - _toolsT0,
1074
+ });
1075
+ } catch {}
1053
1076
  // Loop-phase timing (diagnostics): where non-model time goes per
1054
1077
  // iteration — presend (repair/compact/snapshot), send (provider
1055
1078
  // round-trip incl. streaming), tools (batch execution). Gated by the
1056
1079
  // same env as [turn-timing] so bench runs opt in via -AgentEnv.
1057
1080
  if (process.env.MIXDOG_TURN_TIMING === '1') {
1058
1081
  try {
1059
- process.stderr.write(`[loop-timing] iter=${nextIteration} presend=${sendStartedAt - _iterT0}ms send=${_sendEndedAt - sendStartedAt}ms tools=${Date.now() - _toolsT0}ms calls=${_callsToExecute.length}\n`);
1082
+ process.stderr.write(`[loop-timing] iter=${nextIteration} presend=${preSendMs}ms send=${_sendEndedAt - sendStartedAt}ms tools=${_toolsEndedAt - _toolsT0}ms calls=${_callsToExecute.length}\n`);
1060
1083
  } catch { /* diagnostics only */ }
1061
1084
  }
1085
+ _lastToolBatchEndedAt = _toolsEndedAt;
1062
1086
  _toolBatchJustCompleted = true;
1063
1087
  _continuationsSinceToolBatch = 0;
1064
1088
  _lastToolBatchHadSleep = _callsToExecute.some(isSleepLikeToolCall);
@@ -1,13 +1,11 @@
1
1
  // Eager tool-dispatch controller, extracted from agent-loop.mjs. Owns the
2
2
  // per-turn pending promise map, the intra-turn in-flight signature set, and
3
- // the mutation epoch. Read-only calls may start while the provider is still
4
- // streaming. Side-effect calls stream-start too until the first apply_patch
5
- // appears in the stream (they are definitively in segment 0); after that they
6
- // wait for their segment bounded by apply_patch barriers. Patches execute
7
- // serially as barriers, so a failed patch can skip every later side effect.
3
+ // the mutation epoch. Every valid call may start while the provider is still
4
+ // streaming, including apply_patch and shell in the same batch. Tool-local
5
+ // conflict guards own safety; results are collected later in call order.
8
6
  import { normalizeToolEnvelope } from './tool-envelope.mjs';
9
7
  import { isInvalidToolArgsMarker } from '../providers/openai-compat-stream.mjs';
10
- import { _intraTurnSig, _isMutationTool, _isOrderedGateSkippable, _isReadTool, _isScopedCacheableTool, _stripMcpPrefix } from './loop/tool-classify.mjs';
8
+ import { _intraTurnSig, _isReadTool, _isScopedCacheableTool, _stripMcpPrefix } from './loop/tool-classify.mjs';
11
9
  import { tryReadCached, tryScopedToolCached } from './read-dedup.mjs';
12
10
  import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
13
11
  import { executeTool } from './loop/tool-exec.mjs';
@@ -40,11 +38,6 @@ export function createEagerDispatcher({
40
38
  // resets at the turn boundary without leaking across getIterations().
41
39
  const _eagerInFlightSigs = new Map();
42
40
  const epoch = { mutation: 0 };
43
- // True once an apply_patch tool_use has been seen in THIS turn's
44
- // stream. Before that, every side-effect call streamed so far sits
45
- // before the first patch in call order (segment 0), so it may start
46
- // immediately — a patch that arrives later cannot gate an EARLIER call.
47
- let _streamSeenMutation = false;
48
41
  const startEagerTool = (call) => {
49
42
  if (!call?.id || pending.has(call.id) || !isParallelDispatchable(call.name)) return null;
50
43
  // Never eager-execute a call whose arguments failed to parse
@@ -162,21 +155,10 @@ export function createEagerDispatcher({
162
155
  return entry;
163
156
  };
164
157
  const startEagerRun = (calls, startIndex, dupSet) => {
165
- const _nextOrderedMutationIndex = calls.findIndex(
166
- (call, index) => index >= startIndex && _isMutationTool(call?.name),
167
- );
168
158
  for (let j = startIndex; j < calls.length; j += 1) {
169
159
  const call = calls[j];
170
- // Side effects may eager-start in the current segment but not
171
- // across the next apply_patch barrier. Known read-only work
172
- // may cross it and is protected by mutation-epoch re-execution.
173
160
  if (!call?.id || !isParallelDispatchable(call.name)) continue;
174
161
  if (dupSet && dupSet.has(call.id)) continue;
175
- if (
176
- _nextOrderedMutationIndex >= 0
177
- && j >= _nextOrderedMutationIndex
178
- && _isOrderedGateSkippable(call.name)
179
- ) continue;
180
162
  // A null return here is NOT a state barrier. It means a
181
163
  // non-barrier stub — intra-turn in-flight dup, repeat-failure /
182
164
  // cross-turn dedup, pre-dispatch-deny, invalid-args, or a cache
@@ -187,17 +169,6 @@ export function createEagerDispatcher({
187
169
  }
188
170
  };
189
171
  const onToolCall = (call) => {
190
- if (_isMutationTool(call?.name)) {
191
- _streamSeenMutation = true;
192
- return;
193
- }
194
- // Declared read-only calls always overlap streaming (epoch guard
195
- // re-executes them if a later patch lands). Side-effect calls may
196
- // stream-start only while NO apply_patch has streamed yet: their
197
- // call-order position is already fixed before the first barrier,
198
- // matching exactly what startEagerRun would do post-batch. Once a
199
- // patch has streamed, later side effects wait for their segment.
200
- if (!isEagerDispatchable(call?.name, tools) && _streamSeenMutation) return;
201
172
  startEagerTool(call);
202
173
  };
203
174
  return { pending, epoch, startEagerTool, startEagerRun, onToolCall };
@@ -71,6 +71,19 @@ export function isLoopbackHttpUrl(value) {
71
71
  */
72
72
  export function routeWebFetchCall(call) {
73
73
  if (call?.name !== 'web_fetch') return call;
74
+ // A JSON-stringified array ('["https://a","https://b"]') is a common
75
+ // provider serialisation artifact. Parse it back into real URLs so the
76
+ // fetch surface routes and validates them instead of rejecting the call.
77
+ const rawUrl = call?.arguments?.url;
78
+ if (typeof rawUrl === 'string' && /^\s*\[/.test(rawUrl)) {
79
+ try {
80
+ const parsed = JSON.parse(rawUrl);
81
+ if (Array.isArray(parsed) && parsed.length > 0
82
+ && parsed.every((item) => typeof item === 'string' && item.trim())) {
83
+ call.arguments.url = parsed.map((item) => item.trim());
84
+ }
85
+ } catch { /* leave the original value for the downstream validator */ }
86
+ }
74
87
  const urls = callUrls(call);
75
88
  if (!urls.length) return call;
76
89
  if (urls.every(isLoopbackHttpUrl)) call.name = 'local_fetch';
@@ -28,8 +28,8 @@ export function isOutputLimitStopReason(reason) {
28
28
  // NOT a completed assistant turn even when it holds final-looking text: the
29
29
  // provider explicitly stated it intends to keep sampling inside the SAME user
30
30
  // turn. Two structured shapes are honored:
31
- // - `endTurn === false` (Codex/Responses `end_turn=false`; see
32
- // codex-rs/core/src/session/turn.rs Completed{ end_turn } → needs_follow_up)
31
+ // - `endTurn === false` (Responses `end_turn=false` on the completed frame,
32
+ // which means the turn needs a follow-up)
33
33
  // - Anthropic `stop_reason: 'pause_turn'` (server-side pause; the turn is
34
34
  // resumed by replaying the transcript unchanged)
35
35
  // Structural only — no lexical/progress-text heuristic is consulted, and a
@@ -15,30 +15,6 @@ export function _isMutationTool(name) {
15
15
  const n = _stripMcpPrefix(name);
16
16
  return n === 'apply_patch';
17
17
  }
18
- // Side-effect-free read-only tools that may eager-start across an upcoming
19
- // apply_patch barrier and may keep running after an earlier patch fails.
20
- // Everything NOT in this set waits for its side-effect segment and is skipped
21
- // after a failed patch. Kept separate from _isMutationTool, which stays
22
- // apply_patch-only for epoch counting.
23
- const ORDERED_GATE_SAFE_READONLY_TOOLS = new Set([
24
- 'read',
25
- 'find',
26
- 'glob',
27
- 'list',
28
- 'grep',
29
- 'code_graph',
30
- 'explore',
31
- 'search',
32
- 'web_fetch',
33
- ]);
34
- // True when a LATER call in a batch must be skipped because an earlier ordered
35
- // mutation (apply_patch) already failed. Conservative: only the known
36
- // side-effect-free read-only tools above keep running; shell, write tools, and
37
- // unknown MCP tools are skipped so they cannot act on unchanged/partially
38
- // changed state.
39
- export function _isOrderedGateSkippable(name) {
40
- return !ORDERED_GATE_SAFE_READONLY_TOOLS.has(_stripMcpPrefix(name));
41
- }
42
18
  const SCOPED_CACHEABLE_TOOLS = new Set([
43
19
  'code_graph',
44
20
  'grep',
@@ -227,36 +227,10 @@ export async function executeTool(name, args, cwd, callerSessionId, sessionRef,
227
227
  }
228
228
  if (name === 'apply_patch') {
229
229
  const patchArgs = typeof args === 'string' ? { patch: args } : { ...(args || {}) };
230
- // post_shell: optional verification command executed through the normal
231
- // one-shot shell path ONLY after the patch applies cleanly; a failed
232
- // patch skips it. Patch text and shell output return as ONE tool
233
- // result, so patch+verify costs a single call. Runtime-only knob:
234
- // stripped before executePatchTool sees the args.
235
- const postShell = typeof patchArgs.post_shell === 'string' && patchArgs.post_shell.trim()
236
- ? patchArgs.post_shell.trim()
237
- : null;
238
- delete patchArgs.post_shell;
239
- const patchResult = await executePatchTool(name, patchArgs, cwd, {
230
+ return executePatchTool(name, patchArgs, cwd, {
240
231
  sessionId: callerSessionId,
241
232
  toolCallId: executeOpts.toolCallId || null,
242
233
  });
243
- if (!postShell) return patchResult;
244
- const patchNorm = normalizeToolEnvelope(patchResult);
245
- const patchText = typeof patchNorm.result === 'string' ? patchNorm.result : String(patchNorm.result ?? '');
246
- // Text-based failure detection only: legacy string returns normalize
247
- // to explicitSuccess:false even on success, so that flag is unusable here.
248
- const patchFailed = /^Error[\s:[]/.test(patchText.trimStart());
249
- if (patchFailed) {
250
- return `${patchText}\n--- post_shell skipped: patch failed ---`;
251
- }
252
- const shellRes = await executeBuiltinTool('shell', { command: postShell }, cwd, completionToolOpts);
253
- const shellNorm = normalizeToolEnvelope(shellRes);
254
- const shellText = typeof shellNorm.result === 'string' ? shellNorm.result : String(shellNorm.result ?? '');
255
- const shellFailed = /^Error[\s:[]/.test(shellText.trimStart());
256
- const header = shellFailed
257
- ? '--- post_shell FAILED (patch is applied; fix and re-verify) ---'
258
- : '--- post_shell ---';
259
- return `${patchText}\n\n${header}\n${shellText}`;
260
234
  }
261
235
  if (isBuiltinTool(name)) {
262
236
  // clientHostPid threaded for the same per-terminal job-scope reason as
@@ -12,7 +12,6 @@ import {
12
12
  isSkillDisabled,
13
13
  } from '../../context/collect.mjs';
14
14
  import { isAgentOwner } from '../../agent-owner.mjs';
15
- import { _isMutationTool } from './tool-classify.mjs';
16
15
 
17
16
  // Eager-dispatch: tools with readOnlyHint:true in their declaration are safe
18
17
  // to execute during SSE parsing so tool work overlaps with the rest of the
@@ -41,16 +40,13 @@ export function isEagerDispatchable(name, tools) {
41
40
  return set.has(name);
42
41
  }
43
42
 
44
- // Full-parallel dispatch policy: EVERY tool call in an assistant turn starts
45
- // executing immediately — ordering across dependent work is the MODEL's job
46
- // (split into separate turns). The only exception is the ordered mutation
47
- // (apply_patch), which stays in the serial batch body so multi-patch turns
48
- // keep deterministic order, the all-or-nothing failure gate, and cache
49
- // invalidation sequencing. isEagerDispatchable above remains the READ-ONLY
50
- // classifier used for dedup eligibility, steering, and post-mutation result
51
- // invalidation.
43
+ // Full-parallel dispatch policy: EVERY valid tool call in an assistant turn
44
+ // starts immediately. Mutations rely on their own conflict scope (apply_patch
45
+ // uses per-path locks), so disjoint writes never wait behind a global barrier.
46
+ // isEagerDispatchable above remains the READ-ONLY classifier used for dedup
47
+ // eligibility, steering, and post-mutation result invalidation.
52
48
  export function isParallelDispatchable(name) {
53
- return typeof name === 'string' && name.length > 0 && !_isMutationTool(name);
49
+ return typeof name === 'string' && name.length > 0;
54
50
  }
55
51
 
56
52
  // Read-only is necessary but not sufficient for result deduplication. Loader
@@ -645,10 +645,15 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
645
645
  onProviderSendStarted: () => {
646
646
  _turnInterruption.markProviderSendStarted();
647
647
  _scheduleTurnCheckpoint(true);
648
+ try { askOpts?.onProviderSendStarted?.(); } catch {}
648
649
  },
649
650
  onToolPhaseStarted: () => {
650
651
  _turnInterruption.markToolPhaseStarted();
651
652
  _scheduleTurnCheckpoint(true);
653
+ try { askOpts?.onToolPhaseStarted?.(); } catch {}
654
+ },
655
+ onToolPhaseCompleted: (detail) => {
656
+ try { askOpts?.onToolPhaseCompleted?.(detail); } catch {}
652
657
  },
653
658
  onUsageDelta: (d) => {
654
659
  persistIterationMetrics(d).catch(() => {});