dsh-autorestart 0.3.5 → 0.3.6

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
@@ -173,6 +173,9 @@ export const inject = ['agents', 'goals', 'tools', 'timer', 'systemPrompt', 'ses
173
173
  * the real schema and project defaults; `normalizeConfig` still re-applies the same
174
174
  * defaults for direct `apply()` calls in tests.
175
175
  *
176
+ * Every key carries a one-sentence description, so the settings page explains what
177
+ * each knob does instead of showing a bare name.
178
+ *
176
179
  * Construction is guarded: a wrong/foreign schemastery fallback must never turn a
177
180
  * missing schema into a failed plugin activation, and the coarsened `schemasterySource`
178
181
  * is reported by the health route instead of an absolute install path.
@@ -181,38 +184,63 @@ function buildConfigSchema() {
181
184
  if (schemastery === undefined) return undefined
182
185
  try {
183
186
  return schemastery.object({
184
- enabled: schemastery.boolean().default(true),
185
- stateFile: schemastery.string().default(''),
186
- resumeOnBoot: schemastery.boolean().default(true),
187
- recoveryDelayMs: schemastery.number().min(0).default(6000),
188
- maxSessions: schemastery.number().step(1).min(1).default(4),
189
- maxResumeAttempts: schemastery.number().step(1).min(1).default(2),
190
- deferPollMs: schemastery.number().min(250).default(3000),
191
- deferSoftMs: schemastery.number().min(0).default(300000),
192
- deferHardMs: schemastery.number().min(0).default(900000),
193
- deferPolicy: schemastery.union(['auto', 'wait']).default('auto'),
187
+ enabled: schemastery.boolean().default(true)
188
+ .description('Master switch: when false the plugin loads but does nothing.'),
189
+ stateFile: schemastery.string().default('')
190
+ .description('Absolute path of the durable checkpoint file; empty means <DSH_HOME>/storages/dsh-autorestart/state.json.'),
191
+ resumeOnBoot: schemastery.boolean().default(true)
192
+ .description('Resume interrupted sessions automatically after the host starts again.'),
193
+ recoveryDelayMs: schemastery.number().min(0).default(6000)
194
+ .description('Milliseconds the plugin waits after boot before it recovers interrupted sessions.'),
195
+ maxSessions: schemastery.number().step(1).min(1).default(4)
196
+ .description('How many interrupted sessions one recovery pass resumes.'),
197
+ maxResumeAttempts: schemastery.number().step(1).min(1).default(2)
198
+ .description('How often a single session is retried before the recovery gives up on it.'),
199
+ deferPollMs: schemastery.number().min(250).default(3000)
200
+ .description('Milliseconds between two restart-gate checks.'),
201
+ deferSoftMs: schemastery.number().min(0).default(300000)
202
+ .description('Milliseconds after which a pending restart is announced as waiting for a long time.'),
203
+ deferHardMs: schemastery.number().min(0).default(900000)
204
+ .description('Milliseconds after which a pending restart gives up, unless forceRestartWhenBusy is enabled.'),
205
+ deferPolicy: schemastery.union(['auto', 'wait']).default('auto')
206
+ .description('How a pending restart treats a busy conversation: auto waits for it, wait blocks until every conversation is idle.'),
194
207
  // hard deadline only forces a restart over a running conversation when enabled
195
- forceRestartWhenBusy: schemastery.boolean().default(false),
196
- restartMode: schemastery.union(['app', 'command']).default('app'),
197
- restartCommand: schemastery.string().default(''),
198
- relaunchDelaySeconds: schemastery.number().min(0).default(2),
208
+ forceRestartWhenBusy: schemastery.boolean().default(false)
209
+ .description('Let the hard deadline restart the host even while a conversation is still running.'),
210
+ restartMode: schemastery.union(['app', 'command']).default('app')
211
+ .description('How the host is restarted: app relaunches the desktop app, command runs restartCommand.'),
212
+ restartCommand: schemastery.string().default('')
213
+ .description('Shell command used when restartMode is "command"; empty means there is no custom command.'),
214
+ relaunchDelaySeconds: schemastery.number().min(0).default(2)
215
+ .description('Seconds between the kill of the old host and the relaunch of the new one.'),
199
216
  // quiet window that must pass after the last conversation activity
200
- settleMs: schemastery.number().min(0).default(2000),
201
- autoReloadPage: schemastery.boolean().default(true),
202
- blockManualRestart: schemastery.boolean().default(true),
203
- healthPath: schemastery.string().default('/__dsh_autorestart/health'),
204
- continuationText: schemastery.string().default(''),
217
+ settleMs: schemastery.number().min(0).default(2000)
218
+ .description('Milliseconds the host must stay quiet after the last conversation activity before a restart may fire.'),
219
+ autoReloadPage: schemastery.boolean().default(true)
220
+ .description('Reload the web page automatically when the host boot token changes.'),
221
+ blockManualRestart: schemastery.boolean().default(true)
222
+ .description('Block shell commands that would kill or relaunch DeepSeek Harness by hand.'),
223
+ healthPath: schemastery.string().default('/__dsh_autorestart/health')
224
+ .description('HTTP path of the read-only health route used by the client UI and by verification scripts.'),
225
+ continuationText: schemastery.string().default('')
226
+ .description('Custom text injected as the continuation message; empty means the built-in notice.'),
205
227
  // pause between two recovery batches (each batch resumes maxSessions sessions)
206
- recoveryBatchDelayMs: schemastery.number().min(0).default(1200),
228
+ recoveryBatchDelayMs: schemastery.number().min(0).default(1200)
229
+ .description('Milliseconds between two recovery batches.'),
207
230
  // pause between two resolveAgent attempts inside one boot
208
- resumeRetryDelayMs: schemastery.number().min(0).default(1000),
231
+ resumeRetryDelayMs: schemastery.number().min(0).default(1000)
232
+ .description('Milliseconds between two resolveAgent attempts inside one boot.'),
209
233
  // pre-restart snapshot + automatic rollback when the host never comes back
210
- backupOnRestart: schemastery.boolean().default(true),
234
+ backupOnRestart: schemastery.boolean().default(true)
235
+ .description('Take a snapshot of the profile before every restart, so a host that cannot boot again is rolled back automatically.'),
211
236
  // include the installed plugin tree in the snapshot (bigger, but it makes a
212
237
  // rollback of "a plugin upgrade broke the boot" possible)
213
- backupIncludePlugins: schemastery.boolean().default(true),
214
- maxBackups: schemastery.number().step(1).min(1).default(2),
215
- rollbackTimeoutMs: schemastery.number().min(1000).default(90000),
238
+ backupIncludePlugins: schemastery.boolean().default(true)
239
+ .description('Include the installed plugin tree in that snapshot: about 117 MB and roughly 3 extra seconds, but it is what makes a rollback of a broken plugin upgrade possible.'),
240
+ maxBackups: schemastery.number().step(1).min(1).default(2)
241
+ .description('How many snapshots are kept; older ones are deleted automatically.'),
242
+ rollbackTimeoutMs: schemastery.number().min(1000).default(90000)
243
+ .description('Milliseconds the detached watcher waits for the relaunched host before it restores the snapshot.'),
216
244
  })
217
245
  } catch (error) {
218
246
  schemasterySource = `construction-failed: ${String(error)}`
@@ -328,6 +356,20 @@ function resolveHandoffPath(config) {
328
356
  return `${prefix}/handoff`
329
357
  }
330
358
 
359
+ /**
360
+ * Resolve the choice-route path: a sibling of the health route, under the same
361
+ * prefix (`POST /choice` persists the one-time install choice of the user).
362
+ *
363
+ * @param {object} config
364
+ * @returns {string}
365
+ */
366
+ function resolveChoicePath(config) {
367
+ const healthPath = resolveHealthPath(config)
368
+ const index = healthPath.lastIndexOf('/')
369
+ const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
370
+ return `${prefix}/choice`
371
+ }
372
+
331
373
  /**
332
374
  * The blocking sessions with the reason each one blocks: a session that is
333
375
  * held by an armed goal reports `goal-active` (that goal can be disarmed), a
@@ -484,6 +526,86 @@ function resolveStateFile(config) {
484
526
  return path.join(home, 'storages', 'dsh-autorestart', 'state.json')
485
527
  }
486
528
 
529
+ /**
530
+ * The directory the plugin owns its durable state in.
531
+ *
532
+ * It is the directory of the checkpoint file, so a custom `stateFile` moves the
533
+ * whole state directory with it (the backups live next to it as well).
534
+ *
535
+ * @param {object} config
536
+ * @returns {string}
537
+ */
538
+ function resolveStateDir(config) {
539
+ return path.dirname(resolveStateFile(config))
540
+ }
541
+
542
+ /**
543
+ * The file the DEGRADED choice path writes to.
544
+ *
545
+ * It is only used when the official `configEditor` service is unavailable: the
546
+ * profile configuration stays the single source of truth, and this file is the
547
+ * last-resort record of a choice the Host could not persist yet.
548
+ */
549
+ const INSTALL_CHOICE_FILE = 'install-choice.json'
550
+
551
+ /**
552
+ * Read the recorded install choice from the fallback file.
553
+ *
554
+ * @param {string} stateDir
555
+ * @returns {boolean|null} the recorded value, or null when there is none
556
+ */
557
+ function readInstallChoice(stateDir) {
558
+ try {
559
+ const raw = fs.readFileSync(path.join(stateDir, INSTALL_CHOICE_FILE), 'utf8')
560
+ const parsed = JSON.parse(raw)
561
+ if (!parsed || typeof parsed !== 'object') return null
562
+ return typeof parsed.includePlugins === 'boolean' ? parsed.includePlugins : null
563
+ } catch {
564
+ return null
565
+ }
566
+ }
567
+
568
+ /**
569
+ * Write the install choice into the fallback file.
570
+ *
571
+ * @param {string} stateDir
572
+ * @param {boolean} includePlugins
573
+ * @returns {string} the file that was written
574
+ */
575
+ function writeInstallChoice(stateDir, includePlugins) {
576
+ const file = path.join(stateDir, INSTALL_CHOICE_FILE)
577
+ fs.mkdirSync(stateDir, { recursive: true })
578
+ fs.writeFileSync(
579
+ file,
580
+ JSON.stringify({ includePlugins: includePlugins === true, at: new Date().toISOString() }, null, 2),
581
+ 'utf8',
582
+ )
583
+ return file
584
+ }
585
+
586
+ /**
587
+ * Resolve the effective `backupIncludePlugins` value and where it came from.
588
+ *
589
+ * The profile row config always wins: it is the single source of truth (the
590
+ * settings page and the one-time install prompt write the same key). Only when
591
+ * the row config does not set the key at all does the degraded fallback file
592
+ * get a say, and only when it does not exist does the built-in default apply.
593
+ *
594
+ * @param {object|null|undefined} rawConfig the loader row config, as received
595
+ * @param {string} stateDir
596
+ * @returns {{ includePlugins: boolean, source: 'config'|'fallback-file'|'default' }}
597
+ */
598
+ export function resolveBackupIncludePlugins(rawConfig, stateDir) {
599
+ if (rawConfig && typeof rawConfig === 'object'
600
+ && Object.prototype.hasOwnProperty.call(rawConfig, 'backupIncludePlugins')
601
+ && typeof rawConfig.backupIncludePlugins === 'boolean') {
602
+ return { includePlugins: rawConfig.backupIncludePlugins, source: 'config' }
603
+ }
604
+ const fallback = typeof stateDir === 'string' && stateDir !== '' ? readInstallChoice(stateDir) : null
605
+ if (fallback !== null) return { includePlugins: fallback, source: 'fallback-file' }
606
+ return { includePlugins: DEFAULTS.backupIncludePlugins, source: 'default' }
607
+ }
608
+
487
609
  /**
488
610
  * Resolve the profile directory the running host was started from.
489
611
  *
@@ -553,6 +675,76 @@ function resolveProfileDir() {
553
675
  return resolveProfileDirDetailed().dir
554
676
  }
555
677
 
678
+ /**
679
+ * The pre-restart snapshot: copy the profile into `<stateDir>/backups/<stamp>/`
680
+ * and clear the rollback report an older boot may have left behind.
681
+ *
682
+ * This is the ONE backup entry of the plugin. EVERY restart mode takes its
683
+ * snapshot through this function — the desktop app relaunch AND the custom
684
+ * restart command — so a hosted host that is restarted by a command is just as
685
+ * recoverable as the desktop app. The configuration files decide whether every
686
+ * bundle loads, so a snapshot is what makes a "the host does not come back"
687
+ * rollback possible at all.
688
+ *
689
+ * A failing backup never blocks the restart: the reason is logged and the
690
+ * caller continues without a rollback point.
691
+ *
692
+ * @param {{
693
+ * config?: object,
694
+ * stateDir?: string,
695
+ * log?: { warn: Function },
696
+ * profile?: { dir?: string|null, source?: string },
697
+ * createBackup?: Function,
698
+ * }} [options]
699
+ * @returns {{ backup: object|null, profileDir: string|null, profile: { dir: string|null, source: string } }}
700
+ */
701
+ export function takePreRestartSnapshot({
702
+ config = {},
703
+ stateDir = '',
704
+ log = console,
705
+ profile = null,
706
+ createBackup: createBackupFn = createBackup,
707
+ } = {}) {
708
+ const info = profile && typeof profile === 'object'
709
+ ? { dir: profile.dir ?? null, source: profile.source ?? 'unknown' }
710
+ : { dir: null, source: 'unknown' }
711
+ const profileDir = typeof info.dir === 'string' && info.dir.trim() !== '' ? info.dir : null
712
+ // The snapshot is optional: the operator can turn it off entirely.
713
+ if (config.backupOnRestart !== true) return { backup: null, profileDir, profile: info }
714
+ if (profileDir === null) {
715
+ log.warn('no profile directory is known; skipping the pre-restart backup', { source: info.source })
716
+ return { backup: null, profileDir: null, profile: info }
717
+ }
718
+ let backup = null
719
+ try {
720
+ backup = createBackupFn({
721
+ profileDir,
722
+ backupsDir: backupsRoot(stateDir),
723
+ includePlugins: config.backupIncludePlugins !== false,
724
+ maxBackups: config.maxBackups,
725
+ })
726
+ } catch (error) {
727
+ // A backup that throws must not take the restart down with it.
728
+ backup = { ok: false, error: String(error) }
729
+ }
730
+ if (backup && backup.ok === true) {
731
+ log.warn('pre-restart backup written', {
732
+ dir: backup.dir,
733
+ bytes: backup.bytes,
734
+ includePlugins: backup.includePlugins,
735
+ pruned: backup.pruned,
736
+ profileDirSource: info.source,
737
+ })
738
+ // A report from an older boot must not be mistaken for this one.
739
+ clearRollbackReport(stateDir)
740
+ } else {
741
+ log.warn('the pre-restart backup failed; continuing without a rollback point', {
742
+ error: backup && backup.error ? backup.error : 'unknown error',
743
+ })
744
+ }
745
+ return { backup, profileDir, profile: info }
746
+ }
747
+
556
748
  /** A logger that never throws and never requires a particular Cordis logger shape. */
557
749
  function createLogger(ctx) {
558
750
  const logger = ctx.logger
@@ -596,10 +788,91 @@ function isRootAgent(ctx, agent) {
596
788
  }
597
789
  }
598
790
 
599
- /** The plugin's whole runtime state. */
600
- function createRuntime(ctx, config) {
791
+ /**
792
+ * Resolve the official `configEditor` service, or null when it is unavailable.
793
+ *
794
+ * The service is optional: the plugin must load in a composition that does not
795
+ * provide it, and the one-time install choice then falls back to a file.
796
+ *
797
+ * @param {object} ctx
798
+ * @returns {any|null}
799
+ */
800
+ function resolveConfigEditor(ctx) {
801
+ try {
802
+ if (ctx && ctx.configEditor && typeof ctx.configEditor.edit === 'function') return ctx.configEditor
803
+ } catch {}
804
+ try {
805
+ if (ctx && typeof ctx.get === 'function') {
806
+ const service = ctx.get('configEditor')
807
+ if (service && typeof service.edit === 'function') return service
808
+ }
809
+ } catch {}
810
+ return null
811
+ }
812
+
813
+ /**
814
+ * Find this plugin's own row among the profile entries.
815
+ *
816
+ * The service hands out the LOADER's own entries, so the row id lives in
817
+ * `entry.options.id` (the id the bundle layer inserts: `dsh-autorestart`).
818
+ * A projected entry (the shape the settings UI shows) carries the same id as
819
+ * `patchId` or `id`, so both are accepted as well.
820
+ *
821
+ * @param {any} editor the configEditor service
822
+ * @returns {any|null} the entry, or null when this plugin has no row
823
+ */
824
+ function findOwnEntry(editor) {
825
+ let entries = []
826
+ try {
827
+ entries = typeof editor.entries === 'function' ? editor.entries() : []
828
+ } catch {
829
+ return null
830
+ }
831
+ if (!Array.isArray(entries)) return null
832
+ for (const entry of entries) {
833
+ if (!entry || typeof entry !== 'object') continue
834
+ const options = entry.options && typeof entry.options === 'object' ? entry.options : null
835
+ const candidates = [
836
+ options ? options.id : undefined,
837
+ entry.patchId,
838
+ entry.id,
839
+ ]
840
+ for (const candidate of candidates) {
841
+ if (candidate === undefined || candidate === null) continue
842
+ if (String(candidate) === name) return entry
843
+ }
844
+ }
845
+ return null
846
+ }
847
+
848
+ /**
849
+ * The plugin's whole runtime state.
850
+ *
851
+ * @param {object} ctx
852
+ * @param {object} config the normalized config
853
+ * @param {{
854
+ * backupChoiceSource?: string,
855
+ * env?: object,
856
+ * snapshot?: Function,
857
+ * runCommand?: Function,
858
+ * scheduleDetached?: Function,
859
+ * }} [options] `env` replaces parts of the detected environment and the three
860
+ * functions replace the side-effecting primitives of the restart path. They
861
+ * exist so a restart can be exercised without spawning a process or touching
862
+ * the real profile; production never passes them.
863
+ */
864
+ export function createRuntime(ctx, config, options = {}) {
601
865
  const log = createLogger(ctx)
602
- const env = detectEnvironment()
866
+ const env = {
867
+ ...detectEnvironment(),
868
+ ...(options.env && typeof options.env === 'object' ? options.env : {}),
869
+ }
870
+ // The ONE pre-restart snapshot entry: every restart mode uses it.
871
+ const snapshotFn = typeof options.snapshot === 'function' ? options.snapshot : takePreRestartSnapshot
872
+ const runCommandFn = typeof options.runCommand === 'function' ? options.runCommand : runCommandNow
873
+ const scheduleDetachedFn = typeof options.scheduleDetached === 'function'
874
+ ? options.scheduleDetached
875
+ : scheduleDetachedRestart
603
876
  const stateFile = resolveStateFile(config)
604
877
 
605
878
  const loaded = readState(stateFile, Date.now())
@@ -1246,7 +1519,32 @@ function createRuntime(ctx, config) {
1246
1519
  persist()
1247
1520
  log.warn('executing graceful restart', { generation: state.generation, pid: process.pid })
1248
1521
  try {
1249
- if (config.restartMode === 'command' && config.restartCommand.trim() !== '') {
1522
+ // How the host is restarted: the custom command, or the desktop app.
1523
+ const useCustomCommand = config.restartMode === 'command' && config.restartCommand.trim() !== ''
1524
+ const canRelaunchDesktop = Boolean(env.isDesktopApp && env.appExePath)
1525
+ if (!useCustomCommand && !canRelaunchDesktop) {
1526
+ // No restart method at all (the host is not the desktop app and there is
1527
+ // no custom command): nothing is restarted, so nothing is snapshotted
1528
+ // either — there is no rollback point for a restart that never happens.
1529
+ log.warn('no restart method available (not the desktop app and restartCommand is empty); leaving the checkpoint in place')
1530
+ rollbackRestart('no restart method available')
1531
+ return
1532
+ }
1533
+
1534
+ // ONE snapshot for EVERY restart mode: the profile is copied BEFORE
1535
+ // anything is killed, no matter how the host is restarted. The desktop
1536
+ // app relaunch and the custom restart command share this single entry, so
1537
+ // a hosted host that is restarted by a command is just as recoverable as
1538
+ // the desktop app.
1539
+ const stateDir = path.dirname(stateFile)
1540
+ const snapshot = snapshotFn({
1541
+ config,
1542
+ stateDir,
1543
+ log,
1544
+ profile: resolveProfileDirDetailed(),
1545
+ })
1546
+
1547
+ if (useCustomCommand) {
1250
1548
  const command = buildCustomCommand({
1251
1549
  template: config.restartCommand,
1252
1550
  pid: process.pid,
@@ -1254,9 +1552,10 @@ function createRuntime(ctx, config) {
1254
1552
  appExePath: env.appExePath || '',
1255
1553
  })
1256
1554
  log.warn('running custom restart command:', command)
1257
- const outcome = runCommandNow({ command })
1555
+ // A failed snapshot never blocks the restart: the command runs anyway.
1556
+ const outcome = runCommandFn({ command })
1258
1557
  if (outcome && outcome.ok === false) rollbackRestart(outcome.error || 'custom command failed')
1259
- } else if (env.isDesktopApp && env.appExePath) {
1558
+ } else {
1260
1559
  const aborts = Number(state.restart.gateAborts) || 0
1261
1560
  const url = aborts > 20 ? null : restartGateUrl()
1262
1561
  if (aborts > 20) {
@@ -1264,37 +1563,9 @@ function createRuntime(ctx, config) {
1264
1563
  } else if (url === null) {
1265
1564
  log.warn('the web server port is unknown; scheduling an UNGATED restart (the helper cannot re-check the idle gate)')
1266
1565
  }
1267
- // Snapshot the profile BEFORE anything is killed: the configuration files
1268
- // decide whether every bundle loads, so a snapshot is what makes a
1269
- // "the host does not come back" rollback possible at all.
1270
- const stateDir = path.dirname(stateFile)
1271
- const profileInfo = resolveProfileDirDetailed()
1272
- const profileDir = profileInfo.dir
1273
- let backup = null
1274
- if (config.backupOnRestart === true && profileDir !== null) {
1275
- backup = createBackup({
1276
- profileDir,
1277
- backupsDir: backupsRoot(stateDir),
1278
- includePlugins: config.backupIncludePlugins !== false,
1279
- maxBackups: config.maxBackups,
1280
- })
1281
- if (backup.ok === true) {
1282
- log.warn('pre-restart backup written', {
1283
- dir: backup.dir,
1284
- bytes: backup.bytes,
1285
- includePlugins: backup.includePlugins,
1286
- pruned: backup.pruned,
1287
- profileDirSource: profileInfo.source,
1288
- })
1289
- // A report from an older boot must not be mistaken for this one.
1290
- clearRollbackReport(stateDir)
1291
- } else {
1292
- log.warn('the pre-restart backup failed; continuing without a rollback point', { error: backup.error })
1293
- }
1294
- } else if (config.backupOnRestart === true) {
1295
- log.warn('no profile directory is known; skipping the pre-restart backup', { source: profileInfo.source })
1296
- }
1297
-
1566
+ // The snapshot was already taken above (the shared entry).
1567
+ const backup = snapshot && snapshot.backup ? snapshot.backup : null
1568
+ const profileDir = snapshot && snapshot.profileDir ? snapshot.profileDir : null
1298
1569
  const healthUrl = restartHealthUrl()
1299
1570
  const watchScriptPath = backup && backup.ok === true && healthUrl !== null && profileDir !== null
1300
1571
  ? path.join(stateDir, 'restart-' + state.generation + '-watch.ps1')
@@ -1311,7 +1582,7 @@ function createRuntime(ctx, config) {
1311
1582
  // Only a crash log written after this point belongs to this restart.
1312
1583
  since: new Date().toISOString(),
1313
1584
  })
1314
- const result = scheduleDetachedRestart({
1585
+ const result = scheduleDetachedFn({
1315
1586
  appExePath: env.appExePath,
1316
1587
  relaunchDelaySeconds: config.relaunchDelaySeconds,
1317
1588
  gateUrl: url,
@@ -1322,9 +1593,6 @@ function createRuntime(ctx, config) {
1322
1593
  log.warn('scheduled detached restart:', result)
1323
1594
  if (!result || result.ok !== true) rollbackRestart((result && result.error) || 'detached restart refused')
1324
1595
  else armGateWatchdog()
1325
- } else {
1326
- log.warn('no restart method available (not the desktop app and restartCommand is empty); leaving the checkpoint in place')
1327
- rollbackRestart('no restart method available')
1328
1596
  }
1329
1597
  } catch (error) {
1330
1598
  rollbackRestart(String(error))
@@ -1396,6 +1664,14 @@ function createRuntime(ctx, config) {
1396
1664
  get bootId() {
1397
1665
  return bootId
1398
1666
  },
1667
+ /**
1668
+ * Where the effective `backupIncludePlugins` value came from:
1669
+ * `config` (the profile row), `fallback-file` (the degraded path) or
1670
+ * `default` (nothing was ever chosen).
1671
+ */
1672
+ get backupChoiceSource() {
1673
+ return options.backupChoiceSource || 'default'
1674
+ },
1399
1675
  get isNewBoot() {
1400
1676
  return boot.isNewBoot
1401
1677
  },
@@ -1430,6 +1706,53 @@ function createRuntime(ctx, config) {
1430
1706
  stateFile,
1431
1707
  log,
1432
1708
  persist,
1709
+ /**
1710
+ * Persist the user's one-time install choice: whether the pre-restart
1711
+ * backup also snapshots the installed plugin tree.
1712
+ *
1713
+ * The official `configEditor` service is the primary path: the choice goes
1714
+ * into the profile row of this plugin, so the settings page and this prompt
1715
+ * read and write the same key (one source of truth). When the service is
1716
+ * unavailable the choice degrades to a file in the plugin's state directory
1717
+ * and a warning is logged, so the choice is never silently lost.
1718
+ *
1719
+ * @param {boolean} includePlugins
1720
+ * @returns {Promise<{ applied: 'config'|'fallback-file', includePlugins: boolean }>}
1721
+ */
1722
+ async applyBackupChoice(includePlugins) {
1723
+ const value = includePlugins === true
1724
+ const editor = resolveConfigEditor(ctx)
1725
+ if (editor !== null) {
1726
+ try {
1727
+ const entry = findOwnEntry(editor)
1728
+ if (entry !== null) {
1729
+ await editor.edit(entry, (current) => {
1730
+ const base = current && typeof current === 'object' ? { ...current } : {}
1731
+ return { ...base, backupIncludePlugins: value }
1732
+ })
1733
+ // The Loader reconciles the row, but the running instance must not
1734
+ // keep the old value until that happens.
1735
+ config.backupIncludePlugins = value
1736
+ log.info(`the install choice was written into the profile configuration: backupIncludePlugins=${value}`)
1737
+ return { applied: 'config', includePlugins: value }
1738
+ }
1739
+ log.warn(`the configEditor service has no profile row for ${name}; the install choice degrades to ${INSTALL_CHOICE_FILE}`)
1740
+ } catch (error) {
1741
+ log.warn(`could not write the install choice into the profile configuration: ${describeError(error)}`)
1742
+ }
1743
+ } else {
1744
+ log.warn(`the configEditor service is unavailable; the install choice degrades to ${INSTALL_CHOICE_FILE}`)
1745
+ }
1746
+ const file = path.join(path.dirname(stateFile), INSTALL_CHOICE_FILE)
1747
+ try {
1748
+ writeInstallChoice(path.dirname(stateFile), value)
1749
+ } catch (error) {
1750
+ throw new Error(`could not write the install choice to ${file}: ${describeError(error)}`)
1751
+ }
1752
+ config.backupIncludePlugins = value
1753
+ log.warn(`the install choice was written to ${file} instead of the profile configuration`)
1754
+ return { applied: 'fallback-file', includePlugins: value }
1755
+ },
1433
1756
  toolErrors,
1434
1757
  startPolling,
1435
1758
  stopPolling,
@@ -2145,7 +2468,8 @@ function registerTools(ctx, runtime, config) {
2145
2468
  + '("goal-active" = an armed goal keeps it busy, "goal-exhausted" = its goal can no longer be '
2146
2469
  + 'resumed), the goals this plugin disarmed, the last recovery action/detail per session, '
2147
2470
  + 'whether a session was handed off to a fresh session (target session id and time) or is over '
2148
- + 'the model context window (such a session is never auto-resumed), the '
2471
+ + 'the model context window (such a session is never auto-resumed), whether the pre-restart '
2472
+ + 'backup also snapshots the installed plugins (backupIncludePlugins), the '
2149
2473
  + 'resolved state-file path, and the detected restart environment. Read-only.',
2150
2474
  parameters: {
2151
2475
  type: 'object',
@@ -2174,6 +2498,7 @@ function registerTools(ctx, runtime, config) {
2174
2498
  handoffs: { type: 'array', items: { type: 'object' } },
2175
2499
  profileDir: { type: 'string' },
2176
2500
  profileDirSource: { type: 'string' },
2501
+ backupIncludePlugins: { type: 'boolean' },
2177
2502
  lastRollback: { type: 'object' },
2178
2503
  environment: { type: 'object' },
2179
2504
  sessions: { type: 'array', items: { type: 'object' } },
@@ -2201,6 +2526,8 @@ function registerTools(ctx, runtime, config) {
2201
2526
  // Where the pre-restart backup comes from (null dir = it would be skipped).
2202
2527
  profileDirSource: runtime.profileDir.source,
2203
2528
  ...(runtime.profileDir.dir ? { profileDir: runtime.profileDir.dir } : {}),
2529
+ // Whether the pre-restart backup also snapshots the installed plugins.
2530
+ backupIncludePlugins: config.backupIncludePlugins !== false,
2204
2531
  // Present only when the previous restart had to be rolled back.
2205
2532
  ...(runtime.lastRollback ? { lastRollback: runtime.lastRollback } : {}),
2206
2533
  environment: {
@@ -2906,6 +3233,77 @@ function buildHandoffRoute(ctx, runtime, config) {
2906
3233
  }
2907
3234
  }
2908
3235
 
3236
+ /**
3237
+ * Build the POST `/choice` route: persist the one-time install choice of the
3238
+ * user (whether the pre-restart backup also snapshots the installed plugins).
3239
+ *
3240
+ * It sits next to the health route and uses the same authentication rule
3241
+ * (loopback OR a request the connection service admits). Body:
3242
+ * `{ "includePlugins": boolean }`; anything that is not a boolean is refused
3243
+ * with a 400 and NOTHING is written.
3244
+ *
3245
+ * The answer is `{ ok: true, applied: 'config' | 'fallback-file', includePlugins }`:
3246
+ * `applied` says where the choice landed — the profile configuration (the
3247
+ * official path) or the degraded fallback file.
3248
+ */
3249
+ function buildChoiceRoute(ctx, runtime, config) {
3250
+ const routePath = resolveChoicePath(config)
3251
+ return {
3252
+ kind: 'exact',
3253
+ path: routePath,
3254
+ handler: (req, res) => {
3255
+ const remote = req && req.socket ? req.socket.remoteAddress : undefined
3256
+ const loopback = remote === undefined
3257
+ || remote === '127.0.0.1'
3258
+ || remote === '::1'
3259
+ || remote === '::ffff:127.0.0.1'
3260
+ if (!loopback && !admittedByConnection(ctx, req)) {
3261
+ res.statusCode = 403
3262
+ res.setHeader('content-type', 'application/json; charset=utf-8')
3263
+ res.end('{"ok":false,"error":"loopback-only"}')
3264
+ return
3265
+ }
3266
+ if (!req || req.method !== 'POST') {
3267
+ res.statusCode = 405
3268
+ res.setHeader('content-type', 'application/json; charset=utf-8')
3269
+ res.setHeader('allow', 'POST')
3270
+ res.end('{"ok":false,"error":"method-not-allowed"}')
3271
+ return
3272
+ }
3273
+ void readJsonBody(req).then(async (payload) => {
3274
+ const body = payload && typeof payload === 'object' ? payload : {}
3275
+ const includePlugins = body.includePlugins
3276
+ if (typeof includePlugins !== 'boolean') {
3277
+ // Nothing is written for a malformed choice: the caller's answer
3278
+ // must not change the profile configuration.
3279
+ res.statusCode = 400
3280
+ res.setHeader('content-type', 'application/json; charset=utf-8')
3281
+ res.setHeader('cache-control', 'no-store')
3282
+ res.end(JSON.stringify({
3283
+ ok: false,
3284
+ error: 'includePlugins-must-be-boolean',
3285
+ instruction: 'Send { "includePlugins": true | false }.',
3286
+ }))
3287
+ return
3288
+ }
3289
+ const result = await runtime.applyBackupChoice(includePlugins)
3290
+ res.statusCode = 200
3291
+ res.setHeader('content-type', 'application/json; charset=utf-8')
3292
+ res.setHeader('cache-control', 'no-store')
3293
+ res.end(JSON.stringify({
3294
+ ok: true,
3295
+ applied: result.applied,
3296
+ includePlugins: result.includePlugins === true,
3297
+ }))
3298
+ }).catch((error) => {
3299
+ res.statusCode = 500
3300
+ res.setHeader('content-type', 'application/json; charset=utf-8')
3301
+ res.end(JSON.stringify({ ok: false, error: describeError(error) }))
3302
+ })
3303
+ },
3304
+ }
3305
+ }
3306
+
2909
3307
  /** Expose a tiny read-only health route for humans and verification scripts. */
2910
3308
  function registerHealthRoute(ctx, runtime, config) {
2911
3309
  const routePath = typeof config.healthPath === 'string' && config.healthPath.startsWith('/')
@@ -3016,6 +3414,10 @@ function registerHealthRoute(ctx, runtime, config) {
3016
3414
  // the restart).
3017
3415
  parkUrl: resolveParkPath(config),
3018
3416
  recoverUrl: resolveRecoverPath(config),
3417
+ // The route that persists the one-time install choice, plus the
3418
+ // value that is effective right now.
3419
+ choiceUrl: resolveChoicePath(config),
3420
+ backupIncludePlugins: config.backupIncludePlugins !== false,
3019
3421
  parkedGoals: parkedGoalList(runtime),
3020
3422
  recovery: recoveryList(live),
3021
3423
  })
@@ -3030,6 +3432,8 @@ function registerHealthRoute(ctx, runtime, config) {
3030
3432
  quietForMs: 0,
3031
3433
  parkUrl: resolveParkPath(config),
3032
3434
  recoverUrl: resolveRecoverPath(config),
3435
+ choiceUrl: resolveChoicePath(config),
3436
+ backupIncludePlugins: config.backupIncludePlugins !== false,
3033
3437
  parkedGoals: [],
3034
3438
  recovery: [],
3035
3439
  })
@@ -3086,6 +3490,7 @@ function registerHealthRoute(ctx, runtime, config) {
3086
3490
  const parkRoute = buildParkRoute(ctx, runtime, config)
3087
3491
  const recoverRoute = buildRecoverRoute(ctx, runtime, config)
3088
3492
  const handoffRoute = buildHandoffRoute(ctx, runtime, config)
3493
+ const choiceRoute = buildChoiceRoute(ctx, runtime, config)
3089
3494
  try {
3090
3495
  const origin = typeof webServer.getOrigin === 'function' ? String(webServer.getOrigin()) : ''
3091
3496
  const portMatch = /:(\d+)\/?$/.exec(origin)
@@ -3112,6 +3517,11 @@ function registerHealthRoute(ctx, runtime, config) {
3112
3517
  } catch (error) {
3113
3518
  runtime.log.warn('could not register the handoff route:', String(error))
3114
3519
  }
3520
+ try {
3521
+ target.effect(() => webServer.register(choiceRoute), 'dsh-autorestart: choice route')
3522
+ } catch (error) {
3523
+ runtime.log.warn('could not register the choice route:', String(error))
3524
+ }
3115
3525
  if (config.autoReloadPage !== false && typeof webServer.tapIndex === 'function') {
3116
3526
  try {
3117
3527
  target.effect(
@@ -3245,7 +3655,12 @@ function trackAgents(ctx, runtime, config) {
3245
3655
  */
3246
3656
  export function apply(ctx, rawConfig) {
3247
3657
  const config = normalizeConfig(rawConfig)
3248
- const runtime = createRuntime(ctx, config)
3658
+ // The one-time install choice lives in the profile row config (the single
3659
+ // source of truth). Only when the row does not set the key at all does the
3660
+ // degraded fallback file get a say.
3661
+ const choice = resolveBackupIncludePlugins(rawConfig, resolveStateDir(config))
3662
+ config.backupIncludePlugins = choice.includePlugins
3663
+ const runtime = createRuntime(ctx, config, { backupChoiceSource: choice.source })
3249
3664
  const log = runtime.log
3250
3665
 
3251
3666
  log.info('activated', {
@@ -3256,6 +3671,8 @@ export function apply(ctx, rawConfig) {
3256
3671
  desktopApp: runtime.env.isDesktopApp,
3257
3672
  appExePath: runtime.env.appExePath,
3258
3673
  configSchema: Config !== undefined,
3674
+ backupIncludePlugins: config.backupIncludePlugins,
3675
+ backupChoiceSource: choice.source,
3259
3676
  })
3260
3677
  if (Config === undefined) {
3261
3678
  log.warn('Config schema is unavailable in this build (schemastery could not be loaded); configuration still works through cordis.patch.yml and the built-in defaults')
@@ -3294,4 +3711,4 @@ export function apply(ctx, rawConfig) {
3294
3711
  }, 'dsh-autorestart lifecycle')
3295
3712
  }
3296
3713
 
3297
- export { DEFAULTS, normalizeConfig, resolveStateFile }
3714
+ export { DEFAULTS, normalizeConfig, resolveStateFile, resolveStateDir, resolveChoicePath }