改完插件让 AI 自己重启应用并在原会话继续
30.3/100
综合分
1
GitHub 星标
—
npm 周下载
—/5
用户评分
数据截至 2026/10/6 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
自动兼容检查未通过:仓库 package.json 标记 private,未发布到 npm,需从源码安装。以下命令仍可尝试,但可能失败。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add deadbushxw/dsh-self-restart该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:已验证
是什么
dsh 原生插件 · chat
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
更新放缓:最近一次提交在 32 天前
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
社区实装还没有人打卡 · 装过的第一个就是你
安装兼容性检查需源码安装仓库 package.json 标记 private,未发布到 npm,需从源码安装
展开查看检查明细 ↓
以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。
✓npm 包dsh-self-restart @ 0.3.4
✓Node 引擎要求 >=20 · 基线 Node 22.19 满足
✓dsh CLI 依赖未声明 dsh 版本约束
✓入口文件main/exports/bin 已声明
源码安装时若 pnpm 拦截 prepare 脚本,按其提示在 pnpm-workspace.yaml 的 allowBuilds 中放行后重跑。
验证方式:npm registry 存在性 + package.json 静态校验 · 最后检查 2026/10/7 10:39:45
安全扫描自动 · 静态分析源码v2.7.0🔵 扫描:含敏感能力尚无人工评估展开查看扫描说明与证据 ↓
安全扫描自动 · 静态分析源码
v2.7.0静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。
可执行外部命令读写/删除本地文件
查看扫描证据(共 24 处,展示前 23 条)
- 读取本地文件lib/index.js:130
if (existsSync(join(dir, name))) files.push(name); - 读取本地文件lib/index.js:135
entries = readdirSync(join(dir, relative), { withFileTypes: true }); - 读取本地文件lib/index.js:166
return statSync(path).mtimeMs > sinceMs; - 读取本地文件lib/index.js:183
result[relative] = createHash('sha256').update(readFileSync(join(dir, relative))).digest('hex'); - 执行外部命令来自配置/变量lib/index.js:345
execFile( - 读取本地文件lib/index.js:518
return readFileSync(join(dirname(identity.appPath ?? ''), 'version'), 'utf8').trim(); - 读取本地文件lib/index.js:922
const alive = await waitFor(() => existsSync(join(tickets.dirOf(ticketId), FILES.log)), { timeoutMs: RESTARTER_PROOF_MS, intervalMs: 150, signal: exec - 读取本地文件lib/config.js:202
export function readConfigFile(path, readFile = (target) => readFileSync(target, 'utf8')) { - 读取本地文件lib/config.js:239
constructor({ rowConfig, filePath, readFile = (path) => readFileSync(path, 'utf8'), statFile = (path) => statSync(path), log } = {}) { - 读取本地文件lib/config.js:239
constructor({ rowConfig, filePath, readFile = (path) => readFileSync(path, 'utf8'), statFile = (path) => statSync(path), log } = {}) { - 读取本地文件lib/detect.js:264
readFile = (path) => readFileSync(path, 'utf8'), - 读取本地文件lib/staleness.js:232
return createHash('sha256').update(readFileSync(path)).digest('hex'); - 读取本地文件lib/staleness.js:302
const left = statSync(a, { bigint: true }); - 读取本地文件lib/staleness.js:303
const right = statSync(b, { bigint: true }); - 写入本地文件lib/ticket.js:121
mkdirSync(dirname(file), { recursive: true }); - 写入本地文件lib/ticket.js:125
writeFileSync(handle, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); - 写入本地文件lib/ticket.js:130
renameSync(temp, file); - 读取本地文件lib/ticket.js:146
text = readFileSync(file, 'utf8'); - 写入本地文件lib/ticket.js:168
mkdirSync(dirname(file), { recursive: true }); - 写入本地文件lib/ticket.js:169
appendFileSync(file, `${JSON.stringify(entry)}\n`, 'utf8'); - 读取本地文件lib/ticket.js:181
text = readFileSync(file, 'utf8'); - 读取本地文件lib/ticket.js:504
names = readdirSync(this.root, { withFileTypes: true }); - 读取本地文件lib/ticket.js:564
return statSync(path).mtimeMs;
扫描于 2026/11/2(1 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库
静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
功能亮点
- AI触发应用重启
- 原会话继续
- 补刷新界面入口
展开完整介绍(132 字)↓
DSH 桌面端插件:改完插件后由 AI 自己触发重启,重启完在同一条会话里接着往下做,并补上打包版桌面端缺失的「刷新界面」入口 | DSH(DeepSeek Harness)桌面端插件:从工具调用里重启应用,接着同一条会话继续,并补上打包版桌面端缺失的页面刷新入口
README
由 DeepSeek 最新模型翻译生成dsh-self-restart
用一次工具调用重启 DSH,然后在同一条会话里接着往下做。
English ·
这个 DSH 插件把「改了到底生没生效」从一套手工流程变成一次工具调用。它包含 DSH 插件的两个半侧:
- 宿主半侧,提供工具 dsh_status、dsh_stale_check、dsh_apply、 dsh_restart、dsh_ui_reload、dsh_selfcheck,以及 dsh_restart 背后的整套机制;
- 客户端半侧,补上打包版桌面端缺失的「刷新页面」入口,并充当宿主在页面里的手。
它解决的问题很窄,但很真实:DSH 只在进程启动时加载一次插件的宿主代码,没有热替换,插件管理器自己会把结果标成 restart-required,界面文案也写着「更改将在下次启动生效」——但它不会替你完成那次启动,也不会把对话接回来。这个插件补的就是这一段。
这个插件会关闭并重新启动整个应用。 装之前请先读重启会毁掉什么。它不会自作主张:除非智能体调用 dsh_restart、 dsh_apply 或用户点击刷新按钮,否则它什么都不会重启。
展开完整文档(README 剩余 1 节)
dsh-self-restart
用一次工具调用重启 DSH,然后在同一条会话里接着往下做。
English ·
这个 DSH 插件把「改了到底生没生效」从一套手工流程变成一次工具调用。它包含 DSH 插件的两个半侧:
- 宿主半侧,提供工具 dsh_status、dsh_stale_check、dsh_apply、 dsh_restart、dsh_ui_reload、dsh_selfcheck,以及 dsh_restart 背后的整套机制;
- 客户端半侧,补上打包版桌面端缺失的「刷新页面」入口,并充当宿主在页面里的手。
它解决的问题很窄,但很真实:DSH 只在进程启动时加载一次插件的宿主代码,没有热替换,插件管理器自己会把结果标成 restart-required,界面文案也写着「更改将在下次启动生效」——但它不会替你完成那次启动,也不会把对话接回来。这个插件补的就是这一段。
这个插件会关闭并重新启动整个应用。 装之前请先读重启会毁掉什么。它不会自作主张:除非智能体调用 dsh_restart、 dsh_apply 或用户点击刷新按钮,否则它什么都不会重启。
目录
- 它做什么
- 环境要求
- 安装
- 重启会毁掉什么
- 一次重启到底是怎么跑的
- 工具
- 配置
- 刷新控件
- 排查
- 仓库边界
- 开发
- 已知限制
- 致谢
- 许可证
它做什么
四件事,按你大概会需要的顺序排列。
1. 告诉你一次改动需要什么。 dsh_stale_check 会把配置档里每个「从目录安装」的插件源目录与 DSH 真正在加载的安装副本做哈希对比,并把每个运行时文件的修改时间与当前进程的启动时间比较,然后给出代价最小的可行动作:
| 结论 | 含义 |
| none | 加载器自己就能处理,或者没有实质变化。 |
| entries-retry | 有客户端插件加载失败,模块加载器可以原地重试。 |
| reload-ui | 需要刷新页面。会话历史、草稿、正在跑的回合都保留。 |
| restart | 宿主代码变了,必须换一个进程。 |
2. 只做最小的那个。 dsh_apply 执行上面判定出的动作,而不是一律升级成重启。只改了客户端产物,不该赔上一次重启。
3. 重启,然后回来。 dsh_restart 写一份票据,把它交给一个在 DSH 作业对象之外 创建的进程,然后立刻返回。等你的下一轮 turn/end 真正落盘后,那个进程会优雅地关闭 DSH,等壳、宿主与监听端口全部真的消失,重新启动可执行文件,再等新一代进程自报家门。新一代会把一条真实的用户消息注入到同一条会话里,于是 AI 接着上一轮往下干——它是对话里可见的一条用户气泡,不是隐藏的系统提示。
4. 它能刷新页面。 打包版桌面端接管的应用程序菜单里没有「重新加载」项(那一项只在 development 构建里存在),所以用户和 AI 都没有任何刷新入口。dsh_ui_reload 与侧边栏底部的按钮补上这个入口,并且只有在页面确实回来了之后才报成功。
环境要求
- Windows。 重启是基于 Windows 的进程与窗口语义实现的。换平台时工具仍然在,dsh_ui_reload 照常可用,而 dsh_restart 会以 unsupported-platform 明确拒绝,而不是做一半。
- 宿主进程与窗口壳进程分离的 DSH 构建——桌面版就是这样。两个 pid 都在运行时发现,插件不假设它们存在。
- PowerShell,任何受支持的 Windows 都自带;配置后可用 pwsh。
- 没有运行时依赖,没有构建步骤。插件不 import 任何 @deepseek-ai/,原因见已知限制——这是刻意的设计选择,不是遗漏。
安装
安装就是一次 bundle 安装:DSH 把包装进当前配置档、注册加载器行、然后应用它。
不要手工改配置档。
1. 把代码放到机器上
git clone <this-repository-url> "%USERPROFILE%\.dsh\dsh-plugins\dsh-self-restart"
目录随便放,上面的路径只是沿用同工作区其它插件的约定。没有构建步骤,也不需要 npm install。
2. 装进一个配置档
在你想要它的配置档里对智能体说:
用 plugin_manager install_bundle 安装 %USERPROFILE%\.dsh\dsh-plugins\dsh-self-restart 这个 bundle。
或者走界面:设置 → 插件,把该目录添加为本地 bundle 并启用。
plugin_manager 报 application: "applied" 表示已经生效。如果报 restart-required,先按老办法手动重启一次 DSH——之后这件事就可以交给插件了。
3. 确认能用
对智能体说:
调一下 dsh_status。
你应该看到宿主 pid、窗口壳 pid、可执行文件路径、端口、作业外创建原语,以及「还没有重启记录」。
如果原语显示「尚未测量或没有可用者」,那是正常的:原语是在第一次真正请求重启时才测量的。
第一次真正的验证,是来一次无所依赖的重启:
用理由 "install check" 调 dsh_restart,跑完之后再调 dsh_selfcheck。
卸载
plugin_manager remove_bundle dsh-self-restart
配置档里不会留下任何东西。插件自己的数据目录(%DSH_HOME%\plugin-data\dsh-self-restart\)在任何 clone 之外,不属于安装的一部分;想让票据消失就自己删掉它。你放在那里的 config.json 也是你的,同样不会被删除。
重启会毁掉什么
这不是一次温和的操作,把它说成温和的才是这个插件里最不诚实的地方。
| 会失去 | 会保留 |
| DSH 起过的每一个子进程:shell、MCP server、后台任务,以及任何「从 DSH 内部拉起来的常驻服务」。 | 会话日志。每一段对话(包括当前这段)都在磁盘上,会留下来。 |
| 其它每一条会话正在跑的回合。 | 草稿与未提交的输入,它们存在浏览器里。 |
| 页面内未保存的状态,以及侧边栏里内嵌的浏览器标签。 | 发起重启的那条会话正在跑的回合——它正是被续跑的那一个。 |
| 当前这一代进程、它的内存缓存、以及任何只存在于内存里的插件状态。 | 你有意放在 DSH 之外启动的东西。 |
「会失去」的最后一行正是这套设计长成这样的原因:要跨重启存活的服务不能是 DSH 的子进程,因为 DSH 会把子进程放进一个 kill-on-close 的作业对象。我自己第一次做这个实验的结局,是一个系统级的语音合成服务被无优雅记录地强杀,而这个插件也救不了它——它只能让 DSH 自己 体面地结束,而不是被判决死刑。
因为第二行,当另一条会话正在运行、或最近两分钟内活动过时,dsh_restart 会拒绝。
force: true 可以越过,并在票据里记下它越过了。
优雅关闭是刻意模拟「Windows 会话结束」的,而在那个状态下 DSH 会跳过自己的退出确认框。
所以这条拒绝,是唯一挡在「一条会话的重启」与「另一条会话跑到一半的回合」之间的东西。
这也是它存在、而不是弹一个对话框的原因。
一次重启到底是怎么跑的
重启不可能是函数调用,因为调用者活不过它。于是请求变成文件,三个从不同时运行的参与者依次读取上一个留下的东西。
┌──────────────── DSH:即将被替换的那个世界 ──────────────────────────────────┐ │ 窗口壳(Electron main) │ │ └─ app host(cordis profile) │ │ └─ dsh-self-restart 宿主半侧 │ │ • 六个工具 │ │ • 就绪门:在 turn/end 时 flush 会话 │ │ • 用一个能离开 DSH 作业对象的原语创建执行体 │ │ └─ dsh-self-restart 客户端半侧 │ │ • 侧边栏的刷新控件 │ │ • 长轮询宿主,并在页面回来后回报 │ └─────────────────────────────────────────────────────────────────────────────┘ │ 一个票据目录 ▼ ┌──────────────── 在 DSH 作业对象之外的一个短命进程 ──────────────────────────┐ │ tools/restarter.ps1 │ │ 等就绪门 → 优雅关闭(WM_ENDSESSION、WM_CLOSE) │ │ → 超时则显式杀掉壳与宿主 │ │ → 等到两者都消失且端口释放 │ │ → 重新启动可执行文件 │ │ → 等新一代的启动记录,写回执 │ └─────────────────────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────── DSH:新一代 ────────────────────────────────────────────────┐ │ 读票据、写 boot.json、用确定性 requestId 把一条用户消息注入同一条会话 │ │ (重复投递永远不会多出第二条) │ └─────────────────────────────────────────────────────────────────────────────┘
文件契约
每次重启一个目录,位于 %DSH_HOME%\plugin-data\dsh-self-restart\restarts\<ticketId>\:
| 文件 | 谁写 | 是什么 |
| ticket.json | 宿主半侧,在工具返回之前 | 请求:会话、pid、可执行文件、端口、超时、期望指纹 |
| ready.json | 宿主半侧,会话已 flush 之后 | 就绪门:「现在可以杀我了」 |
| restarter.log | 执行体,只追加 | 执行轨迹,每步一行 JSON |
| receipt.json | 执行体,结束时 | 结果:done 或 failed,以及原因 |
| boot.json | 投递续跑消息的那个宿主半侧——必然是当前运行的一代 | 「说话的是哪一代进程」的证据:pid、启动时间、模块 hash,以及它覆盖掉的旧记录属于哪个 pid |
| delivered.json | 接受这条消息的宿主半侧 | 「续跑消息确实被接受」的证据 |
每次写入都是「临时文件 → fsync → rename」,所以读者永远不会看到半个票据;执行体只追加。
每个票据目录是自解释的:不需要插件在线,用 read 和 bash 就能查一次失败的重启。
boot.json 每次投递都会无条件重写,被覆盖的那条记录的 pid 会留在 previousHostPid 里。
这是刻意的:「先写者赢」的记录,写它的往往是先到的那一代,而即将被替换的那一代完全可能在退场路上先到——这正是「重启明明成功了,消息却说没重启」的成因。一个 hostPid 与票据 hostPid 相同的记录,诚实的解读是「说话的还是原来那一代」;执行体也不把这种记录当作新一代起来的证据,会继续等。
为什么执行体必须在 DSH 之外创建
DSH 把子进程放进 Windows 作业对象,它起的一切都会跟着它死——包括执行体,而执行体的全部任务恰恰是活过 DSH。
「在我作业之外创建进程」没有对应的 API,只有一些把创建工作交给别人的原语。所以插件选择测量而不是假设:
| 原语 | 为什么可能成立 | 代价 |
| wmi | Win32_Process.Create 把创建交给 WMI 提供程序宿主,于是执行体不是我们的子进程,不可能落在我们的作业对象里。 | 一次 DCOM 往返;少数企业策略会拒绝。最先尝试。 |
| scheduled-task | 由任务计划服务来运行执行体,结构上的保证与上一条相同。 | 会在系统里留下痕迹,所以默认关闭。 |
| detached | 普通的分离子进程。它就是我们的子进程,因此继承我们的作业对象——只有当那个作业不是 kill-on-close 时才成立。 | 零成本,但这个前提无法从进程内部验证。只有点名才会用。 |
测量到底在测什么
最直觉的那个信号其实没用——这是实测出来的,不是猜的。IsProcessInJob(handle, NULL, …)
回答的是「这个进程有没有在任何作业里」,而在 Windows 11 上,它对所有进程都返回 true,包括 explorer.exe:
explorer pid=6520 inJob=True
DeepSeek Harness pid=4384 inJob=True
pwsh pid=51136 inJob=True
所以「在作业里」区分不了「在 DSH 的作业里」和「在某个作业里」。建立在这个判据上的检查,要么全部通过,要么——就像本插件的第一版那样——全部不通过,于是在任何机器上都拒绝重启。
真正有意义的是这个进程是谁创建的:
作为我们自己的子进程启动 -> 父进程 DeepSeek Harness.exe (我们的作业,无论是哪个)通过 WMI 创建 -> 父进程 WmiPrvSE.exe (一个服务:不是我们的作业)
Windows 服务进程不可能落在这个进程的作业对象里,所以它创建的进程也不可能继承它。这就是判据:探针报告自己的父进程,父进程不是我们,这个原语就胜出。作业位仍然会记进 restarter.log 和 receipt.json,因为重启出问题时它是读者需要的一部分证据——只是它不再充当判决。
如果没有任何原语逃出去,dsh_restart 会带着每个原语各自的结果失败,而不是假装它起的进程能活下来,并给出两条出路:打开计划任务,或者配置 detached 并接受它「未经验证」。走的是哪条,轨迹里会记下来。
为什么是「等」而不是「睡一会」
执行体不 sleep 然后祈祷,它轮询三个条件:
1. 壳进程消失;
2. 宿主进程消失;
3. 监听端口释放。
这三个条件没全部满足就启动,会得到单实例交接(新的那份把焦点交给旧的然后自己退出,看起来和「什么都没发生」一模一样)或者一个需要人来关掉的端口冲突框。等待而不是猜测,是为了避开这两种结局。
工具
| 工具 | 用途 |
| dsh_status | 只读。插件知道的一切:pid、可执行文件、端口、作业外原语、最近票据、配置问题。 |
| dsh_stale_check | 只读。按插件给出需要什么动作,以及为什么。 |
| dsh_apply | 执行代价最小的动作。dryRun: true 只看不做。 |
| dsh_restart | 布置一次重启。立刻返回;重启在你的回合结束后发生。 |
| dsh_ui_reload | 刷新页面,并等页面自己回报。 |
| dsh_selfcheck | 重启之后:新代码到底加载了没有,重启走到了哪一步。 |
dsh_restart 的实际形态
dsh_restart({
reason: "lib/index.js 改了:宿主会缓存模块实例", resumeHint: "模块拆分做了一半,下一步是把票据辅助函数挪出去。", force: false })
它返回票据 id 和一个原语,以及「请结束本轮」的指令。插件刻意不替你结束回合:就绪门等的正是你的 turn/end,为的是让工具结果和产生它的那一轮在被替换之前就落盘。
重启之后,会话里会多出这样一条用户消息:
已重启并恢复。请接着上一轮继续。
票据:20261001T221500-7f3a 原因:lib/index.js 改了:宿主会缓存模块实例进程代:原为 16932 -> 现为 20488
上一轮交接:
- 模块拆分做了一半,下一步是把票据辅助函数挪出去。
重新加载的代码的期望指纹:
- lib/index.js = 3f0a...
开始正事之前,先做这两件事:
1. 调用 dsh_selfcheck,把它报出的指纹与上面的期望值对比。不一致就直说——新代码没有加载。
2. 读取执行轨迹与回执,位置:.../restarter.log;如果记录的 phase 不是 done,请明确指出它停在了哪一步。
然后按交接说明继续原任务。
这是一条带持久 requestId 的真实用户消息,由此有三个值得说清的后果:
- 它可见,用户可以反驳它;
- 它幂等——会话控制器会拿这个 requestId 同时比对待处理收件箱和持久日志,所以无论是下一代进程重试,还是客户端半侧兜底,都不可能产生第二条;
- 它是模板化的。文本是固定模板加上插件自己产出的值,当前会话之外没有任何东西能往里面塞任意指令。
消息怎么送进去:三层
投递是「别的环节失败时它必须还成立」的那一环,所以它有三条彼此独立的路径,共用同一个 requestId:
1. 宿主,启动时。 新一代起来两秒后,它自己投递所有未投递的票据。这是主路径。
2. 页面,二十秒后。 如果还有未投递的,客户端半侧请宿主重试一次。定时器不等于事件,而这一层在页面起来时触发——那正是有人会发现「说好的那条消息没来」的时刻。它是请宿主 去投,而不是自己写任何东西,并且刻意等得足够久,让第 1 层先赢。
3. 宿主,看门狗。 启动后一分钟起、此后每分钟一次,同一个投递再跑一遍。临时的 session/writer-held 就是靠这一层自愈的。
三条路径都走宿主进程里的同一把锁。requestId 让顺序重试安全,但并发重试不安全:两次尝试可能在谁都没写进收件箱之前都判定「没有这条消息」,于是会话里出现两份续跑消息。
test/host.test.mjs 直接驱动了这个竞态。
已经投递过的票据不再是「未投递」,所以第 2、3 层自动变成空操作而不是风险。
配置
没有设置页。 插件不声明 schema,因为声明 schema 就意味着 import 一份宿主运行时包的副本——见已知限制。配置是两层,后者按字段覆盖前者:
1. cordis.patch.yml 里插件行的 config:;以及
2. 一个属于你自己的可选 JSON 文件,在任何 clone 之外:
%DSH_HOME%\plugin-data\dsh-self-restart\config.json
%DSH_HOME% 默认是 %USERPROFILE%\.dsh。这个 JSON 文件在 DSH 运行期间会被重新读取,所以你可以改完立刻生效,不必重启。不是合法 JSON、或字段类型不对的文件会被记进日志然后忽略:上一份可用配置继续生效,dsh_status 会列出问题。不是设置项的键也会被报出来,因为写错键名是最费时间的那种故障。
| 字段 | 默认值 | 含义 |
| enabled | true | 总开关。关掉后工具仍在,但每个动作都会拒绝。 |
| restart.appPath | (推导)* | 要重新启动的可执行文件。由宿主自己的可执行文件推导,只有推导错了才需要设。 |
| restart.appArgs | [] | 重新启动时的参数。 |
| restart.appCwd | (推导) | 重新启动时的工作目录。 |
| restart.port | 0 | 重新启动前必须释放的端口。0 表示从 DSH 公布给子进程的 origin 推导;推不出来时,执行体在关闭任何东西之前会从进程表里查出它。只有在两条路都不对的时候才需要手动设。 |
| restart.readyMode | "turn-end" | turn-end 等发起重启的那一轮干净结束;immediate 完全不等。 |
| restart.closeMode | "graceful-then-force" | force 完全跳过优雅路径。 |
| restart.primitive | "auto" | auto 先试 wmi,允许的话再试 scheduled-task。点名某个原语仍然会测量它,因为「用户点名要 WMI」不等于「WMI 在这台机器上能用」——只有一个例外:"detached" 按你说的算,因为它的成立与否取决于一个 DSH 内部无法检查的前提。走的是哪种,轨迹里会记下来。 |
| restart.powershellPath | (推导) | Windows 上是 powershell.exe;想用 pwsh 就设它。 |
| restart.allowScheduledTask | false | 允许任务计划原语。它与 WMI 一样在结构上可靠,但会在系统里留下痕迹,所以它是备选而不是默认。 |
| restart.gracefulMs | 15000 | 等 DSH 自己收尾多久之后转为强杀。 |
| restart.deadlineMs | 90000 | 执行体等就绪门多久。超时后继续,并记 forced: true。 |
| restart.bootMs | 120000 | 等新一代启动记录多久。 |
| restart.ttlMs | 86400000 | 票据的有效期。过期票据永不投递,所以昨天的请求不会在今天突然变成一条消息。 |
| resume.enabled | true | 新一代是否把对话接回来。 |
| resume.locale | "auto" | auto 跟随 DSH_LOCALE/LANG;也可以强制 "en"/"zh"。 |
| guard.refuseWhenOthersAreActive | true | 有别的会话在跑或刚活动过时拒绝重启。 |
| guard.activeWindowMs | 120000 | 别的会话多久内活动过才算「挡路」。 |
| watchdog.retryCooldownMs | 5000 | 未投递票据看门狗遵守的最小间隔。 |
| staleness.clientChanges | "auto" | auto 认为纯客户端改动由热加载覆盖;reload-ui 主动刷新;none 报「无需动作」。 |
刷新控件
dsh_ui_reload 不需要任何人工操作,但有意思的地方在于:打包版桌面端根本没有刷新入口——它接管的菜单里,重新加载只在 development 构建存在。所以这个插件补了一个,并在侧边栏底部、设置按钮旁边放了一个按钮,点击前先确认。
刷新比重启便宜得多,插件也在工具说明里写清楚了,免得 agent 无谓升级:
- 保留:会话历史、草稿、以及正在跑的回合——它们都在宿主里,壳会从浏览器自己的存储里恢复上次打开的会话;
- 失去:侧边栏里内嵌的浏览器标签,以及页面内未保存的状态。
宿主无法向页面推送任何东西,所以客户端半侧长轮询一条私有路由。工具请求刷新时,requestId 会在
location.reload() 之前存进 sessionStorage;新页面读到它之后回报。这就是为什么工具能
带着证据说「已刷新」,也是为什么页面始终不回答时它说的是「未确认」而不是「完成」——「我问了」和「它发生了」是两件不同的事实。
排查
dsh_restart 报 no-out-of-job-primitive。 没有任何原语创建出「不是本进程创建」的进程。跑 dsh_status 看每个原语各自的结果。把 restart.allowScheduledTask 设为 true 会加上任务计划原语,它与 WMI 有同样的结构保证,代价是系统里会出现又消失一个计划任务。如果你用别的方式确认了这个 DSH 实例的作业对象不是 kill-on-close——比如让 DSH 的某个子进程活过一次手动退出——就把 restart.primitive 设为 "detached";它会被采用,票据里也会记下「这是未经测量就采用的」。
重启一直没发生,轨迹停在 ticket-written。 执行体被创建了但没活下来,或者没起来。票据目录里的 restarter.log 会有一条 launched 然后什么都没有。核对一下用的原语和 dsh_status 测出来的是不是同一个。
轨迹停在 start 之后就没有了,而且进程代从来没变。 执行体撞上了一个它没预料到的错误。它的第一行在任何事之前就写下了,所以 start 之后什么都没有意味着它在读票据的时候就死了——而原因几乎总是编码:
Windows PowerShell 读一个没有 BOM 的文件时用 ANSI 代码页解码。宿主的票据是 UTF-8 且不带 BOM,于是一条含非 ASCII 文本的 reason(也就是说,任何用中文写的 reason)会变成乱码, ConvertFrom-Json 拒绝整张票据,而 $ErrorActionPreference = 'Stop' 让脚本在关闭任何东西之前就直接结束。
这不是假设:第一次真实的重启尝试就是这样什么都没发生,没有回执也没有任何消息。现在执行体碰的每个文件都显式按 UTF-8 读写,未处理的错误也会在退出前写下一行 unhandled-error,带上错误信息和位置。如果你看到那一行,里面就是答案;如果轨迹停在 start 之后什么都没有,那是运行中的脚本还没有这个修复。
轨迹停在 waiting-ready。 就绪门一直没开:发起重启的那一轮始终没有走到干净的 turn/end。执行体在 restart.deadlineMs 之后仍会继续,并把回执标成 forced: true,续跑消息也会重复这一点,让下一轮知道它的前一轮尾巴可能缺失。
轨迹走到 closed 但可执行文件没起来。 receipt.json 会是 failed 并带原因。常见原因是 restart.appPath 不对,或者有东西还占着端口导致单实例交接。
重启正在进行,但你看不到它的窗口。 这是刻意的。执行体是一个 PowerShell 进程,而控制台窗口是人可以关掉、也可以按 Ctrl+C 的东西;两种操作都会把重启杀死在「DSH 已被要求关闭」和「DSH 已被重新启动」之间,结果是应用停在那里、再也不会被拉起来。所以窗口被要求隐藏两次:创建进程时传 Win32_ProcessStartup 的 ShowWindow = 0,启动参数里再带 -WindowStyle Hidden。两者都在 Windows 11 上实测过:同一个 WMI 原语,创建出来的进程在没有启动记录时自报 IsWindowVisible() = true,有的时候是 false;而执行体自己的 identity 轨迹行里会写下 consoleWindowVisible,对真实跑过的那次重启留下证据。如果你确实看到了那个窗口,别动它——此时重启正在半路;轨迹会告诉你这台机器上哪一层隐藏没有生效。
续跑消息来得晚,或者宿主返回 restart-in-flight。 这是刻意设计。发起重启的那一代,在自己创建的执行体还活着时没有资格报告结果:它会对着一个正在进行的重启说「没有发生」,而且会把票据消费掉,真正加载了新代码的那一代就再也没机会开口。这种情况下轨迹里会有一行 delivery-deferred,其他什么都不写,票据保持待投递,宿主 watchdog 和页面会继续重试。等到执行体消失后,同一代就可以如实地报「重启没有发生」——这个例外正是为「重启静默死掉」准备的。
重启发生了,但对话里没出现消息。 调 dsh_selfcheck:它会报执行体阶段和启动记录是否存在。delivered.json 是投递的证据;如果 boot.json 在而 delivered.json 不在,说明进程换了但续跑消息被拒了——轨迹里会有会话控制器返回的错误码(session/not-found、session/writer-held、session/agent-busy)。
下一次启动会用同一个 requestId 重试,所以临时的 writer-held 会自愈。
dsh_ui_reload 报「未确认」。 页面没有在轮询:可能关了,也可能客户端半侧没加载起来。去 设置 → 插件 看有没有失败的客户端条目,然后用 dryRun: true 跑一次 dsh_apply,看插件是否认为该做一次 entries-retry。
插件根本没加载。 插件没激活时 dsh_status 也不在。到宿主日志里找 dsh-self-restart,并在 设置 → 插件 里确认该行是启用的。插件声明了三个必需服务(tools、sessions、sessionController);缺任何一个的 profile 会让它刻意保持不激活,而不是半死不活地跑。
改完代码后 install_bundle 仍报 application: failed。 这是模块缓存,也正是这个插件存在的意义。加载器按 URL import 插件,而 Node 会按 URL 把一个 ES 模块缓存到进程结束——包括 apply 抛过异常的插件,因为 import 本身成功了,只有激活失败。所以你改完文件重装,装上去的仍是旧的那份已求值模块,报错里的行号也是旧的。判断办法:
安装副本是不是你改过的那份代码?sh
node -e "const fs=require('fs');console.log(fs.readFileSync(process.argv[1],'utf8').includes('required: true'))" \
"$DSH_PROFILE_DIR/node_modules/dsh-self-restart/lib/index.js"
如果它打印 false,而报错还在抱怨文件里已经没有的东西,那就是当前进程抱着上一代不放。
set_bundle、以及 remove_bundle 后再 install_bundle,都只是重新登记那一行,清不掉 Node 的模块缓存。按老办法重启一次 DSH;之后这件事就交给 dsh_restart。
改工具 schema 时被 JsonSchemaError 拒绝。
DSH 只接受 JSON Schema 的一个子集:type、oneOf、properties、required、 additionalProperties、items、enum、const,加上 description、title、default、 examples 这几个注解。其中 required 必须是挂在 object 节点上的字符串数组——不是书写 DSL 里那种逐属性的布尔值——而且里面每个名字都必须出现在 properties 里。test/host.test.mjs 把这条规则完整镜像了一份,所以写错会在 npm test 里失败,而不是等到安装。
仓库边界
这个仓库可以直接公开,而这一节就是让这句话可被检验、而不是一句声明的契约。
这件事在本插件上比在多数插件上更重要,因为这个插件的存在意义就是关闭并重新启动正在运行的应用。它在这个过程中观察到的一切都是本机证据:进程 id、会话 id、安装目录的绝对路径、某条会话的工作目录、回执的内容。
会被提交的内容
源代码、测试、工具、文档与元数据:
.gitattributes .gitignore LICENSE
README.md README.en.md
package.json cordis.patch.yml
icon.svg locale/{en,zh}.json
lib/ 插件本体 lib/index.js 宿主半侧:工具、票据、就绪门、续跑投递 lib/client.js 客户端半侧:刷新控件与长轮询 lib/channel.js 私有路由及其线协议 lib/ticket.js 文件契约,以及建立在它之上的生命周期规则 lib/staleness.js 哪种改动需要哪种动作,以及为什么 lib/detect.js 测量哪个原语能离开作业对象 lib/config.js 默认值、校验、可选文件层 lib/identity.js 一切在运行时发现、而不是写死的东西 lib/template.js 续跑消息 tools/restarter.ps1 活过 DSH 的那个进程 tools/job-probe.ps1 决定怎么创建它的那次测量 tools/verify-repo-boundary.mjs 下面描述的那个检查器 test/ 测试套件
刻意不提交的内容
| 排除在外 | 原因 |
| DESIGN.md、docs/、research/ | 内部方案笔记。它们引用了写作那台机器的绝对目录,公开会泄露那台机器的目录结构。 |
| restarts/、plugin-data/、ticket.json、ready.json、receipt.json、restarter.log、boot.json、delivered.json | 重启证据。票据里有进程 id、会话 id 和安装目录绝对路径;回执里有结果;轨迹里有过程。.gitignore 把它们挡在提交之外,边界检查器会在它们一旦被跟踪时失败。 |
| .log、.jsonl | 调试过程留下的轨迹文件。 |
| 你的 config.json | 位于 %DSH_HOME%\plugin-data\dsh-self-restart\,在任何 clone 之外;这里再挡一道。 |
| .test-tmp/ | 平台拒绝在系统临时目录下创建目录时,测试套件放 fixture 的地方。每次运行后删除;忽略它是为了让一次被中断的运行什么都留不下。 |
| node_modules/、coverage/、dist/、build/ | 没有要构建的东西也没有要安装的东西,留在这里只会在 diff 里制造噪音。 |
| package-lock.json、pnpm-lock.yaml、yarn.lock | 安装由 DSH 的 install_bundle(pnpm)负责。第二份 lockfile 只会描述另一个解析器并产生漂移。 |
| .env、.pem、.key、.credentials.yaml 等 | 凭据与本机环境。 |
这个项目里没有任何形式的秘密:它不存账号、不存 token、不存 API key,也不发任何网络请求。
没有任何本机路径被写死,这是设计使然。 要重新启动的可执行文件来自宿主自己的可执行文件,壳的进程 id 来自宿主的父进程,端口来自 DSH 公布给子进程的 origin,数据目录来自 %DSH_HOME%。每一项都可以在配置里覆盖,而每一项都是发现的而不是假设的——这也正是一个 clone 里不含任何属于作者机器的路径的原因。
自己检验这条边界 sh
npm test # 测试套件,包含文件之间的接缝
npm run verify-boundary # 发现本机路径 / 凭据 / 重启证据 / 超大文件就失败
npm run verify-clone # 把已提交的状态克隆到一个别的名字下,再跑上面两项
verify-boundary 读的是git 会发布的那份文件清单(git ls-files),不是工作区——你自己那份 config.json、留作诊断的某份回执、以及内部方案笔记,正是这条边界要挡在外面的它会报出本机绝对路径、形似凭据的文本、重启产物、运行时状态、不是纯 ASCII 的 PowerShell 脚本,以及异常大的文件,发现任何一项都以非零退出。每次 push 之前跑一次。
verify-clone 回答的是 npm test 回答不了的那个问题:你即将 push 的这份状态,自己能立住吗?
它把已提交的树克隆到一个名为 a-checkout-named-something-else 的临时目录,在那里跑测试套件和边界检查。那个名字就是重点——一个断言「插件目录叫 dsh-self-restart」的测试,在作者机器上绿、在任何克隆到 restart-plugin 或解压发布 tarball 的人机器上红,而这个检查就是抓它的。
它确实抓到了,而且抓到两次。
同样的性质也被 test/boundary.test.mjs 断言了,所以犯错会先在 npm test 里失败:两个半侧对路由名的一致、manifest 与自己那份 patch 的一致、宿主半侧不 import 任何宿主运行时包、客户端半侧保持为模块加载器工厂、以及没有任何已发布文件写死盘符路径。
开发 sh
npm test # 无网络、无副作用、不启动任何进程
npm run verify-boundary # 发布边界
npm run verify-clone # 在别人的目录名下重新克隆并重跑
测试套件只写临时目录。它从不重启任何东西、从不发送窗口消息、也从不接触真实的 DSH 进程:每一个有副作用的依赖——spawn、PowerShell、时钟、文件读取、操作系统——都是注入的,所以测试驱动失败路径和驱动正常路径一样容易。
文件划分,以及为什么这么分:
| 文件 | 职责 |
| lib/index.js | 宿主插件:工具、票据生命周期、就绪门、续跑投递。 |
| lib/client.js | 客户端半侧:刷新控件与长轮询。 |
| lib/channel.js | 私有路由与线协议,集中在一处,好让两个半侧都对着它检查。 |
| lib/ticket.js | 文件契约。不 import DSH,也没有自己的时钟:每条规则都可直接测试。 |
| lib/staleness.js | 哪种改动需要哪种动作。作用在路径列表上的纯函数。 |
| lib/detect.js | 逃出作业对象:纯的转义与脚本拼装,加上注入了进程操作的测量循环。 |
| lib/config.js | 默认值、校验,以及那一个可选文件。 |
| lib/identity.js | 一切在运行时发现的东西。纯解析函数与 process 读取分开。 |
| lib/template.js | 续跑消息。 |
| tools/restarter.ps1 | 唯一在 DSH 消失过程中运行的代码。刻意不做任何自己的发现。 |
| tools/job-probe.ps1 | 一个问题,一个答案,写进文件。 |
已知限制
- 仅限 Windows。 优雅关闭是一串 Windows 消息,强杀路径是 taskkill;这里没有 macOS 或 Linux 的等价实现。
- 优雅关闭依赖 Electron 一个可能变化的既有行为。 向壳主窗口投递 WM_ENDSESSION 会让 Electron 发出 session-end 事件,而这正是 DSH 自己收尾路径的开关。如果将来的 Electron 不再这么做,优雅这一步就只会超时,然后由强杀接管。这也正是它写成「优雅优先、强杀兜底」而不是「优雅关闭」的原因。
- 没有设置页,也没有 Schemastery 的 Config schema。 声明 schema 就意味着 import @deepseek-ai/schemastery;而插件解析的是自己的 import——那些 @deepseek-ai/ 运行时包从 DSH 安装目录解析,而不是从 profile 安装的插件解析,所以 import 它们会绑上第二份模块实例,它的常量与 instanceof 判断和正在运行的宿主并不一致。因此本插件用 ctx.tools.register 直接接受的普通对象形态注册工具,并自己校验配置。代价是真实的:配置是文件,不是表单。
- 宿主半侧无法主动申请权限升级。 DSH 的升级辅助函数位于一个 profile 插件解析不到的随附包里,所以插件会尝试,失败后如实说明没有申请升级,而不是假设已经批准。该调用依然受会话自身的权限模式约束,而「不要结束别人的回合」这条护栏是独立强制的。
- 被重启的会话是用用户消息接回来的,不是系统消息。 这是刻意的——它可见、可反驳、可审计——但也意味着对话里会有一条用户没打过的内容。
- 每次启动最多投递一次。 「已接受」的意思是会话控制器收下了这条消息,不代表模型已经开始回答,因为唤醒 agent loop 是异步的。
- 刷新入口是两个,不是四个。 页面可以由工具调用刷新,也可以由侧边栏底部、设置按钮旁边的那个按钮刷新。没有快捷键,也没有状态条:快捷键需要通过一个本包并不依赖的客户端服务注册,而一个静默注册失败的键位比一个从没承诺过的键位更糟。设计里入口集合本来就是未定的,这里是「不靠猜私有 API 就能做出来并验证」的那个子集。
- 强制重启可能截断上一轮。 执行体会说明、回执会说明、续跑消息也会说明。DSH 在恢复会话时会自己收尾一个被中断的回合,所以日志始终是完整的——但那段从没被写下来的尾巴是真的没了。
- 子进程依然会死。 这个插件让 DSH 的关闭变成优雅的,而不是保持服务存活;这是两件事。
任何必须活过重启的东西,都得在 DSH 之外启动。
- 它不是崩溃守护。 DSH 自己崩了不会触发任何重启;插件只在 agent 要求它动手时才行动。
致谢
- 私有路由的做法——直接注册到 web server,而不是用 connection.rpc.handle,并讲 DSH 自己的请求/响应信封——沿用同工作区里 dsh-chat-enhance 插件确立的模式。
- bundle 形态(dsh.bundle.patch、dsh.client、locale 文件、图标,以及那套仓库边界检查器)沿用 dsh-task-ask-notify,同样是同工作区的插件。
- 优雅关闭序列建立在 Electron 自身对 WM_ENDSESSION 的 session-end 处理之上,也建立在那条最早记录该行为的 issue 之上: electron/electron#15880 与 WindowSessionEndEvent。
许可证
MIT © 2026 deadbushxw。预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
deadbushxw/dsh-self-restart
改完插件让 AI 自己重启应用并在原会话继续
相关插件 · 对话 / 记忆
上下文中枢oceanbase/powercontext
上下文跟着工作走 —— 跨会话、跨智能体、跨人。
★ 1,487已汉化DSH
73
智能体记忆库plastic-labs/honcho
Honcho 是记忆基础设施,用来构建有状态的智能体。这些智能体能理解不断变化的人、智能体、群组、项目和想法。
★ 7,894已汉化DSH
71.7
长程智能体控制面loopx-project/loopx
给 Agent 一个目标,让工作持续向前。
★ 6,358已汉化DSH
71.3
智能体上下文数据库volcengine/OpenViking
给 AI 智能体用的上下文数据库
★ 41,379已汉化DSH
71.3
长效机器人团队Fei2411/DS-Bot
DS Bot 是一个基于 DeepSeek Harness简称 dsh的 bot 插件,插件将 dsh 改造为 bot…
★ 5,084已汉化DSH
71.1
可携带记忆层EverMind-AI/EverOS
为什么选择 EverOS
★ 13,778已汉化DSH
71.1
这是你的仓库?认领条目