@yemi33/minions 0.1.1737 → 0.1.1738

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1738 (2026-05-06)
4
+
5
+ ### Features
6
+ - add feature-flag registry for experimental UX gates
7
+ - graceful runtime-switch reset with transcript carryover
8
+
9
+ ### Fixes
10
+ - prevent hung callLLM Promises when child stdio doesn't drain
11
+
12
+ ### Other
13
+ - chore(gitignore): ignore root-level config.json.backup sidecar
14
+
3
15
  ## 0.1.1737 (2026-05-05)
4
16
 
5
17
  ### Other
@@ -101,6 +101,26 @@ function _ccActiveTab() {
101
101
  return _ccTabs.find(function(t) { return t.id === _ccActiveTabId; }) || null;
102
102
  }
103
103
 
104
+ // Build a plain-text transcript from a tab's stored messages — sent on every
105
+ // initial request so the server can carry it over if the session has to reset
106
+ // (runtime switch, system-prompt change). Last 20 user/assistant turns only;
107
+ // system/action rows are skipped because they're UI artifacts, not dialog.
108
+ var CC_TRANSCRIPT_MAX_TURNS = 20;
109
+ function _ccBuildTranscript(tab) {
110
+ if (!tab || !Array.isArray(tab.messages) || tab.messages.length === 0) return [];
111
+ var out = [];
112
+ for (var i = 0; i < tab.messages.length; i++) {
113
+ var m = tab.messages[i];
114
+ if (!m || (m.role !== 'user' && m.role !== 'assistant')) continue;
115
+ var html = typeof m.html === 'string' ? m.html : '';
116
+ var tmp = document.createElement('div');
117
+ tmp.innerHTML = html;
118
+ var text = (tmp.textContent || tmp.innerText || '').trim();
119
+ if (text) out.push({ role: m.role, text: text });
120
+ }
121
+ return out.slice(-CC_TRANSCRIPT_MAX_TURNS);
122
+ }
123
+
104
124
  function _ccMergeStreamText(prev, incoming) {
105
125
  // `prev` is already merged-clean from prior frames (server strips actions
106
126
  // before SSE emission, and any leaked partial was sanitized by the previous
@@ -756,7 +776,15 @@ async function _ccDoSend(message, skipUserMsg, forceTabId) {
756
776
  terminalEventSeen = true;
757
777
  _cleanupStreamDiv();
758
778
  if (evt.sessionReset) {
759
- addMsg('system', '<div style="text-align:center;padding:6px 12px;font-size:11px;color:var(--muted);background:var(--surface2);border-radius:6px;margin:4px 0">Minions was updated — started a fresh session with latest context.</div>', false, activeTabId);
779
+ var resetText;
780
+ if (evt.sessionResetReason === 'runtimeChanged' && evt.previousRuntime && evt.currentRuntime) {
781
+ resetText = 'Runtime switched (' + escHtml(evt.previousRuntime) + ' → ' + escHtml(evt.currentRuntime) + ') — started a fresh session and carried over recent history.';
782
+ } else if (evt.sessionResetReason === 'promptChanged') {
783
+ resetText = 'Minions was updated — started a fresh session and carried over recent history.';
784
+ } else {
785
+ resetText = 'Minions was updated — started a fresh session with latest context.';
786
+ }
787
+ addMsg('system', '<div style="text-align:center;padding:6px 12px;font-size:11px;color:var(--muted);background:var(--surface2);border-radius:6px;margin:4px 0">' + resetText + '</div>', false, activeTabId);
760
788
  }
761
789
  var finalText = _ccMergeStreamText(streamedText, evt.text || '');
762
790
  var rendered = renderMd(finalText || streamedText || '');
@@ -824,7 +852,7 @@ async function _ccDoSend(message, skipUserMsg, forceTabId) {
824
852
  while (true) {
825
853
  var consume = await _ccConsumeStream(
826
854
  reconnectAttempts === 0
827
- ? { message: message, tabId: activeTabId, sessionId: activeTab.sessionId || null }
855
+ ? { message: message, tabId: activeTabId, sessionId: activeTab.sessionId || null, transcript: _ccBuildTranscript(activeTab) }
828
856
  : { tabId: activeTabId, sessionId: activeTab.sessionId || null, reconnect: true },
829
857
  reconnectAttempts > 0
830
858
  );
@@ -0,0 +1,16 @@
1
+ // features-client.js — Client-side feature flag accessor.
2
+ // Reads `window.MINIONS_FEATURES` (data injected at HTML build time:
3
+ // { flags: {id: bool}, defaults: {id: bool} }) and exposes synchronous
4
+ // `MinionsFeatures.isOn(id)` so gated render code can branch without async.
5
+
6
+ window.MinionsFeatures = {
7
+ isOn: function(id) {
8
+ var f = window.MINIONS_FEATURES || { flags: {}, defaults: {} };
9
+ if (Object.prototype.hasOwnProperty.call(f.flags, id)) return f.flags[id] === true;
10
+ return f.defaults && f.defaults[id] === true;
11
+ },
12
+ _setLocal: function(id, enabled) {
13
+ if (!window.MINIONS_FEATURES) window.MINIONS_FEATURES = { flags: {}, defaults: {} };
14
+ window.MINIONS_FEATURES.flags[id] = enabled === true;
15
+ },
16
+ };
@@ -28,10 +28,18 @@ async function openSettings() {
28
28
 
29
29
  _settingsData = null;
30
30
  let data;
31
+ let featuresList = [];
31
32
  try {
32
- const res = await fetch('/api/settings');
33
- data = await res.json();
33
+ const [settingsRes, featuresRes] = await Promise.all([
34
+ fetch('/api/settings'),
35
+ fetch('/api/features').catch(() => null),
36
+ ]);
37
+ data = await settingsRes.json();
34
38
  _settingsData = data;
39
+ if (featuresRes && featuresRes.ok) {
40
+ const fjson = await featuresRes.json().catch(() => null);
41
+ if (fjson && Array.isArray(fjson.features)) featuresList = fjson.features;
42
+ }
35
43
  } catch (e) { showToast('cmd-toast', 'Failed to load settings: ' + e.message, false); return; }
36
44
 
37
45
  const e = data.engine || {};
@@ -276,6 +284,36 @@ async function openSettings() {
276
284
  '<h3 style="font-size:13px;color:var(--blue);margin-bottom:8px">Routing Table</h3>' +
277
285
  '<textarea id="set-routing" rows="12" style="width:100%;padding:8px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:monospace;font-size:11px;resize:vertical">' + escHtml(data.routing || '') + '</textarea>' +
278
286
 
287
+ // Toggles persist immediately via POST /api/features/toggle — no Save needed.
288
+ '<details id="settings-features-details" style="margin-top:16px;border-top:1px solid var(--border);padding-top:12px">' +
289
+ '<summary style="cursor:pointer;font-size:13px;color:var(--blue);user-select:none">Show experimental flags ' +
290
+ '<span style="font-size:10px;color:var(--muted)">(' + featuresList.length + ' registered)</span>' +
291
+ '</summary>' +
292
+ '<div style="font-size:10px;color:var(--muted);margin:8px 0 10px">In-progress UX or behavior gates. Toggles persist immediately. Registry: <code>engine/features.js</code>. Env override: <code>MINIONS_FEATURE_&lt;NAME&gt;=1</code>.</div>' +
293
+ (featuresList.length === 0
294
+ ? '<div style="font-size:11px;color:var(--muted);padding:12px;border:1px dashed var(--border);border-radius:4px;text-align:center">No experimental features registered. Add entries to <code>engine/features.js</code> to gate new work.</div>'
295
+ : '<div style="display:flex;flex-direction:column;gap:8px">' +
296
+ featuresList.map(function(f) {
297
+ const checked = f.enabled ? ' checked' : '';
298
+ const expiredBadge = f.expired
299
+ ? ' <span style="font-size:9px;padding:1px 5px;background:rgba(220,80,80,0.15);color:var(--red);border-radius:3px;margin-left:4px">EXPIRED</span>'
300
+ : '';
301
+ const meta = [];
302
+ if (f.addedIn) meta.push('added in ' + escHtml(f.addedIn));
303
+ if (f.expires) meta.push('expires ' + escHtml(f.expires));
304
+ meta.push(f.default ? 'default: on' : 'default: off');
305
+ return '<label data-feature-id="' + escHtml(f.id) + '" style="display:flex;align-items:flex-start;gap:8px;padding:8px;border:1px solid var(--border);border-radius:4px;cursor:pointer">' +
306
+ '<input type="checkbox" data-feature-toggle="' + escHtml(f.id) + '"' + checked + ' style="margin-top:3px;cursor:pointer">' +
307
+ '<div style="flex:1">' +
308
+ '<div style="font-size:11px;font-weight:600;color:var(--text)">' + escHtml(f.id) + expiredBadge + '</div>' +
309
+ (f.description ? '<div style="font-size:10px;color:var(--muted);margin-top:2px">' + escHtml(f.description) + '</div>' : '') +
310
+ '<div style="font-size:9px;color:var(--muted);margin-top:3px">' + meta.join(' · ') + '</div>' +
311
+ '</div>' +
312
+ '</label>';
313
+ }).join('') +
314
+ '</div>') +
315
+ '</details>' +
316
+
279
317
  '</div>';
280
318
 
281
319
  document.getElementById('modal-title').textContent = 'Settings';
@@ -312,6 +350,34 @@ async function openSettings() {
312
350
  // 3. On defaultCli change → re-fetch models so the input never shows stale list.
313
351
  // The same pattern wires ccCli → ccModel; ccCli inherits defaultCli when unset.
314
352
  initRuntimeFleetUI(e, agents);
353
+
354
+ document.querySelectorAll('input[data-feature-toggle]').forEach(function(input) {
355
+ input.addEventListener('change', async function() {
356
+ const id = input.getAttribute('data-feature-toggle');
357
+ const enabled = input.checked;
358
+ if (window.MinionsFeatures && typeof window.MinionsFeatures._setLocal === 'function') {
359
+ window.MinionsFeatures._setLocal(id, enabled);
360
+ }
361
+ try {
362
+ const r = await fetch('/api/features/toggle', {
363
+ method: 'POST',
364
+ headers: { 'Content-Type': 'application/json' },
365
+ body: JSON.stringify({ id, enabled }),
366
+ });
367
+ if (!r.ok) {
368
+ const j = await r.json().catch(() => ({}));
369
+ throw new Error(j.error || ('HTTP ' + r.status));
370
+ }
371
+ showToast('cmd-toast', 'Feature "' + id + '" ' + (enabled ? 'enabled' : 'disabled'), true);
372
+ } catch (err) {
373
+ input.checked = !enabled;
374
+ if (window.MinionsFeatures && typeof window.MinionsFeatures._setLocal === 'function') {
375
+ window.MinionsFeatures._setLocal(id, !enabled);
376
+ }
377
+ showToast('cmd-toast', 'Failed to toggle "' + id + '": ' + err.message, false);
378
+ }
379
+ });
380
+ });
315
381
  }
316
382
 
317
383
  async function initRuntimeFleetUI(engineCfg, agentsCfg) {
@@ -29,7 +29,7 @@ function buildDashboardHtml() {
29
29
  }
30
30
 
31
31
  const jsFiles = [
32
- 'utils', 'state', 'detail-panel', 'live-stream',
32
+ 'utils', 'state', 'features-client', 'detail-panel', 'live-stream',
33
33
  'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
34
34
  'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
35
35
  'render-other', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
package/dashboard.js CHANGED
@@ -30,6 +30,7 @@ const playbook = require('./engine/playbook');
30
30
  const dispatchMod = require('./engine/dispatch');
31
31
  const steering = require('./engine/steering');
32
32
  const projectDiscovery = require('./engine/project-discovery');
33
+ const features = require('./engine/features');
33
34
  const os = require('os');
34
35
 
35
36
  const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, reopenWorkItem } = shared;
@@ -448,7 +449,7 @@ function buildDashboardHtml() {
448
449
 
449
450
  // Assemble JS modules (order matters: utils → state → renderers → commands → refresh)
450
451
  const jsFiles = [
451
- 'utils', 'state', 'render-utils', 'detail-panel', 'live-stream',
452
+ 'utils', 'state', 'features-client', 'render-utils', 'detail-panel', 'live-stream',
452
453
  'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
453
454
  'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
454
455
  'render-other', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
@@ -461,10 +462,23 @@ function buildDashboardHtml() {
461
462
  jsHtml += `\n// ─── ${f}.js ────────────────────────────────────────\n${content}\n`;
462
463
  }
463
464
 
465
+ // Snapshot feature-flag state at build time so the client renders synchronously
466
+ // without an extra /api/features round-trip. Helper itself lives in
467
+ // dashboard/js/features-client.js (auto-concatenated below).
468
+ let featuresBoot = { flags: {}, defaults: {} };
469
+ try {
470
+ for (const f of features.listFeatures(queries.getConfig())) {
471
+ featuresBoot.flags[f.id] = f.enabled;
472
+ featuresBoot.defaults[f.id] = f.default;
473
+ }
474
+ } catch { /* registry empty or config unreadable — ship empty boot state */ }
475
+
476
+ const featuresBootstrap = `window.MINIONS_FEATURES = ${JSON.stringify(featuresBoot)};\n`;
477
+
464
478
  return layout
465
479
  .replace('/* __CSS__ */', () => css)
466
480
  .replace('<!-- __PAGES__ -->', () => pageHtml)
467
- .replace('/* __JS__ */', () => `window.__MINIONS_HOME = ${JSON.stringify(os.homedir())};\n${jsHtml}`);
481
+ .replace('/* __JS__ */', () => `window.__MINIONS_HOME = ${JSON.stringify(os.homedir())};\n${featuresBootstrap}${jsHtml}`);
468
482
  }
469
483
 
470
484
  let HTML_RAW = buildDashboardHtml();
@@ -1121,6 +1135,28 @@ function _readCcTabSessions({ prune = true } = {}) {
1121
1135
  return sessions;
1122
1136
  }
1123
1137
 
1138
+ const CC_CARRYOVER_MAX_TURNS = 20;
1139
+ const CC_CARRYOVER_PER_MSG_CHARS = 2000;
1140
+
1141
+ function _buildTranscriptCarryover(transcript, { previousRuntime } = {}) {
1142
+ if (!Array.isArray(transcript) || transcript.length === 0) return '';
1143
+ const filtered = transcript.filter(m => m && (m.role === 'user' || m.role === 'assistant') && typeof m.text === 'string' && m.text.trim());
1144
+ if (filtered.length === 0) return '';
1145
+ const recent = filtered.slice(-CC_CARRYOVER_MAX_TURNS);
1146
+ const truncated = filtered.length > recent.length;
1147
+ const header = previousRuntime
1148
+ ? `--- Previous conversation (carried over from ${previousRuntime} session) ---`
1149
+ : `--- Previous conversation (carried over) ---`;
1150
+ const lines = recent.map(m => {
1151
+ const who = m.role === 'user' ? 'User' : 'Assistant';
1152
+ let text = m.text.trim();
1153
+ if (text.length > CC_CARRYOVER_PER_MSG_CHARS) text = text.slice(0, CC_CARRYOVER_PER_MSG_CHARS) + '… [truncated]';
1154
+ return `${who}: ${text}`;
1155
+ });
1156
+ const truncationNote = truncated ? `[earlier messages truncated — showing last ${recent.length} of ${filtered.length}]\n\n` : '';
1157
+ return `${header}\n\n${truncationNote}${lines.join('\n\n')}\n\n--- Current message follows ---`;
1158
+ }
1159
+
1124
1160
  // Load persisted CC session on startup
1125
1161
  try {
1126
1162
  const saved = safeJson(path.join(ENGINE_DIR, 'cc-session.json'));
@@ -5513,7 +5549,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5513
5549
  // Session management — per-tab: use sessionId from request, don't mutate global ccSession
5514
5550
  let tabSessionId = body.sessionId || null;
5515
5551
  let sessionReset = false;
5516
- // If system prompt changed since this session was created, force a fresh session
5552
+ let sessionResetReason = null;
5553
+ let previousRuntime = null;
5554
+ const currentRuntime = shared.resolveCcCli(CONFIG.engine);
5555
+ // If system prompt or runtime changed since this session was created, force a fresh session
5517
5556
  if (tabSessionId) {
5518
5557
  const sessions = _readCcTabSessions();
5519
5558
  const tabEntry = sessions.find(s => s.id === (body.tabId || 'default'));
@@ -5523,12 +5562,19 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5523
5562
  } else if (tabEntry._promptHash && tabEntry._promptHash !== _ccPromptHash) {
5524
5563
  tabSessionId = null;
5525
5564
  sessionReset = true;
5565
+ sessionResetReason = 'promptChanged';
5566
+ } else if (tabEntry.runtime && tabEntry.runtime !== currentRuntime) {
5567
+ tabSessionId = null;
5568
+ sessionReset = true;
5569
+ sessionResetReason = 'runtimeChanged';
5570
+ previousRuntime = tabEntry.runtime;
5526
5571
  }
5527
5572
  }
5528
5573
  const wasResume = !!tabSessionId;
5529
5574
  const sessionId = tabSessionId || null;
5530
5575
  const preamble = wasResume ? '' : buildCCStatePreamble();
5531
- const prompt = (preamble ? preamble + '\n\n---\n\n' : '') + body.message;
5576
+ const carryover = sessionReset ? _buildTranscriptCarryover(body.transcript, { previousRuntime }) : '';
5577
+ const prompt = (preamble ? preamble + '\n\n---\n\n' : '') + (carryover ? carryover + '\n\n---\n\n' : '') + body.message;
5532
5578
 
5533
5579
  const { trackEngineUsage: trackUsage } = require('./engine/llm');
5534
5580
  const streamModel = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
@@ -5606,8 +5652,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5606
5652
  existing.turnCount = sessionReset ? 1 : (existing.turnCount || 0) + 1;
5607
5653
  existing.preview = preview;
5608
5654
  existing._promptHash = _ccPromptHash;
5655
+ existing.runtime = currentRuntime;
5609
5656
  } else {
5610
- sessions.push({ id: _persistTabId, title: (body.message || 'New chat').slice(0, 40), sessionId: responseSessionId, createdAt: new Date(now).toISOString(), lastActiveAt: new Date(now).toISOString(), turnCount: 1, preview, _promptHash: _ccPromptHash });
5657
+ sessions.push({ id: _persistTabId, title: (body.message || 'New chat').slice(0, 40), sessionId: responseSessionId, createdAt: new Date(now).toISOString(), lastActiveAt: new Date(now).toISOString(), turnCount: 1, preview, _promptHash: _ccPromptHash, runtime: currentRuntime });
5611
5658
  }
5612
5659
  safeWrite(CC_SESSIONS_PATH, sessions);
5613
5660
  } catch { /* non-critical */ }
@@ -5630,7 +5677,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5630
5677
  // Issue #1834: surface action JSON parse failures so the UI can warn
5631
5678
  // instead of silently dropping. Client renders this as a small notice.
5632
5679
  if (_actionParseError) donePayload.actionParseError = _actionParseError;
5633
- if (sessionReset) donePayload.sessionReset = true;
5680
+ if (sessionReset) {
5681
+ donePayload.sessionReset = true;
5682
+ if (sessionResetReason) donePayload.sessionResetReason = sessionResetReason;
5683
+ if (previousRuntime) donePayload.previousRuntime = previousRuntime;
5684
+ donePayload.currentRuntime = currentRuntime;
5685
+ }
5634
5686
  liveState.donePayload = donePayload;
5635
5687
  if (liveState.writer) liveState.writer(donePayload);
5636
5688
 
@@ -6253,6 +6305,33 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6253
6305
  } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
6254
6306
  }
6255
6307
 
6308
+ async function handleFeaturesList(req, res) {
6309
+ try {
6310
+ return jsonReply(res, 200, { features: features.listFeatures(CONFIG) });
6311
+ } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
6312
+ }
6313
+
6314
+ async function handleFeaturesToggle(req, res) {
6315
+ try {
6316
+ const body = await readBody(req);
6317
+ const id = body && typeof body.id === 'string' ? body.id.trim() : '';
6318
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
6319
+ if (!features.hasFeature(id)) {
6320
+ return jsonReply(res, 404, { error: `Unknown feature flag: "${id}". Register it in engine/features.js.` });
6321
+ }
6322
+ const enabled = body.enabled === true;
6323
+ const configPath = path.join(MINIONS_DIR, 'config.json');
6324
+ mutateJsonFileLocked(configPath, (cfg) => {
6325
+ if (!cfg.features || typeof cfg.features !== 'object') cfg.features = {};
6326
+ cfg.features[id] = enabled;
6327
+ return cfg;
6328
+ });
6329
+ reloadConfig();
6330
+ invalidateStatusCache();
6331
+ return jsonReply(res, 200, { ok: true, id, enabled });
6332
+ } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
6333
+ }
6334
+
6256
6335
  async function handleHealth(req, res) {
6257
6336
  const engine = getEngineState();
6258
6337
  const agents = getAgents();
@@ -7043,6 +7122,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7043
7122
  { method: 'POST', path: '/api/settings/routing', desc: 'Update routing.md', params: 'content', handler: handleSettingsRouting },
7044
7123
  { method: 'POST', path: '/api/settings/reset', desc: 'Reset engine + claude + agent settings to defaults', handler: handleSettingsReset },
7045
7124
 
7125
+ // Feature flags (experimental / in-progress UX gates — see engine/features.js)
7126
+ { method: 'GET', path: '/api/features', desc: 'List registered feature flags with current enabled state', handler: handleFeaturesList },
7127
+ { method: 'POST', path: '/api/features/toggle', desc: 'Enable/disable a registered feature flag', params: 'id, enabled', handler: handleFeaturesToggle },
7128
+
7046
7129
  // Teams Bot Framework webhook
7047
7130
  { method: 'POST', path: '/api/bot', desc: 'Bot Framework webhook for Teams integration', handler: handleTeamsBot },
7048
7131
  ];
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-05T22:02:28.540Z"
4
+ "cachedAt": "2026-05-06T00:16:35.626Z"
5
5
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * engine/features.js — Feature flag registry. Recipe in CLAUDE.md → "Feature Flags".
3
+ */
4
+
5
+ // Entry shape: id → { description, default: bool, addedIn?: version, expires?: ISO-date }
6
+ const FEATURES = {
7
+ // Example:
8
+ // 'ux-sidebar-v2': { description: '…', default: false, addedIn: '0.1.1738', expires: '2026-06-01' },
9
+ };
10
+
11
+ const ENV_TRUTHY = new Set(['1', 'true', 'on', 'yes']);
12
+ const ENV_FALSY = new Set(['0', 'false', 'off', 'no', '']);
13
+
14
+ function envKey(id) {
15
+ return 'MINIONS_FEATURE_' + String(id).toUpperCase().replace(/-/g, '_');
16
+ }
17
+
18
+ function readEnvOverride(id) {
19
+ const raw = process.env[envKey(id)];
20
+ if (raw === undefined) return undefined;
21
+ const v = String(raw).trim().toLowerCase();
22
+ if (ENV_TRUTHY.has(v)) return true;
23
+ if (ENV_FALSY.has(v)) return false;
24
+ return undefined;
25
+ }
26
+
27
+ function isFeatureOn(id, config, registry = FEATURES) {
28
+ if (!Object.prototype.hasOwnProperty.call(registry, id)) {
29
+ throw new Error(`Unknown feature flag: "${id}". Register it in engine/features.js.`);
30
+ }
31
+ const env = readEnvOverride(id);
32
+ if (env !== undefined) return env;
33
+ const fromConfig = config && config.features && config.features[id];
34
+ if (typeof fromConfig === 'boolean') return fromConfig;
35
+ return registry[id].default === true;
36
+ }
37
+
38
+ function listFeatures(config, registry = FEATURES) {
39
+ const now = Date.now();
40
+ return Object.entries(registry).map(([id, meta]) => {
41
+ const expiresAt = meta.expires ? Date.parse(meta.expires) : NaN;
42
+ return {
43
+ id,
44
+ description: meta.description || '',
45
+ default: meta.default === true,
46
+ enabled: isFeatureOn(id, config, registry),
47
+ addedIn: meta.addedIn || null,
48
+ expires: meta.expires || null,
49
+ expired: expiresAt < now,
50
+ };
51
+ });
52
+ }
53
+
54
+ function hasFeature(id, registry = FEATURES) {
55
+ return Object.prototype.hasOwnProperty.call(registry, id);
56
+ }
57
+
58
+ module.exports = { FEATURES, isFeatureOn, listFeatures, hasFeature };
package/engine/llm.js CHANGED
@@ -23,6 +23,7 @@ const { resolveRuntime } = require('./runtimes');
23
23
  const MINIONS_DIR = shared.MINIONS_DIR;
24
24
  const ENGINE_DIR = path.join(MINIONS_DIR, 'engine');
25
25
  const COPILOT_TASK_COMPLETE_GRACE_MS = 3000;
26
+ const LLM_EXIT_SETTLE_GRACE_MS = 1000;
26
27
  const MISSING_RUNTIME_EXIT_CODE = 78;
27
28
 
28
29
  // ─── Engine-Usage Metrics ────────────────────────────────────────────────────
@@ -609,6 +610,8 @@ function callLLM(promptText, sysPromptText, opts = {}) {
609
610
  maxBudget, bare, fallbackModel,
610
611
  ...runtimeFeatureOpts,
611
612
  });
613
+ let settled = false;
614
+ let exitSettleTimer = null;
612
615
  let taskCompleteTimer = null;
613
616
  const scheduleTaskCompleteClose = () => {
614
617
  if (taskCompleteTimer) return;
@@ -635,11 +638,16 @@ function callLLM(promptText, sysPromptText, opts = {}) {
635
638
 
636
639
  const timer = setTimeout(() => { shared.killImmediate(proc); }, timeout);
637
640
 
638
- proc.on('close', (code) => {
641
+ function finish(code) {
642
+ if (settled) return;
643
+ settled = true;
639
644
  clearTimeout(timer);
645
+ if (exitSettleTimer) clearTimeout(exitSettleTimer);
640
646
  clearTaskCompleteTimer();
641
647
  for (const f of cleanupFiles) safeUnlink(f);
642
648
  const parsed = acc.finalize();
649
+ try { proc.stdout?.destroy(); } catch {}
650
+ try { proc.stderr?.destroy(); } catch {}
643
651
  const durationMs = Date.now() - _startMs;
644
652
  const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
645
653
  // parseError lets the adapter classify obvious failure modes (auth /
@@ -659,10 +667,19 @@ function callLLM(promptText, sysPromptText, opts = {}) {
659
667
  runtime: runtime.name,
660
668
  errorClass: errInfo.code,
661
669
  });
670
+ }
671
+
672
+ proc.on('close', finish);
673
+ proc.on('exit', (code) => {
674
+ if (settled) return;
675
+ exitSettleTimer = setTimeout(() => finish(code), LLM_EXIT_SETTLE_GRACE_MS);
662
676
  });
663
677
 
664
678
  proc.on('error', (err) => {
679
+ if (settled) return;
680
+ settled = true;
665
681
  clearTimeout(timer);
682
+ if (exitSettleTimer) clearTimeout(exitSettleTimer);
666
683
  clearTaskCompleteTimer();
667
684
  for (const f of cleanupFiles) safeUnlink(f);
668
685
  shared.log('error', `LLM spawn error (${label}): ${err.message}`);
@@ -709,6 +726,8 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
709
726
  maxBudget, bare, fallbackModel,
710
727
  ...runtimeFeatureOpts,
711
728
  });
729
+ let settled = false;
730
+ let exitSettleTimer = null;
712
731
  let taskCompleteTimer = null;
713
732
  const scheduleTaskCompleteClose = () => {
714
733
  if (taskCompleteTimer) return;
@@ -738,11 +757,16 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
738
757
 
739
758
  const timer = setTimeout(() => { shared.killImmediate(proc); }, timeout);
740
759
 
741
- proc.on('close', (code) => {
760
+ function finish(code) {
761
+ if (settled) return;
762
+ settled = true;
742
763
  clearTimeout(timer);
764
+ if (exitSettleTimer) clearTimeout(exitSettleTimer);
743
765
  clearTaskCompleteTimer();
744
766
  for (const f of cleanupFiles) safeUnlink(f);
745
767
  const parsed = acc.finalize();
768
+ try { proc.stdout?.destroy(); } catch {}
769
+ try { proc.stderr?.destroy(); } catch {}
746
770
  const durationMs = Date.now() - _startMs;
747
771
  const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
748
772
  const errInfo = code !== 0
@@ -759,10 +783,19 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
759
783
  runtime: runtime.name,
760
784
  errorClass: errInfo.code,
761
785
  });
786
+ }
787
+
788
+ proc.on('close', finish);
789
+ proc.on('exit', (code) => {
790
+ if (settled) return;
791
+ exitSettleTimer = setTimeout(() => finish(code), LLM_EXIT_SETTLE_GRACE_MS);
762
792
  });
763
793
 
764
794
  proc.on('error', (err) => {
795
+ if (settled) return;
796
+ settled = true;
765
797
  clearTimeout(timer);
798
+ if (exitSettleTimer) clearTimeout(exitSettleTimer);
766
799
  clearTaskCompleteTimer();
767
800
  for (const f of cleanupFiles) safeUnlink(f);
768
801
  shared.log('error', `LLM-stream spawn error (${label}): ${err.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1737",
3
+ "version": "0.1.1738",
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"