@rikcodes/teamclaude 1.1.13-rik.4 → 1.1.13-rik.5

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/README.md CHANGED
@@ -16,12 +16,14 @@
16
16
  > bucket's consumption over a rolling window and tag every account row with whichever window binds
17
17
  > first — `Ses TTL 38m` when it runs out before it resets, `Wk 22% unspent` when the reset arrives
18
18
  > first and that much expires. A readout only: no selection code reads it.
19
- > - **[Session titles](docs/usage.md#session-titles-in-the-activity-log)** (`sessionTitles`, on by default):
19
+ > - **[Session titles](docs/usage.md#session-titles-in-the-activity-log)** (`sessionTitles`, off by default):
20
20
  > name each activity row after the Claude Code session that sent the request, reading the title
21
21
  > `/rename` writes and the one Claude Code generates. A session with neither keeps its short id.
22
22
  > - `soonestWeekly` and `distributeSessions` changes now apply on config reload; upstream applies
23
23
  > `distributeSessions` only at startup.
24
24
  >
25
+ > Setup and use of each: [Fork features](#fork-features).
26
+ >
25
27
  > Published as [`@rikcodes/teamclaude`](https://www.npmjs.com/package/@rikcodes/teamclaude); self-update
26
28
  > tracks that package, so installs of this fork can never be replaced by an upstream release.
27
29
  >
@@ -104,6 +106,95 @@ Every field, plus environment variables and network tuning: [docs/configuration.
104
106
 
105
107
  Step-by-step lifecycle: [docs/routing.md](docs/routing.md#request-lifecycle).
106
108
 
109
+ ## Fork features
110
+
111
+ How to set up and use the features in this fork. Everything else in this README is upstream behaviour.
112
+
113
+ ### OpenAI models via a Codex sidecar
114
+
115
+ A Claude Code session can use OpenAI models alongside the Claude accounts. They are billed to a ChatGPT Plus/Pro subscription and use their real model names in the same session. TeamClaude does not translate the wire format itself. A local **sidecar** does this: [raine/claude-code-proxy](https://github.com/raine/claude-code-proxy) speaks `/v1/messages` on the front and the Codex Responses API on the back. TeamClaude starts and supervises this process. It sends every `gpt-*` request to the sidecar and keeps every other request on the Claude accounts.
116
+
117
+ **1. Install the sidecar and log it into your ChatGPT account** (one time):
118
+
119
+ ```bash
120
+ brew install raine/claude-code-proxy/claude-code-proxy
121
+ claude-code-proxy codex auth login
122
+ ```
123
+
124
+ **2. Connect it** — add four pieces to `~/.config/teamclaude.json`:
125
+
126
+ ```json
127
+ {
128
+ "sidecars": [
129
+ { "name": "codex", "command": ["claude-code-proxy", "serve", "--no-monitor", "--port", "18765"] }
130
+ ],
131
+ "accounts": [
132
+ { "name": "codex", "type": "oauth", "accessToken": "unused-local-sidecar",
133
+ "upstream": "http://127.0.0.1:18765", "priority": 100 }
134
+ ],
135
+ "routes": [
136
+ { "name": "codex", "match": ["gpt-*"], "accounts": ["codex"] },
137
+ { "name": "anthropic", "match": ["*"], "accounts": ["your-claude-account", "..."] }
138
+ ],
139
+ "customModels": [
140
+ { "model": "gpt-5.6-sol", "label": "GPT-5.6 Sol", "contextTokens": 272000 },
141
+ { "model": "gpt-5.6-terra", "label": "GPT-5.6 Terra", "contextTokens": 272000 },
142
+ { "model": "gpt-5.6-luna", "label": "GPT-5.6 Luna", "contextTokens": 272000 }
143
+ ]
144
+ }
145
+ ```
146
+
147
+ - `sidecars` — the process that TeamClaude owns. It starts with the server, restarts with backoff after a crash, and stops on shutdown. Its pid, restart count and latest stderr lines are in `teamclaude status --json` under `sidecars`.
148
+ - `accounts` — the sidecar as a [third-party backend account](docs/accounts.md#third-party-backend-accounts). The token is a placeholder because the sidecar uses its own Codex login for authentication. `priority: 100` is the convention for third-party backends. The routes determine what reaches it.
149
+ - `routes` — sends `gpt-*` to the sidecar. **Keep the catch-all `*` route.** Without it, the sidecar joins the exhaustion-fallback pool. A spent Claude fleet would then silently send Claude-model requests to the sidecar, which maps `claude-*` names onto GPT models. With the route, a request can reach a GPT model only when it asks for one by name.
150
+ - `customModels` — the rows that make the models visible to Claude Code (next section).
151
+
152
+ **3. Restart the server.** `teamclaude status --json` should show the sidecar as `running`. If it enters a crash loop, `stderrTail` explains why — the usual cause is that the sidecar is not logged in.
153
+
154
+ #### How models get into Claude Code
155
+
156
+ Claude Code offers only models that it knows, and it does not know `gpt-*`. `customModels` closes this gap. Each row contains a model id that the proxy can serve, an optional picker label and description, and the model's context window.
157
+
158
+ At launch, `teamclaude run` — and the `claude` alias, which passes through `run` — gives these rows to Claude Code:
159
+
160
+ | Row field | Where it ends up |
161
+ | --- | --- |
162
+ | `model`, `label`, `description` | A `/model` picker row under the **real** model id (`--settings`), so `/model gpt-5.6-sol` works picked or typed |
163
+ | `model` | A dispatchable subagent named after the model (`--agents`), so "dispatch a `gpt-5.6-terra` subagent" works from a Claude parent |
164
+ | `contextTokens` | `CLAUDE_CODE_MAX_CONTEXT_TOKENS`, set to the largest value across rows, so Claude Code compacts at the real window instead of assuming 200k |
165
+
166
+ For tools that spawn `claude` themselves, `teamclaude env` can set only environment variables. It carries the window and `ANTHROPIC_CUSTOM_MODEL_OPTION` for the **first** row. For GPT subagents under `env`, create `~/.claude/agents/<name>.md` with `model: gpt-5.6-terra` in its frontmatter.
167
+
168
+ Each request is routed by the model name in its body, so one session can freely mix models: use `claude --model gpt-5.6-sol` for a whole session, `/model gpt-5.6-sol` during a session, or a Claude parent that dispatches a GPT subagent.
169
+
170
+ **To add a model:**
171
+
172
+ 1. Check that your sidecar build lists it: `curl -s http://127.0.0.1:18765/v1/models`. The sidecar has its own allow-list and rejects any id that it does not know, regardless of the TeamClaude configuration. Upgrade the sidecar if the id is missing.
173
+ 2. Add a `customModels` row. Codex publishes the window for each model as `context_window` in `~/.codex/models_cache.json`; copy it to `contextTokens`.
174
+ 3. Start a new `teamclaude run` session. The rows are read at launch, so you do not need to restart the server. If you upgraded the sidecar binary, restart the server — or send `SIGTERM` to the sidecar process and let the supervisor restart it with the new binary.
175
+
176
+ Claude Code prints one `[claude-code:unrecognized_model]` line to stderr for each custom model. This is expected; suppressing it would lose the correct context window. The quota bars for the sidecar account show `unknown` unless the sidecar forwards Codex's rate-limit headers — see [Quota](docs/openai.md#quota). Keep the sidecar on loopback, and use **one** ChatGPT subscription for each person. Pooling several subscriptions is the pattern that OpenAI's fraud systems target ([terms of service](docs/openai.md#terms-of-service)).
177
+
178
+ Full details: [docs/openai.md](docs/openai.md).
179
+
180
+ ### Soonest-weekly rotation
181
+
182
+ Default selection is sticky. It re-ranks only when the current account is exhausted, so a weekly window can expire with unused quota. `soonestWeekly` re-ranks continuously. Among equal-priority accounts, the one whose governing weekly window resets soonest is used first. It preempts the current account when another account resets more than `poolHours` sooner.
183
+
184
+ ```json
185
+ "soonestWeekly": { "enabled": true, "poolHours": 12 }
186
+ ```
187
+
188
+ `distributeSessions` works with this setting. New sessions balance across that pool instead of across all equal-priority accounts. Both settings take effect when the configuration reloads (upstream applies `distributeSessions` only at startup). Details: [Routing](docs/routing.md#soonest-weekly-rotation).
189
+
190
+ ### Burn-rate projection
191
+
192
+ This feature is on by default. Each account row shows which window binds first: `Ses TTL 38m` when the session bucket runs out before it resets, or `Wk 22% unspent` when the weekly reset arrives first and that amount of quota expires. This is a readout only; no selection code reads it. Tune or disable it with `projection: { enabled, windowMinutes, wasteFloor }`. Details: [Quota](docs/quota.md#burn-rate-projection).
193
+
194
+ ### Session titles
195
+
196
+ This feature is off by default. Activity rows use the name of the Claude Code session that sent the request. They use the title written by `/rename` or the title that Claude Code generates; a session with neither keeps its short id. Toggle the feature from the settings screen (**g** → Session titles) or with `sessionTitles: { enabled, width, projectsDir }`. Details: [Usage](docs/usage.md#session-titles-in-the-activity-log).
197
+
107
198
  ## Documentation
108
199
 
109
200
  | Page | Contents |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rikcodes/teamclaude",
3
- "version": "1.1.13-rik.4",
3
+ "version": "1.1.13-rik.5",
4
4
  "description": "Multi-account Claude proxy with automatic quota-based rotation",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -19,9 +19,19 @@ const FORCED_REFRESH_FLOOR_MS = 10_000;
19
19
  const PERSISTED_QUOTA_FIELDS = [
20
20
  'unified5h', 'unified7d', 'unified7dSonnet', 'unified7dFable',
21
21
  'unified5hReset', 'unified7dReset', 'unified7dSonnetReset', 'unified7dFableReset', 'unifiedStatus',
22
+ 'unified7dSonnetSeenAt', 'unified7dFableSeenAt',
22
23
  'tokensLimit', 'tokensRemaining', 'requestsLimit', 'requestsRemaining', 'resetsAt',
23
24
  ];
24
25
 
26
+ // The family (Fable/Sonnet) weekly buckets and the field holding when each was
27
+ // last confirmed by upstream. See _clearExpiredQuotas: a SPENT family reading is
28
+ // only trusted while it is fresh, because nothing but a request of that family
29
+ // can refresh it.
30
+ const FAMILY_WEEKLY_BUCKETS = [
31
+ { key: 'unified7dFable', label: 'Fable' },
32
+ { key: 'unified7dSonnet', label: 'Sonnet' },
33
+ ];
34
+
25
35
  // Longest Codex window still treated as a session bucket. The two lengths seen
26
36
  // in practice are 300 minutes (5h) and 10080 (weekly), so a day sits safely
27
37
  // between them.
@@ -54,6 +64,11 @@ function emptyQuota() {
54
64
  unified7dReset: null, // ms timestamp
55
65
  unified7dSonnetReset: null, // ms timestamp
56
66
  unified7dFableReset: null, // ms timestamp
67
+ // When each family bucket was last confirmed by upstream (ms timestamp).
68
+ // Only these two buckets need it: they are the ones a spent reading can seal
69
+ // itself into, since selection stops sending the family that would refresh them.
70
+ unified7dSonnetSeenAt: null,
71
+ unified7dFableSeenAt: null,
57
72
  unifiedStatus: null, // allowed | allowed_warning | rejected
58
73
  resetsAt: null,
59
74
  };
@@ -105,6 +120,9 @@ function makeAccount(acct, index) {
105
120
  // (post-401) refreshes so a burst of stale in-flight requests can't rotate
106
121
  // the refresh-token family once per request — see ensureTokenFresh.
107
122
  _lastRefreshAt: null,
123
+ // The refresh token upstream last rejected as invalid, if it is still the
124
+ // one we hold — see the dead-token guard in ensureTokenFresh.
125
+ _deadRefreshToken: null,
108
126
  };
109
127
  }
110
128
 
@@ -125,7 +143,7 @@ function sampleModelFor(route) {
125
143
  }
126
144
 
127
145
  export class AccountManager {
128
- constructor(accounts, switchThreshold = 0.98, { refreshFn = refreshAccessToken, throttleProbeFloorMs, forcedRefreshFloorMs = FORCED_REFRESH_FLOOR_MS, routes, ramp, distributeSessions = false, soonestWeekly, projection, sessionTracker } = {}) {
146
+ constructor(accounts, switchThreshold = 0.98, { refreshFn = refreshAccessToken, throttleProbeFloorMs, familyStaleMs, forcedRefreshFloorMs = FORCED_REFRESH_FLOOR_MS, routes, ramp, distributeSessions = false, soonestWeekly, projection, sessionTracker } = {}) {
129
147
  // How long a just-minted token is trusted against a forced refresh.
130
148
  this._forcedRefreshFloorMs = forcedRefreshFloorMs;
131
149
  // Injectable for tests (mirrors Prober's probeFn); defaults to the real
@@ -145,6 +163,13 @@ export class AccountManager {
145
163
  // bias selection for a route's models and reset on restart. A pinned account
146
164
  // that becomes ineligible is skipped — routing falls back to best-available.
147
165
  this.routePins = new Map();
166
+ // Selection cursor per route (routeName → account index; '' when no route
167
+ // matches). A single global cursor reads traffic that alternates between
168
+ // routes as a rotation: the cursor sits on the other route's account, that
169
+ // account fails this model's route check, and selection "switches" away from
170
+ // it. Each such switch arms the ramp below, so steady interleaved traffic
171
+ // holds both accounts at the ramp floor while nothing has failed over.
172
+ this.routeCursors = new Map();
148
173
  this.switchThreshold = switchThreshold;
149
174
  this.setRoutes(routes);
150
175
  this.setSoonestWeekly(soonestWeekly);
@@ -175,6 +200,13 @@ export class AccountManager {
175
200
  // retry-after, short enough that a stale hold cannot pin the fleet.
176
201
  this.throttleProbeFloorMs = throttleProbeFloorMs
177
202
  ?? (Number(process.env.TEAMCLAUDE_THROTTLE_PROBE_FLOOR_MS) || 60_000);
203
+ // How long a SPENT family (Fable/Sonnet) weekly reading is trusted before it
204
+ // is cleared for revalidation (see _clearExpiredQuotas). Long enough that a
205
+ // genuinely spent bucket costs at most one rejected request per account per
206
+ // window, short enough that a stale reading cannot lock a family out for the
207
+ // rest of the weekly window.
208
+ this.familyStaleMs = familyStaleMs
209
+ ?? (Number(process.env.TEAMCLAUDE_FAMILY_STALE_MS) || 30 * 60_000);
178
210
  }
179
211
 
180
212
  /** Start (or restart) the ramp window for an account that just became current,
@@ -261,6 +293,16 @@ export class AccountManager {
261
293
  * request keeps flowing (upstream then fails just the advisor call).
262
294
  */
263
295
  getActiveAccount(exclude = null, model = null, advisorModel = null, sessionId = null) {
296
+ const account = this._pickActiveAccount(exclude, model, advisorModel, sessionId);
297
+ // Record where this route now sits, whatever path chose it — the steady-state
298
+ // path returns the account the cursor already names and never reaches the
299
+ // rotation code, so recording there alone would leave the cursor unset and
300
+ // the next real failover unpaced.
301
+ if (account) this.routeCursors.set(this._cursorKey(model), account.index);
302
+ return account;
303
+ }
304
+
305
+ _pickActiveAccount(exclude, model, advisorModel, sessionId) {
264
306
  // Clear expired quotas across all accounts and switch proactively if a
265
307
  // session reset made a sooner-expiring account the better choice. This runs
266
308
  // on every request so the behaviour holds without the TUI render loop.
@@ -773,6 +815,29 @@ export class AccountManager {
773
815
  if (name !== 'fable' && name !== 'sonnet' && !names.has(name)) this.routePins.delete(name);
774
816
  }
775
817
  }
818
+ // A reload can rename or drop a route, stranding its cursor under a key
819
+ // nothing resolves to. Clearing them costs one extra best-available walk per
820
+ // route and keeps no state that outlives the table it belonged to.
821
+ this.routeCursors?.clear();
822
+ }
823
+
824
+ /** Cursor key for a model: its route's name, or '' when no route matches. */
825
+ _cursorKey(model) {
826
+ return this._routeForModel(model)?.name || '';
827
+ }
828
+
829
+ /** The account this route was serving from before the current selection, or
830
+ * null when it has none — used to tell a rotation from ordinary routing.
831
+ *
832
+ * Before a route has its own cursor, the global one stands in, but only when
833
+ * it names an account the route could have used: a cursor left on another
834
+ * route's account was never this route's position, so moving off it is not a
835
+ * rotation. */
836
+ _previousCursor(model) {
837
+ const recorded = this.routeCursors.get(this._cursorKey(model));
838
+ if (recorded != null) return recorded;
839
+ const current = this.accounts[this.currentIndex];
840
+ return current && this._routeAllows(current, model) ? current.index : null;
776
841
  }
777
842
 
778
843
  /** The first configured route whose globs match `model`, or null. */
@@ -963,11 +1028,44 @@ export class AccountManager {
963
1028
  if (q.unified7dSonnet != null && q.unified7dSonnetReset && now >= q.unified7dSonnetReset) {
964
1029
  q.unified7dSonnet = null;
965
1030
  q.unified7dSonnetReset = null;
1031
+ q.unified7dSonnetSeenAt = null;
966
1032
  changed = true;
967
1033
  }
968
1034
  if (q.unified7dFable != null && q.unified7dFableReset && now >= q.unified7dFableReset) {
969
1035
  q.unified7dFable = null;
970
1036
  q.unified7dFableReset = null;
1037
+ q.unified7dFableSeenAt = null;
1038
+ changed = true;
1039
+ }
1040
+
1041
+ // A family bucket is refreshed ONLY by upstream evidence for that family:
1042
+ // the `7d_oi` headers ride on Fable responses (they are absent from every
1043
+ // other model's response), and the Sonnet bucket comes from the usage
1044
+ // endpoint — an opt-in probe that is off by default. So once such a bucket
1045
+ // reads spent, selection stops sending that family to the account, which is
1046
+ // also the only thing that could have corrected the reading: it seals itself
1047
+ // in until its cached reset passes, up to a week of lockout on an account
1048
+ // whose real family quota reset long ago (issue #167).
1049
+ //
1050
+ // A spent family reading is therefore trusted only while it is fresh. Past
1051
+ // the staleness floor it is cleared, the family falls back to the shared
1052
+ // weekly bucket, and the next request of that family re-establishes the
1053
+ // truth from real headers — a 429 re-arms the gate with a fresh reading and
1054
+ // a fresh timestamp, so a genuinely spent bucket costs one rejected request
1055
+ // per account per window and no more. A reading with headroom is left alone:
1056
+ // it gates nothing, so it cannot seal anything in.
1057
+ for (const { key, label } of FAMILY_WEEKLY_BUCKETS) {
1058
+ if (q[key] == null || q[key] < this.switchThreshold) continue;
1059
+ const seenField = `${key}SeenAt`;
1060
+ // Unknown age (restored from an older state file, or set by a path that
1061
+ // predates the stamp): start the clock now rather than clearing at once,
1062
+ // so a reading is never discarded before it has had a window to prove out.
1063
+ if (!q[seenField]) { q[seenField] = now; continue; }
1064
+ if (now < q[seenField] + this.familyStaleMs) continue;
1065
+ console.log(`[TeamClaude] Account "${account.name}" ${label} weekly reading is stale — revalidating on the next ${label} request`);
1066
+ q[key] = null;
1067
+ q[`${key}Reset`] = null;
1068
+ q[seenField] = null;
971
1069
  changed = true;
972
1070
  }
973
1071
 
@@ -1132,7 +1230,8 @@ export class AccountManager {
1132
1230
  _selectNext(exclude = null, model = null, advisorModel = null) {
1133
1231
  const best = this._pickBestAvailable(exclude, model, advisorModel);
1134
1232
  if (best) {
1135
- const switched = best.index !== this.currentIndex;
1233
+ const previous = this._previousCursor(model);
1234
+ const switched = previous != null && previous !== best.index;
1136
1235
  this.currentIndex = best.index;
1137
1236
  // If we switched to an account whose weekly quota is still unknown, flag
1138
1237
  // it so we re-evaluate once that quota is learned (see updateQuota).
@@ -1204,8 +1303,14 @@ export class AccountManager {
1204
1303
  // overage included"). On current subscription plans this is the Fable weekly
1205
1304
  // limit (it correlates with the usage endpoint's Fable-scoped weekly bucket).
1206
1305
  // Utilization here is already a 0-1 fraction (can exceed 1 when in overage).
1306
+ // These headers ride on Fable responses only, so stamp when the reading was
1307
+ // taken: that timestamp is what lets a spent reading be revalidated instead
1308
+ // of sealing the account out of the family forever (see _clearExpiredQuotas).
1207
1309
  const u7dOi = parseFloat(headers['anthropic-ratelimit-unified-7d_oi-utilization']);
1208
- if (!isNaN(u7dOi)) account.quota.unified7dFable = u7dOi;
1310
+ if (!isNaN(u7dOi)) {
1311
+ account.quota.unified7dFable = u7dOi;
1312
+ account.quota.unified7dFableSeenAt = Date.now();
1313
+ }
1209
1314
  const r7dOi = headers['anthropic-ratelimit-unified-7d_oi-reset'];
1210
1315
  if (r7dOi) account.quota.unified7dFableReset = parseInt(r7dOi, 10) * 1000;
1211
1316
 
@@ -1293,6 +1398,10 @@ export class AccountManager {
1293
1398
  if (!disabled && account.status === 'error') {
1294
1399
  account.status = 'active';
1295
1400
  account.rateLimitedUntil = null;
1401
+ // Operator escape hatch: re-enabling is an explicit "try this again", so
1402
+ // drop the dead-token guard too — otherwise the account would come back
1403
+ // active but never attempt a refresh (see ensureTokenFresh).
1404
+ account._deadRefreshToken = null;
1296
1405
  console.log(`[TeamClaude] Account "${account.name}" re-enabled — clearing error state`);
1297
1406
  }
1298
1407
  }
@@ -1315,12 +1424,23 @@ export class AccountManager {
1315
1424
  if (usage.sevenDay.utilization != null) q.unified7d = usage.sevenDay.utilization;
1316
1425
  if (usage.sevenDay.resetAt != null) q.unified7dReset = usage.sevenDay.resetAt;
1317
1426
  }
1427
+ // The family buckets carry a "last confirmed" stamp (see _clearExpiredQuotas).
1428
+ // A probe is upstream evidence just like a response header, so it refreshes
1429
+ // the stamp — this is the one path that can correct a spent family reading
1430
+ // without spending quota, which is why enabling the probe sidesteps the
1431
+ // staleness problem entirely.
1318
1432
  if (usage.sevenDaySonnet) {
1319
- if (usage.sevenDaySonnet.utilization != null) q.unified7dSonnet = usage.sevenDaySonnet.utilization;
1433
+ if (usage.sevenDaySonnet.utilization != null) {
1434
+ q.unified7dSonnet = usage.sevenDaySonnet.utilization;
1435
+ q.unified7dSonnetSeenAt = Date.now();
1436
+ }
1320
1437
  if (usage.sevenDaySonnet.resetAt != null) q.unified7dSonnetReset = usage.sevenDaySonnet.resetAt;
1321
1438
  }
1322
1439
  if (usage.sevenDayFable) {
1323
- if (usage.sevenDayFable.utilization != null) q.unified7dFable = usage.sevenDayFable.utilization;
1440
+ if (usage.sevenDayFable.utilization != null) {
1441
+ q.unified7dFable = usage.sevenDayFable.utilization;
1442
+ q.unified7dFableSeenAt = Date.now();
1443
+ }
1324
1444
  if (usage.sevenDayFable.resetAt != null) q.unified7dFableReset = usage.sevenDayFable.resetAt;
1325
1445
  }
1326
1446
 
@@ -1372,6 +1492,31 @@ export class AccountManager {
1372
1492
  const account = this.accounts[accountIndex];
1373
1493
  if (!account || account.type !== 'oauth' || !account.refreshToken) return;
1374
1494
 
1495
+ // Dead-token guard: a refresh token upstream already rejected (invalid_grant)
1496
+ // will be rejected every time, so retrying it only floods the OAuth endpoint
1497
+ // — observed live: 287 identical invalid_grant calls after two accounts' tokens
1498
+ // were invalidated (a `/login` elsewhere rotates the token and kills the copy
1499
+ // teamclaude holds). Paths that bypass availability checks keep calling this
1500
+ // (the quota prober refreshes every OAuth account regardless of status, and a
1501
+ // pinned request reaches here without _isAvailable), so marking the account
1502
+ // 'error' alone does not stop the retries. Keyed on the token VALUE, not the
1503
+ // status: the moment a DIFFERENT refresh token arrives (re-login, config
1504
+ // reload, updateAccountTokens) the guard lifts on its own.
1505
+ //
1506
+ // While it holds, the account must also READ as needing a re-login. A
1507
+ // re-import can hand updateAccountTokens a new access token alongside the
1508
+ // same dead refresh token; that path resets status to 'active', so without
1509
+ // this the access token's 401 would force a refresh that silently does
1510
+ // nothing here and the retry would relay the 401 to the client instead of
1511
+ // rotating to another account.
1512
+ if (account._deadRefreshToken && account._deadRefreshToken === account.refreshToken) {
1513
+ if (account.status !== 'error') {
1514
+ account.status = 'error';
1515
+ console.error(`[TeamClaude] Account "${account.name}" still holds a rejected refresh token — run: teamclaude login`);
1516
+ }
1517
+ return;
1518
+ }
1519
+
1375
1520
  if (!force && !isTokenExpiringSoon(account.expiresAt)) return;
1376
1521
 
1377
1522
  // A forced refresh answers a 401, but 401s arrive in bursts: every request
@@ -1400,6 +1545,7 @@ export class AccountManager {
1400
1545
  account.refreshToken = newTokens.refreshToken;
1401
1546
  account.expiresAt = newTokens.expiresAt;
1402
1547
  account._lastRefreshAt = Date.now();
1548
+ account._deadRefreshToken = null; // this token works; clear any stale guard
1403
1549
  console.log(`[TeamClaude] Token refreshed for account "${account.name}"`);
1404
1550
  this._onTokenRefresh?.(accountIndex, newTokens);
1405
1551
  } catch (err) {
@@ -1413,6 +1559,10 @@ export class AccountManager {
1413
1559
  const isAuthRejection = err.status === 400 || err.status === 401 || err.status === 403;
1414
1560
  if (isAuthRejection) {
1415
1561
  account.status = 'error';
1562
+ // Remember WHICH token was rejected so we stop re-sending it (see the
1563
+ // dead-token guard above). A transient failure deliberately does not
1564
+ // arm this — that token may still be good.
1565
+ account._deadRefreshToken = account.refreshToken;
1416
1566
  console.error(`[TeamClaude] Account "${account.name}" needs re-login (refresh token rejected) — run: teamclaude login`);
1417
1567
  }
1418
1568
  } finally {
@@ -1476,6 +1626,12 @@ export class AccountManager {
1476
1626
  if (idx === index) this.routePins.delete(name);
1477
1627
  else if (idx > index) this.routePins.set(name, idx - 1);
1478
1628
  }
1629
+ // Same for the selection cursors: a cursor on the removed account is dropped
1630
+ // so the route re-picks, and one above it follows the shift.
1631
+ for (const [name, idx] of [...this.routeCursors.entries()]) {
1632
+ if (idx === index) this.routeCursors.delete(name);
1633
+ else if (idx > index) this.routeCursors.set(name, idx - 1);
1634
+ }
1479
1635
  }
1480
1636
 
1481
1637
  /**
package/src/config.js CHANGED
@@ -59,7 +59,7 @@ export function createDefaultConfig() {
59
59
  holdSeconds: 0,
60
60
  distributeSessions: false,
61
61
  projection: { enabled: true, windowMinutes: 90, wasteFloor: 0.1 },
62
- sessionTitles: { enabled: true, width: 18 },
62
+ sessionTitles: { enabled: false, width: 18 },
63
63
  eventLogging: 'hide',
64
64
  blockedModels: [],
65
65
  accounts: [],
package/src/identity.js CHANGED
@@ -94,3 +94,27 @@ export function matchAccounts(accounts, query, orgFilter) {
94
94
  }
95
95
  return matches;
96
96
  }
97
+
98
+ /**
99
+ * Automatic naming is safe only when the profile identifies the account.
100
+ * An explicit name is the caller's opt-in to importing without detection.
101
+ */
102
+ export function canUpsertOAuthAccount(profile, userNamed) {
103
+ return Boolean(
104
+ userNamed
105
+ || (profile && !profile.error && (profile.accountUuid || profile.email))
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Copy only known profile identity fields. Omitting unavailable fields keeps a
111
+ * named re-import from erasing identity already stored on the account.
112
+ */
113
+ export function oauthIdentityFields(profile) {
114
+ if (!profile || profile.error) return {};
115
+ return Object.fromEntries(
116
+ ['accountUuid', 'orgUuid', 'orgName']
117
+ .filter(key => profile[key])
118
+ .map(key => [key, profile[key]])
119
+ );
120
+ }