@karpeleslab/teamclaude 1.0.6 → 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/server.js CHANGED
@@ -244,12 +244,32 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
244
244
  accountManager.updateQuota(account.index, rateLimitHeaders);
245
245
 
246
246
  // On 429, wait the retry-after duration and retry on the same account
247
- // (this is a transient rate limit, not quota exhaustion)
247
+ // (this is a transient rate limit, not quota exhaustion).
248
248
  if (upstreamRes.status === 429) {
249
- const retryAfter = parseInt(upstreamRes.headers.get('retry-after'), 10) || 60;
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);
250
256
  // Discard the 429 response body
251
257
  await upstreamRes.body?.cancel();
252
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
+
253
273
  if (logDir) {
254
274
  logSections.push(`=== RESPONSE 429 — waiting ${retryAfter}s ===\n${formatHeaders(upstreamRes.headers)}`);
255
275
  }
@@ -257,7 +277,7 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
257
277
  await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
258
278
  // Client may have disconnected during the wait
259
279
  if (res.destroyed) return;
260
- return forwardRequest(req, res, body, accountManager, upstream, retryCount, hooks, reqId, ctx, logDir);
280
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
261
281
  }
262
282
 
263
283
  // Log response headers
package/src/tui.js CHANGED
@@ -1,4 +1,5 @@
1
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
  }
@@ -314,34 +320,50 @@ export class TUI {
314
320
  const entry = {
315
321
  name, type: 'oauth', source: 'import',
316
322
  accountUuid: profile?.accountUuid || null,
323
+ orgUuid: profile?.orgUuid || null,
324
+ orgName: profile?.orgName || null,
317
325
  accessToken: creds.accessToken,
318
326
  refreshToken: creds.refreshToken,
319
327
  expiresAt: creds.expiresAt,
320
328
  };
321
329
 
322
- // Deduplicate: match by UUID first, then by name
323
- let idx = profile?.accountUuid
324
- ? this.config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
325
- : -1;
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));
326
333
  if (idx < 0) idx = this.config.accounts.findIndex(a => a.name === name);
327
334
 
328
335
  if (idx >= 0) {
329
- this.config.accounts[idx] = entry;
336
+ const prev = this.config.accounts[idx];
337
+ this.config.accounts[idx] = { ...prev, ...entry, name: prev.name };
330
338
  // Update the running account manager entry
331
- const amAcct = this.am.accounts[idx];
339
+ const amAcct = this.am.accounts.find(a => sameIdentity(a, entry)) || this.am.accounts[idx];
332
340
  if (amAcct) {
333
341
  amAcct.credential = creds.accessToken;
334
342
  amAcct.refreshToken = creds.refreshToken;
335
343
  amAcct.expiresAt = creds.expiresAt;
336
344
  amAcct.accountUuid = entry.accountUuid;
337
- amAcct.name = name;
345
+ amAcct.orgUuid = entry.orgUuid;
346
+ amAcct.orgName = entry.orgName;
338
347
  if (amAcct.status === 'error') amAcct.status = 'active';
339
348
  }
340
- this._addLog(`Updated account "${name}"`);
349
+ this._addLog(`Updated account "${prev.name}"`);
341
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
+ }
342
364
  this.config.accounts.push(entry);
343
365
  this.am.addAccount(entry);
344
- this._addLog(`Imported account "${name}"`);
366
+ this._addLog(`Imported account "${entry.name}"`);
345
367
  }
346
368
 
347
369
  await this.saveConfig(this.config);
@@ -369,10 +391,37 @@ export class TUI {
369
391
  this._addLog(`Removed account "${name}"`);
370
392
  }
371
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
+
372
406
  // ── rendering ──────────────────────────────────────
373
407
 
374
408
  render() {
375
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();
376
425
  const W = process.stdout.columns || 80;
377
426
  const H = process.stdout.rows || 24;
378
427
 
@@ -463,9 +512,11 @@ export class TUI {
463
512
  // Type
464
513
  const type = gray(a.type.padEnd(7));
465
514
 
466
- // Status
515
+ // Status — a disabled account is shown as such regardless of its quota state.
467
516
  let status;
468
- switch (a.status) {
517
+ if (a.disabled) {
518
+ status = gray('disabled');
519
+ } else switch (a.status) {
469
520
  case 'active': status = isCur ? green('active') : 'active'; break;
470
521
  case 'throttled': status = yellow('throttled'); break;
471
522
  case 'exhausted': status = red('exhausted'); break;
@@ -504,9 +555,11 @@ export class TUI {
504
555
  _renderFooter() {
505
556
  switch (this.mode) {
506
557
  case 'normal':
507
- 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`;
508
559
  case 'select': {
509
- const act = this.selAction === 'switch' ? 'switch' : 'remove';
560
+ const act = this.selAction === 'switch' ? 'switch'
561
+ : this.selAction === 'toggle' ? 'enable/disable'
562
+ : 'remove';
510
563
  return ` ${dim('↑↓')} select ${bold('Enter')} ${act} ${bold('Esc')} cancel`;
511
564
  }
512
565
  case 'add':