重启 dsh 后自动接回原会话继续对话
28.9/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分
数据截至 2026/10/16 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add liuyun847/dsh-host-restart该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:仅索引
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码v2.7.0🔵 扫描:含敏感能力尚无人工评估展开查看扫描说明与证据 ↓
安全扫描自动 · 静态分析源码
v2.7.0静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。
可执行外部命令读写/删除本地文件
查看扫描证据(共 13 处,展示前 13 条)
- 写入本地文件lib/index.js:600
appendFileSync(logFile, `${new Date().toISOString()} [${source}] ${message}\n`) - 写入本地文件lib/index.js:606
mkdirSync(dirname(file), { recursive: true }) - 写入本地文件lib/index.js:608
writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, 'utf8') - 写入本地文件lib/index.js:609
renameSync(tmp, file) - 读取本地文件lib/index.js:615
return JSON.parse(readFileSync(file, 'utf8')) - 写入本地文件lib/index.js:634
renameSync(file, archiveFileName(file, reason)) - 删除本地文件lib/index.js:636
try { unlinkSync(file) } catch { } - 执行外部命令来自配置/变量lib/index.js:655
execFile(file, args, options, (error, stdout, stderr) => { - 读取本地文件lib/index.js:668
try { return statSync(file).size } catch { return 0 } - 读取本地文件lib/index.js:674
const data = readFileSync(file) - 删除本地文件lib/index.js:1072
try { unlinkSync(resolved.pendingFile) } catch { } - 删除本地文件lib/index.js:1092
try { unlinkSync(resolved.pendingFile) } catch { } - 删除本地文件lib/index.js:1556
try { unlinkSync(resolved.pendingFile) } catch { }
扫描于 2026/10/31(1 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库
静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。
依赖的 DSH / Cordis 模块
@deepseek-ai/cordis@deepseek-ai/dsh-llm@deepseek-ai/dsh-tools给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
DSH 宿主插件:给模型一个 restart_dsh 工具,重启 dsh 后自动恢复原会话续跑 (DeepSeek Harness)
README
由 DeepSeek 最新模型翻译生成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。
展开完整文档(README 剩余 1 节)
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/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.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-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 桌面端参数与演练开关
| 参数 | 默认 | 含义 |
| -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,一个进程都不杀)
1. 桌面模式缺 -DesktopExe(或路径不存在);
2. 桌面模式没显式给 -Port;
3. 插件注入的 pid 存在、但不属于目标 exe 的进程树(exe 路径对不上,或它的父进程不在这棵树里)—— 防 pid 复用;
4. 按 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 重新监听 → 本会话收到续跑消息 | 见下方"两次失败与修法" |
真机验收踩到的两个坑(都已变成测试里的回归锁):
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 条全是模型发起的 工具调用:)。
| # | 判据 | 结果 |
| 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。预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
liuyun847/dsh-host-restart
重启 dsh 后自动接回原会话继续对话
相关插件 · 对话 / 记忆
上下文中枢oceanbase/powercontext
上下文跟着工作走 —— 跨会话、跨智能体、跨人。
★ 1,462已汉化DSH
73
智能体记忆库plastic-labs/honcho
Honcho 是记忆基础设施,用来构建有状态的智能体。这些智能体能理解不断变化的人、智能体、群组、项目和想法。
★ 7,848已汉化DSH
71.7
长程智能体控制面loopx-project/loopx
给 Agent 一个目标,让工作持续向前。
★ 6,358已汉化DSH
71.3
智能体上下文数据库volcengine/OpenViking
给 AI 智能体用的上下文数据库
★ 41,246已汉化DSH
71.3
长效机器人团队Fei2411/DS-Bot
DS Bot 是一个基于 DeepSeek Harness简称 dsh的 bot 插件,插件将 dsh 改造为 bot…
★ 4,717已汉化DSH
71.1
可携带记忆层EverMind-AI/EverOS
为什么选择 EverOS
★ 13,711已汉化DSH
71.1