dsh-turn-recovery 0.2.4 → 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,113 @@
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
+
64
+ ## [0.2.5] - 2026-11-12
65
+
66
+ **宿主契约修正版**。三专家评审(架构 / 安全 / 验收)对着宿主 0.2.2-rc.1 的
67
+ `app.asar` 源码与 8 天生产日志复核 0.2.4,查出一个「处理器在真实宿主上永远空转」
68
+ 的契约漂移、一条会把模型输出发给第三方的越权路径,以及若干状态文件与路径问题。
69
+
70
+ ### 修复(缺陷)
71
+
72
+ - **`agent/inbox/discarded` 载荷漂移(P1)**:宿主 0.2.2-rc.1 只发
73
+ `{ message }`(`dsh-agent-loop/lib/index.js:199`,已从 asar 逐行核实),
74
+ 而 0.2.4 读的是 `payload.agent` → 处理器永远在第一行 `return`:用户点「停止」
75
+ 后待发补写不会被撤销,仍会多跑一个模型回合,且 `DISCARDED by host` 日志永不出现。
76
+ 改为按 `message.id` 反查 `stateById` 中的在途补写,不再依赖载荷里的 agent/session。
77
+ 单测夹具同步改成宿主真实形状(旧夹具自造 `{agent, message}`,所以 65 例全绿也测不到)。
78
+ - **来源闸门(P1)**:新增 `nudgeSources`。`turn/end` 会检查本回合最后一条
79
+ `user/message` 的 `source.kind`,只有落在白名单(默认 = 前台白名单)才补写。
80
+ 社交桥(`wechat-social-bridge` / `form: relay`)默认被排除:桥接器会把模型输出
81
+ 发回给真人联系人,补写等于**替你对外发言**。
82
+ - **伪造认领不得释放在途标记(P2)**:`clearNudgeInFlight` 原来在「id 不匹配即忽略」
83
+ 之前无条件执行,同进程任一插件 append 一条同 kind、随意 id 的消息就能让守卫
84
+ 多唤醒一次模型。现在只在真的对上 id 时清;过期由 120s 认领超时兜底。
85
+ - **header 缺失时 fail-closed(P2)**:新增 `headerlessScope`,默认 `'out'`(不判定)。
86
+ 旧版按 id 形态把 `session-*` 当根会话,可能给「无 header 的子代理」补写,
87
+ 破坏委派语义。老宿主兼容可显式设 `'root'`。
88
+ - **`sawTurnStart` 不再被误清(P2)**:`turn/start` 缺数值 `turn` 时,
89
+ 后续 `assistant/message` 触发的重置会把 `sawTurnStart` 一并清成 false,
90
+ 整个回合被判成「中途加载」而跳过判定。现在只补记 turn 号、保留 `sawTurnStart`。
91
+ - **`logFile` / `budgetFile` 路径校验(P2)**:自定义路径必须 `.log` 结尾,否则回落默认并记
92
+ config issue;轮转前检查文件头是否为本插件日志(首字节 `[`),不是就拒绝 rename/truncate ——
93
+ 避免一次配置手滑不可逆地毁掉无关文件。
94
+ - **状态文件压缩(P2)**:`guard-ids.log` 此前只增不减,超 1 MiB 后读侧按字节截断会把
95
+ TTL 内的旧记录丢掉(晚到认领认不出 → 连环补写)。现在与预算文件同法按 TTL 原子压缩。
96
+ 预算/guard 压缩时若文件里有内容却一条都解析不出来,判定为「不是我们的文件」并拒绝重写。
97
+
98
+ ### 加固
99
+
100
+ - `stateById` 改为 LRU(命中刷新插入序),避免长跑 >500 会话时把活跃会话按插入序淘汰。
101
+ - 日志压平扩展为 `\r \n U+2028 U+2029 ESC`(部分查看器把前两者当换行、把 ESC 当终端着色)。
102
+ - 状态文件压缩、路径拒绝、来源拦截都会留一行日志,便于现场判定「为什么没补写」。
103
+
104
+ ### 测试
105
+
106
+ - 65 → 76 例。新增:真实形状的丢弃事件(含陌生 id)、来源闸门(默认拦 / 显式放开)、
107
+ headerlessScope 两种取值、`sawTurnStart` 补记、guard-ids 压缩、`logFile` 回落、控制字符压平。
108
+ - 测试夹具默认带根会话 header(0.2.5 起无 header 视为范围外),需要测无 header 时传 `__noHeader`。
109
+
110
+ ### 已知遗留
111
+
112
+ - 「`agent/inbox/discarded` 是否真的广播到插件根 `ctx`」仍**未验证**;
113
+ 收不到时 900ms 停止窗口依然存在(README 已如实标注)。
114
+ - 补写投递 → 认领 → 跨重启去重整条链在真实宿主上仍**零生产证据**
115
+ (`delivered=0 claimed=0`),需按 README 的 dryRun 流程取证。
116
+
10
117
  ## [0.2.4] - 2026-11-09
11
118
 
12
119
  **运行期复核版**。0.2.3 之后按 8 天生产日志(645 个 `turn/end`)回头复查代码,
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`)时不判定该回合。
@@ -64,8 +64,14 @@
64
64
  maxNudgesPerHour: 3 # 每会话每小时的补写上限(0 = 关闭补写)
65
65
  sessionScope: root # root = 只处理根会话(按 SessionHeader 判定);all = 连子代理会话一起
66
66
  foregroundKinds: [] # 追加「会取消待发补写」的来源 kind(默认 user / agent-message)
67
+ nudgeSources: [] # 允许被补写的「回合发起来源」;空 = 沿用 foregroundKinds
68
+ # 社交桥(wechat-social-bridge 等)默认**不在**其中:模型输出
69
+ # 可能被转发给第三方联系人,补写等于替你对外发言
70
+ nudgeKinds: [completed] # 允许补写的 turn/end 原因(0.2.6)。默认只补 completed;
71
+ # 放开 aborted/error 等于「用户按了停止也会再唤醒一轮」,谨慎
72
+ headerlessScope: out # 会话无 SessionHeader 时的判定:out = 不碰(默认);root = 沿用旧版 id 形态猜
67
73
  budgetFile: '' # 空 = <DSH_HOME>/state/turn-recovery/nudges.log(跨重启限流)
68
- # 同目录还会写 guard-ids.log(跨重启认出晚到认领的补写 id)
74
+ # 必须 .log 结尾;同目录还会写 guard-ids.log(跨重启认出晚到认领的补写 id)
69
75
  logFile: '' # 空 = <DSH_HOME>/logs/turn-recovery.log
70
76
  logTurns: empty # empty = 只记空回合/被跳过判定的回合;all = 连正常回合也记
71
77
  dryRun: false # true = 判定/限流/日志全部照走,但不真的投递(验证用)
@@ -121,7 +127,7 @@
121
127
 
122
128
  `version=` 是进程里真正加载的版本号(从包自身 `package.json` 读)——**升级后先看这一行**,不要靠「某个日志字段有没有」推断跑的是哪一版(0.1.4 与 0.1.5 都打过 `sawStart`,这个推断会错)。
123
129
 
124
- 排查常用短语:`config issue`(配置写错,插件仍启用)、`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 对不上,已忽略)、`LATE, from guardIds`(认领超时后才到,仍按补写回合处理)、`DISCARDED by host`(用户停止,补写被丢弃)、`a nudge is already awaiting claim`(上一笔还没被认领)、`skipped:in-flight`(本回合没排上,因为上一笔仍在途 —— 不算「已补过」)、`turn N already handled`(该回合确实排过或确实被限流)、`DROPPED: turn moved on`(定时器到点时会话已进入下一回合)、`stop chaining`(补写回合又空,按设计不再连环)、`budget exhausted`(本小时额度用完)。
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` 配到了别的文件,拒绝轮转它)。
125
131
 
126
132
  ## 实现约束
127
133
 
@@ -133,17 +139,20 @@
133
139
  本包零依赖,装进 profile 后也解析不到 `schemastery`(更不能 import `@deepseek-ai/*`)。与其导出一个「在 A 路径像 schema、在 B 路径像垃圾」的对象,不如不导出,把校验放在加载期日志里:错了能看见,也不会让设置页崩。
134
140
  - 所有逻辑包在 try/catch 里:任何异常只写日志,不向上抛,不会拖垮宿主回合。
135
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`)。
136
- - **升级宿主前请先看门槛**:`<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 需要重新适配后再改上界。
137
143
  - `inject = ['systemPrompt']` 是 cordis 的**硬依赖**,不做「可选依赖」改造:本版本 `@deepseek-ai/cordis` 的 `Inject.resolve` 只接受「数组」或「name → config 映射」,没有 `required`/`optional` 语义;写成 `{ optional: [...] }` 会被当成「等一个叫 optional 的服务」,插件将永不激活。
138
144
 
139
145
  ## 已知限制
140
146
 
141
147
  - **会话状态是双索引**:`state` 是 WeakMap(按 session 对象身份,快路径),另有有界(500 条)的 `stateById`(按 `session.id`,兜底)。宿主在某些代码路径上会给出不同的 session 实例;只按对象身份索引时,`turn/start` 与 `turn/end` 会落在两份状态上,`sawTurnStart` 恒为 false、判定链整体静默早退(只留一行 `turn/start not observed`)。现在两个索引同时写,`agent/disposed` 时同时清。
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)`。
142
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` 分支,显示为「请求」卡片),但下游按类型联合写的消费者会漏掉它。
143
- - **用户点停止的窗口**:宿主 `cancel()` 只清 inbox + abort,不 append 任何 session 事件。插件订阅了宿主 dispatch 的 `agent/inbox/discarded` 来撤掉自己那条补写,但该事件是否向插件广播取决于宿主的 dispatch 链路;收不到时,「用户已点停止、插件仍在 `delayMs` 后补写」这个窗口依然存在(默认 900ms,可通过 `delayMs` 调小)。真正发出去的那一回合仍受宿主 abort 语义约束。
144
- - **`sessionScope: 'root'` 的判定依据是宿主的 `Session.header`**:`origin === 'subagent'` 或 `delegationDepth > 0` 才算子代理会话,`orchestrator-*` 这类顶层会话会被正常处理(0.2.0 用 id 前缀判断,把它们全部判成子代理,实测日志里有 19 次静默跳过)。宿主没有 header 的场合(老宿主、测试桩)退回 id 形态:`session-*` / `orchestrator-*` 算根,纯 UUID / `social-*` 不算。被过滤的会话每个 id 只记一条 `skip scope` 日志,不会静默消失。
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 语义约束。
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` 日志,不会静默消失。
145
154
  - **可见字符是「按回合累计」而不是「末条消息」**:一个回合里先写了结论、后来又被工具调用打断的,累计口径下不算空(这是刻意的:用户此前已经看到了文字)。反过来,`aborted` / `interrupted` 等非 `completed` 回合一律不补写,即使它确实没留下正文 —— 那些回合多半是用户自己喊停的,再补一轮会变成「已取消还继续跑」。
146
- - **崩溃遗留**:补写已入队但宿主在认领前崩溃时,该消息可能在下次启动被重放。插件只认 id 匹配的认领(`NOT ours`),因此不会被这种消息骗成「补写回合」,但它仍可能作为一条插件消息出现在转录里。为覆盖「120s 认领超时后才被认领」与「重启后才被认领」两种情形,最近 8 条投递 id 会同时留在内存与 `<budgetFile 同目录>/guard-ids.log` 里(`LATE, from guardIds`),重启后仍能认出它们,不会把补写回合再当成真空回合补第二笔。
155
+ - **崩溃遗留**:补写已入队但宿主在认领前崩溃时,该消息可能在下次启动被重放。插件只认 id 匹配的认领(`NOT ours`),因此不会被这种消息骗成「补写回合」,但它仍可能作为一条插件消息出现在转录里。为覆盖「120s 认领超时后才被认领」与「重启后才被认领」两种情形,最近 8 条投递 id 会同时留在内存与 `<budgetFile 同目录>/guard-ids.log` 里(`LATE, from guardIds`),重启后仍能认出它们,不会把补写回合再当成真空回合补第二笔。**0.2.5 起**该文件超过 1 MiB 会按 TTL 原子压缩(`guard ids compacted: lines=N`)—— 旧版只增不减,而且读侧按字节截断会把 TTL 内的旧记录丢掉,反而让「晚到认领」认不出。
147
156
  - **预算文件是不可信输入**:解析时会丢弃「晚于当前时钟 +5 分钟」的时间戳(否则 `now - t` 为负、该会话被永久判成预算耗尽),并且整份文件只在 1 MiB 以内整读、超过就只读尾部(避免配置写错指向几百 MB 的文件时同步阻塞宿主)。写入用 JSON 行(会话 id 含空格也不会被截断),压缩是「重读磁盘 + 临时文件原子替换」,不会覆盖另一个进程刚追加的行。
148
157
  - **多进程(桌面宿主 + 独立的 web/headless 宿主)**:各自一套内存态,`agent.inbox.nextTurn` 只对本进程可见。预算文件与补写 id 文件是共享的,因此限流大致成立,但「同一会话同时被两个宿主打开」仍可能各补一笔。同一会话请只用一个宿主。
149
158
  - **`dryRun` 不写预算文件**:判定链、日志、限流检查照走,但不投递、不记账,所以它只能证明「判定会发生」,不能用来验证限流是否生效(日志里的 `budgetUsed=n/max` 是当时的已用额度)。
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', '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'];
@@ -260,6 +268,22 @@ function describeConfigIssues(raw) {
260
268
  const bad = raw.foregroundKinds.filter((k) => typeof k !== 'string' || k.trim() === '').length;
261
269
  if (bad > 0) issues.push(bad + ' non-string/empty entry in "foregroundKinds" -> dropped');
262
270
  }
271
+ if (raw.nudgeSources !== undefined && !Array.isArray(raw.nudgeSources)) {
272
+ issues.push('"nudgeSources" expects an array, got ' + brief(raw.nudgeSources) + ' -> ignored');
273
+ } else if (Array.isArray(raw.nudgeSources)) {
274
+ const bad = raw.nudgeSources.filter((k) => typeof k !== 'string' || k.trim() === '').length;
275
+ if (bad > 0) issues.push(bad + ' non-string/empty entry in "nudgeSources" -> dropped');
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
+ }
284
+ if (raw.headerlessScope !== undefined && raw.headerlessScope !== 'out' && raw.headerlessScope !== 'root') {
285
+ issues.push('"headerlessScope" expects "out" | "root", got ' + brief(raw.headerlessScope) + ' -> "out"');
286
+ }
263
287
  if (raw.logTurns !== undefined && raw.logTurns !== 'empty' && raw.logTurns !== 'all') {
264
288
  issues.push('"logTurns" expects "empty" | "all", got ' + brief(raw.logTurns) + ' -> "empty"');
265
289
  }
@@ -332,6 +356,40 @@ export function apply(ctx, config) {
332
356
  const extra = list.filter((k) => typeof k === 'string' && k.trim() !== '').map((k) => k.trim());
333
357
  return new Set([...DEFAULT_FOREGROUND_KINDS, ...extra]);
334
358
  })(),
359
+ /**
360
+ * 允许被补写的「回合发起来源」(0.2.5)。
361
+ *
362
+ * 决定的是「这个会话里该不该由插件插一句话」,与 foregroundKinds 的
363
+ * 「谁的消息能取消待发补写」是两件事:
364
+ * - 社交桥(source.kind='wechat-social-bridge', form='relay')把真人微信
365
+ * 消息送进会话,桥接器又会把模型输出发回给联系人。它不在前台白名单里,
366
+ * 于是 0.2.4 既不会被它取消补写、也不会因此排除该会话 —— 一旦那里出现
367
+ * completed 空回合,插件就会往一段真人对话里塞"请直接输出纯文本",
368
+ * 模型输出经桥接成为对外发文。这是未授权的外部副作用,必须默认关掉。
369
+ * - 显式列在这个白名单里的来源才允许补写;空数组 = 沿用 foregroundKinds。
370
+ * 想给桥接对话也保留保护的用户,把 kind 显式写进来即可(自担对外发言的语义)。
371
+ */
372
+ nudgeSources: (() => {
373
+ const list = Array.isArray(raw.nudgeSources) ? raw.nudgeSources : [];
374
+ const extra = list.filter((k) => typeof k === 'string' && k.trim() !== '').map((k) => k.trim());
375
+ return extra.length > 0 ? new Set(extra) : null;
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
+ })(),
386
+ /**
387
+ * 会话 header 缺失时怎么判定(0.2.5)。宿主的 SessionHeader 是根/子代理的
388
+ * 唯一可信判据;拿不到时旧版按 id 形态猜(session-* 当根会话),于是
389
+ * 「无 header 且 id 形如 session-* 的子代理」会被补写,破坏委派语义。
390
+ * 默认 'out':无法判定就不碰;确有老宿主需要时显式设成 'root'。
391
+ */
392
+ headerlessScope: raw.headerlessScope === 'root' ? 'root' : 'out',
335
393
  // 补写预算的持久化文件(跨重启限流);默认 $DSH_HOME/state/turn-recovery/nudges.log。
336
394
  budgetFile: typeof raw.budgetFile === 'string' ? raw.budgetFile.trim() : '',
337
395
  // 干跑:判定、限流、日志全部照常走,唯独不真的把补写投进队列。
@@ -339,6 +397,11 @@ export function apply(ctx, config) {
339
397
  dryRun: enabledOr(raw.dryRun, false),
340
398
  };
341
399
 
400
+ // 来源闸门的放行集合(0.2.6):nudgeSources 优先,否则沿用前台白名单。
401
+ // 这个集合只算一次,user/message 记录来源与 turn/end 判定闸门必须用同一个,
402
+ // 否则「记的时候用一个集合、判的时候用另一个」会造出静默失效(0.2.5 就是这样)。
403
+ cfg.allowedOrigins = cfg.nudgeSources || cfg.foregroundKinds;
404
+
342
405
  // 宿主不校验插件配置(本包不导出 Config schema,见 README「为什么没有 Config」),
343
406
  // 于是写错键名/类型只会在运行期被静默兜底成默认值 —— 用户以为改生效了,其实没有。
344
407
  // 这里在加载期把可疑配置显式挑出来写进日志,成本是几行字符串拼接。
@@ -348,7 +411,12 @@ export function apply(ctx, config) {
348
411
  const dshHome = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
349
412
  ? process.env.DSH_HOME.trim()
350
413
  : path.join(os.homedir(), '.dsh');
351
- const logPath = cfg.logFile !== '' ? cfg.logFile : path.join(dshHome, 'logs', 'turn-recovery.log');
414
+ // 0.2.5:自定义路径必须像日志文件(.log 结尾)。否则一次配置手滑就可能让
415
+ // 轮转/压缩逻辑去 rename 或 truncate 一个无关文件 —— 那是不可逆的破坏。
416
+ const customLogRejected = cfg.logFile !== '' && !/\.log$/i.test(cfg.logFile);
417
+ const logPath = (cfg.logFile !== '' && !customLogRejected)
418
+ ? cfg.logFile
419
+ : path.join(dshHome, 'logs', 'turn-recovery.log');
352
420
  const fallbackLogPath = path.join(os.tmpdir(), 'turn-recovery.log');
353
421
  const startedAt = Date.now();
354
422
  let lastRotateCheck = 0;
@@ -359,9 +427,33 @@ export function apply(ctx, config) {
359
427
  * 先试 rename,失败就退回 truncate(0) —— 宁可丢历史,也不能让日志无限长。
360
428
  */
361
429
  let rotateWarned = false;
430
+ let foreignLogWarned = false;
431
+ /**
432
+ * 文件头是否像本插件写的日志(首字节 '[',即 ISO 时间戳的开头)。
433
+ * 轮转/截断前必须过这一关:`logFile` 配错时,旧版会直接 rename 或 truncate
434
+ * 用户的任意文件;现在宁可让日志继续长,也不动一个不认识的文件。
435
+ */
436
+ const looksLikeOurLog = (file) => {
437
+ try {
438
+ const fd = fs.openSync(file, 'r');
439
+ try {
440
+ const buf = Buffer.alloc(1);
441
+ const read = fs.readSync(fd, buf, 0, 1, 0);
442
+ if (read === 0) return true; // 空文件:轮转/截断都无害
443
+ return buf[0] === 0x5B; // '['
444
+ } finally { fs.closeSync(fd); }
445
+ } catch { return false; }
446
+ };
362
447
  const rotateIfNeeded = (file) => {
363
448
  try {
364
449
  if (fs.statSync(file).size <= LOG_MAX_BYTES) return;
450
+ if (!looksLikeOurLog(file)) {
451
+ if (!foreignLogWarned) {
452
+ foreignLogWarned = true;
453
+ log('log rotate skipped: file does not look like a turn-recovery log (' + file + ')');
454
+ }
455
+ return;
456
+ }
365
457
  const backup = file + '.1';
366
458
  try {
367
459
  if (fs.existsSync(backup)) fs.unlinkSync(backup);
@@ -397,7 +489,9 @@ export function apply(ctx, config) {
397
489
  const log = (line) => {
398
490
  // 日志行内不允许出现换行:会话 id、错误文本都可能带 \n,拼进去就能在日志里
399
491
  // 伪造出一行看似插件自己写的记录(伪造 turn/end 或 guard armed 会误导排障)。
400
- const safe = String(line).replace(/[\r\n]+/g, ' ');
492
+ // 0.2.5 起连 U+2028/U+2029(部分查看器当换行)与 ESC(终端着色)一起压平:
493
+ // 会话 id 由宿主/桥接插件产生,现网已出现 social-*、bili-whale 这类非 UUID 形态。
494
+ const safe = String(line).replace(/[\r\n\u2028\u2029\x1b]+/g, ' ');
401
495
  const record = '[' + new Date().toISOString() + '] ' + safe + '\n';
402
496
  maybeRotate();
403
497
  try {
@@ -431,9 +525,12 @@ export function apply(ctx, config) {
431
525
  * 也不能让限流文件的 IO 问题拖垮宿主回合)。多进程并发追加时可能丢几行,
432
526
  * 这是可接受的——它只是预算,不是账本。
433
527
  */
434
- const budgetPath = cfg.budgetFile !== ''
528
+ const customBudgetRejected = cfg.budgetFile !== '' && !/\.log$/i.test(cfg.budgetFile);
529
+ const budgetPath = (cfg.budgetFile !== '' && !customBudgetRejected)
435
530
  ? cfg.budgetFile
436
531
  : path.join(dshHome, 'state', 'turn-recovery', 'nudges.log');
532
+ if (customLogRejected) configIssues.push('"logFile" must end with .log -> default used');
533
+ if (customBudgetRejected) configIssues.push('"budgetFile" must end with .log -> default used');
437
534
  const BUDGET_MAX_BYTES = 1024 * 1024;
438
535
  /** 已投递补写 id 的持久化文件(跨重启识别晚到认领用),与预算文件同目录。 */
439
536
  const guardPath = path.join(path.dirname(budgetPath), 'guard-ids.log');
@@ -548,6 +645,64 @@ export function apply(ctx, config) {
548
645
  return list.slice(-GUARD_IDS_MAX);
549
646
  };
550
647
 
648
+ /**
649
+ * guard-ids 文件压缩(0.2.5)。这个文件此前是唯一「只增不减」的持久文件:
650
+ * 每投递一笔写一行,永不轮转;读侧只取尾部 1 MiB,于是超过 1 MiB 后 TTL 内
651
+ * 的旧记录会被按字节截断丢掉 —— 重启后的晚到认领认不出,反而触发连环补写
652
+ * (正是这个文件想解决的场景)。现在与预算文件同法:重读磁盘 → 只留 TTL 内
653
+ * 且每会话至多 GUARD_IDS_MAX 条 → 原子替换。
654
+ * 若文件内容一条都解析不出来(被别的程序写成了别的东西),拒绝重写。
655
+ */
656
+ let guardCompactWarned = false;
657
+ const compactGuardIds = (now) => {
658
+ try {
659
+ const rawText = readTail(guardPath, BUDGET_MAX_BYTES);
660
+ const floor = now - GUARD_ID_TTL_MS;
661
+ const keep = new Map(); // sid -> [{ id, at }]
662
+ let parsed = 0;
663
+ for (const line of rawText.split('\n')) {
664
+ const text = line.trim();
665
+ if (text === '') continue;
666
+ let rec;
667
+ try { rec = JSON.parse(text); } catch { continue; }
668
+ if (!rec || typeof rec.id !== 'string' || typeof rec.sid !== 'string') continue;
669
+ const at = Number(rec.at);
670
+ if (!Number.isFinite(at) || at < floor || at > now + BUDGET_FUTURE_SKEW_MS) continue;
671
+ parsed += 1;
672
+ const list = keep.get(rec.sid) || [];
673
+ const seen = list.find((x) => x.id === rec.id);
674
+ if (seen) seen.at = Math.max(seen.at, at);
675
+ else list.push({ id: rec.id, at });
676
+ keep.set(rec.sid, list);
677
+ }
678
+ if (parsed === 0 && rawText.trim() !== '') {
679
+ if (!guardCompactWarned) {
680
+ guardCompactWarned = true;
681
+ log('guard ids compact skipped: file does not look like turn-recovery state ('
682
+ + guardPath + ')');
683
+ }
684
+ return;
685
+ }
686
+ const lines = [];
687
+ for (const [sid, entries] of keep) {
688
+ for (const entry of entries.slice(-GUARD_IDS_MAX)) {
689
+ lines.push(JSON.stringify({ sid, id: entry.id, at: entry.at }));
690
+ }
691
+ }
692
+ const tmp = guardPath + '.tmp-' + process.pid;
693
+ fs.writeFileSync(tmp, lines.length > 0 ? lines.join('\n') + '\n' : '');
694
+ try {
695
+ fs.renameSync(tmp, guardPath);
696
+ } catch (err) {
697
+ try { fs.unlinkSync(tmp); } catch { /* ignore */ }
698
+ throw err;
699
+ }
700
+ log('guard ids compacted: lines=' + lines.length);
701
+ } catch (err) {
702
+ log('guard ids compact FAILED: ' + errText(err));
703
+ }
704
+ };
705
+
551
706
  const recordGuardId = (session, id, at) => {
552
707
  const sid = session && typeof session.id === 'string' ? session.id : '';
553
708
  if (sid === '' || typeof id !== 'string' || id === '') return;
@@ -558,6 +713,9 @@ export function apply(ctx, config) {
558
713
  while (list.length > GUARD_IDS_MAX) list.shift();
559
714
  fs.mkdirSync(path.dirname(guardPath), { recursive: true });
560
715
  fs.appendFileSync(guardPath, JSON.stringify({ sid, id, at }) + '\n');
716
+ let size = 0;
717
+ try { size = fs.statSync(guardPath).size; } catch { /* 首次写入 */ }
718
+ if (size > BUDGET_MAX_BYTES) compactGuardIds(Date.now());
561
719
  } catch { /* 只服务于去重,写失败不影响补写本身 */ }
562
720
  };
563
721
 
@@ -590,17 +748,31 @@ export function apply(ctx, config) {
590
748
  * 旧版直接用内存 map 覆盖写整份文件 —— 多进程(桌面宿主 + 独立 web/headless)
591
749
  * 同时运行时,会把对方刚追加的行整段抹掉,预算就少算、于是多补。
592
750
  */
751
+ let budgetCompactWarned = false;
593
752
  const compactBudget = (now) => {
753
+ let parsed = 0;
754
+ let rawText = '';
594
755
  try {
595
- const rawText = readTail(budgetPath, BUDGET_MAX_BYTES);
756
+ rawText = readTail(budgetPath, BUDGET_MAX_BYTES);
596
757
  for (const line of rawText.split('\n')) {
597
758
  const rec = parseBudgetLine(line, now);
598
759
  if (!rec) continue;
760
+ parsed += 1;
599
761
  const list = budgetHistory.get(rec.id);
600
762
  if (!list) budgetHistory.set(rec.id, [rec.at]);
601
763
  else if (list.indexOf(rec.at) < 0) list.push(rec.at);
602
764
  }
603
765
  } catch { /* 读不到就只用内存态 */ }
766
+ // 文件里有内容却一条都解析不出来:这不是我们的预算文件(配置指错了,或被
767
+ // 别的程序写成了别的东西)。重写它会毁掉别人的数据,宁可不压缩、只留一行。
768
+ if (parsed === 0 && rawText.trim() !== '') {
769
+ if (!budgetCompactWarned) {
770
+ budgetCompactWarned = true;
771
+ log('budget compact skipped: file does not look like turn-recovery state ('
772
+ + budgetPath + ')');
773
+ }
774
+ return;
775
+ }
604
776
  pruneBudgetHistory(now);
605
777
  const lines = [];
606
778
  for (const [sid, times] of budgetHistory) {
@@ -639,14 +811,18 @@ export function apply(ctx, config) {
639
811
  * 日志里的会话短标签。旧版直接 id.slice(0,8),而根会话 id 形如
640
812
  * session-<uuid>,前 8 位恒为 "session-",多会话根本分不开。
641
813
  * 现在先剥掉 session- / orchestrator- / worker- 前缀再取前 8 位;剥完为空则退回原 id 前 8 位。
814
+ * 只拿到 id(没有 session 对象)的路径——例如宿主只发 { message } 的
815
+ * agent/inbox/discarded——直接复用 shortId。
642
816
  */
817
+ const shortId = (rawId) => {
818
+ const id = typeof rawId === 'string' ? rawId : '';
819
+ if (id === '') return 'no-session';
820
+ const stripped = id.replace(/^(session-|orchestrator-|worker-)/, '');
821
+ return stripped ? stripped.slice(0, 8) : id.slice(0, 8);
822
+ };
823
+
643
824
  const tag = (session) => {
644
- try {
645
- const id = session && typeof session.id === 'string' ? session.id : '';
646
- if (!id) return 'no-session';
647
- const stripped = id.replace(/^(session-|orchestrator-|worker-)/, '');
648
- return stripped ? stripped.slice(0, 8) : id.slice(0, 8);
649
- } catch { return 'no-session'; }
825
+ try { return shortId(session && session.id); } catch { return 'no-session'; }
650
826
  };
651
827
 
652
828
  /**
@@ -668,6 +844,8 @@ export function apply(ctx, config) {
668
844
  */
669
845
  const scopeSeen = new Set();
670
846
  let scopeSeenCapped = false;
847
+ /** headerlessScope='root' 时,每个「靠 id 形态放行」的会话只记一条日志。 */
848
+ const headerlessSeen = new Set();
671
849
 
672
850
  /** header 判定根会话;返回 null 表示无法判定(没有 header)。 */
673
851
  const rootByHeader = (session) => {
@@ -702,15 +880,34 @@ export function apply(ctx, config) {
702
880
  return false;
703
881
  }
704
882
  const id = session && typeof session.id === 'string' ? session.id : '';
705
- if (/^(session-|orchestrator-)/.test(id)) return true;
706
- if (id === '') return false;
707
- logScopeSkip(session, 'id is not a root shape and no header');
883
+ // 0.2.5:拿不到 header 时默认**不碰**(fail-closed)。旧版按 id 形态把
884
+ // session-* / orchestrator-* 当根会话,于是一个「无 header 且 id 形如
885
+ // session-*」的子代理会话也会被补写 —— 往子代理里塞"不要调用工具"会破坏
886
+ // 委派语义。确有老宿主需要兼容时,显式设 headerlessScope: 'root'。
887
+ if (cfg.headerlessScope === 'root' && /^(session-|orchestrator-)/.test(id)) {
888
+ if (!headerlessSeen.has(id) && headerlessSeen.size < SCOPE_SEEN_MAX) {
889
+ headerlessSeen.add(id);
890
+ log('scope fallback: no header, id shape=root -> in scope (headerlessScope=root) ['
891
+ + shortId(id) + ']');
892
+ }
893
+ return true;
894
+ }
895
+ logScopeSkip(session, id === '' ? 'no header and no id' : 'no header (headerlessScope=out)');
708
896
  return false;
709
897
  };
710
898
 
711
899
  log('plugin activated: pid=' + process.pid + ' version=' + VERSION
712
900
  + ' enabled=' + cfg.enabled + ' instruction=' + cfg.injectInstruction
713
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 { /* 自检失败不影响主流程 */ }
714
911
  if (configIssues.length > 0) {
715
912
  for (const issue of configIssues) log('config issue: ' + issue);
716
913
  log('config issues: ' + configIssues.length + ' (defaults used where noted)');
@@ -815,6 +1012,34 @@ export function apply(ctx, config) {
815
1012
  * 拿去补写会对着「本来有正文的回合」发假提醒。
816
1013
  */
817
1014
  sawTurnStart: false,
1015
+ /**
1016
+ * 本回合最近一条非补写 user/message 的来源 kind('' = 本回合没见到)。
1017
+ * 0.2.5 的来源闸门用它:只有来源在 nudgeSources(默认前台白名单)里
1018
+ * 才允许补写,避免往社交桥这类「模型输出会被转发给第三方」的会话里插话。
1019
+ */
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,
818
1043
  /**
819
1044
  * 本回合已经「处理过」:要么排上了补写,要么在排补写前就被限流/去重挡下。
820
1045
  * 只在 turn/end 的判定走完之后置位 —— 旧版在调用 scheduleNudge 之前就置位,
@@ -854,15 +1079,21 @@ export function apply(ctx, config) {
854
1079
  // 两个索引都写:对象身份是最快路径,id 是宿主换 session 实例时的兜底。
855
1080
  if (session && typeof session === 'object') state.set(session, s);
856
1081
  const sid = session && typeof session.id === 'string' ? session.id : '';
857
- if (sid !== '' && stateById.get(sid) !== s) {
858
- if (stateById.size >= STATE_BY_ID_MAX && !stateById.has(sid)) {
859
- // Map 保序,第一个就是最早插入的一条。淘汰是安全的:该会话的下一次
860
- // 事件会拿到一个新状态(字段齐全,只是当回合累计值从零开始),
861
- // 而状态本身是缓存,淘汰不等同于丢判定能力。
862
- const oldest = stateById.keys().next();
863
- if (!oldest.done) stateById.delete(oldest.value);
1082
+ if (sid !== '') {
1083
+ const existing = stateById.get(sid);
1084
+ if (existing === s) {
1085
+ // LRU:命中即刷新插入顺序(Map 保序)。旧版按纯插入序淘汰,长跑 >500 个
1086
+ // 会话时会把仍在活跃的会话踢掉,它的 sawTurnStart 随即复位 → 静默漏补。
1087
+ stateById.delete(sid);
1088
+ stateById.set(sid, s);
1089
+ } else {
1090
+ if (stateById.size >= STATE_BY_ID_MAX && !stateById.has(sid)) {
1091
+ // Map 保序,第一个就是最久未使用的(见上面的命中刷新)。
1092
+ const oldest = stateById.keys().next();
1093
+ if (!oldest.done) stateById.delete(oldest.value);
1094
+ }
1095
+ stateById.set(sid, s);
864
1096
  }
865
- stateById.set(sid, s);
866
1097
  }
867
1098
  return s;
868
1099
  };
@@ -895,6 +1126,10 @@ export function apply(ctx, config) {
895
1126
  s.visibleNonText = false;
896
1127
  s.sawTurnStart = false;
897
1128
  s.nudgeHandledTurn = null;
1129
+ // 注意:**不要**在这里清 turnOrigin / sawUserMessage。
1130
+ // 宿主在部分路径上会先 append user/message、再 append turn/start(测试里也有这种
1131
+ // 顺序),在这里清空会把本回合的来源抹掉,于是一个人类回合被判成「来源未知」→
1132
+ // fail-closed 拒补。来源标记的复位由 turn/end 的快照路径负责。
898
1133
  };
899
1134
 
900
1135
  /**
@@ -1003,26 +1238,33 @@ export function apply(ctx, config) {
1003
1238
  }
1004
1239
 
1005
1240
  /**
1006
- * P2-C 缓解:用户点「停止」时宿主 cancel() 只清 inbox + abort,不 append 任何
1007
- * session 事件,插件单靠 session/event 无从得知那条补写已被丢弃,900ms 后照发,
1008
- * 于是「用户已经喊停」还会多跑一轮模型。
1009
- * 宿主(rc.5 bundle 内确认存在)会 dispatch 'agent/inbox/discarded'。这里尽力订阅,
1010
- * 但只在「被丢弃消息的 id 恰好是我们的补写」时才撤定时器:id 不匹配、或宿主根本
1011
- * 不向插件广播该事件时,行为退回旧版(不会误伤别的消息、也不会乱取消)。
1241
+ * P2-C 缓解(0.2.5 修):用户点「停止」时宿主 cancel() 只清 inbox + abort,
1242
+ * 不 append 任何 session 事件,插件单靠 session/event 无从得知那条补写已被丢弃,
1243
+ * delayMs 后照发,于是「用户已经喊停」还会多跑一轮模型。
1244
+ *
1245
+ * 宿主 0.2.2-rc.1 的真实载荷(app.asar 内 dsh-agent-loop/lib/index.js:199):
1246
+ * this.dispatch.emit("agent/inbox/discarded", { message })
1247
+ * —— 只有 message,没有 agent、也没有 session。0.2.4 及更早读的是 payload.agent,
1248
+ * 于是 handler 在真实宿主上永远在第一行 return:定时器不撤、日志不写。
1249
+ * 单测又用自造的 { agent, message } 形状喂它,所以 65 例全绿也覆盖不到这个失效。
1250
+ * 现在按 message.id 反查持有该在途 id 的会话状态,不再依赖载荷里的 agent/session。
1012
1251
  */
1013
1252
  const onInboxDiscarded = (payload) => {
1014
1253
  try {
1015
- const agent = (payload && payload.agent) || payload;
1016
- const session = agent && agent.session;
1017
- if (!session) return;
1018
- const s = stateLookup(session);
1019
- if (!s) return;
1020
- const message = payload && payload.message;
1254
+ const message = payload && payload.message ? payload.message : payload;
1021
1255
  const id = message && typeof message.id === 'string' ? message.id : '';
1022
- if (id === '' || s.nudgeMessageId === null || id !== s.nudgeMessageId) return;
1023
- clearTimer(s, 'nudge discarded by host');
1024
- clearNudgeInFlight(s, 'discarded by host');
1025
- log('nudge DISCARDED by host before claim [' + tag(session) + ']');
1256
+ if (id === '') return;
1257
+ let hit = 0;
1258
+ for (const [sid, s] of stateById) {
1259
+ if (s.nudgeMessageId !== id) continue;
1260
+ clearTimer(s, 'nudge discarded by host');
1261
+ clearNudgeInFlight(s, 'discarded by host');
1262
+ log('nudge DISCARDED by host before claim [' + shortId(sid) + ']');
1263
+ hit += 1;
1264
+ }
1265
+ if (hit === 0) {
1266
+ log('inbox discarded: message ' + id.slice(0, 8) + ' is not an in-flight nudge -> ignored');
1267
+ }
1026
1268
  } catch (err) {
1027
1269
  log('agent/inbox/discarded handler error: ' + errText(err));
1028
1270
  }
@@ -1263,6 +1505,7 @@ export function apply(ctx, config) {
1263
1505
  if (s.timer && !s.awaitingGuardTurn) clearTimer(s, 'new turn started');
1264
1506
  resetTurnCounters(s, data.turn);
1265
1507
  s.sawTurnStart = true;
1508
+ s.everSawTurnStart = true;
1266
1509
  return;
1267
1510
  }
1268
1511
 
@@ -1277,19 +1520,21 @@ export function apply(ctx, config) {
1277
1520
  // 仍可能认领那笔「已超时」的消息 —— 它进转录后开出的照样是补写回合。
1278
1521
  // 不认它,下一个空回合就会被当成真空回合再补一笔(连环补写)。
1279
1522
  const late = !matched && typeof data.id === 'string' && s.guardIds.indexOf(data.id) >= 0;
1280
- // 非「晚到」的情形都清在途标记(与旧版一致):对上 id 说明它已被消费;
1281
- // 对不上说明队列已经往前走了(上一进程遗留/重放),这笔补写也不会再被认领。
1282
- // 只有晚到认领例外 —— 它可能是旧 id,不该顺手抹掉新一笔的在途状态。
1283
- if (!late) clearNudgeInFlight(s, null);
1284
- // 关键:只有真的对上 id 才算「补写回合」。
1523
+ // 关键:只有真的对上 id(或晚到认领)才算「补写回合」。
1285
1524
  // 旧版把 matched 只写进日志、然后无条件置 awaitingGuardTurn,于是任何
1286
1525
  // 同来源但 id 不符的消息(上次进程遗留、重放、另一实例写入)都会被误认,
1287
1526
  // 导致下一个真正的空回合被判成「连环补写」而静默跳过。
1288
1527
  if (!matched && !late) {
1528
+ // 0.2.5 修:这里**不再清在途标记**。旧版在早退之前无条件 clear,
1529
+ // 于是同进程任一插件只要 append 一条 source.kind=plugin:turn-recovery
1530
+ // 且 id 随意的消息,就能把在途标记抹掉 → 下一个空回合多补一笔
1531
+ // (多一轮模型唤醒与费用)。真正「队列已前进」由 120s 认领超时兜底。
1289
1532
  log('guard-kind message seen but NOT ours (id mismatch/expired) -> ignored ['
1290
1533
  + tag(session) + ']');
1291
1534
  return;
1292
1535
  }
1536
+ // 只有对上 id 时它才算被消费;晚到认领是旧 id,不该抹掉新一笔的在途状态。
1537
+ if (matched) clearNudgeInFlight(s, null);
1293
1538
  s.awaitingGuardTurn = true;
1294
1539
  stats.claimed += 1;
1295
1540
  log('guard message claimed' + (late ? ' (LATE, from guardIds)' : '')
@@ -1305,6 +1550,21 @@ export function apply(ctx, config) {
1305
1550
  // 0.2.0 用「后台黑名单 + 未知即前台」,宿主每加一个通知来源就可能让补写
1306
1551
  // 被静默取消;白名单把默认值改成「不认识就当背景」,漏取消由 turn/start 兜底。
1307
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;
1559
+ // 记下本回合是谁发起的:turn/end 的来源闸门读它(见 nudgeSources)。
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
+ }
1308
1568
  // form:'notice' 是宿主的通知形态(tool-jobs / action-cards / plan-mode /
1309
1569
  // tool-goal 都用它):无论 kind 叫什么,它都不是人在说话。
1310
1570
  if (form !== 'notice' && cfg.foregroundKinds.has(kind)) {
@@ -1316,7 +1576,14 @@ export function apply(ctx, config) {
1316
1576
  if (event.type === 'assistant/message') {
1317
1577
  const message = data.message;
1318
1578
  if (!message || message.role !== 'assistant') return;
1319
- if (typeof data.turn === 'number' && s.turn !== data.turn) resetTurnCounters(s, data.turn);
1579
+ if (typeof data.turn === 'number' && s.turn !== data.turn) {
1580
+ // 0.2.5:turn/start 见到过、但它的 turn 不是数值时,s.turn 仍是 null;
1581
+ // 这里若走 resetTurnCounters 会把 sawTurnStart 一并清成 false,于是整个
1582
+ // 回合被误判成「中途加载」而跳过判定(日志与热加载同形,排障会跑偏)。
1583
+ // 只补记 turn 号、保留 sawTurnStart。
1584
+ if (s.sawTurnStart && s.turn === null) s.turn = data.turn;
1585
+ else resetTurnCounters(s, data.turn);
1586
+ }
1320
1587
  const seen = inspectMessage(message);
1321
1588
  s.assistantMessages += 1;
1322
1589
  s.visibleChars += seen.chars;
@@ -1348,8 +1615,37 @@ export function apply(ctx, config) {
1348
1615
  return;
1349
1616
  }
1350
1617
 
1351
- const sawStart = s.sawTurnStart === true;
1352
- const empty = !s.visibleNonText && s.visibleChars < cfg.minVisibleChars;
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;
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;
1353
1649
  stats.turns += 1;
1354
1650
  if (empty) {
1355
1651
  if (kind === 'completed') stats.emptyCompleted += 1;
@@ -1358,21 +1654,47 @@ export function apply(ctx, config) {
1358
1654
  // 正常回合默认不写行:实测这类行占日志的一半,而它只在「某回合为什么
1359
1655
  // 没被判空」时才有人看。排查期把 logTurns 切成 'all' 即可恢复逐回合记录。
1360
1656
  if (empty || cfg.logTurns === 'all') {
1361
- log('turn/end turn=' + turn + ' kind=' + kind + ' steps=' + s.assistantMessages
1362
- + ' 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
1363
1659
  + ' sawStart=' + sawStart + ' [' + tag(session) + ']');
1364
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
+ }
1365
1668
  if (stats.turns % STATS_EVERY_TURNS === 0) log(statsLine(null));
1366
1669
 
1367
1670
  if (!sawStart) {
1368
- // 插件在回合中途才加载/热重载:看不到 turn/start,也就漏掉了该回合早前的
1369
- // 助手消息,visibleChars 少算是必然的,不能据此判定空回合。
1370
- log('skip judgement: turn/start not observed for turn ' + turn + ' (plugin loaded mid-turn?) [' + tag(session) + ']');
1371
- 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) + ']');
1372
1683
  }
1373
1684
  if (!empty) return;
1374
- if (kind !== 'completed') {
1375
- 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');
1687
+ return;
1688
+ }
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) + ']');
1376
1698
  return;
1377
1699
  }
1378
1700
  if (wasGuard) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-turn-recovery",
3
- "version": "0.2.4",
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",