@rikcodes/teamclaude 1.1.14-rik.2 → 1.1.15-rik.1

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": "@rikcodes/teamclaude",
3
- "version": "1.1.14-rik.2",
3
+ "version": "1.1.15-rik.1",
4
4
  "description": "Multi-account Claude proxy with automatic quota-based rotation",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -1,10 +1,11 @@
1
1
  import { refreshAccessToken, isTokenExpiringSoon, isTokenExpired, formatMoney } from './oauth.js';
2
- import { providerOf, DEFAULT_PROVIDER } from './provider.js';
2
+ import { providerOf, DEFAULT_PROVIDER, isSubscriptionAccount } from './provider.js';
3
3
  import { refreshCodexToken } from './codex-auth.js';
4
4
  import { parseCodexQuota, parseCodexPlanType } from './codex-quota.js';
5
5
  import { sameIdentity } from './identity.js';
6
6
  import { weeklyBucketForModel, modelGlobMatches, modelFamily, gatingUtilization, resolveMaxUsage } from './model.js';
7
7
  import { SessionTracker } from './session-tracker.js';
8
+ import { buildQuotaSummary } from './quota-summary.js';
8
9
  import { QuotaProjection, PROJECTED_BUCKETS } from './quota-projection.js';
9
10
 
10
11
  // Re-exported for callers that import these model helpers from here.
@@ -129,6 +130,11 @@ function makeAccount(acct, index) {
129
130
  accountUuid: acct.accountUuid || null,
130
131
  orgUuid: acct.orgUuid || null,
131
132
  orgName: acct.orgName || null,
133
+ organizationType: acct.organizationType || null,
134
+ rateLimitTier: acct.rateLimitTier || null,
135
+ seatTier: acct.seatTier || null,
136
+ hasClaudeMax: acct.hasClaudeMax ?? null,
137
+ hasClaudePro: acct.hasClaudePro ?? null,
132
138
  priority: acct.priority || 0,
133
139
  disabled: acct.disabled || false,
134
140
  maxUsage: acct.maxUsage ?? null,
@@ -237,6 +243,15 @@ export class AccountManager {
237
243
  // it. Each such switch arms the ramp below, so steady interleaved traffic
238
244
  // holds both accounts at the ramp floor while nothing has failed over.
239
245
  this.routeCursors = new Map();
246
+ // One cursor per provider. `currentIndex` is a single slot, and a request
247
+ // only another provider can serve would otherwise drag it across: a Codex
248
+ // request moved it onto a Codex account and the next Anthropic request moved
249
+ // it straight back, flapping the TUI's current-account marker and re-arming
250
+ // storm control on every alternation. A provider partition is the purest
251
+ // form of "barred for THIS request only" — an Anthropic account serves every
252
+ // Anthropic request perfectly well — so it must not move the fleet, exactly
253
+ // as a spent family bucket does not (#276).
254
+ this.providerCursors = new Map();
240
255
  this.switchThreshold = switchThreshold;
241
256
  this.setRoutes(routes);
242
257
  this.setSoonestWeekly(soonestWeekly);
@@ -459,6 +474,14 @@ export class AccountManager {
459
474
  * window (storm control) so they trickle out rather than flood. Extends an
460
475
  * existing pause rather than shortening it.
461
476
  */
477
+ /** True while an account is inside a rate-limit pause (see pauseAccount). The
478
+ * pause deliberately does NOT mark the account throttled, so selection still
479
+ * offers it — a caller choosing a failover target has to ask. */
480
+ isPaused(index, now = Date.now()) {
481
+ const account = this.accounts[index];
482
+ return !!(account?.pausedUntil && now < account.pausedUntil);
483
+ }
484
+
462
485
  pauseAccount(index, seconds) {
463
486
  const account = this.accounts[index];
464
487
  if (!account) return;
@@ -511,12 +534,43 @@ export class AccountManager {
511
534
  * request keeps flowing (upstream then fails just the advisor call).
512
535
  */
513
536
  getActiveAccount(exclude = null, model = null, advisorModel = null, sessionId = null, provider = DEFAULT_PROVIDER) {
514
- const account = this._pickActiveAccount(this._excludeOtherProviders(exclude, provider), model, advisorModel, sessionId);
537
+ // Selection reads this.currentIndex as "where the fleet is". With more than
538
+ // one provider that is a single slot for several fleets, so a request whose
539
+ // provider does not own it borrows the slot for the walk and hands it back.
540
+ //
541
+ // With one provider — every config that predates #246 — `borrowed` is always
542
+ // false and this is the same code it was.
543
+ const owner = providerOf(this.accounts[this.currentIndex]);
544
+ const borrowed = !!this.accounts.length && owner !== provider;
545
+ const saved = this.currentIndex;
546
+ if (borrowed) {
547
+ const own = this.providerCursors.get(provider);
548
+ if (own != null && this.accounts[own] && providerOf(this.accounts[own]) === provider) this.currentIndex = own;
549
+ }
550
+
551
+ let account;
552
+ // Scoped rather than threaded through _select/_selectNext/_divertedFor: the
553
+ // whole walk is synchronous, so nothing can interleave and observe it, and
554
+ // the alternative is a provider argument on six private methods that exist
555
+ // to answer a different question.
556
+ this._selectingProvider = provider;
557
+ try {
558
+ account = this._pickActiveAccount(this._excludeOtherProviders(exclude, provider), model, advisorModel, sessionId);
559
+ } finally {
560
+ this._selectingProvider = null;
561
+ // Hand the slot back before anything can observe it moved. Only the
562
+ // provider that owns currentIndex gets to change it.
563
+ if (borrowed) this.currentIndex = saved;
564
+ }
565
+
515
566
  // Record where this route now sits, whatever path chose it — the steady-state
516
567
  // path returns the account the cursor already names and never reaches the
517
568
  // rotation code, so recording there alone would leave the cursor unset and
518
569
  // the next real failover unpaced.
519
- if (account) this.routeCursors.set(this._cursorKey(model), account.index);
570
+ if (account) {
571
+ this.routeCursors.set(this._cursorKey(model, advisorModel, provider), account.index);
572
+ this.providerCursors.set(providerOf(account), account.index);
573
+ }
520
574
  return account;
521
575
  }
522
576
 
@@ -535,7 +589,13 @@ export class AccountManager {
535
589
  * the common single-provider case allocates nothing.
536
590
  */
537
591
  _excludeOtherProviders(exclude, provider) {
538
- const foreign = this.accounts.filter(a => providerOf(a) !== provider);
592
+ // Only SUBSCRIPTION accounts are partitioned. A Claude Max token is issued
593
+ // to Claude and a ChatGPT token to Codex; neither plan can be spent by the
594
+ // other's client, so crossing them is never right. An API key carries no
595
+ // such tie — it is metered capacity, not a seat — so it stays eligible for
596
+ // whichever app is asking, which is what keeps a third-party backend usable
597
+ // from both.
598
+ const foreign = this.accounts.filter(a => providerOf(a) !== provider && isSubscriptionAccount(a));
539
599
  if (foreign.length === 0) return exclude;
540
600
  const combined = new Set(exclude || []);
541
601
  for (const account of foreign) combined.add(account.index);
@@ -607,6 +667,11 @@ export class AccountManager {
607
667
  const betterExists = this._preemptedBy(current, model, advisorModel, exclude);
608
668
  return betterExists ? this._selectNext(exclude, model, advisorModel) : current;
609
669
  }
670
+ // Barred for this request only: keep the family where it was diverted.
671
+ // Barred outright (disabled, throttled, spent): rotate, as before.
672
+ const diverted = this._currentBarredOnlyFor(model, advisorModel, exclude)
673
+ ? this._divertedFor(model, advisorModel, exclude) : null;
674
+ if (diverted) return diverted;
610
675
  const next = this._selectNext(exclude, model, advisorModel);
611
676
  if (next) return next;
612
677
  // No account is under the switch threshold. Before refusing locally, allow a
@@ -776,6 +841,13 @@ export class AccountManager {
776
841
  this._isAvailable(a, model) && (a.priority || 0) < (current.priority || 0));
777
842
  if (!better) return current.index;
778
843
  }
844
+ // Mirror _select's diversion cursor, so the preview names the account a
845
+ // diverted family will actually land on rather than the one a fresh walk
846
+ // would pick.
847
+ if (this._currentBarredOnlyFor(model)) {
848
+ const diverted = this._divertedFor(model);
849
+ if (diverted) return diverted.index;
850
+ }
779
851
  const best = this._pickBestAvailable(null, model);
780
852
  return best ? best.index : null;
781
853
  }
@@ -978,7 +1050,13 @@ export class AccountManager {
978
1050
 
979
1051
  // A structured organization-policy 403 means this account cannot serve OAuth
980
1052
  // requests right now. Skip it across requests until the short cooldown ends.
981
- if (this._entitlementDenied(account)) return false;
1053
+ //
1054
+ // A reason string, not `false`: this function's contract is "a reason, or
1055
+ // null when it can serve", and getStatus surfaces the value verbatim. `false`
1056
+ // read as available against that contract and, being falsy, made
1057
+ // unavailableLine drop the row — so the one state added to make a refusal
1058
+ // explainable was the only one that printed no explanation (#258).
1059
+ if (this._entitlementDenied(account)) return 'entitlement';
982
1060
 
983
1061
  // Check rate limit expiry
984
1062
  if (account.status === 'throttled' && account.rateLimitedUntil) {
@@ -1257,9 +1335,59 @@ export class AccountManager {
1257
1335
  this.routeCursors?.clear();
1258
1336
  }
1259
1337
 
1260
- /** Cursor key for a model: its route's name, or '' when no route matches. */
1261
- _cursorKey(model) {
1262
- return this._routeForModel(model)?.name || '';
1338
+ /**
1339
+ * Cursor key for a request: its route's name, or — with no route configured —
1340
+ * the weekly bucket the model spends, so Fable and Opus keep separate cursors
1341
+ * in the default config too (one key for everything let a Fable diversion be
1342
+ * read back as the Opus cursor, #276). An advisor request is keyed by both
1343
+ * families it needs: it is diverted for the advisor's sake as often as the
1344
+ * executor's, and sharing a cursor with plain requests for the executor's
1345
+ * model would have each overwrite the other's and re-log the diversion.
1346
+ */
1347
+ _cursorKey(model, advisorModel = null, provider = this._selectingProvider || DEFAULT_PROVIDER) {
1348
+ const own = this._routeForModel(model)?.name || (model ? this._weeklyBucketFor(model) : '');
1349
+ const base = (() => {
1350
+ if (!advisorModel) return own;
1351
+ const adv = this._routeForModel(advisorModel)?.name || this._weeklyBucketFor(advisorModel);
1352
+ return adv === own ? own : `${own}+${adv}`;
1353
+ })();
1354
+ // Namespaced by provider so a diversion recorded for one provider is never
1355
+ // read back as another's. Model names do not have to differ between
1356
+ // providers, and a bucket key certainly does not — an unprefixed key would
1357
+ // hand a Codex request the account an Anthropic request was diverted to.
1358
+ // The default provider keeps the bare key, so existing cursors and every
1359
+ // single-provider config are unchanged.
1360
+ return provider === DEFAULT_PROVIDER ? base : `${provider}\u0000${base}`;
1361
+ }
1362
+
1363
+ /**
1364
+ * True when the current account can serve in general but not THIS request:
1365
+ * its family bucket or cap is spent, a route excludes it, or the advisor
1366
+ * model's family is spent. That is a per-request diversion, not a rotation,
1367
+ * and the fleet stays where it is. False for disabled / error / throttled /
1368
+ * 5h or shared weekly over threshold, which bar every request and must
1369
+ * rotate the fleet as before.
1370
+ */
1371
+ _currentBarredOnlyFor(model, advisorModel = null, exclude = null) {
1372
+ const current = this.accounts[this.currentIndex];
1373
+ return !!model && !!current && !exclude?.has(current.index)
1374
+ && this._isAvailable(current, null) && !this._isAvailable(current, model, advisorModel);
1375
+ }
1376
+
1377
+ /**
1378
+ * Where this request's family was last diverted to, if that account can still
1379
+ * serve it. Consulted only once the current account has been found barred
1380
+ * for this request alone, so a family returns to the current account the
1381
+ * moment it can serve it again rather than staying diverted out of habit.
1382
+ * Yields to a higher-priority account the same way the current account does.
1383
+ */
1384
+ _divertedFor(model, advisorModel = null, exclude = null) {
1385
+ if (!model) return null;
1386
+ const idx = this.routeCursors.get(this._cursorKey(model, advisorModel));
1387
+ const account = idx != null ? this.accounts[idx] : null;
1388
+ if (!account || account.index === this.currentIndex || exclude?.has(account.index)) return null;
1389
+ if (!this._isAvailable(account, model, advisorModel)) return null;
1390
+ return this._preemptedBy(account, model, advisorModel, exclude) ? null : account;
1263
1391
  }
1264
1392
 
1265
1393
  /** The account this route was serving from before the current selection, or
@@ -1269,8 +1397,8 @@ export class AccountManager {
1269
1397
  * it names an account the route could have used: a cursor left on another
1270
1398
  * route's account was never this route's position, so moving off it is not a
1271
1399
  * rotation. */
1272
- _previousCursor(model) {
1273
- const recorded = this.routeCursors.get(this._cursorKey(model));
1400
+ _previousCursor(model, advisorModel = null) {
1401
+ const recorded = this.routeCursors.get(this._cursorKey(model, advisorModel));
1274
1402
  if (recorded != null) return recorded;
1275
1403
  const current = this.accounts[this.currentIndex];
1276
1404
  return current && this._routeAllows(current, model) ? current.index : null;
@@ -1449,6 +1577,14 @@ export class AccountManager {
1449
1577
  // Clear expired unified quotas
1450
1578
  if (q.unified5h != null && q.unified5hReset && now >= q.unified5hReset) {
1451
1579
  console.log(`[TeamClaude] Account "${account.name}" session quota reset`);
1580
+ // Recorded on the account, not just returned. _clearExpiredQuotas is
1581
+ // reached from two directions — refreshExpiredQuotas on the request path,
1582
+ // which runs the session-reset switch rule, and _isNearQuota via
1583
+ // unavailableReason, which getStatus calls for every account on every
1584
+ // read. Whichever noticed first used to consume the event, so with a
1585
+ // dashboard polling every 5s the rule almost never ran (#275). The flag
1586
+ // outlives the observation; only the request path clears it.
1587
+ account.sessionResetPending = true;
1452
1588
  q.unified5h = null;
1453
1589
  q.unified5hReset = null;
1454
1590
  // `rejected` describes the shared buckets and this is one of them: a
@@ -1557,13 +1693,31 @@ export class AccountManager {
1557
1693
  * account's (and it still has weekly quota), so we spend the quota closest to
1558
1694
  * refreshing first.
1559
1695
  */
1696
+ /**
1697
+ * Clear windows whose reset has passed, WITHOUT the session-reset switch.
1698
+ *
1699
+ * For read paths. refreshExpiredQuotas() also runs _switchOnSessionReset,
1700
+ * which moves currentIndex — so calling that from getStatus would let a GET
1701
+ * on /teamclaude/status rotate the fleet as a side effect of being read, and
1702
+ * a polling dashboard would quietly drive routing.
1703
+ */
1704
+ sweepExpiredQuotas() {
1705
+ for (const account of this.accounts) this._clearExpiredQuotas(account);
1706
+ }
1707
+
1560
1708
  refreshExpiredQuotas() {
1561
1709
  let changed = false;
1562
1710
  const sessionReset = [];
1563
1711
  for (const account of this.accounts) {
1564
1712
  const r = this._clearExpiredQuotas(account);
1565
1713
  if (r.changed) changed = true;
1566
- if (r.session) sessionReset.push(account);
1714
+ // The flag, not r.session: a status read may have cleared the window
1715
+ // seconds earlier, and the rule still has to run. Cleared here because
1716
+ // this is the only path that acts on it.
1717
+ if (account.sessionResetPending) {
1718
+ account.sessionResetPending = false;
1719
+ sessionReset.push(account);
1720
+ }
1567
1721
  }
1568
1722
  if (sessionReset.length) this._switchOnSessionReset(sessionReset);
1569
1723
  return changed;
@@ -1697,15 +1851,25 @@ export class AccountManager {
1697
1851
  _selectNext(exclude = null, model = null, advisorModel = null) {
1698
1852
  const best = this._pickBestAvailable(exclude, model, advisorModel);
1699
1853
  if (best) {
1700
- const previous = this._previousCursor(model);
1854
+ const previous = this._previousCursor(model, advisorModel);
1701
1855
  const switched = previous != null && previous !== best.index;
1702
- this.currentIndex = best.index;
1856
+ // A model-scoped exclusion — the current account serves everything but
1857
+ // this request (family bucket, cap, route, or the advisor's family) —
1858
+ // diverts this request alone. The global cursor stays: the account was
1859
+ // chosen, often by hand for a weekly window about to lapse, to be spent by
1860
+ // what it can serve, and moving the whole fleet off it for one family's
1861
+ // sake undid that on the next request (#276).
1862
+ const current = this.accounts[this.currentIndex];
1863
+ const scoped = this._currentBarredOnlyFor(model, advisorModel, exclude);
1864
+ if (!scoped) this.currentIndex = best.index;
1703
1865
  // If we switched to an account whose weekly quota is still unknown, flag
1704
1866
  // it so we re-evaluate once that quota is learned (see updateQuota).
1705
1867
  best.probing = best.quota.unified7dReset == null;
1706
1868
  if (switched) {
1707
1869
  this._beginRamp(best);
1708
- console.log(`[TeamClaude] Switched to account "${best.name}"`);
1870
+ console.log(scoped
1871
+ ? `[TeamClaude] Diverting "${model}" to "${best.name}" — "${current.name}" cannot serve it`
1872
+ : `[TeamClaude] Switched to account "${best.name}"`);
1709
1873
  }
1710
1874
  return best;
1711
1875
  }
@@ -2063,6 +2227,15 @@ export class AccountManager {
2063
2227
  }
2064
2228
  }
2065
2229
 
2230
+ /** Apply subscription metadata learned from the OAuth profile endpoint. */
2231
+ applyProfileData(accountIndex, profile) {
2232
+ const account = this.accounts[accountIndex];
2233
+ if (!account || !profile || profile.error) return;
2234
+ for (const field of ['organizationType', 'rateLimitTier', 'seatTier', 'hasClaudeMax', 'hasClaudePro']) {
2235
+ if (profile[field] != null) account[field] = profile[field];
2236
+ }
2237
+ }
2238
+
2066
2239
  /**
2067
2240
  * Mark an account as rate-limited for a given duration.
2068
2241
  */
@@ -2260,7 +2433,14 @@ export class AccountManager {
2260
2433
  return this.accounts.map(a => {
2261
2434
  const quota = {};
2262
2435
  for (const f of PERSISTED_QUOTA_FIELDS) quota[f] = a.quota[f];
2263
- return { accountUuid: a.accountUuid, orgUuid: a.orgUuid, orgName: a.orgName, name: a.name, quota };
2436
+ const profile = {
2437
+ organizationType: a.organizationType,
2438
+ rateLimitTier: a.rateLimitTier,
2439
+ seatTier: a.seatTier,
2440
+ hasClaudeMax: a.hasClaudeMax,
2441
+ hasClaudePro: a.hasClaudePro,
2442
+ };
2443
+ return { accountUuid: a.accountUuid, orgUuid: a.orgUuid, orgName: a.orgName, name: a.name, profile, quota };
2264
2444
  });
2265
2445
  }
2266
2446
 
@@ -2277,6 +2457,9 @@ export class AccountManager {
2277
2457
  for (const f of PERSISTED_QUOTA_FIELDS) {
2278
2458
  if (match.quota[f] != null) account.quota[f] = match.quota[f];
2279
2459
  }
2460
+ for (const field of ['organizationType', 'rateLimitTier', 'seatTier', 'hasClaudeMax', 'hasClaudePro']) {
2461
+ if (match.profile?.[field] != null) account[field] = match.profile[field];
2462
+ }
2280
2463
  // We already know this account's weekly window, so it isn't "probing".
2281
2464
  if (account.quota.unified7dReset != null) account.probing = false;
2282
2465
  }
@@ -2290,9 +2473,19 @@ export class AccountManager {
2290
2473
  // client and dimension value to anyone who can read status, and on a shared
2291
2474
  // proxy that is every key holder.
2292
2475
  getStatus({ sessionDetail = false } = {}) {
2476
+ // Sweep first: nothing else on the read path does, so an idle server kept
2477
+ // reporting a window whose reset had already passed — at its old percentage,
2478
+ // with a timestamp in the past — to `status --json`, anything scripted
2479
+ // against the endpoint, and the attached TUI, whose own refresh is a no-op
2480
+ // precisely because it trusts the server to have done this (#237).
2481
+ this.sweepExpiredQuotas();
2293
2482
  const sessions = this.sessionTracker.stats(undefined, { detail: sessionDetail });
2294
2483
  return {
2295
2484
  currentAccount: this.accounts[this.currentIndex]?.name,
2485
+ // Where a request no route claims lands right now — the same derivation
2486
+ // as each route's `target`, so a status reader need not assume "the
2487
+ // current account" when that account is blocked or outranked.
2488
+ defaultTarget: this._routeTarget(null),
2296
2489
  switchThreshold: this.effectiveThreshold,
2297
2490
  // The full table when one is configured, so status output can show the
2298
2491
  // per-bucket values rather than only the representative number.
@@ -2340,4 +2533,10 @@ export class AccountManager {
2340
2533
  })),
2341
2534
  };
2342
2535
  }
2536
+
2537
+ /** Return per-account quota and fleet aggregates for status clients. */
2538
+ getQuotaSummary() {
2539
+ this.refreshExpiredQuotas();
2540
+ return buildQuotaSummary(this.accounts);
2541
+ }
2343
2542
  }
@@ -58,10 +58,70 @@ export function configIndexFor(configAccounts, managerAccounts, mgrIdx) {
58
58
  * its own and read another's credential a restart later. Pairing by id does not
59
59
  * reach this axis.
60
60
  */
61
- export function mergeAccountsForSave(configAccounts, managerAccounts, diskAccounts) {
62
- return configAccounts.map(a => {
61
+ /**
62
+ * Whether this entry's OAuth token belongs in `config.json` at all.
63
+ *
64
+ * The save used to write `accessToken: am.credential` onto EVERY entry, which
65
+ * is right for an ordinary oauth account and wrong for the other two kinds:
66
+ *
67
+ * - an API-KEY account's credential is its `apiKey`, so the save mirrored the
68
+ * same secret into `accessToken` — and makeAccount reads
69
+ * `acct.accessToken || acct.apiKey`, preferring the mirror. Rotating the key
70
+ * in config.json was then ignored on the next cold start, while a running
71
+ * server picked it up, so the failure looked like the provider rejecting a
72
+ * key that reads correctly in the file (#202).
73
+ * - an `importFrom` account delegates to a credentials file on purpose. One
74
+ * save materialised the token into config.json, and from then on the copy
75
+ * won over re-reading the file, so the entry stopped delegating in practice
76
+ * (#204).
77
+ *
78
+ * Refreshed tokens for ordinary oauth entries still persist — that is the case
79
+ * this write exists for.
80
+ */
81
+ function ownsInlineToken(entry) {
82
+ return entry?.type === 'oauth' && !entry.importFrom;
83
+ }
84
+
85
+ // Entries the operator removed in this process, by id.
86
+ //
87
+ // The save has to tell two things apart that look identical on disk: a row that
88
+ // appeared since the last reload (`teamclaude login` while the server runs —
89
+ // keep it) and a row the operator just deleted (drop it). Both are "on disk but
90
+ // not in memory", and removal is itself a save, so adopting disk-only rows
91
+ // without this would resurrect the account the operator was in the middle of
92
+ // deleting.
93
+ //
94
+ // Held on the config object and non-enumerable, so it never reaches JSON.
95
+ const REMOVED = Symbol('removedAccountIds');
96
+
97
+ /** Record that `id` was deliberately removed, so a save does not adopt it back. */
98
+ export function markAccountRemoved(config, id) {
99
+ if (!config || !id) return;
100
+ if (!config[REMOVED]) {
101
+ Object.defineProperty(config, REMOVED, { value: new Set(), enumerable: false, writable: true });
102
+ }
103
+ config[REMOVED].add(id);
104
+ }
105
+
106
+ /** The ids removed so far. */
107
+ export function removedAccountIds(config) {
108
+ return config?.[REMOVED] || new Set();
109
+ }
110
+
111
+ /**
112
+ * Forget the removals, once a save has written a list that omits them. After
113
+ * that the rows are gone from disk too, so there is nothing left to re-adopt
114
+ * and keeping the ids would only strand them if the operator re-added the same
115
+ * account later.
116
+ */
117
+ export function clearRemovedAccountIds(config) {
118
+ if (config?.[REMOVED]) config[REMOVED].clear();
119
+ }
120
+
121
+ export function mergeAccountsForSave(configAccounts, managerAccounts, diskAccounts, removedIds = new Set()) {
122
+ const merged = configAccounts.map(a => {
63
123
  const am = managerAccountFor(managerAccounts, a);
64
- const live = am ? {
124
+ const live = am && ownsInlineToken(a) ? {
65
125
  ...a,
66
126
  accessToken: am.credential,
67
127
  refreshToken: am.refreshToken,
@@ -70,6 +130,24 @@ export function mergeAccountsForSave(configAccounts, managerAccounts, diskAccoun
70
130
  const diskAcct = diskAccounts.find(d => sameIdentity(d, a));
71
131
  return diskAcct ? { ...diskAcct, ...live } : live;
72
132
  });
133
+
134
+ // Carry over rows that exist on disk and not in memory. The list used to be
135
+ // exactly as long as the in-memory one, so an account added by another
136
+ // process since the last reload — `teamclaude login` or `import` while the
137
+ // server runs — was silently dropped by the next save (#205). The refresh
138
+ // handler already re-reads disk for this reason; the save had no equivalent.
139
+ //
140
+ // Except the ones the operator removed: removal is itself a save, so without
141
+ // that check this would resurrect the account being deleted, on the very
142
+ // write that was meant to delete it.
143
+ const keptIds = new Set(merged.map(a => a?.id).filter(Boolean));
144
+ for (const diskAcct of diskAccounts) {
145
+ if (!diskAcct?.id) continue; // pre-id row; identity above covers it
146
+ if (keptIds.has(diskAcct.id)) continue;
147
+ if (removedIds.has(diskAcct.id)) continue;
148
+ merged.push(diskAcct);
149
+ }
150
+ return merged;
73
151
  }
74
152
 
75
153
  /**