@yemi33/minions 0.1.2178 → 0.1.2180

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 (39) hide show
  1. package/README.md +7 -5
  2. package/bin/minions.js +39 -17
  3. package/dashboard/js/command-parser.js +1 -1
  4. package/dashboard/js/memory-panel.js +324 -0
  5. package/dashboard/js/qa.js +2 -2
  6. package/dashboard/js/refresh.js +19 -1
  7. package/dashboard/js/render-other.js +143 -2
  8. package/dashboard/js/render-prs.js +2 -1
  9. package/dashboard/js/render-schedules.js +1 -1
  10. package/dashboard/js/render-watches.js +1 -1
  11. package/dashboard/js/render-work-items.js +18 -1
  12. package/dashboard/js/settings.js +23 -0
  13. package/dashboard/pages/engine-memory-panel.html +56 -0
  14. package/dashboard/pages/engine.html +1 -0
  15. package/dashboard/pages/tools.html +8 -0
  16. package/dashboard/slim/js/link-pr.js +5 -5
  17. package/dashboard/slim/js/modals-tiles.js +44 -3
  18. package/dashboard/slim/js/projects.js +8 -6
  19. package/dashboard/slim/styles.css +20 -0
  20. package/dashboard-build.js +17 -2
  21. package/dashboard.js +693 -19
  22. package/docs/branch-derivation.md +13 -1
  23. package/docs/diagnostics-memory.md +446 -0
  24. package/docs/harness-propagation.md +273 -0
  25. package/docs/human-vs-automated.md +1 -1
  26. package/docs/runtime-adapters.md +5 -0
  27. package/engine/cli.js +24 -5
  28. package/engine/diagnostics-memory.js +190 -0
  29. package/engine/lifecycle.js +111 -1
  30. package/engine/preflight.js +265 -0
  31. package/engine/queries.js +331 -19
  32. package/engine/runtimes/claude.js +36 -0
  33. package/engine/runtimes/codex.js +19 -0
  34. package/engine/runtimes/copilot.js +27 -36
  35. package/engine/shared.js +390 -15
  36. package/engine/spawn-agent.js +178 -12
  37. package/engine/watchdog.js +6 -0
  38. package/engine.js +277 -4
  39. package/package.json +2 -2
package/README.md CHANGED
@@ -57,7 +57,7 @@ node ~/.minions/minions.js init
57
57
  | `minions remove <dir-or-name> [--keep-data \| --purge --force]` | Unlink a project: cancels pending work items, drains dispatch + kills active agents, cleans worktrees, disables linked schedules, archives `projects/<name>/` to `projects/.archived/<name>-YYYYMMDD/`. Use `--keep-data` to leave the data dir in place, or `--purge --force` to delete it. |
58
58
  | `minions list` | List all linked projects with descriptions |
59
59
  | `minions restart` | Start engine and dashboard together (recommended after reboot) |
60
- | `minions start` | Start engine daemon (ticks every 10s, auto-syncs MCP servers) |
60
+ | `minions start` | Start engine daemon (ticks every 10s) |
61
61
  | `minions stop` | Stop the engine |
62
62
  | `minions status` | Show agents, projects, dispatch queue, quality metrics |
63
63
  | `minions pause` / `resume` | Pause/resume dispatching |
@@ -71,7 +71,7 @@ node ~/.minions/minions.js init
71
71
  | `minions kill` | Kill all active agents and reset their dispatches to pending |
72
72
  | `minions complete <dispatch-id>` | Manually mark a dispatch as completed |
73
73
  | `minions config set-cli <R> [--model M]` | Persist the default runtime/model without starting the engine |
74
- | `minions mcp-sync` | Sync MCP servers from `~/.claude.json` |
74
+ | `minions mcp-sync` | Print harness propagation diagnostic (read-only; same source as `minions doctor --harness`) |
75
75
  | `minions cleanup` | Run cleanup manually (temp files, worktrees, zombies) |
76
76
  | `minions dash` | Open dashboard (starts if not already running, port 7331) |
77
77
  | `minions nuke --confirm` | Factory reset runtime state and reset config to defaults |
@@ -322,9 +322,11 @@ When dispatching agents, the engine reads each project's `CLAUDE.md` and injects
322
322
 
323
323
  Agents need repo-host tooling to create PRs, post review comments, check status, and handle review feedback. GitHub repos use `gh`. Azure DevOps repos should use the `az` CLI first and keep the Azure DevOps MCP server available only as a fallback when `az` is unavailable or does not support the required operation.
324
324
 
325
- Agents inherit MCP servers directly from `~/.claude.json` as Claude Code processes add fallback servers there and they're immediately available to all agents on next spawn.
325
+ Agents inherit MCP servers, skills, and slash-commands directly from each runtime's native config — Claude reads `~/.claude.json` + `~/.claude/{skills,commands}/`, Copilot reads `~/.copilot/` + `~/.agents/`, Codex reads `~/.codex/`. Add fallback servers, skills, or commands there and they're immediately available to all agents on next spawn. No sync step is required.
326
326
 
327
- Manually refresh with `minions mcp-sync`.
327
+ **What your agent sees.** On every dispatch, the engine merges three layers around the agent's worktree: (1) **user-scope** MCP servers, skills, and slash-commands from the runtime's home dir (e.g. `~/.claude/`, `~/.copilot/`); (2) **project-scope** equivalents from `<repo>/.claude/`, `<repo>/.copilot/`, `<repo>/.mcp.json` — propagated even when the worktree is on a different branch than your live checkout (gate: `engine.harnessPropagateProjectLocal`, default on); (3) **workspace MCPs** from the worktree's `.mcp.json`, pre-approved for Claude so the agent never has to prompt (gate: `engine.claudePreApproveWorkspaceMcps`, default on). Per-agent opt-out via `agent.hermeticHarness: true` (or fleet-wide `engine.hermeticHarness: true`) strips everything except `minionsDir`, giving you a known-empty harness for reproducible runs.
328
+
329
+ To audit which propagation surfaces (asset dirs, skill roots, slash-command dirs, MCP config files) each runtime sees on this host, run `minions mcp-sync` (or `minions doctor --harness` for the same diagnostic with a non-zero exit on registry errors). Both commands are read-only — they print the per-runtime survey without mutating any state. See [docs/harness-propagation.md](docs/harness-propagation.md) for the propagation contract.
328
330
 
329
331
  ### GitHub Users
330
332
 
@@ -346,7 +348,7 @@ az login
346
348
  az devops configure --defaults organization=https://dev.azure.com/YOUR_ORG project=YOUR_PROJECT
347
349
  ```
348
350
 
349
- Optionally add the [Azure DevOps MCP server](https://github.com/microsoft/azure-devops-mcp) to your Claude Code settings (`~/.claude.json`) as a fallback. The engine will auto-sync it to all agents on next start.
351
+ Optionally add the [Azure DevOps MCP server](https://github.com/microsoft/azure-devops-mcp) to your Claude Code settings (`~/.claude.json`) as a fallback. Agents inherit it on next spawn no sync step needed.
350
352
 
351
353
  ## Work Items
352
354
 
package/bin/minions.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * minions list List linked projects
11
11
  * minions update [--no-wait] Update to latest version (--no-wait backgrounds the post-update restart)
12
12
  * minions version Show installed and package versions
13
- * minions doctor Check prerequisites and runtime health
13
+ * minions doctor [--harness] Check prerequisites and runtime health (--harness: print harness propagation diagnostic)
14
14
  * minions restart [--open] Stop + start engine + dashboard (--open forces a new browser tab)
15
15
  * minions start [--open] Start engine + dashboard if not already running (no-op when up)
16
16
  * minions stop Stop the engine
@@ -29,7 +29,7 @@
29
29
  * minions config set-cli <R> [--model M] Persist default runtime/model
30
30
  * minions bridge <subcmd> Constellation bridge: status|health|enable|disable
31
31
  * minions plan <file|text> [proj] Run a plan
32
- * minions mcp-sync Sync MCP servers from ~/.claude.json
32
+ * minions mcp-sync Print harness propagation diagnostic (read-only; same source as `doctor --harness`)
33
33
  * minions nuke --confirm Factory reset runtime state/config
34
34
  * minions uninstall --confirm Remove Minions and uninstall package
35
35
  */
@@ -217,9 +217,9 @@ async function _waitForBrowserReconnect(minionsHome, { afterMs, timeoutMs = 5000
217
217
  return false;
218
218
  }
219
219
 
220
- function _openInBrowser(url) {
221
- const result = openUrlInBrowser(url);
222
- if (!result.ok) {
220
+ function _openInBrowser(url, reason, callerHint) {
221
+ const result = openUrlInBrowser(url, { reason, callerHint });
222
+ if (!result.ok && !result.suppressed) {
223
223
  console.log(` Could not auto-open browser: ${result.error}`);
224
224
  console.log(` Please open ${url} manually.`);
225
225
  }
@@ -400,11 +400,15 @@ function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs })
400
400
  }
401
401
  console.log(` Restart verified: engine PID ${result.engine.pid}; dashboard healthy.`);
402
402
 
403
- const shouldOpen = forceOpen || !dashWasUp ||
404
- !(await _waitForBrowserReconnect(MINIONS_HOME, { afterMs: restartStartMs, timeoutMs: 5000 }));
403
+ const shouldOpen = forceOpen || (
404
+ process.env.MINIONS_NO_AUTO_OPEN !== '1' && (
405
+ !dashWasUp ||
406
+ !(await _waitForBrowserReconnect(MINIONS_HOME, { afterMs: restartStartMs, timeoutMs: 5000 }))
407
+ )
408
+ );
405
409
  if (shouldOpen) {
406
410
  console.log(` Opening dashboard in browser...`);
407
- _openInBrowser(`http://localhost:${actualPort}`);
411
+ _openInBrowser(`http://localhost:${actualPort}`, 'cli-restart-no-beacon', 'bin/minions.js:407');
408
412
  }
409
413
  console.log('');
410
414
  })().catch(err => {
@@ -861,11 +865,20 @@ function init() {
861
865
 
862
866
  void (async () => {
863
867
  const actualPort = await _waitForDashboardPortFile(MINIONS_HOME, 8000) || upgradeRequested.port;
864
- const shouldOpen = forceOpen || !dashWasUp ||
865
- !(await _waitForBrowserReconnect(MINIONS_HOME, { afterMs: restartStartMs, timeoutMs: 5000 }));
868
+ // W-mqb9y83o same hard kill-switch as spawnFullStackAndVerify:
869
+ // MINIONS_NO_AUTO_OPEN=1 in the parent env (set by the watchdog spawn
870
+ // and /api/dashboard/restart spawn) suppresses auto-open regardless of
871
+ // the beacon heuristic. `forceOpen` (`--open` / MINIONS_FORCE_OPEN=1)
872
+ // still wins as the explicit operator override.
873
+ const shouldOpen = forceOpen || (
874
+ process.env.MINIONS_NO_AUTO_OPEN !== '1' && (
875
+ !dashWasUp ||
876
+ !(await _waitForBrowserReconnect(MINIONS_HOME, { afterMs: restartStartMs, timeoutMs: 5000 }))
877
+ )
878
+ );
866
879
  if (shouldOpen) {
867
880
  console.log(` Opening dashboard in browser...`);
868
- _openInBrowser(`http://localhost:${actualPort}`);
881
+ _openInBrowser(`http://localhost:${actualPort}`, 'cli-upgrade-no-beacon', 'bin/minions.js:868');
869
882
  }
870
883
  })().catch(err => {
871
884
  console.log(` Could not open dashboard: ${err.message}`);
@@ -1087,7 +1100,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1087
1100
  minions init Bootstrap ~/.minions/ (first time)
1088
1101
  minions update [--no-wait] Update to latest version (--no-wait backgrounds the restart)
1089
1102
  minions version Show installed vs package version
1090
- minions doctor Check prerequisites and runtime health
1103
+ minions doctor [--harness] Check prerequisites and runtime health (--harness: print harness propagation diagnostic)
1091
1104
  minions add <project-dir> Link a project (interactive)
1092
1105
  minions remove <project-dir> Unlink a project
1093
1106
  minions list List linked projects
@@ -1110,7 +1123,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1110
1123
  minions config set-cli <R> [--model M]
1111
1124
  Persist default runtime/model without starting
1112
1125
  minions bridge <subcmd> Constellation bridge: status|health|enable|disable
1113
- minions mcp-sync Sync MCP servers from ~/.claude.json
1126
+ minions mcp-sync Print harness propagation diagnostic (read-only; same source as 'doctor --harness')
1114
1127
  minions cleanup Clean temp files, worktrees, zombies
1115
1128
  minions pr comment <repo> <n> Post a marker-prepended PR comment via gh
1116
1129
  --agent <id> --kind <k> [--wi <id>] (--body-file <f> | --body <text>)
@@ -1209,7 +1222,7 @@ ${fs.existsSync(path.join(PKG_ROOT, '.git')) ? `
1209
1222
  console.log(`\n Minions is already running (engine PID ${enginePid}; dashboard http://localhost:${startResolved.port}).`);
1210
1223
  if (forceOpen) {
1211
1224
  console.log(` Opening dashboard in browser...`);
1212
- _openInBrowser(`http://localhost:${startResolved.port}`);
1225
+ _openInBrowser(`http://localhost:${startResolved.port}`, 'cli-start-force-open', 'bin/minions.js:1212');
1213
1226
  } else {
1214
1227
  console.log(` Run \`minions dash\` to open the dashboard, or \`minions start --open\` to force a new browser tab.\n`);
1215
1228
  }
@@ -1437,8 +1450,17 @@ ${fs.existsSync(path.join(PKG_ROOT, '.git')) ? `
1437
1450
  console.log(' npm install -g @yemi33/minions && minions init\n');
1438
1451
  } else if (cmd === 'doctor') {
1439
1452
  ensureInstalled();
1440
- const { doctor } = require(path.join(MINIONS_HOME, 'engine', 'preflight'));
1441
- doctor(MINIONS_HOME).then(ok => process.exit(ok ? 0 : 1));
1453
+ const { doctor, runHarnessDoctor } = require(path.join(MINIONS_HOME, 'engine', 'preflight'));
1454
+ if (rest.includes('--harness')) {
1455
+ // P-a3f9b2c1 — print the harness propagation diagnostic and exit. The
1456
+ // legacy `minions doctor` still runs the full preflight + runtime fleet
1457
+ // sweep; `--harness` is a one-shot survey of the asset-propagation
1458
+ // surfaces only (see docs/harness-propagation.md).
1459
+ const ok = runHarnessDoctor(MINIONS_HOME);
1460
+ process.exit(ok ? 0 : 1);
1461
+ } else {
1462
+ doctor(MINIONS_HOME).then(ok => process.exit(ok ? 0 : 1));
1463
+ }
1442
1464
  } else if (cmd === 'watchdog') {
1443
1465
  // External recovery scheduled by the OS. Survives cluster-kill scenarios
1444
1466
  // the in-process supervisor can't (Windows job-object teardown when the
@@ -1547,7 +1569,7 @@ ${fs.existsSync(path.join(PKG_ROOT, '.git')) ? `
1547
1569
  handled = true;
1548
1570
  const url = `http://localhost:${dashResolved.port}`;
1549
1571
  console.log(`\n Dashboard already running: ${url}\n`);
1550
- openUrlInBrowser(url);
1572
+ openUrlInBrowser(url, { reason: 'cli-dash-warm', callerHint: 'bin/minions.js:1559' });
1551
1573
  });
1552
1574
  sock.on('error', () => {
1553
1575
  sock.destroy();
@@ -8,7 +8,7 @@ function cmdUpdateAgentList(agents) {
8
8
  cmdAgents = (agents || []).map(a => ({ id: a.id, name: a.name, emoji: a.emoji, role: a.role }));
9
9
  }
10
10
  function cmdUpdateProjectList(projects) {
11
- cmdProjects = (projects || []).map(p => ({ name: p.name, description: p.description || '' }));
11
+ cmdProjects = (projects || []).map(p => ({ name: p.name, displayName: p.displayName || p.name, description: p.description || '' }));
12
12
  }
13
13
 
14
14
  function showToast(id, msg, ok, durationMs) {
@@ -0,0 +1,324 @@
1
+ // dashboard/js/memory-panel.js — Memory panel poller + inline SVG sparkline.
2
+ // P-d4e5f6a7 (memory + perf audit plan, dashboard surface).
3
+ //
4
+ // Lifecycle: mountMemoryPanel() registers two intervals — a 10 s poll against
5
+ // /api/diagnostics/memory for live values, and a 60 s poll against
6
+ // /api/diagnostics/memory/history?process={engine,dashboard} for the inline
7
+ // SVG sparkline (RSS + heapUsed over the last hour). unmountMemoryPanel()
8
+ // clears both intervals.
9
+ //
10
+ // Mount/unmount registration uses the canonical PAGE_LAZY_LOADERS +
11
+ // PAGE_LEAVE_HOOKS maps declared in state.js — engine page enter triggers
12
+ // mount, every page-leave call triggers unmount (idempotent). state.js
13
+ // evaluates earlier in the assembled bundle so the maps already exist when
14
+ // this file pushes its hooks; the initial switchPage(currentPage) call at
15
+ // the end of refresh.js (last file in the bundle) is what fires the first
16
+ // mount when a user direct-loads /engine.
17
+ //
18
+ // XSS safety: nothing here writes into innerHTML / adjacent-html sinks. Live
19
+ // values land via textContent; the sparkline is constructed via
20
+ // createElementNS so it stays clean against eslint-plugin-no-unsanitized.
21
+
22
+ const MEMORY_POLL_LIVE_MS = 10_000;
23
+ const MEMORY_POLL_HISTORY_MS = 60_000;
24
+ const MEMORY_SPARKLINE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
25
+
26
+ let _memoryPanelLiveInterval = null;
27
+ let _memoryPanelHistoryInterval = null;
28
+ let _memoryPanelMounted = false;
29
+
30
+ function _memFmtBytes(n) {
31
+ if (!Number.isFinite(n)) return '—';
32
+ const mb = n / (1024 * 1024);
33
+ if (mb < 1024) return mb.toFixed(1) + ' MB';
34
+ return (mb / 1024).toFixed(2) + ' GB';
35
+ }
36
+
37
+ function _memFmtMs(n) {
38
+ if (!Number.isFinite(n)) return '—';
39
+ if (n < 1) return n.toFixed(2) + ' ms';
40
+ if (n < 100) return n.toFixed(1) + ' ms';
41
+ return Math.round(n) + ' ms';
42
+ }
43
+
44
+ function _memFmtUptime(seconds) {
45
+ if (!Number.isFinite(seconds) || seconds < 0) return '—';
46
+ const s = Math.floor(seconds);
47
+ if (s < 60) return s + 's';
48
+ const m = Math.floor(s / 60);
49
+ if (m < 60) return m + 'm ' + (s % 60) + 's';
50
+ const h = Math.floor(m / 60);
51
+ if (h < 24) return h + 'h ' + (m % 60) + 'm';
52
+ const d = Math.floor(h / 24);
53
+ return d + 'd ' + (h % 24) + 'h';
54
+ }
55
+
56
+ function _memSetText(id, val) {
57
+ const el = document.getElementById(id);
58
+ if (el) el.textContent = val;
59
+ }
60
+
61
+ function _memRenderCard(prefix, sample, stale) {
62
+ if (!sample) {
63
+ const fallback = stale ? 'stale' : '—';
64
+ _memSetText('memory-' + prefix + '-rss', fallback);
65
+ _memSetText('memory-' + prefix + '-heap', '—');
66
+ _memSetText('memory-' + prefix + '-external', '—');
67
+ _memSetText('memory-' + prefix + '-lag', '—');
68
+ _memSetText('memory-' + prefix + '-gc', '—');
69
+ _memSetText('memory-' + prefix + '-uptime', '—');
70
+ return;
71
+ }
72
+ _memSetText('memory-' + prefix + '-rss', _memFmtBytes(sample.rss));
73
+ _memSetText('memory-' + prefix + '-heap', _memFmtBytes(sample.heapUsed) + ' / ' + _memFmtBytes(sample.heapTotal));
74
+ _memSetText('memory-' + prefix + '-external', _memFmtBytes(sample.external));
75
+ _memSetText('memory-' + prefix + '-lag', _memFmtMs(sample.eventLoopLagP50) + ' / ' + _memFmtMs(sample.eventLoopLagP99));
76
+ const gcVal = (Number.isFinite(sample.lastGcPauseMs) && sample.lastGcPauseMs > 0)
77
+ ? _memFmtMs(sample.lastGcPauseMs) + (sample.lastGcKind ? ' (' + sample.lastGcKind + ')' : '')
78
+ : '—';
79
+ _memSetText('memory-' + prefix + '-gc', gcVal);
80
+ _memSetText('memory-' + prefix + '-uptime', _memFmtUptime(sample.uptime));
81
+ }
82
+
83
+ function _memSetStaleBadge(id, stale) {
84
+ const el = document.getElementById(id);
85
+ if (!el) return;
86
+ el.style.display = stale ? '' : 'none';
87
+ }
88
+
89
+ // Pure builder: returns an <svg> element rendering two polylines (RSS +
90
+ // heapUsed) over the last MEMORY_SPARKLINE_WINDOW_MS of samples, or a
91
+ // "no samples" label when the buffer is empty. Pure DOM via createElementNS
92
+ // keeps eslint-plugin-no-unsanitized happy.
93
+ function _memBuildSparkline(samples, opts) {
94
+ const svgNS = 'http://www.w3.org/2000/svg';
95
+ const width = (opts && opts.width) || 320;
96
+ const height = (opts && opts.height) || 60;
97
+ const pad = 2;
98
+
99
+ const svg = document.createElementNS(svgNS, 'svg');
100
+ svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
101
+ svg.setAttribute('preserveAspectRatio', 'none');
102
+ svg.setAttribute('width', '100%');
103
+ svg.setAttribute('height', String(height));
104
+ svg.style.display = 'block';
105
+
106
+ if (!Array.isArray(samples) || samples.length === 0) {
107
+ const label = document.createElementNS(svgNS, 'text');
108
+ label.setAttribute('x', String(width / 2));
109
+ label.setAttribute('y', String(height / 2));
110
+ label.setAttribute('text-anchor', 'middle');
111
+ label.setAttribute('dominant-baseline', 'middle');
112
+ label.setAttribute('fill', 'currentColor');
113
+ label.setAttribute('opacity', '0.5');
114
+ label.setAttribute('font-size', '11');
115
+ label.textContent = 'no samples yet';
116
+ svg.appendChild(label);
117
+ return svg;
118
+ }
119
+
120
+ // Clip to the last hour, falling back to the full ring when nothing recent.
121
+ const now = Date.now();
122
+ const tMin = now - MEMORY_SPARKLINE_WINDOW_MS;
123
+ const windowed = samples.filter(function (s) {
124
+ return s && Number.isFinite(s.capturedAt) && s.capturedAt >= tMin;
125
+ });
126
+ const useSamples = windowed.length ? windowed : samples.filter(function (s) {
127
+ return s && Number.isFinite(s.capturedAt);
128
+ });
129
+ if (!useSamples.length) return svg;
130
+
131
+ const t0 = useSamples[0].capturedAt;
132
+ const t1 = useSamples[useSamples.length - 1].capturedAt;
133
+ const tSpan = Math.max(1, t1 - t0);
134
+
135
+ let yMin = Infinity;
136
+ let yMax = -Infinity;
137
+ for (const s of useSamples) {
138
+ for (const v of [s.rss, s.heapUsed]) {
139
+ if (Number.isFinite(v)) {
140
+ if (v < yMin) yMin = v;
141
+ if (v > yMax) yMax = v;
142
+ }
143
+ }
144
+ }
145
+ if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) { yMin = 0; yMax = 1; }
146
+ const ySpan = Math.max(1, yMax - yMin);
147
+
148
+ function _series(field) {
149
+ const pts = [];
150
+ for (const s of useSamples) {
151
+ const v = s[field];
152
+ if (!Number.isFinite(v)) continue;
153
+ const x = pad + ((s.capturedAt - t0) / tSpan) * (width - 2 * pad);
154
+ const y = height - pad - ((v - yMin) / ySpan) * (height - 2 * pad);
155
+ pts.push(x.toFixed(1) + ',' + y.toFixed(1));
156
+ }
157
+ return pts.join(' ');
158
+ }
159
+
160
+ function _addLine(field, color) {
161
+ const pts = _series(field);
162
+ if (!pts) return;
163
+ const line = document.createElementNS(svgNS, 'polyline');
164
+ line.setAttribute('points', pts);
165
+ line.setAttribute('fill', 'none');
166
+ line.setAttribute('stroke', color);
167
+ line.setAttribute('stroke-width', '1.5');
168
+ line.setAttribute('vector-effect', 'non-scaling-stroke');
169
+ svg.appendChild(line);
170
+ }
171
+
172
+ _addLine('rss', 'var(--blue, #4ea1ff)');
173
+ _addLine('heapUsed', 'var(--green, #4caf50)');
174
+ return svg;
175
+ }
176
+
177
+ function _memRenderSparkline(containerId, samples) {
178
+ const el = document.getElementById(containerId);
179
+ if (!el) return;
180
+ const width = el.clientWidth || 320;
181
+ const svg = _memBuildSparkline(samples, { width, height: 60 });
182
+ el.replaceChildren(svg);
183
+ }
184
+
185
+ async function _memRefreshLive() {
186
+ let data;
187
+ try {
188
+ const res = await fetch('/api/diagnostics/memory');
189
+ if (!res.ok) return;
190
+ data = await res.json();
191
+ } catch {
192
+ return; // network blip — leave previous values in place
193
+ }
194
+ if (!data || typeof data !== 'object') return;
195
+ _memRenderCard('engine', data.engine, !!data.engineStale);
196
+ _memRenderCard('dashboard', data.dashboard, false);
197
+ _memSetStaleBadge('memory-engine-stale-badge', !!data.engineStale);
198
+ }
199
+
200
+ async function _memRefreshHistory() {
201
+ await Promise.all(['engine', 'dashboard'].map(async function (proc) {
202
+ try {
203
+ const res = await fetch('/api/diagnostics/memory/history?process=' + encodeURIComponent(proc));
204
+ if (!res.ok) return;
205
+ const data = await res.json();
206
+ const samples = (data && Array.isArray(data.samples)) ? data.samples : [];
207
+ _memRenderSparkline('memory-' + proc + '-sparkline', samples);
208
+ } catch {
209
+ // network blip — keep the previous sparkline
210
+ }
211
+ }));
212
+ }
213
+
214
+ // P-e5f6a7b8 — operator-driven heap snapshot capture button. Gates the
215
+ // POST behind a window.confirm() that calls out the multi-second stall
216
+ // and the 50–200 MB per-process disk write. Renders the resulting paths
217
+ // (and any engine timeout) into #memory-heap-snapshot-result via
218
+ // textContent so the XSS gate remains clean.
219
+ const HEAP_SNAPSHOT_CONFIRM_TOKEN = 'YES_I_UNDERSTAND_THIS_STALLS_THE_ENGINE';
220
+ const HEAP_SNAPSHOT_CONFIRM_MSG =
221
+ 'Capture a heap snapshot from BOTH the engine and dashboard processes?\n\n' +
222
+ '⚠️ Each process will be stalled for several seconds and will write a 50–200 MB ' +
223
+ '.heapsnapshot file under engine/diagnostics/. Only do this when actively ' +
224
+ 'debugging a leak. Rate-limited to 1 capture per 60 seconds.';
225
+
226
+ function _memSetHeapResult(text) {
227
+ const el = document.getElementById('memory-heap-snapshot-result');
228
+ if (el) el.textContent = text;
229
+ }
230
+
231
+ async function _memCaptureHeapSnapshot() {
232
+ const btn = document.getElementById('memory-heap-snapshot-btn');
233
+ if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
234
+ if (!window.confirm(HEAP_SNAPSHOT_CONFIRM_MSG)) return;
235
+ }
236
+ if (btn) { btn.disabled = true; }
237
+ _memSetHeapResult('Capturing… (engine + dashboard each stall for several seconds)');
238
+ try {
239
+ const res = await fetch(
240
+ '/api/diagnostics/heap-snapshot?confirm=' + encodeURIComponent(HEAP_SNAPSHOT_CONFIRM_TOKEN),
241
+ { method: 'POST' });
242
+ let data = null;
243
+ try { data = await res.json(); } catch { /* empty body */ }
244
+ if (!res.ok) {
245
+ const msg = (data && (data.error || data.hint)) || ('HTTP ' + res.status);
246
+ _memSetHeapResult('Capture failed: ' + msg);
247
+ return;
248
+ }
249
+ const lines = [];
250
+ lines.push('Dashboard snapshot: ' + (data && data.dashboardSnapshot ? data.dashboardSnapshot : '(none)'));
251
+ if (data && data.engineTimedOut) {
252
+ lines.push('Engine snapshot: TIMED OUT after ' + ((data.timeoutMs || 30000) / 1000) + 's ' +
253
+ '— the engine may have captured it after the dashboard gave up; check engine/diagnostics/.');
254
+ } else {
255
+ lines.push('Engine snapshot: ' + (data && data.engineSnapshot ? data.engineSnapshot : '(none)'));
256
+ }
257
+ lines.push('');
258
+ lines.push('Load these in Chrome DevTools → Memory → Load to inspect.');
259
+ _memSetHeapResult(lines.join('\n'));
260
+ } catch (e) {
261
+ _memSetHeapResult('Capture failed: ' + (e && e.message ? e.message : String(e)));
262
+ } finally {
263
+ if (btn) { btn.disabled = false; }
264
+ }
265
+ }
266
+
267
+ function _memWireHeapSnapshotButton() {
268
+ const btn = document.getElementById('memory-heap-snapshot-btn');
269
+ if (!btn || btn.dataset.heapSnapshotWired === '1') return;
270
+ btn.dataset.heapSnapshotWired = '1';
271
+ btn.addEventListener('click', _memCaptureHeapSnapshot);
272
+ }
273
+
274
+ function mountMemoryPanel() {
275
+ if (_memoryPanelMounted) return;
276
+ // No-op when the engine page hasn't been assembled yet (defensive — the
277
+ // fragment is always part of the bundle, but this keeps the hook safe
278
+ // against partial DOM states during test harnesses).
279
+ if (!document.getElementById('memory-panel-content')) return;
280
+ _memoryPanelMounted = true;
281
+ _memRefreshLive();
282
+ _memRefreshHistory();
283
+ _memoryPanelLiveInterval = setInterval(_memRefreshLive, MEMORY_POLL_LIVE_MS);
284
+ _memoryPanelHistoryInterval = setInterval(_memRefreshHistory, MEMORY_POLL_HISTORY_MS);
285
+ _memWireHeapSnapshotButton();
286
+ }
287
+
288
+ function unmountMemoryPanel() {
289
+ if (!_memoryPanelMounted) return;
290
+ _memoryPanelMounted = false;
291
+ if (_memoryPanelLiveInterval) {
292
+ clearInterval(_memoryPanelLiveInterval);
293
+ _memoryPanelLiveInterval = null;
294
+ }
295
+ if (_memoryPanelHistoryInterval) {
296
+ clearInterval(_memoryPanelHistoryInterval);
297
+ _memoryPanelHistoryInterval = null;
298
+ }
299
+ }
300
+
301
+ // Register the lifecycle hooks against the canonical maps in state.js.
302
+ // `const` declarations don't block mutating the underlying object/array,
303
+ // so we can splice in our entries without editing state.js. Best-effort:
304
+ // any failure leaves the panel unmounted but does not break navigation.
305
+ try {
306
+ if (typeof PAGE_LAZY_LOADERS === 'object' && PAGE_LAZY_LOADERS) {
307
+ if (!Array.isArray(PAGE_LAZY_LOADERS.engine)) PAGE_LAZY_LOADERS.engine = [];
308
+ if (PAGE_LAZY_LOADERS.engine.indexOf('mountMemoryPanel') < 0) {
309
+ PAGE_LAZY_LOADERS.engine.push('mountMemoryPanel');
310
+ }
311
+ }
312
+ if (typeof PAGE_LEAVE_HOOKS !== 'undefined' && Array.isArray(PAGE_LEAVE_HOOKS)) {
313
+ if (PAGE_LEAVE_HOOKS.indexOf('unmountMemoryPanel') < 0) {
314
+ PAGE_LEAVE_HOOKS.push('unmountMemoryPanel');
315
+ }
316
+ }
317
+ } catch { /* registration is best-effort */ }
318
+
319
+ window.MinionsMemoryPanel = {
320
+ mountMemoryPanel,
321
+ unmountMemoryPanel,
322
+ _buildSparkline: _memBuildSparkline,
323
+ _captureHeapSnapshot: _memCaptureHeapSnapshot,
324
+ };
@@ -620,7 +620,7 @@ async function loadQaProjectsSelect() {
620
620
  const res = await fetch('/api/status');
621
621
  const json = res.ok ? await res.json() : {};
622
622
  if (Array.isArray(json && json.projects)) {
623
- projects = json.projects.map(p => ({ name: p.name, description: p.description || '' }));
623
+ projects = json.projects.map(p => ({ name: p.name, displayName: p.displayName || p.name, description: p.description || '' }));
624
624
  }
625
625
  }
626
626
  } catch { projects = []; }
@@ -631,7 +631,7 @@ async function loadQaProjectsSelect() {
631
631
  if (!p || !p.name) continue;
632
632
  const opt = document.createElement('option');
633
633
  opt.value = p.name;
634
- opt.textContent = p.name;
634
+ opt.textContent = p.displayName || p.name;
635
635
  if (previouslySelected.has(p.name)) opt.selected = true;
636
636
  sel.appendChild(opt);
637
637
  }
@@ -134,7 +134,7 @@ const RENDER_VERSIONS = {
134
134
  prdProgress: 1,
135
135
  prdPrs: 1,
136
136
  inbox: 2,
137
- projects: 1,
137
+ projects: 2,
138
138
  notes: 1,
139
139
  prd: 1,
140
140
  prs: 3,
@@ -149,7 +149,9 @@ const RENDER_VERSIONS = {
149
149
  metrics: 1,
150
150
  workItems: 3,
151
151
  skills: 1,
152
+ commands: 1,
152
153
  mcpServers: 1,
154
+ harnessDiag: 1,
153
155
  schedules: 1,
154
156
  watches: 2,
155
157
  meetings: 1,
@@ -854,8 +856,24 @@ function _processStatusUpdate(data, opts) {
854
856
  });
855
857
  _changed('skills', data.skills);
856
858
  _safeRender('skills', function() { renderSkills(data.skills || []); });
859
+ _changed('commands', data.commands);
860
+ _safeRender('commands', function() { renderCommands(data.commands || []); });
857
861
  _changed('mcpServers', data.mcpServers);
858
862
  _safeRender('mcpServers', function() { renderMcpServers(data.mcpServers || []); });
863
+ // Harness propagation diagnostic comes from /api/harness/diagnostics
864
+ // (rare-change, fetched on each /api/status refresh so the tools page is
865
+ // always up to date when an operator drops a project-local skill).
866
+ _safeRender('harnessDiag', function() {
867
+ fetch('/api/harness/diagnostics')
868
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
869
+ .then(function (fresh) {
870
+ window._lastHarnessDiag = fresh;
871
+ _safeRender('harnessDiag', function() { renderHarnessDiagnostics(fresh); });
872
+ })
873
+ .catch(function () {
874
+ if (window._lastHarnessDiag) _safeRender('harnessDiag', function() { renderHarnessDiagnostics(window._lastHarnessDiag); });
875
+ });
876
+ });
859
877
  // Schedule definitions stay on /api/status (config-derived), but the
860
878
  // _lastRun/_lastResult overlay is re-applied client-side from
861
879
  // /state/engine/schedule-runs.json so a freshly-fired schedule lights up