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/README.md CHANGED
@@ -13,6 +13,13 @@ Chinese version: [README.zh.md](docs/README.zh.md)
13
13
 
14
14
  `dsh-autorestart` turns a DeepSeek Harness restart into a resumable event instead of a dead end.
15
15
 
16
+ **New in 0.7.0** — it now also knows *how it was started*: the desktop client, somebody else's
17
+ supervisor (a shell script, systemd, a container, pm2, a Windows scheduled task), or a bare process
18
+ nobody manages. It never starts a second DSH next to a running one, and when it cannot tell it does
19
+ nothing instead of guessing a command. The settings page shows what is really inside the
20
+ pre-restart backups (count, local time, size, path), and carries a **Report a problem** entry that
21
+ copies the diagnostics and opens a pre-filled GitHub issue.
22
+
16
23
  An agent can request a graceful restart. The plugin waits until every conversation is idle and the
17
24
  host has been quiet for the settle window, then it restarts the host. Once the host is back, the
18
25
  interrupted sessions resume automatically: a session that owns a durable goal gets that goal
@@ -51,6 +58,121 @@ Two safety gates protect the restart. The host checks that no conversation is bu
51
58
  helper checks the same gate over loopback immediately before it kills the process, so a turn that
52
59
  starts in between is never killed.
53
60
 
61
+ ## The whole thing in one page (ordinary users can stop here)
62
+
63
+ ### Why a restart does not lose your work
64
+
65
+ The plugin turns a restart into a **resumable** event, and the order never changes:
66
+
67
+ 1. **The session state is written to disk first** — what every conversation was doing and which
68
+ goals were still armed goes into a checkpoint on disk;
69
+ 2. **It queues until every conversation is idle** — whoever is still running is waited for;
70
+ 3. **The process is replaced** — the old one exits, a new one comes up (the desktop app relaunches
71
+ itself; the hosted web client is covered below);
72
+ 4. **The new process checks itself** — four things: is the port up, are the routes there, did the
73
+ plugin load, did the sessions resume;
74
+ 5. **If it did not come up, it rolls back** — the pre-restart snapshot (configuration + installed
75
+ plugins) is put back, and the verdict of that restart is written down honestly.
76
+
77
+ So "the restart lost my work" becomes "the restart carried on": you do not have to type "continue".
78
+
79
+ ### Two shapes of DSH, each minding its own business
80
+
81
+ | | Desktop client | Hosted web client (`dsh web`) |
82
+ |---|---|---|
83
+ | Who brings it back | **the app itself** (it closes itself and opens itself again) | it first asks "who started me?" |
84
+ | Should a system service be installed | **no** — installing one makes it start **twice**: one more DSH at every login | only when *nobody* manages it |
85
+ | Somebody else already manages it (a supervisor script / systemd / a container / pm2 / a Windows scheduled task…) | n/a | **it only exits gracefully** and lets that supervisor bring it back (it does not start a second one) |
86
+ | It cannot tell who started it | n/a | **it does nothing and guesses no command** — "I cannot tell" beats starting a second copy next to the first |
87
+
88
+ ### "Start at login": the plugin gives advice, it does not change your system
89
+
90
+ The plugin **never** changes your system configuration and **never** registers a startup entry for
91
+ you. It explains in the settings page how "start at login" works on *this* machine; the commands can
92
+ be copied, and whether you use them is your call.
93
+
94
+ | Your situation | What it recommends | Note |
95
+ |---|---|---|
96
+ | `dsh web` on Windows | start at login: a scheduled task first (no administrator rights); if this machine refuses that, a `.cmd` in the startup folder | the record runs a **real node** + the supervisor script, not the DSH client itself (that would open a second DSH at every login) |
97
+ | Linux (not WSL) | a systemd user service | that is **start after login**; **start at boot** (without a login) needs the linger enabled once |
98
+ | WSL with systemd | the same: a systemd user unit + the linger | same |
99
+ | WSL without systemd | there is no "start at login" at all. Two ways out: ① turn systemd on inside WSL (change the distribution's configuration and restart the subsystem); ② let the Windows side start the distribution with a scheduled task or a startup-folder entry (much closer to a real boot autostart) | the plugin **never** changes your distribution's configuration for you, and it never recommends a container-orchestration setup inside WSL (the orchestrator of WSL is Windows itself) |
100
+ | container / k8s | the orchestrator does it (`restart: always` and friends) | there is no "start at login" inside a container |
101
+
102
+ **"Start after login" and "start at boot" are not the same thing**: the first waits for you to log
103
+ in, the second happens when the machine comes up (and needs the linger enabled once). The plugin
104
+ writes that sentence and the matching command into the settings page.
105
+
106
+ ### If DSH does not come up
107
+
108
+ **First work out which situation you are in**: if the whole environment is broken (a changed
109
+ configuration, another plugin, a bad upgrade) → go back to the last good state; if **only this
110
+ plugin** is suspect → take this plugin out instead.
111
+
112
+ **And use the scripts of YOUR platform** — a Linux machine has no `.cmd` file, and a Windows
113
+ machine has no `.sh`:
114
+
115
+ | Your machine | back to the last good state | take this plugin out |
116
+ |---|---|---|
117
+ | **Windows** (the desktop app, `dsh web` on Windows) | double-click `scripts/restore-profile.cmd` | double-click `scripts/emergency-disable.cmd` |
118
+ | **Linux / WSL / macOS** (`dsh web`) | `sh scripts/restore-profile.sh` | `sh scripts/emergency-disable.sh` |
119
+
120
+ Two scripts per platform, and they do different jobs:
121
+
122
+ - **`restore-profile.cmd` / `restore-profile.sh` — back to the last good state.** It puts the
123
+ newest snapshot that carries a manifest back over the profile, and leaves the broken one behind
124
+ as `profile.broken-<timestamp>` (moved aside, not deleted). It does not restart DSH for you.
125
+ - **`emergency-disable.cmd` / `emergency-disable.sh` — take this plugin out and change nothing
126
+ else.** It removes only `dsh-autorestart` from the profile's bundle list and keeps a backup of
127
+ the file.
128
+
129
+ **Which one:** "back to the last good state" → `restore-profile`; "I only suspect this plugin" →
130
+ `emergency-disable`. The boundaries (the profile directory has to exist, a snapshot with a
131
+ manifest is required, DSH is never restarted for you, the `broken-` folder stays) are written down
132
+ in [`scripts/README.md`](scripts/README.md).
133
+
134
+ **What each platform can do** (and what it cannot) is spelled out in
135
+ [`docs/platform-matrix.md`](docs/platform-matrix.md) — the settings page shows the paths of the
136
+ scripts that really exist on the machine you are on.
137
+
138
+ ### What has really been tested
139
+
140
+ | How it was started | Tested on a real machine? | Evidence |
141
+ | --- | --- | --- |
142
+ | started by a shell supervisor script (`supervise-*.sh` and friends) | **yes** | recognised on a real machine as external, the evidence points at that script process, and **installing a second supervisor was refused** |
143
+ | started by hand in a terminal (foreground) | **partly** | on a real machine it recognised itself as "nobody manages it" (mode `bare`, the evidence is the parent being bash) and handed out an installation plan; the real "install → run → uninstall" was done on an instance that was **already managed by a supervisor**, so the bare instance never had the full install/uninstall cycle |
144
+ | started in the background (`nohup` / `setsid`) | **partly** | on a real machine it was recognised as a bare background start; **"the re-launch command outlives its shell" is pinned by unit tests only** (the machine did not report the detached flag back then) |
145
+ | WSL with systemd | **yes** | really installed and really removed |
146
+ | a Windows login task | **partly** | on a real machine one login-triggered task was created → queried (State=Ready, the logon trigger) → deleted (no leftovers) with the system's own tools, which proves that no administrator rights are needed; **"the fallback into the startup folder when the task is refused" is pinned by unit tests only** |
147
+ | the desktop client | **yes** | on a real machine it comes back by itself (there is a snapshot and a self-check record, no rollback was triggered, and it does not start twice); that is the client's own business, it is not part of the hosted matrix |
148
+ | pm2 / forever | no, unit tests only | the recognition and the behaviour are pinned by unit tests |
149
+ | container / compose | no, unit tests only | same |
150
+ | `npx` / `pnpm dlx` wrapper | no, unit tests only | same |
151
+ | started by another DSH plugin | no, unit tests only | same |
152
+ | tmux / screen / an SSH session | no, unit tests only | same |
153
+ | WSL started by a Windows-side process | no, unit tests only | same (it is recognised as "cannot tell", so nothing happens) |
154
+
155
+ **Not tested on a real machine does not mean unsupported**: those paths are pinned by unit tests, and
156
+ "no full install/uninstall cycle" or "unit tests only" does not mean unsupported either. If your
157
+ environment is recognised wrongly, please send us the "startup mode + the evidence" block from the
158
+ settings page.
159
+
160
+ ### Report a problem
161
+
162
+ The settings page has a "**Report a problem**" group at the bottom:
163
+
164
+ - The main button "**Copy the diagnostics and open a GitHub issue**": one click puts the diagnostics
165
+ on the clipboard and opens a **pre-filled** issue page (title and outline) — paste the clipboard
166
+ text into it.
167
+ - The diagnostics carry **no access token and no secrets**: only the plugin version, the platform,
168
+ whether this is the desktop client, how the host was started (with the evidence), the supervisor,
169
+ the restart mode, and the verdict of the last restart (with the checks that failed).
170
+ - When the browser refuses the clipboard (an insecure context, or no permission), the plugin shows
171
+ the diagnostics in a **selectable** box and says so — a report never dies because the browser said
172
+ no.
173
+ - No GitHub account? The same group shows the mailbox (252170229@qq.com) as plain text, plus a
174
+ "Copy the diagnostics" button.
175
+
54
176
  ## What it occupies in DSH
55
177
 
56
178
  Nothing here is hidden: this is the complete list of where the plugin attaches itself, so you can see
@@ -64,7 +186,7 @@ what it adds before you install it.
64
186
  | System prompt | One "Restart policy" section that tells the agent how to ask for a restart and what the plugin will do |
65
187
  | Harness hooks | `agent/status` (tracks which conversations are busy), `tools/pre-execute` (blocks a shell command that would kill or relaunch DSH by hand), and the boot pass that resumes interrupted sessions |
66
188
  | Profile row | One bundle row, `dsh-autorestart` |
67
- | Writes | Only under `<DSH_HOME>/storages/dsh-autorestart/` — `state.json` (checkpoint), `backups/` (pre-restart snapshots), `restart-<gen>.cmd` / `restart-<gen>-watch.ps1` (the detached helper), `last-rollback.json` (rollback report). The only time it writes into the profile itself is a rollback, which copies that snapshot's `cordis.patch.yml`, `package.json`, `pnpm-lock.yaml` and `node_modules` back |
189
+ | Writes | Only under `<DSH_HOME>/storages/dsh-autorestart/` — `state.json` (checkpoint), `backups/` (pre-restart snapshots), `restart-<gen>.cmd` / `restart-<gen>-watch.ps1` (the detached helper), `last-rollback.json` (rollback report), `restart-outcome.json` (the decision of a restart the plugin judged itself), `access.json` (the access link of the running instance, mode `0600`). The only time it writes into the profile itself is a rollback, which copies that snapshot's `cordis.patch.yml`, `package.json`, `pnpm-lock.yaml` and `node_modules` back |
68
190
 
69
191
  ## Why
70
192
 
@@ -76,7 +198,7 @@ what it adds before you install it.
76
198
 
77
199
  ## Install
78
200
 
79
- Current version: **0.6.2**.
201
+ Current version: **0.8.0**.
80
202
 
81
203
  From npm — the package is published under the name `dsh-autorestart`:
82
204
 
@@ -111,7 +233,7 @@ so you can call them yourself.
111
233
  | --- | --- | --- |
112
234
  | `continuum_restart` | Requests a graceful restart. The restart waits until every conversation is idle and the host has been quiet for `settleMs`, then the app is relaunched and this session resumes automatically. | `reason` (string, recorded in the checkpoint and the log), `resume` (boolean, default `true`), `defer_seconds` (number, extra seconds to wait before the first idle check), `parkGoals` (boolean, default `true`) — disarm a goal-driven session before the restart |
113
235
  | `continuum_status` | Read-only snapshot of the lifecycle: whether a restart is pending, which sessions were checkpointed, why each blocking session blocks, the goals this plugin disarmed, the last recovery action per session, the handoffs, the state file path and the detected restart environment. | `include_sessions` (boolean, default `true`) — include the per-session checkpoint entries |
114
- | `continuum_cancel_restart` | Cancels a pending restart and releases the continuation request of every requester. Does nothing when no restart is pending. | none |
236
+ | `continuum_cancel_restart` | Withdraws a pending restart request. By default it withdraws **only the calling session's own request** (`scope: "mine"`); the other sessions that asked for the same restart keep it. `scope: "all"` cancels the request for everyone. Does nothing when no restart is pending. | `scope` (string, `mine` (default) or `all`), `reason` (string, recorded in the log) |
115
237
  | `continuum_park_goal` | Disarms the goal of a session so that it no longer blocks the restart. The current round still finishes and the goal is re-armed after the restart. | `sessionId` (string, optional) — defaults to the calling session |
116
238
  | `continuum_recover_now` | Re-runs the recovery immediately instead of waiting for the next restart, and reports the action and the reason for each session. | `sessionId` (string, optional) — defaults to all sessions pending recovery |
117
239
  | `continuum_handoff` | Hands a session whose conversation exceeded the model context window over to a fresh session, carrying the goal objective, the recorded reasons and a bounded tail of the conversation. | `sessionId` (string, optional) — defaults to the calling session, `maxChars` (number, default `20000`) — character budget for the conversation tail |
@@ -119,8 +241,66 @@ so you can call them yourself.
119
241
  A goal is an enhancement, not a requirement: without one, the plugin injects a single continuation
120
242
  message instead of re-arming a goal.
121
243
 
244
+ ### Cancelling a restart: only your own request, or everybody's
245
+
246
+ A restart request is **coalesced**: several sessions may ask for the same restart, and the plugin
247
+ keeps every requester in `restart.requesters` so that all of them are resumed after that one
248
+ restart. That is why "cancel" has to say **whose** request it cancels:
249
+
250
+ | Scope | What happens |
251
+ | --- | --- |
252
+ | `mine` (the default) | Only the **calling session's** request is withdrawn. The restart stays pending while other requesters remain, and `restart.bySessionId` is re-pointed at one of them. When the calling session was the **last** requester, the restart really goes away: the phase returns to `idle` and the session is released. |
253
+ | `all` | The request is cancelled for **everybody**: every requester is released and the pending restart is gone. This is "nobody restarts any more". |
254
+
255
+ Two calls are refused, and both leave the state untouched — a silent "cancel everything" is never
256
+ the fallback:
257
+
258
+ - the calling session is unknown (the tool was called without a session) → refused;
259
+ - the calling session did **not** ask for the pending restart → refused.
260
+
261
+ The tool reports what happened: `cancelled` (whether the restart request itself is gone),
262
+ `remainingRequesters` (how many sessions still wait for it), `scope` and `phase`.
263
+
264
+ ```json
265
+ { "ok": true, "cancelled": false, "scope": "mine", "remainingRequesters": 1, "sessionId": "sess-a", "phase": "deferred" }
266
+ ```
267
+
268
+ ### The capsule: click it for the details and for the way out
269
+
270
+ The capsule in the composer row is 22px high and ONE line wide, so it carries numbers only
271
+ (`待重启 · 已等 16 分钟 · 3 项任务在跑`) — never a name and never a hint. Clicking it (or pressing
272
+ Enter/Space on it) opens a small panel above it:
273
+
274
+ - how many tasks the restart is waiting for;
275
+ - how many OTHER sessions wait for the same restart;
276
+ - one button: **Cancel my restart**.
277
+
278
+ The button opens a confirmation (no timeout, nothing counts down): *Cancel your restart?* — and what
279
+ cancelling means right now (only you, or you while the others still get their restart). Only the
280
+ confirmation sends the request; `Esc`, a click outside the panel or **Never mind** closes it without
281
+ doing anything.
282
+
283
+ The request withdraws ONLY this session's restart request (`POST /__dsh_autorestart/cancel` with
284
+ `{ "sessionId": "…", "scope": "mine" }`): the other sessions keep theirs, and the restart still
285
+ happens for them. There is no "cancel everybody" in the panel — that stays an explicit tool call
286
+ (`continuum_cancel_restart` with `scope: "all"`).
287
+
122
288
  ## Configuration
123
289
 
290
+ Two blocks of the settings page are worth a word, because they show facts instead of guesses:
291
+
292
+ - **What is backed up right now** (right under the two backup switches): the HOST reads
293
+ `<DSH_HOME>/storages/dsh-autorestart/backups/`, so the number of copies, the **local time** of each
294
+ one, its size (MB), whether the installed plugins are in it and where it lives are all real. Only
295
+ the directory metadata is read — the contents are never walked (one copy can be 150 MB). With no
296
+ backup at all it says: "No backup yet; one is written before every restart."
297
+ - **How this host was started** (hosted web only): one plain line ("How I remember you were started:
298
+ by your own script (the restart is its job — I stay out of it)") plus two small actions (detect
299
+ again / undo); the command itself is folded away under **"Technical details"** and stays closed
300
+ until somebody opens it. The **desktop client does not render that block at all** — it brings
301
+ itself back up and must never write a `restartCommand`. When the Host cannot say which kind of
302
+ host this is, the whole block stays away.
303
+
124
304
  Every key has a default, written in the `insert` row of `cordis.patch.yml`. To override a key, add
125
305
  an `- id: dsh-autorestart` row with the same key to the `cordis.patch.yml` of the profile at
126
306
  `~/.dsh/profiles/<profile>/cordis.patch.yml` (it replaces the whole config, it is not merged). The
@@ -154,7 +334,9 @@ show up in the Plugin Manager and in the config editor.
154
334
  | `backupOnRestart` | `true` | Snapshot the profile before every restart, so a restart that leaves the host unable to boot can be rolled back. |
155
335
  | `backupIncludePlugins` | `true` | Include the installed plugin tree in that snapshot (about 117 MB here, ~3 s). `false` keeps only the configuration files. |
156
336
  | `maxBackups` | `2` | How many snapshots are kept; older ones are deleted when a new one is written. |
157
- | `rollbackTimeoutMs` | `90000` | How long the detached watcher waits for the relaunched host before it restores the snapshot and launches the host again. |
337
+ | `rollbackTimeoutMs` | `90000` | How long the detached watcher waits for the new host before it restores the snapshot. |
338
+ | `restartCommandTimeoutMs` | `30000` | How long the restart command may run before the plugin stops waiting for it. A command that starts the host in the foreground never returns — that is reported as "still running", not as a failure. |
339
+ | `rollbackCopyTimeoutMs` | `120000` | How long ONE restore step (the configuration files, then the plugin tree) may take. A restore that does not finish in time is given up and the reason is written into the rollback report. |
158
340
  | `showSleepButton` | `true` | Whether the moon button of the graceful shutdown is shown at the foot of the left sidebar, next to Settings. |
159
341
  | `sleepCommand` | `''` | A custom shutdown command for the graceful shutdown. Empty = the host is detected automatically; a value replaces the detected command (`cmd.exe /c …` on Windows, `/bin/sh -c …` everywhere else). |
160
342
 
@@ -306,23 +488,242 @@ it was started from, so a restart that leaves the host unable to boot can be und
306
488
  like this one the full snapshot measures about 117 MB and takes under three seconds.
307
489
  - **How many are kept:** the newest `maxBackups` snapshots (default `2`); older ones are deleted when
308
490
  a new one is written.
309
- - **How a failed boot is detected:** the detached helper that kills and relaunches the host also runs
310
- a small PowerShell watcher. It polls the health route for `rollbackTimeoutMs` (default `90000`). A
311
- healthy answer ends the watch and nothing is touched.
491
+ - **How a failed boot is detected:** a small watcher runs **outside** the host process (so it survives
492
+ the host being killed). It waits for the **new** instance and probes **its** health route for
493
+ `rollbackTimeoutMs` (default `90000`). The new instance publishes its own port and its own boot id
494
+ in `<storage>/dsh-autorestart/access.json`, so the watcher never mistakes the old instance (or the
495
+ old port) for the new one. A healthy answer ends the watch and nothing is touched.
312
496
  - **What happens when the host never answers:** the watcher copies the snapshot back over the profile
313
- (configuration files, and the plugin tree when the snapshot holds one), writes
497
+ (configuration files, and the plugin tree when the snapshot holds one) and writes
314
498
  `last-rollback.json` next to the checkpoint — including the tail of the newest crash log written
315
- *after* this restart — and launches the host again with the restored configuration.
499
+ *after* this restart and the reason the restore failed, if it did. It does **not** start a second
500
+ copy of the host: the instance that was launched is left alone, so there is never a second window
501
+ or a second web server.
502
+ - **The hosted mode has the same safety net.** With `restartMode: command` the restart command
503
+ usually kills the very process the plugin runs in, so the plugin cannot confirm anything itself.
504
+ The watcher is therefore started **before** the command runs, and it is the one that decides
505
+ whether the new instance came up.
506
+ - **The command is judged, not assumed.** A restart command that cannot be started, or that exits
507
+ with an error, is reported as a failure (with its exit code and output) and the snapshot is
508
+ restored — it is never reported as a successful restart.
316
509
  - **How you learn about it:** the next boot reports it. `continuum_status` gains a `lastRollback`
317
- object (`at`, `waitedMs`, `rolledBack`, `backupDir`, `crashLog`, `crashTail`), and the next restart
318
- clears the report so it is never mistaken for a fresh one.
510
+ object (`at`, `waitedMs`, `rolledBack`, `reason`, `backupDir`, `crashLog`, `crashTail`), and the
511
+ next restart clears the report so it is never mistaken for a fresh one.
319
512
  - **Turning it off:** `backupOnRestart: false` skips the snapshot entirely (no rollback point, and no
320
513
  watcher), which is what a user who keeps their own backups may prefer. `backupIncludePlugins:
321
514
  false` keeps the snapshot but limits it to the configuration files.
322
515
 
323
516
  The rollback is deliberately conservative: it only runs when the relaunched host never answers its
324
517
  own health route. A slow start inside `rollbackTimeoutMs` is never treated as a failure, and a
325
- healthy host is never touched.
518
+ healthy host is never touched. The restore itself is bounded as well: every copy step gives up after
519
+ `rollbackCopyTimeoutMs` (default `120000`) and says so in the report.
520
+
521
+ ### When the machine refuses a scheduled task (the automatic rollback is gone)
522
+
523
+ The safety net above hangs on a **one-off scheduled task**: the helper that brings the host back
524
+ after a restart is a task that fires a minute later. On a machine where such a task cannot be
525
+ created (a corporate policy, a locked-down account, a machine without the rights) the net is
526
+ silently missing.
527
+
528
+ So the plugin **checks the machine once** — the first time it runs there — and tells the truth in
529
+ the settings page:
530
+
531
+ - `Automatic rollback: available` — the net is there.
532
+ - `Automatic rollback: **not available**` — and exactly what is lost: *DSH will not recover by
533
+ itself when it does not come up after a restart*. The line points at the restore script in
534
+ "If DSH will not start", which is the way out.
535
+
536
+ **What this deliberately does NOT do:**
537
+
538
+ - **No fallback chain.** There is no "use the startup folder instead", no "use a systemd timer
539
+ instead", no second mechanism that is tried when the first one is refused.
540
+ - **No bypassing the policy.** The plugin does not look for a way around a machine that says no.
541
+ If the machine refuses, the answer is "not available" — and only that is reported.
542
+
543
+ A refused check is **never** a failed installation: the plugin works exactly as before, only the
544
+ safety net is missing. The check itself is harmless by construction — it registers a task that
545
+ fires in two minutes and does nothing (`cmd /c exit 0`), reads it back, deletes it, and reads it
546
+ back once more to prove that it is gone. It runs **once**, not on every visit of the settings page,
547
+ and it never leaves a task behind (not even when a step of it fails).
548
+
549
+ ### The watchdog: the three ways it can be triggered
550
+
551
+ The rollback above needs somebody to *wait*: the host is restarted, and a
552
+ one-off thing has to notice that the new host never came up. That thing is the
553
+ **watchdog** (`scripts/watchdog.mjs`), and how it is triggered is the part
554
+ that differs between the machines — so the plugin picks the best way the
555
+ machine has and says which one it picked:
556
+
557
+ | Tier | Mechanism | Where |
558
+ |---|---|---|
559
+ | `scheduled-task` | a one-off Windows scheduled task (read back after it was registered, deleted completely afterwards) | the desktop app and `dsh web` on Windows |
560
+ | `systemd-run` | a systemd user timer (`systemd-run --user --on-active=…`), reset when it is over | `dsh web` on Linux, WSL with systemd |
561
+ | `detached-process` | a background process of our own (`setsid nohup sh -c 'sleep …; …'`), its pid in the state directory | containers, WSL without systemd |
562
+ | `none` | nothing on this machine | then there is **no automatic rollback**, and the settings page says so |
563
+
564
+ **One function decides it** (`lib/watchdog-tier.js`), and the settings page, the
565
+ diagnostics and the watchdog itself all ask that one function — so the warning
566
+ ("this machine has no automatic rollback") can never drift away from what the
567
+ plugin actually does.
568
+
569
+ The flow is the same for every tier (`lib/watchdog.js`): wait for the new host
570
+ to write the ready marker → ask again (a slow host is not a dead host) → is an
571
+ instance alive? (then there is nothing to restore) → take the shared lock
572
+ (`rollback.lock`, so exactly one of us restores) → restore the snapshot → start
573
+ the host again → wait for the ready marker once more. At most **one** restore
574
+ and **one** start; then the watchdog stops and writes what happened into
575
+ `<stateDir>/last-watchdog-report.json` (which tier it used, and why).
576
+
577
+ **Only one watchdog at a time.** There is an older watcher in this plugin as
578
+ well (it restores the snapshot but does **not** start the host again). Both take
579
+ the same lock, so running them together is worse than running one: the one that
580
+ loses the lock gives up — and if the new one lost, the profile would be restored
581
+ and **nothing would be running**. So the plugin decides once, in one place
582
+ (`lib/watchdog-arbiter.js`): the new watchdog takes over whenever it can be
583
+ armed at all, and the old one only runs when the new one cannot (no tier on this
584
+ machine, the watchdog is switched off, or the registration failed). The report
585
+ names the arbiter (`arbiter: "v2"` or `"v1"`), so it is visible at a glance who
586
+ was watching.
587
+
588
+ `watchdogEnabled: false` switches it off entirely.
589
+
590
+ ### The access link after a restart
591
+
592
+ The boot token of `dsh web` is created **per process**, so the link the browser holds answers `401`
593
+ after a restart — the user would be locked out of their own interface. The plugin therefore writes
594
+ the access link of the running instance into `<storage>/dsh-autorestart/access.json` (mode `0600`:
595
+ the file holds a secret), and reports it in `continuum_status` and in the log. The browser half
596
+ follows that link when the one it holds is stale, so the session comes back on its own.
597
+
598
+ ### Start at login: the supervisor of this plugin
599
+
600
+ A hosted host (`dsh web`) that was started by hand does not come back when it dies — and a restart
601
+ that nobody brings back is a restart the user has to do themselves. The plugin can install a
602
+ **supervisor**: a small standalone process that starts the host, watches it, and starts it again when
603
+ it is gone.
604
+
605
+ - **It is a separate process.** The plugin lives inside the host, so it can never be the thing that
606
+ brings the host back. The supervisor is a zero-dependency Node script (`supervisor/supervisor.js`,
607
+ shipped with the package) that the plugin copies into `<storage>/dsh-autorestart/supervisor/` when
608
+ it is installed.
609
+ - **What it does:** start the host with the command from `supervisor.json`, wait for it to exit, back
610
+ off (1 s → 30 s; the delay is only reset after a start that actually worked) and start it again.
611
+ When the host does not come back at all, the snapshot of the last restart is restored.
612
+ - **How it is registered (no administrator rights):**
613
+ - **Windows:** a scheduled task `dsh-autorestart-web` (`/SC ONLOGON /RL LIMITED`). If the machine
614
+ forbids ordinary users from creating scheduled tasks, the plugin falls back to a `.cmd` in the
615
+ startup folder (same zero rights, same "at login" semantics).
616
+ - **Linux / WSL:** a systemd **user** unit
617
+ (`~/.config/systemd/user/dsh-autorestart-web.service`). A machine without a service manager (a
618
+ container, a WSL without systemd) has no "at login" at all — there the plugin says so and leaves
619
+ the start command to the user.
620
+ - **The guide of every platform.** "Start at login" means something different everywhere, so the
621
+ plugin hands out one piece of advice per platform (the settings page shows it, with the commands
622
+ you can copy):
623
+
624
+ | platform | what the plugin does |
625
+ |---|---|
626
+ | **Windows** | a scheduled task (`/SC ONLOGON /RL LIMITED`, no administrator); when the machine refuses it, a `.cmd` in the startup folder. The command names a **real node** + `supervisor.js` — never the application executable. |
627
+ | **Linux** | a systemd **user** service — plus the `loginctl enable-linger <user>` hint, because without the linger it only starts *after* a login, not at boot. |
628
+ | **WSL with systemd** | the same user unit, plus the linger hint. |
629
+ | **WSL without systemd** | no "at login" at all: two ways out — ① turn systemd on inside WSL (`/etc/wsl.conf` → `[boot] systemd=true` + `wsl --shutdown`; **a system configuration change**) or ② let Windows start the distribution (`wsl.exe -d <distro> -e <command>`; **needs a Windows-side action, but it is much closer to a real boot autostart**). Inside WSL the plugin **never** recommends a container-orchestration setup — the orchestrator of WSL is Windows. |
630
+ | **container / k8s** | no promise at all: the orchestrator brings it back (`restart: always`). |
631
+ | **unknown** | nothing is installed and nothing is guessed: the plugin says why and asks the user to choose. |
632
+
633
+ A host that is only **guessed** to be WSL (the mounted Windows drive is the only evidence) gets no
634
+ automatic anything: the plugin says "not sure, pick one yourself" and changes nothing.
635
+ - **It is idempotent:** installing twice leaves exactly ONE record (the task/unit name is fixed, the
636
+ task is created with `/F`, and the daemon takes an exclusive lock — two supervisors are impossible).
637
+ - **A failed installation leaves nothing behind:** when a step fails half way, the unit (or the task)
638
+ that was already written is taken away again and the rollback is reported in the step list — a
639
+ half-installed supervisor would start a stale one at the next login.
640
+ - **The program is never repeated:** a step carries the program in `file` and the arguments in `args`,
641
+ so the record runs `systemctl --user enable --now …` and never `systemctl systemctl …` (which every
642
+ systemd answers with "Unknown command verb systemctl").
643
+ - **It is reversible:** the previous `restartMode` / `restartCommand` are saved before anything
644
+ changes and put back by the uninstall. The settings page has one button for it ("Settings →
645
+ Auto-restart & shut down → Start at login"), and the manual way out is printed in the card and in
646
+ the log. When a step of the uninstall fails, the pending cleanup is written down instead of being
647
+ dropped silently — but only when something is really left, and it lives next to the supervisor
648
+ directory (`<stateDir>/UNINSTALL-PENDING.json`), so writing it cannot bring that directory back.
649
+ - **The first-run card:** a hosted host is asked ONCE whether the supervisor may be installed. The
650
+ card says what it would change, which rights it needs, and how to remove it again. Choosing "keep
651
+ the way I start it" changes nothing — the plugin only records that a supervisor of your own is
652
+ already there (so it never installs a second one).
653
+
654
+ The desktop app is **not** asked: it brings itself back up on its own (`restartMode: app`), so the
655
+ question would be noise there. It is also **refused** the installation itself — a login record that
656
+ starts the supervisor would start the Electron client, and that would open a **second** copy of the
657
+ app at every login. `plan` / `install` answer `ok: false` with `error: "desktop-manages-itself"` and
658
+ an empty step list there, and the settings page shows `mode: "app"` (`restartCapable: true`) instead
659
+ of the scary "restart not available".
660
+
661
+ The record that starts the supervisor always names a **real Node interpreter**: the process itself
662
+ when it is node, otherwise the runtime that ships with the installation
663
+ (`resources/runtime/primary-runtime/dependencies/node/bin/node.exe`), otherwise the `node` of the
664
+ PATH. The application executable is never used as a stand-in for node, and when no interpreter is
665
+ found the installation is refused with the reason.
666
+
667
+ ### How this host was started (the startup matrix)
668
+
669
+ A hosted web host can be started in a dozen ways, and the plugin has to behave correctly in every
670
+ one of them. The classification is a **pure function** (`lib/startup-mode.js`): it takes the parent
671
+ chain, the environment markers, the container markers and the platform, and answers
672
+ `{ mode, kind, evidence, selfReplay, canInstallSupervisor, suggestion }`.
673
+
674
+ | How it was started | `mode` | What the plugin does |
675
+ |---|---|---|
676
+ | a plain terminal (`dsh web`) | `bare` | the supervisor of this plugin MAY be installed; otherwise the derived command (after your confirmation) |
677
+ | `nohup … &` / `setsid` | `bare` | the same — but the restart command is **detached** (`setsid nohup …`), so it does not die with the shell that started it |
678
+ | a supervisor script (`supervise-*.sh`) | `external` | graceful exit only (`selfReplay: refused`) — the script brings the host back |
679
+ | a systemd unit | `external` | graceful exit only (evidence: `INVOCATION_ID` or the parent chain) |
680
+ | pm2 / forever | `external` | graceful exit only |
681
+ | docker / compose / kubernetes | `container` | no "at login" at all: the compose fragment (`restart: always`) is the answer, and no supervisor is installed inside a container |
682
+ | a Windows scheduled task / an NSSM service | `external` | graceful exit only |
683
+ | WSL, started by a Windows process | `unknown` | nothing is written: the parent is invisible in WSL — pick the plain terminal (install your own supervisor) or write your own start command (optional) |
684
+ | `npx` / `pnpm dlx` | `bare` (usable) or `unknown` | usable when the resolved script path is in `argv[1]`; otherwise nothing is guessed |
685
+ | another DSH plugin spawned it | `external` | graceful exit only — never a second host |
686
+ | tmux / screen / an SSH session | `bare` | the command is replayed, but **detached from the session** (a graceful exit would break the session) |
687
+
688
+ **The two rules that matter more than the table:** only a `bare` host may get a self-replay command
689
+ or the supervisor of this plugin, and an `unknown` host gets `selfReplay: 'unknown'` and nothing
690
+ else — the plugin would rather say "I cannot tell" than start a second host next to the first.
691
+
692
+ The settings page shows the verdict **with its evidence** ("Started by … — ancestor:1234: systemd"),
693
+ so it is visible WHY the plugin says what it says.
694
+
695
+ ### "Keep the way I start it": the derived restart command (E) and the dry run (B)
696
+
697
+ When the user keeps their own way of starting the host, the plugin can still write down a command
698
+ that starts it again — but only if it can be derived **without guessing**. A wrong command is worse
699
+ than no command: it looks like a restart and leaves the user with a dead host.
700
+
701
+ The candidate is built from what the process itself knows: `[process.execPath, ...process.execArgv,
702
+ ...process.argv.slice(1)]` plus the working directory. The **priority** decides what happens next:
703
+
704
+ | Level | Situation | What is written |
705
+ |---|---|---|
706
+ | **E0** | The supervisor of this plugin started this instance | Nothing — the restart is asked of the supervisor over its control channel (the plugin writes the rollback request and lets the supervisor kill and start the host). |
707
+ | **E1** | An **external** supervisor is in charge (systemd, a user script, pm2) | **Not** a self-replay command: `restartCommand` becomes a graceful exit (`kill -TERM <pid>` / `taskkill /PID <pid>`), because the other supervisor starts the host again. A second supervisor would fight over the port. |
708
+ | **E2** | No supervisor and no init (a container) | The command is handed out together with a compose fragment (`restart: always`); no automatic start is promised. |
709
+ | **E3** | No supervisor, an init is available | The derived command — after the user confirmed the dry run. |
710
+
711
+ The **refusals** (each one has a code, and each one names the way out): the desktop app (it restarts
712
+ itself), a host without a web server, a command line that cannot be read back, an external
713
+ supervisor, a container without an init, and a port that changes on every start (the cookie of the
714
+ browser would die with it — the port has to be fixed).
715
+
716
+ The **dry run** (B) is the promise: the command, the working directory, the port and the health URL
717
+ are shown **before** anything is written. Only an explicit confirmation writes the command into the
718
+ profile configuration; the values that were in effect before are kept, so the settings page can put
719
+ them back with one click.
720
+
721
+ - The dry run: `POST /__dsh_autorestart/supervisor` with `{ "action": "dry-run" }` (or the tool
722
+ `continuum_supervisor` with `action: "dry-run"`) — it writes nothing.
723
+ - The confirmation: `{ "action": "apply", "confirmed": true }` — without `confirmed` the Host
724
+ answers `400 confirmation-required` and writes nothing.
725
+ - The undo: `{ "action": "revert", "confirmed": true }` — the previous `restartMode` /
726
+ `restartCommand` are written back.
326
727
 
327
728
  ### The one-time install question
328
729
 
@@ -412,6 +813,35 @@ be cancelled — the card has a button, and the moon button itself becomes a tog
412
813
  is armed. If a conversation starts working again during the countdown, the countdown goes back to
413
814
  "waiting" and nothing is switched off.
414
815
 
816
+ ### Which machine gets switched off?
817
+
818
+ The plugin works out where it runs and switches off **that machine** — and the label in the
819
+ settings says which one, so there is no guessing:
820
+
821
+ | Where DSH runs | What the shutdown switches off | The label you see |
822
+ |---|---|---|
823
+ | The Windows desktop app | that Windows computer | "Switch off this Windows computer" |
824
+ | `dsh web` on Windows | that Windows computer | "Switch off this Windows computer" |
825
+ | `dsh web` on a Linux host | that Linux host | "Shut down this Linux host" |
826
+ | **`dsh web` inside WSL** | **the whole Windows machine** (not the little WSL box!) | "The DSH inside WSL → switches off the whole Windows" |
827
+ | A container | nothing at all (there is no command) | "This machine has no reliable way to switch off" |
828
+
829
+ > ⚠️ **A shutdown countdown inside WSL means your whole machine goes down** — not just that
830
+ > web service. WSL is a small Linux environment *inside* Windows; shutting that environment
831
+ > down would only stop the distribution. To switch off the **host**, the plugin goes through
832
+ > the mounted drive: `shutdown.exe` (`/mnt/c/Windows/System32/shutdown.exe /s /f /t 0`), with
833
+ > `powershell.exe -Command "Stop-Computer -Force"` and `cmd.exe /c shutdown.exe /s /f /t 0`
834
+ > as the fallbacks. In other words: **the host goes off.**
835
+
836
+ **How far this is verified.** The *plan* is verified for every platform
837
+ (`test/sleep-plan-posix.test.mjs`: inside WSL the plan must take the "switch off the whole
838
+ Windows" branch, a plain Linux plan must not contain a single Windows command, and a machine
839
+ without a way to switch off has an empty command and **executes nothing**). The real power-off
840
+ is verified on **Windows**; on POSIX it is deliberately **not** switched off for real — a
841
+ common-sense fact is not worth ending every session on the machine for.
842
+
843
+ The full picture per platform is in [`docs/platform-matrix.md`](docs/platform-matrix.md).
844
+
415
845
  ### Shutdown and restart: who goes first
416
846
 
417
847
  **The short version.** Pressing the moon button means "switch the machine off when the work is
@@ -578,6 +1008,24 @@ was only recognised as WSL because the Windows drive is mounted — a hint, not
578
1008
  `lastRun.dryRun` marks the last run as a rehearsal that switched nothing off; and
579
1009
  `backupOnRestart` / `backupIncludePlugins` report the effective scope of the pre-restart backup.
580
1010
 
1011
+ **The record of the last restart.** `GET <healthPath>?state=1` and the `continuum_status` tool both
1012
+ report `lastRestart`: the snapshot that was taken before the restart (`snapshot.dir`,
1013
+ `snapshot.stamp`, `snapshot.atLocal` — the local time, because the directory name is UTC —,
1014
+ `snapshot.durationMs`, `snapshot.pruned`) and the verdict of the instance that came up
1015
+ (`result` is `pending`, `ok` or `failed`, plus `verifiedAt`, `durationMs` and the four boot checks
1016
+ `listener` / `routes` / `plugin` / `recovery`). A restart whose new instance never came up stays
1017
+ `pending` — that is visible too. The file behind it is
1018
+ `<stateDir>/last-restart-verdict.json`.
1019
+
1020
+ **Who starts this host.** The `supervisor` block of the same payload says `mode`
1021
+ (`app` / `installed` / `external` / `unknown` / `off`), `restartCapable`, and — since a second supervisor
1022
+ would fight over the port — `selfReplay` (`allowed` / `refused` / `unknown` / `n/a`) plus the `external`
1023
+ evidence (`kind`, `evidence`, `commandLine`) when somebody else is in charge. On Linux the parent
1024
+ chain of the host is read (`/proc/<pid>/cmdline`), so a `supervise-*.sh` script of the user is
1025
+ recognised. When nobody can be identified the mode is `unknown`, never a silent "bare host": the
1026
+ plugin then refuses to replay its own command. The **desktop app** is never `unknown`: it brings
1027
+ itself back up, so it reports `mode: "app"` with `restartCapable: true` and `selfReplay: "n/a"`.
1028
+
581
1029
  **When the shutdown command does not start.** The host waits for the real answer of the process. If
582
1030
  the command cannot start (missing binary, denied permission), the shutdown does **not** pretend it
583
1031
  worked: the phase goes back to `idle`, the reason is reported ("the shutdown command did not start
Binary file
Binary file
Binary file
Binary file
Binary file