dsh-connect-workbuddy 2.3.1 → 3.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
@@ -1,5 +1,95 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.0.1 (2026-09-30)
4
+
5
+ ### Bug Fixes
6
+
7
+ #### 保存账号池会把该区域的模型目录整段删掉,于是「保存后目标模型就下架了」
8
+
9
+ **用户报告**:在账号池里点保存后,目标模型立刻显示「已下架」,并且「一键测试所有账号」被停用;**macOS 上不出现**。
10
+
11
+ **根因(真机取证)**:`__save` 的路由此前做的是 `{ ...current, ...incoming }` —— **区域级整体替换**。而客户端只能按自己的 settings 镜像拼出槽位,这个镜像**本仓库自己注释过对本命名空间不可靠**(经本端点写入不会刷新它,在 0.1.7 线上可长期陈旧)。于是:
12
+
13
+ 1. 保存账号池时客户端 POST 的是 `{ cn: { pool } }`;
14
+ 2. Host 用这一个键**替换掉整个 cn 槽** → `lastCatalog`、`enabledModelIds`、`imageModelIds`、`contextBudgets`、`enabled` **全被删除**;
15
+ 3. `displayModels` 见 `lastCatalog` 为空即回落到**静态 CN 目录**(`src/catalog.ts`),而它只有 `deepseek-v4-flash`(第 39 行),**没有 `deepseek-v4.1-flash`**(那在第 59 行,属国际版);
16
+ 4. 于是判「已下架」,`resolveTargetModel` 返回 `stale`,测试随之停用。
17
+
18
+ **证据**:同一 profile 下留有 1:58 的配置备份(20711 B,`cn.lastCatalog` 含 `auto`/`hy4-preview`/… 完整清单),而当前配置(14873 B)的 `cn` 槽**只剩 `pool`**——差出的约 6 KB 正是被删掉的目录。本项目审计 `docs/audit/D-verification.md` L-4 记录过同一机制(当时是 `toggleRegion` 的过期整槽写入),但只修了写入交错,没有修「整体替换」这一步。
19
+
20
+ **修法**:`__save` 的合并**下沉一层**——字段的键做一层(保兄弟区域),被触及区域槽内的键再做一层。**只保留客户端未提及的键**;客户端显式发来的字段(含空数组)依旧覆盖,因此「主动清空」不受影响。刻意不再更深:`contextBudgets` 这类嵌套 map 由其所有者整体发送,继续递归会让单个条目无法删除。
21
+
22
+ **为什么不动 macOS**:该合并只在「客户端漏发了某个键」时才有差别;macOS 上的槽位是完整的,因此对它是恒等操作。
23
+
24
+ 守卫:`tests/web-status.spec.ts` 新增 4 例——① 只发 `{cn:{pool}}` 时 `contextBudgets`/`enabled` 必须存活(**这条直接复现本次数据丢失**);② 国际版同理(`{global:{pool}}` 不得删掉 global 的目录与预算);③ 显式发空数组仍能清空;④ `accounts` 的账号 id 是字符串,不得被对象化。**变异验证**:把合并改回一层展开 → ① 与 ② 同时变红(已实测)。
25
+
26
+ **即时补救**:卡片上的「切回自动」会把目标换成该区域的免费模型(`hy3`,静态目录里也有),立即可用;被删掉的模型勾选与上下文预算需在「模型管理」里重新保存一次以重建 `lastCatalog`。
27
+
28
+ **国际版(global)同样已覆盖,而且它更需要这个修复**:
29
+
30
+ - **为什么覆盖**:合并遍历的是**字段自己的键**,不认区域名——`{cn:{…}}` 与 `{global:{…}}` 走的是同一段代码,没有区域分支。有专门用例钉住(`preserves omitted fields in the INTERNATIONAL region too`)。
31
+ - **为什么它更需要**:同一个删除在国际版**不表现出任何症状**。`displayModels` 在 `lastCatalog` 为空时回落到静态目录,而**国际版静态目录里有 `deepseek-v4.1-flash`(`src/catalog.ts:59`,倍率 0)**,国内版静态目录没有(只有 `deepseek-v4-flash`,L39)。所以国际版用户的目标模型照旧解析成功——目录、勾选、上下文预算**被默默删掉却毫无提示**。国内版至少还会喊「已下架」。
32
+ - 这条区域差异已用 `tests/catalog.spec.ts` 的断言钉住(`cn.has('deepseek-v4.1-flash') === false` 且 `cn.has('deepseek-v4-flash') === true`),避免日后改动任一份目录时悄悄改变「哪些故障可见」。
33
+
34
+ **另说明**:本次两处修复都不区分区域。`isFileContentionWriteError` 是**平台**维度的(只在 Windows 上会命中 `EPERM/EBUSY/EACCES`),两条区域共用同一段客户端代码,因此国际版的保存失败提示也一并改善。
35
+
36
+ - **Windows 上文件被占用时,两条保存路径只甩原始 EPERM、不给做法**(模型列表与账号池;账号路径本来就有)。真机实测(3.0.0 宿主 + 锁住 profile 的 `cordis.patch.yml`):写入返回 HTTP 500,报文是
37
+ `EPERM: operation not permitted, rename 'C:\…\cordis.patch.yml.79498ff8fb27.tmp' -> '…\cordis.patch.yml'`
38
+ ——只报了一个临时文件路径,**没有说明原因、也没告诉用户怎么办**。而 `row.accountsWriteFailed` 早就把「杀毒软件/同步盘占用 → 关闭占用者后重试」写进了文案。
39
+ - **修法**:新增 `isFileContentionWriteError()`(匹配 `EPERM`/`EBUSY`/`EACCES` 三个**词边界**标记——正是 `dsh-atomic-write` 在 win32 上重试的那三个),命中时在原因后追加 `row.saveContentionHint`;模型列表与账号池两条路径接上。
40
+ - **只对文件占用生效,不做无条件提示**:校验类失败走的是同一个 catch,对那种失败说「去关闭占用文件的程序」会把用户引向不存在的原因。有专门用例钉住「无关失败不得出现该提示」。
41
+ - **macOS 行为不变**:POSIX 的 `rename` 无论目标是否被打开都直接替换,所以这三个错误码在 macOS/Linux 上基本不会出现——检测器天然只在 Windows 上生效。
42
+ - **顺带记录的核验结论:Windows 保存逻辑本身没有「静默成功」**。锁住文件时 `regions` 与 `accounts` 两条写入都返回 500 并带 `EPERM`,客户端随即抛 `WorkBuddySettingsWriteError`,卡片明确报错并**保留草稿/原选择**(三条保存路径都不会丢弃用户改动)。回退路径的回读校验读的是「被拒后重新加载的 Host 状态」,不会把乐观写入误判为已落盘。因此本次修的是**报错文案的可操作性**,不是落盘判定。
43
+
44
+ ### Docs
45
+
46
+ - **改正 3.0.0 里一处过期的免费模型举例**(README 两份 + 上方 3.0.0 条目):原文写「该区域倍率为 0 的免费模型(**国内版即 `deepseek-v4.1-flash`**)」。2026-09-30 在真机上核对 live catalog(16 个模型)后,`deepseek-v4.1-flash` 的倍率是 **0.11**,国内版**唯一**倍率为 0 的是 **`hy3`**(ctx 192000)。
47
+ - **机制本来是对的,错的只是举例**:`pickFreeModel` 严格按 `creditMultiplier === 0` 现挑、多个候选取上下文窗口最大者,实测输入真实目录时返回 `hy3`,规则成立;`docs/ACCOUNT_POOL_PLAN.md` §4.4 早就写明「不写死 `deepseek-v4.1-flash`」并解释了原因。所以这条只是文档里的名字腐坏了,不是行为缺陷。
48
+ - **为什么不改成另一个写死的名字**:上游会改倍率(本次观察到的就是一次改动),任何名字都会再次过期。现改为陈述「按 `creditMultiplier === 0` 现挑」并附本次实测值,同时说明为何不写死。
49
+ - **顺带印证的一条正确设计**:`auto` 在上游目录里**没有** `creditMultiplier` 字段,`pickFreeModel` 不把它当免费——这正是该模块注释警告过的陷阱(缺失 ≠ 0),实测确认未踩。
50
+
51
+ ## 3.0.0 (2026-09-30)
52
+
53
+ > **主版本号升级的理由**:账号池是第一个会改变「哪个账号被计费」的功能。在此之前插件只有一条硬规则——**绝不为了找积分而重排账号**(`src/auth.ts` 的原注释:*This is NOT credit-seeking*)。账号池把这条规则变成**用户显式授权的例外**:打开「按积分自动轮换」后,被计费的账号可能不是你手动选的那个。这是语义级的变化,因此走主版本号。
54
+
55
+ ### Features
56
+
57
+ #### 账号池:多账号批量操作与自动轮换
58
+
59
+ - **一键签到所有账号**:逐个账号签到,**签到前先查当天状态**,已签到的直接标记、**不发写入请求**(幂等,不重复领取);单个账号失败只标记该行,**不中断整批**。
60
+ - **一键测试所有账号**:对每个成员发一次**真实体积**的探测(约 25k 输入 token,复用模型行的 `probeModel`),得出该账号此刻能否用这个模型。默认目标是**该区域倍率为 0 的免费模型**(**不写死名字**:实测国内版当前只有 `hy3` 一个免费模型,而 `deepseek-v4.1-flash` 已从 0 变为 0.11——上游会改倍率,所以按目录里的 `creditMultiplier === 0` 现挑),也可指定具体模型;指定的模型若离开目录,手动动作**明确拒绝**(`target-model-stale`)而不是偷换一个。
61
+ - **按积分自动轮换**:在可用成员中按 **① 积分余额高者优先 → ② 快过期的积分优先用完 → ③ 平手时凭据更新鲜者优先** 选出计费账号。**与手动选择互斥**:开启后手动下拉立即置灰并就地给出原因与关闭按钮(否则会出现「下拉写着 A、实际计费 B」)。**轮换只在运行时决定用谁,绝不写回 `config.accounts[region]`**,关掉开关即原样恢复你上次的手动选择;每次换号都在卡片上留一条记录。
62
+ - **成员是显式勾选、默认空**:「本机有登录」不等于「要拿它去签到和花积分」。**空池不会退化成「全部账号」**——未勾选时两个批量按钮直接拒绝。
63
+ - **定时自动测试**:间隔 **5 ~ 1440 分钟(默认 30)**,定时器每 60 秒走一次。下限 5 分钟是刻意的:更密只会把整池打成限流。
64
+ - **每个区域各有独立的一池**:国内版与国际版的成员、目标模型、间隔与轮换开关互不影响。
65
+ - **不提供自动签到**(明确边界):签到是写操作、会真实领取奖励,只在你点按钮时执行;卡片上也不放一个永远点不动的开关。
66
+ - **成本如实标注**:测试是真实请求,指向免费模型(倍率 0)时几乎不花钱,但**不是零成本空转**。
67
+
68
+ ### Bug Fixes
69
+
70
+ - **卡片里的时间戳全部改为 24 小时制**:四个 `Intl.DateTimeFormat` 都漏了 `hourCycle`,于是跟随浏览器语言的默认偏好——en-US 渲染成 `09/30, 01:23 PM`。现统一为 `hourCycle: 'h23'`(不用 `hour12: false`:后者在部分引擎会落到 `h24`,把半夜显示成 `24:00`)。新增渲染回归测试钉住这一点,并用变异体确认该测试有判别力(去掉 `hourCycle` 即变红)。
71
+ - **陈旧的「目标模型」不再冻结整池的测量**:定时轮次原先在 `resolveTargetModel` 解不出 id 时直接返回,而「你指定的模型已离开目录」正是产出「无 id」的情形之一。于是一处显示偏好的错误会让整个区域的测量停摆:`被限流` 的成员靠时钟就能回池,而 `连不上上游` 的成员只能靠**新的探测**清除——探测不再写入,该成员便**永久挂起**。现在定时轮次会回落到该区域的**免费**模型继续刷新;没有免费模型时仍然整轮跳过(**绝不在定时器上烧收费模型**)。手动动作依旧拒绝陈旧目标。
72
+ - **一次刷新失败不再抹掉整个面板**:`refreshUsage` 的失败路径原先把整个区域的状态塌缩成 `{status:'error', message}`,而面板主体 gate 在 `signed-in` 上——于是**一次网络抖动就让正在编辑的账号池连草稿一起消失**。现在沿用项目既有的「快照 + 分区错误叠加」范式:**有数据就叠加 `refreshError`,没数据才塌缩**。
73
+ - **卡片不再永远渲染未登录占位**:`usageGuard` 的极性写反了(`begin()` 返回的探针报告的是**过期**),导致每一个未被更新的响应都被丢弃。已修正为 `const stale = …; if (stale()) return`,并由真实渲染测试(jsdom + react-dom)钉住。
74
+ - **间隔输入框可以正常输入**:逐键 clamp 会让 `120` 被记成 `520`、清空即跳回 `5`。现在只把**合法的整数**折进草稿,显示始终是原始输入,失焦/回车时**恰好收敛一次**。
75
+ - **保存失败不再丢弃草稿**:写入失败或「回读没有带来新 props」时保留草稿并说明原因;**草稿是用户编辑的唯一副本**。
76
+ - **批量动作后重新读取用量**:签到会领取积分、测试会写入探测结果,而面板数字来自用量路由——现在批量成功后**恰好重新读取一次**,且**失败时不重读**(避免掩盖错误)。
77
+ - **同一区域的用量请求只允许最新一次写入**:轮询 / 保存 / 批量三种刷新会并发,慢的响应原先可以覆盖新的快照。
78
+ - **成员勾选不再静默丢账号**:`editDraft` 的依赖数组缺了保存的成员列表,勾选会以**旧成员列表**为基准,保存即静默移出成员。
79
+ - **池路由的错误按原因本地化**:四个 503/500 位置现在都带结构化的 `reason`,中文用户不再只看到英文。
80
+ - **幽灵池(成员已消失)给出可执行的恢复指引**,而不是笼统失败。
81
+
82
+ ### Tests
83
+
84
+ - **引入真实渲染测试(jsdom + react-dom)**:第四轮独立对抗验证证明「读源码文本」的守卫与「住在行为里」的缺陷之间必然有缝——18 个变异体恢复了**用户可见**的缺陷而整套测试仍全绿。本轮把 `AccountPool.tsx` / `WorkBuddyCard.tsx` 真正挂载进 DOM 断言,从根上结束这一整类问题,并据此击毙了其中 16 个(余 2 个经逐字论证为**等价变异体**,不恢复任何缺陷)。
85
+ - 逐条收紧被证明可绕过的守卫:`stripLiterals()` 连**字符串字面量**一起去(原先只去注释,一个字符串就能顶替一次真实调用);「恰好一次」改为对**标识符**计数;A-8 增加「`committed` 只能被写一次且不在 `catch` 里」。
86
+ - 修掉一个会**伪造结论**的工装缺陷:调度器用例对负载敏感,固定轮数的排空在 CPU 争用下会读到 0,变异工装会把它读成「守卫抓住了变异体」,从而报告出并不存在的覆盖。现在改为**排空到条件成立**。
87
+
88
+ ### Docs
89
+
90
+ - README 两份均新增**账号池**章节(含产品截图):两个批量动作、成员为何必须显式勾选、自动轮换与手动选择的互斥、目标模型与间隔、四种探测结果与回池条件、以及为什么卡片上有两个「保存」。
91
+ - 新增 `docs/audit/` 审计与修复档案:A–I 九份独立报告 + MASTER 汇总 + E-fix-log 逐轮修复记录。全部 51 条审计条目经独立验证**100% 可复现**(0 条误报)。
92
+
3
93
  ## 2.3.1 (2026-09-29)
4
94
 
5
95
  ### Features
package/README.en.md CHANGED
@@ -29,6 +29,7 @@ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bund
29
29
 
30
30
  **Two things to know.** ① **It really does spend credits** — one measured probe reported `credit` 0.02 (a refused one 429s and costs almost nothing). So testing is **manual and per-model only**: there is deliberately no “Test selected” batch button, because sweeping the whole list from one click is an uncontrolled spend — and the Host enforces the same rule, **accepting a single model only** and refusing multi-model requests. ② The upstream advertises a 1M context yet throttles far below it, and the plugin advertises up to 200K to DSH, so DSH compacts very late — hitting the throttle in a long session follows directly from that.
31
31
  - **Local account switching** — discovers the multiple sign-in credentials WorkBuddy's desktop app leaves behind and lets you switch per region. Tokens are never written to DSH settings.
32
+ - **Account pool: batch actions across several accounts, plus credit-aware rotation** — 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 — when you want it — **rotation by credits**, letting the plugin pick which pool member gets billed. 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". Rotation and manual selection are **mutually exclusive** and the card says why, in place; rotation only decides who serves at runtime and **never writes back your manual choice**, so switching it off restores that choice verbatim. 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).
32
33
  - **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.
33
34
  - **Read-only credits overview** — remaining credit aggregated per package, plus each model's credit multiplier. Queries consume no credits.
34
35
  - **Multi-candidate credential paths** — probes the platform defaults for macOS / Windows / Linux in turn, overridable by environment variable or directly in the card. **When nothing is found, the card lists which paths were probed and why each one failed**: five distinct reasons (absent / unreadable / no usable token / encrypted with no key available / belongs to the other region), so the easiest case to misdiagnose — signed in, but the desktop app is not present — says the app must be there instead of telling you to sign in again. The **belongs to the other region** reason is different in kind: that sign-in is real and usable, it is simply filed under the other tab, so the card states the fix outright — switch tabs, no need to sign in again. The list says it once: the paragraph states the conclusion, the collapsed list supplies the per-path detail, and the two never repeat each other.
@@ -55,6 +56,64 @@ Credentials are read (read-only) from the WorkBuddy desktop app's own auth file.
55
56
 
56
57
  **Encrypted credential fields**: newer desktop builds (Windows first) replace the auth file's `accessToken` / `refreshToken` strings with an AES-256-GCM envelope (`{"$wbEncrypted":1,"envelope":"…"}`), and the account's **display name (`nickname`) is encrypted the same way** — left unopened it degrades to a bare uin number. The envelope is sealed with a field key **compiled into the app itself**, not a user secret — so the plugin ships no key copy. When it meets an encrypted document it runs the installed app with `ELECTRON_RUN_AS_NODE` and calls that app's own native binding (`electron_browser_workbuddy_storage.loggerGet()`) to fetch the same payload and derive the key locally: it asks the very build that wrote the file. The key is cached in process memory only — never on disk, never in logs. Plain-string documents (macOS, older Windows builds) take the original path and never spawn a child process. `account.phoneNumber` is encrypted there too, and the plugin deliberately **neither reads nor displays it**. If the app lives somewhere unusual, point `WORKBUDDY_APP_EXECUTABLE` at its executable.
57
58
 
59
+ ## Account pool
60
+
61
+ <p align="center">
62
+ <img src="docs/assets/dsh-connect-workbuddy-account-pool.png" width="900" alt="dsh-connect-workbuddy account pool: check-in all, test all, membership selection and credit-aware rotation" />
63
+ </p>
64
+
65
+ A machine usually carries more than one WorkBuddy sign-in. The **account pool** turns that into a single click: tick the accounts you want into the pool, then press **Check in all accounts** or **Test all accounts**; open **rotation by credits** when you want the plugin to choose the billed account for you.
66
+
67
+ **Each region (domestic / international) has its own independent pool** — membership, target model, interval, and the rotation switch never affect the other side, because the two sides' accounts belong to different upstream stacks.
68
+
69
+ ### The two batch actions
70
+
71
+ - **Check in all accounts** — signs in account by account. It **reads today's status first**, so an account that is already checked in is marked as such and receives **no write request at all** (idempotent — no duplicate claims); a single account failing marks only that row and **never aborts the batch**.
72
+ - **Test all accounts** — sends one **real-volume** probe per member (about 25k input tokens; the same `probeModel` the model rows use, so it inherits both measured facts: throttling is triggered by request **size**, and the reset time is written into the response **body** rather than a header), reporting whether that account can serve that model right now.
73
+
74
+ **Which model is tested:** by default the **zero-multiplier free model in that region's catalog** (measured: the domestic catalog currently has exactly one, `hy3`; the name is deliberately **not** hard-coded, because the upstream changes multipliers — `deepseek-v4.1-flash` has already moved from 0 to 0.11), or a specific model you pick in the dropdown. If the model you named has left the catalog, the **manual action refuses outright** (`target-model-stale`) instead of quietly substituting another one — you asked to test *that* model, and swapping it answers a question you did not ask.
75
+
76
+ **Cost and boundaries:** testing is a real request. Pointed at the free model (multiplier 0) it costs virtually nothing, but it is **not a free no-op**; check-in claims that day's real reward. Both actions run **only when you press the button** — the plugin **does not offer automatic check-in**, and the card does not show a switch that can never be flipped.
77
+
78
+ ### Membership is an explicit opt-in, empty by default
79
+
80
+ "signed in on this machine" is not "spend this account's credits and claim its rewards". Membership starts **empty** and must be ticked one by one (or with **Select all**); **an empty pool never degrades into "all accounts"** — with nothing ticked, both batch actions are refused rather than running every account on your behalf.
81
+
82
+ Ticking boxes is a **draft edit**: it lands on **Save**, the save button carries a dirty marker until then, and the draft survives switching tabs or closing the card.
83
+
84
+ ### Rotation by credits: turning "pick an account by balance" into an explicit grant
85
+
86
+ This plugin's standing rule is that it **never reorders accounts to seek credits** — which account gets billed has to be predictable. Rotation is the exception **you switch on yourself**:
87
+
88
+ 1. Once on, the plugin ranks the usable members by **① highest credit balance first → ② credits expiring soonest first → ③ freshest credential as the tie-break** and bills the winner.
89
+ 2. **It is mutually exclusive with manual selection**: turning it on **greys out the account dropdown immediately** and explains why in place, with a switch-off button right there. Otherwise you get the worst possible state — **the dropdown reads account A while account B is actually billed**.
90
+ 3. **Rotation only decides who serves at runtime and never writes back your manual choice** (it does not touch `config.accounts[region]`). Switch it off and your last manual pick returns untouched.
91
+
92
+ **Every rotation leaves a line in the card** (when, and from whom to whom) rather than happening silently.
93
+
94
+ ### The automatic test interval
95
+
96
+ A timer ticks every 60 seconds and runs a "test all accounts" pass on your interval (**5 to 1440 minutes, default 30**). The 5-minute floor is deliberate: anything denser only throttles the whole pool.
97
+
98
+ **One easily missed edge:** if the model you named has left the catalog, the scheduled pass **does not stop** — it falls back to the region's free model and keeps the measurements fresh. It has to: a member comes back into the pool through a **new probe result** (below), and a stale display preference must not freeze the whole pool's measurements. The manual action still refuses, for the reason given above.
99
+
100
+ ### Why an account leaves the pool, and when it comes back
101
+
102
+ | Probe outcome | What it means for rotation |
103
+ | --- | --- |
104
+ | Usable | Eligible |
105
+ | Rate limited | Out of the pool **temporarily**, returning on the reset time the upstream stated (and when it states none, the card says so rather than **inventing a countdown**) |
106
+ | Out of credit | Out of the pool, returning at the monthly package's refresh time |
107
+ | Credential rejected / model not found / upstream unreachable / failed | Out of the pool until a later probe refreshes the result |
108
+
109
+ The key line: **"rate limited" is not "unusable"**. It is precisely the state most worth waiting on — treating it as permanently dead throws away the accounts that would have served you best.
110
+
111
+ ### Saving and drafts: why the card has two Save buttons
112
+
113
+ One for model management, one for the pool — **deliberately kept apart** rather than merged: the two draft domains differ, and merging them means one failed write drags the other down (a catalog that will not save would stop you saving pool preferences too); a shared Discard would also throw away both drafts on a single misclick. Both sides use a **verified write** — **a write that did not land never discards the draft**, or your edits are lost for good.
114
+
115
+ One class of state in the pool **does not go through a draft**: probe results, cooldowns, and the account rotation currently picked. Those are **observations**, written frequently by the plugin itself. The reason is practical — drafts are overwrite-based, so if observations lived in one, a single manual **Save** could roll the result a timer had just written **back to a several-minute-old value**.
116
+
58
117
  ## Install
59
118
 
60
119
  > ⚠️ **Version requirement: DSH 0.1.7-rc.1 or newer (current window: `0.1.7-rc.1` – `0.2.x`).** Since v2.1.0 this plugin only supports hosts on DSH 0.1.7-rc.1 and above (older hosts cannot resolve the dependencies to install this version); hosts on 0.1.5 and earlier should stay on v2.0.15. Since v2.1.2 the upper bound is **`<0.3.0-0`**: the **whole 0.2.0 line, GA included, is verified**; the 0.3.0 line is explicitly refused. See [Supported host range](#supported-host-range) below.
package/README.md CHANGED
@@ -10,6 +10,7 @@
10
10
  <a href="README.en.md">English</a> ·
11
11
  <a href="#安装">安装</a> ·
12
12
  <a href="#工作原理">工作原理</a> ·
13
+ <a href="#账号池">账号池</a> ·
13
14
  <a href="CHANGELOG.md">更新日志</a> ·
14
15
  <a href="https://github.com/dingminhua/dsh-connect-workbuddy/issues">问题反馈</a>
15
16
  </p>
@@ -35,6 +36,7 @@
35
36
 
36
37
  **两点需要知道**:① **它真的花钱**——实测一次报 `credit` 0.02(被限流时直接 429,几乎不花)。因此**只能按单个模型手工点**,没有「测试已勾选」这类批量按钮:一次覆盖整个列表就是一次不可控支出(服务端也据此**只接受单个模型**,多模型请求会被拒)。② 上游虽宣称 1M 上下文,但节流线远低于此;插件向 DSH 宣称的窗口最高 200K,所以 DSH 很晚才压缩对话——长会话撞限流是这条机制的直接结果。
37
38
  - **本机账号切换** —— 自动发现 WorkBuddy 桌面端留下的多个登录凭据,可按区域切换账号;Token 不写入 DSH 设置。
39
+ - **账号池:多账号批量操作与自动轮换** —— 把要用的账号勾进池子,即可**一键签到所有账号**、**一键测试所有账号 × 你指定的模型**(默认挑该区域倍率为 0 的免费模型),需要时再打开**按积分自动轮换**让插件在池内自行挑一个计费账号。成员**默认一个都不含**、必须显式勾选(「本机有登录」不等于「要拿它去签到和花积分」);自动轮换与手动选择**互斥**并就地说明原因;轮换只决定运行时用谁,**永不写回你的手动选择**,关掉开关即原样恢复。测试走的是与模型行同一套真实体积探测,因此**有真实成本**;**不提供自动签到**——签到会真实领取奖励,只在你点按钮时执行。详见[账号池](#账号池)。
38
40
  - **凭据失效时给出可执行的建议** —— 上游拒绝当前账号的 token 时,插件会**实际探测**本机其他账号:若有账号仍然可用,就直接告诉你「切换到它即可」(并附一键切换),而不是笼统地让你重新登录——被钉在一个作废的旧备份上时,重新登录并不能解决问题。只有本机确实没有别的账号可切换时,才会提示重新登录。
39
41
  - **只读积分概览** —— 按套餐聚合展示剩余积分,并列出每个模型的积分倍率。查询不消耗积分。
40
42
  - **凭据路径多候选** —— macOS / Windows / Linux 逐一探测默认位置,也支持环境变量与卡片内直接指定。**未登录时卡片会列出「探测过哪些路径、各自为什么没成」**:五档原因(不存在 / 读不了 / 没有可用 token / 字段已加密且拿不到密钥 / 属于另一个地区)分开呈现,因此「已经登录了、但桌面 App 不在场」这种最容易误判的情况会明说需要 App 在场,而不是叫你重新登录一次。**属于另一个地区**那一档尤其不同:登录信息是真实可用的,只是挂在另一个标签页下,卡片会直接给出结论「切换标签页即可,不用重新登录」。列表只讲一次——提示段落说结论,折叠列表给路径细节,两处不重复同一件事。
@@ -61,6 +63,64 @@ DSH PiAiAdapter(每个 provider 一套)
61
63
 
62
64
  **加密凭据字段**:新版桌面端(Windows 先行)把 auth 文件里的 `accessToken` / `refreshToken` 从纯字符串改为 `{"$wbEncrypted":1,"envelope":"…"}` 的 AES-256-GCM 信封,**账号昵称 `nickname` 同样被加密**(不解密就会退回成一串 uin 数字)。信封用的是 App **自身构建期内置**的字段密钥,并非用户密钥,因此插件不内置任何密钥副本,而是在需要时用 `ELECTRON_RUN_AS_NODE` 调用已安装 App 的原生绑定(`electron_browser_workbuddy_storage.loggerGet()`)取回同一份载荷并就地派生密钥——也就是「向写下这个文件的那个构建本身询问」。密钥只在本进程内存中缓存,不落盘、不进日志;纯字符串文件(macOS 与旧版 Windows)走原路径,一次子进程都不会启动。其中的 `account.phoneNumber` 虽同样加密,插件**不读取也不显示**。若 App 安装在非常规位置,用 `WORKBUDDY_APP_EXECUTABLE` 指定其可执行文件。
63
65
 
66
+ ## 账号池
67
+
68
+ <p align="center">
69
+ <img src="docs/assets/dsh-connect-workbuddy-account-pool.png" width="900" alt="dsh-connect-workbuddy 账号池区块:一键签到所有账号、一键测试所有账号、成员勾选与自动轮换" />
70
+ </p>
71
+
72
+ 一台机器上往往登录过多个 WorkBuddy 账号。**账号池**把这件事收成一次点击:把要用的账号勾进池子,然后「一键签到所有账号」或「一键测试所有账号」;需要时再打开**按积分自动轮换**,让插件在池内自行挑一个计费账号。
73
+
74
+ **每个区域(国内版 / 国际版)各有独立的一池**:成员、目标模型、间隔与轮换开关互不影响——两边的账号本来就属于不同的上游栈。
75
+
76
+ ### 两个批量动作
77
+
78
+ - **一键签到所有账号** —— 逐个账号签到。**签到前先查当天状态**,已签到的直接标记为「已签到」而**不发写入请求**(幂等,不重复领取);任何单个账号失败只标记该行,**不中断整批**。
79
+ - **一键测试所有账号** —— 对每个成员发一次**真实体积**的探测请求(约 25k 输入 token;与模型行的「测试」是同一套 `probeModel`,因而同样继承两个实测结论:限流**按请求体积**触发、恢复时间写在**响应体**里而不是响应头),得出该账号此刻能否用这个模型。
80
+
81
+ **测试目标是哪个模型**:默认是**该区域目录里倍率为 0 的免费模型**(实测国内版当前只有 `hy3` 一个;**不写死名字**,因为上游会改倍率——`deepseek-v4.1-flash` 就已从 0 变成 0.11),也可以在下拉里指定一个具体模型。指定的模型若已离开目录,**手动动作会明确拒绝**(`target-model-stale`),而不是偷偷换一个——你点的是「测这个模型」,换掉就是答非所问。
82
+
83
+ **成本与边界**:测试是真实请求,指向免费模型时(倍率 0)几乎不花钱,但它**不是零成本的空转**;签到则会真实领取当日奖励。两个动作都**只在你点按钮时执行**——插件**不提供自动签到**,卡片上也不放一个永远点不动的开关。
84
+
85
+ ### 池成员是显式勾选,默认一个都不含
86
+
87
+ 「本机有登录」不等于「要拿它去签到和花积分」。成员默认**空**,必须逐个勾选(或点「全选」);**空池不会退化成「全部账号」**——没勾选时两个批量动作直接拒绝,而不是替你把所有账号跑一遍。
88
+
89
+ 勾选是一次**草稿编辑**:点「保存」才落盘,未保存时保存按钮带 dirty 指示;切走或关掉卡片,草稿仍在。
90
+
91
+ ### 自动轮换:把「按积分选账号」变成一次显式授权
92
+
93
+ 插件此前的原则是**绝不为了找积分而重排账号**——账单落在谁身上必须可预期。自动轮换是**你显式打开的**例外:
94
+
95
+ 1. 打开后,插件在可用的成员里按 **① 积分余额高者优先 → ② 快过期的积分优先用完 → ③ 平手时凭据更新鲜者优先** 排序,把得分最高的那个作为当前计费账号。
96
+ 2. **它与手动选择互斥**:自动轮换开启时,账号下拉**立即置灰**,并就地给出原因与一个「关闭自动轮换」的按钮。否则你会看到最坏的一种状态——**下拉里写着账号 A,实际计费的却是账号 B**。
97
+ 3. **轮换只在运行时决定用谁,绝不写回你的手动选择**(不写 `config.accounts[region]`)。所以关掉开关后,你上次手动选的账号原封不动地回来。
98
+
99
+ **每次轮换都在卡片上留下一条记录**(何时、从谁到谁),而不是悄悄发生。
100
+
101
+ ### 自动测试的时间间隔
102
+
103
+ 定时器每 60 秒走一次,按你设定的间隔(**5 ~ 1440 分钟,默认 30**)执行一轮「测试所有账号」。下限是 5 分钟而非更短,是刻意的:更密只会把整池打成限流。
104
+
105
+ **一个容易忽略的边界**:若你指定的模型已离开目录,定时轮次**不会就此停摆**——它会回落到该区域的免费模型继续刷新测量。这必须如此:成员能否回池靠的是**新的探测结果**(见下),一个陈旧的显示偏好不该把整池的测量冻住。手动动作仍然拒绝,理由同上。
106
+
107
+ ### 账号为什么出池,以及什么时候回来
108
+
109
+ | 探测结果 | 对轮换的意义 |
110
+ | --- | --- |
111
+ | 可用 | 可入选 |
112
+ | 被限流 | **暂时**出池,按上游给出的重置时间自动回池(上游没说时间就如实说没说,**不编造倒计时**) |
113
+ | 积分耗尽 | 出池,按月周期包的刷新时间回池 |
114
+ | 凭据被拒 / 模型不存在 / 连不上上游 / 失败 | 出池,直到下一次探测刷新结果 |
115
+
116
+ 关键一条:**「被限流」不等于不可用**。它恰恰是最值得等的那一类状态——把它当成永久失效剔除,正好丢掉池子里本来最能用的账号。
117
+
118
+ ### 保存与草稿:为什么卡片上有两个「保存」
119
+
120
+ 模型管理一个、账号池一个,这是**刻意保留**而非漏合并:两者的草稿域不同,合并后一次写入失败会连累另一边(模型目录写不进去,就连池偏好也存不了);一个共享的「放弃」还会在一次误点里同时丢掉两份草稿。两边都是**验证写入**——**写不成功不丢弃草稿**,否则用户的编辑就永久丢了。
121
+
122
+ 池里还有一类状态**不走草稿**:探测结果、冷却时间、当前轮换到的账号。它们是**观测事实**,由插件自己高频写入。原因很实际——草稿是覆盖式的,若观测结果也进草稿,你手工点一次「保存」就可能把定时器刚写进去的结果**回退成几分钟前的旧值**。
123
+
64
124
  ## 安装
65
125
 
66
126
  > ⚠️ **版本要求:DSH 0.1.7-rc.1 及以上(当前窗口:`0.1.7-rc.1` ~ `0.2.x`)。** 自 v2.1.0 起本插件只支持 DSH 0.1.7-rc.1 及以上的宿主(旧版宿主无法通过依赖解析安装本版);0.1.5 及更早的宿主请停留在 v2.0.15。自 v2.1.2 起上界放宽为 **`<0.3.0-0`**——**0.2.0 全线(含正式版)已实测支持**,0.3.0 线则被明确拒绝。详见下方[受支持的宿主范围](#受支持的宿主范围)。