mixdog 0.9.87 → 0.9.89

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 (167) hide show
  1. package/package.json +13 -7
  2. package/scripts/tool-failures.mjs +60 -24
  3. package/src/agents/heavy-worker/AGENT.md +3 -4
  4. package/src/agents/worker/AGENT.md +1 -2
  5. package/src/cli.mjs +8 -0
  6. package/src/defaults/cycle3-review-prompt.md +4 -4
  7. package/src/rules/agent/00-core.md +3 -0
  8. package/src/rules/agent/42-cycle3-agent.md +5 -6
  9. package/src/rules/lead/01-general.md +2 -0
  10. package/src/rules/shared/01-tool.md +29 -35
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
  12. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
  13. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
  16. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
  17. package/src/runtime/agent/orchestrator/config.mjs +29 -11
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
  21. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
  25. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
  26. package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
  28. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
  29. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
  30. package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
  35. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
  41. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
  42. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
  43. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
  44. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
  46. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
  54. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
  55. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
  56. package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
  57. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
  58. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
  59. package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
  60. package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
  61. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
  62. package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
  63. package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
  64. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
  65. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
  66. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
  67. package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
  68. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
  69. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
  70. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
  71. package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
  72. package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
  73. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
  74. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
  77. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
  83. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
  85. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
  86. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
  87. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
  88. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
  89. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
  90. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
  91. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
  92. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
  93. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  94. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
  95. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
  96. package/src/runtime/media/lanes.mjs +2 -2
  97. package/src/runtime/media/renditions.mjs +101 -17
  98. package/src/runtime/media/renditions.test.mjs +54 -0
  99. package/src/runtime/media/store.mjs +111 -43
  100. package/src/runtime/media/store.test.mjs +53 -11
  101. package/src/runtime/memory/index.mjs +26 -50
  102. package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
  103. package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
  104. package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
  105. package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
  106. package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
  107. package/src/runtime/memory/lib/http-router.mjs +4 -0
  108. package/src/runtime/memory/lib/ko-morph.mjs +49 -6
  109. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
  110. package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
  111. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
  112. package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
  113. package/src/runtime/memory/lib/query-handlers.mjs +26 -28
  114. package/src/runtime/memory/tool-defs.mjs +3 -3
  115. package/src/runtime/shared/atomic-file.mjs +5 -1
  116. package/src/runtime/shared/child-guardian.mjs +73 -2
  117. package/src/runtime/shared/provider-api-key.mjs +22 -5
  118. package/src/runtime/shared/tool-execution-contract.mjs +53 -0
  119. package/src/session-runtime/config-helpers.mjs +6 -5
  120. package/src/session-runtime/lifecycle-api.mjs +16 -4
  121. package/src/session-runtime/media-api.mjs +31 -16
  122. package/src/session-runtime/prewarm.mjs +2 -24
  123. package/src/session-runtime/runtime-core.mjs +33 -15
  124. package/src/session-runtime/runtime-tunables.mjs +0 -3
  125. package/src/session-runtime/session-lifecycle.mjs +0 -6
  126. package/src/session-runtime/session-title.mjs +228 -0
  127. package/src/session-runtime/session-turn-api.mjs +11 -7
  128. package/src/session-runtime/workflow-agents-api.mjs +16 -0
  129. package/src/session-runtime/workflow.mjs +7 -5
  130. package/src/standalone/agent-tool/job-views.mjs +90 -36
  131. package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
  132. package/src/standalone/agent-tool/worker-index.mjs +8 -0
  133. package/src/standalone/agent-tool/worker-rows.mjs +2 -1
  134. package/src/standalone/agent-tool.mjs +17 -17
  135. package/src/standalone/agent-watchdog-registry.mjs +19 -2
  136. package/src/standalone/channel-daemon.mjs +8 -0
  137. package/src/standalone/explore-tool.mjs +1 -1
  138. package/src/standalone/memory-runtime-proxy.mjs +4 -7
  139. package/src/standalone/projects.mjs +4 -1
  140. package/src/standalone/usage-dashboard.mjs +25 -3
  141. package/src/tui/App.jsx +5 -3
  142. package/src/tui/app/resume-picker.mjs +7 -2
  143. package/src/tui/app/route-pickers.mjs +11 -117
  144. package/src/tui/app/slash-dispatch.mjs +13 -3
  145. package/src/tui/app/transcript-row-estimate.mjs +1 -1
  146. package/src/tui/app/transcript-window.mjs +17 -93
  147. package/src/tui/app/use-transcript-scroll.mjs +32 -2
  148. package/src/tui/app/use-transcript-window.mjs +64 -114
  149. package/src/tui/components/Markdown.jsx +19 -2
  150. package/src/tui/components/Message.jsx +8 -3
  151. package/src/tui/components/ToolExecution.jsx +7 -3
  152. package/src/tui/dist/index.mjs +243 -223
  153. package/src/tui/engine/live-share.mjs +15 -5
  154. package/src/tui/engine/render-timing.mjs +2 -2
  155. package/src/tui/engine/session-api-ext.mjs +74 -4
  156. package/src/tui/engine/session-flow.mjs +3 -1
  157. package/src/tui/engine.mjs +2 -2
  158. package/src/tui/hooks/useSharedTick.mjs +0 -2
  159. package/src/tui/index.jsx +5 -5
  160. package/src/ui/statusline-agents.mjs +44 -11
  161. package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
  162. package/src/workflows/default/WORKFLOW.md +3 -3
  163. package/src/workflows/solo/WORKFLOW.md +5 -3
  164. package/src/runtime/media/index.mjs +0 -18
  165. package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
  166. package/src/standalone/agent-shard/shard-child.mjs +0 -300
  167. package/src/standalone/agent-shard/shard-pool.mjs +0 -443
@@ -4,17 +4,17 @@
4
4
  * Single source of truth across every provider (openai-oauth-ws, openai-oauth,
5
5
  * anthropic-oauth, anthropic, gemini, openai-ws, openai-compat).
6
6
  *
7
- * Goal: when a provider returns a transient server-side condition we should
8
- * retry; when it returns a deterministic refusal (auth, permission, quota)
9
- * we should fail fast. Mid-stream WS events (server-supplied error / response
10
- * .failed messages) historically lost their HTTP status because the message
11
- * was wrapped without classification — that left "Our servers are currently
12
- * overloaded" indistinguishable from a permanent failure to the retry layer.
7
+ * Goal: when a provider returns a TYPED transient server-side condition we
8
+ * should retry; when it returns a deterministic refusal (auth, permission,
9
+ * quota) we should fail fast. Evidence is structural only — HTTP status,
10
+ * Node errno, SDK error type, WS close code, or a typed field on a wire event.
11
+ * Error MESSAGE TEXT is never parsed into a status/transience/auth verdict:
12
+ * an untyped failure stays 'unknown' and is surfaced, not retried.
13
13
  *
14
14
  * Usage:
15
- * import { classifyError, populateHttpStatusFromMessage } from './retry-classifier.mjs'
15
+ * import { classifyError, typedStatusFrom } from './retry-classifier.mjs'
16
16
  * const kind = classifyError(err) // 'auth' | 'permanent' | 'transient' | 'unknown'
17
- * populateHttpStatusFromMessage(err) // mutates err.httpStatus if message hints at one
17
+ * typedStatusFrom(event.response?.error, event) // structured status, or 0
18
18
  */
19
19
 
20
20
  import {
@@ -24,6 +24,9 @@ import {
24
24
  PROVIDER_RETRY_MAX_ATTEMPTS,
25
25
  createTimeoutSignal,
26
26
  } from '../stall-policy.mjs'
27
+ import { readStreamOutcome } from './lib/stream-outcome.mjs'
28
+
29
+ export { readStreamOutcome, stampStreamOutcome, isReplaySafe, isReplayUnsafe, canPromoteToSuccess, hasObservedOutput, hasDispatchedToolCalls, STREAM_TRANSPORTS } from './lib/stream-outcome.mjs'
27
30
 
28
31
  // HTTP statuses considered transient — safe to retry with backoff.
29
32
  // 408 — request timeout
@@ -39,47 +42,23 @@ const TRANSIENT_STATUSES = new Set([408, 409])
39
42
  const AUTH_STATUSES = new Set([401, 403])
40
43
  const PERMANENT_STATUSES = new Set([400, 404, 405, 410, 415, 422])
41
44
 
42
- // Server-message text patterns. Used when a WS / SSE event carries an error
43
- // payload but no explicit status code — we sniff the text and assign the most
44
- // likely HTTP equivalent so the retry layer can use the same rules.
45
- const MESSAGE_PATTERNS = [
46
- // OpenAI OAuth/API sometimes surfaces generic backend failures only as
47
- // message text plus a request ID, with no HTTP status on the WS event.
48
- { regex: /(?:an error occurred while processing your request|please include the request id)/i, status: 503 },
49
- // Overload / transient 5xx — server is asking us to back off. The `\b`
50
- // anchor is intentionally OMITTED on the trailing side of `overload` so
51
- // "overloaded" / "overloading" both match (inflected forms are common in
52
- // server error text).
53
- { regex: /(?:overload(?:ed|ing)?|temporarily unavailable|try again later|service unavailable|bad gateway|gateway timeout)/i, status: 503 },
54
- // Explicit 5xx mention.
55
- { regex: /\b(?:5\d\d|http 5\d\d)\b/i, status: 503 },
56
- // Rate limit / quota — same retry posture as 429 but treat as permanent
57
- // by classifyError because per-call retry won't change the answer (the
58
- // window must elapse). Providers that want time-bounded retry should
59
- // honor Retry-After on the original response, not loop here.
60
- { regex: /(?:rate[_ -]?limit|quota|too many requests|resource exhausted|insufficient_quota|quota_exceeded)/i, status: 429 },
61
- // Auth — never retryable from our side.
62
- { regex: /\b(?:unauthorized|unauthorised|authentication|not authenticated|token expired|access token|invalid api key)\b/i, status: 401 },
63
- { regex: /\b(?:forbidden|permission denied|policy violation)\b/i, status: 403 },
64
- ]
45
+ // Structured status fields a provider error / wire event may carry. A value is
46
+ // accepted only when it is a real numeric HTTP status; string codes
47
+ // ('server_error', 'forbidden', ...) and free text are ignored, so nothing is
48
+ // ever synthesized from a message.
49
+ const TYPED_STATUS_KEYS = ['httpStatus', 'http_status', 'status', 'statusCode', 'status_code', 'code']
65
50
 
66
51
  /**
67
- * Inspect `err.message` (or the explicit `msg` argument) and, if the text
68
- * matches one of the known transient/permanent patterns, set `err.httpStatus`
69
- * to the corresponding code. No-op when httpStatus is already set.
70
- *
71
- * Returns the resolved httpStatus (existing or newly assigned), or 0 when
72
- * nothing matched.
52
+ * Read the first TYPED HTTP status carried by any of `sources` (error object,
53
+ * wire event, event.response.error payload, ...). Returns 0 when none of them
54
+ * declares one. Never inspects message text.
73
55
  */
74
- export function populateHttpStatusFromMessage(err, msg = null) {
75
- if (!err || typeof err !== 'object') return 0
76
- if (Number(err.httpStatus) > 0) return Number(err.httpStatus)
77
- const text = String(msg ?? err.message ?? '')
78
- if (!text) return 0
79
- for (const { regex, status } of MESSAGE_PATTERNS) {
80
- if (regex.test(text)) {
81
- err.httpStatus = status
82
- return status
56
+ export function typedStatusFrom(...sources) {
57
+ for (const source of sources) {
58
+ if (!source || typeof source !== 'object') continue
59
+ for (const key of TYPED_STATUS_KEYS) {
60
+ const n = Number(source[key])
61
+ if (Number.isFinite(n) && n >= 100 && n <= 599) return Math.floor(n)
83
62
  }
84
63
  }
85
64
  return 0
@@ -98,12 +77,13 @@ export function populateHttpStatusFromMessage(err, msg = null) {
98
77
  */
99
78
  export function classifyError(err) {
100
79
  if (!err) return 'unknown'
101
- // Once a streamed tool call has been surfaced to the loop, retrying the same
102
- // provider turn can double-execute that tool. Providers mark these stream
103
- // failures as unsafe so the shared retry wrapper fails fast.
104
- if (err.emittedToolCall === true || err.toolCallEmitted === true
105
- || err.partialToolCall === true || err.emittedThinking === true
106
- || err.unsafeToRetry === true) return 'permanent'
80
+ // Canonical stream-outcome contract owns replay safety: exposed
81
+ // text/reasoning or a dispatched/ambiguous complete tool call makes
82
+ // re-issuing the same turn unsafe (double side effect / output
83
+ // concatenation) → 'permanent'. Everything else stays classifiable by the
84
+ // typed rules below; an unknown/untyped failure ends as 'unknown', never a
85
+ // blanket retry.
86
+ if (readStreamOutcome(err).replayUnsafe === true) return 'permanent'
107
87
  // Cancellation is a caller decision, never a transport symptom. Anthropic's
108
88
  // APIUserAbortError inherits Error without overriding `name`, so recognize
109
89
  // only exact SDK constructor/type markers (plus standard AbortError markers)
@@ -112,7 +92,7 @@ export function classifyError(err) {
112
92
  if (chain.some(isExplicitUserAbortError)) return 'permanent'
113
93
 
114
94
  // Current typed HTTP status outranks stale stream/connection annotations.
115
- const status = Number(err.httpStatus || err.status || err.response?.status || 0) || populateHttpStatusFromMessage(err)
95
+ const status = Number(err.httpStatus || err.status || err.response?.status || 0) || 0
116
96
  if (AUTH_STATUSES.has(status)) return 'auth'
117
97
  if (status === 429) return 'permanent'
118
98
  if (PERMANENT_STATUSES.has(status)
@@ -129,14 +109,10 @@ export function classifyError(err) {
129
109
  // transient: we can retry the same request and may succeed.
130
110
  if (chain.some((item) => TRANSIENT_ERROR_CODES.has(String(item?.code || '')))) return 'transient'
131
111
  // The Anthropic SDK uses APIConnectionError for transport failures which
132
- // may not carry a Node errno. Native fetch commonly wraps the errno in
133
- // cause.code, or exposes only TypeError("fetch failed").
134
- const name = String(err.name || '')
135
- const message = String(err.message || '')
136
- if (name === 'APIConnectionError'
137
- || (name === 'TypeError' && /fetch failed|network error/i.test(message))) {
138
- return 'transient'
139
- }
112
+ // may not carry a Node errno. Native fetch wraps its errno in cause.code,
113
+ // which the bounded chain check above already covers — a bare
114
+ // TypeError('fetch failed') with no errno stays 'unknown'.
115
+ if (String(err.name || '') === 'APIConnectionError') return 'transient'
140
116
 
141
117
  return 'unknown'
142
118
  }
@@ -261,8 +237,6 @@ function isPermanentQuotaError(err) {
261
237
  for (const item of boundedCauseChain(err)) {
262
238
  const codes = [item?.code, item?.error?.code]
263
239
  if (codes.some((code) => permanentCodes.has(String(code || '').toLowerCase()))) return true
264
- const text = `${String(item?.message || '')} ${String(item?.error?.message || '')}`
265
- if (/insufficient_quota|quota[_ -]?exceeded|resource exhausted/i.test(text)) return true
266
240
  }
267
241
  return false
268
242
  }
@@ -389,12 +363,13 @@ export function classifyMidstreamError(err, signals, policy = {}) {
389
363
 
390
364
  // WebSocket classification consumes the provider's stream-state signals.
391
365
  function _classifyMidstreamWs(err, state, attemptIndex, policy) {
392
- if (state.sawCompleted) return null
393
- // Once a tool call has been dispatched, no transport outcome is replay-safe.
394
- // This includes a nominal close-1000 before response.completed: the tool may
395
- // already be executing, so retry/fallback could duplicate its side effect.
396
- if (state.emittedToolCall) return null
397
- if (state.emittedText || err?.liveTextEmitted) return null
366
+ // Terminal + replay gate. A mid-stream retry re-issues the turn, so it is
367
+ // denied once visible output was relayed or a tool call was dispatched
368
+ // (including a nominal close-1000 before response.completed: the tool may be
369
+ // executing). Everything below is the typed transient classification.
370
+ const outcome = readStreamOutcome(err, state)
371
+ if (outcome.terminalObserved) return null
372
+ if (outcome.replaySafe !== true) return null
398
373
  if (err?.wsFrameTooLarge || state.wsFrameTooLarge) {
399
374
  return _allowMidstream('ws_frame_too_large', attemptIndex, policy)
400
375
  }
@@ -418,8 +393,7 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
418
393
  if (name === 'StreamStalledAbortError' || name === 'StreamStalledError'
419
394
  || err?.code === 'ESTREAMSTALL' || err?.streamStalled === true
420
395
  || state.watchdogAbort === 'StreamStalledAbortError') {
421
- // A stall AFTER a tool emit is unsafe to replay (double side-effect).
422
- if (err?.unsafeToRetry === true) return null
396
+ // A stall after any exposure was already denied by the outcome gate.
423
397
  return _allowMidstream('stream_stalled', attemptIndex, policy)
424
398
  }
425
399
  if (closeCode === 4000) return _allowMidstream('ws_4000', attemptIndex, policy)
@@ -444,9 +418,6 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
444
418
  const name = err?.name || ''
445
419
  if (name === 'AgentStallAbortError') return _allowMidstream('agent_stall', attemptIndex, policy)
446
420
  if (name === 'StreamStalledAbortError' || name === 'StreamStalledError' || err?.code === 'ESTREAMSTALL' || err?.streamStalled === true) {
447
- // A stall that fired AFTER a tool call was emitted is unsafe to replay
448
- // (double side-effect); surface it as terminal so the turn is not retried.
449
- if (err?.unsafeToRetry === true) return null
450
421
  return _allowMidstream('stream_stalled', attemptIndex, policy)
451
422
  }
452
423
 
@@ -463,23 +434,42 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
463
434
 
464
435
  const failed = err?.responseFailed || state.responseFailedPayload
465
436
  if (failed) {
466
- try {
467
- const blob = JSON.stringify(failed).toLowerCase()
468
- if (blob.includes('stream_disconnected')) return _allowMidstream('response_failed_disconnected', attemptIndex, policy)
469
- if (blob.includes('network_error')) return _allowMidstream('response_failed_network', attemptIndex, policy)
470
- if (blob.includes('auth context expired')) return _allowMidstream('response_failed_auth_expired', attemptIndex, policy)
471
- } catch {}
437
+ // STRUCTURED evidence only: the failure's own numeric status and its
438
+ // explicit error code/type field. The payload is never stringified and
439
+ // searched — a message/body that merely CONTAINS "network_error",
440
+ // "stream_disconnected" or "auth context expired" is not a typed retry
441
+ // signal and leaves the turn terminal.
442
+ const detail = failed?.response?.error || failed?.error || failed
443
+ const failedStatus = typedStatusFrom(detail, failed?.response, failed)
444
+ if (failedStatus >= 500 && failedStatus < 600) {
445
+ return _allowMidstream(`http_${failedStatus}`, attemptIndex, policy)
446
+ }
447
+ for (const field of [detail?.code, detail?.type]) {
448
+ const key = typeof field === 'string' ? field.trim().toLowerCase() : ''
449
+ const classifier = RESPONSE_FAILED_CODE_CLASSIFIERS.get(key)
450
+ if (classifier) return _allowMidstream(classifier, attemptIndex, policy)
451
+ }
472
452
  }
473
453
 
474
454
  return null
475
455
  }
476
456
 
457
+ // Explicit `response.failed` error codes/types that describe a transport-level
458
+ // interruption (Codex retries these); every other code is terminal.
459
+ const RESPONSE_FAILED_CODE_CLASSIFIERS = new Map([
460
+ ['stream_disconnected', 'response_failed_disconnected'],
461
+ ['network_error', 'response_failed_network'],
462
+ ['auth_context_expired', 'response_failed_auth_expired'],
463
+ ['auth_expired', 'response_failed_auth_expired'],
464
+ ])
465
+
477
466
  // SSE classification consumes the provider's stream-state signals.
478
467
  function _classifyMidstreamSse(err, state, attemptIndex, policy) {
479
468
  if (attemptIndex >= policy.defaultRetries) return null
480
- if (state.sawCompleted) return null
469
+ const outcome = readStreamOutcome(err, state)
470
+ if (outcome.terminalObserved) return null
481
471
  if (state.userAbort) return null
482
- if (state.emittedText || state.emittedToolCall || state.partialToolCall || state.emittedThinking) return null
472
+ if (outcome.replaySafe !== true) return null
483
473
 
484
474
  if (!err) return null
485
475
  const status = Number(err?.httpStatus || err?.status || err?.response?.status || 0)
@@ -490,9 +480,6 @@ function _classifyMidstreamSse(err, state, attemptIndex, policy) {
490
480
  const name = err?.name || ''
491
481
  if (name === 'AgentStallAbortError') return 'agent_stall'
492
482
  if (name === 'StreamStalledAbortError' || name === 'StreamStalledError' || err?.code === 'ESTREAMSTALL' || err?.streamStalled === true) {
493
- // A stall AFTER a tool emit is unsafe to replay (double side-effect) →
494
- // terminal (null), no mid-stream retry. Otherwise route to stream_stalled.
495
- if (err?.unsafeToRetry === true) return null
496
483
  return 'stream_stalled'
497
484
  }
498
485
  if (state.watchdogAbort === 'AgentStallAbortError') return 'agent_stall'
@@ -503,10 +490,6 @@ function _classifyMidstreamSse(err, state, attemptIndex, policy) {
503
490
  if (code === 'ETIMEDOUT' || code === 'ESOCKETTIMEDOUT') return 'timeout'
504
491
  if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'EAI_NODATA') return 'dns'
505
492
 
506
- const msg = String(err?.message || '').toLowerCase()
507
- if (msg.includes('stream timed out after') && msg.includes('of inactivity')) return 'sse_idle_timeout'
508
- if (msg.includes('body stream') && msg.includes('terminated')) return 'stream_terminated'
509
- if (msg.includes('fetch failed')) return 'fetch_failed'
510
493
  if (classifyError(err) === 'transient') return 'connection'
511
494
 
512
495
  return null
@@ -530,23 +513,25 @@ const TRANSPORT_FALLBACK_ERRNO = new Set([
530
513
  export function shouldFallbackTransport(err, { signal, enabled = true } = {}) {
531
514
  if (!enabled) return false
532
515
  if (signal?.aborted) return false
533
- if (err?.liveTextEmitted === true) return false
534
- if (err?.emittedToolCall === true || err?.unsafeToRetry === true) return false
516
+ // Transport fallback re-issues the request on another transport: it is a
517
+ // replay, so the exposure deny applies. Eligibility itself stays typed
518
+ // (status / errno / classifier), matching Codex's WS→HTTPS switch.
519
+ if (readStreamOutcome(err).replaySafe !== true) return false
535
520
  const status = Number(err?.httpStatus || err?.status || 0)
536
- // 401 is auth recovery, never transport fallback. Codex treats other
537
- // unexpected handshake statuses as retryable transport errors; 426 is the
538
- // explicit immediate WS→HTTPS switch.
521
+ // 401 is auth recovery, never transport fallback. 426 is the explicit
522
+ // immediate WS→HTTPS switch; every other status must be TYPED transient
523
+ // (408/409/5xx) — an arbitrary nonzero status is not fallback evidence.
539
524
  if (status === 401) return false
540
525
  if (status === 426) return true
541
- if (status > 0) return true
526
+ if (TRANSIENT_STATUSES.has(status) || (status >= 500 && status < 600)) return true
527
+ if (status > 0) return false
542
528
  const code = String(err?.code || '')
543
529
  if (TRANSPORT_FALLBACK_ERRNO.has(code)) return true
544
530
  const classifier = String(err?.retryClassifier || err?.midstreamClassifier || '')
545
531
  if (TRANSPORT_FALLBACK_CLASSIFIERS.has(classifier)) return true
546
532
  if (/^http_5\d\d$/.test(classifier)) return true
547
533
  if (err?.firstByteTimeout) return true
548
- const msg = String(err?.message || '')
549
- return /opening handshake has timed out|socket hang up|acquire timed out|no first server event|no meaningful output/i.test(msg)
534
+ return false
550
535
  }
551
536
 
552
537
  // C) Stream-safety stamp latches. Mirrors openai-oauth-ws's _stampLiveText /
@@ -645,12 +630,17 @@ function _sleepChunkWithAbort(ms, signal, sleepFn, abortMessage) {
645
630
  export function classifyHandshakeError(err, { retry429 = true } = {}) {
646
631
  if (!err) return null
647
632
  const code = err.code || ''
648
- const msg = String(err.message || '')
649
633
  const status = Number(err.httpStatus || 0)
650
634
 
651
635
  if (status === 401 || status === 426 || (status === 429 && !retry429)) return null
652
636
  if (status > 0) {
653
- return `http_${status}`
637
+ // Typed transient handshake statuses only. A 403/404/4xx upgrade refusal
638
+ // is a deterministic decision: spending the retry budget on it cannot
639
+ // change the answer.
640
+ if (status === 429 || TRANSIENT_STATUSES.has(status) || (status >= 500 && status < 600)) {
641
+ return `http_${status}`
642
+ }
643
+ return null
654
644
  }
655
645
 
656
646
  if (code === 'ECONNRESET') return 'reset'
@@ -660,9 +650,6 @@ export function classifyHandshakeError(err, { retry429 = true } = {}) {
660
650
  if (code === 'EWSACQUIRETIMEOUT') return 'acquire_timeout'
661
651
  if (code === 'ENETUNREACH' || code === 'EHOSTUNREACH' || code === 'EPIPE') return 'network'
662
652
 
663
- if (/opening handshake has timed out/i.test(msg)) return 'timeout'
664
- if (/socket hang up/i.test(msg)) return 'reset'
665
-
666
653
  return null
667
654
  }
668
655
 
@@ -677,9 +664,8 @@ export function classifyHandshakeError(err, { retry429 = true } = {}) {
677
664
  * the provider's own responsibility via its native abort plumbing.
678
665
  * - Uses classifyError() to decide retry. 'transient' → retry,
679
666
  * 'auth' / 'permanent' / 'unknown' → throw immediately.
680
- * - populateHttpStatusFromMessage(err) is called on every caught error so
681
- * server-text errors (e.g. "Our servers are currently overloaded")
682
- * resolve to httpStatus before classification.
667
+ * - Classification is typed-only: an error with no status/errno/SDK type is
668
+ * 'unknown' and is surfaced immediately instead of being replayed.
683
669
  *
684
670
  * Returns whatever `fn()` resolves to. Throws the last error if every retry
685
671
  * is exhausted, or the first error if it's classified non-transient.
@@ -736,16 +722,13 @@ export async function withRetry(fn, opts = {}) {
736
722
  throw reason instanceof Error ? reason : new Error('withRetry: aborted')
737
723
  }
738
724
  lastErr = caught
739
- populateHttpStatusFromMessage(caught)
740
725
  const status = Number(caught?.httpStatus || caught?.status || caught?.response?.status || 0)
741
726
  const kind = classifyError(caught)
742
- const outputWasExposed = caught?.liveTextEmitted === true
743
- || caught?.emittedText === true
744
- || caught?.emittedToolCall === true
745
- || caught?.toolCallEmitted === true
746
- || caught?.partialToolCall === true
747
- || caught?.emittedThinking === true
748
- if (outputWasExposed || caught?.unsafeToRetry === true) throw caught
727
+ // Hard replay boundary: a retry RE-ISSUES the request, so it is denied
728
+ // once visible output was relayed or a tool call was dispatched. Whether
729
+ // an eligible failure is actually retried remains the typed question
730
+ // resolved by classifyError()/status below.
731
+ if (readStreamOutcome(caught).replaySafe !== true) throw caught
749
732
  // Claude Code treats x-should-retry:false as an explicit server veto
750
733
  // (except an internal-only 5xx override that Mixdog does not have).
751
734
  // Keep this ahead of status defaults, including the request-local 429 path.