dsh-autorestart 0.5.0 → 0.6.1

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/README.md CHANGED
@@ -29,10 +29,14 @@ round starts, so the session goes idle and the restart can proceed. The goal is
29
29
  restart and continues.
30
30
 
31
31
  The browser shows the state: a compact "pending restart" pill on the left side of the input toolbar
32
- and a small dot next to the session title in the sidebar. The pill's text reports how many
33
- conversations are still in the way (counted once per conversation, and the tooltip explains that a
34
- conversation with queued-but-not-started work counts too); it does not open a details panel. The dot
35
- breathes when the restart is about to fire.
32
+ and a small dot next to the session title in the sidebar. The pill's text reports how many **tasks**
33
+ are still in the way — a conversation, a subagent or a teammate counts as one task each, and the
34
+ tooltip explains that queued-but-not-started work counts too; it does not open a details panel. The
35
+ dot breathes when the restart is about to fire.
36
+
37
+ Every blocker says **what it is**: a conversation is named by its own title, and a **subagent** is
38
+ named after the conversation that created it ("Subagent of *that* conversation"). A raw session id is
39
+ never shown to the user.
36
40
 
37
41
  Before every restart the plugin snapshots the profile, so a restart that leaves the host unable to
38
42
  boot is rolled back automatically — see
@@ -72,7 +76,7 @@ what it adds before you install it.
72
76
 
73
77
  ## Install
74
78
 
75
- Current version: **0.5.0**.
79
+ Current version: **0.6.1**.
76
80
 
77
81
  From npm — the package is published under the name `dsh-autorestart`:
78
82
 
@@ -140,6 +144,7 @@ show up in the Plugin Manager and in the config editor.
140
144
  | `restartCommand` | `''` | Custom restart command. Supports the `{pid}`, `{ppid}` and `{app}` placeholders. |
141
145
  | `relaunchDelaySeconds` | `2` | How long the detached helper waits before it kills the app (`ping -n 3`, roughly 2 seconds). |
142
146
  | `settleMs` | `2000` | Quiet window in milliseconds: the host must stay silent this long after the last conversation activity before a restart may start. |
147
+ | `restartGraceMs` | `10000` | Grace window in milliseconds: how long the host waits after the last conversation finished before it is really restarted or switched off — a dropdown: `0` / `3` / `5` / `10` / `20` / `30` seconds. `0` means "no extra wait". |
143
148
  | `autoReloadPage` | `true` | `dsh web`: reload the page automatically when the host boot token changes. |
144
149
  | `blockManualRestart` | `true` | Hard-block an agent that tries to kill or restart DSH with a shell command, and point it at `continuum_restart` instead. |
145
150
  | `healthPath` | `/__dsh_autorestart/health` | Health check route. Returns the boot id, the pid, the phase and the self-check flags. |
@@ -161,6 +166,30 @@ The two waits are independent of each other, and both default to two seconds:
161
166
  Both can be overridden separately in the profile config. A larger `settleMs` is the more
162
167
  conservative choice, because it is less likely to cut a session short that has just started.
163
168
 
169
+ ### Why the host waits a few more seconds after the work is done
170
+
171
+ **The blind spot.** This plugin can only see the **DSH agents**: it knows whether a conversation is
172
+ running, whether it has queued inbox work, and when the last of them went idle. It cannot see the
173
+ work other plugins queue with a timer of their own — a memory plugin that writes its notes a few
174
+ seconds after the conversation ended, a summary, an MCP callback, a log flush. That work is
175
+ invisible here, so "every agent is idle" does not mean "nothing is happening any more".
176
+
177
+ **The grace window.** `restartGraceMs` (default **10 seconds**) is the extra wait for exactly
178
+ that: after the LAST conversation finished, the host waits this long before it is really restarted
179
+ or switched off. It is a plain, configurable insurance — not a special case for any particular
180
+ plugin.
181
+
182
+ - **Both paths use it.** The restart gate waits `max(settleMs, restartGraceMs)` of quiet, and the
183
+ graceful shutdown waits the same grace **before** its countdown starts (so the grace never pushes
184
+ a countdown that is already running back by a round).
185
+ - **New activity restarts the timer.** If a conversation starts working again while the host is
186
+ waiting, the grace starts over — the wait always follows the last activity.
187
+ - **`0` means "as before".** With `restartGraceMs: 0` the behaviour is exactly the one of 0.4.0.
188
+ - **Where to change it.** Settings → Auto-restart & shut down → *Wait a few more seconds after the
189
+ work is done*. The status interfaces report it too: `GET /health?state=1`, `GET /health?gate=1`,
190
+ `continuum_status` and `GET /sleep` all carry `graceMs` (the configured window) and `quietForMs`
191
+ (how long the host has actually been quiet).
192
+
164
193
  ### Platform note
165
194
 
166
195
  `restartMode: app` only applies to the DeepSeek Harness desktop app. A hosted `dsh web`
@@ -255,9 +284,25 @@ once, in the same composer dock, as a small card:
255
284
  already shown (`seenFeatureVersions` in the checkpoint file). A fresh installation never sees the
256
285
  card: the install question already explains the features, and answering it marks the announcement
257
286
  as seen.
258
- - **Once per version, for ever.** "Got it" records the version, the card disappears at once, and it
259
- never comes back — not with the next restart of the host, and not with the next version of the
260
- plugin (a *new* version of a feature is announced again, of course).
287
+ - **"Seen" means "not older", not "equal".** The card counts as answered as soon as the recorded
288
+ version is **not older** than the current feature version — a record of `0.6.0` counts as seen
289
+ while the feature itself stands at `0.5.0`. Why not equality? Because the record does not say
290
+ *which* release the user answered on (until 0.6.0 the card carried the version of the package),
291
+ so an equality check would tell a user who already dismissed the card that they never saw it. A
292
+ record that is not a version at all counts as seen as well: a broken record must not make the
293
+ card come back for ever.
294
+ - **Once per feature version, for ever.** "Got it" records the version, the card disappears at once,
295
+ and it never comes back — not with the next restart of the host, and not with the next release of
296
+ the plugin (a *newer version of the feature* is announced again, of course).
297
+ - **The announcement follows the FEATURE, not the release.** The version it carries is a constant in
298
+ the source (`SLEEP_FEATURE_VERSION` in `index.js`), **not** the version of the package: a release
299
+ never brings the card back. Until 0.6.0 the card carried the package version, and every release
300
+ (0.5.0 → 0.6.0) brought "graceful shutdown" back to the users who had already answered it.
301
+ - **How to announce it again on purpose.** Raise `SLEEP_FEATURE_VERSION` to a version **newer** than
302
+ the one that was recorded last: the card is then shown exactly once more. Do that **only** when
303
+ the feature itself changed so much that the user has to hear about it again (the shutdown became a
304
+ completely different thing, the button moved somewhere else, …). A release, a bug fix or a small
305
+ addition must **not** touch it — that is exactly the bug this constant exists to prevent.
261
306
  - **An unanswered card is not swallowed.** The card stays until it is answered, and it comes back with
262
307
  the next start of DSH.
263
308
  - **The countdown is read, not written.** The seconds in the card are the ones that are really in
@@ -265,7 +310,8 @@ once, in the same composer dock, as a small card:
265
310
  ("The button is currently switched off; you can turn it on in the settings").
266
311
  - **Where the state lives.** `<DSH_HOME>/storages/dsh-autorestart/state.json`, next to the checkpoint
267
312
  of the plugin. The host serves it on `GET /announcements` (`{ ok, pending: [{ feature, version }] }`)
268
- and records it on `POST /announcements` (`{ feature }`).
313
+ and records it on `POST /announcements` (`{ feature }`). The `version` in that answer is the feature
314
+ version — the same number that `SLEEP_FEATURE_VERSION` holds.
269
315
 
270
316
  ## Graceful shutdown (收工关机)
271
317
 
@@ -276,6 +322,80 @@ be cancelled — the card has a button, and the moon button itself becomes a tog
276
322
  is armed. If a conversation starts working again during the countdown, the countdown goes back to
277
323
  "waiting" and nothing is switched off.
278
324
 
325
+ ### Shutdown and restart: who goes first
326
+
327
+ **The short version.** Pressing the moon button means "switch the machine off when the work is
328
+ done" — **not** "switch it off right now, whatever else is running". So:
329
+
330
+ 1. **A pending restart wins.** If a restart is still on the list, the shutdown does **not** happen.
331
+ The card says *"Waiting for the pending restart to finish before shutting down"*. The restart
332
+ goes first, the sessions come back with it and keep working, and only then does the machine
333
+ go off.
334
+ 2. **The work keeps running after the restart.** The sessions that resume with the restart are
335
+ running work: while any of them is busy, the shutdown waits. The machine only goes off when
336
+ **every** session is idle again.
337
+ 3. **The shutdown intent survives the restart.** You pressed the button **once**. The restart does
338
+ not swallow that request: it is written into the checkpoint file, and the new boot picks it up
339
+ and keeps waiting for the work. You do **not** have to press the button a second time.
340
+ 4. **You can cancel at any time.** The waiting card has a *"Keep it on"* button; pressing it (or the
341
+ moon button again) forgets the intent for good.
342
+
343
+ **Where the intent lives.** In the checkpoint file
344
+ (`<DSH_HOME>/storages/dsh-autorestart/state.json`) as `sleep: { armed, armedAt, countdownSeconds }`.
345
+ It is cleared as soon as the machine went off, when the shutdown failed, and when you cancel. A
346
+ record that cannot be read is dropped — asking once more is much better than a machine that switches
347
+ itself off for ever because of a half-written file.
348
+
349
+ **For the troubleshooting.** `GET /sleep` reports `armedAt` (when the shutdown was armed) next to
350
+ `phase`, `blockers` and `graceMs`.
351
+
352
+ ### Troubleshooting: the machine did not switch off
353
+
354
+ **These are the real answers of a running host** (the copy of the host is Chinese, the fields are
355
+ not):
356
+
357
+ ```
358
+ POST /__dsh_autorestart/sleep {"action":"arm"} # three conversations are working
359
+ → phase=waiting armed=true remainingMs=20000 (the full value: the countdown has NOT started)
360
+ message: 等 3 个任务收工,收工后开始倒数。
361
+
362
+ # after a restart was requested:
363
+ GET /__dsh_autorestart/sleep
364
+ → phase=waiting armed=true remainingMs=20000
365
+ message: 等这次重启完成后再关机……
366
+ blockers: [{"sessionId":"restart-pending","kind":"restart-pending",
367
+ "label":"待执行的重启","name":"待执行的重启"}]
368
+ ```
369
+
370
+ `remainingMs` at its full value is the tell: **the countdown has not started**, so the machine is
371
+ waiting for something. Here is what it can be, and what to do about it:
372
+
373
+ 1. **Some tasks are still working.** `blockers[]` lists them; look at `kind`:
374
+ `session` is a conversation, `subagent` is a child agent of one. A subagent counts as work on
375
+ purpose — otherwise a restart would cut it off. **What to do:** let it finish, or stop that
376
+ conversation. The list shrinks on its own.
377
+ 2. **A restart is still pending.** `kind` is `restart-pending` (`label`/`name` are
378
+ `待执行的重启`). That is normal queueing, not a fault: the restart runs first, the sessions
379
+ come back with it, and the countdown continues afterwards. **What to do:** nothing — wait for
380
+ the restart. (You can also cancel the restart with `continuum_cancel_restart`.)
381
+ 3. **The work just ended and the grace window is still running** (default 10 seconds, so that a
382
+ memory or summary plugin can still write its notes). **What you see:** `graceMs` is the
383
+ configured window, `quietForMs` is how long the host has actually been quiet. **What to do:**
384
+ wait; if another conversation starts working during the wait, the timer starts over (that is
385
+ the point of the window). Set it to `0` in the settings if you do not want the extra wait.
386
+ 4. **Somebody started working during the countdown.** The countdown goes back to "waiting" and
387
+ `remainingMs` returns to its full value. **What you see:** the message says that somebody
388
+ started working again, and `blockers[]` lists who. **What to do:** let them finish.
389
+ 5. **You want to know who it is waiting for.** `continuum_status` → `blockedBy[]` and
390
+ `GET /sleep` → `blockers[]` are the **same data**: the first is for agents, the second for
391
+ scripts. Each entry has `kind` (what it is), `label` (the title of the session) and `name`
392
+ (the whole name, e.g. "the subagent of the coding session"). A session whose title could not
393
+ be resolved reports `label: null` and an empty `name` — that is why the waiting line then
394
+ only says "等 3 个任务收工" without a list of names.
395
+ 6. **You do not want it at all.** The cancel button on the waiting card (or the moon button
396
+ again, or `POST /sleep {"action":"cancel"}`) clears the intent for good: `armed` becomes
397
+ `false` and `armedAt` becomes `null`.
398
+
279
399
  **Where the button is.** The moon button sits at the **very bottom of the left sidebar, right next
280
400
  to the Settings row** (`sidebar.footer.action`). That seat is a **root** slot, so the button is
281
401
  there even when no conversation is open — on any page. When the sidebar is collapsed into its narrow
package/client.js CHANGED
@@ -114,20 +114,33 @@ const SLEEP_DEFAULT_COUNTDOWN_SECONDS = 20
114
114
  /** How long the note of a finished rehearsal stays on screen (ms). */
115
115
  const SLEEP_DRY_RUN_NOTICE_MS = 8_000
116
116
 
117
+ /**
118
+ * The grace window the settings page offers (a dropdown, in seconds).
119
+ *
120
+ * It is the extra wait after the last conversation finished, so that work
121
+ * other plugins queue with a timer (a memory plugin writing its notes, a
122
+ * summary) still gets its chance — this plugin cannot see that work.
123
+ */
124
+ const GRACE_CHOICES_SECONDS = Object.freeze([0, 3, 5, 10, 20, 30])
125
+
126
+ /** The grace window used when the Host did not report one (ms / seconds). */
127
+ const GRACE_DEFAULT_MS = 10_000
128
+ const GRACE_DEFAULT_SECONDS = 10
129
+
117
130
  const ZH = {
118
131
  label: '待重启',
119
- busyPrefix: ' · 被 ',
120
- busySuffix: ' 个会话占用',
132
+ busyPrefix: ' · 等待 ',
133
+ busySuffix: ' 个任务结束',
121
134
  idle: ' · 空闲后自动重启',
122
- goalActive: ' · 被某个会话自动续跑的 goal 占着',
123
- goalExhausted: ' · 某个会话的 goal 轮次额度已用完,无法续跑',
135
+ goalActive: ' · 被某个自动续跑的 goal 占着',
136
+ goalExhausted: ' · 某个任务的 goal 轮次额度已用完,无法续跑',
124
137
  parkedGoals: ' · 已停泊 {n} 个 goal,重启后自动继续',
125
138
  parkButton: '停泊并重启',
126
139
  parkButtonAria: '停泊该会话的 goal,并立即请求重启',
127
140
  parkBusy: '停泊中…',
128
141
  parkError: '停泊失败:{message}',
129
142
  tooltip: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度',
130
- tooltipBusy: '等待其他会话空闲后自动重启(计数含“排队但还没开始”的任务);重启后本会话会自动继续进度',
143
+ tooltipBusy: '等待其他任务结束(计数含“排队但还没开始”的任务)后自动重启;重启后本会话会自动继续进度',
131
144
  tooltipWithError: '等待其他会话空闲后自动重启;重启后本会话会自动继续进度。{message}',
132
145
  dotAria: '待重启',
133
146
  panelHeading: '待重启',
@@ -136,7 +149,13 @@ const ZH = {
136
149
  panelStateGoalActive: '等待其他会话停泊它自动续跑的 goal',
137
150
  panelStateGoalExhausted: '等待其他会话的 goal(轮次额度已用完)',
138
151
  panelBlockedTitle: '占用会话:{title}',
139
- panelBlockedGeneric: '有一个会话正在运行',
152
+ panelBlockedGeneric: '有一个任务正在运行',
153
+ // One line per blocker: a session and a subagent are told apart, and
154
+ // neither of them is ever shown as a raw session id.
155
+ panelBlockedSession: '会话「{label}」正在运行',
156
+ panelBlockedSubagent: '{name}正在运行',
157
+ panelBlockedSubagentGeneric: '有一个子智能体正在运行',
158
+ panelBlockedRestartPending: '等这次重启完成后再关机',
140
159
  panelGoalActiveLine: '占用会话「{title}」:goal 正在自动续跑(goal-active)',
141
160
  panelGoalExhaustedLine: '占用会话「{title}」:goal 轮次额度已用完,无法继续(goal-exhausted)',
142
161
  panelParkedTitle: '已停泊的 goal(重启后自动继续):',
@@ -177,6 +196,10 @@ const ZH = {
177
196
  'settings.seconds.immediate': '不倒数(立即关机)',
178
197
  'settings.seconds.value': '{n} 秒',
179
198
  'settings.seconds.hint': '按下按钮后,等所有会话收工,再倒数这么多秒才关机;「不倒数」表示会话一收工就关机。',
199
+ 'settings.grace.label': '收工后再等几秒(给记忆/总结类插件留时间)',
200
+ 'settings.grace.hint': '最后一个会话收工后,再等这么久才真正重启或关机——我们只看得见 DSH 的会话,看不见别的插件用定时器排队的活。等待期间如果又有会话开始干活,会重新计时。',
201
+ 'settings.grace.value': '{n} 秒',
202
+ 'settings.grace.immediate': '不等(立即重启/关机)',
180
203
  'settings.backup.label': '重启前备份',
181
204
  'settings.backup.hint': '每次重启前先快照一次当前配置;万一重启后起不来,能自动回退。',
182
205
  'settings.backupPlugins.label': '备份包含已安装插件',
@@ -212,7 +235,8 @@ const ZH = {
212
235
  'sleep.countdown.hint': '再过 {n} 秒就关机,点「算了,别关」可以留下。',
213
236
  'sleep.waiting.title': '还在等大家收工',
214
237
  'sleep.waiting.body': '等这些会话忙完,就轮到关机啦:',
215
- 'sleep.waiting.count': '还有 {n} 个会话在干活',
238
+ 'sleep.waiting.count': '还有 {n} 个任务在干活',
239
+ 'sleep.waiting.subagents': '其中 {k} 个是子智能体',
216
240
  'sleep.waiting.blocker': '· {title}',
217
241
  'sleep.waiting.none': '(正在确认还有谁在忙)',
218
242
  'sleep.waiting.cancel': '不关了',
@@ -246,8 +270,8 @@ const ZH = {
246
270
 
247
271
  const EN = {
248
272
  label: 'Pending restart',
249
- busyPrefix: ' · blocked by ',
250
- busySuffix: ' session(s)',
273
+ busyPrefix: ' · waiting for ',
274
+ busySuffix: ' task(s) to finish',
251
275
  idle: ' · restarts once idle',
252
276
  goalActive: ' · held by a self-continuing goal',
253
277
  goalExhausted: ' · a goal ran out of rounds',
@@ -259,7 +283,7 @@ const EN = {
259
283
  tooltip:
260
284
  'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart.',
261
285
  tooltipBusy:
262
- 'Waits for the other sessions to become idle, then restarts automatically (the count includes work that is queued but not started yet); this session resumes its progress after the restart.',
286
+ 'Waits for the other tasks to finish (the count includes work that is queued but not started yet), then restarts automatically; this session resumes its progress after the restart.',
263
287
  tooltipWithError:
264
288
  'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart. {message}',
265
289
  dotAria: 'Pending restart',
@@ -269,7 +293,13 @@ const EN = {
269
293
  panelStateGoalActive: 'Waiting for the other sessions to park their self-continuing goal',
270
294
  panelStateGoalExhausted: 'Waiting for the other sessions\' goal (its round budget is used up)',
271
295
  panelBlockedTitle: 'Blocking session: {title}',
272
- panelBlockedGeneric: 'One session is running',
296
+ panelBlockedGeneric: 'One task is running',
297
+ // One line per blocker: a session and a subagent are told apart, and
298
+ // neither of them is ever shown as a raw session id.
299
+ panelBlockedSession: 'Session "{label}" is running',
300
+ panelBlockedSubagent: '{name} is running',
301
+ panelBlockedSubagentGeneric: 'One subagent is running',
302
+ panelBlockedRestartPending: 'Waiting for the pending restart to finish before shutting down',
273
303
  panelGoalActiveLine: 'Blocking session "{title}": the goal keeps driving rounds by itself (goal-active)',
274
304
  panelGoalExhaustedLine: 'Blocking session "{title}": the goal ran out of rounds and cannot continue (goal-exhausted)',
275
305
  panelParkedTitle: 'Parked goals (they continue after the restart):',
@@ -310,6 +340,10 @@ const EN = {
310
340
  'settings.seconds.immediate': 'No countdown (switch off right away)',
311
341
  'settings.seconds.value': '{n} seconds',
312
342
  'settings.seconds.hint': 'After the button: wait for every conversation to be done, count down this many seconds and switch off. "No countdown" switches off as soon as the conversations are done.',
343
+ 'settings.grace.label': 'Wait a few more seconds after the work is done',
344
+ 'settings.grace.hint': 'After the last conversation finished, wait this long before the host is really restarted or switched off — this plugin only sees the DSH conversations, not the work other plugins queue with a timer. If a conversation starts working again while waiting, the timer starts over.',
345
+ 'settings.grace.value': '{n} seconds',
346
+ 'settings.grace.immediate': 'Do not wait (restart/switch off right away)',
313
347
  'settings.backup.label': 'Backup before a restart',
314
348
  'settings.backup.hint': 'Take a snapshot of the current configuration before every restart, so a host that cannot boot again is rolled back automatically.',
315
349
  'settings.backupPlugins.label': 'Include the installed plugins in the backup',
@@ -345,7 +379,8 @@ const EN = {
345
379
  'sleep.countdown.hint': 'Powering off in {n} seconds — pick "Never mind, keep it on" to stay.',
346
380
  'sleep.waiting.title': 'Waiting for everyone to finish',
347
381
  'sleep.waiting.body': 'Once these sessions are done, the machine powers off:',
348
- 'sleep.waiting.count': '{n} session(s) still working',
382
+ 'sleep.waiting.count': '{n} task(s) still working',
383
+ 'sleep.waiting.subagents': '{k} of them are subagents',
349
384
  'sleep.waiting.blocker': '· {title}',
350
385
  'sleep.waiting.none': '(checking who is still busy)',
351
386
  'sleep.waiting.cancel': 'Keep it on',
@@ -731,6 +766,53 @@ if (moduleLoader) moduleLoader.load({
731
766
  ))
732
767
  }
733
768
 
769
+ /**
770
+ * The line of one blocker: a session and a subagent are told apart, and
771
+ * neither of them is ever shown as a raw session id.
772
+ *
773
+ * The Host says WHAT the blocker is (`kind`) and HOW it is called: a
774
+ * session by its own title (`label`), a subagent by the whole phrase
775
+ * (`name`: "写代码的会话的子智能体"). The phrase is used as it is — it
776
+ * reads as a sentence, while a bracketed label would read like an
777
+ * annotation. A subagent whose parent title is unknown has no name of its
778
+ * own, so it gets the generic line instead of an empty bracket.
779
+ *
780
+ * @param {any} entry the blocker the Host reported
781
+ * @returns {string}
782
+ */
783
+ function blockerLine(entry) {
784
+ const t = translate ?? ((name) => name)
785
+ const kind = entry && entry.kind === 'subagent'
786
+ ? 'subagent'
787
+ : (entry && entry.kind === 'restart-pending' ? 'restart-pending' : 'session')
788
+ const label = entry && typeof entry.label === 'string' ? entry.label.trim() : ''
789
+ if (kind === 'restart-pending') {
790
+ // A pending restart goes first: the shutdown waits for it.
791
+ return t('panelBlockedRestartPending')
792
+ }
793
+ if (kind === 'subagent') {
794
+ const name = entry && typeof entry.name === 'string' ? entry.name.trim() : ''
795
+ if (name === '' || label === '') return t('panelBlockedSubagentGeneric')
796
+ return fill(t('panelBlockedSubagent'), { name })
797
+ }
798
+ return label === ''
799
+ ? t('panelBlockedGeneric')
800
+ : fill(t('panelBlockedSession'), { label })
801
+ }
802
+
803
+ /**
804
+ * Whether a blocker is a pending RESTART (and not a task that is running).
805
+ *
806
+ * The restart is not a task: it is the thing the shutdown waits for, so it
807
+ * is never counted as one.
808
+ *
809
+ * @param {any} entry
810
+ * @returns {boolean}
811
+ */
812
+ function isRestartPending(entry) {
813
+ return Boolean(entry) && entry.kind === 'restart-pending'
814
+ }
815
+
734
816
  /** How long the pending restart has been waiting, as a locale string. */
735
817
  function formatWaiting(ms, t) {
736
818
  const totalSeconds = Math.max(0, Math.floor((ms || 0) / 1000))
@@ -2296,6 +2378,15 @@ if (moduleLoader) moduleLoader.load({
2296
2378
  const backupIncludePlugins = status && typeof status === 'object' && typeof status.backupIncludePlugins === 'boolean'
2297
2379
  ? status.backupIncludePlugins
2298
2380
  : true
2381
+ // The grace window: how long the host waits after the last conversation
2382
+ // finished. The Host reports it in milliseconds; the dropdown works in
2383
+ // seconds.
2384
+ const reportedGraceMs = status && typeof status === 'object' && typeof status.graceMs === 'number'
2385
+ ? status.graceMs
2386
+ : GRACE_DEFAULT_MS
2387
+ const graceSeconds = GRACE_CHOICES_SECONDS.indexOf(Math.round(reportedGraceMs / 1000)) === -1
2388
+ ? GRACE_DEFAULT_SECONDS
2389
+ : Math.round(reportedGraceMs / 1000)
2299
2390
 
2300
2391
  /**
2301
2392
  * Write one change into the profile configuration.
@@ -2363,6 +2454,33 @@ if (moduleLoader) moduleLoader.load({
2363
2454
  ),
2364
2455
  h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.seconds.hint')),
2365
2456
  ),
2457
+ h(
2458
+ 'label',
2459
+ { className: 'dsh-autorestart-settings__field' },
2460
+ h('span', { className: 'dsh-autorestart-settings__label' }, t('settings.grace.label')),
2461
+ h(
2462
+ 'select',
2463
+ {
2464
+ className: 'dsh-autorestart-settings__select',
2465
+ value: String(graceSeconds),
2466
+ disabled: busy,
2467
+ 'data-dsh-autorestart-setting': 'restartGraceMs',
2468
+ onChange: (event) => {
2469
+ const value = Number(event && event.target ? event.target.value : NaN)
2470
+ if (!Number.isFinite(value) || value === graceSeconds) return
2471
+ void save({ restartGraceMs: value * 1000 })
2472
+ },
2473
+ },
2474
+ GRACE_CHOICES_SECONDS.map((choice) => h(
2475
+ 'option',
2476
+ { key: String(choice), value: String(choice) },
2477
+ choice === 0
2478
+ ? t('settings.grace.immediate')
2479
+ : fill(t('settings.grace.value'), { n: choice }),
2480
+ )),
2481
+ ),
2482
+ h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.grace.hint')),
2483
+ ),
2366
2484
  h(
2367
2485
  'label',
2368
2486
  { className: 'dsh-autorestart-settings__field' },
@@ -2873,6 +2991,14 @@ if (moduleLoader) moduleLoader.load({
2873
2991
  if (phase === 'waiting') {
2874
2992
  ensureSleepStyle()
2875
2993
  const blockers = status && Array.isArray(status.blockers) ? status.blockers : []
2994
+ // A pending restart is not a task: it is what the shutdown waits for,
2995
+ // so it is never counted as one.
2996
+ const tasks = blockers.filter((entry) => !isRestartPending(entry))
2997
+ // How many of the blockers are subagents: the card says so, so the
2998
+ // count is not read as "that many conversations".
2999
+ const subagentCount = tasks.filter(
3000
+ (entry) => entry && entry.kind === 'subagent',
3001
+ ).length
2876
3002
  return h(
2877
3003
  'div',
2878
3004
  {
@@ -2886,23 +3012,35 @@ if (moduleLoader) moduleLoader.load({
2886
3012
  h('p', { className: 'dsh-autorestart-sleep-waiting__title' }, t('sleep.waiting.title')),
2887
3013
  h('p', { className: 'dsh-autorestart-sleep-waiting__body' }, t('sleep.waiting.body')),
2888
3014
  blockers.length === 0
2889
- ? h('p', { className: 'dsh-autorestart-sleep-waiting__none' }, t('sleep.waiting.none'))
3015
+ ? h(
3016
+ 'p',
3017
+ { className: 'dsh-autorestart-sleep-waiting__none' },
3018
+ // The Host's own message explains WHY the machine waits (the
3019
+ // grace window for work this plugin cannot see); the generic
3020
+ // line is only the fallback.
3021
+ typeof status.message === 'string' && status.message !== ''
3022
+ ? status.message
3023
+ : t('sleep.waiting.none'),
3024
+ )
2890
3025
  : h(
2891
3026
  'ul',
2892
3027
  { className: 'dsh-autorestart-sleep-waiting__list' },
2893
3028
  blockers.map((entry, index) => h(
2894
3029
  'li',
2895
3030
  { key: 'blocker-' + index },
2896
- fill(t('sleep.waiting.blocker'), {
2897
- title: entry && typeof entry.title === 'string' && entry.title !== ''
2898
- ? entry.title
2899
- : t('panelBlockedGeneric'),
2900
- }),
3031
+ blockerLine(entry),
2901
3032
  )),
2902
3033
  ),
2903
- h('p', { className: 'dsh-autorestart-sleep-waiting__count' }, blockers.length === 0
3034
+ h('p', { className: 'dsh-autorestart-sleep-waiting__count' }, tasks.length === 0
3035
+ ? null
3036
+ : fill(t('sleep.waiting.count'), { n: tasks.length })),
3037
+ subagentCount === 0
2904
3038
  ? null
2905
- : fill(t('sleep.waiting.count'), { n: blockers.length })),
3039
+ : h(
3040
+ 'p',
3041
+ { className: 'dsh-autorestart-sleep-waiting__subagents' },
3042
+ fill(t('sleep.waiting.subagents'), { k: subagentCount }),
3043
+ ),
2906
3044
  errorLine(),
2907
3045
  h(
2908
3046
  'button',
package/cordis.patch.yml CHANGED
@@ -25,6 +25,14 @@
25
25
  relaunchDelaySeconds: 2
26
26
  # quiet window (ms) after the last conversation activity before a restart may fire
27
27
  settleMs: 2000
28
+ # Grace window (ms): the extra wait after the LAST conversation finished,
29
+ # before the host is really restarted or switched off. This plugin can
30
+ # only see the DSH agents — other plugins (a memory plugin that writes
31
+ # its notes a few seconds later, a summary, an MCP callback) queue
32
+ # their work with a timer of their own, and that work is invisible
33
+ # here. A DROPDOWN in the settings page: 0 / 3 / 5 / 10 / 20 / 30
34
+ # seconds. `0` means "no extra wait" (the behaviour of 0.4.0).
35
+ restartGraceMs: 10000
28
36
  # dsh web: reload the page when the host boot token changes
29
37
  autoReloadPage: true
30
38
  # hard-block shell commands that would kill/relaunch DSH by hand