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 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
@@ -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, { remoteAccess });
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 (session: ${best.usage.sessionPercent}%, weekly: ${best.usage.weeklyPercent}%${pri})`,
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 (session: ${best.usage.sessionPercent}%, weekly: ${best.usage.weeklyPercent}%)`,
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 higher of session or weekly.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nonstop",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Automatic Claude Code account switching on rate limits + Slack remote access",
5
5
  "type": "module",
6
6
  "bin": {
File without changes
@@ -0,0 +1 @@
1
+ {}