mixdog 0.9.2 → 0.9.4

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 (417) hide show
  1. package/package.json +8 -3
  2. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  3. package/scripts/bench/lead-review-tasks-r3.json +20 -0
  4. package/scripts/bench/lead-review-tasks.json +20 -0
  5. package/scripts/bench/r4-mixed-tasks.json +20 -0
  6. package/scripts/bench/review-tasks.json +20 -0
  7. package/scripts/bench/round-codex.json +114 -0
  8. package/scripts/bench/round-mixdog-lead-r3.json +269 -0
  9. package/scripts/bench/round-mixdog-lead.json +269 -0
  10. package/scripts/bench/round-mixdog.json +126 -0
  11. package/scripts/bench/round-r10-bigsample.json +679 -0
  12. package/scripts/bench/round-r11-codexalign.json +257 -0
  13. package/scripts/bench/round-r4-codex.json +114 -0
  14. package/scripts/bench/round-r4-mixed.json +225 -0
  15. package/scripts/bench/round-r5-gpt-lead.json +259 -0
  16. package/scripts/bench/round-r6-codex.json +114 -0
  17. package/scripts/bench/round-r6-solo.json +257 -0
  18. package/scripts/bench/round-r7-full.json +254 -0
  19. package/scripts/bench/round-r8-fulldefault.json +255 -0
  20. package/scripts/bench-run.mjs +215 -29
  21. package/scripts/build-tui.mjs +13 -1
  22. package/scripts/explore-bench.mjs +124 -0
  23. package/scripts/freevar-smoke.mjs +95 -0
  24. package/scripts/hook-bus-test.mjs +191 -0
  25. package/scripts/internal-comms-bench.mjs +1 -0
  26. package/scripts/internal-comms-smoke.mjs +10 -9
  27. package/scripts/mouse-probe.mjs +45 -0
  28. package/scripts/output-style-bench.mjs +13 -6
  29. package/scripts/output-style-smoke.mjs +4 -4
  30. package/scripts/path-suffix-test.mjs +57 -0
  31. package/scripts/provider-toolcall-test.mjs +7 -3
  32. package/scripts/recall-bench.mjs +207 -0
  33. package/scripts/recall-usecase-cases.json +18 -0
  34. package/scripts/recall-usecase-probe.json +6 -0
  35. package/scripts/session-bench.mjs +152 -6
  36. package/scripts/tool-smoke.mjs +30 -67
  37. package/scripts/tui-render-smoke.mjs +90 -0
  38. package/scripts/webhook-smoke.mjs +208 -0
  39. package/src/agents/debugger/AGENT.md +5 -2
  40. package/src/agents/heavy-worker/AGENT.md +21 -11
  41. package/src/agents/maintainer/AGENT.md +4 -0
  42. package/src/agents/reviewer/AGENT.md +3 -2
  43. package/src/agents/worker/AGENT.md +21 -11
  44. package/src/lib/rules-builder.cjs +4 -0
  45. package/src/mixdog-session-runtime.mjs +933 -3731
  46. package/src/output-styles/default.md +34 -9
  47. package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
  48. package/src/output-styles/minimal.md +4 -1
  49. package/src/output-styles/simple.md +22 -7
  50. package/src/repl.mjs +5 -5
  51. package/src/rules/agent/00-common.md +2 -0
  52. package/src/rules/agent/30-explorer.md +8 -11
  53. package/src/rules/lead/lead-brief.md +12 -0
  54. package/src/rules/lead/lead-tool.md +2 -9
  55. package/src/rules/shared/01-tool.md +11 -5
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
  59. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
  60. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
  61. package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
  62. package/src/runtime/agent/orchestrator/config.mjs +69 -2
  63. package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  65. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +63 -21
  66. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +97 -1343
  70. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
  71. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +78 -10
  72. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
  73. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
  74. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
  75. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
  76. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
  77. package/src/runtime/agent/orchestrator/providers/gemini.mjs +44 -1014
  78. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +18 -4
  79. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  80. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +86 -11
  81. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +348 -0
  82. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  83. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
  84. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
  85. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
  86. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
  87. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
  88. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +41 -1142
  89. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +732 -0
  90. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
  91. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +303 -2119
  92. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +140 -995
  93. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +227 -0
  94. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
  95. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +436 -0
  96. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
  97. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
  99. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
  100. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
  101. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
  102. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
  103. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
  104. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
  105. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
  106. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
  107. package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
  108. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  109. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +274 -0
  110. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
  111. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  112. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  113. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  114. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +47 -0
  115. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +182 -0
  116. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
  117. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  118. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  119. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
  120. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  121. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
  122. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  123. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  124. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  125. package/src/runtime/agent/orchestrator/session/loop.mjs +409 -1304
  126. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
  127. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +230 -0
  128. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  129. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +149 -0
  130. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  131. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
  132. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
  133. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  134. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
  135. package/src/runtime/agent/orchestrator/session/manager.mjs +226 -2114
  136. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
  137. package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
  138. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
  139. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
  140. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
  141. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +33 -42
  142. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  143. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  144. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
  145. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  146. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
  147. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  148. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
  149. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
  150. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
  151. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -87
  152. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
  153. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
  154. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +78 -304
  155. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -6
  156. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  157. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  158. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  159. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +551 -0
  160. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  161. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  162. package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
  163. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  164. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  165. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  166. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1080 -0
  167. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  168. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  169. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  170. package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
  171. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  172. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
  173. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
  174. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
  175. package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
  176. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
  177. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
  178. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
  179. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
  180. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
  181. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
  182. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
  183. package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
  184. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -23
  185. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +10 -74
  186. package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
  187. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  188. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
  189. package/src/runtime/channels/backends/discord-access.mjs +32 -0
  190. package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
  191. package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
  192. package/src/runtime/channels/backends/discord.mjs +12 -292
  193. package/src/runtime/channels/index.mjs +241 -894
  194. package/src/runtime/channels/lib/backend-dispatch.mjs +44 -0
  195. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  196. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  197. package/src/runtime/channels/lib/event-pipeline.mjs +18 -1
  198. package/src/runtime/channels/lib/event-queue.mjs +63 -4
  199. package/src/runtime/channels/lib/inbound-routing.mjs +111 -0
  200. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  201. package/src/runtime/channels/lib/output-forwarder.mjs +9 -1
  202. package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
  203. package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
  204. package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
  205. package/src/runtime/channels/lib/session-discovery.mjs +56 -4
  206. package/src/runtime/channels/lib/telegram-format.mjs +19 -22
  207. package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
  208. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  209. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
  210. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
  211. package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
  212. package/src/runtime/channels/lib/webhook/deliveries.mjs +312 -0
  213. package/src/runtime/channels/lib/webhook/log.mjs +42 -0
  214. package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
  215. package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
  216. package/src/runtime/channels/lib/webhook.mjs +36 -570
  217. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  218. package/src/runtime/channels/tool-defs.mjs +11 -130
  219. package/src/runtime/memory/index.mjs +258 -2050
  220. package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
  221. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
  222. package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
  223. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  224. package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
  225. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  226. package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
  227. package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
  228. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
  229. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
  230. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
  231. package/src/runtime/memory/lib/memory-cycle2.mjs +72 -837
  232. package/src/runtime/memory/lib/memory-embed.mjs +149 -0
  233. package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
  234. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  235. package/src/runtime/memory/lib/memory-recall-store.mjs +22 -2
  236. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  237. package/src/runtime/memory/lib/memory.mjs +20 -0
  238. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
  239. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  240. package/src/runtime/memory/lib/query-handlers.mjs +780 -0
  241. package/src/runtime/memory/lib/recall-format.mjs +238 -0
  242. package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
  243. package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
  244. package/src/runtime/memory/tool-defs.mjs +6 -14
  245. package/src/runtime/search/lib/http-fetch.mjs +274 -0
  246. package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
  247. package/src/runtime/search/lib/web-tools.mjs +24 -602
  248. package/src/runtime/shared/abort-controller.mjs +1 -1
  249. package/src/runtime/shared/atomic-file.mjs +26 -1
  250. package/src/runtime/shared/background-tasks.mjs +2 -3
  251. package/src/runtime/shared/buffered-appender.mjs +149 -0
  252. package/src/runtime/shared/launcher-control.mjs +2 -2
  253. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  254. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  255. package/src/runtime/shared/tool-primitives.mjs +308 -0
  256. package/src/runtime/shared/tool-result-summary.mjs +515 -0
  257. package/src/runtime/shared/tool-surface.mjs +80 -898
  258. package/src/runtime/shared/transcript-writer.mjs +52 -2
  259. package/src/runtime/shared/update-checker.mjs +7 -4
  260. package/src/session-runtime/config-helpers.mjs +291 -0
  261. package/src/session-runtime/config-lifecycle.mjs +232 -0
  262. package/src/session-runtime/cwd-plugins.mjs +226 -0
  263. package/src/session-runtime/effort.mjs +128 -0
  264. package/src/session-runtime/fs-utils.mjs +10 -0
  265. package/src/session-runtime/mcp-glue.mjs +177 -0
  266. package/src/session-runtime/model-capabilities.mjs +130 -0
  267. package/src/session-runtime/model-recency.mjs +111 -0
  268. package/src/session-runtime/native-search.mjs +247 -0
  269. package/src/session-runtime/output-styles.mjs +126 -0
  270. package/src/session-runtime/plugin-mcp.mjs +114 -0
  271. package/src/session-runtime/prewarm.mjs +142 -0
  272. package/src/session-runtime/provider-models.mjs +278 -0
  273. package/src/session-runtime/provider-usage.mjs +120 -0
  274. package/src/session-runtime/quick-model-rows.mjs +170 -0
  275. package/src/session-runtime/quick-search-models.mjs +46 -0
  276. package/src/session-runtime/session-hooks.mjs +93 -0
  277. package/src/session-runtime/session-text.mjs +100 -0
  278. package/src/session-runtime/settings-api.mjs +319 -0
  279. package/src/session-runtime/statusline-route.mjs +35 -0
  280. package/src/session-runtime/tool-catalog.mjs +720 -0
  281. package/src/session-runtime/tool-defs.mjs +84 -0
  282. package/src/session-runtime/warmup-schedulers.mjs +201 -0
  283. package/src/session-runtime/workflow.mjs +358 -0
  284. package/src/standalone/agent-tool/helpers.mjs +237 -0
  285. package/src/standalone/agent-tool/notify.mjs +107 -0
  286. package/src/standalone/agent-tool/provider-init.mjs +143 -0
  287. package/src/standalone/agent-tool/render.mjs +152 -0
  288. package/src/standalone/agent-tool/tool-def.mjs +55 -0
  289. package/src/standalone/agent-tool.mjs +155 -677
  290. package/src/standalone/channel-worker.mjs +7 -9
  291. package/src/standalone/explore-tool.mjs +40 -12
  292. package/src/standalone/hook-bus/config.mjs +207 -0
  293. package/src/standalone/hook-bus/constants.mjs +90 -0
  294. package/src/standalone/hook-bus/handlers.mjs +481 -0
  295. package/src/standalone/hook-bus/payload.mjs +31 -0
  296. package/src/standalone/hook-bus/rules.mjs +77 -0
  297. package/src/standalone/hook-bus.mjs +110 -746
  298. package/src/standalone/memory-runtime-proxy.mjs +7 -0
  299. package/src/standalone/opencode-go-login.mjs +125 -0
  300. package/src/standalone/provider-admin.mjs +15 -19
  301. package/src/standalone/usage-dashboard.mjs +3 -1
  302. package/src/tui/App.jsx +1163 -7571
  303. package/src/tui/app/app-format.mjs +206 -0
  304. package/src/tui/app/channel-pickers.mjs +510 -0
  305. package/src/tui/app/clipboard.mjs +67 -0
  306. package/src/tui/app/core-memory-picker.mjs +210 -0
  307. package/src/tui/app/extension-pickers.mjs +506 -0
  308. package/src/tui/app/input-parsers.mjs +193 -0
  309. package/src/tui/app/maintenance-pickers.mjs +324 -0
  310. package/src/tui/app/model-options.mjs +330 -0
  311. package/src/tui/app/model-picker.mjs +365 -0
  312. package/src/tui/app/onboarding-steps.mjs +400 -0
  313. package/src/tui/app/project-picker.mjs +247 -0
  314. package/src/tui/app/provider-setup-picker.mjs +580 -0
  315. package/src/tui/app/resume-picker.mjs +55 -0
  316. package/src/tui/app/route-pickers.mjs +419 -0
  317. package/src/tui/app/settings-picker.mjs +490 -0
  318. package/src/tui/app/slash-commands.mjs +101 -0
  319. package/src/tui/app/slash-dispatch.mjs +427 -0
  320. package/src/tui/app/text-layout.mjs +46 -0
  321. package/src/tui/app/theme-effort-pickers.mjs +154 -0
  322. package/src/tui/app/transcript-window.mjs +671 -0
  323. package/src/tui/app/use-mouse-input.mjs +460 -0
  324. package/src/tui/app/use-prompt-handlers.mjs +310 -0
  325. package/src/tui/app/use-transcript-scroll.mjs +510 -0
  326. package/src/tui/app/use-transcript-window.mjs +589 -0
  327. package/src/tui/components/ConfirmBar.jsx +1 -1
  328. package/src/tui/components/Picker.jsx +32 -4
  329. package/src/tui/components/PromptInput.jsx +259 -80
  330. package/src/tui/components/SlashCommandPalette.jsx +8 -1
  331. package/src/tui/components/StatusLine.jsx +63 -12
  332. package/src/tui/components/TextEntryPanel.jsx +11 -0
  333. package/src/tui/components/ToolExecution.jsx +56 -588
  334. package/src/tui/components/TranscriptItem.jsx +105 -0
  335. package/src/tui/components/UsagePanel.jsx +18 -4
  336. package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
  337. package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
  338. package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
  339. package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
  340. package/src/tui/components/tool-execution/text-format.mjs +161 -0
  341. package/src/tui/components/tool-output-format.mjs +2 -2
  342. package/src/tui/display-width.mjs +20 -3
  343. package/src/tui/dist/index.mjs +18034 -17188
  344. package/src/tui/engine/agent-envelope.mjs +296 -0
  345. package/src/tui/engine/agent-job-feed.mjs +133 -0
  346. package/src/tui/engine/boot-profile.mjs +21 -0
  347. package/src/tui/engine/labels.mjs +67 -0
  348. package/src/tui/engine/notice-text.mjs +112 -0
  349. package/src/tui/engine/notification-plan.mjs +76 -0
  350. package/src/tui/engine/queue-helpers.mjs +161 -0
  351. package/src/tui/engine/render-timing.mjs +17 -0
  352. package/src/tui/engine/session-stats.mjs +46 -0
  353. package/src/tui/engine/tool-approval.mjs +94 -0
  354. package/src/tui/engine/tool-call-fields.mjs +23 -0
  355. package/src/tui/engine/tool-card-results.mjs +234 -0
  356. package/src/tui/engine/tool-result-status.mjs +135 -0
  357. package/src/tui/engine/tool-result-text.mjs +126 -0
  358. package/src/tui/engine.mjs +405 -1385
  359. package/src/tui/figures.mjs +5 -0
  360. package/src/tui/index.jsx +105 -0
  361. package/src/tui/input-editing.mjs +60 -10
  362. package/src/tui/keyboard-protocol.mjs +2 -2
  363. package/src/tui/lib/voice-recorder.mjs +35 -19
  364. package/src/tui/markdown/format-token.mjs +11 -9
  365. package/src/tui/paste-attachments.mjs +38 -0
  366. package/src/tui/statusline-ansi-bridge.mjs +11 -3
  367. package/src/tui/theme.mjs +6 -0
  368. package/src/tui/themes/base.mjs +2 -2
  369. package/src/tui/themes/kanagawa.mjs +4 -4
  370. package/src/tui/themes/teal.mjs +4 -5
  371. package/src/tui/themes/utils.mjs +1 -1
  372. package/src/ui/statusline-agents.mjs +213 -0
  373. package/src/ui/statusline-format.mjs +146 -0
  374. package/src/ui/statusline-segments.mjs +148 -0
  375. package/src/ui/statusline.mjs +77 -462
  376. package/src/ui/tool-card.mjs +0 -1
  377. package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
  378. package/src/workflows/default/WORKFLOW.md +16 -9
  379. package/src/workflows/sequential/WORKFLOW.md +16 -11
  380. package/src/workflows/solo/WORKFLOW.md +5 -1
  381. package/vendor/ink/build/display-width.js +19 -3
  382. package/vendor/ink/build/ink.js +103 -6
  383. package/vendor/ink/build/log-update.js +17 -3
  384. package/vendor/ink/build/wrap-text.js +125 -0
  385. package/scripts/_test-folder-dialog.mjs +0 -30
  386. package/scripts/fix-brief-fn.mjs +0 -35
  387. package/scripts/fix-format-tool-surface.mjs +0 -24
  388. package/scripts/fix-tool-exec-visible.mjs +0 -42
  389. package/scripts/patch-agent-brief.mjs +0 -48
  390. package/scripts/patch-app.mjs +0 -21
  391. package/scripts/patch-app2.mjs +0 -18
  392. package/scripts/patch-dist-brief.mjs +0 -96
  393. package/scripts/patch-tool-exec.mjs +0 -70
  394. package/src/examples/schedules/SCHEDULE.example.md +0 -32
  395. package/src/examples/webhooks/WEBHOOK.example.md +0 -40
  396. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
  397. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
  398. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
  399. package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
  400. package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
  401. package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
  402. package/src/standalone/agent-task-status.test.mjs +0 -76
  403. package/src/tui/components/tool-output-format.test.mjs +0 -399
  404. package/src/tui/display-width.test.mjs +0 -35
  405. package/src/tui/engine-runtime-notification.test.mjs +0 -115
  406. package/src/tui/engine-tool-result-text.test.mjs +0 -75
  407. package/src/tui/markdown/format-token.test.mjs +0 -354
  408. package/src/tui/markdown/render-ansi.test.mjs +0 -108
  409. package/src/tui/markdown/stream-fence.test.mjs +0 -26
  410. package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
  411. package/src/tui/prompt-history-store.test.mjs +0 -52
  412. package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
  413. package/src/tui/transcript-tool-failures.test.mjs +0 -111
  414. package/src/ui/markdown.test.mjs +0 -70
  415. package/src/ui/statusline-context-label.test.mjs +0 -15
  416. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
  417. package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
@@ -5,40 +5,58 @@
5
5
  * Raw HTTP + SSE streaming, reuses message/tool conversion patterns
6
6
  * from anthropic.mjs. agent-trace instrumented.
7
7
  */
8
- import { readFileSync, existsSync, statSync } from 'fs';
9
- import { join } from 'path';
10
- import { createServer } from 'http';
11
- import { randomBytes, createHash } from 'crypto';
8
+ import { randomBytes } from 'crypto';
12
9
  import {
13
10
  traceAgentFetch,
14
11
  traceAgentSse,
15
12
  traceAgentUsage,
16
13
  } from '../agent-trace.mjs';
17
14
  import { createAbortController } from '../../../shared/abort-controller.mjs';
18
- import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
19
- import { resolvePluginData } from '../../../shared/plugin-paths.mjs';
20
- import { enrichModels } from './model-catalog.mjs';
21
- import { makeModelCache } from './model-cache.mjs';
15
+ import { resolveAnthropicMaxTokens } from './anthropic-max-tokens.mjs';
16
+ import {
17
+ _loadModelCache,
18
+ _setInMemoryCatalog,
19
+ _catalogHas,
20
+ _displayModel,
21
+ _catalogOutputTokens,
22
+ normalizeAndSaveCatalog,
23
+ resolveLatestAnthropicModel,
24
+ resolveAnthropicModelAfter404,
25
+ ensureLatestAnthropicModel,
26
+ } from './anthropic-model-resolve.mjs';
22
27
  import { sanitizeToolPairs, sanitizeAnthropicContentPairs } from '../session/context-utils.mjs';
28
+ import {
29
+ TOKEN_REFRESH_SKEW_MS,
30
+ resolveCliVersion,
31
+ loadCredentials,
32
+ hasAnthropicOAuthCredentials,
33
+ describeAnthropicOAuthCredentials,
34
+ forgetAnthropicOAuthCredentials,
35
+ _scrubTokens,
36
+ _credentialsMaxMtime,
37
+ refreshOAuthCredentials,
38
+ beginOAuthLogin,
39
+ loginOAuth,
40
+ } from './anthropic-oauth-credentials.mjs';
23
41
  import {
24
42
  PROVIDER_FIRST_BYTE_TIMEOUT_MS,
25
43
  PROVIDER_HTTP_RESPONSE_TIMEOUT_MS,
26
44
  PROVIDER_RETRY_BACKOFF_MS,
27
45
  PROVIDER_RETRY_MAX_ATTEMPTS,
28
- PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
29
- PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
30
- streamStalledError,
31
46
  createPassthroughSignal,
32
47
  } from '../stall-policy.mjs';
33
48
  import {
34
49
  classifyError,
35
- classifyMidstreamError,
36
50
  midstreamBackoffFor,
37
- MIDSTREAM_RETRY_POLICY,
38
51
  retryAfterMsFromError,
39
- sleepWithAbort,
40
52
  withRetry,
41
53
  } from './retry-classifier.mjs';
54
+ import {
55
+ ANTHROPIC_MAX_MIDSTREAM_RETRIES,
56
+ parseSSEStream,
57
+ _classifyMidstreamError,
58
+ _midstreamSleepWithAbort,
59
+ } from './anthropic-sse.mjs';
42
60
  import { buildAnthropicBetaHeaders, supportsAnthropicFastMode } from './anthropic-betas.mjs';
43
61
  import {
44
62
  applyAnthropicEffortToBody,
@@ -47,33 +65,11 @@ import {
47
65
  } from './anthropic-effort.mjs';
48
66
  import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
49
67
  import { normalizeContentForAnthropic } from './media-normalization.mjs';
50
- import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
51
- import { scanLeakedToolCalls, createToolCallDedupe } from './anthropic-leaked-toolcall.mjs';
52
-
53
- // --- Model catalog cache helpers ---
54
- // Disk-backed cache so repeated process starts (cron, tool calls) don't
55
- // hammer /v1/models. 24h TTL matches the upstream client cadence.
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;
68
+
69
+ // --- Model catalog cache helpers: extracted to anthropic-model-resolve.mjs ---
60
70
  // SSE progress emits (per-request "Response …" and "Done:" lines). Off by default.
61
71
  const SSE_VERBOSE = process.env.MIXDOG_SSE_VERBOSE === '1';
62
72
 
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
- };
76
-
77
73
  function formatRetryAfter(ms) {
78
74
  if (ms == null) return '';
79
75
  const n = Number(ms);
@@ -102,26 +98,6 @@ function anthropicQuotaError(status, headers, bodyText = '') {
102
98
  return err;
103
99
  }
104
100
 
105
- const _modelCache = makeModelCache({
106
- fileName: 'anthropic-oauth-models.json',
107
- ttlMs: MODEL_CACHE_TTL_MS,
108
- version: ANTHROPIC_MODEL_CACHE_SCHEMA_VERSION,
109
- onSave: (m) => { _inMemoryCatalog = Array.isArray(m) ? m.slice() : null; },
110
- });
111
-
112
- // Async wrappers so callers can keep awaiting; the shared cache CRUD is sync.
113
- async function _loadModelCache() {
114
- return _modelCache.loadSync();
115
- }
116
-
117
- async function _saveModelCache(models) {
118
- _modelCache.save(models);
119
- }
120
-
121
- // In-memory mirror of the disk catalog — populated on first listModels() and
122
- // refreshed after every _saveModelCache. Used by _catalogHas and _displayModel
123
- // so hot paths don't hit disk on every response.
124
- let _inMemoryCatalog = null;
125
101
  let _modelRefreshInFlight = null;
126
102
  let _oauthRefreshInFlight = null;
127
103
  // No in-memory credential cache: the canonical credentials file is the
@@ -131,192 +107,14 @@ let _oauthRefreshInFlight = null;
131
107
  // disk on demand is cheap (one stat + one small JSON parse) and removes
132
108
  // the cache-vs-disk skew entirely.
133
109
 
134
-
135
- function _catalogHas(id) {
136
- if (!id || !Array.isArray(_inMemoryCatalog)) return false;
137
- return _inMemoryCatalog.some(m => m.id === id);
138
- }
139
-
140
- // Display-name normalization for trace / usage. Turns dated or version-alias
141
- // ids into the version alias form: claude-opus-4-7 → claude-opus-4.7,
142
- // claude-haiku-4-5-20251001 → claude-haiku-4.5. Falls back to the raw id.
143
- function _displayModel(id) {
144
- if (!id || typeof id !== 'string') return id;
145
- const m = id.match(/^claude-([a-z]+)-(\d+)(?:-(\d+))?(?:-\d{8})?$/i);
146
- if (!m) return id;
147
- return `claude-${m[1].toLowerCase()}-${m[2]}${m[3] ? `.${m[3]}` : ''}`;
148
- }
149
-
150
- function _capabilitySupported(capability) {
151
- return capability === true || capability?.supported === true;
152
- }
153
-
154
- // Classify a model id into our common tier/family shape. Anthropic's catalog
155
- // mixes dated ids (claude-opus-4-5-20251101), versioned aliases
156
- // (claude-opus-4-6), and the raw family tokens resolved via env vars.
157
- function _normalizeAnthropicModel(raw) {
158
- const id = raw?.id || raw?.name;
159
- if (!id) return null;
160
- const familyMatch = id.match(/^claude-([a-z]+)/i);
161
- const family = familyMatch ? familyMatch[1].toLowerCase() : 'other';
162
- // Dated: trailing -YYYYMMDD (8 digits).
163
- const dated = /-\d{8}$/.test(id);
164
- // Versioned alias: claude-<family>-<major>-<minor>[-...] with no dated suffix.
165
- const versioned = !dated && /^claude-[a-z]+-\d+(?:-\d+)?$/i.test(id);
166
- const tier = dated ? 'dated' : versioned ? 'version' : 'family';
167
- const releaseDate = dated
168
- ? id.match(/-(\d{4})(\d{2})(\d{2})$/)
169
- : null;
170
- const effortValues = effortValuesForModel(raw?.capabilities, id);
171
- return {
172
- id,
173
- display: raw?.display_name || _prettyName(id, family),
174
- family,
175
- provider: 'anthropic-oauth',
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,
178
- tier,
179
- latest: false, // assigned in a second pass once full list is known
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 }] : [],
183
- };
184
- }
185
-
186
- function _prettyName(id, family) {
187
- const v = id.match(/^claude-[a-z]+-(\d+)(?:-(\d+))?/i);
188
- const base = family[0].toUpperCase() + family.slice(1);
189
- return v ? `${base} ${v[1]}${v[2] ? `.${v[2]}` : ''}` : base;
190
- }
191
-
192
- function _defaultContextForModel(id, family) {
193
- const text = String(id || '');
194
- const version = text.match(/^claude-[a-z]+-(\d+)(?:-(\d+))?/i);
195
- if (Number(version?.[1] || 0) >= 5) return 1000000;
196
- if (/^claude-(opus|sonnet)-4-(6|7|8)(?:$|-)/i.test(text)) return 1000000;
197
- if (family && family !== 'other') return 200000;
198
- return 200000;
199
- }
200
-
201
- // Mark the highest-numbered version per family as `latest: true`. Uses a simple
202
- // lexicographic comparison on the numeric parts embedded in the id.
203
- function _markLatestByFamily(models) {
204
- const byFamily = new Map();
205
- for (const m of models) {
206
- if (m.tier !== 'version') continue;
207
- const cur = byFamily.get(m.family);
208
- if (!cur || _compareVersion(m.id, cur.id) > 0) {
209
- byFamily.set(m.family, m);
210
- }
211
- }
212
- for (const m of byFamily.values()) m.latest = true;
213
- }
214
-
215
- function _compareVersion(a, b) {
216
- const na = (a.match(/^claude-[a-z]+-(\d+)(?:-(\d+))?/i) || []).slice(1).map(Number);
217
- const nb = (b.match(/^claude-[a-z]+-(\d+)(?:-(\d+))?/i) || []).slice(1).map(Number);
218
- for (let i = 0; i < Math.max(na.length, nb.length); i++) {
219
- if ((na[i] || 0) !== (nb[i] || 0)) return (na[i] || 0) - (nb[i] || 0);
220
- }
221
- return a.localeCompare(b);
222
- }
223
-
224
- // Newest HIGH-TIER chat model by version, read from the SYNC in-memory catalog
225
- // mirror. Symmetric with resolveLatestGrokModel / resolveLatestCodexModel.
226
- // Anthropic ships three families: opus / sonnet / haiku. "Latest" is the
227
- // highest version across opus + sonnet only — haiku is the cheap tier and is
228
- // never the flagship default. Returns null until listModels() populates the
229
- // mirror; callers must warm the catalog (ensureLatestAnthropicModel) when null.
230
- export function resolveLatestAnthropicModel() {
231
- if (!Array.isArray(_inMemoryCatalog)) return null;
232
- let best = null;
233
- for (const m of _inMemoryCatalog) {
234
- if (!m?.id || (m.family !== 'opus' && m.family !== 'sonnet')) continue;
235
- if (!best || _compareVersion(m.id, best.id) > 0) best = m;
236
- }
237
- return best?.id || null;
238
- }
239
-
240
- function resolveAnthropicModelAfter404(requested) {
241
- if (!Array.isArray(_inMemoryCatalog)) return null;
242
- const wanted = String(requested || '');
243
- const family = (wanted.match(/^claude-([a-z]+)/i) || [])[1]?.toLowerCase() || null;
244
- let best = null;
245
- for (const m of _inMemoryCatalog) {
246
- if (!m?.id) continue;
247
- if (family && m.family !== family) continue;
248
- if (!family && m.family !== 'opus' && m.family !== 'sonnet') continue;
249
- if (!best || _compareVersion(m.id, best.id) > 0) best = m;
250
- }
251
- if (best?.id && best.id !== wanted) return best.id;
252
- if (family === 'opus') {
253
- const flagship = resolveLatestAnthropicModel();
254
- if (flagship && flagship !== wanted) return flagship;
255
- }
256
- return null;
257
- }
258
-
259
- export async function ensureLatestAnthropicModel(provider) {
260
- let m = resolveLatestAnthropicModel();
261
- if (m) return m;
262
- await provider._refreshModelCache();
263
- m = resolveLatestAnthropicModel();
264
- if (m) return m;
265
- throw new Error('[anthropic-oauth] model catalog unavailable after warmup — cannot resolve default model');
266
- }
267
-
268
110
  const API_URL = 'https://api.anthropic.com/v1/messages';
269
- // SSRF guard for the OAuth token endpoint override. Env-supplied URLs must be
270
- // https with a valid http(s) URL shape; reject file:/data:/ftp:/etc. and any
271
- // http override so a hostile env cannot redirect refresh-token requests.
272
- function assertSafeTokenURL(rawURL) {
273
- let parsed;
274
- try {
275
- parsed = new URL(String(rawURL));
276
- } catch {
277
- throw new Error(`[anthropic-oauth] invalid ANTHROPIC_OAUTH_TOKEN_URL: ${rawURL}`);
278
- }
279
- if (parsed.protocol.toLowerCase() !== 'https:') {
280
- throw new Error(`[anthropic-oauth] ANTHROPIC_OAUTH_TOKEN_URL must use https (got ${parsed.protocol})`);
281
- }
282
- return rawURL;
283
- }
284
- const TOKEN_URL = assertSafeTokenURL(process.env.ANTHROPIC_OAUTH_TOKEN_URL || 'https://platform.claude.com/v1/oauth/token');
285
111
  const ANTHROPIC_VERSION = '2023-06-01';
286
- const DEFAULT_CREDENTIALS_PATH = join(resolvePluginData(), 'anthropic-oauth-credentials.json');
287
- const CLAUDE_CODE_CLIENT_ID = process.env.ANTHROPIC_OAUTH_CLIENT_ID || '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
288
- const TOKEN_REFRESH_SKEW_MS = 5 * 60_000;
289
- const CLAUDE_AI_AUTHORIZE_URL = 'https://claude.com/cai/oauth/authorize';
290
- const ALL_OAUTH_SCOPES = [
291
- 'org:create_api_key',
292
- 'user:profile',
293
- 'user:inference',
294
- 'user:sessions:claude_code',
295
- 'user:mcp_servers',
296
- 'user:file_upload',
297
- ];
298
- const OAUTH_LOGIN_SCOPE = ALL_OAUTH_SCOPES.join(' ');
299
- const OAUTH_CALLBACK_HOST = 'localhost';
300
- const OAUTH_CALLBACK_PORT = 54545;
301
- const OAUTH_CALLBACK_PATH = '/callback';
302
- const OAUTH_REDIRECT_URI = `http://${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`;
303
- const OAUTH_MANUAL_REDIRECT_URI = process.env.ANTHROPIC_OAUTH_MANUAL_REDIRECT_URI || 'https://platform.claude.com/oauth/code/callback';
304
- const OAUTH_SUCCESS_REDIRECT_URL = process.env.ANTHROPIC_OAUTH_SUCCESS_REDIRECT_URL || 'https://platform.claude.com/oauth/code/success?app=claude-code';
305
- const OAUTH_LOGIN_TIMEOUT_MS = 5 * 60_000;
306
- const OAUTH_TOKEN_TIMEOUT_MS = 30_000;
307
-
308
- // Anthropic OAuth contract for first-party OAuth clients.
309
- // Opus/Sonnet requests are gated on a specific system-prompt prefix.
310
- // Mixdog keeps that upstream client contract for OAuth routing. Haiku is not
112
+
113
+ // Anthropic OAuth contract for first-party OAuth clients: Opus/Sonnet
114
+ // requests are gated on this exact system-prompt prefix. Haiku is not
311
115
  // gated and ignores this prefix.
312
116
  const CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude.";
313
- const OAUTH_BETA_HEADERS = 'oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,extended-cache-ttl-2025-04-11,advanced-tool-use-2025-11-20';
314
- const DEFAULT_CLI_VERSION = '2.1.77';
315
-
316
- function resolveCliVersion() {
317
- return process.env.MIXDOG_CLI_VERSION
318
- || DEFAULT_CLI_VERSION;
319
- }
117
+ const OAUTH_BETA_HEADERS = 'oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,extended-cache-ttl-2025-04-11';
320
118
 
321
119
  function requiresSystemPrefix(model) {
322
120
  // High-tier Claude OAuth models require the first-party system prefix for
@@ -364,98 +162,36 @@ function buildSystemBlocks(systemMsgs, model, systemTtl, tier3Ttl) {
364
162
  // Apply per-tier cache_control. BP1/BP2 -> systemTtl, BP3 -> tier3Ttl. The
365
163
  // gating prefix block is never cached (Anthropic routes on its exact bytes).
366
164
  // tier3Ttl === null leaves the 3rd block uncached (e.g. maintenance roles).
165
+ // Anthropic caps cache_control breakpoints at 4 per request; defensively
166
+ // cap it here too so an unexpectedly large systemMsgs array can never
167
+ // mark more than 4 blocks (extras keep their text, just lose the
168
+ // cache_control breakpoint, not the block itself).
169
+ const MAX_SYSTEM_BREAKPOINTS = 4;
170
+ let bpCount = 0;
367
171
  for (const b of blocks) {
368
172
  const tier = b._tier;
369
173
  delete b._tier;
370
174
  if (b.text === CLAUDE_CODE_SYSTEM_PREFIX) continue;
371
175
  const ttl = tier === 'tier3' ? tier3Ttl : systemTtl;
372
- if (ttl) b.cache_control = ttl;
373
- }
374
- return blocks;
375
- }
376
-
377
- // Per-model max_tokens when the model id is explicitly listed. New models
378
- // (e.g., Sonnet 4.7) won't match a specific entry and fall through to the
379
- // family-based heuristic below. Conservative defaults — model may support
380
- // more but we'd rather stay within safe bounds.
381
- const MAX_TOKENS = {
382
- 'claude-opus-4-8': 65536,
383
- 'claude-opus-4-7': 65536,
384
- 'claude-opus-4-6': 65536,
385
- 'claude-sonnet-4-6': 16384,
386
- 'claude-haiku-4-5-20251001': 8192,
387
- };
388
-
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();
176
+ if (ttl && bpCount < MAX_SYSTEM_BREAKPOINTS) {
177
+ b.cache_control = ttl;
178
+ bpCount++;
418
179
  }
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
180
  }
181
+ return blocks;
426
182
  }
427
183
 
428
- function _fallbackMaxTokens(model) {
429
- if (MAX_TOKENS[model]) return MAX_TOKENS[model];
430
- const id = String(model || '').toLowerCase();
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;
439
- if (id.includes('sonnet')) return 16384;
440
- if (id.includes('haiku')) return 8192;
441
- return 8192;
442
- }
443
-
444
- // resolveMaxTokens: catalog-driven max_tokens for a model id.
184
+ // resolveMaxTokens: catalog-driven max_tokens for a model id. Thin wrapper
185
+ // around the shared anthropic-max-tokens helper (also used by the API-key
186
+ // twin in anthropic.mjs) this provider supplies its own in-memory-mirror-
187
+ // first catalog lookup strategy (see anthropic-model-resolve.mjs).
445
188
  // 1. MIXDOG_ANTHROPIC_MAX_OUTPUT_TOKENS env override, if set, wins outright.
446
189
  // 2. Catalog outputTokens (trusted over hardcoded heuristics when present),
447
190
  // clamped to [MAX_TOKENS_FLOOR, safetyCap].
448
191
  // 3. Static MAX_TOKENS table / family heuristic fallback when the catalog
449
192
  // has no entry for this model, also clamped to the safety cap.
450
193
  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);
194
+ return resolveAnthropicMaxTokens(model, { catalogLookup: _catalogOutputTokens });
459
195
  }
460
196
 
461
197
  const MIN_THINKING_BUDGET = 1024;
@@ -475,250 +211,7 @@ function clampThinkingBudgetTokens(value, maxTokens) {
475
211
  const CACHE_TTL_STABLE = { type: 'ephemeral', ttl: '1h' }; // tools, system, tier3, messages
476
212
  const CACHE_TTL_VOLATILE = { type: 'ephemeral' }; // explicit 5m override
477
213
 
478
- // --- Credential helpers ---
479
-
480
- function _pushUnique(list, value) {
481
- if (!value || typeof value !== 'string') return;
482
- if (!list.includes(value)) list.push(value);
483
- }
484
-
485
- function credentialCandidates() {
486
- const paths = [];
487
- _pushUnique(paths, process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH);
488
- _pushUnique(paths, DEFAULT_CREDENTIALS_PATH);
489
- return paths;
490
- }
491
-
492
- // Fallback expiry from the access_token's JWT `exp` claim (epoch ms) when the
493
- // credentials file carries no explicit expiresAt — without it expiresAt stays 0,
494
- // which ensureAuth reads as "never expires", disabling proactive refresh. Claude
495
- // OAuth tokens are opaque so this returns 0 and the file's expiresAt governs; kept
496
- // for parity with the other OAuth providers. JWT `exp` is epoch SECONDS (RFC 7519).
497
- function _expiryFromAccessToken(token) {
498
- try {
499
- const parts = String(token || '').split('.');
500
- if (parts.length !== 3) return 0;
501
- const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf-8'));
502
- const exp = Number(payload?.exp);
503
- return Number.isFinite(exp) && exp > 0 ? exp * 1000 : 0;
504
- } catch { return 0; }
505
- }
506
-
507
- function _loadCredentialsFile(path) {
508
- if (!existsSync(path)) return null;
509
- try {
510
- const stat = statSync(path);
511
- const raw = JSON.parse(readFileSync(path, 'utf-8'));
512
- const oauth = raw?.claudeAiOauth;
513
- if (!oauth?.accessToken) return null;
514
- return {
515
- path,
516
- mtimeMs: stat.mtimeMs,
517
- accessToken: oauth.accessToken,
518
- refreshToken: oauth.refreshToken || null,
519
- expiresAt: _normalizeExpiresAt(oauth.expiresAt ?? oauth.expires_at) || _expiryFromAccessToken(oauth.accessToken),
520
- scopes: Array.isArray(oauth.scopes) ? oauth.scopes : [],
521
- subscriptionType: oauth.subscriptionType || null,
522
- };
523
- } catch {
524
- return null;
525
- }
526
- }
527
-
528
- // Cross-process safe credential save. Lockfile (O_EXCL) prevents two Mixdog
529
- // refreshers from clobbering each other; atomic rename guarantees readers see
530
- // either the old or new file, never a half-written one. Used so refresh_token
531
- // rotation propagates to other Mixdog readers of the same credentials file
532
- // instead of leaving them stuck on the previous refresh_token.
533
- function _saveCredentialsFile(path, raw) {
534
- // Secret file, not parent-dir ACL mutation. `secret: true` clamps the file
535
- // itself on Windows; it deliberately leaves the data dir inheritance alone.
536
- writeJsonAtomicSync(path, raw, { lock: true, fsyncDir: true, mode: 0o600, secret: true });
537
- }
538
-
539
- // Cheap stat-only probe so ensureAuth can detect Mixdog-updated credentials
540
- // without paying a full JSON read every call.
541
- function _credentialsMaxMtime() {
542
- let max = 0;
543
- for (const p of credentialCandidates()) {
544
- try {
545
- const s = statSync(p);
546
- if (s.mtimeMs > max) max = s.mtimeMs;
547
- } catch { /* not present — skip */ }
548
- }
549
- return max;
550
- }
551
-
552
- function loadCredentials() {
553
- const loaded = credentialCandidates()
554
- .map(_loadCredentialsFile)
555
- .filter(Boolean);
556
- if (!loaded.length) return null;
557
- loaded.sort((a, b) => (Number(b.expiresAt) || 0) - (Number(a.expiresAt) || 0));
558
- return loaded[0];
559
- }
560
-
561
- // Public predicate used by config.buildDefaultConfig — provider is enabled
562
- // when on-disk credentials exist AND carry the inference scope. Single
563
- // truth: same loader the runtime uses, no parallel hard-coded path probe.
564
- export function hasAnthropicOAuthCredentials() {
565
- const creds = loadCredentials();
566
- if (!creds?.accessToken) return false;
567
- return Array.isArray(creds.scopes) && creds.scopes.includes('user:inference');
568
- }
569
-
570
- export function describeAnthropicOAuthCredentials() {
571
- try {
572
- const creds = loadCredentials();
573
- if (!creds?.accessToken) {
574
- return { authenticated: false, status: 'Not Set', detail: 'Mixdog OAuth credentials' };
575
- }
576
- const hasInferenceScope = Array.isArray(creds.scopes) && creds.scopes.includes('user:inference');
577
- const hasRefresh = Boolean(creds.refreshToken);
578
- const expiresAt = _normalizeExpiresAt(creds.expiresAt);
579
- const expiring = expiresAt > 0 && expiresAt < Date.now() + TOKEN_REFRESH_SKEW_MS;
580
- const expired = expiresAt > 0 && expiresAt <= Date.now();
581
- const detail = creds.path || DEFAULT_CREDENTIALS_PATH;
582
- if (!hasInferenceScope) {
583
- return { authenticated: false, status: 'Missing Scope', detail, expiresAt };
584
- }
585
- if (!hasRefresh) {
586
- return {
587
- authenticated: expiresAt === 0 || !expired,
588
- status: expired ? 'Reauth Required' : 'Access Only',
589
- detail: `${detail}; no refresh token`,
590
- expiresAt,
591
- };
592
- }
593
- if (expired) return { authenticated: true, status: 'Refresh Required', detail, expiresAt };
594
- if (expiring) return { authenticated: true, status: 'Refresh Soon', detail, expiresAt };
595
- return { authenticated: true, status: 'Valid', detail, expiresAt };
596
- } catch (err) {
597
- return { authenticated: false, status: 'Error', detail: String(err?.message || err).slice(0, 200) };
598
- }
599
- }
600
-
601
- export function forgetAnthropicOAuthCredentials() {
602
- let removed = false;
603
- for (const path of credentialCandidates()) {
604
- if (!existsSync(path)) continue;
605
- try {
606
- const raw = JSON.parse(readFileSync(path, 'utf-8'));
607
- if (raw?.claudeAiOauth) {
608
- delete raw.claudeAiOauth;
609
- _saveCredentialsFile(path, raw);
610
- removed = true;
611
- }
612
- } catch (err) {
613
- throw new Error(`Anthropic OAuth reset failed for ${path}: ${err?.message || err}`);
614
- }
615
- }
616
- return { removed };
617
- }
618
-
619
- function _normalizeExpiresAt(value) {
620
- if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return 0;
621
- return value < 1e12 ? value * 1000 : value;
622
- }
623
-
624
- function _scrubTokens(text) {
625
- return String(text || '')
626
- .replace(/Bearer [A-Za-z0-9._\-]+/g, 'Bearer [REDACTED]')
627
- .replace(/sk-ant-[A-Za-z0-9._\-]+/g, '[REDACTED]')
628
- .replace(/"access[Tt]oken"\s*:\s*"[^"]+"/g, '"accessToken":"[REDACTED]"')
629
- .replace(/"refresh[Tt]oken"\s*:\s*"[^"]+"/g, '"refreshToken":"[REDACTED]"')
630
- .replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token":"[REDACTED]"')
631
- .replace(/"refresh_token"\s*:\s*"[^"]+"/g, '"refresh_token":"[REDACTED]"');
632
- }
633
-
634
- async function refreshOAuthCredentials(creds) {
635
- if (!creds?.refreshToken) {
636
- throw new Error('Anthropic OAuth refresh token not available. Open /providers in mixdog to sign in again.');
637
- }
638
-
639
- const controller = new AbortController();
640
- const timeout = setTimeout(() => controller.abort(), 30_000);
641
- try {
642
- const res = await fetch(TOKEN_URL, {
643
- method: 'POST',
644
- headers: {
645
- 'Content-Type': 'application/json',
646
- 'anthropic-dangerous-direct-browser-access': 'true',
647
- 'user-agent': `claude-cli/${resolveCliVersion()} (external, sdk-cli)`,
648
- },
649
- body: JSON.stringify({
650
- grant_type: 'refresh_token',
651
- refresh_token: creds.refreshToken,
652
- client_id: CLAUDE_CODE_CLIENT_ID,
653
- }),
654
- // Never follow a redirect on a secret-bearing request: a token
655
- // endpoint that 307/308-redirects would replay the refresh_token to
656
- // the redirect target. Fail loud instead.
657
- redirect: 'error',
658
- signal: controller.signal,
659
- dispatcher: getLlmDispatcher(),
660
- });
661
-
662
- const text = await res.text();
663
- let json = null;
664
- try { json = text ? JSON.parse(text) : null; } catch { /* handled below */ }
665
- if (!res.ok) {
666
- const isInvalidGrant = text.includes('invalid_grant') || json?.error === 'invalid_grant';
667
- throw Object.assign(new Error(`token refresh ${res.status}: ${_scrubTokens(text).slice(0, 200)}`), { isInvalidGrant });
668
- }
669
-
670
- const accessToken = json?.access_token || json?.accessToken;
671
- if (!accessToken) throw new Error('token refresh returned no access token');
672
- const expiresAt = _normalizeExpiresAt(json?.expires_at ?? json?.expiresAt)
673
- || (typeof json?.expires_in === 'number' ? Date.now() + json.expires_in * 1000 : 0);
674
- const refreshed = {
675
- path: creds.path,
676
- accessToken,
677
- refreshToken: json?.refresh_token || json?.refreshToken || creds.refreshToken,
678
- expiresAt,
679
- scopes: Array.isArray(json?.scope) ? json.scope : creds.scopes,
680
- subscriptionType: creds.subscriptionType,
681
- };
682
- // Persist rotated tokens back so any other Mixdog reader of the same
683
- // credentials file picks up the new refresh_token. Without this, a
684
- // later process can replay an old single-use refresh token and loop on
685
- // invalid_grant.
686
- if (creds.path && existsSync(creds.path)) {
687
- try {
688
- const raw = JSON.parse(readFileSync(creds.path, 'utf-8'));
689
- raw.claudeAiOauth = {
690
- ...(raw.claudeAiOauth || {}),
691
- accessToken: refreshed.accessToken,
692
- refreshToken: refreshed.refreshToken,
693
- expiresAt: refreshed.expiresAt,
694
- scopes: refreshed.scopes,
695
- };
696
- _saveCredentialsFile(creds.path, raw);
697
- } catch (err) {
698
- process.stderr.write(`[anthropic-oauth] credential save failed: ${_scrubTokens(err?.message || String(err)).slice(0, 200)}\n`);
699
- throw new Error(`[oauth] credentials save failed: ${err?.message ?? String(err)}`);
700
- }
701
- }
702
- return refreshed;
703
- } catch (err) {
704
- if (err?.name === 'AbortError') {
705
- throw new Error('Anthropic OAuth token refresh timed out after 30000ms');
706
- }
707
- throw err;
708
- } finally {
709
- clearTimeout(timeout);
710
- }
711
- }
712
-
713
- // Exported so callers can detect re-auth-required scenarios and prompt the user.
714
- export class ReauthRequired extends Error {
715
- constructor(message) {
716
- super(message);
717
- this.name = 'ReauthRequired';
718
- }
719
- }
720
-
721
- // --- Message conversion (mirrors anthropic.mjs) ---
214
+ // --- Message conversion ---
722
215
 
723
216
  function withCacheControl(block, ttl = CACHE_TTL_VOLATILE) {
724
217
  if (!block || typeof block !== 'object' || block.cache_control) return block;
@@ -965,575 +458,7 @@ function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_
965
458
  return sanitizedMessages;
966
459
  }
967
460
 
968
- // --- SSE parser ---
969
-
970
- function _captureMidstreamAbort(state, reason) {
971
- if (!state) return;
972
- const reasonName = reason?.name || '';
973
- if (reasonName === 'AgentStallAbortError' || reasonName === 'StreamStalledAbortError') {
974
- state.watchdogAbort = reasonName;
975
- } else {
976
- state.userAbort = true;
977
- }
978
- }
979
-
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');
984
- }
985
-
986
- function _statusForAnthropicSseError(type, message) {
987
- const kind = String(type || '').toLowerCase();
988
- const text = String(message || '').toLowerCase();
989
- if (kind.includes('overload') || text.includes('overload')) return 503;
990
- if (kind.includes('rate_limit') || text.includes('rate limit') || text.includes('quota')) return 429;
991
- if (kind.includes('authentication') || text.includes('authentication') || text.includes('unauthorized')) return 401;
992
- if (kind.includes('permission') || text.includes('forbidden')) return 403;
993
- if (kind.includes('not_found') || text.includes('not found')) return 404;
994
- if (kind.includes('invalid_request')) return 400;
995
- return 0;
996
- }
997
-
998
- function _anthropicSseError(event) {
999
- const payload = event?.error && typeof event.error === 'object' ? event.error : event;
1000
- const type = payload?.type || event?.type || 'error';
1001
- const message = payload?.message || 'Anthropic SSE error';
1002
- const err = new Error(`Anthropic OAuth SSE error ${type}: ${message}`);
1003
- err.name = 'AnthropicSseError';
1004
- err.code = 'EANTHROPIC_SSE_ERROR';
1005
- err.providerErrorType = type;
1006
- err.requestId = event?.request_id || event?.requestId || null;
1007
- const status = _statusForAnthropicSseError(type, message);
1008
- if (status) {
1009
- err.httpStatus = status;
1010
- err.status = status;
1011
- }
1012
- return err;
1013
- }
1014
-
1015
- async function parseSSEStream(response, signal, abortStream, onStreamDelta, onToolCall, state, onTextDelta, knownToolNames) {
1016
- const reader = response.body.getReader();
1017
- const decoder = new TextDecoder();
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;
1032
- let content = '';
1033
- let hasThinkingContent = false;
1034
- const contentBlockTypes = new Set();
1035
- let model = '';
1036
- let toolCalls = [];
1037
- let usage = { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, raw: null };
1038
- let stopReason = null;
1039
- let buffer = '';
1040
- let idleTimedOut = false;
1041
- let firstMessageTimedOut = false;
1042
- let idleTimer = null;
1043
- let firstMessageTimer = null;
1044
- let currentEvent = '';
1045
-
1046
- const pendingToolInputs = new Map();
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
-
1106
- // Holds the in-flight reader.read() race rejector so the idle timer can
1107
- // force-unblock the loop even when reader.cancel() fails to settle the
1108
- // pending read (undici half-open socket). See resetIdleTimer below.
1109
- let idleReject = null;
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
-
1171
- const resetIdleTimer = () => {
1172
- // OFF by default. When disabled the
1173
- // idle timer never arms, so the stream is never killed on inactivity;
1174
- // the agent stall watchdog (600s) remains the dead-stream backstop.
1175
- if (!PROVIDER_SSE_IDLE_WATCHDOG_ENABLED) return;
1176
- if (idleTimer) clearTimeout(idleTimer);
1177
- idleTimer = setTimeout(() => {
1178
- idleTimedOut = true;
1179
- try { abortStream?.(); } catch (err) {
1180
- try { process.stderr.write(`[anthropic-oauth] sse idle abortStream failed: ${err?.message ?? String(err)}\n`); } catch {}
1181
- }
1182
- try {
1183
- const _c = reader.cancel('SSE idle timeout');
1184
- if (_c && typeof _c.catch === 'function') _c.catch(() => {});
1185
- } catch (err) {
1186
- try { process.stderr.write(`[anthropic-oauth] sse idle cancel failed: ${err?.message ?? String(err)}\n`); } catch {}
1187
- }
1188
- // Force-reject the in-flight reader.read() race even when reader.cancel()
1189
- // fails to settle the pending read: without this the await below stays
1190
- // pending forever and the SSE idle timeout never unblocks the loop —
1191
- // the 391s-hang root cause.
1192
- if (idleReject) {
1193
- const e = _attachStallPartial(streamStalledError('Anthropic OAuth SSE', SSE_IDLE_TIMEOUT_MS, { emittedToolCall: !!state?.emittedToolCall }));
1194
- const r = idleReject; idleReject = null; r(e);
1195
- }
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.
1199
- }, SSE_IDLE_TIMEOUT_MS);
1200
- try { idleTimer.unref?.(); } catch {}
1201
- };
1202
-
1203
- const onAbort = () => {
1204
- try {
1205
- const _c = reader.cancel('SSE aborted');
1206
- if (_c && typeof _c.catch === 'function') _c.catch(() => {});
1207
- } catch {}
1208
- };
1209
- if (signal) {
1210
- if (signal.aborted) {
1211
- _captureMidstreamAbort(state, signal.reason);
1212
- throw signal.reason instanceof Error
1213
- ? signal.reason
1214
- : new Error('Anthropic OAuth SSE stream aborted');
1215
- }
1216
- signal.addEventListener('abort', onAbort, { once: true });
1217
- }
1218
-
1219
- try {
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();
1227
- streamLoop: while (true) {
1228
- let chunk;
1229
- try {
1230
- // Race the read against the idle timer's rejector so a stuck
1231
- // reader.read() (cancel did not settle it) still unblocks here.
1232
- chunk = await new Promise((resolve, reject) => {
1233
- idleReject = reject;
1234
- reader.read().then(resolve, reject);
1235
- });
1236
- } catch (err) {
1237
- if (idleTimedOut) {
1238
- throw _attachStallPartial(streamStalledError('Anthropic OAuth SSE', SSE_IDLE_TIMEOUT_MS, { emittedToolCall: !!state?.emittedToolCall }));
1239
- }
1240
- if (firstMessageTimedOut) {
1241
- throw firstMessageTimeoutError();
1242
- }
1243
- if (signal?.aborted) {
1244
- _captureMidstreamAbort(state, signal.reason);
1245
- throw signal.reason instanceof Error
1246
- ? signal.reason
1247
- : new Error('Anthropic OAuth SSE stream aborted');
1248
- }
1249
- throw err;
1250
- }
1251
- const { done, value } = chunk;
1252
- if (done) break;
1253
-
1254
- buffer += decoder.decode(value, { stream: true });
1255
- const lines = buffer.split('\n');
1256
- buffer = lines.pop() || '';
1257
-
1258
- for (const line of lines) {
1259
- if (line.startsWith(':')) {
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.
1266
- continue;
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;
1273
- if (line.startsWith('event: ')) {
1274
- currentEvent = line.slice(7).trim();
1275
- continue;
1276
- }
1277
- if (!line.startsWith('data: ')) continue;
1278
- const data = line.slice(6).trim();
1279
- if (!data) continue;
1280
-
1281
- try {
1282
- const event = JSON.parse(data);
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
-
1296
- if (currentEvent === 'error' || event?.type === 'error' || event?.error) {
1297
- throw _anthropicSseError(event);
1298
- }
1299
-
1300
- if (event.type === 'message_start' && event.message) {
1301
- clearFirstMessageTimer();
1302
- if (state) state.sawMessageStart = true;
1303
- if (event.message.model) model = event.message.model;
1304
- if (event.message.usage) {
1305
- usage.inputTokens = event.message.usage.input_tokens || 0;
1306
- usage.cachedTokens = event.message.usage.cache_read_input_tokens || 0;
1307
- usage.cacheWriteTokens = event.message.usage.cache_creation_input_tokens || 0;
1308
- usage.raw = { ...event.message.usage };
1309
- }
1310
- }
1311
-
1312
- if (event.type === 'content_block_start') {
1313
- const block = event.content_block;
1314
- if (block?.type === 'tool_use') {
1315
- pendingToolInputs.set(event.index, {
1316
- id: block.id || '',
1317
- name: block.name || '',
1318
- inputJson: '',
1319
- });
1320
- }
1321
- }
1322
-
1323
- if (event.type === 'content_block_delta') {
1324
- const delta = event.delta;
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();
1332
- if (delta?.type === 'text_delta') {
1333
- try { onStreamDelta?.(); } catch {}
1334
- // Live text relay (gateway): forward the explicit
1335
- // text chunk. thinking/signature/input_json deltas
1336
- // intentionally stay off this path.
1337
- // Invariant: once a non-empty chunk has been relayed
1338
- // live it cannot be withdrawn, so flag the attempt so
1339
- // the mid-stream retry loop treats any later failure
1340
- // as final (a retry would concatenate attempts).
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
- }
1356
- }
1357
- }
1358
- if (delta?.type === 'thinking_delta' || delta?.type === 'signature_delta') {
1359
- // Extended-thinking block: provider reasoning without
1360
- // user-visible text. Track presence so a final turn
1361
- // that emitted ONLY thinking (no text_delta, no
1362
- // tool_use) can be classified by the loop as
1363
- // synthesis-stalled rather than silent empty.
1364
- hasThinkingContent = true;
1365
- try { onStreamDelta?.(); } catch {}
1366
- }
1367
- if (delta?.type === 'input_json_delta') {
1368
- const pending = pendingToolInputs.get(event.index);
1369
- if (pending) {
1370
- pending.inputJson += delta.partial_json || '';
1371
- }
1372
- try { onStreamDelta?.(); } catch {}
1373
- }
1374
- }
1375
-
1376
- if (event.type === 'content_block_stop') {
1377
- const pending = pendingToolInputs.get(event.index);
1378
- if (pending) {
1379
- // Bare JSON.parse threw straight up into the
1380
- // surrounding broad catch, which swallowed the
1381
- // whole tool_call — the loop never saw it and
1382
- // the assistant turn ended with an unmatched
1383
- // tool_use id. Wrap the parse so a malformed
1384
- // input still produces a tool_call (with an
1385
- // invalid-args marker and a logged error) instead
1386
- // of a silent drop or accidental `{}` dispatch.
1387
- let parsedArgs = {};
1388
- if (pending.inputJson) {
1389
- try { parsedArgs = JSON.parse(pending.inputJson); }
1390
- catch (parseErr) {
1391
- process.stderr.write(`[anthropic-oauth] tool args JSON.parse failed (id=${pending.id}, name=${pending.name}): ${parseErr?.message || parseErr}\n`);
1392
- parsedArgs = makeInvalidToolArgsMarker(pending.inputJson, parseErr instanceof Error ? parseErr.message : String(parseErr));
1393
- }
1394
- }
1395
- // Tool arguments must be a plain object. Anthropic's
1396
- // tool_use input is always a JSON object, but a
1397
- // malformed stream could parse to an array/string/
1398
- // number — wrap those as {} to keep the contract
1399
- // (invariant-based, no heuristic coercion).
1400
- if (parsedArgs === null
1401
- || typeof parsedArgs !== 'object'
1402
- || Array.isArray(parsedArgs)) {
1403
- process.stderr.write(`[anthropic-oauth] tool args not a plain object (id=${pending.id}, name=${pending.name}, type=${Array.isArray(parsedArgs) ? 'array' : typeof parsedArgs}); using {}\n`);
1404
- parsedArgs = {};
1405
- }
1406
- const call = {
1407
- id: pending.id,
1408
- name: pending.name,
1409
- arguments: parsedArgs,
1410
- };
1411
- pendingToolInputs.delete(event.index);
1412
- // Eager dispatch: let the loop start this tool
1413
- // before message_stop arrives. The loop keys
1414
- // pending promises by call.id so order is safe.
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
- }
1430
- try { onStreamDelta?.(); } catch {}
1431
- }
1432
- }
1433
-
1434
- if (event.type === 'message_delta') {
1435
- if (event.delta?.stop_reason) {
1436
- stopReason = event.delta.stop_reason;
1437
- }
1438
- if (event.usage) {
1439
- usage.outputTokens = event.usage.output_tokens || 0;
1440
- usage.raw = { ...(usage.raw || {}), ...event.usage };
1441
- }
1442
- if (stopReason === 'tool_use' && toolCalls.length > 0 && pendingToolInputs.size === 0) {
1443
- if (state) state.sawCompleted = true;
1444
- break streamLoop;
1445
- }
1446
- }
1447
- if (event.type === 'message_stop') {
1448
- if (state) state.sawCompleted = true;
1449
- // Anthropic streams can keep emitting `:ping` keepalive
1450
- // frames after `message_stop`; if we wait for EOF the
1451
- // outer reader.read() loop hangs indefinitely. Break
1452
- // out of streamLoop the moment the message ends.
1453
- break streamLoop;
1454
- }
1455
- // Unified prompt volume — what the model actually ingested.
1456
- // Anthropic splits input into three billable slots (uncached
1457
- // input + cache_read + cache_create); keep them separate for
1458
- // cost math but also expose the sum so cross-provider logs
1459
- // have a consistent `promptTokens` meaning.
1460
- usage.promptTokens = (usage.inputTokens || 0)
1461
- + (usage.cachedTokens || 0)
1462
- + (usage.cacheWriteTokens || 0);
1463
- } catch (err) {
1464
- if (err?.code === 'EANTHROPIC_SSE_ERROR') throw err;
1465
- /* skip malformed events */
1466
- }
1467
- }
1468
- }
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
-
1476
- // Truncated-stream guard: if the reader loop exited (EOF or break)
1477
- // after message_start but without seeing message_stop / a tool_use
1478
- // stop_reason, the assistant turn was cut off mid-flight. Returning
1479
- // success here would silently surface partial content (or a partially
1480
- // streamed tool_use whose input_json never completed) as final.
1481
- // Throw a typed truncated-stream error so the loop can decide whether
1482
- // to retry, surface, or escalate instead of accepting the partial.
1483
- if (state?.sawMessageStart && !state?.sawCompleted) {
1484
- const pendingToolUse = pendingToolInputs.size > 0;
1485
- const err = Object.assign(
1486
- new Error(
1487
- `Anthropic OAuth SSE stream truncated: message_start without message_stop`
1488
- + (pendingToolUse ? ` (pending tool_use input)` : ''),
1489
- ),
1490
- {
1491
- name: 'TruncatedStreamError',
1492
- code: 'TRUNCATED_STREAM',
1493
- truncatedStream: true,
1494
- pendingToolUse,
1495
- stopReason,
1496
- },
1497
- );
1498
- throw err;
1499
- }
1500
-
1501
- return {
1502
- content,
1503
- model,
1504
- toolCalls: toolCalls.length ? toolCalls : undefined,
1505
- usage,
1506
- stopReason,
1507
- hasThinkingContent,
1508
- contentBlockTypes: Array.from(contentBlockTypes),
1509
- };
1510
- } finally {
1511
- if (idleTimer) clearTimeout(idleTimer);
1512
- clearFirstMessageTimer();
1513
- if (signal) signal.removeEventListener('abort', onAbort);
1514
- try { reader.releaseLock(); } catch (err) {
1515
- try { process.stderr.write(`[anthropic-oauth] reader releaseLock failed: ${err?.message ?? String(err)}\n`); } catch {}
1516
- }
1517
- }
1518
- }
1519
-
1520
- /**
1521
- * Classify an Anthropic SSE failure for single-shot mid-stream retry.
1522
- *
1523
- * Retry is allowed only after `message_start` and before `message_stop`,
1524
- * and only when no tool call has already been surfaced to the loop.
1525
- * That keeps recovery limited to transport/stream stalls without risking
1526
- * duplicate eager tool execution.
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).
1534
- export function _classifyMidstreamError(err, state) {
1535
- return classifyMidstreamError(err, state, SSE_MIDSTREAM_POLICY);
1536
- }
461
+ // --- SSE parser + midstream retry policy: extracted to anthropic-sse.mjs ---
1537
462
 
1538
463
  // --- Build request body ---
1539
464
 
@@ -1562,12 +487,30 @@ function resolveCacheTtls(opts) {
1562
487
  // free for sessions that don't carry one. Previously null here meant any
1563
488
  // caller that skipped agent runtime resolve (CLI, raw agent spawn)
1564
489
  // silently lost the tier3 cache layer even though it supported one.
1565
- return {
490
+ const resolved = {
1566
491
  tools: pick('tools', null),
1567
492
  system: pick('system', CACHE_TTL_STABLE),
1568
493
  tier3: pick('tier3', CACHE_TTL_STABLE),
1569
494
  messages: pick('messages', CACHE_TTL_STABLE),
1570
495
  };
496
+ // A partial cacheStrategy override (e.g. {system:'5m'} while tier3/
497
+ // messages default to '1h') can put a longer TTL after a shorter one in
498
+ // request order, which Anthropic rejects: 1h breakpoints must all appear
499
+ // before any 5m breakpoint. Normalize left-to-right in wire order
500
+ // (system -> tier3 -> messages; tools is emitted before system and is
501
+ // excluded from the run) so a later layer is downgraded to the earliest
502
+ // shorter TTL seen so far — never re-promoted. Layers set to null ('none')
503
+ // emit no breakpoint at all, so they neither violate nor constrain
504
+ // ordering and are skipped.
505
+ const ttlRank = (ttl) => (ttl === CACHE_TTL_STABLE ? 2 : 1); // 1h=2, 5m=1
506
+ let minRank = Infinity;
507
+ for (const layer of ['system', 'tier3', 'messages']) {
508
+ if (!resolved[layer]) continue;
509
+ const rank = ttlRank(resolved[layer]);
510
+ if (rank > minRank) resolved[layer] = CACHE_TTL_VOLATILE;
511
+ else minRank = rank;
512
+ }
513
+ return resolved;
1571
514
  }
1572
515
 
1573
516
  // BP3 (tier3) is injected by session/manager as its own `system` role block —
@@ -1790,6 +733,11 @@ export class AnthropicOAuthProvider {
1790
733
  if (body.speed === 'fast') {
1791
734
  this.fastModeBetaHeaderLatched = true;
1792
735
  }
736
+ // advanced-tool-use-2025-11-20 beta is only needed when this request
737
+ // actually carries deferred (defer_loading) tools — gate the header on
738
+ // that instead of sending it unconditionally on every request.
739
+ const hasDeferredTools = Array.isArray(body.tools)
740
+ && body.tools.some((t) => t && t.defer_loading === true);
1793
741
  // Known tool names for the leaked-tool-call guard in parseSSEStream:
1794
742
  // recovered leaked calls are only synthesized when they name a tool
1795
743
  // actually offered to this request (native + lowered). Derived from the
@@ -1866,7 +814,7 @@ export class AnthropicOAuthProvider {
1866
814
  'anthropic-beta': buildAnthropicBetaHeaders({
1867
815
  base: OAUTH_BETA_HEADERS,
1868
816
  fastMode: this.fastModeBetaHeaderLatched,
1869
- toolSearch: true,
817
+ toolSearch: hasDeferredTools,
1870
818
  effort: shouldIncludeEffortBeta(useModel, opts),
1871
819
  }),
1872
820
  'anthropic-dangerous-direct-browser-access': 'true',
@@ -2195,7 +1143,7 @@ export class AnthropicOAuthProvider {
2195
1143
  // works offline or when Anthropic's /v1/models is momentarily down.
2196
1144
  const cached = await _loadModelCache();
2197
1145
  if (cached) {
2198
- _inMemoryCatalog = cached.slice();
1146
+ _setInMemoryCatalog(cached);
2199
1147
  return cached;
2200
1148
  }
2201
1149
  try {
@@ -2216,13 +1164,8 @@ export class AnthropicOAuthProvider {
2216
1164
  if (!res.ok) throw new Error(`list_models ${res.status}`);
2217
1165
  const data = await res.json();
2218
1166
  const items = Array.isArray(data?.data) ? data.data : [];
2219
- const normalized = items
2220
- .map(m => _normalizeAnthropicModel(m))
2221
- .filter(Boolean);
2222
- _markLatestByFamily(normalized);
2223
- // Enrich with LiteLLM catalog metadata (context, pricing, capabilities)
2224
- const enriched = await enrichModels(normalized);
2225
- await _saveModelCache(enriched);
1167
+ // Normalize + mark-latest + LiteLLM-enrich + persist (shared helper).
1168
+ const enriched = await normalizeAndSaveCatalog(items);
2226
1169
  return enriched;
2227
1170
  } catch (err) {
2228
1171
  if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(`[anthropic-oauth] listModels fetch failed (${err.message})\n`);
@@ -2264,12 +1207,7 @@ export class AnthropicOAuthProvider {
2264
1207
  if (!res.ok) throw new Error(`list_models ${res.status}`);
2265
1208
  const data = await res.json();
2266
1209
  const items = Array.isArray(data?.data) ? data.data : [];
2267
- const normalized = items
2268
- .map(m => _normalizeAnthropicModel(m))
2269
- .filter(Boolean);
2270
- _markLatestByFamily(normalized);
2271
- const enriched = await enrichModels(normalized);
2272
- await _saveModelCache(enriched);
1210
+ const enriched = await normalizeAndSaveCatalog(items);
2273
1211
  if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(`[anthropic-oauth] catalog refreshed (${enriched.length} models)\n`);
2274
1212
  return enriched;
2275
1213
  } catch (err) {
@@ -2287,204 +1225,20 @@ export class AnthropicOAuthProvider {
2287
1225
  }
2288
1226
  }
2289
1227
 
2290
- // --- Login flow (PKCE loopback, export for setup UI / CLI) ---
2291
-
2292
- function _oauthGeneratePKCE() {
2293
- const verifier = randomBytes(32).toString('base64url');
2294
- const challenge = createHash('sha256').update(verifier).digest('base64url');
2295
- return { verifier, challenge };
2296
- }
2297
-
2298
- function _oauthCredentialsWritePath() {
2299
- for (const p of credentialCandidates()) {
2300
- if (existsSync(p)) return p;
2301
- }
2302
- return DEFAULT_CREDENTIALS_PATH;
2303
- }
2304
-
2305
- function _oauthParseScopeField(scope) {
2306
- if (Array.isArray(scope)) return scope;
2307
- return String(scope || '').split(' ').filter(Boolean);
2308
- }
2309
-
2310
- function _parseOAuthCodeInput(input) {
2311
- const value = String(input || '').trim();
2312
- if (!value) return { code: '', state: '' };
2313
- try {
2314
- const url = new URL(value);
2315
- const code = url.searchParams.get('code') || '';
2316
- const state = url.searchParams.get('state') || '';
2317
- if (code || state) return { code, state, redirectUri: `${url.origin}${url.pathname}` };
2318
- } catch { /* not a URL */ }
2319
- if (value.includes('#')) {
2320
- const [code, state] = value.split('#', 2);
2321
- return { code: String(code || '').trim(), state: String(state || '').trim() };
2322
- }
2323
- if (value.includes('code=')) {
2324
- const params = new URLSearchParams(value.startsWith('?') ? value.slice(1) : value);
2325
- return { code: params.get('code') || '', state: params.get('state') || '' };
2326
- }
2327
- return { code: value, state: '' };
2328
- }
2329
-
2330
- async function exchangeAuthorizationCode({ pkce, code, state, redirectUri }) {
2331
- const cleanCode = String(code || '').trim();
2332
- if (!cleanCode) throw new Error('[anthropic-oauth] authorization code is required');
2333
- const tokenRes = await fetch(TOKEN_URL, {
2334
- method: 'POST',
2335
- headers: {
2336
- 'Content-Type': 'application/json',
2337
- 'anthropic-dangerous-direct-browser-access': 'true',
2338
- 'user-agent': `claude-cli/${resolveCliVersion()} (external, sdk-cli)`,
2339
- },
2340
- body: JSON.stringify({
2341
- grant_type: 'authorization_code',
2342
- code: cleanCode,
2343
- redirect_uri: redirectUri,
2344
- client_id: CLAUDE_CODE_CLIENT_ID,
2345
- code_verifier: pkce.verifier,
2346
- state,
2347
- }),
2348
- redirect: 'error',
2349
- signal: AbortSignal.timeout(OAUTH_TOKEN_TIMEOUT_MS),
2350
- dispatcher: getLlmDispatcher(),
2351
- });
2352
- if (!tokenRes.ok) {
2353
- const text = await tokenRes.text().catch(() => '');
2354
- throw new Error(`[anthropic-oauth] token exchange ${tokenRes.status}: ${_scrubTokens(text).slice(0, 500)}`);
2355
- }
2356
- const json = await tokenRes.json();
2357
- const accessToken = json?.access_token || json?.accessToken;
2358
- const refreshToken = json?.refresh_token || json?.refreshToken;
2359
- if (!accessToken || !refreshToken) {
2360
- throw new Error('[anthropic-oauth] token exchange response missing access_token or refresh_token');
2361
- }
2362
- const expiresAt = _normalizeExpiresAt(json?.expires_at ?? json?.expiresAt)
2363
- || (typeof json?.expires_in === 'number' ? Date.now() + json.expires_in * 1000 : 0);
2364
- const scopes = _oauthParseScopeField(json?.scope);
2365
- const credPath = _oauthCredentialsWritePath();
2366
- let raw = {};
2367
- if (existsSync(credPath)) {
2368
- raw = JSON.parse(readFileSync(credPath, 'utf-8'));
2369
- }
2370
- const existingOauth = raw.claudeAiOauth || {};
2371
- raw.claudeAiOauth = {
2372
- ...existingOauth,
2373
- accessToken,
2374
- refreshToken,
2375
- expiresAt,
2376
- scopes,
2377
- subscriptionType: existingOauth.subscriptionType ?? null,
2378
- };
2379
- _saveCredentialsFile(credPath, raw);
2380
- return {
2381
- path: credPath,
2382
- accessToken,
2383
- refreshToken,
2384
- expiresAt,
2385
- scopes,
2386
- subscriptionType: raw.claudeAiOauth.subscriptionType,
2387
- };
2388
- }
2389
-
2390
- export async function beginOAuthLogin() {
2391
- const pkce = _oauthGeneratePKCE();
2392
- const state = randomBytes(32).toString('base64url');
2393
- const buildUrl = (redirectUri) => {
2394
- const url = new URL(CLAUDE_AI_AUTHORIZE_URL);
2395
- url.searchParams.set('code', 'true');
2396
- url.searchParams.set('client_id', CLAUDE_CODE_CLIENT_ID);
2397
- url.searchParams.set('response_type', 'code');
2398
- url.searchParams.set('redirect_uri', redirectUri);
2399
- url.searchParams.set('scope', OAUTH_LOGIN_SCOPE);
2400
- url.searchParams.set('code_challenge', pkce.challenge);
2401
- url.searchParams.set('code_challenge_method', 'S256');
2402
- url.searchParams.set('state', state);
2403
- return url;
2404
- };
2405
- const url = buildUrl(OAUTH_REDIRECT_URI);
2406
- const manualUrl = buildUrl(OAUTH_MANUAL_REDIRECT_URI);
2407
-
2408
- let server = null;
2409
- let timeout = null;
2410
- let finish = null;
2411
- const waitForCallback = new Promise((resolve, reject) => {
2412
- let settled = false;
2413
- finish = (value, error = null) => {
2414
- if (settled) return;
2415
- settled = true;
2416
- if (timeout) clearTimeout(timeout);
2417
- try { server?.close(); } catch { /* already closed */ }
2418
- if (error) reject(error);
2419
- else resolve(value);
2420
- };
2421
- server = createServer(async (req, res) => {
2422
- const u = new URL(req.url || '/', `http://${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}`);
2423
- if (u.pathname !== OAUTH_CALLBACK_PATH) {
2424
- res.writeHead(404);
2425
- res.end();
2426
- return;
2427
- }
2428
- const code = u.searchParams.get('code');
2429
- if (!code || u.searchParams.get('state') !== state) {
2430
- res.writeHead(400);
2431
- res.end('Invalid');
2432
- finish(null);
2433
- return;
2434
- }
2435
- try {
2436
- const tokens = await exchangeAuthorizationCode({ pkce, code, state, redirectUri: OAUTH_REDIRECT_URI });
2437
- res.writeHead(302, { Location: OAUTH_SUCCESS_REDIRECT_URL });
2438
- res.end();
2439
- finish(tokens);
2440
- } catch (err) {
2441
- const error = err instanceof Error ? err : new Error(String(err));
2442
- res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
2443
- res.end(`Claude login failed: ${error.message}`);
2444
- finish(null, error);
2445
- }
2446
- });
2447
- timeout = setTimeout(() => finish(null), OAUTH_LOGIN_TIMEOUT_MS);
2448
- server.listen(OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_HOST, async () => {
2449
- process.stderr.write(`\n[anthropic-oauth] Open this URL to log in with Claude:\n${url.toString()}\n\nIf the localhost callback cannot complete, open this manual URL and paste the shown code#state:\n${manualUrl.toString()}\n\n`);
2450
- try {
2451
- const { openInBrowser } = await import('../../../shared/open-url.mjs');
2452
- openInBrowser(url.toString());
2453
- } catch (err) {
2454
- process.stderr.write(`[anthropic-oauth] browser open failed: ${String(err?.message || err).slice(0, 200)}\n`);
2455
- }
2456
- });
2457
- server.on('error', (err) => finish(null, new Error(`[anthropic-oauth] callback server failed on ${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}: ${err?.message || err}`)));
2458
- });
2459
-
2460
- return {
2461
- provider: 'anthropic-oauth',
2462
- url: url.toString(),
2463
- manualUrl: manualUrl.toString(),
2464
- waitForCallback,
2465
- completeCode: async (input) => {
2466
- const parsed = _parseOAuthCodeInput(input);
2467
- if (parsed.state && parsed.state !== state) throw new Error('[anthropic-oauth] OAuth state mismatch');
2468
- const redirectUri = parsed.redirectUri || (parsed.state ? OAUTH_MANUAL_REDIRECT_URI : OAUTH_REDIRECT_URI);
2469
- const tokens = await exchangeAuthorizationCode({ pkce, code: parsed.code, state, redirectUri });
2470
- finish?.(tokens);
2471
- return tokens;
2472
- },
2473
- cancel: () => {
2474
- finish?.(null);
2475
- },
2476
- };
2477
- }
2478
-
2479
- export async function loginOAuth() {
2480
- const login = await beginOAuthLogin();
2481
- return await login.waitForCallback;
2482
- }
1228
+ // Re-exports so external callers of anthropic-oauth.mjs keep their existing
1229
+ // import path after the credential/login-flow extraction into
1230
+ // anthropic-oauth-credentials.mjs.
1231
+ export {
1232
+ hasAnthropicOAuthCredentials,
1233
+ describeAnthropicOAuthCredentials,
1234
+ forgetAnthropicOAuthCredentials,
1235
+ beginOAuthLogin,
1236
+ loginOAuth,
1237
+ };
2483
1238
 
2484
- // Additive exports for test harnesses.
2485
- // Lets the SSE parser be exercised in isolation against a synthetic
2486
- // ReadableStream without needing a live OAuth session.
2487
- export { parseSSEStream };
1239
+ // Re-exports so anthropic.mjs and the test harnesses keep their existing
1240
+ // import path after the SSE-parser extraction into anthropic-sse.mjs.
1241
+ export { parseSSEStream, _classifyMidstreamError, ANTHROPIC_MAX_MIDSTREAM_RETRIES };
2488
1242
 
2489
1243
  // Test-only escape hatch for scripts/tool-smoke.mjs to verify the
2490
1244
  // catalog-driven max-tokens resolution without duplicating its logic.