dsh-autorestart 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/checkpoint.js CHANGED
@@ -106,6 +106,7 @@ const TOP_LEVEL_KEYS = new Set([
106
106
  'parkedGoals',
107
107
  'disarmedGoals',
108
108
  'seenFeatureVersions',
109
+ 'sleep',
109
110
  'updatedAt',
110
111
  ]);
111
112
 
@@ -174,6 +175,7 @@ export function createEmptyState(now = Date.now()) {
174
175
  restart: null,
175
176
  sessions: {},
176
177
  seenFeatureVersions: {},
178
+ sleep: null,
177
179
  updatedAt: now,
178
180
  };
179
181
  }
@@ -306,6 +308,32 @@ function normalizeSeenFeatureVersions(raw) {
306
308
  return { seenFeatureVersions, changed };
307
309
  }
308
310
 
311
+ /**
312
+ * Normalize the recorded shutdown intent.
313
+ *
314
+ * The intent is the user's "shut the machine down when the work is done" — it
315
+ * has to survive a restart, so it lives in the checkpoint file. A record that
316
+ * is broken is DROPPED: asking the user once more is much better than a
317
+ * machine that switches itself off for ever because of a half-written file.
318
+ *
319
+ * @param {unknown} raw
320
+ * @returns {{ sleep: { armed: true, armedAt: number, countdownSeconds: number|null }|null, changed: boolean }}
321
+ */
322
+ function normalizeSleep(raw) {
323
+ if (raw == null) return { sleep: null, changed: false };
324
+ if (!isObject(raw)) return { sleep: null, changed: true };
325
+ if (raw.armed !== true) return { sleep: null, changed: true };
326
+ const armedAt = isFiniteNumber(raw.armedAt) ? raw.armedAt : null;
327
+ if (armedAt === null) return { sleep: null, changed: true };
328
+ const countdownSeconds = isFiniteNumber(raw.countdownSeconds) && raw.countdownSeconds >= 0
329
+ ? Math.trunc(raw.countdownSeconds)
330
+ : null;
331
+ return {
332
+ sleep: { armed: true, armedAt, countdownSeconds },
333
+ changed: raw.countdownSeconds !== countdownSeconds,
334
+ };
335
+ }
336
+
309
337
  /**
310
338
  * Normalize the handoff record of a session: `{ handedOffTo, handoffAt, carriedChars }`.
311
339
  * A missing record stays `null`; a corrupt one is dropped instead of being thrown on.
@@ -574,6 +602,14 @@ export function normalizeState(raw, now = Date.now()) {
574
602
  if (seen.changed) repaired = true;
575
603
  }
576
604
 
605
+ // The recorded shutdown intent: it has to survive a restart, so the machine
606
+ // goes off AFTER the restart and the work that resumed with it.
607
+ if (raw.sleep != null) {
608
+ const sleep = normalizeSleep(raw.sleep);
609
+ state.sleep = sleep.sleep;
610
+ if (sleep.changed) repaired = true;
611
+ }
612
+
577
613
  return { state, repaired };
578
614
  }
579
615
 
@@ -1235,6 +1271,48 @@ export function cancelRestart(state, { now = Date.now() } = {}) {
1235
1271
  return state;
1236
1272
  }
1237
1273
 
1274
+ /**
1275
+ * Record the shutdown intent: "switch the machine off when the work is done".
1276
+ *
1277
+ * The intent lives in the checkpoint file, so it survives a restart: a
1278
+ * shutdown that is asked for while a restart is pending waits for that
1279
+ * restart (and for the sessions that resume with it) before the machine goes
1280
+ * off.
1281
+ *
1282
+ * Mutates `state` in place and returns it.
1283
+ *
1284
+ * @param {object} state
1285
+ * @param {{ armedAt?: number, countdownSeconds?: number|null }} [options]
1286
+ * @returns {object} state
1287
+ */
1288
+ export function armSleep(state, { armedAt = Date.now(), countdownSeconds = null } = {}) {
1289
+ state.sleep = {
1290
+ armed: true,
1291
+ armedAt: isFiniteNumber(armedAt) ? armedAt : Date.now(),
1292
+ countdownSeconds: isFiniteNumber(countdownSeconds) && countdownSeconds >= 0
1293
+ ? Math.trunc(countdownSeconds)
1294
+ : null,
1295
+ };
1296
+ state.updatedAt = state.sleep.armedAt;
1297
+ return state;
1298
+ }
1299
+
1300
+ /**
1301
+ * Forget the shutdown intent: the machine stays on (or it already went off).
1302
+ *
1303
+ * Mutates `state` in place and returns it.
1304
+ *
1305
+ * @param {object} state
1306
+ * @param {{ now?: number }} [options]
1307
+ * @returns {object} state
1308
+ */
1309
+ export function clearSleep(state, { now = Date.now() } = {}) {
1310
+ if (state.sleep === null || state.sleep === undefined) return state;
1311
+ state.sleep = null;
1312
+ state.updatedAt = now;
1313
+ return state;
1314
+ }
1315
+
1238
1316
  /**
1239
1317
  * Set the lifecycle phase, ignoring unknown values.
1240
1318
  * Mutates `state` in place and returns it.
package/lib/semver.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * dsh-autorestart — the smallest semver comparison this plugin needs.
3
+ *
4
+ * The feature announcements are de-duplicated by VERSION, not by equality: a
5
+ * record counts as "seen" as soon as it is not older than the current feature
6
+ * version. That comparison has to be numeric — the string comparison of
7
+ * "0.10.0" and "0.9.0" says the opposite of the truth, because "10" sorts
8
+ * before "9".
9
+ *
10
+ * Only the plain `x.y.z` triple is understood. Anything else (a build
11
+ * metadata suffix, a pre-release tag, a broken record) has no order at all,
12
+ * and the caller decides what to do with that.
13
+ *
14
+ * @module lib/semver
15
+ */
16
+
17
+ /** A version is three numbers and nothing else. */
18
+ const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/
19
+
20
+ /**
21
+ * Split a version into its three numbers.
22
+ *
23
+ * @param {unknown} value
24
+ * @returns {[number, number, number]|null} null when it is not a version
25
+ */
26
+ function parseVersion(value) {
27
+ if (typeof value !== 'string') return null
28
+ const match = VERSION_PATTERN.exec(value.trim())
29
+ if (!match) return null
30
+ return [Number(match[1]), Number(match[2]), Number(match[3])]
31
+ }
32
+
33
+ /**
34
+ * Compare two versions numerically.
35
+ *
36
+ * @param {unknown} a the left version
37
+ * @param {unknown} b the right version
38
+ * @returns {-1|0|1|null} -1 when `a` is older, 1 when it is newer, 0 when they
39
+ * are the same, and null when one of them is not a version at all
40
+ */
41
+ export function compareSemver(a, b) {
42
+ const left = parseVersion(a)
43
+ const right = parseVersion(b)
44
+ if (left === null || right === null) return null
45
+ for (let index = 0; index < 3; index += 1) {
46
+ if (left[index] !== right[index]) return left[index] < right[index] ? -1 : 1
47
+ }
48
+ return 0
49
+ }
package/lib/sleep.js CHANGED
@@ -498,6 +498,10 @@ export function runShutdown(plan, {
498
498
  * setInterval?: (fn: () => void, ms: number) => any,
499
499
  * clearInterval?: (handle: any) => void,
500
500
  * titleOf?: (sessionId: string) => string|undefined,
501
+ * quietForMs?: () => number,
502
+ * graceMs?: () => number,
503
+ * restartPending?: () => { pending: boolean, blockers?: any[] },
504
+ * onDisarm?: (reason: string) => void,
501
505
  * hostLabel?: string,
502
506
  * supported?: boolean,
503
507
  * }} [options]
@@ -512,6 +516,10 @@ export function createSleepController({
512
516
  setInterval: setIntervalFn = setInterval,
513
517
  clearInterval: clearIntervalFn = clearInterval,
514
518
  titleOf = () => '',
519
+ quietForMs = () => 0,
520
+ graceMs = () => 0,
521
+ restartPending = () => ({ pending: false, blockers: [] }),
522
+ onDisarm = () => {},
515
523
  hostLabel = '',
516
524
  supported = true,
517
525
  } = {}) {
@@ -523,6 +531,9 @@ export function createSleepController({
523
531
  let armed = false;
524
532
  let blockers = [];
525
533
  let remainingMs = 0;
534
+ // The countdown is over and only the grace window was missing: the machine
535
+ // goes off when the grace is over, WITHOUT counting down again.
536
+ let countdownDone = false;
526
537
  let lastRun = null;
527
538
  let message = supported
528
539
  ? '还没点「收工关机」:所有会话收工后倒数几秒,就关掉这台电脑。'
@@ -531,7 +542,46 @@ export function createSleepController({
531
542
  let disposed = false;
532
543
  let lastTickAt = now();
533
544
 
534
- /** The busy conversations, as `{ sessionId, title }` entries. */
545
+ /**
546
+ * One blocker entry, in the shape the UI expects.
547
+ *
548
+ * The Host knows WHAT the blocker is (`kind`: a session or a subagent) and
549
+ * HOW it is called (`label`: the title of the session, or of the session
550
+ * that created the subagent). A raw session id never reaches the
551
+ * interface: an unknown name is a generic one.
552
+ *
553
+ * @param {any} entry the entry the Host produced
554
+ * @param {string} sessionId
555
+ * @returns {{ sessionId: string, kind: string, label: string|null, name: string, parentTitle: string|null, title: string }}
556
+ */
557
+ const blockerEntry = (entry, sessionId) => {
558
+ const kind = entry.kind === 'subagent' ? 'subagent' : 'session';
559
+ let label = typeof entry.label === 'string' ? entry.label.trim() : '';
560
+ if (label === '') {
561
+ try {
562
+ const resolved = titleOf(sessionId);
563
+ label = typeof resolved === 'string' ? resolved.trim() : '';
564
+ } catch { /* a missing title is not a problem */ }
565
+ }
566
+ let name = typeof entry.name === 'string' ? entry.name.trim() : '';
567
+ // No title, no name: a generic "会话" would only produce a list of
568
+ // identical entries ("会话、会话、会话"), which says nothing. The client
569
+ // writes the generic line when the name is empty.
570
+ if (name === '') name = label;
571
+ const parentTitle = typeof entry.parentTitle === 'string' ? entry.parentTitle.trim() : '';
572
+ return {
573
+ sessionId,
574
+ kind,
575
+ label: label !== '' ? label : null,
576
+ name,
577
+ parentTitle: parentTitle !== '' ? parentTitle : null,
578
+ // `title` is the old name of `label`: it stays, so an older client
579
+ // keeps working.
580
+ title: label !== '' ? label : name,
581
+ };
582
+ };
583
+
584
+ /** The busy conversations, as `{ sessionId, kind, label, name }` entries. */
535
585
  const listBusy = () => {
536
586
  let list = [];
537
587
  try {
@@ -546,14 +596,7 @@ export function createSleepController({
546
596
  if (!entry || typeof entry !== 'object') continue;
547
597
  const sessionId = entry.sessionId == null ? '' : String(entry.sessionId);
548
598
  if (sessionId === '') continue;
549
- let title = '';
550
- try {
551
- const resolved = titleOf(sessionId);
552
- title = typeof resolved === 'string' ? resolved.trim() : '';
553
- } catch { /* a missing title is not a problem */ }
554
- // The title falls back to the session id: the field always exists, so
555
- // the UI can always say WHO is still working.
556
- out.push({ sessionId, title: title !== '' ? title : sessionId });
599
+ out.push(blockerEntry(entry, sessionId));
557
600
  }
558
601
  return out;
559
602
  };
@@ -570,6 +613,59 @@ export function createSleepController({
570
613
  }
571
614
  };
572
615
 
616
+ /**
617
+ * Leave the armed state: the shutdown is over (done, refused or cancelled).
618
+ *
619
+ * The Host uses this to forget the recorded intent — the machine stays on
620
+ * (or it already went off), so the intent must not survive.
621
+ *
622
+ * @param {string} reason
623
+ */
624
+ const disarm = (reason) => {
625
+ try { onDisarm(reason); } catch (error) {
626
+ if (log && typeof log.warn === 'function') log.warn('the disarm hook failed:', errorText(error));
627
+ }
628
+ };
629
+
630
+ /**
631
+ * The pending restart, if there is one.
632
+ *
633
+ * A restart goes FIRST: the shutdown waits for it — and for the sessions
634
+ * that resume with it. The intent survives the restart, so the machine goes
635
+ * off after everything is done, not before.
636
+ *
637
+ * @returns {{ pending: boolean, blockers: any[] }}
638
+ */
639
+ const readRestartPending = () => {
640
+ let verdict = null;
641
+ try { verdict = restartPending(); } catch { verdict = null; }
642
+ if (!verdict || typeof verdict !== 'object') return { pending: false, blockers: [] };
643
+ const blockers = Array.isArray(verdict.blockers) ? verdict.blockers : [];
644
+ return { pending: verdict.pending === true, blockers };
645
+ };
646
+
647
+ /**
648
+ * The grace window that is still open.
649
+ *
650
+ * The plugin only sees the DSH agents. Other plugins (a memory plugin
651
+ * that writes its notes a few seconds after the conversation ended, a
652
+ * summary, an MCP callback) queue their work with a timer of their own —
653
+ * work that is invisible here. The grace window is the extra wait that
654
+ * leaves them time.
655
+ *
656
+ * @returns {{ pending: boolean, remainingMs: number }}
657
+ */
658
+ const readGrace = () => {
659
+ let grace = 0;
660
+ try { grace = Number(graceMs()); } catch { grace = 0; }
661
+ if (!Number.isFinite(grace) || grace <= 0) return { pending: false, remainingMs: 0 };
662
+ let quiet = 0;
663
+ try { quiet = Number(quietForMs()); } catch { quiet = 0; }
664
+ if (!Number.isFinite(quiet) || quiet < 0) quiet = 0;
665
+ const remainingMs = Math.max(0, grace - quiet);
666
+ return { pending: remainingMs > 0, remainingMs };
667
+ };
668
+
573
669
  /** The blockers of a gate verdict, in the shape the UI expects. */
574
670
  const blockersFromVerdict = (verdict) => {
575
671
  const list = verdict && Array.isArray(verdict.blockedBy) ? verdict.blockedBy : [];
@@ -578,18 +674,51 @@ export function createSleepController({
578
674
  if (!entry || typeof entry !== 'object') continue;
579
675
  const sessionId = entry.sessionId == null ? '' : String(entry.sessionId);
580
676
  if (sessionId === '') continue;
581
- let title = '';
582
- try {
583
- const resolved = titleOf(sessionId);
584
- title = typeof resolved === 'string' ? resolved.trim() : '';
585
- } catch { /* a missing title is not a problem */ }
586
- out.push({ sessionId, title: title !== '' ? title : sessionId });
677
+ out.push(blockerEntry(entry, sessionId));
587
678
  }
588
679
  return out;
589
680
  };
590
681
 
591
- /** The human name of the busy conversations. */
592
- const names = (list) => list.map((entry) => entry.title || entry.sessionId).join('、');
682
+ /**
683
+ * The names of the blockers — but only when EVERY one of them has a title.
684
+ *
685
+ * A list of generic names ("会话、会话、会话") says nothing and looks like a
686
+ * bug, so a list with a nameless entry in it is left out entirely: the
687
+ * caller then writes the line without the parenthetical.
688
+ *
689
+ * @param {any[]} list
690
+ * @returns {string} the joined names, or '' when the list cannot be named
691
+ */
692
+ const names = (list) => {
693
+ const out = [];
694
+ for (const entry of Array.isArray(list) ? list : []) {
695
+ const label = entry && typeof entry.label === 'string' ? entry.label.trim() : '';
696
+ const name = entry && typeof entry.name === 'string' ? entry.name.trim() : '';
697
+ if (label === '' || name === '') return '';
698
+ out.push(name);
699
+ }
700
+ return out.join('、');
701
+ };
702
+
703
+ /**
704
+ * The line that says WHO the machine waits for.
705
+ *
706
+ * - every blocker has a title → the names are listed;
707
+ * - one blocker with a title → the singular form ("等「甲」收工");
708
+ * - otherwise → the plain count, without a parenthetical.
709
+ *
710
+ * @param {any[]} list the blockers
711
+ * @param {{ immediate?: boolean }} options `immediate` = no countdown at all
712
+ * @returns {string}
713
+ */
714
+ const waitingFor = (list, { immediate = false } = {}) => {
715
+ const count = Array.isArray(list) ? list.length : 0;
716
+ const tail = immediate ? '收工后立即关机。' : '收工后开始倒数。';
717
+ const named = names(list);
718
+ if (count === 1 && named !== '') return `等「${named}」收工,${tail}`;
719
+ if (count > 0 && named !== '') return `等 ${count} 个任务收工(${named}),${tail}`;
720
+ return `等 ${count} 个任务收工,${tail}`;
721
+ };
593
722
 
594
723
  /** Normalize whatever the shutdown handler returned into the reported shape. */
595
724
  const normalizeRun = (value) => {
@@ -652,24 +781,61 @@ export function createSleepController({
652
781
  if (!armed) return status();
653
782
  if (phase === 'shutting-down') return status();
654
783
  try {
784
+ // A pending restart goes FIRST. The shutdown waits for it — and for the
785
+ // sessions that resume with it. The intent survives the restart, so the
786
+ // machine goes off after everything is done.
787
+ const pendingRestart = readRestartPending();
788
+ if (pendingRestart.pending) {
789
+ phase = 'waiting';
790
+ blockers = pendingRestart.blockers;
791
+ countdownDone = false;
792
+ remainingMs = countdownMs;
793
+ message = '等这次重启完成后再关机……';
794
+ return status();
795
+ }
796
+
655
797
  const busy = listBusy();
656
798
  if (busy.length > 0) {
657
799
  const again = phase === 'countdown';
658
800
  phase = 'waiting';
659
801
  blockers = busy;
802
+ countdownDone = false;
660
803
  remainingMs = countdownMs;
661
804
  message = again
662
- ? `${names(busy)} 又开始干活了,倒数先停一下,等它们收工。`
663
- : (countdownMs > 0
664
- ? `等 ${busy.length} 个会话收工(${names(busy)}),收工后开始倒数。`
665
- : `等 ${busy.length} 个会话收工(${names(busy)}),收工后立即关机。`);
805
+ ? (names(busy) !== ''
806
+ ? `${names(busy)} 又开始干活了,倒数先停一下,等它们收工。`
807
+ : '又有任务开始干活了,倒数先停一下,等它们收工。')
808
+ : waitingFor(busy, { immediate: countdownMs === 0 });
809
+ return status();
810
+ }
811
+
812
+ // The grace window: the last conversation finished a moment ago, and
813
+ // other plugins still have work queued that this plugin cannot see (a
814
+ // memory plugin writing its notes, a summary). The countdown does not
815
+ // start before the grace is over — so the grace never pushes the
816
+ // countdown back, it only delays its start.
817
+ const grace = readGrace();
818
+ if (grace.pending) {
819
+ phase = 'waiting';
820
+ blockers = [];
821
+ remainingMs = countdownMs;
822
+ message = `再等 ${Math.ceil(grace.remainingMs / 1000)} 秒,给记忆/总结类插件留点时间……`;
666
823
  return status();
667
824
  }
668
825
 
669
826
  if (phase !== 'countdown') {
670
827
  phase = 'countdown';
671
- remainingMs = countdownMs;
672
- lastTickAt = now();
828
+ // The countdown runs: nobody blocks it any more.
829
+ blockers = [];
830
+ if (countdownDone) {
831
+ // The countdown is over and only the grace window was missing: the
832
+ // countdown does NOT start again, the machine goes off as soon as
833
+ // the grace is over (the gate below decides).
834
+ remainingMs = 0;
835
+ } else {
836
+ remainingMs = countdownMs;
837
+ lastTickAt = now();
838
+ }
673
839
  } else {
674
840
  const stamp = now();
675
841
  const elapsed = Math.max(TICK_MS, stamp - lastTickAt);
@@ -689,16 +855,25 @@ export function createSleepController({
689
855
  if (verdict.allow !== true) {
690
856
  phase = 'waiting';
691
857
  blockers = blockersFromVerdict(verdict);
692
- remainingMs = countdownMs;
693
858
  const reason = typeof verdict.reason === 'string' ? verdict.reason : '';
694
- if (reason === 'settling') {
695
- // The machine was not quiet long enough: say THAT, and do not claim
696
- // that somebody started working.
697
- message = '机器还没安静够久,再等一轮……';
698
- } else if (blockers.length > 0) {
699
- message = `倒数结束前又有人开始干活了(${names(blockers)}),关机先暂停,等所有会话收工。`;
859
+ if (reason === 'grace') {
860
+ // Only the grace window was missing: the countdown is over and
861
+ // stays over — the machine goes off when the grace is over.
862
+ countdownDone = true;
863
+ remainingMs = 0;
864
+ message = '最后一点收尾还没结束,再给记忆/总结类插件留几秒……';
700
865
  } else {
701
- message = '倒数结束时机器还没安静下来,关机先暂停,再等一轮。';
866
+ countdownDone = false;
867
+ remainingMs = countdownMs;
868
+ if (reason === 'settling') {
869
+ // The machine was not quiet long enough: say THAT, and do not claim
870
+ // that somebody started working.
871
+ message = '机器还没安静够久,再等一轮……';
872
+ } else if (blockers.length > 0) {
873
+ message = `倒数结束前又有人开始干活了${names(blockers) !== '' ? `(${names(blockers)})` : ''},关机先暂停,等所有会话收工。`;
874
+ } else {
875
+ message = '倒数结束时机器还没安静下来,关机先暂停,再等一轮。';
876
+ }
702
877
  }
703
878
  return status();
704
879
  }
@@ -737,9 +912,14 @@ export function createSleepController({
737
912
  remainingMs = 0;
738
913
  const planned = [run.command || '', ...run.args].filter((part) => part !== '').join(' ');
739
914
  message = `试运行:本来会执行 ${planned},这次不会真的关机。`;
915
+ disarm('dry-run');
916
+ return;
917
+ }
918
+ if (run.executed === true) {
919
+ // The machine is going off: the intent is spent.
920
+ disarm('shutdown-completed');
740
921
  return;
741
922
  }
742
- if (run.executed === true) return;
743
923
  if (run.error === 'gate-blocked') {
744
924
  // Somebody started working in the very last moment: wait for them —
745
925
  // and KEEP the list, so the waiting card can say WHO is still busy.
@@ -753,7 +933,7 @@ export function createSleepController({
753
933
  // that somebody started working.
754
934
  message = '机器还没安静够久,再等一轮……';
755
935
  } else if (blockers.length > 0) {
756
- message = `倒数结束时又有人开始干活了(${names(blockers)}),关机先暂停,等所有会话收工。`;
936
+ message = `倒数结束时又有人开始干活了${names(blockers) !== '' ? `(${names(blockers)})` : ''},关机先暂停,等所有会话收工。`;
757
937
  } else {
758
938
  message = '倒数结束时机器还没安静下来,关机先暂停,再等一轮。';
759
939
  }
@@ -765,6 +945,7 @@ export function createSleepController({
765
945
  blockers = [];
766
946
  remainingMs = 0;
767
947
  message = `关机命令没跑起来(${run.error || 'unknown error'}),电脑还开着,可以再点一次月亮按钮。`;
948
+ disarm('shutdown-failed');
768
949
  };
769
950
  if (result && typeof result.then === 'function') {
770
951
  result.then(
@@ -796,6 +977,7 @@ export function createSleepController({
796
977
  armed = true;
797
978
  phase = 'waiting';
798
979
  blockers = [];
980
+ countdownDone = false;
799
981
  remainingMs = countdownMs;
800
982
  lastTickAt = now();
801
983
  message = countdownMs > 0
@@ -812,8 +994,10 @@ export function createSleepController({
812
994
  armed = false;
813
995
  phase = 'cancelled';
814
996
  blockers = [];
997
+ countdownDone = false;
815
998
  remainingMs = 0;
816
999
  message = '收工关机已取消,电脑不会关机。';
1000
+ disarm('cancelled');
817
1001
  return status();
818
1002
  };
819
1003
 
package/locale/en.json CHANGED
@@ -24,6 +24,12 @@
24
24
  "value": "{n} seconds",
25
25
  "hint": "After the button: wait for every conversation to be done, count down this many seconds and switch off. \"No countdown\" switches off as soon as the conversations are done."
26
26
  },
27
+ "grace": {
28
+ "label": "Wait a few more seconds after the work is done",
29
+ "immediate": "Do not wait (restart/switch off right away)",
30
+ "value": "{n} seconds",
31
+ "hint": "After the last conversation finished, wait this long before the host is really restarted or switched off — this plugin only sees the DSH conversations, not the work other plugins queue with a timer. If a conversation starts working again while waiting, the timer starts over."
32
+ },
27
33
  "backup": {
28
34
  "label": "Backup before a restart",
29
35
  "hint": "Take a snapshot of the current configuration before every restart, so a host that cannot boot again is rolled back automatically."
@@ -81,7 +87,8 @@
81
87
  "waiting": {
82
88
  "title": "Waiting for everyone to finish",
83
89
  "body": "Once these sessions are done, the machine powers off:",
84
- "count": "{n} session(s) still working",
90
+ "count": "{n} task(s) still working",
91
+ "subagents": "{k} of them are subagents",
85
92
  "blocker": "· {title}",
86
93
  "none": "(checking who is still busy)",
87
94
  "cancel": "Keep it on"
package/locale/zh.json CHANGED
@@ -24,6 +24,12 @@
24
24
  "value": "{n} 秒",
25
25
  "hint": "按下按钮后,等所有会话收工,再倒数这么多秒才关机;「不倒数」表示会话一收工就关机。"
26
26
  },
27
+ "grace": {
28
+ "label": "收工后再等几秒(给记忆/总结类插件留时间)",
29
+ "immediate": "不等(立即重启/关机)",
30
+ "value": "{n} 秒",
31
+ "hint": "最后一个会话收工后,再等这么久才真正重启或关机——我们只看得见 DSH 的会话,看不见别的插件用定时器排队的活。等待期间如果又有会话开始干活,会重新计时。"
32
+ },
27
33
  "backup": {
28
34
  "label": "重启前备份",
29
35
  "hint": "每次重启前先快照一次当前配置;万一重启后起不来,能自动回退。"
@@ -81,7 +87,8 @@
81
87
  "waiting": {
82
88
  "title": "还在等大家收工",
83
89
  "body": "等这些会话忙完,就轮到关机啦:",
84
- "count": "还有 {n} 个会话在干活",
90
+ "count": "还有 {n} 个任务在干活",
91
+ "subagents": "其中 {k} 个是子智能体",
85
92
  "blocker": "· {title}",
86
93
  "none": "(正在确认还有谁在忙)",
87
94
  "cancel": "不关了"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-autorestart",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
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.",
6
6
  "license": "MIT",