dsh-agy-link 0.4.14 → 0.4.16

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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.16 (2026-08-24)
4
+
5
+ - **External Re-Login Sync (换号自动/手动同步).**
6
+ - **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.
7
+ - **`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.
8
+ - **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.
9
+ - **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).
10
+ - **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.
11
+ - **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.
12
+
13
+ ## 0.4.15 (2026-08-24)
14
+
15
+ - **Quota Polling Risk-Exposure Minimization (root-cause follow-up).**
16
+ - **Poll Interval 5min → 15min (configurable)**: New `quotaPollIntervalMs` config (env `DSH_AGY_QUOTA_POLL_INTERVAL_MS`, clamped to >= 60s) cuts background `v1internal` polling volume by 3x — the poller was the last remaining high-frequency network surface after the CLI-level hardening.
17
+ - **Restricted-Account Poll Gating**: Automatic polling now skips disabled, auth-quarantined (`invalid_grant`) and 429-cooldown accounts (`shouldPollAccount`), so the poller never keeps probing Google endpoints for accounts already known to be limited. Manual UI force-refresh still refreshes everything.
18
+ - **Userinfo Endpoint Called Only When Email Unknown**: The primary account previously hit the OAuth userinfo endpoint on every poll cycle just to detect account switching; that detection now rides the local log scan (`detectEmailFromAgyLogs`, zero network), eliminating one network call per cycle.
19
+
3
20
  ## 0.4.14 (2026-08-24)
4
21
 
5
22
  - **Hardened 429 Rate Limit Cooldown & Circuit Breaker (Anti-Risk Control).**
package/dist/client.js CHANGED
@@ -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
@@ -90,7 +90,8 @@ function defaultConfig() {
90
90
  rateLimitPerMinute: 0,
91
91
  autoFallbackModel: false,
92
92
  logRetentionDays: 7,
93
- disableTelemetry: true
93
+ disableTelemetry: true,
94
+ quotaPollIntervalMs: 9e5
94
95
  };
95
96
  }
96
97
  const Err = {
@@ -225,7 +226,8 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
225
226
  rateLimitPerMinute: asNum(get("rateLimitPerMinute")) ?? base.rateLimitPerMinute,
226
227
  autoFallbackModel: asBool(get("autoFallbackModel")) ?? base.autoFallbackModel,
227
228
  logRetentionDays: asNum(get("logRetentionDays")) ?? base.logRetentionDays,
228
- disableTelemetry: asBool(get("disableTelemetry")) ?? base.disableTelemetry
229
+ disableTelemetry: asBool(get("disableTelemetry")) ?? base.disableTelemetry,
230
+ quotaPollIntervalMs: asNum(get("quotaPollIntervalMs")) ?? base.quotaPollIntervalMs
229
231
  };
230
232
  if (env.DSH_AGY_ENABLED !== void 0) cfg.enabled = asBool(env.DSH_AGY_ENABLED) ?? cfg.enabled;
231
233
  if (env.DSH_AGY_BIN) cfg.agyBin = env.DSH_AGY_BIN;
@@ -271,6 +273,10 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
271
273
  const dt = asBool(env.DSH_AGY_DISABLE_TELEMETRY);
272
274
  if (dt !== void 0) cfg.disableTelemetry = dt;
273
275
  }
276
+ if (env.DSH_AGY_QUOTA_POLL_INTERVAL_MS) {
277
+ const q = asNum(env.DSH_AGY_QUOTA_POLL_INTERVAL_MS);
278
+ if (q && q >= 6e4) cfg.quotaPollIntervalMs = q;
279
+ }
274
280
  return cfg;
275
281
  }
276
282
  //#endregion
@@ -284,6 +290,20 @@ function modelFamilyOf(modelId) {
284
290
  if (id.startsWith("gpt-") || id.startsWith("openai/") || id.includes("gpt-oss")) return "openai";
285
291
  return "unknown";
286
292
  }
293
+ /**
294
+ * Whether the background quota poller should touch this account at all.
295
+ * Disabled, auth-quarantined and cooldown accounts are skipped so automatic
296
+ * polling never hammers Google endpoints for accounts already known to be
297
+ * restricted (risk-control exposure minimization). Manual force refresh
298
+ * from the UI bypasses this gate.
299
+ */
300
+ function shouldPollAccount(account) {
301
+ if (!account.enabled) return false;
302
+ if (account.authRequired) return false;
303
+ const now = Date.now();
304
+ for (const cd of Object.values(account.cooldowns)) if (cd && cd.cooldownUntil > now) return false;
305
+ return true;
306
+ }
287
307
  function defaultPoolData() {
288
308
  return {
289
309
  version: 1,
@@ -27062,7 +27082,7 @@ var AccountPoolManager = class {
27062
27082
  * and signed in via /agy add-account.
27063
27083
  */
27064
27084
  bootstrapDefaultAccount() {
27065
- if (this.data.accounts.length > 0) return;
27085
+ if (this.data.accounts.some((a) => a.systemHome)) return;
27066
27086
  const primary = {
27067
27087
  id: "acc_primary",
27068
27088
  alias: "主账号 (系统登录)",
@@ -27073,7 +27093,7 @@ var AccountPoolManager = class {
27073
27093
  cooldowns: {},
27074
27094
  quotas: {}
27075
27095
  };
27076
- this.data.accounts.push(primary);
27096
+ this.data.accounts.unshift(primary);
27077
27097
  this.data.primaryAccountId = primary.id;
27078
27098
  this.persist();
27079
27099
  }
@@ -27223,7 +27243,7 @@ var AccountPoolManager = class {
27223
27243
  force: true
27224
27244
  });
27225
27245
  } catch {}
27226
- if (this.data.primaryAccountId === id) this.data.primaryAccountId = this.data.accounts[0]?.id;
27246
+ if (this.data.primaryAccountId === id) this.data.primaryAccountId = void 0;
27227
27247
  if (this.data.activeAccountIds) {
27228
27248
  for (const [fam, accId] of Object.entries(this.data.activeAccountIds)) if (accId === id) delete this.data.activeAccountIds[fam];
27229
27249
  }
@@ -27264,6 +27284,22 @@ var AccountPoolManager = class {
27264
27284
  }
27265
27285
  this.persist();
27266
27286
  }
27287
+ /**
27288
+ * External re-login detected (agy logout + new login): identity-bound
27289
+ * state from the PREVIOUS account (cooldowns, quotas, auth quarantine)
27290
+ * must not leak onto the new one. Resets everything email-bound while
27291
+ * keeping slot config (alias, dir, proxy, enabled).
27292
+ */
27293
+ resetAccountIdentity(id, newEmail) {
27294
+ const acc = this.getAccount(id);
27295
+ if (!acc) return;
27296
+ acc.email = newEmail;
27297
+ acc.cooldowns = {};
27298
+ acc.quotas = {};
27299
+ delete acc.authRequired;
27300
+ delete acc.authError;
27301
+ this.persist();
27302
+ }
27267
27303
  clearAuthRequired(id) {
27268
27304
  const acc = this.getAccount(id);
27269
27305
  if (!acc) return;
@@ -27882,20 +27918,16 @@ var QuotaService = class {
27882
27918
  const detected = detectEmailFromAgyLogs(home);
27883
27919
  if (detected) email = detected;
27884
27920
  }
27921
+ if (email && email !== account.email) this.pool.resetAccountIdentity(account.id, email);
27885
27922
  const accessToken = await this.getValidAccessToken(account);
27886
- if (!accessToken) {
27887
- if (email && email !== account.email) this.pool.updateAccountQuotas(account.id, account.quotas, email);
27888
- return null;
27889
- }
27923
+ if (!accessToken) return null;
27890
27924
  const [summary, discovered] = await Promise.all([this.fetchQuotaSummary(accessToken, account.proxyUrl), this.fetchAvailableModels(accessToken, account.proxyUrl)]);
27891
- if (account.systemHome || !email) {
27925
+ if (!email) {
27892
27926
  const info = await this.fetchUserInfo(accessToken, account.proxyUrl);
27893
27927
  if (info?.email) email = info.email;
27894
27928
  }
27895
- if (!summary && (!discovered || !discovered.models)) {
27896
- if (email && email !== account.email) this.pool.updateAccountQuotas(account.id, account.quotas, email);
27897
- return null;
27898
- }
27929
+ if (!summary && (!discovered || !discovered.models)) return null;
27930
+ if (account.authRequired) this.pool.clearAuthRequired(account.id);
27899
27931
  const familyQuotas = {};
27900
27932
  if (summary && Array.isArray(summary.groups)) for (const group of summary.groups) {
27901
27933
  const dName = (group.displayName || "").toLowerCase();
@@ -27964,9 +27996,29 @@ var QuotaService = class {
27964
27996
  }
27965
27997
  /**
27966
27998
  * Refresh quota statistics for all accounts in the pool.
27999
+ * Automatic polling (force=false) skips restricted accounts (disabled /
28000
+ * auth-quarantined / in cooldown) so the poller never keeps knocking on
28001
+ * Google endpoints for accounts already known to be limited. Manual force
28002
+ * refresh from the UI refreshes everything.
28003
+ *
28004
+ * Before gating, a ZERO-NETWORK identity reconciliation runs for flagged
28005
+ * slots: an external `agy logout` + re-login writes the new email into the
28006
+ * newest CLI logs, and detecting that locally lets us reset the stale
28007
+ * quarantine/cooldowns so the slot rejoins polling — no extra request to
28008
+ * Google is made for this check (risk-control neutral).
27967
28009
  */
27968
28010
  async refreshAllQuotas(force = false) {
27969
- const accounts = this.pool.getAccounts();
28011
+ let accounts = this.pool.getAccounts();
28012
+ if (!force) {
28013
+ const now = Date.now();
28014
+ for (const acc of accounts) {
28015
+ const flagged = acc.authRequired || Object.values(acc.cooldowns).some((cd) => cd && cd.cooldownUntil > now);
28016
+ if (!acc.systemHome || !flagged) continue;
28017
+ const detected = detectEmailFromAgyLogs(acc.systemHome || !acc.dir ? homedir() : acc.dir);
28018
+ if (detected && detected !== acc.email) this.pool.resetAccountIdentity(acc.id, detected);
28019
+ }
28020
+ accounts = accounts.filter(shouldPollAccount);
28021
+ }
27970
28022
  await Promise.allSettled(accounts.map((acc) => this.refreshAccountQuota(acc, force)));
27971
28023
  }
27972
28024
  };
@@ -28713,6 +28765,7 @@ function apply(ctx, entryConfig = {}) {
28713
28765
  const acc = pool.getAccount(id);
28714
28766
  if (acc) await quota.refreshAccountQuota(acc, true);
28715
28767
  } else await quota.refreshAllQuotas(true);
28768
+ catalog.forceRefresh().catch(() => void 0);
28716
28769
  sendJson(res, 200, {
28717
28770
  ok: true,
28718
28771
  pool: pool.getPoolData()
@@ -28830,7 +28883,7 @@ function apply(ctx, entryConfig = {}) {
28830
28883
  quota.refreshAllQuotas().catch(() => void 0);
28831
28884
  };
28832
28885
  const boot = setTimeout(refresh, 5e3);
28833
- const timer = setInterval(refresh, 3e5);
28886
+ const timer = setInterval(refresh, Math.max(6e4, getConfig().quotaPollIntervalMs));
28834
28887
  return () => {
28835
28888
  clearTimeout(boot);
28836
28889
  clearInterval(timer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-agy-link",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
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",