maxpool 1.5.86 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.5.86",
3
+ "version": "1.6.0",
4
4
  "description": "Multi-account Claude Code proxy with adaptive, rate-aware load balancing across Claude accounts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -44,4 +44,4 @@
44
44
  "eslint": "^9.39.5",
45
45
  "git-cliff": "2.13.1"
46
46
  }
47
- }
47
+ }
@@ -1,4 +1,5 @@
1
1
  import { refreshAccessToken, isTokenExpiringSoon, modelFamily, tokenFingerprint } from './oauth.js';
2
+ import { peakWindowState, DEFAULT_PEAK_CAP } from './peak-window.js';
2
3
 
3
4
  // Bounded re-poll hold for an account blocked ONLY by a transient, self-clearing
4
5
  // condition whose exact recovery time is unknown: (a) a weekly-critical account
@@ -194,6 +195,14 @@ const MAX_FAILED_PROBES = 4;
194
195
  // day rather than never.
195
196
  const PROBE_FAILURE_ALERT_AT = 20;
196
197
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
198
+ // Peak-hour governance (2026-08-18). A peak TIER, added to _effectivePriority after the
199
+ // mode layer: tier 1 = provider inside its peak window (de-prefer), tier 2 = also over
200
+ // its peakCap (last-resort). A STRIDE, not an additive constant, so a peak account ranks
201
+ // below every non-peak account BY CONSTRUCTION even against a hand-set `priority: 500`,
202
+ // while intra-tier ordering (the mode layer) is preserved. Not Infinity — the selector's
203
+ // `priority < bestPriority` is false for Infinity vs Infinity and an all-peaked pool
204
+ // would select nothing.
205
+ const PEAK_TIER_STRIDE = 1_000_000;
197
206
  const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
198
207
 
199
208
  // Quota fields that survive a restart: utilization levels and their reset
@@ -322,6 +331,7 @@ export class AccountManager {
322
331
  this.routingMode = 'automatic';
323
332
  this.preferredAccountName = null;
324
333
  this.sessionBindings = new Map();
334
+ this._peakCache = null; // per-UTC-minute peak-window memo (see _peakStateFor)
325
335
  this.sessionPolicies = new Map();
326
336
  this.upstreamThrottle = {
327
337
  until: null,
@@ -362,10 +372,10 @@ export class AccountManager {
362
372
  */
363
373
  getActiveAccount(requestInfo = {}, excludedIndexes = new Set()) {
364
374
  this.refreshExpiredQuotas();
365
- return this._selectNext(requestInfo, excludedIndexes);
375
+ return this._selectNext(requestInfo, excludedIndexes, requestInfo.now);
366
376
  }
367
377
 
368
- nextRetryForRequest(requestInfo = {}, excludedIndexes = new Set()) {
378
+ nextRetryForRequest(requestInfo = {}, excludedIndexes = new Set(), now = Date.now()) {
369
379
  this.refreshExpiredQuotas();
370
380
  const upstreamRetry = this._upstreamThrottleRetry();
371
381
  if (upstreamRetry && !this._hasAvailableProvider(requestInfo, excludedIndexes)) {
@@ -434,7 +444,7 @@ export class AccountManager {
434
444
  // finite hold. It must NEVER contribute the WEEKLY reset (days) — that is the
435
445
  // multi-day-hang the bounded path fences off; see the !fairnessOnlyBlock
436
446
  // guards on the weekly branches below.
437
- if (!fairnessOnlyBlock && this._isAvailable(account, { allowWeeklyReserve: true, model: requestInfo.model })) {
447
+ if (!fairnessOnlyBlock && this._isAvailable(account, { allowWeeklyReserve: true, model: requestInfo.model, now })) {
438
448
  return {
439
449
  available: true,
440
450
  retryAfterMs: 0,
@@ -443,13 +453,13 @@ export class AccountManager {
443
453
  matchingRoutes,
444
454
  };
445
455
  }
446
- if (fairnessOnlyBlock && this._isAvailable(account, { allowWeeklyReserve: true, allowWeeklyCritical: true, model: requestInfo.model })) {
456
+ if (fairnessOnlyBlock && this._isAvailable(account, { allowWeeklyReserve: true, allowWeeklyCritical: true, model: requestInfo.model, now })) {
447
457
  soonestBoundedHold = Math.min(soonestBoundedHold, BOUNDED_REPOLL_HOLD_MS);
448
458
  if (!boundedHoldCause) boundedHoldCause = 'queued_behind_fairness';
449
459
  continue;
450
460
  }
451
461
 
452
- const retry = this._retryInfo(account, requestInfo.model);
462
+ const retry = this._retryInfo(account, requestInfo.model, now);
453
463
  note(retry.cause);
454
464
  if (!fairnessOnlyBlock && retry.weeklyCritical && this._isAvailable(account, { allowWeeklyReserve: true, allowWeeklyCritical: true, model: requestInfo.model })) {
455
465
  return {
@@ -464,7 +474,7 @@ export class AccountManager {
464
474
  // A known, soon short-term reset (5h cap / rate-limit / cooldown) — even on
465
475
  // a weekly-critical account, this is the REAL near-term recovery time, so
466
476
  // it holds here with the true cause rather than the distant weekly reset.
467
- const ms = retry.retryAt - Date.now();
477
+ const ms = retry.retryAt - now;
468
478
  if (ms < soonestTemporary) {
469
479
  soonestTemporary = ms;
470
480
  temporaryCause = retry.cause;
@@ -488,7 +498,7 @@ export class AccountManager {
488
498
  // avoid (a weekly-exhausted gated fleet stays terminal → honest error,
489
499
  // matching the no-newcomer-vs-queue distinction). Only its short-term reset
490
500
  // (above) or the bounded hold may fire.
491
- const ms = retry.retryAt - Date.now();
501
+ const ms = retry.retryAt - now;
492
502
  if (ms < soonestWeekly) soonestWeekly = ms;
493
503
  } else if (!fairnessOnlyBlock && retry.cause === 'weekly_exhausted' && !retry.retryAt) {
494
504
  // Weekly-capped but we haven't learned the reset time (cold start /
@@ -540,7 +550,7 @@ export class AccountManager {
540
550
  };
541
551
  }
542
552
 
543
- hasAvailableRoute(requestInfo = {}, excludedIndexes = new Set()) {
553
+ hasAvailableRoute(requestInfo = {}, excludedIndexes = new Set(), now = Date.now()) {
544
554
  this.refreshExpiredQuotas();
545
555
  const profile = requestInfo.profile || 'claude';
546
556
  // Route-EXISTENCE check only (order-independent `.some`): unlike the acquire
@@ -562,7 +572,7 @@ export class AccountManager {
562
572
  return weeklyPasses.some(options => this.accounts.some(account => {
563
573
  if (excludedIndexes.has(account.index)) return false;
564
574
  if (!this._matchesRequest(account, profile, requestInfo)) return false;
565
- return this._isAvailable(account, { ...options, model: requestInfo.model });
575
+ return this._isAvailable(account, { ...options, model: requestInfo.model, now });
566
576
  }));
567
577
  }
568
578
 
@@ -580,7 +590,9 @@ export class AccountManager {
580
590
  const weight = Math.max(1, Number(requestInfo.weight) || 1);
581
591
  const upstreamThrottleProbe = account.type !== 'provider' && this._claimUpstreamThrottleProbe();
582
592
  if (requestInfo.sessionKey) {
583
- this._bindSession(requestInfo.sessionKey, account, requestInfo.model);
593
+ // Same clock the selection above used, so the peak-failover guard can never
594
+ // disagree with the peak tier that caused the move (defaults to real time).
595
+ this._bindSession(requestInfo.sessionKey, account, requestInfo.model, requestInfo.now);
584
596
  }
585
597
  account.inFlight++;
586
598
  account.activeWeight += weight;
@@ -715,7 +727,7 @@ export class AccountManager {
715
727
  _isAvailable(account, options = {}) {
716
728
  if (!account) return false;
717
729
  if (!account.enabled) return false;
718
- const now = Date.now();
730
+ const now = options.now ?? Date.now();
719
731
 
720
732
  // Check rate limit expiry
721
733
  if (account.status === 'throttled' && account.rateLimitedUntil) {
@@ -765,6 +777,12 @@ export class AccountManager {
765
777
  // by request-path callers; model-agnostic call sites skip this gate.
766
778
  if (options.model && this._scopedExhausted(account, options.model)) return false;
767
779
 
780
+ // PEAK HARD BAR (peakCap: 0.0 — "never during peak"). The ONLY peak gate on
781
+ // ELIGIBILITY; the soft cap is a priority tier and can never strand a request.
782
+ // No allow* opt-out exists on purpose: zero means zero. The request parks on the
783
+ // finite hold the _retryInfo peak branch hands the oracle — it must never die.
784
+ if (this._peakHardBarred(account, now)) return false;
785
+
768
786
  return true;
769
787
  }
770
788
 
@@ -1251,7 +1269,7 @@ export class AccountManager {
1251
1269
  * near-reset session (whose pace stays normal) never triggers → no churn. Does
1252
1270
  * NOT flip the instant a request migrates (unlike live in-flight, left to the
1253
1271
  * score loop), so a healthy bound account never ping-pongs. */
1254
- _isBoundAccountHot(account) {
1272
+ _isBoundAccountHot(account, now = Date.now()) {
1255
1273
  return this._isSessionQuotaUnavailable(account)
1256
1274
  || ['reserve', 'critical', 'exhausted'].includes(this._weeklyPaceState(account))
1257
1275
  // SESSION-window pressure counts too. _isSessionQuotaUnavailable only fires at
@@ -1263,7 +1281,11 @@ export class AccountManager {
1263
1281
  // there is still headroom, not at the cliff edge. _shouldRebalanceBoundSession
1264
1282
  // still requires a clearly-cheaper, strictly-healthier target, so a hot account
1265
1283
  // with no better alternative keeps its sessions — this only opens the question.
1266
- || this._sessionWindowUsage(account) >= this.scheduler.weeklySoftThreshold;
1284
+ || this._sessionWindowUsage(account) >= this.scheduler.weeklySoftThreshold
1285
+ // PEAK (2026-08-18): a provider inside its peak window is "hot" in the COST
1286
+ // sense — staying bound burns at 2x for hours. Peak-only ⇒ inert off-peak
1287
+ // (SC2). This only OPENS the question; the rebalance gates still decide.
1288
+ || this._peakTier(account, now) > 0;
1267
1289
  }
1268
1290
 
1269
1291
  /** Fraction of the SESSION (5h) window consumed, across both quota shapes.
@@ -1320,7 +1342,7 @@ export class AccountManager {
1320
1342
  if (!this._isWarming(account, now)) continue;
1321
1343
  if (!this._matchesRequest(account, profile, requestInfo)) continue;
1322
1344
  // Genuinely-healthy target only (same bar as the hot-rebalance candidate scan).
1323
- if (!this._isAvailable(account, { allowWeeklyReserve: false, allowWeeklyCritical: false, model: requestInfo.model })) continue;
1345
+ if (!this._isAvailable(account, { allowWeeklyReserve: false, allowWeeklyCritical: false, model: requestInfo.model, now })) continue;
1324
1346
  const score = this._scoreAccount(account, requestInfo, ctx);
1325
1347
  if (score < bestScore) {
1326
1348
  bestScore = score;
@@ -1330,10 +1352,10 @@ export class AccountManager {
1330
1352
  return best;
1331
1353
  }
1332
1354
 
1333
- _shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx) {
1355
+ _shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx, now = Date.now()) {
1334
1356
  if (!this._migrationSafeForRequest(requestInfo)) return false;
1335
1357
  if (requestInfo.queueTicket || requestInfo.queueAdmitted) return false;
1336
- if (!this._isBoundAccountHot(bound)) return false;
1358
+ if (!this._isBoundAccountHot(bound, now)) return false;
1337
1359
 
1338
1360
  const boundScore = this._scoreAccount(bound, requestInfo, scoringCtx);
1339
1361
  // Tier guard on the SAME axis as the trigger (pace, not raw). If the trigger is
@@ -1357,8 +1379,11 @@ export class AccountManager {
1357
1379
  // remove assuming the fall-through protects signed thinking — it does not.
1358
1380
  if (requestInfo.requiresAnthropicThinkingIntegrity === true && account.type === 'provider') continue;
1359
1381
  if (!this._matchesRequest(account, profile, requestInfo)) continue;
1382
+ // PEAK (2026-08-18): never migrate ONTO a peak-suppressed account — the move
1383
+ // would immediately be a 2x-burn destination. Peak-only ⇒ inert off-peak.
1384
+ if (this._peakTier(account, now) > 0) continue;
1360
1385
  // Genuinely-healthy alternatives only (normal/soft/unknown weekly + model headroom).
1361
- if (!this._isAvailable(account, { allowWeeklyReserve: false, allowWeeklyCritical: false, model: requestInfo.model })) continue;
1386
+ if (!this._isAvailable(account, { allowWeeklyReserve: false, allowWeeklyCritical: false, model: requestInfo.model, now })) continue;
1362
1387
  const score = this._scoreAccount(account, requestInfo, scoringCtx);
1363
1388
  if (score < bestScore) {
1364
1389
  bestScore = score;
@@ -1391,6 +1416,14 @@ export class AccountManager {
1391
1416
  // and preserving the last of a 5h window beats concurrency spread.
1392
1417
  if (this._sessionWindowUsage(bound) >= this.scheduler.weeklyReserveThreshold) return true;
1393
1418
 
1419
+ // PEAK (2026-08-18) — absolute escape. The score margins below are calibrated for
1420
+ // LOAD relief and are UNSATISFIABLE for a healthy peak account: peak tier 1/2 with
1421
+ // weekly pace `normal` means bestTier < boundTier can never hold, so without this
1422
+ // escape the exact sessions causing the 2x spend would never move. Flap-stable:
1423
+ // clause (b) guarantees every candidate is non-peak, and within the window nothing
1424
+ // pulls the session back. Peak-only ⇒ inert off-peak (SC2).
1425
+ if (this._peakTier(bound, now) > 0) return true;
1426
+
1394
1427
  // Otherwise the trigger was PACE-only on a RAW-healthy account — a fast-burner
1395
1428
  // that still has real absolute headroom (RAW soft but pace reserve/critical,
1396
1429
  // e.g. 79% used resetting in ~3.5d). Keep the conservative gate so it isn't
@@ -1402,8 +1435,7 @@ export class AccountManager {
1402
1435
  && bestTier < boundTier;
1403
1436
  }
1404
1437
 
1405
- _retryInfo(account, model = null) {
1406
- const now = Date.now();
1438
+ _retryInfo(account, model = null, now = Date.now()) {
1407
1439
  const q = account.quota || {};
1408
1440
 
1409
1441
  // TERMINAL (non-recoverable) states FIRST — before any weekly/short-term
@@ -1435,10 +1467,54 @@ export class AccountManager {
1435
1467
  // account reports its REAL near-term recovery, not the distant weekly reset.
1436
1468
  const shortTerm = this._shortTermRetry(account, now, q);
1437
1469
 
1470
+ // PEAK HARD BAR (peakCap 0.0) — barred until a KNOWN wall-clock time, so the oracle
1471
+ // must hold FINITE. Placement is pinned: AFTER the terminal checks above, BEFORE
1472
+ // the weekly branches. A LATE placement falls through to the terminal
1473
+ // `{cause:'unavailable'}` return, contributes to NO recovery bucket, collapses
1474
+ // nextRetryForRequest to Infinity, and server.js error-fasts — a session KILL at
1475
+ // 06:00 UTC. Precedence: weekly_exhausted (below) outranks peak when the weekly
1476
+ // reset dominates; here the max() merge with shortTerm means BOTH must clear.
1477
+ if (this._peakHardBarred(account, now)) {
1478
+ const { endsAt } = this._peakStateFor(account.provider, now);
1479
+ // Only a weekly reset that is ACTUALLY BLOCKING may dominate. providerWkReset is
1480
+ // set on every successfully-PROBED provider account — i.e. the steady state, not
1481
+ // an exceptional one — so reading it unconditionally made a HEALTHY account at 5%
1482
+ // weekly report a 72h "weekly_exhausted" hold instead of the real 3h peak hold.
1483
+ // That is the multi-day-hang class this branch exists to prevent, inverted.
1484
+ // (Caught by red-team probe 2026-08-18; the D5b control passed only because its
1485
+ // fixture left quota={}, a state a probed account is never in.)
1486
+ const weeklyBlocked = weeklyState === 'exhausted';
1487
+ const weeklyReset = weeklyBlocked ? (q.providerWkReset || q.unified7dReset || 0) : 0;
1488
+ // CAUSE follows the DOMINANT blocker, not merely the branch we are in. A
1489
+ // peak-barred account that is ALSO weekly-exhausted clears in DAYS, not at the
1490
+ // window end — labelling that `peak_window` would tell the user "peak ends in
1491
+ // 3h" while the real wait is 3 days (the misleading-message class this codebase
1492
+ // has been bitten by before). The TIME was always right via the max-merge; this
1493
+ // makes the LABEL agree with it.
1494
+ const retryAt = Math.max(endsAt || 0, shortTerm?.retryAt || 0, weeklyReset);
1495
+ const weeklyDominates = weeklyReset > 0 && weeklyReset >= (endsAt || 0);
1496
+ return {
1497
+ cause: weeklyDominates ? 'weekly_exhausted' : 'peak_window',
1498
+ retryAt,
1499
+ queueable: true,
1500
+ };
1501
+ }
1438
1502
  if (weeklyState === 'exhausted') {
1439
1503
  // Hard block: only a weekly reset unblocks it — a sooner short-term clear
1440
1504
  // does not help — so key the hold on the weekly reset.
1441
- return { cause: 'weekly_exhausted', retryAt: q.unified7dReset || null, queueable: false };
1505
+ //
1506
+ // Read the PROVIDER reset too. `unified7dReset` is an Anthropic-only field;
1507
+ // a GLM/Kimi account stores its weekly reset in `providerWkReset`
1508
+ // (applyProviderUsage). Reading only the Anthropic field returned retryAt:null
1509
+ // for every weekly-exhausted PROVIDER, which lands on `weeklyUnknownReset` in
1510
+ // nextRetryForRequest → retryAfterMs: Infinity → server.js error-fasts → the
1511
+ // live session is KILLED, despite the real reset time being known all along.
1512
+ // Reproduced 2026-08-18 with providerWk=0.9995 + a known providerWkReset.
1513
+ return {
1514
+ cause: 'weekly_exhausted',
1515
+ retryAt: q.unified7dReset || q.providerWkReset || null,
1516
+ queueable: false,
1517
+ };
1442
1518
  }
1443
1519
 
1444
1520
  if (weeklyState === 'critical') {
@@ -1556,7 +1632,9 @@ export class AccountManager {
1556
1632
  return null;
1557
1633
  }
1558
1634
 
1559
- _selectNext(requestInfo = {}, excludedIndexes = new Set()) {
1635
+ // `now` is the injected clock for every time-varying predicate on the selection
1636
+ // path (peak tier, sticky escape). Defaults to the real clock; tests pass it.
1637
+ _selectNext(requestInfo = {}, excludedIndexes = new Set(), now = Date.now()) {
1560
1638
  // Adaptive least-loaded balancing: spread requests across every healthy
1561
1639
  // account immediately, and let live load, quota pressure, and recent errors
1562
1640
  // push traffic away from weaker accounts.
@@ -1576,7 +1654,7 @@ export class AccountManager {
1576
1654
  const pinned = this.accounts.find(a => a.name === requestInfo.pinnedAccountName);
1577
1655
  if (pinned && !excludedIndexes.has(pinned.index)
1578
1656
  && this._matchesRequest(pinned, profile, requestInfo)
1579
- && this._isAvailable(pinned, { allowWeeklyReserve: true, allowWeeklyCritical: true, model: requestInfo.model })) {
1657
+ && this._isAvailable(pinned, { allowWeeklyReserve: true, allowWeeklyCritical: true, model: requestInfo.model, now })) {
1580
1658
  this.currentIndex = pinned.index;
1581
1659
  return pinned;
1582
1660
  }
@@ -1588,7 +1666,7 @@ export class AccountManager {
1588
1666
  { allowWeeklyReserve: true, allowWeeklyCritical: false },
1589
1667
  { allowWeeklyReserve: true, allowWeeklyCritical: true },
1590
1668
  ];
1591
- if (preferredPasses.some(options => this._isAvailable(preferred, { ...options, model: requestInfo.model }))) {
1669
+ if (preferredPasses.some(options => this._isAvailable(preferred, { ...options, model: requestInfo.model, now }))) {
1592
1670
  this.currentIndex = preferred.index;
1593
1671
  return preferred;
1594
1672
  }
@@ -1600,7 +1678,7 @@ export class AccountManager {
1600
1678
  // `_isWarming`, not on a binding.
1601
1679
  const isStickyMode = this.scheduler.routingMode === 'sticky';
1602
1680
  const bound = isStickyMode
1603
- ? this._boundAccount(requestInfo.sessionKey, profile, excludedIndexes, requestInfo)
1681
+ ? this._boundAccount(requestInfo.sessionKey, profile, excludedIndexes, requestInfo, now)
1604
1682
  : null;
1605
1683
  if (bound) {
1606
1684
  // Warmup-pull: onboard a freshly-ADDED account (added mid-session, no reload)
@@ -1617,7 +1695,7 @@ export class AccountManager {
1617
1695
  return warmupTarget; // _bindSession re-homes the session on acquire
1618
1696
  }
1619
1697
  if (!this._hasHigherPriorityAvailable(bound, profile, excludedIndexes, requestInfo)
1620
- && !this._shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx)) {
1698
+ && !this._shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx, now)) {
1621
1699
  return bound;
1622
1700
  }
1623
1701
  }
@@ -1648,9 +1726,9 @@ export class AccountManager {
1648
1726
  const account = this.accounts[idx];
1649
1727
  if (excludedIndexes.has(account.index)) continue;
1650
1728
  if (!this._matchesRequest(account, profile, requestInfo)) continue;
1651
- if (!this._isAvailable(account, { ...weeklyOptions, model: requestInfo.model })) continue;
1729
+ if (!this._isAvailable(account, { ...weeklyOptions, model: requestInfo.model, now })) continue;
1652
1730
 
1653
- const priority = this._effectivePriority(account, requestInfo);
1731
+ const priority = this._effectivePriority(account, requestInfo, now);
1654
1732
  const score = this._scoreAccount(account, requestInfo, scoringCtx);
1655
1733
  if (priority < bestPriority || (priority === bestPriority && score < bestScore)) {
1656
1734
  bestPriority = priority;
@@ -1702,15 +1780,15 @@ export class AccountManager {
1702
1780
  return null;
1703
1781
  }
1704
1782
 
1705
- _boundAccount(sessionKey, profile, excludedIndexes = new Set(), requestInfo = {}) {
1783
+ _boundAccount(sessionKey, profile, excludedIndexes = new Set(), requestInfo = {}, now = Date.now()) {
1706
1784
  if (!sessionKey) return null;
1707
1785
  const binding = this._sessionBinding(sessionKey);
1708
1786
  if (!binding) return null;
1709
1787
 
1710
- const home = this._eligibleBoundAccount(binding.homeName, profile, excludedIndexes, { allowWeeklyReserve: true }, requestInfo);
1788
+ const home = this._eligibleBoundAccount(binding.homeName, profile, excludedIndexes, { allowWeeklyReserve: true, now }, requestInfo);
1711
1789
  if (home) return home;
1712
1790
 
1713
- const current = this._eligibleBoundAccount(binding.currentName, profile, excludedIndexes, { allowWeeklyReserve: true }, requestInfo);
1791
+ const current = this._eligibleBoundAccount(binding.currentName, profile, excludedIndexes, { allowWeeklyReserve: true, now }, requestInfo);
1714
1792
  if (current) return current;
1715
1793
 
1716
1794
  const homeExists = binding.homeName && this.accounts.some(a => a.name === binding.homeName);
@@ -1731,7 +1809,7 @@ export class AccountManager {
1731
1809
  return account;
1732
1810
  }
1733
1811
 
1734
- _bindSession(sessionKey, account, model = null) {
1812
+ _bindSession(sessionKey, account, model = null, now = Date.now()) {
1735
1813
  const priority = this._priority(account);
1736
1814
  const binding = this._sessionBinding(sessionKey) || {
1737
1815
  homeName: account.name,
@@ -1739,7 +1817,19 @@ export class AccountManager {
1739
1817
  currentName: account.name,
1740
1818
  };
1741
1819
 
1742
- if (!binding.homeName || priority < binding.homePriority) {
1820
+ // PEAK FAILOVER GUARD (2026-08-18): a move forced by peak suppression is a
1821
+ // FAILOVER, not a by-choice rebalance — keep homeName so the session returns to
1822
+ // its home once the window closes. Without this, `priority < homePriority`
1823
+ // (oauth 0 < provider 10) permanently re-homes every sticky GLM session live at
1824
+ // 06:00 UTC, and it never returns after 10:00 — an SC2 violation. Mirrors the
1825
+ // CHOICE-vs-FAILOVER distinction the equal-priority branch below already encodes.
1826
+ // Both legs need the previous home, so it is resolved once here.
1827
+ const oldHome = binding.homeName ? this.accounts.find(a => a.name === binding.homeName) : null;
1828
+ const oldHomeInPeak = oldHome != null && this._peakTier(oldHome, now) > 0;
1829
+ // Lower-priority leg: only a move ONTO a non-peak account is peak-driven.
1830
+ const peakFailover = oldHomeInPeak && this._peakTier(account, now) === 0;
1831
+
1832
+ if (!binding.homeName || (priority < binding.homePriority && !peakFailover)) {
1743
1833
  binding.homeName = account.name;
1744
1834
  binding.homePriority = priority;
1745
1835
  } else if (priority === binding.homePriority && account.name !== binding.homeName) {
@@ -1751,8 +1841,12 @@ export class AccountManager {
1751
1841
  // Model-aware: a move off a home that's capped for THIS model (but healthy
1752
1842
  // for others) is a FAILOVER, not a by-choice rebalance — keep homeName so the
1753
1843
  // session snaps back once the model's scoped cap resets.
1754
- const oldHome = this.accounts.find(a => a.name === binding.homeName);
1755
- if (oldHome && this._isAvailable(oldHome, { allowWeeklyReserve: true, model })) {
1844
+ //
1845
+ // PEAK FAILOVER GUARD (equal-priority leg): an in-peak old home reads AVAILABLE
1846
+ // (tier 1/2 is a ranking, not an eligibility bar) — so without this check the
1847
+ // CHOICE branch would treat a peak-driven move as by-choice and permanently
1848
+ // re-home the session. A peak home is a FAILOVER cause: keep homeName.
1849
+ if (oldHome && !oldHomeInPeak && this._isAvailable(oldHome, { allowWeeklyReserve: true, model })) {
1756
1850
  binding.homeName = account.name;
1757
1851
  }
1758
1852
  }
@@ -1855,7 +1949,17 @@ export class AccountManager {
1855
1949
  // Claude session load-balances across Claude+GLM+Kimi rather than using providers
1856
1950
  // only as last-resort. 'never'/'when-exhausted' keep the provider's own priority
1857
1951
  // (10/20) → fallback-only. A foreign session is provider-only regardless.
1858
- _effectivePriority(account, requestInfo = {}) {
1952
+ _effectivePriority(account, requestInfo = {}, now = Date.now()) {
1953
+ // PEAK TIER (2026-08-18): a stride added AFTER the mode layer, so a peak provider
1954
+ // ranks strictly below every non-peak account in EVERY routing mode with no
1955
+ // per-mode branch. Tier 0 returns base IDENTICALLY — off-peak behaviour is
1956
+ // byte-identical to the pre-peak implementation (SC2 by construction).
1957
+ const base = this._basePriority(account, requestInfo);
1958
+ const tier = this._peakTier(account, now);
1959
+ return tier === 0 ? base : base + tier * PEAK_TIER_STRIDE;
1960
+ }
1961
+
1962
+ _basePriority(account, requestInfo = {}) {
1859
1963
  const base = Number.isFinite(account.priority) ? account.priority : 0;
1860
1964
  const mode = this.scheduler.routingMode;
1861
1965
  const incompatible = this._effectiveIncompatible(requestInfo).incompatible;
@@ -1934,9 +2038,147 @@ export class AccountManager {
1934
2038
  const providers = { ...(this.scheduler.providers || {}) };
1935
2039
  providers[providerKey] = { ...(providers[providerKey] || {}), claudeFallback: policy };
1936
2040
  this.scheduler.providers = providers;
2041
+ this._peakCache = null; // provider settings changed → peak memo is stale (MINOR 10)
1937
2042
  return true;
1938
2043
  }
1939
2044
 
2045
+ /** Peak settings setter (TUI + config writes). Validates, spread-preserves sibling
2046
+ * keys, clears the per-minute memo. Returns false on invalid input (no change). */
2047
+ setPeakSettingsForProvider(providerKey, { peakWindows, peakCap, peakDepreference, peakTimezone } = {}) {
2048
+ if (!providerKey) return false;
2049
+ const existing = this.scheduler.providers?.[providerKey] || {};
2050
+ const next = { ...existing };
2051
+ if (peakWindows !== undefined) {
2052
+ if (!Array.isArray(peakWindows)) return false;
2053
+ next.peakWindows = peakWindows;
2054
+ }
2055
+ if (peakCap !== undefined) {
2056
+ const c = Number(peakCap);
2057
+ if (!Number.isFinite(c) || c < 0 || c > 1) return false;
2058
+ next.peakCap = c;
2059
+ }
2060
+ if (peakDepreference !== undefined) next.peakDepreference = Boolean(peakDepreference);
2061
+ if (peakTimezone !== undefined) {
2062
+ // null = follow the machine's zone. A string must be a zone Intl accepts —
2063
+ // validate by construction so a typo is rejected here rather than silently
2064
+ // falling back at every routing decision.
2065
+ if (peakTimezone !== null) {
2066
+ if (typeof peakTimezone !== 'string') return false;
2067
+ try { new Intl.DateTimeFormat('en-US', { timeZone: peakTimezone }); } catch { return false; }
2068
+ }
2069
+ next.peakTimezone = peakTimezone;
2070
+ }
2071
+ const providers = { ...(this.scheduler.providers || {}) };
2072
+ providers[providerKey] = next;
2073
+ this.scheduler.providers = providers;
2074
+ this._peakCache = null;
2075
+ return true;
2076
+ }
2077
+
2078
+ /** Machine-readable peak state for the status endpoint (SC9). Per provider:
2079
+ * inPeak/endsAt/cap/depreference; plus which accounts are tier-1/2/barred. */
2080
+ peakSummary(now = Date.now()) {
2081
+ const providers = {};
2082
+ for (const a of this.accounts) {
2083
+ if (a.type !== 'provider' || providers[a.provider]) continue;
2084
+ const { inPeak, endsAt, settings } = this._peakStateFor(a.provider, now);
2085
+ providers[a.provider] = { inPeak, endsAt, cap: settings.cap, depreference: settings.depreference };
2086
+ }
2087
+ return { providers };
2088
+ }
2089
+
2090
+ // ── Peak-hour governance (2026-08-18) ─────────────────────────────────────────
2091
+ // Peak is a DERIVED, STATELESS overlay: a pure function of now + config. Never
2092
+ // persisted, never latched, never timer-driven — so sleep/wake, restart and the
2093
+ // zero-downtime reload need no recovery code. All peak predicates route through
2094
+ // these four helpers so the selector, the oracle and the TUI can never disagree.
2095
+
2096
+ /** Merged peak settings for one provider family. A malformed cap falls back to the
2097
+ * shipped default rather than throwing — but a malformed WINDOW yields `[]`, i.e.
2098
+ * never peak, so a config typo degrades to today's behaviour instead of benching an
2099
+ * account. Defaults resolve ONLY from scheduler.providers (seeded by the loadConfig
2100
+ * migration / createDefaultConfig — see peak-window.js for why they must not live
2101
+ * in DEFAULT_SCHEDULER). Resolved once per provider per minute via _peakStateFor. */
2102
+ _peakSettingsFor(providerKey) {
2103
+ const p = this.scheduler.providers?.[providerKey] || {};
2104
+ // Accept ONLY a real number. `Number(null)`/`Number('')`/`Number(false)`/`Number([])`
2105
+ // are all 0 — which is the HARD BAR — so a config carrying `peakCap: null` (the
2106
+ // natural JSON spelling of "no cap", and the very convention `peakTimezone: null`
2107
+ // uses in this same object) would silently bench every GLM account for 4h every
2108
+ // weekday. The window path already documents "a typo degrades to today's
2109
+ // behaviour"; the cap path must match it.
2110
+ const cap = typeof p.peakCap === 'number' && Number.isFinite(p.peakCap) ? p.peakCap : undefined;
2111
+ return {
2112
+ windows: Array.isArray(p.peakWindows) ? p.peakWindows : [],
2113
+ // null/absent ⇒ follow the MACHINE's local zone (wallClockIn's own default).
2114
+ // A string pins an IANA zone. Both are user-settable (2026-08-18).
2115
+ timezone: typeof p.peakTimezone === 'string' && p.peakTimezone ? p.peakTimezone : null,
2116
+ cap: cap === undefined ? DEFAULT_PEAK_CAP : Math.max(0, Math.min(1, cap)),
2117
+ depreference: p.peakDepreference !== false,
2118
+ };
2119
+ }
2120
+
2121
+ /** {inPeak, endsAt, settings} for a provider family, memoized per UTC MINUTE (the
2122
+ * evaluation is minute-stable by construction, so the memo is exact). Carrying the
2123
+ * resolved settings in the same entry is what lets _peakTier / _peakHardBarred /
2124
+ * peakSummary read them without re-resolving. Invalidated by the peak/provider
2125
+ * setters, which are the only things that can change settings mid-minute. */
2126
+ _peakStateFor(providerKey, now = Date.now()) {
2127
+ const minute = Math.floor(now / 60_000);
2128
+ if (!this._peakCache || this._peakCache.minute !== minute) {
2129
+ this._peakCache = { minute, byProvider: new Map() };
2130
+ }
2131
+ let st = this._peakCache.byProvider.get(providerKey);
2132
+ if (!st) {
2133
+ const settings = this._peakSettingsFor(providerKey);
2134
+ st = { ...peakWindowState(settings.windows, now, settings.timezone), settings };
2135
+ this._peakCache.byProvider.set(providerKey, st);
2136
+ }
2137
+ return st;
2138
+ }
2139
+
2140
+ /** WEEKLY utilization 0..1, or null when unreadable. The cap's basis (D2): TOTAL
2141
+ * WEEKLY — deliberately NOT _weeklyRawState's max(providerSes, providerWk), which
2142
+ * conflates the 5h session window with the weekly one. null (legacy TOKENS_LIMIT
2143
+ * plan / weeklyAbsent) FAILS OPEN: unknown must never mean over-cap. Provider-only,
2144
+ * like every peak predicate — OAuth accounts have no peak concept. */
2145
+ _peakWeeklyUtilization(account) {
2146
+ const wk = account?.quota?.providerWk;
2147
+ return wk != null ? clamp01(wk) : null;
2148
+ }
2149
+
2150
+ /** SOFT cap: weekly utilization at/over the cap during peak ⇒ tier 2 (last-resort).
2151
+ * cap >= 1 is "feature off" (SC4) — the strict < 1 guard also stops cap 1.0 firing
2152
+ * at exactly util 1.0. cap === 0 is NOT handled here; it is the hard bar, a
2153
+ * different mechanism entirely (utilization-independent). */
2154
+ _peakCapExceeded(account, settings) {
2155
+ if (!(settings.cap > 0 && settings.cap < 1)) return false;
2156
+ const util = this._peakWeeklyUtilization(account);
2157
+ if (util == null) return false; // fail open
2158
+ return util >= settings.cap;
2159
+ }
2160
+
2161
+ /** HARD bar: peakCap === 0.0 means "never use this provider during peak" (D4).
2162
+ * Utilization is irrelevant — zero means zero. The ONLY peak predicate that gates
2163
+ * ELIGIBILITY (_isAvailable); everything else is ranking. */
2164
+ _peakHardBarred(account, now = Date.now()) {
2165
+ if (account?.type !== 'provider') return false;
2166
+ const { inPeak, settings } = this._peakStateFor(account.provider, now);
2167
+ return inPeak && settings.cap === 0;
2168
+ }
2169
+
2170
+ /** 0 = unaffected · 1 = peak, de-preferred · 2 = peak + over the soft cap.
2171
+ * OAuth accounts are 0 always (no peak concept). A provider with no window is 0
2172
+ * always (Kimi by default — SC5). The cap is independent of depreference: turning
2173
+ * depreference off must not disable the cap (both knobs exist, D7). */
2174
+ _peakTier(account, now = Date.now()) {
2175
+ if (account?.type !== 'provider') return 0;
2176
+ const { inPeak, settings } = this._peakStateFor(account.provider, now);
2177
+ if (!inPeak) return 0;
2178
+ if (this._peakCapExceeded(account, settings)) return 2;
2179
+ return settings.depreference ? 1 : 0;
2180
+ }
2181
+
1940
2182
  _isRequestCompatible(account, profile, requestInfo = {}) {
1941
2183
  if (!this._matchesProfile(account, profile)) return false;
1942
2184
 
@@ -3373,6 +3615,7 @@ export class AccountManager {
3373
3615
  admissionPaused: this.admissionPaused,
3374
3616
  safetyMaxActivePerAccount: this.scheduler.safetyMaxActivePerAccount,
3375
3617
  safetyMaxGlobalActive: this.scheduler.safetyMaxGlobalActive,
3618
+ peak: this.peakSummary(),
3376
3619
  },
3377
3620
  upstreamThrottle: {
3378
3621
  active: this._isUpstreamThrottleBlocking(),
package/src/config.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFile, writeFile, mkdir, rename, chmod, unlink } from 'node:fs/promises';
2
+ import { DEFAULT_PEAK_PROVIDERS, mergePeakDefaults } from './peak-window.js';
2
3
  import { existsSync } from 'node:fs';
3
4
  import { join, dirname } from 'node:path';
4
5
  import { homedir } from 'node:os';
@@ -117,7 +118,14 @@ export function createDefaultConfig() {
117
118
  // Left EMPTY: an unset provider inherits the policy above, so off-by-default holds
118
119
  // without shadowing it. The TUI writes an entry here only when you steer one
119
120
  // provider differently from the other.
120
- providers: {},
121
+ // Peak-hour governance defaults (2026-08-18). Fresh installs get the block ON
122
+ // DISK; existing installs inherit it at load (see the migration in loadConfig).
123
+ // NEVER in account-manager's DEFAULT_SCHEDULER.providers: the shallow spread
124
+ // there wipes it for any config that already has `providers`, and a default in
125
+ // that object reaches every directly-constructed AccountManager in the test
126
+ // suite, making it time-dependent. Both proven by probe 2026-08-18.
127
+ providers: structuredClone(DEFAULT_PEAK_PROVIDERS),
128
+ peakDefaultsVersion: 1,
121
129
  },
122
130
  retry: {
123
131
  maxAttemptsPerRequest: 0,
@@ -191,6 +199,18 @@ export async function loadConfig() {
191
199
  for (const key of Object.keys(defaults)) {
192
200
  if (!(key in parsed)) parsed[key] = defaults[key];
193
201
  }
202
+ // ── Peak-hours migration (2026-08-18) ──
203
+ // The top-level backfill fills ABSENT top-level keys only, and every real config
204
+ // already HAS `scheduler` — the nested provider defaults never reach an existing
205
+ // install without this pass. Merges by key-PRESENCE (an explicit user value wins;
206
+ // `peakWindows: []` = never-peak and survives), gated on peakDefaultsVersion so
207
+ // deliberately-emptied windows are not re-seeded. CLONES — config.scheduler is
208
+ // reference-shared with the TUI persist path, and mutating it would write the
209
+ // defaults to disk on the next toggle (pre-mortem MINOR 8).
210
+ if (parsed.scheduler && typeof parsed.scheduler === 'object') {
211
+ parsed.scheduler.providers = mergePeakDefaults(parsed.scheduler.providers, parsed.scheduler.peakDefaultsVersion);
212
+ parsed.scheduler.peakDefaultsVersion = 1;
213
+ }
194
214
  }
195
215
  return parsed;
196
216
  }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Peak-hour window evaluation — PURE, zero imports, zero deps.
3
+ *
4
+ * z.ai charges 50% of the standard credit rate OFF-peak; peak is full rate, so peak
5
+ * spend costs 2x. Peak = Mon-Fri 14:00-18:00 SGT (UTC+8, no DST) = 06:00-10:00 UTC,
6
+ * 20h of 168h. Kimi (Moonshot) publishes no peak multiplier — it ships with NO
7
+ * window = never peak = unaffected.
8
+ *
9
+ * Design invariants (task-2026-08-18-maxpool-peak-hours-glm-governance):
10
+ * - UTC ONLY. days use Date#getUTCDay() numbering (0=Sun .. 6=Sat). No timezone
11
+ * math, no tz library — SGT has no DST so the fixed UTC window is exact year-round.
12
+ * - Start INCLUSIVE, end EXCLUSIVE: m >= start && m < end.
13
+ * - A day names the day the window STARTS. A wrapping window (end <= start) spans
14
+ * the UTC day boundary: segment A on each listed day, segment B on the NEXT day.
15
+ * - A MALFORMED window NEVER matches. The feature is on by default; a config typo
16
+ * must degrade to today's behaviour (never bench GLM), never to 24/7 de-preference.
17
+ * - Overlapping windows merge: endsAt is the LATEST matching end, so a hold never
18
+ * wakes into a second contiguous window.
19
+ */
20
+
21
+ export const MINUTES_PER_DAY = 1440;
22
+ export const DEFAULT_PEAK_CAP = 0.5;
23
+
24
+ /** The shipped defaults. ONE literal, imported by config.js (fresh installs get it on
25
+ * disk) AND the loadConfig migration (existing installs inherit it at load). Never
26
+ * DEFAULT_SCHEDULER — see the task research for the probe that proved that placement
27
+ * makes the test suite time-dependent and is wiped by the shallow provider spread. */
28
+ export const DEFAULT_PEAK_PROVIDERS = {
29
+ zai: {
30
+ // The VENDOR's window, expressed in the vendor's own zone. z.ai states
31
+ // Mon-Fri 14:00-18:00 SGT; pinning peakTimezone to Asia/Singapore means the
32
+ // window tracks what z.ai actually bills no matter where the laptop thinks it
33
+ // is, AND survives a DST change if the vendor ever restates it in a DST zone.
34
+ //
35
+ // USER-ADJUSTABLE (2026-08-18): both the hours and the zone are config. Set
36
+ // `peakTimezone` to any IANA zone, or to null to follow the MACHINE's local
37
+ // zone — the right choice if you'd rather reason in your own wall clock.
38
+ // startMin/endMin are minutes-from-midnight IN THAT ZONE.
39
+ peakTimezone: 'Asia/Singapore',
40
+ peakWindows: [{ days: [1, 2, 3, 4, 5], startMin: 14 * 60, endMin: 18 * 60 }],
41
+ peakCap: DEFAULT_PEAK_CAP,
42
+ peakDepreference: true,
43
+ },
44
+ kimi: { peakWindows: [], peakCap: DEFAULT_PEAK_CAP, peakDepreference: true },
45
+ };
46
+
47
+ /** Validate + normalize one window row. Returns {days:Set, start, end} or null.
48
+ * null ⇒ this row never matches (see invariants). */
49
+ export function normalizePeakWindow(w) {
50
+ if (!w || typeof w !== 'object') return null;
51
+ // `startMin`/`endMin` are the current names (minutes-from-midnight in the window's
52
+ // timezone). `startUtcMin`/`endUtcMin` are accepted as legacy aliases so a config
53
+ // written before the timezone knob keeps working unchanged.
54
+ const start = Number(w.startMin ?? w.startUtcMin);
55
+ const end = Number(w.endMin ?? w.endUtcMin);
56
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
57
+ if (start < 0 || start >= MINUTES_PER_DAY) return null; // start is a minute-of-day
58
+ if (end <= 0 || end > MINUTES_PER_DAY) return null; // end EXCLUSIVE; 1440 == midnight
59
+ if (end === start) return null; // zero-length ⇒ never
60
+ const days = Array.isArray(w.days)
61
+ ? [...new Set(w.days.map(Number).filter(n => Number.isInteger(n) && n >= 0 && n <= 6))]
62
+ : [];
63
+ if (!days.length) return null; // no days ⇒ never, not "every day"
64
+ return { days: new Set(days), start, end };
65
+ }
66
+
67
+ /** Wall-clock parts (weekday + minute-of-day) for `now` in an IANA timezone, using
68
+ * Node's built-in Intl — zero dependencies, and DST-correct by construction (the
69
+ * offset is resolved for THAT instant, not a fixed number).
70
+ *
71
+ * `tz` resolution order, per the user requirement (2026-08-18):
72
+ * 1. an explicit IANA zone in config (`peakTimezone`) — for a laptop whose clock
73
+ * is set to somewhere the user is not,
74
+ * 2. else the MACHINE's local zone (the default: maxpool follows the laptop),
75
+ * 3. else UTC.
76
+ * An invalid/unknown zone falls back to the machine zone rather than throwing —
77
+ * a typo must never take routing down.
78
+ */
79
+ const DAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
80
+ const _dtfCache = new Map();
81
+
82
+ export function wallClockIn(now, tz) {
83
+ const zone = tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
84
+ let dtf = _dtfCache.get(zone);
85
+ if (!dtf) {
86
+ try {
87
+ dtf = new Intl.DateTimeFormat('en-US', {
88
+ timeZone: zone, hour12: false, weekday: 'short', hour: '2-digit', minute: '2-digit',
89
+ });
90
+ } catch {
91
+ // Unknown zone → machine local. Never throw from the routing hot path.
92
+ dtf = new Intl.DateTimeFormat('en-US', { hour12: false, weekday: 'short', hour: '2-digit', minute: '2-digit' });
93
+ }
94
+ _dtfCache.set(zone, dtf);
95
+ }
96
+ const parts = Object.fromEntries(dtf.formatToParts(new Date(now)).map(p => [p.type, p.value]));
97
+ const day = DAY_INDEX[parts.weekday] ?? 0;
98
+ // hour '24' appears at midnight in some locales' hour12:false output — normalize.
99
+ const hour = Number(parts.hour) % 24;
100
+ return { day, min: hour * 60 + Number(parts.minute) };
101
+ }
102
+
103
+ /** Is `now` inside any window, and when does the peak end?
104
+ * @returns {{inPeak: boolean, endsAt: number|null}} endsAt is an ms epoch; null when
105
+ * not in peak. Pure in `now` — the caller injects it; no ambient clock reads here. */
106
+ export function peakWindowState(windows, now, tz = null) {
107
+ if (!Array.isArray(windows) || !Number.isFinite(now)) return { inPeak: false, endsAt: null };
108
+ // Normalize ONCE — both the match scan and the contiguity chain below read this.
109
+ const norm = [];
110
+ for (const raw of windows) {
111
+ const w = normalizePeakWindow(raw);
112
+ if (w) norm.push(w);
113
+ }
114
+ if (!norm.length) return { inPeak: false, endsAt: null };
115
+
116
+ // Window times are WALL-CLOCK in `tz` — the config's `peakTimezone` when set, else the
117
+ // MACHINE's own zone (2026-08-18 user requirement: follow the laptop by default, but
118
+ // let the user pin a zone, because a laptop clock is often set to somewhere they aren't).
119
+ // `endsAt` stays an absolute epoch, derived by projecting the remaining wall-clock
120
+ // minutes from local midnight.
121
+ const { day, min } = wallClockIn(now, tz);
122
+ const midnightUtc = now - min * 60_000 - (now % 60_000); // local midnight as an epoch
123
+ const yesterday = (day + 6) % 7;
124
+ let endsAt = null;
125
+ const note = (mins) => {
126
+ const t = midnightUtc + mins * 60_000;
127
+ if (endsAt == null || t > endsAt) endsAt = t;
128
+ };
129
+
130
+ for (const w of norm) {
131
+ const wraps = w.end < w.start;
132
+ // Segment A — the window's own UTC day (its full length, or to midnight when wrapping).
133
+ if (w.days.has(day)) {
134
+ const segEnd = wraps ? MINUTES_PER_DAY : w.end;
135
+ if (min >= w.start && min < segEnd) note(segEnd);
136
+ }
137
+ // Segment B — the spill onto the NEXT UTC day, for a wrapping window only.
138
+ // Keyed on the START day (yesterday from now), so a Fri 22:00-02:00 window is
139
+ // peak early Saturday but never early Sunday.
140
+ if (wraps && w.days.has(yesterday) && min < w.end) note(w.end);
141
+ }
142
+ if (endsAt == null) return { inPeak: false, endsAt: null };
143
+ // CONTIGUITY EXTENSION: a hold that wakes exactly at endsAt must not land inside a
144
+ // FOLLOW-ON window. Extend endsAt across any window that starts at/before the
145
+ // current end on the same day (chain until no extension). Simple O(n·k); n is tiny.
146
+ let extended = true;
147
+ while (extended) {
148
+ extended = false;
149
+ for (const w of norm) {
150
+ if (!w.days.has(day) || w.end <= w.start) continue; // wrapping windows never extend
151
+ const curMin = (endsAt - midnightUtc) / 60_000;
152
+ if (w.start <= curMin && w.end > curMin) {
153
+ const t = midnightUtc + w.end * 60_000;
154
+ if (t > endsAt) { endsAt = t; extended = true; }
155
+ }
156
+ }
157
+ }
158
+ return { inPeak: true, endsAt };
159
+ }
160
+
161
+ /** Merge shipped defaults into a provider's settings by key-PRESENCE (an explicit
162
+ * user value always survives; only an absent key inherits). CLONES — never mutates
163
+ * the input (the config object is reference-shared with the TUI persist path). */
164
+ export function mergePeakDefaults(providers, version) {
165
+ if (Number(version) >= 1) return providers; // already seeded — respect user edits
166
+ const out = {};
167
+ for (const key of Object.keys(providers || {})) out[key] = { ...(providers[key] || {}) };
168
+ for (const [key, seed] of Object.entries(DEFAULT_PEAK_PROVIDERS)) {
169
+ const existing = out[key] ||= {};
170
+ // Fields come from the seed itself, so a new peak setting added to
171
+ // DEFAULT_PEAK_PROVIDERS is migrated without editing a parallel list here.
172
+ for (const field of Object.keys(seed)) {
173
+ if (!(field in existing)) existing[field] = seed[field];
174
+ }
175
+ }
176
+ return out;
177
+ }
package/src/server.js CHANGED
@@ -1555,6 +1555,30 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
1555
1555
  return `This session is too large for the GLM/Kimi fallbacks (their ~256K limit) — it needs a 1M-context Claude account, and they're all busy right now.${eta} It sends as soon as one frees; /compact shortens the session if you'd rather not wait.`;
1556
1556
  }
1557
1557
 
1558
+ // PEAK (2026-08-18): a provider family hard-barred by peakCap:0 inside its window.
1559
+ // Placed AFTER the incompat + large-context branches on purpose — both have strictly
1560
+ // better explanations for THEIR requests, and this preempting them told a >256K
1561
+ // session to raise peakCap (which cannot help it). ENABLED accounts only, and the ETA
1562
+ // comes from the peak window end — never the caller's retryAfter, which may reflect
1563
+ // an entirely different blocker (red-team 2026-08-18).
1564
+ {
1565
+ const peakBarred = [];
1566
+ const seenPeakProviders = new Set();
1567
+ for (const a of accountManager.accounts || []) {
1568
+ if (a.type !== 'provider' || a.enabled === false || seenPeakProviders.has(a.provider)) continue;
1569
+ seenPeakProviders.add(a.provider);
1570
+ if (accountManager._peakHardBarred?.(a)) peakBarred.push(a);
1571
+ }
1572
+ if (peakBarred.length) {
1573
+ const names = [...new Set(peakBarred.map(a => (a.provider === 'zai' ? 'GLM' : a.provider === 'kimi' ? 'Kimi' : a.provider)))];
1574
+ const endsAt = Math.max(...peakBarred.map(a => accountManager._peakStateFor?.(a.provider)?.endsAt || 0));
1575
+ const leftMs = endsAt - Date.now();
1576
+ const eta = leftMs > 0 ? ` Peak ends in ~${formatRetryDuration(Math.round(leftMs / 1000))}.` : '';
1577
+ return `${names.join(' and ')} ${names.length === 1 ? 'is' : 'are'} switched off during peak hours (a setting: peakCap 0), and no other account can take this request.${eta} Set scheduler.providers.<provider>.peakCap above 0 to let it cover peak hours.`;
1578
+ }
1579
+ }
1580
+
1581
+
1558
1582
  // ENABLED only — a disabled account is not "at its limit", it is off; counting it
1559
1583
  // made a 1-enabled-account pool report "all 9 accounts at their limit".
1560
1584
  const claudeCount = accountManager.accounts.filter(a => a.type !== 'provider' && a.enabled !== false).length;
package/src/tui.js CHANGED
@@ -520,6 +520,25 @@ export class TUI {
520
520
  }
521
521
  }
522
522
 
523
+ /** The peak fragment for the routing header: `GLM peak · ends in 2h05m` while any
524
+ * provider is inside its window, '' otherwise. Names the window end so the 4h
525
+ * shape is legible; per-account detail lives in the row tags. `now` is injectable
526
+ * so a test can assert the rendered string without waiting for 06:00 UTC. */
527
+ _peakHeaderNote(now = Date.now()) {
528
+ const parts = [];
529
+ const seen = new Set();
530
+ for (const a of this.am.accounts) {
531
+ if (a.type !== 'provider' || seen.has(a.provider)) continue;
532
+ seen.add(a.provider);
533
+ const st = this.am._peakStateFor?.(a.provider, now);
534
+ if (!st?.inPeak || !st.endsAt) continue;
535
+ const label = a.provider === 'zai' ? 'GLM' : a.provider === 'kimi' ? 'Kimi' : a.provider;
536
+ const mins = Math.max(0, Math.ceil((st.endsAt - now) / 60_000));
537
+ parts.push(`${yellow(label + ' peak')} ${dim(`ends in ${Math.floor(mins / 60)}h${String(mins % 60).padStart(2, '0')}m`)}`);
538
+ }
539
+ return parts.join(' ');
540
+ }
541
+
523
542
  _routingLine(routing, xpText) {
524
543
  return ` Routing ${cyan(routing)}${xpText}`;
525
544
  }
@@ -1485,9 +1504,18 @@ export class TUI {
1485
1504
  // header read "Balance all · Cross-provider: always" which looked like two
1486
1505
  // conflicting settings when only the first one does anything.
1487
1506
  if (hasProviders) {
1507
+ // PEAK (SC9): name an active window — the feature must be visible, not silent.
1508
+ // Only while a provider is genuinely in-window, so an off-peak screen is
1509
+ // byte-identical to today (render parity, TEST PLAN H1).
1488
1510
  // Cross-provider fragment is sticky-only — under other modes it is inert and
1489
1511
  // reading "always" next to "Balance all" looked like two conflicting controls.
1490
1512
  if (mode === 'sticky') xpText = this._crossProviderText();
1513
+ // PEAK note is APPENDED after, never before: the sticky assignment above
1514
+ // overwrites xpText wholesale, so prepending silently discarded the peak note
1515
+ // in sticky — which is DEFAULT_SCHEDULER.routingMode, the TUI's own fallback,
1516
+ // AND the legacy-policy migration target (red-team 2026-08-18).
1517
+ const peakNote = this._peakHeaderNote();
1518
+ if (peakNote) xpText += (xpText ? ' ' : '') + peakNote;
1491
1519
  // Overflow visibility: when GLM/Kimi actually served requests recently, surface the
1492
1520
  // volume so provider traffic isn't a mystery. This is DATA, not a control — shown
1493
1521
  // in every mode. Reuses the SAME 15m load window as the per-row "15m Nr" column.
@@ -1832,6 +1860,17 @@ export class TUI {
1832
1860
  wkCell = q.providerWk != null ? bar(q.providerWk, bw, q.providerWkReset)
1833
1861
  : emptyBar(q.weeklyAbsent ? 'none' : '—', bw);
1834
1862
  note = this._probeHealthNote(a);
1863
+ // PEAK row tag (SC9): mirrors the router's own predicates — hard-barred = off,
1864
+ // tier 2 = over cap, tier 1 = peak (or "cap n/a" when the weekly is unreadable,
1865
+ // so the soft cap is inert). The label can never disagree with routing because
1866
+ // it reads the same predicates.
1867
+ if (this.am._peakHardBarred?.(a)) note += ` ${red('peak·off')}`;
1868
+ else {
1869
+ const tier = this.am._peakTier?.(a) || 0;
1870
+ const wu = this.am._peakWeeklyUtilization?.(a);
1871
+ if (tier === 2) note += ` ${yellow(`peak·capped ${Math.round((wu ?? 0) * 100)}%`)}`;
1872
+ else if (tier === 1) note += wu == null ? ` ${dim('peak·cap n/a')}` : ` ${yellow('peak')}`;
1873
+ }
1835
1874
  } else if (q.providerQuotaSource === 'console-only') {
1836
1875
  sesCell = emptyBar('n/a', bw);
1837
1876
  wkCell = emptyBar('n/a', bw);