@bitkyc08/opencodex 2.67.0 → 2.68.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 (257) hide show
  1. package/bin/ocx.mjs +3 -0
  2. package/gui/dist/assets/App-DCBismRi.css +1 -0
  3. package/gui/dist/assets/App-mpsibsFj.js +51 -0
  4. package/gui/dist/assets/Tray-Bn8WhKBH.js +1 -0
  5. package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
  6. package/gui/dist/assets/index-kUE8tuAK.js +86 -0
  7. package/gui/dist/assets/quota-summary-DL-bNuNL.js +1 -0
  8. package/gui/dist/index.html +21 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-image-guard.ts +13 -1
  11. package/src/adapters/anthropic-image-normalize.ts +3 -2
  12. package/src/adapters/anthropic-output-schema.ts +36 -0
  13. package/src/adapters/anthropic.ts +10 -2
  14. package/src/adapters/base.ts +7 -0
  15. package/src/adapters/codebuddy/live-models.ts +180 -0
  16. package/src/adapters/coding-agent/protocol.ts +91 -12
  17. package/src/adapters/coding-agent/turn.ts +32 -27
  18. package/src/adapters/devin/cloud-direct/index.ts +1 -0
  19. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
  20. package/src/adapters/devin.ts +9 -6
  21. package/src/adapters/google-http.ts +4 -4
  22. package/src/adapters/google.ts +93 -3
  23. package/src/adapters/kiro/adapter.ts +6 -2
  24. package/src/adapters/kiro/stream.ts +12 -2
  25. package/src/adapters/kiro/usage.ts +3 -1
  26. package/src/adapters/kiro-errors.ts +11 -1
  27. package/src/adapters/kiro-events.ts +27 -1
  28. package/src/adapters/kiro-refusal.ts +30 -0
  29. package/src/adapters/kiro-retry.ts +71 -31
  30. package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
  31. package/src/adapters/openai-chat/messages.ts +31 -4
  32. package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
  33. package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
  34. package/src/adapters/openai-chat/tool-schema.ts +6 -2
  35. package/src/adapters/openai-chat.ts +2 -2
  36. package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
  37. package/src/adapters/openai-responses/passthrough.ts +9 -3
  38. package/src/adapters/opencode-go-additional-tools.ts +19 -10
  39. package/src/adapters/physical-send.ts +10 -3
  40. package/src/adapters/registry.ts +2 -1
  41. package/src/adapters/responses-tool-schema.ts +31 -3
  42. package/src/adapters/run-turn-queue.ts +63 -23
  43. package/src/adapters/unique-tool-call-ids.ts +63 -0
  44. package/src/adapters/xai-web-search.ts +32 -2
  45. package/src/bridge/sse.ts +14 -2
  46. package/src/chat/inbound.ts +20 -6
  47. package/src/claude/agents-inject.ts +4 -0
  48. package/src/claude/desktop-3p.ts +53 -13
  49. package/src/claude/desktop-profile.ts +41 -4
  50. package/src/claude/inbound-model-options.ts +14 -2
  51. package/src/claude/inbound.ts +1 -1
  52. package/src/claude/intercept/connect-proxy.ts +17 -1
  53. package/src/claude/intercept/local-ca.ts +7 -1
  54. package/src/claude/message-threads.ts +28 -0
  55. package/src/cli/account-api.ts +13 -0
  56. package/src/cli/account-auth.ts +49 -2
  57. package/src/cli/account-extended.ts +4 -2
  58. package/src/cli/account.ts +4 -2
  59. package/src/cli/capabilities.ts +17 -0
  60. package/src/cli/claude.ts +3 -1
  61. package/src/cli/dispatch.ts +62 -5
  62. package/src/cli/index.ts +84 -79
  63. package/src/cli/minimax.ts +4 -2
  64. package/src/cli/opencode.ts +6 -1
  65. package/src/cli/restart-handoff.ts +110 -0
  66. package/src/cli/status.ts +51 -0
  67. package/src/client/connect.ts +34 -15
  68. package/src/client/link-ingress.ts +102 -0
  69. package/src/client/link-join.ts +37 -16
  70. package/src/client/link-relay.ts +228 -45
  71. package/src/client/link-state.ts +54 -0
  72. package/src/client/link-status.ts +39 -0
  73. package/src/client/link-teardown.ts +2 -2
  74. package/src/client/link-tunnel.ts +615 -86
  75. package/src/client/machine-api.ts +10 -4
  76. package/src/client/machine-listener.ts +57 -10
  77. package/src/client/runtime.ts +210 -35
  78. package/src/clients/config-export/constants.ts +1 -12
  79. package/src/clients/config-export/contracts.ts +2 -0
  80. package/src/clients/config-export/model-metadata.ts +9 -3
  81. package/src/clients/config-export/omp.ts +1 -1
  82. package/src/clients/config-export/zcode-store.ts +2 -2
  83. package/src/clients/config-export.ts +36 -3
  84. package/src/codex/app-server-processes.ts +32 -0
  85. package/src/codex/app-server-restart-service.ts +20 -3
  86. package/src/codex/catalog/access-programs.ts +50 -0
  87. package/src/codex/catalog/build-entries.ts +13 -0
  88. package/src/codex/catalog/derive-entry.ts +1 -0
  89. package/src/codex/catalog/model-hints.ts +11 -0
  90. package/src/codex/catalog/parsing.ts +6 -2
  91. package/src/codex/catalog/provider-models.ts +81 -0
  92. package/src/codex/catalog/retained-sync.ts +3 -0
  93. package/src/codex/codex-write-lock.ts +2 -2
  94. package/src/codex/convergence.ts +2 -0
  95. package/src/codex/desired-state.ts +14 -4
  96. package/src/codex/home.ts +21 -3
  97. package/src/codex/inject/restore.ts +17 -0
  98. package/src/codex/inject/routing-classify.ts +3 -2
  99. package/src/codex/inject-coordination.ts +4 -1
  100. package/src/codex/inject.ts +8 -1
  101. package/src/codex/journal.ts +6 -2
  102. package/src/codex/management-convergence.ts +9 -0
  103. package/src/codex/model-entitlements.ts +28 -5
  104. package/src/codex/routing/idle-window.ts +58 -0
  105. package/src/codex/routing-drift.ts +134 -0
  106. package/src/codex/routing-healer.ts +419 -0
  107. package/src/codex/routing.ts +10 -0
  108. package/src/codex/runtime.ts +188 -57
  109. package/src/codex/sibling-handoff.ts +71 -0
  110. package/src/codex/sibling-start.ts +139 -0
  111. package/src/codex/sync.ts +5 -3
  112. package/src/combos/failover.ts +32 -5
  113. package/src/combos/request.ts +11 -3
  114. package/src/combos/reset-window.ts +10 -5
  115. package/src/combos/resolve.ts +36 -6
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/load-degrade.ts +2 -0
  118. package/src/config/paths.ts +7 -1
  119. package/src/config/process-state.ts +10 -1
  120. package/src/config/schema/compaction-recovery.ts +15 -0
  121. package/src/config/schema/config-schema.ts +2 -0
  122. package/src/config/schema/leaf-validators.ts +9 -3
  123. package/src/generated/compatibility-version.json +360 -204
  124. package/src/images/loop.ts +47 -32
  125. package/src/integrations/catalog-refresh.ts +4 -0
  126. package/src/integrations/omp-yaml-source.ts +1 -0
  127. package/src/lib/claude-request-projection.ts +101 -0
  128. package/src/lib/local-account-switch-capability.ts +48 -0
  129. package/src/lib/local-upstream.ts +146 -0
  130. package/src/lib/package-tree-integrity.ts +25 -3
  131. package/src/lib/package-tree-retarget.ts +130 -0
  132. package/src/lib/process-control.ts +4 -1
  133. package/src/lib/system-restart-contract.ts +106 -0
  134. package/src/lib/upstream-retry.ts +57 -1
  135. package/src/link/ports.ts +9 -0
  136. package/src/link/ssh-argv.ts +16 -0
  137. package/src/link/ssh-runner.ts +70 -2
  138. package/src/link/tunnel-state.ts +58 -9
  139. package/src/oauth/account-quota-rank.ts +29 -18
  140. package/src/oauth/generic-account-failover.ts +179 -22
  141. package/src/oauth/index.ts +17 -4
  142. package/src/oauth/kiro-account-load.ts +107 -0
  143. package/src/oauth/kiro-device-login.ts +312 -0
  144. package/src/oauth/kiro-terminal-failover.ts +28 -0
  145. package/src/oauth/login-flow-state.ts +7 -1
  146. package/src/oauth/pool-settings-capability.ts +23 -14
  147. package/src/oauth/store.ts +56 -3
  148. package/src/oauth/types.ts +4 -0
  149. package/src/plugins/loader.ts +350 -0
  150. package/src/plugins/upstream-hooks.ts +119 -0
  151. package/src/protocols/encoders/adapter-events.ts +9 -2
  152. package/src/providers/account-quota-disk.ts +42 -4
  153. package/src/providers/codebuddy-models.ts +6 -2
  154. package/src/providers/command-code-efforts.ts +33 -10
  155. package/src/providers/kiro-account-state-disk.ts +44 -0
  156. package/src/providers/kiro-model-catalog.ts +162 -0
  157. package/src/providers/kiro-models.ts +5 -4
  158. package/src/providers/kiro-quota-metrics.ts +35 -0
  159. package/src/providers/kiro-usage.ts +118 -14
  160. package/src/providers/quota/account-cache.ts +43 -7
  161. package/src/providers/quota/antigravity.ts +8 -3
  162. package/src/providers/quota/kiro-account-probe.ts +12 -0
  163. package/src/providers/quota/vendor-probes-key.ts +31 -26
  164. package/src/providers/quota/vendor-probes-oauth.ts +16 -7
  165. package/src/providers/quota-types.ts +14 -0
  166. package/src/providers/quota.ts +21 -21
  167. package/src/providers/registry/entries-extended.ts +5 -3
  168. package/src/providers/request-pacing.ts +410 -12
  169. package/src/remote-control/workspace-codex-runtime.ts +4 -3
  170. package/src/responses/citation-markers.ts +132 -65
  171. package/src/responses/hosted-tool-policy.ts +14 -3
  172. package/src/responses/parser-content.ts +3 -2
  173. package/src/responses/parser.ts +4 -1
  174. package/src/responses/schema.ts +4 -0
  175. package/src/responses/visualization-directives.ts +182 -0
  176. package/src/server/chat-native-sse.ts +24 -12
  177. package/src/server/chat-native.ts +137 -68
  178. package/src/server/claude-messages.ts +147 -12
  179. package/src/server/index/link-listener.ts +7 -0
  180. package/src/server/index/optional-listeners.ts +4 -1
  181. package/src/server/index/package-tree-guard.ts +35 -16
  182. package/src/server/index/serve-options.ts +4 -2
  183. package/src/server/index/startup-warnings.ts +6 -0
  184. package/src/server/index.ts +7 -7
  185. package/src/server/inference/client-encoder-delivery.ts +3 -0
  186. package/src/server/inference/context.ts +30 -2
  187. package/src/server/local-account-switch-auth.ts +79 -0
  188. package/src/server/management/agent-settings-routes.ts +11 -3
  189. package/src/server/management/config-routes.ts +28 -11
  190. package/src/server/management/context.ts +2 -0
  191. package/src/server/management/link-routes.ts +123 -39
  192. package/src/server/management/logs-usage-routes.ts +9 -42
  193. package/src/server/management/model-rows.ts +1 -0
  194. package/src/server/management/oauth-account-routes.ts +67 -12
  195. package/src/server/management/route-registry.ts +5 -5
  196. package/src/server/management/sibling-guard.ts +60 -0
  197. package/src/server/management/storage-log-guard-routes.ts +72 -6
  198. package/src/server/management/system-restart.ts +92 -135
  199. package/src/server/management/system-routes.ts +1 -1
  200. package/src/server/management-api.ts +31 -3
  201. package/src/server/management-auth.ts +3 -0
  202. package/src/server/port-reclaim.ts +111 -1
  203. package/src/server/proxy-liveness.ts +16 -0
  204. package/src/server/relay.ts +16 -15
  205. package/src/server/request-log-filter.ts +28 -27
  206. package/src/server/request-log.ts +7 -3
  207. package/src/server/request-metrics.ts +28 -0
  208. package/src/server/responses/adapter-continuation.ts +151 -21
  209. package/src/server/responses/adapter-delivery.ts +21 -7
  210. package/src/server/responses/adapter-dispatch.ts +239 -51
  211. package/src/server/responses/codex-ws-pool.ts +5 -4
  212. package/src/server/responses/codex-ws-request.ts +4 -1
  213. package/src/server/responses/compact.ts +8 -4
  214. package/src/server/responses/compaction-recovery-policy.ts +116 -0
  215. package/src/server/responses/compaction-recovery.ts +330 -0
  216. package/src/server/responses/core-combo-failure.ts +1 -0
  217. package/src/server/responses/core-combo.ts +5 -1
  218. package/src/server/responses/core-lifetime.ts +22 -0
  219. package/src/server/responses/core-options.ts +16 -1
  220. package/src/server/responses/core.ts +14 -4
  221. package/src/server/responses/empty-completion-guard.ts +2 -0
  222. package/src/server/responses/fetch-helpers.ts +82 -31
  223. package/src/server/responses/passthrough-delivery.ts +3 -2
  224. package/src/server/responses/passthrough-dispatch.ts +12 -2
  225. package/src/server/responses/passthrough-execution.ts +2 -2
  226. package/src/server/responses/request-send-budget.ts +21 -5
  227. package/src/server/responses/request-transport.ts +116 -18
  228. package/src/server/responses/reset-replay.ts +32 -0
  229. package/src/server/responses/run-turn-execution.ts +136 -19
  230. package/src/server/responses/sidecar-execution.ts +41 -8
  231. package/src/server/responses/terminal-guard.ts +2 -0
  232. package/src/server/responses/ws-upstream.ts +44 -4
  233. package/src/server/restart-replacement.ts +382 -0
  234. package/src/server/startup-health-cache.ts +23 -4
  235. package/src/server/stop-teardown.ts +35 -1
  236. package/src/server/system-env.ts +3 -0
  237. package/src/service/windows-taskxml.ts +11 -7
  238. package/src/service/windows-wrapper-exit.ts +8 -0
  239. package/src/stall-timeout.ts +35 -10
  240. package/src/storage/policy-job.ts +4 -0
  241. package/src/storage/scanner.ts +177 -29
  242. package/src/storage/storage-mutation-coordinator.ts +17 -0
  243. package/src/tray/windows-tray.ps1 +50 -23
  244. package/src/types/config.ts +4 -0
  245. package/src/types/provider.ts +5 -1
  246. package/src/types/request.ts +13 -2
  247. package/src/update/index.ts +3 -1
  248. package/src/update/job.ts +6 -2
  249. package/src/update/mise-launcher-target.ts +100 -0
  250. package/src/update/worker-launch.ts +46 -0
  251. package/src/usage/log.ts +2 -0
  252. package/src/web-search/loop.ts +5 -2
  253. package/gui/dist/assets/App-BJsT8Icc.css +0 -1
  254. package/gui/dist/assets/App-D3pNGiN4.js +0 -51
  255. package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
  256. package/gui/dist/assets/index-CouvvtMV.js +0 -86
  257. package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
@@ -14,7 +14,9 @@
14
14
  * (`oauth/anthropic-routing.ts` owns affinity and a fail-closed local-cli credential rule).
15
15
  * Both are excluded by `isGenericFailoverProvider`.
16
16
  */
17
- import { getAccountSet } from "./store";
17
+ import { credentialGeneration, getAccountSet } from "./store";
18
+ import { accountInFlight } from "./kiro-account-load";
19
+ import type { ProviderAccount } from "./types";
18
20
  import { getValidAccessSnapshotForAccount, type OAuthAccessSnapshot } from "./index";
19
21
  import {
20
22
  accountHeadroomPercent,
@@ -34,12 +36,25 @@ import {
34
36
  seedPoolRotationAccount,
35
37
  } from "./pool-kernel";
36
38
  import { parseRetryAfterMs } from "../combos/failover";
39
+ import type { KiroRefusalKind } from "../adapters/kiro-refusal";
40
+ import { kiroAccountEvidence } from "../providers/kiro-usage";
41
+ import { kiroEvidenceIdentity } from "../providers/kiro-account-state-disk";
42
+ import { kiroAccountSupportsModel } from "../providers/kiro-model-catalog";
43
+ import { ACCOUNT_QUOTA_TTL_MS } from "../providers/quota-wire";
37
44
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
38
45
  import type { OcxConfig, OcxProviderConfig } from "../types";
39
46
 
40
47
  /** Cap same-request rotations so a short Retry-After cannot spin. Mirrors the Anthropic bound. */
41
48
  export const GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST = 3;
42
49
 
50
+ /**
51
+ * Most accounts one request may fund and rotate through, whatever the roster size. Each funded
52
+ * account gets the normal transient ladder, so this fixes the default ingress ceiling at
53
+ * 6 × TRANSIENT_RETRY_MAX_ATTEMPTS physical sends: enrolling more accounts cannot turn one request
54
+ * into a pool-wide 429 storm.
55
+ */
56
+ export const GENERIC_OAUTH_MAX_ACCOUNTS_PER_REQUEST = 6;
57
+
43
58
  const DEFAULT_COOLDOWN_MS = 60_000;
44
59
 
45
60
  /**
@@ -68,7 +83,8 @@ const EXCLUDED_PROVIDERS = new Set(["openai", "anthropic"]);
68
83
 
69
84
  interface AccountHealth {
70
85
  cooldownUntil: number;
71
- cooldownSource: "retry-after" | "default";
86
+ cooldownSource: "retry-after" | "default" | "kiro-suspension";
87
+ identity?: string;
72
88
  }
73
89
 
74
90
  interface PresenceEntry {
@@ -85,9 +101,26 @@ const presence = new Map<string, PresenceEntry>();
85
101
  const healthKey = (provider: string, accountId: string, family?: QuotaModelFamily) =>
86
102
  family ? `${provider}\u0000${accountId}\u0000${family}` : `${provider}\u0000${accountId}`;
87
103
 
104
+ export function quarantineKiroSuspendedAccount(accountId: string, generation?: string, now = Date.now()): void {
105
+ const live = getAccountSet("kiro")?.accounts.find(row => row.id === accountId);
106
+ if (!live || (generation && credentialGeneration(live.credential) !== generation)) return;
107
+ health.set(healthKey("kiro", accountId), {
108
+ cooldownUntil: now + 24 * 60 * 60_000, cooldownSource: "kiro-suspension",
109
+ identity: kiroEvidenceIdentity(live),
110
+ });
111
+ sweepExpiredOnWrite(now);
112
+ }
113
+
88
114
  function isCooled(provider: string, accountId: string, now: number, family?: QuotaModelFamily): boolean {
89
115
  const entry = health.get(healthKey(provider, accountId, family));
90
116
  if (!entry) return false;
117
+ if (provider === "kiro" && entry.cooldownSource === "kiro-suspension") {
118
+ const live = getAccountSet("kiro")?.accounts.find(row => row.id === accountId);
119
+ if (!live || entry.identity !== kiroEvidenceIdentity(live)) {
120
+ health.delete(healthKey(provider, accountId, family));
121
+ return false;
122
+ }
123
+ }
91
124
  if (entry.cooldownUntil <= now) {
92
125
  health.delete(healthKey(provider, accountId, family));
93
126
  return false;
@@ -95,6 +128,22 @@ function isCooled(provider: string, accountId: string, now: number, family?: Quo
95
128
  return true;
96
129
  }
97
130
 
131
+ export type KiroSkipReason = "needs_reauth" | "suspended" | "cooldown" | "quota_exhausted";
132
+
133
+ /** Eligibility for automatic alternatives; an active singleton can still send. */
134
+ export function kiroAutoSelection(
135
+ account: ProviderAccount, now = Date.now(),
136
+ ): { autoSelectable: boolean; skipReason?: KiroSkipReason } {
137
+ if (account.needsReauth === true) return { autoSelectable: false, skipReason: "needs_reauth" };
138
+ const cooled = isCooled("kiro", account.id, now);
139
+ if (cooled && health.get(healthKey("kiro", account.id))?.cooldownSource === "kiro-suspension")
140
+ return { autoSelectable: false, skipReason: "suspended" };
141
+ if (kiroAccountEvidence(account, now).exhausted === true)
142
+ return { autoSelectable: false, skipReason: "quota_exhausted" };
143
+ if (cooled) return { autoSelectable: false, skipReason: "cooldown" };
144
+ return { autoSelectable: true };
145
+ }
146
+
98
147
  /** True when this provider participates in generic rotation at all. */
99
148
  export function isGenericFailoverProvider(providerName: string, provider: OcxProviderConfig): boolean {
100
149
  return provider.authMode === "oauth" && !EXCLUDED_PROVIDERS.has(providerName);
@@ -112,7 +161,10 @@ function eligibleAccountCount(providerName: string, now: number): number {
112
161
  const cached = presence.get(providerName);
113
162
  if (cached && now >= cached.readAt && now - cached.readAt < PRESENCE_CACHE_TTL_MS) return cached.eligible;
114
163
  const set = getAccountSet(providerName);
115
- const eligible = set ? set.accounts.filter(account => account.needsReauth !== true).length : 0;
164
+ // Kiro terminal refresh marks the just-refused account needsReauth before the alternate
165
+ // selector runs. Both stored logins still express consent to recover through the survivor.
166
+ const eligible = set ? (providerName === "kiro" ? set.accounts.length
167
+ : set.accounts.filter(account => account.needsReauth !== true).length) : 0;
116
168
  presence.set(providerName, { eligible, readAt: now });
117
169
  return eligible;
118
170
  }
@@ -163,7 +215,7 @@ export function isGenericOAuthFailoverEnabled(
163
215
  * turns it off only when the provider has no override. A malformed value falls through rather
164
216
  * than taking a provider out of service.
165
217
  */
166
- function isProactivePreferenceEnabled(config: OcxConfig, providerName: string, now: number): boolean {
218
+ export function isProactivePreferenceEnabled(config: OcxConfig, providerName: string, now: number): boolean {
167
219
  const provider = config.providers?.[providerName];
168
220
  if (!provider || !isGenericFailoverProvider(providerName, provider)) return false;
169
221
  const perProvider = provider.oauthAccountFailover?.enabled;
@@ -181,6 +233,25 @@ export function eligibleFailoverAccounts(providerName: string, now = Date.now(),
181
233
  return eligibleIdsIn(getAccountSet(providerName), providerName, now, family);
182
234
  }
183
235
 
236
+ /** Model evidence only ranks accounts already eligible and below any configured cap. */
237
+ export function preferKiroModelSupport(
238
+ providerName: string, ids: string[], model?: string | null, config?: OcxConfig,
239
+ ): string[] {
240
+ if (providerName !== "kiro" || !model) return ids;
241
+ const cap = config?.providers?.kiro?.oauthAccountFailover?.maxConcurrentPerAccount;
242
+ const withRoom = typeof cap === "number" && Number.isInteger(cap) && cap >= 1 && cap <= 100
243
+ ? ids.filter(id => accountInFlight("kiro", id) < cap) : ids;
244
+ const positive = withRoom.filter(id => kiroAccountSupportsModel(id, model) === true);
245
+ return positive.length ? positive : ids;
246
+ }
247
+
248
+ function kiroModelListingHasRoom(config: OcxConfig, accountId: string, model: string): boolean {
249
+ if (kiroAccountSupportsModel(accountId, model) !== true) return false;
250
+ const cap = config.providers?.kiro?.oauthAccountFailover?.maxConcurrentPerAccount;
251
+ return !(typeof cap === "number" && Number.isInteger(cap) && cap >= 1 && cap <= 100)
252
+ || accountInFlight("kiro", accountId) < cap;
253
+ }
254
+
184
255
  function eligibleIdsIn(
185
256
  set: ReturnType<typeof getAccountSet>,
186
257
  providerName: string,
@@ -189,7 +260,8 @@ function eligibleIdsIn(
189
260
  ): string[] {
190
261
  if (!set) return [];
191
262
  return set.accounts
192
- .filter(account => account.needsReauth !== true && !isCooled(providerName, account.id, now, family))
263
+ .filter(account => providerName === "kiro" ? kiroAutoSelection(account, now).autoSelectable
264
+ : account.needsReauth !== true && !isCooled(providerName, account.id, now, family))
193
265
  .map(account => account.id);
194
266
  }
195
267
 
@@ -214,7 +286,7 @@ export function hasEligibleGenericOAuthFailoverTarget(
214
286
  }
215
287
 
216
288
  /** Generic pool strategies the kernel can actually run. `quota` IS the pre-kernel path. */
217
- type ActiveGenericStrategy = "round-robin" | "fill-first";
289
+ type ActiveGenericStrategy = "round-robin" | "fill-first" | "least-loaded";
218
290
 
219
291
  /** Matches the Codex and Anthropic pools; the DTO still reports `null` for "not stored". */
220
292
  const DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD = 80;
@@ -230,6 +302,7 @@ const DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD = 80;
230
302
  function activeGenericStrategy(config: OcxConfig, providerName: string): ActiveGenericStrategy | null {
231
303
  if (config.pool?.kernel !== true) return null;
232
304
  const raw = config.providers?.[providerName]?.oauthAccountFailover?.strategy;
305
+ if (providerName === "kiro" && raw === "least-loaded") return raw;
233
306
  return raw === "round-robin" || raw === "fill-first" ? raw : null;
234
307
  }
235
308
 
@@ -260,9 +333,9 @@ function stableGenericRoster(providerName: string): string[] {
260
333
  * statement about observed usage, and treating "no observation" as "spent" would evacuate every
261
334
  * quota-less provider off its active account on the very first request.
262
335
  */
263
- function isOverAutoSwitchThreshold(providerName: string, accountId: string, threshold: number, requestedModelId?: string | null): boolean {
336
+ function isOverAutoSwitchThreshold(providerName: string, accountId: string, threshold: number, requestedModelId?: string | null, account?: ProviderAccount): boolean {
264
337
  if (threshold <= 0) return false;
265
- const headroom = accountHeadroomPercent(providerName, accountId, requestedModelId);
338
+ const headroom = accountHeadroomPercent(providerName, accountId, requestedModelId, account);
266
339
  if (headroom === null) return false;
267
340
  return 100 - headroom >= threshold;
268
341
  }
@@ -277,16 +350,18 @@ function pickFillFirstGenericAccount(
277
350
  activeId: string | undefined,
278
351
  now: number,
279
352
  requestedModelId?: string | null,
353
+ accountRows?: ReadonlyMap<string, ProviderAccount>,
280
354
  ): string | null {
281
355
  const stableAll = stableGenericRoster(providerName);
282
356
  if (stableAll.length < 2) return null;
283
357
  const family = classifyModelFamilyForQuota(providerName, requestedModelId);
284
- const eligible = new Set(eligibleFailoverAccounts(providerName, now, family));
358
+ const eligible = new Set(preferKiroModelSupport(providerName,
359
+ eligibleFailoverAccounts(providerName, now, family), requestedModelId, config));
285
360
  const stored = config.providers?.[providerName]?.oauthAccountFailover?.autoSwitchThreshold;
286
361
  const threshold = typeof stored === "number" && Number.isInteger(stored) && stored >= 0 && stored <= 100
287
362
  ? stored
288
363
  : DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD;
289
- if (activeId && eligible.has(activeId) && !isOverAutoSwitchThreshold(providerName, activeId, threshold, requestedModelId)) {
364
+ if (activeId && eligible.has(activeId) && !isOverAutoSwitchThreshold(providerName, activeId, threshold, requestedModelId, accountRows?.get(activeId))) {
290
365
  return null;
291
366
  }
292
367
  const start = activeId ? stableAll.indexOf(activeId) : -1;
@@ -343,6 +418,22 @@ export function rotateGenericOAuthAccountOn429(
343
418
  now = Date.now(),
344
419
  requestedModelId?: string | null,
345
420
  ): string | null {
421
+ return rotateGenericOAuthAccountOnRefusal(config, providerName, failedAccountId, "rate",
422
+ retryAfterHeader, now, requestedModelId);
423
+ }
424
+
425
+ /** Kiro adds account-scoped refusal classes; all other providers retain their 429 policy. */
426
+ export function rotateGenericOAuthAccountOnRefusal(
427
+ config: OcxConfig,
428
+ providerName: string,
429
+ failedAccountId: string,
430
+ kind: KiroRefusalKind,
431
+ retryAfterHeader: string | null | undefined,
432
+ now = Date.now(),
433
+ requestedModelId?: string | null,
434
+ monthlyCooldownMs?: number,
435
+ ): string | null {
436
+ if (kind === "other" || (providerName !== "kiro" && kind !== "rate")) return null;
346
437
  if (!isGenericOAuthFailoverEnabled(config, providerName)) return null;
347
438
  const set = getAccountSet(providerName);
348
439
  // A single stored account has nowhere to go; rotating to itself would just replay the 429.
@@ -351,14 +442,22 @@ export function rotateGenericOAuthAccountOn429(
351
442
  // `preserveServerDelay` keeps the delay the server actually stated, bounded by the parser's
352
443
  // one-day ceiling, exactly as the combo path does. Truncating it locally only guarantees a
353
444
  // second 429 on an account we were told to leave alone.
354
- const parsed = parseRetryAfterMs(retryAfterHeader, now, { preserveImmediate: true, preserveServerDelay: true });
445
+ const parsed = kind === "rate"
446
+ ? parseRetryAfterMs(retryAfterHeader, now, { preserveImmediate: true, preserveServerDelay: true })
447
+ : undefined;
355
448
  // An account whose allowance is provably spent gets a reset-aligned cooldown instead of
356
449
  // the default minute: retrying it every 60s until the window rolls over is pure waste.
357
450
  // A Retry-After from upstream still wins — it is the server's own instruction.
358
- const exhausted = parsed === undefined ? exhaustedCooldownMs(providerName, failedAccountId, now) : null;
359
- const cooldownMs = exhausted ?? parsed ?? DEFAULT_COOLDOWN_MS;
451
+ const exhausted = parsed === undefined && providerName !== "kiro"
452
+ ? exhaustedCooldownMs(providerName, failedAccountId, now, set.accounts.find(account => account.id === failedAccountId)) : null;
453
+ const cooldownMs = kind === "suspended" ? 24 * 60 * 60_000
454
+ : kind === "monthly_quota" ? (monthlyCooldownMs ?? ACCOUNT_QUOTA_TTL_MS)
455
+ : providerName === "kiro" ? (parsed ?? 10_000)
456
+ : (exhausted ?? parsed ?? DEFAULT_COOLDOWN_MS);
360
457
  const family = classifyModelFamilyForQuota(providerName, requestedModelId);
361
- health.set(healthKey(providerName, failedAccountId, family), {
458
+ // Suspension is recorded by the caller with the sent generation before the quorum check.
459
+ // Rewriting it here would lose its login-identity fence.
460
+ if (kind !== "suspended") health.set(healthKey(providerName, failedAccountId, providerName === "kiro" ? undefined : family), {
362
461
  cooldownUntil: now + cooldownMs,
363
462
  cooldownSource: parsed ? "retry-after" : "default",
364
463
  });
@@ -375,12 +474,21 @@ export function rotateGenericOAuthAccountOn429(
375
474
  const order = set.accounts.map(account => account.id);
376
475
  const start = order.indexOf(failedAccountId);
377
476
  const ring = start >= 0 ? [...order.slice(start + 1), ...order.slice(0, start)] : order;
378
- const candidates = ring.filter(id => id !== failedAccountId && eligible.includes(id));
477
+ let candidates = ring.filter(id => id !== failedAccountId && eligible.includes(id));
379
478
  if (candidates.length === 0) return null;
479
+ const cap = providerName === "kiro" ? config.providers?.kiro?.oauthAccountFailover?.maxConcurrentPerAccount : undefined;
480
+ if (typeof cap === "number" && Number.isInteger(cap) && cap >= 1 && cap <= 100) {
481
+ const withRoom = candidates.filter(id => accountInFlight("kiro", id) < cap);
482
+ if (withRoom.length > 0) candidates = withRoom;
483
+ }
484
+ candidates = preferKiroModelSupport(providerName, candidates, requestedModelId, config);
380
485
  // The 429 path branches too. Leaving it on the quota ranking would make a configured
381
486
  // strategy inert in practice the moment anything actually failed, which is the case the
382
487
  // operator chose the strategy for.
383
488
  const strategy = activeGenericStrategy(config, providerName);
489
+ if (strategy === "least-loaded" && isProactivePreferenceEnabled(config, "kiro", now)) {
490
+ return candidates.sort((a, b) => accountInFlight("kiro", a) - accountInFlight("kiro", b))[0] ?? null;
491
+ }
384
492
  if (strategy === "round-robin") {
385
493
  // PICK here, not peek: the failure already happened and this answer is the one being used,
386
494
  // so the ring genuinely advances.
@@ -403,7 +511,31 @@ export function rotateGenericOAuthAccountOn429(
403
511
  }
404
512
  // With no quota evidence this returns the ring untouched, so providers without
405
513
  // per-account quota keep exactly the traversal they have today.
406
- return rankAccountsByHeadroom(providerName, candidates, requestedModelId)[0] ?? null;
514
+ return rankAccountsByHeadroom(providerName, candidates, requestedModelId,
515
+ new Map(set.accounts.map(account => [account.id, account])))[0] ?? null;
516
+ }
517
+
518
+ /** Known dead-account exclusion is a proactive choice with narrow-over-broad precedence. */
519
+ export function refusalAwareInitialKiroAccount(
520
+ config: OcxConfig, activeId: string, now = Date.now(), requestedModelId?: string | null,
521
+ ): string | null {
522
+ if (!isProactivePreferenceEnabled(config, "kiro", now)) return null;
523
+ const set = getAccountSet("kiro");
524
+ if (!set || set.accounts.length < 2) return null;
525
+ const active = set.accounts.find(account => account.id === activeId);
526
+ if (!active) return null;
527
+ const eligibleIds = eligibleIdsIn(set, "kiro", now, classifyModelFamilyForQuota("kiro", requestedModelId));
528
+ const preferred = preferKiroModelSupport("kiro", eligibleIds, requestedModelId, config);
529
+ const activeLacks = requestedModelId && kiroAccountSupportsModel(activeId, requestedModelId) === false
530
+ && preferred.some(id => id !== activeId && kiroModelListingHasRoom(config, id, requestedModelId));
531
+ if (!activeLacks && !isCooled("kiro", activeId, now)
532
+ && kiroAccountEvidence(active, now).exhausted !== true) return null;
533
+ const eligible = new Set(preferred);
534
+ const start = set.accounts.findIndex(account => account.id === activeId);
535
+ for (const account of [...set.accounts.slice(start + 1), ...set.accounts.slice(0, start)]) {
536
+ if (eligible.has(account.id)) return account.id;
537
+ }
538
+ return null;
407
539
  }
408
540
 
409
541
  /**
@@ -446,18 +578,41 @@ export function preferredInitialAccount(
446
578
  const selected = getAccountSet(providerName);
447
579
  if (!selected) return null;
448
580
  const active = selected.activeAccountId;
581
+ const accountRows = new Map(selected.accounts.map(account => [account.id, account]));
449
582
  const order = selected.accounts.filter(account => account.needsReauth !== true).map(account => account.id);
450
583
  if (order.length < 2) return null;
451
584
 
585
+ const modelEligible = preferKiroModelSupport(providerName,
586
+ eligibleFailoverAccounts(providerName, now, classifyModelFamilyForQuota(providerName, requestedModelId)),
587
+ requestedModelId, config);
588
+ const activeLacks = providerName === "kiro" && !!active && !!requestedModelId
589
+ && kiroAccountSupportsModel(active, requestedModelId) === false;
590
+ const listingSibling = activeLacks
591
+ ? modelEligible.find(id => id !== active && kiroModelListingHasRoom(config, id, requestedModelId!))
592
+ : undefined;
593
+ if (listingSibling) return listingSibling;
594
+
452
595
  // A configured strategy answers this question itself. Both guards below exist to protect the
453
596
  // QUOTA answer, and both are fatal to the other two: hasHeadroomEvidence refuses every
454
597
  // provider with no quota data, which is exactly where round-robin is the point, and the
455
598
  // healthy-active return fires before autoSwitchThreshold can ever be read, so fill-first
456
599
  // would never reach its own test. Cooldowns and reauth are still honoured inside each pick.
457
600
  const strategy = activeGenericStrategy(config, providerName);
601
+ if (strategy === "least-loaded" && providerName === "kiro") {
602
+ const family = classifyModelFamilyForQuota(providerName, requestedModelId);
603
+ let candidates = eligibleFailoverAccounts(providerName, now, family);
604
+ const cap = config.providers?.kiro?.oauthAccountFailover?.maxConcurrentPerAccount;
605
+ if (typeof cap === "number" && Number.isInteger(cap) && cap >= 1 && cap <= 100) {
606
+ candidates = candidates.filter(id => accountInFlight("kiro", id) < cap);
607
+ }
608
+ candidates = preferKiroModelSupport(providerName, candidates, requestedModelId, config);
609
+ const picked = candidates.sort((a, b) => accountInFlight("kiro", a) - accountInFlight("kiro", b))[0];
610
+ return picked && picked !== active ? picked : null;
611
+ }
458
612
  if (strategy === "round-robin") {
459
613
  const family = classifyModelFamilyForQuota(providerName, requestedModelId);
460
- const eligibleNow = eligibleFailoverAccounts(providerName, now, family);
614
+ const eligibleNow = preferKiroModelSupport(providerName,
615
+ eligibleFailoverAccounts(providerName, now, family), requestedModelId, config);
461
616
  if (eligibleNow.length === 0) return null;
462
617
  // PEEK, not pick: this proposal is discardable, and advancing the ring for an account the
463
618
  // resolver then rejects would skip a turn for nothing. noteGenericPoolSelection commits.
@@ -469,26 +624,28 @@ export function preferredInitialAccount(
469
624
  return picked && picked !== active ? picked : null;
470
625
  }
471
626
  if (strategy === "fill-first") {
472
- const picked = pickFillFirstGenericAccount(config, providerName, active, now, requestedModelId);
627
+ const picked = pickFillFirstGenericAccount(config, providerName, active, now, requestedModelId, accountRows);
473
628
  return picked && picked !== active ? picked : null;
474
629
  }
475
630
 
476
631
  const activeRow = selected.accounts.find(account => account.id === active);
477
632
  if (activeRow && activeRow.needsReauth !== true
478
633
  && !isCooled(providerName, activeRow.id, now, classifyModelFamilyForQuota(providerName, requestedModelId))
479
- && !isAccountQuotaExhausted(providerName, activeRow.id, requestedModelId)) return null;
634
+ && !isAccountQuotaExhausted(providerName, activeRow.id, requestedModelId, activeRow)) return null;
480
635
 
481
636
  // Evidence is required BEFORE eligibility narrows the field. Without this, a provider
482
637
  // with no quota data at all could still be redirected: cool the active account with a
483
638
  // 429 and the eligible list collapses to one candidate, which any ranking returns
484
639
  // unchanged — an answer that looks ranked but was never measured. The no-op guarantee
485
640
  // for quota-less providers has to be checked on the full roster.
486
- if (!hasHeadroomEvidence(providerName, order, requestedModelId)) return null;
641
+ if (!hasHeadroomEvidence(providerName, order, requestedModelId, accountRows)) return null;
487
642
 
488
643
  // Cooldowns are respected here, unlike in the presence count: this picks the account to
489
644
  // send to right now, and one inside its 429 window is the single candidate we hold
490
645
  // positive evidence against.
491
- const eligible = order.filter(id => !isCooled(providerName, id, now, classifyModelFamilyForQuota(providerName, requestedModelId)));
646
+ const eligible = preferKiroModelSupport(providerName,
647
+ order.filter(id => !isCooled(providerName, id, now, classifyModelFamilyForQuota(providerName, requestedModelId))),
648
+ requestedModelId, config);
492
649
  if (eligible.length === 0) return null;
493
650
 
494
651
  // Start the ring at the active account so an unranked outcome reproduces today's choice.
@@ -497,7 +654,7 @@ export function preferredInitialAccount(
497
654
  const candidates = ring.filter(id => eligible.includes(id));
498
655
  if (candidates.length === 0) return null;
499
656
 
500
- const best = rankAccountsByHeadroom(providerName, candidates, requestedModelId)[0] ?? null;
657
+ const best = rankAccountsByHeadroom(providerName, candidates, requestedModelId, accountRows)[0] ?? null;
501
658
  // Nothing to do when the ranking agrees with the account we would have used anyway.
502
659
  //
503
660
  // A proposal still needs guarded selection commit after credential resolution: a
@@ -53,7 +53,7 @@ import { resolveProviderTransport } from "../providers/xai-transport";
53
53
  import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
54
54
  import { logOAuthEvent } from "./log";
55
55
  import { captureConfigGeneration, sweepExpiredOnWrite } from "../lib/state-store-sweeper";
56
- import { clearManualCodeSlot, ensureManualCodeSlot, kiroLoginSettling, loginAbort, loginState, waitForManualLoginCode } from "./login-flow-state";
56
+ import { clearManualCodeSlot, ensureManualCodeSlot, kiroLoginSettling, loginAbort, loginState, waitForManualLoginCode, type OAuthLoginHint } from "./login-flow-state";
57
57
  export { reconcileOAuthFlowState, submitManualLoginCode } from "./login-flow-state";
58
58
  import { randomUUID } from "node:crypto";
59
59
  export {
@@ -1626,6 +1626,10 @@ export async function runLogin(
1626
1626
  const loginProviderConfig = preflightConfig
1627
1627
  ? (def.resolveProviderConfig?.(preflightConfig) ?? preflightConfig.providers[provider] ?? def.providerConfig)
1628
1628
  : def.providerConfig;
1629
+ if (provider === "kiro" && opts?.reauthAccountId
1630
+ && getAccountSet("kiro")?.accounts.some(a => a.id === opts.reauthAccountId && a.loginOrigin === "kiro-device")) {
1631
+ throw new Error("Native Kiro device accounts cannot be reauthenticated with kiro-cli; remove and re-add the account.");
1632
+ }
1629
1633
  const rawCred = await def.login(ctrl, opts, loginProviderConfig);
1630
1634
  const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" };
1631
1635
  const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction;
@@ -1653,6 +1657,7 @@ export async function runLogin(
1653
1657
  }
1654
1658
  await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, cred, {
1655
1659
  assertBeforePersist: deps.assertCurrentOwner,
1660
+ rotateLoginId: true,
1656
1661
  });
1657
1662
  } else {
1658
1663
  const saveOptions = {
@@ -1764,7 +1769,7 @@ export interface OAuthAccountSummary {
1764
1769
  * the config at its request boundary and resolves the policy there with `emailMaskingEnabled`.
1765
1770
  * The default masks, so every existing caller keeps today's behaviour.
1766
1771
  */
1767
- export function getLoginStatus(provider: string, maskEmails = true): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
1772
+ export function getLoginStatus(provider: string, maskEmails = true): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; hint?: OAuthLoginHint; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
1768
1773
  const cred = getCredential(provider);
1769
1774
  const st = loginState.get(provider);
1770
1775
  const set = getAccountSet(provider);
@@ -1795,6 +1800,7 @@ export function getLoginStatus(provider: string, maskEmails = true): { loggedIn:
1795
1800
  source: cred?.source,
1796
1801
  error: st?.error,
1797
1802
  done: st?.done ?? false,
1803
+ ...(st?.hint && !st.done ? { hint: { url: st.hint.url, instructions: st.hint.instructions, deviceCode: st.hint.deviceCode } } : {}),
1798
1804
  ...(set ? { activeAccountId: set.activeAccountId, accounts } : {}),
1799
1805
  };
1800
1806
  }
@@ -1849,8 +1855,15 @@ export async function startLoginFlow(
1849
1855
  let urlResolved = false;
1850
1856
  const ctrl: OAuthController = {
1851
1857
  onAuth: ({ url, instructions, deviceCode }) => {
1852
- urlResolved = true;
1853
- resolve({ url, instructions, deviceCode });
1858
+ if (abort.signal.aborted || loginAbort.get(provider)?.controller !== abort) return;
1859
+ // Device approval can fall back to manual input. Replace, never merge: the
1860
+ // previous device code must disappear when the provider changes the next step.
1861
+ const hint = { url, instructions, deviceCode };
1862
+ loginState.set(provider, { done: false, hint });
1863
+ if (!urlResolved) {
1864
+ urlResolved = true;
1865
+ resolve({ ...hint });
1866
+ }
1854
1867
  },
1855
1868
  onProgress: () => {},
1856
1869
  // GUI fallback when the browser cannot hit the loopback callback server.
@@ -0,0 +1,107 @@
1
+ /** Process-local serving capacity for Kiro OAuth accounts. */
2
+ export interface AccountLease {
3
+ readonly provider: string;
4
+ readonly accountId: string;
5
+ release(): void;
6
+ }
7
+
8
+ export interface AccountLeaseOptions {
9
+ maxConcurrentPerAccount?: number;
10
+ waitMs?: number;
11
+ signal?: AbortSignal;
12
+ }
13
+
14
+ export const KIRO_LEASE_MAX_MS = 15 * 60_000;
15
+ export const KIRO_ACCOUNT_WAIT_MS = 250;
16
+
17
+ interface LeaseRecord { acquiredAt: number; released: boolean }
18
+ interface Waiter { grant(now: number): "granted" | "blocked" | "expired" }
19
+ interface AccountState { records: Map<number, LeaseRecord>; waiters: Waiter[] }
20
+
21
+ const accounts = new Map<string, AccountState>();
22
+ let nextLeaseId = 0;
23
+ const keyOf = (provider: string, accountId: string) => `${provider}\u0000${accountId}`;
24
+
25
+ function handoff(state: AccountState, now: number): void {
26
+ while (state.waiters.length > 0) {
27
+ const waiter = state.waiters.shift()!;
28
+ const result = waiter.grant(now);
29
+ if (result === "granted") return;
30
+ if (result === "blocked") { state.waiters.unshift(waiter); return; }
31
+ }
32
+ }
33
+
34
+ function leaseFor(provider: string, accountId: string, key: string, state: AccountState, now: number): AccountLease {
35
+ const id = ++nextLeaseId;
36
+ const record: LeaseRecord = { acquiredAt: now, released: false };
37
+ state.records.set(id, record);
38
+ accounts.set(key, state);
39
+ return { provider, accountId, release() {
40
+ if (record.released) return;
41
+ record.released = true;
42
+ state.records.delete(id);
43
+ handoff(state, Date.now());
44
+ if (state.records.size === 0 && state.waiters.length === 0) accounts.delete(key);
45
+ } };
46
+ }
47
+
48
+ function reclaim(key: string, now: number): AccountState | undefined {
49
+ const state = accounts.get(key);
50
+ if (!state) return undefined;
51
+ for (const [id, record] of state.records) {
52
+ if (now - record.acquiredAt < KIRO_LEASE_MAX_MS) continue;
53
+ record.released = true;
54
+ state.records.delete(id);
55
+ handoff(state, now);
56
+ }
57
+ if (state.records.size === 0 && state.waiters.length === 0) accounts.delete(key);
58
+ return state;
59
+ }
60
+
61
+ export function accountInFlight(provider: string, accountId: string): number {
62
+ return reclaim(keyOf(provider, accountId), Date.now())?.records.size ?? 0;
63
+ }
64
+
65
+ /** A null result means the deadline expired or the caller aborted. */
66
+ export async function acquireAccountLease(
67
+ provider: string, accountId: string, opts: AccountLeaseOptions = {},
68
+ ): Promise<AccountLease | null> {
69
+ const key = keyOf(provider, accountId);
70
+ const deadline = Date.now() + Math.max(0, opts.waitMs ?? 0);
71
+ if (opts.signal?.aborted) return null;
72
+ const now = Date.now();
73
+ const state: AccountState = reclaim(key, now) ?? { records: new Map(), waiters: [] };
74
+ if (state.waiters.length === 0
75
+ && (opts.maxConcurrentPerAccount === undefined || state.records.size < opts.maxConcurrentPerAccount)) {
76
+ return leaseFor(provider, accountId, key, state, now);
77
+ }
78
+ const remaining = deadline - now;
79
+ if (remaining <= 0) return null;
80
+ return await new Promise<AccountLease | null>(resolve => {
81
+ let settled = false;
82
+ const finish = (lease: AccountLease | null) => {
83
+ if (settled) return;
84
+ settled = true;
85
+ clearTimeout(timer);
86
+ opts.signal?.removeEventListener("abort", abort);
87
+ const index = state.waiters.indexOf(waiter);
88
+ if (index >= 0) state.waiters.splice(index, 1);
89
+ if (state.records.size === 0 && state.waiters.length === 0) accounts.delete(key);
90
+ resolve(lease);
91
+ };
92
+ const abort = () => finish(null);
93
+ const waiter: Waiter = { grant(grantNow) {
94
+ if (settled) return "expired";
95
+ if (opts.signal?.aborted || grantNow >= deadline) { finish(null); return "expired"; }
96
+ if (opts.maxConcurrentPerAccount !== undefined && state.records.size >= opts.maxConcurrentPerAccount)
97
+ return "blocked";
98
+ finish(leaseFor(provider, accountId, key, state, grantNow));
99
+ return "granted";
100
+ } };
101
+ state.waiters.push(waiter);
102
+ accounts.set(key, state);
103
+ const timer = setTimeout(() => finish(null), remaining);
104
+ opts.signal?.addEventListener("abort", abort, { once: true });
105
+ if (opts.signal?.aborted) finish(null);
106
+ });
107
+ }