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 +129 -9
- package/client.js +158 -20
- package/cordis.patch.yml +8 -0
- package/docs/README.zh.md +69 -8
- package/index.js +528 -41
- package/lib/checkpoint.js +78 -0
- package/lib/semver.js +49 -0
- package/lib/sleep.js +217 -33
- package/locale/en.json +8 -1
- package/locale/zh.json +8 -1
- package/package.json +1 -1
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
|
-
|
|
34
|
-
|
|
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.
|
|
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
|
-
- **
|
|
259
|
-
|
|
260
|
-
|
|
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: ' ·
|
|
123
|
-
goalExhausted: ' ·
|
|
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: ' ·
|
|
250
|
-
busySuffix: '
|
|
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
|
|
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
|
|
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}
|
|
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(
|
|
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
|
-
|
|
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' },
|
|
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
|
-
:
|
|
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
|