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/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,7 @@ import {
73
75
  runCommandNow,
74
76
  scheduleDetachedRestart,
75
77
  } from './lib/restart.js'
78
+ import { compareSemver } from './lib/semver.js'
76
79
  import {
77
80
  createSleepController,
78
81
  COUNTDOWN_CHOICES,
@@ -83,6 +86,47 @@ import {
83
86
  snapCountdownChoice,
84
87
  } from './lib/sleep.js'
85
88
 
89
+ /**
90
+ * The grace window: how long the host waits after the LAST conversation
91
+ * finished before it is really restarted or switched off.
92
+ *
93
+ * This plugin can only see the DSH agents. Other plugins (a memory plugin
94
+ * that writes its notes a few seconds after the conversation ended, a summary,
95
+ * an MCP callback) queue their work with a timer of their own — work that is
96
+ * invisible here. A short, configurable grace window is left for it.
97
+ *
98
+ * The values are the ones the settings page offers (in milliseconds).
99
+ */
100
+ const GRACE_CHOICES_MS = Object.freeze([0, 3000, 5000, 10000, 20000, 30000])
101
+
102
+ /** The grace window that is used when nothing was configured. */
103
+ const DEFAULT_GRACE_MS = 10000
104
+
105
+ /**
106
+ * Snap a grace value onto the nearest offered choice.
107
+ *
108
+ * A profile that carries a value that is not offered (a hand-edited row, an
109
+ * older build) must not break the boot: the value is snapped onto the
110
+ * nearest choice, exactly like the countdown length is.
111
+ *
112
+ * @param {unknown} value
113
+ * @returns {number} one of {@link GRACE_CHOICES_MS}
114
+ */
115
+ function snapGraceChoice(value) {
116
+ const raw = Number(value)
117
+ if (!Number.isFinite(raw) || raw < 0) return DEFAULT_GRACE_MS
118
+ let best = GRACE_CHOICES_MS[0]
119
+ let bestDistance = Math.abs(raw - best)
120
+ for (const choice of GRACE_CHOICES_MS) {
121
+ const distance = Math.abs(raw - choice)
122
+ if (distance < bestDistance) {
123
+ best = choice
124
+ bestDistance = distance
125
+ }
126
+ }
127
+ return best
128
+ }
129
+
86
130
  /**
87
131
  * The version of this plugin, as a fallback.
88
132
  *
@@ -90,17 +134,36 @@ import {
90
134
  * `pluginVersion()`), but a bundled installation may not ship that file — so
91
135
  * the constant below is the last resort. A test keeps the two in sync.
92
136
  */
93
- const PLUGIN_VERSION_FALLBACK = '0.5.0'
137
+ const PLUGIN_VERSION_FALLBACK = '0.6.1'
138
+
139
+ /**
140
+ * The version of the graceful-shutdown FEATURE, not the version of the package.
141
+ *
142
+ * The one-time announcement is bound to this constant. It used to follow the
143
+ * version in package.json, and that made every release resurrect the card the
144
+ * user had already answered (0.5.0 -> 0.6.1 brought "收工关机" back). A release
145
+ * is not news about the feature, so the two are decoupled.
146
+ *
147
+ * WHEN TO RAISE IT: only when the feature itself changed so much that the
148
+ * user has to hear about it again (the shutdown became a completely different
149
+ * thing, the button moved to another place, ...). Any new value works: the
150
+ * recorded version no longer matches, so the announcement is shown once more.
151
+ *
152
+ * WHEN NOT TO RAISE IT: a release, a bug fix, a small addition, a new option
153
+ * that the documentation explains. None of those deserve a card that
154
+ * interrupts the user a second time.
155
+ */
156
+ const SLEEP_FEATURE_VERSION = '0.5.0'
94
157
 
95
158
  /** The cached version of this plugin (read once per process). */
96
159
  let pluginVersionCache = null
97
160
 
98
161
  /**
99
- * The version of this plugin.
162
+ * The version of this plugin (the PACKAGE version).
100
163
  *
101
- * It is the version of the package itself: the feature announcements are
102
- * bound to it, so a new version of the plugin can announce a new feature (and
103
- * the announcement of an old version never comes back).
164
+ * It says which release is installed - not what the user has been told about.
165
+ * The feature announcements are bound to `SLEEP_FEATURE_VERSION`, so a release
166
+ * never brings an answered announcement back.
104
167
  *
105
168
  * @returns {string}
106
169
  */
@@ -126,19 +189,51 @@ function pluginVersion() {
126
189
  *
127
190
  * The install prompt only ever reaches a fresh installation, so an
128
191
  * installation that already existed would never learn about a new feature.
129
- * Every entry of this list is therefore announced once per version: the
130
- * announcement is shown until the user presses "知道了", and the Host records
131
- * the version it has shown, so it never comes back.
192
+ * Every entry of this list is therefore announced once per FEATURE version:
193
+ * the announcement is shown until the user presses "知道了", and the Host
194
+ * records the feature version it has shown, so it never comes back.
195
+ *
196
+ * The version of an entry is the version of the FEATURE, never the version of
197
+ * the package - see `SLEEP_FEATURE_VERSION`.
132
198
  *
133
199
  * @returns {{ feature: string, version: string }[]}
134
200
  */
135
201
  function featureAnnouncements() {
136
202
  return [
137
203
  // The graceful shutdown ("收工关机") and its moon button.
138
- { feature: 'sleep', version: pluginVersion() },
204
+ { feature: 'sleep', version: SLEEP_FEATURE_VERSION },
139
205
  ]
140
206
  }
141
207
 
208
+ /**
209
+ * Whether the user has already seen the announcement of one feature.
210
+ *
211
+ * The record is the version that was announced when the user answered. It
212
+ * counts as seen as soon as it is NOT OLDER than the current feature version
213
+ * - not only when it is exactly that version:
214
+ *
215
+ * - the announcement used to carry the version of the PACKAGE, so a user
216
+ * who answered it on the release 0.6.1 recorded "0.6.1" while the feature
217
+ * itself stands at "0.5.0". With an equality check that record would
218
+ * look stale, and the very same card would be shown to a user who already
219
+ * dismissed it;
220
+ * - a record that is not a version at all counts as seen as well: a broken
221
+ * record must not make the card come back for ever.
222
+ *
223
+ * A missing record counts as NOT seen: a missing record must never swallow an
224
+ * announcement.
225
+ *
226
+ * @param {unknown} recorded the version in the state file
227
+ * @param {string} current the current feature version
228
+ * @returns {boolean} true when the user has seen it
229
+ */
230
+ function isFeatureSeen(recorded, current) {
231
+ if (typeof recorded !== 'string' || recorded.trim() === '') return false
232
+ const order = compareSemver(recorded, current)
233
+ if (order === null) return true
234
+ return order >= 0
235
+ }
236
+
142
237
  /**
143
238
  * `@deepseek-ai/schemastery` resolves inside modules that live in the dsh installation,
144
239
  * but NOT from this bundle: a profile-installed bundle is a symlink into the user's
@@ -281,6 +376,12 @@ function buildConfigSchema() {
281
376
  // quiet window that must pass after the last conversation activity
282
377
  settleMs: schemastery.number().min(0).default(2000)
283
378
  .description('Milliseconds the host must stay quiet after the last conversation activity before a restart may fire.'),
379
+ // The grace window: the extra wait after the last conversation finished.
380
+ // Other plugins queue work with timers of their own (a memory plugin
381
+ // writing its notes, a summary, an MCP callback) and that work is
382
+ // invisible here — so a short, configurable grace window is left for it.
383
+ restartGraceMs: schemastery.number().min(0).default(DEFAULT_GRACE_MS)
384
+ .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).'),
284
385
  autoReloadPage: schemastery.boolean().default(true)
285
386
  .description('Reload the web page automatically when the host boot token changes.'),
286
387
  blockManualRestart: schemastery.boolean().default(true)
@@ -347,6 +448,7 @@ const DEFAULTS = {
347
448
  restartCommand: '',
348
449
  relaunchDelaySeconds: 2,
349
450
  settleMs: 2000,
451
+ restartGraceMs: DEFAULT_GRACE_MS,
350
452
  autoReloadPage: true,
351
453
  blockManualRestart: true,
352
454
  healthPath: '/__dsh_autorestart/health',
@@ -393,6 +495,9 @@ function normalizeConfig(raw) {
393
495
  // profile that still carries an old value (15, 45, …) is snapped onto the
394
496
  // nearest one instead of breaking, and 0 means "no countdown at all".
395
497
  config.sleepCountdownSeconds = snapCountdownChoice(config.sleepCountdownSeconds)
498
+ // The grace window is one of the offered choices as well: a hand-edited
499
+ // profile value is snapped onto the nearest one instead of breaking.
500
+ config.restartGraceMs = snapGraceChoice(config.restartGraceMs)
396
501
  if (typeof config.sleepCommand !== 'string') config.sleepCommand = ''
397
502
  if (typeof config.showSleepButton !== 'boolean') config.showSleepButton = true
398
503
  return config
@@ -673,7 +778,7 @@ const INSTALL_CHOICE_FILE = 'install-choice.json'
673
778
  * Read the recorded install choice from the fallback file.
674
779
  *
675
780
  * @param {string} stateDir
676
- * @returns {{ includePlugins: boolean|null, backupOnRestart: boolean|null, showSleepButton: boolean|null, sleepCountdownSeconds: number|null }|null}
781
+ * @returns {{ includePlugins: boolean|null, backupOnRestart: boolean|null, showSleepButton: boolean|null, sleepCountdownSeconds: number|null, restartGraceMs: number|null }|null}
677
782
  */
678
783
  function readInstallChoice(stateDir) {
679
784
  try {
@@ -687,6 +792,7 @@ function readInstallChoice(stateDir) {
687
792
  sleepCountdownSeconds: typeof parsed.sleepCountdownSeconds === 'number'
688
793
  ? parsed.sleepCountdownSeconds
689
794
  : null,
795
+ restartGraceMs: typeof parsed.restartGraceMs === 'number' ? parsed.restartGraceMs : null,
690
796
  }
691
797
  } catch {
692
798
  return null
@@ -700,7 +806,7 @@ function readInstallChoice(stateDir) {
700
806
  * erase the recorded backup scope (and the other way round).
701
807
  *
702
808
  * @param {string} stateDir
703
- * @param {{ includePlugins?: boolean, backupOnRestart?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number }} values
809
+ * @param {{ includePlugins?: boolean, backupOnRestart?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number, restartGraceMs?: number }} values
704
810
  * @returns {string} the file that was written
705
811
  */
706
812
  function writeInstallChoice(stateDir, values) {
@@ -710,6 +816,7 @@ function writeInstallChoice(stateDir, values) {
710
816
  backupOnRestart: null,
711
817
  showSleepButton: null,
712
818
  sleepCountdownSeconds: null,
819
+ restartGraceMs: null,
713
820
  }
714
821
  const payload = {
715
822
  includePlugins: typeof values.includePlugins === 'boolean' ? values.includePlugins : current.includePlugins,
@@ -718,6 +825,9 @@ function writeInstallChoice(stateDir, values) {
718
825
  sleepCountdownSeconds: typeof values.sleepCountdownSeconds === 'number'
719
826
  ? values.sleepCountdownSeconds
720
827
  : current.sleepCountdownSeconds,
828
+ restartGraceMs: typeof values.restartGraceMs === 'number'
829
+ ? values.restartGraceMs
830
+ : current.restartGraceMs,
721
831
  at: new Date().toISOString(),
722
832
  }
723
833
  fs.mkdirSync(stateDir, { recursive: true })
@@ -1143,9 +1253,89 @@ export function createRuntime(ctx, config, options = {}) {
1143
1253
  return count
1144
1254
  }
1145
1255
 
1256
+ /**
1257
+ * Whether this agent is a subagent (a delegated child), and which session
1258
+ * created it.
1259
+ *
1260
+ * The origin is the ONLY reliable marker: a forked session also carries a
1261
+ * parent session, but it is a top-level conversation, not a subagent.
1262
+ *
1263
+ * @param {any} agent
1264
+ * @returns {string|null} the parent session id, or null when this is not a
1265
+ * subagent
1266
+ */
1267
+ const subagentParentOf = (agent) => {
1268
+ try {
1269
+ if (!agent || typeof agent !== 'object') return null
1270
+ const session = agent.session && typeof agent.session === 'object' ? agent.session : null
1271
+ const header = session && session.header && typeof session.header === 'object' ? session.header : null
1272
+ const origin = agent.origin != null ? agent.origin : (header ? header.origin : undefined)
1273
+ if (origin !== 'subagent') return null
1274
+ const parent = agent.parentId != null
1275
+ ? agent.parentId
1276
+ : agent.parentSessionId != null
1277
+ ? agent.parentSessionId
1278
+ : (header ? header.parentSession : undefined)
1279
+ if (parent == null || parent === '') return null
1280
+ return String(parent)
1281
+ } catch {
1282
+ return null
1283
+ }
1284
+ }
1285
+
1286
+ /**
1287
+ * The readable identity of one blocker: WHAT it is (a session or a
1288
+ * subagent) and HOW it is called.
1289
+ *
1290
+ * A subagent has no title of its own, so it is named after the session that
1291
+ * created it ("<that session>'s subagent"). A raw session id never reaches
1292
+ * the interface: an unknown name is a generic one.
1293
+ *
1294
+ * @param {string} sessionId
1295
+ * @param {any} agent
1296
+ * @returns {{ kind: 'session'|'subagent', label: string|null, name: string, parentTitle: string|null, parentSessionId: string|null }}
1297
+ */
1298
+ const describeBlocker = (sessionId, agent) => {
1299
+ const parent = agent ? subagentParentOf(agent) : null
1300
+ if (parent === null) {
1301
+ let title = null
1302
+ try { title = sessionTitle(sessionId) } catch { /* a missing title is not a problem */ }
1303
+ const label = title ? String(title) : null
1304
+ return {
1305
+ kind: 'session',
1306
+ // The label is the title of the session itself.
1307
+ label,
1308
+ // The full name of the blocker: its title — or NOTHING when it has no
1309
+ // title. A generic name ("会话") says nothing and would end up in the
1310
+ // interface as "会话「会话」" or as a list of three identical
1311
+ // entries; the client decides what to write when the name is empty.
1312
+ name: label || '',
1313
+ parentTitle: null,
1314
+ parentSessionId: null,
1315
+ }
1316
+ }
1317
+ let parentTitle = null
1318
+ try { parentTitle = sessionTitle(parent) } catch { /* a missing title is not a problem */ }
1319
+ const title = parentTitle ? String(parentTitle) : null
1320
+ return {
1321
+ kind: 'subagent',
1322
+ // The label is the title of the session that created it: the UI shows
1323
+ // "子智能体(<that title>)".
1324
+ label: title,
1325
+ // The full name of the blocker: "<that session>'s subagent".
1326
+ name: title ? `${title}的子智能体` : '子智能体',
1327
+ parentTitle: title,
1328
+ parentSessionId: parent,
1329
+ }
1330
+ }
1331
+
1146
1332
  /**
1147
1333
  * Every conversation that is still working, subagents and teammates included.
1148
1334
  * 'running' is a live turn; 'queued' is inbox work that has not started yet.
1335
+ *
1336
+ * Every entry carries the kind of the blocker and a readable name, so the
1337
+ * interface can tell a session from a subagent and never has to show a raw
1338
+ * session id.
1149
1339
  */
1150
1340
  const busyAgents = () => {
1151
1341
  const out = []
@@ -1158,8 +1348,10 @@ export function createRuntime(ctx, config, options = {}) {
1158
1348
  for (const agent of agents) {
1159
1349
  try {
1160
1350
  if (!agent || !agent.id) continue
1161
- if (agent.status === 'running') out.push({ sessionId: String(agent.id), reason: 'running' })
1162
- else if (pendingInboxCount(agent) > 0) out.push({ sessionId: String(agent.id), reason: 'queued' })
1351
+ const sessionId = String(agent.id)
1352
+ const described = describeBlocker(sessionId, agent)
1353
+ if (agent.status === 'running') out.push({ sessionId, reason: 'running', ...described })
1354
+ else if (pendingInboxCount(agent) > 0) out.push({ sessionId, reason: 'queued', ...described })
1163
1355
  } catch {}
1164
1356
  }
1165
1357
  return out
@@ -1492,12 +1684,41 @@ export function createRuntime(ctx, config, options = {}) {
1492
1684
 
1493
1685
  const anyAgentRunning = () => busyAgents().length > 0
1494
1686
 
1495
- /** The two-layer restart gate: no busy conversation AND a quiet settle window. */
1687
+ /**
1688
+ * Whether a restart is still pending.
1689
+ *
1690
+ * A pending restart goes FIRST: the graceful shutdown waits for it — and for
1691
+ * the sessions that resume with it. The shutdown intent survives the
1692
+ * restart, so the machine goes off after everything is done.
1693
+ *
1694
+ * @returns {boolean}
1695
+ */
1696
+ const pendingRestart = () => state.restart !== null && state.restart !== undefined
1697
+
1698
+ /** How long the host has been quiet (no conversation activity at all). */
1699
+ const quietForMs = () => Math.max(0, Date.now() - lastActivityAt)
1700
+
1701
+ /**
1702
+ * The grace window: how much longer the host waits after the last
1703
+ * conversation finished, so that work other plugins queue with a timer (a
1704
+ * memory plugin writing its notes, a summary) still gets its chance.
1705
+ */
1706
+ const graceMs = () => config.restartGraceMs
1707
+
1708
+ /**
1709
+ * The quiet window the restart gate really uses.
1710
+ *
1711
+ * It is the settle window, but never shorter than the grace window: the
1712
+ * grace is the *extra* wait on top of the settle, not a replacement.
1713
+ */
1714
+ const effectiveSettleMs = () => Math.max(config.settleMs, config.restartGraceMs)
1715
+
1716
+ /** The two-layer restart gate: no busy conversation AND a quiet window. */
1496
1717
  const gate = () => restartGate(state, {
1497
1718
  busyAgents: busyAgents(),
1498
1719
  lastActivityAt,
1499
1720
  now: Date.now(),
1500
- settleMs: config.settleMs,
1721
+ settleMs: effectiveSettleMs(),
1501
1722
  })
1502
1723
 
1503
1724
  /**
@@ -1512,7 +1733,43 @@ export function createRuntime(ctx, config, options = {}) {
1512
1733
  */
1513
1734
  const titleCache = { at: 0, titles: new Map(), inFlight: false }
1514
1735
 
1515
- /** Store the titles of one session-list answer. Never throws. */
1736
+ /**
1737
+ * The title of one row of the session list.
1738
+ *
1739
+ * The title is a PROJECTION: the summary row carries it under
1740
+ * `projections.values.title` (the same place `dsh-session-reference` reads it
1741
+ * from), not under a `title` field of its own. A plain `title`/`name` is
1742
+ * accepted too, so a list with a different shape still works.
1743
+ *
1744
+ * @param {any} item one row of the session list
1745
+ * @returns {string} the title, or '' when the row carries none
1746
+ */
1747
+ const titleOfSummary = (item) => {
1748
+ try {
1749
+ if (!item || typeof item !== 'object') return ''
1750
+ const projections = item.projections && typeof item.projections === 'object'
1751
+ ? item.projections
1752
+ : null
1753
+ const values = projections && projections.values && typeof projections.values === 'object'
1754
+ ? projections.values
1755
+ : null
1756
+ const candidates = [
1757
+ values ? values.title : undefined,
1758
+ item.title,
1759
+ item.name,
1760
+ ]
1761
+ for (const candidate of candidates) {
1762
+ if (typeof candidate !== 'string') continue
1763
+ const title = candidate.trim()
1764
+ if (title !== '') return title
1765
+ }
1766
+ return ''
1767
+ } catch {
1768
+ return ''
1769
+ }
1770
+ }
1771
+
1772
+ /** Store the titles of one session-list answer. Never throws. */
1516
1773
  const applySessionTitles = (payload) => {
1517
1774
  const items = payload && Array.isArray(payload.items)
1518
1775
  ? payload.items
@@ -1520,9 +1777,13 @@ export function createRuntime(ctx, config, options = {}) {
1520
1777
  const titles = new Map()
1521
1778
  for (const item of items) {
1522
1779
  if (!item || typeof item !== 'object') continue
1523
- const id = item.id === undefined || item.id === null ? '' : String(item.id)
1780
+ // The session list names its rows `sessionId` (`id` is an older shape).
1781
+ const rawId = item.sessionId !== undefined && item.sessionId !== null
1782
+ ? item.sessionId
1783
+ : item.id
1784
+ const id = rawId === undefined || rawId === null ? '' : String(rawId)
1524
1785
  if (!id) continue
1525
- const title = typeof item.title === 'string' ? item.title.trim() : ''
1786
+ const title = titleOfSummary(item)
1526
1787
  if (title) titles.set(id, title)
1527
1788
  }
1528
1789
  titleCache.titles = titles
@@ -1813,7 +2074,7 @@ export function createRuntime(ctx, config, options = {}) {
1813
2074
  anyAgentRunning: busy.length > 0,
1814
2075
  busyAgents: busy,
1815
2076
  lastActivityAt,
1816
- settleMs: config.settleMs,
2077
+ settleMs: effectiveSettleMs(),
1817
2078
  now,
1818
2079
  softMs: config.deferSoftMs,
1819
2080
  hardMs: config.deferHardMs,
@@ -1858,7 +2119,7 @@ export function createRuntime(ctx, config, options = {}) {
1858
2119
  * is unavailable the choice degrades to a file in the plugin's state
1859
2120
  * directory and a warning is logged, so the choice is never silently lost.
1860
2121
  *
1861
- * @param {{ backupOnRestart?: boolean, backupIncludePlugins?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number }} changes
2122
+ * @param {{ backupOnRestart?: boolean, backupIncludePlugins?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number, restartGraceMs?: number }} changes
1862
2123
  * @returns {Promise<{ applied: 'config'|'fallback-file' }>}
1863
2124
  */
1864
2125
  const persistConfigChoice = async (changes) => {
@@ -1883,6 +2144,10 @@ export function createRuntime(ctx, config, options = {}) {
1883
2144
  wanted.sleepCountdownSeconds = changes.sleepCountdownSeconds
1884
2145
  fallback.sleepCountdownSeconds = changes.sleepCountdownSeconds
1885
2146
  }
2147
+ if (typeof changes.restartGraceMs === 'number') {
2148
+ wanted.restartGraceMs = changes.restartGraceMs
2149
+ fallback.restartGraceMs = changes.restartGraceMs
2150
+ }
1886
2151
  const editor = resolveConfigEditor(ctx)
1887
2152
  if (editor !== null) {
1888
2153
  try {
@@ -1919,11 +2184,16 @@ export function createRuntime(ctx, config, options = {}) {
1919
2184
  /**
1920
2185
  * The feature announcements the user has not seen yet.
1921
2186
  *
1922
- * A feature is pending when the recorded version is not the current one: a
1923
- * user who has seen the announcement of version 0.5.0 never sees it again,
1924
- * and a user who upgrades to a newer version sees the announcement of that
1925
- * version. A feature without a record is always pending — a missing record
1926
- * must never swallow an announcement.
2187
+ * A feature is pending when the recorded version is OLDER than the current
2188
+ * one: a user who has seen the announcement of the feature version 0.5.0
2189
+ * never sees it again - not with the next release of the plugin, because
2190
+ * the announcement follows the FEATURE and not the package. A user who
2191
+ * answered the card on an intermediate release (the announcement used to
2192
+ * carry the package version) has seen it as well: a record that is not
2193
+ * older than the current version never brings the card back.
2194
+ *
2195
+ * A feature without a record is always pending - a missing record must
2196
+ * never swallow an announcement.
1927
2197
  *
1928
2198
  * @returns {{ feature: string, version: string }[]}
1929
2199
  */
@@ -1931,7 +2201,7 @@ export function createRuntime(ctx, config, options = {}) {
1931
2201
  const seen = state.seenFeatureVersions && typeof state.seenFeatureVersions === 'object'
1932
2202
  ? state.seenFeatureVersions
1933
2203
  : {}
1934
- return featureAnnouncements().filter((entry) => seen[entry.feature] !== entry.version)
2204
+ return featureAnnouncements().filter((entry) => !isFeatureSeen(seen[entry.feature], entry.version))
1935
2205
  }
1936
2206
 
1937
2207
  /**
@@ -2074,6 +2344,31 @@ export function createRuntime(ctx, config, options = {}) {
2074
2344
  stopPolling,
2075
2345
  tick,
2076
2346
  anyAgentRunning,
2347
+ /**
2348
+ * Whether a restart is still pending (the graceful shutdown waits for it).
2349
+ *
2350
+ * @returns {boolean}
2351
+ */
2352
+ pendingRestart,
2353
+ /**
2354
+ * How long the host has been quiet (no conversation activity at all).
2355
+ *
2356
+ * @returns {number}
2357
+ */
2358
+ quietForMs,
2359
+ /**
2360
+ * The grace window: the extra wait after the last conversation finished.
2361
+ *
2362
+ * @returns {number} milliseconds
2363
+ */
2364
+ graceMs,
2365
+ /**
2366
+ * The quiet window the restart gate really uses: the settle window, but
2367
+ * never shorter than the grace window.
2368
+ *
2369
+ * @returns {number} milliseconds
2370
+ */
2371
+ effectiveSettleMs,
2077
2372
  executeRestart,
2078
2373
  flushSessions,
2079
2374
  releaseRequesters,
@@ -2782,7 +3077,9 @@ function registerTools(ctx, runtime, config) {
2782
3077
  'Report the dsh-autorestart lifecycle: whether a restart is pending, which sessions were '
2783
3078
  + 'checkpointed as interrupted or continuation-pending, why each blocking session blocks '
2784
3079
  + '("goal-active" = an armed goal keeps it busy, "goal-exhausted" = its goal can no longer be '
2785
- + 'resumed), the goals this plugin disarmed, the last recovery action/detail per session, '
3080
+ + 'resumed), the goals this plugin disarmed, how long the host has been quiet and how long '
3081
+ + 'the grace window is (the extra wait after the last conversation finished, for work other '
3082
+ + 'plugins queue with a timer), the last recovery action/detail per session, '
2786
3083
  + 'whether a session was handed off to a fresh session (target session id and time) or is over '
2787
3084
  + 'the model context window (such a session is never auto-resumed), whether the pre-restart '
2788
3085
  + 'backup also snapshots the installed plugins (backupIncludePlugins), the '
@@ -2808,7 +3105,33 @@ function registerTools(ctx, runtime, config) {
2808
3105
  pendingRestart: { type: 'boolean' },
2809
3106
  gateAllow: { type: 'boolean' },
2810
3107
  quietForMs: { type: 'number' },
2811
- blockedBy: { type: 'array', items: { type: 'object' } },
3108
+ graceMs: { type: 'number' },
3109
+ effectiveSettleMs: { type: 'number' },
3110
+ blockedBy: {
3111
+ type: 'array',
3112
+ items: {
3113
+ type: 'object',
3114
+ // Every blocker says WHAT it is (`session` or `subagent`) and
3115
+ // HOW it is called (`label`: the title of the session, or of the
3116
+ // session that created the subagent).
3117
+ //
3118
+ // The title fields are NULLABLE: a session whose title could not
3119
+ // be resolved reports `null`, and a strict schema would reject
3120
+ // the whole tool output for it. The tool schema has no type
3121
+ // arrays, so "string or null" is declared as a `oneOf`.
3122
+ properties: {
3123
+ sessionId: { type: 'string' },
3124
+ reason: { type: 'string' },
3125
+ busyReason: { type: 'string' },
3126
+ kind: { type: 'string', enum: ['session', 'subagent', 'restart-pending'] },
3127
+ label: { oneOf: [{ type: 'string' }, { type: 'null' }] },
3128
+ name: { type: 'string' },
3129
+ parentTitle: { oneOf: [{ type: 'string' }, { type: 'null' }] },
3130
+ parentSessionId: { oneOf: [{ type: 'string' }, { type: 'null' }] },
3131
+ title: { oneOf: [{ type: 'string' }, { type: 'null' }] },
3132
+ },
3133
+ },
3134
+ },
2812
3135
  parkedGoals: { type: 'array', items: { type: 'object' } },
2813
3136
  recovery: { type: 'array', items: { type: 'object' } },
2814
3137
  handoffs: { type: 'array', items: { type: 'object' } },
@@ -2835,6 +3158,10 @@ function registerTools(ctx, runtime, config) {
2835
3158
  pendingRestart: state.restart !== null,
2836
3159
  gateAllow: verdict.allow,
2837
3160
  quietForMs: Math.round(verdict.quietForMs),
3161
+ // The grace window (the extra wait after the last conversation
3162
+ // finished) and the quiet window the gate really uses.
3163
+ graceMs: runtime.graceMs(),
3164
+ effectiveSettleMs: runtime.effectiveSettleMs(),
2838
3165
  blockedBy: blockingDetails(verdict.blockedBy),
2839
3166
  parkedGoals: parkedGoalDetails(),
2840
3167
  recovery: recoveryList(state),
@@ -3564,7 +3891,8 @@ function buildHandoffRoute(ctx, runtime, config) {
3564
3891
  *
3565
3892
  * The record lives in the checkpoint file, so an announcement that was shown
3566
3893
  * once never comes back — not with the next boot and not with the next
3567
- * version of the plugin.
3894
+ * release of the plugin. Only a new FEATURE version (see
3895
+ * `SLEEP_FEATURE_VERSION`) brings it back, on purpose.
3568
3896
  */
3569
3897
  function buildAnnouncementsRoute(ctx, runtime, config) {
3570
3898
  const routePath = resolveAnnouncementsPath(config)
@@ -3682,7 +4010,8 @@ function buildChoiceRoute(ctx, runtime, config) {
3682
4010
  const hasBackup = Object.prototype.hasOwnProperty.call(body, 'backupOnRestart')
3683
4011
  const hasShow = Object.prototype.hasOwnProperty.call(body, 'showSleepButton')
3684
4012
  const hasSeconds = Object.prototype.hasOwnProperty.call(body, 'sleepCountdownSeconds')
3685
- if (!hasInclude && !hasBackupInclude && !hasBackup && !hasShow && !hasSeconds) {
4013
+ const hasGrace = Object.prototype.hasOwnProperty.call(body, 'restartGraceMs')
4014
+ if (!hasInclude && !hasBackupInclude && !hasBackup && !hasShow && !hasSeconds && !hasGrace) {
3686
4015
  // No key was asked for: there is nothing to persist.
3687
4016
  res.statusCode = 400
3688
4017
  res.setHeader('content-type', 'application/json; charset=utf-8')
@@ -3690,7 +4019,7 @@ function buildChoiceRoute(ctx, runtime, config) {
3690
4019
  res.end(JSON.stringify({
3691
4020
  ok: false,
3692
4021
  error: 'no-choice',
3693
- 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 }.',
4022
+ 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 }.',
3694
4023
  }))
3695
4024
  return
3696
4025
  }
@@ -3751,6 +4080,17 @@ function buildChoiceRoute(ctx, runtime, config) {
3751
4080
  }))
3752
4081
  return
3753
4082
  }
4083
+ if (hasGrace && GRACE_CHOICES_MS.indexOf(body.restartGraceMs) === -1) {
4084
+ res.statusCode = 400
4085
+ res.setHeader('content-type', 'application/json; charset=utf-8')
4086
+ res.setHeader('cache-control', 'no-store')
4087
+ res.end(JSON.stringify({
4088
+ ok: false,
4089
+ error: 'restartGraceMs-must-be-offered',
4090
+ instruction: `Send { "restartGraceMs": ${GRACE_CHOICES_MS.join(' | ')} }.`,
4091
+ }))
4092
+ return
4093
+ }
3754
4094
  const changes = {}
3755
4095
  // The route field is `includePlugins`, the configuration key is
3756
4096
  // `backupIncludePlugins` (the profile row keeps the plugin's own name).
@@ -3759,6 +4099,7 @@ function buildChoiceRoute(ctx, runtime, config) {
3759
4099
  if (hasBackup) changes.backupOnRestart = body.backupOnRestart === true
3760
4100
  if (hasShow) changes.showSleepButton = body.showSleepButton === true
3761
4101
  if (hasSeconds) changes.sleepCountdownSeconds = body.sleepCountdownSeconds
4102
+ if (hasGrace) changes.restartGraceMs = body.restartGraceMs
3762
4103
  const result = await runtime.applyConfigChoice(changes)
3763
4104
  // The one-time install prompt explained the features to the user, so
3764
4105
  // the announcement of a fresh installation must not follow it.
@@ -3779,6 +4120,7 @@ function buildChoiceRoute(ctx, runtime, config) {
3779
4120
  if (hasBackup) answer.backupOnRestart = body.backupOnRestart === true
3780
4121
  if (hasShow) answer.showSleepButton = body.showSleepButton === true
3781
4122
  if (hasSeconds) answer.sleepCountdownSeconds = body.sleepCountdownSeconds
4123
+ if (hasGrace) answer.restartGraceMs = body.restartGraceMs
3782
4124
  res.statusCode = 200
3783
4125
  res.setHeader('content-type', 'application/json; charset=utf-8')
3784
4126
  res.setHeader('cache-control', 'no-store')
@@ -3816,13 +4158,39 @@ function sleepDryRun() {
3816
4158
  * happened). The sleep feature counts down on screen itself, so it only needs
3817
4159
  * the busy check — and it keeps `settleMs` out of the picture entirely.
3818
4160
  *
4161
+ * The GRACE window is the exception: it is not a settle window but the extra
4162
+ * wait for work this plugin cannot see (a memory plugin writing its notes a
4163
+ * few seconds after the conversation ended). It is honoured BEFORE the
4164
+ * countdown starts, so the countdown is never pushed back by it.
4165
+ *
3819
4166
  * The restart gate itself is untouched: the restart feature keeps its quiet
3820
- * window exactly as it was.
4167
+ * window exactly as it was (plus the same grace).
3821
4168
  *
3822
4169
  * @param {object} runtime
3823
- * @returns {{ allow: boolean, reason: 'busy'|'quiet', blockedBy: any[] }}
4170
+ * @returns {{ allow: boolean, reason: 'busy'|'grace'|'quiet', blockedBy: any[] }}
3824
4171
  */
3825
4172
  function sleepGate(runtime) {
4173
+ // A pending restart goes FIRST: the shutdown waits for it — and for the
4174
+ // sessions that resume with it. The intent survives the restart, so the
4175
+ // machine goes off after everything is done, not before.
4176
+ let pending = false
4177
+ try {
4178
+ pending = typeof runtime.pendingRestart === 'function' && runtime.pendingRestart() === true
4179
+ } catch {
4180
+ pending = false
4181
+ }
4182
+ if (pending) {
4183
+ return {
4184
+ allow: false,
4185
+ reason: 'restart-pending',
4186
+ blockedBy: [{
4187
+ sessionId: 'restart-pending',
4188
+ kind: 'restart-pending',
4189
+ label: '待执行的重启',
4190
+ name: '待执行的重启',
4191
+ }],
4192
+ }
4193
+ }
3826
4194
  let busy = []
3827
4195
  try {
3828
4196
  busy = runtime.busyAgents()
@@ -3831,6 +4199,16 @@ function sleepGate(runtime) {
3831
4199
  }
3832
4200
  const list = Array.isArray(busy) ? busy : []
3833
4201
  if (list.length > 0) return { allow: false, reason: 'busy', blockedBy: list }
4202
+ // The grace window: the last conversation may have finished a moment ago,
4203
+ // and other plugins still have work queued that this plugin cannot see.
4204
+ let quiet = 0
4205
+ let grace = 0
4206
+ try { quiet = Number(runtime.quietForMs()) } catch { quiet = 0 }
4207
+ try { grace = Number(runtime.graceMs()) } catch { grace = 0 }
4208
+ if (!Number.isFinite(quiet) || quiet < 0) quiet = 0
4209
+ if (Number.isFinite(grace) && grace > 0 && quiet < grace) {
4210
+ return { allow: false, reason: 'grace', blockedBy: [] }
4211
+ }
3834
4212
  return { allow: true, reason: 'quiet', blockedBy: [] }
3835
4213
  }
3836
4214
 
@@ -3912,6 +4290,36 @@ function createSleepService(ctx, runtime, config, options = {}) {
3912
4290
  busyAgents: () => runtime.busyAgents(),
3913
4291
  gate,
3914
4292
  titleOf: (sessionId) => runtime.sessionTitle(sessionId),
4293
+ // The grace window: after the last conversation finished the machine
4294
+ // waits a little longer, so that work this plugin cannot see (a memory
4295
+ // plugin writing its notes, a summary) still gets its chance.
4296
+ quietForMs: () => runtime.quietForMs(),
4297
+ graceMs: () => runtime.graceMs(),
4298
+ // A pending restart goes first: the shutdown waits for it.
4299
+ restartPending: () => {
4300
+ const pending = runtime.pendingRestart()
4301
+ if (!pending) return { pending: false, blockers: [] }
4302
+ return {
4303
+ pending: true,
4304
+ blockers: [{
4305
+ sessionId: 'restart-pending',
4306
+ kind: 'restart-pending',
4307
+ label: '待执行的重启',
4308
+ name: '待执行的重启',
4309
+ }],
4310
+ }
4311
+ },
4312
+ // The intent is spent when the machine went off, when the shutdown failed
4313
+ // and when the user cancelled it: the checkpoint must not keep it.
4314
+ onDisarm: (reason) => {
4315
+ try {
4316
+ clearSleep(runtime.state)
4317
+ runtime.persist()
4318
+ runtime.log.info('the shutdown intent was cleared:', reason)
4319
+ } catch (error) {
4320
+ runtime.log.warn('could not clear the shutdown intent:', String(error))
4321
+ }
4322
+ },
3915
4323
  countdownSeconds: seconds,
3916
4324
  hostLabel: final.shortLabel,
3917
4325
  supported: final.supported,
@@ -3960,6 +4368,46 @@ function createSleepService(ctx, runtime, config, options = {}) {
3960
4368
  return liveController
3961
4369
  },
3962
4370
  invocation,
4371
+ /**
4372
+ * Arm the shutdown AND remember the intent.
4373
+ *
4374
+ * The intent lives in the checkpoint file, so it survives a restart: a
4375
+ * shutdown asked for while a restart is pending waits for that restart —
4376
+ * and for the sessions that resume with it.
4377
+ *
4378
+ * @returns {object} the sleep status
4379
+ */
4380
+ arm: () => {
4381
+ try {
4382
+ armSleep(runtime.state, {
4383
+ armedAt: Date.now(),
4384
+ countdownSeconds: config.sleepCountdownSeconds,
4385
+ })
4386
+ runtime.persist()
4387
+ } catch (error) {
4388
+ runtime.log.warn('could not record the shutdown intent:', String(error))
4389
+ }
4390
+ return liveController.arm()
4391
+ },
4392
+ /**
4393
+ * Cancel the shutdown: the intent is gone for good (the disarm hook of
4394
+ * the controller clears the checkpoint).
4395
+ *
4396
+ * @returns {object} the sleep status
4397
+ */
4398
+ cancel: () => liveController.cancel(),
4399
+ /**
4400
+ * The recorded shutdown intent, if there is one.
4401
+ *
4402
+ * @returns {{ armed: true, armedAt: number, countdownSeconds: number|null }|null}
4403
+ */
4404
+ intent: () => runtime.state.sleep,
4405
+ /**
4406
+ * Arm the shutdown from a recorded intent (the boot restore).
4407
+ *
4408
+ * @returns {object} the sleep status
4409
+ */
4410
+ restore: () => liveController.arm(),
3963
4411
  }
3964
4412
  }
3965
4413
 
@@ -3968,17 +4416,26 @@ function createSleepService(ctx, runtime, config, options = {}) {
3968
4416
  *
3969
4417
  * @param {{ plan: object, controller: object, invocation: () => object }} sleep
3970
4418
  * @param {object} config
4419
+ * @param {object} [runtime] the runtime (for the quiet window)
3971
4420
  * @returns {object}
3972
4421
  */
3973
- function sleepStatusPayload(sleep, config) {
4422
+ function sleepStatusPayload(sleep, config, runtime) {
3974
4423
  const status = sleep.controller.status()
3975
4424
  const invocation = sleep.invocation()
4425
+ const intent = typeof sleep.intent === 'function' ? sleep.intent() : null
3976
4426
  return {
3977
4427
  ok: true,
3978
4428
  phase: status.phase,
3979
4429
  armed: status.armed,
3980
4430
  showSleepButton: config.showSleepButton !== false,
3981
4431
  countdownSeconds: status.countdownSeconds,
4432
+ // The grace window and how long the host has been quiet: both are needed
4433
+ // to tell "the machine is waiting for the grace" from "somebody is busy".
4434
+ graceMs: config.restartGraceMs,
4435
+ quietForMs: runtime && typeof runtime.quietForMs === 'function' ? runtime.quietForMs() : 0,
4436
+ // When the shutdown was armed: the intent has to survive a restart, so the
4437
+ // time is reported for the troubleshooting.
4438
+ armedAt: intent && typeof intent.armedAt === 'number' ? intent.armedAt : null,
3982
4439
  /**
3983
4440
  * The pre-restart backup scope, straight from the effective configuration:
3984
4441
  * the settings page reads its two switches from here.
@@ -4040,7 +4497,7 @@ function buildSleepRoute(ctx, runtime, config, sleep) {
4040
4497
  }
4041
4498
  const method = req && typeof req.method === 'string' ? req.method.toUpperCase() : 'GET'
4042
4499
  if (method === 'GET') {
4043
- answer(sleepStatusPayload(sleep, config))
4500
+ answer(sleepStatusPayload(sleep, config, runtime))
4044
4501
  return
4045
4502
  }
4046
4503
  if (method !== 'POST') {
@@ -4062,11 +4519,12 @@ function buildSleepRoute(ctx, runtime, config, sleep) {
4062
4519
  return
4063
4520
  }
4064
4521
  if (action === 'arm') {
4065
- sleep.controller.arm()
4522
+ // The intent is recorded too: it survives a restart.
4523
+ sleep.arm()
4066
4524
  } else {
4067
- sleep.controller.cancel()
4525
+ sleep.cancel()
4068
4526
  }
4069
- answer(sleepStatusPayload(sleep, config))
4527
+ answer(sleepStatusPayload(sleep, config, runtime))
4070
4528
  }).catch((error) => {
4071
4529
  answer({ ok: false, error: describeError(error) }, 400)
4072
4530
  })
@@ -4115,6 +4573,11 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
4115
4573
  generation: state.generation,
4116
4574
  pendingRestart: state.restart !== null,
4117
4575
  settleMs: config.settleMs,
4576
+ // The grace window and the quiet window the gate really uses: both
4577
+ // are needed to tell "waiting for the grace" from "somebody is busy".
4578
+ graceMs: config.restartGraceMs,
4579
+ effectiveSettleMs: runtime.effectiveSettleMs(),
4580
+ quietForMs: typeof verdict.quietForMs === 'number' ? verdict.quietForMs : 0,
4118
4581
  busyCount: verdict.blockedBy.length,
4119
4582
  agentCount,
4120
4583
  })
@@ -4178,6 +4641,9 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
4178
4641
  allowRestart: verdict.allow === true,
4179
4642
  quietForMs: typeof verdict.quietForMs === 'number' ? verdict.quietForMs : 0,
4180
4643
  settleMs: config.settleMs,
4644
+ // The grace window and the quiet window the gate really uses.
4645
+ graceMs: config.restartGraceMs,
4646
+ effectiveSettleMs: runtime.effectiveSettleMs(),
4181
4647
  updatedAt: live.updatedAt,
4182
4648
  // The park route, so the client never has to guess the prefix, and
4183
4649
  // the goals this plugin already disarmed (they are re-armed after
@@ -4508,12 +4974,31 @@ export function apply(ctx, rawConfig, options = {}) {
4508
4974
  // The boot recovery pass is the plugin's primary boot duty, so it is scheduled
4509
4975
  // BEFORE the auxiliary timers (the health-route registration retry below): a
4510
4976
  // fallback registered later must never shadow or postpone the recovery pass.
4977
+ //
4978
+ // The recorded shutdown intent is restored AFTER the recovery: a shutdown
4979
+ // that was asked for before the restart must wait for the work that resumes
4980
+ // with it — the machine goes off after everything is done, not before.
4981
+ const restoreSleepIntent = () => {
4982
+ const intent = sleep.intent()
4983
+ if (!intent || intent.armed !== true) return
4984
+ log.info('the shutdown intent survived the restart; the machine goes off after the work is done', {
4985
+ armedAt: intent.armedAt,
4986
+ })
4987
+ sleep.restore()
4988
+ }
4511
4989
  if (runtime.isNewBoot && config.resumeOnBoot) {
4512
4990
  runtime.recoverTimer = ctx.timeout(() => {
4513
- recoverAfterRestart(ctx, runtime, config).catch((error) => {
4514
- log.warn('boot recovery failed:', String(error))
4515
- })
4991
+ recoverAfterRestart(ctx, runtime, config)
4992
+ .then(() => { restoreSleepIntent() })
4993
+ .catch((error) => {
4994
+ log.warn('boot recovery failed:', String(error))
4995
+ restoreSleepIntent()
4996
+ })
4516
4997
  }, Math.max(0, config.recoveryDelayMs))
4998
+ } else {
4999
+ // No recovery pass: the intent is restored right away (a hot reload must
5000
+ // not lose it either).
5001
+ restoreSleepIntent()
4517
5002
  }
4518
5003
 
4519
5004
  registerTools(ctx, runtime, config)
@@ -4540,4 +5025,6 @@ export {
4540
5025
  resolveAnnouncementsPath,
4541
5026
  featureAnnouncements,
4542
5027
  pluginVersion,
5028
+ PLUGIN_VERSION_FALLBACK,
5029
+ SLEEP_FEATURE_VERSION,
4543
5030
  }