dsh-connect-workbuddy 5.6.2 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.7.0 (2026-10-23)
4
+
5
+ > 特性版本:加强 WorkBuddy 渠道校验(`11128`)的三级自愈救援,并改善失败证据回显。**凭据、计费、provider 注册与普通请求路径均未改动**;默认策略仍只在渠道校验拒绝后启用,可选 `channelPerturbation: "always"` 让已确认普通请求必失败的环境跳过无效首轮。
6
+
7
+ ### Features
8
+
9
+ - **渠道校验新增密集零宽救援与可配置扰动策略。**
10
+ - 渠道门现在按「强制中性头 → 稀疏零宽扰动 → 密集零宽扰动」逐级重试;密集模式在被扫描的 `system` / `assistant` 文本字符边界插入 U+200B,能覆盖没有标点的纯 prose 指纹。
11
+ - 新增全局 `channelPerturbation` 配置:`off` 只保留失败后救援,`failure`(默认)从成功救援中学习形状,`always` 每个请求都从密集救援形状起步。CN 与 global 两个 provider 均由同一配置驱动。
12
+ - 普通请求仍然逐字节保持原样(除已有的已知指纹归一化);任何渠道救援都使用同一账号,不触发账号池换号,也不记为账号失败。
13
+
14
+ ### Fixes
15
+
16
+ - **渠道失败证据不再只显示被扫描消息的首行。**
17
+ - 证据现在显示首行、尾行(若文本有多行)与总字数,帮助区分「已知开头命中」与「assistant 历史中段存在精确子串」;这避免把插件已中性化的开头误报为网关新增词条。
18
+ - `OpeningEvidence` 增加 `textLength` 与可选 `closing`,失败回复仍保持原有的 `workbuddy 上游失败:…(http N)` 报告形状。
19
+
20
+ ### Docs
21
+
22
+ - 新增 [`docs/CHANNEL-FINGERPRINT-RESEARCH.md`](docs/CHANNEL-FINGERPRINT-RESEARCH.md),整理主流网关、中转站与适配层对客户端指纹的处理方式,并记录本次实现取舍。
23
+ - 更新 [`docs/DESIGN.md`](docs/DESIGN.md),补充三级渠道救援、扰动策略与证据回显设计。
24
+
25
+ ### Tests
26
+
27
+ - 新增密集扰动、`always` / `off` 策略、证据回显字段与既有渠道门回归测试;渠道相关测试 80 条全部通过,完整套件 2137 条通过、2 条按平台跳过。
28
+
29
+ ## 5.6.3 (2026-10-23)
30
+
31
+ > 补丁版本:修「免费模型测出来可用,却显示 8 天后才能用」。**请求路径、provider 路由、计费与凭据一律未变。**
32
+
33
+ ### Fixes
34
+
35
+ - **成功的探测被当成「要等到下个月」,免费模型显示「约 8 天后」**
36
+ - **症状**(用户报告 + 真机记录):倍率 `x0.00` 的免费模型,测试结果是 `✓ 可用`,同一行却写着 `11/01, 00:00 之后可再用 · 约 8 天后`,读起来就是「这个免费模型要等 8 天」。
37
+ - **根因**:`cooldownOf()` 把**任何**存在的 `Retry-After` 头都当成「你在此之前不能用」,不区分响应是 200 还是 429。而这个网关在**成功的 200** 上也会带 `Retry-After`,其值是**账号月度积分包的刷新时刻**(本机 `.workbuddy-pool.global.json` 里三条记录逐字为 `outcome:"ok"` + `retryAtMs:2026-10-31T16:00:00Z` + `message:"credit 0"`)。于是「额度什么时候补」被渲染成了「模型什么时候解禁」。
38
+ - **修法**:把「成功没有冷却」这条规则**放在 `cooldownOf()` 里**(`outcome === 'ok'` 直接返回 `{}`),而不是只在调用点去掉。理由是这类头在成功响应上确实存在,任何未来的调用方都可能再把头透传进来;规则只有一处,就无法被绕过。`probeModel()` 的成功分支同步不再请求冷却,注释写明为什么。
39
+ - **旧记录一起治**:已经落盘的 `ok` + `retryAtMs` 记录,由 `retryAtMsOf()` 在**读取时**丢弃那个时间(这是所有消费者的唯一入口:卡片、输入框面板、换号路由),下一次 `writePoolProbes` 把修好的形状写回文件。`mergePoolProbe()` 同步拒绝把旧记录的 `retryAtMs` 继承到新的 `ok` 测量上——否则每次重测都会把那个八天重新捡回来。**测量本身不丢**:`modelId`、`atMs`、`source` 都保留,只是不再附一个不存在的等待。
40
+ - **文案层也设了一道闸**:`availabilityLine()` / `availabilityMark()`(卡片与输入框面板共用的那一条规则)遇到 `outcome: 'ok'` 一律只输出判定,不再拼「之后可再用」。这样即使将来又有写入方带进一个时间,用户看到的仍然只是 `✓ 可用`。
41
+ - **没有动的地方**:真实限流(`rate-limited`)、模型繁忙、积分耗尽三条路径的恢复时间**完全不变**——它们的时间是上游明确说的「什么时候能再用」,是有用信息。本次只摘掉「成功」上的时间。
42
+ - **测试**:`tests/probe.spec.ts` 增 2 条(`cooldownOf` 对 `ok` 忽略 `Retry-After` 与 quota-refresh;`probeModel` 端到端在 200 带 `Retry-After` 时不产出 `retryAtMs`);`tests/account-pool-store.spec.ts` 增 3 条(旧 `ok`+时间被读时治愈、新 `ok` 不继承旧时间、仍限流的账号照旧继承);`tests/account-state.spec.ts` 增 3 条(卡片与面板都只印判定、限流行仍印时间)。
43
+
3
44
  ## 5.6.2 (2026-10-23)
4
45
 
5
46
  > 补丁版本:修 5.6.0/5.6.1 里「折后价读不出来」的原因。**请求路径、provider 路由、计费与凭据一律未变。**
package/README.en.md CHANGED
@@ -39,7 +39,7 @@ It depends on the WorkBuddy client's own endpoints (not a public API), so the pl
39
39
  - **Model load on model rows (CN only)** — the upstream answers "how busy is this model right now" from a separate read-only probe (`POST /v2/chat/busy`, up to 50 ids per call, free, no account state touched). The plugin asks it once per catalog refresh and draws the level as a small figure beside the model name: **the number of people = how many are queueing** (healthy `normal` = one yellow figure, `busy` = two orange ones, `congested` = three red ones), while **idle is a green rocket** (nobody is queueing = full speed ahead) — idle is deliberately *not* drawn as "one green person", because at 12px "nobody is waiting" and "one person is waiting" have to be distinguishable at a glance; **unavailable** is a crossed-out figure. The probe's own `unknown` **draws nothing**: **"we were told nothing" is never reported as "all good"**. Colours follow the theme and the `title` tooltip ("极度拥堵") is still there. **The level is not persisted**: the catalog is a fact that survives a restart, a load level is a fact about that minute, so it is stamped onto the runtime catalog only and dies with the process; a failed probe logs one warning and never blocks the refresh. Measured on the CN gateway: the probe answers byte-identically under the CLI and the app identity, the 50-id ceiling is real (62 ids → HTTP 400), and 19 models come back in 186 ms. **The international region shows no load badge, because the international gateway simply does not have this endpoint** — measured, `www.workbuddy.ai` answers **404** for `/v2/chat/busy` (and for the neighbouring `/v3/chat/busy`, `/v2/models/busy` and friends), while the very same credential reads its `/v3/config` fine (28 models) and that document carries **no load or queue field at all**. This is not something the plugin left unimplemented, and it is not a configuration or permission problem: **upstream has never published this data for the international region**. So the international region shows the model name and campaign badges only; the failed probe degrades quietly to "no badge", never blocking the model list, and the plugin never guesses a load level from latency or credits.
40
40
  - **Queueing tells you your position** — upstream expresses a queue with three business codes (`6020` waiting, `6021` queue full, `6022` this user already at the maximum). Instead of passing along a raw upstream error, the plugin says "模型排队中:你排在第 8777 位,队列共 9021 个请求;约 7 秒后自动重试". **A queue does not trigger failover**: the line is shared platform-wide and bound to the model, so another account is handed the same position — no account-pool rotation, no extra requests burned. (The upstream's `/v2/chat/queue/status` returns an i18n KEY rather than a sentence, so the plugin never relays its `message` and always uses its own wording.) **The "resend once it is your turn" half is not implemented** — that needs the queue answer's real envelope on the OpenAI-compatible channel, and writing it unmeasured would mean code that never runs.
41
41
  - **Tabbed settings card (accounts / models / capabilities / diagnostics)** — the card used to be a single 3,500-pixel column that mixed two things with completely different commit semantics: **operating accounts** (who is serving, how much is left, switch order, check-in log) and **editing the catalog** (ticking models, context budgets, reasoning tiers, turning thinking off). An account change takes effect on the click; a catalog change is a draft that needs Save — so the Save button often sat three screens away from the thing it saves. Now each tab does one thing, and **switching tabs neither refetches nor drops an unsaved draft** (inactive panels stay mounted and are merely `hidden`, so they also leave the accessibility tree). The third tab, Capabilities, holds the **grants**: let the plugin drive the local CodeBuddy CLI, let the model buy an image on its own, where the credit readout is drawn. Each takes effect on the click and never passes through Save, and each is expected to grow sub-options of its own (a working directory for the CLI route, a size or price cap for the image tool) — which is why they no longer share one thin strip above the account table. The fourth tab, Diagnostics, is a read-only fact table: current region, upstream host, the account actually serving (by NAME, never by id), sign-in validity, how many models the catalog holds, how many are enabled, the pool switch, and the **raw text** of the upstream's most recent error (quoted, never translated or summarised) — its only reason to exist is to be pasted into a bug report, so it contains no control that changes a setting.
42
- - **Model availability testing, including cooldown recovery times** — working out when a limited model frees up used to mean guessing through repeated failures. Now every model row has a **Test** button that gives you a direct answer for whether the model works right now and, if not, when it comes back. **It sends a real-volume request (about 25k input tokens)**, rather than "ping it and see if it answers" — because the upstream throttles on request **SIZE** (measured: about 20k tokens pass, about 30k are refused), so a small request looks fine while every request in a long conversation may be refused. That is exactly where "the test passes but I am limited" came from. **Where the recovery time comes from**: the upstream's 429 carries **no** `Retry-After` header (and no `X-RateLimit-*`), but it writes the reset time into the response **body** — `{"code":6004,"msg":"…将在 2026-09-30 02:30:30 UTC+8 重置…"}`. The plugin parses that time and converts it using the UTC offset the message states, so the result does not depend on your machine's timezone; the card then shows “✗ Rate limited · usable again after 2026/9/30 02:30:30”. When none of the sources states a time, the plugin **never invents a countdown** and says plainly that no time was given. A rate limit deliberately does **not** borrow the monthly refresh time, which would tell you to wait weeks for a limit that clears in seconds. Probes run in the Host, so **the credential never reaches the page**, and results are not written to settings — your saved selection is untouched.
42
+ - **Model availability testing, including cooldown recovery times** — working out when a limited model frees up used to mean guessing through repeated failures. Now every model row has a **Test** button that gives you a direct answer for whether the model works right now and, if not, when it comes back. **It sends a real-volume request (about 25k input tokens)**, rather than "ping it and see if it answers" — because the upstream throttles on request **SIZE** (measured: about 20k tokens pass, about 30k are refused), so a small request looks fine while every request in a long conversation may be refused. That is exactly where "the test passes but I am limited" came from. **Where the recovery time comes from**: the upstream's 429 carries **no** `Retry-After` header (and no `X-RateLimit-*`), but it writes the reset time into the response **body** — `{"code":6004,"msg":"…将在 2026-09-30 02:30:30 UTC+8 重置…"}`. The plugin parses that time and converts it using the UTC offset the message states, so the result does not depend on your machine's timezone; the card then shows “✗ Rate limited · usable again after 2026/9/30 02:30:30”. When none of the sources states a time, the plugin **never invents a countdown** and says plainly that no time was given. A rate limit deliberately does **not** borrow the monthly refresh time, which would tell you to wait weeks for a limit that clears in seconds. **A success never has a cooldown**: this gateway also attaches `Retry-After` to a 200, and its value is the account's monthly quota reset (measured: `2026-11-01 00:00 +08:00`) — not a wait. So a successful row shows “✓ Usable” and never appends “usable again after”, and records written by older builds with that stray time are healed on read. Probes run in the Host, so **the credential never reaches the page**, and results are not written to settings — your saved selection is untouched.
43
43
  - **Image generation tool `workbuddy_draw_image` (CN only, OFF by default)** — hands WorkBuddy's text-to-image capability to the agent: say "draw me a cat sunning itself on a windowsill" in the conversation and the model calls the tool itself, then posts the image back. **It is off by default**, because it is the one capability here where the MODEL decides to spend: about 5.71 credits an image, with no per-call confirmation available in the host. Tick 「Let the model draw images」 under the card's Accounts tab to enable it; while it is off the tool is **not registered at all**, so the model never sees it and cannot attempt a call that would fail. **It is CN-only** — the international catalog lists no image model at all (not unimplemented; upstream has none). **Endpoint and request body are copied from the official CLI's ImageService** (`POST /v2/images/generations`, model `hunyuan-image-alpha`), and the product is a 24-hour COS signed URL the plugin tells you to download in time. **It really costs money**: about **5.71 credits per image** measured, so the tool description states the price outright, a failure carries a 30-second cooldown (while a bad parameter, a missing sign-in or an unknown model costs nothing and does not cool down), and there is no "draw four at once" parameter that could spend uncontrollably. The definition is hand-written raw JSON Schema, **adding no dependency**. See [docs/IMAGE_GEN.md](docs/IMAGE_GEN.md) for the evidence, protocol and live run.
44
44
 
45
45
  **Two things to know.** ① **It really does spend credits** — one measured probe reported `credit` 0.02 (a refused one 429s and costs almost nothing). So testing is **manual and per-model only**: there is deliberately no “Test selected” batch button, because sweeping the whole list from one click is an uncontrolled spend — and the Host enforces the same rule, **accepting a single model only** and refusing multi-model requests. ② The upstream advertises a 1M context yet throttles far below it, and the plugin advertises each model's **native** window to DSH (no longer clamped to 200K as of 3.5.0), so DSH compacts very late — hitting the throttle in a long session follows directly from that. To make DSH compact sooner, set a “DSH context budget” cap on the model's row (200K / 500K / the model's native window).
package/README.md CHANGED
@@ -46,7 +46,7 @@
46
46
  - **模型行上的活动徽标** —— 上游的模型目录里带着活动信息(免费试用、夜间折扣、限时免费、订阅优先等),插件**不新增任何请求**(复用刷新模型时本来就会拉的那份目录配置),直接在模型行上画一枚胶囊徽标:颜色与文案都由上游给出,悬停能看到上游的活动说明、活动的起止日期与每日生效时段(例如「夜间 23:00–7:50」)。**徽标是「此刻」的函数**:活动只在它自己声明的时间窗内显示,窗口一关徽标就自己消失;跨零点的时段按上游声明的时区判断;同一个模型命中多条活动时只画优先级最高的那条。上游没给结束时间的权益标记(如「订阅优先」)则常驻,直到上游把它下架。**徽标只是提示,不改变模型行为**——它标注的是上游此刻给出的活动与折扣,不替你做选择。**同一枚徽标也出现在 DSH 的模型选择器里**:菜单每一行都在模型名之后、队列图标之前画同样的胶囊 —— 菜单行用上游的短词(「夜间」「限免」),输入框旁那个触发器用完整词(「夜间折扣」「独家限免」);判定用的是同一份数据、同一个时钟,因为选模型的那一屏正好是最该看到「现在免费吗」的地方。(两个区域的上游目录都下发活动字段:国内版按 App 身份读 `/v3/config` 时带 8 条促销 + 1 条等级标记,国际版带 5 条 + 1 条;取证与判定规则见 [docs/PROMO_BADGE.md](docs/PROMO_BADGE.md)。)
47
47
  - **模型行上的繁忙程度(仅国内版)** —— 上游另有一条只读探针(`POST /v2/chat/busy`,一次最多 50 个 id,免费、不动账号状态)回答「这个模型此刻多忙」,插件在每次刷新目录时顺带问一次,把等级画成模型名旁边的一枚小人图标:排队人数 = 小人个数(正常 1 个黄人 / 轻微拥堵 2 个橙人 / 极度拥堵 3 个红人),**空闲**则是一枚**绿色小火箭**(没人排队 = 可以全速跑)——空闲不画成「绿色的 1 个人」,因为 12px 下「没人等」与「有 1 个人在等」必须一眼分开;**不可用**是一个划掉的小人。探针没回答的 `unknown` **一律不画**,**「没被告知」不会被当成「一切正常」**;颜色跟随主题,`title` 悬停文字(「极度拥堵」四个字)仍在。**等级不落盘**:目录是能活过重启的事实,负载只是那一分钟的事实,所以等级只盖在运行时目录上、随进程消失,探针失败只记一条 warn,绝不挡住目录刷新。国内版实测这条探针在 CLI 与 App 两种身份下答案逐字节相同,50 个 id 的上限也真实存在(62 个 → HTTP 400),19 个模型 186 ms 返回。**国际版没有这个徽标,因为国际网关根本没有这条接口** —— 实测 `www.workbuddy.ai` 对 `/v2/chat/busy` 回 **404**(`/v3/chat/busy`、`/v2/models/busy` 等同族路径也全是 404),而同一份凭据读它的 `/v3/config` 正常返回 28 个模型、**没有任何负载/排队字段**。这不是本插件没实现,也不是配置或权限问题:**上游国际版从未提供这个数据**。所以国际版只显示模型名与活动徽标,探针那条请求失败后安静退化成「没有徽标」,不会挡住模型列表,也不会用延迟或积分去猜一个「忙不忙」。
48
48
  - **排队时说清楚你排在第几位** —— 上游用三个业务码表达排队(`6020` 等待、`6021` 队列满、`6022` 已达上限)。撞上它们时,插件不再给一句上游报错,而是「模型排队中:你排在第 8777 位,队列共 9021 个请求。请稍后重新发送这条消息,或换一个模型」。**排队不会触发换号**:队列跟随的是模型与平台级的队伍,换个账号拿到的还是同一个位次,所以这里既不走账号池也不额外烧请求(上游 `/v2/chat/queue/status` 回的 `message` 是 i18n key 而不是人话,插件因此从不转述它,只用自己的措辞)。**排队不会自动重发,也不会被自动重试**——插件没有排队轮询循环(它需要排队答案在 OpenAI 兼容通道上的真实信封,没测到就不写),并且明确告诉宿主不要重试(`x-should-retry: false`):一次排队在几千位之后,重试十几次也轮不到,只会把真实请求白白花掉。位次报出来,由你决定是重发还是换模型。
49
- - **模型可用性测试(含冷却恢复时间)** —— 限量模型的冷却时间过去只能靠反复试错去猜。现在每个模型行有一个「测试」按钮,直接告诉你它此刻能否使用、不能用时何时恢复。**它发的是一条真实体积的请求(约 25k 输入 token)**,而不是「发个 ping 看通不通」——因为上游的限流**按请求体积触发**(实测约 20k 能过、约 30k 被拒),小请求看着正常、长对话里却可能每次都被拒,那正是「测试通过、实际被限」的来源。**恢复时间的来源**:上游的 429 **不带** `Retry-After` 响应头(也没有 `X-RateLimit-*`),但它把重置时间写在**响应体**里——`{"code":6004,"msg":"…将在 2026-09-30 02:30:30 UTC+8 重置…"}`。插件从响应体解析该时间(并按其声明的 UTC 偏移换算,不受你本机时区影响),因此卡片能直接显示「✗ 被限流 · 2026/9/30 02:30:30 之后可再用」。三处来源都没说时,插件**不编造倒计时**,如实显示「上游未给出何时恢复」。限流**不会**去借月周期包的刷新时间(那会让人为几秒的限流去等几周)。探测在后台发起,**凭据不会传到页面**;结果不写入设置,也不改变你已保存的勾选。
49
+ - **模型可用性测试(含冷却恢复时间)** —— 限量模型的冷却时间过去只能靠反复试错去猜。现在每个模型行有一个「测试」按钮,直接告诉你它此刻能否使用、不能用时何时恢复。**它发的是一条真实体积的请求(约 25k 输入 token)**,而不是「发个 ping 看通不通」——因为上游的限流**按请求体积触发**(实测约 20k 能过、约 30k 被拒),小请求看着正常、长对话里却可能每次都被拒,那正是「测试通过、实际被限」的来源。**恢复时间的来源**:上游的 429 **不带** `Retry-After` 响应头(也没有 `X-RateLimit-*`),但它把重置时间写在**响应体**里——`{"code":6004,"msg":"…将在 2026-09-30 02:30:30 UTC+8 重置…"}`。插件从响应体解析该时间(并按其声明的 UTC 偏移换算,不受你本机时区影响),因此卡片能直接显示「✗ 被限流 · 2026/9/30 02:30:30 之后可再用」。三处来源都没说时,插件**不编造倒计时**,如实显示「上游未给出何时恢复」。限流**不会**去借月周期包的刷新时间(那会让人为几秒的限流去等几周)。**成功即无冷却**:这个网关在 200 响应上也会带 `Retry-After`,值是账号月度积分包的刷新时刻(实测 `2026-11-01 00:00 +08:00`),它不是「你在此之前不能用」——所以成功的行只显示「✓ 可用」,绝不附「之后可再用」;旧版本已经写进文件的那类记录会在读取时自动治愈。探测在后台发起,**凭据不会传到页面**;结果不写入设置,也不改变你已保存的勾选。
50
50
 
51
51
  **两点需要知道**:① **它真的花钱**——实测一次报 `credit` 0.02(被限流时直接 429,几乎不花)。因此**只能按单个模型手工点**,没有「测试已勾选」这类批量按钮:一次覆盖整个列表就是一次不可控支出(服务端也据此**只接受单个模型**,多模型请求会被拒)。② 上游虽宣称 1M 上下文,但节流线远低于此;插件按模型的**原生窗口**向 DSH 宣称(3.5.0 起不再默认压到 200K),所以 DSH 很晚才压缩对话——长会话撞限流是这条机制的直接结果。想让 DSH 更早压缩,就在模型行上手动设一个「DSH 上下文预算」上限(可选 200K / 500K / 模型原生窗口)。
52
52
  - **生图工具 `workbuddy_draw_image`(国内版,默认关闭)** —— 把 WorkBuddy 的文生图能力交给 agent:你在对话里说「帮我画一只在窗台上晒太阳的猫」,模型自己就会调用这个工具,画完把图贴回来。**默认不开启**,因为这是本插件里唯一一项「由模型自己决定花钱」的能力:约 5.71 积分一张,而宿主没有「调用前先确认」的机制。想用就去卡片「账号」栏勾上「允许模型自主生图」;不勾的时候这个工具**根本不会注册**,模型看不到它,也就不会发起注定失败的调用。**它只在国内版可用**——国际版的模型目录里一条生图模型都没有(不是没做,是上游没有)。**端点与请求体照抄官方 CLI 的 ImageService**(`POST /v2/images/generations`,`hunyuan-image-alpha` 模型),产物是 24 小时有效的 COS 签名 URL,插件会提醒你及时下载。**它真的花钱**:实测约 **5.71 积分/张**,所以工具描述里直接写了单价,失败后有 30 秒冷却(但参数写错、没登录、模型不存在这三类一分钱没花,不进冷却),也不提供「一次画四张」这类不可控支出的参数。工具定义是手写的 raw JSON Schema,**没有引入任何新依赖**。取证、协议与联调记录见 [docs/IMAGE_GEN.md](docs/IMAGE_GEN.md)。
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthCandidates, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, Mt as hostPeersInfo, a as readHostHeartbeat, b as WorkBuddyCredentialStore, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, k as legacyWorkbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, vt as WORKBUDDY_APP_EXECUTABLE_ENV, w as defaultDesktopAuthDirs, wt as findWorkbuddyAppExecutableWithSource, y as WORKBUDDY_AUTH_FILE_ENV } from "./host-heartbeat-DWoUWD70.js";
2
+ import { C as defaultDesktopAuthCandidates, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, Mt as hostPeersInfo, a as readHostHeartbeat, b as WorkBuddyCredentialStore, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, k as legacyWorkbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, vt as WORKBUDDY_APP_EXECUTABLE_ENV, w as defaultDesktopAuthDirs, wt as findWorkbuddyAppExecutableWithSource, y as WORKBUDDY_AUTH_FILE_ENV } from "./host-heartbeat-CVz2J0Xt.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
package/lib/client.js CHANGED
@@ -1375,6 +1375,7 @@ window.__ModuleLoader__.load({
1375
1375
  function availabilityLine(t, account, nowMs = Date.now()) {
1376
1376
  const probe = account.probe;
1377
1377
  if (probe !== void 0) {
1378
+ if (probe.outcome === "ok") return outcomeText(t, probe.outcome);
1378
1379
  if (probe.retryAtMs !== void 0) {
1379
1380
  const left = remainingText(t, probe.retryAtMs, nowMs);
1380
1381
  const at = t("row.poolRetryAt", { at: formatShortClock(probe.retryAtMs) });
@@ -1403,6 +1404,7 @@ window.__ModuleLoader__.load({
1403
1404
  function availabilityMark(t, account, nowMs = Date.now()) {
1404
1405
  const probe = account.probe;
1405
1406
  if (probe !== void 0) {
1407
+ if (probe.outcome === "ok") return outcomeMarkText(t, probe.outcome);
1406
1408
  if (probe.retryAtMs !== void 0) {
1407
1409
  const left = recoversInText(t, probe.retryAtMs, nowMs);
1408
1410
  return left === "" ? outcomeMarkText(t, probe.outcome) : `${outcomeMarkText(t, probe.outcome)} · ${left}`;
@@ -2832,6 +2832,11 @@ const WORD_INTERNAL_BREAK = /([A-Z][A-Za-z0-9]*)(\s+)(?=[A-Z][A-Za-z0-9])/gu;
2832
2832
  * the request AND the neutral head could not change anything. Requests the
2833
2833
  * gateway answers keep their bytes untouched.
2834
2834
  *
2835
+ * `dense` adds word-boundary breaks for the last-resort rescue. The default
2836
+ * sparse form is still used first because it is the least invasive and has been
2837
+ * measured; dense mode exists for the refusal both the neutral head and the
2838
+ * sparse form could not clear.
2839
+ *
2835
2840
  * Idempotent: a body that already carries U+200B in a scanned message is left
2836
2841
  * alone, so a retry never stacks perturbations. Returns `undefined` when
2837
2842
  * nothing would change, which is the caller's signal not to spend a request.
@@ -2839,7 +2844,7 @@ const WORD_INTERNAL_BREAK = /([A-Z][A-Za-z0-9]*)(\s+)(?=[A-Z][A-Za-z0-9])/gu;
2839
2844
  * Never throws: a malformed body is the upstream's to reject, and a parse error
2840
2845
  * must not turn a rescue into a crash.
2841
2846
  */
2842
- function perturbChannelFingerprints(bodyJson) {
2847
+ function perturbChannelFingerprints(bodyJson, dense = false) {
2843
2848
  let parsed;
2844
2849
  try {
2845
2850
  parsed = JSON.parse(bodyJson);
@@ -2853,8 +2858,14 @@ function perturbChannelFingerprints(bodyJson) {
2853
2858
  let changed = false;
2854
2859
  const perturb = (text) => {
2855
2860
  if (text.includes(ZERO_WIDTH_SPACE)) return void 0;
2856
- const next = text.replace(/([.!?])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(/([。!?])/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}`);
2857
- return next === text ? void 0 : next;
2861
+ const boundaries = text.replace(/([.!?])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(/([。!?])/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}`);
2862
+ if (!dense) return boundaries === text ? void 0 : boundaries;
2863
+ const denseText = boundaries.replace(/(?=\S)/gu, (whole, offset, source) => {
2864
+ if (source.slice(Math.max(0, offset - 1), offset + 1).includes(ZERO_WIDTH_SPACE)) return "";
2865
+ const previous = source.slice(0, offset).match(/\S(?=\S*$)/u)?.[0];
2866
+ return previous !== void 0 && /[A-Za-z0-9぀-ヿ㐀-鿿]/u.test(previous) ? ZERO_WIDTH_SPACE : "";
2867
+ });
2868
+ return denseText === text ? void 0 : denseText;
2858
2869
  };
2859
2870
  for (const value of messages) {
2860
2871
  if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
@@ -2937,8 +2948,11 @@ function openingEvidenceOfBody(bodyJson) {
2937
2948
  if (evidence.length >= SYSTEM_EVIDENCE_LIMIT) break;
2938
2949
  const opening = firstNonEmptyLineOf(text).replace(/\s+/gu, " ").trim();
2939
2950
  if (opening === "") continue;
2951
+ const closing = lastNonEmptyLineOf(text).replace(/\s+/gu, " ").trim();
2940
2952
  evidence.push({
2941
2953
  opening: clip(opening, SYSTEM_OPENING_LIMIT),
2954
+ textLength: text.length,
2955
+ ...closing === "" || closing === opening ? {} : { closing: clip(closing, SYSTEM_OPENING_LIMIT) },
2942
2956
  role: roleOfEvidence,
2943
2957
  neutralized: startsWithNeutralHead(opening) || isUnapprovedChannelOpening(opening),
2944
2958
  messageIndex: index
@@ -2973,6 +2987,22 @@ function firstNonEmptyLineOf(content) {
2973
2987
  return "";
2974
2988
  }
2975
2989
  /**
2990
+ * The last line of a text that carries something.
2991
+ *
2992
+ * The gate has also been measured to match an exact substring INSIDE a scanned
2993
+ * message, so a first line alone can point at the wrong evidence: a neutralised
2994
+ * opening may sit above a fingerprint deeper in the same assistant history
2995
+ * message. The closing line is the cheapest observable other end of that text.
2996
+ */
2997
+ function lastNonEmptyLineOf(content) {
2998
+ const lines = content.split("\n");
2999
+ for (let index = lines.length - 1; index >= 0; index--) {
3000
+ const line = lines[index];
3001
+ if (line.trim() !== "") return line;
3002
+ }
3003
+ return "";
3004
+ }
3005
+ /**
2976
3006
  * Make the conversation open with a system message, prepending the fallback
2977
3007
  * when nothing else supplies one.
2978
3008
  *
@@ -6121,7 +6151,7 @@ function generationOf(version) {
6121
6151
  //#endregion
6122
6152
  //#region src/version.ts
6123
6153
  /** The npm package version this build was produced from. */
6124
- const WORKBUDDY_CONNECT_VERSION = "5.6.2";
6154
+ const WORKBUDDY_CONNECT_VERSION = "5.7.0";
6125
6155
  //#endregion
6126
6156
  //#region src/host-heartbeat.ts
6127
6157
  /**
package/lib/index.d.ts CHANGED
@@ -1864,6 +1864,16 @@ interface WorkBuddyShimOptions {
1864
1864
  client: Pick<WorkBuddyUpstreamClient, 'chatStream'>;
1865
1865
  catalog: WorkBuddyCatalog;
1866
1866
  logger?: ShimLogger;
1867
+ /**
1868
+ * When to perturb scanned messages with invisible characters.
1869
+ *
1870
+ * `off` keeps the ordinary fail-then-rescue ladder; `failure` (the default)
1871
+ * remembers a successful rescue and starts later requests with it; `always`
1872
+ * starts every request with the dense rescue. `always` is for users whose
1873
+ * gateway has already made the plain attempt a guaranteed 11128 — it trades a
1874
+ * little prompt byte-level purity for avoiding that doomed round trip.
1875
+ */
1876
+ channelPerturbation?: 'off' | 'failure' | 'always';
1867
1877
  /**
1868
1878
  * Pool failover for chat requests. Absent means no failover at all — the
1869
1879
  * pre-pool behaviour, where the selected account serves and its failure is
@@ -2649,6 +2659,12 @@ declare function parseRetryAfter(value: string | null, nowMs: number): number |
2649
2659
  * as "the upstream did not say when". That remains the honest answer for a
2650
2660
  * genuinely timeless failure; inventing a number here would be pure fiction.
2651
2661
  *
2662
+ * An `ok` outcome ALWAYS returns `{}`, whatever headers or body came with it.
2663
+ * A successful probe has already answered "can this account serve right now?"
2664
+ * with yes, so there is no wait to state; and on this gateway a 200 can carry a
2665
+ * `Retry-After` whose value is the account's monthly quota reset, which must
2666
+ * never be rendered as a lockout. See the guard at the top of the function.
2667
+ *
2652
2668
  * `model-busy` is the ONE exception, and it is not an invented countdown: the
2653
2669
  * upstream states a magnitude in prose ("about a minute") rather than a
2654
2670
  * timestamp, and the card's own vocabulary has no way to render "about". A
@@ -4526,6 +4542,15 @@ interface Config {
4526
4542
  * client-fingerprint workaround, not an account one — see `cli-bridge/runner`.
4527
4543
  */
4528
4544
  cliBridge?: CliBridgeSettings;
4545
+ /**
4546
+ * When scanned messages are perturbed with invisible characters.
4547
+ *
4548
+ * `failure` (default) learns from a successful channel-gate rescue and starts
4549
+ * later requests with the learned form; `always` starts every request with
4550
+ * the dense form for a gateway whose plain attempt is a guaranteed refusal;
4551
+ * `off` keeps only the post-failure rescue ladder.
4552
+ */
4553
+ channelPerturbation?: 'off' | 'failure' | 'always';
4529
4554
  }
4530
4555
  /**
4531
4556
  * Mark a schema's field as volatile on the DSH lines that support it.
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as prepareChatBody, A as parseWorkBuddyAuth, At as readAtRestKey, B as declaredTools, C as defaultDesktopAuthCandidates, Ct as findWorkbuddyAppExecutable, D as isEncryptedCredentialError, Dt as macosBundleExecutable, E as hasEncryptedCredentialFields, Et as isWorkbuddyBundle, F as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, Ft as writeFileAtomic, G as isFirstMessageGateBody, H as isContextTooLongBody, I as WorkBuddyCredentialRejectedError, J as openingEvidenceOfBody, K as knownRegionOf, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, N as CONTEXT_OVERFLOW_MARKER, Nt as resolveDshHome, O as isVaultAccountId, Ot as macosNestedAppBundles, P as CREDENTIAL_REJECTED_CODE, Pt as withFileLock, Q as perturbChannelFingerprints, R as channelGateMemberOf, S as authFileName, St as fetchAtRestKeyPayload, T as defaultDesktopAuthPath, Tt as isEncryptedFieldWrapper, U as isConversationIdGateBody, V as forceNeutralHead, W as isCredentialRejectedError, X as parseReasoning, Y as parseCreditMultiplier, Z as parseUpstreamModel, _ as ENCRYPTED_CREDENTIAL_CODE, _t as nativeModalityOf, a as readHostHeartbeat, at as lastRefusalFor, b as WorkBuddyCredentialStore, bt as deriveAtRestKey, c as WORKBUDDY_CONNECT_VERSION, ct as recordRefusal, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as WorkBuddyImageError, et as regionOf, f as WorkBuddyCatalog, ft as pickImageModel, g as withoutRouters, gt as imageDefaultFor, h as fallbackModelsFor, ht as NATIVE_MODALITY_BY_MODEL_ID, i as processStartTimeMs, it as defaultOffModelIds, j as workbuddyAccountId, jt as workbuddyAppExecutableCandidates, k as legacyWorkbuddyOwnAuthPath, kt as openEncryptedField, l as piAiRuntimeInfo, lt as withEffectiveOff, m as deriveCatalog, mt as queueNoticeText, n as clearHostHeartbeat, nt as attachBusyLevels, o as workbuddyHostHeartbeatPath, ot as offDefaultFor, p as applyContextBudgets, pt as parseQueueAnswer, q as modelIdOfBody, r as isHeartbeatProcessAlive, rt as REFUSES_OFF_MODEL_IDS, s as writeHostHeartbeat, st as offRefused, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as requestEvidenceOf, u as FALLBACK_WORKBUDDY_MODELS, ut as DEFAULT_IMAGE_SIZE, v as WORKBUDDY_AUTH_FILENAME, vt as WORKBUDDY_APP_EXECUTABLE_ENV, w as defaultDesktopAuthDirs, x as WorkBuddyEncryptedCredentialError, xt as deriveAtRestKeyId, y as WORKBUDDY_AUTH_FILE_ENV, yt as clearAtRestKeyCache, z as classifyUpstreamError } from "./host-heartbeat-DWoUWD70.js";
1
+ import { $ as prepareChatBody, A as parseWorkBuddyAuth, At as readAtRestKey, B as declaredTools, C as defaultDesktopAuthCandidates, Ct as findWorkbuddyAppExecutable, D as isEncryptedCredentialError, Dt as macosBundleExecutable, E as hasEncryptedCredentialFields, Et as isWorkbuddyBundle, F as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, Ft as writeFileAtomic, G as isFirstMessageGateBody, H as isContextTooLongBody, I as WorkBuddyCredentialRejectedError, J as openingEvidenceOfBody, K as knownRegionOf, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, N as CONTEXT_OVERFLOW_MARKER, Nt as resolveDshHome, O as isVaultAccountId, Ot as macosNestedAppBundles, P as CREDENTIAL_REJECTED_CODE, Pt as withFileLock, Q as perturbChannelFingerprints, R as channelGateMemberOf, S as authFileName, St as fetchAtRestKeyPayload, T as defaultDesktopAuthPath, Tt as isEncryptedFieldWrapper, U as isConversationIdGateBody, V as forceNeutralHead, W as isCredentialRejectedError, X as parseReasoning, Y as parseCreditMultiplier, Z as parseUpstreamModel, _ as ENCRYPTED_CREDENTIAL_CODE, _t as nativeModalityOf, a as readHostHeartbeat, at as lastRefusalFor, b as WorkBuddyCredentialStore, bt as deriveAtRestKey, c as WORKBUDDY_CONNECT_VERSION, ct as recordRefusal, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as WorkBuddyImageError, et as regionOf, f as WorkBuddyCatalog, ft as pickImageModel, g as withoutRouters, gt as imageDefaultFor, h as fallbackModelsFor, ht as NATIVE_MODALITY_BY_MODEL_ID, i as processStartTimeMs, it as defaultOffModelIds, j as workbuddyAccountId, jt as workbuddyAppExecutableCandidates, k as legacyWorkbuddyOwnAuthPath, kt as openEncryptedField, l as piAiRuntimeInfo, lt as withEffectiveOff, m as deriveCatalog, mt as queueNoticeText, n as clearHostHeartbeat, nt as attachBusyLevels, o as workbuddyHostHeartbeatPath, ot as offDefaultFor, p as applyContextBudgets, pt as parseQueueAnswer, q as modelIdOfBody, r as isHeartbeatProcessAlive, rt as REFUSES_OFF_MODEL_IDS, s as writeHostHeartbeat, st as offRefused, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as requestEvidenceOf, u as FALLBACK_WORKBUDDY_MODELS, ut as DEFAULT_IMAGE_SIZE, v as WORKBUDDY_AUTH_FILENAME, vt as WORKBUDDY_APP_EXECUTABLE_ENV, w as defaultDesktopAuthDirs, x as WorkBuddyEncryptedCredentialError, xt as deriveAtRestKeyId, y as WORKBUDDY_AUTH_FILE_ENV, yt as clearAtRestKeyCache, z as classifyUpstreamError } from "./host-heartbeat-CVz2J0Xt.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";
@@ -4020,7 +4020,8 @@ function channelEvidenceText(bodyJson) {
4020
4020
  const shown = entries.slice(0, CHANNEL_EVIDENCE_LIMIT);
4021
4021
  const rendered = shown.map((entry) => {
4022
4022
  const label = entry.neutralized ? "(插件已加中性头)" : "(不在插件已知名单里,网关可能新增了词条)";
4023
- return `「${entry.role}:${entry.opening}」${label}`;
4023
+ const span = entry.closing === void 0 ? `,共 ${entry.textLength} 字` : `,首尾为「${entry.closing}」,共 ${entry.textLength} 字`;
4024
+ return `「${entry.role}:${entry.opening}${span}」${label}`;
4024
4025
  }).join("、");
4025
4026
  const hidden = entries.length - shown.length;
4026
4027
  return `请求里被网关扫描的消息开头:${rendered}${hidden > 0 ? `……另有 ${hidden} 条未列出。` : "。"}`;
@@ -4217,6 +4218,7 @@ function createWorkBuddyShim(options) {
4217
4218
  const onAccountFailure = options.onAccountFailure;
4218
4219
  const onRequestBody = options.onRequestBody;
4219
4220
  const onUpstreamUsage = options.onUpstreamUsage;
4221
+ const channelPerturbation = options.channelPerturbation ?? "failure";
4220
4222
  const SHARED_SECRET = randomBytes(32).toString("base64url");
4221
4223
  /**
4222
4224
  * Whether this process's prompts have been PROVEN to need the invisible
@@ -4379,19 +4381,7 @@ function createWorkBuddyShim(options) {
4379
4381
  }
4380
4382
  const controller = new AbortController();
4381
4383
  res.on("close", () => controller.abort());
4382
- /**
4383
- * The body to send FIRST, which is the perturbed form once this process has
4384
- * proven the gateway demands it.
4385
- *
4386
- * `perturbChannelFingerprints` is consulted only when the flag is set, so a
4387
- * fresh process still pays the plain attempt — that is what discovers
4388
- * whether the gate is on at all (see `channelNeedsPerturbation`).
4389
- *
4390
- * The perturbed form is a purely additive transform: it inserts zero-width
4391
- * spaces and changes no word the model reads, so starting from it is not a
4392
- * behaviour change, only a saved round trip.
4393
- */
4394
- const preferred = channelNeedsPerturbation ? perturbChannelFingerprints(prepared) ?? prepared : prepared;
4384
+ const preferred = channelPerturbation === "always" || channelNeedsPerturbation ? perturbChannelFingerprints(prepared, channelPerturbation === "always") ?? prepared : prepared;
4395
4385
  let result = await client.chatStream(credential, preferred, controller.signal);
4396
4386
  if (channelNeedsPerturbation && !result.ok && result.kind === "channel_reject") {
4397
4387
  channelNeedsPerturbation = false;
@@ -4432,6 +4422,7 @@ function createWorkBuddyShim(options) {
4432
4422
  */
4433
4423
  let retriedWithForcedHead = false;
4434
4424
  let retriedWithPerturbation = false;
4425
+ let retriedWithDensePerturbation = false;
4435
4426
  if (!result.ok && result.kind === "channel_reject" && !controller.signal.aborted) {
4436
4427
  const forced = forceNeutralHead(prepared);
4437
4428
  if (forced !== void 0) {
@@ -4448,6 +4439,15 @@ function createWorkBuddyShim(options) {
4448
4439
  if (result.ok) channelNeedsPerturbation = true;
4449
4440
  }
4450
4441
  }
4442
+ if (!result.ok && result.kind === "channel_reject" && !controller.signal.aborted) {
4443
+ const dense = perturbChannelFingerprints(forced ?? prepared, true);
4444
+ if (dense !== void 0) {
4445
+ logger?.warn("dsh-connect-workbuddy: the channel gate refused the sparse perturbation too; retrying once with a dense word-boundary perturbation");
4446
+ result = await client.chatStream(credential, dense, controller.signal);
4447
+ retriedWithDensePerturbation = true;
4448
+ if (result.ok && channelPerturbation === "failure") channelNeedsPerturbation = true;
4449
+ }
4450
+ }
4451
4451
  }
4452
4452
  const triedAccountIds = [];
4453
4453
  /**
@@ -4564,6 +4564,7 @@ function createWorkBuddyShim(options) {
4564
4564
  const retries = [];
4565
4565
  if (retriedWithForcedHead) retries.push("强制中性头");
4566
4566
  if (retriedWithPerturbation) retries.push("一次不可见扰动");
4567
+ if (retriedWithDensePerturbation) retries.push("一次密集不可见扰动");
4567
4568
  const retryNote = retries.length === 0 ? "" : `,并已用${retries.join("与")}重试过`;
4568
4569
  const member = channelGateMemberOf(result.message, requestEvidenceOf(prepared));
4569
4570
  const said = detail?.rawMsg ?? detail?.displayMsg;
@@ -7008,6 +7009,12 @@ function parseUpstreamResetAt(message) {
7008
7009
  * as "the upstream did not say when". That remains the honest answer for a
7009
7010
  * genuinely timeless failure; inventing a number here would be pure fiction.
7010
7011
  *
7012
+ * An `ok` outcome ALWAYS returns `{}`, whatever headers or body came with it.
7013
+ * A successful probe has already answered "can this account serve right now?"
7014
+ * with yes, so there is no wait to state; and on this gateway a 200 can carry a
7015
+ * `Retry-After` whose value is the account's monthly quota reset, which must
7016
+ * never be rendered as a lockout. See the guard at the top of the function.
7017
+ *
7011
7018
  * `model-busy` is the ONE exception, and it is not an invented countdown: the
7012
7019
  * upstream states a magnitude in prose ("about a minute") rather than a
7013
7020
  * timestamp, and the card's own vocabulary has no way to render "about". A
@@ -7017,6 +7024,7 @@ function parseUpstreamResetAt(message) {
7017
7024
  * an account.
7018
7025
  */
7019
7026
  function cooldownOf(input) {
7027
+ if (input.outcome === "ok") return {};
7020
7028
  const fromHeader = parseRetryAfter(input.retryAfter, input.nowMs);
7021
7029
  if (fromHeader !== void 0) return {
7022
7030
  retryAtMs: fromHeader,
@@ -7136,13 +7144,7 @@ async function probeModel(input) {
7136
7144
  outcome: "ok",
7137
7145
  elapsedMs: Date.now() - startedAt,
7138
7146
  status: answer.status,
7139
- ...credit === void 0 ? {} : { message: `credit ${credit}` },
7140
- ...cooldownOf({
7141
- outcome: "ok",
7142
- retryAfter: answer.retryAfter,
7143
- nowMs: input.nowMs,
7144
- ...input.quotaRefreshAtMs === void 0 ? {} : { quotaRefreshAtMs: input.quotaRefreshAtMs }
7145
- })
7147
+ ...credit === void 0 ? {} : { message: `credit ${credit}` }
7146
7148
  };
7147
7149
  }
7148
7150
  const body = answer.body ?? "";
@@ -8357,6 +8359,7 @@ function workbuddyPoolStorePath(region) {
8357
8359
  * would render a waiting time on an account that is not waiting for anything.
8358
8360
  */
8359
8361
  function retryAtMsOf(probe) {
8362
+ if (probe.outcome === "ok") return void 0;
8360
8363
  if (probe.retryAtMs !== void 0) return probe.retryAtMs;
8361
8364
  if (probe.outcome !== "rate-limited" && probe.outcome !== "out-of-credit") return void 0;
8362
8365
  if (probe.message === void 0) return void 0;
@@ -8455,6 +8458,10 @@ function mergePoolProbe(existing, update) {
8455
8458
  ...update,
8456
8459
  accountName: existing.accountName
8457
8460
  } : update;
8461
+ if (update.outcome === "ok") {
8462
+ const { retryAtMs: _dropped, ...rest } = merged;
8463
+ return rest;
8464
+ }
8458
8465
  if (update.retryAtMs !== void 0) return merged;
8459
8466
  if (existing.retryAtMs === void 0 || existing.retryAtMs <= update.atMs) return merged;
8460
8467
  return {
@@ -9689,7 +9696,12 @@ const Config = z.object({
9689
9696
  enabledModelIds: z.array(z.string()).default([]).description("Deprecated: pre-region-split CN selection"),
9690
9697
  imageModelIds: z.array(z.string()).default([]).description("Deprecated: pre-region-split CN image opt-in"),
9691
9698
  contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Deprecated: pre-region-split CN context budgets"),
9692
- cliBridge: asVolatile(cliBridgeConfig.default({}).description("Optional CodeBuddy CLI route; off by default and independent of the providers"))
9699
+ cliBridge: asVolatile(cliBridgeConfig.default({}).description("Optional CodeBuddy CLI route; off by default and independent of the providers")),
9700
+ channelPerturbation: asVolatile(z.union([
9701
+ z.const("off"),
9702
+ z.const("failure"),
9703
+ z.const("always")
9704
+ ]).default("failure").description("When to perturb scanned messages with invisible characters: \"off\" only rescues after a channel-gate refusal, \"failure\" (default) learns from a successful rescue and starts later requests perturbed, \"always\" starts every request with the dense rescue for a gateway whose plain attempt is guaranteed to fail."))
9693
9705
  });
9694
9706
  /**
9695
9707
  * One region's saved model state. A config written before the region split has
@@ -9849,6 +9861,7 @@ function apply(ctx, config) {
9849
9861
  client,
9850
9862
  catalog,
9851
9863
  logger: ctx.logger,
9864
+ channelPerturbation: unwrapVolatile(config.channelPerturbation) ?? "failure",
9852
9865
  /**
9853
9866
  * Pool routing, bound to THIS region.
9854
9867
  *
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.6.2",
5
+ "version": "5.7.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",