- Accueil
- Plugins
- Développement et outils de plugins
- dsh-plugin-restart-desktop
dsh-plugin-restart-desktop
railgun20001/dsh-plugin-restart-desktop
One-click restart for the DSH desktop app that keeps the window position, size, maximized state and display, with an optional resume of the sessions the restart interrupted (not only the one you had open). The entry is an icon at the right of the sidebar footer account row plus a /restart-desktop slash command; when the account row cannot be recognised it falls back to the sidebar.footer.action seat, so the button does not disappear. Windows desktop only.
Installer
dsh plugin --profile web add github:railgun20001/dsh-plugin-restart-desktopREADME
dsh-plugin-restart-desktop
DSH 桌面版(DeepSeek Harness Electron 壳)的一键重启插件:保留窗口位置、大小、最大化状态与所在显示器,可选在重启后自动续上被打断的会话(不只是你当前打开的那个)。
只有一个入口:侧栏底部账号行右侧的图标按钮(刷新图标)。单击即重启 —— 没有二次确认,有任务在跑时也不要求再点一次(那一路实况只写进悬停提示)。插件不提供任何设置页配置。
| 入口 | 位置 |
|---|---|
| 账号行右侧图标 | 侧栏底部那一行账号(头像 + 昵称)的右边 |
账号行认不出来时(官方改了 DOM)会自动回落到官方给第三方插件的动作座位 sidebar.footer.action(侧栏底部同一行区域的图标按钮),功能不会凭空消失。
另外还注册了斜杠命令 /restart-desktop(一样单击即重启;这条路上的宿主侧警告仍需 /restart-desktop --force 越过)。
为什么需要这个插件
这不是「官方本来就有、只是藏得深」的功能,而是官方没有留任何通道。两条硬事实(都来自解包 app.asar 读主进程源码):
- 主进程没有暴露重启。 完整 IPC 只有
shutdown/quit-inspection/update-tasks(主进程 → host)与ready/fatal/shutdown-complete/platform-session(host → 主进程);渲染进程侧的dshDesktopAPI 只有browser/deviceInfo/keyboard/shortcuts/updates。内置的「重启应用与 Host」菜单项只在 development 分支里加入菜单,打包版永远看不到;Windows 上托盘菜单只有「打开应用」和「退出」。 - 窗口几何从不落盘。
createWindow()硬编码width: 1280, height: 820,主进程里没有getBounds保存、没有 window-state 存储,%APPDATA%\@deepseek-ai\dsh-desktop\下也没有任何窗口状态文件。正常退出再开一定回到 1280×820。
还有一条容易踩的坑:窗口的关闭按钮不会退出应用(主进程 preventDefault + hide,只是收进托盘),所以「关掉再打开」这条最直觉的路并不成立。
所以插件只能自己动手:从宿主进程里派一个分离的 helper,由它结束 Electron 主进程、等端口释放、拉起新实例,再逐像素还原窗口。
它是怎么做的
DeepSeek Harness.exe ← Electron 主进程:窗口、托盘、更新、spawn host
└── DeepSeek Harness.exe ← host 子进程(ELECTRON_RUN_AS_NODE=1),监听 127.0.0.1:19387
└── 本插件的宿主半
└── (分离)helper:node lib/helper.mjs handoff.json
- 宿主半校验目标是不是桌面宿主、把「现在停掉会打断什么」讲清楚、写一份交接描述,然后把 helper 拉起来。
- helper 等 1.5 秒(让 HTTP 响应先送达页面)→ 趁主进程还活着捕获窗口几何 → 结束 Electron 主进程 → 等主进程消失、等端口释放 → 拉起新实例 → 等新窗口出现 → 还原几何。
关键取舍:只杀主进程,不带 /T
主进程一死,host 子进程到它的 IPC 通道断开,host 里的 process.once('disconnect') 会走 application.shutdown.shutdown(0) —— 这和官方正常退出走的是同一个 stop(),会话日志因此正常落盘。带 /T 会把 host 一起强杀,那才是真的可能丢数据。
只有一种情况会碰到 host:端口在主进程消失后仍然不放(说明 host 卡住了)。那一步是最后手段,且会把「它的会话日志可能不完整」如实写进日志。
窗口几何
lib/geometry.ps1 用 GetWindowPlacement 取 rcNormalPosition(用户真正选定的那个矩形 —— 窗口最大化时当前矩形是最大化后的,只有它记录着要还原的尺寸)、showCmd(常规 / 最小化 / 最大化),以及 MonitorFromWindow / GetMonitorInfo(窗口在哪块显示器上,例如 \\.\DISPLAY2),重启后用 SetWindowPos + ShowWindow 放回去,并读回实际值写进日志。
最大化的窗口必须先把位置放回去,再最大化。 Windows 是在「窗口此刻所在的那块屏」上最大化的,而重新拉起的应用总是把窗口创建在主屏(笔记本屏)上 —— 所以对一个最大化的窗口直接 ShowWindow(SW_MAXIMIZE),等于把住在副屏的窗口拽回笔记本屏。现在的顺序是:退出最大化 → SetWindowPos 到捕获到的正常矩形(它标记着用户当时在用哪块屏)→ 最大化。最小化同理:先放回那块屏再最小化,否则从任务栏还原时会弹到主屏。
还原后还会读回窗口所在的显示器并与捕获值比对,wanted / actual / onTarget 一并写进日志与结果文件。若目标屏仍在、窗口却落在别处(正常矩形跨两块屏时会发生),脚本会把它居中到目标屏再最大化一次,并在结果里标 corrected: true;目标屏已经拔掉时不做任何猜测,只如实报 onTarget: false。
这个脚本必须保持 ASCII-only:Windows PowerShell 5.1 对无 BOM 的
-File脚本按 ANSI 解码,一句中文注释的字节被解码坏之后可能连行尾换行一起吞掉,下一行代码就整行变成注释(实测踩过:还原时静默拿不到目标显示器,wanted变成null)。npm test里有一条断言把这条约束钉住。
调研结论:重启后能不能自动恢复对话
能,但形态不是「续跑那一轮」。结论分三层:
| 问题 | 结论 |
|---|---|
| 被打断的那一轮能续跑吗? | 不能。 主进程被结束、host 优雅关停,正在生成的那一轮就没了。官方自己的退出确认框也写「当前正在运行的任务将会中断」。 |
| 会话会丢吗? | 不会。 会话按工作区落盘(session.v4.jsonl.zstd),客户端还把「当前会话」持久化在 dsh-app://app 的 localStorage(dsh.sessions.current),重启后自动回到同一个会话,历史完整。 |
| 那「自动恢复对话」是什么? | 把被打断的会话恢复成活 Agent,注入一条只陈述事实的恢复消息(什么时候重启过、这个会话当时是什么状态),怎么接着做由模型自己按会话记录判断。 |
实现走的是官方通道(源码级核对过):
ctx.agents.resume({ resumeSessionId, setup })—— 把持久化会话恢复成活 Agent。setup会把会话原本的 agent preset 重新挂上,否则恢复出来的 Agent 会丢掉它那一整套工具(bash / read / goal …);preset 解析不到时降级为「不带 preset 工具恢复」并记日志,而不是整个失败。agent.followup(message)—— 往收件箱排一条消息并唤醒驱动。官方dsh-headless与@deepseek-ai/dsh-goal-round-driver都走这两步。- 消息的
source.kind刻意不是'user':Chat 把source.kind === 'user'的用户消息渲染成用户气泡,其余 kind 渲染成「注入的上下文行」。用自定义 kind,这条自动消息在对话里长得像系统注入 —— 它确实是。 - 消息正文是纯报告(英文),只有两句:
The desktop app restarted at … (scheduled by dsh-plugin-restart-desktop).+At that moment this session was running a turn.(或有几条消息在队列 / 几个后台任务在跑;没有明细就写The state of this session at that moment was not recorded.)。用英文是因为宿主自己注入的元信息本来就是英文(被打断工具调用上的ToolOutcomeUnknownError),同一层上下文保持同一种语言。不写 "continue" / "do not retry blindly" 这类指令 —— 重试判据宿主已经写在会话记录里,插件再讲一遍既重复,又可能把说错的话永久写进历史,让模型为了「对齐」它去改动本来没坏的东西。旧版标记没有details时只陈述「重启过」,不拿名单去推断这个会话当时在干什么。
副作用面被刻意收敛到最小(自动发消息会真的花钱、真的跑模型):
- 名单由宿主自己在排程那一刻取实况:只接「agent 正在生成 / 跑工具」或「收件箱里有排队消息」的会话 —— 也就是那一轮对话确实被打断了的会话。刻意不是「你当前打开的那个会话」:在一个会话里挂着长任务、切到另一个会话点重启时,被打断的是后台那个;早先的实现把标记落在空闲的当前会话上,于是
not-interrupted,真正被打断的任务一条恢复消息都拿不到(上报现场就是这个形状); - 后台任务在跑不算「被打断」,只作为明细记进标记。任务活着不等于对话没结束:实测有一个会话挂着跑了 34 小时的
pnpm dev,而它的最后一轮对话在前一天就正常turn/end了;按任务唤醒它只会凭空多跑一轮。应用重启会杀掉后台任务,这件事写在「已知边界」里,不用恢复消息去通知模型; - 新版标记里的
sessionId只是状态页展示用的「客户端当前会话」,不参与恢复名单。旧实现把它无条件补进名单,于是用户在哪个会话里点重启、哪个会话就会被续一轮 —— 哪怕它早已结束(真实事故:最后一轮比重启早 3 分钟就完成了); - agents / jobs 服务读不到、形状不认识、抛错时名单为空 —— 宁可一条都不恢复,也绝不凭猜测唤醒会话;
- 空闲会话与历史会话永不被唤醒;
- 标记带 5 分钟 TTL:宿主要是在几分钟之后才起来(比如用户手动重开),就不再自动发消息;
- 标记在宿主启动时被取走即删,所以一次重启最多恢复一次,恢复失败也不会在下次启动时重放;
- 一次重启可能接上多个会话,每个各发一条恢复消息;其中一个恢复失败不连累其余,失败原因逐个记进日志。
安装
从 npm 装(推荐):
plugin_manager(action: "install_bundle", target: "dsh-plugin-restart-desktop")
或者直接写进 profile —— bundles 与 dependencies 两处都要有:
// ~/.dsh/profiles/desktop/package.json
"dsh": { "profile": { "bundles": [ /* … */ "dsh-plugin-restart-desktop" ] } },
"dependencies": { "dsh-plugin-restart-desktop": "^1.0.0" }
开发本仓库时用 link: 指向本地目录,改完代码立即生效:
plugin_manager(action: "install_bundle", target: "link:C:\work\my\dsh\plugins\dsh-plugin-restart-desktop")
安装后 DSH 的 HMR 会当场把两半挂上,不需要重启(实测:安装后宿主路由立即应答,客户端半的图标按钮立即出现在账号行右侧)。如果入口没出现,刷新一次页面即可。
正常情况下 HMR 会当场把两半挂上,不必重启。只有在没开 HMR 的组合里,宿主半才要等 DSH 下次启动;客户端半每次刷新页面重新取。两半版本不一致时,控制台 __DSH_RESTART_DESKTOP__.status().version 是宿主版本、__DSH_RESTART_DESKTOP__.version 是客户端版本。
只支持 Windows 桌面版(能力探测不通过会明确拒绝,见「已知边界」)。宿主版本要求见
package.json的engines.dsh,作者本机实测于0.2.1-rc.5。
使用
点侧栏底部账号行右侧的刷新图标:一次点击就排程重启 —— 窗口的位置、大小、最大化状态与所在显示器保留,被打断的会话按宿主实况自动续接;页面随后断开,应用重启并回到原来的样子。
单击即重启意味着不拦你:宿主守卫(有任务在跑 / 插件正在安装)不再要求第二次点击,只在按钮的悬停提示里说明「会打断它们」。斜杠命令那条路仍然保留一次警告(/restart-desktop --force 才是「我看过了」)。
配置
插件没有设置页,界面开关一个都不提供。两个宿主侧行为开关仍有默认值(都是开),需要时改插件 config 或宿主侧设置文件:
// ~/.dsh/profiles/desktop/package.json 里本插件那一行的 config
{ "geometry": true, "resume": true } // 还原窗口几何 / 自动续接被打断的会话
宿主侧设置文件 ~/.dsh/restart-desktop/settings.json 优先于 config;也可以 POST /dsh-plugin-restart-desktop/api/settings?geometry=0&resume=0 改。
排障
浏览器控制台里有 globalThis.__DSH_RESTART_DESKTOP__:
__DSH_RESTART_DESKTOP__.status() // 宿主状态快照
__DSH_RESTART_DESKTOP__.button() // 账号行按钮的注入状态(found / injected / lastReason)
__DSH_RESTART_DESKTOP__.sweep() // 立刻重扫一次账号行
__DSH_RESTART_DESKTOP__.action() // 按钮此刻的动作状态
button().lastReason 的取值:injected(这一轮插进去了)、already-present(已经在位)、no-account-row(没找到账号行)、insert-failed、no-document(不是浏览器环境)。
宿主侧的交接记录在 ~/.dsh/restart-desktop/:handoff-*.json、restart-*.log、restart-*.result.json、restart-*.geometry.json(保留最近 20 组)。几何结果文件里除了矩形,还有 wanted / actual / onTarget / corrected(显示器名与矩形),重启后窗口到底落在哪块屏一眼可见。
已知边界(写清楚,免得当成 bug)
- 正在跑的那一轮一定会断,不会自动续跑;自动恢复注入的是一份事实报告,不是「重放那一轮」,也不替模型决定该怎么接着做。
- 正在被工具写入的文件可能停在半路。插件不做回滚。
- 后台任务 / 子 agent / teammate 全部被杀;定时任务在应用关闭期间不会触发。被杀的后台任务不会换来一条恢复消息(对话本身没被打断),需要的话重启后自己再拉起。
- 账号行右侧那个按钮靠观察 DOM。官方没给第三方留「账号行里的按钮」座位(
sidebar.settings是 single 座位,整块由@deepseek-ai/dsh-client-ui-settings-account渲染),所以只能等它渲染出来、按官方写死的data-signed-out+aria-haspopup="menu"认出那个展开按钮,再顺它往上两层定位到账号行、把按钮插在账号行右边。判据里没有一个哈希类名(官方 CSS 模块类名每个构建都变)。认不出来时不猜:回落到官方sidebar.footer.action座位,日志与button().lastReason会写明原因。 - 侧栏收起时不显示这个按钮:Windows 上官方本来就把整个底栏(账号行那一片)隐藏了。收起状态下用斜杠命令
/restart-desktop。 - 只支持 Windows 桌面版,而且是能力探测后的拒绝,不是文档约定:
- 非桌面宿主(
dsh web/ headless):desktop: false,重启返回 409; - 非 Windows 的桌面版(macOS / Linux):
desktop: true但capabilities.restart: false,重启在写交接描述之前就返回 409,原因写明「当前平台(darwin)还没有实现进程控制驱动」; - helper 里还有第二道同样的检查:万一交接描述被别的方式塞进来,它会在等待 1.5 秒之前掉头,不会空等 15 秒再空拉三次。
能力快照由
lib/platform.mjs统一给出,GET /dsh-plugin-restart-desktop/api/status的platform与capabilities字段就是它的原样输出(restart/launchArgs/geometry/alert/reason)。客户端按钮读同一份快照:不支持时直接禁用,悬停提示里就是reason那句话。
- 非桌面宿主(
- 窗口几何依赖
rcNormalPosition与显示器本身:还原记的是「哪块屏 + 哪个矩形」,比对靠显示器设备名(\\.\DISPLAY2)。重启期间被拔掉或换掉的屏不会凭空回来 —— 此时窗口落在系统给的那块屏上,日志与结果文件里onTarget: false会直说(脚本不会往一个不存在的坐标上挪窗口)。各屏 DPI 不同时工作区坐标与屏幕坐标可能不一致,还原结果可能偏移(日志里有 wanted / actual)。
验证情况
已经跑过的(全部通过):
| 层 | 内容 |
|---|---|
单元(npm test,本次 85 项) | 账号行触发按钮的识别判据(严格 / 宽松 / 反例)、重启状态机(单击即执行、busy·done 期间的点击被忽略、失败可重试、非桌面版 / 非 Windows 平台禁用)、平台能力快照(Windows / macOS / Linux / 未知平台,注入式、可在任何机器上跑)、helper 在不支持平台上的即时拒绝、提示文案与颜色语气、当前会话 id 的解析、宿主守卫判据、「哪些会话真的在跑」的快照判据、可信请求判定、交接目录清理、设置读写、恢复标记(新旧两种形态、TTL、跳过)与多会话恢复流程的每一条分支、lib/geometry.ps1 的 ASCII-only 约束 |
| 客户端 bundle 冒烟 | 在 Node 里用假 window / document / React 真正执行 lib/client.js:factory 注册、apply 跑完、账号行已在时只挂提示浮层、账号行认不出来时观察期过后回落到官方座位、刷新后「已重启」提示被消费、诊断入口可用、slots 缺失时优雅退出 |
| 账号行图标按钮 | 用假 DOM 按官方真实结构(footerActions 下面的账号动作区 → AccountMenu.root → Menu 的 span → button[data-signed-out][aria-haspopup="menu"])整条跑一遍注入桥:落点就在账号行右侧、幂等、update() 把忙碌/提示/禁用写进节点、被官方重渲染踢出去后下一轮插回来、认不出账号行时页面上不多出任何节点、stop 之后不再动 DOM。结构取自解包 app.asar 里的 AccountMenu / Menu / ui-sidebar 源码 |
| 宿主挂载与路由 | 假 ctx 真 apply 一遍,按真实 HTTP 形状调路由:状态契约、403 / 404 / 409 / 500、设置写入、守卫先拦、force=1 才交接(并核对写下的待恢复名单来自宿主实况,而不是请求里那个会话) |
| 端到端 | 临时编译一个 WinForms 靶子程序当「应用」,跑完 lib/helper.mjs 全链路:捕获几何(700×500 @ 300,200)→ 结束靶子主进程(确认日志里是 /T 之外的方式)→ 拉起新实例(靶子第二次启动刻意用默认几何 320×220 @ 20,20)→ 独立复核新窗口实际几何逐像素等于重启前 |
| 多显示器(本机三块屏) | 靶子第一次在副屏 \\.\DISPLAY2 上最大化 → 重新拉起的实例按默认几何落在主屏 \\.\DISPLAY1 → helper 全链路还原 → 独立复核窗口回到副屏、最大化矩形与重启前逐像素一致、结果里 onTarget: true;另一条用例手工构造「正常矩形与记录的显示器互相矛盾」的状态,验证纠偏分支(corrected: true / onTarget: true) |
| 复现上报缺陷(对照实验) | 同一段靶子、同一个「副屏上最大化 → 重启」场景,分别用 git HEAD 的旧脚本与本次修复后的脚本跑一遍:旧版落到主屏 \\.\DISPLAY1(正是上报的「在左上屏,重启回到笔记本屏」),新版落回 \\.\DISPLAY2。另:17:14 那次真实重启的会话日志显示,被打断的是后台 session-3f255a53(turn/end reason: interrupted),而插件当时瞄准的是空闲的 session-c5744114(last-resume.json 写着 not-interrupted)—— 与修好的判据逐条对上 |
| helper 启动方式 | 用真正的 DeepSeek Harness.exe + ELECTRON_RUN_AS_NODE=1 + detached 跑一次最小脚本,证明这条启动路径确实能跑起来 |
| 安装后的活体验证 | 插件安装到 desktop profile 后,DSH 的 HMR 当场把两半都挂上了(无需重启):• 宿主半路由 GET /dsh-plugin-restart-desktop/api/status 实测返回 {"host":"dsh-plugin-restart-desktop","version":"0.1.0","desktop":true,...},守卫正确读到真实服务(有任务在跑(4 项正在生成或运行工具、1 个后台任务在跑),12 个在挂会话);• 客户端半的注入落点在解包出来的官方源码里逐层核对过(上一行那条离线用例就是照它写的)。 这一版(账号行图标按钮)还没有在真实窗口里目视确认过:客户端半要刷新一次页面才会换上新 bundle,而刷新会打断正在进行的会话。第一次刷新后请看账号行右侧是否出现刷新图标,控制台 __DSH_RESTART_DESKTOP__.button() 会给出 found / injected / lastReason。 |
没有跑过的(诚实说明):修复后的版本还没有真正重启过一次正在运行的 DSH —— 一跑就会杀掉当前会话。上面的对照实验与多显示器用例用的都是临时编译的靶子程序,生产路径的每一段都被单独验证过,但「在本机完整重启一次 DSH(新版)」以及「真的 resume 一个 Agent 并发出一条恢复消息」这两步,仍然留给你第一次点按钮时完成(~/.dsh/restart-desktop/ 下的 restart-*.log 与 last-resume.json 会把结果如实记下来)。
复现:
cd C:\work\my\dsh\plugins\dsh-plugin-restart-desktop
npm run check # bundle 一致性 + npm test(本次 85 项)
npm run test:e2e # 端到端(需要 Windows 桌面会话;多显示器那两条需要在多屏机器上跑,约 20 秒)
发布时的现状:插件仍以 link: 方式常驻作者本机 desktop profile,账号行按钮这条路已经在真实窗口里用过(包括 2026-11-05 15:42 那次由按钮排程的真实重启 —— 会话被打断后按新判据恢复)。
文件结构
index.js 宿主半:路由、任务守卫、交接、启动后自动续接会话
lib/platform.mjs 平台能力(「本机能不能重启 / 有没有几何驱动」的唯一事实来源)
lib/helper.mjs 分离 helper:重启的实际执行者
lib/geometry.ps1 Win32 窗口几何捕获 / 还原(刻意只用 ASCII)
lib/resume.mjs 重启后自动续上被打断会话(纯逻辑 + 服务缝)
src/client-core.js 客户端纯逻辑:账号行识别判据、重启状态机、提示文案
src/account-button.js 账号行右侧图标按钮的 DOM 注入
src/client-main.js 客户端接线:图标按钮 + 官方座位回落 + 提示浮层
lib/client.js 客户端 bundle(由 build/bundle.mjs 生成,不要手改)
build/bundle.mjs 客户端 bundle 构建(三源文件内联,无外部依赖)
test/fake-dom.mjs 假 DOM(客户端 DOM 注入的离线测试用)
test/ 单元 + 冒烟 + 端到端测试
发布(维护者)
推 v* tag 即触发 .github/workflows/release.yml:校验 tag 与 package.json 版本一致 → npm run check → 发布 → 建 GitHub Release。
认证走 npm 可信发布(Trusted Publishing / OIDC),仓库里不存任何 npm token。 一次性前置(在 npmjs.com 上做):包的 Settings → Trusted publishing → GitHub Actions,填 railgun20001、dsh-plugin-restart-desktop、工作流文件名 release.yml,允许 npm publish。该配置要求创建后 2 天内完成首次成功发布,所以顺序是「先把包发出来 → 再配可信发布」;npm 要求 CLI ≥ 11.5.1、Node ≥ 22.14。
流程是幂等的:registry 上已存在的版本不会重发,只补一个 GitHub Release —— 所以 v1.0.0 这个 tag 可以照推,首次发布是本地 npm publish 干的,CI 只负责把它挂成 Release。
本地手动发(首次发布只能这么干:可信发布不支持为「还不存在的包」预配置):
# 本机 npm 默认 registry 是镜像源,登录要显式指官方源;
# package.json 的 publishConfig.registry 已钉住发布目标,publish 不必再带 --registry。
npm login --registry=https://registry.npmjs.org/
npm run check
npm publish
发布内容由 files 白名单决定:index.js + lib/(含构建好的 lib/client.js)+ cordis.patch.yml + README + LICENSE。src/、build/、test/ 不进包 —— 所以发版前必须先 npm run build 并提交,prepublishOnly 里的 --check 会拦住忘提交的情形。
卸载
plugin_manager(action: "remove_bundle", target: "dsh-plugin-restart-desktop")
宿主侧状态在 ~/.dsh/restart-desktop/,可以直接删;浏览器里只剩一个 dsh-plugin-restart-desktop/pending-v1 标记(重启完成后的一次性提示),一样可以直接删。
许可
Plugins associés
deepseek-harness
deepseek-ai/deepseek-harness
dsh-market
dsh-market/dsh-market
dsh-web (dsh-plugin-manager)
zhu1090093659/dsh-web
dsh-web
zhu1090093659/dsh-web