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 +1 -1
- package/src/account-manager.js +10 -1
- package/src/config.js +42 -0
- package/src/index.js +130 -56
- package/src/server.js +138 -11
- package/src/tui.js +17 -17
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -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.
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
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 = (
|
|
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 = (
|
|
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
|
-
|
|
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
|
-
|
|
1079
|
-
|
|
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
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
-
}
|
|
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:
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
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
|
-
//
|
|
1310
|
-
//
|
|
1311
|
-
//
|
|
1312
|
-
|
|
1313
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1398
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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':
|
|
176
|
-
if (
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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 = (
|
|
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
|
-
//
|
|
535
|
-
//
|
|
536
|
-
|
|
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-
|
|
559
|
-
//
|
|
560
|
-
const cfg = (
|
|
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 = (
|
|
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
|