dsh-workbuddy-xdpool 0.4.5 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,67 @@
4
4
 
5
5
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
6
6
 
7
+ ## 1.0.1 (2026-09-23)
8
+
9
+ 修复两个用号逻辑上的缺陷,并把账号卡片重新排了一遍。
10
+
11
+ ### 一、「当前使用」显示的账号不对
12
+
13
+ 卡片上显示的账号,是你**最近一次实际在用**的那个吗?之前不是 —— 它显示的是「**准备尝试**」的账号。
14
+
15
+ 举个例子:某个账号积分用光了,插件去试它、失败、自动换到另一个号继续服务。但你确实在用(换号成功了),卡片却还显示那个已经废掉的号。
16
+
17
+ 现在改成**只有请求真正成功**才记录,所以显示的永远是你实际在用的那个。
18
+
19
+ ### 二、账号积分用尽时,会直接把错误抛给你
20
+
21
+ 上游有两种"不让用":
22
+
23
+ | 情况 | 含义 | 之前的表现 |
24
+ | --- | --- | --- |
25
+ | **频率限制** | 这个号暂时被限流,等会儿能恢复 | 正确:冷却这个号,**自动换号** |
26
+ | **额度用尽** | 这个号这个周期的积分花完了 | ❌ **直接报错给你**,不换号 |
27
+
28
+ 现在额度用尽也会**自动换到有积分的账号**,并把那个号整体停用 30 分钟(不是只停用某个模型)。请求不会再因此失败。
29
+
30
+ ### 三、账号卡片排版重做
31
+
32
+ - 每个账号的**开关**做成了胶囊按钮(和「账号使用方式」同一套样式),一眼能看出是开还是关
33
+ - 账号卡**分层更清楚**:标题行(名字|状态|开关)→ 分隔线 → 信息与积分两栏
34
+ - 去掉了多余的边框和重复信息(不再显示 `www.workbuddy.cn` 这类域名 —— 上面的区域标签已经说明了)
35
+ - 新增顶部「**当前使用**」提示条,一眼看到正在用哪个号
36
+
37
+ ## 1.0.0 (2026-09-21)
38
+
39
+ 这一版把「账号」和「用号方式」都交给你控制。
40
+
41
+ ### 一、可以停用不想用的账号了
42
+
43
+ 账号列表里每个账号右侧新增一个开关,关掉它这个账号就**不再参与请求**。
44
+
45
+ - 关掉的账号仍然显示在列表里(变灰),随时可以再打开
46
+ - 设置会被保存,**重启 DSH 后依然有效**
47
+ - 只影响路由,不会删除或改动你的登录凭据
48
+
49
+ ### 二、新增「均衡」用号模式
50
+
51
+ 原来的两种方式都保留,另加一种:
52
+
53
+ | 模式 | 行为 |
54
+ | --- | --- |
55
+ | **优先**(默认,行为不变) | 一直用同一个账号,直到它被限流才换下一个 |
56
+ | **均衡**(新增) | 每个可用账号都参与抽取,**闲置越久权重越高** |
57
+ | **轮换** | 严格轮流,把额度摊平 |
58
+
59
+ **为什么需要「均衡」**:默认的「优先」会死磕第一个账号,把它打到限流才换 —— 如果你有 5 个号,其余 4 个基本闲着。切到「均衡」后请求会自然分散到各个账号,**更不容易撞限流**,可用时间更长。
60
+
61
+ 在卡片顶部「账号使用方式」里切换,即时生效。
62
+
63
+ ### 其他
64
+
65
+ - 显式选定的账号(如卡片上「下一个」标记的那个)在三种模式下**都会被优先使用**
66
+ - 处于冷却中的账号在「均衡」模式下**一定不会被选中**,冷却结束自动回到抽取池
67
+
7
68
  ## 0.4.5 (2026-09-21)
8
69
 
9
70
  本版**功能与 0.4.4 完全相同**,只是换了一条发布通道。
package/lib/bin.js CHANGED
@@ -758,10 +758,38 @@ function candidateAuthDirs(env = process.env) {
758
758
  * Read-only pool of every discovered WorkBuddy account, with rate-limit
759
759
  * cooldown and round-robin failover.
760
760
  */
761
+ /** Idle bonus per hour an account has been unused (reference-panel default). */
762
+ const IDLE_WEIGHT_PER_HOUR = .5;
763
+ /** Ceiling for the idle bonus, so an idle account cannot dominate forever. */
764
+ const IDLE_WEIGHT_MAX = 5;
765
+ /**
766
+ * Weight one account by how long it has been idle.
767
+ *
768
+ * The base of 1 keeps every eligible account in play: an account that served a
769
+ * moment ago still has a small chance, so a single unhealthy account cannot pin
770
+ * the pool to itself, and the weights never sum to zero.
771
+ *
772
+ * `lastUsedAt === undefined` means "never used in this process", which earns the
773
+ * full bonus: on a fresh start every account ties, and the weighted draw spreads
774
+ * the first requests instead of always picking the first entry.
775
+ */
776
+ function idleWeight(lastUsedAt, now) {
777
+ if (lastUsedAt === void 0) return 6;
778
+ const hours = (now - lastUsedAt) / 36e5;
779
+ return 1 + Math.min(Math.max(hours, 0) * IDLE_WEIGHT_PER_HOUR, IDLE_WEIGHT_MAX);
780
+ }
781
+ /** Default rest for an account whose credits ran out (packs reset on their own schedule). */
782
+ const EXHAUST_COOLDOWN_MS = 18e5;
761
783
  var WorkBuddyAccountPool = class {
762
784
  logger;
763
785
  authDirs;
764
786
  cooldownMs;
787
+ /**
788
+ * How long an account stays out of rotation after the upstream reports its
789
+ * credits are spent. Credit packs reset on their own schedule rather than on a
790
+ * rate-limit window, so this is much longer than `cooldownMs`.
791
+ */
792
+ exhaustCooldownMs;
765
793
  client;
766
794
  refreshMarginMs;
767
795
  accounts = [];
@@ -770,11 +798,30 @@ var WorkBuddyAccountPool = class {
770
798
  cursor = 0;
771
799
  lastScanAtMs = 0;
772
800
  preferredId;
801
+ /**
802
+ * Account ids the user switched off on the card.
803
+ *
804
+ * Disabling is a user preference rather than a property of the credential:
805
+ * `scan()` rebuilds every account object from the auth files, so the set
806
+ * lives on the pool and is re-applied from settings after each scan.
807
+ */
808
+ disabledIds = /* @__PURE__ */ new Set();
809
+ /**
810
+ * Last time each account served a request, epoch ms. Drives the idle term
811
+ * of the priority-mode weighting below: an account that just served loses to
812
+ * one that has been idle, so a small pool stops hammering a single account.
813
+ *
814
+ * In-memory on purpose: it only biases the next pick, so a cold start that
815
+ * treats every account as idle is the right default. Not keyed by id lookup
816
+ * misses because a removed account simply disappears from the map on re-scan.
817
+ */
818
+ lastUsedAt = /* @__PURE__ */ new Map();
773
819
  refreshInflight = /* @__PURE__ */ new Map();
774
820
  constructor(options = {}) {
775
821
  this.logger = options.logger;
776
822
  this.authDirs = options.authDirs ?? candidateAuthDirs();
777
823
  this.cooldownMs = options.cooldownMs ?? 6e4;
824
+ this.exhaustCooldownMs = options.exhaustCooldownMs ?? EXHAUST_COOLDOWN_MS;
778
825
  this.client = options.client;
779
826
  this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
780
827
  this.distribution = options.distribution ?? "priority";
@@ -787,7 +834,9 @@ var WorkBuddyAccountPool = class {
787
834
  applyConfig(options) {
788
835
  if (options.authDirs !== void 0 && options.authDirs.length > 0) this.authDirs = options.authDirs;
789
836
  if (options.cooldownMs !== void 0 && options.cooldownMs >= 1e3) this.cooldownMs = options.cooldownMs;
837
+ if (options.exhaustCooldownMs !== void 0 && options.exhaustCooldownMs >= 1e3) this.exhaustCooldownMs = options.exhaustCooldownMs;
790
838
  if (options.distribution !== void 0) this.distribution = options.distribution;
839
+ if (options.disabledAccountIds !== void 0) this.disabledIds = new Set(options.disabledAccountIds);
791
840
  }
792
841
  /** Rescan the auth directories and merge newly discovered accounts. */
793
842
  async scan() {
@@ -840,12 +889,47 @@ var WorkBuddyAccountPool = class {
840
889
  */
841
890
  available(now, modelId, region) {
842
891
  return this.accounts.filter((account) => {
892
+ if (this.disabledIds.has(account.id)) return false;
843
893
  if (account.cooldownUntilMs > now) return false;
844
894
  if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
845
895
  if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
846
896
  return true;
847
897
  });
848
898
  }
899
+ /** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
900
+ pickRoundRobin(pool) {
901
+ const index = this.cursor % pool.length;
902
+ const account = pool[index];
903
+ if (account === void 0) return void 0;
904
+ this.cursor = (index + 1) % pool.length;
905
+ return account;
906
+ }
907
+ /**
908
+ * Priority mode: weighted random over the eligible accounts.
909
+ *
910
+ * The weight is an idle bonus — `1 + min(idleHours * perHour, max)` — so an
911
+ * account that has never served (or has been idle for a while) outranks one
912
+ * that just answered. Reference panel logic drops its success-rate term
913
+ * entirely because a lifetime error counter penalises an account forever;
914
+ * instantaneous health is already handled by cooldowns, which is why those
915
+ * accounts never reach this list.
916
+ *
917
+ * A pool with no idle history (fresh process) hashes to equal weights, which
918
+ * spreads the very first picks instead of always returning index 0.
919
+ */
920
+ pickByWeight(pool) {
921
+ if (pool.length === 1) return pool[0];
922
+ const now = Date.now();
923
+ const weights = pool.map((account) => idleWeight(this.lastUsedAt.get(account.id), now));
924
+ const total = weights.reduce((sum, weight) => sum + weight, 0);
925
+ if (!Number.isFinite(total) || total <= 0) return pool[0];
926
+ let roll = Math.random() * total;
927
+ for (let index = 0; index < pool.length; index += 1) {
928
+ roll -= weights[index] ?? 0;
929
+ if (roll < 0) return pool[index];
930
+ }
931
+ return pool[pool.length - 1];
932
+ }
849
933
  /**
850
934
  * Pick the account to serve a request.
851
935
  *
@@ -875,16 +959,14 @@ var WorkBuddyAccountPool = class {
875
959
  }
876
960
  if (pool.length === 0) return void 0;
877
961
  if (this.preferredId !== void 0) {
878
- const preferredIndex = pool.findIndex((account) => account.id === this.preferredId);
879
- if (preferredIndex > 0) {
880
- const [preferred] = pool.splice(preferredIndex, 1);
881
- if (preferred !== void 0) pool = [preferred, ...pool];
962
+ const preferred = pool.find((account) => account.id === this.preferredId);
963
+ if (preferred !== void 0) {
964
+ await this.ensureFresh(preferred);
965
+ return preferred;
882
966
  }
883
967
  }
884
- const index = this.distribution === "round-robin" ? this.cursor % pool.length : 0;
885
- const account = pool[index];
968
+ const account = this.distribution === "round-robin" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
886
969
  if (account === void 0) return void 0;
887
- if (this.distribution === "round-robin") this.cursor = (index + 1) % pool.length;
888
970
  await this.ensureFresh(account);
889
971
  return account;
890
972
  }
@@ -896,6 +978,48 @@ var WorkBuddyAccountPool = class {
896
978
  prefer(accountId) {
897
979
  this.preferredId = accountId;
898
980
  }
981
+ /** Whether the user switched this account off on the card. */
982
+ isDisabled(accountId) {
983
+ return this.disabledIds.has(accountId);
984
+ }
985
+ /** Every account id the user switched off, in discovery order. */
986
+ disabledIdsInOrder() {
987
+ return this.accounts.filter((account) => this.disabledIds.has(account.id)).map((account) => account.id);
988
+ }
989
+ /**
990
+ * Record that an account actually served a request.
991
+ *
992
+ * Called by the shim once the upstream answers 200 — only then is the account
993
+ * the one the user is really being served by. `balanced` mode reads the same map
994
+ * for its idle weighting, so a request that failed over to another account must
995
+ * not count as used for the account that was merely tried.
996
+ */
997
+ noteServed(accountId) {
998
+ if (!this.accounts.some((account) => account.id === accountId)) return;
999
+ this.lastUsedAt.set(accountId, Date.now());
1000
+ }
1001
+ /**
1002
+ * The account that served the most recent request, if any.
1003
+ *
1004
+ * Distinct from "who would serve the next one": this is a record of what
1005
+ * actually happened, which is what the card needs to answer "which account am
1006
+ * I using right now?". Under `balanced` there is no deterministic next account
1007
+ * at all, so a recorded fact is the only honest answer.
1008
+ *
1009
+ * Returns undefined before the first request of the process, and after every
1010
+ * known account has been re-scanned away (a login swapped out under us).
1011
+ */
1012
+ lastServedId() {
1013
+ let newest;
1014
+ for (const [id, at] of this.lastUsedAt) {
1015
+ if (!this.accounts.some((account) => account.id === id)) continue;
1016
+ if (newest === void 0 || at > newest.at) newest = {
1017
+ id,
1018
+ at
1019
+ };
1020
+ }
1021
+ return newest?.id;
1022
+ }
899
1023
  /** Best-effort refresh of one account after a session-dead upstream answer. */
900
1024
  async refreshAccount(accountId) {
901
1025
  const account = this.accounts.find((item) => item.id === accountId);
@@ -946,6 +1070,23 @@ var WorkBuddyAccountPool = class {
946
1070
  }
947
1071
  }
948
1072
  /**
1073
+ * Cool a whole account after the upstream reports its credits are spent.
1074
+ *
1075
+ * Credit exhaustion is an ACCOUNT condition, unlike a model rate limit: every
1076
+ * model on that account is unusable until the quota resets, so this cools the
1077
+ * account as a whole (no `modelId`) for the configured exhaustion window. The
1078
+ * shim then rotates to a different account instead of failing the request.
1079
+ */
1080
+ penalizeExhausted(accountId) {
1081
+ const until = Date.now() + this.exhaustCooldownMs;
1082
+ this.penalize(accountId, until);
1083
+ this.logger?.warn(`
1084
+ dsh-workbuddy-xdpool: account credits exhausted; cooling the whole account until
1085
+
1086
+ ${new Date(until).toISOString()}
1087
+ `);
1088
+ }
1089
+ /**
949
1090
  * Mark an account (or one of its models) rate-limited.
950
1091
  *
951
1092
  * With `modelId`, only that model on the account is cooled — the account's
@@ -1173,6 +1314,7 @@ z.object({
1173
1314
  authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
1174
1315
  cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
1175
1316
  distribution: z.union(["priority", "round-robin"]).default("priority").description("How requests are spread: priority (drain one) or round-robin"),
1317
+ disabledAccountIds: z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)"),
1176
1318
  enabledModelIds: z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet"),
1177
1319
  imageModelIds: z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet"),
1178
1320
  contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Legacy shared context budgets; used by a region with no per-region selection yet"),
package/lib/client.js CHANGED
@@ -128,11 +128,32 @@ window.__ModuleLoader__.load({
128
128
  /* Account list (each account = a labeled subpanel, same as dingminhua). */
129
129
  .dsm-workbuddy-xdpool-accounts{display:flex;flex-direction:column;gap:14px;border-top:1px solid var(--dsw-alias-border-l2,#36373b);padding-top:14px}
130
130
  .dsm-workbuddy-xdpool-accounts-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
131
+ /* "In use now": answers which account is serving without scanning every row. */
132
+ /* A hairline + tint reads as status; a filled bar would read as a call to action. */
133
+ .dsm-workbuddy-xdpool-current{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin:10px 0 0;padding:9px 13px;border:1px solid color-mix(in oklab, var(--dsw-alias-state-success-primary,#22a06b) 32%, transparent);border-radius:12px;background:color-mix(in oklab, var(--dsw-alias-state-success-primary,#22a06b) 9%, transparent)}
134
+ .dsm-workbuddy-xdpool-current-dot{width:7px;height:7px;border-radius:50%;flex:none;background:var(--dsw-alias-state-success-primary,#22a06b);box-shadow:0 0 0 3px color-mix(in oklab, var(--dsw-alias-state-success-primary,#22a06b) 18%, transparent)}
135
+ .dsm-workbuddy-xdpool-current-label{color:var(--dsw-alias-state-success-primary,#22a06b);font-size:11.5px;line-height:18px;font-weight:600;letter-spacing:.02em;flex:none}
136
+ .dsm-workbuddy-xdpool-current-name{color:var(--dsw-alias-label-primary,#e6e6e6);font-size:13px;line-height:18px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
137
+ .dsm-workbuddy-xdpool-current-note{color:var(--dsw-alias-state-warning-primary,#d97706);font-size:11px;line-height:16px}
131
138
  .dsm-workbuddy-xdpool-accounts-title{margin:0;color:var(--dsw-alias-label-primary,#e6e6e6);font-size:14px;font-weight:600;line-height:20px}
132
139
  .dsm-workbuddy-xdpool-accounts-summary{margin:2px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:18px}
133
- .dsm-workbuddy-xdpool-account{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:14px;background:var(--dsw-alias-bg-layer-2,#24262c)}
134
- .dsm-workbuddy-xdpool-account-copy{display:flex;flex-direction:column;gap:3px;min-width:0}
140
+ .dsm-workbuddy-xdpool-account{display:flex;flex-direction:column;gap:0;padding:0;border:1px solid color-mix(in oklab, var(--dsw-alias-border-l2,#3a3d45) 55%, transparent);border-radius:16px;background:var(--dsw-alias-bg-layer-2,#24262c);box-shadow:0 1px 3px rgba(0,0,0,.04);overflow:hidden}
141
+ /* Body row inside a card: identity on the left, credit panels on the right. */
142
+ /* flex-wrap lets the panels drop below on a narrow card instead of squeezing both. */
143
+ .dsm-workbuddy-xdpool-account-body{display:flex;align-items:stretch;gap:0;flex-wrap:wrap}
144
+ .dsm-workbuddy-xdpool-account-copy{display:flex;flex-direction:column;align-items:flex-start;gap:6px;flex:1 1 200px;min-width:0;padding:14px 16px;justify-content:center}
135
145
  .dsm-workbuddy-xdpool-account-label{color:var(--dsw-alias-label-primary,#e6e6e6);font-size:14px;font-weight:600;line-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
146
+ .dsm-workbuddy-xdpool-account-head .dsm-workbuddy-xdpool-account-toggle{margin-left:auto}
147
+ .dsm-workbuddy-xdpool-account-head{display:flex;align-items:center;gap:10px;min-width:0;padding:12px 16px;border-bottom:1px solid color-mix(in oklab, var(--dsw-alias-border-l2,#3a3d45) 45%, transparent)}
148
+ /* Account switched off on the card: still listed (so it can be turned back on) but visually muted. */
149
+ .dsm-workbuddy-xdpool-account-off{opacity:.55}
150
+ /* Small pill switch: "in rotation" vs "off". Native checkbox styled by the label. */
151
+ .dsm-workbuddy-xdpool-account-toggle{appearance:none;font:inherit;display:inline-flex;align-items:center;gap:5px;cursor:pointer;flex:none;border:1px solid color-mix(in oklab, var(--dsw-alias-border-l2,#3a3d45) 70%, transparent);border-radius:999px;padding:3px 10px;font-size:11px;line-height:17px;background:transparent;color:var(--dsw-alias-label-tertiary,#9aa0a8);transition:color .16s,border-color .16s,background .16s}
152
+ .dsm-workbuddy-xdpool-account-toggle:hover:not(:disabled){color:var(--dsw-alias-label-primary,#e6e6e6);border-color:var(--dsw-alias-label-dimmed,#777)}
153
+ .dsm-workbuddy-xdpool-account-toggle:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
154
+ .dsm-workbuddy-xdpool-account-toggle:disabled{cursor:default;opacity:.6}
155
+ .dsm-workbuddy-xdpool-account-toggle-on{background:color-mix(in oklab, var(--dsw-alias-state-success-primary,#22a06b) 14%, transparent);border-color:color-mix(in oklab, var(--dsw-alias-state-success-primary,#22a06b) 55%, transparent);color:var(--dsw-alias-state-success-primary,#22a06b);font-weight:600}
156
+ .dsm-workbuddy-xdpool-account-toggle-dot{width:6px;height:6px;border-radius:50%;flex:none;background:currentColor;opacity:.9}
136
157
  .dsm-workbuddy-xdpool-account-tags{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
137
158
  .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)}
138
159
  .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)}
@@ -143,9 +164,9 @@ window.__ModuleLoader__.load({
143
164
  .dsm-workbuddy-xdpool-account-error{margin:0;color:var(--dsw-alias-state-error-primary,#ef4444);font-size:13px;line-height:20px}
144
165
 
145
166
  /* Two-column stats: packages on the left, total + check-in on the right. */
146
- .dsm-workbuddy-xdpool-stats{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(170px,.85fr);gap:10px;margin-top:10px}
147
- .dsm-workbuddy-xdpool-panel{display:flex;flex-direction:column;min-width:0;gap:7px;padding:14px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:12px;background:var(--dsw-alias-bg-layer-2,#24262c)}
148
- .dsm-workbuddy-xdpool-panel-title{color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:18px}
167
+ .dsm-workbuddy-xdpool-stats{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(168px,.85fr);gap:0;flex:1 1 420px;min-width:0;max-width:660px;border-left:1px solid color-mix(in oklab, var(--dsw-alias-border-l2,#3a3d45) 45%, transparent)}
168
+ .dsm-workbuddy-xdpool-panel{display:flex;flex-direction:column;min-width:0;gap:7px;padding:14px 16px}
169
+ .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}
149
170
  .dsm-workbuddy-xdpool-panel-empty{color:var(--dsw-alias-label-tertiary,#999);font-size:14px;line-height:20px}
150
171
  .dsm-workbuddy-xdpool-panel-error{color:var(--dsw-alias-state-error-primary,#ef4444);font-size:12px;line-height:18px;word-break:break-word}
151
172
  .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}
@@ -163,9 +184,9 @@ window.__ModuleLoader__.load({
163
184
  fold onto their own line so a long package name cannot squeeze them out. */
164
185
  .dsm-workbuddy-xdpool-packages-when{grid-column:1/-1;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:11px;line-height:15px;font-variant-numeric:tabular-nums}
165
186
  .dsm-workbuddy-xdpool-packages-when-soon{color:var(--dsw-alias-state-warning-primary,#d97706)}
166
- .dsm-workbuddy-xdpool-panel-total{position:relative;align-items:center;text-align:center;overflow:hidden}
187
+ .dsm-workbuddy-xdpool-panel-total{position:relative;align-items:center;text-align:center;justify-content:center;overflow:hidden;background:color-mix(in oklab, var(--dsw-alias-state-success-primary,#22a06b) 5%, transparent)}
167
188
  .dsm-workbuddy-xdpool-panel-total::before{content:"";position:absolute;top:0;left:0;right:0;height:3px;opacity:.9;background:var(--dsw-alias-state-success-primary,#22a06b)}
168
- .dsm-workbuddy-xdpool-total-value{color:var(--dsw-alias-state-success-primary,#22a06b);font-size:30px;line-height:34px;font-weight:700;letter-spacing:-.5px;white-space:nowrap;font-variant-numeric:tabular-nums}
189
+ .dsm-workbuddy-xdpool-total-value{color:var(--dsw-alias-state-success-primary,#22a06b);font-size:28px;line-height:32px;font-weight:800;letter-spacing:-.02em;white-space:nowrap;font-variant-numeric:tabular-nums}
169
190
 
170
191
  /* Model directory list. */
171
192
  .dsm-workbuddy-xdpool-models{display:flex;flex-direction:column;gap:10px;border-top:1px solid var(--dsw-alias-border-l2,#36373b);padding-top:14px}
@@ -374,6 +395,8 @@ window.__ModuleLoader__.load({
374
395
  const [flash, setFlash] = (0, react.useState)(void 0);
375
396
  /** Account id whose daily claim is currently in flight. */
376
397
  const [checkinBusyId, setCheckinBusyId] = (0, react.useState)(void 0);
398
+ /** Account id whose enable/disable switch is in flight, if any. */
399
+ const [accountBusyId, setAccountBusyId] = (0, react.useState)(void 0);
377
400
  /**
378
401
  * Draft model selection. `undefined` means "no local edits"; once a checkbox
379
402
  * is touched the draft takes over and is what the Save button posts. Discard
@@ -509,6 +532,35 @@ window.__ModuleLoader__.load({
509
532
  if (mounted.current) setCheckinBusyId(void 0);
510
533
  }
511
534
  };
535
+ /**
536
+ * Switch one account in or out of the pool.
537
+ *
538
+ * The id travels in the body and the host validates it against the accounts it
539
+ * really knows, so a stale tab cannot write an orphan id. Disabling only stops
540
+ * the account from being picked — it stays listed so it can be turned back on —
541
+ * and the change is saved through the settings section, so it survives a
542
+ * restart and is re-applied after every re-scan.
543
+ */
544
+ const toggleAccountDisabled = async (accountId, disabled) => {
545
+ const write = settingsScope?.set;
546
+ if (write === void 0) {
547
+ setError(t?.("row.modelsSaveError", { message: "settings scope is read-only" }) ?? "settings scope is read-only");
548
+ return;
549
+ }
550
+ setAccountBusyId(accountId);
551
+ setFlash(void 0);
552
+ try {
553
+ const current = (status?.accounts ?? []).filter((account) => account.disabled === true).map((account) => account.id);
554
+ const next = disabled ? current.includes(accountId) ? current : [...current, accountId] : current.filter((id) => id !== accountId);
555
+ await write.call(settingsScope, "disabledAccountIds", next);
556
+ await refresh(activeRegion);
557
+ } catch (cause) {
558
+ const message = cause instanceof Error ? cause.message : String(cause);
559
+ if (mounted.current) setError(t?.("row.accountToggleError", { message }) ?? "Could not switch the account: " + message);
560
+ } finally {
561
+ if (mounted.current) setAccountBusyId(void 0);
562
+ }
563
+ };
512
564
  /** Keep the draft in step with the server copy while nothing is dirty. */
513
565
  const modelDraft = draft ?? (status === void 0 ? {} : draftFromStatus(status));
514
566
  /** Model edits need a writable settings scope; otherwise the rows are read-only. */
@@ -722,17 +774,21 @@ window.__ModuleLoader__.load({
722
774
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
723
775
  className: "dsm-workbuddy-xdpool-dist-title",
724
776
  children: t?.("row.distTitle") ?? "Account usage"
725
- }), ["priority", "round-robin"].map((option) => {
777
+ }), [
778
+ "priority",
779
+ "balanced",
780
+ "round-robin"
781
+ ].map((option) => {
726
782
  const active = (status.distribution ?? "priority") === option;
727
- const label = option === "priority" ? t?.("row.distPriority") ?? "Priority" : t?.("row.distRoundRobin") ?? "Round-robin";
728
- const hint = option === "priority" ? t?.("row.distPriorityHint") ?? "" : t?.("row.distRoundRobinHint") ?? "";
783
+ const label = option === "priority" ? t?.("row.distPriority") ?? "Priority" : option === "balanced" ? t?.("row.distBalanced") ?? "Balanced" : t?.("row.distRoundRobin") ?? "Round-robin";
784
+ const hint = option === "priority" ? t?.("row.distPriorityHint") ?? "" : option === "balanced" ? t?.("row.distBalancedHint") ?? "" : t?.("row.distRoundRobinHint") ?? "";
729
785
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
730
786
  type: "button",
731
787
  role: "radio",
732
788
  "aria-checked": active,
733
789
  title: hint,
734
790
  disabled: !modelsEditable,
735
- className: `dsm-workbuddy-xdpool-dist-option${active ? " dsm-workbuddy-xdpool-dist-option-active" : ""}`,
791
+ className: "dsm-workbuddy-xdpool-dist-option" + (active ? " dsm-workbuddy-xdpool-dist-option-active" : ""),
736
792
  onClick: () => {
737
793
  setDistribution(option);
738
794
  },
@@ -801,27 +857,55 @@ window.__ModuleLoader__.load({
801
857
  accountCount > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
802
858
  className: "dsm-workbuddy-xdpool-accounts",
803
859
  "aria-label": t?.("row.accountsTitle") ?? "Accounts",
804
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
805
- className: "dsm-workbuddy-xdpool-accounts-head",
806
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
807
- className: "dsm-workbuddy-xdpool-accounts-title",
808
- children: t?.("row.accountsTitle") ?? "Accounts in the pool"
809
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
810
- className: "dsm-workbuddy-xdpool-accounts-summary",
811
- children: t?.("row.accountsSummary", {
812
- count: accountCount,
813
- cooling
814
- }) ?? `${accountCount} account(s) · ${cooling} cooling`
815
- })]
816
- }), status?.accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountBlock, {
817
- account,
818
- ...status.activeAccountId === void 0 ? {} : { activeAccountId: status.activeAccountId },
819
- ...checkinBusyId === void 0 ? {} : { checkinBusyId },
820
- onClaimCheckin: (accountId) => {
821
- claimCheckin(accountId);
822
- },
823
- t
824
- }, account.id))]
860
+ children: [
861
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
862
+ className: "dsm-workbuddy-xdpool-accounts-head",
863
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
864
+ className: "dsm-workbuddy-xdpool-accounts-title",
865
+ children: t?.("row.accountsTitle") ?? "Accounts in the pool"
866
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
867
+ className: "dsm-workbuddy-xdpool-accounts-summary",
868
+ children: t?.("row.accountsSummary", {
869
+ count: accountCount,
870
+ cooling
871
+ }) ?? `${accountCount} account(s) · ${cooling} cooling`
872
+ })]
873
+ }),
874
+ (() => {
875
+ const active = status?.activeAccountId === void 0 ? void 0 : status.accounts.find((account) => account.id === status.activeAccountId);
876
+ if (active === void 0) return null;
877
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
878
+ className: "dsm-workbuddy-xdpool-current",
879
+ children: [
880
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsm-workbuddy-xdpool-current-dot" }),
881
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
882
+ className: "dsm-workbuddy-xdpool-current-label",
883
+ children: t?.("row.currentAccount") ?? "In use now"
884
+ }),
885
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
886
+ className: "dsm-workbuddy-xdpool-current-name",
887
+ children: active.label
888
+ }),
889
+ active.disabled === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
890
+ className: "dsm-workbuddy-xdpool-current-note",
891
+ children: t?.("row.currentAccountDisabled") ?? "disabled — will switch on the next request"
892
+ }) : null
893
+ ]
894
+ });
895
+ })(),
896
+ status?.accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountBlock, {
897
+ account,
898
+ ...checkinBusyId === void 0 ? {} : { checkinBusyId },
899
+ onClaimCheckin: (accountId) => {
900
+ claimCheckin(accountId);
901
+ },
902
+ onToggleDisabled: (accountId, disabled) => {
903
+ toggleAccountDisabled(accountId, disabled);
904
+ },
905
+ ...accountBusyId === void 0 ? {} : { accountBusyId },
906
+ t
907
+ }, account.id))
908
+ ]
825
909
  }) : null,
826
910
  (status?.models.length ?? 0) > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
827
911
  className: "dsm-workbuddy-xdpool-models",
@@ -880,66 +964,75 @@ window.__ModuleLoader__.load({
880
964
  });
881
965
  }
882
966
  /** One account block: label + status tag + meta + optional credit panels. */
883
- function AccountBlock({ account, activeAccountId, t, checkinBusyId, onClaimCheckin }) {
884
- const isActive = account.id === activeAccountId;
967
+ function AccountBlock({ account, t, checkinBusyId, onClaimCheckin, accountBusyId, onToggleDisabled }) {
968
+ const isDisabled = account.disabled === true;
885
969
  const isCooling = account.cooling === true;
886
970
  const cooldownUntil = account.cooldownUntil !== void 0 ? Date.parse(account.cooldownUntil) : void 0;
887
971
  const modelCooldowns = account.modelCooldowns ?? [];
888
- const tag = isActive ? {
889
- text: t?.("row.accountNext") ?? "Next up",
890
- cls: "dsm-workbuddy-xdpool-account-tag"
891
- } : isCooling ? {
972
+ const tag = isCooling ? {
892
973
  text: t?.("row.cooling") ?? "Cooling",
893
974
  cls: "dsm-workbuddy-xdpool-account-tag dsm-workbuddy-xdpool-account-tag-cooling"
894
975
  } : null;
895
976
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
896
- className: "dsm-workbuddy-xdpool-account",
977
+ className: isDisabled ? "dsm-workbuddy-xdpool-account dsm-workbuddy-xdpool-account-off" : "dsm-workbuddy-xdpool-account",
897
978
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
898
- className: "dsm-workbuddy-xdpool-account-copy",
979
+ className: "dsm-workbuddy-xdpool-account-head",
899
980
  children: [
900
981
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
901
982
  className: "dsm-workbuddy-xdpool-account-label",
902
983
  children: account.label
903
984
  }),
904
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
905
- className: "dsm-workbuddy-xdpool-account-tags",
906
- children: tag === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
907
- className: tag.cls,
908
- children: tag.text
909
- })
910
- }),
911
- account.domain !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
912
- className: "dsm-workbuddy-xdpool-account-meta",
913
- children: account.domain
985
+ tag === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
986
+ className: tag.cls,
987
+ children: tag.text
914
988
  }),
915
- account.expiresAt !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
916
- className: "dsm-workbuddy-xdpool-account-meta",
917
- children: t?.("row.tokenExpiry", { time: formatDateTime(account.expiresAt) }) ?? `token ${formatDateTime(account.expiresAt)}`
918
- }) : null,
919
- isCooling && cooldownUntil !== void 0 && !Number.isNaN(cooldownUntil) ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
920
- className: "dsm-workbuddy-xdpool-account-meta",
921
- children: [
922
- t?.("row.cooldownUntil", { time: formatTime(cooldownUntil) }) ?? `until ${formatTime(cooldownUntil)}`,
923
- " · ",
924
- t?.("row.cooldownHits", { hits: account.rateLimitHits ?? 0 }) ?? `${account.rateLimitHits ?? 0} hit(s)`
925
- ]
926
- }) : null,
927
- modelCooldowns.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
928
- className: "dsm-workbuddy-xdpool-account-modelcool",
929
- children: modelCooldowns.map((mc) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
930
- className: "dsm-workbuddy-xdpool-account-modelcool-chip",
931
- children: t?.("row.modelCooling", {
932
- model: mc.modelId,
933
- time: formatDateTime(mc.until)
934
- }) ?? `${mc.modelId} cooling to ${formatDateTime(mc.until)}`
935
- }, mc.modelId))
936
- }) : null
989
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
990
+ type: "button",
991
+ role: "switch",
992
+ "aria-checked": !isDisabled,
993
+ className: isDisabled ? "dsm-workbuddy-xdpool-account-toggle" : "dsm-workbuddy-xdpool-account-toggle dsm-workbuddy-xdpool-account-toggle-on",
994
+ title: t?.("row.accountToggleHint") ?? "Enable this account (uncheck to keep it out of the pool)",
995
+ disabled: accountBusyId === account.id,
996
+ onClick: () => {
997
+ onToggleDisabled(account.id, !isDisabled);
998
+ },
999
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsm-workbuddy-xdpool-account-toggle-dot" }), isDisabled ? t?.("row.accountOff") ?? "Disabled" : t?.("row.accountInRotation") ?? "Enabled"]
1000
+ })
937
1001
  ]
938
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountStats, {
939
- account,
940
- t,
941
- checkinBusy: checkinBusyId === account.id,
942
- onClaim: onClaimCheckin
1002
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1003
+ className: "dsm-workbuddy-xdpool-account-body",
1004
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1005
+ className: "dsm-workbuddy-xdpool-account-copy",
1006
+ children: [
1007
+ account.expiresAt !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1008
+ className: "dsm-workbuddy-xdpool-account-meta",
1009
+ children: t?.("row.tokenExpiry", { time: formatDateTime(account.expiresAt) }) ?? `token ${formatDateTime(account.expiresAt)}`
1010
+ }) : null,
1011
+ isCooling && cooldownUntil !== void 0 && !Number.isNaN(cooldownUntil) ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1012
+ className: "dsm-workbuddy-xdpool-account-meta",
1013
+ children: [
1014
+ t?.("row.cooldownUntil", { time: formatTime(cooldownUntil) }) ?? `until ${formatTime(cooldownUntil)}`,
1015
+ " · ",
1016
+ t?.("row.cooldownHits", { hits: account.rateLimitHits ?? 0 }) ?? `${account.rateLimitHits ?? 0} hit(s)`
1017
+ ]
1018
+ }) : null,
1019
+ modelCooldowns.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1020
+ className: "dsm-workbuddy-xdpool-account-modelcool",
1021
+ children: modelCooldowns.map((mc) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1022
+ className: "dsm-workbuddy-xdpool-account-modelcool-chip",
1023
+ children: t?.("row.modelCooling", {
1024
+ model: mc.modelId,
1025
+ time: formatDateTime(mc.until)
1026
+ }) ?? `${mc.modelId} cooling to ${formatDateTime(mc.until)}`
1027
+ }, mc.modelId))
1028
+ }) : null
1029
+ ]
1030
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountStats, {
1031
+ account,
1032
+ t,
1033
+ checkinBusy: checkinBusyId === account.id,
1034
+ onClaim: onClaimCheckin
1035
+ })]
943
1036
  })]
944
1037
  });
945
1038
  }
@@ -1185,7 +1278,12 @@ window.__ModuleLoader__.load({
1185
1278
  "row.accountsSummary": "{count} account(s) · {cooling} cooling",
1186
1279
  "row.ok": "Healthy — requests auto-rotate across accounts",
1187
1280
  "row.allCooling": "Every account is rate-limited right now; requests pause until a cooldown lifts.",
1188
- "row.accountNext": "Next up",
1281
+ "row.accountInRotation": "Enabled",
1282
+ "row.accountOff": "Disabled",
1283
+ "row.accountToggleHint": "Enable this account (uncheck to keep it out of the pool)",
1284
+ "row.accountToggleError": "Could not switch the account: {message}",
1285
+ "row.currentAccount": "In use now",
1286
+ "row.currentAccountDisabled": "switched off — the next request moves on",
1189
1287
  "row.cooling": "Cooling (rate-limited)",
1190
1288
  "row.cooldownHits": "{hits} hit(s)",
1191
1289
  "row.cooldownUntil": "until {time}",
@@ -1238,6 +1336,8 @@ window.__ModuleLoader__.load({
1238
1336
  "row.distPriorityHint": "Drain one account before moving to the next",
1239
1337
  "row.distRoundRobin": "Round-robin",
1240
1338
  "row.distRoundRobinHint": "Spread the spend evenly across accounts",
1339
+ "row.distBalanced": "Balanced",
1340
+ "row.distBalancedHint": "Draw from every account, favouring whichever has been idle longest",
1241
1341
  "row.accountsScanning": "Detecting…",
1242
1342
  "row.resetCooldowns": "Clear all cooldowns",
1243
1343
  "row.resetCooldownsBusy": "Clearing…",
@@ -1271,7 +1371,12 @@ window.__ModuleLoader__.load({
1271
1371
  "row.accountsSummary": "{count} 个账号 · {cooling} 个冷却中",
1272
1372
  "row.ok": "运行健康 —— 请求会在各账号间自动轮换",
1273
1373
  "row.allCooling": "当前所有账号都处于限流冷却,请求会暂停直到某个冷却结束。",
1274
- "row.accountNext": "下一个",
1374
+ "row.accountInRotation": "已启用",
1375
+ "row.accountOff": "已停用",
1376
+ "row.accountToggleHint": "启用该账号(取消勾选则不参与池子)",
1377
+ "row.accountToggleError": "切换账号失败:{message}",
1378
+ "row.currentAccount": "当前使用",
1379
+ "row.currentAccountDisabled": "已停用 —— 下次请求会换号",
1275
1380
  "row.cooling": "冷却中(被限流)",
1276
1381
  "row.cooldownHits": "触发 {hits} 次",
1277
1382
  "row.cooldownUntil": "至 {time}",
@@ -1324,6 +1429,8 @@ window.__ModuleLoader__.load({
1324
1429
  "row.distPriorityHint": "优先用完一个账号再换下一个",
1325
1430
  "row.distRoundRobin": "轮换模式",
1326
1431
  "row.distRoundRobinHint": "积分平均分摊到各账号",
1432
+ "row.distBalanced": "均衡",
1433
+ "row.distBalancedHint": "每个账号都参与抽取,闲置越久权重越高",
1327
1434
  "row.accountsScanning": "正在检测…",
1328
1435
  "row.resetCooldowns": "清除所有冷却",
1329
1436
  "row.resetCooldownsBusy": "正在清除…",
package/lib/index.d.ts CHANGED
@@ -228,7 +228,7 @@ export declare function workbuddyAccountId(credential: Pick<WorkBuddyCredential,
228
228
  /** Every directory the pool should scan, in probe order. */
229
229
  export declare function candidateAuthDirs(env?: NodeJS.ProcessEnv): string[];
230
230
  /** How the pool chooses which account serves the next request. */
231
- type AccountDistribution = 'priority' | 'round-robin';
231
+ type AccountDistribution = 'priority' | 'round-robin' | 'balanced';
232
232
  interface AccountPoolOptions {
233
233
  /** Logger for discovery and rotation events. */
234
234
  logger?: {
@@ -240,6 +240,8 @@ interface AccountPoolOptions {
240
240
  authDirs?: readonly string[];
241
241
  /** How long a rate-limited account stays out of rotation. */
242
242
  cooldownMs?: number;
243
+ /** How long an account rests after its credits run out (default 30 minutes). */
244
+ exhaustCooldownMs?: number;
243
245
  /** Upstream client used to refresh near-expiry tokens. */
244
246
  client?: TokenRefresher;
245
247
  /** Refresh this long before actual expiry; default five minutes. */
@@ -256,14 +258,16 @@ interface AccountPoolOptions {
256
258
  */
257
259
  distribution?: AccountDistribution;
258
260
  }
259
- /**
260
- * Read-only pool of every discovered WorkBuddy account, with rate-limit
261
- * cooldown and round-robin failover.
262
- */
263
261
  export declare class WorkBuddyAccountPool {
264
262
  private readonly logger;
265
263
  private authDirs;
266
264
  private cooldownMs;
265
+ /**
266
+ * How long an account stays out of rotation after the upstream reports its
267
+ * credits are spent. Credit packs reset on their own schedule rather than on a
268
+ * rate-limit window, so this is much longer than `cooldownMs`.
269
+ */
270
+ private exhaustCooldownMs;
267
271
  private readonly client;
268
272
  private readonly refreshMarginMs;
269
273
  private accounts;
@@ -272,6 +276,24 @@ export declare class WorkBuddyAccountPool {
272
276
  private cursor;
273
277
  private lastScanAtMs;
274
278
  private preferredId;
279
+ /**
280
+ * Account ids the user switched off on the card.
281
+ *
282
+ * Disabling is a user preference rather than a property of the credential:
283
+ * `scan()` rebuilds every account object from the auth files, so the set
284
+ * lives on the pool and is re-applied from settings after each scan.
285
+ */
286
+ private disabledIds;
287
+ /**
288
+ * Last time each account served a request, epoch ms. Drives the idle term
289
+ * of the priority-mode weighting below: an account that just served loses to
290
+ * one that has been idle, so a small pool stops hammering a single account.
291
+ *
292
+ * In-memory on purpose: it only biases the next pick, so a cold start that
293
+ * treats every account as idle is the right default. Not keyed by id lookup
294
+ * misses because a removed account simply disappears from the map on re-scan.
295
+ */
296
+ private lastUsedAt;
275
297
  private refreshInflight;
276
298
  constructor(options?: AccountPoolOptions);
277
299
  /**
@@ -282,7 +304,9 @@ export declare class WorkBuddyAccountPool {
282
304
  applyConfig(options: {
283
305
  authDirs?: readonly string[];
284
306
  cooldownMs?: number;
307
+ exhaustCooldownMs?: number;
285
308
  distribution?: AccountDistribution;
309
+ disabledAccountIds?: readonly string[];
286
310
  }): void;
287
311
  /** Rescan the auth directories and merge newly discovered accounts. */
288
312
  scan(): Promise<WorkBuddyAccount[]>;
@@ -298,6 +322,22 @@ export declare class WorkBuddyAccountPool {
298
322
  * model, e.g. CLI diagnostics).
299
323
  */
300
324
  private available;
325
+ /** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
326
+ private pickRoundRobin;
327
+ /**
328
+ * Priority mode: weighted random over the eligible accounts.
329
+ *
330
+ * The weight is an idle bonus — `1 + min(idleHours * perHour, max)` — so an
331
+ * account that has never served (or has been idle for a while) outranks one
332
+ * that just answered. Reference panel logic drops its success-rate term
333
+ * entirely because a lifetime error counter penalises an account forever;
334
+ * instantaneous health is already handled by cooldowns, which is why those
335
+ * accounts never reach this list.
336
+ *
337
+ * A pool with no idle history (fresh process) hashes to equal weights, which
338
+ * spreads the very first picks instead of always returning index 0.
339
+ */
340
+ private pickByWeight;
301
341
  /**
302
342
  * Pick the account to serve a request.
303
343
  *
@@ -323,6 +363,31 @@ export declare class WorkBuddyAccountPool {
323
363
  /** How the pool currently spreads requests. Shown on the card. */
324
364
  currentDistribution(): AccountDistribution;
325
365
  prefer(accountId: string | undefined): void;
366
+ /** Whether the user switched this account off on the card. */
367
+ isDisabled(accountId: string): boolean;
368
+ /** Every account id the user switched off, in discovery order. */
369
+ disabledIdsInOrder(): string[];
370
+ /**
371
+ * Record that an account actually served a request.
372
+ *
373
+ * Called by the shim once the upstream answers 200 — only then is the account
374
+ * the one the user is really being served by. `balanced` mode reads the same map
375
+ * for its idle weighting, so a request that failed over to another account must
376
+ * not count as used for the account that was merely tried.
377
+ */
378
+ noteServed(accountId: string): void;
379
+ /**
380
+ * The account that served the most recent request, if any.
381
+ *
382
+ * Distinct from "who would serve the next one": this is a record of what
383
+ * actually happened, which is what the card needs to answer "which account am
384
+ * I using right now?". Under `balanced` there is no deterministic next account
385
+ * at all, so a recorded fact is the only honest answer.
386
+ *
387
+ * Returns undefined before the first request of the process, and after every
388
+ * known account has been re-scanned away (a login swapped out under us).
389
+ */
390
+ lastServedId(): string | undefined;
326
391
  /** Best-effort refresh of one account after a session-dead upstream answer. */
327
392
  refreshAccount(accountId: string): Promise<void>;
328
393
  /**
@@ -332,6 +397,15 @@ export declare class WorkBuddyAccountPool {
332
397
  * endpoint never takes down a working session.
333
398
  */
334
399
  private ensureFresh;
400
+ /**
401
+ * Cool a whole account after the upstream reports its credits are spent.
402
+ *
403
+ * Credit exhaustion is an ACCOUNT condition, unlike a model rate limit: every
404
+ * model on that account is unusable until the quota resets, so this cools the
405
+ * account as a whole (no `modelId`) for the configured exhaustion window. The
406
+ * shim then rotates to a different account instead of failing the request.
407
+ */
408
+ penalizeExhausted(accountId: string): void;
335
409
  /**
336
410
  * Mark an account (or one of its models) rate-limited.
337
411
  *
@@ -572,6 +646,11 @@ interface PoolWebAccount {
572
646
  modelId: string;
573
647
  until: string;
574
648
  }>;
649
+ /**
650
+ * Whether the user switched this account off. A disabled account never
651
+ * serves a request, but it stays listed so the card can switch it back on.
652
+ */
653
+ disabled: boolean;
575
654
  rateLimitHits: number;
576
655
  /** ISO timestamp of the last successful use (best-effort pool bookkeeping). */
577
656
  lastUsedAt?: string;
@@ -657,7 +736,6 @@ interface PoolWebModel {
657
736
  /** Whether this model is currently enabled in the picker. */
658
737
  enabled: boolean;
659
738
  }
660
- /** The user's saved model selection, echoed back so the card can diff a draft. */
661
739
  interface PoolWebModelSelection {
662
740
  /** Absent = every model is enabled. */
663
741
  enabledModelIds?: readonly string[];
@@ -697,7 +775,7 @@ interface PoolWebStatus {
697
775
  */
698
776
  type PoolRegion = 'cn' | 'global';
699
777
  /** How the pool spreads requests across its accounts. */
700
- type PoolDistribution = 'priority' | 'round-robin';
778
+ type PoolDistribution = 'priority' | 'round-robin' | 'balanced';
701
779
  //#endregion
702
780
  //#region src/index.d.ts
703
781
  /** Stable Cordis plugin name. */
@@ -725,6 +803,13 @@ export interface Config {
725
803
  * spend evenly instead. Absent reads as `priority`.
726
804
  */
727
805
  distribution?: 'priority' | 'round-robin';
806
+ /**
807
+ * Account ids switched off on the card. A disabled account is never picked
808
+ * to serve a request, but it stays in the pool and on the card so it can be
809
+ * switched back on. Ids are the pool's stable per-credential keys, which
810
+ * survive re-scans (see WorkBuddyAccountPool.disabledIds).
811
+ */
812
+ disabledAccountIds?: string[];
728
813
  /**
729
814
  * Model ids enabled in the picker. Absent means "every model the catalog
730
815
  * advertises" — an unconfigured install should never present an empty model
package/lib/index.js CHANGED
@@ -774,10 +774,38 @@ function candidateAuthDirs(env = process.env) {
774
774
  * Read-only pool of every discovered WorkBuddy account, with rate-limit
775
775
  * cooldown and round-robin failover.
776
776
  */
777
+ /** Idle bonus per hour an account has been unused (reference-panel default). */
778
+ const IDLE_WEIGHT_PER_HOUR = .5;
779
+ /** Ceiling for the idle bonus, so an idle account cannot dominate forever. */
780
+ const IDLE_WEIGHT_MAX = 5;
781
+ /**
782
+ * Weight one account by how long it has been idle.
783
+ *
784
+ * The base of 1 keeps every eligible account in play: an account that served a
785
+ * moment ago still has a small chance, so a single unhealthy account cannot pin
786
+ * the pool to itself, and the weights never sum to zero.
787
+ *
788
+ * `lastUsedAt === undefined` means "never used in this process", which earns the
789
+ * full bonus: on a fresh start every account ties, and the weighted draw spreads
790
+ * the first requests instead of always picking the first entry.
791
+ */
792
+ function idleWeight(lastUsedAt, now) {
793
+ if (lastUsedAt === void 0) return 6;
794
+ const hours = (now - lastUsedAt) / 36e5;
795
+ return 1 + Math.min(Math.max(hours, 0) * IDLE_WEIGHT_PER_HOUR, IDLE_WEIGHT_MAX);
796
+ }
797
+ /** Default rest for an account whose credits ran out (packs reset on their own schedule). */
798
+ const EXHAUST_COOLDOWN_MS = 18e5;
777
799
  var WorkBuddyAccountPool = class {
778
800
  logger;
779
801
  authDirs;
780
802
  cooldownMs;
803
+ /**
804
+ * How long an account stays out of rotation after the upstream reports its
805
+ * credits are spent. Credit packs reset on their own schedule rather than on a
806
+ * rate-limit window, so this is much longer than `cooldownMs`.
807
+ */
808
+ exhaustCooldownMs;
781
809
  client;
782
810
  refreshMarginMs;
783
811
  accounts = [];
@@ -786,11 +814,30 @@ var WorkBuddyAccountPool = class {
786
814
  cursor = 0;
787
815
  lastScanAtMs = 0;
788
816
  preferredId;
817
+ /**
818
+ * Account ids the user switched off on the card.
819
+ *
820
+ * Disabling is a user preference rather than a property of the credential:
821
+ * `scan()` rebuilds every account object from the auth files, so the set
822
+ * lives on the pool and is re-applied from settings after each scan.
823
+ */
824
+ disabledIds = /* @__PURE__ */ new Set();
825
+ /**
826
+ * Last time each account served a request, epoch ms. Drives the idle term
827
+ * of the priority-mode weighting below: an account that just served loses to
828
+ * one that has been idle, so a small pool stops hammering a single account.
829
+ *
830
+ * In-memory on purpose: it only biases the next pick, so a cold start that
831
+ * treats every account as idle is the right default. Not keyed by id lookup
832
+ * misses because a removed account simply disappears from the map on re-scan.
833
+ */
834
+ lastUsedAt = /* @__PURE__ */ new Map();
789
835
  refreshInflight = /* @__PURE__ */ new Map();
790
836
  constructor(options = {}) {
791
837
  this.logger = options.logger;
792
838
  this.authDirs = options.authDirs ?? candidateAuthDirs();
793
839
  this.cooldownMs = options.cooldownMs ?? 6e4;
840
+ this.exhaustCooldownMs = options.exhaustCooldownMs ?? EXHAUST_COOLDOWN_MS;
794
841
  this.client = options.client;
795
842
  this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
796
843
  this.distribution = options.distribution ?? "priority";
@@ -803,7 +850,9 @@ var WorkBuddyAccountPool = class {
803
850
  applyConfig(options) {
804
851
  if (options.authDirs !== void 0 && options.authDirs.length > 0) this.authDirs = options.authDirs;
805
852
  if (options.cooldownMs !== void 0 && options.cooldownMs >= 1e3) this.cooldownMs = options.cooldownMs;
853
+ if (options.exhaustCooldownMs !== void 0 && options.exhaustCooldownMs >= 1e3) this.exhaustCooldownMs = options.exhaustCooldownMs;
806
854
  if (options.distribution !== void 0) this.distribution = options.distribution;
855
+ if (options.disabledAccountIds !== void 0) this.disabledIds = new Set(options.disabledAccountIds);
807
856
  }
808
857
  /** Rescan the auth directories and merge newly discovered accounts. */
809
858
  async scan() {
@@ -856,12 +905,47 @@ var WorkBuddyAccountPool = class {
856
905
  */
857
906
  available(now, modelId, region) {
858
907
  return this.accounts.filter((account) => {
908
+ if (this.disabledIds.has(account.id)) return false;
859
909
  if (account.cooldownUntilMs > now) return false;
860
910
  if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
861
911
  if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
862
912
  return true;
863
913
  });
864
914
  }
915
+ /** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
916
+ pickRoundRobin(pool) {
917
+ const index = this.cursor % pool.length;
918
+ const account = pool[index];
919
+ if (account === void 0) return void 0;
920
+ this.cursor = (index + 1) % pool.length;
921
+ return account;
922
+ }
923
+ /**
924
+ * Priority mode: weighted random over the eligible accounts.
925
+ *
926
+ * The weight is an idle bonus — `1 + min(idleHours * perHour, max)` — so an
927
+ * account that has never served (or has been idle for a while) outranks one
928
+ * that just answered. Reference panel logic drops its success-rate term
929
+ * entirely because a lifetime error counter penalises an account forever;
930
+ * instantaneous health is already handled by cooldowns, which is why those
931
+ * accounts never reach this list.
932
+ *
933
+ * A pool with no idle history (fresh process) hashes to equal weights, which
934
+ * spreads the very first picks instead of always returning index 0.
935
+ */
936
+ pickByWeight(pool) {
937
+ if (pool.length === 1) return pool[0];
938
+ const now = Date.now();
939
+ const weights = pool.map((account) => idleWeight(this.lastUsedAt.get(account.id), now));
940
+ const total = weights.reduce((sum, weight) => sum + weight, 0);
941
+ if (!Number.isFinite(total) || total <= 0) return pool[0];
942
+ let roll = Math.random() * total;
943
+ for (let index = 0; index < pool.length; index += 1) {
944
+ roll -= weights[index] ?? 0;
945
+ if (roll < 0) return pool[index];
946
+ }
947
+ return pool[pool.length - 1];
948
+ }
865
949
  /**
866
950
  * Pick the account to serve a request.
867
951
  *
@@ -891,16 +975,14 @@ var WorkBuddyAccountPool = class {
891
975
  }
892
976
  if (pool.length === 0) return void 0;
893
977
  if (this.preferredId !== void 0) {
894
- const preferredIndex = pool.findIndex((account) => account.id === this.preferredId);
895
- if (preferredIndex > 0) {
896
- const [preferred] = pool.splice(preferredIndex, 1);
897
- if (preferred !== void 0) pool = [preferred, ...pool];
978
+ const preferred = pool.find((account) => account.id === this.preferredId);
979
+ if (preferred !== void 0) {
980
+ await this.ensureFresh(preferred);
981
+ return preferred;
898
982
  }
899
983
  }
900
- const index = this.distribution === "round-robin" ? this.cursor % pool.length : 0;
901
- const account = pool[index];
984
+ const account = this.distribution === "round-robin" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
902
985
  if (account === void 0) return void 0;
903
- if (this.distribution === "round-robin") this.cursor = (index + 1) % pool.length;
904
986
  await this.ensureFresh(account);
905
987
  return account;
906
988
  }
@@ -912,6 +994,48 @@ var WorkBuddyAccountPool = class {
912
994
  prefer(accountId) {
913
995
  this.preferredId = accountId;
914
996
  }
997
+ /** Whether the user switched this account off on the card. */
998
+ isDisabled(accountId) {
999
+ return this.disabledIds.has(accountId);
1000
+ }
1001
+ /** Every account id the user switched off, in discovery order. */
1002
+ disabledIdsInOrder() {
1003
+ return this.accounts.filter((account) => this.disabledIds.has(account.id)).map((account) => account.id);
1004
+ }
1005
+ /**
1006
+ * Record that an account actually served a request.
1007
+ *
1008
+ * Called by the shim once the upstream answers 200 — only then is the account
1009
+ * the one the user is really being served by. `balanced` mode reads the same map
1010
+ * for its idle weighting, so a request that failed over to another account must
1011
+ * not count as used for the account that was merely tried.
1012
+ */
1013
+ noteServed(accountId) {
1014
+ if (!this.accounts.some((account) => account.id === accountId)) return;
1015
+ this.lastUsedAt.set(accountId, Date.now());
1016
+ }
1017
+ /**
1018
+ * The account that served the most recent request, if any.
1019
+ *
1020
+ * Distinct from "who would serve the next one": this is a record of what
1021
+ * actually happened, which is what the card needs to answer "which account am
1022
+ * I using right now?". Under `balanced` there is no deterministic next account
1023
+ * at all, so a recorded fact is the only honest answer.
1024
+ *
1025
+ * Returns undefined before the first request of the process, and after every
1026
+ * known account has been re-scanned away (a login swapped out under us).
1027
+ */
1028
+ lastServedId() {
1029
+ let newest;
1030
+ for (const [id, at] of this.lastUsedAt) {
1031
+ if (!this.accounts.some((account) => account.id === id)) continue;
1032
+ if (newest === void 0 || at > newest.at) newest = {
1033
+ id,
1034
+ at
1035
+ };
1036
+ }
1037
+ return newest?.id;
1038
+ }
915
1039
  /** Best-effort refresh of one account after a session-dead upstream answer. */
916
1040
  async refreshAccount(accountId) {
917
1041
  const account = this.accounts.find((item) => item.id === accountId);
@@ -962,6 +1086,23 @@ var WorkBuddyAccountPool = class {
962
1086
  }
963
1087
  }
964
1088
  /**
1089
+ * Cool a whole account after the upstream reports its credits are spent.
1090
+ *
1091
+ * Credit exhaustion is an ACCOUNT condition, unlike a model rate limit: every
1092
+ * model on that account is unusable until the quota resets, so this cools the
1093
+ * account as a whole (no `modelId`) for the configured exhaustion window. The
1094
+ * shim then rotates to a different account instead of failing the request.
1095
+ */
1096
+ penalizeExhausted(accountId) {
1097
+ const until = Date.now() + this.exhaustCooldownMs;
1098
+ this.penalize(accountId, until);
1099
+ this.logger?.warn(`
1100
+ dsh-workbuddy-xdpool: account credits exhausted; cooling the whole account until
1101
+
1102
+ ${new Date(until).toISOString()}
1103
+ `);
1104
+ }
1105
+ /**
965
1106
  * Mark an account (or one of its models) rate-limited.
966
1107
  *
967
1108
  * With `modelId`, only that model on the account is cooled — the account's
@@ -1333,10 +1474,10 @@ function createWorkBuddyAdapter(options) {
1333
1474
  streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
1334
1475
  retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-xdpool retryPolicy"),
1335
1476
  configuredMaxTokens: /* @__PURE__ */ new Map(),
1336
- modelErrors: /* @__PURE__ */ new Map(),
1337
1477
  ...REQUEST_IMAGE_BUDGETS,
1338
1478
  piProvider: provider
1339
1479
  };
1480
+ Object.assign(profile, { modelErrors: /* @__PURE__ */ new Map() });
1340
1481
  let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
1341
1482
  return {
1342
1483
  providerId,
@@ -1573,6 +1714,7 @@ function createWorkBuddyShim(options) {
1573
1714
  const result = await client.chatStream(account.credential, prepared, controller.signal);
1574
1715
  if (result.ok) {
1575
1716
  logger?.info?.(`dsh-workbuddy-xdpool: served by ${account.label}`);
1717
+ pool.noteServed(account.id);
1576
1718
  res.writeHead(200, {
1577
1719
  "Content-Type": "text/event-stream",
1578
1720
  "Cache-Control": "no-cache",
@@ -1601,6 +1743,11 @@ function createWorkBuddyShim(options) {
1601
1743
  await pool.refreshAccount(account.id);
1602
1744
  continue;
1603
1745
  }
1746
+ if (result.kind === "hard_credit") {
1747
+ pool.penalizeExhausted(account.id);
1748
+ logger?.warn(`dsh-workbuddy-xdpool: ${account.label} has no credits left (attempt ${attempt + 1}/${maxAttempts}); rotating`);
1749
+ continue;
1750
+ }
1604
1751
  if (result.kind !== "soft_rate") break;
1605
1752
  exhaustedByRateLimit = true;
1606
1753
  pool.penalize(account.id, parseRateLimitReset(result.message), modelId);
@@ -1739,6 +1886,8 @@ const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/cooldowns/reset"
1739
1886
  const POOL_CHECKIN_PATH = "/plugins/dsh-workbuddy-xdpool/checkin";
1740
1887
  /** Plugin-owned model-selection save endpoint (writes the settings section). */
1741
1888
  const POOL_MODELS_SAVE_PATH = "/plugins/dsh-workbuddy-xdpool/models/save";
1889
+ /** Switch one account in or out of the pool (card toggle). */
1890
+ const POOL_ACCOUNT_DISABLE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/disabled";
1742
1891
  //#endregion
1743
1892
  //#region src/web-status.ts
1744
1893
  /** Redact token-like content before it crosses to the browser. */
@@ -1830,7 +1979,25 @@ function parseSelection(body) {
1830
1979
  }
1831
1980
  return out;
1832
1981
  }
1833
- function toWebAccount(account) {
1982
+ /**
1983
+ * Validate the account-toggle body. Returns undefined for anything malformed so
1984
+ * the route answers 400 instead of writing a half-applied switch.
1985
+ *
1986
+ * The id must name a currently known account: accepting an arbitrary string
1987
+ * would let a stale tab (or a renamed credential) leave orphan ids in the
1988
+ * settings file that no card can ever switch back off.
1989
+ */
1990
+ function parseAccountToggle(body, known) {
1991
+ const accountId = typeof body["accountId"] === "string" ? body["accountId"].trim() : "";
1992
+ const disabled = body["disabled"];
1993
+ if (accountId === "" || typeof disabled !== "boolean") return void 0;
1994
+ if (!known(accountId)) return void 0;
1995
+ return {
1996
+ accountId,
1997
+ disabled
1998
+ };
1999
+ }
2000
+ function toWebAccount(account, disabled) {
1834
2001
  const now = Date.now();
1835
2002
  const cooling = account.cooldownUntilMs > now;
1836
2003
  const modelCooldowns = Object.entries(account.modelCooldowns).filter(([, until]) => until > now).sort((a, b) => a[1] - b[1]).map(([modelId, until]) => ({
@@ -1846,6 +2013,7 @@ function toWebAccount(account) {
1846
2013
  cooling,
1847
2014
  ...cooling ? { cooldownUntil: new Date(account.cooldownUntilMs).toISOString() } : {},
1848
2015
  ...modelCooldowns.length === 0 ? {} : { modelCooldowns },
2016
+ disabled,
1849
2017
  rateLimitHits: account.rateLimitHits
1850
2018
  };
1851
2019
  }
@@ -1878,7 +2046,7 @@ async function poolWebStatus(deps, region = "cn") {
1878
2046
  const rows = [];
1879
2047
  const now = Date.now();
1880
2048
  for (const account of accounts) {
1881
- const row = toWebAccount(account);
2049
+ const row = toWebAccount(account, deps.pool.isDisabled(account.id));
1882
2050
  if (!row.cooling) {
1883
2051
  try {
1884
2052
  const credits = await deps.client.fetchCredits(account.credential);
@@ -1911,7 +2079,8 @@ async function poolWebStatus(deps, region = "cn") {
1911
2079
  rows.push(row);
1912
2080
  }
1913
2081
  const cooling = rows.filter((row) => row.cooling).length;
1914
- const firstUsable = accounts.find((account) => account.cooldownUntilMs <= now);
2082
+ const lastServed = deps.pool.lastServedId();
2083
+ const firstUsable = lastServed !== void 0 ? accounts.find((account) => account.id === lastServed) : accounts.find((account) => account.cooldownUntilMs <= now);
1915
2084
  let shim;
1916
2085
  if (deps.shim === void 0) shim = { running: false };
1917
2086
  else try {
@@ -2040,8 +2209,31 @@ function registerPoolStatusRoute(ctx, deps) {
2040
2209
  }
2041
2210
  }
2042
2211
  });
2212
+ const disposeAccountDisable = ctx.webServer.register({
2213
+ kind: "exact",
2214
+ path: POOL_ACCOUNT_DISABLE_PATH,
2215
+ handler: async (req, res) => {
2216
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
2217
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
2218
+ if (deps.setAccountDisabled === void 0) return json(res, 503, { error: "settings service unavailable; the account switch cannot be saved" });
2219
+ try {
2220
+ const body = await readJsonBody(req);
2221
+ const known = new Set(deps.pool.list().map((account) => account.id));
2222
+ const toggle = parseAccountToggle(body, (id) => known.has(id));
2223
+ if (toggle === void 0) return json(res, 400, { error: "invalid account toggle payload" });
2224
+ await deps.setAccountDisabled(toggle.accountId, toggle.disabled);
2225
+ json(res, 200, {
2226
+ ok: true,
2227
+ ...toggle
2228
+ });
2229
+ } catch (error) {
2230
+ json(res, 500, { error: safeMessage(error) });
2231
+ }
2232
+ }
2233
+ });
2043
2234
  return () => {
2044
2235
  disposeCheckin();
2236
+ disposeAccountDisable();
2045
2237
  disposeModelsSave();
2046
2238
  disposeReset();
2047
2239
  disposeRescan();
@@ -2100,6 +2292,7 @@ const Config = z.object({
2100
2292
  authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
2101
2293
  cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
2102
2294
  distribution: z.union(["priority", "round-robin"]).default("priority").description("How requests are spread: priority (drain one) or round-robin"),
2295
+ disabledAccountIds: z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)"),
2103
2296
  enabledModelIds: z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet"),
2104
2297
  imageModelIds: z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet"),
2105
2298
  contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Legacy shared context budgets; used by a region with no per-region selection yet"),
@@ -2169,11 +2362,12 @@ function apply(ctx, config = {}) {
2169
2362
  }
2170
2363
  };
2171
2364
  const applyConfigFromSource = () => {
2172
- const { authFile, cooldownMs, distribution, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal } = current();
2365
+ const { authFile, cooldownMs, distribution, disabledAccountIds, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal } = current();
2173
2366
  core.pool.applyConfig({
2174
2367
  ...authFile === void 0 ? {} : { authDirs: [dirname(authFile)] },
2175
2368
  ...cooldownMs === void 0 ? {} : { cooldownMs },
2176
- distribution: distribution ?? "priority"
2369
+ distribution: distribution ?? "priority",
2370
+ ...disabledAccountIds === void 0 ? {} : { disabledAccountIds }
2177
2371
  });
2178
2372
  const legacySelection = {
2179
2373
  ...enabledModelIds === void 0 ? {} : { enabledModelIds },
@@ -2191,14 +2385,30 @@ function apply(ctx, config = {}) {
2191
2385
  * the three model-selection keys, so the settings file cannot be steered from
2192
2386
  * the browser; the catalog re-reads through `onChange` either way.
2193
2387
  */
2388
+ /**
2389
+ * Write one key of the plugin's own settings section. Only ever called with the
2390
+ * model-selection keys, the distribution, and the disabled-account list, so the
2391
+ * settings file cannot be steered from the browser; the catalogs and the pool
2392
+ * re-read through `onChange` either way.
2393
+ *
2394
+ * `set` returns a promise, so a rejection must be caught explicitly: a bare
2395
+ * `void write.call(...)` swallows it, and the card then looks like it saved
2396
+ * while the value never reached the settings file.
2397
+ */
2194
2398
  const setSetting = (key, value) => {
2195
2399
  if (value === void 0) return;
2196
2400
  const write = settingsService?.set;
2197
- if (write === void 0) return;
2401
+ if (write === void 0) {
2402
+ ctx.logger.warn?.(`dsh-workbuddy-xdpool: no settings writer; ` + key + ` was not saved`);
2403
+ return;
2404
+ }
2198
2405
  try {
2199
- write.call(settingsService, key, value);
2406
+ const result = write.call(settingsService, key, value);
2407
+ if (result !== void 0 && typeof result.then === "function") result.catch((error) => {
2408
+ ctx.logger.warn?.(`dsh-workbuddy-xdpool: failed to persist ` + key, error);
2409
+ });
2200
2410
  } catch (error) {
2201
- ctx.logger.warn?.(`dsh-workbuddy-xdpool: failed to persist ${key}`, error);
2411
+ ctx.logger.warn?.(`dsh-workbuddy-xdpool: failed to persist ` + key, error);
2202
2412
  }
2203
2413
  };
2204
2414
  const shims = {
@@ -2246,6 +2456,11 @@ function apply(ctx, config = {}) {
2246
2456
  ...selection.imageModelIds === void 0 ? {} : { imageModelIds: [...selection.imageModelIds] },
2247
2457
  ...selection.contextBudgets === void 0 ? {} : { contextBudgets: { ...selection.contextBudgets } }
2248
2458
  });
2459
+ },
2460
+ setAccountDisabled: (accountId, disabled) => {
2461
+ const currentIds = current().disabledAccountIds ?? [];
2462
+ const next = disabled ? currentIds.includes(accountId) ? currentIds : [...currentIds, accountId] : currentIds.filter((id) => id !== accountId);
2463
+ setSetting("disabledAccountIds", next);
2249
2464
  }
2250
2465
  }));
2251
2466
  api = {
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": "0.4.5",
5
+ "version": "1.0.1",
6
6
  "license": "MIT",
7
7
  "author": "XDTrees",
8
8
  "homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",