agl 40.4.0 → 40.5.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 (156) hide show
  1. package/changelog.md +33 -0
  2. package/dist_serve/bundle.js +2228 -2177
  3. package/dist_serve/bundle.js.map +2 -2
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/aglapi/aglapisurface.document.js +2 -2
  6. package/dist_ts/classes.accountsactions.d.ts +11 -1
  7. package/dist_ts/classes.accountsactions.js +59 -2
  8. package/dist_ts/classes.controller.js +5 -5
  9. package/dist_ts/classes.flexaccountaccess.js +3 -2
  10. package/dist_ts/classes.harnessreleases.d.ts +14 -4
  11. package/dist_ts/classes.harnessreleases.js +28 -4
  12. package/dist_ts/classes.inferencegateway.d.ts +28 -3
  13. package/dist_ts/classes.inferencegateway.js +177 -61
  14. package/dist_ts/classes.inferenceplacement.d.ts +5 -3
  15. package/dist_ts/classes.inferenceplacement.js +2 -2
  16. package/dist_ts/classes.inferencerouter.d.ts +349 -91
  17. package/dist_ts/classes.inferencerouter.js +804 -297
  18. package/dist_ts/classes.inferenceservice.d.ts +14 -2
  19. package/dist_ts/classes.inferenceservice.js +53 -14
  20. package/dist_ts/classes.inferencespend.d.ts +339 -0
  21. package/dist_ts/classes.inferencespend.js +815 -0
  22. package/dist_ts/classes.inferenceusagecache.d.ts +8 -0
  23. package/dist_ts/classes.inferenceusagecache.js +9 -5
  24. package/dist_ts/classes.inferenceusagetap.d.ts +50 -9
  25. package/dist_ts/classes.inferenceusagetap.js +185 -31
  26. package/dist_ts/classes.proxycontrol.d.ts +3 -2
  27. package/dist_ts/classes.proxycontrol.js +1 -2
  28. package/dist_ts/classes.proxydaemon.js +3 -2
  29. package/dist_ts/classes.proxystore.d.ts +6 -0
  30. package/dist_ts/classes.proxystore.js +11 -2
  31. package/dist_ts/fleet/classes.claudeworkers.d.ts +5 -4
  32. package/dist_ts/fleet/classes.claudeworkers.js +10 -9
  33. package/dist_ts/functions.claudecoderelease.d.ts +12 -6
  34. package/dist_ts/functions.claudecoderelease.js +13 -7
  35. package/dist_ts/functions.inferencecli.js +12 -8
  36. package/dist_ts/interfaces.auth.d.ts +1 -1
  37. package/dist_ts/interfaces.auth.js +3 -1
  38. package/dist_ts/plugins.d.ts +3 -1
  39. package/dist_ts/plugins.js +7 -2
  40. package/dist_ts_authswitch/authority-apikeys.d.ts +32 -0
  41. package/dist_ts_authswitch/authority-apikeys.js +65 -0
  42. package/dist_ts_authswitch/authority-contract.d.ts +172 -33
  43. package/dist_ts_authswitch/authority-contract.js +54 -1
  44. package/dist_ts_authswitch/authority-overview.js +8 -1
  45. package/dist_ts_authswitch/authority-placement.d.ts +7 -2
  46. package/dist_ts_authswitch/authority-placement.js +10 -3
  47. package/dist_ts_authswitch/authority-runtime-contract.d.ts +2 -2
  48. package/dist_ts_authswitch/authority-spend.d.ts +103 -0
  49. package/dist_ts_authswitch/authority-spend.js +110 -0
  50. package/dist_ts_authswitch/authority-subscription.d.ts +12 -1
  51. package/dist_ts_authswitch/authority-subscription.js +24 -1
  52. package/dist_ts_authswitch/authority-usagelimit.d.ts +7 -4
  53. package/dist_ts_authswitch/authority-usagelimit.js +89 -39
  54. package/dist_ts_authswitch/authority-usagewindows.d.ts +4 -1
  55. package/dist_ts_authswitch/authority-usagewindows.js +41 -16
  56. package/dist_ts_authswitch/classes.authoritybroker.d.ts +30 -19
  57. package/dist_ts_authswitch/classes.authoritybroker.js +152 -61
  58. package/dist_ts_authswitch/classes.authoritycli.d.ts +9 -4
  59. package/dist_ts_authswitch/classes.authoritycli.js +99 -30
  60. package/dist_ts_authswitch/classes.authorityclient.d.ts +24 -7
  61. package/dist_ts_authswitch/classes.authorityclient.js +45 -15
  62. package/dist_ts_authswitch/classes.authoritydaemon.js +39 -12
  63. package/dist_ts_authswitch/classes.authoritydatabase.d.ts +28 -5
  64. package/dist_ts_authswitch/classes.authoritydatabase.js +171 -9
  65. package/dist_ts_authswitch/classes.authorityimport.js +2 -2
  66. package/dist_ts_authswitch/classes.authoritymodels.d.ts +71 -3
  67. package/dist_ts_authswitch/classes.authoritymodels.js +249 -20
  68. package/dist_ts_authswitch/classes.authorityregistry.js +4 -2
  69. package/dist_ts_authswitch/classes.authorityusage.d.ts +69 -7
  70. package/dist_ts_authswitch/classes.authorityusage.js +118 -4
  71. package/dist_ts_authswitch/classes.claudenative.d.ts +23 -8
  72. package/dist_ts_authswitch/classes.claudenative.js +49 -21
  73. package/dist_ts_authswitch/classes.claudesignin.d.ts +14 -2
  74. package/dist_ts_authswitch/classes.claudesignin.js +72 -23
  75. package/dist_ts_authswitch/classes.codexmanaged.d.ts +8 -0
  76. package/dist_ts_authswitch/classes.codexmanaged.js +33 -18
  77. package/dist_ts_authswitch/classes.harnessprocesses.d.ts +8 -0
  78. package/dist_ts_authswitch/classes.harnessprocesses.js +41 -9
  79. package/dist_ts_authswitch/testsupport.d.ts +24 -5
  80. package/dist_ts_authswitch/testsupport.js +56 -12
  81. package/dist_ts_authswitch/ts_migration/0011_account_api_key_fields.d.ts +12 -0
  82. package/dist_ts_authswitch/ts_migration/0011_account_api_key_fields.js +21 -0
  83. package/dist_ts_authswitch/ts_migration/index.js +3 -1
  84. package/dist_ts_interfaces/functions.authorityscope.d.ts +18 -7
  85. package/dist_ts_interfaces/functions.authorityscope.js +35 -8
  86. package/dist_ts_interfaces/interfaces.d.ts +1 -1
  87. package/dist_ts_interfaces/interfaces.js +1 -1
  88. package/dist_ts_interfaces/plugins.d.ts +2 -1
  89. package/dist_ts_interfaces/plugins.js +7 -4
  90. package/dist_ts_interfaces/requests.accounts.d.ts +56 -0
  91. package/dist_ts_interfaces/requests.accounts.js +11 -1
  92. package/dist_ts_interfaces/requests.d.ts +2 -2
  93. package/dist_ts_interfaces/requests.js +1 -1
  94. package/docs/authswitch.md +75 -12
  95. package/package.json +5 -5
  96. package/readme.md +199 -22
  97. package/ts/00_commitinfo_data.ts +1 -1
  98. package/ts/aglapi/aglapisurface.document.ts +2 -2
  99. package/ts/classes.accountsactions.ts +75 -1
  100. package/ts/classes.controller.ts +4 -4
  101. package/ts/classes.flexaccountaccess.ts +2 -1
  102. package/ts/classes.harnessreleases.ts +25 -4
  103. package/ts/classes.inferencegateway.ts +184 -64
  104. package/ts/classes.inferenceplacement.ts +6 -4
  105. package/ts/classes.inferencerouter.ts +1026 -314
  106. package/ts/classes.inferenceservice.ts +56 -13
  107. package/ts/classes.inferencespend.ts +870 -0
  108. package/ts/classes.inferenceusagecache.ts +16 -4
  109. package/ts/classes.inferenceusagetap.ts +159 -34
  110. package/ts/classes.proxycontrol.ts +3 -3
  111. package/ts/classes.proxydaemon.ts +2 -1
  112. package/ts/classes.proxystore.ts +11 -1
  113. package/ts/fleet/classes.claudeworkers.ts +9 -8
  114. package/ts/functions.claudecoderelease.ts +13 -6
  115. package/ts/functions.inferencecli.ts +11 -7
  116. package/ts/interfaces.auth.ts +2 -0
  117. package/ts/plugins.ts +7 -0
  118. package/ts_authswitch/authority-apikeys.ts +85 -0
  119. package/ts_authswitch/authority-contract.ts +201 -34
  120. package/ts_authswitch/authority-overview.ts +7 -0
  121. package/ts_authswitch/authority-placement.ts +12 -4
  122. package/ts_authswitch/authority-runtime-contract.ts +2 -2
  123. package/ts_authswitch/authority-spend.ts +168 -0
  124. package/ts_authswitch/authority-subscription.ts +28 -1
  125. package/ts_authswitch/authority-usagelimit.ts +95 -40
  126. package/ts_authswitch/authority-usagewindows.ts +42 -17
  127. package/ts_authswitch/classes.authoritybroker.ts +161 -68
  128. package/ts_authswitch/classes.authoritycli.ts +112 -32
  129. package/ts_authswitch/classes.authorityclient.ts +66 -19
  130. package/ts_authswitch/classes.authoritydaemon.ts +43 -13
  131. package/ts_authswitch/classes.authoritydatabase.ts +178 -8
  132. package/ts_authswitch/classes.authorityimport.ts +1 -1
  133. package/ts_authswitch/classes.authoritymodels.ts +165 -19
  134. package/ts_authswitch/classes.authorityregistry.ts +3 -0
  135. package/ts_authswitch/classes.authorityusage.ts +171 -10
  136. package/ts_authswitch/classes.claudenative.ts +51 -22
  137. package/ts_authswitch/classes.claudesignin.ts +74 -24
  138. package/ts_authswitch/classes.codexmanaged.ts +30 -16
  139. package/ts_authswitch/classes.harnessprocesses.ts +39 -8
  140. package/ts_authswitch/native-contracts.md +82 -21
  141. package/ts_authswitch/readme.md +1 -1
  142. package/ts_authswitch/testsupport.ts +58 -11
  143. package/ts_authswitch/ts_migration/0011_account_api_key_fields.ts +20 -0
  144. package/ts_authswitch/ts_migration/index.ts +2 -0
  145. package/ts_interfaces/functions.authorityscope.ts +43 -8
  146. package/ts_interfaces/interfaces.ts +1 -1
  147. package/ts_interfaces/plugins.ts +6 -3
  148. package/ts_interfaces/requests.accounts.ts +48 -0
  149. package/ts_interfaces/requests.ts +4 -0
  150. package/ts_web/00_commitinfo_data.ts +1 -1
  151. package/ts_web/accounts.plugins.ts +7 -1
  152. package/ts_web/accounts.templates.ts +20 -11
  153. package/ts_web/classes.accountsreadmodel.ts +326 -71
  154. package/ts_web/elements.accounts.ts +219 -20
  155. package/ts_web/elements.codexconnections.ts +3 -1
  156. package/ts_web/plugins.ts +4 -2
@@ -8,6 +8,9 @@ import { authSwitchCombineUsageObservations, authSwitchObservedUsage,
8
8
  type TAuthSwitchUsageObservation } from './authority-usageheaders.js';
9
9
  import { usageWindowPeriod } from './accounts.js';
10
10
  import type { IAuthSwitchTokenUsage } from './authority-usagetokens.js';
11
+ import { authSwitchSpendPeriod, type IAuthSwitchSpendReading } from './authority-spend.js';
12
+ import type { IAuthSwitchSpendAnswer, TAuthSwitchPaymentProblemCause as TCause } from './authority-contract.js';
13
+ import { authSwitchPaymentProblemHeld } from './authority-subscription.js';
11
14
  import { AuthSwitchUsageEstimator, authSwitchRolledOpenAiLimits, type IAuthSwitchUsageEstimateState,
12
15
  type IAuthSwitchUsageEstimatorOptions, type TAuthSwitchEstimateBasis } from './classes.authorityusageestimate.js';
13
16
 
@@ -16,12 +19,12 @@ type TRateLimits = IOpenAiAccountRateLimits;
16
19
  /**
17
20
  * Why a usage reading is missing or incomplete. `held_in_container`: the Claude login runs in a registered container
18
21
  * home, whose files are the container's; the authority reads its usage again once the login is moved out. `pending`:
19
- * the account's first read has not finished yet.
22
+ * the account's first read has not finished yet. `spend_unavailable`: an API key's spend ledger could not be read.
20
23
  */
21
24
  export type TAuthSwitchUsageProblem =
22
25
  | 'none' | 'unsupported' | 'access_unavailable' | 'provider_unavailable'
23
26
  | 'provider_rejected' | 'profile_unavailable' | 'usage_unavailable'
24
- | 'context_changed' | 'expired' | 'held_in_container' | 'pending';
27
+ | 'context_changed' | 'expired' | 'held_in_container' | 'pending' | 'spend_unavailable';
25
28
 
26
29
  export interface IAuthSwitchNativeUsageSource {
27
30
  homeId: string;
@@ -69,10 +72,18 @@ export interface IAuthSwitchClaudeStatus {
69
72
  rateLimit: { retryAt: string | null } | null;
70
73
  }
71
74
 
72
- export type TAuthSwitchUsageReading =
75
+ /** A usage reading the authority reads from a provider for a subscription sign-in, and stores between its runs. */
76
+ export type TAuthSwitchProviderUsageReading =
73
77
  | { kind: 'openai'; rateLimits: TRateLimits }
74
78
  | { kind: 'anthropic'; status: IAuthSwitchClaudeStatus };
75
79
 
80
+ /**
81
+ * A login's usage reading: a subscription sign-in's, as its provider stated it, or an API key's monthly spend
82
+ * (`spend`), which the authority answers from what AGL's inference proxy reported and never reads from a provider.
83
+ */
84
+ export type TAuthSwitchUsageReading = TAuthSwitchProviderUsageReading
85
+ | { kind: 'spend'; spend: IAuthSwitchSpendReading };
86
+
76
87
  export interface IAuthSwitchUsageAccess {
77
88
  accessToken: string;
78
89
  accountId: string;
@@ -96,10 +107,33 @@ export interface IAuthSwitchUsageAccess {
96
107
  export interface IAuthSwitchPaymentProblem {
97
108
  at: string;
98
109
  subscription: IClaudeSubscription | null;
110
+ /**
111
+ * What the provider refused for (`TAuthSwitchPaymentProblemCause`), present only for `spend_cap` and `quota`, which
112
+ * only an API key's login is held with; absent, the cause is `billing`, on a Claude login and an API key alike. A
113
+ * problem with a cause has no subscription.
114
+ */
115
+ cause?: Exclude<TAuthSwitchPaymentProblemCause, 'billing'>;
116
+ /**
117
+ * When a `spend_cap` lifts by itself: the end of the month in UTC `at` falls in (`authSwitchSpendPeriod(at).end`),
118
+ * present exactly for that cause; once it passed the problem holds nothing (`authSwitchPaymentProblemHeld`). Every
119
+ * other problem holds until it is cleared: a Claude login's by a read, an API key's by a re-check (`usage.recheck`).
120
+ */
121
+ until?: string;
99
122
  }
100
123
 
101
124
  /**
102
- * How a re-check's read went (`AuthSwitchAuthorityUsage.recheck`): `healthy` -- the profile was found, the sign-in
125
+ * Why a provider refused inference on an API key for money, as AGL's inference proxy read the refusal: `billing`, the
126
+ * organization's billing (Anthropic HTTP 402 `billing_error`, and every Claude sign-in's payment problem); `spend_cap`,
127
+ * the monthly spend cap of the key's usage tier, which lifts at 00:00 UTC on the first of the next month (Anthropic 429
128
+ * `enforced_spend_limit_reached`); `quota`, the organization's or project's credit or spend limit (OpenAI 429
129
+ * `insufficient_quota`).
130
+ */
131
+ export type TAuthSwitchPaymentProblemCause = 'billing' | 'spend_cap' | 'quota';
132
+
133
+ /**
134
+ * How a re-check went (`AuthSwitchAuthorityUsage.recheck`). `cleared`: the login is an API key, whose hold a re-check
135
+ * lifts without asking the provider, which states no billing or quota on a model list; the next request shows whether the
136
+ * provider takes the key again. Every other outcome is a Claude login's read: `healthy` -- the profile was found, the sign-in
103
137
  * accepted and the identity matched -- clears a payment problem; every other outcome clears nothing. `credential_rejected`:
104
138
  * Anthropic rejected the sign-in, renewed too. `token_unusable`: no access token usable for a profile read (it could not
105
139
  * be resolved, is not fresh enough, or lacks the `user:profile` scope). `profile_missing`: the profile read failed or its
@@ -108,7 +142,7 @@ export interface IAuthSwitchPaymentProblem {
108
142
  * lies ahead.
109
143
  */
110
144
  export type TAuthSwitchRecheckOutcome = 'healthy' | 'credential_rejected' | 'token_unusable' | 'profile_missing'
111
- | 'mismatch' | 'read_failed';
145
+ | 'mismatch' | 'read_failed' | 'cleared';
112
146
 
113
147
  /** A re-check's answer: the usage the authority holds once its read finished, and how that read went. */
114
148
  export interface IAuthSwitchUsageRecheck {
@@ -120,7 +154,7 @@ export interface IAuthSwitchUsageRecheck {
120
154
  export interface IAuthSwitchStoredUsage {
121
155
  context: IAuthSwitchUsageContext;
122
156
  updateId: string;
123
- reading: TAuthSwitchUsageReading | null;
157
+ reading: TAuthSwitchProviderUsageReading | null;
124
158
  observedAt: string | null;
125
159
  currentUntil: string | null;
126
160
  staleUntil: string | null;
@@ -262,6 +296,15 @@ export interface IAuthSwitchAuthorityUsageOptions {
262
296
  estimate?: IAuthSwitchUsageEstimatorOptions;
263
297
  /** Where the reader's notes go, one line each: standard error, the service's journal, unless set. */
264
298
  log?: (lineArg: string) => void;
299
+ /**
300
+ * An API key's spend ledger: its month's reading at `nowArg`, and a spend report recorded in one transaction
301
+ * (`AuthSwitchAuthorityDatabase.readSpend`, `.recordSpend`). Without it the authority answers no key's usage.
302
+ */
303
+ spend?: {
304
+ read(accountId: string, loginId: string, nowArg: number): Promise<IAuthSwitchSpendReading>;
305
+ record(accountId: string, loginId: string, answers: readonly IAuthSwitchSpendAnswer[],
306
+ nowArg: number): Promise<IAuthSwitchSpendReading>;
307
+ };
265
308
  }
266
309
 
267
310
  /** What a client that served requests on a login reports about the account: see `report`. */
@@ -345,6 +388,15 @@ const usable = (context: IAuthSwitchUsageContext): boolean =>
345
388
  || (context.owner === 'claude_native' && context.nativeSource !== null
346
389
  && context.grantState === 'native'))));
347
390
 
391
+ /** An API key of its account's provider, held or removed: its spend is recorded either way. */
392
+ const apiKeyOfAccount = (context: IAuthSwitchUsageContext): boolean => context.workspaceId === 'api-key'
393
+ && ((context.providerId === 'anthropic' && context.purpose === 'anthropic_api_key')
394
+ || (context.providerId === 'openai' && context.purpose === 'openai_api_key'));
395
+
396
+ /** An API key's login the authority holds: a key nothing else renews. Its usage is its spend ledger. */
397
+ const apiKeyLogin = (context: IAuthSwitchUsageContext): boolean => apiKeyOfAccount(context) && context.owner === 'daemon'
398
+ && context.nativeSource === null;
399
+
348
400
  /** A login read through Claude Code's own home: its context names the home only once that home is opened for a read. */
349
401
  const native = (context: IAuthSwitchUsageContext): boolean => context.providerId === 'anthropic'
350
402
  && context.purpose === 'claude_host_native' && context.owner === 'claude_native' && context.grantState === 'native';
@@ -664,7 +716,8 @@ export class AuthSwitchAuthorityUsage {
664
716
  * carries it. Refused for any login but a Claude login the authority renews, and for one with no usage record of its
665
717
  * sign-in yet, which the proxy never routed to. The provider is not asked.
666
718
  */
667
- public reportPaymentProblem(accountId: string, loginId: string, observedAt: string): Promise<IAuthSwitchUsageSnapshot> {
719
+ public reportPaymentProblem(accountId: string, loginId: string, observedAt: string,
720
+ cause: TCause = 'billing'): Promise<IAuthSwitchUsageSnapshot> {
668
721
  if (this.closed) return Promise.reject(new Error('Authority usage helper is closed.'));
669
722
  return this.admit((async () => {
670
723
  if (!/^[a-f0-9]{64}$/.test(accountId) || !/^[a-f0-9]{64}$/.test(loginId) || !Number.isFinite(Date.parse(observedAt))) {
@@ -672,9 +725,21 @@ export class AuthSwitchAuthorityUsage {
672
725
  }
673
726
  for (let attempt = 1; ; attempt++) {
674
727
  const context = await this.currentContext(accountId, loginId);
728
+ if (context && context.accountId === accountId && context.loginId === loginId && !context.removed
729
+ && apiKeyLogin(context) && context.grantState === 'ready') {
730
+ if (await this.holdApiKey(context, observedAt, cause)) return this.spendAnswer(context);
731
+ if (attempt >= paymentProblemWriteAttempts) {
732
+ throw new AuthSwitchRefusal('account_changed',
733
+ 'The key\'s record kept changing, so its payment problem was not written; report it again.');
734
+ }
735
+ continue;
736
+ }
675
737
  if (!context || context.accountId !== accountId || context.loginId !== loginId || !paymentProblemLogin(context)) {
676
738
  throw new AuthSwitchRefusal('login_unavailable',
677
- 'Only a Claude login the account service renews is held with a payment problem.');
739
+ 'Only a Claude login the account service renews, or an API key it holds, is held with a payment problem.');
740
+ }
741
+ if (cause !== 'billing') {
742
+ throw new AuthSwitchRefusal('login_unavailable', 'A Claude login is held only for a billing error.');
678
743
  }
679
744
  const stored = await this.options.store.read(accountId, loginId);
680
745
  const held = stored && authSwitchSameUsageSource(context, stored.context) ? stored : null;
@@ -714,9 +779,13 @@ export class AuthSwitchAuthorityUsage {
714
779
  throw new Error('Invalid authority usage identity.');
715
780
  }
716
781
  const context = await this.currentContext(accountId, loginId);
782
+ if (context && context.accountId === accountId && context.loginId === loginId && !context.removed
783
+ && apiKeyLogin(context)) {
784
+ return this.clearApiKey(context);
785
+ }
717
786
  if (!context || context.accountId !== accountId || context.loginId !== loginId
718
787
  || context.providerId !== 'anthropic' || context.purpose !== 'claude_host_native') {
719
- throw new AuthSwitchRefusal('login_unavailable', 'Only a Claude login is re-checked.');
788
+ throw new AuthSwitchRefusal('login_unavailable', 'Only a Claude login or an API key is re-checked.');
720
789
  }
721
790
  const key = this.key(accountId, loginId);
722
791
  await this.answer(accountId, loginId, null);
@@ -737,6 +806,97 @@ export class AuthSwitchAuthorityUsage {
737
806
  })());
738
807
  }
739
808
 
809
+ /**
810
+ * Records what answers relayed on an API key's login cost (`authswitch.authority.usage.spend`) and answers the key's
811
+ * usage once the report committed. A removed key's spend is recorded too; its usage then answers unavailable.
812
+ */
813
+ public recordSpend(accountId: string, loginId: string,
814
+ answers: readonly IAuthSwitchSpendAnswer[]): Promise<IAuthSwitchUsageSnapshot> {
815
+ if (this.closed) return Promise.reject(new Error('Authority usage helper is closed.'));
816
+ return this.admit((async () => {
817
+ if (!/^[a-f0-9]{64}$/.test(accountId) || !/^[a-f0-9]{64}$/.test(loginId)) {
818
+ throw new Error('Invalid authority usage identity.');
819
+ }
820
+ const context = await this.currentContext(accountId, loginId);
821
+ const spend = this.options.spend;
822
+ if (!spend || !context || context.accountId !== accountId || context.loginId !== loginId || !apiKeyOfAccount(context)) {
823
+ throw new AuthSwitchRefusal('login_unavailable', 'Only an API key\'s login records spend.');
824
+ }
825
+ await spend.record(accountId, loginId, answers, this.now());
826
+ return this.answer(accountId, loginId, null);
827
+ })());
828
+ }
829
+
830
+ /**
831
+ * An API key's usage: its month's spend from the ledger, current as of now, which stays current until the month ends
832
+ * or a report or a limit change moves it, and the payment problem it is held with, while it holds.
833
+ */
834
+ private async spendAnswer(context: IAuthSwitchUsageContext): Promise<IAuthSwitchUsageSnapshot> {
835
+ const { accountId, loginId } = context;
836
+ if (context.removed || !this.options.spend) {
837
+ return this.unavailable(accountId, loginId, context.removed ? 'access_unavailable' : 'unsupported');
838
+ }
839
+ const now = this.now();
840
+ let spend: IAuthSwitchSpendReading;
841
+ let held: IAuthSwitchStoredUsage | null;
842
+ try {
843
+ [spend, held] = await Promise.all([this.options.spend.read(accountId, loginId, now),
844
+ this.options.store.read(accountId, loginId)]);
845
+ } catch {
846
+ this.log('Authswitch could not read an API key\'s spend; the key is answered without a reading.');
847
+ return this.unavailable(accountId, loginId, 'spend_unavailable');
848
+ }
849
+ const paymentProblem = held && authSwitchSameUsageSource(context, held.context) && held.paymentProblem
850
+ && authSwitchPaymentProblemHeld(held.paymentProblem, now) ? held.paymentProblem : undefined;
851
+ const readAt = timestamp(now);
852
+ return { accountId, loginId, state: 'current', reading: { kind: 'spend', spend }, observedAt: readAt, readAt,
853
+ currentUntil: spend.resetAt, staleUntil: spend.resetAt, lastAttemptAt: null, nextAttemptAt: null,
854
+ lastReadError: null, problem: 'none', ...(paymentProblem ? { paymentProblem } : {}) };
855
+ }
856
+
857
+ /**
858
+ * Holds an API key with a payment problem on its usage record, which holds nothing else: when it was seen -- a time
859
+ * ahead of the authority's clock counts as now -- and its cause; `billing` is written as no cause, and a `spend_cap`
860
+ * lifts at the end of the month it was seen in. False when another write of the record landed first.
861
+ */
862
+ private async holdApiKey(context: IAuthSwitchUsageContext, observedAt: string, cause: TCause): Promise<boolean> {
863
+ const stored = await this.options.store.read(context.accountId, context.loginId);
864
+ const held = stored && authSwitchSameUsageSource(context, stored.context) ? stored : null;
865
+ const now = this.now();
866
+ const at = timestamp(Math.min(Date.parse(observedAt), now));
867
+ const paymentProblem: IAuthSwitchPaymentProblem = cause === 'spend_cap'
868
+ ? { at, subscription: null, cause, until: authSwitchSpendPeriod(Date.parse(at)).end }
869
+ : cause === 'quota' ? { at, subscription: null, cause } : { at, subscription: null };
870
+ const next: IAuthSwitchStoredUsage = { context, updateId: plugins.crypto.randomUUID(), reading: null,
871
+ observedAt: null, currentUntil: null, staleUntil: null, lastAttemptAt: at, nextAttemptAt: at, failures: 0,
872
+ problem: 'none', paymentProblem };
873
+ return this.options.store.publishIfCurrent(context, held && sameContext(context, held.context) ? held.updateId : null,
874
+ next);
875
+ }
876
+
877
+ /**
878
+ * A re-check of an API key: lifts the payment problem it is held with, without asking the provider, whose model list
879
+ * states no billing or quota; the next request shows whether the provider takes the key again.
880
+ */
881
+ private async clearApiKey(context: IAuthSwitchUsageContext): Promise<IAuthSwitchUsageRecheck> {
882
+ for (let attempt = 1; ; attempt++) {
883
+ const stored = await this.options.store.read(context.accountId, context.loginId);
884
+ const held = stored && authSwitchSameUsageSource(context, stored.context) && stored.paymentProblem ? stored : null;
885
+ if (!held) return { usage: await this.spendAnswer(context), outcome: 'cleared' };
886
+ // A record without the member: the store's transition drops a problem the draft does not hold.
887
+ const { paymentProblem: _lifted, ...rest } = held;
888
+ const next: IAuthSwitchStoredUsage = { ...rest, context, updateId: plugins.crypto.randomUUID() };
889
+ if (await this.options.store.publishIfCurrent(context, sameContext(context, held.context) ? held.updateId : null,
890
+ next)) {
891
+ return { usage: await this.spendAnswer(context), outcome: 'cleared' };
892
+ }
893
+ if (attempt >= paymentProblemWriteAttempts) {
894
+ throw new AuthSwitchRefusal('account_changed', 'The key\'s record kept changing, so its hold was not lifted; '
895
+ + 're-check it again.');
896
+ }
897
+ }
898
+ }
899
+
740
900
  /** Logs a Claude subscription status the first time this run reads it, up to `maximumLoggedSubscriptionStatuses`. */
741
901
  private noteSubscriptionStatus(statusArg: string | null): void {
742
902
  if (statusArg === null || this.subscriptionStatusesCapped || this.loggedSubscriptionStatuses.has(statusArg)) return;
@@ -774,6 +934,7 @@ export class AuthSwitchAuthorityUsage {
774
934
  if (context.accountId !== accountId || context.loginId !== loginId) {
775
935
  return this.unavailable(accountId, loginId, 'context_changed');
776
936
  }
937
+ if (apiKeyLogin(context)) return this.spendAnswer(context);
777
938
  if (!readable(context)) {
778
939
  this.unschedule(accountId, loginId);
779
940
  return this.unavailable(accountId, loginId,
@@ -999,7 +1160,7 @@ export class AuthSwitchAuthorityUsage {
999
1160
  previous = latest && sameContext(context, latest.context) ? latest : null;
1000
1161
  held = latest && authSwitchSameUsageSource(context, latest.context) ? latest : null;
1001
1162
  };
1002
- let reading: TAuthSwitchUsageReading | null = null;
1163
+ let reading: TAuthSwitchProviderUsageReading | null = null;
1003
1164
  /** Whether the read stated the account's windows: only then is it a reading the schedule waits a full slot after. */
1004
1165
  let windowsRead = false;
1005
1166
  let rateLimited = false;
@@ -14,11 +14,20 @@ export const CLAUDE_NATIVE_MINIMUM_VERSION = '2.1.273';
14
14
  /**
15
15
  * The releases whose lock table, live credential pickup and native-login selector were read from their own bundle
16
16
  * (native-contracts.md). The usage of an adopted home's login is read from the home only while every Claude Code
17
- * session running there runs one of these or a later release than the newest of them, which is read as that one
18
- * (`claudeNativeReleaseReadsAsVerified`, owner ruling 2026-10-22, AGL-685); an installed release between the floor and
19
- * the newest that is not listed is read from only while no session runs in that home.
17
+ * session running there runs one of these or a release from `CLAUDE_NATIVE_READ_AS_VERIFIED_FROM` on
18
+ * (`claudeNativeReleaseReadsAsVerified`, owner ruling 2026-10-22, AGL-685); an installed release below that line that
19
+ * is not listed is read from only while no session runs in that home. The controller's verified release (2.1.306,
20
+ * AGL-686) and its fleet floor (2.1.282) are listed. 2.1.288, the release AGL's own Claude chats run through the Agent
21
+ * SDK, had these items read from the SDK's binary (AGL-688) and is listed for the usage read only: the fleet never ran
22
+ * it, so it is never the fleet's floor or the verified release.
20
23
  */
21
- export const CLAUDE_NATIVE_VERIFIED_RELEASES: readonly string[] = ['2.1.273', '2.1.282'];
24
+ export const CLAUDE_NATIVE_VERIFIED_RELEASES: readonly string[] = ['2.1.273', '2.1.282', '2.1.288', '2.1.306'];
25
+ /**
26
+ * The line from which the usage read takes any release, listed or not, as verified (owner ruling 2026-10-22, AGL-685):
27
+ * the controller's fleet floor (`claudeCodeFleetFloor`), which a test holds equal to it. It does not move when a newer
28
+ * release is verified.
29
+ */
30
+ export const CLAUDE_NATIVE_READ_AS_VERIFIED_FROM = '2.1.282';
22
31
  /**
23
32
  * The releases whose credential custody contract was read from their own bundle (native-contracts.md): the OAuth
24
33
  * refresh lock and its legacy twin, the credential store's write lock and the global config lock; the store read
@@ -126,7 +135,8 @@ export class ClaudeNativeEffectiveAuthUnsupportedError extends Error {
126
135
  public readonly sessions: readonly IClaudeNativeRefusedSession[] = []) {
127
136
  super((reason === 'unsupported_release'
128
137
  ? `Reading Claude Code's own login requires Claude Code ${CLAUDE_NATIVE_MINIMUM_VERSION} or newer, and a session `
129
- + `running in this home must run a verified release (${CLAUDE_NATIVE_VERIFIED_RELEASES.join(', ')}) or a newer one.`
138
+ + `running in this home must run a verified release (${CLAUDE_NATIVE_VERIFIED_RELEASES.join(', ')}) or any release `
139
+ + `from ${CLAUDE_NATIVE_READ_AS_VERIFIED_FROM} on.`
130
140
  : `Cannot prove that Claude Code uses its native claude.ai login (${reason}).`)
131
141
  + (sessions.length ? ` Refused: ${sessions.map(describeClaudeNativeRefusedSession).join('; ')}.` : ''));
132
142
  }
@@ -179,6 +189,10 @@ export interface IClaudeNativeRunningContext {
179
189
  /** The Agent SDK version whose platform package holds its executable, for an Agent SDK binary only. */
180
190
  agentSdkVersion?: string;
181
191
  env: NodeJS.ProcessEnv | null;
192
+ /**
193
+ * Its working directory as its own root sees it; null when that cannot be resolved -- deleted under it, for one -- so
194
+ * nothing that needs it (a relative home or store, its project settings) can be proven.
195
+ */
182
196
  cwd: string | null;
183
197
  hasSettingsOverride: boolean | null;
184
198
  /** The selected native home as seen through this process's mount namespace. */
@@ -251,17 +265,15 @@ const atLeast = (version: [number, number, number], minimum: [number, number, nu
251
265
  const claudeBuildPattern = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:-[0-9a-f]{1,64})?$/;
252
266
  /**
253
267
  * Whether a session of this build is read like a verified release when its home's login is read for usage: a listed
254
- * release, or a build of a release no older than the newest listed one, which is read as that one. A hashed build is
255
- * ordered by its release. Custody never uses this: it matches whole build names (`CLAUDE_NATIVE_CUSTODY_RELEASES`).
268
+ * release, or a build of a release from `CLAUDE_NATIVE_READ_AS_VERIFIED_FROM` on. A hashed build is ordered by its
269
+ * release. Custody never uses this: it matches whole build names (`CLAUDE_NATIVE_CUSTODY_RELEASES`).
256
270
  */
257
271
  export const claudeNativeReleaseReadsAsVerified = (build: string | null): boolean => {
258
272
  if (build === null) return false;
259
273
  if (CLAUDE_NATIVE_VERIFIED_RELEASES.includes(build)) return true;
260
274
  const match = claudeBuildPattern.exec(build);
261
275
  if (!match) return false;
262
- const newest = CLAUDE_NATIVE_VERIFIED_RELEASES.map(release => parseClaudeVersion(release)!)
263
- .reduce((left, right) => atLeast(left, right) ? left : right);
264
- return atLeast([Number(match[1]), Number(match[2]), Number(match[3])], newest);
276
+ return atLeast([Number(match[1]), Number(match[2]), Number(match[3])], parseClaudeVersion(CLAUDE_NATIVE_READ_AS_VERIFIED_FROM)!);
265
277
  };
266
278
 
267
279
  /**
@@ -458,10 +470,15 @@ export const claudeNativeCredentialStoreDirectory = (env: NodeJS.ProcessEnv): st
458
470
  ? plugins.path.join(env.HOME, '.claude').normalize('NFC') : null;
459
471
  };
460
472
 
473
+ /**
474
+ * An environment selects this home and keeps its credential store there too: a store elsewhere
475
+ * (`CLAUDE_SECURESTORAGE_CONFIG_DIR`) selects another login, so it is refused as an override.
476
+ */
461
477
  const assertNativeHome = (env: NodeJS.ProcessEnv, configDir: string): void => {
462
- const selected = selectedNativeHome(env);
463
- if (!selected || !plugins.path.isAbsolute(selected) || plugins.path.normalize(selected) !== configDir) {
464
- throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
478
+ for (const selected of [selectedNativeHome(env), claudeNativeCredentialStoreDirectory(env)]) {
479
+ if (!selected || !plugins.path.isAbsolute(selected) || plugins.path.normalize(selected) !== configDir) {
480
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
481
+ }
465
482
  }
466
483
  };
467
484
 
@@ -695,10 +712,24 @@ class ClaudeNativeVersionProbe implements IClaudeNativeVersionProbe {
695
712
  }
696
713
  }
697
714
 
715
+ /**
716
+ * The working directory `/proc/<pid>/cwd` names, when that path is the very directory the process works in as its own
717
+ * root sees it; null otherwise, as for a directory deleted under the process, which the link names `<path> (deleted)`.
718
+ */
719
+ const resolvedProcessCwd = (pid: number, root: string): string | null => {
720
+ try {
721
+ const cwd = plugins.fs.readlinkSync(`/proc/${pid}/cwd`);
722
+ return plugins.path.isAbsolute(cwd) && sameDirectory(directoryIdentity(`/proc/${pid}/cwd`),
723
+ directoryIdentity(processViewPath(cwd, root))) ? cwd : null;
724
+ } catch { return null; }
725
+ };
726
+
698
727
  /**
699
728
  * Every Claude Code process of this user, the home it selects and the directory of its credential store, resolved
700
729
  * through its own mount namespace (`/proc/<pid>/root`), so a process in a container that bind-mounts a home is matched to
701
- * that home's inode. A live process whose context cannot be read is listed with unknown fields rather than skipped.
730
+ * that home's inode. A live process whose context cannot be read is listed with unknown fields rather than skipped. A
731
+ * working directory that cannot be resolved leaves `cwd` unknown and only what depends on it: a home or store the
732
+ * process names by an absolute path is still resolved under its root, one named by a relative path is not.
702
733
  */
703
734
  export const scanClaudeRunningContexts = (): readonly IClaudeNativeRunningContext[] => {
704
735
  const contexts: IClaudeNativeRunningContext[] = [];
@@ -725,20 +756,18 @@ export const scanClaudeRunningContexts = (): readonly IClaudeNativeRunningContex
725
756
  }
726
757
  mountNamespace = plugins.fs.readlinkSync(`/proc/${running.pid}/ns/mnt`);
727
758
  env = selectorEnvironment(readProcessContext(`/proc/${running.pid}/environ`));
728
- cwd = plugins.fs.readlinkSync(`/proc/${running.pid}/cwd`);
729
759
  const root = `/proc/${running.pid}/root`;
730
760
  processRoot = root;
731
- if (!plugins.path.isAbsolute(cwd) || !sameDirectory(
732
- directoryIdentity(`/proc/${running.pid}/cwd`),
733
- directoryIdentity(processViewPath(cwd, root)))) {
734
- throw new Error('Claude process working directory cannot be resolved in its mount namespace.');
735
- }
761
+ cwd = resolvedProcessCwd(running.pid, root);
736
762
  // A directory the process names, as it sees it: an absolute path under its own root, a relative one under its cwd.
737
763
  const processDirectory = (selected: string | null, name: string): IClaudeNativeDirectoryIdentity => {
738
764
  if (!selected) throw new Error(`Claude process ${name} is unknown.`);
739
765
  if (plugins.path.isAbsolute(selected) && plugins.path.normalize(selected) !== selected) {
740
766
  throw new Error(`Claude process ${name} path is ambiguous.`);
741
767
  }
768
+ if (!plugins.path.isAbsolute(selected) && cwd === null) {
769
+ throw new Error(`Claude process ${name} is relative to a working directory that cannot be resolved.`);
770
+ }
742
771
  return directoryIdentity(plugins.path.isAbsolute(selected)
743
772
  ? processViewPath(selected, root)
744
773
  // Preserve the proc magic symlink before `..` is resolved by the target's VFS.
@@ -908,8 +937,8 @@ export class ClaudeNativeAdapter {
908
937
 
909
938
  /**
910
939
  * The whole support check of reading the login Claude Code selects in the home: the home's custody, and the proof
911
- * that Claude Code selects its native login there -- for the daemon's own environment and settings and for every
912
- * process running on the home.
940
+ * that Claude Code selects its native login there -- for the daemon's own environment (its home and its credential
941
+ * store are both this home) and settings, and for every process running on the home.
913
942
  */
914
943
  private assertSupported(): IClaudeNativeSupport {
915
944
  this.assertHomeDirectories();
@@ -1,6 +1,6 @@
1
1
  import * as plugins from './plugins.js';
2
- import { AuthSwitchRefusal, authSwitchClaudeSignInArguments,
3
- type IAuthSwitchClaudeSignInOperation } from './authority-contract.js';
2
+ import { AuthSwitchRefusal, authSwitchClaudeSignInArguments, authSwitchClaudeSignInHoldersText,
3
+ type IAuthSwitchClaudeSignInHolder, type IAuthSwitchClaudeSignInOperation } from './authority-contract.js';
4
4
  import type { AuthSwitchGrantImport, IAuthSwitchImportClaudeTokens } from './classes.authorityimport.js';
5
5
  import type { IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
6
6
  import { canonicalContainerDirectory, ClaudeContainerHome, discardContainerPair, inspectClaudeContainerDirectory,
@@ -38,6 +38,20 @@ const isUuid = (value: string): boolean => /^[a-f0-9-]{36}$/.test(value);
38
38
  const shellWord = (value: string): string => `'${value.replaceAll('\'', '\'\\\'\'')}'`;
39
39
  const finished = (state: IAuthSwitchClaudeSignInOperation['state']): boolean =>
40
40
  state !== 'pending' && state !== 'committing';
41
+ /**
42
+ * How much earlier than a sign-in's home a process must have started, by the process table, to be proven older than
43
+ * it: `ps` names the start to the whole second, cut down, from a boot time the kernel also keeps to the whole second, so
44
+ * a process may have started up to two seconds after the moment the table names. The rest is room for clock slew.
45
+ */
46
+ const startedBeforeMarginMs = 5_000;
47
+ const startedBefore = (startedAt: string | null, madeAtMs: number): boolean => {
48
+ const started = startedAt === null ? Number.NaN : Date.parse(startedAt);
49
+ return Number.isFinite(started) && started + startedBeforeMarginMs <= madeAtMs;
50
+ };
51
+ const sameHolders = (left: readonly IAuthSwitchClaudeSignInHolder[] | null,
52
+ right: readonly IAuthSwitchClaudeSignInHolder[] | null): boolean =>
53
+ left === null || right === null ? left === right : left.length === right.length && left.every((holder, index) =>
54
+ holder.pid === right[index]!.pid && holder.startedAt === right[index]!.startedAt && holder.reason === right[index]!.reason);
41
55
 
42
56
  interface IClaudeSignIn {
43
57
  operation: IAuthSwitchClaudeSignInOperation;
@@ -51,7 +65,8 @@ interface IClaudeSignIn {
51
65
 
52
66
  /**
53
67
  * Claude sign-ins: Claude Code signs in to a home the daemon made for that one sign-in, and the daemon imports the
54
- * login it finds there once no Claude Code process runs on the home any more, then removes the home.
68
+ * login it finds there once no Claude Code process may still run on the home, then removes the home. Until then the
69
+ * sign-in names the processes that hold it (`heldBy`).
55
70
  *
56
71
  * The home is the daemon's own, below a directory of its user that no one else can write, and it is touched only by
57
72
  * descriptor, as a container home is (`ClaudeContainerHome`): a home replaced by a link cannot redirect a read or the
@@ -103,12 +118,17 @@ export class ClaudeSignInAuthority {
103
118
  const directory = plugins.path.join(this.options.directory, operationId);
104
119
  const { parent, leaf } = inspectClaudeContainerDirectory(directory, this.owner);
105
120
  if (leaf !== null) throw new Error('A Claude sign-in home already exists for this operation.');
106
- ClaudeContainerHome.open(directory, parent, { create: true, owner: this.owner }).close();
121
+ // Read before the home is made, so no process that started after it can seem to have started before it.
107
122
  const now = this.now();
123
+ ClaudeContainerHome.open(directory, parent, { create: true, owner: this.owner }).close();
108
124
  const signIn: IClaudeSignIn = {
109
125
  operation: { id: operationId, state: 'pending',
126
+ // The credential store is pinned to the home as well: one an inherited CLAUDE_SECURESTORAGE_CONFIG_DIR names
127
+ // would take the new login, over whatever login it holds, and the home would never get it.
110
128
  prompt: { flow: 'terminal', directory,
111
- command: `CLAUDE_CONFIG_DIR=${shellWord(directory)} claude ${authSwitchClaudeSignInArguments.join(' ')}` },
129
+ command: `CLAUDE_CONFIG_DIR=${shellWord(directory)} CLAUDE_SECURESTORAGE_CONFIG_DIR=${shellWord(directory)} `
130
+ + `claude ${authSwitchClaudeSignInArguments.join(' ')}` },
131
+ heldBy: null,
112
132
  result: null, error: null, createdAt: new Date(now).toISOString(), updatedAt: new Date(now).toISOString(),
113
133
  expiresAt: new Date(now + signInWindowMs).toISOString(), finishedAt: null, revision: 1 },
114
134
  directory, parent, timer: null, commit: null,
@@ -189,14 +209,13 @@ export class ClaudeSignInAuthority {
189
209
  }, this.pollIntervalMs);
190
210
  }
191
211
 
192
- /** Looks once for the login: imports it when it is there and no Claude Code runs on the home, else waits on. */
212
+ /**
213
+ * Looks once for the login: imports it when it is there and nothing holds it (`holders`), else waits on, keeping in
214
+ * `heldBy` what holds it. At `expiresAt` it ends instead and imports nothing, saying whether a login had arrived and
215
+ * what held it.
216
+ */
193
217
  private poll(signIn: IClaudeSignIn): void {
194
218
  if (this.closed || signIn.operation.state !== 'pending') return;
195
- if (this.now() >= Date.parse(signIn.operation.expiresAt)) {
196
- this.end(signIn, 'failed', 'No Claude login arrived within fifteen minutes, so nothing was added. Start a new '
197
- + 'sign-in.');
198
- return;
199
- }
200
219
  let read: { pair: IClaudeContainerPair; identity: IClaudeContainerDirectoryIdentity | null } | null = null;
201
220
  try {
202
221
  const home = ClaudeContainerHome.open(signIn.directory, signIn.parent, { create: false, owner: this.owner });
@@ -210,29 +229,59 @@ export class ClaudeSignInAuthority {
210
229
  return;
211
230
  }
212
231
  const { pair, identity } = read;
213
- let ready: boolean;
214
- try {
215
- // A process whose home or credential store is the sign-in home, or unknown, may still write the login there.
216
- ready = pair.credentialRaw !== null && identity !== null && !this.runningContexts().some(context =>
217
- context.nativeHomeIdentity === null || context.credentialStoreIdentity === null
218
- || sameContainerDirectory(context.nativeHomeIdentity, identity)
219
- || sameContainerDirectory(context.credentialStoreIdentity, identity));
220
- } catch {
232
+ let holders: IAuthSwitchClaudeSignInHolder[] | null = null;
233
+ if (pair.credentialRaw !== null && identity !== null) {
234
+ try { holders = this.holders(identity, Date.parse(signIn.operation.createdAt)); }
235
+ catch {
236
+ discardContainerPair(pair);
237
+ this.end(signIn, 'failed', 'The authority could not tell whether Claude Code still runs on the sign-in home, '
238
+ + 'so nothing was added. Start a new sign-in.');
239
+ return;
240
+ }
241
+ }
242
+ if (this.now() >= Date.parse(signIn.operation.expiresAt)) {
221
243
  discardContainerPair(pair);
222
- this.end(signIn, 'failed', 'The authority could not tell whether Claude Code still runs on the sign-in home, '
223
- + 'so nothing was added. Start a new sign-in.');
244
+ this.end(signIn, 'failed', holders === null
245
+ ? 'No Claude login arrived within fifteen minutes, so nothing was added. Start a new sign-in.'
246
+ : holders.length > 0
247
+ ? 'A Claude login arrived but was not added: until the sign-in ended, fifteen minutes after it started, these '
248
+ + `Claude Code processes kept it from being imported: ${authSwitchClaudeSignInHoldersText(holders)}. Start a `
249
+ + 'new sign-in once they have ended.'
250
+ : 'A Claude login arrived only as the sign-in ended, fifteen minutes after it started, so it was not added. '
251
+ + 'Start a new sign-in.');
224
252
  return;
225
253
  }
226
- if (!ready) {
254
+ if (holders === null || holders.length > 0) {
227
255
  discardContainerPair(pair);
256
+ if (!sameHolders(signIn.operation.heldBy, holders)) this.change(signIn, { heldBy: holders });
228
257
  this.schedule(signIn);
229
258
  return;
230
259
  }
231
260
  signIn.commit = this.commit(signIn, pair);
232
261
  }
233
262
 
263
+ /**
264
+ * The Claude Code processes that keep a login in the home `identity` from being imported, as the process table lists
265
+ * them: each whose home or credential store is the home, so it may still write the login there, and each whose home
266
+ * or store is unknown, unless it provably started before the home was made at `madeAtMs`. A process's settings are
267
+ * fixed when it starts, and the home's name was random until it was made, so an older process cannot name it.
268
+ */
269
+ private holders(identity: IClaudeContainerDirectoryIdentity, madeAtMs: number): IAuthSwitchClaudeSignInHolder[] {
270
+ const holders: IAuthSwitchClaudeSignInHolder[] = [];
271
+ for (const context of this.runningContexts()) {
272
+ const reason: IAuthSwitchClaudeSignInHolder['reason'] | null =
273
+ (context.nativeHomeIdentity !== null && sameContainerDirectory(context.nativeHomeIdentity, identity))
274
+ || (context.credentialStoreIdentity !== null && sameContainerDirectory(context.credentialStoreIdentity, identity))
275
+ ? 'on_home'
276
+ : (context.nativeHomeIdentity === null || context.credentialStoreIdentity === null)
277
+ && !startedBefore(context.startedAt, madeAtMs) ? 'uninspectable' : null;
278
+ if (reason !== null) holders.push({ pid: context.pid, startedAt: context.startedAt, reason });
279
+ }
280
+ return holders;
281
+ }
282
+
234
283
  private async commit(signIn: IClaudeSignIn, pair: IClaudeContainerPair): Promise<void> {
235
- this.change(signIn, { state: 'committing', prompt: null });
284
+ this.change(signIn, { state: 'committing', prompt: null, heldBy: null });
236
285
  try {
237
286
  const result = await this.grants.importClaudeSignIn({ directory: signIn.directory,
238
287
  credentialRaw: pair.credentialRaw!, configRaw: pair.configRaw });
@@ -259,7 +308,8 @@ export class ClaudeSignInAuthority {
259
308
  signIn.timer = null;
260
309
  try { this.removeHome(signIn.parent, plugins.path.basename(signIn.directory)); }
261
310
  catch { /* The sign-in is over whatever the removal did; the next start removes a home that stayed. */ }
262
- this.change(signIn, { state, prompt: null, error, result, finishedAt: new Date(this.now()).toISOString() });
311
+ this.change(signIn, { state, prompt: null, heldBy: null, error, result,
312
+ finishedAt: new Date(this.now()).toISOString() });
263
313
  }
264
314
 
265
315
  private change(signIn: IClaudeSignIn, patch: Partial<IAuthSwitchClaudeSignInOperation>): void {