上一篇给 dsh web 写了桌面快捷启动插件 dsh-desktop_quick_launcher。发出来之后自己天天在用,用着用着就发现:「能用」和「敢用」之间还差着一大截。于是从 v0.1.1 一路迭代到了 v0.2.7,这篇记录一下中间加了什么。

一句话版本:桌面图标还在,但插件现在管的是启动 / 重启 / 退出三件事,而且在任何情况下都不会切断正在生成的回答。

最想先说的:绝不打断正在生成的回答

装插件、升 DSH 核心、重打补丁,都要重启 dsh web。而一个「朴素」的重启按钮是很危险的:你正在等模型写完一段代码,手一滑点了重启,回答就没了。

所以 v0.2 里,「重启」和「停止」都先过一道门:

  • 未结束的回合会阻断操作。 宿主读取 live session,套用 @deepseek-ai/dsh-session 自己的开放回合判据(由最后一个 turn/start / turn/end 边界决定),返回 409 busy 并列出相关会话。
  • 界面上的处理比报错友好:生成期间按钮直接禁用,并提供一个「等空闲后自动重启」(客户端队列,上限 10 分钟,可取消)。
  • 就算你排了队,退出前宿主还会再查一次:如果这时刚好有新回答开始,重启会被记为 aborted-busy 并取消,页面提示「已取消:检测到新开始的回答」。
  • 逃生口是刻意留的:万一某个回合永久卡死,硬阻断会让重启按钮彻底失效。所以保留了 force: true,但只能通过二次、措辞明确的确认触达(「强制:会中断正在生成的回答」),并且会在重启报告里记上 forced: true。

重启这件事,是怎么才走通的

这部分是 v0.2 里最折腾的:让一个进程「自杀后重生」,在 Windows 上比想象中难。

第一版的思路很自然:宿主 spawn 一个 detached 子进程,让它等宿主退出后拉起新实例。实测直接死 —— 在 Windows 上,这个子进程会在执行任何一条语句之前就随宿主的进程树被杀掉,一行代码都跑不到。

现在的方案是计划任务:

  • 宿主把重启助手作为计划任务启动,它的进程属于任务计划服务,宿主自己的清理动不到它;
  • 但宿主不会「启动完就放心去死」:它会等助手把共享状态文件推进到 handoff 之后,才回响应并退出;
  • 如果助手始终没报到,重启会被取消、服务继续运行 —— 重启机制坏掉,不再等于服务变死。这一点很重要。

助手里还有几个必须显式处理的细节:

  • 计划任务的启动目录是 %SystemRoot%\System32,而且没有 DSH_HOME,两个都得显式还原,否则新实例会找不到自己的家;
  • 助手绝不使用 taskkill /T:它自己是旧宿主的子孙进程,/T 会把它自己一起杀掉;
  • 助手从捕获的 stdout 里解析新实例的 dsh web: http://…/?token=… 一行,留作浏览器 cookie 失效时的兜底。

那页面为什么能自己回来?因为浏览器会话 cookie 的签名密钥持久化在 $DSH_HOME/.credentials.yaml(默认 30 天),能跨重启存活;客户端等 /ping 返回一个和重启前不同的 instanceId,然后重新加载原 URL。另外,同时只允许一次重启 —— inflight 标记让第二次请求拿到 409 restart-inflight,双击出的两个标签页不会起两个助手。

出问题时,别再让人猜

v0.1 时代最难受的是:图标双击了、服务没起来,然后什么信息都没有。v0.2 在诊断上做了三件事:

① 分级探针,把「没起来」拆成四种各有结论的状态。 顺序是:TCP 连接 → 插件 /ping(确认是本插件,拿到 instanceId)→ GET / 指纹(dsh web authentication required)→ 外来服务。于是「DSH 已在运行 / 端口被别的程序占用 / 端口有人监听但从不响应(僵死实例)/ 端口空闲」是可以区分的。只要端口不是空闲,就是一条诊断而不是一次启动尝试:启动器会报告占用者的 PID 和进程名,然后停下。

② 捕获子进程输出。 v0.1 用 -WindowStyle Hidden 把 stdout/stderr 全丢了,失败了只看到一个「代码 1」。现在它们写进 dsh-child.out.log / dsh-child.err.log,末尾 15 行同时进入桌面消息框和报告 —— 通常这就是全部答案。

③ 机器可读的状态文件。 每次运行都会原子写(临时文件 + 改名,UTF-8 无 BOM)launcher-status.json,重启写 restart-status.json;九类失败结论各自带下一步建议;GUI 会在页面加载时把上次启动失败提示一次(一次性横幅),你不必先知道「有日志文件这回事」。

顺带一提,并发也处理了:命名互斥量会串行化同时来的调用 —— 连点三次图标,不会再让三个启动器去抢 3080,多出来的调用转为等待。

面板和设置卡片

右下角的悬浮件现在是一组:桌面图标/详情、停止、重启。

  • 详情弹出面板:实例信息(PID、端口、运行时长、版本)、当前生成状态、上次启动报告、上次重启报告、抓到的子进程输出尾部。
  • 插件还在设置里贡献了自己的一页(settings.section 槽位,和其它插件页并列):
    • 悬浮按钮:三个按钮各有独立开关,可以只留一部分,也可以全部隐藏(全关掉右下角就干干净净);
    • 日志与状态文件:列出各个 log / 状态文件的大小与修改时间,支持查看(末尾)、清空(单个或全部)、刷新、打开目录。状态文件是删除而不是清空,这样面板显示的是「暂无记录」而不是解析错误;
    • 这些路由只接受白名单里的名字(绝不接受调用方传来的路径),写操作需要 nonce。

几个「事后想起来很关键」的修复

  1. v0.2.3:快捷方式开的是裸地址,首次打开必然要求认证。 以前打开 http://127.0.0.1:3080,浏览器没有 cookie 时 DSH 会返回 dsh web authentication required。现在启动器会解析带 token 的 URL(优先 /ping 公布的 authUrl,缓存在 auth-url.txt;其次是自己启动子进程时捕获的那一行),首次加载就是已认证状态。
  2. v0.2.4:把 /ping 的 200 当成了「就绪」。 可那时 SPA 兜底还没注册好,浏览器打开就是 HTTP ERROR 404。现在的启动器有就绪门(Test-GuiReady)和 starting 阶段 —— 只等待、不重复起实例;顺带在 DSH 模态框打开时把悬浮件藏起来。
  3. v0.2.5:启动器脚本「永远不更新」。 快捷方式跑的一直是「创建图标那一刻」写下的脚本 —— 也就是说前面两个版本的启动器修复从未真正生效过。现在宿主每次启动都会幂等重写已有的 launcher.ps1(不创建图标),升级即生效;只有快捷方式被删掉这种情况才需要点一下重建(v0.2.6 加了「一键重建桌面快捷方式」,并在 /status 里暴露 shortcut { name, path, exists })。
  4. v0.2.7:别把「版本错位」报成「快捷方式不存在」。 客户端 bundle 是从磁盘实时加载的,会先于宿主更新,于是旧宿主缺的字段被误报成「快捷方式没了」。现在会显式报告客户端/宿主版本错位并提示重启,字段缺失时显示「无法确认」而不是「不存在」。

接口和配置

给想自己接一下的人。路由一共这几条(完整前缀是 /api/dsh-desktop_quick_launcher/…,状态变更类需要 nonce):

路由 方法 用途
/ping GET 实例 id、nonce、pid、端口、运行时长
/status GET 实例 + busy + 上次启动/重启报告 + 日志路径
/create POST 写启动脚本 + 桌面图标
/restart POST 移交给独立助手后退出
/shutdown POST 优雅退出
/logs、/logs/clear、/logs/open GET/POST 日志与状态文件
/options POST 写入面板开关

安全上,所有路由仅限回环(socket 地址 + Host 头 + sec-fetch-site + Origin)—— 这挺重要,因为 DSH 的插件路由在浏览器登录门之外;另外所有状态变更路由都要求一个由 GET /ping 下发的每实例 nonce(放在 x-dsh-ql-nonce 头里),否则返回 403 nonce-required。它能挡住跨站页面盲发 POST 到 127.0.0.1,但挡不住本机进程(它自己就能读 /ping)。

配置项也变多了(schemastery desktop-quick-launcher 命名空间):restartGraceMs、restartTimeoutSec、busyPolicy(block | warn)、restartMethod(auto | schtasks | detached)、helperStartTimeoutMs,以及三个悬浮按钮的显示开关。

升级 / 安装

1
2
dsh plugin --profile web add github:KLucen/dsh-desktop_quick_launcher
# 重启 dsh web(之后就可以用面板上的重启按钮了)

github.com 不通就走镜像:

1
dsh plugin --profile web add "https://gh-proxy.com/https://codeload.github.com/KLucen/dsh-desktop_quick_launcher/tar.gz/refs/heads/main"

从 v0.2.5 起,升级后不需要再碰桌面图标 —— 宿主启动时会重写脚本,现有快捷方式立刻用上新逻辑。开发的话仓库里有测试:pnpm typecheck && pnpm test 会把真实的宿主半边挂到 stub cordis 上下文上,直接驱动真实的路由处理器,并注入助手的 spawn 与进程退出钩子、把 DSH_HOME 指向临时目录 —— 所以它不会真的拉起助手、不会退出测试进程,也不会碰你的真实 profile 和桌面。

已知限制(如实说)

  • 重启和退出都会终止整个 dsh web 进程:会话会持久化并可恢复,但正在生成的回答不会被保留 —— 这正是它在回合未结束时拒绝执行的原因;
  • busy 检查读取 live session store,如果该服务缺失或 API 变化,检查会放行(并在 busyCheck 里记录原因)—— 改成 fail-closed 的话,重启会永久失效;想要 v0.1 那种「只报告不阻断」的行为,可以设 busyPolicy: warn;
  • nonce 只挡跨站浏览器请求,挡不住本机进程;
  • 所有路由仅限回环:服务器暴露到局域网时,远程浏览器用不了;
  • Windows 图标固定叫 DSH-Web.lnk,同名会被覆盖;
  • Windows 的重启助手是 PowerShell 脚本,在禁止执行 PowerShell 的机器上只剩计划任务路径或手动重启;POSIX 上的重启助手尚未实现(那边只能等宿主退出后手动启动);
  • 仍未发布 npm,安装走 GitHub tarball。

尾巴

从「桌面图标 + 一键退出」到「启动 / 重启 / 退出 + 一整套诊断」,这个插件现在算是到了我自己敢天天用的程度:升级不用担心回答被掐断,出问题不用再靠猜。

仓库还是那个:KLucen/dsh-desktop_quick_launcher(Apache-2.0),有 bug 或想法欢迎开 issue。下一篇大概就是 v0.3 的 POSIX 重启助手了 —— 如果我没偷懒的话。