dsh-connect-workbuddy 5.13.1 → 5.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,166 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.14.1 (2026-10-30)
4
+
5
+ > 一个「新模型进名册后被当成纯文本」的缺口,与 #168 同形:宿主以 `MODEL_DOES_NOT_SUPPORT_IMAGES`
6
+ > 拒收粘贴的图片,而根因是**核验表没收录那个 id**,不是用户没勾。附带把核验表的**证据分档**
7
+ > 写清,并说明为什么社区 PR #175 的另一半没有合入。
8
+
9
+ ### Bug Fixes
10
+
11
+ - **核验表补登 `space-bunny` 与 `minimax-m3.1-flash`:两条被目录自述为原生多模态的模型不再被判成纯文本**
12
+ - **现场**:这两个 id 在 2026-09-25 那份核验快照之后才进名册,于是每次刷新都仅因「不在表里」被判成
13
+ `unknown` → `text`,宿主以 `MODEL_DOES_NOT_SUPPORT_IMAGES` 拒绝粘贴的图片——与 #168 同一形状,
14
+ 只是根因从「`imageModelIds` 没提到它」换成了「核验表没收录它」。
15
+ - **证据**:本机 CN 目录缓存(`~/.workbuddy/cache/acc-product-config-v3.json`,WorkBuddy 5.8.3,
16
+ 文档日期 2026-10-14)里,`space-bunny` 的描述是「推理速度极快,编码能力强劲,并**支持原生多模态
17
+ 输入**的匿名大模型」,`minimax-m3.1-flash` 是「**原生多模态**,擅长 office、agentic、编程任务…」,
18
+ 两条 `supportsImages` 均为 true。
19
+ - **为什么这对 `supportsImages` 不构成矛盾**:那条判据是「`supportsImages` 对几乎整份名册都报 true」,
20
+ 而这里的判据是**描述里的措辞**——目录对 `hy3`、`glm-5.3` 这类纯文本模型从不写「原生多模态」。
21
+ 也就是说描述是有区分度的信号,那个布尔字段没有。表因此分成**两档证据**:A 档厂商文档 13 条,
22
+ B 档上游目录自述 2 条,`docs/DESIGN.md` 的「核验表」一节把两档并列写清。
23
+ - **没有打开的口子**:B 档目前**恰好两条**,由 `tests/native-modality.spec.ts` 钉住;新增 id 仍按
24
+ A 档要求附厂商来源。若「目录说了」变成立即可用的规则,就等于绕回这张表本来要取代的「报 true 就勾」。
25
+ 前缀/族匹配照旧禁止——`minimax-m3.1` 仍是 `unknown`。
26
+ - **实测影响面**(本机 CN `cli` 名册 21 个重算):预勾选从 5 个变成 **7 个**,新增的正是这两条;
27
+ 其余 8 个无来源 id 仍旧默认不勾。
28
+ - **测试**:`tests/native-modality.spec.ts` 的条数断言 13 → 15,并新增一条钉住「B 档恰好是这两条、
29
+ 且不向族内扩散」。把本提交的 `src/native-modality.ts` 撤回后这两条都会红。
30
+ - **来源**:改动来自社区 PR [#175](https://github.com/dingminhua/dsh-connect-workbuddy/pull/175)
31
+ (by @beibing173)的核验表部分。该 PR 附带的「输入框积分胶囊独立开关」未随本版合入,理由见下条。
32
+
33
+ ### Docs
34
+
35
+ - **两条 README 的图片判定措辞改准**:原文写「只有**厂商文档**明确写为原生多模态的模型才自动勾选」,
36
+ 而本版起核验表里多了两条证据档位为「上游目录自述」的条目,照旧文读会以为这两条不该预勾选。
37
+ 现在写明「只有核验表**收录**为原生多模态的才勾」,并点出两档证据与「新增 id 仍要厂商来源」。
38
+
39
+ ### Notes
40
+
41
+ - **未合入:PR #175 的「胶囊独立开关」**。它假设「输入框胶囊」与「卡片积分概览」共用一个开关,但
42
+ 5.14.0 的代码里 `showCreditsInMainUi` **只隐藏胶囊本身**(消费点只有 `ComposerCredits`、
43
+ `ComposerCreditsGate` 与卡片上那一个勾选框;同一张卡开关 on/off 的渲染差异只有勾选框的 `checked`),
44
+ 卡片并没有被它门控的积分概览。照原样合入会得到两个作用相同的勾选框,且文案承诺了一个不存在的区别。
45
+ 真有「把胶囊从模型选择器那一行挪开」的诉求时,现成的 `creditPosition` 已经提供第二个座位
46
+ (`composer` / `stats`,即输入框下方那一行),不需要再加一个开关。
47
+
48
+ ## 5.14.0 (2026-10-29)
49
+
50
+ > 一个「新模型发不了图」的**行为变更**,加三条独立缺陷:#167 补上 5.13.1 漏掉的第二句被禁指纹、
51
+ > #174 修掉三处「诊断与读取各说一套」并给临时文件与请求体兜底、#170 让失败的状态读取可恢复。
52
+
53
+ ### Bug Fixes
54
+
55
+ - **新增的多模态模型不再被当成纯文本:广告值改为「用户勾选 ∪ 厂商核验表」(#168)**
56
+ - **现场**:`deepseek-v4.1-flash` 已勾选,发图仍被宿主拦下——`Model "…" does not
57
+ support image input`(`MODEL_DOES_NOT_SUPPORT_IMAGES`,宿主 `session/attachment-invalid`
58
+ 的判据是 `resolveModelInfo().inputModalities` 不含 `image`)。报错来自宿主,但判据来自插件
59
+ 广播的 `multimodal`。
60
+ - **根因**:`imageModelIds` 有两个写入者且语义不同——用户勾选(决定)与目录刷新(当天名册的
61
+ 快照)。仅凭数组无法区分,于是「上次刷新之后才出现在名册里的模型」仅因**不在数组里**就被盖成
62
+ `multimodal: false`,`workBuddyModelInput` 答 `['text']`。本机实测复现:cn 的数组是
63
+ `deepseek-v4.1-flash / glm-5.3-flash / kimi-k3-1`,此后名册新增的
64
+ `minimax-m3.1-flash`、`kimi-k3.1-pro`、`kimi-k2.8-preview`、`hy3-x` 全部不可用。
65
+ - **修法**:`effectiveImageIds()` —— 保存的数组**优先**(用户勾过的照旧生效,反向勾选也尊重),
66
+ 仅对数组**没有提到**的 id 用 `imageDefaultFor` 的厂商核验表补齐;`imageSelectionExplicit`
67
+ (卡片保存时置 `true`)一旦成立就完全以数组为准,使「显式取消勾选」不会被下一次读取悄悄改回去。
68
+ 四个盖 `multimodal` 的地方(保存 / 发现 / 启动种子 / 卡片读数)统一走它。
69
+ - **没做的事**:不给未核验的 id 猜。`hy3-x` 仍是 `['text']`,守住 `docs/DESIGN.md` 那条
70
+ 「不猜」的教训。
71
+ - **测试**:新增 `tests/issue168-image-seed.spec.ts`,经 `ctx.llm.resolveModelInfo`
72
+ (宿主自己的判据)断言广告值;含「不猜」「显式取消勾选仍有效」「显式反向勾选仍有效」三条对照。
73
+ 对 5.13.1 代码跑该文件会红,即缺陷被真实钉住。
74
+
75
+ - **状态读取失败不再被当成终态(#170,合并 PR #171 by @aa52312300aa-sys)**
76
+ - 失败文档带 `accounts: []`,于是 `hasAccounts` 为 false,60s 轮询与 open effect 一起把自己
77
+ 关掉;一次瞬时 `Failed to fetch` 会永久停在卡片上,只能关掉卡片再打开。
78
+ - 现在:错误横幅在**展开时**按 3s→30s 指数退避重试(以 `loaded` 文档身份为 key,折叠即解除武装),
79
+ 横幅从裸的 `TypeError` 改成「读取账号状态失败:…」+「重试」按钮;新增
80
+ `row.usageErrorHint` / `row.usageRetry`(中英)与 `.dsm-workbuddy-usage-error-block`。
81
+ - **测试**:新增 `tests/issue170-status-retry.spec.tsx`,钉住退避序列(3s 后有请求、再 3s 没有、
82
+ 再 3s 才有)、读到即停、「重试」按钮立即读取且不等待退避、空 message 回落到
83
+ `row.requestFailed`、以及折叠期间零请求。
84
+
85
+ - **Web GUI 段落的第二句也是独立指纹,改词表补齐(#167)**
86
+ - #167 用前缀二分测到最小失败窗口是 `er target, they mean this GUI.`:只改第一句(5.13.1 的做法)
87
+ 仍 400,两句都改才 200。此前「第二句不是独立指纹」的结论被这次实测推翻,写入表中的注释与
88
+ `tests/issue163-sentence-rewrite.spec.ts` 里那条断言一并纠正。
89
+ - 新增两行长→短的表项(`without naming another target, …` 与 `they mean this GUI.`),因为门槛在
90
+ **截断形态**上也会触发;长行先跑,完整段落不会落到短行。
91
+ - **测试**:新增 `tests/issue167-second-gui-span.spec.ts`(两句都消失、截断窗口也消失、改写后
92
+ 仍是可读指令、幂等、经 `prepareChatBody` 对 system 与 assistant 生效而对 user 不动、无关文本不变)。
93
+
94
+ - **心跳存活性不再把「进程启动时刻」和「心跳写入时刻」相比;auth 文件名接受 `-ai.info`(#174)**
95
+ - **① 误判为本机实测**:`isHeartbeatProcessAlive` 拿 `ps` 的进程启动时间与 `heartbeat.registeredAt`
96
+ (插件写入心跳那一刻)比,窗口 60s。两者的差是「宿主起来之后过了多久才挂载插件」,真实场景普遍
97
+ 远超 60s——报告者实测进程 19:47:20、心跳 19:58:45,差 684,817 ms,于是 `doctor` / `status`
98
+ 对一个刚写完心跳的活进程报 `hostBundle: "stale"`。既有测试没挡住,因为它把 `registeredAt`
99
+ **设成**进程启动时刻,等于断言了这个 bug 的前提。
100
+ 修法:心跳新增 `hostStartedAt`(写心跳时取本进程启动时刻),比较改与**它**比;v1 心跳仍可读,
101
+ 降级用 `registeredAt` 并按更宽的窗口判定(宁可漏判 PID 复用,也不把活宿主叫成死的)。
102
+ - **② 文件名**:`WORKBUDDY_LIVE_FILENAME` 只有 `workbuddy-desktop.info`,而 5.7.x 桌面端写的是
103
+ `workbuddy-desktop-ai.info`。后果是双重的:`fileRank` 的「live 永远优先」从不命中(排序退化为
104
+ 按 `lastRefreshAtMs`,结果对只是运气),而 `doctor` 报 `desktopAuthFile.present: false` 并给出
105
+ 「设置 WORKBUDDY_AUTH_FILE」的无效建议——同时账号列表是满的。修法:已知 live 名变成列表,
106
+ `fileRank` / 候选扫描 / `doctor` 统一走 `isLiveDesktopAuthFileName`。
107
+ - **顺带查出的第三处**:`resolveDesktopCandidates()`(`desktopAuthPath()`、`desktopFilePresent()`
108
+ 的来源)原本**忽略 `authDirs`**,于是诊断路径与读取器实际扫描的路径不是同一组——与 ② 是同一种
109
+ 「诊断与读取各说一套」的缺陷。现在两者同源。
110
+ - `diagnose()` 的 `missing` 改为**按目录**报一次、且只在目录里没有任何可读凭据时才报:否则在
111
+ 用 `-ai.info` 的机器上会多出一行「另一个名字缺失」,而那条用户无法处理,也与已读到的账号相矛盾。
112
+
113
+ - **生成类临时文件不再只写不删(#174 ③)**
114
+ - 生图(`%TEMP%/workbuddy-image-gen`)与视频(`workbuddy-video-gen`)原来只 `mkdir` + `writeFile`,
115
+ 仓库内无任何清理路径。新增 `src/temp-files.ts` 的 `pruneTempDir()`,**挂在写入之后**按 mtime
116
+ 保留最新 20 个(不是启动时扫一遍:插件可能活在久不重启的宿主里,那时恰恰不生成)。
117
+ 能力被吞掉——清理失败绝不能把一次成功的生成变成失败。
118
+
119
+ - **请求体超限回 413 而不是 500(#174 ④)**
120
+ - `readBody` 原来 reject 一个裸 `Error`,被外层 catch 写成 `500 internal`。改为具名
121
+ `RequestBodyTooLargeError` → 413 + `types: client`。
122
+ - 同时修掉一个连带缺陷:原来超限时 `req.destroy()` **销毁 socket**,那样 413 根本送不到客户端,
123
+ 客户端只会看到连接被重置——比 500 更糟。现在改为停止读取(摘掉监听 + `pause()`),响应得以送达。
124
+
125
+ ### Docs
126
+
127
+ - **市场条目已收录,但线上描述停在 2.0.15——修正为当前能力,并把「条目不会自己更新」写进文档(#164 的索引问题顺带查出)**
128
+ - **实际状态更正**:PR [#3812](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/3812) **已于 2026-08-30 合并**(merge `c079c705`),而两份 README 与本目录一直写着「等待维护者合并」。(与 #164 的「市场索引把 5.12.x 标成最新」是**两件独立的事**:条目里没有版本字段,索引给旧版是同步节奏问题,不是这句描述造成的。)
129
+ - **线上条目过期**:`data/plugins/dingminhua__dsh-connect-workbuddy.yml` 还是 2.0.15 时写的那句——没提国内版/国际版双供应商、没提每次刷新读取积分倍率、没提账号池自动换号,也没说明签到是**手动**的(会真实领取奖励,只在点按钮时执行)。已提更新 PR [#10281](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/10281),并补 `icon:` 指向本仓库 `icons/dsh-connect-workbuddy-128.png`。
130
+ - **写进文档的教训**:条目合并后不会自己更新;插件每次有大的能力变化,描述要另开一次 PR 跟上——注册表评审明确「描述会被当作声明,并对着代码核」。
131
+ - 同时修正两处过时规范:**没有提交数门槛**(对方 #4196 已废除),以及**只声明 `dsh.client` 是最常见的被拒原因**。
132
+ - 本目录的条目镜像补 `icon:` 字段;`description.en` / `description.zh` 仍与 `locale/en.json` / `locale/zh.json` 逐字一致,由 `tests/locale-meta.spec.ts` 钉住(该测试会拒绝任何一侧的重新翻译)。
133
+
134
+ - **两份 README 的「安装」维护一条版本下限:CN 区渠道校验 400 请确保 ≥ 5.14.0(#164、#167)**
135
+ - **为什么**:#164 的实测矩阵确认 5.13.1 的按句改词在同一份 body 上 **200**,而 5.12.1 / 5.12.4 的
136
+ 原样、`prepareChatBody`、三级救援**全部 400**。停在旧版的用户会反复撞上这个已经修好的 400。
137
+ - **5.14.0 把下限从 5.13.1 提到 5.14.0**:5.13.1 只改了 Web GUI 段的第一句,#167 证明第二句是
138
+ 独立指纹,所以停在 5.13.1 仍会有一部分人必现 400。下限跟着「第一个真正改完两句的版本」走,
139
+ 否则这条警告反而会把人引到一个仍然会撞墙的版本上。
140
+ - **两个升级不到的原因也写进去了**:市场不同索引在同一时刻可能给出三个「最新」(#164 实测
141
+ 5.12.3 / 5.12.4 / 5.13.1 并存),以及 pnpm 的 24 小时 `minimumReleaseAge` 会把浮动范围静默压回
142
+ 上一版;需要立刻修复时给的是**精确版本号**命令。
143
+
144
+ ### Tests
145
+
146
+ - **把 #164 测到的「最小触发跨度」钉成回归测试(`tests/issue163-sentence-rewrite.spec.ts`)**
147
+ - #164 二分出门槛比 #163 更短:`@` 路径段**前 112 字**(两句)、Web GUI 段**前 217 字**(两句)
148
+ 就会触发;两句各自单发都是 200。改词表必须把这两个**截断形态**也清掉,否则宿主只发半段时
149
+ 仍会被拒。测试断言两段改写后都不含指纹、且改写后仍是可读的指令。
150
+ - 同时记录一条与 #164 建议 2 对应的口径:第 0 级改词会让 system 略长(#164 实测 +32 字节 / 5 行),
151
+ 自助对比正文长度时不要把它当成「扰动后长度」。
152
+
153
+ - **#174 四条各自钉住可失败点**:心跳(长运行宿主必须判活、`hostStartedAt` 与 `registeredAt`
154
+ 冲突时以精确者为先、v1 心跳仍可读、容差常量本身)、临时文件(按 mtime 而非文件名、只清本类型、
155
+ 目录不存在返 0、`keep` 是有限值)、413(真发一个超限流,断言状态码)、diagnose
156
+ (文件名命中时**不**报 missing、tried 与 failures 按目录对齐)。
157
+ - 对修复前代码跑:心跳 4 条红、413 1 条红、#174 临时文件与 diagnose 的新用例分别红。
158
+
159
+ - **#170 的退避是「有界」的,不只是「有重试」**:`tests/issue170-status-retry.spec.tsx` 断言第二次
160
+ 尝试**不在** 3s 时发生(恒为 3s 的定频实现会让这条红),并且读到成功后才回到安静的 60s 轮询。
161
+ - **#168 断言的是宿主真正读的那个字段**:`inputModalities`,不是 `multimodal` 中间量;只测后者的话,
162
+ 盖值的四个调用点全部传错数组也能绿。
163
+
3
164
  ## 5.13.1 (2026-10-29)
4
165
 
5
166
  ### Bug Fixes
package/README.en.md CHANGED
@@ -32,7 +32,7 @@ It depends on the WorkBuddy client's own endpoints (not a public API), so the pl
32
32
  - **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.
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
- - **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.
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 verified table**: a refresh pre-checks only models the table records as natively multimodal, leaves documented text-only models unchecked, and treats any id without a source as unverified and unchecked rather than guessing. The table carries two evidence tiers — vendor documentation (13 entries) and **the upstream catalog's own description** (2 entries: `space-bunny` and `minimax-m3.1-flash`, whose descriptions say “native multimodal”) — while a NEW id still needs a vendor source; see the “Image input” section of [docs/DESIGN.md](docs/DESIGN.md). 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
36
 
37
37
  <img src="docs/assets/dsh-connect-workbuddy-model-picker.png" width="420" alt="DSH model picker: the same campaign badge (night discount / Free now) drawn after the model name, per region (no account data)" />
38
38
  - **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. **The multiplier shown is the price you actually pay**: while a “night discount” is running, the card and the picker both show the discounted `x0.06` instead of the catalog figure, and both go back on their own when the window closes — no restart needed (issue #120). **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).)
@@ -52,7 +52,7 @@ It depends on the WorkBuddy client's own endpoints (not a public API), so the pl
52
52
  - **Optional: make the usage page record these calls like the official client's (5.8.0, issue #132, OFF by default)** — with `usageIdentity` on, chat requests carry `X-IDE-Name: WorkBuddy` (plus `X-IDE-Type: CLI` and a version taken from the existing product UA, not a second hard-coded number), so the usage page's client column is no longer blank, and the conversation-identity triple becomes the official **32-character lower-case hex UUIDv7** with `X-Request-ID` alongside it — the gateway echoes that id as the SSE `id` and writes it into the usage page, so hyphenated v4 ids made one account's rows look like two different clients — while "fresh per request, all distinct" has not been relaxed. **`User-Agent` is untouched under either setting**: billing and rate limits recognise the CLI channel. The image endpoint does not follow this switch; it keeps the shape measured on 2026-10-21. It is off by default because the shape fixes nothing on its own (the three `11128` doors read message openings, a missing conversation id, and whether the first message is a system prompt — no header moves any of them) and the only measurement is a reporter's on an older build, so a purely cosmetic alignment does not get to put every account at risk first. There is also an opt-in `keepPromptLast` (**off by default**): the usage page takes the request text from the LAST `user` message, and the host appends its time samples, runtime context and `<system-reminder>` blocks AFTER what you actually typed, so that column recorded injected text; when enabled it moves those injected blocks **above the real prompt** — order only, except that the recorded prompt's own leading `<acp …>mNNNNN</acp>` marker is stripped (leave it and that column still opens with a marker, i.e. nothing was fixed); earlier messages **keep** theirs, because the model cites history by those ids and the only id that can never matter is the message being answered now. It stays off by default because the order is something the upstream counts and a few leading characters are removed from the real prompt. The move matches only the host's own injected prefixes, and a conversation with no injected tail is untouched byte for byte — marker included — pinned by tests written for both.
53
53
  - **Optional: move each new session's title request onto the hidden lightweight model (issue #133, OFF by default)** — the DSH host fires one title-generation request per new session through the same provider as the conversation, so it is billed at the main paid model's rate. With `sessionTitle.enabled` on, a request the plugin recognises as a title (its only user message opens with the host's template `Generate the session title from this JSON array of human messages:`) is re-pointed at `sessionTitle.model` (default `wb-lite`). The reporter measured 0 credits for those calls, with three caveats: it is still a real upstream request and counts toward request volume; the measurement is the reporter's account, not reproduced here; detection is fail-closed — if the host rewords the template the rewrite silently stops and billing reverts, and ordinary conversations are never touched. If the upstream rejects the model, only that session's title fails once; the conversation is unaffected. Like `usageIdentity` / `keepPromptLast` / `extraInjectedPrefixes` in the bullets above, `sessionTitle` is read once when the plugin mounts: **after editing `cordis.patch.yml`, reload the plugin or restart DSH for the change to take effect** — hot edits do not apply.
54
54
  - **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.
55
- - **Optional: a LAST-RESORT 「继续」 when the gateway refuses the request (OFF by default, 1 send by default)** — when the upstream rejects with `400` and business code `11128` the turn simply ends: the plugin does not retry inside that request (its three-rung ladder already ran), it does not swap accounts (the gate matches message contents, and every account sends the same ones), and the host does not retry. **This is not a fix for 11128.** Fixed system fingerprints (the identity / Web GUI / @-path paragraph from #163) are byte-identical every turn, so resending only pays for the same 400 again; since 5.13.1 `prepareChatBody` rewrites known phrases sentence by sentence. Only content that happens to change between turns can be helped by a new turn. Turning on the card's Capabilities switch makes the engine wait 300 ms and send `继续` through `agent.followup` (`source.kind` is `workbuddy-continue`, **not** `user`). **The cap counts CONSECUTIVE sends** (`channelRejectContinue.maxConsecutive`, default **1**; the old default of 3 is disproven — if one cannot help, three cannot): one successful turn, or one message you type yourself, resets it; the plugin's own message does not. Subagent sessions are never touched, and neither is a session already running or with something queued. **Every send spends a real request**, hence OFF by default; it is also the only upstream-shaped switch whose change **takes effect immediately**. It can be set directly in `cordis.patch.yml`:
55
+ - **Optional: a LAST-RESORT 「继续」 when the gateway refuses the request (OFF by default, 1 send by default)** — when the upstream rejects with `400` and business code `11128` the turn simply ends: the plugin does not retry inside that request (its three-rung ladder already ran), it does not swap accounts (the gate matches message contents, and every account sends the same ones), and the host does not retry. **This is not a fix for 11128.** Fixed system fingerprints (the identity / Web GUI / @-path paragraph from #163) are byte-identical every turn, so resending only pays for the same 400 again; since 5.13.1 `prepareChatBody` rewrites known phrases sentence by sentence (5.14.0 adds the Web-GUI paragraph's second sentence, see #167). Only content that happens to change between turns can be helped by a new turn. Turning on the card's Capabilities switch makes the engine wait 300 ms and send `继续` through `agent.followup` (`source.kind` is `workbuddy-continue`, **not** `user`). **The cap counts CONSECUTIVE sends** (`channelRejectContinue.maxConsecutive`, default **1**; the old default of 3 is disproven — if one cannot help, three cannot): one successful turn, or one message you type yourself, resets it; the plugin's own message does not. Subagent sessions are never touched, and neither is a session already running or with something queued. **Every send spends a real request**, hence OFF by default; it is also the only upstream-shaped switch whose change **takes effect immediately**. It can be set directly in `cordis.patch.yml`:
56
56
 
57
57
  ```yaml
58
58
  channelRejectContinue:
@@ -184,6 +184,10 @@ One class of state in the pool **does not go through a draft**: probe results, c
184
184
 
185
185
  > ⚠️ **Version requirement: DSH 0.1.7-rc.1 or newer (current window: `0.1.7-rc.1` – `0.2.x` GA).** Since v2.1.0 this plugin only supports hosts on DSH 0.1.7-rc.1 and above (older hosts cannot resolve the dependencies to install this version); hosts on 0.1.5 and earlier should stay on v2.0.15. Since v2.1.2 the upper bound is **`<0.3.0-0`**; the 0.3.0 line is explicitly refused. See [Supported host range](#supported-host-range) below.
186
186
 
187
+ > ⚠️ **For the CN 「channel verification」 400 (`code 11128`), make sure the plugin is ≥ 5.14.0.** From 5.13.1 the plugin rewrites the known fixed fingerprints **before the request is sent**, and 5.14.0 adds the Web-GUI paragraph's SECOND sentence (#167, the branch still reproducing on 5.13.1); 5.12.x and earlier only had the neutral-head + zero-width ladder, which measured **400 on every rung** for the same body (matrix in [#164](https://github.com/dingminhua/dsh-connect-workbuddy/issues/164)). Anyone left on an older build keeps hitting a 400 that is already fixed, and it looks like "fails every single time, restarting does nothing".
188
+ >
189
+ > Two things stop people from actually upgrading: **① different marketplace indexes can report three different "latest" at the same moment** (5.12.3 / 5.12.4 / 5.13.1 were all live at once per #164), so "check for updates" on a stale index never shows the new version; **② pnpm's 24-hour release-age window** (`minimumReleaseAge`) **silently resolves a floating range back to the previous version**. When you need this fixed right now, pin the **exact version**: `dsh-connect-workbuddy@5.14.0`.
190
+
187
191
  ### Supported host range
188
192
 
189
193
  DSH's shipped gate (`evaluatePluginCompatibility` in `packages/boot/app-boot/src/plugin-compatibility.ts`) checks every declared `dsh-*` peer range; **one unsatisfied peer skips the entire bundle** (it lands in `skippedBundles` and is only printed to stderr) — no provider is registered, no card appears, the model list goes empty, and the page shows no error at all.
@@ -454,12 +458,14 @@ else { await envelope.response.text() /* or consume it as SSE */ }
454
458
 
455
459
  ## Marketplace listing
456
460
 
457
- The plugin ships an installable `dsh.bundle` manifest and is published to npm. Community marketplaces generally sync entries from the [Awesome DSH Plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) registry; the submission draft lives at [`awesome-dsh-plugin-submission/dingminhua__dsh-connect-workbuddy.yml`](awesome-dsh-plugin-submission/dingminhua__dsh-connect-workbuddy.yml), and the formal submission is PR [#3812](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/3812) (`data/plugins/dingminhua__dsh-connect-workbuddy.yml`, CI and Submission gate green, awaiting maintainer merge).
461
+ The plugin ships an installable `dsh.bundle` manifest and is published to npm. Community marketplaces generally sync entries from the [Awesome DSH Plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) registry; the local mirror of the entry lives at [`awesome-dsh-plugin-submission/dingminhua__dsh-connect-workbuddy.yml`](awesome-dsh-plugin-submission/dingminhua__dsh-connect-workbuddy.yml). **The entry was merged and listed via PR [#3812](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/3812) (2026-08-30).**
462
+
463
+ > ⚠️ **A merged entry does not update itself.** #3812 was merged when the plugin was at 2.0.15, so the registry line kept describing that release: no CN/international dual providers, no per-refresh credit multipliers, no account pool with failover, and nothing saying check-in is **manual** (it claims a real reward and only runs on a button press). **Every meaningful capability change needs its own entry PR**; the registry review states plainly that a description is read as a claim and checked against the code. The current update is PR [#10281](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/10281).
458
464
 
459
465
  **Listing directory rules** (per [contributing.md](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/blob/main/contributing.md)):
460
466
 
461
467
  - One YAML per plugin: `data/plugins/<owner>__<repo>.yml` with `url` (exactly matching the repo), `name` (`owner/repo`), `category` (one of the valid values; this project uses `model`), and `description.en` (required, ending with a period) / `description.zh` (optional); a description containing `: ` must be quoted.
462
- - The repo must declare a `dsh.bundle` manifest (here `dsh.bundle.patch: ./cordis.patch.yml`) and carry the `dsh-plugin` topic; the ≥ 10 commits and ≥ 1 day age are checked automatically by CI.
468
+ - The repo must declare a `dsh.bundle` manifest (here `dsh.bundle.patch: ./cordis.patch.yml`) and carry the `dsh-plugin` topic — **declaring only `dsh.client` is the most common rejection**. The 1-day repo age is checked automatically by CI (there is **no commit-count bar**; that one was removed).
463
469
  - Both READMEs (`README.md` / `README.zh.md`) are generated — never edit by hand; after changing the YAML, regenerate with `node scripts/generate-readme.mjs`.
464
470
  - At most 3 entries per PR; change only your own entry, never touch other plugins.
465
471
 
@@ -467,7 +473,7 @@ Marketplace screenshots and GitHub README badges are two separate mechanisms:
467
473
 
468
474
  - **GitHub badges** are produced by the Shields/dshfind image links at the top of this README.
469
475
  - **Marketplace screenshots** follow the current convention and are declared in the plugin's own root [`screenshots.json`](screenshots.json) (this project declares its usage-card screenshot there). The registry's `data/screenshots.json` is a legacy fallback and **no longer accepts new keys** (this project removed its edit before submitting).
470
- - **Marketplace icons/placeholders** follow each marketplace's own display rules; they are not a generic npm `package.json` field nor a README badge.
476
+ - **Marketplace icons** are declared with the entry's optional `icon:` field (this project points at `icons/dsh-connect-workbuddy-128.png`): it **must be a GitHub-hosted raster** (png/jpg/jpeg/webp) — SVG and third-party image hosts are refused — and storefronts fall back to the owner avatar when it is omitted. It is not a generic npm `package.json` field, nor a README badge.
471
477
 
472
478
  **The title and description on the plugin card are not rendered by this plugin** — they are **package metadata** the DSH plugin manager reads: it takes its words from `meta.title` / `meta.description` in `locale/<lang>.json` (`locale/en.json` is the anchor, so without it `zh.json` is never read; without the `"./locale/*.json"` wildcard in `exports` the whole directory is skipped **silently**), and falls back to `package.json`'s `description` for English. The same sentence therefore lives in three places in this repository, and [`tests/locale-meta.spec.ts`](tests/locale-meta.spec.ts) pins them together: the card dictionary (`locale/en.json` / `locale/zh.json`), the English fallback (`package.json.description`) and the listing draft (`description.en` / `description.zh` in `awesome-dsh-plugin-submission/dingminhua__dsh-connect-workbuddy.yml`). **Edit one, edit all three.**
473
479
 
package/README.md CHANGED
@@ -40,7 +40,7 @@
40
40
  - **插件卡片 tab 切换** —— 设置卡片顶部为「国内版 / 国际版」两个 tab,各含独立的账号池与模型管理;每个 tab 的账号、目录、勾选与未保存草稿完全隔离——在一个 tab 里切账号或刷新模型,不会触碰另一边的运行时目录与会话。
41
41
  - **可单独关闭任一版本供应商** —— 每个 tab 右侧有一个勾选框(默认都勾选)。**取消勾选即把该供应商从 DSH 模型选择器里彻底撤掉**(不是只藏起 tab):它的路由与「设置 → 模型」页的目录条目一并摘除,启动时也不再为它发无用请求。账号、积分、目录与勾选**全部保留**,重新勾选即完整恢复。典型用法是「我根本用不到国际版」——关掉它,让选择器干净。注意:若某会话此前选中的正是被关闭供应商的模型,该会话再调用会报 `NO_ADAPTER`,卡片会在关闭态的 tab 上明确提示这一点,不会静默失败。
42
42
  - **设置卡片分四栏:账号 / 模型 / 能力 / 诊断** —— 原来这张卡片是一列 3000 多像素的长页,把两类互不相干的事情混排在一起:**运维账号**(谁在服务、还剩多少、切换顺序、签到日志)与**编辑目录**(勾模型、上下文预算、推理档、关闭思考)。它们连提交语义都不同——账号改动点一下即生效,目录改动是草稿、必须点保存——所以「保存」按钮常常离它要保存的东西隔着三屏。现在分栏,一栏只做一件事;**切栏既不会重新拉取,也不会丢掉未保存的目录草稿**(非活动栏留在页面里,只是通过 `hidden` 不可见,因此也不进入无障碍树)。第三栏「能力」放的是**授权类开关**——允许调用本机 CodeBuddy CLI、允许模型自主生图、积分读数显示在哪——它们点一下即生效、从不经过保存,而且每一个都留着长自己子选项的位置(CLI 的工作目录、生图的尺寸或价格上限),所以不挤在账号表格上方那一小条里。第四栏「诊断」是只读的事实表:当前区域、上游域名、真正在服务的账号名、登录有效期、目录里共几个模型、启用了几个、账号池开关,以及上游最近一次错误的**原文**(原样引用,不翻译、不概括)——这一栏存在的唯一理由就是能被整段贴进问题反馈,所以里面没有任何会改设置的控件。
43
- - **模型管理** —— 从上游刷新完整模型目录,逐项勾选启用或禁用;刷新是草稿操作,点保存才生效。上游同时给出积分倍率、上下文/输出上限与推理档位;**图片输入按厂商核验表判定**:刷新时只有厂商文档明确写为原生多模态的模型才自动勾选,文档写明纯文本的不勾,**没有厂商资料的一律按「未核实」不猜**(上游的 `supportsImages` 是平台侧声明,不等于模型能看图,不参与判定)。用户仍可随时手动勾选,勾选状态保存在本地。**每个模型行还有「可关闭思考」(`off`)勾选框**:决定要不要为该模型提供「完全关闭思考」这一档位。默认由内置复核名单给出(上游声称可关闭、实测却对它返回 HTTP 400 的 9 个模型默认不勾),你可以按模型改勾/取消——**取消**用于当前不需要该档位的模型;**名单内那 9 个模型不可勾选**(行上标「拒绝 off」,悬停有说明):该档位被勾上后不只是多一个选项,pi-ai 会把它当作**所有未指定档位请求的默认值**,压缩摘要请求正属于这一类,勾一次等于让该模型的每一次 `/compact` 都 400 / 11133(issue #83)。名单外的模型仍完全由你的勾选与自身声明决定;首次保存即写入你的勾选,之后不再重算。
43
+ - **模型管理** —— 从上游刷新完整模型目录,逐项勾选启用或禁用;刷新是草稿操作,点保存才生效。上游同时给出积分倍率、上下文/输出上限与推理档位;**图片输入按核验表判定**:刷新时只有核验表收录为原生多模态的模型才自动勾选,写明纯文本的不勾,**没有资料的 id 一律按「未核实」不猜**(上游的 `supportsImages` 是平台侧声明,不等于模型能看图,不参与判定)。核验表分两档证据——厂商文档(13 条)与**上游目录自述**(2 条:`space-bunny`、`minimax-m3.1-flash`,各自的描述里写着「原生多模态」)——但新增 id 仍要求厂商来源,详见 [docs/DESIGN.md](docs/DESIGN.md) 的「图片输入」一节。用户仍可随时手动勾选,勾选状态保存在本地。**每个模型行还有「可关闭思考」(`off`)勾选框**:决定要不要为该模型提供「完全关闭思考」这一档位。默认由内置复核名单给出(上游声称可关闭、实测却对它返回 HTTP 400 的 9 个模型默认不勾),你可以按模型改勾/取消——**取消**用于当前不需要该档位的模型;**名单内那 9 个模型不可勾选**(行上标「拒绝 off」,悬停有说明):该档位被勾上后不只是多一个选项,pi-ai 会把它当作**所有未指定档位请求的默认值**,压缩摘要请求正属于这一类,勾一次等于让该模型的每一次 `/compact` 都 400 / 11133(issue #83)。名单外的模型仍完全由你的勾选与自身声明决定;首次保存即写入你的勾选,之后不再重算。
44
44
 
45
45
  <img src="docs/assets/dsh-connect-workbuddy-model-picker.png" width="420" alt="DSH 模型选择器:同一枚活动徽标(夜间折扣 / Free now)画在模型名之后(无账号信息)" />
46
46
  - **模型行上的活动徽标** —— 上游的模型目录里带着活动信息(免费试用、夜间折扣、限时免费、订阅优先等),插件**不新增任何请求**(复用刷新模型时本来就会拉的那份目录配置),直接在模型行上画一枚胶囊徽标:颜色与文案都由上游给出,悬停能看到上游的活动说明、活动的起止日期与每日生效时段(例如「夜间 23:00–7:50」)。**徽标是「此刻」的函数**:活动只在它自己声明的时间窗内显示,窗口一关徽标就自己消失;跨零点的时段按上游声明的时区判断;同一个模型命中多条活动时只画优先级最高的那条。上游没给结束时间的权益标记(如「订阅优先」)则常驻,直到上游把它下架。**徽标只是提示,不改变模型行为**——它标注的是上游此刻给出的活动与折扣,不替你做选择。**同一枚徽标也出现在 DSH 的模型选择器里**:菜单每一行都在模型名之后、队列图标之前画同样的胶囊 —— 菜单行用上游的短词(「夜间」「限免」),输入框旁那个触发器用完整词(「夜间折扣」「独家限免」);判定用的是同一份数据、同一个时钟,因为选模型的那一屏正好是最该看到「现在免费吗」的地方。(两个区域的上游目录都下发活动字段:国内版按 App 身份读 `/v3/config` 时带 8 条促销 + 1 条等级标记,国际版带 5 条 + 1 条;取证与判定规则见 [docs/PROMO_BADGE.md](docs/PROMO_BADGE.md)。)
@@ -61,7 +61,7 @@
61
61
  - **可选:让上游用量页把这笔记成官方客户端发的那笔(5.8.0,issue #132,默认关闭)** —— 开 `usageIdentity` 后 chat 请求带上 `X-IDE-Name: WorkBuddy`(连同 `X-IDE-Type: CLI` 与版本号;版本号取自现有的产品 UA,不留第二个硬编码数字),用量页的「使用端」由此不再空白;会话身份三件套同时改成官方客户端那种 **32 位十六进制 UUIDv7** 并补上 `X-Request-ID`——网关把这个 id 回显成 SSE 的 `id` 并写进用量页,此前带连字符的 v4 与官方记录摆在同一页上像两种客户端;**每请求现生成、互不相同**这条没有放松。**`User-Agent` 在两种取值下都一字未动**——账单与限流认的是 CLI 通道;生图路径也不跟着这个开关变,它保持自己 2026-10-21 实测过的形状。**默认关**的理由:形状本身不修任何东西(`11128` 的三道门读的是消息开头、会话 id 有没有、首条是不是 system,头改不动其中任何一条),而实测证据来自报告者的账号与旧版本、本插件未在真机复现——一次纯显示对齐不该让所有账号先承担风险。另有可选的 `keepPromptLast`(**默认关闭**):用量页的「请求」列取自 body 里**最后一条** `user` 消息,而宿主把时间采样、运行时上下文、`<system-reminder>` 追加在你真正输入的那句**之后**,那一列于是常年记的是注入块;开启后把这些注入块**搬到真实输入之前**(只变顺序;另外会剥掉**被记录那一条**真实输入前面的 `<acp …>mNNNNN</acp>` 标记,否则那一列仍然以标记开头而等于没修,更早的消息一律保留自己的标记,因为模型靠它们引用历史)。默认关的理由是它会改变上游会数到的消息顺序、并删掉真实输入的几个前导字符;搬移只匹配宿主自己的注入前缀,而普通对话在没有注入尾块时逐字节不变(连标记也不剥)——这两点各有专门测试钉住。
62
62
  - **可选:把每个新会话的标题请求改到隐藏轻量模型(issue #133,默认关闭)** —— DSH 宿主每个新会话会发一次标题生成请求,走的是与对话相同的 provider,于是这笔按主付费模型的费率计费。开启 `sessionTitle.enabled` 后,插件把能按宿主标题模板(`Generate the session title from this JSON array of human messages:` 开头、整条 body 只有这一条 user 消息)识别出的标题请求改写到 `sessionTitle.model`(默认 `wb-lite`)。报告者实测该模型对这类请求 **0 积分**,但注意三点:这仍是**真实上游请求**,请求数照算;证据是报告者账号级的,本插件未复现;识别按 fail-closed 设计——宿主若改模板,改写静默停止、回到原计费,不会误伤普通对话。若上游拒绝该模型,只是该会话标题生成失败一次,不影响主对话。另外,`sessionTitle` 与本条及上一条的 `usageIdentity` / `keepPromptLast` / `extraInjectedPrefixes` 一样,都是插件挂载时读一次配置:**改完 `cordis.patch.yml` 后需要重载插件或重启 DSH 才生效**,热改不生效。
63
63
  - **福利活动:待领取标记 + 单账号「领取」按钮** —— 卡片会逐账号标出上游活动(补偿 / 赠礼)里**还没领的那一份**,数据来自上游自己的两个状态接口,标记阶段**只读**、两个写接口一次都不调用。显示规则完全由上游状态决定:`active && !claimed` 才显示,`claimed` 整行消失,活动过期(`active` 翻 `false`)自动消失——**不用本地时钟去比 `end_time`**,本机时间偏了也不会与服务端打架;`end_time` 只用于 3 天内临期高亮。`active` 单独看不行:已领取的账号 `active` 仍为 `true`,只看它会让领过的账号永远挂着待领取。读取复用签到的管线(每账号一次、30 分钟缓存、账号池关闭时不发请求),任一接口失败按「未读」处理,不会把一次失败缓存成「全都没福利」。行尾是一个实心琥珀色的**胶囊按钮「领取」**,一次只领**一个账号的一个活动**:没有「全部领取」,因为一次点击花掉的是一份不可撤销的真实权益,写路由也**强制要求 `accountId`**,请求本身无法批量领取。领取前**重新读取**状态再判定,已领取或已过期一律 409 且不调用上游;领取后用上游的**新读数**作答并刷新卡片,不做乐观更新——即使上游静默失败也不会留下「已领取」的假象,成功后同时清掉该账号的福利缓存,按钮不会顶着旧状态赖着不走。
64
- - **可选:被网关渠道校验拦截后的最后兜底「继续」(默认关闭,默认只发 1 次)** —— 上游用 `400` + 业务码 `11128` 拒绝时,这一轮就此结束:插件不会在原请求里重试(三级救援已跑完),不会换号(网关匹配的是消息内容,每个账号发出去都一样),宿主也不重试。**它不是 11128 的修复**:固定 system 指纹(#163 的 identity / Web GUI / @ 路径整段)每一轮逐字节相同,重发只会再付一次同一个 400;5.13.1 起这些由 `prepareChatBody` 的按句改词处理。只有被拒内容刚好随轮次变化的窄情况,才可能靠新回合碰过去。开启卡片「能力」栏的开关后,插件检测到这一类失败、等 300ms、用 `agent.followup` 发一条 `继续`(`source.kind` 是 `workbuddy-continue`,**不是** `user`)。**上限数的是「连续」次数**(`channelRejectContinue.maxConsecutive`,默认 **1**;旧默认 3 已证伪——1 次救不了,3 次也救不了):成功一轮、或你自己发一条消息,计数清零;插件自己发的不清零。子代理会话一律不碰;会话正在跑、或收件箱已有排队内容时也不发。**每次都会真实消耗一次请求**,所以默认关闭;它也是本插件里**唯一一个改完即生效**的上游类开关。与其它上游开关一样,也能直接在 `cordis.patch.yml` 里写:
64
+ - **可选:被网关渠道校验拦截后的最后兜底「继续」(默认关闭,默认只发 1 次)** —— 上游用 `400` + 业务码 `11128` 拒绝时,这一轮就此结束:插件不会在原请求里重试(三级救援已跑完),不会换号(网关匹配的是消息内容,每个账号发出去都一样),宿主也不重试。**它不是 11128 的修复**:固定 system 指纹(#163 的 identity / Web GUI / @ 路径整段)每一轮逐字节相同,重发只会再付一次同一个 400;5.13.1 起这些由 `prepareChatBody` 的按句改词处理(5.14.0 又补上 Web GUI 段落的第二句,见 #167)。只有被拒内容刚好随轮次变化的窄情况,才可能靠新回合碰过去。开启卡片「能力」栏的开关后,插件检测到这一类失败、等 300ms、用 `agent.followup` 发一条 `继续`(`source.kind` 是 `workbuddy-continue`,**不是** `user`)。**上限数的是「连续」次数**(`channelRejectContinue.maxConsecutive`,默认 **1**;旧默认 3 已证伪——1 次救不了,3 次也救不了):成功一轮、或你自己发一条消息,计数清零;插件自己发的不清零。子代理会话一律不碰;会话正在跑、或收件箱已有排队内容时也不发。**每次都会真实消耗一次请求**,所以默认关闭;它也是本插件里**唯一一个改完即生效**的上游类开关。与其它上游开关一样,也能直接在 `cordis.patch.yml` 里写:
65
65
 
66
66
  ```yaml
67
67
  channelRejectContinue:
@@ -194,6 +194,10 @@ DSH PiAiAdapter(每个 provider 一套)
194
194
 
195
195
  > ⚠️ **版本要求:DSH 0.1.7-rc.1 及以上(当前窗口:`0.1.7-rc.1` ~ `0.2.x` 正式版)。** 自 v2.1.0 起本插件只支持 DSH 0.1.7-rc.1 及以上的宿主(旧版宿主无法通过依赖解析安装本版);0.1.5 及更早的宿主请停留在 v2.0.15。自 v2.1.2 起上界放宽为 **`<0.3.0-0`**,0.3.0 线被明确拒绝。详见下方[受支持的宿主范围](#受支持的宿主范围)。
196
196
 
197
+ > ⚠️ **国内区的「渠道校验」400(`code 11128`)请确保插件 ≥ 5.14.0。** 5.13.1 起会在**发出请求之前**按句改写已知的固定指纹,5.14.0 又补上了 Web GUI 段落的第二句(#167,5.13.1 上仍然必现的那一支);5.12.x 及更早只有「中性头 + 零宽扰动」三级救援,在同一份 body 上实测**全部 400**(对照矩阵见 [#164](https://github.com/dingminhua/dsh-connect-workbuddy/issues/164))。停在旧版的人会反复撞上这个已经修好的 400,而表现是「每次必现、重启也没用」。
198
+ >
199
+ > 两个会让人升级不到的原因:**① 插件市场的不同索引在同一时刻可能给出三个「最新」**([#164](https://github.com/dingminhua/dsh-connect-workbuddy/issues/164) 实测到 5.12.3 / 5.12.4 / 5.13.1 并存),在旧索引上点「检查更新」看不到新版;**② pnpm 的 24 小时新版本等待期**(`minimumReleaseAge`)会把浮动范围**静默压回上一个版本**。需要立刻修复时请写**精确版本号**:`dsh plugin --profile desktop add dsh-connect-workbuddy@5.14.0`(原因见下方「刚发布的版本请写精确版本号」那条)。
200
+
197
201
  ### 受支持的宿主范围
198
202
 
199
203
  DSH 的随包门禁(`packages/boot/app-boot/src/plugin-compatibility.ts` 的 `evaluatePluginCompatibility`)会逐条核对本插件声明的 `dsh-*` peer 范围;**只要有一条不满足,整个组合包会被跳过**(进入 `skippedBundles`,仅打印到 stderr)——provider 不注册、卡片不出现、模型列表清空,而页面上没有任何报错。
@@ -469,12 +473,14 @@ else { await envelope.response.text() /* 或者按 SSE 消费 */ }
469
473
 
470
474
  ## 市场收录与展示
471
475
 
472
- 插件已包含可安装的 `dsh.bundle` manifest,并发布到 npm。社区市场通常从 [Awesome DSH Plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) 注册表同步条目;仓库内的提交草稿位于 [`awesome-dsh-plugin-submission/dingminhua__dsh-connect-workbuddy.yml`](awesome-dsh-plugin-submission/dingminhua__dsh-connect-workbuddy.yml),正式提交为 PR [#3812](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/3812)(`data/plugins/dingminhua__dsh-connect-workbuddy.yml`,已通过 CI 与 Submission gate,等待维护者合并)。
476
+ 插件已包含可安装的 `dsh.bundle` manifest,并发布到 npm。社区市场通常从 [Awesome DSH Plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) 注册表同步条目;仓库内的条目镜像位于 [`awesome-dsh-plugin-submission/dingminhua__dsh-connect-workbuddy.yml`](awesome-dsh-plugin-submission/dingminhua__dsh-connect-workbuddy.yml)。**条目已于 PR [#3812](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/3812) 合并收录(2026-08-30)**。
477
+
478
+ > ⚠️ **条目合并之后不会自己更新。** #3812 收录时插件还在 2.0.15,注册表上挂的描述因此一直停在当时的能力:没提国内版/国际版双供应商、没提每次刷新读取积分倍率、没提账号池自动换号,也没说明签到是**手动**的(签到会真实领取奖励,只在用户点按钮时执行)。**插件每次有大的能力变化,条目也要另开一次 PR 跟上**——注册表评审明确「描述必须属实、会对着代码核」。当前那条更新是 PR [#10281](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/10281)。
473
479
 
474
480
  **收录目录规范**(依据 [contributing.md](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/blob/main/contributing.md)):
475
481
 
476
482
  - 每个插件一个 YAML:`data/plugins/<owner>__<repo>.yml`,字段为 `url`(与仓库地址完全一致)、`name`(`owner/repo`)、`category`(有效值之一,本项目为 `model`)、`description.en`(必填,以句号结尾)/ `description.zh`(可选);描述中含 `: ` 必须加引号。
477
- - 仓库必须声明 `dsh.bundle` manifest(本项目 `dsh.bundle.patch: ./cordis.patch.yml`),且被 `dsh-plugin` topic 标记;提交数 ≥ 10、仓库满 1 天由 CI 自动检查。
483
+ - 仓库必须声明 `dsh.bundle` manifest(本项目 `dsh.bundle.patch: ./cordis.patch.yml`),且被 `dsh-plugin` topic 标记;**只声明 `dsh.client` 是最常见的被拒原因**。仓库创建满 1 天由 CI 自动检查(**没有提交数门槛**,那条已废除)。
478
484
  - 两个 README(`README.md` / `README.zh.md`)由脚本生成,不得手工编辑;修改 YAML 后执行 `node scripts/generate-readme.mjs` 重新生成。
479
485
  - 一个 PR 最多收录 3 条;只改自己的条目,不动其他插件。
480
486
 
@@ -482,7 +488,7 @@ else { await envelope.response.text() /* 或者按 SSE 消费 */ }
482
488
 
483
489
  - **GitHub 徽章**由本 README 顶部的 Shields/dshfind 图片链接生成。
484
490
  - **市场截图**按当前约定由**插件自己仓库根目录的 `screenshots.json`** 声明(本项目声明了 [screenshots.json](screenshots.json),含使用界面截图),注册表 `data/screenshots.json` 是旧约定下的回退,**不再添加新键**(本项目已在提交前撤销该文件的改动)。
485
- - **市场图标/占位图**由具体市场的展示规则决定,并不是 npm `package.json` 的通用字段,也不是 README 徽章。
491
+ - **市场图标**在注册表条目里用可选的 `icon:` 字段声明(本项目指向 `icons/dsh-connect-workbuddy-128.png`):**必须是 GitHub 托管的位图**(png/jpg/jpeg/webp),SVG 与第三方图床会被拒;不写则回退到作者头像。它并不是 npm `package.json` 的通用字段,也不是 README 徽章。
486
492
 
487
493
  **插件卡上那句标题与简介不是本插件渲染的**——那是 DSH 插件管理器读的**包元数据**:它按 `locale/<lang>.json` 的 `meta.title` / `meta.description` 取词(`locale/en.json` 是锚点,缺了它 `zh.json` 永远不会被读;`exports` 里缺 `"./locale/*.json"` 通配则整个目录被**静默**跳过),英文回退到 `package.json` 的 `description`。因此同一句话在本仓库写在三处,并由 [`tests/locale-meta.spec.ts`](tests/locale-meta.spec.ts) 钉死:卡面字典(`locale/en.json` / `locale/zh.json`)、英文回退(`package.json.description`)、市场条目草稿(`awesome-dsh-plugin-submission/dingminhua__dsh-connect-workbuddy.yml` 的 `description.en` / `description.zh`)。**改一处就要同改三处。**
488
494
 
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as WorkBuddyCredentialStore, D as defaultDesktopAuthDirs, E as defaultDesktopAuthCandidates, I as workbuddyOwnAuthPath, Lt as WORKBUDDY_APP_EXECUTABLE_ENV, M as legacyWorkbuddyOwnAuthPath, S as WORKBUDDY_AUTH_FILE_ENV, Ut as findWorkbuddyAppExecutableWithSource, V as WorkBuddyUpstreamClient, Zt as hostPeersInfo, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as rosterSeenPath, f as piAiRuntimeInfo, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, o as workbuddyHostHeartbeatPath, p as FALLBACK_WORKBUDDY_MODELS, r as isHeartbeatProcessAlive, u as describeRosterSeenPayload } from "./host-heartbeat-BtMcdOIa.js";
2
+ import { C as WorkBuddyCredentialStore, D as defaultDesktopAuthDirs, E as defaultDesktopAuthCandidates, I as workbuddyOwnAuthPath, M as legacyWorkbuddyOwnAuthPath, Qt as hostPeersInfo, Rt as WORKBUDDY_APP_EXECUTABLE_ENV, S as WORKBUDDY_AUTH_FILE_ENV, V as WorkBuddyUpstreamClient, Wt as findWorkbuddyAppExecutableWithSource, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as rosterSeenPath, f as piAiRuntimeInfo, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, o as workbuddyHostHeartbeatPath, p as FALLBACK_WORKBUDDY_MODELS, r as isHeartbeatProcessAlive, u as describeRosterSeenPayload } from "./host-heartbeat-CCvdfUtL.js";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { realpathSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
package/lib/client.js CHANGED
@@ -2174,7 +2174,9 @@ window.__ModuleLoader__.load({
2174
2174
  "kimi-k2.6": "multimodal",
2175
2175
  "kimi-k2.7": "multimodal",
2176
2176
  "kimi-k3-1": "multimodal",
2177
- "minimax-m3": "multimodal"
2177
+ "minimax-m3": "multimodal",
2178
+ "minimax-m3.1-flash": "multimodal",
2179
+ "space-bunny": "multimodal"
2178
2180
  };
2179
2181
  /**
2180
2182
  * Ids the upstream lists in the model roster that are ROUTERS, not models.
@@ -5199,6 +5201,9 @@ window.__ModuleLoader__.load({
5199
5201
  .dsm-workbuddy-searched-more{margin-top:6px;padding:0;border:0;background:none;color:var(--dsw-alias-brand-primary,#5686fe);font:inherit;font-size:12px;line-height:18px;cursor:pointer;text-align:left}
5200
5202
  .dsm-workbuddy-searched-more:hover{text-decoration:underline}
5201
5203
  .dsm-workbuddy-usage-error{margin:0;font-size:14px;line-height:22px;color:var(--dsw-alias-state-error-primary,#ef4444)}
5204
+ /* A failed status read is recoverable, so the banner carries an action: the
5205
+ message alone told the user nothing they could do but close the card. */
5206
+ .dsm-workbuddy-usage-error-block{display:flex;flex-direction:column;align-items:flex-start;gap:10px;margin-top:12px}
5202
5207
  .dsm-workbuddy-usage-dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto}
5203
5208
  .dsm-workbuddy-models{display:flex;flex-direction:column;gap:10px;border-top:1px solid var(--dsw-alias-border-l2,#36373b);padding-top:14px}
5204
5209
  .dsm-workbuddy-models-fold{display:flex;flex-direction:column;gap:10px}
@@ -6168,6 +6173,18 @@ window.__ModuleLoader__.load({
6168
6173
  */
6169
6174
  const POLL_INTERVAL_MS = 6e4;
6170
6175
  /**
6176
+ * Retry cadence for a status read that FAILED.
6177
+ *
6178
+ * The failed read used to be terminal: `status.status === 'error'` made
6179
+ * `hasAccounts` false, which gated both the 60s poll and the open effect, so a
6180
+ * single transient failure (a WebView fetch rejected while the DSH web process
6181
+ * was restarting) left "Failed to fetch" on screen until the card was closed
6182
+ * and reopened. Backoff doubles from BASE up to MAX and resets on the first
6183
+ * read that lands; nothing is armed while the card is collapsed.
6184
+ */
6185
+ const STATUS_RETRY_BASE_MS = 3e3;
6186
+ const STATUS_RETRY_MAX_MS = 3e4;
6187
+ /**
6171
6188
  * The reasoning tiers the local CodeBuddy CLI accepts for its `--effort` flag.
6172
6189
  *
6173
6190
  * Kept in sync with `EFFORT_LEVELS` in `src/cli-bridge/tool.ts` by hand: that
@@ -6937,6 +6954,36 @@ window.__ModuleLoader__.load({
6937
6954
  status.status
6938
6955
  ]);
6939
6956
  /**
6957
+ * Recover from a failed status read without a close/reopen.
6958
+ *
6959
+ * Keyed on the document identity (`loaded`), not on `status.status`: every
6960
+ * failed read writes a FRESH `{ status: 'error' }` object, so the identity
6961
+ * changes and the next attempt is armed. A successful read replaces it with a
6962
+ * new object too, which resets the counter through the branch below.
6963
+ */
6964
+ const statusRetries = (0, react.useRef)(0);
6965
+ (0, react.useEffect)(() => {
6966
+ if (!open || status.status !== "error") {
6967
+ statusRetries.current = 0;
6968
+ return;
6969
+ }
6970
+ const delay = Math.min(STATUS_RETRY_MAX_MS, STATUS_RETRY_BASE_MS * 2 ** statusRetries.current);
6971
+ const controller = new AbortController();
6972
+ const timer = window.setTimeout(() => {
6973
+ statusRetries.current += 1;
6974
+ refreshUsage(activeRegion, controller.signal);
6975
+ }, delay);
6976
+ return () => {
6977
+ window.clearTimeout(timer);
6978
+ controller.abort();
6979
+ };
6980
+ }, [
6981
+ open,
6982
+ activeRegion,
6983
+ refreshUsage,
6984
+ loaded
6985
+ ]);
6986
+ /**
6940
6987
  * Re-detect the local sign-ins and refresh the panel.
6941
6988
  *
6942
6989
  * This deliberately writes NO account selection. It used to persist whatever
@@ -7833,6 +7880,7 @@ window.__ModuleLoader__.load({
7833
7880
  lastCatalog: visibleModels.map(toPersistedWorkBuddyModel),
7834
7881
  enabledModelIds: [...activeEnabledIds],
7835
7882
  imageModelIds: [...activeImageIds],
7883
+ imageSelectionExplicit: true,
7836
7884
  offModelIds: [...activeOffIds],
7837
7885
  contextBudgets: activeContextBudgets
7838
7886
  });
@@ -9118,9 +9166,21 @@ window.__ModuleLoader__.load({
9118
9166
  t
9119
9167
  }) : null
9120
9168
  ] }) : null,
9121
- status.status === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
9122
- className: "dsm-workbuddy-usage-error",
9123
- children: status.message
9169
+ status.status === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
9170
+ className: "dsm-workbuddy-usage-error-block",
9171
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
9172
+ className: "dsm-workbuddy-usage-error",
9173
+ role: "alert",
9174
+ children: t("row.usageErrorHint", { message: status.message === "" ? t("row.requestFailed") : status.message })
9175
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
9176
+ type: "button",
9177
+ className: "dsm-btn dsm-btn-outline",
9178
+ onClick: () => {
9179
+ statusRetries.current = 0;
9180
+ refreshUsage(activeRegion);
9181
+ },
9182
+ children: t("row.usageRetry")
9183
+ })]
9124
9184
  }) : null
9125
9185
  ]
9126
9186
  })
@@ -10842,6 +10902,8 @@ window.__ModuleLoader__.load({
10842
10902
  "row.credentialRejectedChoose": "Other local sign-ins exist but none of them answered the upstream just now. Try switching to one above; if none works, sign in again in the desktop app.",
10843
10903
  "row.requestFailed": "Request failed",
10844
10904
  "row.requestFailedHint": "Refresh failed — showing the last loaded data: {message}",
10905
+ "row.usageErrorHint": "Could not read the account status: {message}. The local DSH service may have just restarted — retrying automatically.",
10906
+ "row.usageRetry": "Retry",
10845
10907
  "row.poolErrUnavailable": "The account pool is not available in this build (or its batch route is missing). Restart the app; if it persists, the plugin needs reinstalling.",
10846
10908
  "row.poolErrFailed": "The batch failed on the host side: {message}",
10847
10909
  "row.poolErrHttp": "The batch was refused (HTTP {status}). Check the plugin is up to date and try again.",
@@ -11276,6 +11338,8 @@ window.__ModuleLoader__.load({
11276
11338
  "row.credentialRejectedChoose": "本机还有其他账号,但刚才都没能通过上游校验。可以在上方切换一个试试;如果都不行,请在桌面端重新登录。",
11277
11339
  "row.requestFailed": "请求失败",
11278
11340
  "row.requestFailedHint": "刷新失败——下方为上次加载的数据:{message}",
11341
+ "row.usageErrorHint": "读取账号状态失败:{message}。本地 DSH 服务可能刚重启过,正在自动重试。",
11342
+ "row.usageRetry": "重试",
11279
11343
  "row.poolErrUnavailable": "本版本没有可用的账号池(或其批量接口缺失)。请重启应用;若仍不行,插件可能需要重新安装。",
11280
11344
  "row.poolErrFailed": "批量操作在宿主侧失败:{message}",
11281
11345
  "row.poolErrHttp": "批量操作被拒绝(HTTP {status})。请确认插件是最新版本后重试。",
@@ -980,7 +980,9 @@ const NATIVE_MODALITY_BY_MODEL_ID = {
980
980
  "kimi-k2.6": "multimodal",
981
981
  "kimi-k2.7": "multimodal",
982
982
  "kimi-k3-1": "multimodal",
983
- "minimax-m3": "multimodal"
983
+ "minimax-m3": "multimodal",
984
+ "minimax-m3.1-flash": "multimodal",
985
+ "space-bunny": "multimodal"
984
986
  };
985
987
  /**
986
988
  * Ids the upstream lists in the model roster that are ROUTERS, not models.
@@ -1039,6 +1041,47 @@ function imageDefaultFor(info) {
1039
1041
  if (info.supportsImages === false) return false;
1040
1042
  return NATIVE_MODALITY_BY_MODEL_ID[info.id] === "multimodal";
1041
1043
  }
1044
+ /**
1045
+ * Delete the oldest files in `dir`, keeping the newest `keep`.
1046
+ *
1047
+ * Ordering is by mtime, not filename: the image name embeds an ISO timestamp
1048
+ * and the video name embeds one plus a URL tail, so neither sorts reliably as a
1049
+ * string once more than one generator writes into the directory.
1050
+ *
1051
+ * @returns how many files were removed (for tests and for callers that want to
1052
+ * log a real number); `0` when the directory is absent, unreadable, small
1053
+ * enough, or when every removal failed.
1054
+ */
1055
+ async function pruneTempDir(dir, keep = 20, extensions = [".png", ".mp4"]) {
1056
+ let names;
1057
+ try {
1058
+ names = (await readdir(dir)).filter((name) => extensions.some((ext) => name.endsWith(ext)));
1059
+ } catch {
1060
+ return 0;
1061
+ }
1062
+ if (names.length <= keep) return 0;
1063
+ const dated = await Promise.all(names.map(async (name) => {
1064
+ const path = join(dir, name);
1065
+ try {
1066
+ return {
1067
+ path,
1068
+ mtimeMs: (await stat(path)).mtimeMs
1069
+ };
1070
+ } catch {
1071
+ return {
1072
+ path,
1073
+ mtimeMs: 0
1074
+ };
1075
+ }
1076
+ }));
1077
+ dated.sort((a, b) => b.mtimeMs - a.mtimeMs);
1078
+ let removed = 0;
1079
+ for (const entry of dated.slice(Math.max(0, keep))) try {
1080
+ await rm(entry.path, { force: true });
1081
+ removed += 1;
1082
+ } catch {}
1083
+ return removed;
1084
+ }
1042
1085
  /** Everything the app treats as a queue condition (`isQueueError`). */
1043
1086
  const WORKBUDDY_QUEUE_CODES = [
1044
1087
  6020,
@@ -3525,6 +3568,17 @@ function scansOpening(role) {
3525
3568
  * `from` strings are matched ANYWHERE, not just at an opening, because the
3526
3569
  * measured rule is position-independent. Only a hit is replaced; every other
3527
3570
  * byte is left alone, so an ordinary conversation is unchanged.
3571
+ *
3572
+ * WHICH HOST PACKAGE TO WATCH WHEN A ROW STOPS MATCHING (issue #164 traced the
3573
+ * three paragraphs to their owners in the DSH install): the identity sentence
3574
+ * comes from `@deepseek-ai/dsh-system-prompt`, the @-path paragraph from
3575
+ * `@deepseek-ai/dsh-file-reference`, and the Web GUI paragraph from
3576
+ * `@deepseek-ai/dsh-harness-dev-context`. #164 also bisected the MINIMUM
3577
+ * triggering span: the @-path paragraph already fails at its first two sentences
3578
+ * (112 chars) and the Web GUI paragraph at its first two (217 chars), each
3579
+ * sentence alone answering 200 — so a row must clear the truncated shape too,
3580
+ * which is exactly what sentence-level rows do. Pinned by
3581
+ * `tests/issue163-sentence-rewrite.spec.ts`.
3528
3582
  */
3529
3583
  const CHANNEL_GATE_PHRASES = [
3530
3584
  ["You are an AI agent powered by DeepSeek Harness", "You are an AI agent running inside DeepSeek Harness"],
@@ -3539,7 +3593,9 @@ const CHANNEL_GATE_PHRASES = [
3539
3593
  ["Anything else is a file:", "Everything else is a file:"],
3540
3594
  ["use the read tool when its contents are needed,", "use the read tool whenever you need its contents,"],
3541
3595
  ["and do not claim to have inspected it before reading.", "and never claim to have inspected it before reading it."],
3542
- ["@\"...\" quotes a path containing spaces.", "A path containing spaces is quoted with @\"...\"."]
3596
+ ["@\"...\" quotes a path containing spaces.", "A path containing spaces is quoted with @\"...\"."],
3597
+ ["without naming another target, they mean this GUI.", "and names no other target, the interface described here is meant."],
3598
+ ["they mean this GUI.", "the interface described here is meant."]
3543
3599
  ];
3544
3600
  /**
3545
3601
  * Rewrite the measured gate phrases in one text part.
@@ -5244,6 +5300,7 @@ var WorkBuddyUpstreamClient = class {
5244
5300
  const safeUrl = url.replace(/[^A-Za-z0-9._-]+/g, "_").slice(-32);
5245
5301
  const path = join(dir, `video-${stamp}-${safeUrl}.mp4`);
5246
5302
  await writeFile(path, buffer);
5303
+ pruneTempDir(dir, 20, [".mp4"]);
5247
5304
  return path;
5248
5305
  }
5249
5306
  /** Query today's check-in status without changing account state. */
@@ -5630,8 +5687,38 @@ function mergeCredentialDisplayNames(previous, next) {
5630
5687
  const WORKBUDDY_AUTH_FILENAME = ".workbuddy-auth.json";
5631
5688
  /** Env variable that overrides the desktop auth-file location. */
5632
5689
  const WORKBUDDY_AUTH_FILE_ENV = "WORKBUDDY_AUTH_FILE";
5633
- /** Basename of the live WorkBuddy desktop auth file. */
5634
- const WORKBUDDY_LIVE_FILENAME = "workbuddy-desktop.info";
5690
+ /**
5691
+ * Basenames the live WorkBuddy desktop auth file is known to use.
5692
+ *
5693
+ * ISSUE #174: the constant used to be ONE name, `workbuddy-desktop.info`, and
5694
+ * the 5.7.x Windows app writes `workbuddy-desktop-ai.info` instead — the same
5695
+ * directory, the same payload, a different filename (measured by the reporter:
5696
+ * `%LOCALAPPDATA%\CodeBuddyExtension\Data\Public\auth\` held
5697
+ * `workbuddy-desktop-ai.info` plus timestamped backups of that same name, and no
5698
+ * file carrying the constant's name at all). The consequences were both silent:
5699
+ * the "live file always wins" rule in {@link fileRank} never matched, so
5700
+ * ordering fell back to `lastRefreshAtMs` (correct by luck, not by rule), and
5701
+ * `doctor` reported `desktopAuthFile.present: false` with a hint to set
5702
+ * `WORKBUDDY_AUTH_FILE` — on a machine whose credential had just been read
5703
+ * successfully from the default directory.
5704
+ *
5705
+ * Ordered, not a set: {@link WORKBUDDY_LIVE_FILENAME} stays the one this build
5706
+ * treats as canonical for writing, and the alternatives are probed after it. The
5707
+ * FIRST entry is what {@link defaultDesktopAuthCandidates} reports as the
5708
+ * expected path, so keep it there.
5709
+ */
5710
+ const WORKBUDDY_LIVE_FILENAMES = ["workbuddy-desktop.info", "workbuddy-desktop-ai.info"];
5711
+ WORKBUDDY_LIVE_FILENAMES[0];
5712
+ /**
5713
+ * Whether one basename is a LIVE desktop auth file (any of the known names).
5714
+ *
5715
+ * Every place that asked "is this the live file?" now asks this, so a new
5716
+ * spelling is added in one list instead of being chased through the reader, the
5717
+ * ranker and `doctor` separately — which is exactly how the three drifted apart.
5718
+ */
5719
+ function isLiveDesktopAuthFileName(name) {
5720
+ return WORKBUDDY_LIVE_FILENAMES.includes(name);
5721
+ }
5635
5722
  /** Prefix of the plugin-owned per-region credential copies (legacy, read-only). */
5636
5723
  const WORKBUDDY_OWN_PREFIX = ".workbuddy-auth";
5637
5724
  /**
@@ -5875,9 +5962,17 @@ function defaultDesktopAuthDirs(platform = process.platform, home = homedir(), e
5875
5962
  function nonEmptyEnv(value) {
5876
5963
  return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
5877
5964
  }
5878
- /** The live auth file's platform candidates, in probe order. */
5965
+ /**
5966
+ * The live auth file's platform candidates, in probe order.
5967
+ *
5968
+ * Every known basename for every platform directory: a machine can hold the old
5969
+ * and the new spelling side by side during an app upgrade, and probing only the
5970
+ * canonical one is the defect issue #174 reports.
5971
+ */
5879
5972
  function defaultDesktopAuthCandidates() {
5880
- return defaultDesktopAuthDirs().map((dir) => join(dir, WORKBUDDY_LIVE_FILENAME));
5973
+ const candidates = [];
5974
+ for (const dir of defaultDesktopAuthDirs()) for (const name of WORKBUDDY_LIVE_FILENAMES) candidates.push(join(dir, name));
5975
+ return candidates;
5881
5976
  }
5882
5977
  /** First platform-default candidate; see {@link defaultDesktopAuthCandidates}. */
5883
5978
  function defaultDesktopAuthPath() {
@@ -6025,7 +6120,7 @@ function hasEncryptedCredentialFields(text) {
6025
6120
  * backups, never the primary ordering.
6026
6121
  */
6027
6122
  function fileRank(path) {
6028
- return authFileName(path) === WORKBUDDY_LIVE_FILENAME ? 0 : 1;
6123
+ return isLiveDesktopAuthFileName(authFileName(path)) ? 0 : 1;
6029
6124
  }
6030
6125
  /**
6031
6126
  * Whether `candidate` is a better pick than `incumbent` for the same account.
@@ -6414,7 +6509,7 @@ var WorkBuddyCredentialStore = class {
6414
6509
  const fromEnv = process.env[WORKBUDDY_AUTH_FILE_ENV];
6415
6510
  const explicit = this.desktopPathOverride ?? (fromEnv !== void 0 && fromEnv.trim() !== "" ? fromEnv : void 0);
6416
6511
  if (explicit !== void 0) return [explicit];
6417
- return defaultDesktopAuthCandidates();
6512
+ return (this.authDirs ?? defaultDesktopAuthDirs()).flatMap((dir) => WORKBUDDY_LIVE_FILENAMES.map((name) => join(dir, name)));
6418
6513
  }
6419
6514
  /** The resolved desktop auth-file path, for diagnostics. */
6420
6515
  desktopAuthPath() {
@@ -6441,10 +6536,20 @@ var WorkBuddyCredentialStore = class {
6441
6536
  return files;
6442
6537
  }
6443
6538
  const dirs = this.authDirs ?? defaultDesktopAuthDirs();
6539
+ const seen = /* @__PURE__ */ new Set();
6444
6540
  for (const dir of dirs) {
6445
- const live = join(dir, WORKBUDDY_LIVE_FILENAME);
6446
- files.push(live);
6447
- for (const backup of await this.backupsBeside(live)) files.push(backup);
6541
+ for (const name of WORKBUDDY_LIVE_FILENAMES) {
6542
+ const live = join(dir, name);
6543
+ if (!seen.has(live)) {
6544
+ seen.add(live);
6545
+ files.push(live);
6546
+ }
6547
+ }
6548
+ for (const name of WORKBUDDY_LIVE_FILENAMES) for (const backup of await this.backupsBeside(join(dir, name))) {
6549
+ if (seen.has(backup)) continue;
6550
+ seen.add(backup);
6551
+ files.push(backup);
6552
+ }
6448
6553
  }
6449
6554
  return files;
6450
6555
  }
@@ -6952,8 +7057,33 @@ var WorkBuddyCredentialStore = class {
6952
7057
  */
6953
7058
  async diagnose() {
6954
7059
  const candidates = [];
6955
- for (const path of await this.candidateFiles()) {
6956
- const probe = await probeAuthFile(path, this.resolveAtRestKey);
7060
+ const desktopTried = this.resolveDesktopCandidates();
7061
+ const probed = [];
7062
+ for (const path of await this.candidateFiles()) probed.push({
7063
+ path,
7064
+ probe: await probeAuthFile(path, this.resolveAtRestKey)
7065
+ });
7066
+ /**
7067
+ * Directories that need no `missing` complaint (issue #174).
7068
+ *
7069
+ * A directory is "accounted for" when it either yielded a readable
7070
+ * credential OR produced some other, more specific complaint. The first case
7071
+ * is the healthy machine whose app writes only one of the two known
7072
+ * spellings; the second is a machine holding a broken file — and telling
7073
+ * that user "no auth file at the expected path" would be both false and
7074
+ * less useful than the `invalid` line they already have.
7075
+ */
7076
+ const accountedDirs = /* @__PURE__ */ new Set();
7077
+ for (const entry of probed) {
7078
+ if ("credential" in entry.probe) {
7079
+ accountedDirs.add(dirname(entry.path));
7080
+ continue;
7081
+ }
7082
+ if (entry.probe.failure.reason !== "missing") accountedDirs.add(dirname(entry.path));
7083
+ }
7084
+ /** Directories whose `missing` complaint has already been made. */
7085
+ const missingReported = /* @__PURE__ */ new Set();
7086
+ for (const { path, probe } of probed) {
6957
7087
  if ("credential" in probe) {
6958
7088
  if (!this.matchesRegion(probe.credential.domain)) candidates.push({
6959
7089
  path,
@@ -6963,6 +7093,11 @@ var WorkBuddyCredentialStore = class {
6963
7093
  });
6964
7094
  continue;
6965
7095
  }
7096
+ if (probe.failure.reason === "missing") {
7097
+ const dir = dirname(path);
7098
+ if (accountedDirs.has(dir) || missingReported.has(dir)) continue;
7099
+ missingReported.add(dir);
7100
+ }
6966
7101
  candidates.push({
6967
7102
  path,
6968
7103
  source: "desktop",
@@ -7021,7 +7156,7 @@ var WorkBuddyCredentialStore = class {
7021
7156
  }
7022
7157
  } catch {}
7023
7158
  return {
7024
- tried,
7159
+ tried: [...desktopTried, ...this.ownCandidates()],
7025
7160
  failures: candidates
7026
7161
  };
7027
7162
  }
@@ -7797,7 +7932,7 @@ function describeRosterSeenPayload(raw) {
7797
7932
  //#endregion
7798
7933
  //#region src/version.ts
7799
7934
  /** The npm package version this build was produced from. */
7800
- const WORKBUDDY_CONNECT_VERSION = "5.13.1";
7935
+ const WORKBUDDY_CONNECT_VERSION = "5.14.1";
7801
7936
  //#endregion
7802
7937
  //#region src/host-heartbeat.ts
7803
7938
  /**
@@ -7815,8 +7950,18 @@ const WORKBUDDY_CONNECT_VERSION = "5.13.1";
7815
7950
  */
7816
7951
  /** Basename of the host heartbeat file inside the Harness home. */
7817
7952
  const WORKBUDDY_HOST_HEARTBEAT_FILENAME = ".workbuddy-host-heartbeat.json";
7818
- /** Current on-disk heartbeat format; readers reject others. */
7819
- const HEARTBEAT_FORMAT_VERSION = 1;
7953
+ /**
7954
+ * On-disk heartbeat formats this build WRITES and READS.
7955
+ *
7956
+ * 1 → 2 added `hostStartedAt` (issue #174). Version 1 is still READ, on purpose:
7957
+ * rejecting it would turn the first `doctor` after an upgrade into a spurious
7958
+ * "no heartbeat at all", which is a worse diagnosis than the imprecise
7959
+ * comparison version 1 supports. Anything newer than {@link HEARTBEAT_FORMAT_VERSION}
7960
+ * is rejected, because a future field could change the meaning of the ones read
7961
+ * here.
7962
+ */
7963
+ const HEARTBEAT_FORMAT_VERSION = 2;
7964
+ const READABLE_HEARTBEAT_VERSIONS = [1, 2];
7820
7965
  /** Absolute path of the host heartbeat file. */
7821
7966
  function workbuddyHostHeartbeatPath() {
7822
7967
  return join(resolveDshHome(), WORKBUDDY_HOST_HEARTBEAT_FILENAME);
@@ -7901,10 +8046,40 @@ function processStartTimeMs(pid) {
7901
8046
  }
7902
8047
  }
7903
8048
  /**
8049
+ * How close a recorded start time must be to the live process's start time for
8050
+ * the two to be considered the SAME process.
8051
+ *
8052
+ * Two tolerances, for two different comparisons:
8053
+ *
8054
+ * - {@link HOST_START_REFERENCE_TOLERANCE_MS} when the heartbeat records
8055
+ * `hostStartedAt` — both sides are then the SAME quantity (a process start
8056
+ * time), so the window only has to absorb the coarse resolution of
8057
+ * `ps -o lstart=` (whole seconds) and PowerShell's string round-trip.
8058
+ * - {@link LEGACY_REFERENCE_TOLERANCE_MS} when it does not, and the only
8059
+ * available reference is `registeredAt`. That is a different quantity
8060
+ * (when the plugin mounted, not when the process started), so it is a
8061
+ * lower bound rather than an answer: a host that started more than this
8062
+ * before the plugin mounted would have looked dead. Kept wide on purpose —
8063
+ * a missed reuse is far less harmful than calling a live host stale.
8064
+ */
8065
+ const HOST_START_REFERENCE_TOLERANCE_MS = 12e4;
8066
+ const LEGACY_REFERENCE_TOLERANCE_MS = 432e5;
8067
+ /**
7904
8068
  * Whether the recorded host process still matches the heartbeat's PID.
7905
8069
  *
7906
8070
  * A PID can be reused after a crash, so the recorded start time is compared
7907
8071
  * against the live process: a different start time means a different process.
8072
+ *
8073
+ * ISSUE #174 — the reference used to be `registeredAt`, the moment the plugin
8074
+ * WROTE the heartbeat, and the comparison was `|startedAt − registeredAt| < 60s`.
8075
+ * Those are two different quantities: `registeredAt` is when the host finished
8076
+ * booting and mounted this plugin, which on a real machine is minutes after the
8077
+ * process started. Measured by the reporter on Windows 11: process start
8078
+ * 19:47:20, heartbeat written 19:58:45 — 684,817 ms apart — so `doctor` and
8079
+ * `status` reported `hostBundle: "stale"` for a host that was alive and had
8080
+ * just written the file. The `hostStartedAt` field exists so the comparison has
8081
+ * the right reference; `registeredAt` is only read for heartbeats written by an
8082
+ * older build, where it is the sole (and merely near) signal available.
7908
8083
  */
7909
8084
  function isHeartbeatProcessAlive(heartbeat) {
7910
8085
  if (!Number.isInteger(heartbeat.pid) || heartbeat.pid <= 0) return false;
@@ -7915,7 +8090,9 @@ function isHeartbeatProcessAlive(heartbeat) {
7915
8090
  }
7916
8091
  const startedAt = processStartTimeMs(heartbeat.pid);
7917
8092
  if (startedAt === void 0) return true;
7918
- return Math.abs(startedAt - heartbeat.registeredAt) < 6e4;
8093
+ const recorded = heartbeat.hostStartedAt;
8094
+ if (recorded !== void 0 && Number.isFinite(recorded)) return Math.abs(startedAt - recorded) < HOST_START_REFERENCE_TOLERANCE_MS;
8095
+ return Math.abs(startedAt - heartbeat.registeredAt) < LEGACY_REFERENCE_TOLERANCE_MS;
7919
8096
  }
7920
8097
  /** Read the heartbeat; absent or unparsable files report undefined. */
7921
8098
  async function readHostHeartbeat() {
@@ -7923,30 +8100,41 @@ async function readHostHeartbeat() {
7923
8100
  const parsed = JSON.parse(await readFile(workbuddyHostHeartbeatPath(), "utf8"));
7924
8101
  if (typeof parsed !== "object" || parsed === null) return void 0;
7925
8102
  const document = parsed;
7926
- if (document["version"] !== HEARTBEAT_FORMAT_VERSION) return void 0;
8103
+ if (!READABLE_HEARTBEAT_VERSIONS.includes(document["version"])) return void 0;
7927
8104
  if (document["package"] !== "dsh-connect-workbuddy") return void 0;
7928
8105
  const pid = document["pid"];
7929
8106
  const registeredAt = document["registeredAt"];
7930
8107
  if (typeof pid !== "number" || typeof registeredAt !== "number") return void 0;
8108
+ const hostStartedAt = document["hostStartedAt"];
7931
8109
  return {
7932
- version: HEARTBEAT_FORMAT_VERSION,
8110
+ version: document["version"],
7933
8111
  package: "dsh-connect-workbuddy",
7934
8112
  pluginVersion: typeof document["pluginVersion"] === "string" ? document["pluginVersion"] : WORKBUDDY_CONNECT_VERSION,
7935
8113
  registeredAt,
7936
- pid
8114
+ pid,
8115
+ ...typeof hostStartedAt === "number" && Number.isFinite(hostStartedAt) ? { hostStartedAt } : {}
7937
8116
  };
7938
8117
  } catch {
7939
8118
  return;
7940
8119
  }
7941
8120
  }
7942
- /** Write the heartbeat for the current process. */
8121
+ /**
8122
+ * Write the heartbeat for the current process.
8123
+ *
8124
+ * `hostStartedAt` is captured HERE, from this process, so the later staleness
8125
+ * check compares like with like (issue #174). Sampled on every write rather
8126
+ * than cached: the value is cheap, and a module-level cache would be wrong the
8127
+ * moment this function is exercised from a child process.
8128
+ */
7943
8129
  async function writeHostHeartbeat() {
8130
+ const hostStartedAt = processStartTimeMs(process.pid);
7944
8131
  const heartbeat = {
7945
8132
  version: HEARTBEAT_FORMAT_VERSION,
7946
8133
  package: "dsh-connect-workbuddy",
7947
8134
  pluginVersion: WORKBUDDY_CONNECT_VERSION,
7948
8135
  registeredAt: Date.now(),
7949
- pid: process.pid
8136
+ pid: process.pid,
8137
+ ...hostStartedAt === void 0 ? {} : { hostStartedAt }
7950
8138
  };
7951
8139
  await writeFile(workbuddyHostHeartbeatPath(), `${JSON.stringify(heartbeat, null, 2)}\n`, { mode: 384 });
7952
8140
  }
@@ -7955,4 +8143,4 @@ async function clearHostHeartbeat() {
7955
8143
  await rm(workbuddyHostHeartbeatPath(), { force: true });
7956
8144
  }
7957
8145
  //#endregion
7958
- export { parseCreditMultiplier as $, withFileLock as $t, isEncryptedCredentialError as A, pickMediaModel as At, WorkBuddyCredentialRejectedError as B, deriveAtRestKeyId as Bt, WorkBuddyCredentialStore as C, IMAGE_TAG_TEXT_TO_IMAGE as Ct, defaultDesktopAuthDirs as D, imageMimeForPath as Dt, defaultDesktopAuthCandidates as E, imageDataUrl as Et, workbuddyAccountId as F, imageDefaultFor as Ft, forceNeutralHead as G, isWorkbuddyBundle as Gt, channelGateMemberOf as H, findWorkbuddyAppExecutable as Ht, workbuddyOwnAuthPath as I, nativeModalityOf as It, isCredentialRejectedError as J, openEncryptedField as Jt, isContextTooLongBody as K, macosBundleExecutable as Kt, CONTEXT_OVERFLOW_MARKER as L, WORKBUDDY_APP_EXECUTABLE_ENV as Lt, legacyWorkbuddyOwnAuthPath as M, parseQueueAnswer as Mt, parseWorkBuddyAuth as N, queueNoticeText as Nt, defaultDesktopAuthPath as O, isBareBase64Image as Ot, preferredAccountName as P, NATIVE_MODALITY_BY_MODEL_ID as Pt, openingEvidenceOfBody as Q, resolveDshHome as Qt, CREDENTIAL_REJECTED_CODE as R, clearAtRestKeyCache as Rt, WORKBUDDY_AUTH_FILE_ENV as S, IMAGE_TAG_IMAGE_TO_IMAGE as St, authFileName as T, WorkBuddyImageError as Tt, classifyUpstreamError as U, findWorkbuddyAppExecutableWithSource as Ut, WorkBuddyUpstreamClient as V, fetchAtRestKeyPayload as Vt, declaredTools as W, isEncryptedFieldWrapper as Wt, knownRegionOf as X, workbuddyAppExecutableCandidates as Xt, isFirstMessageGateBody as Y, readAtRestKey as Yt, modelIdOfBody as Z, hostPeersInfo as Zt, deriveCatalog as _, WorkBuddyVideoError as _t, readHostHeartbeat as a, requestEvidenceOf as at, ENCRYPTED_CREDENTIAL_CODE as b, resolveVideoParams as bt, WORKBUDDY_CONNECT_VERSION as c, REFUSES_OFF_MODEL_IDS as ct, rosterSeenPath as d, offDefaultFor as dt, writeFileAtomic as en, parseReasoning as et, piAiRuntimeInfo as f, offRefused as ft, applyContextBudgets as g, VIDEO_TAG_TEXT_TO_VIDEO as gt, WorkBuddyCatalog as h, VIDEO_TAG_IMAGE_TO_VIDEO as ht, processStartTimeMs as i, regionOf as it, isVaultAccountId as j, sniffImageMime as jt, hasEncryptedCredentialFields as k, parseImageDataUrl as kt, createFileRosterSeenStore as l, defaultOffModelIds as lt, FALLBACK_WORKBUDDY_MODELS_GLOBAL as m, withEffectiveOff as mt, clearHostHeartbeat as n, perturbChannelFingerprints as nt, workbuddyHostHeartbeatPath as o, rewriteSessionTitleModel as ot, FALLBACK_WORKBUDDY_MODELS as p, recordRefusal as pt, isConversationIdGateBody as q, macosNestedAppBundles as qt, isHeartbeatProcessAlive as r, prepareChatBody as rt, writeHostHeartbeat as s, attachBusyLevels as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, parseUpstreamModel as tt, describeRosterSeenPayload as u, lastRefusalFor as ut, fallbackModelsFor as v, parseSupportedSecondsRange as vt, WorkBuddyEncryptedCredentialError as w, MAX_INPUT_IMAGE_BYTES as wt, WORKBUDDY_AUTH_FILENAME as x, DEFAULT_IMAGE_SIZE as xt, withoutRouters as y, pickVideoModel as yt, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as z, deriveAtRestKey as zt };
8146
+ export { parseCreditMultiplier as $, resolveDshHome as $t, isEncryptedCredentialError as A, pickMediaModel as At, WorkBuddyCredentialRejectedError as B, deriveAtRestKey as Bt, WorkBuddyCredentialStore as C, IMAGE_TAG_TEXT_TO_IMAGE as Ct, defaultDesktopAuthDirs as D, imageMimeForPath as Dt, defaultDesktopAuthCandidates as E, imageDataUrl as Et, workbuddyAccountId as F, NATIVE_MODALITY_BY_MODEL_ID as Ft, forceNeutralHead as G, isEncryptedFieldWrapper as Gt, channelGateMemberOf as H, fetchAtRestKeyPayload as Ht, workbuddyOwnAuthPath as I, imageDefaultFor as It, isCredentialRejectedError as J, macosNestedAppBundles as Jt, isContextTooLongBody as K, isWorkbuddyBundle as Kt, CONTEXT_OVERFLOW_MARKER as L, nativeModalityOf as Lt, legacyWorkbuddyOwnAuthPath as M, parseQueueAnswer as Mt, parseWorkBuddyAuth as N, queueNoticeText as Nt, defaultDesktopAuthPath as O, isBareBase64Image as Ot, preferredAccountName as P, pruneTempDir as Pt, openingEvidenceOfBody as Q, hostPeersInfo as Qt, CREDENTIAL_REJECTED_CODE as R, WORKBUDDY_APP_EXECUTABLE_ENV as Rt, WORKBUDDY_AUTH_FILE_ENV as S, IMAGE_TAG_IMAGE_TO_IMAGE as St, authFileName as T, WorkBuddyImageError as Tt, classifyUpstreamError as U, findWorkbuddyAppExecutable as Ut, WorkBuddyUpstreamClient as V, deriveAtRestKeyId as Vt, declaredTools as W, findWorkbuddyAppExecutableWithSource as Wt, knownRegionOf as X, readAtRestKey as Xt, isFirstMessageGateBody as Y, openEncryptedField as Yt, modelIdOfBody as Z, workbuddyAppExecutableCandidates as Zt, deriveCatalog as _, WorkBuddyVideoError as _t, readHostHeartbeat as a, requestEvidenceOf as at, ENCRYPTED_CREDENTIAL_CODE as b, resolveVideoParams as bt, WORKBUDDY_CONNECT_VERSION as c, REFUSES_OFF_MODEL_IDS as ct, rosterSeenPath as d, offDefaultFor as dt, withFileLock as en, parseReasoning as et, piAiRuntimeInfo as f, offRefused as ft, applyContextBudgets as g, VIDEO_TAG_TEXT_TO_VIDEO as gt, WorkBuddyCatalog as h, VIDEO_TAG_IMAGE_TO_VIDEO as ht, processStartTimeMs as i, regionOf as it, isVaultAccountId as j, sniffImageMime as jt, hasEncryptedCredentialFields as k, parseImageDataUrl as kt, createFileRosterSeenStore as l, defaultOffModelIds as lt, FALLBACK_WORKBUDDY_MODELS_GLOBAL as m, withEffectiveOff as mt, clearHostHeartbeat as n, perturbChannelFingerprints as nt, workbuddyHostHeartbeatPath as o, rewriteSessionTitleModel as ot, FALLBACK_WORKBUDDY_MODELS as p, recordRefusal as pt, isConversationIdGateBody as q, macosBundleExecutable as qt, isHeartbeatProcessAlive as r, prepareChatBody as rt, writeHostHeartbeat as s, attachBusyLevels as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, writeFileAtomic as tn, parseUpstreamModel as tt, describeRosterSeenPayload as u, lastRefusalFor as ut, fallbackModelsFor as v, parseSupportedSecondsRange as vt, WorkBuddyEncryptedCredentialError as w, MAX_INPUT_IMAGE_BYTES as wt, WORKBUDDY_AUTH_FILENAME as x, DEFAULT_IMAGE_SIZE as xt, withoutRouters as y, pickVideoModel as yt, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as z, clearAtRestKeyCache as zt };
package/lib/index.d.ts CHANGED
@@ -208,7 +208,13 @@ declare function legacyWorkbuddyOwnAuthPath(): string;
208
208
  * testable on any host without touching a real machine.
209
209
  */
210
210
  declare function defaultDesktopAuthDirs(platform?: NodeJS.Platform, home?: string, env?: NodeJS.ProcessEnv): string[];
211
- /** The live auth file's platform candidates, in probe order. */
211
+ /**
212
+ * The live auth file's platform candidates, in probe order.
213
+ *
214
+ * Every known basename for every platform directory: a machine can hold the old
215
+ * and the new spelling side by side during an app upgrade, and probing only the
216
+ * canonical one is the defect issue #174 reports.
217
+ */
212
218
  declare function defaultDesktopAuthCandidates(): string[];
213
219
  /** First platform-default candidate; see {@link defaultDesktopAuthCandidates}. */
214
220
  declare function defaultDesktopAuthPath(): string | undefined;
@@ -2454,9 +2460,11 @@ declare function createWorkBuddyAdapter(options: WorkBuddyAdapterOptions): WorkB
2454
2460
  * the same defect and fixed it the same way — a reviewed table keyed by exact
2455
2461
  * model id, with a source and a review date per entry.
2456
2462
  *
2457
- * This snapshot is copied from that project's
2458
- * `server/services/native_modalities.json` (all entries reviewed 2026-09-25).
2459
- * The provenance — per-id source URL and evidence — is kept in
2463
+ * This snapshot BEGAN as a copy of that project's
2464
+ * `server/services/native_modalities.json` (all entries reviewed 2026-09-25) and
2465
+ * has since grown by two ids whose evidence is a second, weaker class: the
2466
+ * upstream catalog's own description for that id (see the table's second
2467
+ * section). Provenance — per-id source URL and evidence — is kept in
2460
2468
  * `docs/DESIGN.md` rather than here, because the build ships this module's
2461
2469
  * comments verbatim into the browser bundle. Keep the two in sync when either
2462
2470
  * side is updated; this is a maintained snapshot, NOT a live read.
@@ -2740,17 +2748,31 @@ declare function resolveCredentialRecovery(options: {
2740
2748
  */
2741
2749
  /** Basename of the host heartbeat file inside the Harness home. */
2742
2750
  declare const WORKBUDDY_HOST_HEARTBEAT_FILENAME = ".workbuddy-host-heartbeat.json";
2743
- /** Current on-disk heartbeat format; readers reject others. */
2744
- declare const HEARTBEAT_FORMAT_VERSION = 1;
2751
+ declare const READABLE_HEARTBEAT_VERSIONS: readonly number[];
2745
2752
  /** On-disk shape of the heartbeat. */
2746
2753
  interface WorkBuddyHostHeartbeat {
2747
- version: typeof HEARTBEAT_FORMAT_VERSION;
2754
+ /** The format actually on disk; this build writes {@link HEARTBEAT_FORMAT_VERSION}. */
2755
+ version: (typeof READABLE_HEARTBEAT_VERSIONS)[number];
2748
2756
  package: 'dsh-connect-workbuddy';
2749
2757
  pluginVersion: string;
2750
2758
  /** Epoch milliseconds when the host registered the provider. */
2751
2759
  registeredAt: number;
2752
2760
  /** Host process PID, to distinguish a stale heartbeat after a crash. */
2753
2761
  pid: number;
2762
+ /**
2763
+ * Epoch milliseconds when THIS process started (issue #174).
2764
+ *
2765
+ * The staleness check needs to recognise a REUSED PID: `process.kill(pid, 0)`
2766
+ * only says "some process has this number", so after a crash the heartbeat
2767
+ * can describe a process that died while the number now belongs to something
2768
+ * else. The way to tell is to compare the live process's start time against
2769
+ * the one recorded HERE.
2770
+ *
2771
+ * Absent on heartbeats written by older builds (`version: 1`): the reader
2772
+ * then falls back to {@link WorkBuddyHostHeartbeat.registeredAt}, which is
2773
+ * what those builds wrote, and is only a last-resort approximation.
2774
+ */
2775
+ hostStartedAt?: number;
2754
2776
  }
2755
2777
  /** Absolute path of the host heartbeat file. */
2756
2778
  declare function workbuddyHostHeartbeatPath(): string;
@@ -2768,11 +2790,29 @@ declare function processStartTimeMs(pid: number): number | undefined;
2768
2790
  *
2769
2791
  * A PID can be reused after a crash, so the recorded start time is compared
2770
2792
  * against the live process: a different start time means a different process.
2793
+ *
2794
+ * ISSUE #174 — the reference used to be `registeredAt`, the moment the plugin
2795
+ * WROTE the heartbeat, and the comparison was `|startedAt − registeredAt| < 60s`.
2796
+ * Those are two different quantities: `registeredAt` is when the host finished
2797
+ * booting and mounted this plugin, which on a real machine is minutes after the
2798
+ * process started. Measured by the reporter on Windows 11: process start
2799
+ * 19:47:20, heartbeat written 19:58:45 — 684,817 ms apart — so `doctor` and
2800
+ * `status` reported `hostBundle: "stale"` for a host that was alive and had
2801
+ * just written the file. The `hostStartedAt` field exists so the comparison has
2802
+ * the right reference; `registeredAt` is only read for heartbeats written by an
2803
+ * older build, where it is the sole (and merely near) signal available.
2771
2804
  */
2772
2805
  declare function isHeartbeatProcessAlive(heartbeat: WorkBuddyHostHeartbeat): boolean;
2773
2806
  /** Read the heartbeat; absent or unparsable files report undefined. */
2774
2807
  declare function readHostHeartbeat(): Promise<WorkBuddyHostHeartbeat | undefined>;
2775
- /** Write the heartbeat for the current process. */
2808
+ /**
2809
+ * Write the heartbeat for the current process.
2810
+ *
2811
+ * `hostStartedAt` is captured HERE, from this process, so the later staleness
2812
+ * check compares like with like (issue #174). Sampled on every write rather
2813
+ * than cached: the value is cheap, and a module-level cache would be wrong the
2814
+ * moment this function is exercised from a child process.
2815
+ */
2776
2816
  declare function writeHostHeartbeat(): Promise<void>;
2777
2817
  /** Remove the heartbeat; called when the plugin is disposed. */
2778
2818
  declare function clearHostHeartbeat(): Promise<void>;
@@ -4990,8 +5030,35 @@ interface WorkBuddyRegionState {
4990
5030
  lastCatalog?: WorkBuddyModelInfo[];
4991
5031
  /** The user's selection in this region, as model ids. */
4992
5032
  enabledModelIds?: string[];
4993
- /** Model ids the user explicitly opted into image input. */
5033
+ /**
5034
+ * Model ids the user opted into image input for this region.
5035
+ *
5036
+ * Every id here is honoured. What the SAVED array cannot express is the
5037
+ * difference between "I looked at this model and unticked it" and "this model
5038
+ * did not exist when the list was last refreshed" — so the runtime also adds
5039
+ * a model this build's vendor table classifies as verified-multimodal, UNLESS
5040
+ * {@link imageSelectionExplicit} says the user has edited these boxes. See
5041
+ * `effectiveImageIds` (issue #168); the seed never rewrites this array.
5042
+ */
4994
5043
  imageModelIds?: string[];
5044
+ /**
5045
+ * Whether the image boxes have been edited by a human (issue #168).
5046
+ *
5047
+ * `imageModelIds` alone cannot distinguish a DECISION from a leftover refresh
5048
+ * snapshot: the array is written both by the card's save (a judgement) and by
5049
+ * a catalog refresh (what the roster said on that day). Without that
5050
+ * distinction a model the roster gained after the last refresh was stamped
5051
+ * `multimodal: false` purely by its ABSENCE, `workBuddyModelInput` answered
5052
+ * `['text']`, and the host refused the attachment with
5053
+ * `MODEL_DOES_NOT_SUPPORT_IMAGES` — 「这个模型不支持图片」 — for a model the
5054
+ * plugin's own table calls verified-multimodal.
5055
+ *
5056
+ * `false`/absent means "nobody has chosen yet, so the table may fill the gaps";
5057
+ * a card save sets `true`, after which this array is the whole answer — which
5058
+ * is what keeps an explicit UNTICK of a verified-multimodal model from being
5059
+ * silently re-ticked on the next read.
5060
+ */
5061
+ imageSelectionExplicit?: boolean;
4995
5062
  /**
4996
5063
  * Model ids whose `off` thinking level is offered (issue #34). Seeded from
4997
5064
  * the built-in rule (upstream declaration minus the models known to reject
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as parseCreditMultiplier, $t as withFileLock, A as isEncryptedCredentialError, At as pickMediaModel, B as WorkBuddyCredentialRejectedError, Bt as deriveAtRestKeyId, C as WorkBuddyCredentialStore, Ct as IMAGE_TAG_TEXT_TO_IMAGE, D as defaultDesktopAuthDirs, Dt as imageMimeForPath, E as defaultDesktopAuthCandidates, Et as imageDataUrl, F as workbuddyAccountId, Ft as imageDefaultFor, G as forceNeutralHead, Gt as isWorkbuddyBundle, H as channelGateMemberOf, Ht as findWorkbuddyAppExecutable, I as workbuddyOwnAuthPath, It as nativeModalityOf, J as isCredentialRejectedError, Jt as openEncryptedField, K as isContextTooLongBody, Kt as macosBundleExecutable, L as CONTEXT_OVERFLOW_MARKER, Lt as WORKBUDDY_APP_EXECUTABLE_ENV, M as legacyWorkbuddyOwnAuthPath, Mt as parseQueueAnswer, N as parseWorkBuddyAuth, Nt as queueNoticeText, O as defaultDesktopAuthPath, Ot as isBareBase64Image, P as preferredAccountName, Pt as NATIVE_MODALITY_BY_MODEL_ID, Q as openingEvidenceOfBody, Qt as resolveDshHome, R as CREDENTIAL_REJECTED_CODE, Rt as clearAtRestKeyCache, S as WORKBUDDY_AUTH_FILE_ENV, St as IMAGE_TAG_IMAGE_TO_IMAGE, T as authFileName, Tt as WorkBuddyImageError, U as classifyUpstreamError, V as WorkBuddyUpstreamClient, Vt as fetchAtRestKeyPayload, W as declaredTools, Wt as isEncryptedFieldWrapper, X as knownRegionOf, Xt as workbuddyAppExecutableCandidates, Y as isFirstMessageGateBody, Yt as readAtRestKey, Z as modelIdOfBody, _ as deriveCatalog, _t as WorkBuddyVideoError, a as readHostHeartbeat, at as requestEvidenceOf, b as ENCRYPTED_CREDENTIAL_CODE, bt as resolveVideoParams, c as WORKBUDDY_CONNECT_VERSION, ct as REFUSES_OFF_MODEL_IDS, dt as offDefaultFor, en as writeFileAtomic, et as parseReasoning, f as piAiRuntimeInfo, ft as offRefused, g as applyContextBudgets, gt as VIDEO_TAG_TEXT_TO_VIDEO, h as WorkBuddyCatalog, ht as VIDEO_TAG_IMAGE_TO_VIDEO, i as processStartTimeMs, it as regionOf, j as isVaultAccountId, jt as sniffImageMime, k as hasEncryptedCredentialFields, kt as parseImageDataUrl, l as createFileRosterSeenStore, lt as defaultOffModelIds, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, mt as withEffectiveOff, n as clearHostHeartbeat, nt as perturbChannelFingerprints, o as workbuddyHostHeartbeatPath, ot as rewriteSessionTitleModel, p as FALLBACK_WORKBUDDY_MODELS, pt as recordRefusal, q as isConversationIdGateBody, qt as macosNestedAppBundles, r as isHeartbeatProcessAlive, rt as prepareChatBody, s as writeHostHeartbeat, st as attachBusyLevels, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as parseUpstreamModel, ut as lastRefusalFor, v as fallbackModelsFor, vt as parseSupportedSecondsRange, w as WorkBuddyEncryptedCredentialError, wt as MAX_INPUT_IMAGE_BYTES, x as WORKBUDDY_AUTH_FILENAME, xt as DEFAULT_IMAGE_SIZE, y as withoutRouters, yt as pickVideoModel, z as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, zt as deriveAtRestKey } from "./host-heartbeat-BtMcdOIa.js";
1
+ import { $ as parseCreditMultiplier, $t as resolveDshHome, A as isEncryptedCredentialError, At as pickMediaModel, B as WorkBuddyCredentialRejectedError, Bt as deriveAtRestKey, C as WorkBuddyCredentialStore, Ct as IMAGE_TAG_TEXT_TO_IMAGE, D as defaultDesktopAuthDirs, Dt as imageMimeForPath, E as defaultDesktopAuthCandidates, Et as imageDataUrl, F as workbuddyAccountId, Ft as NATIVE_MODALITY_BY_MODEL_ID, G as forceNeutralHead, Gt as isEncryptedFieldWrapper, H as channelGateMemberOf, Ht as fetchAtRestKeyPayload, I as workbuddyOwnAuthPath, It as imageDefaultFor, J as isCredentialRejectedError, Jt as macosNestedAppBundles, K as isContextTooLongBody, Kt as isWorkbuddyBundle, L as CONTEXT_OVERFLOW_MARKER, Lt as nativeModalityOf, M as legacyWorkbuddyOwnAuthPath, Mt as parseQueueAnswer, N as parseWorkBuddyAuth, Nt as queueNoticeText, O as defaultDesktopAuthPath, Ot as isBareBase64Image, P as preferredAccountName, Pt as pruneTempDir, Q as openingEvidenceOfBody, R as CREDENTIAL_REJECTED_CODE, Rt as WORKBUDDY_APP_EXECUTABLE_ENV, S as WORKBUDDY_AUTH_FILE_ENV, St as IMAGE_TAG_IMAGE_TO_IMAGE, T as authFileName, Tt as WorkBuddyImageError, U as classifyUpstreamError, Ut as findWorkbuddyAppExecutable, V as WorkBuddyUpstreamClient, Vt as deriveAtRestKeyId, W as declaredTools, X as knownRegionOf, Xt as readAtRestKey, Y as isFirstMessageGateBody, Yt as openEncryptedField, Z as modelIdOfBody, Zt as workbuddyAppExecutableCandidates, _ as deriveCatalog, _t as WorkBuddyVideoError, a as readHostHeartbeat, at as requestEvidenceOf, b as ENCRYPTED_CREDENTIAL_CODE, bt as resolveVideoParams, c as WORKBUDDY_CONNECT_VERSION, ct as REFUSES_OFF_MODEL_IDS, dt as offDefaultFor, en as withFileLock, et as parseReasoning, f as piAiRuntimeInfo, ft as offRefused, g as applyContextBudgets, gt as VIDEO_TAG_TEXT_TO_VIDEO, h as WorkBuddyCatalog, ht as VIDEO_TAG_IMAGE_TO_VIDEO, i as processStartTimeMs, it as regionOf, j as isVaultAccountId, jt as sniffImageMime, k as hasEncryptedCredentialFields, kt as parseImageDataUrl, l as createFileRosterSeenStore, lt as defaultOffModelIds, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, mt as withEffectiveOff, n as clearHostHeartbeat, nt as perturbChannelFingerprints, o as workbuddyHostHeartbeatPath, ot as rewriteSessionTitleModel, p as FALLBACK_WORKBUDDY_MODELS, pt as recordRefusal, q as isConversationIdGateBody, qt as macosBundleExecutable, r as isHeartbeatProcessAlive, rt as prepareChatBody, s as writeHostHeartbeat, st as attachBusyLevels, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tn as writeFileAtomic, tt as parseUpstreamModel, ut as lastRefusalFor, v as fallbackModelsFor, vt as parseSupportedSecondsRange, w as WorkBuddyEncryptedCredentialError, wt as MAX_INPUT_IMAGE_BYTES, x as WORKBUDDY_AUTH_FILENAME, xt as DEFAULT_IMAGE_SIZE, y as withoutRouters, yt as pickVideoModel, z as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, zt as clearAtRestKeyCache } from "./host-heartbeat-CCvdfUtL.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -4361,7 +4361,36 @@ const NO_RETRY_HEADERS = Object.freeze({
4361
4361
  "x-should-retry": "false",
4362
4362
  "retry-after": "0"
4363
4363
  });
4364
- /** Read a request body with a size cap; over-limit bodies fail the request. */
4364
+ /**
4365
+ * An over-limit request body (issue #174).
4366
+ *
4367
+ * A named class rather than an `Error` with a magic message, because the two
4368
+ * callers must answer it with a DIFFERENT status: the protocol answer is 413,
4369
+ * and classifying it as an ordinary internal failure made it a 500. A class
4370
+ * survives renaming the message, which is the only reason this is not a
4371
+ * string comparison.
4372
+ */
4373
+ var RequestBodyTooLargeError = class extends Error {
4374
+ limitBytes;
4375
+ constructor(limitBytes) {
4376
+ super(`request body too large (limit ${limitBytes} bytes)`);
4377
+ this.limitBytes = limitBytes;
4378
+ this.name = "RequestBodyTooLargeError";
4379
+ }
4380
+ };
4381
+ /**
4382
+ * Read a request body with a size cap; over-limit bodies fail the request.
4383
+ *
4384
+ * AN OVER-LIMIT BODY IS STOPPED, NOT DESTROYED (issue #174). Calling
4385
+ * `req.destroy()` here tears down the SOCKET, so the 413 the caller is about to
4386
+ * write can never reach the client — it saw a reset connection instead of an
4387
+ * answer, which is strictly worse than the 500 this replaced. So the stream is
4388
+ * paused and its listeners are dropped: the request is no longer read, the
4389
+ * response still goes out, and Node closes the connection once the response
4390
+ * ends. Bytes already arriving are discarded with the socket, which is the
4391
+ * whole point of a cap (`content-length` is not trusted, so the cap has to be
4392
+ * enforced on arrival).
4393
+ */
4365
4394
  function readBody(req) {
4366
4395
  return new Promise((resolve, reject) => {
4367
4396
  const chunks = [];
@@ -4369,8 +4398,11 @@ function readBody(req) {
4369
4398
  req.on("data", (chunk) => {
4370
4399
  size += chunk.length;
4371
4400
  if (size > REQUEST_BODY_LIMIT) {
4372
- reject(/* @__PURE__ */ new Error("request body too large"));
4373
- req.destroy();
4401
+ chunks.length = 0;
4402
+ req.removeAllListeners("data");
4403
+ req.removeAllListeners("end");
4404
+ req.pause();
4405
+ reject(new RequestBodyTooLargeError(REQUEST_BODY_LIMIT));
4374
4406
  return;
4375
4407
  }
4376
4408
  chunks.push(chunk);
@@ -5281,8 +5313,10 @@ function createWorkBuddyShim(options) {
5281
5313
  }
5282
5314
  writeOpenAIError(res, 404, "not_found", `no such route: ${req.method} ${url}`);
5283
5315
  } catch (error) {
5284
- if (!res.headersSent) writeOpenAIError(res, 500, "internal", String(error));
5285
- else res.end();
5316
+ if (!res.headersSent) {
5317
+ if (error instanceof RequestBodyTooLargeError) writeOpenAIError(res, 413, "client", error.message, "request_too_large");
5318
+ else writeOpenAIError(res, 500, "internal", String(error));
5319
+ } else res.end();
5286
5320
  }
5287
5321
  }
5288
5322
  /**
@@ -5672,14 +5706,16 @@ function isBillableFailure$1(error, sent) {
5672
5706
  if (error instanceof WorkBuddyImageError) return BILLABLE_FAILURE_CODES$1.has(error.code);
5673
5707
  return true;
5674
5708
  }
5709
+ /** 生成图片的临时目录(issue #174)——清理由 {@link pruneTempDir} 负责。 */
5710
+ const TEMP_IMAGE_DIR = join(tmpdir(), "workbuddy-image-gen");
5675
5711
  /** 把 b64 图片落成一个临时文件,返回绝对路径。 */
5676
5712
  async function writeTempImage(b64, model) {
5677
- const dir = join(tmpdir(), "workbuddy-image-gen");
5678
- await mkdir(dir, { recursive: true });
5713
+ await mkdir(TEMP_IMAGE_DIR, { recursive: true });
5679
5714
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
5680
5715
  const safeModel = model.replace(/[^A-Za-z0-9._-]+/g, "_");
5681
- const path = join(dir, `${safeModel}-${stamp}.png`);
5716
+ const path = join(TEMP_IMAGE_DIR, `${safeModel}-${stamp}.png`);
5682
5717
  await writeFile(path, Buffer.from(b64, "base64"));
5718
+ pruneTempDir(TEMP_IMAGE_DIR, 20, [".png"]);
5683
5719
  return path;
5684
5720
  }
5685
5721
  /**
@@ -11241,6 +11277,12 @@ const regionStateConfig = z.object({
11241
11277
  * ticked correctly without the user having to save.
11242
11278
  */
11243
11279
  offModelIds: z.array(z.string()).default([]).description("Model ids whose \"off\" thinking level is offered; seeded from the built-in rule when first read."),
11280
+ /**
11281
+ * Whether a human has edited the image boxes (issue #168). `false` lets the
11282
+ * vendor-verified table fill ids the saved array does not mention; `true`
11283
+ * makes `imageModelIds` the whole answer, so an explicit untick sticks.
11284
+ */
11285
+ imageSelectionExplicit: z.boolean().default(false),
11244
11286
  contextBudgets: z.dict(z.number().step(1).min(1)).default({}),
11245
11287
  pool: poolConfig.default({})
11246
11288
  });
@@ -11583,10 +11625,51 @@ function apply(ctx, config) {
11583
11625
  ...model,
11584
11626
  ...images.has(model.id) ? { multimodal: true } : { multimodal: false }
11585
11627
  }));
11628
+ /**
11629
+ * The image selection one region should be stamped with (issue #168).
11630
+ *
11631
+ * The saved array alone cannot answer "does this model take images?", because
11632
+ * it has two writers with different meanings: the user ticking a box (a
11633
+ * DECISION, to be kept forever) and a catalog refresh re-deriving the
11634
+ * vendor-verified defaults (a SNAPSHOT of the roster as it stood that day).
11635
+ * Stamping the snapshot marks every model the roster gained SINCE as
11636
+ * text-only, `workBuddyModelInput` then answers `['text']`, and the host
11637
+ * refuses the attachment outright — `Model "…" does not support image input`
11638
+ * (`MODEL_DOES_NOT_SUPPORT_IMAGES`) — for a model `imageDefaultFor`
11639
+ * classifies as verified-multimodal.
11640
+ *
11641
+ * So the two sides are UNIONED, each contributing exactly what it can know:
11642
+ *
11643
+ * - the SAVED array states every model the user has ticked. A tick is
11644
+ * honoured even where the vendor table disagrees (someone may know this
11645
+ * account refuses images for that model).
11646
+ * - the VENDOR TABLE states the models this build can vouch for, and is
11647
+ * consulted only for ids the saved array says nothing about. That is what
11648
+ * un-breaks a newcomer without touching a single decision already made.
11649
+ *
11650
+ * `imageSelectionExplicit` closes the one gap a union cannot: the array cannot
11651
+ * say whether an ABSENT id was unticked or simply did not exist yet. Once a
11652
+ * human has edited the boxes, absence is a decision again and the table stops
11653
+ * contributing at all.
11654
+ *
11655
+ * This keeps every existing shape working: an empty `imageModelIds` on a
11656
+ * never-edited region now means "the user has not chosen any" and is filled
11657
+ * from the verified list; a populated one keeps every id it carries. It is
11658
+ * deliberately NOT a hard gate on the flag alone, because the settings seam
11659
+ * (`ctx.settings.update`) is a public write path and
11660
+ * `tests/settings-integration.spec.ts` pins that writing `imageModelIds`
11661
+ * there must reach the adapter without a second "I edited it" call.
11662
+ */
11663
+ const effectiveImageIds = (state, models) => {
11664
+ const chosen = new Set(state.imageModelIds ?? []);
11665
+ if (state.imageSelectionExplicit === true) return chosen;
11666
+ for (const model of models) if (!chosen.has(model.id) && imageDefaultFor(model)) chosen.add(model.id);
11667
+ return chosen;
11668
+ };
11586
11669
  const configuredModels = (value, region, liveModels = []) => {
11587
11670
  const state = regionStateOf(value, region);
11588
11671
  const roster = resolveRoster(state.lastCatalog ?? [], liveModels, region, { keepLoadLevel: true });
11589
- const declared = withImageSelection(deriveCatalog(roster, new Set(state.enabledModelIds ?? []), state.contextBudgets ?? {}), new Set(state.imageModelIds ?? []));
11672
+ const declared = withImageSelection(deriveCatalog(roster, new Set(state.enabledModelIds ?? []), state.contextBudgets ?? {}), effectiveImageIds(state, roster));
11590
11673
  return withEffectiveOff(declared, new Set(savedOffModelIds(state, declared))).map((model) => model.reasoning === void 0 || lastRefusalFor(model.id) === void 0 ? model : {
11591
11674
  ...model,
11592
11675
  reasoning: {
@@ -12137,7 +12220,7 @@ function apply(ctx, config) {
12137
12220
  */
12138
12221
  busyLevels: (region) => liveBusyLevels(region),
12139
12222
  enabledModelIds: (region) => regionStateOf(current(), region).enabledModelIds ?? [],
12140
- imageModelIds: (region) => regionStateOf(current(), region).imageModelIds ?? [],
12223
+ imageModelIds: (region) => [...effectiveImageIds(regionStateOf(current(), region), displayModels(current(), region))],
12141
12224
  offModelIds: (region) => savedOffModelIds(regionStateOf(current(), region), displayModels(current(), region)),
12142
12225
  contextBudgets: (region) => regionStateOf(current(), region).contextBudgets ?? {},
12143
12226
  discoverModels,
@@ -13162,7 +13245,7 @@ function apply(ctx, config) {
13162
13245
  const discovered = await discoverModels(region, signal);
13163
13246
  const live = await withBusyLevels(region, discovered, signal);
13164
13247
  const state = regionStateOf(current(), region);
13165
- return withImageSelection(deriveCatalog(live, new Set(state.enabledModelIds ?? []), state.contextBudgets ?? {}), new Set(state.imageModelIds ?? [])).map((model) => ({
13248
+ return withImageSelection(deriveCatalog(live, new Set(state.enabledModelIds ?? []), state.contextBudgets ?? {}), effectiveImageIds(state, live)).map((model) => ({
13166
13249
  id: model.id,
13167
13250
  name: workBuddyDisplayName(model),
13168
13251
  contextWindow: model.contextWindow,
@@ -13186,7 +13269,7 @@ function apply(ctx, config) {
13186
13269
  const models = await withBusyLevels(region, await client.fetchModels(credential, void 0, regionStateOf(current(), region).enabledModelIds ?? []));
13187
13270
  if (stopped) return;
13188
13271
  const state = regionStateOf(current(), region);
13189
- stacks[region].catalog.set(withEffectiveOff(withImageSelection(deriveCatalog(models, new Set(state.enabledModelIds ?? []), state.contextBudgets ?? {}), new Set(state.imageModelIds ?? [])), new Set(savedOffModelIds(state, models))));
13272
+ stacks[region].catalog.set(withEffectiveOff(withImageSelection(deriveCatalog(models, new Set(state.enabledModelIds ?? []), state.contextBudgets ?? {}), effectiveImageIds(state, models)), new Set(savedOffModelIds(state, models))));
13190
13273
  adapters[region].invalidate();
13191
13274
  try {
13192
13275
  await stacks[region].rosterSeen.note(models.map((model) => model.id));
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型(国内版与国际版)接入 DeepSeek Harness:每次刷新按上游目录自动读取每个模型的积分倍率,支持逐模型图片输入开关、多账号切换与账号池自动换号,并提供每日签到、卡片与输入框里的胶囊积分读数等只读积分概览。",
5
- "version": "5.13.1",
5
+ "version": "5.14.1",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",