dsh-autorestart 0.6.2 → 0.8.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 +460 -12
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
- package/assets/screenshot-4.png +0 -0
- package/assets/screenshot-5.png +0 -0
- package/client.js +6329 -3863
- package/cordis.patch.yml +44 -1
- package/docs/README.zh.md +297 -9
- package/docs/platform-matrix.md +92 -0
- package/docs/rescue-drill.md +606 -0
- package/index.js +2867 -199
- package/lib/access.js +212 -0
- package/lib/autostart-advice.js +198 -0
- package/lib/backup.js +131 -4
- package/lib/blockers.js +228 -0
- package/lib/checkpoint.js +147 -0
- package/lib/diagnostics.js +188 -0
- package/lib/health.js +264 -0
- package/lib/host-kind.js +145 -0
- package/lib/node-runtime.js +144 -0
- package/lib/rescue-paths.js +253 -0
- package/lib/rescue.js +596 -0
- package/lib/restart-verdict.js +327 -0
- package/lib/restart.js +823 -56
- package/lib/scheduler-capability.js +300 -0
- package/lib/sleep.js +46 -49
- package/lib/startup-mode.js +491 -0
- package/lib/supervisor-config.js +735 -0
- package/lib/supervisor-install.js +557 -0
- package/lib/supervisor.js +1276 -0
- package/lib/watchdog-arbiter.js +117 -0
- package/lib/watchdog-schedule.js +310 -0
- package/lib/watchdog-tier.js +182 -0
- package/lib/watchdog.js +479 -0
- package/locale/en.json +274 -64
- package/locale/zh.json +274 -64
- package/package.json +7 -2
- package/screenshots.json +6 -4
- package/scripts/README.md +65 -0
- package/scripts/drill-rescue.ps1 +508 -0
- package/scripts/emergency-disable.cmd +27 -0
- package/scripts/emergency-toggle.ps1 +373 -0
- package/scripts/restore-profile.cmd +44 -0
- package/scripts/restore-profile.ps1 +608 -0
- package/scripts/run-tests.ps1 +46 -0
- package/supervisor/supervisor.js +884 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CAN THIS MACHINE CREATE A ONE-OFF SCHEDULED TASK?
|
|
3
|
+
*
|
|
4
|
+
* The automatic rollback of this plugin works through a scheduled task: the
|
|
5
|
+
* host is restarted, and a task that fires a minute later brings it back if
|
|
6
|
+
* it did not come up. On a machine where such a task cannot be created
|
|
7
|
+
* (a corporate policy, a machine without the rights, a locked-down service
|
|
8
|
+
* account) that safety net is silently missing — and the user has to be told
|
|
9
|
+
* what they lose.
|
|
10
|
+
*
|
|
11
|
+
* WHAT THIS DOES NOT DO (on purpose):
|
|
12
|
+
*
|
|
13
|
+
* - it does NOT design a fallback chain ("use the startup folder instead",
|
|
14
|
+
* "use a systemd timer instead", ...): the user decided against it;
|
|
15
|
+
* - it does NOT try to bypass the policy ("maybe another way works"): if
|
|
16
|
+
* the machine says no, the answer is no.
|
|
17
|
+
*
|
|
18
|
+
* It only DETECTS, once, and tells the truth.
|
|
19
|
+
*
|
|
20
|
+
* THE PROBE NEVER LEAVES A TASK BEHIND:
|
|
21
|
+
*
|
|
22
|
+
* 1. a task with a unique name is registered (it fires in two minutes and
|
|
23
|
+
* does nothing: `cmd /c exit 0`);
|
|
24
|
+
* 2. it is read back and verified (the trigger is the one we asked for);
|
|
25
|
+
* 3. it is deleted;
|
|
26
|
+
* 4. it is read back AGAIN — the task must be gone.
|
|
27
|
+
*
|
|
28
|
+
* The verification is the XML of the task, not its human readable listing:
|
|
29
|
+
* the XML has no translated words, so the check does not depend on the
|
|
30
|
+
* language of the machine ("Ready" in English, "就绪" in Chinese).
|
|
31
|
+
*
|
|
32
|
+
* Everything that touches the system is injected, so a test never has to
|
|
33
|
+
* create a real task.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { execFileSync } from 'node:child_process';
|
|
37
|
+
|
|
38
|
+
/** The scheduler works on this machine. */
|
|
39
|
+
export const SCHEDULER_OK = 'ok';
|
|
40
|
+
/** The scheduler is refused here — the automatic rollback is not available. */
|
|
41
|
+
export const SCHEDULER_BLOCKED = 'blocked';
|
|
42
|
+
/** Nobody checked yet. */
|
|
43
|
+
export const SCHEDULER_UNKNOWN = 'unknown';
|
|
44
|
+
|
|
45
|
+
/** The name of the probe task. It is unique per run, so two probes cannot collide. */
|
|
46
|
+
export const PROBE_TASK_PREFIX = 'dsh-autorestart-capability-probe';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The name of the probe task for this run.
|
|
50
|
+
*
|
|
51
|
+
* @param {number} now
|
|
52
|
+
* @param {number|string} pid
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function probeTaskName(now = Date.now(), pid = 0) {
|
|
56
|
+
const stamp = Math.trunc(Number(now) || 0).toString(36);
|
|
57
|
+
const who = String(pid === 0 ? '0' : pid).replace(/[^A-Za-z0-9]/g, '');
|
|
58
|
+
return `${PROBE_TASK_PREFIX}-${who}-${stamp}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The trigger of the probe: two minutes from now, in the formats schtasks
|
|
63
|
+
* wants and in the format the XML reports.
|
|
64
|
+
*
|
|
65
|
+
* @param {number} now
|
|
66
|
+
* @returns {{ sd: string, st: string, iso: string }}
|
|
67
|
+
*/
|
|
68
|
+
export function probeTrigger(now = Date.now()) {
|
|
69
|
+
const date = new Date(Math.trunc(Number(now) || 0) + 2 * 60 * 1000);
|
|
70
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
71
|
+
const yyyy = date.getFullYear();
|
|
72
|
+
const mm = pad(date.getMonth() + 1);
|
|
73
|
+
const dd = pad(date.getDate());
|
|
74
|
+
const hh = pad(date.getHours());
|
|
75
|
+
const mi = pad(date.getMinutes());
|
|
76
|
+
return { sd: `${yyyy}/${mm}/${dd}`, st: `${hh}:${mi}`, iso: `${yyyy}-${mm}-${dd}T${hh}:${mi}` };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The default executor: run a command and report what it said.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} file
|
|
83
|
+
* @param {string[]} args
|
|
84
|
+
* @returns {{ ok: boolean, stdout: string, error: string|null }}
|
|
85
|
+
*/
|
|
86
|
+
function defaultExec(file, args) {
|
|
87
|
+
try {
|
|
88
|
+
const stdout = execFileSync(file, args, { encoding: 'utf8', timeout: 20000, windowsHide: true });
|
|
89
|
+
return { ok: true, stdout: typeof stdout === 'string' ? stdout : '', error: null };
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
stdout: error && typeof error.stdout === 'string' ? error.stdout : '',
|
|
94
|
+
error: error && error.message ? error.message : String(error),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The trigger of the task, read out of its XML.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} xml
|
|
103
|
+
* @returns {string} the ISO start boundary, or an empty string
|
|
104
|
+
*/
|
|
105
|
+
export function readStartBoundary(xml) {
|
|
106
|
+
const text = typeof xml === 'string' ? xml : '';
|
|
107
|
+
const match = /<StartBoundary>([^<]+)<\/StartBoundary>/i.exec(text);
|
|
108
|
+
return match ? match[1].trim() : '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The trigger of the task, read out of its XML, compared with what we asked
|
|
113
|
+
* for (the XML writes seconds, we asked without them).
|
|
114
|
+
*
|
|
115
|
+
* @param {string} xml
|
|
116
|
+
* @param {string} expectedIso
|
|
117
|
+
* @returns {boolean}
|
|
118
|
+
*/
|
|
119
|
+
export function triggerMatches(xml, expectedIso) {
|
|
120
|
+
const boundary = readStartBoundary(xml);
|
|
121
|
+
if (boundary === '') return false;
|
|
122
|
+
return boundary.slice(0, 16) === String(expectedIso).slice(0, 16);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Would the task fire?
|
|
127
|
+
*
|
|
128
|
+
* The XML does NOT always carry an `<Enabled>` element: a task without one is
|
|
129
|
+
* enabled (that is the default), and only an explicit `false` disables it.
|
|
130
|
+
* (A real run on a Chinese Windows answered with a `<TimeTrigger>` that has
|
|
131
|
+
* no `<Enabled>` at all — a check that demanded `<Enabled>true</Enabled>` would
|
|
132
|
+
* have called a perfectly working machine "blocked".)
|
|
133
|
+
*
|
|
134
|
+
* @param {string} xml
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
export function taskEnabled(xml) {
|
|
138
|
+
const text = typeof xml === 'string' ? xml : '';
|
|
139
|
+
return !/<Enabled>false<\/Enabled>/i.test(text);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Does the task carry an action (is it a real one-off task)?
|
|
144
|
+
*
|
|
145
|
+
* @param {string} xml
|
|
146
|
+
* @returns {boolean}
|
|
147
|
+
*/
|
|
148
|
+
export function taskHasAction(xml) {
|
|
149
|
+
const text = typeof xml === 'string' ? xml : '';
|
|
150
|
+
return /<Command>/i.test(text);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* THE PROBE.
|
|
155
|
+
*
|
|
156
|
+
* @param {{
|
|
157
|
+
* platform?: string,
|
|
158
|
+
* exec?: (file: string, args: string[]) => { ok: boolean, stdout: string, error: string|null },
|
|
159
|
+
* now?: number,
|
|
160
|
+
* taskName?: string,
|
|
161
|
+
* pid?: number|string,
|
|
162
|
+
* }} [options]
|
|
163
|
+
* @returns {{ scheduler: string, checkedAt: number, evidence: object }}
|
|
164
|
+
*/
|
|
165
|
+
export function probeSchedulerCapability({
|
|
166
|
+
platform = process.platform,
|
|
167
|
+
exec = defaultExec,
|
|
168
|
+
now = Date.now(),
|
|
169
|
+
taskName = probeTaskName(now, process.pid),
|
|
170
|
+
pid = process.pid,
|
|
171
|
+
} = {}) {
|
|
172
|
+
const checkedAt = Math.trunc(Number(now) || Date.now());
|
|
173
|
+
const evidence = { platform, taskName, steps: [] };
|
|
174
|
+
|
|
175
|
+
// Only Windows uses a one-off scheduled task. Everywhere else the question
|
|
176
|
+
// does not apply (the advice for those platforms is elsewhere).
|
|
177
|
+
if (platform !== 'win32') {
|
|
178
|
+
return {
|
|
179
|
+
scheduler: SCHEDULER_OK,
|
|
180
|
+
checkedAt,
|
|
181
|
+
evidence: {
|
|
182
|
+
...evidence,
|
|
183
|
+
applicable: false,
|
|
184
|
+
note: 'not applicable: this platform does not use a one-off scheduled task',
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const trigger = probeTrigger(checkedAt);
|
|
190
|
+
const step = (kind, ok, detail) => {
|
|
191
|
+
evidence.steps.push({ kind, ok, detail: detail === undefined ? null : detail });
|
|
192
|
+
return ok;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// (0) Idempotence: a task of an earlier run (or of another instance) must
|
|
196
|
+
// not be in the way.
|
|
197
|
+
const preClean = exec('schtasks', ['/Delete', '/TN', taskName, '/F']);
|
|
198
|
+
step('pre-clean', true, preClean.ok ? 'deleted a leftover probe task' : 'no leftover probe task');
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
// (1) Register the probe task: it fires in two minutes and does nothing.
|
|
202
|
+
const created = exec('schtasks', [
|
|
203
|
+
'/Create', '/TN', taskName, '/SC', 'ONCE', '/SD', trigger.sd, '/ST', trigger.st,
|
|
204
|
+
'/TR', 'cmd /c exit 0', '/F',
|
|
205
|
+
]);
|
|
206
|
+
if (!step('create', created.ok, created.ok ? 'the task was registered' : created.error)) {
|
|
207
|
+
return { scheduler: SCHEDULER_BLOCKED, checkedAt, evidence };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// (2) Read it back: the trigger must be the one we asked for, and the
|
|
211
|
+
// task must be enabled (that is what "Ready" means in the XML).
|
|
212
|
+
const query = exec('schtasks', ['/Query', '/TN', taskName, '/XML']);
|
|
213
|
+
if (!step('verify', query.ok, query.ok ? 'the task was read back' : query.error)) {
|
|
214
|
+
return { scheduler: SCHEDULER_BLOCKED, checkedAt, evidence };
|
|
215
|
+
}
|
|
216
|
+
if (!step('trigger', triggerMatches(query.stdout, trigger.iso), 'expected ' + trigger.iso + ', got ' + readStartBoundary(query.stdout))) {
|
|
217
|
+
return { scheduler: SCHEDULER_BLOCKED, checkedAt, evidence };
|
|
218
|
+
}
|
|
219
|
+
if (!step('enabled', taskEnabled(query.stdout), 'the task would fire')) {
|
|
220
|
+
return { scheduler: SCHEDULER_BLOCKED, checkedAt, evidence };
|
|
221
|
+
}
|
|
222
|
+
if (!step('action', taskHasAction(query.stdout), 'the task carries an action')) {
|
|
223
|
+
return { scheduler: SCHEDULER_BLOCKED, checkedAt, evidence };
|
|
224
|
+
}
|
|
225
|
+
} catch (error) {
|
|
226
|
+
step('error', false, error && error.message ? error.message : String(error));
|
|
227
|
+
return { scheduler: SCHEDULER_BLOCKED, checkedAt, evidence };
|
|
228
|
+
} finally {
|
|
229
|
+
// (3) Delete it — ALWAYS, also when a step above failed. A registration
|
|
230
|
+
// that was refused can still have left a task behind (the command may
|
|
231
|
+
// have created it and then failed), so the cleanup is not conditional.
|
|
232
|
+
const removed = exec('schtasks', ['/Delete', '/TN', taskName, '/F']);
|
|
233
|
+
step('delete', removed.ok, removed.ok ? 'the probe task was deleted' : removed.error);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// (4) Read it back once more: it must be GONE. A task that survives the
|
|
237
|
+
// probe is a task the user would find in his task scheduler.
|
|
238
|
+
const after = exec('schtasks', ['/Query', '/TN', taskName, '/XML']);
|
|
239
|
+
const gone = !after.ok;
|
|
240
|
+
step('gone', gone, gone ? 'the task is gone' : 'the task still exists: ' + after.error);
|
|
241
|
+
if (!gone) {
|
|
242
|
+
return { scheduler: SCHEDULER_BLOCKED, checkedAt, evidence };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { scheduler: SCHEDULER_OK, checkedAt, evidence };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Must the user be warned now?
|
|
250
|
+
*
|
|
251
|
+
* The warning is for ONE moment: the transition into "blocked". A machine
|
|
252
|
+
* that was blocked and stays blocked does not nag, and a user who has seen it
|
|
253
|
+
* is not asked again.
|
|
254
|
+
*
|
|
255
|
+
* @param {object|null} previous the capabilities before the check
|
|
256
|
+
* @param {object|null} current the capabilities after the check
|
|
257
|
+
* @returns {boolean}
|
|
258
|
+
*/
|
|
259
|
+
export function shouldWarnScheduler(previous, current) {
|
|
260
|
+
if (!current || typeof current !== 'object') return false;
|
|
261
|
+
if (current.scheduler !== SCHEDULER_BLOCKED) return false;
|
|
262
|
+
if (current.warnedAt) return false;
|
|
263
|
+
const before = previous && typeof previous === 'object' ? previous.scheduler : SCHEDULER_UNKNOWN;
|
|
264
|
+
// Only the TRANSITION warns: from ok or unknown into blocked.
|
|
265
|
+
return before !== SCHEDULER_BLOCKED;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The capabilities with the warning marked as seen.
|
|
270
|
+
*
|
|
271
|
+
* @param {object} capabilities
|
|
272
|
+
* @param {number} [now]
|
|
273
|
+
* @returns {object}
|
|
274
|
+
*/
|
|
275
|
+
export function markSchedulerWarned(capabilities, now = Date.now()) {
|
|
276
|
+
const record = capabilities && typeof capabilities === 'object' ? capabilities : {};
|
|
277
|
+
if (record.warnedAt) return record;
|
|
278
|
+
return { ...record, warnedAt: Math.trunc(Number(now) || Date.now()) };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* A capabilities record that says nothing yet.
|
|
283
|
+
*
|
|
284
|
+
* @returns {{ scheduler: string, checkedAt: number|null, evidence: object|null, warnedAt: number|null }}
|
|
285
|
+
*/
|
|
286
|
+
export function emptyCapabilities() {
|
|
287
|
+
return { scheduler: SCHEDULER_UNKNOWN, checkedAt: null, evidence: null, warnedAt: null };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Is the capabilities record readable?
|
|
292
|
+
*
|
|
293
|
+
* @param {unknown} raw
|
|
294
|
+
* @returns {boolean}
|
|
295
|
+
*/
|
|
296
|
+
export function isCapabilities(raw) {
|
|
297
|
+
if (!raw || typeof raw !== 'object') return false;
|
|
298
|
+
const scheduler = raw.scheduler;
|
|
299
|
+
return scheduler === SCHEDULER_OK || scheduler === SCHEDULER_BLOCKED || scheduler === SCHEDULER_UNKNOWN;
|
|
300
|
+
}
|
package/lib/sleep.js
CHANGED
|
@@ -30,11 +30,17 @@
|
|
|
30
30
|
import { spawn as spawnProcess } from 'node:child_process';
|
|
31
31
|
import fs from 'node:fs';
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
import { blockerTaskKeys, countBlockerTasks } from './blockers.js';
|
|
34
|
+
import { detectHostKind, detectWslEvidence, HOST_KINDS, WSL_PROC_VERSION, WSL_SHUTDOWN_EXE } from './host-kind.js';
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* The Windows shutdown binary as it is reachable from WSL.
|
|
38
|
+
*
|
|
39
|
+
* The detection itself lives in `host-kind.js` (the shutdown and the autostart
|
|
40
|
+
* halves of the plugin must agree on what machine this is); it is re-exported
|
|
41
|
+
* here so the old import path keeps working.
|
|
42
|
+
*/
|
|
43
|
+
export { WSL_SHUTDOWN_EXE, WSL_PROC_VERSION, detectWslEvidence };
|
|
38
44
|
|
|
39
45
|
/** How many seconds the countdown lasts by default. */
|
|
40
46
|
export const DEFAULT_COUNTDOWN_SECONDS = 20;
|
|
@@ -119,41 +125,6 @@ export function snapCountdownChoice(value) {
|
|
|
119
125
|
return best;
|
|
120
126
|
}
|
|
121
127
|
|
|
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
128
|
/**
|
|
158
129
|
* Detect the host and build the command that switches the physical machine off.
|
|
159
130
|
*
|
|
@@ -203,12 +174,12 @@ export function detectShutdownPlan({
|
|
|
203
174
|
}
|
|
204
175
|
|
|
205
176
|
if (plat === 'linux') {
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
177
|
+
const host = detectHostKind({ platform: plat, env, readFile: readFileSafe, exists: existsSafe });
|
|
178
|
+
if (host.kind === HOST_KINDS.WSL) {
|
|
208
179
|
// The mounted Windows binary is only a HINT: a real Linux with a
|
|
209
180
|
// manually mounted C: drive looks exactly the same. When that is the
|
|
210
181
|
// only evidence, the plan says so instead of pretending to be sure.
|
|
211
|
-
const suspect =
|
|
182
|
+
const suspect = host.suspect === true;
|
|
212
183
|
return {
|
|
213
184
|
kind: 'wsl',
|
|
214
185
|
label: suspect
|
|
@@ -216,7 +187,7 @@ export function detectShutdownPlan({
|
|
|
216
187
|
: 'WSL 里的 DSH → 关掉整台 Windows',
|
|
217
188
|
source: suspect
|
|
218
189
|
? `${WSL_SHUTDOWN_EXE}(疑似 WSL,仅凭 /mnt/c 探测)`
|
|
219
|
-
: evidence,
|
|
190
|
+
: host.evidence,
|
|
220
191
|
// The Windows binary, reached through the mounted C: drive: this shuts
|
|
221
192
|
// the whole machine down, not the WSL distribution.
|
|
222
193
|
command: WSL_SHUTDOWN_EXE,
|
|
@@ -550,9 +521,14 @@ export function createSleepController({
|
|
|
550
521
|
* that created the subagent). A raw session id never reaches the
|
|
551
522
|
* interface: an unknown name is a generic one.
|
|
552
523
|
*
|
|
524
|
+
* `parentSessionId` is kept: it is the exact identity of the session that
|
|
525
|
+
* created a subagent, so the card can count a parent and its subagents as
|
|
526
|
+
* ONE task (the rule of `lib/blockers.js`). It is null when the Host did
|
|
527
|
+
* not report one — the title is then the fallback.
|
|
528
|
+
*
|
|
553
529
|
* @param {any} entry the entry the Host produced
|
|
554
530
|
* @param {string} sessionId
|
|
555
|
-
* @returns {{ sessionId: string, kind: string, label: string|null, name: string, parentTitle: string|null, title: string }}
|
|
531
|
+
* @returns {{ sessionId: string, kind: string, label: string|null, name: string, parentTitle: string|null, parentSessionId: string|null, title: string }}
|
|
556
532
|
*/
|
|
557
533
|
const blockerEntry = (entry, sessionId) => {
|
|
558
534
|
const kind = entry.kind === 'subagent' ? 'subagent' : 'session';
|
|
@@ -569,12 +545,17 @@ export function createSleepController({
|
|
|
569
545
|
// writes the generic line when the name is empty.
|
|
570
546
|
if (name === '') name = label;
|
|
571
547
|
const parentTitle = typeof entry.parentTitle === 'string' ? entry.parentTitle.trim() : '';
|
|
548
|
+
const parentSessionId = typeof entry.parentSessionId === 'string' ? entry.parentSessionId.trim() : '';
|
|
572
549
|
return {
|
|
573
550
|
sessionId,
|
|
574
551
|
kind,
|
|
575
552
|
label: label !== '' ? label : null,
|
|
576
553
|
name,
|
|
577
554
|
parentTitle: parentTitle !== '' ? parentTitle : null,
|
|
555
|
+
// The identity of the session that created the subagent: it is what lets
|
|
556
|
+
// the card count a parent and its subagents as ONE task. It is only
|
|
557
|
+
// there when the Host knows it — a plain session keeps its old shape.
|
|
558
|
+
...(parentSessionId !== '' ? { parentSessionId } : {}),
|
|
578
559
|
// `title` is the old name of `label`: it stays, so an older client
|
|
579
560
|
// keeps working.
|
|
580
561
|
title: label !== '' ? label : name,
|
|
@@ -686,18 +667,31 @@ export function createSleepController({
|
|
|
686
667
|
* bug, so a list with a nameless entry in it is left out entirely: the
|
|
687
668
|
* caller then writes the line without the parenthetical.
|
|
688
669
|
*
|
|
670
|
+
* The names are the names of the TASKS: a session and its own subagents are
|
|
671
|
+
* ONE task, and it is named after the session itself (after its subagent
|
|
672
|
+
* when the session is idle and only the subagent works). So the list has as
|
|
673
|
+
* many names as the count has tasks.
|
|
674
|
+
*
|
|
689
675
|
* @param {any[]} list
|
|
690
676
|
* @returns {string} the joined names, or '' when the list cannot be named
|
|
691
677
|
*/
|
|
692
678
|
const names = (list) => {
|
|
693
|
-
const
|
|
694
|
-
|
|
679
|
+
const entries = Array.isArray(list) ? list : [];
|
|
680
|
+
const keys = blockerTaskKeys(entries);
|
|
681
|
+
const byTask = new Map();
|
|
682
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
683
|
+
const entry = entries[index];
|
|
695
684
|
const label = entry && typeof entry.label === 'string' ? entry.label.trim() : '';
|
|
696
685
|
const name = entry && typeof entry.name === 'string' ? entry.name.trim() : '';
|
|
697
686
|
if (label === '' || name === '') return '';
|
|
698
|
-
|
|
687
|
+
const key = keys[index] !== '' ? keys[index] : ('entry:' + index);
|
|
688
|
+
const isSubagent = entry.kind === 'subagent';
|
|
689
|
+
const known = byTask.get(key);
|
|
690
|
+
if (!known || (known.isSubagent && !isSubagent)) {
|
|
691
|
+
byTask.set(key, { name, isSubagent });
|
|
692
|
+
}
|
|
699
693
|
}
|
|
700
|
-
return
|
|
694
|
+
return Array.from(byTask.values()).map((item) => item.name).join('、');
|
|
701
695
|
};
|
|
702
696
|
|
|
703
697
|
/**
|
|
@@ -707,12 +701,15 @@ export function createSleepController({
|
|
|
707
701
|
* - one blocker with a title → the singular form ("等「甲」收工");
|
|
708
702
|
* - otherwise → the plain count, without a parenthetical.
|
|
709
703
|
*
|
|
704
|
+
* The count is about TASKS: a session and its own subagents are ONE task
|
|
705
|
+
* (the rule of `lib/blockers.js`), the same number the left sidebar shows.
|
|
706
|
+
*
|
|
710
707
|
* @param {any[]} list the blockers
|
|
711
708
|
* @param {{ immediate?: boolean }} options `immediate` = no countdown at all
|
|
712
709
|
* @returns {string}
|
|
713
710
|
*/
|
|
714
711
|
const waitingFor = (list, { immediate = false } = {}) => {
|
|
715
|
-
const count =
|
|
712
|
+
const count = countBlockerTasks(list);
|
|
716
713
|
const tail = immediate ? '收工后立即关机。' : '收工后开始倒数。';
|
|
717
714
|
const named = names(list);
|
|
718
715
|
if (count === 1 && named !== '') return `等「${named}」收工,${tail}`;
|