@adhdev/daemon-core 1.0.18-rc.2 → 1.0.18-rc.3

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,248 @@
1
+ import { execFileSync, type ExecFileSyncOptions } from 'child_process';
2
+ import * as path from 'path';
3
+
4
+ export interface ProcessLifecycleOptions {
5
+ platform?: NodeJS.Platform;
6
+ execFileSync?: typeof import('child_process').execFileSync;
7
+ }
8
+
9
+ export interface OwnedProcessInfo {
10
+ pid: number;
11
+ commandLine: string | null;
12
+ }
13
+
14
+ function defaultExecFileSync(): typeof import('child_process').execFileSync {
15
+ return execFileSync;
16
+ }
17
+
18
+ function getWindowsProcessCommandLine(
19
+ pid: number,
20
+ exec: typeof import('child_process').execFileSync,
21
+ ): string | null {
22
+ const pidFilter = `ProcessId=${pid}`;
23
+
24
+ try {
25
+ const psOut = exec('powershell.exe', [
26
+ '-NoProfile',
27
+ '-NonInteractive',
28
+ '-ExecutionPolicy', 'Bypass',
29
+ '-Command',
30
+ `(Get-CimInstance Win32_Process -Filter "${pidFilter}").CommandLine`,
31
+ ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
32
+ const text = String(psOut).trim();
33
+ if (text) return text;
34
+ } catch {
35
+ // fall through to wmic fallback
36
+ }
37
+
38
+ try {
39
+ const wmicOut = exec('wmic', [
40
+ 'process', 'where', pidFilter, 'get', 'CommandLine',
41
+ ], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
42
+ const text = String(wmicOut).trim();
43
+ if (text) return text;
44
+ } catch {
45
+ // noop
46
+ }
47
+
48
+ return null;
49
+ }
50
+
51
+ export function getProcessCommandLine(
52
+ pid: number,
53
+ options: ProcessLifecycleOptions = {},
54
+ ): string | null {
55
+ if (!Number.isFinite(pid) || pid <= 0) return null;
56
+ const exec = options.execFileSync ?? defaultExecFileSync();
57
+ const platform = options.platform ?? process.platform;
58
+
59
+ if (platform === 'win32') {
60
+ return getWindowsProcessCommandLine(pid, exec);
61
+ }
62
+
63
+ try {
64
+ const text = String(exec('ps', ['-o', 'command=', '-p', String(pid)], {
65
+ encoding: 'utf8',
66
+ timeout: 3000,
67
+ stdio: ['ignore', 'pipe', 'ignore'],
68
+ })).trim();
69
+ return text || null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Extract the script argument (the token after the node executable) from a
77
+ * command line. Handles both quoted and unquoted executables/scripts.
78
+ */
79
+ export function parseNodeScriptPath(commandLine: string | null): string | null {
80
+ if (!commandLine) return null;
81
+ let rest = commandLine.trim();
82
+
83
+ // Skip the leading executable token.
84
+ if (rest.startsWith('"')) {
85
+ const end = rest.indexOf('"', 1);
86
+ if (end === -1) return null;
87
+ rest = rest.slice(end + 1).trim();
88
+ } else {
89
+ const idx = rest.search(/\s/);
90
+ if (idx === -1) return null;
91
+ rest = rest.slice(idx + 1).trim();
92
+ }
93
+ if (!rest) return null;
94
+
95
+ if (rest.startsWith('"')) {
96
+ const end = rest.indexOf('"', 1);
97
+ return end === -1 ? rest.slice(1) : rest.slice(1, end);
98
+ }
99
+ const idx = rest.search(/\s/);
100
+ return idx === -1 ? rest : rest.slice(0, idx);
101
+ }
102
+
103
+ function normalizeWindowsPath(value: string): string {
104
+ return value.toLowerCase().replace(/\//g, '\\').replace(/\\+$/, '');
105
+ }
106
+
107
+ function isCommandLineUnderPrefix(commandLine: string | null, prefix: string): boolean {
108
+ if (!commandLine) return false;
109
+ const needle = normalizeWindowsPath(prefix);
110
+ // Command-line paths may quote Windows separators as either single or
111
+ // doubled backslashes; collapse doubles before matching.
112
+ const haystack = commandLine.split('\\\\').join('\\').toLowerCase();
113
+ return haystack.includes(needle);
114
+ }
115
+
116
+ export function killProcess(pid: number, options: ProcessLifecycleOptions = {}): boolean {
117
+ if (!Number.isFinite(pid) || pid <= 0) return false;
118
+ const exec = options.execFileSync ?? defaultExecFileSync();
119
+ const platform = options.platform ?? process.platform;
120
+
121
+ try {
122
+ if (platform === 'win32') {
123
+ exec('taskkill', ['/PID', String(pid), '/T', '/F'], {
124
+ stdio: 'ignore',
125
+ windowsHide: true,
126
+ });
127
+ } else {
128
+ process.kill(pid, 'SIGTERM');
129
+ }
130
+ return true;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ export async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
137
+ const start = Date.now();
138
+ while (Date.now() - start < timeoutMs) {
139
+ try {
140
+ process.kill(pid, 0);
141
+ await new Promise((resolve) => setTimeout(resolve, 250));
142
+ } catch {
143
+ return true;
144
+ }
145
+ }
146
+ return false;
147
+ }
148
+
149
+ /**
150
+ * List Node processes whose command line places them under any of the supplied
151
+ * prefixes. Windows-only — the versioned-prefix lifecycle this supports does not
152
+ * exist on POSIX, so the helper is a no-op there.
153
+ */
154
+ export function listOwnedNodeProcesses(options: {
155
+ prefixes: readonly string[];
156
+ excludePids?: readonly number[];
157
+ markers?: readonly string[];
158
+ } & ProcessLifecycleOptions): OwnedProcessInfo[] {
159
+ const platform = options.platform ?? process.platform;
160
+ if (platform !== 'win32') return [];
161
+
162
+ const exec = options.execFileSync ?? defaultExecFileSync();
163
+ const prefixes = options.prefixes.map((p) => normalizeWindowsPath(p));
164
+ const exclude = new Set((options.excludePids ?? []).filter((n) => Number.isFinite(n) && n > 0));
165
+ // Command-line paths use Windows separators; normalize marker separators the
166
+ // same way so a marker like "dist/cli/index.js" matches "dist\cli\index.js".
167
+ const markers = (options.markers ?? []).map((m) => m.toLowerCase().replace(/\//g, '\\'));
168
+
169
+ let pids: number[] = [];
170
+ try {
171
+ const out = String(exec('powershell.exe', [
172
+ '-NoProfile',
173
+ '-NonInteractive',
174
+ '-ExecutionPolicy', 'Bypass',
175
+ '-Command',
176
+ 'Get-Process node -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id | ConvertTo-Json -Compress',
177
+ ], { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true })).trim();
178
+ if (out) {
179
+ const parsed = JSON.parse(out);
180
+ pids = Array.isArray(parsed) ? parsed : [parsed];
181
+ }
182
+ } catch {
183
+ return [];
184
+ }
185
+
186
+ const results: OwnedProcessInfo[] = [];
187
+ for (const pid of pids) {
188
+ if (!Number.isFinite(pid) || pid <= 0 || exclude.has(pid)) continue;
189
+ const commandLine = getProcessCommandLine(pid, { platform, execFileSync: exec });
190
+ if (!commandLine) continue;
191
+ const lower = commandLine.toLowerCase();
192
+ const underPrefix = prefixes.some((prefix) => lower.includes(prefix));
193
+ if (!underPrefix) continue;
194
+ if (markers.length > 0 && !markers.some((marker) => lower.includes(marker))) continue;
195
+ results.push({ pid, commandLine });
196
+ }
197
+ return results;
198
+ }
199
+
200
+ export async function stopOwnedProcesses(options: {
201
+ processes: readonly OwnedProcessInfo[];
202
+ waitMs?: number;
203
+ } & ProcessLifecycleOptions): Promise<{ stopped: number; survivors: OwnedProcessInfo[] }> {
204
+ const waitMs = options.waitMs ?? 15_000;
205
+ const killed = new Set<number>();
206
+
207
+ for (const p of options.processes) {
208
+ if (killProcess(p.pid, options)) killed.add(p.pid);
209
+ }
210
+
211
+ const survivors: OwnedProcessInfo[] = [];
212
+ for (const p of options.processes) {
213
+ if (!killed.has(p.pid)) {
214
+ survivors.push(p);
215
+ continue;
216
+ }
217
+ const exited = await waitForPidExit(p.pid, waitMs);
218
+ if (!exited) survivors.push(p);
219
+ }
220
+
221
+ // A process counts as stopped only when it left the process table; a failed
222
+ // kill or a post-kill timeout both land it in `survivors`.
223
+ return { stopped: options.processes.length - survivors.length, survivors };
224
+ }
225
+
226
+ export async function stopOwnedProcessesForPrefixes(options: {
227
+ prefixes: readonly string[];
228
+ excludePids?: readonly number[];
229
+ markers?: readonly string[];
230
+ waitMs?: number;
231
+ log?: (message: string) => void;
232
+ } & ProcessLifecycleOptions): Promise<{ stopped: number; survivors: OwnedProcessInfo[] }> {
233
+ const processes = listOwnedNodeProcesses(options);
234
+ if (processes.length === 0) return { stopped: 0, survivors: [] };
235
+
236
+ options.log?.(`Stopping ${processes.length} owned process(es) under prefixes: ${options.prefixes.join(', ')}`);
237
+ const result = await stopOwnedProcesses({
238
+ processes,
239
+ waitMs: options.waitMs,
240
+ platform: options.platform,
241
+ execFileSync: options.execFileSync,
242
+ });
243
+
244
+ if (result.survivors.length > 0) {
245
+ options.log?.(`Could not stop ${result.survivors.length} owned process(es): ${result.survivors.map((s) => s.pid).join(', ')}`);
246
+ }
247
+ return result;
248
+ }
@@ -4,11 +4,18 @@ import * as fs from 'fs';
4
4
  import * as os from 'os';
5
5
  import * as path from 'path';
6
6
  import {
7
+ ADHDEV_OWNED_MARKERS,
7
8
  createDefaultWindowsAtomicHooks,
8
9
  findPortableNode22,
9
10
  performWindowsAtomicUpgrade,
10
11
  resolveWindowsInstallerLayout,
11
12
  } from './windows-atomic-upgrade.js';
13
+ import {
14
+ getProcessCommandLine,
15
+ killProcess,
16
+ stopOwnedProcessesForPrefixes,
17
+ waitForPidExit,
18
+ } from './process-lifecycle.js';
12
19
 
13
20
  const UPGRADE_HELPER_ENV = 'ADHDEV_DAEMON_UPGRADE_HELPER';
14
21
 
@@ -235,77 +242,11 @@ export function execNpmCommandSync(
235
242
  );
236
243
  }
237
244
 
238
- function killPid(pid: number): boolean {
239
- try {
240
- if (process.platform === 'win32') {
241
- execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
242
- } else {
243
- process.kill(pid, 'SIGTERM');
244
- }
245
- return true;
246
- } catch {
247
- return false;
248
- }
249
- }
250
-
251
- function getWindowsProcessCommandLine(pid: number): string | null {
252
- const pidFilter = `ProcessId=${pid}`;
253
- try {
254
- const psOut = execFileSync('powershell.exe', [
255
- '-NoProfile',
256
- '-NonInteractive',
257
- '-ExecutionPolicy', 'Bypass',
258
- '-Command',
259
- `(Get-CimInstance Win32_Process -Filter "${pidFilter}").CommandLine`,
260
- ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).trim();
261
- if (psOut) return psOut;
262
- } catch {
263
- // fall through to wmic fallback
264
- }
265
-
266
- try {
267
- const wmicOut = execFileSync('wmic', [
268
- 'process', 'where', pidFilter, 'get', 'CommandLine',
269
- ], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).trim();
270
- if (wmicOut) return wmicOut;
271
- } catch {
272
- // noop
273
- }
274
- return null;
275
- }
276
-
277
- function getProcessCommandLine(pid: number): string | null {
278
- if (!Number.isFinite(pid) || pid <= 0) return null;
279
- if (process.platform === 'win32') return getWindowsProcessCommandLine(pid);
280
- try {
281
- const text = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], {
282
- encoding: 'utf8',
283
- timeout: 3000,
284
- stdio: ['ignore', 'pipe', 'ignore'],
285
- }).trim();
286
- return text || null;
287
- } catch {
288
- return null;
289
- }
290
- }
291
-
292
245
  function isManagedSessionHostPid(pid: number): boolean {
293
246
  const commandLine = getProcessCommandLine(pid);
294
247
  return !!commandLine && /session-host-daemon/i.test(commandLine);
295
248
  }
296
249
 
297
- async function waitForPidExit(pid: number, timeoutMs: number): Promise<void> {
298
- const start = Date.now();
299
- while (Date.now() - start < timeoutMs) {
300
- try {
301
- process.kill(pid, 0);
302
- await new Promise((resolve) => setTimeout(resolve, 250));
303
- } catch {
304
- return;
305
- }
306
- }
307
- }
308
-
309
250
  export async function stopSessionHostProcesses(appName: string): Promise<void> {
310
251
  const pidFile = path.join(os.homedir(), '.adhdev', `${appName}-session-host.pid`);
311
252
  let killedPid: number | null = null;
@@ -313,7 +254,7 @@ export async function stopSessionHostProcesses(appName: string): Promise<void> {
313
254
  if (fs.existsSync(pidFile)) {
314
255
  const pid = Number.parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
315
256
  if (Number.isFinite(pid) && pid !== process.pid && isManagedSessionHostPid(pid)) {
316
- if (killPid(pid)) killedPid = pid;
257
+ if (killProcess(pid)) killedPid = pid;
317
258
  }
318
259
  }
319
260
  } catch {
@@ -430,7 +371,7 @@ export async function stopForeignNativeAddonHolders(
430
371
  appendUpgradeLog(
431
372
  `Foreign native-addon holder found: pid ${holder.pid}${holder.commandLine ? ` — ${holder.commandLine}` : ''}`,
432
373
  );
433
- const killed = killPid(holder.pid);
374
+ const killed = killProcess(holder.pid);
434
375
  if (killed) {
435
376
  await waitForPidExit(holder.pid, 15000);
436
377
  appendUpgradeLog(`Terminated foreign native-addon holder pid ${holder.pid}`);
@@ -600,11 +541,31 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
600
541
  throw new Error(`portable Node.js 22 npm CLI is missing: ${npmCliPath}`);
601
542
  }
602
543
  appendUpgradeLog(`Installer-managed pointer layout detected; active prefix will remain untouched: ${windowsInstallerLayout.activePrefix}`);
544
+
545
+ // Terminate any ADHDev-owned process still executing from the current active
546
+ // prefix or the legacy stable shim tree before activation. The parent daemon
547
+ // and this helper itself are excluded: the parent is already exiting, and the
548
+ // helper must survive to complete the upgrade.
549
+ const upgradePids = [process.pid, payload.parentPid].filter((n): n is number => Number.isFinite(n) && n > 0);
550
+ const preStop = await stopOwnedProcessesForPrefixes({
551
+ prefixes: [windowsInstallerLayout.activePrefix, windowsInstallerLayout.stablePrefix],
552
+ excludePids: upgradePids,
553
+ markers: Array.from(ADHDEV_OWNED_MARKERS),
554
+ waitMs: 15_000,
555
+ log: appendUpgradeLog,
556
+ });
557
+ if (preStop.survivors.length > 0) {
558
+ throw new Error(
559
+ `Cannot upgrade: owned processes still running under current prefix: ${preStop.survivors.map((s) => s.pid).join(', ')}`
560
+ );
561
+ }
562
+
603
563
  await performWindowsAtomicUpgrade({
604
564
  layout: windowsInstallerLayout,
605
565
  packageName: payload.packageName,
606
566
  targetVersion: payload.targetVersion,
607
567
  portableNode,
568
+ excludePids: upgradePids,
608
569
  hooks: createDefaultWindowsAtomicHooks({
609
570
  packageName: payload.packageName,
610
571
  targetVersion: payload.targetVersion,
@@ -2,11 +2,21 @@ import { execFileSync, spawn, spawnSync, type ChildProcess } from 'child_process
2
2
  import * as fs from 'fs';
3
3
  import * as http from 'http';
4
4
  import * as path from 'path';
5
+ import { stopOwnedProcessesForPrefixes } from './process-lifecycle.js';
5
6
 
6
7
  const POINTER_NAME = '.adhdev-current';
7
8
  const STABLE_FILES = [POINTER_NAME, 'adhdev.cmd', 'adhdev.ps1', 'adhdev'] as const;
8
9
  const DEFAULT_HEALTH_PORT = 19222;
9
10
 
11
+ // Markers that identify a Node process as ADHDev-owned when its command line
12
+ // also lives under a versioned install prefix. This deliberately excludes
13
+ // arbitrary user scripts that happen to be located under ~/.adhdev.
14
+ export const ADHDEV_OWNED_MARKERS = [
15
+ 'session-host-daemon',
16
+ 'node_modules/adhdev',
17
+ 'node_modules/@adhdev/daemon-standalone',
18
+ ] as const;
19
+
10
20
  export interface WindowsInstallerLayout {
11
21
  homeDir: string;
12
22
  installRoot: string;
@@ -22,7 +32,7 @@ export interface WindowsAtomicUpgradeHooks {
22
32
  restartOld: (portableNode: string) => void;
23
33
  waitForHealth: (pid: number, targetVersion: string) => Promise<boolean>;
24
34
  stopProcess: (pid: number) => void;
25
- cleanup: (layout: WindowsInstallerLayout, activePrefix: string) => void;
35
+ cleanup: (layout: WindowsInstallerLayout, activePrefix: string) => void | Promise<void>;
26
36
  log: (message: string) => void;
27
37
  }
28
38
 
@@ -32,6 +42,8 @@ export interface WindowsAtomicUpgradeOptions {
32
42
  targetVersion: string;
33
43
  portableNode: string;
34
44
  hooks: WindowsAtomicUpgradeHooks;
45
+ /** PIDs that must never be terminated during prefix sweeps (helper + parent daemon). */
46
+ excludePids?: number[];
35
47
  }
36
48
 
37
49
  export interface WindowsAtomicUpgradeResult {
@@ -252,6 +264,25 @@ export async function performWindowsAtomicUpgrade(options: WindowsAtomicUpgradeO
252
264
  validateStagedCli(portableNode, stagedCliEntry, targetVersion);
253
265
  hooks.log(`Validated staged CLI and portable Node 22 shims in ${stagedPrefix}`);
254
266
 
267
+ // Stop every ADHDev-owned process still executing from the current active
268
+ // prefix or the legacy stable shim tree before moving the pointer. A stale
269
+ // session-host left running here keeps node-pty's native addon mapped from
270
+ // the old tree and resolves lazy requires against a deleted prefix after
271
+ // activation. Survivors block activation so the pointer is never corrupted.
272
+ const excludedPids = new Set([process.pid, ...(options.excludePids ?? [])].filter((n) => Number.isFinite(n) && n > 0));
273
+ const preStop = await stopOwnedProcessesForPrefixes({
274
+ prefixes: [layout.activePrefix, layout.stablePrefix],
275
+ excludePids: Array.from(excludedPids),
276
+ markers: Array.from(ADHDEV_OWNED_MARKERS),
277
+ waitMs: 15_000,
278
+ log: hooks.log,
279
+ });
280
+ if (preStop.survivors.length > 0) {
281
+ throw new Error(
282
+ `Cannot activate ${versionName}: ${preStop.survivors.length} owned process(es) still running under the current prefix`
283
+ );
284
+ }
285
+
255
286
  activated = true;
256
287
  publishStableShimsAndPointer(layout, versionName);
257
288
  hooks.log(`Atomically activated ${versionName}`);
@@ -261,7 +292,7 @@ export async function performWindowsAtomicUpgrade(options: WindowsAtomicUpgradeO
261
292
  throw new Error('replacement daemon did not pass the health/version gate');
262
293
  }
263
294
  hooks.log(`Replacement daemon pid ${daemonPid} passed health for ${targetVersion}`);
264
- hooks.cleanup(layout, stagedPrefix);
295
+ await hooks.cleanup(layout, stagedPrefix);
265
296
  return { stagedPrefix, stagedCliEntry, daemonPid };
266
297
  } catch (error) {
267
298
  if (restarted?.pid) hooks.stopProcess(restarted.pid);
@@ -274,7 +305,7 @@ export async function performWindowsAtomicUpgrade(options: WindowsAtomicUpgradeO
274
305
  }
275
306
  }
276
307
  try { hooks.restartOld(portableNode); } catch { hooks.log('Failed to restart the previous daemon during rollback'); }
277
- try { hooks.cleanup(layout, layout.activePrefix); } catch { /* failure path must preserve original error */ }
308
+ try { await hooks.cleanup(layout, layout.activePrefix); } catch { /* failure path must preserve original error */ }
278
309
  throw error;
279
310
  }
280
311
  }
@@ -353,7 +384,14 @@ export function createDefaultWindowsAtomicHooks(options: {
353
384
  stopProcess: (pid) => {
354
385
  try { execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }); } catch { /* noop */ }
355
386
  },
356
- cleanup: (layout, activePrefix) => boundedCleanupInactivePrefixes(layout, activePrefix, options.log),
387
+ cleanup: async (layout, activePrefix) => cleanupInactivePrefixesWithGuard({
388
+ layout,
389
+ activePrefix,
390
+ excludePids: [process.pid],
391
+ markers: Array.from(ADHDEV_OWNED_MARKERS),
392
+ waitMs: 15_000,
393
+ log: options.log,
394
+ }),
357
395
  log: options.log,
358
396
  };
359
397
  }
@@ -383,3 +421,57 @@ export function boundedCleanupInactivePrefixes(
383
421
  ], { timeout: 5000, windowsHide: true, stdio: 'ignore' });
384
422
  if (result.error || result.status !== 0) log('Bounded inactive-prefix cleanup was incomplete; future updates will retry');
385
423
  }
424
+
425
+ export async function cleanupInactivePrefixesWithGuard(options: {
426
+ layout: WindowsInstallerLayout;
427
+ activePrefix: string;
428
+ excludePids?: number[];
429
+ markers?: readonly string[];
430
+ waitMs?: number;
431
+ log?: (message: string) => void;
432
+ }): Promise<void> {
433
+ const { layout, activePrefix, log } = options;
434
+ let candidates: string[] = [];
435
+ try {
436
+ candidates = fs.readdirSync(layout.installRoot, { withFileTypes: true })
437
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith('version-'))
438
+ .map((entry) => path.join(layout.installRoot, entry.name))
439
+ .filter((entry) => normalizeForCompare(entry) !== normalizeForCompare(activePrefix))
440
+ .sort()
441
+ .slice(0, 8);
442
+ } catch {
443
+ return;
444
+ }
445
+ if (candidates.length === 0) return;
446
+
447
+ for (const candidate of candidates) {
448
+ const stopResult = await stopOwnedProcessesForPrefixes({
449
+ prefixes: [candidate],
450
+ excludePids: options.excludePids,
451
+ markers: options.markers,
452
+ waitMs: options.waitMs ?? 15_000,
453
+ log,
454
+ });
455
+ if (stopResult.survivors.length > 0) {
456
+ log?.(`Skipping cleanup of ${candidate}: ${stopResult.survivors.length} owned process(es) could not be stopped`);
457
+ continue;
458
+ }
459
+ removeInactivePrefix(candidate, log);
460
+ }
461
+ }
462
+
463
+ function removeInactivePrefix(target: string, log?: (message: string) => void): void {
464
+ try {
465
+ const escaped = quotePowerShellLiteral(target);
466
+ const script = `if (Test-Path -LiteralPath ${escaped}) { Remove-Item -LiteralPath ${escaped} -Recurse -Force }`;
467
+ const encoded = Buffer.from(script, 'utf16le').toString('base64');
468
+ const result = spawnSync('powershell.exe', [
469
+ '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded,
470
+ ], { timeout: 5000, windowsHide: true, stdio: 'ignore' });
471
+ if (result.error || result.status !== 0) {
472
+ log?.(`Failed to remove inactive prefix ${target}: ${result.error?.message || `exit ${result.status}`}`);
473
+ }
474
+ } catch (error: any) {
475
+ log?.(`Failed to remove inactive prefix ${target}: ${error?.message || String(error)}`);
476
+ }
477
+ }
@@ -8,6 +8,8 @@ import {
8
8
  type SessionHostEndpoint,
9
9
  type SessionHostRequestType,
10
10
  } from '@adhdev/session-host-core';
11
+ import { getProcessCommandLine, parseNodeScriptPath } from '../commands/process-lifecycle.js';
12
+ import { LOG } from '../logging/logger.js';
11
13
  import { ensureSessionHostReady as ensureSharedSessionHostReady } from './runtime-support.js';
12
14
  import { DEFAULT_SESSION_HOST_READY_TIMEOUT_MS } from '../runtime-defaults.js';
13
15
 
@@ -97,6 +99,16 @@ export function createManagedSessionHost(options: ManagedSessionHostOptions): Ma
97
99
  return require.resolve('@adhdev/session-host-daemon');
98
100
  }
99
101
 
102
+ function pathsEquivalent(left: string, right: string): boolean {
103
+ return path.resolve(left).toLowerCase() === path.resolve(right).toLowerCase();
104
+ }
105
+
106
+ function getRunningSessionHostScriptPath(pid: number): string | null {
107
+ const commandLine = getProcessCommandLine(pid);
108
+ if (!commandLine || !/session-host-daemon/i.test(commandLine)) return null;
109
+ return parseNodeScriptPath(commandLine);
110
+ }
111
+
100
112
  function getPidFile(): string {
101
113
  return path.join(os.homedir(), '.adhdev', `${appName}-session-host.pid`);
102
114
  }
@@ -178,6 +190,28 @@ export function createManagedSessionHost(options: ManagedSessionHostOptions): Ma
178
190
 
179
191
  async function ensureReady(): Promise<SessionHostEndpoint> {
180
192
  options.beforeEnsureReady?.();
193
+
194
+ // Defensive guard: on Windows the daemon may have been restarted from a
195
+ // new versioned prefix while a session-host from the old prefix is still
196
+ // running. Because the old host is reachable on the same socket endpoint,
197
+ // `ensureSharedSessionHostReady` would reuse it and lazy requires would
198
+ // resolve against the deleted tree. Detect the mismatch and stop the stale
199
+ // host before it can be reused; a matching or unreadable host is left alone.
200
+ if (process.platform === 'win32') {
201
+ const existingPid = getPid();
202
+ if (existingPid !== null) {
203
+ const runningPath = getRunningSessionHostScriptPath(existingPid);
204
+ const currentEntry = resolveEntry();
205
+ if (runningPath && !pathsEquivalent(runningPath, currentEntry)) {
206
+ LOG.warn(
207
+ 'SessionHost',
208
+ `Detected stale host pid ${existingPid} running from ${runningPath}; restarting from ${currentEntry}`,
209
+ );
210
+ stopManagedSessionHostProcess();
211
+ }
212
+ }
213
+ }
214
+
181
215
  try {
182
216
  return await ensureSharedSessionHostReady({
183
217
  appName,