mixdog 0.8.0 → 0.9.0

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 (285) hide show
  1. package/README.md +47 -23
  2. package/package.json +33 -27
  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 +344 -0
  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 +1223 -95
  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 -7
  62. package/src/lib/rules-builder.cjs +240 -96
  63. package/src/lib/text-utils.cjs +1 -1
  64. package/src/mixdog-session-runtime.mjs +2325 -450
  65. package/src/output-styles/default.md +12 -28
  66. package/src/output-styles/extreme-simple.md +9 -6
  67. package/src/output-styles/simple.md +22 -9
  68. package/src/repl.mjs +118 -59
  69. package/src/rules/agent/00-common.md +15 -0
  70. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  71. package/src/rules/agent/30-explorer.md +22 -0
  72. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  73. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  75. package/src/rules/lead/01-general.md +9 -5
  76. package/src/rules/lead/04-workflow.md +51 -12
  77. package/src/rules/lead/lead-tool.md +6 -0
  78. package/src/rules/shared/01-tool.md +12 -1
  79. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  80. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  81. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  82. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  83. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  85. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  86. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  87. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  88. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  89. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  90. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  91. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  92. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  93. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +377 -243
  95. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +146 -93
  96. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  97. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  99. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  100. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  101. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  102. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  103. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  104. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  106. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +221 -70
  107. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +477 -147
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -496
  109. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  110. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  111. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +31 -11
  112. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  113. package/src/runtime/agent/orchestrator/session/cache/post-edit-marks.mjs +4 -4
  114. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +1 -1
  115. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  116. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  117. package/src/runtime/agent/orchestrator/session/loop.mjs +851 -674
  118. package/src/runtime/agent/orchestrator/session/manager.mjs +1593 -466
  119. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  120. package/src/runtime/agent/orchestrator/session/store.mjs +291 -46
  121. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  122. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  123. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  124. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +34 -7
  125. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +1 -1
  126. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -0
  127. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +9 -9
  128. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  129. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  130. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  131. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  132. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  133. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  138. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  139. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  140. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  141. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  142. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  143. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  144. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +1 -1
  145. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  146. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  147. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  148. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  149. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  150. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  151. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  152. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  153. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  154. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  155. package/src/runtime/channels/index.mjs +152 -24
  156. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  157. package/src/runtime/channels/lib/session-discovery.mjs +3 -2
  158. package/src/runtime/channels/lib/tool-format.mjs +0 -2
  159. package/src/runtime/channels/lib/transcript-discovery.mjs +3 -2
  160. package/src/runtime/channels/lib/webhook.mjs +1 -1
  161. package/src/runtime/channels/tool-defs.mjs +29 -29
  162. package/src/runtime/memory/index.mjs +635 -107
  163. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  164. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  165. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  166. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  167. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  168. package/src/runtime/memory/lib/memory-cycle1.mjs +38 -17
  169. package/src/runtime/memory/lib/memory-cycle2.mjs +6 -7
  170. package/src/runtime/memory/lib/memory-cycle3.mjs +4 -4
  171. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -1
  172. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  173. package/src/runtime/memory/lib/memory.mjs +88 -9
  174. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  175. package/src/runtime/memory/lib/pg/adapter.mjs +15 -1
  176. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  177. package/src/runtime/memory/lib/runtime-fetcher.mjs +37 -3
  178. package/src/runtime/memory/lib/session-ingest.mjs +194 -0
  179. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  180. package/src/runtime/memory/tool-defs.mjs +46 -37
  181. package/src/runtime/search/index.mjs +102 -466
  182. package/src/runtime/search/lib/web-tools.mjs +45 -25
  183. package/src/runtime/search/tool-defs.mjs +16 -23
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/atomic-file.mjs +4 -3
  186. package/src/runtime/shared/background-tasks.mjs +122 -11
  187. package/src/runtime/shared/child-spawn-gate.mjs +145 -0
  188. package/src/runtime/shared/config.mjs +7 -4
  189. package/src/runtime/shared/err-text.mjs +131 -4
  190. package/src/runtime/shared/llm/cost.mjs +2 -2
  191. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  192. package/src/runtime/shared/llm/index.mjs +34 -11
  193. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  194. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  195. package/src/runtime/shared/singleton-owner.mjs +104 -0
  196. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  197. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  198. package/src/runtime/shared/tool-surface.mjs +624 -98
  199. package/src/runtime/shared/user-data-guard.mjs +0 -2
  200. package/src/standalone/agent-task-status.mjs +203 -0
  201. package/src/standalone/agent-task-status.test.mjs +76 -0
  202. package/src/standalone/agent-tool.mjs +1913 -0
  203. package/src/standalone/channel-worker.mjs +370 -14
  204. package/src/standalone/explore-tool.mjs +165 -70
  205. package/src/standalone/folder-dialog.mjs +314 -0
  206. package/src/standalone/hook-bus.mjs +898 -22
  207. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  208. package/src/standalone/projects.mjs +226 -0
  209. package/src/standalone/provider-admin.mjs +41 -24
  210. package/src/standalone/seeds.mjs +2 -69
  211. package/src/standalone/usage-dashboard.mjs +96 -8
  212. package/src/tui/App.jsx +4798 -2153
  213. package/src/tui/components/AnsiText.jsx +39 -28
  214. package/src/tui/components/ContextPanel.jsx +87 -29
  215. package/src/tui/components/Markdown.jsx +43 -77
  216. package/src/tui/components/MarkdownTable.jsx +9 -184
  217. package/src/tui/components/Message.jsx +28 -11
  218. package/src/tui/components/Picker.jsx +95 -56
  219. package/src/tui/components/PromptInput.jsx +367 -239
  220. package/src/tui/components/QueuedCommands.jsx +1 -1
  221. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  222. package/src/tui/components/Spinner.jsx +67 -38
  223. package/src/tui/components/StatusLine.jsx +606 -38
  224. package/src/tui/components/TextEntryPanel.jsx +128 -9
  225. package/src/tui/components/ToolExecution.jsx +617 -368
  226. package/src/tui/components/TurnDone.jsx +3 -3
  227. package/src/tui/components/UsagePanel.jsx +3 -5
  228. package/src/tui/components/tool-output-format.mjs +365 -0
  229. package/src/tui/components/tool-output-format.test.mjs +220 -0
  230. package/src/tui/dist/index.mjs +8915 -2418
  231. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  232. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  233. package/src/tui/engine.mjs +1455 -279
  234. package/src/tui/figures.mjs +21 -40
  235. package/src/tui/index.jsx +75 -31
  236. package/src/tui/input-editing.mjs +25 -0
  237. package/src/tui/markdown/format-token.mjs +511 -68
  238. package/src/tui/markdown/format-token.test.mjs +216 -0
  239. package/src/tui/markdown/render-ansi.mjs +94 -0
  240. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  241. package/src/tui/markdown/stream-fence.mjs +34 -0
  242. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  243. package/src/tui/markdown/table-layout.mjs +250 -0
  244. package/src/tui/paste-attachments.mjs +0 -7
  245. package/src/tui/spinner-verbs.mjs +1 -2
  246. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  247. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  248. package/src/tui/theme.mjs +746 -24
  249. package/src/tui/time-format.mjs +1 -1
  250. package/src/tui/transcript-tool-failures.mjs +67 -0
  251. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  252. package/src/ui/ansi.mjs +1 -2
  253. package/src/ui/markdown.mjs +85 -26
  254. package/src/ui/markdown.test.mjs +70 -0
  255. package/src/ui/model-display.mjs +121 -0
  256. package/src/ui/session-stats.mjs +44 -0
  257. package/src/ui/statusline-context-label.test.mjs +15 -0
  258. package/src/ui/statusline.mjs +386 -178
  259. package/src/ui/tool-card.mjs +3 -16
  260. package/src/vendor/statusline/bin/statusline-lib.mjs +8 -4
  261. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  262. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  263. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  264. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  265. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  266. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  267. package/src/workflows/default/WORKFLOW.md +12 -5
  268. package/src/workflows/default/workflow.json +0 -1
  269. package/src/workflows/solo/WORKFLOW.md +15 -0
  270. package/src/workflows/solo/workflow.json +7 -0
  271. package/vendor/ink/build/output.js +6 -1
  272. package/src/agents/scheduler-task.md +0 -3
  273. package/src/agents/web-researcher/AGENT.md +0 -3
  274. package/src/agents/web-researcher/agent.json +0 -6
  275. package/src/agents/webhook-handler.md +0 -3
  276. package/src/rules/bridge/00-common.md +0 -5
  277. package/src/rules/bridge/30-explorer.md +0 -4
  278. package/src/rules/lead/00-tool-lead.md +0 -5
  279. package/src/rules/shared/00-language.md +0 -3
  280. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  281. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  282. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  283. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  284. package/src/standalone/bridge-tool.mjs +0 -1414
  285. 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,14 @@ import {
31
30
  } from '../stall-policy.mjs';
32
31
  import {
33
32
  classifyError,
33
+ midstreamBackoffFor,
34
34
  retryAfterMsFromError,
35
35
  withRetry,
36
36
  } from './retry-classifier.mjs';
37
37
  import { buildAnthropicBetaHeaders, supportsAnthropicFastMode } from './anthropic-betas.mjs';
38
38
  import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
39
39
  import { normalizeContentForAnthropic } from './media-normalization.mjs';
40
+ import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
40
41
 
41
42
  // --- Model catalog cache helpers ---
42
43
  // Disk-backed cache so repeated process starts (cron, tool calls) don't
@@ -45,6 +46,36 @@ const MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
45
46
  // SSE progress emits (per-request "Response …" and "Done:" lines). Off by default.
46
47
  const SSE_VERBOSE = process.env.MIXDOG_SSE_VERBOSE === '1';
47
48
 
49
+ /** Bounded mid-stream SSE retries (transient stream loss); shared with anthropic.mjs. */
50
+ export const ANTHROPIC_MAX_MIDSTREAM_RETRIES = 3;
51
+
52
+ function formatRetryAfter(ms) {
53
+ const n = Number(ms);
54
+ if (!Number.isFinite(n) || n < 0) return '';
55
+ if (n >= 60_000 && n % 60_000 === 0) return `${Math.round(n / 60_000)}m`;
56
+ if (n >= 1000) return `${Math.ceil(n / 1000)}s`;
57
+ return `${Math.ceil(n)}ms`;
58
+ }
59
+
60
+ function anthropicQuotaError(status, headers, bodyText = '') {
61
+ const retryAfterMs = retryAfterMsFromError({ headers, response: { headers } });
62
+ const retryAfter = formatRetryAfter(retryAfterMs);
63
+ const detail = bodyText ? `: ${String(bodyText).slice(0, 200)}` : '';
64
+ const retry = retryAfter ? ` retryAfter=${retryAfter}` : '';
65
+ const err = new Error(`Anthropic OAuth API ${status} quota/rate limit${retry}${detail}`);
66
+ err.name = 'ProviderQuotaError';
67
+ err.code = 'PROVIDER_QUOTA';
68
+ err.httpStatus = status;
69
+ err.status = status;
70
+ err.headers = headers;
71
+ err.response = { status, headers };
72
+ err.retryAfterMs = retryAfterMs;
73
+ err.providerQuota = true;
74
+ err.quotaExceeded = true;
75
+ err.unsafeToRetry = true;
76
+ return err;
77
+ }
78
+
48
79
  const _modelCache = makeModelCache({
49
80
  fileName: 'anthropic-oauth-models.json',
50
81
  ttlMs: MODEL_CACHE_TTL_MS,
@@ -215,10 +246,9 @@ function assertSafeTokenURL(rawURL) {
215
246
  }
216
247
  return rawURL;
217
248
  }
218
- const TOKEN_URL = assertSafeTokenURL(process.env.ANTHROPIC_OAUTH_TOKEN_URL || 'https://console.anthropic.com/v1/oauth/token');
249
+ const TOKEN_URL = assertSafeTokenURL(process.env.ANTHROPIC_OAUTH_TOKEN_URL || 'https://platform.claude.com/v1/oauth/token');
219
250
  const ANTHROPIC_VERSION = '2023-06-01';
220
251
  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
252
  const CLAUDE_CODE_CLIENT_ID = process.env.ANTHROPIC_OAUTH_CLIENT_ID || '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
223
253
  const TOKEN_REFRESH_SKEW_MS = 5 * 60_000;
224
254
  const CLAUDE_AI_AUTHORIZE_URL = 'https://claude.com/cai/oauth/authorize';
@@ -235,15 +265,17 @@ const OAUTH_CALLBACK_HOST = 'localhost';
235
265
  const OAUTH_CALLBACK_PORT = 54545;
236
266
  const OAUTH_CALLBACK_PATH = '/callback';
237
267
  const OAUTH_REDIRECT_URI = `http://${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`;
268
+ const OAUTH_MANUAL_REDIRECT_URI = process.env.ANTHROPIC_OAUTH_MANUAL_REDIRECT_URI || 'https://platform.claude.com/oauth/code/callback';
269
+ const OAUTH_SUCCESS_REDIRECT_URL = process.env.ANTHROPIC_OAUTH_SUCCESS_REDIRECT_URL || 'https://platform.claude.com/oauth/code/success?app=claude-code';
238
270
  const OAUTH_LOGIN_TIMEOUT_MS = 5 * 60_000;
239
271
  const OAUTH_TOKEN_TIMEOUT_MS = 30_000;
240
272
 
241
- // Anthropic OAuth contract for first-party Claude Code clients.
273
+ // Anthropic OAuth contract for first-party OAuth clients.
242
274
  // Opus/Sonnet requests are gated on a specific system-prompt prefix.
243
275
  // Mixdog keeps that upstream client contract for OAuth routing. Haiku is not
244
276
  // gated and ignores this prefix.
245
277
  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';
278
+ 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
279
  const DEFAULT_CLI_VERSION = '2.1.77';
248
280
 
249
281
  function resolveCliVersion() {
@@ -252,23 +284,30 @@ function resolveCliVersion() {
252
284
  }
253
285
 
254
286
  function requiresSystemPrefix(model) {
255
- // Opus / Sonnet require the Claude Code system prefix when authenticated
287
+ // Opus / Sonnet require the OAuth system prefix when authenticated
256
288
  // via OAuth. Haiku does not.
257
289
  return /^claude-(opus|sonnet)/i.test(String(model || ''));
258
290
  }
259
291
 
260
292
  // 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
293
+ // system block. When it reads exactly the OAuth system prefix string it routes
294
+ // into the first-party OAuth pool; any other
263
295
  // content (even the prefix concatenated with extra text in the same block)
264
296
  // falls into the standard pool and Opus/Sonnet return 429. Splitting into
265
297
  // 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)
298
+ function buildSystemBlocks(systemMsgs, model, systemTtl, tier3Ttl) {
299
+ // systemMsgs is an array of { content, cacheTier } each non-empty element
300
+ // becomes its own Anthropic content block with its own cache_control
301
+ // breakpoint. Blocks tagged cacheTier:'tier3' (BP3 sessionMarker) take the
302
+ // tier3 TTL; every other block (BP1 baseRules / BP2 stableSystem) takes the
303
+ // system TTL. Invariant: callers must pass an array.
304
+ const items = Array.isArray(systemMsgs)
305
+ ? systemMsgs
306
+ .map(m => ({
307
+ text: typeof m?.content === 'string' ? m.content.trim() : '',
308
+ tier: m?.cacheTier === 'tier3' ? 'tier3' : 'system',
309
+ }))
310
+ .filter(it => it.text)
272
311
  : [];
273
312
  const gated = requiresSystemPrefix(model);
274
313
 
@@ -276,22 +315,24 @@ function buildSystemBlocks(systemText, model, cacheControl, maxCacheBlocks = 2)
276
315
  if (gated) {
277
316
  blocks.push({ type: 'text', text: CLAUDE_CODE_SYSTEM_PREFIX });
278
317
  }
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.
318
+ for (let i = 0; i < items.length; i++) {
319
+ let body = items[i].text;
320
+ // Strip a duplicated OAuth system prefix from the first block if present.
282
321
  if (gated && i === 0 && body.startsWith(CLAUDE_CODE_SYSTEM_PREFIX)) {
283
322
  body = body.slice(CLAUDE_CODE_SYSTEM_PREFIX.length).trim();
284
323
  if (!body) continue;
285
324
  }
286
- blocks.push({ type: 'text', text: body });
325
+ blocks.push({ type: 'text', text: body, _tier: items[i].tier });
287
326
  }
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
- }
327
+ // Apply per-tier cache_control. BP1/BP2 -> systemTtl, BP3 -> tier3Ttl. The
328
+ // gating prefix block is never cached (Anthropic routes on its exact bytes).
329
+ // tier3Ttl === null leaves the 3rd block uncached (e.g. maintenance roles).
330
+ for (const b of blocks) {
331
+ const tier = b._tier;
332
+ delete b._tier;
333
+ if (b.text === CLAUDE_CODE_SYSTEM_PREFIX) continue;
334
+ const ttl = tier === 'tier3' ? tier3Ttl : systemTtl;
335
+ if (ttl) b.cache_control = ttl;
295
336
  }
296
337
  return blocks;
297
338
  }
@@ -325,6 +366,18 @@ const EFFORT_BUDGET = {
325
366
  max: 32768,
326
367
  };
327
368
 
369
+ const MIN_THINKING_BUDGET = 1024;
370
+ const THINKING_OUTPUT_RESERVE = 1024;
371
+
372
+ function clampThinkingBudgetTokens(value, maxTokens) {
373
+ const desired = Math.floor(Number(value));
374
+ const max = Math.floor(Number(maxTokens));
375
+ if (!Number.isFinite(desired) || desired <= 0 || !Number.isFinite(max)) return null;
376
+ const ceiling = max - THINKING_OUTPUT_RESERVE;
377
+ if (ceiling < MIN_THINKING_BUDGET) return null;
378
+ return Math.max(MIN_THINKING_BUDGET, Math.min(desired, ceiling));
379
+ }
380
+
328
381
  // Tracks which unknown effort labels we've already logged so a repeated
329
382
  // session-level misconfig doesn't flood stderr with the same warning.
330
383
  const _LOGGED_UNKNOWN_EFFORT = new Set();
@@ -345,7 +398,6 @@ function credentialCandidates() {
345
398
  const paths = [];
346
399
  _pushUnique(paths, process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH);
347
400
  _pushUnique(paths, DEFAULT_CREDENTIALS_PATH);
348
- _pushUnique(paths, CLAUDE_CODE_CREDENTIALS_PATH);
349
401
  return paths;
350
402
  }
351
403
 
@@ -385,21 +437,19 @@ function _loadCredentialsFile(path) {
385
437
  }
386
438
  }
387
439
 
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.
440
+ // Cross-process safe credential save. Lockfile (O_EXCL) prevents two Mixdog
441
+ // refreshers from clobbering each other; atomic rename guarantees readers see
442
+ // either the old or new file, never a half-written one. Used so refresh_token
443
+ // rotation propagates to other Mixdog readers of the same credentials file
444
+ // instead of leaving them stuck on the previous refresh_token.
394
445
  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 });
446
+ // Secret file, not parent-dir ACL mutation. `secret: true` clamps the file
447
+ // itself on Windows; it deliberately leaves the data dir inheritance alone.
448
+ writeJsonAtomicSync(path, raw, { lock: true, fsyncDir: true, mode: 0o600, secret: true });
399
449
  }
400
450
 
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.
451
+ // Cheap stat-only probe so ensureAuth can detect Mixdog-updated credentials
452
+ // without paying a full JSON read every call.
403
453
  function _credentialsMaxMtime() {
404
454
  let max = 0;
405
455
  for (const p of credentialCandidates()) {
@@ -433,7 +483,7 @@ export function describeAnthropicOAuthCredentials() {
433
483
  try {
434
484
  const creds = loadCredentials();
435
485
  if (!creds?.accessToken) {
436
- return { authenticated: false, status: 'Not Set', detail: DEFAULT_CREDENTIALS_PATH };
486
+ return { authenticated: false, status: 'Not Set', detail: 'Mixdog OAuth credentials' };
437
487
  }
438
488
  const hasInferenceScope = Array.isArray(creds.scopes) && creds.scopes.includes('user:inference');
439
489
  const hasRefresh = Boolean(creds.refreshToken);
@@ -495,7 +545,7 @@ function _scrubTokens(text) {
495
545
 
496
546
  async function refreshOAuthCredentials(creds) {
497
547
  if (!creds?.refreshToken) {
498
- throw new Error('Anthropic OAuth refresh token not available. Run /auth anthropic-oauth or /providers in mixdog to re-authenticate.');
548
+ throw new Error('Anthropic OAuth refresh token not available. Open /providers in mixdog to sign in again.');
499
549
  }
500
550
 
501
551
  const controller = new AbortController();
@@ -541,10 +591,10 @@ async function refreshOAuthCredentials(creds) {
541
591
  scopes: Array.isArray(json?.scope) ? json.scope : creds.scopes,
542
592
  subscriptionType: creds.subscriptionType,
543
593
  };
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.
594
+ // Persist rotated tokens back so any other Mixdog reader of the same
595
+ // credentials file picks up the new refresh_token. Without this, a
596
+ // later process can replay an old single-use refresh token and loop on
597
+ // invalid_grant.
548
598
  if (creds.path && existsSync(creds.path)) {
549
599
  try {
550
600
  const raw = JSON.parse(readFileSync(creds.path, 'utf-8'));
@@ -557,8 +607,8 @@ async function refreshOAuthCredentials(creds) {
557
607
  };
558
608
  _saveCredentialsFile(creds.path, raw);
559
609
  } 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)}`);
610
+ process.stderr.write(`[anthropic-oauth] credential save failed: ${_scrubTokens(err?.message || String(err)).slice(0, 200)}\n`);
611
+ throw new Error(`[oauth] credentials save failed: ${err?.message ?? String(err)}`);
562
612
  }
563
613
  }
564
614
  return refreshed;
@@ -642,9 +692,11 @@ function _sanitizeInputSchema(schema, toolName) {
642
692
  if (addition) description = description ? `${description} ${addition}` : addition;
643
693
  }
644
694
  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
- );
695
+ if (process.env.MIXDOG_DEBUG_SESSION_LOG) {
696
+ process.stderr.write(
697
+ `[anthropic-oauth-sanitizer] tool="${toolName ?? ''}" compound="${compoundKey}" branches=${Array.isArray(compound) ? compound.length : 0} mergedProps=${mergedPropsCount}\n`
698
+ );
699
+ }
648
700
  return {
649
701
  type: 'object',
650
702
  ...(description ? { description } : {}),
@@ -653,11 +705,28 @@ function _sanitizeInputSchema(schema, toolName) {
653
705
  }
654
706
 
655
707
  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
- }));
708
+ return tools.map(t => {
709
+ const out = {
710
+ name: t.name,
711
+ description: t.description,
712
+ input_schema: _sanitizeInputSchema(t.inputSchema, t.name),
713
+ };
714
+ if (t.deferLoading === true || t.defer_loading === true) out.defer_loading = true;
715
+ return out;
716
+ });
717
+ }
718
+ function nativeAnthropicTools(opts) {
719
+ return Array.isArray(opts?.nativeTools)
720
+ ? opts.nativeTools.filter(t => t && typeof t === 'object')
721
+ : [];
722
+ }
723
+ function deferredAnthropicTools(activeTools, opts) {
724
+ if (opts?.session?.deferredNativeTools !== true) return [];
725
+ const active = new Set((activeTools || []).map((tool) => String(tool?.name || '').trim()).filter(Boolean));
726
+ const catalog = Array.isArray(opts.session.deferredToolCatalog) ? opts.session.deferredToolCatalog : [];
727
+ return catalog
728
+ .filter((tool) => tool?.name && !active.has(String(tool.name)))
729
+ .map((tool) => ({ ...tool, deferLoading: true }));
661
730
  }
662
731
 
663
732
  function toAnthropicMessages(messages) {
@@ -694,10 +763,15 @@ function toAnthropicMessages(messages) {
694
763
 
695
764
  if (m.role === 'tool') {
696
765
  const last = result[result.length - 1];
766
+ const refs = Array.isArray(m.nativeToolSearch?.toolReferences)
767
+ ? m.nativeToolSearch.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
768
+ : [];
697
769
  const block = {
698
770
  type: 'tool_result',
699
771
  tool_use_id: m.toolCallId || '',
700
- content: normalizeContentForAnthropic(m.content),
772
+ content: refs.length
773
+ ? refs.map((name) => ({ type: 'tool_reference', tool_name: name }))
774
+ : normalizeContentForAnthropic(m.content),
701
775
  };
702
776
  if (last?.role === 'user' && Array.isArray(last.content)) {
703
777
  last.content.push(block);
@@ -717,17 +791,15 @@ function toAnthropicMessages(messages) {
717
791
  // sanitizeAnthropicContentPairs has already run (and must NOT run again
718
792
  // after this), the blocks we mark here are exactly the blocks the provider
719
793
  // 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
794
  // message-anchor: prefer a safe tool_result tail, then a previous real user
724
795
  // text turn if another slot remains. Synthetic
725
796
  // <system-reminder> messages and current pure-text prompts
726
797
  // are excluded so first-turn prompts do not create a fresh
727
798
  // BP4 write on every new session.
728
- // messageTtl === null disables the tail; tier3Ttl === null disables tier3.
799
+ // messageTtl === null disables the tail. BP3 (tier3) now rides a system block,
800
+ // so it is no longer marked here.
729
801
  // ANTHROPIC_MSG_SLOTS=0 is honoured upstream by passing messageTtl = null.
730
- function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_VOLATILE, messageSlots = 1, tier3Ttl = null } = {}) {
802
+ function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_VOLATILE, messageSlots = 1 } = {}) {
731
803
  if (!Array.isArray(sanitizedMessages) || sanitizedMessages.length === 0) {
732
804
  return sanitizedMessages;
733
805
  }
@@ -741,29 +813,20 @@ function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_
741
813
  return '';
742
814
  };
743
815
  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
816
 
749
817
  const markLast = (msg, ttl) => {
750
818
  if (!msg) return;
751
819
  msg.content = appendCacheControl(msg.content, ttl);
752
820
  };
753
821
  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.
822
+ const canMarkMessageIdx = (idx) => {
823
+ // System-reminder messages (volatileTail / roleSpecific BP4) vary
824
+ // per-call, so never pin them with a 1h marker. The 1h system blocks
825
+ // (BP1/BP2/BP3) already satisfy Anthropic's "1h before 5m" ordering.
759
826
  if (idx < 0) return false;
760
827
  const msg = sanitizedMessages[idx];
761
828
  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)) {
829
+ && isSystemReminder(msg?.content)) {
767
830
  return false;
768
831
  }
769
832
  return true;
@@ -798,29 +861,16 @@ function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_
798
861
  return -1;
799
862
  };
800
863
 
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
864
  if (messageTtl !== null) {
815
865
  const slots = Math.max(0, Math.min(4, Number(messageSlots) || 0));
816
- const marked = new Set(tier3MsgIdx >= 0 ? [tier3MsgIdx] : []);
866
+ const marked = new Set();
817
867
  const candidates = [latestToolResultTailIdx(), previousUserTextAnchorIdx()];
818
868
  for (const idx of candidates) {
819
869
  if (slots <= 0) break;
820
- if (idx < 0 || marked.has(idx) || !canMarkMessageIdx(idx, tier3MsgIdx)) continue;
870
+ if (idx < 0 || marked.has(idx) || !canMarkMessageIdx(idx)) continue;
821
871
  markLast(sanitizedMessages[idx], messageTtl);
822
872
  marked.add(idx);
823
- if (marked.size - (tier3MsgIdx >= 0 ? 1 : 0) >= slots) break;
873
+ if (marked.size >= slots) break;
824
874
  }
825
875
  }
826
876
 
@@ -832,13 +882,34 @@ function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_
832
882
  function _captureMidstreamAbort(state, reason) {
833
883
  if (!state) return;
834
884
  const reasonName = reason?.name || '';
835
- if (reasonName === 'BridgeStallAbortError' || reasonName === 'StreamStalledAbortError') {
885
+ if (reasonName === 'AgentStallAbortError' || reasonName === 'StreamStalledAbortError') {
836
886
  state.watchdogAbort = reasonName;
837
887
  } else {
838
888
  state.userAbort = true;
839
889
  }
840
890
  }
841
891
 
892
+ async function _midstreamSleepWithAbort(ms, signal) {
893
+ if (!ms) return;
894
+ if (!signal) {
895
+ await new Promise((r) => setTimeout(r, ms));
896
+ return;
897
+ }
898
+ await new Promise((resolve, reject) => {
899
+ const t = setTimeout(() => {
900
+ try { signal.removeEventListener('abort', onAbort); } catch {}
901
+ resolve();
902
+ }, ms);
903
+ const onAbort = () => {
904
+ clearTimeout(t);
905
+ const reason = signal.reason;
906
+ reject(reason instanceof Error ? reason : new Error('Anthropic OAuth mid-stream retry backoff aborted'));
907
+ };
908
+ if (signal.aborted) { onAbort(); return; }
909
+ signal.addEventListener('abort', onAbort, { once: true });
910
+ });
911
+ }
912
+
842
913
  function _statusForAnthropicSseError(type, message) {
843
914
  const kind = String(type || '').toLowerCase();
844
915
  const text = String(message || '').toLowerCase();
@@ -894,7 +965,7 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
894
965
  const resetIdleTimer = () => {
895
966
  // OFF by default. When disabled the
896
967
  // idle timer never arms, so the stream is never killed on inactivity;
897
- // the bridge stall watchdog (600s) remains the dead-stream backstop.
968
+ // the agent stall watchdog (600s) remains the dead-stream backstop.
898
969
  if (!PROVIDER_SSE_IDLE_WATCHDOG_ENABLED) return;
899
970
  if (idleTimer) clearTimeout(idleTimer);
900
971
  idleTimer = setTimeout(() => {
@@ -975,7 +1046,7 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
975
1046
  if (line.startsWith(':')) {
976
1047
  // SSE comment frame (Anthropic `:ping` keepalive). The HTML Standard SSE
977
1048
  // 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
1049
+ // the agent stall watchdog sees the stream is still alive during Opus
979
1050
  // extended-thinking pauses. No content is emitted — this only refreshes
980
1051
  // the runtime's lastStreamDeltaAt timestamp.
981
1052
  try { onStreamDelta?.(); } catch {}
@@ -1062,15 +1133,15 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1062
1133
  // whole tool_call — the loop never saw it and
1063
1134
  // the assistant turn ended with an unmatched
1064
1135
  // 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.
1136
+ // input still produces a tool_call (with an
1137
+ // invalid-args marker and a logged error) instead
1138
+ // of a silent drop or accidental `{}` dispatch.
1068
1139
  let parsedArgs = {};
1069
1140
  if (pending.inputJson) {
1070
1141
  try { parsedArgs = JSON.parse(pending.inputJson); }
1071
1142
  catch (parseErr) {
1072
1143
  process.stderr.write(`[anthropic-oauth] tool args JSON.parse failed (id=${pending.id}, name=${pending.name}): ${parseErr?.message || parseErr}\n`);
1073
- parsedArgs = {};
1144
+ parsedArgs = makeInvalidToolArgsMarker(pending.inputJson, parseErr instanceof Error ? parseErr.message : String(parseErr));
1074
1145
  }
1075
1146
  }
1076
1147
  // Tool arguments must be a plain object. Anthropic's
@@ -1189,7 +1260,7 @@ async function parseSSEStream(response, signal, abortStream, onStreamDelta, onTo
1189
1260
  */
1190
1261
  export function _classifyMidstreamError(err, state) {
1191
1262
  if (!state) return null;
1192
- if ((state.attemptIndex | 0) >= 1) return null;
1263
+ if ((state.attemptIndex | 0) >= ANTHROPIC_MAX_MIDSTREAM_RETRIES) return null;
1193
1264
  if (state.sawCompleted) return null;
1194
1265
  if (!state.sawMessageStart) return null;
1195
1266
  if (state.userAbort) return null;
@@ -1200,9 +1271,9 @@ export function _classifyMidstreamError(err, state) {
1200
1271
  if (status === 401 || status === 403 || status === 429) return null;
1201
1272
 
1202
1273
  const name = err?.name || '';
1203
- if (name === 'BridgeStallAbortError') return 'bridge_stall';
1274
+ if (name === 'AgentStallAbortError') return 'agent_stall';
1204
1275
  if (name === 'StreamStalledAbortError') return 'stream_stalled';
1205
- if (state.watchdogAbort === 'BridgeStallAbortError') return 'bridge_stall';
1276
+ if (state.watchdogAbort === 'AgentStallAbortError') return 'agent_stall';
1206
1277
  if (state.watchdogAbort === 'StreamStalledAbortError') return 'stream_stalled';
1207
1278
 
1208
1279
  const code = err?.code || err?.cause?.code || '';
@@ -1232,69 +1303,51 @@ function resolveCacheTtls(opts) {
1232
1303
  return fallback;
1233
1304
  };
1234
1305
  // 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)
1306
+ // BP1 baseRules — 1h (shared tool policy + compact skill manifest)
1307
+ // BP2 stableSystem — 1h (role/system rules)
1308
+ // BP3 tier3 — 1h (sessionMarker: stable memory/meta body)
1238
1309
  // BP4 messages — 1h sliding tail (tool_result cache across iter)
1239
1310
  // tools BP is dropped — system BP covers the tools prefix via
1240
1311
  // Anthropic's prompt cache prefix semantics (order: tools → system
1241
1312
  // → messages).
1242
1313
  // 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.
1314
+ // memory/meta tuple and the BP slot is only spent when a 3rd
1315
+ // (cacheTier:'tier3') system block is actually present, so this default is
1316
+ // free for sessions that don't carry one. Previously null here meant any
1317
+ // caller that skipped agent runtime resolve (CLI, raw agent spawn)
1318
+ // silently lost the tier3 cache layer even though it supported one.
1249
1319
  return {
1250
- tools: pick('tools', CACHE_TTL_STABLE),
1320
+ tools: pick('tools', null),
1251
1321
  system: pick('system', CACHE_TTL_STABLE),
1252
1322
  tier3: pick('tier3', CACHE_TTL_STABLE),
1253
1323
  messages: pick('messages', CACHE_TTL_STABLE),
1254
1324
  };
1255
1325
  }
1256
1326
 
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
- }
1327
+ // BP3 (tier3) is injected by session/manager as its own `system` role block —
1328
+ // the 3rd system block, tagged `cacheTier:'tier3'`. buildSystemBlocks applies
1329
+ // the tier3 1h cache_control to that block; BP1/BP2 take the system TTL. No
1330
+ // `<system-reminder>` user message / sentinel scan is involved anymore.
1276
1331
 
1277
1332
  function buildRequestBody(messages, model, tools, sendOpts) {
1278
1333
  const systemMsgs = messages.filter(m => m.role === 'system');
1279
1334
  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
1335
  const maxTokens = resolveMaxTokens(model);
1285
1336
  const opts = sendOpts || {};
1286
1337
  const ttls = resolveCacheTtls(opts);
1287
- const systemBlocks = buildSystemBlocks(systemTexts, model, ttls?.system, 2);
1338
+ // Each system message becomes its own Anthropic content block with its own
1339
+ // breakpoint: BP1 baseRules + BP2 stableSystem at ttls.system, BP3
1340
+ // sessionMarker (cacheTier:'tier3') at ttls.tier3.
1341
+ const systemBlocks = buildSystemBlocks(systemMsgs, model, ttls?.system, ttls?.tier3);
1288
1342
 
1289
1343
  // 4-BP budget layout. tools BP is dropped — system BP covers the
1290
1344
  // 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;
1345
+ // (order: tools → system → messages). That frees slots for the
1346
+ // messages-tail. The system blocks now hold BP1/BP2/BP3 (tier3), so the
1347
+ // tier3 breakpoint is accounted for inside systemBpUsed.
1348
+ const systemBpUsed = systemBlocks.filter(b => b.cache_control).length;
1294
1349
  const toolsBpUsed = 0;
1295
- const tier3Idx = ttls.tier3 ? findTier3Index(chatMsgs) : -1;
1296
- const tier3BpUsed = tier3Idx >= 0 ? 1 : 0;
1297
- const usedSlots = toolsBpUsed + systemBpUsed + tier3BpUsed;
1350
+ const usedSlots = toolsBpUsed + systemBpUsed;
1298
1351
  // Env override for BP strategy. ANTHROPIC_MSG_SLOTS=0 disables message
1299
1352
  // caching entirely. Any value >=1 first marks the previous user text turn
1300
1353
  // so consecutive requests share a breakpoint; a second free slot marks the
@@ -1310,10 +1363,9 @@ function buildRequestBody(messages, model, tools, sendOpts) {
1310
1363
  // marked block. NEVER sanitize again after this (see send path).
1311
1364
  // msgSlots === 0 (ANTHROPIC_MSG_SLOTS=0, or no free slot) → tail disabled.
1312
1365
  const tailTtl = msgSlots > 0 ? ttls.messages : null;
1313
- const tier3Ttl = tier3Idx >= 0 ? ttls.tier3 : null;
1314
1366
  const anthropicMessages = applyAnthropicCacheMarkers(
1315
1367
  toAnthropicMessages(chatMsgs),
1316
- { messageTtl: tailTtl, messageSlots: msgSlots, tier3Ttl },
1368
+ { messageTtl: tailTtl, messageSlots: msgSlots },
1317
1369
  );
1318
1370
 
1319
1371
  const body = {
@@ -1325,20 +1377,24 @@ function buildRequestBody(messages, model, tools, sendOpts) {
1325
1377
 
1326
1378
  if (systemBlocks.length) body.system = systemBlocks;
1327
1379
 
1328
- if (tools?.length) {
1380
+ const nativeTools = nativeAnthropicTools(opts);
1381
+ const deferredTools = deferredAnthropicTools(tools || [], opts);
1382
+ if (tools?.length || nativeTools.length || deferredTools.length) {
1329
1383
  // No cache_control on tools — the systemBase BP already covers the
1330
1384
  // tools prefix via Anthropic's prompt cache prefix semantics (order:
1331
1385
  // tools → system → messages). Placing a separate BP here would waste
1332
1386
  // a slot that's better spent on messages tail.
1333
- body.tools = toAnthropicTools(tools);
1387
+ body.tools = [...nativeTools, ...toAnthropicTools([...(tools || []), ...deferredTools])];
1334
1388
  }
1335
1389
 
1336
1390
  const thinkingBudgetTokens = Number(opts.thinkingBudgetTokens);
1337
1391
  if (Number.isFinite(thinkingBudgetTokens) && thinkingBudgetTokens > 0) {
1338
- body.thinking = { type: 'enabled', budget_tokens: Math.floor(thinkingBudgetTokens) };
1392
+ const budgetTokens = clampThinkingBudgetTokens(thinkingBudgetTokens, maxTokens);
1393
+ if (budgetTokens) body.thinking = { type: 'enabled', budget_tokens: budgetTokens };
1339
1394
  } else if (opts.effort) {
1340
1395
  if (EFFORT_BUDGET[opts.effort]) {
1341
- body.thinking = { type: 'enabled', budget_tokens: EFFORT_BUDGET[opts.effort] };
1396
+ const budgetTokens = clampThinkingBudgetTokens(EFFORT_BUDGET[opts.effort], maxTokens);
1397
+ if (budgetTokens) body.thinking = { type: 'enabled', budget_tokens: budgetTokens };
1342
1398
  } else if (!_LOGGED_UNKNOWN_EFFORT.has(opts.effort)) {
1343
1399
  _LOGGED_UNKNOWN_EFFORT.add(opts.effort);
1344
1400
  try {
@@ -1382,12 +1438,12 @@ export class AnthropicOAuthProvider {
1382
1438
  this.credentials = loadCredentials();
1383
1439
  }
1384
1440
  if (!this.credentials) {
1385
- throw new Error('Anthropic OAuth credentials not found. Run /auth anthropic-oauth or /providers in mixdog to authenticate.');
1441
+ throw new Error('Anthropic OAuth credentials not found. Open /providers in mixdog to sign in.');
1386
1442
  }
1387
1443
 
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.
1444
+ // Pick up Mixdog-updated tokens the moment the credentials file is
1445
+ // rewritten — without this, a fresh /auth login in another process is
1446
+ // ignored until the in-memory token's expiry skew triggers a refresh.
1391
1447
  const diskMtime = _credentialsMaxMtime();
1392
1448
  if (diskMtime > 0 && diskMtime > (this.credentials.mtimeMs || 0)) {
1393
1449
  const fresh = loadCredentials();
@@ -1439,7 +1495,7 @@ export class AnthropicOAuthProvider {
1439
1495
  process.stderr.write(`[anthropic-oauth] WARNING: token expiring but no refresh token; using current token until expiry\n`);
1440
1496
  return latest;
1441
1497
  }
1442
- throw new Error('Anthropic OAuth refresh token not available. Run /auth anthropic-oauth or /providers in mixdog to re-authenticate.');
1498
+ throw new Error('Anthropic OAuth refresh token not available. Open /providers in mixdog to sign in again.');
1443
1499
  }
1444
1500
 
1445
1501
  try {
@@ -1466,6 +1522,11 @@ export class AnthropicOAuthProvider {
1466
1522
  }
1467
1523
 
1468
1524
  async send(messages, model, tools, sendOpts) {
1525
+ // Re-warm the kept-alive socket before the turn. preconnect() is a
1526
+ // best-effort no-op while a socket is still hot (TTL gate), but after an
1527
+ // idle gap longer than the keep-alive window it re-opens one in parallel
1528
+ // with auth/body build so the POST below skips the cold TLS handshake.
1529
+ preconnect('https://api.anthropic.com');
1469
1530
  // Defense-in-depth: enforce tool_use / tool_result pairing before
1470
1531
  // the Anthropic API call. The trim.mjs sanitize pass is normally
1471
1532
  // invoked by the budget trimmer in loop.mjs, but dispatches under
@@ -1502,7 +1563,7 @@ export class AnthropicOAuthProvider {
1502
1563
  // (PROVIDER_HTTP_RESPONSE_TIMEOUT_MS) for a socket that never sends a
1503
1564
  // first byte (truly wedged),
1504
1565
  // (b) externalSignal (client disconnect / replaced-by-newer-request), and
1505
- // (c) the bridge stall watchdog (STALL_ABORT_S, 600s, progress-based) plus
1566
+ // (c) the agent stall watchdog (STALL_ABORT_S, 600s, progress-based) plus
1506
1567
  // the optional SSE idle watchdog for a stream that goes dead mid-flight.
1507
1568
  // totalSignal is therefore a pure pass-through of externalSignal with no timer.
1508
1569
  const totalTimeout = createPassthroughSignal(externalSignal);
@@ -1558,6 +1619,7 @@ export class AnthropicOAuthProvider {
1558
1619
  'anthropic-beta': buildAnthropicBetaHeaders({
1559
1620
  base: OAUTH_BETA_HEADERS,
1560
1621
  fastMode: this.fastModeBetaHeaderLatched,
1622
+ toolSearch: true,
1561
1623
  }),
1562
1624
  'anthropic-dangerous-direct-browser-access': 'true',
1563
1625
  'user-agent': `claude-cli/${resolveCliVersion()} (external, sdk-cli)`,
@@ -1569,7 +1631,7 @@ export class AnthropicOAuthProvider {
1569
1631
  dispatcher: getLlmDispatcher(),
1570
1632
  });
1571
1633
 
1572
- traceBridgeFetch({
1634
+ traceAgentFetch({
1573
1635
  sessionId,
1574
1636
  headersMs: Date.now() - fetchStartedAt,
1575
1637
  httpStatus: response.status,
@@ -1611,6 +1673,12 @@ export class AnthropicOAuthProvider {
1611
1673
  const status = Number(result?.response?.status || 0);
1612
1674
  const transientStatus = classifyError({ httpStatus: status }) === 'transient';
1613
1675
  if (transientStatus || status === 429) {
1676
+ if (status === 429) {
1677
+ const quotaText = await result.response.text().catch(() => '');
1678
+ cleanupCancelHandler(result.cancelHandler);
1679
+ try { result.controller?.abort?.(); } catch {}
1680
+ throw anthropicQuotaError(status, result?.response?.headers, this.scrubTokens(quotaText));
1681
+ }
1614
1682
  const err = new Error(`Anthropic OAuth API ${status}`);
1615
1683
  err.httpStatus = status;
1616
1684
  err.status = status;
@@ -1642,9 +1710,9 @@ export class AnthropicOAuthProvider {
1642
1710
  } catch {}
1643
1711
  },
1644
1712
  });
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;
1713
+ // Bounded mid-stream retries for transient stream loss; jittered backoff
1714
+ // between attempts (see catch branches).
1715
+ const MAX_MIDSTREAM_RETRIES = ANTHROPIC_MAX_MIDSTREAM_RETRIES;
1648
1716
  let firstAttemptError = null;
1649
1717
  let firstAttemptClassifier = null;
1650
1718
 
@@ -1669,6 +1737,10 @@ export class AnthropicOAuthProvider {
1669
1737
  const safeText = this.scrubTokens(text).slice(0, 200);
1670
1738
  process.stderr.write(`[anthropic-oauth] API error ${response.status}: ${safeText}\n`);
1671
1739
 
1740
+ if (response.status === 429) {
1741
+ throw anthropicQuotaError(response.status, response.headers, safeText);
1742
+ }
1743
+
1672
1744
  // Phase I: on unknown/404 model errors, force a catalog refresh and
1673
1745
  // retry once. Protects against a silently-rotated model id.
1674
1746
  const isUnknownModel = response.status === 404
@@ -1717,7 +1789,7 @@ export class AnthropicOAuthProvider {
1717
1789
 
1718
1790
  const ttftMs = midState.ttftAt ? midState.ttftAt - sseStartedAt : null;
1719
1791
  const liveModel = result.model || useModel;
1720
- traceBridgeSse({
1792
+ traceAgentSse({
1721
1793
  sessionId,
1722
1794
  sseParseMs: Date.now() - sseStartedAt,
1723
1795
  ttftMs,
@@ -1726,7 +1798,7 @@ export class AnthropicOAuthProvider {
1726
1798
  transport: 'sse',
1727
1799
  });
1728
1800
 
1729
- traceBridgeUsage({
1801
+ traceAgentUsage({
1730
1802
  sessionId,
1731
1803
  iteration,
1732
1804
  inputTokens: result.usage?.inputTokens || 0,
@@ -1799,6 +1871,7 @@ export class AnthropicOAuthProvider {
1799
1871
  firstAttemptClassifier = 'empty_stream';
1800
1872
  try { controller?.abort?.(err); } catch { /* best-effort teardown */ }
1801
1873
  try { process.stderr.write(`[anthropic-oauth] empty stream (no message_start) — retry ${attemptIndex + 1}/${MAX_MIDSTREAM_RETRIES}\n`); } catch {}
1874
+ await _midstreamSleepWithAbort(midstreamBackoffFor(attemptIndex + 1), totalSignal);
1802
1875
  continue;
1803
1876
  }
1804
1877
  if (classifyError(err) === 'transient'
@@ -1811,6 +1884,7 @@ export class AnthropicOAuthProvider {
1811
1884
  try {
1812
1885
  process.stderr.write(`[anthropic-oauth] transient SSE error — retry ${attemptIndex + 1}/${MAX_MIDSTREAM_RETRIES} (${err?.providerErrorType || err?.message || 'unknown'})\n`);
1813
1886
  } catch {}
1887
+ await _midstreamSleepWithAbort(midstreamBackoffFor(attemptIndex + 1), totalSignal);
1814
1888
  continue;
1815
1889
  }
1816
1890
  // Truncated stream (message_start without message_stop): the
@@ -1832,6 +1906,7 @@ export class AnthropicOAuthProvider {
1832
1906
  firstAttemptClassifier = 'truncated_stream';
1833
1907
  try { controller?.abort?.(err); } catch { /* best-effort teardown */ }
1834
1908
  try { process.stderr.write(`[anthropic-oauth] truncated stream — retry ${attemptIndex + 1}/${MAX_MIDSTREAM_RETRIES}\n`); } catch {}
1909
+ await _midstreamSleepWithAbort(midstreamBackoffFor(attemptIndex + 1), totalSignal);
1835
1910
  continue;
1836
1911
  }
1837
1912
  const classifier = _classifyMidstreamError(err, midState);
@@ -1845,6 +1920,7 @@ export class AnthropicOAuthProvider {
1845
1920
  try {
1846
1921
  process.stderr.write(`[anthropic-oauth] mid-stream recovered: retry ${attemptIndex + 1}/${MAX_MIDSTREAM_RETRIES} (cause: ${classifier})\n`);
1847
1922
  } catch {}
1923
+ await _midstreamSleepWithAbort(midstreamBackoffFor(attemptIndex + 1), totalSignal);
1848
1924
  continue;
1849
1925
  }
1850
1926
  if (attemptIndex > 0 && firstAttemptError) {
@@ -1982,31 +2058,118 @@ function _oauthParseScopeField(scope) {
1982
2058
  return String(scope || '').split(' ').filter(Boolean);
1983
2059
  }
1984
2060
 
1985
- export async function loginOAuth() {
2061
+ function _parseOAuthCodeInput(input) {
2062
+ const value = String(input || '').trim();
2063
+ if (!value) return { code: '', state: '' };
2064
+ try {
2065
+ const url = new URL(value);
2066
+ const code = url.searchParams.get('code') || '';
2067
+ const state = url.searchParams.get('state') || '';
2068
+ if (code || state) return { code, state, redirectUri: `${url.origin}${url.pathname}` };
2069
+ } catch { /* not a URL */ }
2070
+ if (value.includes('#')) {
2071
+ const [code, state] = value.split('#', 2);
2072
+ return { code: String(code || '').trim(), state: String(state || '').trim() };
2073
+ }
2074
+ if (value.includes('code=')) {
2075
+ const params = new URLSearchParams(value.startsWith('?') ? value.slice(1) : value);
2076
+ return { code: params.get('code') || '', state: params.get('state') || '' };
2077
+ }
2078
+ return { code: value, state: '' };
2079
+ }
2080
+
2081
+ async function exchangeAuthorizationCode({ pkce, code, state, redirectUri }) {
2082
+ const cleanCode = String(code || '').trim();
2083
+ if (!cleanCode) throw new Error('[anthropic-oauth] authorization code is required');
2084
+ const tokenRes = await fetch(TOKEN_URL, {
2085
+ method: 'POST',
2086
+ headers: {
2087
+ 'Content-Type': 'application/json',
2088
+ 'anthropic-dangerous-direct-browser-access': 'true',
2089
+ 'user-agent': `claude-cli/${resolveCliVersion()} (external, sdk-cli)`,
2090
+ },
2091
+ body: JSON.stringify({
2092
+ grant_type: 'authorization_code',
2093
+ code: cleanCode,
2094
+ redirect_uri: redirectUri,
2095
+ client_id: CLAUDE_CODE_CLIENT_ID,
2096
+ code_verifier: pkce.verifier,
2097
+ state,
2098
+ }),
2099
+ redirect: 'error',
2100
+ signal: AbortSignal.timeout(OAUTH_TOKEN_TIMEOUT_MS),
2101
+ dispatcher: getLlmDispatcher(),
2102
+ });
2103
+ if (!tokenRes.ok) {
2104
+ const text = await tokenRes.text().catch(() => '');
2105
+ throw new Error(`[anthropic-oauth] token exchange ${tokenRes.status}: ${_scrubTokens(text).slice(0, 500)}`);
2106
+ }
2107
+ const json = await tokenRes.json();
2108
+ const accessToken = json?.access_token || json?.accessToken;
2109
+ const refreshToken = json?.refresh_token || json?.refreshToken;
2110
+ if (!accessToken || !refreshToken) {
2111
+ throw new Error('[anthropic-oauth] token exchange response missing access_token or refresh_token');
2112
+ }
2113
+ const expiresAt = _normalizeExpiresAt(json?.expires_at ?? json?.expiresAt)
2114
+ || (typeof json?.expires_in === 'number' ? Date.now() + json.expires_in * 1000 : 0);
2115
+ const scopes = _oauthParseScopeField(json?.scope);
2116
+ const credPath = _oauthCredentialsWritePath();
2117
+ let raw = {};
2118
+ if (existsSync(credPath)) {
2119
+ raw = JSON.parse(readFileSync(credPath, 'utf-8'));
2120
+ }
2121
+ const existingOauth = raw.claudeAiOauth || {};
2122
+ raw.claudeAiOauth = {
2123
+ ...existingOauth,
2124
+ accessToken,
2125
+ refreshToken,
2126
+ expiresAt,
2127
+ scopes,
2128
+ subscriptionType: existingOauth.subscriptionType ?? null,
2129
+ };
2130
+ _saveCredentialsFile(credPath, raw);
2131
+ return {
2132
+ path: credPath,
2133
+ accessToken,
2134
+ refreshToken,
2135
+ expiresAt,
2136
+ scopes,
2137
+ subscriptionType: raw.claudeAiOauth.subscriptionType,
2138
+ };
2139
+ }
2140
+
2141
+ export async function beginOAuthLogin() {
1986
2142
  const pkce = _oauthGeneratePKCE();
1987
2143
  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) => {
2144
+ const buildUrl = (redirectUri) => {
2145
+ const url = new URL(CLAUDE_AI_AUTHORIZE_URL);
2146
+ url.searchParams.set('code', 'true');
2147
+ url.searchParams.set('client_id', CLAUDE_CODE_CLIENT_ID);
2148
+ url.searchParams.set('response_type', 'code');
2149
+ url.searchParams.set('redirect_uri', redirectUri);
2150
+ url.searchParams.set('scope', OAUTH_LOGIN_SCOPE);
2151
+ url.searchParams.set('code_challenge', pkce.challenge);
2152
+ url.searchParams.set('code_challenge_method', 'S256');
2153
+ url.searchParams.set('state', state);
2154
+ return url;
2155
+ };
2156
+ const url = buildUrl(OAUTH_REDIRECT_URI);
2157
+ const manualUrl = buildUrl(OAUTH_MANUAL_REDIRECT_URI);
2158
+
2159
+ let server = null;
2160
+ let timeout = null;
2161
+ let finish = null;
2162
+ const waitForCallback = new Promise((resolve, reject) => {
1999
2163
  let settled = false;
2000
- let timeout = null;
2001
- const settle = (value, error = null) => {
2164
+ finish = (value, error = null) => {
2002
2165
  if (settled) return;
2003
2166
  settled = true;
2004
2167
  if (timeout) clearTimeout(timeout);
2005
- try { server.close(); } catch { /* already closed */ }
2168
+ try { server?.close(); } catch { /* already closed */ }
2006
2169
  if (error) reject(error);
2007
2170
  else resolve(value);
2008
2171
  };
2009
- const server = createServer(async (req, res) => {
2172
+ server = createServer(async (req, res) => {
2010
2173
  const u = new URL(req.url || '/', `http://${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}`);
2011
2174
  if (u.pathname !== OAUTH_CALLBACK_PATH) {
2012
2175
  res.writeHead(404);
@@ -2017,76 +2180,24 @@ export async function loginOAuth() {
2017
2180
  if (!code || u.searchParams.get('state') !== state) {
2018
2181
  res.writeHead(400);
2019
2182
  res.end('Invalid');
2020
- settle(null);
2183
+ finish(null);
2021
2184
  return;
2022
2185
  }
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
2186
  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
- });
2187
+ const tokens = await exchangeAuthorizationCode({ pkce, code, state, redirectUri: OAUTH_REDIRECT_URI });
2188
+ res.writeHead(302, { Location: OAUTH_SUCCESS_REDIRECT_URL });
2189
+ res.end();
2190
+ finish(tokens);
2083
2191
  } catch (err) {
2084
- settle(null, err instanceof Error ? err : new Error(String(err)));
2192
+ const error = err instanceof Error ? err : new Error(String(err));
2193
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
2194
+ res.end(`Claude login failed: ${error.message}`);
2195
+ finish(null, error);
2085
2196
  }
2086
2197
  });
2087
- timeout = setTimeout(() => settle(null), OAUTH_LOGIN_TIMEOUT_MS);
2198
+ timeout = setTimeout(() => finish(null), OAUTH_LOGIN_TIMEOUT_MS);
2088
2199
  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`);
2200
+ 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
2201
  try {
2091
2202
  const { openInBrowser } = await import('../../../shared/open-url.mjs');
2092
2203
  openInBrowser(url.toString());
@@ -2094,8 +2205,31 @@ export async function loginOAuth() {
2094
2205
  process.stderr.write(`[anthropic-oauth] browser open failed: ${String(err?.message || err).slice(0, 200)}\n`);
2095
2206
  }
2096
2207
  });
2097
- server.on('error', (err) => settle(null, new Error(`[anthropic-oauth] callback server failed on ${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}: ${err?.message || err}`)));
2208
+ server.on('error', (err) => finish(null, new Error(`[anthropic-oauth] callback server failed on ${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}: ${err?.message || err}`)));
2098
2209
  });
2210
+
2211
+ return {
2212
+ provider: 'anthropic-oauth',
2213
+ url: url.toString(),
2214
+ manualUrl: manualUrl.toString(),
2215
+ waitForCallback,
2216
+ completeCode: async (input) => {
2217
+ const parsed = _parseOAuthCodeInput(input);
2218
+ if (parsed.state && parsed.state !== state) throw new Error('[anthropic-oauth] OAuth state mismatch');
2219
+ const redirectUri = parsed.redirectUri || (parsed.state ? OAUTH_MANUAL_REDIRECT_URI : OAUTH_REDIRECT_URI);
2220
+ const tokens = await exchangeAuthorizationCode({ pkce, code: parsed.code, state, redirectUri });
2221
+ finish?.(tokens);
2222
+ return tokens;
2223
+ },
2224
+ cancel: () => {
2225
+ finish?.(null);
2226
+ },
2227
+ };
2228
+ }
2229
+
2230
+ export async function loginOAuth() {
2231
+ const login = await beginOAuthLogin();
2232
+ return await login.waitForCallback;
2099
2233
  }
2100
2234
 
2101
2235
  // Additive exports for test harnesses.