dsh-autorestart 0.8.3 → 0.8.5

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/lib/backup.js CHANGED
@@ -31,8 +31,176 @@ export const PLUGINS_DIR = 'node_modules';
31
31
  /** The manifest written into every backup. */
32
32
  export const MANIFEST_NAME = 'manifest.json';
33
33
 
34
- /** How many backups are kept by default. */
35
- export const DEFAULT_MAX_BACKUPS = 2;
34
+ /**
35
+ * How many backups are kept by default.
36
+ *
37
+ * THREE (user decision, 2026-10-30: "the current one plus the two before it"),
38
+ * so a rollback can step one restart further back than the newest copy. The
39
+ * newest KNOWN-GOOD backup is never pruned whatever this number says — losing
40
+ * it would leave the machine with nothing to roll back to.
41
+ */
42
+ export const DEFAULT_MAX_BACKUPS = 3;
43
+
44
+ /**
45
+ * THE IDENTITY OF A BACKUP (user decision, 2026-10-30).
46
+ *
47
+ * A pre-restart snapshot is taken BEFORE the restart: it holds exactly the state
48
+ * that is about to be tested, i.e. possibly the state that breaks the boot. So a
49
+ * restore may only ever use a backup that HAS BOOTED: the known-good one.
50
+ *
51
+ * Two identities:
52
+ * 'snapshot' taken before a restart, not proven yet;
53
+ * 'known-good' a restart with this content really came up — or the seed taken
54
+ * on first install (nothing to prove yet, nothing to lose either).
55
+ *
56
+ * A directory without a readable meta.json is 'unknown': it was written before
57
+ * this rule existed and is NEVER used for an automatic rollback.
58
+ */
59
+ export const META_NAME = 'meta.json';
60
+ export const KIND_SNAPSHOT = 'snapshot';
61
+ export const KIND_KNOWN_GOOD = 'known-good';
62
+ export const KIND_UNKNOWN = 'unknown';
63
+
64
+ /**
65
+ * Coerce anything into one of the three identities.
66
+ *
67
+ * @param {unknown} raw
68
+ * @returns {string}
69
+ */
70
+ function normalizeKind(raw) {
71
+ if (raw === KIND_KNOWN_GOOD) return KIND_KNOWN_GOOD;
72
+ if (raw === KIND_SNAPSHOT) return KIND_SNAPSHOT;
73
+ return KIND_UNKNOWN;
74
+ }
75
+
76
+ /**
77
+ * Read the identity of one backup directory.
78
+ *
79
+ * A missing or unreadable meta.json is NOT an error: it means the backup was
80
+ * written before the identity layer existed, and the answer is `'unknown'`.
81
+ *
82
+ * @param {string} backupDir
83
+ * @returns {{ kind: string, at: number|null, version: string|null, bootId: string|null, requestId: number|null, reason: string|null, upgradedAt: number|null }}
84
+ */
85
+ export function readBackupMeta(backupDir) {
86
+ const empty = {
87
+ kind: KIND_UNKNOWN,
88
+ at: null,
89
+ version: null,
90
+ bootId: null,
91
+ requestId: null,
92
+ reason: null,
93
+ upgradedAt: null,
94
+ };
95
+ let parsed = null;
96
+ try {
97
+ parsed = JSON.parse(fs.readFileSync(path.join(String(backupDir ?? ''), META_NAME), 'utf8'));
98
+ } catch {
99
+ return empty;
100
+ }
101
+ if (!parsed || typeof parsed !== 'object') return empty;
102
+ return {
103
+ kind: normalizeKind(parsed.kind),
104
+ at: Number.isFinite(parsed.at) ? parsed.at : null,
105
+ version: typeof parsed.version === 'string' && parsed.version !== '' ? parsed.version : null,
106
+ bootId: typeof parsed.bootId === 'string' && parsed.bootId !== '' ? parsed.bootId : null,
107
+ requestId: Number.isFinite(parsed.requestId) ? parsed.requestId : null,
108
+ reason: typeof parsed.reason === 'string' && parsed.reason !== '' ? parsed.reason : null,
109
+ upgradedAt: Number.isFinite(parsed.upgradedAt) ? parsed.upgradedAt : null,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Write the identity of one backup directory.
115
+ *
116
+ * Best effort: a backup whose meta cannot be written stays a usable `'unknown'`
117
+ * one (it is never used for an AUTOMATIC rollback, but it is still on disk).
118
+ *
119
+ * @param {string} backupDir
120
+ * @param {object} [meta]
121
+ * @returns {boolean} whether it was written
122
+ */
123
+ export function writeBackupMeta(backupDir, meta = {}) {
124
+ const record = {
125
+ kind: normalizeKind(meta.kind),
126
+ at: Number.isFinite(meta.at) ? meta.at : Date.now(),
127
+ version: typeof meta.version === 'string' && meta.version !== '' ? meta.version : null,
128
+ bootId: typeof meta.bootId === 'string' && meta.bootId !== '' ? meta.bootId : null,
129
+ requestId: Number.isFinite(meta.requestId) ? meta.requestId : null,
130
+ reason: typeof meta.reason === 'string' && meta.reason !== '' ? meta.reason : null,
131
+ upgradedAt: Number.isFinite(meta.upgradedAt) ? meta.upgradedAt : null,
132
+ };
133
+ try {
134
+ fs.writeFileSync(path.join(String(backupDir ?? ''), META_NAME), JSON.stringify(record, null, 2), 'utf8');
135
+ return true;
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * PROMOTE a snapshot to known-good — the restart that used it really came up.
143
+ *
144
+ * The CONTENT does not change: "it booted" IS the proof, and it is written where
145
+ * the next rollback looks for it.
146
+ *
147
+ * @param {string} backupDir
148
+ * @param {{ reason?: string, version?: string|null, bootId?: string|null, now?: number }} [options]
149
+ * @returns {boolean}
150
+ */
151
+ export function markKnownGood(backupDir, { reason = 'boot-ok', version = null, bootId = null, now = Date.now() } = {}) {
152
+ const current = readBackupMeta(backupDir);
153
+ const manifest = readManifest(backupDir);
154
+ // Nothing to promote: neither an identity nor a manifest to stand on.
155
+ if (manifest === null && current.kind === KIND_UNKNOWN) return false;
156
+ return writeBackupMeta(backupDir, {
157
+ kind: KIND_KNOWN_GOOD,
158
+ at: current.at !== null ? current.at : (manifest && Number.isFinite(manifest.at) ? manifest.at : now),
159
+ version: version !== null && version !== undefined ? version : current.version,
160
+ bootId: bootId !== null && bootId !== undefined ? bootId : current.bootId,
161
+ requestId: current.requestId,
162
+ reason: current.kind === KIND_KNOWN_GOOD ? (current.reason || reason) : reason,
163
+ upgradedAt: now,
164
+ });
165
+ }
166
+
167
+ /**
168
+ * THE NEWEST BACKUP THAT HAS PROVEN IT CAN BOOT — the only valid target of an
169
+ * automatic rollback.
170
+ *
171
+ * Backups without a meta.json (`'unknown'`) are skipped on purpose: they were
172
+ * written before the identity layer and were never proven.
173
+ *
174
+ * @param {string} backupsDir the backups ROOT (the directory holding the copies)
175
+ * @returns {object|null}
176
+ */
177
+ export function latestKnownGood(backupsDir) {
178
+ for (const entry of listBackupIdentities(backupsDir)) {
179
+ if (entry.kind === KIND_KNOWN_GOOD) return entry;
180
+ }
181
+ return null;
182
+ }
183
+
184
+ /**
185
+ * Every backup with its identity, newest first (what the settings page and the
186
+ * health route show: identity, time, size, version).
187
+ *
188
+ * @param {string} backupsDir the backups ROOT
189
+ * @returns {Array<object>}
190
+ */
191
+ export function listBackupIdentities(backupsDir) {
192
+ return listBackups(backupsDir).map((backup) => {
193
+ const meta = readBackupMeta(backup.dir);
194
+ return {
195
+ ...backup,
196
+ kind: meta.kind,
197
+ version: meta.version,
198
+ reason: meta.reason,
199
+ requestId: meta.requestId,
200
+ upgradedAt: meta.upgradedAt,
201
+ };
202
+ });
203
+ }
36
204
 
37
205
  /** The report the detached helper writes when it rolls a backup back. */
38
206
  export const ROLLBACK_REPORT_NAME = 'last-rollback.json';
@@ -365,6 +533,10 @@ export function removeBackupDir(dir) {
365
533
  /**
366
534
  * Keep only the newest `maxBackups` backup directories.
367
535
  *
536
+ * THE NEWEST KNOWN-GOOD BACKUP IS NEVER REMOVED (user decision, 2026-10-30): it
537
+ * is the only copy a failed boot may be rolled back to, so the "oldest first"
538
+ * order steps around it — even when it is the very last one left.
539
+ *
368
540
  * @param {string} backupsDir
369
541
  * @param {number} [maxBackups]
370
542
  * @returns {string[]} the names that were removed
@@ -382,8 +554,24 @@ export function pruneBackups(backupsDir, maxBackups = DEFAULT_MAX_BACKUPS) {
382
554
  } catch {
383
555
  return [];
384
556
  }
557
+ // THE NEWEST KNOWN-GOOD IS NEVER REMOVED: it is the only thing a failed boot
558
+ // can roll back to, so "the oldest first" has to step around it.
559
+ const protectedName = (() => {
560
+ for (const name of names.slice().reverse()) {
561
+ const meta = readBackupMeta(path.join(backupsDir, name));
562
+ if (meta.kind === KIND_KNOWN_GOOD) return name;
563
+ }
564
+ return null;
565
+ })();
566
+ const victims = [];
567
+ const budget = Math.max(0, names.length - limit);
568
+ for (const name of names) {
569
+ if (victims.length >= budget) break;
570
+ if (name === protectedName) continue;
571
+ victims.push(name);
572
+ }
385
573
  const removed = [];
386
- for (const name of names.slice(0, Math.max(0, names.length - limit))) {
574
+ for (const name of victims) {
387
575
  const target = path.join(backupsDir, name);
388
576
  try {
389
577
  fs.rmSync(target, { recursive: true, force: true });
@@ -473,11 +661,16 @@ export function readManifest(backupDir) {
473
661
  * includePlugins?: boolean,
474
662
  * maxBackups?: number,
475
663
  * now?: number,
664
+ * kind?: string,
665
+ * reason?: string|null,
666
+ * version?: string|null,
667
+ * bootId?: string|null,
668
+ * requestId?: number|null,
476
669
  * onProgress?: ((progress: { files: number, bytes: number, total: number, filesTotal: number }) => void)|null,
477
670
  * shouldCancel?: (() => boolean)|null,
478
671
  * measure?: ((profileDir: string, includePlugins: boolean) => { files: number, bytes: number })|null,
479
672
  * }} options
480
- * @returns {{ ok: boolean, cancelled: boolean, dir: string|null, stamp: string|null, at: number|null, atLocal: string|null, durationMs: number, bytes: number, totalBytes: number, fileCount: number, files: string[], includePlugins: boolean, pruned: string[], error: string|null }}
673
+ * @returns {{ ok: boolean, cancelled: boolean, dir: string|null, stamp: string|null, at: number|null, atLocal: string|null, kind: string, durationMs: number, bytes: number, totalBytes: number, fileCount: number, files: string[], includePlugins: boolean, pruned: string[], error: string|null }}
481
674
  */
482
675
  export async function createBackup({
483
676
  profileDir,
@@ -485,12 +678,20 @@ export async function createBackup({
485
678
  includePlugins = true,
486
679
  maxBackups = DEFAULT_MAX_BACKUPS,
487
680
  now = Date.now(),
681
+ kind = KIND_SNAPSHOT,
682
+ reason = null,
683
+ version = null,
684
+ bootId = null,
685
+ requestId = null,
488
686
  onProgress = null,
489
687
  shouldCancel = null,
490
688
  measure = null,
491
689
  } = {}) {
492
690
  const profile = typeof profileDir === 'string' ? profileDir.trim() : '';
493
691
  const root = typeof backupsDir === 'string' ? backupsDir.trim() : '';
692
+ // A snapshot taken before a restart is NOT proven yet; only an explicit
693
+ // 'known-good' (the first-install seed) starts out proven.
694
+ const metaKind = kind === KIND_KNOWN_GOOD || kind === KIND_UNKNOWN ? kind : KIND_SNAPSHOT;
494
695
  const startedAt = Date.now();
495
696
  const base = {
496
697
  ok: false,
@@ -499,6 +700,7 @@ export async function createBackup({
499
700
  stamp: null,
500
701
  at: null,
501
702
  atLocal: null,
703
+ kind: metaKind,
502
704
  durationMs: 0,
503
705
  bytes: 0,
504
706
  totalBytes: 0,
@@ -656,6 +858,18 @@ export async function createBackup({
656
858
  return { ...base, stamp, files, bytes, totalBytes: measured.bytes, fileCount, error: 'could not write the backup manifest: ' + (error && error.message ? error.message : String(error)) };
657
859
  }
658
860
 
861
+ // THE IDENTITY, right next to the manifest: what this copy is and where it
862
+ // came from. Best effort — a copy whose meta cannot be written stays an
863
+ // 'unknown' one on disk (never used for an automatic rollback).
864
+ writeBackupMeta(dir, {
865
+ kind: metaKind,
866
+ at: Number(now),
867
+ version,
868
+ bootId,
869
+ requestId,
870
+ reason,
871
+ });
872
+
659
873
  const pruned = pruneBackups(root, maxBackups);
660
874
  return {
661
875
  ...base,
@@ -664,6 +878,7 @@ export async function createBackup({
664
878
  stamp,
665
879
  at: Number(now),
666
880
  atLocal: manifest.atLocal,
881
+ kind: metaKind,
667
882
  durationMs: Math.max(0, Date.now() - startedAt),
668
883
  bytes,
669
884
  totalBytes: measured.bytes,
package/lib/checkpoint.js CHANGED
@@ -59,6 +59,12 @@ const RESTART_KEYS = new Set([
59
59
  'notBefore',
60
60
  'requesters',
61
61
  'parkGoals',
62
+ // THE SNAPSHOT THIS REQUEST ALREADY TOOK. It lives on the request, so it is
63
+ // dropped by itself when the request is withdrawn or carried out (the request
64
+ // object is replaced / removed), and it survives a deferral — which is the whole
65
+ // point: a 205 MB copy must not be repeated because a conversation interrupted
66
+ // the restart (user report, 2026-10-30).
67
+ 'snapshot',
62
68
  ]);
63
69
 
64
70
  const ENTRY_KEYS = new Set([
@@ -110,6 +116,9 @@ const TOP_LEVEL_KEYS = new Set([
110
116
  'seenFeatureVersions',
111
117
  'onboarding',
112
118
  'sleep',
119
+ // The seed of the FIRST safe copy: what happened when a new host process
120
+ // looked for a known-good backup and found none (user decision, 2026-10-30).
121
+ 'firstInstallBackup',
113
122
  'updatedAt',
114
123
  ]);
115
124
 
@@ -228,6 +237,10 @@ function normalizeRestartPhase(raw, now) {
228
237
  // that polls every 2 s would otherwise never learn that a backup happened
229
238
  // (user report, 2026-10-29). null = nothing was measured.
230
239
  snapshotBytes: isFiniteNumber(raw.snapshotBytes) ? Math.max(0, Math.trunc(raw.snapshotBytes)) : null,
240
+ // WHY the stage is what it is ("deferred: conversation-active", "snapshot
241
+ // done", …). A stage without a reason cannot be diagnosed after the fact —
242
+ // and the hang this fixes was exactly an unexplained leftover stage.
243
+ reason: typeof raw.reason === 'string' && raw.reason !== '' ? raw.reason : null,
231
244
  blocked: isFiniteNumber(raw.blocked) ? Math.max(0, Math.trunc(raw.blocked)) : 0,
232
245
  };
233
246
  return { record, changed };
@@ -244,7 +257,16 @@ function normalizeRestartPhase(raw, now) {
244
257
  * @param {{ phase: string, now?: number, blocked?: number, restartStage?: boolean }} options
245
258
  * @returns {object|null} the recorded stage
246
259
  */
247
- export function setRestartPhase(state, { phase, now = Date.now(), blocked = 0, restartStage = true } = {}) {
260
+ export function setRestartPhase(state, {
261
+ phase,
262
+ now = Date.now(),
263
+ blocked = 0,
264
+ restartStage = true,
265
+ reason = null,
266
+ // undefined = carry what the record already measured, null = forget it, a
267
+ // number = this is the size of the snapshot this restart already paid for.
268
+ snapshotBytes,
269
+ } = {}) {
248
270
  if (!isObject(state)) return null;
249
271
  if (!RESTART_PHASES.includes(phase)) return state.restartPhase ?? null;
250
272
  const moment = isFiniteNumber(now) ? now : Date.now();
@@ -273,8 +295,14 @@ export function setRestartPhase(state, { phase, now = Date.now(), blocked = 0, r
273
295
  ? previous.progress
274
296
  : null,
275
297
  // A NEW restart starts with nothing measured; a stage change inside one
276
- // restart keeps the size it already has.
277
- snapshotBytes: sameRestart ? measuredBytes : null,
298
+ // restart keeps the size it already has — and a caller that knows the size
299
+ // (the request's own snapshot) may state it explicitly.
300
+ snapshotBytes: snapshotBytes === null
301
+ ? null
302
+ : (isFiniteNumber(snapshotBytes) ? Math.max(0, Math.trunc(snapshotBytes)) : (sameRestart ? measuredBytes : null)),
303
+ // WHY this stage (see normalizeRestartPhase): "deferred: <reason>" is what
304
+ // tells a reader that the restart went back to waiting on purpose.
305
+ reason: typeof reason === 'string' && reason !== '' ? reason : null,
278
306
  blocked: isFiniteNumber(blocked) ? Math.max(0, Math.trunc(blocked)) : 0,
279
307
  };
280
308
  state.restartPhase = record;
@@ -308,6 +336,7 @@ export function setRestartProgress(state, { files = 0, bytes = 0, total = 0, fil
308
336
  // The live byte counter IS the size of the snapshot so far: keeping it here
309
337
  // means the number survives the moment the copy ends.
310
338
  snapshotBytes: number(bytes),
339
+ reason: previous !== null && typeof previous.reason === 'string' ? previous.reason : null,
311
340
  blocked: previous !== null && isFiniteNumber(previous.blocked) ? previous.blocked : 0,
312
341
  };
313
342
  state.updatedAt = moment;
@@ -715,6 +744,31 @@ function normalizeRequesters(raw) {
715
744
  * @param {unknown} raw
716
745
  * @returns {{ restart: object|null, changed: boolean }}
717
746
  */
747
+ /**
748
+ * Coerce the "this request already took a snapshot" record.
749
+ *
750
+ * It is a pointer plus the numbers of that copy, and nothing else: the profile
751
+ * itself lives in the backup directory, which is checked against the manifest
752
+ * before it is used again.
753
+ *
754
+ * @param {unknown} raw
755
+ * @returns {{ requestedAt: number, dir: string, profileDir: string|null, files: number, bytes: number, total: number, at: number }|null}
756
+ */
757
+ function normalizeRestartSnapshot(raw) {
758
+ if (!isObject(raw)) return null;
759
+ if (typeof raw.dir !== 'string' || raw.dir === '') return null;
760
+ const number = (value) => (isFiniteNumber(value) ? Math.max(0, Math.trunc(value)) : 0);
761
+ return {
762
+ requestedAt: number(raw.requestedAt),
763
+ dir: raw.dir,
764
+ profileDir: typeof raw.profileDir === 'string' && raw.profileDir !== '' ? raw.profileDir : null,
765
+ files: number(raw.files),
766
+ bytes: number(raw.bytes),
767
+ total: number(raw.total),
768
+ at: number(raw.at),
769
+ };
770
+ }
771
+
718
772
  function normalizeRestart(raw) {
719
773
  if (!isObject(raw)) return { restart: null, changed: raw !== null && raw !== undefined };
720
774
  let changed = false;
@@ -736,6 +790,11 @@ function normalizeRestart(raw) {
736
790
  // never deadlock behind a goal that keeps its own agent busy.
737
791
  parkGoals: raw.parkGoals !== false,
738
792
  };
793
+ // The snapshot is only carried when there IS one: a checkpoint that never took
794
+ // a snapshot must normalize to exactly the object it was.
795
+ const snapshot = normalizeRestartSnapshot(raw.snapshot);
796
+ if (snapshot !== null) restart.snapshot = snapshot;
797
+ else if (raw.snapshot != null) changed = true;
739
798
  if (!isFiniteNumber(raw.generation) || raw.generation < 0) changed = true;
740
799
  if (!isFiniteNumber(raw.requestedAt)) changed = true;
741
800
  if (!isFiniteNumber(raw.attempt) || raw.attempt < 0) changed = true;
@@ -845,9 +904,40 @@ export function normalizeState(raw, now = Date.now()) {
845
904
  if (onboarding.changed) repaired = true;
846
905
  }
847
906
 
907
+ // THE FIRST SAFE COPY: what happened when this machine looked for a
908
+ // known-good backup and found none. It survives a restart, so a machine with
909
+ // NO safe copy can say so instead of looking healthy.
910
+ if (raw.firstInstallBackup != null) {
911
+ const seeded = normalizeFirstInstallBackup(raw.firstInstallBackup);
912
+ if (seeded === null) repaired = true;
913
+ else state.firstInstallBackup = seeded;
914
+ }
915
+
848
916
  return { state, repaired };
849
917
  }
850
918
 
919
+ /**
920
+ * Normalize the record of the first-install safe copy.
921
+ *
922
+ * A record without a `result` says nothing, so it is dropped (and the caller
923
+ * marks the state as repaired).
924
+ *
925
+ * @param {unknown} raw
926
+ * @returns {{ result: string, at: number|null, dir: string|null, bytes: number|null, error: string|null }|null}
927
+ */
928
+ function normalizeFirstInstallBackup(raw) {
929
+ if (!isObject(raw)) return null;
930
+ const result = typeof raw.result === 'string' && raw.result !== '' ? raw.result : null;
931
+ if (result === null) return null;
932
+ return {
933
+ result,
934
+ at: isFiniteNumber(raw.at) ? raw.at : null,
935
+ dir: typeof raw.dir === 'string' && raw.dir !== '' ? raw.dir : null,
936
+ bytes: isFiniteNumber(raw.bytes) ? raw.bytes : null,
937
+ error: typeof raw.error === 'string' && raw.error !== '' ? raw.error : null,
938
+ };
939
+ }
940
+
851
941
  /**
852
942
  * Read a checkpoint from disk. Never throws and never creates directories.
853
943
  *
@@ -163,6 +163,7 @@ export function describeRestartPhase({
163
163
  progress = null,
164
164
  blocking = null,
165
165
  snapshotBytes = null,
166
+ reason = null,
166
167
  now = Date.now(),
167
168
  } = {}) {
168
169
  const stage = isRestartPhase(phase) ? phase : 'idle';
@@ -299,6 +300,10 @@ export function describeRestartPhase({
299
300
  snapshotBytes: snapshotBytes === null || snapshotBytes === undefined || snapshotBytes === ''
300
301
  ? null
301
302
  : nonNegative(snapshotBytes),
303
+ // WHY the stage is what it is: "deferred: conversation-active" says the
304
+ // restart went back to waiting, which is what a reader needs to tell a
305
+ // postponed restart from a running one.
306
+ reason: typeof reason === 'string' && reason !== '' ? reason : null,
302
307
  text,
303
308
  };
304
309
  }
package/locale/en.json CHANGED
@@ -204,7 +204,24 @@
204
204
  "time": "Backup time",
205
205
  "size": "Backup size",
206
206
  "withPlugins": "(plugins included)",
207
- "withoutPlugins": "(plugins excluded)"
207
+ "withoutPlugins": "(plugins excluded)",
208
+ "kindLabel": "Identity",
209
+ "kind": {
210
+ "known-good": "Known good",
211
+ "snapshot": "This restart's snapshot",
212
+ "unknown": "Old backup (unverified)"
213
+ },
214
+ "version": "version {v}",
215
+ "identityHint": "A backup has one of two identities: ① “known good” — the copy a boot has proven; when something breaks, only a known-good copy is rolled back, and the first install creates one automatically. ② “this restart's snapshot” — taken right before every restart and not proven yet; old backups (unverified) and that snapshot are never rolled back automatically. At most {n} copies are kept together (the oldest goes first; a known-good copy is never deleted). A manual rollback asks for confirmation first, then restarts automatically.",
216
+ "rollback": "Roll back to this copy",
217
+ "rollbackConfirmTitle": "Roll back to this copy?",
218
+ "rollbackConfirmBody": "The backup from {time} will be restored over the current state — **what you have now is replaced** (a snapshot of it is taken first, so this rollback can be undone).",
219
+ "rollbackConfirmRestart": "A restart follows automatically; this page disconnects during it and works again afterwards.",
220
+ "rollbackConfirmYes": "Roll back",
221
+ "rollbackConfirmNo": "Cancel",
222
+ "rollbackBusy": "Rolling back…",
223
+ "rollbackDone": "Rolled back — restarting…",
224
+ "rollbackFailed": "The rollback did not work: {message}"
208
225
  },
209
226
  "backupPlugins": {
210
227
  "label": "Include the installed plugins in the backup",
@@ -339,6 +356,9 @@
339
356
  "size": "{done} / {total}",
340
357
  "sizeDone": "{done}",
341
358
  "restarting": "Restarting…",
359
+ "deferred": "A conversation just started — restart postponed",
360
+ "deferredNote": "It will retry automatically when idle · {count} waiting",
361
+ "deferredNoteZero": "It will retry automatically when idle",
342
362
  "back": "Back again"
343
363
  }
344
364
  },
package/locale/zh.json CHANGED
@@ -204,7 +204,24 @@
204
204
  "time": "备份时间",
205
205
  "size": "备份大小",
206
206
  "withPlugins": "(含已装插件)",
207
- "withoutPlugins": "(不含已装插件)"
207
+ "withoutPlugins": "(不含已装插件)",
208
+ "kindLabel": "身份",
209
+ "kind": {
210
+ "known-good": "安全份",
211
+ "snapshot": "本次快照",
212
+ "unknown": "旧备份(未验证)"
213
+ },
214
+ "version": "版本 {v}",
215
+ "identityHint": "备份有两种身份:①「安全份」——启动成功后验证过的那一份,出问题只会回滚安全份;第一次安装时会自动生成一份。②「本次快照」——每次重启前刚拍的,还没验证过;旧备份(未验证)和它都不会被自动回滚。两类合计最多保留 {n} 份,超出删最旧的那份(安全份不会被删)。手动回退会先二次确认,然后会自动重启。",
216
+ "rollback": "回退到这一份",
217
+ "rollbackConfirmTitle": "确定回退到这一份?",
218
+ "rollbackConfirmBody": "会把 {time} 的那份备份还原到现在——**当前状态会被它覆盖**(覆盖前会先自动存一份当前快照,所以这次回退还能再退回来)。",
219
+ "rollbackConfirmRestart": "还原完成后会自动重启一次;重启期间这个页面会断开,回来后接着用。",
220
+ "rollbackConfirmYes": "确认回退",
221
+ "rollbackConfirmNo": "取消",
222
+ "rollbackBusy": "正在回退…",
223
+ "rollbackDone": "已回退,正在重启…",
224
+ "rollbackFailed": "回退没成功:{message}"
208
225
  },
209
226
  "backupPlugins": {
210
227
  "label": "备份包含已安装插件",
@@ -339,6 +356,9 @@
339
356
  "size": "{done} / {total}",
340
357
  "sizeDone": "{done}",
341
358
  "restarting": "正在重启…",
359
+ "deferred": "有新会话在跑,重启已暂缓",
360
+ "deferredNote": "等它结束会自动继续 · 已等 {count} 个会话",
361
+ "deferredNoteZero": "等它结束会自动继续",
342
362
  "back": "已回来"
343
363
  }
344
364
  },
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-autorestart",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "type": "module",
5
- "description": "DeepSeek Harness plugin: restart without losing work. Waits until every conversation is idle, restarts the host, then automatically resumes the interrupted sessions. AI-generated, not human-reviewed: use with caution.",
5
+ "description": "DeepSeek Harness plugin: idle-aware restart with session resume, a snapshot before every restart that is restored automatically if the host fails to come back, and a countdown shutdown; works on the Windows desktop client and on hosted dsh web (Windows, WSL, Linux). AI-generated, not human-reviewed: use with caution.",
6
6
  "license": "MIT",
7
7
  "keywords": [
8
8
  "dsh",