dsh-host-restart
DSH 宿主插件:给模型一个 restart_dsh 工具,重启 dsh 后自动恢复原会话续跑 (DeepSeek Harness)
- Stars
- 0
- Language
- JavaScript
- Created
- Sep 26, 2026
- Updated
- Oct 5, 2026
Introduction
dsh-host-restart
给 DSH 宿主加一个模型可调用的工具 restart_dsh:杀掉当前 dsh 后端 → 拉起新进程 → 新进程自动恢复原会话并注入「已重启」继续对话。
两种宿主各走一条路(v0.6.0 起):web 宿主 = 杀 node 后端 + 常驻看门狗拉起;Electron 桌面端 = 杀掉整棵应用进程树 + 冷启动同一个 exe。
用途:改完宿主插件代码或 cordis patch 后常常要重启 dsh 才生效,而重启会掐断正在进行的会话。 本插件把「重启」和「把会话接回来」合成一步,用户不用刷新页面、不用重新描述上下文。
v0.7.0(2026-10-03,用户要求):新增人工指令
/restart—— 此前重启只有模型工具一个入口, 人想重启得先说服模型去调它。现在同一套流程多了一条人能直接敲的入口(输入框里/restart), 行为与工具完全一致:同样写标记、同样起驱动脚本、重启后同样注入固定文案「dsh已重启,继续」, 交接记录在场时同样让位给dsh-host-sl。实现上是两条入口共用同一个执行体performRestart(会话校验 → 会话检测 → 交接保存 → 写标记 → 起脚本,一个字节都不分叉),差别只有两处:失败文案 前缀(restart_dsh 未执行://restart 未执行:)与成功文案说给谁听(工具那句「请立刻结束本轮回复」 对指令没有意义)。注册走官方commands服务,不进 inject(与slHandoff同款理由:inject 是 "全有才 apply"的硬门),服务缺席/形态不符只写一行日志、工具照常。用例 115 → 131(新增test/restart-command.test.mjs16 条)。⚠ 模块按 URL 缓存 ⇒ 要等下一次 dsh 重启才生效, 而那次重启之后/restart就能用了。见 §15。 v0.6.0(2026-09-30):支持 Electron 桌面端 —— 本机 DSH 于 2026-09-30 从 web 端切到官方桌面端0.2.0-rc.2,而 v0.5.0 的两处判据在桌面端双双失效(实测):桌面端后端是DeepSeek Harness.exe(Electron 的 Node 模式子进程,命令行里是--expose-internals …\app.asar\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\index.js), 既不是node.exe、也没有dsh\lib\bin.js⇒ 旧判据一个进程都杀不掉;看门狗自启已停用 (dsh-watchdog.vbs.disabled)、桌面端也没有 3080 ⇒ 旧的自起分支拼出来的命令行与真实进程无关。 桌面端只有一种可行语义:整树重启(Host 一死,Electron 主进程会走"崩溃恢复"弹原生对话框, 那条路要人点,不能当自动化用)。身份判据从 profile/端口 改成 pid + exe 路径,拉起方式从 看门狗换手 改成 杀掉整棵同 exe 进程树 + 冷启动应用;只杀同一安装路径的应用(用户 2026-09-30 选定)。判据、实测进程树、演练开关与验收判据见 §14。用例 99 → 115(新增test/desktop-mode.test.mjs,16 个用例)。 ⚠ 模块按 URL 缓存 ⇒ 要等下一次应用重启才生效。 v0.5.0(2026-09-29,用户拍板):工具不再有任何参数、注入文案固定。 删掉restart_dsh唯一的参数note(删参数属破坏性变更,故升 minor),工具描述里关于 note 的 表述一并删掉;buildInjectText()保留为导出函数但忽略参数、恒返回固定文案,注入的就是这一句:dsh已重启,继续。重启前调slHandoff.saveAll也不再传note—— 那一步只是为了把对方的 活跃工作表刷到最新;"续跑消息由谁注入"仍按pendingSummary让位,但注入内容里没有本插件的说明。 让位逻辑一个字没动:仍然只让dsh-host-sl说话,本插件仅在对方缺席/没接管时兜底,兜底那条 现在就是上面那句固定文案,不再拼任何别的内容。用例 98 → 99(新增"参数表为空 + 描述不提 note + 调用点不再读 args"的静态用例,并把buildInjectText那条改成"传了 note 也不许被拼进去")。 ⚠ 模块按 URL 缓存 ⇒ 要等下一次 dsh 重启才生效。见 §6 的交付指纹与 §13。 2026-09-29(卸载清理修复,版本号仍是 v0.4.2):把「插件卸载时的清理」从挂不上事件的ctx.on('dispose', …)改成唯一一处ctx.effect(() => () => { … })—— 本机 cordis 卸载 fiber 时 发的是internal/plugin、没有dispose事件,所以旧写法那段清理一次都没跑过(tools.register的 disposer 没被调用、「启动注入」与「兜底复查」两个定时器没被取消),现在卸载时真的回收。 新增 6 条回归用例(test/lifecycle-cleanup.test.mjs)⇒ 全套件 92 → 98 全绿。 ⚠ 模块按 URL 缓存 ⇒ 要等下一次 dsh 重启才生效。见 §4「与「已重启」注入的合并」末条与 §6。 v0.4.2(2026-09-29):把两条注入合并成一次、一轮(用户要求)。旧版一次重启注入两条消息 —— 本插件的「已重启」路径更短(约 4s)必然先跑,dsh-host-sl的【sl 交接续跑】多一步list()判定 (约 6s)排进 next-turn,于是第一轮拿不到交接记录,可能空转或乱做。现在新进程读到自己的重启 标记后先判断「这次有没有dsh-host-sl的待续标记、且其中含本会话尚未处理的条目」:有 ⇒ 本插件 不注入,交给对方那条(它的正文开头就写着「dsh 已重启」;v0.4.2 当时靠note把"下一步" 写进记录正文,v0.5.0 已删掉 note 参数、注入内容里不再有本插件的说明);没有 ⇒ 照旧自己注入。 兜底(必须有):让位之后约 10s 复查一次 —— 对方让位或失败了(标记里仍有本会话条目)且本会话status !== 'running'⇒ 自己补注入一次。判据、取数顺序、日志与验收判据见 §4「与「已重启」注入的 合并」与 §9。 v0.4.1(2026-09-28):apply 阶段那行「重启前的交接保存:slHandoff服务不在场(dsh-host-sl 未装载?)」 是假阴性,已经删掉 —— apply 那一刻同进程的dsh-host-sl还没把服务挂上(服务由它自己的 fiberprovide,时机在 apply 之后),所以ctx.get('slHandoff')必然读不到;而同一个进程里稍后的工具调用 又看得到它(21:38、23:05 两次重启前的saveAll都真的存了盘)。现在改成首次restart_dsh工具 被调用时探测一次(那时服务一定可见),探测结论与真实调用结果不一致时如实区分。见 §5。 v0.4.0(2026-09-28):重启不再被拒绝。原先"检测到别的会话还有活在跑就拒绝"的那层闸门已删除 (用户要求:重启不该被拒绝);检测照旧跑,结果只进日志与工具返回文案 —— 点名会被打断的会话 id、原因, 以及交接记录存没存下。理由、代价(被打断的那一步工具调用救不回来)与文案形状见 §4「其它会话在飞:只告知,不拦截」。 v0.3.0(2026-09-28):发起重启前会先调可选服务slHandoff(由同 profile 的dsh-host-sl提供)存一份会话交接记录 —— 保存结果写进工具返回文案;服务缺席/抛错绝不阻断重启。 语义、顺序与实测耗时见 §4「重启前的交接保存」。 2026-09-28 晚(覆盖所有活跃会话):调用面从save(当前会话)换成saveAll({all:true, noteSessionId, session})(v0.5.0 起不再传note)——dsh-host-sl会枚举 宿主里所有活着的 agent(顶层会话 + 子代理)各存一份交接记录。 本插件仍然只复活发起重启的那个会话,其余会话/子代理的续跑完全靠这份交接(见 §4)。
1. 时序
1.0 两种宿主的差别(v0.6.0)
| web 宿主(原路径) | Electron 桌面端(v0.6.0 新增) | |
|---|---|---|
| 后端进程 | node.exe + dsh\lib\bin.js <profile> --port 3080 | DeepSeek Harness.exe --expose-internals …\dsh-desktop-host\lib\index.js(Electron 主进程的子进程) |
| 身份判据 | pid + profile + 端口 | pid + exe 路径(没有 profile;端口只用于就绪判定) |
| 杀谁 | 命中身份的那一个 dsh 进程 | 同一 exe 的全部进程(主进程 + GPU/渲染/网络/后端 Host/runner),只限这一个安装路径 |
| 怎么起 | 看门狗换手 / 自行拉起 node bin.js | 等旧进程退干净(Electron 单实例锁)→ Start-Process 冷启动同一个 exe |
| 就绪判据 | 端口可应答 + 抓到带 token 的 dsh web: 行 | 端口可应答(默认 19387)+ 主进程在 |
| 脚本参数 | -ProfileName / -Port | -Mode desktop / -DesktopExe <exe> / -Port |
| 额外产物 | dsh-last-url.txt(带 token URL) | 无(桌面端没有 token URL) |
会话续跑那一半两边完全一样:标记文件 + sessionController.resolveAgent + 注入,一个字没改。
模型调用 restart_dsh()(**无参数**,v0.5.0)
│
├─⓪ 检测:其它会话还有活在跑 ⇒ **只告知、不拦截**(v0.4.0,2026-09-28)——
│ 口径三条:自己在本轮运行 / 名下子代理在跑 / 名下后台作业未结算(见 §4);
│ 结果写进日志与工具返回文案(点名会话 id + 原因 + 交接存没存下),然后**照常往下走**
├─⓪.5 **重启前保存交接**(可选服务 slHandoff,由 dsh-host-sl 提供):
│ `saveAll({all:true, noteSessionId, session})` ⇒ **所有活跃会话**(顶层+子代理)各一份记录;
│ await 到保存完成 → 结果(成功+全部记录文件路径 / 失败+原因)进工具返回文案;
│ 服务缺席、形态不符、调用抛错 ⇒ 只写一行日志,**照常往下走**(见 §4)
├─① 插件写标记 $DSH_HOME/storages/dsh-restart/pending.json
│ { sessionId, text:"dsh已重启,继续", createdAt, waitSeconds, pidBefore }
├─② 插件用 WMI(Win32_Process.Create) 启动驱动脚本 —— 脱离 dsh 进程树,
│ 并把**本实例身份**(自己的 pid + profile 名 + 端口)随命令行传给脚本
├─③ 插件确认脚本真的起来了(日志首行出现本次 SessionId)后才返回
└─④ 工具结果要求模型立刻结束本轮输出
│
[dsh-restart.ps1](独立进程,父级是 WmiPrvSE.exe)
a. 单实例保护 → 解析本实例身份(-DshPid/-ProfileName/-Port) → 等 WaitSeconds(固定 2s,只够本次工具结果落盘)
b. 只杀**本实例**的 dsh:精确 pid(命令行已校验)优先 → 退按 profile 名 + 端口扫描 →
仍确定不了 ⇒ 退出码 4,一个进程都不杀(fail closed)
· dsh 没被杀掉 ⇒ 放弃,退出码 2(避免拉起后 EADDRINUSE 假崩溃)
c. **先探测看门狗进程在不在**(判据:node 后紧跟 dsh-watchdog.js;2026-09-24 起重启不再依赖看门狗):
· 在 ⇒ 换手(杀旧看门狗 → 用当前 DSH_ENTRY 起新实例)→ 轮询本实例端口等它回门户
(它判定端口空闲需约 15s)→ 发一次 HTTP 请求触发它拉起 dsh → 转 e
· 不在 ⇒ **不再盲等 25s 门户**,直接转 d
d. 自行拉起 dsh(看门狗不在时的**主路径**,不再是"降级"):
绝对路径 node + Start-Process 重定向 stdout/stderr 到 dsh-selflaunch-out/-err.log
→ 就绪判据=端口可应答 **且** 输出文件里用 `dsh web:\s*(\S+)` 抓到**带 token** 的 URL
→ 失败最多重试 3 次(每次写清原因:进程已退出 / 无 URL 行 / URL 不带 token / 端口未应答)
→ 仍失败 ⇒ 退出码 2;成功后把带 token 的完整 URL 覆盖写入 dsh-last-url.txt
→ 再尽力起回一个看门狗接管这个 dsh(失败只记日志,不影响"重启已成功")
e. 轮询管理端口 3081 /dsh-url 直到新 dsh 就绪(最长 60s);走 d 的路径在 d 内已确认就绪
│
[新 dsh 进程] 插件 apply → 延迟 4s → 读标记
→ 等 sessionController 就绪 → resolveAgent(sessionId)(必要时 resume + 重新挂载会话记录的 preset)
→ **v0.4.2:先看交接记录会不会接手这次注入**(`slHandoff.pendingSummary()`,旧版/缺席则退回
读 <DSH_HOME>\storages\sl-handoff\pending.json):
· 待续标记里有本会话的未处理条目(`sessionId` 相等且 `done !== true`)⇒ **本插件不注入**,
删掉自己的标记,排一个 10s 复查定时器 —— 这一次注入由 dsh-host-sl 的【sl 交接续跑】承担,
于是**整轮只有一条消息**(对方的正文开头写着「dsh 已重启」;v0.5.0 起注入内容里
没有本插件的说明)
· 没有 / 服务缺席 / 本会话不在标记里 / 读不到 / 形态不符 ⇒ `agent.followup("dsh已重启,继续")`
→ 删标记(与 v0.4.1 逐字相同,日志里写清是哪种原因)
→ 10s 复查(**只有让位那条路会排它**):标记里**仍有**本会话的未处理条目(= 对方让位或失败了)
**且**本会话 `status !== 'running'` ⇒ 自己补注入一次(`buildInjectText()` 的固定文案,与照旧注入同一条);
标记里已无本会话条目(对方接手了)/ 会话已在 running / 插件已卸载 ⇒ 什么都不做
整轮耗时:看门狗在场约 26 秒,不在场约 11 秒。依据是 2026-09-17 实测的一次工具发起的重启
(当时 wait_seconds=15,全程 39 秒 = 15s 静默 + 1s 杀进程 + 15s 看门狗回门户 + 2s 新 dsh 启动
- 4s 注入延迟 + 2s 注入)。其中那 15s 是看门狗判定"端口空闲"的固定开销,与本插件无关;
静默自 2026-09-27 起固定 2s(旧版默认 6s、可由模型传 2~60),上面前两项随之各减 4 秒。
看门狗不在场时更快:那条 15s 固定开销不再发生(脚本探不到看门狗进程就直接自行拉起),
代价是少了看门狗的 token 自动跳转(见 §4「自行拉起路径的 token」)。
⓪.5 的交接保存给这条时间线加几毫秒(实测见 §4),不影响上面任何一项。
v0.4.2 起注入只剩一条消息、一轮:让位给交接记录时,那次注入发生在
dsh-host-sl的路径上 (约 6s 那一轮),本插件那条不再单独发生 —— 时间线本身不变,变的是"只跑一轮"。
2. 文件与配置
| 位置 | 作用 |
|---|---|
~/.dsh/profiles/desktop/plugins/dsh-host-restart/lib/index.js | 插件本体:注册工具 + 启动时消费标记并注入 |
~/.dsh/profiles/desktop/plugins/dsh-host-restart/cordis.patch.yml | 本包自己的注册行 id: restart-dsh(包层 patch:本包是组合包,由 profile 的 dsh.profile.bundles 加载;改它不需要重启,但不会自己触发重组合,见 §7 末段) |
<工具目录>\dsh-restart.ps1 | 驱动脚本(随仓库发布:driver/dsh-restart.ps1,见 §16;默认落点 <DSH_HOME>\tools\dsh-restart.ps1,可用 restartScript 覆盖 —— 它不在包的 files 清单里,得自己拷到落点):解析本实例身份 → 只杀本实例 dsh → 探看门狗(在就换手+触发,不在就自行拉起)→ 等就绪(退出码 4 = 身份确定不了,一个都不杀) |
<工具目录>\dsh-restart.log | 插件与脚本共用的流程日志(默认落点 <DSH_HOME>\tools\dsh-restart.log,可用 logFile 覆盖;超过 1MB 截断保留尾部) |
~/.dsh/storages/dsh-restart/pending.json | 重启标记;成功注入即删,陈旧/失败改名归档为 pending.<原因>-<时间>.json |
<工具目录>\dsh-selflaunch-out.log / dsh-selflaunch-err.log | 脚本自行拉起 dsh 时的 stdout/stderr(每轮覆盖写,不是追加)。默认与 -LogFile 同目录,可用 -SelfLaunchLogDir 覆盖 |
<工具目录>\dsh-last-url.txt | 最近一次自起 dsh 的带 token 完整 URL(覆盖写、只保留最近一次)。用途:清 cookie / 换浏览器时取一次;重启日志里只记"已取得带 token 的 URL",不留 token 明文 |
%USERPROFILE%\Desktop\kill_dsh.bat | 桌面人工用的"杀 web 实例"脚本(判据是命令行含 dsh\lib\bin.js 且含 web,跨实例)。驱动脚本不再调用它,只按本实例身份杀进程 |
<工具目录>\dsh-restart.lock | 驱动脚本的锁文件(单实例保护)。退出后故意不删:判据是"里面记的 pid 是否还活着",所以残留无害、也不会阻塞下次重启 |
~/.dsh/storages/sl-handoff/ | 不是本插件的目录:重启前存下的交接记录落在这里(dsh-host-sl 管),见 §4「重启前的交接保存」 |
⚠ 源码在
plugins\下,但真正被加载的是node_modules\里的那份拷贝(含包内cordis.patch.yml)。 本包 4 个共享文件实测全部是独立拷贝(node_modules\dsh-host-restart是独立目录,非 junction)—— 拷贝文件在源目录内容变了时它仍可能报Already up to date直接跳过同步 —— 必须dshpm remove再add(硬链接形态不需要这步:原地改即两侧生效)。 完整流程见 §7,已因此踩过一次坑(§8 第 3 条)。
启停:Web 侧栏「插件」页 →「已安装」区里本卡的总开关(写 dsh.profile.bundles);
点开卡片后每一行还有行级开关(向 profile 的 cordis.patch.yml 写 disabled 覆盖 ——
profile 层在包层之后应用,所以覆写优先)。
插件配置(包内 cordis.patch.yml 的 config 段,全部可选;profile 层可按 id 覆写):
bootDelayMs(4000) / staleMs(600000) / controllerWaitMs(30000) /
restartScript / logFile / pendingDir / psExe / wmiExec(仅测试注入的启动原语,见 §6)/
handoffPendingFile(v0.4.2:交接记录那条退路要读的文件,默认 <home>\storages\sl-handoff\pending.json,
与 dsh-host-sl 的默认落点一致;只读,测试把它指到临时目录)/
deferRecheckMs(v0.4.2:让位之后的兜底复查延迟,默认 10000)。
非法值会 fail loud(抛错、不注册工具)。
驱动脚本与日志的默认落点是 DSH home 下的 tools 目录:<DSH_HOME>\tools\dsh-restart.ps1
与 <DSH_HOME>\tools\dsh-restart.log(DSH_HOME 未设时用 ~\.dsh,与 pendingDir 的算法一致)。
驱动脚本本体现在随仓库发布(driver/dsh-restart.ps1,见 §16),但它不在包的 files 清单里,
也不会自动落到默认落点 —— 装到默认落点,或用 restartScript / logFile 指到你放脚本的地方。
等待不是配置项:waitSeconds 自 2026-09-27 起固定为常量 WAIT_SECONDS = 2,配置里写它不再生效
(理由见 §4「为什么等待仍固定成 2s」)。
工具参数:无(v0.5.0 起清零)。两个参数先后被删:wait_seconds 于 2026-09-27 删除(等待固定成
WAIT_SECONDS = 2),note 于 2026-09-29 删除(注入文案固定成 dsh已重启,继续)。
parameters 在源码里写成空对象 —— defineTool 不接受省略该字段。
3. 为什么这样启动驱动脚本(本机实测结论)
dsh 的 pwsh 工具走 dsh-subprocess-local,Windows 上 detached:false 且带 taskkill 树级清理
⇒ 任何由 dsh 直接/间接拉起的进程都不保证能在 dsh 被杀后继续运行。所以驱动脚本必须由一个
与 dsh 进程树无关的载体启动。逐项实测结果(判据=WMI Create 后子进程能否写出文件):
| 通道 | 结果 | 说明 |
|---|---|---|
conhost.exe --headless "pwsh.exe" … | ✅ 0.4s 起效、无窗口 | 首选 |
"pwsh.exe" -WindowStyle Hidden … | ✅ 4.5s 起效、无窗口 | 回退通道(不依赖 conhost) |
"pwsh.exe" …(不带 WindowStyle) | ✅ 0.9s,但会分配控制台窗口 | 不用 |
wscript.exe + vbs 中转 | ❌ 完全不执行 | WMI 进程没有可用的交互式 window station,GUI 子系统宿主起不来 |
mshta.exe / cscript.exe 中转 | ❌ 完全不执行 | 同上 |
Start-Process / 普通 spawn | ❌ 仍在 dsh 进程树内 | 会被一起清理 |
⚠ -WindowStyle Hidden 只是冷启动慢(约 4.5 秒),不是失败 —— 启动确认窗口必须 ≥9s,
否则会把回退通道误判为不可用,进而重复启动驱动(脚本因此带单实例保护,见下)。
插件对每次启动都做启动确认:WMI Create 返回 0 只说明进程被创建,脚本解析/启动失败在
CreateProcess 层看不到;因此记录日志偏移,等驱动脚本写下含本次 SessionId 的首行日志才算成功,
失败则换下一通道重试,全失败就撤销标记并如实报错(不会出现"以为重启了其实什么都没发生")。
启动命令行里同时带着本实例身份:-DshPid(插件宿主自己的 pid)、-ProfileName(profile 名)、
-Port(端口)。三者都由插件从自己的 argv 读出 ⇒ 驱动脚本在目标进程的命令行里必然能找到
同样的 token,"杀哪一个 dsh"因此有确定答案(判据与 fail-closed 语义见 §4)。
4. 边界与失败语义
- 只对主会话开放:
session.header.origin === 'subagent'或拿不到会话 id → 直接拒绝,不写标记、不重启。 原因:子代理会话不能作为"重启后要恢复的那个会话"(sessionController会拒绝 subagent-owned 会话)。 v0.4.0 后只剩这两条拒绝路径(就是本行前半句的两种情形:拿不到会话 id、子代理发起)。 - 看门狗未运行(2026-09-24 改):脚本先探测看门狗进程在不在,不在就不再盲等 25 秒门户,
立即自行拉起 dsh —— 绝对路径 node +
Start-Process把 stdout/stderr 重定向到dsh-selflaunch-out.log(禁止走管道:父进程退出后子进程会 EPIPE 崩,实测 ~0.5s), 就绪判据=端口可应答 且 输出文件里抓到带 token 的dsh web:URL(两者缺一不算就绪), 失败最多重试 3 次(每次在日志里写清原因),仍失败才退出码 2。注入照常完成。 - 为什么不用 WMI 直起 node(最容易被"简化"改回去的老路,实测否决):
Win32_Process.Create起 node 会弹出可见的终端窗口(实测 WindowsTerminal,确认在屏幕上可见 on-screen),且无法重定向 stdout/stderr —— 输出全丢,dsh 启动时打印的一次性 token URL 拿不到(浏览器无法自动取得带 token 的地址)。 所以自行拉起固定用Start-Process+ 文件重定向 +-WindowStyle Hidden,node 用绝对路径C:\Program Files\nodejs\node.exe(裸node在 WMI 宿主里虽能解析、但依赖服务环境变量不可靠);实测子进程在父脚本退出后仍存活 110s+、输出完整落盘、无可见窗口。 - 自行拉起路径的 token:看门狗不在场时没人解析 dsh 的 stdout,脚本自己抓到的带 token URL 会
覆盖写入
<工具目录>\dsh-last-url.txt(只留最近一次,日志里不留 token 明文)。 浏览器通常已有长效 cookie,直接访问 3080 即可;清过 cookie / 换浏览器时从该文件取一次 URL 访问。 - 看门狗回归(尽力而为):自行拉起成功且 dsh 已就绪后,脚本会尝试起回一个看门狗去接管这个 dsh。 顺序是"先 dsh 后看门狗"——看门狗启动时探测端口,见是 dsh 就接管;此时 dsh 已绑定端口, 不存在门户与新 dsh 抢端口的问题(反过来才会有)。回归失败只记日志,不影响"重启已成功"。
- 自行拉起需要 profile 名:profile 是 dsh 的位置参数(缺了它会
error: --profile <name> is required退出), 所以身份里没有 profile 时脚本不做"猜一个"的尝试,直接前置失败(退出码 1)。 - dsh 没被杀掉:脚本放弃拉起并记日志(避免双实例抢 3080)。
- 重复调用:标记后写覆盖前写,只有最后一次生效。
- 陈旧标记:超过 10 分钟(或时间戳在未来)→ 归档不注入,避免下次启动凭空插一条"已重启"。
- 注入失败(sessionController 不可用 / resume 报错):标记归档为
pending.failed-*.json, 只记日志、不阻断宿主启动、不无限重试。 - 本轮输出被截断:进程被杀时尚未落盘的输出会丢;前端"保留被打断回答"的补丁会保住已产出的部分。 静默固定 2s 后,能确定落盘的只有本次工具结果,模型随后写的收尾正文基本都会被截断 —— 这是为堵住"判定之后、杀之前"那段无人复查的窗口所付的代价。
- 为什么等待仍固定成 2s(2026-09-27 定,2026-09-28 保留):判定与杀之间隔着这段等待,窗口里随时可能
有别的会话开始跑(另一个标签页发消息、子代理结束唤醒父会话、定时提醒到点),而那时没有任何机制复查。
旧版把等待交给模型(
wait_seconds2~60s,默认 6)只会让被打断的活更多。 现在只留"本次工具结果落盘"必需的 2s,工具参数与配置项一并删除。
其它会话在飞:只告知,不拦截(v0.4.0,2026-09-28)
-
不再拒绝:v0.3.0 及以前,检测到别的会话还有活在跑就直接
return {ok:false}(一个字节都不写盘、 不启动任何进程)。用户 2026-09-28 明确要求「重启不应该被拒绝」,那层闸门已删除:检测照旧跑, 结果只进日志与工具返回文案,然后照常写标记、照常起驱动脚本。 -
为什么删:闸门挡住的正是"用户自己按下的重启"—— 改完插件/配置要生效就得重启,而闸门把决定权从 用户手里拿走,只给"等它们跑完"这一条出路;
slHandoff的交接记录已经能把被打断的会话接回来, 闸门的边际价值不足以继续拦人。 -
代价(不许说轻):重启照样硬杀别人的回合。交接记录救得回上下文,救不回被打断的那一步工具 调用 —— 冷恢复是带着记录重起一轮,不是从断点续跑。工具返回文案里逐字写着这句,别改成"无损恢复"。
-
文案说什么:
本次重启会打断 N 个其它会话(\id`(会话在跑)、…)+ 各自原因 +本会话自己名下还有 N 个子代理在跑`(有才写)+ 交接记录的结局(存下了/没存下+原因)。原文示例:重启已发起:…整轮约 20~40 秒。重启前已保存会话交接记录:C:\…\handoff-session-self.md、…(新进程启动时 会由 dsh-host-sl 注入回本会话)。本次重启会打断 2 个其它会话(`session-other-a`(会话在跑)、 `session-other-b`(后台作业在跑))、本会话自己名下还有 1 个子代理在跑:它们与本会话共用同一个 dsh 宿主, 会被一起硬杀 —— 正在跑的那一步工具调用直接丢(交接记录救得回上下文、救不回这一步);这些会话的交接记录 已随本次重启存下(本次共 3 份,含本会话),新进程启动时会由 dsh-host-sl 逐条注入、各自重起一轮接着跑。 请立刻结束本轮回复,…交接没存下时后半句换成
这些会话的交接记录这次没存下来(<原因>),新进程里没人会把它们唤回 —— 那些会话的上下文需要你重新交代。;没有任何活在飞时整段省略;检测不到时写本次没能读到宿主会话表(<原因>),无法提前说明会打断哪些会话;若此刻还有别的会话或子代理在跑, 它们会一起被硬杀。(不假装"没有别的会话")。 -
检测口径三条(2026-09-27 扩):① 会话自己在本轮运行(
status === 'running'—— 含"等审批/等回答", 那种轮次仍然是 running);② 名下还有正在跑的子代理(顶层会话派完后台子代理就结束本轮 ⇒ 父会话 idle、子代理还在跑;roots()看不见子代理,靠session.header.parentSession上溯归到它的根); ③ 名下还有未结算的后台作业(running/stopping;作业记录是纯内存态,硬杀即永久丢失)。 为什么正是这三条:DSH 自己的"会话还有活动"口径(归档准入)就是这三样。 -
发起者自己名下的子代理/作业不算"其它会话":它们与本会话同进程共命,单独计进
detectRunningSessions返回值的own并写进文案(本会话自己名下还有 N 个子代理在跑)—— 它们同样被硬杀,只是重启后由dsh-host-sl按"先顶层、后子代理"逐条唤回,而本会话由本插件唤醒。 -
检测的可见范围:只看本进程的会话注册表与作业表(
ctx.get('agents')/ctx.get('jobs'))—— 另一个 dsh 进程(另一个 profile、试跑实例)里的会话它看不见,也就不会出现在文案里。 -
标签页开着但空闲、名下无子代理无作业 ⇒ 不算"在飞的活":文案里不会出现它,重启时它照样被杀。
-
判定到杀之间仍有约 3~5 秒窗口(静默 2s + 驱动脚本启动与身份解析):这段里新开始的活不会被写进文案, 也没有第二次检查。本轮明确不做"等待期复检 + 取消旗标",属已知残留(重复调用顶掉标记同属此列)。
-
服务读不到 = 照常重启,但不许假装没事:
agents(或jobs)拿不到、接口形态不符、枚举抛错时, 日志留一行无法检测其它会话(...),按无其它会话处理,照常重启,文案写"本次没能读到宿主会话表(...)"。 ⇒ 上游接口一旦漂移,这条检测会静默失效(重启照旧成功,只是说不清会打断谁),唯一线索就是那行 warn。
重启前的交接保存(v0.3.0,可选服务 slHandoff)
-
它是什么:重启会硬杀宿主,本会话在飞的工作靠两样东西接回来 —— 本插件的「已重启」注入, 以及
dsh-host-sl的交接记录("重启前你在做什么")。所以本插件在发起重启前顺手请对方存一份。 -
调用面:
ctx.get('slHandoff').saveAll({ all: true, noteSessionId, session })(服务由dsh-host-sl提供)。all:true= "覆盖所有活跃会话";noteSessionId= 发起重启的这个 会话;session= 服务枚举不到 live agent 表时的退路(只存这一个)。 v0.5.0 起不再传note:调用它只是为了把对方的活跃工作表刷到最新,"续跑消息由谁注入"仍按pendingSummary让位,但注入内容里没有本插件的说明(noteSessionId/session两个形参 保留 —— v0.7.0 的dsh-host-sl不读它们,保留只为兼容)。slHandoff故意不进inject:inject 是"全有才 apply"的硬门,写进去就等于"dsh-host-sl 没装时本插件连工具都不注册"。所以用ctx.get()可选读取,并由静态用例钉住"它不在 inject 里"。 -
顺序(硬要求):两条拒绝路径之后 →
await保存完成 → 写重启标记 → 启动驱动脚本 → 返回。- 放在拒绝路径之后:那两条(拿不到归属会话 / 子代理发起)的语义是"一个字节都不写盘", 那时不该留下交接记录;"其它会话有活在跑"自 v0.4.0 起不是拒绝路径,照常走到这里;
- 放在启动驱动脚本之前:那 2 秒静默窗口由驱动脚本从自己启动那一刻开始计时、只用来保证
"本次工具结果落盘";保存排在它前面只会让"发起重启"整体晚一点点,不会挤占这个窗口。
反过来(先起脚本再存)才会真的吃掉窗口。
test/inject-coverage.test.mjs用源码位置静态钉住这个顺序。
-
实测耗时(2026-09-28,本机;脚本读内存历史 + 渲染 + 写盘,各跑 15 次取中位):
场景 最快 中位 最慢 记录文件 会话 500 条消息 2.3ms 3.4ms 8.9ms 3.0 KB 会话 2000 条消息 3.7ms 4.4ms 7.2ms 3.1 KB 会话 5000 条消息 6.3ms 7.3ms 10.0ms 3.1 KB 三个 500 条会话批量(未来形状) 5.3ms 6.7ms 8.7ms 各 3 KB 记录正文有分节上限,所以文件大小恒定在 3 KB 上下,耗时主要跟"扫多少条消息"走 —— 毫秒级, 相对 2 秒静默可忽略。未测到的部分如实标注:
session.deriveMessages()是宿主侧的活(内存投影 重建),上表用的是等价大小的内存数组;宿主那一层的量级同阶(单次全量扫描 + 字符串处理), 真要出事也是秒级以上的异常会话,那时它拖慢的是发起重启,而不是静默窗口。 -
完全 fail-soft:服务不存在、
save不是函数、取save就抛(代理/getter)、调用抛错、 服务返回ok:false—— 一律只写一行日志 + 把原因写进工具返回文案,绝不阻断重启:- 成功:
重启前已保存会话交接记录:<记录文件路径>(新进程启动时会由 dsh-host-sl 注入回本会话)。 - 失败:
重启前的会话交接未保存:<原因>(不影响本次重启)。
- 成功:
-
服务接口:
saveAll覆盖所有活跃会话(2026-09-28 晚):saveAll(options)里options.all = true,dsh-host-sl会枚举ctx.agents.list()里的每一个 live agent (顶层会话 + 子代理)各存一份记录,一次调用只写一份列表标记。为什么必须这样:重启硬杀整个 进程,在飞的活不止当前这一个会话 —— 用户开着几个标签页、会话下还挂着后台子代理;本插件自己 只复活发起重启的会话,其余会话与全部子代理的续跑完全靠这份交接(新进程里由dsh-host-sl按"先顶层、后子代理"的顺序逐个唤回)。只存当前会话 = 那些会话重启后没有交接可续。- 不传
note(v0.5.0):注入内容由接手方(dsh-host-sl的【sl 交接续跑】)给出,本插件 不往里塞说明;options.noteSessionId仍带上,只作兼容(v0.7.0 的dsh-host-sl不读它)。 - 只认
saveAll,不退回save:dsh-host-slv0.3.0 只有save(单会话)。退回就等于 悄悄回到"只存当前会话",而"服务形态不符"本身是 fail-soft 的一条(只记一行日志、照常重启, 文案里写清"需要 dsh-host-sl v0.4.0+"),不会把重启卡住。 - 枚举不到 live agent 表时,
dsh-host-sl侧会 fail-soft 退回"只存session那一个" (本插件传的就是当前会话)—— 所以最坏情况与旧行为等价,不会"一个都不存"。
- 不传
-
与「已重启」注入的合并(v0.4.2,2026-09-29 用户拍板):一次重启只注入一条消息、只跑一轮。
- 旧版为什么是两条:两条注入各自独立 —— 本插件路径更短(apply 后 4s +
resolveAgent,约 4s) 必然先跑;dsh-host-sl多一步list()判定(约 6s)排进 next-turn ⇒ 第一轮拿不到交接记录, 那一轮只能凭「已重启 + note」空转或乱做,交接记录要到第二轮才到(v0.5.0 起 note 已删, 这一段的"note"是当时的历史形态)。 - 现在的判据:新进程读到自己的重启标记、并且已经
resolveAgent拿到 agent 之后,先问一句 「这次有没有dsh-host-sl的待续标记、且其中含本会话尚未处理的条目」—— 条目判据只有两条:sessionId相等、done !== true。active不参与:空闲条目 (active:false,保存那一刻没有在飞的活)同样会被对方处理掉并归档,所以它一样意味着"有人接手"。 - 取数顺序(两条来源,都要 fail-soft):①
ctx.get('slHandoff')上的只读方法pendingSummary()(dsh-host-slv0.6.0 起提供;旧版没有这个方法 ⇒typeof !== 'function'就往下走);② 退回只读默认路径<DSH_HOME>\storages\sl-handoff\pending.json(handoffPendingFile配置项可覆盖;v2 列表与 v1 单会话标记都读得进);③ 两条都拿不到 ⇒ 视为"没人接手"⇒ 照旧自己注入。服务抛错、返回值形态不符、文件损坏一律退到下一条来源, 日志里写清是哪一种。 - 有 ⇒ 本插件不注入:交给对方的【sl 交接续跑】。v0.4.2 当时靠
note把"下一步"写进记录正文, v0.5.0 已删掉 note 参数 —— 现在注入的就是对方那条(正文开头写着「dsh 已重启」), 本插件不再往里塞任何说明。 日志:交接记录将接手注入,本次不再单独注入「已重启」:…(来源=service|file,kind=…,active=…)。 - 没有 ⇒ 照旧自己注入,日志写清原因(服务缺席 / 没有标记 / 本会话不在标记里 / 读不到 / 形态不符):
交接记录不会接手本次注入(<原因>),照旧自己注入「已重启」。 - 兜底(必须有,否则对方一让位就没人叫醒会话):决定让位后延迟约 10s(
deferRecheckMs) 复查一次,两个条件都成立才自己补注入一次(buildInjectText()的固定文案,与照旧注入同一条消息): ① 标记里仍有本会话的未处理条目(= 对方让位了 —— 闸门① 别的顶层会话正在跑一轮 —— 或失败了); ② 本会话status !== 'running'(对方已经把它排进下一轮 ⇒ 别再插一条)。 复查时标记里已无本会话条目(对方接手了,条目被移除或整份归档)或会话已在 running ⇒ 什么都不做。 日志:交接记录没接手(<原因>),自己兜底注入「已重启」/…兜底注入跳过。 - 卸载清理只有一处登记,走
ctx.effect(2026-09-29 修):ctx.effect(() => () => { … })—— cordis 卸载时真的会跑,一次回收四样:disposed标记、cancelBoot「启动注入」定时器、disposeTool(tools.register返回的 disposer)与cancelRecheck兜底复查定时器。 不许用ctx.on('dispose', …):本机 cordis(cordis/lib/index.js:969-970)在 fiber 卸载时 发的是internal/plugin,根本没有dispose事件 —— 本包历史那处挂的就是它,那段清理一次都 没跑过(已修:原先只处理disposed/cancelRecheck的那段 effect 已并进来)。 ⚠ 生效条件:模块按 URL 缓存 ⇒ 装载中的旧代码仍带缺陷,要等下一次 dsh 重启才换成新代码。test/lifecycle-cleanup.test.mjs(6 条)钉住四种回收与可重复调用,test/boot-inject.test.mjs在假 ctx 上把"卸载 ⇒ 定时器被取消 ⇒ 到期也不注入"钉死了。 - 已知残留:让位判据不看标记的新鲜度(
dsh-host-sl的staleMs默认 24h)。若那次saveAll失败、磁盘上留着一份超过 24h 的旧标记且里面有本会话条目,对方启动时会把整份标记 判为陈旧并归档(不注入),而本插件在 10s 复查时看到"标记里已没有本会话条目"⇒ 也不补注入 ⇒ 这一轮没人叫醒会话。触发条件是"交接保存失败 + 旧标记超期",概率很低;要消掉它得让两边共用 同一份新鲜度判据(本次不做,如实记在这里)。
- 旧版为什么是两条:两条注入各自独立 —— 本插件路径更短(apply 后 4s +
-
失败也不影响重启,但有副作用残留:若交接存下来了、而重启本身失败(驱动脚本起不来), 本插件会撤销自己的重启标记,那份交接记录与它的待续标记留在
sl-handoff里 —— 下次任意一次 dsh 启动时dsh-host-sl会把它注入回来(内容仍是"重启前的状态"), 不想要就手工删~\.dsh\storages\sl-handoff\pending.json。 -
只杀本实例:驱动脚本按"本实例身份"选进程 —— 精确 pid(插件注入的宿主 pid,命令行已校验) → 退按 profile 名 + 端口扫描 → 仍确定不了 ⇒ 退出码 4,一个进程都不杀(fail closed)。 为什么必须这样:profile 名是 dsh 的位置参数(
dsh web …等价dsh --profile web), 所以"命令行含web"这种判据跨实例 —— 2026-09-21 试跑实测,从 teamlab 实例(--port 3090) 发起重启会杀掉 web 实例(3080)。桌面kill_dsh.bat仍是那个跨实例判据,只作人工工具, 驱动脚本已不再调用它。旧版插件(不传身份参数)启动新脚本时,脚本会从重启标记的pidBefore兜底取宿主 pid(要求标记的 sessionId 与本次一致、且写于 5 分钟内), 所以"改完插件之后的第一次重启"不会空转。
5. 排障
下面写的 <工具目录> 指驱动脚本与日志所在的目录:默认是 <DSH_HOME>\tools(DSH_HOME 未设时
即 ~\.dsh\tools),用 restartScript / logFile 改过就以配置为准(见 §2)。
| 现象 | 查什么 |
|---|---|
| 工具调用后什么都没发生 | <工具目录>\dsh-restart.log:有没有 ===== 重启驱动启动 行;没有就是启动通道失败(插件日志里会写命中的通道与失败原因) |
看门狗在场时,重启日志出现 触发请求返回异常…401 (Unauthorized) | 属正常现象,不需要处理 —— 浏览器页面自动重连已抢先唤起 dsh,脚本那次触发请求落在刚绑定端口的新 dsh 上(无 token 故 401);只要同一轮日志随后出现 新 dsh 已就绪,本轮重启就是成功的 |
| dsh 重启了但会话没续 | 日志里搜 发现重启标记 / 已向会话 … 注入;~/.dsh/storages/dsh-restart/ 下有没有 pending.failed-*.json |
| 重启后只来了一条续跑消息(本该两条) | 这是 v0.4.2 的正常形态:交接记录接手了这次注入。日志搜 交接记录将接手注入,本次不再单独注入「已重启」;~\.dsh\storages\sl-handoff\sl-handoff.log 里应有对应的 恢复条目成功:…。那一轮的正文是【sl 交接续跑】(开头写着「dsh 已重启」;v0.5.0 起注入内容里没有本插件的说明) |
| 重启后两条消息都到了 / 只到了「已重启」 | 搜 交接记录不会接手本次注入((原因:服务缺席 / 没有标记 / 本会话不在标记里 / 读不到 / 形态不符 ⇒ 照旧自己注入)与 交接记录没接手((兜底:对方让位或失败了,本插件 10s 后自己补了一条)。两条都到说明兜底那次与对方那条撞上了 —— 只有"对方让位后又成功注入"才会这样,日志里 交接记录复查: 那行会写清当时看到的状态 |
| 交接记录那条一直没来、会话没人叫醒 | ① ~\.dsh\storages\sl-handoff\sl-handoff.log 搜 恢复判定: / 本次恢复结束:(对方是不是让位/整份归档了);② <工具目录>\dsh-restart.log 搜 交接记录复查: —— 若写的是 待续标记里已没有本会话的未处理条目(dsh-host-sl 已接手),兜底注入跳过,说明对方把条目移除了却没注入(§4 末尾那条已知残留:超期旧标记被归档) |
| 工具返回文案说"本次重启会打断 N 个其它会话" | 这是告知不是拦阻(v0.4.0 起重启不会被拒绝):文案里已点名 id、原因与交接记录存没存下;日志里搜 会话检测:(含每条原因 session / subagent / job)与 其它会话有活在跑(N 个),不再拒绝:照常重启。想避免打断就把重启推迟到它们跑完 |
| 想确认"这次打断了谁、接回来没有" | 被打断名单看工具返回文案或日志那两行;能不能接回来看 ~\.dsh\storages\sl-handoff\sl-handoff.log 的逐条 恢复条目成功:…(失败条目写原因并留在标记里等下次启动) |
| 怀疑检测失效(说不清会打断谁) | 日志里搜 无法检测其它会话(:出现即说明 agents/jobs 服务形态漂移,检测已 fail-soft 跳过(这是它唯一的失效信号,重启照常但文案只说"没能读到宿主会话表") |
| 工具返回文案说「重启前的会话交接未保存」 | 看同一行给的原因:slHandoff 服务不可用(dsh-host-sl 未装载?) ⇒ 对端插件没装/没加载(重启照常);不是函数 ⇒ 服务形态漂移(旧版 dsh-host-sl(≤v0.3.0)只有 save、没有 saveAll,也会走这条,文案里写着"需要 v0.4.0+");抛错(...) ⇒ 对端保存时炸了。日志里搜 重启前的交接保存 / 调用 slHandoff.saveAll() 有同一条记录 |
| 想确认"对端插件到底装没装" | 别再找 apply 那行了(v0.4.1 起 apply 阶段不探测 —— 那一刻 dsh-host-sl 还没把服务挂上,旧版那行"服务不在场(dsh-host-sl 未装载?)"是假阴性)。现在在 <工具目录>\dsh-restart.log 里搜 (首次调用时探测):服务在场(首次调用时探测) = 真装好了;服务不在场(dsh-host-sl 未装载?)…(首次调用时探测) = 确实没装/没加载;服务在场但没有 saveAll()(接口形态不符,dsh-host-sl 需要 v0.4.0+) = 装的是旧版。若探测结论与真实调用结果不一致,紧跟的 交接服务探测说"…",但真实调用… 那行会说明以真实调用为准。没调用过 restart_dsh 工具或 /restart 指令就不会有这行(惰性探测,只探一次) |
| 想确认这次重启到底存没存交接 | <工具目录>\dsh-restart.log 搜 重启前已保存会话交接记录(N 个会话):(成功,含全部记录文件路径)或 重启前的交接保存未成功:(失败,含原因);记录文件本体在 ~\.dsh\storages\sl-handoff\ |
| 重启后只有发起重启的会话被唤醒,别的会话没动静 | 本插件只复活发起重启的那个会话;其余会话靠 dsh-host-sl 的交接记录唤回。查 ~\.dsh\storages\sl-handoff\sl-handoff.log:应有 发现待续标记:共 N 条(顶层 a / 子代理 b) 与逐条 恢复条目成功:…;失败条目会写原因并留在标记里等下次启动 |
| 重启后页面卡"启动中" | <工具目录>\dsh-watchdog.log(看门狗侧)与 dsh-watchdog-dsh.log(dsh 侧),本插件不参与端口接管 |
| 想确认是不是本插件干的 | ~/.dsh/storages/plugins.json 里的 restart-dsh 条目 + 工具表里的 restart_dsh |
| 从别的 profile 实例发起重启,怕误杀生产实例 | 日志里搜 本实例身份: 与 本实例的 dsh 进程(…)(含判据来源);解析不出身份会写 fail closed(退出 4),此时一个进程都没被杀,把 -ProfileName/-Port 补进调用参数即可 |
| 重启后浏览器要 token / 提示未授权 | 本次若走的是"自行拉起"(日志搜 自行拉起:就绪),后回归的看门狗没有 token 自动跳转 —— 从 <工具目录>\dsh-last-url.txt 取一次 URL 访问,dsh 会签发长效 cookie |
| 自行拉起失败 | ① dsh-restart.log 里每轮的原因(进程已退出 / 无 URL 行 / URL 不带 token / 端口未应答);② node 自身的报错在 dsh-selflaunch-err.log;③ 兜底双击桌面 dsh-web.bat(由看门狗拉起) |
卸载时 pnpm remove 报 ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED | 本 profile 的 pnpm 带供应链策略:进 profile 目录(~\.dsh\profiles\desktop\)直接跑 pnpm remove dsh-host-restart --config.minimum-release-age=0。dshpm 的 --fast 只对 add 有效 —— pnpm remove 不接受 --minimum-release-age 这类参数 |
6. 测试与回滚
cd ~\.dsh\profiles\desktop\plugins\dsh-host-restart
$env:DSH_RESTART_NO_LAUNCH='1' # 结构性熔断:不设它就别跑(放行用例会真的起进程)
node --test "test/*.test.mjs" # 131 个用例,分布在八个文件:
# pending(8):标记判定 / 注入文案 / 启动命令行 / 配置校验(纯函数);
# v0.5.0 起那条"注入文案"断言的是**恒为固定句**:传了 note 也不许被拼进去
# inject-coverage(11):静态断言 ctx.<service> 都已声明 inject、
# 代码里不再有 required:false / wait_seconds、apply 任何情况下都不抛回 loader、
# **工具参数已清零且调用点不再读 args(v0.5.0 新增)**、
# **slHandoff 不在 inject 里(可选读取)**、**保存→写标记→起脚本的顺序**、
# **apply 阶段不再写"交接服务在不在"那行(v0.4.1)**
# session-gate(41):多会话检测 —— 三条口径(本轮/子代理/作业)的
# **只告知不拦截**(在飞时照常写标记 + 经桩发起一次启动,文案点名 id 与原因)/
# 无活在飞 / fail-soft(含"检测不到也不假装没有别的会话");
# **重启前的交接保存**:服务在场(含**多会话返回多路径**)/
# 缺席(在飞时文案不许谎称已存)/抛错/形态不符(旧版只有 save ⇒ 也算形态不符)/
# 返回 ok:false/异步实现/重启失败也报交接结果;
# **交接服务的探测时机(v0.4.1)**:首次调用时写一次(在场/不在场/形态不符三种)/
# 同一实例只探一次/探测只读(不影响真实保存)/探测与真实结果不一致时如实区分;
# 两条真拒绝(无归属会话 / 子代理发起)仍不写盘、不存交接
# instance-identity(9):本实例身份解析 + "多 profile 下只命中自己"的判据
# (与 ps1 同一条正则的 JS 镜像) + 启动命令行带身份 + 静态护栏
# boot-inject(24,**v0.4.2**):两条注入的合并 —— 取数顺序(pendingSummary()
# 优先 / 退回读文件 / 都拿不到)、让位判据(含 active:false 的空闲条目)、
# 照旧注入的四种"没人接手"情形、兜底两条(标记里仍有 ⇒ 补注入;
# 会话已 running / 标记已无本会话条目 / 插件已卸载 ⇒ 不补)、
# **复查定时器由 ctx.effect 登记并在卸载时被回收**、配置默认值
# lifecycle-cleanup(6,**2026-09-29 新增**):卸载清理 —— 装载后 `ctx.effect`
# 恰好登记一处清理(且没有任何 dispose 处理器)/ 执行它 ⇒ 工具 disposer 被调用、
# 「启动注入」与「兜底复查」两个定时器被取消、`disposed` 置位 /
# 可重复调用(不重复调 disposer、不把异常抛回 cordis)/ 反向对照:不清理则定时器到期真的注入
# desktop-mode(16,**v0.6.0 新增**):桌面端身份判据 —— 按 pid + exe 路径
# 解析本实例身份(web 端仍是 profile/端口)/ exe 只接受绝对路径且不含双引号 /
# 命令行里取 exe 的规范化(剥引号、统一分隔符、小写比较)与"裸路径不许按空格切分"/
# 整棵树都算命中 / 启动命令行带 `-Mode desktop` 与 `-DesktopExe`(含参数注入护栏)/
# 与驱动脚本 `Test-DshInstanceCommandLine` 同一条正则的 JS 镜像
# restart-command(16,**v0.7.0 新增**):人工指令 `/restart` —— 指令名合法
# (^[a-z][a-z0-9_-]*$ 且与工具名不同)/ 注册形态(描述非空、无 input)/
# `commands` 服务缺席、形态不符、register 抛错都只降级(绝不抛回 loader)/
# **不进 inject** 且源码里不许出现 `ctx.commands` 属性访问 /
# **与工具共用同一个执行体**:两条入口写出的标记逐字节相同,只有失败文案前缀
# 与成功文案说给谁听不同(指令文案不含"请立刻结束本轮回复")/
# 真拒绝两条(无归属会话、子代理发起)与带参数报用法错 ⇒ 都不写标记、不碰启动桩 /
# 启动通道全失败时标记被撤销 / 返回形态始终是合法 `CommandResult`(error 的 text 非空)/
# 卸载时命令 disposer 与工具 disposer 一起被回收 / 版本行回显 v0.7.0
测试纪律(硬性,2026-09-21 事故换来的)
放行路径会真的起进程:restart_dsh 经 WMI 创建驱动脚本 ⇒ 几秒后杀掉 dsh 与其它会话。
所以测试与验证脚本必须把启动原语换掉,而不是只换日志目录、也不是只看结果文案:
- 桩打在启动层:
apply的 config 传wmiExec(源码里唯一会 spawn 的那一步realExecFile被整段替换)。 断言要写成"桩被调用了几次 / 命令行里是哪个脚本路径",不要断言自备日志文件里的内容。 - 假路径兜底:
restartScript/psExe指向临时目录下不存在的路径 —— 桩万一被摘掉,只剩 ENOENT。 - 进程级熔断:测试进程设
DSH_RESTART_NO_LAUNCH=1,真实execFile被直接拒绝。
三道防线任意一道成立都不可能起真实进程;test/session-gate.test.mjs 里有专门用例逐条钉住它们
(当年就是"只断言自备日志文件 + 没覆盖 restartScript/psExe"⇒ 假阴性盖住了真实副作用)。
新增的交接保存用例同样走 callTool(即同样命中这三道防线),断言落在"服务被调用了几次 /
传进去的会话与 noteSessionId 是什么 / 启动桩被调用了几次",不是只看文案。
2026-09-28(v0.3.0)新增能力的变异验证(临时改坏 → 用例必须红 → 还原后 SHA256 与改前一致
= C85CB04959A4F261F2BBF9071095637D1918C32139CE3B921762F10F14CF4789):
| 变异 | 结果 |
|---|---|
交接服务缺席时"假装成功"(返回 ok:true) | 2 条红(服务缺席用例 + 单测里那条 fail-soft) |
| 交接调用抛错时不再 fail-soft(把异常直接抛给工具层) | 2 条红(抛错用例 + 单测里那条 fail-soft) |
2026-09-28 晚(改用 saveAll 覆盖所有活跃会话)的变异验证(同样:改坏 → 跑全量 → 还原 → 核对 SHA256
= 872CBE57FA21B56410FADC5A8DDE93B9EB3D156E526B879F8D755F28D02FC0D1):
| 变异 | 结果 |
|---|---|
退回"只存当前会话"(saveAll → save,参数换回 (session, note) —— 当时还传 note) | 8 条红(桩只实现了 saveAll ⇒ 交接相关的 8 条用例全部走到"形态不符"分支) |
形态检查看 save 而不是 saveAll(service.save ?? service.saveAll) | 1 条红("旧版只有 save 的服务必须被判为形态不符"那条) |
丢掉 noteSessionId(所有会话都写同一句 note —— 当时还传 note) | 1 条红(noteSessionId 断言那条) |
2026-09-28 晚(v0.4.0「不再拒绝」)的变异验证(同样:改坏 → 跑全量 → 还原 → 核对 SHA256
= B8A43BEA630591FE6955268DABAA41AC873358ACC09627FFA388740BE5F57FBD):
| 变异 | 结果 |
|---|---|
把拒绝闸门装回去(在飞时 return {ok:false},一个字节都不写盘) | 4 条红(三条"照常重启"用例 + "在飞 + 交接服务缺席"那条) |
交接没存下却谎称已存(recoveryClause 忽略 ok:false) | 2 条红(formatInterruptedNotice 纯函数那条 + "在飞 + 服务缺席"那条) |
丢掉"本会话自己的子代理"计数(ownSubagents += 0) | 2 条红(own 计数那条 + 文案里"本会话名下在跑的活"那条) |
⚠ 上面两张表里的 SHA256(
C85CB049…/872CBE57…)是 v0.4.0 之前的lib/index.js: 它们记录的是当时那次验证的还原基准,不是当前文件的哈希;当前两侧哈希见 §7 的比对命令输出。
2026-09-28 晚(v0.4.1「探测挪到首次工具调用」)的变异验证(改坏 → 跑全量 → 还原 → 核对 SHA256
= 3ED3DFD74F943DC831AD82557E7724D83BDCADC6B072535DBE571E5677BB90A4):
| 变异 | 结果 |
|---|---|
把探测挪回 apply 阶段(probeHandoffOnce() 在 applyInner 里直接调一次) | 2 条红("apply 阶段不再探测交接服务" + "同一插件实例里只探一次"——后者因为 apply 时已经探过,工具调用时不再写那行) |
2026-09-29(v0.4.2「两条注入合并成一次」)的变异验证(改坏 → 跑全量 → 还原 → 核对 SHA256
= A2FD6BC33F9AFCF91194A645CB515B02D2C76DCADC263EA5F2A08D66B80A3187):
| 变异 | 结果 |
|---|---|
findUnhandledHandoffItem 恒返回 undefined(= 永远不认为"有人接手",让位与兜底两条路全部失效) | 11 条红(让位 3 条 + 兜底 6 条 + 摘要判据 1 条 + ctx.effect 回收 1 条) |
2026-09-29(v0.5.0「删 note 参数 + 注入文案固定」)的变异验证(改坏 → 跑全量 → 还原 → 核对 SHA256
= BD28459695E93BF830E861AA652756991AA96D372E325CE4280FBA2C5B8A9144):
| 变异 | 结果 |
|---|---|
buildInjectText 改回"拼接 note"(把 v0.4.2 的实现装回去) | 2 条红(buildInjectText 固定文案那条 + 新增的"参数已清零/调用点不再读 args"静态那条) |
parameters 里把 note 加回去(工具重新收一个参数) | 1 条红("工具没有任何参数"那条:参数表非空 + 描述/参数表里出现 note) |
交付指纹(v0.5.0,两份副本内容一致):
lib/index.js SHA256 = BD28459695E93BF830E861AA652756991AA96D372E325CE4280FBA2C5B8A9144;
package.json SHA256 = B7CBB60539A25370D7CBB6BE29AAEF715AA1FB5611C77924081EB608EE30715C;
node --test "test/*.test.mjs" = 99 用例全绿(DSH_RESTART_NO_LAUNCH=1)。
⚠ 生效条件:lib/*.js 属模块代码,loader 按 URL 缓存已 import 的模块 ⇒ 要等下一次 dsh 重启
才换成新代码(package.json / README 这类非模块文件不受此限,但版本号要重启后才在日志里回显)。
(v0.4.2 + 卸载清理修复那版是 F8FF79766BB2DC69C5332317042A3A618B8E0C194CA8258D46CDF57247A904D4 / 98 用例;
再往前:v0.4.2 终版 A2FD6BC33F9AFCF91194A645CB515B02D2C76DCADC263EA5F2A08D66B80A3187 / 92 用例;
v0.4.1 是 3ED3DFD74F943DC831AD82557E7724D83BDCADC6B072535DBE571E5677BB90A4 / 68 用例;
v0.4.0 是 B8A43BEA630591FE6955268DABAA41AC873358ACC09627FFA388740BE5F57FBD / 62 用例。
⚠ 上面这些哈希都是当时那次验证的还原基准,不是当前文件的哈希 —— 当前两侧哈希见 §7 的比对命令输出。)
驱动脚本自己也有隔离演练开关(不杀任何真实进程,见脚本头注释):加 -SelfLaunchTest
再配 -TestDshEntry <假 dsh 脚本>,端口限定在 39000-39999,就会跳过看门狗换手/回归、
强制走"自行拉起"分支并用假 dsh 替代真实入口;默认不开时行为与生产完全一致:
pwsh -File "<工具目录>\dsh-restart.ps1" -SelfLaunchTest -TestDshEntry <假 dsh.js> `
-ProfileName webtest -Port 39011 -WaitSeconds 0 -LogFile <临时目录>\drill.log
回滚(按顺序):
- 插件页本卡的总开关停用(写
dsh.profile.bundles),或在 profile 的cordis.patch.yml里写一行- id: restart-dsh+disabled: true覆写(profile 层在包层之后应用 ⇒ 覆写优先) —— 这两条路动的都是 dsh-hmr 监视的输入(profile 的package.json/cordis.patch.yml), 保存即触发重组合,工具消失; - 在 profile 目录下跑
pnpm remove dsh-host-restart(⚠ 若报ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED, 改跑pnpm remove dsh-host-restart --config.minimum-release-age=0,见 §5); - 删
~/.dsh/profiles/desktop/plugins/dsh-host-restart/、<工具目录>\dsh-restart.ps1, 可选删<工具目录>\dsh-restart.log与~/.dsh/storages/dsh-restart/; kill_dsh.bat与看门狗保持原样,不受影响。
7. 改动这个插件(四步,别跳)
# 1) 改源码(唯一真源在 plugins\ 下)
notepad ~\.dsh\profiles\desktop\plugins\dsh-host-restart\lib\index.js
# 2) 跑测试 —— inject 覆盖自检能挡住"宿主起不来"这类改动
cd ~\.dsh\profiles\desktop\plugins\dsh-host-restart ; node --test "test/*.test.mjs"
# 3) 同步到真正被加载的那份(直接 add 可能报 Already up to date 而不同步,必须 remove 再 add)
cd ~\.dsh\profiles\desktop
pnpm remove dsh-host-restart
pnpm add file:./plugins/dsh-host-restart
# ⚠ 这两条只动 node_modules 与 dependencies —— 若 profile 的 package.json 里
# dsh.profile.bundles 没有 dsh-host-restart 这一项,记得自己补上(否则不会被当组合包加载)
# 4) 重启 dsh —— loader 按 URL 缓存模块,改 lib/*.js 必然要重启。
# v0.7.0 起有两种发起方式:模型调 restart_dsh 工具,或人直接在输入框敲 /restart 指令(两者同一执行体)。
# ⚠ 但"这次改动本身"要等重启后才生效 ⇒ 第一次重启还得用工具/手动,之后 /restart 才可用。
包内 cordis.patch.yml 属包层 patch(同上需同步到副本):改它不需要重启,但不会自己触发重组合
—— dsh-hmr 只监视 profile 的 cordis.patch.yml、home 层 cordis.patch.yml 与 profile 的 package.json
三个输入(dsh-hmr/lib/index.js:353-376),包内文件不在其中;重组合时会重读全部 bundle 层,所以改完
要有一次触发才被读入:在插件页点一下本卡(或任意行级)开关,或保存 profile 的 cordis.patch.yml
里任意一处改动。只有 lib/*.js 这类模块代码受 URL 缓存限制,必须走上面的第 4 步重启。
同步是否真的发生,用 SHA256 对一下两份(test/ 不进运行副本,测试从源码目录跑):
Get-FileHash ~\.dsh\profiles\desktop\plugins\dsh-host-restart\lib\index.js,
~\.dsh\profiles\desktop\node_modules\dsh-host-restart\lib\index.js -Algorithm SHA256
两个哈希必须一致 —— 拷贝文件改完源码没同步、或没核对哈希,就等于"改了没生效"(§8 第 3 条踩过一次)。
不想经历 remove 造成的空窗(宿主正在服务用户的会话时)也可以直接覆盖改动的文件 ——
那对本包这种拷贝文件成立(硬链接文件则要原地改写,直接覆盖会断链),依赖规格没变时 lockfile 无需更新:
Copy-Item ~\.dsh\profiles\desktop\plugins\dsh-host-restart\lib\index.js `
~\.dsh\profiles\desktop\node_modules\dsh-host-restart\lib\index.js -Force
# README 等其它改了文件同理;然后照上面核对 SHA256
(若改用 dsh-plugin-manager(dshpm)这类封装命令代替上面两步,记得加 --no-verify:
它收尾的 dsh --dump-config 校验会挂住不返回。)
8. 事故记录(2026-09-17,三条都已变成测试或流程)
| 现象 | 根因 | 现在的防线 |
|---|---|---|
| 每次启动 dsh 都在打印带 token 的启动行之前退出,看门狗反复拉起反复崩,浏览器一直卡"启动中" | ctx.timeout 是 timer 服务的 mixin,而 inject 只声明了 ['tools'] —— cordis 对未声明注入的服务属性访问直接抛错,apply 抛错 ⇒ 插件树加载失败 ⇒ 整个 dsh 起不来 | inject = ['tools','timer'];apply 外层 try/catch(插件永远不该拖垮宿主);test/inject-coverage.test.mjs 静态断言覆盖 |
工具始终不出现:restart_dsh 工具注册失败: parameters.note.required must be true when present(当时的报错原文;note 参数本身已于 v0.5.0 删除) | value-schema DSL 的 required 只接受 true,可选参数必须省略该字段 | 删掉两处 required: false;测试断言代码里不再出现 |
| 改了代码却毫无变化(插件一直跑旧逻辑) | 本包 4/4 共享文件是独立拷贝,且 pnpm 可能跳过同步:改的是 A 份、加载的是 B 份 | 本节 §7 的 remove+add 流程 + SHA256 比对 |
9. 验收记录(2026-09-29 00:05,v0.4.1 的惰性探测)
15:58:40.272Z [plugin] apply: v0.4.1 …⇒ 新代码已在宿主里(那次部署重启装载的)。- apply 阶段不再打印那行自检:最后一条假阴性是 v0.4.0 进程的
15:05:36.943Z 重启前的交接保存:slHandoff 服务不在场(dsh-host-sl 未装载?),工具照常可用; v0.4.1 之后(15:58、16:05 两次启动)apply 阶段一条都没有。 - 首次工具调用时探测一次,结论正确:
16:05:27.985Z 重启前的交接保存:slHandoff 服务在场(首次工具调用时探测)—— 同一轮里saveAll真的存下 2 份记录(工具文案重启前已保存会话交接记录:<路径1>、<路径2>), 探测结论与真实调用一致,"说不在场、其实在场"的假阴性消失。 - 未覆盖:
不在场/形态不符(旧版dsh-host-sl只有save)/ 探测抛错三条分支只有离线用例, 真机没造过(要造得先卸掉或换掉dsh-host-sl,代价大于收益)。
10. v0.4.2 的真机验收判据(协调者执行,需用户同意)
一次「先敲 /sl 再走重启工具」的重启,按下面四条看(离线用例已把逻辑钉死,这里只看真机是不是那样跑):
| # | 判据 | 怎么看 |
|---|---|---|
| 1 | 新版本已装载 | <工具目录>\dsh-restart.log 里有 apply: v0.4.2 …;~/.dsh/storages/plugins.json 里 restart-dsh 的版本行也对得上 |
| 2 | 只有一条续跑消息 | 重启后本会话只收到一条 user 消息:正文是【sl 交接续跑】(开头写着「dsh 已重启」)。不该再出现单独的「dsh已重启,继续」那一条 |
| 3 | 让位日志 | 同一份日志里有 交接记录将接手注入,本次不再单独注入「已重启」:…(来源=service,kind=…,active=…) —— 来源=service 说明 slHandoff.pendingSummary() 这条路真的生效了(来源=file 说明退回了读文件,也能用,但值得查一下服务为什么没提供方法) |
| 4 | 兜底没被触发(正常路径) | 日志里不该出现 交接记录没接手(;若出现,紧接着的 交接记录复查: 那行会写清当时标记里还有什么、会话是什么 status |
-
反面判据(出问题时的样子):两条消息都到(
交接记录不会接手本次注入(…)与对方的注入同时发生)、 或一条都没到(交接记录复查:待续标记里已没有本会话的未处理条目(dsh-host-sl 已接手),兜底注入跳过但对方其实没注入 —— 见 §4 末尾那条已知残留)。 -
未覆盖(如实标注):兜底那两条真机分支(对方让位/失败 ⇒ 本插件 10s 后补注入;会话已 running ⇒ 不补)只有离线用例,真机没造过 —— 要造得让
dsh-host-sl在重启后那次启动里让位(例如另一个顶层 会话正在跑一轮),代价与不确定性都偏高。(要造得先把
dsh-host-sl降级成没有该方法的旧版)。
11. 验收记录(2026-09-29 01:51,v0.4.2)
一次工具重启(发起会话 session-ca1f2a92,重启前保存 2 个会话)后的实际日志,逐条对上 §10 的四条判据:
- 判据 1(版本已装载) ✅
17:51:46.222Z apply: v0.4.2 pendingFile=… script=… wait=2s(固定) handoff=slHandoff(可选,不进 inject) - 判据 3(让位日志) ✅
17:51:50.683Z 交接记录将接手注入,本次不再单独注入「已重启」:待续标记里有本会话 session-ca1f2a92-… 的未处理条目(来源=service,kind=root,active=true) —— note 已随 saveAll 写进记录正文的「下一步」一节;10000ms 后复查兜底(这行是 v0.4.2 的原文,会话 id 已截短;v0.5.0 起日志里这半句改成注入内容由对方的【sl 交接续跑】给出(本插件不往里面塞说明)) ——来源=service:dsh-host-sl.pendingSummary()这条路真的生效(不是退回读文件)。 - 判据 4(兜底没被触发) ✅
17:52:00.693Z 交接记录复查:待续标记里已没有本会话 session-ca1f2a92-… 的未处理条目(dsh-host-sl 已接手),兜底注入跳过—— 10 秒后复查看到对方已接手、没补第二条;全程没有交接记录没接手(。 - 判据 2(只有一条续跑消息) ✅ 会话侧只收到一条 user 消息(【sl 交接续跑】…),
没有单独的「已重启。…」(v0.5.0 起那条固定文案是
dsh已重启,继续);同批sl-handoff.log:17:51:52.124Z 恢复条目成功:顶层 session-ca1f2a92…。
未覆盖(同 §10 末尾):兜底那两条真机分支(对方让位/失败 ⇒ 10s 后补注入;会话已 running ⇒ 不补)
与 来源=file 退路,都只有离线用例 —— 要造得让 dsh-host-sl 在重启后那次启动里让位(例如另一个顶层
会话正在跑一轮),代价与不确定性都偏高。
12. v0.5.0 的真机验收判据(协调者执行,需用户同意)
v0.5.0 只改了两件事:工具参数清零(删 note)、注入文案固定成 dsh已重启,继续。
让位逻辑一个字没动,所以 §10/§11 那四条判据照旧适用;这里只列 v0.5.0 特有的三条:
| # | 判据 | 怎么看 |
|---|---|---|
| 1 | 新版本已装载 | <工具目录>\dsh-restart.log 里有 apply: v0.5.0 …;~/.dsh/storages/plugins.json 里 restart-dsh 的版本行也对得上。⚠ 装载前必须先有一次 dsh 重启(模块按 URL 缓存) |
| 2 | 工具表里 restart_dsh 没有参数 | 工具定义里 parameters 是空对象({type:'object',properties:{}}),描述里搜不到 note |
| 3 | 注入的就是那一句 | 让位没发生时(交接记录不会接手本次注入),会话收到的那条 user 消息正文逐字等于 dsh已重启,继续;兜底那次(交接记录没接手()也逐字等于它 |
- 反面判据(出问题时的样子):注入正文里出现「已重启。」「补充说明:」「请继续重启前未完成的工作」
这类旧文案 —— 说明跑的还是 v0.4.2 的旧模块(没重启装载),或
buildInjectText被改回去了。 - 未覆盖(如实标注):真机上"调用方传 note"这条路已经不可能发生(参数已删,模型看不到它),
所以"传了也不拼进去"只有离线用例(
pending.test.mjs与session-gate.test.mjs各一条)钉住;dsh-host-slv0.7.0 收到不带note的saveAll后的行为也只有离线桩验证(它的源码已确认不读该字段)。
13. 自测记录(2026-09-29,v0.5.0 交付时)
node --test "test/*.test.mjs"(DSH_RESTART_NO_LAUNCH=1)= 99 用例全绿(六个文件: pending 8 / inject-coverage 11 / session-gate 41 / instance-identity 9 / boot-inject 24 / lifecycle-cleanup 6)。- 变异验证两次(见 §6):
buildInjectText改回拼接 note ⇒ 2 条红;parameters加回note⇒ 1 条红; 两次都还原并核对 SHA256 =BD28459695E93BF830E861AA652756991AA96D372E325CE4280FBA2C5B8A9144。 - 两份副本(
plugins\与node_modules\)的lib/index.js/package.json/README.mdSHA256 各自相等;两份lib/index.js的node --check都是 exit 0。 - 未做真机验收:本次交付只做到"离线全绿 + 两份一致",真机那三条判据(§12)要等下一次 dsh 重启。
14. 桌面端(Electron)实现与验收(v0.6.0,2026-09-30)
14.1 实测的进程树(本机 0.2.0-rc.2)
DeepSeek Harness.exe (27936) ← 主进程,父进程 = explorer.exe,命令行就是 exe 本身
├─ 28152 --type=gpu-process
├─ 28176 --type=utility --utility-sub-type=network.mojom.NetworkService
├─ 28224 --type=renderer
├─ 21676 --type=utility --utility-sub-type=audio.mojom.AudioService
└─ 28564 --expose-internals …\app.asar\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\index.js …
└─ (瞬时) …\dsh-subprocess-local\lib\runner.js → pwsh(插件/工具起的子进程壳)
- 主进程与全部子进程共用同一个 exe ⇒ 按 exe 路径扫一遍就是整棵树(且只覆盖这一个安装)。
- 主进程不能用
--type=区分:后端 Host 子进程也没有--type=。踩过的坑:第一版判据写成 "没有--type=就是主进程",演练时命中 3 个"主进程"(27936 主进程、28564 后端 Host、还有一个 恰好同时存在的 pwsh),于是 fail closed、桌面端永远重启不了。现在只有一个判据函数Test-DesktopTreeMember:同一个 exe + 它的父进程也在这棵树里 ⇒ 它属于这棵树;取反就是 "根进程"(父进程不在这棵树里的那个 = Electron 主进程)。父进程已退出的孤儿进程同样算根 —— 那种情况下会命中多个根,脚本按"无法确定本实例"fail closed(退出 4),不瞎杀。 - ⚠ pid 校验只要求"属于这棵树",不能要求"是主进程"(2026-09-30 真机验收踩到的第二个坑):
插件跑在后端 Host 里,它注入的
-DshPid就是 Host 的 pid(实测主进程 28568 下面挂着 Host 30032),而 Host 的父进程才是主进程 ⇒ 用"必须是主进程"去校验它必然为假,结果是工具放行、 脚本却fail closed(退出 4),表现为"点了重启但什么都没发生"。现在改用Test-DesktopTreeMember -Process $exact,四种形态(真实 Host pid / 按 exe 扫描 / 不属于本树的 pid / 缺参数)都在 §14.6 的演练里锁住。 - 后端 Host 监听
127.0.0.1:19387(dsh-desktop-host里硬编码['--no-open','--port','19387'])。
14.2 为什么必须整树重启
Electron 主进程把 Host 当子进程管(DesktopHostProcess):Host 一死,主进程立刻走
reportFatal → DesktopFatalRecovery 弹原生对话框(按钮:退出 / 重启应用 / 禁用第三方插件)。
那条路要人点,不能当自动化用。所以脚本杀的是整棵树(含主进程),再冷启动同一个 exe。
顺序硬约束:先杀旧、后起新。Electron 有单实例锁(requestSingleInstanceLock,第二个实例会把
已有窗口聚焦后自杀),所以脚本杀完会等旧进程全部退出(最多 30 秒)再启动 —— 不等干净新实例会自杀。
14.3 自起通道已实测
脚本由 WMI 创建(父进程 WmiPrvSE.exe,脱离 dsh 进程树),它在里面 Start-Process 冷启动 exe。
WMI 创建的进程能正常显示 GUI 窗口 —— 2026-09-30 用 notepad 做过对照:WMI 创建的 notepad
MainWindowHandle 非 0、标题正常(对照组那次失败是 Win11 记事本的单实例行为,不是窗口站问题)。
14.4 桌面端参数与演练开关
| 参数 | 默认 | 含义 |
|---|---|---|
-Mode | web | web = 原路径;desktop = 桌面端分支。拼错的模式名直接退出 1(不许静默按 web 跑) |
-DesktopExe | 空 | 应用 exe 完整路径(插件从 process.execPath 取)。桌面模式下必填,缺了退出 4 |
-Port | 3080 | 桌面模式下必须显式给(默认 3080 是 web 的,不能当就绪判据)⇒ 没给就退出 4 |
-DesktopDryRun | 关 | 演练:只校验身份并打印将执行的命令,不杀任何进程、不启动任何东西 |
-DesktopLaunchAttempts / -DesktopRetryDelaySeconds / -DesktopReadySeconds | 2 / 3 / 60 | 冷启动的重试次数、间隔与就绪期限 |
演练开关的用法(离线自测,零副作用):
pwsh -File "<工具目录>\dsh-restart.ps1" -SessionId test -WaitSeconds 0 -Mode desktop \
-DesktopExe "$env:LOCALAPPDATA\Programs\DeepSeek Harness\DeepSeek Harness.exe" \
-Port 19387 -DesktopDryRun
# 期望:身份校验通过 → "命中主进程=1 整棵树=N 个进程" → exit 0
14.5 fail closed 的四种形态(都是退出码 4,一个进程都不杀)
- 桌面模式缺
-DesktopExe(或路径不存在); - 桌面模式没显式给
-Port; - 插件注入的 pid 存在、但不属于目标 exe 的进程树(exe 路径对不上,或它的父进程不在这棵树里)—— 防 pid 复用;
- 按 exe 扫描命中多个根(同一 exe 有多个主进程)—— 无法确定本实例。
14.6 验收判据与实测结果(2026-09-30)
| # | 判据 | 结果 |
|---|---|---|
| 1 | npm test 全绿(115 个用例,其中 test/desktop-mode.test.mjs 16 个) | ✅ 115/115 |
| 2 | 演练:传真实后端 Host pid ⇒ 通过校验、命中 1 个根、exit 0 | ✅ pid=30032(已校验:同一 exe、且属于这棵进程树)、整棵树 6 个进程 |
| 3 | 演练:不传 pid(按 exe 扫描)⇒ 命中唯一根、exit 0 | ✅ 根 = 28568 |
| 4 | 演练:传不属于本树的 pid ⇒ exit 4 | ✅ 退出 4、一个进程都没杀 |
| 5 | 演练:缺 -DesktopExe / 缺 -Port / -Mode typo ⇒ exit 4 / 4 / 1 | ✅ 三条都符合 |
| 6 | 真机:调用 restart_dsh ⇒ 窗口消失数秒 → 冷启动同一个 exe → 19387 重新监听 → 本会话收到续跑消息 | 见下方"两次失败与修法" |
真机验收踩到的两个坑(都已变成测试里的回归锁):
- 判据依赖
--expose-internals:它是 Node 自己的选项,会被 Node 消费掉、不进process.argv⇒ 桌面端被判成 web(日志apply: v0.6.0 mode=web)。现在判据只看入口脚本路径dsh-desktop-host\lib\index.js。 - pid 校验要求"必须是主进程":插件注入的是后端 Host 的 pid,它的父进程才是主进程
⇒ 工具放行、脚本却
fail closed(退出 4),表现为"点了重启但什么都没发生"(用户 18:15 报的 就是这个)。现在 pid 校验改用Test-DesktopTreeMember(同一 exe + 父进程也在这棵树里)。
⚠ 两个坑都不是"逻辑写错",而是对运行时事实的假设错了(Node 会吞掉自己的选项、插件跑在子进程里)
—— 所以这一节的演练开关 -DesktopDryRun 值得每次改动后都跑一遍:它能在不杀任何进程的前提下
把身份判据走完。
15. 人工指令 /restart(v0.7.0,2026-10-03)
15.1 为什么加它
重启此前只有模型工具一个入口:想重启就得让模型去调 restart_dsh。改完插件代码要生效、或模型
不肯调工具时,人没有别的办法(只能自己杀进程,那就没有"把会话接回来"这一步了)。
现在多了一条人能直接敲的入口 —— 在输入框里发 /restart 即可,不占模型回合(命令由
commands 服务直接执行,不发给模型)。
15.2 行为与工具完全一致(用户 2026-10-03 选定)
| 环节 | 工具 restart_dsh | 指令 /restart |
|---|---|---|
| 会话校验(无归属会话 / 子代理发起 ⇒ 拒绝) | 同一段 | 同一段 |
| 其它会话在飞 ⇒ 只告知不拦截 | 同一段 | 同一段 |
重启前存交接(可选服务 slHandoff) | 同一段 | 同一段 |
| 写标记 → 起驱动脚本 | 同一段 | 同一段 |
| 重启后注入 | 固定文案「dsh已重启,继续」 | 同一条(让位逻辑也相同) |
| 参数 | 无 | 无(带参数报用法错) |
| 失败文案前缀 | restart_dsh 未执行: | /restart 未执行: |
| 成功文案 | 给模型(含「请立刻结束本轮回复」) | 给人(换成「本页会短暂断开,新进程起来后本会话自动续上」) |
两条入口在源码里共用 performRestart(ctx, session, env)(lib/index.js),调用点只剩"传哪个
会话、文案前缀用哪个"。改这条链路时两处一起改 —— 分叉出去就是两份会各自漂移的实现。
15.3 注册形态与边界
- 走官方
commands服务(契约见@deepseek-ai/dsh-commands的CommandDefinition):name必须匹配^[a-z][a-z0-9_-]*$(大写、中文、点号都不合法)。已占用的内置名是compact/feedback/record/goal/permission/plan/export,同 profile 的dsh-btw另有btw一族 ⇒restart无冲突。 commands不进inject:inject 是"全有才 apply"的硬门,写进去就等于"它缺席时连restart_dsh工具与启动注入一起废掉"。改用ctx.get('commands')可选读取 + 完全 fail-soft —— 与slHandoff同款处理(test/restart-command.test.mjs里有一条静态用例钉住"源码不许出现ctx.commands属性访问")。- 命令 disposer 收在
disposeCommand里,由唯一那处ctx.effect在卸载时回收(不给它单独再挂 一处 effect,否则破坏"清理只登记一处"的约定)。 - handler 返回
CommandResult:{kind:'success', text}/{kind:'error', text},error 的 text 必须 非空(dsh-commands的normalizeResult会抛)。带参数(/restart now)按内置/compact的惯例 直接报用法错,连执行体都不进。 - 命令的
command/run/command/done是 log-only append(不强制 flush)⇒ 进程被杀时这两条 日志可能来不及落盘。重启本身不受影响(标记已写、驱动脚本已起)。
15.4 验收记录(2026-10-04 02:31–02:32 真机实测)
用户手动重启应用一次(加载 v0.7.0)后,当场敲 /restart 走完了整条链路 —— 这是日志里第一条、
也是唯一一条 /restart 调用(此前 52 条全是模型发起的 工具调用:)。
| # | 判据 | 结果 |
|---|---|---|
| 1 | npm test 全绿 | ✅ 131/131(新增 test/restart-command.test.mjs 16 条) |
| 2 | 指令注册进注册表 | ✅ 18:31:44 /restart 指令已注册(commands.register 返回 disposer=true) |
| 3 | 指令能被人敲出来并执行 | ✅ 18:32:04 /restart 调用:session=session-519efee5… wait=2s launcherPid=29132 mode=headless dshPid=19816 |
| 4 | 重启后本会话自动接回 | ✅ 02:32:07 杀整棵树 → 02:32:13 新实例就绪 → 02:32:14 注入「已重启」回同一会话 |
| 5 | 让位逻辑未受影响 | ✅ 当时本会话空闲 ⇒ saveAll 存下 0 个会话 ⇒ 插件照旧自己注入(没把注入让给一张空表) |
| 6 | 工具没被影响 | ✅ 新进程里 restart_dsh 工具已注册(disposer=true),与指令并存 |
| 7 | 带参数报用法错 | ⬜ 未演(零风险,随时可试:敲 /restart now ⇒ 回 用法:/restart(不带参数),进程不动) |
关键日志(<工具目录>\dsh-restart.log;插件行是 UTC,脚本行是本机时间,两者差 8 小时):
2026-10-03T18:31:44.805Z [plugin] apply: v0.7.0 mode=desktop …
2026-10-03T18:31:44.807Z [plugin] /restart 指令已注册(commands.register 返回 disposer=true)
2026-10-03T18:32:04.442Z [plugin] /restart 调用:session=session-519efee5-… launcherPid=29132 mode=headless
2026-10-04T02:32:07.493+08:00 [ps1] 桌面端:准备终止 5 个进程(pid=27012, 27124, 24016, 6460, 19816)
2026-10-04T02:32:13.845+08:00 [ps1] 桌面端:新实例已就绪(第 1 次尝试,pid=30596,端口 19387 已应答)
2026-10-03T18:32:14.442Z [plugin] 已向会话 session-519efee5-… 注入「已重启」
⚠ 一个值得记住的时序细节:第一次重启仍然得靠手动或工具(模块按 URL 缓存,新代码要等新进程才加载)
—— 那次重启之后 /restart 才存在。这不是缺陷,是"用旧代码重启才能加载新代码"的必然。
16. 驱动脚本(driver/dsh-restart.ps1,2026-10-05 起随仓库发布)
它是什么:真正干"杀 dsh → 拉起新 dsh → 等新实例就绪"的那一半。插件本体(lib/index.js)
只负责写重启标记、用 WMI 把它启动起来、等新进程回来注入续跑消息;杀哪一个、怎么起、等多久,
判定全在脚本里(为什么必须由 WMI 启动见 §3)。退出码:0=新 dsh 已就绪 / 1=前置失败 /
2=就绪超时或 dsh 没被杀掉 / 3=已有驱动实例在运行 / 4=确定不了本实例身份(一个进程都不杀)。
默认落点:<DSH_HOME>\tools\dsh-restart.ps1(DSH_HOME 未设时即 ~\.dsh\tools\dsh-restart.ps1),
与插件配置项 restartScript 的默认值一致;日志默认在同目录的 dsh-restart.log(logFile 可覆盖)。
仓库里这一份放在 driver/dsh-restart.ps1,不在包的 files 清单里 ⇒ 不会被自动放到默认落点:
装的时候自己拷过去,或用 restartScript / logFile 指到你放脚本的地方。
前提:
| 项 | 要求 |
|---|---|
| PowerShell | pwsh(PowerShell 7)优先,插件失败会回退 powershell.exe;脚本本身按 5.1 兼容写(不用 ??/三元) |
| WMI | Win32_Process.Create 可用 —— 脚本必须由它启动(父进程 WmiPrvSE.exe,与 dsh 进程树无关),否则脚本会随 dsh 一起被 taskkill 清掉,见 §3 |
| Node | 自行拉起分支用绝对路径 C:\Program Files\nodejs\node.exe(WMI 给的是服务环境,裸 node 只靠机级 PATH、实测不可靠);装在别处用 -NodeExe 覆盖 |
| 看门狗 | 可选件,不随本仓库发布。有它就换手交给它拉起、没有就由脚本自行拉起 —— 两条路都能把重启走完,缺看门狗只是少了"冷启动门户 + 按需接管" |
参数(全部有默认值;插件只传身份那几个:-SessionId / -WaitSeconds / -Mode / -DesktopExe /
-DshPid / -ProfileName / -Port / -PendingFile):
| 参数 | 默认 | 说明 |
|---|---|---|
-DshEntry | $env:APPDATA 下的 npm\node_modules\@deepseek-ai\dsh\lib\bin.js | 自行拉起时要起哪个 dsh 入口(用环境变量推导,不写死用户名) |
-LogFile | <DSH_HOME>\tools\dsh-restart.log | 流程日志;-SelfLaunchLogDir / -LastUrlFile 默认跟着它所在目录走 |
-NodeExe | node | 自行拉起用的 node —— 脚本优先用内置绝对路径 C:\Program Files\nodejs\node.exe,找不到才回退到本参数(裸 node 依赖 WMI 宿主的 PATH,实测不可靠) |
-Mode | web | web = node 宿主 + 看门狗/自起;desktop = Electron 整树重启(v0.6.0,见 §14) |
-DesktopExe | 空 | 桌面模式必填(插件从 process.execPath 传),缺了退出 4 |
-DshPid / -ProfileName / -Port / -PendingFile | 由插件注入 | "杀哪一个 dsh"的身份判据;三个身份都缺 ⇒ 退出 4,绝不见同名就杀 |
-DoorWaitSeconds / -ReadyWaitSeconds | 25 / 60 | 等看门狗回门户、等新实例就绪的期限 |
-SelfLaunchAttempts / -SelfLaunchReadySeconds / -SelfLaunchRetryDelaySeconds | 3 / 12 / 2 | 自行拉起的重试次数、单轮就绪超时、间隔 |
-DesktopLaunchAttempts / -DesktopRetryDelaySeconds / -DesktopReadySeconds | 2 / 3 / 60 | 桌面端冷启动的同名三项 |
-DesktopDryRun / -SelfLaunchTest + -TestDshEntry | 关 | 演练开关:只校验身份、不杀任何进程(见 §6 与 §14.4;自起演练端口限定 39000–39999) |
其余参数(-KillScript 已废弃等)见脚本 param( 块自带注释。
⚠ 它会杀掉当前 dsh 实例并把新实例拉起来 —— 别在非目标环境里随手跑。 只验证身份判据而不动任何进程,用演练开关:桌面端
-DesktopDryRun、web 端-SelfLaunchTest -TestDshEntry <假 dsh 脚本>(两者都不杀进程、不启动真实实例)。 脚本还会尽量起回一个看门狗接管新实例:那一步失败只写日志,不影响"重启已完成"的判定。
许可
MIT License,见 LICENSE。Copyright (c) 2026 liuyun847。