dsh-connect-workbuddy 3.0.2 → 3.3.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
@@ -1,5 +1,195 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.3.0 (2026-10-01)
4
+
5
+ ### Features
6
+
7
+ - **DSML 恢复:模型把工具调用写进正文时,插件现在会把它转回真正的 `tool_calls`。**
8
+ - **背景**:上游模型在长上下文下偶尔漏掉工具调用的起始包裹,把 `<|DSML|invoke name="…">` 这类标记直接写进 `delta.content`。DSH 因此**没有任何东西可以路由**——标记被当正文渲染出来,也就是用户看到的「一堵乱码墙」。这是模型族的有名缺陷,不是本插件的 bug,但本插件正好站在「拿到字节之后、交给客户端之前」这一层,是唯一能动手的地方。
9
+ - **四道闸**(缺任何一道,上游都真的出过生产事故:把空参数调用发出去、把**讨论**这段标记的正文执行成真实调用、把请求根本没声明的工具提升成可执行调用):
10
+ 1. **闭合才提交** —— 只有闭标签到达才产出调用;被 token 上限截断的块按原文显示,绝不猜。
11
+ 2. **名字必须在本次请求声明的工具里** —— 名字取自 `prepareChatBody` 已经解析过的那份请求体,不再解析第二遍。
12
+ 3. **没声明工具就完全不做** —— `tool_choice: "none"` 会在预处理时删掉 `tools`,于是「本次没声明工具」是一读即得的既成事实,而不是另一条要记得同步的规则。
13
+ 4. **任何可疑一律回滚成正文** —— 必填参数缺失、名字对不上、解析失败:整块**原样显示**。注意是「显示」不是「删掉」:静默吞掉一段文本会丢内容而用户看不出来——「检查永远不命中」与「检查通过」在界面上长得一模一样,这个坑本项目已经付过一次学费。
14
+ - **只有整轮都是标记时才重发一次**:能恢复的形态当场转换(零重新生成、不加延迟、不重复计费)。本仓库真实捕获的那一类——工具名被写坏成 `<|DSML| validate>`、拿不到任何工具名——注定过不了第二道闸,按原文显示;只有当**整轮去除标记后没有任何正文**时才重发一次,并复用既有的账号池换号语义。判据是可复核的既成事实,不是猜测。
15
+ - **普通回答不受影响**:只有在「回答到目前为止全是标记」时才扣住字节,一旦出现真正的正文或调用就立刻按序放行并恢复原样透传。因此正常请求的延迟没有变化,也不会出现「先丢字节再反悔」——那会造成「两段回答拼接」,是既有换号逻辑明确拒绝的事。
16
+ - **上游已经会发结构化调用时一律不插手**(粘性判定):一次回答里两条调用通道互相竞争,比任一条单独出问题更糟。
17
+ - 解析器移植自 [`hawklithm/workbuddy2api`](https://github.com/hawklithm/workbuddy2api) 的 `dsml_parser.py`(MIT),并补上它**没有的**第 2、3 道闸;逐条偏离见 `src/dsml-recovery.ts` 头部与 `THIRD_PARTY_NOTICES.md`。
18
+
19
+ - **`scripts/verify-dsml.mjs` 新增 `--through-shim`**:让真上游响应**经过 shim** 再判定,量的才是「客户端最终看到什么」。原有的 `--live` 直连 `client.chatStream`、绕过转换,对已经能恢复的泄漏会给出**「仍在泄漏」的假结论**——而这正是该脚本最不该犯的错。同一次改动里,它的静态探针也从「响应路径是否仍是 `body.pipe(res)`」改为「响应路径是否已被接管」,否则实现之后它会反过来陈述事实。
20
+
21
+ ### Docs
22
+
23
+ - **`docs/DSML-LEAK-RESEARCH.md` 第五节改写**:原口径是「本仓库不实现恢复(只诊断)」,其理由里有两条后来不再成立——声明了哪些工具、必填参数是什么,`prepareChatBody` 早就解析过了;「闭合才提交」由解析器自己判定。该节现在说明定位已改为**诊断 + 恢复**,并指明恢复必须照抄第三、四节那四道闸。
24
+ - 新增 `docs/DSML-RECOVERY-PLAN.md`:移植清单、四道闸落点、改动点、测试矩阵(含「移除任一闸必然变红」的变异清单)与文档口径修正。
25
+ - `THIRD_PARTY_NOTICES.md`:新增 `hawklithm/workbuddy2api` 条目,并**修正第四节**——它原本写「未复制、修改或再分发任何参考项目的源文件」,而本次是**代码移植**,那句话必须如实改掉。
26
+ - 两份 README 的致谢章节补上 `hawklithm/workbuddy2api`,并标明它是名单里**唯一一项代码移植**(发布清单要求致谢章节列出全部参考项目;漏掉它等于把本版的主线改动排除在署名之外)。
27
+ - `docs/WINDOWS.md` §5 登记诊断脚本的跨平台写法:`verify-dsml.mjs` 改用 Node 读源码代替 `grep`,仓库根改用 `fileURLToPath`。
28
+ - 新增 `docs/DSML-RECOVERY-WINDOWS-VERIFY.md`:面向「到 Windows 机器上再验一次」的操作手册,取证点全部是机器可读的,并把两条最容易被误判成失败的事实写在最前面(工具名被写坏的那一类仍以正文显示;模型会摇摆)。
29
+
30
+ ## 3.2.0 (2026-10-01)
31
+
32
+ ### Features
33
+
34
+ - **账号池的开关从此就是「智能路由 + 自动换号」:排序决定每一次请求用谁,失败时依次用池内其他可用账号重试,直到有人接手或全部不可用。**
35
+ - **触发与顺序**:某个请求失败 → 按池子的排序依次取下一个**可用**成员重试,全部失败才把错误报出来,并在错误里注明 `(after trying N accounts)`(不注明的话,「换过号仍然失败」与「你选的账号失败了」在界面上长得一模一样)。
36
+ - **哪些失败会换**:429 限流、402 无积分、401 凭据失效、502 网关/网络——这些都是**某个账号**的状态,换一个很可能就过了。**请求本身不合法(HTTP 400)不换**:同一个请求体换哪个账号都是同样的 400,走完整池只是让用户多等 N 轮才看到同一个错误。**流中途断开也不换**——字节已经发出去了,重放会让客户端看到两段拼接的回答(这条写在 `src/shim.ts` 的重试循环里,是它只覆盖 `!result.ok` 的原因)。
37
+ - **排序决定「谁服务」,不只是「换给谁」**:开启账号池后,**每一次请求**都由池子的排序选出服务账号(可用优先 → 积分高 → 快过期 → 凭据新 → 账号 id)。所以卡片上的「当前使用」是排序的结果,不一定是用户手动选的那个——这也正是「在测试之后有一个智能排序」的意思。
38
+ - 实现上是**每请求一次**选择:shim 在 `resolve()` **之前**先问宿主(`prepareAccount`),宿主用**纯本地数据**排序,把胜者写进 store 的运行时覆盖(`setRotatedAccount`),然后照旧走 `resolve()`。这样 token 刷新仍然只有一处,不会在路由路径上被改写成「发一个过期 token」。
39
+ - **请求路径绝不抓积分**:积分是排序的第二键,但每个成员一次上游调用。所以请求路径只用**新鲜读数**(卡片轮询 / 批量测试 / 保存时读到的那份,10 分钟内有效;由 `poolMembersOf` 顺手写进快照),过期就**跳过这一键**,而不是为了排序去打上游——否则一页对话会变成 N 个额外请求。
40
+ - **从不写回用户的选择**:覆盖只存在于运行时(从不落盘),关掉账号池立即清空、用户选定的账号完全恢复生效。卡片因此必须显示「当前使用」。而账号选择器**只在池关闭时**渲染:池开着时由排序决定,那时候摆一个改了不生效的下拉就是在骗人。
41
+ - **候选走 `credentialFor()` 而不是 `resolve()`**:换号与路由都不改选择状态,只是在运行时覆盖上取凭据。
42
+
43
+ - **跳过已知不可用的成员,但「没测过」算可用**:仍在冷却期内的限流账号、凭据被上游拒绝的账号都不参与(免得花一次往返去重新学到测量结果已经写着的事);**从没测试过的账号默认视为可用**——刚发现的登录不该因为「还没测」就被排除在外。这正是「一键测试」的意义所在:测试结果直接决定谁能被选中。
44
+ - **循环有刹车**:`triedAccountIds` 是唯一的刹车,因此 shim **自己**也会拒绝一个「已经试过的」候选(不只依赖策略层的自觉)——否则一个策略缺陷会变成无上限的重复发送,真实消耗额度。客户端断开后停止重试(`AbortSignal`)。
45
+ - **修掉一个一直存在的缺陷**:`req.on('close')` 无法表达「客户端挂了」——Node 在**请求体读完**时就触发它,所以在 `readBody` 之后才挂的监听**永远不触发**(实测:真实断开后 signal 仍是 un-aborted),而挂在 `readBody` **之前**则会立刻中止每一个请求。改用 `res.on('close')`:正常完成时它发生在 `res.end` 之后(无影响),中途断开时才触发(于是上游调用被真正取消)。这同时让上面那条「断开即停止重试」从「写了但不可能生效」变成真的生效。
46
+
47
+ - **删除「按积分自动轮换」与「自动测试间隔」两个设置项,以及它们背后的机制。**
48
+ - 轮换(按「可用性 → 积分 → 可用时间」挑计费账号)与它的运行时覆盖(`setRotatedAccount`/`rotatedAccount`/`rotationGuard`/`createLatestWins` 用法)、自动测试定时器(`setInterval` + `duePoolRegions` + `runScheduledPoolTest` + `poolLastRunMs`)整体移除。设置 schema 少两个字段:读旧配置时它们被静默忽略(zod 的默认 strip 行为),不需要迁移。
49
+ - **为什么不留着作为可选项**:两者与「换号」是同一件事的两种相反做法——轮换是**持久**改选、换号是**按请求**借用。同时存在会让人无法回答「现在到底谁在计费」,而卡片只能显示一个答案。用户裁定:要换号,不要轮换。
50
+ - 顺带清掉随之成为死代码的客户端部分:`rotationLockState`、`rotatedToAccountId` 观测字段与那段「宿主侧轮换记录」的日志逻辑、`intervalEditOnInput`/`parseIntervalInput`/`POOL_INTERVAL_MIN|MAX`、以及两语言里对应的文案键。
51
+
52
+ - **海外区域不再提供签到**(`checkinSupported` 能力位)。
53
+ - 国内版 App 每天有签到奖励,国际区域没有对应的东西——所以海外版的卡片**不渲染**「一键签到所有账号」按钮,也不再渲染每行的「签到」列(空列会暗示「有这个状态、只是没读到」)。
54
+ - 后端同样拒绝该动作(`409 checkin-unsupported`),而不是只把按钮藏起来:否则会留下一个「界面看不见、接口却还能打」的死角。判定放在宿主(`region === 'cn'`)而不是浏览器比较区域名——策略属于宿主,卡片不该知道哪个区域碰巧有这个功能。
55
+
56
+ - **卡片上「单账号时代」的装饰全部退场:账号选择下拉、已登录状态卡、积分面板都不再渲染。**
57
+ - **账号选择下拉**:有了账号池之后它是**自相矛盾**的——池开启时由排序决定谁服务,它改什么都不会生效,却看起来像权威;而它还可能和别处显示的账号不一致(真机截图里就出现过)。
58
+ - **已登录状态卡(账号名 + token 过期)**:多个账号同时登录是常态,用单数说「已登录:A」本身就不成立;而 token 会自动续期,那个到期时间**不是用户能据以行动的东西**。真正出问题时,凭据失效面板会带着补救动作(「切换到可用账号」)说出来。
59
+ - **积分面板与它的单账号签到**:积分已经在账号池的成员表里**逐账号**显示(还带最近到期时间),并且是排序的第二键;而那个签到按钮只对当前账号生效,还**绕过了账号池的勾选**——「本机有登录 ≠ 要拿它去签到和花积分」正是这个池子要守护的同意。
60
+ - **账号选择并没有失去入口**:它搬进了账号池区块,且**只在池关闭时渲染**——那才是「手动模式」,也正是那个下拉唯一说得通的状态(见下方 Bug Fixes)。
61
+ - 随之删除的四个文案键与只服务于这些元素的样式规则一并清理;「切换账号」的代码路径保留(凭据失效时的「切换到可用账号」仍在用它)。
62
+
63
+ - **模型管理移到卡片最后。**
64
+ - 顺序改为:区域标签 → **账号池** → **模型管理**。账号池是日常要碰的东西,不该被一个可折叠的长列表压在下面;模型列表是一次性配置,放最后更顺。(原先夹在中间的积分面板已整体移除,理由见上。)
65
+
66
+ - **「重新检测账号」移到账号池区块里。**
67
+ - 理由:账号池的成员表**就是**现在管理账号的地方,新登录的账号会以「一行待勾选」的形式出现在那里,所以「再看一眼本机登录」属于它旁边,而不是卡片顶部的状态栏。原来它和状态栏里那句「已登录:X」放一起,还会造成一种误导——有账号池之后**多个账号同时登录**是常态,用单数说「已登录」并不准确。
68
+ - **但未登录状态必须保留一个副本**:账号池区块只在「有账号的区域」渲染,而「我刚在桌面端登录了,你再看看」恰恰是**未登录时**才需要的动作。少了这个副本,那条路就没有入口了。守卫里为此专门有一条用例。
69
+
70
+ ### Bug Fixes
71
+
72
+ - **修正:账号池的开关被错误地同时管住了「手动动作」。**
73
+ - 现象:关掉账号池之后,卡片的「一键签到 / 一键测试」都置灰了,而 Host 也会以 `409 pool-disabled` 拒绝同一个请求——于是「关闭」等于「再也没法签到或测试」。
74
+ - 根因:把「插件是否自动路由」与「用户是否可以操作」当成了一个条件。它们是两件事。
75
+ - 修法:开关只保留它在路由上的作用(排序选谁、失败换谁);签到、测试、目标模型选择与勾选在任何状态下都可用。`409 pool-disabled` 这条原因连同它的文案键一并删除。
76
+ - **唯一保留的硬约束**:池里没勾选任何成员时批量动作一律拒绝——空的池绝不退化成「那就对全部执行」。
77
+
78
+ - **修正:关闭账号池后没有任何地方可以选择要用的账号。**
79
+ - 现象:把账号池关掉之后,想换一个服务账号却找不到入口——卡片头部的账号下拉已被移除,而账号池区块也没有提供选择。
80
+ - 根因:删掉那个下拉时只论证了「池开启时它改了不生效」,漏了另一半——**池关闭时它是唯一的选择入口**。于是「关闭」变成了「卡在之前选定的那个账号上」。
81
+ - 修法:把账号选择放进账号池区块,**只在池关闭时渲染**(那就是手动模式),选项来自本区域已检测到的账号,当前生效的那个为选中值;写入走与「切换到可用账号」同一条已验证路径。**池开启时该行不渲染**——那时由排序决定,一个改了不生效的下拉比没有更糟。
82
+ - 顺带给它一个专属 class(`dsm-workbuddy-pool-account-select`):它与「目标测试模型」共用 `pool-select`,导致按类名查询会命中先渲染的那个——这个坑在测试里当场就被踩到了。
83
+
84
+ - **修正:线上碰到的 429 没有留下任何测量,于是下一个请求又会先撞同一个账号。**
85
+ - 现象:某个账号刚被限流,但下一个请求仍然先发给它、撞一次失败、再换号——每个请求都白付一次往返。虽然结果仍然是成功(换号救回来了),但「发现有可用的就继续用它」只对**当前这一次请求**成立。
86
+ - 根因:测量只在**手动「一键测试」**时写盘;聊天路径碰到失败时什么都不记。而排序读的正是这些已存测量,所以一个刚被限流的账号在排序里仍然是「未测试 → 候选」。
87
+ - 修法:失败发生时把它记成测量——新增 `onAccountFailure`(shim → 宿主)与 `recordAccountFailure`(宿主侧落盘),用 `outcomeOfFailure` 归类、用 `cooldownOf` 从上游报文里解析它**自己给出的重置时间**(「将在 … 重置」)。于是该账号在冷却期内被排序排除,下一个请求直接走可用账号。
88
+ - **只对每个失败尝试上报一次,包含最后一次**:没有任何后继可试的那个账号,恰恰是下一个请求最该避开的——只上报「有后继」的那些会漏掉最要紧的一例。
89
+ - **两类刻意不记**:`client`(HTTP 400)——上游拒绝的是**请求本身**,与账号无关,记了会因为一个坏 body 而闲置一个好账号;以及**未被勾选进池**的账号——勾选才是「可以花这个账号的积分」的显式同意,而测量会影响路由。池关闭时也不记:那时没有任何路由在读它。
90
+ - **上报不阻塞重试**:写盘只影响后续请求,而用户正等着这次重试;回调抛错也只记一行 warn,不影响请求本身。
91
+
92
+ - **修正:所有成员都被限流时,卡片仍把其中一个报成「当前使用」。**
93
+ - 现象:池里每个账号都在冷却期内时,卡片上方写「当前使用:A」,而正下方的成员表同时写着「A 被限流」——同一屏自相矛盾,而「池子换到了可用账号」这件事也就无法被验证。
94
+ - 根因:请求与显示混用了同一个答案。没人可用时 `applyPoolSelection` 会清空运行时覆盖(好让**请求**落到你保存的那个账号上),而卡片读的也是 `store.current()`,于是把那个「兜底账号」当成了在服务的账号。
95
+ - 修法:把两个问题分开。请求该去哪就去哪(兜底不变,失败照实报),但**显示**在「当前账号已被测量为不可用」时报「没有可用的账号」——卡片本来就有这个状态,而且那才是事实。不是池成员的账号不算在内:那时池没有意见,就不该替它表态。
96
+
97
+ - **修正:DSML 检测器只认双竖线,对真实泄漏报「一切正常」。**(仓库内诊断工具;**不影响插件行为,也不随 npm 包发布**——`src/markup-diagnosis.ts` 无人 import,不进 `lib/`)
98
+ - 现象:`|DSML|` 的**竖线个数是写法变量,不是 token 的一部分**。一次真实捕获的正文块里
99
+ 五个标记混用三种写法——`<|DSML| validate>`、`<|DSML| parameter name="spec">`、
100
+ `</|DSML| invoke>` 都是单竖线,只有收尾 `</||DSML|| calls>` 是双竖线。
101
+ 只按双竖线匹配,会对一个真的泄漏了四次的会话给出零命中——而「检查永不命中」看起来与
102
+ 「检查通过」一模一样。
103
+ - 修法:新增 `BARS`(1–2 个全角竖线)与 `MARKUP_RE`,定位与计数一律改按它匹配
104
+ (`MARKUP_TOKEN` 保留为双竖线的参考写法);`CALL_ATTEMPT` 除 `invoke name="…"` 外
105
+ 再接受 `parameter name=` 子句——那次泄漏的开头被写坏成 `<|DSML| validate>`(工具名占了
106
+ `invoke name="…"` 的位置),但每个参数子句都完整。
107
+ - 配套:「在行内反引号里引用标记」也计为 `mention`。参数子句既可能是调用、也可能是在**解释**
108
+ 语法,而解释性引用写在反引号里;不区分就会把解释当成事发,数字随即失去意义。
109
+ - 效果(同一份会话记录):判定为真实泄漏 **6 次 / 2 会话 → 12 次 / 4 会话**;另单独计出 13 次
110
+ 「讨论/引用」不计入。`scripts/verify-dsml.mjs` 的抬头同步改正——它此前把「双竖线常量」
111
+ 印成「检测的 token」,与实际匹配口径不符。
112
+ - 外部依据与上游各层修法见 `docs/DSML-LEAK-RESEARCH.md`(含 vLLM 那些**关闭未合并**的
113
+ 尝试,以及急切恢复炸出的三类生产事故)。
114
+
115
+ - **账号池的「保存 / 放弃修改」行动作行改为与模型管理一致:按钮靠右,左侧放「鼓励一下」。**
116
+ - 同一张卡片上两处「提交你的修改」行为长得不一样——模型管理那行是左侧鼓励链接(★)、右侧按钮,
117
+ 账号池这行却是按钮在左、提示在右。同类动作行不该教出两套肌肉记忆。
118
+ - 现在两行同构:左侧鼓励链接、右侧按钮组(放弃修改 → 保存,次要动作在前,与模型行同序);
119
+ 样式也对齐(相同的上边框与间距,`justify-content: space-between` 把按钮顶到右侧)。
120
+ - 该行原有的状态提示(「修改任一设置后即可保存」/「有未保存的修改」)**整句删除**,两行因此完全等形。
121
+ 它本来就是多余的:**保存按钮自身的可用状态**已经区分了这两种情况;而一句只在 dirty 时才出现的文字,会让这行在每次编辑时抖动一下。
122
+ - `WORKBUDDY_GITHUB_URL` 提到 `src/status-paths.ts`:现在两个区块都链接到它,复制一份 URL 就是多一处会忘记同步的地方。
123
+
124
+ - **账号池顶部重新设计:删掉两处重复的说明,按钮收成一行两簇。**
125
+ - **副标题整行删除**(「本区域 N 个账号 · 目标模型 X」):账号数就是成员表的行数,目标模型就是下面
126
+ 那个控件的当前值。首个控件之前先读两行说明,成本不小。
127
+ - **徽章旁那 4 行说明整行删除**:它讲的「失败会借用另一个成员重试」与「启用账号池」自己的说明重叠,
128
+ 而且池关闭时那件事根本不会发生。
129
+ - **目标模型的来源移进「目标测试模型」这一行**(`当前:deepseek-v4.1-flash(你指定)`),
130
+ 而不是像原来那样在副标题与底部各说一遍——信息没丢,但只剩一处。
131
+ - **按钮收成一行两簇**:左簇是「对勾选的账号做的事」(一键签到、一键测试),右簇是「重新发现账号」
132
+ (重新检测账号)。此前重新检测独占一行靠右、签到/测试在下一行靠左,两行两种对齐却没有理由。
133
+ - 随之删除:2 个文案键(`poolSummary`/`poolCurrentHint`,两语言共 4 行)、2 条只服务于它们的样式规则
134
+ 与 1 条已无使用者的 `pool-current` 容器规则;新增 2 个来源文案键。
135
+ - 设计过程的形态校对稿留在 `docs/pool-header-redesign.html`(不进发布包)。
136
+ - **模型区不再可折叠:去掉「点击收起」与那个折叠头。**
137
+ - 原来它是原生 `<details>`:表头可点、带方向指示箭头、中间一行「点击收起 / 点击展开」。
138
+ - 去掉的理由:模型列表正是用户来看的东西,折叠它只会把想看的内容藏起来;而且这让模型区成了卡片上
139
+ **唯一**一个「点一下就能藏起来」的区块,读起来像另一种性质的区块,而不是同一张卡片里的一段。
140
+ - 表头与列表照常渲染,表头仍写明「已启用 N 个」;**未保存标记仍留在表头**(这一条比折叠时代更重要——
141
+ 列表长的时候,用户不必滚到底部的按钮才知道有改动没保存)。
142
+ - 随之删除:3 个文案键(`modelsFoldHint`/`modelsFoldShow`/`modelsFoldHide`,两语言共 6 行)、
143
+ 2 条样式规则、以及表头为 `<details>`/`<summary>` 而写的原生三角抑制、焦点环与手型光标。
144
+ - 「探测过哪些路径」那个折叠列表**不受影响**——它仍是 `<details>`,那是另一件事。
145
+ ### Docs
146
+
147
+ - README 双语与 `docs/ACCOUNT_POOL_PLAN.md` 同步改版说明;`docs/assets/account-pool-demo.html` 演示原型同步(去掉两个开关、改写启用文案、海外版不显示签到)。
148
+
149
+ ## 3.1.0 (2026-10-01)
150
+
151
+ ### Bug Fixes
152
+
153
+ - **修复国际版每一步都 400 / 业务码 11128(`first message is not system prompt`),而国内版正常。**
154
+ - **现象**:用户切到 `workbuddy-global` 后,每一步都失败;同一会话里国内版 18 个 turn 全部成功。响应体里带的是 `displayMsg: "The request was blocked by security policy"`,看起来像风控,实际不是。
155
+ - **根因(真机复现)**:两个网关都要求会话**以 `system` 消息开头**,**国际网关强制、国内网关容忍**——这就是「只有国际版报」的全部原因。而 system 消息会在到达本插件的 shim 之前就消失:`dsh-llm-pi-ai` 把首条 `system` 消息抽成 `Context.systemPrompt`,pi-ai 只在 `if (context.systemPrompt)` 为真时才发它——**空串或空文本是 falsy**,于是它一条 system 都不发,并把 `messages` 里那条 `system` 降级成 `user`。出站因此以 user 打头。
156
+ - **实测矩阵**(真实国际网关,插件真实 shim + adapter 路径):首条 system 文本为空 → 线上 `["user"]` → 11128;`system=""` 且 `messages` 首条是 system → 线上 `["user","user"]` → 11128;system 是空格 `" "` → 线上 `["system","user"]` → 200。**空格能过、空串不能**,正好卡在 pi-ai 的真值判断上。
157
+ - **修法**(两道,都在出站前):
158
+ 1. `src/adapter.ts` 的 `adaptLegacyPiAiContext` 把 `systemPrompt !== undefined` 改成真值判断。空串不再被当作「已经设好了」而提前返回,于是 `messages` 里真实存在的那条 system 会被折回 `systemPrompt`——**把用户真正的提示词救回来**,而不是替换掉。
159
+ 2. 兜底:确实一条 system 都没有时,补一条最小占位(`WORKBUDDY_FALLBACK_SYSTEM_PROMPT`,定义在 `src/upstream.ts`)。同时 `prepareChatBody` 增加 `ensureSystemHead()` 作为最后一道:出站前首条不是 `system` 就补。
160
+ - **占位提示词刻意极短**(`You are a helpful assistant.`):它是最后手段,不是人格设定;编一段长的会悄悄改变所有受影响请求的模型行为。
161
+ - **与探测路径对齐**:`src/probe.ts` 早就知道这条规则(注释写着「探测请求总是带一条 system 消息,两个网关都接受」),但聊天这条路一直没有这道防护——本次补齐。
162
+ - **守卫**:`tests/adapter.spec.ts` 新增「空 `systemPrompt` 不吞掉真实 system 文本」与「无 system 时补占位」两例,并**修正了一条编码旧契约的用例**(原先断言「没有 system 消息时返回同一个对象」,而这正是必须改动的输入);`tests/upstream.spec.ts` 新增三例:user 打头时补占位、已是 system 打头时不重复补、无历史时一条都不补。
163
+ - **未验证**:生产宿主里究竟是哪条路径进入该分支(空文本被丢弃,还是 `options.system=''` 导致降级)——两者都被兜底覆盖,且都已在真实国际网关上端到端验证返回 200。
164
+
165
+ - **修复 issue #26(以及 #25 的完整根因):3.0.2 的 `adaptLegacyPiAiContext` 折叠会丢掉 system prompt 与全部工具声明——上一条只治了 400 症状,本条治根。**
166
+ - **现象(#26)**:升级 3.0.2 后 desktop profile 下所有 WorkBuddy 模型都无法调用工具:模型不产生 `tool_calls`,而是在正文里用 Markdown 代码块**编造**工具调用与"执行结果",`stopReason` 恒为 `stop`(报告者实测 15 个请求 `calls=0`;编造的工具名是 `Bash` 而非 DSH 真实的 `pwsh`——连工具清单都没收到)。国际版则先撞上网关校验表现为 #25 的硬 400。
167
+ - **根因(逐条对照两版 pi-ai 源码核实)**:pi-ai 0.87 的 `normalizeContext` 把 system prompt 与工具声明**只**放在 `messages` 首条 system 消息上(`content` 与 `toolsAdded`),顶层 `systemPrompt`/`tools` 字段就此消失;而 0.87 的 `openai-completions` **全文没有一处读顶层 `systemPrompt`**,工具只从首条 system 消息的 `toolsAdded` 取(宿主 asar 里的 0.87.1 已逐行核实)。3.0.2 的 `withLegacyContext` 按 **context 形状**判断"要修",把首条 system 消息删掉、文本折回顶层——恰好删掉了两个代次 api 各自唯一的载体:
168
+ - 插件解析到 **0.87**(干净安装,无磁盘 0.85 副本,#26 报告者的机器):顶层 `systemPrompt` 没人读 → **提示词丢**;`toolsAdded` 被删 → **工具丢** → 模型编造工具调用;
169
+ - 插件解析到 **0.85**(本机:`~/.dsh/profiles/desktop` 里的安装是指向本仓库的符号链接,嵌套 dev 依赖 0.85.1,而宿主 asar 是 0.87.1):折回的文本 0.85 认,但折叠**只搬文本不搬工具**——0.85 只读顶层 `context.tools`,而 `toolsAdded` 被丢弃 → **工具同样丢**。两个方向都是错的,区别只是症状。
170
+ - **为什么不能照 #26 的"最小修复"直接删掉 wrapper**:本机插件解析的正是 0.85.1,宿主却用 0.87.1 归一化 context——删掉 wrapper 会立刻复活 issue #24 的崩溃(0.85 的 `estimateMessageTokens` 对字符串 content 的 system 消息抛 `Cannot read properties of undefined (reading 'length')`,已在本机复现为流内 error、请求未发出)。报告者机器与作者机器**环境互补**,任何基于单一环境的修法都会弄坏另一台。
171
+ - **修法(按代次门控,而不是按 context 形状猜)**:新增 `src/pi-ai-runtime.ts`,判定**插件自己解析到的那份 pi-ai**:
172
+ 1. **modern(0.87+)**:`withLegacyContext` 原样返回 api,context **字节级透传**——0.87 自己就能消费归一化 transcript(#26 的复现脚本证明了这条路 tools 与 prompt 都在);
173
+ 2. **legacy(0.85)**:折叠照旧,但**把工具状态一并提升**——按 0.87 自己 `getCurrentTools` 的语义重放每条 system 消息的 `toolsRemoved`/`toolsAdded`(先删后加、按名去重),写回顶层 `context.tools`(顶层已有 `tools` 数组时不覆盖)。
174
+ - **检测刻意不 import pi-ai 本体**:用 `import.meta.resolve` 定位入口文件(不执行模块)+ 读取**所属 manifest 的版本号**判定代次(0.87+ → modern;peer 范围内版本与 `normalizeContext` 特征一致)。原因是 standalone CLI 必须在**市场安装**(本地无 pi-ai 副本、模块只由宿主提供)下也能跑 `doctor`——静态 import 会让它在加载期就 `ERR_MODULE_NOT_FOUND`(已用 3.0.2 官方包在裸目录复现同一现象:缺 peer 时谁都跑不起来,本次新增的这条依赖则被彻底避免)。
175
+ - **解析失败 = 宿主提供**(市场安装的常态):此时默认走 modern 透传,因为**会归一化 context 的宿主(0.2.0+)都提供 0.87.1**;该默认对旧宿主同样安全——旧宿主从不归一化,原生 0.85 形状在两个分支下都是恒等。
176
+ - **实测(本机 0.85.1,真实 api + fetch 注入捕获出站 body)**:修复前有 wrapper → `tools = MISSING`、system 文本在;修复后 → **`tools = 1`(pwsh)、首条 `system` 消息文本完整**。无 wrapper 的对照 → system 也丢、且 #24 崩溃以流内 error 出现。
177
+ - **doctor 可见性(#26 的附加建议)**:`doctor` 输出新增 `piAiRuntime` 块(generation / version / resolvedFrom / **resolvedLocally** / adapter 行为说明),人类可读行同步打印;legacy 时追加 hint 提示"插件解析到 0.85 而 DSH 0.2.0+ 宿主用 0.87 归一化,适配器在桥接,若工具异常请清理嵌套 pi-ai 副本"。JSON schemaVersion 2 → 3。**两种环境的真实输出已分别实测**:仓库检出(嵌套 0.85.1)→ `legacy 0.85.1 (<pnpm 真实路径>) — folds…`;市场式安装(有 peer、无 pi-ai)→ `modern, host-provided (no local copy; DSH 0.2.0+ ships 0.87.1) — passes…`。
178
+ - **守卫**:接线用例从一条拆成**三条**——legacy 折叠并提升工具(含 `tools` 断言)、modern **同一对象引用**透传(`toBe`,最强度断言)、默认跟随实际解析代次;新增 `tests/adapter-outgoing-body.spec.ts` 按 #26 建议的测法**直接断言出站请求体**(`body.tools` 与首条 system 文本,并在失败信息里带出流内错误);新增 `tests/pi-ai-runtime.spec.ts` 四例钉住检测自洽(generation 与模块特征一致、路径与版本来自同一份 manifest、结果缓存稳定)——**不钉具体版本号**,peer 范围内换版本时检测必须跟着走而不是被测试冻住。
179
+ - **对上一条(#25 修复)的关系**:`ensureSystemHead`/兜底占位保留——它们是 wire 层最后防线(空文本 system、`options.system=''` 等退化输入仍需兜底),但 400 消失后若不修本条,国际版会立刻从"报错"变成"不报错但没工具"(#26 原文预测,本机复现坐实)。
180
+ - **跨机真机验证(发布前)**:Windows 11 与 macOS 两台机器、国内版与国际版两个区域,均完成真实工具调用(`pwsh` 执行 `echo`,输出逐字一致;模型列出的工具名是 DSH 真名而非编造的 `Bash`)。Windows 那台先复现了 #25 的 400 报文与 #26 的「我没有可用的 shell 工具」,部署修复后同一路径通过——红绿对照。存档见 `docs/ISSUE-25-26-WINDOWS-VERIFY.md`。
181
+ - **本次未覆盖**:两台机器都是 junction 到仓库本体(解析到嵌套 0.85.1)→ 都走 legacy 桥接分支;**modern(市场安装、宿主提供 pi-ai)分支尚无真机端到端观测**,仅有单元测试的恒等断言与模拟安装树上的 `doctor` 选中验证。
182
+
183
+ ### Features
184
+
185
+ - **模型区可折叠**:模型列表现在折在标题行下面(标题、已启用数量、「从 WorkBuddy 刷新」按钮留在外面),点标题行即可收起,把下方的账号池一次露出来。用原生 `<details>` 实现,因此键盘可达、助记语义正确,且不引入任何状态库。
186
+ - **标题左侧有折叠箭头**:收起时朝右、展开时旋转 90° 朝下(0.16s 过渡,与卡片既有过渡一致)。**原生三角被隐藏、改为自绘**——否则会出现两个方向相反的状态指示器。旋转由 `details[open]` 驱动,不需要额外状态,也就不可能与实际显示不一致;箭头本身是纯装饰(`<details>` 已向助记技术播报状态),故标记 `aria-hidden`。
187
+ - **默认展开**:这是用户原本就看得到的内容,默认折起来会让人以为它没了。
188
+ - **折叠状态不持久化**:卡片只挂载当前 tab,所以「记住」需要新增一个设置字段——一个纯外观偏好不值得。切 tab 或重开卡片会回到默认。
189
+ - **折叠态标题行会显示「有未保存的修改」**:保存/放弃按钮在折叠区里面,且只在 `dirty` 时可点;没有这个提示,用户勾了几个模型再折起来,就把自己的未保存改动藏在了折叠后面而界面上一言不发。
190
+ - **标题行中间加了一句淡色动作提示**(「点击展开」/「点击收起」):只有箭头时,「这里能点」这件事不够明确。提示说的是**此刻能做的那个动作**,而不是固定的「展开或收起」——后者会让读者自己去想该按哪个,正是这句提示要消除的疑问。它用与标题下方摘要行相同的淡色 token,读起来是提示而不是第二个标题;窄屏(≤760px)下隐藏,因为箭头已足以表明这是折叠控件,先让位给标题与按钮。
191
+ - **刷新按钮不会误触发折叠**:按钮位于 `<summary>` 内,而点击 summary 内任何位置都会激活这个 disclosure——所以 summary 的 `onClick` 对落在操作区的点击调用了 `preventDefault()`,否则「刷新模型」会顺手把列表收起来。这条有专门用例钉住。
192
+
3
193
  ## 3.0.2 (2026-09-30)
4
194
 
5
195
  ### Bug Fixes
package/README.en.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h1 align="center">dsh-connect-workbuddy</h1>
6
6
 
7
- <p align="center"><b>Connect locally signed-in WorkBuddy models to DeepSeek Harness, with a read-only credits overview and model management.</b></p>
7
+ <p align="center"><b>Connect locally signed-in WorkBuddy models to DeepSeek Harness, with an account pool and model management.</b></p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/dsh-connect-workbuddy"><img src="https://img.shields.io/npm/v/dsh-connect-workbuddy?style=flat-square&label=npm&color=cb3837" alt="npm version"></a>
@@ -17,21 +17,21 @@
17
17
 
18
18
  [English](README.en.md) | 中文
19
19
 
20
- A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bundle plugin that connects locally signed-in WorkBuddy models to the DSH model picker — **both the CN app and the international WorkBuddy AI app are supported** — with a read-only credits overview and selectable model management. **The CN and international sides are two parallel providers (`workbuddy` / `workbuddy-global`) that can be used at the same time**; the settings card separates them with tabs for convenient management.
20
+ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bundle plugin that connects locally signed-in WorkBuddy models to the DSH model picker — **both the CN app and the international WorkBuddy AI app are supported** — with an account pool and selectable model management. **The CN and international sides are two parallel providers (`workbuddy` / `workbuddy-global`) that can be used at the same time**; the settings card separates them with tabs for convenient management.
21
21
 
22
22
  ## Features
23
23
 
24
- - **Dual parallel providers (CN + international)** — the CN side registers as the `workbuddy` provider (`GLM-5.3`, `DeepSeek-V4-Pro`, `Kimi-K3`, `MiniMax-M3`, `Hy3`, etc.), the international side as `workbuddy-global` (`GPT-5.6`, `Gemini-3.5-Flash`, `GLM-5.3`, `Kimi-K3`, etc.). **A region's models appear in the DSH model picker when that region has an account**: with accounts on both sides, both rosters are selectable at once, and different sessions can each pick a side without interfering. **A region with no local sign-in lists no models** — it cannot serve a single request, so listing them would offer nothing but failures. Sign in once for that region and its models come back on "detect accounts again" or after a restart; the provider itself stays registered either way, because its settings card and account picker are how you sign in. Model names carry the upstream credit multiplier (e.g. `GLM-5.3 · x0.79`), matching WorkBuddy's own model menu.
25
- - **Tabbed settings card** — the card's top carries a "Domestic / Global" tab bar; each tab holds its own account picker, credits overview, and model management. Account, directory, selection, and unsaved drafts are fully isolated per tab — switching accounts or refreshing models on one tab never touches the other side's runtime catalog or sessions.
24
+ - **Dual parallel providers (CN + international)** — the CN side registers as the `workbuddy` provider (`GLM-5.3`, `DeepSeek-V4-Pro`, `Kimi-K3`, `MiniMax-M3`, `Hy3`, etc.), the international side as `workbuddy-global` (`GPT-5.6`, `Gemini-3.5-Flash`, `GLM-5.3`, `Kimi-K3`, etc.). **A region's models appear in the DSH model picker when that region has an account**: with accounts on both sides, both rosters are selectable at once, and different sessions can each pick a side without interfering. **A region with no local sign-in lists no models** — it cannot serve a single request, so listing them would offer nothing but failures. Sign in once for that region and its models come back on "detect accounts again" or after a restart; the provider itself stays registered either way, because its settings card is how you sign in (re-detection lives in the pool block). Model names carry the upstream credit multiplier (e.g. `GLM-5.3 · x0.79`), matching WorkBuddy's own model menu.
25
+ - **Tabbed settings card** — the card's top carries a "Domestic / Global" tab bar; each tab holds its own account pool and model management. Account, directory, selection, and unsaved drafts are fully isolated per tab — switching accounts or refreshing models on one tab never touches the other side's runtime catalog or sessions.
26
26
  - **Switch either provider off on its own** — each tab carries an on/off checkbox (both checked by default). Unchecking one **withdraws that provider from DSH's model picker entirely** (it is not merely a hidden tab): its adapter route and its "Settings → Models" directory entry are both pulled, and startup no longer sends pointless requests for it. Account, credits, directory, and selection are all kept, and re-checking restores everything. The typical use is "I have no use for the international side" — switch it off and keep the picker clean. Note: a session that had already selected a model from the switched-off provider will report `NO_ADAPTER` on its next call; the card states this plainly on the switched-off tab instead of failing silently.
27
27
  - **Model management** — refresh the full catalog from upstream and enable or disable each model individually; refresh is a draft operation that only takes effect on save. Upstream also reports credit multiplier, context/output limits, and reasoning efforts; **image input is decided by a vendor-verified table**: a refresh pre-checks only models the vendor's own documentation calls natively multimodal, leaves documented text-only models unchecked, and treats anything without a vendor source as unverified and unchecked rather than guessing. Upstream's `supportsImages` is a *platform* declaration, not the model's capability, so it does not drive that decision at all. You can still tick any box by hand, and your selection is kept locally.
28
28
  - **Model availability testing, including cooldown recovery times** — working out when a limited model frees up used to mean guessing through repeated failures. Now every model row has a **Test** button that gives you a direct answer for whether the model works right now and, if not, when it comes back. **It sends a real-volume request (about 25k input tokens)**, rather than "ping it and see if it answers" — because the upstream throttles on request **SIZE** (measured: about 20k tokens pass, about 30k are refused), so a small request looks fine while every request in a long conversation may be refused. That is exactly where "the test passes but I am limited" came from. **Where the recovery time comes from**: the upstream's 429 carries **no** `Retry-After` header (and no `X-RateLimit-*`), but it writes the reset time into the response **body** — `{"code":6004,"msg":"…将在 2026-09-30 02:30:30 UTC+8 重置…"}`. The plugin parses that time and converts it using the UTC offset the message states, so the result does not depend on your machine's timezone; the card then shows “✗ Rate limited · usable again after 2026/9/30 02:30:30”. When none of the sources states a time, the plugin **never invents a countdown** and says plainly that no time was given. A rate limit deliberately does **not** borrow the monthly refresh time, which would tell you to wait weeks for a limit that clears in seconds. Probes run in the Host, so **the credential never reaches the page**, and results are not written to settings — your saved selection is untouched.
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
+ - **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).
33
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.
34
- - **Read-only credits overview** — remaining credit aggregated per package, plus each model's credit multiplier. Queries consume no credits.
34
+ - **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.
35
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.
36
36
  - **Secure loopback shim** — one random port + in-process random secret per region; the real WorkBuddy token is never handed to pi-ai.
37
37
  - **Command-line diagnostics** — `status` / `doctor` / `logout`, reporting sign-in and credits per region, without a browser.
@@ -59,12 +59,12 @@ Credentials are read (read-only) from the WorkBuddy desktop app's own auth file.
59
59
  ## Account pool
60
60
 
61
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" />
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 automatic failover" />
63
63
  </p>
64
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.
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**; switch on **Enable the account pool** and the plugin will carry on through a failure by itself.
66
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.
67
+ **Each region (domestic / international) has its own independent pool** — membership and target model never affect the other side, because the two sides' accounts belong to different upstream stacks.
68
68
 
69
69
  ### The two batch actions
70
70
 
@@ -81,25 +81,47 @@ A machine usually carries more than one WorkBuddy sign-in. The **account pool**
81
81
 
82
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
83
 
84
- ### Rotation by credits: turning "pick an account by balance" into an explicit grant
84
+ ### Automatic routing and failover: what "Enable the account pool" turns on
85
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**:
86
+ This plugin's standing rule is that it **never rewrites who pays** — your saved choice is yours. What the switch changes is **who serves**, decided by the pool's ranking, request by request:
87
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.
88
+ 1. **The ranking picks the serving account for every request** — so the card's "in use" account is the ranking's answer, which is not necessarily the one you picked above it. The ranking is below.
89
+ 2. **On a failure the next usable member is tried**, then the next, until one serves it or none is left. Only then is the error reported — annotated with how many accounts were tried, so a pool that failed over and still lost does not read like a single account failing.
90
+ 3. **Your saved selection is never rewritten.** Turning the pool off restores it immediately and completely (the pool steers through a runtime override, never through the settings). That is also why the card states which account is actually in use: it may be a different one, and you should be able to see that.
91
+ 4. **Which failures trigger a retry:** 429 rate limits, 402 exhausted credits, 401 dead sessions, 502 gateway/network errors — all of these describe **one account's** state, and another may well survive them.
92
+ 5. **Which do not:** a malformed request (HTTP 400). Every account answers the same 400, so walking the pool would only multiply the wait before the user sees an error they must act on anyway. A stream that dies **mid-flight** is not retried either — bytes are already on the wire, and replaying would splice two answers together.
93
+ 6. **Members already known to be unusable are skipped** — a rate-limited account still inside its stated cooldown, or one whose credential the upstream rejected. **An account that was never tested counts as usable**: a freshly discovered sign-in must not be invisible just because nobody has measured it yet.
94
+ 7. **A live failure is RECORDED as a measurement** (with the reset time the upstream stated), so the NEXT request starts from an available account instead of re-hitting the account that just refused it. Before this, only the manual batch test wrote measurements — so a just-limited account still read as untested and kept being picked first. HTTP 400 is the exception: that is the REQUEST being rejected, which says nothing about the account, and recording it would sideline a good one. Accounts not checked into the pool are not measured either, since the tick is what grants "you may spend this account's credits".
95
+ 7. Every retry leaves a `warn` line in the host log naming the failure and the account that follows.
91
96
 
92
- **Every rotation leaves a line in the card** (when, and from whom to whom) rather than happening silently.
97
+ ### What the switch actually controls
93
98
 
94
- ### The automatic test interval
99
+ **It controls whether the PLUGIN decides by itself — not whether YOU may act.**
95
100
 
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.
101
+ | | Pool **on** | Pool **off** |
102
+ |---|---|---|
103
+ | Who serves | Chosen **automatically** by the ranking (usable → credits → soonest-expiring → freshest credential) | The account you selected; the plugin does not choose |
104
+ | On a failure | **Automatically** retried on the next usable account | Reported as-is, no retry |
105
+ | Check-in all / test all | Available | **Still available** |
106
+ | Target test model | Editable | **Still editable** |
97
107
 
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.
108
+ The last two rows are deliberate: check-in and testing are MANUAL actions over the members you ticked, which is a different question from "does the plugin route automatically". Gating them on the switch meant switching the pool off ALSO removed the ability to check in or test — while "off" should mean "I'll do it by hand", not "nothing is allowed".
109
+
110
+ **The one hard rule**: with nothing checked in the pool, batch actions are refused. An empty pool **never** degrades into "then run on every account" — that is the one reading which would spend credits you never authorized.
111
+
112
+ **Where manual selection lives**: in the account pool section. **With the pool off** that section shows an "Account in use" dropdown; picking one takes effect immediately (it writes the region's account slot through the same verified path the credential-recovery "switch to a usable account" action uses). **With the pool on the row is absent** — the ranking decides there, and a dropdown that changes nothing is worse than no dropdown. Account discovery ("detect accounts again"), membership, check-in and testing all sit in that one section, so accounts are managed in exactly one place.
113
+
114
+ ### The ranking (what decides who serves)
115
+
116
+ 1. **Usable first** — an account a measurement rules out (limited with its cooldown unexpired, credential rejected, upstream unreachable) sorts last and is not chosen;
117
+ 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;
118
+ 3. **Soonest-expiring credits first** — spend what lapses in three days before it is lost;
119
+ 4. **Freshest credential first**;
120
+ 5. The account id closes the order, making it **total** and reproducible rather than jittering with the roster's order.
99
121
 
100
122
  ### Why an account leaves the pool, and when it comes back
101
123
 
102
- | Probe outcome | What it means for rotation |
124
+ | Probe outcome | What it means for failover |
103
125
  | --- | --- |
104
126
  | Usable | Eligible |
105
127
  | 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**) |
@@ -108,11 +130,17 @@ A timer ticks every 60 seconds and runs a "test all accounts" pass on your inter
108
130
 
109
131
  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
132
 
133
+ ### Manual selection: there is no account dropdown any more
134
+
135
+ Accounts are managed **in the account pool**: membership checkboxes are the selection surface, and re-detection lives there too. The old "account" dropdown had become self-contradictory once the pool existed — with the pool on, the ranking decides who serves, so changing it did nothing yet looked authoritative, and the status line above could name one account while the dropdown showed another.
136
+
137
+ The region status line changed with it: **while the pool is on it does not name a single account** (several signed in at once is the normal case, so "Signed in: A" was simply the wrong claim) — it states that the region is usable and that the pool picks. **While the pool is off it does name the account**, because then it really is the only answer. The dot and the token expiry beside it are about "can this region be used", which is a different question from "who is being billed" — the pool block answers that one.
138
+
111
139
  ### Saving and drafts: why the card has two Save buttons
112
140
 
113
141
  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
142
 
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**.
143
+ One class of state in the pool **does not go through a draft**: probe results, cooldowns, and the account that served a request. 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
144
 
117
145
  ## Install
118
146
 
@@ -240,6 +268,7 @@ This project builds on work others have published. Sources and licenses are cred
240
268
 
241
269
  - [corrinehu/dsh-workbuddy-connect](https://github.com/corrinehu/dsh-workbuddy-connect) (MIT, Copyright (c) 2026 Corrine Hu) — **the principal reference for this project.** That project first validated a working WorkBuddy-to-DSH integration: desktop credential discovery and refresh, the upstream protocol and header conventions, loopback shim hardening, pi-ai provider assembly, and the status CLI were all studied from it. This project reimplements those proven capabilities while focusing on improving the experience.
242
270
  - [Sliverkiss/workbuddy2api](https://github.com/Sliverkiss/workbuddy2api) (MIT) — reference implementation of the WorkBuddy upstream protocol (the `copilot.tencent.com` wire behavior), referenced via `dsh-workbuddy-connect`.
271
+ - [hawklithm/workbuddy2api](https://github.com/hawklithm/workbuddy2api) (MIT, Copyright (c) 2026 Mayer) — **the only entry in this list that is a CODE PORT rather than a design reference**: its `src/codebuddy_proxy/dsml_parser.py` is a DSML tool-call parser for a proxy in front of the same WorkBuddy/CodeBuddy upstream, and this project ported it to `src/dsml-recovery.ts` (the tag state machine, ignored-region detection, missing-wrapper repair, recursive parameter parsing, and the streaming buffer), adding the two gates it does not have (a tool name must be declared in THIS request; no declared tools means no recovery at all). The three deliberate deviations are listed in that file's header; the formal attribution is in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
243
272
 
244
273
  ### Baseline for the plugin presentation and structure
245
274
 
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h1 align="center">dsh-connect-workbuddy</h1>
6
6
 
7
- <p align="center"><b>把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。</b></p>
7
+ <p align="center"><b>把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供账号池与模型管理。</b></p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="README.en.md">English</a> ·
@@ -24,21 +24,21 @@
24
24
  <a href="https://dshfind.com/plugins/dingminhua/dsh-connect-workbuddy"><img src="https://dshfind.com/api/badge/dingminhua/dsh-connect-workbuddy" alt="dshfind plugin"></a>
25
25
  </p>
26
26
 
27
- 一个独立的 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bundle 插件。它把本机已登录的 WorkBuddy 账号(**国内版与国际版 WorkBuddy AI 均支持**)接到 DSH 的模型选择器,同时提供**只读**的积分概览与可勾选的模型管理界面。**国内版与国际版是两个并行的供应商(`workbuddy` / `workbuddy-global`),可同时使用**;插件设置卡片以 tab 区分两者,方便统一管理。
27
+ 一个独立的 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bundle 插件。它把本机已登录的 WorkBuddy 账号(**国内版与国际版 WorkBuddy AI 均支持**)接到 DSH 的模型选择器,同时提供账号池与可勾选的模型管理界面。**国内版与国际版是两个并行的供应商(`workbuddy` / `workbuddy-global`),可同时使用**;插件设置卡片以 tab 区分两者,方便统一管理。
28
28
 
29
29
  ## 功能特性
30
30
 
31
- - **双供应商并行接入(国内版 + 国际版)** —— 国内版注册为 `workbuddy` provider(`GLM-5.3`、`DeepSeek-V4-Pro`、`Kimi-K3`、`MiniMax-M3`、`Hy3` 等),国际版注册为 `workbuddy-global` provider(`GPT-5.6`、`Gemini-3.5-Flash`、`GLM-5.3`、`Kimi-K3` 等)。**有账号的区域其模型会出现在 DSH 模型选择器里**:两边都有账号时两组同时可选,不同会话可以各选一边,互不干扰。**本机没有账号的区域不列出任何模型**(该区域一个请求也发不出去,列出来只会点到就失败);只要在该区域登录一次,「重新检测账号」或重启后模型就会自动出现——provider 本身始终注册着,因为它的设置卡片与账号选择器正是登录的入口。模型名内嵌上游积分倍率(如 `GLM-5.3 · x0.79`),与 WorkBuddy 自身模型菜单一致。
32
- - **插件卡片 tab 切换** —— 设置卡片顶部为「国内版 / 国际版」两个 tab,各含独立的账号选择、积分概览与模型管理;每个 tab 的账号、目录、勾选与未保存草稿完全隔离——在一个 tab 里切账号或刷新模型,不会触碰另一边的运行时目录与会话。
31
+ - **双供应商并行接入(国内版 + 国际版)** —— 国内版注册为 `workbuddy` provider(`GLM-5.3`、`DeepSeek-V4-Pro`、`Kimi-K3`、`MiniMax-M3`、`Hy3` 等),国际版注册为 `workbuddy-global` provider(`GPT-5.6`、`Gemini-3.5-Flash`、`GLM-5.3`、`Kimi-K3` 等)。**有账号的区域其模型会出现在 DSH 模型选择器里**:两边都有账号时两组同时可选,不同会话可以各选一边,互不干扰。**本机没有账号的区域不列出任何模型**(该区域一个请求也发不出去,列出来只会点到就失败);只要在该区域登录一次,「重新检测账号」或重启后模型就会自动出现——provider 本身始终注册着,因为它的设置卡片正是登录的入口(重新检测在账号池区块里)。模型名内嵌上游积分倍率(如 `GLM-5.3 · x0.79`),与 WorkBuddy 自身模型菜单一致。
32
+ - **插件卡片 tab 切换** —— 设置卡片顶部为「国内版 / 国际版」两个 tab,各含独立的账号池与模型管理;每个 tab 的账号、目录、勾选与未保存草稿完全隔离——在一个 tab 里切账号或刷新模型,不会触碰另一边的运行时目录与会话。
33
33
  - **可单独关闭任一版本供应商** —— 每个 tab 右侧有一个勾选框(默认都勾选)。**取消勾选即把该供应商从 DSH 模型选择器里彻底撤掉**(不是只藏起 tab):它的路由与「设置 → 模型」页的目录条目一并摘除,启动时也不再为它发无用请求。账号、积分、目录与勾选**全部保留**,重新勾选即完整恢复。典型用法是「我根本用不到国际版」——关掉它,让选择器干净。注意:若某会话此前选中的正是被关闭供应商的模型,该会话再调用会报 `NO_ADAPTER`,卡片会在关闭态的 tab 上明确提示这一点,不会静默失败。
34
34
  - **模型管理** —— 从上游刷新完整模型目录,逐项勾选启用或禁用;刷新是草稿操作,点保存才生效。上游同时给出积分倍率、上下文/输出上限与推理档位;**图片输入按厂商核验表判定**:刷新时只有厂商文档明确写为原生多模态的模型才自动勾选,文档写明纯文本的不勾,**没有厂商资料的一律按「未核实」不猜**(上游的 `supportsImages` 是平台侧声明,不等于模型能看图,不参与判定)。用户仍可随时手动勾选,勾选状态保存在本地。
35
35
  - **模型可用性测试(含冷却恢复时间)** —— 限量模型的冷却时间过去只能靠反复试错去猜。现在每个模型行有一个「测试」按钮,直接告诉你它此刻能否使用、不能用时何时恢复。**它发的是一条真实体积的请求(约 25k 输入 token)**,而不是「发个 ping 看通不通」——因为上游的限流**按请求体积触发**(实测约 20k 能过、约 30k 被拒),小请求看着正常、长对话里却可能每次都被拒,那正是「测试通过、实际被限」的来源。**恢复时间的来源**:上游的 429 **不带** `Retry-After` 响应头(也没有 `X-RateLimit-*`),但它把重置时间写在**响应体**里——`{"code":6004,"msg":"…将在 2026-09-30 02:30:30 UTC+8 重置…"}`。插件从响应体解析该时间(并按其声明的 UTC 偏移换算,不受你本机时区影响),因此卡片能直接显示「✗ 被限流 · 2026/9/30 02:30:30 之后可再用」。三处来源都没说时,插件**不编造倒计时**,如实显示「上游未给出何时恢复」。限流**不会**去借月周期包的刷新时间(那会让人为几秒的限流去等几周)。探测在后台发起,**凭据不会传到页面**;结果不写入设置,也不改变你已保存的勾选。
36
36
 
37
37
  **两点需要知道**:① **它真的花钱**——实测一次报 `credit` 0.02(被限流时直接 429,几乎不花)。因此**只能按单个模型手工点**,没有「测试已勾选」这类批量按钮:一次覆盖整个列表就是一次不可控支出(服务端也据此**只接受单个模型**,多模型请求会被拒)。② 上游虽宣称 1M 上下文,但节流线远低于此;插件向 DSH 宣称的窗口最高 200K,所以 DSH 很晚才压缩对话——长会话撞限流是这条机制的直接结果。
38
38
  - **本机账号切换** —— 自动发现 WorkBuddy 桌面端留下的多个登录凭据,可按区域切换账号;Token 不写入 DSH 设置。
39
- - **账号池:多账号批量操作与自动轮换** —— 把要用的账号勾进池子,即可**一键签到所有账号**、**一键测试所有账号 × 你指定的模型**(默认挑该区域倍率为 0 的免费模型),需要时再打开**按积分自动轮换**让插件在池内自行挑一个计费账号。成员**默认一个都不含**、必须显式勾选(「本机有登录」不等于「要拿它去签到和花积分」);自动轮换与手动选择**互斥**并就地说明原因;轮换只决定运行时用谁,**永不写回你的手动选择**,关掉开关即原样恢复。测试走的是与模型行同一套真实体积探测,因此**有真实成本**;**不提供自动签到**——签到会真实领取奖励,只在你点按钮时执行。详见[账号池](#账号池)。
39
+ - **账号池:多账号批量操作与自动换号** —— 把要用的账号勾进池子,即可**一键签到所有账号**、**一键测试所有账号 × 你指定的模型**(默认挑该区域倍率为 0 的免费模型);开启**启用账号池**即同时打开**自动换号**——某个请求在上游失败时,插件会依次用池内其他可用账号重试,直到有一个接手、或全部不可用为止,然后才把错误报给你。成员**默认一个都不含**、必须显式勾选(「本机有登录」不等于「要拿它去签到和花积分」);**开启后由池子的排序决定每一次请求用谁**(可用优先,其次积分高、快过期的积分包、凭据更新的);失败时依次用池内其他可用账号重试。排序**从不写回你保存的选择**——关掉开关,你选定的账号立刻完全生效。只有请求本身不合法(HTTP 400)不换号:同一个请求体换谁都是 400,重试只会白等。**这个开关只管「插件要不要自己决定」**:关掉它,插件不再替你选账号、也不再替你换号,但**签到与测试仍然可用**(它们是手动动作)。测试走的是与模型行同一套真实体积探测,因此**有真实成本**;**不提供自动签到**——签到会真实领取奖励,只在你点按钮时执行。详见[账号池](#账号池)。
40
40
  - **凭据失效时给出可执行的建议** —— 上游拒绝当前账号的 token 时,插件会**实际探测**本机其他账号:若有账号仍然可用,就直接告诉你「切换到它即可」(并附一键切换),而不是笼统地让你重新登录——被钉在一个作废的旧备份上时,重新登录并不能解决问题。只有本机确实没有别的账号可切换时,才会提示重新登录。
41
- - **只读积分概览** —— 按套餐聚合展示剩余积分,并列出每个模型的积分倍率。查询不消耗积分。
41
+ - **积分信息在账号池里** —— 成员表逐账号显示剩余积分与最近到期时间,排序的第二键就是积分(谁多谁优先)。查询是**只读**的、不消耗积分。
42
42
  - **凭据路径多候选** —— macOS / Windows / Linux 逐一探测默认位置,也支持环境变量与卡片内直接指定。**未登录时卡片会列出「探测过哪些路径、各自为什么没成」**:五档原因(不存在 / 读不了 / 没有可用 token / 字段已加密且拿不到密钥 / 属于另一个地区)分开呈现,因此「已经登录了、但桌面 App 不在场」这种最容易误判的情况会明说需要 App 在场,而不是叫你重新登录一次。**属于另一个地区**那一档尤其不同:登录信息是真实可用的,只是挂在另一个标签页下,卡片会直接给出结论「切换标签页即可,不用重新登录」。列表只讲一次——提示段落说结论,折叠列表给路径细节,两处不重复同一件事。
43
43
  - **安全 loopback shim** —— 每区域一个随机端口 + 进程内随机 secret,真实 WorkBuddy token 不交给 pi-ai。
44
44
  - **命令行诊断** —— `status` / `doctor` / `logout`,按区域报告登录与积分,无需浏览器即可确认宿主状态。
@@ -66,12 +66,48 @@ DSH PiAiAdapter(每个 provider 一套)
66
66
  ## 账号池
67
67
 
68
68
  <p align="center">
69
- <img src="docs/assets/dsh-connect-workbuddy-account-pool.png" width="900" alt="dsh-connect-workbuddy 账号池区块:一键签到所有账号、一键测试所有账号、成员勾选与自动轮换" />
69
+ <img src="docs/assets/dsh-connect-workbuddy-account-pool.png" width="900" alt="dsh-connect-workbuddy 账号池区块:一键签到所有账号、一键测试所有账号、成员勾选与自动换号" />
70
70
  </p>
71
71
 
72
- 一台机器上往往登录过多个 WorkBuddy 账号。**账号池**把这件事收成一次点击:把要用的账号勾进池子,然后「一键签到所有账号」或「一键测试所有账号」;需要时再打开**按积分自动轮换**,让插件在池内自行挑一个计费账号。
72
+ 一台机器上往往登录过多个 WorkBuddy 账号。**账号池**把这件事收成一次点击:把要用的账号勾进池子,然后「一键签到所有账号」或「一键测试所有账号」;再打开**启用账号池**,插件就会在请求失败时自动换号接着跑。
73
73
 
74
- **每个区域(国内版 / 国际版)各有独立的一池**:成员、目标模型、间隔与轮换开关互不影响——两边的账号本来就属于不同的上游栈。
74
+ **每个区域(国内版 / 国际版)各有独立的一池**:成员与目标模型互不影响——两边的账号本来就属于不同的上游栈。**签到只有国内版有**:海外区域不显示签到按钮,也没有那一列。
75
+
76
+ ### 自动换号(开启账号池即生效)
77
+
78
+ - **谁来服务由排序决定**:开启账号池后,**每一次请求**都发给池子里排序最靠前的可用账号——所以「当前使用」显示的是排序的结果,不一定是你手动选的那个。排序规则见下一节。
79
+ - **失败时继续往下试**:某个请求在上游失败时,插件会**依次**用池内其他可用账号重试,直到有一个接手、或全部不可用为止;全部失败才把错误报出来(并在错误里注明尝试过几个账号)。
80
+ - **哪些失败会换号**:429 限流、402 无积分、401 凭据失效、502 网关/网络——这些都是**某个账号**的状态,换一个账号很可能就过了。
81
+ - **哪些不会**:请求本身不合法(HTTP 400)。同一个请求体换哪个账号都是同样的 400,重试只是让用户多等几轮才看到同一个错误。**流中途断开也不会重试**——字节已经发出去了,重放会让客户端看到两段拼接的回答。
82
+ - **已知不可用的账号会被跳过**:正在限流冷却期内、或凭据已被上游拒绝的成员不会参与(这也是「一键测试」的意义:测试结果直接决定谁能被选中)。**从没测试过的账号按「默认可用」参与**——刚发现的登录不该因为「还没测」就被排除。
83
+ - **手动选择不会被改写**:池子通过运行时覆盖决定「谁服务」,**从不写回你保存的选择**;关闭账号池,你选定的账号立刻完全恢复生效。这也是为什么卡片要显示「当前使用」——它可能不是你在上面选的那个,而你应该看得出来。
84
+ - **失败会被记下来**:某个请求撞上失败时,插件把这次失败写成该账号的测量(含上游报文里给出的重置时间),所以**下一个请求直接从可用账号开始**,不会每次都先撞一遍同一个限流账号。只有需要你手动「一键测试」时才不写测量这件事,从此不成立了。HTTP 400 例外——那是**请求**不合法,与账号无关,记下来会白白闲置一个好账号;未勾选进池的账号也不记(勾选才是「可以花它的积分」的同意)。
85
+ - 每次换号都会在宿主日志里留一行 `warn`,写明因何失败、换到了下一个账号。
86
+
87
+ ### 这个开关到底控制什么
88
+
89
+ **它只控制「插件要不要自己决定」,不控制「你能不能操作」。**
90
+
91
+ | | 账号池 **开启** | 账号池 **关闭** |
92
+ |---|---|---|
93
+ | 谁服务 | 由排序**自动**选出(可用优先 → 积分高 → 快过期 → 凭据新) | **你在账号池里手动选**「使用哪个账号」 |
94
+ | 请求失败 | **自动**换下一个可用账号重试 | 不换号,如实报错 |
95
+ | 一键签到 / 一键测试 | 可用 | **仍然可用** |
96
+ | 目标测试模型 | 可改 | **仍然可改** |
97
+
98
+ 最后两行是刻意的:签到与测试是**手动动作**,它们作用于你勾选的成员,与「插件是否自动路由」是两件事。之前把按钮也挂在开关上,导致关掉账号池就**再也没法签到或测试**——而「关闭」本该是「我手动来」,不是「什么都不许做」。
99
+
100
+ **手动选择账号在哪里**:账号池区块里。**关闭账号池时**,该区块会显示一行「使用哪个账号」的下拉,选定即生效(写回本区域的账号槽,与凭据失效时的「切换到可用账号」走同一条已验证的写入路径);**开启账号池时这一行不显示**——那时由排序决定,一个改了不生效的下拉比没有更糟。账号的发现(「重新检测账号」)、勾选、签到与测试全部集中在这个区块里,所以账号管理只有一处。
101
+
102
+ **唯一的硬约束**:池里一个成员都没勾选时,批量动作一律拒绝。空的池**绝不**退化成「那就对全部账号执行」——那是唯一会花掉你没授权过的积分的解读。
103
+
104
+ ### 排序规则(开启账号池后决定谁服务)
105
+
106
+ 1. **可用优先** —— 被测量判定为不可用(限流未到恢复时间、凭据被拒、连通性失败等)的排到最后,不参与选择;
107
+ 2. **积分高者优先** —— 在可用的账号里选积分最多的。**积分只在有新鲜读数时参与**(卡片轮询、批量测试或保存时读到的那份,10 分钟内有效):请求路径**不会**为了排序去打上游,否则一页对话就会变成 N 个额外请求;
108
+ 3. **快过期的积分包优先** —— 3 天内到期的先用,过期即作废;
109
+ 4. **凭据更晚过期的优先**;
110
+ 5. 最后按账号 id 收尾,保证顺序是**全序**、可复现,不会因为账号表顺序变化而抖动。
75
111
 
76
112
  ### 两个批量动作
77
113
 
@@ -88,21 +124,11 @@ DSH PiAiAdapter(每个 provider 一套)
88
124
 
89
125
  勾选是一次**草稿编辑**:点「保存」才落盘,未保存时保存按钮带 dirty 指示;切走或关掉卡片,草稿仍在。
90
126
 
91
- ### 自动轮换:把「按积分选账号」变成一次显式授权
92
-
93
- 插件此前的原则是**绝不为了找积分而重排账号**——账单落在谁身上必须可预期。自动轮换是**你显式打开的**例外:
94
-
95
- 1. 打开后,插件在可用的成员里按 **① 积分余额高者优先 → ② 快过期的积分优先用完 → ③ 平手时凭据更新鲜者优先** 排序,把得分最高的那个作为当前计费账号。
96
- 2. **它与手动选择互斥**:自动轮换开启时,账号下拉**立即置灰**,并就地给出原因与一个「关闭自动轮换」的按钮。否则你会看到最坏的一种状态——**下拉里写着账号 A,实际计费的却是账号 B**。
97
- 3. **轮换只在运行时决定用谁,绝不写回你的手动选择**(不写 `config.accounts[region]`)。所以关掉开关后,你上次手动选的账号原封不动地回来。
98
-
99
- **每次轮换都在卡片上留下一条记录**(何时、从谁到谁),而不是悄悄发生。
100
-
101
- ### 自动测试的时间间隔
127
+ ### 手动选择:卡片上没有账号下拉了
102
128
 
103
- 定时器每 60 秒走一次,按你设定的间隔(**5 ~ 1440 分钟,默认 30**)执行一轮「测试所有账号」。下限是 5 分钟而非更短,是刻意的:更密只会把整池打成限流。
129
+ 账号**一律在账号池里管**:成员勾选就是现在的账号选择面,「重新检测账号」也在那一块。原先那个「账号选择」下拉在有账号池之后是自相矛盾的——池开启时由排序决定谁服务,它改什么都不生效,却看起来像权威;顶部状态行还可能显示成 A、下拉显示成 B。
104
130
 
105
- **一个容易忽略的边界**:若你指定的模型已离开目录,定时轮次**不会就此停摆**——它会回落到该区域的免费模型继续刷新测量。这必须如此:成员能否回池靠的是**新的探测结果**(见下),一个陈旧的显示偏好不该把整池的测量冻住。手动动作仍然拒绝,理由同上。
131
+ 所以区域状态行也跟着改:**池开启时不再报出某一个账号名**(多个账号同时登录是常态,用单数说「已登录:A」本身就是错的),只陈述本区域可用、由池决定用谁;**池关闭时才报出那个账号**——那时它确实是唯一答案。状态行旁边的圆点与 token 过期时间讲的是「这个区域能不能用」,和「谁在计费」是两件事,后者由账号池区块负责显示。
106
132
 
107
133
  ### 账号为什么出池,以及什么时候回来
108
134
 
@@ -249,6 +275,7 @@ dsh plugin --profile desktop add /Users/dmh2002/DshProject/dsh-connect-workbuddy
249
275
 
250
276
  - [corrinehu/dsh-workbuddy-connect](https://github.com/corrinehu/dsh-workbuddy-connect)(MIT,Copyright (c) 2026 Corrine Hu)— **本项目的主要参照**。WorkBuddy 接入 DeepSeek Harness 的完整可行方案由该项目首先验证:桌面端凭据的发现与刷新机制、上游协议与请求头约定、loopback shim 的入站加固、pi-ai provider 的装配方式、以及状态诊断 CLI,均以其为参照。本项目在保留这些已验证能力的基础上重写,着重改善使用体验。
251
277
  - [Sliverkiss/workbuddy2api](https://github.com/Sliverkiss/workbuddy2api)(MIT)— WorkBuddy 上游协议(`copilot.tencent.com` 的 wire behavior)的参照实现,经 `dsh-workbuddy-connect` 转引。
278
+ - [hawklithm/workbuddy2api](https://github.com/hawklithm/workbuddy2api)(MIT,Copyright (c) 2026 Mayer)— **本项目的致谢名单里唯一一项「代码移植」而非「思路参照」**:其 `src/codebuddy_proxy/dsml_parser.py` 是一份代理同一 WorkBuddy/CodeBuddy 上游的 DSML 工具调用解析器,本项目把它移植为 `src/dsml-recovery.ts`(标签状态机、忽略区域判定、缺包裹修复、参数递归解析、流式缓冲),并在其上补了它没有的两道闸(工具名必须在本次请求声明的工具里、无声明工具完全不做恢复)。三处有意偏离逐条写在该文件头部,义务声明见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
252
279
 
253
280
  ### 插件呈现与结构的基线
254
281
 
@@ -25,6 +25,7 @@
25
25
  | --- | --- | --- | --- |
26
26
  | `dsh-workbuddy-connect` | <https://github.com/corrinehu/dsh-workbuddy-connect> | WorkBuddy 接入的完整参照:凭据发现、上游协议、shim、适配器、CLI | **MIT**(Copyright (c) 2026 Corrine Hu) |
27
27
  | `workbuddy2api` | <https://github.com/Sliverkiss/workbuddy2api> | WorkBuddy 上游协议(`copilot.tencent.com` 的 wire behavior)的参照实现 | **MIT** |
28
+ | `workbuddy2api`(DSML 解析器) | <https://github.com/hawklithm/workbuddy2api> | **代码移植**(本清单中唯一一项不只是思路参照):`src/codebuddy_proxy/dsml_parser.py` 的标签状态机、忽略区域判定(Markdown 围栏 / 行内代码 / CDATA / 注释)、缺包裹修复、参数递归解析与流式缓冲,移植为 `src/dsml-recovery.ts`;并在其上补了该实现没有的两道闸(工具名必须在本次请求声明的工具里、无声明工具完全不做恢复)。逐条改动与理由见该文件头部注释与 `docs/DSML-RECOVERY-PLAN.md` | **MIT**(Copyright (c) 2026 Mayer) |
28
29
 
29
30
  ### 与 DSH 插件结构 / 外观相关
30
31
 
@@ -48,8 +49,9 @@
48
49
  ## 四、本项目的使用方式
49
50
 
50
51
  - **借鉴设计思路 + 独立实现**:关键模块均为本项目独立编写,并在各源文件头部注释中标注所参考的具体项目与模式。
51
- - **不整体复制源码**:未复制、修改或再分发上述任何参考项目的源文件。
52
- - **版权声明保留**:所有参考项目的版权归各自作者所有;本项目代码不构成对它们的再分发。
52
+ - **唯一一处代码移植**:`src/dsml-recovery.ts` 是 [`hawklithm/workbuddy2api`](https://github.com/hawklithm/workbuddy2api) 的 `src/codebuddy_proxy/dsml_parser.py`(MIT,Copyright (c) 2026 Mayer)的 **TypeScript 移植**,不是思路参照。该文件的头部注释逐条列出了三处**有意偏离**(参考实现里重复定义的 `find_invoke_blocks`、其流式注入与自身解析器输出形状不一致、以及它没有的两道闸)。按 MIT 条款,本项目保留其版权与许可声明,并在上表与本条中同时标注。
53
+ - **除此之外不复制源码**:其余各参考项目均只借鉴设计思路,未复制、修改或再分发其源文件。
54
+ - **版权声明保留**:所有参考项目的版权归各自作者所有。
53
55
  - **后续引入依赖时**:引入任何新的 WorkBuddy 相关外部依赖、或复用其他项目代码时,必须同步更新本文件,并遵守对应许可证的署名与声明要求。
54
56
 
55
57
  ## 五、随包分发
package/docs/WINDOWS.md CHANGED
@@ -98,6 +98,7 @@ WMI 服务(`Win32_Process.Create`)创建的进程与 Electron 宿主同为
98
98
  | 子进程启动选项(`windowsHide`)与测试 | `src/host-heartbeat.ts` `PROCESS_PROBE_OPTIONS`、`src/at-rest.ts` `fetchAtRestKeyPayload()`;`tests/platform-standing.spec.ts`、`tests/host-heartbeat.spec.ts` |
99
99
  | 写入回读校验与测试 | `src/client/account-selection.ts`;`tests/client-account-selection.spec.ts` |
100
100
  | 设置隔离(供测试) | `vitest.config.ts`(`LOCALAPPDATA` / `APPDATA` / `HOME` / `USERPROFILE` / `XDG_CONFIG_HOME`) |
101
+ | 诊断脚本的跨平台写法 | `scripts/verify-dsml.mjs`:用 Node 读源码代替 `grep`(Windows 上没有 `grep`,而 `tryExec` 会吞掉失败,于是「工具缺失」会伪装成「没找到」);仓库根用 `fileURLToPath` 而不是 `new URL(...).pathname`(后者在 Windows 上给出 `/C:/...`,任何 fs 调用都不接受) |
101
102
 
102
103
  ## 6. 尚未做的(如实登记)
103
104