@modelprofile.com/authswitch 3.2.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 +63 -13
  3. package/dist_ts/accounts.js +199 -31
  4. package/dist_ts/classes.accountlist.d.ts +0 -24
  5. package/dist_ts/classes.accountlist.js +2 -213
  6. package/dist_ts/classes.claudecodeharness.d.ts +61 -3
  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 +17 -4
  11. package/dist_ts/classes.claudestatus.js +89 -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 +29 -7
  15. package/dist_ts/classes.cli.js +172 -78
  16. package/dist_ts/classes.codexharness.d.ts +6 -2
  17. package/dist_ts/classes.codexharness.js +7 -3
  18. package/dist_ts/classes.codexstatus.d.ts +5 -4
  19. package/dist_ts/classes.codexstatus.js +36 -19
  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 +39 -19
  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 +106 -32
  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 +6 -2
  31. package/dist_ts/classes.opencodeharness.js +9 -5
  32. package/dist_ts/classes.operations.js +3 -2
  33. package/dist_ts/classes.tui.js +10 -6
  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 +76 -15
  49. package/dist_ts/interfaces.list.d.ts +7 -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 +360 -106
  58. package/ts/00_commitinfo_data.ts +3 -3
  59. package/ts/accounts.ts +202 -34
  60. package/ts/classes.accountlist.ts +2 -216
  61. package/ts/classes.claudecodeharness.ts +126 -13
  62. package/ts/classes.claudecodelocks.ts +132 -0
  63. package/ts/classes.claudestatus.ts +88 -54
  64. package/ts/classes.claudetokenrefresh.ts +85 -0
  65. package/ts/classes.cli.ts +163 -63
  66. package/ts/classes.codexharness.ts +7 -3
  67. package/ts/classes.codexstatus.ts +29 -18
  68. package/ts/classes.credentialstore.ts +53 -9
  69. package/ts/classes.fileharness.ts +69 -31
  70. package/ts/classes.limits.ts +126 -33
  71. package/ts/classes.listrenderer.ts +328 -0
  72. package/ts/classes.login.ts +1 -1
  73. package/ts/classes.opencodeharness.ts +9 -5
  74. package/ts/classes.operations.ts +2 -1
  75. package/ts/classes.tui.ts +9 -5
  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 +78 -6
  84. package/ts/interfaces.list.ts +7 -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,25 +5,42 @@ 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, 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'];
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 };
36
+
37
+ /** The account a command acts on, named as it was offered, with the badge it was offered with. */
38
+ interface IChosenAccount {
39
+ id: string;
40
+ name: string;
41
+ /** ` (type:Max) (5h:63%) (w:88%)`, empty when no part is known, or null when it was not read. */
42
+ badge: string | null;
43
+ }
27
44
 
28
45
  /** Harness-independent command routing and interactive account management. */
29
46
  export class AuthSwitchCli {
@@ -61,6 +78,9 @@ ${bold('Usage')}
61
78
  authswitch active one row per provider: which account is in use, and since when
62
79
  authswitch <harness> preuse <account> [--prompt <text>] [--model <id>]
63
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%)
64
84
  authswitch <harness> stash [account/provider] [--keep]
65
85
  save one active login; --keep leaves it active
66
86
  authswitch <harness> use [account] [--stop|--force-stop|--keep-running]
@@ -80,14 +100,24 @@ ${bold('Options')}
80
100
  -v, --version show the version
81
101
  -i, --interactive open the guided account manager
82
102
  --tui open the terminal management dashboard
83
- --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)
84
105
  --stop stop this harness's running instances before the switch
85
106
  --force-stop the same, then kill instances that ignore SIGTERM
86
107
  --keep-running switch without stopping anything
87
108
 
88
- A switch works while OpenCode or Claude Code is running. Instances that keep running hold
89
- the previous login in memory and can write it back at their next token refresh, so the
90
- 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.
91
121
 
92
122
  Preuse consumes the selected account's quota. Its default prompt is:
93
123
  "${defaultPreusePrompt}"
@@ -109,6 +139,11 @@ ${bold('Environment')}
109
139
  const qualified = args[0] !== 'preuse';
110
140
  return await this.commandPreuse(qualified ? this.harnesses.get(args[0]) : undefined, args.slice(qualified ? 2 : 1));
111
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
+ }
112
147
  const jsonFlags = args.filter(value => value === '--json');
113
148
  const json = jsonFlags.length === 1;
114
149
  if (jsonFlags.length > 1) { process.stderr.write('Use --json only once.\n'); return 2; }
@@ -222,14 +257,17 @@ ${bold('Environment')}
222
257
  * Decide what happens to the harness's own running instances before its credential file changes.
223
258
  *
224
259
  * The switch itself no longer depends on this: it writes atomically and re-saves the outgoing
225
- * login either way. What a running instance still costs is durability -- it holds the previous
226
- * login in memory and can write it back at its next refresh -- so the instances are named and
227
- * stopping them is offered, never assumed. Without a terminal and without a flag nothing is
228
- * 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.
229
266
  */
230
267
  private async settleRunningInstances(harnessArg: IAuthHarness, modeArg?: TStopMode): Promise<void> {
231
268
  const control = harnessArg.processes;
232
- if (!control) return;
269
+ const stopRequested = modeArg === 'stop' || modeArg === 'force-stop';
270
+ if (!control || (harnessArg.liveSwap === true && !stopRequested)) return;
233
271
  let running: IHarnessProcess[];
234
272
  try { running = control.list(); }
235
273
  catch { process.stdout.write(`${orange(`Could not check for running ${plainText(harnessArg.label)} processes; continuing with the switch.`)}\n`); return; }
@@ -238,7 +276,7 @@ ${bold('Environment')}
238
276
  if (control.stopUnavailableReason !== null) { process.stdout.write(`${dim(control.stopUnavailableReason)}\n`); return; }
239
277
  const stoppable = running.filter(item => !item.isAncestor);
240
278
  if (!stoppable.length || modeArg === 'keep-running') return;
241
- let stop = modeArg === 'stop' || modeArg === 'force-stop';
279
+ let stop = stopRequested;
242
280
  if (modeArg === undefined) {
243
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; }
244
282
  stop = await this.out.prompts.ask({
@@ -286,28 +324,18 @@ ${bold('Environment')}
286
324
 
287
325
  private async commandPreuse(harnessArg: IAuthHarness | undefined, argsArg: string[]): Promise<number> {
288
326
  let reference: string | undefined;
289
- let prompt = defaultPreusePrompt;
327
+ let prompt: string;
290
328
  let model: string | undefined;
291
- const flags = new Set<string>();
292
329
  try {
293
- for (let index = 0; index < argsArg.length; index++) {
294
- const argument = argsArg[index];
295
- if (argument.startsWith('--prompt=') || argument.startsWith('--model=') || argument === '--prompt' || argument === '--model') {
296
- const separator = argument.indexOf('=');
297
- const flag = separator < 0 ? argument : argument.slice(0, separator);
298
- if (flags.has(flag)) throw new PreuseError(`Use ${flag} only once.`);
299
- flags.add(flag);
300
- const value = separator < 0 ? argsArg[++index] : argument.slice(separator + 1);
301
- if (value === undefined) throw new PreuseError(`${flag} requires a value.`);
302
- if (flag === '--prompt') prompt = value; else model = value;
303
- } else if (argument.startsWith('-') || reference !== undefined) {
304
- throw new PreuseError('Usage: authswitch [harness] preuse <account> [--prompt <text>] [--model <id>]');
305
- } else reference = argument;
306
- }
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');
307
335
  validatePreuseOptions({ prompt, model });
308
- 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.');
309
337
  } catch (error) {
310
- 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`);
311
339
  return 2;
312
340
  }
313
341
  const harness = harnessArg ?? await this.selectHarness();
@@ -354,13 +382,11 @@ ${bold('Environment')}
354
382
  if (windows?.length) {
355
383
  const now = Date.now();
356
384
  process.stdout.write('Reset schedule reported after the prompt:\n');
357
- if ((process.stdout.columns ?? 100) < 40) {
358
- 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`);
359
- } else await this.out.table(windows, { columns: [
385
+ await consoleTable(this.out, windows, [
360
386
  { key: 'window', title: 'Window', value: row => `${plainText(row.label)} (${duration(row.durationSeconds)})` },
361
- { key: 'usage', title: 'Used', value: row => `${row.usedPercent}%` },
387
+ { key: 'usage', title: 'Used', value: row => usagePercentText(row.usedPercent) },
362
388
  { key: 'reset', title: 'Reset in', value: row => until(row.resetAt, now) },
363
- ], overflow: 'wrap' });
389
+ ]);
364
390
  } else process.stdout.write('Reset schedule could not be verified; the completed prompt will not be repeated.\n');
365
391
  } catch {
366
392
  process.stderr.write('Prompt completed, but its reset schedule could not be verified or displayed. The prompt will not be repeated.\n');
@@ -372,6 +398,57 @@ ${bold('Environment')}
372
398
  }
373
399
  }
374
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
+
375
452
  private async selectHarness(): Promise<IAuthHarness | undefined> {
376
453
  if (this.harnesses.size === 1) return this.harnesses.values().next().value;
377
454
  if (this.harnesses.size === 0 || !canPrompt()) {
@@ -406,7 +483,7 @@ ${bold('Environment')}
406
483
  for (const current of state.accounts.filter(account => account.isActive && !account.isStashed)) {
407
484
  const answer = await this.out.prompts.ask({
408
485
  name: 'saveCurrent', type: 'confirm',
409
- message: `${plainText(current.label)}${current.slotId ? ` (${plainText(current.slotId)})` : ''} is not saved in ${harnessArg.label}. Save this login before switching?`, default: true,
486
+ message: `${accountName(current)} is not saved in ${harnessArg.label}. Save this login before switching?`, default: true,
410
487
  });
411
488
  if (answer !== true) return 'declined';
412
489
  if (this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'save', keepActive: true, accountId: current.id })) !== 0) return 'failed';
@@ -457,12 +534,12 @@ ${bold('Environment')}
457
534
  case 'list': code = await this.commandList([harnessArg]); break;
458
535
  case 'doctor': code = this.reportOutcome(await harnessArg.diagnose()); break;
459
536
  case 'drop': {
460
- const id = await this.promptForAccount(harnessArg, `Which saved ${harnessArg.label} account should be removed?`, true, true);
461
- if (!id) break;
537
+ const target = await this.promptForAccount(harnessArg, `Which saved ${harnessArg.label} account should be removed?`, { allowBack: true, includeIncomplete: true });
538
+ if (!target) break;
462
539
  const confirmation = await this.out.prompts.ask({
463
- name: 'confirmDrop', type: 'confirm', message: `Remove the saved login for ${plainText(id)}? The active login stays as it is.`, default: false,
540
+ name: 'confirmDrop', type: 'confirm', message: `Remove the saved login for ${plainText(target.id)}? The active login stays as it is.`, default: false,
464
541
  });
465
- if (confirmation === true) code = this.reportOutcome(await harnessArg.removeAccount(id));
542
+ if (confirmation === true) code = this.reportOutcome(await harnessArg.removeAccount(target.id));
466
543
  break;
467
544
  }
468
545
  case 'harness': return null;
@@ -482,7 +559,7 @@ ${bold('Environment')}
482
559
  process.stdout.write(`${dim('no account is currently active')}\n`);
483
560
  }
484
561
  if (stateArg.saveUnavailableReason) process.stdout.write(`${dim(stateArg.saveUnavailableReason)}\n`);
485
- 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`);
486
563
  }
487
564
 
488
565
  private async selectActive(harness: IAuthHarness, reference?: string): Promise<IHarnessAccount | undefined> {
@@ -492,7 +569,7 @@ ${bold('Environment')}
492
569
  if (accounts.length === 1) return accounts[0];
493
570
  if (!accounts.length) { process.stderr.write('No matching active login to save.\n'); return undefined; }
494
571
  if (!canPrompt()) { process.stderr.write(`Several logins are active. Use authswitch ${harness.id} stash <account/provider> [--keep].\n`); return undefined; }
495
- const id = await this.out.prompts.ask({ name: 'activeAccount', type: 'list', message: 'Which active login should be saved?', choices: accounts.map(account => ({ name: `${plainText(account.label)}${account.slotId ? ` (${plainText(account.slotId)})` : ''}`, value: account.id })) });
572
+ const id = await this.out.prompts.ask({ name: 'activeAccount', type: 'list', message: 'Which active login should be saved?', choices: accounts.map(account => ({ name: accountName(account), value: account.id })) });
496
573
  return accounts.find(account => account.id === id);
497
574
  }
498
575
 
@@ -503,12 +580,13 @@ ${bold('Environment')}
503
580
  }
504
581
 
505
582
  /**
506
- * The condensed views are projections of the same read-only account list, so a provider is queried
507
- * 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`.
508
586
  */
509
587
  private async commandCondensed(commandArg: 'limits' | 'active', harnessesArg: IAuthHarness[], jsonArg: boolean): Promise<number> {
510
588
  const list = await readAccountList(harnessesArg);
511
- const document = commandArg === 'limits' ? accountLimits(list) : activeAccounts(list);
589
+ const document = commandArg === 'limits' ? accountLimits(list, harnessesArg) : activeAccounts(list, harnessesArg);
512
590
  if (jsonArg) process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
513
591
  else if ('limits' in document) await new CondensedRenderer(this.out).renderLimits(document);
514
592
  else await new CondensedRenderer(this.out).renderActive(document);
@@ -518,17 +596,10 @@ ${bold('Environment')}
518
596
  private async commandList(harnessesArg: IAuthHarness[], jsonArg = false): Promise<number> {
519
597
  const list = await readAccountList(harnessesArg);
520
598
  if (jsonArg) process.stdout.write(`${JSON.stringify(list, null, 2)}\n`);
521
- else await new AccountListRenderer(this.out).render(list);
599
+ else await new AccountListRenderer(this.out).render(list, harnessesArg);
522
600
  return list.complete ? 0 : 1;
523
601
  }
524
602
 
525
- private printAccount(accountArg: IHarnessAccount): void {
526
- const saved = accountArg.isStashed ? 'saved' : 'not saved';
527
- process.stdout.write(`${accountArg.isActive ? green('*') : ' '} ${bold(plainText(accountArg.label))} ${dim(`(${saved})`)}\n`);
528
- if (accountArg.savedAt) process.stdout.write(` ${dim('Saved: ' + plainText(accountArg.savedAt))}\n`);
529
- for (const detail of accountArg.details) process.stdout.write(` ${detail}\n`);
530
- }
531
-
532
603
  private async resolveAccount(harnessArg: IAuthHarness, referenceArg: string): Promise<string | null> {
533
604
  const resolved = await harnessArg.resolveAccount(referenceArg);
534
605
  if ('id' in resolved) return resolved.id;
@@ -538,6 +609,14 @@ ${bold('Environment')}
538
609
  return null;
539
610
  }
540
611
 
612
+ /**
613
+ * Activate a saved account and report what is now active with its badge.
614
+ *
615
+ * The badge is read once per command through the bounded status read: the chooser reads it before it
616
+ * offers the accounts and the report repeats it, while a target named on the command line is read only
617
+ * after its switch, so nothing waits on it. A read that fails or does not answer in time leaves the name
618
+ * alone and never decides the outcome.
619
+ */
541
620
  private async commandUse(harnessArg: IAuthHarness, referenceArg?: string, allowBackArg = false, stopModeArg?: TStopMode): Promise<number> {
542
621
  let id: string | null = null;
543
622
  if (referenceArg) {
@@ -548,21 +627,42 @@ ${bold('Environment')}
548
627
  const offered = await this.offerToSave(harnessArg);
549
628
  if (offered !== 'ready') return offered === 'failed' ? 1 : 0;
550
629
  }
551
- id ??= await this.promptForAccount(harnessArg, `Which ${harnessArg.label} account should be active?`, allowBackArg);
552
- if (id === null) return allowBackArg ? 0 : 1;
553
- return this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'switch', accountId: id }, stopModeArg));
630
+ const target = id === null
631
+ ? await this.promptForAccount(harnessArg, `Which ${harnessArg.label} account should be active?`, { allowBack: allowBackArg, withBadges: true })
632
+ : await this.namedAccount(harnessArg, id);
633
+ if (target === null) return allowBackArg ? 0 : 1;
634
+ const code = this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'switch', accountId: target.id }, stopModeArg));
635
+ if (code !== 0) return code;
636
+ const badge = target.badge ?? (await readAccountBadges(harnessArg, [target.id])).get(target.id) ?? '';
637
+ process.stdout.write(`Now active: ${bold(target.name)}${badge}\n`);
638
+ return code;
639
+ }
640
+
641
+ /**
642
+ * A switch target named on the command line; its badge is not read here, so the switch never waits on it.
643
+ *
644
+ * The reference already resolved to this id, so the name is presentation only: a state read that fails
645
+ * names the account by its id and leaves the switch, which reports any real credential problem, to run.
646
+ */
647
+ private async namedAccount(harnessArg: IAuthHarness, idArg: string): Promise<IChosenAccount> {
648
+ let account: IHarnessAccount | undefined;
649
+ try { account = (await harnessArg.readState()).accounts.find(item => item.id === idArg); }
650
+ catch { account = undefined; }
651
+ return { id: idArg, name: account ? accountName(account) : plainText(idArg), badge: null };
554
652
  }
555
653
 
556
- private async promptForAccount(harnessArg: IAuthHarness, messageArg: string, allowBackArg: boolean, includeIncompleteArg = false): Promise<string | null> {
557
- const accounts = (await harnessArg.readState()).accounts.filter((accountArg) => accountArg.isStashed || (includeIncompleteArg && accountArg.savedAt !== null));
654
+ private async promptForAccount(harnessArg: IAuthHarness, messageArg: string, optionsArg: { allowBack?: boolean; includeIncomplete?: boolean; withBadges?: boolean } = {}): Promise<IChosenAccount | null> {
655
+ const accounts = (await harnessArg.readState()).accounts.filter((accountArg) => accountArg.isStashed || (optionsArg.includeIncomplete === true && accountArg.savedAt !== null));
558
656
  if (accounts.length === 0) { process.stderr.write(`${red('no stashed accounts')} - run \`authswitch ${harnessArg.id} stash\` first\n`); return null; }
559
657
  if (!canPrompt()) { process.stderr.write(`${red('an account is required')} without an interactive terminal: authswitch ${harnessArg.id} use <account>\n`); return null; }
658
+ const badges = optionsArg.withBadges ? await readAccountBadges(harnessArg, accounts.map((accountArg) => accountArg.id)) : null;
659
+ const offered = accounts.map((accountArg): IChosenAccount => ({ id: accountArg.id, name: accountName(accountArg), badge: badges === null ? null : badges.get(accountArg.id) ?? '' }));
560
660
  const answer = await this.out.prompts.ask({
561
661
  name: 'account', type: 'list', message: messageArg,
562
662
  default: accounts.find((accountArg) => accountArg.isActive)?.id ?? accounts[0].id,
563
- choices: [...accounts.map((accountArg) => ({ name: `${plainText(accountArg.label)}${accountArg.slotId ? ` (${plainText(accountArg.slotId)})` : ''}${accountArg.isActive ? ' (active)' : ''}`, value: accountArg.id })),
564
- ...(allowBackArg ? [{ name: 'Back', value: null }] : [])],
663
+ choices: [...accounts.map((accountArg, index) => ({ name: `${offered[index].name}${accountArg.isActive ? ' (active)' : ''}${offered[index].badge ?? ''}`, value: accountArg.id })),
664
+ ...(optionsArg.allowBack ? [{ name: 'Back', value: null }] : [])],
565
665
  });
566
- return typeof answer === 'string' ? answer : null;
666
+ return offered.find((accountArg) => accountArg.id === answer) ?? null;
567
667
  }
568
668
  }
@@ -2,7 +2,7 @@ import { CodexSwitcher } from './classes.codexswitcher.js';
2
2
  import { CodexAccountStatus } from './classes.codexstatus.js';
3
3
  import { CodexPreuse } from './classes.codexpreuse.js';
4
4
  import { PreuseError } from './preuse.js';
5
- import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessOutcome, IHarnessState, IHarnessPreuseOptions, IHarnessPreuseResult } from './interfaces.harness.js';
5
+ import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessOutcome, IHarnessState, IHarnessPreuseOptions, IHarnessPreuseResult, IHarnessStatusOptions } from './interfaces.harness.js';
6
6
  import type { IStashListing } from './interfaces.js';
7
7
  import { countPaired, enrollmentState } from './helpers.js';
8
8
  import { beginSavedOpenAiLogin } from './classes.login.js';
@@ -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(),
@@ -70,10 +74,10 @@ export class CodexHarness implements IAuthHarness {
70
74
  };
71
75
  }
72
76
 
73
- public async readAccountStatus(accountIdArg: string): Promise<IHarnessAccountStatus> {
77
+ public async readAccountStatus(accountIdArg: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
74
78
  const credential = this.readAccountCredential(accountIdArg);
75
79
  if (!credential) return { facts: [], problems: ['No matching credential is available for status lookup.'] };
76
- return this.accountStatus.read(credential.raw, credential.identity);
80
+ return this.accountStatus.read(credential.raw, credential.identity, optionsArg);
77
81
  }
78
82
 
79
83
  public async preuseAccount(accountIdArg: string, optionsArg: IHarnessPreuseOptions): Promise<IHarnessPreuseResult> {
@@ -1,6 +1,7 @@
1
1
  import type { ICodexIdentity } from './interfaces.js';
2
- import type { IHarnessAccountStatus, IHarnessStatusSummary } from './interfaces.harness.js';
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,15 +32,20 @@ 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
- public async read(rawArg: string, identityArg: ICodexIdentity): Promise<IHarnessAccountStatus> {
48
+ public async read(rawArg: string, identityArg: ICodexIdentity, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
43
49
  if (identityArg.kind !== 'chatgpt') return {
44
50
  facts: [{ label: 'Authentication', value: 'API key' }, { label: 'Subscription and usage', value: 'Not exposed by Codex for API-key logins' }], problems: [],
45
51
  };
@@ -47,18 +53,18 @@ export class CodexAccountStatus {
47
53
  try { accessToken = text(record(record(JSON.parse(rawArg)).tokens).access_token); }
48
54
  catch { return { facts: [], problems: ['The login has no usable access token. Log in again with Codex, then save the account.'] }; }
49
55
  if (!identityArg.accountId) return { facts: [], problems: ['The login has no account ID for a status lookup.'] };
50
- return this.readCredential(accessToken, identityArg.accountId, identityArg.planType);
56
+ return this.readCredential(accessToken, identityArg.accountId, identityArg.planType, optionsArg);
51
57
  }
52
58
 
53
59
  /** Read the ChatGPT account directly, independent of a harness's on-disk format. */
54
- public async readCredential(accessToken: string, accountId: string, planType?: string | null): Promise<IHarnessAccountStatus> {
60
+ public async readCredential(accessToken: string, accountId: string, planType?: string | null, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
55
61
  const result: IHarnessAccountStatus = { facts: [], problems: [] };
56
62
  const identityArg = { accountId, planType };
57
63
  const sections = await Promise.allSettled([
58
- this.get('wham/usage', accessToken, identityArg.accountId),
59
- this.get('wham/rate-limit-reset-credits', accessToken, identityArg.accountId),
60
- this.get('wham/profiles/me', accessToken, identityArg.accountId),
61
- this.get('accounts/check/v4-2023-04-27', accessToken, identityArg.accountId),
64
+ this.get('wham/usage', accessToken, identityArg.accountId, optionsArg.signal),
65
+ this.get('wham/rate-limit-reset-credits', accessToken, identityArg.accountId, optionsArg.signal),
66
+ this.get('wham/profiles/me', accessToken, identityArg.accountId, optionsArg.signal),
67
+ this.get('accounts/check/v4-2023-04-27', accessToken, identityArg.accountId, optionsArg.signal),
62
68
  ]);
63
69
  const usage = sections[0];
64
70
  if (usage.status === 'fulfilled' && usage.value.account_id != null && usage.value.account_id !== identityArg.accountId) {
@@ -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' } };
@@ -92,8 +100,9 @@ export class CodexAccountStatus {
92
100
  return result;
93
101
  }
94
102
 
95
- private async get(pathArg: string, tokenArg: string, accountIdArg: string): Promise<Record<string, unknown>> {
96
- const signal = AbortSignal.timeout(10_000);
103
+ private async get(pathArg: string, tokenArg: string, accountIdArg: string, cancelArg?: AbortSignal): Promise<Record<string, unknown>> {
104
+ const timeout = AbortSignal.timeout(10_000);
105
+ const signal = cancelArg ? AbortSignal.any([cancelArg, timeout]) : timeout;
97
106
  try {
98
107
  const response = await this.fetcher(`https://chatgpt.com/backend-api/${pathArg}`, {
99
108
  method: 'GET', redirect: 'error', signal,
@@ -104,6 +113,7 @@ export class CodexAccountStatus {
104
113
  if (!response.ok) {
105
114
  await response.body?.cancel();
106
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()));
107
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.');
108
118
  if (response.status === 403) throw new StatusRequestError('Access denied by the service (HTTP 403).');
109
119
  throw new StatusRequestError(`Service returned HTTP ${response.status}.`);
@@ -125,7 +135,7 @@ export class CodexAccountStatus {
125
135
  catch { throw new StatusRequestError('The service returned invalid status data.'); }
126
136
  } catch (errorArg) {
127
137
  // Never surface remote bodies, request headers, token strings or arbitrary fetch errors.
128
- if (signal.aborted) throw new Error('The status request timed out.');
138
+ if (signal.aborted) throw new Error(timeout.aborted ? 'The status request timed out.' : 'The status request was cancelled.');
129
139
  if (errorArg instanceof StatusRequestError) throw errorArg;
130
140
  throw new Error('The status service could not be reached.');
131
141
  }
@@ -156,9 +166,9 @@ export class CodexAccountStatus {
156
166
  const append = (label: string, value: string) => facts.push({ section: 'Billing', summaryKey: 'billing', label, value });
157
167
  if (billing.hasActiveSubscription !== undefined) append('Active subscription', billing.hasActiveSubscription ? 'yes' : 'no');
158
168
  if (billing.autoRenew !== undefined) append('Automatic renewal', billing.autoRenew ? 'on' : 'off');
159
- if (billing.renewsAt) append('Renewal date (UTC)', billing.renewsAt);
160
- if (billing.cancelsAt) append('Cancellation date (UTC)', billing.cancelsAt);
161
- 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);
162
172
  return { facts, summary: { billing } };
163
173
  }
164
174
 
@@ -171,8 +181,9 @@ export class CodexAccountStatus {
171
181
  const appendLimit = (nameArg: string, valueArg: unknown, scopeArg: 'account' | 'feature') => {
172
182
  if (valueArg == null) return;
173
183
  const limit = record(valueArg);
174
- facts.push({ section: 'Limits & credits', label: `${nameArg} usage allowed`, value: boolean(limit.allowed) ? 'yes' : 'no' });
175
- 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' });
176
187
  for (const [key, name] of [['primary_window', 'primary'], ['secondary_window', 'secondary']]) {
177
188
  if (limit[key] == null) continue;
178
189
  const window = record(limit[key]);
@@ -250,7 +261,7 @@ export class CodexAccountStatus {
250
261
  return { date, tokens: numeric(day.tokens) };
251
262
  }).sort((leftArg, rightArg) => rightArg.date.localeCompare(leftArg.date));
252
263
  for (const day of days.slice(0, 7)) facts.push({ section: 'Daily tokens', label: `Tokens ${day.date}`, value: day.tokens.toLocaleString('en-US') });
253
- 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) });
254
265
  }
255
266
  return facts;
256
267
  }