mixdog 0.8.1 → 0.9.1

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 (299) hide show
  1. package/README.md +47 -23
  2. package/package.json +34 -28
  3. package/scripts/_test-folder-dialog.mjs +30 -0
  4. package/scripts/agent-parallel-smoke.mjs +388 -0
  5. package/scripts/agent-tag-reuse-smoke.mjs +183 -0
  6. package/scripts/background-task-meta-smoke.mjs +38 -0
  7. package/scripts/boot-smoke.mjs +52 -9
  8. package/scripts/build-runtime-linux.sh +348 -0
  9. package/scripts/build-runtime-macos.sh +217 -0
  10. package/scripts/build-runtime-windows.ps1 +242 -0
  11. package/scripts/compact-active-turn-test.mjs +68 -0
  12. package/scripts/compact-smoke.mjs +859 -129
  13. package/scripts/compact-trigger-migration-smoke.mjs +187 -0
  14. package/scripts/fix-brief-fn.mjs +35 -0
  15. package/scripts/fix-format-tool-surface.mjs +24 -0
  16. package/scripts/fix-tool-exec-visible.mjs +42 -0
  17. package/scripts/generate-runtime-manifest.mjs +166 -0
  18. package/scripts/hook-bus-test.mjs +330 -0
  19. package/scripts/lead-workflow-smoke.mjs +33 -39
  20. package/scripts/live-worker-smoke.mjs +43 -37
  21. package/scripts/llm-trace-summary.mjs +315 -0
  22. package/scripts/memory-meta-concurrency-test.mjs +20 -0
  23. package/scripts/output-style-smoke.mjs +56 -15
  24. package/scripts/parent-abort-link-test.mjs +44 -0
  25. package/scripts/patch-agent-brief.mjs +48 -0
  26. package/scripts/patch-app.mjs +21 -0
  27. package/scripts/patch-app2.mjs +18 -0
  28. package/scripts/patch-dist-brief.mjs +96 -0
  29. package/scripts/patch-tool-exec.mjs +70 -0
  30. package/scripts/pretool-ask-runtime-test.mjs +54 -0
  31. package/scripts/provider-toolcall-test.mjs +376 -0
  32. package/scripts/reactive-compact-persist-smoke.mjs +124 -0
  33. package/scripts/sanitize-tool-pairs-test.mjs +260 -0
  34. package/scripts/session-context-bench.mjs +205 -33
  35. package/scripts/session-ingest-smoke.mjs +177 -0
  36. package/scripts/set-effort-config-test.mjs +41 -0
  37. package/scripts/smoke-runtime-negative.ps1 +106 -0
  38. package/scripts/smoke-runtime-negative.sh +97 -0
  39. package/scripts/smoke.mjs +25 -0
  40. package/scripts/tool-result-hook-test.mjs +48 -0
  41. package/scripts/tool-smoke.mjs +1222 -90
  42. package/scripts/toolcall-args-test.mjs +150 -0
  43. package/scripts/tui-background-failure-smoke.mjs +73 -0
  44. package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
  45. package/src/agents/debugger/AGENT.md +8 -0
  46. package/src/agents/explore/AGENT.md +4 -0
  47. package/src/agents/heavy-worker/AGENT.md +9 -3
  48. package/src/agents/maintainer/AGENT.md +4 -0
  49. package/src/agents/reviewer/AGENT.md +8 -0
  50. package/src/agents/scheduler-task/AGENT.md +12 -0
  51. package/src/agents/scheduler-task/agent.json +6 -0
  52. package/src/agents/webhook-handler/AGENT.md +12 -0
  53. package/src/agents/webhook-handler/agent.json +6 -0
  54. package/src/agents/worker/AGENT.md +9 -3
  55. package/src/app.mjs +77 -3
  56. package/src/defaults/hidden-roles.json +17 -12
  57. package/src/headless-role.mjs +117 -0
  58. package/src/help.mjs +30 -0
  59. package/src/hooks/lib/permission-evaluator.cjs +11 -475
  60. package/src/lib/keychain-cjs.cjs +9 -1
  61. package/src/lib/mixdog-debug.cjs +0 -29
  62. package/src/lib/plugin-paths.cjs +1 -7
  63. package/src/lib/rules-builder.cjs +242 -98
  64. package/src/lib/text-utils.cjs +1 -1
  65. package/src/mixdog-session-runtime.mjs +2211 -446
  66. package/src/output-styles/default.md +12 -28
  67. package/src/output-styles/extreme-simple.md +9 -6
  68. package/src/output-styles/simple.md +22 -9
  69. package/src/repl.mjs +108 -59
  70. package/src/rules/agent/00-common.md +15 -0
  71. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  72. package/src/rules/agent/30-explorer.md +22 -0
  73. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  75. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  76. package/src/rules/lead/01-general.md +9 -5
  77. package/src/rules/lead/04-workflow.md +51 -12
  78. package/src/rules/lead/lead-tool.md +6 -0
  79. package/src/rules/shared/01-tool.md +12 -1
  80. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  81. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  82. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  85. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  86. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  87. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  88. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  89. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  90. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  92. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  93. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  94. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  95. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +380 -268
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +131 -93
  97. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  98. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  100. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  101. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  102. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  103. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  104. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  107. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +203 -71
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +512 -312
  109. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -518
  110. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  111. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +289 -11
  113. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  114. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  115. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  116. package/src/runtime/agent/orchestrator/session/loop.mjs +840 -612
  117. package/src/runtime/agent/orchestrator/session/manager.mjs +1574 -459
  118. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  119. package/src/runtime/agent/orchestrator/session/store.mjs +67 -29
  120. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  121. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  122. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  123. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +35 -8
  124. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -8
  125. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -53
  126. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  127. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  130. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  132. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  133. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  138. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  139. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  140. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  141. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  142. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  143. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  146. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  147. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  148. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  149. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  150. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  151. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  152. package/src/runtime/channels/index.mjs +149 -51
  153. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  154. package/src/runtime/channels/lib/config.mjs +0 -1
  155. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  156. package/src/runtime/channels/lib/executor.mjs +0 -3
  157. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  158. package/src/runtime/channels/lib/output-forwarder.mjs +1 -8
  159. package/src/runtime/channels/lib/runtime-paths.mjs +0 -6
  160. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  161. package/src/runtime/channels/lib/session-discovery.mjs +3 -6
  162. package/src/runtime/channels/lib/tool-format.mjs +0 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -12
  164. package/src/runtime/channels/lib/webhook.mjs +1 -1
  165. package/src/runtime/channels/tool-defs.mjs +29 -29
  166. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  167. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  168. package/src/runtime/memory/index.mjs +519 -118
  169. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  170. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  171. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  172. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  173. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  174. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  175. package/src/runtime/memory/lib/memory-cycle1.mjs +28 -7
  176. package/src/runtime/memory/lib/memory-cycle2.mjs +4 -5
  177. package/src/runtime/memory/lib/memory-cycle3.mjs +2 -2
  178. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -2
  179. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  180. package/src/runtime/memory/lib/memory.mjs +88 -9
  181. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  182. package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
  183. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  184. package/src/runtime/memory/lib/runtime-fetcher.mjs +80 -21
  185. package/src/runtime/memory/lib/session-ingest.mjs +196 -0
  186. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  187. package/src/runtime/memory/tool-defs.mjs +46 -37
  188. package/src/runtime/search/index.mjs +104 -473
  189. package/src/runtime/search/lib/config.mjs +0 -4
  190. package/src/runtime/search/lib/state.mjs +1 -15
  191. package/src/runtime/search/lib/web-tools.mjs +44 -25
  192. package/src/runtime/search/tool-defs.mjs +16 -23
  193. package/src/runtime/shared/abort-controller.mjs +1 -1
  194. package/src/runtime/shared/atomic-file.mjs +4 -3
  195. package/src/runtime/shared/background-tasks.mjs +122 -11
  196. package/src/runtime/shared/child-spawn-gate.mjs +139 -0
  197. package/src/runtime/shared/config.mjs +7 -4
  198. package/src/runtime/shared/err-text.mjs +131 -4
  199. package/src/runtime/shared/llm/cost.mjs +2 -2
  200. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  201. package/src/runtime/shared/llm/index.mjs +34 -11
  202. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  203. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  204. package/src/runtime/shared/singleton-owner.mjs +104 -0
  205. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  206. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  207. package/src/runtime/shared/tool-surface.mjs +624 -95
  208. package/src/runtime/shared/user-data-guard.mjs +0 -2
  209. package/src/standalone/agent-task-status.mjs +203 -0
  210. package/src/standalone/agent-task-status.test.mjs +76 -0
  211. package/src/standalone/agent-tool.mjs +1913 -0
  212. package/src/standalone/channel-worker.mjs +370 -14
  213. package/src/standalone/explore-tool.mjs +165 -70
  214. package/src/standalone/folder-dialog.mjs +314 -0
  215. package/src/standalone/hook-bus.mjs +898 -22
  216. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  217. package/src/standalone/projects.mjs +226 -0
  218. package/src/standalone/provider-admin.mjs +41 -24
  219. package/src/standalone/seeds.mjs +1 -78
  220. package/src/standalone/usage-dashboard.mjs +96 -8
  221. package/src/tui/App.jsx +4800 -2140
  222. package/src/tui/components/AnsiText.jsx +39 -28
  223. package/src/tui/components/ContextPanel.jsx +14 -4
  224. package/src/tui/components/Markdown.jsx +43 -77
  225. package/src/tui/components/MarkdownTable.jsx +9 -184
  226. package/src/tui/components/Message.jsx +28 -11
  227. package/src/tui/components/Picker.jsx +95 -56
  228. package/src/tui/components/PromptInput.jsx +428 -239
  229. package/src/tui/components/QueuedCommands.jsx +1 -1
  230. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  231. package/src/tui/components/Spinner.jsx +67 -38
  232. package/src/tui/components/StatusLine.jsx +606 -38
  233. package/src/tui/components/TextEntryPanel.jsx +128 -9
  234. package/src/tui/components/ToolExecution.jsx +597 -362
  235. package/src/tui/components/TurnDone.jsx +3 -3
  236. package/src/tui/components/UsagePanel.jsx +3 -5
  237. package/src/tui/components/tool-output-format.mjs +499 -0
  238. package/src/tui/components/tool-output-format.test.mjs +312 -0
  239. package/src/tui/dist/index.mjs +9192 -2399
  240. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  241. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  242. package/src/tui/engine.mjs +1455 -279
  243. package/src/tui/figures.mjs +21 -40
  244. package/src/tui/index.jsx +75 -31
  245. package/src/tui/input-editing.mjs +25 -0
  246. package/src/tui/markdown/format-token.mjs +671 -69
  247. package/src/tui/markdown/format-token.test.mjs +312 -0
  248. package/src/tui/markdown/render-ansi.mjs +94 -0
  249. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  250. package/src/tui/markdown/stream-fence.mjs +34 -0
  251. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  252. package/src/tui/markdown/table-layout.mjs +250 -0
  253. package/src/tui/paste-attachments.mjs +0 -7
  254. package/src/tui/spinner-verbs.mjs +1 -2
  255. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  256. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  257. package/src/tui/theme.mjs +756 -24
  258. package/src/tui/time-format.mjs +1 -1
  259. package/src/tui/transcript-tool-failures.mjs +67 -0
  260. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  261. package/src/ui/ansi.mjs +1 -2
  262. package/src/ui/markdown.mjs +85 -26
  263. package/src/ui/markdown.test.mjs +70 -0
  264. package/src/ui/model-display.mjs +121 -0
  265. package/src/ui/session-stats.mjs +44 -0
  266. package/src/ui/statusline-context-label.test.mjs +15 -0
  267. package/src/ui/statusline.mjs +386 -178
  268. package/src/ui/tool-card.mjs +2 -13
  269. package/src/vendor/statusline/bin/statusline-lib.mjs +1 -620
  270. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  271. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  272. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  273. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  274. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  275. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  276. package/src/workflows/default/WORKFLOW.md +12 -5
  277. package/src/workflows/default/workflow.json +0 -1
  278. package/src/workflows/solo/WORKFLOW.md +15 -0
  279. package/src/workflows/solo/workflow.json +7 -0
  280. package/vendor/ink/build/ink.js +54 -8
  281. package/vendor/ink/build/output.js +6 -1
  282. package/src/agents/scheduler-task.md +0 -3
  283. package/src/agents/web-researcher/AGENT.md +0 -3
  284. package/src/agents/web-researcher/agent.json +0 -6
  285. package/src/agents/webhook-handler.md +0 -3
  286. package/src/hooks/lib/permission-rules.cjs +0 -170
  287. package/src/hooks/lib/settings-loader.cjs +0 -112
  288. package/src/lib/hook-pipe-path.cjs +0 -10
  289. package/src/rules/bridge/00-common.md +0 -5
  290. package/src/rules/bridge/30-explorer.md +0 -4
  291. package/src/rules/lead/00-tool-lead.md +0 -5
  292. package/src/rules/shared/00-language.md +0 -3
  293. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  294. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  295. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  296. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  297. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  298. package/src/standalone/bridge-tool.mjs +0 -1414
  299. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -3,18 +3,17 @@
3
3
  * for Claude Max subscription access.
4
4
  *
5
5
  * Raw HTTP + SSE streaming, reuses message/tool conversion patterns
6
- * from anthropic.mjs. Bridge-trace instrumented.
6
+ * from anthropic.mjs. agent-trace instrumented.
7
7
  */
8
8
  import { readFileSync, existsSync, statSync } from 'fs';
9
9
  import { join } from 'path';
10
- import { homedir } from 'os';
11
10
  import { createServer } from 'http';
12
11
  import { randomBytes, createHash } from 'crypto';
13
12
  import {
14
- traceBridgeFetch,
15
- traceBridgeSse,
16
- traceBridgeUsage,
17
- } from '../bridge-trace.mjs';
13
+ traceAgentFetch,
14
+ traceAgentSse,
15
+ traceAgentUsage,
16
+ } from '../agent-trace.mjs';
18
17
  import { createAbortController } from '../../../shared/abort-controller.mjs';
19
18
  import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
20
19
  import { resolvePluginData } from '../../../shared/plugin-paths.mjs';
@@ -31,12 +30,17 @@ import {
31
30
  } from '../stall-policy.mjs';
32
31
  import {
33
32
  classifyError,
33
+ classifyMidstreamError,
34
+ midstreamBackoffFor,
35
+ MIDSTREAM_RETRY_POLICY,
34
36
  retryAfterMsFromError,
37
+ sleepWithAbort,
35
38
  withRetry,
36
39
  } from './retry-classifier.mjs';
37
40
  import { buildAnthropicBetaHeaders, supportsAnthropicFastMode } from './anthropic-betas.mjs';
38
41
  import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
39
42
  import { normalizeContentForAnthropic } from './media-normalization.mjs';
43
+ import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
40
44
 
41
45
  // --- Model catalog cache helpers ---
42
46
  // Disk-backed cache so repeated process starts (cron, tool calls) don't
@@ -45,6 +49,47 @@ const MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
45
49
  // SSE progress emits (per-request "Response …" and "Done:" lines). Off by default.
46
50
  const SSE_VERBOSE = process.env.MIXDOG_SSE_VERBOSE === '1';
47
51
 
52
+ /** Bounded mid-stream SSE retries (transient stream loss); shared with anthropic.mjs.
53
+ * Sourced from the single shared retry-budget table (MIDSTREAM_RETRY_POLICY.sse). */
54
+ export const ANTHROPIC_MAX_MIDSTREAM_RETRIES = MIDSTREAM_RETRY_POLICY.sse.defaultRetries;
55
+
56
+ // Policy passed to the shared classifyMidstreamError for the SSE path. The
57
+ // top-of-function attempt-budget gate uses defaultRetries (3); perClassifierGate
58
+ // is false so the classifier returns raw bucket strings (the loop owns the
59
+ // MAX_MIDSTREAM_RETRIES bound), matching the former _classifyMidstreamError.
60
+ const SSE_MIDSTREAM_POLICY = {
61
+ mode: 'sse',
62
+ defaultRetries: MIDSTREAM_RETRY_POLICY.sse.defaultRetries,
63
+ perClassifierGate: false,
64
+ };
65
+
66
+ function formatRetryAfter(ms) {
67
+ const n = Number(ms);
68
+ if (!Number.isFinite(n) || n < 0) return '';
69
+ if (n >= 60_000 && n % 60_000 === 0) return `${Math.round(n / 60_000)}m`;
70
+ if (n >= 1000) return `${Math.ceil(n / 1000)}s`;
71
+ return `${Math.ceil(n)}ms`;
72
+ }
73
+
74
+ function anthropicQuotaError(status, headers, bodyText = '') {
75
+ const retryAfterMs = retryAfterMsFromError({ headers, response: { headers } });
76
+ const retryAfter = formatRetryAfter(retryAfterMs);
77
+ const detail = bodyText ? `: ${String(bodyText).slice(0, 200)}` : '';
78
+ const retry = retryAfter ? ` retryAfter=${retryAfter}` : '';
79
+ const err = new Error(`Anthropic OAuth API ${status} quota/rate limit${retry}${detail}`);
80
+ err.name = 'ProviderQuotaError';
81
+ err.code = 'PROVIDER_QUOTA';
82
+ err.httpStatus = status;
83
+ err.status = status;
84
+ err.headers = headers;
85
+ err.response = { status, headers };
86
+ err.retryAfterMs = retryAfterMs;
87
+ err.providerQuota = true;
88
+ err.quotaExceeded = true;
89
+ err.unsafeToRetry = true;
90
+ return err;
91
+ }
92
+
48
93
  const _modelCache = makeModelCache({
49
94
  fileName: 'anthropic-oauth-models.json',
50
95
  ttlMs: MODEL_CACHE_TTL_MS,
@@ -215,10 +260,9 @@ function assertSafeTokenURL(rawURL) {
215
260
  }
216
261
  return rawURL;
217
262
  }
218
- const TOKEN_URL = assertSafeTokenURL(process.env.ANTHROPIC_OAUTH_TOKEN_URL || 'https://console.anthropic.com/v1/oauth/token');
263
+ const TOKEN_URL = assertSafeTokenURL(process.env.ANTHROPIC_OAUTH_TOKEN_URL || 'https://platform.claude.com/v1/oauth/token');
219
264
  const ANTHROPIC_VERSION = '2023-06-01';
220
265
  const DEFAULT_CREDENTIALS_PATH = join(resolvePluginData(), 'anthropic-oauth-credentials.json');
221
- const CLAUDE_CODE_CREDENTIALS_PATH = join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), '.credentials.json');
222
266
  const CLAUDE_CODE_CLIENT_ID = process.env.ANTHROPIC_OAUTH_CLIENT_ID || '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
223
267
  const TOKEN_REFRESH_SKEW_MS = 5 * 60_000;
224
268
  const CLAUDE_AI_AUTHORIZE_URL = 'https://claude.com/cai/oauth/authorize';
@@ -235,15 +279,17 @@ const OAUTH_CALLBACK_HOST = 'localhost';
235
279
  const OAUTH_CALLBACK_PORT = 54545;
236
280
  const OAUTH_CALLBACK_PATH = '/callback';
237
281
  const OAUTH_REDIRECT_URI = `http://${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`;
282
+ const OAUTH_MANUAL_REDIRECT_URI = process.env.ANTHROPIC_OAUTH_MANUAL_REDIRECT_URI || 'https://platform.claude.com/oauth/code/callback';
283
+ const OAUTH_SUCCESS_REDIRECT_URL = process.env.ANTHROPIC_OAUTH_SUCCESS_REDIRECT_URL || 'https://platform.claude.com/oauth/code/success?app=claude-code';
238
284
  const OAUTH_LOGIN_TIMEOUT_MS = 5 * 60_000;
239
285
  const OAUTH_TOKEN_TIMEOUT_MS = 30_000;
240
286
 
241
- // Anthropic OAuth contract for first-party Claude Code clients.
287
+ // Anthropic OAuth contract for first-party OAuth clients.
242
288
  // Opus/Sonnet requests are gated on a specific system-prompt prefix.
243
289
  // Mixdog keeps that upstream client contract for OAuth routing. Haiku is not
244
290
  // gated and ignores this prefix.
245
291
  const CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude.";
246
- const OAUTH_BETA_HEADERS = 'oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,extended-cache-ttl-2025-04-11';
292
+ const OAUTH_BETA_HEADERS = 'oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,extended-cache-ttl-2025-04-11,advanced-tool-use-2025-11-20';
247
293
  const DEFAULT_CLI_VERSION = '2.1.77';
248
294
 
249
295
  function resolveCliVersion() {
@@ -252,23 +298,30 @@ function resolveCliVersion() {
252
298
  }
253
299
 
254
300
  function requiresSystemPrefix(model) {
255
- // Opus / Sonnet require the Claude Code system prefix when authenticated
301
+ // Opus / Sonnet require the OAuth system prefix when authenticated
256
302
  // via OAuth. Haiku does not.
257
303
  return /^claude-(opus|sonnet)/i.test(String(model || ''));
258
304
  }
259
305
 
260
306
  // OAuth rate-limit pool routing is gated by the server inspecting the first
261
- // system block. When it reads exactly "You are Claude Code, Anthropic's
262
- // official CLI for Claude." it routes into the Claude Code pool; any other
307
+ // system block. When it reads exactly the OAuth system prefix string it routes
308
+ // into the first-party OAuth pool; any other
263
309
  // content (even the prefix concatenated with extra text in the same block)
264
310
  // falls into the standard pool and Opus/Sonnet return 429. Splitting into
265
311
  // two blocks — [prefix, rest] — keeps both routing and user instructions.
266
- function buildSystemBlocks(systemText, model, cacheControl, maxCacheBlocks = 2) {
267
- // systemText is an array of strings each element becomes its own Anthropic
268
- // content block with its own cache_control breakpoint (BP1 + BP2).
269
- // Invariant: callers must pass an array; scalar strings are not accepted.
270
- const texts = Array.isArray(systemText)
271
- ? systemText.map(s => typeof s === 'string' ? s.trim() : '').filter(Boolean)
312
+ function buildSystemBlocks(systemMsgs, model, systemTtl, tier3Ttl) {
313
+ // systemMsgs is an array of { content, cacheTier } each non-empty element
314
+ // becomes its own Anthropic content block with its own cache_control
315
+ // breakpoint. Blocks tagged cacheTier:'tier3' (BP3 sessionMarker) take the
316
+ // tier3 TTL; every other block (BP1 baseRules / BP2 stableSystem) takes the
317
+ // system TTL. Invariant: callers must pass an array.
318
+ const items = Array.isArray(systemMsgs)
319
+ ? systemMsgs
320
+ .map(m => ({
321
+ text: typeof m?.content === 'string' ? m.content.trim() : '',
322
+ tier: m?.cacheTier === 'tier3' ? 'tier3' : 'system',
323
+ }))
324
+ .filter(it => it.text)
272
325
  : [];
273
326
  const gated = requiresSystemPrefix(model);
274
327
 
@@ -276,22 +329,24 @@ function buildSystemBlocks(systemText, model, cacheControl, maxCacheBlocks = 2)
276
329
  if (gated) {
277
330
  blocks.push({ type: 'text', text: CLAUDE_CODE_SYSTEM_PREFIX });
278
331
  }
279
- for (let i = 0; i < texts.length; i++) {
280
- let body = texts[i];
281
- // Strip a duplicated Claude Code prefix from the first block if present.
332
+ for (let i = 0; i < items.length; i++) {
333
+ let body = items[i].text;
334
+ // Strip a duplicated OAuth system prefix from the first block if present.
282
335
  if (gated && i === 0 && body.startsWith(CLAUDE_CODE_SYSTEM_PREFIX)) {
283
336
  body = body.slice(CLAUDE_CODE_SYSTEM_PREFIX.length).trim();
284
337
  if (!body) continue;
285
338
  }
286
- blocks.push({ type: 'text', text: body });
339
+ blocks.push({ type: 'text', text: body, _tier: items[i].tier });
287
340
  }
288
- if (cacheControl) {
289
- let remaining = Math.max(0, Math.min(2, Number(maxCacheBlocks) || 0));
290
- for (let i = blocks.length - 1; i >= 0 && remaining > 0; i--) {
291
- if (blocks[i]?.text === CLAUDE_CODE_SYSTEM_PREFIX) continue;
292
- blocks[i] = { ...blocks[i], cache_control: cacheControl };
293
- remaining -= 1;
294
- }
341
+ // Apply per-tier cache_control. BP1/BP2 -> systemTtl, BP3 -> tier3Ttl. The
342
+ // gating prefix block is never cached (Anthropic routes on its exact bytes).
343
+ // tier3Ttl === null leaves the 3rd block uncached (e.g. maintenance roles).
344
+ for (const b of blocks) {
345
+ const tier = b._tier;
346
+ delete b._tier;
347
+ if (b.text === CLAUDE_CODE_SYSTEM_PREFIX) continue;
348
+ const ttl = tier === 'tier3' ? tier3Ttl : systemTtl;
349
+ if (ttl) b.cache_control = ttl;
295
350
  }
296
351
  return blocks;
297
352
  }
@@ -325,6 +380,18 @@ const EFFORT_BUDGET = {
325
380
  max: 32768,
326
381
  };
327
382
 
383
+ const MIN_THINKING_BUDGET = 1024;
384
+ const THINKING_OUTPUT_RESERVE = 1024;
385
+
386
+ function clampThinkingBudgetTokens(value, maxTokens) {
387
+ const desired = Math.floor(Number(value));
388
+ const max = Math.floor(Number(maxTokens));
389
+ if (!Number.isFinite(desired) || desired <= 0 || !Number.isFinite(max)) return null;
390
+ const ceiling = max - THINKING_OUTPUT_RESERVE;
391
+ if (ceiling < MIN_THINKING_BUDGET) return null;
392
+ return Math.max(MIN_THINKING_BUDGET, Math.min(desired, ceiling));
393
+ }
394
+
328
395
  // Tracks which unknown effort labels we've already logged so a repeated
329
396
  // session-level misconfig doesn't flood stderr with the same warning.
330
397
  const _LOGGED_UNKNOWN_EFFORT = new Set();
@@ -345,7 +412,6 @@ function credentialCandidates() {
345
412
  const paths = [];
346
413
  _pushUnique(paths, process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH);
347
414
  _pushUnique(paths, DEFAULT_CREDENTIALS_PATH);
348
- _pushUnique(paths, CLAUDE_CODE_CREDENTIALS_PATH);
349
415
  return paths;
350
416
  }
351
417
 
@@ -385,21 +451,19 @@ function _loadCredentialsFile(path) {
385
451
  }
386
452
  }
387
453
 
388
- // Cross-process safe write-back. Lockfile (O_EXCL) prevents two refreshers
389
- // from clobbering each other; atomic rename guarantees readers see either
390
- // the old or new file, never a half-written one. Used so refresh_token
391
- // rotation propagates to other readers of the same credentials file instead of
392
- // leaving them stuck on the previous
393
- // refresh_token. Mirrors openai-oauth.mjs:saveTokens.
454
+ // Cross-process safe credential save. Lockfile (O_EXCL) prevents two Mixdog
455
+ // refreshers from clobbering each other; atomic rename guarantees readers see
456
+ // either the old or new file, never a half-written one. Used so refresh_token
457
+ // rotation propagates to other Mixdog readers of the same credentials file
458
+ // instead of leaving them stuck on the previous refresh_token.
394
459
  function _saveCredentialsFile(path, raw) {
395
- // No `secret: true`: this may be an externally-owned credentials file.
396
- // Mixdog only writes back the rotated refresh_token and must not rewrite
397
- // parent directory permissions.
398
- writeJsonAtomicSync(path, raw, { lock: true, fsyncDir: true, mode: 0o600 });
460
+ // Secret file, not parent-dir ACL mutation. `secret: true` clamps the file
461
+ // itself on Windows; it deliberately leaves the data dir inheritance alone.
462
+ writeJsonAtomicSync(path, raw, { lock: true, fsyncDir: true, mode: 0o600, secret: true });
399
463
  }
400
464
 
401
- // Cheap stat-only probe so ensureAuth can detect host-rotated credentials
402
- // (claude login, logout/relogin) without paying a full JSON read every call.
465
+ // Cheap stat-only probe so ensureAuth can detect Mixdog-updated credentials
466
+ // without paying a full JSON read every call.
403
467
  function _credentialsMaxMtime() {
404
468
  let max = 0;
405
469
  for (const p of credentialCandidates()) {
@@ -433,7 +497,7 @@ export function describeAnthropicOAuthCredentials() {
433
497
  try {
434
498
  const creds = loadCredentials();
435
499
  if (!creds?.accessToken) {
436
- return { authenticated: false, status: 'Not Set', detail: DEFAULT_CREDENTIALS_PATH };
500
+ return { authenticated: false, status: 'Not Set', detail: 'Mixdog OAuth credentials' };
437
501
  }
438
502
  const hasInferenceScope = Array.isArray(creds.scopes) && creds.scopes.includes('user:inference');
439
503
  const hasRefresh = Boolean(creds.refreshToken);
@@ -495,7 +559,7 @@ function _scrubTokens(text) {
495
559
 
496
560
  async function refreshOAuthCredentials(creds) {
497
561
  if (!creds?.refreshToken) {
498
- throw new Error('Anthropic OAuth refresh token not available. Run /auth anthropic-oauth or /providers in mixdog to re-authenticate.');
562
+ throw new Error('Anthropic OAuth refresh token not available. Open /providers in mixdog to sign in again.');
499
563
  }
500
564
 
501
565
  const controller = new AbortController();
@@ -541,10 +605,10 @@ async function refreshOAuthCredentials(creds) {
541
605
  scopes: Array.isArray(json?.scope) ? json.scope : creds.scopes,
542
606
  subscriptionType: creds.subscriptionType,
543
607
  };
544
- // Persist rotated tokens back so any other reader of the same
545
- // credentials file picks up the new refresh_token.
546
- // Without this, host's next refresh invalidates our copy and we
547
- // loop on invalid_grant.
608
+ // Persist rotated tokens back so any other Mixdog reader of the same
609
+ // credentials file picks up the new refresh_token. Without this, a
610
+ // later process can replay an old single-use refresh token and loop on
611
+ // invalid_grant.
548
612
  if (creds.path && existsSync(creds.path)) {
549
613
  try {
550
614
  const raw = JSON.parse(readFileSync(creds.path, 'utf-8'));
@@ -557,8 +621,8 @@ async function refreshOAuthCredentials(creds) {
557
621
  };
558
622
  _saveCredentialsFile(creds.path, raw);
559
623
  } catch (err) {
560
- process.stderr.write(`[anthropic-oauth] credential write-back failed: ${_scrubTokens(err?.message || String(err)).slice(0, 200)}\n`);
561
- throw new Error(`[oauth] credentials write-back failed: ${err?.message ?? String(err)}`);
624
+ process.stderr.write(`[anthropic-oauth] credential save failed: ${_scrubTokens(err?.message || String(err)).slice(0, 200)}\n`);
625
+ throw new Error(`[oauth] credentials save failed: ${err?.message ?? String(err)}`);
562
626
  }
563
627
  }
564
628
  return refreshed;
@@ -642,9 +706,11 @@ function _sanitizeInputSchema(schema, toolName) {
642
706
  if (addition) description = description ? `${description} ${addition}` : addition;
643
707
  }
644
708
  const mergedPropsCount = Object.keys(mergedProps).length;
645
- process.stderr.write(
646
- `[anthropic-oauth-sanitizer] tool="${toolName ?? ''}" compound="${compoundKey}" branches=${Array.isArray(compound) ? compound.length : 0} mergedProps=${mergedPropsCount}\n`
647
- );
709
+ if (process.env.MIXDOG_DEBUG_SESSION_LOG) {
710
+ process.stderr.write(
711
+ `[anthropic-oauth-sanitizer] tool="${toolName ?? ''}" compound="${compoundKey}" branches=${Array.isArray(compound) ? compound.length : 0} mergedProps=${mergedPropsCount}\n`
712
+ );
713
+ }
648
714
  return {
649
715
  type: 'object',
650
716
  ...(description ? { description } : {}),
@@ -653,11 +719,28 @@ function _sanitizeInputSchema(schema, toolName) {
653
719
  }
654
720
 
655
721
  function toAnthropicTools(tools) {
656
- return tools.map(t => ({
657
- name: t.name,
658
- description: t.description,
659
- input_schema: _sanitizeInputSchema(t.inputSchema, t.name),
660
- }));
722
+ return tools.map(t => {
723
+ const out = {
724
+ name: t.name,
725
+ description: t.description,
726
+ input_schema: _sanitizeInputSchema(t.inputSchema, t.name),
727
+ };
728
+ if (t.deferLoading === true || t.defer_loading === true) out.defer_loading = true;
729
+ return out;
730
+ });
731
+ }
732
+ function nativeAnthropicTools(opts) {
733
+ return Array.isArray(opts?.nativeTools)
734
+ ? opts.nativeTools.filter(t => t && typeof t === 'object')
735
+ : [];
736
+ }
737
+ function deferredAnthropicTools(activeTools, opts) {
738
+ if (opts?.session?.deferredNativeTools !== true) return [];
739
+ const active = new Set((activeTools || []).map((tool) => String(tool?.name || '').trim()).filter(Boolean));
740
+ const catalog = Array.isArray(opts.session.deferredToolCatalog) ? opts.session.deferredToolCatalog : [];
741
+ return catalog
742
+ .filter((tool) => tool?.name && !active.has(String(tool.name)))
743
+ .map((tool) => ({ ...tool, deferLoading: true }));
661
744
  }
662
745
 
663
746
  function toAnthropicMessages(messages) {
@@ -694,10 +777,15 @@ function toAnthropicMessages(messages) {
694
777
 
695
778
  if (m.role === 'tool') {
696
779
  const last = result[result.length - 1];
780
+ const refs = Array.isArray(m.nativeToolSearch?.toolReferences)
781
+ ? m.nativeToolSearch.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
782
+ : [];
697
783
  const block = {
698
784
  type: 'tool_result',
699
785
  tool_use_id: m.toolCallId || '',
700
- content: normalizeContentForAnthropic(m.content),
786
+ content: refs.length
787
+ ? refs.map((name) => ({ type: 'tool_reference', tool_name: name }))
788
+ : normalizeContentForAnthropic(m.content),
701
789
  };
702
790
  if (last?.role === 'user' && Array.isArray(last.content)) {
703
791
  last.content.push(block);
@@ -717,17 +805,15 @@ function toAnthropicMessages(messages) {
717
805
  // sanitizeAnthropicContentPairs has already run (and must NOT run again
718
806
  // after this), the blocks we mark here are exactly the blocks the provider
719
807
  // sees, so the cache breakpoint is stable across turns.
720
- // tier3: the user message whose first text block / string content
721
- // startsWith '<system-reminder>' AND includes BP3_SENTINEL —
722
- // mark its last content block with tier3Ttl.
723
808
  // message-anchor: prefer a safe tool_result tail, then a previous real user
724
809
  // text turn if another slot remains. Synthetic
725
810
  // <system-reminder> messages and current pure-text prompts
726
811
  // are excluded so first-turn prompts do not create a fresh
727
812
  // BP4 write on every new session.
728
- // messageTtl === null disables the tail; tier3Ttl === null disables tier3.
813
+ // messageTtl === null disables the tail. BP3 (tier3) now rides a system block,
814
+ // so it is no longer marked here.
729
815
  // ANTHROPIC_MSG_SLOTS=0 is honoured upstream by passing messageTtl = null.
730
- function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_VOLATILE, messageSlots = 1, tier3Ttl = null } = {}) {
816
+ function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_VOLATILE, messageSlots = 1 } = {}) {
731
817
  if (!Array.isArray(sanitizedMessages) || sanitizedMessages.length === 0) {
732
818
  return sanitizedMessages;
733
819
  }
@@ -741,29 +827,20 @@ function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_
741
827
  return '';
742
828
  };
743
829
  const isSystemReminder = (content) => firstText(content).startsWith('<system-reminder>');
744
- const isTier3SystemReminder = (content) => {
745
- const text = firstText(content);
746
- return text.startsWith('<system-reminder>') && text.includes(BP3_SENTINEL);
747
- };
748
830
 
749
831
  const markLast = (msg, ttl) => {
750
832
  if (!msg) return;
751
833
  msg.content = appendCacheControl(msg.content, ttl);
752
834
  };
753
835
  const ttlRank = (ttl) => ttl?.ttl === '1h' ? 2 : 1;
754
- const canMarkMessageIdx = (idx, tier3Idx) => {
755
- // Anthropic requires 1h cache_control blocks to appear before 5m
756
- // blocks. If tier3 is a later 1h marker, do not put a 5m marker before
757
- // it. Gateway Anthropic routes set messages:'1h', so this guard mostly
758
- // protects raw/default callers.
836
+ const canMarkMessageIdx = (idx) => {
837
+ // System-reminder messages (volatileTail / roleSpecific BP4) vary
838
+ // per-call, so never pin them with a 1h marker. The 1h system blocks
839
+ // (BP1/BP2/BP3) already satisfy Anthropic's "1h before 5m" ordering.
759
840
  if (idx < 0) return false;
760
841
  const msg = sanitizedMessages[idx];
761
842
  if (ttlRank(messageTtl) > ttlRank(CACHE_TTL_VOLATILE)
762
- && isSystemReminder(msg?.content)
763
- && !isTier3SystemReminder(msg?.content)) {
764
- return false;
765
- }
766
- if (tier3Idx >= 0 && idx < tier3Idx && ttlRank(messageTtl) < ttlRank(tier3Ttl)) {
843
+ && isSystemReminder(msg?.content)) {
767
844
  return false;
768
845
  }
769
846
  return true;
@@ -798,29 +875,16 @@ function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_
798
875
  return -1;
799
876
  };
800
877
 
801
- // tier3 — locate the sentinel-tagged system-reminder user message.
802
- let tier3MsgIdx = -1;
803
- if (tier3Ttl !== null) {
804
- for (let i = 0; i < sanitizedMessages.length; i++) {
805
- const m = sanitizedMessages[i];
806
- if (m?.role === 'user' && isTier3SystemReminder(m.content)) {
807
- tier3MsgIdx = i;
808
- break;
809
- }
810
- }
811
- if (tier3MsgIdx >= 0) markLast(sanitizedMessages[tier3MsgIdx], tier3Ttl);
812
- }
813
-
814
878
  if (messageTtl !== null) {
815
879
  const slots = Math.max(0, Math.min(4, Number(messageSlots) || 0));
816
- const marked = new Set(tier3MsgIdx >= 0 ? [tier3MsgIdx] : []);
880
+ const marked = new Set();
817
881
  const candidates = [latestToolResultTailIdx(), previousUserTextAnchorIdx()];
818
882
  for (const idx of candidates) {
819
883
  if (slots <= 0) break;
820
- if (idx < 0 || marked.has(idx) || !canMarkMessageIdx(idx, tier3MsgIdx)) continue;
884
+ if (idx < 0 || marked.has(idx) || !canMarkMessageIdx(idx)) continue;
821
885
  markLast(sanitizedMessages[idx], messageTtl);
822
886
  marked.add(idx);
823
- if (marked.size - (tier3MsgIdx >= 0 ? 1 : 0) >= slots) break;
887
+ if (marked.size >= slots) break;
824
888
  }
825
889
  }
826
890
 
@@ -832,13 +896,19 @@ function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_
832
896
  function _captureMidstreamAbort(state, reason) {
833
897
  if (!state) return;
834
898
  const reasonName = reason?.name || '';
835
- if (reasonName === 'BridgeStallAbortError' || reasonName === 'StreamStalledAbortError') {
899
+ if (reasonName === 'AgentStallAbortError' || reasonName === 'StreamStalledAbortError') {
836
900
  state.watchdogAbort = reasonName;
837
901
  } else {
838
902
  state.userAbort = true;
839
903
  }
840
904
  }
841
905
 
906
+ // Abort-aware mid-stream backoff sleep → shared sleepWithAbort
907
+ // (retry-classifier.mjs). abortMessage preserves the prior fallback text.
908
+ function _midstreamSleepWithAbort(ms, signal) {
909
+ return sleepWithAbort(ms, signal, undefined, 'Anthropic OAuth mid-stream retry backoff aborted');
910
+ }
911
+
842
912
  function _statusForAnthropicSseError(type, message) {
843
913
  const kind = String(type || '').toLowerCase();
844
914
  const text = String(message || '').toLowerCase();
@@ -894,7 +964,7 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
894
964
  const resetIdleTimer = () => {
895
965
  // OFF by default. When disabled the
896
966
  // idle timer never arms, so the stream is never killed on inactivity;
897
- // the bridge stall watchdog (600s) remains the dead-stream backstop.
967
+ // the agent stall watchdog (600s) remains the dead-stream backstop.
898
968
  if (!PROVIDER_SSE_IDLE_WATCHDOG_ENABLED) return;
899
969
  if (idleTimer) clearTimeout(idleTimer);
900
970
  idleTimer = setTimeout(() => {
@@ -975,7 +1045,7 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
975
1045
  if (line.startsWith(':')) {
976
1046
  // SSE comment frame (Anthropic `:ping` keepalive). The HTML Standard SSE
977
1047
  // spec says comments are silently ignored, but we surface them here so
978
- // the bridge-stall-watchdog sees the stream is still alive during Opus
1048
+ // the agent stall watchdog sees the stream is still alive during Opus
979
1049
  // extended-thinking pauses. No content is emitted — this only refreshes
980
1050
  // the runtime's lastStreamDeltaAt timestamp.
981
1051
  try { onStreamDelta?.(); } catch {}
@@ -1062,15 +1132,15 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1062
1132
  // whole tool_call — the loop never saw it and
1063
1133
  // the assistant turn ended with an unmatched
1064
1134
  // tool_use id. Wrap the parse so a malformed
1065
- // input still produces a tool_call (with empty
1066
- // arguments and a logged error) instead of a
1067
- // silent drop.
1135
+ // input still produces a tool_call (with an
1136
+ // invalid-args marker and a logged error) instead
1137
+ // of a silent drop or accidental `{}` dispatch.
1068
1138
  let parsedArgs = {};
1069
1139
  if (pending.inputJson) {
1070
1140
  try { parsedArgs = JSON.parse(pending.inputJson); }
1071
1141
  catch (parseErr) {
1072
1142
  process.stderr.write(`[anthropic-oauth] tool args JSON.parse failed (id=${pending.id}, name=${pending.name}): ${parseErr?.message || parseErr}\n`);
1073
- parsedArgs = {};
1143
+ parsedArgs = makeInvalidToolArgsMarker(pending.inputJson, parseErr instanceof Error ? parseErr.message : String(parseErr));
1074
1144
  }
1075
1145
  }
1076
1146
  // Tool arguments must be a plain object. Anthropic's
@@ -1187,35 +1257,14 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1187
1257
  * That keeps recovery limited to transport/stream stalls without risking
1188
1258
  * duplicate eager tool execution.
1189
1259
  */
1260
+ // Thin wrapper: the SSE mid-stream decision tree now lives in the shared
1261
+ // classifyMidstreamError (retry-classifier.mjs, policy.mode='sse'). Kept as a
1262
+ // named export so internal call sites AND anthropic.mjs (which imports this
1263
+ // symbol) keep resolving it. Behavior is byte-identical — the shared function
1264
+ // is the relocated original, gated by SSE_MIDSTREAM_POLICY (defaultRetries=3,
1265
+ // perClassifierGate:false).
1190
1266
  export function _classifyMidstreamError(err, state) {
1191
- if (!state) return null;
1192
- if ((state.attemptIndex | 0) >= 1) return null;
1193
- if (state.sawCompleted) return null;
1194
- if (!state.sawMessageStart) return null;
1195
- if (state.userAbort) return null;
1196
- if (state.emittedToolCall) return null;
1197
-
1198
- if (!err) return null;
1199
- const status = Number(err?.httpStatus || 0);
1200
- if (status === 401 || status === 403 || status === 429) return null;
1201
-
1202
- const name = err?.name || '';
1203
- if (name === 'BridgeStallAbortError') return 'bridge_stall';
1204
- if (name === 'StreamStalledAbortError') return 'stream_stalled';
1205
- if (state.watchdogAbort === 'BridgeStallAbortError') return 'bridge_stall';
1206
- if (state.watchdogAbort === 'StreamStalledAbortError') return 'stream_stalled';
1207
-
1208
- const code = err?.code || err?.cause?.code || '';
1209
- if (code === 'ECONNRESET') return 'reset';
1210
- if (code === 'ETIMEDOUT' || code === 'ESOCKETTIMEDOUT') return 'timeout';
1211
- if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'EAI_NODATA') return 'dns';
1212
-
1213
- const msg = String(err?.message || '').toLowerCase();
1214
- if (msg.includes('stream timed out after') && msg.includes('of inactivity')) return 'sse_idle_timeout';
1215
- if (msg.includes('body stream') && msg.includes('terminated')) return 'stream_terminated';
1216
- if (msg.includes('fetch failed')) return 'fetch_failed';
1217
-
1218
- return null;
1267
+ return classifyMidstreamError(err, state, SSE_MIDSTREAM_POLICY);
1219
1268
  }
1220
1269
 
1221
1270
  // --- Build request body ---
@@ -1232,69 +1281,51 @@ function resolveCacheTtls(opts) {
1232
1281
  return fallback;
1233
1282
  };
1234
1283
  // BP budget (4 total):
1235
- // BP1 baseRules — 1h (shared across ALL roles)
1236
- // BP2 roleCatalog — 1h (shared across ALL roles)
1237
- // BP3 tier3 — 1h (sessionMarker: mixdog.md + stable role body)
1284
+ // BP1 baseRules — 1h (shared tool policy + compact skill manifest)
1285
+ // BP2 stableSystem — 1h (role/system rules)
1286
+ // BP3 tier3 — 1h (sessionMarker: stable memory/meta body)
1238
1287
  // BP4 messages — 1h sliding tail (tool_result cache across iter)
1239
1288
  // tools BP is dropped — system BP covers the tools prefix via
1240
1289
  // Anthropic's prompt cache prefix semantics (order: tools → system
1241
1290
  // → messages).
1242
1291
  // tier3 defaults to 1h (stable) — sessionMarker content is stable per
1243
- // (role, permission, project) tuple and Anthropic only spends the BP
1244
- // slot when findTier3Index() actually finds a <system-reminder> block,
1245
- // so this default is free for sessions that don't carry one. Previously
1246
- // null here meant any caller that skipped smart bridge resolve (CLI,
1247
- // raw bridge spawn) silently lost the tier3 cache layer even
1248
- // though their message layout supported it.
1292
+ // memory/meta tuple and the BP slot is only spent when a 3rd
1293
+ // (cacheTier:'tier3') system block is actually present, so this default is
1294
+ // free for sessions that don't carry one. Previously null here meant any
1295
+ // caller that skipped agent runtime resolve (CLI, raw agent spawn)
1296
+ // silently lost the tier3 cache layer even though it supported one.
1249
1297
  return {
1250
- tools: pick('tools', CACHE_TTL_STABLE),
1298
+ tools: pick('tools', null),
1251
1299
  system: pick('system', CACHE_TTL_STABLE),
1252
1300
  tier3: pick('tier3', CACHE_TTL_STABLE),
1253
1301
  messages: pick('messages', CACHE_TTL_STABLE),
1254
1302
  };
1255
1303
  }
1256
1304
 
1257
- // Tier 3 is injected by session/manager as a user message wrapped in
1258
- // `<system-reminder>` whose body starts with the explicit sentinel
1259
- // `<!-- bp3-sentinel -->` (emitted by collect.mjs:composeSystemPrompt only
1260
- // when stable mixdog.md/role-specific context is present). The sentinel is mandatory:
1261
- // volatileTail (role/permission/taskBrief/memoryRecap) is also wrapped in
1262
- // `<system-reminder>` but varies per-call, so a plain prefix match would
1263
- // pin per-call data to the 1h BP3 slot and explode the cache.
1264
- const BP3_SENTINEL = '<!-- bp3-sentinel -->';
1265
- function findTier3Index(chatMsgs) {
1266
- for (let i = 0; i < chatMsgs.length; i++) {
1267
- const m = chatMsgs[i];
1268
- if (m?.role === 'user' && typeof m.content === 'string'
1269
- && m.content.startsWith('<system-reminder>')
1270
- && m.content.includes(BP3_SENTINEL)) {
1271
- return i;
1272
- }
1273
- }
1274
- return -1;
1275
- }
1305
+ // BP3 (tier3) is injected by session/manager as its own `system` role block —
1306
+ // the 3rd system block, tagged `cacheTier:'tier3'`. buildSystemBlocks applies
1307
+ // the tier3 1h cache_control to that block; BP1/BP2 take the system TTL. No
1308
+ // `<system-reminder>` user message / sentinel scan is involved anymore.
1276
1309
 
1277
1310
  function buildRequestBody(messages, model, tools, sendOpts) {
1278
1311
  const systemMsgs = messages.filter(m => m.role === 'system');
1279
1312
  const chatMsgs = messages.filter(m => m.role !== 'system');
1280
- // Pass each system message text as its own entry so the Anthropic body
1281
- // gets N separate content blocks — each can have its own BP
1282
- // independent of the others.
1283
- const systemTexts = systemMsgs.map(m => m.content);
1284
1313
  const maxTokens = resolveMaxTokens(model);
1285
1314
  const opts = sendOpts || {};
1286
1315
  const ttls = resolveCacheTtls(opts);
1287
- const systemBlocks = buildSystemBlocks(systemTexts, model, ttls?.system, 2);
1316
+ // Each system message becomes its own Anthropic content block with its own
1317
+ // breakpoint: BP1 baseRules + BP2 stableSystem at ttls.system, BP3
1318
+ // sessionMarker (cacheTier:'tier3') at ttls.tier3.
1319
+ const systemBlocks = buildSystemBlocks(systemMsgs, model, ttls?.system, ttls?.tier3);
1288
1320
 
1289
1321
  // 4-BP budget layout. tools BP is dropped — system BP covers the
1290
1322
  // tools prefix via Anthropic's prompt cache prefix semantics
1291
- // (order: tools → system → messages). That frees slots for
1292
- // tier3 + messages-tail.
1293
- const systemBpUsed = ttls.system ? systemBlocks.filter(b => b.cache_control).length : 0;
1323
+ // (order: tools → system → messages). That frees slots for the
1324
+ // messages-tail. The system blocks now hold BP1/BP2/BP3 (tier3), so the
1325
+ // tier3 breakpoint is accounted for inside systemBpUsed.
1326
+ const systemBpUsed = systemBlocks.filter(b => b.cache_control).length;
1294
1327
  const toolsBpUsed = 0;
1295
- const tier3Idx = ttls.tier3 ? findTier3Index(chatMsgs) : -1;
1296
- const tier3BpUsed = tier3Idx >= 0 ? 1 : 0;
1297
- const usedSlots = toolsBpUsed + systemBpUsed + tier3BpUsed;
1328
+ const usedSlots = toolsBpUsed + systemBpUsed;
1298
1329
  // Env override for BP strategy. ANTHROPIC_MSG_SLOTS=0 disables message
1299
1330
  // caching entirely. Any value >=1 first marks the previous user text turn
1300
1331
  // so consecutive requests share a breakpoint; a second free slot marks the
@@ -1310,10 +1341,9 @@ function buildRequestBody(messages, model, tools, sendOpts) {
1310
1341
  // marked block. NEVER sanitize again after this (see send path).
1311
1342
  // msgSlots === 0 (ANTHROPIC_MSG_SLOTS=0, or no free slot) → tail disabled.
1312
1343
  const tailTtl = msgSlots > 0 ? ttls.messages : null;
1313
- const tier3Ttl = tier3Idx >= 0 ? ttls.tier3 : null;
1314
1344
  const anthropicMessages = applyAnthropicCacheMarkers(
1315
1345
  toAnthropicMessages(chatMsgs),
1316
- { messageTtl: tailTtl, messageSlots: msgSlots, tier3Ttl },
1346
+ { messageTtl: tailTtl, messageSlots: msgSlots },
1317
1347
  );
1318
1348
 
1319
1349
  const body = {
@@ -1325,20 +1355,24 @@ function buildRequestBody(messages, model, tools, sendOpts) {
1325
1355
 
1326
1356
  if (systemBlocks.length) body.system = systemBlocks;
1327
1357
 
1328
- if (tools?.length) {
1358
+ const nativeTools = nativeAnthropicTools(opts);
1359
+ const deferredTools = deferredAnthropicTools(tools || [], opts);
1360
+ if (tools?.length || nativeTools.length || deferredTools.length) {
1329
1361
  // No cache_control on tools — the systemBase BP already covers the
1330
1362
  // tools prefix via Anthropic's prompt cache prefix semantics (order:
1331
1363
  // tools → system → messages). Placing a separate BP here would waste
1332
1364
  // a slot that's better spent on messages tail.
1333
- body.tools = toAnthropicTools(tools);
1365
+ body.tools = [...nativeTools, ...toAnthropicTools([...(tools || []), ...deferredTools])];
1334
1366
  }
1335
1367
 
1336
1368
  const thinkingBudgetTokens = Number(opts.thinkingBudgetTokens);
1337
1369
  if (Number.isFinite(thinkingBudgetTokens) && thinkingBudgetTokens > 0) {
1338
- body.thinking = { type: 'enabled', budget_tokens: Math.floor(thinkingBudgetTokens) };
1370
+ const budgetTokens = clampThinkingBudgetTokens(thinkingBudgetTokens, maxTokens);
1371
+ if (budgetTokens) body.thinking = { type: 'enabled', budget_tokens: budgetTokens };
1339
1372
  } else if (opts.effort) {
1340
1373
  if (EFFORT_BUDGET[opts.effort]) {
1341
- body.thinking = { type: 'enabled', budget_tokens: EFFORT_BUDGET[opts.effort] };
1374
+ const budgetTokens = clampThinkingBudgetTokens(EFFORT_BUDGET[opts.effort], maxTokens);
1375
+ if (budgetTokens) body.thinking = { type: 'enabled', budget_tokens: budgetTokens };
1342
1376
  } else if (!_LOGGED_UNKNOWN_EFFORT.has(opts.effort)) {
1343
1377
  _LOGGED_UNKNOWN_EFFORT.add(opts.effort);
1344
1378
  try {
@@ -1382,12 +1416,12 @@ export class AnthropicOAuthProvider {
1382
1416
  this.credentials = loadCredentials();
1383
1417
  }
1384
1418
  if (!this.credentials) {
1385
- throw new Error('Anthropic OAuth credentials not found. Run /auth anthropic-oauth or /providers in mixdog to authenticate.');
1419
+ throw new Error('Anthropic OAuth credentials not found. Open /providers in mixdog to sign in.');
1386
1420
  }
1387
1421
 
1388
- // Pick up host-rotated tokens the moment the credentials file is
1389
- // rewritten — without this, a fresh `claude login` is ignored until
1390
- // the in-memory token's expiry skew triggers a refresh.
1422
+ // Pick up Mixdog-updated tokens the moment the credentials file is
1423
+ // rewritten — without this, a fresh /auth login in another process is
1424
+ // ignored until the in-memory token's expiry skew triggers a refresh.
1391
1425
  const diskMtime = _credentialsMaxMtime();
1392
1426
  if (diskMtime > 0 && diskMtime > (this.credentials.mtimeMs || 0)) {
1393
1427
  const fresh = loadCredentials();
@@ -1439,7 +1473,7 @@ export class AnthropicOAuthProvider {
1439
1473
  process.stderr.write(`[anthropic-oauth] WARNING: token expiring but no refresh token; using current token until expiry\n`);
1440
1474
  return latest;
1441
1475
  }
1442
- throw new Error('Anthropic OAuth refresh token not available. Run /auth anthropic-oauth or /providers in mixdog to re-authenticate.');
1476
+ throw new Error('Anthropic OAuth refresh token not available. Open /providers in mixdog to sign in again.');
1443
1477
  }
1444
1478
 
1445
1479
  try {
@@ -1466,6 +1500,11 @@ export class AnthropicOAuthProvider {
1466
1500
  }
1467
1501
 
1468
1502
  async send(messages, model, tools, sendOpts) {
1503
+ // Re-warm the kept-alive socket before the turn. preconnect() is a
1504
+ // best-effort no-op while a socket is still hot (TTL gate), but after an
1505
+ // idle gap longer than the keep-alive window it re-opens one in parallel
1506
+ // with auth/body build so the POST below skips the cold TLS handshake.
1507
+ preconnect('https://api.anthropic.com');
1469
1508
  // Defense-in-depth: enforce tool_use / tool_result pairing before
1470
1509
  // the Anthropic API call. The trim.mjs sanitize pass is normally
1471
1510
  // invoked by the budget trimmer in loop.mjs, but dispatches under
@@ -1502,7 +1541,7 @@ export class AnthropicOAuthProvider {
1502
1541
  // (PROVIDER_HTTP_RESPONSE_TIMEOUT_MS) for a socket that never sends a
1503
1542
  // first byte (truly wedged),
1504
1543
  // (b) externalSignal (client disconnect / replaced-by-newer-request), and
1505
- // (c) the bridge stall watchdog (STALL_ABORT_S, 600s, progress-based) plus
1544
+ // (c) the agent stall watchdog (STALL_ABORT_S, 600s, progress-based) plus
1506
1545
  // the optional SSE idle watchdog for a stream that goes dead mid-flight.
1507
1546
  // totalSignal is therefore a pure pass-through of externalSignal with no timer.
1508
1547
  const totalTimeout = createPassthroughSignal(externalSignal);
@@ -1558,6 +1597,7 @@ export class AnthropicOAuthProvider {
1558
1597
  'anthropic-beta': buildAnthropicBetaHeaders({
1559
1598
  base: OAUTH_BETA_HEADERS,
1560
1599
  fastMode: this.fastModeBetaHeaderLatched,
1600
+ toolSearch: true,
1561
1601
  }),
1562
1602
  'anthropic-dangerous-direct-browser-access': 'true',
1563
1603
  'user-agent': `claude-cli/${resolveCliVersion()} (external, sdk-cli)`,
@@ -1569,7 +1609,7 @@ export class AnthropicOAuthProvider {
1569
1609
  dispatcher: getLlmDispatcher(),
1570
1610
  });
1571
1611
 
1572
- traceBridgeFetch({
1612
+ traceAgentFetch({
1573
1613
  sessionId,
1574
1614
  headersMs: Date.now() - fetchStartedAt,
1575
1615
  httpStatus: response.status,
@@ -1611,6 +1651,12 @@ export class AnthropicOAuthProvider {
1611
1651
  const status = Number(result?.response?.status || 0);
1612
1652
  const transientStatus = classifyError({ httpStatus: status }) === 'transient';
1613
1653
  if (transientStatus || status === 429) {
1654
+ if (status === 429) {
1655
+ const quotaText = await result.response.text().catch(() => '');
1656
+ cleanupCancelHandler(result.cancelHandler);
1657
+ try { result.controller?.abort?.(); } catch {}
1658
+ throw anthropicQuotaError(status, result?.response?.headers, this.scrubTokens(quotaText));
1659
+ }
1614
1660
  const err = new Error(`Anthropic OAuth API ${status}`);
1615
1661
  err.httpStatus = status;
1616
1662
  err.status = status;
@@ -1642,9 +1688,9 @@ export class AnthropicOAuthProvider {
1642
1688
  } catch {}
1643
1689
  },
1644
1690
  });
1645
- // One retry only: enough to recover transient stream loss without
1646
- // quietly replaying long-running work multiple times.
1647
- const MAX_MIDSTREAM_RETRIES = 1;
1691
+ // Bounded mid-stream retries for transient stream loss; jittered backoff
1692
+ // between attempts (see catch branches).
1693
+ const MAX_MIDSTREAM_RETRIES = ANTHROPIC_MAX_MIDSTREAM_RETRIES;
1648
1694
  let firstAttemptError = null;
1649
1695
  let firstAttemptClassifier = null;
1650
1696
 
@@ -1669,6 +1715,10 @@ export class AnthropicOAuthProvider {
1669
1715
  const safeText = this.scrubTokens(text).slice(0, 200);
1670
1716
  process.stderr.write(`[anthropic-oauth] API error ${response.status}: ${safeText}\n`);
1671
1717
 
1718
+ if (response.status === 429) {
1719
+ throw anthropicQuotaError(response.status, response.headers, safeText);
1720
+ }
1721
+
1672
1722
  // Phase I: on unknown/404 model errors, force a catalog refresh and
1673
1723
  // retry once. Protects against a silently-rotated model id.
1674
1724
  const isUnknownModel = response.status === 404
@@ -1717,7 +1767,7 @@ export class AnthropicOAuthProvider {
1717
1767
 
1718
1768
  const ttftMs = midState.ttftAt ? midState.ttftAt - sseStartedAt : null;
1719
1769
  const liveModel = result.model || useModel;
1720
- traceBridgeSse({
1770
+ traceAgentSse({
1721
1771
  sessionId,
1722
1772
  sseParseMs: Date.now() - sseStartedAt,
1723
1773
  ttftMs,
@@ -1726,7 +1776,7 @@ export class AnthropicOAuthProvider {
1726
1776
  transport: 'sse',
1727
1777
  });
1728
1778
 
1729
- traceBridgeUsage({
1779
+ traceAgentUsage({
1730
1780
  sessionId,
1731
1781
  iteration,
1732
1782
  inputTokens: result.usage?.inputTokens || 0,
@@ -1799,6 +1849,7 @@ export class AnthropicOAuthProvider {
1799
1849
  firstAttemptClassifier = 'empty_stream';
1800
1850
  try { controller?.abort?.(err); } catch { /* best-effort teardown */ }
1801
1851
  try { process.stderr.write(`[anthropic-oauth] empty stream (no message_start) — retry ${attemptIndex + 1}/${MAX_MIDSTREAM_RETRIES}\n`); } catch {}
1852
+ await _midstreamSleepWithAbort(midstreamBackoffFor(attemptIndex + 1), totalSignal);
1802
1853
  continue;
1803
1854
  }
1804
1855
  if (classifyError(err) === 'transient'
@@ -1811,6 +1862,7 @@ export class AnthropicOAuthProvider {
1811
1862
  try {
1812
1863
  process.stderr.write(`[anthropic-oauth] transient SSE error — retry ${attemptIndex + 1}/${MAX_MIDSTREAM_RETRIES} (${err?.providerErrorType || err?.message || 'unknown'})\n`);
1813
1864
  } catch {}
1865
+ await _midstreamSleepWithAbort(midstreamBackoffFor(attemptIndex + 1), totalSignal);
1814
1866
  continue;
1815
1867
  }
1816
1868
  // Truncated stream (message_start without message_stop): the
@@ -1832,6 +1884,7 @@ export class AnthropicOAuthProvider {
1832
1884
  firstAttemptClassifier = 'truncated_stream';
1833
1885
  try { controller?.abort?.(err); } catch { /* best-effort teardown */ }
1834
1886
  try { process.stderr.write(`[anthropic-oauth] truncated stream — retry ${attemptIndex + 1}/${MAX_MIDSTREAM_RETRIES}\n`); } catch {}
1887
+ await _midstreamSleepWithAbort(midstreamBackoffFor(attemptIndex + 1), totalSignal);
1835
1888
  continue;
1836
1889
  }
1837
1890
  const classifier = _classifyMidstreamError(err, midState);
@@ -1845,6 +1898,7 @@ export class AnthropicOAuthProvider {
1845
1898
  try {
1846
1899
  process.stderr.write(`[anthropic-oauth] mid-stream recovered: retry ${attemptIndex + 1}/${MAX_MIDSTREAM_RETRIES} (cause: ${classifier})\n`);
1847
1900
  } catch {}
1901
+ await _midstreamSleepWithAbort(midstreamBackoffFor(attemptIndex + 1), totalSignal);
1848
1902
  continue;
1849
1903
  }
1850
1904
  if (attemptIndex > 0 && firstAttemptError) {
@@ -1982,31 +2036,118 @@ function _oauthParseScopeField(scope) {
1982
2036
  return String(scope || '').split(' ').filter(Boolean);
1983
2037
  }
1984
2038
 
1985
- export async function loginOAuth() {
2039
+ function _parseOAuthCodeInput(input) {
2040
+ const value = String(input || '').trim();
2041
+ if (!value) return { code: '', state: '' };
2042
+ try {
2043
+ const url = new URL(value);
2044
+ const code = url.searchParams.get('code') || '';
2045
+ const state = url.searchParams.get('state') || '';
2046
+ if (code || state) return { code, state, redirectUri: `${url.origin}${url.pathname}` };
2047
+ } catch { /* not a URL */ }
2048
+ if (value.includes('#')) {
2049
+ const [code, state] = value.split('#', 2);
2050
+ return { code: String(code || '').trim(), state: String(state || '').trim() };
2051
+ }
2052
+ if (value.includes('code=')) {
2053
+ const params = new URLSearchParams(value.startsWith('?') ? value.slice(1) : value);
2054
+ return { code: params.get('code') || '', state: params.get('state') || '' };
2055
+ }
2056
+ return { code: value, state: '' };
2057
+ }
2058
+
2059
+ async function exchangeAuthorizationCode({ pkce, code, state, redirectUri }) {
2060
+ const cleanCode = String(code || '').trim();
2061
+ if (!cleanCode) throw new Error('[anthropic-oauth] authorization code is required');
2062
+ const tokenRes = await fetch(TOKEN_URL, {
2063
+ method: 'POST',
2064
+ headers: {
2065
+ 'Content-Type': 'application/json',
2066
+ 'anthropic-dangerous-direct-browser-access': 'true',
2067
+ 'user-agent': `claude-cli/${resolveCliVersion()} (external, sdk-cli)`,
2068
+ },
2069
+ body: JSON.stringify({
2070
+ grant_type: 'authorization_code',
2071
+ code: cleanCode,
2072
+ redirect_uri: redirectUri,
2073
+ client_id: CLAUDE_CODE_CLIENT_ID,
2074
+ code_verifier: pkce.verifier,
2075
+ state,
2076
+ }),
2077
+ redirect: 'error',
2078
+ signal: AbortSignal.timeout(OAUTH_TOKEN_TIMEOUT_MS),
2079
+ dispatcher: getLlmDispatcher(),
2080
+ });
2081
+ if (!tokenRes.ok) {
2082
+ const text = await tokenRes.text().catch(() => '');
2083
+ throw new Error(`[anthropic-oauth] token exchange ${tokenRes.status}: ${_scrubTokens(text).slice(0, 500)}`);
2084
+ }
2085
+ const json = await tokenRes.json();
2086
+ const accessToken = json?.access_token || json?.accessToken;
2087
+ const refreshToken = json?.refresh_token || json?.refreshToken;
2088
+ if (!accessToken || !refreshToken) {
2089
+ throw new Error('[anthropic-oauth] token exchange response missing access_token or refresh_token');
2090
+ }
2091
+ const expiresAt = _normalizeExpiresAt(json?.expires_at ?? json?.expiresAt)
2092
+ || (typeof json?.expires_in === 'number' ? Date.now() + json.expires_in * 1000 : 0);
2093
+ const scopes = _oauthParseScopeField(json?.scope);
2094
+ const credPath = _oauthCredentialsWritePath();
2095
+ let raw = {};
2096
+ if (existsSync(credPath)) {
2097
+ raw = JSON.parse(readFileSync(credPath, 'utf-8'));
2098
+ }
2099
+ const existingOauth = raw.claudeAiOauth || {};
2100
+ raw.claudeAiOauth = {
2101
+ ...existingOauth,
2102
+ accessToken,
2103
+ refreshToken,
2104
+ expiresAt,
2105
+ scopes,
2106
+ subscriptionType: existingOauth.subscriptionType ?? null,
2107
+ };
2108
+ _saveCredentialsFile(credPath, raw);
2109
+ return {
2110
+ path: credPath,
2111
+ accessToken,
2112
+ refreshToken,
2113
+ expiresAt,
2114
+ scopes,
2115
+ subscriptionType: raw.claudeAiOauth.subscriptionType,
2116
+ };
2117
+ }
2118
+
2119
+ export async function beginOAuthLogin() {
1986
2120
  const pkce = _oauthGeneratePKCE();
1987
2121
  const state = randomBytes(32).toString('base64url');
1988
- const url = new URL(CLAUDE_AI_AUTHORIZE_URL);
1989
- url.searchParams.set('code', 'true');
1990
- url.searchParams.set('client_id', CLAUDE_CODE_CLIENT_ID);
1991
- url.searchParams.set('response_type', 'code');
1992
- url.searchParams.set('redirect_uri', OAUTH_REDIRECT_URI);
1993
- url.searchParams.set('scope', OAUTH_LOGIN_SCOPE);
1994
- url.searchParams.set('code_challenge', pkce.challenge);
1995
- url.searchParams.set('code_challenge_method', 'S256');
1996
- url.searchParams.set('state', state);
1997
-
1998
- return new Promise((resolve, reject) => {
2122
+ const buildUrl = (redirectUri) => {
2123
+ const url = new URL(CLAUDE_AI_AUTHORIZE_URL);
2124
+ url.searchParams.set('code', 'true');
2125
+ url.searchParams.set('client_id', CLAUDE_CODE_CLIENT_ID);
2126
+ url.searchParams.set('response_type', 'code');
2127
+ url.searchParams.set('redirect_uri', redirectUri);
2128
+ url.searchParams.set('scope', OAUTH_LOGIN_SCOPE);
2129
+ url.searchParams.set('code_challenge', pkce.challenge);
2130
+ url.searchParams.set('code_challenge_method', 'S256');
2131
+ url.searchParams.set('state', state);
2132
+ return url;
2133
+ };
2134
+ const url = buildUrl(OAUTH_REDIRECT_URI);
2135
+ const manualUrl = buildUrl(OAUTH_MANUAL_REDIRECT_URI);
2136
+
2137
+ let server = null;
2138
+ let timeout = null;
2139
+ let finish = null;
2140
+ const waitForCallback = new Promise((resolve, reject) => {
1999
2141
  let settled = false;
2000
- let timeout = null;
2001
- const settle = (value, error = null) => {
2142
+ finish = (value, error = null) => {
2002
2143
  if (settled) return;
2003
2144
  settled = true;
2004
2145
  if (timeout) clearTimeout(timeout);
2005
- try { server.close(); } catch { /* already closed */ }
2146
+ try { server?.close(); } catch { /* already closed */ }
2006
2147
  if (error) reject(error);
2007
2148
  else resolve(value);
2008
2149
  };
2009
- const server = createServer(async (req, res) => {
2150
+ server = createServer(async (req, res) => {
2010
2151
  const u = new URL(req.url || '/', `http://${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}`);
2011
2152
  if (u.pathname !== OAUTH_CALLBACK_PATH) {
2012
2153
  res.writeHead(404);
@@ -2017,76 +2158,24 @@ export async function loginOAuth() {
2017
2158
  if (!code || u.searchParams.get('state') !== state) {
2018
2159
  res.writeHead(400);
2019
2160
  res.end('Invalid');
2020
- settle(null);
2161
+ finish(null);
2021
2162
  return;
2022
2163
  }
2023
- res.writeHead(200, { 'Content-Type': 'text/html' });
2024
- res.end('<html><body><h2>Claude login successful! You can close this tab.</h2></body></html>');
2025
2164
  try {
2026
- const tokenRes = await fetch(TOKEN_URL, {
2027
- method: 'POST',
2028
- headers: {
2029
- 'Content-Type': 'application/json',
2030
- 'anthropic-dangerous-direct-browser-access': 'true',
2031
- 'user-agent': `claude-cli/${resolveCliVersion()} (external, sdk-cli)`,
2032
- },
2033
- body: JSON.stringify({
2034
- grant_type: 'authorization_code',
2035
- code,
2036
- redirect_uri: OAUTH_REDIRECT_URI,
2037
- client_id: CLAUDE_CODE_CLIENT_ID,
2038
- code_verifier: pkce.verifier,
2039
- state,
2040
- }),
2041
- redirect: 'error',
2042
- signal: AbortSignal.timeout(OAUTH_TOKEN_TIMEOUT_MS),
2043
- dispatcher: getLlmDispatcher(),
2044
- });
2045
- if (!tokenRes.ok) {
2046
- const text = await tokenRes.text().catch(() => '');
2047
- settle(null, new Error(`[anthropic-oauth] token exchange ${tokenRes.status}: ${_scrubTokens(text).slice(0, 500)}`));
2048
- return;
2049
- }
2050
- const json = await tokenRes.json();
2051
- const accessToken = json?.access_token || json?.accessToken;
2052
- const refreshToken = json?.refresh_token || json?.refreshToken;
2053
- if (!accessToken || !refreshToken) {
2054
- settle(null, new Error('[anthropic-oauth] token exchange response missing access_token or refresh_token'));
2055
- return;
2056
- }
2057
- const expiresAt = _normalizeExpiresAt(json?.expires_at ?? json?.expiresAt)
2058
- || (typeof json?.expires_in === 'number' ? Date.now() + json.expires_in * 1000 : 0);
2059
- const scopes = _oauthParseScopeField(json?.scope);
2060
- const credPath = _oauthCredentialsWritePath();
2061
- let raw = {};
2062
- if (existsSync(credPath)) {
2063
- raw = JSON.parse(readFileSync(credPath, 'utf-8'));
2064
- }
2065
- const existingOauth = raw.claudeAiOauth || {};
2066
- raw.claudeAiOauth = {
2067
- ...existingOauth,
2068
- accessToken,
2069
- refreshToken,
2070
- expiresAt,
2071
- scopes,
2072
- subscriptionType: existingOauth.subscriptionType ?? null,
2073
- };
2074
- _saveCredentialsFile(credPath, raw);
2075
- resolve({
2076
- path: credPath,
2077
- accessToken,
2078
- refreshToken,
2079
- expiresAt,
2080
- scopes,
2081
- subscriptionType: raw.claudeAiOauth.subscriptionType,
2082
- });
2165
+ const tokens = await exchangeAuthorizationCode({ pkce, code, state, redirectUri: OAUTH_REDIRECT_URI });
2166
+ res.writeHead(302, { Location: OAUTH_SUCCESS_REDIRECT_URL });
2167
+ res.end();
2168
+ finish(tokens);
2083
2169
  } catch (err) {
2084
- settle(null, err instanceof Error ? err : new Error(String(err)));
2170
+ const error = err instanceof Error ? err : new Error(String(err));
2171
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
2172
+ res.end(`Claude login failed: ${error.message}`);
2173
+ finish(null, error);
2085
2174
  }
2086
2175
  });
2087
- timeout = setTimeout(() => settle(null), OAUTH_LOGIN_TIMEOUT_MS);
2176
+ timeout = setTimeout(() => finish(null), OAUTH_LOGIN_TIMEOUT_MS);
2088
2177
  server.listen(OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_HOST, async () => {
2089
- process.stderr.write(`\n[anthropic-oauth] Open this URL to log in with Claude:\n${url.toString()}\n\n`);
2178
+ 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`);
2090
2179
  try {
2091
2180
  const { openInBrowser } = await import('../../../shared/open-url.mjs');
2092
2181
  openInBrowser(url.toString());
@@ -2094,8 +2183,31 @@ export async function loginOAuth() {
2094
2183
  process.stderr.write(`[anthropic-oauth] browser open failed: ${String(err?.message || err).slice(0, 200)}\n`);
2095
2184
  }
2096
2185
  });
2097
- server.on('error', (err) => settle(null, new Error(`[anthropic-oauth] callback server failed on ${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}: ${err?.message || err}`)));
2186
+ server.on('error', (err) => finish(null, new Error(`[anthropic-oauth] callback server failed on ${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}: ${err?.message || err}`)));
2098
2187
  });
2188
+
2189
+ return {
2190
+ provider: 'anthropic-oauth',
2191
+ url: url.toString(),
2192
+ manualUrl: manualUrl.toString(),
2193
+ waitForCallback,
2194
+ completeCode: async (input) => {
2195
+ const parsed = _parseOAuthCodeInput(input);
2196
+ if (parsed.state && parsed.state !== state) throw new Error('[anthropic-oauth] OAuth state mismatch');
2197
+ const redirectUri = parsed.redirectUri || (parsed.state ? OAUTH_MANUAL_REDIRECT_URI : OAUTH_REDIRECT_URI);
2198
+ const tokens = await exchangeAuthorizationCode({ pkce, code: parsed.code, state, redirectUri });
2199
+ finish?.(tokens);
2200
+ return tokens;
2201
+ },
2202
+ cancel: () => {
2203
+ finish?.(null);
2204
+ },
2205
+ };
2206
+ }
2207
+
2208
+ export async function loginOAuth() {
2209
+ const login = await beginOAuthLogin();
2210
+ return await login.waitForCallback;
2099
2211
  }
2100
2212
 
2101
2213
  // Additive exports for test harnesses.