claude-nonstop 0.3.0 → 0.4.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/README.md +50 -1
- package/bin/claude-nonstop.js +67 -0
- package/lib/runner.js +146 -5
- package/lib/scorer.js +81 -5
- package/lib/usage.js +126 -1
- package/package.json +1 -1
- package/remote/data/.gitkeep +0 -0
- package/remote/data/channel-map.json +1 -0
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ On launch, claude-nonstop checks usage across all accounts and picks the one wit
|
|
|
45
45
|
|
|
46
46
|
| Command | Description |
|
|
47
47
|
|---------|-------------|
|
|
48
|
-
| `status` | Show usage with progress bars and reset times |
|
|
48
|
+
| `status` | Show usage with progress bars and reset times (Enterprise usage-based accounts show a spend bar instead) |
|
|
49
49
|
| `add <name>` | Add a new Claude account (opens browser for OAuth) |
|
|
50
50
|
| `remove <name>` | Remove an account |
|
|
51
51
|
| `list` | List accounts with auth status |
|
|
@@ -72,6 +72,53 @@ On launch, claude-nonstop checks usage across all accounts and picks the one wit
|
|
|
72
72
|
|
|
73
73
|
Any unrecognized arguments are passed through to `claude` directly. Use `-a <name>` to select a specific account.
|
|
74
74
|
|
|
75
|
+
## How Enterprise usage limits are tracked
|
|
76
|
+
|
|
77
|
+
Subscription accounts (Pro/Max) and Enterprise usage-based accounts are metered
|
|
78
|
+
differently, and claude-nonstop handles both transparently.
|
|
79
|
+
|
|
80
|
+
**Two kinds of meter.** Every account is checked against the same OAuth usage
|
|
81
|
+
endpoint (`GET /api/oauth/usage`), but the response shape differs:
|
|
82
|
+
|
|
83
|
+
- **Window-metered** (Pro/Max): the API returns rolling `five_hour` and
|
|
84
|
+
`seven_day` utilization percentages. `status` shows these as the 5-hour / 7-day
|
|
85
|
+
bars. This is the meter you hit mid-work.
|
|
86
|
+
- **Spend-metered** (Enterprise usage-based): the API returns `five_hour` and
|
|
87
|
+
`seven_day` as `null` and instead a `spend` block with `used`, `limit`, and
|
|
88
|
+
`percent` in USD (minor units / cents). `status` shows a dollar bar, e.g.
|
|
89
|
+
`spend: ████████░░ 65% ($649.13 / $1,000.00)`.
|
|
90
|
+
|
|
91
|
+
An account is treated as spend-metered **only when both rolling windows are
|
|
92
|
+
null**. Enterprise's plan is purely dollar-based; Pro/Max accounts also carry a
|
|
93
|
+
`spend` block (their extra-usage overage cap), but their real meter is the
|
|
94
|
+
rolling window, so they stay window-metered and their live session/weekly usage
|
|
95
|
+
is never hidden behind the overage number.
|
|
96
|
+
|
|
97
|
+
**The cap is always read live.** The spend limit is taken from the API's
|
|
98
|
+
`spend.limit` on every check — there is no cached or hardcoded cap — so if your
|
|
99
|
+
org's limit changes, claude-nonstop picks it up on the next `status` or account
|
|
100
|
+
selection. The percentage is computed locally from `used / limit` rather than
|
|
101
|
+
trusting the API's reported `percent`, which has been observed to briefly lag
|
|
102
|
+
during high-traffic periods.
|
|
103
|
+
|
|
104
|
+
**Kept up to date automatically.** Usage is re-fetched fresh on every `status`
|
|
105
|
+
call and every account selection (launch, switch, `use --best`), and the
|
|
106
|
+
preemption watcher re-polls every 5 minutes while running. There is no
|
|
107
|
+
separate polling daemon — freshness comes from fetching on demand. An Enterprise
|
|
108
|
+
account that is over its monthly cap (`spend.percent` ≥ 100%) folds into the same
|
|
109
|
+
effective-utilization logic as a rate-limited subscription account, so the scorer
|
|
110
|
+
stops routing to it and resumes after the monthly reset.
|
|
111
|
+
|
|
112
|
+
**Monthly reset.** Enterprise spend caps reset at **00:00 UTC on the 1st of each
|
|
113
|
+
calendar month** — a fixed, universal rule confirmed against Anthropic's official
|
|
114
|
+
Spend Limits API documentation (the same for every org, independent of the
|
|
115
|
+
subscription start date). The API does not expose the reset datetime, so
|
|
116
|
+
claude-nonstop computes it locally and displays it in US Pacific time, labeled
|
|
117
|
+
`(computed)` — e.g. `Resets: Jun 30, 5:00 PM PDT (computed)`. Note this is the
|
|
118
|
+
*spend* reset specifically; it is distinct from the rolling 5-hour/7-day rate
|
|
119
|
+
limits (which reset on their own rolling windows) and from Enterprise seat-fee
|
|
120
|
+
billing (charged on the contract anniversary, not the 1st).
|
|
121
|
+
|
|
75
122
|
## Install
|
|
76
123
|
|
|
77
124
|
The easiest way to install is to ask Claude Code:
|
|
@@ -201,6 +248,8 @@ This creates a tmux session named after the current directory, enables `--danger
|
|
|
201
248
|
|
|
202
249
|
**Multi-account switching** queries the Anthropic usage API for all accounts (~200ms), picks the one with the most headroom, then monitors Claude's output for rate limit messages in real-time. On detection: kill, migrate session files to the next account, resume with `claude --resume`.
|
|
203
250
|
|
|
251
|
+
**Preempting back to a higher-priority account.** Rate-limit switching only moves *down* the priority list — it reacts to a limit on the current account and never climbs back on its own. Without a counterpart, a long session that fell back to a last-resort account (e.g. a metered Enterprise account) would stay there for hours even after the preferred account's rolling window reset, because no rate limit fires to trigger a re-check. So while parked on a non-top-priority account, a background watcher re-polls usage every 5 minutes and, the moment a strictly higher-priority account drops below the "freed up" threshold (90% by default, kept under the 98% exhaustion cutoff so it never swaps onto a near-full account), it migrates the session back up. Preemptive swaps reuse the same migrate/resume path and don't count against the swap budget. The watcher only arms when you've set account priorities and at least one account outranks the current one; tune it with `CLAUDE_NONSTOP_PREEMPT_INTERVAL_MS` (poll interval in ms — set to `0` to disable) and `CLAUDE_NONSTOP_PREEMPT_THRESHOLD` (the freed-up percentage).
|
|
252
|
+
|
|
204
253
|
**Slack remote access** uses Claude Code [hooks](https://docs.anthropic.com/en/docs/claude-code/hooks) — `SessionStart` creates a Slack channel, `Stop` posts a completion summary. A separate webhook process connects via Slack Socket Mode and relays channel messages to tmux. The runner scrapes PTY output for tool activity and posts progress updates to Slack every ~10 seconds.
|
|
205
254
|
|
|
206
255
|
## Architecture
|
package/bin/claude-nonstop.js
CHANGED
|
@@ -449,6 +449,8 @@ async function cmdStatus() {
|
|
|
449
449
|
|
|
450
450
|
if (account.usage.error) {
|
|
451
451
|
console.log(` Usage: error (${account.usage.error})`);
|
|
452
|
+
} else if (account.usage.meterType === 'spend') {
|
|
453
|
+
renderSpendUsage(account.usage);
|
|
452
454
|
} else {
|
|
453
455
|
const sessionBar = makeBar(account.usage.sessionPercent);
|
|
454
456
|
const weeklyBar = makeBar(account.usage.weeklyPercent);
|
|
@@ -1677,3 +1679,68 @@ function formatResetTime(isoString) {
|
|
|
1677
1679
|
return isoString;
|
|
1678
1680
|
}
|
|
1679
1681
|
}
|
|
1682
|
+
|
|
1683
|
+
/**
|
|
1684
|
+
* Render an absolute reset datetime in US Pacific time. The monthly spend reset
|
|
1685
|
+
* is a fixed UTC instant (00:00 on the 1st), which the claude.ai UI shows in
|
|
1686
|
+
* Pacific — 5:00 PM PDT in summer, 4:00 PM PST in winter. Using a real IANA
|
|
1687
|
+
* zone (not a fixed offset) keeps the DST abbreviation correct year-round.
|
|
1688
|
+
* e.g. "Jun 30, 5:00 PM PDT".
|
|
1689
|
+
*/
|
|
1690
|
+
function formatAbsoluteReset(isoString) {
|
|
1691
|
+
try {
|
|
1692
|
+
const date = new Date(isoString);
|
|
1693
|
+
if (isNaN(date.getTime())) return isoString;
|
|
1694
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
1695
|
+
timeZone: 'America/Los_Angeles',
|
|
1696
|
+
month: 'short', day: 'numeric',
|
|
1697
|
+
hour: 'numeric', minute: '2-digit',
|
|
1698
|
+
timeZoneName: 'short',
|
|
1699
|
+
}).format(date);
|
|
1700
|
+
} catch {
|
|
1701
|
+
return isoString;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
/**
|
|
1706
|
+
* Format a minor-unit amount (e.g. cents) as a currency string using the
|
|
1707
|
+
* payload's own exponent, so non-USD / non-2-decimal currencies render right.
|
|
1708
|
+
* e.g. (64913, 'USD', 2) -> "$649.13"; (5000, 'JPY', 0) -> "¥5000".
|
|
1709
|
+
*/
|
|
1710
|
+
function formatSpendAmount(amountMinor, currency = 'USD', exponent = 2) {
|
|
1711
|
+
if (typeof amountMinor !== 'number') return '—';
|
|
1712
|
+
const exp = typeof exponent === 'number' ? exponent : 2;
|
|
1713
|
+
const major = amountMinor / Math.pow(10, exp);
|
|
1714
|
+
try {
|
|
1715
|
+
return new Intl.NumberFormat('en-US', {
|
|
1716
|
+
style: 'currency',
|
|
1717
|
+
currency: currency || 'USD',
|
|
1718
|
+
minimumFractionDigits: exp,
|
|
1719
|
+
maximumFractionDigits: exp,
|
|
1720
|
+
}).format(major);
|
|
1721
|
+
} catch {
|
|
1722
|
+
// Unknown currency code — fall back to a plain number with the code.
|
|
1723
|
+
return `${major.toFixed(exp)} ${currency || ''}`.trim();
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/**
|
|
1728
|
+
* Render usage for a spend-metered (Enterprise usage-based) account: a dollar
|
|
1729
|
+
* bar of spend-against-cap plus the locally-computed monthly reset. A null
|
|
1730
|
+
* limit means the org has no spend cap (unlimited).
|
|
1731
|
+
*/
|
|
1732
|
+
function renderSpendUsage(usage) {
|
|
1733
|
+
const { spendUsedMinor, spendLimitMinor, spendCurrency, spendExponent, spendPercent, spendResetsAt } = usage;
|
|
1734
|
+
const used = formatSpendAmount(spendUsedMinor, spendCurrency, spendExponent);
|
|
1735
|
+
|
|
1736
|
+
if (spendLimitMinor == null) {
|
|
1737
|
+
console.log(` spend: unlimited (${used} used)`);
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
const limit = formatSpendAmount(spendLimitMinor, spendCurrency, spendExponent);
|
|
1742
|
+
console.log(` spend: ${makeBar(spendPercent)} ${spendPercent}% (${used} / ${limit})`);
|
|
1743
|
+
if (spendResetsAt) {
|
|
1744
|
+
console.log(` Resets: ${formatAbsoluteReset(spendResetsAt)} (computed)`);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
package/lib/runner.js
CHANGED
|
@@ -20,7 +20,7 @@ import fs from 'node:fs';
|
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import { readCredentials } from './keychain.js';
|
|
22
22
|
import { checkAllUsage } from './usage.js';
|
|
23
|
-
import { pickBestAccount, effectiveUtilization } from './scorer.js';
|
|
23
|
+
import { pickBestAccount, effectiveUtilization, pickPreemptAccount, PREEMPT_THRESHOLD } from './scorer.js';
|
|
24
24
|
import { findLatestSession, migrateSession } from './session.js';
|
|
25
25
|
import { reauthExpiredAccounts } from './reauth.js';
|
|
26
26
|
import { CONFIG_DIR } from './config.js';
|
|
@@ -51,6 +51,12 @@ const KILL_ESCALATION_DELAY = 3000;
|
|
|
51
51
|
const EXHAUSTION_THRESHOLD = 99;
|
|
52
52
|
/** Maximum sleep duration when waiting for a rate limit reset (6 hours). */
|
|
53
53
|
const MAX_SLEEP_MS = 6 * 60 * 60 * 1000;
|
|
54
|
+
/**
|
|
55
|
+
* How often the background watcher re-checks usage while parked on a
|
|
56
|
+
* lower-priority account, to see if a higher-priority account has freed up
|
|
57
|
+
* (default 5 minutes). Set CLAUDE_NONSTOP_PREEMPT_INTERVAL_MS=0 to disable.
|
|
58
|
+
*/
|
|
59
|
+
const PREEMPT_POLL_INTERVAL_MS = 5 * 60 * 1000;
|
|
54
60
|
|
|
55
61
|
// ─── ANSI Stripping ────────────────────────────────────────────────────────
|
|
56
62
|
|
|
@@ -72,6 +78,37 @@ function spawnHookNotify(type, data) {
|
|
|
72
78
|
child.unref();
|
|
73
79
|
}
|
|
74
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the background-watcher poll interval (ms) from the environment,
|
|
83
|
+
* falling back to the default. A value of 0 (or negative/invalid) disables
|
|
84
|
+
* the watcher entirely.
|
|
85
|
+
*
|
|
86
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
87
|
+
* @returns {number} interval in ms, or 0 if disabled
|
|
88
|
+
*/
|
|
89
|
+
function resolvePreemptIntervalMs(env = process.env) {
|
|
90
|
+
const raw = env.CLAUDE_NONSTOP_PREEMPT_INTERVAL_MS;
|
|
91
|
+
if (raw === undefined || raw === '') return PREEMPT_POLL_INTERVAL_MS;
|
|
92
|
+
const n = Number(raw);
|
|
93
|
+
if (!Number.isFinite(n) || n <= 0) return 0;
|
|
94
|
+
return n;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the "freed up" utilization threshold (%) from the environment,
|
|
99
|
+
* falling back to the scorer default.
|
|
100
|
+
*
|
|
101
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
102
|
+
* @returns {number} threshold percent
|
|
103
|
+
*/
|
|
104
|
+
function resolvePreemptThreshold(env = process.env) {
|
|
105
|
+
const raw = env.CLAUDE_NONSTOP_PREEMPT_THRESHOLD;
|
|
106
|
+
if (raw === undefined || raw === '') return PREEMPT_THRESHOLD;
|
|
107
|
+
const n = Number(raw);
|
|
108
|
+
if (!Number.isFinite(n) || n < 0 || n > 100) return PREEMPT_THRESHOLD;
|
|
109
|
+
return n;
|
|
110
|
+
}
|
|
111
|
+
|
|
75
112
|
/**
|
|
76
113
|
* Find the earliest reset time across all non-excluded accounts.
|
|
77
114
|
*
|
|
@@ -205,7 +242,54 @@ export async function run(claudeArgs, selectedAccount, allAccounts, options = {}
|
|
|
205
242
|
}
|
|
206
243
|
|
|
207
244
|
while (swapCount <= maxSwaps) {
|
|
208
|
-
const result = await runOnce(claudeArgs, currentAccount, sessionId, {
|
|
245
|
+
const result = await runOnce(claudeArgs, currentAccount, sessionId, {
|
|
246
|
+
remoteAccess,
|
|
247
|
+
allAccounts,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Background watcher preempted: a higher-priority account freed up while we
|
|
251
|
+
// were parked on a lower-priority one. Treat like a swap, but force priority
|
|
252
|
+
// selection and don't charge it against the swap budget (it's a recovery,
|
|
253
|
+
// not a rate-limit retreat).
|
|
254
|
+
if (result.preemptDetected && result.preemptTo) {
|
|
255
|
+
const cwd = process.cwd();
|
|
256
|
+
const session = findLatestSession(currentAccount.configDir, cwd);
|
|
257
|
+
const nextAccount = result.preemptTo;
|
|
258
|
+
console.error(`\n[claude-nonstop] Switching to "${nextAccount.name}" (${result.preemptReason})`);
|
|
259
|
+
|
|
260
|
+
if (remoteAccess) {
|
|
261
|
+
spawnHookNotify('account-switch', {
|
|
262
|
+
session_id: sessionId || null,
|
|
263
|
+
cwd,
|
|
264
|
+
from_account: currentAccount.name,
|
|
265
|
+
to_account: nextAccount.name,
|
|
266
|
+
reason: result.preemptReason,
|
|
267
|
+
swap_count: swapCount,
|
|
268
|
+
max_swaps: maxSwaps,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (session) {
|
|
273
|
+
const migration = migrateSession(currentAccount.configDir, nextAccount.configDir, cwd, session.sessionId);
|
|
274
|
+
if (migration.success) {
|
|
275
|
+
sessionId = session.sessionId;
|
|
276
|
+
console.error(`[claude-nonstop] Session ${sessionId} migrated successfully`);
|
|
277
|
+
} else {
|
|
278
|
+
console.error(`[claude-nonstop] Session migration failed: ${migration.error}`);
|
|
279
|
+
console.error('[claude-nonstop] Starting fresh session on new account');
|
|
280
|
+
sessionId = null;
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
sessionId = null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (sessionId) {
|
|
287
|
+
claudeArgs = buildResumeArgs(claudeArgs, sessionId, RATE_LIMIT_CONTINUE_MSG);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
currentAccount = nextAccount;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
209
293
|
|
|
210
294
|
if (result.exitCode !== null && !result.rateLimitDetected) {
|
|
211
295
|
// Normal exit — propagate the exit code
|
|
@@ -382,9 +466,11 @@ export async function run(claudeArgs, selectedAccount, allAccounts, options = {}
|
|
|
382
466
|
}
|
|
383
467
|
|
|
384
468
|
/**
|
|
385
|
-
* Run Claude once, monitoring for rate limits
|
|
469
|
+
* Run Claude once, monitoring for rate limits and (when a higher-priority
|
|
470
|
+
* account can outrank the current one) running a background watcher that
|
|
471
|
+
* preempts to that account once it frees up.
|
|
386
472
|
*
|
|
387
|
-
* @returns {Promise<{ exitCode: number|null, rateLimitDetected: boolean, resetTime: string|null, sessionId: string|null }>}
|
|
473
|
+
* @returns {Promise<{ exitCode: number|null, rateLimitDetected: boolean, resetTime: string|null, sessionId: string|null, preemptDetected: boolean, preemptTo: object|null, preemptReason: string|null }>}
|
|
388
474
|
*/
|
|
389
475
|
function runOnce(claudeArgs, account, existingSessionId, options = {}) {
|
|
390
476
|
return new Promise((resolve) => {
|
|
@@ -426,6 +512,54 @@ function runOnce(claudeArgs, account, existingSessionId, options = {}) {
|
|
|
426
512
|
let resetTime = null;
|
|
427
513
|
let outputBuffer = '';
|
|
428
514
|
|
|
515
|
+
// Background watcher: while parked on a lower-priority account, periodically
|
|
516
|
+
// check whether a higher-priority account has freed up and, if so, preempt
|
|
517
|
+
// (kill child -> caller migrates session -> resumes on the better account).
|
|
518
|
+
let preemptDetected = false;
|
|
519
|
+
let preemptTo = null;
|
|
520
|
+
let preemptReason = null;
|
|
521
|
+
let watcherBusy = false;
|
|
522
|
+
const allAccounts = options.allAccounts || [];
|
|
523
|
+
const pollMs = resolvePreemptIntervalMs();
|
|
524
|
+
const preemptThreshold = resolvePreemptThreshold();
|
|
525
|
+
// Only worth polling when the current account isn't already top priority and
|
|
526
|
+
// there's at least one account that could outrank it.
|
|
527
|
+
const currentPriority = allAccounts.find(a => a.name === account.name)?.priority;
|
|
528
|
+
const watcherEnabled = pollMs > 0
|
|
529
|
+
&& currentPriority != null
|
|
530
|
+
&& allAccounts.some(a => a.priority != null && a.priority < currentPriority);
|
|
531
|
+
|
|
532
|
+
async function pollForHigherPriority() {
|
|
533
|
+
if (watcherBusy || rateLimitDetected || preemptDetected) return;
|
|
534
|
+
watcherBusy = true;
|
|
535
|
+
try {
|
|
536
|
+
const withTokens = allAccounts
|
|
537
|
+
.map(a => ({ ...a, token: readCredentials(a.configDir).token }))
|
|
538
|
+
.filter(a => a.token);
|
|
539
|
+
const withUsage = await checkAllUsage(withTokens);
|
|
540
|
+
if (rateLimitDetected || preemptDetected) return; // raced with another path
|
|
541
|
+
const choice = pickPreemptAccount(withUsage, account.name, { threshold: preemptThreshold });
|
|
542
|
+
if (choice) {
|
|
543
|
+
preemptDetected = true;
|
|
544
|
+
preemptTo = choice.account;
|
|
545
|
+
preemptReason = choice.reason;
|
|
546
|
+
child.kill('SIGTERM');
|
|
547
|
+
setTimeout(() => {
|
|
548
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
549
|
+
}, KILL_ESCALATION_DELAY);
|
|
550
|
+
}
|
|
551
|
+
} catch {
|
|
552
|
+
// Usage check failed (network/keychain) — skip this tick, try again next.
|
|
553
|
+
} finally {
|
|
554
|
+
watcherBusy = false;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const watcherTimer = watcherEnabled
|
|
559
|
+
? setInterval(pollForHigherPriority, pollMs)
|
|
560
|
+
: null;
|
|
561
|
+
if (watcherTimer && typeof watcherTimer.unref === 'function') watcherTimer.unref();
|
|
562
|
+
|
|
429
563
|
child.onData((data) => {
|
|
430
564
|
process.stdout.write(data);
|
|
431
565
|
|
|
@@ -460,6 +594,8 @@ function runOnce(claudeArgs, account, existingSessionId, options = {}) {
|
|
|
460
594
|
if (cleaned) return;
|
|
461
595
|
cleaned = true;
|
|
462
596
|
|
|
597
|
+
if (watcherTimer) clearInterval(watcherTimer);
|
|
598
|
+
|
|
463
599
|
for (const sig of signals) {
|
|
464
600
|
process.removeListener(sig, signalHandlers[sig]);
|
|
465
601
|
}
|
|
@@ -474,7 +610,7 @@ function runOnce(claudeArgs, account, existingSessionId, options = {}) {
|
|
|
474
610
|
|
|
475
611
|
for (const sig of signals) {
|
|
476
612
|
const handler = () => {
|
|
477
|
-
if (!rateLimitDetected) {
|
|
613
|
+
if (!rateLimitDetected && !preemptDetected) {
|
|
478
614
|
try { child.kill(sig); } catch {}
|
|
479
615
|
}
|
|
480
616
|
};
|
|
@@ -491,6 +627,9 @@ function runOnce(claudeArgs, account, existingSessionId, options = {}) {
|
|
|
491
627
|
rateLimitDetected,
|
|
492
628
|
resetTime,
|
|
493
629
|
sessionId: existingSessionId,
|
|
630
|
+
preemptDetected,
|
|
631
|
+
preemptTo,
|
|
632
|
+
preemptReason,
|
|
494
633
|
});
|
|
495
634
|
});
|
|
496
635
|
});
|
|
@@ -563,4 +702,6 @@ export {
|
|
|
563
702
|
RATE_LIMIT_CONTINUE_MSG, FLAGS_WITH_VALUES,
|
|
564
703
|
findEarliestReset, formatDuration, sleep, deactivateStaleChannels,
|
|
565
704
|
EXHAUSTION_THRESHOLD, MAX_SLEEP_MS,
|
|
705
|
+
resolvePreemptIntervalMs, resolvePreemptThreshold,
|
|
706
|
+
PREEMPT_POLL_INTERVAL_MS,
|
|
566
707
|
};
|
package/lib/scorer.js
CHANGED
|
@@ -12,6 +12,17 @@
|
|
|
12
12
|
|
|
13
13
|
const PRIORITY_THRESHOLD = 98;
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Short human-readable usage descriptor for log/reason strings. Spend-metered
|
|
17
|
+
* accounts report their dollar utilization; window accounts report session/weekly.
|
|
18
|
+
*/
|
|
19
|
+
function describeUsage(usage) {
|
|
20
|
+
if (usage?.meterType === 'spend') {
|
|
21
|
+
return `spend: ${usage.spendPercent ?? 0}%`;
|
|
22
|
+
}
|
|
23
|
+
return `session: ${usage?.sessionPercent ?? 0}%, weekly: ${usage?.weeklyPercent ?? 0}%`;
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
/**
|
|
16
27
|
* Pick the best account from a list of accounts with usage data.
|
|
17
28
|
*
|
|
@@ -59,7 +70,7 @@ export function pickBestAccount(accounts, excludeName, options = {}) {
|
|
|
59
70
|
|
|
60
71
|
return {
|
|
61
72
|
account: best,
|
|
62
|
-
reason: `priority selection (
|
|
73
|
+
reason: `priority selection (${describeUsage(best.usage)}${pri})`,
|
|
63
74
|
};
|
|
64
75
|
}
|
|
65
76
|
|
|
@@ -74,7 +85,7 @@ export function pickBestAccount(accounts, excludeName, options = {}) {
|
|
|
74
85
|
|
|
75
86
|
return {
|
|
76
87
|
account: best,
|
|
77
|
-
reason: `lowest utilization (
|
|
88
|
+
reason: `lowest utilization (${describeUsage(best.usage)})`,
|
|
78
89
|
};
|
|
79
90
|
}
|
|
80
91
|
|
|
@@ -90,11 +101,76 @@ export function pickByPriority(accounts) {
|
|
|
90
101
|
}
|
|
91
102
|
|
|
92
103
|
/**
|
|
93
|
-
* Calculate effective utilization — the
|
|
104
|
+
* Calculate effective utilization — the highest of session, weekly, or spend.
|
|
105
|
+
*
|
|
106
|
+
* Spend-metered (Enterprise usage-based) accounts have null session/weekly
|
|
107
|
+
* windows, so their utilization is driven by spendPercent. Folding it into the
|
|
108
|
+
* same max() means an Enterprise account that is over its monthly dollar cap
|
|
109
|
+
* (spendPercent >= 100) is treated as exhausted by every selection path —
|
|
110
|
+
* routing, priority, and preemption — with no other changes needed.
|
|
94
111
|
*/
|
|
95
112
|
export function effectiveUtilization(usage) {
|
|
96
113
|
if (!usage) return 100;
|
|
97
|
-
return Math.max(usage.sessionPercent || 0, usage.weeklyPercent || 0);
|
|
114
|
+
return Math.max(usage.sessionPercent || 0, usage.weeklyPercent || 0, usage.spendPercent || 0);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Default utilization (%) below which a higher-priority account is considered
|
|
119
|
+
* "freed up" enough to preempt the current lower-priority account.
|
|
120
|
+
* Kept below PRIORITY_THRESHOLD so we never swap back to an account that the
|
|
121
|
+
* normal switch logic would immediately reject as near-exhausted.
|
|
122
|
+
*/
|
|
123
|
+
const PREEMPT_THRESHOLD = 90;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Decide whether to preempt the current account in favor of a higher-priority
|
|
127
|
+
* one that has freed up. Used by the background watcher so a last-resort account
|
|
128
|
+
* (e.g. priority 4) is abandoned as soon as priorities 1–N recover.
|
|
129
|
+
*
|
|
130
|
+
* A swap is recommended only when some account with a STRICTLY lower priority
|
|
131
|
+
* number than the current account has effective utilization below `threshold`.
|
|
132
|
+
* Among qualifying accounts, the lowest priority number wins (utilization breaks
|
|
133
|
+
* ties), matching pickByPriority semantics.
|
|
134
|
+
*
|
|
135
|
+
* Returns null when no swap is warranted: current account has no priority, is
|
|
136
|
+
* already top priority, has a usage error, or no higher-priority account is
|
|
137
|
+
* sufficiently free.
|
|
138
|
+
*
|
|
139
|
+
* @param {Array<{name, usage, token?, priority?}>} accounts - accounts with usage
|
|
140
|
+
* @param {string} currentName - the account currently in use
|
|
141
|
+
* @param {object} [options]
|
|
142
|
+
* @param {number} [options.threshold=PREEMPT_THRESHOLD] - free-enough cutoff (%)
|
|
143
|
+
* @returns {{ account: object, reason: string } | null}
|
|
144
|
+
*/
|
|
145
|
+
export function pickPreemptAccount(accounts, currentName, options = {}) {
|
|
146
|
+
const threshold = options.threshold ?? PREEMPT_THRESHOLD;
|
|
147
|
+
const current = accounts.find(a => a.name === currentName);
|
|
148
|
+
// Only preempt away from an account that has a priority. A null/undefined
|
|
149
|
+
// priority is treated as lowest rank, but nothing can outrank "lowest" in a
|
|
150
|
+
// way that matters here, so there is no higher-priority target to jump to.
|
|
151
|
+
if (!current || current.priority == null) return null;
|
|
152
|
+
|
|
153
|
+
const candidates = accounts.filter(a => {
|
|
154
|
+
if (a.name === currentName) return false;
|
|
155
|
+
if (!a.token) return false;
|
|
156
|
+
if (a.usage?.error) return false;
|
|
157
|
+
if (a.priority == null) return false;
|
|
158
|
+
if (a.priority >= current.priority) return false; // must outrank current
|
|
159
|
+
return effectiveUtilization(a.usage) < threshold;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (candidates.length === 0) return null;
|
|
163
|
+
|
|
164
|
+
candidates.sort((a, b) => {
|
|
165
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
166
|
+
return effectiveUtilization(a.usage) - effectiveUtilization(b.usage);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const best = candidates[0];
|
|
170
|
+
return {
|
|
171
|
+
account: best,
|
|
172
|
+
reason: `higher-priority account freed up (now priority ${best.priority}, was on ${currentName} priority ${current.priority}; ${describeUsage(best.usage)})`,
|
|
173
|
+
};
|
|
98
174
|
}
|
|
99
175
|
|
|
100
|
-
export { PRIORITY_THRESHOLD };
|
|
176
|
+
export { PRIORITY_THRESHOLD, PREEMPT_THRESHOLD };
|
package/lib/usage.js
CHANGED
|
@@ -19,11 +19,126 @@ export function normalizePercent(value) {
|
|
|
19
19
|
return Math.round(value);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Compute the next monthly spend-limit reset instant.
|
|
24
|
+
*
|
|
25
|
+
* Enterprise usage-based orgs reset their spend cap on the calendar-month
|
|
26
|
+
* boundary at 00:00 UTC on the 1st (NOT the subscription anniversary). The
|
|
27
|
+
* OAuth usage API does not expose this datetime, so we compute it locally.
|
|
28
|
+
* The same instant renders as 5:00 PM PDT in summer and 4:00 PM PST in winter
|
|
29
|
+
* — that DST difference is handled at display time via a real IANA zone.
|
|
30
|
+
*
|
|
31
|
+
* Why this is safe to hardcode (researched + cross-validated 2026-06-17):
|
|
32
|
+
* Anthropic's official Spend Limits API doc states verbatim that "monthly spend
|
|
33
|
+
* resets at 00:00 UTC on the first of each calendar month" — a fixed, universal
|
|
34
|
+
* rule, the same for every org, independent of when the subscription started.
|
|
35
|
+
* This is the SPEND/usage-credit reset specifically. Do not conflate it with:
|
|
36
|
+
* - rolling rate limits (5-hour / 7-day windows), which reset on a per-account
|
|
37
|
+
* rolling basis, NOT on a calendar or billing boundary; or
|
|
38
|
+
* - seat-fee billing, which is charged on the contract anniversary, NOT the 1st.
|
|
39
|
+
* The API doc notes `period` is an "open set" (only `monthly` exists today), so
|
|
40
|
+
* if Anthropic ever adds a non-monthly cadence this assumption would need a
|
|
41
|
+
* revisit — which is why the reset is surfaced to users labeled "(computed)".
|
|
42
|
+
*
|
|
43
|
+
* @param {Date} [now] - reference instant (defaults to current time)
|
|
44
|
+
* @returns {Date} first day of the next calendar month at 00:00:00.000 UTC
|
|
45
|
+
*/
|
|
46
|
+
export function nextMonthlyResetUTC(now = new Date()) {
|
|
47
|
+
// Date.UTC rolls month 12 into the next year automatically.
|
|
48
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Empty/disabled spend fields — merged into every usage result so callers can
|
|
53
|
+
* treat the shape uniformly regardless of meter type.
|
|
54
|
+
*/
|
|
55
|
+
const NO_SPEND = {
|
|
56
|
+
meterType: 'window',
|
|
57
|
+
spendUsedMinor: null,
|
|
58
|
+
spendLimitMinor: null,
|
|
59
|
+
spendCurrency: null,
|
|
60
|
+
spendExponent: null,
|
|
61
|
+
spendPercent: 0,
|
|
62
|
+
spendResetsAt: null,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Detect and normalize a spend-metered (Enterprise usage-based) payload.
|
|
67
|
+
*
|
|
68
|
+
* Precedence: the `spend` block is authoritative; the older `extra_usage`
|
|
69
|
+
* block is the fallback when `spend` is absent. Returns null when the payload
|
|
70
|
+
* is window-metered (no usable spend signal), so the caller keeps existing
|
|
71
|
+
* behavior.
|
|
72
|
+
*
|
|
73
|
+
* Percent is computed locally from used/limit and preferred over the reported
|
|
74
|
+
* `spend.percent`, which has been observed to lag (a transient stale reading
|
|
75
|
+
* returned 100% while used/limit said 65%). A null limit means UNLIMITED and a
|
|
76
|
+
* zero limit means included-only — both yield 0% and are never "exhausted".
|
|
77
|
+
*
|
|
78
|
+
* @param {object} data - raw usage API payload
|
|
79
|
+
* @returns {object|null} normalized spend fields, or null if window-metered
|
|
80
|
+
*/
|
|
81
|
+
export function parseSpend(data) {
|
|
82
|
+
// A spend block is the PRIMARY meter only when the rolling windows are absent
|
|
83
|
+
// (true Enterprise usage-based orgs return five_hour/seven_day = null). Max and
|
|
84
|
+
// Pro accounts also carry a spend block, but it is just their extra-usage
|
|
85
|
+
// overage cap — their real meter is the rolling window, so we must NOT flip
|
|
86
|
+
// them to spend-metering or we would hide live session/weekly usage.
|
|
87
|
+
const windowMetered = data?.five_hour != null || data?.seven_day != null;
|
|
88
|
+
if (windowMetered) return null;
|
|
89
|
+
|
|
90
|
+
const spend = data?.spend;
|
|
91
|
+
const extra = data?.extra_usage;
|
|
92
|
+
|
|
93
|
+
let usedMinor = null;
|
|
94
|
+
let limitMinor = null;
|
|
95
|
+
let currency = null;
|
|
96
|
+
let exponent = null;
|
|
97
|
+
let reportedPercent = null;
|
|
98
|
+
|
|
99
|
+
if (spend && (spend.used || spend.limit)) {
|
|
100
|
+
usedMinor = typeof spend.used?.amount_minor === 'number' ? spend.used.amount_minor : null;
|
|
101
|
+
limitMinor = typeof spend.limit?.amount_minor === 'number' ? spend.limit.amount_minor : null;
|
|
102
|
+
currency = spend.used?.currency ?? spend.limit?.currency ?? null;
|
|
103
|
+
exponent = spend.used?.exponent ?? spend.limit?.exponent ?? null;
|
|
104
|
+
reportedPercent = typeof spend.percent === 'number' ? spend.percent : null;
|
|
105
|
+
} else if (extra && extra.is_enabled && typeof extra.monthly_limit === 'number') {
|
|
106
|
+
usedMinor = typeof extra.used_credits === 'number' ? Math.round(extra.used_credits) : null;
|
|
107
|
+
limitMinor = extra.monthly_limit;
|
|
108
|
+
currency = extra.currency ?? null;
|
|
109
|
+
exponent = typeof extra.decimal_places === 'number' ? extra.decimal_places : null;
|
|
110
|
+
reportedPercent = typeof extra.utilization === 'number' ? extra.utilization : null;
|
|
111
|
+
} else {
|
|
112
|
+
return null; // window-metered
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let percent;
|
|
116
|
+
if (limitMinor == null || limitMinor === 0) {
|
|
117
|
+
// Unlimited (null) or included-only (0): no meaningful ratio; never over budget.
|
|
118
|
+
percent = 0;
|
|
119
|
+
} else if (usedMinor != null) {
|
|
120
|
+
percent = Math.min(100, normalizePercent((usedMinor / limitMinor) * 100));
|
|
121
|
+
} else {
|
|
122
|
+
// used missing but limit present: fall back to the reported percent.
|
|
123
|
+
percent = normalizePercent(reportedPercent ?? 0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
meterType: 'spend',
|
|
128
|
+
spendUsedMinor: usedMinor,
|
|
129
|
+
spendLimitMinor: limitMinor,
|
|
130
|
+
spendCurrency: currency,
|
|
131
|
+
spendExponent: exponent,
|
|
132
|
+
spendPercent: percent,
|
|
133
|
+
spendResetsAt: nextMonthlyResetUTC().toISOString(),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
22
137
|
/**
|
|
23
138
|
* Check usage for a single account token.
|
|
24
139
|
*
|
|
25
140
|
* @param {string} token - OAuth access token
|
|
26
|
-
* @returns {Promise<{sessionPercent: number, weeklyPercent: number, sessionResetsAt: string|null, weeklyResetsAt: string|null, error: string|null}>}
|
|
141
|
+
* @returns {Promise<{sessionPercent: number, weeklyPercent: number, sessionResetsAt: string|null, weeklyResetsAt: string|null, meterType: string, spendUsedMinor: number|null, spendLimitMinor: number|null, spendCurrency: string|null, spendExponent: number|null, spendPercent: number, spendResetsAt: string|null, error: string|null}>}
|
|
27
142
|
*/
|
|
28
143
|
export async function checkUsage(token) {
|
|
29
144
|
try {
|
|
@@ -49,12 +164,19 @@ export async function checkUsage(token) {
|
|
|
49
164
|
weeklyPercent: 0,
|
|
50
165
|
sessionResetsAt: null,
|
|
51
166
|
weeklyResetsAt: null,
|
|
167
|
+
...NO_SPEND,
|
|
52
168
|
error: `HTTP ${res.status}`,
|
|
53
169
|
};
|
|
54
170
|
}
|
|
55
171
|
|
|
56
172
|
const data = await res.json();
|
|
57
173
|
|
|
174
|
+
// Enterprise usage-based orgs are spend-metered: five_hour/seven_day are
|
|
175
|
+
// null and a `spend` (or `extra_usage`) block carries the dollar meter.
|
|
176
|
+
// parseSpend returns null for window-metered accounts, so we fall back to
|
|
177
|
+
// the existing window fields then.
|
|
178
|
+
const spend = parseSpend(data) ?? NO_SPEND;
|
|
179
|
+
|
|
58
180
|
// New nested format: { five_hour: { utilization: N, resets_at: "..." }, seven_day: { ... } }
|
|
59
181
|
if (data.five_hour !== undefined || data.seven_day !== undefined) {
|
|
60
182
|
return {
|
|
@@ -62,6 +184,7 @@ export async function checkUsage(token) {
|
|
|
62
184
|
weeklyPercent: normalizePercent(data.seven_day?.utilization ?? 0),
|
|
63
185
|
sessionResetsAt: data.five_hour?.resets_at ?? null,
|
|
64
186
|
weeklyResetsAt: data.seven_day?.resets_at ?? null,
|
|
187
|
+
...spend,
|
|
65
188
|
error: null,
|
|
66
189
|
};
|
|
67
190
|
}
|
|
@@ -72,6 +195,7 @@ export async function checkUsage(token) {
|
|
|
72
195
|
weeklyPercent: normalizePercent(data.seven_day_utilization ?? 0),
|
|
73
196
|
sessionResetsAt: data.five_hour_reset_at ?? null,
|
|
74
197
|
weeklyResetsAt: data.seven_day_reset_at ?? null,
|
|
198
|
+
...spend,
|
|
75
199
|
error: null,
|
|
76
200
|
};
|
|
77
201
|
} catch (error) {
|
|
@@ -80,6 +204,7 @@ export async function checkUsage(token) {
|
|
|
80
204
|
weeklyPercent: 0,
|
|
81
205
|
sessionResetsAt: null,
|
|
82
206
|
weeklyResetsAt: null,
|
|
207
|
+
...NO_SPEND,
|
|
83
208
|
error: error.name === 'AbortError' ? 'timeout' : error.message,
|
|
84
209
|
};
|
|
85
210
|
}
|
package/package.json
CHANGED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|