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.mjs 16 条)。⚠ 模块按 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 还没把服务挂上(服务由它自己的 fiber
provide,时机在 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/web/plugins/dsh-host-restart/lib/index.js | 插件本体:注册工具 + 启动时消费标记并注入 |
~/.dsh/profiles/web/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_seconds 2~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)、调用抛错、
服务返回 —— 一律 + 把原因写进工具返回文案,:
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+");抛错(...) ⇒ 对端保存时炸了。日志里搜 重启前的交接保存 / 有同一条记录 |
6. 测试与回滚
cd ~\.dsh\profiles\web\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/web/plugins/dsh-host-restart/、<工具目录>\dsh-restart.ps1,
可选删 <工具目录>\dsh-restart.log 与 ~/.dsh/storages/dsh-restart/;
kill_dsh.bat 与看门狗保持原样,不受影响。
7. 改动这个插件(四步,别跳)
# 1) 改源码(唯一真源在 plugins\ 下)
notepad ~\.dsh\profiles\web\plugins\dsh-host-restart\lib\index.js
# 2) 跑测试 —— inject 覆盖自检能挡住"宿主起不来"这类改动
cd ~\.dsh\profiles\web\plugins\dsh-host-restart ; node --test "test/*.test.mjs"
# 3) 同步到真正被加载的那份(直接 add 可能报 Already up to date 而不同步,必须 remove 再 add)
cd ~\.dsh\profiles\web
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\web\plugins\dsh-host-restart\lib\index.js,
~\.dsh\profiles\web\node_modules\dsh-host-restart\lib\index.js -Algorithm SHA256
两个哈希必须一致 —— 拷贝文件改完源码没同步、或没核对哈希,就等于"改了没生效"(§8 第 3 条踩过一次)。
不想经历 remove 造成的空窗(宿主正在服务用户的会话时)也可以直接覆盖改动的文件 ——
那对本包这种拷贝文件成立(硬链接文件则要原地改写,直接覆盖会断链),依赖规格没变时 lockfile 无需更新:
Copy-Item ~\.dsh\profiles\web\plugins\dsh-host-restart\lib\index.js `
~\.dsh\profiles\web\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-sl v0.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.md
SHA256 各自相等;两份 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。