dsh-autorestart 0.3.6 → 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/lib/sleep.js ADDED
@@ -0,0 +1,1012 @@
1
+ /**
2
+ * dsh-autorestart — the host half of the graceful shutdown ("收工关机").
3
+ *
4
+ * The feature waits until every conversation is idle, counts down on screen and
5
+ * then powers the REAL host machine off.
6
+ *
7
+ * The hard part is the host detection. When the Host runs inside WSL, shutting
8
+ * the distro down (or calling `wsl.exe --shutdown`) only stops the subsystem:
9
+ * Windows keeps running, so nothing was really switched off. This module
10
+ * therefore detects the host and always produces a command that reaches the
11
+ * physical machine:
12
+ *
13
+ * win32 → shutdown.exe /s /f /t 0
14
+ * linux inside WSL → /mnt/c/Windows/System32/shutdown.exe /s /f /t 0
15
+ * linux (real) → systemctl poweroff
16
+ * darwin → osascript -e 'tell application "System Events" to shut down'
17
+ * anything else → unsupported (NOTHING is ever executed)
18
+ *
19
+ * The countdown is ours, so the shutdown command never asks the operating
20
+ * system for another countdown of its own (`/t 0`).
21
+ *
22
+ * Everything in this module is pure or dependency-injected: the platform, the
23
+ * environment, the file probes, the clock, the spawner and the timer are all
24
+ * parameters. That is what lets the whole matrix be covered by unit tests
25
+ * without touching the machine.
26
+ *
27
+ * ESM only, Node builtins only. No DSH / npm imports.
28
+ */
29
+
30
+ import { spawn as spawnProcess } from 'node:child_process';
31
+ import fs from 'node:fs';
32
+
33
+ /** The Windows shutdown binary as it is reachable from WSL. */
34
+ export const WSL_SHUTDOWN_EXE = '/mnt/c/Windows/System32/shutdown.exe';
35
+
36
+ /** The file that tells a WSL kernel from a real Linux kernel. */
37
+ export const WSL_PROC_VERSION = '/proc/version';
38
+
39
+ /** How many seconds the countdown lasts by default. */
40
+ export const DEFAULT_COUNTDOWN_SECONDS = 20;
41
+
42
+ /**
43
+ * The countdown lengths the settings page offers (a dropdown).
44
+ *
45
+ * `0` means "no countdown at all": the machine is switched off as soon as
46
+ * every conversation is idle and the gate opens.
47
+ */
48
+ export const COUNTDOWN_CHOICES = Object.freeze([0, 10, 20, 30, 60]);
49
+
50
+ /** The shortest countdown the configuration may ask for (0 = no countdown). */
51
+ export const MIN_COUNTDOWN_SECONDS = 0;
52
+
53
+ /** The longest countdown the configuration may ask for. */
54
+ export const MAX_COUNTDOWN_SECONDS = 120;
55
+
56
+ /** How often the controller checks the state (one countdown step per second). */
57
+ export const TICK_MS = 1000;
58
+
59
+ /** The states the sleep controller can be in. */
60
+ export const SLEEP_PHASES = Object.freeze([
61
+ 'idle',
62
+ 'waiting',
63
+ 'countdown',
64
+ 'shutting-down',
65
+ 'cancelled',
66
+ 'unsupported',
67
+ ]);
68
+
69
+ /** @param {unknown} value */
70
+ function errorText(value) {
71
+ if (value && typeof value.message === 'string' && value.message) return value.message;
72
+ return String(value);
73
+ }
74
+
75
+ /** @param {unknown} value */
76
+ function nonEmptyString(value) {
77
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : '';
78
+ }
79
+
80
+ /**
81
+ * Clamp a countdown length into the supported range.
82
+ *
83
+ * @param {unknown} value
84
+ * @returns {number}
85
+ */
86
+ export function normalizeCountdownSeconds(value) {
87
+ const seconds = Number(value);
88
+ if (!Number.isFinite(seconds)) return DEFAULT_COUNTDOWN_SECONDS;
89
+ return Math.min(MAX_COUNTDOWN_SECONDS, Math.max(MIN_COUNTDOWN_SECONDS, Math.trunc(seconds)));
90
+ }
91
+
92
+ /**
93
+ * Snap a configured countdown onto one of the offered choices.
94
+ *
95
+ * A profile that still carries an old value (`15`, `45`, …) must not break:
96
+ * the nearest offered length wins, and a tie goes to the LONGER one
97
+ * (`15` → `20`, `45` → `60`). Anything that is not a number at all (a
98
+ * string that is not a number, `null`, `undefined`) falls back to the
99
+ * default.
100
+ *
101
+ * @param {unknown} value
102
+ * @returns {number} one of {@link COUNTDOWN_CHOICES}
103
+ */
104
+ export function snapCountdownChoice(value) {
105
+ if (typeof value !== 'number' && typeof value !== 'string') return DEFAULT_COUNTDOWN_SECONDS;
106
+ if (typeof value === 'string' && value.trim() === '') return DEFAULT_COUNTDOWN_SECONDS;
107
+ const seconds = Number(value);
108
+ if (!Number.isFinite(seconds)) return DEFAULT_COUNTDOWN_SECONDS;
109
+ let best = COUNTDOWN_CHOICES[0];
110
+ let bestDistance = Infinity;
111
+ for (const choice of COUNTDOWN_CHOICES) {
112
+ const distance = Math.abs(choice - seconds);
113
+ // `<=` so a tie goes to the LONGER choice (15 → 20, 45 → 60).
114
+ if (distance <= bestDistance) {
115
+ best = choice;
116
+ bestDistance = distance;
117
+ }
118
+ }
119
+ return best;
120
+ }
121
+
122
+ /**
123
+ * The evidence that this Linux is really a WSL distribution.
124
+ *
125
+ * Any ONE of them is enough:
126
+ * 1. `WSL_DISTRO_NAME` is set (WSL exports it into every process),
127
+ * 2. `WSL_INTEROP` is set (WSL exports it into every process),
128
+ * 3. `/proc/version` mentions "microsoft" (the WSL kernel says so),
129
+ * 4. the Windows shutdown binary is reachable under `/mnt/c`.
130
+ *
131
+ * @param {object} env
132
+ * @param {(file: string) => string} readFile
133
+ * @param {(file: string) => boolean} exists
134
+ * @returns {string|null} the name of the evidence, or null
135
+ */
136
+ export function detectWslEvidence(env, readFile, exists) {
137
+ const environ = env && typeof env === 'object' ? env : {};
138
+ if (nonEmptyString(environ.WSL_DISTRO_NAME) !== '') return 'WSL_DISTRO_NAME';
139
+ if (nonEmptyString(environ.WSL_INTEROP) !== '') return 'WSL_INTEROP';
140
+ let version = '';
141
+ try {
142
+ version = String(readFile(WSL_PROC_VERSION) || '');
143
+ } catch {
144
+ version = '';
145
+ }
146
+ if (version.toLowerCase().includes('microsoft')) return WSL_PROC_VERSION;
147
+ let found = false;
148
+ try {
149
+ found = exists(WSL_SHUTDOWN_EXE) === true;
150
+ } catch {
151
+ found = false;
152
+ }
153
+ if (found) return WSL_SHUTDOWN_EXE;
154
+ return null;
155
+ }
156
+
157
+ /**
158
+ * Detect the host and build the command that switches the physical machine off.
159
+ *
160
+ * The returned plan is a plain description: nothing is executed here.
161
+ *
162
+ * @param {{
163
+ * platform?: string,
164
+ * env?: object,
165
+ * readFile?: (file: string) => string,
166
+ * exists?: (file: string) => boolean,
167
+ * }} [options]
168
+ * @returns {{
169
+ * kind: 'windows'|'wsl'|'linux'|'macos'|'unsupported',
170
+ * label: string,
171
+ * source: string,
172
+ * command: string|null,
173
+ * args: string[],
174
+ * fallbacks: { command: string, args: string[] }[],
175
+ * platform: string,
176
+ * }}
177
+ */
178
+ export function detectShutdownPlan({
179
+ platform = process.platform,
180
+ env = process.env,
181
+ readFile,
182
+ exists,
183
+ } = {}) {
184
+ const plat = nonEmptyString(platform) || process.platform;
185
+ const readFileSafe = typeof readFile === 'function'
186
+ ? readFile
187
+ : (file) => fs.readFileSync(file, 'utf8');
188
+ const existsSafe = typeof exists === 'function'
189
+ ? exists
190
+ : (file) => fs.existsSync(file);
191
+
192
+ if (plat === 'win32') {
193
+ return {
194
+ kind: 'windows',
195
+ label: '关掉这台 Windows 电脑',
196
+ source: 'process.platform',
197
+ command: 'shutdown.exe',
198
+ // The countdown is ours, so Windows must not count down a second time.
199
+ args: ['/s', '/f', '/t', '0'],
200
+ fallbacks: [],
201
+ platform: plat,
202
+ };
203
+ }
204
+
205
+ if (plat === 'linux') {
206
+ const evidence = detectWslEvidence(env, readFileSafe, existsSafe);
207
+ if (evidence !== null) {
208
+ // The mounted Windows binary is only a HINT: a real Linux with a
209
+ // manually mounted C: drive looks exactly the same. When that is the
210
+ // only evidence, the plan says so instead of pretending to be sure.
211
+ const suspect = evidence === WSL_SHUTDOWN_EXE;
212
+ return {
213
+ kind: 'wsl',
214
+ label: suspect
215
+ ? '疑似 WSL(仅凭 /mnt/c 探测)→ 关掉整台 Windows'
216
+ : 'WSL 里的 DSH → 关掉整台 Windows',
217
+ source: suspect
218
+ ? `${WSL_SHUTDOWN_EXE}(疑似 WSL,仅凭 /mnt/c 探测)`
219
+ : evidence,
220
+ // The Windows binary, reached through the mounted C: drive: this shuts
221
+ // the whole machine down, not the WSL distribution.
222
+ command: WSL_SHUTDOWN_EXE,
223
+ args: ['/s', '/f', '/t', '0'],
224
+ fallbacks: [
225
+ { command: 'powershell.exe', args: ['-NoProfile', '-Command', 'Stop-Computer -Force'] },
226
+ { command: 'cmd.exe', args: ['/c', 'shutdown.exe', '/s', '/f', '/t', '0'] },
227
+ ],
228
+ // True when the plan rests on the mounted drive alone.
229
+ suspect,
230
+ platform: plat,
231
+ };
232
+ }
233
+ return {
234
+ kind: 'linux',
235
+ label: '关闭这台 Linux 主机',
236
+ source: 'process.platform',
237
+ command: 'systemctl',
238
+ args: ['poweroff'],
239
+ fallbacks: [{ command: 'shutdown', args: ['-h', 'now'] }],
240
+ platform: plat,
241
+ };
242
+ }
243
+
244
+ if (plat === 'darwin') {
245
+ return {
246
+ kind: 'macos',
247
+ label: '关闭这台 Mac',
248
+ source: 'process.platform',
249
+ command: 'osascript',
250
+ args: ['-e', 'tell application "System Events" to shut down'],
251
+ fallbacks: [],
252
+ platform: plat,
253
+ };
254
+ }
255
+
256
+ return {
257
+ kind: 'unsupported',
258
+ label: '这台机器没有可靠的关机方式',
259
+ source: 'process.platform',
260
+ command: null,
261
+ args: [],
262
+ fallbacks: [],
263
+ platform: plat,
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Apply the configured custom command to a plan.
269
+ *
270
+ * A non-empty `sleepCommand` wins over the detected one: on Windows it runs
271
+ * through `cmd.exe /c`, everywhere else through `/bin/sh -c`. The override is
272
+ * recorded in `source` so the UI can say that the automatic detection was
273
+ * replaced by the operator's own command.
274
+ *
275
+ * @param {object} plan the detected plan
276
+ * @param {{ sleepCommand?: string, platform?: string }} [options]
277
+ * @returns {object} the plan that will actually be executed
278
+ */
279
+ export function planInvocation(plan, { sleepCommand = '', platform = process.platform } = {}) {
280
+ const base = plan && typeof plan === 'object' ? plan : {};
281
+ const command = nonEmptyString(sleepCommand);
282
+ if (command === '') return { ...base };
283
+ const plat = nonEmptyString(platform) || base.platform || process.platform;
284
+ const windows = plat === 'win32';
285
+ return {
286
+ ...base,
287
+ command: windows ? 'cmd.exe' : '/bin/sh',
288
+ args: windows ? ['/c', command] : ['-c', command],
289
+ source: 'sleepCommand',
290
+ detectedSource: typeof base.source === 'string' ? base.source : '',
291
+ overridden: true,
292
+ };
293
+ }
294
+
295
+ /**
296
+ * How long a command may take to really start before it counts as failed (ms).
297
+ *
298
+ * `spawn()` reports a failure through the child's `error` event, so the answer
299
+ * only arrives asynchronously. The timeout is the safety net for a spawner
300
+ * that never answers at all.
301
+ */
302
+ export const SPAWN_TIMEOUT_MS = 5000;
303
+
304
+ /**
305
+ * Start ONE command and wait for the real answer.
306
+ *
307
+ * `spawn` means the command really started; `error` means it did not (the
308
+ * binary is missing, the permission is denied, ...). Nothing is swallowed:
309
+ * the caller learns which one of the two happened.
310
+ *
311
+ * @param {string} command
312
+ * @param {string[]} args
313
+ * @param {{
314
+ * spawn?: Function,
315
+ * log?: { warn?: Function },
316
+ * timeoutMs?: number,
317
+ * setTimeout?: Function,
318
+ * clearTimeout?: Function,
319
+ * }} [options]
320
+ * @returns {Promise<{ ok: boolean, pid: number|null, error: string|null }>}
321
+ */
322
+ function startCommand(command, args, {
323
+ spawn = spawnProcess,
324
+ timeoutMs = SPAWN_TIMEOUT_MS,
325
+ setTimeout: setTimeoutFn = setTimeout,
326
+ clearTimeout: clearTimeoutFn = clearTimeout,
327
+ } = {}) {
328
+ return new Promise((resolve) => {
329
+ let child = null;
330
+ try {
331
+ child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
332
+ } catch (error) {
333
+ resolve({ ok: false, pid: null, error: `spawn-failed: ${errorText(error)}` });
334
+ return;
335
+ }
336
+ const pid = child && Number.isFinite(child.pid) ? child.pid : null;
337
+ if (!child || typeof child.on !== 'function') {
338
+ // A spawner without events (a test double) counts as started.
339
+ try {
340
+ if (child && typeof child.unref === 'function') child.unref();
341
+ } catch { /* nothing else to do */ }
342
+ resolve({ ok: true, pid, error: null });
343
+ return;
344
+ }
345
+ let settled = false;
346
+ let timer = null;
347
+ const finish = (ok, error) => {
348
+ if (settled) return;
349
+ settled = true;
350
+ if (timer !== null) {
351
+ try {
352
+ clearTimeoutFn(timer);
353
+ } catch { /* nothing else to do */ }
354
+ timer = null;
355
+ }
356
+ resolve({ ok, pid, error });
357
+ };
358
+ child.on('spawn', () => {
359
+ try {
360
+ if (child && typeof child.unref === 'function') child.unref();
361
+ } catch { /* nothing else to do */ }
362
+ finish(true, null);
363
+ });
364
+ child.on('error', (error) => {
365
+ finish(false, `spawn-failed: ${errorText(error)}`);
366
+ });
367
+ try {
368
+ timer = setTimeoutFn(() => {
369
+ finish(false, `spawn-timeout: ${command} did not start within ${timeoutMs}ms`);
370
+ }, timeoutMs);
371
+ if (timer && typeof timer.unref === 'function') timer.unref();
372
+ } catch { /* the timeout is only a safety net */ }
373
+ });
374
+ }
375
+
376
+ /**
377
+ * Execute the shutdown command, detached from the host.
378
+ *
379
+ * The gate is checked ONE more time right before the command runs: a session
380
+ * that started working in the meantime must be able to stop the shutdown.
381
+ *
382
+ * The command is really started, not merely requested: the answer arrives
383
+ * with the child's `spawn` event, and a command that does not start is
384
+ * reported as a FAILURE (`executed: false`). When the main command does not
385
+ * start, the plan's fallbacks are tried one after another (the WSL plan
386
+ * reaches Windows through two of them), and the first one that starts wins.
387
+ *
388
+ * With `dryRun` nothing is executed at all: the command that WOULD run is
389
+ * reported instead.
390
+ *
391
+ * @param {object} plan the plan to execute
392
+ * @param {{
393
+ * spawn?: Function,
394
+ * dryRun?: boolean,
395
+ * log?: { warn?: Function, info?: Function },
396
+ * gate?: () => { allow?: boolean } | null,
397
+ * timeoutMs?: number,
398
+ * }} [options]
399
+ * @returns {Promise<{
400
+ * ok: boolean,
401
+ * at: string,
402
+ * command: string|null,
403
+ * args: string[],
404
+ * executed: boolean,
405
+ * pid: number|null,
406
+ * error: string|null,
407
+ * dryRun?: boolean,
408
+ * }>}
409
+ */
410
+ export function runShutdown(plan, {
411
+ spawn = spawnProcess,
412
+ dryRun = false,
413
+ log = console,
414
+ gate = null,
415
+ timeoutMs = SPAWN_TIMEOUT_MS,
416
+ } = {}) {
417
+ const at = new Date().toISOString();
418
+ const source = plan && typeof plan === 'object' ? plan : {};
419
+ const command = nonEmptyString(source.command);
420
+ const args = Array.isArray(source.args) ? source.args.map((entry) => String(entry)) : [];
421
+ if (command === '') {
422
+ return Promise.resolve({ ok: false, at, command: null, args: [], executed: false, pid: null, error: 'no-shutdown-command' });
423
+ }
424
+
425
+ if (typeof gate === 'function') {
426
+ let verdict = null;
427
+ try {
428
+ verdict = gate();
429
+ } catch (error) {
430
+ verdict = null;
431
+ if (log && typeof log.warn === 'function') log.warn('the sleep gate failed:', errorText(error));
432
+ }
433
+ if (!verdict || verdict.allow !== true) {
434
+ return Promise.resolve({ ok: false, at, command, args, executed: false, pid: null, error: 'gate-blocked' });
435
+ }
436
+ }
437
+
438
+ if (dryRun === true) {
439
+ return Promise.resolve({ ok: true, at, command, args, executed: false, pid: null, error: null, dryRun: true });
440
+ }
441
+
442
+ // The main command first, then the fallbacks in the order of the plan.
443
+ const attempts = [{ command, args }];
444
+ const fallbacks = Array.isArray(source.fallbacks) ? source.fallbacks : [];
445
+ for (const fallback of fallbacks) {
446
+ if (!fallback || typeof fallback !== 'object') continue;
447
+ const fallbackCommand = nonEmptyString(fallback.command);
448
+ if (fallbackCommand === '') continue;
449
+ attempts.push({
450
+ command: fallbackCommand,
451
+ args: Array.isArray(fallback.args) ? fallback.args.map((entry) => String(entry)) : [],
452
+ });
453
+ }
454
+
455
+ return (async () => {
456
+ let lastError = 'no-shutdown-command';
457
+ for (const attempt of attempts) {
458
+ const outcome = await startCommand(attempt.command, attempt.args, { spawn, timeoutMs });
459
+ if (outcome.ok) {
460
+ return {
461
+ ok: true,
462
+ at,
463
+ command: attempt.command,
464
+ args: attempt.args,
465
+ executed: true,
466
+ pid: outcome.pid,
467
+ error: null,
468
+ };
469
+ }
470
+ lastError = outcome.error;
471
+ if (log && typeof log.warn === 'function') {
472
+ log.warn(`the sleep command ${attempt.command} did not start:`, outcome.error);
473
+ }
474
+ }
475
+ return { ok: false, at, command, args, executed: false, pid: null, error: lastError };
476
+ })();
477
+ }
478
+
479
+ /**
480
+ * The state machine behind the graceful sleep.
481
+ *
482
+ * `idle → waiting → countdown → shutting-down`, with `cancelled` and
483
+ * `unsupported` as the two ways out. The controller never decides on its own
484
+ * whether a conversation is busy: it asks the injected `busyAgents` and the
485
+ * injected `gate`, so the sleep feature and the restart feature always agree
486
+ * on what "idle" means.
487
+ *
488
+ * Every tick is wrapped in a try/catch: a broken tick must never take the
489
+ * host down.
490
+ *
491
+ * @param {{
492
+ * now?: () => number,
493
+ * busyAgents?: () => { sessionId: string, reason?: string }[],
494
+ * gate?: () => { allow?: boolean, blockedBy?: any[] },
495
+ * shutdown?: () => any,
496
+ * countdownSeconds?: number,
497
+ * log?: { warn?: Function, info?: Function },
498
+ * setInterval?: (fn: () => void, ms: number) => any,
499
+ * clearInterval?: (handle: any) => void,
500
+ * titleOf?: (sessionId: string) => string|undefined,
501
+ * quietForMs?: () => number,
502
+ * graceMs?: () => number,
503
+ * restartPending?: () => { pending: boolean, blockers?: any[] },
504
+ * onDisarm?: (reason: string) => void,
505
+ * hostLabel?: string,
506
+ * supported?: boolean,
507
+ * }} [options]
508
+ */
509
+ export function createSleepController({
510
+ now = () => Date.now(),
511
+ busyAgents = () => [],
512
+ gate = () => ({ allow: true, reason: 'quiet', blockedBy: [] }),
513
+ shutdown = () => ({ ok: false, error: 'no-shutdown-handler' }),
514
+ countdownSeconds = DEFAULT_COUNTDOWN_SECONDS,
515
+ log = console,
516
+ setInterval: setIntervalFn = setInterval,
517
+ clearInterval: clearIntervalFn = clearInterval,
518
+ titleOf = () => '',
519
+ quietForMs = () => 0,
520
+ graceMs = () => 0,
521
+ restartPending = () => ({ pending: false, blockers: [] }),
522
+ onDisarm = () => {},
523
+ hostLabel = '',
524
+ supported = true,
525
+ } = {}) {
526
+ const seconds = normalizeCountdownSeconds(countdownSeconds);
527
+ const countdownMs = seconds * 1000;
528
+ const target = nonEmptyString(hostLabel) || '关掉这台电脑';
529
+
530
+ let phase = supported ? 'idle' : 'unsupported';
531
+ let armed = false;
532
+ let blockers = [];
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;
537
+ let lastRun = null;
538
+ let message = supported
539
+ ? '还没点「收工关机」:所有会话收工后倒数几秒,就关掉这台电脑。'
540
+ : '这台机器没有可靠的关机方式,「收工关机」按钮不可用。';
541
+ let timer = null;
542
+ let disposed = false;
543
+ let lastTickAt = now();
544
+
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. */
585
+ const listBusy = () => {
586
+ let list = [];
587
+ try {
588
+ list = busyAgents();
589
+ } catch (error) {
590
+ if (log && typeof log.warn === 'function') log.warn('could not read the busy conversations:', errorText(error));
591
+ list = [];
592
+ }
593
+ if (!Array.isArray(list)) return [];
594
+ const out = [];
595
+ for (const entry of list) {
596
+ if (!entry || typeof entry !== 'object') continue;
597
+ const sessionId = entry.sessionId == null ? '' : String(entry.sessionId);
598
+ if (sessionId === '') continue;
599
+ out.push(blockerEntry(entry, sessionId));
600
+ }
601
+ return out;
602
+ };
603
+
604
+ /** The gate verdict; a broken gate denies, it never opens. */
605
+ const readGate = () => {
606
+ try {
607
+ const verdict = gate();
608
+ if (verdict && typeof verdict === 'object') return verdict;
609
+ return { allow: false, reason: 'gate-unavailable', blockedBy: [] };
610
+ } catch (error) {
611
+ if (log && typeof log.warn === 'function') log.warn('the sleep gate failed:', errorText(error));
612
+ return { allow: false, reason: 'gate-error', blockedBy: [] };
613
+ }
614
+ };
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
+
669
+ /** The blockers of a gate verdict, in the shape the UI expects. */
670
+ const blockersFromVerdict = (verdict) => {
671
+ const list = verdict && Array.isArray(verdict.blockedBy) ? verdict.blockedBy : [];
672
+ const out = [];
673
+ for (const entry of list) {
674
+ if (!entry || typeof entry !== 'object') continue;
675
+ const sessionId = entry.sessionId == null ? '' : String(entry.sessionId);
676
+ if (sessionId === '') continue;
677
+ out.push(blockerEntry(entry, sessionId));
678
+ }
679
+ return out;
680
+ };
681
+
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
+ };
722
+
723
+ /** Normalize whatever the shutdown handler returned into the reported shape. */
724
+ const normalizeRun = (value) => {
725
+ const run = value && typeof value === 'object' ? value : {};
726
+ const normalized = {
727
+ at: typeof run.at === 'string' ? run.at : new Date(now()).toISOString(),
728
+ command: typeof run.command === 'string' ? run.command : null,
729
+ args: Array.isArray(run.args) ? run.args.map((entry) => String(entry)) : [],
730
+ executed: run.executed === true,
731
+ pid: Number.isFinite(run.pid) ? run.pid : null,
732
+ error: run.error == null ? null : String(run.error),
733
+ };
734
+ // A dry run is reported as such, because the UI has to know that the
735
+ // command was NOT executed. A real run keeps its shape untouched.
736
+ if (run.dryRun === true) normalized.dryRun = true;
737
+ return normalized;
738
+ };
739
+
740
+ /** The current state of the machine. */
741
+ const status = () => ({
742
+ phase,
743
+ armed,
744
+ countdownSeconds: seconds,
745
+ remainingMs,
746
+ blockers: blockers.map((entry) => ({ ...entry })),
747
+ message,
748
+ lastRun: lastRun === null ? null : { ...lastRun },
749
+ });
750
+
751
+ const stopTimer = () => {
752
+ if (timer === null) return;
753
+ const handle = timer;
754
+ timer = null;
755
+ try {
756
+ clearIntervalFn(handle);
757
+ } catch (error) {
758
+ if (log && typeof log.warn === 'function') log.warn('could not stop the sleep timer:', errorText(error));
759
+ }
760
+ };
761
+
762
+ const startTimer = () => {
763
+ if (timer !== null) return;
764
+ try {
765
+ timer = setIntervalFn(() => {
766
+ void tick();
767
+ }, TICK_MS);
768
+ } catch (error) {
769
+ timer = null;
770
+ if (log && typeof log.warn === 'function') log.warn('could not start the sleep timer:', errorText(error));
771
+ }
772
+ };
773
+
774
+ /**
775
+ * One step of the state machine.
776
+ *
777
+ * Never throws: a broken tick is logged and the state is left as it is.
778
+ */
779
+ const tick = () => {
780
+ if (disposed) return status();
781
+ if (!armed) return status();
782
+ if (phase === 'shutting-down') return status();
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
+
797
+ const busy = listBusy();
798
+ if (busy.length > 0) {
799
+ const again = phase === 'countdown';
800
+ phase = 'waiting';
801
+ blockers = busy;
802
+ countdownDone = false;
803
+ remainingMs = countdownMs;
804
+ message = again
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)} 秒,给记忆/总结类插件留点时间……`;
823
+ return status();
824
+ }
825
+
826
+ if (phase !== 'countdown') {
827
+ phase = 'countdown';
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
+ }
839
+ } else {
840
+ const stamp = now();
841
+ const elapsed = Math.max(TICK_MS, stamp - lastTickAt);
842
+ lastTickAt = stamp;
843
+ remainingMs = Math.max(0, remainingMs - elapsed);
844
+ }
845
+ // A countdown of zero seconds is no countdown at all: the gate is asked
846
+ // right away and the machine is switched off the moment it opens.
847
+ message = countdownMs > 0
848
+ ? `倒数 ${Math.ceil(remainingMs / 1000)} 秒后${target}……`
849
+ : '0 秒:立即关机。';
850
+ if (remainingMs > 0) return status();
851
+
852
+ // The countdown is over: the gate is checked ONE more time, so a
853
+ // conversation that started working in the meantime still wins.
854
+ const verdict = readGate();
855
+ if (verdict.allow !== true) {
856
+ phase = 'waiting';
857
+ blockers = blockersFromVerdict(verdict);
858
+ const reason = typeof verdict.reason === 'string' ? verdict.reason : '';
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 = '最后一点收尾还没结束,再给记忆/总结类插件留几秒……';
865
+ } else {
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
+ }
877
+ }
878
+ return status();
879
+ }
880
+
881
+ phase = 'shutting-down';
882
+ stopTimer();
883
+ // A countdown of zero seconds says so in the message too: the user
884
+ // must see that the machine went off without any countdown.
885
+ message = countdownMs > 0
886
+ ? `正在${target}……`
887
+ : '0 秒:立即关机,正在关掉这台电脑。';
888
+ let result = null;
889
+ try {
890
+ result = shutdown();
891
+ } catch (error) {
892
+ result = { ok: false, error: errorText(error) };
893
+ }
894
+ /**
895
+ * The phase only says "shutting down" when the command really started:
896
+ * a command that did not start leaves the machine alone, and the state
897
+ * must never keep saying "正在关机" (the UI would then show a screen
898
+ * without a way out).
899
+ */
900
+ const applyRun = (value) => {
901
+ const raw = value && typeof value === 'object' ? value : {};
902
+ const run = normalizeRun(value);
903
+ lastRun = run;
904
+ // A dry run reports the command it WOULD run: nothing was executed, and
905
+ // the state must NOT keep saying "shutting down" — the UI would then
906
+ // show a screen without a way out (the countdown is over, the moon
907
+ // button is covered, and there is no cancel button on that screen).
908
+ if (raw.dryRun === true) {
909
+ phase = 'idle';
910
+ armed = false;
911
+ blockers = [];
912
+ remainingMs = 0;
913
+ const planned = [run.command || '', ...run.args].filter((part) => part !== '').join(' ');
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');
921
+ return;
922
+ }
923
+ if (run.error === 'gate-blocked') {
924
+ // Somebody started working in the very last moment: wait for them —
925
+ // and KEEP the list, so the waiting card can say WHO is still busy.
926
+ const verdict = readGate();
927
+ phase = 'waiting';
928
+ blockers = blockersFromVerdict(verdict);
929
+ remainingMs = countdownMs;
930
+ const reason = typeof verdict.reason === 'string' ? verdict.reason : '';
931
+ if (reason === 'settling') {
932
+ // The machine was not quiet long enough: say THAT, and do not claim
933
+ // that somebody started working.
934
+ message = '机器还没安静够久,再等一轮……';
935
+ } else if (blockers.length > 0) {
936
+ message = `倒数结束时又有人开始干活了${names(blockers) !== '' ? `(${names(blockers)})` : ''},关机先暂停,等所有会话收工。`;
937
+ } else {
938
+ message = '倒数结束时机器还没安静下来,关机先暂停,再等一轮。';
939
+ }
940
+ startTimer();
941
+ return;
942
+ }
943
+ phase = 'idle';
944
+ armed = false;
945
+ blockers = [];
946
+ remainingMs = 0;
947
+ message = `关机命令没跑起来(${run.error || 'unknown error'}),电脑还开着,可以再点一次月亮按钮。`;
948
+ disarm('shutdown-failed');
949
+ };
950
+ if (result && typeof result.then === 'function') {
951
+ result.then(
952
+ (value) => { applyRun(value); },
953
+ (error) => { applyRun({ ok: false, error: errorText(error) }); },
954
+ );
955
+ } else {
956
+ applyRun(result);
957
+ }
958
+ return status();
959
+ } catch (error) {
960
+ // A tick must never take the host down.
961
+ if (log && typeof log.warn === 'function') log.warn('the sleep tick failed:', errorText(error));
962
+ return status();
963
+ }
964
+ };
965
+
966
+ /** Arm the feature: wait for the conversations, then count down, then shut down. */
967
+ const arm = () => {
968
+ if (disposed) return status();
969
+ if (!supported) {
970
+ armed = false;
971
+ phase = 'unsupported';
972
+ blockers = [];
973
+ remainingMs = 0;
974
+ message = '这台机器没有可靠的关机方式,「收工关机」按钮不可用。';
975
+ return status();
976
+ }
977
+ armed = true;
978
+ phase = 'waiting';
979
+ blockers = [];
980
+ countdownDone = false;
981
+ remainingMs = countdownMs;
982
+ lastTickAt = now();
983
+ message = countdownMs > 0
984
+ ? '已武装,等所有会话收工后开始倒数。'
985
+ : '已武装:等所有会话收工后立即关机。';
986
+ startTimer();
987
+ tick();
988
+ return status();
989
+ };
990
+
991
+ /** Cancel everything: no timer may survive the cancellation. */
992
+ const cancel = () => {
993
+ stopTimer();
994
+ armed = false;
995
+ phase = 'cancelled';
996
+ blockers = [];
997
+ countdownDone = false;
998
+ remainingMs = 0;
999
+ message = '收工关机已取消,电脑不会关机。';
1000
+ disarm('cancelled');
1001
+ return status();
1002
+ };
1003
+
1004
+ /** Release the timer when the plugin is unloaded. */
1005
+ const dispose = () => {
1006
+ disposed = true;
1007
+ stopTimer();
1008
+ armed = false;
1009
+ };
1010
+
1011
+ return { arm, tick, cancel, status, dispose };
1012
+ }