dsh-connect-workbuddy 5.12.4 → 5.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,95 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.13.1 (2026-10-29)
4
+
5
+ ### Bug Fixes
6
+
7
+ - **按句改写网关固定指纹,落在 `neutralizeChannelFingerprints` 之前(#163,2026-10-29 实测)**
8
+ - **新测量**:CN 网关会在匹配前规范化 U+200B;system 开头插零宽仍 11128,`Note: ` 或真改词
9
+ 才 200。`user` / `tool` 开头不扫(18:46 连测 3 次 200),所以不碰这两个角色。
10
+ - **第三处是整段组合指纹**:`@` 路径那 8 句逐句单独发 200,整段一起发 400。只改开头或整段
11
+ `replaceAll` 都不满足维护性要求;按句改才能既拆掉组合指纹,又覆盖宿主改一两个词的情况。
12
+ - **修法**:新增 `rewriteChannelFingerprintPhrases()` 与按句表(identity、coding agent、Web GUI、
13
+ `Agent Teams is available in this session`、`@` 路径整段逐句),在 `prepareChatBody()` 里
14
+ `keepPromptLast` 之后、`neutralizeChannelFingerprints` **之前**调用;只遍历 `system` /
15
+ `assistant` 的 string content 与 array text part,`user` / `tool`、工具调用、图片不动;无命中
16
+ 逐字节不变,函数幂等。
17
+ - **测试**:新增 `tests/issue163-sentence-rewrite.spec.ts`(三处 span 消失、普通对话不变、
18
+ 幂等、assistant 改写、array part、user/tool 不动、无命中 no-op);同步更新 `issue98*` /
19
+ `upstream` / `identity-clause-break` 里旧的「中性头 + 原文」断言。`pnpm run check` 通过。
20
+
21
+ - **自动「继续」降为最后兜底:默认 1 次,文案不再承诺有效(#163)**
22
+ - **证据**:固定 system 指纹每轮逐字节相同;同一 body 打上百次都是 400,只改三处才 200
23
+ (freedomx86 17:44);现场 3 次自动续发、Windpixy 反复继续/新老会话、QIYU 三级救援全跑完
24
+ 仍 400;appulsexi-maker 19:12 的零历史新会话、只有 system 一条,仍 11128。新回合换不掉
25
+ 固定 system 内容,自动续发对这一族无效。
26
+ - **修法**:`DEFAULT_MAX_CONSECUTIVE` 与 schema / 客户端默认 3 → **1**;卡片、配置描述、
27
+ README 与 `docs/PINNED-11128-RETRY.md` 改为「最后兜底,不是 11128 的修复;固定指纹由按句
28
+ 改词处理,只有随轮次变化的内容才可能靠它碰过」。
29
+ - 仍然默认关闭;每次仍真实消耗一次请求。**行为面**未变(只认 `channel_reject`、不换号、
30
+ 不碰子代理、成功/真人消息清零)。
31
+
32
+ - **换 provider 后,另一家的 thinking 不再被摊成 assistant 正文发给 WorkBuddy(#163 现场)**
33
+ - **现场**:`session-bb9396be` 在 02:03:43 从 `dsh-opencode-go/deepseek-v4.1-flash` 切到
34
+ `workbuddy/deepseek-v4.1-flash`,02:04:35 起连续 4 个回合 400/11128;错误里三条 assistant 的
35
+ 14,410 / 4,225 / 5,138 字,逐字等于切换前 seq 98/109/114 的 reasoning 块。
36
+ - **机制**:宿主把另一家的 assistant 轮次重建为 `dsh-foreign`,pi-ai 把无签名的 thinking 块
37
+ 改写成可见 `text`;WorkBuddy 网关扫描 assistant 消息,把它当成未授权渠道。`dropForeignThinking`
38
+ 本来就是修这个的,但默认关闭,且卡片上没有开关。
39
+ - **修法**:`withLegacyContext` 在 `alignAssistantIdentities` 之后**无条件**丢弃**跨 provider** 的
40
+ foreign thinking(`api: dsh-foreign`,或 provider 不属于本 bundle 的两条路由);同一路由内换
41
+ 模型(`hy3` → `deepseek-v4.1-flash`)仍由 `dropForeignThinking` 显式开关控制。跨 provider 的
42
+ 思考块没有签名、本来就不可能作为 reasoning 重放,丢弃不改变语义;文本与工具调用不动。
43
+ - **诊断**:自动丢弃时写一条 warn(仅在开关关闭时),`latestForeignThinking` 仍照旧上报。
44
+ - **测试**:`tests/issue147-foreign-thinking.spec.ts` 新增三条(跨 provider 选中、同路由模型
45
+ 切换不选、global 路由对齐后不误伤);`tests/issue147-model-leg-wiring.spec.ts` 新增两条(两个
46
+ generation × 两个入口的 `dsh-foreign` 回合在开关关闭时也被丢弃)。
47
+
48
+ ## 5.13.0 (2026-10-28)
49
+
50
+ > 用户贴的那条失败:`本轮运行失败400: {"message":"workbuddy 上游失败:被网关的渠道校验拦截(不是账号、
51
+ > 额度或网络问题)(http 400)(code 11128,requestId …)。…","type":"channel_reject"}`,
52
+ > 以及一句明确的需求:「当我收到这样的信息的时候,自动发送继续,并且如果连续收到3次就不再发送」。
53
+ > 这条需求成立的前提是搞清楚**为什么重发可能有用**,因为插件自己在 `perturbChannelFingerprints` 旁的
54
+ > 注释一直说「给定提示词被拒是确定性的」,而现场报告反复说「再发一次就过了」。两者其实不矛盾:**新回合
55
+ > 是一份重新组装的请求**(surface 重排、注入块位置变化、学到扰动偏好后第一发就带扰动),所以「再发一次」
56
+ > 不是把被拒的字节原样再塞一遍——那是 `channel_reject` 被排除在 `isFailoverWorthy` 之外的同一条理由。
57
+
58
+ ### Features
59
+
60
+ - **可选:渠道门拒绝(`channel_reject` / 11128)之后自动再发一次「继续」,连续上限 3 次**
61
+ - **为什么需要它**:这一类失败在插件里是**三重不救**——不换号(`isFailoverWorthy` 刻意排除:网关读的是
62
+ 消息开头,换账号只会每个账号白烧一次真实请求)、不在原地重试(同一请求内的三级救援阶梯
63
+ `forceNeutralHead` → 一次零宽扰动 → 密集零宽扰动已经跑完)、宿主也不重试(400 不在
64
+ `DEFAULT_RETRYABLE_CODES` 里,`LvFailure.code` 还会被 pi-ai 按 `/\b400\b/` 重新分类成
65
+ `INVALID_REQUEST`)。于是这一轮直接结束,用户看到的就是那句 400。
66
+ - **它做什么**:`session/event` 上认 `turn/end` + `reason.kind === 'error'` + 这一类失败 → 等 300ms →
67
+ `agent.followup(createUserMessage(...))` 发一条 `继续`。**不做**任何换号、换区、改请求体的事。
68
+ - **上限的语义是「连续」**(`maxConsecutive`,默认 3):一次 `completed` 回合、或一条
69
+ `source.kind === 'user'` 的消息立即清零;**插件自己发的那条不清零**——否则我们自己发的消息会把计数
70
+ 抹掉,上限永远不生效(这条单独有测试钉住)。`aborted` + `reason.kind === 'user'`(用户点了停止)
71
+ 也清零,同 `dsh-purge` / `auto-continue` 的处置。
72
+ - **不做的四件事**:子代理会话一律不碰(它的回合由父代理驱动);`status === 'running'` 时不发(宿主可能
73
+ 自己起了新回合);收件箱里有排队内容时不发(用户可能正在打字,抢在前面会让模型先答错东西);
74
+ `turn/start` 一来就取消已排定的那一条。
75
+ - **失败如何识别**:`code === 'channel_reject'`,或消息里带插件自己的 kind 字面量 / 它自己的那句
76
+ 「渠道校验拦截」。**刻意不用 `code` 单值判定**——宿主那一份 `code` 极可能已经被 pi-ai 从文本重分类成
77
+ `INVALID_REQUEST`,只看它会漏掉每一次真实失败。
78
+ - **默认关闭**,因为每次都会真实消耗一次请求,与 `usageIdentity` / `imageToolEnabled` 同一条规则。
79
+ 它是本插件里**唯一改完即生效**的上游类开关:引擎每次失败现读配置,不必重载插件(因此它**不在**那组
80
+ 「改完需重载」的提示里,而是自己一组)。
81
+ - **卡片**:「能力」栏新增一个开关(`.dsm-workbuddy-channel-continue-check`),展开时回报上限与发送
82
+ 文本;这两个值卡片只读不写,写入时整块带上,一次布尔写不会把它们擦回默认(同 `sessionTitle` 的处置)。
83
+ - **测试**:`tests/channel-continue.spec.ts`(引擎 30 例,含上限、清零、不清零、子代理、running、
84
+ 收件箱忙、seq 去重、卸载、会话数上限、事件总线缺失时功能失效而非拖垮插件)、
85
+ `tests/channel-continue-wiring.spec.ts`(真 `apply()` 挂载:真的订阅了、真的读到活配置、
86
+ 真的随 fiber 卸载)、`tests/card-channel-continue.spec.tsx`(渲染与写入契约)、
87
+ `tests/web-status.spec.ts`(`__save` 白名单整块接受)。
88
+ - **溯源**:「连续失败上限」的语义来自 `dsh-client-auto-continue`(MIT,HsiangNianian),
89
+ 「哪些失败码不续 / 收件箱忙不插队 / 用 `source.kind` 区分真人介入」来自 `dsh-purge`
90
+ (MIT,YuJunZhiXue)。两者都只是**思路参照**,未复制源码;相对它们本实现是收窄。
91
+ 已同步写入 `THIRD_PARTY_NOTICES.md`、两份 README 的致谢与 `docs/DESIGN.md` §5.3 的分发表。
92
+
3
93
  ## 5.12.4 (2026-10-28)
4
94
 
5
95
  > issue #159 的**报告现象成立,归因不成立**。现场确实出现了「三级救援一级都没跑」的 `11128`,但把它
package/README.en.md CHANGED
@@ -52,6 +52,15 @@ It depends on the WorkBuddy client's own endpoints (not a public API), so the pl
52
52
  - **Optional: make the usage page record these calls like the official client's (5.8.0, issue #132, OFF by default)** — with `usageIdentity` on, chat requests carry `X-IDE-Name: WorkBuddy` (plus `X-IDE-Type: CLI` and a version taken from the existing product UA, not a second hard-coded number), so the usage page's client column is no longer blank, and the conversation-identity triple becomes the official **32-character lower-case hex UUIDv7** with `X-Request-ID` alongside it — the gateway echoes that id as the SSE `id` and writes it into the usage page, so hyphenated v4 ids made one account's rows look like two different clients — while "fresh per request, all distinct" has not been relaxed. **`User-Agent` is untouched under either setting**: billing and rate limits recognise the CLI channel. The image endpoint does not follow this switch; it keeps the shape measured on 2026-10-21. It is off by default because the shape fixes nothing on its own (the three `11128` doors read message openings, a missing conversation id, and whether the first message is a system prompt — no header moves any of them) and the only measurement is a reporter's on an older build, so a purely cosmetic alignment does not get to put every account at risk first. There is also an opt-in `keepPromptLast` (**off by default**): the usage page takes the request text from the LAST `user` message, and the host appends its time samples, runtime context and `<system-reminder>` blocks AFTER what you actually typed, so that column recorded injected text; when enabled it moves those injected blocks **above the real prompt** — order only, except that the recorded prompt's own leading `<acp …>mNNNNN</acp>` marker is stripped (leave it and that column still opens with a marker, i.e. nothing was fixed); earlier messages **keep** theirs, because the model cites history by those ids and the only id that can never matter is the message being answered now. It stays off by default because the order is something the upstream counts and a few leading characters are removed from the real prompt. The move matches only the host's own injected prefixes, and a conversation with no injected tail is untouched byte for byte — marker included — pinned by tests written for both.
53
53
  - **Optional: move each new session's title request onto the hidden lightweight model (issue #133, OFF by default)** — the DSH host fires one title-generation request per new session through the same provider as the conversation, so it is billed at the main paid model's rate. With `sessionTitle.enabled` on, a request the plugin recognises as a title (its only user message opens with the host's template `Generate the session title from this JSON array of human messages:`) is re-pointed at `sessionTitle.model` (default `wb-lite`). The reporter measured 0 credits for those calls, with three caveats: it is still a real upstream request and counts toward request volume; the measurement is the reporter's account, not reproduced here; detection is fail-closed — if the host rewords the template the rewrite silently stops and billing reverts, and ordinary conversations are never touched. If the upstream rejects the model, only that session's title fails once; the conversation is unaffected. Like `usageIdentity` / `keepPromptLast` / `extraInjectedPrefixes` in the bullets above, `sessionTitle` is read once when the plugin mounts: **after editing `cordis.patch.yml`, reload the plugin or restart DSH for the change to take effect** — hot edits do not apply.
54
54
  - **Benefits: a marker for what is unclaimed, plus a per-account Claim button** — the card marks, account by account, anything in the upstream's benefit campaigns (compensation / gifts) that **has not been claimed yet**. The data comes from the upstream's own two status endpoints; in the marking stage the plugin is **read-only** and never calls the two write endpoints. Three display rules all follow the upstream's state: `active && !claimed` shows the row, `claimed` removes it entirely, and an expired campaign (upstream flips `active` to `false`) disappears by itself — the plugin **never compares `end_time` against the local clock**, so a skewed machine cannot argue with the server; `end_time` only drives the "three days left" highlight. `active` alone is not enough to decide: an already-claimed account still reports `active: true`, and reading it alone would leave a claimed campaign hanging there forever. The read reuses the check-in pipeline (once per account, cached for 30 minutes, no request while the account pool is off), and a failing endpoint counts as "unread" rather than caching "nobody has benefits". Each row ends in a solid amber **Claim pill**, and one click claims **one campaign of one account**: there is no "claim everything", because a click spends one irrevocable real entitlement — and the write route **requires `accountId`**, so the request itself cannot batch. Before writing it **re-reads** the status and decides again; anything already claimed or expired returns 409 without touching upstream. After a successful claim it answers with the upstream's **fresh reading** and refreshes the card, with no optimistic update — even a silent upstream failure cannot leave a false "claimed" behind — and it also drops that account's cached benefit state, so the pill cannot linger on a stale status.
55
+ - **Optional: a LAST-RESORT 「继续」 when the gateway refuses the request (OFF by default, 1 send by default)** — when the upstream rejects with `400` and business code `11128` the turn simply ends: the plugin does not retry inside that request (its three-rung ladder already ran), it does not swap accounts (the gate matches message contents, and every account sends the same ones), and the host does not retry. **This is not a fix for 11128.** Fixed system fingerprints (the identity / Web GUI / @-path paragraph from #163) are byte-identical every turn, so resending only pays for the same 400 again; since 5.13.1 `prepareChatBody` rewrites known phrases sentence by sentence. Only content that happens to change between turns can be helped by a new turn. Turning on the card's Capabilities switch makes the engine wait 300 ms and send `继续` through `agent.followup` (`source.kind` is `workbuddy-continue`, **not** `user`). **The cap counts CONSECUTIVE sends** (`channelRejectContinue.maxConsecutive`, default **1**; the old default of 3 is disproven — if one cannot help, three cannot): one successful turn, or one message you type yourself, resets it; the plugin's own message does not. Subagent sessions are never touched, and neither is a session already running or with something queued. **Every send spends a real request**, hence OFF by default; it is also the only upstream-shaped switch whose change **takes effect immediately**. It can be set directly in `cordis.patch.yml`:
56
+
57
+ ```yaml
58
+ channelRejectContinue:
59
+ enabled: true
60
+ maxConsecutive: 1
61
+ text: 继续
62
+ ```
63
+
55
64
  - **Actionable advice when a credential is refused** — when the upstream rejects the selected account's token, the plugin actually **probes** the other local sign-ins: if one still answers, it tells you to switch to it (with a one-click switch) instead of vaguely asking you to sign in again — re-authenticating fixes nothing while you are pinned to a revoked backup. Only when there is genuinely no other local account does it ask you to sign in again.
56
65
  - **Add accounts by QR sign-in, import/export them in bulk, test or delete them one by one** — the WorkBuddy desktop app is no longer a prerequisite: the card's **Sign in with QR code** button (labelled **Sign in on the web** for the international build — that region uses a browser login page, there is no QR; it sits just above **More settings** and acts immediately, with nothing to save) opens a dialog and takes you **straight to the authorization page** in your browser; if the browser blocks that, the dialog's link, copy and **Open page again** are the fallback. Every credential lands in the plugin's own vault (`$DSH_HOME/workbuddy-vault/<region>/<accountId>.json`, one file per account), and the desktop app's auth files stay **read-only and are never modified**. Each row of the account table gains **Test** and **Remove**: a row test reuses the same probe as the batch run, so the two are comparable; removing deletes only the copy the plugin stored and **hides** the account: it disappears at once and the automatic poll cannot bring it back, while **Detect accounts again** restores it (signing in or importing the same account clears the hide too). **The desktop app's sign-in is never touched** — removing means "this plugin no longer lists it", not "sign out of the app". **Export / import accounts** moves credentials to another machine: pick accounts to export a JSON file → choose a file and read a **redacted preview** (field names and whether a token is present; the token itself never enters the preview) → tick what to import. An import overwrites per account rather than duplicating, and **only a record positively identified as belonging to that region is stored** — a domain the plugin cannot classify is skipped and counted as skipped. See [Account pool](#account-pool).
57
66
  - **Credits live in the account pool** — the member table shows each account's remaining credits and nearest expiry, and credits are the ranking's second key (more credits serves first). The queries are **read-only** and consume no credits.
@@ -483,8 +492,9 @@ Marketplace screenshots and GitHub README badges are two separate mechanisms:
483
492
  - **Switching region makes the context "grow", and can hit `code 11115` immediately — the gateway is not over-charging you.** The same history is counted under different buckets in the two regions, and the root cause is how the host's bundled `@earendil-works/pi-ai` replays history. It evaluates `isSameModel` for every assistant message (`transform-messages.js:68`, keyed on provider **and** api **and** model id all being equal): for a message that fails, its thinking is **downgraded to ordinary content** (`:88–96` — not dropped), and content is what the gateways count, while thinking goes out in a different field. This plugin registers the domestic and international editions as **two provider ids** (`workbuddy` / `workbuddy-global`, see [How it works](#how-it-works)) that carry the **same model id** — so one region switch moves **all the other side's thinking into the content bucket at once**. That is the cause of issue #59's "the same conversation history is charged 1.374M by the international gateway and 708K by the domestic one (1.94×)"; switching back is the same formula read the other way, 0.696×.
484
493
  - Three consequences you can see: a region switch can move the token count by ±30%–94%, and **how far depends only on the other side's thinking character count, not on total history length**; **switching models inside one region** triggers the same downgrade (the model leg of `isSameModel`), so the count can move without a region switch at all — the downgrade itself cannot be removed, but **its becoming prose can**, see `dropForeignThinking` below; and that jump lands on top of the host's current compaction threshold (`min(0.8 × window, window − 128000 − 65536)`), so a session that was comfortably under the limit can hit `code 11115 prompt is too long` the moment you switch region.
485
494
  - **The downgrade is not only a counting problem: the reasoning becomes the answer, and the model then copies that shape (issue #147).** The bullet above covers what the downgrade does to BILLING; this one covers what it does to CONTENT. For a message where `isSameModel` fails, pi-ai does not drop its thinking — it rewrites each block to `{type:'text'}` (`transform-messages.js:87–90`), i.e. the answer channel. So after a provider switch, **another model's internal working notes are presented as this turn's reply**, and a model that reads "all of my past reasoning was plain text" keeps writing its plan into the text channel from then on. The reporter measured one real turn: 56 of 80 assistant messages carried an answer while their own `reasoning` was ≤ 1 character, 20 of those held English working notes totalling 58,960 characters, and CJK fell to 1.37% of the 60,984 visible characters where the previous six turns had been entirely Chinese. It also pushed the session from 696,966 to 758,005 total tokens against an 800,000 compaction threshold.
486
- - **`dropForeignThinking` (off by default)**: when on, this plugin **removes** those foreign thinking blocks before the request goes out, instead of letting them be flattened into prose. Removal rather than preservation, because these blocks are built by the host's `foreignAssistant()` and carry **no `thinkingSignature`** — even claimed as our own turn they could never be replayed as signed reasoning, so their only possible contribution to the wire was the prose they became. Text blocks and tool calls are never touched, and neither is any message's identity.
487
- - With the switch off the request is **byte-for-byte** what earlier builds sent: it changes bytes the upstream counts, so it is an explicit opt-in on the same rule as `usageIdentity`.
495
+ - **Cross-provider foreign thinking is dropped unconditionally; the `dropForeignThinking` switch only covers a model switch inside one route (off by default)**: this plugin **removes** those foreign thinking blocks before the request goes out, instead of letting them be flattened into prose. Removal rather than preservation, because these blocks are built by the host's `foreignAssistant()` and carry **no `thinkingSignature`** — even claimed as our own turn they could never be replayed as signed reasoning, so their only possible contribution to the wire was the prose they became. Text blocks and tool calls are never touched, and neither is any message's identity.
496
+ - **No switch for the cross-provider leg**: after a provider switch the host rebuilds the other provider's assistant turns as `dsh-foreign`, and pi-ai flattens their thinking into assistant text; the WorkBuddy gateway scans assistant messages, so the whole turn is refused with `11128` (issue #163's measured scene: 14,410 / 4,225 / 5,138 characters of reasoning replayed as prose, with all three in-place rescue rungs and three auto-`继续` attempts failing). Those blocks could never be replayed as reasoning anyway, so dropping them is a correction rather than a preference and does not wait for an opt-in. `dropForeignThinking` only widens the same repair to a model switch **inside one route** (`hy3` → `deepseek-v4.1-flash`, both served by this bundle and stamped `provider: workbuddy`); for that leg the off path is still **byte-for-byte** what earlier builds sent.
497
+ - An automatic drop writes a warn (only while the switch is off) and still reports through `latestForeignThinking`, so the log names which blocks that request removed.
488
498
  - The ORDER is load-bearing: the repair runs **after** `alignAssistantIdentities`. A global-region turn of this plugin's own arrives carrying `api: 'dsh-foreign'` too (the host's replay-degrade path), so diagnosing first would classify it as foreign and delete its reasoning — exactly issue #102 in reverse. `tests/issue147-foreign-thinking.spec.ts` pins the order through pi-ai's real `transformMessages`.
489
499
  - Turns already recorded in history are **not** retroactively repaired: they are already stored as prose, and the switch governs later requests only. Starting a new session after the provider switch is the direct way out.
490
500
  - **It also covers switching MODELS inside one region** (`hy3` → `deepseek-v4.1-flash`, both served by this plugin, both stamped `provider: workbuddy`). The provider and api legs are equal there, so only the `model` leg separates the turn from the request — and 5.12.0's predicate compared just the first two, so that switch detected **nothing** and the switch looked broken. The model id comes from the request entry points (`model.id` on `stream` / `streamSimple`); omitting it keeps 5.12.0's behaviour exactly, which is why the card's read-only path is unaffected (it reads `lastForeignThinking`, which the request path fills).
@@ -511,6 +521,11 @@ This project builds on work others have published. Sources and licenses are cred
511
521
  - [Sliverkiss/workbuddy2api](https://github.com/Sliverkiss/workbuddy2api) (MIT) — reference implementation of the WorkBuddy upstream protocol (the `copilot.tencent.com` wire behavior), referenced via `dsh-workbuddy-connect`.
512
522
  - [hawklithm/workbuddy2api](https://github.com/hawklithm/workbuddy2api) (MIT, Copyright (c) 2026 Mayer) — **the only entry in this list that is a CODE PORT rather than a design reference**: its `src/codebuddy_proxy/dsml_parser.py` is a DSML tool-call parser for a proxy in front of the same WorkBuddy/CodeBuddy upstream, and this project ported it to `src/dsml-recovery.ts` (the tag state machine, ignored-region detection, missing-wrapper repair, recursive parameter parsing, and the streaming buffer), adding the two gates it does not have (a tool name must be declared in THIS request; no declared tools means no recovery at all). The three deliberate deviations are listed in that file's header; the formal attribution is in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
513
523
 
524
+ ### References for the auto-continue semantics
525
+
526
+ - [HsiangNianian/dsh-auto-continue](https://github.com/HsiangNianian/dsh-auto-continue) (MIT) — the source of the "consecutive-failure cap" semantics: the counter increments on send, stops at the cap, resets on a successful turn, and skips subagent sessions. This project's `channelRejectContinue` takes only that layer, not its engine.
527
+ - [YuJunZhiXue/dsh-purge](https://github.com/YuJunZhiXue/dsh-purge) (MIT) — three concrete practices: which failure codes must not be continued (quota / rate limit / transport), do not queue behind a busy inbox, and use a message's `source.kind` to tell human intervention from the plugin's own message.
528
+
514
529
  ### Baseline for the plugin presentation and structure
515
530
 
516
531
  - [dingminhua/dsh-connect-trae](https://github.com/dingminhua/dsh-connect-trae) (MIT, Copyright (c) 2026 LaoDing) — the plugin's presentation is kept consistent with it: the settings-card structure and interaction, the model-management (refresh → select → save) and account-selection model, the read-only host↔client route shape, and the npm release engineering.
package/README.md CHANGED
@@ -61,6 +61,15 @@
61
61
  - **可选:让上游用量页把这笔记成官方客户端发的那笔(5.8.0,issue #132,默认关闭)** —— 开 `usageIdentity` 后 chat 请求带上 `X-IDE-Name: WorkBuddy`(连同 `X-IDE-Type: CLI` 与版本号;版本号取自现有的产品 UA,不留第二个硬编码数字),用量页的「使用端」由此不再空白;会话身份三件套同时改成官方客户端那种 **32 位十六进制 UUIDv7** 并补上 `X-Request-ID`——网关把这个 id 回显成 SSE 的 `id` 并写进用量页,此前带连字符的 v4 与官方记录摆在同一页上像两种客户端;**每请求现生成、互不相同**这条没有放松。**`User-Agent` 在两种取值下都一字未动**——账单与限流认的是 CLI 通道;生图路径也不跟着这个开关变,它保持自己 2026-10-21 实测过的形状。**默认关**的理由:形状本身不修任何东西(`11128` 的三道门读的是消息开头、会话 id 有没有、首条是不是 system,头改不动其中任何一条),而实测证据来自报告者的账号与旧版本、本插件未在真机复现——一次纯显示对齐不该让所有账号先承担风险。另有可选的 `keepPromptLast`(**默认关闭**):用量页的「请求」列取自 body 里**最后一条** `user` 消息,而宿主把时间采样、运行时上下文、`<system-reminder>` 追加在你真正输入的那句**之后**,那一列于是常年记的是注入块;开启后把这些注入块**搬到真实输入之前**(只变顺序;另外会剥掉**被记录那一条**真实输入前面的 `<acp …>mNNNNN</acp>` 标记,否则那一列仍然以标记开头而等于没修,更早的消息一律保留自己的标记,因为模型靠它们引用历史)。默认关的理由是它会改变上游会数到的消息顺序、并删掉真实输入的几个前导字符;搬移只匹配宿主自己的注入前缀,而普通对话在没有注入尾块时逐字节不变(连标记也不剥)——这两点各有专门测试钉住。
62
62
  - **可选:把每个新会话的标题请求改到隐藏轻量模型(issue #133,默认关闭)** —— DSH 宿主每个新会话会发一次标题生成请求,走的是与对话相同的 provider,于是这笔按主付费模型的费率计费。开启 `sessionTitle.enabled` 后,插件把能按宿主标题模板(`Generate the session title from this JSON array of human messages:` 开头、整条 body 只有这一条 user 消息)识别出的标题请求改写到 `sessionTitle.model`(默认 `wb-lite`)。报告者实测该模型对这类请求 **0 积分**,但注意三点:这仍是**真实上游请求**,请求数照算;证据是报告者账号级的,本插件未复现;识别按 fail-closed 设计——宿主若改模板,改写静默停止、回到原计费,不会误伤普通对话。若上游拒绝该模型,只是该会话标题生成失败一次,不影响主对话。另外,`sessionTitle` 与本条及上一条的 `usageIdentity` / `keepPromptLast` / `extraInjectedPrefixes` 一样,都是插件挂载时读一次配置:**改完 `cordis.patch.yml` 后需要重载插件或重启 DSH 才生效**,热改不生效。
63
63
  - **福利活动:待领取标记 + 单账号「领取」按钮** —— 卡片会逐账号标出上游活动(补偿 / 赠礼)里**还没领的那一份**,数据来自上游自己的两个状态接口,标记阶段**只读**、两个写接口一次都不调用。显示规则完全由上游状态决定:`active && !claimed` 才显示,`claimed` 整行消失,活动过期(`active` 翻 `false`)自动消失——**不用本地时钟去比 `end_time`**,本机时间偏了也不会与服务端打架;`end_time` 只用于 3 天内临期高亮。`active` 单独看不行:已领取的账号 `active` 仍为 `true`,只看它会让领过的账号永远挂着待领取。读取复用签到的管线(每账号一次、30 分钟缓存、账号池关闭时不发请求),任一接口失败按「未读」处理,不会把一次失败缓存成「全都没福利」。行尾是一个实心琥珀色的**胶囊按钮「领取」**,一次只领**一个账号的一个活动**:没有「全部领取」,因为一次点击花掉的是一份不可撤销的真实权益,写路由也**强制要求 `accountId`**,请求本身无法批量领取。领取前**重新读取**状态再判定,已领取或已过期一律 409 且不调用上游;领取后用上游的**新读数**作答并刷新卡片,不做乐观更新——即使上游静默失败也不会留下「已领取」的假象,成功后同时清掉该账号的福利缓存,按钮不会顶着旧状态赖着不走。
64
+ - **可选:被网关渠道校验拦截后的最后兜底「继续」(默认关闭,默认只发 1 次)** —— 上游用 `400` + 业务码 `11128` 拒绝时,这一轮就此结束:插件不会在原请求里重试(三级救援已跑完),不会换号(网关匹配的是消息内容,每个账号发出去都一样),宿主也不重试。**它不是 11128 的修复**:固定 system 指纹(#163 的 identity / Web GUI / @ 路径整段)每一轮逐字节相同,重发只会再付一次同一个 400;5.13.1 起这些由 `prepareChatBody` 的按句改词处理。只有被拒内容刚好随轮次变化的窄情况,才可能靠新回合碰过去。开启卡片「能力」栏的开关后,插件检测到这一类失败、等 300ms、用 `agent.followup` 发一条 `继续`(`source.kind` 是 `workbuddy-continue`,**不是** `user`)。**上限数的是「连续」次数**(`channelRejectContinue.maxConsecutive`,默认 **1**;旧默认 3 已证伪——1 次救不了,3 次也救不了):成功一轮、或你自己发一条消息,计数清零;插件自己发的不清零。子代理会话一律不碰;会话正在跑、或收件箱已有排队内容时也不发。**每次都会真实消耗一次请求**,所以默认关闭;它也是本插件里**唯一一个改完即生效**的上游类开关。与其它上游开关一样,也能直接在 `cordis.patch.yml` 里写:
65
+
66
+ ```yaml
67
+ channelRejectContinue:
68
+ enabled: true
69
+ maxConsecutive: 1
70
+ text: 继续
71
+ ```
72
+
64
73
  - **凭据失效时给出可执行的建议** —— 上游拒绝当前账号的 token 时,插件会**实际探测**本机其他账号:若有账号仍然可用,就直接告诉你「切换到它即可」(并附一键切换),而不是笼统地让你重新登录——被钉在一个作废的旧备份上时,重新登录并不能解决问题。只有本机确实没有别的账号可切换时,才会提示重新登录。
65
74
  - **扫码添加账号、批量导入导出、逐账号测试/删除** —— 不必先装 WorkBuddy 桌面 App:卡片里的「扫码登录」按钮(国际版为「网页登录」——那边是浏览器登录页,没有二维码;这一行就在「更多设置」上方,点一下立即生效,不需要保存)点开即弹出对话框,并在浏览器里**自动打开**授权页;被浏览器拦截时对话框内提供链接、复制与「重新打开页面」兜底。所有凭据统一落到插件自己的凭据库(`$DSH_HOME/workbuddy-vault/<region>/<accountId>.json`,一账号一文件),**桌面 App 的认证文件始终只读、永不被改动**。账号表格每一行右侧有「测试」与「删除」:测试与批量测试走同一套探测,结果可比;删除只删插件自己存的那份,并把这个账号**暂时隐藏**:删掉后立刻消失,也不会被自动轮询带回来;点「重新检测账号」即可找回(重新登录或导入同一账号也会清除隐藏记录)。**桌面 App 的登录始终不动**——删除是「本插件不再列出它」,不是退出 App 登录。「导出 / 导入账号」可把凭据搬到另一台机器:多选导出 JSON → 选文件后先看**脱敏预览**(只显示字段与是否有 token,明文不进预览)→ 勾选导入。导入按账号去重覆盖,**只有确认属于该区域**的记录才入库,认不出的域名会被跳过并如实计入「已跳过」。详见[账号池](#账号池)。
66
75
  - **积分信息在账号池里** —— 成员表逐账号显示剩余积分与最近到期时间,排序的第二键就是积分(谁多谁优先)。查询是**只读**的、不消耗积分。
@@ -312,6 +321,8 @@ dsh plugin --profile web exec dsh-connect-workbuddy status
312
321
 
313
322
  **这条路到底绕开了什么,说清楚。** 提供方那条路要让请求看起来像 CodeBuddy 客户端才能过网关的渠道校验,4.15.x 起那条校验已经升级成「按提示词中段做精确子串匹配」,靠句读处插零宽空格躲开——这是军备竞赛,而且不再免费:issue #98 里已经出现**账号级**限制(`code 11140`,「账号存在异常行为,已被限制使用」)。
314
323
 
324
+ > **2026-10-29 的 #163 复测更新**:零宽这条路已经被网关堵死——CN 网关在匹配前会规范化 U+200B,system 开头每个词边界都插零宽仍然 11128。现在改为**按句改词**(`rewriteChannelFingerprintPhrases`,落在 `neutralizeChannelFingerprints` 之前),只改命中句、其余逐字节不动,也不再需要把提示词伪装成第三方 agent。细节见 CHANGELOG 的 Unreleased。
325
+
315
326
  CLI 路线逃掉的是**客户端指纹层**,不是账号层。它用 `CODEBUDDY_AUTH_TOKEN` 把本插件 vault 里的同一个 WorkBuddy 账号交给 CLI,所以「跟网关说话的是真的 CodeBuddy CLI 进程、没有身份要伪造、因此账号不会被限制」这句话**不成立**:身份仍然是同一批账号,`11140` 是账号级的,同一批账号在 CLI 路线上一样受限。真正被绕开的只有「这条请求的客户端指纹像不像 CodeBuddy」——这一层是真实的收益(渠道校验不会再因为提示词里有句读而拒你),但它不等于「免封」。两条路是互补的容错,不是替代。
316
327
 
317
328
  ### 前提
@@ -498,8 +509,9 @@ else { await envelope.response.text() /* 或者按 SSE 消费 */ }
498
509
  - **本插件已在 4.9.0 起从插件侧绕开它**:两条路由注册的 **model 身份统一为 `workbuddy`**(`src/adapter.ts` 的 `WORKBUDDY_MODEL_IDENTITY`),也就是模型自己声明的 `provider` 字段两边相同,于是 `isSameModel` 成立、换区不再把 thinking 降级成正文。**路由 id 没有合并**——它仍是 `workbuddy` / `workbuddy-global`,各自指向自己的 shim、目录与凭据,所以一侧故障不会带走另一侧,模型选择器也仍是两组。**4.9.0/4.9.1 的这版修复漏了一半,国际版整区不可用(issue #75)**:上面的说法只对 `getModel(provider, id)` 这一条按**参数**查表的路径成立;真正跑聊天的 `models.streamSimple(model, …)` 第一步是 `requireProvider(model)` = `providers.get(model.provider)`(`models.js:351–355`),查的是**模型上盖的那个字段**。于是国内版(路由 `workbuddy`、盖 `workbuddy`)自洽,国际版(路由 `workbuddy-global`、盖 `workbuddy`)在只注册了 `workbuddy-global` 的注册表里查不到,**每一个请求都在发出前就抛 `Unknown provider: workbuddy`**——一次也会失败,且报错里不带任何 WorkBuddy 字样,看起来像宿主坏了。**4.9.2 起改为:每条路由把自己的注册表同时登记上「路由 id」和「共享身份」两个键**(`src/adapter.ts` 的 `modelIdentityKeysOf`),模型上盖的身份仍然两边相同(#59 不回退),而 `requireProvider` 在国际版自己的注册表里也能查到、并拿到**国际版自己的** provider 对象(含它自己的 shim 地址与凭据)。pi-ai 的注册表本来就是 `Map`(`models.js:37` 的 `providers.set(provider.id, provider)`,键取的是 **provider 对象的 `id`**,不是 profiles 映射的键),所以这是注册表原生支持的事;**路由集合、模型选择器、目录与凭据都没有变化**,多出来的那个键不出现在任何 UI 里。回归测试 `tests/issue75-global-provider-dispatch.spec.ts` 走的是宿主的真实构造(`createModels` + `setProvider` + `streamSimple`),并保留一个反证用例固定住旧的失败文案。详见 `docs/audit/ISSUE59-OPTIONS.md` 的「#75 CORRECTION」一节。
499
510
  - 升级后仍可能看到计数变化的部分:**在同一区域内换模型**仍会降级(`model` 那条腿是真的换了模型,thinking 签名未必对新模型有效,保留才是错的)——降级本身无法消除,但**它变成正文这件事可以**,见下面 `dropForeignThinking` 那一条;**升级之前就录在历史里的消息**带着旧的按区域 id,重放时仍按旧规则降级——这种历史只会越聊越少,不会反过来影响新录的消息。
500
511
  - **降级不只是计数问题:思考会变成正文,模型随后照抄这个格式(issue #147)。** 上一条说的是「thinking 被降级」对计费的影响,这条说的是它对**内容**的影响。pi-ai 对 `isSameModel` 不成立的消息**不是丢弃** thinking,而是把它改写成 `{type:'text'}`(`transform-messages.js:87–90`)——也就是答案通道。于是跨 provider 切换后,**另一个模型的内部工作笔记会作为本轮回答呈现**,模型读到「我过去的思考都是正文」这个形状后,接下来的每一轮都继续把计划写进正文。报告者在一轮真实会话里量到:80 条 assistant 消息中 56 条带正文而自身 `reasoning` ≤ 1 字符,其中 20 条是英文工作笔记、合计 58,960 字符;整轮 60,984 字符正文里 CJK 只剩 1.37%,而此前 6 轮的可见回复全是中文。它同时把会话从 696,966 推到 758,005 total token(压缩阈值 800,000)。
501
- - **开关 `dropForeignThinking`(默认关闭)**:打开后,本插件在请求发出前**删掉**那些「本来会被压成正文」的外来 thinking 块。之所以是删而不是保留:这些块由宿主以 `foreignAssistant()` 构造,**不带 `thinkingSignature`**,即使被当成自己的轮次也无法作为带签名的 reasoning 重放——它们对线上唯一的贡献就是被压成的那段正文。正文块与工具调用一律不动,每条消息的身份也不动。
502
- - 关闭时**一个字节都不变**,与之前版本完全一致——它改的是上游会数到的字节,所以按 `usageIdentity` 的同一条规矩做成显式 opt-in。
512
+ - **跨 provider 的 foreign thinking 无条件丢弃;开关 `dropForeignThinking` 只负责同一路由内换模型(默认关闭)**:本插件在请求发出前**删掉**那些「本来会被压成正文」的外来 thinking 块。之所以是删而不是保留:这些块由宿主以 `foreignAssistant()` 构造,**不带 `thinkingSignature`**,即使被当成自己的轮次也无法作为带签名的 reasoning 重放——它们对线上唯一的贡献就是被压成的那段正文。正文块与工具调用一律不动,每条消息的身份也不动。
513
+ - **跨 provider 不设开关**:换 provider 后宿主把另一家的 assistant 轮次重建成 `dsh-foreign`,pi-ai 会把它们的 thinking 摊成 assistant 正文;WorkBuddy 网关正好扫描 assistant 消息,于是整轮以 `11128` 被拒(issue #163 的现场:14,410 / 4,225 / 5,138 字的 reasoning 被当成正文重放,三次原地救援与三轮自动「继续」全失败)。这些块本来就不可能作为 reasoning 重放,丢弃是纠正而不是偏好,所以不等待用户 opt-in。`dropForeignThinking` 只把同样的修复扩展到**同一路由内换模型**(`hy3` → `deepseek-v4.1-flash`,两边都由本插件提供、都盖 `provider: workbuddy`);那一条的关闭路径仍**一个字节都不变**。
514
+ - 自动丢弃时写一条 warn(仅在开关关闭时),`latestForeignThinking` 仍照旧上报,日志里能看出这次请求删掉了哪些块。
503
515
  - 顺序很关键:修复跑在 `alignAssistantIdentities` **之后**。国际区自己的轮次到达时也带 `api: 'dsh-foreign'`(宿主的重放降级路径),先诊断会把它误判成外来轮次并删掉它自己的推理——那正是 #102 的反向回归。`tests/issue147-foreign-thinking.spec.ts` 用真实 pi-ai 的 `transformMessages` 钉住了这个顺序。
504
516
  - 已经录进历史的坏轮次**不会**被追溯修复:它们已经作为正文存在会话里,开关只管之后的请求。想彻底摆脱,切换 provider 后新开会话是最直接的办法。
505
517
  - **它同时覆盖「同一个区域内换模型」**(`hy3` → `deepseek-v4.1-flash`,两者都由本插件提供、都盖 `provider: workbuddy`)。这时 provider 与 api 两条腿都相等,判据只差 `model` 一条腿——5.12.0 的判定只比前两条,因此这种切换**一个块也检不出**,开关看起来无效。判断所需的 model id 由请求入口(`stream` / `streamSimple` 的 `model.id`)传入;不传时行为与 5.12.0 完全一致,卡片那条只读路径因此不受影响(它读的本来就是请求路径填好的 `lastForeignThinking`)。
@@ -526,6 +538,11 @@ else { await envelope.response.text() /* 或者按 SSE 消费 */ }
526
538
  - [Sliverkiss/workbuddy2api](https://github.com/Sliverkiss/workbuddy2api)(MIT)— WorkBuddy 上游协议(`copilot.tencent.com` 的 wire behavior)的参照实现,经 `dsh-workbuddy-connect` 转引。
527
539
  - [hawklithm/workbuddy2api](https://github.com/hawklithm/workbuddy2api)(MIT,Copyright (c) 2026 Mayer)— **本项目的致谢名单里唯一一项「代码移植」而非「思路参照」**:其 `src/codebuddy_proxy/dsml_parser.py` 是一份代理同一 WorkBuddy/CodeBuddy 上游的 DSML 工具调用解析器,本项目把它移植为 `src/dsml-recovery.ts`(标签状态机、忽略区域判定、缺包裹修复、参数递归解析、流式缓冲),并在其上补了它没有的两道闸(工具名必须在本次请求声明的工具里、无声明工具完全不做恢复)。三处有意偏离逐条写在该文件头部,义务声明见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
528
540
 
541
+ ### 自动续跑的语义参照
542
+
543
+ - [HsiangNianian/dsh-auto-continue](https://github.com/HsiangNianian/dsh-auto-continue)(MIT)— 「连续失败上限」这套语义的来源:发出时计数自增、达到上限停止、成功回合清零、跳过子代理会话。本项目的 `channelRejectContinue` 只取这层语义,未照搬其引擎。
544
+ - [YuJunZhiXue/dsh-purge](https://github.com/YuJunZhiXue/dsh-purge)(MIT)— 三处具体做法:哪些失败码不该续(配额/限流/传输)、收件箱有排队就不插队、以及用消息的 `source.kind` 区分真人介入与插件自己的消息。
545
+
529
546
  ### 插件呈现与结构的基线
530
547
 
531
548
  - [dingminhua/dsh-connect-trae](https://github.com/dingminhua/dsh-connect-trae)(MIT,Copyright (c) 2026 LaoDing)— 插件的呈现方式与其保持一致:设置卡片的结构与交互、模型管理(刷新 → 勾选 → 保存)与账号选择模型、只读的 host↔client 路由形态,以及 npm 发布工程。
@@ -35,6 +35,15 @@
35
35
  | `dsh-subagent-default-model` | <https://github.com/dingminhua/dsh-subagent-default-model> | `dsm-*` 卡片 CSS、`row.*` 文案键约定、品牌图标 | **MIT**(Copyright (c) 2026 LaoDing) |
36
36
  | `dsh-codex-connect` | <https://github.com/franksong2702/dsh-codex-connect> | DSH 插件结构与 pi-ai provider 注册的参照(经 `dsh-workbuddy-connect` 转引) | **Apache-2.0** |
37
37
 
38
+ ### 与「失败后自动续跑」相关(5.13.0 新增)
39
+
40
+ | 项目 | 仓库 | 参考内容 | 许可证 |
41
+ | --- | --- | --- | --- |
42
+ | `dsh-client-auto-continue` | <https://github.com/HsiangNianian/dsh-auto-continue> | 「连续失败上限」这一整套语义:发出时 `consecutive` 自增、达到 `maxConsecutive` 停止、成功回合清零、`session.header.origin === 'subagent'` 跳过子代理、用 `agent.followup(createUserMessage(...))` 投递。`src/channel-continue.ts` 只取这层语义,未照搬其约 1590 行的引擎(收尾原因矩阵、自适应退避、自主循环、循环守卫、通知) | **MIT** |
43
+ | `dsh-purge` | <https://github.com/YuJunZhiXue/dsh-purge> | `lib/continue-retry.js` 的三处做法:`NO_CONTINUE_CODES`(配额/限流/传输这类「重发也拿同一句回答」的失败不续)、`inboxBusy`(收件箱有排队就不插队)、以及用 `user/message` 的 `source.kind` 区分真人介入与插件自己的消息 | **MIT** |
44
+
45
+ > 两项均为**思路参照**,未复制其源码。本项目相对两者是收窄:只认 `channel_reject` 一种失败、不换号、不原地重试、不落盘状态。逐条差异写在 `src/channel-continue.ts` 的头部注释里(`docs/DESIGN.md` §5.3 要求)。
46
+
38
47
  ## 三、Apache-2.0 特别说明
39
48
 
40
49
  `dsh-codex-connect` 采用 **Apache License 2.0**,其义务重于 MIT。本项目对它的参考是**间接的**:经由 `dsh-workbuddy-connect` 转引其 DSH 插件结构与 provider 注册思路。即便如此,本项目仍遵守 Apache-2.0 第 4 条的要求:
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-guET27pw.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-BtMcdOIa.js";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { realpathSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
package/lib/client.js CHANGED
@@ -844,6 +844,41 @@ window.__ModuleLoader__.load({
844
844
  return [...seen];
845
845
  }
846
846
  /**
847
+ * The `channelRejectContinue` block's defaults, mirroring the Host schema.
848
+ *
849
+ * Duplicated here rather than imported from the Host entry on purpose: this
850
+ * module is the browser half's node-free boundary (`src/index.ts` pulls in the
851
+ * whole provider stack), and the two values must agree, which a test pins.
852
+ */
853
+ const CHANNEL_CONTINUE_DEFAULTS = {
854
+ enabled: false,
855
+ maxConsecutive: 1,
856
+ text: "继续"
857
+ };
858
+ /**
859
+ * Read the `channelRejectContinue` block out of a settings snapshot.
860
+ *
861
+ * Same defensive shape as {@link sessionTitleOf}: anything missing or malformed
862
+ * resolves to the schema's own defaults, so the card shows what the plugin will
863
+ * actually do instead of an empty control — and an absent block reads as OFF,
864
+ * because enabling this spends real requests without the user typing anything.
865
+ *
866
+ * @param configured - the whole settings snapshot.
867
+ * @returns the three values the switch renders.
868
+ */
869
+ function channelRejectContinueOf(configured) {
870
+ const raw = unwrapVolatileDeep(configured?.["channelRejectContinue"]);
871
+ const block = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {};
872
+ const max = block["maxConsecutive"];
873
+ const capped = typeof max === "number" && Number.isFinite(max) ? Math.min(10, Math.max(1, Math.trunc(max))) : CHANNEL_CONTINUE_DEFAULTS.maxConsecutive;
874
+ const text = block["text"];
875
+ return {
876
+ enabled: block["enabled"] === true,
877
+ maxConsecutive: capped,
878
+ text: typeof text === "string" && text.trim() !== "" ? text.trim() : CHANNEL_CONTINUE_DEFAULTS.text
879
+ };
880
+ }
881
+ /**
847
882
  * Write one upstream-identity switch, verifying it landed.
848
883
  *
849
884
  * Same two writers as {@link writeCliBridgeEnabled} and for the same measured
@@ -876,7 +911,7 @@ window.__ModuleLoader__.load({
876
911
  }
877
912
  if (delivered) {
878
913
  const after = scope.getSnapshot().value;
879
- if (field === "usageIdentity" || field === "keepPromptLast" ? upstreamSwitchOf(after, field) === value : field === "sessionTitle" ? JSON.stringify(sessionTitleOf(after)) === JSON.stringify(value) : JSON.stringify(extraInjectedPrefixesOf(after)) === JSON.stringify([...value])) return;
914
+ if (field === "usageIdentity" || field === "keepPromptLast" ? upstreamSwitchOf(after, field) === value : field === "sessionTitle" ? JSON.stringify(sessionTitleOf(after)) === JSON.stringify(value) : field === "channelRejectContinue" ? JSON.stringify(channelRejectContinueOf(after)) === JSON.stringify(value) : JSON.stringify(extraInjectedPrefixesOf(after)) === JSON.stringify([...value])) return;
880
915
  }
881
916
  throw hostError instanceof Error ? hostError : new WorkBuddySettingsWriteError(field, "neither the Host save endpoint nor the settings scope persisted the value");
882
917
  }
@@ -5041,7 +5076,7 @@ window.__ModuleLoader__.load({
5041
5076
  CONTRAST colour, so a ticked box painted itself as a heavy black pill — the
5042
5077
  loudest object in a section made of plain 13px text. */
5043
5078
  .dsm-workbuddy-pref input{flex:none;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
5044
- .dsm-workbuddy-pref-check,.dsm-workbuddy-image-tool-check,.dsm-workbuddy-video-tool-check,.dsm-workbuddy-cli-bridge-check,.dsm-workbuddy-upstream-check{inline-size:15px;block-size:15px;margin:3px 0 0}
5079
+ .dsm-workbuddy-pref-check,.dsm-workbuddy-image-tool-check,.dsm-workbuddy-video-tool-check,.dsm-workbuddy-cli-bridge-check,.dsm-workbuddy-upstream-check,.dsm-workbuddy-channel-continue-check{inline-size:15px;block-size:15px;margin:3px 0 0}
5045
5080
  /* The upstream-identity switches are their own group, so they get a divider
5046
5081
  above them instead of relying on spacing alone: they change the request the
5047
5082
  whole plugin sends, not a per-region display choice, and reading them as the
@@ -6655,6 +6690,8 @@ window.__ModuleLoader__.load({
6655
6690
  const [keepPromptLastBusy, setKeepPromptLastBusy] = (0, react.useState)(false);
6656
6691
  const [sessionTitleOn, setSessionTitleOn] = (0, react.useState)(void 0);
6657
6692
  const [sessionTitleBusy, setSessionTitleBusy] = (0, react.useState)(false);
6693
+ const [channelRejectContinueOn, setChannelRejectContinueOn] = (0, react.useState)(void 0);
6694
+ const [channelRejectContinueBusy, setChannelRejectContinueBusy] = (0, react.useState)(false);
6658
6695
  /**
6659
6696
  * Probe results, keyed by region then model id.
6660
6697
  *
@@ -7166,6 +7203,22 @@ window.__ModuleLoader__.load({
7166
7203
  /** The custom injected prefixes in effect, used only to REPORT their count. */
7167
7204
  const extraInjectedPrefixes = () => extraInjectedPrefixesOf(settingsScope?.getSnapshot().value);
7168
7205
  /**
7206
+ * The auto-continue block after a channel-gate refusal (see
7207
+ * `src/channel-continue.ts`), read the three-level way the other switches are.
7208
+ *
7209
+ * The cap and the text are read from the committed document rather than
7210
+ * mirrored in card state: only `enabled` has a control here, so a local copy of
7211
+ * the other two would be a second source of truth for values this card can
7212
+ * only READ.
7213
+ */
7214
+ const channelRejectContinueValue = () => {
7215
+ const committed = channelRejectContinueOf(settingsScope?.getSnapshot().value);
7216
+ return channelRejectContinueOn === void 0 ? committed : {
7217
+ ...committed,
7218
+ enabled: channelRejectContinueOn
7219
+ };
7220
+ };
7221
+ /**
7169
7222
  * Flip one upstream-identity switch.
7170
7223
  *
7171
7224
  * No `refreshUsage` afterwards, unlike the CLI switch: these change the shape
@@ -7175,13 +7228,16 @@ window.__ModuleLoader__.load({
7175
7228
  */
7176
7229
  const toggleUpstreamSwitch = async (field, enabled) => {
7177
7230
  if (settingsScope === void 0) return;
7178
- const setBusy = field === "usageIdentity" ? setUsageIdentityBusy : field === "keepPromptLast" ? setKeepPromptLastBusy : setSessionTitleBusy;
7179
- const setOwn = field === "usageIdentity" ? setUsageIdentityOn : field === "keepPromptLast" ? setKeepPromptLastOn : setSessionTitleOn;
7231
+ const setBusy = field === "usageIdentity" ? setUsageIdentityBusy : field === "keepPromptLast" ? setKeepPromptLastBusy : field === "sessionTitle" ? setSessionTitleBusy : setChannelRejectContinueBusy;
7232
+ const setOwn = field === "usageIdentity" ? setUsageIdentityOn : field === "keepPromptLast" ? setKeepPromptLastOn : field === "sessionTitle" ? setSessionTitleOn : setChannelRejectContinueOn;
7180
7233
  setBusy(true);
7181
7234
  try {
7182
7235
  await writeUpstreamSwitch(settingsScope, field, field === "sessionTitle" ? {
7183
7236
  ...sessionTitleOf(settingsScope.getSnapshot().value),
7184
7237
  enabled
7238
+ } : field === "channelRejectContinue" ? {
7239
+ ...channelRejectContinueOf(settingsScope.getSnapshot().value),
7240
+ enabled
7185
7241
  } : enabled);
7186
7242
  if (mounted.current) setOwn(enabled);
7187
7243
  } catch (error) {
@@ -8302,6 +8358,61 @@ window.__ModuleLoader__.load({
8302
8358
  })]
8303
8359
  }, field))]
8304
8360
  });
8361
+ /**
8362
+ * The opt-in auto-continue after a channel-gate refusal.
8363
+ *
8364
+ * Its own group, NOT a row in {@link upstreamSwitches} above, because it
8365
+ * breaks that group's one shared promise: those four are sampled when the
8366
+ * plugin mounts and need a reload, while this one is read on every failure and
8367
+ * takes effect immediately. Hiding that difference behind a shared hint would
8368
+ * teach the user the wrong rule for exactly the switch where being wrong is
8369
+ * expensive — this one spends real requests.
8370
+ *
8371
+ * The cap and the text are REPORTED while the switch is on, not edited: both
8372
+ * already live in the config file, and the cap is the one number the user asked
8373
+ * to be exactly 3, so an unvalidated free-text field here would be a second way
8374
+ * to get it wrong.
8375
+ */
8376
+ const channelRejectContinuePreference = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
8377
+ className: "dsm-workbuddy-prefs",
8378
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
8379
+ className: "dsm-workbuddy-pref dsm-workbuddy-upstream-pref",
8380
+ title: t("row.channelRejectContinueHint"),
8381
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
8382
+ type: "checkbox",
8383
+ className: "dsm-workbuddy-channel-continue-check",
8384
+ checked: channelRejectContinueValue().enabled,
8385
+ disabled: !canWrite || saving || poolBusy || channelRejectContinueBusy,
8386
+ onChange: (event) => {
8387
+ toggleUpstreamSwitch("channelRejectContinue", event.target.checked);
8388
+ }
8389
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
8390
+ className: "dsm-workbuddy-pref-copy",
8391
+ children: [
8392
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
8393
+ className: "dsm-workbuddy-pref-label",
8394
+ children: t("row.channelRejectContinueLabel")
8395
+ }),
8396
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
8397
+ className: "dsm-workbuddy-pref-hint",
8398
+ children: t("row.channelRejectContinueHint")
8399
+ }),
8400
+ channelRejectContinueValue().enabled ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
8401
+ className: "dsm-workbuddy-pref-hint",
8402
+ children: [
8403
+ t("row.channelRejectContinueLimit"),
8404
+ " ",
8405
+ String(channelRejectContinueValue().maxConsecutive),
8406
+ " · ",
8407
+ t("row.channelRejectContinueText"),
8408
+ " ",
8409
+ channelRejectContinueValue().text
8410
+ ]
8411
+ }) : null
8412
+ ]
8413
+ })]
8414
+ })
8415
+ });
8305
8416
  const creditPreferences = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
8306
8417
  className: "dsm-workbuddy-prefs",
8307
8418
  children: [
@@ -8866,7 +8977,8 @@ window.__ModuleLoader__.load({
8866
8977
  children: t("row.panelCapabilitiesHint")
8867
8978
  }),
8868
8979
  creditPreferences,
8869
- upstreamSwitches
8980
+ upstreamSwitches,
8981
+ channelRejectContinuePreference
8870
8982
  ]
8871
8983
  }),
8872
8984
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -11038,6 +11150,10 @@ window.__ModuleLoader__.load({
11038
11150
  "row.sessionTitleHint": "The host fires one title request per new session and bills it at the main model rate. This re-points that one request at a hidden lightweight model. The request still happens either way. Off by default.",
11039
11151
  "row.sessionTitleModel": "Target model:",
11040
11152
  "row.extraPrefixesCount": "Custom injected prefixes in effect:",
11153
+ "row.channelRejectContinueLabel": "Resend 「继续」 after the gateway refuses the request",
11154
+ "row.channelRejectContinueHint": "LAST RESORT, not a fix for 11128. When the gateway answers 400 with code 11128 (「被网关的渠道校验拦截」), the turn just fails: the plugin never retries it in place and never swaps accounts, because the gate matches the message contents and every account sends the same ones. Fixed system fingerprints are byte-identical every turn, so resending only pays for the same 400 again; since 5.13.1 `prepareChatBody` rewrites known phrases instead. The narrow case this can help is content that changes between turns. It sends the text below as a NEW turn and spends a real request each time. Stops after N CONSECUTIVE sends (default 1); one successful turn, or one message you type yourself, resets the count. Subagent sessions are never touched. Off by default. Takes effect immediately, no reload.",
11155
+ "row.channelRejectContinueLimit": "Max consecutive sends:",
11156
+ "row.channelRejectContinueText": "Text sent:",
11041
11157
  "row.cliBridgeLabel": "Let the local CodeBuddy CLI do the work",
11042
11158
  "row.cliBridgeHint": "Adds the codebuddy_ask tool and the /codebuddy command. Each call spends CodeBuddy quota. injectResult only affects /codebuddy — its result is injected back into the session, costing one DSH turn when the session is idle; tool calls never read it. Rate-limited: 6 turns per session per hour, 20 per process per hour, one turn at a time. Off by default.",
11043
11159
  "row.cliBridgeModelLabel": "CLI model",
@@ -11468,6 +11584,10 @@ window.__ModuleLoader__.load({
11468
11584
  "row.sessionTitleHint": "宿主每个新会话都会发一次标题请求,并按主模型费率计费;开启后这一次改投隐藏的轻量模型。请求无论开不开都会发出。默认关闭。",
11469
11585
  "row.sessionTitleModel": "目标模型:",
11470
11586
  "row.extraPrefixesCount": "生效的自定义注入前缀:",
11587
+ "row.channelRejectContinueLabel": "被网关渠道校验拦截后,自动再发一次「继续」",
11588
+ "row.channelRejectContinueHint": "最后兜底,不是 11128 的修复。上游用 400 + code 11128(「被网关的渠道校验拦截」)拒绝时,这一轮就直接失败了:插件不会在原地重试,也不会换号——网关匹配的是消息内容,每个账号发出去都一样。固定 system 指纹每一轮逐字节相同,重发只会再付一次同一个 400;5.13.1 起由 `prepareChatBody` 按句改词处理。只有被拒内容刚好随轮次变化的窄情况才可能靠新回合碰过去。它会把下面这句作为新回合发出,每次真实消耗一次请求;默认只发 1 次、默认关闭;连续计数,成功一轮或你自己发一条消息即清零。子代理会话一律不碰,改完立即生效,不必重载插件。",
11589
+ "row.channelRejectContinueLimit": "连续发送上限:",
11590
+ "row.channelRejectContinueText": "发送的文本:",
11471
11591
  "row.cliBridgeLabel": "允许调用本机 CodeBuddy CLI 干活",
11472
11592
  "row.cliBridgeHint": "开启后多出 codebuddy_ask 工具与 /codebuddy 指令;每次调用花 CodeBuddy 配额。injectResult 只影响 /codebuddy:其结果回注会话,会话空闲时多花一次 DSH 回合;工具调用不读它。限流:每会话每小时 6 回合、每进程每小时 20 回合、同时只跑一个回合。默认关闭。",
11473
11593
  "row.cliBridgeModelLabel": "CLI 模型",
@@ -3503,6 +3503,91 @@ function scansOpening(role) {
3503
3503
  return name === "system" || name === "assistant";
3504
3504
  }
3505
3505
  /**
3506
+ * The gateway's current content fingerprints, and same-meaning rewrites for
3507
+ * them (issue #163).
3508
+ *
3509
+ * WHY REWRITE INSTEAD OF ZERO-WIDTH. On 2026-10-29 two independent probes
3510
+ * measured that the CN gateway normalizes invisible characters before matching:
3511
+ * the DSH identity with U+200B at every word boundary still answers 11128.
3512
+ * `breakIdentityClauses` and both perturbation rungs therefore cannot help this
3513
+ * family. A real wording change is what the same probes measured as 200.
3514
+ *
3515
+ * WHY SENTENCE-LEVEL, NOT ONE WHOLE-PARAGRAPH LITERAL. The third fingerprint is
3516
+ * an eight-sentence, roughly 800-character @-path paragraph. Two reporters
3517
+ * measured that each sentence alone answers 200 and the whole paragraph answers
3518
+ * 400 — it is a COMBINATION, not a single listed sentence. Replacing the
3519
+ * paragraph as one literal would stop matching the moment the host changes one
3520
+ * word; replacing every sentence breaks the combination and keeps working when
3521
+ * only some of the text moves. The rows below are the table proposed in issue
3522
+ * #163 (yicneggima-glitch, 18:56; QIYU-PLUS, 18:57), with the identity and Web
3523
+ * GUI rows both probes measured.
3524
+ *
3525
+ * `from` strings are matched ANYWHERE, not just at an opening, because the
3526
+ * measured rule is position-independent. Only a hit is replaced; every other
3527
+ * byte is left alone, so an ordinary conversation is unchanged.
3528
+ */
3529
+ const CHANNEL_GATE_PHRASES = [
3530
+ ["You are an AI agent powered by DeepSeek Harness", "You are an AI agent running inside DeepSeek Harness"],
3531
+ ["You are a coding agent powered by the ", "You are a coding agent running on the "],
3532
+ ["You are interacting with the user through the DeepSeek Harness Web GUI", "You are working with the user inside the DeepSeek Harness Web GUI"],
3533
+ ["Agent Teams is available in this session", "Agent Teams is enabled for this session"],
3534
+ ["Tokens prefixed with @ are paths the user explicitly referenced.", "Paths that begin with @ are the ones the user explicitly referenced."],
3535
+ ["Relative paths resolve from the workspace root;", "Relative paths are resolved against the workspace root;"],
3536
+ ["absolute paths identify files or directories on the host.", "absolute paths point at files or directories on the host."],
3537
+ ["A trailing slash marks a directory:", "A trailing slash indicates a directory:"],
3538
+ ["list it when its contents matter.", "list it whenever its contents matter."],
3539
+ ["Anything else is a file:", "Everything else is a file:"],
3540
+ ["use the read tool when its contents are needed,", "use the read tool whenever you need its contents,"],
3541
+ ["and do not claim to have inspected it before reading.", "and never claim to have inspected it before reading it."],
3542
+ ["@\"...\" quotes a path containing spaces.", "A path containing spaces is quoted with @\"...\"."]
3543
+ ];
3544
+ /**
3545
+ * Rewrite the measured gate phrases in one text part.
3546
+ *
3547
+ * Exported for the tests and for callers that build a body themselves. It is
3548
+ * idempotent: every `to` string is different from its `from` string and no
3549
+ * rewrite recreates another `from` string, so a second pass is a no-op.
3550
+ */
3551
+ function rewriteChannelFingerprintPhrases(text) {
3552
+ let out = text;
3553
+ for (const [from, to] of CHANNEL_GATE_PHRASES) if (out.includes(from)) out = out.replaceAll(from, to);
3554
+ return out;
3555
+ }
3556
+ /**
3557
+ * Apply {@link rewriteChannelFingerprintPhrases} to every scanned text part.
3558
+ *
3559
+ * `system` and `assistant` only (the roles the gate reads). `user` and `tool`
3560
+ * are deliberately untouched: QIYU-PLUS re-measured both on 2026-10-29 and
3561
+ * both answer 200 with the DSH identity at an opening, and rewriting a user's
3562
+ * own words would change the conversation for no gate.
3563
+ */
3564
+ function rewriteChannelFingerprintPhrasesInBody(obj) {
3565
+ const messages = obj["messages"];
3566
+ if (!Array.isArray(messages)) return;
3567
+ for (const value of messages) {
3568
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
3569
+ const message = value;
3570
+ if (!scansOpening(message["role"])) continue;
3571
+ const content = message["content"];
3572
+ if (typeof content === "string") {
3573
+ const next = rewriteChannelFingerprintPhrases(content);
3574
+ if (next !== content) message["content"] = next;
3575
+ continue;
3576
+ }
3577
+ if (!Array.isArray(content)) continue;
3578
+ for (const part of content) {
3579
+ if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
3580
+ const record = part;
3581
+ const type = record["type"];
3582
+ if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
3583
+ const text = record["text"];
3584
+ if (typeof text !== "string") continue;
3585
+ const next = rewriteChannelFingerprintPhrases(text);
3586
+ if (next !== text) record["text"] = next;
3587
+ }
3588
+ }
3589
+ }
3590
+ /**
3506
3591
  * Move every scanned message (`system` and `assistant`) off the content
3507
3592
  * fingerprint above by prepending the placeholder head.
3508
3593
  *
@@ -4153,6 +4238,7 @@ function prepareChatBody(source, options) {
4153
4238
  }
4154
4239
  ensureSystemHead(obj);
4155
4240
  if (options?.keepPromptLast === true && Array.isArray(obj["messages"])) keepPromptLast(obj["messages"], injectedPrefixPattern(options.extraInjectedPrefixes));
4241
+ rewriteChannelFingerprintPhrasesInBody(obj);
4156
4242
  neutralizeChannelFingerprints(obj);
4157
4243
  normalizeToolChoice(obj);
4158
4244
  dropRefusedReasoning(obj);
@@ -7711,7 +7797,7 @@ function describeRosterSeenPayload(raw) {
7711
7797
  //#endregion
7712
7798
  //#region src/version.ts
7713
7799
  /** The npm package version this build was produced from. */
7714
- const WORKBUDDY_CONNECT_VERSION = "5.12.4";
7800
+ const WORKBUDDY_CONNECT_VERSION = "5.13.1";
7715
7801
  //#endregion
7716
7802
  //#region src/host-heartbeat.ts
7717
7803
  /**
package/lib/index.d.ts CHANGED
@@ -2334,12 +2334,21 @@ interface WorkBuddyAdapterOptions {
2334
2334
  */
2335
2335
  piAiGeneration?: PiAiGeneration;
2336
2336
  /**
2337
- * Drop foreign thinking blocks pi-ai would publish as prose (issue #147).
2338
- *
2339
- * OFF by default, and read LIVE (a getter, not a boolean) so the switch takes
2340
- * effect on the next request without reloading the plugin: turning it on
2341
- * mid-session is the whole point — the user sees the working-notes turn,
2342
- * flips the switch, and the next turn is answered normally.
2337
+ * Drop SAME-ROUTE foreign thinking blocks pi-ai would publish as prose
2338
+ * (issue #147).
2339
+ *
2340
+ * CROSS-PROVIDER turns are dropped unconditionally: after a provider switch
2341
+ * the host rebuilds the other provider's assistant turns as `dsh-foreign`,
2342
+ * their unsigned thinking blocks can never be replayed, and pi-ai would
2343
+ * otherwise flatten them into assistant prose that the WorkBuddy gateway
2344
+ * refuses with 11128 (issue #163's measured scene). This switch only widens
2345
+ * the repair to a model switch INSIDE one of this bundle's own routes.
2346
+ *
2347
+ * OFF by default for that same-route leg, and read LIVE (a getter, not a
2348
+ * boolean) so the switch takes effect on the next request without reloading
2349
+ * the plugin: turning it on mid-session is the whole point — the user sees
2350
+ * the working-notes turn, flips the switch, and the next turn is answered
2351
+ * normally.
2343
2352
  */
2344
2353
  dropForeignThinking?: () => boolean;
2345
2354
  /**
@@ -5200,6 +5209,37 @@ interface Config {
5200
5209
  */
5201
5210
  model?: string;
5202
5211
  };
5212
+ /**
5213
+ * 被网关渠道校验拦截(`channel_reject` / 11128)之后自动再发一次「继续」,
5214
+ * 可选、有上限。默认关闭。
5215
+ *
5216
+ * 这条失败**不换号也不重试**(`isFailoverWorthy` 刻意把它排除在外:网关匹配的
5217
+ * 是消息开头,换账号只会每个账号白烧一次请求),所以这一轮就此结束,用户看到的
5218
+ * 是「本轮运行失败400: … 被网关的渠道校验拦截 …」。固定指纹由 `prepareChatBody`
5219
+ * 的按句改词处理;这条开关只是**最后兜底**:只有被拒内容刚好随轮次变化的窄情况
5220
+ * 才可能靠新回合碰过去。固定 system 指纹每轮相同,重发也救不了(#163 实测)。
5221
+ *
5222
+ * 默认关闭的理由与 {@link Config.usageIdentity} 相同:它会在用户没有发消息的
5223
+ * 情况下花掉真实请求,属于必须显式授权的开销。上限默认 1 次:1 次救不了,3 次也
5224
+ * 救不了,旧默认 3 只会把同一个 400 付三遍。
5225
+ */
5226
+ channelRejectContinue?: {
5227
+ /**
5228
+ * 是否启用自动续发。只有显式 `true` 才算开启。
5229
+ */
5230
+ enabled?: boolean;
5231
+ /**
5232
+ * 连续自动续发的次数上限(默认 1,夹在 1..10)。
5233
+ *
5234
+ * 「连续」是关键:一次成功回合、或者用户自己发了一条消息,计数立刻清零。
5235
+ * 所以上限拦的是「一直过不去就一直试」,而不是这个会话一生的总次数。
5236
+ */
5237
+ maxConsecutive?: number;
5238
+ /**
5239
+ * 续发时发送的文本,默认「继续」。
5240
+ */
5241
+ text?: string;
5242
+ };
5203
5243
  }
5204
5244
  /**
5205
5245
  * 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 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";
1
+ import { $ as parseCreditMultiplier, $t as withFileLock, A as isEncryptedCredentialError, At as pickMediaModel, B as WorkBuddyCredentialRejectedError, Bt as deriveAtRestKeyId, C as WorkBuddyCredentialStore, Ct as IMAGE_TAG_TEXT_TO_IMAGE, D as defaultDesktopAuthDirs, Dt as imageMimeForPath, E as defaultDesktopAuthCandidates, Et as imageDataUrl, F as workbuddyAccountId, Ft as imageDefaultFor, G as forceNeutralHead, Gt as isWorkbuddyBundle, H as channelGateMemberOf, Ht as findWorkbuddyAppExecutable, I as workbuddyOwnAuthPath, It as nativeModalityOf, J as isCredentialRejectedError, Jt as openEncryptedField, K as isContextTooLongBody, Kt as macosBundleExecutable, L as CONTEXT_OVERFLOW_MARKER, Lt as WORKBUDDY_APP_EXECUTABLE_ENV, M as legacyWorkbuddyOwnAuthPath, Mt as parseQueueAnswer, N as parseWorkBuddyAuth, Nt as queueNoticeText, O as defaultDesktopAuthPath, Ot as isBareBase64Image, P as preferredAccountName, Pt as NATIVE_MODALITY_BY_MODEL_ID, Q as openingEvidenceOfBody, Qt as resolveDshHome, R as CREDENTIAL_REJECTED_CODE, Rt as clearAtRestKeyCache, S as WORKBUDDY_AUTH_FILE_ENV, St as IMAGE_TAG_IMAGE_TO_IMAGE, T as authFileName, Tt as WorkBuddyImageError, U as classifyUpstreamError, V as WorkBuddyUpstreamClient, Vt as fetchAtRestKeyPayload, W as declaredTools, Wt as isEncryptedFieldWrapper, X as knownRegionOf, Xt as workbuddyAppExecutableCandidates, Y as isFirstMessageGateBody, Yt as readAtRestKey, Z as modelIdOfBody, _ as deriveCatalog, _t as WorkBuddyVideoError, a as readHostHeartbeat, at as requestEvidenceOf, b as ENCRYPTED_CREDENTIAL_CODE, bt as resolveVideoParams, c as WORKBUDDY_CONNECT_VERSION, ct as REFUSES_OFF_MODEL_IDS, dt as offDefaultFor, en as writeFileAtomic, et as parseReasoning, f as piAiRuntimeInfo, ft as offRefused, g as applyContextBudgets, gt as VIDEO_TAG_TEXT_TO_VIDEO, h as WorkBuddyCatalog, ht as VIDEO_TAG_IMAGE_TO_VIDEO, i as processStartTimeMs, it as regionOf, j as isVaultAccountId, jt as sniffImageMime, k as hasEncryptedCredentialFields, kt as parseImageDataUrl, l as createFileRosterSeenStore, lt as defaultOffModelIds, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, mt as withEffectiveOff, n as clearHostHeartbeat, nt as perturbChannelFingerprints, o as workbuddyHostHeartbeatPath, ot as rewriteSessionTitleModel, p as FALLBACK_WORKBUDDY_MODELS, pt as recordRefusal, q as isConversationIdGateBody, qt as macosNestedAppBundles, r as isHeartbeatProcessAlive, rt as prepareChatBody, s as writeHostHeartbeat, st as attachBusyLevels, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as parseUpstreamModel, ut as lastRefusalFor, v as fallbackModelsFor, vt as parseSupportedSecondsRange, w as WorkBuddyEncryptedCredentialError, wt as MAX_INPUT_IMAGE_BYTES, x as WORKBUDDY_AUTH_FILENAME, xt as DEFAULT_IMAGE_SIZE, y as withoutRouters, yt as pickVideoModel, z as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, zt as deriveAtRestKey } from "./host-heartbeat-BtMcdOIa.js";
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";
@@ -1745,6 +1745,39 @@ function isForeignAssistant(message, identity, requestModelId) {
1745
1745
  return typeof model === "string" && model !== requestModelId;
1746
1746
  }
1747
1747
  /**
1748
+ * Whether this assistant turn came from a DIFFERENT PROVIDER/route, rather than
1749
+ * from a different model on one of this bundle's own routes.
1750
+ *
1751
+ * WHY THIS DISTINCTION EXISTS (issue #163's live scene). A session switched
1752
+ * from `dsh-opencode-go` to a WorkBuddy route mid-turn. The host rebuilt the
1753
+ * other provider's assistant turns as `dsh-foreign`, and pi-ai rewrote their
1754
+ * unsigned `thinking` blocks into visible `text` — 14,410 / 4,225 / 5,138
1755
+ * characters in the measured scene. WorkBuddy's gateway scans every assistant
1756
+ * message and refused the turn with business code 11128; all three in-place
1757
+ * rescue rungs and three auto-`继续` attempts failed because the bytes never
1758
+ * changed.
1759
+ *
1760
+ * A cross-provider thinking block is different from a same-route model switch:
1761
+ * it carries no `thinkingSignature` (the host builds `foreignAssistant()`
1762
+ * without one), so it could never be replayed as reasoning. Its only possible
1763
+ * contribution to the wire is the prose pi-ai manufactures from it. Dropping it
1764
+ * is therefore a correctness repair, not a preference — and it must happen
1765
+ * even when the user never opted into {@link dropForeignThinking}.
1766
+ *
1767
+ * Same-route model switches (`hy3` → `deepseek-v4.1-flash`) stay behind the
1768
+ * explicit switch, because those turns WERE produced by this bundle and their
1769
+ * reasoning may be worth keeping in the transcript for the user to read.
1770
+ */
1771
+ function isCrossProviderAssistant(message, identity, ownedRoutes) {
1772
+ if (typeof message !== "object" || message === null) return false;
1773
+ const record = message;
1774
+ if (record["role"] !== "assistant") return false;
1775
+ if (record["api"] === "dsh-foreign") return true;
1776
+ const provider = record["provider"];
1777
+ if (typeof provider !== "string") return true;
1778
+ return provider !== identity && !ownedRoutes.includes(provider);
1779
+ }
1780
+ /**
1748
1781
  * Every foreign thinking block pi-ai would turn into visible prose (issue #147).
1749
1782
  *
1750
1783
  * THE DEFECT. pi-ai keeps a historical `thinking` block only while
@@ -1801,38 +1834,26 @@ function foreignThinkingOf(context, identity = WORKBUDDY_MODEL_IDENTITY, request
1801
1834
  return found;
1802
1835
  }
1803
1836
  /**
1804
- * Remove the foreign thinking blocks {@link foreignThinkingOf} finds.
1805
- *
1806
- * WHY REMOVAL IS THE HONEST REPAIR. The alternatives are both worse:
1807
- *
1808
- * - LEAVE IT: pi-ai publishes the other provider's private working notes as
1809
- * this turn's answer (the #147 defect), so the user reads reasoning instead
1810
- * of a reply and the model imitates that shape forever after.
1811
- * - KEEP IT AS THINKING: impossible from here. `isSameModel` is pi-ai's own
1812
- * predicate over fields this plugin does not control (`model.provider` /
1813
- * `model.api` / `model.id` are compared against the MESSAGE's), and the
1814
- * only way to satisfy it is to claim the foreign turn as this model's own
1815
- * output — which is exactly the provenance lie issue #111 removed. These
1816
- * blocks carry no `thinkingSignature` (the host builds them that way), so
1817
- * they could not be replayed as signed reasoning even if claimed.
1818
- *
1819
- * Dropping is what the request builder already does with them in every other
1820
- * branch: an unsigned foreign thinking block is never sent as
1821
- * `reasoning_content`, so its only possible contribution to the wire was the
1822
- * flattened prose this function removes. Every other block — text, tool calls —
1823
- * is left exactly where it is, and so is every message's identity.
1837
+ * The subset of {@link foreignThinkingOf} that is safe to repair without asking.
1824
1838
  *
1825
- * Idempotent: a second pass finds nothing to remove. Returns the SAME context
1826
- * object when nothing changed, so the common case allocates nothing.
1839
+ * This is the predicate the adapter uses when `dropForeignThinking` is OFF: a
1840
+ * turn from another provider/route is dropped unconditionally, because its
1841
+ * unsigned thinking block can never be replayed and its flattened prose is what
1842
+ * the WorkBuddy gateway refuses (issue #163's provider-switch scene). A turn
1843
+ * from this bundle under a different model id is NOT included — that one is
1844
+ * still the explicit switch's business.
1827
1845
  *
1828
- * @param context - a pi-ai context.
1829
- * @param identity - the identity this adapter stamps on its own models.
1830
- * @param requestModelId - model id the request is addressed to (see
1831
- * {@link isForeignAssistant}); omitted, only the provider/api legs are judged.
1832
- * @returns the repaired context, or the input when no block qualified.
1846
+ * Runs after `alignAssistantIdentities`, so a global-region turn this bundle
1847
+ * owns has already had its `dsh-foreign` marker repaired away and is not
1848
+ * misclassified.
1833
1849
  */
1834
- function dropForeignThinking(context, identity = WORKBUDDY_MODEL_IDENTITY, requestModelId) {
1835
- const affected = foreignThinkingOf(context, identity, requestModelId);
1850
+ function crossProviderThinkingOf(context, identity = WORKBUDDY_MODEL_IDENTITY, ownedRoutes = [WORKBUDDY_PROVIDER, WORKBUDDY_GLOBAL_PROVIDER]) {
1851
+ const messages = context.messages;
1852
+ if (!Array.isArray(messages)) return [];
1853
+ return foreignThinkingOf(context, identity).filter((block) => isCrossProviderAssistant(messages[block.messageIndex], identity, ownedRoutes));
1854
+ }
1855
+ /** Remove exactly the listed blocks; the one place that edits a transcript. */
1856
+ function dropThinkingBlocks(context, affected) {
1836
1857
  if (affected.length === 0) return context;
1837
1858
  const messages = context.messages;
1838
1859
  const byMessage = /* @__PURE__ */ new Map();
@@ -1860,13 +1881,12 @@ function withLegacyContext(api, generation, identity = WORKBUDDY_MODEL_IDENTITY,
1860
1881
  /** One place so both branches and both entry points agree on the order. */
1861
1882
  const prepare = (context, requestModelId) => {
1862
1883
  const aligned = alignAssistantIdentities(generation === "modern" ? foldModernContext(context) : adaptLegacyPiAiContext(context), identity, ownedRoutes);
1863
- if (repair?.dropForeignThinking !== true) return aligned;
1864
- const affected = foreignThinkingOf(aligned, identity, requestModelId);
1884
+ const affected = repair?.dropForeignThinking === true ? foreignThinkingOf(aligned, identity, requestModelId) : crossProviderThinkingOf(aligned, identity, ownedRoutes);
1865
1885
  if (affected.length === 0) return aligned;
1866
1886
  try {
1867
- repair.onForeignThinking?.(affected);
1887
+ repair?.onForeignThinking?.(affected);
1868
1888
  } catch {}
1869
- return dropForeignThinking(aligned, identity, requestModelId);
1889
+ return dropThinkingBlocks(aligned, affected);
1870
1890
  };
1871
1891
  if (generation === "modern") return {
1872
1892
  ...api,
@@ -2212,6 +2232,368 @@ async function foldOnce(deps, agent, measurement, window, target) {
2212
2232
  return false;
2213
2233
  }
2214
2234
  //#endregion
2235
+ //#region src/channel-continue.ts
2236
+ /**
2237
+ * 「被网关渠道校验拦截」时自动再发一次「继续」——可选、有上限。
2238
+ *
2239
+ * ## 这条功能要解决的那一次失败
2240
+ *
2241
+ * 用户粘贴的原文:
2242
+ *
2243
+ * 本轮运行失败400: {"message":"workbuddy 上游失败:被网关的渠道校验拦截(不是账号、
2244
+ * 额度或网络问题)(http 400)(code 11128,requestId …)。…",
2245
+ * "type":"channel_reject","code":"channel_reject"}
2246
+ *
2247
+ * 它被 `classifyUpstreamError` 判成 `channel_reject`(`src/upstream.ts`),而这个类
2248
+ * **刻意不换号**(`isFailoverWorthy`,`src/shim.ts`):网关匹配的是请求里消息的开头,
2249
+ * 换账号只会每个账号白烧一次真实请求。所以插件在同一请求内跑完三级救援阶梯
2250
+ * (`forceNeutralHead` → 一次零宽扰动 → 密集零宽扰动)之后,就把 400 原样交给宿主。
2251
+ *
2252
+ * 宿主也不会替我们重发:`channel_reject` 走的是 400,`code` 又是它不认识的字符串,
2253
+ * 不在 `DEFAULT_RETRYABLE_CODES` 里,于是这一轮直接结束,用户看到的正是上面那句。
2254
+ *
2255
+ * ## 为什么它过去看起来可能成功(只对随轮次变化的窄情况)
2256
+ *
2257
+ * `docs/PINNED-11128-RETRY.md` §2 记录了两条互相矛盾的事实:插件自己在
2258
+ * `perturbChannelFingerprints` 旁的注释说「给定提示词被拒是确定性的」,而现场报告
2259
+ * (#87–#98、#113)反复出现「把这条消息再发一次就过了」。二者都能成立,原因是**每
2260
+ * 一次新回合都是一份新的请求**:宿主会把当前 surface 重新组装、注入块的位置与内容
2261
+ * 可能变化、插件也可能因为学到偏好而从第一发就带扰动。所以「重发一次」不是把被拒
2262
+ * 的字节原样再塞一遍(那才是真正的浪费),而是**开一个新回合**。
2263
+ *
2264
+ * #163 的 2026-10-29 实测把这条解释收窄了:固定 system 指纹每轮逐字节相同,新回合
2265
+ * 换不掉它;只有被拒内容恰好落在随轮次变化的段上时才可能碰过。不要把它当成
2266
+ * 「11128 只要重发就能过」的承诺。
2267
+ *
2268
+ * 本模块就是把这个「手动再发一次」自动化,并且**只做这件事**:
2269
+ *
2270
+ * - 只认 `channel_reject` 这一种失败。配额、限流、网络、内容审核都有各自的处置,
2271
+ * 那些失败再发「继续」只会多烧一次请求(`NO_CONTINUE_CODES` 的同一判断)。
2272
+ * - 上限默认 **1** 次,**连续**计数:一次成功回合或一条真实用户消息立即清零。
2273
+ * 旧默认是 3;#163 的实测把「新回合会换掉被拒内容」证伪了(固定 system 指纹每轮
2274
+ * 相同,3 次只是把同一个 400 付三遍),所以一次足够:1 次救不了,3 次也救不了。
2275
+ * - 不做换号、不换区域、不改请求内容、不重试同一份字节:换号对这类拒绝无效,而
2276
+ * 篡改请求才是这条功能不该碰的东西。
2277
+ * - 子代理会话一律不碰:子代理由父代理驱动,抢跑会打乱执行树。
2278
+ *
2279
+ * 它是**最后兜底**,不是 11128 的修复:固定指纹由 `prepareChatBody` 的按句改词处理;
2280
+ * 改词后仍 11128,多半是新的固定指纹,重发同样无效。只有「被拒内容刚好随轮次变化」
2281
+ * 的窄情况才可能靠它碰过去,所以默认关闭、默认只发 1 次。
2282
+ *
2283
+ * 参考:`dsh-client-auto-continue`(MIT,HsiangNianian/dsh-auto-continue)—
2284
+ * 「连续失败上限」这套**语义**取自它:`state.consecutive` 在每次发出时自增、
2285
+ * `consecutive >= maxConsecutive` 就停、只在真正成功的回合清零、子代理会话按
2286
+ * `session.header.origin === 'subagent'` 跳过、发消息用
2287
+ * `agent.followup(createUserMessage(...))`。本模块**没有**照搬它的引擎:那个实现
2288
+ * 有 1590 行,覆盖收尾原因矩阵、自适应退避、自主循环、循环守卫与浏览器通知,而
2289
+ * 这里只需要「一种失败、一条消息、一个上限」。
2290
+ * 参考:`dsh-purge`(MIT,YuJunZhiXue/dsh-purge)的 `lib/continue-retry.js` —
2291
+ * 三处具体做法:**被排除的失败码清单**(`NO_CONTINUE_CODES`:配额、限流、传输
2292
+ * 这些「重发也拿同一句回答」的失败不该续)、**收件箱忙就不插队**(`inboxBusy`),
2293
+ * 以及用 `user/message` 的 `source.kind` 区分「真人介入」与「插件自己发的」,以免
2294
+ * 自己的回显把上限清零。
2295
+ * 改动:本项目相对两者都是**收窄**,不是扩展——(a)只认 `channel_reject` 这一种
2296
+ * 失败,两套参考实现都是「除永久码外一律续」;(b)不做原地重试、不做换号、不做
2297
+ * 退避,因为这一类失败换谁、隔多久都是同一份请求;(c)计数状态只活在进程里、不
2298
+ * 落盘(参考实现有持久化文件),因为「刚刚这一串失败」本来就是即时的事实;(d)把
2299
+ * 订阅交给调用方注入(`onSessionEvent`),因为本插件没有 `@deepseek-ai/dsh-session`
2300
+ * 这条 peer 依赖,也不该为一条监听引入它。
2301
+ *
2302
+ * @module dsh-connect-workbuddy/channel-continue
2303
+ */
2304
+ /**
2305
+ * 自动续发消息的 `source.kind`。
2306
+ *
2307
+ * 不用 `'user'`:那等于声称这条消息是用户敲的,转录里会渲染成右对齐的用户气泡,
2308
+ * 用户既没说过这句话、也就无从判断它是谁发的。`MessageSourceMap` 是**可合并扩展**
2309
+ * 的(其自带注释:「each producer declares its own kind in its own module」),
2310
+ * 声明在 `src/dsh-llm-augment.d.ts`。`dsh-connect-atomcode` / `dsh-purge` 同样做法。
2311
+ */
2312
+ const CONTINUE_SOURCE_KIND = "workbuddy-continue";
2313
+ /** 默认续发的文本。用户要的就是「继续」两个字。 */
2314
+ const DEFAULT_CONTINUE_TEXT = "继续";
2315
+ /** 上限的硬边界:0 等于关闭,超过 10 次已经不属于「再试一次」的语义。 */
2316
+ const MAX_CONSECUTIVE_CEILING = 10;
2317
+ /**
2318
+ * 发出前的等待。
2319
+ *
2320
+ * `turn/end` 是在回合**已经收尾**之后追加的,但宿主紧接着还要把这一步的状态落定;
2321
+ * 立刻 `followup` 有机会撞上宿主自己的收尾动作。300ms 是 `dsh-purge` 的
2322
+ * `continue-retry` 在同一位置实测可用的量级,这里取同一个值,不另挑一个没有依据的数。
2323
+ */
2324
+ const SEND_DELAY_MS = 300;
2325
+ /** 每个进程最多记多少个会话的计数(长开进程的 FIFO 兜底,`dsh-purge` 同一做法)。 */
2326
+ const SESSION_CAP = 200;
2327
+ /** 超过时一次丢多少个。 */
2328
+ const SESSION_EVICT = 50;
2329
+ /**
2330
+ * 把配置里的两个标量归一化成一个可用的设置对象。
2331
+ *
2332
+ * 两处「防御」都是有意为之:`enabled` 只认显式 `true`(这条功能会花真实请求,
2333
+ * 缺失或畸形必须读成「关」),`maxConsecutive` 与 `text` 越界时退回默认值,
2334
+ * 因为一个 `0`/`NaN`/空白文本会让功能变成静默空转——那比关着更难查。
2335
+ *
2336
+ * @param enabled - 配置里的开关标量。
2337
+ * @param max - 配置里的上限标量。
2338
+ * @param text - 配置里的文本。
2339
+ * @returns 归一化后的设置。
2340
+ */
2341
+ function channelContinueSettingsOf(enabled, max, text) {
2342
+ const capped = Math.min(MAX_CONSECUTIVE_CEILING, Math.max(1, typeof max === "number" && Number.isFinite(max) ? Math.trunc(max) : 1));
2343
+ const body = typeof text === "string" && text.trim() !== "" ? text.trim() : DEFAULT_CONTINUE_TEXT;
2344
+ return {
2345
+ enabled: enabled === true,
2346
+ maxConsecutive: capped,
2347
+ text: body
2348
+ };
2349
+ }
2350
+ /**
2351
+ * 这段失败是不是「被网关渠道校验拦截」。
2352
+ *
2353
+ * 不能只看 `code`。插件在 shim 里写的是 `type: "channel_reject"` 与
2354
+ * `code: "channel_reject"`(`KIND_STATUS` / 未翻译的 kind),但宿主拿到的
2355
+ * `LlmFailure.code` 由 pi-ai 从**消息文本**重新分类(`classifyPiAiError`,
2356
+ * `@deepseek-ai/dsh-llm-pi-ai`):`/\b400\b/` 命中就归成 `INVALID_REQUEST`。
2357
+ * 也就是说 host 侧这一份 `code` 极可能不是 `channel_reject`,而那句插件自
2358
+ * 己的话(连同 JSON 里的 `channel_reject`)一定在 `message` 里。
2359
+ *
2360
+ * 于是判断刻意「够宽但锚得准」:命中插件自己的 kind 字面量或它自己的那句中文说明,
2361
+ * 就算这一类;其余一切(配额、限流、审核、网络、模型不可用)都不算——它们各有
2362
+ * 自己的处置,重发「继续」只会多花一次真实请求。
2363
+ *
2364
+ * @param failure - `turn/end` 的 `reason.error`,形状按 `LlmFailure` 读。
2365
+ * @returns 是否属于渠道门拒绝。
2366
+ */
2367
+ function isChannelRejectFailure(failure) {
2368
+ if (typeof failure !== "object" || failure === null) return false;
2369
+ const record = failure;
2370
+ if (typeof record.code === "string" && record.code === "channel_reject") return true;
2371
+ const message = record.message;
2372
+ if (typeof message !== "string" || message === "") return false;
2373
+ if (message.includes("channel_reject")) return true;
2374
+ return message.includes("渠道校验拦截");
2375
+ }
2376
+ /** 结构化的会话切片。 */
2377
+ function sessionIdOf(session) {
2378
+ const id = session?.id;
2379
+ return typeof id === "string" ? id : "";
2380
+ }
2381
+ /** 子代理会话:它的回合由父代理驱动,插件不该插手。 */
2382
+ function isSubagentSession(session, agent) {
2383
+ if (session?.header?.origin === "subagent") return true;
2384
+ return agent?.session?.header?.origin === "subagent";
2385
+ }
2386
+ /**
2387
+ * agent 的收件箱里是否已经有人排队。
2388
+ *
2389
+ * 只做「看起来非空」的结构探测,覆盖宿主不同版本的三种表示(`hasPending()` /
2390
+ * `size` / `length`)。有排队就放弃这一次续发:用户很可能刚敲了一条消息,此时插一条
2391
+ * 「继续」会让他的提问排到自动消息后面。
2392
+ */
2393
+ function inboxBusy(agent) {
2394
+ const inbox = agent.inbox;
2395
+ if (typeof inbox !== "object" || inbox === null) return false;
2396
+ const probe = inbox;
2397
+ try {
2398
+ if (typeof probe.hasPending === "function" && probe.hasPending() === true) return true;
2399
+ if (typeof probe.size === "number" && probe.size > 0) return true;
2400
+ if (typeof probe.length === "number" && probe.length > 0) return true;
2401
+ } catch {}
2402
+ return false;
2403
+ }
2404
+ /**
2405
+ * 建一台自动续发引擎。
2406
+ *
2407
+ * 只认 `session/event` 上的三件事:`turn/end`(判失败/清计数)、`turn/start`
2408
+ * (取消待发)、`user/message`(真人插话就把计数清零)。没有轮询、没有扫描、
2409
+ * 没有持久化:计数失效于进程即可,它描述的本来就是「刚刚这一串失败」。
2410
+ *
2411
+ * @param host - 注入的宿主依赖。
2412
+ * @returns 引擎句柄。
2413
+ */
2414
+ function createChannelContinue(host) {
2415
+ const states = /* @__PURE__ */ new Map();
2416
+ let disposed = false;
2417
+ const stateOf = (sessionId) => {
2418
+ let state = states.get(sessionId);
2419
+ if (state === void 0) {
2420
+ if (states.size >= SESSION_CAP) {
2421
+ let dropped = 0;
2422
+ for (const key of states.keys()) {
2423
+ if (dropped >= SESSION_EVICT) break;
2424
+ const victim = states.get(key);
2425
+ if (victim?.pending !== null && victim?.pending !== void 0) clearTimeout(victim.pending);
2426
+ states.delete(key);
2427
+ dropped += 1;
2428
+ }
2429
+ }
2430
+ state = {
2431
+ consecutive: 0,
2432
+ lastHandledSeq: 0,
2433
+ pending: null,
2434
+ capped: false
2435
+ };
2436
+ states.set(sessionId, state);
2437
+ }
2438
+ return state;
2439
+ };
2440
+ const cancelPending = (state) => {
2441
+ if (state.pending === null) return;
2442
+ clearTimeout(state.pending);
2443
+ state.pending = null;
2444
+ };
2445
+ /** 真的把「继续」发出去。所有放弃的分支都留一行日志,不静默。 */
2446
+ const fire = (sessionId, state, session) => {
2447
+ state.pending = null;
2448
+ if (disposed) return;
2449
+ const config = host.getConfig();
2450
+ if (!config.enabled) return;
2451
+ const agent = host.getAgent(sessionId);
2452
+ if (agent === void 0) {
2453
+ host.log(`跳过自动续发 ${sessionId}:会话已没有 live agent`);
2454
+ return;
2455
+ }
2456
+ if (isSubagentSession(session, agent)) return;
2457
+ if (agent.status === "running") {
2458
+ host.log(`跳过自动续发 ${sessionId}:回合已经在跑`);
2459
+ return;
2460
+ }
2461
+ if (inboxBusy(agent)) {
2462
+ host.log(`跳过自动续发 ${sessionId}:收件箱里已有排队内容`);
2463
+ return;
2464
+ }
2465
+ try {
2466
+ const message = createUserMessage({
2467
+ content: [{
2468
+ type: "text",
2469
+ text: config.text
2470
+ }],
2471
+ source: {
2472
+ kind: CONTINUE_SOURCE_KIND,
2473
+ form: "notice",
2474
+ summary: "channel-reject-continue"
2475
+ }
2476
+ });
2477
+ agent.followup(message);
2478
+ state.consecutive += 1;
2479
+ host.log(`已自动发送「${config.text}」到 ${sessionId}(被渠道校验拦截),第 ${String(state.consecutive)}/${String(config.maxConsecutive)} 次连续`);
2480
+ if (state.consecutive >= config.maxConsecutive) host.log(`已达连续上限 ${String(config.maxConsecutive)} 次,${sessionId} 不再自动续发,等用户介入`);
2481
+ } catch (error) {
2482
+ host.log(`自动续发失败 ${sessionId}:${error instanceof Error ? error.message : String(error)}`);
2483
+ }
2484
+ };
2485
+ return {
2486
+ onSessionEvent(session, event) {
2487
+ if (disposed) return;
2488
+ const sessionId = sessionIdOf(session);
2489
+ if (sessionId === "") return;
2490
+ const record = event;
2491
+ const type = record?.type;
2492
+ if (typeof type !== "string") return;
2493
+ const state = stateOf(sessionId);
2494
+ if (type === "turn/start") {
2495
+ cancelPending(state);
2496
+ return;
2497
+ }
2498
+ if (type === "user/message") {
2499
+ if (record?.data?.source?.kind === "user") {
2500
+ cancelPending(state);
2501
+ state.consecutive = 0;
2502
+ state.capped = false;
2503
+ }
2504
+ return;
2505
+ }
2506
+ if (type !== "turn/end") return;
2507
+ const seq = typeof record?.seq === "number" ? record.seq : 0;
2508
+ if (seq !== 0 && state.lastHandledSeq === seq) return;
2509
+ state.lastHandledSeq = seq;
2510
+ const reason = record?.data?.reason;
2511
+ const kind = typeof reason === "object" && reason !== null ? reason.kind : void 0;
2512
+ if (kind === "completed") {
2513
+ cancelPending(state);
2514
+ state.consecutive = 0;
2515
+ state.capped = false;
2516
+ return;
2517
+ }
2518
+ if (kind === "aborted") {
2519
+ if ((reason?.reason)?.kind === "user") {
2520
+ cancelPending(state);
2521
+ state.consecutive = 0;
2522
+ state.capped = false;
2523
+ }
2524
+ return;
2525
+ }
2526
+ if (kind !== "error") return;
2527
+ const failure = reason.error;
2528
+ if (!isChannelRejectFailure(failure)) return;
2529
+ const config = host.getConfig();
2530
+ if (!config.enabled) return;
2531
+ if (state.pending !== null) return;
2532
+ if (state.consecutive >= config.maxConsecutive) {
2533
+ if (!state.capped) {
2534
+ state.capped = true;
2535
+ host.log(`已达连续上限 ${String(config.maxConsecutive)} 次,${sessionId} 停止自动续发`);
2536
+ }
2537
+ return;
2538
+ }
2539
+ host.log(`检测到渠道门拒绝 ${sessionId},${String(SEND_DELAY_MS)}ms 后自动发送「${config.text}」(第 ${String(state.consecutive + 1)}/${String(config.maxConsecutive)} 次)`);
2540
+ state.pending = setTimeout(() => {
2541
+ try {
2542
+ fire(sessionId, state, session);
2543
+ } catch (error) {
2544
+ host.log(`自动续发回调异常 ${sessionId}:${error instanceof Error ? error.message : String(error)}`);
2545
+ }
2546
+ }, SEND_DELAY_MS);
2547
+ state.pending.unref?.();
2548
+ },
2549
+ dispose() {
2550
+ disposed = true;
2551
+ for (const state of states.values()) {
2552
+ if (state.pending !== null) clearTimeout(state.pending);
2553
+ state.pending = null;
2554
+ }
2555
+ states.clear();
2556
+ },
2557
+ stateOf(sessionId) {
2558
+ const state = states.get(sessionId);
2559
+ if (state === void 0) return void 0;
2560
+ return {
2561
+ consecutive: state.consecutive,
2562
+ pending: state.pending !== null
2563
+ };
2564
+ }
2565
+ };
2566
+ }
2567
+ /**
2568
+ * 把引擎挂到宿主的事件总线上。
2569
+ *
2570
+ * 订阅走注入的 `onSessionEvent`,而不是在这里 `ctx.on`:宿主把 `session/event`
2571
+ * 声明在 `@deepseek-ai/dsh-session` 的类型里,本插件没有也不该有那条 peer 依赖,
2572
+ * 由调用方用一行鸭子类型的 cast 提供,才能让这个模块在没装宿主包的测试里也能跑。
2573
+ *
2574
+ * @param mount - 宿主切片。
2575
+ * @returns 卸载函数(取消订阅并清空计数)。
2576
+ */
2577
+ function installChannelContinue(mount) {
2578
+ const engine = createChannelContinue(mount);
2579
+ let off;
2580
+ try {
2581
+ off = mount.onSessionEvent((session, event) => {
2582
+ engine.onSessionEvent(session, event);
2583
+ });
2584
+ } catch (error) {
2585
+ mount.log(`未能订阅 session/event,自动续发不可用(插件其余部分不受影响):${error instanceof Error ? error.message : String(error)}`);
2586
+ engine.dispose();
2587
+ return () => {};
2588
+ }
2589
+ return () => {
2590
+ try {
2591
+ off?.();
2592
+ } catch {}
2593
+ engine.dispose();
2594
+ };
2595
+ }
2596
+ //#endregion
2215
2597
  //#region src/body-report.ts
2216
2598
  /**
2217
2599
  * Body-composition report: WHAT was sent upstream, in counts (issue #59).
@@ -9627,7 +10009,8 @@ const UPSTREAM_SWITCH_FIELDS = /* @__PURE__ */ new Set([
9627
10009
  "keepPromptLast",
9628
10010
  "extraInjectedPrefixes",
9629
10011
  "sessionTitle",
9630
- "dropForeignThinking"
10012
+ "dropForeignThinking",
10013
+ "channelRejectContinue"
9631
10014
  ]);
9632
10015
  /** A non-null, non-array object: the only shape the slot merge applies to. */
9633
10016
  function isPlainRecord(value) {
@@ -10536,7 +10919,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
10536
10919
  req.on("error", reject);
10537
10920
  });
10538
10921
  const field = body.field;
10539
- if (field !== "regions" && field !== "accounts" && field !== "cliBridge" && !(typeof field === "string" && UPSTREAM_SWITCH_FIELDS.has(field))) return json(res, 400, { error: "field must be regions, accounts, cliBridge, usageIdentity, keepPromptLast, extraInjectedPrefixes, sessionTitle or dropForeignThinking" });
10922
+ if (field !== "regions" && field !== "accounts" && field !== "cliBridge" && !(typeof field === "string" && UPSTREAM_SWITCH_FIELDS.has(field))) return json(res, 400, { error: "field must be regions, accounts, cliBridge, usageIdentity, keepPromptLast, extraInjectedPrefixes, sessionTitle, dropForeignThinking or channelRejectContinue" });
10540
10923
  const row = settings.describe().find((r) => String(r.ns).includes("workbuddy"));
10541
10924
  if (row === void 0) return json(res, 503, { error: "workbuddy namespace missing from describe()" });
10542
10925
  const current = field === "regions" ? regionsMapOf(row.value?.[field]) : unwrapVolatileDeep(row.value?.[field] ?? {});
@@ -10947,11 +11330,16 @@ const Config = z.object({
10947
11330
  usageIdentity: asVolatile(z.boolean().default(false).description("Make the upstream's usage page record these calls like the official client's: add the X-IDE-* identity headers (which fill the 使用端 column) and send the conversation ids as 32-hex UUIDv7. Off by default — it is a display alignment measured by a reporter, not by this plugin, and the chat channel identity (User-Agent) and request body never change.")),
10948
11331
  keepPromptLast: asVolatile(z.boolean().default(false).description("Move the harness-injected runtime context above the real prompt, so the WorkBuddy usage page records what you actually typed instead of the injected block. Message count and text are preserved; the one edit is the recorded prompt's own leading <acp> marker. Off by default because it changes the message order the upstream counts.")),
10949
11332
  extraInjectedPrefixes: asVolatile(z.array(z.string()).default([]).description("Extra literal prefixes (issue #133) treated like the harness-injected context markers when keepPromptLast is on — for blocks a companion plugin injects that the built-in list does not recognize. Each entry matches the start of a trailing user message; read only while keepPromptLast is enabled. Sampled when the plugin mounts: reload the plugin (or restart the host) after changing it.")),
10950
- dropForeignThinking: asVolatile(z.boolean().default(false).description("Drop foreign thinking blocks that pi-ai would publish as prose (issue #147). After a provider switch the host rebuilds the other provider's assistant turns as dsh-foreign, and pi-ai then rewrites their thinking blocks into visible text, so the model's working notes become the answer and it copies that shape on later turns. On, those unsigned blocks are removed from the request (they could never be replayed as reasoning anyway); text and tool calls are untouched. Off by default because it changes bytes the upstream counts.")),
11333
+ dropForeignThinking: asVolatile(z.boolean().default(false).description("Drop foreign thinking blocks that pi-ai would publish as prose (issue #147). CROSS-PROVIDER turns are dropped automatically: after a provider switch the other provider's unsigned thinking blocks can never be replayed, and pi-ai would flatten them into assistant prose that the gateway refuses with 11128 (issue #163's measured scene). This switch only widens the repair to a model switch inside one of this bundle's own routes. On, those blocks are removed from the request; text and tool calls are untouched. Off by default for the same-route leg because it changes bytes the upstream counts.")),
10951
11334
  sessionTitle: asVolatile(z.object({
10952
11335
  enabled: z.boolean().default(false),
10953
11336
  model: z.string().default("wb-lite")
10954
- }).default({}).description("Move the host session-title request onto a hidden lightweight model (issues #132/#133). The host fires one real upstream request per new session either way; enabled re-points it at `model` (default wb-lite, measured 0 credits by the reporter) instead of billing the main paid model. Request volume still counts, and the lightweight model is reporter-measured, not reproduced here. Sampled when the plugin mounts: reload the plugin (or restart the host) after changing it."))
11337
+ }).default({}).description("Move the host session-title request onto a hidden lightweight model (issues #132/#133). The host fires one real upstream request per new session either way; enabled re-points it at `model` (default wb-lite, measured 0 credits by the reporter) instead of billing the main paid model. Request volume still counts, and the lightweight model is reporter-measured, not reproduced here. Sampled when the plugin mounts: reload the plugin (or restart the host) after changing it.")),
11338
+ channelRejectContinue: asVolatile(z.object({
11339
+ enabled: z.boolean().default(false),
11340
+ maxConsecutive: z.number().step(1).min(1).max(10).default(1),
11341
+ text: z.string().default("继续")
11342
+ }).default({}).description("LAST RESORT, not a fix for 11128. After the gateway refuses a turn with `channel_reject` (business code 11128), send 「继续」 once automatically. Off by default: it spends a real request without the user typing anything. Fixed system fingerprints are handled by prepareChatBody's sentence rewrites; they are byte-identical every turn, so resending only pays for the same 400 again. The narrow case this can help is content that changes between turns. `maxConsecutive` (default 1) counts CONSECUTIVE auto-sends: one successful turn, or one real user message, resets it. Subagent sessions are never touched. Live: the engine reads this on every failure, so no reload is needed."))
10955
11343
  });
10956
11344
  /**
10957
11345
  * One region's saved model state. A config written before the region split has
@@ -11259,6 +11647,34 @@ function apply(ctx, config) {
11259
11647
  let current = () => config;
11260
11648
  let invalidateCatalog = () => {};
11261
11649
  /**
11650
+ * 渠道门拒绝(`channel_reject` / 11128)之后的自动「继续」。
11651
+ *
11652
+ * 挂在这里而不是 adapter 里,理由是**它要的是一个新回合**:adapter 只看得到
11653
+ * 一次请求,`channel_reject` 在同一个请求内已经跑完三级救援阶梯,再在那一层
11654
+ * 重发就是把被拒的字节原样再送一遍。宿主把这一轮判死之后才追加 `turn/end`,
11655
+ * 而新回合会让宿主重新组装 surface —— 那才是 `docs/PINNED-11128-RETRY.md`
11656
+ * 里「把这条消息再发一次」真正做的事。
11657
+ *
11658
+ * 会花真实请求,所以开关默认关闭,且只在用户显式打开后才读配置(见
11659
+ * `channelContinueSettingsOf`:只有 `=== true` 算开启)。
11660
+ */
11661
+ const disposeChannelContinue = installChannelContinue({
11662
+ getConfig: () => {
11663
+ const block = unwrapVolatile(current().channelRejectContinue);
11664
+ return channelContinueSettingsOf(block?.enabled, block?.maxConsecutive, block?.text);
11665
+ },
11666
+ getAgent: (sessionId) => {
11667
+ return ctx.get("agents")?.get(sessionId);
11668
+ },
11669
+ log: (message) => {
11670
+ ctx.logger.warn(`dsh-connect-workbuddy: ${message}`);
11671
+ },
11672
+ onSessionEvent: (listener) => ctx.on("session/event", listener)
11673
+ });
11674
+ ctx.effect(() => () => {
11675
+ disposeChannelContinue();
11676
+ });
11677
+ /**
11262
11678
  * REPAIR a region's saved directory against the live upstream roster.
11263
11679
  *
11264
11680
  * ISSUES #114 AND #115, THE PERSISTED HALF. The read side now serves the
@@ -12695,6 +13111,7 @@ function apply(ctx, config) {
12695
13111
  blocks,
12696
13112
  atMs: Date.now()
12697
13113
  };
13114
+ if (unwrapVolatile(current().dropForeignThinking) !== true) ctx.logger.warn(`dsh-connect-workbuddy: dropped ${blocks.length} cross-provider thinking block(s) before a WorkBuddy request (provider switch); see issue #163`);
12698
13115
  }
12699
13116
  });
12700
13117
  invalidateCatalog = () => {
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.4",
5
+ "version": "5.13.1",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",