@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.
Files changed (54) hide show
  1. package/bin/minions.js +42 -2
  2. package/dashboard/js/refresh.js +8 -2
  3. package/dashboard/js/render-other.js +38 -0
  4. package/dashboard/js/render-work-items.js +61 -20
  5. package/dashboard/js/settings.js +7 -8
  6. package/dashboard/pages/engine.html +6 -0
  7. package/dashboard.js +110 -27
  8. package/docs/README.md +1 -0
  9. package/docs/claude-md-propagation.md +118 -0
  10. package/docs/completion-reports.md +20 -1
  11. package/docs/engine-restart.md +10 -0
  12. package/docs/runtime-adapters.md +54 -22
  13. package/docs/skills.md +2 -0
  14. package/docs/workspace-manifests.md +1 -1
  15. package/docs/worktree-lifecycle.md +23 -0
  16. package/engine/acp-transport.js +63 -35
  17. package/engine/ado-comment.js +3 -1
  18. package/engine/agent-worker-pool.js +11 -4
  19. package/engine/cc-worker-pool.js +4 -2
  20. package/engine/claude-md-context.js +195 -0
  21. package/engine/cli.js +9 -1
  22. package/engine/comment-format.js +51 -14
  23. package/engine/consolidation.js +47 -3
  24. package/engine/db/migrations/026-pre-dispatch-rejections.js +55 -0
  25. package/engine/dispatch.js +80 -23
  26. package/engine/execution-model.js +68 -0
  27. package/engine/gh-comment.js +6 -3
  28. package/engine/github.js +6 -7
  29. package/engine/lifecycle.js +195 -56
  30. package/engine/llm.js +59 -83
  31. package/engine/memory-store.js +3 -1
  32. package/engine/pipeline.js +147 -20
  33. package/engine/playbook.js +39 -6
  34. package/engine/pooled-agent-process.js +28 -26
  35. package/engine/prd-store.js +14 -6
  36. package/engine/quarantine-refs.js +33 -0
  37. package/engine/queries.js +8 -6
  38. package/engine/restart-health.js +69 -4
  39. package/engine/runtimes/claude.js +100 -25
  40. package/engine/runtimes/codex.js +19 -0
  41. package/engine/runtimes/contract.js +239 -0
  42. package/engine/runtimes/copilot.js +85 -8
  43. package/engine/runtimes/index.js +37 -9
  44. package/engine/shared.js +157 -64
  45. package/engine/spawn-agent.js +79 -113
  46. package/engine/spawn-phase-watchdog.js +8 -37
  47. package/engine/supervisor.js +72 -16
  48. package/engine/timeout.js +87 -16
  49. package/engine/untrusted-fence.js +2 -1
  50. package/engine.js +434 -125
  51. package/package.json +1 -1
  52. package/playbooks/fix.md +20 -0
  53. package/playbooks/review.md +2 -0
  54. 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 = { ...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 —
@@ -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');
@@ -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
@@ -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
- // 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
  }
@@ -1964,11 +1974,19 @@ function _countWorktrees() {
1964
1974
  try {
1965
1975
  const config = queries.getConfig();
1966
1976
  const projects = shared.getProjects(config);
1967
- let count = 0;
1977
+ const worktreeRoots = new Map();
1968
1978
  for (const p of projects) {
1969
- const root = p.localPath ? path.resolve(p.localPath) : null;
1970
- if (!root) continue;
1971
- const wtRoot = path.resolve(root, config.engine?.worktreeRoot || shared.ENGINE_DEFAULTS.worktreeRoot);
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 mtimes = await Promise.all(
2996
+ const promise = Promise.all(
2970
2997
  inputs.map(fp => _freshnessEntryMtimeMs(fp, 2)),
2971
- );
2972
- return Math.floor(mtimes.reduce((max, mtime) => Math.max(max, mtime), 0));
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
- // W-mpmwxni2000c25c7-a — accept both the legacy 'unknown-model' errorClass
5306
- // (still emitted by the dashboard preflight at _preflightModelCheck) and
5307
- // the new 'model-unavailable' code returned by copilot.parseError for
5308
- // invalid-model responses. Suppress in either case — the edit already
5309
- // landed, so the stale model error shouldn't surface to the user.
5310
- if (ccError.errorClass !== 'unknown-model' && ccError.errorClass !== 'model-unavailable') return false;
5311
- return String(ccError.runtime || '').toLowerCase() === 'copilot';
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 Promise.all(allFiles.map(async f => {
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
- 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 */ }
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 maxScan = shared.DASHBOARD_PORT_SCAN_MAX || 100;
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
- console.error(`\n No free port in [${REQUESTED_PORT}..${REQUESTED_PORT + maxScan - 1}]. Free one and retry.\n`);
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.