dsh-self-restart
用一次工具调用重启 DSH,然后在同一条会话里接着往下做。
这个 DSH 插件把「改了到底生没生效」从一套手工流程变成一次工具调用。它包含 DSH 插件的两个半侧:
- 宿主半侧,提供工具
dsh_status、dsh_stale_check、dsh_apply、dsh_restart、dsh_ui_reload、dsh_selfcheck,以及dsh_restart背后的整套机制; - 客户端半侧,补上打包版桌面端缺失的「刷新页面」入口,并充当宿主在页面里的手。
它解决的问题很窄,但很真实:DSH 只在进程启动时加载一次插件的宿主代码,没有热替换,插件
管理器自己会把结果标成 restart-required,界面文案也写着「更改将在下次启动生效」——
但它不会替你完成那次启动,也不会把对话接回来。这个插件补的就是这一段。
这个插件会关闭并重新启动整个应用。 装之前请先读 重启会毁掉什么。它不会自作主张:除非 agent 调用
dsh_restart、dsh_apply或用户点击刷新按钮,否则它什么都不会重启。
目录
它做什么
四件事,按你大概会需要的顺序排列。
1. 告诉你一次改动需要什么。 dsh_stale_check 会把 profile 里每个「从目录安装」
的插件源目录与 DSH 真正在加载的安装副本做哈希对比,并把每个运行时文件的修改时间
与当前进程的启动时间比较,然后给出代价最小的可行动作:
| 结论 | 含义 |
|---|---|
none |
加载器自己就能处理,或者没有实质变化。 |
entries-retry |
有客户端插件加载失败,模块加载器可以原地重试。 |
reload-ui |
需要刷新页面。会话历史、草稿、正在跑的回合都保留。 |
restart |
宿主代码变了,必须换一个进程。 |
2. 只做最小的那个。 dsh_apply 执行上面判定出的动作,而不是一律升级成重启。只改了
客户端产物,不该赔上一次重启。
3. 重启,然后回来。 dsh_restart 写一份票据,把它交给一个在 DSH 作业对象之外
创建的进程,然后立刻返回。等你的下一轮 turn/end 真正落盘后,那个进程会优雅地关闭 DSH,
等壳、宿主与监听端口全部真的消失,重新启动可执行文件,再等新一代进程自报家门。新一代会把
一条真实的用户消息注入到同一条会话里,于是 AI 接着上一轮往下干——它是对话里可见的
一条用户气泡,不是隐藏的系统提示。
4. 它能刷新页面。 打包版桌面端接管的应用程序菜单里没有「重新加载」项(那一项只在
development 构建里存在),所以用户和 AI 都没有任何刷新入口。dsh_ui_reload 与侧边栏底部
的按钮补上这个入口,并且只有在页面确实回来了之后才报成功。
环境要求
- Windows。 重启是基于 Windows 的进程与窗口语义实现的。换平台时工具仍然在,
dsh_ui_reload照常可用,而dsh_restart会以unsupported-platform明确拒绝,而不是做一半。 - 宿主进程与窗口壳进程分离的 DSH 构建——桌面版就是这样。两个 pid 都在运行时发现, 插件不假设它们存在。
- PowerShell,任何受支持的 Windows 都自带;配置后可用
pwsh。 - 没有运行时依赖,没有构建步骤。插件不 import 任何
@deepseek-ai/*, 原因见已知限制——这是刻意的设计选择,不是遗漏。
安装
安装就是一次 bundle 安装:DSH 把包装进当前 profile、注册加载器行、然后应用它。 不要手工改 profile。
1. 把代码放到机器上
git clone <this-repository-url> "%USERPROFILE%\.dsh\dsh-plugins\dsh-self-restart"
目录随便放,上面的路径只是沿用同工作区其它插件的约定。没有构建步骤,也不需要
npm install。
2. 装进一个 profile
在你想要它的 profile 里对 agent 说:
用
plugin_manager install_bundle安装%USERPROFILE%\.dsh\dsh-plugins\dsh-self-restart这个 bundle。
或者走界面:设置 → 插件,把该目录添加为本地 bundle 并启用。
plugin_manager 报 application: "applied" 表示已经生效。如果报 restart-required,
先按老办法手动重启一次 DSH——之后这件事就可以交给插件了。
3. 确认能用
对 agent 说:
调一下
dsh_status。
你应该看到宿主 pid、窗口壳 pid、可执行文件路径、端口、作业外创建原语,以及「还没有重启记录」。 如果原语显示「尚未测量或没有可用者」,那是正常的:原语是在第一次真正请求重启时才测量的。
第一次真正的验证,是来一次无所依赖的重启:
用理由 "install check" 调
dsh_restart,跑完之后再调dsh_selfcheck。
卸载
plugin_manager remove_bundle dsh-self-restart
profile 里不会留下任何东西。插件自己的数据目录
(%DSH_HOME%\plugin-data\dsh-self-restart\)在任何 clone 之外,不属于安装的一部分;
想让票据消失就自己删掉它。你放在那里的 config.json 也是你的,同样不会被删除。
重启会毁掉什么
这不是一次温和的操作,把它说成温和的才是这个插件里最不诚实的地方。
| 会失去 | 会保留 |
|---|---|
| DSH 起过的每一个子进程:shell、MCP server、后台任务,以及任何「从 DSH 内部拉起来的常驻服务」。 | 会话日志。每一段对话(包括当前这段)都在磁盘上,会留下来。 |
| 其它每一条会话正在跑的回合。 | 草稿与未提交的输入,它们存在浏览器里。 |
| 页面内未保存的状态,以及侧边栏里内嵌的浏览器标签。 | 发起重启的那条会话正在跑的回合——它正是被续跑的那一个。 |
| 当前这一代进程、它的内存缓存、以及任何只存在于内存里的插件状态。 | 你有意放在 DSH 之外启动的东西。 |
「会失去」的最后一行正是这套设计长成这样的原因:要跨重启存活的服务不能是 DSH 的子进程, 因为 DSH 会把子进程放进一个 kill-on-close 的作业对象。我自己第一次做这个实验的结局,是 一个系统级的语音合成服务被无优雅记录地强杀,而这个插件也救不了它——它只能让 DSH 自己 体面地结束,而不是被判决死刑。
因为第二行,当另一条会话正在运行、或最近两分钟内活动过时,dsh_restart 会拒绝。
force: true 可以越过,并在票据里记下它越过了。
优雅关闭是刻意模拟「Windows 会话结束」的,而在那个状态下 DSH 会跳过自己的退出确认框。 所以这条拒绝,是唯一挡在「一条会话的重启」与「另一条会话跑到一半的回合」之间的东西。 这也是它存在、而不是弹一个对话框的原因。
一次重启到底是怎么跑的
重启不可能是函数调用,因为调用者活不过它。于是请求变成文件,三个从不同时运行的参与者依次 读取上一个留下的东西。
┌──────────────── DSH:即将被替换的那个世界 ──────────────────────────────────┐
│ 窗口壳(Electron main) │
│ └─ app host(cordis profile) │
│ └─ dsh-self-restart 宿主半侧 │
│ • 六个工具 │
│ • 就绪门:在 turn/end 时 flush 会话 │
│ • 用一个能离开 DSH 作业对象的原语创建执行体 │
│ └─ dsh-self-restart 客户端半侧 │
│ • 侧边栏的刷新控件 │
│ • 长轮询宿主,并在页面回来后回报 │
└─────────────────────────────────────────────────────────────────────────────┘
│ 一个票据目录
▼
┌──────────────── 在 DSH 作业对象之外的一个短命进程 ──────────────────────────┐
│ tools/restarter.ps1 │
│ 等就绪门 → 优雅关闭(WM_ENDSESSION、WM_CLOSE) │
│ → 超时则显式杀掉壳与宿主 │
│ → 等到两者都消失**且**端口释放 │
│ → 重新启动可执行文件 │
│ → 等新一代的启动记录,写回执 │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────── DSH:新一代 ────────────────────────────────────────────────┐
│ 读票据、写 boot.json、用确定性 requestId 把一条用户消息注入同一条会话 │
│ (重复投递永远不会多出第二条) │
└─────────────────────────────────────────────────────────────────────────────┘
文件契约
每次重启一个目录,位于 %DSH_HOME%\plugin-data\dsh-self-restart\restarts\<ticketId>\:
| 文件 | 谁写 | 是什么 |
|---|---|---|
ticket.json |
宿主半侧,在工具返回之前 | 请求:会话、pid、可执行文件、端口、超时、期望指纹 |
ready.json |
宿主半侧,会话已 flush 之后 | 就绪门:「现在可以杀我了」 |
restarter.log |
执行体,只追加 | 执行轨迹,每步一行 JSON |
receipt.json |
执行体,结束时 | 结果:done 或 failed,以及原因 |
boot.json |
投递续跑消息的那个宿主半侧——必然是当前运行的一代 | 「说话的是哪一代进程」的证据:pid、启动时间、模块 hash,以及它覆盖掉的旧记录属于哪个 pid |
delivered.json |
接受这条消息的宿主半侧 | 「续跑消息确实被接受」的证据 |
每次写入都是「临时文件 → fsync → rename」,所以读者永远不会看到半个票据;执行体只追加。
每个票据目录是自解释的:不需要插件在线,用 read 和 bash 就能查一次失败的重启。
boot.json 每次投递都会无条件重写,被覆盖的那条记录的 pid 会留在 previousHostPid 里。
这是刻意的:「先写者赢」的记录,写它的往往是先到的那一代,而即将被替换的那一代完全可能
在退场路上先到——这正是「重启明明成功了,消息却说没重启」的成因。一个 hostPid 与票据
hostPid 相同的记录,诚实的解读是「说话的还是原来那一代」;执行体也不把这种记录当作
新一代起来的证据,会继续等。
为什么执行体必须在 DSH 之外创建
DSH 把子进程放进 Windows 作业对象,它起的一切都会跟着它死——包括执行体,而执行体的全部任务 恰恰是活过 DSH。
「在我作业之外创建进程」没有对应的 API,只有一些把创建工作交给别人的原语。所以插件 选择测量而不是假设:
| 原语 | 为什么可能成立 | 代价 |
|---|---|---|
wmi |
Win32_Process.Create 把创建交给 WMI 提供程序宿主,于是执行体不是我们的子进程,不可能落在我们的作业对象里。 |
一次 DCOM 往返;少数企业策略会拒绝。最先尝试。 |
scheduled-task |
由任务计划服务来运行执行体,结构上的保证与上一条相同。 | 会在系统里留下痕迹,所以默认关闭。 |
detached |
普通的分离子进程。它就是我们的子进程,因此继承我们的作业对象——只有当那个作业不是 kill-on-close 时才成立。 | 零成本,但这个前提无法从进程内部验证。只有点名才会用。 |
测量到底在测什么
最直觉的那个信号其实没用——这是实测出来的,不是猜的。IsProcessInJob(handle, NULL, …)
回答的是「这个进程有没有在任何作业里」,而在 Windows 11 上,它对所有进程都返回 true,
包括 explorer.exe:
explorer pid=6520 inJob=True
DeepSeek Harness pid=4384 inJob=True
pwsh pid=51136 inJob=True
所以「在作业里」区分不了「在 DSH 的作业里」和「在某个作业里」。建立在这个判据上的检查,要么 全部通过,要么——就像本插件的第一版那样——全部不通过,于是在任何机器上都拒绝重启。
真正有意义的是这个进程是谁创建的:
作为我们自己的子进程启动 -> 父进程 DeepSeek Harness.exe (我们的作业,无论是哪个)
通过 WMI 创建 -> 父进程 WmiPrvSE.exe (一个服务:不是我们的作业)
Windows 服务进程不可能落在这个进程的作业对象里,所以它创建的进程也不可能继承它。这就是判据:
探针报告自己的父进程,父进程不是我们,这个原语就胜出。作业位仍然会记进 restarter.log 和
receipt.json,因为重启出问题时它是读者需要的一部分证据——只是它不再充当判决。
如果没有任何原语逃出去,dsh_restart 会带着每个原语各自的结果失败,而不是假装它起的进程能
活下来,并给出两条出路:打开计划任务,或者配置 detached 并接受它「未经验证」。走的是哪条,
轨迹里会记下来。
为什么是「等」而不是「睡一会」
执行体不 sleep 然后祈祷,它轮询三个条件:
- 壳进程消失;
- 宿主进程消失;
- 监听端口释放。
这三个条件没全部满足就启动,会得到单实例交接(新的那份把焦点交给旧的然后自己退出,看起来 和「什么都没发生」一模一样)或者一个需要人来关掉的端口冲突框。等待而不是猜测,是为了避开 这两种结局。
工具
| 工具 | 用途 |
|---|---|
dsh_status |
只读。插件知道的一切:pid、可执行文件、端口、作业外原语、最近票据、配置问题。 |
dsh_stale_check |
只读。按插件给出需要什么动作,以及为什么。 |
dsh_apply |
执行代价最小的动作。dryRun: true 只看不做。 |
dsh_restart |
布置一次重启。立刻返回;重启在你的回合结束后发生。 |
dsh_ui_reload |
刷新页面,并等页面自己回报。 |
dsh_selfcheck |
重启之后:新代码到底加载了没有,重启走到了哪一步。 |
dsh_restart 的实际形态
dsh_restart({
reason: "lib/index.js 改了:宿主会缓存模块实例",
resumeHint: "模块拆分做了一半,下一步是把票据辅助函数挪出去。",
force: false
})
它返回票据 id 和一个原语,以及「请结束本轮」的指令。插件刻意不替你结束回合:就绪门等的正是
你的 turn/end,为的是让工具结果和产生它的那一轮在被替换之前就落盘。
重启之后,会话里会多出这样一条用户消息:
已重启并恢复。请接着上一轮继续。
票据:`20261001T221500-7f3a`
原因:lib/index.js 改了:宿主会缓存模块实例
进程代:原为 16932 -> 现为 20488
上一轮交接:
- 模块拆分做了一半,下一步是把票据辅助函数挪出去。
重新加载的代码的期望指纹:
- `lib/index.js` = `3f0a...`
开始正事之前,先做这两件事:
1. 调用 `dsh_selfcheck`,把它报出的指纹与上面的期望值对比。不一致就直说——新代码没有加载。
2. 读取执行轨迹与回执,位置:`.../restarter.log`;如果记录的 phase 不是 `done`,
请明确指出它停在了哪一步。
然后按交接说明继续原任务。
这是一条带持久 requestId 的真实用户消息,由此有三个值得说清的后果:
- 它可见,用户可以反驳它;
- 它幂等——会话控制器会拿这个 requestId 同时比对待处理收件箱和持久日志,所以无论是 下一代进程重试,还是客户端半侧兜底,都不可能产生第二条;
- 它是模板化的。文本是固定模板加上插件自己产出的值,当前会话之外没有任何东西能往里面 塞任意指令。
消息怎么送进去:三层
投递是「别的环节失败时它必须还成立」的那一环,所以它有三条彼此独立的路径,共用同一个 requestId:
- 宿主,启动时。 新一代起来两秒后,它自己投递所有未投递的票据。这是主路径。
- 页面,二十秒后。 如果还有未投递的,客户端半侧请宿主重试一次。定时器不等于事件, 而这一层在页面起来时触发——那正是有人会发现「说好的那条消息没来」的时刻。它是请宿主 去投,而不是自己写任何东西,并且刻意等得足够久,让第 1 层先赢。
- 宿主,看门狗。 启动后一分钟起、此后每分钟一次,同一个投递再跑一遍。临时的
session/writer-held就是靠这一层自愈的。
三条路径都走宿主进程里的同一把锁。requestId 让顺序重试安全,但并发重试不安全:
两次尝试可能在谁都没写进收件箱之前都判定「没有这条消息」,于是会话里出现两份续跑消息。
test/host.test.mjs 直接驱动了这个竞态。
已经投递过的票据不再是「未投递」,所以第 2、3 层自动变成空操作而不是风险。
配置
没有设置页。 插件不声明 schema,因为声明 schema 就意味着 import 一份宿主运行时包的副本 ——见已知限制。配置是两层,后者按字段覆盖前者:
cordis.patch.yml里插件行的config:;以及一个属于你自己的可选 JSON 文件,在任何 clone 之外:
%DSH_HOME%\plugin-data\dsh-self-restart\config.json
%DSH_HOME% 默认是 %USERPROFILE%\.dsh。这个 JSON 文件在 DSH 运行期间会被重新读取,所以
你可以改完立刻生效,不必重启。不是合法 JSON、或字段类型不对的文件会被记进日志然后忽略:
上一份可用配置继续生效,dsh_status 会列出问题。不是设置项的键也会被报出来,因为写错键名
是最费时间的那种故障。
| 字段 | 默认值 | 含义 |
|---|---|---|
enabled |
true |
总开关。关掉后工具仍在,但每个动作都会拒绝。 |
restart.appPath |
(推导) | 要重新启动的可执行文件。由宿主自己的可执行文件推导,只有推导错了才需要设。 |
restart.appArgs |
[] |
重新启动时的参数。 |
restart.appCwd |
(推导) | 重新启动时的工作目录。 |
restart.port |
0 |
重新启动前必须释放的端口。0 表示从 DSH 公布给子进程的 origin 推导;推不出来时,执行体在关闭任何东西之前会从进程表里查出它。只有在两条路都不对的时候才需要手动设。 |
restart.readyMode |
"turn-end" |
turn-end 等发起重启的那一轮干净结束;immediate 完全不等。 |
restart.closeMode |
"graceful-then-force" |
force 完全跳过优雅路径。 |
restart.primitive |
"auto" |
auto 先试 wmi,允许的话再试 scheduled-task。点名某个原语仍然会测量它,因为「用户点名要 WMI」不等于「WMI 在这台机器上能用」——只有一个例外:"detached" 按你说的算,因为它的成立与否取决于一个 DSH 内部无法检查的前提。走的是哪种,轨迹里会记下来。 |
restart.powershellPath |
(推导) | Windows 上是 powershell.exe;想用 pwsh 就设它。 |
restart.allowScheduledTask |
false |
允许任务计划原语。它与 WMI 一样在结构上可靠,但会在系统里留下痕迹,所以它是备选而不是默认。 |
restart.gracefulMs |
15000 |
等 DSH 自己收尾多久之后转为强杀。 |
restart.deadlineMs |
90000 |
执行体等就绪门多久。超时后继续,并记 forced: true。 |
restart.bootMs |
120000 |
等新一代启动记录多久。 |
restart.ttlMs |
86400000 |
票据的有效期。过期票据永不投递,所以昨天的请求不会在今天突然变成一条消息。 |
resume.enabled |
true |
新一代是否把对话接回来。 |
resume.locale |
"auto" |
auto 跟随 DSH_LOCALE/LANG;也可以强制 "en"/"zh"。 |
guard.refuseWhenOthersAreActive |
true |
有别的会话在跑或刚活动过时拒绝重启。 |
guard.activeWindowMs |
120000 |
别的会话多久内活动过才算「挡路」。 |
watchdog.retryCooldownMs |
5000 |
未投递票据看门狗遵守的最小间隔。 |
staleness.clientChanges |
"auto" |
auto 认为纯客户端改动由热加载覆盖;reload-ui 主动刷新;none 报「无需动作」。 |
刷新控件
dsh_ui_reload 不需要任何人工操作,但有意思的地方在于:打包版桌面端根本没有刷新入口——
它接管的菜单里,重新加载只在 development 构建存在。所以这个插件补了一个,并在侧边栏底部、
设置按钮旁边放了一个按钮,点击前先确认。
刷新比重启便宜得多,插件也在工具说明里写清楚了,免得 agent 无谓升级:
- 保留:会话历史、草稿、以及正在跑的回合——它们都在宿主里,壳会从浏览器自己的存储里 恢复上次打开的会话;
- 失去:侧边栏里内嵌的浏览器标签,以及页面内未保存的状态。
宿主无法向页面推送任何东西,所以客户端半侧长轮询一条私有路由。工具请求刷新时,requestId 会在
location.reload() 之前存进 sessionStorage;新页面读到它之后回报。这就是为什么工具能
带着证据说「已刷新」,也是为什么页面始终不回答时它说的是「未确认」而不是「完成」——
「我问了」和「它发生了」是两件不同的事实。
排查
dsh_restart 报 no-out-of-job-primitive。
没有任何原语创建出「不是本进程创建」的进程。跑 dsh_status 看每个原语各自的结果。把
restart.allowScheduledTask 设为 true 会加上任务计划原语,它与 WMI 有同样的结构保证,
代价是系统里会出现又消失一个计划任务。如果你用别的方式确认了这个 DSH 实例的作业对象不是
kill-on-close——比如让 DSH 的某个子进程活过一次手动退出——就把 restart.primitive 设为
"detached";它会被采用,票据里也会记下「这是未经测量就采用的」。
重启一直没发生,轨迹停在 ticket-written。
执行体被创建了但没活下来,或者没起来。票据目录里的 restarter.log 会有一条 launched
然后什么都没有。核对一下用的原语和 dsh_status 测出来的是不是同一个。
轨迹停在 start 之后就没有了,而且进程代从来没变。
执行体撞上了一个它没预料到的错误。它的第一行在任何事之前就写下了,所以 start 之后什么都没有
意味着它在读票据的时候就死了——而原因几乎总是编码:
Windows PowerShell 读一个没有 BOM 的文件时用 ANSI 代码页解码。宿主的票据是 UTF-8 且不带 BOM,于是一条含非 ASCII 文本的 reason(也就是说,任何用中文写的 reason)会变成乱码,
ConvertFrom-Json拒绝整张票据,而$ErrorActionPreference = 'Stop'让脚本在关闭任何东西 之前就直接结束。
这不是假设:第一次真实的重启尝试就是这样什么都没发生,没有回执也没有任何消息。现在执行体碰的
每个文件都显式按 UTF-8 读写,未处理的错误也会在退出前写下一行 unhandled-error,带上错误信息
和位置。如果你看到那一行,里面就是答案;如果轨迹停在 start 之后什么都没有,那是运行中的
脚本还没有这个修复。
轨迹停在 waiting-ready。
就绪门一直没开:发起重启的那一轮始终没有走到干净的 turn/end。执行体在
restart.deadlineMs 之后仍会继续,并把回执标成 forced: true,续跑消息也会重复这一点,
让下一轮知道它的前一轮尾巴可能缺失。
轨迹走到 closed 但可执行文件没起来。
receipt.json 会是 failed 并带原因。常见原因是 restart.appPath 不对,或者有东西还占着
端口导致单实例交接。
重启正在进行,但你看不到它的窗口。
这是刻意的。执行体是一个 PowerShell 进程,而控制台窗口是人可以关掉、也可以按 Ctrl+C 的东西;
两种操作都会把重启杀死在「DSH 已被要求关闭」和「DSH 已被重新启动」之间,结果是应用停在那里、
再也不会被拉起来。所以窗口被要求隐藏两次:创建进程时传 Win32_ProcessStartup 的
ShowWindow = 0,启动参数里再带 -WindowStyle Hidden。两者都在 Windows 11 上实测过:同一个
WMI 原语,创建出来的进程在没有启动记录时自报 IsWindowVisible() = true,有的时候是
false;而执行体自己的 identity 轨迹行里会写下 consoleWindowVisible,对真实跑过的那次重启
留下证据。如果你确实看到了那个窗口,别动它——此时重启正在半路;轨迹会告诉你这台机器上哪一层
隐藏没有生效。
续跑消息来得晚,或者宿主返回 restart-in-flight。
这是刻意设计。发起重启的那一代,在自己创建的执行体还活着时没有资格报告结果:它会对着
一个正在进行的重启说「没有发生」,而且会把票据消费掉,真正加载了新代码的那一代就再也没机会
开口。这种情况下轨迹里会有一行 delivery-deferred,其他什么都不写,票据保持待投递,宿主
watchdog 和页面会继续重试。等到执行体消失后,同一代就可以如实地报「重启没有发生」——这个
例外正是为「重启静默死掉」准备的。
重启发生了,但对话里没出现消息。
调 dsh_selfcheck:它会报执行体阶段和启动记录是否存在。delivered.json 是投递的证据;
如果 boot.json 在而 delivered.json 不在,说明进程换了但续跑消息被拒了——轨迹里会有会话
控制器返回的错误码(session/not-found、session/writer-held、session/agent-busy)。
下一次启动会用同一个 requestId 重试,所以临时的 writer-held 会自愈。
dsh_ui_reload 报「未确认」。
页面没有在轮询:可能关了,也可能客户端半侧没加载起来。去 设置 → 插件 看有没有失败的客户端
条目,然后用 dryRun: true 跑一次 dsh_apply,看插件是否认为该做一次 entries-retry。
插件根本没加载。
插件没激活时 dsh_status 也不在。到宿主日志里找 dsh-self-restart,并在 设置 → 插件 里
确认该行是启用的。插件声明了三个必需服务(tools、sessions、sessionController);
缺任何一个的 profile 会让它刻意保持不激活,而不是半死不活地跑。
改完代码后 install_bundle 仍报 application: failed。
这是模块缓存,也正是这个插件存在的意义。加载器按 URL import 插件,而 Node 会按 URL
把一个 ES 模块缓存到进程结束——包括 apply 抛过异常的插件,因为 import 本身成功了,只有激活
失败。所以你改完文件重装,装上去的仍是旧的那份已求值模块,报错里的行号也是旧的。判断办法:
# 安装副本是不是你改过的那份代码?
node -e "const fs=require('fs');console.log(fs.readFileSync(process.argv[1],'utf8').includes('required: true'))" \
"$DSH_PROFILE_DIR/node_modules/dsh-self-restart/lib/index.js"
如果它打印 false,而报错还在抱怨文件里已经没有的东西,那就是当前进程抱着上一代不放。
set_bundle、以及 remove_bundle 后再 install_bundle,都只是重新登记那一行,清不掉 Node 的
模块缓存。按老办法重启一次 DSH;之后这件事就交给 dsh_restart。
改工具 schema 时被 JsonSchemaError 拒绝。
DSH 只接受 JSON Schema 的一个子集:type、oneOf、properties、required、
additionalProperties、items、enum、const,加上 description、title、default、
examples 这几个注解。其中 required 必须是挂在 object 节点上的字符串数组——不是书写 DSL
里那种逐属性的布尔值——而且里面每个名字都必须出现在 properties 里。test/host.test.mjs
把这条规则完整镜像了一份,所以写错会在 npm test 里失败,而不是等到安装。
仓库边界
这个仓库可以直接公开,而这一节就是让这句话可被检验、而不是一句声明的契约。
这件事在本插件上比在多数插件上更重要,因为这个插件的存在意义就是关闭并重新启动正在运行的 应用。它在这个过程中观察到的一切都是本机证据:进程 id、会话 id、安装目录的绝对路径、某条 会话的工作目录、回执的内容。
会被提交的内容
源代码、测试、工具、文档与元数据:
.gitattributes .gitignore LICENSE
README.md README.en.md
package.json cordis.patch.yml
icon.svg locale/{en,zh}.json
lib/** 插件本体
lib/index.js 宿主半侧:工具、票据、就绪门、续跑投递
lib/client.js 客户端半侧:刷新控件与长轮询
lib/channel.js 私有路由及其线协议
lib/ticket.js 文件契约,以及建立在它之上的生命周期规则
lib/staleness.js 哪种改动需要哪种动作,以及为什么
lib/detect.js 测量哪个原语能离开作业对象
lib/config.js 默认值、校验、可选文件层
lib/identity.js 一切在运行时发现、而不是写死的东西
lib/template.js 续跑消息
tools/restarter.ps1 活过 DSH 的那个进程
tools/job-probe.ps1 决定怎么创建它的那次测量
tools/verify-repo-boundary.mjs 下面描述的那个检查器
test/** 测试套件
刻意不提交的内容
| 排除在外 | 原因 |
|---|---|
DESIGN.md、docs/**、research/** |
内部方案笔记。它们引用了写作那台机器的绝对目录,公开会泄露那台机器的目录结构。 |
restarts/、plugin-data/、ticket.json、ready.json、receipt.json、restarter.log、boot.json、delivered.json |
重启证据。票据里有进程 id、会话 id 和安装目录绝对路径;回执里有结果;轨迹里有过程。.gitignore 把它们挡在提交之外,边界检查器会在它们一旦被跟踪时失败。 |
*.log、*.jsonl |
调试过程留下的轨迹文件。 |
你的 config.json |
位于 %DSH_HOME%\plugin-data\dsh-self-restart\,在任何 clone 之外;这里再挡一道。 |
.test-tmp/ |
平台拒绝在系统临时目录下创建目录时,测试套件放 fixture 的地方。每次运行后删除;忽略它是为了让一次被中断的运行什么都留不下。 |
node_modules/、coverage/、dist/、build/ |
没有要构建的东西也没有要安装的东西,留在这里只会在 diff 里制造噪音。 |
package-lock.json、pnpm-lock.yaml、yarn.lock |
安装由 DSH 的 install_bundle(pnpm)负责。第二份 lockfile 只会描述另一个解析器并产生漂移。 |
.env*、*.pem、*.key、.credentials.yaml 等 |
凭据与本机环境。 |
这个项目里没有任何形式的秘密:它不存账号、不存 token、不存 API key,也不发任何网络请求。
没有任何本机路径被写死,这是设计使然。 要重新启动的可执行文件来自宿主自己的
可执行文件,壳的进程 id 来自宿主的父进程,端口来自 DSH 公布给子进程的 origin,
数据目录来自 %DSH_HOME%。每一项都可以在配置里覆盖,而每一项都是发现的而不是假设
的——这也正是一个 clone 里不含任何属于作者机器的路径的原因。
自己检验这条边界
npm test # 测试套件,包含文件之间的接缝
npm run verify-boundary # 发现本机路径 / 凭据 / 重启证据 / 超大文件就失败
npm run verify-clone # 把已提交的状态克隆到一个别的名字下,再跑上面两项
verify-boundary 读的是git 会发布的那份文件清单(git ls-files),不是工作区——
你自己那份 config.json、留作诊断的某份回执、以及内部方案笔记,正是这条边界要挡在外面的
东西。它会报出本机绝对路径、形似凭据的文本、重启产物、运行时状态、不是纯 ASCII 的
PowerShell 脚本,以及异常大的文件,发现任何一项都以非零退出。每次 push 之前跑一次。
verify-clone 回答的是 npm test 回答不了的那个问题:你即将 push 的这份状态,自己能立住吗?
它把已提交的树克隆到一个名为 a-checkout-named-something-else 的临时目录,在那里跑测试
套件和边界检查。那个名字就是重点——一个断言「插件目录叫 dsh-self-restart」的测试,在作者机器
上绿、在任何克隆到 restart-plugin 或解压发布 tarball 的人机器上红,而这个检查就是抓它的。
它确实抓到了,而且抓到两次。
同样的性质也被 test/boundary.test.mjs 断言了,所以犯错会先在 npm test 里失败:
两个半侧对路由名的一致、manifest 与自己那份 patch 的一致、宿主半侧不 import 任何宿主运行时包、
客户端半侧保持为模块加载器工厂、以及没有任何已发布文件写死盘符路径。
开发
npm test # 无网络、无副作用、不启动任何进程
npm run verify-boundary # 发布边界
npm run verify-clone # 在别人的目录名下重新克隆并重跑
测试套件只写临时目录。它从不重启任何东西、从不发送窗口消息、也从不接触真实的 DSH 进程:
每一个有副作用的依赖——spawn、PowerShell、时钟、文件读取、操作系统——都是注入的,
所以测试驱动失败路径和驱动正常路径一样容易。
文件划分,以及为什么这么分:
| 文件 | 职责 |
|---|---|
lib/index.js |
宿主插件:工具、票据生命周期、就绪门、续跑投递。 |
lib/client.js |
客户端半侧:刷新控件与长轮询。 |
lib/channel.js |
私有路由与线协议,集中在一处,好让两个半侧都对着它检查。 |
lib/ticket.js |
文件契约。不 import DSH,也没有自己的时钟:每条规则都可直接测试。 |
lib/staleness.js |
哪种改动需要哪种动作。作用在路径列表上的纯函数。 |
lib/detect.js |
逃出作业对象:纯的转义与脚本拼装,加上注入了进程操作的测量循环。 |
lib/config.js |
默认值、校验,以及那一个可选文件。 |
lib/identity.js |
一切在运行时发现的东西。纯解析函数与 process 读取分开。 |
lib/template.js |
续跑消息。 |
tools/restarter.ps1 |
唯一在 DSH 消失过程中运行的代码。刻意不做任何自己的发现。 |
tools/job-probe.ps1 |
一个问题,一个答案,写进文件。 |
已知限制
- 仅限 Windows。 优雅关闭是一串 Windows 消息,强杀路径是
taskkill;这里没有 macOS 或 Linux 的等价实现。 - 优雅关闭依赖 Electron 一个可能变化的既有行为。 向壳主窗口投递
WM_ENDSESSION会让 Electron 发出session-end事件,而这正是 DSH 自己收尾路径的开关。如果将来的 Electron 不再这么做,优雅这一步就只会超时,然后由强杀接管。这也正是它写成「优雅优先、强杀兜底」 而不是「优雅关闭」的原因。 - 没有设置页,也没有 Schemastery 的
Configschema。 声明 schema 就意味着 import@deepseek-ai/schemastery;而插件解析的是自己的 import——那些@deepseek-ai/*运行时包 从 DSH 安装目录解析,而不是从 profile 安装的插件解析,所以 import 它们会绑上第二份 模块实例,它的常量与instanceof判断和正在运行的宿主并不一致。因此本插件用ctx.tools.register直接接受的普通对象形态注册工具,并自己校验配置。代价是真实的: 配置是文件,不是表单。 - 宿主半侧无法主动申请权限升级。 DSH 的升级辅助函数位于一个 profile 插件解析不到的随附 包里,所以插件会尝试,失败后如实说明没有申请升级,而不是假设已经批准。该调用依然受会话 自身的权限模式约束,而「不要结束别人的回合」这条护栏是独立强制的。
- 被重启的会话是用用户消息接回来的,不是系统消息。 这是刻意的——它可见、可反驳、可审计 ——但也意味着对话里会有一条用户没打过的内容。
- 每次启动最多投递一次。 「已接受」的意思是会话控制器收下了这条消息,不代表模型已经开始 回答,因为唤醒 agent loop 是异步的。
- 刷新入口是两个,不是四个。 页面可以由工具调用刷新,也可以由侧边栏底部、设置按钮旁边的 那个按钮刷新。没有快捷键,也没有状态条:快捷键需要通过一个本包并不依赖的客户端服务注册, 而一个静默注册失败的键位比一个从没承诺过的键位更糟。设计里入口集合本来就是未定的, 这里是「不靠猜私有 API 就能做出来并验证」的那个子集。
- 强制重启可能截断上一轮。 执行体会说明、回执会说明、续跑消息也会说明。DSH 在恢复会话时 会自己收尾一个被中断的回合,所以日志始终是完整的——但那段从没被写下来的尾巴是真的没了。
- 子进程依然会死。 这个插件让 DSH 的关闭变成优雅的,而不是保持服务存活;这是两件事。 任何必须活过重启的东西,都得在 DSH 之外启动。
- 它不是崩溃守护。 DSH 自己崩了不会触发任何重启;插件只在 agent 要求它动手时才行动。
致谢
- 私有路由的做法——直接注册到 web server,而不是用
connection.rpc.handle,并讲 DSH 自己的 请求/响应信封——沿用同工作区里dsh-chat-enhance插件确立的模式。 - bundle 形态(
dsh.bundle.patch、dsh.client、locale 文件、图标,以及那套仓库边界检查器) 沿用dsh-task-ask-notify,同样是同工作区的插件。 - 优雅关闭序列建立在 Electron 自身对
WM_ENDSESSION的session-end处理之上,也建立在那条 最早记录该行为的 issue 之上: electron/electron#15880 与 WindowSessionEndEvent。
许可证
MIT © 2026 deadbushxw。
No comments yet. Be the first to write one.