dsh-connect-workbuddy 5.4.0 → 5.5.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,86 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Fixes
6
+
7
+ - **排序箭头在「账号池还没保存开启」时不出现 —— 先勾选再排序这条自然路径被堵死。** `AccountPool.tsx` 的 `moveEnabled` 读的是**已保存**的 `pool.enabled`,而同一行旁边的 `isMember` 读的却是**草稿** `active.memberAccountIds`。于是:勾选账号入池(草稿变了)→ 想去排序 → **箭头根本不存在**,因为开关还没保存;用户既看不到箭头,也就没有理由去按保存,下一次保存自然「什么都没写」——这正是「手动排序没保存」的实际形态。改成读草稿的 `active.enabled`:箭头描述的是草稿,就该跟着草稿走(报告运行中的池仍然是 `poolRunning`,那个值没有动)。变异验证:改回 `pool.enabled`,新增用例立刻红(期望 4 个箭头、实际 0 个)。
8
+ - **`regionsMapOf()` 会跳到更深一层,读到同名区域的过期副本。** 旧规则是「只要有 `regions` 键就往下跳」,理由是可以同时接受「整份 settings」和「regions map」两种入参。但 regions **map 自己也可能有 `regions` 键** —— 本机真实 profile 里 `config.regions` 下就躺着 `accounts` / `regions` / `cliBridge` 三个旧写入留下的兄弟键。一旦跳下去,读到的就是**另一份更旧的同名区域**:实测同一份文档,「按 section 读」得到 `['c','a','b']`,「按 map 读」得到 `[]`(正是「排序明明存了却读不出来」)。现在改为**按形状判定**:外层对象只要含有区域 id(`cn` / `global`)就是 map,直接用它;否则才是 section,才往里跳。变异验证:把判定换回无条件跳,新增用例立刻红。
9
+
10
+ ### Docs
11
+
12
+ - **写明负载徽标仅在国内版:国际网关没有这条接口(实测,非推断)。** 2026-10-22 用插件自己的凭据与客户端对 `www.workbuddy.ai` 实测:`POST /v2/chat/busy` 回 **404 `{"error_msg":"404 Route Not Found"}`**,另试 `/v3/chat/busy`、`/v2/models/busy`、`/v2/chat/model/busy`、`/v1/chat/busy`(POST + GET 各一次)**12 个组合全是 404**;同一份凭据读该网关的 `/v3/config` **200、28 个模型**,且这 28 个模型的**全部字段名里没有任何**含 busy / load / queue / wait / level / pressure 的字段。结论:**不是路径写错、不是身份不对、不是权限问题 —— 上游国际版从未提供这个事实**。行为保持不变(探针失败仍被 `withBusyLevels` 捕获、记一条 warn、模型目录照常下发,卡片渲染为「没有徽标」),本次只补文档与代码注释,**故意不按区域门控探针**:真门控等于把「上游将来补上」这件事也一并关掉,而今天多付的代价只是每轮一次 404。顺带订正 README 里一句已过时的描述(还写着「`idle` 有词但不画」):5.3.1 起 `idle` 就是那枚**绿色火箭**、`normal` 是 1 个黄人,两处文案现在与 `src/client/busy-icon.tsx` 的实际渲染一致。
13
+
14
+ ## 5.5.0 (2026-10-22)
15
+
16
+ > 新增能力属 **minor**:实时胶囊、会话续接、模型选择器的活动标签,以及账号池与卡片的信息架构整理。**provider 路由、模型目录、计费与凭据一律未变**;CLI 路线仍默认关闭,关闭时插件行为与 5.4.0 一致。
17
+ > **实时胶囊是唯一的例外**:它的只读路由 `/plugins/dsh-connect-workbuddy/cli-live` 与客户端组件**无条件注册**(否则客户端无法把「没有回合」和「没有这条路由」区分开),所以开关关着时每个打开的页面仍会每 5 秒问一次这条本地路由——它不碰上游、不花额度,代价是一个空数组的 JSON。
18
+
19
+ ### Features
20
+
21
+ - **跑的时候看得见(实时胶囊)**:`conversation.composer.dock` 上挂一颗 `CodeBuddy · 7 tools · 42s · last: Bash`,数据来自插件自己的只读路由 `/plugins/dsh-connect-workbuddy/cli-live`(序列化宿主侧的 live board)。宿主工具调用是一次性的,`execute()` 不返回界面就什么都不知道——这颗胶囊补的就是那几分钟的空白。5 秒轮询、秒数由 1 秒本地 tick 推、多会话共享一个在途请求;没有回合时渲染 `null`,那一行不动。路由**无条件注册**(开关关着也在),客户端才能把「没有回合」与「没有这条路由」分开。**胶囊只描述本会话的回合**:座位 `conversation.composer.dock` 声明成 `scope: 'session'`,`inject` 第一个参数就是 `sessionId`,组件按它过滤——live board 是**宿主级**的(一条路由回答进程里所有会话),不过滤就会在 A 会话的输入框下面描述 B 会话(甚至另一个标签页)的回合。宿主没给 `sessionId` 时不过滤;宿主拿不到 `agent.id` 而标成 `sessionId: ''` 的行也保留,那同样是「真有人在干活」的证据。**不按供应商门控**:额度胶囊只在 WorkBuddy 供应商下出现是因为它报那家的额度,这颗胶囊报的是一个本地进程,CLI 路线刻意与模型无关。
22
+ - **连续调用默认续接同一条 CodeBuddy 对话**(`sessionContinuity`,默认 `auto`)。第一次调用报回的 CLI `session_id` 记在宿主进程里(按 DSH 会话分开,最多 256 条,FIFO),第二次调用没显式传 `session_id` 时自动用它走 `--session-id`。**先量过再接**:真机跑两轮,第一轮让 CLI 记住 `7391`,第二轮只传会话 id 问它数字,答出 `7391`,且两次回显同一个 id;另一个方向也量了——传一个**不存在**的 id 不报错,CLI 会静默开一条新会话,所以这条记忆丢掉或过期只会退化成「一次新对话」,不会串到别人的对话里。显式传 `session_id` 永远优先(可以故意开新线);设 `off` 回到「每次独立」。**没有落盘**:值是一个 CLI 自己拥有的 uuid,CLI 对不存在的 id 不报错,弄丢的代价只是一次新对话,不值得为它引入一个需要回收的文件——因此重启宿主后记忆清空。
23
+ - **模型选择器里也能看见活动标签了**:卡片上的模型行一直有 `夜间折扣` / `独家限免` 这类胶囊,但**真正做选择的地方**(输入框旁的模型选择器)只有一个队列图标,于是同一次促销在卡片里写着、在选择器里看不见 —— 而选择器恰恰是用户决定「今天用哪个」的那一屏。
24
+ - **零新请求、零硬编码**:促销候选本来就跟在 label slot 已经在拉的那个 usage 响应里(`WorkBuddyWebModel.promos`,`src/web-status.ts:672` 透传)。原来的 `levelsNow()` 只取了 `busyLevels`,把同一份 `models[]` 里的 `promos` 丢掉了;现在合并成 `statusNow()` 一次取回 `{ levels, promos }`。文案与颜色全部来自上游(`parseWorkBuddyPromos` 解析的 `/v3/config`),插件不判断「什么时候该免费」。
25
+ - **用时钟判定,不在拉取时冻结**:`selectPromoBadge(promos, Date.now())` 在**渲染时**求值,并挂一个共享的 60 秒 ticker(首个订阅建立、最后一个卸载清掉,`unref()` 不吊住进程)重算 —— 否则 23:59 拉到的「夜间折扣」会一直挂到第二天早上。
26
+ - **窄行说短词**:菜单行(`placement: 'option'`)用上游给的 `shortLabel`(`夜间` / `限免`),输入框触发器(`trigger`)用完整 `label`(`夜间折扣` / `独家限免`);胶囊整体不截断,只对文字做省略号(`.dsm-workbuddy-model-label-promo` 限宽 6em,内层 `-text` 省略),避免把同一行里宿主的勾选、推理档位挤出去。顺序固定为**名字 → 促销 → 队列图标**:活动说「多少钱」,图标说「堵不堵」,是两个问题。
27
+ - 顺带统一计费倍率写法:卡片原来是 `(0.00x)`,选择器是 `x0.00`,同一个数字两种写法;卡片改为 `x0.00`(`src/client/WorkBuddyCard.tsx`)。
28
+ - `tests/model-label-slot.spec.tsx` 新增三条:①胶囊在名字之后、带上游色 `#009273`、且队列图标仍在;②`option` 用短词、`trigger` 用完整词;③`validUntil` 已过的活动**一个像素都不画**。都用 `mount` + `settle()` 而不是 `renderToStaticMarkup`:促销是在 `useEffect` 拉回来的,静态渲染跑不到那里。
29
+ - **三个「能力开关」搬进自己的「能力」分区,不再挤在「账号」面板顶部**:CLI 干活、模型自主生图、积分读数(含「显示位置」picker)原来平铺在「账号」面板的分区 tab 下面就着,和账号表格抢同一屏。这些开关**授权的是插件能替你做什么**(其中两个直接花钱、注册工具),而且点一下**当场生效** —— 它们既不属于「哪个账号服务这个 tab」,也不属于任何写着「点保存才生效」的区块。
30
+ - 新增第四个分区 tab「能力」(介于「模型」与「诊断」之间),面板内容为一句说明(「插件可以替你做什么。这些开关点一下立即生效 —— 这一区里没有保存按钮。」)加那三组开关,顺序按代价排:**先花钱(CLI / 生图),再显示(积分读数)**。
31
+ - 这样每个开关都有地方长自己的子选项 —— CLI 的工作目录、生图的尺寸或价格上限 —— 而不用继续在表格上方那一小条里越堆越长。
32
+ - `tests/card-credits-switch.spec.tsx` 的两条断言随之改写:开关必须落在 `#dsm-workbuddy-panel-capabilities` **里面**,且 `#dsm-workbuddy-panel-accounts` 里**没有**它们;另一条断 tab 的文档顺序(模型 → 能力 → 诊断)、默认折叠、点击后展开且 `aria-selected` 变成 true。测试用 `[id="…"]` 而不是 `#…`:这套 jsdom 里 `#id` 选择器一律返回 null(踩过一次,全部断言假红)。
33
+ - 未登录时仍然只有「账号」一屏(分区 tab 本来就不渲染),「能力」面板因此沿用 `hidden={signedIn && panel !== 'capabilities'}`,开关不会在旧 Host 上消失。
34
+
35
+ ### Fixes
36
+
37
+ - **回注不再冒充用户发言**:`source.kind` 从 `'user'` 改成 `'codebuddy-bridge'`。`MessageSourceMap` 本来就是**可扩展**的(每个生产者在自己模块里声明 kind,`dsh-connect-atomcode` 就是这么做的),只在宿主自己的闭集里挑一个「最像的」既没必要也没好处——副作用是 GUI 把这条回注渲染成**用户气泡**,看不出它是通知。声明放在 `src/dsh-llm-augment.d.ts`;`PROVENANCE_LINE` 仍然留在正文首行,因为模型看不见 `source`。
38
+ - **胶囊改用 composer 共用的样式**:原来是内联 `fontSize: 0.85em` / `opacity: .85`,字号继承 dock 正文,和旁边的额度胶囊、ContextMeter 对不齐,看着像外挂。类定义进 `WORKBUDDY_COMPOSER_CSS`(`.dsm-workbuddy-cli-live` / `-dot`,12px、`label-tertiary`、tab-nums、`prefers-reduced-motion` 关掉脉冲),由组件自己幂等注入——额度胶囊被 gate 关掉时样式也得在。
39
+ - **Windows 上自动探测 CLI 会 100% 失败(阻断级)。** #107 把清单写成「每个根目录都拼 `lib/node_modules`」,但 Windows 的 npm 全局前缀是 `%APPDATA%\npm`,包直接落在 `<prefix>\node_modules`,**没有 `lib` 这一层**。于是它探测的是 `%APPDATA%\npm\lib\node_modules\...` —— 那个目录在 Windows 上根本不存在,9 条候选全部落空,`resolveCliEntry('')` 直接报「没有找到本机安装的 CodeBuddy CLI」,用户必须手填 `cliBridge.binary` 才能用。修法:清单改成「每个根目录自带自己的布局」—— Windows 前缀拼 `node_modules`,POSIX 前缀拼 `lib/node_modules`,pnpm / yarn 的全局仓本身**就是** `node_modules` 根;volta 的镜像目录改成按版本展开(`tools/image/node/<version>/lib/node_modules`)。顺带去重:`%APPDATA%\npm` 与 `~/AppData/Roaming/npm` 在正常 Windows 上是同一个目录,原清单把它列了两遍。**为什么 CI 没拦住**:#107 的 runner spec 里没有任何一条针对 `cliEntryCandidates()` 的断言,所以 windows-latest 腿照样绿着 —— 绿的是测试,坏的是真机。现在补了 4 条。
40
+ - **同一类缺陷还有三处(一并补上):pnpm / yarn / fnm 在 Windows 上的默认位置。** 清单原来只写 POSIX 路径(`~/.local/share/pnpm/global/5`、`~/.config/yarn/global`、`~/.fnm/node-versions`),而这三家在 Windows 上分别落在 `%LOCALAPPDATA%\pnpm\global\5`、`%LOCALAPPDATA%\Yarn\Data\global`、`%APPDATA%\fnm\node-versions`;pnpm 在 macOS 上还有 `~/Library/pnpm/global/5`。这些路径按各自工具文档的默认值补 —— 判错只多一次 `existsSync`,判漏就是「CLI not found」。**本机只能实测 npm 那条**(`%APPDATA%\npm\node_modules\@tencent-ai\codebuddy-code` 确认存在),其余三条没有对应的全局安装可验证。
41
+ - **`/codebuddy` 的示例在自己的默认配置下必然失败。** hint 写「或写你的要求(如"修一下这个空指针")」,但非评审调用**不传** `permission_mode`,于是走默认的 `dontAsk` = 写文件被拒。工具入口有 `permission_mode` 参数可按次覆盖,指令入口什么都没有 —— 这条不对称让 hint 举的那个例子只能是「这些工具被拒绝」。修法:新增 `--edit` 前缀(`/codebuddy --edit 修一下这个空指针`),只对这一次调用生效、不改默认值;`--edit` 单独用 = 评审并顺手修掉找到的问题。hint 与描述改成「留空 = 评审 / 写字 = 只读执行 / `--edit` 开头 = 允许改文件」,被拒时的提示也改成指向 `--edit`。`--edit` 会被剥掉,不会传给 CodeBuddy。
42
+ - **配额拒绝时会泄漏临时目录。** `resolveWorkingDir()` 在 `allow_unattended` 下**先**创建 scratch 目录,`gate.tryEnter()` 在它之后;被拒时直接 `return`,那个目录就留在 OS temp 里 —— 配额耗尽后反复重试,每次留一个空目录。实测本机 temp 里已经堆了 **9 个**。修法:拒绝分支里同样 `removeScratch`。测试是**先证明它会红**才留下的:把修复临时摘掉跑一次,报 `expected [ 'dsh-codebuddy-scratch-fVf7kZ' ] to deeply equal []`。
43
+ - **`scratchDir()` 的固定共享目录改成每次调用一个新目录。** 原来是固定路径 `tmpdir()/dsh-codebuddy-scratch`:两个并发的无人值守回合会共用一块可读写空间,互相看得见、也改得掉对方的文件,上一轮留下的东西还在。现在用 `mkdtempSync`,每次拿到一个空的、名字唯一的目录,回合结束时(`finally`)尽力删除。`allowUnattended` 仍然默认 `false`。
44
+ - **`isReviewInvocation` 的注释过期。** 它还写着「empty line」,而 `--edit` 单独出现也会走评审分支。一行注释的事,但下一个读代码的人会按注释理解行为。
45
+ - **胶囊的秒数会冻住(新增功能里的缺陷,被新测试当场逮到)。** 时钟偏移原本在**每次轮询返回**时重算(`offsetRef.current = next.serverTime - Date.now()`),但 `readLive` 会把同一份文档缓存到下一个轮询周期,而那份文档带的是**旧的** `serverTime` —— 于是每次命中缓存都把已经过去的时间从偏移里再减一遍,`Date.now() + offset - startedAt` 恒等于文档刚取到时的年龄,计数器永远显示同一个秒数(实测:过了 1.1 秒仍然显示 `0s`)。修法:只在 `serverTime` 变化(即真的拿到新文档)时重算偏移。测试是**先证明它会红**才留下的:把守卫临时改成恒真,报 `expected 'CodeBuddy · 3 tools · 0s · last: Read' to contain '1s'`。
46
+ - **`serverTime` 缺校验时标签会印出 `NaNs`(独立复审发现的第二处)。** 路由是本插件自己的,但「自己人」不等于「永远对」:字段被改名、`undefined` 被 `JSON.stringify` 丢掉、以后换了 shape,都会让 `serverTime` 变成 `NaN`,偏移变 `NaN`,`Math.round(NaN)` 还是 `NaN`,而用户正好在这一行看「还要多久」。现在整份文档先过 `acceptDocument()`:`turns` 必须是数组、`serverTime` 必须是有限数字,单条回合缺 `startedAt`/`toolCalls` 时只丢那一条。测试同样是**先证明会红**:摘掉守卫跑一次,报 `expected 'CodeBuddy · 3 tools · NaNs · last: Read' to be ''`。
47
+ - **路由连续失败时胶囊不会退场。** 原来的注释说「失败就继续显示上一份文档,等下一个 tick」——对一次掉包是对的,对一条开始返 500 的路由就是错的:胶囊会无限期描述一个早就结束的回合,秒数还在涨。现在连续三个周期(15s)拿不到文档就清空,长到能扛过一次重载,短到不会在输入框上留一颗幽灵。
48
+ - **胶囊会把别人的回合算成自己的(新增功能里的缺陷)。** live board 是**宿主级**的,一条路由回答进程里所有会话;组件原来不加区分地画「最新的那条」,于是 A 会话输入框下面会描述 B 会话(甚至另一个标签页)的回合——一条关于你没在看的目录的字,计数器还在动。现在座位用 `scope: 'session'` 给的 `sessionId` 过滤,只留本会话的行;宿主没给 id 时不过滤,`sessionId: ''`(宿主拿不到 `agent.id`)的行也保留。
49
+ - **板子上的 `sessionId` 被 CLI 自己的 uuid 覆盖。** `tool.ts` 在「finishing」阶段写过 `liveTurn.sessionId = outcome.sessionId`,但那是**两个不同的 id**:板子上的 `sessionId` 是回合所属的 DSH 会话(开行时取自 `execution.agent.id`,也是胶囊过滤用的那个),`outcome.sessionId` 是 CLI 自己的会话 uuid(结果里以 `session_id` 返回,用于续接 CLI 会话)。覆盖之后,每个回合在最后几秒都会显示成属于一个不存在的会话——加上本会话过滤后,胶囊会把自己的行丢掉。现在两个 id 各归各位;回归测试**先证明它会红**才留下:把那一行临时加回去,报 `expected 'cli-session-uuid' to be 'session-dsh'`。
50
+ - **账号池的总开关上移为模式条**:原来决定整页行为的「启用账号池」在页面最底部,而被它控制的一切在顶部。现在它紧跟标题,同一行给出当前模式(自动挑号 / 手动选号)和一句说明;池关闭时账号选择器收进标题行右侧,不再单占一行并重复一次账号名。
51
+ - **删掉逐字重复的整段说明**:同一段「开启后……关闭后……」原来在按钮下方和底部开关下方各出现一次,读起来像开发注释漏到界面上。
52
+ - **「当前使用」与「未入池」不再同框**:两者是正交概念,同时出现会让人问「都在用了为什么不在池里」。行内只保留「未入池」,当前账号不在池里时在模式条下给一句旁注(`poolServingOutside`)。
53
+ - **禁用原因就近**:空池时「勾选至少一个账号,才能签到与测试。」紧贴「已选 0 / 1」,不再挂在半屏之外。
54
+ - **排序规则说明移到「更多设置」顶部**:纯说明不该排在账号前面(用户的直接反馈)。
55
+ - **测试模型只说一遍**:设置行、行尾、表头原本各说一次同一个模型;行尾改为仅在「实际测的模型 ≠ 本池保存的模型」时才提示。「目标模型」列名本就是误称(该列是可用性状态),改为「状态」。
56
+ - **保存条改为 sticky**:两个让改动生效的按钮原本在整页最底部,编辑完时正好在视口之外。
57
+ - 「账号池设置」降级为「更多设置」;`row.poolNeverTested` 由「尚未测试」改为「未测试」。
58
+ - **两个批量按钮不再说「所有账号」**:它们只走**勾选进池**的成员 —— 而表格里未勾选的账号就摆在旁边,所以「一键签到所有账号」会被读成「我登录过的每一个账号」,与同一个面板里那句「只有勾选的账号参与轮换、签到与测试」正好相反。改名「**签到勾选的账号**」「**测试勾选的账号**」(英文 `Check in selected` / `Test selected`):对谁做,由按钮自己说完。**禁用条件本来就是同一套语义**(一个都没勾就置灰),所以这只是把界面上最后一句不实的话改掉,交互与请求集合一个字没动。
59
+ - 英文旧标签 `Check in all accounts` 也一并改掉:英文的 "all accounts" 和中文一样,读的是「所有账号」,不是「池里所有成员」。
60
+ - README 里引用旧按钮名的措辞同步(中 5 处 / 英 4 处);**CHANGELOG 的历史条目保留原措辞** —— 那是当时界面上的事实,改掉等于伪造记录。
61
+ - 文案键没动(测试用的是保键翻译器 `t`),所以没有测试需要改:`tests/pool-render.spec.tsx` 与 `tests/pool-card-render.spec.tsx` 里 `m.button('row.poolCheckinAll')` 这类断言照旧生效,也正是它们保证这个键不会被顺手改名。
62
+ - **批量按钮搬进成员区,紧跟「已选 N / M · 全选 / 全不选」**:这两个按钮只对**勾选的账号**做事,而「勾了哪些、勾了几个」是**成员区**在回答 —— 它原来却挂在成员区**上方**,读起来像一条页面级工具栏:先看到「签到勾选的账号」,往下 40px 才遇到「本池包含的账号 / 已选 1 / 1」。顺序改成**先声明范围,再给动作,再列账号**:`本池包含的账号 + 已选 + 全选/全不选` → `签到勾选的账号 · 测试勾选的账号 ·…· 重新检测账号` → 表格。
63
+ - 顺带去掉这条工具条的上边框:它现在是成员块内部的一行,和上面那行计数是一件事,分隔线会把按钮和解释它们的数字切开;真正需要分隔的是它下面那块「更多设置」,那里的线保留。
64
+ - 空池时的就近提示(「勾选至少一个账号,才能签到与测试。」)也跟着落到「已选 0 / 1」的下一行,禁用原因和禁用状态现在同屏同一处。
65
+ - 新增 `tests/pool-render.spec.tsx` 断言:`pool-actions` 必须排在 `pool-members-head` **之后**、`pool-table` **之前**,且 `closest('.dsm-workbuddy-pool-members')` 不为 null(是成员块的子节点,不是兄弟节点)。三条断的都是文档顺序,改回去会直接红。
66
+ - **「扫码登录 / 导出导入」那一块不再自己念一遍标题,并从「更多设置」里搬到了它上方**:两处都是用户直接指出的观感问题,改的却是同一块 DOM。
67
+ - **删掉重复的一行**:这一块原来每条上面都有一行小标题——「扫码添加账号」压着一个「扫码登录」按钮,「导出 / 导入账号」压着「导出账号」「导入账号」。两个词说的是同一件事,标题只比按钮多说两个字,却每条都要占掉一行高度。**标题保留但不显示**:它现在是该 `<section>` 的 `aria-label`,读屏器仍然知道这块叫什么,而屏幕上由按钮自己报名。`.dsm-workbuddy-bar-label` 这条样式规则随之删除——删掉类而留着空 `<p>` 是没有意义的,空段落一样占那一行。
68
+ - **搬到「更多设置」上方**:它原本在「更多设置」标题**底下**、紧跟标题里的那句「下面是偏好设置:点保存才生效」。这句话对账号工具是**假的**——扫码登录和导入点一下当场生效,从来没有保存这一步。动作和偏好混在同一块里,先读说明的人会去找一个根本不需要的按钮,先动手的人则会被表格自己变了吓一跳。搬到标题上方之后,虚线边框加「这里没有保存按钮」把两件事分开了:**上面是动作,下面是偏好**。
69
+ - 这一块顺带从两行竖排换成一行左对齐的工具条:标题没了之后没有东西需要竖着堆,而横排才是用户找「立即执行」的地方;「扫码登录」排在第一个、贴着面板左边,和一键签到、下面的「更多设置」同一条视觉栏。窄面板下仍然自动换行,状态文字也不再被省略号截断(在工具条里它宁可换行也要说完)。
70
+ - 这三处都写进了测试:`tests/oauth-wording.spec.tsx` 改为断言 `aria-label`(`textContent` 看不见属性,删掉名字就会拿到 null 而不是悄悄通过),并新增 `tests/account-tools-placement.spec.tsx`——「在上方」断的是**文档顺序**,「不在保存区块里」断的是 `closest('.dsm-workbuddy-pool-settings')` 为 null,两条都不是源码注释能糊过去的。
71
+
72
+ ### Tests
73
+
74
+ - 重做版补的 8 条:`cliEntryCandidates()` 的四条布局断言(Windows 的 npm 前缀下直接是 `node_modules`、**没有 `lib` 层**;POSIX 前缀仍拼 `lib/node_modules`;pnpm / yarn 的全局仓本身就是模块根;同一个目录只被探测一次);Windows 上 pnpm / yarn / fnm 三个仓库位置一条(fnm 那条要真造一个版本目录,否则「按版本展开」根本没被执行);`parseCommandInput` 的 `--edit` 一条(带正文 → `acceptEdits` 且正文剥掉前缀);配额拒绝后临时目录不残留一条;以及一条**反向约束**:指令的 hint 里不得出现默认权限模式跑不起来的示例。
75
+ - **实时胶囊的测试(新增)**:`tests/cli-bridge-live.spec.ts`(12 条)锁宿主侧 live board —— 开/退休告、失败回合也退休、指令与工具两个入口的标签、`elapsedMs` 由宿主时钟算、无 board 时不告警、以及「目录校验拒绝的调用**不得**留下胶囊」(公告发生在所有前置条件之后,所以没开始的工作不会闪一下);`tests/cli-bridge-live-pill.spec.tsx`(19 条)锁客户端 —— 空闲渲染 `null`、标签含工具数与秒数、`starting`/`finishing` 不显示秒、`+N`、**秒数在两次轮询之间本地前进且请求数不变**、宿主时钟偏移校正、路由失败/畸形文档/没有 `fetch` 都不抛、**`serverTime` 不是数字时整份文档作废(否则标签会印出 `NaNs`)**、**单条回合缺数字时只丢那一条**、**连续三个周期失败后胶囊自己退场**、目录只进 tooltip 不进标签、**只描述本会话的回合**(别会话的行既不出现在标签里也不计入 `+N`)、**只有别会话在跑时渲染 `null`**、**宿主标不成会话的行照样保留**、**座位不给 `sessionId` 时不过滤**,以及 `readLive` 的并发合并与缓存过期。另在 `cli-bridge-runner.spec.ts` 补一条「板子上的 `sessionId` 必须是 DSH 会话 id,不得被 CLI 自己的 uuid 覆盖」——**先证明它会红**才留下:把那一行临时加回去,报 `expected 'cli-session-uuid' to be 'session-dsh'`。
76
+ - **会话续接的 7 条**(`cli-bridge-runner.spec.ts` 4 条 + `cli-bridge.spec.ts` 3 条):第二轮真的带上第一轮报回的 `--session-id`、**第一轮不带**(没有可续接的就不该传)、显式 `session_id` 盖过记忆、`off` 时两轮都不带、两个 DSH 会话互不串(记忆按会话分桶)、设置的默认值与错值回退、以及记忆本身的写入/覆盖/清空/空值不覆盖/256 条上限。跨会话那条第一版用的是固定 key,被记忆污染后改成每例独立 key + `forgetCliSession` —— 那不是「修测试」,是测试自己发现了一个真实的隔离缺口。
77
+ - `tests/pool-render.spec.tsx`:新增 `pool-actions` 必须排在 `pool-members-head` 之后、`pool-table` 之前,且属于成员块子节点的断言;排序规则说明改为断言「在表格之后、目标模型设置之前」。
78
+ - `tests/account-tools-placement.spec.tsx`(新增):「在上方」断文档顺序,「不在保存区块里」断 `closest('.dsm-workbuddy-pool-settings')` 为 null,「没有标题」断 `.dsm-workbuddy-bar-label` 不存在且标题只存在于 `aria-label`。
79
+ - `tests/oauth-wording.spec.tsx`:断言从 `textContent` 改为 `aria-label`(属性不进 textContent,删掉名字会拿到 null 而不是悄悄通过)。
80
+ - `tests/model-label-slot.spec.tsx` 新增三条:①胶囊在名字之后、带上游色 `#009273`、且队列图标仍在;②`option` 用短词、`trigger` 用完整词;③`validUntil` 已过的活动**一个像素都不画**。都用 `mount` + `settle()` 而不是 `renderToStaticMarkup`:促销是在 `useEffect` 拉回来的,静态渲染跑不到那里。
81
+ - `tests/card-credits-switch.spec.tsx`:开关必须落在 `#dsm-workbuddy-panel-capabilities` **里面**,且 `#dsm-workbuddy-panel-accounts` 里**没有**它们;另一条断 tab 的文档顺序(模型 → 能力 → 诊断)、默认折叠、点击后展开且 `aria-selected` 变成 true。
82
+ - 全量:**124 文件 / 2055 条通过(另 2 条 skip,共 125 文件 / 2057 条)**;`tsc -p tsconfig.json` 与 `tsc -p tsconfig.client.json` 均 exit 0;`tsdown` build 成功。
83
+
3
84
  ## 5.4.0 (2026-10-22)
4
85
 
5
86
  > 少数版本:修四处真实缺陷,**请求路径、provider 路由、计费与凭据一律未变**。其中三处是「代码写好了但没被走到」的缺陷——功能早就存在,只是有一整类真实报文从未进入它们。
package/README.en.md CHANGED
@@ -33,10 +33,10 @@ It depends on the WorkBuddy client's own endpoints (not a public API), so the pl
33
33
  - **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.
34
34
  - **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.
35
35
  - **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. **Every model row also carries a “Can disable” (`off`) checkbox**: it decides whether that model gets the “turn thinking off entirely” level. The default comes from a reviewed built-in table (the 9 models that claim the capability but answer HTTP 400 for it start unticked), and you can change it per model — **untick** for a model you do not need the level on. **The 9 tabled models cannot be ticked at all** (their row is marked "refuses off" with an explanatory tooltip): the level is not merely an extra option — pi-ai uses it as the DEFAULT for every request that names no tier, and a compaction summary is exactly such a request, so one tick made every `/compact` for that model fail with HTTP 400 / code 11133 (issue #83). Models outside the table are still decided entirely by your selection and their own declaration; your ticks are saved on first save and recomputed no further.
36
- - **Campaign badges on model rows** — the upstream model catalog carries campaign information (free trials, night discounts, limited-time free use, subscription-only tiers, and the like). The plugin adds **no extra request** (it reuses the catalog configuration it already fetches when refreshing models) and draws a pill badge right on the model row: the upstream supplies both the colour and the wording, and hovering shows the upstream's campaign text, its start/end dates, and its daily window (e.g. “night 23:00–7:50”). **A badge is a function of the current moment**: it shows only inside the window the upstream declares and disappears on its own when that window closes; a window that crosses midnight is judged in the timezone the upstream declared; when several campaigns hit the same model, only the highest-priority one is drawn. A benefit marker without an end date (such as “subscription only”) stays until upstream withdraws it. **A badge is a hint, not behaviour** — it reports the campaign and discount upstream currently advertises, and makes no choice for you. (Both regions' catalogs ship the campaign fields: read as the app, the CN `/v3/config` carries 8 promotions + 1 tier badge and the global one carries 5 + 1; the evidence and the decision rules are written up in [docs/PROMO_BADGE.md](docs/PROMO_BADGE.md).)
37
- - **Model load on model rows** — the upstream answers "how busy is this model right now" from a separate read-only probe (`POST /v2/chat/busy`, up to 50 ids per call, free, no account state touched). The plugin asks it once per catalog refresh and appends the level after the multiplier: `Hy4 preview · x0.29 · 极度拥堵`. **Only `busy` / `congested` / `unavailable` are drawn**: `idle` has a word but stays off the row — 7 of 20 models were idle when measured, so a badge on every row would be decoration rather than a warning; `normal` and the probe's own `unknown` draw nothing, and **"we were told nothing" is never reported as "all good"**. **The level is not persisted**: the catalog is a fact that survives a restart, a load level is a fact about that minute, so it is stamped onto the runtime catalog only and dies with the process; a failed probe logs one warning and never blocks the refresh. Measured: the probe answers byte-identically under the CLI and the app identity, the 50-id ceiling is real (62 ids → HTTP 400), and 19 models come back in 186 ms.
36
+ - **Campaign badges on model rows** — the upstream model catalog carries campaign information (free trials, night discounts, limited-time free use, subscription-only tiers, and the like). The plugin adds **no extra request** (it reuses the catalog configuration it already fetches when refreshing models) and draws a pill badge right on the model row: the upstream supplies both the colour and the wording, and hovering shows the upstream's campaign text, its start/end dates, and its daily window (e.g. “night 23:00–7:50”). **A badge is a function of the current moment**: it shows only inside the window the upstream declares and disappears on its own when that window closes; a window that crosses midnight is judged in the timezone the upstream declared; when several campaigns hit the same model, only the highest-priority one is drawn. A benefit marker without an end date (such as “subscription only”) stays until upstream withdraws it. **A badge is a hint, not behaviour** — it reports the campaign and discount upstream currently advertises, and makes no choice for you. **The same badge now appears in the DSH model picker**: every menu row draws the identical pill after the model name and before the load icon — menu rows use the upstream's short wording ("night", "free"), the composer trigger beside the input uses the full one ("night discount", "exclusive free"); judged from the same data at the same clock, because the screen where the model is chosen is exactly where "is it free right now" matters most. (Both regions' catalogs ship the campaign fields: read as the app, the CN `/v3/config` carries 8 promotions + 1 tier badge and the global one carries 5 + 1; the evidence and the decision rules are written up in [docs/PROMO_BADGE.md](docs/PROMO_BADGE.md).)
37
+ - **Model load on model rows (CN only)** — the upstream answers "how busy is this model right now" from a separate read-only probe (`POST /v2/chat/busy`, up to 50 ids per call, free, no account state touched). The plugin asks it once per catalog refresh and draws the level as a small figure beside the model name: **the number of people = how many are queueing** (healthy `normal` = one yellow figure, `busy` = two orange ones, `congested` = three red ones), while **idle is a green rocket** (nobody is queueing = full speed ahead) — idle is deliberately *not* drawn as "one green person", because at 12px "nobody is waiting" and "one person is waiting" have to be distinguishable at a glance; **unavailable** is a crossed-out figure. The probe's own `unknown` **draws nothing**: **"we were told nothing" is never reported as "all good"**. Colours follow the theme and the `title` tooltip ("极度拥堵") is still there. **The level is not persisted**: the catalog is a fact that survives a restart, a load level is a fact about that minute, so it is stamped onto the runtime catalog only and dies with the process; a failed probe logs one warning and never blocks the refresh. Measured on the CN gateway: the probe answers byte-identically under the CLI and the app identity, the 50-id ceiling is real (62 ids → HTTP 400), and 19 models come back in 186 ms. **The international region shows no load badge, because the international gateway simply does not have this endpoint** — measured, `www.workbuddy.ai` answers **404** for `/v2/chat/busy` (and for the neighbouring `/v3/chat/busy`, `/v2/models/busy` and friends), while the very same credential reads its `/v3/config` fine (28 models) and that document carries **no load or queue field at all**. This is not something the plugin left unimplemented, and it is not a configuration or permission problem: **upstream has never published this data for the international region**. So the international region shows the model name and campaign badges only; the failed probe degrades quietly to "no badge", never blocking the model list, and the plugin never guesses a load level from latency or credits.
38
38
  - **Queueing tells you your position** — upstream expresses a queue with three business codes (`6020` waiting, `6021` queue full, `6022` this user already at the maximum). Instead of passing along a raw upstream error, the plugin says "模型排队中:你排在第 8777 位,队列共 9021 个请求;约 7 秒后自动重试". **A queue does not trigger failover**: the line is shared platform-wide and bound to the model, so another account is handed the same position — no account-pool rotation, no extra requests burned. (The upstream's `/v2/chat/queue/status` returns an i18n KEY rather than a sentence, so the plugin never relays its `message` and always uses its own wording.) **The "resend once it is your turn" half is not implemented** — that needs the queue answer's real envelope on the OpenAI-compatible channel, and writing it unmeasured would mean code that never runs.
39
- - **Tabbed settings card (accounts / models / diagnostics)** — the card used to be a single 3,500-pixel column that mixed two things with completely different commit semantics: **operating accounts** (who is serving, how much is left, switch order, check-in log) and **editing the catalog** (ticking models, context budgets, reasoning tiers, turning thinking off). An account change takes effect on the click; a catalog change is a draft that needs Save — so the Save button often sat three screens away from the thing it saves. Now each tab does one thing, and **switching tabs neither refetches nor drops an unsaved draft** (inactive panels stay mounted and are merely `hidden`, so they also leave the accessibility tree). The third tab, Diagnostics, is a read-only fact table: current region, upstream host, the account actually serving (by NAME, never by id), sign-in validity, how many models the catalog holds, how many are enabled, the pool switch, and the **raw text** of the upstream's most recent error (quoted, never translated or summarised) — its only reason to exist is to be pasted into a bug report, so it contains no control that changes a setting.
39
+ - **Tabbed settings card (accounts / models / capabilities / diagnostics)** — the card used to be a single 3,500-pixel column that mixed two things with completely different commit semantics: **operating accounts** (who is serving, how much is left, switch order, check-in log) and **editing the catalog** (ticking models, context budgets, reasoning tiers, turning thinking off). An account change takes effect on the click; a catalog change is a draft that needs Save — so the Save button often sat three screens away from the thing it saves. Now each tab does one thing, and **switching tabs neither refetches nor drops an unsaved draft** (inactive panels stay mounted and are merely `hidden`, so they also leave the accessibility tree). The third tab, Capabilities, holds the **grants**: let the plugin drive the local CodeBuddy CLI, let the model buy an image on its own, where the credit readout is drawn. Each takes effect on the click and never passes through Save, and each is expected to grow sub-options of its own (a working directory for the CLI route, a size or price cap for the image tool) — which is why they no longer share one thin strip above the account table. The fourth tab, Diagnostics, is a read-only fact table: current region, upstream host, the account actually serving (by NAME, never by id), sign-in validity, how many models the catalog holds, how many are enabled, the pool switch, and the **raw text** of the upstream's most recent error (quoted, never translated or summarised) — its only reason to exist is to be pasted into a bug report, so it contains no control that changes a setting.
40
40
  - **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.
41
41
  - **Image generation tool `workbuddy_draw_image` (CN only, OFF by default)** — hands WorkBuddy's text-to-image capability to the agent: say "draw me a cat sunning itself on a windowsill" in the conversation and the model calls the tool itself, then posts the image back. **It is off by default**, because it is the one capability here where the MODEL decides to spend: about 5.71 credits an image, with no per-call confirmation available in the host. Tick 「Let the model draw images」 under the card's Accounts tab to enable it; while it is off the tool is **not registered at all**, so the model never sees it and cannot attempt a call that would fail. **It is CN-only** — the international catalog lists no image model at all (not unimplemented; upstream has none). **Endpoint and request body are copied from the official CLI's ImageService** (`POST /v2/images/generations`, model `hunyuan-image-alpha`), and the product is a 24-hour COS signed URL the plugin tells you to download in time. **It really costs money**: about **5.71 credits per image** measured, so the tool description states the price outright, a failure carries a 30-second cooldown (while a bad parameter, a missing sign-in or an unknown model costs nothing and does not cool down), and there is no "draw four at once" parameter that could spend uncontrollably. The definition is hand-written raw JSON Schema, **adding no dependency**. See [docs/IMAGE_GEN.md](docs/IMAGE_GEN.md) for the evidence, protocol and live run.
42
42
 
@@ -48,7 +48,7 @@ It depends on the WorkBuddy client's own endpoints (not a public API), so the pl
48
48
  - **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).
49
49
  - **Benefits: a marker for what is unclaimed, plus a per-account Claim button** — the card marks, account by account, anything in the upstream's benefit campaigns (compensation / gifts) that **has not been claimed yet**. The data comes from the upstream's own two status endpoints; in the marking stage the plugin is **read-only** and never calls the two write endpoints. Three display rules all follow the upstream's state: `active && !claimed` shows the row, `claimed` removes it entirely, and an expired campaign (upstream flips `active` to `false`) disappears by itself — the plugin **never compares `end_time` against the local clock**, so a skewed machine cannot argue with the server; `end_time` only drives the "three days left" highlight. `active` alone is not enough to decide: an already-claimed account still reports `active: true`, and reading it alone would leave a claimed campaign hanging there forever. The read reuses the check-in pipeline (once per account, cached for 30 minutes, no request while the account pool is off), and a failing endpoint counts as "unread" rather than caching "nobody has benefits". Each row ends in a solid amber **Claim pill**, and one click claims **one campaign of one account**: there is no "claim everything", because a click spends one irrevocable real entitlement — and the write route **requires `accountId`**, so the request itself cannot batch. Before writing it **re-reads** the status and decides again; anything already claimed or expired returns 409 without touching upstream. After a successful claim it answers with the upstream's **fresh reading** and refreshes the card, with no optimistic update — even a silent upstream failure cannot leave a false "claimed" behind — and it also drops that account's cached benefit state, so the pill cannot linger on a stale status.
50
50
  - **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.
51
- - **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).
51
+ - **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 **Sign in with QR code** button (labelled **Sign in on the web** for the international build — that region uses a browser login page, there is no QR; it sits just above **More settings** and acts immediately, with nothing to save) 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).
52
52
  - **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.
53
53
  - **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.
54
54
  - **Secure loopback shim** — one random port + in-process random secret per region; the real WorkBuddy token is never handed to pi-ai.
@@ -81,14 +81,14 @@ Credentials are read (read-only) from the WorkBuddy desktop app's own auth file.
81
81
  <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" />
82
82
  </p>
83
83
 
84
- 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.
84
+ 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 selected** or **Test selected**; switch on **Enable the account pool** and the plugin will carry on through a failure by itself.
85
85
 
86
86
  **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.
87
87
 
88
88
  ### The two batch actions
89
89
 
90
- - **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**.
91
- - **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.
90
+ - **Check in selected** — 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**.
91
+ - **Test selected** — 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.
92
92
 
93
93
  **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.
94
94
 
@@ -288,7 +288,7 @@ dsh plugin --profile web exec dsh-connect-workbuddy status
288
288
 
289
289
  ## CLI route (optional): let CodeBuddy do the work
290
290
 
291
- > Off by default (`cliBridge.enabled: false`). While it is off, the plugin behaves **exactly** as before: no extra tool, no extra command, no extra request.
291
+ > Off by default (`cliBridge.enabled: false`). While it is off, no tool and no command is registered, and the provider route is untouched. **The one exception** is the live pill described below: its read-only route and client component are registered unconditionally (the client needs them to tell "no turn" from "no route"), so an open page still asks that local route every 5 seconds — it never touches the upstream and spends nothing, and answers with an empty array.
292
292
 
293
293
  This route uses CodeBuddy as a **tool** rather than as a model provider: the plugin exposes a `codebuddy_ask` tool and a `/codebuddy` command, and a local, already signed-in CodeBuddy CLI runs one real turn in its own process — reading files, running `git`, editing code — then hands the conclusion back.
294
294
 
@@ -321,10 +321,26 @@ Every other key has a default. See the table below. Changing them does not need
321
321
  | `codebuddy_ask` tool | the model decides to call it | the session workspace, or a `working_dir` the caller passes, or `cliBridge.workingDir` | CodeBuddy quota, plus the DSH tokens of the wake-up the result is injected into (`injectResult` is on by default) |
322
322
  | `/codebuddy` command | a human types it | **always the current session workspace** | the same — the command itself skips the model, but by default the result is injected back into it |
323
323
 
324
- An **empty** `/codebuddy` means "review the uncommitted changes in this workspace" (that invocation is forced to the read-only `dontAsk` permission mode and is told to run `git diff` itself and report only what it actually saw); **text after it** is sent through verbatim.
324
+ An **empty** `/codebuddy` means "review the uncommitted changes in this workspace" (that invocation is forced to the read-only `dontAsk` permission mode and is told to run `git diff` itself and report only what it actually saw); **text after it** is sent through verbatim; **a leading `--edit`** grants write permission for that one turn (`acceptEdits`, e.g. `/codebuddy --edit fix this null deref`) without changing the default. `--edit` on its own means "review and fix what you find".
325
325
 
326
326
  When the turn finishes, the conclusion is **injected back into the session model** (`cliBridge.injectResult`, on by default): an idle session is woken for a new turn, a running one gets it queued for its next step. **That step really spends DSH tokens** — set `injectResult` to `false` to leave the model untouched, and the `/codebuddy` result is then shown to you only. The injected text starts with `[dsh-connect-workbuddy · 由 /codebuddy 自动回注,不是用户输入]` so neither you nor the model mistakes it for a human message.
327
327
 
328
+ ### While it runs: the pill under the composer
329
+
330
+ A Host tool call is **one-shot**: the interface learns nothing until `execute()` returns. The minutes in between — reading files, running `git`, retrying — used to be a blank spinner, and a user could not tell "still working" from "hung".
331
+
332
+ So the plugin registers a **live pill** on `conversation.composer.dock` (the row under the composer, next to the credit readout): `CodeBuddy · 7 tools · 42s · last: Bash`. It is fed by the plugin's own read-only route, `/plugins/dsh-connect-workbuddy/cli-live`, which serializes the host-side live board (`src/cli-bridge/live.ts`).
333
+
334
+ - **It appears only while a turn is live.** With nothing running the component renders `null`, so the row is untouched — and the route is registered **unconditionally** (even with the switch off) so the client can tell "no turn" from "no route" without knowing the switch's state.
335
+ - **It describes only the turn this session started.** The live board is **host-wide** (one route answers for every session in the process), so the seat — `conversation.composer.dock`, declared `scope: 'session'` — hands the registration a `sessionId` and the pill keeps only those rows. Without that filter, session A's composer would describe session B's turn (or another tab's): a line about a directory the reader is not in, with a counter that keeps moving. When the host passes no `sessionId` (an older host, or a test mounting the component directly) the filter is **off** — losing a diagnostic readout is worse than showing a neighbouring turn — and a row the host could not attribute (`sessionId: ''`) is kept for the same reason: it is still evidence of real work.
336
+ - **No provider gate.** The credit readout renders only while the session's model is a WorkBuddy provider, because it reports that provider's quota. This pill reports a local process, and the CLI route is deliberately model-independent: a turn started from a DeepSeek session has to be visible too. `cliBridge.enabled` needs no separate check either — with the bridge off no turn can begin, the board is empty, and the pill renders nothing.
337
+ - **The seconds tick locally, without extra requests.** The document is polled every 5s; the counter is advanced by a 1s local tick from `startedAt`. Concurrent sessions share **one** in-flight request (module cache + inflight dedupe), so N tabs do not become N polls.
338
+ - **Clock skew is corrected against the host.** `startedAt` is the host's clock; a browser a minute behind would compute a negative age. The document ships `serverTime` and the client derives an offset from it — recomputed **only when a fresh document arrives**, because a cached one carries the old `serverTime` and re-measuring each poll would subtract the elapsed time again and freeze the counter.
339
+ - **The tool count is an approximation.** It counts `tool_use` frames in the CLI's NDJSON; `phase` is `starting` / `running` / `finishing`, and `lastTool` is the most recent tool name. These are progress signals for a human, not a billing figure.
340
+ - **It neither lies nor lingers.** A document whose `serverTime` is not a finite number is discarded whole (otherwise the label would print `NaNs`), and three consecutive failed polls (15s) clear the pill rather than leave a ghost describing a turn that ended. With several turns live in one session it shows the newest and appends `+N`; the directory goes in the tooltip only, because this dock is one line.
341
+
342
+ > The pill lives in the plugin's client entry (`dsh.client`), so changing it means **rebuilding `lib/client.js` and restarting the Host**, same as host-side code.
343
+
328
344
  ### Permissions: read-only by default
329
345
 
330
346
  `permissionMode` defaults to **`dontAsk`**: read-only tools (Read/Glob/Grep) run, writes and command execution are refused, and the turn **does not hang** — the CLI ends the turn itself and reports which tools were denied.
package/README.md CHANGED
@@ -39,10 +39,10 @@
39
39
  - **双供应商并行接入(国内版 + 国际版)** —— 国内版注册为 `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 自身模型菜单一致。
40
40
  - **插件卡片 tab 切换** —— 设置卡片顶部为「国内版 / 国际版」两个 tab,各含独立的账号池与模型管理;每个 tab 的账号、目录、勾选与未保存草稿完全隔离——在一个 tab 里切账号或刷新模型,不会触碰另一边的运行时目录与会话。
41
41
  - **可单独关闭任一版本供应商** —— 每个 tab 右侧有一个勾选框(默认都勾选)。**取消勾选即把该供应商从 DSH 模型选择器里彻底撤掉**(不是只藏起 tab):它的路由与「设置 → 模型」页的目录条目一并摘除,启动时也不再为它发无用请求。账号、积分、目录与勾选**全部保留**,重新勾选即完整恢复。典型用法是「我根本用不到国际版」——关掉它,让选择器干净。注意:若某会话此前选中的正是被关闭供应商的模型,该会话再调用会报 `NO_ADAPTER`,卡片会在关闭态的 tab 上明确提示这一点,不会静默失败。
42
- - **设置卡片分三栏:账号 / 模型 / 诊断** —— 原来这张卡片是一列 3000 多像素的长页,把两类互不相干的事情混排在一起:**运维账号**(谁在服务、还剩多少、切换顺序、签到日志)与**编辑目录**(勾模型、上下文预算、推理档、关闭思考)。它们连提交语义都不同——账号改动点一下即生效,目录改动是草稿、必须点保存——所以「保存」按钮常常离它要保存的东西隔着三屏。现在分三栏,一栏只做一件事;**切栏既不会重新拉取,也不会丢掉未保存的目录草稿**(非活动栏留在页面里,只是通过 `hidden` 不可见,因此也不进入无障碍树)。第三栏「诊断」是只读的事实表:当前区域、上游域名、真正在服务的账号名、登录有效期、目录里共几个模型、启用了几个、账号池开关,以及上游最近一次错误的**原文**(原样引用,不翻译、不概括)——这一栏存在的唯一理由就是能被整段贴进问题反馈,所以里面没有任何会改设置的控件。
42
+ - **设置卡片分四栏:账号 / 模型 / 能力 / 诊断** —— 原来这张卡片是一列 3000 多像素的长页,把两类互不相干的事情混排在一起:**运维账号**(谁在服务、还剩多少、切换顺序、签到日志)与**编辑目录**(勾模型、上下文预算、推理档、关闭思考)。它们连提交语义都不同——账号改动点一下即生效,目录改动是草稿、必须点保存——所以「保存」按钮常常离它要保存的东西隔着三屏。现在分栏,一栏只做一件事;**切栏既不会重新拉取,也不会丢掉未保存的目录草稿**(非活动栏留在页面里,只是通过 `hidden` 不可见,因此也不进入无障碍树)。第三栏「能力」放的是**授权类开关**——允许调用本机 CodeBuddy CLI、允许模型自主生图、积分读数显示在哪——它们点一下即生效、从不经过保存,而且每一个都留着长自己子选项的位置(CLI 的工作目录、生图的尺寸或价格上限),所以不挤在账号表格上方那一小条里。第四栏「诊断」是只读的事实表:当前区域、上游域名、真正在服务的账号名、登录有效期、目录里共几个模型、启用了几个、账号池开关,以及上游最近一次错误的**原文**(原样引用,不翻译、不概括)——这一栏存在的唯一理由就是能被整段贴进问题反馈,所以里面没有任何会改设置的控件。
43
43
  - **模型管理** —— 从上游刷新完整模型目录,逐项勾选启用或禁用;刷新是草稿操作,点保存才生效。上游同时给出积分倍率、上下文/输出上限与推理档位;**图片输入按厂商核验表判定**:刷新时只有厂商文档明确写为原生多模态的模型才自动勾选,文档写明纯文本的不勾,**没有厂商资料的一律按「未核实」不猜**(上游的 `supportsImages` 是平台侧声明,不等于模型能看图,不参与判定)。用户仍可随时手动勾选,勾选状态保存在本地。**每个模型行还有「可关闭思考」(`off`)勾选框**:决定要不要为该模型提供「完全关闭思考」这一档位。默认由内置复核名单给出(上游声称可关闭、实测却对它返回 HTTP 400 的 9 个模型默认不勾),你可以按模型改勾/取消——**取消**用于当前不需要该档位的模型;**名单内那 9 个模型不可勾选**(行上标「拒绝 off」,悬停有说明):该档位被勾上后不只是多一个选项,pi-ai 会把它当作**所有未指定档位请求的默认值**,压缩摘要请求正属于这一类,勾一次等于让该模型的每一次 `/compact` 都 400 / 11133(issue #83)。名单外的模型仍完全由你的勾选与自身声明决定;首次保存即写入你的勾选,之后不再重算。
44
- - **模型行上的活动徽标** —— 上游的模型目录里带着活动信息(免费试用、夜间折扣、限时免费、订阅优先等),插件**不新增任何请求**(复用刷新模型时本来就会拉的那份目录配置),直接在模型行上画一枚胶囊徽标:颜色与文案都由上游给出,悬停能看到上游的活动说明、活动的起止日期与每日生效时段(例如「夜间 23:00–7:50」)。**徽标是「此刻」的函数**:活动只在它自己声明的时间窗内显示,窗口一关徽标就自己消失;跨零点的时段按上游声明的时区判断;同一个模型命中多条活动时只画优先级最高的那条。上游没给结束时间的权益标记(如「订阅优先」)则常驻,直到上游把它下架。**徽标只是提示,不改变模型行为**——它标注的是上游此刻给出的活动与折扣,不替你做选择。(两个区域的上游目录都下发活动字段:国内版按 App 身份读 `/v3/config` 时带 8 条促销 + 1 条等级标记,国际版带 5 条 + 1 条;取证与判定规则见 [docs/PROMO_BADGE.md](docs/PROMO_BADGE.md)。)
45
- - **模型行上的繁忙程度** —— 上游另有一条只读探针(`POST /v2/chat/busy`,一次最多 50 个 id,免费、不动账号状态)回答「这个模型此刻多忙」,插件在每次刷新目录时顺带问一次,把等级拼在倍率后面:`Hy4 preview · x0.29 · 极度拥堵`。**只画 `busy` / `congested` / `unavailable`**(轻微拥堵 / 极度拥堵 / 不可用):`idle` 有词但不画——实测 20 个模型里 7 个空闲,每行都挂就成了装饰而不是警告;`normal` 与探针没回答的 `unknown` 一律不画,**「没被告知」不会被当成「一切正常」**。**等级不落盘**:目录是能活过重启的事实,负载只是那一分钟的事实,所以等级只盖在运行时目录上、随进程消失,探针失败只记一条 warn,绝不挡住目录刷新。实测这条探针在 CLI 与 App 两种身份下答案逐字节相同,50 个 id 的上限也真实存在(62 个 → HTTP 400),19 个模型 186 ms 返回。
44
+ - **模型行上的活动徽标** —— 上游的模型目录里带着活动信息(免费试用、夜间折扣、限时免费、订阅优先等),插件**不新增任何请求**(复用刷新模型时本来就会拉的那份目录配置),直接在模型行上画一枚胶囊徽标:颜色与文案都由上游给出,悬停能看到上游的活动说明、活动的起止日期与每日生效时段(例如「夜间 23:00–7:50」)。**徽标是「此刻」的函数**:活动只在它自己声明的时间窗内显示,窗口一关徽标就自己消失;跨零点的时段按上游声明的时区判断;同一个模型命中多条活动时只画优先级最高的那条。上游没给结束时间的权益标记(如「订阅优先」)则常驻,直到上游把它下架。**徽标只是提示,不改变模型行为**——它标注的是上游此刻给出的活动与折扣,不替你做选择。**同一枚徽标也出现在 DSH 的模型选择器里**:菜单每一行都在模型名之后、队列图标之前画同样的胶囊 —— 菜单行用上游的短词(「夜间」「限免」),输入框旁那个触发器用完整词(「夜间折扣」「独家限免」);判定用的是同一份数据、同一个时钟,因为选模型的那一屏正好是最该看到「现在免费吗」的地方。(两个区域的上游目录都下发活动字段:国内版按 App 身份读 `/v3/config` 时带 8 条促销 + 1 条等级标记,国际版带 5 条 + 1 条;取证与判定规则见 [docs/PROMO_BADGE.md](docs/PROMO_BADGE.md)。)
45
+ - **模型行上的繁忙程度(仅国内版)** —— 上游另有一条只读探针(`POST /v2/chat/busy`,一次最多 50 个 id,免费、不动账号状态)回答「这个模型此刻多忙」,插件在每次刷新目录时顺带问一次,把等级画成模型名旁边的一枚小人图标:排队人数 = 小人个数(正常 1 个黄人 / 轻微拥堵 2 个橙人 / 极度拥堵 3 个红人),**空闲**则是一枚**绿色小火箭**(没人排队 = 可以全速跑)——空闲不画成「绿色的 1 个人」,因为 12px 下「没人等」与「有 1 个人在等」必须一眼分开;**不可用**是一个划掉的小人。探针没回答的 `unknown` **一律不画**,**「没被告知」不会被当成「一切正常」**;颜色跟随主题,`title` 悬停文字(「极度拥堵」四个字)仍在。**等级不落盘**:目录是能活过重启的事实,负载只是那一分钟的事实,所以等级只盖在运行时目录上、随进程消失,探针失败只记一条 warn,绝不挡住目录刷新。国内版实测这条探针在 CLI 与 App 两种身份下答案逐字节相同,50 个 id 的上限也真实存在(62 个 → HTTP 400),19 个模型 186 ms 返回。**国际版没有这个徽标,因为国际网关根本没有这条接口** —— 实测 `www.workbuddy.ai` 对 `/v2/chat/busy` 回 **404**(`/v3/chat/busy`、`/v2/models/busy` 等同族路径也全是 404),而同一份凭据读它的 `/v3/config` 正常返回 28 个模型、**没有任何负载/排队字段**。这不是本插件没实现,也不是配置或权限问题:**上游国际版从未提供这个数据**。所以国际版只显示模型名与活动徽标,探针那条请求失败后安静退化成「没有徽标」,不会挡住模型列表,也不会用延迟或积分去猜一个「忙不忙」。
46
46
  - **排队时说清楚你排在第几位** —— 上游用三个业务码表达排队(`6020` 等待、`6021` 队列满、`6022` 已达上限)。撞上它们时,插件不再给一句上游报错,而是「模型排队中:你排在第 8777 位,队列共 9021 个请求。请稍后重新发送这条消息,或换一个模型」。**排队不会触发换号**:队列跟随的是模型与平台级的队伍,换个账号拿到的还是同一个位次,所以这里既不走账号池也不额外烧请求(上游 `/v2/chat/queue/status` 回的 `message` 是 i18n key 而不是人话,插件因此从不转述它,只用自己的措辞)。**排队不会自动重发,也不会被自动重试**——插件没有排队轮询循环(它需要排队答案在 OpenAI 兼容通道上的真实信封,没测到就不写),并且明确告诉宿主不要重试(`x-should-retry: false`):一次排队在几千位之后,重试十几次也轮不到,只会把真实请求白白花掉。位次报出来,由你决定是重发还是换模型。
47
47
  - **模型可用性测试(含冷却恢复时间)** —— 限量模型的冷却时间过去只能靠反复试错去猜。现在每个模型行有一个「测试」按钮,直接告诉你它此刻能否使用、不能用时何时恢复。**它发的是一条真实体积的请求(约 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 之后可再用」。三处来源都没说时,插件**不编造倒计时**,如实显示「上游未给出何时恢复」。限流**不会**去借月周期包的刷新时间(那会让人为几秒的限流去等几周)。探测在后台发起,**凭据不会传到页面**;结果不写入设置,也不改变你已保存的勾选。
48
48
 
@@ -52,10 +52,10 @@
52
52
  - **对话输入框积分读数(国内版 / 国际版自动切换)** —— 对话输入框左侧工具栏常驻一行可点击的读数(如 `WB CN · 1,072`),**按本会话当前选中的模型自动切换区域**:选中 WB CN 的模型就显示国内版余额,选中 WB AI 的模型就显示国际版余额;用其它 provider 的模型时该读数完全不出现。每 5 分钟自动刷新,也可点开手动刷新。点击读数弹出面板:**按账号列出的余额表格**,点某一行即可切换到该账号(与卡片上的账号选择是同一个设置);**当前不能用的账号会在行内标出原因**(如「被限流」「积分耗尽」),标注口径与账号池表格一致。面板里的余额只在打开时才去查,因此不会让 5 分钟的后台刷新按账号数翻倍。要关掉某一边,就在卡片对应区域的 tab 下取消勾选「在输入框工具栏显示积分读数」;该区域的供应商被关闭时,读数不显示、开关也置灰。同一个开关下面还有「显示位置」这一行:默认在输入框工具栏(与模型选择同一行),改成「输入框下方」后读数落到轮次/步数/token 统计那一行——装了别的插件把工具栏座位占掉时用得上。
53
53
 
54
54
  <img src="docs/assets/dsh-connect-workbuddy-composer-credits.png" width="360" alt="对话输入框积分面板:国内版账号余额表格、刷新积分按钮与最近刷新时间" />
55
- - **账号池:多账号批量操作与自动换号** —— 把要用的账号勾进池子,即可**一键签到所有账号**、**一键测试所有账号 × 你指定的模型**(默认挑该区域倍率为 0 的免费模型);开启**启用账号池**即同时打开**自动换号**——某个请求在上游失败时,插件会依次用池内其他可用账号重试,直到有一个接手、或全部不可用为止,然后才把错误报给你。成员**默认一个都不含**、必须显式勾选(「本机有登录」不等于「要拿它去签到和花积分」);**开启后由池子的排序决定每一次请求用谁**(可用优先,其次快过期先花、积分高、凭据更新的);失败时依次用池内其他可用账号重试。排序**从不写回你保存的选择**——关掉开关,你选定的账号立刻完全生效。只有请求本身不合法(HTTP 400)不换号:同一个请求体换谁都是 400,重试只会白等。**这个开关只管「插件要不要自己决定」**:关掉它,插件不再替你选账号、也不再替你换号,但**签到与测试仍然可用**(它们是手动动作)。测试走的是与模型行同一套真实体积探测,因此**有真实成本**;**不提供自动签到**——签到会真实领取奖励,只在你点按钮时执行。详见[账号池](#账号池)。
55
+ - **账号池:多账号批量操作与自动换号** —— 把要用的账号勾进池子,即可**签到勾选的账号**、**测试勾选的账号 × 你指定的模型**(默认挑该区域倍率为 0 的免费模型);开启**启用账号池**即同时打开**自动换号**——某个请求在上游失败时,插件会依次用池内其他可用账号重试,直到有一个接手、或全部不可用为止,然后才把错误报给你。成员**默认一个都不含**、必须显式勾选(「本机有登录」不等于「要拿它去签到和花积分」);**开启后由池子的排序决定每一次请求用谁**(可用优先,其次快过期先花、积分高、凭据更新的);失败时依次用池内其他可用账号重试。排序**从不写回你保存的选择**——关掉开关,你选定的账号立刻完全生效。只有请求本身不合法(HTTP 400)不换号:同一个请求体换谁都是 400,重试只会白等。**这个开关只管「插件要不要自己决定」**:关掉它,插件不再替你选账号、也不再替你换号,但**签到与测试仍然可用**(它们是手动动作)。测试走的是与模型行同一套真实体积探测,因此**有真实成本**;**不提供自动签到**——签到会真实领取奖励,只在你点按钮时执行。详见[账号池](#账号池)。
56
56
  - **福利活动:待领取标记 + 单账号「领取」按钮** —— 卡片会逐账号标出上游活动(补偿 / 赠礼)里**还没领的那一份**,数据来自上游自己的两个状态接口,标记阶段**只读**、两个写接口一次都不调用。显示规则完全由上游状态决定:`active && !claimed` 才显示,`claimed` 整行消失,活动过期(`active` 翻 `false`)自动消失——**不用本地时钟去比 `end_time`**,本机时间偏了也不会与服务端打架;`end_time` 只用于 3 天内临期高亮。`active` 单独看不行:已领取的账号 `active` 仍为 `true`,只看它会让领过的账号永远挂着待领取。读取复用签到的管线(每账号一次、30 分钟缓存、账号池关闭时不发请求),任一接口失败按「未读」处理,不会把一次失败缓存成「全都没福利」。行尾是一个实心琥珀色的**胶囊按钮「领取」**,一次只领**一个账号的一个活动**:没有「全部领取」,因为一次点击花掉的是一份不可撤销的真实权益,写路由也**强制要求 `accountId`**,请求本身无法批量领取。领取前**重新读取**状态再判定,已领取或已过期一律 409 且不调用上游;领取后用上游的**新读数**作答并刷新卡片,不做乐观更新——即使上游静默失败也不会留下「已领取」的假象,成功后同时清掉该账号的福利缓存,按钮不会顶着旧状态赖着不走。
57
57
  - **凭据失效时给出可执行的建议** —— 上游拒绝当前账号的 token 时,插件会**实际探测**本机其他账号:若有账号仍然可用,就直接告诉你「切换到它即可」(并附一键切换),而不是笼统地让你重新登录——被钉在一个作废的旧备份上时,重新登录并不能解决问题。只有本机确实没有别的账号可切换时,才会提示重新登录。
58
- - **扫码添加账号、批量导入导出、逐账号测试/删除** —— 不必先装 WorkBuddy 桌面 App:卡片里的「扫码添加账号」(国际版为「网页登录」——那边是浏览器登录页,没有二维码)点开即弹出对话框,并在浏览器里**自动打开**授权页;被浏览器拦截时对话框内提供链接、复制与「重新打开页面」兜底。所有凭据统一落到插件自己的凭据库(`$DSH_HOME/workbuddy-vault/<region>/<accountId>.json`,一账号一文件),**桌面 App 的认证文件始终只读、永不被改动**。账号表格每一行右侧有「测试」与「删除」:测试与批量测试走同一套探测,结果可比;删除只删插件自己存的那份,并把这个账号**暂时隐藏**:删掉后立刻消失,也不会被自动轮询带回来;点「重新检测账号」即可找回(重新登录或导入同一账号也会清除隐藏记录)。**桌面 App 的登录始终不动**——删除是「本插件不再列出它」,不是退出 App 登录。「导出 / 导入账号」可把凭据搬到另一台机器:多选导出 JSON → 选文件后先看**脱敏预览**(只显示字段与是否有 token,明文不进预览)→ 勾选导入。导入按账号去重覆盖,**只有确认属于该区域**的记录才入库,认不出的域名会被跳过并如实计入「已跳过」。详见[账号池](#账号池)。
58
+ - **扫码添加账号、批量导入导出、逐账号测试/删除** —— 不必先装 WorkBuddy 桌面 App:卡片里的「扫码登录」按钮(国际版为「网页登录」——那边是浏览器登录页,没有二维码;这一行就在「更多设置」上方,点一下立即生效,不需要保存)点开即弹出对话框,并在浏览器里**自动打开**授权页;被浏览器拦截时对话框内提供链接、复制与「重新打开页面」兜底。所有凭据统一落到插件自己的凭据库(`$DSH_HOME/workbuddy-vault/<region>/<accountId>.json`,一账号一文件),**桌面 App 的认证文件始终只读、永不被改动**。账号表格每一行右侧有「测试」与「删除」:测试与批量测试走同一套探测,结果可比;删除只删插件自己存的那份,并把这个账号**暂时隐藏**:删掉后立刻消失,也不会被自动轮询带回来;点「重新检测账号」即可找回(重新登录或导入同一账号也会清除隐藏记录)。**桌面 App 的登录始终不动**——删除是「本插件不再列出它」,不是退出 App 登录。「导出 / 导入账号」可把凭据搬到另一台机器:多选导出 JSON → 选文件后先看**脱敏预览**(只显示字段与是否有 token,明文不进预览)→ 勾选导入。导入按账号去重覆盖,**只有确认属于该区域**的记录才入库,认不出的域名会被跳过并如实计入「已跳过」。详见[账号池](#账号池)。
59
59
  - **积分信息在账号池里** —— 成员表逐账号显示剩余积分与最近到期时间,排序的第二键就是积分(谁多谁优先)。查询是**只读**的、不消耗积分。
60
60
  - **凭据路径多候选** —— macOS / Windows / Linux 逐一探测默认位置,也支持环境变量与卡片内直接指定。**未登录时卡片会列出「探测过哪些路径、各自为什么没成」**:五档原因(不存在 / 读不了 / 没有可用 token / 字段已加密且拿不到密钥 / 属于另一个地区)分开呈现,因此「已经登录了、但桌面 App 不在场」这种最容易误判的情况会明说需要 App 在场,而不是叫你重新登录一次。**属于另一个地区**那一档尤其不同:登录信息是真实可用的,只是挂在另一个标签页下,卡片会直接给出结论「切换标签页即可,不用重新登录」。列表只讲一次——提示段落说结论,折叠列表给路径细节,两处不重复同一件事。
61
61
  - **安全 loopback shim** —— 每区域一个随机端口 + 进程内随机 secret,真实 WorkBuddy token 不交给 pi-ai。
@@ -85,10 +85,10 @@ DSH PiAiAdapter(每个 provider 一套)
85
85
  ## 账号池
86
86
 
87
87
  <p align="center">
88
- <img src="docs/assets/dsh-connect-workbuddy-account-pool.png" width="900" alt="dsh-connect-workbuddy 账号池区块:一键签到所有账号、一键测试所有账号、成员勾选与自动换号" />
88
+ <img src="docs/assets/dsh-connect-workbuddy-account-pool.png" width="900" alt="dsh-connect-workbuddy 账号池区块:签到勾选的账号、测试勾选的账号、成员勾选与自动换号" />
89
89
  </p>
90
90
 
91
- 一台机器上往往登录过多个 WorkBuddy 账号。**账号池**把这件事收成一次点击:把要用的账号勾进池子,然后「一键签到所有账号」或「一键测试所有账号」;再打开**启用账号池**,插件就会在请求失败时自动换号接着跑。
91
+ 一台机器上往往登录过多个 WorkBuddy 账号。**账号池**把这件事收成一次点击:把要用的账号勾进池子,然后「签到勾选的账号」或「测试勾选的账号」;再打开**启用账号池**,插件就会在请求失败时自动换号接着跑。
92
92
 
93
93
  **每个区域(国内版 / 国际版)各有独立的一池**:成员与目标模型互不影响——两边的账号本来就属于不同的上游栈。**签到只有国内版有**:海外区域不显示签到按钮,也没有那一列。
94
94
 
@@ -134,8 +134,8 @@ DSH PiAiAdapter(每个 provider 一套)
134
134
 
135
135
  ### 两个批量动作
136
136
 
137
- - **一键签到所有账号** —— 逐个账号签到。**签到前先查当天状态**,已签到的直接标记为「已签到」而**不发写入请求**(幂等,不重复领取);任何单个账号失败只标记该行,**不中断整批**。
138
- - **一键测试所有账号** —— 对每个成员发一次**真实体积**的探测请求(约 25k 输入 token;与模型行的「测试」是同一套 `probeModel`,因而同样继承两个实测结论:限流**按请求体积**触发、恢复时间写在**响应体**里而不是响应头),得出该账号此刻能否用这个模型。
137
+ - **签到勾选的账号** —— 逐个账号签到。**签到前先查当天状态**,已签到的直接标记为「已签到」而**不发写入请求**(幂等,不重复领取);任何单个账号失败只标记该行,**不中断整批**。
138
+ - **测试勾选的账号** —— 对每个成员发一次**真实体积**的探测请求(约 25k 输入 token;与模型行的「测试」是同一套 `probeModel`,因而同样继承两个实测结论:限流**按请求体积**触发、恢复时间写在**响应体**里而不是响应头),得出该账号此刻能否用这个模型。
139
139
 
140
140
  **测试目标是哪个模型**:默认是**该区域目录里倍率为 0 的免费模型**(实测国内版当前只有 `hy3` 一个;**不写死名字**,因为上游会改倍率——`deepseek-v4.1-flash` 就已从 0 变成 0.11),也可以在下拉里指定一个具体模型。指定的模型若已离开目录,**手动动作会明确拒绝**(`target-model-stale`),而不是偷偷换一个——你点的是「测这个模型」,换掉就是答非所问。
141
141
 
@@ -298,7 +298,7 @@ dsh plugin --profile web exec dsh-connect-workbuddy status
298
298
 
299
299
  ## CLI 路线(可选):让 CodeBuddy 直接干活
300
300
 
301
- > 默认关闭(`cliBridge.enabled: false`)。关闭时插件的行为与之前**完全一致**:不多一个工具、不多一条指令、不多一个请求。
301
+ > 默认关闭(`cliBridge.enabled: false`)。关闭时不注册工具、不注册指令,provider 那条路一行都没变。**唯一的例外**是下面那颗实时胶囊:它的只读路由与客户端组件无条件注册(客户端要靠它区分「没有回合」和「没有这条路由」),所以关着时每个打开的页面仍会每 5 秒问一次那条本地路由——不碰上游、不花额度,返回一个空数组。
302
302
 
303
303
  这条路不把 CodeBuddy 当模型提供方,而是当**工具**:插件提供一个 `codebuddy_ask` 工具和一条 `/codebuddy` 指令,由本机已登录的 CodeBuddy CLI 在自己的进程里跑一轮真实任务——读文件、跑 `git`、改代码——再把结论交回来。
304
304
 
@@ -331,10 +331,26 @@ CLI 路线逃掉的是**客户端指纹层**,不是账号层。它用 `CODEBUD
331
331
  | `codebuddy_ask` 工具 | 模型自己决定调用 | 会话工作区;或调用方传的 `working_dir`;或 `cliBridge.workingDir` | CodeBuddy 的配额 + 那一次回注唤醒的 DSH token(`injectResult` 默认开) |
332
332
  | `/codebuddy` 指令 | 用户敲 | **自动取当前会话的工作区** | 同上——指令本身不经过模型,但默认会把结论回注给模型 |
333
333
 
334
- `/codebuddy` 后面**留空** = 让 CodeBuddy 评审当前工作区里未提交的改动(这种调用强制只读的 `dontAsk` 权限模式,并且要求它自己跑 `git diff`、只报确实看见的问题);**写了字** = 原样把这句话发过去。
334
+ `/codebuddy` 后面**留空** = 让 CodeBuddy 评审当前工作区里未提交的改动(这种调用强制只读的 `dontAsk` 权限模式,并且要求它自己跑 `git diff`、只报确实看见的问题);**写了字** = 原样把这句话发过去;**以 `--edit` 开头** = 这一轮允许它改文件(`acceptEdits`,只影响这一次,不改变默认值,例如 `/codebuddy --edit 修一下这个空指针`;`--edit` 单独用 = 评审并顺手修掉找到的问题)。
335
335
 
336
336
  跑完之后结论会**回注给会话模型**(`cliBridge.injectResult`,默认开):会话空闲时直接唤醒它起一轮,正在跑就排进它的下一步。**这一步真的花 DSH token**——把 `injectResult` 设成 `false` 才是「完全不碰模型」,那时 `/codebuddy` 的结论只显示给你看。回注的文本以一行 `[dsh-connect-workbuddy · 由 /codebuddy 自动回注,不是用户输入]` 开头,免得你(或者模型)把它当成自己打的字。
337
337
 
338
+ ### 跑的时候看得见:输入框下方那颗胶囊
339
+
340
+ 工具调用在 DSH 里是**一次性**的:`execute()` 什么时候返回,界面才知道发生了什么。中间那几分钟(读文件、跑 `git`、反复试)本来是一片空白,用户分不清「还在干活」和「已经卡死」。
341
+
342
+ 所以插件在 `conversation.composer.dock`(输入框下方那一行,和额度胶囊同排)挂了一颗**实时胶囊**:`CodeBuddy · 7 tools · 42s · last: Bash`。它由插件自己的只读路由 `/plugins/dsh-connect-workbuddy/cli-live` 供数,那个路由把宿主侧的 live board(`src/cli-bridge/live.ts`)序列化出去。
343
+
344
+ - **只在真有回合在跑时出现。** 没有回合时组件渲染 `null`,那一行一个像素都不变——这条路由是**无条件注册**的(开关关着也在),客户端才能把「没有回合」和「没有这条路由」区分开,而不用知道开关状态。
345
+ - **只描述本会话的回合。** live board 是**宿主级**的(一条路由回答进程里所有会话),所以座位 `conversation.composer.dock` 声明成 `scope: 'session'`、`inject` 第一个参数就是 `sessionId`,胶囊只留下这一行的行。不这么过滤,A 会话输入框下面就会描述 B 会话(甚至另一个标签页)的回合——一条关于你没在看的目录的字,计数器还在动。宿主没给 `sessionId` 时(老宿主,或直接挂载组件的测试)**不过滤**:少一颗诊断读数比假装没有回合更糟;宿主拿不到 `agent.id` 而标成 `sessionId: ''` 的行也照样保留,那同样是「真有人在干活」的证据。
346
+ - **不按供应商门控。** 额度胶囊只在会话的模型是 WorkBuddy 供应商时出现,因为它报的是那家供应商的额度;这颗胶囊报的是**一个本地进程**,而 CLI 路线刻意与模型无关——从 DeepSeek 会话里发起也应当看得见。`cliBridge.enabled` 也不需要单独判断:关着时不会有回合开始,板子是空的,胶囊自然什么都不渲染。
347
+ - **秒数在本地走,不额外发请求。** 5 秒轮询一次文档,秒数由 1 秒的本地 tick 从 `startedAt` 推出来。多个会话共享**同一个**在途请求(模块级缓存 + inflight 去重),N 个标签页不会变成 N 倍轮询。
348
+ - **时钟偏差按宿主时间校正。** `startedAt` 是宿主时钟;浏览器时钟慢一分钟就会算出负数。文档里带 `serverTime`,客户端据此算偏移——而且**只在拿到新文档时**重算(缓存的文档带的是旧 `serverTime`,每次都重算会把已经过去的时间从偏移里再减一遍,计数器就会冻在原地)。
349
+ - **工具次数是近似值。** 从 CLI 的 NDJSON 里数 `tool_use` 帧得来,`phase` 是 `starting` / `running` / `finishing` 三态;`lastTool` 是最近一次工具名。这些是给人看的进度信号,不是计费依据。
350
+ - **它不会撒谎,也不会赖着不走。** 文档里 `serverTime` 不是有限数字时整份作废(否则标签会印出 `NaNs`);路由连续三个周期(15s)失败就清空,不会在输入框上留一颗描述着已结束回合的幽灵。同一个会话里多条回合同时跑时它显示最新的那条,`+N` 表示还有几条,目录只进 tooltip——因为这条 dock 只有一行。
351
+
352
+ > 这颗胶囊走的是插件的客户端入口(`dsh.client`),所以**改它需要重新构建 `lib/client.js` 并重启宿主**,和宿主侧代码一样。
353
+
338
354
  ### 权限:默认只读
339
355
 
340
356
  `permissionMode` 默认 **`dontAsk`**:只读工具(Read/Glob/Grep)自动放行,写文件与执行命令被拒,且**不卡住**——CLI 会自己结束这一轮并告诉你哪些工具被拒了。
@@ -365,12 +381,13 @@ CLI 路线逃掉的是**客户端指纹层**,不是账号层。它用 `CODEBUD
365
381
  | `allowUnattended` | `false` | 允许在**没有任何工作目录**时退回一个临时目录。默认关:CLI 有读写权限,不该在没人指定的目录里跑。 |
366
382
  | `maxReplyChars` | `60000` | 交回给会话模型的最大字符数,超出截断并注明(硬上限 400000)。 |
367
383
  | `injectResult` | `true` | 跑完后是否把结论回注给会话模型。设为 `false` 才完全不碰模型。 |
384
+ | `sessionContinuity` | `auto` | 同一个 DSH 会话里,下一次调用是否续接上一次的 CodeBuddy 对话。`auto` = 自动续接(走 `--session-id`),`off` = 每次都是独立的新对话。显式传 `session_id` 参数时以参数为准。 |
368
385
  | `maxTurnsPerSessionHour` | `6` | 每个会话每小时最多几次。`0` = 不限。 |
369
386
  | `maxTurnsPerHour` | `20` | 全部会话每小时合计最多几次。`0` = 不限。 |
370
387
 
371
388
  ### 边界
372
389
 
373
- - **不共享上下文。** CodeBuddy 是另一个 agent,它看不到这段对话。它适合「独立审一块代码」「跑一轮调查」;不适合「接着我刚才那个思路改」——那种事它得自己重新把仓库读一遍。
390
+ - **它看不到这段对话,但看得到自己的上一轮。** CodeBuddy 是另一个 agent,DSH 这边的对话历史它拿不到。不过从 4.14 起,同一个会话里连续两次调用默认是**同一条 CodeBuddy 对话**(`sessionContinuity=auto`):第二次知道第一次说了什么、读了哪些文件,不用从零重读仓库。所以「接着刚才那个思路改」现在可行,但它依据的是 CodeBuddy 自己的上下文,不是你在这里说的话——真要它知道你说过什么,还是得写进指令。想回到「每次都独立」就设 `sessionContinuity=off`。
374
391
  - **它有写权限的门槛,没有沙箱。** 默认 `dontAsk` 只读;调成 `acceptEdits` 之后它真的会改工作区里的文件。允许根只约束它在**哪个目录**里跑,不约束它在这个目录里做什么。
375
392
  - **两套账。** CodeBuddy CLI 的配额和 DSH 的模型额度是两回事。指令本身不花 DSH 模型 token,但默认的回注会让会话模型多跑一轮,那一次是真的 DSH token。描述里写了成本信号,也设了每小时上限,防止「回注 → 模型再问 → 再回注」这种循环。
376
393
  - **失败就停,不换着账号试。** 只有明确识别出凭据被拒(401 / `Authentication required` / `/login`)才会换下一个账号;超时、崩溃、找不到 CLI 都直接报错——十几个 vault 账号逐个重试一遍 900 秒,比失败本身更糟。换号也不再看模型说的话:早期版本把 CLI 的**正文**也扫了一遍,导致「评审一段包含 `HTTP 401` 的代码」被误判成凭据失效。
package/docs/WINDOWS.md CHANGED
@@ -122,6 +122,7 @@ WMI 服务(`Win32_Process.Create`)创建的进程与 Electron 宿主同为
122
122
  | 未发布(PR #44) | 「重新检测账号」没能把删掉的账号找回来,而界面什么都不说 | 隐藏名单删除被拒时异常被吞掉(`rm` 的 `force` 不忽略 `EPERM`)——详见下节(B 档) |
123
123
  | 未发布(PR #44 追加) | `tests/windows-file-lock.spec.ts` 在真机上恒红,而 CI 恒绿 | **测试自身的清场竞态**(不是产品缺陷):`kill()` 只投递信号,内核关闭句柄在其后数毫秒,此窗口内 `rm` 必得 `EBUSY`——详见下节 |
124
124
  | 4.10.1(issue #77 第二轮) | 打开设置页要等 60 s;CPU profile 里 19.1 s 的请求有 15.3 s 在 `spawnSync`(约 135 个 `reg query`) | 字段密钥的探测**只缓存成功**,且共享扫描窗口从**开始**时刻计窗——两个乘数叠在 `scanAll()` 的逐文件探测上(§1-6)。**根因跨平台**,Windows 只是把重复探测换成了同步子进程,才变成 60 秒 |
125
+ | 5.5.0(PR #116,阻断级) | `codebuddy` CLI 的自动探测在 Windows 上 **100% 失败**,只能手填 `cliBridge.binary` | CLI 候选清单把**每个**根目录都拼上 `lib/node_modules`,而 Windows 的 npm 全局前缀 `%APPDATA%\npm` 下包直接落在 `<prefix>\node_modules`、**没有 `lib` 层**,9 条候选全部落空。CI 未拦住是因为 runner spec 里当时**没有任何**针对 `cliEntryCandidates()` 的断言(绿的是测试,坏的是真机)。修法:每个根目录自带自己的布局,并补上 pnpm / yarn / fnm 的 Windows 默认仓位置(C 档:本机只实测了 npm 那条) |
125
126
 
126
127
  ### 「重新检测账号」的静默失败(PR #44,**B 档 CI 实测**)
127
128
 
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthCandidates, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, Mt as hostPeersInfo, a as readHostHeartbeat, b as WorkBuddyCredentialStore, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, k as legacyWorkbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, vt as WORKBUDDY_APP_EXECUTABLE_ENV, w as defaultDesktopAuthDirs, wt as findWorkbuddyAppExecutableWithSource, y as WORKBUDDY_AUTH_FILE_ENV } from "./host-heartbeat-D5FWM_8k.js";
2
+ import { C as defaultDesktopAuthCandidates, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, Mt as hostPeersInfo, a as readHostHeartbeat, b as WorkBuddyCredentialStore, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, k as legacyWorkbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, vt as WORKBUDDY_APP_EXECUTABLE_ENV, w as defaultDesktopAuthDirs, wt as findWorkbuddyAppExecutableWithSource, y as WORKBUDDY_AUTH_FILE_ENV } from "./host-heartbeat-CVnH8S3J.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts