DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

deadbushxw /

deadbushxw/dsh-self-restart

Verified

DSH 桌面端插件:改完插件后由 AI 自己触发重启,重启完在同一条会话里接着往下做,并补上打包版桌面端缺失的「刷新界面」入口 | DSH (DeepSeek Harness) desktop plugin: restarts the app from a tool call, resumes the same conversation, and adds the page-reload entry point the packaged desktop build lacks

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@6d290e5b

dsh-self-restart

用一次工具调用重启 DSH,然后在同一条会话里接着往下做。

English · 中文


这个 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 然后祈祷,它轮询三个条件:

  1. 壳进程消失;
  2. 宿主进程消失;
  3. 监听端口释放。

这三个条件没全部满足就启动,会得到单实例交接(新的那份把焦点交给旧的然后自己退出,看起来 和「什么都没发生」一模一样)或者一个需要人来关掉的端口冲突框。等待而不是猜测,是为了避开 这两种结局。


工具

工具 用途
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. 宿主,启动时。 新一代起来两秒后,它自己投递所有未投递的票据。这是主路径。
  2. 页面,二十秒后。 如果还有未投递的,客户端半侧请宿主重试一次。定时器不等于事件, 而这一层在页面起来时触发——那正是有人会发现「说好的那条消息没来」的时刻。它是请宿主 去投,而不是自己写任何东西,并且刻意等得足够久,让第 1 层先赢。
  3. 宿主,看门狗。 启动后一分钟起、此后每分钟一次,同一个投递再跑一遍。临时的 session/writer-held 就是靠这一层自愈的。

三条路径都走宿主进程里的同一把锁。requestId 让顺序重试安全,但并发重试不安全: 两次尝试可能在谁都没写进收件箱之前都判定「没有这条消息」,于是会话里出现两份续跑消息。 test/host.test.mjs 直接驱动了这个竞态。

已经投递过的票据不再是「未投递」,所以第 2、3 层自动变成空操作而不是风险。


配置

没有设置页。 插件不声明 schema,因为声明 schema 就意味着 import 一份宿主运行时包的副本 ——见已知限制。配置是两层,后者按字段覆盖前者:

  1. cordis.patch.yml 里插件行的 config:;以及

  2. 一个属于你自己的可选 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 的 Config schema。 声明 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。

—/ 5

No ratings yet

Verified DSH bundle

Commit 6d290e5b12c3

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout