mixdog 0.9.3 → 0.9.5

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 (382) hide show
  1. package/README.md +112 -38
  2. package/package.json +10 -3
  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/r5-orchestrated-task.json +7 -0
  7. package/scripts/bench/review-tasks.json +20 -0
  8. package/scripts/bench/round-codex.json +114 -0
  9. package/scripts/bench/round-mixdog-lead-r3.json +269 -0
  10. package/scripts/bench/round-mixdog-lead.json +269 -0
  11. package/scripts/bench/round-mixdog.json +126 -0
  12. package/scripts/bench/round-r10-bigsample.json +679 -0
  13. package/scripts/bench/round-r11-codexalign.json +257 -0
  14. package/scripts/bench/round-r13-clientmeta.json +464 -0
  15. package/scripts/bench/round-r14-betafeatures.json +466 -0
  16. package/scripts/bench/round-r15-fulldefault.json +462 -0
  17. package/scripts/bench/round-r16-sessionid.json +466 -0
  18. package/scripts/bench/round-r17-wirebytes.json +456 -0
  19. package/scripts/bench/round-r18-prewarm.json +468 -0
  20. package/scripts/bench/round-r19-clean.json +472 -0
  21. package/scripts/bench/round-r20-prewarm-clean.json +475 -0
  22. package/scripts/bench/round-r21-delta-retry.json +473 -0
  23. package/scripts/bench/round-r22-full-probe.json +693 -0
  24. package/scripts/bench/round-r23-itemprobe.json +701 -0
  25. package/scripts/bench/round-r24-shapefix.json +677 -0
  26. package/scripts/bench/round-r25-serial.json +464 -0
  27. package/scripts/bench/round-r26-parallel3.json +671 -0
  28. package/scripts/bench/round-r27-parallel10.json +894 -0
  29. package/scripts/bench/round-r28-parallel10-stagger.json +882 -0
  30. package/scripts/bench/round-r29-parallel10-stagger166.json +886 -0
  31. package/scripts/bench/round-r30-instid.json +253 -0
  32. package/scripts/bench/round-r31-upgradeprobe.json +256 -0
  33. package/scripts/bench/round-r32-vs-codex-lead.json +254 -0
  34. package/scripts/bench/round-r33-vs-codex-codex.json +115 -0
  35. package/scripts/bench/round-r34-orchestrated.json +120 -0
  36. package/scripts/bench/round-r35-orchestrated-codex.json +61 -0
  37. package/scripts/bench/round-r36-orchestrated-capped.json +128 -0
  38. package/scripts/bench/round-r4-codex.json +114 -0
  39. package/scripts/bench/round-r4-mixed.json +225 -0
  40. package/scripts/bench/round-r5-gpt-lead.json +259 -0
  41. package/scripts/bench/round-r6-codex.json +114 -0
  42. package/scripts/bench/round-r6-solo.json +257 -0
  43. package/scripts/bench/round-r7-full.json +254 -0
  44. package/scripts/bench/round-r8-fulldefault.json +255 -0
  45. package/scripts/bench-run.mjs +251 -32
  46. package/scripts/freevar-smoke.mjs +95 -0
  47. package/scripts/internal-comms-bench.mjs +3 -4
  48. package/scripts/internal-comms-smoke.mjs +10 -9
  49. package/scripts/model-catalog-audit.mjs +209 -0
  50. package/scripts/model-list-sanitize-test.mjs +37 -0
  51. package/scripts/mouse-probe.mjs +45 -0
  52. package/scripts/output-style-bench.mjs +13 -6
  53. package/scripts/output-style-smoke.mjs +4 -4
  54. package/scripts/provider-toolcall-test.mjs +7 -3
  55. package/scripts/recall-bench.mjs +76 -13
  56. package/scripts/recall-quality-cases.json +12 -0
  57. package/scripts/recall-usecase-cases.json +18 -0
  58. package/scripts/session-bench.mjs +152 -6
  59. package/scripts/tool-smoke.mjs +25 -65
  60. package/scripts/tui-render-smoke.mjs +90 -0
  61. package/scripts/webhook-smoke.mjs +208 -0
  62. package/src/agents/debugger/AGENT.md +4 -1
  63. package/src/agents/heavy-worker/AGENT.md +9 -8
  64. package/src/agents/maintainer/AGENT.md +4 -0
  65. package/src/agents/reviewer/AGENT.md +2 -1
  66. package/src/agents/scheduler-task/AGENT.md +2 -3
  67. package/src/agents/webhook-handler/AGENT.md +2 -3
  68. package/src/agents/worker/AGENT.md +10 -7
  69. package/src/app.mjs +12 -1
  70. package/src/headless-role.mjs +7 -1
  71. package/src/lib/rules-builder.cjs +4 -0
  72. package/src/mixdog-session-runtime.mjs +647 -2056
  73. package/src/output-styles/default.md +30 -9
  74. package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
  75. package/src/output-styles/minimal.md +8 -6
  76. package/src/output-styles/simple.md +21 -7
  77. package/src/rules/agent/00-common.md +6 -3
  78. package/src/rules/agent/30-explorer.md +16 -5
  79. package/src/rules/lead/01-general.md +5 -5
  80. package/src/rules/lead/lead-brief.md +15 -0
  81. package/src/rules/lead/lead-tool.md +6 -15
  82. package/src/rules/shared/01-tool.md +17 -21
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +8 -3
  84. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
  85. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
  86. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
  87. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
  88. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
  89. package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
  90. package/src/runtime/agent/orchestrator/config.mjs +69 -2
  91. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +62 -20
  92. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
  93. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +81 -1281
  95. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +32 -3
  97. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
  100. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
  101. package/src/runtime/agent/orchestrator/providers/gemini.mjs +43 -1013
  102. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +17 -3
  103. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +105 -11
  104. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +356 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
  107. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
  108. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
  109. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +40 -1143
  110. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +740 -0
  111. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
  112. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +349 -2131
  113. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +143 -1002
  114. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +229 -0
  115. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
  116. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +465 -0
  117. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
  118. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
  119. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +80 -0
  120. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
  121. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
  122. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
  123. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
  124. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
  125. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
  126. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
  127. package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
  128. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +14 -2
  129. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
  130. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +275 -0
  132. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
  133. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
  134. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
  135. package/src/runtime/agent/orchestrator/session/loop.mjs +278 -402
  136. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
  137. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +7 -4
  138. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +12 -0
  139. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
  140. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
  141. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
  142. package/src/runtime/agent/orchestrator/session/manager.mjs +166 -1087
  143. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
  144. package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
  145. package/src/runtime/agent/orchestrator/stall-policy.mjs +20 -1
  146. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
  147. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
  148. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +35 -44
  149. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
  150. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
  151. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
  152. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
  153. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -92
  154. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
  155. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
  156. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +28 -265
  157. package/src/runtime/agent/orchestrator/tools/builtin.mjs +0 -6
  158. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +57 -3
  159. package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
  160. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +10 -122
  161. package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
  162. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
  163. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
  164. package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
  165. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
  166. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
  167. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
  168. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
  169. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
  170. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
  171. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
  172. package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
  173. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +0 -21
  174. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -72
  175. package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
  176. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
  177. package/src/runtime/channels/backends/discord-access.mjs +32 -0
  178. package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
  179. package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
  180. package/src/runtime/channels/backends/discord.mjs +27 -318
  181. package/src/runtime/channels/backends/telegram.mjs +8 -12
  182. package/src/runtime/channels/index.mjs +247 -701
  183. package/src/runtime/channels/lib/backend-dispatch.mjs +46 -0
  184. package/src/runtime/channels/lib/config.mjs +37 -149
  185. package/src/runtime/channels/lib/event-pipeline.mjs +22 -5
  186. package/src/runtime/channels/lib/event-queue.mjs +78 -13
  187. package/src/runtime/channels/lib/inbound-routing.mjs +74 -0
  188. package/src/runtime/channels/lib/interaction-workflows.mjs +5 -113
  189. package/src/runtime/channels/lib/output-forwarder.mjs +1 -1
  190. package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
  191. package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
  192. package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
  193. package/src/runtime/channels/lib/scheduler.mjs +27 -113
  194. package/src/runtime/channels/lib/session-discovery.mjs +56 -4
  195. package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
  196. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  197. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
  198. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
  199. package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
  200. package/src/runtime/channels/lib/webhook/deliveries.mjs +313 -0
  201. package/src/runtime/channels/lib/webhook/log.mjs +42 -0
  202. package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
  203. package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
  204. package/src/runtime/channels/lib/webhook.mjs +43 -616
  205. package/src/runtime/channels/tool-defs.mjs +11 -130
  206. package/src/runtime/memory/index.mjs +210 -1948
  207. package/src/runtime/memory/lib/core-memory-store.mjs +5 -1
  208. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
  209. package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
  210. package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
  211. package/src/runtime/memory/lib/ko-morph.mjs +195 -0
  212. package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
  213. package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
  214. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
  215. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
  216. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
  217. package/src/runtime/memory/lib/memory-cycle2.mjs +24 -842
  218. package/src/runtime/memory/lib/memory-embed.mjs +149 -0
  219. package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
  220. package/src/runtime/memory/lib/memory-recall-store.mjs +69 -12
  221. package/src/runtime/memory/lib/memory-text-utils.mjs +46 -0
  222. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
  223. package/src/runtime/memory/lib/query-handlers.mjs +802 -0
  224. package/src/runtime/memory/lib/recall-format.mjs +55 -0
  225. package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
  226. package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
  227. package/src/runtime/memory/tool-defs.mjs +5 -13
  228. package/src/runtime/search/lib/http-fetch.mjs +274 -0
  229. package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
  230. package/src/runtime/search/lib/web-tools.mjs +24 -602
  231. package/src/runtime/shared/atomic-file.mjs +26 -1
  232. package/src/runtime/shared/config.mjs +14 -4
  233. package/src/runtime/shared/launcher-control.mjs +2 -2
  234. package/src/runtime/shared/markdown-frontmatter.mjs +19 -0
  235. package/src/runtime/shared/schedules-store.mjs +13 -3
  236. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  237. package/src/runtime/shared/tool-primitives.mjs +308 -0
  238. package/src/runtime/shared/tool-result-summary.mjs +515 -0
  239. package/src/runtime/shared/tool-surface.mjs +80 -898
  240. package/src/runtime/shared/transcript-writer.mjs +23 -0
  241. package/src/runtime/shared/update-checker.mjs +7 -4
  242. package/src/session-runtime/config-helpers.mjs +119 -2
  243. package/src/session-runtime/config-lifecycle.mjs +232 -0
  244. package/src/session-runtime/cwd-plugins.mjs +226 -0
  245. package/src/session-runtime/mcp-glue.mjs +177 -0
  246. package/src/session-runtime/model-recency.mjs +111 -0
  247. package/src/session-runtime/native-search.mjs +247 -0
  248. package/src/session-runtime/output-styles.mjs +11 -9
  249. package/src/session-runtime/prewarm.mjs +142 -0
  250. package/src/session-runtime/provider-models.mjs +278 -0
  251. package/src/session-runtime/provider-usage.mjs +120 -0
  252. package/src/session-runtime/quick-model-rows.mjs +205 -0
  253. package/src/session-runtime/quick-search-models.mjs +47 -0
  254. package/src/session-runtime/session-hooks.mjs +93 -0
  255. package/src/session-runtime/settings-api.mjs +352 -0
  256. package/src/session-runtime/tool-catalog.mjs +29 -29
  257. package/src/session-runtime/tool-defs.mjs +84 -0
  258. package/src/session-runtime/warmup-schedulers.mjs +201 -0
  259. package/src/session-runtime/workflow.mjs +1 -1
  260. package/src/standalone/agent-tool/helpers.mjs +237 -0
  261. package/src/standalone/agent-tool/notify.mjs +107 -0
  262. package/src/standalone/agent-tool/provider-init.mjs +143 -0
  263. package/src/standalone/agent-tool/render.mjs +152 -0
  264. package/src/standalone/agent-tool/tool-def.mjs +55 -0
  265. package/src/standalone/agent-tool.mjs +138 -669
  266. package/src/standalone/channel-admin.mjs +102 -90
  267. package/src/standalone/channel-worker.mjs +4 -7
  268. package/src/standalone/explore-tool.mjs +64 -14
  269. package/src/standalone/hook-bus/config.mjs +207 -0
  270. package/src/standalone/hook-bus/constants.mjs +90 -0
  271. package/src/standalone/hook-bus/handlers.mjs +481 -0
  272. package/src/standalone/hook-bus/payload.mjs +31 -0
  273. package/src/standalone/hook-bus/rules.mjs +77 -0
  274. package/src/standalone/hook-bus.mjs +77 -870
  275. package/src/standalone/memory-runtime-proxy.mjs +7 -0
  276. package/src/standalone/opencode-go-login.mjs +5 -1
  277. package/src/standalone/provider-admin.mjs +1 -16
  278. package/src/standalone/usage-dashboard.mjs +3 -1
  279. package/src/tui/App.jsx +1059 -8110
  280. package/src/tui/app/app-format.mjs +213 -0
  281. package/src/tui/app/channel-pickers.mjs +508 -0
  282. package/src/tui/app/clipboard.mjs +67 -0
  283. package/src/tui/app/core-memory-picker.mjs +210 -0
  284. package/src/tui/app/extension-pickers.mjs +506 -0
  285. package/src/tui/app/input-parsers.mjs +193 -0
  286. package/src/tui/app/maintenance-pickers.mjs +356 -0
  287. package/src/tui/app/model-options.mjs +334 -0
  288. package/src/tui/app/model-picker.mjs +365 -0
  289. package/src/tui/app/onboarding-steps.mjs +400 -0
  290. package/src/tui/app/project-picker.mjs +247 -0
  291. package/src/tui/app/provider-setup-picker.mjs +580 -0
  292. package/src/tui/app/resume-picker.mjs +55 -0
  293. package/src/tui/app/route-pickers.mjs +419 -0
  294. package/src/tui/app/settings-picker.mjs +489 -0
  295. package/src/tui/app/slash-commands.mjs +101 -0
  296. package/src/tui/app/slash-dispatch.mjs +427 -0
  297. package/src/tui/app/text-layout.mjs +46 -0
  298. package/src/tui/app/theme-effort-pickers.mjs +154 -0
  299. package/src/tui/app/transcript-window.mjs +677 -0
  300. package/src/tui/app/use-mouse-input.mjs +460 -0
  301. package/src/tui/app/use-prompt-handlers.mjs +310 -0
  302. package/src/tui/app/use-transcript-scroll.mjs +512 -0
  303. package/src/tui/app/use-transcript-window.mjs +607 -0
  304. package/src/tui/components/ConfirmBar.jsx +10 -7
  305. package/src/tui/components/Picker.jsx +64 -15
  306. package/src/tui/components/PromptInput.jsx +33 -102
  307. package/src/tui/components/SlashCommandPalette.jsx +8 -1
  308. package/src/tui/components/StatusLine.jsx +69 -15
  309. package/src/tui/components/TextEntryPanel.jsx +11 -0
  310. package/src/tui/components/ToolExecution.jsx +52 -594
  311. package/src/tui/components/TranscriptItem.jsx +105 -0
  312. package/src/tui/components/UsagePanel.jsx +18 -4
  313. package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
  314. package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
  315. package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
  316. package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
  317. package/src/tui/components/tool-execution/text-format.mjs +161 -0
  318. package/src/tui/display-width.mjs +20 -3
  319. package/src/tui/dist/index.mjs +13553 -12384
  320. package/src/tui/engine/agent-job-feed.mjs +133 -0
  321. package/src/tui/engine/notification-plan.mjs +76 -0
  322. package/src/tui/engine/render-timing.mjs +17 -0
  323. package/src/tui/engine/tool-approval.mjs +94 -0
  324. package/src/tui/engine/tool-card-results.mjs +234 -0
  325. package/src/tui/engine/tool-result-status.mjs +135 -0
  326. package/src/tui/engine.mjs +170 -574
  327. package/src/tui/figures.mjs +5 -0
  328. package/src/tui/index.jsx +65 -1
  329. package/src/tui/input-editing.mjs +2 -2
  330. package/src/tui/markdown/format-token.mjs +4 -1
  331. package/src/tui/statusline-ansi-bridge.mjs +11 -3
  332. package/src/tui/theme.mjs +6 -0
  333. package/src/ui/statusline-agents.mjs +213 -0
  334. package/src/ui/statusline-format.mjs +146 -0
  335. package/src/ui/statusline-segments.mjs +148 -0
  336. package/src/ui/statusline.mjs +77 -501
  337. package/src/ui/tool-card.mjs +0 -1
  338. package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
  339. package/src/workflows/default/WORKFLOW.md +16 -18
  340. package/src/workflows/sequential/WORKFLOW.md +16 -18
  341. package/vendor/ink/build/display-width.js +19 -3
  342. package/vendor/ink/build/ink.js +112 -7
  343. package/vendor/ink/build/log-update.js +17 -3
  344. package/vendor/ink/build/wrap-text.js +125 -0
  345. package/scripts/_test-folder-dialog.mjs +0 -30
  346. package/scripts/fix-brief-fn.mjs +0 -35
  347. package/scripts/fix-format-tool-surface.mjs +0 -24
  348. package/scripts/fix-tool-exec-visible.mjs +0 -42
  349. package/scripts/patch-agent-brief.mjs +0 -48
  350. package/scripts/patch-app.mjs +0 -21
  351. package/scripts/patch-app2.mjs +0 -18
  352. package/scripts/patch-dist-brief.mjs +0 -96
  353. package/scripts/patch-tool-exec.mjs +0 -70
  354. package/src/examples/schedules/SCHEDULE.example.md +0 -32
  355. package/src/examples/webhooks/WEBHOOK.example.md +0 -40
  356. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
  357. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
  358. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
  359. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +0 -162
  360. package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
  361. package/src/runtime/channels/lib/holidays.mjs +0 -138
  362. package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
  363. package/src/runtime/shared/task-notification-envelope.test.mjs +0 -107
  364. package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
  365. package/src/standalone/agent-task-status.test.mjs +0 -76
  366. package/src/tui/components/tool-output-format.test.mjs +0 -399
  367. package/src/tui/display-width.test.mjs +0 -35
  368. package/src/tui/engine-runtime-notification.test.mjs +0 -115
  369. package/src/tui/engine-tool-result-text.test.mjs +0 -75
  370. package/src/tui/input-editing.selection.test.mjs +0 -75
  371. package/src/tui/markdown/format-token.test.mjs +0 -354
  372. package/src/tui/markdown/render-ansi.test.mjs +0 -108
  373. package/src/tui/markdown/stream-fence.test.mjs +0 -26
  374. package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
  375. package/src/tui/paste-fix.test.mjs +0 -119
  376. package/src/tui/prompt-history-store.test.mjs +0 -52
  377. package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
  378. package/src/tui/transcript-tool-failures.test.mjs +0 -111
  379. package/src/ui/markdown.test.mjs +0 -70
  380. package/src/ui/statusline-context-label.test.mjs +0 -15
  381. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
  382. package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
@@ -5,41 +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';
22
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';
23
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';
24
41
  import {
25
42
  PROVIDER_FIRST_BYTE_TIMEOUT_MS,
26
43
  PROVIDER_HTTP_RESPONSE_TIMEOUT_MS,
27
44
  PROVIDER_RETRY_BACKOFF_MS,
28
45
  PROVIDER_RETRY_MAX_ATTEMPTS,
29
- PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
30
- PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
31
- streamStalledError,
32
46
  createPassthroughSignal,
33
47
  } from '../stall-policy.mjs';
34
48
  import {
35
49
  classifyError,
36
- classifyMidstreamError,
37
50
  midstreamBackoffFor,
38
- MIDSTREAM_RETRY_POLICY,
39
51
  retryAfterMsFromError,
40
- sleepWithAbort,
41
52
  withRetry,
42
53
  } from './retry-classifier.mjs';
54
+ import {
55
+ ANTHROPIC_MAX_MIDSTREAM_RETRIES,
56
+ parseSSEStream,
57
+ _classifyMidstreamError,
58
+ _midstreamSleepWithAbort,
59
+ } from './anthropic-sse.mjs';
43
60
  import { buildAnthropicBetaHeaders, supportsAnthropicFastMode } from './anthropic-betas.mjs';
44
61
  import {
45
62
  applyAnthropicEffortToBody,
@@ -48,33 +65,11 @@ import {
48
65
  } from './anthropic-effort.mjs';
49
66
  import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
50
67
  import { normalizeContentForAnthropic } from './media-normalization.mjs';
51
- import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
52
- import { scanLeakedToolCalls, createToolCallDedupe } from './anthropic-leaked-toolcall.mjs';
53
-
54
- // --- Model catalog cache helpers ---
55
- // Disk-backed cache so repeated process starts (cron, tool calls) don't
56
- // hammer /v1/models. 24h TTL matches the upstream client cadence.
57
- const MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
58
- // Bump when the on-disk cache shape changes so stale-shape entries are
59
- // discarded instead of misread.
60
- const ANTHROPIC_MODEL_CACHE_SCHEMA_VERSION = 1;
68
+
69
+ // --- Model catalog cache helpers: extracted to anthropic-model-resolve.mjs ---
61
70
  // SSE progress emits (per-request "Response …" and "Done:" lines). Off by default.
62
71
  const SSE_VERBOSE = process.env.MIXDOG_SSE_VERBOSE === '1';
63
72
 
64
- /** Bounded mid-stream SSE retries (transient stream loss); shared with anthropic.mjs.
65
- * Sourced from the single shared retry-budget table (MIDSTREAM_RETRY_POLICY.sse). */
66
- export const ANTHROPIC_MAX_MIDSTREAM_RETRIES = MIDSTREAM_RETRY_POLICY.sse.defaultRetries;
67
-
68
- // Policy passed to the shared classifyMidstreamError for the SSE path. The
69
- // top-of-function attempt-budget gate uses defaultRetries (3); perClassifierGate
70
- // is false so the classifier returns raw bucket strings (the loop owns the
71
- // MAX_MIDSTREAM_RETRIES bound), matching the former _classifyMidstreamError.
72
- const SSE_MIDSTREAM_POLICY = {
73
- mode: 'sse',
74
- defaultRetries: MIDSTREAM_RETRY_POLICY.sse.defaultRetries,
75
- perClassifierGate: false,
76
- };
77
-
78
73
  function formatRetryAfter(ms) {
79
74
  if (ms == null) return '';
80
75
  const n = Number(ms);
@@ -103,26 +98,6 @@ function anthropicQuotaError(status, headers, bodyText = '') {
103
98
  return err;
104
99
  }
105
100
 
106
- const _modelCache = makeModelCache({
107
- fileName: 'anthropic-oauth-models.json',
108
- ttlMs: MODEL_CACHE_TTL_MS,
109
- version: ANTHROPIC_MODEL_CACHE_SCHEMA_VERSION,
110
- onSave: (m) => { _inMemoryCatalog = Array.isArray(m) ? m.slice() : null; },
111
- });
112
-
113
- // Async wrappers so callers can keep awaiting; the shared cache CRUD is sync.
114
- async function _loadModelCache() {
115
- return _modelCache.loadSync();
116
- }
117
-
118
- async function _saveModelCache(models) {
119
- _modelCache.save(models);
120
- }
121
-
122
- // In-memory mirror of the disk catalog — populated on first listModels() and
123
- // refreshed after every _saveModelCache. Used by _catalogHas and _displayModel
124
- // so hot paths don't hit disk on every response.
125
- let _inMemoryCatalog = null;
126
101
  let _modelRefreshInFlight = null;
127
102
  let _oauthRefreshInFlight = null;
128
103
  // No in-memory credential cache: the canonical credentials file is the
@@ -132,190 +107,14 @@ let _oauthRefreshInFlight = null;
132
107
  // disk on demand is cheap (one stat + one small JSON parse) and removes
133
108
  // the cache-vs-disk skew entirely.
134
109
 
135
-
136
- function _catalogHas(id) {
137
- if (!id || !Array.isArray(_inMemoryCatalog)) return false;
138
- return _inMemoryCatalog.some(m => m.id === id);
139
- }
140
-
141
- // Display-name normalization for trace / usage. Turns dated or version-alias
142
- // ids into the version alias form: claude-opus-4-7 → claude-opus-4.7,
143
- // claude-haiku-4-5-20251001 → claude-haiku-4.5. Falls back to the raw id.
144
- function _displayModel(id) {
145
- if (!id || typeof id !== 'string') return id;
146
- const m = id.match(/^claude-([a-z]+)-(\d+)(?:-(\d+))?(?:-\d{8})?$/i);
147
- if (!m) return id;
148
- return `claude-${m[1].toLowerCase()}-${m[2]}${m[3] ? `.${m[3]}` : ''}`;
149
- }
150
-
151
- function _capabilitySupported(capability) {
152
- return capability === true || capability?.supported === true;
153
- }
154
-
155
- // Classify a model id into our common tier/family shape. Anthropic's catalog
156
- // mixes dated ids (claude-opus-4-5-20251101), versioned aliases
157
- // (claude-opus-4-6), and the raw family tokens resolved via env vars.
158
- function _normalizeAnthropicModel(raw) {
159
- const id = raw?.id || raw?.name;
160
- if (!id) return null;
161
- const familyMatch = id.match(/^claude-([a-z]+)/i);
162
- const family = familyMatch ? familyMatch[1].toLowerCase() : 'other';
163
- // Dated: trailing -YYYYMMDD (8 digits).
164
- const dated = /-\d{8}$/.test(id);
165
- // Versioned alias: claude-<family>-<major>-<minor>[-...] with no dated suffix.
166
- const versioned = !dated && /^claude-[a-z]+-\d+(?:-\d+)?$/i.test(id);
167
- const tier = dated ? 'dated' : versioned ? 'version' : 'family';
168
- const releaseDate = dated
169
- ? id.match(/-(\d{4})(\d{2})(\d{2})$/)
170
- : null;
171
- const effortValues = effortValuesForModel(raw?.capabilities, id);
172
- return {
173
- id,
174
- display: raw?.display_name || _prettyName(id, family),
175
- family,
176
- provider: 'anthropic-oauth',
177
- contextWindow: raw?.context_window || raw?.max_context_window || raw?.max_input_tokens || _defaultContextForModel(id, family),
178
- outputTokens: raw?.max_tokens || raw?.max_output_tokens || null,
179
- tier,
180
- latest: false, // assigned in a second pass once full list is known
181
- releaseDate: releaseDate ? `${releaseDate[1]}-${releaseDate[2]}-${releaseDate[3]}` : null,
182
- supportsReasoning: effortValues.length > 0 || _capabilitySupported(raw?.capabilities?.thinking),
183
- reasoningOptions: effortValues.length ? [{ type: 'effort', values: effortValues }] : [],
184
- };
185
- }
186
-
187
- function _prettyName(id, family) {
188
- const v = id.match(/^claude-[a-z]+-(\d+)(?:-(\d+))?/i);
189
- const base = family[0].toUpperCase() + family.slice(1);
190
- return v ? `${base} ${v[1]}${v[2] ? `.${v[2]}` : ''}` : base;
191
- }
192
-
193
- function _defaultContextForModel(id, family) {
194
- const text = String(id || '');
195
- const version = text.match(/^claude-[a-z]+-(\d+)(?:-(\d+))?/i);
196
- if (Number(version?.[1] || 0) >= 5) return 1000000;
197
- if (/^claude-(opus|sonnet)-4-(6|7|8)(?:$|-)/i.test(text)) return 1000000;
198
- if (family && family !== 'other') return 200000;
199
- return 200000;
200
- }
201
-
202
- // Mark the highest-numbered version per family as `latest: true`. Uses a simple
203
- // lexicographic comparison on the numeric parts embedded in the id.
204
- function _markLatestByFamily(models) {
205
- const byFamily = new Map();
206
- for (const m of models) {
207
- if (m.tier !== 'version') continue;
208
- const cur = byFamily.get(m.family);
209
- if (!cur || _compareVersion(m.id, cur.id) > 0) {
210
- byFamily.set(m.family, m);
211
- }
212
- }
213
- for (const m of byFamily.values()) m.latest = true;
214
- }
215
-
216
- function _compareVersion(a, b) {
217
- const na = (a.match(/^claude-[a-z]+-(\d+)(?:-(\d+))?/i) || []).slice(1).map(Number);
218
- const nb = (b.match(/^claude-[a-z]+-(\d+)(?:-(\d+))?/i) || []).slice(1).map(Number);
219
- for (let i = 0; i < Math.max(na.length, nb.length); i++) {
220
- if ((na[i] || 0) !== (nb[i] || 0)) return (na[i] || 0) - (nb[i] || 0);
221
- }
222
- return a.localeCompare(b);
223
- }
224
-
225
- // Newest HIGH-TIER chat model by version, read from the SYNC in-memory catalog
226
- // mirror. 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
112
 
308
113
  // Anthropic OAuth contract for first-party OAuth clients: Opus/Sonnet
309
114
  // requests are gated on this exact system-prompt prefix. Haiku is not
310
115
  // gated and ignores this prefix.
311
116
  const CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude.";
312
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';
313
- const DEFAULT_CLI_VERSION = '2.1.77';
314
-
315
- function resolveCliVersion() {
316
- return process.env.MIXDOG_CLI_VERSION
317
- || DEFAULT_CLI_VERSION;
318
- }
319
118
 
320
119
  function requiresSystemPrefix(model) {
321
120
  // High-tier Claude OAuth models require the first-party system prefix for
@@ -363,40 +162,29 @@ function buildSystemBlocks(systemMsgs, model, systemTtl, tier3Ttl) {
363
162
  // Apply per-tier cache_control. BP1/BP2 -> systemTtl, BP3 -> tier3Ttl. The
364
163
  // gating prefix block is never cached (Anthropic routes on its exact bytes).
365
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;
366
171
  for (const b of blocks) {
367
172
  const tier = b._tier;
368
173
  delete b._tier;
369
174
  if (b.text === CLAUDE_CODE_SYSTEM_PREFIX) continue;
370
175
  const ttl = tier === 'tier3' ? tier3Ttl : systemTtl;
371
- if (ttl) b.cache_control = ttl;
372
- }
373
- return blocks;
374
- }
375
-
376
- // Catalog-reported outputTokens for a model id, read from the in-memory
377
- // catalog mirror (lazily populated from the disk cache if the mirror hasn't
378
- // been warmed yet by listModels()). Never throws — any failure just means
379
- // "no catalog data", and callers fall back to the static heuristics below.
380
- function _catalogOutputTokens(model) {
381
- if (!model) return null;
382
- try {
383
- if (!Array.isArray(_inMemoryCatalog)) {
384
- const cached = _modelCache.loadSync();
385
- if (Array.isArray(cached)) _inMemoryCatalog = cached.slice();
176
+ if (ttl && bpCount < MAX_SYSTEM_BREAKPOINTS) {
177
+ b.cache_control = ttl;
178
+ bpCount++;
386
179
  }
387
- if (!Array.isArray(_inMemoryCatalog)) return null;
388
- const entry = _inMemoryCatalog.find(m => m?.id === model);
389
- const out = Number(entry?.outputTokens);
390
- return Number.isFinite(out) && out > 0 ? out : null;
391
- } catch {
392
- return null;
393
180
  }
181
+ return blocks;
394
182
  }
395
183
 
396
184
  // resolveMaxTokens: catalog-driven max_tokens for a model id. Thin wrapper
397
185
  // around the shared anthropic-max-tokens helper (also used by the API-key
398
186
  // twin in anthropic.mjs) — this provider supplies its own in-memory-mirror-
399
- // first catalog lookup strategy.
187
+ // first catalog lookup strategy (see anthropic-model-resolve.mjs).
400
188
  // 1. MIXDOG_ANTHROPIC_MAX_OUTPUT_TOKENS env override, if set, wins outright.
401
189
  // 2. Catalog outputTokens (trusted over hardcoded heuristics when present),
402
190
  // clamped to [MAX_TOKENS_FLOOR, safetyCap].
@@ -423,249 +211,6 @@ function clampThinkingBudgetTokens(value, maxTokens) {
423
211
  const CACHE_TTL_STABLE = { type: 'ephemeral', ttl: '1h' }; // tools, system, tier3, messages
424
212
  const CACHE_TTL_VOLATILE = { type: 'ephemeral' }; // explicit 5m override
425
213
 
426
- // --- Credential helpers ---
427
-
428
- function _pushUnique(list, value) {
429
- if (!value || typeof value !== 'string') return;
430
- if (!list.includes(value)) list.push(value);
431
- }
432
-
433
- function credentialCandidates() {
434
- const paths = [];
435
- _pushUnique(paths, process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH);
436
- _pushUnique(paths, DEFAULT_CREDENTIALS_PATH);
437
- return paths;
438
- }
439
-
440
- // Fallback expiry from the access_token's JWT `exp` claim (epoch ms) when the
441
- // credentials file carries no explicit expiresAt — without it expiresAt stays 0,
442
- // which ensureAuth reads as "never expires", disabling proactive refresh. Claude
443
- // OAuth tokens are opaque so this returns 0 and the file's expiresAt governs.
444
- // JWT `exp` is epoch SECONDS (RFC 7519).
445
- function _expiryFromAccessToken(token) {
446
- try {
447
- const parts = String(token || '').split('.');
448
- if (parts.length !== 3) return 0;
449
- const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf-8'));
450
- const exp = Number(payload?.exp);
451
- return Number.isFinite(exp) && exp > 0 ? exp * 1000 : 0;
452
- } catch { return 0; }
453
- }
454
-
455
- function _loadCredentialsFile(path) {
456
- if (!existsSync(path)) return null;
457
- try {
458
- const stat = statSync(path);
459
- const raw = JSON.parse(readFileSync(path, 'utf-8'));
460
- const oauth = raw?.claudeAiOauth;
461
- if (!oauth?.accessToken) return null;
462
- return {
463
- path,
464
- mtimeMs: stat.mtimeMs,
465
- accessToken: oauth.accessToken,
466
- refreshToken: oauth.refreshToken || null,
467
- expiresAt: _normalizeExpiresAt(oauth.expiresAt ?? oauth.expires_at) || _expiryFromAccessToken(oauth.accessToken),
468
- scopes: Array.isArray(oauth.scopes) ? oauth.scopes : [],
469
- subscriptionType: oauth.subscriptionType || null,
470
- };
471
- } catch {
472
- return null;
473
- }
474
- }
475
-
476
- // Cross-process safe credential save. Lockfile (O_EXCL) prevents two Mixdog
477
- // refreshers from clobbering each other; atomic rename guarantees readers see
478
- // either the old or new file, never a half-written one. Used so refresh_token
479
- // rotation propagates to other Mixdog readers of the same credentials file
480
- // instead of leaving them stuck on the previous refresh_token.
481
- function _saveCredentialsFile(path, raw) {
482
- // Secret file, not parent-dir ACL mutation. `secret: true` clamps the file
483
- // itself on Windows; it deliberately leaves the data dir inheritance alone.
484
- writeJsonAtomicSync(path, raw, { lock: true, fsyncDir: true, mode: 0o600, secret: true });
485
- }
486
-
487
- // Cheap stat-only probe so ensureAuth can detect Mixdog-updated credentials
488
- // without paying a full JSON read every call.
489
- function _credentialsMaxMtime() {
490
- let max = 0;
491
- for (const p of credentialCandidates()) {
492
- try {
493
- const s = statSync(p);
494
- if (s.mtimeMs > max) max = s.mtimeMs;
495
- } catch { /* not present — skip */ }
496
- }
497
- return max;
498
- }
499
-
500
- function loadCredentials() {
501
- const loaded = credentialCandidates()
502
- .map(_loadCredentialsFile)
503
- .filter(Boolean);
504
- if (!loaded.length) return null;
505
- loaded.sort((a, b) => (Number(b.expiresAt) || 0) - (Number(a.expiresAt) || 0));
506
- return loaded[0];
507
- }
508
-
509
- // Public predicate used by config.buildDefaultConfig — provider is enabled
510
- // when on-disk credentials exist AND carry the inference scope. Single
511
- // truth: same loader the runtime uses, no parallel hard-coded path probe.
512
- export function hasAnthropicOAuthCredentials() {
513
- const creds = loadCredentials();
514
- if (!creds?.accessToken) return false;
515
- return Array.isArray(creds.scopes) && creds.scopes.includes('user:inference');
516
- }
517
-
518
- export function describeAnthropicOAuthCredentials() {
519
- try {
520
- const creds = loadCredentials();
521
- if (!creds?.accessToken) {
522
- return { authenticated: false, status: 'Not Set', detail: 'Mixdog OAuth credentials' };
523
- }
524
- const hasInferenceScope = Array.isArray(creds.scopes) && creds.scopes.includes('user:inference');
525
- const hasRefresh = Boolean(creds.refreshToken);
526
- const expiresAt = _normalizeExpiresAt(creds.expiresAt);
527
- const expiring = expiresAt > 0 && expiresAt < Date.now() + TOKEN_REFRESH_SKEW_MS;
528
- const expired = expiresAt > 0 && expiresAt <= Date.now();
529
- const detail = creds.path || DEFAULT_CREDENTIALS_PATH;
530
- if (!hasInferenceScope) {
531
- return { authenticated: false, status: 'Missing Scope', detail, expiresAt };
532
- }
533
- if (!hasRefresh) {
534
- return {
535
- authenticated: expiresAt === 0 || !expired,
536
- status: expired ? 'Reauth Required' : 'Access Only',
537
- detail: `${detail}; no refresh token`,
538
- expiresAt,
539
- };
540
- }
541
- if (expired) return { authenticated: true, status: 'Refresh Required', detail, expiresAt };
542
- if (expiring) return { authenticated: true, status: 'Refresh Soon', detail, expiresAt };
543
- return { authenticated: true, status: 'Valid', detail, expiresAt };
544
- } catch (err) {
545
- return { authenticated: false, status: 'Error', detail: String(err?.message || err).slice(0, 200) };
546
- }
547
- }
548
-
549
- export function forgetAnthropicOAuthCredentials() {
550
- let removed = false;
551
- for (const path of credentialCandidates()) {
552
- if (!existsSync(path)) continue;
553
- try {
554
- const raw = JSON.parse(readFileSync(path, 'utf-8'));
555
- if (raw?.claudeAiOauth) {
556
- delete raw.claudeAiOauth;
557
- _saveCredentialsFile(path, raw);
558
- removed = true;
559
- }
560
- } catch (err) {
561
- throw new Error(`Anthropic OAuth reset failed for ${path}: ${err?.message || err}`);
562
- }
563
- }
564
- return { removed };
565
- }
566
-
567
- function _normalizeExpiresAt(value) {
568
- if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return 0;
569
- return value < 1e12 ? value * 1000 : value;
570
- }
571
-
572
- function _scrubTokens(text) {
573
- return String(text || '')
574
- .replace(/Bearer [A-Za-z0-9._\-]+/g, 'Bearer [REDACTED]')
575
- .replace(/sk-ant-[A-Za-z0-9._\-]+/g, '[REDACTED]')
576
- .replace(/"access[Tt]oken"\s*:\s*"[^"]+"/g, '"accessToken":"[REDACTED]"')
577
- .replace(/"refresh[Tt]oken"\s*:\s*"[^"]+"/g, '"refreshToken":"[REDACTED]"')
578
- .replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token":"[REDACTED]"')
579
- .replace(/"refresh_token"\s*:\s*"[^"]+"/g, '"refresh_token":"[REDACTED]"');
580
- }
581
-
582
- async function refreshOAuthCredentials(creds) {
583
- if (!creds?.refreshToken) {
584
- throw new Error('Anthropic OAuth refresh token not available. Open /providers in mixdog to sign in again.');
585
- }
586
-
587
- const controller = new AbortController();
588
- const timeout = setTimeout(() => controller.abort(), 30_000);
589
- try {
590
- const res = await fetch(TOKEN_URL, {
591
- method: 'POST',
592
- headers: {
593
- 'Content-Type': 'application/json',
594
- 'anthropic-dangerous-direct-browser-access': 'true',
595
- 'user-agent': `claude-cli/${resolveCliVersion()} (external, sdk-cli)`,
596
- },
597
- body: JSON.stringify({
598
- grant_type: 'refresh_token',
599
- refresh_token: creds.refreshToken,
600
- client_id: CLAUDE_CODE_CLIENT_ID,
601
- }),
602
- // Never follow a redirect on a secret-bearing request: a token
603
- // endpoint that 307/308-redirects would replay the refresh_token to
604
- // the redirect target. Fail loud instead.
605
- redirect: 'error',
606
- signal: controller.signal,
607
- dispatcher: getLlmDispatcher(),
608
- });
609
-
610
- const text = await res.text();
611
- let json = null;
612
- try { json = text ? JSON.parse(text) : null; } catch { /* handled below */ }
613
- if (!res.ok) {
614
- const isInvalidGrant = text.includes('invalid_grant') || json?.error === 'invalid_grant';
615
- throw Object.assign(new Error(`token refresh ${res.status}: ${_scrubTokens(text).slice(0, 200)}`), { isInvalidGrant });
616
- }
617
-
618
- const accessToken = json?.access_token || json?.accessToken;
619
- if (!accessToken) throw new Error('token refresh returned no access token');
620
- const expiresAt = _normalizeExpiresAt(json?.expires_at ?? json?.expiresAt)
621
- || (typeof json?.expires_in === 'number' ? Date.now() + json.expires_in * 1000 : 0);
622
- const refreshed = {
623
- path: creds.path,
624
- accessToken,
625
- refreshToken: json?.refresh_token || json?.refreshToken || creds.refreshToken,
626
- expiresAt,
627
- scopes: Array.isArray(json?.scope) ? json.scope : creds.scopes,
628
- subscriptionType: creds.subscriptionType,
629
- };
630
- // Persist rotated tokens back so any other Mixdog reader of the same
631
- // credentials file picks up the new refresh_token. Without this, a
632
- // later process can replay an old single-use refresh token and loop on
633
- // invalid_grant.
634
- if (creds.path && existsSync(creds.path)) {
635
- try {
636
- const raw = JSON.parse(readFileSync(creds.path, 'utf-8'));
637
- raw.claudeAiOauth = {
638
- ...(raw.claudeAiOauth || {}),
639
- accessToken: refreshed.accessToken,
640
- refreshToken: refreshed.refreshToken,
641
- expiresAt: refreshed.expiresAt,
642
- scopes: refreshed.scopes,
643
- };
644
- _saveCredentialsFile(creds.path, raw);
645
- } catch (err) {
646
- process.stderr.write(`[anthropic-oauth] credential save failed: ${_scrubTokens(err?.message || String(err)).slice(0, 200)}\n`);
647
- throw new Error(`[oauth] credentials save failed: ${err?.message ?? String(err)}`);
648
- }
649
- }
650
- return refreshed;
651
- } catch (err) {
652
- if (err?.name === 'AbortError') {
653
- throw new Error('Anthropic OAuth token refresh timed out after 30000ms');
654
- }
655
- throw err;
656
- } finally {
657
- clearTimeout(timeout);
658
- }
659
- }
660
-
661
- // Exported so callers can detect re-auth-required scenarios and prompt the user.
662
- export class ReauthRequired extends Error {
663
- constructor(message) {
664
- super(message);
665
- this.name = 'ReauthRequired';
666
- }
667
- }
668
-
669
214
  // --- Message conversion ---
670
215
 
671
216
  function withCacheControl(block, ttl = CACHE_TTL_VOLATILE) {
@@ -913,576 +458,7 @@ function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_
913
458
  return sanitizedMessages;
914
459
  }
915
460
 
916
- // --- SSE parser ---
917
-
918
- function _captureMidstreamAbort(state, reason) {
919
- if (!state) return;
920
- const reasonName = reason?.name || '';
921
- if (reasonName === 'AgentStallAbortError' || reasonName === 'StreamStalledAbortError') {
922
- state.watchdogAbort = reasonName;
923
- } else {
924
- state.userAbort = true;
925
- }
926
- }
927
-
928
- // Abort-aware mid-stream backoff sleep → shared sleepWithAbort
929
- // (retry-classifier.mjs). abortMessage preserves the prior fallback text.
930
- function _midstreamSleepWithAbort(ms, signal) {
931
- return sleepWithAbort(ms, signal, undefined, 'Anthropic OAuth mid-stream retry backoff aborted');
932
- }
933
-
934
- function _statusForAnthropicSseError(type, message) {
935
- const kind = String(type || '').toLowerCase();
936
- const text = String(message || '').toLowerCase();
937
- if (kind.includes('overload') || text.includes('overload')) return 503;
938
- if (kind.includes('rate_limit') || text.includes('rate limit') || text.includes('quota')) return 429;
939
- if (kind.includes('authentication') || text.includes('authentication') || text.includes('unauthorized')) return 401;
940
- if (kind.includes('permission') || text.includes('forbidden')) return 403;
941
- if (kind.includes('not_found') || text.includes('not found')) return 404;
942
- if (kind.includes('invalid_request')) return 400;
943
- return 0;
944
- }
945
-
946
- function _anthropicSseError(event) {
947
- const payload = event?.error && typeof event.error === 'object' ? event.error : event;
948
- const type = payload?.type || event?.type || 'error';
949
- const message = payload?.message || 'Anthropic SSE error';
950
- const err = new Error(`Anthropic OAuth SSE error ${type}: ${message}`);
951
- err.name = 'AnthropicSseError';
952
- err.code = 'EANTHROPIC_SSE_ERROR';
953
- err.providerErrorType = type;
954
- err.requestId = event?.request_id || event?.requestId || null;
955
- const status = _statusForAnthropicSseError(type, message);
956
- if (status) {
957
- err.httpStatus = status;
958
- err.status = status;
959
- }
960
- return err;
961
- }
962
-
963
- async function parseSSEStream(response, signal, abortStream, onStreamDelta, onToolCall, state, onTextDelta, knownToolNames) {
964
- const reader = response.body.getReader();
965
- const decoder = new TextDecoder();
966
- // SEMANTIC idle window: reset only by real model events (message/content/
967
- // tool deltas), NOT by raw keepalive bytes. A ping-only wedge therefore
968
- // trips this within the window instead of hanging until the 30-min agent
969
- // watchdog. See resetIdleTimer + the per-event reset in the loop below.
970
- // state.semanticIdleTimeoutMs is a test/override seam (same shape as
971
- // firstMessageTimeoutMs); production uses the shared env-backed default.
972
- const SSE_IDLE_TIMEOUT_MS = Number.isFinite(Number(state?.semanticIdleTimeoutMs))
973
- && Number(state.semanticIdleTimeoutMs) > 0
974
- ? Number(state.semanticIdleTimeoutMs)
975
- : PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
976
- const SSE_FIRST_MESSAGE_TIMEOUT_MS = Number.isFinite(Number(state?.firstMessageTimeoutMs))
977
- && Number(state.firstMessageTimeoutMs) > 0
978
- ? Number(state.firstMessageTimeoutMs)
979
- : PROVIDER_FIRST_BYTE_TIMEOUT_MS;
980
- let content = '';
981
- let hasThinkingContent = false;
982
- const contentBlockTypes = new Set();
983
- let model = '';
984
- let toolCalls = [];
985
- let usage = { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, raw: null };
986
- let stopReason = null;
987
- let buffer = '';
988
- let idleTimedOut = false;
989
- let firstMessageTimedOut = false;
990
- let idleTimer = null;
991
- let firstMessageTimer = null;
992
- let currentEvent = '';
993
-
994
- const pendingToolInputs = new Map();
995
-
996
- // Leaked tool-call guard. The model (esp. Opus via OAuth) occasionally
997
- // emits a tool call as plain text tags inside `text_delta` instead of a
998
- // native `tool_use` block. `leakBuffer` is a minimal rolling window that
999
- // only holds back text when a partial sentinel prefix is present, so a
1000
- // tag split across chunk boundaries is still detected while ordinary text
1001
- // still streams promptly. The guard is additive: the native tool_use path
1002
- // (content_block_start/input_json_delta/content_block_stop) is untouched.
1003
- const _knownTools = knownToolNames instanceof Set
1004
- ? knownToolNames
1005
- : new Set(Array.isArray(knownToolNames) ? knownToolNames : []);
1006
- const _leakGuardEnabled = _knownTools.size > 0;
1007
- const _isKnownTool = (name) => _knownTools.has(name);
1008
- let leakBuffer = '';
1009
- // Running markdown fence/inline-code state threaded across text_delta
1010
- // chunks (Fix 1): a tool-call tag inside a ```code fence``` or inline span
1011
- // is a doc example, not a real call — the guard emits it as text.
1012
- let leakFenceState = undefined;
1013
- // Cross-path fingerprint dedupe (Fix 2): a synthesized text-leaked call and
1014
- // an identical native tool_use block must dispatch onToolCall exactly once.
1015
- const _toolDedupe = createToolCallDedupe();
1016
-
1017
- // Synthesize + dispatch a recovered leaked call exactly like the native
1018
- // content_block_stop path (push into toolCalls, flag state, eager
1019
- // onToolCall). A generated id uses the same `toolu_`-prefixed shape as
1020
- // Anthropic's native tool-call ids.
1021
- const dispatchLeakedCall = (recovered) => {
1022
- let args = recovered?.arguments;
1023
- if (args === null || typeof args !== 'object' || Array.isArray(args)) args = {};
1024
- // Skip if an identical native (or prior synthetic) call already fired.
1025
- if (!_toolDedupe.shouldDispatch(recovered.name, args)) return;
1026
- const call = {
1027
- id: `toolu_leaked_${randomBytes(8).toString('hex')}`,
1028
- name: recovered.name,
1029
- arguments: args,
1030
- };
1031
- toolCalls.push(call);
1032
- if (state) state.emittedToolCall = true;
1033
- try { onToolCall?.(call); } catch {}
1034
- try { onStreamDelta?.(); } catch {}
1035
- };
1036
-
1037
- // Feed accumulated text through the scanner. On `final` nothing is held
1038
- // back so legitimate text is never lost at stream end.
1039
- const pumpLeakBuffer = (final) => {
1040
- if (!_leakGuardEnabled) return;
1041
- if (!leakBuffer && !final) return;
1042
- const { emit, calls, rest, fenceState } = scanLeakedToolCalls(leakBuffer, { isKnownTool: _isKnownTool, final, fenceState: leakFenceState });
1043
- leakBuffer = rest;
1044
- leakFenceState = fenceState;
1045
- if (emit) {
1046
- content += emit;
1047
- if (onTextDelta) {
1048
- if (state) state.emittedText = true;
1049
- try { onTextDelta(emit); } catch {}
1050
- }
1051
- }
1052
- for (const c of calls) dispatchLeakedCall(c);
1053
- };
1054
-
1055
- // Holds the in-flight reader.read() race rejector so the idle timer can
1056
- // force-unblock the loop even when reader.cancel() fails to settle the
1057
- // pending read (undici half-open socket). See resetIdleTimer below.
1058
- let idleReject = null;
1059
-
1060
- const firstMessageTimeoutError = () => {
1061
- const err = new Error(`Anthropic OAuth SSE stream produced no message_start within ${SSE_FIRST_MESSAGE_TIMEOUT_MS}ms`);
1062
- err.code = 'EEMPTYSTREAM';
1063
- err.isEmptyStream = true;
1064
- err.firstByteTimeout = true;
1065
- return err;
1066
- };
1067
-
1068
- const clearFirstMessageTimer = () => {
1069
- if (firstMessageTimer) {
1070
- clearTimeout(firstMessageTimer);
1071
- firstMessageTimer = null;
1072
- }
1073
- };
1074
-
1075
- const armFirstMessageTimer = () => {
1076
- if (!(SSE_FIRST_MESSAGE_TIMEOUT_MS > 0)) return;
1077
- clearFirstMessageTimer();
1078
- firstMessageTimer = setTimeout(() => {
1079
- if (state?.sawMessageStart) return;
1080
- firstMessageTimedOut = true;
1081
- const err = firstMessageTimeoutError();
1082
- try { abortStream?.(err); } catch (abortErr) {
1083
- try { process.stderr.write(`[anthropic-oauth] sse first-message abortStream failed: ${abortErr?.message ?? String(abortErr)}\n`); } catch {}
1084
- }
1085
- try {
1086
- const _c = reader.cancel('SSE first message timeout');
1087
- if (_c && typeof _c.catch === 'function') _c.catch(() => {});
1088
- } catch (cancelErr) {
1089
- try { process.stderr.write(`[anthropic-oauth] sse first-message cancel failed: ${cancelErr?.message ?? String(cancelErr)}\n`); } catch {}
1090
- }
1091
- if (idleReject) {
1092
- const r = idleReject; idleReject = null; r(err);
1093
- }
1094
- }, SSE_FIRST_MESSAGE_TIMEOUT_MS);
1095
- try { firstMessageTimer.unref?.(); } catch {}
1096
- };
1097
-
1098
- // Attach the partial stream state to a mid-stream stall error so the agent
1099
- // loop can decide SUCCESS vs FAILURE. The recurring "worker finished but
1100
- // owner never notified" case is a FINAL no-tool summary stream that wedges
1101
- // ping-only after the real work (tool calls) already completed in earlier
1102
- // iterations: there is streamed `content`, no pending tool_use, and no
1103
- // emitted tool call this iteration. The loop treats that as a successful
1104
- // partial-final (deliver the summary we have) instead of dropping it. A
1105
- // stall WITH a pending/emitted tool call stays a hard failure (a tool whose
1106
- // input never completed must never be reported as done).
1107
- const _attachStallPartial = (err) => {
1108
- try {
1109
- err.partialContent = content;
1110
- err.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
1111
- err.pendingToolUse = pendingToolInputs.size > 0;
1112
- err.partialModel = model || undefined;
1113
- err.partialUsage = usage;
1114
- err.partialStopReason = stopReason || undefined;
1115
- err.partialHasThinking = hasThinkingContent;
1116
- } catch { /* best-effort enrichment */ }
1117
- return err;
1118
- };
1119
-
1120
- const resetIdleTimer = () => {
1121
- // OFF by default. When disabled the
1122
- // idle timer never arms, so the stream is never killed on inactivity;
1123
- // the agent stall watchdog (600s) remains the dead-stream backstop.
1124
- if (!PROVIDER_SSE_IDLE_WATCHDOG_ENABLED) return;
1125
- if (idleTimer) clearTimeout(idleTimer);
1126
- idleTimer = setTimeout(() => {
1127
- idleTimedOut = true;
1128
- try { abortStream?.(); } catch (err) {
1129
- try { process.stderr.write(`[anthropic-oauth] sse idle abortStream failed: ${err?.message ?? String(err)}\n`); } catch {}
1130
- }
1131
- try {
1132
- const _c = reader.cancel('SSE idle timeout');
1133
- if (_c && typeof _c.catch === 'function') _c.catch(() => {});
1134
- } catch (err) {
1135
- try { process.stderr.write(`[anthropic-oauth] sse idle cancel failed: ${err?.message ?? String(err)}\n`); } catch {}
1136
- }
1137
- // Force-reject the in-flight reader.read() race even when reader.cancel()
1138
- // fails to settle the pending read: without this the await below stays
1139
- // pending forever and the SSE idle timeout never unblocks the loop —
1140
- // the 391s-hang root cause.
1141
- if (idleReject) {
1142
- const e = _attachStallPartial(streamStalledError('Anthropic OAuth SSE', SSE_IDLE_TIMEOUT_MS, { emittedToolCall: !!state?.emittedToolCall }));
1143
- const r = idleReject; idleReject = null; r(e);
1144
- }
1145
- // Shared provider policy: short SEMANTIC-event inactivity catches the
1146
- // ping-only wedge where SSE starts, emits some deltas, then goes silent
1147
- // while `:ping` keepalives keep the transport socket warm.
1148
- }, SSE_IDLE_TIMEOUT_MS);
1149
- try { idleTimer.unref?.(); } catch {}
1150
- };
1151
-
1152
- const onAbort = () => {
1153
- try {
1154
- const _c = reader.cancel('SSE aborted');
1155
- if (_c && typeof _c.catch === 'function') _c.catch(() => {});
1156
- } catch {}
1157
- };
1158
- if (signal) {
1159
- if (signal.aborted) {
1160
- _captureMidstreamAbort(state, signal.reason);
1161
- throw signal.reason instanceof Error
1162
- ? signal.reason
1163
- : new Error('Anthropic OAuth SSE stream aborted');
1164
- }
1165
- signal.addEventListener('abort', onAbort, { once: true });
1166
- }
1167
-
1168
- try {
1169
- // Part A / reviewer fix: do NOT arm the SEMANTIC idle timer before the
1170
- // stream has produced its first event. A slow first response is governed
1171
- // by armFirstMessageTimer() (first-byte window) alone; arming the
1172
- // semantic idle here could let it win and mis-abort a legitimately slow
1173
- // first response as a stall. The semantic idle is first armed at
1174
- // `message_start` (see below), so it only ever guards MID-stream silence.
1175
- armFirstMessageTimer();
1176
- streamLoop: while (true) {
1177
- let chunk;
1178
- try {
1179
- // Race the read against the idle timer's rejector so a stuck
1180
- // reader.read() (cancel did not settle it) still unblocks here.
1181
- chunk = await new Promise((resolve, reject) => {
1182
- idleReject = reject;
1183
- reader.read().then(resolve, reject);
1184
- });
1185
- } catch (err) {
1186
- if (idleTimedOut) {
1187
- throw _attachStallPartial(streamStalledError('Anthropic OAuth SSE', SSE_IDLE_TIMEOUT_MS, { emittedToolCall: !!state?.emittedToolCall }));
1188
- }
1189
- if (firstMessageTimedOut) {
1190
- throw firstMessageTimeoutError();
1191
- }
1192
- if (signal?.aborted) {
1193
- _captureMidstreamAbort(state, signal.reason);
1194
- throw signal.reason instanceof Error
1195
- ? signal.reason
1196
- : new Error('Anthropic OAuth SSE stream aborted');
1197
- }
1198
- throw err;
1199
- }
1200
- const { done, value } = chunk;
1201
- if (done) break;
1202
-
1203
- buffer += decoder.decode(value, { stream: true });
1204
- const lines = buffer.split('\n');
1205
- buffer = lines.pop() || '';
1206
-
1207
- for (const line of lines) {
1208
- if (line.startsWith(':')) {
1209
- // SSE comment frame (Anthropic `:ping` keepalive). Keep it
1210
- // at transport level only: comments must not refresh the
1211
- // agent's semantic progress timestamp, or a ping-only 200
1212
- // can look alive forever without message_start/content.
1213
- // Crucially it also does NOT reset the SEMANTIC idle timer
1214
- // below — a ping-only wedge must trip the idle abort.
1215
- continue;
1216
- }
1217
- // Blank lines are SSE record separators — emitted after EVERY
1218
- // frame, including `:ping` keepalives — so they are NOT semantic
1219
- // progress and must not reset the idle timer (else a ping frame's
1220
- // trailing blank would keep a wedge alive forever).
1221
- if (line === '') continue;
1222
- if (line.startsWith('event: ')) {
1223
- currentEvent = line.slice(7).trim();
1224
- continue;
1225
- }
1226
- if (!line.startsWith('data: ')) continue;
1227
- const data = line.slice(6).trim();
1228
- if (!data) continue;
1229
-
1230
- try {
1231
- const event = JSON.parse(data);
1232
-
1233
- // SEMANTIC idle reset (Part A): reset the idle timer ONLY for
1234
- // real progress events, NOT for Anthropic keepalives. Anthropic
1235
- // sends pings as a NAMED event (`event: ping` /
1236
- // `data: {"type":"ping"}`), not just `:` comment frames, so a
1237
- // named ping must be excluded here or a ping-only wedge keeps
1238
- // the timer alive forever. Everything that is not a ping is a
1239
- // genuine server event (message_start/content/tool/thinking
1240
- // deltas, message_delta/stop, errors) and counts as progress.
1241
- if (currentEvent !== 'ping' && event?.type !== 'ping') {
1242
- resetIdleTimer();
1243
- }
1244
-
1245
- if (currentEvent === 'error' || event?.type === 'error' || event?.error) {
1246
- throw _anthropicSseError(event);
1247
- }
1248
-
1249
- if (event.type === 'message_start' && event.message) {
1250
- clearFirstMessageTimer();
1251
- if (state) state.sawMessageStart = true;
1252
- if (event.message.model) model = event.message.model;
1253
- if (event.message.usage) {
1254
- usage.inputTokens = event.message.usage.input_tokens || 0;
1255
- usage.cachedTokens = event.message.usage.cache_read_input_tokens || 0;
1256
- usage.cacheWriteTokens = event.message.usage.cache_creation_input_tokens || 0;
1257
- usage.raw = { ...event.message.usage };
1258
- }
1259
- }
1260
-
1261
- if (event.type === 'content_block_start') {
1262
- const block = event.content_block;
1263
- if (block?.type === 'tool_use') {
1264
- pendingToolInputs.set(event.index, {
1265
- id: block.id || '',
1266
- name: block.name || '',
1267
- inputJson: '',
1268
- });
1269
- }
1270
- }
1271
-
1272
- if (event.type === 'content_block_delta') {
1273
- const delta = event.delta;
1274
- if (delta?.type) contentBlockTypes.add(delta.type);
1275
- // Time-to-first-token: stamp the first content delta
1276
- // (text / thinking / tool input_json) exactly once so
1277
- // the SSE trace can separate first-byte latency from
1278
- // total stream/generation time. Without this stamp
1279
- // ttftMs was always null and reported as 0ms.
1280
- if (state && !state.ttftAt) state.ttftAt = Date.now();
1281
- if (delta?.type === 'text_delta') {
1282
- try { onStreamDelta?.(); } catch {}
1283
- // Live text relay (gateway): forward the explicit
1284
- // text chunk. thinking/signature/input_json deltas
1285
- // intentionally stay off this path.
1286
- // Invariant: once a non-empty chunk has been relayed
1287
- // live it cannot be withdrawn, so flag the attempt so
1288
- // the mid-stream retry loop treats any later failure
1289
- // as final (a retry would concatenate attempts).
1290
- if (_leakGuardEnabled) {
1291
- // Route text through the leaked-tool-call guard.
1292
- // It appends to `content`, forwards visible text
1293
- // via onTextDelta, and synthesizes/dispatches any
1294
- // recovered known-tool call — suppressing the
1295
- // tags from the visible stream. A partial sentinel
1296
- // is held in leakBuffer until the next chunk.
1297
- leakBuffer += delta.text || '';
1298
- pumpLeakBuffer(false);
1299
- } else {
1300
- content += delta.text || '';
1301
- if (delta.text && onTextDelta) {
1302
- if (state) state.emittedText = true;
1303
- try { onTextDelta(delta.text); } catch {}
1304
- }
1305
- }
1306
- }
1307
- if (delta?.type === 'thinking_delta' || delta?.type === 'signature_delta') {
1308
- // Extended-thinking block: provider reasoning without
1309
- // user-visible text. Track presence so a final turn
1310
- // that emitted ONLY thinking (no text_delta, no
1311
- // tool_use) can be classified by the loop as
1312
- // synthesis-stalled rather than silent empty.
1313
- hasThinkingContent = true;
1314
- try { onStreamDelta?.(); } catch {}
1315
- }
1316
- if (delta?.type === 'input_json_delta') {
1317
- const pending = pendingToolInputs.get(event.index);
1318
- if (pending) {
1319
- pending.inputJson += delta.partial_json || '';
1320
- }
1321
- try { onStreamDelta?.(); } catch {}
1322
- }
1323
- }
1324
-
1325
- if (event.type === 'content_block_stop') {
1326
- const pending = pendingToolInputs.get(event.index);
1327
- if (pending) {
1328
- // Bare JSON.parse threw straight up into the
1329
- // surrounding broad catch, which swallowed the
1330
- // whole tool_call — the loop never saw it and
1331
- // the assistant turn ended with an unmatched
1332
- // tool_use id. Wrap the parse so a malformed
1333
- // input still produces a tool_call (with an
1334
- // invalid-args marker and a logged error) instead
1335
- // of a silent drop or accidental `{}` dispatch.
1336
- let parsedArgs = {};
1337
- if (pending.inputJson) {
1338
- try { parsedArgs = JSON.parse(pending.inputJson); }
1339
- catch (parseErr) {
1340
- process.stderr.write(`[anthropic-oauth] tool args JSON.parse failed (id=${pending.id}, name=${pending.name}): ${parseErr?.message || parseErr}\n`);
1341
- parsedArgs = makeInvalidToolArgsMarker(pending.inputJson, parseErr instanceof Error ? parseErr.message : String(parseErr));
1342
- }
1343
- }
1344
- // Tool arguments must be a plain object. Anthropic's
1345
- // tool_use input is always a JSON object, but a
1346
- // malformed stream could parse to an array/string/
1347
- // number — wrap those as {} to keep the contract
1348
- // (invariant-based, no heuristic coercion).
1349
- if (parsedArgs === null
1350
- || typeof parsedArgs !== 'object'
1351
- || Array.isArray(parsedArgs)) {
1352
- 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`);
1353
- parsedArgs = {};
1354
- }
1355
- const call = {
1356
- id: pending.id,
1357
- name: pending.name,
1358
- arguments: parsedArgs,
1359
- };
1360
- pendingToolInputs.delete(event.index);
1361
- // Eager dispatch: let the loop start this tool
1362
- // before message_stop arrives. The loop keys
1363
- // pending promises by call.id so order is safe.
1364
- // Fix 2: skip the ENTIRE call (push + dispatch) when a
1365
- // text-leaked synthetic of the same (name,args) already
1366
- // fired — otherwise the duplicate stays in `toolCalls`
1367
- // and the loop executes the side-effecting tool twice.
1368
- // An invalid-args marker never fingerprint-collides with
1369
- // a real recovered call, so malformed native calls still
1370
- // dispatch (the marker path is unaffected).
1371
- if (_toolDedupe.shouldDispatch(call.name, call.arguments)) {
1372
- toolCalls.push(call);
1373
- if (state) state.emittedToolCall = true;
1374
- // Eager dispatch: let the loop start this tool
1375
- // before message_stop arrives. The loop keys
1376
- // pending promises by call.id so order is safe.
1377
- try { onToolCall?.(call); } catch {}
1378
- }
1379
- try { onStreamDelta?.(); } catch {}
1380
- }
1381
- }
1382
-
1383
- if (event.type === 'message_delta') {
1384
- if (event.delta?.stop_reason) {
1385
- stopReason = event.delta.stop_reason;
1386
- }
1387
- if (event.usage) {
1388
- usage.outputTokens = event.usage.output_tokens || 0;
1389
- usage.raw = { ...(usage.raw || {}), ...event.usage };
1390
- }
1391
- if (stopReason === 'tool_use' && toolCalls.length > 0 && pendingToolInputs.size === 0) {
1392
- if (state) state.sawCompleted = true;
1393
- break streamLoop;
1394
- }
1395
- }
1396
- if (event.type === 'message_stop') {
1397
- if (state) state.sawCompleted = true;
1398
- // Anthropic streams can keep emitting `:ping` keepalive
1399
- // frames after `message_stop`; if we wait for EOF the
1400
- // outer reader.read() loop hangs indefinitely. Break
1401
- // out of streamLoop the moment the message ends.
1402
- break streamLoop;
1403
- }
1404
- // Unified prompt volume — what the model actually ingested.
1405
- // Anthropic splits input into three billable slots (uncached
1406
- // input + cache_read + cache_create); keep them separate for
1407
- // cost math but also expose the sum so cross-provider logs
1408
- // have a consistent `promptTokens` meaning.
1409
- usage.promptTokens = (usage.inputTokens || 0)
1410
- + (usage.cachedTokens || 0)
1411
- + (usage.cacheWriteTokens || 0);
1412
- } catch (err) {
1413
- if (err?.code === 'EANTHROPIC_SSE_ERROR') throw err;
1414
- /* skip malformed events */
1415
- }
1416
- }
1417
- }
1418
-
1419
- // Stream ended: flush any held-back leaked-tool-call buffer. `final`
1420
- // holds nothing back, so a trailing partial sentinel that never
1421
- // resolved into a real call is surfaced as ordinary text — legitimate
1422
- // user-visible content is never lost on the failure path.
1423
- pumpLeakBuffer(true);
1424
-
1425
- // Truncated-stream guard: if the reader loop exited (EOF or break)
1426
- // after message_start but without seeing message_stop / a tool_use
1427
- // stop_reason, the assistant turn was cut off mid-flight. Returning
1428
- // success here would silently surface partial content (or a partially
1429
- // streamed tool_use whose input_json never completed) as final.
1430
- // Throw a typed truncated-stream error so the loop can decide whether
1431
- // to retry, surface, or escalate instead of accepting the partial.
1432
- if (state?.sawMessageStart && !state?.sawCompleted) {
1433
- const pendingToolUse = pendingToolInputs.size > 0;
1434
- const err = Object.assign(
1435
- new Error(
1436
- `Anthropic OAuth SSE stream truncated: message_start without message_stop`
1437
- + (pendingToolUse ? ` (pending tool_use input)` : ''),
1438
- ),
1439
- {
1440
- name: 'TruncatedStreamError',
1441
- code: 'TRUNCATED_STREAM',
1442
- truncatedStream: true,
1443
- pendingToolUse,
1444
- stopReason,
1445
- },
1446
- );
1447
- throw err;
1448
- }
1449
-
1450
- return {
1451
- content,
1452
- model,
1453
- toolCalls: toolCalls.length ? toolCalls : undefined,
1454
- usage,
1455
- stopReason,
1456
- hasThinkingContent,
1457
- contentBlockTypes: Array.from(contentBlockTypes),
1458
- };
1459
- } finally {
1460
- if (idleTimer) clearTimeout(idleTimer);
1461
- clearFirstMessageTimer();
1462
- if (signal) signal.removeEventListener('abort', onAbort);
1463
- try { reader.releaseLock(); } catch (err) {
1464
- try { process.stderr.write(`[anthropic-oauth] reader releaseLock failed: ${err?.message ?? String(err)}\n`); } catch {}
1465
- }
1466
- }
1467
- }
1468
-
1469
- /**
1470
- * Classify an Anthropic SSE failure for single-shot mid-stream retry.
1471
- *
1472
- * Retry is allowed only after `message_start` and before `message_stop`,
1473
- * and only when no tool call has already been surfaced to the loop.
1474
- * That keeps recovery limited to transport/stream stalls without risking
1475
- * duplicate eager tool execution.
1476
- */
1477
- // Thin wrapper: the SSE mid-stream decision tree now lives in the shared
1478
- // classifyMidstreamError (retry-classifier.mjs, policy.mode='sse'). Kept as a
1479
- // named export so internal call sites AND anthropic.mjs (which imports this
1480
- // symbol) keep resolving it. Behavior is byte-identical — the shared function
1481
- // is the relocated original, gated by SSE_MIDSTREAM_POLICY (defaultRetries=3,
1482
- // perClassifierGate:false).
1483
- export function _classifyMidstreamError(err, state) {
1484
- return classifyMidstreamError(err, state, SSE_MIDSTREAM_POLICY);
1485
- }
461
+ // --- SSE parser + midstream retry policy: extracted to anthropic-sse.mjs ---
1486
462
 
1487
463
  // --- Build request body ---
1488
464
 
@@ -1511,12 +487,30 @@ function resolveCacheTtls(opts) {
1511
487
  // free for sessions that don't carry one. Previously null here meant any
1512
488
  // caller that skipped agent runtime resolve (CLI, raw agent spawn)
1513
489
  // silently lost the tier3 cache layer even though it supported one.
1514
- return {
490
+ const resolved = {
1515
491
  tools: pick('tools', null),
1516
492
  system: pick('system', CACHE_TTL_STABLE),
1517
493
  tier3: pick('tier3', CACHE_TTL_STABLE),
1518
494
  messages: pick('messages', CACHE_TTL_STABLE),
1519
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;
1520
514
  }
1521
515
 
1522
516
  // BP3 (tier3) is injected by session/manager as its own `system` role block —
@@ -2149,7 +1143,7 @@ export class AnthropicOAuthProvider {
2149
1143
  // works offline or when Anthropic's /v1/models is momentarily down.
2150
1144
  const cached = await _loadModelCache();
2151
1145
  if (cached) {
2152
- _inMemoryCatalog = cached.slice();
1146
+ _setInMemoryCatalog(cached);
2153
1147
  return cached;
2154
1148
  }
2155
1149
  try {
@@ -2170,13 +1164,8 @@ export class AnthropicOAuthProvider {
2170
1164
  if (!res.ok) throw new Error(`list_models ${res.status}`);
2171
1165
  const data = await res.json();
2172
1166
  const items = Array.isArray(data?.data) ? data.data : [];
2173
- const normalized = items
2174
- .map(m => _normalizeAnthropicModel(m))
2175
- .filter(Boolean);
2176
- _markLatestByFamily(normalized);
2177
- // Enrich with LiteLLM catalog metadata (context, pricing, capabilities)
2178
- const enriched = await enrichModels(normalized);
2179
- await _saveModelCache(enriched);
1167
+ // Normalize + mark-latest + LiteLLM-enrich + persist (shared helper).
1168
+ const enriched = await normalizeAndSaveCatalog(items);
2180
1169
  return enriched;
2181
1170
  } catch (err) {
2182
1171
  if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(`[anthropic-oauth] listModels fetch failed (${err.message})\n`);
@@ -2218,12 +1207,7 @@ export class AnthropicOAuthProvider {
2218
1207
  if (!res.ok) throw new Error(`list_models ${res.status}`);
2219
1208
  const data = await res.json();
2220
1209
  const items = Array.isArray(data?.data) ? data.data : [];
2221
- const normalized = items
2222
- .map(m => _normalizeAnthropicModel(m))
2223
- .filter(Boolean);
2224
- _markLatestByFamily(normalized);
2225
- const enriched = await enrichModels(normalized);
2226
- await _saveModelCache(enriched);
1210
+ const enriched = await normalizeAndSaveCatalog(items);
2227
1211
  if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(`[anthropic-oauth] catalog refreshed (${enriched.length} models)\n`);
2228
1212
  return enriched;
2229
1213
  } catch (err) {
@@ -2241,204 +1225,20 @@ export class AnthropicOAuthProvider {
2241
1225
  }
2242
1226
  }
2243
1227
 
2244
- // --- Login flow (PKCE loopback, export for setup UI / CLI) ---
2245
-
2246
- function _oauthGeneratePKCE() {
2247
- const verifier = randomBytes(32).toString('base64url');
2248
- const challenge = createHash('sha256').update(verifier).digest('base64url');
2249
- return { verifier, challenge };
2250
- }
2251
-
2252
- function _oauthCredentialsWritePath() {
2253
- for (const p of credentialCandidates()) {
2254
- if (existsSync(p)) return p;
2255
- }
2256
- return DEFAULT_CREDENTIALS_PATH;
2257
- }
2258
-
2259
- function _oauthParseScopeField(scope) {
2260
- if (Array.isArray(scope)) return scope;
2261
- return String(scope || '').split(' ').filter(Boolean);
2262
- }
2263
-
2264
- function _parseOAuthCodeInput(input) {
2265
- const value = String(input || '').trim();
2266
- if (!value) return { code: '', state: '' };
2267
- try {
2268
- const url = new URL(value);
2269
- const code = url.searchParams.get('code') || '';
2270
- const state = url.searchParams.get('state') || '';
2271
- if (code || state) return { code, state, redirectUri: `${url.origin}${url.pathname}` };
2272
- } catch { /* not a URL */ }
2273
- if (value.includes('#')) {
2274
- const [code, state] = value.split('#', 2);
2275
- return { code: String(code || '').trim(), state: String(state || '').trim() };
2276
- }
2277
- if (value.includes('code=')) {
2278
- const params = new URLSearchParams(value.startsWith('?') ? value.slice(1) : value);
2279
- return { code: params.get('code') || '', state: params.get('state') || '' };
2280
- }
2281
- return { code: value, state: '' };
2282
- }
2283
-
2284
- async function exchangeAuthorizationCode({ pkce, code, state, redirectUri }) {
2285
- const cleanCode = String(code || '').trim();
2286
- if (!cleanCode) throw new Error('[anthropic-oauth] authorization code is required');
2287
- const tokenRes = await fetch(TOKEN_URL, {
2288
- method: 'POST',
2289
- headers: {
2290
- 'Content-Type': 'application/json',
2291
- 'anthropic-dangerous-direct-browser-access': 'true',
2292
- 'user-agent': `claude-cli/${resolveCliVersion()} (external, sdk-cli)`,
2293
- },
2294
- body: JSON.stringify({
2295
- grant_type: 'authorization_code',
2296
- code: cleanCode,
2297
- redirect_uri: redirectUri,
2298
- client_id: CLAUDE_CODE_CLIENT_ID,
2299
- code_verifier: pkce.verifier,
2300
- state,
2301
- }),
2302
- redirect: 'error',
2303
- signal: AbortSignal.timeout(OAUTH_TOKEN_TIMEOUT_MS),
2304
- dispatcher: getLlmDispatcher(),
2305
- });
2306
- if (!tokenRes.ok) {
2307
- const text = await tokenRes.text().catch(() => '');
2308
- throw new Error(`[anthropic-oauth] token exchange ${tokenRes.status}: ${_scrubTokens(text).slice(0, 500)}`);
2309
- }
2310
- const json = await tokenRes.json();
2311
- const accessToken = json?.access_token || json?.accessToken;
2312
- const refreshToken = json?.refresh_token || json?.refreshToken;
2313
- if (!accessToken || !refreshToken) {
2314
- throw new Error('[anthropic-oauth] token exchange response missing access_token or refresh_token');
2315
- }
2316
- const expiresAt = _normalizeExpiresAt(json?.expires_at ?? json?.expiresAt)
2317
- || (typeof json?.expires_in === 'number' ? Date.now() + json.expires_in * 1000 : 0);
2318
- const scopes = _oauthParseScopeField(json?.scope);
2319
- const credPath = _oauthCredentialsWritePath();
2320
- let raw = {};
2321
- if (existsSync(credPath)) {
2322
- raw = JSON.parse(readFileSync(credPath, 'utf-8'));
2323
- }
2324
- const existingOauth = raw.claudeAiOauth || {};
2325
- raw.claudeAiOauth = {
2326
- ...existingOauth,
2327
- accessToken,
2328
- refreshToken,
2329
- expiresAt,
2330
- scopes,
2331
- subscriptionType: existingOauth.subscriptionType ?? null,
2332
- };
2333
- _saveCredentialsFile(credPath, raw);
2334
- return {
2335
- path: credPath,
2336
- accessToken,
2337
- refreshToken,
2338
- expiresAt,
2339
- scopes,
2340
- subscriptionType: raw.claudeAiOauth.subscriptionType,
2341
- };
2342
- }
2343
-
2344
- export async function beginOAuthLogin() {
2345
- const pkce = _oauthGeneratePKCE();
2346
- const state = randomBytes(32).toString('base64url');
2347
- const buildUrl = (redirectUri) => {
2348
- const url = new URL(CLAUDE_AI_AUTHORIZE_URL);
2349
- url.searchParams.set('code', 'true');
2350
- url.searchParams.set('client_id', CLAUDE_CODE_CLIENT_ID);
2351
- url.searchParams.set('response_type', 'code');
2352
- url.searchParams.set('redirect_uri', redirectUri);
2353
- url.searchParams.set('scope', OAUTH_LOGIN_SCOPE);
2354
- url.searchParams.set('code_challenge', pkce.challenge);
2355
- url.searchParams.set('code_challenge_method', 'S256');
2356
- url.searchParams.set('state', state);
2357
- return url;
2358
- };
2359
- const url = buildUrl(OAUTH_REDIRECT_URI);
2360
- const manualUrl = buildUrl(OAUTH_MANUAL_REDIRECT_URI);
2361
-
2362
- let server = null;
2363
- let timeout = null;
2364
- let finish = null;
2365
- const waitForCallback = new Promise((resolve, reject) => {
2366
- let settled = false;
2367
- finish = (value, error = null) => {
2368
- if (settled) return;
2369
- settled = true;
2370
- if (timeout) clearTimeout(timeout);
2371
- try { server?.close(); } catch { /* already closed */ }
2372
- if (error) reject(error);
2373
- else resolve(value);
2374
- };
2375
- server = createServer(async (req, res) => {
2376
- const u = new URL(req.url || '/', `http://${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}`);
2377
- if (u.pathname !== OAUTH_CALLBACK_PATH) {
2378
- res.writeHead(404);
2379
- res.end();
2380
- return;
2381
- }
2382
- const code = u.searchParams.get('code');
2383
- if (!code || u.searchParams.get('state') !== state) {
2384
- res.writeHead(400);
2385
- res.end('Invalid');
2386
- finish(null);
2387
- return;
2388
- }
2389
- try {
2390
- const tokens = await exchangeAuthorizationCode({ pkce, code, state, redirectUri: OAUTH_REDIRECT_URI });
2391
- res.writeHead(302, { Location: OAUTH_SUCCESS_REDIRECT_URL });
2392
- res.end();
2393
- finish(tokens);
2394
- } catch (err) {
2395
- const error = err instanceof Error ? err : new Error(String(err));
2396
- res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
2397
- res.end(`Claude login failed: ${error.message}`);
2398
- finish(null, error);
2399
- }
2400
- });
2401
- timeout = setTimeout(() => finish(null), OAUTH_LOGIN_TIMEOUT_MS);
2402
- server.listen(OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_HOST, async () => {
2403
- 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`);
2404
- try {
2405
- const { openInBrowser } = await import('../../../shared/open-url.mjs');
2406
- openInBrowser(url.toString());
2407
- } catch (err) {
2408
- process.stderr.write(`[anthropic-oauth] browser open failed: ${String(err?.message || err).slice(0, 200)}\n`);
2409
- }
2410
- });
2411
- server.on('error', (err) => finish(null, new Error(`[anthropic-oauth] callback server failed on ${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}: ${err?.message || err}`)));
2412
- });
2413
-
2414
- return {
2415
- provider: 'anthropic-oauth',
2416
- url: url.toString(),
2417
- manualUrl: manualUrl.toString(),
2418
- waitForCallback,
2419
- completeCode: async (input) => {
2420
- const parsed = _parseOAuthCodeInput(input);
2421
- if (parsed.state && parsed.state !== state) throw new Error('[anthropic-oauth] OAuth state mismatch');
2422
- const redirectUri = parsed.redirectUri || (parsed.state ? OAUTH_MANUAL_REDIRECT_URI : OAUTH_REDIRECT_URI);
2423
- const tokens = await exchangeAuthorizationCode({ pkce, code: parsed.code, state, redirectUri });
2424
- finish?.(tokens);
2425
- return tokens;
2426
- },
2427
- cancel: () => {
2428
- finish?.(null);
2429
- },
2430
- };
2431
- }
2432
-
2433
- export async function loginOAuth() {
2434
- const login = await beginOAuthLogin();
2435
- return await login.waitForCallback;
2436
- }
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
+ };
2437
1238
 
2438
- // Additive exports for test harnesses.
2439
- // Lets the SSE parser be exercised in isolation against a synthetic
2440
- // ReadableStream without needing a live OAuth session.
2441
- 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 };
2442
1242
 
2443
1243
  // Test-only escape hatch for scripts/tool-smoke.mjs to verify the
2444
1244
  // catalog-driven max-tokens resolution without duplicating its logic.