mixdog 0.9.0 → 0.9.2

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 (240) hide show
  1. package/package.json +10 -3
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
@@ -21,35 +21,61 @@ import { enrichModels } from './model-catalog.mjs';
21
21
  import { makeModelCache } from './model-cache.mjs';
22
22
  import { sanitizeToolPairs, sanitizeAnthropicContentPairs } from '../session/context-utils.mjs';
23
23
  import {
24
+ PROVIDER_FIRST_BYTE_TIMEOUT_MS,
24
25
  PROVIDER_HTTP_RESPONSE_TIMEOUT_MS,
25
26
  PROVIDER_RETRY_BACKOFF_MS,
26
27
  PROVIDER_RETRY_MAX_ATTEMPTS,
27
- PROVIDER_SSE_IDLE_TIMEOUT_MS,
28
28
  PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
29
+ PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
30
+ streamStalledError,
29
31
  createPassthroughSignal,
30
32
  } from '../stall-policy.mjs';
31
33
  import {
32
34
  classifyError,
35
+ classifyMidstreamError,
33
36
  midstreamBackoffFor,
37
+ MIDSTREAM_RETRY_POLICY,
34
38
  retryAfterMsFromError,
39
+ sleepWithAbort,
35
40
  withRetry,
36
41
  } from './retry-classifier.mjs';
37
42
  import { buildAnthropicBetaHeaders, supportsAnthropicFastMode } from './anthropic-betas.mjs';
43
+ import {
44
+ applyAnthropicEffortToBody,
45
+ effortValuesForModel,
46
+ shouldIncludeEffortBeta,
47
+ } from './anthropic-effort.mjs';
38
48
  import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
39
49
  import { normalizeContentForAnthropic } from './media-normalization.mjs';
40
50
  import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
51
+ import { scanLeakedToolCalls, createToolCallDedupe } from './anthropic-leaked-toolcall.mjs';
41
52
 
42
53
  // --- Model catalog cache helpers ---
43
54
  // Disk-backed cache so repeated process starts (cron, tool calls) don't
44
55
  // hammer /v1/models. 24h TTL matches the upstream client cadence.
45
56
  const MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
57
+ // Bump when the on-disk cache shape changes so stale-shape entries are
58
+ // discarded instead of misread (mirrors openai-oauth's schema-version gate).
59
+ const ANTHROPIC_MODEL_CACHE_SCHEMA_VERSION = 1;
46
60
  // SSE progress emits (per-request "Response …" and "Done:" lines). Off by default.
47
61
  const SSE_VERBOSE = process.env.MIXDOG_SSE_VERBOSE === '1';
48
62
 
49
- /** Bounded mid-stream SSE retries (transient stream loss); shared with anthropic.mjs. */
50
- export const ANTHROPIC_MAX_MIDSTREAM_RETRIES = 3;
63
+ /** Bounded mid-stream SSE retries (transient stream loss); shared with anthropic.mjs.
64
+ * Sourced from the single shared retry-budget table (MIDSTREAM_RETRY_POLICY.sse). */
65
+ export const ANTHROPIC_MAX_MIDSTREAM_RETRIES = MIDSTREAM_RETRY_POLICY.sse.defaultRetries;
66
+
67
+ // Policy passed to the shared classifyMidstreamError for the SSE path. The
68
+ // top-of-function attempt-budget gate uses defaultRetries (3); perClassifierGate
69
+ // is false so the classifier returns raw bucket strings (the loop owns the
70
+ // MAX_MIDSTREAM_RETRIES bound), matching the former _classifyMidstreamError.
71
+ const SSE_MIDSTREAM_POLICY = {
72
+ mode: 'sse',
73
+ defaultRetries: MIDSTREAM_RETRY_POLICY.sse.defaultRetries,
74
+ perClassifierGate: false,
75
+ };
51
76
 
52
77
  function formatRetryAfter(ms) {
78
+ if (ms == null) return '';
53
79
  const n = Number(ms);
54
80
  if (!Number.isFinite(n) || n < 0) return '';
55
81
  if (n >= 60_000 && n % 60_000 === 0) return `${Math.round(n / 60_000)}m`;
@@ -79,6 +105,7 @@ function anthropicQuotaError(status, headers, bodyText = '') {
79
105
  const _modelCache = makeModelCache({
80
106
  fileName: 'anthropic-oauth-models.json',
81
107
  ttlMs: MODEL_CACHE_TTL_MS,
108
+ version: ANTHROPIC_MODEL_CACHE_SCHEMA_VERSION,
82
109
  onSave: (m) => { _inMemoryCatalog = Array.isArray(m) ? m.slice() : null; },
83
110
  });
84
111
 
@@ -120,6 +147,10 @@ function _displayModel(id) {
120
147
  return `claude-${m[1].toLowerCase()}-${m[2]}${m[3] ? `.${m[3]}` : ''}`;
121
148
  }
122
149
 
150
+ function _capabilitySupported(capability) {
151
+ return capability === true || capability?.supported === true;
152
+ }
153
+
123
154
  // Classify a model id into our common tier/family shape. Anthropic's catalog
124
155
  // mixes dated ids (claude-opus-4-5-20251101), versioned aliases
125
156
  // (claude-opus-4-6), and the raw family tokens resolved via env vars.
@@ -136,15 +167,19 @@ function _normalizeAnthropicModel(raw) {
136
167
  const releaseDate = dated
137
168
  ? id.match(/-(\d{4})(\d{2})(\d{2})$/)
138
169
  : null;
170
+ const effortValues = effortValuesForModel(raw?.capabilities, id);
139
171
  return {
140
172
  id,
141
173
  display: raw?.display_name || _prettyName(id, family),
142
174
  family,
143
175
  provider: 'anthropic-oauth',
144
- contextWindow: raw?.context_window || raw?.max_context_window || _defaultContextForModel(id, family),
176
+ contextWindow: raw?.context_window || raw?.max_context_window || raw?.max_input_tokens || _defaultContextForModel(id, family),
177
+ outputTokens: raw?.max_tokens || raw?.max_output_tokens || null,
145
178
  tier,
146
179
  latest: false, // assigned in a second pass once full list is known
147
180
  releaseDate: releaseDate ? `${releaseDate[1]}-${releaseDate[2]}-${releaseDate[3]}` : null,
181
+ supportsReasoning: effortValues.length > 0 || _capabilitySupported(raw?.capabilities?.thinking),
182
+ reasoningOptions: effortValues.length ? [{ type: 'effort', values: effortValues }] : [],
148
183
  };
149
184
  }
150
185
 
@@ -284,9 +319,11 @@ function resolveCliVersion() {
284
319
  }
285
320
 
286
321
  function requiresSystemPrefix(model) {
287
- // Opus / Sonnet require the OAuth system prefix when authenticated
288
- // via OAuth. Haiku does not.
289
- return /^claude-(opus|sonnet)/i.test(String(model || ''));
322
+ // High-tier Claude OAuth models require the first-party system prefix for
323
+ // OAuth pool routing. Haiku does not; keep every other Claude family (Opus,
324
+ // Sonnet, Fable, and future non-Haiku families) on the prefixed path.
325
+ const id = String(model || '').toLowerCase();
326
+ return /^claude-/.test(id) && !/^claude-haiku(?:-|$)/.test(id);
290
327
  }
291
328
 
292
329
  // OAuth rate-limit pool routing is gated by the server inspecting the first
@@ -349,22 +386,77 @@ const MAX_TOKENS = {
349
386
  'claude-haiku-4-5-20251001': 8192,
350
387
  };
351
388
 
352
- function resolveMaxTokens(model) {
389
+ // Sanity floor/ceiling for resolveMaxTokens. Catalog-reported outputTokens
390
+ // (from the Anthropic API, cached to disk) can be trusted above these
391
+ // hardcoded fallbacks, but we still clamp to a safety cap so a bad/huge
392
+ // catalog value can't blow the thinking+output budget, and floor so a
393
+ // missing/zero catalog value never degenerates to an unusable cap.
394
+ const MAX_TOKENS_FLOOR = 8192;
395
+ const DEFAULT_SAFETY_CAP = 65536;
396
+ // Parse MIXDOG_ANTHROPIC_MAX_OUTPUT_TOKENS strictly: only a finite positive
397
+ // number is a valid override. Invalid values ("0", negatives, garbage,
398
+ // whitespace) are treated as unset — NOT as "use the default cap outright" —
399
+ // so resolveMaxTokens still consults catalog/fallback for low-cap models.
400
+ function _envMaxOutputOverride() {
401
+ const raw = process.env.MIXDOG_ANTHROPIC_MAX_OUTPUT_TOKENS;
402
+ if (raw == null || String(raw).trim() === '') return null;
403
+ const n = Number(raw);
404
+ if (!Number.isFinite(n) || n <= 0) return null;
405
+ return Math.floor(n);
406
+ }
407
+
408
+ // Catalog-reported outputTokens for a model id, read from the in-memory
409
+ // catalog mirror (lazily populated from the disk cache if the mirror hasn't
410
+ // been warmed yet by listModels()). Never throws — any failure just means
411
+ // "no catalog data", and callers fall back to the static heuristics below.
412
+ function _catalogOutputTokens(model) {
413
+ if (!model) return null;
414
+ try {
415
+ if (!Array.isArray(_inMemoryCatalog)) {
416
+ const cached = _modelCache.loadSync();
417
+ if (Array.isArray(cached)) _inMemoryCatalog = cached.slice();
418
+ }
419
+ if (!Array.isArray(_inMemoryCatalog)) return null;
420
+ const entry = _inMemoryCatalog.find(m => m?.id === model);
421
+ const out = Number(entry?.outputTokens);
422
+ return Number.isFinite(out) && out > 0 ? out : null;
423
+ } catch {
424
+ return null;
425
+ }
426
+ }
427
+
428
+ function _fallbackMaxTokens(model) {
353
429
  if (MAX_TOKENS[model]) return MAX_TOKENS[model];
354
430
  const id = String(model || '').toLowerCase();
355
431
  if (id.includes('opus')) return 65536;
432
+ if (id.includes('fable')) return 65536;
433
+ // Sonnet 5+ ships a much larger output budget than the legacy 4.x line
434
+ // (this is the claude-sonnet-5 fix: 16384 was starving visible output
435
+ // once extended thinking ate into the same hard cap). Keep sonnet-4-x
436
+ // conservative at 16384; only bump 5+.
437
+ const sonnetVersion = id.match(/^claude-sonnet-(\d+)/);
438
+ if (sonnetVersion) return Number(sonnetVersion[1]) >= 5 ? 65536 : 16384;
356
439
  if (id.includes('sonnet')) return 16384;
357
440
  if (id.includes('haiku')) return 8192;
358
441
  return 8192;
359
442
  }
360
443
 
361
- const EFFORT_BUDGET = {
362
- low: 1024,
363
- medium: 4096,
364
- high: 16384,
365
- xhigh: 32768,
366
- max: 32768,
367
- };
444
+ // resolveMaxTokens: catalog-driven max_tokens for a model id.
445
+ // 1. MIXDOG_ANTHROPIC_MAX_OUTPUT_TOKENS env override, if set, wins outright.
446
+ // 2. Catalog outputTokens (trusted over hardcoded heuristics when present),
447
+ // clamped to [MAX_TOKENS_FLOOR, safetyCap].
448
+ // 3. Static MAX_TOKENS table / family heuristic fallback when the catalog
449
+ // has no entry for this model, also clamped to the safety cap.
450
+ function resolveMaxTokens(model) {
451
+ const envOverride = _envMaxOutputOverride();
452
+ if (envOverride != null) return envOverride;
453
+ const safetyCap = DEFAULT_SAFETY_CAP;
454
+ const catalogValue = _catalogOutputTokens(model);
455
+ if (catalogValue != null) {
456
+ return Math.max(MAX_TOKENS_FLOOR, Math.min(catalogValue, safetyCap));
457
+ }
458
+ return Math.min(_fallbackMaxTokens(model), safetyCap);
459
+ }
368
460
 
369
461
  const MIN_THINKING_BUDGET = 1024;
370
462
  const THINKING_OUTPUT_RESERVE = 1024;
@@ -378,10 +470,6 @@ function clampThinkingBudgetTokens(value, maxTokens) {
378
470
  return Math.max(MIN_THINKING_BUDGET, Math.min(desired, ceiling));
379
471
  }
380
472
 
381
- // Tracks which unknown effort labels we've already logged so a repeated
382
- // session-level misconfig doesn't flood stderr with the same warning.
383
- const _LOGGED_UNKNOWN_EFFORT = new Set();
384
-
385
473
  // Layered cache TTLs — stable layers get 1h, volatile layers get 5m.
386
474
  // Anthropic requires 1h entries to appear before 5m entries in the request.
387
475
  const CACHE_TTL_STABLE = { type: 'ephemeral', ttl: '1h' }; // tools, system, tier3, messages
@@ -889,25 +977,10 @@ function _captureMidstreamAbort(state, reason) {
889
977
  }
890
978
  }
891
979
 
892
- async function _midstreamSleepWithAbort(ms, signal) {
893
- if (!ms) return;
894
- if (!signal) {
895
- await new Promise((r) => setTimeout(r, ms));
896
- return;
897
- }
898
- await new Promise((resolve, reject) => {
899
- const t = setTimeout(() => {
900
- try { signal.removeEventListener('abort', onAbort); } catch {}
901
- resolve();
902
- }, ms);
903
- const onAbort = () => {
904
- clearTimeout(t);
905
- const reason = signal.reason;
906
- reject(reason instanceof Error ? reason : new Error('Anthropic OAuth mid-stream retry backoff aborted'));
907
- };
908
- if (signal.aborted) { onAbort(); return; }
909
- signal.addEventListener('abort', onAbort, { once: true });
910
- });
980
+ // Abort-aware mid-stream backoff sleep → shared sleepWithAbort
981
+ // (retry-classifier.mjs). abortMessage preserves the prior fallback text.
982
+ function _midstreamSleepWithAbort(ms, signal) {
983
+ return sleepWithAbort(ms, signal, undefined, 'Anthropic OAuth mid-stream retry backoff aborted');
911
984
  }
912
985
 
913
986
  function _statusForAnthropicSseError(type, message) {
@@ -939,10 +1012,23 @@ function _anthropicSseError(event) {
939
1012
  return err;
940
1013
  }
941
1014
 
942
- async function parseSSEStream(response, signal, abortStream, onStreamDelta, onToolCall, state, onTextDelta) {
1015
+ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onToolCall, state, onTextDelta, knownToolNames) {
943
1016
  const reader = response.body.getReader();
944
1017
  const decoder = new TextDecoder();
945
- const SSE_IDLE_TIMEOUT_MS = PROVIDER_SSE_IDLE_TIMEOUT_MS;
1018
+ // SEMANTIC idle window: reset only by real model events (message/content/
1019
+ // tool deltas), NOT by raw keepalive bytes. A ping-only wedge therefore
1020
+ // trips this within the window instead of hanging until the 30-min agent
1021
+ // watchdog. See resetIdleTimer + the per-event reset in the loop below.
1022
+ // state.semanticIdleTimeoutMs is a test/override seam (same shape as
1023
+ // firstMessageTimeoutMs); production uses the shared env-backed default.
1024
+ const SSE_IDLE_TIMEOUT_MS = Number.isFinite(Number(state?.semanticIdleTimeoutMs))
1025
+ && Number(state.semanticIdleTimeoutMs) > 0
1026
+ ? Number(state.semanticIdleTimeoutMs)
1027
+ : PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
1028
+ const SSE_FIRST_MESSAGE_TIMEOUT_MS = Number.isFinite(Number(state?.firstMessageTimeoutMs))
1029
+ && Number(state.firstMessageTimeoutMs) > 0
1030
+ ? Number(state.firstMessageTimeoutMs)
1031
+ : PROVIDER_FIRST_BYTE_TIMEOUT_MS;
946
1032
  let content = '';
947
1033
  let hasThinkingContent = false;
948
1034
  const contentBlockTypes = new Set();
@@ -952,16 +1038,136 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
952
1038
  let stopReason = null;
953
1039
  let buffer = '';
954
1040
  let idleTimedOut = false;
1041
+ let firstMessageTimedOut = false;
955
1042
  let idleTimer = null;
1043
+ let firstMessageTimer = null;
956
1044
  let currentEvent = '';
957
1045
 
958
1046
  const pendingToolInputs = new Map();
959
1047
 
1048
+ // Leaked tool-call guard. The model (esp. Opus via OAuth) occasionally
1049
+ // emits a tool call as plain text tags inside `text_delta` instead of a
1050
+ // native `tool_use` block. `leakBuffer` is a minimal rolling window that
1051
+ // only holds back text when a partial sentinel prefix is present, so a
1052
+ // tag split across chunk boundaries is still detected while ordinary text
1053
+ // still streams promptly. The guard is additive: the native tool_use path
1054
+ // (content_block_start/input_json_delta/content_block_stop) is untouched.
1055
+ const _knownTools = knownToolNames instanceof Set
1056
+ ? knownToolNames
1057
+ : new Set(Array.isArray(knownToolNames) ? knownToolNames : []);
1058
+ const _leakGuardEnabled = _knownTools.size > 0;
1059
+ const _isKnownTool = (name) => _knownTools.has(name);
1060
+ let leakBuffer = '';
1061
+ // Running markdown fence/inline-code state threaded across text_delta
1062
+ // chunks (Fix 1): a tool-call tag inside a ```code fence``` or inline span
1063
+ // is a doc example, not a real call — the guard emits it as text.
1064
+ let leakFenceState = undefined;
1065
+ // Cross-path fingerprint dedupe (Fix 2): a synthesized text-leaked call and
1066
+ // an identical native tool_use block must dispatch onToolCall exactly once.
1067
+ const _toolDedupe = createToolCallDedupe();
1068
+
1069
+ // Synthesize + dispatch a recovered leaked call exactly like the native
1070
+ // content_block_stop path (push into toolCalls, flag state, eager
1071
+ // onToolCall). A generated id mirrors the `toolu_`-prefixed native shape.
1072
+ const dispatchLeakedCall = (recovered) => {
1073
+ let args = recovered?.arguments;
1074
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) args = {};
1075
+ // Skip if an identical native (or prior synthetic) call already fired.
1076
+ if (!_toolDedupe.shouldDispatch(recovered.name, args)) return;
1077
+ const call = {
1078
+ id: `toolu_leaked_${randomBytes(8).toString('hex')}`,
1079
+ name: recovered.name,
1080
+ arguments: args,
1081
+ };
1082
+ toolCalls.push(call);
1083
+ if (state) state.emittedToolCall = true;
1084
+ try { onToolCall?.(call); } catch {}
1085
+ try { onStreamDelta?.(); } catch {}
1086
+ };
1087
+
1088
+ // Feed accumulated text through the scanner. On `final` nothing is held
1089
+ // back so legitimate text is never lost at stream end.
1090
+ const pumpLeakBuffer = (final) => {
1091
+ if (!_leakGuardEnabled) return;
1092
+ if (!leakBuffer && !final) return;
1093
+ const { emit, calls, rest, fenceState } = scanLeakedToolCalls(leakBuffer, { isKnownTool: _isKnownTool, final, fenceState: leakFenceState });
1094
+ leakBuffer = rest;
1095
+ leakFenceState = fenceState;
1096
+ if (emit) {
1097
+ content += emit;
1098
+ if (onTextDelta) {
1099
+ if (state) state.emittedText = true;
1100
+ try { onTextDelta(emit); } catch {}
1101
+ }
1102
+ }
1103
+ for (const c of calls) dispatchLeakedCall(c);
1104
+ };
1105
+
960
1106
  // Holds the in-flight reader.read() race rejector so the idle timer can
961
1107
  // force-unblock the loop even when reader.cancel() fails to settle the
962
1108
  // pending read (undici half-open socket). See resetIdleTimer below.
963
1109
  let idleReject = null;
964
1110
 
1111
+ const firstMessageTimeoutError = () => {
1112
+ const err = new Error(`Anthropic OAuth SSE stream produced no message_start within ${SSE_FIRST_MESSAGE_TIMEOUT_MS}ms`);
1113
+ err.code = 'EEMPTYSTREAM';
1114
+ err.isEmptyStream = true;
1115
+ err.firstByteTimeout = true;
1116
+ return err;
1117
+ };
1118
+
1119
+ const clearFirstMessageTimer = () => {
1120
+ if (firstMessageTimer) {
1121
+ clearTimeout(firstMessageTimer);
1122
+ firstMessageTimer = null;
1123
+ }
1124
+ };
1125
+
1126
+ const armFirstMessageTimer = () => {
1127
+ if (!(SSE_FIRST_MESSAGE_TIMEOUT_MS > 0)) return;
1128
+ clearFirstMessageTimer();
1129
+ firstMessageTimer = setTimeout(() => {
1130
+ if (state?.sawMessageStart) return;
1131
+ firstMessageTimedOut = true;
1132
+ const err = firstMessageTimeoutError();
1133
+ try { abortStream?.(err); } catch (abortErr) {
1134
+ try { process.stderr.write(`[anthropic-oauth] sse first-message abortStream failed: ${abortErr?.message ?? String(abortErr)}\n`); } catch {}
1135
+ }
1136
+ try {
1137
+ const _c = reader.cancel('SSE first message timeout');
1138
+ if (_c && typeof _c.catch === 'function') _c.catch(() => {});
1139
+ } catch (cancelErr) {
1140
+ try { process.stderr.write(`[anthropic-oauth] sse first-message cancel failed: ${cancelErr?.message ?? String(cancelErr)}\n`); } catch {}
1141
+ }
1142
+ if (idleReject) {
1143
+ const r = idleReject; idleReject = null; r(err);
1144
+ }
1145
+ }, SSE_FIRST_MESSAGE_TIMEOUT_MS);
1146
+ try { firstMessageTimer.unref?.(); } catch {}
1147
+ };
1148
+
1149
+ // Attach the partial stream state to a mid-stream stall error so the agent
1150
+ // loop can decide SUCCESS vs FAILURE. The recurring "worker finished but
1151
+ // owner never notified" case is a FINAL no-tool summary stream that wedges
1152
+ // ping-only after the real work (tool calls) already completed in earlier
1153
+ // iterations: there is streamed `content`, no pending tool_use, and no
1154
+ // emitted tool call this iteration. The loop treats that as a successful
1155
+ // partial-final (deliver the summary we have) instead of dropping it. A
1156
+ // stall WITH a pending/emitted tool call stays a hard failure (a tool whose
1157
+ // input never completed must never be reported as done).
1158
+ const _attachStallPartial = (err) => {
1159
+ try {
1160
+ err.partialContent = content;
1161
+ err.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
1162
+ err.pendingToolUse = pendingToolInputs.size > 0;
1163
+ err.partialModel = model || undefined;
1164
+ err.partialUsage = usage;
1165
+ err.partialStopReason = stopReason || undefined;
1166
+ err.partialHasThinking = hasThinkingContent;
1167
+ } catch { /* best-effort enrichment */ }
1168
+ return err;
1169
+ };
1170
+
965
1171
  const resetIdleTimer = () => {
966
1172
  // OFF by default. When disabled the
967
1173
  // idle timer never arms, so the stream is never killed on inactivity;
@@ -984,13 +1190,14 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
984
1190
  // pending forever and the SSE idle timeout never unblocks the loop —
985
1191
  // the 391s-hang root cause.
986
1192
  if (idleReject) {
987
- const e = new Error(`Anthropic OAuth SSE stream timed out after ${SSE_IDLE_TIMEOUT_MS}ms of inactivity`);
988
- e.code = 'ETIMEDOUT';
1193
+ const e = _attachStallPartial(streamStalledError('Anthropic OAuth SSE', SSE_IDLE_TIMEOUT_MS, { emittedToolCall: !!state?.emittedToolCall }));
989
1194
  const r = idleReject; idleReject = null; r(e);
990
1195
  }
991
- // Shared provider policy: short inter-chunk inactivity catches the
992
- // sess_9cfd11-class stuck pattern where SSE starts but then goes silent.
1196
+ // Shared provider policy: short SEMANTIC-event inactivity catches the
1197
+ // ping-only wedge where SSE starts, emits some deltas, then goes silent
1198
+ // while `:ping` keepalives keep the transport socket warm.
993
1199
  }, SSE_IDLE_TIMEOUT_MS);
1200
+ try { idleTimer.unref?.(); } catch {}
994
1201
  };
995
1202
 
996
1203
  const onAbort = () => {
@@ -1010,7 +1217,13 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1010
1217
  }
1011
1218
 
1012
1219
  try {
1013
- resetIdleTimer();
1220
+ // Part A / reviewer fix: do NOT arm the SEMANTIC idle timer before the
1221
+ // stream has produced its first event. A slow first response is governed
1222
+ // by armFirstMessageTimer() (first-byte window) alone; arming the
1223
+ // semantic idle here could let it win and mis-abort a legitimately slow
1224
+ // first response as a stall. The semantic idle is first armed at
1225
+ // `message_start` (see below), so it only ever guards MID-stream silence.
1226
+ armFirstMessageTimer();
1014
1227
  streamLoop: while (true) {
1015
1228
  let chunk;
1016
1229
  try {
@@ -1022,9 +1235,10 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1022
1235
  });
1023
1236
  } catch (err) {
1024
1237
  if (idleTimedOut) {
1025
- const idleErr = new Error(`Anthropic OAuth SSE stream timed out after ${SSE_IDLE_TIMEOUT_MS}ms of inactivity`);
1026
- idleErr.code = 'ETIMEDOUT';
1027
- throw idleErr;
1238
+ throw _attachStallPartial(streamStalledError('Anthropic OAuth SSE', SSE_IDLE_TIMEOUT_MS, { emittedToolCall: !!state?.emittedToolCall }));
1239
+ }
1240
+ if (firstMessageTimedOut) {
1241
+ throw firstMessageTimeoutError();
1028
1242
  }
1029
1243
  if (signal?.aborted) {
1030
1244
  _captureMidstreamAbort(state, signal.reason);
@@ -1037,21 +1251,25 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1037
1251
  const { done, value } = chunk;
1038
1252
  if (done) break;
1039
1253
 
1040
- resetIdleTimer();
1041
1254
  buffer += decoder.decode(value, { stream: true });
1042
1255
  const lines = buffer.split('\n');
1043
1256
  buffer = lines.pop() || '';
1044
1257
 
1045
1258
  for (const line of lines) {
1046
1259
  if (line.startsWith(':')) {
1047
- // SSE comment frame (Anthropic `:ping` keepalive). The HTML Standard SSE
1048
- // spec says comments are silently ignored, but we surface them here so
1049
- // the agent stall watchdog sees the stream is still alive during Opus
1050
- // extended-thinking pauses. No content is emitted — this only refreshes
1051
- // the runtime's lastStreamDeltaAt timestamp.
1052
- try { onStreamDelta?.(); } catch {}
1260
+ // SSE comment frame (Anthropic `:ping` keepalive). Keep it
1261
+ // at transport level only: comments must not refresh the
1262
+ // agent's semantic progress timestamp, or a ping-only 200
1263
+ // can look alive forever without message_start/content.
1264
+ // Crucially it also does NOT reset the SEMANTIC idle timer
1265
+ // below a ping-only wedge must trip the idle abort.
1053
1266
  continue;
1054
1267
  }
1268
+ // Blank lines are SSE record separators — emitted after EVERY
1269
+ // frame, including `:ping` keepalives — so they are NOT semantic
1270
+ // progress and must not reset the idle timer (else a ping frame's
1271
+ // trailing blank would keep a wedge alive forever).
1272
+ if (line === '') continue;
1055
1273
  if (line.startsWith('event: ')) {
1056
1274
  currentEvent = line.slice(7).trim();
1057
1275
  continue;
@@ -1063,11 +1281,24 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1063
1281
  try {
1064
1282
  const event = JSON.parse(data);
1065
1283
 
1284
+ // SEMANTIC idle reset (Part A): reset the idle timer ONLY for
1285
+ // real progress events, NOT for Anthropic keepalives. Anthropic
1286
+ // sends pings as a NAMED event (`event: ping` /
1287
+ // `data: {"type":"ping"}`), not just `:` comment frames, so a
1288
+ // named ping must be excluded here or a ping-only wedge keeps
1289
+ // the timer alive forever. Everything that is not a ping is a
1290
+ // genuine server event (message_start/content/tool/thinking
1291
+ // deltas, message_delta/stop, errors) and counts as progress.
1292
+ if (currentEvent !== 'ping' && event?.type !== 'ping') {
1293
+ resetIdleTimer();
1294
+ }
1295
+
1066
1296
  if (currentEvent === 'error' || event?.type === 'error' || event?.error) {
1067
1297
  throw _anthropicSseError(event);
1068
1298
  }
1069
1299
 
1070
1300
  if (event.type === 'message_start' && event.message) {
1301
+ clearFirstMessageTimer();
1071
1302
  if (state) state.sawMessageStart = true;
1072
1303
  if (event.message.model) model = event.message.model;
1073
1304
  if (event.message.usage) {
@@ -1092,8 +1323,13 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1092
1323
  if (event.type === 'content_block_delta') {
1093
1324
  const delta = event.delta;
1094
1325
  if (delta?.type) contentBlockTypes.add(delta.type);
1326
+ // Time-to-first-token: stamp the first content delta
1327
+ // (text / thinking / tool input_json) exactly once so
1328
+ // the SSE trace can separate first-byte latency from
1329
+ // total stream/generation time. Without this stamp
1330
+ // ttftMs was always null and reported as 0ms.
1331
+ if (state && !state.ttftAt) state.ttftAt = Date.now();
1095
1332
  if (delta?.type === 'text_delta') {
1096
- content += delta.text || '';
1097
1333
  try { onStreamDelta?.(); } catch {}
1098
1334
  // Live text relay (gateway): forward the explicit
1099
1335
  // text chunk. thinking/signature/input_json deltas
@@ -1102,9 +1338,21 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1102
1338
  // live it cannot be withdrawn, so flag the attempt so
1103
1339
  // the mid-stream retry loop treats any later failure
1104
1340
  // as final (a retry would concatenate attempts).
1105
- if (delta.text && onTextDelta) {
1106
- if (state) state.emittedText = true;
1107
- try { onTextDelta(delta.text); } catch {}
1341
+ if (_leakGuardEnabled) {
1342
+ // Route text through the leaked-tool-call guard.
1343
+ // It appends to `content`, forwards visible text
1344
+ // via onTextDelta, and synthesizes/dispatches any
1345
+ // recovered known-tool call — suppressing the
1346
+ // tags from the visible stream. A partial sentinel
1347
+ // is held in leakBuffer until the next chunk.
1348
+ leakBuffer += delta.text || '';
1349
+ pumpLeakBuffer(false);
1350
+ } else {
1351
+ content += delta.text || '';
1352
+ if (delta.text && onTextDelta) {
1353
+ if (state) state.emittedText = true;
1354
+ try { onTextDelta(delta.text); } catch {}
1355
+ }
1108
1356
  }
1109
1357
  }
1110
1358
  if (delta?.type === 'thinking_delta' || delta?.type === 'signature_delta') {
@@ -1160,13 +1408,25 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1160
1408
  name: pending.name,
1161
1409
  arguments: parsedArgs,
1162
1410
  };
1163
- toolCalls.push(call);
1164
1411
  pendingToolInputs.delete(event.index);
1165
- if (state) state.emittedToolCall = true;
1166
1412
  // Eager dispatch: let the loop start this tool
1167
1413
  // before message_stop arrives. The loop keys
1168
1414
  // pending promises by call.id so order is safe.
1169
- try { onToolCall?.(call); } catch {}
1415
+ // Fix 2: skip the ENTIRE call (push + dispatch) when a
1416
+ // text-leaked synthetic of the same (name,args) already
1417
+ // fired — otherwise the duplicate stays in `toolCalls`
1418
+ // and the loop executes the side-effecting tool twice.
1419
+ // An invalid-args marker never fingerprint-collides with
1420
+ // a real recovered call, so malformed native calls still
1421
+ // dispatch (the marker path is unaffected).
1422
+ if (_toolDedupe.shouldDispatch(call.name, call.arguments)) {
1423
+ toolCalls.push(call);
1424
+ if (state) state.emittedToolCall = true;
1425
+ // Eager dispatch: let the loop start this tool
1426
+ // before message_stop arrives. The loop keys
1427
+ // pending promises by call.id so order is safe.
1428
+ try { onToolCall?.(call); } catch {}
1429
+ }
1170
1430
  try { onStreamDelta?.(); } catch {}
1171
1431
  }
1172
1432
  }
@@ -1207,6 +1467,12 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1207
1467
  }
1208
1468
  }
1209
1469
 
1470
+ // Stream ended: flush any held-back leaked-tool-call buffer. `final`
1471
+ // holds nothing back, so a trailing partial sentinel that never
1472
+ // resolved into a real call is surfaced as ordinary text — legitimate
1473
+ // user-visible content is never lost on the failure path.
1474
+ pumpLeakBuffer(true);
1475
+
1210
1476
  // Truncated-stream guard: if the reader loop exited (EOF or break)
1211
1477
  // after message_start but without seeing message_stop / a tool_use
1212
1478
  // stop_reason, the assistant turn was cut off mid-flight. Returning
@@ -1243,6 +1509,7 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1243
1509
  };
1244
1510
  } finally {
1245
1511
  if (idleTimer) clearTimeout(idleTimer);
1512
+ clearFirstMessageTimer();
1246
1513
  if (signal) signal.removeEventListener('abort', onAbort);
1247
1514
  try { reader.releaseLock(); } catch (err) {
1248
1515
  try { process.stderr.write(`[anthropic-oauth] reader releaseLock failed: ${err?.message ?? String(err)}\n`); } catch {}
@@ -1258,35 +1525,14 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1258
1525
  * That keeps recovery limited to transport/stream stalls without risking
1259
1526
  * duplicate eager tool execution.
1260
1527
  */
1528
+ // Thin wrapper: the SSE mid-stream decision tree now lives in the shared
1529
+ // classifyMidstreamError (retry-classifier.mjs, policy.mode='sse'). Kept as a
1530
+ // named export so internal call sites AND anthropic.mjs (which imports this
1531
+ // symbol) keep resolving it. Behavior is byte-identical — the shared function
1532
+ // is the relocated original, gated by SSE_MIDSTREAM_POLICY (defaultRetries=3,
1533
+ // perClassifierGate:false).
1261
1534
  export function _classifyMidstreamError(err, state) {
1262
- if (!state) return null;
1263
- if ((state.attemptIndex | 0) >= ANTHROPIC_MAX_MIDSTREAM_RETRIES) return null;
1264
- if (state.sawCompleted) return null;
1265
- if (!state.sawMessageStart) return null;
1266
- if (state.userAbort) return null;
1267
- if (state.emittedToolCall) return null;
1268
-
1269
- if (!err) return null;
1270
- const status = Number(err?.httpStatus || 0);
1271
- if (status === 401 || status === 403 || status === 429) return null;
1272
-
1273
- const name = err?.name || '';
1274
- if (name === 'AgentStallAbortError') return 'agent_stall';
1275
- if (name === 'StreamStalledAbortError') return 'stream_stalled';
1276
- if (state.watchdogAbort === 'AgentStallAbortError') return 'agent_stall';
1277
- if (state.watchdogAbort === 'StreamStalledAbortError') return 'stream_stalled';
1278
-
1279
- const code = err?.code || err?.cause?.code || '';
1280
- if (code === 'ECONNRESET') return 'reset';
1281
- if (code === 'ETIMEDOUT' || code === 'ESOCKETTIMEDOUT') return 'timeout';
1282
- if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'EAI_NODATA') return 'dns';
1283
-
1284
- const msg = String(err?.message || '').toLowerCase();
1285
- if (msg.includes('stream timed out after') && msg.includes('of inactivity')) return 'sse_idle_timeout';
1286
- if (msg.includes('body stream') && msg.includes('terminated')) return 'stream_terminated';
1287
- if (msg.includes('fetch failed')) return 'fetch_failed';
1288
-
1289
- return null;
1535
+ return classifyMidstreamError(err, state, SSE_MIDSTREAM_POLICY);
1290
1536
  }
1291
1537
 
1292
1538
  // --- Build request body ---
@@ -1387,21 +1633,13 @@ function buildRequestBody(messages, model, tools, sendOpts) {
1387
1633
  body.tools = [...nativeTools, ...toAnthropicTools([...(tools || []), ...deferredTools])];
1388
1634
  }
1389
1635
 
1390
- const thinkingBudgetTokens = Number(opts.thinkingBudgetTokens);
1391
- if (Number.isFinite(thinkingBudgetTokens) && thinkingBudgetTokens > 0) {
1392
- const budgetTokens = clampThinkingBudgetTokens(thinkingBudgetTokens, maxTokens);
1393
- if (budgetTokens) body.thinking = { type: 'enabled', budget_tokens: budgetTokens };
1394
- } else if (opts.effort) {
1395
- if (EFFORT_BUDGET[opts.effort]) {
1396
- const budgetTokens = clampThinkingBudgetTokens(EFFORT_BUDGET[opts.effort], maxTokens);
1397
- if (budgetTokens) body.thinking = { type: 'enabled', budget_tokens: budgetTokens };
1398
- } else if (!_LOGGED_UNKNOWN_EFFORT.has(opts.effort)) {
1399
- _LOGGED_UNKNOWN_EFFORT.add(opts.effort);
1400
- try {
1401
- process.stderr.write(`[anthropic-oauth] unknown effort=${opts.effort} ignored (known: ${Object.keys(EFFORT_BUDGET).join(',')})\n`);
1402
- } catch {}
1403
- }
1404
- }
1636
+ applyAnthropicEffortToBody(body, {
1637
+ model,
1638
+ opts,
1639
+ maxTokens,
1640
+ clampThinkingBudgetTokens,
1641
+ logTag: 'anthropic-oauth',
1642
+ });
1405
1643
 
1406
1644
  if (opts.fast === true && supportsAnthropicFastMode(model)) {
1407
1645
  body.speed = 'fast';
@@ -1552,6 +1790,15 @@ export class AnthropicOAuthProvider {
1552
1790
  if (body.speed === 'fast') {
1553
1791
  this.fastModeBetaHeaderLatched = true;
1554
1792
  }
1793
+ // Known tool names for the leaked-tool-call guard in parseSSEStream:
1794
+ // recovered leaked calls are only synthesized when they name a tool
1795
+ // actually offered to this request (native + lowered). Derived from the
1796
+ // final request body so it matches exactly what the model was given.
1797
+ const knownToolNames = new Set(
1798
+ (Array.isArray(body.tools) ? body.tools : [])
1799
+ .map((t) => (t && typeof t.name === 'string' ? t.name : null))
1800
+ .filter(Boolean),
1801
+ );
1555
1802
  const sessionId = opts.sessionId || null;
1556
1803
  const iteration = Number.isFinite(Number(opts.iteration)) ? Number(opts.iteration) : null;
1557
1804
  // Option A: no absolute wall-clock cap on streaming generation. A stream
@@ -1620,6 +1867,7 @@ export class AnthropicOAuthProvider {
1620
1867
  base: OAUTH_BETA_HEADERS,
1621
1868
  fastMode: this.fastModeBetaHeaderLatched,
1622
1869
  toolSearch: true,
1870
+ effort: shouldIncludeEffortBeta(useModel, opts),
1623
1871
  }),
1624
1872
  'anthropic-dangerous-direct-browser-access': 'true',
1625
1873
  'user-agent': `claude-cli/${resolveCliVersion()} (external, sdk-cli)`,
@@ -1780,11 +2028,12 @@ export class AnthropicOAuthProvider {
1780
2028
  const result = await parseSSEFn(
1781
2029
  response,
1782
2030
  controller.signal,
1783
- () => controller.abort(),
2031
+ (reason) => controller.abort(reason),
1784
2032
  onStreamDelta,
1785
2033
  onToolCall,
1786
2034
  midState,
1787
2035
  onTextDelta,
2036
+ knownToolNames,
1788
2037
  );
1789
2038
 
1790
2039
  const ttftMs = midState.ttftAt ? midState.ttftAt - sseStartedAt : null;
@@ -2236,3 +2485,7 @@ export async function loginOAuth() {
2236
2485
  // Lets the SSE parser be exercised in isolation against a synthetic
2237
2486
  // ReadableStream without needing a live OAuth session.
2238
2487
  export { parseSSEStream };
2488
+
2489
+ // Test-only escape hatch for scripts/tool-smoke.mjs to verify the
2490
+ // catalog-driven max-tokens resolution without duplicating its logic.
2491
+ export const _test = { resolveMaxTokens };