@noleemits/vision-builder-control-mcp 4.47.0 → 4.48.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.
package/index.js CHANGED
@@ -111,7 +111,7 @@ process.on('SIGINT', () => {
111
111
  // CONFIG
112
112
  // ================================================================
113
113
 
114
- const VERSION = '4.47.0';
114
+ const VERSION = '4.48.3';
115
115
  const MIN_PLUGIN_VERSION = '4.13.0'; // Minimum WP plugin version required by this MCP server
116
116
 
117
117
  // ================================================================
@@ -3887,6 +3887,18 @@ function getToolDefinitions() {
3887
3887
  },
3888
3888
  required: ['page_id']
3889
3889
  }
3890
+ },
3891
+ // ── Class Registry ──
3892
+ {
3893
+ name: 'list_classes',
3894
+ description: 'List all registered CSS classes in the NVBC class registry. Returns classes with name, description, category, and which element types they apply to. Use to discover available classes before applying them via update_element. Optionally filter by category or element type.',
3895
+ inputSchema: {
3896
+ type: 'object',
3897
+ properties: {
3898
+ category: { type: 'string', enum: ['layout', 'cards', 'typography', 'hero', 'contact', 'decorative', 'utility'], description: 'Filter to a specific category.' },
3899
+ applies_to: { type: 'string', enum: ['container', 'widget', 'heading', 'button', 'image', 'any'], description: 'Filter to classes that apply to this element type.' }
3900
+ }
3901
+ }
3890
3902
  }
3891
3903
  ];
3892
3904
  }
@@ -7258,6 +7270,37 @@ async function handleToolCall(name, args) {
7258
7270
  return ok(`Front page set to "${r.front_page_title}" (ID: ${r.front_page_id})${r.blog_page_id ? `\nBlog page: ${r.blog_page_id}` : ''}`);
7259
7271
  }
7260
7272
 
7273
+ case 'list_classes': {
7274
+ const params = new URLSearchParams();
7275
+ if (args.category) params.set('category', args.category);
7276
+ if (args.applies_to) params.set('applies_to', args.applies_to);
7277
+
7278
+ const r = await apiCall(`/class-registry?${params}`);
7279
+
7280
+ let msg = `=== CSS CLASS REGISTRY (${r.total} classes) ===\n`;
7281
+ msg += `Categories: ${r.categories.join(', ')}\n\n`;
7282
+
7283
+ const byCategory = {};
7284
+ for (const c of r.classes) {
7285
+ if (!byCategory[c.category]) byCategory[c.category] = [];
7286
+ byCategory[c.category].push(c);
7287
+ }
7288
+
7289
+ for (const [cat, items] of Object.entries(byCategory)) {
7290
+ msg += `── ${cat.toUpperCase()} ──\n`;
7291
+ for (const c of items) {
7292
+ const tags = c.applies_to.join(', ');
7293
+ const src = c.source === 'plugin' ? '[plugin]' : '[site]';
7294
+ msg += ` .${c.name} ${src}\n`;
7295
+ msg += ` ${c.description}\n`;
7296
+ msg += ` Applies to: ${tags}\n`;
7297
+ }
7298
+ msg += '\n';
7299
+ }
7300
+
7301
+ return ok(msg);
7302
+ }
7303
+
7261
7304
  default:
7262
7305
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
7263
7306
  }
@@ -7319,18 +7362,31 @@ async function startStdio() {
7319
7362
  // NVBC_MCP_PARENT_WATCH=0. No-op in HTTP mode (only called from stdio).
7320
7363
  startParentWatchdog();
7321
7364
 
7322
- // Monitor stdin/stdout lifecycle (diagnostic)
7323
- process.stdin.on('end', () => {
7324
- console.error('[LIFECYCLE] stdin ended');
7325
- });
7326
- process.stdin.on('close', () => {
7327
- console.error('[LIFECYCLE] stdin closed');
7328
- });
7365
+ // Exit when the Claude session closes its end of the stdin pipe.
7366
+ // This is the primary self-destruct mechanism — the parent-watchdog is backup
7367
+ // for cases where Windows handle inheritance keeps stdin open after the parent dies.
7368
+ // Grace period: 5s covers any brief reconnects, then we shut down cleanly.
7369
+ let _exitScheduled = false;
7370
+ const scheduleExit = (reason) => {
7371
+ if (_exitScheduled) return;
7372
+ _exitScheduled = true;
7373
+ console.error(`[LIFECYCLE] ${reason} — exiting in 5s`);
7374
+ clearInterval(global._mcpKeepAlive);
7375
+ setTimeout(() => {
7376
+ console.error('[LIFECYCLE] grace period elapsed, exiting');
7377
+ process.exit(0);
7378
+ }, 5000);
7379
+ };
7380
+
7381
+ process.stdin.on('end', () => scheduleExit('stdin ended'));
7382
+ process.stdin.on('close', () => scheduleExit('stdin closed'));
7329
7383
  process.stdin.on('error', (err) => {
7330
7384
  console.error('[LIFECYCLE] stdin error:', err.message);
7385
+ scheduleExit('stdin error');
7331
7386
  });
7332
7387
  process.stdout.on('error', (err) => {
7333
7388
  console.error('[LIFECYCLE] stdout error:', err.message);
7389
+ scheduleExit('stdout error');
7334
7390
  });
7335
7391
 
7336
7392
  _server = createMcpServer();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noleemits/vision-builder-control-mcp",
3
- "version": "4.47.0",
3
+ "version": "4.48.3",
4
4
  "description": "Vision Builder Control MCP server - design token-driven page builder tools for WordPress/Elementor websites",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -1,120 +1,228 @@
1
1
  /**
2
2
  * Parent-watchdog for the stdio MCP server.
3
3
  *
4
- * Walks the parent-process chain at startup to find the "owning" session
5
- * (typically Claude Code / VS Code / Cursor / a terminal), then heartbeats
6
- * its existence every few seconds. When the owner dies whether cleanly
7
- * or via SIGKILL / taskkill /F / OS crash — the watchdog terminates this
8
- * process. Solves the orphan-accumulation leak where the global keepalive
9
- * interval in index.js was preventing exit after the host crashed.
4
+ * Walks the parent process chain at startup to find a stable "session anchor"
5
+ * (the process that represents the owning Claude session). Watches that
6
+ * anchor by PID + creation-time fingerprint. When it dies, this process exits.
10
7
  *
11
- * Works regardless of intermediate wrappers (npx, npm.cmd, cmd.exe) because
12
- * the chain walk skips over them and locks onto the last non-system ancestor.
8
+ * Anchor selection (in priority order):
9
+ * 1. `claude.exe` — Claude Desktop per-window process
10
+ * 2. A `node.exe` whose command line references claude-code / @anthropic CLI
11
+ * 3. First non-shell ancestor (skip `cmd.exe`, `conhost.exe`, `sh`, `bash`)
12
+ * 4. Direct parent (process.ppid) — last-resort fallback
13
13
  *
14
- * Disable per-process via NVBC_MCP_PARENT_WATCH=0.
14
+ * Why anchor selection matters:
15
+ * - Watching `process.ppid` alone fails on Windows when invoked via `npx -y`.
16
+ * The chain is: claude.exe → node(npx) → cmd.exe → node(MCP). `process.ppid`
17
+ * points to the `cmd.exe` shim, which dies within milliseconds of startup.
18
+ * Windows then recycles that PID, eventually assigning it to some unrelated
19
+ * long-lived process. The watchdog then sees "parent alive" forever.
20
+ * - Walking to the chain-top fails when running inside VS Code / Cursor: those
21
+ * processes outlive individual Claude sessions, so the watchdog never fires.
22
+ * - The fix is to pick a session-scoped anchor (claude.exe per window, or the
23
+ * claude-code node in VS Code) and validate by creation-time to defeat
24
+ * PID recycling.
25
+ *
26
+ * Env controls:
27
+ * NVBC_MCP_PARENT_WATCH=0 Disable watchdog entirely
28
+ * NVBC_MCP_WATCHDOG_OWNER_PID=N Override anchor selection (use PID N)
29
+ * NVBC_MCP_WATCHDOG_TRACE=1 Verbose logging of chain walk + ticks
15
30
  */
16
31
 
17
32
  import { execSync } from 'node:child_process';
18
- import { readFileSync } from 'node:fs';
19
- import { platform } from 'node:os';
20
33
 
21
34
  const CHECK_INTERVAL_MS = 7000;
22
- const MAX_CHAIN_DEPTH = 32;
23
-
24
- const SYSTEM_ROOTS_WIN = new Set([
25
- 'explorer.exe', 'services.exe', 'svchost.exe', 'wininit.exe',
26
- 'smss.exe', 'csrss.exe', 'lsass.exe', 'winlogon.exe',
27
- 'system', 'system idle process', 'registry', 'memory compression',
35
+ // Re-verify creation time every N ticks (catches PID recycling within ~N*7s).
36
+ const RECYCLE_CHECK_EVERY_N_TICKS = 8;
37
+ const SHELL_NAMES = new Set([
38
+ 'cmd.exe', 'conhost.exe', 'cmd', 'sh', 'bash', 'zsh', 'fish', 'dash', 'ksh'
28
39
  ]);
40
+ const TRACE = process.env.NVBC_MCP_WATCHDOG_TRACE === '1';
29
41
 
30
- /**
31
- * Snapshot every process on the system in one syscall.
32
- * Returns Map<pid, { pid, ppid, name }>.
33
- * Spawning PowerShell once is ~400ms; spawning it N times is N × 400ms,
34
- * which is why per-step queries were timing out before the watchdog could arm.
35
- */
36
- function snapshotWindows() {
37
- const out = execSync(
38
- 'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Json -Compress"',
39
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000, windowsHide: true, maxBuffer: 32 * 1024 * 1024 }
40
- );
41
- const data = JSON.parse(out);
42
+ function trace(msg) {
43
+ if (TRACE) console.error(`[WATCHDOG:TRACE] ${msg}`);
44
+ }
45
+
46
+ function isAlive(pid) {
47
+ try {
48
+ process.kill(pid, 0);
49
+ return true;
50
+ } catch (e) {
51
+ if (e.code === 'ESRCH') return false;
52
+ // EPERM: process exists but we can't signal it — still alive.
53
+ return true;
54
+ }
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Process-snapshot helpers (one-shot at startup, then cheap PID checks)
59
+ // ---------------------------------------------------------------------------
60
+
61
+ function parseCimDate(v) {
62
+ if (v == null) return null;
63
+ if (typeof v === 'number') return v;
64
+ if (typeof v === 'object' && v.value != null) return parseCimDate(v.value);
65
+ const s = String(v);
66
+ // PowerShell ConvertTo-Json on a DateTime emits /Date(milliseconds)/
67
+ const m = s.match(/\/Date\((-?\d+)\)\//);
68
+ if (m) return parseInt(m[1], 10);
69
+ // Or ISO 8601
70
+ const t = Date.parse(s);
71
+ return Number.isFinite(t) ? t : null;
72
+ }
73
+
74
+ function snapshotWin32() {
75
+ // Single PowerShell call. ConvertTo-Json -Depth 2 keeps CreationDate as
76
+ // a /Date(...)/ string. Cap rows defensively in case of huge process tables.
77
+ const ps = [
78
+ 'Get-CimInstance Win32_Process',
79
+ 'Select-Object ProcessId,ParentProcessId,Name,CommandLine,CreationDate',
80
+ 'ConvertTo-Json -Compress -Depth 2'
81
+ ].join(' | ');
82
+ const cmd = `powershell -NoProfile -NonInteractive -Command "${ps}"`;
83
+ const out = execSync(cmd, {
84
+ encoding: 'utf8',
85
+ stdio: ['ignore', 'pipe', 'ignore'],
86
+ timeout: 15000,
87
+ maxBuffer: 32 * 1024 * 1024,
88
+ });
89
+ const arr = JSON.parse(out);
90
+ const list = Array.isArray(arr) ? arr : [arr];
42
91
  const map = new Map();
43
- for (const row of (Array.isArray(data) ? data : [data])) {
44
- map.set(row.ProcessId, {
45
- pid: row.ProcessId,
46
- ppid: row.ParentProcessId,
47
- name: String(row.Name || ''),
92
+ for (const p of list) {
93
+ if (!p || typeof p.ProcessId !== 'number') continue;
94
+ map.set(p.ProcessId, {
95
+ ppid: typeof p.ParentProcessId === 'number' ? p.ParentProcessId : 0,
96
+ name: p.Name || '',
97
+ cmdline: p.CommandLine || '',
98
+ createDate: parseCimDate(p.CreationDate),
48
99
  });
49
100
  }
50
101
  return map;
51
102
  }
52
103
 
53
104
  function snapshotPosix() {
54
- // ps is universal. Single fork, all processes, parseable.
55
- const out = execSync('ps -axo pid=,ppid=,comm=', {
56
- encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000, maxBuffer: 32 * 1024 * 1024,
105
+ // `ps -axo pid=,ppid=,lstart=,command=` is portable across Linux/macOS.
106
+ // lstart is 5 whitespace-separated tokens (Day Mon DD HH:MM:SS YYYY).
107
+ const out = execSync('ps -axo pid=,ppid=,lstart=,command=', {
108
+ encoding: 'utf8',
109
+ stdio: ['ignore', 'pipe', 'ignore'],
110
+ timeout: 5000,
111
+ maxBuffer: 16 * 1024 * 1024,
57
112
  });
58
113
  const map = new Map();
59
114
  for (const line of out.split('\n')) {
60
- const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
115
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.+)$/);
61
116
  if (!m) continue;
62
117
  const pid = parseInt(m[1], 10);
63
118
  const ppid = parseInt(m[2], 10);
64
- map.set(pid, { pid, ppid, name: m[3].trim() });
119
+ const lstart = m[3];
120
+ const cmdline = m[4];
121
+ const name = (cmdline.split(/\s+/)[0] || '').split('/').pop() || '';
122
+ const ts = Date.parse(lstart);
123
+ map.set(pid, {
124
+ ppid,
125
+ name,
126
+ cmdline,
127
+ createDate: Number.isFinite(ts) ? ts : null,
128
+ });
65
129
  }
66
130
  return map;
67
131
  }
68
132
 
69
- /**
70
- * Walk up from process.pid using an in-memory snapshot. Returns the last
71
- * non-system ancestor PID, or null.
72
- */
73
- function findOwningAncestor() {
74
- const os = platform();
75
- let snapshot;
133
+ function snapshotProcesses() {
134
+ return process.platform === 'win32' ? snapshotWin32() : snapshotPosix();
135
+ }
136
+
137
+ // Single-PID creation-time refresh (used during recycle checks).
138
+ function fetchCreateDate(pid) {
139
+ if (process.platform === 'win32') {
140
+ const cmd = `powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CreationDate"`;
141
+ const out = execSync(cmd, {
142
+ encoding: 'utf8',
143
+ stdio: ['ignore', 'pipe', 'ignore'],
144
+ timeout: 5000,
145
+ }).trim();
146
+ if (!out) return null;
147
+ return parseCimDate(out);
148
+ }
149
+ // POSIX: use `ps -o lstart= -p <pid>` so the value matches snapshotPosix.
76
150
  try {
77
- snapshot = os === 'win32' ? snapshotWindows() : snapshotPosix();
78
- } catch (err) {
79
- throw new Error(`process snapshot failed: ${err.message}`);
151
+ const out = execSync(`ps -o lstart= -p ${pid}`, {
152
+ encoding: 'utf8',
153
+ stdio: ['ignore', 'pipe', 'ignore'],
154
+ timeout: 3000,
155
+ }).trim();
156
+ if (!out) return null;
157
+ const t = Date.parse(out);
158
+ return Number.isFinite(t) ? t : null;
159
+ } catch (e) {
160
+ if (e.status === 1) return null; // ps returns 1 when no such pid
161
+ throw e;
80
162
  }
163
+ }
81
164
 
82
- let cur = process.pid;
83
- let lastNonSystem = null;
84
- let depth = 0;
165
+ // ---------------------------------------------------------------------------
166
+ // Chain walking and anchor selection
167
+ // ---------------------------------------------------------------------------
85
168
 
86
- while (depth++ < MAX_CHAIN_DEPTH) {
169
+ function walkChain(snapshot, startPid, maxDepth = 30) {
170
+ const chain = [];
171
+ const seen = new Set();
172
+ let cur = startPid;
173
+ for (let i = 0; i < maxDepth; i++) {
174
+ if (!cur || cur <= 1 || seen.has(cur)) break;
175
+ seen.add(cur);
87
176
  const info = snapshot.get(cur);
88
- if (!info || !info.ppid) break;
177
+ if (!info) break;
178
+ chain.push({ pid: cur, ...info });
179
+ cur = info.ppid;
180
+ }
181
+ return chain;
182
+ }
89
183
 
90
- const ppid = info.ppid;
91
- if (ppid === 0) break; // Windows pseudo-root.
92
- if (os !== 'win32' && ppid === 1) break; // POSIX init/systemd/launchd.
184
+ function lower(s) { return (s || '').toLowerCase(); }
93
185
 
94
- if (os === 'win32') {
95
- const parentInfo = snapshot.get(ppid);
96
- if (!parentInfo) break;
97
- if (SYSTEM_ROOTS_WIN.has((parentInfo.name || '').toLowerCase())) break;
98
- }
186
+ function looksLikeClaudeCodeNode(entry) {
187
+ const name = lower(entry.name);
188
+ if (!/^node(\.exe)?$/.test(name)) return false;
189
+ const cmd = lower(entry.cmdline);
190
+ if (!cmd) return false;
191
+ // Match common ways the Claude Code CLI appears in the command line.
192
+ return (
193
+ cmd.includes('claude-code') ||
194
+ cmd.includes('@anthropic') ||
195
+ /[\\/]claude(?:\.cmd|\.js)?(?:\s|$)/.test(cmd) ||
196
+ cmd.includes('claude/cli.js') ||
197
+ cmd.includes('claude\\cli.js')
198
+ );
199
+ }
99
200
 
100
- lastNonSystem = ppid;
101
- cur = ppid;
102
- }
201
+ function findAnchor(chain) {
202
+ if (chain.length === 0) return null;
103
203
 
104
- return lastNonSystem;
105
- }
204
+ // 1. Claude Desktop per-window process.
205
+ const claudeDesktop = chain.find(x => /^claude\.exe$/i.test(x.name));
206
+ if (claudeDesktop) return { ...claudeDesktop, reason: 'claude_desktop' };
106
207
 
107
- function isAlive(pid) {
108
- try {
109
- process.kill(pid, 0);
110
- return true;
111
- } catch (e) {
112
- if (e.code === 'ESRCH') return false;
113
- // EPERM: process exists, we just can't signal it. Still alive.
114
- return true;
115
- }
208
+ // 2. Claude Code CLI node process (VS Code / terminal).
209
+ const claudeCli = chain.find(looksLikeClaudeCodeNode);
210
+ if (claudeCli) return { ...claudeCli, reason: 'claude_code_cli' };
211
+
212
+ // 3. First non-shell ancestor (skips cmd.exe between npx and the real MCP).
213
+ // chain[0] is ourselves start the search at index 1.
214
+ const nonShell = chain.slice(1).find(x => !SHELL_NAMES.has(lower(x.name)));
215
+ if (nonShell) return { ...nonShell, reason: 'first_non_shell_ancestor' };
216
+
217
+ // 4. Last-resort: direct parent.
218
+ if (chain[1]) return { ...chain[1], reason: 'direct_parent_fallback' };
219
+ return null;
116
220
  }
117
221
 
222
+ // ---------------------------------------------------------------------------
223
+ // Public API
224
+ // ---------------------------------------------------------------------------
225
+
118
226
  /**
119
227
  * Start the parent-watchdog. Should only be called from stdio mode.
120
228
  *
@@ -126,38 +234,102 @@ export function startParentWatchdog() {
126
234
  return { ownerPid: null, enabled: false, reason: 'env_disabled' };
127
235
  }
128
236
 
129
- let ownerPid = null;
237
+ let snapshot;
238
+ try {
239
+ snapshot = snapshotProcesses();
240
+ } catch (e) {
241
+ console.error(`[WATCHDOG] snapshot failed: ${e.message} — falling back to ppid`);
242
+ return startFallbackPpidWatch();
243
+ }
130
244
 
131
- // Explicit override — watch this PID instead of walking the chain.
132
- // Escape hatch for unusual process trees (tmux, screen, supervisor).
245
+ // Walk our chain.
246
+ const chain = walkChain(snapshot, process.pid);
247
+ if (TRACE) {
248
+ for (const entry of chain) {
249
+ trace(`chain pid=${entry.pid} ppid=${entry.ppid} name=${entry.name} createDate=${entry.createDate}`);
250
+ }
251
+ }
252
+
253
+ // Optional override via env var.
254
+ let anchor = null;
133
255
  const override = parseInt(process.env.NVBC_MCP_WATCHDOG_OWNER_PID || '', 10);
134
256
  if (Number.isFinite(override) && override > 0) {
135
- ownerPid = override;
257
+ const info = snapshot.get(override);
258
+ anchor = info
259
+ ? { pid: override, ...info, reason: 'env_override' }
260
+ : { pid: override, name: 'unknown', cmdline: '', createDate: null, ppid: 0, reason: 'env_override_unsnapshot' };
136
261
  } else {
137
- try {
138
- ownerPid = findOwningAncestor();
139
- } catch (err) {
140
- console.error(`[WATCHDOG] ancestry walk failed: ${err.message} — watchdog disabled`);
141
- return { ownerPid: null, enabled: false, reason: 'walk_failed' };
142
- }
262
+ anchor = findAnchor(chain);
143
263
  }
144
264
 
145
- if (!ownerPid) {
146
- console.error('[WATCHDOG] no non-system ancestor found — watchdog disabled (daemonized run?)');
147
- return { ownerPid: null, enabled: false, reason: 'no_ancestor' };
265
+ if (!anchor || !anchor.pid || anchor.pid <= 1) {
266
+ console.error('[WATCHDOG] could not identify anchor process — watchdog disabled');
267
+ return { ownerPid: null, enabled: false, reason: 'no_anchor' };
148
268
  }
149
269
 
150
- console.error(`[WATCHDOG] watching parent session pid=${ownerPid} (interval=${CHECK_INTERVAL_MS}ms)`);
270
+ const cachedCreateDate = anchor.createDate;
271
+ console.error(
272
+ `[WATCHDOG] anchor pid=${anchor.pid} name=${anchor.name} reason=${anchor.reason}` +
273
+ ` createDate=${cachedCreateDate ?? 'unknown'} (interval=${CHECK_INTERVAL_MS}ms)`
274
+ );
151
275
 
276
+ let tick = 0;
152
277
  const interval = setInterval(() => {
153
- if (!isAlive(ownerPid)) {
154
- console.error(`[WATCHDOG] parent pid=${ownerPid} is gone — shutting down`);
155
- // Bypass the global keepalive + beforeExit safety net in index.js.
278
+ tick++;
279
+ // Fast existence check.
280
+ if (!isAlive(anchor.pid)) {
281
+ console.error(`[WATCHDOG] anchor pid=${anchor.pid} (${anchor.name}) is gone — shutting down`);
156
282
  process.exit(0);
283
+ return;
284
+ }
285
+
286
+ // Periodic creation-time re-verification to catch PID recycling.
287
+ if (cachedCreateDate && tick % RECYCLE_CHECK_EVERY_N_TICKS === 0) {
288
+ try {
289
+ const current = fetchCreateDate(anchor.pid);
290
+ if (current == null) {
291
+ console.error(`[WATCHDOG] anchor pid=${anchor.pid} creation-date lookup returned empty — assuming gone, shutting down`);
292
+ process.exit(0);
293
+ return;
294
+ }
295
+ // Allow 2s slop for clock formatting differences.
296
+ if (Math.abs(current - cachedCreateDate) > 2000) {
297
+ console.error(
298
+ `[WATCHDOG] anchor pid=${anchor.pid} was recycled` +
299
+ ` (createDate cached=${cachedCreateDate} current=${current}) — shutting down`
300
+ );
301
+ process.exit(0);
302
+ return;
303
+ }
304
+ trace(`recycle-check ok (tick=${tick}, pid=${anchor.pid})`);
305
+ } catch (e) {
306
+ console.error(`[WATCHDOG] recycle-check failed (${e.message}) — shutting down to be safe`);
307
+ process.exit(0);
308
+ }
157
309
  }
158
310
  }, CHECK_INTERVAL_MS);
159
311
 
312
+ // unref so this interval doesn't prevent exit if everything else finishes.
160
313
  interval.unref();
161
314
 
162
- return { ownerPid, enabled: true };
315
+ return { ownerPid: anchor.pid, enabled: true, reason: anchor.reason };
316
+ }
317
+
318
+ // Fallback used when the process snapshot itself fails (PowerShell missing,
319
+ // ps unavailable, etc.). Watches process.ppid only — the pre-v4.48.1 behavior.
320
+ function startFallbackPpidWatch() {
321
+ const ownerPid = process.ppid;
322
+ if (!ownerPid || ownerPid <= 1) {
323
+ console.error('[WATCHDOG] no valid parent pid — disabled');
324
+ return { ownerPid: null, enabled: false, reason: 'no_parent' };
325
+ }
326
+ console.error(`[WATCHDOG] fallback: watching direct parent pid=${ownerPid} (interval=${CHECK_INTERVAL_MS}ms)`);
327
+ const interval = setInterval(() => {
328
+ if (!isAlive(ownerPid)) {
329
+ console.error(`[WATCHDOG] parent pid=${ownerPid} is gone — shutting down`);
330
+ process.exit(0);
331
+ }
332
+ }, CHECK_INTERVAL_MS);
333
+ interval.unref();
334
+ return { ownerPid, enabled: true, reason: 'fallback_ppid' };
163
335
  }