mixdog 0.9.94 → 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 (259) 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 +109 -0
  5. package/package.json +26 -13
  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/scripts/lib/isolated-root-cleanup.mjs +19 -0
  10. package/scripts/run-suite.mjs +100 -0
  11. package/src/defaults/mixdog-config.template.json +5 -8
  12. package/src/lib/rules-builder.cjs +4 -3
  13. package/src/output-styles/detailed.md +13 -15
  14. package/src/output-styles/extreme-minimal.md +7 -11
  15. package/src/output-styles/minimal.md +4 -7
  16. package/src/output-styles/simple.md +11 -13
  17. package/src/rules/agent/30-explorer.md +7 -4
  18. package/src/rules/lead/01-general.md +1 -2
  19. package/src/rules/lead/lead-brief.md +4 -5
  20. package/src/rules/lead/lead-tool.md +3 -2
  21. package/src/rules/shared/01-tool.md +20 -35
  22. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  23. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  24. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  26. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +13 -2
  27. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -0
  28. package/src/runtime/agent/orchestrator/agent-trace.mjs +29 -0
  29. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  30. package/src/runtime/agent/orchestrator/mcp/client.mjs +29 -14
  31. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  33. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +97 -7
  34. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +11 -4
  35. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +2 -3
  36. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  38. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +4 -6
  39. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  40. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  41. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  42. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +1 -1
  43. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +8 -8
  44. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  45. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  46. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +29 -16
  47. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -2
  48. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  49. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +14 -16
  50. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  51. package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +5 -4
  52. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +13 -14
  53. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -3
  54. package/src/runtime/agent/orchestrator/providers/registry.mjs +51 -4
  55. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +82 -22
  56. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  57. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  58. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +40 -36
  59. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +7 -0
  60. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +10 -3
  61. package/src/runtime/agent/orchestrator/session/context-utils.mjs +10 -11
  62. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +5 -33
  63. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +3 -3
  64. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +0 -14
  65. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  66. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -8
  67. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  68. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  69. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
  70. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
  71. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -4
  72. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
  73. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +1 -1
  74. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +168 -83
  75. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  76. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +3 -0
  77. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  78. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -1
  79. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +34 -18
  80. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  81. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  82. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +89 -11
  83. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
  84. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +6 -39
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -6
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -4
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +56 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +124 -19
  90. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  91. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  92. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  93. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +7 -16
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +14 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +4 -4
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +2 -2
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
  100. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +2 -1
  101. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +3 -3
  102. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
  103. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +24 -10
  104. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  105. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +20 -43
  106. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +76 -3
  107. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +13 -1
  108. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +10 -5
  109. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +38 -13
  110. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  111. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +98 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -2
  113. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +8 -6
  114. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +138 -25
  115. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +47 -2
  116. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
  117. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +3 -1
  118. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
  119. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +9 -2
  120. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  121. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +2 -2
  122. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -2
  123. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +57 -2
  124. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +177 -16
  125. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +21 -1
  126. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +217 -25
  127. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +10 -10
  128. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +14 -15
  129. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +60 -8
  130. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  131. package/src/runtime/channels/backends/discord.mjs +5 -14
  132. package/src/runtime/channels/backends/telegram.mjs +0 -5
  133. package/src/runtime/channels/lib/config.mjs +2 -2
  134. package/src/runtime/channels/lib/inbound-handler.mjs +1 -2
  135. package/src/runtime/channels/lib/output-forwarder.mjs +24 -5
  136. package/src/runtime/channels/lib/scheduler.mjs +3 -3
  137. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  138. package/src/runtime/media/renditions.mjs +21 -2
  139. package/src/runtime/memory/index.mjs +5 -38
  140. package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
  141. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  142. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  143. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
  144. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  145. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  146. package/src/runtime/memory/lib/http-router.mjs +8 -0
  147. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  148. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  149. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  150. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  151. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  152. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  153. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  154. package/src/runtime/memory/tool-defs.mjs +7 -11
  155. package/src/runtime/search/tool-defs.mjs +2 -2
  156. package/src/runtime/shared/agent-route-config.mjs +119 -0
  157. package/src/runtime/shared/atomic-file.mjs +133 -4
  158. package/src/runtime/shared/background-tasks.mjs +6 -3
  159. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  160. package/src/runtime/shared/child-guardian.mjs +204 -108
  161. package/src/runtime/shared/child-spawn-gate.mjs +60 -31
  162. package/src/runtime/shared/config.mjs +130 -44
  163. package/src/runtime/shared/resource-admission.mjs +146 -58
  164. package/src/runtime/shared/stream-progress.mjs +6 -0
  165. package/src/runtime/shared/tool-status.mjs +10 -1
  166. package/src/runtime/shared/tool-surface.mjs +2 -7
  167. package/src/runtime/shared/turn-snapshot.mjs +411 -21
  168. package/src/runtime/shared/turn-worktree-snapshot.mjs +552 -0
  169. package/src/session-runtime/config-helpers.mjs +39 -37
  170. package/src/session-runtime/context-status.mjs +9 -3
  171. package/src/session-runtime/lifecycle-api.mjs +35 -5
  172. package/src/session-runtime/mcp-glue.mjs +11 -6
  173. package/src/session-runtime/model-route-api.mjs +7 -5
  174. package/src/session-runtime/provider-auth-api.mjs +0 -7
  175. package/src/session-runtime/provider-models.mjs +94 -43
  176. package/src/session-runtime/runtime-core.mjs +57 -19
  177. package/src/session-runtime/runtime-tunables.mjs +4 -0
  178. package/src/session-runtime/self-update.mjs +33 -4
  179. package/src/session-runtime/session-lifecycle.mjs +11 -2
  180. package/src/session-runtime/session-text.mjs +2 -1
  181. package/src/session-runtime/session-turn-api.mjs +139 -23
  182. package/src/session-runtime/settings-api.mjs +5 -17
  183. package/src/session-runtime/tool-defs.mjs +3 -3
  184. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  185. package/src/session-runtime/workflow.mjs +25 -28
  186. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  187. package/src/standalone/agent-tool/helpers.mjs +2 -2
  188. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  189. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  190. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  191. package/src/standalone/agent-tool.mjs +4 -4
  192. package/src/standalone/backend-daemon.mjs +631 -0
  193. package/src/standalone/channel-admin.mjs +12 -12
  194. package/src/standalone/channel-daemon-client.mjs +17 -1
  195. package/src/standalone/channel-daemon-transport.mjs +231 -9
  196. package/src/standalone/channel-worker.mjs +3 -2
  197. package/src/standalone/engine-daemon-client.mjs +1069 -0
  198. package/src/standalone/engine-daemon-protocol.mjs +32 -0
  199. package/src/standalone/engine-daemon-service.mjs +1108 -0
  200. package/src/standalone/engine-daemon-transport.mjs +794 -0
  201. package/src/standalone/explore-tool.mjs +187 -70
  202. package/src/standalone/fair-call-scheduler.mjs +264 -0
  203. package/src/standalone/session-protocol.mjs +188 -0
  204. package/src/tui/App.jsx +92 -64
  205. package/src/tui/app/app-format.mjs +4 -2
  206. package/src/tui/app/app-view.jsx +10 -2
  207. package/src/tui/app/channel-pickers.mjs +7 -6
  208. package/src/tui/app/core-memory-picker.mjs +20 -20
  209. package/src/tui/app/doctor.mjs +5 -11
  210. package/src/tui/app/extension-pickers.mjs +20 -18
  211. package/src/tui/app/maintenance-pickers.mjs +27 -27
  212. package/src/tui/app/message-selector.mjs +103 -0
  213. package/src/tui/app/onboarding-steps.mjs +24 -20
  214. package/src/tui/app/project-picker.mjs +80 -56
  215. package/src/tui/app/prompt-submit.mjs +59 -43
  216. package/src/tui/app/resume-picker.mjs +2 -2
  217. package/src/tui/app/route-pickers.mjs +16 -9
  218. package/src/tui/app/settings-picker.mjs +60 -65
  219. package/src/tui/app/slash-dispatch.mjs +32 -28
  220. package/src/tui/app/transcript-window.mjs +57 -0
  221. package/src/tui/app/usage-context-panels.mjs +22 -7
  222. package/src/tui/app/use-mouse-input.mjs +25 -3
  223. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  224. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  225. package/src/tui/app/use-prompt-hint.mjs +3 -2
  226. package/src/tui/app/use-prompt-queue-history.mjs +39 -17
  227. package/src/tui/app/use-transcript-scroll.mjs +39 -10
  228. package/src/tui/app/use-transcript-window.mjs +62 -14
  229. package/src/tui/app/use-welcome-prompt-hint.mjs +2 -2
  230. package/src/tui/components/PromptInput.jsx +81 -38
  231. package/src/tui/components/Spinner.jsx +89 -95
  232. package/src/tui/components/TextEntryPanel.jsx +14 -0
  233. package/src/tui/components/ToolExecution.jsx +2 -2
  234. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  235. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  236. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  237. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  238. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  239. package/src/tui/dist/index.mjs +1694 -10633
  240. package/src/tui/engine/agent-job-feed.mjs +2 -2
  241. package/src/tui/engine/live-share.mjs +110 -3
  242. package/src/tui/engine/session-api-ext.mjs +24 -5
  243. package/src/tui/engine/session-api.mjs +165 -55
  244. package/src/tui/engine/session-flow.mjs +50 -5
  245. package/src/tui/engine/tool-card-results.mjs +10 -2
  246. package/src/tui/engine/tool-result-text.mjs +10 -0
  247. package/src/tui/engine/turn.mjs +11 -64
  248. package/src/tui/engine-local-session.mjs +1124 -0
  249. package/src/tui/engine.mjs +16 -1065
  250. package/src/tui/index.jsx +41 -4
  251. package/src/tui/markdown/stream-fence.mjs +1 -1
  252. package/src/tui/spinner-meta.mjs +80 -0
  253. package/src/tui/spinner-verbs.mjs +83 -0
  254. package/src/ui/statusline-segments.mjs +43 -10
  255. package/src/ui/statusline.mjs +10 -1
  256. package/scripts/tmp-cdp-errors.mjs +0 -41
  257. package/scripts/tmp-cdp-inspect.mjs +0 -41
  258. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +0 -176
  259. package/src/standalone/channel-daemon.mjs +0 -226
@@ -104,6 +104,15 @@ export function classifyError(err) {
104
104
  if (err.truncatedStream === true || err.code === 'TRUNCATED_STREAM') return 'transient'
105
105
 
106
106
  if (TRANSIENT_STATUSES.has(status) || (status >= 500 && status < 600)) return 'transient'
107
+ // A stream that closed WITHOUT its terminal frame is a transport symptom,
108
+ // not a model verdict: the socket carries no HTTP status and no Node errno,
109
+ // so without this it classified as 'unknown' and no loop-level replay was
110
+ // ever attempted (observed live: pooled WS retired by the server between
111
+ // turns → close 1000 before response.created → the whole turn failed).
112
+ // Reference behavior retries the same disconnect (codex `CodexErr::Stream`
113
+ // is_retryable, cc falls back/retries the stream); the exposure deny above
114
+ // still fails closed for anything already relayed or dispatched.
115
+ if (isNonTerminalStreamClose(err)) return 'transient'
107
116
 
108
117
  // Socket-level codes (Node errno) — DNS / reset / refused / timeout are all
109
118
  // transient: we can retry the same request and may succeed.
@@ -124,6 +133,50 @@ const TRANSIENT_ERROR_CODES = new Set([
124
133
  'EPROVIDERTIMEOUT', 'EGEMINITIMEOUT', 'ESTREAMSTALL', 'EWSACQUIRETIMEOUT',
125
134
  ])
126
135
 
136
+ // WebSocket close codes that can end a Responses stream BEFORE its terminal
137
+ // frame. 1000/1001/1005 are "nominal" closes the server sends when it retires
138
+ // a pooled socket; 1006/1011/1012 are abnormal/overload closes. None of them
139
+ // is a completed turn, and all are safe to re-issue when nothing was exposed.
140
+ // 4000 is our own local stall close and keeps the stall classification path.
141
+ const NON_TERMINAL_STREAM_CLOSE_CODES = new Set([1000, 1001, 1005, 1006, 1011, 1012])
142
+
143
+ /**
144
+ * True when the error describes a stream socket that closed without the
145
+ * provider's terminal frame (response.completed / message_stop). Replay
146
+ * PERMISSION is still owned by the stream-outcome contract; this predicate
147
+ * only answers "was this a transport-level disconnect".
148
+ */
149
+ export function isNonTerminalStreamClose(err) {
150
+ if (!err || typeof err !== 'object') return false
151
+ const code = Number(err.wsCloseCode ?? err.streamCloseCode ?? 0) || 0
152
+ if (!NON_TERMINAL_STREAM_CLOSE_CODES.has(code)) return false
153
+ return readStreamOutcome(err).terminalObserved !== true
154
+ }
155
+
156
+ /**
157
+ * Should this failed stream be re-issued as a NON-STREAMING request?
158
+ *
159
+ * cc's last safety net: when a stream dies it repeats the same request with
160
+ * `stream:false` instead of failing the turn (claude.ts, gated off only when
161
+ * streaming tool execution could double-run a tool). MixDog dispatches tools
162
+ * eagerly, so this stays deliberately narrower than cc: only a stream that
163
+ * exposed NOTHING qualifies. An exposed stream is already covered by the
164
+ * loop-level retraction replay (send-with-recovery), which asks the owner to
165
+ * withdraw the rendered characters first.
166
+ */
167
+ export function canFallbackNonStreaming(err, { signal } = {}) {
168
+ if (!err || signal?.aborted) return false
169
+ const outcome = readStreamOutcome(err)
170
+ // A completed turn needs no fallback; a user cancel must never be re-issued.
171
+ if (outcome.terminalObserved || outcome.userAbort) return false
172
+ // Exposure/dispatch fails closed — re-running would duplicate output or a
173
+ // side effect.
174
+ if (outcome.replaySafe !== true) return false
175
+ return classifyError(err) === 'transient'
176
+ || outcome.stallObserved === true
177
+ || outcome.truncatedStream === true
178
+ }
179
+
127
180
  function boundedCauseChain(err) {
128
181
  const chain = []
129
182
  const seen = new Set()
@@ -252,7 +305,7 @@ function isRetryable(err) {
252
305
  return classifyError(err) === 'transient'
253
306
  }
254
307
 
255
- /** Claude Code compatible Anthropic request budget: 10 retries (11 attempts).
308
+ /** Anthropic request budget: 10 retries (11 attempts).
256
309
  * CLAUDE_CODE_MAX_RETRIES is intentionally read per request for reload/tests.
257
310
  * The upper bound prevents an accidental unbounded retry loop. */
258
311
  export function anthropicMaxAttempts() {
@@ -262,16 +315,16 @@ export function anthropicMaxAttempts() {
262
315
  return retries + 1
263
316
  }
264
317
 
265
- // Claude Code request defaults (withRetry.ts): 500ms exponential backoff,
318
+ // Anthropic retry defaults: 500ms exponential backoff,
266
319
  // capped at 32s, with positive-only jitter up to 25% of the base delay.
267
- // The leading duplicate accounts for withRetry's sleep-before-attempt index:
320
+ // The leading duplicate accounts for the sleep-before-attempt index:
268
321
  // retry attempt 2 reads index 1.
269
322
  export const ANTHROPIC_RETRY_BACKOFF_MS = Object.freeze([
270
323
  500, 500, 1000, 2000, 4000, 8000, 16000, 32000, 32000, 32000, 32000,
271
324
  ])
272
325
  export const ANTHROPIC_RETRY_JITTER_RATIO = 0.25
273
326
 
274
- // Claude Code's Anthropic SDK client defaults API_TIMEOUT_MS to ten minutes.
327
+ // The Anthropic SDK client defaults API_TIMEOUT_MS to ten minutes.
275
328
  // Read per request, like CLAUDE_CODE_MAX_RETRIES, so env reload/tests work.
276
329
  export function anthropicRequestTimeoutMs() {
277
330
  const parsed = Number.parseInt(process.env.API_TIMEOUT_MS || '', 10)
@@ -318,10 +371,10 @@ export function jitterDelayMs(ms, ratio = PROVIDER_RETRY_JITTER_RATIO, mode = 's
318
371
  // Mid-stream 'stream_stalled' recoveries retry in place, which is right for a
319
372
  // one-off blip but lets a chronically dying stream burn a whole task budget
320
373
  // slowly (observed live: one send stretched 149s→298s→556s across stall
321
- // retries before the agent deadline killed the task). Reference stacks bound
322
- // this instead of retrying forever: Claude Code caps each request at ~300s
323
- // wall clock (API_TIMEOUT_MS) and Codex kills a stream after one 300s silent
324
- // gap (stream_idle_timeout). This guard is the equivalent for our in-place
374
+ // retries before the agent deadline killed the task). A stalling stream is
375
+ // bounded instead of retried forever: a request is capped at ~300s wall
376
+ // clock (API_TIMEOUT_MS) and a stream dies after one 300s silent gap
377
+ // (stream idle timeout). This guard is the equivalent for our in-place
325
378
  // recovery: the clock starts at the FIRST stall of a send, and stall-classified
326
379
  // retries are allowed only inside that window; past it the stall error
327
380
  // surfaces so loop-level transport retry issues a FRESH request. Healthy
@@ -357,7 +410,7 @@ export function createStallRetryBudget(budgetMs = STREAM_STALL_RETRY_BUDGET_MS,
357
410
  // branched on a hardcoded provider name.
358
411
 
359
412
  // F) Retry-budget profiles as DATA. The numbers live ONLY here now.
360
- // ws.*Retries (5) — one Codex Responses stream retry budget.
413
+ // ws.*Retries (5) — one Responses stream retry budget.
361
414
  // sse.defaultRetries (3) — anthropic single-shot SSE mid-stream budget.
362
415
  export const MIDSTREAM_RETRY_POLICY = {
363
416
  ws: { transientCloseRetries: 5, defaultRetries: 5, backoff: [250, 1000, 2000, 4000, 5000] },
@@ -435,11 +488,13 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
435
488
  }
436
489
  if (!state.sawResponseCreated) {
437
490
  const closeCode = Number(err?.wsCloseCode || state.wsCloseCode || 0)
438
- // An abnormal close before response.created has not produced any response
439
- // bytes to the caller. It is therefore safe to reconnect and replay under
440
- // the normal ws_1006 bounded retry policy (text/tool emission was denied
441
- // above before reaching this gate).
442
- if (closeCode !== 1006 && closeCode !== 1011 && closeCode !== 1012) return null
491
+ // A close before response.created has not produced any response bytes to
492
+ // the caller, so it is safe to reconnect and replay under the bounded
493
+ // retry policy (text/tool emission was denied above before this gate).
494
+ // NOMINAL closes count too: a pooled socket the server retires between
495
+ // turns closes with 1000/1001/1005 and must reconnect fresh — treating
496
+ // that as terminal killed the turn instead of reissuing it.
497
+ if (!NON_TERMINAL_STREAM_CLOSE_CODES.has(closeCode)) return null
443
498
  }
444
499
  if (state.userAbort) return null
445
500
 
@@ -465,7 +520,11 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
465
520
  if (closeCode === 1012) return _allowMidstream('ws_1012', attemptIndex, policy)
466
521
  if (closeCode >= 4000 && closeCode < 5000 && closeCode !== 4000) return null
467
522
  if (closeCode === 4000) return _allowMidstream('ws_4000', attemptIndex, policy)
468
- if (closeCode === 1000 && state.sawResponseCreated && !state.sawCompleted) return _allowMidstream('ws_1000', attemptIndex, policy)
523
+ // Nominal close without the terminal frame — before OR after
524
+ // response.created. Only a completed turn is terminal here.
525
+ if ((closeCode === 1000 || closeCode === 1001 || closeCode === 1005) && !state.sawCompleted) {
526
+ return _allowMidstream(`ws_${closeCode}`, attemptIndex, policy)
527
+ }
469
528
 
470
529
  const failed = err?.responseFailed || state.responseFailedPayload
471
530
  if (failed) {
@@ -490,7 +549,7 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
490
549
  }
491
550
 
492
551
  // Explicit `response.failed` error codes/types that describe a transport-level
493
- // interruption (Codex retries these); every other code is terminal.
552
+ // interruption (these are retryable); every other code is terminal.
494
553
  const RESPONSE_FAILED_CODE_CLASSIFIERS = new Map([
495
554
  ['stream_disconnected', 'response_failed_disconnected'],
496
555
  ['network_error', 'response_failed_network'],
@@ -536,7 +595,8 @@ function _classifyMidstreamSse(err, state, attemptIndex, policy) {
536
595
  const TRANSPORT_FALLBACK_CLASSIFIERS = new Set([
537
596
  'timeout', 'reset', 'dns', 'refused', 'network', 'acquire_timeout', 'http_5xx',
538
597
  'first_byte_timeout',
539
- 'ws_1006', 'ws_1011', 'ws_1012', 'ws_1000', 'ws_4000', 'agent_stall', 'stream_stalled',
598
+ 'ws_1006', 'ws_1011', 'ws_1012', 'ws_1000', 'ws_1001', 'ws_1005', 'ws_4000',
599
+ 'agent_stall', 'stream_stalled',
540
600
  'response_failed_disconnected', 'response_failed_network', 'response_failed_auth_expired',
541
601
  'ws_send_failed',
542
602
  ])
@@ -550,7 +610,7 @@ export function shouldFallbackTransport(err, { signal, enabled = true } = {}) {
550
610
  if (signal?.aborted) return false
551
611
  // Transport fallback re-issues the request on another transport: it is a
552
612
  // replay, so the exposure deny applies. Eligibility itself stays typed
553
- // (status / errno / classifier), matching Codex's WS→HTTPS switch.
613
+ // (status / errno / classifier) for the WS→HTTPS switch.
554
614
  if (readStreamOutcome(err).replaySafe !== true) return false
555
615
  const status = Number(err?.httpStatus || err?.status || 0)
556
616
  // 401 is auth recovery, never transport fallback. 426 is the explicit
@@ -660,7 +720,7 @@ function _sleepChunkWithAbort(ms, signal, sleepFn, abortMessage) {
660
720
  // E) Handshake classifier (moved here from openai-oauth-ws). Default-deny:
661
721
  // anything not recognized as transient returns null. HTTP 401 is reserved
662
722
  // for auth recovery and 426 for immediate HTTPS fallback. The OpenAI OAuth
663
- // caller opts out of 429 retries (Codex retry_429:false); all other callers
723
+ // caller opts out of 429 retries (retry429:false); all other callers
664
724
  // retain the historical retryable UnexpectedStatus policy.
665
725
  export function classifyHandshakeError(err, { retry429 = true } = {}) {
666
726
  if (!err) return null
@@ -764,8 +824,8 @@ export async function withRetry(fn, opts = {}) {
764
824
  // an eligible failure is actually retried remains the typed question
765
825
  // resolved by classifyError()/status below.
766
826
  if (readStreamOutcome(caught).replaySafe !== true) throw caught
767
- // Claude Code treats x-should-retry:false as an explicit server veto
768
- // (except an internal-only 5xx override that Mixdog does not have).
827
+ // x-should-retry:false is an explicit server veto on retrying and is
828
+ // honored as-is.
769
829
  // Keep this ahead of status defaults, including the request-local 429 path.
770
830
  const shouldRetryHeader = _headerValue(
771
831
  caught?.headers || caught?.response?.headers || caught?.data?.responseHeaders,
@@ -786,7 +846,7 @@ export async function withRetry(fn, opts = {}) {
786
846
  }
787
847
  continue
788
848
  }
789
- // Claude Code's optional model fallback fires on the third 529. This
849
+ // The optional model fallback fires on the third 529. This
790
850
  // remains opt-in: providers pass fallbackModel only when the caller set
791
851
  // one. The hard progress veto above must run first so fallback can never
792
852
  // replay partial thinking/tool output.
@@ -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
+ });