@yemi33/minions 0.1.2381 → 0.1.2382

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 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 = { ...process.env, MINIONS_NO_AUTO_OPEN: '1' };
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.log(` Dashboard bound to alternate port ${actualPort} (requested ${requested.port} was busy)`);
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 —
@@ -151,8 +151,11 @@ const RENDER_VERSIONS = {
151
151
  dispatch: 2,
152
152
  engineLog: 2,
153
153
  metrics: 1,
154
- // Bumped 8->9: row actions now preserve work-item source scope when IDs collide.
155
- workItems: 9,
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: 'Pre-dispatch evaluation rejected: ' + firstLine,
58
- full: rawReason,
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
- var titleEl = document.getElementById('modal-title');
1090
- var bodyEl = document.getElementById('modal-body');
1091
- if (!titleEl || !bodyEl) return;
1092
- titleEl.textContent = filename + ' (archived)';
1093
- // eslint-disable-next-line no-unsanitized/property -- reason: renderMd() escapes note content before assembling HTML; filename is set via textContent
1094
- bodyEl.innerHTML = '<div style="font-size:var(--text-md);line-height:1.7;color:var(--muted)">' + renderMd(content) + '</div>';
1095
- // Wire Doc Chat for the deeplinked archive note, mirroring the live-inbox
1096
- // openModal() path (render-prs.js). Without this the archive branch opened
1097
- // the note as plain text with no Q&A panel, so deeplinking into a note
1098
- // from a (completed) work item sometimes silently dropped doc-chat.
1099
- _modalDocContext = { title: filename + ' (archived)', content: content, selection: '' };
1100
- _modalFilePath = 'notes/archive/' + filename; showModalQa();
1101
- document.getElementById('modal').classList.add('open');
1102
- // P-34fa5d79 Source-WI chip for archive notes. Same convention as the
1103
- // live-inbox branch above; parsed off the freshly-fetched content.
1104
- _wiInjectSourceChipFromFrontmatter(content, 'modal-body');
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
- closeModal();
1108
- switchPage('inbox');
1146
+ applyIfCurrent(function() {
1147
+ closeModal();
1148
+ switchPage('inbox');
1149
+ });
1109
1150
  });
1110
1151
  }
1111
1152
 
@@ -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
- let opts = '<option value=""' + (!currentValue ? ' selected' : '') + '>(fleet/default)</option>';
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 ? (id + ' — ' + m.name) : id;
981
- opts += '<option value="' + escHtml(id) + '"' + (id === currentValue ? ' selected' : '') + '>' + escHtml(label) + '</option>';
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 = '<select ' + baseAttrs + ' style="' + baseStyle + '">' + opts + '</select>';
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
@@ -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
- // 5s TTL means external .md edits surface within 5s. Mutation handlers must
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 = 5000;
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
  }
@@ -2961,15 +2971,35 @@ async function _freshnessEntryMtimeMs(fp, depth) {
2961
2971
  return max;
2962
2972
  }
2963
2973
 
2974
+ const FRESHNESS_MTIME_CACHE_TTL_MS = 1000;
2975
+ const _freshnessMtimeCache = new Map();
2976
+
2964
2977
  async function _maxInputMtimeMs(inputs) {
2978
+ const key = inputs.map(fp => path.resolve(fp)).sort().join('\0');
2979
+ const now = Date.now();
2980
+ const cached = _freshnessMtimeCache.get(key);
2981
+ if (cached && now - cached.createdAt < FRESHNESS_MTIME_CACHE_TTL_MS) {
2982
+ return cached.promise;
2983
+ }
2965
2984
  // Directory mtime only advances on add/remove, not in-place edits. Walk two
2966
2985
  // levels so prd/<plan>.json and agents/<id>/live-output.log both bust ETags.
2967
2986
  // Deeper steering edits still rely on add/remove cadence, matching the prior
2968
2987
  // bounded walk.
2969
- const mtimes = await Promise.all(
2988
+ const promise = Promise.all(
2970
2989
  inputs.map(fp => _freshnessEntryMtimeMs(fp, 2)),
2971
- );
2972
- return Math.floor(mtimes.reduce((max, mtime) => Math.max(max, mtime), 0));
2990
+ ).then(mtimes => Math.floor(mtimes.reduce((max, mtime) => Math.max(max, mtime), 0)));
2991
+ _freshnessMtimeCache.set(key, { createdAt: now, promise });
2992
+ if (_freshnessMtimeCache.size > 100) {
2993
+ for (const [cacheKey, entry] of _freshnessMtimeCache) {
2994
+ if (now - entry.createdAt >= FRESHNESS_MTIME_CACHE_TTL_MS) _freshnessMtimeCache.delete(cacheKey);
2995
+ }
2996
+ }
2997
+ try {
2998
+ return await promise;
2999
+ } catch (err) {
3000
+ if (_freshnessMtimeCache.get(key)?.promise === promise) _freshnessMtimeCache.delete(key);
3001
+ throw err;
3002
+ }
2973
3003
  }
2974
3004
  async function serveFreshJson(req, res, opts) {
2975
3005
  const inputs = Array.isArray(opts && opts.inputs) ? opts.inputs : [];
@@ -3161,7 +3191,20 @@ function handleStateRead(req, res) {
3161
3191
  res.setHeader('Content-Type', _stateReadContentType(path.extname(resolved)));
3162
3192
  try {
3163
3193
  const stream = fs.createReadStream(resolved);
3194
+ // HANDLE-LEAK GUARD (#858): destroy the read stream if the client aborts or
3195
+ // the connection closes before the pipe drains. Without this, an aborted
3196
+ // GET /state/notes.md leaks the open file handle indefinitely (confirmed
3197
+ // via handle64: dashboard held a handle on notes.md for 2+ hours). On
3198
+ // Windows that lingering handle makes the engine's consolidation
3199
+ // renameSync throw EPERM, which used to crash-loop the whole daemon.
3200
+ const destroyStream = () => { if (!stream.destroyed) stream.destroy(); };
3201
+ if (typeof res.on === 'function') res.on('close', destroyStream);
3202
+ if (typeof req.on === 'function') {
3203
+ req.on('close', destroyStream);
3204
+ req.on('aborted', destroyStream);
3205
+ }
3164
3206
  stream.on('error', (err) => {
3207
+ destroyStream();
3165
3208
  if (!res.headersSent) {
3166
3209
  res.statusCode = 500;
3167
3210
  res.setHeader('Content-Type', 'application/json');
@@ -7688,7 +7731,7 @@ const server = http.createServer(async (req, res) => {
7688
7731
  { dir: path.join(PLANS_DIR, 'archive'), archived: true },
7689
7732
  ]) {
7690
7733
  const allFiles = (await fsp.readdir(dir).catch(() => [])).filter(f => f.endsWith('.md'));
7691
- const dirResults = await Promise.all(allFiles.map(async f => {
7734
+ const dirResults = await _mapInBatches(allFiles, 25, async f => {
7692
7735
  const filePath = path.join(dir, f);
7693
7736
  const [content, stat] = await Promise.all([
7694
7737
  fsp.readFile(filePath, 'utf8').catch(() => ''),
@@ -7725,10 +7768,20 @@ const server = http.createServer(async (req, res) => {
7725
7768
  revisionFeedback: null,
7726
7769
  version: versionMatch ? parseInt(versionMatch[1]) : null,
7727
7770
  };
7728
- }));
7771
+ });
7729
7772
  for (const r of dirResults) if (r) plans.push(r);
7730
7773
  }
7731
7774
 
7775
+ const sourcePlanMtimes = new Map();
7776
+ const sourcePlanNames = [...new Set(prdRows
7777
+ .filter(row => !row.archived && row.plan?.source_plan)
7778
+ .map(row => row.plan.source_plan))];
7779
+ const sourceStats = await _mapInBatches(sourcePlanNames, 25, async sourcePlan => {
7780
+ const stat = await fsp.stat(path.join(PLANS_DIR, sourcePlan)).catch(() => null);
7781
+ return [sourcePlan, stat ? Math.floor(stat.mtimeMs) : null];
7782
+ });
7783
+ for (const [sourcePlan, mtimeMs] of sourceStats) sourcePlanMtimes.set(sourcePlan, mtimeMs);
7784
+
7732
7785
  // --- PRD JSON records (prd/*.json + prd/archive/*.json, SQL-backed) ---
7733
7786
  for (const row of prdRows) {
7734
7787
  const plan = row.plan;
@@ -7742,11 +7795,9 @@ const server = http.createServer(async (req, res) => {
7742
7795
  // so the Plans tab does not silently mirror a stale-but-cached false.
7743
7796
  let freshStale = false;
7744
7797
  if (!archived && plan.source_plan) {
7745
- try {
7746
- const sourceMtime = Math.floor(fs.statSync(path.join(PLANS_DIR, plan.source_plan)).mtimeMs);
7747
- const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
7748
- if (recorded && sourceMtime > recorded) freshStale = true;
7749
- } catch { /* source plan may have been deleted/renamed */ }
7798
+ const sourceMtime = sourcePlanMtimes.get(plan.source_plan);
7799
+ const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
7800
+ if (recorded && sourceMtime != null && sourceMtime > recorded) freshStale = true;
7750
7801
  }
7751
7802
  // P-e8d49105 — _projects rollup feeds the plan-card multi-badge
7752
7803
  // path in dashboard/js/render-plans.js. For PRD JSONs, walk
@@ -11762,6 +11813,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11762
11813
  age_minutes: ageMinutes,
11763
11814
  };
11764
11815
  }
11816
+
11765
11817
  return {
11766
11818
  agentId: rec.agentId,
11767
11819
  valid: true,
@@ -11785,6 +11837,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11785
11837
  }
11786
11838
  }
11787
11839
 
11840
+ async function handleWorktreeQuarantineRefs(req, res) {
11841
+ try {
11842
+ const { listQuarantineRefs } = require('./engine/quarantine-refs');
11843
+ return jsonReply(res, 200, {
11844
+ items: await listQuarantineRefs(PROJECTS),
11845
+ generatedAt: new Date().toISOString(),
11846
+ }, req);
11847
+ } catch (e) {
11848
+ return jsonReply(res, 500, { error: e.message }, req);
11849
+ }
11850
+ }
11851
+
11788
11852
  async function handleKeepProcessesKill(req, res) {
11789
11853
  try {
11790
11854
  const body = await readBody(req);
@@ -13179,6 +13243,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13179
13243
  }},
13180
13244
  { method: 'GET', path: '/api/health', desc: 'Lightweight health check for monitoring', handler: handleHealth },
13181
13245
  { method: 'GET', path: '/api/keep-processes', desc: 'List all active agents/<id>/keep-pids.json entries (W-mp68q6ke0010de68)', handler: handleKeepProcessesList },
13246
+ { method: 'GET', path: '/api/worktree-quarantine-refs', desc: 'List recoverable refs/minions/quarantine/* and quarantine-wip/* refs across configured projects', handler: handleWorktreeQuarantineRefs },
13182
13247
  { 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
13248
  // P-4b8d2e57 (managed-spawn item 4): engine-managed long-running services.
13184
13249
  // List/by-name return ETag + honor If-None-Match → 304. Kill removes from
@@ -14767,7 +14832,8 @@ if (require.main === module) {
14767
14832
  // supervisor) discovers it instead of guessing 7331. The original
14768
14833
  // server.on('error') one-shot exit is replaced by the retry loop — only
14769
14834
  // non-EADDRINUSE errors abort.
14770
- const maxScan = shared.DASHBOARD_PORT_SCAN_MAX || 100;
14835
+ const requireRequestedPort = process.env.MINIONS_REQUIRE_DASHBOARD_PORT === '1';
14836
+ const maxScan = requireRequestedPort ? 1 : (shared.DASHBOARD_PORT_SCAN_MAX || 100);
14771
14837
  let listenErr = null;
14772
14838
  for (let attempt = 0; attempt < maxScan; attempt++) {
14773
14839
  const tryPort = REQUESTED_PORT + attempt;
@@ -14793,7 +14859,10 @@ if (require.main === module) {
14793
14859
  }
14794
14860
  if (listenErr) {
14795
14861
  if (listenErr.code === 'EADDRINUSE') {
14796
- console.error(`\n No free port in [${REQUESTED_PORT}..${REQUESTED_PORT + maxScan - 1}]. Free one and retry.\n`);
14862
+ const range = requireRequestedPort
14863
+ ? String(REQUESTED_PORT)
14864
+ : `[${REQUESTED_PORT}..${REQUESTED_PORT + maxScan - 1}]`;
14865
+ console.error(`\n No free dashboard port at ${range}. Free it and retry.\n`);
14797
14866
  } else {
14798
14867
  console.error(listenErr);
14799
14868
  }
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.
@@ -0,0 +1,118 @@
1
+ # CLAUDE.md propagation to non-Claude runtimes
2
+
3
+ **Work item:** W-mrdon0pe000l045a
4
+ **Config flag:** `engine.propagateClaudeMdForNonClaudeRuntimes` (default `true`)
5
+ **Per-project override:** `project.propagateClaudeMdForNonClaudeRuntimes`
6
+ **Module:** `engine/claude-md-context.js` · **Injection:** `engine/playbook.js`
7
+
8
+ ## Problem
9
+
10
+ `CLAUDE.md` auto-discovery is a **Claude-Code-CLI-only** convention — the `claude`
11
+ binary reads repo-authored `CLAUDE.md` files itself at startup (unless `--bare`).
12
+ The **Copilot** and **Codex** runtimes have **no equivalent auto-load** for
13
+ `CLAUDE.md` at all. So repo-authored `CLAUDE.md` files — which frequently carry
14
+ load-bearing "must be kept in sync" instructions and name blessed tooling — are
15
+ structurally invisible to a Copilot/Codex-based fleet.
16
+
17
+ ### Motivating incident
18
+
19
+ `W-mrdkv6zc00071fef` (a `fluidclientframework.android` dependency bump in
20
+ `office/src`) missed a **third** sync-point file (`settings.gradle.kts:116`) that
21
+ `loop/app/android/android/CLAUDE.md` explicitly warns about, and that
22
+ `updateDeps.bat` (named in that same `CLAUDE.md`) would have caught via its build
23
+ verification step. Neither the implementer nor the reviewer ever saw that file's
24
+ content because Copilot has no `CLAUDE.md` auto-load mechanism. Propagating
25
+ `CLAUDE.md` content directly addresses recurrence of this bug class.
26
+
27
+ ## Relationship to Copilot's native `AGENTS.md` discovery (NOT the same gap)
28
+
29
+ Copilot has its **own native `AGENTS.md` auto-load**, which (post-#817, "Delegate
30
+ repository harness discovery to native runtimes") Minions leaves to the runtime
31
+ CLI itself rather than suppressing or synthesizing.
32
+
33
+ `CLAUDE.md` propagation is **independent and additive**:
34
+
35
+ - `CLAUDE.md` carries **zero split-brain risk** for Copilot/Codex, because those
36
+ runtimes **never read it themselves** — there is nothing to conflict with.
37
+ - Therefore propagation is **orthogonal** to Copilot's native `AGENTS.md`
38
+ discovery: whether or not the runtime loads its own `AGENTS.md` has **no
39
+ effect** on `CLAUDE.md` propagation.
40
+
41
+ | Concern | Owner | What governs it |
42
+ |---------|-------|-----------------|
43
+ | Copilot's OWN `AGENTS.md` auto-load | the Copilot CLI (native) | delegated to the runtime; Minions does not suppress or synthesize it |
44
+ | Repo `CLAUDE.md` invisible to non-Claude runtimes | `propagateClaudeMdForNonClaudeRuntimes` | injects a "Project instructions (CLAUDE.md)" prompt layer |
45
+
46
+ ## Capability flag
47
+
48
+ Each runtime adapter (`engine/runtimes/<name>.js`) declares
49
+ `capabilities.claudeMdNativeDiscovery`:
50
+
51
+ | Runtime | `claudeMdNativeDiscovery` | Propagation |
52
+ |---------|---------------------------|-------------|
53
+ | `claude` | `true` | **skipped** — the Claude CLI already reads CLAUDE.md; re-injecting would double it |
54
+ | `copilot` | `false` | **injected** |
55
+ | `codex` | `false` | **injected** |
56
+
57
+ Unknown runtimes are treated as **not** auto-discovering (propagate), since an
58
+ unrecognized runtime is far more likely to lack native CLAUDE.md loading than to
59
+ have it. Engine code gates on the capability flag, never on `runtime.name`.
60
+
61
+ ## Bounded discovery algorithm
62
+
63
+ `office/src` is a huge monorepo — a full tree walk collecting every `CLAUDE.md`
64
+ is not viable. Instead the module mirrors how a human/tool resolves "nearest
65
+ applicable instructions":
66
+
67
+ 1. **Collect path hints** for the dispatch (`engine.js#_deriveClaudeMdPathHints`):
68
+ `item.meta.workdir` and `item.references[*].path`.
69
+ 2. **Walk UP** from each hint directory toward the **project root**, `stat`-ing
70
+ `CLAUDE.md` at each ancestor level (`discoverClaudeMdFiles`).
71
+ 3. **Always include the project-root `CLAUDE.md`** as a fallback — if there are
72
+ no path hints, at minimum the project root is checked (discovery is never
73
+ skipped entirely).
74
+ 4. **Nearest-wins ordering:** deepest directory first, project root last, so the
75
+ most specific instructions lead.
76
+ 5. **Bounds (cheap by construction):**
77
+ - `MAX_WALK_DEPTH = 24` ancestor levels per hint (deep-path guard),
78
+ - `MAX_FILES = 8` total collected files,
79
+ - path-traversal guard — hints that escape the project root are dropped,
80
+ - the walk **never ascends above the project root**.
81
+
82
+ This only `stat`s `CLAUDE.md` at a small number of ancestor directories, so it
83
+ does not meaningfully slow dispatch even in a monorepo.
84
+
85
+ ## Injection & byte cap
86
+
87
+ `engine/playbook.js#renderPlaybook` adds `CLAUDE.md` propagation as a context
88
+ layer alongside the existing `pinned.md` → `notes.md` →
89
+ `knowledge/agents/<agentId>.md` layers:
90
+
91
+ - Header: `## Project instructions (CLAUDE.md)`, with a short provenance note so
92
+ the agent understands why it appears and that "keep in sync" rules are
93
+ authoritative.
94
+ - Content is wrapped in an `<UNTRUSTED-INPUT>` fence (repo-authored, semi-trusted).
95
+ - Each file is labeled by its repo-relative path (`### <relPath>`).
96
+ - The whole block is capped at `ENGINE_DEFAULTS.maxNotesPromptBytes` (the same
97
+ budget the sibling notes / agent-memory layers use), so it can never balloon a
98
+ prompt on a large monorepo. Individual files are truncated with a
99
+ "read the full file if needed" marker when they exceed the remaining budget.
100
+
101
+ Injection only happens when **all** hold:
102
+
103
+ 1. the resolved runtime's `claudeMdNativeDiscovery` is falsy (Copilot/Codex),
104
+ 2. `resolvePropagateClaudeMdForNonClaudeRuntimes(project, engine)` returns true,
105
+ 3. the project has a `localPath`, and
106
+ 4. at least one non-empty `CLAUDE.md` was discovered.
107
+
108
+ ## Config resolution
109
+
110
+ `shared.resolvePropagateClaudeMdForNonClaudeRuntimes(project, engine)`:
111
+
112
+ 1. per-project boolean `project.propagateClaudeMdForNonClaudeRuntimes`, else
113
+ 2. fleet-wide boolean `engine.propagateClaudeMdForNonClaudeRuntimes`, else
114
+ 3. hardcoded default `true`.
115
+
116
+ Only an explicit boolean counts as "set" at either level (undefined/null/string
117
+ fall through). Editable from **Settings → Copilot Tuning → "Propagate CLAUDE.md
118
+ to non-Claude runtimes"** or via `POST /api/settings`.