@modelprofile.com/authswitch 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist_ts/00_commitinfo_data.js +3 -3
  2. package/dist_ts/accounts.d.ts +42 -15
  3. package/dist_ts/accounts.js +98 -27
  4. package/dist_ts/classes.accountlist.d.ts +0 -25
  5. package/dist_ts/classes.accountlist.js +2 -216
  6. package/dist_ts/classes.claudecodeharness.d.ts +59 -1
  7. package/dist_ts/classes.claudecodeharness.js +130 -14
  8. package/dist_ts/classes.claudecodelocks.d.ts +35 -0
  9. package/dist_ts/classes.claudecodelocks.js +117 -0
  10. package/dist_ts/classes.claudestatus.d.ts +15 -2
  11. package/dist_ts/classes.claudestatus.js +88 -73
  12. package/dist_ts/classes.claudetokenrefresh.d.ts +32 -0
  13. package/dist_ts/classes.claudetokenrefresh.js +77 -0
  14. package/dist_ts/classes.cli.d.ts +14 -7
  15. package/dist_ts/classes.cli.js +125 -66
  16. package/dist_ts/classes.codexharness.d.ts +4 -0
  17. package/dist_ts/classes.codexharness.js +5 -1
  18. package/dist_ts/classes.codexstatus.d.ts +2 -1
  19. package/dist_ts/classes.codexstatus.js +25 -9
  20. package/dist_ts/classes.credentialstore.d.ts +28 -2
  21. package/dist_ts/classes.credentialstore.js +41 -12
  22. package/dist_ts/classes.fileharness.d.ts +37 -17
  23. package/dist_ts/classes.fileharness.js +58 -24
  24. package/dist_ts/classes.limits.d.ts +46 -7
  25. package/dist_ts/classes.limits.js +94 -36
  26. package/dist_ts/classes.listrenderer.d.ts +17 -0
  27. package/dist_ts/classes.listrenderer.js +313 -0
  28. package/dist_ts/classes.login.d.ts +1 -1
  29. package/dist_ts/classes.login.js +1 -1
  30. package/dist_ts/classes.opencodeharness.d.ts +4 -0
  31. package/dist_ts/classes.opencodeharness.js +7 -3
  32. package/dist_ts/classes.operations.js +3 -2
  33. package/dist_ts/classes.tui.js +4 -3
  34. package/dist_ts/classes.watch.d.ts +108 -0
  35. package/dist_ts/classes.watch.js +219 -0
  36. package/dist_ts/classes.watchlock.d.ts +33 -0
  37. package/dist_ts/classes.watchlock.js +118 -0
  38. package/dist_ts/claudehttp.d.ts +39 -0
  39. package/dist_ts/claudehttp.js +83 -0
  40. package/dist_ts/cliargs.d.ts +36 -0
  41. package/dist_ts/cliargs.js +60 -0
  42. package/dist_ts/consoletable.d.ts +21 -0
  43. package/dist_ts/consoletable.js +63 -0
  44. package/dist_ts/helpers.d.ts +7 -0
  45. package/dist_ts/helpers.js +16 -1
  46. package/dist_ts/index.d.ts +3 -0
  47. package/dist_ts/index.js +4 -1
  48. package/dist_ts/interfaces.harness.d.ts +60 -11
  49. package/dist_ts/interfaces.list.d.ts +3 -1
  50. package/dist_ts/plugins.d.ts +7 -0
  51. package/dist_ts/plugins.js +6 -1
  52. package/dist_ts/ratelimit.d.ts +8 -0
  53. package/dist_ts/ratelimit.js +13 -0
  54. package/dist_ts/watchpolicy.d.ts +44 -0
  55. package/dist_ts/watchpolicy.js +82 -0
  56. package/package.json +5 -3
  57. package/readme.md +315 -110
  58. package/ts/00_commitinfo_data.ts +3 -3
  59. package/ts/accounts.ts +109 -34
  60. package/ts/classes.accountlist.ts +2 -219
  61. package/ts/classes.claudecodeharness.ts +125 -12
  62. package/ts/classes.claudecodelocks.ts +132 -0
  63. package/ts/classes.claudestatus.ts +87 -54
  64. package/ts/classes.claudetokenrefresh.ts +85 -0
  65. package/ts/classes.cli.ts +112 -53
  66. package/ts/classes.codexharness.ts +4 -0
  67. package/ts/classes.codexstatus.ts +17 -7
  68. package/ts/classes.credentialstore.ts +53 -9
  69. package/ts/classes.fileharness.ts +67 -29
  70. package/ts/classes.limits.ts +110 -36
  71. package/ts/classes.listrenderer.ts +328 -0
  72. package/ts/classes.login.ts +1 -1
  73. package/ts/classes.opencodeharness.ts +6 -2
  74. package/ts/classes.operations.ts +2 -1
  75. package/ts/classes.tui.ts +3 -2
  76. package/ts/classes.watch.ts +263 -0
  77. package/ts/classes.watchlock.ts +100 -0
  78. package/ts/claudehttp.ts +92 -0
  79. package/ts/cliargs.ts +71 -0
  80. package/ts/consoletable.ts +62 -0
  81. package/ts/helpers.ts +14 -0
  82. package/ts/index.ts +3 -0
  83. package/ts/interfaces.harness.ts +60 -5
  84. package/ts/interfaces.list.ts +3 -1
  85. package/ts/plugins.ts +9 -0
  86. package/ts/ratelimit.ts +14 -0
  87. package/ts/watchpolicy.ts +121 -0
package/ts/classes.cli.ts CHANGED
@@ -5,29 +5,34 @@ import { OpenCodeHarness } from './classes.opencodeharness.js';
5
5
  import { ClaudeCodeHarness } from './classes.claudecodeharness.js';
6
6
  import { AuthSwitchTui } from './classes.tui.js';
7
7
  import { AglAuthSwitchCoordinator, AuthSwitchOperations, type TAuthSwitchCoordinator, type TAuthSwitchMutation } from './classes.operations.js';
8
- import { AccountListRenderer, readAccountList } from './classes.accountlist.js';
8
+ import { readAccountList } from './classes.accountlist.js';
9
+ import { AccountListRenderer } from './classes.listrenderer.js';
9
10
  import { accountLimits, activeAccounts, CondensedRenderer } from './classes.limits.js';
10
- import { credentialDriftNote, duration, orderedUsageWindows, readAccountBadges, until } from './accounts.js';
11
+ import { consoleTable } from './consoletable.js';
12
+ import { accountName, credentialDriftNote, duration, orderedUsageWindows, readAccountBadges, until, usagePercentText } from './accounts.js';
11
13
  import { describeHarnessProcesses, describeStopOutcome } from './classes.harnessprocesses.js';
12
14
  import { defaultPreusePrompt, PreuseError, validatePreuseOptions } from './preuse.js';
15
+ import { parseCommandArgs, parseDurationOption, parseIntegerOption, UsageError } from './cliargs.js';
16
+ import { AuthSwitchWatch, watchEventText } from './classes.watch.js';
17
+ import { WatchBusyError, WatchLock } from './classes.watchlock.js';
18
+ import { authSwitchHome } from './classes.credentialstore.js';
13
19
  import type { CodexSwitcher } from './classes.codexswitcher.js';
14
20
  import type { IAuthHarness, IHarnessAccount, IHarnessOutcome, IHarnessProcess, IHarnessState, IHarnessStopOutcome, IHarnessPreuseResult } from './interfaces.harness.js';
15
21
  import { bold, dim, green, orange, plainText, red } from './formatting.js';
16
22
 
17
23
  const canPrompt = (): boolean =>
18
24
  process.stdin.isTTY === true && process.stdout.isTTY === true && !process.env.CI;
19
- const COMMANDS = ['login', 'stash', 'list', 'ls', 'limits', 'active', 'use', 'preuse', 'current', 'drop', 'rm', 'doctor'];
25
+ const COMMANDS = ['login', 'stash', 'list', 'ls', 'limits', 'active', 'use', 'preuse', 'watch', 'current', 'drop', 'rm', 'doctor'];
20
26
  /** What to do about the harness's own running instances before a credential file is rewritten. */
21
27
  export type TStopMode = 'stop' | 'force-stop' | 'keep-running';
22
28
  const STOP_FLAGS = ['--stop', '--force-stop', '--keep-running'];
23
29
  /** Commands that replace or clear a native login, and therefore offer to stop its instances. */
24
30
  const STOP_COMMANDS = ['use', 'stash'];
25
- /** Read-only overviews that work across every registered harness and support --json. */
31
+ /** Overviews across every registered harness; none of them changes which account is in use, and all support --json. */
26
32
  const OVERVIEW_COMMANDS = ['list', 'ls', 'limits', 'active'];
27
-
28
- /** An account as prompts and reports name it: its label, qualified by its credential slot when it has one. */
29
- const accountName = (accountArg: IHarnessAccount): string =>
30
- `${plainText(accountArg.label)}${accountArg.slotId ? ` (${plainText(accountArg.slotId)})` : ''}`;
33
+ const WATCH_USAGE = 'Usage: authswitch [harness] watch [harness] [--interval <duration>] [--threshold <percent>] [--dry-run] [--once] [--json]';
34
+ const WATCH_INTERVAL = { defaultMs: 120_000, minMs: 60_000, maxMs: 86_400_000 };
35
+ const WATCH_THRESHOLD = { default: 95, min: 50, max: 100 };
31
36
 
32
37
  /** The account a command acts on, named as it was offered, with the badge it was offered with. */
33
38
  interface IChosenAccount {
@@ -73,6 +78,9 @@ ${bold('Usage')}
73
78
  authswitch active one row per provider: which account is in use, and since when
74
79
  authswitch <harness> preuse <account> [--prompt <text>] [--model <id>]
75
80
  send one prompt through that account without switching
81
+ authswitch watch [harness] [--interval <duration>] [--threshold <percent>] [--dry-run] [--once] [--json]
82
+ check usage every 2m and switch to a better saved account
83
+ when the active one reaches the threshold (95%)
76
84
  authswitch <harness> stash [account/provider] [--keep]
77
85
  save one active login; --keep leaves it active
78
86
  authswitch <harness> use [account] [--stop|--force-stop|--keep-running]
@@ -92,14 +100,24 @@ ${bold('Options')}
92
100
  -v, --version show the version
93
101
  -i, --interactive open the guided account manager
94
102
  --tui open the terminal management dashboard
95
- --json JSON output for list / ls / limits / active only
103
+ --json JSON output for list / ls / limits / active; one JSON event per line for
104
+ watch, written after the command (authswitch watch --json)
96
105
  --stop stop this harness's running instances before the switch
97
106
  --force-stop the same, then kill instances that ignore SIGTERM
98
107
  --keep-running switch without stopping anything
99
108
 
100
- A switch works while OpenCode or Claude Code is running. Instances that keep running hold
101
- the previous login in memory and can write it back at their next token refresh, so the
102
- switch offers to stop them first; without a terminal it leaves them running and says so.
109
+ A switch works while OpenCode or Claude Code is running. Claude Code picks the new login
110
+ up on its next request, so its sessions are stopped only on --stop or --force-stop.
111
+ OpenCode keeps the previous login in memory and can write it back at its next token
112
+ refresh, so an OpenCode switch offers to stop it first; without a terminal it leaves
113
+ it running and says so.
114
+
115
+ Watch covers the harnesses that support automatic switching (${[...this.harnesses.values()].filter(harness => harness.autoSwitch === true).map(harness => harness.id).join(', ') || 'none registered'}).
116
+ --interval takes seconds or a unit (120, 90s, 2m; from 1m to 1d); --threshold takes 50 to 100.
117
+ A switch goes to a saved account at least 10 points below the threshold, or, when every
118
+ account is used up, to the one usable again first. No session of yours is stopped, though
119
+ a Codex switch restarts Codex' own app-server; --dry-run only reports, --once checks once.
120
+ One watch runs per AUTHSWITCH_HOME.
103
121
 
104
122
  Preuse consumes the selected account's quota. Its default prompt is:
105
123
  "${defaultPreusePrompt}"
@@ -121,6 +139,11 @@ ${bold('Environment')}
121
139
  const qualified = args[0] !== 'preuse';
122
140
  return await this.commandPreuse(qualified ? this.harnesses.get(args[0]) : undefined, args.slice(qualified ? 2 : 1));
123
141
  }
142
+ // Watch owns its options, --json among them.
143
+ if (args[0] === 'watch' || (this.harnesses.has(args[0]) && args[1] === 'watch')) {
144
+ const qualified = args[0] !== 'watch';
145
+ return await this.commandWatch(qualified ? this.harnesses.get(args[0]) : undefined, args.slice(qualified ? 2 : 1));
146
+ }
124
147
  const jsonFlags = args.filter(value => value === '--json');
125
148
  const json = jsonFlags.length === 1;
126
149
  if (jsonFlags.length > 1) { process.stderr.write('Use --json only once.\n'); return 2; }
@@ -234,14 +257,17 @@ ${bold('Environment')}
234
257
  * Decide what happens to the harness's own running instances before its credential file changes.
235
258
  *
236
259
  * The switch itself no longer depends on this: it writes atomically and re-saves the outgoing
237
- * login either way. What a running instance still costs is durability -- it holds the previous
238
- * login in memory and can write it back at its next refresh -- so the instances are named and
239
- * stopping them is offered, never assumed. Without a terminal and without a flag nothing is
240
- * signalled, and only pids enumerated here for this user are ever signalled at all.
260
+ * login either way. A harness that picks up swaps live needs nothing here, so its instances are
261
+ * only stopped on an explicit --stop or --force-stop. Any other running instance still costs
262
+ * durability -- it holds the previous login in memory and can write it back at its next refresh --
263
+ * so the instances are named and stopping them is offered, never assumed. Without a terminal and
264
+ * without a flag nothing is signalled, and only pids enumerated here for this user are ever
265
+ * signalled at all.
241
266
  */
242
267
  private async settleRunningInstances(harnessArg: IAuthHarness, modeArg?: TStopMode): Promise<void> {
243
268
  const control = harnessArg.processes;
244
- if (!control) return;
269
+ const stopRequested = modeArg === 'stop' || modeArg === 'force-stop';
270
+ if (!control || (harnessArg.liveSwap === true && !stopRequested)) return;
245
271
  let running: IHarnessProcess[];
246
272
  try { running = control.list(); }
247
273
  catch { process.stdout.write(`${orange(`Could not check for running ${plainText(harnessArg.label)} processes; continuing with the switch.`)}\n`); return; }
@@ -250,7 +276,7 @@ ${bold('Environment')}
250
276
  if (control.stopUnavailableReason !== null) { process.stdout.write(`${dim(control.stopUnavailableReason)}\n`); return; }
251
277
  const stoppable = running.filter(item => !item.isAncestor);
252
278
  if (!stoppable.length || modeArg === 'keep-running') return;
253
- let stop = modeArg === 'stop' || modeArg === 'force-stop';
279
+ let stop = stopRequested;
254
280
  if (modeArg === undefined) {
255
281
  if (!canPrompt()) { process.stdout.write(`${dim('Leaving them running. Use --stop, --force-stop or --keep-running to decide this without a terminal.')}\n`); return; }
256
282
  stop = await this.out.prompts.ask({
@@ -298,28 +324,18 @@ ${bold('Environment')}
298
324
 
299
325
  private async commandPreuse(harnessArg: IAuthHarness | undefined, argsArg: string[]): Promise<number> {
300
326
  let reference: string | undefined;
301
- let prompt = defaultPreusePrompt;
327
+ let prompt: string;
302
328
  let model: string | undefined;
303
- const flags = new Set<string>();
304
329
  try {
305
- for (let index = 0; index < argsArg.length; index++) {
306
- const argument = argsArg[index];
307
- if (argument.startsWith('--prompt=') || argument.startsWith('--model=') || argument === '--prompt' || argument === '--model') {
308
- const separator = argument.indexOf('=');
309
- const flag = separator < 0 ? argument : argument.slice(0, separator);
310
- if (flags.has(flag)) throw new PreuseError(`Use ${flag} only once.`);
311
- flags.add(flag);
312
- const value = separator < 0 ? argsArg[++index] : argument.slice(separator + 1);
313
- if (value === undefined) throw new PreuseError(`${flag} requires a value.`);
314
- if (flag === '--prompt') prompt = value; else model = value;
315
- } else if (argument.startsWith('-') || reference !== undefined) {
316
- throw new PreuseError('Usage: authswitch [harness] preuse <account> [--prompt <text>] [--model <id>]');
317
- } else reference = argument;
318
- }
330
+ const parsed = parseCommandArgs(argsArg, { values: ['--prompt', '--model'], flags: [], maxPositionals: 1,
331
+ usage: 'Usage: authswitch [harness] preuse <account> [--prompt <text>] [--model <id>]' });
332
+ [reference] = parsed.positionals;
333
+ prompt = parsed.values.get('--prompt') ?? defaultPreusePrompt;
334
+ model = parsed.values.get('--model');
319
335
  validatePreuseOptions({ prompt, model });
320
- if (!reference && !canPrompt()) throw new PreuseError('preuse requires an account outside an interactive terminal.');
336
+ if (!reference && !canPrompt()) throw new UsageError('preuse requires an account outside an interactive terminal.');
321
337
  } catch (error) {
322
- process.stderr.write(`${error instanceof PreuseError ? error.message : 'Invalid preuse arguments.'}\n`);
338
+ process.stderr.write(`${error instanceof UsageError || error instanceof PreuseError ? error.message : 'Invalid preuse arguments.'}\n`);
323
339
  return 2;
324
340
  }
325
341
  const harness = harnessArg ?? await this.selectHarness();
@@ -366,13 +382,11 @@ ${bold('Environment')}
366
382
  if (windows?.length) {
367
383
  const now = Date.now();
368
384
  process.stdout.write('Reset schedule reported after the prompt:\n');
369
- if ((process.stdout.columns ?? 100) < 40) {
370
- for (const window of windows) process.stdout.write(`${plainText(window.label)} (${duration(window.durationSeconds)})\n Used: ${window.usedPercent}%\n Reset in: ${until(window.resetAt, now)}\n`);
371
- } else await this.out.table(windows, { columns: [
385
+ await consoleTable(this.out, windows, [
372
386
  { key: 'window', title: 'Window', value: row => `${plainText(row.label)} (${duration(row.durationSeconds)})` },
373
- { key: 'usage', title: 'Used', value: row => `${row.usedPercent}%` },
387
+ { key: 'usage', title: 'Used', value: row => usagePercentText(row.usedPercent) },
374
388
  { key: 'reset', title: 'Reset in', value: row => until(row.resetAt, now) },
375
- ], overflow: 'wrap' });
389
+ ]);
376
390
  } else process.stdout.write('Reset schedule could not be verified; the completed prompt will not be repeated.\n');
377
391
  } catch {
378
392
  process.stderr.write('Prompt completed, but its reset schedule could not be verified or displayed. The prompt will not be repeated.\n');
@@ -384,6 +398,57 @@ ${bold('Environment')}
384
398
  }
385
399
  }
386
400
 
401
+ /**
402
+ * Watch usage and switch automatically; see `AuthSwitchWatch`. SIGINT and SIGTERM end it cleanly with status 0.
403
+ * `--once` runs one check and exits 1 when a switch it attempted did not complete.
404
+ */
405
+ private async commandWatch(harnessArg: IAuthHarness | undefined, argsArg: string[]): Promise<number> {
406
+ let options: { harnesses: IAuthHarness[]; intervalMs: number; threshold: number; dryRun: boolean; once: boolean; json: boolean };
407
+ try {
408
+ const parsed = parseCommandArgs(argsArg, { values: ['--interval', '--threshold'], flags: ['--dry-run', '--once', '--json', '--help', '-h'],
409
+ maxPositionals: harnessArg ? 0 : 1, usage: WATCH_USAGE });
410
+ if (parsed.flags.has('--help') || parsed.flags.has('-h')) { process.stdout.write(`${this.usage()}\n`); return 0; }
411
+ const named = parsed.positionals[0];
412
+ const harness = harnessArg ?? (named === undefined ? undefined : this.harnesses.get(named));
413
+ if (named !== undefined && !harness) throw new UsageError(`Unknown harness "${plainText(named)}". ${WATCH_USAGE}`);
414
+ if (harness && harness.autoSwitch !== true) throw new UsageError(`${plainText(harness.label)} does not support automatic switching.`);
415
+ const harnesses = (harness ? [harness] : [...this.harnesses.values()].filter(item => item.autoSwitch === true))
416
+ .sort((left, right) => left.label.localeCompare(right.label) || left.id.localeCompare(right.id));
417
+ if (!harnesses.length) throw new UsageError('No registered harness supports automatic switching.');
418
+ const interval = parsed.values.get('--interval');
419
+ const threshold = parsed.values.get('--threshold');
420
+ options = {
421
+ harnesses, dryRun: parsed.flags.has('--dry-run'), once: parsed.flags.has('--once'), json: parsed.flags.has('--json'),
422
+ intervalMs: interval === undefined ? WATCH_INTERVAL.defaultMs : parseDurationOption('--interval', interval, WATCH_INTERVAL),
423
+ threshold: threshold === undefined ? WATCH_THRESHOLD.default : parseIntegerOption('--threshold', threshold, WATCH_THRESHOLD),
424
+ };
425
+ } catch (error) {
426
+ if (!(error instanceof UsageError)) throw error;
427
+ process.stderr.write(`${error.message}\n`);
428
+ return 2;
429
+ }
430
+ const watch = new AuthSwitchWatch({
431
+ harnesses: options.harnesses, operations: this.operations, lock: new WatchLock(authSwitchHome()),
432
+ intervalMs: options.intervalMs, threshold: options.threshold, dryRun: options.dryRun,
433
+ onEvent: event => { process.stdout.write(`${options.json ? JSON.stringify(event) : watchEventText(event)}\n`); },
434
+ });
435
+ const controller = new AbortController();
436
+ const stop = () => controller.abort();
437
+ process.once('SIGINT', stop);
438
+ process.once('SIGTERM', stop);
439
+ try {
440
+ const completed = await watch.run(controller.signal, { once: options.once });
441
+ return options.once && !completed ? 1 : 0;
442
+ } catch (error) {
443
+ if (!(error instanceof WatchBusyError)) throw error;
444
+ process.stderr.write(`${error.message}\n`);
445
+ return 1;
446
+ } finally {
447
+ process.removeListener('SIGINT', stop);
448
+ process.removeListener('SIGTERM', stop);
449
+ }
450
+ }
451
+
387
452
  private async selectHarness(): Promise<IAuthHarness | undefined> {
388
453
  if (this.harnesses.size === 1) return this.harnesses.values().next().value;
389
454
  if (this.harnesses.size === 0 || !canPrompt()) {
@@ -494,7 +559,7 @@ ${bold('Environment')}
494
559
  process.stdout.write(`${dim('no account is currently active')}\n`);
495
560
  }
496
561
  if (stateArg.saveUnavailableReason) process.stdout.write(`${dim(stateArg.saveUnavailableReason)}\n`);
497
- for (const drift of stateArg.credentialDrift ?? []) process.stdout.write(`${orange(credentialDriftNote(harnessArg.id, drift))}\n`);
562
+ for (const drift of stateArg.credentialDrift ?? []) process.stdout.write(`${orange(credentialDriftNote(harnessArg, drift))}\n`);
498
563
  }
499
564
 
500
565
  private async selectActive(harness: IAuthHarness, reference?: string): Promise<IHarnessAccount | undefined> {
@@ -515,12 +580,13 @@ ${bold('Environment')}
515
580
  }
516
581
 
517
582
  /**
518
- * The condensed views are projections of the same read-only account list, so a provider is queried
519
- * once, per account, with the same bounded timeouts and per-account failure isolation as `list`.
583
+ * The condensed views are projections of the same account list, which never changes which account is in
584
+ * use, so a provider is queried once, per account, with the same bounded timeouts and per-account failure
585
+ * isolation as `list`.
520
586
  */
521
587
  private async commandCondensed(commandArg: 'limits' | 'active', harnessesArg: IAuthHarness[], jsonArg: boolean): Promise<number> {
522
588
  const list = await readAccountList(harnessesArg);
523
- const document = commandArg === 'limits' ? accountLimits(list) : activeAccounts(list);
589
+ const document = commandArg === 'limits' ? accountLimits(list, harnessesArg) : activeAccounts(list, harnessesArg);
524
590
  if (jsonArg) process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
525
591
  else if ('limits' in document) await new CondensedRenderer(this.out).renderLimits(document);
526
592
  else await new CondensedRenderer(this.out).renderActive(document);
@@ -530,17 +596,10 @@ ${bold('Environment')}
530
596
  private async commandList(harnessesArg: IAuthHarness[], jsonArg = false): Promise<number> {
531
597
  const list = await readAccountList(harnessesArg);
532
598
  if (jsonArg) process.stdout.write(`${JSON.stringify(list, null, 2)}\n`);
533
- else await new AccountListRenderer(this.out).render(list);
599
+ else await new AccountListRenderer(this.out).render(list, harnessesArg);
534
600
  return list.complete ? 0 : 1;
535
601
  }
536
602
 
537
- private printAccount(accountArg: IHarnessAccount): void {
538
- const saved = accountArg.isStashed ? 'saved' : 'not saved';
539
- process.stdout.write(`${accountArg.isActive ? green('*') : ' '} ${bold(plainText(accountArg.label))} ${dim(`(${saved})`)}\n`);
540
- if (accountArg.savedAt) process.stdout.write(` ${dim('Saved: ' + plainText(accountArg.savedAt))}\n`);
541
- for (const detail of accountArg.details) process.stdout.write(` ${detail}\n`);
542
- }
543
-
544
603
  private async resolveAccount(harnessArg: IAuthHarness, referenceArg: string): Promise<string | null> {
545
604
  const resolved = await harnessArg.resolveAccount(referenceArg);
546
605
  if ('id' in resolved) return resolved.id;
@@ -16,6 +16,10 @@ export class CodexHarness implements IAuthHarness {
16
16
  public readonly loginHint = 'Use authswitch codex login to sign in and save another account.';
17
17
  public readonly loginProviders: IHarnessLoginProvider[] = [{ providerId: 'openai', label: 'OpenAI', flows: ['device'] }];
18
18
  public readonly diagnosticsLabel = 'Check credential storage and remote control';
19
+ /** Running Codex sessions keep the login they loaded; the switch itself restarts the managed app-server. */
20
+ public readonly liveSwap = false;
21
+ /** Every saved ChatGPT login reports its usage, and a switch restarts only the managed app-server. */
22
+ public readonly autoSwitch = true;
19
23
 
20
24
  constructor(
21
25
  private readonly switcher = new CodexSwitcher(),
@@ -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 { latestRetryAt, retryAtFrom } from './ratelimit.js';
4
5
 
5
6
  // Verified Chromium-compatible billing request profile, independent of any locally installed browser.
6
7
  const billingUserAgent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36';
@@ -31,13 +32,18 @@ const count = (valueArg: unknown): number => {
31
32
  /** Only fixed, local diagnostics may cross the HTTP error boundary. */
32
33
  class StatusRequestError extends Error {}
33
34
 
35
+ /** The service refused a section for too many requests; that says nothing about the account's usage. */
36
+ class StatusRateLimitError extends StatusRequestError {
37
+ constructor(public readonly retryAt: string | null) { super('The service is rate limiting requests (HTTP 429); try again later.'); }
38
+ }
39
+
34
40
  /**
35
41
  * Read-only ChatGPT status contract verified against Codex 0.154.0.
36
42
  * These source-defined backend routes are isolated here. No token refresh,
37
43
  * credential writes, app-server lifecycle changes or reset consumption occur.
38
44
  */
39
45
  export class CodexAccountStatus {
40
- constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
46
+ constructor(private readonly fetcher: typeof fetch = globalThis.fetch, private readonly now: () => number = Date.now) {}
41
47
 
42
48
  public async read(rawArg: string, identityArg: ICodexIdentity, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
43
49
  if (identityArg.kind !== 'chatgpt') return {
@@ -85,6 +91,8 @@ export class CodexAccountStatus {
85
91
  }
86
92
  catch { result.problems.push(`${labels[index]}: the service returned an unsupported response; no values were inferred.`); }
87
93
  }
94
+ const limited = sections.flatMap(section => section.status === 'rejected' && section.reason instanceof StatusRateLimitError ? [section.reason] : []);
95
+ if (limited.length) result.rateLimit = { retryAt: latestRetryAt(limited.map(error => error.retryAt)) };
88
96
  if (!result.summary?.subscription && identityArg.planType) {
89
97
  result.facts.unshift({ section: 'Subscription', summaryKey: 'subscription', label: 'Last known plan', value: `${identityArg.planType} (from stored login; not verified live)` });
90
98
  result.summary = { ...result.summary, subscription: { plan: identityArg.planType, source: 'stored' } };
@@ -105,6 +113,7 @@ export class CodexAccountStatus {
105
113
  if (!response.ok) {
106
114
  await response.body?.cancel();
107
115
  if (response.headers.get('cf-mitigated') === 'challenge') throw new StatusRequestError('Blocked by a service verification challenge (Cloudflare); this lookup is unavailable to the current HTTP client.');
116
+ if (response.status === 429) throw new StatusRateLimitError(retryAtFrom(response.headers.get('retry-after'), this.now()));
108
117
  if (response.status === 401) throw new StatusRequestError('Login expired or was rejected. Log in again with the account’s harness, then save the account.');
109
118
  if (response.status === 403) throw new StatusRequestError('Access denied by the service (HTTP 403).');
110
119
  throw new StatusRequestError(`Service returned HTTP ${response.status}.`);
@@ -157,9 +166,9 @@ export class CodexAccountStatus {
157
166
  const append = (label: string, value: string) => facts.push({ section: 'Billing', summaryKey: 'billing', label, value });
158
167
  if (billing.hasActiveSubscription !== undefined) append('Active subscription', billing.hasActiveSubscription ? 'yes' : 'no');
159
168
  if (billing.autoRenew !== undefined) append('Automatic renewal', billing.autoRenew ? 'on' : 'off');
160
- if (billing.renewsAt) append('Renewal date (UTC)', billing.renewsAt);
161
- if (billing.cancelsAt) append('Cancellation date (UTC)', billing.cancelsAt);
162
- if (billing.expiresAt) append('Subscription expiry (UTC)', billing.expiresAt);
169
+ if (billing.renewsAt) append('Renewal date', billing.renewsAt);
170
+ if (billing.cancelsAt) append('Cancellation date', billing.cancelsAt);
171
+ if (billing.expiresAt) append('Subscription expiry', billing.expiresAt);
163
172
  return { facts, summary: { billing } };
164
173
  }
165
174
 
@@ -172,8 +181,9 @@ export class CodexAccountStatus {
172
181
  const appendLimit = (nameArg: string, valueArg: unknown, scopeArg: 'account' | 'feature') => {
173
182
  if (valueArg == null) return;
174
183
  const limit = record(valueArg);
175
- facts.push({ section: 'Limits & credits', label: `${nameArg} usage allowed`, value: boolean(limit.allowed) ? 'yes' : 'no' });
176
- facts.push({ section: 'Limits & credits', label: `${nameArg} limit reached`, value: boolean(limit.limit_reached) ? 'yes' : 'no' });
184
+ // The service's own reading of the same windows, so a view that shows the windows need not repeat it.
185
+ facts.push({ section: 'Limits & credits', summaryKey: 'usageWindows', label: `${nameArg} usage allowed`, value: boolean(limit.allowed) ? 'yes' : 'no' });
186
+ facts.push({ section: 'Limits & credits', summaryKey: 'usageWindows', label: `${nameArg} limit reached`, value: boolean(limit.limit_reached) ? 'yes' : 'no' });
177
187
  for (const [key, name] of [['primary_window', 'primary'], ['secondary_window', 'secondary']]) {
178
188
  if (limit[key] == null) continue;
179
189
  const window = record(limit[key]);
@@ -251,7 +261,7 @@ export class CodexAccountStatus {
251
261
  return { date, tokens: numeric(day.tokens) };
252
262
  }).sort((leftArg, rightArg) => rightArg.date.localeCompare(leftArg.date));
253
263
  for (const day of days.slice(0, 7)) facts.push({ section: 'Daily tokens', label: `Tokens ${day.date}`, value: day.tokens.toLocaleString('en-US') });
254
- if (days.length > 7) facts.push({ section: 'Daily tokens', label: 'Daily activity', value: `Showing the latest 7 of ${days.length} reported days` });
264
+ facts.push({ section: 'Daily tokens', label: 'Reported days', value: String(days.length) });
255
265
  }
256
266
  return facts;
257
267
  }
@@ -1,5 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
- import { writeSecretFileAtomically } from './helpers.js';
2
+ import { errorCode, pause, writeSecretFileAtomically } from './helpers.js';
3
3
 
4
4
  export const credentialRecord = (value: unknown): Record<string, unknown> => {
5
5
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Unsupported credential document.');
@@ -64,11 +64,29 @@ export interface IStoredCredential {
64
64
  credential: Record<string, unknown>;
65
65
  }
66
66
 
67
+ /** The root of authswitch's own state: `AUTHSWITCH_HOME`, or `~/.authswitch`. */
68
+ export const authSwitchHome = (envArg: NodeJS.ProcessEnv = process.env): string =>
69
+ envArg.AUTHSWITCH_HOME || plugins.path.join(plugins.os.homedir(), '.authswitch');
70
+
71
+ export interface ICredentialStoreOptions {
72
+ /**
73
+ * How long an operation waits for another authswitch operation on the same store, 45 seconds by default: longer
74
+ * than the longest operation that holds the lock -- a Claude Code login refresh (30 s) plus the 15 s a native
75
+ * write waits for Claude Code's own locks -- so such operations queue instead of being told the store is locked.
76
+ */
77
+ lockTimeoutMs?: number;
78
+ }
79
+
80
+ const DEFAULT_LOCK_TIMEOUT_MS = 45_000;
81
+ const LOCK_RETRY_MS = 50;
82
+
67
83
  /** A single atomic, owner-only record keeps the saved credential and its identity together. */
68
84
  export class CredentialStore {
69
85
  public readonly dir: string;
70
- constructor(harness: string, root = process.env.AUTHSWITCH_HOME || plugins.path.join(plugins.os.homedir(), '.authswitch')) {
86
+ private readonly lockTimeoutMs: number;
87
+ constructor(harness: string, root = authSwitchHome(), optionsArg: ICredentialStoreOptions = {}) {
71
88
  this.dir = plugins.path.join(root, harness);
89
+ this.lockTimeoutMs = optionsArg.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
72
90
  }
73
91
  public id(slot: string, identity: string): string { return credentialHash(JSON.stringify([slot, identity])); }
74
92
  private file(id: string): string {
@@ -89,12 +107,22 @@ export class CredentialStore {
89
107
  .map(name => this.read(name.slice(0, -5))).sort((a, b) => b.savedAt.localeCompare(a.savedAt));
90
108
  }
91
109
  public save(entry: Omit<IStoredCredential, 'schemaVersion' | 'id' | 'savedAt'>): IStoredCredential {
92
- const record: IStoredCredential = { ...entry, schemaVersion: 1, id: this.id(entry.slotId, entry.identity), savedAt: new Date().toISOString() };
110
+ return this.write({ ...entry, schemaVersion: 1, id: this.id(entry.slotId, entry.identity), savedAt: new Date().toISOString() },
111
+ 'Saved credential verification failed; the active login was preserved.');
112
+ }
113
+ /**
114
+ * Replaces a saved login's credential in place, such as with refreshed tokens: the account, its identity and when
115
+ * it was saved stay as they are. The caller holds the lock and has checked the record it replaces.
116
+ */
117
+ 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.');
119
+ }
120
+ private write(record: IStoredCredential, failureArg: string): IStoredCredential {
93
121
  plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
94
122
  plugins.fs.chmodSync(this.dir, 0o700);
95
123
  const raw = JSON.stringify(record, null, 2) + '\n';
96
124
  writeSecretFileAtomically(this.file(record.id), raw);
97
- if (readCredentialDocument(this.file(record.id)).raw !== raw) throw new Error('Saved credential verification failed; the active login was preserved.');
125
+ if (readCredentialDocument(this.file(record.id)).raw !== raw) throw new Error(failureArg);
98
126
  return this.read(record.id);
99
127
  }
100
128
  public remove(id: string): void { this.read(id); plugins.fs.unlinkSync(this.file(id)); }
@@ -124,13 +152,29 @@ export class CredentialStore {
124
152
  plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
125
153
  writeSecretFileAtomically(this.switchFile, JSON.stringify({ schemaVersion: 1, switches: records }, null, 2) + '\n');
126
154
  }
127
- public locked<T>(action: () => T): T {
155
+ /**
156
+ * Runs `action` as the only authswitch operation on this store; the lock is held until an asynchronous action settles.
157
+ *
158
+ * An operation that finds the store locked waits for it, up to the store's lock timeout, so concurrent operations
159
+ * (a manual switch and a watch refreshing a saved login) run one after the other instead of failing. The signal
160
+ * ends the wait early; an action that has started is never interrupted by it.
161
+ */
162
+ public async locked<T>(action: () => T | Promise<T>, optionsArg: { signal?: AbortSignal } = {}): Promise<T> {
128
163
  plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
129
164
  const file = plugins.path.join(this.dir, '.lock');
130
- let fd: number;
131
- try { fd = plugins.fs.openSync(file, 'wx', 0o600); }
132
- catch { throw new Error(`Account storage is locked. Close other authswitch operations; if none remain, remove ${file}.`); }
133
- try { return action(); }
165
+ const deadline = Date.now() + this.lockTimeoutMs;
166
+ let fd: number | undefined;
167
+ while (fd === undefined) {
168
+ optionsArg.signal?.throwIfAborted();
169
+ try { fd = plugins.fs.openSync(file, 'wx', 0o600); }
170
+ catch (error) {
171
+ if (errorCode(error) !== 'EEXIST' || Date.now() >= deadline) {
172
+ throw new Error(`Account storage is locked. Close other authswitch operations; if none remain, remove ${file}.`);
173
+ }
174
+ await pause(Math.min(LOCK_RETRY_MS, deadline - Date.now()), optionsArg.signal);
175
+ }
176
+ }
177
+ try { return await action(); }
134
178
  finally { plugins.fs.closeSync(fd); plugins.fs.unlinkSync(file); }
135
179
  }
136
180
  }