dsh-turn-recovery 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,60 @@
7
7
  > `@nlflora/dsh-empty-turn-guard`)冲突,自 0.2.0 起更名为 `dsh-turn-recovery`。0.1.x 及更早的条目保留旧名原文,
8
8
  > 其提到的 `plugin:empty-turn-guard`、`empty-turn-guard.log` 等标识在 0.2.0 起分别改为 `plugin:turn-recovery`、`turn-recovery.log`。
9
9
 
10
+ ## [0.2.6] - 2026-11-14
11
+
12
+ **新版宿主契约破裂版**。对着 DSH **0.2.2-rc.3**(`app.asar` 内实际加载的运行时)
13
+ 逐条核对事件形状后,查出 0.2.5 的**来源闸门在真实宿主上把每一个空回合都拦掉了** ——
14
+ 功能整体失效,而 76 个单测全绿,因为单测没有复现宿主「每回合追加运行时上下文」的行为。
15
+
16
+ ### 修复(缺陷)
17
+
18
+ - **来源闸门被 `runtime-context` 覆盖(P0,功能整体失效)**:宿主 `dsh-agent-loop`
19
+ 在人类消息之后会 append 一条 `user/message`,其 `source = { kind: 'runtime-context',
20
+ form: 'snapshot' }`(时间在变,几乎每回合都重发)。0.2.5 的
21
+ `if (form !== 'notice') s.turnOrigin = kind` 每见到一条就覆盖,于是
22
+ `turnOrigin` 恒等于 `runtime-context`,空回合判定通过后被闸门拦下,
23
+ **补写永不发生**。生产日志三周 `emptyCompleted=0 / delivered=0 / claimed=0`。
24
+ 改为:`turnOrigin` 只在来源落在放行集合里时记录,且**首个优先**、后面的不覆盖;
25
+ 另用 `lastSourceKind` 保留"实际拦截的是谁"用于日志。
26
+ - **`form:'notice'` 回合 fail-open(P1)**:旧版对 `turnOrigin === ''` 一律放行,
27
+ 于是「只有通知形态消息」的回合(`form:'notice'`)会被补写。现在一旦本回合见过
28
+ 带 `source.kind` 的消息、却没有一条在放行集合里,就 fail-closed。
29
+ 完全没见过消息的回合(罕见的内部回合)仍按旧行为放行,日志留痕。
30
+ - **回合计数不随回合复位(P1,静默漏判)**:`visibleChars / assistantMessages /
31
+ sawTurnStart` 只在 `turn/start` 里清零。`turn/start` 没被看到的回合会沿用上一回合
32
+ 的计数:真的空回合被判成"非空"(漏补写),`sawTurnStart` 也停在 true,
33
+ 使"中途加载不判定"的保护同时失效。实测日志:`turn=2 … steps=1 visibleChars=27`,
34
+ 而该回合一条 assistant 消息都没有。现在 `turn/end` 先整体快照、**立刻复位**计数再判定;
35
+ `s.turn` 以 `turn/end` 为准(缺 `turn/start` 时它落后一整回合,会让定时器的
36
+ "回合已前进"检查把刚排的补写当成过期丢弃)。
37
+ - **复核 `turn/start` 缺失的语义(P1)**:新增 `everSawTurnStart`。本会话从未见过
38
+ `turn/start` → 仍按"中途加载"跳过(fail-closed);之前见过 → 视为事件丢失,
39
+ 用已复位的计数正常判定,不再整回合白漏一次补写。
40
+ - **新增 `nudgeKinds` 配置(P2,产品决定显式化)**:默认 `['completed']`。
41
+ 生产日志里 695 个 `turn/end` 有 200 个是空回合,全部是 `aborted` / `error` / `blocked`,
42
+ 而旧版只处理 `completed` —— 也就是说这套逻辑在本机三周内 0 次命中目标场景。
43
+ 是否给中断/报错回合补写是产品决定,现在把它做成配置:放开 `'aborted'` 等于
44
+ "用户按了停止之后插件还会再唤醒一轮模型",默认绝不打开。
45
+ - **新增 `SUSPICIOUS` 防御日志(P3)**:非空判定却没有任何 assistant 消息时记一行,
46
+ 用于发现"visibleChars 来自别处"这类回归。正常路径零成本。
47
+ - **启动自检行(P2)**:加载时打一行 `host api: systemPrompt.section=ok session/event=ok
48
+ nudgeKinds=… nudgeSources=…`,跨小版本加载出问题时不必先读源码猜哪一步没接上。
49
+
50
+ ### 变更
51
+
52
+ - `engines.dsh` 上界从 `<0.3.0-0` 放宽到 `<0.4.0-0`:旧上界会在 DSH 升到 0.3 时
53
+ 让 pnpm 直接拒绝装载 bundle(表现为插件**静默消失**)。跨 0.4 仍需重新适配。
54
+ - 单测 76 → **83** 例:新增 runtime-context 覆盖、纯上下文/纯 notice 回合、
55
+ `turn/start` 缺失的计数复位、`nudgeKinds` 默认与放开、配置写错、启动自检。
56
+
57
+ ### 已知未验证(如实记录)
58
+
59
+ - `agent/inbox/discarded` 在真实点「停止」时是否到达 `ctx.on`:源码形状已核对,
60
+ 运行期未抓到实例。
61
+ - 生产转录为 `session.v4.jsonl.zstd`,未解压核对 runtime-context 的落盘顺序;
62
+ 结论依据宿主源码(`dsh-agent-loop` 的 append 顺序)+ 灰盒复现 + 插件自身日志。
63
+
10
64
  ## [0.2.5] - 2026-11-12
11
65
 
12
66
  **宿主契约修正版**。三专家评审(架构 / 安全 / 验收)对着宿主 0.2.2-rc.1 的
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  ## 行为
16
16
 
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)。助手消息含图片/文件/音视频附件时该回合不算空(界面上不是空白);用户消息里的附件不计入这个判定。
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` 落在 `nudgeKinds`(默认 `['completed']`)里」,判定为空回合 —— **没有任何 assistant 消息的 completed 回合也算空**(0 < 1)。助手消息含图片/文件/音视频附件时该回合不算空(界面上不是空白);用户消息里的附件不计入这个判定。
18
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
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`)时不判定该回合。
@@ -67,6 +67,8 @@
67
67
  nudgeSources: [] # 允许被补写的「回合发起来源」;空 = 沿用 foregroundKinds
68
68
  # 社交桥(wechat-social-bridge 等)默认**不在**其中:模型输出
69
69
  # 可能被转发给第三方联系人,补写等于替你对外发言
70
+ nudgeKinds: [completed] # 允许补写的 turn/end 原因(0.2.6)。默认只补 completed;
71
+ # 放开 aborted/error 等于「用户按了停止也会再唤醒一轮」,谨慎
70
72
  headerlessScope: out # 会话无 SessionHeader 时的判定:out = 不碰(默认);root = 沿用旧版 id 形态猜
71
73
  budgetFile: '' # 空 = <DSH_HOME>/state/turn-recovery/nudges.log(跨重启限流)
72
74
  # 必须 .log 结尾;同目录还会写 guard-ids.log(跨重启认出晚到认领的补写 id)
@@ -125,7 +127,7 @@
125
127
 
126
128
  `version=` 是进程里真正加载的版本号(从包自身 `package.json` 读)——**升级后先看这一行**,不要靠「某个日志字段有没有」推断跑的是哪一版(0.1.4 与 0.1.5 都打过 `sawStart`,这个推断会错)。
127
129
 
128
- 排查常用短语:`config issue`(配置写错,插件仍启用)、`skip scope`(被 `sessionScope` 过滤掉的会话,括号里写明是 `header says subagent` 还是 `no header (headerlessScope=out)`)、`scope fallback`(`headerlessScope: 'root'` 时靠 id 形态放行的会话,首次记一行)、`guard NOT armed (fail-closed`(同名 prompt 段落已被占用,插件整体不启用,不再谎报 armed)、`inbox already holds a pending turn-recovery nudge`(队列里已有一笔,未重复投递)、`turn/start not observed`(热加载中途,不判定)、`NOT ours`(同来源但 id 对不上,已忽略;0.2.5 起不再因此清在途标记)、`LATE, from guardIds`(认领超时后才到,仍按补写回合处理)、`DISCARDED by host`(用户停止,补写被丢弃)、`is not an in-flight nudge`(丢弃事件里的 id 不是我们的在途补写)、`origin=… not in nudge sources`(来源闸门拦下的空回合,例如社交桥)、`a nudge is already awaiting claim`(上一笔还没被认领)、`skipped:in-flight`(本回合没排上,因为上一笔仍在途 —— 不算「已补过」)、`turn N already handled`(该回合确实排过或确实被限流)、`DROPPED: turn moved on`(定时器到点时会话已进入下一回合)、`stop chaining`(补写回合又空,按设计不再连环)、`budget exhausted`(本小时额度用完)、`guard ids compacted` / `budget compact skipped`(状态文件压缩结果)、`log rotate skipped: file does not look like`(`logFile` 配到了别的文件,拒绝轮转它)。
130
+ 排查常用短语:`config issue`(配置写错,插件仍启用)、`skip scope`(被 `sessionScope` 过滤掉的会话,括号里写明是 `header says subagent` 还是 `no header (headerlessScope=out)`)、`scope fallback`(`headerlessScope: 'root'` 时靠 id 形态放行的会话,首次记一行)、`guard NOT armed (fail-closed`(同名 prompt 段落已被占用,插件整体不启用,不再谎报 armed)、`inbox already holds a pending turn-recovery nudge`(队列里已有一笔,未重复投递)、`turn/start not observed`(热加载中途,不判定)、`NOT ours`(同来源但 id 对不上,已忽略;0.2.5 起不再因此清在途标记)、`LATE, from guardIds`(认领超时后才到,仍按补写回合处理)、`DISCARDED by host`(用户停止,补写被丢弃)、`is not an in-flight nudge`(丢弃事件里的 id 不是我们的在途补写)、`origin=… not in nudge sources`(来源闸门拦下的空回合,例如社交桥 / 纯 runtime-context / 纯 notice)、`kind=… not in nudgeKinds`(该回合原因不在允许补写的集合里,例如 aborted / error)、`turn/start missing for turn N`(事件丢失但计数是干净的,仍会判定)、`SUSPICIOUS: turn=N`(判成非空却没有任何 assistant 消息,说明计数有回归)、`host api: …`(加载自检,`MISSING` 表示宿主接口对不上)、`a nudge is already awaiting claim`(上一笔还没被认领)、`skipped:in-flight`(本回合没排上,因为上一笔仍在途 —— 不算「已补过」)、`turn N already handled`(该回合确实排过或确实被限流)、`DROPPED: turn moved on`(定时器到点时会话已进入下一回合)、`stop chaining`(补写回合又空,按设计不再连环)、`budget exhausted`(本小时额度用完)、`guard ids compacted` / `budget compact skipped`(状态文件压缩结果)、`log rotate skipped: file does not look like`(`logFile` 配到了别的文件,拒绝轮转它)。
129
131
 
130
132
  ## 实现约束
131
133
 
@@ -137,13 +139,15 @@
137
139
  本包零依赖,装进 profile 后也解析不到 `schemastery`(更不能 import `@deepseek-ai/*`)。与其导出一个「在 A 路径像 schema、在 B 路径像垃圾」的对象,不如不导出,把校验放在加载期日志里:错了能看见,也不会让设置页崩。
138
140
  - 所有逻辑包在 try/catch 里:任何异常只写日志,不向上抛,不会拖垮宿主回合。
139
141
  - 宿主门槛 `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`)。
140
- - **升级宿主前请先看门槛**:`<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 漂移风险)。
142
+ - **升级宿主前请先看门槛**:`<0.4.0-0` 的上界(0.2.6 起从 `<0.3.0-0` 放宽)意味着宿主升到 0.4.0(含 `0.4.0-alpha*` / `0.4.0-beta*`)时本插件会**静默不加载**(`dsh-app-boot` 只往 stderr 写一行 `skipping profile bundle`)。0.2.5 的上界是 `<0.3.0-0`,所以 DSH 一升到 0.3.x 就会这样消失。请先看日志首行 `plugin activated: ... version=...` 与紧随其后的 `host api: ...` 自检行:没有它们就是没加载或没接上。跨 0.4 需要重新适配后再改上界。
141
143
  - `inject = ['systemPrompt']` 是 cordis 的**硬依赖**,不做「可选依赖」改造:本版本 `@deepseek-ai/cordis` 的 `Inject.resolve` 只接受「数组」或「name → config 映射」,没有 `required`/`optional` 语义;写成 `{ optional: [...] }` 会被当成「等一个叫 optional 的服务」,插件将永不激活。
142
144
 
143
145
  ## 已知限制
144
146
 
145
147
  - **会话状态是双索引**:`state` 是 WeakMap(按 session 对象身份,快路径),另有有界(500 条)的 `stateById`(按 `session.id`,兜底)。宿主在某些代码路径上会给出不同的 session 实例;只按对象身份索引时,`turn/start` 与 `turn/end` 会落在两份状态上,`sawTurnStart` 恒为 false、判定链整体静默早退(只留一行 `turn/start not observed`)。现在两个索引同时写,`agent/disposed` 时同时清。
146
- - **补写只在「人类发起」的回合里发生(0.2.5 来源闸门)**:`turn/end` 会检查本回合最后一条 `user/message` 的 `source.kind`,只有落在 `nudgeSources`(默认等于前台白名单 `user` / `agent-message`)里才补写。原因是社交桥(`source.kind='wechat-social-bridge'`, `form='relay'`)这类会话里,桥接器会把模型输出发回给真实的微信/QQ 联系人 —— 插件往这种会话塞一句"请直接输出纯文本",等于**替你对外发言**。需要给桥接对话也保留保护时,把该 kind 写进 `nudgeSources` 显式开启。本回合没有 `user/message`(拿不到来源)时按"无法判定"放行,日志里能看到判定过程。
148
+ - **补写只在「人类发起」的回合里发生(0.2.6 来源闸门)**:`turn/end` 会检查本回合的 `source.kind`,只有落在 `nudgeSources`(默认等于前台白名单 `user` / `agent-message`)里才补写。原因是社交桥(`source.kind='wechat-social-bridge'`, `form='relay'`)这类会话里,桥接器会把模型输出发回给真实的微信/QQ 联系人 —— 插件往这种会话塞一句"请直接输出纯文本",等于**替你对外发言**。需要给桥接对话也保留保护时,把该 kind 写进 `nudgeSources` 显式开启。
149
+
150
+ 0.2.5 的写法有两个洞,0.2.6 都补上了:① 宿主 `dsh-agent-loop` 在人类消息之后会 append 一条 `source.kind='runtime-context'` / `form='snapshot'` 的运行时上下文快照(时间在变,几乎每回合都重发),旧版每见到一条非 `notice` 消息就覆盖 `turnOrigin`,于是它恒等于 `runtime-context` —— 空回合判定通过后被闸门拦掉,**补写永不发生**(DSH 0.2.2-rc.3 上实测如此)。现在只记录落在放行集合里、且是本回合**首个**的来源。② 旧版对空来源一律放行,于是「只有 `form:'notice'` 通知」的回合也会被补写;现在见过带来源的消息却一条都不在放行集合里就 fail-closed。完全没见过带来源消息的回合(罕见的内部回合)仍按旧行为放行,日志会写明 `origin=(none)`。
147
151
  - **补写消息以 `role: 'user'` 落盘**(`source.kind = 'plugin:turn-recovery'`):宿主 session 层对 `source.kind` 不做枚举校验(只要求非空字符串),插件侧也改不了 role。桌面前端按 `source.kind !== 'user'` 渲染成上下文卡片(不是用户气泡),mnemon 只收录 `source.kind === 'user'`,所以界面与记忆都不受污染;但**会话转录文件里它确实是一条 user 消息**,审计时要看 `source.kind` 区分。另外 `plugin:*` 这个前缀不在宿主 `MessageSourceMap` 的类型联合里 —— 运行时不受影响(前端对未知 kind 走 `default` 分支,显示为「请求」卡片),但下游按类型联合写的消费者会漏掉它。
148
152
  - **用户点停止的窗口**:宿主 `cancel()` 只清 inbox + abort,不 append 任何 session 事件。插件订阅宿主的 `agent/inbox/discarded` 来撤掉自己那条补写。0.2.4 及更早读的是 `payload.agent`,而宿主 0.2.2-rc.1 实际只发 `{ message }`(`dsh-agent-loop/lib/index.js:199`),于是处理器永远在第一行 return —— 0.2.5 改为按 `message.id` 反查在途补写,真实形状下才会真的撤掉定时器。**仍未验证**的是:该事件是否向插件的根 `ctx` 广播(宿主在自己的 `ownerCtx` 上监听 `agent/disposed`,说明 agent 作用域事件存在)。收不到时,「用户已点停止、插件仍在 `delayMs` 后补写」这个窗口依然存在(默认 900ms,可通过 `delayMs` 调小)。真正发出去的那一回合仍受宿主 abort 语义约束。
149
153
  - **`sessionScope: 'root'` 的判定依据是宿主的 `Session.header`**:`origin === 'subagent'` 或 `delegationDepth > 0` 才算子代理会话,`orchestrator-*` 这类顶层会话会被正常处理(0.2.0 用 id 前缀判断,把它们全部判成子代理,实测日志里有 19 次静默跳过)。**0.2.5 起**:拿不到 header 时默认**不判定**(`headerlessScope: 'out'`,fail-closed)—— 旧版按 id 形态把 `session-*` / `orchestrator-*` 当根会话,于是「无 header 的子代理」也会被补写,往子代理里塞"不要调用工具"会破坏委派语义。确有老宿主需要兼容时显式设 `headerlessScope: 'root'`(首次放行会记一行 `scope fallback`)。被过滤的会话每个 id 只记一条 `skip scope` 日志,不会静默消失。
package/lib/index.js CHANGED
@@ -77,6 +77,14 @@ const SOURCE_KIND = SOURCE.kind;
77
77
  * 若某个自定义桥接需要取消语义,用配置 `foregroundKinds: ['wechat-bridge']` 补进来。
78
78
  */
79
79
  const DEFAULT_FOREGROUND_KINDS = Object.freeze(['user', 'agent-message']);
80
+ /**
81
+ * 允许补写的 turn/end 原因(0.2.6)。
82
+ *
83
+ * 默认只有 'completed'。aborted / error / blocked 意味着「用户按了停止」或
84
+ * 「上游失败」:这类回合补写会在用户喊停之后又唤醒一轮模型,或在错误级联上
85
+ * 再叠一次调用,副作用比"界面空白"更糟。确有需要时用 nudgeKinds 显式放开。
86
+ */
87
+ const DEFAULT_NUDGE_KINDS = Object.freeze(['completed']);
80
88
  const SECTION_NAME = 'turn-recovery';
81
89
  /** 950 已被 instruction-memory 占用;960 在其后、TOOL_BASH(1000) 之前。 */
82
90
  const SECTION_ORDER = 960;
@@ -191,7 +199,7 @@ function enabledOr(value, fallback) {
191
199
  const CONFIG_KEYS = new Set([
192
200
  'enabled', 'injectInstruction', 'dryRun', 'instructionText',
193
201
  'minVisibleChars', 'delayMs', 'maxNudgesPerHour', 'logFile', 'logTurns',
194
- 'sessionScope', 'foregroundKinds', 'nudgeSources', 'headerlessScope', 'budgetFile',
202
+ 'sessionScope', 'foregroundKinds', 'nudgeSources', 'nudgeKinds', 'headerlessScope', 'budgetFile',
195
203
  ]);
196
204
  const NUMERIC_KEYS = ['minVisibleChars', 'delayMs', 'maxNudgesPerHour'];
197
205
  const STRING_KEYS = ['instructionText', 'logFile', 'budgetFile'];
@@ -266,6 +274,13 @@ function describeConfigIssues(raw) {
266
274
  const bad = raw.nudgeSources.filter((k) => typeof k !== 'string' || k.trim() === '').length;
267
275
  if (bad > 0) issues.push(bad + ' non-string/empty entry in "nudgeSources" -> dropped');
268
276
  }
277
+ if (raw.nudgeKinds !== undefined && !Array.isArray(raw.nudgeKinds)) {
278
+ issues.push('"nudgeKinds" expects an array, got ' + brief(raw.nudgeKinds) + ' -> default used');
279
+ } else if (Array.isArray(raw.nudgeKinds)) {
280
+ const bad = raw.nudgeKinds.filter((k) => typeof k !== 'string' || k.trim() === '').length;
281
+ if (bad > 0) issues.push(bad + ' non-string/empty entry in "nudgeKinds" -> dropped');
282
+ if (raw.nudgeKinds.length === 0) issues.push('"nudgeKinds" is empty -> default ["completed"] used');
283
+ }
269
284
  if (raw.headerlessScope !== undefined && raw.headerlessScope !== 'out' && raw.headerlessScope !== 'root') {
270
285
  issues.push('"headerlessScope" expects "out" | "root", got ' + brief(raw.headerlessScope) + ' -> "out"');
271
286
  }
@@ -359,6 +374,15 @@ export function apply(ctx, config) {
359
374
  const extra = list.filter((k) => typeof k === 'string' && k.trim() !== '').map((k) => k.trim());
360
375
  return extra.length > 0 ? new Set(extra) : null;
361
376
  })(),
377
+ /**
378
+ * 哪些 turn/end 原因允许补写(0.2.6)。默认 ['completed']。
379
+ * 放开 'aborted' 等于「用户按停止之后插件还会再唤醒一轮」,谨慎使用。
380
+ */
381
+ nudgeKinds: (() => {
382
+ const list = Array.isArray(raw.nudgeKinds) ? raw.nudgeKinds : [];
383
+ const items = list.filter((k) => typeof k === 'string' && k.trim() !== '').map((k) => k.trim());
384
+ return items.length > 0 ? new Set(items) : new Set(DEFAULT_NUDGE_KINDS);
385
+ })(),
362
386
  /**
363
387
  * 会话 header 缺失时怎么判定(0.2.5)。宿主的 SessionHeader 是根/子代理的
364
388
  * 唯一可信判据;拿不到时旧版按 id 形态猜(session-* 当根会话),于是
@@ -373,6 +397,11 @@ export function apply(ctx, config) {
373
397
  dryRun: enabledOr(raw.dryRun, false),
374
398
  };
375
399
 
400
+ // 来源闸门的放行集合(0.2.6):nudgeSources 优先,否则沿用前台白名单。
401
+ // 这个集合只算一次,user/message 记录来源与 turn/end 判定闸门必须用同一个,
402
+ // 否则「记的时候用一个集合、判的时候用另一个」会造出静默失效(0.2.5 就是这样)。
403
+ cfg.allowedOrigins = cfg.nudgeSources || cfg.foregroundKinds;
404
+
376
405
  // 宿主不校验插件配置(本包不导出 Config schema,见 README「为什么没有 Config」),
377
406
  // 于是写错键名/类型只会在运行期被静默兜底成默认值 —— 用户以为改生效了,其实没有。
378
407
  // 这里在加载期把可疑配置显式挑出来写进日志,成本是几行字符串拼接。
@@ -870,6 +899,15 @@ export function apply(ctx, config) {
870
899
  log('plugin activated: pid=' + process.pid + ' version=' + VERSION
871
900
  + ' enabled=' + cfg.enabled + ' instruction=' + cfg.injectInstruction
872
901
  + ' dryRun=' + cfg.dryRun + ' dshHome=' + dshHome);
902
+ // 宿主 API 自检(0.2.6):engines.dsh 的上界放宽之后,跨小版本加载可能遇到
903
+ // 签名变动。把真正用到的入口打一行,出问题时不必先读源码猜是哪一步没接上。
904
+ try {
905
+ log('host api: systemPrompt.section='
906
+ + (ctx.systemPrompt && typeof ctx.systemPrompt.section === 'function' ? 'ok' : 'MISSING')
907
+ + ' session/event=' + (typeof ctx.on === 'function' ? 'ok' : 'MISSING')
908
+ + ' nudgeKinds=' + [...cfg.nudgeKinds].join('|')
909
+ + ' nudgeSources=' + [...cfg.allowedOrigins].join('|'));
910
+ } catch { /* 自检失败不影响主流程 */ }
873
911
  if (configIssues.length > 0) {
874
912
  for (const issue of configIssues) log('config issue: ' + issue);
875
913
  log('config issues: ' + configIssues.length + ' (defaults used where noted)');
@@ -980,6 +1018,28 @@ export function apply(ctx, config) {
980
1018
  * 才允许补写,避免往社交桥这类「模型输出会被转发给第三方」的会话里插话。
981
1019
  */
982
1020
  turnOrigin: '',
1021
+ /**
1022
+ * 本回合是否真的收到过 user/message(不含 form:'notice' 的通知)。
1023
+ * 0.2.6:闸门用它区分两种「turnOrigin 是空」的情况:
1024
+ * - 没见到任何消息(自动化/内部回合)→ 维持旧行为,允许补写;
1025
+ * - 见到了消息但没有一条在放行集合里(runtime-context / 社交桥 / goal)
1026
+ * → fail-closed,不补写。
1027
+ */
1028
+ sawUserMessage: false,
1029
+ /**
1030
+ * 本回合最后一条带 source.kind 的消息来源,**只用于日志**。
1031
+ * turnOrigin 只收放行集合里的来源,被拒时日志若不写实际来源,
1032
+ * 排查时会看到 "origin=(none)" 而不知道该拦的是谁。
1033
+ */
1034
+ lastSourceKind: '',
1035
+ /**
1036
+ * 本会话是否**曾经**看到过 turn/start(跨回合保持,不随回合复位)。
1037
+ * 用来区分「插件中途加载,本回合开头没看见」与「事件丢了,但计数是干净的」:
1038
+ * - 从没见过的第一个回合 → 无法判断,跳过(fail-closed);
1039
+ * - 之后的回合缺 turn/start → 计数已在上一回合 turn/end 复位,
1040
+ * 仍可正常判定,不必整回合放弃(否则漏判)。
1041
+ */
1042
+ everSawTurnStart: false,
983
1043
  /**
984
1044
  * 本回合已经「处理过」:要么排上了补写,要么在排补写前就被限流/去重挡下。
985
1045
  * 只在 turn/end 的判定走完之后置位 —— 旧版在调用 scheduleNudge 之前就置位,
@@ -1066,6 +1126,10 @@ export function apply(ctx, config) {
1066
1126
  s.visibleNonText = false;
1067
1127
  s.sawTurnStart = false;
1068
1128
  s.nudgeHandledTurn = null;
1129
+ // 注意:**不要**在这里清 turnOrigin / sawUserMessage。
1130
+ // 宿主在部分路径上会先 append user/message、再 append turn/start(测试里也有这种
1131
+ // 顺序),在这里清空会把本回合的来源抹掉,于是一个人类回合被判成「来源未知」→
1132
+ // fail-closed 拒补。来源标记的复位由 turn/end 的快照路径负责。
1069
1133
  };
1070
1134
 
1071
1135
  /**
@@ -1441,6 +1505,7 @@ export function apply(ctx, config) {
1441
1505
  if (s.timer && !s.awaitingGuardTurn) clearTimer(s, 'new turn started');
1442
1506
  resetTurnCounters(s, data.turn);
1443
1507
  s.sawTurnStart = true;
1508
+ s.everSawTurnStart = true;
1444
1509
  return;
1445
1510
  }
1446
1511
 
@@ -1485,10 +1550,21 @@ export function apply(ctx, config) {
1485
1550
  // 0.2.0 用「后台黑名单 + 未知即前台」,宿主每加一个通知来源就可能让补写
1486
1551
  // 被静默取消;白名单把默认值改成「不认识就当背景」,漏取消由 turn/start 兜底。
1487
1552
  const form = source && typeof source.form === 'string' ? source.form : '';
1553
+ // 本回合确实有「带明确来源」的消息进来。闸门据此区分两种情况:
1554
+ // - 没见过任何带来源的消息(罕见的内部回合)→ 维持旧行为,允许补写;
1555
+ // - 见过、但没有一条在放行集合里(runtime-context / notice / 桥接 / goal)
1556
+ // → fail-closed,不补写。
1557
+ if (kind !== '') s.sawUserMessage = true;
1558
+ if (kind !== '') s.lastSourceKind = kind;
1488
1559
  // 记下本回合是谁发起的:turn/end 的来源闸门读它(见 nudgeSources)。
1489
- // form:'notice' 是宿主的通知形态,不算「有人说话」,但它的 kind 仍可能
1490
- // 恰好是 user —— 那种情况不覆盖 turnOrigin,避免把通知当成人类回合。
1491
- if (form !== 'notice') s.turnOrigin = kind;
1560
+ // 0.2.6:只记「放行集合里」的来源,且首个优先、后面的不覆盖。
1561
+ // 旧版每见到一条非 notice 消息就无条件覆盖,而宿主的 agent-loop 会在
1562
+ // 人类消息之后 append 一条 source.kind='runtime-context' / form='snapshot'
1563
+ // 的上下文快照(时间在变,几乎每回合都重发),于是 turnOrigin 恒等于
1564
+ // 'runtime-context',空回合判定通过后又被闸门拦下 —— 补写永不发生。
1565
+ if (form !== 'notice' && s.turnOrigin === '' && cfg.allowedOrigins.has(kind)) {
1566
+ s.turnOrigin = kind;
1567
+ }
1492
1568
  // form:'notice' 是宿主的通知形态(tool-jobs / action-cards / plan-mode /
1493
1569
  // tool-goal 都用它):无论 kind 叫什么,它都不是人在说话。
1494
1570
  if (form !== 'notice' && cfg.foregroundKinds.has(kind)) {
@@ -1539,11 +1615,37 @@ export function apply(ctx, config) {
1539
1615
  return;
1540
1616
  }
1541
1617
 
1542
- const sawStart = s.sawTurnStart === true;
1543
- const empty = !s.visibleNonText && s.visibleChars < cfg.minVisibleChars;
1544
- // 回合已结束:来源标记取一次就清,避免跨回合残留把上一回合的来源当成这一次的。
1545
- const turnOrigin = s.turnOrigin;
1618
+ // 0.2.6:先整体快照,再立刻复位本回合计数。
1619
+ // 旧版只在 turn/start 里复位,于是「turn/start 没被看到」的回合会沿用上一回合的
1620
+ // visibleChars / assistantMessages / sawTurnStart:真的空回合被判成非空(漏补写),
1621
+ // 而 sawTurnStart 也停在 true,"中途加载不判定"的保护同时失效。
1622
+ // 实测(logTurns:'all'):turn=2 打出 steps=1 visibleChars=27,而该回合根本没有
1623
+ // 任何 assistant 消息 —— 两个值都是从 turn=1 继承来的。
1624
+ const snapshot = {
1625
+ steps: s.assistantMessages,
1626
+ visibleChars: s.visibleChars,
1627
+ visibleNonText: s.visibleNonText === true,
1628
+ sawStart: s.sawTurnStart === true,
1629
+ origin: s.turnOrigin,
1630
+ lastSource: s.lastSourceKind,
1631
+ sawUserMessage: s.sawUserMessage === true,
1632
+ };
1633
+ s.assistantMessages = 0;
1634
+ s.visibleChars = 0;
1635
+ s.visibleNonText = false;
1636
+ s.sawTurnStart = false;
1546
1637
  s.turnOrigin = '';
1638
+ s.sawUserMessage = false;
1639
+ s.lastSourceKind = '';
1640
+
1641
+ const sawStart = snapshot.sawStart;
1642
+ const empty = !snapshot.visibleNonText && snapshot.visibleChars < cfg.minVisibleChars;
1643
+ // 回合已结束:来源标记已在上面取进快照并清空。
1644
+ const turnOrigin = snapshot.origin;
1645
+ // 回合号以 turn/end 为准:缺 turn/start 时 s.turn 会落后一整回合,而定时器的
1646
+ // 「回合已前进」检查拿 s.turn 与 scheduledTurn 比对,落后就会把本回合刚排的
1647
+ // 补写当成过期丢弃(实测表现为「判定通过但一笔都没发」)。
1648
+ s.turn = turn;
1547
1649
  stats.turns += 1;
1548
1650
  if (empty) {
1549
1651
  if (kind === 'completed') stats.emptyCompleted += 1;
@@ -1552,30 +1654,47 @@ export function apply(ctx, config) {
1552
1654
  // 正常回合默认不写行:实测这类行占日志的一半,而它只在「某回合为什么
1553
1655
  // 没被判空」时才有人看。排查期把 logTurns 切成 'all' 即可恢复逐回合记录。
1554
1656
  if (empty || cfg.logTurns === 'all') {
1555
- log('turn/end turn=' + turn + ' kind=' + kind + ' steps=' + s.assistantMessages
1556
- + ' visibleChars=' + s.visibleChars + ' empty=' + empty + ' wasGuard=' + wasGuard
1657
+ log('turn/end turn=' + turn + ' kind=' + kind + ' steps=' + snapshot.steps
1658
+ + ' visibleChars=' + snapshot.visibleChars + ' empty=' + empty + ' wasGuard=' + wasGuard
1557
1659
  + ' sawStart=' + sawStart + ' [' + tag(session) + ']');
1558
1660
  }
1661
+ // 防御性自查(0.2.6):没有任何 assistant 消息、也没有可见附件,却被判成非空。
1662
+ // 这说明 visibleChars 来自别的地方(复位漏了、或宿主把别的角色的消息算进来了)。
1663
+ // 这一行只在真的出问题时出现,平时零成本。
1664
+ if (!empty && snapshot.steps === 0 && !snapshot.visibleNonText) {
1665
+ log('SUSPICIOUS: turn=' + turn + ' judged non-empty with 0 assistant messages'
1666
+ + ' (visibleChars=' + snapshot.visibleChars + ') [' + tag(session) + ']');
1667
+ }
1559
1668
  if (stats.turns % STATS_EVERY_TURNS === 0) log(statsLine(null));
1560
1669
 
1561
1670
  if (!sawStart) {
1562
- // 插件在回合中途才加载/热重载:看不到 turn/start,也就漏掉了该回合早前的
1563
- // 助手消息,visibleChars 少算是必然的,不能据此判定空回合。
1564
- log('skip judgement: turn/start not observed for turn ' + turn + ' (plugin loaded mid-turn?) [' + tag(session) + ']');
1565
- return;
1671
+ // 0.2.6:区分两种「没看到 turn/start」。
1672
+ // - 本会话从未见过 turn/start:插件多半是回合中途加载/热重载,看不到该回合
1673
+ // 早前的 assistant 消息,visibleChars 少算是必然的 → 跳过(fail-closed)。
1674
+ // - 之前见过:这是事件丢失,而计数已在上一回合 turn/end 复位,
1675
+ // 本回合看到的就是完整数据 → 继续判定,否则会白漏一次补写。
1676
+ if (!s.everSawTurnStart) {
1677
+ log('skip judgement: turn/start not observed (first turn of this session, turn '
1678
+ + turn + ') - plugin loaded mid-turn? [' + tag(session) + ']');
1679
+ return;
1680
+ }
1681
+ log('turn/start missing for turn ' + turn
1682
+ + ' but earlier turns were seen -> judging with reset counters [' + tag(session) + ']');
1566
1683
  }
1567
1684
  if (!empty) return;
1568
- if (kind !== 'completed') {
1569
- log('empty turn but kind=' + kind + ' -> no nudge');
1685
+ if (!cfg.nudgeKinds.has(kind)) {
1686
+ log('empty turn but kind=' + kind + ' not in nudgeKinds -> no nudge');
1570
1687
  return;
1571
1688
  }
1572
- // 来源闸门(0.2.5):只给「人类发起」的回合补写。社交桥等来源的会话
1573
- // 一旦被补写,模型输出可能被桥接器转发给第三方联系人 —— 那是用户没有
1574
- // 授权的对外发言,必须默认拒绝;需要时用 nudgeSources 显式放开。
1575
- const allowedOrigins = cfg.nudgeSources || cfg.foregroundKinds;
1576
- if (turnOrigin !== '' && !allowedOrigins.has(turnOrigin)) {
1577
- log('empty turn but origin=' + turnOrigin + ' not in nudge sources -> no nudge ['
1578
- + tag(session) + ']');
1689
+ // 来源闸门(0.2.6 重写):
1690
+ // - turnOrigin 只在来源属于放行集合时才记录(见 user/message 分支),
1691
+ // 宿主每回合追加 runtime-context 覆盖它的问题不再存在;
1692
+ // - 本回合见过带来源的消息、却没有一条在放行集合里 → fail-closed。
1693
+ // 旧版对空来源一律放行,于是「只有 form:'notice' 通知」的回合也会被补写。
1694
+ const allowedOrigins = cfg.allowedOrigins;
1695
+ if (snapshot.sawUserMessage && !allowedOrigins.has(turnOrigin)) {
1696
+ log('empty turn but origin=' + (turnOrigin || snapshot.lastSource || '(none)')
1697
+ + ' not in nudge sources -> no nudge [' + tag(session) + ']');
1579
1698
  return;
1580
1699
  }
1581
1700
  if (wasGuard) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-turn-recovery",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
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-0"
41
+ "dsh": ">=0.2.1-rc.1 <0.4.0-0"
42
42
  },
43
43
  "scripts": {
44
44
  "test": "node test/guard.test.mjs",