@yemi33/minions 0.1.1736 → 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 +17 -0
- package/dashboard/js/command-center.js +30 -2
- package/dashboard/js/features-client.js +16 -0
- package/dashboard/js/settings.js +68 -2
- package/dashboard-build.js +1 -1
- package/dashboard.js +89 -6
- package/engine/cleanup.js +27 -3
- package/engine/cli.js +17 -7
- package/engine/copilot-models.json +1 -1
- package/engine/dispatch.js +29 -11
- package/engine/features.js +58 -0
- package/engine/llm.js +142 -26
- package/engine/shared.js +1 -0
- package/engine.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
15
|
+
## 0.1.1737 (2026-05-05)
|
|
16
|
+
|
|
17
|
+
### Other
|
|
18
|
+
- perf: buffered metrics, snapshot-diff sidecar, parallel worktree branch probes
|
|
19
|
+
|
|
3
20
|
## 0.1.1736 (2026-05-05)
|
|
4
21
|
|
|
5
22
|
### 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
|
-
|
|
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
|
+
};
|
package/dashboard/js/settings.js
CHANGED
|
@@ -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
|
|
33
|
-
|
|
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_<NAME>=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) {
|
package/dashboard-build.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|
|
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
|
];
|
package/engine/cleanup.js
CHANGED
|
@@ -8,7 +8,7 @@ const path = require('path');
|
|
|
8
8
|
const shared = require('./shared');
|
|
9
9
|
const queries = require('./queries');
|
|
10
10
|
|
|
11
|
-
const { exec, execSilent, log, ts, ENGINE_DEFAULTS } = shared;
|
|
11
|
+
const { exec, execAsync, execSilent, log, ts, ENGINE_DEFAULTS } = shared;
|
|
12
12
|
const { safeJson, safeWrite, safeReadDir, mutateCooldowns, mutateWorkItems, mutateJsonFileLocked, getProjects, projectWorkItemsPath, projectPrPath,
|
|
13
13
|
sanitizeBranch, KB_CATEGORIES } = shared;
|
|
14
14
|
const { getDispatch, getAgentStatus } = queries;
|
|
@@ -56,6 +56,15 @@ function getWorktreeBranch(wtPath) {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
async function getWorktreeBranchAsync(wtPath) {
|
|
60
|
+
try {
|
|
61
|
+
const out = await execAsync(`git -C "${wtPath}" branch --show-current`, { encoding: 'utf8', timeout: 5000 });
|
|
62
|
+
return (out || '').toString().trim();
|
|
63
|
+
} catch {
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
let _orphanPidProcessNamesCache = null;
|
|
60
69
|
function _orphanPidProcessNames() {
|
|
61
70
|
if (_orphanPidProcessNamesCache) return _orphanPidProcessNamesCache;
|
|
@@ -134,7 +143,7 @@ function _killProcessInWorktree(dir, activeProcesses, activeIds) {
|
|
|
134
143
|
|
|
135
144
|
// ─── Cleanup Orchestrator ────────────────────────────────────────────────────
|
|
136
145
|
|
|
137
|
-
function runCleanup(config, verbose = false) {
|
|
146
|
+
async function runCleanup(config, verbose = false) {
|
|
138
147
|
const activeProcesses = engine().activeProcesses;
|
|
139
148
|
const projects = getProjects(config);
|
|
140
149
|
let cleaned = { tempFiles: 0, liveOutputs: 0, worktrees: 0, zombies: 0 };
|
|
@@ -248,11 +257,26 @@ function runCleanup(config, verbose = false) {
|
|
|
248
257
|
const dispatch = getDispatch();
|
|
249
258
|
const activeDispatchIds = new Set((dispatch.active || []).map(d => d.id));
|
|
250
259
|
|
|
260
|
+
// Probe `git branch --show-current` for every worktree in chunks of 5.
|
|
261
|
+
// Sequential probing was the dominant cost in the cleanup phase
|
|
262
|
+
// (5–15s tick stall every 10 ticks at 50+ worktrees), but unbounded
|
|
263
|
+
// Promise.all would spawn 50+ concurrent git children — bad on Windows
|
|
264
|
+
// where each fork pays AV-scan overhead. Mirrors engine/ado.js:611.
|
|
265
|
+
const BRANCH_PROBE_CONCURRENCY = 5;
|
|
266
|
+
const branchMap = new Map();
|
|
267
|
+
for (let i = 0; i < allDirs.length; i += BRANCH_PROBE_CONCURRENCY) {
|
|
268
|
+
const batch = allDirs.slice(i, i + BRANCH_PROBE_CONCURRENCY);
|
|
269
|
+
const pairs = await Promise.all(
|
|
270
|
+
batch.map(async ({ wtPath }) => [wtPath, await getWorktreeBranchAsync(wtPath)])
|
|
271
|
+
);
|
|
272
|
+
for (const [wtPath, branch] of pairs) branchMap.set(wtPath, branch);
|
|
273
|
+
}
|
|
274
|
+
|
|
251
275
|
for (const { dir, wtPath } of allDirs) {
|
|
252
276
|
|
|
253
277
|
let shouldClean = false;
|
|
254
278
|
let isProtected = false;
|
|
255
|
-
const actualBranch =
|
|
279
|
+
const actualBranch = branchMap.get(wtPath) || '';
|
|
256
280
|
|
|
257
281
|
// Check if this worktree's branch is merged/abandoned
|
|
258
282
|
// Prefer actual git branch metadata; compact Windows dirs intentionally omit branch names.
|
package/engine/cli.js
CHANGED
|
@@ -745,6 +745,16 @@ const commands = {
|
|
|
745
745
|
}
|
|
746
746
|
watchForWorkChanges();
|
|
747
747
|
|
|
748
|
+
// Drain in-memory buffers (metrics, logs) before any process.exit. Logs
|
|
749
|
+
// a stderr line if a flush fails so a refactor mistake (typo, missing
|
|
750
|
+
// export) doesn't go silent.
|
|
751
|
+
function drainBuffers() {
|
|
752
|
+
try { require('./llm').flushMetricsBuffer(); }
|
|
753
|
+
catch (err) { try { console.error(`[shutdown] flushMetricsBuffer failed: ${err.message}`); } catch {} }
|
|
754
|
+
try { shared.flushLogs(); }
|
|
755
|
+
catch (err) { try { console.error(`[shutdown] flushLogs failed: ${err.message}`); } catch {} }
|
|
756
|
+
}
|
|
757
|
+
|
|
748
758
|
// Graceful shutdown — wait for active agents before exiting
|
|
749
759
|
let shuttingDown = false;
|
|
750
760
|
function gracefulShutdown(signal) {
|
|
@@ -768,7 +778,7 @@ const commands = {
|
|
|
768
778
|
e.log('warn', 'Graceful shutdown skipped control.json stopped transition; control file is owned by a different engine process');
|
|
769
779
|
}
|
|
770
780
|
e.log('info', 'Graceful shutdown complete (no active agents)');
|
|
771
|
-
|
|
781
|
+
drainBuffers();
|
|
772
782
|
console.log('No active agents — stopped.');
|
|
773
783
|
process.exit(0);
|
|
774
784
|
}
|
|
@@ -785,7 +795,7 @@ const commands = {
|
|
|
785
795
|
e.log('warn', 'Graceful shutdown skipped control.json stopped transition; control file is owned by a different engine process');
|
|
786
796
|
}
|
|
787
797
|
e.log('info', 'Graceful shutdown complete (all agents finished)');
|
|
788
|
-
|
|
798
|
+
drainBuffers();
|
|
789
799
|
console.log('All agents finished — stopped.');
|
|
790
800
|
process.exit(0);
|
|
791
801
|
}
|
|
@@ -796,7 +806,7 @@ const commands = {
|
|
|
796
806
|
e.log('warn', 'Graceful shutdown skipped control.json stopped transition; control file is owned by a different engine process');
|
|
797
807
|
}
|
|
798
808
|
e.log('warn', `Graceful shutdown timed out after ${timeout / 1000}s with ${e.activeProcesses.size} agent(s) still active`);
|
|
799
|
-
|
|
809
|
+
drainBuffers();
|
|
800
810
|
console.log(`Shutdown timeout (${timeout / 1000}s) — force exiting with ${e.activeProcesses.size} agent(s) still running.`);
|
|
801
811
|
process.exit(1);
|
|
802
812
|
}
|
|
@@ -811,7 +821,7 @@ const commands = {
|
|
|
811
821
|
const msg = reason instanceof Error ? reason.stack || reason.message : String(reason);
|
|
812
822
|
console.error(`[FATAL] Unhandled promise rejection: ${msg}`);
|
|
813
823
|
try { shared.log('fatal', `Unhandled promise rejection: ${msg}`); } catch { /* best effort */ }
|
|
814
|
-
|
|
824
|
+
drainBuffers();
|
|
815
825
|
process.exit(1);
|
|
816
826
|
});
|
|
817
827
|
|
|
@@ -819,7 +829,7 @@ const commands = {
|
|
|
819
829
|
const msg = err instanceof Error ? err.stack || err.message : String(err);
|
|
820
830
|
console.error(`[FATAL] Uncaught exception: ${msg}`);
|
|
821
831
|
try { shared.log('fatal', `Uncaught exception: ${msg}`); } catch { /* best effort */ }
|
|
822
|
-
|
|
832
|
+
drainBuffers();
|
|
823
833
|
process.exit(1);
|
|
824
834
|
});
|
|
825
835
|
},
|
|
@@ -1359,11 +1369,11 @@ const commands = {
|
|
|
1359
1369
|
console.log(`\nDone: ${killed.length} dispatches killed, agents reset.`);
|
|
1360
1370
|
},
|
|
1361
1371
|
|
|
1362
|
-
cleanup() {
|
|
1372
|
+
async cleanup() {
|
|
1363
1373
|
const e = engine();
|
|
1364
1374
|
const config = getConfig();
|
|
1365
1375
|
console.log('\n=== Cleanup ===\n');
|
|
1366
|
-
const result = e.runCleanup(config, true);
|
|
1376
|
+
const result = await e.runCleanup(config, true);
|
|
1367
1377
|
console.log(`\nDone: ${result.tempFiles} temp files, ${result.liveOutputs} live outputs, ${result.worktrees} worktrees, ${result.zombies} zombies cleaned.`);
|
|
1368
1378
|
},
|
|
1369
1379
|
|
package/engine/dispatch.js
CHANGED
|
@@ -24,18 +24,35 @@ function lifecycle() { if (!_lifecycle) _lifecycle = require('./lifecycle'); ret
|
|
|
24
24
|
// ─── Dispatch Mutation ───────────────────────────────────────────────────────
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* Safe to call on every mutation: small prompts are untouched.
|
|
27
|
+
* Walk pending + active dispatch entries and snapshot {id → prompt} for items
|
|
28
|
+
* that have a string prompt. Used to diff before/after a mutation so we only
|
|
29
|
+
* re-check items the mutator actually touched (#1167).
|
|
31
30
|
*/
|
|
32
|
-
function
|
|
31
|
+
function _snapshotPrompts(dispatch) {
|
|
32
|
+
const snap = new Map();
|
|
33
|
+
for (const list of [dispatch.pending, dispatch.active]) {
|
|
34
|
+
if (!Array.isArray(list)) continue;
|
|
35
|
+
for (const item of list) {
|
|
36
|
+
if (item && item.id && typeof item.prompt === 'string') snap.set(item.id, item.prompt);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return snap;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Sidecar oversized prompts only for items the mutator added or modified.
|
|
44
|
+
* Keeps dispatch.json safe from bloat (#1167) without paying O(n) on every
|
|
45
|
+
* unrelated mutation (e.g. status flips, completion-marking).
|
|
46
|
+
*/
|
|
47
|
+
function _sidecarChangedPrompts(dispatch, prevSnap) {
|
|
33
48
|
const threshold = ENGINE_DEFAULTS.maxDispatchPromptBytes;
|
|
34
|
-
const
|
|
35
|
-
for (const list of lists) {
|
|
49
|
+
for (const list of [dispatch.pending, dispatch.active]) {
|
|
36
50
|
if (!Array.isArray(list)) continue;
|
|
37
51
|
for (const item of list) {
|
|
38
|
-
if (item
|
|
52
|
+
if (!item || typeof item.prompt !== 'string') continue;
|
|
53
|
+
const prev = item.id ? prevSnap.get(item.id) : undefined;
|
|
54
|
+
if (prev === item.prompt) continue; // untouched — already validated on insert
|
|
55
|
+
sidecarDispatchPrompt(item, threshold);
|
|
39
56
|
}
|
|
40
57
|
}
|
|
41
58
|
}
|
|
@@ -46,10 +63,11 @@ function mutateDispatch(mutator) {
|
|
|
46
63
|
dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
|
|
47
64
|
dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
|
|
48
65
|
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
66
|
+
const prevSnap = _snapshotPrompts(dispatch);
|
|
49
67
|
const next = mutator(dispatch) ?? dispatch;
|
|
50
|
-
// Prompt-size guard:
|
|
51
|
-
//
|
|
52
|
-
|
|
68
|
+
// Prompt-size guard: only scan items whose prompt changed (or new items),
|
|
69
|
+
// so a 100-item status-flip doesn't re-byte-count every prompt.
|
|
70
|
+
_sidecarChangedPrompts(next, prevSnap);
|
|
53
71
|
return next;
|
|
54
72
|
}, { defaultValue: defaultDispatch });
|
|
55
73
|
// Invalidate the read cache so next getDispatch() sees fresh data
|
|
@@ -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,44 +23,123 @@ 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 ────────────────────────────────────────────────────
|
|
30
|
+
//
|
|
31
|
+
// Updates accumulate in an in-memory buffer and flush every
|
|
32
|
+
// metricsFlushIntervalMs (default 10s). Replaces the per-call mutateJsonFileLocked
|
|
33
|
+
// that was both serializing the LLM hot path and bumping metrics.json mtime
|
|
34
|
+
// on every call — defeating the dashboard fast-state mtime-based early-exit.
|
|
35
|
+
|
|
36
|
+
let _pendingMetrics = { engine: Object.create(null), daily: Object.create(null) };
|
|
37
|
+
let _flushTimer = null;
|
|
38
|
+
|
|
39
|
+
function _emptyEngineDelta() {
|
|
40
|
+
return { calls: 0, costUsd: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0, totalDurationMs: 0, timedCalls: 0 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function _emptyDailyDelta() {
|
|
44
|
+
return { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _ensureFlushTimer() {
|
|
48
|
+
if (_flushTimer) return;
|
|
49
|
+
const interval = shared.ENGINE_DEFAULTS.metricsFlushIntervalMs || 10000;
|
|
50
|
+
_flushTimer = setInterval(flushMetricsBuffer, interval);
|
|
51
|
+
if (typeof _flushTimer.unref === 'function') _flushTimer.unref();
|
|
52
|
+
}
|
|
29
53
|
|
|
30
54
|
function trackEngineUsage(category, usage) {
|
|
31
55
|
if (!usage) return;
|
|
32
56
|
if (category && (category.startsWith('_test') || category.startsWith('test-'))) return;
|
|
57
|
+
|
|
58
|
+
if (!_pendingMetrics.engine[category]) _pendingMetrics.engine[category] = _emptyEngineDelta();
|
|
59
|
+
const cat = _pendingMetrics.engine[category];
|
|
60
|
+
cat.calls++;
|
|
61
|
+
cat.costUsd += usage.costUsd || 0;
|
|
62
|
+
cat.inputTokens += usage.inputTokens || 0;
|
|
63
|
+
cat.outputTokens += usage.outputTokens || 0;
|
|
64
|
+
cat.cacheRead += usage.cacheRead || 0;
|
|
65
|
+
cat.cacheCreation += usage.cacheCreation || 0;
|
|
66
|
+
if (usage.durationMs) {
|
|
67
|
+
cat.totalDurationMs += usage.durationMs;
|
|
68
|
+
cat.timedCalls += 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const today = ts().slice(0, 10);
|
|
72
|
+
if (!_pendingMetrics.daily[today]) _pendingMetrics.daily[today] = _emptyDailyDelta();
|
|
73
|
+
const daily = _pendingMetrics.daily[today];
|
|
74
|
+
daily.costUsd += usage.costUsd || 0;
|
|
75
|
+
daily.inputTokens += usage.inputTokens || 0;
|
|
76
|
+
daily.outputTokens += usage.outputTokens || 0;
|
|
77
|
+
daily.cacheRead += usage.cacheRead || 0;
|
|
78
|
+
|
|
79
|
+
_ensureFlushTimer();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function flushMetricsBuffer() {
|
|
83
|
+
const pending = _pendingMetrics;
|
|
84
|
+
if (!Object.keys(pending.engine).length && !Object.keys(pending.daily).length) return;
|
|
85
|
+
_pendingMetrics = { engine: Object.create(null), daily: Object.create(null) };
|
|
86
|
+
|
|
33
87
|
try {
|
|
34
88
|
const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
|
|
35
89
|
mutateJsonFileLocked(metricsPath, (metrics) => {
|
|
36
90
|
if (!metrics._engine) metrics._engine = {};
|
|
37
|
-
|
|
38
|
-
metrics._engine[category]
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
91
|
+
for (const [category, delta] of Object.entries(pending.engine)) {
|
|
92
|
+
if (!metrics._engine[category]) {
|
|
93
|
+
metrics._engine[category] = { calls: 0, costUsd: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0 };
|
|
94
|
+
}
|
|
95
|
+
const cat = metrics._engine[category];
|
|
96
|
+
cat.calls = (cat.calls || 0) + delta.calls;
|
|
97
|
+
cat.costUsd = (cat.costUsd || 0) + delta.costUsd;
|
|
98
|
+
cat.inputTokens = (cat.inputTokens || 0) + delta.inputTokens;
|
|
99
|
+
cat.outputTokens = (cat.outputTokens || 0) + delta.outputTokens;
|
|
100
|
+
cat.cacheRead = (cat.cacheRead || 0) + delta.cacheRead;
|
|
101
|
+
cat.cacheCreation = (cat.cacheCreation || 0) + delta.cacheCreation;
|
|
102
|
+
if (delta.timedCalls > 0) {
|
|
103
|
+
cat.totalDurationMs = (cat.totalDurationMs || 0) + delta.totalDurationMs;
|
|
104
|
+
cat.timedCalls = (cat.timedCalls || 0) + delta.timedCalls;
|
|
105
|
+
}
|
|
50
106
|
}
|
|
51
|
-
|
|
52
|
-
const today = ts().slice(0, 10);
|
|
53
107
|
if (!metrics._daily) metrics._daily = {};
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
108
|
+
for (const [day, delta] of Object.entries(pending.daily)) {
|
|
109
|
+
if (!metrics._daily[day]) {
|
|
110
|
+
metrics._daily[day] = { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, tasks: 0 };
|
|
111
|
+
}
|
|
112
|
+
const d = metrics._daily[day];
|
|
113
|
+
d.costUsd += delta.costUsd;
|
|
114
|
+
d.inputTokens += delta.inputTokens;
|
|
115
|
+
d.outputTokens += delta.outputTokens;
|
|
116
|
+
d.cacheRead += delta.cacheRead;
|
|
117
|
+
}
|
|
61
118
|
return metrics;
|
|
62
119
|
});
|
|
63
|
-
} catch (e) {
|
|
120
|
+
} catch (e) {
|
|
121
|
+
// Re-merge pending so the next flush retries — never drop counters silently.
|
|
122
|
+
for (const [category, delta] of Object.entries(pending.engine)) {
|
|
123
|
+
if (!_pendingMetrics.engine[category]) _pendingMetrics.engine[category] = _emptyEngineDelta();
|
|
124
|
+
const c = _pendingMetrics.engine[category];
|
|
125
|
+
c.calls += delta.calls; c.costUsd += delta.costUsd;
|
|
126
|
+
c.inputTokens += delta.inputTokens; c.outputTokens += delta.outputTokens;
|
|
127
|
+
c.cacheRead += delta.cacheRead; c.cacheCreation += delta.cacheCreation;
|
|
128
|
+
c.totalDurationMs += delta.totalDurationMs; c.timedCalls += delta.timedCalls;
|
|
129
|
+
}
|
|
130
|
+
for (const [day, delta] of Object.entries(pending.daily)) {
|
|
131
|
+
if (!_pendingMetrics.daily[day]) _pendingMetrics.daily[day] = _emptyDailyDelta();
|
|
132
|
+
const d = _pendingMetrics.daily[day];
|
|
133
|
+
d.costUsd += delta.costUsd; d.inputTokens += delta.inputTokens;
|
|
134
|
+
d.outputTokens += delta.outputTokens; d.cacheRead += delta.cacheRead;
|
|
135
|
+
}
|
|
136
|
+
console.error('metrics flush:', e.message);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function _resetMetricsBufferForTest() {
|
|
141
|
+
_pendingMetrics = { engine: Object.create(null), daily: Object.create(null) };
|
|
142
|
+
if (_flushTimer) { clearInterval(_flushTimer); _flushTimer = null; }
|
|
64
143
|
}
|
|
65
144
|
|
|
66
145
|
// ─── Runtime Binary Resolution (TTL-cached) ──────────────────────────────────
|
|
@@ -83,7 +162,10 @@ function _resolveBin(runtime) {
|
|
|
83
162
|
if (!runtime) return null;
|
|
84
163
|
const key = runtime.name;
|
|
85
164
|
const cached = _binCache.get(key);
|
|
86
|
-
|
|
165
|
+
// Trust the 30-min TTL — skip per-call existsSync (10-50ms on Windows w/ AV).
|
|
166
|
+
// If the binary disappears mid-window, spawn fails with ENOENT and the
|
|
167
|
+
// adapter's resolveBinary() reprobes on the next cache miss.
|
|
168
|
+
if (cached && Date.now() - cached.ts < _BIN_TTL) {
|
|
87
169
|
return { bin: cached.bin, native: cached.native, leadingArgs: cached.leadingArgs };
|
|
88
170
|
}
|
|
89
171
|
let resolved = null;
|
|
@@ -528,6 +610,8 @@ function callLLM(promptText, sysPromptText, opts = {}) {
|
|
|
528
610
|
maxBudget, bare, fallbackModel,
|
|
529
611
|
...runtimeFeatureOpts,
|
|
530
612
|
});
|
|
613
|
+
let settled = false;
|
|
614
|
+
let exitSettleTimer = null;
|
|
531
615
|
let taskCompleteTimer = null;
|
|
532
616
|
const scheduleTaskCompleteClose = () => {
|
|
533
617
|
if (taskCompleteTimer) return;
|
|
@@ -554,11 +638,16 @@ function callLLM(promptText, sysPromptText, opts = {}) {
|
|
|
554
638
|
|
|
555
639
|
const timer = setTimeout(() => { shared.killImmediate(proc); }, timeout);
|
|
556
640
|
|
|
557
|
-
|
|
641
|
+
function finish(code) {
|
|
642
|
+
if (settled) return;
|
|
643
|
+
settled = true;
|
|
558
644
|
clearTimeout(timer);
|
|
645
|
+
if (exitSettleTimer) clearTimeout(exitSettleTimer);
|
|
559
646
|
clearTaskCompleteTimer();
|
|
560
647
|
for (const f of cleanupFiles) safeUnlink(f);
|
|
561
648
|
const parsed = acc.finalize();
|
|
649
|
+
try { proc.stdout?.destroy(); } catch {}
|
|
650
|
+
try { proc.stderr?.destroy(); } catch {}
|
|
562
651
|
const durationMs = Date.now() - _startMs;
|
|
563
652
|
const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
|
|
564
653
|
// parseError lets the adapter classify obvious failure modes (auth /
|
|
@@ -578,10 +667,19 @@ function callLLM(promptText, sysPromptText, opts = {}) {
|
|
|
578
667
|
runtime: runtime.name,
|
|
579
668
|
errorClass: errInfo.code,
|
|
580
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);
|
|
581
676
|
});
|
|
582
677
|
|
|
583
678
|
proc.on('error', (err) => {
|
|
679
|
+
if (settled) return;
|
|
680
|
+
settled = true;
|
|
584
681
|
clearTimeout(timer);
|
|
682
|
+
if (exitSettleTimer) clearTimeout(exitSettleTimer);
|
|
585
683
|
clearTaskCompleteTimer();
|
|
586
684
|
for (const f of cleanupFiles) safeUnlink(f);
|
|
587
685
|
shared.log('error', `LLM spawn error (${label}): ${err.message}`);
|
|
@@ -628,6 +726,8 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
|
|
|
628
726
|
maxBudget, bare, fallbackModel,
|
|
629
727
|
...runtimeFeatureOpts,
|
|
630
728
|
});
|
|
729
|
+
let settled = false;
|
|
730
|
+
let exitSettleTimer = null;
|
|
631
731
|
let taskCompleteTimer = null;
|
|
632
732
|
const scheduleTaskCompleteClose = () => {
|
|
633
733
|
if (taskCompleteTimer) return;
|
|
@@ -657,11 +757,16 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
|
|
|
657
757
|
|
|
658
758
|
const timer = setTimeout(() => { shared.killImmediate(proc); }, timeout);
|
|
659
759
|
|
|
660
|
-
|
|
760
|
+
function finish(code) {
|
|
761
|
+
if (settled) return;
|
|
762
|
+
settled = true;
|
|
661
763
|
clearTimeout(timer);
|
|
764
|
+
if (exitSettleTimer) clearTimeout(exitSettleTimer);
|
|
662
765
|
clearTaskCompleteTimer();
|
|
663
766
|
for (const f of cleanupFiles) safeUnlink(f);
|
|
664
767
|
const parsed = acc.finalize();
|
|
768
|
+
try { proc.stdout?.destroy(); } catch {}
|
|
769
|
+
try { proc.stderr?.destroy(); } catch {}
|
|
665
770
|
const durationMs = Date.now() - _startMs;
|
|
666
771
|
const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
|
|
667
772
|
const errInfo = code !== 0
|
|
@@ -678,10 +783,19 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
|
|
|
678
783
|
runtime: runtime.name,
|
|
679
784
|
errorClass: errInfo.code,
|
|
680
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);
|
|
681
792
|
});
|
|
682
793
|
|
|
683
794
|
proc.on('error', (err) => {
|
|
795
|
+
if (settled) return;
|
|
796
|
+
settled = true;
|
|
684
797
|
clearTimeout(timer);
|
|
798
|
+
if (exitSettleTimer) clearTimeout(exitSettleTimer);
|
|
685
799
|
clearTaskCompleteTimer();
|
|
686
800
|
for (const f of cleanupFiles) safeUnlink(f);
|
|
687
801
|
shared.log('error', `LLM-stream spawn error (${label}): ${err.message}`);
|
|
@@ -700,10 +814,12 @@ module.exports = {
|
|
|
700
814
|
callLLM,
|
|
701
815
|
callLLMStreaming,
|
|
702
816
|
trackEngineUsage,
|
|
817
|
+
flushMetricsBuffer,
|
|
703
818
|
// Exposed for unit tests — engine code MUST use the runtime adapter contract.
|
|
704
819
|
_buildSpawnAgentFlags,
|
|
705
820
|
_resolveBin,
|
|
706
821
|
_resetBinCache,
|
|
822
|
+
_resetMetricsBufferForTest,
|
|
707
823
|
_resolveRuntimeFor,
|
|
708
824
|
_resolveModelFor,
|
|
709
825
|
_resolveModelForRuntime,
|
package/engine/shared.js
CHANGED
|
@@ -918,6 +918,7 @@ const ENGINE_DEFAULTS = {
|
|
|
918
918
|
docSessionTtlMs: 7 * 24 * 60 * 60 * 1000, // 7d — longer-lived doc sessions, still bounded
|
|
919
919
|
docSessionMaxEntries: 200, // cap doc-chat session map/disk store by least-recent activity
|
|
920
920
|
ccLiveStreamMaxAgeMs: 30 * 60 * 1000, // hard cap reconnect buffers if abort/cleanup stalls
|
|
921
|
+
metricsFlushIntervalMs: 10000, // batch trackEngineUsage writes to metrics.json — flushed every 10s instead of per-call to cut lock contention and dashboard mtime churn
|
|
921
922
|
maxLlmRawBytes: 256 * 1024, // keep only a bounded stdout tail from direct Claude calls
|
|
922
923
|
maxLlmStderrBytes: 64 * 1024, // keep only a bounded stderr tail from direct Claude calls
|
|
923
924
|
maxLlmLineBufferBytes: 128 * 1024, // cap the incremental JSON line buffer to avoid malformed-stream OOMs
|
package/engine.js
CHANGED
|
@@ -4071,7 +4071,7 @@ async function tickInner() {
|
|
|
4071
4071
|
|
|
4072
4072
|
// 2.5. Periodic cleanup + MCP sync (every 10 ticks = ~5 minutes)
|
|
4073
4073
|
if (tickCount % 10 === 0) {
|
|
4074
|
-
|
|
4074
|
+
try { await runCleanup(config); } catch (e) { log('warn', `runCleanup: ${e.message}`); }
|
|
4075
4075
|
}
|
|
4076
4076
|
|
|
4077
4077
|
// 2.55. Check persistent watches (3 tick-equivalents, default ~3 minutes)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
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"
|