@ray1270/dsh-restart 0.0.0-stage → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ray_lv
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,290 @@
1
- # Temporary Holding Version
1
+ # @ray1270/dsh-restart
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/Ray1270/dsh-restart/actions/workflows/ci.yml/badge.svg)](https://github.com/Ray1270/dsh-restart/actions/workflows/ci.yml)
4
+ [![Release](https://img.shields.io/github/v/release/Ray1270/dsh-restart?sort=semver&display_name=tag)](https://github.com/Ray1270/dsh-restart/releases/latest)
5
+
6
+ 给 DeepSeek Harness **桌面版**加两个常驻按钮,就在窗口顶栏「应用」「编辑」右边:
7
+
8
+ ```
9
+ 应用 编辑 刷新 重启
10
+ ```
11
+
12
+ - **刷新** = 重新加载这个页面。Host 不动、会话记录不动、正在跑的 agent 也不动,只把这个 Web 客户端重新装一遍。加载完仍然停在你刚才那个会话。
13
+ - **重启** = 重启整个应用(Electron 壳 + Host)。点两下,2–4 秒后应用带着**你刚才那个会话**重新打开。
14
+
15
+ > 为什么需要它:桌面版的端口每次启动都是随机分配的(`--port 0`),所以每次重启等于换了一个 origin——旧页面失联、壳自动开新页面、你被扔回首页。想「重启一下看效果」原本要:关窗口 → 找图标 → 等启动 → 手动点回会话。
16
+ >
17
+ > 刷新则解决另一半问题:**客户端插件的改动**(slot 注册、界面代码)只需要重新加载页面就能生效,没必要为此重启 Host。但 DSH 的当前会话只活在页面里、不在 URL 上,手按 F5 会掉回首页——所以这个按钮顺手把会话也带回来了。
18
+
19
+ ---
20
+
21
+ ## 用起来是什么样
22
+
23
+ | 状态 | 顶栏显示 | 说明 |
24
+ | --- | --- | --- |
25
+ | 空闲 | `刷新` `重启` | 两个都是普通菜单项,样式与「应用」「编辑」一致 |
26
+ | 点「刷新」 | `刷新中` | 约 0.1 秒后整页重载;回到同一会话(2 分钟内有效) |
27
+ | 点「重启」第一次 | `确认重启` / `确认重启 · N 项` | 预检;N = 此刻会被中断的 agent / job / 终端数量;4 秒不点自动撤销 |
28
+ | 点「重启」第二次 | `重启中` | 1–3 秒后应用退出,随即自动拉起;回来时停在同一会话(30 分钟内有效) |
29
+ | 失败 | `重启失败` / `刷新失败` | 6 秒后复位;原因在 tooltip 里 |
30
+
31
+ **为什么刷新只要一下、重启要两下**:刷新不会杀掉任何服务端的东西(agent 继续跑、会话继续写),代价最大只是**输入框里没发出去的草稿**会丢——tooltip 里写明了。重启会真的中断活动任务,所以必须让人看清数字再决定。
32
+
33
+ 顶栏那条菜单是桌面壳自己画的(`preload` 里的 shadow DOM),插件只能在它后面追加同级按钮——所以样式、字号、悬停、拖动区都和原生菜单项一致,窗口变窄也不会挤到右边的窗口按钮。
34
+
35
+ 不是 Windows 桌面版、或者壳没有画出这条菜单时,同一对按钮回落到**会话标题栏最右侧**(macOS / 纯浏览器里就是这个)。两条入口共用同一套逻辑,同时在屏幕上时顶栏那份优先,标题栏那份自动让位。
36
+
37
+ 没有任何弹窗,也没有控制台窗口一闪而过——重启全程屏幕上只有应用自己关掉再打开。
38
+
39
+
40
+ ---
41
+
42
+ ## 安装
43
+
44
+ 已发布到 npm,一行命令:
45
+
46
+ ```bash
47
+ dsh plugin --profile desktop add @ray1270/dsh-restart
48
+ ```
49
+
50
+ 装完**重启一次应用**才会加载(这一次只能手动重启 —— 鸡生蛋),之后就靠这两个按钮了。
51
+
52
+ `dsh plugin add` 走的是 `pnpm add` 到 profile 里,再把包名追加进 `dsh.profile.bundles`,不用手改任何文件。应用里的 **插件 → 添加插件** 走的是同一条路,只是多一个填路径的输入框。
53
+
54
+ 不想走 npm 就下载附件:到 [Releases](https://github.com/Ray1270/dsh-restart/releases) 拿最新的 `ray1270-dsh-restart-<版本>.tgz`,在 **插件 → 添加插件** 里填它的绝对路径。两条路装的是同一个文件 —— release 附件和 npm 上那份 tgz 的 sha256 逐字节相同(每个版本都核对过)。
55
+
56
+ 想自己再核对一遍:
57
+
58
+ ```powershell
59
+ Get-FileHash .\ray1270-dsh-restart-<版本>.tgz -Algorithm SHA256 # 与同名 .sha256 文件比对
60
+ ```
61
+
62
+ 从源码装(开发):
63
+
64
+ ```bash
65
+ dsh plugin --profile desktop add "<这个目录的绝对路径>"
66
+ ```
67
+
68
+ 装完同样要重启一次应用。
69
+
70
+ ---
71
+
72
+ ## 它到底怎么做的
73
+
74
+ ### 刷新:纯客户端,Host 不参与
75
+
76
+ ```
77
+ [浏览器] 点「刷新」
78
+ │ 1. 把当前 sessionId 写进 localStorage(同源,重载不丢)
79
+ │ 2. location.reload()
80
+ ▼
81
+ [页面] 重新加载:client 插件重新拉取、重新挂载
82
+ │ 3. 读到标记 → uiWorkspace.openSession(sessionId) → 删标记
83
+ ▼
84
+ [浏览器] 停在同一个会话
85
+ ```
86
+
87
+ 刷新不跨 origin,所以标记放浏览器存储就够了,不必走 Host 文件——**刷新这条链路上没有任何一次网络请求**(harness 里用「fetch 调用次数 = 0」盯着这一点)。
88
+
89
+ ### 重启:Host 侧排程,helper 由 WMI 服务启动
90
+
91
+ ```
92
+ [浏览器] 点第二下
93
+ │ POST /api/dsh-restart/restart { sessionId, force }
94
+ ▼
95
+ [Host] 1. 活动工作检查(force 才放行)
96
+ 2. 写 pending-restore.json(重启后要回的会话)
97
+ 3. 起一个短命的 powershell,让它调用
98
+ Win32_Process.Create → 由 WmiPrvSE.exe 创建 helper.ps1
99
+ (创建时带 SW_HIDE 启动信息:helper 生来没有窗口)
100
+ 4. 等 helper 写出 boot 状态 → 才回 200
101
+ │
102
+ ▼ helper 的父进程是 WmiPrvSE,不在应用的进程树里
103
+ [helper] 5. 复核 ShellPid 的镜像路径仍等于目标 exe(防 PID 复用)
104
+ 6. taskkill /F 只杀 Electron 壳(**不加 /T**)
105
+ 7. 等 Host 自己退出(最多 8 秒,超时才补刀)
106
+ 8. Start-Process 重新拉起 exe(失败重试一次,并校验新进程还活着)
107
+ 9. 结果写 restart-state.json
108
+ │
109
+ ▼ 壳一死 → Host 的 IPC 管道断开 → process.once("disconnect")
110
+ [Host] application.shutdown.shutdown(0) —— 它自己的优雅退出,5 秒上限
111
+ ```
112
+
113
+ **关键点 1:只杀壳,不杀树。** Host 子进程注册了 `process.once("disconnect", () => stop())`,壳一死它就自己走完整的 dispose 流程。所以「硬杀」只硬在壳这一下,Host 侧仍然是优雅收尾。
114
+
115
+ **关键点 2:helper 必须活着跨过「壳死掉」那一刻——普通子进程做不到。** 这一点最初是错的,实际表现就是「只有关、没有开」:helper 写下 `boot`、写下 `killing`、taskkill 发出去了,然后它自己跟着壳一起消失,重拉那一步根本没执行。应用关掉,再也没回来。
116
+
117
+ 原因:**这个应用的子进程树不活过壳**(壳被杀 → 整棵树一起收走)。所以 helper 不能是 Host 的子进程,也不能是壳的孙进程——它必须由**进程树之外**的进程创建。`Win32_Process.Create` 由 `WmiPrvSE.exe` 执行,helper 的父进程是 WMI 服务,壳死的时候它毫发无损。`helper.log` 的 boot 行会记下 `parent=…(WmiPrvSE)`,这是这条链路生效的直接证据。
118
+
119
+ **关键点 3:不用 `detached: true`,也不靠它。** 在 Windows 上,Node 用 `detached` 启动 `powershell.exe` 会让它立刻以 0 退出——一行都不执行(实测)。而且 `detached` 本来也不提供进程树隔离(那需要 `CREATE_BREAKAWAY_FROM_JOB`,而这里的 job 不允许)。真正让 helper 活下来的是「由谁创建」,不是「怎么 detached」。
120
+
121
+ **关键点 4:WMI 启动失败就放弃重启,不赌。** 短命的 launcher 把结果写进 `launch-result.json`;Host 等不到 helper 的 `boot`、或 WMI 明确拒绝,就直接回 500 并写 `cancel-restart` 标记——还在延迟中的 helper 看到标记会立刻放弃,不会在界面已经说「没重启」之后再把应用关掉。
122
+
123
+ **关键点 5:创建的进程要「生来就是隐藏的」,不然屏幕上会多一个 PowerShell 窗口。** WMI 的 `Win32_Process.Create` 不带启动信息,控制台程序(helper 是 `powershell.exe`)便会**在创建的那一刻拿到一个可见的控制台窗口**——实测它会一直留在屏幕上,直到 helper 自己退出。`-WindowStyle Hidden` 救不了:那个开关是在窗口已经存在之后才去隐藏,实测无效。所以 launcher 构造 `Win32_ProcessStartup`(`ShowWindow = 0`,即 SW_HIDE)一起交给 `Create`,进程**一开始就不可见**。
124
+
125
+ ---
126
+
127
+ ## 为什么不用 `ctx.appExit`(生态里现成的「安全重启」)
128
+
129
+ `ctx.appExit(0)` 在桌面版等于让 Host 自杀。Host 一退,壳的 `child.once('close')` 就判定为**异常终止**,弹出「应用意外停止」恢复框,要用户再点一次「重启」。
130
+
131
+ 那条路能用,但:多一次点击、日志里留一次假崩溃、语义上是「应用崩了」。这个插件选的是「杀壳 + 重新拉起」,所以能一次点完。`appExit` 那条路留作兜底(见下面的自救)。
132
+
133
+ ---
134
+
135
+ ## 配置
136
+
137
+ 写在 profile 的加载行 `config:` 里(也可以直接改 `cordis.patch.yml`):
138
+
139
+ | 键 | 默认 | 含义 |
140
+ | --- | --- | --- |
141
+ | `enabled` | `true` | 重启总开关;`false` 时重启会报「不可用」并拒绝一切重启请求(刷新不受影响) |
142
+ | `delayMs` | `1200` | 动手前的等待,用来保证 HTTP 回执先到浏览器 |
143
+ | `hostGraceMs` | `8000` | 等 Host 自己优雅退出的上限,超时才强杀 |
144
+ | `killTimeoutMs` | `15000` | 等旧进程彻底消失的硬上限 |
145
+ | `pendingTtlMinutes` | `30` | 「重启后回哪个会话」标记的有效期 |
146
+
147
+ 刷新没有配置项:它不碰 Host,也就没有可配置的危险动作。
148
+
149
+ ---
150
+
151
+ ## 出问题了怎么自救
152
+
153
+ 按可能性排序:
154
+
155
+ 1. **点了没反应、应用还在。** helper 没起来(WMI 被禁用/拦、脚本被删)。此时**应用完全没受影响**——这是刻意的:Host 等到 helper 的 `boot` 状态才回 200,等不到就直接返回 500 并放弃重启。按钮会在 12 秒后变成红色提示。先看 `~/.dsh/dsh-restart/launch-result.json`(WMI 那一步的返回码和错误),再看 `helper.log`。
156
+ 2. **应用退出了但没回来。** 说明 helper 在杀之前就死了(旧版本的症状),或者 `Start-Process` 被拦。手动双击 `D:\dsh\DeepSeek Harness.exe` 即可;`restart-state.json` 里会写着停在哪一步,`helper.log` 的 boot 行会写出 helper 的父进程是不是 `WmiPrvSE`。
157
+ 3. **点「刷新」后回到了首页。** 说明会话标记没能写下(无痕模式、存储被策略关闭)或会话已被删。刷新链路失败一律静默退回首页,不会卡住。
158
+ 4. **想彻底绕过这一切。** 托盘右键退出,再双击图标——和以前一样。
159
+
160
+ 状态文件都在 `~/.dsh/dsh-restart/`:
161
+
162
+ | 文件 | 内容 |
163
+ | --- | --- |
164
+ | `restart-state.json` | 最近一次重启的阶段(`boot` → `killing` → `killed` → `relaunching` → `relaunched` / `failed` / `aborted`) |
165
+ | `helper.log` | 逐行带时间戳的过程日志,boot 行带 helper 的父进程 |
166
+ | `launch-result.json` | WMI 创建 helper 那一步的结果:`ok` / `pid` / `returnValue` / `windowHidden` / `error` |
167
+ | `pending-restore.json` | 待恢复的会话标记,被下一次页面加载消费后即删除 |
168
+ | `cancel-restart` | 只在 Host 放弃一次启动时短暂出现:还在延迟中的 helper 看到它就退出,不再动手 |
169
+
170
+ ---
171
+
172
+ ## 已知限制
173
+
174
+ - **刷新不重载 Host 侧插件。** 它只是把浏览器这一半重新装一遍:客户端插件、原生菜单、样式都会更新;Host 插件(路由、服务)必须用「重启」。
175
+ - **刷新会丢没发出去的草稿。** 输入框内容只活在页面里。已发送的对话、正在生成的回复、后台 job 都不受影响。
176
+ - **顶栏入口依赖桌面壳的菜单条。** 那条菜单是 `preload` 用 `data-windows-menu` 标记 + open shadow root 画的,不是 Web 客户端的 slot。插件按「探测不到就退让」处理:找不到宿主就静默不挂载,标题栏入口顶上;壳若在未来版本改了结构,最多是回到标题栏,不会报错也不会留半个按钮。
177
+ - **同一时刻只有一个入口在屏幕上。** 顶栏挂载成功时标题栏那对会渲染成空(但仍然挂着,因为它是唯一拿得到 `sessionId` 的地方,用来做刷新/重启后的会话回填)。
178
+ - **重启只支持桌面版。** CLI / headless 的 Host 没有壳可重启;检测到这种情况时按钮会明确报「不支持」。刷新在任何环境都可用。
179
+ - **重启会中断进行中的工作。** 确认框里的数字就是会断掉的东西,确认过就是真断。会话记录是追加写,丢的是进行中那一轮和 job 输出。
180
+ - **重启后端口会变。** 旧标签页一律失联,壳会开新页面;远程访问的地址也会变。刷新不会。
181
+ - **重启依赖 WMI 服务。** helper 必须由 `WmiPrvSE.exe` 创建,否则它活不过壳被杀的那一刻。WMI 被策略关掉、或 `Win32_Process.Create` 被安全软件拦住时,重启会**明确拒绝**(应用不受影响),错误和返回码写在 `launch-result.json`。这是有意的取舍:宁愿不重启,也不要关掉一个回不来的应用。
182
+ - **杀软可能好奇。** 「隐藏窗口的 PowerShell 杀掉应用再拉起」是 EDR 的经典启发式,加上 WMI 创建进程就更容易被盯。所以 helper 是明文的、带完整注释的、固定路径、日志可查,没有做任何编码混淆;`launch-result.json` 连 WMI 的原始返回码都留着。
183
+ - **极端情况下仍可能看到一个控制台窗口。** 如果某台机器上 `Win32_ProcessStartup` 构造失败,helper 就会退回「可见窗口」启动(重启本身照常工作):`launch-result.json` 里 `windowHidden` 会是 `false`,Host 日志也会留一行 warn。属于「看得见、可查」,不是静默发生。
184
+
185
+ ---
186
+
187
+ ## 开发与测试
188
+
189
+ 没有任何构建步骤:`lib/index.js`(Host 半区)、`lib/client.js`(浏览器半区)、`lib/helper.ps1`(重启动作)都是可以直接读的源码。
190
+
191
+ ```bash
192
+ node scripts/smoke-host.mjs # Host 半区 37 项:路由、状态、活动工作计数、标记消费、命令行/WMI 脚本拼装(含隐藏窗口)
193
+ node scripts/smoke-client.mjs # 浏览器半区 27 项:装载、slot 注册、两条入口、会话续接、刷新标记
194
+ node scripts/selftest-helper.mjs # helper 真机自检 23 项:直启与 WMI 启动各跑一遍杀进程/等退出/重新拉起、取消标记、PID 复用防护,并扫屏确认没有控制台窗口
195
+ node scripts/package-check.mjs # 15 项:打包、解开、验包,再用包里那份 lib 跑一遍上面两个 smoke
196
+ node scripts/install-check.mjs # 13 项:把 tgz 真的装进一个新建的 profile,再让 launcher 组合它(需要 PATH 上有 dsh 和 pnpm;CI 里也跑这一步)
197
+ ```
198
+
199
+ 前三个看的是**源码树**,后两个看的是**发出去的那个 tgz**。这两者会分叉:`files` 漏写一个文件、bundle patch 没进包、`exports` 指错路径——源码树全都正常,用户装上去什么都没有。`package-check` 把包解开、用它自带的 `lib/` 再跑一遍 smoke;`install-check` 走得更远一步,真的让 launcher 把这个包装进 profile。两条都是「别相信源码树正常就等于包正常」。
200
+
201
+ `selftest-helper.mjs` 用一次性的 `powershell.exe` 进程冒充「壳」和「Host」,**不会碰真正的 DSH**,因为它自己正跑在里面。第 2 组用的是和 Host 完全相同的 WMI 启动路径,并断言 helper 的父进程是 `WmiPrvSE.exe`——也就是说,「helper 不随壳一起死」这一点是跑出来验证的,不是推断的。同一组还会在 helper 运行期间**真的枚举屏幕上的可见窗口**(`scripts/find-visible-window.ps1`),确认没有那个标题为 `dsh-restart helper` 的控制台窗口;扫描器本身先对着一个已知可见的窗口验证过「看得见才算数」,免得负断言变成一个瞎子的断言。
202
+
203
+ 手工打开的浏览器 harness(`file://` 打开即可):
204
+
205
+ | 文件 | 断言 | 覆盖 |
206
+ | --- | --- | --- |
207
+ | `scripts/caption-harness.html` | 23 | 挂载时机、按钮顺序、字号/高度继承、两击重启契约、`sessionId` 进入 POST body |
208
+ | `scripts/refresh-harness.html` | 10 | **真重载**:点刷新 → 页面真的重载 → 标记存活 → 回到同一会话 → 标记被消费 |
209
+
210
+ `refresh-harness.html` 分两阶段跑(结果和「已点击」状态放 sessionStorage,跨重载存活),因为要测的行为本身就是「页面会消失」。
211
+
212
+ 浏览器半区的 slot 位置来自 DSH 自带的 slot 目录(`dsh-cordis-client-runner` 里的 `conversation.session.header.utilities`:右对齐、可叠加、注入 `sessionId`)。顶栏那条菜单条则来自 `app.asar` 的 `lib/preload-app.cjs`(`installWindowsMenu`)。
213
+
214
+ ---
215
+
216
+ ## 发布(维护者)
217
+
218
+ 打 tag 就算发布,没有手工上传的那一步:
219
+
220
+ ```bash
221
+ # 1. 改 package.json 的 version(tag 必须与之一致,否则 release 流程第一步就红)
222
+ # 2. 提交
223
+ git tag v0.3.7
224
+ git push origin main --tags
225
+ ```
226
+
227
+ `release.yml` 随后在 `windows-latest` 上按这个顺序做六件事:
228
+
229
+ 1. 比对 tag 与 `package.json` 的 version(不一致就红,不打包);
230
+ 2. 跑 `npm test`;
231
+ 3. 用同一个 checkout 打 `npm pack`,附上 `.sha256`;
232
+ 4. 连着验两次这个 tgz。`package-check` 把它解开:`files` 承诺的、`main` / `exports` 指向的、bundle patch 和 `lib/helper.ps1` 都必须在包里,最后把**包里那份** `lib/index.js` 和 `lib/client.js` 拉起来跑各自的 smoke(37 项 / 27 项)。`install-check` 再往前一步:把它真的装进一个刚建的 profile,让真 launcher 组合一遍,再读回它组合出来的东西(13 项);
233
+ 5. 删掉这个 tag 上可能已有的 release,重新建一个公开的,正文由 `scripts/release-notes.mjs` 从上一个 tag 之后的提交生成。
234
+ 6. 把**同一个 tgz**(不是重新打包)`npm publish` 到 npm。默认走 trusted publishing:这一步拿 runner 的 OIDC 身份换一个只够发一次的凭据,`--provenance` 顺带给 tarball 签一份来源证明,仓库里没有任何 npm 密钥。发完把 npm 现在提供的那个文件下载回来、和本地 `.sha256` 比一遍——字节一致才算过。只有「既没配 trusted publisher、也没有 `NPM_TOKEN`」(比如本地打 tag)才打印一行跳过;该发而没发出去是红的。
235
+
236
+ trusted publishing 已经配好了,**不用再去网页上点设置**。当前关系随时可复核:
237
+
238
+ ```bash
239
+ npm trust list @ray1270/dsh-restart
240
+ # type: github / id: 7414ac6a-742e-44c2-aa23-866885d25bed
241
+ # file: release.yml / repository: Ray1270/dsh-restart
242
+ # permissions: publish, stage publish
243
+ ```
244
+
245
+ 它是用 CLI 建的,代价只有一次 OTP(2FA),不需要开浏览器:
246
+
247
+ ```bash
248
+ npm trust github @ray1270/dsh-restart --file release.yml \
249
+ --repo Ray1270/dsh-restart --allow-publish -y
250
+ ```
251
+
252
+ 要复刻到别的包上,前提和约束是:
253
+
254
+ - npm 上 `@ray1270` 得先是你的(注册同名账号即自动持有这个 scope);
255
+ - `--file` 填 workflow 的**文件名**(`release.yml`),不是路径;`environment` 留空就是不绑定 environment;发布这一步必须和它写在同一个文件里(复用 workflow 会改变 npm 看到的那个文件名);
256
+ - 仓库里**不用加任何 secret**:`release.yml` 已经有 `permissions: id-token: write`,发布步骤靠 runner 的 OIDC 身份换凭据。npm CLI 得 ≥ 11.5.2(node 24 自带的够用),发布步骤会先查版本,太老就带着明确信息红掉,而不是丢一句「认证失败」。
257
+
258
+ `NPM_TOKEN` secret 仍然被支持(有就优先用它):Classic 的 **Automation**,或者 Granular 里勾 **Read and write**、只给这一个包。但 npm 已公布 bypass-2FA token 的直接发布会在 2027 年 1 月收回([changelog](https://github.blog/changelog/2026-07-31-restricting-npm-bypass-2fa-granular-access-tokens/)),所以默认路径是 OIDC。
259
+
260
+ 之后打的 tag 会自动多一份 npm 镜像;已经发过的版本不会被重复发布(重跑同一个 tag 会打印 `already there — left alone`),所以 `workflow_dispatch` 那条恢复路径仍然安全。
261
+
262
+ 第 4 步里的 `install-check` 需要一个真的 launcher,而在一台 windows runner 上 `npm install --global pnpm @deepseek-ai/dsh` 量到 **13 分 16 秒**(654 个包)。第一次把它加进流程时,发布就是卡在这里,然后被我拿掉了。
263
+
264
+ 后来发现同一台镜像上换成 `npm i -g --prefix "$RUNNER_TEMP/dsh-launcher"`,同样 654 个包 **64 秒**(又一次 78 秒)——区别只是写到 C: 盘的默认前缀,还是写到临时盘。所以现在用的是后者,`.github/actions/launcher/action.yml` 里就这一段加一行 PATH;CI 里也给它单开了一个 `install` job,整轮约 1 分半。
265
+
266
+ 中间还试过按 launcher 版本缓存那个前缀:197 MB,恢复 40 秒,对比 64 秒的安装——省下半分钟,代价是一个会失效的缓存、一段「缓存里的东西还跑得起来吗」的探测,以及两处要同步的版本号。删了。
267
+
268
+ 顺序是刻意的:**先验版本、再跑测试、再验包、再验装、最后才发**。任何一步红了都什么都发不出去,所以 Release 里那个 tgz 一定是「跑绿了、包里那份代码真的能起来、而且真能装进 profile」的那份。
269
+
270
+ 两条容易踩的坑,都是这台流水线自己踩出来的:
271
+
272
+ - **发布说明不能用 `--generate-notes`。** 那个功能只列「合并的 PR」,而这个仓库是直接往 `main` 推的,v0.3.3 第一版发出来时正文就只有一行 `Full Changelog`。
273
+ - **不能用 `gh release edit` 就地改。** tag 被移到新提交之后,旧 release 对外就查不到了(`GET /releases/tags/vX` 返回 404),但带 token 的 `gh release view` 还找得到它——于是编辑「成功」了三次,公开 API 里始终只有一个更老的版本。所以现在是删掉重建。
274
+
275
+ 这两条的共同点,也是第 3 步存在的理由:**流水线的绿灯说的是「命令返回 0」,不是「东西对了」。** 所以每一步都回头看结果——读回 API 看 release 是不是公开的、正文够不够长;解开包看里面是不是真的齐。
276
+
277
+ 发布完还会**读回 API 校验**:`isDraft` 必须是 `false`、正文长度必须够;不对就用 `::error::` 让这一步红掉。想重发一个已存在的 tag(比如某步一时抽风),用 `workflow_dispatch` 再跑一次即可——它会重建 release,说明也一起重算。
278
+
279
+
280
+ ---
281
+
282
+ ## 卸载
283
+
284
+ ```bash
285
+ dsh plugin --profile desktop remove @ray1270/dsh-restart
286
+ ```
287
+
288
+ 或者从 profile 的 `dsh.profile.bundles` 里删掉那一行,再重启。
289
+
290
+ 残留物只有 `~/.dsh/dsh-restart/` 这个目录,删掉即可。
@@ -0,0 +1,33 @@
1
+ # @ray1270/dsh-restart bundle patch: mounts the restart plugin into the host
2
+ # composition of a dsh profile. The Host half only adds four routes under
3
+ # /api/dsh-restart/*; the browser half adds 刷新 + 重启 to the window caption
4
+ # bar (and to the Session header where that bar does not exist).
5
+ #
6
+ # Activation:
7
+ #
8
+ # dsh plugin --profile desktop add <this package|local path>
9
+ #
10
+ # The routes refuse every destructive request unless this Host really is a
11
+ # desktop Host (Electron binary in Node mode), so enabling the bundle in a CLI
12
+ # profile is harmless: the button reports "not supported" and does nothing.
13
+ #
14
+ # Config fields (all optional):
15
+ # enabled master switch; false hides nothing but refuses
16
+ # every restart (default true)
17
+ # delayMs pause before the helper kills the shell, so the HTTP
18
+ # reply reaches the browser first (default 1200)
19
+ # hostGraceMs how long the Host may take to shut itself down before
20
+ # the helper force-kills it (default 8000)
21
+ # killTimeoutMs hard ceiling on waiting for the old processes to
22
+ # disappear (default 15000)
23
+ # pendingTtlMinutes how long "reopen this Session after the restart" stays
24
+ # believable (default 30)
25
+ - insert:
26
+ - id: '@ray1270/dsh-restart'
27
+ name: '@ray1270/dsh-restart'
28
+ config:
29
+ enabled: true
30
+ delayMs: 1200
31
+ hostGraceMs: 8000
32
+ killTimeoutMs: 15000
33
+ pendingTtlMinutes: 30
package/dev.patch.yml ADDED
@@ -0,0 +1,24 @@
1
+ # Development overlay: identical to cordis.patch.yml except for the plugin
2
+ # anchor. `./lib/index.js` is rewritten by the loader into a file URL beside
3
+ # THIS patch file, so the plugin loads straight from the source tree with no
4
+ # install step:
5
+ #
6
+ # dsh --profile desktop --patch <abs path to this file>
7
+ #
8
+ # (A bare `name` instead resolves from the profile's own node_modules, which is
9
+ # the right anchor for an installed bundle — see cordis.patch.yml.)
10
+ #
11
+ # Note that the client half is NOT picked up by `--patch`: the browser module is
12
+ # discovered from the installed package (`exports["./client"]` + `dsh.client`).
13
+ # Use the install step when testing the button itself.
14
+ - insert:
15
+ # Quoted for the same reason as cordis.patch.yml: `@` cannot open a plain
16
+ # YAML scalar.
17
+ - id: '@ray1270/dsh-restart'
18
+ name: ./lib/index.js
19
+ config:
20
+ enabled: true
21
+ delayMs: 1200
22
+ hostGraceMs: 8000
23
+ killTimeoutMs: 15000
24
+ pendingTtlMinutes: 30