@karpeleslab/teamclaude 1.0.5 → 1.0.7

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KarpelesLab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,18 +1,27 @@
1
1
  # TeamClaude
2
2
 
3
+ [![CI](https://github.com/KarpelesLab/teamclaude/actions/workflows/ci.yml/badge.svg)](https://github.com/KarpelesLab/teamclaude/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/@karpeleslab/teamclaude.svg)](https://www.npmjs.com/package/@karpeleslab/teamclaude)
5
+ [![node](https://img.shields.io/node/v/@karpeleslab/teamclaude.svg)](https://nodejs.org)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
+
3
8
  Multi-account Claude proxy with automatic quota-based rotation for [Claude Code](https://claude.ai/claude-code).
4
9
 
5
- Sits transparently between Claude Code and the Anthropic API, managing multiple Claude Max accounts and automatically switching when one approaches its session or weekly quota limit.
10
+ Sits transparently between Claude Code and the Anthropic API, managing multiple Claude Max (or API key) accounts and automatically switching when one approaches its session or weekly quota limit.
6
11
 
7
12
  ![TeamClaude TUI](screenshots/teamclaude.png)
8
13
 
9
14
  ## Features
10
15
 
11
16
  - **Automatic account rotation** — switches to the next account when session (5h) or weekly (7d) quota reaches the configured threshold (default 98%)
12
- - **Auto-retry on 429** — if an account is rate-limited, transparently retries with the next one
13
- - **Interactive TUI** — real-time dashboard with color-coded quota bars showing reset countdowns, activity log, and keyboard controls
14
- - **OAuth token refresh** — proactively refreshes expiring tokens, intercepts client token renewals, and persists them to config
17
+ - **Auto-retry on 429** — waits the `retry-after` duration and retries the same account; switches to the next on persistent errors
18
+ - **Interactive TUI** — real-time dashboard with color-coded quota bars, reset countdowns, activity log, and keyboard controls
19
+ - **OAuth token management** — automatically refreshes tokens nearing expiry and persists them to config; client token refreshes pass through untouched
15
20
  - **Hot-reload accounts** — add accounts via `import` or `login` while the server is running, press **R** to pick them up
21
+ - **Org-aware accounts** — one email can hold multiple accounts across different organizations (e.g. corp + personal); dedup is keyed on account + org, and names disambiguate as `email (Org)`
22
+ - **Rotation priority** — pin a preferred account order with `teamclaude priority`
23
+ - **Enable/disable accounts** — temporarily pause an account without removing it (`teamclaude disable`/`enable`, or `d` in the TUI); re-enabling also clears a stuck error state
24
+ - **Quota persistence** — observed quota survives restarts (saved to a sibling state file), so rotation state isn't lost on restart; stale windows are discarded automatically
16
25
  - **Request logging** — optional full request/response logging for debugging
17
26
  - **Zero dependencies** — uses only Node.js built-in modules
18
27
 
@@ -67,7 +76,11 @@ claude /login # Log into an account in Claude Code
67
76
  teamclaude import # Import its credentials
68
77
  ```
69
78
 
70
- Re-importing the same account updates its credentials.
79
+ Re-importing the same account updates its credentials. You can also import from a custom path:
80
+
81
+ ```bash
82
+ teamclaude import --from /path/to/credentials.json
83
+ ```
71
84
 
72
85
  ### API Key
73
86
 
@@ -86,18 +99,33 @@ teamclaude server
86
99
  ```
87
100
 
88
101
  When running from a TTY, shows an interactive TUI with:
89
- - Account table with session/weekly quota progress bars
102
+ - Account table with session/weekly quota progress bars and reset countdowns
90
103
  - Real-time activity log with request tracking
91
- - Keyboard shortcuts: **s**witch, **a**dd, **r**emove, **R**eload, **q**uit
104
+ - Keyboard shortcuts (see below)
92
105
 
93
106
  Falls back to plain log output when not a TTY (e.g. running as a service).
94
107
 
108
+ #### TUI Keyboard Shortcuts
109
+
110
+ | Key | Action |
111
+ |-----|--------|
112
+ | `s` | Switch active account |
113
+ | `a` | Add account (import or API key) |
114
+ | `r` | Remove an account |
115
+ | `d` | Enable/disable an account |
116
+ | `R` | Reload accounts from config |
117
+ | `q` | Quit |
118
+
119
+ In selection mode, use `j`/`k` or arrow keys to navigate, `Enter` to confirm, `Esc` to cancel.
120
+
95
121
  ### Run Claude Code through the proxy
96
122
 
97
123
  ```bash
98
124
  teamclaude run
99
125
  ```
100
126
 
127
+ `run` probes the proxy first: if it's up, Claude Code is routed through it; if it's **not** running, `claude` is launched directly so nothing breaks.
128
+
101
129
  Or manually set the environment:
102
130
 
103
131
  ```bash
@@ -105,16 +133,38 @@ eval $(teamclaude env)
105
133
  claude
106
134
  ```
107
135
 
136
+ ### Routing plain `claude` automatically (alias)
137
+
138
+ So you don't have to type `teamclaude run` every time, add a shell alias that makes plain `claude` go through the proxy (and fall back to direct when it's down):
139
+
140
+ ```bash
141
+ teamclaude alias # print the alias for your shell
142
+ teamclaude alias --install # or write it to your shell rc (--uninstall to remove)
143
+ ```
144
+
145
+ This is an interactive-shell alias — it affects `claude` typed at a prompt, not `claude` spawned by editors or scripts. It's a thin passthrough to `teamclaude run`, which holds the proxy-up/down logic.
146
+
108
147
  ### Other commands
109
148
 
110
149
  ```bash
111
- teamclaude accounts # List accounts with live profile info
150
+ teamclaude accounts # List accounts with subscription tier and token status
151
+ teamclaude accounts -v # Also show token expiry times
112
152
  teamclaude status # Show live proxy status (requires running server)
113
- teamclaude remove <name> # Remove an account
153
+ teamclaude remove <name> # Remove an account (by name or email)
154
+ teamclaude disable <name> # Temporarily exclude an account from rotation
155
+ teamclaude enable <name> # Re-enable it (also clears a stuck error state)
156
+ teamclaude priority <name> 1 # Set rotation priority (lower = preferred)
157
+ teamclaude alias # Print/install a `claude` alias that routes via the proxy
114
158
  teamclaude api <path> # Call an API endpoint with account credentials
115
159
  teamclaude help # Show all commands
116
160
  ```
117
161
 
162
+ When the same email belongs to multiple organizations, accounts are named
163
+ `email (Org)` to keep them distinct. Pass `--org <name|uuid>` to disambiguate a
164
+ bare email, e.g. `teamclaude remove user@example.com --org Acme`. Use
165
+ `teamclaude priority <name> --first` / `--last` to move an account to the front
166
+ or back of the rotation order.
167
+
118
168
  ### Request logging
119
169
 
120
170
  Log full request/response details to a directory (one file per request):
@@ -127,6 +177,8 @@ teamclaude server --log-to /tmp/requests
127
177
 
128
178
  Config is stored at `~/.config/teamclaude.json` (or `$XDG_CONFIG_HOME/teamclaude.json`). A random proxy API key is generated on first use.
129
179
 
180
+ Volatile runtime state (observed quota) is written separately to `teamclaude.state.json` alongside the config, so the config file stays clean and hand-editable. The state file is safe to delete — quota is simply re-learned from traffic.
181
+
130
182
  Override the config path with `TEAMCLAUDE_CONFIG`:
131
183
 
132
184
  ```bash
@@ -145,9 +197,12 @@ TEAMCLAUDE_CONFIG=./my-config.json teamclaude server
145
197
  "switchThreshold": 0.98,
146
198
  "accounts": [
147
199
  {
148
- "name": "user@example.com",
200
+ "name": "user@example.com (Acme)",
149
201
  "type": "oauth",
150
202
  "accountUuid": "...",
203
+ "orgUuid": "...",
204
+ "orgName": "Acme",
205
+ "priority": 0,
151
206
  "accessToken": "sk-ant-oat01-...",
152
207
  "refreshToken": "sk-ant-ort01-...",
153
208
  "expiresAt": 1774384968427
@@ -156,14 +211,38 @@ TEAMCLAUDE_CONFIG=./my-config.json teamclaude server
156
211
  }
157
212
  ```
158
213
 
214
+ | Field | Description |
215
+ |-------|-------------|
216
+ | `proxy.port` | Local port the proxy listens on |
217
+ | `proxy.apiKey` | API key clients use to authenticate with the proxy |
218
+ | `upstream` | Upstream API base URL |
219
+ | `switchThreshold` | Quota utilization (0–1) at which to switch accounts |
220
+ | `accounts[].accountUuid` | Anthropic account (person) id; set automatically from the OAuth profile |
221
+ | `accounts[].orgUuid` / `orgName` | Organization the account is scoped to — lets one email hold multiple org accounts |
222
+ | `accounts[].priority` | Rotation preference, lower = preferred (default 0) |
223
+ | `accounts[].disabled` | If `true`, the account is excluded from rotation until re-enabled |
224
+
159
225
  ## How It Works
160
226
 
161
227
  1. Claude Code connects to the local proxy instead of `api.anthropic.com`
162
228
  2. The proxy selects the active account and forwards requests with that account's credentials
163
- 3. Rate limit headers from the API (`anthropic-ratelimit-unified-*`) track session and weekly quota
164
- 4. When usage reaches the threshold, the proxy switches to the next available account
165
- 5. If all accounts are exhausted, returns 429 with the soonest reset time
229
+ 3. OAuth tokens expiring within 5 minutes are automatically refreshed and persisted to config
230
+ 4. Rate limit headers from the API (`anthropic-ratelimit-unified-*`) track session (5h) and weekly (7d) quota utilization
231
+ 5. When usage reaches the threshold, the proxy switches to the next available account via round-robin
232
+ 6. On 429 responses, the proxy waits the `retry-after` duration and retries; on persistent errors, it switches accounts
233
+ 7. Transient network errors (connection reset, timeout) drop the connection so the client can retry
234
+ 8. If all accounts are exhausted, returns 429 with the soonest reset time
235
+ 9. Client token refresh requests (`/v1/oauth/token`) are relayed to upstream untouched — the proxy and client manage their own token lifecycles independently
236
+
237
+ ## Security
238
+
239
+ The only canonical sources for TeamClaude are this repository
240
+ (https://github.com/KarpelesLab/teamclaude) and the
241
+ [`@karpeleslab/teamclaude`](https://www.npmjs.com/package/@karpeleslab/teamclaude)
242
+ npm package. TeamClaude is **never** distributed as a downloadable binary
243
+ archive — be wary of soft-forks that bundle a `.zip` and tell you to extract and
244
+ run it. See [SECURITY.md](SECURITY.md) for details and how to report issues.
166
245
 
167
246
  ## License
168
247
 
169
- MIT
248
+ MIT — see [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karpeleslab/teamclaude",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Multi-account Claude proxy with automatic quota-based rotation",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -11,7 +11,9 @@
11
11
  "src/"
12
12
  ],
13
13
  "scripts": {
14
- "start": "node src/index.js"
14
+ "start": "node src/index.js",
15
+ "test": "node --test",
16
+ "lint": "eslint ."
15
17
  },
16
18
  "keywords": [
17
19
  "claude",
@@ -23,5 +25,8 @@
23
25
  "license": "MIT",
24
26
  "engines": {
25
27
  "node": ">=18.0.0"
28
+ },
29
+ "devDependencies": {
30
+ "eslint": "^9.0.0"
26
31
  }
27
32
  }
@@ -1,4 +1,13 @@
1
1
  import { refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
2
+ import { sameIdentity } from './identity.js';
3
+
4
+ // Quota fields that survive a restart: utilization levels and their reset
5
+ // windows, learned passively from upstream responses. Transient/derived state
6
+ // (probing, requalify, rateLimitedUntil) is intentionally excluded.
7
+ const PERSISTED_QUOTA_FIELDS = [
8
+ 'unified5h', 'unified7d', 'unified5hReset', 'unified7dReset', 'unifiedStatus',
9
+ 'tokensLimit', 'tokensRemaining', 'requestsLimit', 'requestsRemaining', 'resetsAt',
10
+ ];
2
11
 
3
12
  function emptyQuota() {
4
13
  return {
@@ -24,10 +33,17 @@ export class AccountManager {
24
33
  name: acct.name,
25
34
  type: acct.type,
26
35
  accountUuid: acct.accountUuid || null,
36
+ orgUuid: acct.orgUuid || null,
37
+ orgName: acct.orgName || null,
38
+ priority: acct.priority || 0,
39
+ disabled: acct.disabled || false,
27
40
  credential: acct.accessToken || acct.apiKey,
28
41
  refreshToken: acct.refreshToken || null,
29
42
  expiresAt: acct.expiresAt || null,
30
43
  status: 'active',
44
+ // No quota is known at startup, so start probing: the first response for
45
+ // an account reveals its weekly limit and triggers re-evaluation.
46
+ probing: true,
31
47
  quota: emptyQuota(),
32
48
  usage: {
33
49
  totalInputTokens: 0,
@@ -46,9 +62,26 @@ export class AccountManager {
46
62
  * Returns null if all accounts are exhausted.
47
63
  */
48
64
  getActiveAccount() {
65
+ // Clear expired quotas across all accounts and switch proactively if a
66
+ // session reset made a sooner-expiring account the better choice. This runs
67
+ // on every request so the behaviour holds without the TUI render loop.
68
+ this.refreshExpiredQuotas();
49
69
  const current = this.accounts[this.currentIndex];
70
+ // We just learned a probed account's weekly quota — re-evaluate which
71
+ // account is best now that its limit is known.
72
+ if (current && current.requalify) {
73
+ current.requalify = false;
74
+ const next = this._selectNext();
75
+ if (next) return next;
76
+ }
50
77
  if (this._isAvailable(current)) {
51
- return current;
78
+ // A strictly higher-priority (lower value) available account preempts a
79
+ // healthy current one. Within the same priority tier we stay put, so the
80
+ // common case (all accounts at the default priority 0) is unchanged and
81
+ // never thrashes — preemption only triggers when priorities differ.
82
+ const betterExists = this.accounts.some(a =>
83
+ this._isAvailable(a) && (a.priority || 0) < (current.priority || 0));
84
+ return betterExists ? this._selectNext() : current;
52
85
  }
53
86
  return this._selectNext();
54
87
  }
@@ -56,6 +89,9 @@ export class AccountManager {
56
89
  _isAvailable(account) {
57
90
  if (!account) return false;
58
91
 
92
+ // Manually disabled accounts are skipped entirely until re-enabled.
93
+ if (account.disabled) return false;
94
+
59
95
  // Check rate limit expiry
60
96
  if (account.status === 'throttled' && account.rateLimitedUntil) {
61
97
  if (Date.now() < account.rateLimitedUntil) return false;
@@ -70,21 +106,33 @@ export class AccountManager {
70
106
  return true;
71
107
  }
72
108
 
73
- _isNearQuota(account) {
109
+ /**
110
+ * Clear any quota counters whose reset time has passed. Cheap and safe to
111
+ * call frequently (e.g. from the TUI render loop) — once a counter is cleared
112
+ * it stays null until the next upstream response repopulates it, so the
113
+ * "reset" log fires at most once per window.
114
+ * @returns {{changed: boolean, session: boolean}} what was cleared.
115
+ */
116
+ _clearExpiredQuotas(account) {
74
117
  const q = account.quota;
75
118
  const now = Date.now();
119
+ let changed = false;
120
+ let session = false;
76
121
 
77
122
  // Clear expired unified quotas
78
123
  if (q.unified5h != null && q.unified5hReset && now >= q.unified5hReset) {
79
124
  console.log(`[TeamClaude] Account "${account.name}" session quota reset`);
80
125
  q.unified5h = null;
81
126
  q.unified5hReset = null;
127
+ changed = true;
128
+ session = true;
82
129
  }
83
130
  if (q.unified7d != null && q.unified7dReset && now >= q.unified7dReset) {
84
131
  console.log(`[TeamClaude] Account "${account.name}" weekly quota reset`);
85
132
  q.unified7d = null;
86
133
  q.unified7dReset = null;
87
134
  q.unifiedStatus = null;
135
+ changed = true;
88
136
  }
89
137
 
90
138
  // Clear expired standard quotas
@@ -94,7 +142,69 @@ export class AccountManager {
94
142
  q.requestsRemaining = null;
95
143
  q.requestsLimit = null;
96
144
  q.resetsAt = null;
145
+ changed = true;
146
+ }
147
+
148
+ return { changed, session };
149
+ }
150
+
151
+ /**
152
+ * Clear expired quotas across all accounts. Called from the display loop and
153
+ * the request path so a window expiry (e.g. the 5-hour session quota) resets
154
+ * the view instantly rather than waiting for the next request.
155
+ *
156
+ * When an account's session quota resets, it may have become the better
157
+ * choice — switch to it if its weekly limit expires sooner than the current
158
+ * account's (and it still has weekly quota), so we spend the quota closest to
159
+ * refreshing first.
160
+ */
161
+ refreshExpiredQuotas() {
162
+ let changed = false;
163
+ const sessionReset = [];
164
+ for (const account of this.accounts) {
165
+ const r = this._clearExpiredQuotas(account);
166
+ if (r.changed) changed = true;
167
+ if (r.session) sessionReset.push(account);
97
168
  }
169
+ if (sessionReset.length) this._switchOnSessionReset(sessionReset);
170
+ return changed;
171
+ }
172
+
173
+ /**
174
+ * Given accounts whose session quota just reset, switch to the one whose
175
+ * weekly limit expires soonest — but only if that is sooner than the current
176
+ * account's weekly limit and the account still has weekly quota to spend.
177
+ */
178
+ _switchOnSessionReset(candidates) {
179
+ const current = this.accounts[this.currentIndex];
180
+ // Need a known weekly reset on the current account to compare against;
181
+ // if it is unknown we are still probing it, so leave it alone.
182
+ if (!current || current.quota.unified7dReset == null) return;
183
+
184
+ let best = null;
185
+ let bestWeekly = current.quota.unified7dReset;
186
+ for (const acc of candidates) {
187
+ if (acc.index === this.currentIndex) continue;
188
+ if (!this._isAvailable(acc)) continue; // enough session & weekly quota left
189
+ // Don't demote to a lower-priority (higher value) account on a reset.
190
+ if ((acc.priority || 0) > (current.priority || 0)) continue;
191
+ const weekly = acc.quota.unified7dReset;
192
+ if (weekly == null) continue; // need a known weekly to compare
193
+ if (weekly < bestWeekly) {
194
+ bestWeekly = weekly;
195
+ best = acc;
196
+ }
197
+ }
198
+
199
+ if (best) {
200
+ this.currentIndex = best.index;
201
+ console.log(`[TeamClaude] Account "${best.name}" session quota reset and weekly expires sooner — switching to it`);
202
+ }
203
+ }
204
+
205
+ _isNearQuota(account) {
206
+ const q = account.quota;
207
+ this._clearExpiredQuotas(account);
98
208
 
99
209
  // Unified quotas (Claude Max) — utilization is already 0-1
100
210
  if (q.unified5h != null && q.unified5h >= this.switchThreshold) return true;
@@ -115,17 +225,47 @@ export class AccountManager {
115
225
  }
116
226
 
117
227
  _selectNext() {
118
- const startIndex = this.currentIndex;
119
-
120
- for (let i = 1; i <= this.accounts.length; i++) {
121
- const idx = (startIndex + i) % this.accounts.length;
122
- const account = this.accounts[idx];
228
+ // Selection order among available accounts:
229
+ // 1. lowest `priority` value (operator-controlled; default 0, lower = preferred)
230
+ // 2. then the account with no known weekly limit — using it lets us
231
+ // discover its quota
232
+ // 3. then the account whose weekly limit expires soonest: that quota is
233
+ // closest to refreshing, so spending it first preserves accounts whose
234
+ // weekly window resets further out.
235
+ // With all priorities at the default 0, this reduces to the original
236
+ // weekly-reset heuristic.
237
+ let best = null;
238
+ let bestPriority = Infinity;
239
+ let bestReset = Infinity;
240
+
241
+ for (let i = 0; i < this.accounts.length; i++) {
242
+ const account = this.accounts[i];
243
+ // _isAvailable filters out accounts at/above the switch threshold, so the
244
+ // soonest-expiring pick only ever lands on an account whose 5-hour quota
245
+ // is still below 98%.
246
+ if (!this._isAvailable(account)) continue;
247
+
248
+ const priority = account.priority || 0;
249
+ // Unknown weekly reset sorts first so we fill it in.
250
+ const weeklyReset = account.quota.unified7dReset || -Infinity;
251
+ if (priority < bestPriority ||
252
+ (priority === bestPriority && weeklyReset < bestReset)) {
253
+ bestPriority = priority;
254
+ bestReset = weeklyReset;
255
+ best = account;
256
+ }
257
+ }
123
258
 
124
- if (this._isAvailable(account)) {
125
- this.currentIndex = idx;
126
- console.log(`[TeamClaude] Switched to account "${account.name}"`);
127
- return account;
259
+ if (best) {
260
+ const switched = best.index !== this.currentIndex;
261
+ this.currentIndex = best.index;
262
+ // If we switched to an account whose weekly quota is still unknown, flag
263
+ // it so we re-evaluate once that quota is learned (see updateQuota).
264
+ best.probing = best.quota.unified7dReset == null;
265
+ if (switched) {
266
+ console.log(`[TeamClaude] Switched to account "${best.name}"`);
128
267
  }
268
+ return best;
129
269
  }
130
270
 
131
271
  // All accounts unavailable — find the one that resets soonest
@@ -173,6 +313,14 @@ export class AccountManager {
173
313
  if (r5h) account.quota.unified5hReset = parseInt(r5h, 10) * 1000;
174
314
  if (r7d) account.quota.unified7dReset = parseInt(r7d, 10) * 1000;
175
315
 
316
+ // We switched to this account to discover its weekly quota; now that we
317
+ // know it, flag for re-evaluation so selection can pick the best account.
318
+ if (account.probing && account.quota.unified7dReset != null) {
319
+ account.probing = false;
320
+ account.requalify = true;
321
+ console.log(`[TeamClaude] Learned weekly quota for "${account.name}", re-evaluating selection`);
322
+ }
323
+
176
324
  const uStatus = headers['anthropic-ratelimit-unified-status'];
177
325
  if (uStatus) account.quota.unifiedStatus = uStatus;
178
326
 
@@ -216,6 +364,22 @@ export class AccountManager {
216
364
  if (outputTokens) account.usage.totalOutputTokens += outputTokens;
217
365
  }
218
366
 
367
+ /**
368
+ * Enable or disable an account. A disabled account is skipped by rotation
369
+ * until re-enabled. Re-enabling also clears a stuck 'error' state (and any
370
+ * lingering rate-limit hold) so the account is retried immediately.
371
+ */
372
+ setDisabled(accountIndex, disabled) {
373
+ const account = this.accounts[accountIndex];
374
+ if (!account) return;
375
+ account.disabled = disabled;
376
+ if (!disabled && account.status === 'error') {
377
+ account.status = 'active';
378
+ account.rateLimitedUntil = null;
379
+ console.log(`[TeamClaude] Account "${account.name}" re-enabled — clearing error state`);
380
+ }
381
+ }
382
+
219
383
  /**
220
384
  * Mark an account as rate-limited for a given duration.
221
385
  */
@@ -252,7 +416,11 @@ export class AccountManager {
252
416
  this._onTokenRefresh?.(accountIndex, newTokens);
253
417
  } catch (err) {
254
418
  console.error(`[TeamClaude] Token refresh failed for "${account.name}": ${err.message}`);
255
- account.status = 'error';
419
+ // Only mark as error if the access token is actually expired;
420
+ // a failed proactive refresh shouldn't kill a still-valid token
421
+ if (!account.expiresAt || Date.now() >= account.expiresAt) {
422
+ account.status = 'error';
423
+ }
256
424
  } finally {
257
425
  account._refreshPromise = null;
258
426
  }
@@ -297,10 +465,16 @@ export class AccountManager {
297
465
  name: acctData.name,
298
466
  type: acctData.type,
299
467
  accountUuid: acctData.accountUuid || null,
468
+ orgUuid: acctData.orgUuid || null,
469
+ orgName: acctData.orgName || null,
470
+ priority: acctData.priority || 0,
471
+ disabled: acctData.disabled || false,
300
472
  credential: acctData.accessToken || acctData.apiKey,
301
473
  refreshToken: acctData.refreshToken || null,
302
474
  expiresAt: acctData.expiresAt || null,
303
475
  status: 'active',
476
+ // Unknown quota until the first response — probe it like startup accounts.
477
+ probing: true,
304
478
  quota: emptyQuota(),
305
479
  usage: { totalInputTokens: 0, totalOutputTokens: 0, totalRequests: 0, lastUsed: null },
306
480
  rateLimitedUntil: null,
@@ -322,6 +496,36 @@ export class AccountManager {
322
496
  }
323
497
  }
324
498
 
499
+ /**
500
+ * Serialize persistable quota state for all accounts (no credentials), keyed
501
+ * by account identity so it can be matched back after a restart.
502
+ */
503
+ exportQuotaState() {
504
+ return this.accounts.map(a => {
505
+ const quota = {};
506
+ for (const f of PERSISTED_QUOTA_FIELDS) quota[f] = a.quota[f];
507
+ return { accountUuid: a.accountUuid, orgUuid: a.orgUuid, orgName: a.orgName, name: a.name, quota };
508
+ });
509
+ }
510
+
511
+ /**
512
+ * Restore quota learned in a previous run. Matches saved entries to accounts
513
+ * by identity. Stale windows are not special-cased here — _clearExpiredQuotas
514
+ * wipes any restored window whose reset time has already passed on first use.
515
+ */
516
+ restoreQuotaState(saved) {
517
+ if (!Array.isArray(saved)) return;
518
+ for (const account of this.accounts) {
519
+ const match = saved.find(s => sameIdentity(s, account));
520
+ if (!match || !match.quota) continue;
521
+ for (const f of PERSISTED_QUOTA_FIELDS) {
522
+ if (match.quota[f] != null) account.quota[f] = match.quota[f];
523
+ }
524
+ // We already know this account's weekly window, so it isn't "probing".
525
+ if (account.quota.unified7dReset != null) account.probing = false;
526
+ }
527
+ }
528
+
325
529
  /**
326
530
  * Return a status summary of all accounts (safe to expose, no credentials).
327
531
  */
@@ -332,6 +536,9 @@ export class AccountManager {
332
536
  accounts: this.accounts.map(a => ({
333
537
  name: a.name,
334
538
  type: a.type,
539
+ orgName: a.orgName || null,
540
+ priority: a.priority || 0,
541
+ disabled: a.disabled || false,
335
542
  status: a.status,
336
543
  quota: { ...a.quota },
337
544
  usage: { ...a.usage },