dsh-self-restart
让 DSH 里的 Agent 能「自己重启自己」,并且重启完自己醒过来接着干。
DSH 桌面版是个常驻进程:改了插件、改了配置、内存涨了、偶尔卡住了,都得关掉再打开。 但这里有个死结 —— 关掉 DSH 就等于关掉正在干活的 Agent 自己,它没法一边关自己一边把自己拉起来。
这个插件提供那个「替它做」的东西。
平台:仅 Windows 桌面版。它靠 WMI 创建脱离进程、靠
taskkill/ 窗口消息关闭应用, 换平台要另写一套。
核心:一次重启,多个会话各自续命
如果只记一件事,记这个:
它把「重启」做成一个可并发复用的动作。 几个会话可以把各自的活挂到同一次重启上, 重启完再把它们各自叫醒、各自拿回自己的理由、各自判断要不要接着干。
这不是「A 重启完再轮到 B」,而是并成一趟:
会话 A:restart_self(reason="改完插件要验证") ┐
会话 B:restart_self(reason="等配置生效") ├─→ 同一趟车(最多 8 人,谁先来谁先上)
会话 C:restart_self(reason="清理内存") ┘ ← 还可以在等待途中随时加入
↓
等**所有人**都不忙 → 关一次 → 拉起一次 → 验证端口
↓
并发唤醒:A 拿"改完插件要验证" / B 拿"等配置生效" / C 拿"清理内存"
↓
A、B、C 各自跑起来,互不等待 —— 一台机器上三条活同时重新开始
为什么这是"并发"而不是"排队处理" —— 两个阶段都成立,但要分开说:
| 阶段 | 行为 | 是不是并发 |
|---|---|---|
| 排队 | 任意时刻、任意会话都可以把自己的活挂到同一趟车上;等待途中也能随时加入 | ✅ 并发加入 |
| 重启 | 只关一次、只拉一次 —— 不管车上有 1 人还是 8 人 | 一次动作,不是 N 次 |
| 唤醒 | 逐条投递(一条失败不影响其余,也不用等上一个人跑完) | 投递是串行的 |
| 醒后 | 每个会话独立跑自己那一轮,互不等待、互不阻塞 | ✅ 真并发执行 |
⚠️ 一个容易写错的点,这里说清楚:唤醒消息是逐条投的,不是同时群发。 但那只是投递动作的先后,投完就各自跑了 —— 不是"排队执行",后醒来的不用等先醒的干完。
这带来五个别处没有的性质:
| 性质 | 意味着什么 |
|---|---|
| 只关一次、只拉一次 | 3 个人排队,不是重启 3 次。整台机器只中断一次,中位几十秒 |
| 每人各拿各的理由 | 唤醒消息是按 成员 × 自己的 reason 逐条投的,不是群发一句通用话 |
| 每人各自决定续不续接 | 同一趟车里 A 选 resume: true、B 选 resume: false 都行 —— 逐人判断,不是一刀切 |
| 一个失败不拖累其余 | 逐人记账(resume:<单号>:<会话id>):B 投递失败,A 和 C 照常被叫醒 |
| 可以各自下车 | 名单里永远是"发车之后要叫醒谁"。谁不想被叫醒就摘掉自己,其余人照常 —— 见下 |
两种退出方式,别用错:
| 调用 | 影响范围 | 什么时候用 |
|---|---|---|
restart_status(leave: true) |
只有你自己 | "这车跟我没关系了"——车照开,不叫醒我;我是最后一个人则整趟不发车 |
restart_status(cancel: true) |
整趟车所有人 | "这趟别开了"——包括别人的重启一起作废 |
⚠️ 只是自己不想要了,永远该用 leave。用 cancel 会把别人的重启一起打掉。
为什么值得这样设计:多个会话同时要重启是常态(改完插件,几个窗口都在等); 而且重启期间整台机器是停的,每多一次重启就多一次中断。 所以正确的模型不是"每个会话各排各的",而是"一次中断,服务所有人"。
⚠️ 代价也要说清:一趟车里只要还有人没空闲,整趟都会等。人越多,等到空档越难。 所以「取消」是整趟取消 —— 这也正是它必须让你先看名单的原因。 (只想让自己退出、不想连累别人的,用「下车」,见下面对两者区别的说明。)
什么时候你不需要它
- 只是想手动重启一次 → 不需要,直接重启 DSH 更快。
- 不跑长任务、也不在意会话被打断 → 不需要,它解决的是"重启完要接着干"。
- 不是 Windows 桌面版 → 用不了,见「已知边界」。
它真正省事的是这个场景:你自己不在电脑前,但希望 Agent 把活干完、需要重启时自己排上、 重启完自己醒过来继续 —— 而不是第二天回来发现卡在某个报错上。
它提供什么
一句话定位:把「重启宿主」变成一个可被多个会话并发复用的动作。 不是"每个会话各重启一次",而是"一次中断,服务所有排队的会话"。
| 工具 | 干什么 |
|---|---|
restart_self |
排一次「延时重启」:等真正空闲后,由一个脱离进程的助手把 DSH 关掉、重新拉起、并验证端口真的回来了 |
restart_status |
看有没有待执行的重启、现在忙不忙、上次重启成不成、这台机器重启要多久;也能「下车」或「取消」 |
① Agent 调 restart_self
② 还可以有别的会话陆续排进来(同一趟车,最多 8 人;等待途中也能随时加入)
③ 助手(脱离进程)等所有人都不忙了 → 关掉 DSH → 重新拉起 → 验证端口
④ 起来后插件按名单**逐条**把唤醒消息投进每个会话(各拿各的理由)
⑤ 每个会话被唤醒后就**各自跑起来**,互不等待 —— 一次重启,N 条活同时续上
几条设计取舍(会影响你怎么用它)
绝不强行打断别人。 有别的会话在忙就排队等,不是作废、也不是硬闯。 默认一直等(
maxWaitMinutes: 0),超过notifyAfterMinutes(默认 20 分钟) 还没发车会走一次通知钩子(见「外部通知」)。不想被叫醒就「下车」,不要用「取消」。 这是两个不同的出口:
restart_status(leave: true)—— 只影响你自己:把你这一个会话从叫醒名单里摘掉。 车上还有别人 → 车照开,只是不会叫醒你;摘完没人了 → 这趟本来就没人为它等了,直接不发车。restart_status(cancel: true)—— 影响整趟:车上所有人的这次重启一起作废。 所以取消之前先调一次restart_status()看名单(它会列出每个人 + 各自的理由 + 你在不在里面)。
一句话区分:
leave是"这车跟我没关系了",cancel是"这趟别开了"。 只是自己不想要了,永远该用leave—— 用cancel会连累别人。不弹窗打扰,但有一张不阻塞的状态卡片。 卡片上有三个出口: 「不等了,立刻重启」(只跳过"等家务")、「打断」按钮(取消整趟)、右上角的 ✕。 「打断」和 ✕ 写的是同一个取消标记;「不等了」写的是另一个标记(
force.flag), 只影响"还要不要等家务"。排队时会在会话标题前挂
♻️,一眼能看出谁在等;上次重启失败的会话挂⭕(2 小时后自动消失)。 ⚠️ 从 IM 通道来的会话,图标排在频道前缀后面(QQ · ♻️ 原标题)。 原因:有插件会按「开头是不是QQ ·」判断要不要补前缀,图标抢到最前面会让它反复补。 兼容范围(实测):认 16 个常见渠道标签,清单内收敛;清单外若对方只加一次也幂等。一趟车结束就封口。 取消 / 失败 / 成功三条路都会删掉计划文件、清掉取消标记, 否则会出现"已取消"和"待执行"同时成立的假状态。
「关掉了却没人拉起来」是唯一不可接受的后果。 所以除了主助手,还有一个保险丝进程: 万一主助手在关机途中死了,保险丝负责把 DSH 拉回来。
安装
本插件是一个标准的 DSH bundle(package.json 里带 dsh.bundle.patch,自带 cordis.patch.yml)。
方式一:用 DSH 的插件管理(推荐)
让 Agent 调用它自带的 plugin_manager 工具:
plugin_manager(action="install_bundle", target="<本目录的绝对路径>")
install_bundle 会自己完成「装包 + 选中 bundle」两件事。
⚠️ 不要用 shell 命令模拟这两步(拷目录、手改 profile 的
package.json/cordis.patch.yml、在 profile 目录里跑 pnpm)—— 这是 DSH 官方文档明确禁止的做法。
方式二:本地开发时用 workspace 链接
把本目录登记进 profile 的 package.json:
{
"dependencies": { "dsh-self-restart": "workspace:*" },
"dsh": { "profile": { "bundles": [ "dsh-self-restart" ] } }
}
然后在 profile 目录里装一次依赖、重启 DSH。
(仓库自带的 deploy.ps1 就是给这种方式配套的:源码 → 实际加载位置 的同步 + 对账。)
依赖
- Node 内置模块(
fs/path/os/child_process/url/crypto); @deepseek-ai/schemastery—— 由 DSH 宿主提供(所以同时列为peerDependencies)。
「重启完自己醒过来」用的是 DSH 自带的正式服务
sessionController(随发行版默认挂载)—— 不需要实验 bundle,也不需要额外安装任何东西。
配置
所有配置项都有默认值,写在 lib/index.js 的 Config schema 里;
要改就在自己 profile 的 cordis.patch.yml 里覆盖同名 id 的 config:
- id: self-restart
config:
idleSeconds: 12
notifyAfterMinutes: 20
| 配置项 | 默认 | 说明 |
|---|---|---|
enabled |
true |
总开关 |
port |
19387 |
兜底端口;实际端口每 10 秒从宿主现取 |
dshExe |
'' |
DSH 主程序路径;留空 = 自动找 |
stateDir |
'' |
状态目录;留空 = %DSH_HOME%\self-restart |
idleSeconds |
12 |
「自己这一轮说完了」要连续静默多少秒 |
othersWindowSeconds |
120 |
「别人还在忙」的判定窗口 |
confirmBufferSeconds |
5 |
排上之后留给回复落盘的缓冲 |
confirmSeconds |
10 |
动手前的确认窗(卡片上有倒计时,仍可打断) |
maxWaitMinutes |
0 |
最多等多久;0 = 一直等(另有 12 小时硬上限) |
checkSeconds |
10 |
助手的轮询间隔 |
bootTimeoutSeconds |
90 |
拉起后等端口回来的上限 |
maxAttempts |
3 |
拉起失败后的重试次数 |
notifyAfterMinutes |
20 |
等这么久还没发车就通知一次;0 = 不通知 |
notifyRepeat |
false |
要不要重复通知 |
notifyRepeatMinutes |
60 |
重复通知的间隔 |
noticeCommand |
'' |
外部通知命令(见下节);空 = 完全不通知 |
noticeTimeoutMs |
30000 |
通知命令超时,超时就杀掉 |
waitForImDirect |
true |
IM 私聊算不算「有人在用」 |
waitForAllSessions |
false |
所有会话都 idle 才动手(含群聊) |
queueMaxMembers |
8 |
同一趟车最多几个人 |
wakeMessage |
true |
重启成功后要不要把唤醒消息投回原会话 |
wakeMessageMaxChars |
600 |
唤醒消息里原样带回 reason 的字数上限 |
wakeDelaySeconds |
3 |
起来后等几秒再投唤醒消息(等服务就位) |
resumeHint |
'' |
唤醒消息里附一句自定义提示(留空 = 不提) |
外部通知(notice hook)
本插件不内置任何通知渠道。 它只在特定事件发生时,执行你自己配置的一条命令, 把事件内容通过环境变量交给它 —— 发不发、发给谁、用什么发,全在那条命令里。
为什么这样设计
通知渠道属于使用者环境,不属于插件。写死某个渠道等于要求每个使用者都装同样的东西; 没装就静默失败,那比没有通知更糟。所以这里只留一个接口,默认关闭。
怎么配
- id: self-restart
config:
noticeCommand: 'node C:\tools\my-notify.mjs'
命令里用环境变量取内容:
| 环境变量 | 内容 |
|---|---|
DSH_SR_EVENT |
事件名,见下表 |
DSH_SR_TEXT |
一句话正文(已经是单行,可直接发) |
DSH_SR_PLANID |
这趟重启的单号 |
DSH_SR_COUNT / DSH_SR_ELAPSED / DSH_SR_WAITEDMIN 等 |
各事件附带的标量字段(字段名大写归一) |
DSH_SR_EVENT |
什么时候触发 |
|---|---|
restart-queued-long |
等了 notifyAfterMinutes 还没发车 |
restart-done |
重启成功且之前发过"还在排队"通知(配对,不刷屏) |
wake-failed |
重启成功,但没能叫醒排队的会话 |
restart-failed |
重启失败或被取消 |
安全边界
- 插件只执行
noticeCommand这一条你自己写的命令, 不执行DSH_SR_TEXT等 payload 里的任何内容 —— payload 只作为环境变量传入,不参与解释。 - 命令由
shell执行(所以可以带参数、管道、重定向)。 这意味着命令本身有完整权限 —— 只写你信任的命令,别把会话内容拼进去。 - 有超时(
noticeTimeoutMs)、有并发上限(同时最多一条在跑)、失败只记日志。 - 通知失败绝不影响重启:重启是该做的事,通知是锦上添花。
重启完自己醒过来接着干
重启成功后,插件会把一条消息投进原会话(不是定时提醒、不是系统提示),
会话因此被驱动起来跑一轮 —— 带回来的正是你当初写的 reason。
resume: false的重启(收尾型)不叫醒。- 投递是**「至多一次」的:宁可偶尔"这次没叫醒、需要手动开", 也绝不允许同一次重启投出两条 —— 两条一样的「接着干」会让同一件活被干两遍。 所以顺序是先记账、再投递、投不进去才回滚**。
- 消息投出去就撤不回了:
restart_status(cancel: true)只能拦住还没投递的。
「闲下来」是怎么判断的:两段式
- 第一段:等自己这一轮说完。 连续
idleSeconds没有新动静。 - 第二段:等别人。 再看一遍全部会话,
othersWindowSeconds内写过会话的算「在动」。
不是所有"别人"都一样:
| 来源 | 算不算"有人在用" | 说明 |
|---|---|---|
| 本地窗口 | 算 | |
| IM 私聊 | 算(waitForImDirect 可关) |
私聊那头随时可能来人 |
| IM 群聊 | 不算 | 那是机器人在跟别人聊,重启中位只有几十秒 |
| 查不到 | 算(保守) | 宁可多等 |
⚠️ 默认值下,"自动找空闲"可能经常等不到:如果你有大量 IM 私聊会话,
第一个该考虑调的就是 waitForImDirect。
「不等了,立刻重启」
等家务等到没完时,卡片上的「不等了,立刻重启」只跳过等家务这一段,
不是强制关闭别人的对话。它写的是 force.flag,和「打断」(写 cancel.flag)是两回事。
状态文件在哪
%DSH_HOME%\self-restart\(默认 ~\.dsh\self-restart\):
| 文件 | 是什么 |
|---|---|
state.json |
插件每 10 秒写的实时状态(端口、在跑的会话、待执行的重启) |
plan.json |
当前这一趟重启的计划;没有待执行重启时不存在 |
last-run.json |
上一次重启的结果(助手写) |
history.jsonl |
流水账,一行一次 |
reported.json |
插件自己的记账(哪些已汇报、哪些已通知) |
helper.log / plugin.log |
助手 / 插件的日志 |
notify-<planId>.json |
通知配对用的记账 |
日志会一直留着,需要时自己清理。
开发
改完怎么让它生效
- 改了
lib/index.js→ 必须重启宿主(Node 侧代码不会热加载)。 - 改了
bin/*.ps1→ 下次排重启就生效(助手是现读现跑的)。
开发目录和 DSH 实际加载的目录是分开的,用 deploy.ps1 同步 + 对账:
pwsh -NoProfile -File deploy.ps1 -Check # 只对账(0=一致 / 5=不一致)
pwsh -NoProfile -File deploy.ps1 -DryRun # 预演会复制哪些文件
pwsh -NoProfile -File deploy.ps1 # 真正同步
跑测试
powershell -NoProfile -ExecutionPolicy Bypass -File test\run-all-tests.ps1
powershell ... -File test\run-all-tests.ps1 -Only 'test-helper','test-two-phase' # 只跑指定几套
⚠️ 测试全程不碰真 DSH:所有用例都用假目标(假 exe / 假端口 / 假会话目录), 每套末尾还有"真 DSH 监听者 PID 没变"的断言兜底。
目录结构
lib/index.js 插件主体(工具注册、状态轮询、唤醒投递、标题标记)
lib/notice-hook.js 外部通知钩子(执行用户配置的命令)
bin/dsh-self-restart.ps1 助手:等待 → 关闭 → 拉起 → 验证 → 写结果
bin/dsh-restart-window.ps1 状态卡片(WPF)
cordis.patch.yml bundle 补丁(挂载点)
deploy.ps1 开发目录 → 加载目录 的同步 + 对账
test/ 自测(ps1 + mjs)
它内部怎么做的(想改代码再看)
为什么要「脱离进程」的助手:它用 WMI 创建,父级是系统的 WmiPrvSE,
不在 DSH 的进程树里 —— 所以 DSH 被杀掉之后它照样活着,能把 DSH 拉回来。
(同一个脚本有三种身份:点火器负责把自己脱离出去,正主动手关/拉,
保险丝守「正主死在半路」这一种最坏情况。)
为什么「先确认拉得起来,才允许动手关」:曾经用了一个不存在的主程序路径做自测, 助手照样先把 DSH 杀了,杀完才发现没法拉起。所以动手前有一道硬闸: 主程序不存在 / 是 0 字节 / 读不到 → 原地退出,DSH 一根汗毛不动。
为什么宽限期只有几秒:taskkill 不带 /F 是给窗口发 WM_CLOSE,
而 DSH 的退出条件是 window-all-closed —— 它有多个顶层窗口,可见的只有一个,
其余是隐藏的功能窗口,不会被外部关闭请求关掉。
所以「礼貌关闭」在结构上就不可能让它退出,等多久都一样。
已知边界
- 只在 Windows 桌面版上工作。 找不到主程序就明确失败,绝不乱猜路径。
sessionController服务是自动唤醒的前提(DSH 自带,正常组合下都在)。 拿不到 → 不会有「重启完自动叫醒」,但重启本身照常。- 唤醒只在
outcome=success时投递:重启失败 / 被取消 / 没执行(skipped-busy) 都不会叫醒 —— 失败时叫醒 Agent 继续干会把故障掩盖掉。 - 投递是「至多一次」(刻意的),见上文。
- 通知是「尽力而为」:
noticeCommand失败、超时、没配,都不影响重启本身。
No comments yet. Be the first to write one.