dsh-autorestart 0.6.2 → 0.7.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
@@ -26,6 +26,7 @@ import path from 'node:path'
26
26
  import {
27
27
  armSleep,
28
28
  beginBoot,
29
+ cancelOwnRestart,
29
30
  cancelRestart,
30
31
  clearSleep,
31
32
  disarmedRecord,
@@ -37,6 +38,7 @@ import {
37
38
  parkedRecord,
38
39
  pruneSessions,
39
40
  readState,
41
+ recordOnboarding,
40
42
  recoverableSessions,
41
43
  requestRestart,
42
44
  setContextOverflow,
@@ -62,18 +64,70 @@ import {
62
64
  } from './lib/lifecycle.js'
63
65
  import {
64
66
  backupsRoot,
67
+ claimRollbackLock,
65
68
  clearRollbackReport,
66
69
  createBackup,
70
+ listBackups,
67
71
  readRollbackReport,
72
+ releaseRollbackLock,
73
+ restoreBackup,
74
+ ROLLBACK_LOCK_NAME,
68
75
  ROLLBACK_REPORT_NAME,
69
76
  } from './lib/backup.js'
77
+ import {
78
+ accessFilePath,
79
+ readAccessRecord,
80
+ resolveAccessUrl,
81
+ writeAccessRecord,
82
+ } from './lib/access.js'
83
+ import {
84
+ detectSupervisor,
85
+ installSupervisor,
86
+ planSupervisorInstall,
87
+ readSupervisorConfig,
88
+ requestSupervisorAction,
89
+ supervisorPaths,
90
+ supervisorStatus,
91
+ uninstallSupervisor,
92
+ writeRollbackRequest,
93
+ } from './lib/supervisor.js'
94
+ import { composeFragment, detectInitSystems } from './lib/supervisor-install.js'
95
+ import { classifyStartup } from './lib/startup-mode.js'
96
+ import { detectHostKind } from './lib/host-kind.js'
97
+ import { planAutostartAdvice } from './lib/autostart-advice.js'
98
+ import { buildDiagnostics, ISSUE_URL_BASE, SUPPORT_EMAIL } from './lib/diagnostics.js'
99
+ import { countBlockerTasks } from './lib/blockers.js'
100
+ import {
101
+ buildDryRunPlan,
102
+ composeRestartFragment,
103
+ deriveStartCommand,
104
+ detectContainer,
105
+ detectExternalSupervisor,
106
+ inspectExternalSupervisor,
107
+ pickFreePort,
108
+ } from './lib/supervisor-config.js'
109
+ import {
110
+ buildRestartVerdict,
111
+ buildSnapshotRecord,
112
+ evaluateRestartChecks,
113
+ readRestartVerdict,
114
+ RESTART_RESULTS,
115
+ summarizeRestartVerdict,
116
+ writeRestartVerdict,
117
+ } from './lib/restart-verdict.js'
118
+ import { waitForHealthyInstance } from './lib/health.js'
70
119
  import {
71
120
  buildCustomCommand,
72
121
  buildWatchScript,
122
+ DEFAULT_COMMAND_TIMEOUT_MS,
123
+ DEFAULT_ROLLBACK_COPY_TIMEOUT_MS,
73
124
  DEFAULT_ROLLBACK_TIMEOUT_MS,
74
125
  detectEnvironment,
126
+ launchDetachedWatcher,
127
+ readProcessChain,
75
128
  runCommandNow,
76
129
  scheduleDetachedRestart,
130
+ writeTextFileAtomic,
77
131
  } from './lib/restart.js'
78
132
  import { compareSemver } from './lib/semver.js'
79
133
  import { isWorkSessionEvent } from './lib/activity.js'
@@ -125,6 +179,114 @@ const DEFAULT_RESTART_GRACE_MS = 0
125
179
  */
126
180
  const LONG_WAIT_LOG_INTERVAL_MS = 60000
127
181
 
182
+ /**
183
+ * The marker the plugin writes when it has decided the restart ITSELF.
184
+ *
185
+ * The rollback watcher is the fallback for the case that the plugin dies
186
+ * with the host (a hosted restart kills the very process the plugin runs in).
187
+ * When the plugin survives the restart command, it decides alone — and writes
188
+ * this marker, so the watcher stands down instead of rolling back a restart
189
+ * that was fine.
190
+ */
191
+ const RESTART_OUTCOME_FILE = 'restart-outcome.json'
192
+
193
+ /**
194
+ * The name of the rollback report of a restart that the plugin decided itself.
195
+ *
196
+ * It is the same file the detached watcher writes (`last-rollback.json`), so
197
+ * the interface shows the rollback either way.
198
+ */
199
+ const RESTART_REPORT_NAME = ROLLBACK_REPORT_NAME
200
+
201
+ /**
202
+ * Resolve the connection service of the host, when it provides one.
203
+ *
204
+ * The connection service signs the access URL of the running host (the boot
205
+ * token). It is optional: a host without it still gets the plain origin.
206
+ *
207
+ * @param {any} ctx
208
+ * @returns {any|null}
209
+ */
210
+ function resolveConnectionService(ctx) {
211
+ try {
212
+ if (ctx && ctx.connection && typeof ctx.connection.authenticatedUrl === 'function') return ctx.connection
213
+ } catch {}
214
+ try {
215
+ if (ctx && typeof ctx.get === 'function') {
216
+ const service = ctx.get('connection')
217
+ if (service && typeof service.authenticatedUrl === 'function') return service
218
+ }
219
+ } catch {}
220
+ return null
221
+ }
222
+
223
+ /**
224
+ * The access link of the running instance, or null.
225
+ *
226
+ * @param {any} runtime
227
+ * @returns {string|null}
228
+ */
229
+ function accessUrlOf(runtime) {
230
+ try {
231
+ const record = runtime.accessRecord
232
+ if (record && typeof record.url === 'string' && record.url !== '') return record.url
233
+ } catch {}
234
+ return null
235
+ }
236
+
237
+ /**
238
+ * Publish the instance record of the running host.
239
+ *
240
+ * The record carries three things that nobody else knows:
241
+ *
242
+ * - the PORT this instance listens on (the desktop app uses a random one,
243
+ * so the port of the previous instance is worthless);
244
+ * - the BOOT ID of this instance, so a watcher can tell "the new instance"
245
+ * from "the old one";
246
+ * - the ACCESS LINK with the boot token, so the user finds the working link
247
+ * after a restart instead of being locked out by a 401.
248
+ *
249
+ * The file is written with the mode 0600: it holds a secret.
250
+ *
251
+ * @param {any} ctx the plugin context
252
+ * @param {any} runtime the runtime of this plugin
253
+ * @param {object} config the normalized config
254
+ * @param {string} origin the origin of the web server (`protocol//host:port`)
255
+ * @returns {{ ok: boolean, path: string|null, url: string|null, error: string|null }}
256
+ */
257
+ function publishInstanceRecord(ctx, runtime, config, origin) {
258
+ const stateDir = path.dirname(runtime.stateFile)
259
+ try {
260
+ const { url, token } = resolveAccessUrl({
261
+ origin,
262
+ connection: resolveConnectionService(ctx),
263
+ })
264
+ const written = writeAccessRecord({
265
+ stateDir,
266
+ bootId: runtime.bootId,
267
+ pid: process.pid,
268
+ port: runtime.webPort,
269
+ url,
270
+ token,
271
+ })
272
+ if (!written.ok) {
273
+ runtime.log.warn('could not publish the instance record:', written.error)
274
+ return { ok: false, path: written.path, url, error: written.error }
275
+ }
276
+ runtime.log.info('the instance record was published', {
277
+ path: written.path,
278
+ port: runtime.webPort,
279
+ // The token itself is a secret: the record carries it, the log only
280
+ // says that there is one.
281
+ hasToken: Boolean(token),
282
+ })
283
+ return { ok: true, path: written.path, url, error: null }
284
+ } catch (error) {
285
+ runtime.log.warn('could not publish the instance record:', String(error))
286
+ return { ok: false, path: null, url: null, error: String(error) }
287
+ }
288
+ }
289
+
128
290
  /**
129
291
  * Snap a reserve value onto the nearest offered choice.
130
292
  *
@@ -158,7 +320,7 @@ function snapGraceChoice(value, fallback = DEFAULT_RESTART_GRACE_MS) {
158
320
  * `pluginVersion()`), but a bundled installation may not ship that file — so
159
321
  * the constant below is the last resort. A test keeps the two in sync.
160
322
  */
161
- const PLUGIN_VERSION_FALLBACK = '0.6.2'
323
+ const PLUGIN_VERSION_FALLBACK = '0.7.0'
162
324
 
163
325
  /**
164
326
  * The version of the graceful-shutdown FEATURE, not the version of the package.
@@ -179,6 +341,14 @@ const PLUGIN_VERSION_FALLBACK = '0.6.2'
179
341
  */
180
342
  const SLEEP_FEATURE_VERSION = '0.5.0'
181
343
 
344
+ /**
345
+ * The version of the self-starting supervisor ("开机自启守护").
346
+ *
347
+ * The one-time question about it is bound to this version: a release never
348
+ * brings an answered question back.
349
+ */
350
+ const SUPERVISOR_FEATURE_VERSION = '0.7.0'
351
+
182
352
  /** The cached version of this plugin (read once per process). */
183
353
  let pluginVersionCache = null
184
354
 
@@ -226,6 +396,8 @@ function featureAnnouncements() {
226
396
  return [
227
397
  // The graceful shutdown ("收工关机") and its moon button.
228
398
  { feature: 'sleep', version: SLEEP_FEATURE_VERSION },
399
+ // The self-starting supervisor ("开机自启守护").
400
+ { feature: 'supervisor', version: SUPERVISOR_FEATURE_VERSION },
229
401
  ]
230
402
  }
231
403
 
@@ -442,6 +614,30 @@ function buildConfigSchema() {
442
614
  .description('How many snapshots are kept; older ones are deleted automatically.'),
443
615
  rollbackTimeoutMs: schemastery.number().min(1000).default(90000)
444
616
  .description('Milliseconds the detached watcher waits for the relaunched host before it restores the snapshot.'),
617
+ // The self-starting supervisor: a separate process that brings the host
618
+ // back when it dies. See the "开机自启" section of the settings.
619
+ supervisorMode: schemastery.union(['auto', 'installed', 'external', 'off']).default('auto')
620
+ .description('Who starts this host: auto = nobody decided yet, installed = the supervisor of this plugin, external = your own way, off = no automatic start.'),
621
+ supervisorKind: schemastery.union([
622
+ schemastery.const(''),
623
+ schemastery.const('windows-schtasks'),
624
+ schemastery.const('windows-startup'),
625
+ schemastery.const('systemd-user'),
626
+ schemastery.const('systemd-system'),
627
+ ]).default('')
628
+ .description('Which form of the self-start record was installed (empty = the default of this platform).'),
629
+ supervisorRestartDelayMs: schemastery.number().min(0).default(3000)
630
+ .description('Milliseconds the supervisor waits before it starts the host again after it died.'),
631
+ supervisorRestartDelayMaxMs: schemastery.number().min(0).default(30000)
632
+ .description('The upper bound of that delay: the supervisor backs off further with every failed start, but never longer than this.'),
633
+ supervisorStartTimeoutMs: schemastery.number().min(1000).default(90000)
634
+ .description('Milliseconds the supervisor waits for the host to answer its health route before it restores the snapshot.'),
635
+ supervisorKillGraceMs: schemastery.number().min(0).default(15000)
636
+ .description('Milliseconds the supervisor waits between asking the host to stop and killing it.'),
637
+ supervisorPort: schemastery.number().min(0).default(0)
638
+ .description('The port the supervisor waits for to become free before it starts the host (0 = the port of the current instance).'),
639
+ supervisorAutoInstall: schemastery.boolean().default(false)
640
+ .description('Whether the one-time question may install the supervisor without asking again.'),
445
641
  // graceful shutdown ("收工关机"): wait for every conversation, count down, then
446
642
  // switch the REAL host machine off (a WSL host shuts Windows down, not the distro)
447
643
  showSleepButton: schemastery.boolean().default(true)
@@ -500,6 +696,19 @@ const DEFAULTS = {
500
696
  backupIncludePlugins: true,
501
697
  maxBackups: 2,
502
698
  rollbackTimeoutMs: 90000,
699
+ // How long the restart command may run before the plugin stops waiting for
700
+ // it, and how long ONE restore step may take before the watcher gives up.
701
+ restartCommandTimeoutMs: DEFAULT_COMMAND_TIMEOUT_MS,
702
+ rollbackCopyTimeoutMs: DEFAULT_ROLLBACK_COPY_TIMEOUT_MS,
703
+ // The self-starting supervisor.
704
+ supervisorMode: 'auto',
705
+ supervisorKind: '',
706
+ supervisorRestartDelayMs: 3000,
707
+ supervisorRestartDelayMaxMs: 30000,
708
+ supervisorStartTimeoutMs: 90000,
709
+ supervisorKillGraceMs: 15000,
710
+ supervisorPort: 0,
711
+ supervisorAutoInstall: false,
503
712
  // Graceful shutdown ("收工关机"): the button at the foot of the left sidebar,
504
713
  // the countdown before the machine is switched off and the optional custom
505
714
  // shutdown command (empty = the host is detected automatically).
@@ -530,6 +739,35 @@ function normalizeConfig(raw) {
530
739
  const rollbackTimeout = Number(config.rollbackTimeoutMs)
531
740
  config.rollbackTimeoutMs =
532
741
  Number.isFinite(rollbackTimeout) && rollbackTimeout >= 1000 ? Math.trunc(rollbackTimeout) : 90000
742
+ // How long the restart command may run, and how long ONE restore step may
743
+ // take. Both are bounded, so neither a hung command nor a hung `robocopy`
744
+ // can hold the restart for ever.
745
+ const commandTimeout = Number(config.restartCommandTimeoutMs)
746
+ config.restartCommandTimeoutMs =
747
+ Number.isFinite(commandTimeout) && commandTimeout >= 1000 ? Math.trunc(commandTimeout) : DEFAULT_COMMAND_TIMEOUT_MS
748
+ const copyTimeout = Number(config.rollbackCopyTimeoutMs)
749
+ config.rollbackCopyTimeoutMs =
750
+ Number.isFinite(copyTimeout) && copyTimeout >= 1000 ? Math.trunc(copyTimeout) : DEFAULT_ROLLBACK_COPY_TIMEOUT_MS
751
+ // The supervisor knobs: a wrong value must not break the boot, it falls back
752
+ // to the documented default.
753
+ if (!['auto', 'installed', 'external', 'off'].includes(config.supervisorMode)) config.supervisorMode = 'auto'
754
+ if (!['', 'windows-schtasks', 'windows-startup', 'systemd-user', 'systemd-system'].includes(config.supervisorKind)) {
755
+ config.supervisorKind = ''
756
+ }
757
+ const supervisorDelay = Number(config.supervisorRestartDelayMs)
758
+ config.supervisorRestartDelayMs = Number.isFinite(supervisorDelay) && supervisorDelay >= 0 ? Math.trunc(supervisorDelay) : 3000
759
+ const supervisorDelayMax = Number(config.supervisorRestartDelayMaxMs)
760
+ config.supervisorRestartDelayMaxMs =
761
+ Number.isFinite(supervisorDelayMax) && supervisorDelayMax >= 0 ? Math.trunc(supervisorDelayMax) : 30000
762
+ const supervisorStartTimeout = Number(config.supervisorStartTimeoutMs)
763
+ config.supervisorStartTimeoutMs =
764
+ Number.isFinite(supervisorStartTimeout) && supervisorStartTimeout >= 1000 ? Math.trunc(supervisorStartTimeout) : 90000
765
+ const supervisorKillGrace = Number(config.supervisorKillGraceMs)
766
+ config.supervisorKillGraceMs =
767
+ Number.isFinite(supervisorKillGrace) && supervisorKillGrace >= 0 ? Math.trunc(supervisorKillGrace) : 15000
768
+ const supervisorPort = Number(config.supervisorPort)
769
+ config.supervisorPort = Number.isFinite(supervisorPort) && supervisorPort >= 0 ? Math.trunc(supervisorPort) : 0
770
+ if (typeof config.supervisorAutoInstall !== 'boolean') config.supervisorAutoInstall = false
533
771
  // The countdown of the graceful shutdown is one of the offered choices: a
534
772
  // profile that still carries an old value (15, 45, …) is snapped onto the
535
773
  // nearest one instead of breaking, and 0 means "no countdown at all".
@@ -608,6 +846,21 @@ function resolveChoicePath(config) {
608
846
  return `${prefix}/choice`
609
847
  }
610
848
 
849
+ /**
850
+ * Resolve the supervisor-route path: a sibling of the health route, under the
851
+ * same prefix (`POST /supervisor` installs, uninstalls and reports the
852
+ * self-starting supervisor).
853
+ *
854
+ * @param {object} config
855
+ * @returns {string}
856
+ */
857
+ function resolveSupervisorPath(config) {
858
+ const healthPath = resolveHealthPath(config)
859
+ const index = healthPath.lastIndexOf('/')
860
+ const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
861
+ return `${prefix}/supervisor`
862
+ }
863
+
611
864
  /**
612
865
  * Resolve the sleep-route path: a sibling of the health route, under the same
613
866
  * prefix (`GET /sleep` reports the state of the graceful sleep, `POST /sleep`
@@ -623,6 +876,20 @@ function resolveSleepPath(config) {
623
876
  return `${prefix}/sleep`
624
877
  }
625
878
 
879
+ /**
880
+ * Resolve the cancel-route path: a sibling of the health route, under the same
881
+ * prefix (`POST /cancel` withdraws ONE session's own restart request).
882
+ *
883
+ * @param {object} config
884
+ * @returns {string}
885
+ */
886
+ function resolveCancelPath(config) {
887
+ const healthPath = resolveHealthPath(config)
888
+ const index = healthPath.lastIndexOf('/')
889
+ const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
890
+ return `${prefix}/cancel`
891
+ }
892
+
626
893
  /**
627
894
  * Resolve the announcements-route path: a sibling of the health route, under
628
895
  * the same prefix (`GET /announcements` reports the feature announcements the
@@ -1019,6 +1286,20 @@ function resolveProfileDir() {
1019
1286
  return resolveProfileDirDetailed().dir
1020
1287
  }
1021
1288
 
1289
+ /**
1290
+ * THE BACKUPS DIRECTORY.
1291
+ *
1292
+ * It is built in exactly ONE place: the snapshot writes here, and the
1293
+ * settings page reads here. Two places would mean two answers to "where do
1294
+ * the backups live".
1295
+ *
1296
+ * @param {string} dir the state directory
1297
+ * @returns {string}
1298
+ */
1299
+ function backupsDirFor(dir) {
1300
+ return backupsRoot(typeof dir === 'string' ? dir : '')
1301
+ }
1302
+
1022
1303
  /**
1023
1304
  * The pre-restart snapshot: copy the profile into `<stateDir>/backups/<stamp>/`
1024
1305
  * and clear the rollback report an older boot may have left behind.
@@ -1031,7 +1312,10 @@ function resolveProfileDir() {
1031
1312
  * rollback possible at all.
1032
1313
  *
1033
1314
  * A failing backup never blocks the restart: the reason is logged and the
1034
- * caller continues without a rollback point.
1315
+ * caller continues without a rollback point. It is NOT silent though: the
1316
+ * facts of the snapshot (was one asked for, was one taken, which directory,
1317
+ * how long it took, which older snapshots were pruned) travel with the answer,
1318
+ * so the restart record can tell the operator what the rollback net is.
1035
1319
  *
1036
1320
  * @param {{
1037
1321
  * config?: object,
@@ -1040,7 +1324,14 @@ function resolveProfileDir() {
1040
1324
  * profile?: { dir?: string|null, source?: string },
1041
1325
  * createBackup?: Function,
1042
1326
  * }} [options]
1043
- * @returns {{ backup: object|null, profileDir: string|null, profile: { dir: string|null, source: string } }}
1327
+ * @returns {{
1328
+ * backup: object|null,
1329
+ * profileDir: string|null,
1330
+ * profile: { dir: string|null, source: string },
1331
+ * requested: boolean,
1332
+ * durationMs: number,
1333
+ * error: string|null,
1334
+ * }}
1044
1335
  */
1045
1336
  export function takePreRestartSnapshot({
1046
1337
  config = {},
@@ -1049,21 +1340,31 @@ export function takePreRestartSnapshot({
1049
1340
  profile = null,
1050
1341
  createBackup: createBackupFn = createBackup,
1051
1342
  } = {}) {
1343
+ const startedAt = Date.now()
1052
1344
  const info = profile && typeof profile === 'object'
1053
1345
  ? { dir: profile.dir ?? null, source: profile.source ?? 'unknown' }
1054
1346
  : { dir: null, source: 'unknown' }
1055
1347
  const profileDir = typeof info.dir === 'string' && info.dir.trim() !== '' ? info.dir : null
1056
1348
  // The snapshot is optional: the operator can turn it off entirely.
1057
- if (config.backupOnRestart !== true) return { backup: null, profileDir, profile: info }
1349
+ if (config.backupOnRestart !== true) {
1350
+ return { backup: null, profileDir, profile: info, requested: false, durationMs: 0, error: null }
1351
+ }
1058
1352
  if (profileDir === null) {
1059
1353
  log.warn('no profile directory is known; skipping the pre-restart backup', { source: info.source })
1060
- return { backup: null, profileDir: null, profile: info }
1354
+ return {
1355
+ backup: null,
1356
+ profileDir: null,
1357
+ profile: info,
1358
+ requested: true,
1359
+ durationMs: Math.max(0, Date.now() - startedAt),
1360
+ error: 'no profile directory is known',
1361
+ }
1061
1362
  }
1062
1363
  let backup = null
1063
1364
  try {
1064
1365
  backup = createBackupFn({
1065
1366
  profileDir,
1066
- backupsDir: backupsRoot(stateDir),
1367
+ backupsDir: backupsDirFor(stateDir),
1067
1368
  includePlugins: config.backupIncludePlugins !== false,
1068
1369
  maxBackups: config.maxBackups,
1069
1370
  })
@@ -1071,9 +1372,14 @@ export function takePreRestartSnapshot({
1071
1372
  // A backup that throws must not take the restart down with it.
1072
1373
  backup = { ok: false, error: String(error) }
1073
1374
  }
1375
+ const durationMs = Math.max(0, Date.now() - startedAt)
1074
1376
  if (backup && backup.ok === true) {
1075
1377
  log.warn('pre-restart backup written', {
1076
1378
  dir: backup.dir,
1379
+ stamp: backup.stamp,
1380
+ at: backup.at,
1381
+ atLocal: backup.atLocal,
1382
+ durationMs,
1077
1383
  bytes: backup.bytes,
1078
1384
  includePlugins: backup.includePlugins,
1079
1385
  pruned: backup.pruned,
@@ -1086,7 +1392,14 @@ export function takePreRestartSnapshot({
1086
1392
  error: backup && backup.error ? backup.error : 'unknown error',
1087
1393
  })
1088
1394
  }
1089
- return { backup, profileDir, profile: info }
1395
+ return {
1396
+ backup,
1397
+ profileDir,
1398
+ profile: info,
1399
+ requested: true,
1400
+ durationMs,
1401
+ error: backup && backup.ok === true ? null : (backup && backup.error ? backup.error : 'unknown error'),
1402
+ }
1090
1403
  }
1091
1404
 
1092
1405
  /** A logger that never throws and never requires a particular Cordis logger shape. */
@@ -1200,7 +1513,9 @@ function findOwnEntry(editor) {
1200
1513
  * snapshot?: Function,
1201
1514
  * runCommand?: Function,
1202
1515
  * scheduleDetached?: Function,
1203
- * }} [options] `env` replaces parts of the detected environment and the three
1516
+ * launchWatcher?: Function,
1517
+ * probeInstance?: Function,
1518
+ * }} [options] `env` replaces parts of the detected environment and the
1204
1519
  * functions replace the side-effecting primitives of the restart path. They
1205
1520
  * exist so a restart can be exercised without spawning a process or touching
1206
1521
  * the real profile; production never passes them.
@@ -1217,6 +1532,16 @@ export function createRuntime(ctx, config, options = {}) {
1217
1532
  const scheduleDetachedFn = typeof options.scheduleDetached === 'function'
1218
1533
  ? options.scheduleDetached
1219
1534
  : scheduleDetachedRestart
1535
+ // The rollback watcher is started OUTSIDE the host process, so it survives the
1536
+ // death of the host — which is what a hosted restart does.
1537
+ const launchWatcherFn = typeof options.launchWatcher === 'function'
1538
+ ? options.launchWatcher
1539
+ : launchDetachedWatcher
1540
+ // The health confirmation of the new instance (in-process; the detached
1541
+ // watcher has its own, outside the host).
1542
+ const probeInstanceFn = typeof options.probeInstance === 'function'
1543
+ ? options.probeInstance
1544
+ : waitForHealthyInstance
1220
1545
  const stateFile = resolveStateFile(config)
1221
1546
 
1222
1547
  const loaded = readState(stateFile, Date.now())
@@ -1230,6 +1555,20 @@ export function createRuntime(ctx, config, options = {}) {
1230
1555
  state = boot.state
1231
1556
  const bootId = state.bootId
1232
1557
 
1558
+ // THE INSTANCE RECORD exists from the first moment of this boot: it carries
1559
+ // the boot id (which tells the rollback watcher that this instance is the
1560
+ // one being replaced). As soon as the web server is known, the record is
1561
+ // written again — with the port of this instance and its access link.
1562
+ try {
1563
+ writeAccessRecord({
1564
+ stateDir: path.dirname(stateFile),
1565
+ bootId,
1566
+ pid: process.pid,
1567
+ })
1568
+ } catch (error) {
1569
+ log.warn('could not write the instance record:', String(error))
1570
+ }
1571
+
1233
1572
  let pollTimer
1234
1573
  let recoverTimer
1235
1574
  let disposed = false
@@ -1239,6 +1578,12 @@ export function createRuntime(ctx, config, options = {}) {
1239
1578
  * logged, never thrown: the plugin still loads and the other tools still work.
1240
1579
  */
1241
1580
  const toolErrors = []
1581
+ /**
1582
+ * The routes of this plugin that the web server accepted, and the ones it
1583
+ * refused. The boot check of the restart record reports them.
1584
+ */
1585
+ const registeredRoutes = []
1586
+ const routeErrors = []
1242
1587
 
1243
1588
  const persist = () => {
1244
1589
  try {
@@ -2087,20 +2432,31 @@ const titleOfSummary = (item) => {
2087
2432
  }
2088
2433
 
2089
2434
  /**
2090
- * Release the continuation request of EVERY session that asked for the current
2435
+ * Release the continuation request of the sessions that asked for the current
2091
2436
  * restart (cancel / give-up paths). Requests are coalesced into ONE restart, so
2092
2437
  * releasing only the last requester would leave the others pinned as pending.
2438
+ *
2439
+ * `only` releases EXACTLY those sessions — that is how one session withdraws
2440
+ * its own request without touching the requests of the other sessions.
2441
+ *
2442
+ * @param {string[]|null} [only] the sessions to release; null = every requester
2093
2443
  */
2094
- const releaseRequesters = () => {
2095
- const restart = state.restart
2096
- if (!restart) return
2444
+ const releaseRequesters = (only = null) => {
2097
2445
  const ids = new Set()
2098
- if (Array.isArray(restart.requesters)) {
2099
- for (const requester of restart.requesters) {
2100
- if (requester && typeof requester.sessionId === 'string' && requester.sessionId) ids.add(requester.sessionId)
2446
+ if (Array.isArray(only)) {
2447
+ for (const sessionId of only) {
2448
+ if (typeof sessionId === 'string' && sessionId) ids.add(sessionId)
2449
+ }
2450
+ } else {
2451
+ const restart = state.restart
2452
+ if (!restart) return
2453
+ if (Array.isArray(restart.requesters)) {
2454
+ for (const requester of restart.requesters) {
2455
+ if (requester && typeof requester.sessionId === 'string' && requester.sessionId) ids.add(requester.sessionId)
2456
+ }
2101
2457
  }
2458
+ if (typeof restart.bySessionId === 'string' && restart.bySessionId) ids.add(restart.bySessionId)
2102
2459
  }
2103
- if (typeof restart.bySessionId === 'string' && restart.bySessionId) ids.add(restart.bySessionId)
2104
2460
  for (const sessionId of ids) {
2105
2461
  const entry = state.sessions ? state.sessions[sessionId] : undefined
2106
2462
  if (!entry) continue
@@ -2171,144 +2527,942 @@ const titleOfSummary = (item) => {
2171
2527
  return 'http://127.0.0.1:' + port + resolveHealthPath(config)
2172
2528
  }
2173
2529
 
2174
- /** Execute the restart now; the detached helper survives our own death. */
2175
- const executeRestart = async () => {
2176
- stopPolling()
2177
- if (!state.restart) return
2530
+ /**
2531
+ * The state directory of this plugin (the checkpoint, the backups, the
2532
+ * instance record and the helper scripts live next to each other).
2533
+ */
2534
+ const stateDirOf = () => path.dirname(stateFile)
2535
+
2536
+ /**
2537
+ * The durable record of the last restart: the snapshot that was taken, the
2538
+ * verdict of the new instance and its four boot checks.
2539
+ *
2540
+ * @returns {object|null} the summary (null when no restart happened yet)
2541
+ */
2542
+ const lastRestartVerdict = () => summarizeRestartVerdict(readRestartVerdict(stateDirOf()))
2543
+
2544
+ /**
2545
+ * The version of THIS plugin (the one the user runs).
2546
+ *
2547
+ * @returns {string}
2548
+ */
2549
+ const pluginVersion = () => {
2178
2550
  try {
2179
- await flushSessions()
2180
- } catch {}
2181
- // Re-check the gate AFTER the flush and BEFORE anything is killed or scheduled:
2182
- // another conversation may have started while the sessions were being written.
2183
- // This re-check also protects the ungated fallback (>20 gate aborts) and the
2184
- // forceRestartWhenBusy path: even when the detached helper cannot re-check the
2185
- // gate itself, a busy conversation is never killed here.
2186
- // Last chance to park a goal-driven session: without it the detached
2187
- // helper would refuse to kill the process and the restart would wait.
2551
+ const raw = fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')
2552
+ const parsed = JSON.parse(raw)
2553
+ return typeof parsed.version === 'string' && parsed.version !== '' ? parsed.version : 'unknown'
2554
+ } catch {
2555
+ return 'unknown'
2556
+ }
2557
+ }
2558
+
2559
+ /**
2560
+ * Write (or update) the durable record of the restart.
2561
+ *
2562
+ * The record is written by BOTH halves of a restart: the instance that is
2563
+ * replaced writes the `pending` half (with the snapshot), the instance that
2564
+ * replaces it writes the final verdict. `skipIfFinalized` keeps the older
2565
+ * instance from overwriting a verdict that the new one already wrote.
2566
+ *
2567
+ * @param {object} [patch] the fields to change
2568
+ * @param {{ skipIfFinalized?: boolean }} [options]
2569
+ * @returns {object|null} the record that is on disk now
2570
+ */
2571
+ const recordRestartVerdict = (patch = {}, options = {}) => {
2572
+ const dir = stateDirOf()
2573
+ let current = null
2188
2574
  try {
2189
- disarmBlockingGoals(busyAgents())
2190
- } catch (error) {
2191
- log.warn('could not disarm the goals of the blocking sessions:', String(error))
2575
+ current = readRestartVerdict(dir)
2576
+ } catch {
2577
+ current = null
2192
2578
  }
2193
- const verdict = gate()
2194
- if (!verdict.allow) {
2195
- log.info('restart deferred: a conversation is still active or the host is settling', {
2196
- reason: verdict.reason,
2197
- blockedBy: verdict.blockedBy,
2198
- quietForMs: verdict.quietForMs,
2199
- })
2200
- persist()
2201
- startPolling()
2202
- return
2579
+ if (options.skipIfFinalized === true
2580
+ && current !== null
2581
+ && current.bootId !== null
2582
+ && current.result !== RESTART_RESULTS.PENDING) {
2583
+ return current
2203
2584
  }
2204
- setPhase(state, 'restarting')
2205
- state.restart.attempt += 1
2206
- persist()
2207
- log.warn('executing graceful restart', { generation: state.generation, pid: process.pid })
2208
- try {
2209
- // How the host is restarted: the custom command, or the desktop app.
2210
- const useCustomCommand = config.restartMode === 'command' && config.restartCommand.trim() !== ''
2211
- const canRelaunchDesktop = Boolean(env.isDesktopApp && env.appExePath)
2212
- if (!useCustomCommand && !canRelaunchDesktop) {
2213
- // No restart method at all (the host is not the desktop app and there is
2214
- // no custom command): nothing is restarted, so nothing is snapshotted
2215
- // either — there is no rollback point for a restart that never happens.
2216
- log.warn('no restart method available (not the desktop app and restartCommand is empty); leaving the checkpoint in place')
2217
- rollbackRestart('no restart method available')
2218
- return
2219
- }
2585
+ const merged = buildRestartVerdict({ ...(current ?? {}), ...patch })
2586
+ const written = writeRestartVerdict(dir, merged)
2587
+ if (!written.ok) log.warn('could not write the restart record:', written.error)
2588
+ return merged
2589
+ }
2220
2590
 
2221
- // ONE snapshot for EVERY restart mode: the profile is copied BEFORE
2222
- // anything is killed, no matter how the host is restarted. The desktop
2223
- // app relaunch and the custom restart command share this single entry, so
2224
- // a hosted host that is restarted by a command is just as recoverable as
2225
- // the desktop app.
2226
- const stateDir = path.dirname(stateFile)
2227
- const snapshot = snapshotFn({
2228
- config,
2229
- stateDir,
2230
- log,
2231
- profile: resolveProfileDirDetailed(),
2232
- })
2591
+ /**
2592
+ * WHICH MACHINE IS THIS?
2593
+ *
2594
+ * The same answer the graceful shutdown uses (`lib/host-kind.js`): Windows,
2595
+ * WSL, a real Linux, a macOS. It matters for the autostart: inside WSL the
2596
+ * "start at login" is a different thing (a systemd user unit + the linger,
2597
+ * or a task on the Windows side), and the Docker/Compose answer of a
2598
+ * container is simply wrong there.
2599
+ *
2600
+ * @returns {object} the host (kind, evidence, suspect, distro)
2601
+ */
2602
+ const hostKindOf = () => detectHostKind({ platform: env.platform, env: process.env })
2233
2603
 
2234
- if (useCustomCommand) {
2235
- const command = buildCustomCommand({
2236
- template: config.restartCommand,
2237
- pid: process.pid,
2238
- ppid: process.ppid,
2239
- appExePath: env.appExePath || '',
2240
- })
2241
- log.warn('running custom restart command:', command)
2242
- // A failed snapshot never blocks the restart: the command runs anyway.
2243
- const outcome = runCommandFn({ command })
2244
- if (outcome && outcome.ok === false) rollbackRestart(outcome.error || 'custom command failed')
2245
- } else {
2246
- const aborts = Number(state.restart.gateAborts) || 0
2247
- const url = aborts > 20 ? null : restartGateUrl()
2248
- if (aborts > 20) {
2249
- log.error('falling back to an UNGATED restart after repeated idle-gate aborts', { gateAborts: aborts })
2250
- } else if (url === null) {
2251
- log.warn('the web server port is unknown; scheduling an UNGATED restart (the helper cannot re-check the idle gate)')
2252
- }
2253
- // The snapshot was already taken above (the shared entry).
2254
- const backup = snapshot && snapshot.backup ? snapshot.backup : null
2255
- const profileDir = snapshot && snapshot.profileDir ? snapshot.profileDir : null
2256
- const healthUrl = restartHealthUrl()
2257
- const watchScriptPath = backup && backup.ok === true && healthUrl !== null && profileDir !== null
2258
- ? path.join(stateDir, 'restart-' + state.generation + '-watch.ps1')
2259
- : null
2260
- const watchScript = watchScriptPath === null
2261
- ? null
2262
- : buildWatchScript({
2263
- healthUrl,
2264
- backupDir: backup.dir,
2265
- profileDir,
2266
- reportPath: path.join(stateDir, ROLLBACK_REPORT_NAME),
2267
- appExePath: env.appExePath,
2268
- timeoutMs: config.rollbackTimeoutMs,
2269
- // Only a crash log written after this point belongs to this restart.
2270
- since: new Date().toISOString(),
2271
- })
2272
- const result = scheduleDetachedFn({
2273
- appExePath: env.appExePath,
2274
- relaunchDelaySeconds: config.relaunchDelaySeconds,
2275
- gateUrl: url,
2276
- scriptPath: url === null ? null : path.join(stateDir, 'restart-' + state.generation + '.cmd'),
2277
- watchScriptPath,
2278
- watchScript,
2279
- })
2280
- log.warn('scheduled detached restart:', result)
2281
- if (!result || result.ok !== true) rollbackRestart((result && result.error) || 'detached restart refused')
2282
- else armGateWatchdog()
2283
- }
2284
- } catch (error) {
2285
- rollbackRestart(String(error))
2286
- }
2604
+ /**
2605
+ * THE PLATFORM GUIDE: how "start at login" works on THIS machine.
2606
+ *
2607
+ * Windows (a scheduled task, with the startup folder as the fallback),
2608
+ * Linux (a systemd user unit + the linger hint), WSL (the same, or the two
2609
+ * ways out when there is no systemd), a container (the orchestrator) and the
2610
+ * unknown host (nothing, and no guessing). The answer is computed ONCE
2611
+ * (the machine does not change while the host runs) and the settings page
2612
+ * reads it.
2613
+ *
2614
+ * @returns {object} the advice (kind, reason, notes, commands, options)
2615
+ */
2616
+ let cachedAutostart = null
2617
+ const autostartAdviceOf = () => {
2618
+ if (cachedAutostart !== null) return cachedAutostart
2619
+ const host = hostKindOf()
2620
+ const init = detectInitSystems({ platform: env.platform, homeDir: os.homedir() })
2621
+ cachedAutostart = planAutostartAdvice({
2622
+ host,
2623
+ platform: env.platform,
2624
+ hasSystemd: init.systemdUser,
2625
+ container: detectContainer({ env: process.env }).container,
2626
+ user: typeof process.env.USER === 'string' ? process.env.USER : '',
2627
+ distro: host.distro,
2628
+ })
2629
+ return cachedAutostart
2287
2630
  }
2288
2631
 
2289
- /** One decision tick while a restart is pending. */
2290
- const tick = () => {
2291
- if (disposed) return
2292
- if (!state.restart) {
2293
- stopPolling()
2294
- return
2295
- }
2296
- const now = Date.now()
2297
- const busy = busyAgents()
2298
- // A goal-driven session blocks the restart forever: park its goal BEFORE the
2299
- // gate decides that it is blocked, so the next poll sees an idle session.
2300
- if (busy.length > 0) {
2301
- try {
2302
- disarmBlockingGoals(busy)
2303
- } catch (error) {
2304
- log.warn('could not disarm the goals of the blocking sessions:', String(error))
2305
- }
2632
+ /**
2633
+ * WHAT IS ON DISK RIGHT NOW: the backups of this host.
2634
+ *
2635
+ * The settings page shows it ("what is backed up right now"), so the facts
2636
+ * have to come from the disk and not from the browser: the browser may not
2637
+ * even see the disk. Only the DIRECTORY is read — the manifest of every
2638
+ * copy — because one copy can be 150 MB and its contents are never walked.
2639
+ *
2640
+ * @returns {{ dir: string, max: number, count: number, items: Array<object> }}
2641
+ */
2642
+ const backupsInfo = () => {
2643
+ const dir = backupsDirFor(stateDirOf())
2644
+ const items = listBackups(dir).map((item) => ({
2645
+ name: item.name,
2646
+ dir: item.dir,
2647
+ at: item.at,
2648
+ atLocal: item.atLocal,
2649
+ bytes: item.bytes,
2650
+ files: item.files,
2651
+ includePlugins: item.includePlugins,
2652
+ }))
2653
+ return {
2654
+ dir,
2655
+ max: config.maxBackups,
2656
+ count: items.length,
2657
+ items,
2306
2658
  }
2307
- const decision = restartDecision(state, {
2308
- anyAgentRunning: busy.length > 0,
2309
- busyAgents: busy,
2310
- lastActivityAt: quietSince(),
2311
- settleMs: effectiveSettleMs(),
2659
+ }
2660
+
2661
+ /**
2662
+ * THE DIAGNOSTICS of "report a problem".
2663
+ *
2664
+ * One generator, on the host: the browser copies the text it gets, so the
2665
+ * two can never drift. It carries the facts a bug report needs — and NOT
2666
+ * the access token of this instance (the generator only copies the fields
2667
+ * it knows, and everything it copies is redacted again).
2668
+ *
2669
+ * @returns {string}
2670
+ */
2671
+ const diagnosticsText = () => buildDiagnostics({
2672
+ version: pluginVersion(),
2673
+ platform: env.platform,
2674
+ hostKind: hostKindOf().kind,
2675
+ isDesktopApp: env.isDesktopApp === true,
2676
+ startup: startupModeOf(),
2677
+ supervisor: supervisorStatusOf(),
2678
+ restartMode: config.restartMode,
2679
+ lastRestart: lastRestartVerdict(),
2680
+ stateDir: stateDirOf(),
2681
+ })
2682
+
2683
+ /**
2684
+ * HOW THIS HOST WAS STARTED (the startup matrix).
2685
+ *
2686
+ * The answer decides what the plugin may do: only a `bare` host (nobody
2687
+ * manages it) may get the supervisor of this plugin or a self-replay
2688
+ * command. Everything else — an external supervisor, a container, the
2689
+ * desktop app, or a host whose starter cannot be established — must not
2690
+ * lead to a second host.
2691
+ *
2692
+ * @returns {object} the classification (mode, kind, evidence, …)
2693
+ */
2694
+ const startupModeOf = () => classifyStartup({
2695
+ platform: env.platform,
2696
+ env: process.env,
2697
+ ppid: process.ppid,
2698
+ isDesktopApp: env.isDesktopApp === true,
2699
+ supervised: detectSupervisor({ env: process.env, stateDir: stateDirOf() }).supervised,
2700
+ container: detectContainer({ env: process.env }).container,
2701
+ argv: process.argv,
2702
+ execPath: process.execPath,
2703
+ readChain: readProcessChain,
2704
+ })
2705
+
2706
+ /**
2707
+ * What the plugin knows about the supervisor.
2708
+ *
2709
+ * The parent chain of this very process is part of the answer: a host that
2710
+ * somebody ELSE supervises (systemd, pm2, a `supervise-*.sh` script) must
2711
+ * not be restarted by a self-replay — the supervisor starts it again, and a
2712
+ * second host on the same port is a double start.
2713
+ *
2714
+ * @returns {object} the status (mode, kind, installed, running, restartCapable)
2715
+ */
2716
+ const supervisorStatusOf = () => supervisorStatus({
2717
+ stateDir: stateDirOf(),
2718
+ env: process.env,
2719
+ platform: env.platform,
2720
+ mode: config.supervisorMode,
2721
+ ppid: process.ppid,
2722
+ // The desktop app brings ITSELF back up (`restartMode: app`): it is not
2723
+ // "nobody knows", and the restart is available.
2724
+ isDesktopApp: env.isDesktopApp === true,
2725
+ // HOW this host was started (the startup matrix), with its evidence.
2726
+ startup: startupModeOf(),
2727
+ // THE PLATFORM GUIDE: how "start at login" works here (Windows / Linux /
2728
+ // WSL / container), with the commands the user can copy.
2729
+ autostart: autostartAdviceOf(),
2730
+ })
2731
+
2732
+ /**
2733
+ * The start command the supervisor would use.
2734
+ *
2735
+ * This is the PLAIN command line of this very process: the interpreter, its
2736
+ * own arguments and the script it runs. The full derivation (with the
2737
+ * refusal rules) belongs to the "E" step; this is the honest minimum the
2738
+ * supervisor needs to be able to start the host again.
2739
+ *
2740
+ * @returns {{ execPath: string, execArgv: string[], argv: string[], cwd: string }}
2741
+ */
2742
+ const supervisorStartCommand = () => ({
2743
+ execPath: process.execPath,
2744
+ execArgv: Array.isArray(process.execArgv) ? process.execArgv.map(String) : [],
2745
+ argv: Array.isArray(process.argv) ? process.argv.slice(1).map(String) : [],
2746
+ cwd: process.cwd(),
2747
+ })
2748
+
2749
+ /**
2750
+ * The environment the supervisor hands to the host it starts.
2751
+ *
2752
+ * Only a WHITELIST is inherited (the daemon does that too): the host's own
2753
+ * internal variables must not leak into the next start, or the profile would
2754
+ * drift.
2755
+ *
2756
+ * @returns {Record<string, string>}
2757
+ */
2758
+ const supervisorEnv = () => {
2759
+ const env = {}
2760
+ const source = process.env || {}
2761
+ for (const key of [
2762
+ 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LC_ALL', 'SystemRoot',
2763
+ 'TEMP', 'TMP', 'APPDATA', 'LOCALAPPDATA', 'ProgramData',
2764
+ 'NUMBER_OF_PROCESSORS', 'WSLENV', 'WSL_DISTRO_NAME', 'WSL_INTEROP',
2765
+ ]) {
2766
+ const value = source[key]
2767
+ if (typeof value === 'string' && value !== '') env[key] = value
2768
+ }
2769
+ // The two variables that say WHICH profile this host belongs to.
2770
+ if (typeof source.DSH_HOME === 'string' && source.DSH_HOME !== '') env.DSH_HOME = source.DSH_HOME
2771
+ if (typeof source.DSH_PROFILE === 'string' && source.DSH_PROFILE !== '') env.DSH_PROFILE = source.DSH_PROFILE
2772
+ return env
2773
+ }
2774
+
2775
+ /**
2776
+ * Install the supervisor.
2777
+ *
2778
+ * @param {{ confirmed?: boolean, dryRun?: boolean }} [options]
2779
+ * @returns {object} the outcome (ok, kind, steps, error, plan)
2780
+ */
2781
+ const installSupervisorNow = (options = {}) => {
2782
+ const stateDir = stateDirOf()
2783
+ const profile = resolveProfileDirDetailed()
2784
+ const start = supervisorStartCommand()
2785
+ const result = installSupervisor({
2786
+ stateDir,
2787
+ profileDir: profile.dir,
2788
+ platform: env.platform,
2789
+ confirmed: options.confirmed === true,
2790
+ dryRun: options.dryRun === true,
2791
+ // The desktop app manages itself: a login record would start a SECOND
2792
+ // client at every login, so the installation is refused there.
2793
+ isDesktopApp: env.isDesktopApp === true,
2794
+ // The startup matrix: only a `bare` host (nobody manages it) may get
2795
+ // the supervisor of this plugin.
2796
+ startup: startupModeOf(),
2797
+ // WHICH MACHINE IS THIS? (WSL is not a Linux: the autostart there is a
2798
+ // different thing, and the Docker/Compose answer is a wrong one.)
2799
+ host: hostKindOf(),
2800
+ nodePath: process.execPath,
2801
+ execPath: start.execPath,
2802
+ execArgv: start.execArgv,
2803
+ argv: start.argv,
2804
+ cwd: start.cwd,
2805
+ env: supervisorEnv(),
2806
+ profileEnv: {
2807
+ ...(process.env.DSH_HOME ? { DSH_HOME: process.env.DSH_HOME } : {}),
2808
+ ...(process.env.DSH_PROFILE ? { DSH_PROFILE: process.env.DSH_PROFILE } : {}),
2809
+ },
2810
+ port: config.supervisorPort > 0 ? config.supervisorPort : (runtime.webPort || 0),
2811
+ healthUrl: restartHealthUrl() ?? '',
2812
+ ownerUser: typeof process.env.USER === 'string' ? process.env.USER : (typeof process.env.LOGNAME === 'string' ? process.env.LOGNAME : ''),
2813
+ timings: {
2814
+ restartDelayMs: config.supervisorRestartDelayMs,
2815
+ restartDelayMaxMs: config.supervisorRestartDelayMaxMs,
2816
+ startTimeoutMs: config.supervisorStartTimeoutMs,
2817
+ killGraceMs: config.supervisorKillGraceMs,
2818
+ },
2819
+ kind: config.supervisorKind,
2820
+ // The previous way of starting the host: the uninstall puts it back.
2821
+ previous: { restartMode: config.restartMode, restartCommand: config.restartCommand },
2822
+ log: (message) => log.warn(message),
2823
+ })
2824
+ if (result.ok) {
2825
+ log.warn('the supervisor was installed', { kind: result.kind, degraded: result.degraded })
2826
+ } else {
2827
+ log.error('the supervisor was not installed:', result.error)
2828
+ }
2829
+ return result
2830
+ }
2831
+
2832
+ /**
2833
+ * Uninstall the supervisor and give the machine back to the user.
2834
+ *
2835
+ * @returns {Promise<object>} the outcome (ok, steps, error, pending, previous)
2836
+ */
2837
+ const uninstallSupervisorNow = async () => {
2838
+ const result = await uninstallSupervisor({
2839
+ stateDir: stateDirOf(),
2840
+ platform: env.platform,
2841
+ log: (message) => log.warn(message),
2842
+ })
2843
+ if (result.ok) {
2844
+ log.warn('the supervisor was uninstalled', { previous: result.previous })
2845
+ } else {
2846
+ log.error('the supervisor could not be removed completely:', result.error)
2847
+ }
2848
+ return result
2849
+ }
2850
+
2851
+ /**
2852
+ * Record the answer of the one-time question about the supervisor.
2853
+ *
2854
+ * @param {string} choice 'install' | 'external' | 'dismissed'
2855
+ * @param {object} [previous] the values that were in effect before
2856
+ * @returns {object} the recorded answer
2857
+ */
2858
+ const recordSupervisorChoice = (choice, previous = null) => {
2859
+ const record = recordOnboarding(state, {
2860
+ feature: 'supervisor',
2861
+ version: SUPERVISOR_FEATURE_VERSION,
2862
+ choice,
2863
+ previous,
2864
+ now: Date.now(),
2865
+ })
2866
+ persist()
2867
+ return record
2868
+ }
2869
+
2870
+ /**
2871
+ * Derive the command that starts this host again — or say why there is
2872
+ * none. Nothing is written: this is the dry run (B).
2873
+ *
2874
+ * @param {{ fixedPort?: number }} [options]
2875
+ * @returns {Promise<object>} the derivation (the plan of what WOULD be written)
2876
+ */
2877
+ const deriveStartCommandNow = async (options = {}) => {
2878
+ const detected = detectSupervisor({ env: process.env, stateDir: stateDirOf() })
2879
+ const external = detectExternalSupervisor({
2880
+ ppid: process.ppid,
2881
+ env: process.env,
2882
+ platform: env.platform,
2883
+ })
2884
+ const container = detectContainer({ env: process.env })
2885
+ const init = detectInitSystems({ platform: env.platform, homeDir: os.homedir() })
2886
+ // A random port has to be FIXED, or the cookie of the browser dies with
2887
+ // every restart. The port is picked once and then written down.
2888
+ let fixedPort = Number(options.fixedPort) > 0 ? Number(options.fixedPort) : 0
2889
+ if (fixedPort === 0 && config.supervisorPort > 0) fixedPort = config.supervisorPort
2890
+ if (fixedPort === 0 && Number(runtime.webPort) > 0 && config.supervisorPort === 0) {
2891
+ // The port of the running instance is the best candidate: it is the one
2892
+ // the browser knows.
2893
+ fixedPort = Number(runtime.webPort)
2894
+ }
2895
+ const derivation = deriveStartCommand({
2896
+ execPath: process.execPath,
2897
+ execArgv: process.execArgv,
2898
+ argv: process.argv,
2899
+ cwd: process.cwd(),
2900
+ platform: env.platform,
2901
+ env: process.env,
2902
+ pid: process.pid,
2903
+ isDesktopApp: env.isDesktopApp === true,
2904
+ webPort: runtime.webPort || 0,
2905
+ supervised: detected.supervised === true,
2906
+ externalSupervisor: external,
2907
+ container: container.container,
2908
+ // THE STARTUP MATRIX: it decides whether a self-replay is possible at
2909
+ // all (and whether the command must be detached from its session).
2910
+ startup: startupModeOf(),
2911
+ hasInit: env.platform === 'win32' ? true : init.systemdUser,
2912
+ fixedPort,
2913
+ currentRestartMode: config.restartMode,
2914
+ currentRestartCommand: config.restartCommand,
2915
+ healthPath: resolveHealthPath(config),
2916
+ })
2917
+ const plan = buildDryRunPlan(derivation)
2918
+ return {
2919
+ ...plan,
2920
+ derivation,
2921
+ externalSupervisor: external,
2922
+ container: container.container,
2923
+ initReason: init.reason,
2924
+ // The fragment a container needs (there is no "at login" there).
2925
+ compose: derivation.reason === 'container-no-init' && derivation.argv
2926
+ ? composeRestartFragment(derivation.argv)
2927
+ : null,
2928
+ }
2929
+ }
2930
+
2931
+ /**
2932
+ * Write the derived command into the profile configuration — only after the
2933
+ * user confirmed the dry run.
2934
+ *
2935
+ * @param {{ derivation?: object }} [options]
2936
+ * @returns {Promise<object>} the outcome
2937
+ */
2938
+ const applyDerivedCommand = async (options = {}) => {
2939
+ const derivation = options.derivation && typeof options.derivation === 'object'
2940
+ ? options.derivation
2941
+ : await deriveStartCommandNow()
2942
+ if (!derivation || derivation.ok !== true || !derivation.writes) {
2943
+ return {
2944
+ ok: false,
2945
+ error: 'nothing-to-write',
2946
+ reason: derivation ? derivation.reason : 'unknown',
2947
+ }
2948
+ }
2949
+ const previous = {
2950
+ restartMode: config.restartMode,
2951
+ restartCommand: config.restartCommand,
2952
+ }
2953
+ try {
2954
+ await runtime.applyConfigChoice({
2955
+ restartMode: derivation.writes.restartMode,
2956
+ restartCommand: derivation.writes.restartCommand,
2957
+ })
2958
+ } catch (error) {
2959
+ return { ok: false, error: describeError(error), reason: derivation.reason }
2960
+ }
2961
+ // The values that were in effect are kept, so the undo can put them back.
2962
+ recordSupervisorChoice('external', previous)
2963
+ log.warn('the derived restart command was written', {
2964
+ mode: derivation.writes.restartMode,
2965
+ reason: derivation.reason,
2966
+ })
2967
+ return { ok: true, writes: derivation.writes, previous, reason: derivation.reason }
2968
+ }
2969
+
2970
+ /**
2971
+ * Put the previous start command back (the undo).
2972
+ *
2973
+ * @returns {Promise<object>} the outcome
2974
+ */
2975
+ const revertDerivedCommand = async () => {
2976
+ const record = state.onboarding
2977
+ const previous = record && typeof record === 'object' ? record.previous : null
2978
+ if (!previous || typeof previous.restartMode !== 'string') {
2979
+ return { ok: false, error: 'nothing-to-revert' }
2980
+ }
2981
+ try {
2982
+ await runtime.applyConfigChoice({
2983
+ restartMode: previous.restartMode,
2984
+ restartCommand: previous.restartCommand === null ? '' : previous.restartCommand,
2985
+ })
2986
+ } catch (error) {
2987
+ return { ok: false, error: describeError(error) }
2988
+ }
2989
+ log.warn('the previous restart command was put back', previous)
2990
+ return { ok: true, previous }
2991
+ }
2992
+
2993
+ /**
2994
+ * Write the marker that tells the rollback watcher "the plugin has decided
2995
+ * on its own — stand down".
2996
+ *
2997
+ * The watcher is the fallback for the case that the plugin dies with the
2998
+ * host. When the plugin SURVIVES the restart command it decides alone, and
2999
+ * this marker keeps the watcher from rolling back a restart that was fine.
3000
+ *
3001
+ * @param {{ decided: boolean, ok?: boolean, reason?: string }} outcome
3002
+ * @returns {boolean} whether the marker was written
3003
+ */
3004
+ const writeRestartOutcome = (outcome) => {
3005
+ const file = path.join(stateDirOf(), RESTART_OUTCOME_FILE)
3006
+ try {
3007
+ fs.writeFileSync(file, JSON.stringify({
3008
+ decided: true,
3009
+ ok: outcome && outcome.ok === true,
3010
+ reason: outcome && typeof outcome.reason === 'string' ? outcome.reason : '',
3011
+ at: new Date().toISOString(),
3012
+ bootId,
3013
+ }, null, 2), 'utf8')
3014
+ return true
3015
+ } catch (error) {
3016
+ log.warn('could not write the restart outcome marker:', String(error))
3017
+ return false
3018
+ }
3019
+ }
3020
+
3021
+ /**
3022
+ * Write the rollback report of a restart that FAILED, so the interface can
3023
+ * say what happened.
3024
+ *
3025
+ * The report has the same shape as the one the detached watcher writes, so
3026
+ * there is exactly ONE place that reads it.
3027
+ *
3028
+ * @param {{ rolledBack: boolean, reason: string, backupDir?: string|null, restored?: string[] }} details
3029
+ * @returns {boolean} whether the report was written
3030
+ */
3031
+ const writeRestartReport = (details) => {
3032
+ const file = path.join(stateDirOf(), RESTART_REPORT_NAME)
3033
+ try {
3034
+ fs.writeFileSync(file, JSON.stringify({
3035
+ ok: false,
3036
+ at: new Date().toISOString(),
3037
+ waitedMs: 0,
3038
+ rolledBack: details.rolledBack === true,
3039
+ rollbackError: details.rolledBack === true ? null : details.reason,
3040
+ reason: details.reason,
3041
+ backupDir: details.backupDir ?? null,
3042
+ restored: Array.isArray(details.restored) ? details.restored : [],
3043
+ // The plugin restores the files; it never starts a second host.
3044
+ appStarted: false,
3045
+ }, null, 2), 'utf8')
3046
+ return true
3047
+ } catch (error) {
3048
+ log.warn('could not write the rollback report:', String(error))
3049
+ return false
3050
+ }
3051
+ }
3052
+
3053
+ /**
3054
+ * Put the pre-restart snapshot back over the live profile.
3055
+ *
3056
+ * This is the in-process half of the rollback: it runs when the plugin
3057
+ * itself can see that the restart failed (the restart command did not
3058
+ * start, or the new instance never answered). The detached watcher does
3059
+ * the same thing when the plugin died with the host.
3060
+ *
3061
+ * @param {{ backup: any, profileDir: string|null, reason: string }} details
3062
+ * @returns {{ rolledBack: boolean, restored: string[], error: string|null }}
3063
+ */
3064
+ const rollbackInProcess = ({ backup, profileDir, reason }) => {
3065
+ const restored = []
3066
+ if (!backup || backup.ok !== true || typeof backup.dir !== 'string' || backup.dir === '') {
3067
+ log.error('the restart failed and there is no snapshot to roll back to:', reason)
3068
+ writeRestartReport({ rolledBack: false, reason, backupDir: null, restored })
3069
+ return { rolledBack: false, restored, error: 'no snapshot to roll back to' }
3070
+ }
3071
+ if (typeof profileDir !== 'string' || profileDir === '') {
3072
+ log.error('the restart failed and the profile directory is unknown:', reason)
3073
+ writeRestartReport({ rolledBack: false, reason, backupDir: backup.dir, restored })
3074
+ return { rolledBack: false, restored, error: 'the profile directory is unknown' }
3075
+ }
3076
+ // THE LOCK: the detached watcher may be restoring the snapshot at the same
3077
+ // time. Exactly ONE of them copies the files — two copies at once would
3078
+ // write the same files (117 MB of plugin tree) at the same time.
3079
+ const lock = claimRollbackLock(stateDirOf())
3080
+ if (!lock.ok) {
3081
+ log.warn('another rollback is already running; this one stands down:', lock.error)
3082
+ return { rolledBack: false, restored, error: lock.error }
3083
+ }
3084
+ let result = null
3085
+ try {
3086
+ result = restoreBackup({ backupDir: backup.dir, profileDir })
3087
+ } catch (error) {
3088
+ result = { ok: false, restored: [], error: String(error) }
3089
+ } finally {
3090
+ releaseRollbackLock(stateDirOf())
3091
+ }
3092
+ if (result && result.ok === true) {
3093
+ log.warn('the pre-restart snapshot was restored:', {
3094
+ reason,
3095
+ restored: result.restored,
3096
+ backupDir: backup.dir,
3097
+ })
3098
+ writeRestartReport({ rolledBack: true, reason, backupDir: backup.dir, restored: result.restored })
3099
+ return { rolledBack: true, restored: result.restored, error: null }
3100
+ }
3101
+ const error = (result && result.error) || 'the restore failed'
3102
+ log.error('the pre-restart snapshot could not be restored:', reason, error)
3103
+ writeRestartReport({ rolledBack: false, reason, backupDir: backup.dir, restored })
3104
+ return { rolledBack: false, restored, error }
3105
+ }
3106
+
3107
+ /**
3108
+ * Arm the rollback watcher: the process that confirms the new instance and
3109
+ * puts the snapshot back when it never comes.
3110
+ *
3111
+ * The watcher is started OUTSIDE the host process, because the restart
3112
+ * command usually kills the host itself — in that case the plugin cannot
3113
+ * confirm anything any more, and the watcher is the only safety net left.
3114
+ *
3115
+ * @param {{ backup: any, profileDir: string|null, gateScriptPath?: string|null }} details
3116
+ * @returns {{ ok: boolean, method: string, error: string|null, scriptPath: string|null }}
3117
+ */
3118
+ const armRollbackWatcher = ({ backup, profileDir, gateScriptPath = null }) => {
3119
+ if (!backup || backup.ok !== true || typeof backup.dir !== 'string' || backup.dir === '') {
3120
+ log.warn('no pre-restart snapshot: the automatic rollback is not armed')
3121
+ return { ok: false, method: 'none', error: 'no snapshot', scriptPath: null }
3122
+ }
3123
+ if (typeof profileDir !== 'string' || profileDir === '') {
3124
+ log.warn('the profile directory is unknown: the automatic rollback is not armed')
3125
+ return { ok: false, method: 'none', error: 'the profile directory is unknown', scriptPath: null }
3126
+ }
3127
+ const stateDir = stateDirOf()
3128
+ const scriptPath = path.join(stateDir, 'restart-' + state.generation + '-watch.ps1')
3129
+ const script = buildWatchScript({
3130
+ // The fallback health URL (the port of the OLD instance). It is only
3131
+ // used when the host publishes no instance record at all.
3132
+ healthUrl: restartHealthUrl() ?? '',
3133
+ healthPath: resolveHealthPath(config),
3134
+ // The instance record: the port of the NEW instance and its boot id.
3135
+ portFile: accessFilePath(stateDir),
3136
+ expectedBootId: bootId,
3137
+ outcomeFile: path.join(stateDir, RESTART_OUTCOME_FILE),
3138
+ backupDir: backup.dir,
3139
+ profileDir,
3140
+ reportPath: path.join(stateDir, ROLLBACK_REPORT_NAME),
3141
+ lockFile: path.join(stateDir, ROLLBACK_LOCK_NAME),
3142
+ timeoutMs: config.rollbackTimeoutMs,
3143
+ rollbackTimeoutMs: config.rollbackCopyTimeoutMs,
3144
+ since: new Date().toISOString(),
3145
+ selfCleanPaths: [scriptPath, gateScriptPath].filter(Boolean),
3146
+ })
3147
+ const written = writeTextFileAtomic(scriptPath, script)
3148
+ if (!written.ok) {
3149
+ log.error('could not write the rollback watcher:', written.error)
3150
+ return { ok: false, method: 'none', error: written.error, scriptPath }
3151
+ }
3152
+ const result = launchWatcherFn({
3153
+ watchScriptPath: scriptPath,
3154
+ platform: env.platform,
3155
+ })
3156
+ if (!result || result.ok !== true) {
3157
+ log.error('could not start the rollback watcher:', (result && result.error) || 'unknown error')
3158
+ return { ok: false, method: (result && result.method) || 'none', error: (result && result.error) || null, scriptPath }
3159
+ }
3160
+ log.warn('the rollback watcher is armed', {
3161
+ scriptPath,
3162
+ method: result.method,
3163
+ timeoutMs: config.rollbackTimeoutMs,
3164
+ })
3165
+ return { ok: true, method: result.method, error: null, scriptPath }
3166
+ }
3167
+
3168
+ /** Execute the restart now; the detached helper survives our own death. */
3169
+ const executeRestart = async () => {
3170
+ stopPolling()
3171
+ if (!state.restart) return
3172
+ try {
3173
+ await flushSessions()
3174
+ } catch {}
3175
+ // Re-check the gate AFTER the flush and BEFORE anything is killed or scheduled:
3176
+ // another conversation may have started while the sessions were being written.
3177
+ // This re-check also protects the ungated fallback (>20 gate aborts) and the
3178
+ // forceRestartWhenBusy path: even when the detached helper cannot re-check the
3179
+ // gate itself, a busy conversation is never killed here.
3180
+ // Last chance to park a goal-driven session: without it the detached
3181
+ // helper would refuse to kill the process and the restart would wait.
3182
+ try {
3183
+ disarmBlockingGoals(busyAgents())
3184
+ } catch (error) {
3185
+ log.warn('could not disarm the goals of the blocking sessions:', String(error))
3186
+ }
3187
+ const verdict = gate()
3188
+ if (!verdict.allow) {
3189
+ log.info('restart deferred: a conversation is still active or the host is settling', {
3190
+ reason: verdict.reason,
3191
+ blockedBy: verdict.blockedBy,
3192
+ quietForMs: verdict.quietForMs,
3193
+ })
3194
+ persist()
3195
+ startPolling()
3196
+ return
3197
+ }
3198
+ setPhase(state, 'restarting')
3199
+ state.restart.attempt += 1
3200
+ persist()
3201
+ log.warn('executing graceful restart', { generation: state.generation, pid: process.pid })
3202
+ try {
3203
+ // How the host is restarted: the custom command, or the desktop app.
3204
+ const useCustomCommand = config.restartMode === 'command' && config.restartCommand.trim() !== ''
3205
+ const canRelaunchDesktop = Boolean(env.isDesktopApp && env.appExePath)
3206
+ if (!useCustomCommand && !canRelaunchDesktop) {
3207
+ // No restart method at all (the host is not the desktop app and there is
3208
+ // no custom command): nothing is restarted, so nothing is snapshotted
3209
+ // either — there is no rollback point for a restart that never happens.
3210
+ log.warn('no restart method available (not the desktop app and restartCommand is empty); leaving the checkpoint in place')
3211
+ rollbackRestart('no restart method available')
3212
+ return
3213
+ }
3214
+
3215
+ // ONE snapshot for EVERY restart mode: the profile is copied BEFORE
3216
+ // anything is killed, no matter how the host is restarted. The desktop
3217
+ // app relaunch and the custom restart command share this single entry, so
3218
+ // a hosted host that is restarted by a command is just as recoverable as
3219
+ // the desktop app.
3220
+ const stateDir = path.dirname(stateFile)
3221
+ const snapshot = snapshotFn({
3222
+ config,
3223
+ stateDir,
3224
+ log,
3225
+ profile: resolveProfileDirDetailed(),
3226
+ })
3227
+ // The snapshot is the rollback point of BOTH branches.
3228
+ const backup = snapshot && snapshot.backup ? snapshot.backup : null
3229
+ const profileDir = snapshot && snapshot.profileDir ? snapshot.profileDir : null
3230
+
3231
+ // THE DURABLE RESTART RECORD. The instance that is being replaced
3232
+ // writes the "pending" half BEFORE it runs the restart command: the
3233
+ // snapshot it took (directory, local time, duration, which older
3234
+ // snapshots were pruned) and the generation. The instance that replaces
3235
+ // it rewrites the record as `ok` / `failed` after it has checked itself
3236
+ // (see `verifyRestartBoot`). If the new instance never comes up, the
3237
+ // record stays `pending` — which is exactly what has to be visible.
3238
+ recordRestartVerdict({
3239
+ result: RESTART_RESULTS.PENDING,
3240
+ mode: useCustomCommand ? 'command' : 'app',
3241
+ generation: state.generation,
3242
+ requestedAt: state.restart && Number.isFinite(state.restart.requestedAt)
3243
+ ? state.restart.requestedAt
3244
+ : null,
3245
+ startedAt: Date.now(),
3246
+ previousBootId: bootId,
3247
+ bootId: null,
3248
+ reason: state.restart && typeof state.restart.reason === 'string' ? state.restart.reason : null,
3249
+ snapshot: buildSnapshotRecord({
3250
+ requested: snapshot && snapshot.requested === true,
3251
+ backup,
3252
+ profileDir,
3253
+ durationMs: snapshot && Number.isFinite(snapshot.durationMs) ? snapshot.durationMs : null,
3254
+ error: snapshot && snapshot.error ? snapshot.error : null,
3255
+ }),
3256
+ checks: [],
3257
+ })
3258
+ // The marker of an EARLIER decision must not confuse this restart's
3259
+ // watcher: it would stand down before it even looked at the new
3260
+ // instance. It is removed BEFORE the watcher is armed.
3261
+ try {
3262
+ fs.rmSync(path.join(stateDir, RESTART_OUTCOME_FILE), { force: true })
3263
+ } catch {}
3264
+
3265
+ if (useCustomCommand) {
3266
+ // (0) THE C ROUTE. When the supervisor of this plugin started this
3267
+ // instance, the restart is asked of the SUPERVISOR (over its control
3268
+ // channel) — not of a command. The supervisor kills the host, starts
3269
+ // it again and puts the snapshot back if it does not come.
3270
+ const detected = detectSupervisor({ env: process.env, stateDir })
3271
+ if (detected.supervised) {
3272
+ if (backup && backup.ok === true) {
3273
+ writeRollbackRequest(stateDir, {
3274
+ backupDir: backup.dir,
3275
+ profileDir: profileDir ?? '',
3276
+ generation: state.generation,
3277
+ reason: 'plugin-restart',
3278
+ })
3279
+ }
3280
+ const asked = await requestSupervisorAction({ stateDir, action: 'restart' })
3281
+ if (asked && asked.ok === true) {
3282
+ log.warn('the restart was handed to the supervisor of this plugin')
3283
+ return
3284
+ }
3285
+ log.error(
3286
+ 'the supervisor did not accept the restart request; falling back to the restart command:',
3287
+ asked ? asked.error : 'unknown error',
3288
+ )
3289
+ }
3290
+
3291
+ const command = buildCustomCommand({
3292
+ template: config.restartCommand,
3293
+ pid: process.pid,
3294
+ ppid: process.ppid,
3295
+ appExePath: env.appExePath || '',
3296
+ })
3297
+ log.warn('running custom restart command:', command)
3298
+
3299
+ // (1) THE SAFETY NET. The restart command usually kills THIS process
3300
+ // (that is what restarting the host means), so the plugin cannot
3301
+ // confirm anything once the command runs. The watcher is started
3302
+ // BEFORE the command: it waits for the new instance outside the host
3303
+ // and puts the snapshot back when the new instance never comes.
3304
+ //
3305
+ // It is NOT armed when the supervisor of this plugin is in charge:
3306
+ // that one restores the snapshot itself, and two of them would write
3307
+ // the same files at the same time.
3308
+ if (!detected.supervised) armRollbackWatcher({ backup, profileDir })
3309
+
3310
+ try {
3311
+ // (2) THE COMMAND, with a real success judgement. The old code only
3312
+ // looked at "the spawn did not throw", so a command that does not
3313
+ // exist or that exits with an error was reported as a successful
3314
+ // restart.
3315
+ const outcome = await runCommandFn({
3316
+ command,
3317
+ timeoutMs: config.restartCommandTimeoutMs,
3318
+ })
3319
+ if (!outcome || outcome.ok !== true) {
3320
+ const reason = (outcome && outcome.error) || 'the restart command failed'
3321
+ log.error('the restart command failed; the pre-restart snapshot is restored:', reason)
3322
+ rollbackInProcess({ backup, profileDir, reason })
3323
+ writeRestartOutcome({ decided: true, ok: false, reason })
3324
+ recordRestartVerdict({
3325
+ result: RESTART_RESULTS.FAILED,
3326
+ verifiedAt: Date.now(),
3327
+ reason,
3328
+ checks: [{ name: 'command', ok: false, detail: reason }],
3329
+ })
3330
+ return
3331
+ }
3332
+ if (outcome.timedOut === true) {
3333
+ log.warn(
3334
+ `the restart command is still running after ${config.restartCommandTimeoutMs}ms; `
3335
+ + 'the new instance is confirmed by the rollback watcher',
3336
+ { command, timeoutMs: config.restartCommandTimeoutMs },
3337
+ )
3338
+ } else {
3339
+ log.warn('the restart command finished', { command, exitCode: outcome.exitCode ?? null })
3340
+ }
3341
+
3342
+ // (3) THE HEALTH CONFIRMATION — only possible when this process
3343
+ // survived the command. The new instance publishes its own port and
3344
+ // its own boot id; the confirmation waits for THAT instance and not
3345
+ // for the one that is being replaced.
3346
+ const confirmed = await probeInstanceFn({
3347
+ accessFile: accessFilePath(stateDir),
3348
+ expectedBootId: bootId,
3349
+ healthPath: resolveHealthPath(config),
3350
+ timeoutMs: config.rollbackTimeoutMs,
3351
+ })
3352
+ if (confirmed && confirmed.ok === true) {
3353
+ log.warn('the new instance answered its health route:', confirmed)
3354
+ writeRestartOutcome({ decided: true, ok: true, reason: 'the new instance answered' })
3355
+ recordRestartVerdict({
3356
+ result: RESTART_RESULTS.OK,
3357
+ verifiedAt: Date.now(),
3358
+ reason: 'the new instance answered',
3359
+ checks: [{ name: 'command', ok: true, detail: 'the new instance answered its health route' }],
3360
+ }, { skipIfFinalized: true })
3361
+ return
3362
+ }
3363
+ const reason = (confirmed && confirmed.reason) || 'the new instance never answered'
3364
+ log.error('the new instance did not come up; the pre-restart snapshot is restored:', reason)
3365
+ rollbackInProcess({ backup, profileDir, reason })
3366
+ writeRestartOutcome({ decided: true, ok: false, reason })
3367
+ recordRestartVerdict({
3368
+ result: RESTART_RESULTS.FAILED,
3369
+ verifiedAt: Date.now(),
3370
+ reason,
3371
+ checks: [{ name: 'command', ok: false, detail: reason }],
3372
+ })
3373
+ } catch (error) {
3374
+ // The command has already run: a retry would restart the host a
3375
+ // SECOND time. The snapshot is restored and the reason is reported.
3376
+ const reason = 'the hosted restart failed: ' + String(error)
3377
+ log.error(reason)
3378
+ rollbackInProcess({ backup, profileDir, reason })
3379
+ writeRestartOutcome({ decided: true, ok: false, reason })
3380
+ recordRestartVerdict({
3381
+ result: RESTART_RESULTS.FAILED,
3382
+ verifiedAt: Date.now(),
3383
+ reason,
3384
+ checks: [{ name: 'command', ok: false, detail: reason }],
3385
+ })
3386
+ }
3387
+ } else {
3388
+ const aborts = Number(state.restart.gateAborts) || 0
3389
+ const url = aborts > 20 ? null : restartGateUrl()
3390
+ if (aborts > 20) {
3391
+ log.error('falling back to an UNGATED restart after repeated idle-gate aborts', { gateAborts: aborts })
3392
+ } else if (url === null) {
3393
+ log.warn('the web server port is unknown; scheduling an UNGATED restart (the helper cannot re-check the idle gate)')
3394
+ }
3395
+ // The snapshot was already taken above (the shared entry).
3396
+ const healthUrl = restartHealthUrl()
3397
+ const gateScriptPath = url === null ? null : path.join(stateDir, 'restart-' + state.generation + '.cmd')
3398
+ // The watcher does NOT need the port of the old instance (it reads the
3399
+ // port of the NEW one from the instance record), so it is armed as
3400
+ // soon as there is a snapshot and a profile directory.
3401
+ const watchScriptPath = backup && backup.ok === true && profileDir !== null
3402
+ ? path.join(stateDir, 'restart-' + state.generation + '-watch.ps1')
3403
+ : null
3404
+ const watchScript = watchScriptPath === null
3405
+ ? null
3406
+ : buildWatchScript({
3407
+ // The fallback URL of the OLD instance. The watcher uses the port
3408
+ // of the NEW instance (from the instance record) — the desktop
3409
+ // app listens on a random port, so the old one is worthless.
3410
+ healthUrl,
3411
+ healthPath: resolveHealthPath(config),
3412
+ portFile: accessFilePath(stateDir),
3413
+ expectedBootId: bootId,
3414
+ outcomeFile: path.join(stateDir, RESTART_OUTCOME_FILE),
3415
+ backupDir: backup.dir,
3416
+ profileDir,
3417
+ reportPath: path.join(stateDir, ROLLBACK_REPORT_NAME),
3418
+ lockFile: path.join(stateDir, ROLLBACK_LOCK_NAME),
3419
+ timeoutMs: config.rollbackTimeoutMs,
3420
+ rollbackTimeoutMs: config.rollbackCopyTimeoutMs,
3421
+ // Only a crash log written after this point belongs to this restart.
3422
+ since: new Date().toISOString(),
3423
+ // The watcher removes the helper scripts when it ends.
3424
+ selfCleanPaths: [watchScriptPath, gateScriptPath].filter(Boolean),
3425
+ })
3426
+ const result = scheduleDetachedFn({
3427
+ appExePath: env.appExePath,
3428
+ relaunchDelaySeconds: config.relaunchDelaySeconds,
3429
+ gateUrl: url,
3430
+ scriptPath: gateScriptPath,
3431
+ watchScriptPath,
3432
+ watchScript,
3433
+ })
3434
+ log.warn('scheduled detached restart:', result)
3435
+ if (!result || result.ok !== true) rollbackRestart((result && result.error) || 'detached restart refused')
3436
+ else armGateWatchdog()
3437
+ }
3438
+ } catch (error) {
3439
+ rollbackRestart(String(error))
3440
+ }
3441
+ }
3442
+
3443
+ /** One decision tick while a restart is pending. */
3444
+ const tick = () => {
3445
+ if (disposed) return
3446
+ if (!state.restart) {
3447
+ stopPolling()
3448
+ return
3449
+ }
3450
+ const now = Date.now()
3451
+ const busy = busyAgents()
3452
+ // A goal-driven session blocks the restart forever: park its goal BEFORE the
3453
+ // gate decides that it is blocked, so the next poll sees an idle session.
3454
+ if (busy.length > 0) {
3455
+ try {
3456
+ disarmBlockingGoals(busy)
3457
+ } catch (error) {
3458
+ log.warn('could not disarm the goals of the blocking sessions:', String(error))
3459
+ }
3460
+ }
3461
+ const decision = restartDecision(state, {
3462
+ anyAgentRunning: busy.length > 0,
3463
+ busyAgents: busy,
3464
+ lastActivityAt: quietSince(),
3465
+ settleMs: effectiveSettleMs(),
2312
3466
  now,
2313
3467
  softMs: config.deferSoftMs,
2314
3468
  hardMs: config.deferHardMs,
@@ -2557,10 +3711,114 @@ const titleOfSummary = (item) => {
2557
3711
  waitedMs: Number.isFinite(report.waitedMs) ? report.waitedMs : null,
2558
3712
  rolledBack: report.rolledBack === true,
2559
3713
  backupDir: typeof report.backupDir === 'string' ? report.backupDir : null,
3714
+ // WHY the restart failed. Without it the interface can only say "it was
3715
+ // rolled back", which is the half of the story the user needs most.
3716
+ reason: typeof report.reason === 'string'
3717
+ ? report.reason
3718
+ : (typeof report.rollbackError === 'string' ? report.rollbackError : ''),
2560
3719
  crashLog: typeof report.crashLog === 'string' ? report.crashLog : null,
2561
3720
  crashTail: tail.length > 4000 ? tail.slice(-4000) : tail,
2562
3721
  }
2563
3722
  },
3723
+ /**
3724
+ * The instance record of the running host: the port it listens on and the
3725
+ * access link (with the boot token) that still works.
3726
+ *
3727
+ * After a restart the OLD link answers 401 — the boot token is created per
3728
+ * process — so this is the link the user needs to get back into the
3729
+ * interface.
3730
+ */
3731
+ get accessRecord() {
3732
+ const read = readAccessRecord(path.dirname(stateFile))
3733
+ return read && read.ok === true ? read.record : null
3734
+ },
3735
+ /**
3736
+ * What the plugin knows about the self-starting supervisor: who starts
3737
+ * this host (the supervisor of this plugin / the user's own way / nobody)
3738
+ * and whether the restart can actually work.
3739
+ */
3740
+ supervisorStatus: supervisorStatusOf,
3741
+ /**
3742
+ * HOW this host was started (the startup matrix): the mode, the evidence
3743
+ * and what the plugin may do about it.
3744
+ */
3745
+ startupMode: startupModeOf,
3746
+ /**
3747
+ * WHAT IS ON DISK RIGHT NOW: the backups of this host.
3748
+ */
3749
+ backupsInfo,
3750
+ /**
3751
+ * THE DIAGNOSTICS of "report a problem" (the text the user copies).
3752
+ */
3753
+ diagnostics: diagnosticsText,
3754
+ /**
3755
+ * WHICH MACHINE IS THIS? (Windows / WSL / Linux / macOS) — the same
3756
+ * answer the graceful shutdown uses.
3757
+ */
3758
+ hostKind: hostKindOf,
3759
+ /**
3760
+ * Install the supervisor. `confirmed` must be true: the confirmation card
3761
+ * is what says "yes".
3762
+ */
3763
+ installSupervisor: installSupervisorNow,
3764
+ /**
3765
+ * Remove the supervisor and give the machine back to the user.
3766
+ */
3767
+ uninstallSupervisor: uninstallSupervisorNow,
3768
+ /**
3769
+ * Record the answer of the one-time question about the supervisor.
3770
+ */
3771
+ recordSupervisorChoice: recordSupervisorChoice,
3772
+ /**
3773
+ * Derive the command that starts this host again (the dry run: nothing is
3774
+ * written).
3775
+ */
3776
+ deriveStartCommand: deriveStartCommandNow,
3777
+ /**
3778
+ * Write the derived command into the profile configuration (only after
3779
+ * the user confirmed it).
3780
+ */
3781
+ applyDerivedCommand: applyDerivedCommand,
3782
+ /**
3783
+ * Put the previous start command back (the undo).
3784
+ */
3785
+ revertDerivedCommand: revertDerivedCommand,
3786
+ /**
3787
+ * The answer of the one-time question about the supervisor (null when it
3788
+ * was never answered).
3789
+ */
3790
+ get onboardingChoice() {
3791
+ const record = state.onboarding
3792
+ if (!record || typeof record !== 'object') return null
3793
+ if (record.feature !== 'supervisor') return null
3794
+ return {
3795
+ feature: record.feature,
3796
+ version: record.version,
3797
+ choice: record.choice,
3798
+ answeredAt: record.answeredAt,
3799
+ }
3800
+ },
3801
+ /**
3802
+ * Whether the one-time question about the supervisor may be asked.
3803
+ *
3804
+ * ONLY for the HOSTED hosts: the desktop app brings itself back up
3805
+ * (`restartMode: app`), so asking a hosted user "shall I install a
3806
+ * supervisor?" would be noise there.
3807
+ *
3808
+ * And NEVER when somebody else is already in charge: a second supervisor
3809
+ * would fight over the port with the first one — that is the double start
3810
+ * the whole external-supervisor detection exists to prevent.
3811
+ */
3812
+ get supervisorOffer() {
3813
+ if (env.isDesktopApp) return false
3814
+ const record = state.onboarding
3815
+ if (record && typeof record === 'object' && record.feature === 'supervisor') return false
3816
+ try {
3817
+ const status = supervisorStatusOf()
3818
+ if (status && status.mode === 'external') return false
3819
+ } catch {}
3820
+ return true
3821
+ },
2564
3822
  env,
2565
3823
  stateFile,
2566
3824
  log,
@@ -2613,6 +3871,24 @@ const titleOfSummary = (item) => {
2613
3871
  return { applied: result.applied, showSleepButton: showSleepButton === true }
2614
3872
  },
2615
3873
  toolErrors,
3874
+ /**
3875
+ * The routes of this plugin that the web server accepted, and the ones it
3876
+ * refused. The boot check reports them: a host that came up without its
3877
+ * routes is NOT a healthy host.
3878
+ */
3879
+ registeredRoutes,
3880
+ routeErrors,
3881
+ /**
3882
+ * The outcome of the boot recovery pass of THIS boot:
3883
+ * `{ ran, ok, detail }`. `ran` is false when the recovery is switched off.
3884
+ */
3885
+ recovery: null,
3886
+ /**
3887
+ * The durable record of the last restart (the snapshot that was taken and
3888
+ * the verdict of the instance that came up).
3889
+ */
3890
+ lastRestartVerdict,
3891
+ recordRestartVerdict,
2616
3892
  startPolling,
2617
3893
  stopPolling,
2618
3894
  tick,
@@ -3220,9 +4496,140 @@ async function recoverAfterRestart(ctx, runtime, config) {
3220
4496
  maxSessions: config.maxSessions,
3221
4497
  })
3222
4498
  }
3223
- persistSafely(runtime)
3224
- log.info('recovery finished:', results)
3225
- return { recovered: results, phase: 'running' }
4499
+ persistSafely(runtime)
4500
+ log.info('recovery finished:', results)
4501
+ return { recovered: results, phase: 'running' }
4502
+ }
4503
+
4504
+ /**
4505
+ * How long a pending restart record stays fresh enough to be finished by
4506
+ * THIS boot. A record that is older belongs to a restart of another day — it
4507
+ * stays `pending`, because that restart really was never confirmed.
4508
+ */
4509
+ export const RESTART_VERDICT_FRESH_MS = 30 * 60 * 1000
4510
+
4511
+ /**
4512
+ * The four checks the instance that replaced the old one runs on itself.
4513
+ *
4514
+ * They answer the question the operator actually has after a restart: "did
4515
+ * the new host really come up?" — and each of them is a fact about THIS boot,
4516
+ * not about the old one:
4517
+ *
4518
+ * - `listener` — the host listens on a known port;
4519
+ * - `routes` — the routes of this plugin were accepted by the web server;
4520
+ * - `plugin` — the plugin itself is alive in this boot;
4521
+ * - `recovery`— the boot recovery pass finished (or was not needed).
4522
+ *
4523
+ * @param {object} runtime
4524
+ * @returns {Array<{ name: string, ok: boolean, detail: string }>}
4525
+ */
4526
+ export function collectBootChecks(runtime) {
4527
+ const checks = []
4528
+
4529
+ const port = Number(runtime && runtime.webPort)
4530
+ checks.push({
4531
+ name: 'listener',
4532
+ ok: Number.isFinite(port) && port > 0,
4533
+ detail: Number.isFinite(port) && port > 0
4534
+ ? 'the host listens on port ' + port
4535
+ : 'the host does not listen on a known port',
4536
+ })
4537
+
4538
+ const registered = Array.isArray(runtime && runtime.registeredRoutes) ? runtime.registeredRoutes : []
4539
+ const routeErrors = Array.isArray(runtime && runtime.routeErrors) ? runtime.routeErrors : []
4540
+ checks.push({
4541
+ name: 'routes',
4542
+ ok: registered.length > 0 && routeErrors.length === 0,
4543
+ detail: registered.length === 0
4544
+ ? 'no route of this plugin was registered'
4545
+ : registered.length + ' route(s) registered'
4546
+ + (routeErrors.length > 0 ? ', ' + routeErrors.length + ' refused' : ''),
4547
+ })
4548
+
4549
+ const bootId = runtime && typeof runtime.bootId === 'string' ? runtime.bootId : ''
4550
+ const toolErrors = Array.isArray(runtime && runtime.toolErrors) ? runtime.toolErrors : []
4551
+ checks.push({
4552
+ name: 'plugin',
4553
+ ok: bootId !== '' && toolErrors.length === 0,
4554
+ detail: bootId === ''
4555
+ ? 'the plugin did not register a boot id'
4556
+ : 'the plugin is alive (boot ' + bootId + ')'
4557
+ + (toolErrors.length > 0 ? ', ' + toolErrors.length + ' tool(s) failed' : ''),
4558
+ })
4559
+
4560
+ const recovery = runtime && runtime.recovery && typeof runtime.recovery === 'object'
4561
+ ? runtime.recovery
4562
+ : null
4563
+ checks.push({
4564
+ name: 'recovery',
4565
+ ok: recovery === null ? false : recovery.ok === true,
4566
+ detail: recovery === null
4567
+ ? 'the boot recovery did not report anything'
4568
+ : (typeof recovery.detail === 'string' && recovery.detail !== '' ? recovery.detail : 'the boot recovery finished'),
4569
+ })
4570
+
4571
+ return checks
4572
+ }
4573
+
4574
+ /**
4575
+ * Finish the durable restart record.
4576
+ *
4577
+ * The instance that replaced the old one reads the `pending` record, runs the
4578
+ * four boot checks and writes the final verdict (`ok` / `failed`) with the
4579
+ * time it verified itself and how long the whole restart took.
4580
+ *
4581
+ * Nothing is written when there is no pending record (this boot was not the
4582
+ * result of a restart) or when the pending record belongs to another day.
4583
+ *
4584
+ * @param {object} runtime
4585
+ * @param {{ timeoutMs?: number, now?: number, sleep?: Function }} [options]
4586
+ * @returns {Promise<object|null>} the record that is on disk now
4587
+ */
4588
+ export async function verifyRestartBoot(runtime, options = {}) {
4589
+ const log = runtime.log
4590
+ const stateDir = path.dirname(runtime.stateFile)
4591
+ const pending = readRestartVerdict(stateDir)
4592
+ if (pending === null) return null
4593
+ if (pending.result !== RESTART_RESULTS.PENDING) return pending
4594
+ const now = typeof options.now === 'function' ? options.now : () => Date.now()
4595
+ const timeoutMs = Number.isFinite(Number(options.timeoutMs)) && Number(options.timeoutMs) > 0
4596
+ ? Math.trunc(Number(options.timeoutMs))
4597
+ : 15000
4598
+ const sleep = typeof options.sleep === 'function' ? options.sleep : recoveryDelay
4599
+ const started = now()
4600
+ // The web server may still be coming up: the checks need its port and its
4601
+ // routes, so they wait — but only for a bounded while.
4602
+ while (now() - started < timeoutMs) {
4603
+ const port = Number(runtime.webPort)
4604
+ const registered = Array.isArray(runtime.registeredRoutes) ? runtime.registeredRoutes.length : 0
4605
+ if (Number.isFinite(port) && port > 0 && registered > 0) break
4606
+ await sleep(250)
4607
+ }
4608
+ const checks = collectBootChecks(runtime)
4609
+ const evaluated = evaluateRestartChecks(checks)
4610
+ const verifiedAt = now()
4611
+ const record = runtime.recordRestartVerdict({
4612
+ result: evaluated.ok ? RESTART_RESULTS.OK : RESTART_RESULTS.FAILED,
4613
+ verifiedAt,
4614
+ durationMs: pending.startedAt !== null ? Math.max(0, verifiedAt - pending.startedAt) : null,
4615
+ bootId: runtime.bootId,
4616
+ checks,
4617
+ reason: evaluated.ok
4618
+ ? 'the new instance came up and every check passed'
4619
+ : 'the new instance came up, but ' + evaluated.failed.join(', ') + ' failed',
4620
+ })
4621
+ if (record !== null) {
4622
+ if (evaluated.ok) {
4623
+ log.warn('the new instance verified itself:', { durationMs: record.durationMs, checks })
4624
+ } else {
4625
+ log.error('the new instance did NOT come up cleanly:', {
4626
+ durationMs: record.durationMs,
4627
+ failed: evaluated.failed,
4628
+ checks,
4629
+ })
4630
+ }
4631
+ }
4632
+ return record
3226
4633
  }
3227
4634
 
3228
4635
  /** Promise-based pause used by boot recovery (batching + resolveAgent retries). */
@@ -3436,6 +4843,11 @@ function registerTools(ctx, runtime, config) {
3436
4843
  profileDirSource: { type: 'string' },
3437
4844
  backupIncludePlugins: { type: 'boolean' },
3438
4845
  lastRollback: { type: 'object' },
4846
+ // The last restart: the snapshot that was taken before it and the
4847
+ // verdict of the instance that came up.
4848
+ lastRestart: { type: 'object' },
4849
+ // The access link of the running instance (with the boot token).
4850
+ accessUrl: { type: 'string' },
3439
4851
  environment: { type: 'object' },
3440
4852
  sessions: { type: 'array', items: { type: 'object' } },
3441
4853
  },
@@ -3472,6 +4884,15 @@ function registerTools(ctx, runtime, config) {
3472
4884
  backupIncludePlugins: config.backupIncludePlugins !== false,
3473
4885
  // Present only when the previous restart had to be rolled back.
3474
4886
  ...(runtime.lastRollback ? { lastRollback: runtime.lastRollback } : {}),
4887
+ // THE LAST RESTART: the snapshot that was taken before it (directory,
4888
+ // LOCAL time, duration, pruned) and the verdict of the instance that
4889
+ // came up (the four boot checks). Present only when a restart
4890
+ // happened — otherwise there is nothing to report.
4891
+ ...(runtime.lastRestartVerdict() ? { lastRestart: runtime.lastRestartVerdict() } : {}),
4892
+ // The access link of THIS instance (with the boot token). The boot
4893
+ // token is created per process, so the link the browser holds answers
4894
+ // 401 after a restart — this is the link that still works.
4895
+ ...(accessUrlOf(runtime) ? { accessUrl: accessUrlOf(runtime) } : {}),
3475
4896
  environment: {
3476
4897
  platform: runtime.env.platform,
3477
4898
  isDesktopApp: runtime.env.isDesktopApp,
@@ -3647,11 +5068,19 @@ function registerTools(ctx, runtime, config) {
3647
5068
 
3648
5069
  tool({
3649
5070
  name: 'continuum_cancel_restart',
3650
- description: 'Cancel a restart previously requested through continuum_restart. Does nothing when no restart is pending.',
5071
+ description:
5072
+ 'Cancel a restart requested through continuum_restart. By default ONLY the calling session\'s own request is '
5073
+ + 'withdrawn (scope "mine"): the other sessions that asked for the same restart keep it. Use scope "all" to '
5074
+ + 'cancel the request for EVERYONE ("nobody restarts any more"). Does nothing when no restart is pending.',
3651
5075
  parameters: {
3652
5076
  type: 'object',
3653
5077
  additionalProperties: false,
3654
5078
  properties: {
5079
+ scope: {
5080
+ type: 'string',
5081
+ enum: ['mine', 'all'],
5082
+ description: 'Whose request to cancel: "mine" (the default) withdraws only the calling session\'s own request and leaves the requests of the other sessions alone; "all" cancels the request for every session.',
5083
+ },
3655
5084
  reason: { type: 'string', description: 'Optional note recorded in the log.' },
3656
5085
  },
3657
5086
  },
@@ -3663,20 +5092,248 @@ function registerTools(ctx, runtime, config) {
3663
5092
  properties: {
3664
5093
  ok: { type: 'boolean' },
3665
5094
  cancelled: { type: 'boolean' },
5095
+ scope: { type: 'string' },
5096
+ remainingRequesters: { type: 'integer' },
5097
+ sessionId: { type: 'string' },
3666
5098
  phase: { type: 'string' },
5099
+ error: { type: 'string' },
3667
5100
  },
3668
5101
  },
3669
5102
  (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
3670
5103
  ),
3671
- execute(args = {}) {
5104
+ execute(args = {}, exec) {
3672
5105
  const state = runtime.state
3673
- const hadPending = state.restart !== null
3674
- runtime.releaseRequesters()
3675
- cancelRestart(state, { now: Date.now() })
3676
- runtime.stopPolling()
5106
+ const now = Date.now()
5107
+ const scope = args.scope === 'all' ? 'all' : 'mine'
5108
+ const agent = exec && exec.agent
5109
+ const sessionId = agent && agent.id ? String(agent.id) : ''
5110
+ const reason = typeof args.reason === 'string' && args.reason.trim() !== '' ? args.reason.trim() : ''
5111
+
5112
+ // How many sessions are waiting for the pending restart.
5113
+ const requesterCount = () => {
5114
+ const restart = state.restart
5115
+ if (!restart) return 0
5116
+ const ids = new Set()
5117
+ if (Array.isArray(restart.requesters)) {
5118
+ for (const requester of restart.requesters) {
5119
+ if (requester && typeof requester.sessionId === 'string' && requester.sessionId) ids.add(requester.sessionId)
5120
+ }
5121
+ }
5122
+ if (typeof restart.bySessionId === 'string' && restart.bySessionId) ids.add(restart.bySessionId)
5123
+ return ids.size
5124
+ }
5125
+
5126
+ if (scope === 'all') {
5127
+ // The explicit "nobody restarts any more": the whole request goes.
5128
+ const hadPending = state.restart !== null
5129
+ runtime.releaseRequesters()
5130
+ cancelRestart(state, { now })
5131
+ runtime.stopPolling()
5132
+ runtime.persist()
5133
+ if (hadPending) log.info('pending restart cancelled for EVERYONE:', reason || '(no reason)')
5134
+ return { ok: true, cancelled: hadPending, scope: 'all', remainingRequesters: 0, phase: state.phase }
5135
+ }
5136
+
5137
+ // scope "mine": withdraw ONLY the calling session's request.
5138
+ if (state.restart === null) {
5139
+ // Nothing is pending: the tool does nothing, as before.
5140
+ return { ok: true, cancelled: false, scope: 'mine', remainingRequesters: 0, phase: state.phase }
5141
+ }
5142
+ if (sessionId === '') {
5143
+ // The calling session is unknown: refuse instead of guessing. A
5144
+ // silent "cancel everything" is exactly the accident this scope
5145
+ // exists to prevent.
5146
+ log.warn('the restart request of the calling session could not be withdrawn: the calling session is unknown; nothing was cancelled')
5147
+ return {
5148
+ ok: false,
5149
+ cancelled: false,
5150
+ scope: 'mine',
5151
+ remainingRequesters: requesterCount(),
5152
+ phase: state.phase,
5153
+ error: 'The calling session is unknown, so its own request cannot be identified. Nothing was cancelled. Call this tool from the session that requested the restart, or pass scope "all" to cancel the request for everyone.',
5154
+ }
5155
+ }
5156
+ const outcome = cancelOwnRestart(state, { sessionId, now })
5157
+ if (!outcome.removed) {
5158
+ log.warn(`session ${sessionId} did not request the pending restart; nothing was cancelled`)
5159
+ return {
5160
+ ok: false,
5161
+ cancelled: false,
5162
+ scope: 'mine',
5163
+ remainingRequesters: outcome.remaining,
5164
+ sessionId,
5165
+ phase: state.phase,
5166
+ error: 'This session did not request the pending restart, so nothing was cancelled. Pass scope "all" to cancel the request for every session.',
5167
+ }
5168
+ }
5169
+ // Release ONLY this session's continuation request: the other sessions
5170
+ // stay pinned for the restart they asked for.
5171
+ runtime.releaseRequesters([sessionId])
5172
+ if (outcome.cancelled) {
5173
+ // The last requester left: the restart is really over.
5174
+ runtime.stopPolling()
5175
+ log.info(`session ${sessionId} withdrew the last restart request; the pending restart is cancelled`, reason || '')
5176
+ } else {
5177
+ log.info(`session ${sessionId} withdrew its restart request; ${outcome.remaining} requester(s) remain`)
5178
+ }
3677
5179
  runtime.persist()
3678
- if (hadPending) log.info('pending restart cancelled:', typeof args.reason === 'string' ? args.reason : '(no reason)')
3679
- return { ok: true, cancelled: hadPending, phase: state.phase }
5180
+ return {
5181
+ ok: true,
5182
+ cancelled: outcome.cancelled,
5183
+ scope: 'mine',
5184
+ remainingRequesters: outcome.remaining,
5185
+ sessionId,
5186
+ phase: state.phase,
5187
+ }
5188
+ },
5189
+ })
5190
+
5191
+ tool({
5192
+ name: 'continuum_supervisor',
5193
+ description:
5194
+ 'Inspect and manage the self-starting supervisor of this plugin (the process that brings the host back when it '
5195
+ + 'dies). action "status" reports who starts this host (the supervisor of this plugin / your own way / nobody) '
5196
+ + 'and whether the restart can work at all; action "plan" reports what an installation WOULD change without '
5197
+ + 'touching anything; action "install" and action "uninstall" need confirmed: true, because they really change '
5198
+ + 'the machine (a scheduled task or a systemd user unit that starts the supervisor at login).',
5199
+ parameters: {
5200
+ type: 'object',
5201
+ additionalProperties: false,
5202
+ properties: {
5203
+ action: {
5204
+ type: 'string',
5205
+ enum: ['status', 'plan', 'dry-run', 'install', 'uninstall', 'apply', 'revert'],
5206
+ description: 'What to do: "status" (read-only), "plan" (what the installation would change), "dry-run" (the command this host would be restarted with, and why it may not be derivable), "install"/"uninstall" (the real thing, they need confirmed: true), "apply" (write the derived restart command after the user confirmed the dry run) and "revert" (put the previous command back).',
5207
+ },
5208
+ confirmed: {
5209
+ type: 'boolean',
5210
+ description: 'Required for "install" and "uninstall": the user confirmed the card that says what would change.',
5211
+ },
5212
+ choice: {
5213
+ type: 'string',
5214
+ enum: ['install', 'external', 'dismissed'],
5215
+ description: 'The answer of the one-time question about the supervisor (recorded so the question is never asked twice).',
5216
+ },
5217
+ },
5218
+ },
5219
+ output: textOutput(
5220
+ {
5221
+ type: 'object',
5222
+ additionalProperties: false,
5223
+ required: ['ok', 'action'],
5224
+ properties: {
5225
+ ok: { type: 'boolean' },
5226
+ action: { type: 'string' },
5227
+ kind: { type: 'string' },
5228
+ degraded: { type: 'boolean' },
5229
+ reason: { type: 'string' },
5230
+ error: { type: 'string' },
5231
+ supervisor: { type: 'object' },
5232
+ steps: { type: 'array', items: { type: 'object' } },
5233
+ previous: { type: 'object' },
5234
+ pending: { type: 'object' },
5235
+ },
5236
+ },
5237
+ (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
5238
+ ),
5239
+ async execute(args = {}) {
5240
+ const action = typeof args.action === 'string' ? args.action.trim() : 'status'
5241
+ const supervisor = runtime.supervisorStatus()
5242
+ if (action === 'status') {
5243
+ return { ok: true, action, supervisor }
5244
+ }
5245
+ if (action === 'dry-run' || action === 'derive') {
5246
+ // B: the dry run. It reports what WOULD be written; it writes nothing.
5247
+ const plan = await runtime.deriveStartCommand()
5248
+ return {
5249
+ ok: plan.ok === true,
5250
+ action,
5251
+ level: plan.level,
5252
+ mode: plan.mode,
5253
+ reason: plan.reason,
5254
+ command: plan.command,
5255
+ plan: plan.plan,
5256
+ needsConfirmation: plan.needsConfirmation !== false,
5257
+ writes: plan.writes,
5258
+ compose: plan.compose,
5259
+ supervisor,
5260
+ }
5261
+ }
5262
+ if (action === 'plan') {
5263
+ const plan = planSupervisorInstall({
5264
+ platform: runtime.env.platform,
5265
+ // The desktop app manages itself: the plan is refused there, and no
5266
+ // step is generated that could be run by accident.
5267
+ isDesktopApp: runtime.env.isDesktopApp === true,
5268
+ // The startup matrix: only a `bare` host may get a supervisor.
5269
+ startup: runtime.startupMode(),
5270
+ // WHICH MACHINE IS THIS? (WSL is not a Linux.)
5271
+ host: runtime.hostKind(),
5272
+ nodePath: process.execPath,
5273
+ execPath: process.execPath,
5274
+ supervisorPath: supervisorPaths(path.dirname(runtime.stateFile)).script,
5275
+ configPath: supervisorPaths(path.dirname(runtime.stateFile)).config,
5276
+ kind: config.supervisorKind,
5277
+ })
5278
+ return {
5279
+ ok: plan.error ? false : true,
5280
+ action,
5281
+ kind: plan.kind,
5282
+ reason: plan.reason,
5283
+ error: plan.error ?? undefined,
5284
+ steps: plan.steps,
5285
+ supervisor,
5286
+ }
5287
+ }
5288
+ if (action === 'install' && runtime.env.isDesktopApp === true) {
5289
+ // The desktop app manages itself: a login record would open a SECOND
5290
+ // client at every login. The refusal comes BEFORE the confirmation, so
5291
+ // the user is never asked to confirm something that cannot happen.
5292
+ return {
5293
+ ok: false,
5294
+ action,
5295
+ error: 'desktop-manages-itself',
5296
+ reason: 'the desktop app relaunches itself; a supervisor would start a second copy',
5297
+ steps: [],
5298
+ supervisor,
5299
+ }
5300
+ }
5301
+ if (args.confirmed !== true) {
5302
+ // The card is what says "yes": without it the machine is not touched.
5303
+ return {
5304
+ ok: false,
5305
+ action,
5306
+ error: 'confirmation-required',
5307
+ reason: 'the installation and the uninstallation change the machine, so the user has to confirm the card first',
5308
+ supervisor,
5309
+ }
5310
+ }
5311
+ if (action === 'install') {
5312
+ const choice = typeof args.choice === 'string' ? args.choice : ''
5313
+ if (choice !== '') runtime.recordSupervisorChoice(choice)
5314
+ const result = runtime.installSupervisor({ confirmed: true })
5315
+ return {
5316
+ ok: result.ok === true,
5317
+ action,
5318
+ kind: result.kind,
5319
+ degraded: result.degraded,
5320
+ reason: result.reason,
5321
+ error: result.error ?? undefined,
5322
+ steps: result.steps,
5323
+ supervisor: runtime.supervisorStatus(),
5324
+ }
5325
+ }
5326
+ const result = await runtime.uninstallSupervisor()
5327
+ return {
5328
+ ok: result.ok === true,
5329
+ action,
5330
+ reason: result.ok === true ? 'the supervisor was removed' : undefined,
5331
+ error: result.error ?? undefined,
5332
+ steps: result.steps,
5333
+ previous: result.previous ?? undefined,
5334
+ pending: result.pending ?? undefined,
5335
+ supervisor: runtime.supervisorStatus(),
5336
+ }
3680
5337
  },
3681
5338
  })
3682
5339
  }
@@ -3719,7 +5376,7 @@ const AUTORESTART_PROMPT = [
3719
5376
  '- Call the `continuum_restart` tool instead (optional arguments: `reason`, `resume`, `defer_seconds`, `parkGoals`). It waits until every conversation is idle (no running turn — a running turn ALWAYS blocks; an inbox message nobody claimed blocks only until `queuedGraceMs`, default 60000 ms, after which it is treated as stuck and no longer blocks) AND the host has been quiet for `settleMs` (default 2000 ms); the detached helper re-checks that gate immediately before killing, so other conversations are never killed mid-turn. It then relaunches the app, and after boot this plugin resumes the session and continues the work automatically.',
3720
5377
  '- A goal-driven session is DISARMED automatically before the restart (its current round still finishes, nothing is lost), because such a session would otherwise keep the restart waiting forever: disarming only removes the process-local continuation authority, so no new round starts and the session goes idle. After the restart the goal is re-armed and continues. A goal whose round budget is spent cannot be continued and is reported instead. You can disarm your own goal on demand with `continuum_park_goal`, and retry a stuck recovery with `continuum_recover_now`.',
3721
5378
  '- Persist anything the next turn needs before restarting (workspace files, `todo_write`, goal state); do not rely on in-memory state.',
3722
- '- Inspect the lifecycle with `continuum_status`; cancel a pending restart with `continuum_cancel_restart`.',
5379
+ '- Inspect the lifecycle with `continuum_status`; cancel a pending restart with `continuum_cancel_restart`. Its default scope is `mine`: it withdraws ONLY the calling session\'s own request and leaves the requests of the other sessions alone (a restart request is coalesced, so several sessions can wait for the same restart). Use `scope: "all"` to cancel the request for EVERYONE.',
3723
5380
  '- A session whose context exceeds the model window can be handed off to a fresh session with `continuum_handoff` (it carries the goal objective, the recorded reasons and a bounded tail of the conversation); this plugin refuses to auto-resume such a session, so hand it off instead of retrying it.',
3724
5381
  '- For multi-turn objectives prefer a durable goal (`create_goal`): after a restart the plugin re-arms it and the goal round driver keeps producing rounds. No goal is required for restart continuation — without one the plugin injects a single continuation message that continues the work in a fresh turn.',
3725
5382
  ].join('\n')
@@ -4052,6 +5709,124 @@ function buildParkRoute(ctx, runtime, config) {
4052
5709
  }
4053
5710
  }
4054
5711
 
5712
+ /**
5713
+ * Build the POST `/cancel` route: withdraw ONE session's own restart request.
5714
+ *
5715
+ * It is the sibling of the `/park` route next to the health route and uses the
5716
+ * same authentication rule (loopback OR a request the connection service
5717
+ * admits). Body: `{ "sessionId": string, "scope"?: "mine" }`.
5718
+ *
5719
+ * The route NEVER cancels the request of everybody: `scope: "all"` is
5720
+ * refused, that operation belongs to the tool (where the caller says it out
5721
+ * loud). The answer reports what happened: `{ ok, cancelled, reason,
5722
+ * remainingRequesters, sessionId }`.
5723
+ *
5724
+ * @param {object} ctx
5725
+ * @param {object} runtime
5726
+ * @param {object} config
5727
+ */
5728
+ function buildCancelRoute(ctx, runtime, config) {
5729
+ const routePath = resolveCancelPath(config)
5730
+ return {
5731
+ kind: 'exact',
5732
+ path: routePath,
5733
+ handler: (req, res) => {
5734
+ const remote = req && req.socket ? req.socket.remoteAddress : undefined
5735
+ const loopback = remote === undefined
5736
+ || remote === '127.0.0.1'
5737
+ || remote === '::1'
5738
+ || remote === '::ffff:127.0.0.1'
5739
+ if (!loopback && !admittedByConnection(ctx, req)) {
5740
+ res.statusCode = 403
5741
+ res.setHeader('content-type', 'application/json; charset=utf-8')
5742
+ res.end('{"ok":false,"error":"loopback-only"}')
5743
+ return
5744
+ }
5745
+ if (!req || req.method !== 'POST') {
5746
+ res.statusCode = 405
5747
+ res.setHeader('content-type', 'application/json; charset=utf-8')
5748
+ res.setHeader('allow', 'POST')
5749
+ res.end('{"ok":false,"error":"method-not-allowed"}')
5750
+ return
5751
+ }
5752
+ void readJsonBody(req).then((payload) => {
5753
+ const body = payload && typeof payload === 'object' ? payload : {}
5754
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
5755
+ const scope = typeof body.scope === 'string' && body.scope.trim() !== '' ? body.scope.trim() : 'mine'
5756
+ const answer = (statusCode, value) => {
5757
+ res.statusCode = statusCode
5758
+ res.setHeader('content-type', 'application/json; charset=utf-8')
5759
+ res.setHeader('cache-control', 'no-store')
5760
+ res.end(JSON.stringify(value))
5761
+ }
5762
+ if (scope !== 'mine') {
5763
+ // "Cancel everybody" is a tool operation: the route says no.
5764
+ answer(400, {
5765
+ ok: false,
5766
+ cancelled: false,
5767
+ reason: 'scope-not-allowed',
5768
+ error: 'This route can only withdraw one session\'s own request (scope "mine").',
5769
+ remainingRequesters: 0,
5770
+ sessionId,
5771
+ })
5772
+ return
5773
+ }
5774
+ if (sessionId === '') {
5775
+ answer(400, {
5776
+ ok: false,
5777
+ cancelled: false,
5778
+ reason: 'session-id-required',
5779
+ error: 'The session id is required: without it the own request cannot be identified.',
5780
+ remainingRequesters: 0,
5781
+ sessionId: '',
5782
+ })
5783
+ return
5784
+ }
5785
+ const outcome = cancelOwnRestart(runtime.state, { sessionId, now: Date.now() })
5786
+ if (!outcome.removed) {
5787
+ // Nothing was changed: the caller is not a requester (or the
5788
+ // request is already gone). The reason says which.
5789
+ const error = outcome.reason === 'not-a-requester'
5790
+ ? 'This session did not request the pending restart, so nothing was cancelled.'
5791
+ : outcome.reason === 'no-restart-pending'
5792
+ ? 'There is no pending restart to cancel.'
5793
+ : 'The own request could not be identified, so nothing was cancelled.'
5794
+ runtime.log.warn(`the cancel route refused the request of session ${sessionId}: ${outcome.reason}`)
5795
+ answer(200, {
5796
+ ok: false,
5797
+ cancelled: false,
5798
+ reason: outcome.reason,
5799
+ error,
5800
+ remainingRequesters: outcome.remaining,
5801
+ sessionId,
5802
+ })
5803
+ return
5804
+ }
5805
+ // Release ONLY this session's continuation request.
5806
+ runtime.releaseRequesters([sessionId])
5807
+ if (outcome.cancelled) runtime.stopPolling()
5808
+ runtime.persist()
5809
+ runtime.log.info(
5810
+ outcome.cancelled
5811
+ ? `session ${sessionId} withdrew the last restart request through the cancel route; the pending restart is cancelled`
5812
+ : `session ${sessionId} withdrew its restart request through the cancel route; ${outcome.remaining} requester(s) remain`,
5813
+ )
5814
+ answer(200, {
5815
+ ok: true,
5816
+ cancelled: outcome.cancelled,
5817
+ reason: outcome.reason,
5818
+ remainingRequesters: outcome.remaining,
5819
+ sessionId,
5820
+ })
5821
+ }).catch((error) => {
5822
+ res.statusCode = 400
5823
+ res.setHeader('content-type', 'application/json; charset=utf-8')
5824
+ res.end(JSON.stringify({ ok: false, error: describeError(error) }))
5825
+ })
5826
+ },
5827
+ }
5828
+ }
5829
+
4055
5830
  /**
4056
5831
  * Build the POST `/recover` route: retry the post-restart recovery for the
4057
5832
  * still-pending sessions right now.
@@ -4433,6 +6208,223 @@ function buildChoiceRoute(ctx, runtime, config) {
4433
6208
  }
4434
6209
  }
4435
6210
 
6211
+ /**
6212
+ * The route of the self-starting supervisor.
6213
+ *
6214
+ * Three actions, and NOTHING happens without the confirmation:
6215
+ *
6216
+ * - `status` — who starts this host and whether the restart can work;
6217
+ * - `plan` — what the installation WOULD change (the dry run: it writes
6218
+ * nothing and runs nothing);
6219
+ * - `install` / `uninstall` — the real thing, and they need `confirmed: true`
6220
+ * (the confirmation card is what says "yes").
6221
+ *
6222
+ * @param {object} ctx the plugin context
6223
+ * @param {any} runtime the runtime of this plugin
6224
+ * @param {object} config the normalized config
6225
+ * @returns {object} the route
6226
+ */
6227
+ function buildSupervisorRoute(ctx, runtime, config) {
6228
+ const routePath = resolveSupervisorPath(config)
6229
+ return {
6230
+ kind: 'exact',
6231
+ path: routePath,
6232
+ handler: (req, res) => {
6233
+ const remote = req && req.socket ? req.socket.remoteAddress : undefined
6234
+ const loopback = remote === undefined
6235
+ || remote === '127.0.0.1'
6236
+ || remote === '::1'
6237
+ || remote === '::ffff:127.0.0.1'
6238
+ if (!loopback && !admittedByConnection(ctx, req)) {
6239
+ res.statusCode = 403
6240
+ res.setHeader('content-type', 'application/json; charset=utf-8')
6241
+ res.end('{"ok":false,"error":"loopback-only"}')
6242
+ return
6243
+ }
6244
+ if (!req || req.method !== 'POST') {
6245
+ res.statusCode = 405
6246
+ res.setHeader('content-type', 'application/json; charset=utf-8')
6247
+ res.setHeader('allow', 'POST')
6248
+ res.end('{"ok":false,"error":"method-not-allowed"}')
6249
+ return
6250
+ }
6251
+ void readJsonBody(req).then(async (payload) => {
6252
+ const body = payload && typeof payload === 'object' ? payload : {}
6253
+ const action = typeof body.action === 'string' ? body.action.trim() : ''
6254
+ if (action === 'status') {
6255
+ res.statusCode = 200
6256
+ res.setHeader('content-type', 'application/json; charset=utf-8')
6257
+ res.setHeader('cache-control', 'no-store')
6258
+ res.end(JSON.stringify({ ok: true, action, supervisor: runtime.supervisorStatus() }))
6259
+ return
6260
+ }
6261
+ if (action === 'plan' || action === 'install-plan') {
6262
+ // The installation plan: what the registration WOULD do. Nothing is
6263
+ // written and nothing is run.
6264
+ const plan = planSupervisorInstall({
6265
+ platform: runtime.env.platform,
6266
+ // The desktop app manages itself: there is no plan there.
6267
+ isDesktopApp: runtime.env.isDesktopApp === true,
6268
+ // The startup matrix: only a `bare` host may get a supervisor.
6269
+ startup: runtime.startupMode(),
6270
+ // WHICH MACHINE IS THIS? (WSL is not a Linux.)
6271
+ host: runtime.hostKind(),
6272
+ nodePath: process.execPath,
6273
+ execPath: process.execPath,
6274
+ supervisorPath: supervisorPaths(path.dirname(runtime.stateFile)).script,
6275
+ configPath: supervisorPaths(path.dirname(runtime.stateFile)).config,
6276
+ kind: config.supervisorKind,
6277
+ })
6278
+ res.statusCode = 200
6279
+ res.setHeader('content-type', 'application/json; charset=utf-8')
6280
+ res.setHeader('cache-control', 'no-store')
6281
+ res.end(JSON.stringify({
6282
+ ok: plan.error ? false : true,
6283
+ action,
6284
+ plan,
6285
+ error: plan.error ?? null,
6286
+ reason: plan.reason,
6287
+ supervisor: runtime.supervisorStatus(),
6288
+ }))
6289
+ return
6290
+ }
6291
+ if (action === 'dry-run' || action === 'derive') {
6292
+ // B: the dry run. It shows what WOULD be written and runs nothing.
6293
+ const plan = await runtime.deriveStartCommand()
6294
+ res.statusCode = 200
6295
+ res.setHeader('content-type', 'application/json; charset=utf-8')
6296
+ res.setHeader('cache-control', 'no-store')
6297
+ res.end(JSON.stringify({ ok: true, action, plan, supervisor: runtime.supervisorStatus() }))
6298
+ return
6299
+ }
6300
+ if (action === 'apply' || action === 'revert') {
6301
+ if (body.confirmed !== true) {
6302
+ // The dry run is the confirmation: without it nothing is written.
6303
+ res.statusCode = 400
6304
+ res.setHeader('content-type', 'application/json; charset=utf-8')
6305
+ res.setHeader('cache-control', 'no-store')
6306
+ res.end(JSON.stringify({
6307
+ ok: false,
6308
+ error: 'confirmation-required',
6309
+ instruction: 'Send { "action": "' + action + '", "confirmed": true } after the user confirmed the dry run.',
6310
+ }))
6311
+ return
6312
+ }
6313
+ const result = action === 'apply'
6314
+ ? await runtime.applyDerivedCommand()
6315
+ : await runtime.revertDerivedCommand()
6316
+ res.statusCode = result.ok === true ? 200 : 500
6317
+ res.setHeader('content-type', 'application/json; charset=utf-8')
6318
+ res.setHeader('cache-control', 'no-store')
6319
+ res.end(JSON.stringify({
6320
+ ok: result.ok === true,
6321
+ action,
6322
+ error: result.error ?? null,
6323
+ writes: result.writes ?? null,
6324
+ previous: result.previous ?? null,
6325
+ reason: result.reason ?? null,
6326
+ supervisor: runtime.supervisorStatus(),
6327
+ }))
6328
+ return
6329
+ }
6330
+ if (action === 'external') {
6331
+ // "I keep my own way": nothing is installed, but the answer is
6332
+ // recorded — so the plugin knows that a supervisor is already there
6333
+ // and never installs a second one.
6334
+ runtime.recordSupervisorChoice('external')
6335
+ try {
6336
+ await runtime.applyConfigChoice({ supervisorMode: 'external' })
6337
+ } catch (error) {
6338
+ runtime.log.warn('the supervisor mode could not be written:', String(error))
6339
+ }
6340
+ res.statusCode = 200
6341
+ res.setHeader('content-type', 'application/json; charset=utf-8')
6342
+ res.setHeader('cache-control', 'no-store')
6343
+ res.end(JSON.stringify({ ok: true, action, supervisor: runtime.supervisorStatus() }))
6344
+ return
6345
+ }
6346
+ if (action === 'install' || action === 'uninstall') {
6347
+ if (action === 'install' && runtime.env.isDesktopApp === true) {
6348
+ // The desktop app manages itself: a login record would open a
6349
+ // SECOND client at every login. The refusal comes BEFORE the
6350
+ // confirmation, so nothing is asked of the user.
6351
+ res.statusCode = 400
6352
+ res.setHeader('content-type', 'application/json; charset=utf-8')
6353
+ res.setHeader('cache-control', 'no-store')
6354
+ res.end(JSON.stringify({
6355
+ ok: false,
6356
+ action,
6357
+ error: 'desktop-manages-itself',
6358
+ reason: 'the desktop app relaunches itself; a supervisor would start a second copy',
6359
+ steps: [],
6360
+ supervisor: runtime.supervisorStatus(),
6361
+ }))
6362
+ return
6363
+ }
6364
+ if (body.confirmed !== true) {
6365
+ // The confirmation card is what says "yes": without it nothing is
6366
+ // changed on the machine.
6367
+ res.statusCode = 400
6368
+ res.setHeader('content-type', 'application/json; charset=utf-8')
6369
+ res.setHeader('cache-control', 'no-store')
6370
+ res.end(JSON.stringify({
6371
+ ok: false,
6372
+ error: 'confirmation-required',
6373
+ instruction: 'Send { "action": "' + action + '", "confirmed": true } after the user confirmed the card.',
6374
+ }))
6375
+ return
6376
+ }
6377
+ if (action === 'install') {
6378
+ const choice = typeof body.choice === 'string' ? body.choice : ''
6379
+ if (choice !== '') runtime.recordSupervisorChoice(choice)
6380
+ const result = runtime.installSupervisor({ confirmed: true })
6381
+ res.statusCode = result.ok ? 200 : 500
6382
+ res.setHeader('content-type', 'application/json; charset=utf-8')
6383
+ res.setHeader('cache-control', 'no-store')
6384
+ res.end(JSON.stringify({
6385
+ ok: result.ok === true,
6386
+ action,
6387
+ kind: result.kind,
6388
+ degraded: result.degraded,
6389
+ steps: result.steps,
6390
+ error: result.error,
6391
+ reason: result.reason,
6392
+ supervisor: runtime.supervisorStatus(),
6393
+ }))
6394
+ return
6395
+ }
6396
+ const result = await runtime.uninstallSupervisor()
6397
+ res.statusCode = result.ok ? 200 : 500
6398
+ res.setHeader('content-type', 'application/json; charset=utf-8')
6399
+ res.setHeader('cache-control', 'no-store')
6400
+ res.end(JSON.stringify({
6401
+ ok: result.ok === true,
6402
+ action,
6403
+ steps: result.steps,
6404
+ error: result.error,
6405
+ pending: result.pending,
6406
+ previous: result.previous,
6407
+ supervisor: runtime.supervisorStatus(),
6408
+ }))
6409
+ return
6410
+ }
6411
+ res.statusCode = 400
6412
+ res.setHeader('content-type', 'application/json; charset=utf-8')
6413
+ res.setHeader('cache-control', 'no-store')
6414
+ res.end(JSON.stringify({
6415
+ ok: false,
6416
+ error: 'unknown-action',
6417
+ instruction: 'Send { "action": "status" | "plan" | "install" | "uninstall" }.',
6418
+ }))
6419
+ }).catch((error) => {
6420
+ res.statusCode = 500
6421
+ res.setHeader('content-type', 'application/json; charset=utf-8')
6422
+ res.end(JSON.stringify({ ok: false, error: describeError(error) }))
6423
+ })
6424
+ },
6425
+ }
6426
+ }
6427
+
4436
6428
  /**
4437
6429
  * Is the graceful sleep a dry run?
4438
6430
  *
@@ -4874,7 +6866,9 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
4874
6866
  // window it stops blocking the restart.
4875
6867
  queuedGraceMs: config.queuedGraceMs,
4876
6868
  quietForMs: typeof verdict.quietForMs === 'number' ? verdict.quietForMs : 0,
4877
- busyCount: verdict.blockedBy.length,
6869
+ // The number of TASKS the restart waits for: a session and its own
6870
+ // subagents are ONE task, exactly as the left sidebar counts them.
6871
+ busyCount: countBlockerTasks(verdict.blockedBy),
4878
6872
  agentCount,
4879
6873
  })
4880
6874
  res.statusCode = 200
@@ -4958,6 +6952,9 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
4958
6952
  // the restart).
4959
6953
  parkUrl: resolveParkPath(config),
4960
6954
  recoverUrl: resolveRecoverPath(config),
6955
+ // The route that withdraws ONE session's own restart request (the
6956
+ // manual "cancel my restart" of the pill).
6957
+ cancelUrl: resolveCancelPath(config),
4961
6958
  // The route that persists the one-time install choice, plus the
4962
6959
  // value that is effective right now.
4963
6960
  choiceUrl: resolveChoicePath(config),
@@ -4966,8 +6963,67 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
4966
6963
  announcementsUrl: resolveAnnouncementsPath(config),
4967
6964
  backupOnRestart: config.backupOnRestart !== false,
4968
6965
  backupIncludePlugins: config.backupIncludePlugins !== false,
6966
+ // WHAT IS ON DISK RIGHT NOW: the backups of this host (the count,
6967
+ // the local time, the size, whether the plugins are in it and
6968
+ // where it lives). The browser cannot read the disk, so the Host
6969
+ // reads it — only the directory, never the contents.
6970
+ backups: runtime.backupsInfo(),
6971
+ // THE RESTART COMMAND that is in effect: what is written, and what
6972
+ // an undo would restore. The settings page shows it in a folded
6973
+ // "for a technical colleague" section.
6974
+ restart: {
6975
+ mode: config.restartMode,
6976
+ command: typeof config.restartCommand === 'string' ? config.restartCommand : '',
6977
+ written: typeof config.restartCommand === 'string' && config.restartCommand !== '',
6978
+ canRevert: Boolean(
6979
+ live.onboarding
6980
+ && typeof live.onboarding === 'object'
6981
+ && live.onboarding.previous
6982
+ && typeof live.onboarding.previous.restartMode === 'string',
6983
+ ),
6984
+ previous: live.onboarding && typeof live.onboarding === 'object' && live.onboarding.previous
6985
+ ? live.onboarding.previous
6986
+ : null,
6987
+ },
4969
6988
  parkedGoals: parkedGoalList(runtime),
4970
6989
  recovery: recoveryList(live),
6990
+ // THE ACCESS LINK of this instance (with the boot token). The boot
6991
+ // token is created per process, so the link the browser holds
6992
+ // answers 401 after a restart. The client needs the new link to
6993
+ // get the user back into the interface.
6994
+ accessUrl: accessUrlOf(runtime),
6995
+ // The supervisor: who starts this host, and whether the restart
6996
+ // can work at all.
6997
+ supervisor: runtime.supervisorStatus(),
6998
+ // WHICH END this is. The browser side serves the desktop app AND
6999
+ // the hosted web host, so it may not assume one of them: the
7000
+ // desktop app brings itself back up (there is nothing to install
7001
+ // there), while the hosted host may ask for a supervisor.
7002
+ instance: {
7003
+ isDesktopApp: runtime.env.isDesktopApp === true,
7004
+ platform: runtime.env.platform,
7005
+ },
7006
+ // THE LAST RESTART: the snapshot that was taken before it (the
7007
+ // directory, its LOCAL time, how long it took, which older
7008
+ // snapshots were pruned) and the verdict of the instance that came
7009
+ // up (the four checks). Without it a restart has no visible
7010
+ // result at all.
7011
+ lastRestart: runtime.lastRestartVerdict(),
7012
+ // Whether the one-time question about the supervisor may be asked.
7013
+ supervisorOffer: runtime.supervisorOffer,
7014
+ // The answer of the one-time question about the supervisor (null
7015
+ // while it was never answered).
7016
+ onboarding: runtime.onboardingChoice,
7017
+ // The route that installs, uninstalls and reports the supervisor.
7018
+ supervisorUrl: resolveSupervisorPath(config),
7019
+ // "REPORT A PROBLEM": the diagnostic text (built by ONE generator on
7020
+ // the host, so the browser and the host can never drift) and the
7021
+ // two places a report can go.
7022
+ diagnostics: runtime.diagnostics(),
7023
+ report: {
7024
+ email: SUPPORT_EMAIL,
7025
+ issueUrlBase: ISSUE_URL_BASE,
7026
+ },
4971
7027
  })
4972
7028
  } catch {
4973
7029
  // The banner must never break the health route: fall back to "no restart".
@@ -4983,6 +7039,7 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
4983
7039
  longWait: null,
4984
7040
  parkUrl: resolveParkPath(config),
4985
7041
  recoverUrl: resolveRecoverPath(config),
7042
+ cancelUrl: resolveCancelPath(config),
4986
7043
  choiceUrl: resolveChoicePath(config),
4987
7044
  announcementsUrl: resolveAnnouncementsPath(config),
4988
7045
  backupOnRestart: config.backupOnRestart !== false,
@@ -5044,49 +7101,44 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
5044
7101
  const recoverRoute = buildRecoverRoute(ctx, runtime, config)
5045
7102
  const handoffRoute = buildHandoffRoute(ctx, runtime, config)
5046
7103
  const choiceRoute = buildChoiceRoute(ctx, runtime, config)
7104
+ const cancelRoute = buildCancelRoute(ctx, runtime, config)
5047
7105
  const announcementsRoute = buildAnnouncementsRoute(ctx, runtime, config)
7106
+ const supervisorRoute = buildSupervisorRoute(ctx, runtime, config)
5048
7107
  const sleepRoute = sleep ? buildSleepRoute(ctx, runtime, config, sleep) : null
5049
7108
  try {
5050
7109
  const origin = typeof webServer.getOrigin === 'function' ? String(webServer.getOrigin()) : ''
5051
7110
  const portMatch = /:(\d+)\/?$/.exec(origin)
5052
7111
  if (portMatch) runtime.webPort = Number(portMatch[1])
7112
+ // THE INSTANCE RECORD. The new instance publishes its own port and its
7113
+ // own access link (with the boot token), so:
7114
+ // - the rollback watcher learns the port of the NEW instance instead
7115
+ // of guessing the (random) port of the old one;
7116
+ // - the user finds the access link of the new instance after a
7117
+ // restart, instead of being locked out by a 401.
7118
+ publishInstanceRecord(ctx, runtime, config, origin)
5053
7119
  } catch {}
5054
- try {
5055
- target.effect(() => webServer.register(route), 'dsh-autorestart: health route')
5056
- } catch (error) {
5057
- // A duplicate registration means another load of this plugin already owns it.
5058
- runtime.log.warn('could not register the health route:', String(error))
5059
- }
5060
- try {
5061
- target.effect(() => webServer.register(parkRoute), 'dsh-autorestart: park route')
5062
- } catch (error) {
5063
- runtime.log.warn('could not register the park route:', String(error))
5064
- }
5065
- try {
5066
- target.effect(() => webServer.register(recoverRoute), 'dsh-autorestart: recover route')
5067
- } catch (error) {
5068
- runtime.log.warn('could not register the recover route:', String(error))
5069
- }
5070
- try {
5071
- target.effect(() => webServer.register(handoffRoute), 'dsh-autorestart: handoff route')
5072
- } catch (error) {
5073
- runtime.log.warn('could not register the handoff route:', String(error))
5074
- }
5075
- try {
5076
- target.effect(() => webServer.register(choiceRoute), 'dsh-autorestart: choice route')
5077
- } catch (error) {
5078
- runtime.log.warn('could not register the choice route:', String(error))
5079
- }
5080
- try {
5081
- target.effect(() => webServer.register(announcementsRoute), 'dsh-autorestart: announcements route')
5082
- } catch (error) {
5083
- runtime.log.warn('could not register the announcements route:', String(error))
5084
- }
5085
- if (sleepRoute !== null) {
7120
+ // The routes of this plugin. Every one of them is remembered: the boot
7121
+ // check of the restart record reports "the routes are there" — a host
7122
+ // that came up without them is not a healthy host.
7123
+ const routes = [
7124
+ { name: 'health', route },
7125
+ { name: 'park', route: parkRoute },
7126
+ { name: 'recover', route: recoverRoute },
7127
+ { name: 'handoff', route: handoffRoute },
7128
+ { name: 'choice', route: choiceRoute },
7129
+ { name: 'cancel', route: cancelRoute },
7130
+ { name: 'announcements', route: announcementsRoute },
7131
+ { name: 'supervisor', route: supervisorRoute },
7132
+ ]
7133
+ if (sleepRoute !== null) routes.push({ name: 'sleep', route: sleepRoute })
7134
+ for (const entry of routes) {
5086
7135
  try {
5087
- target.effect(() => webServer.register(sleepRoute), 'dsh-autorestart: sleep route')
7136
+ target.effect(() => webServer.register(entry.route), 'dsh-autorestart: ' + entry.name + ' route')
7137
+ runtime.registeredRoutes.push(entry.name)
5088
7138
  } catch (error) {
5089
- runtime.log.warn('could not register the sleep route:', String(error))
7139
+ // A duplicate registration means another load of this plugin already owns it.
7140
+ runtime.log.warn('could not register the ' + entry.name + ' route:', String(error))
7141
+ runtime.routeErrors.push({ route: entry.name, error: String(error) })
5090
7142
  }
5091
7143
  }
5092
7144
  if (config.autoReloadPage !== false && typeof webServer.tapIndex === 'function') {
@@ -5311,15 +7363,35 @@ export function apply(ctx, rawConfig, options = {}) {
5311
7363
  if (runtime.isNewBoot && config.resumeOnBoot) {
5312
7364
  runtime.recoverTimer = ctx.timeout(() => {
5313
7365
  recoverAfterRestart(ctx, runtime, config)
5314
- .then(() => { restoreSleepIntent() })
7366
+ .then(() => {
7367
+ runtime.recovery = { ran: true, ok: true, detail: 'the boot recovery finished' }
7368
+ restoreSleepIntent()
7369
+ })
5315
7370
  .catch((error) => {
5316
7371
  log.warn('boot recovery failed:', String(error))
7372
+ runtime.recovery = { ran: true, ok: false, detail: 'the boot recovery failed: ' + String(error) }
5317
7373
  restoreSleepIntent()
5318
7374
  })
7375
+ // THE VERDICT OF THE RESTART. The instance that replaced the old one
7376
+ // checks itself (listener / routes / plugin / recovery) and writes the
7377
+ // result into the durable restart record. Without this the four
7378
+ // checks would live nowhere and a failed restart would look like a
7379
+ // successful one.
7380
+ .then(() => verifyRestartBoot(runtime))
7381
+ .catch((error) => {
7382
+ log.warn('the boot verification failed:', String(error))
7383
+ })
5319
7384
  }, Math.max(0, config.recoveryDelayMs))
5320
7385
  } else {
5321
7386
  // No recovery pass: the intent is restored right away (a hot reload must
5322
7387
  // not lose it either).
7388
+ runtime.recovery = {
7389
+ ran: false,
7390
+ ok: true,
7391
+ detail: runtime.isNewBoot
7392
+ ? 'the boot recovery is switched off'
7393
+ : 'this is not a new boot',
7394
+ }
5323
7395
  restoreSleepIntent()
5324
7396
  }
5325
7397
 
@@ -5349,4 +7421,5 @@ export {
5349
7421
  pluginVersion,
5350
7422
  PLUGIN_VERSION_FALLBACK,
5351
7423
  SLEEP_FEATURE_VERSION,
7424
+ SUPERVISOR_FEATURE_VERSION,
5352
7425
  }