dsh-autorestart 0.6.1 → 0.7.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.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * dsh-autorestart — THE DIAGNOSTICS BEHIND "REPORT A PROBLEM".
3
+ *
4
+ * When something goes wrong, the user should be able to hand us the facts in
5
+ * one click — without writing anything down by hand and without leaking
6
+ * anything private.
7
+ *
8
+ * There is exactly ONE generator (`buildDiagnostics`): the host builds the
9
+ * text, the browser copies it. Two generators would drift, and a drifted
10
+ * diagnostic is worse than none.
11
+ *
12
+ * WHAT IS IN IT: the plugin version, the platform, whether this is the
13
+ * desktop client, how the host was started (the mode AND the evidence the
14
+ * verdict rests on), who supervises it, the restart mode, and the verdict of
15
+ * the last restart (with the checks that failed).
16
+ *
17
+ * WHAT IS NEVER IN IT: the access token, a URL that carries a token, or any
18
+ * secret. The generator only copies the fields it knows, so a token cannot
19
+ * leak through a field nobody thought about — and everything that IS copied
20
+ * goes through the redaction again.
21
+ *
22
+ * ESM only, Node builtins only.
23
+ */
24
+
25
+ /** The mailbox that works when GitHub does not. */
26
+ export const SUPPORT_EMAIL = '252170229@qq.com'
27
+
28
+ /** Where the "new issue" page lives. */
29
+ export const ISSUE_URL_BASE = 'https://github.com/Eason-22/dsh-autorestart/issues/new'
30
+
31
+ /** The body of a pre-filled issue must stay short: URLs have a length limit. */
32
+ export const DIAGNOSTIC_BODY_LIMIT = 800
33
+
34
+ /**
35
+ * The patterns that must never survive into a diagnostic.
36
+ *
37
+ * They cover the access token of the instance, the query parameters that
38
+ * carry it, and the usual "key: value" secrets.
39
+ */
40
+ const SECRET_PATTERNS = Object.freeze([
41
+ /([?&](?:token|access[_-]?token|api[_-]?key|apikey|secret|password|auth|authorization)=)[^&\s"'<>]+/gi,
42
+ /("|')?(?:token|access[_-]?token|api[_-]?key|apikey|secret|password|authorization)("|')?\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;}\]]+)/gi,
43
+ /\bBearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi,
44
+ ])
45
+
46
+ /**
47
+ * Remove anything that looks like a secret from a piece of text.
48
+ *
49
+ * @param {unknown} value
50
+ * @returns {string}
51
+ */
52
+ export function redactSecrets(value) {
53
+ let text = typeof value === 'string' ? value : String(value ?? '')
54
+ for (const pattern of SECRET_PATTERNS) {
55
+ text = text.replace(pattern, '[redacted]')
56
+ }
57
+ return text
58
+ }
59
+
60
+ /**
61
+ * Shorten a path: the last four segments are kept (the user name may stay),
62
+ * everything before them is cut.
63
+ *
64
+ * @param {unknown} value
65
+ * @returns {string}
66
+ */
67
+ export function truncatePath(value) {
68
+ const text = typeof value === 'string' ? value.trim() : ''
69
+ if (text === '') return ''
70
+ const parts = text.split(/[\\/]+/).filter((part) => part !== '')
71
+ if (parts.length <= 4) return text
72
+ return '…/' + parts.slice(-4).join('/')
73
+ }
74
+
75
+ /**
76
+ * Cut a text to a length limit (the issue body has to stay short).
77
+ *
78
+ * @param {string} text
79
+ * @param {number} [limit]
80
+ * @returns {string}
81
+ */
82
+ export function truncateBody(text, limit = DIAGNOSTIC_BODY_LIMIT) {
83
+ const source = typeof text === 'string' ? text : ''
84
+ const max = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.trunc(Number(limit)) : DIAGNOSTIC_BODY_LIMIT
85
+ if (source.length <= max) return source
86
+ return source.slice(0, Math.max(0, max - 1)) + '…'
87
+ }
88
+
89
+ /**
90
+ * The pre-filled "new issue" URL.
91
+ *
92
+ * @param {string} base
93
+ * @param {string} title
94
+ * @param {string} body
95
+ * @returns {string}
96
+ */
97
+ export function buildIssueUrl(base, title, body) {
98
+ const root = typeof base === 'string' && base.trim() !== '' ? base.trim() : ISSUE_URL_BASE
99
+ const safeTitle = truncateBody(redactSecrets(title), 200)
100
+ const safeBody = truncateBody(redactSecrets(body), DIAGNOSTIC_BODY_LIMIT)
101
+ return root + '?title=' + encodeURIComponent(safeTitle) + '&body=' + encodeURIComponent(safeBody)
102
+ }
103
+
104
+ /**
105
+ * The `mailto:` link (the fallback when there is no GitHub account).
106
+ *
107
+ * @param {string} email
108
+ * @param {string} subject
109
+ * @param {string} body
110
+ * @returns {string}
111
+ */
112
+ export function buildMailtoUrl(email, subject, body) {
113
+ const to = typeof email === 'string' && email.trim() !== '' ? email.trim() : SUPPORT_EMAIL
114
+ const safeSubject = truncateBody(redactSecrets(subject), 200)
115
+ const safeBody = truncateBody(redactSecrets(body), DIAGNOSTIC_BODY_LIMIT)
116
+ return 'mailto:' + to + '?subject=' + encodeURIComponent(safeSubject) + '&body=' + encodeURIComponent(safeBody)
117
+ }
118
+
119
+ /**
120
+ * Read one field out of a payload, without ever throwing.
121
+ *
122
+ * @param {any} source
123
+ * @param {string} key
124
+ * @param {any} [fallback]
125
+ * @returns {any}
126
+ */
127
+ function field(source, key, fallback = '') {
128
+ if (!source || typeof source !== 'object') return fallback
129
+ const value = source[key]
130
+ return value === undefined || value === null ? fallback : value
131
+ }
132
+
133
+ /**
134
+ * The diagnostic text of this host.
135
+ *
136
+ * It is a PURE function of the payload: the same facts always produce the
137
+ * same text, and nothing outside the fields listed below can ever end up in
138
+ * it.
139
+ *
140
+ * @param {{
141
+ * version?: string,
142
+ * platform?: string,
143
+ * hostKind?: string,
144
+ * isDesktopApp?: boolean,
145
+ * startup?: { mode?: string, kind?: string, evidence?: string },
146
+ * supervisor?: { mode?: string, kind?: string },
147
+ * restartMode?: string,
148
+ * lastRestart?: { result?: string, failedChecks?: string[] },
149
+ * stateDir?: string,
150
+ * }} [payload]
151
+ * @returns {string}
152
+ */
153
+ export function buildDiagnostics(payload = {}) {
154
+ const source = payload && typeof payload === 'object' ? payload : {}
155
+ const startup = field(source, 'startup', {})
156
+ const supervisor = field(source, 'supervisor', {})
157
+ const lastRestart = field(source, 'lastRestart', {})
158
+ const failedChecks = Array.isArray(field(lastRestart, 'failedChecks', []))
159
+ ? field(lastRestart, 'failedChecks', []).map((check) => redactSecrets(String(check)))
160
+ : []
161
+
162
+ const lines = [
163
+ '--- dsh-autorestart diagnostics ---',
164
+ 'plugin version: ' + redactSecrets(field(source, 'version', 'unknown')),
165
+ 'platform: ' + redactSecrets(field(source, 'platform', 'unknown')),
166
+ 'host kind: ' + redactSecrets(field(source, 'hostKind', 'unknown')),
167
+ 'desktop client: ' + (field(source, 'isDesktopApp', false) === true ? 'yes' : 'no'),
168
+ 'startup mode: ' + redactSecrets(field(startup, 'mode', 'unknown')),
169
+ 'startup evidence: ' + redactSecrets(field(startup, 'evidence', 'unknown')),
170
+ 'supervisor mode: ' + redactSecrets(field(supervisor, 'mode', 'unknown')),
171
+ 'supervisor kind: ' + redactSecrets(field(supervisor, 'kind', 'unknown')),
172
+ 'restart mode: ' + redactSecrets(field(source, 'restartMode', 'unknown')),
173
+ 'last restart result: ' + redactSecrets(field(lastRestart, 'result', 'unknown')),
174
+ 'last restart failed checks: ' + (failedChecks.length > 0 ? failedChecks.join(', ') : 'none'),
175
+ ]
176
+ const stateDir = truncatePath(field(source, 'stateDir', ''))
177
+ if (stateDir !== '') lines.push('dsh home: ' + redactSecrets(stateDir))
178
+ lines.push('--- end ---')
179
+ return lines.join('\n')
180
+ }
package/lib/health.js ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * dsh-autorestart — "is the new host really up?".
3
+ *
4
+ * A restart is only successful when the NEW instance answers its health route.
5
+ * The old instance's answer proves nothing: the old instance may still be
6
+ * running (the restart command did nothing), or it may answer on a port the
7
+ * new instance does not use (the desktop app listens on a random port).
8
+ *
9
+ * So the confirmation is built from two facts:
10
+ *
11
+ * 1. the INSTANCE RECORD (lib/access.js) of the new instance — it carries a
12
+ * boot id that is different from the one of the instance that is being
13
+ * replaced, and the port the new instance really listens on;
14
+ * 2. the health route of THAT port.
15
+ *
16
+ * The wait is bounded: when the new instance does not answer in time the
17
+ * caller gets a REASON, never a silent success.
18
+ *
19
+ * ESM only, Node builtins only. No DSH / npm imports.
20
+ */
21
+
22
+ import http from 'node:http';
23
+ import { URL } from 'node:url';
24
+
25
+ import { readAccessRecordFile } from './access.js';
26
+
27
+ /** How long one health request may take before it counts as "no answer". */
28
+ export const DEFAULT_HEALTH_TIMEOUT_MS = 5000;
29
+
30
+ /** How long the new instance may take to come up at all. */
31
+ export const DEFAULT_INSTANCE_WAIT_MS = 90000;
32
+
33
+ /** How often the instance record is read while waiting. */
34
+ export const DEFAULT_INSTANCE_POLL_MS = 1000;
35
+
36
+ /** @param {unknown} value */
37
+ function errorText(value) {
38
+ if (value && typeof value.message === 'string' && value.message) return value.message;
39
+ return String(value);
40
+ }
41
+
42
+ /**
43
+ * Sleep for a while. Never rejects.
44
+ *
45
+ * @param {number} ms
46
+ * @returns {Promise<void>}
47
+ */
48
+ function sleep(ms) {
49
+ return new Promise((resolve) => {
50
+ const timer = setTimeout(resolve, Math.max(0, Number(ms) || 0));
51
+ if (typeof timer.unref === 'function') timer.unref();
52
+ });
53
+ }
54
+
55
+ /**
56
+ * One plain HTTP GET, answered with the parsed JSON body.
57
+ *
58
+ * @param {string} url
59
+ * @param {number} timeoutMs
60
+ * @returns {Promise<{ ok: boolean, status: number, body: any, error: string|null }>}
61
+ */
62
+ function httpGetJson(url, timeoutMs) {
63
+ return new Promise((resolve) => {
64
+ let settled = false;
65
+ const finish = (result) => {
66
+ if (settled) return;
67
+ settled = true;
68
+ resolve(result);
69
+ };
70
+ let request;
71
+ try {
72
+ request = http.get(url, { timeout: timeoutMs }, (response) => {
73
+ const chunks = [];
74
+ response.on('data', (chunk) => chunks.push(chunk));
75
+ response.on('end', () => {
76
+ const text = Buffer.concat(chunks).toString('utf8');
77
+ let body = null;
78
+ try {
79
+ body = JSON.parse(text);
80
+ } catch {
81
+ body = null;
82
+ }
83
+ const status = Number(response.statusCode) || 0;
84
+ finish({
85
+ ok: status >= 200 && status < 300 && body !== null && body.ok === true,
86
+ status,
87
+ body,
88
+ error: status >= 200 && status < 300
89
+ ? (body !== null && body.ok === true ? null : 'the health route did not answer ok')
90
+ : 'the health route answered with status ' + status,
91
+ });
92
+ });
93
+ response.on('error', (error) => {
94
+ finish({ ok: false, status: 0, body: null, error: errorText(error) });
95
+ });
96
+ });
97
+ } catch (error) {
98
+ finish({ ok: false, status: 0, body: null, error: errorText(error) });
99
+ return;
100
+ }
101
+ request.on('timeout', () => {
102
+ try {
103
+ request.destroy();
104
+ } catch {
105
+ /* nothing else to do */
106
+ }
107
+ finish({ ok: false, status: 0, body: null, error: 'the health request timed out' });
108
+ });
109
+ request.on('error', (error) => {
110
+ finish({ ok: false, status: 0, body: null, error: errorText(error) });
111
+ });
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Ask one health route whether the host behind it is alive.
117
+ *
118
+ * @param {{ url?: string, timeoutMs?: number, requestImpl?: Function }} [options]
119
+ * @returns {Promise<{ ok: boolean, status: number, error: string|null }>}
120
+ */
121
+ export function probeHealth({ url = '', timeoutMs = DEFAULT_HEALTH_TIMEOUT_MS, requestImpl = null } = {}) {
122
+ const target = typeof url === 'string' ? url.trim() : '';
123
+ if (target === '') {
124
+ return Promise.resolve({ ok: false, status: 0, error: 'probeHealth requires a url' });
125
+ }
126
+ const budget = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
127
+ ? Math.trunc(Number(timeoutMs))
128
+ : DEFAULT_HEALTH_TIMEOUT_MS;
129
+ const request = typeof requestImpl === 'function' ? requestImpl : httpGetJson;
130
+ return new Promise((resolve) => {
131
+ let settled = false;
132
+ const finish = (result) => {
133
+ if (settled) return;
134
+ settled = true;
135
+ resolve(result);
136
+ };
137
+ const timer = setTimeout(() => {
138
+ finish({ ok: false, status: 0, error: 'the health request timed out after ' + budget + 'ms' });
139
+ }, budget);
140
+ Promise.resolve()
141
+ .then(() => request(target, budget))
142
+ .then((response) => {
143
+ clearTimeout(timer);
144
+ const status = Number(response && response.status) || 0;
145
+ if (response && response.ok === true) {
146
+ finish({ ok: true, status: status || 200, error: null });
147
+ return;
148
+ }
149
+ finish({
150
+ ok: false,
151
+ status,
152
+ error: (response && response.error) || 'the health route did not answer ok',
153
+ });
154
+ })
155
+ .catch((error) => {
156
+ clearTimeout(timer);
157
+ finish({ ok: false, status: 0, error: errorText(error) });
158
+ });
159
+ });
160
+ }
161
+
162
+ /**
163
+ * The health URL of one port on the loopback interface.
164
+ *
165
+ * @param {number|string} port
166
+ * @param {string} healthPath
167
+ * @returns {string}
168
+ */
169
+ export function healthUrlForPort(port, healthPath) {
170
+ const number = Number(port);
171
+ if (!Number.isFinite(number) || number <= 0) return '';
172
+ const path = typeof healthPath === 'string' && healthPath.startsWith('/')
173
+ ? healthPath
174
+ : '/__dsh_autorestart/health';
175
+ return 'http://127.0.0.1:' + Math.trunc(number) + path;
176
+ }
177
+
178
+ /**
179
+ * Wait until the instance that replaced `expectedBootId` answers its health
180
+ * route.
181
+ *
182
+ * The instance record is the only source of the new port: the desktop app
183
+ * listens on a random port, so the port of the old instance is meaningless.
184
+ *
185
+ * @param {{
186
+ * accessFile?: string,
187
+ * expectedBootId?: string|null,
188
+ * healthPath?: string,
189
+ * timeoutMs?: number,
190
+ * intervalMs?: number,
191
+ * probe?: Function,
192
+ * readRecord?: Function,
193
+ * now?: Function,
194
+ * sleep?: Function,
195
+ * }} [options]
196
+ * @returns {Promise<{ ok: boolean, bootId: string|null, port: number|null, url: string|null, reason: string }>}
197
+ */
198
+ export async function waitForHealthyInstance({
199
+ accessFile = '',
200
+ expectedBootId = null,
201
+ healthPath = '/__dsh_autorestart/health',
202
+ timeoutMs = DEFAULT_INSTANCE_WAIT_MS,
203
+ intervalMs = DEFAULT_INSTANCE_POLL_MS,
204
+ probe = probeHealth,
205
+ readRecord = readAccessRecordFile,
206
+ now = () => Date.now(),
207
+ sleep: sleepFn = sleep,
208
+ } = {}) {
209
+ const file = typeof accessFile === 'string' ? accessFile.trim() : '';
210
+ const budget = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
211
+ ? Math.trunc(Number(timeoutMs))
212
+ : DEFAULT_INSTANCE_WAIT_MS;
213
+ const step = Number.isFinite(Number(intervalMs)) && Number(intervalMs) > 0
214
+ ? Math.trunc(Number(intervalMs))
215
+ : DEFAULT_INSTANCE_POLL_MS;
216
+ const expected = typeof expectedBootId === 'string' && expectedBootId !== '' ? expectedBootId : null;
217
+ const deadline = now() + budget;
218
+ let lastReason = 'the new instance did not come up in time';
219
+
220
+ if (file === '') {
221
+ return { ok: false, bootId: null, port: null, url: null, reason: 'waitForHealthyInstance requires the instance record' };
222
+ }
223
+
224
+ while (now() < deadline) {
225
+ const read = readRecord(file);
226
+ const record = read && read.ok === true ? read.record : null;
227
+ const bootId = record && typeof record.bootId === 'string' ? record.bootId : null;
228
+ const port = record && Number.isFinite(Number(record.port)) ? Number(record.port) : null;
229
+ const isNewInstance = bootId !== null && (expected === null || bootId !== expected);
230
+ if (isNewInstance && port !== null && port > 0) {
231
+ const url = healthUrlForPort(port, healthPath);
232
+ const answer = await probe({ url, timeoutMs: DEFAULT_HEALTH_TIMEOUT_MS });
233
+ if (answer && answer.ok === true) {
234
+ return { ok: true, bootId, port, url, reason: 'the new instance answered its health route' };
235
+ }
236
+ lastReason = (answer && answer.error) || 'the new instance did not answer its health route';
237
+ } else if (record === null) {
238
+ lastReason = 'the instance record is not readable yet';
239
+ } else if (!isNewInstance) {
240
+ lastReason = 'the instance that answers is still the one that is being replaced';
241
+ } else {
242
+ lastReason = 'the new instance did not publish its port yet';
243
+ }
244
+ await sleepFn(step);
245
+ }
246
+ return { ok: false, bootId: null, port: null, url: null, reason: lastReason };
247
+ }
248
+
249
+ /**
250
+ * The token of an access URL, or null.
251
+ *
252
+ * @param {string} url
253
+ * @returns {string|null}
254
+ */
255
+ export function tokenOf(url) {
256
+ const text = typeof url === 'string' ? url.trim() : '';
257
+ if (text === '') return null;
258
+ try {
259
+ const parsed = new URL(text);
260
+ return parsed.searchParams.get('token');
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * dsh-autorestart — WHICH MACHINE IS THIS?
3
+ *
4
+ * The plugin has to know whether it runs on Windows, on a real Linux, on a
5
+ * macOS or INSIDE WSL (the Linux of Windows). The answer changes what may be
6
+ * done:
7
+ *
8
+ * - the graceful shutdown must reach the PHYSICAL machine (in WSL, shutting
9
+ * the distro down only stops the subsystem);
10
+ * - the "start at login" of the supervisor is a different thing in WSL
11
+ * (a systemd user unit + `loginctl enable-linger`, or a Windows-side
12
+ * task) than on a real Linux;
13
+ * - inside WSL there is NO Docker/Compose answer: the orchestrator is
14
+ * Windows itself.
15
+ *
16
+ * So the detection lives HERE, once, and both halves of the plugin use it.
17
+ *
18
+ * Everything is dependency-injected (the platform, the environment, the file
19
+ * probes), so the whole matrix can be tested without a machine.
20
+ *
21
+ * ESM only, Node builtins only.
22
+ */
23
+
24
+ import fs from 'node:fs'
25
+
26
+ /** The Windows shutdown binary as it is reachable from WSL. */
27
+ export const WSL_SHUTDOWN_EXE = '/mnt/c/Windows/System32/shutdown.exe'
28
+
29
+ /** The file that tells a WSL kernel from a real Linux kernel. */
30
+ export const WSL_PROC_VERSION = '/proc/version'
31
+
32
+ /** The kinds of host the plugin knows. */
33
+ export const HOST_KINDS = Object.freeze({
34
+ WINDOWS: 'windows',
35
+ WSL: 'wsl',
36
+ LINUX: 'linux',
37
+ MACOS: 'macos',
38
+ UNSUPPORTED: 'unsupported',
39
+ })
40
+
41
+ /** @param {unknown} value */
42
+ function nonEmptyString(value) {
43
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : ''
44
+ }
45
+
46
+ /**
47
+ * The evidence that this Linux is really a WSL distribution.
48
+ *
49
+ * Any ONE of them is enough:
50
+ * 1. `WSL_DISTRO_NAME` is set (WSL exports it into every process),
51
+ * 2. `WSL_INTEROP` is set (WSL exports it into every process),
52
+ * 3. `/proc/version` mentions "microsoft" (the WSL kernel says so),
53
+ * 4. the Windows shutdown binary is reachable under `/mnt/c`.
54
+ *
55
+ * @param {object} env
56
+ * @param {(file: string) => string} readFile
57
+ * @param {(file: string) => boolean} exists
58
+ * @returns {string|null} the name of the evidence, or null
59
+ */
60
+ export function detectWslEvidence(env, readFile, exists) {
61
+ const environ = env && typeof env === 'object' ? env : {}
62
+ if (nonEmptyString(environ.WSL_DISTRO_NAME) !== '') return 'WSL_DISTRO_NAME'
63
+ if (nonEmptyString(environ.WSL_INTEROP) !== '') return 'WSL_INTEROP'
64
+ let version = ''
65
+ try {
66
+ version = String(readFile(WSL_PROC_VERSION) || '')
67
+ } catch {
68
+ version = ''
69
+ }
70
+ if (version.toLowerCase().includes('microsoft')) return WSL_PROC_VERSION
71
+ let found = false
72
+ try {
73
+ found = exists(WSL_SHUTDOWN_EXE) === true
74
+ } catch {
75
+ found = false
76
+ }
77
+ if (found) return WSL_SHUTDOWN_EXE
78
+ return null
79
+ }
80
+
81
+ /**
82
+ * Which machine is this?
83
+ *
84
+ * `suspect` is true when the ONLY evidence is the mounted Windows drive: a
85
+ * real Linux with a manually mounted C: drive looks exactly the same. The
86
+ * plugin says so instead of pretending to be sure — and it does NOT change
87
+ * any system configuration on such a guess.
88
+ *
89
+ * @param {{
90
+ * platform?: string,
91
+ * env?: object,
92
+ * readFile?: (file: string) => string,
93
+ * exists?: (file: string) => boolean,
94
+ * }} [options]
95
+ * @returns {{
96
+ * kind: 'windows'|'wsl'|'linux'|'macos'|'unsupported',
97
+ * platform: string,
98
+ * evidence: string,
99
+ * suspect: boolean,
100
+ * distro: string,
101
+ * }}
102
+ */
103
+ export function detectHostKind({
104
+ platform = process.platform,
105
+ env = process.env,
106
+ readFile,
107
+ exists,
108
+ } = {}) {
109
+ const plat = nonEmptyString(platform) || process.platform
110
+ const environ = env && typeof env === 'object' ? env : process.env
111
+ const readFileSafe = typeof readFile === 'function'
112
+ ? readFile
113
+ : (file) => fs.readFileSync(file, 'utf8')
114
+ const existsSafe = typeof exists === 'function'
115
+ ? exists
116
+ : (file) => fs.existsSync(file)
117
+
118
+ const base = {
119
+ platform: plat,
120
+ evidence: 'process.platform',
121
+ suspect: false,
122
+ distro: nonEmptyString(environ.WSL_DISTRO_NAME),
123
+ }
124
+
125
+ if (plat === 'win32') {
126
+ return { ...base, kind: HOST_KINDS.WINDOWS }
127
+ }
128
+ if (plat === 'linux') {
129
+ const evidence = detectWslEvidence(environ, readFileSafe, existsSafe)
130
+ if (evidence !== null) {
131
+ return {
132
+ ...base,
133
+ kind: HOST_KINDS.WSL,
134
+ evidence,
135
+ // Only the mounted drive: that is a HINT, not a proof.
136
+ suspect: evidence === WSL_SHUTDOWN_EXE,
137
+ }
138
+ }
139
+ return { ...base, kind: HOST_KINDS.LINUX }
140
+ }
141
+ if (plat === 'darwin') {
142
+ return { ...base, kind: HOST_KINDS.MACOS }
143
+ }
144
+ return { ...base, kind: HOST_KINDS.UNSUPPORTED }
145
+ }
package/lib/lifecycle.js CHANGED
@@ -262,10 +262,12 @@ export function selectGoalDisarms(busyAgents, { getGoal, disarmedGoals, parkedGo
262
262
  * forceRestartWhenBusy?: boolean,
263
263
  * }} [options]
264
264
  * @returns {{
265
- * action: 'none'|'wait'|'warn'|'restart'|'force-restart'|'give-up',
265
+ * action: 'none'|'wait'|'warn'|'long-wait'|'restart'|'force-restart'|'give-up',
266
266
  * reason: string,
267
267
  * log?: string,
268
268
  * markSoftWarned?: boolean,
269
+ * blockedBy?: { sessionId: string|null, reason: string }[],
270
+ * waitingSince?: number,
269
271
  * }}
270
272
  */
271
273
  export function restartDecision(
@@ -325,11 +327,19 @@ export function restartDecision(
325
327
  }
326
328
 
327
329
  // Hard deadline reached while a conversation is still running.
330
+ //
331
+ // From here on the plugin does NOT pretend that "everything is fine, we are
332
+ // just waiting a little longer": the wait has become a LONG wait, and the
333
+ // caller (index.js) has to make it VISIBLE — in the log and in the state
334
+ // payload — while KEEPING the user's restart request alive. The request is
335
+ // never silently dropped.
328
336
  if (policy === 'wait') {
329
337
  return {
330
- action: 'warn',
338
+ action: 'long-wait',
331
339
  reason: 'hard-deadline-policy-wait',
332
340
  log: 'defer policy wait: still busy after hard deadline',
341
+ blockedBy: busy,
342
+ waitingSince: requestedAt,
333
343
  };
334
344
  }
335
345
  // The hard deadline only forces a restart when the operator explicitly opted in
@@ -340,11 +350,14 @@ export function restartDecision(
340
350
  return { action: 'force-restart', reason: 'hard-deadline-force-restart' };
341
351
  }
342
352
  // forceRestartWhenBusy is disabled (the default): the restart keeps waiting
343
- // until the idle gate is quiet again, and it never gives up.
353
+ // until the idle gate is quiet again, and it never gives up. The result says
354
+ // so explicitly — a long wait, with the sessions that hold it.
344
355
  return {
345
- action: 'warn',
356
+ action: 'long-wait',
346
357
  reason: 'hard-deadline-waiting-for-conversations',
347
358
  log: 'dsh-autorestart: hard deadline reached but conversations are still running; forceRestartWhenBusy is disabled, so the restart keeps waiting',
359
+ blockedBy: busy,
360
+ waitingSince: requestedAt,
348
361
  };
349
362
  }
350
363