mixdog 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/README.md +7 -5
  2. package/package.json +1 -1
  3. package/src/defaults/shipped-history.json +4 -0
  4. package/src/defaults/skills/pptx/SKILL.md +6 -6
  5. package/src/defaults/skills/pptx/references/review.md +6 -4
  6. package/src/defaults/skills/pptx/scripts/source-extract.mjs +1 -1
  7. package/src/defaults/skills/setup/references/actions.md +13 -3
  8. package/src/defaults/skills/visualize/SKILL.md +12 -9
  9. package/src/rules/shared/60-verification.md +3 -1
  10. package/src/rules/shared/70-delivery.md +4 -2
  11. package/src/runtime/agent/orchestrator/cache-mtime.mjs +26 -5
  12. package/src/runtime/agent/orchestrator/config.mjs +10 -4
  13. package/src/runtime/agent/orchestrator/context/collect.mjs +2 -1
  14. package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +11 -5
  15. package/src/runtime/agent/orchestrator/context/skill-catalog.mjs +74 -21
  16. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +86 -8
  17. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +5 -1
  18. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +65 -7
  19. package/src/runtime/agent/orchestrator/mcp/client.mjs +128 -44
  20. package/src/runtime/agent/orchestrator/mcp/lazy-output-validator.mjs +25 -0
  21. package/src/runtime/agent/orchestrator/mcp/plugin-placeholders.mjs +54 -0
  22. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +108 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +19 -11
  25. package/src/runtime/agent/orchestrator/providers/auto-effort.mjs +9 -0
  26. package/src/runtime/agent/orchestrator/providers/command-code-oauth-login.mjs +337 -0
  27. package/src/runtime/agent/orchestrator/providers/command-code-oauth-tokens.mjs +159 -0
  28. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +444 -0
  29. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +7 -4
  31. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +9 -2
  32. package/src/runtime/agent/orchestrator/providers/gemini.mjs +41 -28
  33. package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +7 -1
  34. package/src/runtime/agent/orchestrator/providers/model-proxy-bridge.mjs +1080 -0
  35. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +15 -0
  36. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +35 -0
  37. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -0
  38. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +6 -5
  39. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +6 -2
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +11 -0
  42. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +2 -1
  43. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +9 -3
  44. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +4 -4
  45. package/src/runtime/agent/orchestrator/providers/registry.mjs +35 -9
  46. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +3 -0
  47. package/src/runtime/agent/orchestrator/runtime-core/extension-scopes.mjs +15 -55
  48. package/src/runtime/agent/orchestrator/runtime-core/goal-control.mjs +15 -2
  49. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +267 -84
  50. package/src/runtime/agent/orchestrator/runtime-core/skill-tool-loading.mjs +4 -1
  51. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-schema.mjs +21 -0
  52. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +54 -3
  53. package/src/runtime/agent/orchestrator/session/catalog-db/index.mjs +284 -0
  54. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +268 -0
  55. package/src/runtime/agent/orchestrator/session/catalog-db/sqlite-gate.mjs +27 -0
  56. package/src/runtime/agent/orchestrator/session/catalog-db/worker.mjs +628 -0
  57. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +6 -2
  58. package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +184 -0
  59. package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +136 -0
  60. package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +128 -0
  61. package/src/runtime/agent/orchestrator/session/conversation-import/imported-session.mjs +127 -0
  62. package/src/runtime/agent/orchestrator/session/conversation-import/turns.mjs +101 -0
  63. package/src/runtime/agent/orchestrator/session/explicit-skills.mjs +83 -5
  64. package/src/runtime/agent/orchestrator/session/imported-message.mjs +11 -0
  65. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +2 -1
  66. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +1 -1
  67. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +3 -2
  68. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +6 -4
  69. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +4 -3
  70. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +3 -0
  71. package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +12 -3
  72. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +3 -1
  73. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +20 -12
  74. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +9 -2
  75. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +4 -0
  76. package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +17 -4
  77. package/src/runtime/agent/orchestrator/session/manager.mjs +0 -1
  78. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +4 -3
  79. package/src/runtime/agent/orchestrator/session/store/listing.mjs +6 -2
  80. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +6 -0
  81. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +817 -0
  82. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +7 -0
  83. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +19 -12
  84. package/src/runtime/agent/orchestrator/session/store-summary-reader-catalog-fixture.mjs +158 -0
  85. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +183 -374
  86. package/src/runtime/agent/orchestrator/session/synthetic-user-envelope.mjs +3 -1
  87. package/src/runtime/agent/orchestrator/session/tool-batch/pre-dispatch.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +21 -2
  89. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +38 -17
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +17 -1
  91. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/spawn-env.mjs +4 -0
  92. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +2 -2
  93. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +6 -6
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +173 -22
  95. package/src/runtime/agent/orchestrator/tools/builtin/read-single-guards.mjs +8 -4
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +5 -4
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +15 -2
  98. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +13 -10
  99. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +1 -1
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +17 -12
  101. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch/root-resolution.mjs +2 -2
  102. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/codex-batch.mjs +4 -4
  104. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/route.mjs +2 -2
  105. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
  106. package/src/runtime/agent/orchestrator/tools/patch/read-redirects.mjs +34 -30
  107. package/src/runtime/agent/orchestrator/tools/patch/sequence/units.mjs +7 -7
  108. package/src/runtime/agent/orchestrator/tools/patch/sequence.mjs +1 -1
  109. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -0
  110. package/src/runtime/attachments/store.d.mts +3 -0
  111. package/src/runtime/channels/lib/owned-runtime/owned-start.mjs +1 -1
  112. package/src/runtime/channels/lib/webhook/session-dispatch.mjs +16 -4
  113. package/src/runtime/channels/lib/worker-main.mjs +17 -14
  114. package/src/runtime/computer-bridge/linux-readiness.mjs +0 -2
  115. package/src/runtime/effort-judge/judge-client.mjs +56 -18
  116. package/src/runtime/memory/lib/memory.mjs +1 -1
  117. package/src/runtime/memory/lib/session-ingest.mjs +2 -2
  118. package/src/runtime/shared/atomic-file.mjs +25 -4
  119. package/src/runtime/shared/extension-scopes.mjs +5 -3
  120. package/src/runtime/shared/file-lock.mjs +37 -11
  121. package/src/runtime/shared/llm/anthropic-effort.mjs +14 -0
  122. package/src/runtime/shared/llm/model-catalog.mjs +0 -12
  123. package/src/runtime/shared/llm/provider-catalog-registry.mjs +6 -0
  124. package/src/runtime/shared/plugin-manifest.mjs +18 -9
  125. package/src/runtime/shared/pristine-execution-contract.json +9 -2
  126. package/src/runtime/shared/pristine-execution.mjs +28 -4
  127. package/src/runtime/shared/provider-accounts.mjs +124 -20
  128. package/src/runtime/shared/provider-api-key.mjs +12 -0
  129. package/src/runtime/shared/shell-display.mjs +9 -3
  130. package/src/runtime/shared/skill-mentions.d.mts +10 -0
  131. package/src/runtime/shared/skill-mentions.mjs +133 -0
  132. package/src/runtime/shared/skill-script-runtime.mjs +264 -0
  133. package/src/runtime/shared/tool-primitives.mjs +35 -2
  134. package/src/runtime/shared/turn-worktree-snapshot.mjs +25 -5
  135. package/src/runtime/shared/worker-requests.mjs +15 -2
  136. package/src/session-runtime/automation-agents.mjs +8 -2
  137. package/src/session-runtime/boot/apis.mjs +55 -0
  138. package/src/session-runtime/boot/config.mjs +5 -2
  139. package/src/session-runtime/boot/facade.mjs +1 -0
  140. package/src/session-runtime/boot/infrastructure.mjs +29 -18
  141. package/src/session-runtime/conversation-import/discovery.mjs +698 -0
  142. package/src/session-runtime/conversation-import/service.mjs +369 -0
  143. package/src/session-runtime/cwd-plugins/cwd-apply.mjs +10 -5
  144. package/src/session-runtime/cwd-plugins/plugin-status.mjs +47 -42
  145. package/src/session-runtime/mcp-glue/connect.mjs +57 -10
  146. package/src/session-runtime/mcp-glue/plugin-servers.mjs +499 -0
  147. package/src/session-runtime/mcp-glue/server-catalog.mjs +115 -30
  148. package/src/session-runtime/mcp-glue.mjs +22 -2
  149. package/src/session-runtime/new-session-config.mjs +3 -2
  150. package/src/session-runtime/notification-bus.mjs +9 -2
  151. package/src/session-runtime/provider-auth/accounts.mjs +33 -15
  152. package/src/session-runtime/provider-models/web-search-catalog.mjs +5 -1
  153. package/src/session-runtime/provider-readiness.mjs +16 -10
  154. package/src/session-runtime/resource-extension-sync.mjs +20 -5
  155. package/src/session-runtime/resource-mcp-api.mjs +101 -77
  156. package/src/session-runtime/resource-plugins-api.mjs +29 -41
  157. package/src/session-runtime/resource-scope-decorate.mjs +6 -8
  158. package/src/session-runtime/resource-skills-api.mjs +23 -2
  159. package/src/session-runtime/schedule-session-run.mjs +1 -1
  160. package/src/session-runtime/services/channel-session-router.mjs +4 -1
  161. package/src/session-runtime/services/hook-bus/command-handler.mjs +4 -1
  162. package/src/session-runtime/services/hook-bus/handlers.mjs +7 -3
  163. package/src/session-runtime/services/provider-admin.mjs +64 -18
  164. package/src/session-runtime/services/usage-dashboard-model.mjs +3 -0
  165. package/src/session-runtime/setup-tool/executor.mjs +12 -1
  166. package/src/session-runtime/setup-tool/extended-actions.mjs +1 -0
  167. package/src/session-runtime/setup-tool/settings-contract.mjs +1 -0
  168. package/src/session-runtime/setup-tool/tool-defs.mjs +7 -1
  169. package/src/session-runtime/skills-api.mjs +7 -3
  170. package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +1 -0
  171. package/src/session-runtime/webhook-session-run.mjs +16 -4
  172. package/src/skill-script-entry.mjs +36 -0
  173. package/src/standalone/daemon-stored-session-views.mjs +11 -3
  174. package/src/standalone/daemon.mjs +41 -4
  175. package/src/standalone/session-protocol.mjs +5 -0
  176. package/src/standalone/session-service/session-calls/session-action.mjs +6 -1
  177. package/src/tui/App.jsx +9 -1
  178. package/src/tui/app/app-view/floating-panel.jsx +9 -1
  179. package/src/tui/app/app-view.jsx +6 -0
  180. package/src/tui/app/extension-pickers/plugins-pickers.mjs +35 -16
  181. package/src/tui/app/model-options.mjs +2 -0
  182. package/src/tui/app/skill-mention-palette.mjs +88 -0
  183. package/src/tui/app/slash-palette.mjs +23 -7
  184. package/src/tui/app/transcript-row-estimate.mjs +11 -3
  185. package/src/tui/app/use-prompt-draft-flow.mjs +7 -2
  186. package/src/tui/app/use-prompt-state.mjs +5 -0
  187. package/src/tui/components/Message.jsx +22 -1
  188. package/src/tui/components/PromptInput.jsx +21 -3
  189. package/src/tui/components/SlashCommandPalette.jsx +12 -3
  190. package/src/tui/components/TranscriptItem.jsx +9 -1
  191. package/src/tui/components/prompt-input/key-handler.mjs +21 -8
  192. package/src/tui/components/user-message-skills.mjs +38 -0
  193. package/src/tui/dist/index.mjs +507 -171
  194. package/src/tui/session/queue-helpers.mjs +7 -2
  195. package/src/tui/session/session-api/extensions.mjs +4 -2
  196. package/src/tui/session/session-api/settings.mjs +7 -0
  197. package/src/tui/session/session-flow/drain.mjs +14 -4
  198. package/src/tui/session/tui-steering-persist.mjs +25 -9
  199. package/src/tui/session/turn.mjs +17 -0
  200. package/src/runtime/effort-judge/previous-human-request.mjs +0 -162
  201. package/src/runtime/effort-judge/verified-context-input.mjs +0 -10
@@ -3,9 +3,11 @@ import {
3
3
  accountScopedSessionId,
4
4
  readProviderAccountPool,
5
5
  chooseProviderAccount,
6
- changeProviderAccounts,
6
+ selectProviderAccountIf,
7
7
  blockProviderAccount,
8
8
  providerAccountExhausted,
9
+ providerAccountExhaustedForModelOnly,
10
+ providerAccountPolicy,
9
11
  providerAccountsResetAt,
10
12
  } from '../../../shared/provider-accounts.mjs';
11
13
  import { withProviderAccount, hasExplicitProviderAuthBinding } from '../../../shared/provider-auth-binding.mjs';
@@ -116,13 +118,82 @@ function boundAccountSend(providerName, row, messages, options) {
116
118
  return { opts, history, emitted: () => emitted };
117
119
  }
118
120
 
119
- // Commit only a successful fallback, without overwriting a newer selection.
120
- function commitFallbackAccount(providerName, pool, row) {
121
- if (row.id === pool.selectedId) return;
122
- const current = readProviderAccountPool(providerName);
123
- if (current.selectedId === pool.selectedId && current.accounts.some((entry) => entry.id === row.id)) {
124
- return changeProviderAccounts(providerName, { selectedId: row.id });
121
+ // Commit only a successful fallback, without overwriting a newer selection:
122
+ // the selection is compared under the write's lock, not before it. A fallback
123
+ // forced by a model-scoped limit ("7D Fable" full, or a block attributed to
124
+ // one family) serves this request only: the selected account still serves
125
+ // every other model, so it stays the provider's account of record.
126
+ async function commitFallbackAccount(providerName, pool, row, model, refusalScoped, policy) {
127
+ if (row.id === pool.selectedId || refusalScoped) return null;
128
+ if (accountExhaustedForModelOnly(providerName, pool.selectedId, model)) return null;
129
+ return selectProviderAccountIf(providerName, pool.selectedId, row.id, policy);
130
+ }
131
+
132
+ // The account each session runs on under the headroom policy. Every answer
133
+ // records its provider and account in the assistant message's
134
+ // `providerMetadata` (and in provider state when the adapter keeps one), so
135
+ // the binding survives a restart and eviction from this map, which only
136
+ // spares the history walk. Bounded, oldest binding first out.
137
+ const SESSION_ACCOUNT_LIMIT = 1000;
138
+ const ROUTING_METADATA_KEY = 'accountRouting';
139
+ const sessionAccounts = new Map();
140
+
141
+ // Forget every in-memory binding, as a restart does (tests).
142
+ export function resetSessionAccountBindings() {
143
+ sessionAccounts.clear();
144
+ }
145
+
146
+ function rememberSessionAccount(key, id) {
147
+ sessionAccounts.delete(key);
148
+ sessionAccounts.set(key, id);
149
+ if (sessionAccounts.size > SESSION_ACCOUNT_LIMIT) sessionAccounts.delete(sessionAccounts.keys().next().value);
150
+ }
151
+
152
+ function accountExhaustedForModelOnly(providerName, id, model) {
153
+ const pool = readProviderAccountPool(providerName);
154
+ const row = pool.accounts.find((entry) => entry.id === id);
155
+ return (
156
+ Boolean(row) &&
157
+ providerAccountExhaustedForModelOnly(row, Date.now(), model, { useCredits: pool.useCredits === true })
158
+ );
159
+ }
160
+
161
+ // Only a binding recorded for THIS provider counts: a session that switched
162
+ // providers carries another roster's account ids (every roster has a `default`).
163
+ // The session's binding is read before the serving account, which differs
164
+ // when a model-scoped limit lent this one answer to another account.
165
+ function sessionAccountId(providerName, key, messages, options) {
166
+ if (key && sessionAccounts.has(key)) return sessionAccounts.get(key);
167
+ const state = options.providerState;
168
+ if (state?.providerAccountId && state.providerAccountProvider === providerName) {
169
+ return state.providerSessionAccountId || state.providerAccountId;
170
+ }
171
+ for (let index = (messages?.length || 0) - 1; index >= 0; index -= 1) {
172
+ const routing = messages[index]?.providerMetadata?.[ROUTING_METADATA_KEY];
173
+ if (routing?.accountId && routing.provider === providerName) return routing.sessionAccountId || routing.accountId;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ // Stamp the serving account on the answer, independent of transport state,
179
+ // with the account the session stays bound to (`bindingId`).
180
+ function recordServingAccount(providerName, result, id, bindingId) {
181
+ if (!result || typeof result !== 'object') return;
182
+ if (result.providerReplay) result.providerReplay = { ...result.providerReplay, accountId: id };
183
+ if (result.providerState) {
184
+ result.providerState = {
185
+ ...result.providerState,
186
+ providerAccountId: id,
187
+ providerSessionAccountId: bindingId,
188
+ providerAccountProvider: providerName,
189
+ };
125
190
  }
191
+ const metadata =
192
+ result.providerMetadata && typeof result.providerMetadata === 'object' ? result.providerMetadata : {};
193
+ result.providerMetadata = {
194
+ ...metadata,
195
+ [ROUTING_METADATA_KEY]: { provider: providerName, accountId: id, sessionAccountId: bindingId },
196
+ };
126
197
  }
127
198
 
128
199
  // Whether the failed account's quota window is exhausted, plus the server's
@@ -158,12 +229,18 @@ function poolSend(providerName, account) {
158
229
  let lastError;
159
230
  let probed = false;
160
231
  const evidenced = new Set();
232
+ const sessionKey = options.sessionId ? `${providerName}:${options.sessionId}` : null;
233
+ const boundId = sessionAccountId(providerName, sessionKey, messages, options);
234
+ // Whether this send already left an account, and whether the latest quota
235
+ // refusal held only for this model's family.
236
+ let switched = false;
237
+ let refusalScoped = false;
161
238
  // Mark `error` only when every account in `ids` (default: the roster) is
162
239
  // exhausted with quota evidence: observed in this send, or a full window.
163
240
  const markExhausted = (error, ids) => {
164
241
  const pool = readProviderAccountPool(providerName);
165
- // Auto switching off: the eligible set is the selected account alone.
166
- const scope = ids || (pool.auto === false ? [pool.selectedId] : null);
242
+ // Manual policy: the eligible set is the selected account alone.
243
+ const scope = ids || (providerAccountPolicy(pool) === 'manual' ? [pool.selectedId] : null);
167
244
  const rows = scope ? pool.accounts.filter((entry) => scope.includes(entry.id)) : pool.accounts;
168
245
  const opts = { useCredits: pool.useCredits === true };
169
246
  const now = Date.now();
@@ -194,7 +271,8 @@ function poolSend(providerName, account) {
194
271
  }
195
272
  return account('default').send(messages, model, tools, options);
196
273
  }
197
- const row = chooseProviderAccount(pool, attempted, Date.now(), model);
274
+ const policy = providerAccountPolicy(pool);
275
+ const row = chooseProviderAccount(pool, attempted, Date.now(), model, { sessionAccountId: boundId });
198
276
  if (!row) {
199
277
  // Once per send: a refusal built on a stale meter must not outlive the
200
278
  // meter. Re-measuring can only widen the roster, so it is tried before
@@ -209,9 +287,18 @@ function poolSend(providerName, account) {
209
287
  const bound = boundAccountSend(providerName, row, messages, options);
210
288
  try {
211
289
  const result = await account(row.id).send(bound.history, model, tools, bound.opts);
212
- if (result?.providerReplay) result.providerReplay = { ...result.providerReplay, accountId: row.id };
213
- if (result?.providerState) result.providerState = { ...result.providerState, providerAccountId: row.id };
214
- if (commitFallbackAccount(providerName, pool, row)) {
290
+ // Headroom policy: a session's first pick, or its move off its account
291
+ // on a limit, becomes the selection; continuing on its account does
292
+ // not, so concurrent sessions never flip the selection back and forth.
293
+ // A move forced by a model-scoped limit serves this request only.
294
+ const leftBound = Boolean(boundId) && row.id !== boundId;
295
+ const scoped = switched
296
+ ? refusalScoped
297
+ : policy === 'headroom' && leftBound && accountExhaustedForModelOnly(providerName, boundId, model);
298
+ recordServingAccount(providerName, result, row.id, scoped && boundId ? boundId : row.id);
299
+ if (sessionKey && policy === 'headroom' && !scoped) rememberSessionAccount(sessionKey, row.id);
300
+ const commit = policy !== 'headroom' || !boundId || leftBound;
301
+ if (commit && (await commitFallbackAccount(providerName, pool, row, model, scoped, policy))) {
215
302
  // Publish only a committed selection, never a speculative retry or
216
303
  // one superseded by a manual switch. The existing progress channel
217
304
  // carries this across runtime shards to the attached UI.
@@ -230,7 +317,7 @@ function poolSend(providerName, account) {
230
317
  if (options.signal?.aborted) throw error;
231
318
  if (error?.reauthRequired === true) {
232
319
  if (
233
- pool.auto === false ||
320
+ policy === 'manual' ||
234
321
  bound.emitted() ||
235
322
  error.unsafeToRetry ||
236
323
  error.liveTextEmitted ||
@@ -238,13 +325,15 @@ function poolSend(providerName, account) {
238
325
  )
239
326
  throw error;
240
327
  lastError = error;
328
+ switched = true;
329
+ refusalScoped = false;
241
330
  options.onStageChange?.('reconnecting', { message: 'Sign-in expired — switching to the next account' });
242
331
  continue;
243
332
  }
244
333
  const { exhausted, delay, evidence } = await accountQuotaExhausted(providerName, row, model, error, account);
245
334
  if (options.signal?.aborted || !exhausted) throw error;
246
335
  if (evidence) evidenced.add(row.id);
247
- blockProviderAccount(
336
+ await blockProviderAccount(
248
337
  providerName,
249
338
  row.id,
250
339
  Date.now() + (delay > 0 ? delay : 5 * 60_000),
@@ -253,9 +342,11 @@ function poolSend(providerName, account) {
253
342
  { quota: evidence === true }
254
343
  );
255
344
  if (bound.emitted() || error.unsafeToRetry || error.liveTextEmitted || error.emittedToolCall) throw error;
256
- // Auto switching off: the eligible set is the selected account alone.
257
- if (pool.auto === false) throw markExhausted(error, [row.id]);
345
+ // Manual policy: the eligible set is the selected account alone.
346
+ if (policy === 'manual') throw markExhausted(error, [row.id]);
258
347
  lastError = error;
348
+ switched = true;
349
+ refusalScoped = accountExhaustedForModelOnly(providerName, row.id, model);
259
350
  options.onStageChange?.('reconnecting', { message: 'Quota exhausted — switching to the next account' });
260
351
  }
261
352
  }
@@ -10,7 +10,7 @@ import { createNpmVersionSource, maxSemver } from './npm-cli-version.mjs';
10
10
  // source release just to advance the user-agent version.
11
11
  // Claude Opus 5.5 requires 2.1.280 or newer. The release workflow raises this
12
12
  // to the published CLI version (scripts/sync-client-version-floors.mjs).
13
- export const DEFAULT_CLI_VERSION = '2.1.296';
13
+ export const DEFAULT_CLI_VERSION = '2.1.299';
14
14
  export const CLAUDE_CLI_NPM_PACKAGE = '@anthropic-ai/claude-code';
15
15
 
16
16
  const CACHE_SCHEMA_VERSION = 1;
@@ -1,4 +1,4 @@
1
- import { getAgentApiKey } from '../../../shared/provider-api-key.mjs';
1
+ import { reloadProviderApiKey, resolveProviderApiKey } from '../../../shared/provider-api-key.mjs';
2
2
  import { sanitizeToolPairs } from '../session/context-utils.mjs';
3
3
  import { AnthropicFallbackTriggeredError } from './retry-classifier.mjs';
4
4
  import { readStreamOutcome } from './lib/stream-outcome.mjs';
@@ -42,9 +42,19 @@ export class AnthropicProvider {
42
42
  constructor(config) {
43
43
  this.config = config || {};
44
44
  this.name = this.config.name || 'anthropic';
45
- this.apiKey = this.config.apiKey || (this.name === 'anthropic' ? process.env.ANTHROPIC_API_KEY : null);
45
+ this.apiKey = resolveProviderApiKey(this.name, this.config) || null;
46
46
  this.client = this._createClient(this.apiKey);
47
47
  }
48
+ // Client for the currently resolved key; rebuilt when the key changed since
49
+ // it was built (e.g. a keyless-constructed instance).
50
+ _currentClient() {
51
+ const key = resolveProviderApiKey(this.name, this.config) || null;
52
+ if (key && key !== this.apiKey) {
53
+ this.apiKey = key;
54
+ this.client = this._createClient(key);
55
+ }
56
+ return this.client;
57
+ }
48
58
  // Tool-search is a request capability, not an account capability. Keep it
49
59
  // off the client defaults and add it only on turns that actually serialize
50
60
  // defer_loading tools.
@@ -55,8 +65,10 @@ export class AnthropicProvider {
55
65
  const custom = isCustomProviderName(this.name);
56
66
  const keyless = custom && this.config.keyless === true;
57
67
  const betaHeaders = this.config.disableBetaHeaders || custom ? null : buildAnthropicBetaHeaders();
68
+ let credentials = { apiKey };
69
+ if (custom) credentials = { apiKey: keyless ? '' : apiKey || '', authToken: null };
58
70
  return new (loadAnthropic())({
59
- ...(custom ? { apiKey: keyless ? '' : apiKey || '', authToken: null } : { apiKey }),
71
+ ...credentials,
60
72
  ...(this.config.baseURL ? { baseURL: this.config.baseURL } : {}),
61
73
  defaultHeaders: {
62
74
  ...(betaHeaders ? { 'anthropic-beta': betaHeaders } : {}),
@@ -68,10 +80,7 @@ export class AnthropicProvider {
68
80
  }
69
81
  reloadApiKey() {
70
82
  try {
71
- const newKey =
72
- getAgentApiKey(this.name) ||
73
- this.config.apiKey ||
74
- (this.name === 'anthropic' ? process.env.ANTHROPIC_API_KEY : null);
83
+ const newKey = reloadProviderApiKey(this.name, this.config);
75
84
  if (newKey) {
76
85
  this.config = { ...(this.config || {}), apiKey: newKey };
77
86
  this.apiKey = newKey;
@@ -160,7 +169,7 @@ export class AnthropicProvider {
160
169
  // The SDK calls and their initial-response retry policy
161
170
  // (anthropic-api-transport.mjs).
162
171
  const transport = createAnthropicApiTransport({
163
- client: this.client,
172
+ client: this._currentClient(),
164
173
  label: this.name,
165
174
  opts,
166
175
  useModel,
@@ -296,8 +305,7 @@ export class AnthropicProvider {
296
305
  }
297
306
  }
298
307
  async listModels() {
299
- const apiKey =
300
- this.apiKey || this.config?.apiKey || (this.name === 'anthropic' ? process.env.ANTHROPIC_API_KEY : null);
308
+ const apiKey = resolveProviderApiKey(this.name, this.config);
301
309
  if (!apiKey) return MODELS;
302
310
  try {
303
311
  const base = String(this.config?.baseURL || 'https://api.anthropic.com').replace(/\/+$/, '');
@@ -329,6 +337,6 @@ export class AnthropicProvider {
329
337
  async isAvailable() {
330
338
  // Availability probes must not spend tokens or depend on a live
331
339
  // network. Dispatch owns authentication validation and 401 reload.
332
- return !!(this.apiKey || this.config?.apiKey || (this.name === 'anthropic' && process.env.ANTHROPIC_API_KEY));
340
+ return !!resolveProviderApiKey(this.name, this.config);
333
341
  }
334
342
  }
@@ -89,6 +89,15 @@ export function resolveAutoEffort({ base, options, probs }) {
89
89
  const ladder = autoEffortLadder(options);
90
90
  const at = ladder.indexOf(String(base ?? '').toLowerCase());
91
91
  if (at < 0) return null;
92
+ return resolveOnLadder(ladder, at, probs);
93
+ }
94
+
95
+ /** True when some judged outcome could apply: the base lies on the model's ladder. */
96
+ export function autoEffortApplicable({ base, options }) {
97
+ return autoEffortLadder(options).includes(String(base ?? '').toLowerCase());
98
+ }
99
+
100
+ function resolveOnLadder(ladder, at, probs) {
92
101
  const { step, level, confidence } = judgedStep(probs);
93
102
  const effort = ladder[Math.max(0, Math.min(ladder.length - 1, at + step))];
94
103
  return { base: ladder[at], effort, step, level, confidence: Number(confidence.toFixed(3)) };
@@ -0,0 +1,337 @@
1
+ // Command Code browser sign-in. The Studio CLI auth page mints a Provider API
2
+ // key and POSTs it (with our one-shot state) as JSON to a loopback callback.
3
+ // A pasted key or pasted callback JSON completes the same login by hand. The
4
+ // first sign-in on a machine adopts an existing Command Code CLI login instead.
5
+ import { createServer } from 'node:http';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { readFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { openLoginUrl } from './lib/open-login-url.mjs';
11
+ import { readProviderAccountPool } from '../../../shared/provider-accounts.mjs';
12
+ import { withProviderAccount } from '../../../shared/provider-auth-binding.mjs';
13
+ import {
14
+ LOGIN_TIMEOUT_MS,
15
+ PROVIDER_ID,
16
+ STUDIO_ORIGIN,
17
+ fetchCommandCodeIdentity,
18
+ isWellFormedApiKey,
19
+ loadTokens,
20
+ saveTokens,
21
+ } from './command-code-oauth-tokens.mjs';
22
+
23
+ export const CALLBACK_PORT = 5959;
24
+ const CALLBACK_PATH = '/callback';
25
+ const MAX_CALLBACK_BYTES = 16 * 1024;
26
+ const CALLBACK_FIELDS = Object.freeze(['apiKey', 'userId', 'userName', 'keyName']);
27
+
28
+ const CORS_HEADERS = Object.freeze({
29
+ 'Access-Control-Allow-Origin': STUDIO_ORIGIN,
30
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
31
+ 'Access-Control-Allow-Headers': 'Content-Type',
32
+ // The Studio page is public and the callback is loopback: Chromium asks first.
33
+ 'Access-Control-Allow-Private-Network': 'true',
34
+ Vary: 'Origin',
35
+ });
36
+
37
+ export const defaultLocalAuthPath = () => join(homedir(), '.commandcode', 'auth.json');
38
+
39
+ /** Validate the Studio callback body against this login's state. */
40
+ export function parseCallbackPayload(value, expectedState) {
41
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
42
+ throw new Error('[command-code-oauth] callback body must be an object');
43
+ }
44
+ if (typeof value.state !== 'string' || !expectedState || value.state !== expectedState) {
45
+ throw new Error('[command-code-oauth] callback state mismatch');
46
+ }
47
+ for (const field of CALLBACK_FIELDS) {
48
+ if (typeof value[field] !== 'string' || !value[field].trim()) {
49
+ throw new Error(`[command-code-oauth] callback is missing ${field}`);
50
+ }
51
+ }
52
+ const apiKey = value.apiKey.trim();
53
+ if (!isWellFormedApiKey(apiKey)) throw new Error('[command-code-oauth] callback carried a malformed API key');
54
+ return { apiKey, userId: value.userId.trim(), userName: value.userName.trim(), keyName: value.keyName.trim() };
55
+ }
56
+
57
+ /** A paste is either the callback JSON (state-checked) or a bare API key. */
58
+ export function parsePastedInput(input, expectedState) {
59
+ const pasted = String(input ?? '').trim();
60
+ if (!pasted) throw new Error('[command-code-oauth] API key is required');
61
+ if (pasted.startsWith('{')) {
62
+ let doc;
63
+ try {
64
+ doc = JSON.parse(pasted);
65
+ } catch {
66
+ throw new Error('[command-code-oauth] pasted callback is not valid JSON');
67
+ }
68
+ return parseCallbackPayload(doc, expectedState);
69
+ }
70
+ if (!isWellFormedApiKey(pasted)) throw new Error('[command-code-oauth] pasted API key is malformed');
71
+ return { apiKey: pasted, userId: '', userName: '', keyName: '' };
72
+ }
73
+
74
+ // No account of this provider is connected yet: no roster entry and no
75
+ // credential in the default slot. Adding a second account or reconnecting an
76
+ // existing one must pick a fresh browser identity, never the local CLI login.
77
+ function isFirstSignIn() {
78
+ try {
79
+ if (readProviderAccountPool(PROVIDER_ID).accounts.length) return false;
80
+ return !withProviderAccount(PROVIDER_ID, 'default', () => loadTokens());
81
+ } catch {
82
+ // An unreadable roster cannot prove this is the first account.
83
+ return false;
84
+ }
85
+ }
86
+
87
+ function readLocalAuth(path) {
88
+ try {
89
+ const doc = JSON.parse(readFileSync(path, 'utf-8'));
90
+ const apiKey = typeof doc?.apiKey === 'string' ? doc.apiKey.trim() : '';
91
+ const userId = typeof doc?.userId === 'string' ? doc.userId.trim() : '';
92
+ return apiKey && userId && isWellFormedApiKey(apiKey) ? { apiKey, userId } : null;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ function listenOn(server, port, host) {
99
+ return new Promise((resolve, reject) => {
100
+ server.once('error', reject);
101
+ server.listen(port, host, () => {
102
+ server.off('error', reject);
103
+ resolve();
104
+ });
105
+ });
106
+ }
107
+
108
+ function readBody(req) {
109
+ return new Promise((resolve, reject) => {
110
+ const chunks = [];
111
+ let size = 0;
112
+ req.on('data', (chunk) => {
113
+ size += chunk.length;
114
+ if (size > MAX_CALLBACK_BYTES) {
115
+ reject(new Error('[command-code-oauth] callback body too large'));
116
+ req.destroy();
117
+ return;
118
+ }
119
+ chunks.push(chunk);
120
+ });
121
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
122
+ req.on('error', reject);
123
+ });
124
+ }
125
+
126
+ function reply(res, status, body) {
127
+ res.writeHead(status, { ...CORS_HEADERS, ...(body ? { 'Content-Type': 'application/json' } : {}) });
128
+ res.end(body ? JSON.stringify(body) : undefined);
129
+ }
130
+
131
+ function authorizeUrl(port, state) {
132
+ const url = new URL('/studio/auth/cli', STUDIO_ORIGIN);
133
+ url.searchParams.set('callback', `http://127.0.0.1:${port}${CALLBACK_PATH}`);
134
+ url.searchParams.set('state', state);
135
+ return url.toString();
136
+ }
137
+
138
+ export async function beginOAuthLogin({
139
+ openBrowser = true,
140
+ timeoutMs = LOGIN_TIMEOUT_MS,
141
+ fetchImpl = fetch,
142
+ port: preferredPort = CALLBACK_PORT,
143
+ importLocal = isFirstSignIn(),
144
+ localAuthPath = defaultLocalAuthPath(),
145
+ printUrl = false,
146
+ } = {}) {
147
+ // Validate and store a key under its whoami identity.
148
+ const store = async (candidate, signal) => {
149
+ const identity = await fetchCommandCodeIdentity(candidate.apiKey, { fetchImpl, signal });
150
+ return { apiKey: candidate.apiKey, userId: identity.userId, userName: identity.userName || candidate.userName };
151
+ };
152
+
153
+ if (importLocal) {
154
+ const local = readLocalAuth(localAuthPath);
155
+ if (local) {
156
+ let tokens = null;
157
+ try {
158
+ tokens = saveTokens(await store(local));
159
+ } catch {
160
+ /* a stale or rejected CLI login falls through to the browser */
161
+ }
162
+ if (tokens) {
163
+ return {
164
+ provider: PROVIDER_ID,
165
+ url: '',
166
+ manualUrl: null,
167
+ imported: true,
168
+ waitForCallback: Promise.resolve(tokens),
169
+ cancel: () => {},
170
+ };
171
+ }
172
+ }
173
+ }
174
+
175
+ const state = randomBytes(32).toString('base64url');
176
+ const servers = [];
177
+ // Open client connections, and the ones still owed a reply by a request
178
+ // that is completing the login.
179
+ const sockets = new Set();
180
+ const replying = new Set();
181
+ const work = new AbortController();
182
+ let timer = null;
183
+ let finish = null;
184
+ let settled = false;
185
+ const waitForCallback = new Promise((resolve, reject) => {
186
+ finish = (value, error = null) => {
187
+ if (settled) return;
188
+ settled = true;
189
+ if (timer) clearTimeout(timer);
190
+ work.abort();
191
+ for (const server of servers) {
192
+ try {
193
+ server.close();
194
+ } catch {
195
+ /* already closed */
196
+ }
197
+ }
198
+ // close() only stops accepting: drop every connection (a stalled
199
+ // partial POST included) except those about to get their reply.
200
+ for (const socket of sockets) if (!replying.has(socket)) socket.destroy();
201
+ if (error) reject(error);
202
+ else resolve(value);
203
+ };
204
+ timer = setTimeout(
205
+ () => finish(null, new Error(`[command-code-oauth] login timed out after ${Math.round(timeoutMs / 1000)}s`)),
206
+ timeoutMs
207
+ );
208
+ });
209
+ // A caller may only use the paste path; a later timeout/cancel must not be an unhandled rejection.
210
+ waitForCallback.catch(() => {});
211
+
212
+ // The credential is written only while the login is still open, and the
213
+ // login completes in the same synchronous step.
214
+ const settle = async (candidate) => {
215
+ const tokens = await store(candidate, work.signal);
216
+ if (settled) throw new Error('[command-code-oauth] login is no longer active');
217
+ const saved = saveTokens(tokens);
218
+ finish(saved);
219
+ return saved;
220
+ };
221
+
222
+ const handleRequest = async (req, res) => {
223
+ const origin = req.headers.origin;
224
+ if (origin && origin !== STUDIO_ORIGIN) return reply(res, 403, { success: false, error: 'Forbidden origin' });
225
+ if (req.method === 'OPTIONS') return reply(res, 204, null);
226
+ let pathname;
227
+ try {
228
+ pathname = new URL(req.url || '/', 'http://127.0.0.1').pathname;
229
+ } catch {
230
+ return reply(res, 400, { success: false, error: 'Bad request' });
231
+ }
232
+ if (pathname !== CALLBACK_PATH || settled) {
233
+ return reply(res, 404, { success: false, error: 'Not found' });
234
+ }
235
+ if (req.method !== 'POST') return reply(res, 405, { success: false, error: 'Method not allowed' });
236
+ // Anything that is not this login's well-formed callback is refused and
237
+ // the login keeps waiting.
238
+ let doc;
239
+ try {
240
+ doc = JSON.parse(await readBody(req));
241
+ } catch {
242
+ return reply(res, 400, { success: false, error: 'Invalid JSON body' });
243
+ }
244
+ let candidate;
245
+ try {
246
+ candidate = parseCallbackPayload(doc, state);
247
+ } catch (err) {
248
+ return reply(res, 400, { success: false, error: String(err.message).replace(/^\[command-code-oauth\]\s*/, '') });
249
+ }
250
+ replying.add(req.socket);
251
+ try {
252
+ await settle(candidate);
253
+ reply(res, 200, { success: true });
254
+ } catch (err) {
255
+ reply(res, 400, { success: false, error: 'API key validation failed' });
256
+ finish(null, err instanceof Error ? err : new Error(String(err)));
257
+ }
258
+ };
259
+
260
+ // No request can fail outside this handler; once the login is over, a
261
+ // connection closes as soon as its reply is flushed.
262
+ const onRequest = (req, res) => {
263
+ res.on('finish', () => {
264
+ replying.delete(req.socket);
265
+ if (settled) req.socket.destroy();
266
+ });
267
+ handleRequest(req, res).catch(() => {
268
+ replying.delete(req.socket);
269
+ if (res.headersSent) res.destroy();
270
+ else reply(res, 400, { success: false, error: 'Bad request' });
271
+ });
272
+ };
273
+ const trackSockets = (server) => {
274
+ server.on('connection', (socket) => {
275
+ if (settled) {
276
+ socket.destroy();
277
+ return;
278
+ }
279
+ sockets.add(socket);
280
+ socket.on('close', () => {
281
+ sockets.delete(socket);
282
+ replying.delete(socket);
283
+ });
284
+ });
285
+ return server;
286
+ };
287
+
288
+ // The advertised callback is 127.0.0.1; ::1 is bound on the same port when
289
+ // possible because `localhost` may resolve to it first.
290
+ let primary = trackSockets(createServer(onRequest));
291
+ try {
292
+ try {
293
+ await listenOn(primary, preferredPort, '127.0.0.1');
294
+ } catch (err) {
295
+ // The fixed port is taken (another login, another app): any free port.
296
+ if (!preferredPort) throw err;
297
+ primary = trackSockets(createServer(onRequest));
298
+ await listenOn(primary, 0, '127.0.0.1');
299
+ }
300
+ } catch (err) {
301
+ finish(null, err);
302
+ throw err;
303
+ }
304
+ const port = primary.address().port;
305
+ servers.push(primary);
306
+ const secondary = trackSockets(createServer(onRequest));
307
+ try {
308
+ await listenOn(secondary, port, '::1');
309
+ servers.push(secondary);
310
+ } catch {
311
+ /* no IPv6 loopback: the IPv4 listener is enough */
312
+ }
313
+ for (const server of servers) {
314
+ server.on('error', (err) =>
315
+ finish(null, new Error(`[command-code-oauth] callback server failed: ${err?.message || err}`))
316
+ );
317
+ }
318
+
319
+ const url = authorizeUrl(port, state);
320
+ if (printUrl) process.stderr.write(`\n[command-code-oauth] Open this URL to sign in:\n${url}\n\n`);
321
+ if (openBrowser) await openLoginUrl(PROVIDER_ID, url);
322
+
323
+ return {
324
+ provider: PROVIDER_ID,
325
+ url,
326
+ manualUrl: null,
327
+ port,
328
+ waitForCallback,
329
+ completeCode: async (input) => settle(parsePastedInput(input, state)),
330
+ cancel: () => finish(null),
331
+ };
332
+ }
333
+
334
+ export async function loginOAuth() {
335
+ const login = await beginOAuthLogin({ printUrl: true });
336
+ return await login.waitForCallback;
337
+ }