@yemi33/minions 0.1.2178 → 0.1.2179

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
@@ -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}`);
@@ -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
  }
@@ -1547,7 +1560,7 @@ ${fs.existsSync(path.join(PKG_ROOT, '.git')) ? `
1547
1560
  handled = true;
1548
1561
  const url = `http://localhost:${dashResolved.port}`;
1549
1562
  console.log(`\n Dashboard already running: ${url}\n`);
1550
- openUrlInBrowser(url);
1563
+ openUrlInBrowser(url, { reason: 'cli-dash-warm', callerHint: 'bin/minions.js:1559' });
1551
1564
  });
1552
1565
  sock.on('error', () => {
1553
1566
  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,262 @@
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
+ function mountMemoryPanel() {
215
+ if (_memoryPanelMounted) return;
216
+ // No-op when the engine page hasn't been assembled yet (defensive — the
217
+ // fragment is always part of the bundle, but this keeps the hook safe
218
+ // against partial DOM states during test harnesses).
219
+ if (!document.getElementById('memory-panel-content')) return;
220
+ _memoryPanelMounted = true;
221
+ _memRefreshLive();
222
+ _memRefreshHistory();
223
+ _memoryPanelLiveInterval = setInterval(_memRefreshLive, MEMORY_POLL_LIVE_MS);
224
+ _memoryPanelHistoryInterval = setInterval(_memRefreshHistory, MEMORY_POLL_HISTORY_MS);
225
+ }
226
+
227
+ function unmountMemoryPanel() {
228
+ if (!_memoryPanelMounted) return;
229
+ _memoryPanelMounted = false;
230
+ if (_memoryPanelLiveInterval) {
231
+ clearInterval(_memoryPanelLiveInterval);
232
+ _memoryPanelLiveInterval = null;
233
+ }
234
+ if (_memoryPanelHistoryInterval) {
235
+ clearInterval(_memoryPanelHistoryInterval);
236
+ _memoryPanelHistoryInterval = null;
237
+ }
238
+ }
239
+
240
+ // Register the lifecycle hooks against the canonical maps in state.js.
241
+ // `const` declarations don't block mutating the underlying object/array,
242
+ // so we can splice in our entries without editing state.js. Best-effort:
243
+ // any failure leaves the panel unmounted but does not break navigation.
244
+ try {
245
+ if (typeof PAGE_LAZY_LOADERS === 'object' && PAGE_LAZY_LOADERS) {
246
+ if (!Array.isArray(PAGE_LAZY_LOADERS.engine)) PAGE_LAZY_LOADERS.engine = [];
247
+ if (PAGE_LAZY_LOADERS.engine.indexOf('mountMemoryPanel') < 0) {
248
+ PAGE_LAZY_LOADERS.engine.push('mountMemoryPanel');
249
+ }
250
+ }
251
+ if (typeof PAGE_LEAVE_HOOKS !== 'undefined' && Array.isArray(PAGE_LEAVE_HOOKS)) {
252
+ if (PAGE_LEAVE_HOOKS.indexOf('unmountMemoryPanel') < 0) {
253
+ PAGE_LEAVE_HOOKS.push('unmountMemoryPanel');
254
+ }
255
+ }
256
+ } catch { /* registration is best-effort */ }
257
+
258
+ window.MinionsMemoryPanel = {
259
+ mountMemoryPanel,
260
+ unmountMemoryPanel,
261
+ _buildSparkline: _memBuildSparkline,
262
+ };
@@ -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,
@@ -12,7 +12,7 @@ function renderProjects(projects) {
12
12
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: project name, path, branch metadata)
13
13
  list.innerHTML = visible.map(p =>
14
14
  '<span data-project="' + escHtml(p.name) + '" title="' + escHtml(p.path || '') + '" style="display:inline-flex;align-items:center;gap:6px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:3px 10px;color:var(--blue);font-weight:500;cursor:help">' +
15
- escHtml(p.name) +
15
+ escHtml(p.displayName || p.name) +
16
16
  _renderProjectBranch(p) +
17
17
  '<span onclick="event.stopPropagation();projectChipRemove(\'' + escHtml(p.name) + '\')" title="Remove project (cancels pending work, archives data dir)" style="color:var(--muted);font-weight:600;cursor:pointer;padding:0 2px;line-height:1" onmouseover="this.style.color=\'var(--red)\'" onmouseout="this.style.color=\'var(--muted)\'">&times;</span>' +
18
18
  '</span>'
@@ -320,7 +320,8 @@ function openAddPrModal() {
320
320
  const projects = (typeof cmdProjects !== 'undefined' ? cmdProjects : []) || [];
321
321
  const projOpts = projects.map(p => {
322
322
  const name = typeof p === 'object' ? p.name : p;
323
- return '<option value="' + escapeHtml(name) + '">' + escapeHtml(name) + '</option>';
323
+ const label = (typeof p === 'object' && (p.displayName || p.name)) || name;
324
+ return '<option value="' + escapeHtml(name) + '">' + escapeHtml(label) + '</option>';
324
325
  }).join('');
325
326
  const inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
326
327
 
@@ -422,7 +422,7 @@ function _scheduleFormHtml(sched, isEdit) {
422
422
  const priorities = ['high', 'medium', 'low'];
423
423
  const typeOpts = types.map(t => '<option value="' + t + '"' + ((sched.type || 'implement') === t ? ' selected' : '') + '>' + t + '</option>').join('');
424
424
  const priOpts = priorities.map(p => '<option value="' + p + '"' + ((sched.priority || 'medium') === p ? ' selected' : '') + '>' + p + '</option>').join('');
425
- const projOpts = '<option value="">Any</option>' + (cmdProjects || []).map(p => '<option value="' + escHtml(p.name) + '"' + (sched.project === p.name ? ' selected' : '') + '>' + escHtml(p.name) + '</option>').join('');
425
+ const projOpts = '<option value="">Any</option>' + (cmdProjects || []).map(p => '<option value="' + escHtml(p.name) + '"' + (sched.project === p.name ? ' selected' : '') + '>' + escHtml(p.displayName || p.name) + '</option>').join('');
426
426
  const agentOpts = '<option value="">Auto</option>' + (cmdAgents || []).map(a => '<option value="' + escHtml(a.id) + '"' + (sched.agent === a.id ? ' selected' : '') + '>' + escHtml(a.name) + '</option>').join('');
427
427
 
428
428
  const inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
@@ -486,7 +486,7 @@ function _watchFormHtml() {
486
486
  }).join('');
487
487
 
488
488
  var agentOpts = '<option value="">human</option>' + (cmdAgents || []).map(function(a) { return '<option value="' + escHtml(a.id) + '">' + escHtml(a.name) + '</option>'; }).join('');
489
- var projOpts = '<option value="">Any</option>' + (cmdProjects || []).map(function(p) { return '<option value="' + escHtml(p.name) + '">' + escHtml(p.name) + '</option>'; }).join('');
489
+ var projOpts = '<option value="">Any</option>' + (cmdProjects || []).map(function(p) { return '<option value="' + escHtml(p.name) + '">' + escHtml(p.displayName || p.name) + '</option>'; }).join('');
490
490
 
491
491
  return '<div style="display:flex;flex-direction:column;gap:12px;font-family:inherit">' +
492
492
  '<div id="watch-form-error" style="display:none;color:var(--red);font-size:var(--text-md);padding:6px 10px;background:rgba(255,50,50,0.1);border-radius:var(--radius-sm)"></div>' +
@@ -0,0 +1,49 @@
1
+ <!--
2
+ dashboard/pages/engine-memory-panel.html — Memory panel fragment (P-d4e5f6a7).
3
+
4
+ Side-by-side cards for the engine and dashboard processes. Live values are
5
+ refreshed every 10 s by dashboard/js/memory-panel.js (GET /api/diagnostics/memory);
6
+ the inline SVG sparkline is refreshed every 60 s
7
+ (GET /api/diagnostics/memory/history?process=...). The yellow
8
+ "engine sample stale" badge is unhidden by the JS module when
9
+ engineStale === true.
10
+
11
+ dashboard-build.js substitutes this fragment into dashboard/pages/engine.html
12
+ at assembly time (see pageSubFragments).
13
+ -->
14
+ <section id="memory-panel-section">
15
+ <h2>Memory <span style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">RSS / heap / event-loop / GC for engine + dashboard processes</span></h2>
16
+ <div id="memory-panel-content" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px">
17
+ <div id="memory-card-engine" class="memory-card" style="border:1px solid var(--border);border-radius:4px;padding:10px;background:var(--surface2)">
18
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
19
+ <div style="font-weight:600;font-size:var(--text-md);font-family:monospace">engine</div>
20
+ <span id="memory-engine-stale-badge" style="display:none;background:var(--yellow);color:#000;padding:2px 8px;border-radius:3px;font-size:var(--text-sm);font-weight:600">engine sample stale</span>
21
+ </div>
22
+ <dl style="display:grid;grid-template-columns:max-content 1fr;gap:3px 12px;margin:0 0 8px 0;font-size:var(--text-base)">
23
+ <dt style="color:var(--muted);margin:0">RSS</dt><dd id="memory-engine-rss" style="margin:0;font-family:monospace">—</dd>
24
+ <dt style="color:var(--muted);margin:0">Heap used / total</dt><dd id="memory-engine-heap" style="margin:0;font-family:monospace">—</dd>
25
+ <dt style="color:var(--muted);margin:0">External</dt><dd id="memory-engine-external" style="margin:0;font-family:monospace">—</dd>
26
+ <dt style="color:var(--muted);margin:0">Event-loop lag p50 / p99</dt><dd id="memory-engine-lag" style="margin:0;font-family:monospace">—</dd>
27
+ <dt style="color:var(--muted);margin:0">Last GC pause</dt><dd id="memory-engine-gc" style="margin:0;font-family:monospace">—</dd>
28
+ <dt style="color:var(--muted);margin:0">Uptime</dt><dd id="memory-engine-uptime" style="margin:0;font-family:monospace">—</dd>
29
+ </dl>
30
+ <div style="font-size:var(--text-sm);color:var(--muted);margin-bottom:2px"><span style="color:var(--blue,#4ea1ff)">●</span> RSS &nbsp; <span style="color:var(--green,#4caf50)">●</span> heapUsed &nbsp;<span style="opacity:0.7">(last hour)</span></div>
31
+ <div id="memory-engine-sparkline" style="height:60px;width:100%;color:var(--fg)"></div>
32
+ </div>
33
+ <div id="memory-card-dashboard" class="memory-card" style="border:1px solid var(--border);border-radius:4px;padding:10px;background:var(--surface2)">
34
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
35
+ <div style="font-weight:600;font-size:var(--text-md);font-family:monospace">dashboard</div>
36
+ </div>
37
+ <dl style="display:grid;grid-template-columns:max-content 1fr;gap:3px 12px;margin:0 0 8px 0;font-size:var(--text-base)">
38
+ <dt style="color:var(--muted);margin:0">RSS</dt><dd id="memory-dashboard-rss" style="margin:0;font-family:monospace">—</dd>
39
+ <dt style="color:var(--muted);margin:0">Heap used / total</dt><dd id="memory-dashboard-heap" style="margin:0;font-family:monospace">—</dd>
40
+ <dt style="color:var(--muted);margin:0">External</dt><dd id="memory-dashboard-external" style="margin:0;font-family:monospace">—</dd>
41
+ <dt style="color:var(--muted);margin:0">Event-loop lag p50 / p99</dt><dd id="memory-dashboard-lag" style="margin:0;font-family:monospace">—</dd>
42
+ <dt style="color:var(--muted);margin:0">Last GC pause</dt><dd id="memory-dashboard-gc" style="margin:0;font-family:monospace">—</dd>
43
+ <dt style="color:var(--muted);margin:0">Uptime</dt><dd id="memory-dashboard-uptime" style="margin:0;font-family:monospace">—</dd>
44
+ </dl>
45
+ <div style="font-size:var(--text-sm);color:var(--muted);margin-bottom:2px"><span style="color:var(--blue,#4ea1ff)">●</span> RSS &nbsp; <span style="color:var(--green,#4caf50)">●</span> heapUsed &nbsp;<span style="opacity:0.7">(last hour)</span></div>
46
+ <div id="memory-dashboard-sparkline" style="height:60px;width:100%;color:var(--fg)"></div>
47
+ </div>
48
+ </div>
49
+ </section>
@@ -3,6 +3,7 @@
3
3
  <section>
4
4
  <div id="engine-quick-stats" style="display:flex;gap:16px;margin-bottom:12px;font-size:var(--text-base);color:var(--muted)"></div>
5
5
  </section>
6
+ <!-- __ENGINE_MEMORY_PANEL__ -->
6
7
  <section>
7
8
  <h2>Engine Log <span style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">tick-by-tick audit trail of engine operations</span></h2>
8
9
  <div class="log-list" id="engine-log">No log entries yet.</div>
@@ -6,18 +6,18 @@
6
6
  var modal = document.getElementById('slim-linkpr-modal');
7
7
  if (!modal) return;
8
8
  var projects = (lastStatusData && Array.isArray(lastStatusData.projects) ? lastStatusData.projects : [])
9
- .map(function(p) { return p && p.name ? String(p.name) : null; })
10
- .filter(Boolean);
9
+ .filter(function(p) { return p && p.name; })
10
+ .map(function(p) { return { name: String(p.name), label: String(p.displayName || p.name) }; });
11
11
  var sel = document.getElementById('slim-linkpr-project');
12
12
  sel.textContent = '';
13
13
  var auto = document.createElement('option');
14
14
  auto.value = '';
15
15
  auto.textContent = 'Auto-detect from URL (central if no unique match)';
16
16
  sel.appendChild(auto);
17
- projects.forEach(function(name) {
17
+ projects.forEach(function(proj) {
18
18
  var o = document.createElement('option');
19
- o.value = name;
20
- o.textContent = name;
19
+ o.value = proj.name;
20
+ o.textContent = proj.label;
21
21
  sel.appendChild(o);
22
22
  });
23
23
  var warn = document.getElementById('slim-linkpr-noproj');
@@ -159,7 +159,7 @@
159
159
  }
160
160
  var prs = Array.isArray(data.pullRequests) ? data.pullRequests : [];
161
161
  if (!prs.length) { tileEmpty(body, 'No tracked pull requests.'); return; }
162
- prs.forEach(function(p) {
162
+ function prCard(p) {
163
163
  var num = p.prNumber || p.number || p.id;
164
164
  var build = p.buildStatus || '';
165
165
  var review = p.reviewStatus || '';
@@ -168,7 +168,7 @@
168
168
  : ((p.status === 'active' || p.status === 'linked') ? 'blue' : ''));
169
169
  var statusText = document.createElement('span');
170
170
  statusText.textContent = p.status || 'unknown';
171
- body.appendChild(tileCard({
171
+ return tileCard({
172
172
  title: (num ? '#' + num + ' ' : '') + (p.title || '(untitled PR)'),
173
173
  // Project slug first (under title), then build/review row below — the
174
174
  // project is the strongest disambiguator across PRs and benefits from
@@ -178,7 +178,48 @@
178
178
  metaNodes: [statusText, statusSpan('build', build, BUILD_STATUS), statusSpan('review', review, REVIEW_STATUS)],
179
179
  chip: { text: p.status || '—', cls: cls },
180
180
  href: p.url || null,
181
- }));
181
+ });
182
+ }
183
+ // Partition once into three fixed buckets (status normalized to lowercase),
184
+ // preserving each PR's relative order within its bucket. Mirrors shared
185
+ // PR_STATUS (active/merged/abandoned/closed/linked); anything that does not
186
+ // match the merged/abandoned buckets defaults to Active so a PR with an
187
+ // unexpected status never silently disappears.
188
+ var buckets = { active: [], merged: [], abandoned: [] };
189
+ prs.forEach(function(p) {
190
+ var s = String(p.status || '').toLowerCase();
191
+ if (s === 'abandoned') buckets.abandoned.push(p);
192
+ else if (s === 'merged' || s === 'completed' || s === 'closed') buckets.merged.push(p);
193
+ else buckets.active.push(p);
194
+ });
195
+ // Fixed section order: Active (open) → Merged / Completed → Abandoned.
196
+ // Empty sections are omitted entirely.
197
+ var sections = [
198
+ { label: 'Active', prs: buckets.active, open: true },
199
+ { label: 'Merged / Completed', prs: buckets.merged, open: false },
200
+ { label: 'Abandoned', prs: buckets.abandoned, open: false },
201
+ ];
202
+ sections.forEach(function(section) {
203
+ if (!section.prs.length) return;
204
+ var details = document.createElement('details');
205
+ details.className = 'tile-section';
206
+ if (section.open) details.open = true;
207
+ var summary = document.createElement('summary');
208
+ summary.className = 'tile-section-summary';
209
+ var label = document.createElement('span');
210
+ label.className = 'tile-section-label';
211
+ label.textContent = section.label;
212
+ var count = document.createElement('span');
213
+ count.className = 'tile-section-count';
214
+ count.textContent = '(' + section.prs.length + ')';
215
+ summary.appendChild(label);
216
+ summary.appendChild(count);
217
+ details.appendChild(summary);
218
+ var sectionBody = document.createElement('div');
219
+ sectionBody.className = 'tile-section-body';
220
+ section.prs.forEach(function(p) { sectionBody.appendChild(prCard(p)); });
221
+ details.appendChild(sectionBody);
222
+ body.appendChild(details);
182
223
  });
183
224
  }
184
225
 
@@ -31,7 +31,7 @@
31
31
  // chosen, so the indicator never auto-defaults and the user can always clear
32
32
  // their choice back to it. Returns the select so the caller can attach a
33
33
  // change listener.
34
- function makeContextSelect(projects, selected) {
34
+ function makeContextSelect(projects, selected, displayByName) {
35
35
  var sel = document.createElement('select');
36
36
  sel.id = 'chat-context-select';
37
37
  sel.setAttribute('aria-label', 'Working in project');
@@ -44,7 +44,7 @@
44
44
  var p = projects[i];
45
45
  var opt = document.createElement('option');
46
46
  opt.value = p;
47
- opt.textContent = p;
47
+ opt.textContent = (displayByName && displayByName[p]) || p;
48
48
  if (p === selected) opt.selected = true;
49
49
  sel.appendChild(opt);
50
50
  }
@@ -59,9 +59,11 @@
59
59
  var res = await fetch('/api/status');
60
60
  if (!res.ok) throw new Error('HTTP ' + res.status);
61
61
  var data = await res.json();
62
- var projects = (data && Array.isArray(data.projects) ? data.projects : [])
63
- .map(function(p) { return p && p.name ? String(p.name) : null; })
64
- .filter(Boolean);
62
+ var projectList = (data && Array.isArray(data.projects) ? data.projects : [])
63
+ .filter(function(p) { return p && p.name; });
64
+ var projects = projectList.map(function(p) { return String(p.name); });
65
+ var displayByName = {};
66
+ projectList.forEach(function(p) { displayByName[String(p.name)] = String(p.displayName || p.name); });
65
67
  if (projects.length === 0) {
66
68
  stripEl.style.display = '';
67
69
  controlsEl.replaceChildren(makeContextEmptyNode(), makeContextAddBtn());
@@ -79,7 +81,7 @@
79
81
  // (italicized via CSS). We deliberately do NOT fall back to projects[0]
80
82
  // here (W-mqayzsj3) so CC turns and project-scoped actions don't
81
83
  // silently target the wrong project.
82
- var sel = makeContextSelect(projects, currentProject);
84
+ var sel = makeContextSelect(projects, currentProject, displayByName);
83
85
  stripEl.style.display = '';
84
86
  controlsEl.replaceChildren(sel, makeContextAddBtn());
85
87
  sel.addEventListener('change', function(ev) {
@@ -681,6 +681,26 @@
681
681
  .tile-chip.blue { background: rgba(88, 166, 255, 0.12); color: var(--blue); border-color: var(--blue); }
682
682
  .tile-empty { color: var(--muted); font-style: italic; font-size: var(--text-md); }
683
683
 
684
+ /* Collapsible PR sections (Active / Merged-Completed / Abandoned). Uses
685
+ native <details>/<summary> — no JS wiring needed for the toggle. */
686
+ .tile-section { margin-bottom: 10px; }
687
+ .tile-section:last-child { margin-bottom: 0; }
688
+ .tile-section-summary {
689
+ display: flex; align-items: center; gap: 6px;
690
+ cursor: pointer; user-select: none;
691
+ padding: 4px 2px; margin-bottom: 6px;
692
+ list-style: none;
693
+ font-size: var(--text-base); color: var(--muted);
694
+ }
695
+ .tile-section-summary::-webkit-details-marker { display: none; }
696
+ .tile-section-summary::before {
697
+ content: "▸"; color: var(--muted); font-size: var(--text-sm);
698
+ transition: transform 0.15s ease;
699
+ }
700
+ .tile-section[open] > .tile-section-summary::before { content: "▾"; }
701
+ .tile-section-label { font-weight: 700; letter-spacing: 0.3px; text-transform: uppercase; color: var(--text); }
702
+ .tile-section-count { color: var(--muted); }
703
+
684
704
  /* Pinned-context list rows (slim-pinned-modal). */
685
705
  .pinned-row {
686
706
  border: 1px solid var(--border);