dsh-connect-workbuddy 5.12.3 → 5.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +135 -4
- package/README.en.md +14 -0
- package/README.md +14 -0
- package/THIRD_PARTY_NOTICES.md +9 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +125 -5
- package/lib/{host-heartbeat-D1N-f8Q6.js → host-heartbeat-DLVGPY4k.js} +53 -16
- package/lib/index.d.ts +30 -0
- package/lib/index.js +395 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,136 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.13.0 (2026-10-28)
|
|
4
|
+
|
|
5
|
+
> 用户贴的那条失败:`本轮运行失败400: {"message":"workbuddy 上游失败:被网关的渠道校验拦截(不是账号、
|
|
6
|
+
> 额度或网络问题)(http 400)(code 11128,requestId …)。…","type":"channel_reject"}`,
|
|
7
|
+
> 以及一句明确的需求:「当我收到这样的信息的时候,自动发送继续,并且如果连续收到3次就不再发送」。
|
|
8
|
+
> 这条需求成立的前提是搞清楚**为什么重发可能有用**,因为插件自己在 `perturbChannelFingerprints` 旁的
|
|
9
|
+
> 注释一直说「给定提示词被拒是确定性的」,而现场报告反复说「再发一次就过了」。两者其实不矛盾:**新回合
|
|
10
|
+
> 是一份重新组装的请求**(surface 重排、注入块位置变化、学到扰动偏好后第一发就带扰动),所以「再发一次」
|
|
11
|
+
> 不是把被拒的字节原样再塞一遍——那是 `channel_reject` 被排除在 `isFailoverWorthy` 之外的同一条理由。
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
- **可选:渠道门拒绝(`channel_reject` / 11128)之后自动再发一次「继续」,连续上限 3 次**
|
|
16
|
+
- **为什么需要它**:这一类失败在插件里是**三重不救**——不换号(`isFailoverWorthy` 刻意排除:网关读的是
|
|
17
|
+
消息开头,换账号只会每个账号白烧一次真实请求)、不在原地重试(同一请求内的三级救援阶梯
|
|
18
|
+
`forceNeutralHead` → 一次零宽扰动 → 密集零宽扰动已经跑完)、宿主也不重试(400 不在
|
|
19
|
+
`DEFAULT_RETRYABLE_CODES` 里,`LvFailure.code` 还会被 pi-ai 按 `/\b400\b/` 重新分类成
|
|
20
|
+
`INVALID_REQUEST`)。于是这一轮直接结束,用户看到的就是那句 400。
|
|
21
|
+
- **它做什么**:`session/event` 上认 `turn/end` + `reason.kind === 'error'` + 这一类失败 → 等 300ms →
|
|
22
|
+
`agent.followup(createUserMessage(...))` 发一条 `继续`。**不做**任何换号、换区、改请求体的事。
|
|
23
|
+
- **上限的语义是「连续」**(`maxConsecutive`,默认 3):一次 `completed` 回合、或一条
|
|
24
|
+
`source.kind === 'user'` 的消息立即清零;**插件自己发的那条不清零**——否则我们自己发的消息会把计数
|
|
25
|
+
抹掉,上限永远不生效(这条单独有测试钉住)。`aborted` + `reason.kind === 'user'`(用户点了停止)
|
|
26
|
+
也清零,同 `dsh-purge` / `auto-continue` 的处置。
|
|
27
|
+
- **不做的四件事**:子代理会话一律不碰(它的回合由父代理驱动);`status === 'running'` 时不发(宿主可能
|
|
28
|
+
自己起了新回合);收件箱里有排队内容时不发(用户可能正在打字,抢在前面会让模型先答错东西);
|
|
29
|
+
`turn/start` 一来就取消已排定的那一条。
|
|
30
|
+
- **失败如何识别**:`code === 'channel_reject'`,或消息里带插件自己的 kind 字面量 / 它自己的那句
|
|
31
|
+
「渠道校验拦截」。**刻意不用 `code` 单值判定**——宿主那一份 `code` 极可能已经被 pi-ai 从文本重分类成
|
|
32
|
+
`INVALID_REQUEST`,只看它会漏掉每一次真实失败。
|
|
33
|
+
- **默认关闭**,因为每次都会真实消耗一次请求,与 `usageIdentity` / `imageToolEnabled` 同一条规则。
|
|
34
|
+
它是本插件里**唯一改完即生效**的上游类开关:引擎每次失败现读配置,不必重载插件(因此它**不在**那组
|
|
35
|
+
「改完需重载」的提示里,而是自己一组)。
|
|
36
|
+
- **卡片**:「能力」栏新增一个开关(`.dsm-workbuddy-channel-continue-check`),展开时回报上限与发送
|
|
37
|
+
文本;这两个值卡片只读不写,写入时整块带上,一次布尔写不会把它们擦回默认(同 `sessionTitle` 的处置)。
|
|
38
|
+
- **测试**:`tests/channel-continue.spec.ts`(引擎 30 例,含上限、清零、不清零、子代理、running、
|
|
39
|
+
收件箱忙、seq 去重、卸载、会话数上限、事件总线缺失时功能失效而非拖垮插件)、
|
|
40
|
+
`tests/channel-continue-wiring.spec.ts`(真 `apply()` 挂载:真的订阅了、真的读到活配置、
|
|
41
|
+
真的随 fiber 卸载)、`tests/card-channel-continue.spec.tsx`(渲染与写入契约)、
|
|
42
|
+
`tests/web-status.spec.ts`(`__save` 白名单整块接受)。
|
|
43
|
+
- **溯源**:「连续失败上限」的语义来自 `dsh-client-auto-continue`(MIT,HsiangNianian),
|
|
44
|
+
「哪些失败码不续 / 收件箱忙不插队 / 用 `source.kind` 区分真人介入」来自 `dsh-purge`
|
|
45
|
+
(MIT,YuJunZhiXue)。两者都只是**思路参照**,未复制源码;相对它们本实现是收窄。
|
|
46
|
+
已同步写入 `THIRD_PARTY_NOTICES.md`、两份 README 的致谢与 `docs/DESIGN.md` §5.3 的分发表。
|
|
47
|
+
|
|
48
|
+
## 5.12.4 (2026-10-28)
|
|
49
|
+
|
|
50
|
+
> issue #159 的**报告现象成立,归因不成立**。现场确实出现了「三级救援一级都没跑」的 `11128`,但把它
|
|
51
|
+
> 唯一地归到 `!controller.signal.aborted` 上不成立:**这个 shim 里 `signal` 只有 `res.on('close')`
|
|
52
|
+
> 一个来源**(`src/shim.ts:1657`,客户端真的断开了),不存在 issue 假设的那个「宿主内部中止」的第二来源;
|
|
53
|
+
> 而按建议删掉三处 `&&` 经实测会把**诊断改坏**(见下)。顺着这条线索查同一个阶梯,找到的**真实缺陷是另一个**:
|
|
54
|
+
> 密集救援 rung 在真实大小的 body 上是 **O(n²)**,现场那种 130 KB 提示词要跑**分钟级同步 CPU**,
|
|
55
|
+
> 于是「救援等于没跑」有了一个不依赖 abort 的解释,且这个解释是实测出来的。
|
|
56
|
+
|
|
57
|
+
### Fixes
|
|
58
|
+
|
|
59
|
+
- **密集扰动 rung 的 O(n²):92 KB 的 body 要 82.7 秒,130 KB 的现场提示词要两分多钟(#159 衍生)**
|
|
60
|
+
- **根因**:`perturbChannelFingerprints(body, true)` 用 `replace(/(?=\S)/gu, cb)`,回调里为**每一个**
|
|
61
|
+
匹配位置重新取「上一个非空白字符」,写法是 `source.slice(0, offset).match(/\S(?=\S*$)/u)` ——
|
|
62
|
+
对每个位置做一次 O(offset) 的前缀切片 + 正则,整体 O(n²)。
|
|
63
|
+
- **实测(本机 Node 25,2026-10-28,改动前)**:
|
|
64
|
+
|
|
65
|
+
| body | 耗时 |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| 11,575 字符 | 1.3 s |
|
|
68
|
+
| 23,075 字符 | 5.2 s |
|
|
69
|
+
| 46,075 字符 | 22.8 s |
|
|
70
|
+
| 92,075 字符 | **82.7 s** |
|
|
71
|
+
| 184,075 字符 | 322.8 s |
|
|
72
|
+
|
|
73
|
+
- **为什么这条比「救援没跑」更该先修**:这一级只在网关已经拒过两次之后才走,也就是**恰好**落在大 body
|
|
74
|
+
的真实会话上;而 92 KB 就是一次普通的长会话,#159 现场那条 `system` 是 130,345 字符。这一级是**同步**
|
|
75
|
+
CPU,先跑完才发请求,而 `chatStream` 对自己**刻意不设超时**(它自己的注释解释了原因:真实答案要预填
|
|
76
|
+
整个提示词,卡一个够用的上限必然切掉慢而正常的答案)。于是救援会先被读者自己的等待熬过去,而**从外面看,
|
|
77
|
+
「跑得太慢」和「根本没跑」是同一件事** —— 这正是 #159 观察到的那个现象。
|
|
78
|
+
- **修法**:把这条规则改成**一次累加扫描**(`spaceDenseBreaks`)。规则本身一字未改,输出**逐字节相同**:
|
|
79
|
+
旧写法那句 `/\S(?=\S*$)/u` 要求「其后全部非空白」,所以它取回的其实是**当前这段非空白串的首字符**,
|
|
80
|
+
而不是上一个字符 —— 这个反直觉的细节正是重写时最容易写错的地方(本轮就写错过两次,靠下面的等价测试
|
|
81
|
+
发现)。**这不是「顺手优化」**:改动只碰耗时,字节不动,因为换一个字节就是换一份没人测过的 body。
|
|
82
|
+
- **实测(同一台机器,改动后)**:92 KB 由 **82.7 s → 10 ms**(约 8000 倍);130 KB 的等价 body 在
|
|
83
|
+
**10 ms 量级**完成。
|
|
84
|
+
- **两条新测试各钉住一半**:`tests/issue159-dense-rescue-cost.spec.ts` 用**旧规则的逐字转录**做参照,
|
|
85
|
+
22 个定点用例 + 1500 例定种子随机语料比对**字节相等**;并钉住耗时 —— 130 KB 在 2 s 内完成,
|
|
86
|
+
320 KB 的中位数在 5 s 内完成(真正线性的实现在该尺寸约 15 ms,旧规则约 15 分钟,两者相差三个数量级,
|
|
87
|
+
所以这个上限既不误报也绝不放过)。
|
|
88
|
+
- **耗时断言的度量方式本身改过两次,两次都是被 CI / 变异测试纠正的**:第一版钉 60 KB→120 KB 的
|
|
89
|
+
**比值 < 8**,变异测试显示旧规则在该尺寸的比值恰好 **4.0** —— 8 倍把二次方实现**放过去了**,等于没钉;
|
|
90
|
+
收紧到 **< 3** 后,**CI 又在正确实现上把它跑红**(ubuntu 实测 60 KB 10 ms、120 KB 34 ms,比值 3.4)——
|
|
91
|
+
在个位数毫秒的样本上,比值量到的是计时粒度、GC 和固定开销,不是被主张的增长。最终改为**在能区分两种
|
|
92
|
+
实现的尺寸上钉绝对上限**(320 KB / 5 s,中位数),依据是 40→640 KB 的实测每翻倍约 2 倍
|
|
93
|
+
(3.5 / 7.3 / 11.2 / 28.8 / 69.3 ms)。
|
|
94
|
+
|
|
95
|
+
- **未采纳 issue 建议的那一行补丁,并给出实测理由(`tests/issue159-abort-gate.spec.ts`)**
|
|
96
|
+
- issue 的建议是删掉三处 `&& !controller.signal.aborted`,理由是「中止已由下传的 `signal` 在
|
|
97
|
+
`chatStream` 内部保证,外层这道 `&&` 是重复且有害的检查」。**「重复」只对了一半**:删掉之后
|
|
98
|
+
`chatStream` 确实会立刻因 abort 返回,但它返回的是 `{status: 0, kind: 'server'}`(被 abort 的
|
|
99
|
+
`fetch` 抛 `AbortError`,在客户端被兜成传输失败)—— 这个结果会**盖掉**真正的 `channel_reject`
|
|
100
|
+
诊断,读者收到的从「网关拒了这条请求」变成「上游服务器错误」;`reportFailures` 还会为一个**根本没
|
|
101
|
+
上线的请求**记一笔账号失败。
|
|
102
|
+
- **实测对照**(真挂断,客户端按真实 `chatStream` 对 abort 的处理返回):
|
|
103
|
+
|
|
104
|
+
| | 保留门(HEAD) | 删掉门(issue 建议) |
|
|
105
|
+
| --- | --- | --- |
|
|
106
|
+
| 断线后额外调用上游 | **0 次** | 2 次 |
|
|
107
|
+
| 最终 kind | `channel_reject`(真实原因) | `server`(自己造出来的) |
|
|
108
|
+
| 账号测量 | **不记** | 记一笔假的 |
|
|
109
|
+
|
|
110
|
+
- 保留的正是 #159 真正关心的那一半:**请求还活着时的 11128,三级照跑**。这一半由
|
|
111
|
+
`issue113-channel-gate-evidence.spec.ts` / `issue98-shim.spec.ts` 覆盖,本轮又加了一条同文件内的
|
|
112
|
+
对照用例,防止将来有人以「救援没跑」为名再把门删掉。
|
|
113
|
+
- 变异验证:把三处 `&&` 删掉 → 新增的断线用例**变红**(`the shim spent a rescue on an aborted signal:
|
|
114
|
+
expected 1 to be +0`),对照用例**仍绿**。
|
|
115
|
+
|
|
116
|
+
### 未改动(据实说明)
|
|
117
|
+
|
|
118
|
+
- issue 里「`retries` 为空」这个字段名**在本仓库不存在**:实际实现是失败文案里的「,并已用…重试过」
|
|
119
|
+
(`src/shim.ts:2067-2071`),`Error` 信封里没有 `retries` 字段。报告者读到的可能是宿主侧的包装,
|
|
120
|
+
本仓库无法据该字段反推,故不据此改动任何判定。
|
|
121
|
+
- issue 的另一半(5.12.3 的 `IDENTITY_CLAUSE` + `neutralizeScannedText` 已修好)与本次改动无关,未触碰。
|
|
122
|
+
|
|
123
|
+
### 验证
|
|
124
|
+
|
|
125
|
+
- 端到端对照:同一个 **116,939 字符**的现场形状 body(三段身份句 + 无标点长 prose + 20 轮 assistant
|
|
126
|
+
历史)走完整 shim,三级阶梯全部跑完后答 200 —— **修复后 29 ms,修复前 144,260 ms(2 分 24 秒)**。
|
|
127
|
+
这一条是本次的真正判据:旧代码不是「永远救不回来」,而是慢到读者早已放弃。
|
|
128
|
+
- 变异验证(每条都确认会让对应用例变红):删掉三处 `&& !controller.signal.aborted` → 新增断线用例
|
|
129
|
+
**变红**(`the shim spent a rescue on an aborted signal: expected 1 to be +0`);把密集规则退回 O(n²)
|
|
130
|
+
实现 → **160 KB 实测 204,670 ms**,超过 5 s 上限 40 倍,**变红**;而正确实现在同一尺寸约 15 ms
|
|
131
|
+
(320 KB 在变异版上跑满 10 分钟未完成,已终止,未作为判据)。
|
|
132
|
+
- 全量:`tsc` 双配置 0 错误,**148 文件 / 2563 通过(2 条按平台跳过)**,`tsdown` 构建通过。
|
|
133
|
+
|
|
3
134
|
## 5.12.3 (2026-10-28)
|
|
4
135
|
|
|
5
136
|
> issue #155 的**两个独立 Bug**:**① 业务码按字节匹配 JSON**——`11128`/`14003`/`14018` 用
|
|
@@ -31,10 +162,10 @@
|
|
|
31
162
|
整个消失**,`channel_reject` 贴出来却没有 `11128` 可锚。第 4 行方向相反(substring 恰好命中嵌套的
|
|
32
163
|
`"code":11128`,解析器当时只认 `error.data.code`),说明这不是「换成解析器就完了」—— 两侧都得读同一个
|
|
33
164
|
事实,才可能一致。
|
|
34
|
-
-
|
|
35
|
-
`result.kind === 'channel_reject'
|
|
36
|
-
|
|
37
|
-
|
|
165
|
+
- **代价不只是文案**:渠道救援阶梯**只认 `channel_reject`**(`src/shim.ts` 的门是
|
|
166
|
+
`result.kind === 'channel_reject'`),所以漏判成任何别的类别都等于整条阶梯不跑 —— `forceNeutralHead` 与两级
|
|
167
|
+
扰动一次都不会触发,用户被要求「调整请求后重试」,而插件根本没试过救援。`14003` 同族实测降级目标**随 HTTP
|
|
168
|
+
状态而变**:`429 {"code": 14003}` → `soft_rate`、`400` → `client`,而 `model_busy` 是三个**不换号**的类别之一
|
|
38
169
|
(`isFailoverWorthy`)—— 落到 `soft_rate` 就是让整个账号池挨个烧一次真实请求去学一个模型级的事实。
|
|
39
170
|
`14018` spaced 单独看会掉成 `client`,实测只有当 `msg` 里带上了 `Credits exhausted` 那句话时才被
|
|
40
171
|
措辞标记救回 `hard_credit` —— 措辞一改就失效,正是本仓库反复记录的那类脆弱判据。
|
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: resend 「继续」 automatically when the gateway refuses the request (OFF by default, capped at 3 consecutive sends)** — when the upstream rejects with `400` and business code `11128` you see 「本轮运行失败400: … 被网关的渠道校验拦截(不是账号、额度或网络问题)…」 and then **the turn simply ends**: the plugin does not retry inside that request (its whole three-rung rescue ladder has already run), it does not swap accounts (the gate matches the openings of the messages, and every account sends the same ones), and the host does not retry either (a 400 is outside its retry set). Sending the message once more by hand really can pass — a new turn makes the host rebuild the surface, which is exactly the remedy [docs/PINNED-11128-RETRY.md](docs/PINNED-11128-RETRY.md) documents. Turning on 「被网关渠道校验拦截后,自动再发一次『继续』」 in the card's Capabilities tab automates that one act: on this failure the engine waits 300 ms and sends `继续` through `agent.followup` (the message's `source.kind` is `workbuddy-continue`, **not** `user` — it does not pretend to be your input and is not rendered as your bubble). **The cap counts CONSECUTIVE sends** (`channelRejectContinue.maxConsecutive`, default 3): one successful turn, or one message you type yourself, resets it immediately; the plugin's own message does **not** reset it, or the cap could never take effect. Subagent sessions are never touched, and neither is a session that is already running or has something queued in its inbox (you may be typing — going first would make the model answer the wrong thing). **Every send spends a real request**, hence OFF by default; it is also the only upstream-shaped switch in this plugin whose change **takes effect immediately** (the engine reads the config on every failure, so no reload is needed). As with the others, it can be set directly in `cordis.patch.yml`:
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
channelRejectContinue:
|
|
59
|
+
enabled: true
|
|
60
|
+
maxConsecutive: 3
|
|
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.
|
|
@@ -511,6 +520,11 @@ This project builds on work others have published. Sources and licenses are cred
|
|
|
511
520
|
- [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
521
|
- [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
522
|
|
|
523
|
+
### References for the auto-continue semantics
|
|
524
|
+
|
|
525
|
+
- [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.
|
|
526
|
+
- [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.
|
|
527
|
+
|
|
514
528
|
### Baseline for the plugin presentation and structure
|
|
515
529
|
|
|
516
530
|
- [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
|
+
- **可选:被网关渠道校验拦截后自动再发一次「继续」(默认关闭,连续上限 3 次)** —— 上游用 `400` + 业务码 `11128` 拒绝时,你看到的正是「本轮运行失败400: … 被网关的渠道校验拦截(不是账号、额度或网络问题)…」,然后**这一轮就此结束**:插件不会在原请求里重试(同一请求内的三级救援阶梯已经跑完了),也不会换号(网关匹配的是消息的开头,每个账号发出去的开头都一样),宿主也不重试(400 不在它的重试集合里)。而**手动把这条消息再发一次**确实可能通过 —— 新回合会让宿主重新组装 surface,这正是 [docs/PINNED-11128-RETRY.md](docs/PINNED-11128-RETRY.md) 给出的建议做法。开启卡片「能力」栏的「被网关渠道校验拦截后,自动再发一次『继续』」之后,插件把这个动作自动化:检测到这一类失败、等 300ms、用 `agent.followup` 发一条 `继续`(那条消息的 `source.kind` 是 `workbuddy-continue`,**不是** `user`——它不冒充你输入,转录里也不会渲染成你的气泡)。**上限数的是「连续」次数**(`channelRejectContinue.maxConsecutive`,默认 3):中途只要成功过一轮、或你自己发了一条消息,计数立刻清零;插件自己发的那条**不清零**,否则上限永远不会生效。子代理会话一律不碰;会话正在跑、或收件箱里已有排队内容时也不发(你可能正在打字,抢在前面会让模型先答错东西)。**每次都会真实消耗一次请求**,所以默认关闭;它也是本插件里**唯一一个改完即生效**的上游类开关(引擎每次失败现读配置,不必重载插件)。与其它上游开关一样,也能直接在 `cordis.patch.yml` 里写:
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
channelRejectContinue:
|
|
68
|
+
enabled: true
|
|
69
|
+
maxConsecutive: 3
|
|
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
|
- **积分信息在账号池里** —— 成员表逐账号显示剩余积分与最近到期时间,排序的第二键就是积分(谁多谁优先)。查询是**只读**的、不消耗积分。
|
|
@@ -526,6 +535,11 @@ else { await envelope.response.text() /* 或者按 SSE 消费 */ }
|
|
|
526
535
|
- [Sliverkiss/workbuddy2api](https://github.com/Sliverkiss/workbuddy2api)(MIT)— WorkBuddy 上游协议(`copilot.tencent.com` 的 wire behavior)的参照实现,经 `dsh-workbuddy-connect` 转引。
|
|
527
536
|
- [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
537
|
|
|
538
|
+
### 自动续跑的语义参照
|
|
539
|
+
|
|
540
|
+
- [HsiangNianian/dsh-auto-continue](https://github.com/HsiangNianian/dsh-auto-continue)(MIT)— 「连续失败上限」这套语义的来源:发出时计数自增、达到上限停止、成功回合清零、跳过子代理会话。本项目的 `channelRejectContinue` 只取这层语义,未照搬其引擎。
|
|
541
|
+
- [YuJunZhiXue/dsh-purge](https://github.com/YuJunZhiXue/dsh-purge)(MIT)— 三处具体做法:哪些失败码不该续(配额/限流/传输)、收件箱有排队就不插队、以及用消息的 `source.kind` 区分真人介入与插件自己的消息。
|
|
542
|
+
|
|
529
543
|
### 插件呈现与结构的基线
|
|
530
544
|
|
|
531
545
|
- [dingminhua/dsh-connect-trae](https://github.com/dingminhua/dsh-connect-trae)(MIT,Copyright (c) 2026 LaoDing)— 插件的呈现方式与其保持一致:设置卡片的结构与交互、模型管理(刷新 → 勾选 → 保存)与账号选择模型、只读的 host↔client 路由形态,以及 npm 发布工程。
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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-
|
|
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-DLVGPY4k.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: 3,
|
|
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": "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 openings and every account sends the same ones. Sending it again as a NEW turn is the manual remedy, and it can pass because the request is rebuilt. This does that automatically, sending the text below. It spends a real request each time. Stops after N CONSECUTIVE sends — 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": "上游用 400 + code 11128(「被网关的渠道校验拦截」)拒绝时,这一轮就直接失败了:插件不会在原地重试,也不会换号——网关匹配的是消息的开头,每个账号发出去的开头都一样。手动「把这条消息再发一次」才是有效的办法,因为那会重新组装一份请求;这个开关就是把它自动化,发送下面这句文本。每次都会真实消耗一次请求。连续发送 N 次后停止:中间只要成功过一轮,或你自己发了一条消息,计数立刻清零。子代理会话一律不碰。默认关闭。改完立即生效,不必重载插件。",
|
|
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 模型",
|
|
@@ -2536,12 +2536,12 @@ function asBusinessCode(value) {
|
|
|
2536
2536
|
* runs the OTHER way (the substring happens to hit the nested `"code":11128`
|
|
2537
2537
|
* while the parser only knew `error.data.code`), which is why this could not be
|
|
2538
2538
|
* fixed by "just use the parser" — both readers must consult ONE fact.
|
|
2539
|
-
* That is not cosmetic —
|
|
2540
|
-
*
|
|
2541
|
-
*
|
|
2542
|
-
* rungs never
|
|
2543
|
-
*
|
|
2544
|
-
*
|
|
2539
|
+
* That is not cosmetic — the channel rescue ladder runs ONLY for
|
|
2540
|
+
* `channel_reject` (`src/shim.ts` gates on `result.kind === 'channel_reject'`),
|
|
2541
|
+
* so any other kind skips it entirely: `forceNeutralHead` and both perturbation
|
|
2542
|
+
* rungs never fire, and the user is told to "adjust the request" for a refusal
|
|
2543
|
+
* the plugin never tried to rescue. Issue #113's whole fix depends on whether
|
|
2544
|
+
* the upstream happened to emit a space.
|
|
2545
2545
|
*
|
|
2546
2546
|
* THREE READS, IN THIS ORDER, and the ordering is the safety property:
|
|
2547
2547
|
*
|
|
@@ -2867,10 +2867,12 @@ function isChannelGateFamilyBody(body) {
|
|
|
2867
2867
|
* ADVISES, and that is {@link channelGateMemberOf}'s job, decided by the
|
|
2868
2868
|
* plugin's own evidence rather than by a wording the upstream may misreport.
|
|
2869
2869
|
*
|
|
2870
|
-
* The code is
|
|
2871
|
-
*
|
|
2872
|
-
*
|
|
2873
|
-
*
|
|
2870
|
+
* The code is read by {@link statesBusinessCode}, exactly as the family test
|
|
2871
|
+
* above it does — this function adds nothing to that decision and must not
|
|
2872
|
+
* appear to own a rule of its own. The reason the code is READ rather than
|
|
2873
|
+
* text-matched is unchanged (issue #155): a request id is hexadecimal and can
|
|
2874
|
+
* contain the digits `11128` by coincidence, and a bare substring search over an
|
|
2875
|
+
* arbitrary failure body is how a red herring gets promoted into a diagnosis.
|
|
2874
2876
|
*/
|
|
2875
2877
|
function isChannelRejectBody(body) {
|
|
2876
2878
|
return isChannelGateFamilyBody(body);
|
|
@@ -3686,6 +3688,45 @@ const ZERO_WIDTH_SPACE = "";
|
|
|
3686
3688
|
* rather than rewriting the text.
|
|
3687
3689
|
*/
|
|
3688
3690
|
const WORD_INTERNAL_BREAK = /([A-Z][A-Za-z0-9]*)(\s+)(?=[A-Z][A-Za-z0-9])/gu;
|
|
3691
|
+
/** A rune the dense rescue treats as a word character. */
|
|
3692
|
+
const DENSE_WORD_RUNE = /[A-Za-z0-9-ヿ㐀-鿿]/u;
|
|
3693
|
+
/**
|
|
3694
|
+
* Space U+200B through each run of non-space runes, when that run starts with a
|
|
3695
|
+
* word rune.
|
|
3696
|
+
*
|
|
3697
|
+
* THE ONE-PASS FORM OF THE DENSE RULE (issue #159). The rule only ever consults
|
|
3698
|
+
* two local facts — the first rune of the CURRENT run of non-space runes, and
|
|
3699
|
+
* whether the rune immediately before this position is already a break — so it
|
|
3700
|
+
* needs one accumulator and no prefix slicing, which is what makes it linear
|
|
3701
|
+
* rather than quadratic. See the body for why the reference is the run's FIRST
|
|
3702
|
+
* rune rather than the previous one, and for the exact old expression it
|
|
3703
|
+
* reproduces.
|
|
3704
|
+
*
|
|
3705
|
+
* It is kept as a standalone function so its output can be compared against a
|
|
3706
|
+
* literal transcription of the old `replace` in the test suite: the rewrite is
|
|
3707
|
+
* a performance change only, and a byte-difference would mean the rescue sends
|
|
3708
|
+
* a body nobody measured.
|
|
3709
|
+
*/
|
|
3710
|
+
function spaceDenseBreaks(text) {
|
|
3711
|
+
const runes = Array.from(text);
|
|
3712
|
+
let out = "";
|
|
3713
|
+
/** First rune of the current run of non-space runes; `undefined` outside one. */
|
|
3714
|
+
let runStart;
|
|
3715
|
+
for (let index = 0; index < runes.length; index += 1) {
|
|
3716
|
+
const rune = runes[index];
|
|
3717
|
+
if (/^\s$/u.test(rune)) {
|
|
3718
|
+
runStart = void 0;
|
|
3719
|
+
out += rune;
|
|
3720
|
+
continue;
|
|
3721
|
+
}
|
|
3722
|
+
runStart ??= rune;
|
|
3723
|
+
const inRun = index > 0 && !/^\s$/u.test(runes[index - 1]);
|
|
3724
|
+
const before = runes[index - 1];
|
|
3725
|
+
if (inRun && runStart !== ZERO_WIDTH_SPACE && DENSE_WORD_RUNE.test(runStart) && rune !== ZERO_WIDTH_SPACE && before !== ZERO_WIDTH_SPACE) out += ZERO_WIDTH_SPACE;
|
|
3726
|
+
out += rune;
|
|
3727
|
+
}
|
|
3728
|
+
return out;
|
|
3729
|
+
}
|
|
3689
3730
|
/**
|
|
3690
3731
|
* A body whose scanned message TEXT is perturbed just enough to break an exact
|
|
3691
3732
|
* content fingerprint.
|
|
@@ -3748,11 +3789,7 @@ function perturbChannelFingerprints(bodyJson, dense = false) {
|
|
|
3748
3789
|
const perturb = (text) => {
|
|
3749
3790
|
const boundaries = text.replace(/([.!?])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(/([。!?])(?!\u200b)/gu, `$1${ZERO_WIDTH_SPACE}`).replace(/([,;])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(WORD_INTERNAL_BREAK, (whole, head, tail) => `${head}${ZERO_WIDTH_SPACE}${tail}`);
|
|
3750
3791
|
if (!dense) return boundaries === text ? void 0 : boundaries;
|
|
3751
|
-
const denseText = boundaries
|
|
3752
|
-
if (source.slice(Math.max(0, offset - 1), offset + 1).includes(ZERO_WIDTH_SPACE)) return "";
|
|
3753
|
-
const previous = source.slice(0, offset).match(/\S(?=\S*$)/u)?.[0];
|
|
3754
|
-
return previous !== void 0 && /[A-Za-z0-9-ヿ㐀-鿿]/u.test(previous) ? ZERO_WIDTH_SPACE : "";
|
|
3755
|
-
});
|
|
3792
|
+
const denseText = spaceDenseBreaks(boundaries);
|
|
3756
3793
|
return denseText === text ? void 0 : denseText;
|
|
3757
3794
|
};
|
|
3758
3795
|
for (const value of messages) {
|
|
@@ -7674,7 +7711,7 @@ function describeRosterSeenPayload(raw) {
|
|
|
7674
7711
|
//#endregion
|
|
7675
7712
|
//#region src/version.ts
|
|
7676
7713
|
/** The npm package version this build was produced from. */
|
|
7677
|
-
const WORKBUDDY_CONNECT_VERSION = "5.
|
|
7714
|
+
const WORKBUDDY_CONNECT_VERSION = "5.13.0";
|
|
7678
7715
|
//#endregion
|
|
7679
7716
|
//#region src/host-heartbeat.ts
|
|
7680
7717
|
/**
|
package/lib/index.d.ts
CHANGED
|
@@ -5200,6 +5200,36 @@ interface Config {
|
|
|
5200
5200
|
*/
|
|
5201
5201
|
model?: string;
|
|
5202
5202
|
};
|
|
5203
|
+
/**
|
|
5204
|
+
* 被网关渠道校验拦截(`channel_reject` / 11128)之后自动再发一次「继续」,
|
|
5205
|
+
* 可选、有上限。默认关闭。
|
|
5206
|
+
*
|
|
5207
|
+
* 这条失败**不换号也不重试**(`isFailoverWorthy` 刻意把它排除在外:网关匹配的
|
|
5208
|
+
* 是消息开头,换账号只会每个账号白烧一次请求),所以这一轮就此结束,用户看到的
|
|
5209
|
+
* 是「本轮运行失败400: … 被网关的渠道校验拦截 …」。而新开一个回合会让宿主重新
|
|
5210
|
+
* 组装 surface,确实可能通过 —— 这正是 `docs/PINNED-11128-RETRY.md` 里那条
|
|
5211
|
+
* 「把这条消息再发一次」的手动做法。
|
|
5212
|
+
*
|
|
5213
|
+
* 默认关闭的理由与 {@link Config.usageIdentity} 相同:它会在用户没有发消息的
|
|
5214
|
+
* 情况下花掉真实请求,属于必须显式授权的开销。上限按用户要求默认 3 次。
|
|
5215
|
+
*/
|
|
5216
|
+
channelRejectContinue?: {
|
|
5217
|
+
/**
|
|
5218
|
+
* 是否启用自动续发。只有显式 `true` 才算开启。
|
|
5219
|
+
*/
|
|
5220
|
+
enabled?: boolean;
|
|
5221
|
+
/**
|
|
5222
|
+
* 连续自动续发的次数上限(默认 3,夹在 1..10)。
|
|
5223
|
+
*
|
|
5224
|
+
* 「连续」是关键:一次成功回合、或者用户自己发了一条消息,计数立刻清零。
|
|
5225
|
+
* 所以上限拦的是「一直过不去就一直试」,而不是这个会话一生的总次数。
|
|
5226
|
+
*/
|
|
5227
|
+
maxConsecutive?: number;
|
|
5228
|
+
/**
|
|
5229
|
+
* 续发时发送的文本,默认「继续」。
|
|
5230
|
+
*/
|
|
5231
|
+
text?: string;
|
|
5232
|
+
};
|
|
5203
5233
|
}
|
|
5204
5234
|
/**
|
|
5205
5235
|
* 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-
|
|
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-DLVGPY4k.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";
|
|
@@ -2212,6 +2212,363 @@ async function foldOnce(deps, agent, measurement, window, target) {
|
|
|
2212
2212
|
return false;
|
|
2213
2213
|
}
|
|
2214
2214
|
//#endregion
|
|
2215
|
+
//#region src/channel-continue.ts
|
|
2216
|
+
/**
|
|
2217
|
+
* 「被网关渠道校验拦截」时自动再发一次「继续」——可选、有上限。
|
|
2218
|
+
*
|
|
2219
|
+
* ## 这条功能要解决的那一次失败
|
|
2220
|
+
*
|
|
2221
|
+
* 用户粘贴的原文:
|
|
2222
|
+
*
|
|
2223
|
+
* 本轮运行失败400: {"message":"workbuddy 上游失败:被网关的渠道校验拦截(不是账号、
|
|
2224
|
+
* 额度或网络问题)(http 400)(code 11128,requestId …)。…",
|
|
2225
|
+
* "type":"channel_reject","code":"channel_reject"}
|
|
2226
|
+
*
|
|
2227
|
+
* 它被 `classifyUpstreamError` 判成 `channel_reject`(`src/upstream.ts`),而这个类
|
|
2228
|
+
* **刻意不换号**(`isFailoverWorthy`,`src/shim.ts`):网关匹配的是请求里消息的开头,
|
|
2229
|
+
* 换账号只会每个账号白烧一次真实请求。所以插件在同一请求内跑完三级救援阶梯
|
|
2230
|
+
* (`forceNeutralHead` → 一次零宽扰动 → 密集零宽扰动)之后,就把 400 原样交给宿主。
|
|
2231
|
+
*
|
|
2232
|
+
* 宿主也不会替我们重发:`channel_reject` 走的是 400,`code` 又是它不认识的字符串,
|
|
2233
|
+
* 不在 `DEFAULT_RETRYABLE_CODES` 里,于是这一轮直接结束,用户看到的正是上面那句。
|
|
2234
|
+
*
|
|
2235
|
+
* ## 为什么「再发一次」确实可能成功
|
|
2236
|
+
*
|
|
2237
|
+
* `docs/PINNED-11128-RETRY.md` §2 记录了两条互相矛盾的事实:插件自己在
|
|
2238
|
+
* `perturbChannelFingerprints` 旁的注释说「给定提示词被拒是确定性的」,而现场报告
|
|
2239
|
+
* (#87–#98、#113)反复出现「把这条消息再发一次就过了」。二者都能成立,原因是**每
|
|
2240
|
+
* 一次新回合都是一份新的请求**:宿主会把当前 surface 重新组装、注入块的位置与内容
|
|
2241
|
+
* 可能变化、插件也可能因为学到偏好而从第一发就带扰动。所以「重发一次」不是把被拒
|
|
2242
|
+
* 的字节原样再塞一遍(那才是真正的浪费),而是**开一个新回合**。
|
|
2243
|
+
*
|
|
2244
|
+
* 本模块就是把这个「手动再发一次」自动化,并且**只做这件事**:
|
|
2245
|
+
*
|
|
2246
|
+
* - 只认 `channel_reject` 这一种失败。配额、限流、网络、内容审核都有各自的处置,
|
|
2247
|
+
* 那些失败再发「继续」只会多烧一次请求(`NO_CONTINUE_CODES` 的同一判断)。
|
|
2248
|
+
* - 上限默认 3 次,**连续**计数:一次成功回合或一条真实用户消息立即清零。计数在
|
|
2249
|
+
* 「发出」时递增,所以用户看到的自动续发次数恰好等于上限。
|
|
2250
|
+
* - 不做换号、不换区域、不改请求内容、不重试同一份字节:换号对这类拒绝无效,而
|
|
2251
|
+
* 篡改请求才是这条功能不该碰的东西。
|
|
2252
|
+
* - 子代理会话一律不碰:子代理由父代理驱动,抢跑会打乱执行树。
|
|
2253
|
+
*
|
|
2254
|
+
* 默认关闭(`channelRejectContinue` 默认 false)。它会在用户没有发消息的情况下
|
|
2255
|
+
* 花掉真实请求,属于「必须由用户显式授权」的那一类开关,与 `usageIdentity`、
|
|
2256
|
+
* `imageToolEnabled` 的同一条规则。
|
|
2257
|
+
*
|
|
2258
|
+
* 参考:`dsh-client-auto-continue`(MIT,HsiangNianian/dsh-auto-continue)—
|
|
2259
|
+
* 「连续失败上限」这套**语义**取自它:`state.consecutive` 在每次发出时自增、
|
|
2260
|
+
* `consecutive >= maxConsecutive` 就停、只在真正成功的回合清零、子代理会话按
|
|
2261
|
+
* `session.header.origin === 'subagent'` 跳过、发消息用
|
|
2262
|
+
* `agent.followup(createUserMessage(...))`。本模块**没有**照搬它的引擎:那个实现
|
|
2263
|
+
* 有 1590 行,覆盖收尾原因矩阵、自适应退避、自主循环、循环守卫与浏览器通知,而
|
|
2264
|
+
* 这里只需要「一种失败、一条消息、一个上限」。
|
|
2265
|
+
* 参考:`dsh-purge`(MIT,YuJunZhiXue/dsh-purge)的 `lib/continue-retry.js` —
|
|
2266
|
+
* 三处具体做法:**被排除的失败码清单**(`NO_CONTINUE_CODES`:配额、限流、传输
|
|
2267
|
+
* 这些「重发也拿同一句回答」的失败不该续)、**收件箱忙就不插队**(`inboxBusy`),
|
|
2268
|
+
* 以及用 `user/message` 的 `source.kind` 区分「真人介入」与「插件自己发的」,以免
|
|
2269
|
+
* 自己的回显把上限清零。
|
|
2270
|
+
* 改动:本项目相对两者都是**收窄**,不是扩展——(a)只认 `channel_reject` 这一种
|
|
2271
|
+
* 失败,两套参考实现都是「除永久码外一律续」;(b)不做原地重试、不做换号、不做
|
|
2272
|
+
* 退避,因为这一类失败换谁、隔多久都是同一份请求;(c)计数状态只活在进程里、不
|
|
2273
|
+
* 落盘(参考实现有持久化文件),因为「刚刚这一串失败」本来就是即时的事实;(d)把
|
|
2274
|
+
* 订阅交给调用方注入(`onSessionEvent`),因为本插件没有 `@deepseek-ai/dsh-session`
|
|
2275
|
+
* 这条 peer 依赖,也不该为一条监听引入它。
|
|
2276
|
+
*
|
|
2277
|
+
* @module dsh-connect-workbuddy/channel-continue
|
|
2278
|
+
*/
|
|
2279
|
+
/**
|
|
2280
|
+
* 自动续发消息的 `source.kind`。
|
|
2281
|
+
*
|
|
2282
|
+
* 不用 `'user'`:那等于声称这条消息是用户敲的,转录里会渲染成右对齐的用户气泡,
|
|
2283
|
+
* 用户既没说过这句话、也就无从判断它是谁发的。`MessageSourceMap` 是**可合并扩展**
|
|
2284
|
+
* 的(其自带注释:「each producer declares its own kind in its own module」),
|
|
2285
|
+
* 声明在 `src/dsh-llm-augment.d.ts`。`dsh-connect-atomcode` / `dsh-purge` 同样做法。
|
|
2286
|
+
*/
|
|
2287
|
+
const CONTINUE_SOURCE_KIND = "workbuddy-continue";
|
|
2288
|
+
/** 默认续发的文本。用户要的就是「继续」两个字。 */
|
|
2289
|
+
const DEFAULT_CONTINUE_TEXT = "继续";
|
|
2290
|
+
/** 上限的硬边界:0 等于关闭,超过 10 次已经不属于「再试一次」的语义。 */
|
|
2291
|
+
const MAX_CONSECUTIVE_CEILING = 10;
|
|
2292
|
+
/**
|
|
2293
|
+
* 发出前的等待。
|
|
2294
|
+
*
|
|
2295
|
+
* `turn/end` 是在回合**已经收尾**之后追加的,但宿主紧接着还要把这一步的状态落定;
|
|
2296
|
+
* 立刻 `followup` 有机会撞上宿主自己的收尾动作。300ms 是 `dsh-purge` 的
|
|
2297
|
+
* `continue-retry` 在同一位置实测可用的量级,这里取同一个值,不另挑一个没有依据的数。
|
|
2298
|
+
*/
|
|
2299
|
+
const SEND_DELAY_MS = 300;
|
|
2300
|
+
/** 每个进程最多记多少个会话的计数(长开进程的 FIFO 兜底,`dsh-purge` 同一做法)。 */
|
|
2301
|
+
const SESSION_CAP = 200;
|
|
2302
|
+
/** 超过时一次丢多少个。 */
|
|
2303
|
+
const SESSION_EVICT = 50;
|
|
2304
|
+
/**
|
|
2305
|
+
* 把配置里的两个标量归一化成一个可用的设置对象。
|
|
2306
|
+
*
|
|
2307
|
+
* 两处「防御」都是有意为之:`enabled` 只认显式 `true`(这条功能会花真实请求,
|
|
2308
|
+
* 缺失或畸形必须读成「关」),`maxConsecutive` 与 `text` 越界时退回默认值,
|
|
2309
|
+
* 因为一个 `0`/`NaN`/空白文本会让功能变成静默空转——那比关着更难查。
|
|
2310
|
+
*
|
|
2311
|
+
* @param enabled - 配置里的开关标量。
|
|
2312
|
+
* @param max - 配置里的上限标量。
|
|
2313
|
+
* @param text - 配置里的文本。
|
|
2314
|
+
* @returns 归一化后的设置。
|
|
2315
|
+
*/
|
|
2316
|
+
function channelContinueSettingsOf(enabled, max, text) {
|
|
2317
|
+
const capped = Math.min(MAX_CONSECUTIVE_CEILING, Math.max(1, typeof max === "number" && Number.isFinite(max) ? Math.trunc(max) : 3));
|
|
2318
|
+
const body = typeof text === "string" && text.trim() !== "" ? text.trim() : DEFAULT_CONTINUE_TEXT;
|
|
2319
|
+
return {
|
|
2320
|
+
enabled: enabled === true,
|
|
2321
|
+
maxConsecutive: capped,
|
|
2322
|
+
text: body
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
/**
|
|
2326
|
+
* 这段失败是不是「被网关渠道校验拦截」。
|
|
2327
|
+
*
|
|
2328
|
+
* 不能只看 `code`。插件在 shim 里写的是 `type: "channel_reject"` 与
|
|
2329
|
+
* `code: "channel_reject"`(`KIND_STATUS` / 未翻译的 kind),但宿主拿到的
|
|
2330
|
+
* `LlmFailure.code` 由 pi-ai 从**消息文本**重新分类(`classifyPiAiError`,
|
|
2331
|
+
* `@deepseek-ai/dsh-llm-pi-ai`):`/\b400\b/` 命中就归成 `INVALID_REQUEST`。
|
|
2332
|
+
* 也就是说 host 侧这一份 `code` 极可能不是 `channel_reject`,而那句插件自
|
|
2333
|
+
* 己的话(连同 JSON 里的 `channel_reject`)一定在 `message` 里。
|
|
2334
|
+
*
|
|
2335
|
+
* 于是判断刻意「够宽但锚得准」:命中插件自己的 kind 字面量或它自己的那句中文说明,
|
|
2336
|
+
* 就算这一类;其余一切(配额、限流、审核、网络、模型不可用)都不算——它们各有
|
|
2337
|
+
* 自己的处置,重发「继续」只会多花一次真实请求。
|
|
2338
|
+
*
|
|
2339
|
+
* @param failure - `turn/end` 的 `reason.error`,形状按 `LlmFailure` 读。
|
|
2340
|
+
* @returns 是否属于渠道门拒绝。
|
|
2341
|
+
*/
|
|
2342
|
+
function isChannelRejectFailure(failure) {
|
|
2343
|
+
if (typeof failure !== "object" || failure === null) return false;
|
|
2344
|
+
const record = failure;
|
|
2345
|
+
if (typeof record.code === "string" && record.code === "channel_reject") return true;
|
|
2346
|
+
const message = record.message;
|
|
2347
|
+
if (typeof message !== "string" || message === "") return false;
|
|
2348
|
+
if (message.includes("channel_reject")) return true;
|
|
2349
|
+
return message.includes("渠道校验拦截");
|
|
2350
|
+
}
|
|
2351
|
+
/** 结构化的会话切片。 */
|
|
2352
|
+
function sessionIdOf(session) {
|
|
2353
|
+
const id = session?.id;
|
|
2354
|
+
return typeof id === "string" ? id : "";
|
|
2355
|
+
}
|
|
2356
|
+
/** 子代理会话:它的回合由父代理驱动,插件不该插手。 */
|
|
2357
|
+
function isSubagentSession(session, agent) {
|
|
2358
|
+
if (session?.header?.origin === "subagent") return true;
|
|
2359
|
+
return agent?.session?.header?.origin === "subagent";
|
|
2360
|
+
}
|
|
2361
|
+
/**
|
|
2362
|
+
* agent 的收件箱里是否已经有人排队。
|
|
2363
|
+
*
|
|
2364
|
+
* 只做「看起来非空」的结构探测,覆盖宿主不同版本的三种表示(`hasPending()` /
|
|
2365
|
+
* `size` / `length`)。有排队就放弃这一次续发:用户很可能刚敲了一条消息,此时插一条
|
|
2366
|
+
* 「继续」会让他的提问排到自动消息后面。
|
|
2367
|
+
*/
|
|
2368
|
+
function inboxBusy(agent) {
|
|
2369
|
+
const inbox = agent.inbox;
|
|
2370
|
+
if (typeof inbox !== "object" || inbox === null) return false;
|
|
2371
|
+
const probe = inbox;
|
|
2372
|
+
try {
|
|
2373
|
+
if (typeof probe.hasPending === "function" && probe.hasPending() === true) return true;
|
|
2374
|
+
if (typeof probe.size === "number" && probe.size > 0) return true;
|
|
2375
|
+
if (typeof probe.length === "number" && probe.length > 0) return true;
|
|
2376
|
+
} catch {}
|
|
2377
|
+
return false;
|
|
2378
|
+
}
|
|
2379
|
+
/**
|
|
2380
|
+
* 建一台自动续发引擎。
|
|
2381
|
+
*
|
|
2382
|
+
* 只认 `session/event` 上的三件事:`turn/end`(判失败/清计数)、`turn/start`
|
|
2383
|
+
* (取消待发)、`user/message`(真人插话就把计数清零)。没有轮询、没有扫描、
|
|
2384
|
+
* 没有持久化:计数失效于进程即可,它描述的本来就是「刚刚这一串失败」。
|
|
2385
|
+
*
|
|
2386
|
+
* @param host - 注入的宿主依赖。
|
|
2387
|
+
* @returns 引擎句柄。
|
|
2388
|
+
*/
|
|
2389
|
+
function createChannelContinue(host) {
|
|
2390
|
+
const states = /* @__PURE__ */ new Map();
|
|
2391
|
+
let disposed = false;
|
|
2392
|
+
const stateOf = (sessionId) => {
|
|
2393
|
+
let state = states.get(sessionId);
|
|
2394
|
+
if (state === void 0) {
|
|
2395
|
+
if (states.size >= SESSION_CAP) {
|
|
2396
|
+
let dropped = 0;
|
|
2397
|
+
for (const key of states.keys()) {
|
|
2398
|
+
if (dropped >= SESSION_EVICT) break;
|
|
2399
|
+
const victim = states.get(key);
|
|
2400
|
+
if (victim?.pending !== null && victim?.pending !== void 0) clearTimeout(victim.pending);
|
|
2401
|
+
states.delete(key);
|
|
2402
|
+
dropped += 1;
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
state = {
|
|
2406
|
+
consecutive: 0,
|
|
2407
|
+
lastHandledSeq: 0,
|
|
2408
|
+
pending: null,
|
|
2409
|
+
capped: false
|
|
2410
|
+
};
|
|
2411
|
+
states.set(sessionId, state);
|
|
2412
|
+
}
|
|
2413
|
+
return state;
|
|
2414
|
+
};
|
|
2415
|
+
const cancelPending = (state) => {
|
|
2416
|
+
if (state.pending === null) return;
|
|
2417
|
+
clearTimeout(state.pending);
|
|
2418
|
+
state.pending = null;
|
|
2419
|
+
};
|
|
2420
|
+
/** 真的把「继续」发出去。所有放弃的分支都留一行日志,不静默。 */
|
|
2421
|
+
const fire = (sessionId, state, session) => {
|
|
2422
|
+
state.pending = null;
|
|
2423
|
+
if (disposed) return;
|
|
2424
|
+
const config = host.getConfig();
|
|
2425
|
+
if (!config.enabled) return;
|
|
2426
|
+
const agent = host.getAgent(sessionId);
|
|
2427
|
+
if (agent === void 0) {
|
|
2428
|
+
host.log(`跳过自动续发 ${sessionId}:会话已没有 live agent`);
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
if (isSubagentSession(session, agent)) return;
|
|
2432
|
+
if (agent.status === "running") {
|
|
2433
|
+
host.log(`跳过自动续发 ${sessionId}:回合已经在跑`);
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
if (inboxBusy(agent)) {
|
|
2437
|
+
host.log(`跳过自动续发 ${sessionId}:收件箱里已有排队内容`);
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
try {
|
|
2441
|
+
const message = createUserMessage({
|
|
2442
|
+
content: [{
|
|
2443
|
+
type: "text",
|
|
2444
|
+
text: config.text
|
|
2445
|
+
}],
|
|
2446
|
+
source: {
|
|
2447
|
+
kind: CONTINUE_SOURCE_KIND,
|
|
2448
|
+
form: "notice",
|
|
2449
|
+
summary: "channel-reject-continue"
|
|
2450
|
+
}
|
|
2451
|
+
});
|
|
2452
|
+
agent.followup(message);
|
|
2453
|
+
state.consecutive += 1;
|
|
2454
|
+
host.log(`已自动发送「${config.text}」到 ${sessionId}(被渠道校验拦截),第 ${String(state.consecutive)}/${String(config.maxConsecutive)} 次连续`);
|
|
2455
|
+
if (state.consecutive >= config.maxConsecutive) host.log(`已达连续上限 ${String(config.maxConsecutive)} 次,${sessionId} 不再自动续发,等用户介入`);
|
|
2456
|
+
} catch (error) {
|
|
2457
|
+
host.log(`自动续发失败 ${sessionId}:${error instanceof Error ? error.message : String(error)}`);
|
|
2458
|
+
}
|
|
2459
|
+
};
|
|
2460
|
+
return {
|
|
2461
|
+
onSessionEvent(session, event) {
|
|
2462
|
+
if (disposed) return;
|
|
2463
|
+
const sessionId = sessionIdOf(session);
|
|
2464
|
+
if (sessionId === "") return;
|
|
2465
|
+
const record = event;
|
|
2466
|
+
const type = record?.type;
|
|
2467
|
+
if (typeof type !== "string") return;
|
|
2468
|
+
const state = stateOf(sessionId);
|
|
2469
|
+
if (type === "turn/start") {
|
|
2470
|
+
cancelPending(state);
|
|
2471
|
+
return;
|
|
2472
|
+
}
|
|
2473
|
+
if (type === "user/message") {
|
|
2474
|
+
if (record?.data?.source?.kind === "user") {
|
|
2475
|
+
cancelPending(state);
|
|
2476
|
+
state.consecutive = 0;
|
|
2477
|
+
state.capped = false;
|
|
2478
|
+
}
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
if (type !== "turn/end") return;
|
|
2482
|
+
const seq = typeof record?.seq === "number" ? record.seq : 0;
|
|
2483
|
+
if (seq !== 0 && state.lastHandledSeq === seq) return;
|
|
2484
|
+
state.lastHandledSeq = seq;
|
|
2485
|
+
const reason = record?.data?.reason;
|
|
2486
|
+
const kind = typeof reason === "object" && reason !== null ? reason.kind : void 0;
|
|
2487
|
+
if (kind === "completed") {
|
|
2488
|
+
cancelPending(state);
|
|
2489
|
+
state.consecutive = 0;
|
|
2490
|
+
state.capped = false;
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
if (kind === "aborted") {
|
|
2494
|
+
if ((reason?.reason)?.kind === "user") {
|
|
2495
|
+
cancelPending(state);
|
|
2496
|
+
state.consecutive = 0;
|
|
2497
|
+
state.capped = false;
|
|
2498
|
+
}
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
if (kind !== "error") return;
|
|
2502
|
+
const failure = reason.error;
|
|
2503
|
+
if (!isChannelRejectFailure(failure)) return;
|
|
2504
|
+
const config = host.getConfig();
|
|
2505
|
+
if (!config.enabled) return;
|
|
2506
|
+
if (state.pending !== null) return;
|
|
2507
|
+
if (state.consecutive >= config.maxConsecutive) {
|
|
2508
|
+
if (!state.capped) {
|
|
2509
|
+
state.capped = true;
|
|
2510
|
+
host.log(`已达连续上限 ${String(config.maxConsecutive)} 次,${sessionId} 停止自动续发`);
|
|
2511
|
+
}
|
|
2512
|
+
return;
|
|
2513
|
+
}
|
|
2514
|
+
host.log(`检测到渠道门拒绝 ${sessionId},${String(SEND_DELAY_MS)}ms 后自动发送「${config.text}」(第 ${String(state.consecutive + 1)}/${String(config.maxConsecutive)} 次)`);
|
|
2515
|
+
state.pending = setTimeout(() => {
|
|
2516
|
+
try {
|
|
2517
|
+
fire(sessionId, state, session);
|
|
2518
|
+
} catch (error) {
|
|
2519
|
+
host.log(`自动续发回调异常 ${sessionId}:${error instanceof Error ? error.message : String(error)}`);
|
|
2520
|
+
}
|
|
2521
|
+
}, SEND_DELAY_MS);
|
|
2522
|
+
state.pending.unref?.();
|
|
2523
|
+
},
|
|
2524
|
+
dispose() {
|
|
2525
|
+
disposed = true;
|
|
2526
|
+
for (const state of states.values()) {
|
|
2527
|
+
if (state.pending !== null) clearTimeout(state.pending);
|
|
2528
|
+
state.pending = null;
|
|
2529
|
+
}
|
|
2530
|
+
states.clear();
|
|
2531
|
+
},
|
|
2532
|
+
stateOf(sessionId) {
|
|
2533
|
+
const state = states.get(sessionId);
|
|
2534
|
+
if (state === void 0) return void 0;
|
|
2535
|
+
return {
|
|
2536
|
+
consecutive: state.consecutive,
|
|
2537
|
+
pending: state.pending !== null
|
|
2538
|
+
};
|
|
2539
|
+
}
|
|
2540
|
+
};
|
|
2541
|
+
}
|
|
2542
|
+
/**
|
|
2543
|
+
* 把引擎挂到宿主的事件总线上。
|
|
2544
|
+
*
|
|
2545
|
+
* 订阅走注入的 `onSessionEvent`,而不是在这里 `ctx.on`:宿主把 `session/event`
|
|
2546
|
+
* 声明在 `@deepseek-ai/dsh-session` 的类型里,本插件没有也不该有那条 peer 依赖,
|
|
2547
|
+
* 由调用方用一行鸭子类型的 cast 提供,才能让这个模块在没装宿主包的测试里也能跑。
|
|
2548
|
+
*
|
|
2549
|
+
* @param mount - 宿主切片。
|
|
2550
|
+
* @returns 卸载函数(取消订阅并清空计数)。
|
|
2551
|
+
*/
|
|
2552
|
+
function installChannelContinue(mount) {
|
|
2553
|
+
const engine = createChannelContinue(mount);
|
|
2554
|
+
let off;
|
|
2555
|
+
try {
|
|
2556
|
+
off = mount.onSessionEvent((session, event) => {
|
|
2557
|
+
engine.onSessionEvent(session, event);
|
|
2558
|
+
});
|
|
2559
|
+
} catch (error) {
|
|
2560
|
+
mount.log(`未能订阅 session/event,自动续发不可用(插件其余部分不受影响):${error instanceof Error ? error.message : String(error)}`);
|
|
2561
|
+
engine.dispose();
|
|
2562
|
+
return () => {};
|
|
2563
|
+
}
|
|
2564
|
+
return () => {
|
|
2565
|
+
try {
|
|
2566
|
+
off?.();
|
|
2567
|
+
} catch {}
|
|
2568
|
+
engine.dispose();
|
|
2569
|
+
};
|
|
2570
|
+
}
|
|
2571
|
+
//#endregion
|
|
2215
2572
|
//#region src/body-report.ts
|
|
2216
2573
|
/**
|
|
2217
2574
|
* Body-composition report: WHAT was sent upstream, in counts (issue #59).
|
|
@@ -9627,7 +9984,8 @@ const UPSTREAM_SWITCH_FIELDS = /* @__PURE__ */ new Set([
|
|
|
9627
9984
|
"keepPromptLast",
|
|
9628
9985
|
"extraInjectedPrefixes",
|
|
9629
9986
|
"sessionTitle",
|
|
9630
|
-
"dropForeignThinking"
|
|
9987
|
+
"dropForeignThinking",
|
|
9988
|
+
"channelRejectContinue"
|
|
9631
9989
|
]);
|
|
9632
9990
|
/** A non-null, non-array object: the only shape the slot merge applies to. */
|
|
9633
9991
|
function isPlainRecord(value) {
|
|
@@ -10536,7 +10894,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
10536
10894
|
req.on("error", reject);
|
|
10537
10895
|
});
|
|
10538
10896
|
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
|
|
10897
|
+
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
10898
|
const row = settings.describe().find((r) => String(r.ns).includes("workbuddy"));
|
|
10541
10899
|
if (row === void 0) return json(res, 503, { error: "workbuddy namespace missing from describe()" });
|
|
10542
10900
|
const current = field === "regions" ? regionsMapOf(row.value?.[field]) : unwrapVolatileDeep(row.value?.[field] ?? {});
|
|
@@ -10951,7 +11309,12 @@ const Config = z.object({
|
|
|
10951
11309
|
sessionTitle: asVolatile(z.object({
|
|
10952
11310
|
enabled: z.boolean().default(false),
|
|
10953
11311
|
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."))
|
|
11312
|
+
}).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.")),
|
|
11313
|
+
channelRejectContinue: asVolatile(z.object({
|
|
11314
|
+
enabled: z.boolean().default(false),
|
|
11315
|
+
maxConsecutive: z.number().step(1).min(1).max(10).default(3),
|
|
11316
|
+
text: z.string().default("继续")
|
|
11317
|
+
}).default({}).description("After the gateway refuses a turn with `channel_reject` (business code 11128), send 「继续」 once more automatically — optional and capped. Off by default: it spends a real request without the user typing anything. That refusal is deliberately NOT retried in place and NOT failed over (every account sends the same opening), so the turn would otherwise just fail; a new turn rebuilds the request surface, which is the manual \"send it again\" remedy docs/PINNED-11128-RETRY.md already recommends. `maxConsecutive` (default 3) 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
11318
|
});
|
|
10956
11319
|
/**
|
|
10957
11320
|
* One region's saved model state. A config written before the region split has
|
|
@@ -11259,6 +11622,34 @@ function apply(ctx, config) {
|
|
|
11259
11622
|
let current = () => config;
|
|
11260
11623
|
let invalidateCatalog = () => {};
|
|
11261
11624
|
/**
|
|
11625
|
+
* 渠道门拒绝(`channel_reject` / 11128)之后的自动「继续」。
|
|
11626
|
+
*
|
|
11627
|
+
* 挂在这里而不是 adapter 里,理由是**它要的是一个新回合**:adapter 只看得到
|
|
11628
|
+
* 一次请求,`channel_reject` 在同一个请求内已经跑完三级救援阶梯,再在那一层
|
|
11629
|
+
* 重发就是把被拒的字节原样再送一遍。宿主把这一轮判死之后才追加 `turn/end`,
|
|
11630
|
+
* 而新回合会让宿主重新组装 surface —— 那才是 `docs/PINNED-11128-RETRY.md`
|
|
11631
|
+
* 里「把这条消息再发一次」真正做的事。
|
|
11632
|
+
*
|
|
11633
|
+
* 会花真实请求,所以开关默认关闭,且只在用户显式打开后才读配置(见
|
|
11634
|
+
* `channelContinueSettingsOf`:只有 `=== true` 算开启)。
|
|
11635
|
+
*/
|
|
11636
|
+
const disposeChannelContinue = installChannelContinue({
|
|
11637
|
+
getConfig: () => {
|
|
11638
|
+
const block = unwrapVolatile(current().channelRejectContinue);
|
|
11639
|
+
return channelContinueSettingsOf(block?.enabled, block?.maxConsecutive, block?.text);
|
|
11640
|
+
},
|
|
11641
|
+
getAgent: (sessionId) => {
|
|
11642
|
+
return ctx.get("agents")?.get(sessionId);
|
|
11643
|
+
},
|
|
11644
|
+
log: (message) => {
|
|
11645
|
+
ctx.logger.warn(`dsh-connect-workbuddy: ${message}`);
|
|
11646
|
+
},
|
|
11647
|
+
onSessionEvent: (listener) => ctx.on("session/event", listener)
|
|
11648
|
+
});
|
|
11649
|
+
ctx.effect(() => () => {
|
|
11650
|
+
disposeChannelContinue();
|
|
11651
|
+
});
|
|
11652
|
+
/**
|
|
11262
11653
|
* REPAIR a region's saved directory against the live upstream roster.
|
|
11263
11654
|
*
|
|
11264
11655
|
* ISSUES #114 AND #115, THE PERSISTED HALF. The read side now serves the
|
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.
|
|
5
|
+
"version": "5.13.0",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|