dsh-agy 0.3.2 → 0.4.1

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.
@@ -544,6 +544,87 @@ function resolveMasterKeyCodec(dshHome) {
544
544
  };
545
545
  }
546
546
  //#endregion
547
+ //#region src/store/paths.ts
548
+ /**
549
+ * The `agy` data folder: every data file this plugin creates lives under
550
+ * `$DSH_HOME/agy/` instead of scattered across the DSH home.
551
+ *
552
+ * Why one folder: agy owned five JSON files sitting directly in `$DSH_HOME`
553
+ * (accounts, stats, models, thinking, fingerprint override) next to the host's
554
+ * own files. A folder is the boundary a user can see, back up, or delete whole.
555
+ *
556
+ * Migration is a ONE-SHOT ATOMIC RENAME, not a read-merge with lazy fallback:
557
+ *
558
+ * - `agy/` sits on the same filesystem as `$DSH_HOME`, so `rename` is atomic
559
+ * and preserves the 0600 mode.
560
+ * - A read-merge would need every store to carry two paths, two file locks,
561
+ * and per-store merge logic — and the merge itself can still lose updates
562
+ * (accounts mutate, stats counts).
563
+ * - A lazy fallback reads the old path forever, which defeats the point: the
564
+ * old file would never leave the home directory.
565
+ *
566
+ * Version-skew window contract: after a migration an OLD build recreates its
567
+ * legacy file, and its writes are invisible to the new layout — the both-files
568
+ * warning in `migrateToAgyDir` is the detector for exactly that (it fires when
569
+ * a later process finds both files present). Local SOP re-syncs all profiles at
570
+ * once, so the window is one app restart; npm users restart every dsh surface
571
+ * per the release note. Accounts logged in by an old CLI during skew need one
572
+ * re-login — enumerable, one-time, accepted in exchange for zero merge code.
573
+ */
574
+ /** The directory under `$DSH_HOME` holding every agy data file. */
575
+ function agyDataDir(dshHome = resolveDshHome()) {
576
+ return join(dshHome, "agy");
577
+ }
578
+ /** A data file inside the agy directory. */
579
+ function agyDataFile(name, dshHome = resolveDshHome()) {
580
+ return join(agyDataDir(dshHome), name);
581
+ }
582
+ /**
583
+ * One-time legacy -> `agy/` migration for one data file.
584
+ *
585
+ * 1. New file already present: warn-once (via `skew`) when the legacy file is
586
+ * ALSO present — an old-version process is still writing the old path.
587
+ * Return the new path regardless: the new layout wins once it exists.
588
+ * 2. No legacy file: fresh install (or already migrated), nothing to do.
589
+ * 3. Otherwise create the directory and rename. On ANY failure (`agy/` cannot
590
+ * be created, EXDEV, permissions, a locked target on Windows) fall back to
591
+ * the LEGACY path for this process: for accounts, the old layout with the
592
+ * real pool beats a new empty one, and `skew` surfaces the reason instead
593
+ * of hiding it.
594
+ *
595
+ * The caller decides what `skew` becomes; `plugin-common.ts` and the CLI log
596
+ * it once per process at most.
597
+ */
598
+ function migrateToAgyDir(name, dshHome = resolveDshHome()) {
599
+ const legacy = join(dshHome, name);
600
+ const file = agyDataFile(name, dshHome);
601
+ if (existsSync(file)) return {
602
+ file,
603
+ migrated: false,
604
+ skew: existsSync(legacy)
605
+ };
606
+ if (!existsSync(legacy)) return {
607
+ file,
608
+ migrated: false,
609
+ skew: false
610
+ };
611
+ try {
612
+ mkdirSync(agyDataDir(dshHome), { recursive: true });
613
+ renameSync(legacy, file);
614
+ return {
615
+ file,
616
+ migrated: true,
617
+ skew: false
618
+ };
619
+ } catch {
620
+ return {
621
+ file: legacy,
622
+ migrated: false,
623
+ skew: true
624
+ };
625
+ }
626
+ }
627
+ //#endregion
547
628
  //#region src/runtime/rotation.ts
548
629
  const BACKOFF_TIERS_MS = [
549
630
  5e3,
@@ -1091,14 +1172,23 @@ function earliestResetTime(a, b) {
1091
1172
  * counts Claude and GPT under one third-party counter, so the weekly budget
1092
1173
  * belongs to both. No model-id prefix rule can recover that split, which is why
1093
1174
  * the mapping lives here rather than in `modelFamilyOf`.
1175
+ *
1176
+ * Window-aware as of #56: a bucket id resolves a family only when the window it
1177
+ * describes is one `CachedQuota` can hold AND the id agrees with the token (see
1178
+ * `agreedWindowKind`). The prefix and the window were two independent decisions
1179
+ * once, and an unrecognized window still claimed a family.
1094
1180
  */
1095
- function familiesForBucketId(bucketId) {
1181
+ function familiesForBucketId(bucketId, window) {
1096
1182
  const id = bucketId.toLowerCase();
1097
- if (id.startsWith("gemini-")) return ["google"];
1098
- if (id.startsWith("3p-") || id.startsWith("third_party-")) return ["anthropic", "openai"];
1099
- if (id.startsWith("claude-")) return ["anthropic"];
1100
- if (id.startsWith("gpt-") || id.startsWith("openai-")) return ["openai"];
1101
- return [];
1183
+ const families = [];
1184
+ if (id.startsWith("gemini-")) families.push("google");
1185
+ else if (id.startsWith("3p-") || id.startsWith("third_party-")) families.push("anthropic", "openai");
1186
+ else if (id.startsWith("claude-")) families.push("anthropic");
1187
+ else if (id.startsWith("gpt-") || id.startsWith("openai-")) families.push("openai");
1188
+ if (families.length === 0) return [];
1189
+ const declared = window === void 0 ? advertisedWindowOf(id) : window.toLowerCase();
1190
+ if (declared === void 0 || agreedWindowKind(id, declared) === void 0) return [];
1191
+ return families;
1102
1192
  }
1103
1193
  /**
1104
1194
  * The families a group's LABEL covers, consulted only when no bucket id was
@@ -1124,7 +1214,6 @@ function familiesForGroupName(name) {
1124
1214
  if (claude && gpt) families.push("anthropic", "openai");
1125
1215
  else if (claude) families.push("anthropic");
1126
1216
  else if (gpt) families.push("openai");
1127
- else if (families.length === 0 && (label.includes("3p") || label.includes("third"))) families.push("anthropic", "openai");
1128
1217
  return families;
1129
1218
  }
1130
1219
  /**
@@ -1145,13 +1234,44 @@ function familiesForGroupName(name) {
1145
1234
  function windowKind(window) {
1146
1235
  return QUOTA_WINDOWS[window.toLowerCase()]?.kind;
1147
1236
  }
1237
+ /** The window token a bucket id advertises, when it ends with one upstream names. */
1238
+ function advertisedWindowOf(bucketId) {
1239
+ const id = bucketId.toLowerCase();
1240
+ const token = id.slice(id.lastIndexOf("-") + 1);
1241
+ return QUOTA_WINDOWS[token] ? token : void 0;
1242
+ }
1243
+ /**
1244
+ * The field a window belongs in, decided from an AGREED (id, token) pair — or
1245
+ * `undefined` when the pair cannot be agreed.
1246
+ *
1247
+ * The family used to come from the bucket id's prefix while the field came from
1248
+ * the window token, as two independent decisions nothing checked against each
1249
+ * other. A bucket whose window the cache cannot hold still claimed the family,
1250
+ * and its reading was then filed by whichever field the TOKEN named. `gemini-weekly`
1251
+ * declaring `5h` is the shape that puts a multi-day weekly fraction into
1252
+ * `remainingFraction` (the 5-hour field), where `isFamilyDrained` and
1253
+ * `parseFutureResetMs` block the account for days on a window they misread.
1254
+ *
1255
+ * Refusing an unagreed pair is deliberately the strict option: the permissive
1256
+ * mapping is exactly the coupling that let two window vocabularies drift into
1257
+ * destroying a measurement. A bucket id that advertises no recognizable token is
1258
+ * held to the declared one alone, so a rename does not silently disable the cache.
1259
+ */
1260
+ function agreedWindowKind(bucketId, window) {
1261
+ const declared = window.toLowerCase();
1262
+ const kind = windowKind(declared);
1263
+ if (kind === void 0) return void 0;
1264
+ const advertised = advertisedWindowOf(bucketId);
1265
+ if (advertised !== void 0 && advertised !== declared) return void 0;
1266
+ return kind;
1267
+ }
1148
1268
  /** The families one group belongs to: its bucket ids first, its label second. */
1149
1269
  function familiesForGroup(group) {
1150
1270
  const families = [];
1151
1271
  const add = (family) => {
1152
1272
  if (!families.includes(family)) families.push(family);
1153
1273
  };
1154
- for (const window of group.windows) for (const family of familiesForBucketId(window.bucketId)) add(family);
1274
+ for (const window of group.windows) for (const family of familiesForBucketId(window.bucketId, window.window)) add(family);
1155
1275
  if (families.length === 0) for (const family of familiesForGroupName(group.name)) add(family);
1156
1276
  return families;
1157
1277
  }
@@ -1163,9 +1283,16 @@ function familiesForGroup(group) {
1163
1283
  * `remainingFraction` and `resetTime`, with no window field at all, which is why
1164
1284
  * a weekly limit used to be invisible to rotation.
1165
1285
  *
1166
- * Two groups can land on one family (upstream may split `3p` later), so each
1167
- * window keeps the MOST pressured reading rather than letting the last group
1168
- * win — the same rule the per-model merge below uses.
1286
+ * Two groups can land on one family (upstream may split `3p` later), so the
1287
+ * family record keeps the MOST pressured reading of each window across those
1288
+ * groups (`Math.min` below) rather than letting the last group win — the same
1289
+ * rule the per-model merge uses.
1290
+ *
1291
+ * WITHIN one group the rule is deliberately different: `??=` keeps upstream's
1292
+ * FIRST bucket of each window, because a group is upstream's own 5h + weekly
1293
+ * pair and a duplicate would otherwise depend on upstream's ordering. "Most
1294
+ * pressured" therefore applies to two GROUPS landing on one family, not to two
1295
+ * buckets inside one group.
1169
1296
  */
1170
1297
  function ingestQuotaGroups(groups) {
1171
1298
  const families = /* @__PURE__ */ new Map();
@@ -1175,7 +1302,7 @@ function ingestQuotaGroups(groups) {
1175
1302
  let rolling;
1176
1303
  let weekly;
1177
1304
  for (const window of group.windows) {
1178
- const kind = windowKind(window.window);
1305
+ const kind = agreedWindowKind(window.bucketId, window.window);
1179
1306
  if (kind === "rolling") rolling ??= window;
1180
1307
  else if (kind === "weekly") weekly ??= window;
1181
1308
  }
@@ -1248,21 +1375,70 @@ function ingestFamilyQuotas(discovered, groups, previous) {
1248
1375
  }
1249
1376
  return Object.fromEntries(families);
1250
1377
  }
1251
- /** The quota record for one family, or the most-pressured family when the model is unknown. */
1252
- function familyQuotaFor(account, family) {
1378
+ /**
1379
+ * The lowest fraction a record reports across its LIVE windows — its overall
1380
+ * pressure.
1381
+ *
1382
+ * A window whose reset has already passed describes a window that no longer
1383
+ * exists, so it does not count — the same rule `isQuotaStale` applies when it
1384
+ * sizes the TTL from the selected record. Scoring a dead window would let one
1385
+ * family's expired weekly reading win the selection and represent the account,
1386
+ * masking another family's live pressure (issue #69: a dead weekly at 0.004
1387
+ * beat a live 0.2 and stretched the refresh TTL from 5 to 15 minutes).
1388
+ *
1389
+ * `undefined` means the record carries no usable LIVE reading at all, which is
1390
+ * deliberately different from a measured zero: only a real number can size a
1391
+ * refresh interval.
1392
+ */
1393
+ function pressureScore(entry, now) {
1394
+ const fractions = [];
1395
+ if (typeof entry.remainingFraction === "number" && Number.isFinite(entry.remainingFraction) && !resetInPast(entry.resetTime, now)) fractions.push(entry.remainingFraction);
1396
+ if (typeof entry.weeklyFraction === "number" && Number.isFinite(entry.weeklyFraction) && !resetInPast(entry.weeklyResetTime, now)) fractions.push(entry.weeklyFraction);
1397
+ return fractions.length === 0 ? void 0 : Math.min(...fractions);
1398
+ }
1399
+ /**
1400
+ * The quota record for one family, or the most-pressured family when the model
1401
+ * is unknown.
1402
+ *
1403
+ * "Most pressured" compares BOTH windows and reports the record whose LOWEST
1404
+ * window is the lowest of all of them. That matters because `isQuotaStale` sizes
1405
+ * the refresh TTL from whichever record this returns: a family at 0.9 / weekly
1406
+ * 0.004 is more pressured than one at 0.2, and comparing the 5-hour fraction
1407
+ * alone picked the 0.2 record — so the weekly-driven 60s TTL was never applied
1408
+ * to the family actually being requested and a spent week was discovered late.
1409
+ *
1410
+ * A record carrying ONLY a weekly reading participates for the same reason: #48
1411
+ * produces that shape legitimately (the anthropic/openai entries of a
1412
+ * Gemini-only pool), and the old `typeof entry.remainingFraction !== 'number'`
1413
+ * guard skipped it outright, leaving the week invisible to TTL sizing.
1414
+ *
1415
+ * Selection considers LIVE windows only (`pressureScore` drops a window whose
1416
+ * reset has passed) — but the with-family path returns the record as-is either
1417
+ * way, because its consumers guard each window individually.
1418
+ *
1419
+ * @param account - the account whose cache to read.
1420
+ * @param family - the exact family's record; omit to select the most pressured.
1421
+ * @param now - current time (Unix ms), which decides whether a window is live.
1422
+ */
1423
+ function familyQuotaFor(account, family, now = Date.now()) {
1253
1424
  const cache = account.cachedQuota ?? {};
1254
1425
  if (family) return cache[family];
1255
1426
  let worst;
1427
+ let worstScore;
1256
1428
  for (const entry of Object.values(cache)) {
1257
- if (typeof entry.remainingFraction !== "number") continue;
1258
- if (!worst || entry.remainingFraction < (worst.remainingFraction ?? 1)) worst = entry;
1429
+ const score = pressureScore(entry, now);
1430
+ if (score === void 0) continue;
1431
+ if (worstScore === void 0 || score < worstScore) {
1432
+ worst = entry;
1433
+ worstScore = score;
1434
+ }
1259
1435
  }
1260
1436
  return worst;
1261
1437
  }
1262
1438
  /** Whether the account's quota cache needs a refresh (missing, or past its health-based TTL). */
1263
1439
  function isQuotaStale(account, now = Date.now()) {
1264
1440
  if (!account.cachedQuota || !account.cachedQuotaUpdatedAt) return true;
1265
- const mostPressured = familyQuotaFor(account);
1441
+ const mostPressured = familyQuotaFor(account, void 0, now);
1266
1442
  const ttl = computeSoftQuotaCacheTtlMs(resetInPast(mostPressured?.resetTime, now) ? void 0 : mostPressured?.remainingFraction, resetInPast(mostPressured?.weeklyResetTime, now) ? void 0 : mostPressured?.weeklyFraction);
1267
1443
  return now - account.cachedQuotaUpdatedAt > ttl;
1268
1444
  }
@@ -1296,26 +1472,6 @@ function resetInPast(resetTime, now) {
1296
1472
  return !Number.isNaN(reset) && reset <= now;
1297
1473
  }
1298
1474
  /**
1299
- * Whether the requested family on this account is soft-quota-exhausted.
1300
- *
1301
- * BOTH windows are checked, because they refill on different clocks. The 5-hour
1302
- * bucket can be nearly empty while the weekly budget is untouched, and the
1303
- * reverse — a spent weekly budget stays spent across four 5-hour refills. Before
1304
- * this, only the 5-hour reading was consulted, so an account whose week was over
1305
- * kept being selected until a real request failed.
1306
- *
1307
- * A window whose reset has already passed is IGNORED rather than read: its
1308
- * fraction describes a window that no longer exists, so the account stays
1309
- * selectable until the next measurement replaces the stale value.
1310
- */
1311
- function isFamilyDrained(account, family, now = Date.now()) {
1312
- const quota = familyQuotaFor(account, family);
1313
- if (!quota) return false;
1314
- if (typeof quota.remainingFraction === "number" && !resetInPast(quota.resetTime, now) && quota.remainingFraction < .15) return true;
1315
- if (typeof quota.weeklyFraction === "number" && !resetInPast(quota.weeklyResetTime, now) && quota.weeklyFraction <= .01) return true;
1316
- return false;
1317
- }
1318
- /**
1319
1475
  * Required drain rate: headroomFraction / remainingHours — how fast the
1320
1476
  * family's remaining quota must be consumed to avoid expiring unused at its
1321
1477
  * reset (mirrors AuthStorage.#computeWindowRequiredDrain with a daily window).
@@ -1350,6 +1506,32 @@ function parseFutureResetMs(resetTime, now) {
1350
1506
  return reset;
1351
1507
  }
1352
1508
  /**
1509
+ * Whether the requested family on this account is effectively quota-exhausted
1510
+ * for account STEERING (affinity pins, the active-account preference): an
1511
+ * explicit quota-exhausted cooldown in force, a spent 5-hour window
1512
+ * (`remainingFraction <= 0` with a future reset), or a weekly window at or
1513
+ * below `WEEKLY_QUOTA_THRESHOLD` — a spent week refills in days, so its rule
1514
+ * mirrors `isFamilyDrained`'s weekly branch rather than the block wall.
1515
+ *
1516
+ * Deliberately a DIFFERENT predicate from the one in `pickAccount`'s `!picked`
1517
+ * branch, which applies a strict zero to BOTH windows to mirror
1518
+ * `rankPoolCandidates`' block wall when classifying `AgyPoolBlockedError`; and
1519
+ * deliberately NARROWER than `isFamilyDrained`'s 5-hour rule
1520
+ * (`SOFT_QUOTA_THRESHOLD`): account prioritization holds a chosen account
1521
+ * until it is actually spent, not until ranking would prefer another one.
1522
+ */
1523
+ function isFamilyQuotaExhausted(account, family, now = Date.now()) {
1524
+ if (account.cooldownReason === "quota-exhausted" && (account.coolingDownUntil ?? 0) > now) return true;
1525
+ const quota = familyQuotaFor(account, family);
1526
+ if (!quota) return false;
1527
+ const spent = (fraction, resetTime, threshold = 0) => {
1528
+ if (typeof fraction !== "number" || fraction > threshold || !resetTime) return false;
1529
+ const resetMs = parseFutureResetMs(resetTime, now);
1530
+ return resetMs !== void 0 && resetMs > now;
1531
+ };
1532
+ return spent(quota.remainingFraction, quota.resetTime, 0) || spent(quota.weeklyFraction, quota.weeklyResetTime, .01);
1533
+ }
1534
+ /**
1353
1535
  * Rank pool candidates for one request, mirroring AuthStorage's antigravity
1354
1536
  * ordering: unblocked first (earliest unblock time among blocked), hot windows
1355
1537
  * last, measured usage before unmeasured, required-drain descending, then
@@ -1361,10 +1543,12 @@ function rankPoolCandidates(entries, modelId, now = Date.now(), startIndex = 0)
1361
1543
  const activePos = entries.findIndex((e) => e.index === startIndex);
1362
1544
  const clampedStart = activePos >= 0 ? activePos : 0;
1363
1545
  const candidates = (entries.length === 0 ? [] : [...entries.slice(clampedStart), ...entries.slice(0, clampedStart)]).map(({ account, index }, orderPos) => {
1364
- const quota = familyQuotaFor(account, family);
1365
- const remaining = quota?.remainingFraction;
1546
+ const quota = familyQuotaFor(account, family, now);
1547
+ const live5h = quota !== void 0 && !resetInPast(quota.resetTime, now);
1548
+ const liveWeekly = quota !== void 0 && !resetInPast(quota.weeklyResetTime, now);
1549
+ const remaining = live5h ? quota.remainingFraction : void 0;
1366
1550
  const used = typeof remaining === "number" ? Math.min(Math.max(1 - remaining, 0), 1) : void 0;
1367
- const weeklyRemaining = quota?.weeklyFraction;
1551
+ const weeklyRemaining = liveWeekly ? quota.weeklyFraction : void 0;
1368
1552
  const weeklyUsed = typeof weeklyRemaining === "number" ? Math.min(Math.max(1 - weeklyRemaining, 0), 1) : void 0;
1369
1553
  let blockedUntil = null;
1370
1554
  if (account.coolingDownUntil && account.coolingDownUntil > now) blockedUntil = account.coolingDownUntil;
@@ -1436,18 +1620,17 @@ const DEFAULT_FINGERPRINT_DATA = {
1436
1620
  ],
1437
1621
  ideTypes: ["ANTIGRAVITY"]
1438
1622
  };
1439
- const USER_OVERRIDE_FILE = "agy-fingerprint-data.json";
1440
1623
  /**
1441
- * Effective fingerprint data: a user override at `$DSH_HOME/agy-fingerprint-data.json`
1624
+ * Effective fingerprint data: a user override at `$DSH_HOME/agy/agy-fingerprint-data.json`
1442
1625
  * wins when present and parseable (hot-updatable without a code release — the
1443
- * bundled copy is compiled in), otherwise the bundled defaults.
1626
+ * bundled copy is compiled in), otherwise the bundled defaults. The legacy
1627
+ * `$DSH_HOME/agy-fingerprint-data.json` location is migrated once by rename.
1444
1628
  */
1445
1629
  let cachedData = null;
1446
1630
  function getFingerprintData() {
1447
1631
  if (cachedData) return cachedData;
1448
1632
  try {
1449
- const dshHome = process.env.DSH_HOME ? process.env.DSH_HOME : join(process.env.HOME ?? ".", ".dsh");
1450
- const overrideFile = join(dshHome, USER_OVERRIDE_FILE);
1633
+ const overrideFile = migrateToAgyDir("agy-fingerprint-data.json", process.env.DSH_HOME ? process.env.DSH_HOME : join(process.env.HOME ?? ".", ".dsh")).file;
1451
1634
  if (existsSync(overrideFile)) {
1452
1635
  const parsed = JSON.parse(readFileSync(overrideFile, "utf8"));
1453
1636
  if (parsed && Array.isArray(parsed.versionPool) && parsed.versionPool.length > 0) {
@@ -1545,6 +1728,30 @@ function fingerprintMode() {
1545
1728
  return process.env.DSH_AGY_FINGERPRINT_MODE === "stable" ? "stable" : "dynamic";
1546
1729
  }
1547
1730
  /**
1731
+ * Fraction-per-hour per window bucket, from two snapshots.
1732
+ *
1733
+ * Pure and exported for a direct unit test. Rules:
1734
+ * - the FIRST sample yields nothing (no baseline yet), and so does a pair
1735
+ * closer than `MIN_BURN_SAMPLE_MS` — two probes seconds apart measure the
1736
+ * probe jitter, not the burn;
1737
+ * - a RISING fraction is a reset (or refresh noise), never negative burn;
1738
+ * - a bucket with no baseline simply has no rate yet.
1739
+ *
1740
+ * Returns null when no bucket produced a rate, so the caller can keep the
1741
+ * previous rates instead of overwriting them with an empty read.
1742
+ */
1743
+ function sampleLimitBurn(prev, next) {
1744
+ if (prev === void 0) return null;
1745
+ const elapsedH = (next.at - prev.at) / 36e5;
1746
+ if (elapsedH < 3e5 / 36e5) return null;
1747
+ const perHour = {};
1748
+ for (const [bucketId, fraction] of Object.entries(next.fractions)) {
1749
+ const before = prev.fractions[bucketId];
1750
+ if (before !== void 0 && before > fraction) perHour[bucketId] = (before - fraction) / elapsedH;
1751
+ }
1752
+ return Object.keys(perHour).length > 0 ? perHour : null;
1753
+ }
1754
+ /**
1548
1755
  * Resolve the impersonation headers for one request from the account's
1549
1756
  * persistent fingerprint (stable identity).
1550
1757
  *
@@ -1553,7 +1760,7 @@ function fingerprintMode() {
1553
1760
  * deliberately DETERMINISTIC rather than randomized: a per-request platform or
1554
1761
  * version would make one account appear to be several different machines, which
1555
1762
  * is the anomaly this identity exists to avoid. It reads
1556
- * {@link getFingerprintData} so a `$DSH_HOME/agy-fingerprint-data.json` override
1763
+ * {@link getFingerprintData} so a `$DSH_HOME/agy/agy-fingerprint-data.json` override
1557
1764
  * still applies on this path.
1558
1765
  */
1559
1766
  function impersonationHeadersFor(account) {
@@ -1575,6 +1782,22 @@ var AgySessionManager = class AgySessionManager {
1575
1782
  refreshInFlight = /* @__PURE__ */ new Map();
1576
1783
  /** In-flight quota fetches keyed by account: concurrent selections share one fetchAvailableModels call. */
1577
1784
  quotaRefreshInFlight = /* @__PURE__ */ new Map();
1785
+ /**
1786
+ * In-flight `retrieveUserQuotaSummary` probes keyed by account.
1787
+ *
1788
+ * ONE promise per account per cycle, shared by BOTH consumers:
1789
+ * `refreshLimits` (display, writes `cachedLimits`) and `refreshQuotaCache`
1790
+ * (scheduling, writes `cachedQuota`). Since #48 both read the same endpoint for
1791
+ * the same account, so a stale account paid two round trips where one would
1792
+ * do; `quotaRefreshInFlight` de-duplicated only WITHIN the scheduling path.
1793
+ * Sharing makes the two caches agree on what upstream actually said.
1794
+ *
1795
+ * It carries the FETCH and nothing else: the invariant that `refreshLimits`
1796
+ * must never write `cachedQuota` (which `rankPoolCandidates` turns into a
1797
+ * `blockedUntil`) is about WHERE a result lands, and each path still decides
1798
+ * that for itself.
1799
+ */
1800
+ quotaSummaryInFlight = /* @__PURE__ */ new Map();
1578
1801
  failureCounts = /* @__PURE__ */ new Map();
1579
1802
  /** Accounts whose request-time project discovery already failed (no retry per request). */
1580
1803
  projectRetryFailed = /* @__PURE__ */ new Set();
@@ -1642,6 +1865,62 @@ var AgySessionManager = class AgySessionManager {
1642
1865
  at: now
1643
1866
  });
1644
1867
  }
1868
+ /**
1869
+ * Which accounts have upstream requests in flight, for the UI's live line.
1870
+ *
1871
+ * The join runs HERE, not in the RPC layer: the in-flight map is keyed by
1872
+ * `accountKey` (id ?? email ?? idx-<refresh>), a derivation the management
1873
+ * surface must not have to replicate — its own email-first key would miss
1874
+ * every account carrying both fields. Counts go through `inFlightCount`, so
1875
+ * an entry leaked past its TTL reads as idle instead of busy forever.
1876
+ */
1877
+ async inFlightAccounts() {
1878
+ const storage = await this.store.load();
1879
+ const now = Date.now();
1880
+ const busy = [];
1881
+ for (const [index, account] of storage.accounts.entries()) {
1882
+ const count = this.inFlightCount(this.accountKey(account), now);
1883
+ if (count > 0) busy.push({
1884
+ index,
1885
+ email: account.email ?? null,
1886
+ count
1887
+ });
1888
+ }
1889
+ return busy;
1890
+ }
1891
+ /** Last fraction snapshot per account, for `sampleLimitBurn` (memory only). */
1892
+ limitFractions = /* @__PURE__ */ new Map();
1893
+ /** Latest burn rate per account: bucketId -> fraction/hour (memory only). */
1894
+ limitBurn = /* @__PURE__ */ new Map();
1895
+ noteLimitSample(key, groups, at) {
1896
+ const fractions = {};
1897
+ for (const group of groups) for (const window of group.windows) if (window.remainingFraction !== null) fractions[window.bucketId] = window.remainingFraction;
1898
+ const next = {
1899
+ at,
1900
+ fractions
1901
+ };
1902
+ const burn = sampleLimitBurn(this.limitFractions.get(key), next);
1903
+ if (burn !== null) this.limitBurn.set(key, { perHour: burn });
1904
+ this.limitFractions.set(key, next);
1905
+ }
1906
+ /**
1907
+ * Burn rates per account INDEX, for the `account.limits` reply.
1908
+ *
1909
+ * The index join runs here, like `inFlightAccounts`: the rate map is keyed
1910
+ * by `accountKey`, a derivation the management surface must not replicate.
1911
+ */
1912
+ async limitBurnRates() {
1913
+ const storage = await this.store.load();
1914
+ const out = [];
1915
+ for (const [index, account] of storage.accounts.entries()) {
1916
+ const entry = this.limitBurn.get(this.accountKey(account));
1917
+ if (entry !== void 0) out.push({
1918
+ index,
1919
+ perHour: entry.perHour
1920
+ });
1921
+ }
1922
+ return out;
1923
+ }
1645
1924
  /** The map key for a conversation; anonymous callers share one bucket. */
1646
1925
  conversationKeyFor(conversationKey) {
1647
1926
  const trimmed = conversationKey?.trim();
@@ -1682,6 +1961,28 @@ var AgySessionManager = class AgySessionManager {
1682
1961
  clearAffinityForAccount(accountKey) {
1683
1962
  for (const [conversation, pin] of this.affinity) if (pin.key === accountKey) this.affinity.delete(conversation);
1684
1963
  }
1964
+ /**
1965
+ * Clear all session affinity pins.
1966
+ *
1967
+ * Called when an account is explicitly activated by the user, so all
1968
+ * ongoing and upcoming conversations immediately steer to the newly
1969
+ * activated account rather than staying pinned to the previous one.
1970
+ */
1971
+ clearAllAffinity() {
1972
+ this.affinity.clear();
1973
+ }
1974
+ /**
1975
+ * Explicitly activate an account by index: sets `storage.activeIndex = index`,
1976
+ * clears session affinity pins so conversations immediately switch to the
1977
+ * activated account, and resets transient failure counts.
1978
+ */
1979
+ async activateAccount(index) {
1980
+ await this.store.mutate((storage) => {
1981
+ if (index < 0 || index >= storage.accounts.length) throw new Error("account not found");
1982
+ storage.activeIndex = index;
1983
+ });
1984
+ this.clearAllAffinity();
1985
+ }
1685
1986
  constructor(options) {
1686
1987
  this.store = options.store;
1687
1988
  this.onRotate = options.onRotate;
@@ -1786,6 +2087,49 @@ var AgySessionManager = class AgySessionManager {
1786
2087
  return this.refreshToken(key, account, cached);
1787
2088
  }
1788
2089
  /**
2090
+ * Probe `retrieveUserQuotaSummary` for one account, sharing ONE in-flight
2091
+ * promise with every other caller in this cycle.
2092
+ *
2093
+ * Returns `[]` on any failure — the fetch itself already answers a dead
2094
+ * endpoint with an empty list — so both consumers read "no groups" the same
2095
+ * way. The promise is dropped as soon as it settles, so the NEXT cycle
2096
+ * re-probes.
2097
+ *
2098
+ * The account's proxy is honoured here or the call would egress direct and
2099
+ * leak the account's real IP, and one timeout bounds the whole endpoint
2100
+ * fallback chain.
2101
+ */
2102
+ probeQuotaSummary(account) {
2103
+ const key = this.accountKey(account);
2104
+ const existing = this.quotaSummaryInFlight.get(key);
2105
+ if (existing) return existing;
2106
+ const probe = (async () => {
2107
+ try {
2108
+ const auth = await this.accessTokenFor(account);
2109
+ if (!auth) return [];
2110
+ const { fetchQuotaSummary } = await import("./quota-summary-adID82I2.mjs");
2111
+ const routed = accountFetch({ proxyUrl: account.proxy });
2112
+ const bounded = (input, init) => {
2113
+ const timeout = AbortSignal.timeout(AgySessionManager.QUOTA_FETCH_TIMEOUT_MS);
2114
+ const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
2115
+ return routed(input, {
2116
+ ...init,
2117
+ signal
2118
+ });
2119
+ };
2120
+ return await fetchQuotaSummary(auth.access, account.projectId, bounded);
2121
+ } catch {
2122
+ return [];
2123
+ }
2124
+ })();
2125
+ this.quotaSummaryInFlight.set(key, probe);
2126
+ const release = () => {
2127
+ if (this.quotaSummaryInFlight.get(key) === probe) this.quotaSummaryInFlight.delete(key);
2128
+ };
2129
+ probe.then(release, release);
2130
+ return probe;
2131
+ }
2132
+ /**
1789
2133
  * Refresh the display-only 5h/weekly windows for every enabled account.
1790
2134
  *
1791
2135
  * SEPARATE from `refreshQuotaCache`, and deliberately so. That method writes
@@ -1833,22 +2177,7 @@ var AgySessionManager = class AgySessionManager {
1833
2177
  const probes = await Promise.all(targets.map(async (account) => {
1834
2178
  const key = this.accountKey(account);
1835
2179
  try {
1836
- const auth = await this.accessTokenFor(account);
1837
- if (!auth) return {
1838
- key,
1839
- ok: false
1840
- };
1841
- const { fetchQuotaSummary } = await import("./quota-summary-adID82I2.mjs");
1842
- const routed = accountFetch({ proxyUrl: account.proxy });
1843
- const bounded = (input, init) => {
1844
- const timeout = AbortSignal.timeout(AgySessionManager.QUOTA_FETCH_TIMEOUT_MS);
1845
- const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
1846
- return routed(input, {
1847
- ...init,
1848
- signal
1849
- });
1850
- };
1851
- const groups = await fetchQuotaSummary(auth.access, account.projectId, bounded);
2180
+ const groups = await this.probeQuotaSummary(account);
1852
2181
  if (groups.length === 0) return {
1853
2182
  key,
1854
2183
  ok: false
@@ -1876,11 +2205,17 @@ var AgySessionManager = class AgySessionManager {
1876
2205
  if (updates.length === 0) return result;
1877
2206
  for (const update of updates) {
1878
2207
  const target = storage.accounts.find((candidate) => this.accountKey(candidate) === update.key);
1879
- if (target) target.cachedLimits = {
1880
- groups: update.groups,
1881
- updatedAt: update.updatedAt
1882
- };
2208
+ if (target) {
2209
+ target.cachedLimits = {
2210
+ groups: update.groups,
2211
+ updatedAt: update.updatedAt
2212
+ };
2213
+ this.noteLimitSample(update.key, update.groups, update.updatedAt);
2214
+ }
1883
2215
  }
2216
+ const live = new Set(storage.accounts.map((account) => this.accountKey(account)));
2217
+ for (const key of this.limitFractions.keys()) if (!live.has(key)) this.limitFractions.delete(key);
2218
+ for (const key of this.limitBurn.keys()) if (!live.has(key)) this.limitBurn.delete(key);
1884
2219
  try {
1885
2220
  await this.store.mutate((s) => {
1886
2221
  for (const update of updates) {
@@ -1911,7 +2246,6 @@ var AgySessionManager = class AgySessionManager {
1911
2246
  const auth = await this.accessTokenFor(account);
1912
2247
  if (!auth) return null;
1913
2248
  const { fetchAvailableModels } = await import("./models-Qn0-sh6X.mjs").then((n) => n.r);
1914
- const { fetchQuotaSummary } = await import("./quota-summary-adID82I2.mjs");
1915
2249
  const routed = accountFetch({ proxyUrl: account.proxy });
1916
2250
  const bounded = (input, init) => {
1917
2251
  const timeout = AbortSignal.timeout(AgySessionManager.QUOTA_FETCH_TIMEOUT_MS);
@@ -1921,7 +2255,7 @@ var AgySessionManager = class AgySessionManager {
1921
2255
  signal
1922
2256
  });
1923
2257
  };
1924
- const [discovered, groups] = await Promise.all([fetchAvailableModels(auth.access, account.projectId, bounded), fetchQuotaSummary(auth.access, account.projectId, bounded)]);
2258
+ const [discovered, groups] = await Promise.all([fetchAvailableModels(auth.access, account.projectId, bounded), this.probeQuotaSummary(account)]);
1925
2259
  const quotas = ingestFamilyQuotas(discovered, groups, account.cachedQuota);
1926
2260
  return {
1927
2261
  key,
@@ -1962,7 +2296,8 @@ var AgySessionManager = class AgySessionManager {
1962
2296
  }
1963
2297
  /**
1964
2298
  * Pick the account for one request: the affinity pin wins while it is fresh,
1965
- * healthy, and not drained for the requested model; otherwise the pool is
2299
+ * healthy, and not quota-exhausted for the requested model; the explicitly
2300
+ * activated account wins next while it is usable; otherwise the pool is
1966
2301
  * ranked by family-scoped usage (OMP-aligned) and the best candidate wins.
1967
2302
  */
1968
2303
  async pickAccount(storage, model, conversationKey) {
@@ -1975,7 +2310,7 @@ var AgySessionManager = class AgySessionManager {
1975
2310
  const lastIndex = storage.accounts.findIndex((a) => this.accountKey(a) === pinnedKey);
1976
2311
  if (lastIndex !== -1) {
1977
2312
  const last = storage.accounts[lastIndex];
1978
- if (last.enabled !== false && !isCoolingDown(last, now) && !isFamilyRateLimited(last, familyKey, now) && !isFamilyDrained(last, family, now)) return {
2313
+ if (last.enabled !== false && !isCoolingDown(last, now) && !isFamilyRateLimited(last, familyKey, now) && !isFamilyQuotaExhausted(last, family, now)) return {
1979
2314
  account: last,
1980
2315
  index: lastIndex
1981
2316
  };
@@ -1986,12 +2321,18 @@ var AgySessionManager = class AgySessionManager {
1986
2321
  index
1987
2322
  })).filter(({ account }) => account.enabled !== false);
1988
2323
  if (eligible.length === 0) return void 0;
2324
+ const active = storage.accounts[storage.activeIndex];
2325
+ const isActiveUsable = (acc) => acc !== void 0 && acc.enabled !== false && !isCoolingDown(acc, now) && !isFamilyRateLimited(acc, familyKey, now) && !isFamilyQuotaExhausted(acc, family, now);
2326
+ if (isActiveUsable(active) && this.inFlightCount(this.accountKey(active), now) < 3) return {
2327
+ account: active,
2328
+ index: storage.activeIndex
2329
+ };
1989
2330
  const ranked = rankPoolCandidates(eligible, model, now, storage.activeIndex);
1990
2331
  const picked = ranked.find((candidate) => candidate.blockedUntil === null && this.inFlightCount(this.accountKey(candidate.account), now) < 3) ?? ranked.find((candidate) => candidate.blockedUntil === null);
1991
2332
  if (!picked) {
1992
2333
  const quotaExhausted = (account) => {
1993
2334
  if (account.cooldownReason === "quota-exhausted" && (account.coolingDownUntil ?? 0) > now) return true;
1994
- const quota = familyQuotaFor(account, family);
2335
+ const quota = familyQuotaFor(account, family, now);
1995
2336
  if (!quota) return false;
1996
2337
  const spent = (fraction, resetTime) => {
1997
2338
  if (typeof fraction !== "number" || fraction > 0 || !resetTime) return false;
@@ -2168,6 +2509,13 @@ var AgySessionManager = class AgySessionManager {
2168
2509
  if (decision.action === "revoke") {
2169
2510
  this.tokenCache.delete(key);
2170
2511
  this.failureCounts.delete(key);
2512
+ const currentIndex = storage.accounts.findIndex((a) => this.accountKey(a) === key);
2513
+ const nextIndex = pickNextAccountIndex(storage.accounts, currentIndex >= 0 ? currentIndex : storage.activeIndex, Date.now());
2514
+ if (nextIndex !== storage.activeIndex) {
2515
+ storage.activeIndex = nextIndex;
2516
+ nextIndexToRotate = nextIndex;
2517
+ }
2518
+ this.clearAffinityForAccount(key);
2171
2519
  return;
2172
2520
  }
2173
2521
  if (kind === "rate-limit" && info?.rateLimitCategory !== "soft_rate_limit") {
@@ -2183,7 +2531,7 @@ var AgySessionManager = class AgySessionManager {
2183
2531
  }
2184
2532
  }
2185
2533
  }
2186
- if (decision.action === "rotate") {
2534
+ if (decision.action === "rotate" || decision.action === "cool") {
2187
2535
  const currentIndex = storage.accounts.findIndex((a) => this.accountKey(a) === key);
2188
2536
  const familyKey = familyKeyOf(info?.model);
2189
2537
  const nextIndex = pickNextAccountIndex(storage.accounts, currentIndex >= 0 ? currentIndex : storage.activeIndex, Date.now(), familyKey);
@@ -2201,7 +2549,8 @@ var AgySessionManager = class AgySessionManager {
2201
2549
  source: "chat",
2202
2550
  ok: false,
2203
2551
  rotated: true,
2204
- poolEvent: true
2552
+ poolEvent: true,
2553
+ reason: kind
2205
2554
  });
2206
2555
  }
2207
2556
  }
@@ -2241,7 +2590,7 @@ var AgySessionManager = class AgySessionManager {
2241
2590
  };
2242
2591
  try {
2243
2592
  const account = session.account.email ?? session.account.id;
2244
- const { toAgyRequestBody } = await import("./translate-BQu8HLvB.mjs").then((n) => n.n);
2593
+ const { toAgyRequestBody } = await import("./translate-YhGDUuVa.mjs").then((n) => n.n);
2245
2594
  const { fetchAgyFirstOk } = await import("./constants-BpyRirvk.mjs").then((n) => n.f);
2246
2595
  const { parseAgySse } = await import("./parse-Bs4rPkHA.mjs").then((n) => n.n);
2247
2596
  const requestId = generateAntigravityRequestId();
@@ -2682,6 +3031,6 @@ var JsonAccountStore = class {
2682
3031
  }
2683
3032
  };
2684
3033
  //#endregion
2685
- export { resolveMasterKeyCodec as _, generateFingerprint as a, describeFetchError as b, resolveAntigravityVersionBounded as c, MASTER_KEY_REF as d, createAesGcmCodec as f, resolveDshHome as g, persistMasterKey as h, isAgyDisabled as i, clearExpiredState as l, loadMasterKey as m, maskProxyUrl as n, recordFingerprintVersion as o, deriveKey as p, AgySessionManager as r, resolveAntigravityVersion as s, JsonAccountStore as t, pickProbeProxyUrl as u, classifyFetchError as v, isSessionAccumulationOverflow as x, classifyHttpError as y };
3034
+ export { isSessionAccumulationOverflow as S, resolveDshHome as _, generateFingerprint as a, classifyHttpError as b, resolveAntigravityVersionBounded as c, migrateToAgyDir as d, MASTER_KEY_REF as f, persistMasterKey as g, loadMasterKey as h, isAgyDisabled as i, clearExpiredState as l, deriveKey as m, maskProxyUrl as n, recordFingerprintVersion as o, createAesGcmCodec as p, AgySessionManager as r, resolveAntigravityVersion as s, JsonAccountStore as t, pickProbeProxyUrl as u, resolveMasterKeyCodec as v, describeFetchError as x, classifyFetchError as y };
2686
3035
 
2687
- //# sourceMappingURL=accounts-CKWybAfc.mjs.map
3036
+ //# sourceMappingURL=accounts-BjdDqUq1.mjs.map