@modelprofile.com/authswitch 3.3.0 → 5.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 +52 -15
  3. package/dist_ts/accounts.js +110 -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 +81 -2
  7. package/dist_ts/classes.claudecodeharness.js +208 -14
  8. package/dist_ts/classes.claudecodelocks.d.ts +38 -0
  9. package/dist_ts/classes.claudecodelocks.js +118 -0
  10. package/dist_ts/classes.claudestatus.d.ts +15 -2
  11. package/dist_ts/classes.claudestatus.js +100 -83
  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 +126 -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 +7 -2
  19. package/dist_ts/classes.codexstatus.js +49 -21
  20. package/dist_ts/classes.credentialstore.d.ts +43 -2
  21. package/dist_ts/classes.credentialstore.js +60 -13
  22. package/dist_ts/classes.fileharness.d.ts +45 -21
  23. package/dist_ts/classes.fileharness.js +64 -26
  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 +8 -4
  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 +349 -115
  58. package/ts/00_commitinfo_data.ts +3 -3
  59. package/ts/accounts.ts +122 -35
  60. package/ts/classes.accountlist.ts +2 -219
  61. package/ts/classes.claudecodeharness.ts +199 -13
  62. package/ts/classes.claudecodelocks.ts +133 -0
  63. package/ts/classes.claudestatus.ts +100 -65
  64. package/ts/classes.claudetokenrefresh.ts +85 -0
  65. package/ts/classes.cli.ts +114 -54
  66. package/ts/classes.codexharness.ts +4 -0
  67. package/ts/classes.codexstatus.ts +40 -18
  68. package/ts/classes.credentialstore.ts +78 -10
  69. package/ts/classes.fileharness.ts +76 -34
  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 +7 -3
  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
@@ -1,8 +1,9 @@
1
- import { credentialRecord, credentialText } from './classes.credentialstore.js';
1
+ import { credentialHash, credentialRecord, credentialText } from './classes.credentialstore.js';
2
+ import { claudeRequest, ClaudeLoginRejectedError, ClaudeRateLimitError, ClaudeRequestError } from './claudehttp.js';
3
+ import { latestRetryAt } from './ratelimit.js';
2
4
  import { plainText } from './formatting.js';
3
- import type { IHarnessAccountStatus, IHarnessStatusOptions, IHarnessUsageWindow, TUsageSeverity } from './interfaces.harness.js';
4
-
5
- class ClaudeStatusError extends Error {}
5
+ import { usageWindowPeriod } from './accounts.js';
6
+ import type { IHarnessAccountStatus, IHarnessStatusFact, IHarnessStatusOptions, IHarnessUsageWindow, TUsageSeverity } from './interfaces.harness.js';
6
7
 
7
8
  interface IClaudeUsageWindows {
8
9
  windows: IHarnessUsageWindow[];
@@ -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 } : {}),
@@ -102,66 +104,96 @@ const claudeUsageWindows = (usage: Record<string, unknown>): IClaudeUsageWindows
102
104
  return Array.isArray(limits) && limits.length ? limitWindows(limits) : { windows: keyedWindows(usage), unsupported: [] };
103
105
  };
104
106
 
105
- /** Read-only Claude Code OAuth account endpoints. No refresh, inference, or browser session is used. */
107
+ /** A profile lookup's answer. Only a lookup that could not be completed is thrown, and so never reused. */
108
+ type TClaudeProfile =
109
+ | { kind: 'found'; plan: string | undefined; facts: IHarnessStatusFact[] }
110
+ | { kind: 'mismatch' }
111
+ | { kind: 'unavailable'; problem: string };
112
+
113
+ /**
114
+ * How long a session reuses an account's profile. The watch polls every few minutes, and the profile (plan,
115
+ * organization, billing type) changes far more rarely, so it is read at most hourly there.
116
+ */
117
+ const PROFILE_REUSE_MS = 3_600_000;
118
+ const REQUEST_TIMEOUT_MS = 10_000;
119
+ const MISMATCH = 'Claude returned a different account or organization; no account data was displayed.';
120
+
121
+ /**
122
+ * Claude Code's read-only OAuth account endpoints: the profile and usage of one login, with the access token it
123
+ * carries. It never refreshes that token; `ClaudeCodeHarness` refreshes a saved login first, and leaves the active
124
+ * login's tokens to Claude Code. No inference or browser session is used.
125
+ */
106
126
  export class ClaudeAccountStatus {
107
- constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
108
- private async get(route: 'usage' | 'profile', accessToken: string, cancelArg?: AbortSignal): Promise<Record<string, unknown>> {
109
- const timeout = AbortSignal.timeout(10_000);
110
- const signal = cancelArg ? AbortSignal.any([cancelArg, timeout]) : timeout;
127
+ constructor(private readonly fetcher: typeof fetch = globalThis.fetch, private readonly now: () => number = Date.now) {}
128
+
129
+ private async get(route: 'usage' | 'profile', accessToken: string, signal?: AbortSignal): Promise<Record<string, unknown>> {
130
+ const { status, body } = await claudeRequest(this.fetcher, {
131
+ url: `https://api.anthropic.com/api/oauth/${route}`, method: 'GET', subject: 'Claude account', timeoutMs: REQUEST_TIMEOUT_MS, signal, now: this.now,
132
+ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json',
133
+ ...(route === 'usage' ? { 'anthropic-beta': 'oauth-2025-04-20' } : { 'Cache-Control': 'no-cache' }) },
134
+ });
135
+ if (status === 401) throw new ClaudeLoginRejectedError();
136
+ if (status < 200 || status > 299) throw new ClaudeRequestError(`Claude account service returned HTTP ${status}.`);
137
+ if (body === null) throw new ClaudeRequestError('The Claude account service returned invalid data.');
138
+ return body;
139
+ }
140
+
141
+ /** The profile, from the session while it is younger than an hour; the session key is a hash of the token, never the token. */
142
+ private profile(token: string, account: Record<string, unknown>, optionsArg: IHarnessStatusOptions): Promise<TClaudeProfile> {
143
+ const lookup = () => this.lookupProfile(token, account, optionsArg.signal);
144
+ return optionsArg.session ? optionsArg.session.reuse(`claude:profile:${credentialHash(token)}`, PROFILE_REUSE_MS, lookup) : lookup();
145
+ }
146
+
147
+ /** A rejected login and an unsupported or foreign profile are answers; a rate limit or a failed request is thrown. */
148
+ private async lookupProfile(token: string, account: Record<string, unknown>, signal?: AbortSignal): Promise<TClaudeProfile> {
149
+ let body: Record<string, unknown>;
150
+ try { body = await this.get('profile', token, signal); }
151
+ catch (error) {
152
+ if (error instanceof ClaudeLoginRejectedError) return { kind: 'unavailable', problem: `Profile: ${error.message}` };
153
+ throw error;
154
+ }
111
155
  try {
112
- const response = await this.fetcher(`https://api.anthropic.com/api/oauth/${route}`, {
113
- method: 'GET', redirect: 'error', signal,
114
- headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json',
115
- ...(route === 'usage' ? { 'anthropic-beta': 'oauth-2025-04-20' } : { 'Cache-Control': 'no-cache' }) },
116
- });
117
- if (!response.ok) {
118
- await response.body?.cancel();
119
- throw new ClaudeStatusError(response.status === 401 ? 'Login expired or was rejected. Log in again with Claude Code and save it.' : `Claude account service returned HTTP ${response.status}.`);
156
+ const user = credentialRecord(body.account);
157
+ const organization = credentialRecord(body.organization);
158
+ if (user.uuid !== account.accountUuid || organization.uuid !== account.organizationUuid) return { kind: 'mismatch' };
159
+ const facts: IHarnessStatusFact[] = [];
160
+ for (const [label, value] of [['Email', user.email], ['Organization', organization.name], ['Billing type', organization.billing_type], ['Seat tier', organization.seat_tier]]) {
161
+ if (credentialText(value)) facts.push({ section: 'Account', label: String(label), value: String(value) });
120
162
  }
121
- const reader = response.body?.getReader();
122
- if (!reader) throw new ClaudeStatusError('The Claude account service returned an empty response.');
123
- const chunks: Uint8Array[] = [];
124
- let size = 0;
125
- try {
126
- while (true) {
127
- const part = await reader.read();
128
- if (part.done) break;
129
- size += part.value.byteLength;
130
- if (size > 1024 * 1024) { await reader.cancel(); throw new ClaudeStatusError('The Claude account response is too large.'); }
131
- chunks.push(part.value);
132
- }
133
- } finally { reader.releaseLock(); }
134
- try { return credentialRecord(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
135
- catch { throw new ClaudeStatusError('The Claude account service returned invalid data.'); }
136
- } catch (error) {
137
- if (signal.aborted) throw new ClaudeStatusError(timeout.aborted ? 'The Claude account request timed out.' : 'The Claude account request was cancelled.');
138
- if (error instanceof ClaudeStatusError) throw error;
139
- throw new ClaudeStatusError('The Claude account service could not be reached.');
140
- }
163
+ return { kind: 'found', plan: credentialText(organization.organization_type)?.replace(/^claude_/, ''), facts };
164
+ } catch { return { kind: 'unavailable', problem: 'Claude profile response is unsupported; no profile values were inferred.' }; }
165
+ }
166
+
167
+ /** The plan the login was saved with, the only one known while the service cannot be asked. */
168
+ private storedSummary(oauth: Record<string, unknown>): Pick<IHarnessAccountStatus, 'summary'> {
169
+ const plan = credentialText(oauth.subscriptionType);
170
+ return plan ? { summary: { subscription: { plan, source: 'stored' } } } : {};
141
171
  }
172
+
173
+ /** A login that could not be made usable for a lookup: its stored plan and why, with the service's retry time for a rate limit. */
174
+ public unavailable(oauth: Record<string, unknown>, problem: string, rateLimit?: IHarnessAccountStatus['rateLimit']): IHarnessAccountStatus {
175
+ return { facts: [], problems: [problem], ...this.storedSummary(oauth), ...(rateLimit ? { rateLimit } : {}) };
176
+ }
177
+
142
178
  public async read(oauth: Record<string, unknown>, account: Record<string, unknown>, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
143
- const result: IHarnessAccountStatus = { facts: [], problems: [] };
144
- const storedPlan = credentialText(oauth.subscriptionType);
145
- if (storedPlan) result.summary = { subscription: { plan: storedPlan, source: 'stored' } };
179
+ const result: IHarnessAccountStatus = { facts: [], problems: [], ...this.storedSummary(oauth) };
146
180
  if (!Array.isArray(oauth.scopes) || !oauth.scopes.includes('user:profile')) {
147
181
  result.facts.push({ section: 'Availability', label: 'Live account status', value: 'This login lacks the user:profile scope. Log in through Claude Code to obtain subscriber status.' });
148
182
  return result;
149
183
  }
150
184
  const token = credentialText(oauth.accessToken);
151
185
  if (!token) return { facts: [], problems: ['No usable Claude Code access token is available.'] };
152
- const [profile, usage] = await Promise.allSettled([this.get('profile', token, optionsArg.signal), this.get('usage', token, optionsArg.signal)]);
186
+ const [profile, usage] = await Promise.allSettled([this.profile(token, account, optionsArg), this.get('usage', token, optionsArg.signal)]);
187
+ const failure = (reason: unknown): string => reason instanceof ClaudeRequestError ? reason.message : 'Lookup failed.';
153
188
  if (profile.status === 'fulfilled') {
154
- try {
155
- const user = credentialRecord(profile.value.account);
156
- const organization = credentialRecord(profile.value.organization);
157
- if (user.uuid !== account.accountUuid || organization.uuid !== account.organizationUuid) return { facts: [], problems: ['Claude returned a different account or organization; no account data was displayed.'] };
158
- const plan = credentialText(organization.organization_type)?.replace(/^claude_/, '');
159
- if (plan) result.summary = { ...result.summary, subscription: { plan, source: 'live' } };
160
- for (const [label, value] of [['Email', user.email], ['Organization', organization.name], ['Billing type', organization.billing_type], ['Seat tier', organization.seat_tier]]) {
161
- if (credentialText(value)) result.facts.push({ section: 'Account', label: String(label), value: String(value) });
162
- }
163
- } catch { result.problems.push('Claude profile response is unsupported; no profile values were inferred.'); }
164
- } else result.problems.push(`Profile: ${profile.reason instanceof ClaudeStatusError ? profile.reason.message : 'Lookup failed.'}`);
189
+ const found = profile.value;
190
+ if (found.kind === 'mismatch') return { facts: [], problems: [MISMATCH] };
191
+ if (found.kind === 'unavailable') result.problems.push(found.problem);
192
+ else {
193
+ if (found.plan) result.summary = { ...result.summary, subscription: { plan: found.plan, source: 'live' } };
194
+ result.facts.push(...found.facts.map(fact => ({ ...fact })));
195
+ }
196
+ } else result.problems.push(`Profile: ${failure(profile.reason)}`);
165
197
  if (usage.status === 'fulfilled') {
166
198
  try {
167
199
  const { windows, unsupported } = claudeUsageWindows(usage.value);
@@ -181,8 +213,11 @@ export class ClaudeAccountStatus {
181
213
  }
182
214
  if (unsupported.length) result.facts.push({ section: 'Availability', label: 'Unsupported limits', value: `The service returned limits without a supported reset window, which are not shown: ${unsupported.join(', ')}.` });
183
215
  } catch { result.problems.push('Claude usage response is unsupported; missing values were not inferred.'); }
184
- } else result.problems.push(`Usage: ${usage.reason instanceof ClaudeStatusError ? usage.reason.message : 'Lookup failed.'}`);
185
- result.facts.push({ section: 'Availability', label: 'Billing dates and earned resets', value: 'Renewal dates, cancellation dates and earned reset credits are not exposed by these Claude Code endpoints.' });
216
+ } else result.problems.push(`Usage: ${failure(usage.reason)}`);
217
+ const limited = [profile, usage].flatMap(item => item.status === 'rejected' && item.reason instanceof ClaudeRateLimitError ? [item.reason] : []);
218
+ if (limited.length) result.rateLimit = { retryAt: latestRetryAt(limited.map(error => error.retryAt)) };
219
+ // The missing renewal dates are the harness's `renewalUnavailableReason`, stated once by the views that show them.
220
+ result.facts.push({ section: 'Availability', label: 'Earned resets', value: 'authswitch does not read earned reset credits for Claude Code logins.' });
186
221
  return result;
187
222
  }
188
223
  }
@@ -0,0 +1,85 @@
1
+ import { credentialText } from './classes.credentialstore.js';
2
+ import { claudeRequest, ClaudeLoginRejectedError, ClaudeRequestError } from './claudehttp.js';
3
+
4
+ /** Claude Code 2.1.273's token endpoint (`TOKEN_URL`, bundle offset @12733057; see hints.md). */
5
+ const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
6
+ /** Claude Code 2.1.273's public OAuth client (`CLIENT_ID`, @12733057), used when a login does not name its own. */
7
+ export const CLAUDE_CODE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
8
+ /** Claude Code 2.1.273 refreshes an access token that expires within five minutes (`cF`, @15359182). */
9
+ const REFRESH_MARGIN_MS = 300_000;
10
+ /**
11
+ * Claude Code 2.1.273 allows a refresh 30 seconds. A refresh is never cut shorter than that, nor cancelled once sent:
12
+ * the service may already have replaced the refresh token the request carries.
13
+ */
14
+ const REFRESH_TIMEOUT_MS = 30_000;
15
+
16
+ /** The token fields a refresh replaces in a stored `claudeAiOauth`; every other field is kept. */
17
+ export interface IClaudeTokens {
18
+ accessToken: string;
19
+ refreshToken: string;
20
+ /** Milliseconds since the epoch, as Claude Code stores them. */
21
+ expiresAt: number;
22
+ refreshTokenExpiresAt?: number;
23
+ scopes?: string[];
24
+ }
25
+
26
+ const seconds = (valueArg: unknown): number | undefined =>
27
+ typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg >= 0 ? valueArg : undefined;
28
+
29
+ /**
30
+ * Refreshes a saved Claude Code login with the refresh-token grant Claude Code 2.1.273 uses (`RQ`, @15355055).
31
+ *
32
+ * It only talks to the token endpoint; which login is refreshed, and how the result is stored, belongs to the harness.
33
+ */
34
+ export class ClaudeTokenRefresh {
35
+ constructor(private readonly fetcher: typeof fetch = globalThis.fetch, private readonly now: () => number = Date.now) {}
36
+
37
+ /**
38
+ * Whether Claude Code would refresh this login before using it: its access token expires within five minutes. A
39
+ * login without a numeric expiry or without a refresh token is used as it is, exactly as Claude Code does.
40
+ */
41
+ public due(oauthArg: Record<string, unknown>): boolean {
42
+ const expiresAt = oauthArg.expiresAt;
43
+ return typeof expiresAt === 'number' && Number.isFinite(expiresAt) && credentialText(oauthArg.refreshToken) !== undefined
44
+ && this.now() + REFRESH_MARGIN_MS >= expiresAt;
45
+ }
46
+
47
+ /**
48
+ * New tokens for the login. A response without a refresh token keeps the one sent, and one without a scope keeps the
49
+ * stored scopes, as Claude Code merges them. Throws `ClaudeRequestError` with a fixed diagnostic; a grant the service
50
+ * rejects is `ClaudeLoginRejectedError`, and a rate limit is `ClaudeRateLimitError`.
51
+ */
52
+ public async refresh(oauthArg: Record<string, unknown>): Promise<IClaudeTokens> {
53
+ const refreshToken = credentialText(oauthArg.refreshToken);
54
+ const scopes = oauthArg.scopes;
55
+ if (!refreshToken || !Array.isArray(scopes) || !scopes.every(scope => typeof scope === 'string')) {
56
+ throw new ClaudeRequestError('This saved login has no refresh token or scopes to refresh with. Log in again with Claude Code and save it.');
57
+ }
58
+ const { status, body } = await claudeRequest(this.fetcher, {
59
+ url: TOKEN_URL, method: 'POST', headers: { 'Content-Type': 'application/json' }, subject: 'Claude sign-in',
60
+ body: JSON.stringify({
61
+ grant_type: 'refresh_token', refresh_token: refreshToken,
62
+ client_id: credentialText(oauthArg.clientId) ?? CLAUDE_CODE_CLIENT_ID, scope: scopes.join(' '),
63
+ }),
64
+ timeoutMs: REFRESH_TIMEOUT_MS, readErrorBody: true, now: this.now,
65
+ });
66
+ if ((status === 400 || status === 401) && body?.error === 'invalid_grant') throw new ClaudeLoginRejectedError();
67
+ if (status < 200 || status > 299) throw new ClaudeRequestError(`The Claude sign-in service returned HTTP ${status}.`);
68
+ const receivedAt = this.now();
69
+ const accessToken = credentialText(body?.access_token);
70
+ const tokenType = body?.token_type;
71
+ const expiresIn = seconds(body?.expires_in);
72
+ const refreshExpiresIn = body?.refresh_token_expires_in == null ? null : seconds(body.refresh_token_expires_in);
73
+ const rotated = body?.refresh_token == null ? null : credentialText(body.refresh_token);
74
+ const scope = body?.scope == null ? null : typeof body.scope === 'string' ? body.scope.split(/\s+/).filter(Boolean) : undefined;
75
+ if (!accessToken || (tokenType != null && (typeof tokenType !== 'string' || tokenType.toLowerCase() !== 'bearer'))
76
+ || expiresIn === undefined || refreshExpiresIn === undefined || rotated === undefined || scope === undefined) {
77
+ throw new ClaudeRequestError('The Claude sign-in service returned an unsupported token response; the saved login was not changed.');
78
+ }
79
+ return {
80
+ accessToken, refreshToken: rotated ?? refreshToken, expiresAt: receivedAt + expiresIn * 1000,
81
+ ...(refreshExpiresIn === null ? {} : { refreshTokenExpiresAt: receivedAt + refreshExpiresIn * 1000 }),
82
+ ...(scope === null || !scope.length ? {} : { scopes: scope }),
83
+ };
84
+ }
85
+ }
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, 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,12 @@ ${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: [
372
- { key: 'window', title: 'Window', value: row => `${plainText(row.label)} (${duration(row.durationSeconds)})` },
373
- { key: 'usage', title: 'Used', value: row => `${row.usedPercent}%` },
385
+ await consoleTable(this.out, windows, [
386
+ // The label already names the window by its length, so the column never repeats it.
387
+ { key: 'window', title: 'Window', value: row => plainText(row.label) },
388
+ { key: 'usage', title: 'Used', value: row => usagePercentText(row.usedPercent) },
374
389
  { key: 'reset', title: 'Reset in', value: row => until(row.resetAt, now) },
375
- ], overflow: 'wrap' });
390
+ ]);
376
391
  } else process.stdout.write('Reset schedule could not be verified; the completed prompt will not be repeated.\n');
377
392
  } catch {
378
393
  process.stderr.write('Prompt completed, but its reset schedule could not be verified or displayed. The prompt will not be repeated.\n');
@@ -384,6 +399,57 @@ ${bold('Environment')}
384
399
  }
385
400
  }
386
401
 
402
+ /**
403
+ * Watch usage and switch automatically; see `AuthSwitchWatch`. SIGINT and SIGTERM end it cleanly with status 0.
404
+ * `--once` runs one check and exits 1 when a switch it attempted did not complete.
405
+ */
406
+ private async commandWatch(harnessArg: IAuthHarness | undefined, argsArg: string[]): Promise<number> {
407
+ let options: { harnesses: IAuthHarness[]; intervalMs: number; threshold: number; dryRun: boolean; once: boolean; json: boolean };
408
+ try {
409
+ const parsed = parseCommandArgs(argsArg, { values: ['--interval', '--threshold'], flags: ['--dry-run', '--once', '--json', '--help', '-h'],
410
+ maxPositionals: harnessArg ? 0 : 1, usage: WATCH_USAGE });
411
+ if (parsed.flags.has('--help') || parsed.flags.has('-h')) { process.stdout.write(`${this.usage()}\n`); return 0; }
412
+ const named = parsed.positionals[0];
413
+ const harness = harnessArg ?? (named === undefined ? undefined : this.harnesses.get(named));
414
+ if (named !== undefined && !harness) throw new UsageError(`Unknown harness "${plainText(named)}". ${WATCH_USAGE}`);
415
+ if (harness && harness.autoSwitch !== true) throw new UsageError(`${plainText(harness.label)} does not support automatic switching.`);
416
+ const harnesses = (harness ? [harness] : [...this.harnesses.values()].filter(item => item.autoSwitch === true))
417
+ .sort((left, right) => left.label.localeCompare(right.label) || left.id.localeCompare(right.id));
418
+ if (!harnesses.length) throw new UsageError('No registered harness supports automatic switching.');
419
+ const interval = parsed.values.get('--interval');
420
+ const threshold = parsed.values.get('--threshold');
421
+ options = {
422
+ harnesses, dryRun: parsed.flags.has('--dry-run'), once: parsed.flags.has('--once'), json: parsed.flags.has('--json'),
423
+ intervalMs: interval === undefined ? WATCH_INTERVAL.defaultMs : parseDurationOption('--interval', interval, WATCH_INTERVAL),
424
+ threshold: threshold === undefined ? WATCH_THRESHOLD.default : parseIntegerOption('--threshold', threshold, WATCH_THRESHOLD),
425
+ };
426
+ } catch (error) {
427
+ if (!(error instanceof UsageError)) throw error;
428
+ process.stderr.write(`${error.message}\n`);
429
+ return 2;
430
+ }
431
+ const watch = new AuthSwitchWatch({
432
+ harnesses: options.harnesses, operations: this.operations, lock: new WatchLock(authSwitchHome()),
433
+ intervalMs: options.intervalMs, threshold: options.threshold, dryRun: options.dryRun,
434
+ onEvent: event => { process.stdout.write(`${options.json ? JSON.stringify(event) : watchEventText(event)}\n`); },
435
+ });
436
+ const controller = new AbortController();
437
+ const stop = () => controller.abort();
438
+ process.once('SIGINT', stop);
439
+ process.once('SIGTERM', stop);
440
+ try {
441
+ const completed = await watch.run(controller.signal, { once: options.once });
442
+ return options.once && !completed ? 1 : 0;
443
+ } catch (error) {
444
+ if (!(error instanceof WatchBusyError)) throw error;
445
+ process.stderr.write(`${error.message}\n`);
446
+ return 1;
447
+ } finally {
448
+ process.removeListener('SIGINT', stop);
449
+ process.removeListener('SIGTERM', stop);
450
+ }
451
+ }
452
+
387
453
  private async selectHarness(): Promise<IAuthHarness | undefined> {
388
454
  if (this.harnesses.size === 1) return this.harnesses.values().next().value;
389
455
  if (this.harnesses.size === 0 || !canPrompt()) {
@@ -494,7 +560,7 @@ ${bold('Environment')}
494
560
  process.stdout.write(`${dim('no account is currently active')}\n`);
495
561
  }
496
562
  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`);
563
+ for (const drift of stateArg.credentialDrift ?? []) process.stdout.write(`${orange(credentialDriftNote(harnessArg, drift))}\n`);
498
564
  }
499
565
 
500
566
  private async selectActive(harness: IAuthHarness, reference?: string): Promise<IHarnessAccount | undefined> {
@@ -515,12 +581,13 @@ ${bold('Environment')}
515
581
  }
516
582
 
517
583
  /**
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`.
584
+ * The condensed views are projections of the same account list, which never changes which account is in
585
+ * use, so a provider is queried once, per account, with the same bounded timeouts and per-account failure
586
+ * isolation as `list`.
520
587
  */
521
588
  private async commandCondensed(commandArg: 'limits' | 'active', harnessesArg: IAuthHarness[], jsonArg: boolean): Promise<number> {
522
589
  const list = await readAccountList(harnessesArg);
523
- const document = commandArg === 'limits' ? accountLimits(list) : activeAccounts(list);
590
+ const document = commandArg === 'limits' ? accountLimits(list, harnessesArg) : activeAccounts(list, harnessesArg);
524
591
  if (jsonArg) process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
525
592
  else if ('limits' in document) await new CondensedRenderer(this.out).renderLimits(document);
526
593
  else await new CondensedRenderer(this.out).renderActive(document);
@@ -530,17 +597,10 @@ ${bold('Environment')}
530
597
  private async commandList(harnessesArg: IAuthHarness[], jsonArg = false): Promise<number> {
531
598
  const list = await readAccountList(harnessesArg);
532
599
  if (jsonArg) process.stdout.write(`${JSON.stringify(list, null, 2)}\n`);
533
- else await new AccountListRenderer(this.out).render(list);
600
+ else await new AccountListRenderer(this.out).render(list, harnessesArg);
534
601
  return list.complete ? 0 : 1;
535
602
  }
536
603
 
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
604
  private async resolveAccount(harnessArg: IAuthHarness, referenceArg: string): Promise<string | null> {
545
605
  const resolved = await harnessArg.resolveAccount(referenceArg);
546
606
  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(),