dsh-autorestart 0.5.0 → 0.6.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/README.md +108 -5
- package/client.js +158 -20
- package/cordis.patch.yml +8 -0
- package/docs/README.zh.md +63 -6
- package/index.js +454 -27
- package/lib/checkpoint.js +78 -0
- package/lib/sleep.js +217 -33
- package/locale/en.json +8 -1
- package/locale/zh.json +8 -1
- package/package.json +1 -1
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/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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
592
|
-
|
|
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
|
-
?
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
672
|
-
|
|
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 === '
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
message =
|
|
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
|
-
|
|
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 =
|
|
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}
|
|
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.
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|