dsh-workbuddy-xdpool 0.4.5 → 1.0.0

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,37 @@
4
4
 
5
5
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
6
6
 
7
+ ## 1.0.0 (2026-09-21)
8
+
9
+ 这一版把「账号」和「用号方式」都交给你控制。
10
+
11
+ ### 一、可以停用不想用的账号了
12
+
13
+ 账号列表里每个账号右侧新增一个开关,关掉它这个账号就**不再参与请求**。
14
+
15
+ - 关掉的账号仍然显示在列表里(变灰),随时可以再打开
16
+ - 设置会被保存,**重启 DSH 后依然有效**
17
+ - 只影响路由,不会删除或改动你的登录凭据
18
+
19
+ ### 二、新增「均衡」用号模式
20
+
21
+ 原来的两种方式都保留,另加一种:
22
+
23
+ | 模式 | 行为 |
24
+ | --- | --- |
25
+ | **优先**(默认,行为不变) | 一直用同一个账号,直到它被限流才换下一个 |
26
+ | **均衡**(新增) | 每个可用账号都参与抽取,**闲置越久权重越高** |
27
+ | **轮换** | 严格轮流,把额度摊平 |
28
+
29
+ **为什么需要「均衡」**:默认的「优先」会死磕第一个账号,把它打到限流才换 —— 如果你有 5 个号,其余 4 个基本闲着。切到「均衡」后请求会自然分散到各个账号,**更不容易撞限流**,可用时间更长。
30
+
31
+ 在卡片顶部「账号使用方式」里切换,即时生效。
32
+
33
+ ### 其他
34
+
35
+ - 显式选定的账号(如卡片上「下一个」标记的那个)在三种模式下**都会被优先使用**
36
+ - 处于冷却中的账号在「均衡」模式下**一定不会被选中**,冷却结束自动回到抽取池
37
+
7
38
  ## 0.4.5 (2026-09-21)
8
39
 
9
40
  本版**功能与 0.4.4 完全相同**,只是换了一条发布通道。
package/lib/bin.js CHANGED
@@ -758,6 +758,26 @@ 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
+ }
761
781
  var WorkBuddyAccountPool = class {
762
782
  logger;
763
783
  authDirs;
@@ -770,6 +790,24 @@ var WorkBuddyAccountPool = class {
770
790
  cursor = 0;
771
791
  lastScanAtMs = 0;
772
792
  preferredId;
793
+ /**
794
+ * Account ids the user switched off on the card.
795
+ *
796
+ * Disabling is a user preference rather than a property of the credential:
797
+ * `scan()` rebuilds every account object from the auth files, so the set
798
+ * lives on the pool and is re-applied from settings after each scan.
799
+ */
800
+ disabledIds = /* @__PURE__ */ new Set();
801
+ /**
802
+ * Last time each account served a request, epoch ms. Drives the idle term
803
+ * of the priority-mode weighting below: an account that just served loses to
804
+ * one that has been idle, so a small pool stops hammering a single account.
805
+ *
806
+ * In-memory on purpose: it only biases the next pick, so a cold start that
807
+ * treats every account as idle is the right default. Not keyed by id lookup
808
+ * misses because a removed account simply disappears from the map on re-scan.
809
+ */
810
+ lastUsedAt = /* @__PURE__ */ new Map();
773
811
  refreshInflight = /* @__PURE__ */ new Map();
774
812
  constructor(options = {}) {
775
813
  this.logger = options.logger;
@@ -788,6 +826,7 @@ var WorkBuddyAccountPool = class {
788
826
  if (options.authDirs !== void 0 && options.authDirs.length > 0) this.authDirs = options.authDirs;
789
827
  if (options.cooldownMs !== void 0 && options.cooldownMs >= 1e3) this.cooldownMs = options.cooldownMs;
790
828
  if (options.distribution !== void 0) this.distribution = options.distribution;
829
+ if (options.disabledAccountIds !== void 0) this.disabledIds = new Set(options.disabledAccountIds);
791
830
  }
792
831
  /** Rescan the auth directories and merge newly discovered accounts. */
793
832
  async scan() {
@@ -840,12 +879,47 @@ var WorkBuddyAccountPool = class {
840
879
  */
841
880
  available(now, modelId, region) {
842
881
  return this.accounts.filter((account) => {
882
+ if (this.disabledIds.has(account.id)) return false;
843
883
  if (account.cooldownUntilMs > now) return false;
844
884
  if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
845
885
  if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
846
886
  return true;
847
887
  });
848
888
  }
889
+ /** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
890
+ pickRoundRobin(pool) {
891
+ const index = this.cursor % pool.length;
892
+ const account = pool[index];
893
+ if (account === void 0) return void 0;
894
+ this.cursor = (index + 1) % pool.length;
895
+ return account;
896
+ }
897
+ /**
898
+ * Priority mode: weighted random over the eligible accounts.
899
+ *
900
+ * The weight is an idle bonus — `1 + min(idleHours * perHour, max)` — so an
901
+ * account that has never served (or has been idle for a while) outranks one
902
+ * that just answered. Reference panel logic drops its success-rate term
903
+ * entirely because a lifetime error counter penalises an account forever;
904
+ * instantaneous health is already handled by cooldowns, which is why those
905
+ * accounts never reach this list.
906
+ *
907
+ * A pool with no idle history (fresh process) hashes to equal weights, which
908
+ * spreads the very first picks instead of always returning index 0.
909
+ */
910
+ pickByWeight(pool) {
911
+ if (pool.length === 1) return pool[0];
912
+ const now = Date.now();
913
+ const weights = pool.map((account) => idleWeight(this.lastUsedAt.get(account.id), now));
914
+ const total = weights.reduce((sum, weight) => sum + weight, 0);
915
+ if (!Number.isFinite(total) || total <= 0) return pool[0];
916
+ let roll = Math.random() * total;
917
+ for (let index = 0; index < pool.length; index += 1) {
918
+ roll -= weights[index] ?? 0;
919
+ if (roll < 0) return pool[index];
920
+ }
921
+ return pool[pool.length - 1];
922
+ }
849
923
  /**
850
924
  * Pick the account to serve a request.
851
925
  *
@@ -875,16 +949,16 @@ var WorkBuddyAccountPool = class {
875
949
  }
876
950
  if (pool.length === 0) return void 0;
877
951
  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];
952
+ const preferred = pool.find((account) => account.id === this.preferredId);
953
+ if (preferred !== void 0) {
954
+ this.lastUsedAt.set(preferred.id, Date.now());
955
+ await this.ensureFresh(preferred);
956
+ return preferred;
882
957
  }
883
958
  }
884
- const index = this.distribution === "round-robin" ? this.cursor % pool.length : 0;
885
- const account = pool[index];
959
+ const account = this.distribution === "round-robin" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
886
960
  if (account === void 0) return void 0;
887
- if (this.distribution === "round-robin") this.cursor = (index + 1) % pool.length;
961
+ this.lastUsedAt.set(account.id, Date.now());
888
962
  await this.ensureFresh(account);
889
963
  return account;
890
964
  }
@@ -896,6 +970,14 @@ var WorkBuddyAccountPool = class {
896
970
  prefer(accountId) {
897
971
  this.preferredId = accountId;
898
972
  }
973
+ /** Whether the user switched this account off on the card. */
974
+ isDisabled(accountId) {
975
+ return this.disabledIds.has(accountId);
976
+ }
977
+ /** Every account id the user switched off, in discovery order. */
978
+ disabledIdsInOrder() {
979
+ return this.accounts.filter((account) => this.disabledIds.has(account.id)).map((account) => account.id);
980
+ }
899
981
  /** Best-effort refresh of one account after a session-dead upstream answer. */
900
982
  async refreshAccount(accountId) {
901
983
  const account = this.accounts.find((item) => item.id === accountId);
@@ -1173,6 +1255,7 @@ z.object({
1173
1255
  authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
1174
1256
  cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
1175
1257
  distribution: z.union(["priority", "round-robin"]).default("priority").description("How requests are spread: priority (drain one) or round-robin"),
1258
+ disabledAccountIds: z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)"),
1176
1259
  enabledModelIds: z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet"),
1177
1260
  imageModelIds: z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet"),
1178
1261
  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
@@ -28,6 +28,8 @@ window.__ModuleLoader__.load({
28
28
  const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/cooldowns/reset";
29
29
  /** Plugin-owned daily check-in action endpoint (claim today's reward). */
30
30
  const POOL_CHECKIN_PATH = "/plugins/dsh-workbuddy-xdpool/checkin";
31
+ /** Switch one account in or out of the pool (card toggle). */
32
+ const POOL_ACCOUNT_DISABLE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/disabled";
31
33
  //#endregion
32
34
  //#region src/client/icon.ts
33
35
  /**
@@ -133,6 +135,13 @@ window.__ModuleLoader__.load({
133
135
  .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
136
  .dsm-workbuddy-xdpool-account-copy{display:flex;flex-direction:column;gap:3px;min-width:0}
135
137
  .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}
138
+ .dsm-workbuddy-xdpool-account-head{display:flex;align-items:center;gap:8px;min-width:0}
139
+ /* Account switched off on the card: still listed (so it can be turned back on) but visually muted. */
140
+ .dsm-workbuddy-xdpool-account-off{opacity:.55}
141
+ /* Small pill switch: "in rotation" vs "off". Native checkbox styled by the label. */
142
+ .dsm-workbuddy-xdpool-account-toggle{display:inline-flex;align-items:center;gap:5px;flex:none;cursor:pointer;color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:11px;line-height:16px;white-space:nowrap}
143
+ .dsm-workbuddy-xdpool-account-toggle input{margin:0;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
144
+ .dsm-workbuddy-xdpool-account-toggle:hover{color:var(--dsw-alias-label-primary,#e6e6e6)}
136
145
  .dsm-workbuddy-xdpool-account-tags{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
137
146
  .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
147
  .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)}
@@ -374,6 +383,8 @@ window.__ModuleLoader__.load({
374
383
  const [flash, setFlash] = (0, react.useState)(void 0);
375
384
  /** Account id whose daily claim is currently in flight. */
376
385
  const [checkinBusyId, setCheckinBusyId] = (0, react.useState)(void 0);
386
+ /** Account id whose enable/disable switch is in flight, if any. */
387
+ const [accountBusyId, setAccountBusyId] = (0, react.useState)(void 0);
377
388
  /**
378
389
  * Draft model selection. `undefined` means "no local edits"; once a checkbox
379
390
  * is touched the draft takes over and is what the Save button posts. Discard
@@ -509,6 +520,41 @@ window.__ModuleLoader__.load({
509
520
  if (mounted.current) setCheckinBusyId(void 0);
510
521
  }
511
522
  };
523
+ /**
524
+ * Switch one account in or out of the pool.
525
+ *
526
+ * The id travels in the body and the host validates it against the accounts it
527
+ * really knows, so a stale tab cannot write an orphan id. Disabling only stops
528
+ * the account from being picked — it stays listed so it can be turned back on —
529
+ * and the change is saved through the settings section, so it survives a
530
+ * restart and is re-applied after every re-scan.
531
+ */
532
+ const toggleAccountDisabled = async (accountId, disabled) => {
533
+ setAccountBusyId(accountId);
534
+ setFlash(void 0);
535
+ try {
536
+ const response = await fetch(POOL_ACCOUNT_DISABLE_PATH, {
537
+ method: "POST",
538
+ headers: {
539
+ accept: "application/json",
540
+ "content-type": "application/json"
541
+ },
542
+ credentials: "same-origin",
543
+ body: JSON.stringify({
544
+ accountId,
545
+ disabled
546
+ })
547
+ });
548
+ const body = await response.json().catch(() => void 0);
549
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`);
550
+ await refresh(activeRegion);
551
+ } catch (cause) {
552
+ const message = cause instanceof Error ? cause.message : String(cause);
553
+ if (mounted.current) setError(t?.("row.accountToggleError", { message }) ?? `Could not switch the account: ${message}`);
554
+ } finally {
555
+ if (mounted.current) setAccountBusyId(void 0);
556
+ }
557
+ };
512
558
  /** Keep the draft in step with the server copy while nothing is dirty. */
513
559
  const modelDraft = draft ?? (status === void 0 ? {} : draftFromStatus(status));
514
560
  /** Model edits need a writable settings scope; otherwise the rows are read-only. */
@@ -722,17 +768,21 @@ window.__ModuleLoader__.load({
722
768
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
723
769
  className: "dsm-workbuddy-xdpool-dist-title",
724
770
  children: t?.("row.distTitle") ?? "Account usage"
725
- }), ["priority", "round-robin"].map((option) => {
771
+ }), [
772
+ "priority",
773
+ "balanced",
774
+ "round-robin"
775
+ ].map((option) => {
726
776
  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") ?? "";
777
+ const label = option === "priority" ? t?.("row.distPriority") ?? "Priority" : option === "balanced" ? t?.("row.distBalanced") ?? "Balanced" : t?.("row.distRoundRobin") ?? "Round-robin";
778
+ const hint = option === "priority" ? t?.("row.distPriorityHint") ?? "" : option === "balanced" ? t?.("row.distBalancedHint") ?? "" : t?.("row.distRoundRobinHint") ?? "";
729
779
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
730
780
  type: "button",
731
781
  role: "radio",
732
782
  "aria-checked": active,
733
783
  title: hint,
734
784
  disabled: !modelsEditable,
735
- className: `dsm-workbuddy-xdpool-dist-option${active ? " dsm-workbuddy-xdpool-dist-option-active" : ""}`,
785
+ className: "dsm-workbuddy-xdpool-dist-option" + (active ? " dsm-workbuddy-xdpool-dist-option-active" : ""),
736
786
  onClick: () => {
737
787
  setDistribution(option);
738
788
  },
@@ -820,6 +870,10 @@ window.__ModuleLoader__.load({
820
870
  onClaimCheckin: (accountId) => {
821
871
  claimCheckin(accountId);
822
872
  },
873
+ onToggleDisabled: (accountId, disabled) => {
874
+ toggleAccountDisabled(accountId, disabled);
875
+ },
876
+ ...accountBusyId === void 0 ? {} : { accountBusyId },
823
877
  t
824
878
  }, account.id))]
825
879
  }) : null,
@@ -880,8 +934,9 @@ window.__ModuleLoader__.load({
880
934
  });
881
935
  }
882
936
  /** One account block: label + status tag + meta + optional credit panels. */
883
- function AccountBlock({ account, activeAccountId, t, checkinBusyId, onClaimCheckin }) {
937
+ function AccountBlock({ account, activeAccountId, t, checkinBusyId, onClaimCheckin, accountBusyId, onToggleDisabled }) {
884
938
  const isActive = account.id === activeAccountId;
939
+ const isDisabled = account.disabled === true;
885
940
  const isCooling = account.cooling === true;
886
941
  const cooldownUntil = account.cooldownUntil !== void 0 ? Date.parse(account.cooldownUntil) : void 0;
887
942
  const modelCooldowns = account.modelCooldowns ?? [];
@@ -893,13 +948,27 @@ window.__ModuleLoader__.load({
893
948
  cls: "dsm-workbuddy-xdpool-account-tag dsm-workbuddy-xdpool-account-tag-cooling"
894
949
  } : null;
895
950
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
896
- className: "dsm-workbuddy-xdpool-account",
951
+ className: isDisabled ? "dsm-workbuddy-xdpool-account dsm-workbuddy-xdpool-account-off" : "dsm-workbuddy-xdpool-account",
897
952
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
898
953
  className: "dsm-workbuddy-xdpool-account-copy",
899
954
  children: [
900
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
901
- className: "dsm-workbuddy-xdpool-account-label",
902
- children: account.label
955
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
956
+ className: "dsm-workbuddy-xdpool-account-head",
957
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
958
+ className: "dsm-workbuddy-xdpool-account-label",
959
+ children: account.label
960
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
961
+ className: "dsm-workbuddy-xdpool-account-toggle",
962
+ title: t?.("row.accountToggleHint") ?? "Include this account in the pool",
963
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
964
+ type: "checkbox",
965
+ checked: !isDisabled,
966
+ disabled: accountBusyId === account.id,
967
+ onChange: (event) => {
968
+ onToggleDisabled(account.id, !event.target.checked);
969
+ }
970
+ }), isDisabled ? t?.("row.accountOff") ?? "Off" : t?.("row.accountInRotation") ?? "In rotation"]
971
+ })]
903
972
  }),
904
973
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
905
974
  className: "dsm-workbuddy-xdpool-account-tags",
@@ -1186,6 +1255,10 @@ window.__ModuleLoader__.load({
1186
1255
  "row.ok": "Healthy — requests auto-rotate across accounts",
1187
1256
  "row.allCooling": "Every account is rate-limited right now; requests pause until a cooldown lifts.",
1188
1257
  "row.accountNext": "Next up",
1258
+ "row.accountInRotation": "In rotation",
1259
+ "row.accountOff": "Off",
1260
+ "row.accountToggleHint": "Include this account in the pool",
1261
+ "row.accountToggleError": "Could not switch the account: {message}",
1189
1262
  "row.cooling": "Cooling (rate-limited)",
1190
1263
  "row.cooldownHits": "{hits} hit(s)",
1191
1264
  "row.cooldownUntil": "until {time}",
@@ -1238,6 +1311,8 @@ window.__ModuleLoader__.load({
1238
1311
  "row.distPriorityHint": "Drain one account before moving to the next",
1239
1312
  "row.distRoundRobin": "Round-robin",
1240
1313
  "row.distRoundRobinHint": "Spread the spend evenly across accounts",
1314
+ "row.distBalanced": "Balanced",
1315
+ "row.distBalancedHint": "Draw from every account, favouring whichever has been idle longest",
1241
1316
  "row.accountsScanning": "Detecting…",
1242
1317
  "row.resetCooldowns": "Clear all cooldowns",
1243
1318
  "row.resetCooldownsBusy": "Clearing…",
@@ -1272,6 +1347,10 @@ window.__ModuleLoader__.load({
1272
1347
  "row.ok": "运行健康 —— 请求会在各账号间自动轮换",
1273
1348
  "row.allCooling": "当前所有账号都处于限流冷却,请求会暂停直到某个冷却结束。",
1274
1349
  "row.accountNext": "下一个",
1350
+ "row.accountInRotation": "参与轮换",
1351
+ "row.accountOff": "已停用",
1352
+ "row.accountToggleHint": "把这个账号计入池子",
1353
+ "row.accountToggleError": "切换账号失败:{message}",
1275
1354
  "row.cooling": "冷却中(被限流)",
1276
1355
  "row.cooldownHits": "触发 {hits} 次",
1277
1356
  "row.cooldownUntil": "至 {time}",
@@ -1324,6 +1403,8 @@ window.__ModuleLoader__.load({
1324
1403
  "row.distPriorityHint": "优先用完一个账号再换下一个",
1325
1404
  "row.distRoundRobin": "轮换模式",
1326
1405
  "row.distRoundRobinHint": "积分平均分摊到各账号",
1406
+ "row.distBalanced": "均衡",
1407
+ "row.distBalancedHint": "每个账号都参与抽取,闲置越久权重越高",
1327
1408
  "row.accountsScanning": "正在检测…",
1328
1409
  "row.resetCooldowns": "清除所有冷却",
1329
1410
  "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?: {
@@ -256,10 +256,6 @@ interface AccountPoolOptions {
256
256
  */
257
257
  distribution?: AccountDistribution;
258
258
  }
259
- /**
260
- * Read-only pool of every discovered WorkBuddy account, with rate-limit
261
- * cooldown and round-robin failover.
262
- */
263
259
  export declare class WorkBuddyAccountPool {
264
260
  private readonly logger;
265
261
  private authDirs;
@@ -272,6 +268,24 @@ export declare class WorkBuddyAccountPool {
272
268
  private cursor;
273
269
  private lastScanAtMs;
274
270
  private preferredId;
271
+ /**
272
+ * Account ids the user switched off on the card.
273
+ *
274
+ * Disabling is a user preference rather than a property of the credential:
275
+ * `scan()` rebuilds every account object from the auth files, so the set
276
+ * lives on the pool and is re-applied from settings after each scan.
277
+ */
278
+ private disabledIds;
279
+ /**
280
+ * Last time each account served a request, epoch ms. Drives the idle term
281
+ * of the priority-mode weighting below: an account that just served loses to
282
+ * one that has been idle, so a small pool stops hammering a single account.
283
+ *
284
+ * In-memory on purpose: it only biases the next pick, so a cold start that
285
+ * treats every account as idle is the right default. Not keyed by id lookup
286
+ * misses because a removed account simply disappears from the map on re-scan.
287
+ */
288
+ private lastUsedAt;
275
289
  private refreshInflight;
276
290
  constructor(options?: AccountPoolOptions);
277
291
  /**
@@ -283,6 +297,7 @@ export declare class WorkBuddyAccountPool {
283
297
  authDirs?: readonly string[];
284
298
  cooldownMs?: number;
285
299
  distribution?: AccountDistribution;
300
+ disabledAccountIds?: readonly string[];
286
301
  }): void;
287
302
  /** Rescan the auth directories and merge newly discovered accounts. */
288
303
  scan(): Promise<WorkBuddyAccount[]>;
@@ -298,6 +313,22 @@ export declare class WorkBuddyAccountPool {
298
313
  * model, e.g. CLI diagnostics).
299
314
  */
300
315
  private available;
316
+ /** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
317
+ private pickRoundRobin;
318
+ /**
319
+ * Priority mode: weighted random over the eligible accounts.
320
+ *
321
+ * The weight is an idle bonus — `1 + min(idleHours * perHour, max)` — so an
322
+ * account that has never served (or has been idle for a while) outranks one
323
+ * that just answered. Reference panel logic drops its success-rate term
324
+ * entirely because a lifetime error counter penalises an account forever;
325
+ * instantaneous health is already handled by cooldowns, which is why those
326
+ * accounts never reach this list.
327
+ *
328
+ * A pool with no idle history (fresh process) hashes to equal weights, which
329
+ * spreads the very first picks instead of always returning index 0.
330
+ */
331
+ private pickByWeight;
301
332
  /**
302
333
  * Pick the account to serve a request.
303
334
  *
@@ -323,6 +354,10 @@ export declare class WorkBuddyAccountPool {
323
354
  /** How the pool currently spreads requests. Shown on the card. */
324
355
  currentDistribution(): AccountDistribution;
325
356
  prefer(accountId: string | undefined): void;
357
+ /** Whether the user switched this account off on the card. */
358
+ isDisabled(accountId: string): boolean;
359
+ /** Every account id the user switched off, in discovery order. */
360
+ disabledIdsInOrder(): string[];
326
361
  /** Best-effort refresh of one account after a session-dead upstream answer. */
327
362
  refreshAccount(accountId: string): Promise<void>;
328
363
  /**
@@ -572,6 +607,11 @@ interface PoolWebAccount {
572
607
  modelId: string;
573
608
  until: string;
574
609
  }>;
610
+ /**
611
+ * Whether the user switched this account off. A disabled account never
612
+ * serves a request, but it stays listed so the card can switch it back on.
613
+ */
614
+ disabled: boolean;
575
615
  rateLimitHits: number;
576
616
  /** ISO timestamp of the last successful use (best-effort pool bookkeeping). */
577
617
  lastUsedAt?: string;
@@ -657,7 +697,6 @@ interface PoolWebModel {
657
697
  /** Whether this model is currently enabled in the picker. */
658
698
  enabled: boolean;
659
699
  }
660
- /** The user's saved model selection, echoed back so the card can diff a draft. */
661
700
  interface PoolWebModelSelection {
662
701
  /** Absent = every model is enabled. */
663
702
  enabledModelIds?: readonly string[];
@@ -697,7 +736,7 @@ interface PoolWebStatus {
697
736
  */
698
737
  type PoolRegion = 'cn' | 'global';
699
738
  /** How the pool spreads requests across its accounts. */
700
- type PoolDistribution = 'priority' | 'round-robin';
739
+ type PoolDistribution = 'priority' | 'round-robin' | 'balanced';
701
740
  //#endregion
702
741
  //#region src/index.d.ts
703
742
  /** Stable Cordis plugin name. */
@@ -725,6 +764,13 @@ export interface Config {
725
764
  * spend evenly instead. Absent reads as `priority`.
726
765
  */
727
766
  distribution?: 'priority' | 'round-robin';
767
+ /**
768
+ * Account ids switched off on the card. A disabled account is never picked
769
+ * to serve a request, but it stays in the pool and on the card so it can be
770
+ * switched back on. Ids are the pool's stable per-credential keys, which
771
+ * survive re-scans (see WorkBuddyAccountPool.disabledIds).
772
+ */
773
+ disabledAccountIds?: string[];
728
774
  /**
729
775
  * Model ids enabled in the picker. Absent means "every model the catalog
730
776
  * advertises" — an unconfigured install should never present an empty model
package/lib/index.js CHANGED
@@ -774,6 +774,26 @@ 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
+ }
777
797
  var WorkBuddyAccountPool = class {
778
798
  logger;
779
799
  authDirs;
@@ -786,6 +806,24 @@ var WorkBuddyAccountPool = class {
786
806
  cursor = 0;
787
807
  lastScanAtMs = 0;
788
808
  preferredId;
809
+ /**
810
+ * Account ids the user switched off on the card.
811
+ *
812
+ * Disabling is a user preference rather than a property of the credential:
813
+ * `scan()` rebuilds every account object from the auth files, so the set
814
+ * lives on the pool and is re-applied from settings after each scan.
815
+ */
816
+ disabledIds = /* @__PURE__ */ new Set();
817
+ /**
818
+ * Last time each account served a request, epoch ms. Drives the idle term
819
+ * of the priority-mode weighting below: an account that just served loses to
820
+ * one that has been idle, so a small pool stops hammering a single account.
821
+ *
822
+ * In-memory on purpose: it only biases the next pick, so a cold start that
823
+ * treats every account as idle is the right default. Not keyed by id lookup
824
+ * misses because a removed account simply disappears from the map on re-scan.
825
+ */
826
+ lastUsedAt = /* @__PURE__ */ new Map();
789
827
  refreshInflight = /* @__PURE__ */ new Map();
790
828
  constructor(options = {}) {
791
829
  this.logger = options.logger;
@@ -804,6 +842,7 @@ var WorkBuddyAccountPool = class {
804
842
  if (options.authDirs !== void 0 && options.authDirs.length > 0) this.authDirs = options.authDirs;
805
843
  if (options.cooldownMs !== void 0 && options.cooldownMs >= 1e3) this.cooldownMs = options.cooldownMs;
806
844
  if (options.distribution !== void 0) this.distribution = options.distribution;
845
+ if (options.disabledAccountIds !== void 0) this.disabledIds = new Set(options.disabledAccountIds);
807
846
  }
808
847
  /** Rescan the auth directories and merge newly discovered accounts. */
809
848
  async scan() {
@@ -856,12 +895,47 @@ var WorkBuddyAccountPool = class {
856
895
  */
857
896
  available(now, modelId, region) {
858
897
  return this.accounts.filter((account) => {
898
+ if (this.disabledIds.has(account.id)) return false;
859
899
  if (account.cooldownUntilMs > now) return false;
860
900
  if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
861
901
  if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
862
902
  return true;
863
903
  });
864
904
  }
905
+ /** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
906
+ pickRoundRobin(pool) {
907
+ const index = this.cursor % pool.length;
908
+ const account = pool[index];
909
+ if (account === void 0) return void 0;
910
+ this.cursor = (index + 1) % pool.length;
911
+ return account;
912
+ }
913
+ /**
914
+ * Priority mode: weighted random over the eligible accounts.
915
+ *
916
+ * The weight is an idle bonus — `1 + min(idleHours * perHour, max)` — so an
917
+ * account that has never served (or has been idle for a while) outranks one
918
+ * that just answered. Reference panel logic drops its success-rate term
919
+ * entirely because a lifetime error counter penalises an account forever;
920
+ * instantaneous health is already handled by cooldowns, which is why those
921
+ * accounts never reach this list.
922
+ *
923
+ * A pool with no idle history (fresh process) hashes to equal weights, which
924
+ * spreads the very first picks instead of always returning index 0.
925
+ */
926
+ pickByWeight(pool) {
927
+ if (pool.length === 1) return pool[0];
928
+ const now = Date.now();
929
+ const weights = pool.map((account) => idleWeight(this.lastUsedAt.get(account.id), now));
930
+ const total = weights.reduce((sum, weight) => sum + weight, 0);
931
+ if (!Number.isFinite(total) || total <= 0) return pool[0];
932
+ let roll = Math.random() * total;
933
+ for (let index = 0; index < pool.length; index += 1) {
934
+ roll -= weights[index] ?? 0;
935
+ if (roll < 0) return pool[index];
936
+ }
937
+ return pool[pool.length - 1];
938
+ }
865
939
  /**
866
940
  * Pick the account to serve a request.
867
941
  *
@@ -891,16 +965,16 @@ var WorkBuddyAccountPool = class {
891
965
  }
892
966
  if (pool.length === 0) return void 0;
893
967
  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];
968
+ const preferred = pool.find((account) => account.id === this.preferredId);
969
+ if (preferred !== void 0) {
970
+ this.lastUsedAt.set(preferred.id, Date.now());
971
+ await this.ensureFresh(preferred);
972
+ return preferred;
898
973
  }
899
974
  }
900
- const index = this.distribution === "round-robin" ? this.cursor % pool.length : 0;
901
- const account = pool[index];
975
+ const account = this.distribution === "round-robin" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
902
976
  if (account === void 0) return void 0;
903
- if (this.distribution === "round-robin") this.cursor = (index + 1) % pool.length;
977
+ this.lastUsedAt.set(account.id, Date.now());
904
978
  await this.ensureFresh(account);
905
979
  return account;
906
980
  }
@@ -912,6 +986,14 @@ var WorkBuddyAccountPool = class {
912
986
  prefer(accountId) {
913
987
  this.preferredId = accountId;
914
988
  }
989
+ /** Whether the user switched this account off on the card. */
990
+ isDisabled(accountId) {
991
+ return this.disabledIds.has(accountId);
992
+ }
993
+ /** Every account id the user switched off, in discovery order. */
994
+ disabledIdsInOrder() {
995
+ return this.accounts.filter((account) => this.disabledIds.has(account.id)).map((account) => account.id);
996
+ }
915
997
  /** Best-effort refresh of one account after a session-dead upstream answer. */
916
998
  async refreshAccount(accountId) {
917
999
  const account = this.accounts.find((item) => item.id === accountId);
@@ -1333,10 +1415,10 @@ function createWorkBuddyAdapter(options) {
1333
1415
  streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
1334
1416
  retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-xdpool retryPolicy"),
1335
1417
  configuredMaxTokens: /* @__PURE__ */ new Map(),
1336
- modelErrors: /* @__PURE__ */ new Map(),
1337
1418
  ...REQUEST_IMAGE_BUDGETS,
1338
1419
  piProvider: provider
1339
1420
  };
1421
+ Object.assign(profile, { modelErrors: /* @__PURE__ */ new Map() });
1340
1422
  let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
1341
1423
  return {
1342
1424
  providerId,
@@ -1739,6 +1821,8 @@ const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/cooldowns/reset"
1739
1821
  const POOL_CHECKIN_PATH = "/plugins/dsh-workbuddy-xdpool/checkin";
1740
1822
  /** Plugin-owned model-selection save endpoint (writes the settings section). */
1741
1823
  const POOL_MODELS_SAVE_PATH = "/plugins/dsh-workbuddy-xdpool/models/save";
1824
+ /** Switch one account in or out of the pool (card toggle). */
1825
+ const POOL_ACCOUNT_DISABLE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/disabled";
1742
1826
  //#endregion
1743
1827
  //#region src/web-status.ts
1744
1828
  /** Redact token-like content before it crosses to the browser. */
@@ -1830,7 +1914,25 @@ function parseSelection(body) {
1830
1914
  }
1831
1915
  return out;
1832
1916
  }
1833
- function toWebAccount(account) {
1917
+ /**
1918
+ * Validate the account-toggle body. Returns undefined for anything malformed so
1919
+ * the route answers 400 instead of writing a half-applied switch.
1920
+ *
1921
+ * The id must name a currently known account: accepting an arbitrary string
1922
+ * would let a stale tab (or a renamed credential) leave orphan ids in the
1923
+ * settings file that no card can ever switch back off.
1924
+ */
1925
+ function parseAccountToggle(body, known) {
1926
+ const accountId = typeof body["accountId"] === "string" ? body["accountId"].trim() : "";
1927
+ const disabled = body["disabled"];
1928
+ if (accountId === "" || typeof disabled !== "boolean") return void 0;
1929
+ if (!known(accountId)) return void 0;
1930
+ return {
1931
+ accountId,
1932
+ disabled
1933
+ };
1934
+ }
1935
+ function toWebAccount(account, disabled) {
1834
1936
  const now = Date.now();
1835
1937
  const cooling = account.cooldownUntilMs > now;
1836
1938
  const modelCooldowns = Object.entries(account.modelCooldowns).filter(([, until]) => until > now).sort((a, b) => a[1] - b[1]).map(([modelId, until]) => ({
@@ -1846,6 +1948,7 @@ function toWebAccount(account) {
1846
1948
  cooling,
1847
1949
  ...cooling ? { cooldownUntil: new Date(account.cooldownUntilMs).toISOString() } : {},
1848
1950
  ...modelCooldowns.length === 0 ? {} : { modelCooldowns },
1951
+ disabled,
1849
1952
  rateLimitHits: account.rateLimitHits
1850
1953
  };
1851
1954
  }
@@ -1878,7 +1981,7 @@ async function poolWebStatus(deps, region = "cn") {
1878
1981
  const rows = [];
1879
1982
  const now = Date.now();
1880
1983
  for (const account of accounts) {
1881
- const row = toWebAccount(account);
1984
+ const row = toWebAccount(account, deps.pool.isDisabled(account.id));
1882
1985
  if (!row.cooling) {
1883
1986
  try {
1884
1987
  const credits = await deps.client.fetchCredits(account.credential);
@@ -2040,8 +2143,31 @@ function registerPoolStatusRoute(ctx, deps) {
2040
2143
  }
2041
2144
  }
2042
2145
  });
2146
+ const disposeAccountDisable = ctx.webServer.register({
2147
+ kind: "exact",
2148
+ path: POOL_ACCOUNT_DISABLE_PATH,
2149
+ handler: async (req, res) => {
2150
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
2151
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
2152
+ if (deps.setAccountDisabled === void 0) return json(res, 503, { error: "settings service unavailable; the account switch cannot be saved" });
2153
+ try {
2154
+ const body = await readJsonBody(req);
2155
+ const known = new Set(deps.pool.list().map((account) => account.id));
2156
+ const toggle = parseAccountToggle(body, (id) => known.has(id));
2157
+ if (toggle === void 0) return json(res, 400, { error: "invalid account toggle payload" });
2158
+ await deps.setAccountDisabled(toggle.accountId, toggle.disabled);
2159
+ json(res, 200, {
2160
+ ok: true,
2161
+ ...toggle
2162
+ });
2163
+ } catch (error) {
2164
+ json(res, 500, { error: safeMessage(error) });
2165
+ }
2166
+ }
2167
+ });
2043
2168
  return () => {
2044
2169
  disposeCheckin();
2170
+ disposeAccountDisable();
2045
2171
  disposeModelsSave();
2046
2172
  disposeReset();
2047
2173
  disposeRescan();
@@ -2100,6 +2226,7 @@ const Config = z.object({
2100
2226
  authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
2101
2227
  cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
2102
2228
  distribution: z.union(["priority", "round-robin"]).default("priority").description("How requests are spread: priority (drain one) or round-robin"),
2229
+ disabledAccountIds: z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)"),
2103
2230
  enabledModelIds: z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet"),
2104
2231
  imageModelIds: z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet"),
2105
2232
  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 +2296,12 @@ function apply(ctx, config = {}) {
2169
2296
  }
2170
2297
  };
2171
2298
  const applyConfigFromSource = () => {
2172
- const { authFile, cooldownMs, distribution, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal } = current();
2299
+ const { authFile, cooldownMs, distribution, disabledAccountIds, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal } = current();
2173
2300
  core.pool.applyConfig({
2174
2301
  ...authFile === void 0 ? {} : { authDirs: [dirname(authFile)] },
2175
2302
  ...cooldownMs === void 0 ? {} : { cooldownMs },
2176
- distribution: distribution ?? "priority"
2303
+ distribution: distribution ?? "priority",
2304
+ ...disabledAccountIds === void 0 ? {} : { disabledAccountIds }
2177
2305
  });
2178
2306
  const legacySelection = {
2179
2307
  ...enabledModelIds === void 0 ? {} : { enabledModelIds },
@@ -2246,6 +2374,11 @@ function apply(ctx, config = {}) {
2246
2374
  ...selection.imageModelIds === void 0 ? {} : { imageModelIds: [...selection.imageModelIds] },
2247
2375
  ...selection.contextBudgets === void 0 ? {} : { contextBudgets: { ...selection.contextBudgets } }
2248
2376
  });
2377
+ },
2378
+ setAccountDisabled: (accountId, disabled) => {
2379
+ const currentIds = current().disabledAccountIds ?? [];
2380
+ const next = disabled ? currentIds.includes(accountId) ? currentIds : [...currentIds, accountId] : currentIds.filter((id) => id !== accountId);
2381
+ setSetting("disabledAccountIds", next);
2249
2382
  }
2250
2383
  }));
2251
2384
  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.0",
6
6
  "license": "MIT",
7
7
  "author": "XDTrees",
8
8
  "homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",