teamclaude-cloud 1.6.0 → 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.6.0",
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');
@@ -387,6 +389,12 @@ async function serverCommand() {
387
389
  if (quotaCache?.probeTemplate && server.importProbeTemplate?.(quotaCache.probeTemplate)) {
388
390
  console.log('[TeamClaude] Restored warm-up probe template');
389
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
+ }
390
398
  // Catch bind-time errors (e.g. EADDRINUSE) only. Once the socket is bound we
391
399
  // remove this handler so a later runtime 'error' isn't misreported as a
392
400
  // listen failure and exit the whole proxy.
@@ -884,7 +892,8 @@ async function statusCommand() {
884
892
  const current = acct.name === data.currentAccount ? ' *' : '';
885
893
 
886
894
  const disabledTag = acct.enabled === false ? ' [disabled]' : '';
887
- 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}`);
888
897
  console.log(` Status: ${acct.status}${acct.enabled === false ? ' (disabled — out of rotation)' : ''}`);
889
898
  if (acct.priority != null) console.log(` Priority: ${acct.priority} (lower = preferred)`);
890
899
  if (acct.maxConcurrent != null) {
@@ -1272,11 +1281,10 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
1272
1281
  expiresAt: creds.expiresAt,
1273
1282
  };
1274
1283
 
1275
- // Deduplicate: match by UUID first, then by name
1276
- let idx = profile?.accountUuid
1277
- ? config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
1278
- : -1;
1279
- 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 });
1280
1288
 
1281
1289
  if (idx >= 0) {
1282
1290
  // Re-credentialing an existing account must not wipe its manual routing
@@ -1296,17 +1304,7 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
1296
1304
  }
1297
1305
 
1298
1306
  // ── config sync helpers ─────────────────────────────────────
1299
-
1300
- /**
1301
- * Find a config account entry matching an in-memory account (by UUID, then name).
1302
- */
1303
- function findConfigAccount(diskConfig, account) {
1304
- if (account.accountUuid) {
1305
- const idx = diskConfig.accounts.findIndex(a => a.accountUuid === account.accountUuid);
1306
- if (idx >= 0) return idx;
1307
- }
1308
- return diskConfig.accounts.findIndex(a => a.name === account.name);
1309
- }
1307
+ // findConfigAccount lives in config.js (pure + unit-tested for the UUID-less case).
1310
1308
 
1311
1309
  /**
1312
1310
  * Sync accounts from disk config: add new accounts and refresh credentials
@@ -1316,10 +1314,11 @@ function findConfigAccount(diskConfig, account) {
1316
1314
  async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
1317
1315
  let added = 0;
1318
1316
  for (const diskAcct of diskConfig.accounts) {
1319
- const matchByUuid = diskAcct.accountUuid &&
1320
- memConfig.accounts.findIndex(a => a.accountUuid === diskAcct.accountUuid);
1321
- const matchByName = memConfig.accounts.findIndex(a => a.name === diskAcct.name);
1322
- 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);
1323
1322
 
1324
1323
  if (memIdx < 0) {
1325
1324
  // New account discovered on disk — add to running server
@@ -1330,11 +1329,14 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
1330
1329
  continue;
1331
1330
  }
1332
1331
 
1333
- // Find the corresponding AccountManager entry UUID first, then name, so a
1334
- // disk entry whose UUID and name resolve to *different* live accounts can't
1335
- // misattribute the update to the name-match when a UUID-match exists.
1336
- const mgr = (diskAcct.accountUuid && accountManager.accounts.find(a => a.accountUuid === diskAcct.accountUuid))
1337
- || 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;
1338
1340
 
1339
1341
  // Apply enable/disable + priority from disk FIRST — independent of credential
1340
1342
  // re-resolution below. A failed re-import (freshCred null) must NOT strand a
@@ -1345,6 +1347,9 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
1345
1347
  if (mgr.enabled !== wantEnabled) accountManager.setEnabled(mgr, wantEnabled);
1346
1348
  const diskPriority = Number.isFinite(diskAcct.priority) ? Math.floor(diskAcct.priority) : null;
1347
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;
1348
1353
  // Mirror the applied state into the in-memory config copy too. Otherwise a
1349
1354
  // later TUI saveConfig (for any unrelated op) would spread the pre-sync
1350
1355
  // enabled/priority over the disk value and silently revert a CLI change.
@@ -1352,6 +1357,15 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
1352
1357
  if (memAcct) {
1353
1358
  if (wantEnabled) delete memAcct.enabled; else memAcct.enabled = false;
1354
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
+ }
1355
1369
  }
1356
1370
  }
1357
1371
 
@@ -1370,7 +1384,29 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
1370
1384
  freshCred = { apiKey: diskAcct.apiKey };
1371
1385
  }
1372
1386
 
1373
- 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
+ }
1374
1410
 
1375
1411
  if (freshCred.accessToken) {
1376
1412
  const changed = mgr.credential !== freshCred.accessToken ||
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
@@ -511,13 +600,35 @@ export function createProxyServer(accountManager, config, hooks = {}) {
511
600
  model: t.model,
512
601
  version: typeof t.version === 'string' && t.version ? t.version : '2023-06-01',
513
602
  beta: typeof t.beta === 'string' && t.beta ? t.beta : null,
514
- 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),
515
606
  _elicitsModelWeekly: t._elicitsModelWeekly === true,
516
607
  _restored: true,
517
608
  };
518
609
  return true;
519
610
  };
520
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
+
521
632
  return server;
522
633
  }
523
634
 
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