dsh-connect-workbuddy 4.6.6 → 4.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.6.7 (2026-10-17)
4
+
5
+ > 版本号是 **patch**:只改账号池的排序规则,没有接口或配置变更。但它是**行为变更**——开启账号池的机器上,「这次请求用谁」可能变了,且新行为正是本版要的。
6
+
7
+ ### Fixes
8
+
9
+ - **「快过期的积分优先」从未生效:第 3 个排序键被第 2 个键完全遮蔽,临期积分必然作废(issue #70)**
10
+ - **症状**:开启账号池后,`当前使用` 永远是积分总量最高的那个账号;哪怕另一个账号有积分**当天就要过期**,它也一直排最后,直到临期积分作废。
11
+ - **根因**:`rankPool()` 的排序键是**严格字典序**——先比积分总量(`scoreOf()` = `credits.total`),**只有总量完全相等**才比到期时间。而总量几乎从不恰好相等,所以「快过期优先」这条规则**在数学上不可达**。报告者的真实病例:账号 C 有 **196.4 分当天 17:21 到期**,只因为比 A/B 少约 1,200 分,即永远排最后,那 196.4 分必然烂掉。
12
+ - **为什么这条特别值得记**:README 把这两条写成**并列的第 2、3 条**,读起来像两个同级偏好;代码里却是严格优先级。**一条「不花就作废」的规则被另一条完全压制,等于死代码**,而它从输出上看不出任何异常——顺序永远「有道理」,只是那条规则从不参与。
13
+ - **改了什么**:把临期判据提到总量之前,并拆成三个独立的键——**有临期积分者优先** → **先到期者优先** → **临期量大者优先**,之后才回到积分总量。
14
+ - **一个单位错误顺手修掉**:旧的 `_expiring` 键实际存的是 `nearestExpiryMs`(**时间戳**),而 `expiringSoon`(**积分量**)在排序里从未被读过。现在两者分开:`_expiring` 仍是时间戳(用于「谁先到期」),`_expiringAmount` 才是积分量。直接拿这两个相减会是一个不容易发现的数量级错误。
15
+ - **一个反直觉但正确的后果,写进了代码注释与两份 README**:这条规则会让**临期 1 分**的账号排到**余额 5 万**的账号前面。**这不是缺陷**——积分是通用的,不花那 1 分它**确定**会损失;而余额 5 万的账号下一轮还在。唯一的真实代价是临期账号可能不够跑完一个请求,此时由既有的换号逻辑接手。若按总量优先,则临期积分**确定**作废。
16
+ - **可用性仍然第一**:处于限流冷却/凭据被拒的账号即使有大量临期积分也**不会**被提前——它花不掉,提上去只会让每个请求都撞墙。
17
+ - **测试**:新增 6 例(报告者真实数据 `C → A → B`、无临期时仍按总量、临期 1 分压过 5 万、都在临期时先到期者优先、同到期时量大者优先、不可用账号不被提升)。**变异验证**:① 恢复旧的字典序(总量先判)→ 红 4 例;② 去掉「先到期者优先」→ 红 2;③ `_atRisk` 恒为 false(等价修复前)→ 红 2。
18
+ - **顺带修正一条名不副实的旧测试**:`prefers the account whose credits expire soonest when totals tie` 的两个夹具都只有 `nearestExpiryMs`、`expiringSoon` 为 0,即**两个账号都没有任何临期积分**。它在旧规则下能过,靠的正是「排序看时间戳、不看量」这个缺陷;现已补上真实的 `expiringSoon`,让它真的在测名字里那件事。
19
+ - **README**(中英同步):排序规则把「快过期先花」提到第 2 条、「积分高者优先」降到第 3 条,并就地写明旧行为为何是死代码、以及「临期 1 分压过 5 万」为何是对的。
20
+
3
21
  ## 4.6.6 (2026-10-17)
4
22
 
5
23
  > 版本号是 **patch**:只动诊断(`WORKBUDDY_BODY_REPORT` 的报告内容),不改请求、响应、计费或界面。**默认行为完全不变**——报告默认仍是关的,关着时这版与 4.6.5 行为等价。
package/README.en.md CHANGED
@@ -32,7 +32,7 @@ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bund
32
32
  - **Credit readout in the composer toolbar (follows the selected model, per region)** — a clickable readout sits at the left of the composer toolbar (e.g. `WB CN · 1,072`) and **switches region automatically with the model this session is using**: pick a WB CN model and it shows the domestic balance, pick a WB AI model and it shows the international one; with any other provider's model it does not appear at all. It refreshes every 5 minutes and can also be refreshed by hand. Clicking it opens a panel: a **table of balances per account**, where clicking a row switches to that account (the same setting the card's account picker writes), and an account that cannot serve right now is **labelled in its row** (e.g. rate-limited, out of credits) using the same wording as the account-pool table. The per-account figures are fetched only while the panel is open, so the 5-minute background refresh never multiplies by the account count. To turn a side off, untick “Show WB CN / WB AI credits in the composer” in that region's tab on the card; while a region's provider is switched off, the readout disappears and its switch is disabled.
33
33
 
34
34
  <img src="docs/assets/dsh-connect-workbuddy-composer-credits.png" width="360" alt="Composer credit panel: a per-account balance table for the domestic region, with a refresh button and a last-refreshed time" />
35
- - **Account pool: batch actions across several accounts, plus automatic failover** — tick the accounts you want into a pool and you get **one-click check-in for every account**, **one-click testing of every account against a model you choose** (defaulting to the region's zero-multiplier free model), and with **Enable the account pool** switched on, **automatic failover**: a request that fails upstream is retried against the pool's other usable accounts, in order, until one serves it or none is left. Membership starts **empty and is an explicit opt-in**: "signed in on this machine" is not the same as "spend this account's credits and claim its rewards". The pool's **ranking decides who serves every request** (usable first, then higher credits, soonest-expiring, freshest credential), and on a failure the next usable member is tried. It **never writes back your saved selection** — switch the pool off and your own account applies again immediately. **The switch only decides whether the PLUGIN chooses** — with it off, the plugin stops picking and switching accounts for you, but check-in and testing stay available (they are manual actions). Testing reuses the model rows' real-volume probe, so it **does cost real credits**; **automatic check-in is deliberately not offered** — a check-in claims real rewards, so it only ever runs when you press the button. See [Account pool](#account-pool).
35
+ - **Account pool: batch actions across several accounts, plus automatic failover** — tick the accounts you want into a pool and you get **one-click check-in for every account**, **one-click testing of every account against a model you choose** (defaulting to the region's zero-multiplier free model), and with **Enable the account pool** switched on, **automatic failover**: a request that fails upstream is retried against the pool's other usable accounts, in order, until one serves it or none is left. Membership starts **empty and is an explicit opt-in**: "signed in on this machine" is not the same as "spend this account's credits and claim its rewards". The pool's **ranking decides who serves every request** (usable first, then expiring-credits-first, higher credits, freshest credential), and on a failure the next usable member is tried. It **never writes back your saved selection** — switch the pool off and your own account applies again immediately. **The switch only decides whether the PLUGIN chooses** — with it off, the plugin stops picking and switching accounts for you, but check-in and testing stay available (they are manual actions). Testing reuses the model rows' real-volume probe, so it **does cost real credits**; **automatic check-in is deliberately not offered** — a check-in claims real rewards, so it only ever runs when you press the button. See [Account pool](#account-pool).
36
36
  - **Actionable advice when a credential is refused** — when the upstream rejects the selected account's token, the plugin actually **probes** the other local sign-ins: if one still answers, it tells you to switch to it (with a one-click switch) instead of vaguely asking you to sign in again — re-authenticating fixes nothing while you are pinned to a revoked backup. Only when there is genuinely no other local account does it ask you to sign in again.
37
37
  - **Add accounts by QR sign-in, import/export them in bulk, test or delete them one by one** — the WorkBuddy desktop app is no longer a prerequisite: the card's **Add account by QR code** (labelled **Sign in on the web** for the international build — that region uses a browser login page, there is no QR) opens a dialog and takes you **straight to the authorization page** in your browser; if the browser blocks that, the dialog's link, copy and **Open page again** are the fallback. Every credential lands in the plugin's own vault (`$DSH_HOME/workbuddy-vault/<region>/<accountId>.json`, one file per account), and the desktop app's auth files stay **read-only and are never modified**. Each row of the account table gains **Test** and **Remove**: a row test reuses the same probe as the batch run, so the two are comparable; removing deletes only the copy the plugin stored and **hides** the account: it disappears at once and the automatic poll cannot bring it back, while **Detect accounts again** restores it (signing in or importing the same account clears the hide too). **The desktop app's sign-in is never touched** — removing means "this plugin no longer lists it", not "sign out of the app". **Export / import accounts** moves credentials to another machine: pick accounts to export a JSON file → choose a file and read a **redacted preview** (field names and whether a token is present; the token itself never enters the preview) → tick what to import. An import overwrites per account rather than duplicating, and **only a record positively identified as belonging to that region is stored** — a domain the plugin cannot classify is skipped and counted as skipped. See [Account pool](#account-pool).
38
38
  - **Credits live in the account pool** — the member table shows each account's remaining credits and nearest expiry, and credits are the ranking's second key (more credits serves first). The queries are **read-only** and consume no credits.
@@ -118,11 +118,15 @@ The last two rows are deliberate: check-in and testing are MANUAL actions over t
118
118
  ### The ranking (what decides who serves)
119
119
 
120
120
  1. **Usable first** — an account a measurement rules out (limited with its cooldown unexpired, credential rejected, upstream unreachable) sorts last and is not chosen;
121
- 2. **Higher credit balance first.** Credits take part **only when a fresh reading exists** (what the card's poll, a batch test or a save collected; valid for 10 minutes). The request path **never** fetches credits — that is an upstream call per member, and one page of chat would turn into N extra requests;
122
- 3. **Soonest-expiring credits first** — spend what lapses in three days before it is lost;
121
+ 2. **Expiring credits first** — points lapsing within three days are lost if unspent, so an account holding any comes first, the soonest-lapsing one ahead of the rest, and between two of them the larger amount at risk wins;
122
+ 3. **Higher credit balance first.** Among accounts with nothing expiring, the largest balance goes first. Credits take part **only when a fresh reading exists** (what the card's poll, a batch test or a save collected; valid for 10 minutes). The request path **never** fetches credits — that is an upstream call per member, and one page of chat would turn into N extra requests;
123
123
  4. **Freshest credential first**;
124
124
  5. The account id closes the order, making it **total** and reproducible rather than jittering with the roster's order.
125
125
 
126
+ > **Why #2 outranks #3** (issue #70): these used to be strict lexicographic keys — total first, expiry consulted only when totals were **exactly** equal. Totals are almost never exactly equal, so the expiry rule **never actually fired**: a "use it or lose it" rule wholly shadowed by another, i.e. dead code. In the reported case an account held **196.4 points lapsing that same day** and still ranked last, merely because it held ~1,200 fewer points than the other two — so all 196.4 were lost.
127
+ >
128
+ > The cost is worth stating plainly: this also puts an account with **1 expiring point** ahead of one holding **50,000**. That is not a defect — credits are fungible, so leaving the 1 point unspent loses it with certainty, whereas the 50,000 account is still there on the next request. The one real cost is a failover round-trip when the expiring account cannot cover a whole request, which the existing failover path absorbs.
129
+
126
130
  ### Why an account leaves the pool, and when it comes back
127
131
 
128
132
  | Probe outcome | What it means for failover |
package/README.md CHANGED
@@ -39,7 +39,7 @@
39
39
  - **对话输入框积分读数(国内版 / 国际版自动切换)** —— 对话输入框左侧工具栏常驻一行可点击的读数(如 `WB CN · 1,072`),**按本会话当前选中的模型自动切换区域**:选中 WB CN 的模型就显示国内版余额,选中 WB AI 的模型就显示国际版余额;用其它 provider 的模型时该读数完全不出现。每 5 分钟自动刷新,也可点开手动刷新。点击读数弹出面板:**按账号列出的余额表格**,点某一行即可切换到该账号(与卡片上的账号选择是同一个设置);**当前不能用的账号会在行内标出原因**(如「被限流」「积分耗尽」),标注口径与账号池表格一致。面板里的余额只在打开时才去查,因此不会让 5 分钟的后台刷新按账号数翻倍。要关掉某一边,就在卡片对应区域的 tab 里取消勾选「在对话框显示 WB CN / WB AI 积分」;该区域的供应商被关闭时,读数不显示、开关也置灰。
40
40
 
41
41
  <img src="docs/assets/dsh-connect-workbuddy-composer-credits.png" width="360" alt="对话输入框积分面板:国内版账号余额表格、刷新积分按钮与最近刷新时间" />
42
- - **账号池:多账号批量操作与自动换号** —— 把要用的账号勾进池子,即可**一键签到所有账号**、**一键测试所有账号 × 你指定的模型**(默认挑该区域倍率为 0 的免费模型);开启**启用账号池**即同时打开**自动换号**——某个请求在上游失败时,插件会依次用池内其他可用账号重试,直到有一个接手、或全部不可用为止,然后才把错误报给你。成员**默认一个都不含**、必须显式勾选(「本机有登录」不等于「要拿它去签到和花积分」);**开启后由池子的排序决定每一次请求用谁**(可用优先,其次积分高、快过期的积分包、凭据更新的);失败时依次用池内其他可用账号重试。排序**从不写回你保存的选择**——关掉开关,你选定的账号立刻完全生效。只有请求本身不合法(HTTP 400)不换号:同一个请求体换谁都是 400,重试只会白等。**这个开关只管「插件要不要自己决定」**:关掉它,插件不再替你选账号、也不再替你换号,但**签到与测试仍然可用**(它们是手动动作)。测试走的是与模型行同一套真实体积探测,因此**有真实成本**;**不提供自动签到**——签到会真实领取奖励,只在你点按钮时执行。详见[账号池](#账号池)。
42
+ - **账号池:多账号批量操作与自动换号** —— 把要用的账号勾进池子,即可**一键签到所有账号**、**一键测试所有账号 × 你指定的模型**(默认挑该区域倍率为 0 的免费模型);开启**启用账号池**即同时打开**自动换号**——某个请求在上游失败时,插件会依次用池内其他可用账号重试,直到有一个接手、或全部不可用为止,然后才把错误报给你。成员**默认一个都不含**、必须显式勾选(「本机有登录」不等于「要拿它去签到和花积分」);**开启后由池子的排序决定每一次请求用谁**(可用优先,其次快过期先花、积分高、凭据更新的);失败时依次用池内其他可用账号重试。排序**从不写回你保存的选择**——关掉开关,你选定的账号立刻完全生效。只有请求本身不合法(HTTP 400)不换号:同一个请求体换谁都是 400,重试只会白等。**这个开关只管「插件要不要自己决定」**:关掉它,插件不再替你选账号、也不再替你换号,但**签到与测试仍然可用**(它们是手动动作)。测试走的是与模型行同一套真实体积探测,因此**有真实成本**;**不提供自动签到**——签到会真实领取奖励,只在你点按钮时执行。详见[账号池](#账号池)。
43
43
  - **凭据失效时给出可执行的建议** —— 上游拒绝当前账号的 token 时,插件会**实际探测**本机其他账号:若有账号仍然可用,就直接告诉你「切换到它即可」(并附一键切换),而不是笼统地让你重新登录——被钉在一个作废的旧备份上时,重新登录并不能解决问题。只有本机确实没有别的账号可切换时,才会提示重新登录。
44
44
  - **扫码添加账号、批量导入导出、逐账号测试/删除** —— 不必先装 WorkBuddy 桌面 App:卡片里的「扫码添加账号」(国际版为「网页登录」——那边是浏览器登录页,没有二维码)点开即弹出对话框,并在浏览器里**自动打开**授权页;被浏览器拦截时对话框内提供链接、复制与「重新打开页面」兜底。所有凭据统一落到插件自己的凭据库(`$DSH_HOME/workbuddy-vault/<region>/<accountId>.json`,一账号一文件),**桌面 App 的认证文件始终只读、永不被改动**。账号表格每一行右侧有「测试」与「删除」:测试与批量测试走同一套探测,结果可比;删除只删插件自己存的那份,并把这个账号**暂时隐藏**:删掉后立刻消失,也不会被自动轮询带回来;点「重新检测账号」即可找回(重新登录或导入同一账号也会清除隐藏记录)。**桌面 App 的登录始终不动**——删除是「本插件不再列出它」,不是退出 App 登录。「导出 / 导入账号」可把凭据搬到另一台机器:多选导出 JSON → 选文件后先看**脱敏预览**(只显示字段与是否有 token,明文不进预览)→ 勾选导入。导入按账号去重覆盖,**只有确认属于该区域**的记录才入库,认不出的域名会被跳过并如实计入「已跳过」。详见[账号池](#账号池)。
45
45
  - **积分信息在账号池里** —— 成员表逐账号显示剩余积分与最近到期时间,排序的第二键就是积分(谁多谁优先)。查询是**只读**的、不消耗积分。
@@ -94,7 +94,7 @@ DSH PiAiAdapter(每个 provider 一套)
94
94
 
95
95
  | | 账号池 **开启** | 账号池 **关闭** |
96
96
  |---|---|---|
97
- | 谁服务 | 由排序**自动**选出(可用优先 → 积分高 → 快过期 → 凭据新) | **你在账号池里手动选**「使用哪个账号」 |
97
+ | 谁服务 | 由排序**自动**选出(可用优先 → 快过期先花 → 积分高 → 凭据新) | **你在账号池里手动选**「使用哪个账号」 |
98
98
  | 请求失败 | **自动**换下一个可用账号重试 | 不换号,如实报错 |
99
99
  | 一键签到 / 一键测试 | 可用 | **仍然可用** |
100
100
  | 目标测试模型 | 可改 | **仍然可改** |
@@ -108,11 +108,15 @@ DSH PiAiAdapter(每个 provider 一套)
108
108
  ### 排序规则(开启账号池后决定谁服务)
109
109
 
110
110
  1. **可用优先** —— 被测量判定为不可用(限流未到恢复时间、凭据被拒、连通性失败等)的排到最后,不参与选择;
111
- 2. **积分高者优先** —— 在可用的账号里选积分最多的。**积分只在有新鲜读数时参与**(卡片轮询、批量测试或保存时读到的那份,10 分钟内有效):请求路径**不会**为了排序去打上游,否则一页对话就会变成 N 个额外请求;
112
- 3. **快过期的积分包优先** —— 3 天内到期的先用,过期即作废;
111
+ 2. **快过期的积分先花** —— 3 天内到期的积分用不掉就作废,所以**有临期积分的账号排在前面**,**先到期的先排**;两个都在临期时,**临期积分多的优先**;
112
+ 3. **积分高者优先** —— 在没有临期积分的账号里,选积分最多的。**积分只在有新鲜读数时参与**(卡片轮询、批量测试或保存时读到的那份,10 分钟内有效):请求路径**不会**为了排序去打上游,否则一页对话就会变成 N 个额外请求;
113
113
  4. **凭据更晚过期的优先**;
114
114
  5. 最后按账号 id 收尾,保证顺序是**全序**、可复现,不会因为账号表顺序变化而抖动。
115
115
 
116
+ > **第 2 条为什么排到第 3 条前面**(issue #70):这两条此前是**严格字典序**——先比积分总量,只有总量**完全相等**才比到期时间。而总量几乎从不恰好相等,于是「快过期优先」**实际上从未生效过**:一条「不花就作废」的规则被另一条完全遮蔽,等于死代码。真实病例里,某账号有 **196.4 分当天作废**,只因为比另两个账号少约 1,200 分就永远排在最后,那 196 分必然烂掉。
117
+ >
118
+ > 代价也要说清:这条规则会让**临期 1 分**的账号排到**余额 5 万**的账号前面。这不是缺陷——积分是通用的,不花那 1 分它**确定**会损失;而余额 5 万的账号下一轮还在。唯一的真实代价是临期账号可能不够跑完一个请求,这时由既有的换号逻辑接手。
119
+
116
120
  ### 两个批量动作
117
121
 
118
122
  - **一键签到所有账号** —— 逐个账号签到。**签到前先查当天状态**,已签到的直接标记为「已签到」而**不发写入请求**(幂等,不重复领取);任何单个账号失败只标记该行,**不中断整批**。
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-NHBxcmkQ.js";
2
+ import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-D7a100yu.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
@@ -3949,7 +3949,7 @@ function generationOf(version) {
3949
3949
  //#endregion
3950
3950
  //#region src/version.ts
3951
3951
  /** The npm package version this build was produced from. */
3952
- const WORKBUDDY_CONNECT_VERSION = "4.6.6";
3952
+ const WORKBUDDY_CONNECT_VERSION = "4.6.7";
3953
3953
  //#endregion
3954
3954
  //#region src/host-heartbeat.ts
3955
3955
  /**
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-NHBxcmkQ.js";
1
+ import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-D7a100yu.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -3281,16 +3281,36 @@ function retryDue(probe, nowMs, fallbackMs = POOL_UNKNOWN_COOLDOWN_MS) {
3281
3281
  *
3282
3282
  * 1. **Usability.** Only accounts that can be billed right now are candidates;
3283
3283
  * they always outrank every excluded one.
3284
- * 2. **Credits, highest first.** Spend the account that has the most, so an
3285
- * account running low is conserved rather than drained first.
3286
- * 3. **Credits expiring soonest first.** Points about to expire are worth
3287
- * exactly zero after they do, so using them earlier is strictly better than
3288
- * saving them.
3289
- * 4. **Freshest credential.** Tie-break so a stale token never wins a draw.
3290
- * 5. **Account id.** Final tie-break, making the order TOTAL: two accounts
3284
+ * 2. **Expiring credits first, soonest first.** Points about to expire are
3285
+ * worth exactly zero after they do, so spending them before they lapse is
3286
+ * strictly better than saving them.
3287
+ * 3. **Largest expiring amount.** Between two accounts that both have expiring
3288
+ * points, the one with more at risk goes first.
3289
+ * 4. **Credits, highest first.** Among accounts with nothing expiring, spend the
3290
+ * one with the most, so a nearly-drained account is conserved.
3291
+ * 5. **Freshest credential.** Tie-break so a stale token never wins a draw.
3292
+ * 6. **Account id.** Final tie-break, making the order TOTAL: two accounts
3291
3293
  * equal on every ranking key would otherwise keep the caller's array order,
3292
3294
  * so the same pool could rotate differently between two runs.
3293
3295
  *
3296
+ * WHY EXPIRY OUTRANKS SIZE (issue #70). These used to be strict lexicographic
3297
+ * keys: total, THEN expiry. Since totals are almost never exactly equal, the
3298
+ * expiry rule was unreachable — a live report had an account with 196 points
3299
+ * lapsing that day ranked LAST, behind two accounts that expire weeks later,
3300
+ * simply because it held ~1,200 fewer points overall. It was never selected, so
3301
+ * the 196 points always lapsed. A "use it or lose it" rule that another rule
3302
+ * completely shadows is not a tie-break, it is dead code.
3303
+ *
3304
+ * WHY SPENDING THE SMALL EXPIRING ACCOUNT IS STILL RIGHT. Ordering by expiry
3305
+ * does mean an account with 1 point lapsing outranks one holding 50,000. That
3306
+ * looks wrong and is not: credits are fungible, so using the rich account
3307
+ * instead does not preserve the 1 point — it spends the 1 point's chance and
3308
+ * loses it. Using the expiring account spends a point that was going to be lost
3309
+ * anyway, and the rich account is still there for the next request. The only
3310
+ * real cost is a failover round-trip if the expiring account cannot cover a
3311
+ * whole request, which the exclusion/failover path already absorbs. Ordering by
3312
+ * total instead loses the expiring points with certainty.
3313
+ *
3294
3314
  * Excluded accounts are still RETURNED (sorted after the candidates) so the
3295
3315
  * card can explain why each is out; they differ from candidates by
3296
3316
  * `excludedBy` being set on them, never by being silently dropped.
@@ -3307,14 +3327,18 @@ function rankPool(members, nowMs) {
3307
3327
  score: scoreOf(member),
3308
3328
  ...excludedBy === void 0 ? {} : { excludedBy },
3309
3329
  _usable: excludedBy === void 0,
3330
+ _atRisk: (member.credits?.expiringSoon ?? 0) > 0,
3331
+ _expiringAmount: member.credits?.expiringSoon ?? 0,
3310
3332
  _expiring: member.credits?.nearestExpiryMs ?? Number.POSITIVE_INFINITY,
3311
3333
  _tokenExpiresAtMs: member.tokenExpiresAtMs ?? 0
3312
3334
  };
3313
3335
  });
3314
3336
  rows.sort((left, right) => {
3315
3337
  if (left._usable !== right._usable) return left._usable ? -1 : 1;
3338
+ if (left._atRisk !== right._atRisk) return left._atRisk ? -1 : 1;
3339
+ if (left._atRisk && right._atRisk && left._expiring !== right._expiring) return left._expiring - right._expiring;
3340
+ if (left._expiringAmount !== right._expiringAmount) return right._expiringAmount - left._expiringAmount;
3316
3341
  if (left.score !== right.score) return right.score - left.score;
3317
- if (left._expiring !== right._expiring) return left._expiring - right._expiring;
3318
3342
  if (left._tokenExpiresAtMs !== right._tokenExpiresAtMs) return right._tokenExpiresAtMs - left._tokenExpiresAtMs;
3319
3343
  return left.account.id < right.account.id ? -1 : left.account.id > right.account.id ? 1 : 0;
3320
3344
  });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
5
- "version": "4.6.6",
5
+ "version": "4.6.7",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",