dsh-crossterm 0.9.12 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,127 @@
1
+ ## 1.0.0 — 2026-10-20
2
+
3
+ ### 1.0.0 概览:自动重启组件从 0.3.30 走到 0.3.40
4
+
5
+ - 十次连续加固,全部来自真实事故与本机复现:wmic 在新系统上失效(排程 `exit 5`);安全软件拦 `mshta` 导致倒计时窗口不落地;两个 helper 同时杀进程;锁判活被系统休眠骗过;node 运行时隐式依赖旧家目录;`launch` / `exit code` 没有留痕;心跳「新鲜但瞎」(16 个 agent 全写 `inTurn:false`)把 fail-closed 变回 fail-open;市场残影与「权威的零」混判;快照降级误伤真在跑的会话。
6
+ - 关键接口事实(绑定本机宿主版本):`session.snapshotEvents(fromSeq = 0, toSeqExclusive = this.seq)` 返回 **0 → 当前 seq 的完整已提交事件**,不是有界窗口。据此把市场残影的降级白名单收回 `{event, snapshot}`;哪天宿主改成有界窗口,必须收回成只认 `event` —— 这条失效条件写死在 `busy-check.mjs:172-175` 的注释里,不只留在文档。
7
+ - 生效范围:`trigger-restart.mjs` / `dsh-restart-helper.mjs` / `busy-check.mjs` 是每次排程现拉的新进程,改完即生效;`components/restart/core.js` 与 `lib/*.js` 由宿主加载,**要重启 DSH 才生效**(本版发布时宿主已重启一次,`producer=event`、`eventsSeen` 递增已现场核过)。
8
+ - 组件版本:自动重启 `0.3.30` → `0.3.40`;入口 0.9.3 / 跨会话 0.5.3 / 跨工作区 0.1.0 / 跨设备 0.4.0 不动。
9
+ - 新增文档:`docs/restart-log-reading.md`(怎么从日志读一次重启的起法与结局)、`docs/huorong-whitelist.md`(安全软件加白的边界)、`docs/selfcheck-home-replaced-pseudocode.md`(家目录被空壳顶替的自检伪代码,含编码完整性检查与「文件坏了 ≠ 读的人用错编码」)。
10
+
11
+
12
+ ### 决策:残影降级收回 snapshot;stuck 名单单独记名(0.3.40)
13
+
14
+ - 背景:0.3.39 把降级判据收紧成只认 `src === 'event'`,复核指出这会放回一个旧形状——市场里崩溃前残留的 `running-agent`,心跳里对应会话是 `{inTurn:false, src:'snapshot'}` → 不降级、一直算「有人忙」(exit 3),而 idle 会话的 `src` 永远是 snapshot、不会自己转成 event 来解锁,于是又变成只能靠 force 走。这与 0.3.38 修的「静默自锁」是同一族,只是从 exit 4 换成 exit 3。
15
+ - 查实:宿主 `snapshotEvents(fromSeq = 0, toSeqExclusive = this.seq)` 返回**0 → 当前 seq 的完整已提交事件**,不是有界窗口。所以 snapshot 路径扫到 `turn/end` 是正向观测(最后一条回合标记是 end),置信度与 event 同级。
16
+ - 决策(采纳复核建议「要自动清理」):降级白名单改为 `{event, snapshot}`;`seed` 只会是 true,不在白名单。现在心跳是 0.3.39 的 `{event:2, snapshot:14}` 分布,市场残影可以自愈、不必 force。
17
+ - 已知限制(写进 STATE):这条依赖「宿主快照是完整事件流」;哪天宿主改成有界窗口,必须收回成只认 `event`,否则会把「标记被挤出窗口」误读成空闲。
18
+ - stuck 名单单独记名:helper 在 `stuckCandidates` 非空时多打一行 `stuck candidates: N (id, …)`,方便下次 grep 到「疑似粘住」而不是当成新 bug 重查(结果 JSON 里的 `stuckCandidates` 字段不变)。
19
+ - 验证:`test-src.mjs` 扩成四情形 —— snapshot 与 event 都降级进 `staleMarket`;`seed` 与「旧形状(无 src)」保持 busy、不降级;四例 `unknown` 全 false。`test-authoritative.mjs` 五情形复跑 PASS;`node --check` 通过。
20
+ - 组件版本:自动重启 `0.3.39` → `0.3.40`。
21
+
22
+ ### 加固:市场残影降级只认实时事件;CLI 退出码区分 busy / unknown(0.3.39)
23
+
24
+ - 降级判据收紧(采纳复核):`heartbeatBusy()` 原来把每条心跳压成布尔,`src`(`event` / `snapshot` / `seed`)被丢掉,于是「这条 false 是实时事件给的,还是快照时点给的」无从分辨。现在 `states` 存 `{ inTurn, src }`,市场残影降级只在 `src === 'event'` 时发生;`snapshot` / `seed` 的 false 一律不降级(保守:宁可多等一次,也不把可能还在跑的会话当成空闲)。今天 `snapshotEvents()` 返回完整已提交事件,所以这条不是 bug 修复而是防将来「快照被改成有界窗口」时静默失灵。
25
+ - CLI 退出码:`busy-check.mjs` 直接执行时原来 busy 与 unknown 都退 3,只看退出码的调用方(如倒计时页)会把「真有人忙」与「读不到权威信号」混成一类。现在 `busy` = 3、`unknown` = 4,与 helper 内部 `waitQuiet()` 的分法一致(helper 仍按 JSON 的 `unknown` 字段判断,不依赖退出码)。
26
+ - 验证:新增 `test-src.mjs` 三情形 —— 心跳 `src:'snapshot'` 与 `src:'seed'` 时市场条目仍留在 busy、`staleMarket` 为空;`src:'event'` 时正常降级进 `staleMarket`;三例 `unknown` 均为 false。原 `test-authoritative.mjs` 五情形(A/B/C/D/E)复跑仍全部 PASS;`node --check` 通过。
27
+ - 组件版本:自动重启 `0.3.38` → `0.3.39`。
28
+
29
+ ### 修复:重启链路在 wmic 不可用的机器上失效(改为 PowerShell CIM 回退)
30
+
31
+ - 症状:`term_restart` / `dsh_restart` 排程后 `trigger-restart.mjs` 以 `exit 5` 结束,日志只有 `mshta create failed` / `wmic create failed: Command failed: wmic process call create ...`;本机 `C:\WINDOWS\System32\Wbem\WMIC.exe` 存在但调用返回非 0,于是「倒计时窗口 + helper」都拉不起来,重启彻底不可用。
32
+ - 根因:`components/restart/scripts/trigger-restart.mjs` 只用 `wmic process call create` 创建脱离 DSH 进程树的进程;较新的 Windows 上 WMIC 已被弃用/禁用,没有回退路径。
33
+ - 修法:`wmiCreate()` 先试 wmic,失败(非 0 或没有 ProcessId)时回退到 PowerShell CIM:`Invoke-CimMethod -ClassName Win32_Process -MethodName Create`;CIM 拉起的进程归 `WmiPrvSE`,同样不在 DSH 进程树里,宿主被杀后不会被连带清理。回退生效时日志打印 `wmic 不可用(…),改用 PowerShell CIM 创建进程`。
34
+ - 验证:CIM 创建独立实测通过(`cmd /c echo CIM-OK > %TEMP%\cim-spawn-test.txt` 成功落盘);`trigger-restart.mjs --delay 20 --dry-run` 全链路走通,日志 `WMI create ok; mshta pid=40084`,helper 收到 request 后在 `scheduledAt` 输出 DRY-RUN result(8 个 app 进程、端口 19387 open),未杀进程。
35
+ - 组件版本:自动重启 `0.3.29` → `0.3.31`。
36
+
37
+ ### 加固:无界面兜底 + helper 单实例锁(安全软件拦 mshta 时也能重启)
38
+
39
+ - 症状:CIM 修好后仍有一次「排程成功但到点没动作」——WMI create 返回了 ProcessId,但安全软件(火绒)把 `mshta.exe` 拦下/延迟放行,进程瞬间消失,倒计时窗口不存在、helper 也永远不会被拉起。
40
+ - 修法一(无界面兜底):`trigger-restart.mjs` 起完 mshta 后轮询最多 `DSH_RESTART_LANDING_MS`(默认 6000 ms)确认它真的活着;没落地就跳过窗口,直接用 `conhost.exe --headless cmd.exe /c ""node.exe" "dsh-restart-helper.mjs""` 拉起 helper(Win11 22H2+ 自带 `conhost --headless`,托住 node 不弹黑框;没有它的老系统退回直拉,会闪一下窗口但排程仍成功)。日志区分 `via=mshta` / `via=node-headless` / `via=node` / `via=cmd`。
41
+ - 修法二(单实例锁):mshta 迟到与 CIM 兜底可能同时拉起两个 helper,两个都去 `taskkill` + 重拉会把刚起来的 DSH 再杀一次。`dsh-restart-helper.mjs` 抢 `restart-module\helper.lock`(锁放总线目录,跨进程/会话可见;内容写 pid + hostname + 抢锁时刻;持锁期间每 10 秒刷一次文件 mtime 作心跳,锁年龄超过 120 秒即判 stale 并删除重抢,PID 复用、进程假活、跨机残留一概覆盖;pid 存活校验留作第二重;锁来自别的主机则直接让路、不跨机双杀),抢不到就写 `result.json` 的 `reason:"duplicate-helper"` 后退出,绝不碰进程。正常退出、Ctrl-C、SIGTERM 都会释放锁。
42
+ - 验证:`DSH_RESTART_FORCE_NOUI=1 trigger-restart.mjs --delay 15 --dry-run` 走通 `via=node-headless`,helper 抢锁成功、到点输出 DRY-RUN result、退出后 `helper.lock` 自动消失;伪造「活 pid 的锁」再起 helper,日志打印 `lock busy: helper pid=…` 并返回 `duplicate-helper`,锁保留、DSH 未动。运行期间窗口列表里没有 node/conhost 的可见窗口。
43
+ - 组件版本:自动重启 `0.3.30` → `0.3.31`。
44
+
45
+ ### 加固:锁判活改成 mtime 心跳、结果留档、node 运行时不再依赖旧家目录
46
+
47
+ - 锁判活(采纳复核建议):上一个版本用「pid 存活 + 锁龄 15 分钟」判 stale,但 pid 存活判不出 PID 复用、进程假活、跨机残留。现在 helper 持锁期间每 10 秒 `utimes` 刷新 `helper.lock` 的 mtime,锁年龄超过 **120 秒**就直接判 stale 删除重抢(helper 单次最长动作实测 < 60 秒,2 分钟留足余量),pid 存活降为第二重校验;抢到锁到进程退出之间由 `setInterval` 持续心跳,`unref()` 不阻止退出。
48
+ - 结果留档:每次运行除写 `logs\result.json`(最近一次)外,再留一份 `logs\result-<stamp>.json`,便于排障时回看历史(trigger 清理 request.json 后尤其是唯一线索)。
49
+ - 迟到 helper 不再凭默认值杀宿主(复核发现的隐患):安全软件延迟放行 mshta 时,helper 可能在重启完成后才被拉起,此时若 `request.json` 已被清掉,旧逻辑会用默认 `delaySec=45` 自己去杀宿主。现在 helper 启动时先看 `request.json` 是否存在,不存在就写 `reason:"no-request"` 后直接退出,不抢锁、不碰进程;正常跑完(dry-run / 取消 / 其他会话忙 / 成功失败)会按 `createdAt` 只清掉「本次」那份请求,新排程不会被误删。
50
+ - node 运行时自包含:`trigger-restart.mjs` 的 `NODE_EXE` 候选顺序改为 `DSH_NODE_EXE` → `$DSH_HOME\dsh-runtimes` → `os.homedir()\.dsh\dsh-runtimes` → `C:\Users\Administrator\.dsh\dsh-runtimes` → `F:\dsh-home\dsh-runtimes`(去重)→ 裸 `node`;同时把 `F:\dsh-home\dsh-runtimes` 整棵(288,697,340 B)复制到 `C:\Users\Administrator\.dsh\dsh-runtimes`,家目录自包含,旧家目录哪天被清掉也不影响重启链路。
51
+ - 文档:`docs\huorong-whitelist.md` 补齐——信任区按整棵 `dsh-runtimes\` 加(运行时子目录名会变),并注明 `conhost --headless` 需要 Windows 11 22H2(build 22621)+。
52
+ - 验证:`trigger-restart.mjs --dry-run --delay 10` 走 `via=node-headless`,日志 `lock acquired pid=…` → 到点 `DRY-RUN result: {"ok":true…port:"open"}` → `helper.lock` 与 `request.json` 均自动消失,`logs\result-<stamp>.json` 落盘;`nodeExe` 解析到 `C:\Users\Administrator\.dsh\dsh-runtimes\…`。心跳实测:长等待(delay 200)期间 `helper.lock` mtime 每 10 秒推进一次(13:43:17 → 13:43:27,age 9.8s)。锁三态实测:新鲜锁 + 活 pid → `lock busy` 拒绝并保留锁;活 pid 但 mtime 已 181 秒 → `stale lock by heartbeat (alive=true age=181s)` 抢占成功;无 `request.json` 直接起 helper → `reason:"no-request"` 且不建锁。
53
+ - 组件版本:自动重启 `0.3.31` → `0.3.32`。
54
+
55
+ ### 加固:系统休眠不再骗过心跳判活 + 结果文件保留上限
56
+
57
+ - 休眠边界(采纳复核建议):合盖/睡眠时所有定时器停摆,唤醒后锁文件 mtime 可能几小时没动,但持锁 helper 其实还活着 —— 只看锁龄就会误抢,导致两次重启。现在「锁龄 stale **且** pid 仍存活」时不当场抢,先记日志 `possible system sleep, waiting 12s…`,等 `LOCK_CONFIRM_MS`(12 秒,约一个心跳周期)后复查:心跳恢复(`age < 120s`)则本实例直接退出(`heartbeat resumed`),仍停滞才判为卡死并抢占(`heartbeat still stalled after confirm window`)。pid 已死的 stale 锁行为不变,仍立即删除重抢。
58
+ - 结果文件保留上限:`logs\result-<stamp>.json` 原样无限增长,现在每次写完顺手清理,只留最近 `RESULT_KEEP = 50` 份(按 mtime 倒序),`result.json` 不受影响。
59
+ - 验证:伪造「活 pid + 10 分钟前 mtime」的锁,起 helper → `stale lock by heartbeat but pid=33208 alive (age=600s); possible system sleep, waiting 12s` → 12 秒后 `heartbeat resumed (age=1s)`,实例退出、锁保留、DSH 未动(心跳恢复场景,不双杀);同一造锁换成真 hung 进程 → `heartbeat still stalled after confirm window (age=612s); treating as hung and stealing lock` → 抢锁成功、`DRY-RUN result ok`、锁与 request 均清理。保留上限:预置 60 份 `result-*.json` 跑一次 no-request 退出,剩 50 份。
60
+ - 组件版本:自动重启 `0.3.32` → `0.3.33`。
61
+
62
+ ### 加固:插件里的 nodeExe 不再写死用户名
63
+
64
+ - 复核发现(与本轮其余三处同类):`components/restart/core.js` 的 `nodeExe()` 虽然已经扫 `DSH_HOME` / `C:\Users\Administrator\.dsh` / `F:\dsh-home`,但其中两处是**字面量**,没有走 `os.homedir()`——换用户名或家目录迁盘后,候选会落到 `F:\dsh-home` 这种测试机专用路径,找不到内置运行时再回退 PATH 的 `node`(这台机器 `where node` 为空),helper 便静默失败。
65
+ - 修法:候选改为 `DSH_HOME` → `path.join(os.homedir(), '.dsh')` → 老的 `C:\Users\Administrator\.dsh` → `F:\dsh-home`,按 `path.resolve` 去重(Windows 大小写不敏感);新增 `import os from 'node:os'`;全部落空时记一条 `plugin.log`(`nodeExe: 未找到内置 dsh-runtimes,回退 PATH 的 node`),不再无声无息。
66
+ - 注意:这段代码在插件模块里(宿主常驻),**要下次重启宿主才生效**;trigger/helper 是每次排程现拉的新进程,不受此限。
67
+ - 验证:`node --check` 通过;用 `@deepseek-ai/dsh-tools` 桩 import `core.js` 通过(`exports: SRC_DIR, default`),`os` 已绑定。
68
+ - 组件版本:自动重启 `0.3.33` → `0.3.34`。
69
+ - 同一版顺带补可审计性:helper 启动时把 `launchMode`(`mshta` / `node-headless` / `cim-direct` / `cmd-window`)、父进程名与 pid 写进当次 `logs\helper-*.log` 首行,退出时再记一行 `exit code=<n>`。此前「这次到底怎么起的、退出码多少」只留在 trigger 的 stdout 里,重启之后无从复查——而守卫与兜底路径恰恰是最容易悄悄退化的一环。`trigger-restart.mjs` 对两条无界面路径显式传 `--launch-mode=`,其余按父进程名推断。
70
+ - 自检文档同步澄清编码检查的范围:只查活跃树,`archive/**` 与 `.bak-*` 故意不参与(`archive\dsh-restart-0.3.28\scripts\_probe-note.html` 带 BOM,若扫进来会让自检第一次跑就误报 `json_bom`)。
71
+ - 「exit 行缺失」正式定义为一类信号:`process.on('exit')` 只在正常退出/未捕获异常终止时触发,被 `TerminateProcess` 强杀或整机断电时一行都不写。因此日志「有 `launch:`、无 `exit code=`」= 进程被外部杀掉,而不是日志被截断;SIGINT/SIGTERM 会先写 `signal SIGTERM received; releasing lock and exiting (143)` 再退出,所以这类终止仍有尾行。读法与三种结局写进新文档 `docs\restart-log-reading.md`。
72
+
73
+ ### 加固:busy 信号缺席不再等于空闲(0.3.35)
74
+
75
+ - 起因:复核指出「快照有时效」比字段本身更要紧——`busy-agents.json` 是某一时刻的切片,过期后应视为**未知**而不是「全部空闲」。守卫若拿过期文件当权威,就退化成旧 `runningAgents` 那个 bug 的形状。
76
+ - 改法(`busy-check.mjs`):`HEARTBEAT_FRESH_MS` 默认 8s → **30s**;`othersBusy()` 新增 `unknown`——市场接口不可用**且**心跳过期时置 true(两个权威信号同时缺席);`waitQuiet()` 把 `unknown` 当「不安静」继续等,等不到返回 `{ok:false, reason:"busy-unknown", unknown:true, sources}`。
77
+ - helper 守卫区分两种失败:`others-busy`(有人真的在忙)与 `busy-unknown`(读不到权威信号),后者 `result.json` 写明 `note: 市场接口与心跳都读不到,无法确认其他会话是否在跑;为免误杀先不重启`,仍然 exit 3。语义一致:宁等一秒,不误杀一次。
78
+ - 明确不做的事:**不加** `lastActivityAt`,也不把「久未动」并进 busy——一个 turn 在等模型/等工具时本来就长时间没输出,`inTurn` 仍为 true;判卡死是另一个信号(未来独立字段 `hung`)。一个字段回答一个问题:`busy` 答「现在能不能重启」,`hung` 答「需不需要救」。
79
+ - 实测:正常(市场+心跳都在)`ok:true`、sources 含 `heartbeat:busy-agents`;把端口与心跳路径都打错 → `ok:false, reason:busy-unknown, sources:[…, unknown:no-authoritative-signal]`。
80
+ - 组件版本:自动重启 `0.3.34` → `0.3.35`。
81
+
82
+ ### 加固:fail-closed 的三个配套边界(0.3.36)
83
+
84
+ - **host-down 要放行**:`busy-unknown` 时先探 19387——端口不通说明宿主已经退出,此时不存在「其他会话在跑」,abort 反而让唯一能救它的操作永远不执行。改为记 `host-down` 后继续重启;端口通且信号缺席才维持 abort。
85
+ - **粘住的 inTurn 要可见**:心跳新增 `sinceMs`(本宿主第几次看到它 inTurn 起算,重启宿主即清零),helper 对 `sinceMs > 15 分钟` 的忙会话不解除 blocking(宁等不误杀),但写 `stuckCandidates` 与可行动的 note(「可能是漏写 turn/end 的粘住状态,可考虑 force 或等它收尾」)。
86
+ - **退出码分开**:`others-busy` → `3`,`busy-unknown` → `4`,调用方不读 note 也能分清「稍后再试」与「信号坏了,请检查」。
87
+ - **unknown 的等待上限**:不再吃满 `quietWaitSec`(180s),信号缺席通常不会等而自愈,给它 `DSH_RESTART_UNKNOWN_WAIT_MS`(默认 60s)就早失败早可行动。
88
+ - 实测:心跳塞一条 `inTurn + sinceMs=20min` 的假会话、市场接口打错端口 → `busy` 里带 `sinceMs:1200000`、`unknown:false`、`reason:others-busy`;helper 侧据此产出 `stuckCandidates`。
89
+ - 组件版本:自动重启 `0.3.35` → `0.3.36`。注意 `core.js`(心跳 `sinceMs`)属宿主常驻,**要下次重启宿主才生效**;helper/busy-check 是现拉进程,下一次排程即生效。
90
+
91
+ ### 修复:心跳改读实时事件流;「新鲜但瞎」判 unknown(0.3.37)
92
+
93
+ - **真问题**:心跳的 `inTurn` 原来读 `agent.session.events`,那是「已加载/已持久化」快照,不跟实时回合。2026-10-20 22:05 实测:心跳 16 个 agent 全 `inTurn:false`(含正在跑工具的那个),同一时刻市场接口报 2 个 running,会话日志最后 `turn/start seq=1475 > turn/end seq=1473`。心跳「新鲜」所以 `unknown = !agents && hb.stale` 防不住它——市场一挂,守卫把「心跳说没人忙」当权威空闲,fail-closed 又变回 fail-open。
94
+ - **生产端**(`core.js`):新增 `ctx.on('session/event')` 订阅,`turn/start` → `inTurn:true`、`turn/end` → `false`,心跳优先用这份实时状态(条目带 `src:"event"`),只在没有事件记录时退回 `snapshotEvents()/events`(`src:"snapshot"`)。每 30 次写(约 60s)落一条诊断:`busy heartbeat: sessions=N inTurn=M sinceEvents=K src={...}`,可直接观测「事件到底有没有来」。
95
+ - **消费端**(`busy-check.mjs`):新增一致性判据——心跳新鲜、且市场接口报了 `runningAgents`(非空)、但心跳里 `inTurn` 数为 0 → 判 `unknown:heartbeat-blind`,按 unknown 处理(fail-closed),不再当空闲。即使生产端修好也保留,防下次静默失效。
96
+ - **id 比对**:`sameSession()` 剥掉 `session-` 前缀后允许前缀匹配,`--self 71734ef7` 这类 8 位短 id 也能认出自己(原来只认全量 id,短 id 会把自己算成别人 → 永远 busy)。
97
+ - 实测:假 ctx(`session.events` 全空)+ 模拟 `turn/start` → 心跳条目变 `inTurn:true, src:"event"`;`turn/end` → `false`。消费端:市场报 2 个 running + 心跳全 false → `unknown:true, sources:[…, unknown:heartbeat-blind]`;心跳有 inTurn → `unknown:false`。
98
+ - **生效范围**:`busy-check.mjs` 是现拉进程,下一轮排程即生效;`core.js` 属宿主常驻,实时事件订阅要下次重启宿主才生效(重启前心跳仍走 snapshot 分支,但消费端一致性判据已经能把这种「新鲜但瞎」判成 unknown)。
99
+ - 组件版本:自动重启 `0.3.36` → `0.3.37`。顺带把 bus 里滞后的 `restart-module/manifest.json`(0.3.33)同步到当前版本——它不是宿主运行时改写的,是发布时拷贝漏了。
100
+
101
+ ### 修复:短 id 在 transcript 源里没排除自己;「权威的零」与「生产端瞎了」分开(0.3.38)
102
+
103
+ - **① 短 id(真 bug)**:`recentSessionWriter()` 里是精确比较 `f.sessionId !== self`,而 `sessionFiles()` 返回的 id 已经剥过 `session-` 前缀,`--self 71734ef7` 这种短 id 永远匹配不上 → 自己一直在写 transcript ⇒ 永远 `why:"transcript-write"`、重启永远被拒。改成 `!sameSession(f.sessionId, self)`,与 market / heartbeat 两处一致。这个 bug 现网没炸只是因为 helper 传的是全量 id;文档又鼓励用短 id 调 CLI,一用就中。上一版 `test-blind.mjs` 传了空 `dshHome`,transcript 源是空的,没测到——测试的隔离把被测对象一起隔掉了。
104
+ - **② blind 判据会自锁(真风险)**:`heartbeatBlind = 新鲜 && market 报有人跑 && 心跳 inTurn 全 0` 无法区分「生产端瞎了」与「生产端权威的零」。市场 `runningAgents` 会留陈旧残影(崩溃前的会话),core.js 生效后心跳说「没人忙」而市场还在报 → 永远 `unknown`(exit 4),而清掉残影只能重启,重启又被拒绝。
105
+ - 生产端心跳 JSON 现在写 `producer:"event"|"snapshot"` 与 `eventsSeen`(累计事件数)。订阅失败或缺 `ctx.on` 时 `producer` 保持 `"snapshot"`。
106
+ - 消费端只在 `producer !== "event"` 时才把「全 false」判成 blind;`producer === "event"` 时它的零是权威的:市场里被心跳明确记为 `inTurn:false` 的 `running-agent` 条目降级进 `staleMarket`(结果里可见、`sources` 记 `market/status:stale-ignored`),不参与 blocking。
107
+ - 订阅建立前已在回合里的会话,用快照「播种」进 `liveTurn`(`src:"seed"`,首个真实事件转 `"event"`),否则它们在下一个 turn 之前一直显示 false——同样是「恒为 0」的形状。
108
+ - 实测:`%TEMP%\cts-release\test-seed.mjs` 造「快照说 turn/start」的会话 → 心跳 `inTurn:true, src:"seed"`,`turn/end` 后 `false, src:"event"`;`test-authoritative.mjs` 四种情形:producer=event + 心跳全 false → busy 空、`staleMarket` 有陈旧条目、`unknown:false`;去掉 producer → `unknown:heartbeat-blind`;有一个真 inTurn → 只留它;短 id 的 transcript 源自排除生效。
109
+ - **③ 顺带一条**:blind 判据原来用市场 `runningAgents` 的原始长度,含「我自己」——校验时机永远有一场自己的回合在跑,于是「只有我在跑」也被判成 blind。改成只看别的会话(`marketOthers`),与 `busy` 里排除自己是同一口径。
110
+ - 组件版本:自动重启 `0.3.37` → `0.3.38`。
111
+
112
+ ## 0.9.13 — 2026-10-20
113
+
114
+ ### 修复:链台账 fail-closed(YOCE_C 冒烟报的两处边角 + 自查的第三处)
115
+
116
+ - 空成员台账:`chains/<code>.json` 的 `members` 为空时,`leaveChainNode()` 已把文件删掉、返回 `removed:0`,但 `unlinkChain()` 只认 `removed>0`,于是把「文件已删」报成 `ok:false`。改为 `removed>0 || empty` 都算成功,返回 `{ ok:true, empty:true }`。
117
+ - presence 读不到不再当全员离线:`dropChainIfOrphan()` 原来用 `listNodes().catch(()=>[])`,`presence/` 目录读失败时拿到空列表,与「全员离线」无法区分,会把还在线的无主链删掉。`lib/bus.js` 拆出 `listNodesStrict()`(读失败抛错),孤儿判定改用严格读取:读不到就直接返回 `{ dropped:false, reason:'presence-unreadable' }`,`term_unlink` 回 `ok:false` 并说明原因;`listNodes()` 保留为展示用 fail-open(读不到返回空列表)。
118
+ - 广播会顺手修剪台账:`term_broadcast` 的 `pruneChain()` 在 `kept=0` 时删台账文件,同样吃 `listNodes()` 的 fail-open —— presence 读失败时一次广播就能把整条链删掉。改为严格读取,读不到就中止广播并返回 `ok:false`,台账不动。
119
+ - 测试:新增 `test/chain.test.mjs`(空台账 / 无主离线 / 有成员在线 / presence 读不到下的 unlink 与 broadcast / 严格读取抛错),并修正 `test/plugin.test.mjs` 工具清单漏掉的 `term_cloud`。
120
+ - 外部回归:YOCE_C 的 `regress.mjs` 在 rebase 后的本树跑 **5/5 全绿**(orphan-offline / member-online / empty-members / unlink-presence-unreadable / broadcast-presence-unreadable);同一脚本在 0.9.10 与已发布 0.9.12 上均为 2/5。
121
+ - 防御性补注:`pruneChain()` 上方写明「`nodes` 必须来自严格读取」,新增调用点不得传 fail-open 的空列表。
122
+ - 基线:0.9.12(已发布)只含「上一任」修复,不含本版三处改动;本版在其代码之上 rebase,避免版本号与内容错位。
123
+ - 组件版本:跨会话 `0.5.2` → `0.5.3`。
124
+
1
125
  ## 0.9.12 — 2026-10-20
2
126
 
3
127
  ### 修复:负责人记录的「上一任」取错人(只改代码一处)
package/MAINTAINERS.md CHANGED
@@ -100,7 +100,7 @@
100
100
  | 组件(插件页顺序) | 当前版本 | 负责人 | 范围 | 规矩 |
101
101
  |---|---|---|---|---|
102
102
  | ① 跨端互通(自更新) | v0.9.3 | `3195b037` | `components/ui/**`、`client/**`、`locale/**`、`lib/web.js` | 组件版本号在 `components/ui/manifest.json` |
103
- | ② 跨会话 | v0.5.2 | `3195b037` | `components/session/**`、`lib/bus.js` 的链/邮件 | 同上 |
103
+ | ② 跨会话 | v0.5.3 | `3195b037` | `components/session/**`、`lib/bus.js` 的链/邮件 | 同上 |
104
104
  | ③ 跨工作区 | v0.1.0 | `3195b037` | `components/terminal/**`、presence / `term_send` | 同上 |
105
105
  | ④ 跨设备 | v0.4.0 | `3195b037` | `components/device/**`、`lib/cloud/**` | 同上 |
106
106
  | ⑤ 自动重启 | v0.3.29 | `3195b037`(暂管;原 `a3e9f80e` 已于 2026-10-20 19:33 交回) | `components/restart/**`(`core.js` / `runtime.js` / `scripts/**`) | 不单独发版;新负责人等后续交接时任命;只动它就把它的版本号加一 |
package/README.md CHANGED
@@ -19,10 +19,10 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
19
19
  | 顺序 | 组件名称 | 版本 | 作用 | 工具 |
20
20
  |---|---|---|---|---|
21
21
  | ① | 跨端互通(入口/自更新) | v0.9.3 | 自更新开关:打开后定期检查 npm 新版本并自动安装(默认关;装完要重启 DSH 生效)。关掉它只失去卡片与自动更新,下面四个组件照常工作。裸包名行,承载插件标题/简介与配置卡片,不挂工具 | — |
22
- | ② | 跨会话 | v0.5.2 | 同一终端里的多个会话之间互通:特征码链、内联邮件、收件箱 | `term_link` `term_unlink` `term_broadcast` `term_inbox` `term_config` |
22
+ | ② | 跨会话 | v0.5.3 | 同一终端里的多个会话之间互通:特征码链、内联邮件、收件箱 | `term_link` `term_unlink` `term_broadcast` `term_inbox` `term_config` |
23
23
  | ③ | 跨工作区 | v0.1.0 | 在本机不同工作区、不同会话之间互发邮件与文件(跨终端本轮不做,暂无条件) | `term_whoami` `term_list` `term_send` `term_send_files` |
24
24
  | ④ | 跨设备 | v0.4.0 | 经云端中转站,在多台机器的 DSH 之间互发邮件与文件 | `term_cloud` |
25
- | ⑤ | 自动重启 | v0.3.29 | 改完插件/配置后让 DSH 自己重启一次,并自动拉起离线会话(原 `dsh-restart` 插件,0.9.0 起并入) | `dsh_restart` |
25
+ | ⑤ | 自动重启 | v0.3.40 | 改完插件/配置后让 DSH 自己重启一次,并自动拉起离线会话(原 `dsh-restart` 插件,0.9.0 起并入) | `dsh_restart` |
26
26
 
27
27
  > 入口行为什么必须用裸包名:浏览器 client 半端只对裸包名行生效(`dsh-client-modules` 的
28
28
  > `locatePkgJson()` 对子路径行返回 undefined),而插件页顶部标题与这一行共用同一份 `locale/zh.json` 的 meta,
@@ -30,36 +30,32 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
30
30
  >
31
31
  > 组件版本号怎么升:**只在该组件自己的文件改动时升它**,跟着插件一起发版(例:只改自动重启 → `components/restart` 的 `0.3.28` → `0.3.29`,其余四个不动)。插件总版本仍按发版技能的三分位规则。
32
32
 
33
- **本版更新(0.9.12)**
33
+ **本版更新(1.0.0)**
34
34
 
35
- - **修「上一任」取错人**:`/about` 的维护者信息与 `logs/ownership.log` 里,「上一任」以前会显示成最早的初版负责人 `6ef6da30`;现在按离任时间取最近的一位(本次交接后应为 `b6aa099b`)。
36
- - 接口多返回一个 `previousAll`(完整历任列表),当前负责人与各组件版本不变,功能零变化。
37
- - 历史日志里已写错的那一行不会自动改写(有去重标记),以 `MAINTAINERS.md` 为准。
38
-
39
- **上一版(0.9.11)**
35
+ - **版本号正式跨过 1.0**:这一版把「自动重启」组件从 0.3.30 推到 0.3.40,十次连续加固全部来自真实事故与本机复现 —— wmic 在新系统失效后改走 PowerShell CIM;安全软件拦 `mshta` 时用 `conhost --headless` 无界面兜底;helper 加单实例锁(mtime 心跳判活、休眠后二次确认、跨主机让路);node 运行时不再依赖旧家目录;`launch:` / `exit code=` 两行让每次重启事后可审。
36
+ - **空闲守卫重做**:心跳从「读会话快照」改成订阅实时 `session/event`,生产端写 `producer` / `eventsSeen` / `src`;消费端把「市场残影」「权威的零」「读不到权威信号」分开判,退出码 `busy=3` / `unknown=4`,不再把「读不到」当成「没人忙」。
37
+ - 本轮不再改 `lib/*.js` 的互通逻辑(链台账 fail-closed 已在 0.9.13 发布),改动集中在 `components/restart`。
38
+ - 文档新增:`docs/restart-log-reading.md`(怎么读 helper 日志)、`docs/huorong-whitelist.md`(加白指引与边界)、`docs/selfcheck-home-replaced-pseudocode.md`(家目录被空壳顶替的自检伪代码,含编码完整性检查)。
39
+ - 组件版本:自动重启 0.3.30 → **0.3.40**;入口 0.9.3 / 跨会话 0.5.3 / 跨工作区 0.1.0 / 跨设备 0.4.0 不动。
40
40
 
41
- - **负责人交接**:本插件维护权与发版权限由 `b6aa099b` 移交给 `3195b037`(显示名「插件—互通插件负责人001」,工作区 `H:\dshGZQ\dsh-crossterm`),时间 2026-10-20 19:55,依据机主「负责人交接,权限交接」。
42
- - 本版只改文档:`MAINTAINERS.md`(`current` / `previous[]` / `modules` / 任期表)、`AGENTS.md` §7、发版技能、`README` 负责人段。
43
- - 助手 `ccf34a5a`(同端多会话2)随交接暂停派活,是否续用由新负责人决定;其他会话仍只读。
44
- - 功能零变化:跨会话 0.5.2 / 跨工作区 0.1.0 / 跨设备 0.4.0 / 自动重启 0.3.29 / 入口 0.9.3。
41
+ **上一版(0.9.13)**
45
42
 
46
- **上一版(0.9.10)**
43
+ - **链台账改 fail-closed**:`presence/` 读不到(目录被删、或变成同名文件)时,`term_unlink ... all=true` 与 `term_broadcast` 以前会把「读不到在线节点」当成「全员离线」,把还在线的链台账删掉。现在改用严格读取:读不到就拒绝删除并回 `ok:false`(广播中止,台账不动)。
44
+ - **空成员台账不再误报失败**:链上成员为空、文件已被删时,`term_unlink` 以前回 `ok:false`;现在回 `ok:true` + `empty:true`。
45
+ - 无主离线台账的清理语义不变(仍回 `droppedChain:true`);新增 `test/chain.test.mjs` 覆盖四条路径。
46
+ - 组件版本:跨会话 0.5.2 → **0.5.3**,其余组件不动。
47
47
 
48
- - **「清除所有特征码」最后一步收口**:本终端已经不在某串码上、links 里也没有记录、而这串码上的成员全部离线时,`term_unlink ... all=true` 现在会把这份无主台账一并删掉(返回 `droppedChain:true`),不用再人工去删 `chains/<code>.json`。
49
- - 判定很保守:本节点还在链上、或有任何一个成员节点在线(含刚心跳过),都**不会**删;只清「谁都不在线」的残档。
50
- - 起因:0.9.9 修好了「台账有本机成员却退不掉」,但还有一种「本机根本没有成员、别的机器早解散了」的残档,之前只能人工删文件(C 盘写入要审批)。
51
- - 组件版本:跨会话 0.5.1 → **0.5.2**,其余组件不动。
48
+ **上一版(0.9.12)**
52
49
 
53
- **上一版(0.9.9)**
54
-
55
- - **修「幽灵链成员」退不掉**(YOCE_C 2026-10-20 报的坑):`term_unlink ... all=true` 以前只遍历内存里的 links,遇到「链台账里还留着本终端成员、但 links 已没有这条记录」就永远退不掉(一直回「本终端没有接在特征码上」)。
56
- - 现在 `all=true` 在 links 为空时也会按 **nodeId** 把本终端在这串码上的成员从台账里摘掉,返回值带 `ghosts`;成员清空时链文件自动删除。
57
- - `chainSweep` 每 6 拍自愈一次:本节点在台账里有、links 里却没有的成员会被摘掉,并记一行日志「chain sweep: 摘掉幽灵成员」。
58
- - 显式传 `sessionId` 的行为不变——只退那一个,不动其它会话(笔误不会误删)。
59
- - 组件版本:跨会话 0.5.0 → **0.5.1**,其余组件不动。
50
+ - **修「上一任」取错人**:`/about` 的维护者信息与 `logs/ownership.log` 里,「上一任」以前会显示成最早的初版负责人 `6ef6da30`;现在按离任时间取最近的一位(本次交接后应为 `b6aa099b`)。
51
+ - 接口多返回一个 `previousAll`(完整历任列表),当前负责人与各组件版本不变,功能零变化。
52
+ - 历史日志里已写错的那一行不会自动改写(有去重标记),以 `MAINTAINERS.md` 为准。
60
53
 
61
- **更早(0.9.8 / 0.9.7 / 0.9.6 / 0.9.5 / 0.9.4 / 0.9.3 / 0.9.2 / 0.9.0 / 0.8.14 / 0.8.13 / 0.8.12 / 0.8.11 / 0.8.10 / 0.8.9 / 0.8.8 / 0.8.7 / 0.8.6 / 0.8.5 / 0.8.0)**
54
+ **更早(0.9.11 / 0.9.10 / 0.9.9 / 0.9.8 / 0.9.7 / 0.9.6 / 0.9.5 / 0.9.4 / 0.9.3 / 0.9.2 / 0.9.0 / 0.8.14 / 0.8.13 / 0.8.12 / 0.8.11 / 0.8.10 / 0.8.9 / 0.8.8 / 0.8.7 / 0.8.6 / 0.8.5 / 0.8.0)**
62
55
 
56
+ - 0.9.11:负责人交接(`b6aa099b` → `3195b037`),只改文档,功能零变化。
57
+ - 0.9.10:「清除所有特征码」收口——本机不在链上且成员全离线时,`term_unlink all=true` 连无主台账一起删(`droppedChain:true`);组件 0.5.1 → 0.5.2。
58
+ - 0.9.9:修「幽灵链成员」退不掉——`all=true` 按 nodeId 把本终端在台账里的成员摘掉(返回带 `ghosts`),`chainSweep` 每 6 拍自愈;组件 0.5.0 → 0.5.1。
63
59
  - 0.9.8:自动重启组件维护权交回、暂由插件负责人直管;只改文档。
64
60
  - 0.9.7:组件版本按机主要求重新定起点(跨会话 v0.5.0 / 跨设备 v0.4.0 / 跨工作区 v0.1.0)。
65
61
  - 0.9.6:去掉「跨账号」组件,插件页变成 5 个组件。
@@ -19,6 +19,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
19
19
  import { execFile } from 'node:child_process'
20
20
  import crypto from 'node:crypto'
21
21
  import fs from 'node:fs'
22
+ import os from 'node:os'
22
23
  import path from 'node:path'
23
24
  import { fileURLToPath } from 'node:url'
24
25
 
@@ -51,8 +52,35 @@ function textOf(value) {
51
52
  function nodeExe() {
52
53
  const explicit = process.env.DSH_NODE_EXE
53
54
  if (explicit && fs.existsSync(explicit)) return explicit
54
- const bundled = 'C:\\Users\\Administrator\\.dsh\\dsh-runtimes\\dsh-primary-runtime\\dependencies\\node\\bin\\node.exe'
55
- if (fs.existsSync(bundled)) return bundled
55
+ // 不写死运行时路径:DSH_HOME 可能被搬走(junction 换成真实目录、home 迁盘),
56
+ // 扫 DSH_HOME 与本机 home 下的 dsh-runtimes,取第一个存在的 node.exe。
57
+ // 家目录一律用 os.homedir() 算,不写死用户名 —— 写死那版在换用户名/迁盘后会静默落到兜底路径。
58
+ const roots = [
59
+ process.env.DSH_HOME,
60
+ path.join(os.homedir(), '.dsh'),
61
+ 'C:\\Users\\Administrator\\.dsh', // 老路径兜底(用户没变时才有意义)
62
+ 'F:\\dsh-home', // 迁移前的旧家目录,最后兜底
63
+ ]
64
+ const seen = new Set()
65
+ for (const raw of roots) {
66
+ if (!raw) continue
67
+ const root = path.resolve(raw)
68
+ if (seen.has(root.toLowerCase())) continue
69
+ seen.add(root.toLowerCase())
70
+ const rt = path.join(root, 'dsh-runtimes')
71
+ let dirs = []
72
+ try {
73
+ dirs = fs.readdirSync(rt)
74
+ } catch {
75
+ continue
76
+ }
77
+ for (const d of dirs) {
78
+ const p = path.join(rt, d, 'dependencies', 'node', 'bin', 'node.exe')
79
+ if (fs.existsSync(p)) return p
80
+ }
81
+ }
82
+ // 走到这里说明四个根都没有内置运行时:PATH 里有没有 node 只能碰运气(这台机器 `where node` 是空的)。
83
+ mark('nodeExe: 未找到内置 dsh-runtimes,回退 PATH 的 node(可能不存在)')
56
84
  return 'node'
57
85
  }
58
86
 
@@ -472,10 +500,36 @@ function scheduleRevive(root) {
472
500
  // 所以插件每 2 秒把「所有 agent 的 id + 是否在回合中」写成 logs\busy-agents.json,
473
501
  // helper 侧的 busy-check.mjs 拿它当第三路信号。
474
502
  const BUSY_HEARTBEAT = path.join(LOG_DIR, 'busy-agents.json')
503
+ // 「这个回合已经跑了多久」:事件体里没有可靠的绝对时间,而且 sticky inTurn(漏写 turn/end)
504
+ // 恰恰是要被识别的情形。所以只记「本进程第一次看到它 inTurn 的时刻」——重启宿主即清零,
505
+ // 语义是「据本宿主所知它已经忙了这么久」,够 helper 判断是不是粘住的。
506
+ const inTurnSince = new Map()
507
+
508
+ // 实时回合状态:来自 ctx.on('session/event'),turn/start → true,turn/end → false。
509
+ // 为什么不能只读 agent.session.events:那是「已加载/已持久化」的快照,实测在真实回合里
510
+ // 会与权威日志不一致(2026-10-20 22:05 心跳 16 个 agent 全 false,同一时刻市场报 2 个在跑,
511
+ // 会话日志最后 turn/start seq=1475 > turn/end seq=1473)。事件流才是实时的。
512
+ const liveTurn = new Map() // sessionId -> { inTurn, since, src: 'event'|'seed' }
513
+ let liveEventSeen = 0
514
+ // 生产端身份:'event' 表示心跳读的是实时事件流(事件流正常时,inTurn:false 就是权威的「没人忙」);
515
+ // 'snapshot' 表示只拿到快照(此时 inTurn 全 false 不能证明没人忙)。消费端靠这个字段区分
516
+ // 「生产端瞎了」与「生产端权威的零」,否则升级完成后陈旧 market 残影会把重启永久锁死。
517
+ let liveProducer = 'snapshot'
518
+ let busyWrites = 0
519
+
520
+ function onSessionEvent(target, event) {
521
+ try {
522
+ const id = String((target && (target.id || target.sessionId)) || '')
523
+ const type = String((event && event.type) || '')
524
+ if (!id || !type) return
525
+ if (type === 'turn/start') liveTurn.set(id, { inTurn: true, since: Date.now(), src: 'event' })
526
+ else if (type === 'turn/end') liveTurn.set(id, { inTurn: false, since: Date.now(), src: 'event' })
527
+ liveEventSeen += 1
528
+ } catch { /* ignore */ }
529
+ }
475
530
 
476
- function inTurn(agent) {
531
+ function eventsInTurn(events) {
477
532
  try {
478
- const events = agent && agent.session && agent.session.events
479
533
  if (!events || !events.length) return false
480
534
  for (let i = events.length - 1; i >= 0; i -= 1) {
481
535
  const type = String((events[i] && events[i].type) || '')
@@ -486,22 +540,79 @@ function inTurn(agent) {
486
540
  } catch { return false }
487
541
  }
488
542
 
543
+ function snapshotInTurn(agent, session) {
544
+ try {
545
+ if (session && typeof session.snapshotEvents === 'function') return eventsInTurn(session.snapshotEvents())
546
+ } catch { /* ignore */ }
547
+ try { if (agent && agent.session) return eventsInTurn(agent.session.events) } catch { /* ignore */ }
548
+ try { if (session) return eventsInTurn(session.events) } catch { /* ignore */ }
549
+ return false
550
+ }
551
+
552
+ // 订阅建立之前就已经在回合里的会话不会再收到 turn/start。用快照「播种」一次,
553
+ // 否则它们会一直显示 false —— 那正是今晚「恒为 0」的形状。播种的条目 src:'seed',
554
+ // 收到第一条真实事件后转为 'event';重启宿主由快照重新播种。
555
+ function seedOneLiveTurn(agent, session) {
556
+ try {
557
+ const sid = String((agent && agent.id) || (session && session.id) || '')
558
+ if (!sid || liveTurn.has(sid)) return
559
+ if (snapshotInTurn(agent, session)) liveTurn.set(sid, { inTurn: true, since: Date.now(), src: 'seed' })
560
+ } catch { /* ignore */ }
561
+ }
562
+
563
+ function seedLiveTurn(ctx) {
564
+ try { for (const agent of ctx.agents.roots()) seedOneLiveTurn(agent, agent && agent.session) } catch { /* ignore */ }
565
+ try {
566
+ for (const s of (ctx.sessions?.list?.() || [])) {
567
+ let agent = null
568
+ try { agent = ctx.agents?.get?.(s.id) || null } catch { /* ignore */ }
569
+ seedOneLiveTurn(agent, s)
570
+ }
571
+ } catch { /* ignore */ }
572
+ }
573
+
574
+ function turnState(id, agent, session) {
575
+ const live = liveTurn.get(id)
576
+ if (live) return { inTurn: live.inTurn, src: live.src || 'event' }
577
+ return { inTurn: snapshotInTurn(agent, session), src: 'snapshot' }
578
+ }
579
+
489
580
  function writeBusyHeartbeat(ctx) {
490
581
  try {
491
582
  const seen = new Map()
492
- const add = (agent) => {
493
- const id = String((agent && agent.id) || '')
494
- if (!id || seen.has(id)) return
495
- seen.set(id, { sessionId: id, inTurn: inTurn(agent) })
583
+ const now = Date.now()
584
+ const add = (id, agent, session) => {
585
+ const sid = String(id || (agent && agent.id) || (session && session.id) || '')
586
+ if (!sid || seen.has(sid)) return
587
+ const state = turnState(sid, agent, session)
588
+ if (!state.inTurn) {
589
+ inTurnSince.delete(sid)
590
+ seen.set(sid, { sessionId: sid, inTurn: false, src: state.src })
591
+ return
592
+ }
593
+ if (!inTurnSince.has(sid)) inTurnSince.set(sid, now)
594
+ seen.set(sid, { sessionId: sid, inTurn: true, sinceMs: now - inTurnSince.get(sid), src: state.src })
496
595
  }
497
- try { for (const agent of ctx.agents.roots()) add(agent) } catch { /* ignore */ }
596
+ try { for (const agent of ctx.agents.roots()) add(agent.id, agent, agent && agent.session) } catch { /* ignore */ }
498
597
  try {
499
598
  for (const s of (ctx.sessions?.list?.() || [])) {
500
- try { add(ctx.agents?.get?.(s.id)) } catch { /* ignore */ }
599
+ let agent = null
600
+ try { agent = ctx.agents?.get?.(s.id) || null } catch { /* ignore */ }
601
+ add(s.id, agent, s)
501
602
  }
502
603
  } catch { /* ignore */ }
503
604
  fs.mkdirSync(LOG_DIR, { recursive: true })
504
- fs.writeFileSync(BUSY_HEARTBEAT, JSON.stringify({ at: new Date().toISOString(), agents: [...seen.values()] }), 'utf8')
605
+ fs.writeFileSync(BUSY_HEARTBEAT, JSON.stringify({ at: new Date().toISOString(), producer: liveProducer, eventsSeen: liveEventSeen, agents: [...seen.values()] }), 'utf8')
606
+ busyWrites += 1
607
+ // 每 30 次写(约 60s)落一条诊断:inTurn 数、来源分布、累计事件数。
608
+ // 这是给对方「session.events 是不是实时」那个实验的现场观测,不必再另写探针。
609
+ if (busyWrites % 30 === 1) {
610
+ const list = [...seen.values()]
611
+ const on = list.filter((x) => x.inTurn)
612
+ const srcs = {}
613
+ for (const x of list) srcs[x.src] = (srcs[x.src] || 0) + 1
614
+ mark(`busy heartbeat: sessions=${list.length} inTurn=${on.length} sinceEvents=${liveEventSeen} src=${JSON.stringify(srcs)}`)
615
+ }
505
616
  } catch (e) {
506
617
  mark(`busy heartbeat failed: ${(e && e.message) || String(e)}`)
507
618
  }
@@ -513,6 +624,20 @@ export default {
513
624
  root.inject(['agents'], (ctx) => {
514
625
  const mounted = new Map()
515
626
 
627
+ // 订阅实时回合事件:这是心跳「谁在忙」的主信号,snapshot/events 只做挂载前的兜底。
628
+ try {
629
+ if (typeof ctx.on === 'function') {
630
+ const off = ctx.on('session/event', onSessionEvent)
631
+ liveProducer = 'event'
632
+ seedLiveTurn(ctx)
633
+ mark(`busy heartbeat: subscribed session/event (disposer=${typeof off}) producer=event`)
634
+ } else {
635
+ mark('busy heartbeat: ctx.on 不可用,只能读 events 快照(可能滞后)')
636
+ }
637
+ } catch (e) {
638
+ mark(`busy heartbeat: subscribe failed ${(e && e.message) || String(e)}`)
639
+ }
640
+
516
641
  const mount = (agent) => {
517
642
  if (!agent || mounted.has(agent)) return
518
643
  try {
@@ -531,6 +656,7 @@ export default {
531
656
  }
532
657
  }, 'dsh-restart: dsh_restart tool')
533
658
  mounted.set(agent, dispose)
659
+ seedOneLiveTurn(agent, agent && agent.session)
534
660
  mark(`mounted on agent ${agent.id}`)
535
661
  const wake = takeWakeFor(agent.id)
536
662
  if (wake) wakeAgent(agent, wake)
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "自动重启",
3
3
  "description": "改完插件/配置后让 DSH 自己重启一次,并自动拉起离线会话(原 dsh-restart 插件,0.9.0 起并入)",
4
- "version": "0.3.29"
4
+ "version": "0.3.40"
5
5
  }
@@ -18,9 +18,19 @@ export const DEFAULT_PORT = Number(process.env.DSH_RESTART_PORT || 19387)
18
18
  // (工具调用期间会掉出列表)和 transcript mtime(长工具调用期间不写盘)都准。
19
19
  const HERE = path.dirname(fileURLToPath(import.meta.url))
20
20
  const HEARTBEAT_FILE = process.env.DSH_BUSY_HEARTBEAT || path.join(HERE, 'logs', 'busy-agents.json')
21
- const HEARTBEAT_FRESH_MS = Number(process.env.DSH_BUSY_HEARTBEAT_MS || 8000)
21
+ // 快照有时效:超过这个时长没刷新,快照就不是「没人忙」,而是「不知道」——不能当空闲用。
22
+ const HEARTBEAT_FRESH_MS = Number(process.env.DSH_BUSY_HEARTBEAT_MS || 30000)
22
23
 
24
+ // id 比对:现网 helper 传完整 `session-xxxxxxxx-…`,但 term_list 与 CLI 手输常用 8 位短 id。
25
+ // 比较时剥掉 session- 前缀后,允许「一方是另一方的前缀」——短 id 只在前缀唯一时才安全,
26
+ // 而多算一个「忙」远好过漏掉自己(漏掉自己 = 把自己当别人 → 永远 busy)。
23
27
  const norm = (id) => String(id ?? '').replace(/^session-/, '')
28
+ const sameSession = (a, b) => {
29
+ const x = norm(a)
30
+ const y = norm(b)
31
+ if (!x || !y) return false
32
+ return x === y || x.startsWith(y) || y.startsWith(x)
33
+ }
24
34
 
25
35
  function httpGetJson(port, pathName, timeoutMs) {
26
36
  return new Promise((resolve) => {
@@ -91,7 +101,7 @@ export function sessionFiles({ dshHome = process.env.DSH_HOME || path.join(proce
91
101
  /** 最近 recentMs 内被写过 transcript 的其他会话;没有则 null。 */
92
102
  export function recentSessionWriter({ selfSessionId, recentMs = QUIET_MS, dshHome } = {}) {
93
103
  const self = norm(selfSessionId)
94
- const files = sessionFiles({ dshHome }).filter((f) => f.sessionId !== self)
104
+ const files = sessionFiles({ dshHome }).filter((f) => !sameSession(f.sessionId, self))
95
105
  if (files.length === 0) return null
96
106
  files.sort((a, b) => b.mtimeMs - a.mtimeMs)
97
107
  const newest = files[0]
@@ -108,7 +118,7 @@ export async function othersBusy({ selfSessionId, port = DEFAULT_PORT, recentMs
108
118
  if (agents) {
109
119
  sources.push('market/status:runningAgents')
110
120
  for (const id of agents) {
111
- if (id !== self) busy.push({ sessionId: id, why: 'running-agent' })
121
+ if (!sameSession(id, self)) busy.push({ sessionId: id, why: 'running-agent' })
112
122
  }
113
123
  } else {
114
124
  sources.push('market/status:unavailable')
@@ -117,7 +127,7 @@ export async function othersBusy({ selfSessionId, port = DEFAULT_PORT, recentMs
117
127
  const recent = recentSessionWriter({ selfSessionId: self, recentMs, dshHome })
118
128
  if (recent) {
119
129
  sources.push(`transcript-mtime(${recentMs}ms)`)
120
- if (!busy.some((b) => norm(b.sessionId) === norm(recent.sessionId))) {
130
+ if (!busy.some((b) => sameSession(b.sessionId, recent.sessionId))) {
121
131
  busy.push({ sessionId: recent.sessionId, why: 'transcript-write' })
122
132
  }
123
133
  } else {
@@ -130,31 +140,80 @@ export async function othersBusy({ selfSessionId, port = DEFAULT_PORT, recentMs
130
140
  } else {
131
141
  sources.push('heartbeat:busy-agents')
132
142
  for (const item of hb.busy) {
133
- if (!busy.some((b) => norm(b.sessionId) === norm(item.sessionId))) busy.push(item)
143
+ if (!busy.some((b) => sameSession(b.sessionId, item.sessionId))) busy.push(item)
144
+ }
145
+ }
146
+
147
+ // 两个权威信号同时拿不到 = 不知道,不等于空闲。
148
+ // 拿一份过期快照当「全空闲」正是旧 runningAgents 那个 bug 的形状,所以这里要 fail-closed。
149
+ const noSignal = !agents && hb.stale
150
+ if (noSignal) sources.push('unknown:no-authoritative-signal')
151
+
152
+ // 第二个变体:心跳「新鲜但瞎」。插件侧 inTurn 一度恒为 false(读的是 session.events 快照,
153
+ // 不跟实时回合),此时心跳每 2s 刷新、看起来完全健康,却永远报「没人忙」。
154
+ // 市场接口同时报了 runningAgents 就说明它不可信 —— 一致性判据,比新鲜度更能防静默失效。
155
+ // 区分两种「心跳全 false」:
156
+ // - producer !== 'event':生产端只拿到快照,全 false 不能证明没人忙 → 判 unknown(fail-closed);
157
+ // - producer === 'event':事件流是活的,全 false 就是权威的「没人忙」→ 市场里的陈旧残影
158
+ // (崩溃前留下的 runningAgents)记进 staleMarket,但不参与 blocking。
159
+ const heartbeatAuthoritative = !hb.stale && hb.producer === 'event'
160
+ // 只看「别的会话」:自己永远在回合里(正在跑这条命令),拿市场里的自己当「有人忙」的证据,
161
+ // 会把「只有我在跑」也判成 blind;同理 self 已在上面从 busy 里排掉了。
162
+ const marketOthers = (agents || []).filter((id) => !sameSession(id, self))
163
+ const heartbeatBlind = !hb.stale && marketOthers.length > 0 && hb.inTurnCount === 0 && !heartbeatAuthoritative
164
+ if (heartbeatBlind) sources.push('unknown:heartbeat-blind')
165
+
166
+ const staleMarket = []
167
+ if (heartbeatAuthoritative) {
168
+ for (let i = busy.length - 1; i >= 0; i -= 1) {
169
+ const item = busy[i]
170
+ if (item.why !== 'running-agent') continue
171
+ const known = hb.states.get(norm(item.sessionId))
172
+ // event 与 snapshot 的 false 都算权威:宿主 snapshotEvents() 返回 0→seq 的完整已提交事件,
173
+ // 扫到 turn/end 是正向观测,不是「没找到标记就猜 false」。seed 只会是 true,不在此列。
174
+ // 若宿主将来把快照改成有界窗口,这里必须收回成只认 'event'。
175
+ if (known && known.inTurn === false && (known.src === 'event' || known.src === 'snapshot')) {
176
+ staleMarket.push(item)
177
+ busy.splice(i, 1)
178
+ }
134
179
  }
180
+ if (staleMarket.length) sources.push('market/status:stale-ignored')
135
181
  }
136
182
 
137
- return { busy, sources, checkedAt: new Date().toISOString() }
183
+ const unknown = noSignal || heartbeatBlind
184
+
185
+ return { busy, sources, unknown, heartbeatBlind, staleMarket, producer: hb.producer, eventsSeen: hb.eventsSeen, checkedAt: new Date().toISOString() }
138
186
  }
139
187
 
140
188
  /** 读插件写的 busy-agents.json:文件够新才采信,其中 inTurn 的会话都算忙。 */
141
189
  export function heartbeatBusy({ selfSessionId, heartbeatFile = HEARTBEAT_FILE, freshMs = HEARTBEAT_FRESH_MS } = {}) {
142
190
  try {
143
191
  const stat = fs.statSync(heartbeatFile)
144
- if (Date.now() - stat.mtimeMs > freshMs) return { stale: true, busy: [] }
192
+ if (Date.now() - stat.mtimeMs > freshMs) return { stale: true, busy: [], inTurnCount: 0, total: 0, producer: null, eventsSeen: 0, states: new Map() }
145
193
  const raw = fs.readFileSync(heartbeatFile, 'utf8').replace(/^\uFEFF/, '')
146
194
  const data = JSON.parse(raw)
147
195
  const agents = Array.isArray(data && data.agents) ? data.agents : []
196
+ const producer = typeof data?.producer === 'string' ? data.producer : null
197
+ const eventsSeen = Number(data?.eventsSeen || 0)
148
198
  const self = norm(selfSessionId)
149
199
  const busy = []
200
+ const states = new Map()
201
+ let inTurnCount = 0
150
202
  for (const agent of agents) {
151
203
  const id = String((agent && agent.sessionId) || '')
152
- if (!id || norm(id) === self) continue
153
- if (agent && agent.inTurn === true) busy.push({ sessionId: id, why: 'agent-in-turn' })
204
+ if (!id) continue
205
+ const src = agent && typeof agent.src === 'string' ? agent.src : null
206
+ states.set(norm(id), { inTurn: agent && agent.inTurn === true, src })
207
+ if (agent && agent.inTurn === true) {
208
+ inTurnCount += 1
209
+ if (sameSession(id, self)) continue
210
+ const sinceMs = Number(agent.sinceMs || 0)
211
+ busy.push({ sessionId: id, why: 'agent-in-turn', ...(sinceMs > 0 ? { sinceMs } : {}) })
212
+ }
154
213
  }
155
- return { stale: false, busy }
214
+ return { stale: false, busy, inTurnCount, total: agents.length, producer, eventsSeen, states }
156
215
  } catch {
157
- return { stale: true, busy: [] }
216
+ return { stale: true, busy: [], inTurnCount: 0, total: 0, producer: null, eventsSeen: 0, states: new Map() }
158
217
  }
159
218
  }
160
219
 
@@ -177,28 +236,51 @@ export async function waitQuiet({
177
236
  // 探测模式(maxWaitMs=0)只问「现在静不静」;否则要攒够 quietMs 的连续静默,
178
237
  // 但等待上限比静默窗口还短时,以等待上限为准,避免永远等不到。
179
238
  const need = maxWaitMs === 0 ? 0 : Math.min(quietMs, maxWaitMs)
239
+ // 信号缺席通常不会「等一会就好」:给它一个短上限,早失败早可行动。
240
+ const UNKNOWN_CAP_MS = Number(process.env.DSH_RESTART_UNKNOWN_WAIT_MS || 60000)
180
241
  let quietSince = null
181
242
  let last = null
243
+ let sawUnknown = false
244
+ let unknownSince = null
182
245
 
183
246
  for (;;) {
184
247
  last = await othersBusy({ selfSessionId, port, recentMs, dshHome })
185
- if (last.busy.length === 0) {
248
+ if (last.unknown) {
249
+ // 不知道 ≠ 静默:当作「不安静」继续等,等不到就报 unknown,让调用方 fail-closed。
250
+ sawUnknown = true
251
+ unknownSince ??= Date.now()
252
+ quietSince = null
253
+ if (Date.now() - unknownSince >= UNKNOWN_CAP_MS) {
254
+ return {
255
+ ok: false,
256
+ waitedMs: Date.now() - startedAt,
257
+ busy: last.busy,
258
+ sources: last.sources,
259
+ unknown: true,
260
+ reason: 'busy-unknown',
261
+ checkedAt: last.checkedAt,
262
+ }
263
+ }
264
+ } else if (last.busy.length === 0) {
186
265
  quietSince ??= Date.now()
187
266
  const quietFor = Date.now() - quietSince
188
267
  if (quietFor >= need) {
189
- return { ok: true, waitedMs: Date.now() - startedAt, busy: [], sources: last.sources, checkedAt: last.checkedAt }
268
+ return { ok: true, waitedMs: Date.now() - startedAt, busy: [], sources: last.sources, unknown: false, checkedAt: last.checkedAt }
190
269
  }
191
270
  } else {
192
271
  quietSince = null
193
272
  }
194
273
 
195
- if (onPoll) onPoll({ elapsedMs: Date.now() - startedAt, quietSince, busy: last.busy, sources: last.sources })
274
+ if (onPoll) onPoll({ elapsedMs: Date.now() - startedAt, quietSince, busy: last.busy, sources: last.sources, unknown: last.unknown })
196
275
  if (Date.now() - startedAt >= maxWaitMs) {
197
276
  return {
198
277
  ok: false,
199
278
  waitedMs: Date.now() - startedAt,
200
279
  busy: last.busy,
201
280
  sources: last.sources,
281
+ unknown: sawUnknown && last.unknown,
282
+ staleMarket: last.staleMarket || [],
283
+ reason: last.unknown ? 'busy-unknown' : 'others-busy',
202
284
  checkedAt: last.checkedAt,
203
285
  }
204
286
  }
@@ -227,5 +309,5 @@ if (import.meta.url === `file:///${process.argv[1]?.replace(/\\/g, '/')}`) {
227
309
  } catch { /* 写不进去就算了:窗口那边读不到会当作“未知”,不误取消 */ }
228
310
  }
229
311
  console.log(text)
230
- process.exit(res.ok ? 0 : 3)
312
+ process.exit(res.ok ? 0 : res.unknown ? 4 : 3)
231
313
  }
@@ -5,6 +5,7 @@
5
5
  import { spawn, execFileSync } from 'node:child_process';
6
6
  import fs from 'node:fs';
7
7
  import net from 'node:net';
8
+ import os from 'node:os';
8
9
  import path from 'node:path';
9
10
  import { fileURLToPath } from 'node:url';
10
11
  import { waitQuiet } from './busy-check.mjs';
@@ -12,6 +13,9 @@ import { waitQuiet } from './busy-check.mjs';
12
13
  const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
13
14
  const LOGS_DIR = path.join(MODULE_DIR, 'logs');
14
15
  const REQUEST_FILE = path.join(MODULE_DIR, 'request.json');
16
+ // 单实例锁:mshta 迟到(被安全软件延迟放行)与 CIM 直拉 helper 兜底同时发生时,
17
+ // 两个 helper 都去 taskkill + 拉起,会把刚起来的 DSH 再杀一次。锁放总线目录,跨进程/会话可见。
18
+ const LOCK_FILE = path.join(MODULE_DIR, 'helper.lock');
15
19
  const APP_EXE = 'F:\\dsh\\DeepSeek Harness.exe';
16
20
  const APP_DIR = 'F:\\dsh';
17
21
  const APP_IMAGE = 'DeepSeek Harness.exe';
@@ -35,6 +39,165 @@ function log(msg) {
35
39
  try { console.log(line); } catch {}
36
40
  }
37
41
 
42
+ // 「这次到底怎么起的、最后退出码多少」只留在 trigger 的 stdout 里,重启后没人能复查——
43
+ // 而守卫/兜底路径恰恰是最容易悄悄退化的一环。把 launchMode、父进程名与退出码写进当次 helper 日志尾行。
44
+ // launchMode 优先取 trigger 显式传的 --launch-mode=,其次按父进程名推断(mshta 的 Run / cmd / WMI 直拉)。
45
+ function parentImage(pid) {
46
+ try {
47
+ const out = execFileSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { encoding: 'utf8', windowsHide: true });
48
+ const m = out.match(/^"([^"]+)"/m);
49
+ return m ? m[1] : '';
50
+ } catch { return ''; }
51
+ }
52
+ const ARG_MODE = (() => {
53
+ const hit = process.argv.slice(2).find((a) => a.startsWith('--launch-mode='));
54
+ return hit ? hit.slice('--launch-mode='.length) : '';
55
+ })();
56
+ const PARENT_IMAGE = parentImage(process.ppid);
57
+ const LAUNCH_MODE = ARG_MODE || {
58
+ 'mshta.exe': 'mshta',
59
+ 'cmd.exe': 'cmd-window',
60
+ 'WmiPrvSE.exe': 'cim-direct',
61
+ 'node.exe': 'node-direct',
62
+ }[PARENT_IMAGE] || (PARENT_IMAGE ? `parent:${PARENT_IMAGE}` : 'unknown');
63
+ log(`launch: mode=${LAUNCH_MODE} parent=${PARENT_IMAGE || '?'} pid=${process.pid} ppid=${process.ppid}`);
64
+ process.on('exit', (code) => log(`exit code=${code}`));
65
+
66
+ // result.json 是「最近一次」的状态;再留一份带时间戳的进 logs\ 供排障翻查(trigger 清理 request.json 后尤其有用)。
67
+ const RESULT_FILE = path.join(LOGS_DIR, 'result.json');
68
+ // 带时间戳的 result 只留最近 RESULT_KEEP 份,否则 logs\ 一年后会堆几千个小文件。
69
+ const RESULT_KEEP = 50;
70
+ function pruneStampedResults() {
71
+ let names = [];
72
+ try { names = fs.readdirSync(LOGS_DIR); } catch { return; }
73
+ const stamped = names
74
+ .filter((name) => /^result-\d{8}-\d{6}(-\d+)?\.json$/.test(name))
75
+ .map((name) => {
76
+ let mtime = 0;
77
+ try { mtime = fs.statSync(path.join(LOGS_DIR, name)).mtimeMs; } catch {}
78
+ return { name, mtime };
79
+ })
80
+ .sort((a, b) => b.mtime - a.mtime);
81
+ for (const item of stamped.slice(RESULT_KEEP)) {
82
+ try { fs.unlinkSync(path.join(LOGS_DIR, item.name)); } catch {}
83
+ }
84
+ }
85
+
86
+ function writeResult(result) {
87
+ const body = JSON.stringify(result, null, 2);
88
+ try { fs.writeFileSync(RESULT_FILE, body, 'utf8'); } catch (err) { log(`write result.json failed: ${err.message}`); }
89
+ try { fs.writeFileSync(path.join(LOGS_DIR, `result-${stampName()}.json`), body, 'utf8'); } catch {}
90
+ pruneStampedResults();
91
+ }
92
+
93
+ // 清掉「本次」请求:只删 createdAt 与我们读到的那份相同的文件,避免把新排程删掉。
94
+ // 不删的话,安全软件延迟放行 mshta 时,迟到 helper 会读到一份 scheduledAt 已过期的请求,
95
+ // 于是宿主刚重启就又被杀一次。duplicate-helper 路径故意不清(留给正牌 helper)。
96
+ function clearRequest(mine) {
97
+ if (!mine || !mine.createdAt) return;
98
+ try {
99
+ const cur = JSON.parse(fs.readFileSync(REQUEST_FILE, 'utf8').replace(/^\uFEFF/, ''));
100
+ if (cur && cur.createdAt === mine.createdAt) {
101
+ fs.unlinkSync(REQUEST_FILE);
102
+ log('request.json cleared (this run)');
103
+ }
104
+ } catch { /* 文件不在或正被改写:都无需处理 */ }
105
+ }
106
+
107
+ // ---- 单实例锁 ----
108
+ // 判活靠 mtime 心跳:helper 活着就每 10s 刷一次锁文件 mtime,所以锁龄超过 STALE 就意味着
109
+ // 持锁进程已经死了(含跨主机残留、PID 复用、进程假活三种情况,一概不用查进程启动时间)。
110
+ // 2 分钟足够覆盖 helper 一次最长动作(杀进程 + 等退出 + 拉 App + 轮询端口,实测 < 60s)。
111
+ const LOCK_STALE_MS = 120 * 1000;
112
+ const LOCK_HEARTBEAT_MS = 10 * 1000;
113
+ let lockHeartbeat = null;
114
+
115
+ function startLockHeartbeat() {
116
+ if (lockHeartbeat) return;
117
+ lockHeartbeat = setInterval(() => {
118
+ try { fs.utimesSync(LOCK_FILE, new Date(), new Date()); } catch { /* 锁被删了就自然停 */ }
119
+ }, LOCK_HEARTBEAT_MS);
120
+ if (lockHeartbeat.unref) lockHeartbeat.unref();
121
+ }
122
+
123
+ function stopLockHeartbeat() {
124
+ if (!lockHeartbeat) return;
125
+ clearInterval(lockHeartbeat);
126
+ lockHeartbeat = null;
127
+ }
128
+
129
+ function pidAlive(pid) {
130
+ if (!Number.isFinite(pid) || pid <= 0) return false;
131
+ try { process.kill(pid, 0); return true; } catch (err) { return !!err && err.code === 'EPERM'; }
132
+ }
133
+
134
+ function readLock() {
135
+ try { return JSON.parse(fs.readFileSync(LOCK_FILE, 'utf8').replace(/^\uFEFF/, '')); } catch { return null; }
136
+ }
137
+
138
+ // 抢锁成功返回 true。锁里写 pid + 抢锁时刻;pid 已死、或锁龄超过 2 分钟(防 PID 复用)即视为 stale。
139
+ // 例外:系统休眠/合盖会让所有定时器停摆,唤醒后锁龄可能一下跳到几小时,但持锁 helper 其实还活着。
140
+ // 所以「读起来 stale 且 pid 还活着」时不当场抢,先等一个确认窗口再看心跳有没有恢复。
141
+ const LOCK_CONFIRM_MS = 12 * 1000;
142
+ async function acquireLock() {
143
+ const mine = { pid: process.pid, host: os.hostname(), at: new Date().toISOString(), argv: process.argv.slice(2) };
144
+ let confirmedSleep = false;
145
+ for (let attempt = 0; attempt < 3; attempt += 1) {
146
+ try {
147
+ const fd = fs.openSync(LOCK_FILE, 'wx');
148
+ fs.writeFileSync(fd, JSON.stringify(mine, null, 2), 'utf8');
149
+ fs.closeSync(fd);
150
+ log(`lock acquired pid=${process.pid}`);
151
+ return true;
152
+ } catch (err) {
153
+ if (err.code !== 'EEXIST') { log(`lock open failed: ${err.code || err.message}`); return false; }
154
+ const old = readLock();
155
+ let ageMs = 0;
156
+ try { ageMs = Date.now() - fs.statSync(LOCK_FILE).mtimeMs; } catch {}
157
+ const sameHost = !old || !old.host || old.host === os.hostname();
158
+ if (!sameHost) {
159
+ log(`lock held by another host (${old.host}); this instance exits to avoid a cross-machine double restart`);
160
+ return false;
161
+ }
162
+ const alive = !!(old && pidAlive(Number(old.pid)));
163
+ // 先看心跳:锁龄超时就是死锁,哪怕 pid 还活着(PID 被复用、进程假活都归这一类)。
164
+ if (ageMs >= LOCK_STALE_MS) {
165
+ if (alive && !confirmedSleep) {
166
+ confirmedSleep = true;
167
+ log(`stale lock by heartbeat but pid=${old.pid} alive (age=${Math.round(ageMs / 1000)}s); possible system sleep, waiting ${LOCK_CONFIRM_MS / 1000}s to see if heartbeat resumes`);
168
+ await sleep(LOCK_CONFIRM_MS);
169
+ let again = Infinity;
170
+ try { again = Date.now() - fs.statSync(LOCK_FILE).mtimeMs; } catch {}
171
+ if (again < LOCK_STALE_MS) {
172
+ log(`heartbeat resumed (age=${Math.round(again / 1000)}s); this instance exits without touching DSH`);
173
+ return false;
174
+ }
175
+ log(`heartbeat still stalled after confirm window (age=${Math.round(again / 1000)}s); treating as hung and stealing lock`);
176
+ } else {
177
+ log(`stale lock by heartbeat (pid=${old && old.pid} alive=${alive} age=${Math.round(ageMs / 1000)}s); removing and retrying`);
178
+ }
179
+ try { fs.unlinkSync(LOCK_FILE); } catch {}
180
+ continue;
181
+ }
182
+ if (alive) {
183
+ log(`lock busy: helper pid=${old.pid} alive since ${old.at} (age=${Math.round(ageMs / 1000)}s); this instance exits without touching DSH`);
184
+ return false;
185
+ }
186
+ log(`stale lock (pid=${old && old.pid} alive=${alive} age=${Math.round(ageMs / 1000)}s); removing and retrying`);
187
+ try { fs.unlinkSync(LOCK_FILE); } catch {}
188
+ }
189
+ }
190
+ return false;
191
+ }
192
+
193
+ function releaseLock() {
194
+ stopLockHeartbeat();
195
+ try {
196
+ const old = readLock();
197
+ if (old && Number(old.pid) === process.pid) fs.unlinkSync(LOCK_FILE);
198
+ } catch { /* 已经不在或读不出来都无所谓 */ }
199
+ }
200
+
38
201
  function readRequest() {
39
202
  try {
40
203
  const raw = fs.readFileSync(REQUEST_FILE, 'utf8').replace(/^\uFEFF/, '');
@@ -142,6 +305,7 @@ async function waitUp(limitMs, tag) {
142
305
  }
143
306
 
144
307
  async function main() {
308
+ const hasRequest = fs.existsSync(REQUEST_FILE);
145
309
  const req = readRequest();
146
310
  const delaySec = Number.isFinite(Number(req.delaySec)) ? Number(req.delaySec) : 45;
147
311
  const dryRun = req.dryRun === true;
@@ -152,6 +316,27 @@ async function main() {
152
316
  log(`request: ${JSON.stringify(req)}`);
153
317
  log(`target kill time: ${new Date(targetMs).toISOString()} (scheduledAt=${scheduledAt})`);
154
318
 
319
+ // 没有 request.json 就没有「待执行的重启」:迟到 helper(安全软件延迟放行 mshta)不能凭默认值
320
+ // 自己去杀宿主 —— 那会在刚重启完的机器上再杀一次。直接退出,不碰锁、不碰进程。
321
+ if (!hasRequest) {
322
+ log('no request.json: no pending restart; exiting without touching DSH');
323
+ writeResult({ ok: false, dryRun, reason: 'no-request', at: new Date().toISOString(), logFile });
324
+ return 0;
325
+ }
326
+
327
+ // 单实例锁:没有锁就不动 DSH。第二个 helper(mshta 迟到 / CIM 兜底)只写日志后退出。
328
+ if (!(await acquireLock())) {
329
+ const result = { ok: false, dryRun, reason: 'duplicate-helper', at: new Date().toISOString(), logFile, note: '另一个 helper 已持有 helper.lock,本次不重复杀进程' };
330
+ writeResult(result);
331
+ return 0;
332
+ }
333
+ process.on('exit', releaseLock);
334
+ // 信号也要留痕:exit 钩子只在正常退出时跑,被 TerminateProcess 强杀时一行都不会有。
335
+ // 所以「日志里有 launch: 但没有 exit code=」= 被外部杀掉(或整机断电),不是逻辑失败——读日志时这条要当信号看。
336
+ process.on('SIGINT', () => { log('signal SIGINT received; releasing lock and exiting (130)'); releaseLock(); process.exit(130); });
337
+ process.on('SIGTERM', () => { log('signal SIGTERM received; releasing lock and exiting (143)'); releaseLock(); process.exit(143); });
338
+ startLockHeartbeat(); // 从抢到锁到进程退出为止,每 10s 刷一次 mtime
339
+
155
340
  const before = appProcesses();
156
341
  log(`pre-kill app processes: ${JSON.stringify(before)}`);
157
342
  log(`pre-kill port ${PORT}: ${(await portOpen()) ? 'open' : 'closed'}`);
@@ -170,7 +355,8 @@ async function main() {
170
355
  if (!fs.existsSync(REQUEST_FILE)) {
171
356
  log('ABORT: request.json 已被撤销(cancel),本次重启作废');
172
357
  const result = { ok: false, dryRun, reason: 'cancelled', at: new Date().toISOString(), logFile };
173
- fs.writeFileSync(path.join(LOGS_DIR, 'result.json'), JSON.stringify(result, null, 2), 'utf8');
358
+ writeResult(result);
359
+ clearRequest(req);
174
360
  return 0;
175
361
  }
176
362
  let cur = null;
@@ -178,7 +364,8 @@ async function main() {
178
364
  if (cur && req.createdAt && cur.createdAt && cur.createdAt !== req.createdAt) {
179
365
  log('ABORT: request.json 已被新排程替换(superseded),本次重启作废');
180
366
  const result = { ok: false, dryRun, reason: 'superseded', at: new Date().toISOString(), logFile };
181
- fs.writeFileSync(path.join(LOGS_DIR, 'result.json'), JSON.stringify(result, null, 2), 'utf8');
367
+ writeResult(result);
368
+ clearRequest(req);
182
369
  return 0;
183
370
  }
184
371
  if (fs.existsSync(path.join(LOGS_DIR, 'now.json'))) {
@@ -191,7 +378,8 @@ async function main() {
191
378
  if (dryRun) {
192
379
  const result = { ok: true, dryRun: true, at: new Date().toISOString(), exeExists: fs.existsSync(APP_EXE), port: (await portOpen()) ? 'open' : 'closed', procs: appProcesses() };
193
380
  log(`DRY-RUN result: ${JSON.stringify(result)}`);
194
- fs.writeFileSync(path.join(LOGS_DIR, 'result.json'), JSON.stringify(result, null, 2), 'utf8');
381
+ writeResult(result);
382
+ clearRequest(req);
195
383
  return 0;
196
384
  }
197
385
 
@@ -235,10 +423,42 @@ async function main() {
235
423
  }
236
424
  log(`idle guard result: ${JSON.stringify(quiet)}`);
237
425
  if (!quiet.ok) {
238
- const result = { ok: false, dryRun: false, reason: 'others-busy', busy: quiet.busy, waitedMs: quiet.waitedMs, at: new Date().toISOString(), logFile };
239
- fs.writeFileSync(path.join(LOGS_DIR, 'result.json'), JSON.stringify(result, null, 2), 'utf8');
240
- log(`ABORT: other sessions still busy, restart cancelled: ${JSON.stringify(quiet.busy)}`);
241
- return 3;
426
+ // 区分三种失败:有人真的在忙(others-busy)、读不到权威信号(busy-unknown)、宿主已死(host-down)。
427
+ // 后者以前会被当成「信号缺席」abort——但宿主已经退出时根本不存在「其他会话在跑」,
428
+ // fail-closed 在这里会犯「把死掉的宿主当成忙碌的宿主」,于是唯一能救它的操作永远不执行。
429
+ const unknown = quiet.unknown === true || quiet.reason === 'busy-unknown';
430
+ const reason = unknown ? 'busy-unknown' : 'others-busy';
431
+ const hostDown = unknown && !(await portOpen());
432
+ if (hostDown) {
433
+ log(`idle guard: signals 缺席但端口 ${PORT} 不通 → 宿主已退出(host-down),按需要重启继续`);
434
+ } else {
435
+ // 粘住的 inTurn(漏写 turn/end)会让 busy 永远为真、重启永远失败,而重启恰恰是解法。
436
+ // 这里不解除 blocking(宁等不误杀),只把「疑似卡住」写进结果,让报错可行动。
437
+ const STUCK_MS = 15 * 60 * 1000;
438
+ const stuck = (quiet.busy || []).filter((b) => Number(b && b.sinceMs) > STUCK_MS);
439
+ const result = {
440
+ ok: false,
441
+ dryRun: false,
442
+ reason,
443
+ busy: quiet.busy,
444
+ stuckCandidates: stuck.length ? stuck : undefined,
445
+ sources: quiet.sources,
446
+ waitedMs: quiet.waitedMs,
447
+ at: new Date().toISOString(),
448
+ logFile,
449
+ note: unknown
450
+ ? '市场接口与心跳都读不到,无法确认其他会话是否在跑;为免误杀先不重启'
451
+ : (stuck.length
452
+ ? `有会话已在回合中超过 ${Math.round(STUCK_MS / 60000)} 分钟(可能是漏写 turn/end 的粘住状态);为免误杀仍不重启,可考虑 force 或等它收尾`
453
+ : undefined),
454
+ };
455
+ if (stuck.length) log(`stuck candidates: ${stuck.length} (${stuck.map((b) => b.sessionId).join(', ')})`);
456
+ writeResult(result);
457
+ clearRequest(req);
458
+ log(`ABORT: ${unknown ? '无法确认其他会话是否在忙(signals 缺席)' : 'other sessions still busy'}, restart cancelled: ${JSON.stringify(quiet.busy)} sources=${JSON.stringify(quiet.sources || [])}${stuck.length ? ` stuck=${JSON.stringify(stuck)}` : ''}`);
459
+ // 两种失败给不同退出码:调用方不读 note 也能分清「稍后再试」(3)与「信号坏了,请检查」(4)。
460
+ return unknown ? 4 : 3;
461
+ }
242
462
  }
243
463
  } else {
244
464
  log('idle guard: skipped (force=true, 用户已同意)');
@@ -313,7 +533,8 @@ async function main() {
313
533
  procs: appProcesses(),
314
534
  logFile,
315
535
  };
316
- fs.writeFileSync(path.join(LOGS_DIR, 'result.json'), JSON.stringify(result, null, 2), 'utf8');
536
+ writeResult(result);
537
+ clearRequest(req);
317
538
  log(`RESULT: ${JSON.stringify(result)}`);
318
539
  ui(result.ok ? 'done' : 'failed', result.reason || (result.ok ? 'ok' : 'failed'));
319
540
  return up.ok ? 0 : 1;
@@ -321,6 +542,6 @@ async function main() {
321
542
 
322
543
  main().then((code) => process.exit(code)).catch((err) => {
323
544
  log(`FATAL: ${err && err.stack ? err.stack : err}`);
324
- try { fs.writeFileSync(path.join(LOGS_DIR, 'result.json'), JSON.stringify({ ok: false, error: String(err), logFile }, null, 2), 'utf8'); } catch {}
545
+ try { writeResult({ ok: false, error: String(err), logFile }); } catch {}
325
546
  process.exit(2);
326
547
  });
@@ -4,8 +4,16 @@ rem ASCII only on purpose: this .cmd must never contain non-ASCII comments,
4
4
  rem otherwise cmd.exe decodes them as GBK and prints garbage errors.
5
5
  setlocal
6
6
  set "MODULE_DIR=%~dp0"
7
- set "NODE=C:\Users\Administrator\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe"
8
- if not exist "%NODE%" set "NODE=node"
7
+ rem Resolve a real node.exe without hardcoding the (movable) home directory:
8
+ rem DSH_NODE_EXE -> <home>\dsh-runtimes\<runtime>\dependencies\node\bin\node.exe -> PATH.
9
+ set "NODE="
10
+ if defined DSH_NODE_EXE if exist "%DSH_NODE_EXE%" set "NODE=%DSH_NODE_EXE%"
11
+ if not defined NODE for %%R in ("%DSH_HOME%" "C:\Users\Administrator\.dsh" "F:\dsh-home") do (
12
+ if not defined NODE if exist "%%~R\dsh-runtimes" for /d %%D in ("%%~R\dsh-runtimes\*") do (
13
+ if not defined NODE if exist "%%~D\dependencies\node\bin\node.exe" set "NODE=%%~D\dependencies\node\bin\node.exe"
14
+ )
15
+ )
16
+ if not defined NODE set "NODE=node"
9
17
  if not exist "%MODULE_DIR%logs" mkdir "%MODULE_DIR%logs"
10
18
  "%NODE%" "%MODULE_DIR%dsh-restart-helper.mjs" >> "%MODULE_DIR%logs\helper-console.log" 2>&1
11
19
  set "RC=%ERRORLEVEL%"
@@ -8,6 +8,7 @@
8
8
  //
9
9
  // 退出码:0 已排程 / 3 已有 helper 在跑 / 4 request.json 写失败 / 5 WMI 拉起失败 / 6 参数错误
10
10
  import fs from 'node:fs';
11
+ import os from 'node:os';
11
12
  import path from 'node:path';
12
13
  import { execFileSync } from 'node:child_process';
13
14
  import { fileURLToPath } from 'node:url';
@@ -19,7 +20,31 @@ const REQUEST_FILE = path.join(MODULE_DIR, 'request.json');
19
20
  const PID_FILE = path.join(LOGS_DIR, 'helper.pid');
20
21
  const RUN_CMD = path.join(MODULE_DIR, 'run-helper.cmd');
21
22
  const UI_FILE = path.join(MODULE_DIR, 'countdown.html');
22
- const NODE_EXE = process.env.DSH_NODE_EXE || 'C:\\Users\\Administrator\\.dsh\\dsh-runtimes\\dsh-primary-runtime\\dependencies\\node\\bin\\node.exe';
23
+ // helper 用真 node 拉起(不是 Electron 宿主),所以这里必须解析出一个真实存在的 node.exe。
24
+ // 家目录可能被搬走,不写死路径:候选 = DSH_NODE_EXE → 各 home 根下的 dsh-runtimes。
25
+ const NODE_EXE = (() => {
26
+ const explicit = process.env.DSH_NODE_EXE;
27
+ if (explicit && fs.existsSync(explicit)) return explicit;
28
+ const roots = [
29
+ process.env.DSH_HOME,
30
+ path.join(os.homedir(), '.dsh'),
31
+ 'C:\\Users\\Administrator\\.dsh',
32
+ 'F:\\dsh-home',
33
+ ].filter(Boolean);
34
+ const seen = new Set();
35
+ for (const root of roots) {
36
+ if (!root || seen.has(root.toLowerCase())) continue;
37
+ seen.add(root.toLowerCase());
38
+ const rt = path.join(root, 'dsh-runtimes');
39
+ let dirs = [];
40
+ try { dirs = fs.readdirSync(rt); } catch { continue; }
41
+ for (const d of dirs) {
42
+ const p = path.join(rt, d, 'dependencies', 'node', 'bin', 'node.exe');
43
+ if (fs.existsSync(p)) return p;
44
+ }
45
+ }
46
+ return 'node';
47
+ })();
23
48
 
24
49
  function parseArgs(argv) {
25
50
  const out = { delay: 45, note: '', dryRun: false, cancel: false, force: false, selfSession: '', quietWait: 180, wake: true, wakeText: '', wakeFile: '' };
@@ -128,9 +153,28 @@ try {
128
153
  console.log(`request.json written: ${JSON.stringify(request)}`);
129
154
 
130
155
  function wmiCreate(commandLine) {
131
- const out = execFileSync('wmic', ['process', 'call', 'create', commandLine], { encoding: 'utf8', windowsHide: true, timeout: 60000 });
132
- const mm = out.match(/ProcessId\s*=\s*(\d+)/);
133
- return mm ? mm[1] : null;
156
+ // WMIC 在新版 Windows 上可能被移除或被禁用(本机 wmic.exe 存在但调用返回非 0),
157
+ // 失败时回退到 PowerShell CIM:Invoke-CimMethod Win32_Process Create 拉起的进程归 WmiPrvSE,
158
+ // 同样不在 DSH 进程树里,宿主被杀后不会被连带清理。
159
+ try {
160
+ const out = execFileSync('wmic', ['process', 'call', 'create', commandLine], { encoding: 'utf8', windowsHide: true, timeout: 60000 });
161
+ const mm = out.match(/ProcessId\s*=\s*(\d+)/);
162
+ if (mm) return mm[1];
163
+ throw new Error(`wmic 未返回 ProcessId:${String(out).trim().slice(0, 200)}`);
164
+ } catch (err) {
165
+ const first = err && err.message ? err.message.replace(/\r?\n/g, ' ') : String(err);
166
+ console.error(`wmic 不可用(${first}),改用 PowerShell CIM 创建进程`);
167
+ const esc = String(commandLine).replace(/'/g, "''");
168
+ const ps = `$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = '${esc}' }; if ($r.ReturnValue -ne 0) { Write-Output ('FAIL ' + $r.ReturnValue); exit 1 }; Write-Output $r.ProcessId`;
169
+ const out2 = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', ps], {
170
+ encoding: 'utf8',
171
+ windowsHide: true,
172
+ timeout: 60000,
173
+ });
174
+ const mm2 = String(out2).match(/(\d+)/);
175
+ if (!mm2) throw new Error(`CIM create 未返回 ProcessId:${String(out2).trim()}`);
176
+ return mm2[1];
177
+ }
134
178
  }
135
179
 
136
180
  // 倒计时窗口要贴在 DSH 窗口正中间(而不是屏幕正中):先取一次 DSH 主窗口矩形写到 logs\ui-geom.json,
@@ -158,18 +202,59 @@ function writeWindowGeom() {
158
202
  const geom = writeWindowGeom();
159
203
  console.log(`window geom: ${JSON.stringify(geom)}`);
160
204
 
161
- // 优先用 mshta 打开倒计时窗口(GUI 子系统,不会弹控制台);窗口自身在到点前 2.5 s 用
162
- // WScript.Shell.Run(窗口隐藏) 拉起 node helper。mshta 不可用时回退到老的 cmd 链。
205
+ // 起法优先级:mshta 倒计时窗口(有 UI)→ CIM 直拉 node helper(无界面兜底)→ cmd 链。
206
+ // 安全软件(火绒)可能拦下/延迟放行 mshta:WMI create 照样返回 ProcessId,但进程瞬间消失。
207
+ // 所以每次起完都要看它是否真的活着,否则会「排程成功、到点没人杀」,机主看到的就是「点了没反应」。
208
+ const HELPER_FILE = path.join(MODULE_DIR, 'dsh-restart-helper.mjs');
209
+ const NOUI = process.env.DSH_RESTART_FORCE_NOUI === '1' || process.env.DSH_RESTART_NOUI === '1';
210
+ const LANDING_CHECK_MS = Math.max(1000, Number(process.env.DSH_RESTART_LANDING_MS || 6000));
211
+
212
+ function sleepSync(ms) {
213
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ }
214
+ }
215
+
216
+ function waitLanded(pid, timeoutMs) {
217
+ const deadline = Date.now() + timeoutMs;
218
+ while (Date.now() < deadline) {
219
+ if (alive(pid)) return true;
220
+ sleepSync(250);
221
+ }
222
+ return alive(pid);
223
+ }
224
+
163
225
  let spawnPid = null;
164
226
  let via = null;
165
- if (fs.existsSync(UI_FILE)) {
227
+ if (!NOUI && fs.existsSync(UI_FILE)) {
166
228
  try {
167
- spawnPid = wmiCreate(`mshta.exe "${UI_FILE}"`);
168
- if (spawnPid) via = 'mshta';
229
+ const mshtaPid = wmiCreate(`mshta.exe "${UI_FILE}"`);
230
+ if (mshtaPid && waitLanded(mshtaPid, LANDING_CHECK_MS)) {
231
+ spawnPid = mshtaPid;
232
+ via = 'mshta';
233
+ } else {
234
+ console.error(`mshta 起了 pid=${mshtaPid} 但 ${LANDING_CHECK_MS}ms 内没落地(多半被安全软件拦了),改用无界面兜底`);
235
+ }
169
236
  } catch (err) {
170
237
  console.error(`mshta create failed: ${err.message}`);
171
238
  }
172
239
  }
240
+ if (!spawnPid) {
241
+ // 无界面兜底要真的「无界面」:node.exe 是控制台程序,WMI 直拉会在屏幕上弹一个黑框。
242
+ // conhost.exe --headless 用无窗口的控制台宿主托住它(Win11 22H2+ 自带),黑框就不会出现;
243
+ // 没有 conhost --headless 的老系统才退回直拉(会闪一下窗口,但排程仍然成功)。
244
+ const conhost = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'conhost.exe');
245
+ const headlessCmd = `conhost.exe --headless cmd.exe /c ""${NODE_EXE}" "${HELPER_FILE}" --launch-mode=node-headless"`;
246
+ const plainCmd = `"${NODE_EXE}" "${HELPER_FILE}" --launch-mode=cim-direct`;
247
+ const order = [headlessCmd, plainCmd];
248
+ if (!fs.existsSync(conhost)) order.shift();
249
+ for (const cmd of order) {
250
+ try {
251
+ spawnPid = wmiCreate(cmd);
252
+ if (spawnPid) { via = cmd === plainCmd ? 'node' : 'node-headless'; break; }
253
+ } catch (err) {
254
+ console.error(`node helper create failed: ${err.message}`);
255
+ }
256
+ }
257
+ }
173
258
  if (!spawnPid) {
174
259
  try {
175
260
  spawnPid = wmiCreate(`cmd.exe /c "${RUN_CMD}"`);
@@ -181,6 +266,7 @@ if (!spawnPid) {
181
266
  }
182
267
  if (!spawnPid) { console.error('WMI create 未返回 ProcessId'); process.exit(5); }
183
268
  console.log(`WMI create ok; ${via} pid=${spawnPid}`);
269
+ if (via === 'node') console.log('无界面兜底:窗口没起来,已直接拉起 node helper(到点照样杀进程重拉;取消用 dsh_restart action=cancel)');
184
270
  console.log(`已排程:${new Date(now + opts.delay * 1000).toISOString()} 杀 DSH 并重拉(dryRun=${opts.dryRun})`);
185
271
  console.log(`日志目录:${LOGS_DIR}`);
186
272
  process.exit(0);
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "跨会话",
3
3
  "description": "同一终端里的多个会话之间互通:特征码链、内联邮件、收件箱",
4
- "version": "0.5.2"
4
+ "version": "0.5.3"
5
5
  }
package/lib/bus.js CHANGED
@@ -23,7 +23,7 @@ import path from 'node:path'
23
23
  import crypto from 'node:crypto'
24
24
  import { rotateIfNeeded } from './logs.js'
25
25
 
26
- export const VERSION = '0.9.12'
26
+ export const VERSION = '1.0.0'
27
27
 
28
28
  /** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
29
29
  export function dshHome() {
@@ -402,14 +402,13 @@ export class Bus {
402
402
  await fsp.unlink(path.join(this.presenceDir, `${nodeId}.json`)).catch(() => {})
403
403
  }
404
404
 
405
- async listNodes() {
405
+ /**
406
+ * presence 目录的严格读取:读不到目录就抛错。
407
+ * 只给「要据此决定删不删东西」的调用方用 —— 读失败绝不能被当成全员离线。
408
+ */
409
+ async listNodesStrict() {
406
410
  const out = []
407
- let names = []
408
- try {
409
- names = await fsp.readdir(this.presenceDir)
410
- } catch {
411
- return out
412
- }
411
+ const names = await fsp.readdir(this.presenceDir)
413
412
  for (const name of names) {
414
413
  if (!name.endsWith('.json')) continue
415
414
  const data = await readJson(path.join(this.presenceDir, name))
@@ -420,6 +419,15 @@ export class Bus {
420
419
  return out
421
420
  }
422
421
 
422
+ /** 展示用:读不到就返回空列表(fail-open 只允许用在这里,不能用来做删除决策)。 */
423
+ async listNodes() {
424
+ try {
425
+ return await this.listNodesStrict()
426
+ } catch {
427
+ return []
428
+ }
429
+ }
430
+
423
431
  async cleanupStale(now = Date.now()) {
424
432
  const limit = this.cfg.offlineAfterMs * 3
425
433
  const removed = []
@@ -554,7 +562,11 @@ export class Bus {
554
562
  const chain = await readJson(file)
555
563
  if (!chain || !Array.isArray(chain.members)) return { code, dropped: false, reason: 'no-chain' }
556
564
  if (chain.members.some((m) => m.nodeId === nodeId)) return { code, dropped: false, reason: 'self-member' }
557
- const nodes = await this.listNodes().catch(() => [])
565
+ const nodes = await this.listNodesStrict().catch(() => undefined)
566
+ if (!nodes) {
567
+ // presence 目录读不到 ≠ 全员离线:宁可留着台账,也不能把还在线的链删掉(fail-closed)
568
+ return { code, dropped: false, reason: 'presence-unreadable', members: chain.members.length }
569
+ }
558
570
  const alive = new Map(nodes.map((n) => [n.nodeId, Number(n.updatedAt) || 0]))
559
571
  const limit = Date.now() - this.cfg.offlineAfterMs * 3
560
572
  const anyAlive = chain.members.some((m) => (alive.get(m.nodeId) || 0) >= limit)
@@ -563,7 +575,13 @@ export class Bus {
563
575
  return { code, dropped: true, members: chain.members.length }
564
576
  }
565
577
 
566
- /** 剔掉已经不存在的成员(进程退出 / 长时间没心跳) */
578
+ /**
579
+ * 剔掉已经不存在的成员(进程退出 / 长时间没心跳)。
580
+ *
581
+ * 注意:`nodes` 必须是「确定读到了 presence」的列表。传空数组(例如读失败被吞成 `[]`)
582
+ * 会让 kept 归零、把整份台账删掉。当前唯一调用点是 `lib/index.js` 的 broadcast,
583
+ * 它用 `listNodesStrict()` 前置把关、读不到就中止;新增调用点也必须照做。
584
+ */
567
585
  async pruneChain(code, nodes) {
568
586
  const chain = await readJson(this.chainFile(code))
569
587
  if (!chain || !Array.isArray(chain.members)) return undefined
package/lib/index.js CHANGED
@@ -1187,9 +1187,12 @@ class CrossTerm {
1187
1187
  // 显式指定 sessionId 时不动 —— 那可能是笔误,不能顺手清别的会话。
1188
1188
  if (all) {
1189
1189
  const ghost = await this.bus.leaveChainNode(norm, this.nodeId).catch(() => undefined)
1190
- if (ghost && ghost.removed > 0) {
1191
- await this.note(`unlinked ${norm} [ghost ${ghost.removed}]`)
1192
- return { ok: true, code: norm, removed: ghost.removed, ghosts: ghost.removed, empty: !!ghost.empty, sessions: [] }
1190
+ // ghost.empty 且 removed=0 = 台账里本来就没有成员、文件已被 leaveChainNode 删掉;
1191
+ // 只看 removed>0 会把「文件已删」报成失败(YOCE_C 2026-10-20 报的 addendum)。
1192
+ if (ghost && (ghost.removed > 0 || ghost.empty)) {
1193
+ const how = ghost.removed > 0 ? `ghost ${ghost.removed}` : 'empty chain'
1194
+ await this.note(`unlinked ${norm} [${how}]`)
1195
+ return { ok: true, code: norm, removed: ghost.removed || 0, ghosts: ghost.removed || 0, empty: !!ghost.empty, sessions: [] }
1193
1196
  }
1194
1197
  // 本终端在台账里也没有成员:如果这串码上的成员全部离线,就当无主台账清掉(0.9.10)
1195
1198
  const orphan = await this.bus.dropChainIfOrphan(norm, this.nodeId).catch(() => undefined)
@@ -1197,6 +1200,9 @@ class CrossTerm {
1197
1200
  await this.note(`unlinked ${norm} [orphan chain, ${orphan.members} 个离线成员]`)
1198
1201
  return { ok: true, code: norm, removed: 0, droppedChain: true, empty: true, sessions: [] }
1199
1202
  }
1203
+ if (orphan && orphan.reason === 'presence-unreadable') {
1204
+ return { ok: false, detail: `读不到本机 presence 目录,无法确认「${norm}」的成员是否在线,为免误删先不动它的台账;稍后再试一次` }
1205
+ }
1200
1206
  }
1201
1207
  return { ok: false, detail: `本终端${scope.length ? `的会话 ${this.labelOf(scope[0])}` : ''}没有接在特征码「${norm}」上` }
1202
1208
  }
@@ -1221,7 +1227,12 @@ class CrossTerm {
1221
1227
  const body = typeof text === 'string' ? text.trim() : ''
1222
1228
  const list = (Array.isArray(files) ? files : (files ? [files] : [])).filter((p) => typeof p === 'string' && p.trim())
1223
1229
  if (!body && !list.length) return { ok: false, detail: 'text 和 files 至少要给一个' }
1224
- const nodes = await this.bus.listNodes()
1230
+ const nodes = await this.bus.listNodesStrict().catch(() => undefined)
1231
+ if (!nodes) {
1232
+ // broadcast 会顺手 pruneChain(kept=0 时删台账)。presence 读失败时若按空列表修剪,
1233
+ // 等于把还在线的链当成全员离线删掉 —— 这里直接中止(fail-closed)。
1234
+ return { ok: false, detail: `读不到本机 presence 目录,无法确认「${norm}」的成员是否在线;本次不广播、也不修剪台账,稍后重试` }
1235
+ }
1225
1236
  const chain = await this.bus.pruneChain(norm, nodes)
1226
1237
  if (!chain || !chain.members?.length) {
1227
1238
  return { ok: false, detail: `特征码「${norm}」上没有活着的会话;先用 term_link 让别人接进来` }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crossterm",
3
- "version": "0.9.12",
3
+ "version": "1.0.0",
4
4
  "description": "跨端互通:同一 DSH 账号下的会话、工作区、设备之间互通 —— 特征码链、内联邮件、云端中转站与自动备份",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",