dsh-workbuddy-xdpool 1.8.0 → 1.8.2

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
@@ -4,6 +4,84 @@
4
4
 
5
5
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
6
6
 
7
+ ## 1.8.2 — 免费的那个被限流了,旁边那个收费的同款还能用
8
+
9
+ > 免费模型被限流的时候,插件以前只会说一句「所有账号都在冷却」。这句话没错,但它让用户以为自己只剩一条路:等。其实同一个模型往往还有一条**收费的**产品行,而且在限流器眼里它们是两个不同的模型——免费行被关小黑屋的时候,收费行通常毫发无伤。这个版本就是把这个「另一条路」写出来。
10
+
11
+ ### 同一个模型,凭什么有两行
12
+
13
+ 上游把一条产品线按**多行**卖,每行有自己的 id、自己的价格、自己的限流额度,但**显示名字是一样的**。实测两个网关:
14
+
15
+ | 网关 | 免费行 | 收费行 |
16
+ | --- | --- | --- |
17
+ | 国际版 | `deepseek-v4.1-flash`(x0.00) | `deepseek-v4.1-flash-sg`(x0.03) |
18
+ | 国内版 | `hy3`(x0.00) | `hy3-x`(x0.05) |
19
+
20
+ 注意 `-sg` 和 `-x` 跟免费行**没有任何前缀关系**,能把它们认成一家的只有那个相同的显示名。所以匹配是按名字来的,不是按 id 猜的。
21
+
22
+ ### 现在会怎么提示
23
+
24
+ - **模型列表**:免费行后面挂一个小灰标「付费同款 deepseek-v4.1-flash-sg(0.03x)」,鼠标悬停解释「免费档先被限流;同一个模型的付费行不受这个限制影响」。灰的、低调的——这一行现在仍然是免费的那个,标签只是告诉你退路在哪,不是催你花钱。
25
+ - **429 报错**:以前是「every account is rate-limited for model deepseek-v4.1-flash (4/4 cooling, tried 0)」,现在后面会接一句「deepseek-v4.1-flash-sg is the same model on a paid row (x0.03 credits) and is rate-limited separately; switch to it to keep going」。
26
+ - **账号卡片**:某个账号上「`hy4-preview` cooling to 10:14」这种小标后面,也会跟上「→ 收费行的 id(倍率)还能用」。
27
+
28
+ ### 刻意没做的事
29
+
30
+ - **收费行不标标签。** 只有倍率等于 0 的行才找孪生兄弟。否则「GLM-5.3」这种本身收费、又恰好有个更便宜的闪速版的模型,也会被挂上一个标签,最后满屏都是提示,等于没提示。
31
+ - **没有收费同款就不说话。** 独苗模型(整条线只有它自己)不会有任何多余文字,也不会留下一个孤零零的分隔符。
32
+ - **不把另一个免费行当退路。** 两个免费行是一起被限流的,切过去纯属换个人排队,说了等于骗人。
33
+ - **倍率未知的行不算数。** 上游没给价格的兄弟行不能被承诺成「那个收费的」——用户切过去以为知道价钱,结果未必。
34
+
35
+ ### 顺带说清楚:国际版没有「每天领 30 积分」
36
+
37
+ 上一版已经查过一遍,这次把话说完整。国际版(`www.workbuddy.ai`)**没有**签到活动,也**没有** `daily-checkin` 这条路由:
38
+
39
+ - `POST /v2/billing/meter/checkin-activity-status` 返回 200,但 `active: false`(字段在,活动没开);
40
+ - `GET /v2/billing/meter/get-user-resource` 直接 404(国内版才有这个接口);
41
+ - `/v2/billing/meter/checkin`、`/daily-checkin-activity`、`/checkin-activity` 全是 404。
42
+
43
+ 国际版真正有的是另一套东西:**成长任务板**(5 条:完成一次对话、安装技能、绑定微信、召唤专家、使用模板)和 **streak 连续打卡**(7 天 / 14 天 / 28 天档位,分别给 0 / 50 / 150 积分)。但它的字段形状跟国内版不一样(没有 credit / energy / target / current),插件目前解析不了,所以**没有**为国际版开自动化——没有可领的东西,开了也只是每天准时去撞 404。
44
+
45
+ 国内版的签到是正常的,多账号逐个领取的链路本来就有,不受本次改动影响。
46
+
47
+ ## 1.8.1 — 一个过期的登录状态,不再拖着整个池子陪跑
48
+
49
+ > 有人的卡片上,好几个账号下面顶着一大段红色英文,大意是「上游不认这个登录了,去桌面端重新登录」。话说得没错,可问题是:它只说了结论,没解决过程——那几个账号明明已经废了,每次请求还是要挨个撞一遍,撞得头破血流,旁边几个好端端的账号一次都轮不上。
50
+
51
+ ### 事情是怎么变成这样的
52
+
53
+ 上游吊销一个登录状态时,**不会顺手把凭据文件里的过期时间改掉**。于是本机留着一份「看起来还有效」的凭据:过期时间写着 11 月 4 日,今天才 10 月,一切正常。
54
+
55
+ 插件就信了。它把这三个账号当成健康账号,按顺序一个一个试:
56
+
57
+ 1. 拿账号 A 去请求,上游回 401;
58
+ 2. 插件想「可能是 token 过期了」,于是刷新 token;
59
+ 3. 上游回答「这个 refresh token 早就没了」(`invalid_grant: Offline user session not found`);
60
+ 4. 插件刷新失败,但**没有记仇**,转头又拿 A 去试;
61
+ 5. 八次重试名额(`maxAttempts`)就这么全喂给了同一个死账号,健康账号一次都没被碰过。
62
+
63
+ 所以现象是「明明还有能用的账号,请求却一直失败」,外加卡片上一片红。
64
+
65
+ ### 现在怎么处理
66
+
67
+ 给账号加了第三种状态:**判死**(前两种是「正常」和「限流冷却」)。
68
+
69
+ - **只有上游亲口说死才算死。** 判据是 `invalid_grant` / `Offline user session not found` 这类明确答复。网络超时、DNS 抽风、5xx 一律不算——否则一次网络抖动就会把好账号关进小黑屋半小时,那比原来的 bug 还难查。
70
+ - **判死的账号立刻退出轮换**,不再参与 `acquire` 的选择,请求当场换健康账号继续,不用等到八次重试耗尽。
71
+ - **想复活有两条路**:在 WorkBuddy 桌面端重新登录该账号(插件发现 refresh token 变了,自动放行),或者点卡片上的「清除所有冷却」手动再给它一次机会。
72
+ - **全池都死了的时候**,返回的不再是含含糊糊的「你还没登录」,而是 401 `session_dead`:`N/M WorkBuddy account(s) for this gateway were rejected by the upstream (their sign-in was revoked); sign in again in the WorkBuddy desktop app to restore them`。「等待」和「重新登录」是两种完全不同的处置方式,不能混着报。
73
+
74
+ ### 卡片上的红字也收拾了一下
75
+
76
+ - 原来那段整段英文报错**直接铺在「积分包」面板里**,把面板撑得老长,右边的「合计」都被挤出屏幕。现在折叠成一个红色小标「**登录已被拒绝**」,鼠标悬停才显示原文。
77
+ - 账号行上加了红色的「登录已被拒绝」标签,下面补一句「请在 WorkBuddy 桌面端重新登录该账号以恢复」——红色是故意的,用琥珀色会让人以为「等一会儿就好」,而这个状态等多久都不会好。
78
+ - 判死的账号**不再参与卡片刷新时的积分 / 签到探测**。以前每刷新一次卡片,就对每个死账号白打两个必定 401 的请求,刷新越勤越热闹。
79
+
80
+ ### 已知边界
81
+
82
+ - 判死状态记在内存里,重启后重新判定。也就是说重启后的第一次请求还会再试一次那个账号,然后才把它请出去——这一次的代价是必要的,否则「重启一下就好了」的临时故障会被误判成永久失效。
83
+ - 国际版每日签到活动(`checkin-activity-status`)实测当前是 `active: false`,所以「多账号自动领 30 积分」目前没有可领的东西;国内版的活动是正常的,逐账号领取的链路本来就有。
84
+
7
85
  ## 1.8.0 — 四种模式,终于排得像个样子了
8
86
 
9
87
  > 上一版加了第四种模式「每对话固定」,功能是好的,排版是坏的。坏得很实在:格子还是按三个排的,第四个只好委屈地掉到下一行,占着半个身位,像个插班生。
package/README.md CHANGED
@@ -50,6 +50,8 @@
50
50
 
51
51
  - **模型目录带标注**:列出池里能用的模型,附带积分倍率(如 `GLM-5.2 · x0.79`)、免费 / 限时免费 / 夜间折扣标签、能否收图和上下文窗口大小,全部跟随上游实时更新。
52
52
 
53
+ - **被限流时告诉你退路**:免费档被 429 冷却时,卡片和报错都会点名**同名的付费行**(如国际版 `deepseek-v4.1-flash` 对 `deepseek-v4.1-flash-sg`、国内版 `hy3` 对 `hy3-x`)。上游按「行」分别限流,所以免费行被关小黑屋时,收费行通常还是通的。
54
+
53
55
  - **国内版 / 国际版自动适配**:按账号凭据里的登录域名自动选上游——国际版登录(`workbuddy.ai`)走 `www.workbuddy.ai`,国内版(默认)走 `copilot.tencent.com`。两种账号可以混在同一个池里。
54
56
 
55
57
  - **想手动做点啥也行**:卡片和命令行都提供「重新检测账号」「清除所有冷却」「每日签到」三个操作。
package/lib/bin.js CHANGED
@@ -2644,6 +2644,28 @@ function idleWeight(lastUsedAt, now) {
2644
2644
  }
2645
2645
  /** Default rest for an account whose credits ran out (packs reset on their own schedule). */
2646
2646
  const EXHAUST_COOLDOWN_MS = 18e5;
2647
+ /**
2648
+ * Default rest for an account the upstream rejected the sign-in of (401/403).
2649
+ *
2650
+ * Unlike a rate limit there is no reset time to read: only signing in again in
2651
+ * the desktop app revives the account. The rest is therefore long enough that a
2652
+ * single request never burns its whole retry budget on the same dead credential,
2653
+ * but short enough that a user who just signed in is not locked out of their own
2654
+ * account for long — and a newer credential file clears the mark outright.
2655
+ */
2656
+ const CREDENTIAL_DEAD_COOLDOWN_MS = 18e5;
2657
+ /**
2658
+ * Does this refresh failure prove the credential itself is dead?
2659
+ *
2660
+ * The upstream answers a revoked refresh token with `invalid_grant: Offline user
2661
+ * session not found` (HTTP 400/401). Network failures, DNS errors and 5xx do NOT
2662
+ * prove anything about the credential and must not mark it dead — that would
2663
+ * take a working account out of rotation for half an hour over a hiccup.
2664
+ */
2665
+ function isCredentialDeadError(error) {
2666
+ const message = error instanceof Error ? error.message : String(error);
2667
+ return message.includes("invalid_grant") || message.includes("Offline user session not found") || message.includes("session_dead");
2668
+ }
2647
2669
  var WorkBuddyAccountPool = class {
2648
2670
  logger;
2649
2671
  authDirs;
@@ -2654,6 +2676,11 @@ var WorkBuddyAccountPool = class {
2654
2676
  * rate-limit window, so this is much longer than `cooldownMs`.
2655
2677
  */
2656
2678
  exhaustCooldownMs;
2679
+ /**
2680
+ * How long an account stays out of rotation after the upstream rejected its
2681
+ * sign-in. Cleared by a newer credential file or an explicit sign-in.
2682
+ */
2683
+ credentialDeadCooldownMs;
2657
2684
  client;
2658
2685
  refreshMarginMs;
2659
2686
  accounts = [];
@@ -2736,6 +2763,7 @@ var WorkBuddyAccountPool = class {
2736
2763
  this.authDirs = options.authDirs ?? candidateAuthDirs();
2737
2764
  this.cooldownMs = options.cooldownMs ?? 6e4;
2738
2765
  this.exhaustCooldownMs = options.exhaustCooldownMs ?? EXHAUST_COOLDOWN_MS;
2766
+ this.credentialDeadCooldownMs = options.credentialDeadCooldownMs ?? CREDENTIAL_DEAD_COOLDOWN_MS;
2739
2767
  this.client = options.client;
2740
2768
  this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
2741
2769
  this.distribution = options.distribution ?? "priority";
@@ -2822,11 +2850,15 @@ var WorkBuddyAccountPool = class {
2822
2850
  });
2823
2851
  continue;
2824
2852
  }
2825
- if (isFresher(credential, existing.credential)) byId.set(id, {
2826
- ...existing,
2827
- credential,
2828
- label: accountLabel(credential)
2829
- });
2853
+ if (isFresher(credential, existing.credential)) {
2854
+ const signedInAgain = credential.refreshToken !== existing.credential.refreshToken || credential.accessToken !== existing.credential.accessToken;
2855
+ byId.set(id, {
2856
+ ...existing,
2857
+ credential,
2858
+ label: accountLabel(credential),
2859
+ ...signedInAgain ? { credentialDeadUntilMs: 0 } : {}
2860
+ });
2861
+ }
2830
2862
  }
2831
2863
  const ordered = [...byId.values()];
2832
2864
  ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
@@ -2857,6 +2889,7 @@ var WorkBuddyAccountPool = class {
2857
2889
  if (balance !== void 0 && balance <= reserve) return false;
2858
2890
  }
2859
2891
  if (account.cooldownUntilMs > now) return false;
2892
+ if ((account.credentialDeadUntilMs ?? 0) > now) return false;
2860
2893
  if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
2861
2894
  if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
2862
2895
  return true;
@@ -2882,15 +2915,21 @@ var WorkBuddyAccountPool = class {
2882
2915
  total: 0,
2883
2916
  cooling: 0,
2884
2917
  disabled: 0,
2918
+ dead: 0,
2885
2919
  reason: "empty"
2886
2920
  };
2887
2921
  let cooling = 0;
2888
2922
  let disabled = 0;
2923
+ let dead = 0;
2889
2924
  for (const account of inRegion) {
2890
2925
  if (this.disabledIds.has(account.id)) {
2891
2926
  disabled += 1;
2892
2927
  continue;
2893
2928
  }
2929
+ if ((account.credentialDeadUntilMs ?? 0) > now) {
2930
+ dead += 1;
2931
+ continue;
2932
+ }
2894
2933
  const modelCooling = modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now;
2895
2934
  if (account.cooldownUntilMs > now || modelCooling) {
2896
2935
  cooling += 1;
@@ -2903,26 +2942,37 @@ var WorkBuddyAccountPool = class {
2903
2942
  total: inRegion.length,
2904
2943
  cooling,
2905
2944
  disabled,
2945
+ dead,
2906
2946
  reason: "reserve"
2907
2947
  };
2908
2948
  }
2909
2949
  }
2950
+ if (dead > 0) return {
2951
+ total: inRegion.length,
2952
+ cooling,
2953
+ disabled,
2954
+ dead,
2955
+ reason: "session_dead"
2956
+ };
2910
2957
  if (cooling > 0) return {
2911
2958
  total: inRegion.length,
2912
2959
  cooling,
2913
2960
  disabled,
2961
+ dead,
2914
2962
  reason: "cooling"
2915
2963
  };
2916
2964
  if (disabled > 0) return {
2917
2965
  total: inRegion.length,
2918
2966
  cooling,
2919
2967
  disabled,
2968
+ dead,
2920
2969
  reason: "disabled"
2921
2970
  };
2922
2971
  return {
2923
2972
  total: inRegion.length,
2924
2973
  cooling,
2925
2974
  disabled,
2975
+ dead,
2926
2976
  reason: "none"
2927
2977
  };
2928
2978
  }
@@ -3026,31 +3076,28 @@ var WorkBuddyAccountPool = class {
3026
3076
  */
3027
3077
  async acquire(modelId, region, conversationKey) {
3028
3078
  if (this.accounts.length === 0) await this.scan();
3029
- let pool = this.available(Date.now(), modelId, region);
3030
- if (pool.length === 0) {
3031
- await this.scan();
3032
- pool = this.available(Date.now(), modelId, region);
3033
- }
3034
- if (pool.length === 0) return void 0;
3035
- if (this.preferredId !== void 0) {
3036
- const preferred = pool.find((account) => account.id === this.preferredId);
3037
- if (preferred !== void 0) {
3038
- await this.ensureFresh(preferred);
3039
- return preferred;
3079
+ const maxTries = this.accounts.length + 1;
3080
+ for (let attempt = 0; attempt < maxTries; attempt += 1) {
3081
+ let pool = this.available(Date.now(), modelId, region);
3082
+ if (pool.length === 0 && attempt === 0) {
3083
+ await this.scan();
3084
+ pool = this.available(Date.now(), modelId, region);
3040
3085
  }
3041
- }
3042
- if (this.distribution === "sticky") {
3043
- const bound = this.affinityAccount(pool, conversationKey);
3044
- if (bound !== void 0) {
3045
- await this.ensureFresh(bound);
3046
- return bound;
3086
+ if (pool.length === 0) return void 0;
3087
+ if (this.preferredId !== void 0) {
3088
+ const preferred = pool.find((account) => account.id === this.preferredId);
3089
+ if (preferred !== void 0 && await this.ensureFresh(preferred)) return preferred;
3090
+ }
3091
+ if (this.distribution === "sticky") {
3092
+ const bound = this.affinityAccount(pool, conversationKey);
3093
+ if (bound !== void 0 && await this.ensureFresh(bound)) return bound;
3047
3094
  }
3095
+ const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
3096
+ if (account === void 0) return void 0;
3097
+ if (!await this.ensureFresh(account)) continue;
3098
+ if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
3099
+ return account;
3048
3100
  }
3049
- const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
3050
- if (account === void 0) return void 0;
3051
- if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
3052
- await this.ensureFresh(account);
3053
- return account;
3054
3101
  }
3055
3102
  /** Pin the account the plugin card should prefer; tokens stay out of settings. */
3056
3103
  /** How the pool currently spreads requests. Shown on the card. */
@@ -3154,31 +3201,68 @@ var WorkBuddyAccountPool = class {
3154
3201
  return newest?.id;
3155
3202
  }
3156
3203
  /** Best-effort refresh of one account after a session-dead upstream answer. */
3157
- async refreshAccount(accountId) {
3204
+ async refreshAccount(accountId, options) {
3205
+ const account = this.accounts.find((item) => item.id === accountId);
3206
+ if (account === void 0) return false;
3207
+ return await this.ensureFresh(account, options?.force === true);
3208
+ }
3209
+ /**
3210
+ * Cool a whole account because the upstream REJECTED its sign-in (401/403).
3211
+ *
3212
+ * Called with direct evidence (the request just came back `session_dead`), so
3213
+ * it does not need to guess: without this, the next retry of the SAME request
3214
+ * picks the same account again — its credential file still claims to be valid
3215
+ * — and a request with 8 attempts spends all 8 on one dead account while
3216
+ * healthy accounts are never tried.
3217
+ */
3218
+ penalizeCredentialDead(accountId) {
3158
3219
  const account = this.accounts.find((item) => item.id === accountId);
3159
3220
  if (account === void 0) return;
3160
- await this.ensureFresh(account);
3221
+ const until = Date.now() + this.credentialDeadCooldownMs;
3222
+ account.credentialDeadUntilMs = Math.max(account.credentialDeadUntilMs ?? 0, until);
3223
+ this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} sign-in was rejected by the upstream; keeping it out of rotation until ${new Date(until).toISOString()} (sign in again to restore it)`);
3224
+ }
3225
+ /** Put an account back in rotation after its sign-in was proven good again. */
3226
+ clearCredentialDead(accountId) {
3227
+ const account = this.accounts.find((item) => item.id === accountId);
3228
+ if (account === void 0) return;
3229
+ account.credentialDeadUntilMs = 0;
3230
+ }
3231
+ /** Accounts currently kept out of rotation because their sign-in was rejected. */
3232
+ deadCredentials() {
3233
+ const now = Date.now();
3234
+ return this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now);
3161
3235
  }
3162
3236
  /**
3163
3237
  * Refresh the account's access token when it is within the margin (or already
3164
3238
  * expired), in-flight de-duped per account. A failed refresh keeps the
3165
3239
  * existing token when it has not yet expired, so an unreachable refresh
3166
3240
  * endpoint never takes down a working session.
3241
+ *
3242
+ * `force` skips the "is it expiring?" gate. That gate is a cost optimisation,
3243
+ * not a correctness rule: it exists so a healthy token is not re-fetched on
3244
+ * every acquire. After the upstream has ALREADY rejected the token, the gate
3245
+ * is actively wrong — a revoked token keeps its future `expiresAtMs` — and the
3246
+ * caller needs the refresh attempted so the credential can be proven dead.
3247
+ *
3248
+ * Returns true when the token is known good afterwards (refreshed, or still
3249
+ * valid), false when the credential was proven dead.
3167
3250
  */
3168
- async ensureFresh(account) {
3169
- if (this.client === void 0) return;
3251
+ async ensureFresh(account, force = false) {
3252
+ if (this.client === void 0) return true;
3170
3253
  const credential = account.credential;
3171
- if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs)) return;
3254
+ if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs) && !force) return true;
3172
3255
  const existing = this.refreshInflight.get(account.id);
3173
3256
  if (existing !== void 0) {
3174
3257
  await existing;
3175
- return;
3258
+ return (account.credentialDeadUntilMs ?? 0) <= Date.now();
3176
3259
  }
3177
3260
  const run = (async () => {
3178
3261
  if (credential.refreshToken === "") {
3179
- if (credential.expiresAtMs > Date.now() + 3e4) return;
3262
+ if (credential.expiresAtMs > Date.now() + 3e4) return true;
3180
3263
  this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} token expired with no refresh token; sign in again`);
3181
- return;
3264
+ this.penalizeCredentialDead(account.id);
3265
+ return false;
3182
3266
  }
3183
3267
  try {
3184
3268
  const outcome = await this.client.refreshToken(credential);
@@ -3189,15 +3273,23 @@ var WorkBuddyAccountPool = class {
3189
3273
  expiresAtMs: outcome.expiresInSec !== void 0 ? Date.now() + outcome.expiresInSec * 1e3 : credential.expiresAtMs,
3190
3274
  ...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain }
3191
3275
  };
3276
+ this.clearCredentialDead(account.id);
3192
3277
  this.logger?.info?.(`dsh-workbuddy-xdpool: refreshed token for ${account.label}`);
3278
+ return true;
3193
3279
  } catch (error) {
3280
+ if (isCredentialDeadError(error)) {
3281
+ this.penalizeCredentialDead(account.id);
3282
+ this.logger?.warn?.(`dsh-workbuddy-xdpool: ${account.label} refresh token was revoked upstream; sign in again to restore it`, error);
3283
+ return false;
3284
+ }
3194
3285
  if (credential.expiresAtMs > Date.now() + 3e4) this.logger?.warn?.(`dsh-workbuddy-xdpool: token refresh failed but token still valid for ${account.label}`, error);
3195
3286
  else this.logger?.error?.(`dsh-workbuddy-xdpool: token refresh failed and token expired for ${account.label}`, error);
3287
+ return true;
3196
3288
  }
3197
3289
  })();
3198
3290
  this.refreshInflight.set(account.id, run);
3199
3291
  try {
3200
- await run;
3292
+ return await run;
3201
3293
  } finally {
3202
3294
  this.refreshInflight.delete(account.id);
3203
3295
  }
@@ -3246,6 +3338,7 @@ ${new Date(until).toISOString()}
3246
3338
  account.cooldownUntilMs = 0;
3247
3339
  account.modelCooldowns = {};
3248
3340
  account.rateLimitHits = 0;
3341
+ account.credentialDeadUntilMs = 0;
3249
3342
  }
3250
3343
  }
3251
3344
  /** Diagnostics snapshot. Account-wide cooling count (per-model cooling excluded:
@@ -3255,6 +3348,7 @@ ${new Date(until).toISOString()}
3255
3348
  return {
3256
3349
  count: this.accounts.length,
3257
3350
  cooling: this.accounts.filter((account) => account.cooldownUntilMs > now).length,
3351
+ dead: this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now).length,
3258
3352
  lastScanAtMs: this.lastScanAtMs
3259
3353
  };
3260
3354
  }
package/lib/client.js CHANGED
@@ -249,9 +249,17 @@ window.__ModuleLoader__.load({
249
249
  .dsm-workbuddy-xdpool-account-tag{padding:1px 8px;border-radius:999px;font-size:11px;line-height:18px;background:var(--dsw-alias-state-success-subtle,rgba(34,160,107,.12));color:var(--dsw-alias-state-success-primary,#22a06b)}
250
250
  .dsm-workbuddy-xdpool-account-tag-cooling{background:var(--dsw-alias-state-warning-subtle,rgba(217,119,6,.15));color:var(--dsw-alias-state-warning-primary,#d97706)}
251
251
  .dsm-workbuddy-xdpool-account-tag-error{background:var(--dsw-alias-state-error-subtle,rgba(239,68,68,.12));color:var(--dsw-alias-state-error-primary,#ef4444)}
252
+ /* A rejected sign-in is red, not amber: unlike a cooldown it will not clear by
253
+ waiting, and it is the one state where the user has to go do something. */
254
+ .dsm-workbuddy-xdpool-account-tag-dead{background:var(--dsw-alias-state-error-subtle,rgba(239,68,68,.12));color:var(--dsw-alias-state-error-primary,#ef4444)}
252
255
  .dsm-workbuddy-xdpool-account-meta{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px;display:flex;flex-wrap:wrap;gap:10px}
256
+ .dsm-workbuddy-xdpool-account-meta-dead{color:var(--dsw-alias-state-error-primary,#ef4444)}
253
257
  .dsm-workbuddy-xdpool-account-modelcool{display:flex;flex-wrap:wrap;gap:6px;margin-top:2px}
254
258
  .dsm-workbuddy-xdpool-account-modelcool-chip{display:inline-flex;align-items:center;gap:4px;padding:1px 8px;border-radius:999px;font-size:11px;line-height:18px;background:var(--dsw-alias-state-warning-subtle,rgba(217,119,6,.12));color:var(--dsw-alias-state-warning-primary,#d97706)}
259
+ /* "→ deepseek-v4.1-flash-sg (0.03x) still works" inside a cooling chip. Keeps
260
+ the warning tint of its parent but drops the weight: it is the way out, not
261
+ a second problem. */
262
+ .dsm-workbuddy-xdpool-account-modelcool-twin{color:var(--dsw-alias-label-secondary,#c6c9d0);cursor:help}
255
263
  .dsm-workbuddy-xdpool-account-error{margin:0;color:var(--dsw-alias-state-error-primary,#ef4444);font-size:13px;line-height:20px}
256
264
 
257
265
  /* Two-column stats: packages on the left, total + check-in on the right. */
@@ -260,6 +268,9 @@ window.__ModuleLoader__.load({
260
268
  .dsm-workbuddy-xdpool-panel-title{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px;letter-spacing:.03em;text-transform:uppercase;font-weight:600}
261
269
  .dsm-workbuddy-xdpool-panel-empty{color:var(--dsw-alias-label-tertiary,#999);font-size:14px;line-height:20px}
262
270
  .dsm-workbuddy-xdpool-panel-error{color:var(--dsw-alias-state-error-primary,#ef4444);font-size:12px;line-height:18px;word-break:break-word}
271
+ /* A rejected sign-in gets a compact chip: the upstream paragraph is long, and
272
+ printed inline it pushed the neighbouring "Total" panel off screen. */
273
+ .dsm-workbuddy-xdpool-panel-error-dead{display:inline-flex;align-items:center;align-self:flex-start;padding:1px 8px;border-radius:999px;background:var(--dsw-alias-state-error-subtle,rgba(239,68,68,.12));font-size:11px;line-height:18px;white-space:nowrap;cursor:help}
263
274
  .dsm-workbuddy-xdpool-panel-foot{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin-top:9px;padding-top:9px;border-top:1px solid var(--dsw-alias-border-l2,#36373b);color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:12px;line-height:18px}
264
275
  .dsm-workbuddy-xdpool-panel-foot strong{color:var(--dsw-alias-label-primary,#e6e6e6);font-size:15px;font-variant-numeric:tabular-nums}
265
276
  .dsm-workbuddy-xdpool-packages{display:flex;flex-direction:column;gap:5px;margin:0;padding:0;list-style:none}
@@ -300,6 +311,10 @@ window.__ModuleLoader__.load({
300
311
  /* "活动至 10-31": the campaign's end date. Muted — it is context for planning,
301
312
  not a claim about the current price. */
302
313
  .dsm-workbuddy-xdpool-model-meta-promo{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:11px;line-height:16px;opacity:.85}
314
+ /* "付费同款 deepseek-v4.1-flash-sg (0.03x)": the way out when the free row is
315
+ rate-limited. Muted like the other meta chips — it is an alternative, not a
316
+ warning, and the row it sits on is still the free one. */
317
+ .dsm-workbuddy-xdpool-model-meta-twin{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:11px;line-height:16px;opacity:.85;cursor:help}
303
318
  /* Unreadable credential files: a warning tint, since it explains a smaller pool
304
319
  and "encrypted" is actionable (start the app once). */
305
320
  .dsm-workbuddy-xdpool-skipped{margin:6px 0 0;padding:7px 10px;border-radius:8px;background:var(--dsw-alias-state-warning-subtle,rgba(217,119,6,.12));display:flex;flex-direction:column;gap:3px}
@@ -503,6 +518,43 @@ window.__ModuleLoader__.load({
503
518
  return status?.kind === "free" || status?.kind === "night";
504
519
  }
505
520
  //#endregion
521
+ //#region src/siblings.ts
522
+ /**
523
+ * The cheapest PAID model sharing `model`'s display name, or undefined.
524
+ *
525
+ * Cheapest rather than first, because the point of the hint is "there is a
526
+ * cheap way out": offering the most expensive twin would make switching look
527
+ * like a trap. A sibling whose price is unknown is skipped — an unpriced row
528
+ * cannot be promised as "the paid one".
529
+ */
530
+ function paidSiblingOf(model, models) {
531
+ let best;
532
+ for (const candidate of models) {
533
+ if (candidate.id === model.id) continue;
534
+ if (candidate.name !== model.name) continue;
535
+ const multiplier = candidate.multiplier;
536
+ if (multiplier === void 0 || multiplier <= 0) continue;
537
+ if (best === void 0 || multiplier < best.multiplier) best = {
538
+ id: candidate.id,
539
+ name: candidate.name,
540
+ multiplier
541
+ };
542
+ }
543
+ return best;
544
+ }
545
+ /**
546
+ * The paid sibling of a model, but ONLY when the model itself is free.
547
+ *
548
+ * The guard is the whole point: a `x0.79` row with a `x0.06` cousin is not a
549
+ * "free model with a paid twin", it is just a more expensive row, and labelling
550
+ * it would be noise on most of the list. The hint answers one question — "the
551
+ * free one is limited, what now".
552
+ */
553
+ function paidAlternativeFor(model, models) {
554
+ if (model.multiplier !== 0) return void 0;
555
+ return paidSiblingOf(model, models);
556
+ }
557
+ //#endregion
506
558
  //#region src/client/PoolCard.tsx
507
559
  /**
508
560
  * WorkBuddy XD Pool card contributed to DSH Plugin configuration.
@@ -1610,6 +1662,7 @@ window.__ModuleLoader__.load({
1610
1662
  })(),
1611
1663
  status?.accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountBlock, {
1612
1664
  account,
1665
+ models: status?.models ?? [],
1613
1666
  ...checkinBusyId === void 0 ? {} : { checkinBusyId },
1614
1667
  onClaimCheckin: (accountId) => {
1615
1668
  claimCheckin(accountId);
@@ -1716,6 +1769,7 @@ window.__ModuleLoader__.load({
1716
1769
  children: [...status?.models ?? []].sort((a, b) => Number(isFreeNow(b, now, activeRegion)) - Number(isFreeNow(a, now, activeRegion))).map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelRow, {
1717
1770
  model,
1718
1771
  region: activeRegion,
1772
+ paidTwin: paidAlternativeFor(model, status?.models ?? []),
1719
1773
  t,
1720
1774
  draft: modelDraft[model.id] ?? {
1721
1775
  enabled: model.enabled,
@@ -1732,12 +1786,16 @@ window.__ModuleLoader__.load({
1732
1786
  });
1733
1787
  }
1734
1788
  /** One account block: label + status tag + meta + optional credit panels. */
1735
- function AccountBlock({ account, t, checkinBusyId, onClaimCheckin, accountBusyId, onToggleDisabled, onIgnoreAccount, onSaveCreditReserve, reserveBusyId }) {
1789
+ function AccountBlock({ account, models, t, checkinBusyId, onClaimCheckin, accountBusyId, onToggleDisabled, onIgnoreAccount, onSaveCreditReserve, reserveBusyId }) {
1736
1790
  const isDisabled = account.disabled === true;
1737
1791
  const isCooling = account.cooling === true;
1792
+ const isDead = account.credentialDead === true;
1738
1793
  const cooldownUntil = account.cooldownUntil !== void 0 ? Date.parse(account.cooldownUntil) : void 0;
1739
1794
  const modelCooldowns = account.modelCooldowns ?? [];
1740
- const tag = isCooling ? {
1795
+ const tag = isDead ? {
1796
+ text: t?.("row.credentialDeadTag") ?? "Sign-in rejected",
1797
+ cls: "dsm-workbuddy-xdpool-account-tag dsm-workbuddy-xdpool-account-tag-dead"
1798
+ } : isCooling ? {
1741
1799
  text: t?.("row.cooling") ?? "Cooling",
1742
1800
  cls: "dsm-workbuddy-xdpool-account-tag dsm-workbuddy-xdpool-account-tag-cooling"
1743
1801
  } : null;
@@ -1795,15 +1853,30 @@ window.__ModuleLoader__.load({
1795
1853
  t?.("row.cooldownHits", { hits: account.rateLimitHits ?? 0 }) ?? `${account.rateLimitHits ?? 0} hit(s)`
1796
1854
  ]
1797
1855
  }) : null,
1856
+ isDead ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1857
+ className: "dsm-workbuddy-xdpool-account-meta dsm-workbuddy-xdpool-account-meta-dead",
1858
+ children: t?.("row.credentialDeadHint") ?? "Sign in again in the WorkBuddy desktop app to restore it"
1859
+ }) : null,
1798
1860
  modelCooldowns.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1799
1861
  className: "dsm-workbuddy-xdpool-account-modelcool",
1800
- children: modelCooldowns.map((mc) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1801
- className: "dsm-workbuddy-xdpool-account-modelcool-chip",
1802
- children: t?.("row.modelCooling", {
1803
- model: mc.modelId,
1804
- time: formatDateTime(mc.until)
1805
- }) ?? `${mc.modelId} cooling to ${formatDateTime(mc.until)}`
1806
- }, mc.modelId))
1862
+ children: modelCooldowns.map((mc) => {
1863
+ const coolingModel = models.find((model) => model.id === mc.modelId);
1864
+ const twin = coolingModel === void 0 ? void 0 : paidAlternativeFor(coolingModel, models);
1865
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1866
+ className: "dsm-workbuddy-xdpool-account-modelcool-chip",
1867
+ children: [t?.("row.modelCooling", {
1868
+ model: mc.modelId,
1869
+ time: formatDateTime(mc.until)
1870
+ }) ?? `${mc.modelId} cooling to ${formatDateTime(mc.until)}`, twin === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1871
+ className: "dsm-workbuddy-xdpool-account-modelcool-twin",
1872
+ title: t?.("row.paidTwinHint") ?? "Same model, paid row",
1873
+ children: t?.("row.rateLimitedPaidTwin", {
1874
+ id: twin.id,
1875
+ rate: twin.multiplier.toFixed(2)
1876
+ }) ?? `→ ${twin.id} (${twin.multiplier.toFixed(2)}x) still works`
1877
+ })]
1878
+ }, mc.modelId);
1879
+ })
1807
1880
  }) : null
1808
1881
  ]
1809
1882
  }),
@@ -1976,8 +2049,9 @@ window.__ModuleLoader__.load({
1976
2049
  children: t?.("row.creditsPackages") ?? "Credit packages"
1977
2050
  }),
1978
2051
  account.creditsError !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1979
- className: "dsm-workbuddy-xdpool-panel-error",
1980
- children: account.creditsError
2052
+ className: `dsm-workbuddy-xdpool-panel-error${account.credentialDead === true ? " dsm-workbuddy-xdpool-panel-error-dead" : ""}`,
2053
+ title: account.creditsError,
2054
+ children: account.credentialDead === true ? t?.("row.credentialDead") ?? "Sign-in rejected" : t?.("row.creditsError") ?? "credits unavailable"
1981
2055
  }) : packages.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1982
2056
  className: "dsm-workbuddy-xdpool-panel-empty",
1983
2057
  children: "–"
@@ -2069,7 +2143,7 @@ window.__ModuleLoader__.load({
2069
2143
  * context radios stay disabled rather than pretending an edit took hold. The
2070
2144
  * draft lives in the parent, so this component only ever reports intent.
2071
2145
  */
2072
- function ModelRow({ model, region, t, draft, editable, onToggle, onToggleImage, onBudget }) {
2146
+ function ModelRow({ model, region, paidTwin, t, draft, editable, onToggle, onToggleImage, onBudget }) {
2073
2147
  const now = /* @__PURE__ */ new Date();
2074
2148
  const tag = tagFor(model, now, region);
2075
2149
  const promo = promoStatusFor(model, now, region);
@@ -2168,6 +2242,14 @@ window.__ModuleLoader__.load({
2168
2242
  className: "dsm-workbuddy-xdpool-model-meta-promo",
2169
2243
  children: promoUntil
2170
2244
  }),
2245
+ paidTwin === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2246
+ className: "dsm-workbuddy-xdpool-model-meta-twin",
2247
+ title: t?.("row.paidTwinHint") ?? "Same model, paid row",
2248
+ children: t?.("row.paidTwin", {
2249
+ id: paidTwin.id,
2250
+ rate: paidTwin.multiplier.toFixed(2)
2251
+ }) ?? `paid twin ${paidTwin.id} (${paidTwin.multiplier.toFixed(2)}x)`
2252
+ }),
2171
2253
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2172
2254
  className: "dsm-workbuddy-xdpool-model-cap",
2173
2255
  children: t?.("row.modelOutput", { size: formatCapacity(model.maxOutputTokens) }) ?? `out ${formatCapacity(model.maxOutputTokens)}`
@@ -2282,6 +2364,9 @@ window.__ModuleLoader__.load({
2282
2364
  "row.creditsPackages": "Credit packages",
2283
2365
  "row.creditsPackage": "{remain} / {size}",
2284
2366
  "row.creditsError": "credits unavailable",
2367
+ "row.credentialDead": "Sign-in rejected",
2368
+ "row.credentialDeadTag": "Sign-in rejected",
2369
+ "row.credentialDeadHint": "The upstream no longer accepts this sign-in. Sign in again in the WorkBuddy desktop app to restore it.",
2285
2370
  "row.creditsSoon": "Expiring in 3 days",
2286
2371
  "row.creditsExpiresAt": "Expires {time}",
2287
2372
  "row.creditsExpiresSoonTitle": "Expiring within 3 days",
@@ -2326,6 +2411,9 @@ window.__ModuleLoader__.load({
2326
2411
  "row.nightFreeNow": "free until {time}",
2327
2412
  "row.nightFreeLater": "free from {time}",
2328
2413
  "row.promoUntil": "promo to {date}",
2414
+ "row.paidTwin": "paid twin {id} ({rate}x)",
2415
+ "row.paidTwinHint": "The free tier is rate-limited first; this same model has a paid row that keeps working",
2416
+ "row.rateLimitedPaidTwin": "Tip: {id} is the same model on a paid row ({rate}x credits) and is limited separately — switch to it to keep going",
2329
2417
  "row.skippedFiles": "{count} credential file(s) could not be read",
2330
2418
  "row.skippedEncrypted": "encrypted — start WorkBuddy once so its key can be read",
2331
2419
  "row.skippedUnreadable": "file could not be read",
@@ -2437,6 +2525,9 @@ window.__ModuleLoader__.load({
2437
2525
  "row.creditsPackages": "积分包",
2438
2526
  "row.creditsPackage": "{remain} / {size}",
2439
2527
  "row.creditsError": "积分不可用",
2528
+ "row.credentialDead": "登录已被拒绝",
2529
+ "row.credentialDeadTag": "登录已被拒绝",
2530
+ "row.credentialDeadHint": "上游已不再接受这个登录状态。请在 WorkBuddy 桌面端重新登录该账号以恢复。",
2440
2531
  "row.creditsSoon": "3 天内到期",
2441
2532
  "row.creditsExpiresAt": "到期 {time}",
2442
2533
  "row.creditsExpiresSoonTitle": "3 天内到期",
@@ -2481,6 +2572,9 @@ window.__ModuleLoader__.load({
2481
2572
  "row.nightFreeNow": "免费至 {time}",
2482
2573
  "row.nightFreeLater": "{time} 起免费",
2483
2574
  "row.promoUntil": "活动至 {date}",
2575
+ "row.paidTwin": "付费同款 {id}({rate}x)",
2576
+ "row.paidTwinHint": "免费档先被限流;同一个模型的付费行不受这个限制影响",
2577
+ "row.rateLimitedPaidTwin": "提示:{id} 是同一个模型的付费行({rate}x 积分),限流是分开算的 —— 切过去就能继续用",
2484
2578
  "row.skippedFiles": "有 {count} 个凭据文件读不出来",
2485
2579
  "row.skippedEncrypted": "已加密 —— 启动一次 WorkBuddy 才能取到密钥",
2486
2580
  "row.skippedUnreadable": "文件读不出来",
package/lib/index.d.ts CHANGED
@@ -810,6 +810,20 @@ interface WorkBuddyAccount {
810
810
  modelCooldowns: Record<string, number>;
811
811
  /** Consecutive rate-limit hits, for diagnostics. */
812
812
  rateLimitHits: number;
813
+ /**
814
+ * Epoch ms until which this account is skipped because the upstream REJECTED
815
+ * its sign-in (401/403), not because of a rate limit.
816
+ *
817
+ * A revoked session is invisible from the credential file: the upstream does
818
+ * not rewrite `expiresAtMs` when it kills a refresh token, so a dead account
819
+ * looks perfectly fresh and, without this field, gets picked again on the very
820
+ * next attempt. With `maxAttempts` retries that meant one dead account could
821
+ * absorb an entire request while healthy accounts were never tried.
822
+ *
823
+ * Only a fresh sign-in in the desktop app clears it, so the cooldown is long;
824
+ * `0`/undefined means "not known to be dead".
825
+ */
826
+ credentialDeadUntilMs?: number;
813
827
  }
814
828
  /**
815
829
  * Platform-default directories holding the desktop app's auth files.
@@ -845,6 +859,11 @@ interface AccountPoolOptions {
845
859
  cooldownMs?: number;
846
860
  /** How long an account rests after its credits run out (default 30 minutes). */
847
861
  exhaustCooldownMs?: number;
862
+ /**
863
+ * How long an account rests after the upstream rejects its sign-in
864
+ * (default 30 minutes). Only a fresh sign-in in the desktop app clears it.
865
+ */
866
+ credentialDeadCooldownMs?: number;
848
867
  /** Upstream client used to refresh near-expiry tokens. */
849
868
  client?: TokenRefresher;
850
869
  /** Refresh this long before actual expiry; default five minutes. */
@@ -874,6 +893,11 @@ export declare class WorkBuddyAccountPool {
874
893
  * rate-limit window, so this is much longer than `cooldownMs`.
875
894
  */
876
895
  private exhaustCooldownMs;
896
+ /**
897
+ * How long an account stays out of rotation after the upstream rejected its
898
+ * sign-in. Cleared by a newer credential file or an explicit sign-in.
899
+ */
900
+ private credentialDeadCooldownMs;
877
901
  private readonly client;
878
902
  private readonly refreshMarginMs;
879
903
  private accounts;
@@ -1018,7 +1042,9 @@ export declare class WorkBuddyAccountPool {
1018
1042
  total: number;
1019
1043
  cooling: number;
1020
1044
  disabled: number;
1021
- reason: 'empty' | 'cooling' | 'disabled' | 'reserve' | 'none';
1045
+ /** Accounts skipped because the upstream rejected their sign-in. */
1046
+ dead: number;
1047
+ reason: 'empty' | 'cooling' | 'disabled' | 'reserve' | 'session_dead' | 'none';
1022
1048
  };
1023
1049
  /** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
1024
1050
  private pickRoundRobin;
@@ -1135,12 +1161,37 @@ export declare class WorkBuddyAccountPool {
1135
1161
  */
1136
1162
  lastServedId(): string | undefined;
1137
1163
  /** Best-effort refresh of one account after a session-dead upstream answer. */
1138
- refreshAccount(accountId: string): Promise<void>;
1164
+ refreshAccount(accountId: string, options?: {
1165
+ force?: boolean;
1166
+ }): Promise<boolean>;
1167
+ /**
1168
+ * Cool a whole account because the upstream REJECTED its sign-in (401/403).
1169
+ *
1170
+ * Called with direct evidence (the request just came back `session_dead`), so
1171
+ * it does not need to guess: without this, the next retry of the SAME request
1172
+ * picks the same account again — its credential file still claims to be valid
1173
+ * — and a request with 8 attempts spends all 8 on one dead account while
1174
+ * healthy accounts are never tried.
1175
+ */
1176
+ penalizeCredentialDead(accountId: string): void;
1177
+ /** Put an account back in rotation after its sign-in was proven good again. */
1178
+ clearCredentialDead(accountId: string): void;
1179
+ /** Accounts currently kept out of rotation because their sign-in was rejected. */
1180
+ deadCredentials(): readonly WorkBuddyAccount[];
1139
1181
  /**
1140
1182
  * Refresh the account's access token when it is within the margin (or already
1141
1183
  * expired), in-flight de-duped per account. A failed refresh keeps the
1142
1184
  * existing token when it has not yet expired, so an unreachable refresh
1143
1185
  * endpoint never takes down a working session.
1186
+ *
1187
+ * `force` skips the "is it expiring?" gate. That gate is a cost optimisation,
1188
+ * not a correctness rule: it exists so a healthy token is not re-fetched on
1189
+ * every acquire. After the upstream has ALREADY rejected the token, the gate
1190
+ * is actively wrong — a revoked token keeps its future `expiresAtMs` — and the
1191
+ * caller needs the refresh attempted so the credential can be proven dead.
1192
+ *
1193
+ * Returns true when the token is known good afterwards (refreshed, or still
1194
+ * valid), false when the credential was proven dead.
1144
1195
  */
1145
1196
  private ensureFresh;
1146
1197
  /**
@@ -1168,6 +1219,7 @@ export declare class WorkBuddyAccountPool {
1168
1219
  status(): {
1169
1220
  count: number;
1170
1221
  cooling: number;
1222
+ dead: number;
1171
1223
  lastScanAtMs: number;
1172
1224
  };
1173
1225
  }
@@ -1218,6 +1270,15 @@ interface PoolWebAccount {
1218
1270
  cooling: boolean;
1219
1271
  /** ISO timestamp when the account-wide 429 cooldown lifts; only while cooling. */
1220
1272
  cooldownUntil?: string;
1273
+ /**
1274
+ * The upstream REJECTED this account's sign-in (401/403) — the credential file
1275
+ * still looks valid because the upstream never rewrites its expiry when it
1276
+ * revokes a token. Distinct from `cooling`: waiting does not fix it, only
1277
+ * signing in again in the desktop app does.
1278
+ */
1279
+ credentialDead?: boolean;
1280
+ /** ISO timestamp when the dead mark expires and the account is retried; only while `credentialDead`. */
1281
+ credentialDeadUntil?: string;
1221
1282
  /**
1222
1283
  * Per-model cooldowns currently active. The account is NOT `cooling` while a
1223
1284
  * model is limited — its other models still serve — but each entry tells the
package/lib/index.js CHANGED
@@ -2752,6 +2752,28 @@ function idleWeight(lastUsedAt, now) {
2752
2752
  }
2753
2753
  /** Default rest for an account whose credits ran out (packs reset on their own schedule). */
2754
2754
  const EXHAUST_COOLDOWN_MS = 18e5;
2755
+ /**
2756
+ * Default rest for an account the upstream rejected the sign-in of (401/403).
2757
+ *
2758
+ * Unlike a rate limit there is no reset time to read: only signing in again in
2759
+ * the desktop app revives the account. The rest is therefore long enough that a
2760
+ * single request never burns its whole retry budget on the same dead credential,
2761
+ * but short enough that a user who just signed in is not locked out of their own
2762
+ * account for long — and a newer credential file clears the mark outright.
2763
+ */
2764
+ const CREDENTIAL_DEAD_COOLDOWN_MS = 18e5;
2765
+ /**
2766
+ * Does this refresh failure prove the credential itself is dead?
2767
+ *
2768
+ * The upstream answers a revoked refresh token with `invalid_grant: Offline user
2769
+ * session not found` (HTTP 400/401). Network failures, DNS errors and 5xx do NOT
2770
+ * prove anything about the credential and must not mark it dead — that would
2771
+ * take a working account out of rotation for half an hour over a hiccup.
2772
+ */
2773
+ function isCredentialDeadError(error) {
2774
+ const message = error instanceof Error ? error.message : String(error);
2775
+ return message.includes("invalid_grant") || message.includes("Offline user session not found") || message.includes("session_dead");
2776
+ }
2755
2777
  var WorkBuddyAccountPool = class {
2756
2778
  logger;
2757
2779
  authDirs;
@@ -2762,6 +2784,11 @@ var WorkBuddyAccountPool = class {
2762
2784
  * rate-limit window, so this is much longer than `cooldownMs`.
2763
2785
  */
2764
2786
  exhaustCooldownMs;
2787
+ /**
2788
+ * How long an account stays out of rotation after the upstream rejected its
2789
+ * sign-in. Cleared by a newer credential file or an explicit sign-in.
2790
+ */
2791
+ credentialDeadCooldownMs;
2765
2792
  client;
2766
2793
  refreshMarginMs;
2767
2794
  accounts = [];
@@ -2844,6 +2871,7 @@ var WorkBuddyAccountPool = class {
2844
2871
  this.authDirs = options.authDirs ?? candidateAuthDirs();
2845
2872
  this.cooldownMs = options.cooldownMs ?? 6e4;
2846
2873
  this.exhaustCooldownMs = options.exhaustCooldownMs ?? EXHAUST_COOLDOWN_MS;
2874
+ this.credentialDeadCooldownMs = options.credentialDeadCooldownMs ?? CREDENTIAL_DEAD_COOLDOWN_MS;
2847
2875
  this.client = options.client;
2848
2876
  this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
2849
2877
  this.distribution = options.distribution ?? "priority";
@@ -2930,11 +2958,15 @@ var WorkBuddyAccountPool = class {
2930
2958
  });
2931
2959
  continue;
2932
2960
  }
2933
- if (isFresher(credential, existing.credential)) byId.set(id, {
2934
- ...existing,
2935
- credential,
2936
- label: accountLabel(credential)
2937
- });
2961
+ if (isFresher(credential, existing.credential)) {
2962
+ const signedInAgain = credential.refreshToken !== existing.credential.refreshToken || credential.accessToken !== existing.credential.accessToken;
2963
+ byId.set(id, {
2964
+ ...existing,
2965
+ credential,
2966
+ label: accountLabel(credential),
2967
+ ...signedInAgain ? { credentialDeadUntilMs: 0 } : {}
2968
+ });
2969
+ }
2938
2970
  }
2939
2971
  const ordered = [...byId.values()];
2940
2972
  ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
@@ -2965,6 +2997,7 @@ var WorkBuddyAccountPool = class {
2965
2997
  if (balance !== void 0 && balance <= reserve) return false;
2966
2998
  }
2967
2999
  if (account.cooldownUntilMs > now) return false;
3000
+ if ((account.credentialDeadUntilMs ?? 0) > now) return false;
2968
3001
  if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
2969
3002
  if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
2970
3003
  return true;
@@ -2990,15 +3023,21 @@ var WorkBuddyAccountPool = class {
2990
3023
  total: 0,
2991
3024
  cooling: 0,
2992
3025
  disabled: 0,
3026
+ dead: 0,
2993
3027
  reason: "empty"
2994
3028
  };
2995
3029
  let cooling = 0;
2996
3030
  let disabled = 0;
3031
+ let dead = 0;
2997
3032
  for (const account of inRegion) {
2998
3033
  if (this.disabledIds.has(account.id)) {
2999
3034
  disabled += 1;
3000
3035
  continue;
3001
3036
  }
3037
+ if ((account.credentialDeadUntilMs ?? 0) > now) {
3038
+ dead += 1;
3039
+ continue;
3040
+ }
3002
3041
  const modelCooling = modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now;
3003
3042
  if (account.cooldownUntilMs > now || modelCooling) {
3004
3043
  cooling += 1;
@@ -3011,26 +3050,37 @@ var WorkBuddyAccountPool = class {
3011
3050
  total: inRegion.length,
3012
3051
  cooling,
3013
3052
  disabled,
3053
+ dead,
3014
3054
  reason: "reserve"
3015
3055
  };
3016
3056
  }
3017
3057
  }
3058
+ if (dead > 0) return {
3059
+ total: inRegion.length,
3060
+ cooling,
3061
+ disabled,
3062
+ dead,
3063
+ reason: "session_dead"
3064
+ };
3018
3065
  if (cooling > 0) return {
3019
3066
  total: inRegion.length,
3020
3067
  cooling,
3021
3068
  disabled,
3069
+ dead,
3022
3070
  reason: "cooling"
3023
3071
  };
3024
3072
  if (disabled > 0) return {
3025
3073
  total: inRegion.length,
3026
3074
  cooling,
3027
3075
  disabled,
3076
+ dead,
3028
3077
  reason: "disabled"
3029
3078
  };
3030
3079
  return {
3031
3080
  total: inRegion.length,
3032
3081
  cooling,
3033
3082
  disabled,
3083
+ dead,
3034
3084
  reason: "none"
3035
3085
  };
3036
3086
  }
@@ -3134,31 +3184,28 @@ var WorkBuddyAccountPool = class {
3134
3184
  */
3135
3185
  async acquire(modelId, region, conversationKey) {
3136
3186
  if (this.accounts.length === 0) await this.scan();
3137
- let pool = this.available(Date.now(), modelId, region);
3138
- if (pool.length === 0) {
3139
- await this.scan();
3140
- pool = this.available(Date.now(), modelId, region);
3141
- }
3142
- if (pool.length === 0) return void 0;
3143
- if (this.preferredId !== void 0) {
3144
- const preferred = pool.find((account) => account.id === this.preferredId);
3145
- if (preferred !== void 0) {
3146
- await this.ensureFresh(preferred);
3147
- return preferred;
3187
+ const maxTries = this.accounts.length + 1;
3188
+ for (let attempt = 0; attempt < maxTries; attempt += 1) {
3189
+ let pool = this.available(Date.now(), modelId, region);
3190
+ if (pool.length === 0 && attempt === 0) {
3191
+ await this.scan();
3192
+ pool = this.available(Date.now(), modelId, region);
3148
3193
  }
3149
- }
3150
- if (this.distribution === "sticky") {
3151
- const bound = this.affinityAccount(pool, conversationKey);
3152
- if (bound !== void 0) {
3153
- await this.ensureFresh(bound);
3154
- return bound;
3194
+ if (pool.length === 0) return void 0;
3195
+ if (this.preferredId !== void 0) {
3196
+ const preferred = pool.find((account) => account.id === this.preferredId);
3197
+ if (preferred !== void 0 && await this.ensureFresh(preferred)) return preferred;
3155
3198
  }
3199
+ if (this.distribution === "sticky") {
3200
+ const bound = this.affinityAccount(pool, conversationKey);
3201
+ if (bound !== void 0 && await this.ensureFresh(bound)) return bound;
3202
+ }
3203
+ const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
3204
+ if (account === void 0) return void 0;
3205
+ if (!await this.ensureFresh(account)) continue;
3206
+ if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
3207
+ return account;
3156
3208
  }
3157
- const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
3158
- if (account === void 0) return void 0;
3159
- if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
3160
- await this.ensureFresh(account);
3161
- return account;
3162
3209
  }
3163
3210
  /** Pin the account the plugin card should prefer; tokens stay out of settings. */
3164
3211
  /** How the pool currently spreads requests. Shown on the card. */
@@ -3262,31 +3309,68 @@ var WorkBuddyAccountPool = class {
3262
3309
  return newest?.id;
3263
3310
  }
3264
3311
  /** Best-effort refresh of one account after a session-dead upstream answer. */
3265
- async refreshAccount(accountId) {
3312
+ async refreshAccount(accountId, options) {
3313
+ const account = this.accounts.find((item) => item.id === accountId);
3314
+ if (account === void 0) return false;
3315
+ return await this.ensureFresh(account, options?.force === true);
3316
+ }
3317
+ /**
3318
+ * Cool a whole account because the upstream REJECTED its sign-in (401/403).
3319
+ *
3320
+ * Called with direct evidence (the request just came back `session_dead`), so
3321
+ * it does not need to guess: without this, the next retry of the SAME request
3322
+ * picks the same account again — its credential file still claims to be valid
3323
+ * — and a request with 8 attempts spends all 8 on one dead account while
3324
+ * healthy accounts are never tried.
3325
+ */
3326
+ penalizeCredentialDead(accountId) {
3327
+ const account = this.accounts.find((item) => item.id === accountId);
3328
+ if (account === void 0) return;
3329
+ const until = Date.now() + this.credentialDeadCooldownMs;
3330
+ account.credentialDeadUntilMs = Math.max(account.credentialDeadUntilMs ?? 0, until);
3331
+ this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} sign-in was rejected by the upstream; keeping it out of rotation until ${new Date(until).toISOString()} (sign in again to restore it)`);
3332
+ }
3333
+ /** Put an account back in rotation after its sign-in was proven good again. */
3334
+ clearCredentialDead(accountId) {
3266
3335
  const account = this.accounts.find((item) => item.id === accountId);
3267
3336
  if (account === void 0) return;
3268
- await this.ensureFresh(account);
3337
+ account.credentialDeadUntilMs = 0;
3338
+ }
3339
+ /** Accounts currently kept out of rotation because their sign-in was rejected. */
3340
+ deadCredentials() {
3341
+ const now = Date.now();
3342
+ return this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now);
3269
3343
  }
3270
3344
  /**
3271
3345
  * Refresh the account's access token when it is within the margin (or already
3272
3346
  * expired), in-flight de-duped per account. A failed refresh keeps the
3273
3347
  * existing token when it has not yet expired, so an unreachable refresh
3274
3348
  * endpoint never takes down a working session.
3349
+ *
3350
+ * `force` skips the "is it expiring?" gate. That gate is a cost optimisation,
3351
+ * not a correctness rule: it exists so a healthy token is not re-fetched on
3352
+ * every acquire. After the upstream has ALREADY rejected the token, the gate
3353
+ * is actively wrong — a revoked token keeps its future `expiresAtMs` — and the
3354
+ * caller needs the refresh attempted so the credential can be proven dead.
3355
+ *
3356
+ * Returns true when the token is known good afterwards (refreshed, or still
3357
+ * valid), false when the credential was proven dead.
3275
3358
  */
3276
- async ensureFresh(account) {
3277
- if (this.client === void 0) return;
3359
+ async ensureFresh(account, force = false) {
3360
+ if (this.client === void 0) return true;
3278
3361
  const credential = account.credential;
3279
- if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs)) return;
3362
+ if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs) && !force) return true;
3280
3363
  const existing = this.refreshInflight.get(account.id);
3281
3364
  if (existing !== void 0) {
3282
3365
  await existing;
3283
- return;
3366
+ return (account.credentialDeadUntilMs ?? 0) <= Date.now();
3284
3367
  }
3285
3368
  const run = (async () => {
3286
3369
  if (credential.refreshToken === "") {
3287
- if (credential.expiresAtMs > Date.now() + 3e4) return;
3370
+ if (credential.expiresAtMs > Date.now() + 3e4) return true;
3288
3371
  this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} token expired with no refresh token; sign in again`);
3289
- return;
3372
+ this.penalizeCredentialDead(account.id);
3373
+ return false;
3290
3374
  }
3291
3375
  try {
3292
3376
  const outcome = await this.client.refreshToken(credential);
@@ -3297,15 +3381,23 @@ var WorkBuddyAccountPool = class {
3297
3381
  expiresAtMs: outcome.expiresInSec !== void 0 ? Date.now() + outcome.expiresInSec * 1e3 : credential.expiresAtMs,
3298
3382
  ...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain }
3299
3383
  };
3384
+ this.clearCredentialDead(account.id);
3300
3385
  this.logger?.info?.(`dsh-workbuddy-xdpool: refreshed token for ${account.label}`);
3386
+ return true;
3301
3387
  } catch (error) {
3388
+ if (isCredentialDeadError(error)) {
3389
+ this.penalizeCredentialDead(account.id);
3390
+ this.logger?.warn?.(`dsh-workbuddy-xdpool: ${account.label} refresh token was revoked upstream; sign in again to restore it`, error);
3391
+ return false;
3392
+ }
3302
3393
  if (credential.expiresAtMs > Date.now() + 3e4) this.logger?.warn?.(`dsh-workbuddy-xdpool: token refresh failed but token still valid for ${account.label}`, error);
3303
3394
  else this.logger?.error?.(`dsh-workbuddy-xdpool: token refresh failed and token expired for ${account.label}`, error);
3395
+ return true;
3304
3396
  }
3305
3397
  })();
3306
3398
  this.refreshInflight.set(account.id, run);
3307
3399
  try {
3308
- await run;
3400
+ return await run;
3309
3401
  } finally {
3310
3402
  this.refreshInflight.delete(account.id);
3311
3403
  }
@@ -3354,6 +3446,7 @@ ${new Date(until).toISOString()}
3354
3446
  account.cooldownUntilMs = 0;
3355
3447
  account.modelCooldowns = {};
3356
3448
  account.rateLimitHits = 0;
3449
+ account.credentialDeadUntilMs = 0;
3357
3450
  }
3358
3451
  }
3359
3452
  /** Diagnostics snapshot. Account-wide cooling count (per-model cooling excluded:
@@ -3363,6 +3456,7 @@ ${new Date(until).toISOString()}
3363
3456
  return {
3364
3457
  count: this.accounts.length,
3365
3458
  cooling: this.accounts.filter((account) => account.cooldownUntilMs > now).length,
3459
+ dead: this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now).length,
3366
3460
  lastScanAtMs: this.lastScanAtMs
3367
3461
  };
3368
3462
  }
@@ -5593,6 +5687,43 @@ function injectSummary(compacted, summary) {
5593
5687
  ];
5594
5688
  }
5595
5689
  //#endregion
5690
+ //#region src/siblings.ts
5691
+ /**
5692
+ * The cheapest PAID model sharing `model`'s display name, or undefined.
5693
+ *
5694
+ * Cheapest rather than first, because the point of the hint is "there is a
5695
+ * cheap way out": offering the most expensive twin would make switching look
5696
+ * like a trap. A sibling whose price is unknown is skipped — an unpriced row
5697
+ * cannot be promised as "the paid one".
5698
+ */
5699
+ function paidSiblingOf(model, models) {
5700
+ let best;
5701
+ for (const candidate of models) {
5702
+ if (candidate.id === model.id) continue;
5703
+ if (candidate.name !== model.name) continue;
5704
+ const multiplier = candidate.multiplier;
5705
+ if (multiplier === void 0 || multiplier <= 0) continue;
5706
+ if (best === void 0 || multiplier < best.multiplier) best = {
5707
+ id: candidate.id,
5708
+ name: candidate.name,
5709
+ multiplier
5710
+ };
5711
+ }
5712
+ return best;
5713
+ }
5714
+ /**
5715
+ * The paid sibling of a model, but ONLY when the model itself is free.
5716
+ *
5717
+ * The guard is the whole point: a `x0.79` row with a `x0.06` cousin is not a
5718
+ * "free model with a paid twin", it is just a more expensive row, and labelling
5719
+ * it would be noise on most of the list. The hint answers one question — "the
5720
+ * free one is limited, what now".
5721
+ */
5722
+ function paidAlternativeFor(model, models) {
5723
+ if (model.multiplier !== 0) return void 0;
5724
+ return paidSiblingOf(model, models);
5725
+ }
5726
+ //#endregion
5596
5727
  //#region src/shim.ts
5597
5728
  /**
5598
5729
  * Loopback OpenAI-compatible endpoint with multi-account failover.
@@ -5778,6 +5909,22 @@ function createWorkBuddyShim(options) {
5778
5909
  const server = createServer((req, res) => {
5779
5910
  handle(req, res);
5780
5911
  });
5912
+ /**
5913
+ * "The same model has a paid row" sentence for a 429 body, or undefined.
5914
+ *
5915
+ * Reads the catalog the shim was built with, so the hint names a row this
5916
+ * gateway ACTUALLY serves — a CN-only id in a global 429 would be worse than
5917
+ * no hint at all. Absent when the cooling model is itself paid, or when the
5918
+ * family has no priced twin.
5919
+ */
5920
+ function paidTwinHint(modelId) {
5921
+ const models = catalog.current();
5922
+ const model = models.find((candidate) => candidate.id === modelId);
5923
+ if (model === void 0) return void 0;
5924
+ const twin = paidAlternativeFor(model, models);
5925
+ if (twin === void 0) return void 0;
5926
+ return `${twin.id} is the same model on a paid row (x${twin.multiplier.toFixed(2)} credits) and is rate-limited separately; switch to it to keep going`;
5927
+ }
5781
5928
  const ready = new Promise((resolve, reject) => {
5782
5929
  server.once("listening", () => resolve());
5783
5930
  server.once("error", reject);
@@ -5889,7 +6036,8 @@ function createWorkBuddyShim(options) {
5889
6036
  if (why.reason === "cooling") {
5890
6037
  const subject = modelId === void 0 ? "every WorkBuddy account is rate-limited" : `every account is rate-limited for model ${modelId}`;
5891
6038
  const upstream = last === void 0 ? "" : ` — ${last.message.slice(0, 200)}`;
5892
- writeOpenAIError(res, 429, "soft_rate", `${subject} (${why.cooling}/${why.total} cooling, tried ${tried.length})${tried.length === 0 ? "" : `: ${tried.join(" → ")}`}${upstream}`);
6039
+ const twin = modelId === void 0 ? void 0 : paidTwinHint(modelId);
6040
+ writeOpenAIError(res, 429, "soft_rate", `${subject} (${why.cooling}/${why.total} cooling, tried ${tried.length})${tried.length === 0 ? "" : `: ${tried.join(" → ")}`}${upstream}` + (twin === void 0 ? "" : ` — ${twin}`));
5893
6041
  return;
5894
6042
  }
5895
6043
  if (why.reason === "disabled") {
@@ -5900,6 +6048,10 @@ function createWorkBuddyShim(options) {
5900
6048
  writeOpenAIError(res, 402, "reserve_floor", `every WorkBuddy account is at or below its credit reserve (${why.total} account(s)); lower the reserve on the pool card or top up credits`);
5901
6049
  return;
5902
6050
  }
6051
+ if (why.reason === "session_dead") {
6052
+ writeOpenAIError(res, 401, "session_dead", `${why.dead}/${why.total} WorkBuddy account(s) for this gateway were rejected by the upstream (their sign-in was revoked); sign in again in the WorkBuddy desktop app to restore them`);
6053
+ return;
6054
+ }
5903
6055
  writeOpenAIError(res, 401, "not_signed_in", why.total === 0 ? "no WorkBuddy credential found for this gateway; sign in on the desktop app (or set WORKBUDDY_AUTH_FILE)" : `no usable WorkBuddy credential among ${why.total} account(s) for this gateway; sign in again on the desktop app`);
5904
6056
  return;
5905
6057
  }
@@ -5916,7 +6068,11 @@ function createWorkBuddyShim(options) {
5916
6068
  };
5917
6069
  if (result.kind === "session_dead") {
5918
6070
  logger?.warn(`dsh-workbuddy-xdpool: ${account.label} session dead; refreshing token and retrying`);
5919
- await pool.refreshAccount(account.id);
6071
+ const before = account.credential.accessToken;
6072
+ if (!await pool.refreshAccount(account.id, { force: true }) || account.credential.accessToken === before) {
6073
+ pool.penalizeCredentialDead(account.id);
6074
+ logger?.warn(`dsh-workbuddy-xdpool: ${account.label} sign-in is no longer valid (attempt ${attempt + 1}/${maxAttempts}); rotating`);
6075
+ }
5920
6076
  continue;
5921
6077
  }
5922
6078
  if (result.kind === "hard_credit") {
@@ -6109,7 +6265,8 @@ async function recoverFromContextOverrun(options) {
6109
6265
  detail: "prompt still exceeded the window after compaction"
6110
6266
  };
6111
6267
  if (result.kind === "session_dead") {
6112
- await pool.refreshAccount(account.id);
6268
+ const before = account.credential.accessToken;
6269
+ if (!await pool.refreshAccount(account.id, { force: true }) || account.credential.accessToken === before) pool.penalizeCredentialDead(account.id);
6113
6270
  continue;
6114
6271
  }
6115
6272
  if (result.kind === "hard_credit") {
@@ -6401,6 +6558,8 @@ function parseAutomationRun(body) {
6401
6558
  function toWebAccount(account, disabled, reserve, reserved) {
6402
6559
  const now = Date.now();
6403
6560
  const cooling = account.cooldownUntilMs > now;
6561
+ const deadUntil = account.credentialDeadUntilMs ?? 0;
6562
+ const credentialDead = deadUntil > now;
6404
6563
  const modelCooldowns = Object.entries(account.modelCooldowns).filter(([, until]) => until > now).sort((a, b) => a[1] - b[1]).map(([modelId, until]) => ({
6405
6564
  modelId,
6406
6565
  until: new Date(until).toISOString()
@@ -6413,6 +6572,8 @@ function toWebAccount(account, disabled, reserve, reserved) {
6413
6572
  ...account.credential.expiresAtMs === 0 ? {} : { expiresAt: new Date(account.credential.expiresAtMs).toISOString() },
6414
6573
  cooling,
6415
6574
  ...cooling ? { cooldownUntil: new Date(account.cooldownUntilMs).toISOString() } : {},
6575
+ credentialDead,
6576
+ ...credentialDead ? { credentialDeadUntil: new Date(deadUntil).toISOString() } : {},
6416
6577
  ...modelCooldowns.length === 0 ? {} : { modelCooldowns },
6417
6578
  disabled,
6418
6579
  creditReserve: reserve,
@@ -6452,7 +6613,7 @@ async function poolWebStatus(deps, region = "cn") {
6452
6613
  const row = toWebAccount(account, deps.pool.isDisabled(account.id), deps.pool.creditReserveOf(account.id), deps.pool.isReserved(account.id));
6453
6614
  const earned = deps.scheduler?.().earningsToday[account.id];
6454
6615
  if (earned !== void 0) Object.assign(row, { automationToday: earned });
6455
- if (!row.cooling) {
6616
+ if (!row.cooling && row.credentialDead !== true) {
6456
6617
  try {
6457
6618
  const credits = await deps.client.fetchCredits(account.credential);
6458
6619
  deps.pool.noteCredits(account.id, credits.total);
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-workbuddy-xdpool",
3
3
  "displayName": "DSH WorkBuddy XD Pool",
4
4
  "description": "Merge every locally signed-in WorkBuddy account into DeepSeek Harness as one auto-failing-over model pool (multi-account rotation, live credits, daily check-in and model catalog).",
5
- "version": "1.8.0",
5
+ "version": "1.8.2",
6
6
  "license": "MIT",
7
7
  "author": "XDTrees",
8
8
  "homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",