← Back to home@liuyun847

dsh-host-restart

DSH 宿主插件:给模型一个 restart_dsh 工具,重启 dsh 后自动恢复原会话续跑 (DeepSeek Harness)

Stars
0
Language
JavaScript
Created
Sep 26, 2026
Updated
Oct 5, 2026
GitHub repo

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.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 3080DeepSeek 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_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.3ms3.4ms8.9ms3.0 KB
    会话 2000 条消息3.7ms4.4ms7.2ms3.1 KB
    会话 5000 条消息6.3ms7.3ms10.0ms3.1 KB
    三个 500 条会话批量(未来形状)5.3ms6.7ms8.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-sl v0.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-sl v0.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 复查时看到"标记里已没有本会话条目"⇒ 也不补注入 ⇒ 这一轮没人叫醒会话。触发条件是"交接保存失败 + 旧标记超期",概率很低;要消掉它得让两边共用 同一份新鲜度判据(本次不做,如实记在这里)。
  • 失败也不影响重启,但有副作用残留:若交接存下来了、而重启本身失败(驱动脚本起不来), 本插件会撤销自己的重启标记,那份交接记录与它的待续标记留在 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 与其它会话。 所以测试与验证脚本必须把启动原语换掉,而不是只换日志目录、也不是只看结果文案:

  1. 桩打在启动层:apply 的 config 传 wmiExec(源码里唯一会 spawn 的那一步 realExecFile 被整段替换)。 断言要写成"桩被调用了几次 / 命令行里是哪个脚本路径",不要断言自备日志文件里的内容。
  2. 假路径兜底:restartScript / psExe 指向临时目录下不存在的路径 —— 桩万一被摘掉,只剩 ENOENT。
  3. 进程级熔断:测试进程设 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

回滚(按顺序):

  1. 插件页本卡的总开关停用(写 dsh.profile.bundles),或在 profile 的 cordis.patch.yml 里写一行 - id: restart-dsh + disabled: true 覆写(profile 层在包层之后应用 ⇒ 覆写优先) —— 这两条路动的都是 dsh-hmr 监视的输入(profile 的 package.json / cordis.patch.yml), 保存即触发重组合,工具消失;
  2. 在 profile 目录下跑 pnpm remove dsh-host-restart (⚠ 若报 ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED, 改跑 pnpm remove dsh-host-restart --config.minimum-release-age=0,见 §5);
  3. 删 ~/.dsh/profiles/desktop/plugins/dsh-host-restart/、<工具目录>\dsh-restart.ps1, 可选删 <工具目录>\dsh-restart.log 与 ~/.dsh/storages/dsh-restart/;
  4. 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-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 桌面端参数与演练开关

参数默认含义
-Modewebweb = 原路径;desktop = 桌面端分支。拼错的模式名直接退出 1(不许静默按 web 跑)
-DesktopExe空应用 exe 完整路径(插件从 process.execPath 取)。桌面模式下必填,缺了退出 4
-Port3080桌面模式下必须显式给(默认 3080 是 web 的,不能当就绪判据)⇒ 没给就退出 4
-DesktopDryRun关演练:只校验身份并打印将执行的命令,不杀任何进程、不启动任何东西
-DesktopLaunchAttempts / -DesktopRetryDelaySeconds / -DesktopReadySeconds2 / 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,一个进程都不杀)

  1. 桌面模式缺 -DesktopExe(或路径不存在);
  2. 桌面模式没显式给 -Port;
  3. 插件注入的 pid 存在、但不属于目标 exe 的进程树(exe 路径对不上,或它的父进程不在这棵树里)—— 防 pid 复用;
  4. 按 exe 扫描命中多个根(同一 exe 有多个主进程)—— 无法确定本实例。

14.6 验收判据与实测结果(2026-09-30)

#判据结果
1npm 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 重新监听 → 本会话收到续跑消息见下方"两次失败与修法"

真机验收踩到的两个坑(都已变成测试里的回归锁):

  1. 判据依赖 --expose-internals:它是 Node 自己的选项,会被 Node 消费掉、不进 process.argv ⇒ 桌面端被判成 web(日志 apply: v0.6.0 mode=web)。现在判据只看入口脚本路径 dsh-desktop-host\lib\index.js。
  2. 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 条全是模型发起的 工具调用:)。

#判据结果
1npm 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 指到你放脚本的地方。

前提:

项要求
PowerShellpwsh(PowerShell 7)优先,插件失败会回退 powershell.exe;脚本本身按 5.1 兼容写(不用 ??/三元)
WMIWin32_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 默认跟着它所在目录走
-NodeExenode自行拉起用的 node —— 脚本优先用内置绝对路径 C:\Program Files\nodejs\node.exe,找不到才回退到本参数(裸 node 依赖 WMI 宿主的 PATH,实测不可靠)
-Modewebweb = node 宿主 + 看门狗/自起;desktop = Electron 整树重启(v0.6.0,见 §14)
-DesktopExe空桌面模式必填(插件从 process.execPath 传),缺了退出 4
-DshPid / -ProfileName / -Port / -PendingFile由插件注入"杀哪一个 dsh"的身份判据;三个身份都缺 ⇒ 退出 4,绝不见同名就杀
-DoorWaitSeconds / -ReadyWaitSeconds25 / 60等看门狗回门户、等新实例就绪的期限
-SelfLaunchAttempts / -SelfLaunchReadySeconds / -SelfLaunchRetryDelaySeconds3 / 12 / 2自行拉起的重试次数、单轮就绪超时、间隔
-DesktopLaunchAttempts / -DesktopRetryDelaySeconds / -DesktopReadySeconds2 / 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。