@yemi33/minions 0.1.2225 → 0.1.2227

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.
@@ -162,7 +162,8 @@ const RENDER_VERSIONS = {
162
162
  // cache-bust entry exists so future tick-driven code (or feature-flag-aware
163
163
  // renderers) can call `_changed('settings', …)` and bump on visual revamps.
164
164
  // Bumped to 2 by W-mpmwxkcn000646cc (left-rail tabbed Settings layout).
165
- settings: 2,
165
+ // Bumped to 3 by W-mqk2s8q1 (Advanced → Diagnostics: relocated the diag button).
166
+ settings: 3,
166
167
  };
167
168
  const _sectionCache = {};
168
169
  const _lastValueByKey = {};
@@ -1383,25 +1384,15 @@ switchPage(currentPage);
1383
1384
 
1384
1385
  window.MinionsRefresh = { refresh };
1385
1386
 
1386
- // ── Refresh-diagnostic footer chip + modal (W-mphejzx100081972) ───────────
1387
- // Always installed (P-c1d4e8b6 deleted the per-flag gate that previously
1388
- // guarded this). The chip floats bottom-left and opens a simple table view
1389
- // of the ring buffer plus a "send to engine" button that POSTs the
1390
- // snapshot to /api/diagnostics/refresh.
1391
- (function installRefreshDiagChip() {
1392
- try {
1393
- const chip = document.createElement('button');
1394
- chip.id = 'refresh-diag-chip';
1395
- chip.type = 'button';
1396
- chip.textContent = 'diag';
1397
- chip.title = 'Open dashboard refresh diagnostics';
1398
- // Bottom-LEFT (not right) to avoid overlapping the Command Center Send
1399
- // button when the CC drawer is open (the drawer covers the right edge).
1400
- chip.style.cssText = 'position:fixed;left:12px;bottom:12px;z-index:9999;background:var(--surface,#222);color:var(--muted,#aaa);border:1px solid var(--border,#444);border-radius:10px;padding:3px 8px;font-size:var(--text-sm);font-family:inherit;cursor:pointer;opacity:0.75';
1401
- chip.addEventListener('click', _openRefreshDiagModal);
1402
- document.body.appendChild(chip);
1403
- } catch { /* DOM may not be ready in unusual test embeds */ }
1404
- })();
1387
+ // ── Refresh-diagnostic modal (W-mphejzx100081972) ─────────────────────────
1388
+ // The modal renders a table view of the ring buffer plus a "send to engine"
1389
+ // button that POSTs the snapshot to /api/diagnostics/refresh. Previously
1390
+ // opened from a floating bottom-left "diag" chip injected into document.body;
1391
+ // relocated into Settings → Advanced → Diagnostics (W-mqk2s8q1) so it lives
1392
+ // alongside the other diagnostic/admin controls instead of overlaying every
1393
+ // page. Exposed on window so the Settings pane's inline handler (which runs
1394
+ // in global scope) can invoke it.
1395
+ try { window._openRefreshDiagModal = _openRefreshDiagModal; } catch { /* non-browser */ }
1405
1396
 
1406
1397
  function _openRefreshDiagModal() {
1407
1398
  // Remove any prior instance so re-clicks re-render against the latest buffer.
@@ -521,6 +521,15 @@ async function openSettings() {
521
521
  settingsToggle('Allow Temp Agents', 'set-allowTempAgents', !!e.allowTempAgents, 'Spawn ephemeral agents when all permanent agents are busy') +
522
522
  settingsToggle('Disable model discovery', 'set-disableModelDiscovery', !!e.disableModelDiscovery, 'Skip /api/runtimes/<name>/models REST calls fleet-wide. Settings UI falls back to free-text.') +
523
523
  settingsToggle('QA: dual-write JSON sidecars', 'set-qaDualWriteJson', e.qaDualWriteJson !== false, 'Keep mirroring qa-runs.json and qa-sessions.json on every mutation alongside the SQLite source of truth. Default ON. Disable only once external tooling has been migrated off the JSON files — disabling drops the rollback path back to the JSON store.') +
524
+ '</div>' +
525
+ // W-mqk2s8q1 — dashboard refresh diagnostics, relocated here from the
526
+ // floating bottom-left "diag" chip so it sits alongside the other
527
+ // diagnostic/admin controls. Opens the same ring-buffer modal defined in
528
+ // refresh.js (exposed as window._openRefreshDiagModal).
529
+ '<h4>Diagnostics</h4>' +
530
+ '<div data-search="diagnostics refresh diag ring buffer status poll etag">' +
531
+ '<button type="button" class="btn-add" onclick="window._openRefreshDiagModal && window._openRefreshDiagModal()">Open Refresh Diagnostics</button>' +
532
+ '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:4px">Inspect the dashboard\'s /api/status refresh ring buffer (response status, ETag hits, render timings, per-section change flags) and optionally send a snapshot to the engine.</div>' +
524
533
  '</div>';
525
534
 
526
535
  // Section registry — order is intentional (Runtime + Auto-fix surface first
@@ -27,7 +27,7 @@ require('./stdio-timestamps').installIfNotInstalled();
27
27
  const fs = require('fs');
28
28
  const path = require('path');
29
29
  const os = require('os');
30
- const { spawn, execSync } = require('child_process');
30
+ const { spawn, spawnSync, execSync } = require('child_process');
31
31
 
32
32
  // Lazy path getters route through engine/shared.js when available so the
33
33
  // supervisor's notion of "the engine dir" honors MINIONS_TEST_DIR (used by
@@ -128,6 +128,139 @@ function listeningPidsForPort(port) {
128
128
  } catch { return []; }
129
129
  }
130
130
 
131
+ // Reap-before-respawn (supervisor respawn-storm fix).
132
+ //
133
+ // checkEngine/checkDashboard only spawn a replacement — they never killed the
134
+ // orphan. When the engine's liveness signal (control.json `pid`) goes stale
135
+ // because a transient EPERM blocked the heartbeat tmp->rename on Windows, the
136
+ // supervisor kept respawning engines WITHOUT reaping the dead-but-still-running
137
+ // ones. Each extra engine contends harder on control.json -> more EPERM -> the
138
+ // PID stays stale -> unbounded accumulation (observed: 36 engines + 41
139
+ // dashboards, dozens of console-window popups per minute). Reaping every stray
140
+ // process that runs THIS install's engine.js/dashboard.js before spawning the
141
+ // replacement bounds the live count to ~1: a persistent EPERM degrades into a
142
+ // slow 1-in-1-out restart cycle instead of a runaway storm.
143
+ //
144
+ // Only ever called from the respawn branch, i.e. AFTER we've already concluded
145
+ // the watched process is dead — so every match is by definition an orphan that
146
+ // is safe to kill. Scoped to MINIONS_DIR's script path so a second Minions
147
+ // checkout on the same machine is never touched. Opt out via
148
+ // MINIONS_SUPERVISOR_REAP=0.
149
+ const REAP_ENABLED = process.env.MINIONS_SUPERVISOR_REAP !== '0';
150
+
151
+ function _normPath(p) { return String(p).replace(/\\/g, '/').toLowerCase(); }
152
+
153
+ // Split a command line into argv tokens, honoring double-quote grouping
154
+ // (Windows CommandLine quotes any path containing spaces, e.g.
155
+ // `"C:\Program Files\nodejs\node.exe"`). We only need this accurate enough to
156
+ // tell which token is the *script* being executed.
157
+ function _tokenizeCmdline(cmdline) {
158
+ const toks = [];
159
+ let cur = '';
160
+ let inQuote = false;
161
+ for (const ch of String(cmdline)) {
162
+ if (ch === '"') { inQuote = !inQuote; continue; }
163
+ if (!inQuote && (ch === ' ' || ch === '\t')) { if (cur) { toks.push(cur); cur = ''; } continue; }
164
+ cur += ch;
165
+ }
166
+ if (cur) toks.push(cur);
167
+ return toks;
168
+ }
169
+
170
+ // True iff `cmdline` RUNS `target` as its script — not merely mentions it as an
171
+ // argument. The script is the first `.js` token after the node executable, so
172
+ // `node .../engine.js start` matches but `node eslint.js .../engine.js` (where
173
+ // engine.js is an argument to a different script) does NOT. Exact, normalized
174
+ // equality also rejects longer-name false positives (`.../engine.js.bak`,
175
+ // `.../engine.json`) that a plain substring `includes` would catch. `target` is
176
+ // already normalized by the caller.
177
+ //
178
+ // This is intentionally stricter than substring matching: every orphan the
179
+ // supervisor needs to reap was spawned by spawnEngine()/spawnDashboard() with a
180
+ // byte-identical `path.join(MINIONS_DIR, '<script>.js')`, so exact script-token
181
+ // match catches all of them while a foreign process that only *names* the path
182
+ // is left alone (fail-safe: never kill on a loose match).
183
+ function _cmdRunsScript(cmdline, target) {
184
+ const toks = _tokenizeCmdline(cmdline);
185
+ // toks[0] is the node executable; the script is the first later .js token.
186
+ for (let i = 1; i < toks.length; i++) {
187
+ const norm = _normPath(toks[i]);
188
+ if (norm.endsWith('.js')) return norm === target;
189
+ }
190
+ return false;
191
+ }
192
+
193
+ // PIDs of `node` processes that RUN `needle` (a script path) as their script.
194
+ // Cross-platform: PowerShell CIM on Windows, `ps` elsewhere. Best effort —
195
+ // returns [] on any enumeration failure (fail-open: leak rather than risk
196
+ // killing the wrong process).
197
+ //
198
+ // The OS does the coarse filtering (WQL `CommandLine LIKE` / a `grep` on the
199
+ // script basename) so only candidate rows — not the machine's entire node/
200
+ // process table — cross into the JS heap; `_cmdRunsScript` then applies the
201
+ // exact script-position match. On a busy host (CI runners, many node procs)
202
+ // this keeps the buffered stdout to a handful of rows instead of megabytes.
203
+ // `maxBuffer` caps the read defensively in case the coarse filter still matches
204
+ // a lot. The basename is an internal constant (`engine.js`/`dashboard.js`), so
205
+ // it carries no shell/WQL-injection risk.
206
+ const _ENUM_MAX_BUFFER = 8 * 1024 * 1024;
207
+ function listNodePidsMatching(needle) {
208
+ const target = _normPath(needle);
209
+ const base = path.basename(needle); // coarse prefilter, e.g. 'engine.js'
210
+ const pids = [];
211
+ try {
212
+ let out = '';
213
+ if (isWin) {
214
+ // spawnSync (no shell) + -EncodedCommand sidesteps cmd.exe quoting — an
215
+ // inline `powershell -Command "... $(...) | ..."` via execSync gets its
216
+ // `$(...)`/`|` mangled by cmd.exe before PowerShell ever sees them.
217
+ const ps = `Get-CimInstance Win32_Process -Filter "name='node.exe' AND CommandLine LIKE '%${base}%'" | `
218
+ + 'ForEach-Object { "$($_.ProcessId)|$($_.CommandLine)" }';
219
+ const res = spawnSync('powershell',
220
+ ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(ps, 'utf16le').toString('base64')],
221
+ { encoding: 'utf8', timeout: 8000, windowsHide: true, maxBuffer: _ENUM_MAX_BUFFER });
222
+ out = (res && res.stdout) || '';
223
+ } else {
224
+ // grep prefilters to candidate rows; no match exits 1 -> execSync throws
225
+ // -> caught below -> [] (correct: nothing to reap).
226
+ out = execSync(`ps -eo pid=,args= | grep -iF -- '${base}'`,
227
+ { encoding: 'utf8', timeout: 8000, maxBuffer: _ENUM_MAX_BUFFER });
228
+ }
229
+ for (const line of out.split('\n')) {
230
+ const trimmed = line.trim();
231
+ if (!trimmed) continue;
232
+ // Windows rows are "PID|<full command line>"; ps rows are "PID <args>".
233
+ const m = isWin ? trimmed.match(/^(\d+)\|(.*)$/) : trimmed.match(/^(\d+)\s+(.*)$/);
234
+ if (!m) continue;
235
+ const pid = Number(m[1]);
236
+ if (pid && _cmdRunsScript(m[2], target)) pids.push(pid);
237
+ }
238
+ } catch { /* enumeration failed — fail-open with whatever we collected */ }
239
+ return pids;
240
+ }
241
+
242
+ function _killHard(pid) {
243
+ const shared = _sharedOrNull();
244
+ try {
245
+ if (shared && typeof shared.killImmediate === 'function') { shared.killImmediate({ pid }); return; }
246
+ } catch { /* fall through to platform kill */ }
247
+ try {
248
+ if (isWin) execSync(`taskkill /F /T /PID ${pid}`, { timeout: 5000, windowsHide: true, stdio: 'ignore' });
249
+ else process.kill(pid, 'SIGKILL');
250
+ } catch { /* already gone */ }
251
+ }
252
+
253
+ // Kill every stray process running `scriptPath`, except this supervisor.
254
+ // Returns the number reaped. No-op when MINIONS_SUPERVISOR_REAP=0.
255
+ function reapStrayProcesses(scriptPath, label) {
256
+ if (!REAP_ENABLED) return 0;
257
+ const pids = listNodePidsMatching(scriptPath).filter(p => p !== process.pid);
258
+ if (!pids.length) return 0;
259
+ console.log(`[supervisor] Reaping ${pids.length} stray ${label} process(es) before respawn: ${pids.join(', ')}`);
260
+ for (const pid of pids) _killHard(pid);
261
+ return pids.length;
262
+ }
263
+
131
264
  function openAppendFd(name) {
132
265
  // Try to use the same append-fd helper the rest of the codebase uses, fall
133
266
  // back to a plain fs.openSync(..., 'a') when it isn't available. We re-route
@@ -226,6 +359,9 @@ function checkEngine(now) {
226
359
  }
227
360
 
228
361
  console.log(`[supervisor] Engine PID ${control.pid || '(none)'} is dead — respawning...`);
362
+ // Reap any orphan engines first so a stale control.json PID can't snowball
363
+ // into an unbounded pile of contending engine processes.
364
+ reapStrayProcesses(path.join(MINIONS_DIR, 'engine.js'), 'engine');
229
365
  const newPid = spawnEngine();
230
366
  _lastEngineRespawnAt = now;
231
367
  console.log(`[supervisor] Engine respawned (new PID: ${newPid})`);
@@ -238,6 +374,10 @@ function checkDashboard(now) {
238
374
  if (pids.length > 0) return;
239
375
 
240
376
  console.log(`[supervisor] Dashboard not listening on port ${dashPort} — respawning...`);
377
+ // Reap stray dashboards first. Besides bounding the count, this collapses the
378
+ // port-desync case: orphan dashboards holding other ports are killed, freeing
379
+ // the canonical port for the fresh one whose beacon _resolveDashPort() reads.
380
+ reapStrayProcesses(path.join(MINIONS_DIR, 'dashboard.js'), 'dashboard');
241
381
  const newPid = spawnDashboard();
242
382
  _lastDashboardRespawnAt = now;
243
383
  console.log(`[supervisor] Dashboard respawned (new PID: ${newPid})`);
@@ -313,6 +453,9 @@ module.exports = {
313
453
  isStopIntentSet,
314
454
  isPidAlive,
315
455
  listeningPidsForPort,
456
+ listNodePidsMatching,
457
+ reapStrayProcesses,
458
+ _cmdRunsScript,
316
459
  openAppendFd,
317
460
  checkEngine,
318
461
  checkDashboard,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2225",
3
+ "version": "0.1.2227",
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"