@karpeleslab/teamclaude 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/oauth.js CHANGED
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import { randomBytes, createHash } from 'node:crypto';
4
4
  import { exec } from 'node:child_process';
5
+ import { createInterface } from 'node:readline';
5
6
  import http from 'node:http';
6
7
 
7
8
  /**
@@ -68,7 +69,7 @@ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_
68
69
  return {
69
70
  accessToken: data.access_token,
70
71
  refreshToken: data.refresh_token || refreshToken,
71
- expiresAt: data.expires_at || (Date.now() + (data.expires_in || 3600) * 1000),
72
+ expiresAt: normalizeExpiresAt(data.expires_at) || (Date.now() + (data.expires_in || 3600) * 1000),
72
73
  };
73
74
  } catch (err) {
74
75
  const isNetworkError = err instanceof Error &&
@@ -84,36 +85,58 @@ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_
84
85
  }
85
86
  }
86
87
 
88
+ /**
89
+ * Normalize an expires_at value to milliseconds.
90
+ * OAuth endpoints may return seconds; Claude Code credentials use milliseconds.
91
+ */
92
+ export function normalizeExpiresAt(expiresAt) {
93
+ if (!expiresAt) return expiresAt;
94
+ // If the value is plausibly in seconds (< 10^12 ≈ year 2001 in ms, year 33658 in s),
95
+ // convert to milliseconds
96
+ return expiresAt < 1e12 ? expiresAt * 1000 : expiresAt;
97
+ }
98
+
87
99
  /**
88
100
  * Check if an OAuth token is expiring within the given threshold.
89
101
  */
90
102
  export function isTokenExpiringSoon(expiresAt, thresholdMs = 5 * 60 * 1000) {
91
103
  if (!expiresAt) return false;
92
- return Date.now() + thresholdMs >= expiresAt;
104
+ return Date.now() + thresholdMs >= normalizeExpiresAt(expiresAt);
93
105
  }
94
106
 
95
107
  /**
96
108
  * Fetch account profile for an OAuth token.
97
- * Returns { email, name, orgName, orgType } or null on failure.
109
+ * Returns { email, name, orgName, orgType, ... } on success,
110
+ * or { error: 'reason' } on failure.
98
111
  */
99
112
  export async function fetchProfile(accessToken) {
100
113
  try {
101
114
  const res = await fetch(PROFILE_URL, {
102
115
  headers: { 'Authorization': `Bearer ${accessToken}` },
103
116
  });
104
- if (!res.ok) return null;
117
+ if (!res.ok) {
118
+ let detail = '';
119
+ try {
120
+ const body = await res.json();
121
+ detail = body?.error?.message || JSON.stringify(body).slice(0, 200);
122
+ } catch {
123
+ detail = await res.text().catch(() => '');
124
+ }
125
+ return { error: `HTTP ${res.status}${detail ? ': ' + detail : ''}` };
126
+ }
105
127
  const data = await res.json();
106
128
  return {
107
129
  accountUuid: data.account?.uuid,
108
130
  email: data.account?.email,
109
131
  name: data.account?.display_name,
132
+ orgUuid: data.organization?.uuid,
110
133
  orgName: data.organization?.name,
111
134
  orgType: data.organization?.organization_type,
112
135
  hasClaudeMax: data.account?.has_claude_max,
113
136
  hasClaudePro: data.account?.has_claude_pro,
114
137
  };
115
- } catch {
116
- return null;
138
+ } catch (err) {
139
+ return { error: err.message || String(err) };
117
140
  }
118
141
  }
119
142
 
@@ -153,10 +176,10 @@ export async function loginOAuth() {
153
176
  console.log(`If it doesn't open, visit:\n ${authUrl.toString()}\n`);
154
177
  openBrowser(authUrl.toString());
155
178
 
156
- // Wait for the authorization code
179
+ // Wait for either the callback server or manual paste from stdin
157
180
  let code;
158
181
  try {
159
- code = await codePromise;
182
+ code = await raceWithStdinCode(codePromise, state);
160
183
  } finally {
161
184
  server.close();
162
185
  }
@@ -185,10 +208,58 @@ export async function loginOAuth() {
185
208
  return {
186
209
  accessToken: tokens.access_token,
187
210
  refreshToken: tokens.refresh_token,
188
- expiresAt: tokens.expires_at || (Date.now() + (tokens.expires_in || 3600) * 1000),
211
+ expiresAt: normalizeExpiresAt(tokens.expires_at) || (Date.now() + (tokens.expires_in || 3600) * 1000),
189
212
  };
190
213
  }
191
214
 
215
+ /**
216
+ * Race the callback server promise against manual code entry from stdin.
217
+ * The user can paste the full callback URL or just the authorization code.
218
+ */
219
+ function raceWithStdinCode(callbackPromise, expectedState) {
220
+ if (!process.stdin.isTTY) return callbackPromise;
221
+
222
+ return new Promise((resolve, reject) => {
223
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
224
+ let settled = false;
225
+
226
+ const settle = (fn, val) => {
227
+ if (settled) return;
228
+ settled = true;
229
+ rl.close();
230
+ fn(val);
231
+ };
232
+
233
+ rl.question('Paste authorization code here (or wait for browser callback): ', answer => {
234
+ const trimmed = answer.trim();
235
+ if (!trimmed) return; // empty input, keep waiting for callback
236
+
237
+ // Try to parse as a URL with ?code= parameter
238
+ try {
239
+ const url = new URL(trimmed);
240
+ const code = url.searchParams.get('code');
241
+ const state = url.searchParams.get('state');
242
+ if (code) {
243
+ if (expectedState && state && state !== expectedState) {
244
+ settle(reject, new Error('OAuth state mismatch'));
245
+ } else {
246
+ settle(resolve, code);
247
+ }
248
+ return;
249
+ }
250
+ } catch {}
251
+
252
+ // Treat raw input as the authorization code
253
+ settle(resolve, trimmed);
254
+ });
255
+
256
+ callbackPromise.then(
257
+ code => settle(resolve, code),
258
+ err => settle(reject, err),
259
+ );
260
+ });
261
+ }
262
+
192
263
  function startCallbackServer(expectedState) {
193
264
  return new Promise((resolve, reject) => {
194
265
  let resolveCode, rejectCode;
package/src/server.js CHANGED
@@ -2,6 +2,7 @@ import http from 'node:http';
2
2
  import { writeFile, mkdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
 
5
+
5
6
  const HOP_BY_HOP_HEADERS = new Set([
6
7
  'host', 'connection', 'keep-alive', 'transfer-encoding',
7
8
  'te', 'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate',
@@ -39,9 +40,11 @@ export function createProxyServer(accountManager, config, hooks = {}) {
39
40
  return;
40
41
  }
41
42
 
42
- // Intercept token refresh requests forward to real endpoint and capture new tokens
43
+ // Let client token refresh requests pass through to upstream untouched.
44
+ // The proxy manages its own tokens via ensureTokenFresh(); intercepting
45
+ // or rewriting client refreshes would cause token rotation conflicts.
43
46
  if (req.method === 'POST' && req.url === '/v1/oauth/token') {
44
- await handleTokenRefresh(req, res, accountManager, hooks);
47
+ await relayRaw(req, res, upstream);
45
48
  return;
46
49
  }
47
50
 
@@ -57,82 +60,52 @@ export function createProxyServer(accountManager, config, hooks = {}) {
57
60
  const body = Buffer.concat(bodyChunks);
58
61
 
59
62
  const ctx = { account: null, status: null };
60
- await forwardRequest(req, res, body, accountManager, upstream, 0, hooks, reqId, ctx, logDir);
61
-
62
- hooks.onRequestEnd?.(reqId, {
63
- method: req.method, path: req.url,
64
- account: ctx.account, status: ctx.status,
65
- });
63
+ try {
64
+ await forwardRequest(req, res, body, accountManager, upstream, 0, hooks, reqId, ctx, logDir);
65
+ } catch (err) {
66
+ ctx.status = ctx.status || 502;
67
+ console.error('[TeamClaude] Unhandled error:', err);
68
+ if (!res.headersSent) {
69
+ res.writeHead(502, { 'Content-Type': 'application/json' });
70
+ res.end(JSON.stringify({
71
+ type: 'error',
72
+ error: { type: 'proxy_error', message: 'Internal proxy error' },
73
+ }));
74
+ }
75
+ } finally {
76
+ hooks.onRequestEnd?.(reqId, {
77
+ method: req.method, path: req.url,
78
+ account: ctx.account, status: ctx.status,
79
+ });
80
+ }
66
81
  } catch (err) {
67
82
  console.error('[TeamClaude] Unhandled error:', err);
68
- if (!res.headersSent) {
69
- res.writeHead(502, { 'Content-Type': 'application/json' });
70
- res.end(JSON.stringify({
71
- type: 'error',
72
- error: { type: 'proxy_error', message: 'Internal proxy error' },
73
- }));
74
- }
75
83
  }
76
84
  });
77
85
 
78
86
  return server;
79
87
  }
80
88
 
81
- const TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token';
82
-
83
89
  /**
84
- * Forward a token refresh request to the real token endpoint, capture new tokens,
85
- * and pass the response back to the client.
90
+ * Relay a request to upstream with no header rewriting pure passthrough.
86
91
  */
87
- async function handleTokenRefresh(req, res, accountManager, hooks) {
92
+ async function relayRaw(req, res, upstream) {
88
93
  const bodyChunks = [];
89
- for await (const chunk of req) {
90
- bodyChunks.push(chunk);
91
- }
92
- const rawBody = Buffer.concat(bodyChunks);
93
-
94
- // Replace the refresh token in the request with the current account's refresh token
95
- // so the renewal happens for the active account, not just the one the client knows about
96
- let body = rawBody;
97
- try {
98
- const parsed = JSON.parse(rawBody.toString());
99
- const currentAccount = accountManager.accounts[accountManager.currentIndex];
100
- if (parsed.grant_type === 'refresh_token' && currentAccount?.type === 'oauth' && currentAccount.refreshToken) {
101
- parsed.refresh_token = currentAccount.refreshToken;
102
- body = JSON.stringify(parsed);
103
- console.log(`[TeamClaude] Token refresh: substituted refresh token for account "${currentAccount.name}"`);
104
- }
105
- } catch {}
94
+ for await (const chunk of req) bodyChunks.push(chunk);
95
+ const body = Buffer.concat(bodyChunks);
106
96
 
107
97
  try {
108
- // Forward to the real token endpoint
109
- const upstreamRes = await fetch(TOKEN_ENDPOINT, {
110
- method: 'POST',
98
+ const upstreamRes = await fetch(`${upstream}${req.url}`, {
99
+ method: req.method,
111
100
  headers: {
112
- 'Content-Type': req.headers['content-type'] || 'application/json',
113
- 'Accept': 'application/json, text/plain, */*',
114
- 'User-Agent': req.headers['user-agent'] || 'axios/1.13.6',
101
+ 'content-type': req.headers['content-type'] || 'application/json',
102
+ 'accept': req.headers['accept'] || 'application/json',
103
+ 'user-agent': req.headers['user-agent'] || 'node',
115
104
  },
116
- body,
105
+ body: body.length > 0 ? body : undefined,
117
106
  });
118
107
 
119
108
  const responseBody = await upstreamRes.text();
120
-
121
- // Capture tokens from successful refresh — update the current account directly
122
- if (upstreamRes.ok) {
123
- try {
124
- const tokens = JSON.parse(responseBody);
125
- if (tokens.access_token) {
126
- accountManager.updateAccountTokens(accountManager.currentIndex, {
127
- accessToken: tokens.access_token,
128
- refreshToken: tokens.refresh_token,
129
- expiresAt: tokens.expires_at || (Date.now() + (tokens.expires_in || 3600) * 1000),
130
- });
131
- }
132
- } catch {}
133
- }
134
-
135
- // Forward response to client
136
109
  const responseHeaders = {};
137
110
  for (const [key, value] of upstreamRes.headers.entries()) {
138
111
  if (key === 'transfer-encoding' || key === 'connection') continue;
@@ -141,17 +114,15 @@ async function handleTokenRefresh(req, res, accountManager, hooks) {
141
114
  res.writeHead(upstreamRes.status, responseHeaders);
142
115
  res.end(responseBody);
143
116
  } catch (err) {
144
- console.error('[TeamClaude] Token refresh proxy error:', err.message);
117
+ console.error('[TeamClaude] Raw relay error:', err.message);
145
118
  if (!res.headersSent) {
146
119
  res.writeHead(502, { 'Content-Type': 'application/json' });
147
- res.end(JSON.stringify({
148
- type: 'error',
149
- error: { type: 'proxy_error', message: `Token refresh failed: ${err.message}` },
150
- }));
120
+ res.end(JSON.stringify({ type: 'error', error: { type: 'proxy_error', message: 'Upstream unreachable' } }));
151
121
  }
152
122
  }
153
123
  }
154
124
 
125
+
155
126
  function logTimestamp() {
156
127
  const d = new Date();
157
128
  const pad = (n, w = 2) => String(n).padStart(w, '0');
@@ -272,6 +243,43 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
272
243
  }
273
244
  accountManager.updateQuota(account.index, rateLimitHeaders);
274
245
 
246
+ // On 429, wait the retry-after duration and retry on the same account
247
+ // (this is a transient rate limit, not quota exhaustion).
248
+ if (upstreamRes.status === 429) {
249
+ // Clamp Retry-After to a sane window: missing/invalid falls back to 60s,
250
+ // and out-of-range values are bounded to [1, 300]. A negative value would
251
+ // otherwise bypass the retry cap — setTimeout returns immediately and
252
+ // markRateLimited would set rateLimitedUntil in the past.
253
+ let retryAfter = parseInt(upstreamRes.headers.get('retry-after'), 10);
254
+ if (Number.isNaN(retryAfter)) retryAfter = 60;
255
+ retryAfter = Math.min(Math.max(retryAfter, 1), 300);
256
+ // Discard the 429 response body
257
+ await upstreamRes.body?.cancel();
258
+
259
+ // Bound the retries: a persistently-throttled upstream must not loop
260
+ // forever (that would tie up the client connection indefinitely).
261
+ // Once retries are exhausted, throttle this account and re-dispatch —
262
+ // getActiveAccount then picks another account, or returns 429 to the
263
+ // client if every account is throttled.
264
+ if (retryCount >= maxRetries) {
265
+ console.log(`[TeamClaude] Persistent 429 on "${account.name}" — throttling ${retryAfter}s and re-dispatching`);
266
+ accountManager.markRateLimited(account.index, retryAfter);
267
+ if (logDir) {
268
+ logSections.push(`=== RESPONSE 429 — capped after ${retryCount} retries, throttling account ===\n${formatHeaders(upstreamRes.headers)}`);
269
+ }
270
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
271
+ }
272
+
273
+ if (logDir) {
274
+ logSections.push(`=== RESPONSE 429 — waiting ${retryAfter}s ===\n${formatHeaders(upstreamRes.headers)}`);
275
+ }
276
+ console.log(`[TeamClaude] 429 on "${account.name}" — waiting ${retryAfter}s before retry`);
277
+ await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
278
+ // Client may have disconnected during the wait
279
+ if (res.destroyed) return;
280
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
281
+ }
282
+
275
283
  // Log response headers
276
284
  if (logDir) {
277
285
  logSections.push(`=== RESPONSE ${upstreamRes.status} ===\n${formatHeaders(upstreamRes.headers)}`);
@@ -329,6 +337,17 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
329
337
  writeRequestLog(logDir, reqId, logSections);
330
338
  }
331
339
 
340
+ const isTransient = err instanceof Error &&
341
+ (err.message.includes('fetch failed') ||
342
+ err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
343
+ err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT');
344
+
345
+ // Transient network errors: just close the connection and let the client retry
346
+ if (isTransient) {
347
+ res.destroy();
348
+ return;
349
+ }
350
+
332
351
  if (retryCount < maxRetries && !res.headersSent) {
333
352
  account.status = 'error';
334
353
  return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
@@ -358,6 +377,9 @@ async function streamResponse(webStream, res, accountIndex, accountManager, stre
358
377
  const { done, value } = await reader.read();
359
378
  if (done) break;
360
379
 
380
+ // Client disconnected — stop reading from upstream
381
+ if (res.destroyed) break;
382
+
361
383
  // Forward chunk immediately
362
384
  const ok = res.write(value);
363
385
 
@@ -375,9 +397,14 @@ async function streamResponse(webStream, res, accountIndex, accountManager, stre
375
397
  parseSSEUsage(event, accountIndex, accountManager);
376
398
  }
377
399
 
378
- // Handle backpressure
400
+ // Handle backpressure — also bail out if client disconnects,
401
+ // because 'drain' will never fire on a destroyed socket
379
402
  if (!ok) {
380
- await new Promise(resolve => res.once('drain', resolve));
403
+ await new Promise(resolve => {
404
+ res.once('drain', resolve);
405
+ res.once('close', resolve);
406
+ });
407
+ if (res.destroyed) break;
381
408
  }
382
409
  }
383
410
 
@@ -386,7 +413,9 @@ async function streamResponse(webStream, res, accountIndex, accountManager, stre
386
413
  parseSSEUsage(sseBuffer, accountIndex, accountManager);
387
414
  }
388
415
  } finally {
389
- res.end();
416
+ // Cancel upstream reader to stop consuming data nobody needs
417
+ reader.cancel().catch(() => {});
418
+ if (!res.writableEnded) res.end();
390
419
  }
391
420
  }
392
421
 
package/src/tui.js CHANGED
@@ -1,4 +1,5 @@
1
- import { importCredentials } from './oauth.js';
1
+ import { importCredentials, fetchProfile } from './oauth.js';
2
+ import { sameIdentity } from './identity.js';
2
3
 
3
4
  // ── ANSI helpers ─────────────────────────────────────────────
4
5
 
@@ -232,6 +233,9 @@ export class TUI {
232
233
  else if (k === 'r' && this.am.accounts.length > 0) {
233
234
  this.mode = 'select'; this.selAction = 'remove'; this.selIdx = 0;
234
235
  }
236
+ else if (k === 'd' && this.am.accounts.length > 0) {
237
+ this.mode = 'select'; this.selAction = 'toggle'; this.selIdx = this.am.currentIndex;
238
+ }
235
239
  else if (k === 'a') { this.mode = 'add'; }
236
240
  else if (k === 'R') { this._doSync(); }
237
241
  }
@@ -244,6 +248,8 @@ export class TUI {
244
248
  if (this.selAction === 'switch') {
245
249
  this.am.currentIndex = this.selIdx;
246
250
  this._addLog(`Switched to "${this.am.accounts[this.selIdx].name}"`);
251
+ } else if (this.selAction === 'toggle') {
252
+ this._doToggleDisabled(this.selIdx);
247
253
  } else {
248
254
  this._doRemove(this.selIdx);
249
255
  }
@@ -283,7 +289,7 @@ export class TUI {
283
289
  if (count > 0) {
284
290
  this._addLog(`Synced ${count} new account(s) from config`);
285
291
  } else {
286
- this._addLog('Config reloaded, no new accounts');
292
+ this._addLog('Config reloaded, credentials refreshed');
287
293
  }
288
294
  } catch (e) {
289
295
  this._addLog(`Sync failed: ${e.message}`);
@@ -292,19 +298,75 @@ export class TUI {
292
298
 
293
299
  async _doImport() {
294
300
  try {
301
+ this._addLog('Importing credentials...');
295
302
  const creds = await importCredentials('~/.claude/.credentials.json');
296
- const n = this.config.accounts.filter(a => a.name.startsWith('max-')).length + 1;
297
- const name = `max-${n}`;
303
+ const profile = await fetchProfile(creds.accessToken);
304
+ const profileOk = profile && !profile.error;
305
+
306
+ if (!profileOk) {
307
+ this._addLog(`Warning: could not fetch profile — ${profile?.error || 'no token'}`);
308
+ }
309
+
310
+ let name;
311
+ if (profile?.email) {
312
+ name = profile.email;
313
+ const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
314
+ if (tier) this._addLog(`Detected Claude ${tier}: ${name}`);
315
+ } else {
316
+ const n = this.config.accounts.filter(a => a.name.startsWith('account-')).length + 1;
317
+ name = `account-${n}`;
318
+ }
319
+
298
320
  const entry = {
299
- name, type: 'oauth',
321
+ name, type: 'oauth', source: 'import',
322
+ accountUuid: profile?.accountUuid || null,
323
+ orgUuid: profile?.orgUuid || null,
324
+ orgName: profile?.orgName || null,
300
325
  accessToken: creds.accessToken,
301
326
  refreshToken: creds.refreshToken,
302
327
  expiresAt: creds.expiresAt,
303
328
  };
304
- this.config.accounts.push(entry);
305
- this.am.addAccount(entry);
329
+
330
+ // Deduplicate by account+org identity (same email in a different org is a
331
+ // distinct account), then by name.
332
+ let idx = this.config.accounts.findIndex(a => sameIdentity(a, entry));
333
+ if (idx < 0) idx = this.config.accounts.findIndex(a => a.name === name);
334
+
335
+ if (idx >= 0) {
336
+ const prev = this.config.accounts[idx];
337
+ this.config.accounts[idx] = { ...prev, ...entry, name: prev.name };
338
+ // Update the running account manager entry
339
+ const amAcct = this.am.accounts.find(a => sameIdentity(a, entry)) || this.am.accounts[idx];
340
+ if (amAcct) {
341
+ amAcct.credential = creds.accessToken;
342
+ amAcct.refreshToken = creds.refreshToken;
343
+ amAcct.expiresAt = creds.expiresAt;
344
+ amAcct.accountUuid = entry.accountUuid;
345
+ amAcct.orgUuid = entry.orgUuid;
346
+ amAcct.orgName = entry.orgName;
347
+ if (amAcct.status === 'error') amAcct.status = 'active';
348
+ }
349
+ this._addLog(`Updated account "${prev.name}"`);
350
+ } else {
351
+ // New org for this person: disambiguate colliding email names with " (org)".
352
+ if (profile?.accountUuid) {
353
+ const orgLbl = a => a.orgName || (a.orgUuid ? a.orgUuid.slice(0, 8) : 'org');
354
+ const collisions = this.config.accounts.filter(
355
+ a => a.accountUuid === entry.accountUuid && !sameIdentity(a, entry)
356
+ );
357
+ if (collisions.length > 0) {
358
+ for (const c of collisions) {
359
+ if (!c.name.includes(' (')) c.name = `${c.name} (${orgLbl(c)})`;
360
+ }
361
+ entry.name = `${name} (${orgLbl(entry)})`;
362
+ }
363
+ }
364
+ this.config.accounts.push(entry);
365
+ this.am.addAccount(entry);
366
+ this._addLog(`Imported account "${entry.name}"`);
367
+ }
368
+
306
369
  await this.saveConfig(this.config);
307
- this._addLog(`Imported account "${name}"`);
308
370
  } catch (e) {
309
371
  this._addLog(`Import failed: ${e.message}`);
310
372
  }
@@ -329,10 +391,37 @@ export class TUI {
329
391
  this._addLog(`Removed account "${name}"`);
330
392
  }
331
393
 
394
+ async _doToggleDisabled(idx) {
395
+ if (idx < 0 || idx >= this.am.accounts.length) return;
396
+ const acct = this.am.accounts[idx];
397
+ const next = !acct.disabled;
398
+ this.am.setDisabled(idx, next); // re-enabling also clears a stuck error state
399
+ // Write an explicit boolean (not delete): saveConfig merges over the on-disk
400
+ // entry, so a `delete` would leave a stale `disabled: true` from disk intact.
401
+ if (this.config.accounts[idx]) this.config.accounts[idx].disabled = next;
402
+ await this.saveConfig(this.config);
403
+ this._addLog(`${next ? 'Disabled' : 'Enabled'} account "${acct.name}"`);
404
+ }
405
+
332
406
  // ── rendering ──────────────────────────────────────
333
407
 
334
408
  render() {
335
409
  if (!this.running) return;
410
+ // Guard against re-entry: clearing an expired quota logs, and _addLog calls
411
+ // render() again — without this the nested call would render twice.
412
+ if (this._rendering) return;
413
+ this._rendering = true;
414
+ try {
415
+ this._render();
416
+ } finally {
417
+ this._rendering = false;
418
+ }
419
+ }
420
+
421
+ _render() {
422
+ // Reset the display the instant a quota window (e.g. 5-hour session) expires,
423
+ // instead of waiting for the next request to clear it.
424
+ this.am.refreshExpiredQuotas();
336
425
  const W = process.stdout.columns || 80;
337
426
  const H = process.stdout.rows || 24;
338
427
 
@@ -423,9 +512,11 @@ export class TUI {
423
512
  // Type
424
513
  const type = gray(a.type.padEnd(7));
425
514
 
426
- // Status
515
+ // Status — a disabled account is shown as such regardless of its quota state.
427
516
  let status;
428
- switch (a.status) {
517
+ if (a.disabled) {
518
+ status = gray('disabled');
519
+ } else switch (a.status) {
429
520
  case 'active': status = isCur ? green('active') : 'active'; break;
430
521
  case 'throttled': status = yellow('throttled'); break;
431
522
  case 'exhausted': status = red('exhausted'); break;
@@ -464,9 +555,11 @@ export class TUI {
464
555
  _renderFooter() {
465
556
  switch (this.mode) {
466
557
  case 'normal':
467
- return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('R')}eload ${bold('q')}uit`;
558
+ return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('d')}isable ${bold('R')}eload ${bold('q')}uit`;
468
559
  case 'select': {
469
- const act = this.selAction === 'switch' ? 'switch' : 'remove';
560
+ const act = this.selAction === 'switch' ? 'switch'
561
+ : this.selAction === 'toggle' ? 'enable/disable'
562
+ : 'remove';
470
563
  return ` ${dim('↑↓')} select ${bold('Enter')} ${act} ${bold('Esc')} cancel`;
471
564
  }
472
565
  case 'add':