@modelprofile.com/authswitch 4.0.0 → 5.1.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.
package/readme.md CHANGED
@@ -187,6 +187,12 @@ authswitch uses `agl authswitch --request <json>` without an AGL package depende
187
187
  A failed or ambiguous transport never falls through to a second local mutation.
188
188
  AGL and the CLI must use matching credential locations and compatible versions.
189
189
 
190
+ `authSwitchMutationReplacesLogin(mutation)` says whether a mutation replaces the
191
+ harness's active login -- `switch`, or `save` without `keepActive` -- and therefore
192
+ concerns the instances running on it. The command line, the dashboard and a host
193
+ such as AGL all read this one rule; `remove` and a save that keeps the login
194
+ active leave the loaded login in place.
195
+
190
196
  OpenCode credentials live in `${XDG_DATA_HOME:-~/.local/share}/opencode/auth.json`.
191
197
  Several providers can be active at once. Saving or switching `openai` preserves
192
198
  every other provider, and `stash openai` without `--keep` clears only OpenAI.
@@ -241,11 +247,31 @@ keeps every other field of the login, and writes the result only while the saved
241
247
  still holds the refresh token it sent. The refresh holds the account store's lock, so a
242
248
  switch started at the same time waits for it and then activates the refreshed login. The
243
249
  **active** login is never refreshed -- Claude Code owns it, and two parties refreshing one
244
- login can invalidate the grant. A refusal is reported, never worked around: `Login expired
245
- or was rejected. Log in again with Claude Code and save it.` for a rejected grant, and a
246
- service failure, an unreachable service or an unsupported response leave the saved login
247
- untouched. Requests carry `User-Agent: authswitch/<version>`, and HTTP 429 is reported as
248
- rate limiting with the service's retry time -- never as an exhausted or expired account.
250
+ login can invalidate the grant. A refusal is reported, never worked around -- a rejected
251
+ grant says so and says what fixes it (below), and a service failure, an unreachable service
252
+ or an unsupported response leave the saved login untouched. Requests carry `User-Agent:
253
+ authswitch/<version>`, and HTTP 429 is reported as rate limiting with the service's retry
254
+ time -- never as an exhausted or expired account.
255
+
256
+ Claude Code also rotates the refresh token of the login it **is** running on, so reading the
257
+ accounts (`list`, `limits`, `active`, `current`, `watch`, the dashboard) mirrors that login
258
+ into its own saved record whenever the two differ. Without it a login replaced outside
259
+ authswitch -- a `/login` in Claude Code, a new device login -- left the stash holding the
260
+ token from before the rotation, which the sign-in service can refuse: it did for two saved
261
+ logins on 2026-09-18. The record keeps its account, its identity and its save time, Claude
262
+ Code's own files are only ever read, and a login with no saved record of its own is never
263
+ saved implicitly; `stash` is what saves a login. An installation whose native login
264
+ authswitch cannot read mirrors nothing, and a read waits at most a second for a lock a
265
+ switch or Claude Code holds before leaving the saved copy to the next read.
266
+
267
+ A grant the sign-in service refused is remembered in the saved record itself, as a hash of
268
+ the refresh token -- never the token -- so a new authswitch run, a restarted watch or a
269
+ second dashboard does not replay a dead login. Saving that login again clears it, as does
270
+ any refresh that replaces the saved credential. Such a login reports `Login expired or was
271
+ rejected. Log in again with Claude Code and save it. Claude Code rotates a login's refresh
272
+ token as it uses it, and the service can refuse the older one a saved copy still holds once
273
+ that login has been replaced outside authswitch; authswitch claude stash --keep saves the
274
+ new one.`
249
275
 
250
276
  OpenCode and Claude saved records are separate owner-only files under
251
277
  `~/.authswitch/opencode` and `~/.authswitch/claude`. Emails are accepted as account
@@ -308,7 +334,7 @@ authswitch watch --json # one JSON event per line
308
334
  14:02:07 Watching Claude Code, Codex every 2m; switching at 95%.
309
335
  14:02:07 Running Codex sessions keep the login they loaded; new sessions use the switched one.
310
336
  14:02:09 Claude Code alice@example.com (type:Max) (5h:17%) (w:4%) ok
311
- 14:02:10 Codex switched bob@example.com → carol@example.com (type:Pro) (5h:3%) (w:12%) — Codex primary 96% ≥ 95%
337
+ 14:02:10 Codex switched bob@example.com → carol@example.com (type:Pro) (5h:3%) (w:12%) — Codex weekly 96% ≥ 95%
312
338
  14:04:09 Claude Code alice@example.com (type:Max) (5h:21%) (w:4%) ok
313
339
  14:04:10 Codex carol@example.com (type:Pro) (5h:4%) (w:12%) ok
314
340
  ```
@@ -481,13 +507,13 @@ Codex
481
507
  │ Plus, unverified │ 2026-09-14 11:32 │ │ │ │
482
508
  └───────────────────┴──────────────────┴────────────────────┴─────────────┴───────────────┘
483
509
 
484
- ┌───────────────────┬───────────────────────┬────────┬───────────┐
485
- │ Account │ Limit type │ Used % │ Resets in │
486
- ├───────────────────┼───────────────────────┼────────┼───────────┤
487
- │ alice@example.com │ Codex secondary │ 12% │ 3d 4h │
488
- │ Pro │ Codex primary │ 37% │ 2h 13m │
489
- │ │ Code review secondary │ 100% │ 3h 59m │
490
- └───────────────────┴───────────────────────┴────────┴───────────┘
510
+ ┌───────────────────┬────────────────────┬────────┬───────────┐
511
+ │ Account │ Limit type │ Used % │ Resets in │
512
+ ├───────────────────┼────────────────────┼────────┼───────────┤
513
+ │ alice@example.com │ Codex weekly │ 12% │ 3d 4h │
514
+ │ Pro │ Codex five-hour │ 37% │ 2h 13m │
515
+ │ │ Code review weekly │ 100% │ 3h 59m │
516
+ └───────────────────┴────────────────────┴────────┴───────────┘
491
517
 
492
518
  ┌────────────────┬───────────────────┐
493
519
  │ Metric │ alice@example.com │
@@ -549,10 +575,18 @@ has the same period; a feature limit, or a general window sharing its period, is
549
575
  named by its label (`Fable weekly 100% · 2d`). Feature limits such as Spark and code
550
576
  review appear in the Limits table and lead the usage cell only when the provider
551
577
  picks one as its headline. The TUI uses the same selection and names. Window
552
- durations come from the provider, not from the plan name or primary/secondary
553
- position; a Pro account with only a general weekly window gets no invented 5-hour
578
+ durations come from the provider, not from the plan name or the slot a window
579
+ arrives in; a Pro account with only a general weekly window gets no invented 5-hour
554
580
  limit.
555
581
 
582
+ Every window label names the window by how long it is -- `Codex weekly`, `Codex
583
+ five-hour`, `Claude weekly`, `Code review weekly` -- so the same length reads the
584
+ same in every harness. The named lengths are `five-hour`, `daily`, `weekly`,
585
+ `monthly` and `annual`, the same set Codex names its own windows with, and its
586
+ primary/secondary slot is shown (`Codex weekly (secondary)`) only where two windows
587
+ of one length would otherwise share a label. A length with no name of its own keeps
588
+ its compact duration (`Codex 90m`) instead of reading as a period it is not.
589
+
556
590
  When accounts of one harness share a label, every table numbers the later ones
557
591
  (`alice@example.com (2)`), so each account keeps its own column in the Details
558
592
  comparison and reads the same everywhere. Missing data stays `n/a`; known zeroes
@@ -585,10 +619,10 @@ Account limits
585
619
  ┃ │ Max │ Claude five-hour │ 12% │ 1h 2m │ │
586
620
  ┃ │ │ Fable weekly │ 88% │ 2d 5h │ │
587
621
  ├──────────────────┼───────────────────┼───────────────────┼────────┼───────────────┼─────────────┤
588
- ┃ Codex │ alice@example.com │ Codex secondary │ 12% │ 3d 4h │ 2026-10-08 │
589
- ┃ │ Pro │ Codex primary │ 37% │ 2h 13m │ auto-renews │
590
- ┃ │ │ Code review secon │ 100% │ 3h 59m │ │
591
- ┃ │ │ dary │ │ │ │
622
+ ┃ Codex │ alice@example.com │ Codex weekly │ 12% │ 3d 4h │ 2026-10-08 │
623
+ ┃ │ Pro │ Codex five-hour │ 37% │ 2h 13m │ auto-renews │
624
+ ┃ │ │ Code review weekl │ 100% │ 3h 59m │ │
625
+ ┃ │ │ y │ │ │ │
592
626
  ┃ ├───────────────────┼───────────────────┼────────┼───────────────┼─────────────┤
593
627
  ┃ │ bob@example.com │ n/a │ n/a │ n/a │ n/a │
594
628
  ┃ │ Plus, unverified │ │ │ │ │
@@ -597,7 +631,7 @@ Account limits
597
631
  ┃ opic │ 20e751c3b707 │ │ │ │ │
598
632
  ┃ │ n/a │ │ │ │ │
599
633
  ├──────────────────┼───────────────────┼───────────────────┼────────┼───────────────┼─────────────┤
600
- ┃ OpenCode / opena │ alice@example.com │ Codex primary │ 0% │ not scheduled │ 2026-10-02 │
634
+ ┃ OpenCode / opena │ alice@example.com │ Codex five-hour │ 0% │ not scheduled │ 2026-10-02 │
601
635
  ┃ i │ Pro │ │ │ │ cancels │
602
636
  └──────────────────┴───────────────────┴───────────────────┴────────┴───────────────┴─────────────┘
603
637
 
@@ -705,8 +739,9 @@ unsaved current login adds how to keep it (`authswitch <harness> stash --keep sa
705
739
  it`).
706
740
 
707
741
  `authswitch <harness> current` prints the same sentence. The same account with a
708
- rotated token is that account refreshing itself and is not reported; re-running the
709
- switch re-arms the check against the file's current contents.
742
+ rotated token is that account refreshing itself and is not reported; for Claude Code the
743
+ rotation is copied into that account's saved record, and re-running the switch re-arms the
744
+ check against the file's current contents.
710
745
 
711
746
  Both commands accept a harness qualifier (`authswitch claude limits`) and `--json`.
712
747
  Neither changes which account is in use: they never activate, switch or clear a login and
@@ -765,13 +800,13 @@ accept schema version 2 and handle null reset timestamps. Scripts should qualify
765
800
  mutation commands with `codex`, `opencode` or `claude`, since all three now register
766
801
  by default. Saved Codex credentials retain their existing format.
767
802
 
768
- For each ChatGPT login, Codex status includes the live plan, primary and secondary
769
- usage windows with remaining percentages and UTC reset times, additional limits
803
+ For each ChatGPT login, Codex status includes the live plan, every usage window the
804
+ service reports with remaining percentages and reset times, additional limits
770
805
  such as code review, credit availability and balance, spend limits when returned,
771
806
  earned reset availability and expiry details, lifetime and peak daily tokens,
772
807
  activity streaks, and the latest seven reported daily token totals.
773
808
 
774
- The Codex adapter implements the read-only GET requests used by Codex 0.154.0 at
809
+ The Codex adapter implements the read-only GET requests used by Codex 0.155.0 at
775
810
  `chatgpt.com/backend-api/wham`: `usage`, `rate-limit-reset-credits`, and `profiles/me`.
776
811
  Billing uses `chatgpt.com/backend-api/accounts/check/v4-2023-04-27`, verified against
777
812
  the official desktop bundle 26.908.40834. It selects the exact credential account;
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: "@modelprofile.com/authswitch",
6
- version: "4.0.0",
6
+ version: "5.1.0",
7
7
  description: "Manage Codex, OpenCode and Claude Code accounts with guided switching and live usage status"
8
8
  }
package/ts/accounts.ts CHANGED
@@ -11,6 +11,18 @@ export const duration = (secondsArg: number): string => secondsArg % 86400 === 0
11
11
  /** The same for a chosen duration that need not be whole minutes: `90s` reads `1m 30s`, never `1.5m`. */
12
12
  export const durationText = (secondsArg: number): string => secondsArg % 60 === 0 ? duration(secondsArg)
13
13
  : secondsArg < 60 ? `${secondsArg}s` : `${Math.floor(secondsArg / 60)}m ${secondsArg % 60}s`;
14
+ /** The window lengths that have a name of their own; every other length is named by its compact duration. */
15
+ const WINDOW_PERIODS: ReadonlyMap<number, string> = new Map([[18000, 'five-hour'], [86400, 'daily'], [604800, 'weekly'], [2592000, 'monthly'], [31536000, 'annual']]);
16
+ /**
17
+ * How every adapter names a usage window in a label: by the window's length alone, so `Codex weekly` and
18
+ * `Claude weekly` mean the same thing and no label repeats a provider's internal slot name. These are the
19
+ * lengths Codex names in 0.155.0 too (`five-hour`, `daily`, `weekly`, `monthly`, `annual`), and a slot a
20
+ * window arrives in says nothing about how long it is. A length with no name of its own keeps its compact
21
+ * form (`90m`), which is honest about a window this vocabulary does not know rather than rounding it.
22
+ *
23
+ * The narrow table cells stay on the compact form throughout (`5h 37% · 2h 13m`); a column is not a label.
24
+ */
25
+ export const usageWindowPeriod = (secondsArg: number): string => WINDOW_PERIODS.get(secondsArg) ?? duration(secondsArg);
14
26
  /** A countdown is a snapshot of the provider's deadline, not proof a quota has reset. */
15
27
  export const until = (timestampArg: string | null, nowArg: number): string => {
16
28
  if (timestampArg === null) return 'Not scheduled';
@@ -1,13 +1,13 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import { FileHarness, type IFileAccount, type IFileHarnessSnapshot } from './classes.fileharness.js';
3
- import { CredentialStore, credentialHash, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw } from './classes.credentialstore.js';
3
+ import { CredentialStore, credentialHash, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw, type IStoredCredential } from './classes.credentialstore.js';
4
4
  import { HarnessProcesses } from './classes.harnessprocesses.js';
5
5
  import { ClaudeAccountStatus } from './classes.claudestatus.js';
6
6
  import { ClaudeCodeLocks } from './classes.claudecodelocks.js';
7
7
  import { ClaudeTokenRefresh, type IClaudeTokens } from './classes.claudetokenrefresh.js';
8
8
  import { CLAUDE_LOGIN_REJECTED, ClaudeLoginRejectedError, ClaudeRateLimitError } from './claudehttp.js';
9
9
  import { writeSecretFileAtomically } from './helpers.js';
10
- import type { IHarnessAccountStatus, IHarnessProcessControl, IHarnessStatusOptions } from './interfaces.harness.js';
10
+ import type { IHarnessAccountStatus, IHarnessProcessControl, IHarnessState, IHarnessStatusOptions } from './interfaces.harness.js';
11
11
 
12
12
  export interface IClaudeCodeHarnessOptions {
13
13
  configDir?: string;
@@ -26,6 +26,25 @@ export interface IClaudeCodeHarnessOptions {
26
26
  now?: () => number;
27
27
  }
28
28
 
29
+ /**
30
+ * How long a read waits for the store and for Claude Code's locks before leaving the saved copy as it found it.
31
+ *
32
+ * Reading accounts must stay as quick as it is while a switch, a refresh or a Claude Code session holds a lock. The
33
+ * saved copy has nothing to finish: whatever the operation holding the lock writes is what the next read mirrors.
34
+ */
35
+ const MIRROR_WAIT_MS = 1_000;
36
+
37
+ /**
38
+ * A saved login the sign-in service refuses, in terms the user can act on.
39
+ *
40
+ * Claude Code rotates the refresh token of the login it is running on (hints.md), and a saved copy keeps the token
41
+ * it was stashed with. Once such a login has been replaced outside authswitch -- a `claude /login`, a new device
42
+ * login -- nothing writes the rotation back, and the service was observed refusing the older token the saved copy
43
+ * still holds (2026-09-18, two saved logins). No retry, no other account and no switch changes that answer; logging
44
+ * in again and saving that login does, so the message says so.
45
+ */
46
+ const SAVED_LOGIN_REJECTED = `${CLAUDE_LOGIN_REJECTED} Claude Code rotates a login's refresh token as it uses it, and the service can refuse the older one a saved copy still holds once that login has been replaced outside authswitch; authswitch claude stash --keep saves the new one.`;
47
+
29
48
  const accountCaches = ['additionalModelOptionsCache', 'additionalModelOptionsAnsweredAt', 'additionalModelCostsCache', 'modelAccessCache', 'orgModelDefaultCache', 'lastSeenOrgDefaultUpdatedAt', 'clientDataCache', 'clientDataCacheSlots', 'autoCompactWindowsCache', 'cachedUsageUtilization'];
30
49
 
31
50
  /**
@@ -36,7 +55,8 @@ const accountCaches = ['additionalModelOptionsCache', 'additionalModelOptionsAns
36
55
  * and write therefore runs under Claude Code's own locks instead of asking for it to be stopped.
37
56
  *
38
57
  * Nothing else refreshes a saved login, so its access token expires a few hours after it was saved. Reading such a
39
- * login's status refreshes it first, the way Claude Code would; the active login's tokens belong to Claude Code.
58
+ * login's status refreshes it first, the way Claude Code would; the active login's tokens belong to Claude Code, and
59
+ * reading the accounts mirrors that login into its own saved copy, so the stash keeps what Claude Code last wrote.
40
60
  */
41
61
  export class ClaudeCodeHarness extends FileHarness {
42
62
  public readonly id = 'claude';
@@ -55,14 +75,6 @@ export class ClaudeCodeHarness extends FileHarness {
55
75
  private readonly settingsFiles: string[];
56
76
  private readonly locks: ClaudeCodeLocks;
57
77
  private readonly tokens: ClaudeTokenRefresh;
58
- /**
59
- * The refresh token the service rejected, per account: a hash, never the token itself.
60
- *
61
- * A rejected grant stays rejected until a new Claude Code login replaces the saved record, so replaying it would
62
- * only add traffic to an auth endpoint -- once every tick of a watch, for as long as it runs. The entry is
63
- * superseded as soon as the record holds a different refresh token.
64
- */
65
- private readonly rejectedGrants = new Map<string, string>();
66
78
 
67
79
  constructor(options: IClaudeCodeHarnessOptions = {}) {
68
80
  super();
@@ -151,6 +163,52 @@ export class ClaudeCodeHarness extends FileHarness {
151
163
  /** The hash that identifies a saved login's refresh token, so a rejected grant is remembered without keeping it. */
152
164
  private grantKey(oauthArg: Record<string, unknown>): string { return credentialHash(credentialText(oauthArg.refreshToken) ?? ''); }
153
165
 
166
+ /**
167
+ * The harness's accounts, with the saved copy of the active login brought up to date first.
168
+ *
169
+ * Claude Code rotates the refresh token of the login it is running on, and until now only a switch or a save wrote
170
+ * that back into the stash. A login replaced outside authswitch between two switches therefore left a saved copy
171
+ * holding the token from before the rotation, which the service was observed refusing -- a saved copy that only
172
+ * turns out to be unusable when that account's status is next read. Every read of the native login now mirrors it
173
+ * into its own saved record, so the stash keeps the credential Claude Code last wrote for an account that has one.
174
+ */
175
+ public async readState(): Promise<IHarnessState> {
176
+ await this.mirrorActiveLogin();
177
+ return super.readState();
178
+ }
179
+
180
+ /**
181
+ * Writes the active login into its own saved record when the two differ.
182
+ *
183
+ * This is the only write a read makes, and it is advisory: a store another operation holds, a lock Claude Code
184
+ * holds and files that keep changing under it all leave the saved copy as it is, because the next read mirrors
185
+ * whatever they wrote. The native files are only ever read, and a login without a saved record of its own is
186
+ * never saved implicitly -- that is what `stash` is for.
187
+ */
188
+ private async mirrorActiveLogin(): Promise<void> {
189
+ try {
190
+ if (this.staleSavedCopy(this.snapshot()) === null) return;
191
+ await this.store.locked(() => this.locks.hold(() => {
192
+ // Under both locks the decision is taken again, from a snapshot proven not to have been read across a write.
193
+ const stale = this.staleSavedCopy(this.stableSnapshot());
194
+ if (stale !== null) this.store.replaceCredential(stale.id, stale.credential);
195
+ return [];
196
+ }, { timeoutMs: MIRROR_WAIT_MS }), { signal: AbortSignal.timeout(MIRROR_WAIT_MS) });
197
+ } catch { /* A saved copy that could not be brought up to date is no reason to fail a read of the accounts. */ }
198
+ }
199
+
200
+ /** The active login's saved record when it no longer holds what Claude Code wrote, with the credential to write. */
201
+ private staleSavedCopy(snapshotArg: IFileHarnessSnapshot): { id: string; credential: Record<string, unknown> } | null {
202
+ // Claude Code runs one login, and an installation this adapter cannot read reports none at all.
203
+ const [active] = snapshotArg.accounts;
204
+ if (!active) return null;
205
+ const id = this.store.id(active.slotId, active.identity);
206
+ let saved: IStoredCredential;
207
+ try { saved = this.store.read(id); } catch { return null; }
208
+ return credentialHash(JSON.stringify(saved.credential)) === credentialHash(JSON.stringify(active.credential))
209
+ ? null : { id, credential: active.credential };
210
+ }
211
+
154
212
  /**
155
213
  * What the native files say about this account: its active login, and why they could not be read at all.
156
214
  *
@@ -182,12 +240,14 @@ export class ClaudeCodeHarness extends FileHarness {
182
240
  if (view.unavailable !== null) {
183
241
  return this.status.unavailable(oauth, `Login refresh: this saved login's access token has expired and cannot be renewed for this installation. ${view.unavailable}`);
184
242
  }
185
- if (this.rejectedGrants.get(id) === this.grantKey(oauth)) return this.status.unavailable(oauth, `Login refresh: ${CLAUDE_LOGIN_REJECTED}`);
243
+ if (this.store.read(id).rejectedGrant === this.grantKey(oauth)) return this.status.unavailable(oauth, `Login refresh: ${SAVED_LOGIN_REJECTED}`);
186
244
  let refreshed: IFileAccount;
187
245
  try { refreshed = await this.refreshSaved(id, optionsArg.signal); }
188
246
  catch (error) {
189
247
  if (optionsArg.signal?.aborted) throw error;
190
- return this.status.unavailable(oauth, `Login refresh: ${error instanceof Error ? error.message : 'the saved login could not be refreshed.'}`,
248
+ const problem = error instanceof ClaudeLoginRejectedError ? SAVED_LOGIN_REJECTED
249
+ : error instanceof Error ? error.message : 'the saved login could not be refreshed.';
250
+ return this.status.unavailable(oauth, `Login refresh: ${problem}`,
191
251
  error instanceof ClaudeRateLimitError ? { retryAt: error.retryAt } : undefined);
192
252
  }
193
253
  return read(refreshed);
@@ -214,7 +274,7 @@ export class ClaudeCodeHarness extends FileHarness {
214
274
  try { tokens = await this.tokens.refresh(oauth); }
215
275
  catch (error) {
216
276
  // Only this token was rejected; a login saved again replaces it and is tried like any other.
217
- if (error instanceof ClaudeLoginRejectedError) this.rejectedGrants.set(id, this.grantKey(oauth));
277
+ if (error instanceof ClaudeLoginRejectedError) this.recordRejectedGrant(id, oauth);
218
278
  throw error;
219
279
  }
220
280
  const latest = this.savedCredential(id);
@@ -224,4 +284,17 @@ export class ClaudeCodeHarness extends FileHarness {
224
284
  return this.savedCredential(id);
225
285
  }, { signal });
226
286
  }
287
+
288
+ /**
289
+ * Remembers a rejected grant in the saved record, so the next process does not send it again either -- a watch
290
+ * would otherwise replay a dead login every tick, and every new CLI run once more. It is written only while the
291
+ * record still holds the token that was sent, and a record that could not be written changes nothing but that:
292
+ * the refusal the caller reports is the answer, and the grant is simply tried once more.
293
+ */
294
+ private recordRejectedGrant(id: string, oauthArg: Record<string, unknown>): void {
295
+ try {
296
+ const latest = credentialRecord(this.savedCredential(id).credential.claudeAiOauth);
297
+ if (latest.refreshToken === oauthArg.refreshToken) this.store.rememberRejectedGrant(id, this.grantKey(oauthArg));
298
+ } catch { /* Recording is advisory; the rejection is reported either way. */ }
299
+ }
227
300
  }
@@ -65,11 +65,12 @@ export class ClaudeCodeLocks {
65
65
  * A held lock never makes authswitch sit on the others: like Claude Code, it releases what it holds, waits and
66
66
  * starts again, so a live session is never blocked by a switch that is still waiting. A lock that could not be
67
67
  * released afterwards is reported as an outcome line, because the write it guarded has completed and the lock
68
- * expires on its own.
68
+ * expires on its own. `timeoutMs` shortens the wait for a caller that must not make a user wait for it -- a read
69
+ * bringing a saved copy up to date has nothing to finish and can leave it to the next read.
69
70
  */
70
- public async hold(actionArg: () => string[]): Promise<string[]> {
71
+ public async hold(actionArg: () => string[], optionsArg: { timeoutMs?: number } = {}): Promise<string[]> {
71
72
  const lockfile = await plugins.loadProperLockfile();
72
- const deadline = Date.now() + this.timeoutMs;
73
+ const deadline = Date.now() + (optionsArg.timeoutMs ?? this.timeoutMs);
73
74
  const held: IHeldLock[] = [];
74
75
  const unreleased: string[] = [];
75
76
  const releaseAll = async (): Promise<void> => {
@@ -2,6 +2,7 @@ import { credentialHash, credentialRecord, credentialText } from './classes.cred
2
2
  import { claudeRequest, ClaudeLoginRejectedError, ClaudeRateLimitError, ClaudeRequestError } from './claudehttp.js';
3
3
  import { latestRetryAt } from './ratelimit.js';
4
4
  import { plainText } from './formatting.js';
5
+ import { usageWindowPeriod } from './accounts.js';
5
6
  import type { IHarnessAccountStatus, IHarnessStatusFact, IHarnessStatusOptions, IHarnessUsageWindow, TUsageSeverity } from './interfaces.harness.js';
6
7
 
7
8
  interface IClaudeUsageWindows {
@@ -22,31 +23,31 @@ const serverName = (value: unknown): string => {
22
23
  return name;
23
24
  };
24
25
 
25
- /** Keyed windows, read only from a usage body that carries no limits[] rows. */
26
+ /** Keyed windows, read only from a usage body that carries no limits[] rows: what each meter is about, and how long its window is. */
26
27
  const KEYED_WINDOWS = [
27
- ['five_hour', 'Claude five-hour', 18000, 'account'], ['seven_day', 'Claude weekly', 604800, 'account'],
28
- ['seven_day_oauth_apps', 'OAuth apps weekly', 604800, 'feature'], ['seven_day_opus', 'Opus weekly', 604800, 'feature'], ['seven_day_sonnet', 'Sonnet weekly', 604800, 'feature'],
28
+ ['five_hour', 'Claude', 18000, 'account'], ['seven_day', 'Claude', 604800, 'account'],
29
+ ['seven_day_oauth_apps', 'OAuth apps', 604800, 'feature'], ['seven_day_opus', 'Opus', 604800, 'feature'], ['seven_day_sonnet', 'Sonnet', 604800, 'feature'],
29
30
  ] as const;
30
31
 
31
32
  const keyedWindows = (usage: Record<string, unknown>): IHarnessUsageWindow[] => {
32
33
  const windows: IHarnessUsageWindow[] = [];
33
- for (const [key, label, durationSeconds, scope] of KEYED_WINDOWS) {
34
+ for (const [key, subject, durationSeconds, scope] of KEYED_WINDOWS) {
34
35
  if (usage[key] == null) continue;
35
36
  const window = credentialRecord(usage[key]);
36
37
  if (window.utilization == null) continue;
37
38
  if (typeof window.utilization !== 'number' || !Number.isFinite(window.utilization) || window.utilization < 0) throw new Error('Unsupported Claude usage utilization.');
38
- windows.push({ label, durationSeconds, scope, usedPercent: window.utilization, resetAt: resetTimestamp(window.resets_at) });
39
+ windows.push({ label: `${subject} ${usageWindowPeriod(durationSeconds)}`, durationSeconds, scope, usedPercent: window.utilization, resetAt: resetTimestamp(window.resets_at) });
39
40
  }
40
41
  return windows;
41
42
  };
42
43
 
43
44
  /**
44
- * The limits[] row groups the window model represents: each group's duration, the period word its
45
- * labels use, and the kind of the group's unscoped account meter, which keeps the plain label.
45
+ * The limits[] row groups the window model represents: each group's duration, which names its windows,
46
+ * and the kind of the group's unscoped account meter, which keeps the plain label.
46
47
  */
47
- const LIMIT_GROUPS: ReadonlyMap<string, { durationSeconds: number; period: string; accountKind: string }> = new Map([
48
- ['session', { durationSeconds: 18000, period: 'five-hour', accountKind: 'session' }],
49
- ['weekly', { durationSeconds: 604800, period: 'weekly', accountKind: 'weekly_all' }],
48
+ const LIMIT_GROUPS: ReadonlyMap<string, { durationSeconds: number; accountKind: string }> = new Map([
49
+ ['session', { durationSeconds: 18000, accountKind: 'session' }],
50
+ ['weekly', { durationSeconds: 604800, accountKind: 'weekly_all' }],
50
51
  ]);
51
52
 
52
53
  const isUsageSeverity = (value: unknown): value is TUsageSeverity => value === 'normal' || value === 'warning' || value === 'critical';
@@ -82,8 +83,9 @@ const limitWindows = (rows: readonly unknown[]): IClaudeUsageWindows => {
82
83
  continue;
83
84
  }
84
85
  const severity = row.severity;
86
+ const period = usageWindowPeriod(meter.durationSeconds);
85
87
  windows.push({
86
- label: subject === undefined ? `Claude ${meter.period}${kind === meter.accountKind ? '' : ` (${kindName})`}` : `${subject} ${meter.period}`,
88
+ label: subject === undefined ? `Claude ${period}${kind === meter.accountKind ? '' : ` (${kindName})`}` : `${subject} ${period}`,
87
89
  durationSeconds: meter.durationSeconds, scope: subject === undefined ? 'account' : 'feature', usedPercent, resetAt,
88
90
  ...(isUsageSeverity(severity) ? { severity } : {}),
89
91
  ...(row.is_active === true ? { headline: true as const } : {}),
package/ts/classes.cli.ts CHANGED
@@ -4,12 +4,12 @@ import { CodexHarness } from './classes.codexharness.js';
4
4
  import { OpenCodeHarness } from './classes.opencodeharness.js';
5
5
  import { ClaudeCodeHarness } from './classes.claudecodeharness.js';
6
6
  import { AuthSwitchTui } from './classes.tui.js';
7
- import { AglAuthSwitchCoordinator, AuthSwitchOperations, type TAuthSwitchCoordinator, type TAuthSwitchMutation } from './classes.operations.js';
7
+ import { AglAuthSwitchCoordinator, AuthSwitchOperations, authSwitchMutationReplacesLogin, type TAuthSwitchCoordinator, type TAuthSwitchMutation } from './classes.operations.js';
8
8
  import { readAccountList } from './classes.accountlist.js';
9
9
  import { AccountListRenderer } from './classes.listrenderer.js';
10
10
  import { accountLimits, activeAccounts, CondensedRenderer } from './classes.limits.js';
11
11
  import { consoleTable } from './consoletable.js';
12
- import { accountName, credentialDriftNote, duration, orderedUsageWindows, readAccountBadges, until, usagePercentText } from './accounts.js';
12
+ import { accountName, credentialDriftNote, orderedUsageWindows, readAccountBadges, until, usagePercentText } from './accounts.js';
13
13
  import { describeHarnessProcesses, describeStopOutcome } from './classes.harnessprocesses.js';
14
14
  import { defaultPreusePrompt, PreuseError, validatePreuseOptions } from './preuse.js';
15
15
  import { parseCommandArgs, parseDurationOption, parseIntegerOption, UsageError } from './cliargs.js';
@@ -247,10 +247,9 @@ ${bold('Environment')}
247
247
  }
248
248
 
249
249
  private mutate(harness: IAuthHarness, mutation: TAuthSwitchMutation, stopModeArg?: TStopMode): Promise<IHarnessOutcome> {
250
- const replacesNativeLogin = mutation.action === 'switch' || (mutation.action === 'save' && !mutation.keepActive);
251
250
  return this.operations.run(harness, mutation, canPrompt() ? async message => await this.out.prompts.ask({
252
251
  name: 'waitForIdle', type: 'confirm', message, default: false,
253
- }) === true : undefined, replacesNativeLogin ? () => this.settleRunningInstances(harness, stopModeArg) : undefined);
252
+ }) === true : undefined, authSwitchMutationReplacesLogin(mutation) ? () => this.settleRunningInstances(harness, stopModeArg) : undefined);
254
253
  }
255
254
 
256
255
  /**
@@ -383,7 +382,8 @@ ${bold('Environment')}
383
382
  const now = Date.now();
384
383
  process.stdout.write('Reset schedule reported after the prompt:\n');
385
384
  await consoleTable(this.out, windows, [
386
- { key: 'window', title: 'Window', value: row => `${plainText(row.label)} (${duration(row.durationSeconds)})` },
385
+ // The label already names the window by its length, so the column never repeats it.
386
+ { key: 'window', title: 'Window', value: row => plainText(row.label) },
387
387
  { key: 'usage', title: 'Used', value: row => usagePercentText(row.usedPercent) },
388
388
  { key: 'reset', title: 'Reset in', value: row => until(row.resetAt, now) },
389
389
  ]);
@@ -1,6 +1,7 @@
1
1
  import type { ICodexIdentity } from './interfaces.js';
2
2
  import type { IHarnessAccountStatus, IHarnessStatusOptions, IHarnessStatusSummary } from './interfaces.harness.js';
3
3
  import { commitinfo } from './00_commitinfo_data.js';
4
+ import { usageWindowPeriod } from './accounts.js';
4
5
  import { latestRetryAt, retryAtFrom } from './ratelimit.js';
5
6
 
6
7
  // Verified Chromium-compatible billing request profile, independent of any locally installed browser.
@@ -38,9 +39,13 @@ class StatusRateLimitError extends StatusRequestError {
38
39
  }
39
40
 
40
41
  /**
41
- * Read-only ChatGPT status contract verified against Codex 0.154.0.
42
+ * Read-only ChatGPT status contract verified against Codex 0.155.0.
42
43
  * These source-defined backend routes are isolated here. No token refresh,
43
44
  * credential writes, app-server lifecycle changes or reset consumption occur.
45
+ *
46
+ * The service decides how many usage windows an account has: it sends the ones it keeps for that
47
+ * account, leaving the other slot `null`, so a login whose general quota is a single weekly window is
48
+ * reported with that one window and no five-hour limit is invented to fill the gap.
44
49
  */
45
50
  export class CodexAccountStatus {
46
51
  constructor(private readonly fetcher: typeof fetch = globalThis.fetch, private readonly now: () => number = Date.now) {}
@@ -184,16 +189,23 @@ export class CodexAccountStatus {
184
189
  // The service's own reading of the same windows, so a view that shows the windows need not repeat it.
185
190
  facts.push({ section: 'Limits & credits', summaryKey: 'usageWindows', label: `${nameArg} usage allowed`, value: boolean(limit.allowed) ? 'yes' : 'no' });
186
191
  facts.push({ section: 'Limits & credits', summaryKey: 'usageWindows', label: `${nameArg} limit reached`, value: boolean(limit.limit_reached) ? 'yes' : 'no' });
187
- for (const [key, name] of [['primary_window', 'primary'], ['secondary_window', 'secondary']]) {
188
- if (limit[key] == null) continue;
189
- const window = record(limit[key]);
190
- const used = numeric(window.used_percent);
191
- const durationSeconds = numeric(window.limit_window_seconds);
192
- const minutes = durationSeconds / 60;
193
- const duration = minutes % 1440 === 0 ? `${minutes / 1440}d` : minutes % 60 === 0 ? `${minutes / 60}h` : `${minutes}m`;
194
- const resetAt = timestamp(window.reset_at);
195
- usageWindows.push({ label: `${nameArg} ${name}`, scope: scopeArg, durationSeconds, usedPercent: used, resetAt });
196
- facts.push({ section: 'Usage', summaryKey: 'usageWindows', label: `${nameArg} ${name} (${duration})`, value: `${used}% used, ${Math.max(0, 100 - used)}% remaining; resets ${resetAt}` });
192
+ // Both slots are read before either is named: only a second window of the same length makes the
193
+ // vendor's slot worth showing, and that is decided from the pair.
194
+ const windows = (['primary_window', 'secondary_window'] as const).flatMap((slotArg) => {
195
+ if (limit[slotArg] == null) return [];
196
+ const window = record(limit[slotArg]);
197
+ return [{
198
+ slot: slotArg === 'primary_window' ? 'primary' : 'secondary',
199
+ usedPercent: numeric(window.used_percent), durationSeconds: numeric(window.limit_window_seconds), resetAt: timestamp(window.reset_at),
200
+ }];
201
+ });
202
+ for (const window of windows) {
203
+ // Named by its length, as Codex names its own windows: a slot carries no length, and which
204
+ // window the service puts in it varies by plan and by which limit is currently binding.
205
+ const shared = windows.some(other => other !== window && other.durationSeconds === window.durationSeconds);
206
+ const label = `${nameArg} ${usageWindowPeriod(window.durationSeconds)}${shared ? ` (${window.slot})` : ''}`;
207
+ usageWindows.push({ label, scope: scopeArg, durationSeconds: window.durationSeconds, usedPercent: window.usedPercent, resetAt: window.resetAt });
208
+ facts.push({ section: 'Usage', summaryKey: 'usageWindows', label, value: `${window.usedPercent}% used, ${Math.max(0, 100 - window.usedPercent)}% remaining; resets ${window.resetAt}` });
197
209
  }
198
210
  };
199
211
  appendLimit('Codex', bodyArg.rate_limit, 'account');
@@ -62,6 +62,14 @@ export interface IStoredCredential {
62
62
  savedAt: string;
63
63
  /** Harness-owned payload. This object must never cross the public account interface. */
64
64
  credential: Record<string, unknown>;
65
+ /**
66
+ * A refresh token of this credential that the provider refused, as a hash -- never the token.
67
+ *
68
+ * A grant the provider rejected stays rejected, so replaying it only adds traffic to an auth endpoint, once per
69
+ * status read for as long as the record holds that token. Remembering it in the record instead of in memory is
70
+ * what makes that true for the next process too. Every write that replaces the credential drops it.
71
+ */
72
+ rejectedGrant?: string;
65
73
  }
66
74
 
67
75
  /** The root of authswitch's own state: `AUTHSWITCH_HOME`, or `~/.authswitch`. */
@@ -89,8 +97,10 @@ export class CredentialStore {
89
97
  this.lockTimeoutMs = optionsArg.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
90
98
  }
91
99
  public id(slot: string, identity: string): string { return credentialHash(JSON.stringify([slot, identity])); }
100
+ /** Whether a value names a credential hash: a rejected grant, or an account id. */
101
+ private isHash(value: unknown): boolean { return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); }
92
102
  private file(id: string): string {
93
- if (!/^[a-f0-9]{64}$/.test(id)) throw new Error('Invalid saved account ID.');
103
+ if (!this.isHash(id)) throw new Error('Invalid saved account ID.');
94
104
  return plugins.path.join(this.dir, `${id}.json`);
95
105
  }
96
106
  public read(id: string): IStoredCredential {
@@ -99,6 +109,9 @@ export class CredentialStore {
99
109
  || !credentialText(value.label) || typeof value.savedAt !== 'string' || !Number.isFinite(Date.parse(value.savedAt))
100
110
  || this.id(value.slotId as string, value.identity as string) !== id) throw new Error('Saved account metadata is invalid; restore it from a verified backup.');
101
111
  credentialRecord(value.credential);
112
+ // Advisory state: a rejected grant that is not a hash says nothing about the login and is dropped, never made
113
+ // the record's problem -- the grant is sent once more instead, which is what a record without one means anyway.
114
+ if (!this.isHash(value.rejectedGrant)) delete value.rejectedGrant;
102
115
  return value as unknown as IStoredCredential;
103
116
  }
104
117
  public list(): IStoredCredential[] {
@@ -115,7 +128,18 @@ export class CredentialStore {
115
128
  * it was saved stay as they are. The caller holds the lock and has checked the record it replaces.
116
129
  */
117
130
  public replaceCredential(id: string, credential: Record<string, unknown>): IStoredCredential {
118
- return this.write({ ...this.read(id), credential }, 'The refreshed login could not be verified after saving it.');
131
+ const record = this.read(id);
132
+ // A remembered rejection belongs to the credential it was taken from; the new one has not been refused.
133
+ delete record.rejectedGrant;
134
+ return this.write({ ...record, credential }, 'The refreshed login could not be verified after saving it.');
135
+ }
136
+ /**
137
+ * Remembers that the provider refused the refresh token this record holds, as `grantHashArg`. The caller holds the
138
+ * lock and has checked that the record still holds the token it sent.
139
+ */
140
+ public rememberRejectedGrant(id: string, grantHashArg: string): IStoredCredential {
141
+ if (!this.isHash(grantHashArg)) throw new Error('A rejected grant is remembered as a credential hash.');
142
+ return this.write({ ...this.read(id), rejectedGrant: grantHashArg }, 'The refused sign-in could not be recorded.');
119
143
  }
120
144
  private write(record: IStoredCredential, failureArg: string): IStoredCredential {
121
145
  plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
@@ -1,4 +1,4 @@
1
- import type { IAuthHarness, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessOutcome, IHarnessProcessControl, IHarnessState, IHarnessStatusOptions } from './interfaces.harness.js';
1
+ import type { IAuthHarness, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessOutcome, IHarnessProcessControl, IHarnessState, IHarnessStatusOptions, THarnessResult } from './interfaces.harness.js';
2
2
  import { credentialHash, CredentialStore, type IStoredCredential, type ISwitchRecord } from './classes.credentialstore.js';
3
3
  import { pauseSynchronously } from './helpers.js';
4
4
  import { plainText } from './formatting.js';
@@ -52,7 +52,7 @@ export abstract class FileHarness implements IAuthHarness {
52
52
  * credential out of the stash. Both the retry count and the pause between attempts are bounded; an
53
53
  * unstable file is reported, never guessed at.
54
54
  */
55
- private stableSnapshot(): IFileHarnessSnapshot {
55
+ protected stableSnapshot(): IFileHarnessSnapshot {
56
56
  let failure: unknown;
57
57
  for (let attempt = 0; attempt < 5; attempt++) {
58
58
  if (attempt) pauseSynchronously(20);
@@ -72,7 +72,11 @@ export abstract class FileHarness implements IAuthHarness {
72
72
  if (account.identity !== entry.identity) throw new Error('Saved credential does not match its account identity.');
73
73
  return account;
74
74
  }
75
- public readState(): IHarnessState {
75
+ /**
76
+ * The harness's accounts. A subclass may have to bring its own state up to date first -- the Claude Code adapter
77
+ * mirrors the active login into its saved copy -- which is why this is the interface's `THarnessResult`.
78
+ */
79
+ public readState(): THarnessResult<IHarnessState> {
76
80
  const snapshot = this.snapshot();
77
81
  const saved = this.store.list();
78
82
  const accounts: IHarnessState['accounts'] = saved.map(entry => {
@@ -243,9 +247,9 @@ export abstract class FileHarness implements IAuthHarness {
243
247
  public removeAccount(id: string): Promise<IHarnessOutcome> {
244
248
  return this.outcome(() => { const account = this.store.read(id); this.store.remove(id); return `Removed saved login for ${plainText(account.label)}; the active login is preserved.`; }, false);
245
249
  }
246
- public diagnose(): IHarnessOutcome {
250
+ public async diagnose(): Promise<IHarnessOutcome> {
247
251
  try {
248
- const state = this.readState();
252
+ const state = await this.readState();
249
253
  return { lines: [`${this.label}: ${state.accounts.filter(account => account.isActive).length} active login(s), ${state.accounts.filter(account => account.isStashed).length} saved account(s).`, this.loginHint], problems: state.saveUnavailableReason ? [state.saveUnavailableReason] : [] };
250
254
  } catch { return { lines: [], problems: ['Credential storage could not be verified. Check file permissions and saved credential records.'] }; }
251
255
  }