dsh-restart
Manifest validRefresh / Restart — The two persistent buttons in the top bar of the DeepSeek Harness Desktop version (refresh + one-click restart)
@ray1270/dsh-restart
给 DeepSeek Harness 桌面版加两个常驻按钮,就在窗口顶栏「应用」「编辑」右边:
应用 编辑 刷新 重启
- 刷新 = 重新加载这个页面。Host 不动、会话记录不动、正在跑的 agent 也不动,只把这个 Web 客户端重新装一遍。加载完仍然停在你刚才那个会话。
- 重启 = 重启整个应用(Electron 壳 + Host)。点两下,2–4 秒后应用带着你刚才那个会话重新打开。
为什么需要它:桌面版的端口每次启动都是随机分配的(
--port 0),所以每次重启等于换了一个 origin——旧页面失联、壳自动开新页面、你被扔回首页。想「重启一下看效果」原本要:关窗口 → 找图标 → 等启动 → 手动点回会话。刷新则解决另一半问题:客户端插件的改动(slot 注册、界面代码)只需要重新加载页面就能生效,没必要为此重启 Host。但 DSH 的当前会话只活在页面里、不在 URL 上,手按 F5 会掉回首页——所以这个按钮顺手把会话也带回来了。
用起来是什么样
| 状态 | 顶栏显示 | 说明 |
|---|---|---|
| 空闲 | 刷新 重启 | 两个都是普通菜单项,样式与「应用」「编辑」一致 |
| 点「刷新」 | 刷新中 | 约 0.1 秒后整页重载;回到同一会话(2 分钟内有效) |
| 点「重启」第一次 | 确认重启 / 确认重启 · N 项 | 预检;N = 此刻会被中断的 agent / job / 终端数量;4 秒不点自动撤销 |
| 点「重启」第二次 | 重启中 | 1–3 秒后应用退出,随即自动拉起;回来时停在同一会话(30 分钟内有效) |
| 失败 | 重启失败 / 刷新失败 | 6 秒后复位;原因在 tooltip 里 |
为什么刷新只要一下、重启要两下:刷新不会杀掉任何服务端的东西(agent 继续跑、会话继续写),代价最大只是输入框里没发出去的草稿会丢——tooltip 里写明了。重启会真的中断活动任务,所以必须让人看清数字再决定。
顶栏那条菜单是桌面壳自己画的(preload 里的 shadow DOM),插件只能在它后面追加同级按钮——所以样式、字号、悬停、拖动区都和原生菜单项一致,窗口变窄也不会挤到右边的窗口按钮。
不是 Windows 桌面版、或者壳没有画出这条菜单时,同一对按钮回落到会话标题栏最右侧(macOS / 纯浏览器里就是这个)。两条入口共用同一套逻辑,同时在屏幕上时顶栏那份优先,标题栏那份自动让位。
没有任何弹窗,也没有控制台窗口一闪而过——重启全程屏幕上只有应用自己关掉再打开。
安装
已发布到 npm,一行命令:
dsh plugin --profile desktop add @ray1270/dsh-restart
装完重启一次应用才会加载(这一次只能手动重启 —— 鸡生蛋),之后就靠这两个按钮了。
dsh plugin add 走的是 pnpm add 到 profile 里,再把包名追加进 dsh.profile.bundles,不用手改任何文件。应用里的 插件 → 添加插件 走的是同一条路,只是多一个填路径的输入框。
不想走 npm 就下载附件:到 Releases 拿最新的 ray1270-dsh-restart-<版本>.tgz,在 插件 → 添加插件 里填它的绝对路径。两条路装的是同一个文件 —— release 附件和 npm 上那份 tgz 的 sha256 逐字节相同(每个版本都核对过)。
想自己再核对一遍:
Get-FileHash .\ray1270-dsh-restart-<版本>.tgz -Algorithm SHA256 # 与同名 .sha256 文件比对
从源码装(开发):
dsh plugin --profile desktop add "<这个目录的绝对路径>"
装完同样要重启一次应用。
它到底怎么做的
刷新:纯客户端,Host 不参与
[浏览器] 点「刷新」
│ 1. 把当前 sessionId 写进 localStorage(同源,重载不丢)
│ 2. location.reload()
▼
[页面] 重新加载:client 插件重新拉取、重新挂载
│ 3. 读到标记 → uiWorkspace.openSession(sessionId) → 删标记
▼
[浏览器] 停在同一个会话
刷新不跨 origin,所以标记放浏览器存储就够了,不必走 Host 文件——刷新这条链路上没有任何一次网络请求(harness 里用「fetch 调用次数 = 0」盯着这一点)。
重启:Host 侧排程,helper 由 WMI 服务启动
[浏览器] 点第二下
│ POST /api/dsh-restart/restart { sessionId, force }
▼
[Host] 1. 活动工作检查(force 才放行)
2. 写 pending-restore.json(重启后要回的会话)
3. 起一个短命的 powershell,让它调用
Win32_Process.Create → 由 WmiPrvSE.exe 创建 helper.ps1
(创建时带 SW_HIDE 启动信息:helper 生来没有窗口)
4. 等 helper 写出 boot 状态 → 才回 200
│
▼ helper 的父进程是 WmiPrvSE,不在应用的进程树里
[helper] 5. 复核 ShellPid 的镜像路径仍等于目标 exe(防 PID 复用)
6. taskkill /F 只杀 Electron 壳(**不加 /T**)
7. 等 Host 自己退出(最多 8 秒,超时才补刀)
8. Start-Process 重新拉起 exe(失败重试一次,并校验新进程还活着)
9. 结果写 restart-state.json
│
▼ 壳一死 → Host 的 IPC 管道断开 → process.once("disconnect")
[Host] application.shutdown.shutdown(0) —— 它自己的优雅退出,5 秒上限
关键点 1:只杀壳,不杀树。 Host 子进程注册了 process.once("disconnect", () => stop()),壳一死它就自己走完整的 dispose 流程。所以「硬杀」只硬在壳这一下,Host 侧仍然是优雅收尾。
关键点 2:helper 必须活着跨过「壳死掉」那一刻——普通子进程做不到。 这一点最初是错的,实际表现就是「只有关、没有开」:helper 写下 boot、写下 killing、taskkill 发出去了,然后它自己跟着壳一起消失,重拉那一步根本没执行。应用关掉,再也没回来。
原因:这个应用的子进程树不活过壳(壳被杀 → 整棵树一起收走)。所以 helper 不能是 Host 的子进程,也不能是壳的孙进程——它必须由进程树之外的进程创建。Win32_Process.Create 由 WmiPrvSE.exe 执行,helper 的父进程是 WMI 服务,壳死的时候它毫发无损。helper.log 的 boot 行会记下 parent=…(WmiPrvSE),这是这条链路生效的直接证据。
关键点 3:不用 detached: true,也不靠它。 在 Windows 上,Node 用 detached 启动 powershell.exe 会让它立刻以 0 退出——一行都不执行(实测)。而且 detached 本来也不提供进程树隔离(那需要 CREATE_BREAKAWAY_FROM_JOB,而这里的 job 不允许)。真正让 helper 活下来的是「由谁创建」,不是「怎么 detached」。
关键点 4:WMI 启动失败就放弃重启,不赌。 短命的 launcher 把结果写进 launch-result.json;Host 等不到 helper 的 boot、或 WMI 明确拒绝,就直接回 500 并写 cancel-restart 标记——还在延迟中的 helper 看到标记会立刻放弃,不会在界面已经说「没重启」之后再把应用关掉。
关键点 5:创建的进程要「生来就是隐藏的」,不然屏幕上会多一个 PowerShell 窗口。 WMI 的 Win32_Process.Create 不带启动信息,控制台程序(helper 是 powershell.exe)便会在创建的那一刻拿到一个可见的控制台窗口——实测它会一直留在屏幕上,直到 helper 自己退出。-WindowStyle Hidden 救不了:那个开关是在窗口已经存在之后才去隐藏,实测无效。所以 launcher 构造 Win32_ProcessStartup(ShowWindow = 0,即 SW_HIDE)一起交给 Create,进程一开始就不可见。
为什么不用 ctx.appExit(生态里现成的「安全重启」)
ctx.appExit(0) 在桌面版等于让 Host 自杀。Host 一退,壳的 child.once('close') 就判定为异常终止,弹出「应用意外停止」恢复框,要用户再点一次「重启」。
那条路能用,但:多一次点击、日志里留一次假崩溃、语义上是「应用崩了」。这个插件选的是「杀壳 + 重新拉起」,所以能一次点完。appExit 那条路留作兜底(见下面的自救)。
配置
写在 profile 的加载行 config: 里(也可以直接改 cordis.patch.yml):
| 键 | 默认 | 含义 |
|---|---|---|
enabled | true | 重启总开关;false 时重启会报「不可用」并拒绝一切重启请求(刷新不受影响) |
delayMs | 1200 | 动手前的等待,用来保证 HTTP 回执先到浏览器 |
hostGraceMs | 8000 | 等 Host 自己优雅退出的上限,超时才强杀 |
killTimeoutMs | 15000 | 等旧进程彻底消失的硬上限 |
pendingTtlMinutes | 30 | 「重启后回哪个会话」标记的有效期 |
刷新没有配置项:它不碰 Host,也就没有可配置的危险动作。
出问题了怎么自救
按可能性排序:
- 点了没反应、应用还在。 helper 没起来(WMI 被禁用/拦、脚本被删)。此时应用完全没受影响——这是刻意的:Host 等到 helper 的
boot状态才回 200,等不到就直接返回 500 并放弃重启。按钮会在 12 秒后变成红色提示。先看~/.dsh/dsh-restart/launch-result.json(WMI 那一步的返回码和错误),再看helper.log。 - 应用退出了但没回来。 说明 helper 在杀之前就死了(旧版本的症状),或者
Start-Process被拦。手动双击D:\dsh\DeepSeek Harness.exe即可;restart-state.json里会写着停在哪一步,helper.log的 boot 行会写出 helper 的父进程是不是WmiPrvSE。 - 点「刷新」后回到了首页。 说明会话标记没能写下(无痕模式、存储被策略关闭)或会话已被删。刷新链路失败一律静默退回首页,不会卡住。
- 想彻底绕过这一切。 托盘右键退出,再双击图标——和以前一样。
状态文件都在 ~/.dsh/dsh-restart/:
| 文件 | 内容 |
|---|---|
restart-state.json | 最近一次重启的阶段(boot → killing → killed → relaunching → relaunched / failed / aborted) |
helper.log | 逐行带时间戳的过程日志,boot 行带 helper 的父进程 |
launch-result.json | WMI 创建 helper 那一步的结果:ok / pid / returnValue / windowHidden / error |
pending-restore.json | 待恢复的会话标记,被下一次页面加载消费后即删除 |
cancel-restart | 只在 Host 放弃一次启动时短暂出现:还在延迟中的 helper 看到它就退出,不再动手 |
已知限制
- 刷新不重载 Host 侧插件。 它只是把浏览器这一半重新装一遍:客户端插件、原生菜单、样式都会更新;Host 插件(路由、服务)必须用「重启」。
- 刷新会丢没发出去的草稿。 输入框内容只活在页面里。已发送的对话、正在生成的回复、后台 job 都不受影响。
- 顶栏入口依赖桌面壳的菜单条。 那条菜单是
preload用data-windows-menu标记 + open shadow root 画的,不是 Web 客户端的 slot。插件按「探测不到就退让」处理:找不到宿主就静默不挂载,标题栏入口顶上;壳若在未来版本改了结构,最多是回到标题栏,不会报错也不会留半个按钮。 - 同一时刻只有一个入口在屏幕上。 顶栏挂载成功时标题栏那对会渲染成空(但仍然挂着,因为它是唯一拿得到
sessionId的地方,用来做刷新/重启后的会话回填)。 - 重启只支持桌面版。 CLI / headless 的 Host 没有壳可重启;检测到这种情况时按钮会明确报「不支持」。刷新在任何环境都可用。
- 重启会中断进行中的工作。 确认框里的数字就是会断掉的东西,确认过就是真断。会话记录是追加写,丢的是进行中那一轮和 job 输出。
- 重启后端口会变。 旧标签页一律失联,壳会开新页面;远程访问的地址也会变。刷新不会。
- 重启依赖 WMI 服务。 helper 必须由
WmiPrvSE.exe创建,否则它活不过壳被杀的那一刻。WMI 被策略关掉、或Win32_Process.Create被安全软件拦住时,重启会明确拒绝(应用不受影响),错误和返回码写在launch-result.json。这是有意的取舍:宁愿不重启,也不要关掉一个回不来的应用。 - 杀软可能好奇。 「隐藏窗口的 PowerShell 杀掉应用再拉起」是 EDR 的经典启发式,加上 WMI 创建进程就更容易被盯。所以 helper 是明文的、带完整注释的、固定路径、日志可查,没有做任何编码混淆;
launch-result.json连 WMI 的原始返回码都留着。 - 极端情况下仍可能看到一个控制台窗口。 如果某台机器上
Win32_ProcessStartup构造失败,helper 就会退回「可见窗口」启动(重启本身照常工作):launch-result.json里windowHidden会是false,Host 日志也会留一行 warn。属于「看得见、可查」,不是静默发生。
开发与测试
没有任何构建步骤:lib/index.js(Host 半区)、lib/client.js(浏览器半区)、lib/helper.ps1(重启动作)都是可以直接读的源码。
node scripts/smoke-host.mjs # Host 半区 37 项:路由、状态、活动工作计数、标记消费、命令行/WMI 脚本拼装(含隐藏窗口)
node scripts/smoke-client.mjs # 浏览器半区 27 项:装载、slot 注册、两条入口、会话续接、刷新标记
node scripts/selftest-helper.mjs # helper 真机自检 23 项:直启与 WMI 启动各跑一遍杀进程/等退出/重新拉起、取消标记、PID 复用防护,并扫屏确认没有控制台窗口
node scripts/package-check.mjs # 15 项:打包、解开、验包,再用包里那份 lib 跑一遍上面两个 smoke
node scripts/install-check.mjs # 13 项:把 tgz 真的装进一个新建的 profile,再让 launcher 组合它(需要 PATH 上有 dsh 和 pnpm;CI 里也跑这一步)
前三个看的是源码树,后两个看的是发出去的那个 tgz。这两者会分叉:files 漏写一个文件、bundle patch 没进包、exports 指错路径——源码树全都正常,用户装上去什么都没有。package-check 把包解开、用它自带的 lib/ 再跑一遍 smoke;install-check 走得更远一步,真的让 launcher 把这个包装进 profile。两条都是「别相信源码树正常就等于包正常」。
selftest-helper.mjs 用一次性的 powershell.exe 进程冒充「壳」和「Host」,不会碰真正的 DSH,因为它自己正跑在里面。第 2 组用的是和 Host 完全相同的 WMI 启动路径,并断言 helper 的父进程是 WmiPrvSE.exe——也就是说,「helper 不随壳一起死」这一点是跑出来验证的,不是推断的。同一组还会在 helper 运行期间真的枚举屏幕上的可见窗口(scripts/find-visible-window.ps1),确认没有那个标题为 dsh-restart helper 的控制台窗口;扫描器本身先对着一个已知可见的窗口验证过「看得见才算数」,免得负断言变成一个瞎子的断言。
手工打开的浏览器 harness(file:// 打开即可):
| 文件 | 断言 | 覆盖 |
|---|---|---|
scripts/caption-harness.html | 23 | 挂载时机、按钮顺序、字号/高度继承、两击重启契约、sessionId 进入 POST body |
scripts/refresh-harness.html | 10 | 真重载:点刷新 → 页面真的重载 → 标记存活 → 回到同一会话 → 标记被消费 |
refresh-harness.html 分两阶段跑(结果和「已点击」状态放 sessionStorage,跨重载存活),因为要测的行为本身就是「页面会消失」。
浏览器半区的 slot 位置来自 DSH 自带的 slot 目录(dsh-cordis-client-runner 里的 conversation.session.header.utilities:右对齐、可叠加、注入 sessionId)。顶栏那条菜单条则来自 app.asar 的 lib/preload-app.cjs(installWindowsMenu)。
发布(维护者)
打 tag 就算发布,没有手工上传的那一步:
# 1. 改 package.json 的 version(tag 必须与之一致,否则 release 流程第一步就红)
# 2. 提交
git tag v0.3.7
git push origin main --tags
release.yml 随后在 windows-latest 上按这个顺序做六件事:
- 比对 tag 与
package.json的 version(不一致就红,不打包); - 跑
npm test; - 用同一个 checkout 打
npm pack,附上.sha256; - 连着验两次这个 tgz。
package-check把它解开:files承诺的、main/exports指向的、bundle patch 和lib/helper.ps1都必须在包里,最后把包里那份lib/index.js和lib/client.js拉起来跑各自的 smoke(37 项 / 27 项)。install-check再往前一步:把它真的装进一个刚建的 profile,让真 launcher 组合一遍,再读回它组合出来的东西(13 项); - 删掉这个 tag 上可能已有的 release,重新建一个公开的,正文由
scripts/release-notes.mjs从上一个 tag 之后的提交生成。 - 把同一个 tgz(不是重新打包)
npm publish到 npm。默认走 trusted publishing:这一步拿 runner 的 OIDC 身份换一个只够发一次的凭据,--provenance顺带给 tarball 签一份来源证明,仓库里没有任何 npm 密钥。发完等 registry 真的能看到这一版(问的是 version 文档、带 nonce,绕过所有缓存;最多两分钟),再把 npm 提供的那份下载回来、和本地.sha256比一遍——字节一致才算过。只有「既没配 trusted publisher、也没有NPM_TOKEN」(比如本地打 tag)才打印一行跳过;该发而没发出去是红的。
trusted publishing 已经配好了,不用再去网页上点设置。当前关系随时可复核:
npm trust list @ray1270/dsh-restart
# type: github / id: 7414ac6a-742e-44c2-aa23-866885d25bed
# file: release.yml / repository: Ray1270/dsh-restart
# permissions: publish, stage publish
它是用 CLI 建的,代价只有一次 OTP(2FA),不需要开浏览器:
npm trust github @ray1270/dsh-restart --file release.yml \
--repo Ray1270/dsh-restart --allow-publish -y
要复刻到别的包上,前提和约束是:
- npm 上
@ray1270得先是你的(注册同名账号即自动持有这个 scope); --file填 workflow 的文件名(release.yml),不是路径;environment留空就是不绑定 environment;发布这一步必须和它写在同一个文件里(复用 workflow 会改变 npm 看到的那个文件名);- 仓库里不用加任何 secret:
release.yml已经有permissions: id-token: write,发布步骤靠 runner 的 OIDC 身份换凭据。npm CLI 得 ≥ 11.5.2(node 24 自带的够用),发布步骤会先查版本,太老就带着明确信息红掉,而不是丢一句「认证失败」。
NPM_TOKEN secret 仍然被支持(有就优先用它):Classic 的 Automation,或者 Granular 里勾 Read and write、只给这一个包。但 npm 已公布 bypass-2FA token 的直接发布会在 2027 年 1 月收回(changelog),所以默认路径是 OIDC。
之后打的 tag 会自动多一份 npm 镜像;已经发过的版本不会被重复发布(重跑同一个 tag 会走「已经有了」那条路,并且仍然比一遍字节,不会盲跳),所以 workflow_dispatch 那条恢复路径仍然安全。
判断「npm 上有没有这一版」不能读 packument:它走 CDN,发布会滞后一两分钟。v0.3.7 就是这么红过一次的——05:12:59 发布成功,05:13:18 的检查判红,而 registry 上 05:14:35 才出现。所以现在读的是 /@ray1270%2fdsh-restart/<version>?t=<nonce> 这个 version 文档,并且最多等两分钟。
第 4 步里的 install-check 需要一个真的 launcher,而在一台 windows runner 上 npm install --global pnpm @deepseek-ai/dsh 量到 13 分 16 秒(654 个包)。第一次把它加进流程时,发布就是卡在这里,然后被我拿掉了。
后来发现同一台镜像上换成 npm i -g --prefix "$RUNNER_TEMP/dsh-launcher",同样 654 个包 64 秒(又一次 78 秒)——区别只是写到 C: 盘的默认前缀,还是写到临时盘。所以现在用的是后者,.github/actions/launcher/action.yml 里就这一段加一行 PATH;CI 里也给它单开了一个 install job,整轮约 1 分半。
中间还试过按 launcher 版本缓存那个前缀:197 MB,恢复 40 秒,对比 64 秒的安装——省下半分钟,代价是一个会失效的缓存、一段「缓存里的东西还跑得起来吗」的探测,以及两处要同步的版本号。删了。
顺序是刻意的:先验版本、再跑测试、再验包、再验装、最后才发。任何一步红了都什么都发不出去,所以 Release 里那个 tgz 一定是「跑绿了、包里那份代码真的能起来、而且真能装进 profile」的那份。
两条容易踩的坑,都是这台流水线自己踩出来的:
- 发布说明不能用
--generate-notes。 那个功能只列「合并的 PR」,而这个仓库是直接往main推的,v0.3.3 第一版发出来时正文就只有一行Full Changelog。 - 不能用
gh release edit就地改。 tag 被移到新提交之后,旧 release 对外就查不到了(GET /releases/tags/vX返回 404),但带 token 的gh release view还找得到它——于是编辑「成功」了三次,公开 API 里始终只有一个更老的版本。所以现在是删掉重建。
这两条的共同点,也是第 3 步存在的理由:流水线的绿灯说的是「命令返回 0」,不是「东西对了」。 所以每一步都回头看结果——读回 API 看 release 是不是公开的、正文够不够长;解开包看里面是不是真的齐。
发布完还会读回 API 校验:isDraft 必须是 false、正文长度必须够;不对就用 ::error:: 让这一步红掉。想重发一个已存在的 tag(比如某步一时抽风),用 workflow_dispatch 再跑一次即可——它会重建 release,说明也一起重算。
卸载
dsh plugin --profile desktop remove @ray1270/dsh-restart
或者从 profile 的 dsh.profile.bundles 里删掉那一行,再重启。
残留物只有 ~/.dsh/dsh-restart/ 这个目录,删掉即可。
Comments
Loading…
Similar plugins
by mwk719
给 DeepSeek Harness Desktop(Windows)的标题栏加一个「重启」按钮。点一下弹出确认框,确认后应用关闭并重新打开。
★ 0
MIT
JavaScript
Nov 2, 2026
dsh plugin --profile web add dsh-restartby PPsz-qqq
DeepSeek Harness plugin: one-click restart button in the top-right of the session header (Windows desktop + dsh web). 一键重启 DeepSeek Harness 客户端。
★ 5
↓ 104/wk
MIT
JavaScript
Oct 3, 2026
dsh plugin --profile web add dsh-restart-buttonby momiji42
Restart button in the DSH desktop title bar + Start with Windows switch, in one plugin.
★ 0
JavaScript
Nov 3, 2026
dsh plugin --profile web add dsh-desktop-toolsby 13520rygvv
DSH desktop plugin: a one-click restart button in Settings, plus a customizable shortcut.
★ 1
JavaScript
Oct 27, 2026
dsh plugin --profile web add @local/dsh-restart-buttonby StabCut
DSH Desktop sidebar restart button: orderly relaunch of DSH Desktop from the sidebar settings row (based on desktopActions.requestRestart).
★ 0
MIT
JavaScript
Aug 21, 2026
dsh plugin --profile web add dsh-plugin-restart-desktopby IHS470
DeepSeek Harness restart button in the DSH desktop window title bar
★ 3
↓ 167/wk
MIT
JavaScript
Oct 3, 2026
dsh plugin --profile web add dsh-plugin-restart