dsh-autorestart 0.6.2 → 0.8.0

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.
Files changed (47) hide show
  1. package/README.md +460 -12
  2. package/assets/screenshot-1.png +0 -0
  3. package/assets/screenshot-2.png +0 -0
  4. package/assets/screenshot-3.png +0 -0
  5. package/assets/screenshot-4.png +0 -0
  6. package/assets/screenshot-5.png +0 -0
  7. package/client.js +6329 -3863
  8. package/cordis.patch.yml +44 -1
  9. package/docs/README.zh.md +297 -9
  10. package/docs/platform-matrix.md +92 -0
  11. package/docs/rescue-drill.md +606 -0
  12. package/index.js +2867 -199
  13. package/lib/access.js +212 -0
  14. package/lib/autostart-advice.js +198 -0
  15. package/lib/backup.js +131 -4
  16. package/lib/blockers.js +228 -0
  17. package/lib/checkpoint.js +147 -0
  18. package/lib/diagnostics.js +188 -0
  19. package/lib/health.js +264 -0
  20. package/lib/host-kind.js +145 -0
  21. package/lib/node-runtime.js +144 -0
  22. package/lib/rescue-paths.js +253 -0
  23. package/lib/rescue.js +596 -0
  24. package/lib/restart-verdict.js +327 -0
  25. package/lib/restart.js +823 -56
  26. package/lib/scheduler-capability.js +300 -0
  27. package/lib/sleep.js +46 -49
  28. package/lib/startup-mode.js +491 -0
  29. package/lib/supervisor-config.js +735 -0
  30. package/lib/supervisor-install.js +557 -0
  31. package/lib/supervisor.js +1276 -0
  32. package/lib/watchdog-arbiter.js +117 -0
  33. package/lib/watchdog-schedule.js +310 -0
  34. package/lib/watchdog-tier.js +182 -0
  35. package/lib/watchdog.js +479 -0
  36. package/locale/en.json +274 -64
  37. package/locale/zh.json +274 -64
  38. package/package.json +7 -2
  39. package/screenshots.json +6 -4
  40. package/scripts/README.md +65 -0
  41. package/scripts/drill-rescue.ps1 +508 -0
  42. package/scripts/emergency-disable.cmd +27 -0
  43. package/scripts/emergency-toggle.ps1 +373 -0
  44. package/scripts/restore-profile.cmd +44 -0
  45. package/scripts/restore-profile.ps1 +608 -0
  46. package/scripts/run-tests.ps1 +46 -0
  47. package/supervisor/supervisor.js +884 -0
package/cordis.patch.yml CHANGED
@@ -60,9 +60,52 @@
60
60
  backupIncludePlugins: true
61
61
  # Keep at most this many snapshots; older ones are deleted automatically.
62
62
  maxBackups: 2
63
+ # The WATCHDOG: the process that waits for the relaunched host and
64
+ # puts the snapshot back when it never answers. It has to be triggered
65
+ # LATER, and the machine decides how (a one-off Windows scheduled task,
66
+ # a systemd user timer, or a detached process of our own). Set this to
67
+ # false to switch the automatic rollback off entirely; the settings
68
+ # page then says that there is no automatic rollback.
69
+ watchdogEnabled: true
63
70
  # How long the detached watcher waits for the relaunched host (ms) before
64
- # it restores the snapshot and launches the host again.
71
+ # it restores the snapshot. The watcher NEVER launches a second copy of
72
+ # the host: it restores the files and writes the report.
65
73
  rollbackTimeoutMs: 90000
74
+ # How long the restart command may run (ms) before the plugin stops
75
+ # waiting for it. A command that starts the host in the foreground never
76
+ # returns — that is not a failure, but it is reported as "still running"
77
+ # instead of "restarted".
78
+ restartCommandTimeoutMs: 30000
79
+ # How long ONE restore step may take (ms): the configuration files, then
80
+ # the installed plugin tree. A restore that does not finish in time is
81
+ # given up and the reason is written into the rollback report.
82
+ rollbackCopyTimeoutMs: 120000
83
+ # The self-starting supervisor: who starts this host.
84
+ # auto = nobody decided yet, installed = the supervisor of this plugin,
85
+ # external = your own way (docker, systemd, your own script),
86
+ # off = no automatic start at all.
87
+ supervisorMode: auto
88
+ # Which form of the self-start record was installed. Empty = the
89
+ # default of this platform (a scheduled task on Windows, a systemd user
90
+ # unit on Linux).
91
+ supervisorKind: ''
92
+ # How long the supervisor waits before it starts the host again after it
93
+ # died. The delay DOUBLES with every failed start.
94
+ supervisorRestartDelayMs: 3000
95
+ # The upper bound of that delay: the supervisor never waits longer.
96
+ supervisorRestartDelayMaxMs: 30000
97
+ # How long the supervisor waits for the host to answer its health route
98
+ # before it restores the snapshot.
99
+ supervisorStartTimeoutMs: 90000
100
+ # How long the supervisor waits between asking the host to stop and
101
+ # killing it.
102
+ supervisorKillGraceMs: 15000
103
+ # The port the supervisor waits for to become free before it starts the
104
+ # host (0 = the port of the current instance).
105
+ supervisorPort: 0
106
+ # Whether the one-time question may install the supervisor without
107
+ # asking again.
108
+ supervisorAutoInstall: false
66
109
  # 收工关机:Show the graceful-shutdown button at the foot of the left
67
110
  # sidebar, next to the Settings row (root scope: it is there even when
68
111
  # no conversation is open).
package/docs/README.zh.md CHANGED
@@ -12,6 +12,8 @@
12
12
 
13
13
  ## 它解决什么问题
14
14
 
15
+ **0.7.0 新增**:它会先认出自己是怎么被启动的——桌面客户端、别人的守护(脚本 / systemd / 容器 / pm2 / Windows 计划任务),还是没人管的裸跑。**有人管就只优雅退出,认不出就什么都不做**,绝不在运行中的 DSH 旁边再开一个。设置页能看到「现在备份了什么」(份数、本地时间、大小、路径),还有一个「报告问题」入口:一键复制诊断信息并打开预填好的 GitHub 问题页(诊断信息不含访问令牌、不含密钥)。
16
+
15
17
  - DeepSeek Harness 在装插件、升级、崩溃、或关掉重开时,正在跑的任务会被打断。这个插件把"重启"变成一件可恢复的事,不再需要你事后补一句"继续":
16
18
  - 等所有对话都空闲了才重启:不会在你或别的会话正在跑的时候把应用关掉;
17
19
  - 重启后自动接着干:被打断的会话自动恢复,目标(goal)会重新武装继续跑;
@@ -30,6 +32,80 @@
30
32
  | Agent 有一个持久 goal(`create_goal`)跨重启 | goal 的 activation 是进程内的,重启后变回 `disarmed`,自动续跑停止 | 启动时重新 `goals.resume()`,goal round driver 立刻开始下一轮 |
31
33
  | Agent 想装/升级插件但"重启必打断" | 只能等或赌 | 调 `continuum_restart`:等所有 agent idle(当前这一轮跑完)再重启,回来自己接着做 |
32
34
 
35
+ ## 三分钟看懂(普通用户读这一节就够)
36
+
37
+ ### 重启为什么不会丢活
38
+
39
+ 插件把"重启"做成一件**可恢复**的事,顺序是固定的:
40
+
41
+ 1. **先把会话状态落盘** —— 每个会话在做什么、哪些 goal 还带着,都先写进磁盘上的检查点;
42
+ 2. **排队等所有会话空闲** —— 谁还在跑就等谁,等齐了才动手;
43
+ 3. **换新进程** —— 老进程退出,新进程起来(桌面版由应用自己拉起自己;Web 托管端见下一节);
44
+ 4. **新进程自检** —— 起来之后核对四项:端口通了没、路由齐了没、插件加载了没、会话恢复完没;
45
+ 5. **没起来就回滚** —— 自检没过,就把重启前的快照(配置 + 已装插件)放回去,并且把这次重启的结论如实写在状态里。
46
+
47
+ 所以"重启丢活"变成了"重启后接着干":你不用再补一句"继续"。
48
+
49
+ ### 两种运行形态,各管各的
50
+
51
+ | | 桌面客户端 | Web 托管端(`dsh web`) |
52
+ |---|---|---|
53
+ | 谁负责把它拉起来 | **应用自己**(自己关自己、自己开自己) | 先看"是谁拉起的" |
54
+ | 要不要装系统服务 | **不要**。装了反而会双开:登录时再多开一个 DSH | 只在"没人管它"的时候才可能装 |
55
+ | 已经有人管着它(守护脚本 / systemd / 容器 / pm2 / Windows 计划任务…) | 不适用 | **只优雅退出**,交给那个守护去拉起它(不自己再起一个) |
56
+ | 认不出是谁拉起的 | 不适用 | **什么都不做,也不猜命令** —— 宁可说"我认不出",也不在第一个旁边再开一个 |
57
+
58
+ ### 开机自启:插件只给建议,不替你改系统
59
+
60
+ 插件**不会**改你的系统配置、**不会**替你注册任何开机项。它只在设置页把"这台机器上怎么做开机自启"讲清楚,要用的命令可以直接复制,要不要用由你决定。
61
+
62
+ | 你的情况 | 推荐做法 | 注意 |
63
+ |---|---|---|
64
+ | Windows 上的 `dsh web` | 登录时自启:优先用"计划任务"(不需要管理员);这台机器不允许的话,就用"启动文件夹"里的一个 .cmd | 记录里跑的是**真正的 node + 守护脚本**,不是 DSH 客户端本体(否则每次登录会再开一个 DSH) |
65
+ | Linux(不是 WSL) | systemd 用户服务 | 这是**登录后自启**;要**开机自启**(不必先登录)得另外开一次 linger |
66
+ | WSL 里有 systemd | 同上:systemd 用户服务 + linger | 同上 |
67
+ | WSL 里没有 systemd | 这里没有"登录时自启"这回事。两条路:① 在 WSL 内部开启 systemd(改发行版的配置 + 重启子系统);② 让 Windows 侧用计划任务/启动文件夹把 WSL 拉起来(更接近真正的开机自启) | 插件**不会**替你改发行版配置,也不会在 WSL 里推荐容器编排那套做法(WSL 的"编排器"是 Windows 本身) |
68
+ | 容器 / k8s | 由编排器负责(compose 的 `restart: always` 那一类) | 容器里没有"登录时自启" |
69
+
70
+ **"登录后自启"和"开机自启"不是一回事**:前者等你登录了才生效,后者是机器一开就生效(要额外开一次 linger)。插件把这句话和对应的命令一起写在设置页里。
71
+
72
+ ### DSH 起不来了
73
+
74
+ 插件目录的 `scripts/` 里有两个脚本,分工不一样(**Windows 用 `.cmd`,Linux/WSL 用 `.sh`**):
75
+
76
+ - **`restore-profile.cmd` / `restore-profile.sh` —— 回到上一次好状态**:把最新的一份带 manifest 的快照放回 profile;坏掉的那份**挪走而不是删掉**,留成 `profile.broken-<时间戳>`。它**不会替你重启 DSH**,重新打开 DSH 是你自己的事。
77
+ - **`emergency-disable.cmd` / `emergency-disable.sh` —— 只把这个插件摘掉**:其余一切原样不动(只从 bundles 里拿掉这一个名字,并留一份文件备份)。
78
+
79
+ **怎么选**:「想整个回到上一个好状态」用前一个;「只怀疑这个插件」用后一个。边界(目标 profile 必须已存在、必须有带 manifest 的快照、不会自动重启、`broken-` 目录会留着)写在 [`scripts/README.md`](../scripts/README.md) 里。每个平台到底有什么、没有什么,看 [平台能力矩阵](platform-matrix.md)。
80
+
81
+ ### 实测状态表(哪些真机验过,哪些只有单元测试)
82
+
83
+ | 启动方式 | 真机验过? | 依据 |
84
+ | --- | --- | --- |
85
+ | 由 shell 守护脚本拉起(`supervise-*.sh` 之类) | **是** | 真机识别为 external,识别依据指到那个脚本进程,并且**拒绝再装第二个守护** |
86
+ | 裸跑:手起(前台) | **部分** | 真机确认它把自己认成"没人管"(mode `bare`,依据是父进程是 bash)并给出了安装计划;**真正的"装 → 跑 → 卸"是在"已有守护"的实例上真机跑通的**,裸跑实例上没做完整装卸 |
87
+ | 裸跑:后台(`nohup` / `setsid`) | **部分** | 真机确认能被认成裸跑(后台启动);**"重拉命令脱离原 shell"这一条只有单元测试**(当时机器上返回的并不是脱离标记) |
88
+ | WSL(有 systemd) | **是** | 真装真卸 |
89
+ | Windows 登录任务 | **部分** | 真机用系统原生方式建 → 查(State=Ready、登录触发器)→ 删(无残留)成功,证明确实不需要管理员;**"被拒后降级到启动文件夹"只有单元测试** |
90
+ | 桌面客户端 | **是** | 真机确认能自己重启回来(有快照与自检记录、没有触发回滚、不会双开);这是客户端自己的事,不在托管端的矩阵里 |
91
+ | pm2 / forever | 否,只有单元测试 | 识别与行为由单元测试钉住 |
92
+ | 容器 / compose | 否,只有单元测试 | 同上 |
93
+ | `npx` / `pnpm dlx` 包装 | 否,只有单元测试 | 同上 |
94
+ | 由别的 DSH 插件拉起 | 否,只有单元测试 | 同上 |
95
+ | tmux / screen / SSH 会话 | 否,只有单元测试 | 同上 |
96
+ | WSL 由 Windows 侧拉起 | 否,只有单元测试 | 同上(认成"认不出",什么都不做) |
97
+
98
+ **未实测 ≠ 不支持**:这些路径的行为由单元测试钉住;「没做完整装卸」「只有单元测试」都不等于不支持。若你的环境被认错,请把状态页里的「启动方式 + 识别依据」贴给我们。
99
+
100
+ ### 报告问题
101
+
102
+ 设置页最下面有一组「**报告问题**」:
103
+
104
+ - 主按钮「**复制诊断信息并去 GitHub 提问题**」:点一下,诊断信息进剪贴板,同时打开一个**已经填好标题和提纲**的新 issue 页 —— 把剪贴板里的诊断信息粘贴进去就行。
105
+ - 诊断信息里**没有**你的访问令牌,也**没有**任何密钥:它只有插件版本、平台、是不是桌面客户端、启动方式(连同判断依据)、监管器、重启模式,以及上一次重启的结果(失败时还有失败的那几项自检)。
106
+ - 浏览器不让你用剪贴板时(不是安全上下文、或没给权限),插件会把诊断信息显示在一个**可以全选**的文本框里,并提示你手动复制 —— 报告不会因为浏览器说"不"就没了。
107
+ - 没有 GitHub 账号?同一组里有一行小字给出邮箱(252170229@qq.com),外加一个「复制诊断信息」按钮。
108
+
33
109
  ## 适合谁 / 不适合谁
34
110
 
35
111
  适合:经常因为装插件、升级、应用崩溃而丢掉正在进行的长任务,希望重启后自动接续的人。
@@ -61,16 +137,131 @@ session resume on boot, durable-goal re-arm, and an agent-callable idle-aware gr
61
137
  | 系统提示词 | 一段「重启政策」说明,告诉 agent 怎么请求重启、插件会做什么 |
62
138
  | 宿主事件 | `agent/status`(跟踪哪些会话在忙)、`tools/pre-execute`(拦下"手动杀/重启 DSH"的 shell 命令)、启动时的恢复流程 |
63
139
  | profile 行 | 一条 bundle 行:`dsh-autorestart` |
64
- | 写文件 | 只在 `<DSH_HOME>/storages/dsh-autorestart/` 下写:`state.json`(检查点)、`backups/`(重启前快照)、`restart-<代次>.cmd` / `restart-<代次>-watch.ps1`(脱离进程的重启 helper)、`last-rollback.json`(回退报告)。唯一会写进 profile 的时机是**回退**:把快照里的 `cordis.patch.yml`、`package.json`、`pnpm-lock.yaml` 和 `node_modules` 拷回去 |
140
+ | 写文件 | 只在 `<DSH_HOME>/storages/dsh-autorestart/` 下写:`state.json`(检查点)、`backups/`(重启前快照)、`restart-<代次>.cmd` / `restart-<代次>-watch.ps1`(脱离进程的重启 helper)、`last-rollback.json`(回退报告)、`restart-outcome.json`(插件自己判定的那次重启的结论)、`access.json`(当前实例的带 token 访问链接,权限 `0600`)。唯一会写进 profile 的时机是**回退**:把快照里的 `cordis.patch.yml`、`package.json`、`pnpm-lock.yaml` 和 `node_modules` 拷回去 |
65
141
 
66
142
  ## 重启前备份与自动回退
67
143
 
68
144
  - **每次重启前**先给 profile 拍快照:`cordis.patch.yml`、`package.json`、`pnpm-lock.yaml`,以及(默认开启)已安装插件目录 `node_modules`。本机实测:**2.76 秒 / 117.6 MB / 5,562 个文件**。
69
145
  - 快照放在 `storages/dsh-autorestart/backups/<时间戳>/`,带 `manifest.json`;**只保留最新 2 份**,写新快照时自动删旧的。
70
- - 重启后 helper 会跑一个隐藏的看护脚本,轮询健康端点 **90 秒**(`rollbackTimeoutMs`):正常回答就静默退出,**什么都不动**。
71
- - 宿主**一直不回应**时:把快照拷回去 → 写 `last-rollback.json`(含"本次重启之后"的崩溃日志尾部,旧日志不会被误当证据)→ **自动再拉起一次**。
72
- - 下次启动时 `continuum_status` 会报 `lastRollback`(时间、等了多久、是否回滚、快照路径、崩溃日志名与尾部摘要)。
73
- - 开关:`backupOnRestart`(默认 `true`)、`backupIncludePlugins`(默认 `true`,关掉就只备配置文件)、`maxBackups`(默认 `2`)、`rollbackTimeoutMs`(默认 `90000`)。
146
+ - 看护脚本跑在**宿主进程之外**(所以宿主被杀它也活着),等**新实例**最多 **90 秒**(`rollbackTimeoutMs`):新实例把自己的端口和 boot id 写进 `access.json`,看护脚本据此找到**新实例**的健康端点(桌面版每次 `--port 0` 端口随机,旧端口没有意义)。正常回答就静默退出,**什么都不动**。
147
+ - 宿主**一直不回应**时:把快照拷回去 → 写 `last-rollback.json`(含"本次重启之后"的崩溃日志尾部,旧日志不会被误当证据)→ **不会再拉起第二个宿主**(已经起来的那一个由它自己继续,回退分支绝不 `Start-Process` 第二个 app)。
148
+ - 拷回本身也有**时间上限**(`rollbackCopyTimeoutMs`,默认 120 秒/步):拷不完就放弃,并把原因写进报告。
149
+ - **托管模式(`restartMode: command`)也有同一张安全网**:重启命令通常会把插件自己所在的进程一起杀掉,所以看护脚本在命令**之前**就启动了,由它来确认新实例有没有起来。命令起不来或以非零码退出时,插件**如实报错**(带退出码和输出)并把快照拷回去,绝不静默当成功。
150
+ - 下次启动时 `continuum_status` 会报 `lastRollback`(时间、等了多久、是否回滚、**失败原因**、快照路径、崩溃日志名与尾部摘要)。
151
+ - 开关:`backupOnRestart`(默认 `true`)、`backupIncludePlugins`(默认 `true`,关掉就只备配置文件)、`maxBackups`(默认 `2`)、`rollbackTimeoutMs`(默认 `90000`)、`restartCommandTimeoutMs`(默认 `30000`)、`rollbackCopyTimeoutMs`(默认 `120000`)。
152
+
153
+ ### 这台电脑不让建计划任务时(自动回滚就没了)
154
+
155
+ 上面那张安全网挂在一个**一次性计划任务**上:把宿主拉回来的看护脚本,是一分钟后才触发的一次性任务。有的机器**建不了**这种任务(公司策略、锁死的账号、没有权限),那么这张网就是**悄悄没有**的。
156
+
157
+ 所以插件**只查一次**(第一次在这台机器上跑的时候),然后在设置页里如实说:
158
+
159
+ - `自动回滚:可用` —— 网在。
160
+ - `自动回滚:**不可用**` —— 并且说清**会丢什么**:*重启后 DSH 起不来时不会自动恢复*。这一行直接指向「出事了怎么办」里的还原脚本,那就是出路。
161
+
162
+ **这里有意不做的事:**
163
+
164
+ - **不做降级链路**:没有「那改用启动文件夹」「那改用 systemd timer」这类备用机制,也没有"第一个不行就试第二个"。
165
+ - **不绕过策略**:插件不会去找"别的办法绕过去"。机器说不行,就是不行,答案只有"不可用"这一句。
166
+
167
+ **检测失败绝不等于安装失败**:插件照常工作,缺的只是那张安全网。检测本身是无害的:注册一个两分钟后触发、什么都不干的任务(`cmd /c exit 0`),回读校验,删掉,再回读一次确认它真的没了。它**只跑一次**(不是每次打开设置页都跑),而且**不留任何任务**(哪怕中间某一步失败也会清理干净)。
168
+
169
+ ### 重启后的访问链接(token 是一次性的)
170
+
171
+ `dsh web` 的 boot token **每个进程都不一样**,重启后浏览器手里那条旧链接会 401,用户就被挡在门外。所以插件把当前实例的访问链接写进 `storages/dsh-autorestart/access.json`(权限 `0600`,里面是密钥),并在 `continuum_status` 与日志里报出来;前端发现手里那条链接过期时,会自己跟到新链接上。
172
+
173
+ ### 看门狗:三种触发方式
174
+
175
+ 上面的回滚需要有人**等**:宿主被重启了,得有个一次性的东西发现新宿主一直没起来。那个东西就是**看门狗**(`scripts/watchdog.mjs`)。而"怎么让它稍后跑起来"是各台机器最不一样的地方,所以插件挑这台机器有的最好方式,并且**说清楚挑的是哪一种**:
176
+
177
+ | 档 | 机制 | 用在哪 |
178
+ |---|---|---|
179
+ | `scheduled-task` | 一次性 Windows 计划任务(建完**回读校验**,之后**完全删掉**) | 桌面客户端、Windows 上的 `dsh web` |
180
+ | `systemd-run` | systemd 用户定时器(`systemd-run --user --on-active=…`),结束后 `reset-failed` 清掉 | Linux 上的 `dsh web`、有 systemd 的 WSL |
181
+ | `detached-process` | 自建后台进程(`setsid nohup sh -c 'sleep …; …'`),pid 写在 stateDir 里 | 容器、没有 systemd 的 WSL |
182
+ | `none` | 这台机器上什么都没有 | 那就**没有自动回滚**,设置页会明说 |
183
+
184
+ **由一个函数决定**(`lib/watchdog-tier.js`),设置页、diagnostics 和看门狗自己问的都是这同一个函数 —— 所以"这台机器没有自动回滚"这句警告**不可能**和插件实际做的事对不上。
185
+
186
+ 三档跑的是**同一套**流程(`lib/watchdog.js`):等新宿主写下 ready 标记 → **再问一次**(起得慢不等于死了)→ 有实例活着吗(活着就没什么好还原的)→ 抢共用的锁(`rollback.lock`,保证只有一个在还原)→ 还原快照 → **再拉起宿主** → 再等一次 ready。最多**一次**还原 + **一次**拉起;然后看门狗停下,把结果写进 `<stateDir>/last-watchdog-report.json`(用的是哪一档、为什么)。
187
+
188
+ **同一时刻只有一个监护者。** 插件里还有一版旧的看门狗(只还原快照、**不拉起**宿主)。两者抢同一把锁,同时开反而更糟:抢不到锁的那个会放弃 —— 要是新版抢输了,结果就是**配置还原了,但没有任何东西在跑**。所以插件只在一个地方做这个决定(`lib/watchdog-arbiter.js`):**新版能用就是新版接管**(v2),新版用不了(这台机器没有档位、看门狗被关掉、或者注册失败)才**回落旧版**(v1)。报告里写着当时是谁在监护(`arbiter: "v2"` 或 `"v1"`),一眼就能看出来。
189
+
190
+ `watchdogEnabled: false` 可以整个关掉它。
191
+
192
+ ### 开机自启:插件自带的守护
193
+
194
+ 手动启动的托管实例(`dsh web`)挂掉之后不会自己回来——没人拉起来的重启,就等于要用户自己再启动一次。插件可以装一个**守护**:一个独立的小进程,负责拉起宿主、看住它、它掉了再拉起。
195
+
196
+ - **它是独立进程**:插件活在宿主里,宿主一死插件也没了,所以插件不可能是"把宿主拉回来"的那个东西。守护是随包发布的零依赖 Node 脚本(`supervisor/supervisor.js`),安装时被复制到 `storages/dsh-autorestart/supervisor/`。
197
+ - **它做什么**:用 `supervisor.json` 里的命令拉起宿主 → 等它退出 → 退避(1 秒起,每次翻倍,30 秒封顶;**只有真正起来过一次才清零**)→ 再拉起。宿主完全起不来时,把上次重启的快照恢复回去。
198
+ - **怎么登记自启(都不需要管理员)**:
199
+ - **Windows**:计划任务 `dsh-autorestart-web`(`/SC ONLOGON /RL LIMITED`)。若该机策略不允许普通用户建计划任务,会自动降级到「启动文件夹」里的一个 `.cmd`(同样零权限、同样是"登录时")。
200
+ - **Linux / WSL**:systemd **用户**单元(`~/.config/systemd/user/dsh-autorestart-web.service`)。没有系统服务管理器的机器(容器、没开 systemd 的 WSL)根本没有"开机自启"这回事——插件会明说,并把启动命令交给用户。
201
+ - **每个平台各有一份「开机自启怎么做」的引导**(设置页会显示,连带可复制的命令):
202
+
203
+ | 托管平台 | 插件怎么做 |
204
+ |---|---|
205
+ | **Windows** | 计划任务(`/SC ONLOGON /RL LIMITED`,不要管理员);这台机器拒绝时降级为「启动文件夹」里的 `.cmd`。注册的命令是**真 node** + `supervisor.js`,绝不是 DSH 客户端本体。 |
206
+ | **Linux(非 WSL)** | systemd **用户**服务;并提示 `loginctl enable-linger <user>`——**不开 linger 就只是"登录后自启",不是"开机自启"**。 |
207
+ | **WSL(有 systemd)** | 同样的用户单元 + linger 提示。 |
208
+ | **WSL(无 systemd)** | 这里根本没有"登录时自启":两条路 —— ① 在 WSL 内部开启 systemd(`/etc/wsl.conf` → `[boot] systemd=true` + `wsl --shutdown`;**这是系统配置改动**);② 在 Windows 侧用计划任务/启动文件夹拉起 WSL(`wsl.exe -d <distro> -e <命令>`;**需要 Windows 侧操作,但更接近真正的开机自启**)。WSL 里**永不**建议容器编排那套做法 —— WSL 的编排器是 Windows 本身。 |
209
+ | **容器 / k8s** | 不承诺开机自启:由编排器负责(compose 的 `restart: always`)。 |
210
+ | **认不出** | 不装任何东西、不猜命令:说明原因,让用户自己选。 |
211
+
212
+ 只**猜成** WSL(仅凭挂载的 Windows 盘)时,插件**什么都不替你做**:只提示"没把握,建议手动选一条"。
213
+ - **装多少次都只有一个**:任务/单元名是固定的,计划任务用 `/F` 覆盖,守护自己还持一把独占锁——两个守护不可能并存。
214
+ - **装到一半失败不会留残骸**:某一步失败时,已经写下的 unit(或计划任务)会被撤掉,回滚过程也报在步骤列表里——半装的守护会在下次登录拉起一个过期的。
215
+ - **命令名不会被拼两次**:一步里程序名放在 `file`、参数放在 `args`,所以跑的是 `systemctl --user enable --now …`,而不是 `systemctl systemctl …`(那会让 systemd 回一句 "Unknown command verb systemctl")。
216
+ - **随时可以还原**:安装前先把原来的 `restartMode` / `restartCommand` 存下来,卸载时写回去。设置页有「开机自启 → 卸载」一个按钮,手动收尾的命令也印在卡片和日志里;卸载哪一步没做完,会把待办**写下来**(`UNINSTALL-PENDING.json`,放在守护目录**旁边**的 `<stateDir>/` 下,所以写它不会把守护目录又拽回来),而且**只有真的还有残留时**才写。
217
+ - **第一次会问一次**:托管实例第一次出现时问一句"要不要装守护"。卡片写清**会改什么 / 需要什么权限 / 怎么卸**。选「保持我现在的启动方式」什么都不改——插件只记录"你已经自己有守护了"(于是永远不会装第二个)。
218
+
219
+ 桌面版**不问**:它自己就能把自己拉起来(`restartMode: app`),问了是噪声。桌面版**也装不了**守护:登录自启记录会去跑 Electron 客户端本体,那等于**每次登录再开一个 DSH**(双开)。所以桌面版上 `plan` / `install` 直接回 `ok: false`、`error: "desktop-manages-itself"`、步骤列表为空;设置页显示 `mode: "app"`(`restartCapable: true`),不会再吓唬用户"重启能力不可用"。
220
+
221
+ 守护的启动命令**必须指向一个真正的 Node**:进程本身就是 node 时用它,否则去安装目录里找 `resources/runtime/primary-runtime/dependencies/node/bin/node.exe`,再退到 PATH 里的 `node`。**绝不拿应用 exe 冒充 node**;一个都找不到就拒绝安装并说清原因。
222
+
223
+ ### 这个宿主是怎么被拉起来的(启动方式矩阵)
224
+
225
+ Web 端的启动方式有十几种,插件必须**每一种都适配对**。分类器是一个**纯函数**(`lib/startup-mode.js`):输入是父进程链 / 环境标记 / 容器标记 / 平台,输出是 `{mode, kind, evidence, selfReplay, canInstallSupervisor, suggestion}`。
226
+
227
+ | 启动方式 | `mode` | 插件怎么做 |
228
+ |---|---|---|
229
+ | 裸终端 `dsh web`(前台) | `bare` | **可以**装自家守护;不装则给出推导命令 + 确认 |
230
+ | `nohup … &` / `setsid` 后台 | `bare` | 同上,但重拉命令**脱离shell**(`setsid nohup …`),不随启动它的 shell 退出而死 |
231
+ | shell 守护脚本(`supervise-*.sh`) | `external` | 只优雅退出(`selfReplay: refused`)——脚本会把它拉回来 |
232
+ | systemd(user / system 单元) | `external` | 只优雅退出(依据:`INVOCATION_ID` 或父进程链) |
233
+ | pm2 / forever | `external` | 只优雅退出 |
234
+ | docker / compose / k8s | `container` | **不承诺开机自启**:给 compose `restart: always` 片段;容器里不装 systemd |
235
+ | Windows 计划任务 / NSSM 服务 | `external` | 只优雅退出 |
236
+ | WSL 里由 Windows 侧拉起 | `unknown` | **什么都不写**:父进程在 WSL 内看不见——建议选「裸终端装自家守护」,或者自己写一条启动命令(可选) |
237
+ | `npx` / `pnpm dlx` 包装 | `bare`(可用)或 `unknown` | `argv[1]` 是解析后的脚本路径就能用;否则拒绝,绝不猜 |
238
+ | 别的 DSH 插件拉起 | `external` | 只优雅退出——**绝不双开** |
239
+ | tmux / screen / SSH 会话 | `bare` | 走「命令重放」分支,且命令**脱离会话**(优雅退出会断开会话) |
240
+
241
+ **比表格更重要的两条规矩**:只有 `bare` 的宿主才允许写自重放命令、才允许装自家守护;`unknown` 一律 `selfReplay: 'unknown'` 并且**什么都不装**——插件宁可说"我认不出",也不在第一个宿主旁边再开一个。
242
+
243
+ 设置页会把判定和**依据**一起显示出来(「当前由 … — ancestor:1234: systemd」),让人看得懂插件为什么这么说。
244
+
245
+ ### 「保持我现在的启动方式」之后:推导重启命令(E)与干跑确认(B)
246
+
247
+ 用户选择保持现状时,插件仍然可以写下一条"能把它拉起来"的命令——但**只能靠推导,绝不猜**。猜错的命令比没有命令更糟:它看起来像重启成功了,实际留下一个起不来的宿主。
248
+
249
+ 候选命令来自进程自己知道的事实:`[process.execPath, ...process.execArgv, ...process.argv.slice(1)]` 加上工作目录。**优先级**决定接下来怎么办:
250
+
251
+ | 级 | 情形 | 写什么 |
252
+ |---|---|---|
253
+ | **E0** | 是插件自己的守护把这个实例拉起来的 | **什么都不写**——重启直接问守护(插件先写回滚请求,让守护去杀进程、重新拉起)。 |
254
+ | **E1** | 有**外部**监管器(systemd / 用户自己的脚本 / pm2) | **不写**自重放命令:`restartCommand` 变成**优雅退出**(`kill -TERM <pid>` / `taskkill /PID <pid>`),因为是那个监管器负责再拉起我们。装第二个守护会抢同一个端口。 |
255
+ | **E2** | 没有守护、也没有 init(容器) | 给出命令 + 一段 compose 片段(`restart: always`),**不承诺**开机自启。 |
256
+ | **E3** | 没有守护、有 init(Windows / systemd 可用) | 推导出的命令——但**要用户确认干跑之后**才写。 |
257
+
258
+ **拒绝**的情形(每种都有代码,也都指出路):桌面版(它自己会重启)、没有 web 服务端的宿主、读不回命令行、已有外部监管器、没有 init 的容器、以及**端口每次都变**(浏览器的 cookie 会跟着 authority 一起死——端口必须固定)。
259
+
260
+ **干跑(B)是一个承诺**:命令、工作目录、端口、健康检查 URL 在**写任何东西之前**原样展示。只有明确的确认才会把命令写进 profile 配置;写之前的原值被记下来,设置页一键就能改回去。
261
+
262
+ - 干跑:`POST /__dsh_autorestart/supervisor` 带 `{ "action": "dry-run" }`(或工具 `continuum_supervisor` 的 `action: "dry-run"`)——**什么都不写**。
263
+ - 确认:`{ "action": "apply", "confirmed": true }`——没有 `confirmed` 宿主回 `400 confirmation-required`,同样什么都不写。
264
+ - 撤销:`{ "action": "revert", "confirmed": true }`——把原来的 `restartMode` / `restartCommand` 写回去。
74
265
 
75
266
  ### 首次安装只问一次:备份要不要连已装插件一起备
76
267
 
@@ -137,7 +328,7 @@ agent/status(running) ──► 写 checkpoint(<DSH_HOME>\storages\dsh-autores
137
328
 
138
329
  ## 安装
139
330
 
140
- 当前版本:**0.6.2**。
331
+ 当前版本:**0.8.0**。
141
332
 
142
333
  ```powershell
143
334
  # 在 dsh 里(或对 Agent 说):把工作区里的这个包安装为 profile bundle
@@ -148,8 +339,34 @@ dsh plugin --profile desktop add C:\path\to\dsh-autorestart
148
339
 
149
340
  安装后行 id 为 `dsh-autorestart`。本机已经装好并处于 `active`。
150
341
 
342
+ ## 起不来怎么办(装了插件之后 DSH 打不开)
343
+
344
+ **先分清是哪种情况**:整个环境坏了(改了配置、装了别的插件、升级崩了)→ 先回到上一次好状态;**只有这个插件可疑** → 直接走下面三步,把这个插件摘掉就够了。(两个脚本各自的边界见 [`scripts/README.md`](../scripts/README.md)。)
345
+
346
+ **用你自己平台的脚本** —— Linux 机器上没有 `.cmd`,Windows 机器上也没有 `.sh`:
347
+
348
+ | 你的机器 | 回到上一次好状态 | 摘掉本插件 |
349
+ |---|---|---|
350
+ | **Windows**(桌面客户端、Windows 上的 `dsh web`) | 双击 `scripts\restore-profile.cmd` | 双击 `scripts\emergency-disable.cmd` |
351
+ | **Linux / WSL / macOS**(托管端) | `sh scripts/restore-profile.sh` | `sh scripts/emergency-disable.sh` |
352
+
353
+ 每个平台到底有什么、没有什么,看 [平台能力矩阵](platform-matrix.md);设置页的「出事了怎么办」只显示**你这台机器上真有的**那两个路径。
354
+
355
+ **不用等 DSH 跑起来**就能把这个插件摘掉 —— 插件目录里带了一个一键开关:
356
+
357
+ 1. **Windows**:双击 `scripts\emergency-disable.cmd`;**Linux/WSL**:`sh scripts/emergency-disable.sh` —— 它会写清楚做了什么;
358
+ 2. **重启 DSH**(关掉再打开)——这次没有这个插件,DSH 就起得来;
359
+ 3. 以后想再试它:Windows 用 `powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\emergency-toggle.ps1 -Action enable`,Linux/WSL 用 `sh scripts/emergency-disable.sh --action enable`,然后再重启 DSH。
360
+
361
+ 它只动**一个文件**里**一个数组**:profile 的 `package.json` 里的 `dsh.profile.bundles`(就是「插件启用列表」那几行)。写之前先备份成 `package.json.bak-emergency-<时间戳>`,写完重新解析一遍 JSON 校验;校验不过就用备份回滚,并如实报错。**重复运行没有副作用**:本来就没启用时只会告诉你「已经禁用」,不会失败。
362
+
151
363
  ## 配置
152
364
 
365
+ 设置页里关于「备份」和「启动方式」的两块,先说清楚它们看的是什么:
366
+
367
+ - **现在备份了什么**(在两个备份勾选的下面):宿主真的去读 `<DSH_HOME>/storages/dsh-autorestart/backups/`,所以份数、每份的**本地时间**、大小(MB)、有没有含已装插件、以及存放路径都是真的。只有目录元信息被读,备份里的内容**不遍历**(一份就可能 150 MB)。没有备份时写的是「还没有备份;每次重启前会自动生成一份」。
368
+ - **启动方式**(Web 托管端才有):默认只给**一行大白话**(例如「我记下的启动方式:由你的脚本拉起(重启交给它,我不插手)」)加两个小动作(重新检测 / 撤销);命令原文收在**「技术细节」**折叠里,默认收起,展开才出现。**桌面客户端这一整块不渲染** —— 它自己拉起自己,也不该写 `restartCommand`。宿主说不清是哪一端时,宁可整块不显示。
369
+
153
370
  所有键都有默认值,写在 `cordis.patch.yml` 的 insert 行里;要覆盖就在 profile 的
154
371
  `~/.dsh/profiles/<profile>/cordis.patch.yml` 里加同名 `- id: dsh-autorestart` 行(**整段替换 config**)。
155
372
  插件同时导出真实的 `Config` schema(schemastery),所以 Plugin Manager / 配置编辑器里能看到键名、类型与默认值
@@ -181,7 +398,9 @@ dsh plugin --profile desktop add C:\path\to\dsh-autorestart
181
398
  | `backupOnRestart` | `true` | 每次重启前给 profile 拍快照;重启后起不来时能自动回退 |
182
399
  | `backupIncludePlugins` | `true` | 快照里是否带上已安装插件目录(本机约 117 MB、多约 3 秒);关掉就只备配置文件 |
183
400
  | `maxBackups` | `2` | 保留几份快照;写新的时自动删旧的 |
184
- | `rollbackTimeoutMs` | `90000` | 看护脚本等新宿主多久(毫秒),超时就回退快照并再拉起一次 |
401
+ | `rollbackTimeoutMs` | `90000` | 看护脚本等新宿主多久(毫秒),超时就回退快照(**不会再拉起第二个宿主**) |
402
+ | `restartCommandTimeoutMs` | `30000` | 重启命令最多让插件等多久(毫秒)。前台跑着宿主的命令永远不返回——那会报「还在运行」,不算失败 |
403
+ | `rollbackCopyTimeoutMs` | `120000` | 拷回快照的**每一步**最多花多久(毫秒):拷不完就放弃,并把原因写进回退报告 |
185
404
  | `showSleepButton` | `true` | 收工关机的月亮按钮是否显示在左侧栏最底部、设置按钮旁边 |
186
405
  | `sleepCountdownSeconds` | `20` | **关机倒数秒数**:收工关机在真正关机前倒数几秒——**下拉框:0 / 10 / 20 / 30 / 60 秒**,默认 20;`0` 表示**不倒数、立即关机**(仍会先等所有会话收工)。**这段看得见的倒数就是关机前的那段等待** —— 关机没有第二个看不见的预留 |
187
406
  | `sleepCommand` | `''` | 收工关机的自定义关机命令;留空 = 自动识别宿主(Windows 用 `cmd.exe /c …`,其它平台用 `/bin/sh -c …`) |
@@ -258,7 +477,7 @@ restart gate: action=wait reason=agents-busy-within-soft-window busyCount=1 quie
258
477
  | `continuum_recover_now` | **立刻重跑恢复**(不用等下一次重启):重试卡住的会话,返回每个会话的动作 + 原因。参数 `sessionId`(省略 = 所有待恢复会话) |
259
478
  | `continuum_handoff` | **会话接力**:把一个已经超出模型 context window 的会话交给一个**全新的会话**继续(带去 goal 目标、检查点里记录的接手原因和一段有界的对话尾部),旧会话保留成存档、goal 被解除。参数 `sessionId`(省略 = 调用方自己的会话)/ `maxChars`(默认 `20000`) |
260
479
  | `continuum_status` | 只读:phase、generation、是否有待重启、检查点路径、识别的重启环境、被记录的会话、每个阻塞会话的阻塞原因、被停泊的 goal |
261
- | `continuum_cancel_restart` | 取消待重启,并释放该会话的续接请求 |
480
+ | `continuum_cancel_restart` | 撤回待重启请求。默认**只撤调用方自己那一份**(`scope: "mine"`),别的会话排队的重启不受影响;`scope: "all"` 才是"所有人都别重启了"。参数 `scope`(`mine`(默认)/ `all`)/ `reason`(写进日志) |
262
481
 
263
482
  除了工具本身(模型在工具表里能看到名字/描述),插件还有两层保证:
264
483
 
@@ -284,6 +503,46 @@ restart gate: action=wait reason=agents-busy-within-soft-window busyCount=1 quie
284
503
  所以**不需要改 AGENTS.md**——这是 Host 插件钩子(工具注册 + system prompt section + tools.guard),对 profile 内所有 agent 生效;
285
504
  你当然也可以额外在 AGENTS.md 里写一条团队规范,但它只是重复。
286
505
 
506
+ ### 取消重启:只撤自己 vs 全撤
507
+
508
+ 重启请求是**合并**的:好几个会话可以同时请求同一次重启,插件把每个请求者都记在 `restart.requesters` 里,
509
+ 重启成功后**一起**被唤醒。所以"取消"必须说清楚**撤的是谁的请求**:
510
+
511
+ | scope | 会发生什么 |
512
+ | --- | --- |
513
+ | `mine`(默认) | 只撤掉**调用方自己**那一份。别的会话还在等这次重启 → 重启请求**保留**,`restart.bySessionId` 改指向剩下的第一个请求者;调用方是**最后一个**请求者时,重启才真的消失:phase 回到 `idle`,这个会话被释放。 |
514
+ | `all` | 为**所有人**取消:所有请求者都被释放,待重启请求消失。也就是"所有人都别重启了"。 |
515
+
516
+ 有两种调用会被**拒绝**,而且**不改动任何状态**——"顺手全撤"永远不是兜底行为:
517
+
518
+ - 不知道调用方是哪个会话(调用时没有会话)→ 拒绝;
519
+ - 调用方**并没有**请求这次重启(只是在别人的会话名下)→ 拒绝。
520
+
521
+ 工具会回报结果:`cancelled`(重启请求本身是否真的没了)、`remainingRequesters`(还有几个会话在等)、
522
+ `scope`、`phase`。例如只撤自己、别人的请求还在时:
523
+
524
+ ```json
525
+ { "ok": true, "cancelled": false, "scope": "mine", "remainingRequesters": 1, "sessionId": "sess-a", "phase": "deferred" }
526
+ ```
527
+
528
+ ### 小胶囊:点它看详情,也从这儿自己撤掉
529
+
530
+ 输入框那排的小胶囊只有 22px 高、一行宽,所以它**只放数字**(`待重启 · 已等 16 分钟 · 3 项任务在跑`)——
531
+ 不放名字,也不放任何"双击可以取消"之类的提示(没地方放)。**单击**它(或聚焦后按 Enter / 空格)会在上方
532
+ 弹出一个小面板:
533
+
534
+ - 这次重启还在等几个任务结束;
535
+ - 还有几个**别的**会话也在等同一次重启;
536
+ - 一个按钮:「取消我的这次重启」。
537
+
538
+ 按钮按下去弹出的是**确认框**(没有时限,也不倒计时):「取消你的这次重启?」,并说清现在取消意味着什么
539
+ (只有你,还是你退出、别人照样重启)。**只有确认框里的「确认取消」才真的发请求**;按 Esc、点面板外面、
540
+ 或者按「再想想」都只是关掉,什么都不做。
541
+
542
+ 这个请求只撤**本会话自己**那一份(`POST /__dsh_autorestart/cancel`,body 是
543
+ `{ "sessionId": "…", "scope": "mine" }`):别的会话照样重启。面板里**没有**"全撤"——那种操作只留给
544
+ 工具显式调用(`continuum_cancel_restart` 带 `scope: "all"`)。
545
+
287
546
  ## 收工关机(Graceful shutdown)
288
547
 
289
548
  **它干什么。** 左侧栏最底部一个月亮按钮:点一下 → 等**所有**会话都收工 → 屏幕上倒数(一个巨大的数字 + 一只蓝色小鲸鱼,每秒换一句中文)→ **关掉整台电脑**。倒数期间随时可以取消:卡片上有按钮,月亮按钮本身在关机期间也变成了"再点一下 = 取消"。倒数期间要是有会话又开始干活,倒计时会退回"等待",**绝不关机**。
@@ -296,6 +555,31 @@ restart gate: action=wait reason=agents-busy-within-soft-window busyCount=1 quie
296
555
 
297
556
  **点一下就够了。** 月亮按钮按下就武装:宿主等所有忙会话收工,在角落里倒数 **20 秒**(浮动卡片,不遮挡界面),然后关掉整台机器。**再点一下**(或点卡片上的「算了,别关」)就取消。除此之外什么都不用用户做:没有确认框、不用点第二次。
298
557
 
558
+ ### 关机:关掉的是哪台机器?
559
+
560
+ 插件会先看自己跑在哪台机器上,然后关掉**那台机器**;设置页里那行字(`host.label`)写的就是它,不会让人猜:
561
+
562
+ | DSH 跑在哪 | 关机按下去会关掉什么 | 界面上的原话 |
563
+ |---|---|---|
564
+ | Windows 桌面客户端 | 这台 Windows 电脑 | 「关掉这台 Windows 电脑」 |
565
+ | Windows 上的 `dsh web` | 这台 Windows 电脑 | 「关掉这台 Windows 电脑」 |
566
+ | Linux 主机上的 `dsh web` | 那台 Linux 主机 | 「关闭这台 Linux 主机」 |
567
+ | **WSL 里的 `dsh web`** | **整台 Windows 主机**(不是那个 WSL 小环境!) | 「WSL 里的 DSH → 关掉整台 Windows」 |
568
+ | 容器 | 什么都不会(命令是空的) | 「这台机器没有可靠的关机方式」 |
569
+
570
+ > ⚠️ **在 WSL 里看到关机倒数,意味着你的整台机器会关机** —— 不是只停那个 Web 服务。
571
+ > WSL 是 Windows 里的一个 Linux 小环境,关掉它只会停掉那个发行版;要让**主机**关机,
572
+ > 插件走的是挂载盘里的 `shutdown.exe`(`/mnt/c/Windows/System32/shutdown.exe /s /f /t 0`),
573
+ > 备选是 `powershell.exe -Command "Stop-Computer -Force"` 与
574
+ > `cmd.exe /c shutdown.exe /s /f /t 0`。也就是说:**关的是主机**。
575
+
576
+ **验到什么程度**:**计划层**每个平台都验过了(`test/sleep-plan-posix.test.mjs`:WSL 的
577
+ 计划必须走「关整台 Windows」那条分支;纯 Linux 的计划里不许出现任何 Windows 命令;
578
+ 不支持的平台命令为空、**什么都不执行**)。**Windows 真关电已验**;**POSIX 有意没有真关电**
579
+ —— 为一条常识证据把所有人的会话都断掉不划算。
580
+
581
+ 每个平台的完整能力见 [平台能力矩阵](platform-matrix.md)。
582
+
299
583
  ### 关机和重启谁先谁后
300
584
 
301
585
  **一句话版本。** 点月亮按钮的意思是「**等活干完了再关电脑**」,不是「现在就算还有别的在跑也关掉」。所以:
@@ -390,6 +674,10 @@ WSL 的判定依据:`WSL_DISTRO_NAME`、`WSL_INTEROP`、`/proc/version` 里出
390
674
 
391
675
  **给代码看的状态字段。** `GET <healthPath 前缀>/sleep`(默认 `/__dsh_autorestart/sleep`)会回答阶段、倒数和"要关的是哪台机器"。其中几个字段是给**程序**看的、不是给人看的:`host.kind` 是机器类别(`windows` / `wsl` / `linux` / `macos` / `unsupported`;如果平台没认出来、但你配了 `sleepCommand`,则是 `custom`);`host.suspect` 为 `true` 表示这台机器**只凭挂着 Windows 盘**才被认成 WSL(是佐证,不是铁证);`lastRun.dryRun` 表示上一次是**试运行**,什么都没真关;`backupOnRestart` / `backupIncludePlugins` 报告重启前备份的当前范围。
392
676
 
677
+ **上一次重启的记录。** `GET <healthPath>?state=1` 和 `continuum_status` 工具都会报 `lastRestart`:重启前拍的那份快照(`snapshot.dir`、`snapshot.stamp`、`snapshot.atLocal`——**本地时间**,因为目录名是 UTC——、`snapshot.durationMs`、`snapshot.pruned`),以及**起来那个新实例**的判定(`result` 是 `pending` / `ok` / `failed`,外加 `verifiedAt`、`durationMs` 和四条开机检查 `listener` / `routes` / `plugin` / `recovery`)。新实例始终没起来时,记录停在 `pending`——这同样看得见。记录落在 `<stateDir>/last-restart-verdict.json`。
678
+
679
+ **这个宿主是谁拉起来的。** 同一个返回里的 `supervisor` 块说 `mode`(`app` / `installed` / `external` / `unknown` / `off`)、`restartCapable`,以及——因为**第二个守护会抢同一个端口**——`selfReplay`(`allowed` / `refused` / `unknown` / `n/a`);别人在管时还有 `external` 的证据(`kind`、`evidence`、`commandLine`)。Linux 上会读宿主的**父进程链**(`/proc/<pid>/cmdline`),所以用户自己的 `supervise-*.sh` 脚本能被认出来。**认不出人时是 `unknown`,绝不会默默当成"裸跑"**:这时插件拒绝自己重放自己的命令。**桌面版不会是 `unknown`**:它自己会把自己拉起来,所以报 `mode: "app"`、`restartCapable: true`、`selfReplay: "n/a"`。
680
+
393
681
  **关机命令起不来时的退路。** 宿主会等命令的**真实结果**(进程有没有真的起来)。命令起不来(找不到程序、权限不够)时,关机**不会假装成功**:状态回到 `idle`,并给出原因("关机命令没跑起来(…),电脑还开着,可以再点一次月亮按钮。"),电脑保持开机。界面上那个「正在关机」的卡片也**不会把界面锁死**:最多停留 8 秒就会给出一个"关掉这个提示"的按钮。
394
682
 
395
683
  ## 需要 goal 吗?
@@ -579,7 +867,7 @@ lib/lifecycle.js 纯决策:bootPlan / restartDecision / selectGoalDisarms
579
867
  lib/restart.js Windows 脱离重启(WMI 主、schtasks 兜底、dryRun、自定义命令)
580
868
  lib/sleep.js 收工关机:识别真宿主机(含 WSL)、关机命令、等待→倒数→关机的状态机
581
869
  lib/semver.js 版本号比较(公告去重用:只看"记录版本是否低于当前功能版本")
582
- locale/{en,zh}.json Plugin Manager 显示用标题/描述
870
+ locale/{en,zh}.json 界面文案的镜像(与 client.js 里注册给 DSH 的两份字典同键同词;键集由一条用例钉住)
583
871
  icon.svg 插件图标
584
872
  LICENSE MIT 许可(发布到插件市场需要)
585
873
  test/ node:test 用例(状态机 / 检查点 / 重启命令 / goal 解除 / 集成恢复 / recover_now)
@@ -0,0 +1,92 @@
1
+ # 跨环境能力矩阵
2
+
3
+ 这张表回答一个具体的问题:**「我这套环境到底能做什么?」**
4
+
5
+ 插件装在哪台机器上,就只有那台机器的答案 —— 所以每一行能力都按平台分开写,
6
+ 而且每一格都要能在代码里找到对应实现,或者明确写「不支持」。
7
+
8
+ - **Windows 桌面**:DeepSeek Harness 桌面客户端(`restartMode: app`)。
9
+ - **Windows web**:托管在 Windows 上的 `dsh web`。
10
+ - **Linux web**:托管在 Linux 上的 `dsh web`。
11
+ - **WSL web**:托管在 WSL(Windows 里的 Linux)上的 `dsh web`。
12
+ - **容器**:Docker / CI 里的容器。
13
+
14
+ 图例:✅ 能用 | ⚠️ 能用但有条件(**每一格都写清「用户会看到什么」**)| ❌ 不支持(为什么在最后一列)
15
+
16
+ ## 能力矩阵
17
+
18
+ | 能力 | Windows 桌面 | Windows web | Linux web | WSL web | 容器 | 说明(实现在哪) |
19
+ |---|---|---|---|---|---|---|
20
+ | 重启(DSH 自己把自己重启起来) | ✅ | ✅ | ✅ | ✅ | ✅ | `lib/restart.js`:桌面用当前 exe,托管用配置的 `restartCommand` |
21
+ | 会话续接(重启后接着干活) | ✅ | ✅ | ✅ | ✅ | ✅ | `lib/checkpoint.js` + `lib/lifecycle.js`:checkpoint 落在 `<DSH_HOME>/storages/dsh-autorestart/` |
22
+ | 重启前备份(profile 快照) | ✅ | ✅ | ✅ | ✅ | ✅ | `lib/backup.js`:快照目录 `<stateDir>/backups/`,默认留 2 份 |
23
+ | **自动回滚(看门狗)** | ✅ 计划任务 | ✅ 计划任务 | ✅ systemd 定时器 | ✅ systemd(有 systemd 时)/ ⚠️ 自建后台进程:没有 systemd 时,用户在设置页会看到「看门狗的触发方式:自建后台进程」 | ⚠️ 自建后台进程:用户会看到「看门狗的触发方式:自建后台进程」(容器里没有 systemd 定时器可依赖) | `lib/watchdog-tier.js` 选档,`lib/watchdog-schedule.js` 注册,`lib/watchdog.js` 跑流程;**同一时刻只有一个监护者**(`lib/watchdog-arbiter.js`:v2 能用就是 v2,v2 用不了才回落 v1) |
24
+ | 自动回滚的**能力检测**(能不能建任务) | ✅ | ✅ | ✅ | ✅ | ✅ | `lib/scheduler-capability.js`:真注册一次任务再回读、删掉、确认没了 |
25
+ | 自动回滚被拒时的降级链路 | ❌ | ❌ | ❌ | ❌ | ❌ | **有意不做**:被禁就是被禁,只告知(`README.md` 的「当机器不允许创建计划任务时」) |
26
+ | 手动还原(出事了自己救) | ✅ `restore-profile.cmd` | ✅ `restore-profile.cmd` | ✅ `restore-profile.sh` | ✅ `restore-profile.sh` | ✅ `restore-profile.sh` | `scripts/restore-profile.ps1` / `scripts/restore-profile.sh` |
27
+ | 摘掉本插件(急救开关) | ✅ `emergency-disable.cmd` | ✅ `emergency-disable.cmd` | ✅ `emergency-disable.sh` | ✅ `emergency-disable.sh` | ✅ `emergency-disable.sh` | `scripts/emergency-toggle.ps1` / `scripts/emergency-disable.sh` |
28
+ | 设置页「出事了怎么办」 | ✅ | ✅ | ✅ | ✅ | ✅ | 只显示**这台机器上真有的**脚本路径(`lib/rescue-paths.js` 按平台给 `.cmd` / `.sh`) |
29
+ | 桌面快捷方式(双击即救) | ✅ | ✅ | ❌ | ❌ | ❌ | 快捷方式是 Windows 的 `.cmd`;别的平台没有可双击的东西,就不显示这个按钮 |
30
+ | 关机倒数 | ✅ 关这台 Windows | ✅ 关这台 Windows | ✅ 关这台 Linux | ✅ **关掉整台 Windows**(不是关 WSL!) | ⚠️ 容器里没有意义:用户会看到「这台机器没有可靠的关机方式」,命令是空的、什么都不会执行 | 计划层已验证(`test/sleep-plan-posix.test.mjs`);Windows 真关电已验,**POSIX 未真关电**(用户决定不真关:一条常识证据不值得断开所有会话) |
31
+ | 开机自启建议 | ✅ | ✅ | ✅ | ✅ | ⚠️ 容器里没有 systemd 用户会话:用户会看到「这里不能开机自启」的说明,插件只会给建议、不会去改系统 | `lib/autostart-advice.js` + `lib/supervisor-install.js`:Windows 用计划任务或启动文件夹,Linux 用 systemd user unit |
32
+ | 问题反馈(诊断信息) | ✅ | ✅ | ✅ | ✅ | ✅ | `lib/diagnostics.js`;设置页复制,诊断信息里也带 watchdog 档位 |
33
+
34
+ ## 关机:会关掉哪台机器?
35
+
36
+ 这一格必须说清楚 —— 猜错了代价太大。
37
+
38
+ | 你在哪 | 关机倒数按下去会关掉什么 | 界面上的原话(`host.label`) |
39
+ |---|---|---|
40
+ | Windows 桌面 | **这台 Windows 电脑** | 「关掉这台 Windows 电脑」 |
41
+ | Windows 上的 `dsh web` | **这台 Windows 电脑** | 「关掉这台 Windows 电脑」 |
42
+ | Linux 主机上的 `dsh web` | **那台 Linux 主机** | 「关闭这台 Linux 主机」 |
43
+ | **WSL 里的 `dsh web`** | **整台 Windows 主机**(不是那个 WSL 小环境!) | 「WSL 里的 DSH → 关掉整台 Windows」 |
44
+ | 容器 | 什么都没有(命令是空的) | 「这台机器没有可靠的关机方式」 |
45
+
46
+ > ⚠️ **在 WSL 里看到关机倒数,意味着你的整台机器会关机** —— 不是只停那个 Web 服务。
47
+ > WSL 是 Windows 上的一个 Linux 小环境,关掉它只会停掉那个发行版;要让**主机**关机,
48
+ > 插件走的是挂载盘里的 `shutdown.exe`(`/mnt/c/Windows/System32/shutdown.exe /s /f /t 0`),
49
+ > 备选是 `powershell.exe -Command "Stop-Computer -Force"` 和
50
+ > `cmd.exe /c shutdown.exe /s /f /t 0`。也就是说:**关的是主机**。
51
+
52
+ **验到什么程度**:计划层已验证(`test/sleep-plan-posix.test.mjs`:WSL 的计划必须走
53
+ 「关整台 Windows」那条分支、纯 Linux 的计划里不许出现任何 Windows 命令、不支持的
54
+ 平台命令为空且**什么都不执行**)。**Windows 真关电已验**;**POSIX 没有真关电** ——
55
+ 用户决定不真关:为一条常识证据把所有人的会话都断掉不划算。
56
+
57
+ ## 三档调度是怎么选的
58
+
59
+ `lib/watchdog-tier.js` 里的 `pickWatchdogTier()` 是**唯一**决定档位的地方
60
+ (设置页的警告、diagnostics 里的说明、真正注册看门狗,问的都是它):
61
+
62
+ | 档 | 机制 | 什么机器 |
63
+ |---|---|---|
64
+ | `scheduled-task` | 一次性 Windows 计划任务(建完回读校验、用完全删、`IgnoreNew`、`EndBoundary` 必填) | Windows(桌面与 web) |
65
+ | `systemd-run` | `systemd-run --user --on-active=<秒>s --unit=dsh-autorestart-watchdog`,结束后 `systemctl --user reset-failed` | Linux、WSL(有 systemd 时) |
66
+ | `detached-process` | `setsid nohup sh -c 'sleep <秒>; <看门狗>' >/dev/null 2>&1 &`,pid 写进 stateDir 并回读确认还活着 | 容器、WSL(没有 systemd 时) |
67
+ | `none` | 没有可用机制 → **看门狗不注册**,`tier: 'none'`,设置页与 diagnostics 都明说「没有自动回滚」 | 被策略禁用的机器 |
68
+
69
+ ## 手工还原脚本在每个平台上做什么
70
+
71
+ 两个版本是**同一套规则**(`scripts/restore-profile.ps1` 与 `scripts/restore-profile.sh`):
72
+
73
+ 1. 默认取 `<stateDir>/backups/` 里**最新带 manifest** 的快照;
74
+ 2. 还原前把现在的 profile 整份挪到 `<profileDir>.broken-<UTC 时间戳>`(**不删**);
75
+ 3. 还原后回读校验:`package.json` 能解析、`dsh.profile.bundles` 非空、
76
+ **声明了 `dsh-autorestart` 就必须能在 `node_modules` 里读到版本号**;
77
+ 4. 路径闸门:只接受 `<DSH_HOME>/profiles/*` 或显式指定的隔离路径;
78
+ 拒绝 `/`、`/usr`、`/etc`、`$HOME` 本身、别人的家目录;
79
+ 5. **不自动重启 DSH**;`--dry-run`(Windows `-DryRun`)可以先看会发生什么。
80
+
81
+ 中文文案都来自 `scripts/rescue-strings.zh.json`(UTF-8 **无 BOM**);没有 node 的
82
+ 机器上退回英文。
83
+
84
+ ## 怎么自己验一遍
85
+
86
+ | 平台 | 命令 |
87
+ |---|---|
88
+ | Windows | `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/drill-rescue.ps1` |
89
+ | Linux / WSL | `sh scripts/drill-rescue.sh` |
90
+
91
+ 演练在**自己的临时目录**里造假 profile、故意弄坏、还原、断言,然后清理干净 ——
92
+ 它不碰任何正在用的 profile。