@yemi33/minions 0.1.2381 → 0.1.2383
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/bin/minions.js +42 -2
- package/dashboard/js/refresh.js +8 -2
- package/dashboard/js/render-other.js +38 -0
- package/dashboard/js/render-work-items.js +61 -20
- package/dashboard/js/settings.js +7 -8
- package/dashboard/pages/engine.html +6 -0
- package/dashboard.js +110 -27
- package/docs/README.md +1 -0
- package/docs/claude-md-propagation.md +118 -0
- package/docs/completion-reports.md +20 -1
- package/docs/engine-restart.md +10 -0
- package/docs/runtime-adapters.md +54 -22
- package/docs/skills.md +2 -0
- package/docs/workspace-manifests.md +1 -1
- package/docs/worktree-lifecycle.md +23 -0
- package/engine/acp-transport.js +63 -35
- package/engine/ado-comment.js +3 -1
- package/engine/agent-worker-pool.js +11 -4
- package/engine/cc-worker-pool.js +4 -2
- package/engine/claude-md-context.js +195 -0
- package/engine/cli.js +9 -1
- package/engine/comment-format.js +51 -14
- package/engine/consolidation.js +47 -3
- package/engine/db/migrations/026-pre-dispatch-rejections.js +55 -0
- package/engine/dispatch.js +80 -23
- package/engine/execution-model.js +68 -0
- package/engine/gh-comment.js +6 -3
- package/engine/github.js +6 -7
- package/engine/lifecycle.js +195 -56
- package/engine/llm.js +59 -83
- package/engine/memory-store.js +3 -1
- package/engine/pipeline.js +147 -20
- package/engine/playbook.js +39 -6
- package/engine/pooled-agent-process.js +28 -26
- package/engine/prd-store.js +14 -6
- package/engine/quarantine-refs.js +33 -0
- package/engine/queries.js +8 -6
- package/engine/restart-health.js +69 -4
- package/engine/runtimes/claude.js +100 -25
- package/engine/runtimes/codex.js +19 -0
- package/engine/runtimes/contract.js +239 -0
- package/engine/runtimes/copilot.js +85 -8
- package/engine/runtimes/index.js +37 -9
- package/engine/shared.js +157 -64
- package/engine/spawn-agent.js +79 -113
- package/engine/spawn-phase-watchdog.js +8 -37
- package/engine/supervisor.js +72 -16
- package/engine/timeout.js +87 -16
- package/engine/untrusted-fence.js +2 -1
- package/engine.js +434 -125
- package/package.json +1 -1
- package/playbooks/fix.md +20 -0
- package/playbooks/review.md +2 -0
- package/skills/check-self-authored-review-comment/SKILL.md +34 -0
package/bin/minions.js
CHANGED
|
@@ -336,7 +336,11 @@ function _sqliteSpawnFlags() {
|
|
|
336
336
|
* CLI's intent (EADDRINUSE retries still apply inside the dashboard if a
|
|
337
337
|
* race opens between the CLI's port-free check and the actual bind). */
|
|
338
338
|
function spawnDashboard(requestedPort) {
|
|
339
|
-
const env = {
|
|
339
|
+
const env = {
|
|
340
|
+
...process.env,
|
|
341
|
+
MINIONS_NO_AUTO_OPEN: '1',
|
|
342
|
+
MINIONS_REQUIRE_DASHBOARD_PORT: '1',
|
|
343
|
+
};
|
|
340
344
|
const out = _openStdioLog('dashboard-stdio.log');
|
|
341
345
|
const err = _openStdioLog('dashboard-stdio.log');
|
|
342
346
|
const args = [..._sqliteSpawnFlags(), path.join(MINIONS_HOME, 'dashboard.js')];
|
|
@@ -444,19 +448,43 @@ function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs })
|
|
|
444
448
|
// failure rather than a false "healthy".
|
|
445
449
|
const actualPort = await _waitForDashboardPortFile(MINIONS_HOME, 12000, 150, dashProc.pid) || requested.port;
|
|
446
450
|
if (actualPort !== requested.port) {
|
|
447
|
-
console.
|
|
451
|
+
console.error(`\n ERROR: Dashboard bound to unexpected port ${actualPort}; required ${requested.port}.`);
|
|
452
|
+
console.error(' Refusing to verify a split-port Minions topology. Run `minions restart` again.');
|
|
453
|
+
_cleanupFailedStackSpawn({
|
|
454
|
+
enginePid: engineProc.pid,
|
|
455
|
+
dashboardPid: dashProc.pid,
|
|
456
|
+
supervisorPid: supProc.pid,
|
|
457
|
+
dashboardPort: actualPort,
|
|
458
|
+
});
|
|
459
|
+
process.exit(1);
|
|
448
460
|
}
|
|
461
|
+
const supervisorInternals = require(path.join(PKG_ROOT, 'engine', 'supervisor'))._internals;
|
|
449
462
|
const result = await waitForRestartHealth({
|
|
450
463
|
minionsHome: MINIONS_HOME,
|
|
464
|
+
expectedEnginePid: engineProc.pid,
|
|
451
465
|
dashboardPid: dashProc.pid,
|
|
452
466
|
dashboardPort: actualPort,
|
|
467
|
+
supervisorPid: supProc.pid,
|
|
453
468
|
timeoutMs: _resolveRestartHealthTimeoutMs(),
|
|
454
469
|
// Verify the process bound to the port IS the dashboard we just spawned
|
|
455
470
|
// (beacon pid match), not a stale leftover still holding it.
|
|
456
471
|
requireBeaconOwner: true,
|
|
472
|
+
requireExactTopology: true,
|
|
473
|
+
listProcessPids: supervisorInternals.listNodePidsMatching,
|
|
474
|
+
topologyScripts: {
|
|
475
|
+
engine: path.join(MINIONS_HOME, 'engine.js'),
|
|
476
|
+
dashboard: path.join(MINIONS_HOME, 'dashboard.js'),
|
|
477
|
+
supervisor: path.join(MINIONS_HOME, 'engine', 'supervisor.js'),
|
|
478
|
+
},
|
|
457
479
|
});
|
|
458
480
|
if (!result.ok) {
|
|
459
481
|
console.error(formatRestartHealthError(result));
|
|
482
|
+
_cleanupFailedStackSpawn({
|
|
483
|
+
enginePid: engineProc.pid,
|
|
484
|
+
dashboardPid: dashProc.pid,
|
|
485
|
+
supervisorPid: supProc.pid,
|
|
486
|
+
dashboardPort: actualPort,
|
|
487
|
+
});
|
|
460
488
|
process.exit(1);
|
|
461
489
|
}
|
|
462
490
|
console.log(` Restart verified: engine PID ${result.engine.pid}; dashboard healthy.`);
|
|
@@ -479,6 +507,17 @@ function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs })
|
|
|
479
507
|
});
|
|
480
508
|
}
|
|
481
509
|
|
|
510
|
+
function _cleanupFailedStackSpawn({ enginePid, dashboardPid, supervisorPid, dashboardPort }) {
|
|
511
|
+
writeStopIntent('restart verification failed');
|
|
512
|
+
for (const pid of [supervisorPid, dashboardPid, enginePid]) killPidOnly(pid);
|
|
513
|
+
for (const pid of [supervisorPid, dashboardPid, enginePid]) waitForPidDeath(pid, 5000);
|
|
514
|
+
if (dashboardPort) {
|
|
515
|
+
killByPort(dashboardPort);
|
|
516
|
+
waitForPortRelease(dashboardPort, 5000);
|
|
517
|
+
}
|
|
518
|
+
shared.clearDashboardPortFile(MINIONS_HOME);
|
|
519
|
+
}
|
|
520
|
+
|
|
482
521
|
/** Poll engine/dashboard-port.json until it appears (dashboard wrote it on
|
|
483
522
|
* listen) or the timeout elapses. Returns the bound port or null on
|
|
484
523
|
* timeout. Stale files left behind by a crashed dashboard are tolerated —
|
|
@@ -873,6 +912,7 @@ function init() {
|
|
|
873
912
|
|
|
874
913
|
// Copy with smart merge logic
|
|
875
914
|
copyDir(PKG_ROOT, MINIONS_HOME, excludeTop, alwaysUpdate, neverOverwrite, isUpgrade, actions);
|
|
915
|
+
shared.syncBundledPersonalSkills(path.join(MINIONS_HOME, 'skills'), { homeDir: os.homedir() });
|
|
876
916
|
|
|
877
917
|
// Create config from template if it doesn't exist
|
|
878
918
|
const configPath = path.join(MINIONS_HOME, 'config.json');
|
package/dashboard/js/refresh.js
CHANGED
|
@@ -151,8 +151,11 @@ const RENDER_VERSIONS = {
|
|
|
151
151
|
dispatch: 2,
|
|
152
152
|
engineLog: 2,
|
|
153
153
|
metrics: 1,
|
|
154
|
-
// Bumped 8
|
|
155
|
-
|
|
154
|
+
// Bumped 7→8: WI detail modal's branch pill moved out of the Artifacts
|
|
155
|
+
// chip row into its own non-interactive "Branch" field (W-mr2qjr3b0002522f).
|
|
156
|
+
// Bumped 8→9 for paused pre-dispatch evaluation guidance.
|
|
157
|
+
// Bumped 9→10: row actions now preserve work-item source scope when IDs collide.
|
|
158
|
+
workItems: 10,
|
|
156
159
|
skills: 1,
|
|
157
160
|
commands: 1,
|
|
158
161
|
mcpServers: 1,
|
|
@@ -840,6 +843,9 @@ function _processStatusUpdate(data, opts) {
|
|
|
840
843
|
.catch(function () { /* keep render even if managed fetch failed — getLastItems() returns the last good cache (or []) */ })
|
|
841
844
|
.then(function () { try { renderKeepProcesses(); } catch {} });
|
|
842
845
|
}
|
|
846
|
+
if (typeof renderWorktreeQuarantineRefs === 'function') {
|
|
847
|
+
_safeRender('worktreeQuarantineRefs', function () { renderWorktreeQuarantineRefs(); });
|
|
848
|
+
}
|
|
843
849
|
// Work items now come from /api/work-items — a dedicated fresh-JSON
|
|
844
850
|
// endpoint that re-runs getWorkItems() server-side on every request
|
|
845
851
|
// with input-mtime ETag (issue #2949). The previous /state/ + stale-
|
|
@@ -1250,3 +1250,41 @@ async function killKeepPid(agentId, pid) {
|
|
|
1250
1250
|
}
|
|
1251
1251
|
|
|
1252
1252
|
window.MinionsKeepProcesses = { renderKeepProcesses, killKeepPid, mountKeepProcessesPanel, unmountKeepProcessesPanel };
|
|
1253
|
+
|
|
1254
|
+
async function renderWorktreeQuarantineRefs() {
|
|
1255
|
+
const root = document.getElementById('worktree-quarantine-refs-content');
|
|
1256
|
+
const count = document.getElementById('worktree-quarantine-refs-count');
|
|
1257
|
+
if (!root) return;
|
|
1258
|
+
let html;
|
|
1259
|
+
try {
|
|
1260
|
+
const res = await fetch('/api/worktree-quarantine-refs');
|
|
1261
|
+
const data = await res.json();
|
|
1262
|
+
if (!res.ok) throw new Error(data.error || String(res.status));
|
|
1263
|
+
const items = Array.isArray(data.items) ? data.items : [];
|
|
1264
|
+
if (count) count.textContent = String(items.length);
|
|
1265
|
+
if (!items.length) {
|
|
1266
|
+
html = '<p class="empty">No quarantined work refs found.</p>';
|
|
1267
|
+
} else {
|
|
1268
|
+
html = '<table style="width:100%;border-collapse:collapse"><thead><tr>' +
|
|
1269
|
+
'<th style="text-align:left">Project</th><th style="text-align:left">Backup ref</th>' +
|
|
1270
|
+
'<th style="text-align:left">SHA</th><th style="text-align:left">Created</th></tr></thead><tbody>' +
|
|
1271
|
+
items.map(function (item) {
|
|
1272
|
+
if (item.error) {
|
|
1273
|
+
return '<tr><td>' + escHtml(item.project || '') + '</td><td colspan="3" style="color:var(--red)">' + escHtml(item.error) + '</td></tr>';
|
|
1274
|
+
}
|
|
1275
|
+
return '<tr><td>' + escHtml(item.project || '') + '</td>' +
|
|
1276
|
+
'<td><code>' + escHtml(item.ref || '') + '</code></td>' +
|
|
1277
|
+
'<td><code>' + escHtml(String(item.sha || '').slice(0, 12)) + '</code></td>' +
|
|
1278
|
+
'<td>' + escHtml(item.createdAt || '') + '</td></tr>';
|
|
1279
|
+
}).join('') +
|
|
1280
|
+
'</tbody></table>';
|
|
1281
|
+
}
|
|
1282
|
+
} catch (e) {
|
|
1283
|
+
if (count) count.textContent = '?';
|
|
1284
|
+
html = '<span style="color:var(--red)">Failed to load: ' + escHtml(e.message) + '</span>';
|
|
1285
|
+
}
|
|
1286
|
+
// eslint-disable-next-line no-unsanitized/method -- structural HTML only; API fields are escaped above
|
|
1287
|
+
root.replaceChildren(document.createRange().createContextualFragment(html));
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
window.MinionsWorktreeQuarantineRefs = { renderWorktreeQuarantineRefs };
|
|
@@ -52,10 +52,17 @@ function needsAttentionInfo(item) {
|
|
|
52
52
|
if (item._preDispatchEval && item._preDispatchEval.valid === false) {
|
|
53
53
|
var rawReason = String(item._preDispatchEval.reason || '(no reason recorded)');
|
|
54
54
|
var firstLine = rawReason.split(/\r?\n/, 1)[0] || rawReason;
|
|
55
|
+
var exhausted = item._preDispatchEval.exhausted === true;
|
|
56
|
+
var prefix = exhausted
|
|
57
|
+
? 'Pre-dispatch evaluation paused after ' + (item._preDispatchEval.rejectCount || '?') + ' unchanged rejections: '
|
|
58
|
+
: 'Pre-dispatch evaluation rejected: ';
|
|
59
|
+
var fullReason = exhausted
|
|
60
|
+
? rawReason + '\n\nEdit the work item description to resume evaluation, or cancel it.'
|
|
61
|
+
: rawReason;
|
|
55
62
|
return {
|
|
56
63
|
kind: 'pre_dispatch_eval',
|
|
57
|
-
short:
|
|
58
|
-
full:
|
|
64
|
+
short: prefix + firstLine,
|
|
65
|
+
full: fullReason,
|
|
59
66
|
};
|
|
60
67
|
}
|
|
61
68
|
|
|
@@ -1065,7 +1072,9 @@ function viewAgentOutput(logPath) {
|
|
|
1065
1072
|
});
|
|
1066
1073
|
}
|
|
1067
1074
|
|
|
1075
|
+
let _inboxNoteOpenSeq = 0;
|
|
1068
1076
|
function openInboxNote(filename) {
|
|
1077
|
+
var openSeq = ++_inboxNoteOpenSeq;
|
|
1069
1078
|
var idx = (inboxData || []).findIndex(function(item) { return item.name === filename; });
|
|
1070
1079
|
if (idx >= 0) {
|
|
1071
1080
|
openModal(idx);
|
|
@@ -1083,29 +1092,61 @@ function openInboxNote(filename) {
|
|
|
1083
1092
|
// 'archive:<name>' token). Fetch it from notes/archive/ and show it in the
|
|
1084
1093
|
// modal instead of bouncing to the inbox page. Falls back to the inbox page
|
|
1085
1094
|
// only if the file is genuinely gone (e.g. merged into notes.md).
|
|
1095
|
+
var title = filename + ' (archived)';
|
|
1096
|
+
var filePath = 'notes/archive/' + filename;
|
|
1097
|
+
var titleEl = document.getElementById('modal-title');
|
|
1098
|
+
var bodyEl = document.getElementById('modal-body');
|
|
1099
|
+
if (titleEl) titleEl.textContent = title;
|
|
1100
|
+
if (bodyEl) {
|
|
1101
|
+
bodyEl.innerHTML = '<p style="color:var(--muted)">Loading...</p>';
|
|
1102
|
+
bodyEl.style.fontFamily = "'Segoe UI', system-ui, sans-serif";
|
|
1103
|
+
bodyEl.style.whiteSpace = 'normal';
|
|
1104
|
+
}
|
|
1105
|
+
_modalDocContext = { title: title, content: '', selection: '' };
|
|
1106
|
+
_modalFilePath = filePath;
|
|
1107
|
+
var qaEl = document.getElementById('modal-qa');
|
|
1108
|
+
if (qaEl) qaEl.style.display = 'none';
|
|
1109
|
+
var modalEl = document.getElementById('modal');
|
|
1110
|
+
if (modalEl) modalEl.classList.add('open');
|
|
1111
|
+
|
|
1112
|
+
// A note can be superseded by another note before its archive fetch resolves,
|
|
1113
|
+
// or by a different artifact stacked above it. Guard both cases so delayed
|
|
1114
|
+
// success and failure handlers only affect the selection that started them.
|
|
1115
|
+
var applyIfCurrent = function(fn) {
|
|
1116
|
+
if (openSeq !== _inboxNoteOpenSeq ||
|
|
1117
|
+
_modalFilePath !== filePath ||
|
|
1118
|
+
!modalEl ||
|
|
1119
|
+
!modalEl.classList.contains('open')) return false;
|
|
1120
|
+
if (typeof withTopFrame === 'function') return withTopFrame('note', filename, fn);
|
|
1121
|
+
fn();
|
|
1122
|
+
return true;
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1086
1125
|
fetch('/state/notes/archive/' + encodeURIComponent(filename))
|
|
1087
1126
|
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
|
|
1088
1127
|
.then(function(content) {
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1128
|
+
applyIfCurrent(function() {
|
|
1129
|
+
if (!titleEl || !bodyEl) return;
|
|
1130
|
+
titleEl.textContent = title;
|
|
1131
|
+
// eslint-disable-next-line no-unsanitized/property -- reason: renderMd() escapes note content before assembling HTML; filename is set via textContent
|
|
1132
|
+
bodyEl.innerHTML = '<div style="font-size:var(--text-md);line-height:1.7;color:var(--muted)">' + renderMd(content) + '</div>';
|
|
1133
|
+
// Wire Doc Chat for the deeplinked archive note, mirroring the live-inbox
|
|
1134
|
+
// openModal() path (render-prs.js). Without this the archive branch opened
|
|
1135
|
+
// the note as plain text with no Q&A panel, so deeplinking into a note
|
|
1136
|
+
// from a (completed) work item sometimes silently dropped doc-chat.
|
|
1137
|
+
_modalDocContext = { title: title, content: content, selection: '' };
|
|
1138
|
+
_modalFilePath = filePath;
|
|
1139
|
+
showModalQa();
|
|
1140
|
+
// P-34fa5d79 — Source-WI chip for archive notes. Same convention as the
|
|
1141
|
+
// live-inbox branch above; parsed off the freshly-fetched content.
|
|
1142
|
+
_wiInjectSourceChipFromFrontmatter(content, 'modal-body');
|
|
1143
|
+
});
|
|
1105
1144
|
})
|
|
1106
1145
|
.catch(function() {
|
|
1107
|
-
|
|
1108
|
-
|
|
1146
|
+
applyIfCurrent(function() {
|
|
1147
|
+
closeModal();
|
|
1148
|
+
switchPage('inbox');
|
|
1149
|
+
});
|
|
1109
1150
|
});
|
|
1110
1151
|
}
|
|
1111
1152
|
|
package/dashboard/js/settings.js
CHANGED
|
@@ -476,6 +476,7 @@ async function openSettings() {
|
|
|
476
476
|
// Tooltip on copilotDisableBuiltinMcps MUST warn about the split-brain risk
|
|
477
477
|
settingsToggle('Copilot: disable built-in MCPs', 'set-copilotDisableBuiltinMcps', e.copilotDisableBuiltinMcps !== false,
|
|
478
478
|
'⚠ When OFF, Copilot agents can autonomously create PRs/labels/comments via the github-mcp-server, bypassing pull-requests.json tracking — Minions and Copilot end up with split views of the same PR. Keep ON unless you understand the risk.') +
|
|
479
|
+
settingsToggle('Propagate CLAUDE.md to non-Claude runtimes', 'set-propagateClaudeMdForNonClaudeRuntimes', e.propagateClaudeMdForNonClaudeRuntimes !== false, 'Copilot/Codex have NO native CLAUDE.md auto-load. When ON (default), the engine bounded-discovers the nearest applicable repo-authored CLAUDE.md files for a dispatch and injects them as a "Project instructions (CLAUDE.md)" context layer so load-bearing "keep in sync" rules and blessed tooling reach the agent. Claude is always skipped (its CLI reads CLAUDE.md itself). Orthogonal to each runtime\'s native AGENTS.md discovery — CLAUDE.md carries no split-brain risk because Copilot/Codex never read it themselves. Per-project override via project.propagateClaudeMdForNonClaudeRuntimes.') +
|
|
479
480
|
settingsToggle('Copilot: reasoning summaries', 'set-copilotReasoningSummaries', !!e.copilotReasoningSummaries, '--enable-reasoning-summaries (Anthropic-family models only)') +
|
|
480
481
|
'</div>' +
|
|
481
482
|
'<div class="settings-grid-2">' +
|
|
@@ -973,19 +974,16 @@ async function loadModelsForAgent(agentId, runtimeName, currentValue) {
|
|
|
973
974
|
cell.innerHTML = '<input ' + baseAttrs + ' value="' + escHtml(currentValue || '') + '" placeholder="' + escHtml(runtimeName) + ' default" style="' + baseStyle + '">';
|
|
974
975
|
return;
|
|
975
976
|
}
|
|
976
|
-
|
|
977
|
+
const listId = 'agent-model-options-' + agentId;
|
|
978
|
+
let opts = '';
|
|
977
979
|
for (const m of models) {
|
|
978
980
|
const id = m.id || m.name || '';
|
|
979
981
|
if (!id) continue;
|
|
980
|
-
const label = m.name && m.name !== id ?
|
|
981
|
-
opts += '<option value="' + escHtml(id) + '"' + (
|
|
982
|
-
}
|
|
983
|
-
// Preserve unknown saved values so a user-set custom ID survives the next save.
|
|
984
|
-
if (currentValue && !models.some(m => (m.id || m.name) === currentValue)) {
|
|
985
|
-
opts += '<option value="' + escHtml(currentValue) + '" selected>' + escHtml(currentValue) + ' (custom — invalid for ' + escHtml(runtimeName) + '?)</option>';
|
|
982
|
+
const label = m.name && m.name !== id ? m.name : '';
|
|
983
|
+
opts += '<option value="' + escHtml(id) + '"' + (label ? ' label="' + escHtml(label) + '"' : '') + '></option>';
|
|
986
984
|
}
|
|
987
985
|
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: agentId, model id, model label, currentValue, runtimeName)
|
|
988
|
-
cell.innerHTML = '<
|
|
986
|
+
cell.innerHTML = '<input ' + baseAttrs + ' list="' + escHtml(listId) + '" value="' + escHtml(currentValue || '') + '" placeholder="' + escHtml(runtimeName) + ' default" autocomplete="off" style="' + baseStyle + '"><datalist id="' + escHtml(listId) + '">' + opts + '</datalist>';
|
|
989
987
|
}
|
|
990
988
|
|
|
991
989
|
function settingsToggle(label, id, checked, hint) {
|
|
@@ -1191,6 +1189,7 @@ async function saveSettings() {
|
|
|
1191
1189
|
claudeFallbackModel: (document.getElementById('set-claudeFallbackModel')?.value ?? '').trim(),
|
|
1192
1190
|
copilotFallbackModel: (document.getElementById('set-copilotFallbackModel')?.value ?? '').trim(),
|
|
1193
1191
|
copilotDisableBuiltinMcps: !!document.getElementById('set-copilotDisableBuiltinMcps')?.checked,
|
|
1192
|
+
propagateClaudeMdForNonClaudeRuntimes: !!document.getElementById('set-propagateClaudeMdForNonClaudeRuntimes')?.checked,
|
|
1194
1193
|
copilotStreamMode: document.getElementById('set-copilotStreamMode')?.value || 'on',
|
|
1195
1194
|
copilotReasoningSummaries: !!document.getElementById('set-copilotReasoningSummaries')?.checked,
|
|
1196
1195
|
claudePreApproveWorkspaceMcps: !!document.getElementById('set-claudePreApproveWorkspaceMcps')?.checked,
|
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
</h2>
|
|
27
27
|
<div id="keep-processes-content"><p class="empty">No agents have left processes running. Set <code>meta.keep_processes: true</code> on a work item to enable.</p></div>
|
|
28
28
|
</section>
|
|
29
|
+
<section id="worktree-quarantine-refs-section">
|
|
30
|
+
<h2>Quarantined Work <span class="count" id="worktree-quarantine-refs-count">0</span>
|
|
31
|
+
<span class="qa-section-subtitle">local backup refs available for manual recovery</span>
|
|
32
|
+
</h2>
|
|
33
|
+
<div id="worktree-quarantine-refs-content"><p class="empty">No quarantined work refs found.</p></div>
|
|
34
|
+
</section>
|
|
29
35
|
<section id="managed-processes-section">
|
|
30
36
|
<h2>Managed Processes <span class="count" id="managed-processes-count">0</span>
|
|
31
37
|
<span class="qa-section-subtitle">engine-managed long-running services</span>
|
package/dashboard.js
CHANGED
|
@@ -26,7 +26,7 @@ const fs = require('fs');
|
|
|
26
26
|
const path = require('path');
|
|
27
27
|
const v8 = require('v8');
|
|
28
28
|
const llm = require('./engine/llm');
|
|
29
|
-
const { resolveRuntime } = require('./engine/runtimes');
|
|
29
|
+
const { resolveRuntime, shouldSuppressPostMutationError } = require('./engine/runtimes');
|
|
30
30
|
|
|
31
31
|
// Dashboard version stamp — captured at module load so it reflects the code actually running.
|
|
32
32
|
// codeCommit is read via _readGitHeadShort (defined below — function declarations
|
|
@@ -1655,13 +1655,23 @@ const PLANS_DIR = path.join(MINIONS_DIR, 'plans');
|
|
|
1655
1655
|
// W-mpetru8a000s123a — /api/plans cache. Dashboard auto-polls every 4s; the
|
|
1656
1656
|
// pre-cache cold path walked 4 directories (plans/, prd/, archive variants),
|
|
1657
1657
|
// sync-stat'd every file, and parsed/regex-scanned each .md → 2-3s blocking.
|
|
1658
|
-
//
|
|
1659
|
-
// call invalidatePlansCache() for immediate visibility
|
|
1658
|
+
// 30s TTL keeps the full markdown/SQL rollup off the 4s browser poll cadence.
|
|
1659
|
+
// Mutation handlers call invalidatePlansCache() for immediate visibility;
|
|
1660
|
+
// out-of-process edits surface within 30s.
|
|
1660
1661
|
let _plansCache = null;
|
|
1661
1662
|
let _plansCacheTs = 0;
|
|
1662
|
-
const PLANS_CACHE_TTL_MS =
|
|
1663
|
+
const PLANS_CACHE_TTL_MS = 30000;
|
|
1663
1664
|
function invalidatePlansCache() { _plansCache = null; _plansCacheTs = 0; }
|
|
1664
1665
|
|
|
1666
|
+
async function _mapInBatches(items, batchSize, mapper) {
|
|
1667
|
+
const result = [];
|
|
1668
|
+
for (let start = 0; start < items.length; start += batchSize) {
|
|
1669
|
+
result.push(...await Promise.all(items.slice(start, start + batchSize).map(mapper)));
|
|
1670
|
+
if (start + batchSize < items.length) await _yieldEventLoop();
|
|
1671
|
+
}
|
|
1672
|
+
return result;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1665
1675
|
function statePathExists(filePath) {
|
|
1666
1676
|
return fs.existsSync(filePath);
|
|
1667
1677
|
}
|
|
@@ -1964,11 +1974,19 @@ function _countWorktrees() {
|
|
|
1964
1974
|
try {
|
|
1965
1975
|
const config = queries.getConfig();
|
|
1966
1976
|
const projects = shared.getProjects(config);
|
|
1967
|
-
|
|
1977
|
+
const worktreeRoots = new Map();
|
|
1968
1978
|
for (const p of projects) {
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1979
|
+
if (!p.localPath) continue;
|
|
1980
|
+
const wtRoot = path.resolve(
|
|
1981
|
+
p.localPath,
|
|
1982
|
+
config.engine?.worktreeRoot || shared.ENGINE_DEFAULTS.worktreeRoot,
|
|
1983
|
+
);
|
|
1984
|
+
const rootKey = shared._normalizeWorktreePath(wtRoot);
|
|
1985
|
+
if (!worktreeRoots.has(rootKey)) worktreeRoots.set(rootKey, wtRoot);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
let count = 0;
|
|
1989
|
+
for (const wtRoot of worktreeRoots.values()) {
|
|
1972
1990
|
try {
|
|
1973
1991
|
for (const dir of fs.readdirSync(wtRoot)) {
|
|
1974
1992
|
const dirPath = path.join(wtRoot, dir);
|
|
@@ -2961,15 +2979,35 @@ async function _freshnessEntryMtimeMs(fp, depth) {
|
|
|
2961
2979
|
return max;
|
|
2962
2980
|
}
|
|
2963
2981
|
|
|
2982
|
+
const FRESHNESS_MTIME_CACHE_TTL_MS = 1000;
|
|
2983
|
+
const _freshnessMtimeCache = new Map();
|
|
2984
|
+
|
|
2964
2985
|
async function _maxInputMtimeMs(inputs) {
|
|
2986
|
+
const key = inputs.map(fp => path.resolve(fp)).sort().join('\0');
|
|
2987
|
+
const now = Date.now();
|
|
2988
|
+
const cached = _freshnessMtimeCache.get(key);
|
|
2989
|
+
if (cached && now - cached.createdAt < FRESHNESS_MTIME_CACHE_TTL_MS) {
|
|
2990
|
+
return cached.promise;
|
|
2991
|
+
}
|
|
2965
2992
|
// Directory mtime only advances on add/remove, not in-place edits. Walk two
|
|
2966
2993
|
// levels so prd/<plan>.json and agents/<id>/live-output.log both bust ETags.
|
|
2967
2994
|
// Deeper steering edits still rely on add/remove cadence, matching the prior
|
|
2968
2995
|
// bounded walk.
|
|
2969
|
-
const
|
|
2996
|
+
const promise = Promise.all(
|
|
2970
2997
|
inputs.map(fp => _freshnessEntryMtimeMs(fp, 2)),
|
|
2971
|
-
);
|
|
2972
|
-
|
|
2998
|
+
).then(mtimes => Math.floor(mtimes.reduce((max, mtime) => Math.max(max, mtime), 0)));
|
|
2999
|
+
_freshnessMtimeCache.set(key, { createdAt: now, promise });
|
|
3000
|
+
if (_freshnessMtimeCache.size > 100) {
|
|
3001
|
+
for (const [cacheKey, entry] of _freshnessMtimeCache) {
|
|
3002
|
+
if (now - entry.createdAt >= FRESHNESS_MTIME_CACHE_TTL_MS) _freshnessMtimeCache.delete(cacheKey);
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
try {
|
|
3006
|
+
return await promise;
|
|
3007
|
+
} catch (err) {
|
|
3008
|
+
if (_freshnessMtimeCache.get(key)?.promise === promise) _freshnessMtimeCache.delete(key);
|
|
3009
|
+
throw err;
|
|
3010
|
+
}
|
|
2973
3011
|
}
|
|
2974
3012
|
async function serveFreshJson(req, res, opts) {
|
|
2975
3013
|
const inputs = Array.isArray(opts && opts.inputs) ? opts.inputs : [];
|
|
@@ -3161,7 +3199,20 @@ function handleStateRead(req, res) {
|
|
|
3161
3199
|
res.setHeader('Content-Type', _stateReadContentType(path.extname(resolved)));
|
|
3162
3200
|
try {
|
|
3163
3201
|
const stream = fs.createReadStream(resolved);
|
|
3202
|
+
// HANDLE-LEAK GUARD (#858): destroy the read stream if the client aborts or
|
|
3203
|
+
// the connection closes before the pipe drains. Without this, an aborted
|
|
3204
|
+
// GET /state/notes.md leaks the open file handle indefinitely (confirmed
|
|
3205
|
+
// via handle64: dashboard held a handle on notes.md for 2+ hours). On
|
|
3206
|
+
// Windows that lingering handle makes the engine's consolidation
|
|
3207
|
+
// renameSync throw EPERM, which used to crash-loop the whole daemon.
|
|
3208
|
+
const destroyStream = () => { if (!stream.destroyed) stream.destroy(); };
|
|
3209
|
+
if (typeof res.on === 'function') res.on('close', destroyStream);
|
|
3210
|
+
if (typeof req.on === 'function') {
|
|
3211
|
+
req.on('close', destroyStream);
|
|
3212
|
+
req.on('aborted', destroyStream);
|
|
3213
|
+
}
|
|
3164
3214
|
stream.on('error', (err) => {
|
|
3215
|
+
destroyStream();
|
|
3165
3216
|
if (!res.headersSent) {
|
|
3166
3217
|
res.statusCode = 500;
|
|
3167
3218
|
res.setHeader('Content-Type', 'application/json');
|
|
@@ -4635,6 +4686,7 @@ function _invokeDocChatViaPool({ prompt, model, effort, engineConfig, systemProm
|
|
|
4635
4686
|
(async () => {
|
|
4636
4687
|
try {
|
|
4637
4688
|
sessionHandle = await ccWorkerPool.getSession({
|
|
4689
|
+
runtime: llm._resolveRuntimeFor({ engineConfig }),
|
|
4638
4690
|
tabId: tabKey,
|
|
4639
4691
|
model,
|
|
4640
4692
|
effort,
|
|
@@ -5302,13 +5354,15 @@ function _recoverPartialDocChatResponse(result, sessionKey) {
|
|
|
5302
5354
|
function _shouldSuppressDocChatPostPatchError(ccError, finalize) {
|
|
5303
5355
|
if (!finalize || finalize.edited !== true) return false;
|
|
5304
5356
|
if (!ccError) return false;
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5357
|
+
try {
|
|
5358
|
+
const runtime = resolveRuntime(ccError.runtime);
|
|
5359
|
+
return shouldSuppressPostMutationError(runtime, {
|
|
5360
|
+
mutationApplied: true,
|
|
5361
|
+
error: ccError,
|
|
5362
|
+
});
|
|
5363
|
+
} catch {
|
|
5364
|
+
return false;
|
|
5365
|
+
}
|
|
5312
5366
|
}
|
|
5313
5367
|
|
|
5314
5368
|
function _buildDocChatResponsePayload({
|
|
@@ -7688,7 +7742,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
7688
7742
|
{ dir: path.join(PLANS_DIR, 'archive'), archived: true },
|
|
7689
7743
|
]) {
|
|
7690
7744
|
const allFiles = (await fsp.readdir(dir).catch(() => [])).filter(f => f.endsWith('.md'));
|
|
7691
|
-
const dirResults = await
|
|
7745
|
+
const dirResults = await _mapInBatches(allFiles, 25, async f => {
|
|
7692
7746
|
const filePath = path.join(dir, f);
|
|
7693
7747
|
const [content, stat] = await Promise.all([
|
|
7694
7748
|
fsp.readFile(filePath, 'utf8').catch(() => ''),
|
|
@@ -7725,10 +7779,20 @@ const server = http.createServer(async (req, res) => {
|
|
|
7725
7779
|
revisionFeedback: null,
|
|
7726
7780
|
version: versionMatch ? parseInt(versionMatch[1]) : null,
|
|
7727
7781
|
};
|
|
7728
|
-
})
|
|
7782
|
+
});
|
|
7729
7783
|
for (const r of dirResults) if (r) plans.push(r);
|
|
7730
7784
|
}
|
|
7731
7785
|
|
|
7786
|
+
const sourcePlanMtimes = new Map();
|
|
7787
|
+
const sourcePlanNames = [...new Set(prdRows
|
|
7788
|
+
.filter(row => !row.archived && row.plan?.source_plan)
|
|
7789
|
+
.map(row => row.plan.source_plan))];
|
|
7790
|
+
const sourceStats = await _mapInBatches(sourcePlanNames, 25, async sourcePlan => {
|
|
7791
|
+
const stat = await fsp.stat(path.join(PLANS_DIR, sourcePlan)).catch(() => null);
|
|
7792
|
+
return [sourcePlan, stat ? Math.floor(stat.mtimeMs) : null];
|
|
7793
|
+
});
|
|
7794
|
+
for (const [sourcePlan, mtimeMs] of sourceStats) sourcePlanMtimes.set(sourcePlan, mtimeMs);
|
|
7795
|
+
|
|
7732
7796
|
// --- PRD JSON records (prd/*.json + prd/archive/*.json, SQL-backed) ---
|
|
7733
7797
|
for (const row of prdRows) {
|
|
7734
7798
|
const plan = row.plan;
|
|
@@ -7742,11 +7806,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
7742
7806
|
// so the Plans tab does not silently mirror a stale-but-cached false.
|
|
7743
7807
|
let freshStale = false;
|
|
7744
7808
|
if (!archived && plan.source_plan) {
|
|
7745
|
-
|
|
7746
|
-
|
|
7747
|
-
|
|
7748
|
-
if (recorded && sourceMtime > recorded) freshStale = true;
|
|
7749
|
-
} catch { /* source plan may have been deleted/renamed */ }
|
|
7809
|
+
const sourceMtime = sourcePlanMtimes.get(plan.source_plan);
|
|
7810
|
+
const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
|
|
7811
|
+
if (recorded && sourceMtime != null && sourceMtime > recorded) freshStale = true;
|
|
7750
7812
|
}
|
|
7751
7813
|
// P-e8d49105 — _projects rollup feeds the plan-card multi-badge
|
|
7752
7814
|
// path in dashboard/js/render-plans.js. For PRD JSONs, walk
|
|
@@ -9396,6 +9458,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9396
9458
|
if (!shared.resolveCcUseWorkerPool(CONFIG.engine)) return { skipped: 'pool-disabled' };
|
|
9397
9459
|
if (!tabId) throw new Error('tabId required');
|
|
9398
9460
|
const result = await ccWorkerPool.warmTab({
|
|
9461
|
+
runtime: resolveRuntime(shared.resolveCcCli(CONFIG.engine)),
|
|
9399
9462
|
tabId,
|
|
9400
9463
|
model: CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel,
|
|
9401
9464
|
effort: CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort,
|
|
@@ -9772,6 +9835,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9772
9835
|
(async () => {
|
|
9773
9836
|
try {
|
|
9774
9837
|
sessionHandle = await ccWorkerPool.getSession({
|
|
9838
|
+
runtime: llm._resolveRuntimeFor({ engineConfig }),
|
|
9775
9839
|
tabId: resolvedTabId,
|
|
9776
9840
|
model,
|
|
9777
9841
|
effort,
|
|
@@ -11762,6 +11826,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11762
11826
|
age_minutes: ageMinutes,
|
|
11763
11827
|
};
|
|
11764
11828
|
}
|
|
11829
|
+
|
|
11765
11830
|
return {
|
|
11766
11831
|
agentId: rec.agentId,
|
|
11767
11832
|
valid: true,
|
|
@@ -11785,6 +11850,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11785
11850
|
}
|
|
11786
11851
|
}
|
|
11787
11852
|
|
|
11853
|
+
async function handleWorktreeQuarantineRefs(req, res) {
|
|
11854
|
+
try {
|
|
11855
|
+
const { listQuarantineRefs } = require('./engine/quarantine-refs');
|
|
11856
|
+
return jsonReply(res, 200, {
|
|
11857
|
+
items: await listQuarantineRefs(PROJECTS),
|
|
11858
|
+
generatedAt: new Date().toISOString(),
|
|
11859
|
+
}, req);
|
|
11860
|
+
} catch (e) {
|
|
11861
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
11862
|
+
}
|
|
11863
|
+
}
|
|
11864
|
+
|
|
11788
11865
|
async function handleKeepProcessesKill(req, res) {
|
|
11789
11866
|
try {
|
|
11790
11867
|
const body = await readBody(req);
|
|
@@ -13179,6 +13256,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13179
13256
|
}},
|
|
13180
13257
|
{ method: 'GET', path: '/api/health', desc: 'Lightweight health check for monitoring', handler: handleHealth },
|
|
13181
13258
|
{ method: 'GET', path: '/api/keep-processes', desc: 'List all active agents/<id>/keep-pids.json entries (W-mp68q6ke0010de68)', handler: handleKeepProcessesList },
|
|
13259
|
+
{ method: 'GET', path: '/api/worktree-quarantine-refs', desc: 'List recoverable refs/minions/quarantine/* and quarantine-wip/* refs across configured projects', handler: handleWorktreeQuarantineRefs },
|
|
13182
13260
|
{ method: 'POST', path: '/api/keep-processes/kill', desc: 'Kill a single kept PID and remove it from the agent\'s keep-pids.json', params: 'agentId, pid', handler: handleKeepProcessesKill },
|
|
13183
13261
|
// P-4b8d2e57 (managed-spawn item 4): engine-managed long-running services.
|
|
13184
13262
|
// List/by-name return ETag + honor If-None-Match → 304. Kill removes from
|
|
@@ -13549,6 +13627,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13549
13627
|
status: 'enrolled',
|
|
13550
13628
|
prId: linkResult.id,
|
|
13551
13629
|
autoManaged,
|
|
13630
|
+
resurrected: linkResult.resurrected === true,
|
|
13552
13631
|
contextOnly: false,
|
|
13553
13632
|
project: linkResult.targetProject?.name || 'central',
|
|
13554
13633
|
projectName: plan.projectName || linkResult.targetProject?.name || null,
|
|
@@ -14767,7 +14846,8 @@ if (require.main === module) {
|
|
|
14767
14846
|
// supervisor) discovers it instead of guessing 7331. The original
|
|
14768
14847
|
// server.on('error') one-shot exit is replaced by the retry loop — only
|
|
14769
14848
|
// non-EADDRINUSE errors abort.
|
|
14770
|
-
const
|
|
14849
|
+
const requireRequestedPort = process.env.MINIONS_REQUIRE_DASHBOARD_PORT === '1';
|
|
14850
|
+
const maxScan = requireRequestedPort ? 1 : (shared.DASHBOARD_PORT_SCAN_MAX || 100);
|
|
14771
14851
|
let listenErr = null;
|
|
14772
14852
|
for (let attempt = 0; attempt < maxScan; attempt++) {
|
|
14773
14853
|
const tryPort = REQUESTED_PORT + attempt;
|
|
@@ -14793,7 +14873,10 @@ if (require.main === module) {
|
|
|
14793
14873
|
}
|
|
14794
14874
|
if (listenErr) {
|
|
14795
14875
|
if (listenErr.code === 'EADDRINUSE') {
|
|
14796
|
-
|
|
14876
|
+
const range = requireRequestedPort
|
|
14877
|
+
? String(REQUESTED_PORT)
|
|
14878
|
+
: `[${REQUESTED_PORT}..${REQUESTED_PORT + maxScan - 1}]`;
|
|
14879
|
+
console.error(`\n No free dashboard port at ${range}. Free it and retry.\n`);
|
|
14797
14880
|
} else {
|
|
14798
14881
|
console.error(listenErr);
|
|
14799
14882
|
}
|
package/docs/README.md
CHANGED
|
@@ -29,6 +29,7 @@ Hands-on stories and distribution guides for people running or evaluating Minion
|
|
|
29
29
|
Architecture, design proposals, and lifecycle references for people working on the engine, dashboard, or playbooks.
|
|
30
30
|
|
|
31
31
|
- [branch-derivation.md](branch-derivation.md) — Engine-side branch fallback (`work/<wi-id>`) vs. agent-authored long form, the structured-vs-loose PR-pointer extractors, and the canonical PR-fix duplication incident.
|
|
32
|
+
- [claude-md-propagation.md](claude-md-propagation.md) — Propagating repo-authored `CLAUDE.md` instructions to runtimes that don't auto-load it (Copilot/Codex): the `claudeMdNativeDiscovery` capability flag, the bounded nearest-applicable walk-up discovery, the `propagateClaudeMdForNonClaudeRuntimes` config knob, and why it's orthogonal to each runtime's native `AGENTS.md` discovery.
|
|
32
33
|
- [command-center.md](command-center.md) — Command Center (CC) chat panel: configured-runtime sessions, resume semantics, system-prompt invalidation, and per-tab session storage.
|
|
33
34
|
- [specs/agent-rename.md](specs/agent-rename.md) — Design spec for **agent rename & name decoupling** — the prerequisite for the Role/Agent Library. Formalizes the stable opaque agent id, makes charters name-agnostic, and adds `POST /api/agents/rename` (display name + emoji). Feature-flagged (`agent-rename`) and fully backward-compatible.
|
|
34
35
|
- [specs/agent-configurability.md](specs/agent-configurability.md) — Design spec for the user-configurable Role / Agent Library, feature-flagged behind `agent-library` and fully backward-compatible. Builds on `agent-rename.md`. Role = the reusable durable charter; agent = a thin instance (identity + variable expertise + role pointer). Phase 0 renames `skills`→`expertise`; Phase 1 exposes/edits role charters + per-agent expertise; Phase 2 adds role/agent CRUD with builtin delete-protection.
|