@yemi33/minions 0.1.2177 → 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.
Files changed (44) hide show
  1. package/bin/minions.js +24 -11
  2. package/dashboard/js/command-parser.js +1 -1
  3. package/dashboard/js/memory-panel.js +262 -0
  4. package/dashboard/js/qa.js +2 -2
  5. package/dashboard/js/refresh.js +9 -1
  6. package/dashboard/js/render-dispatch.js +92 -0
  7. package/dashboard/js/render-other.js +1 -1
  8. package/dashboard/js/render-plans.js +82 -13
  9. package/dashboard/js/render-prs.js +2 -1
  10. package/dashboard/js/render-schedules.js +1 -1
  11. package/dashboard/js/render-watches.js +1 -1
  12. package/dashboard/js/settings.js +100 -11
  13. package/dashboard/layout.html +6 -0
  14. package/dashboard/pages/engine-memory-panel.html +49 -0
  15. package/dashboard/pages/engine.html +1 -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/styles.css +39 -0
  21. package/dashboard-build.js +17 -2
  22. package/dashboard.js +469 -21
  23. package/docs/README.md +8 -1
  24. package/docs/auto-discovery.md +40 -0
  25. package/docs/branch-derivation.md +13 -1
  26. package/docs/cross-repo-plans.md +292 -0
  27. package/docs/deprecated.json +4 -4
  28. package/docs/pr-auto-fix-dispatch.md +64 -0
  29. package/docs/pr-review-fix-loop.md +1 -1
  30. package/docs/watches.md +1 -0
  31. package/engine/ado.js +1 -10
  32. package/engine/diagnostics-memory.js +190 -0
  33. package/engine/dispatch.js +53 -0
  34. package/engine/lifecycle.js +155 -191
  35. package/engine/meeting.js +30 -0
  36. package/engine/playbook.js +15 -0
  37. package/engine/queries.js +165 -5
  38. package/engine/runtimes/copilot.js +19 -0
  39. package/engine/shared.js +303 -3
  40. package/engine/watchdog.js +6 -0
  41. package/engine.js +576 -113
  42. package/package.json +2 -2
  43. package/playbooks/plan-to-prd.md +25 -2
  44. package/playbooks/plan.md +4 -2
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,
@@ -143,6 +143,7 @@ const RENDER_VERSIONS = {
143
143
  version: 1,
144
144
  adoThrottle: 1,
145
145
  ghThrottle: 1,
146
+ pausedBanner: 1,
146
147
  dispatch: 2,
147
148
  engineLog: 2,
148
149
  metrics: 1,
@@ -713,6 +714,13 @@ function _processStatusUpdate(data, opts) {
713
714
  _safeRender('adoThrottle', function() { renderAdoThrottleAlert(data.adoThrottle); });
714
715
  _changed('ghThrottle', data.ghThrottle);
715
716
  _safeRender('ghThrottle', function() { renderGhThrottleAlert(data.ghThrottle); });
717
+ // P-g7a2b4c5 — sticky kill-switch banner. Cache key is the pair of paused
718
+ // flags so the banner only re-renders when either flag actually flips (the
719
+ // full data.engine slice changes every tick because heartbeat/lastTickAt
720
+ // advance).
721
+ const _pausedCacheKey = (data.engine && (data.engine.pollingPaused ? 'p' : '-') + (data.engine.autoFixPaused ? 'a' : '-')) || '--';
722
+ _changed('pausedBanner', _pausedCacheKey);
723
+ _safeRender('pausedBanner', function() { renderPausedBanner(data.engine); });
716
724
  // Dispatch now comes from /api/dispatch — a dedicated fresh-JSON
717
725
  // endpoint that re-runs getDispatchQueue() server-side on every
718
726
  // request (issue #2949). Completion-report sidecars are now loaded
@@ -245,6 +245,98 @@ function renderGhThrottleAlert(ghThrottle) {
245
245
  el.style.display = 'flex';
246
246
  }
247
247
 
248
+ // P-g7a2b4c5 — Sticky cross-page kill-switch banner. Renders whenever either
249
+ // engine.pollingPaused or engine.autoFixPaused is true, with per-row Resume
250
+ // buttons that POST to the convenience endpoints from P-f3c9d0e7. Built with
251
+ // the DOM API (no innerHTML) so the eslint-plugin-no-unsanitized gate passes
252
+ // cleanly — banner content is system-controlled but follows the project
253
+ // convention of avoiding innerHTML in new code anyway.
254
+ function renderPausedBanner(engine) {
255
+ const el = document.getElementById('paused-banner');
256
+ if (!el) return;
257
+ const pollingPaused = !!(engine && engine.pollingPaused);
258
+ const autoFixPaused = !!(engine && engine.autoFixPaused);
259
+ if (!pollingPaused && !autoFixPaused) {
260
+ el.hidden = true;
261
+ while (el.firstChild) el.removeChild(el.firstChild);
262
+ return;
263
+ }
264
+
265
+ // Wipe + rebuild — list-of-pauses model, one row per active pause.
266
+ while (el.firstChild) el.removeChild(el.firstChild);
267
+ el.hidden = false;
268
+
269
+ const title = document.createElement('div');
270
+ title.className = 'paused-banner-title';
271
+ title.textContent = '⚠️ Some engine functions are paused:';
272
+ el.appendChild(title);
273
+
274
+ const list = document.createElement('ul');
275
+ list.className = 'paused-banner-list';
276
+ el.appendChild(list);
277
+
278
+ const rows = [];
279
+ if (pollingPaused) {
280
+ rows.push({
281
+ key: 'polling',
282
+ label: 'Polling',
283
+ detail: 'no new PR state arriving',
284
+ endpoint: '/api/engine/polling/resume',
285
+ });
286
+ }
287
+ if (autoFixPaused) {
288
+ rows.push({
289
+ key: 'autofix',
290
+ label: 'PR auto-fix',
291
+ detail: 'no new PR-triggered dispatches',
292
+ endpoint: '/api/engine/auto-fix/resume',
293
+ });
294
+ }
295
+
296
+ rows.forEach(function (row) {
297
+ const li = document.createElement('li');
298
+ li.className = 'paused-banner-row';
299
+ li.setAttribute('data-paused-row', row.key);
300
+
301
+ const labelSpan = document.createElement('span');
302
+ labelSpan.className = 'paused-banner-row-label';
303
+ labelSpan.textContent = '• ' + row.label;
304
+ li.appendChild(labelSpan);
305
+
306
+ const detailSpan = document.createElement('span');
307
+ detailSpan.className = 'paused-banner-row-detail';
308
+ detailSpan.textContent = '— ' + row.detail;
309
+ li.appendChild(detailSpan);
310
+
311
+ const btn = document.createElement('button');
312
+ btn.type = 'button';
313
+ btn.className = 'paused-banner-row-resume';
314
+ btn.textContent = 'Resume';
315
+ btn.setAttribute('data-paused-resume', row.key);
316
+ btn.addEventListener('click', function () {
317
+ if (btn.disabled) return;
318
+ btn.disabled = true;
319
+ btn.classList.add('clicked');
320
+ fetch(row.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' } })
321
+ .then(function (resp) {
322
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
323
+ if (typeof showToast === 'function') showToast('cmd-toast', row.label + ' resumed', true);
324
+ if (typeof refreshNow === 'function') refreshNow();
325
+ })
326
+ .catch(function (err) {
327
+ btn.disabled = false;
328
+ btn.classList.remove('clicked');
329
+ if (typeof showToast === 'function') {
330
+ showToast('cmd-toast', 'Resume failed: ' + (err && err.message ? err.message : 'unknown'), false);
331
+ }
332
+ });
333
+ });
334
+ li.appendChild(btn);
335
+
336
+ list.appendChild(li);
337
+ });
338
+ }
339
+
248
340
  function renderDispatch(dispatch, opts) {
249
341
  opts = opts || {};
250
342
  if (!dispatch) return;
@@ -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>'