@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/LICENSE +21 -0
- package/README.md +93 -14
- package/package.json +7 -2
- package/src/account-manager.js +214 -11
- package/src/alias.js +123 -0
- package/src/config.js +26 -0
- package/src/identity.js +65 -0
- package/src/index.js +353 -78
- package/src/oauth.js +1 -0
- package/src/server.js +23 -3
- package/src/tui.js +66 -13
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
|
-
|
|
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
|
|
323
|
-
|
|
324
|
-
|
|
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]
|
|
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.
|
|
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
|
-
|
|
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'
|
|
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':
|