mixdog 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/package.json +1 -1
  2. package/scripts/gate-local.mjs +19 -3
  3. package/scripts/model-catalog-audit.mjs +1 -15
  4. package/src/agents/reviewer/AGENT.md +3 -3
  5. package/src/agents/reviewer/agent.json +1 -1
  6. package/src/defaults/shipped-history.json +1 -0
  7. package/src/repl/slash-commands.mjs +16 -1
  8. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +29 -4
  9. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +10 -0
  10. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +36 -7
  11. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +49 -10
  12. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +40 -3
  13. package/src/runtime/agent/orchestrator/mcp/client.mjs +230 -96
  14. package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +103 -0
  15. package/src/runtime/agent/orchestrator/mcp/oauth-credentials.mjs +150 -0
  16. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +111 -0
  17. package/src/runtime/agent/orchestrator/mcp/oauth-probe.mjs +87 -0
  18. package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +95 -0
  19. package/src/runtime/agent/orchestrator/mcp/oauth-request.mjs +122 -0
  20. package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +9 -0
  21. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +229 -0
  22. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +188 -29
  23. package/src/runtime/agent/orchestrator/providers/anthropic-api-recovery.mjs +2 -1
  24. package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +94 -31
  25. package/src/runtime/agent/orchestrator/providers/anthropic-error-contract.mjs +96 -0
  26. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +39 -18
  27. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens-recovery.mjs +55 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +25 -21
  29. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +3 -1
  30. package/src/runtime/agent/orchestrator/providers/anthropic-midstream-recovery.mjs +18 -4
  31. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +13 -1
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-midstream.mjs +10 -1
  34. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-recovery.mjs +23 -9
  35. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +46 -20
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +14 -9
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +73 -35
  38. package/src/runtime/agent/orchestrator/providers/anthropic-overload-fallback.mjs +69 -0
  39. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +4 -0
  40. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -1
  41. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -7
  42. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +30 -9
  43. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  44. package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +30 -14
  45. package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +2 -1
  46. package/src/runtime/agent/orchestrator/providers/antigravity-retry-policy.mjs +93 -0
  47. package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +82 -9
  48. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +3 -0
  49. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +38 -5
  50. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  51. package/src/runtime/agent/orchestrator/providers/cursor-wire-guards.mjs +97 -2
  52. package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-bridge.mjs +102 -23
  53. package/src/runtime/agent/orchestrator/providers/cursor-wire-transport.mjs +103 -33
  54. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +29 -6
  55. package/src/runtime/agent/orchestrator/providers/cursor.mjs +18 -1
  56. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +77 -17
  57. package/src/runtime/agent/orchestrator/providers/gemini-cache-policy.mjs +1 -1
  58. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +29 -8
  59. package/src/runtime/agent/orchestrator/providers/gemini-error-contract.mjs +191 -0
  60. package/src/runtime/agent/orchestrator/providers/gemini-family.mjs +9 -0
  61. package/src/runtime/agent/orchestrator/providers/gemini-history-signatures.mjs +9 -4
  62. package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +3 -1
  63. package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +180 -0
  64. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +21 -82
  65. package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +4 -1
  66. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -12
  67. package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +21 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +56 -23
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
  70. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +68 -19
  71. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +3 -0
  72. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +2 -1
  73. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +4 -9
  74. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +4 -2
  75. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-result.mjs +22 -0
  76. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +1 -0
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-error-contract.mjs +50 -0
  78. package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +23 -0
  79. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +16 -8
  80. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-state.mjs +3 -2
  81. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +94 -13
  82. package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +193 -0
  83. package/src/runtime/agent/orchestrator/providers/openai-compat-stream-common.mjs +66 -2
  84. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +2 -1
  85. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +6 -0
  86. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +4 -2
  87. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -14
  88. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +17 -3
  89. package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +42 -25
  90. package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +274 -0
  91. package/src/runtime/agent/orchestrator/providers/openai-http-sse-request.mjs +72 -32
  92. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/terminal-frames.mjs +18 -11
  93. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-outcome.mjs +1 -2
  94. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-stamps.mjs +2 -2
  95. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-state.mjs +2 -3
  96. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state.mjs +2 -2
  97. package/src/runtime/agent/orchestrator/providers/openai-http-sse-watchdogs.mjs +1 -3
  98. package/src/runtime/agent/orchestrator/providers/openai-oauth-dispatch.mjs +34 -3
  99. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +5 -1
  100. package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +22 -2
  101. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +5 -0
  102. package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +5 -9
  103. package/src/runtime/agent/orchestrator/providers/openai-retry-policy.mjs +9 -0
  104. package/src/runtime/agent/orchestrator/providers/openai-ws-handshake.mjs +16 -8
  105. package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/attempt-context.mjs +1 -2
  106. package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/policy.mjs +25 -14
  107. package/src/runtime/agent/orchestrator/providers/openai-ws-terminal.mjs +18 -11
  108. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +31 -20
  109. package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +581 -0
  110. package/src/runtime/agent/orchestrator/providers/retry-backoff.mjs +46 -7
  111. package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +118 -219
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +2 -0
  113. package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +148 -45
  114. package/src/runtime/agent/orchestrator/providers/retry-midstream.mjs +81 -16
  115. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +3 -0
  116. package/src/runtime/agent/orchestrator/runtime-core/goal-reminder.mjs +0 -18
  117. package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +57 -28
  118. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +19 -16
  119. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +1 -97
  120. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +3 -1
  121. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +4 -0
  122. package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +102 -18
  123. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +3 -1
  124. package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +30 -2
  125. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +11 -2
  126. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +41 -21
  127. package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +5 -10
  128. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +18 -7
  130. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +155 -29
  131. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +6 -3
  132. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +16 -0
  133. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +22 -10
  134. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +5 -0
  136. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +3 -0
  137. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +20 -1
  138. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +2 -2
  139. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/deadline-plan.mjs +12 -15
  140. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  141. package/src/runtime/memory/lib/pg/process.mjs +34 -24
  142. package/src/runtime/memory/lib/pg/supervisor.mjs +16 -13
  143. package/src/runtime/office/README.md +1 -1
  144. package/src/runtime/office/shared/values.mjs +0 -5
  145. package/src/runtime/shared/background-tasks.mjs +92 -14
  146. package/src/runtime/shared/err-text.mjs +25 -0
  147. package/src/runtime/shared/error-presentation.mjs +159 -19
  148. package/src/runtime/shared/llm/anthropic-betas.mjs +4 -3
  149. package/src/runtime/shared/llm/anthropic-effort.mjs +123 -134
  150. package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +50 -14
  151. package/src/runtime/shared/llm/model-catalog.mjs +53 -40
  152. package/src/runtime/shared/llm/openai-daybreak.mjs +4 -16
  153. package/src/runtime/shared/llm/provider-catalog-registry.mjs +11 -0
  154. package/src/runtime/shared/llm/provider-model-identities.mjs +13 -4
  155. package/src/runtime/shared/llm/usage-rollup.mjs +0 -1
  156. package/src/runtime/shared/model-display.mjs +13 -0
  157. package/src/runtime/shared/quota-resume-store.mjs +11 -0
  158. package/src/runtime/shared/session-baseline-store.mjs +960 -0
  159. package/src/runtime/shared/statusline/statusline-format.mjs +4 -4
  160. package/src/runtime/shared/task-notification-envelope.mjs +2 -2
  161. package/src/runtime/shared/turn-changes.mjs +447 -219
  162. package/src/runtime/shared/turn-snapshot-store.mjs +182 -1
  163. package/src/runtime/shared/turn-snapshot.mjs +170 -48
  164. package/src/runtime/shared/turn-worktree-snapshot.mjs +135 -46
  165. package/src/runtime/shared/worktrees/contract.mjs +0 -24
  166. package/src/runtime/shared/worktrees/test-support.mjs +19 -4
  167. package/src/session-runtime/boot/apis.mjs +6 -0
  168. package/src/session-runtime/boot/infrastructure.mjs +6 -0
  169. package/src/session-runtime/lifecycle/session-catalog.mjs +2 -0
  170. package/src/session-runtime/lifecycle/teardown.mjs +1 -1
  171. package/src/session-runtime/mcp-glue/connect.mjs +29 -9
  172. package/src/session-runtime/mcp-glue/server-catalog.mjs +20 -3
  173. package/src/session-runtime/mcp-glue.mjs +3 -0
  174. package/src/session-runtime/mcp-oauth-login.mjs +174 -0
  175. package/src/session-runtime/model-route/model-switch-target.mjs +34 -0
  176. package/src/session-runtime/model-route/set-route.mjs +83 -15
  177. package/src/session-runtime/model-route/web-search-route.mjs +1 -1
  178. package/src/session-runtime/native-web-search/routes.mjs +22 -3
  179. package/src/session-runtime/provider-models/model-meta.mjs +12 -3
  180. package/src/session-runtime/quick-model-rows/web-search-rows.mjs +13 -8
  181. package/src/session-runtime/quick-web-search-models.mjs +13 -38
  182. package/src/session-runtime/resource-extension-sync.mjs +4 -0
  183. package/src/session-runtime/resource-mcp-api.mjs +98 -2
  184. package/src/session-runtime/runtime-review-api.mjs +13 -0
  185. package/src/session-runtime/services/agent-tool/job-task-reconcile.mjs +13 -1
  186. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +7 -2
  187. package/src/session-runtime/services/agent-tool/send-flow.mjs +22 -4
  188. package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +2 -0
  189. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +22 -5
  190. package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -1
  191. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +2 -2
  192. package/src/session-runtime/services/agent-tool/worker-rows.mjs +17 -0
  193. package/src/session-runtime/settings-compaction-api.mjs +1 -0
  194. package/src/standalone/daemon-drain-policy.mjs +8 -0
  195. package/src/standalone/daemon-telemetry.mjs +55 -0
  196. package/src/standalone/daemon.mjs +3 -7
  197. package/src/standalone/session-protocol.mjs +7 -0
  198. package/src/standalone/session-service/agent-tree/agent-cancel.mjs +33 -2
  199. package/src/standalone/session-service/agent-tree/agent-turns.mjs +26 -10
  200. package/src/standalone/session-service/agent-tree.mjs +12 -0
  201. package/src/standalone/session-service/quota-resume.mjs +8 -0
  202. package/src/standalone/session-service/retention/busy.mjs +15 -1
  203. package/src/standalone/session-service-api.mjs +5 -0
  204. package/src/standalone/session-service.mjs +1 -0
  205. package/src/tui/app/app-view.jsx +1 -0
  206. package/src/tui/app/create-app-pickers.mjs +4 -0
  207. package/src/tui/app/extension-pickers/mcp-login.mjs +107 -0
  208. package/src/tui/app/extension-pickers/mcp-servers-picker.mjs +2 -1
  209. package/src/tui/app/extension-pickers.mjs +2 -1
  210. package/src/tui/app/model-options.mjs +11 -16
  211. package/src/tui/app/slash-commands.mjs +7 -2
  212. package/src/tui/app/slash-dispatch/panel-commands.mjs +16 -1
  213. package/src/tui/components/StatusLine.jsx +2 -0
  214. package/src/tui/components/status-line/local-line.mjs +5 -2
  215. package/src/tui/components/status-line/use-statusline-text.mjs +7 -1
  216. package/src/tui/dist/index.mjs +816 -43
  217. package/src/tui/session/quota-resume.mjs +46 -14
  218. package/src/tui/session/session-api/commands.mjs +33 -24
  219. package/src/tui/session/session-api/extensions.mjs +22 -0
  220. package/src/tui/session/session-api/integrations.mjs +7 -0
  221. package/src/tui/session/session-api/lifecycle.mjs +5 -1
  222. package/src/tui/session/session-api/routes.mjs +71 -7
  223. package/src/tui/session/session-api/settings.mjs +5 -14
  224. package/src/tui/session/session-flow/drain.mjs +7 -1
  225. package/src/tui/session/session-flow/failed-turn-rewind.mjs +10 -4
  226. package/src/tui/session/turn.mjs +38 -15
  227. package/src/tui/statusline-ansi-bridge.mjs +1 -1
  228. package/src/ui/statusline-agents.mjs +15 -4
  229. package/src/ui/statusline.mjs +7 -3
  230. package/src/ui/streaming-markdown-heal.mjs +34 -3
  231. package/src/vendor/statusline/src/gateway/claude-current.mjs +14 -4
  232. package/src/runtime/memory/lib/memory-session-merge.mjs +0 -32
  233. package/src/runtime/shared/llm/usage-session-history.mjs +0 -266
@@ -0,0 +1,229 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import keychain from '../../../../lib/keychain-cjs.cjs';
4
+ import { openInBrowser } from '../../../shared/open-url.mjs';
5
+ import { normalizeMcpTransportUrl } from './client-config.mjs';
6
+ import {
7
+ MCP_OAUTH_CALLBACK_MAX_BYTES,
8
+ MCP_OAUTH_CALLBACK_NOT_PENDING,
9
+ parsePastedMcpOAuthCallback,
10
+ } from './oauth-callback.mjs';
11
+ import {
12
+ TOKENS_FLAG,
13
+ createCredentialCache,
14
+ credentialFile,
15
+ readStoredCredentials,
16
+ withCredentialLock,
17
+ writeRevisionMarker,
18
+ } from './oauth-credentials.mjs';
19
+ import { createLoginFlow } from './oauth-login.mjs';
20
+ import { createOAuthProvider } from './oauth-provider.mjs';
21
+ import { createAuthorizedFetch } from './oauth-request.mjs';
22
+ import { REAUTHENTICATION_REQUIRED_MESSAGE, anySignal } from './oauth-shared.mjs';
23
+
24
+ export { MCP_OAUTH_CALLBACK_MAX_BYTES, MCP_OAUTH_CALLBACK_NOT_PENDING, parsePastedMcpOAuthCallback };
25
+
26
+ export const MCP_OAUTH_TIMEOUT_MS = 5 * 60_000;
27
+
28
+ const scopes = new WeakMap();
29
+
30
+ /** Whether a connect failed only because the server needs an (explicit) OAuth
31
+ * sign-in. Transports may wrap the error (SSE re-raises it as an event
32
+ * message), so the message and cause chain count too. */
33
+ export function isMcpOAuthReauthenticationRequired(error) {
34
+ for (let current = error, depth = 0; current && depth < 5; current = current.cause, depth++) {
35
+ if (current.code === 'reauthenticationRequired') return true;
36
+ if (String(current.message ?? current).includes(REAUTHENTICATION_REQUIRED_MESSAGE)) return true;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ // One keychain entry per exact server URL. Windows stores each account as a
42
+ // file name, so the account is a hash of the URL and nothing else.
43
+ export function mcpOAuthAccount(url) {
44
+ return `mcp-oauth.${createHash('sha256').update(url).digest('hex')}`;
45
+ }
46
+
47
+ /** Whether OAuth tokens are stored for one server URL, read from the
48
+ * non-secret revision marker without touching the keychain. */
49
+ export function hasStoredMcpOAuthTokens(url) {
50
+ try {
51
+ const marker = readFileSync(credentialFile(mcpOAuthAccount(normalizeMcpTransportUrl(url)), 'rev'), 'utf8');
52
+ return marker.endsWith(TOKENS_FLAG);
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ /** Log out of one MCP server URL: delete its stored tokens and client
59
+ * registration and drop them from live connections, which then need an
60
+ * explicit reconnect to log in again. */
61
+ export async function clearMcpOAuth({ url, credentialStore = keychain }) {
62
+ url = normalizeMcpTransportUrl(url);
63
+ const account = mcpOAuthAccount(url);
64
+ await withCredentialLock(account, async () => {
65
+ await writeRevisionMarker(account, null);
66
+ if ((await readStoredCredentials(credentialStore, account)) != null)
67
+ await credentialStore.deleteSecretAsync(account);
68
+ });
69
+ scopes.get(credentialStore)?.get(url)?.oauth.clear();
70
+ }
71
+
72
+ // The keychain is the only durable store. Exact URLs, including query strings,
73
+ // partition clients and tokens even when servers share an authorization issuer.
74
+ export function createMcpOAuth({
75
+ url,
76
+ credentialStore = keychain,
77
+ interactive = false,
78
+ isTransportEndpoint = () => false,
79
+ serverName,
80
+ ...options
81
+ }) {
82
+ url = normalizeMcpTransportUrl(url);
83
+ let entries = scopes.get(credentialStore);
84
+ if (!entries) {
85
+ entries = new Map();
86
+ scopes.set(credentialStore, entries);
87
+ }
88
+ let entry = entries.get(url);
89
+ if (!entry) {
90
+ entry = { oauth: createSharedOAuth({ url, credentialStore, ...options }), users: 0 };
91
+ entries.set(url, entry);
92
+ }
93
+ entry.users++;
94
+ const lifetime = new AbortController();
95
+ let onStart = () => {};
96
+ let loginAllowed = interactive === true;
97
+ let loginStarted = false;
98
+ let loginCompleted = false;
99
+ return {
100
+ provider: entry.oauth.provider,
101
+ signal: lifetime.signal,
102
+ set onStart(value) {
103
+ onStart = value;
104
+ },
105
+ /** Whether this connection was opened by an explicit sign-in. */
106
+ get interactive() {
107
+ return interactive === true;
108
+ },
109
+ /** Whether this connection started an interactive (browser) login. */
110
+ get loginStarted() {
111
+ return loginStarted;
112
+ },
113
+ /** Whether that login obtained tokens (the sign-in itself completed). */
114
+ get loginCompleted() {
115
+ return loginCompleted;
116
+ },
117
+ finishConnect() {
118
+ loginAllowed = false;
119
+ },
120
+ /** Every OAuth token this connection held, for scrubbing errors and results. */
121
+ secrets: () => entry.oauth.secrets(),
122
+ /** Answer the browser login this connection started with the redirect URL
123
+ * pasted from the browser, exactly as the loopback callback would. */
124
+ completeCallback(pasted) {
125
+ if (!loginStarted || loginCompleted || lifetime.signal.aborted) throw new Error(MCP_OAUTH_CALLBACK_NOT_PENDING);
126
+ entry.oauth.completeCallback(pasted);
127
+ },
128
+ async fetch(input, init = {}) {
129
+ const signal = anySignal(lifetime.signal, init.signal);
130
+ let startedHere = false;
131
+ return entry.oauth.fetch(
132
+ input,
133
+ { ...init, signal },
134
+ () => {
135
+ if (!loginAllowed) return false;
136
+ loginAllowed = false;
137
+ loginStarted = startedHere = true;
138
+ onStart();
139
+ return true;
140
+ },
141
+ options.openBrowser,
142
+ isTransportEndpoint,
143
+ serverName,
144
+ // The login this request started stored tokens (a cancel, denial,
145
+ // timeout or failed exchange never gets here); the retry after it may
146
+ // still fail on its own.
147
+ () => {
148
+ if (startedHere) loginCompleted = true;
149
+ }
150
+ );
151
+ },
152
+ close() {
153
+ if (lifetime.signal.aborted) return;
154
+ lifetime.abort();
155
+ if (--entry.users === 0) {
156
+ entry.oauth.close();
157
+ entries.delete(url);
158
+ }
159
+ },
160
+ };
161
+ }
162
+
163
+ function createSharedOAuth({
164
+ url,
165
+ credentialStore,
166
+ openBrowser = openInBrowser,
167
+ fetchImpl = fetch,
168
+ timeoutMs = MCP_OAUTH_TIMEOUT_MS,
169
+ }) {
170
+ const lifetime = new AbortController();
171
+ const cache = createCredentialCache({ url, credentialStore, account: mcpOAuthAccount(url) });
172
+ const { creds } = cache;
173
+ const flow = {
174
+ listener: undefined,
175
+ state: undefined,
176
+ verifier: undefined,
177
+ allowBrowser: false,
178
+ browserOpened: false,
179
+ launchBrowser: openBrowser,
180
+ finishCallback: undefined,
181
+ };
182
+ const provider = createOAuthProvider({ cache, flow, lifetimeSignal: lifetime.signal });
183
+ const { login, stopListening, closeListener, answerCallback } = createLoginFlow({
184
+ url,
185
+ cache,
186
+ flow,
187
+ provider,
188
+ lifetimeSignal: lifetime.signal,
189
+ openBrowser,
190
+ fetchImpl,
191
+ timeoutMs,
192
+ });
193
+ const requests = createAuthorizedFetch({
194
+ url,
195
+ cache,
196
+ login,
197
+ fetchImpl,
198
+ lifetimeSignal: lifetime.signal,
199
+ });
200
+ return {
201
+ provider,
202
+ signal: lifetime.signal,
203
+ fetch: requests.fetch,
204
+ secrets: () => [...cache.held],
205
+ /** Answer the pending browser login with a pasted redirect URL: the same
206
+ * state check, PKCE exchange and persistence as the loopback callback,
207
+ * whose listener closes. An invalid paste leaves the login pending. */
208
+ completeCallback(pasted) {
209
+ if (!flow.finishCallback || !flow.browserOpened || !flow.state || !creds.redirectUrl) {
210
+ throw new Error(MCP_OAUTH_CALLBACK_NOT_PENDING);
211
+ }
212
+ const { code } = parsePastedMcpOAuthCallback(pasted, { redirectUrl: creds.redirectUrl, state: flow.state });
213
+ stopListening();
214
+ // A denial (no code) fails the login without echoing its description.
215
+ answerCallback(code);
216
+ },
217
+ /** Forget in-memory credentials (after a log-out) and cancel a login in
218
+ * progress; the next request re-reads the keychain. */
219
+ clear() {
220
+ requests.cancelLogin();
221
+ cache.clear();
222
+ },
223
+ close() {
224
+ lifetime.abort();
225
+ flow.finishCallback?.();
226
+ closeListener();
227
+ },
228
+ };
229
+ }
@@ -11,17 +11,60 @@ import {
11
11
  providerAccountsResetAt,
12
12
  } from '../../../shared/provider-accounts.mjs';
13
13
  import { withProviderAccount, hasExplicitProviderAuthBinding } from '../../../shared/provider-auth-binding.mjs';
14
- import { retryAfterMsFromError, isExplicitUserAbortError, typedErrorCode } from './retry-classifier.mjs';
14
+ import { isExplicitUserAbortError, markProviderRecoveryExhausted } from './retry-classifier.mjs';
15
+ import { sleepWithAbort } from './retry-backoff.mjs';
16
+ import { QUOTA_KINDS, providerErrorKind } from './provider-error-kind.mjs';
15
17
  import { fetchOAuthUsageSnapshot, readCachedOAuthUsageSnapshot } from './oauth-usage.mjs';
16
18
 
17
- // Only an explicit quota refusal advances the roster; a generic 429, auth
18
- // refusal, policy denial or transport failure retains its existing recovery.
19
+ // Only an explicit quota / plan / billing refusal advances the roster as
20
+ // quota; a generic 429, overload, auth refusal, policy denial or transport
21
+ // failure retains its existing recovery.
19
22
  export function isAccountQuotaError(error) {
20
- const status = Number(error?.status || error?.httpStatus || error?.response?.status || 0);
21
- if (status === 401 || status === 403 || isExplicitUserAbortError(error)) return false;
22
- return ['insufficient_quota', 'quota_exceeded', 'usage_limit_reached', 'usage_not_included'].includes(
23
- String(typedErrorCode(error) || error?.error?.type || '').toLowerCase()
24
- );
23
+ if (isExplicitUserAbortError(error)) return false;
24
+ const { kind, status } = providerErrorKind(error);
25
+ if (status === 401 || status === 403) return false;
26
+ return QUOTA_KINDS.has(kind);
27
+ }
28
+
29
+ // Kinds that say nothing about the account: the server (or the network) is
30
+ // temporarily unable to serve (or has no capacity in the requested service
31
+ // tier). They never block or rotate an account.
32
+ const SERVER_SIDE_KINDS = new Set(['overloaded', 'transient', 'service_tier_unavailable']);
33
+
34
+ // Provider-supplied roster cooldowns (`accountCooldownPolicy` on the provider
35
+ // object), used when the server named no delay:
36
+ // { floorMs, rateLimitedMs, quotaStepsMs, unknownMs, rotateOnRateLimit,
37
+ // waitForCooldownMs, blockedMs }
38
+ // blockedMs: cooldown for an `accountBlocked` refusal, which then rotates.
39
+ // quotaStepsMs escalates per consecutive quota refusal of one account (reset
40
+ // by a served answer). Absent → the inferred five minutes.
41
+ // rotateOnRateLimit rotates on a plain 429 rate limit; its advice, when
42
+ // present, becomes that account's cooldown.
43
+ // waitForCooldownMs: when every eligible account is only cooling down (no
44
+ // quota evidence), the send waits — cancellable, reported as reconnect
45
+ // progress — for the earliest cooldown while the send's total wait stays
46
+ // within this bound; beyond it the cooldown error surfaces. Absent → no wait.
47
+ const INFERRED_COOLDOWN_MS = 5 * 60_000;
48
+ const quotaStrikes = new Map();
49
+
50
+ function cooldownPolicyOf(instance) {
51
+ const policy = instance?.accountCooldownPolicy;
52
+ return policy && typeof policy === 'object' ? policy : null;
53
+ }
54
+
55
+ function finiteMs(value, fallback) {
56
+ const n = Number(value);
57
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
58
+ }
59
+
60
+ function policyCooldownMs(policy, strikeKey, reason) {
61
+ if (reason === 'rate_limited') return finiteMs(policy.rateLimitedMs, INFERRED_COOLDOWN_MS);
62
+ if (reason === 'quota' && Array.isArray(policy.quotaStepsMs) && policy.quotaStepsMs.length) {
63
+ const strikes = quotaStrikes.get(strikeKey) || 0;
64
+ quotaStrikes.set(strikeKey, strikes + 1);
65
+ return finiteMs(policy.quotaStepsMs[Math.min(strikes, policy.quotaStepsMs.length - 1)], INFERRED_COOLDOWN_MS);
66
+ }
67
+ return finiteMs(policy.unknownMs, INFERRED_COOLDOWN_MS);
25
68
  }
26
69
 
27
70
  // Burst throttles reopen within seconds. A 429 whose Retry-After points
@@ -77,6 +120,23 @@ function accountsExhaustedError(useCredits) {
77
120
  return error;
78
121
  }
79
122
 
123
+ // Every eligible account is only cooling down after a rate limit (no quota
124
+ // evidence): a rate limit, advising the earliest cooldown end as the wait.
125
+ // The pool already spent its cooldown wait, so this is a terminal verdict for
126
+ // the loop, not an unclassified failure.
127
+ function earliestCooldownMs(rows, now) {
128
+ const ends = rows.map((row) => Number(row.blockedUntil)).filter((until) => Number.isFinite(until) && until > now);
129
+ return ends.length ? Math.min(...ends) - now : null;
130
+ }
131
+
132
+ function accountsCoolingDownError(rows, now) {
133
+ const error = new Error('Every connected account is cooling down after a rate limit. Try again shortly.');
134
+ error.code = 'rate_limited';
135
+ const wait = earliestCooldownMs(rows, now);
136
+ if (wait != null) error.retryAfterMs = wait;
137
+ return markProviderRecoveryExhausted(error, { owner: 'account-pool', terminal: true });
138
+ }
139
+
80
140
  // Structured proof that the send ended because no account can serve `model`
81
141
  // (not merely because a generic 429 or a stopped loop surfaced an error).
82
142
  function quotaExhausted(error, providerName, model, rows, options = {}) {
@@ -200,15 +260,53 @@ function recordServingAccount(providerName, result, id, bindingId) {
200
260
  // retry delay. Anthropic and other subscriptions may use the same 429 code
201
261
  // for burst throttling and exhausted subscription windows, so the native
202
262
  // usage endpoint decides rather than a human-readable message.
263
+ // `until` is when the block lifts: the real quota reset when the provider
264
+ // named one, else the advised delay, else an inferred five minutes. A long
265
+ // Retry-After without quota evidence is a rate-limit cooldown: it rotates
266
+ // this send, blocks no longer than advised and is never quota evidence.
203
267
  async function accountQuotaExhausted(providerName, row, model, error, account) {
204
- const status = Number(error?.status || error?.httpStatus || error?.response?.status || 0);
205
- const delay = retryAfterMsFromError(error);
268
+ const { kind, status, retryAfterMs: delay, resetAt } = providerErrorKind(error);
269
+ const now = Date.now();
270
+ const policy = cooldownPolicyOf(account(row.id));
271
+ const floorMs = policy ? finiteMs(policy.floorMs, 0) : 0;
272
+ const blockUntil = (withReset, reason = 'unknown') => {
273
+ if (withReset && resetAt > now) return { until: resetAt, source: 'retry-after' };
274
+ if (delay > 0) return { until: now + Math.max(delay, floorMs), source: 'retry-after' };
275
+ if (policy) {
276
+ const ms = policyCooldownMs(policy, `${providerName}:${row.id}`, reason);
277
+ return { until: now + Math.max(ms, floorMs), source: 'inferred' };
278
+ }
279
+ return { until: now + INFERRED_COOLDOWN_MS, source: 'inferred' };
280
+ };
281
+ if (SERVER_SIDE_KINDS.has(kind) || isExplicitUserAbortError(error)) {
282
+ return { exhausted: false, delay, evidence: false, ...blockUntil(false) };
283
+ }
284
+ // An account-level block (`accountBlocked`, e.g. the account needs
285
+ // verification): with a policy `blockedMs` that account cools for it and the
286
+ // roster rotates; never quota evidence. Without one it surfaces as before.
287
+ const blockedMs = policy ? finiteMs(policy.blockedMs, 0) : 0;
288
+ if (error?.accountBlocked === true && blockedMs > 0) {
289
+ return {
290
+ exhausted: true,
291
+ accountBlocked: true,
292
+ delay,
293
+ evidence: false,
294
+ until: now + Math.max(blockedMs, floorMs),
295
+ source: 'inferred',
296
+ };
297
+ }
206
298
  const typed = isAccountQuotaError(error);
207
- let exhausted = typed || (status === 429 && delay > QUOTA_WINDOW_RETRY_AFTER_MS);
299
+ if (typed) return { exhausted: true, delay, evidence: true, ...blockUntil(true, 'quota') };
300
+ // A policy with rotateOnRateLimit rotates on a plain rate limit too (the
301
+ // advice as its cooldown, else rateLimitedMs); never quota evidence.
302
+ if (policy?.rotateOnRateLimit === true && kind === 'rate_limited' && status === 429) {
303
+ return { exhausted: true, delay, evidence: false, ...blockUntil(false, 'rate_limited') };
304
+ }
305
+ let exhausted = status === 429 && delay > QUOTA_WINDOW_RETRY_AFTER_MS;
208
306
  // Proof of a spent quota: a typed quota refusal or an exhausted usage window
209
307
  // (a long Retry-After alone only rotates the roster, it proves nothing).
210
- let evidence = typed;
211
- if (!typed && status === 429) {
308
+ let evidence = false;
309
+ if (status === 429) {
212
310
  await fetchOAuthUsageSnapshot({ provider: providerName, accountId: row.id }, account(row.id), () => {}, {
213
311
  force: true,
214
312
  });
@@ -218,7 +316,7 @@ async function accountQuotaExhausted(providerName, row, model, error, account) {
218
316
  evidence = providerAccountExhausted({ ...entry, blockedUntil: 0 }, Date.now(), model, options);
219
317
  exhausted = exhausted || evidence || providerAccountExhausted(entry, Date.now(), model, options);
220
318
  }
221
- return { exhausted, delay, evidence };
319
+ return { exhausted, delay, evidence, ...(exhausted ? blockUntil(evidence, evidence ? 'quota' : 'unknown') : {}) };
222
320
  }
223
321
 
224
322
  // The pool's send: walks the eligible accounts until one answers, blocking
@@ -237,7 +335,9 @@ function poolSend(providerName, account) {
237
335
  let refusalScoped = false;
238
336
  // Mark `error` only when every account in `ids` (default: the roster) is
239
337
  // exhausted with quota evidence: observed in this send, or a full window.
240
- const markExhausted = (error, ids) => {
338
+ // Without an error of its own, the send reports quota exhaustion only on
339
+ // that evidence, and a rate-limit cooldown otherwise.
340
+ const exhaustion = (ids) => {
241
341
  const pool = readProviderAccountPool(providerName);
242
342
  // Manual policy: the eligible set is the selected account alone.
243
343
  const scope = ids || (providerAccountPolicy(pool) === 'manual' ? [pool.selectedId] : null);
@@ -254,9 +354,47 @@ function poolSend(providerName, account) {
254
354
  opts
255
355
  )
256
356
  );
257
- return all ? quotaExhausted(error, providerName, model, rows, opts) : error;
357
+ return { rows, opts, now, all };
358
+ };
359
+ const markExhausted = (error, ids) => {
360
+ const { rows, opts, now, all } = exhaustion(ids);
361
+ if (all) {
362
+ return quotaExhausted(error || accountsExhaustedError(opts.useCredits), providerName, model, rows, opts);
363
+ }
364
+ return error || accountsCoolingDownError(rows, now);
365
+ };
366
+ // Accounts this send blocked on a rate-limit cooldown, the time it spent
367
+ // waiting for cooldowns, and the provider's wait bound.
368
+ const cooled = new Set();
369
+ let waitedMs = 0;
370
+ let sends = 0;
371
+ const waitBoundMs = (ids) => {
372
+ const id = ids?.[0] || readProviderAccountPool(providerName).selectedId || 'default';
373
+ return finiteMs(cooldownPolicyOf(account(id))?.waitForCooldownMs, 0);
374
+ };
375
+ // A cooldown-only eligible set whose earliest cooldown ends inside the
376
+ // remaining wait budget is waited out; otherwise the send fails as before.
377
+ const waitOrThrow = async (error, ids) => {
378
+ const { rows, now, all } = exhaustion(ids);
379
+ const wait = all ? null : earliestCooldownMs(rows, now);
380
+ if (wait == null || waitedMs + wait > waitBoundMs(ids)) throw markExhausted(error, ids);
381
+ options.onStageChange?.('reconnecting', {
382
+ message: `Rate limited — every account is cooling down; retrying in ${Math.ceil(wait / 1000)}s`,
383
+ reason: 'rate_limited',
384
+ waitMs: wait,
385
+ retryAt: now + wait,
386
+ });
387
+ await sleepWithAbort(wait, options.signal);
388
+ waitedMs += wait;
389
+ const lifted = Date.now();
390
+ for (const row of readProviderAccountPool(providerName).accounts) {
391
+ if (cooled.has(row.id) && !(Number(row.blockedUntil) > lifted)) {
392
+ cooled.delete(row.id);
393
+ attempted.delete(row.id);
394
+ }
395
+ }
258
396
  };
259
- while (attempted.size < 20) {
397
+ while (attempted.size < 20 && sends < 20) {
260
398
  options.signal?.throwIfAborted();
261
399
  const pool = readProviderAccountPool(providerName);
262
400
  if (!pool.accounts.length) {
@@ -281,12 +419,15 @@ function poolSend(providerName, account) {
281
419
  probed = true;
282
420
  if (await probeRefusedAccounts(providerName, pool, model, account)) continue;
283
421
  }
284
- throw markExhausted(lastError || accountsExhaustedError(pool.useCredits === true));
422
+ await waitOrThrow(lastError);
423
+ continue;
285
424
  }
286
425
  attempted.add(row.id);
426
+ sends += 1;
287
427
  const bound = boundAccountSend(providerName, row, messages, options);
288
428
  try {
289
429
  const result = await account(row.id).send(bound.history, model, tools, bound.opts);
430
+ quotaStrikes.delete(`${providerName}:${row.id}`);
290
431
  // Headroom policy: a session's first pick, or its move off its account
291
432
  // on a limit, becomes the selection; continuing on its account does
292
433
  // not, so concurrent sessions never flip the selection back and forth.
@@ -330,24 +471,42 @@ function poolSend(providerName, account) {
330
471
  options.onStageChange?.('reconnecting', { message: 'Sign-in expired — switching to the next account' });
331
472
  continue;
332
473
  }
333
- const { exhausted, delay, evidence } = await accountQuotaExhausted(providerName, row, model, error, account);
334
- if (options.signal?.aborted || !exhausted) throw error;
335
- if (evidence) evidenced.add(row.id);
336
- await blockProviderAccount(
474
+ const { exhausted, evidence, until, source, accountBlocked } = await accountQuotaExhausted(
337
475
  providerName,
338
- row.id,
339
- Date.now() + (delay > 0 ? delay : 5 * 60_000),
476
+ row,
340
477
  model,
341
- delay > 0 ? 'retry-after' : 'inferred',
342
- { quota: evidence === true }
478
+ error,
479
+ account
343
480
  );
344
- if (bound.emitted() || error.unsafeToRetry || error.liveTextEmitted || error.emittedToolCall) throw error;
481
+ if (options.signal?.aborted || !exhausted) throw error;
482
+ if (evidence) evidenced.add(row.id);
483
+ else cooled.add(row.id);
484
+ await blockProviderAccount(providerName, row.id, until, model, source, { quota: evidence === true });
485
+ // An account block is unsafe to re-issue on THAT account only; with no
486
+ // output emitted, the next account may serve the same request.
487
+ if (
488
+ bound.emitted() ||
489
+ (error.unsafeToRetry && accountBlocked !== true) ||
490
+ error.liveTextEmitted ||
491
+ error.emittedToolCall
492
+ )
493
+ throw error;
345
494
  // Manual policy: the eligible set is the selected account alone.
346
- if (policy === 'manual') throw markExhausted(error, [row.id]);
495
+ if (policy === 'manual') {
496
+ await waitOrThrow(error, [row.id]);
497
+ lastError = error;
498
+ continue;
499
+ }
347
500
  lastError = error;
348
501
  switched = true;
349
502
  refusalScoped = accountExhaustedForModelOnly(providerName, row.id, model);
350
- options.onStageChange?.('reconnecting', { message: 'Quota exhausted — switching to the next account' });
503
+ options.onStageChange?.('reconnecting', {
504
+ message: evidence
505
+ ? 'Quota exhausted — switching to the next account'
506
+ : accountBlocked
507
+ ? 'Account blocked — switching to the next account'
508
+ : 'Rate limited (cooldown) — switching to the next account',
509
+ });
351
510
  }
352
511
  }
353
512
  throw markExhausted(lastError || new Error('Provider account attempts exhausted.'));
@@ -9,6 +9,7 @@
9
9
  import { PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS, createTimeoutSignal } from '../stall-policy.mjs';
10
10
  import { AnthropicFallbackTriggeredError, markProviderRecoveryExhausted } from './retry-classifier.mjs';
11
11
  import { normalizeAnthropicNonStreamingResponse } from './lib/anthropic-request-utils.mjs';
12
+ import { capForNonStreaming } from './anthropic-max-tokens-recovery.mjs';
12
13
  import { createAnthropicRecoveryGuards } from './anthropic-recovery-guards.mjs';
13
14
 
14
15
  /**
@@ -44,7 +45,7 @@ export function createAnthropicApiRecovery({
44
45
  try {
45
46
  onStageChange?.('requesting', { transport: 'non-streaming-fallback' });
46
47
  } catch {}
47
- const nonStreamingParams = { ...params, stream: false };
48
+ const nonStreamingParams = capForNonStreaming({ ...params, stream: false });
48
49
  const timeoutMs =
49
50
  Number(opts._nonStreamingTimeoutMs) > 0
50
51
  ? Number(opts._nonStreamingTimeoutMs)