teamclaude-cloud 1.5.8 → 1.7.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.8",
3
+ "version": "1.7.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",
@@ -61,6 +61,10 @@ export class AccountManager {
61
61
  // means "no preference" — selection then falls back to use-or-lose. So a
62
62
  // config with no priorities behaves exactly as before.
63
63
  priority: Number.isFinite(acct.priority) ? Math.floor(acct.priority) : null,
64
+ // Provenance: an account pulled from the cloud carries one or more managing
65
+ // group source tags (cloudSources, set by mergePulledAccounts). Surfaced so
66
+ // the dashboard/status can label it "cloud" vs a locally-added account.
67
+ fromCloud: Array.isArray(acct.cloudSources) && acct.cloudSources.length > 0,
64
68
  quota: emptyQuota(),
65
69
  usage: {
66
70
  totalInputTokens: 0,
@@ -1054,6 +1058,7 @@ export class AccountManager {
1054
1058
  status: 'active',
1055
1059
  enabled: acctData.enabled !== false,
1056
1060
  priority: Number.isFinite(acctData.priority) ? Math.floor(acctData.priority) : null,
1061
+ fromCloud: Array.isArray(acctData.cloudSources) && acctData.cloudSources.length > 0,
1057
1062
  quota: emptyQuota(),
1058
1063
  usage: { totalInputTokens: 0, totalOutputTokens: 0, totalRequests: 0, lastUsed: null },
1059
1064
  rateLimitedUntil: null,
@@ -1210,9 +1215,12 @@ export class AccountManager {
1210
1215
  importQuotaState(saved) {
1211
1216
  for (const s of Array.isArray(saved) ? saved : []) {
1212
1217
  if (!s || typeof s !== 'object') continue;
1218
+ // Identity match: UUID-bearing snapshot → UUID only; UUID-less → legacy-only
1219
+ // name (a UUID-less snapshot can't restore its quota onto a different
1220
+ // same-name account that owns a UUID).
1213
1221
  const a = s.accountUuid
1214
1222
  ? this.accounts.find(x => x.accountUuid === s.accountUuid)
1215
- : this.accounts.find(x => x.name === s.name);
1223
+ : this.accounts.find(x => !x.accountUuid && x.name === s.name);
1216
1224
  if (!a) continue;
1217
1225
  if (s.quota && typeof s.quota === 'object') {
1218
1226
  // Merge over emptyQuota so a cache written by an older version (missing
@@ -1251,6 +1259,7 @@ export class AccountManager {
1251
1259
  type: a.type,
1252
1260
  status: a.status,
1253
1261
  enabled: a.enabled !== false,
1262
+ fromCloud: a.fromCloud === true,
1254
1263
  priority: a.priority ?? null,
1255
1264
  // Deep-copy the nested modelWeekly map — the shallow quota spread would
1256
1265
  // otherwise hand callers a live reference into account state.
package/src/config.js CHANGED
@@ -63,6 +63,48 @@ export function writeQuotaCacheSync(data) {
63
63
  } catch { /* best-effort — a failed snapshot must never break the proxy */ }
64
64
  }
65
65
 
66
+ /**
67
+ * Find the index of a config account matching `account`. Returns -1 when none match.
68
+ *
69
+ * Identity rules (the sync/dedup key used across the proxy):
70
+ * - A record that HAS an accountUuid matches ONLY by that UUID. It does NOT fall
71
+ * back to a name match, because two genuinely distinct accounts can share a
72
+ * display name — a name fallback there would cross-apply one account's token /
73
+ * enabled / priority / cloudSources (an access-control tag) onto the other.
74
+ * If a UUID-bearing record's UUID isn't present, it's a new/different account.
75
+ * - Name is the fallback identity ONLY for legacy records that carry no UUID.
76
+ *
77
+ * Written as explicit numeric branches on purpose: an inline
78
+ * `account.accountUuid && findIndex(...)` yields `false` for a UUID-less account,
79
+ * and `false >= 0` is `true` in JS — which would misattribute the match to index 0.
80
+ */
81
+ export function findAccountIndex(accounts, account) {
82
+ if (account.accountUuid) {
83
+ const byUuid = accounts.findIndex(a => a.accountUuid === account.accountUuid);
84
+ if (byUuid >= 0) return byUuid;
85
+ }
86
+ // Legacy-only name fallback (both for a UUID-less incoming record and for a
87
+ // UUID-bearing one whose UUID isn't present yet). It matches ONLY a UUID-LESS
88
+ // same-name entry (`!a.accountUuid`):
89
+ // - UUID-bearing incoming → this is a legacy UPGRADE (the same account named "X"
90
+ // gaining a UUID via re-import); we adopt the existing legacy entry in place
91
+ // instead of appending a duplicate that shares one credential.
92
+ // - It never matches a DIFFERENT-UUID same-name entry (that entry owns a UUID),
93
+ // so two distinct accounts sharing a display name can't cross-apply
94
+ // token / enabled / priority / cloudSources.
95
+ return accounts.findIndex(a => !a.accountUuid && a.name === account.name);
96
+ }
97
+
98
+ export function findConfigAccount(config, account) {
99
+ return findAccountIndex(config.accounts, account);
100
+ }
101
+
102
+ // Object-returning variant for call sites that want the entry, not its index.
103
+ export function findAccount(accounts, account) {
104
+ const i = findAccountIndex(accounts, account);
105
+ return i >= 0 ? accounts[i] : null;
106
+ }
107
+
66
108
  export function createDefaultConfig() {
67
109
  return {
68
110
  proxy: {
package/src/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { unlinkSync, readFileSync, writeFileSync } from 'node:fs';
5
5
  import { createInterface } from 'node:readline';
6
- import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, getServerStatePath, writeServerState, readServerState, clearServerState, readQuotaCache, writeQuotaCacheSync } from './config.js';
6
+ import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, getServerStatePath, writeServerState, readServerState, clearServerState, readQuotaCache, writeQuotaCacheSync, findConfigAccount, findAccount, findAccountIndex } from './config.js';
7
7
  import { AccountManager } from './account-manager.js';
8
8
  import { createProxyServer } from './server.js';
9
9
  import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
@@ -183,6 +183,10 @@ async function serverCommand() {
183
183
  // initialized before this ever runs: the snapshot writers are registered
184
184
  // inside the listen callback.)
185
185
  probeTemplate: server.exportProbeTemplate?.() ?? null,
186
+ // The Fable-window (model-weekly) probe shape, persisted separately so R and
187
+ // the top-up can refresh the Fable limit right after a restart — even once a
188
+ // low-tier request has replaced the general template above.
189
+ modelWeeklyTemplate: server.exportModelWeeklyTemplate?.() ?? null,
186
190
  });
187
191
 
188
192
  // Persist refreshed tokens back to config (re-read from disk to avoid clobbering
@@ -268,19 +272,18 @@ async function serverCommand() {
268
272
  // Match the live account by IDENTITY — never by array index:
269
273
  // resolveAccounts() can skip a tokenless/bad config entry, so
270
274
  // config.accounts and accountManager.accounts are not index-aligned, and
271
- // an index map would overlay the wrong account's credentials. Two-phase
272
- // (UUID first, then name): a single `uuid===x || name===x` find could
273
- // return an earlier same-name account before reaching the real UUID match.
274
- const am = (a.accountUuid && accountManager.accounts.find(x => x.accountUuid === a.accountUuid))
275
- || accountManager.accounts.find(x => x.name === a.name);
275
+ // an index map would overlay the wrong account's credentials. Identity
276
+ // match: UUID-bearing UUID only; UUID-less legacy-only name (so a
277
+ // UUID-bearing record whose UUID isn't live can't name-hijack a different
278
+ // same-name account and cross-apply its credentials).
279
+ const am = findAccount(accountManager.accounts, a);
276
280
  const live = am ? {
277
281
  ...a,
278
282
  accessToken: am.credential,
279
283
  refreshToken: am.refreshToken,
280
284
  expiresAt: am.expiresAt,
281
285
  } : a;
282
- const diskAcct = (a.accountUuid && diskConfig.accounts.find(d => d.accountUuid === a.accountUuid))
283
- || diskConfig.accounts.find(d => d.name === a.name);
286
+ const diskAcct = findAccount(diskConfig.accounts, a);
284
287
  return diskAcct ? { ...diskAcct, ...live } : live;
285
288
  });
286
289
  // The TUI's in-memory config is authoritative for the account SET (an
@@ -325,8 +328,7 @@ async function serverCommand() {
325
328
  cloudPush: config.cloud ? (async () => {
326
329
  // Use the running server's LIVE tokens (freshest) for each account.
327
330
  const accounts = (config.accounts || []).filter(a => a.type !== 'apikey').map(a => {
328
- const am = (a.accountUuid && accountManager.accounts.find(x => x.accountUuid === a.accountUuid))
329
- || accountManager.accounts.find(x => x.name === a.name);
331
+ const am = findAccount(accountManager.accounts, a);
330
332
  return am ? { ...a, accessToken: am.credential, refreshToken: am.refreshToken, expiresAt: am.expiresAt } : a;
331
333
  });
332
334
  if (!accounts.length) throw new Error('No OAuth accounts to push');
@@ -358,6 +360,16 @@ async function serverCommand() {
358
360
  };
359
361
  }
360
362
 
363
+ // Live-reload hook (both TUI and headless): a local CLI edit POSTs /teamclaude/reload
364
+ // so its change (enable/disable/remove/priority) applies to THIS running server
365
+ // immediately, instead of waiting for the next cloud-sync tick. Re-reads disk and
366
+ // applies through the same path as TUI "R" / a sync tick.
367
+ // The /teamclaude/reload endpoint and the cloud-sync loop both reconcile disk→fleet;
368
+ // route BOTH through the one serialized applyDiskToFleet chain (below) so they can't
369
+ // interleave — an older apply must not reconcile a removal while a newer one re-adds
370
+ // from a stale snapshot (Codex HIGH). Each run re-reads disk fresh.
371
+ hooks.onReload = () => applyDiskToFleet(config, accountManager);
372
+
361
373
  // If a TeamClaude server is already running on this config's port, don't try to
362
374
  // bind on top of it — point the user at stop/restart instead of a raw EADDRINUSE.
363
375
  const existing = await findRunningServer(config);
@@ -377,6 +389,12 @@ async function serverCommand() {
377
389
  if (quotaCache?.probeTemplate && server.importProbeTemplate?.(quotaCache.probeTemplate)) {
378
390
  console.log('[TeamClaude] Restored warm-up probe template');
379
391
  }
392
+ // Restore the Fable-window probe shape too, so R / the top-up can refresh the
393
+ // Fable limit on a freshly restarted idle proxy without waiting for fresh
394
+ // Fable traffic to re-seed it.
395
+ if (quotaCache?.modelWeeklyTemplate && server.importModelWeeklyTemplate?.(quotaCache.modelWeeklyTemplate)) {
396
+ console.log('[TeamClaude] Restored Fable-window probe template');
397
+ }
380
398
  // Catch bind-time errors (e.g. EADDRINUSE) only. Once the socket is bound we
381
399
  // remove this handler so a later runtime 'error' isn't misreported as a
382
400
  // listen failure and exit the whole proxy.
@@ -874,7 +892,8 @@ async function statusCommand() {
874
892
  const current = acct.name === data.currentAccount ? ' *' : '';
875
893
 
876
894
  const disabledTag = acct.enabled === false ? ' [disabled]' : '';
877
- console.log(` ${maskIf(acct.name, config.maskMode)} (${acct.type})${current}${disabledTag}`);
895
+ const cloudTag = acct.fromCloud ? ' [cloud]' : '';
896
+ console.log(` ${maskIf(acct.name, config.maskMode)} (${acct.type})${current}${cloudTag}${disabledTag}`);
878
897
  console.log(` Status: ${acct.status}${acct.enabled === false ? ' (disabled — out of rotation)' : ''}`);
879
898
  if (acct.priority != null) console.log(` Priority: ${acct.priority} (lower = preferred)`);
880
899
  if (acct.maxConcurrent != null) {
@@ -1067,7 +1086,6 @@ async function apiCommand() {
1067
1086
  // ── remove ──────────────────────────────────────────────────
1068
1087
 
1069
1088
  async function removeCommand() {
1070
- const config = await loadOrCreateConfig();
1071
1089
  const name = args[1];
1072
1090
 
1073
1091
  if (!name) {
@@ -1075,27 +1093,42 @@ async function removeCommand() {
1075
1093
  process.exit(1);
1076
1094
  }
1077
1095
 
1078
- const idx = config.accounts.findIndex(a => a.name === name);
1079
- if (idx < 0) {
1096
+ // atomicConfigUpdate re-reads disk before writing, so a concurrent token-refresh
1097
+ // write (from a running server) can't resurrect the just-removed account.
1098
+ let found = false;
1099
+ const config = await atomicConfigUpdate((c) => {
1100
+ const idx = (c.accounts || []).findIndex(a => a.name === name);
1101
+ if (idx < 0) return; // not present on the fresh disk copy — no write
1102
+ c.accounts.splice(idx, 1);
1103
+ found = true;
1104
+ });
1105
+ if (!found) {
1080
1106
  console.error(`Account "${name}" not found`);
1081
1107
  process.exit(1);
1082
1108
  }
1083
-
1084
- config.accounts.splice(idx, 1);
1085
- await saveConfig(config);
1086
1109
  console.log(`Removed account "${name}"`);
1110
+ await noteRunningServerReload(config); // drop it from the running server now (not next sync)
1087
1111
  }
1088
1112
 
1089
1113
  // ── enable / disable / priority ─────────────────────────────
1090
1114
 
1091
1115
  /** 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).');
1116
+ async function noteRunningServerReload(config) {
1117
+ const running = await findRunningServer(config).catch(() => null);
1118
+ if (!running) return;
1119
+ // Tell the running server to apply the just-written config change RIGHT NOW
1120
+ // (localhost-only endpoint), instead of waiting for its next cloud-sync tick.
1121
+ try {
1122
+ const res = await fetch(`http://127.0.0.1:${running.port}/teamclaude/reload`, {
1123
+ method: 'POST', headers: { 'x-api-key': config.proxy.apiKey },
1124
+ });
1125
+ if (res.ok) {
1126
+ await res.json().catch(() => ({}));
1127
+ console.log(' Applied to the running server immediately.');
1128
+ return;
1097
1129
  }
1098
- }).catch(() => {});
1130
+ } catch { /* server unreachable mid-shutdown — fall back to the manual hint */ }
1131
+ console.log(' A server is running — apply with: teamclaude restart (or "R" in the TUI).');
1099
1132
  }
1100
1133
 
1101
1134
  async function setEnabledCommand(enabled) {
@@ -1248,11 +1281,10 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
1248
1281
  expiresAt: creds.expiresAt,
1249
1282
  };
1250
1283
 
1251
- // Deduplicate: match by UUID first, then by name
1252
- let idx = profile?.accountUuid
1253
- ? config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
1254
- : -1;
1255
- if (idx < 0) idx = config.accounts.findIndex(a => a.name === name);
1284
+ // Deduplicate by identity: UUID-bearing UUID only; UUID-less legacy-only name
1285
+ // (a UUID-bearing profile whose UUID isn't present is a NEW account, not a
1286
+ // same-name hijack). Same contract as the TUI import path.
1287
+ const idx = findAccountIndex(config.accounts, { accountUuid: profile?.accountUuid, name });
1256
1288
 
1257
1289
  if (idx >= 0) {
1258
1290
  // Re-credentialing an existing account must not wipe its manual routing
@@ -1272,17 +1304,7 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
1272
1304
  }
1273
1305
 
1274
1306
  // ── config sync helpers ─────────────────────────────────────
1275
-
1276
- /**
1277
- * Find a config account entry matching an in-memory account (by UUID, then name).
1278
- */
1279
- function findConfigAccount(diskConfig, account) {
1280
- if (account.accountUuid) {
1281
- const idx = diskConfig.accounts.findIndex(a => a.accountUuid === account.accountUuid);
1282
- if (idx >= 0) return idx;
1283
- }
1284
- return diskConfig.accounts.findIndex(a => a.name === account.name);
1285
- }
1307
+ // findConfigAccount lives in config.js (pure + unit-tested for the UUID-less case).
1286
1308
 
1287
1309
  /**
1288
1310
  * Sync accounts from disk config: add new accounts and refresh credentials
@@ -1292,10 +1314,11 @@ function findConfigAccount(diskConfig, account) {
1292
1314
  async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
1293
1315
  let added = 0;
1294
1316
  for (const diskAcct of diskConfig.accounts) {
1295
- const matchByUuid = diskAcct.accountUuid &&
1296
- memConfig.accounts.findIndex(a => a.accountUuid === diskAcct.accountUuid);
1297
- const matchByName = memConfig.accounts.findIndex(a => a.name === diskAcct.name);
1298
- const memIdx = (matchByUuid >= 0 ? matchByUuid : null) ?? (matchByName >= 0 ? matchByName : -1);
1317
+ // Match the disk entry to an in-memory account (UUID-bearing → UUID only;
1318
+ // UUID-less → name). The previous inline form let `false >= 0` misattribute a
1319
+ // UUID-less disk entry to in-memory index 0 — corrupting enabled/priority/
1320
+ // cloudSources on the wrong account.
1321
+ const memIdx = findConfigAccount(memConfig, diskAcct);
1299
1322
 
1300
1323
  if (memIdx < 0) {
1301
1324
  // New account discovered on disk — add to running server
@@ -1306,11 +1329,14 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
1306
1329
  continue;
1307
1330
  }
1308
1331
 
1309
- // Find the corresponding AccountManager entry UUID first, then name, so a
1310
- // disk entry whose UUID and name resolve to *different* live accounts can't
1311
- // misattribute the update to the name-match when a UUID-match exists.
1312
- const mgr = (diskAcct.accountUuid && accountManager.accounts.find(a => a.accountUuid === diskAcct.accountUuid))
1313
- || accountManager.accounts.find(a => a.name === diskAcct.name);
1332
+ // Resolve the live AccountManager entry with the SAME identity contract as
1333
+ // findConfigAccount above (UUID-bearing UUID only; UUID-less name). Using
1334
+ // one helper for both keeps mgr and memIdx from diverging onto *different*
1335
+ // accounts otherwise a disk entry {uuid:B,name:X} whose UUID isn't live yet
1336
+ // would let memIdx pick config-B while mgr name-matched live-A, cross-applying
1337
+ // B's token / enabled / priority / cloudSources (an access-control tag) onto A.
1338
+ const mgrIdx = findConfigAccount({ accounts: accountManager.accounts }, diskAcct);
1339
+ const mgr = mgrIdx >= 0 ? accountManager.accounts[mgrIdx] : undefined;
1314
1340
 
1315
1341
  // Apply enable/disable + priority from disk FIRST — independent of credential
1316
1342
  // re-resolution below. A failed re-import (freshCred null) must NOT strand a
@@ -1321,6 +1347,9 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
1321
1347
  if (mgr.enabled !== wantEnabled) accountManager.setEnabled(mgr, wantEnabled);
1322
1348
  const diskPriority = Number.isFinite(diskAcct.priority) ? Math.floor(diskAcct.priority) : null;
1323
1349
  if (mgr.priority !== diskPriority) accountManager.setPriority(mgr, diskPriority);
1350
+ // Provenance can change while running (a cloud pull tags/untags an account),
1351
+ // so refresh the cloud flag on reload too.
1352
+ mgr.fromCloud = Array.isArray(diskAcct.cloudSources) && diskAcct.cloudSources.length > 0;
1324
1353
  // Mirror the applied state into the in-memory config copy too. Otherwise a
1325
1354
  // later TUI saveConfig (for any unrelated op) would spread the pre-sync
1326
1355
  // enabled/priority over the disk value and silently revert a CLI change.
@@ -1328,6 +1357,15 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
1328
1357
  if (memAcct) {
1329
1358
  if (wantEnabled) delete memAcct.enabled; else memAcct.enabled = false;
1330
1359
  if (diskPriority === null) delete memAcct.priority; else memAcct.priority = diskPriority;
1360
+ // cloudSources is authoritative on disk (a cloud pull tags/untags it) and
1361
+ // is an access-control signal. Mirror it too, or a later TUI saveConfig
1362
+ // would overlay the stale in-memory array onto disk and resurrect a
1363
+ // source tag a pull just removed — silently defeating a cloud revocation.
1364
+ if (Array.isArray(diskAcct.cloudSources) && diskAcct.cloudSources.length > 0) {
1365
+ memAcct.cloudSources = diskAcct.cloudSources;
1366
+ } else {
1367
+ delete memAcct.cloudSources;
1368
+ }
1331
1369
  }
1332
1370
  }
1333
1371
 
@@ -1346,7 +1384,29 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
1346
1384
  freshCred = { apiKey: diskAcct.apiKey };
1347
1385
  }
1348
1386
 
1349
- if (!freshCred || !mgr) continue;
1387
+ if (!freshCred) continue;
1388
+
1389
+ // In config (memIdx >= 0) but not live: it was skipped at startup as tokenless
1390
+ // and now has a credential on disk. Add it to rotation without waiting for a
1391
+ // restart, merging the freshly-resolved credential (covers the importFrom path
1392
+ // where diskAcct carries no inline token). Mirrors the new-account branch.
1393
+ if (!mgr) {
1394
+ // `mgr` was resolved before the importCredentials() await above, which yields
1395
+ // the event loop — a concurrent reload (TUI R / /teamclaude/reload / cloud
1396
+ // sync) may have already added this account. Re-resolve against the live set
1397
+ // immediately before the SYNCHRONOUS addAccount: the re-check and push run
1398
+ // with no await between them, so at most one sync adds the UUID (no duplicate
1399
+ // that would double effective concurrency/quota for one credential).
1400
+ if (findConfigAccount({ accounts: accountManager.accounts }, diskAcct) < 0) {
1401
+ const creds = freshCred.apiKey
1402
+ ? { apiKey: freshCred.apiKey }
1403
+ : { accessToken: freshCred.accessToken, refreshToken: freshCred.refreshToken, expiresAt: freshCred.expiresAt };
1404
+ accountManager.addAccount({ ...diskAcct, ...creds });
1405
+ added++;
1406
+ console.log(`[TeamClaude] Added now-credentialed account "${diskAcct.name}" to rotation`);
1407
+ }
1408
+ continue;
1409
+ }
1350
1410
 
1351
1411
  if (freshCred.accessToken) {
1352
1412
  const changed = mgr.credential !== freshCred.accessToken ||
@@ -1368,6 +1428,24 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
1368
1428
  return added;
1369
1429
  }
1370
1430
 
1431
+ // Serialized disk→fleet reconcile. The /teamclaude/reload endpoint (onReload) AND the
1432
+ // cloud-sync loop both apply disk to the live fleet; routing both through this one chain
1433
+ // stops them interleaving — an older apply reconciling a removal while a newer one re-adds
1434
+ // from a stale snapshot (Codex HIGH). Each run re-reads disk fresh, so the last-executed
1435
+ // run reflects the freshest config. Failure of one run doesn't stall the chain.
1436
+ let _fleetApplyChain = Promise.resolve();
1437
+ function applyDiskToFleet(config, accountManager) {
1438
+ const run = _fleetApplyChain.then(async () => {
1439
+ const disk = await loadConfig();
1440
+ if (!disk) return { reloaded: 0, removed: 0 };
1441
+ const reloaded = await syncAccountsFromDisk(disk, config, accountManager); // add / token / enable / priority
1442
+ const removed = reconcileRemovedAccounts(disk, config, accountManager); // removal (remove / group shrink)
1443
+ return { reloaded, removed };
1444
+ }, () => ({ reloaded: 0, removed: 0 }));
1445
+ _fleetApplyChain = run.then(() => {}, () => {}); // keep the chain non-rejecting
1446
+ return run;
1447
+ }
1448
+
1371
1449
  // ── live cloud sync (running server ⇄ cloud) ────────────────
1372
1450
 
1373
1451
  // One cloud-sync pass: re-pull with the stored key and reconcile the live fleet.
@@ -1385,8 +1463,7 @@ async function syncFromCloudOnce(config, accountManager, cloud, key) {
1385
1463
  // empty allow-set drops this key's source and removes now-unmanaged accounts).
1386
1464
  const src = keySource(key);
1387
1465
  await atomicConfigUpdate(c => { mergePulledAccounts(c, [], src, []); });
1388
- const disk = await loadConfig();
1389
- const removed = disk ? reconcileRemovedAccounts(disk, config, accountManager) : 0;
1466
+ const { removed } = await applyDiskToFleet(config, accountManager); // via the shared chain
1390
1467
  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
1468
  return { stop: true };
1392
1469
  }
@@ -1394,11 +1471,8 @@ async function syncFromCloudOnce(config, accountManager, cloud, key) {
1394
1471
  return { stop: false };
1395
1472
  }
1396
1473
  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
- }
1474
+ // Reconcile through the shared serialized chain so a concurrent /reload can't interleave.
1475
+ await applyDiskToFleet(config, accountManager);
1402
1476
  return { stop: false };
1403
1477
  }
1404
1478
 
package/src/server.js CHANGED
@@ -58,6 +58,12 @@ export function createProxyServer(accountManager, config, hooks = {}) {
58
58
  : 5 * 60 * 1000;
59
59
  const WARMUP_PROBE_TIMEOUT_MS = 15_000;
60
60
  let probeTemplate = null; // committed { model, version, beta, system } — only after a 2xx
61
+ let mwTemplate = null; // last shape known to elicit the model-weekly (Fable 7d_oi) window,
62
+ // kept in a SEPARATE slot so low-tier traffic (which legitimately
63
+ // replaces probeTemplate) can't erase the fleet's ability to probe
64
+ // the Fable limit. Without this, the first low-tier request after a
65
+ // restart downgrades the restored template and R/top-up can no longer
66
+ // refresh Fable until fresh Fable traffic happens to flow through.
61
67
  let warmupInFlight = false; // guard against overlapping fan-outs
62
68
  let warmupClosed = false; // set on server close: stop scheduling, abort in-flight probes
63
69
  const warmupAbort = new AbortController();
@@ -78,7 +84,11 @@ export function createProxyServer(accountManager, config, hooks = {}) {
78
84
  model: json.model,
79
85
  version: req.headers['anthropic-version'] || '2023-06-01',
80
86
  beta: req.headers['anthropic-beta'] || null,
81
- system: json.system ?? null,
87
+ // Sanitize at the single capture point so BOTH the general and the Fable
88
+ // template store only the canonical OAuth identifier — never the client's
89
+ // secret-bearing project/CLAUDE.md context. Every probe (warm-up, R,
90
+ // top-up) then replays a secret-free system to every account.
91
+ system: sanitizeProbeSystem(json.system ?? null),
82
92
  };
83
93
  }
84
94
 
@@ -94,6 +104,19 @@ export function createProxyServer(accountManager, config, hooks = {}) {
94
104
  function commitProbeTemplate(candidate, status, elicitsModelWeekly = false) {
95
105
  if (!activeWarmup || warmupClosed) return;
96
106
  if (!(status >= 200 && status < 300)) return; // only trust an accepted shape
107
+ // A shape whose own response carried a model-scoped weekly window is the only
108
+ // thing that can probe the Fable (7d_oi) limit. Keep it in its own slot,
109
+ // refreshed from fresh evidence, so the general probeTemplate below can follow
110
+ // the newest tier (and be replaced by low-tier traffic) WITHOUT losing the
111
+ // ability to refresh Fable. A restored mwTemplate is simply overwritten by the
112
+ // next fresh Fable commit; if its model was retired it 4xx's and the per-account
113
+ // _mwProbes cap retires it (needsModelWeekly), then real Fable traffic re-seeds.
114
+ // candidate.system is already sanitized (stageProbeTemplate) to the canonical
115
+ // identifier block only — no client project/CLAUDE.md secret is stored here or
116
+ // persisted to the snapshot.
117
+ if (elicitsModelWeekly) {
118
+ mwTemplate = { model: candidate.model, version: candidate.version, beta: candidate.beta, system: candidate.system };
119
+ }
97
120
  // A template RESTORED from the last run's snapshot is provisional: it let
98
121
  // probes work before any traffic, but upstream accepted it in a previous
99
122
  // process — the model may have been retired since. The first freshly
@@ -116,6 +139,43 @@ export function createProxyServer(accountManager, config, hooks = {}) {
116
139
  return JSON.stringify(b);
117
140
  }
118
141
 
142
+ // Build the probe's system WITHOUT persisting or replaying ANY client text.
143
+ // A captured system may carry tenant secrets — not only in later blocks, but
144
+ // appended after a valid identifier prefix ("You are Claude Code\nSECRET=…") or
145
+ // as the whole string. A prefix/first-block match is therefore only a SIGNAL
146
+ // that this was a Claude-Code OAuth request; when it matches we emit our OWN
147
+ // fixed canonical identifier constant (proxy-controlled, zero client
148
+ // characters), else null. This makes leakage impossible by construction: no
149
+ // client-provided byte is ever stored in the snapshot or sent to another
150
+ // account. A null (systemless) probe may 4xx → the _mwProbes/_partialProbes
151
+ // caps retire it; that only means "no probe-measured quota until real traffic",
152
+ // never a leak. If the canonical constant isn't accepted upstream, warm-up
153
+ // degrades gracefully to real-traffic measurement (a quality tradeoff, not a
154
+ // security one — chosen deliberately: no secret may ever cross accounts).
155
+ const CLAUDE_CODE_SYSTEM_PREFIX = 'You are Claude Code';
156
+ const CLAUDE_CODE_SYSTEM = "You are Claude Code, Anthropic's official CLI for Claude.";
157
+ function sanitizeProbeSystem(system) {
158
+ const looksLikeId = (v) => typeof v === 'string' && v.startsWith(CLAUDE_CODE_SYSTEM_PREFIX);
159
+ let firstText = null;
160
+ if (typeof system === 'string') firstText = system;
161
+ else if (Array.isArray(system) && system.length) {
162
+ const b0 = system[0];
163
+ firstText = typeof b0 === 'string' ? b0 : (b0 && typeof b0 === 'object' ? b0.text : null);
164
+ }
165
+ return looksLikeId(firstText) ? [{ type: 'text', text: CLAUDE_CODE_SYSTEM }] : null;
166
+ }
167
+
168
+ // The shape to replay when probing the model-weekly (Fable) window: prefer the
169
+ // live general template if it already elicits the window, else the dedicated
170
+ // mwTemplate (which survives low-tier template churn and restarts). null when
171
+ // no Fable-eliciting shape has ever been observed — nothing can probe Fable then.
172
+ // mwTemplate carries its OWN sanitized system (the canonical identifier block
173
+ // only — see commitProbeTemplate), so it needs no borrowing: it's both
174
+ // OAuth-acceptable and secret-free.
175
+ function modelWeeklyTemplate() {
176
+ return probeTemplate && probeTemplate._elicitsModelWeekly ? probeTemplate : mwTemplate;
177
+ }
178
+
119
179
  // A probe fetch is bounded by BOTH a timeout and server-close, so a scheduled or
120
180
  // in-flight probe can't keep sending a credentialed request after teardown.
121
181
  // Returns { signal, cleanup }: the caller MUST call cleanup() when the probe
@@ -159,8 +219,12 @@ export function createProxyServer(accountManager, config, hooks = {}) {
159
219
  // capacity 429 could not.
160
220
  // - Learns ONLY from a response upstream accepted (2xx) or an account-level
161
221
  // quota 429 ('rejected') — a 4xx / non-exhaustion 429 / 5xx never mutates state.
162
- async function warmupAccount(account, { force = false } = {}) {
163
- if (!probeTemplate || warmupClosed || account._warming) return;
222
+ async function warmupAccount(account, { force = false, template = null } = {}) {
223
+ // `template` overrides the shape to replay (used by the model-weekly top-up /
224
+ // R Fable pass, which must probe with a Fable-eliciting shape even when the
225
+ // live general template is a lower tier). Defaults to the general template.
226
+ const tmpl = template || probeTemplate;
227
+ if (!tmpl || warmupClosed || account._warming) return;
164
228
  // Don't refresh from a background probe; skip an OAuth account that needs one.
165
229
  if (account.type === 'oauth' && isTokenExpiringSoon(account.expiresAt)) return;
166
230
  // Re-confirm it's still an available, unmeasured, idle candidate — unless
@@ -172,13 +236,13 @@ export function createProxyServer(accountManager, config, hooks = {}) {
172
236
  account._warming = true;
173
237
  const probe = probeSignal();
174
238
  try {
175
- const headers = { 'content-type': 'application/json', 'anthropic-version': probeTemplate.version };
176
- if (probeTemplate.beta) headers['anthropic-beta'] = probeTemplate.beta;
239
+ const headers = { 'content-type': 'application/json', 'anthropic-version': tmpl.version };
240
+ if (tmpl.beta) headers['anthropic-beta'] = tmpl.beta;
177
241
  if (account.type === 'oauth') headers['authorization'] = `Bearer ${account.credential}`;
178
242
  else headers['x-api-key'] = account.credential;
179
243
 
180
244
  const res = await fetch(`${upstream}/v1/messages`, {
181
- method: 'POST', headers, body: buildProbeBody(probeTemplate), signal: probe.signal,
245
+ method: 'POST', headers, body: buildProbeBody(tmpl), signal: probe.signal,
182
246
  });
183
247
  const rl = {};
184
248
  for (const [k, v] of res.headers.entries()) {
@@ -227,6 +291,13 @@ export function createProxyServer(accountManager, config, hooks = {}) {
227
291
  // permanently even after upstream recovers.
228
292
  account._partialProbes = (account._partialProbes || 0) + 1;
229
293
  account._lastFruitlessProbeAt = Date.now(); // paces the slow retry backstop
294
+ // Also cap the model-weekly budget: a Fable-shape probe that 4xx's (e.g. a
295
+ // restored template whose model was retired → 404) never reaches the
296
+ // success branch above, so without this `_mwProbes` would never increment
297
+ // and needsModelWeekly() would re-probe the retired model every interval
298
+ // forever. A deterministic-fruitless outcome retires the Fable probe too;
299
+ // a later success (window populated / window swept) resets it.
300
+ account._mwProbes = (account._mwProbes || 0) + 1;
230
301
  }
231
302
  } catch (err) {
232
303
  // Best-effort: leave the account unmeasured (exactly as before warm-up).
@@ -270,9 +341,22 @@ export function createProxyServer(accountManager, config, hooks = {}) {
270
341
  // Renew both probe budgets — R is an explicit "measure everything now".
271
342
  for (const a of alive) { a._partialProbes = 0; a._mwProbes = 0; }
272
343
  const outcomes = await Promise.all(alive.map(a => warmupAccount(a, { force: true })));
344
+ // Fable pass: if the live general template can't elicit the model-weekly
345
+ // window but a shape that can survives (mwTemplate — possibly from before a
346
+ // restart + low-tier churn), probe the accounts still missing the window with
347
+ // it. This closes the "R refreshes everything except the Fable limit" gap.
348
+ // When the general template already elicits Fable, the pass above did it, so
349
+ // modelWeeklyTemplate() returns probeTemplate and this branch is skipped.
350
+ const mw = modelWeeklyTemplate();
351
+ if (mw && !(probeTemplate && probeTemplate._elicitsModelWeekly)) {
352
+ const mwTargets = alive.filter(a =>
353
+ a.type === 'oauth' && !a._warming && Object.keys(a.quota.modelWeekly).length === 0);
354
+ await Promise.all(mwTargets.map(a => warmupAccount(a, { force: true, template: mw })));
355
+ }
273
356
  // Honest accounting: `targets` is what the user asked to refresh, `measured`
274
357
  // is what actually got fresh data — the TUI reports M/N, never a blanket
275
- // "refreshed N" while probes silently skipped or failed.
358
+ // "refreshed N" while probes silently skipped or failed. (The supplemental
359
+ // Fable pass only fills a secondary window; it doesn't change these counts.)
276
360
  return { targets: targets.length, measured: outcomes.filter(Boolean).length };
277
361
  }
278
362
 
@@ -285,12 +369,17 @@ export function createProxyServer(accountManager, config, hooks = {}) {
285
369
  // that exact account is idle. Force-probes so the fully-measured guard doesn't
286
370
  // exclude them; still skips in-flight/disabled/error accounts.
287
371
  async function topUpModelWeekly() {
288
- if (!activeWarmup || warmupClosed || !probeTemplate || !probeTemplate._elicitsModelWeekly) return;
372
+ if (!activeWarmup || warmupClosed) return;
373
+ // Probe with a Fable-eliciting shape: the general template if it is one, else
374
+ // the dedicated mwTemplate — so the top-up keeps working even after low-tier
375
+ // traffic has replaced the general template (the common post-restart case).
376
+ const mw = modelWeeklyTemplate();
377
+ if (!mw) return;
289
378
  const targets = accountManager.accounts.filter(a =>
290
379
  a.enabled !== false && a.status !== 'error' && a.inflight === 0 && !a._warming
291
380
  && accountManager.needsModelWeekly(a));
292
381
  if (!targets.length) return;
293
- await Promise.all(targets.map(a => warmupAccount(a, { force: true })));
382
+ await Promise.all(targets.map(a => warmupAccount(a, { force: true, template: mw })));
294
383
  }
295
384
 
296
385
  // Probe every currently-unmeasured idle account in parallel. Guarded so two
@@ -345,6 +434,22 @@ export function createProxyServer(accountManager, config, hooks = {}) {
345
434
  return;
346
435
  }
347
436
 
437
+ // Reload endpoint — a local CLI edit (enable/disable/remove/priority) POSTs here
438
+ // so the change applies to the running server IMMEDIATELY instead of waiting for
439
+ // the next cloud-sync tick. Localhost-only (it mutates live rotation state).
440
+ if (req.method === 'POST' && req.url === '/teamclaude/reload') {
441
+ if (!isLocal) { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end('{"error":"localhost only"}'); return; }
442
+ try {
443
+ const r = (await hooks.onReload?.()) || { reloaded: 0 };
444
+ res.writeHead(200, { 'Content-Type': 'application/json' });
445
+ res.end(JSON.stringify({ ok: true, ...r }));
446
+ } catch (e) {
447
+ res.writeHead(500, { 'Content-Type': 'application/json' });
448
+ res.end(JSON.stringify({ error: String(e?.message || e) }));
449
+ }
450
+ return;
451
+ }
452
+
348
453
  // Everything below buffers a request body (the OAuth relay AND the proxied
349
454
  // path) → global admission control to bound proxy memory: inFlightProxied
350
455
  // may not exceed the fleet's useful capacity (sum of per-account caps +
@@ -495,13 +600,35 @@ export function createProxyServer(accountManager, config, hooks = {}) {
495
600
  model: t.model,
496
601
  version: typeof t.version === 'string' && t.version ? t.version : '2023-06-01',
497
602
  beta: typeof t.beta === 'string' && t.beta ? t.beta : null,
498
- system: t.system ?? null,
603
+ // Re-sanitize on restore (defence in depth: a snapshot from an older build
604
+ // could hold a full, secret-bearing system).
605
+ system: sanitizeProbeSystem(t.system),
499
606
  _elicitsModelWeekly: t._elicitsModelWeekly === true,
500
607
  _restored: true,
501
608
  };
502
609
  return true;
503
610
  };
504
611
 
612
+ // The Fable-window template is persisted separately so that after a restart the
613
+ // fleet can refresh Fable via R / top-up even before fresh Fable traffic flows
614
+ // (and even once a low-tier request replaces the general probeTemplate). It has
615
+ // no _restored/_elicits flags: it is always a Fable-eliciting shape, and the
616
+ // next fresh Fable commit overwrites it.
617
+ server.exportModelWeeklyTemplate = () => (mwTemplate ? { ...mwTemplate } : null);
618
+ server.importModelWeeklyTemplate = (t) => {
619
+ if (!activeWarmup || warmupClosed || mwTemplate) return false;
620
+ if (!t || typeof t !== 'object' || typeof t.model !== 'string' || !t.model) return false;
621
+ mwTemplate = {
622
+ model: t.model,
623
+ version: typeof t.version === 'string' && t.version ? t.version : '2023-06-01',
624
+ beta: typeof t.beta === 'string' && t.beta ? t.beta : null,
625
+ // Re-sanitize on restore (defence in depth: a snapshot from an older build
626
+ // could hold a full system). Only the canonical identifier block survives.
627
+ system: sanitizeProbeSystem(t.system),
628
+ };
629
+ return true;
630
+ };
631
+
505
632
  return server;
506
633
  }
507
634
 
@@ -1240,7 +1367,7 @@ function computeRetryAfter(accounts, threshold = 0.98) {
1240
1367
  if (acct.enabled === false || acct.status === 'error') continue;
1241
1368
  // freeAt = max(throttle, every over-threshold quota reset). The account is
1242
1369
  // blocked until the LAST of these clears; taking the min across accounts
1243
- // then gives the soonest the fleet has anything to serve.
1370
+ // then gives the soonest the fleet will have capacity to serve.
1244
1371
  let freeAt = 0;
1245
1372
  if (acct.rateLimitedUntil) freeAt = Math.max(freeAt, new Date(acct.rateLimitedUntil).getTime());
1246
1373
  const q = acct.quota || {};
package/src/tui.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { importCredentials, fetchProfile } from './oauth.js';
2
2
  import { maskIf, maskEmailsIn } from './mask.js';
3
+ import { findAccount, findAccountIndex } from './config.js';
3
4
 
4
5
  // ── ANSI helpers ─────────────────────────────────────────────
5
6
 
@@ -463,11 +464,10 @@ export class TUI {
463
464
  expiresAt: creds.expiresAt,
464
465
  };
465
466
 
466
- // Deduplicate: match by UUID first, then by name
467
- let idx = profile?.accountUuid
468
- ? this.config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
469
- : -1;
470
- if (idx < 0) idx = this.config.accounts.findIndex(a => a.name === name);
467
+ // Deduplicate by identity: UUID-bearing UUID only; UUID-less legacy-only
468
+ // name (a UUID-bearing profile whose UUID isn't present is a NEW account, not
469
+ // a same-name hijack).
470
+ const idx = findAccountIndex(this.config.accounts, { accountUuid: profile?.accountUuid, name });
471
471
 
472
472
  if (idx >= 0) {
473
473
  // Preserve manual routing settings across a re-import (the new entry
@@ -482,8 +482,7 @@ export class TUI {
482
482
  // which can point at a different live account when a tokenless config entry
483
483
  // was skipped at load (config.accounts is not index-aligned with
484
484
  // accountManager.accounts).
485
- const amAcct = (prev.accountUuid && this.am.accounts.find(a => a.accountUuid === prev.accountUuid))
486
- || this.am.accounts.find(a => a.name === prev.name);
485
+ const amAcct = findAccount(this.am.accounts, prev);
487
486
  if (amAcct) {
488
487
  amAcct.credential = creds.accessToken;
489
488
  amAcct.refreshToken = creds.refreshToken;
@@ -531,10 +530,9 @@ export class TUI {
531
530
  // Splice the config entry by IDENTITY, not by the AccountManager index —
532
531
  // config.accounts can hold more entries than AccountManager (tokenless accounts
533
532
  // are skipped at load), so the AM index may delete a different config entry.
534
- // Two-phase (UUID first, then name): a single `uuid===c || name===c` predicate
535
- // could match an earlier same-name entry before the real UUID match.
536
- let cfgIdx = uuid ? this.config.accounts.findIndex(c => c.accountUuid === uuid) : -1;
537
- if (cfgIdx < 0) cfgIdx = this.config.accounts.findIndex(c => c.name === name);
533
+ // Identity match (UUID-bearing UUID only; UUID-less legacy-only name) so a
534
+ // remove can't splice out a different same-name account.
535
+ const cfgIdx = findAccountIndex(this.config.accounts, { accountUuid: uuid, name });
538
536
  if (cfgIdx >= 0) this.config.accounts.splice(cfgIdx, 1);
539
537
  // Drop a cursor anchor that pointed at the removed account; _selected()
540
538
  // falls back to the remembered position on the next keypress/render.
@@ -555,10 +553,9 @@ export class TUI {
555
553
  // 1:1 onto config.accounts. Matching by UUID/name avoids corrupting a
556
554
  // different config entry.
557
555
  this.am.setEnabled(amAcct, newEnabled);
558
- // Match the config entry UUID-first, then name an OR match could persist the
559
- // flag onto the wrong entry when a UUID and a name resolve to different accounts.
560
- const cfg = (amAcct.accountUuid && this.config.accounts.find(a => a.accountUuid === amAcct.accountUuid))
561
- || this.config.accounts.find(a => a.name === amAcct.name);
556
+ // Match the config entry by identity (UUID-bearing UUID only; UUID-less
557
+ // legacy-only name) so the flag can't persist onto a different same-name entry.
558
+ const cfg = findAccount(this.config.accounts, amAcct);
562
559
  if (cfg) cfg.enabled = newEnabled;
563
560
  await this.saveConfig(this.config);
564
561
  // Maintain the offline local-disable overlay (cloud.localDisabled, by uuid) at the
@@ -658,8 +655,7 @@ export class TUI {
658
655
  // display index may not map 1:1 onto config.accounts. Write `null` (not a
659
656
  // deleted key) to clear, so the saveConfig `{...diskAcct, ...live}` merge
660
657
  // can't let a stale disk priority survive.
661
- const cfg = (a.accountUuid && this.config.accounts.find(c => c.accountUuid === a.accountUuid))
662
- || this.config.accounts.find(c => c.name === a.name);
658
+ const cfg = findAccount(this.config.accounts, a);
663
659
  if (cfg) cfg.priority = want;
664
660
  }
665
661
  // Persist via the coalescing saver: rapid ↑/↓ presses mutate synchronously but
@@ -865,6 +861,10 @@ export class TUI {
865
861
  // badges stay column-aligned across mixed account types.
866
862
  line += l3 ? ` ${l3} ${bar(r3, bw, t3)}` : ' '.repeat(6 + bw);
867
863
  }
864
+ // Provenance badge: an account pulled from the cloud is tagged "cloud" so it
865
+ // is distinguishable from one added locally on this machine. Appended (like
866
+ // the rank badge) so it never disrupts the fixed-width columns / quota bars.
867
+ if (a.fromCloud) line += ` ${cyan('cloud')}`;
868
868
  // Order badge: ranked accounts show their 1-based position (#1 = most
869
869
  // preferred). While ordering, unranked accounts are labelled "auto" so the
870
870
  // two groups (pinned order vs use-or-lose) are visible. Appended last so it