teamclaude-cloud 1.5.7 → 1.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamclaude-cloud",
3
- "version": "1.5.7",
3
+ "version": "1.6.0",
4
4
  "description": "Multi-account Claude proxy with quota-based rotation + cloud token sync and auth-key control",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/config.js CHANGED
@@ -160,3 +160,4 @@ export function atomicConfigUpdate(updater) {
160
160
  _configWriteChain = result.then(() => {}, () => {});
161
161
  return result;
162
162
  }
163
+
package/src/index.js CHANGED
@@ -358,6 +358,16 @@ async function serverCommand() {
358
358
  };
359
359
  }
360
360
 
361
+ // Live-reload hook (both TUI and headless): a local CLI edit POSTs /teamclaude/reload
362
+ // so its change (enable/disable/remove/priority) applies to THIS running server
363
+ // immediately, instead of waiting for the next cloud-sync tick. Re-reads disk and
364
+ // applies through the same path as TUI "R" / a sync tick.
365
+ // The /teamclaude/reload endpoint and the cloud-sync loop both reconcile disk→fleet;
366
+ // route BOTH through the one serialized applyDiskToFleet chain (below) so they can't
367
+ // interleave — an older apply must not reconcile a removal while a newer one re-adds
368
+ // from a stale snapshot (Codex HIGH). Each run re-reads disk fresh.
369
+ hooks.onReload = () => applyDiskToFleet(config, accountManager);
370
+
361
371
  // If a TeamClaude server is already running on this config's port, don't try to
362
372
  // bind on top of it — point the user at stop/restart instead of a raw EADDRINUSE.
363
373
  const existing = await findRunningServer(config);
@@ -1067,7 +1077,6 @@ async function apiCommand() {
1067
1077
  // ── remove ──────────────────────────────────────────────────
1068
1078
 
1069
1079
  async function removeCommand() {
1070
- const config = await loadOrCreateConfig();
1071
1080
  const name = args[1];
1072
1081
 
1073
1082
  if (!name) {
@@ -1075,27 +1084,42 @@ async function removeCommand() {
1075
1084
  process.exit(1);
1076
1085
  }
1077
1086
 
1078
- const idx = config.accounts.findIndex(a => a.name === name);
1079
- if (idx < 0) {
1087
+ // atomicConfigUpdate re-reads disk before writing, so a concurrent token-refresh
1088
+ // write (from a running server) can't resurrect the just-removed account.
1089
+ let found = false;
1090
+ const config = await atomicConfigUpdate((c) => {
1091
+ const idx = (c.accounts || []).findIndex(a => a.name === name);
1092
+ if (idx < 0) return; // not present on the fresh disk copy — no write
1093
+ c.accounts.splice(idx, 1);
1094
+ found = true;
1095
+ });
1096
+ if (!found) {
1080
1097
  console.error(`Account "${name}" not found`);
1081
1098
  process.exit(1);
1082
1099
  }
1083
-
1084
- config.accounts.splice(idx, 1);
1085
- await saveConfig(config);
1086
1100
  console.log(`Removed account "${name}"`);
1101
+ await noteRunningServerReload(config); // drop it from the running server now (not next sync)
1087
1102
  }
1088
1103
 
1089
1104
  // ── enable / disable / priority ─────────────────────────────
1090
1105
 
1091
1106
  /** Note that changes apply to a running server only after a reload/restart. */
1092
- function noteRunningServerReload(config) {
1093
- return findRunningServer(config).then(running => {
1094
- if (running) {
1095
- console.log('A server is running apply now with: teamclaude restart');
1096
- console.log(' (or press "R" in the TUI to reload from config).');
1107
+ async function noteRunningServerReload(config) {
1108
+ const running = await findRunningServer(config).catch(() => null);
1109
+ if (!running) return;
1110
+ // Tell the running server to apply the just-written config change RIGHT NOW
1111
+ // (localhost-only endpoint), instead of waiting for its next cloud-sync tick.
1112
+ try {
1113
+ const res = await fetch(`http://127.0.0.1:${running.port}/teamclaude/reload`, {
1114
+ method: 'POST', headers: { 'x-api-key': config.proxy.apiKey },
1115
+ });
1116
+ if (res.ok) {
1117
+ await res.json().catch(() => ({}));
1118
+ console.log(' Applied to the running server immediately.');
1119
+ return;
1097
1120
  }
1098
- }).catch(() => {});
1121
+ } catch { /* server unreachable mid-shutdown — fall back to the manual hint */ }
1122
+ console.log(' A server is running — apply with: teamclaude restart (or "R" in the TUI).');
1099
1123
  }
1100
1124
 
1101
1125
  async function setEnabledCommand(enabled) {
@@ -1368,6 +1392,24 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
1368
1392
  return added;
1369
1393
  }
1370
1394
 
1395
+ // Serialized disk→fleet reconcile. The /teamclaude/reload endpoint (onReload) AND the
1396
+ // cloud-sync loop both apply disk to the live fleet; routing both through this one chain
1397
+ // stops them interleaving — an older apply reconciling a removal while a newer one re-adds
1398
+ // from a stale snapshot (Codex HIGH). Each run re-reads disk fresh, so the last-executed
1399
+ // run reflects the freshest config. Failure of one run doesn't stall the chain.
1400
+ let _fleetApplyChain = Promise.resolve();
1401
+ function applyDiskToFleet(config, accountManager) {
1402
+ const run = _fleetApplyChain.then(async () => {
1403
+ const disk = await loadConfig();
1404
+ if (!disk) return { reloaded: 0, removed: 0 };
1405
+ const reloaded = await syncAccountsFromDisk(disk, config, accountManager); // add / token / enable / priority
1406
+ const removed = reconcileRemovedAccounts(disk, config, accountManager); // removal (remove / group shrink)
1407
+ return { reloaded, removed };
1408
+ }, () => ({ reloaded: 0, removed: 0 }));
1409
+ _fleetApplyChain = run.then(() => {}, () => {}); // keep the chain non-rejecting
1410
+ return run;
1411
+ }
1412
+
1371
1413
  // ── live cloud sync (running server ⇄ cloud) ────────────────
1372
1414
 
1373
1415
  // One cloud-sync pass: re-pull with the stored key and reconcile the live fleet.
@@ -1385,8 +1427,7 @@ async function syncFromCloudOnce(config, accountManager, cloud, key) {
1385
1427
  // empty allow-set drops this key's source and removes now-unmanaged accounts).
1386
1428
  const src = keySource(key);
1387
1429
  await atomicConfigUpdate(c => { mergePulledAccounts(c, [], src, []); });
1388
- const disk = await loadConfig();
1389
- const removed = disk ? reconcileRemovedAccounts(disk, config, accountManager) : 0;
1430
+ const { removed } = await applyDiskToFleet(config, accountManager); // via the shared chain
1390
1431
  console.error(`[TeamClaude] Cloud auth key revoked — removed ${removed} account(s). The proxy will refuse new requests until you re-pull with a valid key.`);
1391
1432
  return { stop: true };
1392
1433
  }
@@ -1394,11 +1435,8 @@ async function syncFromCloudOnce(config, accountManager, cloud, key) {
1394
1435
  return { stop: false };
1395
1436
  }
1396
1437
  await atomicConfigUpdate(c => { mergePulledAccounts(c, pulled, source, allowed); });
1397
- const disk = await loadConfig();
1398
- if (disk) {
1399
- await syncAccountsFromDisk(disk, config, accountManager); // add / token-refresh / enable / priority
1400
- reconcileRemovedAccounts(disk, config, accountManager); // removal (group shrink)
1401
- }
1438
+ // Reconcile through the shared serialized chain so a concurrent /reload can't interleave.
1439
+ await applyDiskToFleet(config, accountManager);
1402
1440
  return { stop: false };
1403
1441
  }
1404
1442
 
@@ -1603,22 +1641,49 @@ async function ensureCloudSession(config) {
1603
1641
  // detection and loses its push (Codex HIGH). Serialize: all concurrent callers
1604
1642
  // share the SAME in-flight refresh instead of each rotating independently.
1605
1643
  if (!_cloudSessionRefresh) {
1606
- const rt = cloud.session.refresh_token;
1607
1644
  _cloudSessionRefresh = (async () => {
1608
1645
  try {
1609
- const s = await cloudRefreshSession(cloud, rt);
1646
+ // #24: the owner session has ONE single-use rotating Supabase refresh token.
1647
+ // A running server + a concurrent CLI can both try to rotate it — one wins,
1648
+ // the other trips reuse detection. Rather than a cross-process FILE LOCK
1649
+ // (whose steal/release edge-cases proved riskier than the rare race — Codex
1650
+ // re-confirmed), RECOVER via freshest-wins: only when THE REFRESH ITSELF
1651
+ // fails (a concurrent process may have already rotated), re-read disk and, if
1652
+ // it holds a fresh DIFFERENT session for the SAME cloud, adopt it. Net: one
1653
+ // rotation lands, the loser adopts it — no cascade, no lock.
1654
+ let s;
1655
+ try {
1656
+ s = await cloudRefreshSession(cloud, cloud.session.refresh_token);
1657
+ } catch (err) {
1658
+ // The refresh failed. Did a concurrent process already rotate + persist a
1659
+ // fresh session? (Scope the recovery to the REFRESH failure only — a
1660
+ // successful refresh whose disk-write later fails must NOT fall in here and
1661
+ // re-adopt the now-consumed old session.)
1662
+ const disk = await loadConfig();
1663
+ const ds = disk?.cloud?.session;
1664
+ const dsExp = (Number(ds?.expires_at) || 0) * 1000;
1665
+ if (disk?.cloud?.url === cloud.url && ds?.access_token
1666
+ && ds.refresh_token && ds.refresh_token !== cloud.session.refresh_token
1667
+ && dsExp && Date.now() + 30_000 < dsExp) {
1668
+ cloud.session = ds; // a concurrent process already refreshed — adopt theirs
1669
+ return ds;
1670
+ }
1671
+ throw err; // genuinely couldn't refresh (and nobody else did)
1672
+ }
1673
+ // Refresh succeeded → the new session is authoritative. Persisting it is
1674
+ // best-effort: a disk-write failure must not discard the valid in-memory
1675
+ // session (returning it keeps this process working; it re-persists next time).
1610
1676
  cloud.session = s;
1611
- await atomicConfigUpdate(c => { c.cloud = { ...(c.cloud || {}), session: s }; });
1677
+ await atomicConfigUpdate(c => { c.cloud = { ...(c.cloud || {}), session: s }; })
1678
+ .catch((e) => console.error(`[TeamClaude] Failed to persist refreshed cloud session: ${e.message}`));
1612
1679
  return s;
1613
1680
  } finally {
1614
1681
  _cloudSessionRefresh = null;
1615
1682
  }
1616
1683
  })();
1617
1684
  }
1618
- // NOTE: concurrent refreshes WITHIN this process share the single in-flight
1619
- // promise above (one rotation). Cross-PROCESS coordination is intentionally not
1620
- // done here — the codebase assumes a single proxy per config (see
1621
- // atomicConfigUpdate's documented cross-process limitation).
1685
+ // Concurrent refreshes WITHIN this process share the single in-flight promise;
1686
+ // the freshest-wins re-read above absorbs a cross-PROCESS rotation race.
1622
1687
  await _cloudSessionRefresh;
1623
1688
  }
1624
1689
  // If the session is (still) expired and cannot be refreshed — a Google/SSO token
package/src/server.js CHANGED
@@ -345,6 +345,22 @@ export function createProxyServer(accountManager, config, hooks = {}) {
345
345
  return;
346
346
  }
347
347
 
348
+ // Reload endpoint — a local CLI edit (enable/disable/remove/priority) POSTs here
349
+ // so the change applies to the running server IMMEDIATELY instead of waiting for
350
+ // the next cloud-sync tick. Localhost-only (it mutates live rotation state).
351
+ if (req.method === 'POST' && req.url === '/teamclaude/reload') {
352
+ if (!isLocal) { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end('{"error":"localhost only"}'); return; }
353
+ try {
354
+ const r = (await hooks.onReload?.()) || { reloaded: 0 };
355
+ res.writeHead(200, { 'Content-Type': 'application/json' });
356
+ res.end(JSON.stringify({ ok: true, ...r }));
357
+ } catch (e) {
358
+ res.writeHead(500, { 'Content-Type': 'application/json' });
359
+ res.end(JSON.stringify({ error: String(e?.message || e) }));
360
+ }
361
+ return;
362
+ }
363
+
348
364
  // Everything below buffers a request body (the OAuth relay AND the proxied
349
365
  // path) → global admission control to bound proxy memory: inFlightProxied
350
366
  // may not exceed the fleet's useful capacity (sum of per-account caps +
@@ -1240,7 +1256,7 @@ function computeRetryAfter(accounts, threshold = 0.98) {
1240
1256
  if (acct.enabled === false || acct.status === 'error') continue;
1241
1257
  // freeAt = max(throttle, every over-threshold quota reset). The account is
1242
1258
  // blocked until the LAST of these clears; taking the min across accounts
1243
- // then gives the soonest the fleet has anything to serve.
1259
+ // then gives the soonest the fleet will have capacity to serve.
1244
1260
  let freeAt = 0;
1245
1261
  if (acct.rateLimitedUntil) freeAt = Math.max(freeAt, new Date(acct.rateLimitedUntil).getTime());
1246
1262
  const q = acct.quota || {};