@noleemits/vision-builder-control-mcp 4.46.2 → 4.48.1
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 +123 -11
- package/package.json +1 -1
- package/parent-watchdog.js +266 -94
package/index.js
CHANGED
|
@@ -111,7 +111,7 @@ process.on('SIGINT', () => {
|
|
|
111
111
|
// CONFIG
|
|
112
112
|
// ================================================================
|
|
113
113
|
|
|
114
|
-
const VERSION = '4.
|
|
114
|
+
const VERSION = '4.48.1';
|
|
115
115
|
const MIN_PLUGIN_VERSION = '4.13.0'; // Minimum WP plugin version required by this MCP server
|
|
116
116
|
|
|
117
117
|
// ================================================================
|
|
@@ -2429,7 +2429,8 @@ function getToolDefinitions() {
|
|
|
2429
2429
|
featured_image_id: { type: 'number', description: 'Media library attachment ID' },
|
|
2430
2430
|
featured_image_basename: { type: 'string', description: 'Filename of an attachment (e.g. "boating-accidents-v2.jpg") — resolves to the matching attachment ID. Use instead of featured_image_id when you have the source filename but not the ID.' },
|
|
2431
2431
|
elementor: { type: 'boolean', description: 'Set up for Elementor editing' },
|
|
2432
|
-
seo: { type: 'object', description: 'RankMath SEO: {title, description, focus_keyword, robots, ...}' }
|
|
2432
|
+
seo: { type: 'object', description: 'RankMath SEO: {title, description, focus_keyword, robots, ...}' },
|
|
2433
|
+
dedupe_by_title: { type: 'boolean', description: 'If true, check for an existing non-trashed post with the same (title, post_type) and return it instead of creating a duplicate. Response will have was_existing:true. Use for idempotent creates over flaky transports — protects against phantom duplicates when an MCP response times out but the server insert succeeded.' }
|
|
2433
2434
|
},
|
|
2434
2435
|
required: ['title']
|
|
2435
2436
|
}
|
|
@@ -2469,6 +2470,35 @@ function getToolDefinitions() {
|
|
|
2469
2470
|
required: ['post_id']
|
|
2470
2471
|
}
|
|
2471
2472
|
},
|
|
2473
|
+
{
|
|
2474
|
+
name: 'get_post_by_slug',
|
|
2475
|
+
description: 'Look up a single post by exact slug within a post type. Returns the post if found, or found:false if not. Use before create_post to check for slug collisions, or to resolve a slug into a post ID without scanning list_posts.',
|
|
2476
|
+
inputSchema: {
|
|
2477
|
+
type: 'object',
|
|
2478
|
+
properties: {
|
|
2479
|
+
slug: { type: 'string', description: 'Exact slug (post_name). Case-insensitive, sanitized server-side.' },
|
|
2480
|
+
post_type: { type: 'string', description: 'Post type slug. Default "post".' },
|
|
2481
|
+
status: { type: 'string', description: 'Comma-separated statuses to include. Default: publish,private,draft,pending (excludes trash).' }
|
|
2482
|
+
},
|
|
2483
|
+
required: ['slug']
|
|
2484
|
+
}
|
|
2485
|
+
},
|
|
2486
|
+
{
|
|
2487
|
+
name: 'change_post_type',
|
|
2488
|
+
description: 'Migrate a WordPress post to a different post type IN PLACE — preserves post ID, post_meta (SEO, featured image, custom fields), comments, attachments, post_date, and any taxonomy terms registered on both types. Atomic operation (single DB write to the post_type column, no save_post re-fire). Optionally rewrites internal links across all post_content and creates a RankMath 301 redirect from the old permalink. Use this instead of create_post + delete_post when moving a page to a CPT — it is faster, lossless, and keeps the URL history.',
|
|
2489
|
+
inputSchema: {
|
|
2490
|
+
type: 'object',
|
|
2491
|
+
properties: {
|
|
2492
|
+
post_id: { type: 'number', description: 'WordPress post/page ID to migrate' },
|
|
2493
|
+
new_post_type: { type: 'string', description: 'Target post type slug. Must be registered.' },
|
|
2494
|
+
slug_override: { type: 'string', description: 'Optional. Force a specific slug. If omitted and the existing slug collides on the new post type, a unique suffix is added automatically.' },
|
|
2495
|
+
update_internal_links: { type: 'boolean', description: 'Rewrite old permalink → new permalink across all post_content. Default: true.' },
|
|
2496
|
+
create_redirect: { type: 'boolean', description: 'Create a RankMath 301 from the old permalink to the new one. Default: true. Requires RankMath active.' },
|
|
2497
|
+
flush_rewrites: { type: 'boolean', description: 'Flush WP rewrite rules after the change. Default: true.' }
|
|
2498
|
+
},
|
|
2499
|
+
required: ['post_id', 'new_post_type']
|
|
2500
|
+
}
|
|
2501
|
+
},
|
|
2472
2502
|
// ── RankMath SEO ──
|
|
2473
2503
|
{
|
|
2474
2504
|
name: 'get_seo',
|
|
@@ -3857,6 +3887,18 @@ function getToolDefinitions() {
|
|
|
3857
3887
|
},
|
|
3858
3888
|
required: ['page_id']
|
|
3859
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
|
+
}
|
|
3860
3902
|
}
|
|
3861
3903
|
];
|
|
3862
3904
|
}
|
|
@@ -4794,11 +4836,37 @@ async function handleToolCall(name, args) {
|
|
|
4794
4836
|
const body = { title: args.title };
|
|
4795
4837
|
const passthrough = ['post_type', 'content', 'excerpt', 'status', 'slug',
|
|
4796
4838
|
'parent', 'parent_slug', 'taxonomies', 'featured_image_id',
|
|
4797
|
-
'featured_image_basename', 'elementor', 'seo'];
|
|
4839
|
+
'featured_image_basename', 'elementor', 'seo', 'dedupe_by_title'];
|
|
4798
4840
|
for (const k of passthrough) if (args[k] !== undefined) body[k] = args[k];
|
|
4799
4841
|
const r = await apiCall('/posts', 'POST', body);
|
|
4800
4842
|
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
4801
|
-
|
|
4843
|
+
const prefix = r.was_existing ? 'Post already existed (dedupe hit)' : 'Post created';
|
|
4844
|
+
return ok(`${prefix}!\nID: ${r.id} | Type: ${r.type} | Status: ${r.status}\nTitle: ${r.title}\nSlug: ${r.slug}\nURL: ${r.url}\nEdit: ${r.edit_url}`);
|
|
4845
|
+
}
|
|
4846
|
+
|
|
4847
|
+
case 'get_post_by_slug': {
|
|
4848
|
+
const params = new URLSearchParams();
|
|
4849
|
+
params.set('slug', args.slug);
|
|
4850
|
+
if (args.post_type) params.set('post_type', args.post_type);
|
|
4851
|
+
if (args.status) params.set('status', args.status);
|
|
4852
|
+
const r = await apiCall(`/posts/by-slug?${params}`);
|
|
4853
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
4854
|
+
if (!r.found) return ok(`No post with slug "${r.slug}" found in post type "${r.post_type}".`);
|
|
4855
|
+
return ok(`Found post!\nID: ${r.id} | Type: ${r.type} | Status: ${r.status}\nTitle: ${r.title}\nSlug: ${r.slug}\nURL: ${r.url}\nEdit: ${r.edit_url}`);
|
|
4856
|
+
}
|
|
4857
|
+
|
|
4858
|
+
case 'change_post_type': {
|
|
4859
|
+
const { post_id, ...body } = args;
|
|
4860
|
+
const r = await apiCall(`/posts/${post_id}/change-type`, 'POST', body);
|
|
4861
|
+
if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
|
|
4862
|
+
let msg = `Post type changed!\nID: ${r.id}\n`;
|
|
4863
|
+
msg += `Type: ${r.old_post_type} → ${r.new_post_type}\n`;
|
|
4864
|
+
msg += `Permalink: ${r.old_permalink} → ${r.new_permalink}\n`;
|
|
4865
|
+
if (r.slug_changed) msg += `Slug: ${r.old_slug} → ${r.new_slug} (collision auto-resolved)\n`;
|
|
4866
|
+
msg += `Internal links rewritten: ${r.internal_links_updated}\n`;
|
|
4867
|
+
msg += `301 redirect created: ${r.redirect_created ? 'yes' : 'no'}\n`;
|
|
4868
|
+
msg += `Rewrite rules flushed: ${r.rewrites_flushed ? 'yes' : 'no'}`;
|
|
4869
|
+
return ok(msg);
|
|
4802
4870
|
}
|
|
4803
4871
|
|
|
4804
4872
|
case 'update_post': {
|
|
@@ -7202,6 +7270,37 @@ async function handleToolCall(name, args) {
|
|
|
7202
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}` : ''}`);
|
|
7203
7271
|
}
|
|
7204
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
|
+
|
|
7205
7304
|
default:
|
|
7206
7305
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
7207
7306
|
}
|
|
@@ -7263,18 +7362,31 @@ async function startStdio() {
|
|
|
7263
7362
|
// NVBC_MCP_PARENT_WATCH=0. No-op in HTTP mode (only called from stdio).
|
|
7264
7363
|
startParentWatchdog();
|
|
7265
7364
|
|
|
7266
|
-
//
|
|
7267
|
-
|
|
7268
|
-
|
|
7269
|
-
|
|
7270
|
-
|
|
7271
|
-
|
|
7272
|
-
|
|
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'));
|
|
7273
7383
|
process.stdin.on('error', (err) => {
|
|
7274
7384
|
console.error('[LIFECYCLE] stdin error:', err.message);
|
|
7385
|
+
scheduleExit('stdin error');
|
|
7275
7386
|
});
|
|
7276
7387
|
process.stdout.on('error', (err) => {
|
|
7277
7388
|
console.error('[LIFECYCLE] stdout error:', err.message);
|
|
7389
|
+
scheduleExit('stdout error');
|
|
7278
7390
|
});
|
|
7279
7391
|
|
|
7280
7392
|
_server = createMcpServer();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noleemits/vision-builder-control-mcp",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.48.1",
|
|
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",
|
package/parent-watchdog.js
CHANGED
|
@@ -1,120 +1,228 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Parent-watchdog for the stdio MCP server.
|
|
3
3
|
*
|
|
4
|
-
* Walks the parent
|
|
5
|
-
* (
|
|
6
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
'
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
ppid:
|
|
47
|
-
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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Chain walking and anchor selection
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
85
168
|
|
|
86
|
-
|
|
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
|
|
177
|
+
if (!info) break;
|
|
178
|
+
chain.push({ pid: cur, ...info });
|
|
179
|
+
cur = info.ppid;
|
|
180
|
+
}
|
|
181
|
+
return chain;
|
|
182
|
+
}
|
|
89
183
|
|
|
90
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
201
|
+
function findAnchor(chain) {
|
|
202
|
+
if (chain.length === 0) return null;
|
|
103
203
|
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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
|
-
//
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
146
|
-
console.error('[WATCHDOG]
|
|
147
|
-
return { ownerPid: null, enabled: false, reason: '
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
}
|