dsh-autorestart 0.3.6 → 0.6.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.
package/index.js CHANGED
@@ -24,8 +24,10 @@ import os from 'node:os'
24
24
  import path from 'node:path'
25
25
 
26
26
  import {
27
+ armSleep,
27
28
  beginBoot,
28
29
  cancelRestart,
30
+ clearSleep,
29
31
  disarmedRecord,
30
32
  entryFor,
31
33
  handoffRecord,
@@ -73,6 +75,112 @@ import {
73
75
  runCommandNow,
74
76
  scheduleDetachedRestart,
75
77
  } from './lib/restart.js'
78
+ import {
79
+ createSleepController,
80
+ COUNTDOWN_CHOICES,
81
+ DEFAULT_COUNTDOWN_SECONDS,
82
+ detectShutdownPlan,
83
+ planInvocation,
84
+ runShutdown,
85
+ snapCountdownChoice,
86
+ } from './lib/sleep.js'
87
+
88
+ /**
89
+ * The grace window: how long the host waits after the LAST conversation
90
+ * finished before it is really restarted or switched off.
91
+ *
92
+ * This plugin can only see the DSH agents. Other plugins (a memory plugin
93
+ * that writes its notes a few seconds after the conversation ended, a summary,
94
+ * an MCP callback) queue their work with a timer of their own — work that is
95
+ * invisible here. A short, configurable grace window is left for it.
96
+ *
97
+ * The values are the ones the settings page offers (in milliseconds).
98
+ */
99
+ const GRACE_CHOICES_MS = Object.freeze([0, 3000, 5000, 10000, 20000, 30000])
100
+
101
+ /** The grace window that is used when nothing was configured. */
102
+ const DEFAULT_GRACE_MS = 10000
103
+
104
+ /**
105
+ * Snap a grace value onto the nearest offered choice.
106
+ *
107
+ * A profile that carries a value that is not offered (a hand-edited row, an
108
+ * older build) must not break the boot: the value is snapped onto the
109
+ * nearest choice, exactly like the countdown length is.
110
+ *
111
+ * @param {unknown} value
112
+ * @returns {number} one of {@link GRACE_CHOICES_MS}
113
+ */
114
+ function snapGraceChoice(value) {
115
+ const raw = Number(value)
116
+ if (!Number.isFinite(raw) || raw < 0) return DEFAULT_GRACE_MS
117
+ let best = GRACE_CHOICES_MS[0]
118
+ let bestDistance = Math.abs(raw - best)
119
+ for (const choice of GRACE_CHOICES_MS) {
120
+ const distance = Math.abs(raw - choice)
121
+ if (distance < bestDistance) {
122
+ best = choice
123
+ bestDistance = distance
124
+ }
125
+ }
126
+ return best
127
+ }
128
+
129
+ /**
130
+ * The version of this plugin, as a fallback.
131
+ *
132
+ * The real version is read from the plugin's own package.json (see
133
+ * `pluginVersion()`), but a bundled installation may not ship that file — so
134
+ * the constant below is the last resort. A test keeps the two in sync.
135
+ */
136
+ const PLUGIN_VERSION_FALLBACK = '0.6.0'
137
+
138
+ /** The cached version of this plugin (read once per process). */
139
+ let pluginVersionCache = null
140
+
141
+ /**
142
+ * The version of this plugin.
143
+ *
144
+ * It is the version of the package itself: the feature announcements are
145
+ * bound to it, so a new version of the plugin can announce a new feature (and
146
+ * the announcement of an old version never comes back).
147
+ *
148
+ * @returns {string}
149
+ */
150
+ function pluginVersion() {
151
+ if (pluginVersionCache !== null) return pluginVersionCache
152
+ let version = PLUGIN_VERSION_FALLBACK
153
+ try {
154
+ const raw = fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')
155
+ const parsed = JSON.parse(raw)
156
+ if (parsed && typeof parsed.version === 'string' && parsed.version.trim() !== '') {
157
+ version = parsed.version.trim()
158
+ }
159
+ } catch {
160
+ // The package.json is not readable (a bundled build): the fallback is the
161
+ // version that was shipped with this build.
162
+ }
163
+ pluginVersionCache = version
164
+ return version
165
+ }
166
+
167
+ /**
168
+ * The features that are announced ONCE, after an upgrade.
169
+ *
170
+ * The install prompt only ever reaches a fresh installation, so an
171
+ * installation that already existed would never learn about a new feature.
172
+ * Every entry of this list is therefore announced once per version: the
173
+ * announcement is shown until the user presses "知道了", and the Host records
174
+ * the version it has shown, so it never comes back.
175
+ *
176
+ * @returns {{ feature: string, version: string }[]}
177
+ */
178
+ function featureAnnouncements() {
179
+ return [
180
+ // The graceful shutdown ("收工关机") and its moon button.
181
+ { feature: 'sleep', version: pluginVersion() },
182
+ ]
183
+ }
76
184
 
77
185
  /**
78
186
  * `@deepseek-ai/schemastery` resolves inside modules that live in the dsh installation,
@@ -216,6 +324,12 @@ function buildConfigSchema() {
216
324
  // quiet window that must pass after the last conversation activity
217
325
  settleMs: schemastery.number().min(0).default(2000)
218
326
  .description('Milliseconds the host must stay quiet after the last conversation activity before a restart may fire.'),
327
+ // The grace window: the extra wait after the last conversation finished.
328
+ // Other plugins queue work with timers of their own (a memory plugin
329
+ // writing its notes, a summary, an MCP callback) and that work is
330
+ // invisible here — so a short, configurable grace window is left for it.
331
+ restartGraceMs: schemastery.number().min(0).default(DEFAULT_GRACE_MS)
332
+ .description('Milliseconds to wait after the last conversation finished before the host is really restarted or switched off: the grace window for work other plugins queue with a timer (0 / 3 / 5 / 10 / 20 / 30 seconds, default 10).'),
219
333
  autoReloadPage: schemastery.boolean().default(true)
220
334
  .description('Reload the web page automatically when the host boot token changes.'),
221
335
  blockManualRestart: schemastery.boolean().default(true)
@@ -241,6 +355,20 @@ function buildConfigSchema() {
241
355
  .description('How many snapshots are kept; older ones are deleted automatically.'),
242
356
  rollbackTimeoutMs: schemastery.number().min(1000).default(90000)
243
357
  .description('Milliseconds the detached watcher waits for the relaunched host before it restores the snapshot.'),
358
+ // graceful shutdown ("收工关机"): wait for every conversation, count down, then
359
+ // switch the REAL host machine off (a WSL host shuts Windows down, not the distro)
360
+ showSleepButton: schemastery.boolean().default(true)
361
+ .description('是否在左侧栏最底部、设置按钮旁边显示「收工关机」按钮:关掉之后界面上不再有这个按钮,关机接口也不再被界面调用。'),
362
+ // The countdown length is a DROPDOWN: a union of the offered choices
363
+ // projects into an enum (the settings page renders a select, not a
364
+ // number field). `loose()` keeps an old profile value from breaking
365
+ // the boot — the value is snapped onto a choice in normalizeConfig.
366
+ sleepCountdownSeconds: schemastery.union(COUNTDOWN_CHOICES.map((value) => schemastery.const(value)))
367
+ .default(DEFAULT_COUNTDOWN_SECONDS)
368
+ .loose()
369
+ .description('收工关机的倒计时秒数(下拉框:0 / 10 / 20 / 30 / 60 秒,默认 20;0 表示不倒数、立即关机,但仍会先等所有会话收工)。'),
370
+ sleepCommand: schemastery.string().default('')
371
+ .description('自定义关机命令:留空表示按系统自动识别(Windows / WSL / Linux / macOS),填了就用这条命令代替自动识别。'),
244
372
  })
245
373
  } catch (error) {
246
374
  schemasterySource = `construction-failed: ${String(error)}`
@@ -268,6 +396,7 @@ const DEFAULTS = {
268
396
  restartCommand: '',
269
397
  relaunchDelaySeconds: 2,
270
398
  settleMs: 2000,
399
+ restartGraceMs: DEFAULT_GRACE_MS,
271
400
  autoReloadPage: true,
272
401
  blockManualRestart: true,
273
402
  healthPath: '/__dsh_autorestart/health',
@@ -280,6 +409,12 @@ const DEFAULTS = {
280
409
  backupIncludePlugins: true,
281
410
  maxBackups: 2,
282
411
  rollbackTimeoutMs: 90000,
412
+ // Graceful shutdown ("收工关机"): the button at the foot of the left sidebar,
413
+ // the countdown before the machine is switched off and the optional custom
414
+ // shutdown command (empty = the host is detected automatically).
415
+ showSleepButton: true,
416
+ sleepCountdownSeconds: DEFAULT_COUNTDOWN_SECONDS,
417
+ sleepCommand: '',
283
418
  }
284
419
 
285
420
  /** Merge a loader row config over {@link DEFAULTS}, dropping wrong-typed values. */
@@ -304,6 +439,15 @@ function normalizeConfig(raw) {
304
439
  const rollbackTimeout = Number(config.rollbackTimeoutMs)
305
440
  config.rollbackTimeoutMs =
306
441
  Number.isFinite(rollbackTimeout) && rollbackTimeout >= 1000 ? Math.trunc(rollbackTimeout) : 90000
442
+ // The countdown of the graceful shutdown is one of the offered choices: a
443
+ // profile that still carries an old value (15, 45, …) is snapped onto the
444
+ // nearest one instead of breaking, and 0 means "no countdown at all".
445
+ config.sleepCountdownSeconds = snapCountdownChoice(config.sleepCountdownSeconds)
446
+ // The grace window is one of the offered choices as well: a hand-edited
447
+ // profile value is snapped onto the nearest one instead of breaking.
448
+ config.restartGraceMs = snapGraceChoice(config.restartGraceMs)
449
+ if (typeof config.sleepCommand !== 'string') config.sleepCommand = ''
450
+ if (typeof config.showSleepButton !== 'boolean') config.showSleepButton = true
307
451
  return config
308
452
  }
309
453
 
@@ -370,6 +514,36 @@ function resolveChoicePath(config) {
370
514
  return `${prefix}/choice`
371
515
  }
372
516
 
517
+ /**
518
+ * Resolve the sleep-route path: a sibling of the health route, under the same
519
+ * prefix (`GET /sleep` reports the state of the graceful sleep, `POST /sleep`
520
+ * arms or cancels it).
521
+ *
522
+ * @param {object} config
523
+ * @returns {string}
524
+ */
525
+ function resolveSleepPath(config) {
526
+ const healthPath = resolveHealthPath(config)
527
+ const index = healthPath.lastIndexOf('/')
528
+ const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
529
+ return `${prefix}/sleep`
530
+ }
531
+
532
+ /**
533
+ * Resolve the announcements-route path: a sibling of the health route, under
534
+ * the same prefix (`GET /announcements` reports the feature announcements the
535
+ * user has not seen yet, `POST /announcements` records that it saw one).
536
+ *
537
+ * @param {object} config
538
+ * @returns {string}
539
+ */
540
+ function resolveAnnouncementsPath(config) {
541
+ const healthPath = resolveHealthPath(config)
542
+ const index = healthPath.lastIndexOf('/')
543
+ const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
544
+ return `${prefix}/announcements`
545
+ }
546
+
373
547
  /**
374
548
  * The blocking sessions with the reason each one blocks: a session that is
375
549
  * held by an armed goal reports `goal-active` (that goal can be disarmed), a
@@ -552,14 +726,22 @@ const INSTALL_CHOICE_FILE = 'install-choice.json'
552
726
  * Read the recorded install choice from the fallback file.
553
727
  *
554
728
  * @param {string} stateDir
555
- * @returns {boolean|null} the recorded value, or null when there is none
729
+ * @returns {{ includePlugins: boolean|null, backupOnRestart: boolean|null, showSleepButton: boolean|null, sleepCountdownSeconds: number|null, restartGraceMs: number|null }|null}
556
730
  */
557
731
  function readInstallChoice(stateDir) {
558
732
  try {
559
733
  const raw = fs.readFileSync(path.join(stateDir, INSTALL_CHOICE_FILE), 'utf8')
560
734
  const parsed = JSON.parse(raw)
561
735
  if (!parsed || typeof parsed !== 'object') return null
562
- return typeof parsed.includePlugins === 'boolean' ? parsed.includePlugins : null
736
+ return {
737
+ includePlugins: typeof parsed.includePlugins === 'boolean' ? parsed.includePlugins : null,
738
+ backupOnRestart: typeof parsed.backupOnRestart === 'boolean' ? parsed.backupOnRestart : null,
739
+ showSleepButton: typeof parsed.showSleepButton === 'boolean' ? parsed.showSleepButton : null,
740
+ sleepCountdownSeconds: typeof parsed.sleepCountdownSeconds === 'number'
741
+ ? parsed.sleepCountdownSeconds
742
+ : null,
743
+ restartGraceMs: typeof parsed.restartGraceMs === 'number' ? parsed.restartGraceMs : null,
744
+ }
563
745
  } catch {
564
746
  return null
565
747
  }
@@ -568,18 +750,36 @@ function readInstallChoice(stateDir) {
568
750
  /**
569
751
  * Write the install choice into the fallback file.
570
752
  *
753
+ * The file is written ADDITIVELY: a choice about the sleep button must not
754
+ * erase the recorded backup scope (and the other way round).
755
+ *
571
756
  * @param {string} stateDir
572
- * @param {boolean} includePlugins
757
+ * @param {{ includePlugins?: boolean, backupOnRestart?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number, restartGraceMs?: number }} values
573
758
  * @returns {string} the file that was written
574
759
  */
575
- function writeInstallChoice(stateDir, includePlugins) {
760
+ function writeInstallChoice(stateDir, values) {
576
761
  const file = path.join(stateDir, INSTALL_CHOICE_FILE)
762
+ const current = readInstallChoice(stateDir) || {
763
+ includePlugins: null,
764
+ backupOnRestart: null,
765
+ showSleepButton: null,
766
+ sleepCountdownSeconds: null,
767
+ restartGraceMs: null,
768
+ }
769
+ const payload = {
770
+ includePlugins: typeof values.includePlugins === 'boolean' ? values.includePlugins : current.includePlugins,
771
+ backupOnRestart: typeof values.backupOnRestart === 'boolean' ? values.backupOnRestart : current.backupOnRestart,
772
+ showSleepButton: typeof values.showSleepButton === 'boolean' ? values.showSleepButton : current.showSleepButton,
773
+ sleepCountdownSeconds: typeof values.sleepCountdownSeconds === 'number'
774
+ ? values.sleepCountdownSeconds
775
+ : current.sleepCountdownSeconds,
776
+ restartGraceMs: typeof values.restartGraceMs === 'number'
777
+ ? values.restartGraceMs
778
+ : current.restartGraceMs,
779
+ at: new Date().toISOString(),
780
+ }
577
781
  fs.mkdirSync(stateDir, { recursive: true })
578
- fs.writeFileSync(
579
- file,
580
- JSON.stringify({ includePlugins: includePlugins === true, at: new Date().toISOString() }, null, 2),
581
- 'utf8',
582
- )
782
+ fs.writeFileSync(file, JSON.stringify(payload, null, 2), 'utf8')
583
783
  return file
584
784
  }
585
785
 
@@ -602,10 +802,60 @@ export function resolveBackupIncludePlugins(rawConfig, stateDir) {
602
802
  return { includePlugins: rawConfig.backupIncludePlugins, source: 'config' }
603
803
  }
604
804
  const fallback = typeof stateDir === 'string' && stateDir !== '' ? readInstallChoice(stateDir) : null
605
- if (fallback !== null) return { includePlugins: fallback, source: 'fallback-file' }
805
+ if (fallback !== null && fallback.includePlugins !== null) {
806
+ return { includePlugins: fallback.includePlugins, source: 'fallback-file' }
807
+ }
606
808
  return { includePlugins: DEFAULTS.backupIncludePlugins, source: 'default' }
607
809
  }
608
810
 
811
+ /**
812
+ * Resolve the effective `backupOnRestart` value and where it came from.
813
+ *
814
+ * The same rule as the backup scope: the profile row config wins, the degraded
815
+ * fallback file fills the gap, and the built-in default applies when neither
816
+ * says anything.
817
+ *
818
+ * @param {object|null|undefined} rawConfig the loader row config, as received
819
+ * @param {string} stateDir
820
+ * @returns {{ backupOnRestart: boolean, source: 'config'|'fallback-file'|'default' }}
821
+ */
822
+ export function resolveBackupOnRestart(rawConfig, stateDir) {
823
+ if (rawConfig && typeof rawConfig === 'object'
824
+ && Object.prototype.hasOwnProperty.call(rawConfig, 'backupOnRestart')
825
+ && typeof rawConfig.backupOnRestart === 'boolean') {
826
+ return { backupOnRestart: rawConfig.backupOnRestart, source: 'config' }
827
+ }
828
+ const fallback = typeof stateDir === 'string' && stateDir !== '' ? readInstallChoice(stateDir) : null
829
+ if (fallback !== null && fallback.backupOnRestart !== null) {
830
+ return { backupOnRestart: fallback.backupOnRestart, source: 'fallback-file' }
831
+ }
832
+ return { backupOnRestart: DEFAULTS.backupOnRestart, source: 'default' }
833
+ }
834
+
835
+ /**
836
+ * Resolve the effective `showSleepButton` value and where it came from.
837
+ *
838
+ * The same rule as the backup scope: the profile row config wins, the degraded
839
+ * fallback file fills the gap, and the built-in default applies when neither
840
+ * says anything.
841
+ *
842
+ * @param {object|null|undefined} rawConfig the loader row config, as received
843
+ * @param {string} stateDir
844
+ * @returns {{ showSleepButton: boolean, source: 'config'|'fallback-file'|'default' }}
845
+ */
846
+ export function resolveShowSleepButton(rawConfig, stateDir) {
847
+ if (rawConfig && typeof rawConfig === 'object'
848
+ && Object.prototype.hasOwnProperty.call(rawConfig, 'showSleepButton')
849
+ && typeof rawConfig.showSleepButton === 'boolean') {
850
+ return { showSleepButton: rawConfig.showSleepButton, source: 'config' }
851
+ }
852
+ const fallback = typeof stateDir === 'string' && stateDir !== '' ? readInstallChoice(stateDir) : null
853
+ if (fallback !== null && fallback.showSleepButton !== null) {
854
+ return { showSleepButton: fallback.showSleepButton, source: 'fallback-file' }
855
+ }
856
+ return { showSleepButton: DEFAULTS.showSleepButton, source: 'default' }
857
+ }
858
+
609
859
  /**
610
860
  * Resolve the profile directory the running host was started from.
611
861
  *
@@ -951,9 +1201,89 @@ export function createRuntime(ctx, config, options = {}) {
951
1201
  return count
952
1202
  }
953
1203
 
1204
+ /**
1205
+ * Whether this agent is a subagent (a delegated child), and which session
1206
+ * created it.
1207
+ *
1208
+ * The origin is the ONLY reliable marker: a forked session also carries a
1209
+ * parent session, but it is a top-level conversation, not a subagent.
1210
+ *
1211
+ * @param {any} agent
1212
+ * @returns {string|null} the parent session id, or null when this is not a
1213
+ * subagent
1214
+ */
1215
+ const subagentParentOf = (agent) => {
1216
+ try {
1217
+ if (!agent || typeof agent !== 'object') return null
1218
+ const session = agent.session && typeof agent.session === 'object' ? agent.session : null
1219
+ const header = session && session.header && typeof session.header === 'object' ? session.header : null
1220
+ const origin = agent.origin != null ? agent.origin : (header ? header.origin : undefined)
1221
+ if (origin !== 'subagent') return null
1222
+ const parent = agent.parentId != null
1223
+ ? agent.parentId
1224
+ : agent.parentSessionId != null
1225
+ ? agent.parentSessionId
1226
+ : (header ? header.parentSession : undefined)
1227
+ if (parent == null || parent === '') return null
1228
+ return String(parent)
1229
+ } catch {
1230
+ return null
1231
+ }
1232
+ }
1233
+
1234
+ /**
1235
+ * The readable identity of one blocker: WHAT it is (a session or a
1236
+ * subagent) and HOW it is called.
1237
+ *
1238
+ * A subagent has no title of its own, so it is named after the session that
1239
+ * created it ("<that session>'s subagent"). A raw session id never reaches
1240
+ * the interface: an unknown name is a generic one.
1241
+ *
1242
+ * @param {string} sessionId
1243
+ * @param {any} agent
1244
+ * @returns {{ kind: 'session'|'subagent', label: string|null, name: string, parentTitle: string|null, parentSessionId: string|null }}
1245
+ */
1246
+ const describeBlocker = (sessionId, agent) => {
1247
+ const parent = agent ? subagentParentOf(agent) : null
1248
+ if (parent === null) {
1249
+ let title = null
1250
+ try { title = sessionTitle(sessionId) } catch { /* a missing title is not a problem */ }
1251
+ const label = title ? String(title) : null
1252
+ return {
1253
+ kind: 'session',
1254
+ // The label is the title of the session itself.
1255
+ label,
1256
+ // The full name of the blocker: its title — or NOTHING when it has no
1257
+ // title. A generic name ("会话") says nothing and would end up in the
1258
+ // interface as "会话「会话」" or as a list of three identical
1259
+ // entries; the client decides what to write when the name is empty.
1260
+ name: label || '',
1261
+ parentTitle: null,
1262
+ parentSessionId: null,
1263
+ }
1264
+ }
1265
+ let parentTitle = null
1266
+ try { parentTitle = sessionTitle(parent) } catch { /* a missing title is not a problem */ }
1267
+ const title = parentTitle ? String(parentTitle) : null
1268
+ return {
1269
+ kind: 'subagent',
1270
+ // The label is the title of the session that created it: the UI shows
1271
+ // "子智能体(<that title>)".
1272
+ label: title,
1273
+ // The full name of the blocker: "<that session>'s subagent".
1274
+ name: title ? `${title}的子智能体` : '子智能体',
1275
+ parentTitle: title,
1276
+ parentSessionId: parent,
1277
+ }
1278
+ }
1279
+
954
1280
  /**
955
1281
  * Every conversation that is still working, subagents and teammates included.
956
1282
  * 'running' is a live turn; 'queued' is inbox work that has not started yet.
1283
+ *
1284
+ * Every entry carries the kind of the blocker and a readable name, so the
1285
+ * interface can tell a session from a subagent and never has to show a raw
1286
+ * session id.
957
1287
  */
958
1288
  const busyAgents = () => {
959
1289
  const out = []
@@ -966,8 +1296,10 @@ export function createRuntime(ctx, config, options = {}) {
966
1296
  for (const agent of agents) {
967
1297
  try {
968
1298
  if (!agent || !agent.id) continue
969
- if (agent.status === 'running') out.push({ sessionId: String(agent.id), reason: 'running' })
970
- else if (pendingInboxCount(agent) > 0) out.push({ sessionId: String(agent.id), reason: 'queued' })
1299
+ const sessionId = String(agent.id)
1300
+ const described = describeBlocker(sessionId, agent)
1301
+ if (agent.status === 'running') out.push({ sessionId, reason: 'running', ...described })
1302
+ else if (pendingInboxCount(agent) > 0) out.push({ sessionId, reason: 'queued', ...described })
971
1303
  } catch {}
972
1304
  }
973
1305
  return out
@@ -1300,12 +1632,41 @@ export function createRuntime(ctx, config, options = {}) {
1300
1632
 
1301
1633
  const anyAgentRunning = () => busyAgents().length > 0
1302
1634
 
1303
- /** The two-layer restart gate: no busy conversation AND a quiet settle window. */
1635
+ /**
1636
+ * Whether a restart is still pending.
1637
+ *
1638
+ * A pending restart goes FIRST: the graceful shutdown waits for it — and for
1639
+ * the sessions that resume with it. The shutdown intent survives the
1640
+ * restart, so the machine goes off after everything is done.
1641
+ *
1642
+ * @returns {boolean}
1643
+ */
1644
+ const pendingRestart = () => state.restart !== null && state.restart !== undefined
1645
+
1646
+ /** How long the host has been quiet (no conversation activity at all). */
1647
+ const quietForMs = () => Math.max(0, Date.now() - lastActivityAt)
1648
+
1649
+ /**
1650
+ * The grace window: how much longer the host waits after the last
1651
+ * conversation finished, so that work other plugins queue with a timer (a
1652
+ * memory plugin writing its notes, a summary) still gets its chance.
1653
+ */
1654
+ const graceMs = () => config.restartGraceMs
1655
+
1656
+ /**
1657
+ * The quiet window the restart gate really uses.
1658
+ *
1659
+ * It is the settle window, but never shorter than the grace window: the
1660
+ * grace is the *extra* wait on top of the settle, not a replacement.
1661
+ */
1662
+ const effectiveSettleMs = () => Math.max(config.settleMs, config.restartGraceMs)
1663
+
1664
+ /** The two-layer restart gate: no busy conversation AND a quiet window. */
1304
1665
  const gate = () => restartGate(state, {
1305
1666
  busyAgents: busyAgents(),
1306
1667
  lastActivityAt,
1307
1668
  now: Date.now(),
1308
- settleMs: config.settleMs,
1669
+ settleMs: effectiveSettleMs(),
1309
1670
  })
1310
1671
 
1311
1672
  /**
@@ -1320,7 +1681,43 @@ export function createRuntime(ctx, config, options = {}) {
1320
1681
  */
1321
1682
  const titleCache = { at: 0, titles: new Map(), inFlight: false }
1322
1683
 
1323
- /** Store the titles of one session-list answer. Never throws. */
1684
+ /**
1685
+ * The title of one row of the session list.
1686
+ *
1687
+ * The title is a PROJECTION: the summary row carries it under
1688
+ * `projections.values.title` (the same place `dsh-session-reference` reads it
1689
+ * from), not under a `title` field of its own. A plain `title`/`name` is
1690
+ * accepted too, so a list with a different shape still works.
1691
+ *
1692
+ * @param {any} item one row of the session list
1693
+ * @returns {string} the title, or '' when the row carries none
1694
+ */
1695
+ const titleOfSummary = (item) => {
1696
+ try {
1697
+ if (!item || typeof item !== 'object') return ''
1698
+ const projections = item.projections && typeof item.projections === 'object'
1699
+ ? item.projections
1700
+ : null
1701
+ const values = projections && projections.values && typeof projections.values === 'object'
1702
+ ? projections.values
1703
+ : null
1704
+ const candidates = [
1705
+ values ? values.title : undefined,
1706
+ item.title,
1707
+ item.name,
1708
+ ]
1709
+ for (const candidate of candidates) {
1710
+ if (typeof candidate !== 'string') continue
1711
+ const title = candidate.trim()
1712
+ if (title !== '') return title
1713
+ }
1714
+ return ''
1715
+ } catch {
1716
+ return ''
1717
+ }
1718
+ }
1719
+
1720
+ /** Store the titles of one session-list answer. Never throws. */
1324
1721
  const applySessionTitles = (payload) => {
1325
1722
  const items = payload && Array.isArray(payload.items)
1326
1723
  ? payload.items
@@ -1328,9 +1725,13 @@ export function createRuntime(ctx, config, options = {}) {
1328
1725
  const titles = new Map()
1329
1726
  for (const item of items) {
1330
1727
  if (!item || typeof item !== 'object') continue
1331
- const id = item.id === undefined || item.id === null ? '' : String(item.id)
1728
+ // The session list names its rows `sessionId` (`id` is an older shape).
1729
+ const rawId = item.sessionId !== undefined && item.sessionId !== null
1730
+ ? item.sessionId
1731
+ : item.id
1732
+ const id = rawId === undefined || rawId === null ? '' : String(rawId)
1332
1733
  if (!id) continue
1333
- const title = typeof item.title === 'string' ? item.title.trim() : ''
1734
+ const title = titleOfSummary(item)
1334
1735
  if (title) titles.set(id, title)
1335
1736
  }
1336
1737
  titleCache.titles = titles
@@ -1621,7 +2022,7 @@ export function createRuntime(ctx, config, options = {}) {
1621
2022
  anyAgentRunning: busy.length > 0,
1622
2023
  busyAgents: busy,
1623
2024
  lastActivityAt,
1624
- settleMs: config.settleMs,
2025
+ settleMs: effectiveSettleMs(),
1625
2026
  now,
1626
2027
  softMs: config.deferSoftMs,
1627
2028
  hardMs: config.deferHardMs,
@@ -1657,6 +2058,134 @@ export function createRuntime(ctx, config, options = {}) {
1657
2058
  }
1658
2059
  }
1659
2060
 
2061
+ /**
2062
+ * Persist one or more user choices into the profile row of this plugin.
2063
+ *
2064
+ * The official `configEditor` service is the primary path: the choice goes
2065
+ * into the profile row of this plugin, so the settings page and the one-time
2066
+ * prompts read and write the same key (one source of truth). When the service
2067
+ * is unavailable the choice degrades to a file in the plugin's state
2068
+ * directory and a warning is logged, so the choice is never silently lost.
2069
+ *
2070
+ * @param {{ backupOnRestart?: boolean, backupIncludePlugins?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number, restartGraceMs?: number }} changes
2071
+ * @returns {Promise<{ applied: 'config'|'fallback-file' }>}
2072
+ */
2073
+ const persistConfigChoice = async (changes) => {
2074
+ // The profile row and the degraded fallback file use different key names for
2075
+ // the backup scope: the row carries the plugin's own key
2076
+ // (`backupIncludePlugins`), the file the short one (`includePlugins`).
2077
+ const wanted = {}
2078
+ const fallback = {}
2079
+ if (typeof changes.backupIncludePlugins === 'boolean') {
2080
+ wanted.backupIncludePlugins = changes.backupIncludePlugins
2081
+ fallback.includePlugins = changes.backupIncludePlugins
2082
+ }
2083
+ if (typeof changes.backupOnRestart === 'boolean') {
2084
+ wanted.backupOnRestart = changes.backupOnRestart
2085
+ fallback.backupOnRestart = changes.backupOnRestart
2086
+ }
2087
+ if (typeof changes.showSleepButton === 'boolean') {
2088
+ wanted.showSleepButton = changes.showSleepButton
2089
+ fallback.showSleepButton = changes.showSleepButton
2090
+ }
2091
+ if (typeof changes.sleepCountdownSeconds === 'number') {
2092
+ wanted.sleepCountdownSeconds = changes.sleepCountdownSeconds
2093
+ fallback.sleepCountdownSeconds = changes.sleepCountdownSeconds
2094
+ }
2095
+ if (typeof changes.restartGraceMs === 'number') {
2096
+ wanted.restartGraceMs = changes.restartGraceMs
2097
+ fallback.restartGraceMs = changes.restartGraceMs
2098
+ }
2099
+ const editor = resolveConfigEditor(ctx)
2100
+ if (editor !== null) {
2101
+ try {
2102
+ const entry = findOwnEntry(editor)
2103
+ if (entry !== null) {
2104
+ await editor.edit(entry, (current) => {
2105
+ const base = current && typeof current === 'object' ? { ...current } : {}
2106
+ return { ...base, ...wanted }
2107
+ })
2108
+ // The Loader reconciles the row, but the running instance must not
2109
+ // keep the old value until that happens.
2110
+ for (const [key, value] of Object.entries(wanted)) config[key] = value
2111
+ log.info(`the choice was written into the profile configuration: ${JSON.stringify(wanted)}`)
2112
+ return { applied: 'config' }
2113
+ }
2114
+ log.warn(`the configEditor service has no profile row for ${name}; the choice degrades to ${INSTALL_CHOICE_FILE}`)
2115
+ } catch (error) {
2116
+ log.warn(`could not write the choice into the profile configuration: ${describeError(error)}`)
2117
+ }
2118
+ } else {
2119
+ log.warn(`the configEditor service is unavailable; the choice degrades to ${INSTALL_CHOICE_FILE}`)
2120
+ }
2121
+ const file = path.join(path.dirname(stateFile), INSTALL_CHOICE_FILE)
2122
+ try {
2123
+ writeInstallChoice(path.dirname(stateFile), fallback)
2124
+ } catch (error) {
2125
+ throw new Error(`could not write the choice to ${file}: ${describeError(error)}`)
2126
+ }
2127
+ for (const [key, value] of Object.entries(wanted)) config[key] = value
2128
+ log.warn(`the choice was written to ${file} instead of the profile configuration`)
2129
+ return { applied: 'fallback-file' }
2130
+ }
2131
+
2132
+ /**
2133
+ * The feature announcements the user has not seen yet.
2134
+ *
2135
+ * A feature is pending when the recorded version is not the current one: a
2136
+ * user who has seen the announcement of version 0.6.0 never sees it again,
2137
+ * and a user who upgrades to a newer version sees the announcement of that
2138
+ * version. A feature without a record is always pending — a missing record
2139
+ * must never swallow an announcement.
2140
+ *
2141
+ * @returns {{ feature: string, version: string }[]}
2142
+ */
2143
+ const pendingAnnouncements = () => {
2144
+ const seen = state.seenFeatureVersions && typeof state.seenFeatureVersions === 'object'
2145
+ ? state.seenFeatureVersions
2146
+ : {}
2147
+ return featureAnnouncements().filter((entry) => seen[entry.feature] !== entry.version)
2148
+ }
2149
+
2150
+ /**
2151
+ * Record that the user has seen the announcement of one feature.
2152
+ *
2153
+ * The record lives in the checkpoint file, so it survives a restart of the
2154
+ * Host: an announcement that was shown once does not come back with the
2155
+ * next boot.
2156
+ *
2157
+ * @param {string} feature the id of the feature
2158
+ * @returns {string[]} the features that are recorded now
2159
+ */
2160
+ const markFeatureSeen = (feature) => {
2161
+ const id = typeof feature === 'string' ? feature.trim() : ''
2162
+ if (id === '') return []
2163
+ const known = featureAnnouncements().find((entry) => entry.feature === id)
2164
+ if (!known) return []
2165
+ if (!state.seenFeatureVersions || typeof state.seenFeatureVersions !== 'object') {
2166
+ state.seenFeatureVersions = {}
2167
+ }
2168
+ state.seenFeatureVersions[id] = known.version
2169
+ persist()
2170
+ return Object.keys(state.seenFeatureVersions).sort()
2171
+ }
2172
+
2173
+ /**
2174
+ * Record that the user has seen the announcement of EVERY feature.
2175
+ *
2176
+ * This is what the one-time install prompt does: it already explains the
2177
+ * features to a fresh installation, so no announcement may follow it.
2178
+ *
2179
+ * @returns {string[]} the features that are recorded now
2180
+ */
2181
+ const markAllFeaturesSeen = () => {
2182
+ const seen = new Set()
2183
+ for (const entry of featureAnnouncements()) {
2184
+ for (const id of markFeatureSeen(entry.feature)) seen.add(id)
2185
+ }
2186
+ return Array.from(seen).sort()
2187
+ }
2188
+
1660
2189
  const runtime = {
1661
2190
  get state() {
1662
2191
  return state
@@ -1706,58 +2235,83 @@ export function createRuntime(ctx, config, options = {}) {
1706
2235
  stateFile,
1707
2236
  log,
1708
2237
  persist,
2238
+ /**
2239
+ * Persist one or more user choices into the profile row of this plugin.
2240
+ *
2241
+ * @param {{ backupOnRestart?: boolean, backupIncludePlugins?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number }} changes
2242
+ * @returns {Promise<{ applied: 'config'|'fallback-file' }>}
2243
+ */
2244
+ applyConfigChoice: (changes) => persistConfigChoice(changes),
2245
+ /**
2246
+ * The feature announcements the user has not seen yet.
2247
+ *
2248
+ * @returns {{ feature: string, version: string }[]}
2249
+ */
2250
+ pendingAnnouncements,
2251
+ /**
2252
+ * Record that the user has seen the announcement of one feature.
2253
+ *
2254
+ * @param {string} feature
2255
+ * @returns {string[]} the features that are recorded now
2256
+ */
2257
+ markFeatureSeen,
2258
+ /**
2259
+ * Record that the user has seen the announcement of every feature.
2260
+ *
2261
+ * @returns {string[]} the features that are recorded now
2262
+ */
2263
+ markAllFeaturesSeen,
1709
2264
  /**
1710
2265
  * Persist the user's one-time install choice: whether the pre-restart
1711
2266
  * backup also snapshots the installed plugin tree.
1712
2267
  *
1713
- * The official `configEditor` service is the primary path: the choice goes
1714
- * into the profile row of this plugin, so the settings page and this prompt
1715
- * read and write the same key (one source of truth). When the service is
1716
- * unavailable the choice degrades to a file in the plugin's state directory
1717
- * and a warning is logged, so the choice is never silently lost.
1718
- *
1719
2268
  * @param {boolean} includePlugins
1720
2269
  * @returns {Promise<{ applied: 'config'|'fallback-file', includePlugins: boolean }>}
1721
2270
  */
1722
2271
  async applyBackupChoice(includePlugins) {
1723
- const value = includePlugins === true
1724
- const editor = resolveConfigEditor(ctx)
1725
- if (editor !== null) {
1726
- try {
1727
- const entry = findOwnEntry(editor)
1728
- if (entry !== null) {
1729
- await editor.edit(entry, (current) => {
1730
- const base = current && typeof current === 'object' ? { ...current } : {}
1731
- return { ...base, backupIncludePlugins: value }
1732
- })
1733
- // The Loader reconciles the row, but the running instance must not
1734
- // keep the old value until that happens.
1735
- config.backupIncludePlugins = value
1736
- log.info(`the install choice was written into the profile configuration: backupIncludePlugins=${value}`)
1737
- return { applied: 'config', includePlugins: value }
1738
- }
1739
- log.warn(`the configEditor service has no profile row for ${name}; the install choice degrades to ${INSTALL_CHOICE_FILE}`)
1740
- } catch (error) {
1741
- log.warn(`could not write the install choice into the profile configuration: ${describeError(error)}`)
1742
- }
1743
- } else {
1744
- log.warn(`the configEditor service is unavailable; the install choice degrades to ${INSTALL_CHOICE_FILE}`)
1745
- }
1746
- const file = path.join(path.dirname(stateFile), INSTALL_CHOICE_FILE)
1747
- try {
1748
- writeInstallChoice(path.dirname(stateFile), value)
1749
- } catch (error) {
1750
- throw new Error(`could not write the install choice to ${file}: ${describeError(error)}`)
1751
- }
1752
- config.backupIncludePlugins = value
1753
- log.warn(`the install choice was written to ${file} instead of the profile configuration`)
1754
- return { applied: 'fallback-file', includePlugins: value }
2272
+ const result = await persistConfigChoice({ backupIncludePlugins: includePlugins === true })
2273
+ return { applied: result.applied, includePlugins: includePlugins === true }
2274
+ },
2275
+ /**
2276
+ * Persist whether the graceful-sleep button is shown in the main UI.
2277
+ *
2278
+ * @param {boolean} showSleepButton
2279
+ * @returns {Promise<{ applied: 'config'|'fallback-file', showSleepButton: boolean }>}
2280
+ */
2281
+ async applySleepButtonChoice(showSleepButton) {
2282
+ const result = await persistConfigChoice({ showSleepButton: showSleepButton === true })
2283
+ return { applied: result.applied, showSleepButton: showSleepButton === true }
1755
2284
  },
1756
2285
  toolErrors,
1757
2286
  startPolling,
1758
2287
  stopPolling,
1759
2288
  tick,
1760
2289
  anyAgentRunning,
2290
+ /**
2291
+ * Whether a restart is still pending (the graceful shutdown waits for it).
2292
+ *
2293
+ * @returns {boolean}
2294
+ */
2295
+ pendingRestart,
2296
+ /**
2297
+ * How long the host has been quiet (no conversation activity at all).
2298
+ *
2299
+ * @returns {number}
2300
+ */
2301
+ quietForMs,
2302
+ /**
2303
+ * The grace window: the extra wait after the last conversation finished.
2304
+ *
2305
+ * @returns {number} milliseconds
2306
+ */
2307
+ graceMs,
2308
+ /**
2309
+ * The quiet window the restart gate really uses: the settle window, but
2310
+ * never shorter than the grace window.
2311
+ *
2312
+ * @returns {number} milliseconds
2313
+ */
2314
+ effectiveSettleMs,
1761
2315
  executeRestart,
1762
2316
  flushSessions,
1763
2317
  releaseRequesters,
@@ -2466,7 +3020,9 @@ function registerTools(ctx, runtime, config) {
2466
3020
  'Report the dsh-autorestart lifecycle: whether a restart is pending, which sessions were '
2467
3021
  + 'checkpointed as interrupted or continuation-pending, why each blocking session blocks '
2468
3022
  + '("goal-active" = an armed goal keeps it busy, "goal-exhausted" = its goal can no longer be '
2469
- + 'resumed), the goals this plugin disarmed, the last recovery action/detail per session, '
3023
+ + 'resumed), the goals this plugin disarmed, how long the host has been quiet and how long '
3024
+ + 'the grace window is (the extra wait after the last conversation finished, for work other '
3025
+ + 'plugins queue with a timer), the last recovery action/detail per session, '
2470
3026
  + 'whether a session was handed off to a fresh session (target session id and time) or is over '
2471
3027
  + 'the model context window (such a session is never auto-resumed), whether the pre-restart '
2472
3028
  + 'backup also snapshots the installed plugins (backupIncludePlugins), the '
@@ -2492,7 +3048,33 @@ function registerTools(ctx, runtime, config) {
2492
3048
  pendingRestart: { type: 'boolean' },
2493
3049
  gateAllow: { type: 'boolean' },
2494
3050
  quietForMs: { type: 'number' },
2495
- blockedBy: { type: 'array', items: { type: 'object' } },
3051
+ graceMs: { type: 'number' },
3052
+ effectiveSettleMs: { type: 'number' },
3053
+ blockedBy: {
3054
+ type: 'array',
3055
+ items: {
3056
+ type: 'object',
3057
+ // Every blocker says WHAT it is (`session` or `subagent`) and
3058
+ // HOW it is called (`label`: the title of the session, or of the
3059
+ // session that created the subagent).
3060
+ //
3061
+ // The title fields are NULLABLE: a session whose title could not
3062
+ // be resolved reports `null`, and a strict schema would reject
3063
+ // the whole tool output for it. The tool schema has no type
3064
+ // arrays, so "string or null" is declared as a `oneOf`.
3065
+ properties: {
3066
+ sessionId: { type: 'string' },
3067
+ reason: { type: 'string' },
3068
+ busyReason: { type: 'string' },
3069
+ kind: { type: 'string', enum: ['session', 'subagent', 'restart-pending'] },
3070
+ label: { oneOf: [{ type: 'string' }, { type: 'null' }] },
3071
+ name: { type: 'string' },
3072
+ parentTitle: { oneOf: [{ type: 'string' }, { type: 'null' }] },
3073
+ parentSessionId: { oneOf: [{ type: 'string' }, { type: 'null' }] },
3074
+ title: { oneOf: [{ type: 'string' }, { type: 'null' }] },
3075
+ },
3076
+ },
3077
+ },
2496
3078
  parkedGoals: { type: 'array', items: { type: 'object' } },
2497
3079
  recovery: { type: 'array', items: { type: 'object' } },
2498
3080
  handoffs: { type: 'array', items: { type: 'object' } },
@@ -2519,6 +3101,10 @@ function registerTools(ctx, runtime, config) {
2519
3101
  pendingRestart: state.restart !== null,
2520
3102
  gateAllow: verdict.allow,
2521
3103
  quietForMs: Math.round(verdict.quietForMs),
3104
+ // The grace window (the extra wait after the last conversation
3105
+ // finished) and the quiet window the gate really uses.
3106
+ graceMs: runtime.graceMs(),
3107
+ effectiveSettleMs: runtime.effectiveSettleMs(),
2522
3108
  blockedBy: blockingDetails(verdict.blockedBy),
2523
3109
  parkedGoals: parkedGoalDetails(),
2524
3110
  recovery: recoveryList(state),
@@ -3233,18 +3819,107 @@ function buildHandoffRoute(ctx, runtime, config) {
3233
3819
  }
3234
3820
  }
3235
3821
 
3822
+ /**
3823
+ * Build the announcements route: the one-time announcement of a new feature.
3824
+ *
3825
+ * It sits next to the health route and uses the same authentication rule
3826
+ * (loopback OR a request the connection service admits).
3827
+ *
3828
+ * - `GET` answers `{ ok: true, pending: [{ feature, version }] }`: the
3829
+ * announcements the user has not seen yet (an empty list when there is
3830
+ * nothing new — a fresh installation is asked at the install prompt, so
3831
+ * it never gets an announcement);
3832
+ * - `POST` with `{ "feature": "sleep" }` records that the user has seen
3833
+ * that announcement and answers `{ ok: true, seen: [...] }`.
3834
+ *
3835
+ * The record lives in the checkpoint file, so an announcement that was shown
3836
+ * once never comes back — not with the next boot and not with the next
3837
+ * version of the plugin.
3838
+ */
3839
+ function buildAnnouncementsRoute(ctx, runtime, config) {
3840
+ const routePath = resolveAnnouncementsPath(config)
3841
+ return {
3842
+ kind: 'exact',
3843
+ path: routePath,
3844
+ handler: (req, res) => {
3845
+ const remote = req && req.socket ? req.socket.remoteAddress : undefined
3846
+ const loopback = remote === undefined
3847
+ || remote === '127.0.0.1'
3848
+ || remote === '::1'
3849
+ || remote === '::ffff:127.0.0.1'
3850
+ if (!loopback && !admittedByConnection(ctx, req)) {
3851
+ res.statusCode = 403
3852
+ res.setHeader('content-type', 'application/json; charset=utf-8')
3853
+ res.end('{"ok":false,"error":"loopback-only"}')
3854
+ return
3855
+ }
3856
+ const answer = (payload, status = 200) => {
3857
+ res.statusCode = status
3858
+ res.setHeader('content-type', 'application/json; charset=utf-8')
3859
+ res.setHeader('cache-control', 'no-store')
3860
+ res.end(JSON.stringify(payload))
3861
+ }
3862
+ const method = req && typeof req.method === 'string' ? req.method.toUpperCase() : 'GET'
3863
+ if (method === 'GET') {
3864
+ answer({ ok: true, pending: runtime.pendingAnnouncements() })
3865
+ return
3866
+ }
3867
+ if (method !== 'POST') {
3868
+ res.statusCode = 405
3869
+ res.setHeader('content-type', 'application/json; charset=utf-8')
3870
+ res.setHeader('allow', 'GET, POST')
3871
+ res.end('{"ok":false,"error":"method-not-allowed"}')
3872
+ return
3873
+ }
3874
+ void readJsonBody(req).then((payload) => {
3875
+ const body = payload && typeof payload === 'object' ? payload : {}
3876
+ const feature = typeof body.feature === 'string' ? body.feature.trim() : ''
3877
+ if (feature === '') {
3878
+ answer({
3879
+ ok: false,
3880
+ error: 'feature-required',
3881
+ instruction: 'Send { "feature": "sleep" }.',
3882
+ }, 400)
3883
+ return
3884
+ }
3885
+ const known = featureAnnouncements().some((entry) => entry.feature === feature)
3886
+ if (!known) {
3887
+ answer({
3888
+ ok: false,
3889
+ error: 'unknown-feature',
3890
+ instruction: `Send { "feature": ${featureAnnouncements().map((entry) => `"${entry.feature}"`).join(' | ')} }.`,
3891
+ }, 400)
3892
+ return
3893
+ }
3894
+ answer({ ok: true, seen: runtime.markFeatureSeen(feature) })
3895
+ }).catch((error) => {
3896
+ answer({ ok: false, error: describeError(error) }, 400)
3897
+ })
3898
+ },
3899
+ }
3900
+ }
3901
+
3236
3902
  /**
3237
3903
  * Build the POST `/choice` route: persist the one-time install choice of the
3238
- * user (whether the pre-restart backup also snapshots the installed plugins).
3904
+ * user (whether the pre-restart backup also snapshots the installed plugins),
3905
+ * the answer to the one-time question whether the graceful-sleep button is
3906
+ * shown, and the settings of the configuration page (how long the countdown
3907
+ * runs, whether the pre-restart backup is taken at all and whether that
3908
+ * backup includes the installed plugins).
3239
3909
  *
3240
3910
  * It sits next to the health route and uses the same authentication rule
3241
3911
  * (loopback OR a request the connection service admits). Body:
3242
- * `{ "includePlugins": boolean }`; anything that is not a boolean is refused
3243
- * with a 400 and NOTHING is written.
3912
+ * `{ "includePlugins"?: boolean, "backupIncludePlugins"?: boolean,
3913
+ * "backupOnRestart"?: boolean, "showSleepButton"?: boolean,
3914
+ * "sleepCountdownSeconds"?: 0 | 10 | 20 | 30 | 60 }` — at least one
3915
+ * of the keys must be present, and a key that IS present must carry a valid
3916
+ * value. Anything else is refused with a 400 and NOTHING is written.
3244
3917
  *
3245
- * The answer is `{ ok: true, applied: 'config' | 'fallback-file', includePlugins }`:
3918
+ * The answer is `{ ok: true, applied: 'config' | 'fallback-file', ... }`:
3246
3919
  * `applied` says where the choice landed — the profile configuration (the
3247
- * official path) or the degraded fallback file.
3920
+ * official path) or the degraded fallback file. Only the keys that were asked
3921
+ * for are echoed back, plus the effective `countdownSeconds` so the settings
3922
+ * page can read them back at once.
3248
3923
  */
3249
3924
  function buildChoiceRoute(ctx, runtime, config) {
3250
3925
  const routePath = resolveChoicePath(config)
@@ -3272,8 +3947,25 @@ function buildChoiceRoute(ctx, runtime, config) {
3272
3947
  }
3273
3948
  void readJsonBody(req).then(async (payload) => {
3274
3949
  const body = payload && typeof payload === 'object' ? payload : {}
3275
- const includePlugins = body.includePlugins
3276
- if (typeof includePlugins !== 'boolean') {
3950
+ const hasInclude = Object.prototype.hasOwnProperty.call(body, 'includePlugins')
3951
+ const hasBackupInclude = Object.prototype.hasOwnProperty.call(body, 'backupIncludePlugins')
3952
+ const hasBackup = Object.prototype.hasOwnProperty.call(body, 'backupOnRestart')
3953
+ const hasShow = Object.prototype.hasOwnProperty.call(body, 'showSleepButton')
3954
+ const hasSeconds = Object.prototype.hasOwnProperty.call(body, 'sleepCountdownSeconds')
3955
+ const hasGrace = Object.prototype.hasOwnProperty.call(body, 'restartGraceMs')
3956
+ if (!hasInclude && !hasBackupInclude && !hasBackup && !hasShow && !hasSeconds && !hasGrace) {
3957
+ // No key was asked for: there is nothing to persist.
3958
+ res.statusCode = 400
3959
+ res.setHeader('content-type', 'application/json; charset=utf-8')
3960
+ res.setHeader('cache-control', 'no-store')
3961
+ res.end(JSON.stringify({
3962
+ ok: false,
3963
+ error: 'no-choice',
3964
+ instruction: 'Send { "includePlugins": true | false } and/or { "backupIncludePlugins": true | false } and/or { "backupOnRestart": true | false } and/or { "showSleepButton": true | false } and/or { "sleepCountdownSeconds": 0 | 10 | 20 | 30 | 60 } and/or { "restartGraceMs": 0 | 3000 | 5000 | 10000 | 20000 | 30000 }.',
3965
+ }))
3966
+ return
3967
+ }
3968
+ if (hasInclude && typeof body.includePlugins !== 'boolean') {
3277
3969
  // Nothing is written for a malformed choice: the caller's answer
3278
3970
  // must not change the profile configuration.
3279
3971
  res.statusCode = 400
@@ -3286,15 +3978,95 @@ function buildChoiceRoute(ctx, runtime, config) {
3286
3978
  }))
3287
3979
  return
3288
3980
  }
3289
- const result = await runtime.applyBackupChoice(includePlugins)
3981
+ if (hasBackupInclude && typeof body.backupIncludePlugins !== 'boolean') {
3982
+ res.statusCode = 400
3983
+ res.setHeader('content-type', 'application/json; charset=utf-8')
3984
+ res.setHeader('cache-control', 'no-store')
3985
+ res.end(JSON.stringify({
3986
+ ok: false,
3987
+ error: 'backupIncludePlugins-must-be-boolean',
3988
+ instruction: 'Send { "backupIncludePlugins": true | false }.',
3989
+ }))
3990
+ return
3991
+ }
3992
+ if (hasBackup && typeof body.backupOnRestart !== 'boolean') {
3993
+ res.statusCode = 400
3994
+ res.setHeader('content-type', 'application/json; charset=utf-8')
3995
+ res.setHeader('cache-control', 'no-store')
3996
+ res.end(JSON.stringify({
3997
+ ok: false,
3998
+ error: 'backupOnRestart-must-be-boolean',
3999
+ instruction: 'Send { "backupOnRestart": true | false }.',
4000
+ }))
4001
+ return
4002
+ }
4003
+ if (hasShow && typeof body.showSleepButton !== 'boolean') {
4004
+ res.statusCode = 400
4005
+ res.setHeader('content-type', 'application/json; charset=utf-8')
4006
+ res.setHeader('cache-control', 'no-store')
4007
+ res.end(JSON.stringify({
4008
+ ok: false,
4009
+ error: 'showSleepButton-must-be-boolean',
4010
+ instruction: 'Send { "showSleepButton": true | false }.',
4011
+ }))
4012
+ return
4013
+ }
4014
+ if (hasSeconds && COUNTDOWN_CHOICES.indexOf(body.sleepCountdownSeconds) === -1) {
4015
+ res.statusCode = 400
4016
+ res.setHeader('content-type', 'application/json; charset=utf-8')
4017
+ res.setHeader('cache-control', 'no-store')
4018
+ res.end(JSON.stringify({
4019
+ ok: false,
4020
+ error: 'sleepCountdownSeconds-must-be-offered',
4021
+ instruction: `Send { "sleepCountdownSeconds": ${COUNTDOWN_CHOICES.join(' | ')} }.`,
4022
+ }))
4023
+ return
4024
+ }
4025
+ if (hasGrace && GRACE_CHOICES_MS.indexOf(body.restartGraceMs) === -1) {
4026
+ res.statusCode = 400
4027
+ res.setHeader('content-type', 'application/json; charset=utf-8')
4028
+ res.setHeader('cache-control', 'no-store')
4029
+ res.end(JSON.stringify({
4030
+ ok: false,
4031
+ error: 'restartGraceMs-must-be-offered',
4032
+ instruction: `Send { "restartGraceMs": ${GRACE_CHOICES_MS.join(' | ')} }.`,
4033
+ }))
4034
+ return
4035
+ }
4036
+ const changes = {}
4037
+ // The route field is `includePlugins`, the configuration key is
4038
+ // `backupIncludePlugins` (the profile row keeps the plugin's own name).
4039
+ if (hasInclude) changes.backupIncludePlugins = body.includePlugins === true
4040
+ if (hasBackupInclude) changes.backupIncludePlugins = body.backupIncludePlugins === true
4041
+ if (hasBackup) changes.backupOnRestart = body.backupOnRestart === true
4042
+ if (hasShow) changes.showSleepButton = body.showSleepButton === true
4043
+ if (hasSeconds) changes.sleepCountdownSeconds = body.sleepCountdownSeconds
4044
+ if (hasGrace) changes.restartGraceMs = body.restartGraceMs
4045
+ const result = await runtime.applyConfigChoice(changes)
4046
+ // The one-time install prompt explained the features to the user, so
4047
+ // the announcement of a fresh installation must not follow it.
4048
+ //
4049
+ // Only the install prompt sends `includePlugins` and `showSleepButton`
4050
+ // (the settings page writes `backupIncludePlugins`,
4051
+ // `backupOnRestart` and `sleepCountdownSeconds`), so these two keys
4052
+ // are exactly the "the install question was answered" moment.
4053
+ if (hasInclude || hasShow) {
4054
+ const seen = runtime.markAllFeaturesSeen()
4055
+ if (seen.length > 0) {
4056
+ runtime.log.info(`the install question marked the feature announcements as seen: ${seen.join(', ')}`)
4057
+ }
4058
+ }
4059
+ const answer = { ok: true, applied: result.applied }
4060
+ if (hasInclude) answer.includePlugins = body.includePlugins === true
4061
+ if (hasBackupInclude) answer.backupIncludePlugins = body.backupIncludePlugins === true
4062
+ if (hasBackup) answer.backupOnRestart = body.backupOnRestart === true
4063
+ if (hasShow) answer.showSleepButton = body.showSleepButton === true
4064
+ if (hasSeconds) answer.sleepCountdownSeconds = body.sleepCountdownSeconds
4065
+ if (hasGrace) answer.restartGraceMs = body.restartGraceMs
3290
4066
  res.statusCode = 200
3291
4067
  res.setHeader('content-type', 'application/json; charset=utf-8')
3292
4068
  res.setHeader('cache-control', 'no-store')
3293
- res.end(JSON.stringify({
3294
- ok: true,
3295
- applied: result.applied,
3296
- includePlugins: result.includePlugins === true,
3297
- }))
4069
+ res.end(JSON.stringify(answer))
3298
4070
  }).catch((error) => {
3299
4071
  res.statusCode = 500
3300
4072
  res.setHeader('content-type', 'application/json; charset=utf-8')
@@ -3304,8 +4076,406 @@ function buildChoiceRoute(ctx, runtime, config) {
3304
4076
  }
3305
4077
  }
3306
4078
 
4079
+ /**
4080
+ * Is the graceful sleep a dry run?
4081
+ *
4082
+ * With `DSH_AUTORESTART_SLEEP_DRYRUN=1` the plugin reports the command it
4083
+ * WOULD run and never runs it. That is what makes the feature testable on a
4084
+ * live machine.
4085
+ *
4086
+ * @returns {boolean}
4087
+ */
4088
+ function sleepDryRun() {
4089
+ return process.env.DSH_AUTORESTART_SLEEP_DRYRUN === '1'
4090
+ }
4091
+
4092
+ /**
4093
+ * The gate of the graceful sleep: the ONE question is "is any conversation
4094
+ * still working?".
4095
+ *
4096
+ * It is deliberately NOT the restart gate: that one also demands a quiet
4097
+ * settle window, and a settle window longer than the countdown would push the
4098
+ * power-off back by whole countdown periods (every restart of the countdown
4099
+ * would also claim that somebody started working again, which is not what
4100
+ * happened). The sleep feature counts down on screen itself, so it only needs
4101
+ * the busy check — and it keeps `settleMs` out of the picture entirely.
4102
+ *
4103
+ * The GRACE window is the exception: it is not a settle window but the extra
4104
+ * wait for work this plugin cannot see (a memory plugin writing its notes a
4105
+ * few seconds after the conversation ended). It is honoured BEFORE the
4106
+ * countdown starts, so the countdown is never pushed back by it.
4107
+ *
4108
+ * The restart gate itself is untouched: the restart feature keeps its quiet
4109
+ * window exactly as it was (plus the same grace).
4110
+ *
4111
+ * @param {object} runtime
4112
+ * @returns {{ allow: boolean, reason: 'busy'|'grace'|'quiet', blockedBy: any[] }}
4113
+ */
4114
+ function sleepGate(runtime) {
4115
+ // A pending restart goes FIRST: the shutdown waits for it — and for the
4116
+ // sessions that resume with it. The intent survives the restart, so the
4117
+ // machine goes off after everything is done, not before.
4118
+ let pending = false
4119
+ try {
4120
+ pending = typeof runtime.pendingRestart === 'function' && runtime.pendingRestart() === true
4121
+ } catch {
4122
+ pending = false
4123
+ }
4124
+ if (pending) {
4125
+ return {
4126
+ allow: false,
4127
+ reason: 'restart-pending',
4128
+ blockedBy: [{
4129
+ sessionId: 'restart-pending',
4130
+ kind: 'restart-pending',
4131
+ label: '待执行的重启',
4132
+ name: '待执行的重启',
4133
+ }],
4134
+ }
4135
+ }
4136
+ let busy = []
4137
+ try {
4138
+ busy = runtime.busyAgents()
4139
+ } catch {
4140
+ busy = []
4141
+ }
4142
+ const list = Array.isArray(busy) ? busy : []
4143
+ if (list.length > 0) return { allow: false, reason: 'busy', blockedBy: list }
4144
+ // The grace window: the last conversation may have finished a moment ago,
4145
+ // and other plugins still have work queued that this plugin cannot see.
4146
+ let quiet = 0
4147
+ let grace = 0
4148
+ try { quiet = Number(runtime.quietForMs()) } catch { quiet = 0 }
4149
+ try { grace = Number(runtime.graceMs()) } catch { grace = 0 }
4150
+ if (!Number.isFinite(quiet) || quiet < 0) quiet = 0
4151
+ if (Number.isFinite(grace) && grace > 0 && quiet < grace) {
4152
+ return { allow: false, reason: 'grace', blockedBy: [] }
4153
+ }
4154
+ return { allow: true, reason: 'quiet', blockedBy: [] }
4155
+ }
4156
+
4157
+ /**
4158
+ * The FINAL plan of the graceful sleep: what will really be executed, and how
4159
+ * the host describes itself in the status JSON.
4160
+ *
4161
+ * Two rules:
4162
+ * 1. `supported` follows the plan that will be executed, NOT the detected
4163
+ * one: a machine whose platform could not be detected is still perfectly
4164
+ * usable when the operator configured a command of their own.
4165
+ * 2. the reported `kind`/`label` belong to that same plan, so the status
4166
+ * never says "this machine has no reliable way to switch off" while a
4167
+ * command is about to run.
4168
+ *
4169
+ * @param {object} plan the detected plan
4170
+ * @param {object} config
4171
+ * @returns {object} the effective plan
4172
+ */
4173
+ function effectiveSleepPlan(plan, config) {
4174
+ const invocation = planInvocation(plan, {
4175
+ sleepCommand: config.sleepCommand,
4176
+ platform: plan.platform,
4177
+ })
4178
+ const custom = invocation.source === 'sleepCommand'
4179
+ const detectedKind = typeof plan.kind === 'string' ? plan.kind : 'unsupported'
4180
+ const detected = detectedKind !== 'unsupported'
4181
+ const command = typeof invocation.command === 'string' && invocation.command !== '' ? invocation.command : null
4182
+ const label = custom
4183
+ ? (detected ? `执行你指定的关机命令(${plan.label})` : '执行你指定的关机命令')
4184
+ : plan.label
4185
+ const kind = custom
4186
+ // A custom command on a platform we could not classify: the machine is
4187
+ // real, the mechanism is the operator's own — it is NOT "unsupported".
4188
+ ? (detected ? detectedKind : 'custom')
4189
+ : detectedKind
4190
+ return {
4191
+ ...invocation,
4192
+ kind,
4193
+ label,
4194
+ // The short phrase the countdown message talks about.
4195
+ shortLabel: custom ? '执行你指定的关机命令' : plan.label,
4196
+ // The final plan decides whether the feature may run at all.
4197
+ supported: command !== null,
4198
+ }
4199
+ }
4200
+
4201
+ /**
4202
+ * Build the graceful-sleep service: the detected host plan plus the state
4203
+ * machine that waits for the conversations, counts down and switches the
4204
+ * machine off.
4205
+ *
4206
+ * The busy check is the runtime's own `busyAgents()`, so the sleep feature and
4207
+ * the restart feature agree on what "every conversation is idle" means. The
4208
+ * gate itself is the sleep-specific one (no settle window): the sleep feature
4209
+ * counts down on screen itself.
4210
+ *
4211
+ * `options.sleepNow` and `options.sleepSpawn` replace the clock and the
4212
+ * process spawner, so the whole flow can be driven without waiting and
4213
+ * without ever touching the machine. Production never passes them.
4214
+ *
4215
+ * @param {object} ctx
4216
+ * @param {object} runtime
4217
+ * @param {object} config
4218
+ * @param {{ sleepNow?: () => number, sleepSpawn?: Function }} [options]
4219
+ * @returns {{ plan: object, controller: object, invocation: () => object }}
4220
+ */
4221
+ function createSleepService(ctx, runtime, config, options = {}) {
4222
+ const plan = detectShutdownPlan({ platform: process.platform, env: process.env })
4223
+ // The configured command (if any) replaces the detected one; the final plan
4224
+ // is what decides whether the feature may run and what the UI is told.
4225
+ const invocation = () => effectiveSleepPlan(plan, config)
4226
+ const gate = () => sleepGate(runtime)
4227
+ const final = invocation()
4228
+ const now = typeof options.sleepNow === 'function' ? options.sleepNow : () => Date.now()
4229
+ const spawn = typeof options.sleepSpawn === 'function' ? options.sleepSpawn : undefined
4230
+ const buildController = (seconds) => createSleepController({
4231
+ now,
4232
+ busyAgents: () => runtime.busyAgents(),
4233
+ gate,
4234
+ titleOf: (sessionId) => runtime.sessionTitle(sessionId),
4235
+ // The grace window: after the last conversation finished the machine
4236
+ // waits a little longer, so that work this plugin cannot see (a memory
4237
+ // plugin writing its notes, a summary) still gets its chance.
4238
+ quietForMs: () => runtime.quietForMs(),
4239
+ graceMs: () => runtime.graceMs(),
4240
+ // A pending restart goes first: the shutdown waits for it.
4241
+ restartPending: () => {
4242
+ const pending = runtime.pendingRestart()
4243
+ if (!pending) return { pending: false, blockers: [] }
4244
+ return {
4245
+ pending: true,
4246
+ blockers: [{
4247
+ sessionId: 'restart-pending',
4248
+ kind: 'restart-pending',
4249
+ label: '待执行的重启',
4250
+ name: '待执行的重启',
4251
+ }],
4252
+ }
4253
+ },
4254
+ // The intent is spent when the machine went off, when the shutdown failed
4255
+ // and when the user cancelled it: the checkpoint must not keep it.
4256
+ onDisarm: (reason) => {
4257
+ try {
4258
+ clearSleep(runtime.state)
4259
+ runtime.persist()
4260
+ runtime.log.info('the shutdown intent was cleared:', reason)
4261
+ } catch (error) {
4262
+ runtime.log.warn('could not clear the shutdown intent:', String(error))
4263
+ }
4264
+ },
4265
+ countdownSeconds: seconds,
4266
+ hostLabel: final.shortLabel,
4267
+ supported: final.supported,
4268
+ log: runtime.log,
4269
+ // The timer belongs to the plugin: `ctx.interval` is disposed with it.
4270
+ setInterval: (fn, ms) => ctx.interval(fn, ms),
4271
+ clearInterval: (handle) => {
4272
+ try {
4273
+ if (typeof handle === 'function') handle()
4274
+ } catch {}
4275
+ },
4276
+ shutdown: () => runShutdown(invocation(), {
4277
+ spawn,
4278
+ dryRun: sleepDryRun(),
4279
+ log: runtime.log,
4280
+ // The gate is checked ONE more time right before the machine is closed —
4281
+ // with the SAME sleep gate (a busy conversation still stops it).
4282
+ gate,
4283
+ }),
4284
+ })
4285
+ // The countdown length belongs to the CONFIGURATION, and the configuration
4286
+ // page can change it while the plugin runs. The controller is rebuilt as
4287
+ // soon as the new length is known — but never while a countdown is running:
4288
+ // that one keeps the length it was started with.
4289
+ let active = buildController(config.sleepCountdownSeconds)
4290
+ let activeSeconds = config.sleepCountdownSeconds
4291
+ const current = () => {
4292
+ const seconds = config.sleepCountdownSeconds
4293
+ if (seconds === activeSeconds) return active
4294
+ if (active.status().armed) return active
4295
+ active.dispose()
4296
+ active = buildController(seconds)
4297
+ activeSeconds = seconds
4298
+ return active
4299
+ }
4300
+ const liveController = {
4301
+ arm: (...args) => current().arm(...args),
4302
+ cancel: (...args) => current().cancel(...args),
4303
+ tick: (...args) => current().tick(...args),
4304
+ status: (...args) => current().status(...args),
4305
+ dispose: (...args) => current().dispose(...args),
4306
+ }
4307
+ return {
4308
+ plan,
4309
+ get controller() {
4310
+ return liveController
4311
+ },
4312
+ invocation,
4313
+ /**
4314
+ * Arm the shutdown AND remember the intent.
4315
+ *
4316
+ * The intent lives in the checkpoint file, so it survives a restart: a
4317
+ * shutdown asked for while a restart is pending waits for that restart —
4318
+ * and for the sessions that resume with it.
4319
+ *
4320
+ * @returns {object} the sleep status
4321
+ */
4322
+ arm: () => {
4323
+ try {
4324
+ armSleep(runtime.state, {
4325
+ armedAt: Date.now(),
4326
+ countdownSeconds: config.sleepCountdownSeconds,
4327
+ })
4328
+ runtime.persist()
4329
+ } catch (error) {
4330
+ runtime.log.warn('could not record the shutdown intent:', String(error))
4331
+ }
4332
+ return liveController.arm()
4333
+ },
4334
+ /**
4335
+ * Cancel the shutdown: the intent is gone for good (the disarm hook of
4336
+ * the controller clears the checkpoint).
4337
+ *
4338
+ * @returns {object} the sleep status
4339
+ */
4340
+ cancel: () => liveController.cancel(),
4341
+ /**
4342
+ * The recorded shutdown intent, if there is one.
4343
+ *
4344
+ * @returns {{ armed: true, armedAt: number, countdownSeconds: number|null }|null}
4345
+ */
4346
+ intent: () => runtime.state.sleep,
4347
+ /**
4348
+ * Arm the shutdown from a recorded intent (the boot restore).
4349
+ *
4350
+ * @returns {object} the sleep status
4351
+ */
4352
+ restore: () => liveController.arm(),
4353
+ }
4354
+ }
4355
+
4356
+ /**
4357
+ * The state of the graceful sleep, in the shape the client renders it.
4358
+ *
4359
+ * @param {{ plan: object, controller: object, invocation: () => object }} sleep
4360
+ * @param {object} config
4361
+ * @param {object} [runtime] the runtime (for the quiet window)
4362
+ * @returns {object}
4363
+ */
4364
+ function sleepStatusPayload(sleep, config, runtime) {
4365
+ const status = sleep.controller.status()
4366
+ const invocation = sleep.invocation()
4367
+ const intent = typeof sleep.intent === 'function' ? sleep.intent() : null
4368
+ return {
4369
+ ok: true,
4370
+ phase: status.phase,
4371
+ armed: status.armed,
4372
+ showSleepButton: config.showSleepButton !== false,
4373
+ countdownSeconds: status.countdownSeconds,
4374
+ // The grace window and how long the host has been quiet: both are needed
4375
+ // to tell "the machine is waiting for the grace" from "somebody is busy".
4376
+ graceMs: config.restartGraceMs,
4377
+ quietForMs: runtime && typeof runtime.quietForMs === 'function' ? runtime.quietForMs() : 0,
4378
+ // When the shutdown was armed: the intent has to survive a restart, so the
4379
+ // time is reported for the troubleshooting.
4380
+ armedAt: intent && typeof intent.armedAt === 'number' ? intent.armedAt : null,
4381
+ /**
4382
+ * The pre-restart backup scope, straight from the effective configuration:
4383
+ * the settings page reads its two switches from here.
4384
+ */
4385
+ backupOnRestart: config.backupOnRestart !== false,
4386
+ backupIncludePlugins: config.backupIncludePlugins !== false,
4387
+ remainingMs: status.remainingMs,
4388
+ blockers: status.blockers,
4389
+ host: {
4390
+ platform: invocation.platform || process.platform,
4391
+ kind: invocation.kind,
4392
+ label: invocation.label,
4393
+ source: invocation.source,
4394
+ // Machine-readable: the host was only recognized as WSL because the
4395
+ // Windows drive is mounted (a hint, not a proof). The UI and the tests
4396
+ // read this instead of guessing from the Chinese label.
4397
+ suspect: invocation.suspect === true,
4398
+ },
4399
+ dryRun: sleepDryRun(),
4400
+ message: status.message,
4401
+ lastRun: status.lastRun,
4402
+ }
4403
+ }
4404
+
4405
+ /**
4406
+ * Build the `/sleep` route: `GET` reports the state of the graceful sleep,
4407
+ * `POST` arms or cancels it.
4408
+ *
4409
+ * It sits next to the health route and uses the same authentication rule
4410
+ * (loopback OR a request the connection service admits). Body:
4411
+ * `{ "action": "arm" | "cancel" }`; anything else is refused with a 400.
4412
+ *
4413
+ * There is NO ticket and no extra header: the desktop client and the hosted
4414
+ * web client drive the very same route, so a request carries nothing but the
4415
+ * action itself.
4416
+ */
4417
+ function buildSleepRoute(ctx, runtime, config, sleep) {
4418
+ const routePath = resolveSleepPath(config)
4419
+ return {
4420
+ kind: 'exact',
4421
+ path: routePath,
4422
+ handler: (req, res) => {
4423
+ const remote = req && req.socket ? req.socket.remoteAddress : undefined
4424
+ const loopback = remote === undefined
4425
+ || remote === '127.0.0.1'
4426
+ || remote === '::1'
4427
+ || remote === '::ffff:127.0.0.1'
4428
+ if (!loopback && !admittedByConnection(ctx, req)) {
4429
+ res.statusCode = 403
4430
+ res.setHeader('content-type', 'application/json; charset=utf-8')
4431
+ res.end('{"ok":false,"error":"loopback-only"}')
4432
+ return
4433
+ }
4434
+ const answer = (payload, status = 200) => {
4435
+ res.statusCode = status
4436
+ res.setHeader('content-type', 'application/json; charset=utf-8')
4437
+ res.setHeader('cache-control', 'no-store')
4438
+ res.end(JSON.stringify(payload))
4439
+ }
4440
+ const method = req && typeof req.method === 'string' ? req.method.toUpperCase() : 'GET'
4441
+ if (method === 'GET') {
4442
+ answer(sleepStatusPayload(sleep, config, runtime))
4443
+ return
4444
+ }
4445
+ if (method !== 'POST') {
4446
+ res.statusCode = 405
4447
+ res.setHeader('content-type', 'application/json; charset=utf-8')
4448
+ res.setHeader('allow', 'GET, POST')
4449
+ res.end('{"ok":false,"error":"method-not-allowed"}')
4450
+ return
4451
+ }
4452
+ void readJsonBody(req).then((payload) => {
4453
+ const body = payload && typeof payload === 'object' ? payload : {}
4454
+ const action = typeof body.action === 'string' ? body.action.trim() : ''
4455
+ if (action !== 'arm' && action !== 'cancel') {
4456
+ answer({
4457
+ ok: false,
4458
+ error: 'unknown-action',
4459
+ instruction: 'Send { "action": "arm" | "cancel" }.',
4460
+ }, 400)
4461
+ return
4462
+ }
4463
+ if (action === 'arm') {
4464
+ // The intent is recorded too: it survives a restart.
4465
+ sleep.arm()
4466
+ } else {
4467
+ sleep.cancel()
4468
+ }
4469
+ answer(sleepStatusPayload(sleep, config, runtime))
4470
+ }).catch((error) => {
4471
+ answer({ ok: false, error: describeError(error) }, 400)
4472
+ })
4473
+ },
4474
+ }
4475
+ }
4476
+
3307
4477
  /** Expose a tiny read-only health route for humans and verification scripts. */
3308
- function registerHealthRoute(ctx, runtime, config) {
4478
+ function registerHealthRoute(ctx, runtime, config, sleep) {
3309
4479
  const routePath = typeof config.healthPath === 'string' && config.healthPath.startsWith('/')
3310
4480
  ? config.healthPath
3311
4481
  : '/__dsh_autorestart/health'
@@ -3345,6 +4515,11 @@ function registerHealthRoute(ctx, runtime, config) {
3345
4515
  generation: state.generation,
3346
4516
  pendingRestart: state.restart !== null,
3347
4517
  settleMs: config.settleMs,
4518
+ // The grace window and the quiet window the gate really uses: both
4519
+ // are needed to tell "waiting for the grace" from "somebody is busy".
4520
+ graceMs: config.restartGraceMs,
4521
+ effectiveSettleMs: runtime.effectiveSettleMs(),
4522
+ quietForMs: typeof verdict.quietForMs === 'number' ? verdict.quietForMs : 0,
3348
4523
  busyCount: verdict.blockedBy.length,
3349
4524
  agentCount,
3350
4525
  })
@@ -3408,6 +4583,9 @@ function registerHealthRoute(ctx, runtime, config) {
3408
4583
  allowRestart: verdict.allow === true,
3409
4584
  quietForMs: typeof verdict.quietForMs === 'number' ? verdict.quietForMs : 0,
3410
4585
  settleMs: config.settleMs,
4586
+ // The grace window and the quiet window the gate really uses.
4587
+ graceMs: config.restartGraceMs,
4588
+ effectiveSettleMs: runtime.effectiveSettleMs(),
3411
4589
  updatedAt: live.updatedAt,
3412
4590
  // The park route, so the client never has to guess the prefix, and
3413
4591
  // the goals this plugin already disarmed (they are re-armed after
@@ -3417,6 +4595,10 @@ function registerHealthRoute(ctx, runtime, config) {
3417
4595
  // The route that persists the one-time install choice, plus the
3418
4596
  // value that is effective right now.
3419
4597
  choiceUrl: resolveChoicePath(config),
4598
+ // The route that reports (and records) the one-time feature
4599
+ // announcements, so the client never has to guess the prefix.
4600
+ announcementsUrl: resolveAnnouncementsPath(config),
4601
+ backupOnRestart: config.backupOnRestart !== false,
3420
4602
  backupIncludePlugins: config.backupIncludePlugins !== false,
3421
4603
  parkedGoals: parkedGoalList(runtime),
3422
4604
  recovery: recoveryList(live),
@@ -3433,6 +4615,8 @@ function registerHealthRoute(ctx, runtime, config) {
3433
4615
  parkUrl: resolveParkPath(config),
3434
4616
  recoverUrl: resolveRecoverPath(config),
3435
4617
  choiceUrl: resolveChoicePath(config),
4618
+ announcementsUrl: resolveAnnouncementsPath(config),
4619
+ backupOnRestart: config.backupOnRestart !== false,
3436
4620
  backupIncludePlugins: config.backupIncludePlugins !== false,
3437
4621
  parkedGoals: [],
3438
4622
  recovery: [],
@@ -3491,6 +4675,8 @@ function registerHealthRoute(ctx, runtime, config) {
3491
4675
  const recoverRoute = buildRecoverRoute(ctx, runtime, config)
3492
4676
  const handoffRoute = buildHandoffRoute(ctx, runtime, config)
3493
4677
  const choiceRoute = buildChoiceRoute(ctx, runtime, config)
4678
+ const announcementsRoute = buildAnnouncementsRoute(ctx, runtime, config)
4679
+ const sleepRoute = sleep ? buildSleepRoute(ctx, runtime, config, sleep) : null
3494
4680
  try {
3495
4681
  const origin = typeof webServer.getOrigin === 'function' ? String(webServer.getOrigin()) : ''
3496
4682
  const portMatch = /:(\d+)\/?$/.exec(origin)
@@ -3522,6 +4708,18 @@ function registerHealthRoute(ctx, runtime, config) {
3522
4708
  } catch (error) {
3523
4709
  runtime.log.warn('could not register the choice route:', String(error))
3524
4710
  }
4711
+ try {
4712
+ target.effect(() => webServer.register(announcementsRoute), 'dsh-autorestart: announcements route')
4713
+ } catch (error) {
4714
+ runtime.log.warn('could not register the announcements route:', String(error))
4715
+ }
4716
+ if (sleepRoute !== null) {
4717
+ try {
4718
+ target.effect(() => webServer.register(sleepRoute), 'dsh-autorestart: sleep route')
4719
+ } catch (error) {
4720
+ runtime.log.warn('could not register the sleep route:', String(error))
4721
+ }
4722
+ }
3525
4723
  if (config.autoReloadPage !== false && typeof webServer.tapIndex === 'function') {
3526
4724
  try {
3527
4725
  target.effect(
@@ -3652,16 +4850,36 @@ function trackAgents(ctx, runtime, config) {
3652
4850
  * Plugin entry point.
3653
4851
  * @param ctx - Cordis plugin context.
3654
4852
  * @param rawConfig - the loader row config (merged over the built-in defaults).
4853
+ * @param {{
4854
+ * sleepNow?: () => number,
4855
+ * sleepSpawn?: Function,
4856
+ * }} [options] - the injectable primitives of the graceful sleep: they replace
4857
+ * the clock and the process spawner, so the whole flow can be driven without
4858
+ * waiting and without ever touching the machine. Production never passes them.
3655
4859
  */
3656
- export function apply(ctx, rawConfig) {
4860
+ export function apply(ctx, rawConfig, options = {}) {
3657
4861
  const config = normalizeConfig(rawConfig)
3658
4862
  // The one-time install choice lives in the profile row config (the single
3659
4863
  // source of truth). Only when the row does not set the key at all does the
3660
4864
  // degraded fallback file get a say.
3661
- const choice = resolveBackupIncludePlugins(rawConfig, resolveStateDir(config))
4865
+ const stateDir = resolveStateDir(config)
4866
+ const choice = resolveBackupIncludePlugins(rawConfig, stateDir)
3662
4867
  config.backupIncludePlugins = choice.includePlugins
3663
- const runtime = createRuntime(ctx, config, { backupChoiceSource: choice.source })
4868
+ // The same rule for the pre-restart backup itself: the profile row decides,
4869
+ // the fallback file fills the gap, the built-in default applies otherwise.
4870
+ const backupChoice = resolveBackupOnRestart(rawConfig, stateDir)
4871
+ config.backupOnRestart = backupChoice.backupOnRestart
4872
+ // The same rule for the graceful-sleep button: the profile row decides, the
4873
+ // fallback file fills the gap, the built-in default applies otherwise.
4874
+ const sleepChoice = resolveShowSleepButton(rawConfig, stateDir)
4875
+ config.showSleepButton = sleepChoice.showSleepButton
4876
+ const runtime = createRuntime(ctx, config, {
4877
+ backupChoiceSource: choice.source,
4878
+ })
3664
4879
  const log = runtime.log
4880
+ // The graceful sleep: the host plan and the state machine that waits for the
4881
+ // conversations, counts down and switches the machine off.
4882
+ const sleep = createSleepService(ctx, runtime, config, options)
3665
4883
 
3666
4884
  log.info('activated', {
3667
4885
  stateFile: runtime.stateFile,
@@ -3671,8 +4889,15 @@ export function apply(ctx, rawConfig) {
3671
4889
  desktopApp: runtime.env.isDesktopApp,
3672
4890
  appExePath: runtime.env.appExePath,
3673
4891
  configSchema: Config !== undefined,
4892
+ backupOnRestart: config.backupOnRestart,
3674
4893
  backupIncludePlugins: config.backupIncludePlugins,
3675
4894
  backupChoiceSource: choice.source,
4895
+ showSleepButton: config.showSleepButton,
4896
+ sleepChoiceSource: sleepChoice.source,
4897
+ // The FINAL plan: what the sleep feature would really do.
4898
+ sleepHost: sleep.invocation().kind,
4899
+ sleepSource: sleep.invocation().source,
4900
+ sleepSupported: sleep.invocation().supported,
3676
4901
  })
3677
4902
  if (Config === undefined) {
3678
4903
  log.warn('Config schema is unavailable in this build (schemastery could not be loaded); configuration still works through cordis.patch.yml and the built-in defaults')
@@ -3691,24 +4916,55 @@ export function apply(ctx, rawConfig) {
3691
4916
  // The boot recovery pass is the plugin's primary boot duty, so it is scheduled
3692
4917
  // BEFORE the auxiliary timers (the health-route registration retry below): a
3693
4918
  // fallback registered later must never shadow or postpone the recovery pass.
4919
+ //
4920
+ // The recorded shutdown intent is restored AFTER the recovery: a shutdown
4921
+ // that was asked for before the restart must wait for the work that resumes
4922
+ // with it — the machine goes off after everything is done, not before.
4923
+ const restoreSleepIntent = () => {
4924
+ const intent = sleep.intent()
4925
+ if (!intent || intent.armed !== true) return
4926
+ log.info('the shutdown intent survived the restart; the machine goes off after the work is done', {
4927
+ armedAt: intent.armedAt,
4928
+ })
4929
+ sleep.restore()
4930
+ }
3694
4931
  if (runtime.isNewBoot && config.resumeOnBoot) {
3695
4932
  runtime.recoverTimer = ctx.timeout(() => {
3696
- recoverAfterRestart(ctx, runtime, config).catch((error) => {
3697
- log.warn('boot recovery failed:', String(error))
3698
- })
4933
+ recoverAfterRestart(ctx, runtime, config)
4934
+ .then(() => { restoreSleepIntent() })
4935
+ .catch((error) => {
4936
+ log.warn('boot recovery failed:', String(error))
4937
+ restoreSleepIntent()
4938
+ })
3699
4939
  }, Math.max(0, config.recoveryDelayMs))
4940
+ } else {
4941
+ // No recovery pass: the intent is restored right away (a hot reload must
4942
+ // not lose it either).
4943
+ restoreSleepIntent()
3700
4944
  }
3701
4945
 
3702
4946
  registerTools(ctx, runtime, config)
3703
4947
  registerManualRestartGuard(ctx, runtime, config)
3704
4948
  registerSystemPrompt(ctx, runtime)
3705
- registerHealthRoute(ctx, runtime, config)
4949
+ registerHealthRoute(ctx, runtime, config, sleep)
3706
4950
 
3707
4951
  ctx.effect(() => () => {
3708
4952
  runtime.markDisposed()
3709
4953
  runtime.stopPolling()
4954
+ // The countdown timer belongs to the plugin: it must not survive it.
4955
+ sleep.controller.dispose()
3710
4956
  persistSafely(runtime)
3711
4957
  }, 'dsh-autorestart lifecycle')
3712
4958
  }
3713
4959
 
3714
- export { DEFAULTS, normalizeConfig, resolveStateFile, resolveStateDir, resolveChoicePath }
4960
+ export {
4961
+ DEFAULTS,
4962
+ normalizeConfig,
4963
+ resolveStateFile,
4964
+ resolveStateDir,
4965
+ resolveChoicePath,
4966
+ resolveSleepPath,
4967
+ resolveAnnouncementsPath,
4968
+ featureAnnouncements,
4969
+ pluginVersion,
4970
+ }