dsh-agy-link 0.4.15 → 0.4.18

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.18 (2026-08-25)
4
+
5
+ - **Quota Fallback Never Clobbers Good Data (5h=100%/weekly-missing 根因).**
6
+ - **What happened**: `retrieveUserQuotaSummary` transiently failed (proxy blip) while `fetchAvailableModels` still answered; the per-model fallback then OVERWROTE the stored family entry with a single-window partial shape — weeklyFraction vanished and the 5h row received wrong-window numbers (observed 5h=100%, reset a week out, weekly `—`, while the live API actually reported 5h 84% / weekly 47%).
7
+ - **`mergeFallbackFamilyQuota`**: last-known-good complete family data now always wins over partial fallback; the fallback only fills families with no usable previous entry (first-ever refresh). Verified live: both endpoints answer correctly and a successful summary refresh fully restores the display.
8
+ - Added `scripts/diag-quota.mts` one-shot endpoint probe for future incidents.
9
+
10
+ ## 0.4.17 (2026-08-25)
11
+
12
+ - **Ghost-Cooldown & Quota-Display Fix (额度显示 0% 根因).**
13
+ - **What happened**: the UI showed 5h quota as 0% while `agy` reported 98% — the parsed quota data was CORRECT all along, but (a) any active local cooldown forced the 5h bar to render 0%, and (b) the loose rate-limit classifier kept creating ghost cooldowns: it scanned the ENTIRE stdout (model prose mentioning "rate limit"/"quota", hash fragments containing "429") and matched bare keywords, so an unrelated tool/permission error froze a healthy account out of rotation with a 15-minute+ cooldown (captured real reason: `rate limit reached: declaring permissions: cortex tool write_to_file … invalid tool call error`).
14
+ - **Hard vs soft classification**: new `looksLikeHardRateLimit` (RESOURCE_EXHAUSTED / code·status·HTTP 429 / too many requests / individual quota reached / quota exceeded·reached·exhausted / rate limit exceeded·reached·hit) is the ONLY pattern allowed to put an account into cooldown; soft signals (model overloaded / high traffic) still shape the error message but never cool accounts.
15
+ - **Scan scope narrowed**: error classification reads stderr + the result envelope's error field only — stdout (event JSON + model prose) no longer participates.
16
+ - **Honest quota bars**: a local cooldown no longer overwrites the server-reported fraction with 0%; it now appends a `· 本地冷却中` note next to the reset time instead.
17
+ - Regression tests pin the exact incident text (cortex tool permission error) as a non-rate-limit fixture.
18
+
19
+ ## 0.4.16 (2026-08-24)
20
+
21
+ - **External Re-Login Sync (换号自动/手动同步).**
22
+ - **Root cause fixed**: after `agy logout` + re-login as a DIFFERENT account, the pool slot kept the old account's email, cooldowns, quotas and `auth_required` quarantine — and poll gating (0.4.15) then skipped the flagged slot forever, freezing the UI on the stale account.
23
+ - **`resetAccountIdentity`**: detecting a changed email (from local CLI logs, zero network) now resets all identity-bound state (cooldowns / quotas / auth quarantine) while keeping slot config — the new account starts clean instead of inheriting the old one's restrictions.
24
+ - **Zero-network reconciliation before poll gating**: the background poller pre-checks flagged slots via local log scan, so an external re-login self-heals within one poll cycle (≤15 min) without any extra request to Google. Manual click is instant.
25
+ - **Auth self-heal on success**: a successful authenticated quota fetch clears a stale `auth_required` flag (the old token's invalid_grant no longer condemns the new login).
26
+ - **Manual refresh upgrades**: the 刷新 button now also re-reads the model catalog (one `agy models` spawn per explicit click only — new subscription tier may expose different models), and every account card gains a 同步 button for single-account refresh.
27
+ - **Primary slot always re-bootstrapped (real root cause of "UI stuck on old account")**: the primary slot was only created for an EMPTY pool, so once deleted it never came back while other accounts remained — the system-HOME login (e.g. after `agy logout` + re-login) had no slot to attach to and the UI kept showing an isolated account as 主账号. The slot is now recreated at the front on every load; deleting it no longer promotes an isolated account to primary. Disable the slot instead of deleting if unwanted.
28
+
3
29
  ## 0.4.15 (2026-08-24)
4
30
 
5
31
  - **Quota Polling Risk-Exposure Minimization (root-cause follow-up).**
package/dist/client.js CHANGED
@@ -963,8 +963,8 @@ body.dark,
963
963
  const info = acc.quotas[familyKey];
964
964
  const cd = acc.cooldowns[familyKey];
965
965
  const inCooldown = cd && cd.cooldownUntil > Date.now();
966
- let pct5h = typeof info?.remainingFraction === "number" && Number.isFinite(info.remainingFraction) ? Math.max(0, Math.min(100, Math.round(info.remainingFraction * 100))) : -1;
967
- if (inCooldown) pct5h = 0;
966
+ const pct5h = typeof info?.remainingFraction === "number" && Number.isFinite(info.remainingFraction) ? Math.max(0, Math.min(100, Math.round(info.remainingFraction * 100))) : -1;
967
+ const cdNote = inCooldown ? " · 本地冷却中" : "";
968
968
  const w5h = formatQuotaWindow(info?.resetTime);
969
969
  const pctWeekly = typeof info?.weeklyFraction === "number" && Number.isFinite(info.weeklyFraction) ? Math.max(0, Math.min(100, Math.round(info.weeklyFraction * 100))) : -1;
970
970
  const wWeekly = formatQuotaWindow(info?.weeklyResetTime);
@@ -1052,7 +1052,7 @@ body.dark,
1052
1052
  fontSize: "12.5px",
1053
1053
  marginBottom: "5px",
1054
1054
  color: "var(--agy-text-primary)"
1055
- } }, brandIcon(FAMILY_BRAND[familyKey], 14), h("span", null, label)), renderLine("5h 额度", pct5h, c5h, w5h.resetText), renderLine("周额度", pctWeekly, cWeekly, wWeekly.resetText));
1055
+ } }, brandIcon(FAMILY_BRAND[familyKey], 14), h("span", null, label)), renderLine("5h 额度", pct5h, c5h, w5h.resetText ? w5h.resetText + cdNote : cdNote.replace(/^ · /, "")), renderLine("周额度", pctWeekly, cWeekly, wWeekly.resetText));
1056
1056
  };
1057
1057
  const renderedAccountCards = accounts.map((acc) => {
1058
1058
  const isPrimary = acc.id === pool?.primaryAccountId;
@@ -1140,7 +1140,17 @@ body.dark,
1140
1140
  gap: "3px"
1141
1141
  },
1142
1142
  onClick: () => toggleExpand(acc.id)
1143
- }, isExpanded ? [uiIcon("chevronUp", 11), " 收起"] : [uiIcon("chevronDown", 11), " 明细"]) : null, !isPrimary ? h("button", {
1143
+ }, isExpanded ? [uiIcon("chevronUp", 11), " 收起"] : [uiIcon("chevronDown", 11), " 明细"]) : null, h("button", {
1144
+ type: "button",
1145
+ className: "agy-btn",
1146
+ style: {
1147
+ ...S.btnSm,
1148
+ gap: "4px"
1149
+ },
1150
+ title: "刷新此账号(换号后点这里立即同步 email / 额度 / 模型)",
1151
+ disabled: isBusy,
1152
+ onClick: () => void refreshQuota(acc.id)
1153
+ }, loadingAction === `refresh:${acc.id}` ? [renderSpinner(), "同步中"] : [uiIcon("refresh", 11), " 同步"]), !isPrimary ? h("button", {
1144
1154
  type: "button",
1145
1155
  className: "agy-btn",
1146
1156
  style: {
package/dist/index.js CHANGED
@@ -115,9 +115,25 @@ function extractAuthUrl(text) {
115
115
  if (!m) return void 0;
116
116
  return m[0].replace(/[)\]>.,;\x27\x22]+$/, "");
117
117
  }
118
+ /**
119
+ * HARD, server-issued rate-limit signatures — the ONLY patterns allowed to
120
+ * put an account into cooldown. Deliberately narrow: bare `429` or
121
+ * `rate limit` matched incidental substrings in the wild (hash/UUID
122
+ * fragments, model prose mentioning quotas, unrelated tool/permission
123
+ * errors quoting such words) and produced ghost cooldowns that froze
124
+ * healthy accounts out of rotation.
125
+ */
126
+ function looksLikeHardRateLimit(text) {
127
+ if (!text) return false;
128
+ return /RESOURCE_EXHAUSTED|code[ :]?429\b|status[ :]?429\b|HTTP[ :]?429\b|too many requests|individual quota reached|quota (?:exceeded|reached|exhausted)|rate[ -]?limit(?:ed)? (?:exceeded|reached|hit)|exceeded (?:your |the )?quota/i.test(text);
129
+ }
130
+ /**
131
+ * Soft heuristic adds capacity signals (model overloaded / high traffic).
132
+ * Shapes the user-facing error message only — NEVER cools an account down.
133
+ */
118
134
  function looksLikeRateLimit(text) {
119
135
  if (!text) return false;
120
- return /429|too many requests|resource_exhausted|quota exceeded|quota reached|individual quota reached|rate limit|model overloaded|server.*experiencing high traffic|exceeded.*quota/i.test(text);
136
+ return looksLikeHardRateLimit(text) || /model overloaded|experiencing high traffic/i.test(text);
121
137
  }
122
138
  /**
123
139
  * Parse reset duration in milliseconds from rate-limit / quota-exhausted error strings.
@@ -2364,11 +2380,8 @@ var AgyAdapter = class extends LlmAdapter {
2364
2380
  const conversationId = streamCid ?? diffed;
2365
2381
  const r = rec.getResultEvent();
2366
2382
  const consumable = r !== null && (r.ok || r.response !== "");
2367
- const isRateLimit = looksLikeRateLimit([
2368
- outcome.stderrTail,
2369
- outcome.stdout,
2370
- parser.stats.lastResultError
2371
- ].filter(Boolean).join(" "));
2383
+ const rawErrText = [outcome.stderrTail, parser.stats.lastResultError].filter(Boolean).join(" ");
2384
+ const isRateLimit = looksLikeRateLimit(rawErrText);
2372
2385
  let failure = null;
2373
2386
  if (outcome.aborted) failure = {
2374
2387
  kind: "aborted",
@@ -2423,7 +2436,7 @@ var AgyAdapter = class extends LlmAdapter {
2423
2436
  }
2424
2437
  } else {
2425
2438
  const effectiveRateLimit = isRateLimit || looksLikeRateLimit(failure.message);
2426
- if (account && effectiveRateLimit) this.deps.pool?.recordFailure(account.id, family, failure.message);
2439
+ if (account && looksLikeHardRateLimit(rawErrText)) this.deps.pool?.recordFailure(account.id, family, failure.message);
2427
2440
  if (account && (failure.code === Err.AUTH || /invalid_grant|not signed in|auth/i.test(failure.message))) this.deps.pool?.markAuthRequired(account.id, failure.message);
2428
2441
  if (!isAux && sessionAccountKey !== "") {
2429
2442
  if (failure.code === Err.AUTH || effectiveRateLimit || failure.message && /conversation.*(not found|invalid|not recognized|expired|does not exist)|session.*(expired|invalid)/i.test(failure.message)) this.deps.store.delete(sessionAccountKey);
@@ -27082,7 +27095,7 @@ var AccountPoolManager = class {
27082
27095
  * and signed in via /agy add-account.
27083
27096
  */
27084
27097
  bootstrapDefaultAccount() {
27085
- if (this.data.accounts.length > 0) return;
27098
+ if (this.data.accounts.some((a) => a.systemHome)) return;
27086
27099
  const primary = {
27087
27100
  id: "acc_primary",
27088
27101
  alias: "主账号 (系统登录)",
@@ -27093,7 +27106,7 @@ var AccountPoolManager = class {
27093
27106
  cooldowns: {},
27094
27107
  quotas: {}
27095
27108
  };
27096
- this.data.accounts.push(primary);
27109
+ this.data.accounts.unshift(primary);
27097
27110
  this.data.primaryAccountId = primary.id;
27098
27111
  this.persist();
27099
27112
  }
@@ -27243,7 +27256,7 @@ var AccountPoolManager = class {
27243
27256
  force: true
27244
27257
  });
27245
27258
  } catch {}
27246
- if (this.data.primaryAccountId === id) this.data.primaryAccountId = this.data.accounts[0]?.id;
27259
+ if (this.data.primaryAccountId === id) this.data.primaryAccountId = void 0;
27247
27260
  if (this.data.activeAccountIds) {
27248
27261
  for (const [fam, accId] of Object.entries(this.data.activeAccountIds)) if (accId === id) delete this.data.activeAccountIds[fam];
27249
27262
  }
@@ -27284,6 +27297,22 @@ var AccountPoolManager = class {
27284
27297
  }
27285
27298
  this.persist();
27286
27299
  }
27300
+ /**
27301
+ * External re-login detected (agy logout + new login): identity-bound
27302
+ * state from the PREVIOUS account (cooldowns, quotas, auth quarantine)
27303
+ * must not leak onto the new one. Resets everything email-bound while
27304
+ * keeping slot config (alias, dir, proxy, enabled).
27305
+ */
27306
+ resetAccountIdentity(id, newEmail) {
27307
+ const acc = this.getAccount(id);
27308
+ if (!acc) return;
27309
+ acc.email = newEmail;
27310
+ acc.cooldowns = {};
27311
+ acc.quotas = {};
27312
+ delete acc.authRequired;
27313
+ delete acc.authError;
27314
+ this.persist();
27315
+ }
27287
27316
  clearAuthRequired(id) {
27288
27317
  const acc = this.getAccount(id);
27289
27318
  if (!acc) return;
@@ -27652,6 +27681,18 @@ var PoolAuthFlow = class {
27652
27681
  };
27653
27682
  //#endregion
27654
27683
  //#region src/host/quota.ts
27684
+ /**
27685
+ * When the quota-summary endpoint transiently fails, per-model fallback
27686
+ * data carries a SINGLE window (sometimes the weekly one) and no weekly
27687
+ * fields. Overwriting a previously COMPLETE family entry with that partial
27688
+ * shape dropped weeklyFraction to none and put wrong-window numbers into
27689
+ * the 5h row (observed: 5h=100% / reset a week out / weekly missing).
27690
+ * Rule: last-known-good complete data always wins over partial fallback.
27691
+ */
27692
+ function mergeFallbackFamilyQuota(prev, fallback) {
27693
+ if (prev && typeof prev.remainingFraction === "number") return prev;
27694
+ return fallback;
27695
+ }
27655
27696
  function detectEmailFromAgyLogs(homeDir) {
27656
27697
  const logDir = join(homeDir, ".gemini", "antigravity-cli", "log");
27657
27698
  if (!existsSync(logDir)) return void 0;
@@ -27902,20 +27943,16 @@ var QuotaService = class {
27902
27943
  const detected = detectEmailFromAgyLogs(home);
27903
27944
  if (detected) email = detected;
27904
27945
  }
27946
+ if (email && email !== account.email) this.pool.resetAccountIdentity(account.id, email);
27905
27947
  const accessToken = await this.getValidAccessToken(account);
27906
- if (!accessToken) {
27907
- if (email && email !== account.email) this.pool.updateAccountQuotas(account.id, account.quotas, email);
27908
- return null;
27909
- }
27948
+ if (!accessToken) return null;
27910
27949
  const [summary, discovered] = await Promise.all([this.fetchQuotaSummary(accessToken, account.proxyUrl), this.fetchAvailableModels(accessToken, account.proxyUrl)]);
27911
27950
  if (!email) {
27912
27951
  const info = await this.fetchUserInfo(accessToken, account.proxyUrl);
27913
27952
  if (info?.email) email = info.email;
27914
27953
  }
27915
- if (!summary && (!discovered || !discovered.models)) {
27916
- if (email && email !== account.email) this.pool.updateAccountQuotas(account.id, account.quotas, email);
27917
- return null;
27918
- }
27954
+ if (!summary && (!discovered || !discovered.models)) return null;
27955
+ if (account.authRequired) this.pool.clearAuthRequired(account.id);
27919
27956
  const familyQuotas = {};
27920
27957
  if (summary && Array.isArray(summary.groups)) for (const group of summary.groups) {
27921
27958
  const dName = (group.displayName || "").toLowerCase();
@@ -27960,11 +27997,11 @@ var QuotaService = class {
27960
27997
  remainingFraction: remaining,
27961
27998
  resetTime
27962
27999
  });
27963
- if (!familyQuotas[fam]) familyQuotas[fam] = {
28000
+ if (!familyQuotas[fam]) familyQuotas[fam] = mergeFallbackFamilyQuota(account.quotas[fam], {
27964
28001
  remainingFraction: remaining,
27965
28002
  resetTime,
27966
28003
  updatedAt: now
27967
- };
28004
+ });
27968
28005
  else if (familyQuotas[fam].remainingFraction === void 0) {
27969
28006
  const curRemaining = familyQuotas[fam].remainingFraction ?? 1;
27970
28007
  if (remaining < curRemaining) {
@@ -27988,9 +28025,25 @@ var QuotaService = class {
27988
28025
  * auth-quarantined / in cooldown) so the poller never keeps knocking on
27989
28026
  * Google endpoints for accounts already known to be limited. Manual force
27990
28027
  * refresh from the UI refreshes everything.
28028
+ *
28029
+ * Before gating, a ZERO-NETWORK identity reconciliation runs for flagged
28030
+ * slots: an external `agy logout` + re-login writes the new email into the
28031
+ * newest CLI logs, and detecting that locally lets us reset the stale
28032
+ * quarantine/cooldowns so the slot rejoins polling — no extra request to
28033
+ * Google is made for this check (risk-control neutral).
27991
28034
  */
27992
28035
  async refreshAllQuotas(force = false) {
27993
- const accounts = force ? this.pool.getAccounts() : this.pool.getAccounts().filter(shouldPollAccount);
28036
+ let accounts = this.pool.getAccounts();
28037
+ if (!force) {
28038
+ const now = Date.now();
28039
+ for (const acc of accounts) {
28040
+ const flagged = acc.authRequired || Object.values(acc.cooldowns).some((cd) => cd && cd.cooldownUntil > now);
28041
+ if (!acc.systemHome || !flagged) continue;
28042
+ const detected = detectEmailFromAgyLogs(acc.systemHome || !acc.dir ? homedir() : acc.dir);
28043
+ if (detected && detected !== acc.email) this.pool.resetAccountIdentity(acc.id, detected);
28044
+ }
28045
+ accounts = accounts.filter(shouldPollAccount);
28046
+ }
27994
28047
  await Promise.allSettled(accounts.map((acc) => this.refreshAccountQuota(acc, force)));
27995
28048
  }
27996
28049
  };
@@ -28737,6 +28790,7 @@ function apply(ctx, entryConfig = {}) {
28737
28790
  const acc = pool.getAccount(id);
28738
28791
  if (acc) await quota.refreshAccountQuota(acc, true);
28739
28792
  } else await quota.refreshAllQuotas(true);
28793
+ catalog.forceRefresh().catch(() => void 0);
28740
28794
  sendJson(res, 200, {
28741
28795
  ok: true,
28742
28796
  pool: pool.getPoolData()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-agy-link",
3
- "version": "0.4.15",
3
+ "version": "0.4.18",
4
4
  "description": "Google Antigravity (agy CLI) models for DeepSeek Harness — stream Gemini/Claude/GPT-OSS subscriptions into DSH with thinking, tool activity, token usage and in-GUI Google OAuth login.",
5
5
  "type": "module",
6
6
  "license": "MIT",