dsh-connect-workbuddy 5.12.2 → 5.12.4

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,190 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.12.4 (2026-10-28)
4
+
5
+ > issue #159 的**报告现象成立,归因不成立**。现场确实出现了「三级救援一级都没跑」的 `11128`,但把它
6
+ > 唯一地归到 `!controller.signal.aborted` 上不成立:**这个 shim 里 `signal` 只有 `res.on('close')`
7
+ > 一个来源**(`src/shim.ts:1657`,客户端真的断开了),不存在 issue 假设的那个「宿主内部中止」的第二来源;
8
+ > 而按建议删掉三处 `&&` 经实测会把**诊断改坏**(见下)。顺着这条线索查同一个阶梯,找到的**真实缺陷是另一个**:
9
+ > 密集救援 rung 在真实大小的 body 上是 **O(n²)**,现场那种 130 KB 提示词要跑**分钟级同步 CPU**,
10
+ > 于是「救援等于没跑」有了一个不依赖 abort 的解释,且这个解释是实测出来的。
11
+
12
+ ### Fixes
13
+
14
+ - **密集扰动 rung 的 O(n²):92 KB 的 body 要 82.7 秒,130 KB 的现场提示词要两分多钟(#159 衍生)**
15
+ - **根因**:`perturbChannelFingerprints(body, true)` 用 `replace(/(?=\S)/gu, cb)`,回调里为**每一个**
16
+ 匹配位置重新取「上一个非空白字符」,写法是 `source.slice(0, offset).match(/\S(?=\S*$)/u)` ——
17
+ 对每个位置做一次 O(offset) 的前缀切片 + 正则,整体 O(n²)。
18
+ - **实测(本机 Node 25,2026-10-28,改动前)**:
19
+
20
+ | body | 耗时 |
21
+ | --- | --- |
22
+ | 11,575 字符 | 1.3 s |
23
+ | 23,075 字符 | 5.2 s |
24
+ | 46,075 字符 | 22.8 s |
25
+ | 92,075 字符 | **82.7 s** |
26
+ | 184,075 字符 | 322.8 s |
27
+
28
+ - **为什么这条比「救援没跑」更该先修**:这一级只在网关已经拒过两次之后才走,也就是**恰好**落在大 body
29
+ 的真实会话上;而 92 KB 就是一次普通的长会话,#159 现场那条 `system` 是 130,345 字符。这一级是**同步**
30
+ CPU,先跑完才发请求,而 `chatStream` 对自己**刻意不设超时**(它自己的注释解释了原因:真实答案要预填
31
+ 整个提示词,卡一个够用的上限必然切掉慢而正常的答案)。于是救援会先被读者自己的等待熬过去,而**从外面看,
32
+ 「跑得太慢」和「根本没跑」是同一件事** —— 这正是 #159 观察到的那个现象。
33
+ - **修法**:把这条规则改成**一次累加扫描**(`spaceDenseBreaks`)。规则本身一字未改,输出**逐字节相同**:
34
+ 旧写法那句 `/\S(?=\S*$)/u` 要求「其后全部非空白」,所以它取回的其实是**当前这段非空白串的首字符**,
35
+ 而不是上一个字符 —— 这个反直觉的细节正是重写时最容易写错的地方(本轮就写错过两次,靠下面的等价测试
36
+ 发现)。**这不是「顺手优化」**:改动只碰耗时,字节不动,因为换一个字节就是换一份没人测过的 body。
37
+ - **实测(同一台机器,改动后)**:92 KB 由 **82.7 s → 10 ms**(约 8000 倍);130 KB 的等价 body 在
38
+ **10 ms 量级**完成。
39
+ - **两条新测试各钉住一半**:`tests/issue159-dense-rescue-cost.spec.ts` 用**旧规则的逐字转录**做参照,
40
+ 22 个定点用例 + 1500 例定种子随机语料比对**字节相等**;并钉住耗时 —— 130 KB 在 2 s 内完成,
41
+ 320 KB 的中位数在 5 s 内完成(真正线性的实现在该尺寸约 15 ms,旧规则约 15 分钟,两者相差三个数量级,
42
+ 所以这个上限既不误报也绝不放过)。
43
+ - **耗时断言的度量方式本身改过两次,两次都是被 CI / 变异测试纠正的**:第一版钉 60 KB→120 KB 的
44
+ **比值 < 8**,变异测试显示旧规则在该尺寸的比值恰好 **4.0** —— 8 倍把二次方实现**放过去了**,等于没钉;
45
+ 收紧到 **< 3** 后,**CI 又在正确实现上把它跑红**(ubuntu 实测 60 KB 10 ms、120 KB 34 ms,比值 3.4)——
46
+ 在个位数毫秒的样本上,比值量到的是计时粒度、GC 和固定开销,不是被主张的增长。最终改为**在能区分两种
47
+ 实现的尺寸上钉绝对上限**(320 KB / 5 s,中位数),依据是 40→640 KB 的实测每翻倍约 2 倍
48
+ (3.5 / 7.3 / 11.2 / 28.8 / 69.3 ms)。
49
+
50
+ - **未采纳 issue 建议的那一行补丁,并给出实测理由(`tests/issue159-abort-gate.spec.ts`)**
51
+ - issue 的建议是删掉三处 `&& !controller.signal.aborted`,理由是「中止已由下传的 `signal` 在
52
+ `chatStream` 内部保证,外层这道 `&&` 是重复且有害的检查」。**「重复」只对了一半**:删掉之后
53
+ `chatStream` 确实会立刻因 abort 返回,但它返回的是 `{status: 0, kind: 'server'}`(被 abort 的
54
+ `fetch` 抛 `AbortError`,在客户端被兜成传输失败)—— 这个结果会**盖掉**真正的 `channel_reject`
55
+ 诊断,读者收到的从「网关拒了这条请求」变成「上游服务器错误」;`reportFailures` 还会为一个**根本没
56
+ 上线的请求**记一笔账号失败。
57
+ - **实测对照**(真挂断,客户端按真实 `chatStream` 对 abort 的处理返回):
58
+
59
+ | | 保留门(HEAD) | 删掉门(issue 建议) |
60
+ | --- | --- | --- |
61
+ | 断线后额外调用上游 | **0 次** | 2 次 |
62
+ | 最终 kind | `channel_reject`(真实原因) | `server`(自己造出来的) |
63
+ | 账号测量 | **不记** | 记一笔假的 |
64
+
65
+ - 保留的正是 #159 真正关心的那一半:**请求还活着时的 11128,三级照跑**。这一半由
66
+ `issue113-channel-gate-evidence.spec.ts` / `issue98-shim.spec.ts` 覆盖,本轮又加了一条同文件内的
67
+ 对照用例,防止将来有人以「救援没跑」为名再把门删掉。
68
+ - 变异验证:把三处 `&&` 删掉 → 新增的断线用例**变红**(`the shim spent a rescue on an aborted signal:
69
+ expected 1 to be +0`),对照用例**仍绿**。
70
+
71
+ ### 未改动(据实说明)
72
+
73
+ - issue 里「`retries` 为空」这个字段名**在本仓库不存在**:实际实现是失败文案里的「,并已用…重试过」
74
+ (`src/shim.ts:2067-2071`),`Error` 信封里没有 `retries` 字段。报告者读到的可能是宿主侧的包装,
75
+ 本仓库无法据该字段反推,故不据此改动任何判定。
76
+ - issue 的另一半(5.12.3 的 `IDENTITY_CLAUSE` + `neutralizeScannedText` 已修好)与本次改动无关,未触碰。
77
+
78
+ ### 验证
79
+
80
+ - 端到端对照:同一个 **116,939 字符**的现场形状 body(三段身份句 + 无标点长 prose + 20 轮 assistant
81
+ 历史)走完整 shim,三级阶梯全部跑完后答 200 —— **修复后 29 ms,修复前 144,260 ms(2 分 24 秒)**。
82
+ 这一条是本次的真正判据:旧代码不是「永远救不回来」,而是慢到读者早已放弃。
83
+ - 变异验证(每条都确认会让对应用例变红):删掉三处 `&& !controller.signal.aborted` → 新增断线用例
84
+ **变红**(`the shim spent a rescue on an aborted signal: expected 1 to be +0`);把密集规则退回 O(n²)
85
+ 实现 → **160 KB 实测 204,670 ms**,超过 5 s 上限 40 倍,**变红**;而正确实现在同一尺寸约 15 ms
86
+ (320 KB 在变异版上跑满 10 分钟未完成,已终止,未作为判据)。
87
+ - 全量:`tsc` 双配置 0 错误,**148 文件 / 2563 通过(2 条按平台跳过)**,`tsdown` 构建通过。
88
+
89
+ ## 5.12.3 (2026-10-28)
90
+
91
+ > issue #155 的**两个独立 Bug**:**① 业务码按字节匹配 JSON**——`11128`/`14003`/`14018` 用
92
+ > `body.includes('"code":11128')` 判定,于是同一个信封 `{"code":11128}` 认得、多一个空格的
93
+ > `{"code": 11128}` 和写成字符串的 `{"code":"11128"}` 都不认,渠道闸被静默判成 `client`,
94
+ > **整条救援阶梯不再运行**(#113 的修复等于被一个空格关掉);**② 删账号只清内存、不清持久化选择**——
95
+ > 磁盘上 `accounts.cn` 仍写着死 id,下次 `applySelection` 把它读回来,CN 整块变「已保存的账号在本机
96
+ > 已不存在」且账号列表一条不渲染。
97
+
98
+ ### Fixes
99
+
100
+ - **渠道闸/限流/耗尽三族业务码按字节匹配,一个空格就漏判(#155)**
101
+ - **现象**:报告者贴回来的那条消息里**自相矛盾**——正文写着 `code 11128`,同一句却带
102
+ `"type":"client"`,收尾是「请按上面的提示调整后重试」。两半出自同一个信封、同一份 body。
103
+ - **根因**:分类器判族用 `body.includes('"code":11128')`——对**一种序列化**做字节测试;上游 JSON 里
104
+ 多一个空格、或把码写成字符串,判据就落空。而 `code 11128` 那半走 `parseUpstreamErrorDetail`
105
+ (`JSON.parse`),照旧读得到。**以下矩阵是把每行都跑在 HEAD 代码上测出来的**,不是推理:
106
+
107
+ | body 写法 | HEAD classify | HEAD detail.upstreamCode |
108
+ | --- | --- | --- |
109
+ | `{"code":11128}` | `channel_reject` | 11128 |
110
+ | `{"code": 11128}` | `client` ← 漏 | 11128 |
111
+ | `{"code":"11128"}` | `client` ← 漏 | `undefined` ← 也漏 |
112
+ | `{"error":{"code":11128}}` | `channel_reject` | `undefined` ← **反向**不一致 |
113
+ | `{"CODE":11128}` | `client` ← 漏 | `undefined` |
114
+ - **两种不同的错位**,报告者只能贴出第一种。第 2 行就是 #155 那条消息:分类器按 substring 说
115
+ `client`,展示那半按 `JSON.parse` 读到**顶层数字** `11128` 照旧打印 —— 一条回复里,诊断和它自己的
116
+ 证据互相打脸。第 3–5 行对支持更糟、对用户不可见:不但两个读取器不一致,**连要求用户提供的那个码都
117
+ 整个消失**,`channel_reject` 贴出来却没有 `11128` 可锚。第 4 行方向相反(substring 恰好命中嵌套的
118
+ `"code":11128`,解析器当时只认 `error.data.code`),说明这不是「换成解析器就完了」—— 两侧都得读同一个
119
+ 事实,才可能一致。
120
+ - **代价不只是文案**:渠道救援阶梯**只认 `channel_reject`**(`src/shim.ts` 的门是
121
+ `result.kind === 'channel_reject'`),所以漏判成任何别的类别都等于整条阶梯不跑 —— `forceNeutralHead` 与两级
122
+ 扰动一次都不会触发,用户被要求「调整请求后重试」,而插件根本没试过救援。`14003` 同族实测降级目标**随 HTTP
123
+ 状态而变**:`429 {"code": 14003}` → `soft_rate`、`400` → `client`,而 `model_busy` 是三个**不换号**的类别之一
124
+ (`isFailoverWorthy`)—— 落到 `soft_rate` 就是让整个账号池挨个烧一次真实请求去学一个模型级的事实。
125
+ `14018` spaced 单独看会掉成 `client`,实测只有当 `msg` 里带上了 `Credits exhausted` 那句话时才被
126
+ 措辞标记救回 `hard_credit` —— 措辞一改就失效,正是本仓库反复记录的那类脆弱判据。
127
+ - **修法**:新增 `statesBusinessCode(body, code)`,`CHANNEL_REJECT_CODE`/`MODEL_BUSY_CODE`/
128
+ `CREDITS_EXHAUSTED_CODE` 三个常量从字符串形态改成数字,6 处家族判断统一走它。**三段读取,顺序即安全性**:
129
+ ① 信封自己声明的码是最终答案,**绝不再做文本扫描** —— 否则 `extError` 里嵌套的 `"code":11128` 会盖掉外层
130
+ `14003` 的自判,正好是 #127 反过来要的精度;② 解析成功却没在已知三种形态里声明码的 body,仍走带引号锚点
131
+ 的文本扫描 —— 这条是**严格扩量**:#127 只留解析器,反而把 `{"error":{"code":11140}}` 这种旧文本匹配本来
132
+ 认得的写法**收窄**掉了;③ 扫描锚在 `"code":<n>` 而非裸数字,十六进制 requestId 恰好含 `11128` 仍不能把
133
+ 无关 body 提上来(原文本形态存在的理由,一并保留)。
134
+ - **一处先前例**:`CONTEXT_TOO_LONG_CODE`(`11115`,#101)早就写成 `/"code"\s*:\s*11115(?!\d)/u` —— 本仓库
135
+ **第三次**独立撞上同一个坑,前两次各修各的族,这就是为什么这次收成一个共享读取器,而不是第四处正则。
136
+ - **顺带修两处同源缺陷**:`envelopeError()` 已把 `code` 解析成数字,却只把 `msg` 交给分类器 —— 而 `msg`
137
+ 永远不含 `"code":…`,于是每个「靠业务码判定」的族在这里都不可见,答案一律落到 `client`(#144 那条
138
+ `workbuddy upstream client (http 400): 请求被拦截,请重新发送` 就是一个插件已经握有其码的信封);改为按
139
+ 规范形重建 body 再分类。`parseUpstreamErrorDetail()` 原先只认**顶层**且 `typeof === 'number'` 的 `code`,
140
+ 现与分类器共用 `businessCodeOfDocument`(补上 `{"error":{"code":…}}`,排在 `error.data.code` 之后以免
141
+ 细节盖掉判定)—— 不再出现「按码判了类、却不打印码」。
142
+ - **删掉一个账号后,该区域的持久化选择复活成死 id(#155 第二份报告)**
143
+ - **现象**:「删了一个账号就这样了」——CN 整块变「未登录」,提示「已保存的账号在本机已不存在」,而上方账号
144
+ 列表**一条都不渲染**;`usage?region=cn&detail=fast` 答 `status=signed-out selectionLost=true`,但同一份
145
+ 响应里明明还有 3 个 token 未过期的账号,国际版正常。报告者自己定位到 `cordis.patch.yml` 里
146
+ `accounts: cn: <本机已不存在的 id>`,并靠手发 `POST /__save {"field":"accounts","value":{"cn":""}}`
147
+ 恢复的 —— 本条就是把那个手动动作变成自动。
148
+ - **根因是「只清了一半」**:`removeAccount()` 确实清了内存里的 `this.accountId`,所以**本次会话内一切正常**、
149
+ 删除也回报成功;但设置文档里的死 id 一个字节都没动,下一次 `applySelection`(重启,或**任何一次设置保存**)
150
+ 把它读回来重新 pin,区域随即掉进死胡同:`current()` 找不到 → `resolve()` 抛 no signed-in account →
151
+ 走 `signed-out` 分支,而该状态下卡片的建议指向一个它自己并不渲染的列表。会话内看不出问题、故障延后到重启才
152
+ 现形,正是这两步之间的缝隙让报告者和「删账号」连不起来。**只存在于内存的忘记不是修复,是拖延。**
153
+ - **修法**:`removeOne` 之后调用 `clearRegionAccountSelection()`,**只在被删账号正是本区持久化 pin 时**
154
+ 写 `accounts[region] = ''`。判定与写入同在一个函数里,调用方无法只挑一半。四条约束都是这条修复的一部分:
155
+ - **写 `''`,绝不删键**:`''` 是「跟随 App 当前登录」的哨兵(也是全新安装的默认值,所以这是**恢复**而非
156
+ 发明状态);删掉的键含义是「从未配置」,会回落到旧版扁平 `accountId`,把用户**悄悄切到另一个账号**。
157
+ - **只清 pin 了该号的区域**:无关的删除必须零次写入 —— 内存里 `current()` 都拒绝回退到别的账号,
158
+ 删除不能绕过它、从设置文件里做同样的事(换账号等于换人计费)。
159
+ - **整字段合并**:`accounts` 是两个区域共用的一张平表,按调用方视角重建会抹掉兄弟区域的 pin;判定与写入
160
+ 都读 `describe()` 的权威文档,且先 `unwrapVolatileDeep` —— 把 volatile 的 `{get}` 活引用写进 payload,
161
+ settings 服务会整次拒绝(`found a function at $.ops[0].value.get`)。
162
+ - **同时覆盖旧版扁平 `accountId`**:pre-split 配置的 `accounts` 是空的、只靠扁平字段 pin,
163
+ `regionPinsAccount` 认这条;而哨兵会**终止**那次回落,所以一次写入同时 settle 两种写法。
164
+ - **fail-soft**:写设置失败只记 warn,绝不回报「删除失败」—— 凭证文件此刻已经真删了,说它还在是第二个谎。
165
+
166
+ ### Tests
167
+
168
+ - `tests/issue155-envelope-spelling.spec.ts` 新增 23 条(#155):紧凑/带空格/字符串码/嵌套四种写法 ×
169
+ 11128/14003/14018/11140 四族;`{"CODE":11128}` 归族(这一半是**恢复**不是保留:member 层先前对 body
170
+ 做了 `toLowerCase()` 因而认大写、`tests/issue98-channel-gate` 钉的是 member,而 family 层测的是原始 body、
171
+ 不认 —— 现在一个读取器同时回答两侧);十六进制 requestId 含 `11128` 不得被提上来;嵌套 `11128` 不得盖掉外层
172
+ `14003` 的自判;**分类器与展示给用户的那条码不许互相矛盾**。
173
+ - `tests/issue155-account-removal.spec.ts` 新增 17 条(#155):`regionPinsAccount` 四个方向(本区 pin 了=清 /
174
+ 兄弟区 pin 了别的=不清 / 已在跟随 App=不写 / 只靠扁平字段 pin=清,且扁平字段只归它被归属的那个区);
175
+ `nextAccountsSelection` 保兄弟区、只答 `accounts` 字段而非整个 section、`''` 作为数据保留(区别于缺键);
176
+ volatile 活引用不得漏进 payload;无服务/无命名空间时零写入。
177
+ - `tests/issue155-removal-e2e.spec.ts` 新增 5 条(#155 的**接线**):真实 `apply()` + 真实路由 + 真实 config
178
+ 对象(settings 双按引用答 `describe()`、`mutate` 原地提交并广播 `loader/volatile-update`),断言
179
+ 「删除 → 触发那次重读(等价于重启或任何一次设置保存的 `applySelection`)→ 区域仍 `signed-in` 且
180
+ `selectionLost` 消失」这条旧代码做不到的完整链条;另测兄弟区 pin 存活、无关删除零次写入、写失败时删除仍回报
181
+ 200。单独存在的理由与 `tests/pool-e2e.spec.ts` 相同:纯 helper 用例对着我自己写的 double,摘掉 `index.ts`
182
+ 里的接线它们全绿。
183
+ - 变异验证(每条都确认会让对应用例变红):分类器退回 `body.includes('"code":N')` → **11 条红**;删掉
184
+ 「本区是否 pin 了该号」的判定 → **4 条红**;写整个 section 而非 `accounts` 字段 → **4 条红**;
185
+ `removeOne` 退回只清内存 → **1 条红**(正是那条延后到重启才现形的链)。
186
+ - 全量:`tsc` 双配置 0 错误,**146 文件 / 2536 通过(2 条按平台跳过)**,`tsdown` 构建通过。
187
+
3
188
  ## 5.12.2 (2026-10-27)
4
189
 
5
190
  > 两处修复:**① 装完重启插件消失**(#151)——把宿主提供的 `@earendil-works/pi-ai` 标成 optional
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-BdTV4Aoi.js";
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-guET27pw.js";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { realpathSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
@@ -2430,14 +2430,14 @@ const HARD_CREDIT_MARKERS = [
2430
2430
  * this guards against. Marker tables rot in a way codes do not: the gateway can
2431
2431
  * reword its sentence without changing the contract it advertises, and the
2432
2432
  * failure is silent (the branch simply stops matching). A code cannot drift like
2433
- * that, and matching it in quoted-key form keeps a request id that happens to
2434
- * contain the digits from promoting an unrelated body.
2433
+ * that, and reading it (see {@link statesBusinessCode}) keeps a request id that
2434
+ * happens to contain the digits from promoting an unrelated body.
2435
2435
  *
2436
2436
  * ONLY a corroborating signal, never a substitute: the code is checked in
2437
2437
  * addition to the markers, so a body that names the code AND reads as a policy
2438
2438
  * refusal still resolves by the marker table's order.
2439
2439
  */
2440
- const CREDITS_EXHAUSTED_CODE = "\"code\":14018";
2440
+ const CREDITS_EXHAUSTED_CODE = 14018;
2441
2441
  /** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
2442
2442
  const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
2443
2443
  /**
@@ -2474,7 +2474,21 @@ function businessCodeOf(body) {
2474
2474
  return match?.[1] === void 0 ? void 0 : Number(match[1]);
2475
2475
  }
2476
2476
  if (typeof parsed !== "object" || parsed === null) return void 0;
2477
- const raw = parsed;
2477
+ return businessCodeOfDocument(parsed);
2478
+ }
2479
+ /**
2480
+ * The business code a PARSED failure document states, or `undefined`.
2481
+ *
2482
+ * ONE READER FOR TWO CALLERS (issue #155's second half). The classifier needs
2483
+ * the code to decide a kind; `parseUpstreamErrorDetail` needs it to print
2484
+ * `code 11128` in the reply the user pastes into an issue. The display half used
2485
+ * to read only a top-level `code` that was `typeof === 'number'`, so for two of
2486
+ * the spellings below the SAME body produced a diagnosis from the code while
2487
+ * showing no code at all — and `client`/`channel_reject` is exactly the pair a
2488
+ * reporter needs to be able to cross-check. Extracted so the two cannot drift
2489
+ * again; `businessCodeOf` keeps the unparseable-body fallback on top of it.
2490
+ */
2491
+ function businessCodeOfDocument(raw) {
2478
2492
  const direct = asBusinessCode(raw["code"]);
2479
2493
  if (direct !== void 0) return direct;
2480
2494
  const error = raw["error"];
@@ -2484,6 +2498,8 @@ function businessCodeOf(body) {
2484
2498
  const nested = asBusinessCode(data["code"]);
2485
2499
  if (nested !== void 0) return nested;
2486
2500
  }
2501
+ const direct = asBusinessCode(error["code"]);
2502
+ if (direct !== void 0) return direct;
2487
2503
  }
2488
2504
  const data = raw["data"];
2489
2505
  if (typeof data === "object" && data !== null) return asBusinessCode(data["code"]);
@@ -2493,21 +2509,97 @@ function asBusinessCode(value) {
2493
2509
  if (typeof value === "number" && Number.isInteger(value)) return value;
2494
2510
  if (typeof value === "string" && /^\d{4,6}$/u.test(value.trim())) return Number(value.trim());
2495
2511
  }
2512
+ /**
2513
+ * Whether a failure body states this business code, in ANY of its spellings.
2514
+ *
2515
+ * THE BUG THIS REMOVES (issue #155). Three of the four code families below were
2516
+ * recognised by `body.includes('"code":11128')` — a byte test on ONE
2517
+ * serialization. Issue #127 had already proven that test unreliable, but it
2518
+ * converted `11140` ONLY: measured on `6bdba2a`, `businessCodeOf` has exactly
2519
+ * one call site, despite that commit's message claiming `11128` as well. So the
2520
+ * SAME envelope answered one thing to the parser and another to the classifier.
2521
+ * Measured row by row against the shipped code:
2522
+ *
2523
+ * body classify detail.upstreamCode
2524
+ * {"code":11128} channel_reject 11128
2525
+ * {"code": 11128} client ← 漏 11128
2526
+ * {"code":"11128"} client ← 漏 undefined ← 也漏
2527
+ * {"error":{"code":11128}} channel_reject undefined ← 反向不一致
2528
+ * {"CODE":11128} client ← 漏 undefined
2529
+ *
2530
+ * TWO DIFFERENT mismatches. Row 2 is what the reporter pasted into issue #155:
2531
+ * `code 11128` in the text (`parseUpstreamErrorDetail` reads a top-level NUMBER,
2532
+ * which JSON.parse delivers even for a spaced body) beside `"type":"client"`
2533
+ * (decided by the substring) — a diagnosis disagreeing with its own evidence
2534
+ * inside one message. Rows 3–5 are worse for support and invisible to the user:
2535
+ * the readers disagree AND the code support asks for is missing entirely. Row 4
2536
+ * runs the OTHER way (the substring happens to hit the nested `"code":11128`
2537
+ * while the parser only knew `error.data.code`), which is why this could not be
2538
+ * fixed by "just use the parser" — both readers must consult ONE fact.
2539
+ * That is not cosmetic — the channel rescue ladder runs ONLY for
2540
+ * `channel_reject` (`src/shim.ts` gates on `result.kind === 'channel_reject'`),
2541
+ * so any other kind skips it entirely: `forceNeutralHead` and both perturbation
2542
+ * rungs never fire, and the user is told to "adjust the request" for a refusal
2543
+ * the plugin never tried to rescue. Issue #113's whole fix depends on whether
2544
+ * the upstream happened to emit a space.
2545
+ *
2546
+ * THREE READS, IN THIS ORDER, and the ordering is the safety property:
2547
+ *
2548
+ * 1. A code the envelope STATES is final. `businessCodeOf` found one ⇒ that is
2549
+ * the answer, and the text scan below is never consulted. Without this rule,
2550
+ * OR-ing the two reads would let a nested sub-object's `"code":11128`
2551
+ * (inside `extError`, say) reclassify an envelope whose own verdict is
2552
+ * `14003` — promoting a detail over the verdict, the reverse of the precision
2553
+ * issue #127 bought.
2554
+ * 2. A body that PARSES but states no code in the three shapes `businessCodeOf`
2555
+ * knows still gets the quoted-key text scan. That is where this fix strictly
2556
+ * widens the old behaviour: an undocumented nesting (`{"error":{"code":…}}`)
2557
+ * used to be recognised by the substring, and dropping it here would be a
2558
+ * regression dressed up as a refactor.
2559
+ * 3. The scan anchors on `"code":<n>`, never the bare digits, so a hexadecimal
2560
+ * request id containing `11128` cannot promote an unrelated body — the hazard
2561
+ * the original quoted-key form existed to avoid, kept intact. The scan is
2562
+ * applied case-insensitively. This half RESTORES rather than preserves a
2563
+ * behaviour, and the two halves of the family disagreed about it: the member
2564
+ * predicates lower-cased the body before testing it (so `{"CODE":11128,…}`
2565
+ * matched, pinned by `tests/issue98-channel-gate`), while the family
2566
+ * predicate tested the RAW body and did not. One reader now answers both.
2567
+ *
2568
+ * The text form is derived from the number rather than declared beside it: two
2569
+ * constants for one code are two truths that drift, and a stale text form would
2570
+ * silently reinstate the bug above for exactly the spelling this function exists
2571
+ * to catch.
2572
+ */
2573
+ function statesBusinessCode(body, code) {
2574
+ const stated = businessCodeOf(body);
2575
+ if (stated !== void 0) return stated === code;
2576
+ return body.toLowerCase().includes(`"code":${code}`);
2577
+ }
2496
2578
  /** The `11140` content-policy family, whatever the envelope's JSON spelling is. */
2497
2579
  const CONTENT_POLICY_CODE = 11140;
2498
2580
  /**
2499
- * Content-policy refusal markers from the chat gateway. The quoted-key form
2500
- * avoids substring hits inside unrelated values (request ids are hex and can
2501
- * contain `11140` by coincidence).
2581
+ * Content-policy refusal markers from the chat gateway. The code is READ rather
2582
+ * than text-matched so a request id cannot contain `11140` by coincidence and
2583
+ * promote an unrelated body — and, since issue #155, so the ONE read is shared
2584
+ * with every other family here instead of being a second, subtly different rule.
2502
2585
  *
2503
2586
  * `request illegal` is deliberately absent: the live `11128` channel-gate body
2504
2587
  * uses the SAME `msg`, so this marker used to run first and classify that gate
2505
2588
  * as `policy_reject`. The consequence was severe — the channel rescue ladder
2506
2589
  * never ran, the reply changed 400 to 403, and users were told to switch
2507
2590
  * accounts for a request-side refusal (issue #127).
2591
+ *
2592
+ * THE NESTING GAP #127 LEFT BEHIND, and why this now reads through
2593
+ * {@link statesBusinessCode}. Issue #127 replaced the text match with the parser
2594
+ * alone, which is exact for the three shapes that parser knows (`code`,
2595
+ * `error.data.code`, `data.code`) — and therefore silently NARROWED coverage for
2596
+ * a body whose code sits anywhere else, such as `{"error":{"code":11140}}`, which
2597
+ * the old text match did catch. The helper keeps both: a code the envelope states
2598
+ * wins outright (the precision #127 was after), and the text scan runs only when
2599
+ * nothing was stated, where it can only restore a match the parser could not see.
2508
2600
  */
2509
2601
  function isContentPolicyBody(body) {
2510
- return businessCodeOf(body) === CONTENT_POLICY_CODE;
2602
+ return statesBusinessCode(body, CONTENT_POLICY_CODE);
2511
2603
  }
2512
2604
  /**
2513
2605
  * Whether a failure body is the account-restriction member of the `11140`
@@ -2607,25 +2699,32 @@ function isUndecodablePolicyBody(body) {
2607
2699
  /**
2608
2700
  * Business code of the MODEL-scoped throttle (`14003`, "too many requests").
2609
2701
  *
2610
- * The quoted-key form for the same reason as the policy markers: a request id is
2611
- * hexadecimal and can contain the digits `14003` by coincidence, and a bare
2612
- * substring search over an arbitrary failure body is how a red herring gets
2613
- * promoted into a routing decision.
2702
+ * A number rather than the quoted-key text form: the text match was exact for
2703
+ * one serialization only, so a spaced or string-quoted envelope lost the
2704
+ * `model_busy` kind and fell through to whatever its HTTP status says — measured
2705
+ * at HEAD, `429 {"code": 14003}` became `soft_rate` and `400` became `client`.
2706
+ * That is not a cosmetic rename: `model_busy` is one of the three NON-failover
2707
+ * kinds (`isFailoverWorthy` in `src/shim.ts`), because the throttle is a fact
2708
+ * about the MODEL and the next credential joins the same one — so `soft_rate`
2709
+ * walks the whole pool burning a real request per account to learn nothing,
2710
+ * while `client` prints neither the kind's advice nor its code (issue #155). See
2711
+ * {@link statesBusinessCode}.
2614
2712
  */
2615
- const MODEL_BUSY_CODE = "\"code\":14003";
2713
+ const MODEL_BUSY_CODE = 14003;
2616
2714
  /**
2617
2715
  * Business code of the gateway's CHANNEL gate (`11128`, "Illegal API invocation
2618
2716
  * from an unapproved channel").
2619
2717
  *
2620
- * The quoted-key form for the same reason as the markers above: a request id is
2621
- * hexadecimal and can contain the digits `11128` by coincidence.
2718
+ * A NUMBER, not the quoted-key text form it used to be. Matching the text was
2719
+ * exact only for one serialization, which is the whole of issue #155 — see
2720
+ * {@link statesBusinessCode}.
2622
2721
  *
2623
2722
  * A DIFFERENT gate from the one {@link conversationHeaders} works around, which
2624
2723
  * also answers `11128` (`conversation id missing`). The code is the same
2625
2724
  * because the gateway reuses it for every rule in that family; the BODY is what
2626
2725
  * tells them apart, so {@link isChannelRejectBody} requires both halves.
2627
2726
  */
2628
- const CHANNEL_REJECT_CODE = "\"code\":11128";
2727
+ const CHANNEL_REJECT_CODE = 11128;
2629
2728
  /**
2630
2729
  * Business code for "the request exceeded the model's context window" (issue #101).
2631
2730
  *
@@ -2723,24 +2822,23 @@ const FIRST_MESSAGE_GATE_MARKER = "first message is not system prompt";
2723
2822
  * SAME second. A 6004, by contrast, carries a `将在 … UTC+8 重置` sentence and
2724
2823
  * is per-account.
2725
2824
  *
2726
- * So the criterion is the CODE alone, matched in its quoted-key form so a
2727
- * request id that happens to contain the digits cannot promote it. Exported so
2728
- * the probe and the chat path cannot drift into two different answers about one
2729
- * body.
2825
+ * So the criterion is the CODE alone, read by {@link statesBusinessCode} so a
2826
+ * request id that happens to contain the digits cannot promote an unrelated
2827
+ * body. Exported so the probe and the chat path cannot drift into two different
2828
+ * answers about one body.
2730
2829
  */
2731
2830
  function isModelBusyBody(body) {
2732
- return body.includes(MODEL_BUSY_CODE);
2831
+ return statesBusinessCode(body, MODEL_BUSY_CODE);
2733
2832
  }
2734
2833
  /**
2735
2834
  * Whether a failure body belongs to the `11128` gate family AT ALL.
2736
2835
  *
2737
- * The code is matched in its quoted-key form (`"code":11128`): a request id is
2738
- * hexadecimal and can contain the digits `11128` by coincidence, and a bare
2739
- * substring search over an arbitrary failure body is how a red herring gets
2740
- * promoted into a diagnosis.
2836
+ * The code is READ, not text-matched (issue #155): a spaced or string-quoted
2837
+ * envelope used to fall out of the family here, and out of the rescue ladder
2838
+ * with it. {@link statesBusinessCode} keeps both reads.
2741
2839
  */
2742
2840
  function isChannelGateFamilyBody(body) {
2743
- return body.includes(CHANNEL_REJECT_CODE);
2841
+ return statesBusinessCode(body, CHANNEL_REJECT_CODE);
2744
2842
  }
2745
2843
  /**
2746
2844
  * Whether a failure body is the gateway's CHANNEL gate — i.e. whether it
@@ -2769,10 +2867,12 @@ function isChannelGateFamilyBody(body) {
2769
2867
  * ADVISES, and that is {@link channelGateMemberOf}'s job, decided by the
2770
2868
  * plugin's own evidence rather than by a wording the upstream may misreport.
2771
2869
  *
2772
- * The code is matched in its quoted-key form (`"code":11128`): a request id is
2773
- * hexadecimal and can contain the digits `11128` by coincidence, and a bare
2774
- * substring search over an arbitrary failure body is how a red herring gets
2775
- * promoted into a diagnosis.
2870
+ * The code is read by {@link statesBusinessCode}, exactly as the family test
2871
+ * above it does — this function adds nothing to that decision and must not
2872
+ * appear to own a rule of its own. The reason the code is READ rather than
2873
+ * text-matched is unchanged (issue #155): a request id is hexadecimal and can
2874
+ * contain the digits `11128` by coincidence, and a bare substring search over an
2875
+ * arbitrary failure body is how a red herring gets promoted into a diagnosis.
2776
2876
  */
2777
2877
  function isChannelRejectBody(body) {
2778
2878
  return isChannelGateFamilyBody(body);
@@ -2791,14 +2891,12 @@ function isChannelRejectBody(body) {
2791
2891
  * `msg` can. Hence the wording is the criterion and the code is the
2792
2892
  * corroboration, never the other way round.
2793
2893
  *
2794
- * BOTH halves are required, and the code is matched in its quoted-key form
2795
- * (`"code":11128`): a request id is hexadecimal and can contain the digits
2796
- * `11128` by coincidence, and a bare substring search over an arbitrary
2797
- * failure body is how a red herring gets promoted into a diagnosis.
2894
+ * BOTH halves are required, and the code is read by {@link statesBusinessCode}
2895
+ * rather than text-matched, so a spaced envelope names its member too (issue
2896
+ * #155). The wording half stays case-insensitive: it is prose.
2798
2897
  */
2799
2898
  function isConversationIdGateBody(body) {
2800
- const lower = body.toLowerCase();
2801
- return lower.includes(CHANNEL_REJECT_CODE) && lower.includes(CONVERSATION_ID_GATE_MARKER);
2899
+ return statesBusinessCode(body, CHANNEL_REJECT_CODE) && body.toLowerCase().includes(CONVERSATION_ID_GATE_MARKER);
2802
2900
  }
2803
2901
  /**
2804
2902
  * Whether a failure body is the FIRST-MESSAGE gate (`11128`).
@@ -2811,11 +2909,11 @@ function isConversationIdGateBody(body) {
2811
2909
  *
2812
2910
  * Excluded from the prompt diagnosis for the same reason as
2813
2911
  * {@link isConversationIdGateBody}, and named by the same rule: the CODE plus
2814
- * the WORDING, case-insensitively, code in its quoted-key form.
2912
+ * the WORDING, the code read rather than text-matched (issue #155), the wording
2913
+ * case-insensitively.
2815
2914
  */
2816
2915
  function isFirstMessageGateBody(body) {
2817
- const lower = body.toLowerCase();
2818
- return lower.includes(CHANNEL_REJECT_CODE) && lower.includes(FIRST_MESSAGE_GATE_MARKER);
2916
+ return statesBusinessCode(body, CHANNEL_REJECT_CODE) && body.toLowerCase().includes(FIRST_MESSAGE_GATE_MARKER);
2819
2917
  }
2820
2918
  /**
2821
2919
  * Which member of the `11128` family a body is, decided by the plugin's OWN
@@ -2885,7 +2983,7 @@ function classifyUpstreamError(status, body) {
2885
2983
  if (status === 402) return "hard_credit";
2886
2984
  const lower = body.toLowerCase();
2887
2985
  for (const marker of HARD_CREDIT_MARKERS) if (lower.includes(marker.toLowerCase()) || body.includes(marker)) return "hard_credit";
2888
- if (body.includes(CREDITS_EXHAUSTED_CODE)) return "hard_credit";
2986
+ if (statesBusinessCode(body, CREDITS_EXHAUSTED_CODE)) return "hard_credit";
2889
2987
  for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
2890
2988
  if (isAccountRestrictedBody(body)) return "account_restricted";
2891
2989
  if (isContentPolicyBody(body)) return "policy_reject";
@@ -2931,7 +3029,8 @@ function parseUpstreamErrorDetail(body) {
2931
3029
  const displayMsg = localizedText(raw.displayMsg) ?? (typeof raw.msg === "string" ? raw.msg : void 0);
2932
3030
  const tips = localizedText(raw.displayTips);
2933
3031
  const detail = {};
2934
- if (typeof raw.code === "number") detail.upstreamCode = raw.code;
3032
+ const statedCode = businessCodeOfDocument(raw);
3033
+ if (statedCode !== void 0) detail.upstreamCode = statedCode;
2935
3034
  if (typeof raw.requestId === "string") detail.requestId = raw.requestId;
2936
3035
  if (typeof raw.msg === "string" && raw.msg !== "") detail.rawMsg = raw.msg;
2937
3036
  if (displayMsg !== void 0) detail.displayMsg = displayMsg;
@@ -3589,6 +3688,45 @@ const ZERO_WIDTH_SPACE = "​";
3589
3688
  * rather than rewriting the text.
3590
3689
  */
3591
3690
  const WORD_INTERNAL_BREAK = /([A-Z][A-Za-z0-9]*)(\s+)(?=[A-Z][A-Za-z0-9])/gu;
3691
+ /** A rune the dense rescue treats as a word character. */
3692
+ const DENSE_WORD_RUNE = /[A-Za-z0-9぀-ヿ㐀-鿿]/u;
3693
+ /**
3694
+ * Space U+200B through each run of non-space runes, when that run starts with a
3695
+ * word rune.
3696
+ *
3697
+ * THE ONE-PASS FORM OF THE DENSE RULE (issue #159). The rule only ever consults
3698
+ * two local facts — the first rune of the CURRENT run of non-space runes, and
3699
+ * whether the rune immediately before this position is already a break — so it
3700
+ * needs one accumulator and no prefix slicing, which is what makes it linear
3701
+ * rather than quadratic. See the body for why the reference is the run's FIRST
3702
+ * rune rather than the previous one, and for the exact old expression it
3703
+ * reproduces.
3704
+ *
3705
+ * It is kept as a standalone function so its output can be compared against a
3706
+ * literal transcription of the old `replace` in the test suite: the rewrite is
3707
+ * a performance change only, and a byte-difference would mean the rescue sends
3708
+ * a body nobody measured.
3709
+ */
3710
+ function spaceDenseBreaks(text) {
3711
+ const runes = Array.from(text);
3712
+ let out = "";
3713
+ /** First rune of the current run of non-space runes; `undefined` outside one. */
3714
+ let runStart;
3715
+ for (let index = 0; index < runes.length; index += 1) {
3716
+ const rune = runes[index];
3717
+ if (/^\s$/u.test(rune)) {
3718
+ runStart = void 0;
3719
+ out += rune;
3720
+ continue;
3721
+ }
3722
+ runStart ??= rune;
3723
+ const inRun = index > 0 && !/^\s$/u.test(runes[index - 1]);
3724
+ const before = runes[index - 1];
3725
+ if (inRun && runStart !== ZERO_WIDTH_SPACE && DENSE_WORD_RUNE.test(runStart) && rune !== ZERO_WIDTH_SPACE && before !== ZERO_WIDTH_SPACE) out += ZERO_WIDTH_SPACE;
3726
+ out += rune;
3727
+ }
3728
+ return out;
3729
+ }
3592
3730
  /**
3593
3731
  * A body whose scanned message TEXT is perturbed just enough to break an exact
3594
3732
  * content fingerprint.
@@ -3651,11 +3789,7 @@ function perturbChannelFingerprints(bodyJson, dense = false) {
3651
3789
  const perturb = (text) => {
3652
3790
  const boundaries = text.replace(/([.!?])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(/([。!?])(?!\u200b)/gu, `$1${ZERO_WIDTH_SPACE}`).replace(/([,;])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(WORD_INTERNAL_BREAK, (whole, head, tail) => `${head}${ZERO_WIDTH_SPACE}${tail}`);
3653
3791
  if (!dense) return boundaries === text ? void 0 : boundaries;
3654
- const denseText = boundaries.replace(/(?=\S)/gu, (whole, offset, source) => {
3655
- if (source.slice(Math.max(0, offset - 1), offset + 1).includes(ZERO_WIDTH_SPACE)) return "";
3656
- const previous = source.slice(0, offset).match(/\S(?=\S*$)/u)?.[0];
3657
- return previous !== void 0 && /[A-Za-z0-9぀-ヿ㐀-鿿]/u.test(previous) ? ZERO_WIDTH_SPACE : "";
3658
- });
3792
+ const denseText = spaceDenseBreaks(boundaries);
3659
3793
  return denseText === text ? void 0 : denseText;
3660
3794
  };
3661
3795
  for (const value of messages) {
@@ -4321,10 +4455,21 @@ function parseEnterpriseInstant(value) {
4321
4455
  * A JSON body on a 401/403 is still a credential refusal: the edge answered in
4322
4456
  * the business shape, but the token is just as unusable, so it maps to the same
4323
4457
  * actionable error instead of a generic "client" failure.
4458
+ *
4459
+ * THE CLASSIFIER IS HANDED THE CODE, NOT JUST THE SENTENCE (issue #155's sibling).
4460
+ * `readEnvelope` has already parsed `code` as a number, and this call used to
4461
+ * rebuild its diagnosis from `msg` alone — a sentence that never contains
4462
+ * `"code":…`. Every family that decides on its business code was therefore
4463
+ * invisible here, and the answer fell through to `client`: the report filed as
4464
+ * `workbuddy upstream client (http 400): 请求被拦截,请重新发送` (#144) is one
4465
+ * envelope whose code the Host was already holding. Re-stating the envelope in
4466
+ * its canonical form is what lets the shared classifier see both halves it is
4467
+ * written to read: the code, and the same `msg` wording it saw before. Strictly
4468
+ * more information than the text-only call, never less.
4324
4469
  */
4325
4470
  function envelopeError(status, envelope) {
4326
4471
  if (status === 401 || status === 403) return new WorkBuddyCredentialRejectedError(status);
4327
- const kind = classifyUpstreamError(status, envelope.msg);
4472
+ const kind = classifyUpstreamError(status, `{"code":${envelope.code},"msg":${JSON.stringify(envelope.msg)}}`);
4328
4473
  return /* @__PURE__ */ new Error(`workbuddy upstream ${kind} (http ${status}): ${envelope.msg.slice(0, 160)}`);
4329
4474
  }
4330
4475
  /**
@@ -7566,7 +7711,7 @@ function describeRosterSeenPayload(raw) {
7566
7711
  //#endregion
7567
7712
  //#region src/version.ts
7568
7713
  /** The npm package version this build was produced from. */
7569
- const WORKBUDDY_CONNECT_VERSION = "5.12.2";
7714
+ const WORKBUDDY_CONNECT_VERSION = "5.12.4";
7570
7715
  //#endregion
7571
7716
  //#region src/host-heartbeat.ts
7572
7717
  /**
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-BdTV4Aoi.js";
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-guET27pw.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";
@@ -715,6 +715,82 @@ function nextRegionEnabled(value, region, enabled) {
715
715
  });
716
716
  }
717
717
  /**
718
+ * The per-region account SELECTIONS map (`Config.accounts`), as plain data.
719
+ *
720
+ * `value` may be the whole settings SECTION or the `accounts` FIELD: `describe()`
721
+ * hands back the section with volatile fields as live references, and a blind
722
+ * spread of that leaks the `get()` function into a write payload — the settings
723
+ * service then refuses the whole save with `found a function at $.ops[0].value.get`
724
+ * (measured at the card's save endpoint, `src/web-status.ts`). Unwrapping first is
725
+ * what makes the merge carry data and not behaviour.
726
+ *
727
+ * The map is recognised by its KEYS, not by its shape: only region ids can appear
728
+ * in it, while a section always carries at least one non-region key (`authFile`,
729
+ * `regions`, `cliBridge`). Same direction as {@link regionsMapOf} chose for the
730
+ * reason documented there — a nested copy is a DIFFERENT, older value, so the
731
+ * outer object's own keys decide what it is.
732
+ */
733
+ function accountsMapOf(value) {
734
+ const unwrapped = unwrapVolatileDeep(value);
735
+ if (!isRecord$1(unwrapped)) return {};
736
+ if (hasRegionKey(unwrapped)) return unwrapped;
737
+ const nested = unwrapVolatileDeep(unwrapped["accounts"]);
738
+ return isRecord$1(nested) ? nested : {};
739
+ }
740
+ /**
741
+ * Build the next `accounts` FIELD for one region's selection.
742
+ *
743
+ * ONLY the target region's key changes; the sibling region is carried over
744
+ * verbatim. `accounts` is a flat two-key map that BOTH regions write, so a
745
+ * whole-field write that rebuilt it from one caller's view would drop the other
746
+ * region's pin — the failure mode every other `next*` helper in this file exists
747
+ * to prevent, in a field that is easier to clobber because it looks trivial.
748
+ *
749
+ * The value is written as given, including `''`. That distinction is load-bearing
750
+ * and not a detail: `''` is the Clear sentinel ("this region follows the app's
751
+ * current sign-in"), while an ABSENT key means "never configured" and still falls
752
+ * back to the legacy flat `accountId` — see `selectAccountFor`. A helper that
753
+ * deleted the key to "clear" a region would silently re-bind it to whichever
754
+ * account the legacy field names.
755
+ */
756
+ function nextAccountsSelection(value, region, accountId) {
757
+ return {
758
+ ...accountsMapOf(value),
759
+ [region]: accountId
760
+ };
761
+ }
762
+ /**
763
+ * Whether one region's selection is PERSISTENTLY bound to this account id.
764
+ *
765
+ * THE QUESTION {@link nextAccountsSelection} MUST ANSWER BEFORE WRITING. Two
766
+ * persisted sources can pin an account, and clearing only the obvious one leaves
767
+ * the other in place:
768
+ *
769
+ * 1. `accounts[region]` — the per-region slot, the current spelling.
770
+ * 2. the deprecated flat `accountId`, which `selectAccountFor` falls back to for
771
+ * the ONE region the startup scan attributed it to (`legacyAccountRegion`) and
772
+ * only when that region's slot is ABSENT. A pre-split profile therefore pins
773
+ * its account through field 2 while field 1 holds nothing at all — and that
774
+ * is precisely a profile whose user can remove an account and inherit an
775
+ * orphaned pin.
776
+ *
777
+ * `accounts[region] === ''` answers `false` even when the id matches nothing: the
778
+ * sentinel means "this region follows the app's current sign-in", which already
779
+ * cannot point at a dead account, so there is nothing to clear and a write would
780
+ * be noise (and a needless settings round trip).
781
+ *
782
+ * @param value the resolved settings section, or the `accounts` field.
783
+ * @param region the region whose pin is being questioned.
784
+ * @param accountId the account about to be removed.
785
+ * @param legacyAccountId the deprecated flat `accountId`, read as persisted.
786
+ * @param legacyRegion which region the startup scan attributed that flat id to.
787
+ */
788
+ function regionPinsAccount(value, region, accountId, legacyAccountId, legacyRegion) {
789
+ const configured = accountsMapOf(value)[region];
790
+ if (configured !== void 0) return configured === accountId;
791
+ return legacyAccountId !== void 0 && legacyAccountId === accountId && legacyRegion === region;
792
+ }
793
+ /**
718
794
  * Project one card row into its persisted `lastCatalog` shape: the native
719
795
  * context window becomes the stored `contextWindow`, and the card-only
720
796
  * presentation fields (`nativeContextWindow`, `multimodal`) are removed BY
@@ -897,6 +973,64 @@ async function healRegionsField(service, ns) {
897
973
  }], void 0);
898
974
  return true;
899
975
  }
976
+ /**
977
+ * Drop one region's PERSISTED account selection, and only if it pinned this
978
+ * account.
979
+ *
980
+ * THE HOST-SIDE HALF OF ISSUE #155'S SECOND REPORT. A user removed one account
981
+ * and the whole CN tab turned into「已保存的账号在本机已不存在」, with no way back:
982
+ * the card's advice points at a list it does not render in that state, so the
983
+ * reporter escaped by hand-posting `{"field":"accounts","value":{"cn":""}}` to
984
+ * the save endpoint. This is that write, made automatic.
985
+ *
986
+ * WHY A WRITE AT ALL — the store already forgets. `removeAccount` clears the
987
+ * in-memory selection, which is why the region keeps working for the rest of the
988
+ * session. But the settings document still names the dead id, so the next
989
+ * `applySelection` — a restart, or any settings save — re-reads it, re-pins it,
990
+ * and the region falls back into the same dead end. An in-memory forget that is
991
+ * never persisted is not a fix; it is a delay.
992
+ *
993
+ * THE DECISION LIVES HERE, WITH THE WRITE, because the two must never be
994
+ * separated by a caller's guess. Clearing a region that did NOT pin this account
995
+ * would move the user off the account they chose, and「换账号等于换人计费」is the
996
+ * rule every writer in this plugin obeys: this function writes for exactly the
997
+ * region whose persisted pin names the account being removed, and no others.
998
+ *
999
+ * WRITES `''`, NEVER A DELETED KEY. The sentinel means "follow the app's current
1000
+ * sign-in" — the plugin's documented default, which is what a fresh install does,
1001
+ * so this restores a state rather than inventing one. An absent key means "never
1002
+ * configured" and falls through to the legacy flat `accountId` (`selectAccountFor`),
1003
+ * which would resurrect whichever account that field names: a silent switch
1004
+ * through another door. The sentinel also TERMINATES that lookup, which is why one
1005
+ * write settles both spellings — a pre-split profile pinned through the flat
1006
+ * field is cleared by this same write.
1007
+ *
1008
+ * FAIL-SOFT BY CONTRACT: a service that cannot answer, or a region that pinned
1009
+ * nothing, costs no write. The caller logs a failed mutation rather than failing
1010
+ * the removal, because the credential file is already gone by then and reporting
1011
+ * "could not remove" would be the lie.
1012
+ *
1013
+ * @param service The Host's settings service, or `undefined` to no-op.
1014
+ * @param ns The namespace this plugin's settings live under.
1015
+ * @param region The region being checked; the sibling is preserved by the merge.
1016
+ * @param accountId The account about to be removed.
1017
+ * @param legacyAccountId The deprecated flat `accountId`, as persisted.
1018
+ * @param legacyRegion Which region the startup scan attributed that field to.
1019
+ * @returns whether a write happened.
1020
+ */
1021
+ async function clearRegionAccountSelection(service, ns, region, accountId, legacyAccountId, legacyRegion) {
1022
+ if (service === void 0) return false;
1023
+ const row = service.describe().find((entry) => entry.ns === ns);
1024
+ if (row === void 0) return false;
1025
+ const section = row.value ?? {};
1026
+ if (!regionPinsAccount(section, region, accountId, legacyAccountId, legacyRegion)) return false;
1027
+ await service.mutate(ns, [{
1028
+ op: "set",
1029
+ path: ["accounts"],
1030
+ value: nextAccountsSelection(section, region, "")
1031
+ }], void 0);
1032
+ return true;
1033
+ }
900
1034
  //#endregion
901
1035
  //#region src/credential-recovery.ts
902
1036
  /** How long a verdict stays fresh by default. */
@@ -12017,8 +12151,31 @@ function apply(ctx, config) {
12017
12151
  * Forget ONE account's plugin-stored credential (the vault entry; the
12018
12152
  * desktop app's files are never touched). The card rescans afterwards,
12019
12153
  * so the row disappears the moment the write lands.
12154
+ *
12155
+ * AND DROP THE REGION'S PERSISTED PIN, when this was the pinned account
12156
+ * (issue #155's second report). `removeAccount` forgets the selection in
12157
+ * memory only, so the next `applySelection` — a restart, or any settings
12158
+ * save — re-reads the dead id out of `accounts.<region>`, re-pins it, and
12159
+ * the tab returns to「已保存的账号在本机已不存在」with the card's advice
12160
+ * pointing at a list that state does not render. The clear is decided from
12161
+ * the settings document rather than from the store, and is scoped to the
12162
+ * one region that pinned this account: an unrelated removal must not move
12163
+ * anybody off the account they chose.
12164
+ *
12165
+ * FAIL-SOFT, like the field repair above: the credential is already gone by
12166
+ * the time this runs, so a settings write that cannot land must not be
12167
+ * reported as a failed removal — that would tell the user the account is
12168
+ * still there when it is not.
12020
12169
  */
12021
- removeOne: async (region, accountId) => stacks[region].store.removeAccount(accountId)
12170
+ removeOne: async (region, accountId) => {
12171
+ const removed = await stacks[region].store.removeAccount(accountId);
12172
+ try {
12173
+ await clearRegionAccountSelection(hostSettings(), settingsNs, region, accountId, unwrapVolatile(current().accountId), legacyAccountRegion);
12174
+ } catch (error) {
12175
+ ctx.logger.warn("dsh-connect-workbuddy: the account was removed, but the region could not be un-pinned from settings; the selection may reappear as orphaned after a restart", error);
12176
+ }
12177
+ return removed;
12178
+ }
12022
12179
  },
12023
12180
  /**
12024
12181
  * OAuth QR sign-in for this card. The session lives in `src/oauth.ts`
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.12.2",
5
+ "version": "5.12.4",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",