dsh-turn-recovery 0.2.0 → 0.2.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.
Files changed (4) hide show
  1. package/CHANGELOG.md +286 -231
  2. package/README.md +12 -9
  3. package/lib/index.js +201 -40
  4. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,231 +1,286 @@
1
- # Changelog
2
-
3
- 本文件记录 dsh-turn-recovery 的所有重要变更,格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
4
- 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
5
-
6
- > **改名说明(0.2.0)**:本插件原名 `dsh-empty-turn-guard`,因与另外两个同名项目(`FYTXY/dsh-empty-turn-guard`、
7
- > `@nlflora/dsh-empty-turn-guard`)冲突,自 0.2.0 起更名为 `dsh-turn-recovery`。0.1.x 及更早的条目保留旧名原文,
8
- > 其提到的 `plugin:empty-turn-guard`、`empty-turn-guard.log` 等标识在 0.2.0 起分别改为 `plugin:turn-recovery`、`turn-recovery.log`。
9
-
10
- ## [0.2.0] - 2026-11-01
11
-
12
- **更名发布**,功能与 0.1.6 完全一致,不含行为变化。
13
-
14
- ### 变更
15
-
16
- - npm 包名 `dsh-empty-turn-guard` → **`dsh-turn-recovery`**;GitHub 仓库同步更名。
17
- - 运行时插件 id `empty-turn-guard` → **`turn-recovery`**(`export const name`、`dsh.bundle.patch` 的 `- id:`、profile 里覆盖用的 `- id:` 全部随之改变)。
18
- - 补写消息来源 `plugin:empty-turn-guard` → **`plugin:turn-recovery`**。
19
- - 系统提示段落名 `empty-turn-guard` → **`turn-recovery`**(`order` 仍为 960)。
20
- - 日志文件名 `empty-turn-guard.log` → **`turn-recovery.log`**。
21
- - 版本号从 0.1.6 跳到 0.2.0:包名与插件 id 都是破坏性变更(旧的 profile 注册与 `node_modules` 链接必须更新,否则宿主会静默跳过该 bundle),按语义化版本应当断代。
22
-
23
- ### 升级须知
24
-
25
- 从 0.1.x 升级需要同时改三处,缺一处宿主要么静默不加载、要么加载旧目录:
26
-
27
- 1. profile 的 `dependencies` 与 `dsh.profile.bundles` 里的包名;
28
- 2. profile 的 `cordis.patch.yml` 里 `- id: empty-turn-guard` → `- id: turn-recovery`(配置内容不变);
29
- 3. `node_modules` 里的包链接(junction)需要重建为新包名。
30
-
31
- 旧日志文件 `~/.dsh/logs/empty-turn-guard.log` 不会自动改名,可以保留作历史,也可以直接删除。
32
-
33
-
34
- 修复第四轮复核(DSH `0.2.1-rc.5`)确认的两个**独立状态机缺陷**,以及用户点停止不可观察、
35
- 日志/配置层面的若干小问题。`lib/index.js` 的判定口径没变,改的都是「状态什么时候被清/被置」。
36
-
37
- ### 修复
38
-
39
- - **P2-A 认领不校验消息 id(中)**:`user/message` 只要 `source.kind = 'plugin:empty-turn-guard'`
40
- 就置 `awaitingGuardTurn = true`,而 `matched` 只写进日志。于是上次进程遗留、重放、或另一实例
41
- 写入的同来源消息会被当成「补写回合」,让下一个**真正的**空回合被 `stop chaining` 静默跳过。
42
- 现在只有 id 与在途补写一致才置位,不一致记 `NOT ours` 并忽略。
43
- - **P2-B 缺少数值 turn 的 turn/end 不清状态(中)**:早退分支在 `wasGuard` 取值与清零之前
44
- `return`,`awaitingGuardTurn` 永久留在 `true`。现在先取后清,再判断 turn 是否可判定。
45
- (当前宿主唯一 emitter 恒给 number,属潜伏缺陷,用 mock 可复现。)
46
- - **P2-C 用户点停止不可观察(低,缓解)**:宿主 `cancel()` 只清 inbox + abort,不发 session 事件,
47
- 900ms 窗口内仍会补写。现在订阅宿主的 `agent/inbox/discarded`,**只**在丢弃的正是我们那条
48
- 补写时才撤标记;宿主不广播该事件时退回旧行为,不会误伤。
49
- - **日志轮转更鲁棒(低)**:Windows 上多进程持句柄时 `rename` 会 EPERM/EBUSY,现在回退
50
- `truncate(0)` 并记一条日志(宁可丢历史,也不让日志无限长)。
51
- - **agent 注册表对账(低)**:除 `agent/disposed` 外,注册表每增长 20 条向宿主 `agents.list()`
52
- 对账一次,删掉已消失的条目。
53
- - **会话过滤留痕(低)**:`sessionScope='root'` 过滤掉的会话每个 id 记一条 `skip scope` 日志,
54
- 「插件没加载」与「被 scope 静默过滤」不再长得一样。
55
- - **激活日志带版本号**:`plugin activated: pid=… version=…`,版本从自身 `package.json` 读,
56
- 不再需要靠「某字段是否存在」推断进程里跑的是哪一版。
57
- - **`instructionText` 上限 4000 字符**:它进每回合 system prompt,超长会长期抬高输入 token。
58
-
59
- ### 测试
60
-
61
- - `test/guard.test.mjs` 28 → **34 条**:新增 P2-A(id 不符不吞下一回合)、P2-B(缺 turn 也清状态)、
62
- P2-C(丢弃自己那条 → 释放在途标记;丢弃别人的 → 不动)、激活日志版本号、`skip scope` 只记一次、
63
- `instructionText` 截断。
64
- - 修了测试夹具的一处失真:`guardClaim()` 过去不带 id,无法表达「认领」。
65
-
66
- ## [0.1.5] - 2026-10-29
67
-
68
- 修复桌面端 / 网页端复核发现的状态机与来源分类问题。
69
-
70
- ### 修复
71
-
72
- - **投递 ≠ 认领(中)**:`awaitingGuardTurn` 过去在 `agent.send()` 成功时就置位,但 send 只是把消息放进
73
- `next-turn` 队列;消息在认领前可能被用户停止/会话取消清掉,下一次真正的空回合会被当成「连环补写」
74
- 跳过。现在投递成功只记录 `nudgeMessageId`(在途),只有转录里出现 `source.kind =
75
- 'plugin:empty-turn-guard'` 的 `user/message`(被宿主认领)才置位 `awaitingGuardTurn`。在途补写 120 秒
76
- 未被认领视为过期;上一笔未认领前不再排第二笔。
77
- - **后台来源白名单覆盖最新宿主(中)**:新增 `agent-instructions` / `ptc-mode` / `model-selection` /
78
- `session-reference` / `skill-invocation` / `compact-checkpoint`。这些是宿主或插件写入的上下文/状态消息,
79
- 不是有人在说话;旧白名单会把它们当插话,取消待发补写。
80
- - **文档口径**:README 的附件判定改为「助手消息含附件不算空」;补充 `dsh headless` 一次性进程的限制;
81
- 更新 2026-10-29 的实测样本。
82
-
83
- ### 测试
84
-
85
- - `test/guard.test.mjs` 从 27 条扩到 **28 条**:新增「投递 ≠ 认领」用例;后台注入用例覆盖最新宿主的
86
- 6 个来源;预算/连环补写用例改为显式模拟宿主认领。
87
-
88
- ### 验证
89
-
90
- - 用真实 DSH `0.2.1-rc.3` 宿主 + 假模型做端到端验证:turn 1 返回 ZWSP → 插件 `nudge DELIVERED` →
91
- turn 2 出现 `plugin:empty-turn-guard` 的 `user/message` → 模型返回可见正文,`wasGuard=true`。
92
- 桌面端生产日志仍未遇到 `completed` 空回合,链路已由 e2e 覆盖。
93
-
94
- ## [0.1.4] - 2026-10-29
95
-
96
- 修复第三轮评审(技术方案评审团:架构 / 应用安全 / 现实核对)确认的缺陷。核心是**空回合判定本身**:
97
- 0.1.3 之前漏判「UI 空白但 `visibleChars >= 1`」的回合,而那正是本插件唯一要救的场景。
98
-
99
- ### 修复
100
-
101
- - **零宽集改用 Unicode 属性(高)**:0.1.3 的手写码位清单漏了 `U+2066–U+2069`(双向隔离符)、`U+E0001`、
102
- `U+E0020–U+E007F`(tag 字符)、`U+1D173–U+1D17A`(音乐控制符)——这些字符渲染为空白却被计成正文,
103
- 回合因此不补写。现在改用 `\p{Default_Ignorable_Code_Point}` 并补 `U+2800`/`U+3164`/`U+FFA0` 三个
104
- 不属于 DI 的填充符,实测不误伤中文/英文/数字/标点。
105
- - **`inject` 假投递(高)**:`agent.inject(m)` 实际是 `send(m, 'next-step', false)`——**不唤醒**,
106
- 消息躺在 next-step 队列里等下一个回合认领。旧实现把它当第三级兜底,于是日志写 `DELIVERED`、
107
- `awaitingGuardTurn` 置 `true`,却没有任何新回合发生,下一次真空回合还会被当成「连环补写」跳过。
108
- 0.1.4 起只保留会唤醒的 `send(next-turn, wakeup)` 与等价的 `followup`,全部失败就明确记 `NOT DELIVERED`。
109
- - **热重载中途误判(中)**:0.1.3 去掉了 `assistantMessages > 0` 门槛后,插件在回合中途加载/热重载时
110
- 看不到该回合早前的助手消息,`visibleChars = 0` 会被当成空回合而发假提醒。新增 `sawTurnStart`,
111
- 没见过 `turn/start` 的回合一律不判定(日志 `turn/start not observed`)。
112
- - **日志 tag 恒定(中)**:根会话 id 形如 `session-<uuid>`,`id.slice(0, 8)` 恒为 `session-`,多会话无法区分。
113
- 现在先剥 `session-` 前缀再取 8 位。
114
- - **图片附件回合误判(低)**:只有 `image` / `file` / `attachment` / `audio` / `video` 的回合在界面上并非空白,
115
- 不再判空(`tool-call`、`reasoning` 仍按空回合处理)。
116
- - **`turn` 缺失(低)**:`turn/end` 没有数值 `turn` 时直接跳过判定(旧实现拿 `s.turn` 兜底,两者都为 `null`
117
- 会命中 `guardSentForTurn === turn` 而静默跳过)。
118
- - **会话范围(低)**:默认只处理根会话(`/^session-/`)。子代理会话(裸 UUID)「只调工具不回正文」是正常收尾,
119
- 社交桥会话(`social-*`)也不是空回合,都不再补写。需要旧行为可设 `sessionScope: 'all'`。
120
- - **插话判定(低)**:`runtime-context` / `time-context` / `skill-catalog` / `dsh-mnemon` 与 `plugin:*`
121
- 视为背景注入,不取消待发补写;其余来源(真人、子代理回报、微信/社交桥)都取消。
122
- - **兜底日志不轮转(低)**:`maybeRotate` 现在对主路径和 `os.tmpdir()` 兜底路径都生效,轮转前显式删除旧 `.1`
123
- (避免 Windows 上覆盖失败)。
124
- - **`engines.dsh` 加上界**:`>=0.2.1-rc.1 <0.3.0`。原来没有上界,将来 0.3.0 起 API 若变,插件会静默加载失败或行为异常。
125
-
126
- ### 测试
127
-
128
- - `test/guard.test.mjs` 从 19 条扩到 **27 条**。新增:中途加载不判定、图片附件不判空、`turn` 缺失、
129
- 会话范围(root/all)、后台注入不取消 / 社交桥取消、只有 `inject` 可用时不算投递成功、
130
- DI 码位(`U+2066`/`U+E0020`/`U+1D173`/`U+E0100`/`U+FFA0`)、日志 tag 剥前缀。
131
-
132
- ### 已知未解
133
-
134
- - 补写消息仍以 `role:'user'` 落进持久转录(`source.kind = 'plugin:empty-turn-guard'`)。宿主
135
- `dsh-session` 对 `source.kind` 无枚举校验,桌面 UI 会把非 `user` 来源渲染成 `kind:'context'` 的上下文块
136
- 而非用户气泡,但转录层面它仍是一条 user 消息——插件侧无法改 `role`,只能靠 `source` 标注。
137
- - 生产环境至今 **0 次**真实补写投递(日志 `nudge DELIVERED` 为 0),链路正确性仍缺一次端到端演练。
138
-
139
- ## [0.1.3] - 2026-10-26
140
-
141
- 修复评审(技术方案评审团:架构 / 应用安全 / 现实核对)发现的缺陷,重点是空回合补写的状态机。
142
-
143
- ### 修复
144
-
145
- - **补写状态机(高)**:`awaitingGuardTurn` 在三条早退路径上不清零——真人插话取消、补写回合非 `completed`(`aborted` / `error` / `max-tokens`)、三级投递全失败——导致**下一次真正的空回合被误判成「连环补写」而静默跳过**,日志还写 `stop chaining`。现在 `turn/end` 入口先取 `wasGuard` 再无条件清零;投递失败也显式清零。
146
- - **`runtime-context` 被当成真人插话(中)**:宿主每一步都会 append 一条 `source.kind === 'runtime-context'` 的 `user/message`(`dsh-agent-loop`),旧实现把所有非本插件的 `user/message` 都当成真人取消补写,实际上补写几乎必被取消。现在只有 `source.kind === 'user'`(`source` 缺失时保守视为真人)才取消。
147
- - **小时预算被取消操作吃掉(中)**:旧实现「调度即记账」,取消/失败也占额度;现在按**投递成功**记账。
148
- - **零宽字符集过窄(中)**:补上 `U+3164`(韩文填充符,最常被当作「隐形字」)、`U+2800`(盲文空白)、`U+180B–U+180E`、`U+FE00–U+FE0F`、`U+2065`、`U+206A–U+206F`、`U+FFA0`、`U+E0100–U+E01EF`、`U+034F`、`U+061C`、`U+115F`、`U+1160`、`U+17B4`、`U+17B5`、`U+2028`、`U+2029` 等。
149
- - **配置语义**:`maxNudgesPerHour: 0` 现在真的关闭补写(旧实现被 `|| 3` 吞成 3);`delayMs: "abc"` 等非法值回落默认值(旧实现 `|| 0` 变成 0 毫秒);`enabled: "false"` 字符串现在真的关闭(旧实现只认布尔 `false`)。
150
- - **回合竞态**:空回合结束后 900ms 内若新回合自己起来,旧实现会把补写插进别人的回合;现在 `turn/start` 取消过期定时器,定时器触发时再校验 `pendingTurn` 是否仍等于当前回合。
151
- - **漏判**:没有任何 `assistant/message` 的 `completed` 回合(`visibleChars === 0`)旧实现因为 `assistantMessages > 0` 前置条件而漏判,现在会补写。
152
- - **热启用惰性**:旧实现只靠 `agent/created` 填 agent 表,插件热启用时存量会话永远没有 agent、补写全被 `SKIPPED`。现在启动时向宿主 `agents` 注册表 `list()` 回填一次。
153
- - **日志**:跟随 `DSH_HOME`(旧实现硬编码 `os.homedir()`)、超过 5 MB 自动轮转、每行带会话 id 前 8 位与 `wasGuard`。
154
-
155
- ### 变更
156
-
157
- - 补写消息正文不再要求模型「不要提及这条提醒」:该消息以 `role:'user'` 落进持久转录,隐瞒来源属于破坏转录完整性。现在明确写「插件自动补发」,并带 `source = { kind: 'plugin:empty-turn-guard', plugin, reason }`。
158
-
159
- ### 测试
160
-
161
- - `test/guard.test.mjs` 从 9 条扩到 **19 条**(此处原写「18 条」有误),并修正**事件载荷形状**:宿主 `session.append(type, data)` 的 `data` 就是消息本体(`data.source.kind`),旧用例包了一层 `{ message: { source } }`,导致「真人插话取消」「plugin 来源不参与判定」两条用例实际空转。新增:非 completed 后不污染下一回合、投递全失败后可恢复、`runtime-context` 不取消、新回合丢弃过期补写、配置 0/非法值语义、扩展零宽集、热启用回填、卸载清理。
162
-
163
- ### 明确不做
164
-
165
- - `inject = ['systemPrompt']` 保持硬依赖。本版本 `@deepseek-ai/cordis` 的 `Inject.resolve` 只接受数组或「name → config 映射」,**没有 `required`/`optional` 语义**;改成 `{ optional: [...] }` 会被当成「等一个叫 optional 的服务」,插件永不激活。
166
-
167
- ## [0.1.2] - 2026-10-25
168
-
169
- 放宽宿主版本门槛:功能无改动。
170
-
171
- ### 变更
172
-
173
- - `engines.dsh` 从 `>=0.2.1-rc.2` 放宽到 `>=0.2.1-rc.1`。
174
- 依据:本插件用到的宿主 API(`ctx.systemPrompt.section`、`agent.send` / `followup` / `inject`、
175
- `agent/created`、`session/event`、`dsh.bundle.patch` 加载)在 DSH `0.2.1-rc.1` / `rc.2` / `rc.3`
176
- 三个版本的实现**逐字节相同**(`dsh-system-prompt`、`dsh-agent-loop`、`dsh-agent`、`dsh-session`、
177
- `dsh-app-boot`、`dsh-plugin-manager`、`dsh-web-app` 的 `lib/index.js` md5 三版本一致,差异仅在
178
- `package.json` 的版本号字段)。所以 rc.1 上代码本来就能跑,旧门槛只是把它挡在门外——
179
- 宿主 `dsh-app-boot` 的版本闸门不满足时会把 bundle 收进 `skippedBundles`,启动只打一行
180
- `dsh: skipping profile bundle "dsh-empty-turn-guard": …` 然后**静默不加载**。
181
-
182
- ### 注意
183
-
184
- - **不要**把范围写成 `>=0.2.1`。semver 规则里预发布版本不满足「不含预发布标识的同段范围」,
185
- `0.2.1-rc.3` 对 `>=0.2.1` 判为 `false`,那样会把 rc.1 / rc.2 / rc.3 全部挡掉。
186
-
187
- ## [0.1.1] - 2026-10-25
188
-
189
- 发布规范化版本:功能与 0.1.0 一致,补齐工程与元数据。
190
-
191
- ### 新增
192
-
193
- - `test/guard.test.mjs`:9 条契约测试,用 mock ctx 驱动真实 `apply()`,不依赖宿主即可回归。
194
- 覆盖零宽空回合补写、正常回合不干预、非 `completed` 不补写、补写回合再空不连环、
195
- 真人插话取消、`send` 失败降级 `followup`、`plugin:` 来源消息不参与判定。
196
- - `scripts/check-metadata.mjs`:发布前元数据检查,校验字段齐全、`files` 路径真实存在、
197
- 无 `TODO` 占位符、`cordis.patch.yml` 的 `- insert:` 与包名对齐、入口可 `import`。
198
- - `CHANGELOG.md`(本文件)。
199
-
200
- ### 修正
201
-
202
- - 重新统计并修正 README 里的实测样本数据(原 0.1.0 写的是 738 回合 / 102 空 / 13.8%,
203
- 该口径把只有工具调用的步骤也算成了空回合)。准确口径:208 会话 / 766 回合,
204
- 可见正文为空的回合 39 个(5.1%),其中 `completed` 的 8 个(1.2%)才是本插件处理的对象。
205
-
206
- ### 变更
207
-
208
- - `package.json` 补齐发布元数据:`repository` / `homepage` / `bugs` / `author` / `exports` /
209
- `engines` / `publishConfig` / `scripts`。
210
- - `prepublishOnly` 串联 `check-metadata` 与 `test`,检查不过不允许发布。
211
-
212
- ## [0.1.0] - 2026-10-25
213
-
214
- 首个可用版本。
215
-
216
- ### 新增
217
-
218
- - **C 空回合保护**:监听 `session/event`,在 `turn/end`(`reason.kind === 'completed'`)时
219
- 累计本回合所有 `assistant/message` 的可见正文;去掉零宽字符后为空则自动排一条
220
- `target: 'next-turn'` 的补写消息(`agent.send(msg, 'next-turn', true)`,
221
- 失败依次降级 `followup` → `inject`),让模型在下一回合补出正文。
222
- - **A 收尾指令注入**:通过 `systemPrompt.section({ name, order: 960, interpolate: false })`
223
- 注入 222 字指令,要求每回合以看得见的正文收尾、禁止用零宽字符或空白充当回答。
224
- - 防误伤:同回合最多补写一次、补写回合再空即停、每小时最多 3 次、
225
- 定时器触发前收到真人消息立即取消、`plugin:` 来源消息不参与判定。
226
- - 日志落 `~/.dsh/logs/empty-turn-guard.log`,主路径不可写时兜底到系统临时目录。
227
-
228
- ### 说明
229
-
230
- - 修复目标问题:模型把输出预算烧在 reasoning 上、正文只返回一个 `U+200B`,
231
- 而 DSH 的 `EMPTY_RESPONSE` 判空因「文本非空」不触发,回合被记为 `completed` 但界面空白。
1
+ # Changelog
2
+
3
+ 本文件记录 dsh-turn-recovery 的所有重要变更,格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
4
+ 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
5
+
6
+ > **改名说明(0.2.0)**:本插件原名 `dsh-empty-turn-guard`,因与另外两个同名项目(`FYTXY/dsh-empty-turn-guard`、
7
+ > `@nlflora/dsh-empty-turn-guard`)冲突,自 0.2.0 起更名为 `dsh-turn-recovery`。0.1.x 及更早的条目保留旧名原文,
8
+ > 其提到的 `plugin:empty-turn-guard`、`empty-turn-guard.log` 等标识在 0.2.0 起分别改为 `plugin:turn-recovery`、`turn-recovery.log`。
9
+
10
+ ## [0.2.1] - 2026-11-03
11
+
12
+ **适配性修复**:针对 DSH `0.2.2-alpha.3` 实测(宿主 `dsh-session` / `dsh-agent-loop` /
13
+ `dsh-system-prompt` / `dsh-app-boot` 源码取证 + 本机 37 个会话日志复核)。不含新功能,
14
+ 但有三处行为变化,升级前请读。
15
+
16
+ ### 修复
17
+
18
+ - **会话范围改按 `SessionHeader` 判定**(原 `lib/index.js` 用 `/^session-/` 猜 id 前缀):
19
+ 只有 `header.origin === 'subagent'` 或 `header.delegationDepth > 0` 才算子代理会话。
20
+ 0.2.0 把 `orchestrator-*` 这类顶层会话全部当成子代理过滤掉,实测日志里有 19 条
21
+ `skip scope: non-root session orchestrator` —— 通过路由器(orchestrator 会话)跑的
22
+ 主对话从来没被保护过。宿主没有 header 时(老宿主 / 测试桩)退回 id 形态判定。
23
+ - **取消策略由「后台黑名单」改为「前台白名单」**:默认只有 `source.kind === 'user'`
24
+ 与 `'agent-message'` 会取消待发补写,且 `source.form === 'notice'` 永远不取消。
25
+ 0.2.0 的黑名单没收录宿主后来新增的 `tool-jobs` / `action-cards` / `plan-mode` /
26
+ `tool-goal`(都是 `form: "notice"`)与 `goal` / `schedule` 自动化来源,它们会让
27
+ 已排好的补写被静默取消。未知来源现在一律按后台处理,漏取消由 `turn/start`
28
+ 分支兜底;自定义桥接可继续用 `foregroundKinds` 加回取消语义。
29
+ - **prompt 段落注册失败改为 fail-closed**:宿主对同名 section 重复注册会直接 throw
30
+ (同一插件被两个 bundle 各装一次就会这样)。旧版 catch 之后仍打印 `guard armed`,
31
+ 于是「222 字符收尾指令其实没进 prompt」在日志里看不出来,且两个实例会各发一条补写。
32
+ 现在记 `guard NOT armed (fail-closed: ...)` 并整体不启用。
33
+ - **投放前查宿主队列去重**:`nudgeMessageId` 是进程内记忆,重启 / 热重载 / 双实例时为空。
34
+ 投递前检查 `agent.inbox.nextTurn` 里是否已有 `plugin:turn-recovery` 来源的待认领消息,
35
+ 有则跳过,避免同一个空回合被连投两笔。
36
+ - **小时预算跨重启生效**:投递时刻追加到 `$DSH_HOME/state/turn-recovery/nudges.log`,
37
+ 进程启动后首次见到某会话时回读最近一小时。旧版的 `nudgeTimes` 只在内存里,重启即清零,
38
+ 同一会话每次重启都能重新领满额度。
39
+ - **`engines.dsh` 上界收紧为 `<0.3.0-0`**:宿主用 `semver` 的 `includePrerelease: true`
40
+ 判定门槛,`<0.3.0` 会放行 `0.3.0-alpha.1` / `0.3.0-beta.2`(实测为 true)。`-0` 是显式的
41
+ 预发布截断。`scripts/check-metadata.mjs` 新增规则强制上界必须写成 `x.y.z-0`。
42
+ - **日志可读性**:`guard armed` 现在带 `instruction=registered|disabled`;`skip scope`
43
+ 带上判定依据(`header says subagent` / `id is not a root shape`);`tag()` 同时剥掉
44
+ `orchestrator-` 前缀(否则所有 orchestrator 会话在日志里都显示成 `[orchestr]`)。
45
+ - **日志行内的换行被压平**:会话 id、错误文本里带 `\n` 时可以伪造出一行看似插件自己
46
+ 写的记录(例如假的 `guard armed` 或 `turn/end`),现在一律替换为空格。
47
+
48
+ ### 新增配置
49
+
50
+ - `foregroundKinds: string[]`:追加「会取消待发补写」的来源 `kind`(默认 `user` / `agent-message`)。
51
+ - `budgetFile: string`:补写预算的持久化路径(默认 `$DSH_HOME/state/turn-recovery/nudges.log`)。
52
+
53
+ ### 已知边界(不变,仅补文档)
54
+
55
+ - 可见字符按**回合累计**而非末条消息计:先写结论、后被工具调用打断的回合不算空。
56
+ - 非 `completed` 回合(`aborted` / `interrupted` / `error` / `max-tokens` / `blocked`)一律不补写。
57
+ - `minVisibleChars` 默认 1 是刻意取最小阈值;要「短于 N 字也算空」可自行调高。
58
+
59
+ ### 验证
60
+
61
+ - `node test/guard.test.mjs`:43 个用例全通过(新增 9 个:header 判根、notice 不取消、
62
+ 前台白名单、`fail-closed`、inbox 去重、跨重启限流、预算落盘、`foregroundKinds`、日志压平)。
63
+ - `node scripts/check-metadata.mjs`:通过;负向验证把上界改回 `<0.3.0` 会失败。
64
+
65
+ ## [0.2.0] - 2026-11-01
66
+
67
+ **更名发布**,功能与 0.1.6 完全一致,不含行为变化。
68
+
69
+ ### 变更
70
+
71
+ - npm 包名 `dsh-empty-turn-guard` → **`dsh-turn-recovery`**;GitHub 仓库同步更名。
72
+ - 运行时插件 id `empty-turn-guard` → **`turn-recovery`**(`export const name`、`dsh.bundle.patch` 的 `- id:`、profile 里覆盖用的 `- id:` 全部随之改变)。
73
+ - 补写消息来源 `plugin:empty-turn-guard` → **`plugin:turn-recovery`**。
74
+ - 系统提示段落名 `empty-turn-guard` → **`turn-recovery`**(`order` 仍为 960)。
75
+ - 日志文件名 `empty-turn-guard.log` → **`turn-recovery.log`**。
76
+ - 版本号从 0.1.6 跳到 0.2.0:包名与插件 id 都是破坏性变更(旧的 profile 注册与 `node_modules` 链接必须更新,否则宿主会静默跳过该 bundle),按语义化版本应当断代。
77
+
78
+ ### 升级须知
79
+
80
+ 从 0.1.x 升级需要同时改三处,缺一处宿主要么静默不加载、要么加载旧目录:
81
+
82
+ 1. profile 的 `dependencies` 与 `dsh.profile.bundles` 里的包名;
83
+ 2. profile 的 `cordis.patch.yml` 里 `- id: empty-turn-guard` → `- id: turn-recovery`(配置内容不变);
84
+ 3. `node_modules` 里的包链接(junction)需要重建为新包名。
85
+
86
+ 旧日志文件 `~/.dsh/logs/empty-turn-guard.log` 不会自动改名,可以保留作历史,也可以直接删除。
87
+
88
+
89
+ 修复第四轮复核(DSH `0.2.1-rc.5`)确认的两个**独立状态机缺陷**,以及用户点停止不可观察、
90
+ 日志/配置层面的若干小问题。`lib/index.js` 的判定口径没变,改的都是「状态什么时候被清/被置」。
91
+
92
+ ### 修复
93
+
94
+ - **P2-A 认领不校验消息 id(中)**:`user/message` 只要 `source.kind = 'plugin:empty-turn-guard'`
95
+ 就置 `awaitingGuardTurn = true`,而 `matched` 只写进日志。于是上次进程遗留、重放、或另一实例
96
+ 写入的同来源消息会被当成「补写回合」,让下一个**真正的**空回合被 `stop chaining` 静默跳过。
97
+ 现在只有 id 与在途补写一致才置位,不一致记 `NOT ours` 并忽略。
98
+ - **P2-B 缺少数值 turn 的 turn/end 不清状态(中)**:早退分支在 `wasGuard` 取值与清零之前
99
+ `return`,`awaitingGuardTurn` 永久留在 `true`。现在先取后清,再判断 turn 是否可判定。
100
+ (当前宿主唯一 emitter 恒给 number,属潜伏缺陷,用 mock 可复现。)
101
+ - **P2-C 用户点停止不可观察(低,缓解)**:宿主 `cancel()` 只清 inbox + abort,不发 session 事件,
102
+ 900ms 窗口内仍会补写。现在订阅宿主的 `agent/inbox/discarded`,**只**在丢弃的正是我们那条
103
+ 补写时才撤标记;宿主不广播该事件时退回旧行为,不会误伤。
104
+ - **日志轮转更鲁棒(低)**:Windows 上多进程持句柄时 `rename` 会 EPERM/EBUSY,现在回退
105
+ `truncate(0)` 并记一条日志(宁可丢历史,也不让日志无限长)。
106
+ - **agent 注册表对账(低)**:除 `agent/disposed` 外,注册表每增长 20 条向宿主 `agents.list()`
107
+ 对账一次,删掉已消失的条目。
108
+ - **会话过滤留痕(低)**:`sessionScope='root'` 过滤掉的会话每个 id 记一条 `skip scope` 日志,
109
+ 「插件没加载」与「被 scope 静默过滤」不再长得一样。
110
+ - **激活日志带版本号**:`plugin activated: pid=… version=…`,版本从自身 `package.json` 读,
111
+ 不再需要靠「某字段是否存在」推断进程里跑的是哪一版。
112
+ - **`instructionText` 上限 4000 字符**:它进每回合 system prompt,超长会长期抬高输入 token。
113
+
114
+ ### 测试
115
+
116
+ - `test/guard.test.mjs` 28 → **34 条**:新增 P2-A(id 不符不吞下一回合)、P2-B(缺 turn 也清状态)、
117
+ P2-C(丢弃自己那条 → 释放在途标记;丢弃别人的 → 不动)、激活日志版本号、`skip scope` 只记一次、
118
+ `instructionText` 截断。
119
+ - 修了测试夹具的一处失真:`guardClaim()` 过去不带 id,无法表达「认领」。
120
+
121
+ ## [0.1.5] - 2026-10-29
122
+
123
+ 修复桌面端 / 网页端复核发现的状态机与来源分类问题。
124
+
125
+ ### 修复
126
+
127
+ - **投递 ≠ 认领(中)**:`awaitingGuardTurn` 过去在 `agent.send()` 成功时就置位,但 send 只是把消息放进
128
+ `next-turn` 队列;消息在认领前可能被用户停止/会话取消清掉,下一次真正的空回合会被当成「连环补写」
129
+ 跳过。现在投递成功只记录 `nudgeMessageId`(在途),只有转录里出现 `source.kind =
130
+ 'plugin:empty-turn-guard'` 的 `user/message`(被宿主认领)才置位 `awaitingGuardTurn`。在途补写 120 秒
131
+ 未被认领视为过期;上一笔未认领前不再排第二笔。
132
+ - **后台来源白名单覆盖最新宿主(中)**:新增 `agent-instructions` / `ptc-mode` / `model-selection` /
133
+ `session-reference` / `skill-invocation` / `compact-checkpoint`。这些是宿主或插件写入的上下文/状态消息,
134
+ 不是有人在说话;旧白名单会把它们当插话,取消待发补写。
135
+ - **文档口径**:README 的附件判定改为「助手消息含附件不算空」;补充 `dsh headless` 一次性进程的限制;
136
+ 更新 2026-10-29 的实测样本。
137
+
138
+ ### 测试
139
+
140
+ - `test/guard.test.mjs` 从 27 条扩到 **28 条**:新增「投递 ≠ 认领」用例;后台注入用例覆盖最新宿主的
141
+ 6 个来源;预算/连环补写用例改为显式模拟宿主认领。
142
+
143
+ ### 验证
144
+
145
+ - 用真实 DSH `0.2.1-rc.3` 宿主 + 假模型做端到端验证:turn 1 返回 ZWSP → 插件 `nudge DELIVERED` →
146
+ turn 2 出现 `plugin:empty-turn-guard` 的 `user/message` → 模型返回可见正文,`wasGuard=true`。
147
+ 桌面端生产日志仍未遇到 `completed` 空回合,链路已由 e2e 覆盖。
148
+
149
+ ## [0.1.4] - 2026-10-29
150
+
151
+ 修复第三轮评审(技术方案评审团:架构 / 应用安全 / 现实核对)确认的缺陷。核心是**空回合判定本身**:
152
+ 0.1.3 之前漏判「UI 空白但 `visibleChars >= 1`」的回合,而那正是本插件唯一要救的场景。
153
+
154
+ ### 修复
155
+
156
+ - **零宽集改用 Unicode 属性(高)**:0.1.3 的手写码位清单漏了 `U+2066–U+2069`(双向隔离符)、`U+E0001`、
157
+ `U+E0020–U+E007F`(tag 字符)、`U+1D173–U+1D17A`(音乐控制符)——这些字符渲染为空白却被计成正文,
158
+ 回合因此不补写。现在改用 `\p{Default_Ignorable_Code_Point}` 并补 `U+2800`/`U+3164`/`U+FFA0` 三个
159
+ 不属于 DI 的填充符,实测不误伤中文/英文/数字/标点。
160
+ - **`inject` 假投递(高)**:`agent.inject(m)` 实际是 `send(m, 'next-step', false)`——**不唤醒**,
161
+ 消息躺在 next-step 队列里等下一个回合认领。旧实现把它当第三级兜底,于是日志写 `DELIVERED`、
162
+ `awaitingGuardTurn` 置 `true`,却没有任何新回合发生,下一次真空回合还会被当成「连环补写」跳过。
163
+ 0.1.4 起只保留会唤醒的 `send(next-turn, wakeup)` 与等价的 `followup`,全部失败就明确记 `NOT DELIVERED`。
164
+ - **热重载中途误判(中)**:0.1.3 去掉了 `assistantMessages > 0` 门槛后,插件在回合中途加载/热重载时
165
+ 看不到该回合早前的助手消息,`visibleChars = 0` 会被当成空回合而发假提醒。新增 `sawTurnStart`,
166
+ 没见过 `turn/start` 的回合一律不判定(日志 `turn/start not observed`)。
167
+ - **日志 tag 恒定(中)**:根会话 id 形如 `session-<uuid>`,`id.slice(0, 8)` 恒为 `session-`,多会话无法区分。
168
+ 现在先剥 `session-` 前缀再取 8 位。
169
+ - **图片附件回合误判(低)**:只有 `image` / `file` / `attachment` / `audio` / `video` 的回合在界面上并非空白,
170
+ 不再判空(`tool-call`、`reasoning` 仍按空回合处理)。
171
+ - **`turn` 缺失(低)**:`turn/end` 没有数值 `turn` 时直接跳过判定(旧实现拿 `s.turn` 兜底,两者都为 `null`
172
+ 会命中 `guardSentForTurn === turn` 而静默跳过)。
173
+ - **会话范围(低)**:默认只处理根会话(`/^session-/`)。子代理会话(裸 UUID)「只调工具不回正文」是正常收尾,
174
+ 社交桥会话(`social-*`)也不是空回合,都不再补写。需要旧行为可设 `sessionScope: 'all'`。
175
+ - **插话判定(低)**:`runtime-context` / `time-context` / `skill-catalog` / `dsh-mnemon` 与 `plugin:*`
176
+ 视为背景注入,不取消待发补写;其余来源(真人、子代理回报、微信/社交桥)都取消。
177
+ - **兜底日志不轮转(低)**:`maybeRotate` 现在对主路径和 `os.tmpdir()` 兜底路径都生效,轮转前显式删除旧 `.1`
178
+ (避免 Windows 上覆盖失败)。
179
+ - **`engines.dsh` 加上界**:`>=0.2.1-rc.1 <0.3.0`。原来没有上界,将来 0.3.0 起 API 若变,插件会静默加载失败或行为异常。
180
+
181
+ ### 测试
182
+
183
+ - `test/guard.test.mjs` 从 19 条扩到 **27 条**。新增:中途加载不判定、图片附件不判空、`turn` 缺失、
184
+ 会话范围(root/all)、后台注入不取消 / 社交桥取消、只有 `inject` 可用时不算投递成功、
185
+ DI 码位(`U+2066`/`U+E0020`/`U+1D173`/`U+E0100`/`U+FFA0`)、日志 tag 剥前缀。
186
+
187
+ ### 已知未解
188
+
189
+ - 补写消息仍以 `role:'user'` 落进持久转录(`source.kind = 'plugin:empty-turn-guard'`)。宿主
190
+ `dsh-session` 对 `source.kind` 无枚举校验,桌面 UI 会把非 `user` 来源渲染成 `kind:'context'` 的上下文块
191
+ 而非用户气泡,但转录层面它仍是一条 user 消息——插件侧无法改 `role`,只能靠 `source` 标注。
192
+ - 生产环境至今 **0 次**真实补写投递(日志 `nudge DELIVERED` 为 0),链路正确性仍缺一次端到端演练。
193
+
194
+ ## [0.1.3] - 2026-10-26
195
+
196
+ 修复评审(技术方案评审团:架构 / 应用安全 / 现实核对)发现的缺陷,重点是空回合补写的状态机。
197
+
198
+ ### 修复
199
+
200
+ - **补写状态机(高)**:`awaitingGuardTurn` 在三条早退路径上不清零——真人插话取消、补写回合非 `completed`(`aborted` / `error` / `max-tokens`)、三级投递全失败——导致**下一次真正的空回合被误判成「连环补写」而静默跳过**,日志还写 `stop chaining`。现在 `turn/end` 入口先取 `wasGuard` 再无条件清零;投递失败也显式清零。
201
+ - **`runtime-context` 被当成真人插话(中)**:宿主每一步都会 append 一条 `source.kind === 'runtime-context'` 的 `user/message`(`dsh-agent-loop`),旧实现把所有非本插件的 `user/message` 都当成真人取消补写,实际上补写几乎必被取消。现在只有 `source.kind === 'user'`(`source` 缺失时保守视为真人)才取消。
202
+ - **小时预算被取消操作吃掉(中)**:旧实现「调度即记账」,取消/失败也占额度;现在按**投递成功**记账。
203
+ - **零宽字符集过窄(中)**:补上 `U+3164`(韩文填充符,最常被当作「隐形字」)、`U+2800`(盲文空白)、`U+180B–U+180E`、`U+FE00–U+FE0F`、`U+2065`、`U+206A–U+206F`、`U+FFA0`、`U+E0100–U+E01EF`、`U+034F`、`U+061C`、`U+115F`、`U+1160`、`U+17B4`、`U+17B5`、`U+2028`、`U+2029` 等。
204
+ - **配置语义**:`maxNudgesPerHour: 0` 现在真的关闭补写(旧实现被 `|| 3` 吞成 3);`delayMs: "abc"` 等非法值回落默认值(旧实现 `|| 0` 变成 0 毫秒);`enabled: "false"` 字符串现在真的关闭(旧实现只认布尔 `false`)。
205
+ - **回合竞态**:空回合结束后 900ms 内若新回合自己起来,旧实现会把补写插进别人的回合;现在 `turn/start` 取消过期定时器,定时器触发时再校验 `pendingTurn` 是否仍等于当前回合。
206
+ - **漏判**:没有任何 `assistant/message` 的 `completed` 回合(`visibleChars === 0`)旧实现因为 `assistantMessages > 0` 前置条件而漏判,现在会补写。
207
+ - **热启用惰性**:旧实现只靠 `agent/created` 填 agent 表,插件热启用时存量会话永远没有 agent、补写全被 `SKIPPED`。现在启动时向宿主 `agents` 注册表 `list()` 回填一次。
208
+ - **日志**:跟随 `DSH_HOME`(旧实现硬编码 `os.homedir()`)、超过 5 MB 自动轮转、每行带会话 id 前 8 位与 `wasGuard`。
209
+
210
+ ### 变更
211
+
212
+ - 补写消息正文不再要求模型「不要提及这条提醒」:该消息以 `role:'user'` 落进持久转录,隐瞒来源属于破坏转录完整性。现在明确写「插件自动补发」,并带 `source = { kind: 'plugin:empty-turn-guard', plugin, reason }`。
213
+
214
+ ### 测试
215
+
216
+ - `test/guard.test.mjs` 从 9 条扩到 **19 条**(此处原写「18 条」有误),并修正**事件载荷形状**:宿主 `session.append(type, data)` 的 `data` 就是消息本体(`data.source.kind`),旧用例包了一层 `{ message: { source } }`,导致「真人插话取消」「plugin 来源不参与判定」两条用例实际空转。新增:非 completed 后不污染下一回合、投递全失败后可恢复、`runtime-context` 不取消、新回合丢弃过期补写、配置 0/非法值语义、扩展零宽集、热启用回填、卸载清理。
217
+
218
+ ### 明确不做
219
+
220
+ - `inject = ['systemPrompt']` 保持硬依赖。本版本 `@deepseek-ai/cordis` 的 `Inject.resolve` 只接受数组或「name → config 映射」,**没有 `required`/`optional` 语义**;改成 `{ optional: [...] }` 会被当成「等一个叫 optional 的服务」,插件永不激活。
221
+
222
+ ## [0.1.2] - 2026-10-25
223
+
224
+ 放宽宿主版本门槛:功能无改动。
225
+
226
+ ### 变更
227
+
228
+ - `engines.dsh` 从 `>=0.2.1-rc.2` 放宽到 `>=0.2.1-rc.1`。
229
+ 依据:本插件用到的宿主 API(`ctx.systemPrompt.section`、`agent.send` / `followup` / `inject`、
230
+ `agent/created`、`session/event`、`dsh.bundle.patch` 加载)在 DSH `0.2.1-rc.1` / `rc.2` / `rc.3`
231
+ 三个版本的实现**逐字节相同**(`dsh-system-prompt`、`dsh-agent-loop`、`dsh-agent`、`dsh-session`、
232
+ `dsh-app-boot`、`dsh-plugin-manager`、`dsh-web-app` 的 `lib/index.js` md5 三版本一致,差异仅在
233
+ `package.json` 的版本号字段)。所以 rc.1 上代码本来就能跑,旧门槛只是把它挡在门外——
234
+ 宿主 `dsh-app-boot` 的版本闸门不满足时会把 bundle 收进 `skippedBundles`,启动只打一行
235
+ `dsh: skipping profile bundle "dsh-empty-turn-guard": …` 然后**静默不加载**。
236
+
237
+ ### 注意
238
+
239
+ - **不要**把范围写成 `>=0.2.1`。semver 规则里预发布版本不满足「不含预发布标识的同段范围」,
240
+ `0.2.1-rc.3` 对 `>=0.2.1` 判为 `false`,那样会把 rc.1 / rc.2 / rc.3 全部挡掉。
241
+
242
+ ## [0.1.1] - 2026-10-25
243
+
244
+ 发布规范化版本:功能与 0.1.0 一致,补齐工程与元数据。
245
+
246
+ ### 新增
247
+
248
+ - `test/guard.test.mjs`:9 条契约测试,用 mock ctx 驱动真实 `apply()`,不依赖宿主即可回归。
249
+ 覆盖零宽空回合补写、正常回合不干预、非 `completed` 不补写、补写回合再空不连环、
250
+ 真人插话取消、`send` 失败降级 `followup`、`plugin:` 来源消息不参与判定。
251
+ - `scripts/check-metadata.mjs`:发布前元数据检查,校验字段齐全、`files` 路径真实存在、
252
+ 无 `TODO` 占位符、`cordis.patch.yml` 的 `- insert:` 与包名对齐、入口可 `import`。
253
+ - `CHANGELOG.md`(本文件)。
254
+
255
+ ### 修正
256
+
257
+ - 重新统计并修正 README 里的实测样本数据(原 0.1.0 写的是 738 回合 / 102 空 / 13.8%,
258
+ 该口径把只有工具调用的步骤也算成了空回合)。准确口径:208 会话 / 766 回合,
259
+ 可见正文为空的回合 39 个(5.1%),其中 `completed` 的 8 个(1.2%)才是本插件处理的对象。
260
+
261
+ ### 变更
262
+
263
+ - `package.json` 补齐发布元数据:`repository` / `homepage` / `bugs` / `author` / `exports` /
264
+ `engines` / `publishConfig` / `scripts`。
265
+ - `prepublishOnly` 串联 `check-metadata` 与 `test`,检查不过不允许发布。
266
+
267
+ ## [0.1.0] - 2026-10-25
268
+
269
+ 首个可用版本。
270
+
271
+ ### 新增
272
+
273
+ - **C 空回合保护**:监听 `session/event`,在 `turn/end`(`reason.kind === 'completed'`)时
274
+ 累计本回合所有 `assistant/message` 的可见正文;去掉零宽字符后为空则自动排一条
275
+ `target: 'next-turn'` 的补写消息(`agent.send(msg, 'next-turn', true)`,
276
+ 失败依次降级 `followup` → `inject`),让模型在下一回合补出正文。
277
+ - **A 收尾指令注入**:通过 `systemPrompt.section({ name, order: 960, interpolate: false })`
278
+ 注入 222 字指令,要求每回合以看得见的正文收尾、禁止用零宽字符或空白充当回答。
279
+ - 防误伤:同回合最多补写一次、补写回合再空即停、每小时最多 3 次、
280
+ 定时器触发前收到真人消息立即取消、`plugin:` 来源消息不参与判定。
281
+ - 日志落 `~/.dsh/logs/empty-turn-guard.log`,主路径不可写时兜底到系统临时目录。
282
+
283
+ ### 说明
284
+
285
+ - 修复目标问题:模型把输出预算烧在 reasoning 上、正文只返回一个 `U+200B`,
286
+ 而 DSH 的 `EMPTY_RESPONSE` 判空因「文本非空」不触发,回合被记为 `completed` 但界面空白。
package/README.md CHANGED
@@ -15,9 +15,9 @@
15
15
  ## 行为
16
16
 
17
17
  1. **检测**:累计本回合所有 `assistant/message` 的 text 块,剥掉不可见字符后 trim。不可见集合用 Unicode 属性 `\p{Default_Ignorable_Code_Point}`(覆盖 `U+00AD / U+034F / U+061C / U+115F / U+1160 / U+17B4 / U+17B5 / U+180B–U+180E / U+200B–U+200F / U+202A–U+202E / U+2060–U+206F / U+FE00–U+FE0F / U+FEFF / U+E0001 / U+E0020–U+E007F / U+E0100–U+E01EF / U+1D173–U+1D17A` 等全部「默认可忽略」码位),再补三个渲染为空的填充符 `U+2800 / U+3164 / U+FFA0` 与行段分隔符 `U+2028 / U+2029`。`turn/end` 时若「可见字符数 < minVisibleChars 且 reason.kind === 'completed'」,判定为空回合 —— **没有任何 assistant 消息的 completed 回合也算空**(0 < 1)。助手消息含图片/文件/音视频附件时该回合不算空(界面上不是空白);用户消息里的附件不计入这个判定。
18
- 2. **补写**:延迟 `delayMs` 后通过 `agent.send(msg, 'next-turn', true)` 排一条 `plugin:turn-recovery` 来源的消息,让模型在下一回合补结论;`send` 抛错时降级到 `agent.followup(msg)`(与前者等价,都是唤醒并开新回合)。**不再使用 `agent.inject`**:它是 `send(msg, 'next-step', false)`,不唤醒,消息只会躺在队列里,旧版据此记 `DELIVERED` 属于假成功。消息正文明确写出「插件自动补发」,不自称普通用户输入。投递成功后只记「在途」并保存消息 id;只有宿主持久转录里出现该来源的 `user/message`(被认领)时,随后的回合才被视为补写回合。投递成功但 120 秒内未被认领视为过期,不阻塞下一次补写;上一笔补写未被认领前不会再排第二笔。
18
+ 2. **补写**:延迟 `delayMs` 后通过 `agent.send(msg, 'next-turn', true)` 排一条 `plugin:turn-recovery` 来源的消息,让模型在下一回合补结论;`send` 抛错时降级到 `agent.followup(msg)`(与前者等价,都是唤醒并开新回合)。**不再使用 `agent.inject`**:它是 `send(msg, 'next-step', false)`,不唤醒,消息只会躺在队列里,旧版据此记 `DELIVERED` 属于假成功。消息正文明确写出「插件自动补发」,不自称普通用户输入。投递成功后只记「在途」并保存消息 id;只有宿主持久转录里出现该来源的 `user/message`(被认领)时,随后的回合才被视为补写回合。投递前还会查宿主自己的 `agent.inbox.nextTurn`:队列里已经躺着一笔本插件的补写时不再投第二笔(内存标记在重启/双实例下会丢,队列不会)。投递成功但 120 秒内未被认领视为过期,不阻塞下一次补写;上一笔补写未被认领前不会再排第二笔。
19
19
  3. **收尾指令**:注册一段 system prompt(name `turn-recovery`,order 960,`interpolate: false`),要求每回合以看得见的正文结束、禁止用零宽字符/空白/空行充当回答。
20
- 4. **防误伤**:同一回合最多补一次;补写回合再空则停止连环;每会话每小时最多 `maxNudgesPerHour` 次(按**投递成功**计,取消/失败不占额度);定时器未触发前收到**有人说话**的消息(真人、子代理回报、微信/社交桥)立即取消,宿主的背景注入(`runtime-context` / `time-context` / `skill-catalog` / `dsh-mnemon` / `agent-instructions` / `ptc-mode` / `model-selection` / `session-reference` / `skill-invocation` / `compact-checkpoint` / `plugin:*`)不算;新回合自己起来时丢弃过期补写;`plugin:turn-recovery` 来源的消息不参与判定;插件在回合中途加载(没见过 `turn/start`)时不判定该回合。
20
+ 4. **防误伤**:同一回合最多补一次;补写回合再空则停止连环;每会话每小时最多 `maxNudgesPerHour` 次(按**投递成功**计,取消/失败不占额度,计数落盘到 `$DSH_HOME/state/turn-recovery/nudges.log`,**重启后仍然有效**);定时器未触发前只有**明确有人在说话**的来源才取消补写 —— 默认是 `user`(真人输入)与 `agent-message`(agent 间转交),可用 `foregroundKinds` 追加自定义桥接;其余一切(宿主的 `runtime-context` / `time-context` / `skill-catalog` / `dsh-mnemon` 等自动上下文、`form: "notice"` 的工具/卡片/计划/任务通知、`goal` / `schedule` 自动化回合、`plugin:*`、以及**本插件不认识的新来源**)都按后台处理、不取消 —— 漏取消由「新回合自己起来」分支兜底;新回合自己起来时丢弃过期补写;`plugin:turn-recovery` 来源的消息不参与判定;插件在回合中途加载(没见过 `turn/start`)时不判定该回合。
21
21
 
22
22
  非 `completed` 的回合(`interrupted` / `error` / `aborted` / `max-tokens` / `blocked`)一律不补写。
23
23
 
@@ -31,7 +31,7 @@
31
31
  // <DSH_HOME>/profiles/<你的 profile>/package.json
32
32
  {
33
33
  "dependencies": {
34
- "dsh-turn-recovery": "^0.2.0"
34
+ "dsh-turn-recovery": "^0.2.1"
35
35
  },
36
36
  "dsh": {
37
37
  "profile": {
@@ -62,7 +62,9 @@
62
62
  minVisibleChars: 1 # 可见字符数下限(<1 按 1 处理)
63
63
  delayMs: 900 # 补写前等待(毫秒,0–60000,非法值回落 900)
64
64
  maxNudgesPerHour: 3 # 每会话每小时的补写上限(0 = 关闭补写)
65
- sessionScope: root # root = 只处理根会话(session-*);all = 连子代理/社交桥会话一起
65
+ sessionScope: root # root = 只处理根会话(按 SessionHeader 判定);all = 连子代理会话一起
66
+ foregroundKinds: [] # 追加「会取消待发补写」的来源 kind(默认 user / agent-message)
67
+ budgetFile: '' # 空 = <DSH_HOME>/state/turn-recovery/nudges.log(跨重启限流)
66
68
  logFile: '' # 空 = <DSH_HOME>/logs/turn-recovery.log
67
69
  ```
68
70
 
@@ -75,7 +77,7 @@
75
77
  ```
76
78
  [2026-10-25T01:49:13.941Z] plugin activated: pid=17520 enabled=true instruction=true dshHome=C:\Users\me\.dsh
77
79
  [2026-10-25T01:49:13.942Z] prompt section registered: order=960 chars=222
78
- [2026-10-25T01:49:13.942Z] guard armed (uptime=1ms)
80
+ [2026-10-25T01:49:13.942Z] guard armed (instruction=registered, uptime=1ms)
79
81
  [2026-10-25T01:58:02.114Z] turn/end turn=3 kind=completed steps=4 visibleChars=812 empty=false wasGuard=false sawStart=true [5f884825]
80
82
  [2026-10-25T02:03:41.507Z] turn/end turn=4 kind=completed steps=6 visibleChars=0 empty=true wasGuard=false sawStart=true [5f884825]
81
83
  [2026-10-25T02:03:41.508Z] nudge SCHEDULED turn=4 delay=900ms [5f884825] (empty completed turn)
@@ -87,22 +89,23 @@
87
89
 
88
90
  `version=` 是进程里真正加载的版本号(从包自身 `package.json` 读)——**升级后先看这一行**,不要靠「某个日志字段有没有」推断跑的是哪一版(0.1.4 与 0.1.5 都打过 `sawStart`,这个推断会错)。
89
91
 
90
- 排查常用短语:`skip scope`(被 `sessionScope` 过滤掉的会话)、`turn/start not observed`(热加载中途,不判定)、`NOT ours`(同来源但 id 对不上,已忽略)、`DISCARDED by host`(用户停止,补写被丢弃)、`a nudge is already awaiting claim`(上一笔还没被认领)、`stop chaining`(补写回合又空,按设计不再连环)、`budget exhausted`(本小时额度用完)。
92
+ 排查常用短语:`skip scope`(被 `sessionScope` 过滤掉的会话,括号里写明是 `header says subagent` 还是 `id is not a root shape`)、`guard NOT armed (fail-closed`(同名 prompt 段落已被占用,插件整体不启用,不再谎报 armed)、`inbox already holds a pending turn-recovery nudge`(队列里已有一笔,未重复投递)、`turn/start not observed`(热加载中途,不判定)、`NOT ours`(同来源但 id 对不上,已忽略)、`DISCARDED by host`(用户停止,补写被丢弃)、`a nudge is already awaiting claim`(上一笔还没被认领)、`stop chaining`(补写回合又空,按设计不再连环)、`budget exhausted`(本小时额度用完)。
91
93
 
92
94
  ## 实现约束
93
95
 
94
96
  - 只 import Node 内置模块(`node:fs` / `node:os` / `node:path` / `node:crypto`)。插件被装进 profile 的 `node_modules` 后,裸名 `@deepseek-ai/*` 在插件目录下 Node 解析不到,少一个依赖就少一个「插件整个加载失败」的风险面。
95
97
  - 不导出 `Config` schema(宿主允许无 schema 的 entry),配置在 `apply(ctx, config)` 内部做防御性默认值合并。
96
98
  - 所有逻辑包在 try/catch 里:任何异常只写日志,不向上抛,不会拖垮宿主回合。
97
- - 宿主门槛 `engines.dsh = ">=0.2.1-rc.1 <0.3.0"`:本插件用到的宿主 API 在 `0.2.1-rc.1` / `rc.2` / `rc.3` / `rc.4` / `rc.5` 里实现完全一致(rc.3 / rc.4 / rc.5 的 `dsh-system-prompt` / `dsh-agent` / `dsh-session` / `dsh-agent-loop` / `dsh-app-boot` 的 `lib/index.js` sha256 逐字节相同),所以门槛只设到 rc.1。**不要写成 `>=0.2.1`**——semver 里预发布版本不满足不含预发布标识的同段范围,`0.2.1-rc.3` 对 `>=0.2.1` 判为 `false`,会连 rc 版本一起挡掉。上界 `<0.3.0` 是安全阀:0.3.0 起若宿主 API 有变,插件应静默跳过而不是带着错误假设继续跑。门槛不满足时宿主会把整个 bundle 静默跳过(启动只留一行 `skipping profile bundle`)。
98
- - **升级宿主前请先看门槛**:`<0.3.0` 的上界意味着宿主升到 0.3.0 时本插件会**静默不加载**(`dsh-app-boot` 只往 stderr 写一行 `skipping profile bundle`)。请先看日志首行 `plugin activated: ... version=...`:没有这一行就是没加载。届时要么升级本插件并把上界往上放,要么临时把 `engines.dsh` 放宽(自担 API 漂移风险)。
99
+ - 宿主门槛 `engines.dsh = ">=0.2.1-rc.1 <0.3.0-0"`:本插件用到的宿主 API 在 `0.2.1-rc.1` / `rc.2` / `rc.3` / `rc.4` / `rc.5` 里实现完全一致(rc.3 / rc.4 / rc.5 的 `dsh-system-prompt` / `dsh-agent` / `dsh-session` / `dsh-agent-loop` / `dsh-app-boot` 的 `lib/index.js` sha256 逐字节相同),所以门槛只设到 rc.1。**不要写成 `>=0.2.1`**——semver 里预发布版本不满足不含预发布标识的同段范围,`0.2.1-rc.3` 对 `>=0.2.1` 判为 `false`,会连 rc 版本一起挡掉。上界必须写成 `<0.3.0-0`:宿主用 `semver` 的 `includePrerelease: true` 判定门槛,写成 `<0.3.0` 时 `0.3.0-alpha.1` / `0.3.0-beta.2` 都会被判为满足(实测 `semver.satisfies('0.3.0-alpha.1', '>=0.2.1-rc.1 <0.3.0', {includePrerelease:true}) === true`),插件会带着未验证的假设加载到下一主线;`-0` 是显式的预发布截断。`scripts/check-metadata.mjs` 会强制检查这个写法。门槛不满足时宿主会把整个 bundle 静默跳过(启动只留一行 `skipping profile bundle`)。
100
+ - **升级宿主前请先看门槛**:`<0.3.0-0` 的上界意味着宿主升到 0.3.0(含 `0.3.0-alpha*` / `0.3.0-beta*`)时本插件会**静默不加载**(`dsh-app-boot` 只往 stderr 写一行 `skipping profile bundle`)。请先看日志首行 `plugin activated: ... version=...`:没有这一行就是没加载。届时要么升级本插件并把上界往上放,要么临时把 `engines.dsh` 放宽(自担 API 漂移风险)。
99
101
  - `inject = ['systemPrompt']` 是 cordis 的**硬依赖**,不做「可选依赖」改造:本版本 `@deepseek-ai/cordis` 的 `Inject.resolve` 只接受「数组」或「name → config 映射」,没有 `required`/`optional` 语义;写成 `{ optional: [...] }` 会被当成「等一个叫 optional 的服务」,插件将永不激活。
100
102
 
101
103
  ## 已知限制
102
104
 
103
105
  - **补写消息以 `role: 'user'` 落盘**(`source.kind = 'plugin:turn-recovery'`):宿主 session 层对 `source.kind` 不做枚举校验,插件侧也改不了 role。桌面前端按 `source.kind !== 'user'` 渲染成上下文卡片(不是用户气泡),mnemon 只收录 `source.kind === 'user'`,所以界面与记忆都不受污染;但**会话转录文件里它确实是一条 user 消息**,审计时要看 `source.kind` 区分。
104
106
  - **用户点停止的窗口**:宿主 `cancel()` 只清 inbox + abort,不 append 任何 session 事件。插件订阅了宿主 dispatch 的 `agent/inbox/discarded` 来撤掉自己那条补写,但该事件是否向插件广播取决于宿主的 dispatch 链路;收不到时,「用户已点停止、插件仍在 `delayMs` 后补写」这个窗口依然存在(默认 900ms,可通过 `delayMs` 调小)。真正发出去的那一回合仍受宿主 abort 语义约束。
105
- - **`sessionScope: 'root'` 的判定依据是 id 前缀 `session-`**:老版本(v3 日志)的纯 UUID 会话目录、`social-*` 社交桥会话都算「非根」,默认不处理。被过滤的会话每个 id 只记一条 `skip scope` 日志,不会静默消失。
107
+ - **`sessionScope: 'root'` 的判定依据是宿主的 `Session.header`**:`origin === 'subagent'` 或 `delegationDepth > 0` 才算子代理会话,`orchestrator-*` 这类顶层会话会被正常处理(0.2.0 用 id 前缀判断,把它们全部判成子代理,实测日志里有 19 次静默跳过)。宿主没有 header 的场合(老宿主、测试桩)退回 id 形态:`session-*` / `orchestrator-*` 算根,纯 UUID / `social-*` 不算。被过滤的会话每个 id 只记一条 `skip scope` 日志,不会静默消失。
108
+ - **可见字符是「按回合累计」而不是「末条消息」**:一个回合里先写了结论、后来又被工具调用打断的,累计口径下不算空(这是刻意的:用户此前已经看到了文字)。反过来,`aborted` / `interrupted` 等非 `completed` 回合一律不补写,即使它确实没留下正文 —— 那些回合多半是用户自己喊停的,再补一轮会变成「已取消还继续跑」。
106
109
  - **崩溃遗留**:补写已入队但宿主在认领前崩溃时,该消息可能在下次启动被重放。插件只认 id 匹配的认领(`NOT ours`),因此不会被这种消息骗成「补写回合」,但它仍可能作为一条插件消息出现在转录里。
107
110
  - **多宿主共用同一日志文件**:两个进程同时写同一路径时轮转可能失败,此时回退 `truncate(0)` 并记一条 `log rotate fell back to truncate`。
108
111
  - **生产环境尚未观测到真实补写**:截至 2026-10-30,本机生产日志里 `nudge DELIVERED` 仍为 0(空回合全部是 `aborted`),所有结论来自单测与 mock 复现。真机端到端(含「补写回合真的开起来」)仍是未验证项。
package/lib/index.js CHANGED
@@ -11,10 +11,20 @@
11
11
  * 本插件做两件事:
12
12
  * C) 监听 turn/end:若该回合所有助手消息的可见正文(剥离零宽字符后)为空,
13
13
  * 就通过 agent.send(msg, 'next-turn', true) 追加一条 plugin 消息,让模型
14
- * 在下一个回合把结论补出来。每回合最多 1 次,每会话每小时有上限,
14
+ * 在下一个回合把结论补出来。每回合最多 1 次,每会话每小时有上限(上限
15
+ * 落盘到 $DSH_HOME/state/turn-recovery/nudges.log,重启后仍然有效),
15
16
  * 补写回合再空则不再连环触发。
16
17
  * A) 注册一段 system prompt(order 960)要求每回合都以可见正文收尾。
17
18
  *
19
+ * 0.2.1 适配性修复(针对宿主 0.2.2-alpha.3 实测):
20
+ * - 会话范围改按 SessionHeader 判定(origin/delegationDepth),不再靠 id 前缀;
21
+ * orchestrator-* 这类顶层会话此前被判成子代理,19 次静默跳过。
22
+ * - 取消策略由「后台黑名单 + 未知即前台」改为「前台白名单 + 未知即后台」:
23
+ * 宿主新增的 notice 形态来源不再误取消待发补写。
24
+ * - prompt 段落注册失败改为 fail-closed(不再写 guard armed 谎报正常)。
25
+ * - 投递前检查宿主 next-turn 队列,进程内标记丢失时不会重复投递。
26
+ * - engines 上界收紧到 <0.3.0-0:<0.3.0 在 includePrerelease 下会放行 0.3.0-*。
27
+ *
18
28
  * 设计约束:
19
29
  * 1) 所有逻辑都包在 try/catch 里,任何异常只写日志,绝不向上抛,以免拖垮宿主回合。
20
30
  * 2) inject 是 cordis 的硬依赖(数组形式)。本版本 cordis 的 Inject.resolve
@@ -53,25 +63,20 @@ const SOURCE = Object.freeze({ kind: 'plugin:' + PLUGIN_ID, plugin: PLUGIN_ID, r
53
63
  const SOURCE_KIND = SOURCE.kind;
54
64
 
55
65
  /**
56
- * 宿主的「背景注入」来源,不算有人在说话(不取消待发补写)。
57
- * runtime-context 由 dsh-agent-loop 每步追加;time-context / skill-catalog /
58
- * dsh-mnemon / agent-instructions 是自动上下文;ptc-mode、model-selection、
59
- * session-reference、skill-invocation、compact-checkpoint 是宿主/插件写入的
60
- * 状态或上下文消息,都不会自己开新回合。宿主新增来源时宁可漏取消,也不要把
61
- * 真正的空回合补写误取消。任何以 `plugin:` 开头的来源也按背景处理。
66
+ * 前台来源:确实「有人在说话」的 user/message,只有它们会取消待发补写。
67
+ *
68
+ * 0.2.1 之前这里是**后台来源黑名单**(BACKGROUND_KINDS),没列到的来源一律按
69
+ * 真人插话处理。宿主后来新增的 form:"notice" 来源(dsh-tool-jobs、
70
+ * dsh-action-cards、dsh-plan-mode、dsh-tool-goal)以及 kind:"goal" /
71
+ * kind:"schedule" 的自动化回合都没在黑名单里,于是每一次都可能把排好的补写
72
+ * 取消掉——插件不报错、日志只写一行 CANCELLED,表现为「装了但没用」。
73
+ * 黑名单天生跟不上宿主的演进,改为白名单:**没见过的来源一律按后台处理**。
74
+ *
75
+ * 漏取消的代价很小:真人消息与自动化回合都会开新回合,turn/start 分支
76
+ * 本来就会丢弃上一回合的待发补写,这里只是提前一点点。
77
+ * 若某个自定义桥接需要取消语义,用配置 `foregroundKinds: ['wechat-bridge']` 补进来。
62
78
  */
63
- const BACKGROUND_KINDS = new Set([
64
- 'runtime-context',
65
- 'time-context',
66
- 'skill-catalog',
67
- 'dsh-mnemon',
68
- 'agent-instructions',
69
- 'ptc-mode',
70
- 'model-selection',
71
- 'session-reference',
72
- 'skill-invocation',
73
- 'compact-checkpoint',
74
- ]);
79
+ const DEFAULT_FOREGROUND_KINDS = Object.freeze(['user', 'agent-message']);
75
80
  const SECTION_NAME = 'turn-recovery';
76
81
  /** 950 已被 instruction-memory 占用;960 在其后、TOOL_BASH(1000) 之前。 */
77
82
  const SECTION_ORDER = 960;
@@ -200,10 +205,19 @@ export function apply(ctx, config) {
200
205
  // 0 表示关闭补写(而不是被 || 吞成 3)。
201
206
  maxNudgesPerHour: intOr(raw.maxNudgesPerHour, 3, 0, 1000),
202
207
  logFile: typeof raw.logFile === 'string' ? raw.logFile.trim() : '',
203
- // 'root'(默认)只处理根会话(id 形如 session-<uuid>);'all' 连子代理会话
204
- // (裸 UUID)和社交桥会话(social-<hex> 等)一起处理。默认不碰子会话:
205
- // 子代理「只调工具、不回正文」是正常收尾,补写会浪费 token 甚至改变委派行为。
208
+ // 'root'(默认)只处理根会话(header.delegationDepth 为 0 或缺省);'all' 连
209
+ // 子代理会话一起处理。默认不碰子会话:子代理「只调工具、不回正文」是正常收尾,
210
+ // 补写会浪费 token 甚至改变委派行为。
206
211
  sessionScope: raw.sessionScope === 'all' ? 'all' : 'root',
212
+ // 追加的「前台来源」:默认只有 user / agent-message 会取消待发补写,
213
+ // 自定义桥接(如社交桥)需要取消语义时把自己的 source.kind 写进来。
214
+ foregroundKinds: (() => {
215
+ const list = Array.isArray(raw.foregroundKinds) ? raw.foregroundKinds : [];
216
+ const extra = list.filter((k) => typeof k === 'string' && k.trim() !== '').map((k) => k.trim());
217
+ return new Set([...DEFAULT_FOREGROUND_KINDS, ...extra]);
218
+ })(),
219
+ // 补写预算的持久化文件(跨重启限流);默认 $DSH_HOME/state/turn-recovery/nudges.log。
220
+ budgetFile: typeof raw.budgetFile === 'string' ? raw.budgetFile.trim() : '',
207
221
  };
208
222
 
209
223
  // 日志位置优先跟随 DSH_HOME(多 profile / 自定义家目录时才不会写错地方)。
@@ -252,7 +266,10 @@ export function apply(ctx, config) {
252
266
  // 主日志写在 $DSH_HOME/logs;万一主路径不可写(受限环境/目录被锁),
253
267
  // 再退到系统临时目录,避免「插件其实活着但一行日志都没有」的误判。
254
268
  const log = (line) => {
255
- const record = '[' + new Date().toISOString() + '] ' + line + '\n';
269
+ // 日志行内不允许出现换行:会话 id、错误文本都可能带 \n,拼进去就能在日志里
270
+ // 伪造出一行看似插件自己写的记录(伪造 turn/end 或 guard armed 会误导排障)。
271
+ const safe = String(line).replace(/[\r\n]+/g, ' ');
272
+ const record = '[' + new Date().toISOString() + '] ' + safe + '\n';
256
273
  maybeRotate();
257
274
  try {
258
275
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
@@ -268,35 +285,141 @@ export function apply(ctx, config) {
268
285
  }
269
286
  };
270
287
 
288
+ /**
289
+ * 补写预算的跨进程持久化。
290
+ *
291
+ * nudgeTimes 挂在 WeakMap 的会话状态上,是纯内存态:宿主一重启就清零,
292
+ * 一个反复空回合的会话每次重启都能重新领满 maxNudgesPerHour 次补写。
293
+ * 重启不是罕见事件(升级、崩溃、切换模型),累起来就是白烧的 token,
294
+ * 所以把每次投递的时刻追加进一个小文件,进程启动后第一次见到某会话时
295
+ * 回读它最近一小时的记录。
296
+ *
297
+ * 约束:只用于限流,不参与任何判定;读写失败一律忽略(宁可多补一次,
298
+ * 也不能让限流文件的 IO 问题拖垮宿主回合)。多进程并发追加时可能丢几行,
299
+ * 这是可接受的——它只是预算,不是账本。
300
+ */
301
+ const budgetPath = cfg.budgetFile !== ''
302
+ ? cfg.budgetFile
303
+ : path.join(dshHome, 'state', 'turn-recovery', 'nudges.log');
304
+ const BUDGET_MAX_BYTES = 1024 * 1024;
305
+ let budgetHydrated = false;
306
+ /** session.id -> 最近一小时内的投递时刻(只在本进程内累计)。 */
307
+ const budgetHistory = new Map();
308
+
309
+ const hydrateBudget = () => {
310
+ if (budgetHydrated) return;
311
+ budgetHydrated = true;
312
+ try {
313
+ const rawText = fs.readFileSync(budgetPath, 'utf8');
314
+ const cutoff = Date.now() - HOUR_MS;
315
+ for (const line of rawText.split('\n')) {
316
+ const sp = line.indexOf(' ');
317
+ if (sp <= 0) continue;
318
+ const id = line.slice(0, sp);
319
+ const at = Number(line.slice(sp + 1).trim());
320
+ if (id === '' || !Number.isFinite(at) || at < cutoff) continue;
321
+ const list = budgetHistory.get(id);
322
+ if (list) list.push(at);
323
+ else budgetHistory.set(id, [at]);
324
+ }
325
+ } catch { /* 文件不存在或读不了:当作没有历史 */ }
326
+ };
327
+
328
+ /** 该会话最近一小时的投递时刻(合并持久化记录与本次进程内的记录)。 */
329
+ const historyOf = (session) => {
330
+ hydrateBudget();
331
+ const id = session && typeof session.id === 'string' ? session.id : '';
332
+ const persisted = id !== '' ? budgetHistory.get(id) : undefined;
333
+ return persisted ? persisted.slice() : [];
334
+ };
335
+
336
+ /** 记录一次真正投递成功的补写;文件过大时先按一小时窗口重写。 */
337
+ const recordNudge = (session, at) => {
338
+ const id = session && typeof session.id === 'string' ? session.id : '';
339
+ if (id === '') return;
340
+ try {
341
+ if (!budgetHistory.has(id)) budgetHistory.set(id, []);
342
+ budgetHistory.get(id).push(at);
343
+ fs.mkdirSync(path.dirname(budgetPath), { recursive: true });
344
+ let size = 0;
345
+ try { size = fs.statSync(budgetPath).size; } catch { /* 首次写入 */ }
346
+ if (size > BUDGET_MAX_BYTES) {
347
+ const cutoff = Date.now() - HOUR_MS;
348
+ const lines = [];
349
+ for (const [sid, times] of budgetHistory) {
350
+ for (const t of times) if (t >= cutoff) lines.push(sid + ' ' + t);
351
+ }
352
+ fs.writeFileSync(budgetPath, lines.join('\n') + (lines.length > 0 ? '\n' : ''));
353
+ } else {
354
+ fs.appendFileSync(budgetPath, id + ' ' + at + '\n');
355
+ }
356
+ } catch { /* 限流退化到内存态,不影响补写本身 */ }
357
+ };
358
+
271
359
  /**
272
360
  * 日志里的会话短标签。旧版直接 id.slice(0,8),而根会话 id 形如
273
361
  * session-<uuid>,前 8 位恒为 "session-",多会话根本分不开。
274
- * 现在先剥掉 session- 前缀再取前 8 位;剥完为空则退回原 id 前 8 位。
362
+ * 现在先剥掉 session- / orchestrator- 前缀再取前 8 位;剥完为空则退回原 id 前 8 位。
275
363
  */
276
364
  const tag = (session) => {
277
365
  try {
278
366
  const id = session && typeof session.id === 'string' ? session.id : '';
279
367
  if (!id) return 'no-session';
280
- const stripped = id.replace(/^session-/, '');
368
+ const stripped = id.replace(/^(session-|orchestrator-)/, '');
281
369
  return stripped ? stripped.slice(0, 8) : id.slice(0, 8);
282
370
  } catch { return 'no-session'; }
283
371
  };
284
372
 
285
373
  /**
286
- * sessionScope='root' 时只认根会话;其余(子代理裸 UUID、social-<hex> 等)跳过。
374
+ * sessionScope='root' 时只认根会话;子代理会话一律跳过。
375
+ *
376
+ * 判据来自宿主的 SessionHeader(dsh-session 构造函数:
377
+ * `this.header = restoredHeader ?? snapshotSessionHeader(id, header)`):
378
+ * 只有 `origin === 'subagent'` 或 `delegationDepth > 0` 才是子代理子会话,
379
+ * 顶层会话的 delegationDepth 为 0 或缺省。
380
+ *
381
+ * 0.2.0 用 /^session-/ 猜 id 形态,把所有 orchestrator-* 会话当成子代理
382
+ * 静默过滤掉(实测日志里有 19 条 skip scope: non-root session orchestrator)。
383
+ * 会话 id 的形态是宿主实现的细节,不该由插件推断。
384
+ *
385
+ * header 缺失(老宿主、鸭子类型对象、测试桩)时退回 id 形态判定:
386
+ * session-* / orchestrator-* 视为根会话,裸 UUID / social-* 跳过。
287
387
  * 过滤时给每个会话打一条日志(同一 id 只打一次):否则「插件没加载」和
288
388
  * 「被 scope 静默过滤」在日志里长得一模一样,排障全靠猜。
289
389
  */
290
390
  const scopeSeen = new Set();
391
+
392
+ /** header 判定根会话;返回 null 表示无法判定(没有 header)。 */
393
+ const rootByHeader = (session) => {
394
+ let header;
395
+ try { header = session && session.header; } catch { return null; }
396
+ if (!header || typeof header !== 'object') return null;
397
+ if (header.origin === 'subagent') return false;
398
+ const depth = header.delegationDepth;
399
+ if (typeof depth === 'number' && Number.isFinite(depth) && depth > 0) return false;
400
+ return true;
401
+ };
402
+
403
+ /** 每个被过滤掉的会话只记一条日志(否则「没加载」和「被过滤」在日志里无法区分)。 */
404
+ const logScopeSkip = (session, why) => {
405
+ const id = session && typeof session.id === 'string' ? session.id : '';
406
+ if (id === '' || scopeSeen.has(id)) return;
407
+ if (scopeSeen.size < 200) scopeSeen.add(id);
408
+ log('skip scope: non-root session ' + id.slice(0, 12) + ' (sessionScope=root, ' + why + ')');
409
+ };
410
+
291
411
  const sessionInScope = (session) => {
292
412
  if (cfg.sessionScope === 'all') return true;
413
+ const byHeader = rootByHeader(session);
414
+ if (byHeader === true) return true;
415
+ if (byHeader === false) {
416
+ logScopeSkip(session, 'header says subagent');
417
+ return false;
418
+ }
293
419
  const id = session && typeof session.id === 'string' ? session.id : '';
294
- if (/^session-/.test(id)) return true;
420
+ if (/^(session-|orchestrator-)/.test(id)) return true;
295
421
  if (id === '') return false;
296
- if (!scopeSeen.has(id)) {
297
- if (scopeSeen.size < 200) scopeSeen.add(id);
298
- log('skip scope: non-root session ' + id.slice(0, 12) + ' (sessionScope=root)');
299
- }
422
+ logScopeSkip(session, 'id is not a root shape and no header');
300
423
  return false;
301
424
  };
302
425
 
@@ -320,7 +443,14 @@ export function apply(ctx, config) {
320
443
  }
321
444
  log('prompt section registered: order=' + SECTION_ORDER + ' chars=' + text.length);
322
445
  } catch (err) {
446
+ // fail-closed:A 段装不上时不再启用补写,也不再写「guard armed」。
447
+ // 宿主对同名 section 重复注册是直接 throw(dsh-system-prompt 的 section
448
+ // 注册会拒绝重名),最常见的触发原因是同一插件被两个 bundle 各装了一次。
449
+ // 此时若照常 arm:两个实例各发一条补写;日志却写着「一切正常」,
450
+ // 而「222 字符的收尾指令其实没进 prompt」这个事实在日志里看不出来。
323
451
  log('prompt section registration FAILED: ' + errText(err));
452
+ log('guard NOT armed (fail-closed: prompt section registration failed)');
453
+ return;
324
454
  }
325
455
  }
326
456
 
@@ -364,7 +494,8 @@ export function apply(ctx, config) {
364
494
  nudgeDeliveredAt: 0,
365
495
  /** 定时器是为哪个回合排的;回合变了就该丢弃。 */
366
496
  pendingTurn: null,
367
- nudgeTimes: [],
497
+ /** 本小时已投递的补写时刻:启动时用持久化记录补齐(跨重启限流)。 */
498
+ nudgeTimes: historyOf(session),
368
499
  timer: null,
369
500
  };
370
501
  state.set(session, s);
@@ -541,6 +672,22 @@ export function apply(ctx, config) {
541
672
  source: { ...SOURCE },
542
673
  });
543
674
 
675
+ /**
676
+ * 宿主的 next-turn 队列里是否已经躺着一笔本插件的补写。
677
+ *
678
+ * nudgeMessageId 是进程内记忆:宿主重启、插件热重载、同一插件被两个 bundle
679
+ * 各装一次时它都是空的,于是会对同一个空回合各投一笔,模型连着被唤醒两次。
680
+ * 队列本身是宿主维护的真相(agent.inbox.nextTurn),以它为准去重。
681
+ */
682
+ const pendingNudgeInInbox = (agent) => {
683
+ try {
684
+ const inbox = agent && agent.inbox;
685
+ const queue = inbox && Array.isArray(inbox.nextTurn) ? inbox.nextTurn : null;
686
+ if (!queue) return false;
687
+ return queue.some((m) => m && m.source && m.source.kind === SOURCE_KIND);
688
+ } catch { return false; }
689
+ };
690
+
544
691
  const deliver = (session, text) => {
545
692
  const agent = agentOf(session);
546
693
  if (!agent) {
@@ -614,6 +761,11 @@ export function apply(ctx, config) {
614
761
  return;
615
762
  }
616
763
  if (s.nudgeMessageId !== null) clearNudgeInFlight(s, 'claim timeout');
764
+ if (pendingNudgeInInbox(agentOf(session))) {
765
+ log('nudge SKIPPED: inbox already holds a pending turn-recovery nudge ['
766
+ + tag(session) + ']');
767
+ return;
768
+ }
617
769
  if (cfg.maxNudgesPerHour === 0) {
618
770
  log('nudge SKIPPED: maxNudgesPerHour=0 [' + tag(session) + ']');
619
771
  return;
@@ -630,6 +782,8 @@ export function apply(ctx, config) {
630
782
  s.nudgeMessageId = message.id;
631
783
  s.nudgeDeliveredAt = Date.now();
632
784
  s.nudgeTimes.push(Date.now());
785
+ // 跨重启限流:把投递时刻落盘,重启后的新进程回读最近一小时。
786
+ recordNudge(session, s.nudgeDeliveredAt);
633
787
  log('nudge awaiting claim id=' + String(message.id).slice(0, 8) + ' [' + tag(session) + ']');
634
788
  } else {
635
789
  clearNudgeInFlight(s, null);
@@ -686,13 +840,19 @@ export function apply(ctx, config) {
686
840
  + ' matched=true [' + tag(session) + ']');
687
841
  return;
688
842
  }
689
- // 宿主的「背景注入」不是有人在说话:runtime-context 每步都追加一条
690
- // (dsh-agent-loop),time-context/skill-catalog/dsh-mnemon/agent-instructions
691
- // 等也是自动上下文;把它们当插话会让补写几乎必被取消(0.1.3 修过的老 bug)。
692
- // 除此之外的一切来源——真人、子代理回报、微信/社交桥消息——都算「有动静」,
693
- // 取消待发补写,避免补写插进别人的输入里。
694
- const isBackground = BACKGROUND_KINDS.has(kind) || kind.startsWith('plugin:');
695
- if (!isBackground) clearTimer(s, 'user message (' + (kind || 'no-kind') + ')');
843
+ // 只有「确实有人在说话」的来源才取消待发补写(见 DEFAULT_FOREGROUND_KINDS):
844
+ // 真人输入与 agent-message 转交。其余一切——宿主的 runtime-context /
845
+ // time-context / skill-catalog / dsh-mnemon 等自动上下文、form:"notice"
846
+ // 的工具/卡片/计划/任务通知、kind:"goal"/"schedule" 的自动化回合,以及
847
+ // 未来新增的、本插件不认识的来源——都按后台处理,不取消。
848
+ // 0.2.0 用「后台黑名单 + 未知即前台」,宿主每加一个通知来源就可能让补写
849
+ // 被静默取消;白名单把默认值改成「不认识就当背景」,漏取消由 turn/start 兜底。
850
+ const form = source && typeof source.form === 'string' ? source.form : '';
851
+ // form:'notice' 是宿主的通知形态(tool-jobs / action-cards / plan-mode /
852
+ // tool-goal 都用它):无论 kind 叫什么,它都不是人在说话。
853
+ if (form !== 'notice' && cfg.foregroundKinds.has(kind)) {
854
+ clearTimer(s, 'user message (' + kind + ')');
855
+ }
696
856
  return;
697
857
  }
698
858
 
@@ -782,7 +942,8 @@ export function apply(ctx, config) {
782
942
  });
783
943
  } catch { /* effect 不可用时忽略 */ }
784
944
 
785
- log('guard armed (uptime=' + (Date.now() - startedAt) + 'ms)');
945
+ log('guard armed (instruction=' + (cfg.injectInstruction ? 'registered' : 'disabled')
946
+ + ', uptime=' + (Date.now() - startedAt) + 'ms)');
786
947
  }
787
948
 
788
949
  export default { name, inject, apply };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-turn-recovery",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "DSH plugin: detect assistant turns that end with no visible text (zero-width placeholder) and re-prompt the model to write the answer; also injects a visible-closing-answer system prompt rule.",
5
5
  "license": "MIT",
6
6
  "author": "HERO476",
@@ -38,7 +38,7 @@
38
38
  ],
39
39
  "engines": {
40
40
  "node": ">=20",
41
- "dsh": ">=0.2.1-rc.1 <0.3.0"
41
+ "dsh": ">=0.2.1-rc.1 <0.3.0-0"
42
42
  },
43
43
  "scripts": {
44
44
  "test": "node test/guard.test.mjs",