@yemi33/minions 0.1.1915 → 0.1.1916

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.
@@ -10,12 +10,11 @@ function prRow(pr) {
10
10
  // If PR is merged/abandoned, treat 'waiting' review as resolved
11
11
  const effectiveReviewStatus = (pr.status === 'merged' || pr.status === 'abandoned') && pr.reviewStatus === 'waiting' ? (pr.status === 'merged' ? 'approved' : 'pending') : pr.reviewStatus;
12
12
  const reviewSource = sq.status || effectiveReviewStatus || 'pending';
13
- const reviewEscalated = !!pr._evalEscalated;
14
- const reviewClass = reviewEscalated ? 'review-escalated' : reviewSource === 'approved' ? 'approved' : (reviewSource === 'changes-requested' || reviewSource === 'rejected') ? 'rejected' : reviewSource === 'waiting' ? 'building' : 'draft';
15
- const reviewLabel = reviewEscalated ? 'review loop escalated (build/conflict may still run)' : sq.status === 'waiting' ? 'reviewing (minions)' : sq.status ? sq.status + ' (minions)' : (effectiveReviewStatus || 'pending');
16
- const reviewTitle = reviewEscalated ? 'Review/re-review and review-fix automation stopped after evalMaxIterations; build-fix and conflict-fix automation may still run.' : '';
17
- const buildClass = pr.buildFixEscalated ? 'build-escalated' : pr._buildStatusStale ? 'build-stale' : pr.buildStatus === 'passing' ? 'build-pass' : pr.buildStatus === 'failing' ? 'build-fail' : pr.buildStatus === 'running' ? 'building' : 'no-build';
18
- const buildLabel = pr.buildFixEscalated ? 'escalated (' + (pr.buildFixAttempts || '?') + ' fixes)' : (pr.buildStatus || 'none') + (pr._buildStatusStale ? ' (stale)' : '');
13
+ const reviewClass = reviewSource === 'approved' ? 'approved' : (reviewSource === 'changes-requested' || reviewSource === 'rejected') ? 'rejected' : reviewSource === 'waiting' ? 'building' : 'draft';
14
+ const reviewLabel = sq.status === 'waiting' ? 'reviewing (minions)' : sq.status ? sq.status + ' (minions)' : (effectiveReviewStatus || 'pending');
15
+ const reviewTitle = '';
16
+ const buildClass = pr._buildStatusStale ? 'build-stale' : pr.buildStatus === 'passing' ? 'build-pass' : pr.buildStatus === 'failing' ? 'build-fail' : pr.buildStatus === 'running' ? 'building' : 'no-build';
17
+ const buildLabel = (pr.buildStatus || 'none') + (pr._buildStatusStale ? ' (stale)' : '');
19
18
  const buildTitle = pr._buildStatusDetail || '';
20
19
  const statusClass = pr.status === 'merged' ? 'merged' : pr.status === 'abandoned' ? 'rejected' : pr.status === 'active' ? 'active' : 'draft';
21
20
  const statusLabel = pr.status || 'active';
@@ -97,6 +97,7 @@ async function openSettings() {
97
97
  settingsToggle('Allow Temp Agents', 'set-allowTempAgents', !!e.allowTempAgents, 'Spawn ephemeral agents when all permanent agents are busy') +
98
98
  settingsToggle('Auto-archive Plans', 'set-autoArchive', !!e.autoArchive, 'Automatically archive plans after verify completes (off = manual archive via dashboard)') +
99
99
  settingsToggle('Auto-complete PRs', 'set-autoCompletePrs', !!e.autoCompletePrs, 'Auto-merge PRs when builds pass and review is approved (opt-in)') +
100
+ settingsToggle('CC Worker Pool', 'set-ccUseWorkerPool', !!e.ccUseWorkerPool, 'Route Command Center / doc-chat through a persistent copilot --acp worker per tab instead of spawning a fresh CLI per turn (opt-in)') +
100
101
  '</div>' +
101
102
 
102
103
  '<h3 style="font-size:13px;color:var(--blue);margin-bottom:8px">PR Polling &amp; Dispatch Gates</h3>' +
@@ -121,9 +122,7 @@ async function openSettings() {
121
122
 
122
123
  '<h3 style="font-size:13px;color:var(--blue);margin-bottom:8px">Limits &amp; Thresholds</h3>' +
123
124
  '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px">' +
124
- settingsField('Eval Max Iterations', 'set-evalMaxIterations', e.evalMaxIterations || 3, '', 'Max review→fix cycles before escalating (1-10)') +
125
125
  settingsField('Eval Max Cost', 'set-evalMaxCost', e.evalMaxCost === null || e.evalMaxCost === undefined ? '' : e.evalMaxCost, '$', 'USD ceiling per work item across all eval iterations (blank = no limit)') +
126
- settingsField('Max Build Fix Attempts', 'set-maxBuildFixAttempts', e.maxBuildFixAttempts || 3, '', 'Max auto-fix dispatches per PR before escalating to human (1-10)') +
127
126
  settingsField('Agent Busy Reassign', 'set-agentBusyReassignMs', e.agentBusyReassignMs || 600000, 'ms', 'Reassign work to another agent after it waits this long on a busy agent') +
128
127
  settingsField('Version Check Interval', 'set-versionCheckInterval', e.versionCheckInterval || 3600000, 'ms', 'How often to check npm for updates (default: 1 hour)') +
129
128
  settingsField('Ignored Comment Authors', 'set-ignoredCommentAuthors', (e.ignoredCommentAuthors || []).join(', '), '', 'Comma-separated usernames — comments auto-closed, never trigger fixes') +
@@ -230,7 +229,6 @@ async function openSettings() {
230
229
  settingsToggle('Copilot: suppress AGENTS.md', 'set-copilotSuppressAgentsMd', e.copilotSuppressAgentsMd !== false, '--no-custom-instructions: stops AGENTS.md auto-load from fighting Minions playbook prompts') +
231
230
  settingsToggle('Copilot: reasoning summaries', 'set-copilotReasoningSummaries', !!e.copilotReasoningSummaries, '--enable-reasoning-summaries (Anthropic-family models only)') +
232
231
  settingsToggle('Disable model discovery', 'set-disableModelDiscovery', !!e.disableModelDiscovery, 'Skip /api/runtimes/<name>/models REST calls fleet-wide. Settings UI falls back to free-text.') +
233
- settingsToggle('Use persistent Copilot worker pool (faster CC responses)', 'set-ccUseWorkerPool', !!e.ccUseWorkerPool, 'Experimental — sub-task C of W-mp2w003600196c51 (CC perf). When ON, Command Center routes through engine/cc-worker-pool.js (one persistent `copilot --acp` process per CC tab) instead of spawning a fresh CLI per turn. Saves ~14s of cold-start cost on warm follow-up turns. Engine/agent dispatch path is unchanged. Off by default.') +
234
232
  '</div>' +
235
233
  '<div style="display:grid;grid-template-columns:1fr 3fr;gap:8px;margin-top:8px">' +
236
234
  '<div>' +
@@ -601,13 +599,12 @@ async function saveSettings() {
601
599
  autoFixReviewFeedback: document.getElementById('set-autoFixReviewFeedback').checked,
602
600
  autoFixHumanComments: document.getElementById('set-autoFixHumanComments').checked,
603
601
  autoCompletePrs: document.getElementById('set-autoCompletePrs').checked,
602
+ ccUseWorkerPool: !!document.getElementById('set-ccUseWorkerPool')?.checked,
604
603
  adoPollEnabled: document.getElementById('set-adoPollEnabled').checked,
605
604
  ghPollEnabled: document.getElementById('set-ghPollEnabled').checked,
606
605
  prPollStatusEvery: document.getElementById('set-prPollStatusEvery').value,
607
606
  prPollCommentsEvery: document.getElementById('set-prPollCommentsEvery').value,
608
- evalMaxIterations: document.getElementById('set-evalMaxIterations').value,
609
607
  evalMaxCost: document.getElementById('set-evalMaxCost').value || null,
610
- maxBuildFixAttempts: document.getElementById('set-maxBuildFixAttempts').value,
611
608
  agentBusyReassignMs: document.getElementById('set-agentBusyReassignMs').value,
612
609
  ignoredCommentAuthors: document.getElementById('set-ignoredCommentAuthors').value,
613
610
  versionCheckInterval: document.getElementById('set-versionCheckInterval').value,
@@ -626,7 +623,6 @@ async function saveSettings() {
626
623
  copilotReasoningSummaries: !!document.getElementById('set-copilotReasoningSummaries')?.checked,
627
624
  maxBudgetUsd: (document.getElementById('set-maxBudgetUsd')?.value ?? '').trim(),
628
625
  disableModelDiscovery: !!document.getElementById('set-disableModelDiscovery')?.checked,
629
- ccUseWorkerPool: !!document.getElementById('set-ccUseWorkerPool')?.checked,
630
626
  maxTurnsByType: (function() {
631
627
  var mbt = {};
632
628
  var types = ['explore', 'ask', 'review', 'implement', 'fix', 'test', 'verify', 'plan', 'decompose'];
package/dashboard.js CHANGED
@@ -7941,32 +7941,44 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7941
7941
 
7942
7942
  const pathname = req.url.split('?')[0];
7943
7943
  const _reqStart = Date.now();
7944
+ // Wrap handler invocations so a thrown rejection never leaves the socket
7945
+ // half-open. Pre-fix, an async handler that threw after writeHead (or before
7946
+ // it could finalize) would leave the response without an end(), parking the
7947
+ // socket in CLOSE_WAIT on the server side indefinitely. Observed in the wild:
7948
+ // 150+ CLOSE_WAIT sockets pinned to port 7331 on a live dashboard.
7949
+ const _logIfApi = () => {
7950
+ if (pathname.startsWith('/api/') && !pathname.includes('/status') && !pathname.includes('/hot-reload') && !pathname.includes('/status-stream') && !pathname.includes('/browser-presence')) {
7951
+ console.log(` ${req.method} ${pathname} ${Date.now() - _reqStart}ms`);
7952
+ }
7953
+ };
7954
+ const _runHandler = async (handler, matchArg) => {
7955
+ try {
7956
+ const _result = await handler(req, res, matchArg);
7957
+ _logIfApi();
7958
+ return _result;
7959
+ } catch (err) {
7960
+ console.error(` ${req.method} ${pathname} handler error:`, err && err.stack || err);
7961
+ _logIfApi();
7962
+ try {
7963
+ if (!res.headersSent) jsonReply(res, 500, { error: 'Internal server error' }, req);
7964
+ else if (!res.writableEnded) res.end();
7965
+ } catch { /* socket may already be torn down */ }
7966
+ }
7967
+ };
7944
7968
  for (const route of ROUTES) {
7945
7969
  if (route.method !== req.method) continue;
7946
7970
  if (typeof route.path === 'string') {
7947
7971
  // For /api/skill, match with query string prefix since it has no fixed path variant
7948
7972
  if (route.path === '/api/skill') {
7949
7973
  if (!req.url.startsWith('/api/skill?') && req.url !== '/api/skill') continue;
7950
- const _result = await route.handler(req, res, {});
7951
- if (pathname.startsWith('/api/') && !pathname.includes('/status') && !pathname.includes('/hot-reload') && !pathname.includes('/status-stream') && !pathname.includes('/browser-presence')) {
7952
- console.log(` ${req.method} ${pathname} ${Date.now() - _reqStart}ms`);
7953
- }
7954
- return _result;
7974
+ return _runHandler(route.handler, {});
7955
7975
  }
7956
7976
  if (pathname !== route.path) continue;
7957
- const _result = await route.handler(req, res, {});
7958
- if (pathname.startsWith('/api/') && !pathname.includes('/status') && !pathname.includes('/hot-reload') && !pathname.includes('/status-stream') && !pathname.includes('/browser-presence')) {
7959
- console.log(` ${req.method} ${pathname} ${Date.now() - _reqStart}ms`);
7960
- }
7961
- return _result;
7977
+ return _runHandler(route.handler, {});
7962
7978
  }
7963
7979
  const m = pathname.match(route.path);
7964
7980
  if (m) {
7965
- const _result = await route.handler(req, res, m);
7966
- if (pathname.startsWith('/api/') && !pathname.includes('/status') && !pathname.includes('/hot-reload') && !pathname.includes('/status-stream') && !pathname.includes('/browser-presence')) {
7967
- console.log(` ${req.method} ${pathname} ${Date.now() - _reqStart}ms`);
7968
- }
7969
- return _result;
7981
+ return _runHandler(route.handler, m);
7970
7982
  }
7971
7983
  }
7972
7984
 
package/engine/cleanup.js CHANGED
@@ -659,6 +659,52 @@ async function runCleanup(config, verbose = false) {
659
659
  }
660
660
  } catch (e) { log('warn', 'cleanup zombie processes: ' + e.message); }
661
661
 
662
+ // 4b. Sweep stray MCP descendants. Spawn-agent.js reaps its descendants on
663
+ // close, but a hard kill (engine restart, OS crash) can skip that path and
664
+ // leave orphan node.exe processes running MCP servers (Azure, Playwright,
665
+ // Loop, etc.) reparented away from the Minions tree. This sweep walks
666
+ // descendants from every anchor we know (engine, dashboard, active spawn-
667
+ // agents) and kills any node MCP processes outside that set.
668
+ cleaned.strayMcps = 0;
669
+ try {
670
+ const allProcs = shared.listAllProcesses();
671
+ if (allProcs.length) {
672
+ const anchorPids = [process.pid];
673
+ for (const info of activeProcesses.values()) {
674
+ if (info?.proc?.pid) anchorPids.push(info.proc.pid);
675
+ }
676
+ // Dashboard runs as a sibling process (not parented to engine on Windows)
677
+ // and hosts Command Center + doc-chat sessions whose Claude CLI children
678
+ // spawn their own MCP servers. Without anchoring it, the sweep would
679
+ // classify those MCP grandchildren as stray and kill live CC state on
680
+ // every cleanup tick. control.json doesn't carry a dashboardPid field,
681
+ // so we anchor by scanning the snapshot we already have for any node
682
+ // running dashboard.js.
683
+ for (const p of allProcs) {
684
+ if (p.cmd && /[\\/]dashboard\.js(?![\w.-])/i.test(p.cmd)) anchorPids.push(p.pid);
685
+ }
686
+ const reach = shared.listProcessReachable(anchorPids, allProcs);
687
+ // MCP server commandlines. Matches scoped (@modelcontextprotocol/*,
688
+ // @<scope>/mcp[-*]) and flat (mcp-server-*, *-mcp-server,
689
+ // loop-mcp-server) package paths. Specifically does NOT match
690
+ // @github/copilot (the Copilot CLI itself) — that would false-positive
691
+ // and kill a user's interactive `copilot` in another terminal, since
692
+ // its parent chain has no Minions ancestor and so isn't in `reach`.
693
+ const mcpRe = /[\\/](?:@modelcontextprotocol[\\/]|@[\w.-]+[\\/]mcp(?:[\\/]|-[\w-]*[\\/])|mcp-server-[\w-]+|[\w-]+-mcp-server|(?:ms-)?loop-mcp-server)[\\/]?/i;
694
+ const strayPids = [];
695
+ for (const p of allProcs) {
696
+ if (!p.cmd || !/node(?:\.exe)?\b/i.test(p.name)) continue;
697
+ if (!mcpRe.test(p.cmd)) continue;
698
+ if (reach.has(p.pid)) continue;
699
+ strayPids.push(p.pid);
700
+ }
701
+ if (strayPids.length) {
702
+ cleaned.strayMcps = shared.killByPidsImmediate(strayPids);
703
+ log('info', `Cleanup: ${cleaned.strayMcps}/${strayPids.length} stray MCP descendants reaped`);
704
+ }
705
+ }
706
+ } catch (e) { log('warn', 'cleanup stray MCPs: ' + e.message); }
707
+
662
708
  // 5. Clean spawn-debug.log
663
709
  try { fs.unlinkSync(path.join(ENGINE_DIR, 'spawn-debug.log')); } catch { /* cleanup */ }
664
710
 
@@ -0,0 +1,5 @@
1
+ {
2
+ "runtime": "copilot",
3
+ "models": null,
4
+ "cachedAt": "2026-05-13T21:49:07.029Z"
5
+ }
package/engine/shared.js CHANGED
@@ -3146,6 +3146,185 @@ function killImmediate(proc) {
3146
3146
  }
3147
3147
  }
3148
3148
 
3149
+ // Single-PID kill (no /T tree walk) — used by the orphan-MCP sweep where we
3150
+ // already enumerated descendants ourselves and the parent is dead, so /T would
3151
+ // be a no-op anyway.
3152
+ function killByPidImmediate(pid) {
3153
+ const n = Number(pid);
3154
+ if (!Number.isInteger(n) || n <= 0 || n === process.pid) return false;
3155
+ if (process.platform === 'win32') {
3156
+ try { _execSync(`taskkill /PID ${n} /F`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); return true; }
3157
+ catch { return false; }
3158
+ }
3159
+ try { process.kill(n, 'SIGKILL'); return true; } catch { return false; }
3160
+ }
3161
+
3162
+ // Batched kill — one OS process for N PIDs. `taskkill` accepts repeated /PID
3163
+ // flags natively; on Unix we still loop process.kill, which is in-process and
3164
+ // cheap. Returns the count of successful kills.
3165
+ function killByPidsImmediate(pids) {
3166
+ const valid = (Array.isArray(pids) ? pids : [])
3167
+ .map(Number)
3168
+ .filter(n => Number.isInteger(n) && n > 0 && n !== process.pid);
3169
+ if (!valid.length) return 0;
3170
+ if (process.platform === 'win32') {
3171
+ const flags = valid.map(p => `/PID ${p}`).join(' ');
3172
+ try { _execSync(`taskkill /F ${flags}`, { stdio: 'pipe', timeout: 5000, windowsHide: true }); return valid.length; }
3173
+ catch {
3174
+ let killed = 0;
3175
+ for (const p of valid) { if (killByPidImmediate(p)) killed++; }
3176
+ return killed;
3177
+ }
3178
+ }
3179
+ let killed = 0;
3180
+ for (const p of valid) {
3181
+ try { process.kill(p, 'SIGKILL'); killed++; } catch { /* dead */ }
3182
+ }
3183
+ return killed;
3184
+ }
3185
+
3186
+ // ─── Process Table Enumeration ───────────────────────────────────────────────
3187
+ // Cross-platform listing of every live process as { pid, ppid, name, cmd? }.
3188
+ // `cmd` is best-effort — included on Windows (via wmic) and Linux (/proc); may
3189
+ // be empty on macOS without ps -ww.
3190
+
3191
+ function _parseWmicCsv(text) {
3192
+ const lines = String(text || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean);
3193
+ if (lines.length < 2) return [];
3194
+ const header = lines[0].split(',');
3195
+ const idx = (col) => header.findIndex(h => h.toLowerCase() === col.toLowerCase());
3196
+ const nameIdx = idx('Name');
3197
+ const ppidIdx = idx('ParentProcessId');
3198
+ const pidIdx = idx('ProcessId');
3199
+ const cmdIdx = idx('CommandLine');
3200
+ if (nameIdx < 0 || ppidIdx < 0 || pidIdx < 0) return [];
3201
+ const out = [];
3202
+ for (let i = 1; i < lines.length; i++) {
3203
+ // CommandLine can contain commas — naive split is fine because Name + PIDs
3204
+ // sit at fixed positions and CommandLine, when present, is the LAST column.
3205
+ const cols = lines[i].split(',');
3206
+ if (cols.length < 4) continue;
3207
+ const pid = parseInt(cols[pidIdx], 10);
3208
+ if (!Number.isInteger(pid) || pid <= 0) continue;
3209
+ const ppid = parseInt(cols[ppidIdx], 10);
3210
+ const name = cols[nameIdx] || '';
3211
+ const cmd = cmdIdx >= 0 ? cols.slice(cmdIdx).join(',') : '';
3212
+ out.push({ pid, ppid: Number.isInteger(ppid) ? ppid : 0, name, cmd });
3213
+ }
3214
+ return out;
3215
+ }
3216
+
3217
+ // PowerShell Get-CimInstance is the modern path (wmic is removed on Win11
3218
+ // 24H2+). We try it first and fall back to wmic for older Windows hosts.
3219
+ function _psListProcesses() {
3220
+ const script = "Get-CimInstance Win32_Process | Select-Object Name,ParentProcessId,ProcessId,CommandLine | ConvertTo-Json -Compress -Depth 2";
3221
+ try {
3222
+ const out = _execSync(
3223
+ `powershell -NoProfile -NonInteractive -Command "${script}"`,
3224
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
3225
+ );
3226
+ const parsed = JSON.parse(out);
3227
+ const arr = Array.isArray(parsed) ? parsed : [parsed];
3228
+ return arr.map(p => ({
3229
+ pid: Number(p.ProcessId),
3230
+ ppid: Number(p.ParentProcessId) || 0,
3231
+ name: p.Name || '',
3232
+ cmd: p.CommandLine || '',
3233
+ })).filter(p => Number.isInteger(p.pid) && p.pid > 0);
3234
+ } catch { return null; }
3235
+ }
3236
+
3237
+ function _winListProcesses() {
3238
+ const ps = _psListProcesses();
3239
+ if (ps && ps.length) return ps;
3240
+ try {
3241
+ const out = _execSync(
3242
+ 'wmic process get Name,ParentProcessId,ProcessId,CommandLine /format:csv',
3243
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
3244
+ );
3245
+ return _parseWmicCsv(out);
3246
+ } catch { return []; }
3247
+ }
3248
+
3249
+ function _unixListProcesses() {
3250
+ try {
3251
+ const out = _execSync(
3252
+ 'ps -A -o pid=,ppid=,comm=',
3253
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }
3254
+ );
3255
+ return out.split(/\r?\n/).map(line => {
3256
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
3257
+ if (!m) return null;
3258
+ return { pid: parseInt(m[1], 10), ppid: parseInt(m[2], 10), name: m[3], cmd: '' };
3259
+ }).filter(Boolean);
3260
+ } catch { return []; }
3261
+ }
3262
+
3263
+ function listAllProcesses() {
3264
+ return process.platform === 'win32' ? _winListProcesses() : _unixListProcesses();
3265
+ }
3266
+
3267
+ function _buildChildMap(processes) {
3268
+ const childMap = new Map();
3269
+ for (const p of processes) {
3270
+ if (!childMap.has(p.ppid)) childMap.set(p.ppid, []);
3271
+ childMap.get(p.ppid).push(p.pid);
3272
+ }
3273
+ return childMap;
3274
+ }
3275
+
3276
+ // BFS descendants of rootPid given a process snapshot. `allProcesses` is
3277
+ // injectable for tests and for amortizing one snapshot across multiple
3278
+ // `listProcessDescendants` calls in the same tick.
3279
+ function listProcessDescendants(rootPid, allProcesses = null) {
3280
+ const root = Number(rootPid);
3281
+ if (!Number.isInteger(root) || root <= 0) return [];
3282
+ const procs = Array.isArray(allProcesses) ? allProcesses : listAllProcesses();
3283
+ const childMap = _buildChildMap(procs);
3284
+ const result = [];
3285
+ const seen = new Set([root]);
3286
+ const queue = [root];
3287
+ while (queue.length) {
3288
+ const cur = queue.shift();
3289
+ const children = childMap.get(cur) || [];
3290
+ for (const c of children) {
3291
+ if (seen.has(c)) continue;
3292
+ seen.add(c);
3293
+ result.push(c);
3294
+ queue.push(c);
3295
+ }
3296
+ }
3297
+ return result;
3298
+ }
3299
+
3300
+ // Same BFS, but starts from any number of root PIDs and returns the full reach
3301
+ // set (including the roots themselves). Used by the orphan-MCP sweep to compute
3302
+ // "everything anchored to Minions" so non-anchored MCP node processes can be
3303
+ // identified.
3304
+ function listProcessReachable(rootPids, allProcesses = null) {
3305
+ const roots = (Array.isArray(rootPids) ? rootPids : [rootPids])
3306
+ .map(Number)
3307
+ .filter(n => Number.isInteger(n) && n > 0);
3308
+ if (!roots.length) return new Set();
3309
+ const procs = Array.isArray(allProcesses) ? allProcesses : listAllProcesses();
3310
+ const childMap = _buildChildMap(procs);
3311
+ const seen = new Set();
3312
+ const queue = [];
3313
+ for (const r of roots) {
3314
+ if (!seen.has(r)) { seen.add(r); queue.push(r); }
3315
+ }
3316
+ while (queue.length) {
3317
+ const cur = queue.shift();
3318
+ const children = childMap.get(cur) || [];
3319
+ for (const c of children) {
3320
+ if (seen.has(c)) continue;
3321
+ seen.add(c);
3322
+ queue.push(c);
3323
+ }
3324
+ }
3325
+ return seen;
3326
+ }
3327
+
3149
3328
  // ─── Work Items & Pull Requests Mutation Helpers ────────────────────────────
3150
3329
 
3151
3330
  /**
@@ -3604,6 +3783,11 @@ module.exports = {
3604
3783
  sleepMs,
3605
3784
  killGracefully,
3606
3785
  killImmediate,
3786
+ killByPidImmediate,
3787
+ killByPidsImmediate,
3788
+ listAllProcesses,
3789
+ listProcessDescendants,
3790
+ listProcessReachable,
3607
3791
  removeWorktree,
3608
3792
  _purgeReservedFiles, // exported for testing
3609
3793
  _WIN_RESERVED_NAMES, // exported for testing
@@ -35,7 +35,7 @@
35
35
  const fs = require('fs');
36
36
  const os = require('os');
37
37
  const path = require('path');
38
- const { runFile, cleanChildEnv, killGracefully, killImmediate, ts, resolveEngineCacheDir } = require('./shared');
38
+ const { runFile, cleanChildEnv, killGracefully, killImmediate, killByPidsImmediate, listProcessDescendants, ts, resolveEngineCacheDir } = require('./shared');
39
39
  const { resolveRuntime } = require('./runtimes');
40
40
  const { acquireAdoTokenSync, isLikelyAdoToken } = require('./ado-token');
41
41
 
@@ -484,6 +484,32 @@ function main() {
484
484
  }, MCP_STARTUP_TIMEOUT);
485
485
  proc.stdout.once('data', () => { gotFirstOutput = true; clearTimeout(startupTimer); });
486
486
 
487
+ // Descendant snapshot loop (orphan-MCP fix). The runtime CLI (claude/copilot)
488
+ // spawns one node child per configured MCP server (Azure, Playwright, Loop,
489
+ // …). When the runtime exits normally on Windows, those grandchildren are
490
+ // reparented and survive indefinitely — they don't watch stdin for EOF, and
491
+ // taskkill /T against the dead parent PID no longer finds them. We snapshot
492
+ // the descendant tree periodically while the runtime is alive so the close
493
+ // handler can reap any survivors by PID after the parent has gone.
494
+ const trackedDescendants = new Set();
495
+ let snapshotInFlight = false;
496
+ function snapshotDescendants() {
497
+ if (!proc.pid || snapshotInFlight) return;
498
+ snapshotInFlight = true;
499
+ try {
500
+ for (const pid of listProcessDescendants(proc.pid)) trackedDescendants.add(pid);
501
+ } catch { /* best-effort */ }
502
+ finally { snapshotInFlight = false; }
503
+ }
504
+ // Initial snapshot is deferred: MCP children spawn ~1-3s after runtime boot,
505
+ // and a 0ms snapshot would always be empty. Recurring snapshots run every
506
+ // 30s — Get-CimInstance enumerates every process on the box, so anything
507
+ // hotter starves the cleanup tick with N concurrent agents.
508
+ const initialSnapshotTimer = setTimeout(snapshotDescendants, 3000);
509
+ if (initialSnapshotTimer.unref) initialSnapshotTimer.unref();
510
+ const descTimer = setInterval(snapshotDescendants, 30000);
511
+ if (descTimer.unref) descTimer.unref();
512
+
487
513
  // Track the real OS exit code via the 'exit' event. Node's 'close' event
488
514
  // can report code=0 on Windows when the OS-level exit was non-zero
489
515
  // (observed empirically with both Claude Code CLI and Copilot CLI exiting
@@ -500,6 +526,18 @@ function main() {
500
526
  });
501
527
  proc.on('close', (code, signal) => {
502
528
  clearTimeout(startupTimer);
529
+ clearTimeout(initialSnapshotTimer);
530
+ clearInterval(descTimer);
531
+ // Final snapshot + reap, but only when the runtime actually spawned
532
+ // children. Read-only / very short agents (exit before the 3s initial
533
+ // snapshot fires) skip the wmic shell-out entirely.
534
+ if (trackedDescendants.size || gotFirstOutput) {
535
+ snapshotDescendants();
536
+ if (trackedDescendants.size) {
537
+ const reaped = killByPidsImmediate([...trackedDescendants]);
538
+ try { fs.appendFileSync(debugPath, `DESCENDANTS reaped=${reaped}/${trackedDescendants.size}\n`); } catch {}
539
+ }
540
+ }
503
541
  // Prefer the 'exit' event's code/signal when present — see note above.
504
542
  const effectiveCode = (realExitFromEvent != null) ? realExitFromEvent : code;
505
543
  const effectiveSignal = realSignalFromEvent || signal;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1915",
3
+ "version": "0.1.1916",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"