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.
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
- /** The Windows shutdown binary as it is reachable from WSL. */
34
- export const WSL_SHUTDOWN_EXE = '/mnt/c/Windows/System32/shutdown.exe';
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
- /** The file that tells a WSL kernel from a real Linux kernel. */
37
- export const WSL_PROC_VERSION = '/proc/version';
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 evidence = detectWslEvidence(env, readFileSafe, existsSafe);
207
- if (evidence !== null) {
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 = evidence === WSL_SHUTDOWN_EXE;
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 out = [];
694
- for (const entry of Array.isArray(list) ? list : []) {
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
- out.push(name);
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 out.join('、');
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 = Array.isArray(list) ? list.length : 0;
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}`;
@@ -0,0 +1,491 @@
1
+ /**
2
+ * dsh-autorestart — HOW THIS HOST WAS STARTED.
3
+ *
4
+ * A hosted web host can be started in a dozen ways, and the plugin has to
5
+ * behave correctly in EVERY one of them:
6
+ *
7
+ * | # | how it was started | what the plugin does |
8
+ * |---|---------------------------------------------|---------------------------------|
9
+ * | 1 | a plain terminal (`dsh web`) | own supervisor MAY be installed |
10
+ * | 2 | `nohup … &` / `setsid` (detached) | same, but the replay is detached|
11
+ * | 3 | a supervisor script (`supervise-*.sh`) | external → graceful exit only |
12
+ * | 4 | a systemd unit | external |
13
+ * | 5 | pm2 / forever | external |
14
+ * | 6 | docker / compose / kubernetes | no "at login"; compose fragment |
15
+ * | 7 | a Windows scheduled task / NSSM service | external |
16
+ * | 8 | WSL, started by a Windows process | unknown (the parent is invisible)|
17
+ * | 9 | `npx` / `pnpm dlx` | usable if the argv can be restored|
18
+ * |10 | another DSH plugin spawned it | external/unknown → never a second|
19
+ * |11 | tmux / screen / an SSH session | replay, but detached from the session |
20
+ *
21
+ * THE TWO RULES THAT MATTER MORE THAN THE TABLE:
22
+ *
23
+ * 1. **NEVER START A SECOND HOST.** Only a `bare` host may get a
24
+ * self-replay command, and only a `bare` host may get the supervisor of
25
+ * this plugin. Everything else is somebody else's business.
26
+ * 2. **WHEN IN DOUBT, BE CONSERVATIVE.** An `unknown` host gets
27
+ * `selfReplay: 'unknown'` and NO supervisor: the plugin would rather
28
+ * say "I cannot tell" than start a second host next to the first.
29
+ *
30
+ * The classifier is a PURE function: the process chain, the environment and
31
+ * the container markers are injected, so every row of the table can be
32
+ * tested without a machine.
33
+ *
34
+ * ESM only, Node builtins only.
35
+ */
36
+
37
+ /** The modes the classifier can answer. */
38
+ export const STARTUP_MODES = Object.freeze({
39
+ /** The desktop app: it relaunches itself. */
40
+ DESKTOP: 'desktop',
41
+ /** The supervisor of THIS plugin started the instance. */
42
+ SELF_SUPERVISED: 'self-supervised',
43
+ /** Somebody else (systemd, pm2, a script, a service, another plugin). */
44
+ EXTERNAL: 'external',
45
+ /** Nobody manages it: a terminal, a detached process, a session. */
46
+ BARE: 'bare',
47
+ /** A container: the orchestrator decides, not the machine. */
48
+ CONTAINER: 'container',
49
+ /** It could not be established. */
50
+ UNKNOWN: 'unknown',
51
+ })
52
+
53
+ /** The kinds of a startup mode (the finer answer). */
54
+ export const STARTUP_KINDS = Object.freeze({
55
+ DESKTOP_APP: 'desktop-app',
56
+ OWN_SUPERVISOR: 'dsh-autorestart-supervisor',
57
+ SYSTEMD: 'systemd',
58
+ PM2: 'pm2',
59
+ FOREVER: 'forever',
60
+ SCRIPT: 'script',
61
+ WINDOWS_TASK: 'windows-task',
62
+ WINDOWS_SERVICE: 'windows-service',
63
+ DSH_PLUGIN: 'dsh-plugin',
64
+ DOCKER: 'docker',
65
+ KUBERNETES: 'kubernetes',
66
+ TERMINAL: 'terminal',
67
+ DETACHED: 'detached',
68
+ SESSION: 'session',
69
+ PACKAGE_RUNNER: 'package-runner',
70
+ WSL_FROM_WINDOWS: 'wsl-from-windows',
71
+ OPAQUE_WRAPPER: 'opaque-wrapper',
72
+ UNREADABLE_CHAIN: 'unreadable-chain',
73
+ })
74
+
75
+ /** The reason codes of the derivation that follow from a startup mode. */
76
+ export const STARTUP_REASONS = Object.freeze({
77
+ DESKTOP: 'desktop-app',
78
+ OWN_SUPERVISOR: 'supervised',
79
+ EXTERNAL: 'external-supervisor',
80
+ BARE: 'ok',
81
+ CONTAINER: 'container-no-init',
82
+ UNKNOWN: 'unknown-startup',
83
+ })
84
+
85
+ /**
86
+ * The environment markers of the common supervisors.
87
+ *
88
+ * They are cheap, exact and need no process tree.
89
+ */
90
+ const SUPERVISOR_ENV_MARKERS = Object.freeze([
91
+ { key: 'INVOCATION_ID', kind: STARTUP_KINDS.SYSTEMD, label: 'a systemd unit' },
92
+ { key: 'pm_id', kind: STARTUP_KINDS.PM2, label: 'pm2' },
93
+ { key: 'PM2_HOME', kind: STARTUP_KINDS.PM2, label: 'pm2' },
94
+ ])
95
+
96
+ /**
97
+ * The ancestors that mean "somebody else is in charge".
98
+ *
99
+ * The ORDER does not matter here: an external match ANYWHERE in the chain
100
+ * wins, because the nearest ancestor may be a plain shell that a supervisor
101
+ * started (`systemd → bash → dsh web`).
102
+ */
103
+ const EXTERNAL_ANCESTORS = Object.freeze([
104
+ { kind: STARTUP_KINDS.SYSTEMD, pattern: /systemd/i, label: 'a systemd unit' },
105
+ { kind: STARTUP_KINDS.PM2, pattern: /\bpm2\b/i, label: 'pm2' },
106
+ { kind: STARTUP_KINDS.FOREVER, pattern: /\bforever\b/i, label: 'forever' },
107
+ {
108
+ kind: STARTUP_KINDS.SCRIPT,
109
+ pattern: /supervise[-_][\w.-]*\.(sh|bash|py|js)|supervisord|\brunsv\b|\bs6-supervise\b|\bmonit\b/i,
110
+ label: 'a supervisor script',
111
+ },
112
+ { kind: STARTUP_KINDS.WINDOWS_SERVICE, pattern: /\bnssm(\.exe)?\b/i, label: 'a Windows service (NSSM)' },
113
+ {
114
+ kind: STARTUP_KINDS.WINDOWS_TASK,
115
+ pattern: /\bsvchost(\.exe)?\b|\btaskhostw(\.exe)?\b/i,
116
+ label: 'the Windows side (a scheduled task or a service)',
117
+ },
118
+ {
119
+ kind: STARTUP_KINDS.DSH_PLUGIN,
120
+ pattern: /[\\/]plugins[\\/][^\\/]+[\\/]/i,
121
+ label: 'another DSH plugin',
122
+ },
123
+ ])
124
+
125
+ /** The ancestors that mean "a session" (not a supervisor, but not free either). */
126
+ const SESSION_ANCESTORS = Object.freeze([
127
+ { pattern: /\btmux\b/i, label: 'a tmux session' },
128
+ { pattern: /\bscreen\b/i, label: 'a screen session' },
129
+ { pattern: /\bsshd\b|\bssh\b/i, label: 'an SSH session' },
130
+ ])
131
+
132
+ /** The ancestors that mean "the command was detached from its shell". */
133
+ const DETACHED_ANCESTORS = Object.freeze([
134
+ { pattern: /\bnohup\b/i, label: 'a nohup command' },
135
+ { pattern: /\bsetsid\b/i, label: 'a setsid command' },
136
+ ])
137
+
138
+ /** The ancestors that mean "a human started it in a terminal". */
139
+ const TERMINAL_ANCESTORS = Object.freeze([
140
+ { pattern: /\b(bash|sh|zsh|fish|dash|ksh|csh|tcsh)\b/i, label: 'a shell' },
141
+ { pattern: /\b(cmd(\.exe)?|powershell(\.exe)?|pwsh(\.exe)?|conhost(\.exe)?|WindowsTerminal(\.exe)?)\b/i, label: 'a Windows terminal' },
142
+ ])
143
+
144
+ /** The package runners that may hide the real command line. */
145
+ const PACKAGE_RUNNERS = Object.freeze([
146
+ { pattern: /^(npx|pnpm|npm|yarn|bunx|dlx)(\.cmd|\.exe|\.ps1)?$/i, label: 'a package runner' },
147
+ ])
148
+
149
+ /** The places in a path that say "a package runner was here". */
150
+ const PACKAGE_RUNNER_PATHS = Object.freeze([
151
+ /[\\/]_npx[\\/]/i,
152
+ /[\\/]\.pnpm[\\/]/i,
153
+ /[\\/]node_modules[\\/]\.bin[\\/]/i,
154
+ /[\\/]node_modules[\\/][^\\/]+[\\/]bin[\\/]/i,
155
+ ])
156
+
157
+ /**
158
+ * The text of one ancestor (the image name and the command line).
159
+ *
160
+ * @param {{ imageName?: string, commandLine?: string }} entry
161
+ * @returns {string}
162
+ */
163
+ function ancestorText(entry) {
164
+ if (!entry || typeof entry !== 'object') return ''
165
+ return [entry.imageName ?? '', entry.commandLine ?? ''].filter(Boolean).join(' ')
166
+ }
167
+
168
+ /**
169
+ * The evidence of one ancestor: what it is and how it was found.
170
+ *
171
+ * @param {{ pid?: number, imageName?: string, commandLine?: string }} entry
172
+ * @returns {string}
173
+ */
174
+ function ancestorEvidence(entry) {
175
+ const pid = Number(entry?.pid)
176
+ const text = ancestorText(entry).trim()
177
+ return 'ancestor:' + (Number.isFinite(pid) ? pid : '?') + ': ' + (text === '' ? '?' : text)
178
+ }
179
+
180
+ /**
181
+ * The first ancestor that matches one of the patterns.
182
+ *
183
+ * @param {Array<object>} chain
184
+ * @param {Array<{ pattern: RegExp, label: string, kind?: string }>} patterns
185
+ * @returns {{ entry: object, label: string, kind: string }|null}
186
+ */
187
+ function firstMatch(chain, patterns) {
188
+ for (const entry of chain) {
189
+ const text = ancestorText(entry)
190
+ if (text === '') continue
191
+ for (const rule of patterns) {
192
+ if (rule.pattern.test(text)) {
193
+ return { entry, label: rule.label, kind: rule.kind ?? '' }
194
+ }
195
+ }
196
+ }
197
+ return null
198
+ }
199
+
200
+ /**
201
+ * Is this a WSL instance?
202
+ *
203
+ * @param {object} env
204
+ * @returns {boolean}
205
+ */
206
+ export function isWsl(env) {
207
+ if (!env || typeof env !== 'object') return false
208
+ if (typeof env.WSL_DISTRO_NAME === 'string' && env.WSL_DISTRO_NAME !== '') return true
209
+ if (typeof env.WSL_INTEROP === 'string' && env.WSL_INTEROP !== '') return true
210
+ return false
211
+ }
212
+
213
+ /**
214
+ * Can the real command line be restored?
215
+ *
216
+ * A package runner (`npx dsh web`) starts the host with the RESOLVED script
217
+ * path in `argv[1]` — that is replayable. A wrapper that erased the command
218
+ * line is not: the plugin must not guess which script it was.
219
+ *
220
+ * @param {{ argv?: string[], execPath?: string }} options
221
+ * @returns {{ ok: boolean, kind: string, label: string, reason: string }}
222
+ */
223
+ export function inspectCommandLine(options = {}) {
224
+ const argv = Array.isArray(options.argv) ? options.argv.map(String) : []
225
+ const execPath = String(options.execPath ?? '')
226
+ const script = String(argv[1] ?? '').trim()
227
+ const runner = PACKAGE_RUNNERS.find((rule) => (
228
+ rule.pattern.test(argv[0] ?? '') || rule.pattern.test(execPath.split(/[\\/]/).pop() ?? '')
229
+ ))
230
+ const runnerInPath = PACKAGE_RUNNER_PATHS.some((rule) => rule.test(script))
231
+ if (runner === undefined && !runnerInPath) {
232
+ // Not a package runner: the command line is used as it is.
233
+ if (script === '') {
234
+ return { ok: false, kind: STARTUP_KINDS.OPAQUE_WRAPPER, label: 'an unreadable command line', reason: 'the command line of this host cannot be read back' }
235
+ }
236
+ return { ok: true, kind: '', label: '', reason: '' }
237
+ }
238
+ const label = runner !== undefined ? runner.label : 'a package runner'
239
+ // A package runner: the resolved script path must be there, or nothing can
240
+ // be replayed.
241
+ if (script !== '' && (/[\\/]/.test(script) || /\.(mjs|cjs|js)$/i.test(script))) {
242
+ return { ok: true, kind: STARTUP_KINDS.PACKAGE_RUNNER, label, reason: '' }
243
+ }
244
+ return {
245
+ ok: false,
246
+ kind: STARTUP_KINDS.OPAQUE_WRAPPER,
247
+ label,
248
+ reason: 'this host was started through ' + label + ', and the real command line cannot be restored: nothing is guessed',
249
+ }
250
+ }
251
+
252
+ /**
253
+ * The command that starts the host again, DETACHED from the shell that runs
254
+ * it.
255
+ *
256
+ * A host that was started with `nohup … &` (or inside a tmux/ssh session)
257
+ * must not die with the shell that runs the command: the new host has to
258
+ * outlive it.
259
+ *
260
+ * @param {string} command
261
+ * @param {string} platform
262
+ * @returns {string}
263
+ */
264
+ export function detachCommand(command, platform) {
265
+ const text = String(command ?? '').trim()
266
+ if (text === '') return ''
267
+ if (platform === 'win32') return 'start "" /B ' + text
268
+ return 'setsid nohup ' + text + ' >/dev/null 2>&1 &'
269
+ }
270
+
271
+ /**
272
+ * Classify HOW this host was started.
273
+ *
274
+ * @param {{
275
+ * platform?: string,
276
+ * env?: object,
277
+ * ppid?: number,
278
+ * isDesktopApp?: boolean,
279
+ * supervised?: boolean,
280
+ * container?: boolean,
281
+ * argv?: string[],
282
+ * execPath?: string,
283
+ * chain?: Array<object>,
284
+ * readChain?: Function,
285
+ * }} [options]
286
+ * @returns {{
287
+ * mode: string,
288
+ * kind: string,
289
+ * evidence: string,
290
+ * selfReplay: 'allowed'|'refused'|'unknown'|'n/a',
291
+ * canInstallSupervisor: boolean,
292
+ * detachCommand: boolean,
293
+ * suggestion: string,
294
+ * chain: Array<object>,
295
+ * }}
296
+ */
297
+ export function classifyStartup(options = {}) {
298
+ const platform = typeof options.platform === 'string' ? options.platform : process.platform
299
+ const env = options.env && typeof options.env === 'object' ? options.env : process.env
300
+ const chain = Array.isArray(options.chain)
301
+ ? options.chain
302
+ : readChainSafely(options)
303
+ const wsl = options.wsl === true || (options.wsl === undefined && isWsl(env))
304
+
305
+ const answer = (mode, kind, evidence, selfReplay, canInstallSupervisor, suggestion, extra = {}) => ({
306
+ mode,
307
+ kind,
308
+ evidence,
309
+ selfReplay,
310
+ canInstallSupervisor,
311
+ detachCommand: extra.detachCommand === true,
312
+ suggestion,
313
+ chain,
314
+ })
315
+
316
+ // (1) THE DESKTOP APP. It relaunches itself; nobody may install anything.
317
+ if (options.isDesktopApp === true) {
318
+ return answer(
319
+ STARTUP_MODES.DESKTOP,
320
+ STARTUP_KINDS.DESKTOP_APP,
321
+ 'the executable of this process is the desktop client',
322
+ 'n/a',
323
+ false,
324
+ 'the desktop app relaunches itself (restartMode: app) — there is no supervisor to install and no command to write',
325
+ )
326
+ }
327
+
328
+ // (2) OUR OWN SUPERVISOR started this instance: the restart is asked of it.
329
+ if (options.supervised === true) {
330
+ return answer(
331
+ STARTUP_MODES.SELF_SUPERVISED,
332
+ STARTUP_KINDS.OWN_SUPERVISOR,
333
+ 'the environment marker DSH_AUTORESTART_SUPERVISED says who started this instance',
334
+ 'n/a',
335
+ false,
336
+ 'the supervisor of this plugin is in charge: the restart is handed to it',
337
+ )
338
+ }
339
+
340
+ // (3) A CONTAINER. There is no "at login" inside a container; the
341
+ // orchestrator decides (compose: restart: always).
342
+ if (options.container === true) {
343
+ const kubernetes = typeof env.KUBERNETES_SERVICE_HOST === 'string' && env.KUBERNETES_SERVICE_HOST !== ''
344
+ return answer(
345
+ STARTUP_MODES.CONTAINER,
346
+ kubernetes ? STARTUP_KINDS.KUBERNETES : STARTUP_KINDS.DOCKER,
347
+ kubernetes ? 'the environment marker KUBERNETES_SERVICE_HOST' : 'the container markers of this process',
348
+ 'refused',
349
+ false,
350
+ 'this host runs in a container: there is no "at login" here — the orchestrator brings it back (compose: restart: always)',
351
+ )
352
+ }
353
+
354
+ // (4) THE ENVIRONMENT MARKERS of the common supervisors.
355
+ for (const marker of SUPERVISOR_ENV_MARKERS) {
356
+ const value = env[marker.key]
357
+ if (typeof value === 'string' && value !== '') {
358
+ return answer(
359
+ STARTUP_MODES.EXTERNAL,
360
+ marker.kind,
361
+ 'env:' + marker.key,
362
+ 'refused',
363
+ false,
364
+ 'this host is started by ' + marker.label + ' — it brings the host back, so the plugin only exits gracefully',
365
+ )
366
+ }
367
+ }
368
+
369
+ // (5) THE PROCESS CHAIN. An external match ANYWHERE in the chain wins: the
370
+ // nearest ancestor may be a plain shell that a supervisor started.
371
+ const external = firstMatch(chain, EXTERNAL_ANCESTORS)
372
+ if (external !== null) {
373
+ return answer(
374
+ STARTUP_MODES.EXTERNAL,
375
+ external.kind === '' ? STARTUP_KINDS.SCRIPT : external.kind,
376
+ ancestorEvidence(external.entry),
377
+ 'refused',
378
+ false,
379
+ 'this host is started by ' + external.label + ' — it brings the host back, so the plugin only exits gracefully',
380
+ )
381
+ }
382
+
383
+ // (6) WSL: a host that the WINDOWS side started has no visible parent
384
+ // here. It must NOT be treated as a bare host.
385
+ if (wsl && (chain.length === 0 || chain.every((entry) => Number(entry.pid) <= 1))) {
386
+ return answer(
387
+ STARTUP_MODES.UNKNOWN,
388
+ STARTUP_KINDS.WSL_FROM_WINDOWS,
389
+ chain.length === 0
390
+ ? 'the parent chain cannot be read (in WSL the Windows side is invisible)'
391
+ : 'the nearest ancestor is the WSL init — the Windows side that started it is invisible',
392
+ 'unknown',
393
+ false,
394
+ 'this host runs in WSL and was started by a Windows process: the parent is invisible here — pick the plain terminal (install your own supervisor) or configure the start command yourself',
395
+ )
396
+ }
397
+
398
+ // (7) THE COMMAND LINE. A package runner that hid the real command line
399
+ // cannot be replayed — and must not be guessed.
400
+ const command = inspectCommandLine({ argv: options.argv, execPath: options.execPath })
401
+ if (!command.ok) {
402
+ return answer(
403
+ STARTUP_MODES.UNKNOWN,
404
+ command.kind,
405
+ command.reason,
406
+ 'unknown',
407
+ false,
408
+ 'this host was started through ' + command.label + ' and the real command line cannot be restored: nothing is guessed, and no supervisor is installed',
409
+ )
410
+ }
411
+
412
+ // (8) A SESSION (tmux / screen / ssh): nobody restarts the host, so a
413
+ // replay is possible — but it must be detached from the session.
414
+ const session = firstMatch(chain, SESSION_ANCESTORS)
415
+ if (session !== null) {
416
+ return answer(
417
+ STARTUP_MODES.BARE,
418
+ STARTUP_KINDS.SESSION,
419
+ ancestorEvidence(session.entry),
420
+ 'allowed',
421
+ true,
422
+ 'this host runs in ' + session.label + ': nobody brings it back, so the restart command must be detached from the session (setsid/nohup) and needs your confirmation',
423
+ { detachCommand: true },
424
+ )
425
+ }
426
+
427
+ // (9) A DETACHED start (`nohup … &`): nobody brings it back either, but
428
+ // the replay must not die with the shell that runs it.
429
+ const detached = firstMatch(chain, DETACHED_ANCESTORS)
430
+ if (detached !== null) {
431
+ return answer(
432
+ STARTUP_MODES.BARE,
433
+ STARTUP_KINDS.DETACHED,
434
+ ancestorEvidence(detached.entry),
435
+ 'allowed',
436
+ true,
437
+ 'this host was started detached (' + detached.label + '): the restart command must outlive the shell that runs it (setsid/nohup)',
438
+ { detachCommand: true },
439
+ )
440
+ }
441
+
442
+ // (10) A PLAIN TERMINAL: nobody manages it. This is the only case in
443
+ // which the plugin may install its own supervisor.
444
+ if (chain.length > 0) {
445
+ const nearest = chain[0]
446
+ const terminal = firstMatch(chain, TERMINAL_ANCESTORS)
447
+ const label = terminal !== null ? terminal.label : 'the process that started it'
448
+ return answer(
449
+ STARTUP_MODES.BARE,
450
+ command.kind === STARTUP_KINDS.PACKAGE_RUNNER ? STARTUP_KINDS.PACKAGE_RUNNER : STARTUP_KINDS.TERMINAL,
451
+ 'parent:' + (Number.isFinite(Number(nearest.pid)) ? nearest.pid : '?') + ': '
452
+ + (nearest.imageName !== '' ? nearest.imageName : label)
453
+ + (command.kind === STARTUP_KINDS.PACKAGE_RUNNER ? ' (' + command.label + ')' : ''),
454
+ 'allowed',
455
+ true,
456
+ 'this host was started by hand in ' + label + ': nobody brings it back, so the plugin may install its own supervisor — or write down the command that starts it again (after your confirmation)',
457
+ )
458
+ }
459
+
460
+ // (11) NOTHING COULD BE READ. Say so, and stay conservative.
461
+ return answer(
462
+ STARTUP_MODES.UNKNOWN,
463
+ STARTUP_KINDS.UNREADABLE_CHAIN,
464
+ platform === 'win32'
465
+ ? 'the process table of Windows could not be read: who starts this host is unknown'
466
+ : 'the parent chain cannot be read: who starts this host is unknown',
467
+ 'unknown',
468
+ false,
469
+ 'it could not be established who starts this host: nothing is written and no supervisor is installed',
470
+ )
471
+ }
472
+
473
+ /**
474
+ * Read the parent chain without ever throwing.
475
+ *
476
+ * @param {{ ppid?: number, platform?: string, readChain?: Function }} options
477
+ * @returns {Array<object>}
478
+ */
479
+ function readChainSafely(options) {
480
+ const platform = typeof options.platform === 'string' ? options.platform : process.platform
481
+ const ppid = Number(options.ppid)
482
+ if (!Number.isFinite(ppid) || ppid <= 0) return []
483
+ const read = typeof options.readChain === 'function' ? options.readChain : null
484
+ if (read === null) return []
485
+ try {
486
+ const chain = read(ppid, { platform })
487
+ return Array.isArray(chain) ? chain : []
488
+ } catch {
489
+ return []
490
+ }
491
+ }