@dsh-restart/one-click-restart
一键重启 DeepSeek Harness:侧边栏「重启 Harness」按钮 + 模型可调用的 restart_harness 工具,在应用退出后由一个独立守护进程把它重新拉起。
安装
方式一(推荐,一条命令)——直接装 GitHub 仓库,仓库已含预构建产物:
dsh plugin --profile <你的 profile> add github:G57651/dsh-one-click-restart
方式二——下载 Releases 里的 .tgz:
dsh plugin --profile <你的 profile> add ./dsh-restart-one-click-restart-0.1.1.tgz
装完重启一次 DSH。之后侧边栏左下角(Settings 上方)会出现「重启 Harness」。
两种方式都不需要 npm、不需要联网装任何依赖、也不需要你手动批准任何构建脚本(插件没有
prepare钩子)。
平台支持(请先读这一段)
| 运行面 | 状态 |
|---|---|
| macOS 桌面端 | ✅ 已实测:点击 → 应用整体退出 → 自动重新拉起 |
web / headless(dsh web) |
✅ 已实测:点击 → 进程退出 → watchdog 拉起 |
| Windows / Linux 桌面端 | ⚠️ 未验证。代码路径会走 web 那条(ctx.appExit),但没有人在这些系统的 Electron 壳上跑过。按钮应当出现,重启行为未经验证 |
桌面端的自动化重启依赖 macOS 的 open / AppleScript quit 事件,因此本质上只在 macOS 上做自动化;其他系统的桌面端会退化为「宿主自己退出 + open 拉起」。
兼容性
- 依赖
@deepseek-ai/dsh-tools: ^0.1.7-rc.1 || ^0.2.0-rc.1,支持 DSH 0.1.7-rc.x 与 0.2.0-rc.x。 - DSH(0.2.0 起)会主动检查插件声明的
@deepseek-ai/dsh-*peer:版本不匹配时,安装会被直接拒绝或回滚,已装的插件行会被置为disabled(表现为「插件没生效」而不是报错)。 - 这是有意为之:客户端 bundle 依赖宿主的模块表与 sidebar slot 契约,这些都随 DSH 版本变化,而宿主不对客户端产物做任何版本检查。宁可让不兼容在安装期报错,也不让它在运行时炸。
- DSH 发布更新的 0.2.x / 0.3.0 时本插件需要再次跟进 peer 范围。届时可用
dsh plugin --profile <p> allow-version ...临时豁免,或等新版本。
功能
- 侧边栏按钮:向 sidebar 底部的
sidebar.footer.actionlist slot 注入按钮。宽栏为「图标 + 文字」整行,窄栏(56px rail)为圆形图标;重启进行中图标持续旋转、文字带跳动点;失败时停止并显示原因。 restart_harness工具:模型/agent 可直接调用(可选delayMs覆盖延迟)。- 令牌保护的 HTTP 路由:
GET /api/restart-harness返回当前令牌(同源、限 loopback),POST /api/restart-harness校验x-restart-token后触发重启。- 客户端不再依赖页面注入:桌面端窗口的 index 来自应用包内的静态
dist,宿主渲染时注入的globalThis.__DSH_RESTART_TOKEN__到不了那个页面,所以按钮改为先从同源GET取令牌再POST。注入仍在(Web 端作为一次往返的快路径),页面已有令牌时不再请求。 - 这条
exact路由会优先于 Connection 包的/api前缀路由,因此宿主通用 API 信任栅栏(loopback Host 校验 + 拒绝sec-fetch-site: cross-site)不会作用到它——插件在 handler 内自行做了同样的校验,非 loopback / 跨站请求直接 403。
- 客户端不再依赖页面注入:桌面端窗口的 index 来自应用包内的静态
工作原理
Node/Electron 进程无法自己拉起自己,所以重启动作交给一个独立进程组里的 watchdog:
detached: true→ 子进程自成一个会话,父进程退出不会连带杀死它;stdio: 'ignore'+child.unref()→ 不持有父进程的管道与事件循环;- watchdog 本身只做三件事,不依赖任何 Harness 服务。
两种运行面的退出策略不同:
- 桌面端(
process.connected):宿主不自行退出。Electron main 会把「非请求性的宿主死亡」当致命错误弹恢复对话框,而 dispose 完成的宿主又会因父 IPC 挂着事件循环不自然退出。所以由 watchdog 用 AppleScript quit 事件优雅退出整个应用、等它真正消失(滞留则升格 SIGTERM/SIGKILL)后open -a拉起。 - web / headless:HTTP 响应冲刷完毕后经
ctx.appExit(0)有界退出,另有硬退出兜底。
配置
在 profile 的 cordis.patch.yml 里按 id 覆盖:
- id: one-click-restart
name: "@dsh-restart/one-click-restart"
config:
appName: "DeepSeek Harness" # open -a 用的应用名
relaunchDelayMs: 1500 # 退出后到拉起之间的延迟
maxRelaunchAttempts: 10 # 拉起重试次数
requestGracefulExit: true # web 路径是否走 appExit
enableHttpRoute: true # 是否暴露按钮用的 HTTP 路由
# restartToken: 固定令牌(默认每次启动随机生成)
# appBundlePath: /Applications/DeepSeek Harness.app
故障排查
| 现象 | 原因与处理 |
|---|---|
| 侧边栏没有按钮 | 装完没重启 DSH;或 profile 的 cordis.patch.yml 里该行被 disabled: true;或 DSH 版本不兼容(见上)。先确认 dsh.profile.bundles 里有 @dsh-restart/one-click-restart。 |
提示 failed to import |
装到的是没有 lib/ 的源码。lib/ 已随仓库提交,若仍发生,检查 node_modules/@dsh-restart/one-click-restart/lib/index.js 是否存在。 |
| 按钮变红「重启失败:unauthorized: restart token is stale」 | 页面持有的令牌与宿主当前令牌不一致(宿主重启后)。再点一次即可——按钮会自动丢弃陈旧令牌并重新取。 |
| 按钮变红「无法连接宿主,重启未开始」 | 取令牌的同源 GET 都没成功:宿主进程已不在、端口不通、或路由被禁用(enableHttpRoute: false)。确认 DSH 正在运行,或检查 profile 配置。 |
| 按钮变红「重启失败:forbidden」 | 请求不是从本机 loopback 同源页面发出的(被反代/远程访问改写了 Host,或 sec-fetch-site: cross-site)。此路由按设计只服务本机页面。 |
| 点了只退出、不重新拉起 | 看 ~/Library/Logs/DeepSeek Harness/crash-*.log。若出现宿主被弹恢复对话框,说明走了非预期路径;macOS 上还需确认 open -a "DeepSeek Harness" 能拉起应用(应用名改了需同步 appName)。 |
| 点了完全没反应 | 应用可能有活跃任务,Harness 自身的退出确认拦下了。 |
从源码构建
pnpm install
pnpm build # tsc 产出 lib/index.js 与 lib/index.d.ts,esbuild 产出 lib/client.js
⚠️ 当前仓库只提交了
src/client/index.tsx(客户端半部的权威源码),宿主半部的src/index.ts与tsconfig*.json尚未纳入本仓库,因此pnpm build的 tsc 步骤跑不起来。宿主的权威产物是lib/index.js+lib/index.d.ts。改宿主半部时请直接改lib/index.js并同步lib/index.d.ts。
lib/client.js 是发往浏览器的 closure-factory 产物(window.__ModuleLoader__.load({ id, factory })),只外部化 react / react/jsx-runtime 两个宿主种子模块。改动 src/ 后请一并提交 lib/,否则 github: 安装拿到的仍是旧代码。
目录结构
├── package.json # dsh.bundle(host patch)+ dsh.client(web 声明)+ exports["./client"]
├── cordis.patch.yml # 贡献给 profile 的 patch layer
├── src/client/index.tsx # 客户端半部:侧边栏按钮(宿主半部源码未纳入本仓库,见上)
├── scripts/relaunch.mjs # 独立 watchdog
├── scripts/build-client.mjs
└── lib/ # 预构建产物(随仓库提交)
许可
变更记录
0.2.0-rc.1(未发版,工作树)
针对 DSH 0.2.0-rc.1 的一轮修复(详见 audit/restart-fixes.md):
- 修掉「按钮 401 unauthorized」根因:桌面窗口的 index 由应用包内静态
dist提供,宿主webserver/index-inject的注入行到不了那个页面,globalThis.__DSH_RESTART_TOKEN__恒为undefined。改为客户端先同源GET /api/restart-harness取令牌再POST;注入保留为 Web 端快路径。lib/index.js的 handler 增加 GET 分支({ token },cache-control: no-store),Allow改为GET, POST。 - 补上被 exact 路由绕过的信任栅栏:该路由优先于 Connection 包的
/api前缀路由,所以通用栅栏失效;handler 内新增 loopback Host 校验与sec-fetch-site: cross-site拒绝(不通过 → 403)。 - 客户端不再把网络错误当成功:取令牌失败时返回失败并给出可操作文案(原来一律
{ ok: true });只有「已持有令牌、POST 确已发出」后的连接中断才按成功处理。 - 配置字段全部
.volatile():7 个字段改为 schemastery volatile 引用,读取统一走新的configValue/readConfig快照,HMR 重载后令牌与设置不会钉死在旧值上。 - 服务名命名空间化:
ctx.provide('restartHarnessToken')→'oneClickRestart.token'(lib/index.d.ts的 module augmentation 同步)。 dsh.client.inject补@deepseek-ai/dsh-client-ui-renderer(slots服务的真实提供者;sidebar 保留)。- devDependencies 对齐 0.2.0-rc.1(原来是 0.1.7-rc.x)。
- watchdog 加固:
--app-name增加白名单校验(非法值回退默认名并告警);killRemaining改为先 pgrep 枚举 PID、ps复核归属后再按 PID 发信号,并打印被杀 PID,不再用pkill -f直接匹配全命令行。
0.1.1
- package.json:
@deepseek-ai/dsh-toolspeer 范围放宽为^0.1.7-rc.1 || ^0.2.0-rc.1——0.2.0-rc.1 起宿主对@deepseek-ai/dsh-*命名空间的 peerDependencies 做兼容性预检,旧范围在 0.2.0-rc.1 上会被预检禁用(stderr 报 "disabling profile plugin")。tools服务名、defineTool、webServer.register(WebRoute)、webserver/index-inject的{kind:'global'}行、ctx.get('appExit')在 0.2.0-rc.1 均未变化,此改动仅为通过预检,宿主侧代码零改动。
No comments yet. Be the first to write one.