@karpeleslab/teamclaude 1.0.5 → 1.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karpeleslab/teamclaude",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Multi-account Claude proxy with automatic quota-based rotation",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -252,7 +252,11 @@ export class AccountManager {
252
252
  this._onTokenRefresh?.(accountIndex, newTokens);
253
253
  } catch (err) {
254
254
  console.error(`[TeamClaude] Token refresh failed for "${account.name}": ${err.message}`);
255
- account.status = 'error';
255
+ // Only mark as error if the access token is actually expired;
256
+ // a failed proactive refresh shouldn't kill a still-valid token
257
+ if (!account.expiresAt || Date.now() >= account.expiresAt) {
258
+ account.status = 'error';
259
+ }
256
260
  } finally {
257
261
  account._refreshPromise = null;
258
262
  }
package/src/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { spawn } from 'node:child_process';
3
+ import { spawnSync } from 'node:child_process';
4
4
  import { createInterface } from 'node:readline';
5
5
  import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath } from './config.js';
6
6
  import { AccountManager } from './account-manager.js';
7
7
  import { createProxyServer } from './server.js';
8
- import { importCredentials, loginOAuth, fetchProfile } from './oauth.js';
8
+ import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
9
9
  import { TUI } from './tui.js';
10
10
 
11
11
  const args = process.argv.slice(2);
@@ -94,8 +94,23 @@ async function serverCommand() {
94
94
  accountManager.onTokenRefresh((idx, newTokens) => {
95
95
  const account = accountManager.accounts[idx];
96
96
  if (!account) return;
97
+ // Keep config.accounts in sync so TUI saveConfig doesn't clobber fresh tokens
98
+ if (config.accounts[idx]) {
99
+ config.accounts[idx].accessToken = newTokens.accessToken;
100
+ config.accounts[idx].refreshToken = newTokens.refreshToken;
101
+ config.accounts[idx].expiresAt = newTokens.expiresAt;
102
+ }
97
103
  atomicConfigUpdate(diskConfig => {
98
- syncNewAccountsFromDisk(diskConfig, config, accountManager);
104
+ // Pick up any new accounts from disk so index matching stays correct
105
+ // (only add, don't refresh credentials — we're about to write the authoritative tokens)
106
+ for (const diskAcct of diskConfig.accounts) {
107
+ const known = (diskAcct.accountUuid && config.accounts.some(a => a.accountUuid === diskAcct.accountUuid))
108
+ || config.accounts.some(a => a.name === diskAcct.name);
109
+ if (!known) {
110
+ config.accounts.push(diskAcct);
111
+ accountManager.addAccount(diskAcct);
112
+ }
113
+ }
99
114
  // Match by UUID first, then by name — index may have shifted
100
115
  const cfgIdx = findConfigAccount(diskConfig, account);
101
116
  if (cfgIdx >= 0) {
@@ -114,22 +129,28 @@ async function serverCommand() {
114
129
  if (useTUI) {
115
130
  tui = new TUI({
116
131
  accountManager, config,
117
- saveConfig: () => atomicConfigUpdate(diskConfig => {
118
- syncNewAccountsFromDisk(diskConfig, config, accountManager);
119
- // Write in-memory accounts back, preserving extra disk-only fields
120
- diskConfig.accounts = config.accounts.map(a => {
132
+ saveConfig: () => atomicConfigUpdate(async diskConfig => {
133
+ // Write in-memory accounts as the authoritative state, preserving
134
+ // extra disk-only fields (e.g. importFrom) where the account still exists.
135
+ // Use live tokens from AccountManager (not the stale config.accounts copy).
136
+ diskConfig.accounts = config.accounts.map((a, i) => {
137
+ const am = accountManager.accounts[i];
138
+ const live = am ? {
139
+ ...a,
140
+ accessToken: am.credential,
141
+ refreshToken: am.refreshToken,
142
+ expiresAt: am.expiresAt,
143
+ } : a;
121
144
  const diskAcct = diskConfig.accounts.find(
122
145
  d => (a.accountUuid && d.accountUuid === a.accountUuid) || d.name === a.name
123
146
  );
124
- return diskAcct ? { ...diskAcct, ...a } : a;
147
+ return diskAcct ? { ...diskAcct, ...live } : live;
125
148
  });
126
149
  }),
127
150
  syncAccounts: async () => {
128
151
  const diskConfig = await loadConfig();
129
152
  if (!diskConfig) return 0;
130
- const before = accountManager.accounts.length;
131
- syncNewAccountsFromDisk(diskConfig, config, accountManager);
132
- return accountManager.accounts.length - before;
153
+ return syncAccountsFromDisk(diskConfig, config, accountManager);
133
154
  },
134
155
  onQuit: () => { server.close(() => process.exit(0)); },
135
156
  });
@@ -299,7 +320,8 @@ async function runCommand() {
299
320
  // Only set ANTHROPIC_BASE_URL — Claude Code keeps its own OAuth token
300
321
  // which the proxy accepts from localhost. Not setting ANTHROPIC_API_KEY
301
322
  // lets Claude Code stay in subscription mode (full model access).
302
- const child = spawn('claude', claudeArgs, {
323
+ // Use spawnSync so the Node process blocks entirely — behaves like execvp.
324
+ const result = spawnSync('claude', claudeArgs, {
303
325
  stdio: 'inherit',
304
326
  env: {
305
327
  ...process.env,
@@ -307,16 +329,16 @@ async function runCommand() {
307
329
  },
308
330
  });
309
331
 
310
- child.on('error', (err) => {
311
- if (err.code === 'ENOENT') {
332
+ if (result.error) {
333
+ if (result.error.code === 'ENOENT') {
312
334
  console.error('Claude Code not found in PATH. Install it first.');
313
335
  } else {
314
- console.error(`Failed to start claude: ${err.message}`);
336
+ console.error(`Failed to start claude: ${result.error.message}`);
315
337
  }
316
338
  process.exit(1);
317
- });
339
+ }
318
340
 
319
- child.on('exit', (code) => process.exit(code ?? 1));
341
+ process.exit(result.status ?? 1);
320
342
  }
321
343
 
322
344
  // ── status ──────────────────────────────────────────────────
@@ -372,6 +394,23 @@ async function accountsCommand() {
372
394
  return;
373
395
  }
374
396
 
397
+ // Refresh expired tokens before fetching profiles
398
+ let configDirty = false;
399
+ await Promise.all(config.accounts.map(async (a) => {
400
+ if (a.type !== 'oauth' || !a.refreshToken) return;
401
+ if (!isTokenExpiringSoon(a.expiresAt)) return;
402
+ try {
403
+ const newTokens = await refreshAccessToken(a.refreshToken);
404
+ a.accessToken = newTokens.accessToken;
405
+ a.refreshToken = newTokens.refreshToken;
406
+ a.expiresAt = newTokens.expiresAt;
407
+ configDirty = true;
408
+ } catch (err) {
409
+ // refresh failed — fetchProfile will report the specific error
410
+ }
411
+ }));
412
+ if (configDirty) await saveConfig(config);
413
+
375
414
  // Fetch profiles in parallel for all OAuth accounts
376
415
  const profiles = await Promise.all(
377
416
  config.accounts.map(a =>
@@ -393,7 +432,7 @@ async function accountsCommand() {
393
432
  } else {
394
433
  seen.set(uuid, i);
395
434
  // Update stored UUID and name from profile
396
- if (profiles[i]) {
435
+ if (profiles[i] && !profiles[i].error) {
397
436
  a.accountUuid = profiles[i].accountUuid;
398
437
  if (profiles[i].email) a.name = profiles[i].email;
399
438
  }
@@ -414,12 +453,13 @@ async function accountsCommand() {
414
453
  }
415
454
 
416
455
  // OAuth account
417
- const tier = p?.hasClaudeMax ? 'Max' : p?.hasClaudePro ? 'Pro' : 'subscription';
418
- const status = p ? `Claude ${tier}` : 'unknown (profile fetch failed)';
456
+ const hasProfile = p && !p.error;
457
+ const tier = hasProfile ? (p.hasClaudeMax ? 'Max' : p.hasClaudePro ? 'Pro' : 'subscription') : null;
458
+ const status = hasProfile ? `Claude ${tier}` : `unknown (${p?.error || 'no token'})`;
419
459
  const src = a.source ? `, ${a.source}` : '';
420
460
  console.log(` [${i + 1}] ${a.name} (${status}${src})`);
421
- if (p?.email && p.email !== a.name) console.log(` Email: ${p.email}`);
422
- if (p?.orgName) console.log(` Org: ${p.orgName}`);
461
+ if (hasProfile && p.email && p.email !== a.name) console.log(` Email: ${p.email}`);
462
+ if (hasProfile && p.orgName) console.log(` Org: ${p.orgName}`);
423
463
  if (verbose && a.expiresAt) {
424
464
  const remaining = a.expiresAt - Date.now();
425
465
  if (remaining <= 0) {
@@ -550,7 +590,11 @@ Config: ${getConfigPath()}
550
590
  async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
551
591
  // Fetch profile to auto-name and deduplicate by account UUID
552
592
  const profile = await fetchProfile(creds.accessToken);
593
+ const profileOk = profile && !profile.error;
553
594
 
595
+ if (!profileOk) {
596
+ console.error(`Warning: could not fetch account profile — ${profile?.error || 'no token'}`);
597
+ }
554
598
  if (!name && profile?.email) {
555
599
  name = profile.email;
556
600
  const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
@@ -603,22 +647,68 @@ function findConfigAccount(diskConfig, account) {
603
647
  }
604
648
 
605
649
  /**
606
- * Detect accounts added to disk config by external processes and add them
607
- * to the running AccountManager + in-memory config.
650
+ * Sync accounts from disk config: add new accounts and refresh credentials
651
+ * for existing ones (handles re-imported OAuth tokens, rotated API keys, etc.).
652
+ * Returns the number of new accounts added.
608
653
  */
609
- function syncNewAccountsFromDisk(diskConfig, memConfig, accountManager) {
654
+ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
655
+ let added = 0;
610
656
  for (const diskAcct of diskConfig.accounts) {
611
- const knownByUuid = diskAcct.accountUuid &&
612
- memConfig.accounts.some(a => a.accountUuid === diskAcct.accountUuid);
613
- const knownByName = memConfig.accounts.some(a => a.name === diskAcct.name);
657
+ const matchByUuid = diskAcct.accountUuid &&
658
+ memConfig.accounts.findIndex(a => a.accountUuid === diskAcct.accountUuid);
659
+ const matchByName = memConfig.accounts.findIndex(a => a.name === diskAcct.name);
660
+ const memIdx = (matchByUuid >= 0 ? matchByUuid : null) ?? (matchByName >= 0 ? matchByName : -1);
614
661
 
615
- if (!knownByUuid && !knownByName) {
662
+ if (memIdx < 0) {
616
663
  // New account discovered on disk — add to running server
617
664
  memConfig.accounts.push(diskAcct);
618
665
  accountManager.addAccount(diskAcct);
666
+ added++;
619
667
  console.log(`[TeamClaude] Picked up new account "${diskAcct.name}" from config`);
668
+ continue;
669
+ }
670
+
671
+ // Existing account — resolve fresh credentials from disk
672
+ let freshCred = null;
673
+ if (diskAcct.type === 'oauth' && diskAcct.importFrom) {
674
+ try {
675
+ const creds = await importCredentials(diskAcct.importFrom);
676
+ freshCred = { accessToken: creds.accessToken, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt };
677
+ } catch (err) {
678
+ console.error(`[TeamClaude] Re-import failed for "${diskAcct.name}": ${err.message}`);
679
+ }
680
+ } else if (diskAcct.type === 'oauth' && diskAcct.accessToken) {
681
+ freshCred = { accessToken: diskAcct.accessToken, refreshToken: diskAcct.refreshToken, expiresAt: diskAcct.expiresAt };
682
+ } else if (diskAcct.type === 'apikey' && diskAcct.apiKey) {
683
+ freshCred = { apiKey: diskAcct.apiKey };
684
+ }
685
+
686
+ if (!freshCred) continue;
687
+
688
+ // Find the corresponding AccountManager entry and update credentials
689
+ const mgr = accountManager.accounts.find(a =>
690
+ (diskAcct.accountUuid && a.accountUuid === diskAcct.accountUuid) || a.name === diskAcct.name
691
+ );
692
+ if (!mgr) continue;
693
+
694
+ if (freshCred.accessToken) {
695
+ const changed = mgr.credential !== freshCred.accessToken ||
696
+ mgr.refreshToken !== freshCred.refreshToken;
697
+ // Don't overwrite in-memory credentials with staler ones from disk
698
+ // (e.g. after a TUI import updated the AM before saveConfig wrote to disk)
699
+ const diskIsStaler = freshCred.expiresAt && mgr.expiresAt &&
700
+ freshCred.expiresAt < mgr.expiresAt;
701
+ if (changed && !diskIsStaler) {
702
+ accountManager.updateAccountTokens(mgr.index, freshCred);
703
+ console.log(`[TeamClaude] Refreshed credentials for "${mgr.name}"`);
704
+ }
705
+ } else if (freshCred.apiKey && mgr.credential !== freshCred.apiKey) {
706
+ mgr.credential = freshCred.apiKey;
707
+ if (mgr.status === 'error') mgr.status = 'active';
708
+ console.log(`[TeamClaude] Updated API key for "${mgr.name}"`);
620
709
  }
621
710
  }
711
+ return added;
622
712
  }
623
713
 
624
714
  // ── helpers ─────────────────────────────────────────────────
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,24 +85,45 @@ 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,
@@ -112,8 +134,8 @@ export async function fetchProfile(accessToken) {
112
134
  hasClaudeMax: data.account?.has_claude_max,
113
135
  hasClaudePro: data.account?.has_claude_pro,
114
136
  };
115
- } catch {
116
- return null;
137
+ } catch (err) {
138
+ return { error: err.message || String(err) };
117
139
  }
118
140
  }
119
141
 
@@ -153,10 +175,10 @@ export async function loginOAuth() {
153
175
  console.log(`If it doesn't open, visit:\n ${authUrl.toString()}\n`);
154
176
  openBrowser(authUrl.toString());
155
177
 
156
- // Wait for the authorization code
178
+ // Wait for either the callback server or manual paste from stdin
157
179
  let code;
158
180
  try {
159
- code = await codePromise;
181
+ code = await raceWithStdinCode(codePromise, state);
160
182
  } finally {
161
183
  server.close();
162
184
  }
@@ -185,10 +207,58 @@ export async function loginOAuth() {
185
207
  return {
186
208
  accessToken: tokens.access_token,
187
209
  refreshToken: tokens.refresh_token,
188
- expiresAt: tokens.expires_at || (Date.now() + (tokens.expires_in || 3600) * 1000),
210
+ expiresAt: normalizeExpiresAt(tokens.expires_at) || (Date.now() + (tokens.expires_in || 3600) * 1000),
189
211
  };
190
212
  }
191
213
 
214
+ /**
215
+ * Race the callback server promise against manual code entry from stdin.
216
+ * The user can paste the full callback URL or just the authorization code.
217
+ */
218
+ function raceWithStdinCode(callbackPromise, expectedState) {
219
+ if (!process.stdin.isTTY) return callbackPromise;
220
+
221
+ return new Promise((resolve, reject) => {
222
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
223
+ let settled = false;
224
+
225
+ const settle = (fn, val) => {
226
+ if (settled) return;
227
+ settled = true;
228
+ rl.close();
229
+ fn(val);
230
+ };
231
+
232
+ rl.question('Paste authorization code here (or wait for browser callback): ', answer => {
233
+ const trimmed = answer.trim();
234
+ if (!trimmed) return; // empty input, keep waiting for callback
235
+
236
+ // Try to parse as a URL with ?code= parameter
237
+ try {
238
+ const url = new URL(trimmed);
239
+ const code = url.searchParams.get('code');
240
+ const state = url.searchParams.get('state');
241
+ if (code) {
242
+ if (expectedState && state && state !== expectedState) {
243
+ settle(reject, new Error('OAuth state mismatch'));
244
+ } else {
245
+ settle(resolve, code);
246
+ }
247
+ return;
248
+ }
249
+ } catch {}
250
+
251
+ // Treat raw input as the authorization code
252
+ settle(resolve, trimmed);
253
+ });
254
+
255
+ callbackPromise.then(
256
+ code => settle(resolve, code),
257
+ err => settle(reject, err),
258
+ );
259
+ });
260
+ }
261
+
192
262
  function startCallbackServer(expectedState) {
193
263
  return new Promise((resolve, reject) => {
194
264
  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,23 @@ 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
+ const retryAfter = parseInt(upstreamRes.headers.get('retry-after'), 10) || 60;
250
+ // Discard the 429 response body
251
+ await upstreamRes.body?.cancel();
252
+
253
+ if (logDir) {
254
+ logSections.push(`=== RESPONSE 429 — waiting ${retryAfter}s ===\n${formatHeaders(upstreamRes.headers)}`);
255
+ }
256
+ console.log(`[TeamClaude] 429 on "${account.name}" — waiting ${retryAfter}s before retry`);
257
+ await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
258
+ // Client may have disconnected during the wait
259
+ if (res.destroyed) return;
260
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount, hooks, reqId, ctx, logDir);
261
+ }
262
+
275
263
  // Log response headers
276
264
  if (logDir) {
277
265
  logSections.push(`=== RESPONSE ${upstreamRes.status} ===\n${formatHeaders(upstreamRes.headers)}`);
@@ -329,6 +317,17 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
329
317
  writeRequestLog(logDir, reqId, logSections);
330
318
  }
331
319
 
320
+ const isTransient = err instanceof Error &&
321
+ (err.message.includes('fetch failed') ||
322
+ err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
323
+ err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT');
324
+
325
+ // Transient network errors: just close the connection and let the client retry
326
+ if (isTransient) {
327
+ res.destroy();
328
+ return;
329
+ }
330
+
332
331
  if (retryCount < maxRetries && !res.headersSent) {
333
332
  account.status = 'error';
334
333
  return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
@@ -358,6 +357,9 @@ async function streamResponse(webStream, res, accountIndex, accountManager, stre
358
357
  const { done, value } = await reader.read();
359
358
  if (done) break;
360
359
 
360
+ // Client disconnected — stop reading from upstream
361
+ if (res.destroyed) break;
362
+
361
363
  // Forward chunk immediately
362
364
  const ok = res.write(value);
363
365
 
@@ -375,9 +377,14 @@ async function streamResponse(webStream, res, accountIndex, accountManager, stre
375
377
  parseSSEUsage(event, accountIndex, accountManager);
376
378
  }
377
379
 
378
- // Handle backpressure
380
+ // Handle backpressure — also bail out if client disconnects,
381
+ // because 'drain' will never fire on a destroyed socket
379
382
  if (!ok) {
380
- await new Promise(resolve => res.once('drain', resolve));
383
+ await new Promise(resolve => {
384
+ res.once('drain', resolve);
385
+ res.once('close', resolve);
386
+ });
387
+ if (res.destroyed) break;
381
388
  }
382
389
  }
383
390
 
@@ -386,7 +393,9 @@ async function streamResponse(webStream, res, accountIndex, accountManager, stre
386
393
  parseSSEUsage(sseBuffer, accountIndex, accountManager);
387
394
  }
388
395
  } finally {
389
- res.end();
396
+ // Cancel upstream reader to stop consuming data nobody needs
397
+ reader.cancel().catch(() => {});
398
+ if (!res.writableEnded) res.end();
390
399
  }
391
400
  }
392
401
 
package/src/tui.js CHANGED
@@ -1,4 +1,4 @@
1
- import { importCredentials } from './oauth.js';
1
+ import { importCredentials, fetchProfile } from './oauth.js';
2
2
 
3
3
  // ── ANSI helpers ─────────────────────────────────────────────
4
4
 
@@ -283,7 +283,7 @@ export class TUI {
283
283
  if (count > 0) {
284
284
  this._addLog(`Synced ${count} new account(s) from config`);
285
285
  } else {
286
- this._addLog('Config reloaded, no new accounts');
286
+ this._addLog('Config reloaded, credentials refreshed');
287
287
  }
288
288
  } catch (e) {
289
289
  this._addLog(`Sync failed: ${e.message}`);
@@ -292,19 +292,59 @@ export class TUI {
292
292
 
293
293
  async _doImport() {
294
294
  try {
295
+ this._addLog('Importing credentials...');
295
296
  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}`;
297
+ const profile = await fetchProfile(creds.accessToken);
298
+ const profileOk = profile && !profile.error;
299
+
300
+ if (!profileOk) {
301
+ this._addLog(`Warning: could not fetch profile — ${profile?.error || 'no token'}`);
302
+ }
303
+
304
+ let name;
305
+ if (profile?.email) {
306
+ name = profile.email;
307
+ const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
308
+ if (tier) this._addLog(`Detected Claude ${tier}: ${name}`);
309
+ } else {
310
+ const n = this.config.accounts.filter(a => a.name.startsWith('account-')).length + 1;
311
+ name = `account-${n}`;
312
+ }
313
+
298
314
  const entry = {
299
- name, type: 'oauth',
315
+ name, type: 'oauth', source: 'import',
316
+ accountUuid: profile?.accountUuid || null,
300
317
  accessToken: creds.accessToken,
301
318
  refreshToken: creds.refreshToken,
302
319
  expiresAt: creds.expiresAt,
303
320
  };
304
- this.config.accounts.push(entry);
305
- this.am.addAccount(entry);
321
+
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;
326
+ if (idx < 0) idx = this.config.accounts.findIndex(a => a.name === name);
327
+
328
+ if (idx >= 0) {
329
+ this.config.accounts[idx] = entry;
330
+ // Update the running account manager entry
331
+ const amAcct = this.am.accounts[idx];
332
+ if (amAcct) {
333
+ amAcct.credential = creds.accessToken;
334
+ amAcct.refreshToken = creds.refreshToken;
335
+ amAcct.expiresAt = creds.expiresAt;
336
+ amAcct.accountUuid = entry.accountUuid;
337
+ amAcct.name = name;
338
+ if (amAcct.status === 'error') amAcct.status = 'active';
339
+ }
340
+ this._addLog(`Updated account "${name}"`);
341
+ } else {
342
+ this.config.accounts.push(entry);
343
+ this.am.addAccount(entry);
344
+ this._addLog(`Imported account "${name}"`);
345
+ }
346
+
306
347
  await this.saveConfig(this.config);
307
- this._addLog(`Imported account "${name}"`);
308
348
  } catch (e) {
309
349
  this._addLog(`Import failed: ${e.message}`);
310
350
  }