@yemi33/minions 0.1.2431 → 0.1.2433

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.
@@ -1153,6 +1153,34 @@ let _lastStatusData = null;
1153
1153
  // fresh state anyway.
1154
1154
  let _refreshInFlight = false;
1155
1155
 
1156
+ // ── Embedded-in-slim polling suspend (W-ms3bbzry000i6612) ────────────────
1157
+ // The slim cockpit's Queued-work / Active-PRs (and engine/plans) tiles embed
1158
+ // this classic SPA in a chrome-off ?embed=1 iframe that is now CACHED and
1159
+ // kept mounted (shown/hidden) instead of cold-booted on every open. A hidden
1160
+ // (display:none) iframe still reports document.visibilityState === 'visible',
1161
+ // so without an explicit signal this embedded SPA would keep polling
1162
+ // /api/status every 4s in the background forever — trading a per-open cold
1163
+ // boot for N permanent background pollers. The slim parent posts
1164
+ // {source:'minions-slim-embed', type:'visibility', visible} whenever it
1165
+ // shows/hides the iframe; we suspend the poll while hidden and resume (with an
1166
+ // immediate refresh for freshness) on show. Installed ONLY in embed mode, so
1167
+ // standalone /work, /prs, /engine, /plans, /settings are 100% unaffected.
1168
+ let _embedPollSuspended = false;
1169
+ (function _installSlimEmbedVisibilityBridge() {
1170
+ try {
1171
+ if (!document.documentElement.classList.contains('embed')) return;
1172
+ window.addEventListener('message', function (ev) {
1173
+ // Same-origin only — reject cross-origin/foreign frames.
1174
+ if (!ev || ev.origin !== window.location.origin) return;
1175
+ const d = ev.data;
1176
+ if (!d || d.source !== 'minions-slim-embed' || d.type !== 'visibility') return;
1177
+ _embedPollSuspended = !d.visible;
1178
+ if (d.visible) { try { refresh(); } catch (e) { /* refresh not yet defined / transient */ } }
1179
+ });
1180
+ } catch (e) { /* defensive — never block boot on this bridge */ }
1181
+ })();
1182
+
1183
+
1156
1184
  // ── Dashboard-unreachable detector ───────────────────────────────────────
1157
1185
  // When the dashboard process dies, /api/status throws or 5xxs and the
1158
1186
  // existing catch block just console.errors — the page keeps painting the
@@ -1340,6 +1368,8 @@ async function refresh(opts) {
1340
1368
  // dashboard isn't hammered at the steady 4s cadence (which produces
1341
1369
  // console-spam and adds load to whatever's wedged).
1342
1370
  if (_nextPollAllowedAt && Date.now() < _nextPollAllowedAt) return;
1371
+ // Embedded + hidden in the slim cockpit: skip polling (see the bridge above).
1372
+ if (_embedPollSuspended) return;
1343
1373
  _refreshInFlight = true;
1344
1374
  const _t0 = Date.now();
1345
1375
  const _gap = _prevRefreshTs ? _t0 - _prevRefreshTs : null;
@@ -33,6 +33,13 @@ function renderProjects(projects) {
33
33
  // a warning chip when branchMismatch (configured ≠ origin/HEAD) OR when the
34
34
  // local branch is behind main, and a compact "↑N ↓M" chip when ahead/behind
35
35
  // counts are non-zero.
36
+ //
37
+ // SEMANTICS SOURCE OF TRUTH: the branch/ahead-behind/dirty/main-drift semantics
38
+ // below are mirrored (data-only, no HTML) in dashboard/shared/project-git-summary.js
39
+ // so the Slim UX project-pill hover can render the SAME facts without forking
40
+ // this HTML renderer (CLAUDE.md "no duplicate solutions"). Keep the two in sync;
41
+ // this function stays the canonical HTML renderer (its unit tests extract it
42
+ // standalone, so it deliberately does not call the shared helper).
36
43
  function _renderProjectBranch(p) {
37
44
  if (!p) return '';
38
45
  if (p.gitState === 'missing') return '<span class="project-warn" title="Project localPath does not exist on disk">(path not found)</span>';
@@ -272,15 +272,20 @@ async function openSettings() {
272
272
  const slimUxEnabled = slimUxFeature
273
273
  ? (slimUxFeature.enabled != null ? !!slimUxFeature.enabled : !!slimUxFeature.default)
274
274
  : false;
275
+ // W-ms25rsk2 — mirror the Font Size cell's vertical rhythm so the two
276
+ // Appearance controls read as a coherent pair: lead with an equivalent small
277
+ // muted label heading ('Slim UX'), then the checkbox row, then a matching
278
+ // muted description below. Previously this cell led straight with the flex
279
+ // checkbox row, so its title sat above the Font Size <label> and the toggle
280
+ // looked misaligned within the grid.
275
281
  const slimUxControl = slimUxFeature
276
282
  ? '<div data-search="slim ux appearance experimental cockpit">' +
277
- '<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer">' +
278
- '<input type="checkbox" data-feature-toggle="slim-ux"' + (slimUxEnabled ? ' checked' : '') + ' style="margin-top:3px;accent-color:var(--blue);width:16px;height:16px;cursor:pointer;flex-shrink:0">' +
279
- '<span style="flex:1">' +
280
- '<span style="font-size:var(--text-md);color:var(--text);display:block">Slim UX</span>' +
281
- '<span style="font-size:var(--text-xs);color:var(--muted);display:block;margin-top:1px">Experimental focused single-screen cockpit. Persists immediately.</span>' +
282
- '</span>' +
283
+ '<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Slim UX</label>' +
284
+ '<label style="display:flex;align-items:center;gap:8px;cursor:pointer">' +
285
+ '<input type="checkbox" data-feature-toggle="slim-ux"' + (slimUxEnabled ? ' checked' : '') + ' style="accent-color:var(--blue);width:16px;height:16px;cursor:pointer;flex-shrink:0">' +
286
+ '<span style="font-size:var(--text-md);color:var(--text)">Enable slim cockpit</span>' +
283
287
  '</label>' +
288
+ '<div style="font-size:var(--text-sm);color:var(--muted);margin-top:1px">Experimental focused single-screen cockpit. Persists immediately.</div>' +
284
289
  '</div>'
285
290
  : '';
286
291
  const paneAppearance =
@@ -0,0 +1,239 @@
1
+ // dashboard/shared/project-git-summary.js
2
+ //
3
+ // Pure, DOM-free summarizer of a /api/status project object's git + dispatch
4
+ // state. This is the SINGLE source of truth for the *semantics* that the
5
+ // classic Projects bar renders as HTML chips in dashboard/js/render-other.js
6
+ // (_renderProjectBranch / _renderWorktreeModePill / _liveValidationTypesArray).
7
+ // Slim UX loads this helper (it does NOT load render-other.js) to build the
8
+ // project-pill hover panel without forking the classic branching logic
9
+ // (CLAUDE.md "no duplicate solutions — reuse before re-implementing").
10
+ //
11
+ // It intentionally returns plain data only — no HTML, no escaping, no DOM — so
12
+ // each surface renders it its own way (classic keeps its HTML chips; slim renders
13
+ // a hover popover + a title-attribute fallback). Keep the field semantics in
14
+ // lock-step with render-other.js; that file remains the canonical HTML renderer
15
+ // and its unit tests (ahead-behind-badge-comparator-label.test.js,
16
+ // dashboard-worktree-mode-pill.test.js) extract those functions standalone, so
17
+ // they are deliberately NOT refactored to call this helper.
18
+
19
+ // Mirrors render-other.js#_liveValidationTypesArray: normalize the single-string
20
+ // legacy shape OR an array of strings into an array of non-empty strings.
21
+ function _projectLiveValidationTypes(p) {
22
+ var raw = p && p.liveValidationType;
23
+ if (!raw) return [];
24
+ var arr = Array.isArray(raw) ? raw : [raw];
25
+ return arr.filter(function (t) { return typeof t === 'string' && t.length > 0; });
26
+ }
27
+
28
+ // Mirrors the checkout-mode classification in render-other.js#_renderWorktreeModePill.
29
+ // Returns { kind, label, types, autoValidate, detail } where kind is one of
30
+ // 'worktree' | 'live' | 'hybrid' | 'hybrid-invalid'.
31
+ function _projectCheckoutMode(p) {
32
+ if (p && p.checkoutMode === 'live') {
33
+ var types = _projectLiveValidationTypes(p);
34
+ if (p.liveValidationInvalid) {
35
+ var effective = types.length > 0 ? types.join(', ') : 'none';
36
+ return {
37
+ kind: 'hybrid-invalid',
38
+ label: 'Hybrid · invalid',
39
+ types: types,
40
+ autoValidate: false,
41
+ detail: 'Invalid hybrid configuration — unsupported settings are ignored. '
42
+ + 'Effective live work-item types: ' + effective + '.',
43
+ };
44
+ }
45
+ if (types.length > 0) {
46
+ var typesText = types.join(', ');
47
+ var autoDispatch = p.liveValidationAutoDispatch === true;
48
+ return {
49
+ kind: 'hybrid',
50
+ label: 'Hybrid · ' + typesText + (autoDispatch ? ' · auto-validate' : ''),
51
+ types: types,
52
+ autoValidate: autoDispatch,
53
+ detail: 'Hybrid dispatch — only the "' + typesText + '" work-item type(s) run '
54
+ + 'in-place; all others use isolated worktrees. '
55
+ + (autoDispatch
56
+ ? 'A validation work item is dispatched automatically after each eligible coding work item.'
57
+ : 'Auto-validation is OFF — validation must be dispatched manually.'),
58
+ };
59
+ }
60
+ return {
61
+ kind: 'live',
62
+ label: 'Live checkout',
63
+ types: [],
64
+ autoValidate: false,
65
+ detail: 'Live-checkout dispatch — agents run in-place inside the project working '
66
+ + 'tree (no isolated worktree); capped to one mutating dispatch.',
67
+ };
68
+ }
69
+ return {
70
+ kind: 'worktree',
71
+ label: 'Worktrees',
72
+ types: [],
73
+ autoValidate: false,
74
+ detail: 'Worktree dispatch mode (default) — each agent runs in its own git worktree.',
75
+ };
76
+ }
77
+
78
+ // Summarize a single project object from GET /api/status.
79
+ // Returns a plain object:
80
+ // {
81
+ // state, // 'missing'|'non-git'|'probe-timeout'|'stale'|'unknown'|'ok'
82
+ // available, // true only when git branch data is present + fresh
83
+ // refreshing, // true when the cached branch data is known-stale
84
+ // message, // short label for non-ok/unavailable states (or null)
85
+ // branch, mainBranch, remoteDefaultBranch,
86
+ // detached, dirty,
87
+ // branchDiffersFromMain,
88
+ // ahead, behind, comparator, branchDiffersFromComparator, showAheadBehind,
89
+ // warn, // null | { kind:'main-drift'|'behind', text, detail }
90
+ // mode, // { kind, label, types, autoValidate, detail }
91
+ // lines, // [{ label, value }] ready-to-render rows
92
+ // warnings, // [string] visible warning chips
93
+ // titleText, // multi-fact "•"-joined string for a title= fallback
94
+ // }
95
+ function summarizeProjectGit(p) {
96
+ var mode = _projectCheckoutMode(p || {});
97
+ var base = {
98
+ state: 'unknown',
99
+ available: false,
100
+ refreshing: false,
101
+ message: null,
102
+ branch: null,
103
+ mainBranch: null,
104
+ remoteDefaultBranch: null,
105
+ detached: false,
106
+ dirty: false,
107
+ branchDiffersFromMain: false,
108
+ ahead: null,
109
+ behind: null,
110
+ comparator: null,
111
+ branchDiffersFromComparator: false,
112
+ showAheadBehind: false,
113
+ warn: null,
114
+ mode: mode,
115
+ lines: [],
116
+ warnings: [],
117
+ titleText: '',
118
+ };
119
+
120
+ if (!p) return base;
121
+
122
+ // Non-ok git states — mirror the early returns of _renderProjectBranch.
123
+ if (p.gitState === 'missing') {
124
+ base.state = 'missing';
125
+ base.message = '(path not found)';
126
+ base.titleText = 'Project localPath does not exist on disk';
127
+ return base;
128
+ }
129
+ if (p.gitState === 'non-git') {
130
+ base.state = 'non-git';
131
+ base.message = '(not a git repo)';
132
+ base.titleText = 'Project path exists but is not a git repository';
133
+ return base;
134
+ }
135
+ if (p.gitState === 'probe-timeout') {
136
+ base.state = 'probe-timeout';
137
+ base.message = '(checking git…)';
138
+ base.titleText = 'Git status probe timed out (slow filesystem). The repository is valid and will refresh.';
139
+ return base;
140
+ }
141
+ if (p.gitState !== 'ok' || !p.gitBranch) {
142
+ // Nothing to show (classic returns an empty string here).
143
+ base.state = 'unknown';
144
+ return base;
145
+ }
146
+ if (p.gitStale) {
147
+ // Cached branch/dirty is known-stale — show a neutral refreshing state
148
+ // rather than a possibly-wrong branch name (parity with classic).
149
+ base.state = 'stale';
150
+ base.refreshing = true;
151
+ base.message = '(refreshing…)';
152
+ base.titleText = 'Branch changed outside the dashboard — refreshing…';
153
+ return base;
154
+ }
155
+
156
+ var branch = p.gitBranch;
157
+ var mainBranch = p.mainBranch || '';
158
+ var remoteDefault = p.remoteDefaultBranch || '';
159
+ var ahead = Number.isFinite(p.ahead) ? p.ahead : null;
160
+ var behind = Number.isFinite(p.behind) ? p.behind : null;
161
+ var branchDiffersFromMain = !!(mainBranch && branch !== mainBranch);
162
+ var comparator = mainBranch || remoteDefault;
163
+ // Comparator for ahead/behind is origin/<mainBranch || remoteDefaultBranch>
164
+ // (engine/queries.js _probeProjectGitStatus), never the branch's own upstream.
165
+ var comparatorRef = mainBranch || remoteDefault;
166
+ var branchDiffersFromComparator = !!(comparator && branch !== comparatorRef);
167
+ var showAheadBehind = ahead !== null && behind !== null && (ahead > 0 || behind > 0);
168
+
169
+ base.state = 'ok';
170
+ base.available = true;
171
+ base.branch = branch;
172
+ base.mainBranch = mainBranch || null;
173
+ base.remoteDefaultBranch = remoteDefault || null;
174
+ base.detached = !!p.gitDetached;
175
+ base.dirty = !!p.gitDirty;
176
+ base.branchDiffersFromMain = branchDiffersFromMain;
177
+ base.ahead = ahead;
178
+ base.behind = behind;
179
+ base.comparator = comparator || null;
180
+ base.branchDiffersFromComparator = branchDiffersFromComparator;
181
+ base.showAheadBehind = showAheadBehind;
182
+
183
+ // Warning chip — mirror _renderProjectBranch precedence: main drift wins over
184
+ // "behind".
185
+ if (p.branchMismatch) {
186
+ base.warn = {
187
+ kind: 'main-drift',
188
+ text: '⚠ main drift',
189
+ detail: 'Configured mainBranch (' + mainBranch + ') differs from origin/HEAD ('
190
+ + remoteDefault + ') — config is likely stale',
191
+ };
192
+ } else if (behind !== null && behind > 0) {
193
+ base.warn = {
194
+ kind: 'behind',
195
+ text: '⚠ behind',
196
+ detail: 'Local branch is ' + behind + ' commits behind origin/' + comparatorRef,
197
+ };
198
+ }
199
+
200
+ // Ready-to-render rows for a hover panel.
201
+ var branchValue = 'on: ' + branch
202
+ + (branchDiffersFromMain ? ' → ' + mainBranch : '')
203
+ + (base.detached ? ' (detached)' : '');
204
+ base.lines.push({ label: 'Branch', value: branchValue });
205
+ if (showAheadBehind) {
206
+ base.lines.push({
207
+ label: 'Ahead / behind',
208
+ value: '↑' + ahead + ' ↓' + behind
209
+ + (branchDiffersFromComparator ? ' vs ' + comparator : ''),
210
+ });
211
+ }
212
+ if (base.dirty) {
213
+ base.lines.push({ label: 'Working tree', value: '● uncommitted changes' });
214
+ }
215
+ base.lines.push({ label: 'Checkout', value: mode.label });
216
+ if (base.warn) base.warnings.push(base.warn.detail);
217
+
218
+ // "•"-joined multi-fact string for a title= attribute fallback. Mirrors the
219
+ // titleBits composition in _renderProjectBranch, then appends checkout mode.
220
+ var titleBits = ['on: ' + branch];
221
+ if (mainBranch) titleBits.push('configured main: ' + mainBranch);
222
+ if (remoteDefault) titleBits.push('origin/HEAD: ' + remoteDefault);
223
+ if (ahead !== null && behind !== null) titleBits.push('↑' + ahead + ' ↓' + behind + ' vs origin/' + comparatorRef);
224
+ if (base.detached) titleBits.push('(detached)');
225
+ if (base.dirty) titleBits.push('(dirty)');
226
+ titleBits.push('checkout: ' + mode.label);
227
+ if (base.warn) titleBits.push(base.warn.detail);
228
+ base.titleText = titleBits.join(' • ');
229
+
230
+ return base;
231
+ }
232
+
233
+ var MinionsProjectGitSummary = {
234
+ summarize: summarizeProjectGit,
235
+ liveValidationTypes: _projectLiveValidationTypes,
236
+ checkoutMode: _projectCheckoutMode,
237
+ };
238
+
239
+ if (typeof window !== 'undefined') window.MinionsProjectGitSummary = MinionsProjectGitSummary;
@@ -13,8 +13,14 @@
13
13
  });
14
14
  }
15
15
  function clearTileModalBody() {
16
- var body = document.getElementById('slim-tile-body');
17
- if (body) body.textContent = '';
16
+ // Reuse contract (W-ms3bbzry000i6612): the embed tiles' iframes are cached
17
+ // and kept mounted for an instant reopen, so we DON'T wipe the modal body.
18
+ // Instead pause + hide the active embed iframe and clear only the transient
19
+ // card container. Guarded with typeof so the source-extracted unit-test copy
20
+ // of this fn (which lacks the embed helpers/host) stays a safe no-op.
21
+ if (typeof _pauseActiveEmbed === 'function') _pauseActiveEmbed();
22
+ var transient = document.getElementById('slim-tile-transient');
23
+ if (transient) transient.textContent = '';
18
24
  }
19
25
  // Stop the agent-detail "Working for" ticker on every dismiss path so the
20
26
  // 1s interval started in openAgentDetail can't leak after the modal closes.
@@ -85,6 +91,131 @@
85
91
  return card;
86
92
  }
87
93
 
94
+ // ── Reusable embed machinery (W-ms3bbzry000i6612) ──────────────────
95
+ // Each single-iframe embed tile (engine · queued · plans · prs) gets ONE
96
+ // cached iframe that is created on first use and thereafter shown/hidden —
97
+ // NEVER reparented or removed (reparenting/removing an <iframe> discards and
98
+ // reloads its browsing context, which would re-cold-boot the whole embedded
99
+ // SPA and defeat the point). Reopening a tile becomes an instant show instead
100
+ // of a full SPA cold-boot + first full fetch/render.
101
+ //
102
+ // A kept-alive hidden iframe would otherwise keep polling /api/status every 4s
103
+ // in the background: a display:none iframe still reports
104
+ // document.visibilityState === 'visible', so the embedded classic refresh loop
105
+ // has no idea it is off-screen. We tell it explicitly with a postMessage the
106
+ // classic SPA listens for (refresh.js, embed-mode only) — suspend while hidden,
107
+ // resume (with an immediate refresh for freshness) on show. So we trade a
108
+ // per-open cold-boot for a single background boot, not N permanent pollers.
109
+ //
110
+ // The cached iframes live in a persistent #slim-tile-embed-host inside the
111
+ // tile modal body that openTileModal/clearTileModalBody never wipe; transient
112
+ // card content (the dispatches tile) lives in a sibling #slim-tile-transient.
113
+ var _embedFrames = {}; // key -> iframe (cached; never removed/reparented)
114
+ var _activeEmbedKey = null; // key of the currently-shown embed iframe (or null)
115
+ // Single-iframe embed tiles (excludes 'automation', which is its own tabbed,
116
+ // already-lazy composite). key -> [src, title, className].
117
+ var _EMBED_TILES = {
118
+ engine: ['/engine?embed=1', 'Engine', 'slim-engine-embed'],
119
+ queued: ['/work?embed=1', 'Work Items', 'slim-work-embed'],
120
+ plans: ['/plans?embed=1', 'Plans', 'slim-plans-embed'],
121
+ prs: ['/prs?embed=1', 'Pull Requests', 'slim-prs-embed'],
122
+ };
123
+
124
+ // Tell an embedded classic SPA whether its iframe is currently visible so it
125
+ // can suspend/resume its 4s poll. Same-origin, so contentWindow is reachable;
126
+ // guarded defensively. No-op before the iframe has a live contentWindow.
127
+ function _postEmbedVisibility(frame, visible) {
128
+ try {
129
+ if (frame && frame.contentWindow) {
130
+ frame.contentWindow.postMessage(
131
+ { source: 'minions-slim-embed', type: 'visibility', visible: !!visible }, '*');
132
+ }
133
+ } catch (e) { /* defensive — same-origin means this should never throw */ }
134
+ }
135
+
136
+ // Ensure the persistent host + transient containers exist inside the tile
137
+ // modal body (created once, never wiped). Returns { host, transient }.
138
+ function _ensureTileContainers() {
139
+ var body = document.getElementById('slim-tile-body');
140
+ if (!body) return null;
141
+ var host = document.getElementById('slim-tile-embed-host');
142
+ if (!host) {
143
+ host = document.createElement('div');
144
+ host.id = 'slim-tile-embed-host';
145
+ host.className = 'slim-tile-embed-host';
146
+ body.appendChild(host);
147
+ }
148
+ var transient = document.getElementById('slim-tile-transient');
149
+ if (!transient) {
150
+ transient = document.createElement('div');
151
+ transient.id = 'slim-tile-transient';
152
+ transient.className = 'slim-tile-transient';
153
+ body.appendChild(transient);
154
+ }
155
+ return { host: host, transient: transient };
156
+ }
157
+
158
+ // Create (once) the cached iframe for an embed tile and return it. Built with
159
+ // createElement (no innerHTML) to satisfy the dashboard no-unsanitized lint
160
+ // gate. Starts hidden; a load-time visibility message pauses its poll until
161
+ // the tile is actually shown.
162
+ function _getOrCreateEmbedFrame(key) {
163
+ if (_embedFrames[key]) return _embedFrames[key];
164
+ var spec = _EMBED_TILES[key];
165
+ if (!spec) return null;
166
+ var containers = _ensureTileContainers();
167
+ if (!containers) return null;
168
+ var frame = document.createElement('iframe');
169
+ frame.className = spec[2];
170
+ frame.src = spec[0];
171
+ frame.title = spec[1];
172
+ frame.hidden = true;
173
+ frame.addEventListener('load', function() {
174
+ _postEmbedVisibility(frame, _activeEmbedKey === key);
175
+ });
176
+ containers.host.appendChild(frame);
177
+ _embedFrames[key] = frame;
178
+ return frame;
179
+ }
180
+
181
+ // Show one embed tile's cached iframe and hide the rest, pausing/resuming
182
+ // each one's embedded poll to match. Reused by every single-iframe embed tile.
183
+ function _showReusableEmbed(key) {
184
+ _getOrCreateEmbedFrame(key);
185
+ Object.keys(_embedFrames).forEach(function(k) {
186
+ var f = _embedFrames[k];
187
+ if (!f) return;
188
+ var on = (k === key);
189
+ f.hidden = !on;
190
+ _postEmbedVisibility(f, on);
191
+ });
192
+ _activeEmbedKey = key;
193
+ }
194
+
195
+ // Hide + pause whichever embed iframe is currently shown (kept mounted for an
196
+ // instant reopen). Called on modal close and when switching to a card tile.
197
+ function _pauseActiveEmbed() {
198
+ Object.keys(_embedFrames).forEach(function(k) {
199
+ var f = _embedFrames[k];
200
+ if (!f) return;
201
+ if (!f.hidden) _postEmbedVisibility(f, false);
202
+ f.hidden = true;
203
+ });
204
+ _activeEmbedKey = null;
205
+ }
206
+
207
+ // Prewarm the two hot embed tiles (Queued work + Active PRs) shortly after the
208
+ // slim cockpit paints, so their first open is an instant show rather than a
209
+ // cold boot. Deferred so it doesn't compete with initial paint; engine/plans
210
+ // are created lazily on first open (and cached/reused thereafter).
211
+ function prewarmHotEmbeds() {
212
+ _getOrCreateEmbedFrame('queued');
213
+ _getOrCreateEmbedFrame('prs');
214
+ }
215
+ (function schedulePrewarm() {
216
+ try { setTimeout(prewarmHotEmbeds, 800); } catch (e) { /* no timers — skip prewarm */ }
217
+ })();
218
+
88
219
  // Per-tile body renderers for the detail modal. Each reads the latest status
89
220
  // snapshot and fills the modal body; openTileModal looks them up by key.
90
221
  // Engine tile body — reuses the LITERAL classic dashboard /engine screen
@@ -100,11 +231,7 @@
100
231
  // /engine even with slim-ux ON (only / is taken over). Built with createElement
101
232
  // (no innerHTML) to satisfy the dashboard no-unsanitized lint gate.
102
233
  function renderEngineBody(body) {
103
- var frame = document.createElement('iframe');
104
- frame.className = 'slim-engine-embed';
105
- frame.src = '/engine?embed=1';
106
- frame.title = 'Engine';
107
- body.appendChild(frame);
234
+ _showReusableEmbed('engine');
108
235
  }
109
236
 
110
237
  function renderDispatchTileBody(body, data, isActive) {
@@ -135,11 +262,7 @@
135
262
  // with slim-ux ON (only / is taken over). Built with createElement (no
136
263
  // innerHTML) to satisfy the dashboard no-unsanitized lint gate.
137
264
  function renderQueuedWorkBody(body) {
138
- var frame = document.createElement('iframe');
139
- frame.className = 'slim-work-embed';
140
- frame.src = '/work?embed=1';
141
- frame.title = 'Work Items';
142
- body.appendChild(frame);
265
+ _showReusableEmbed('queued');
143
266
  }
144
267
 
145
268
  // Plans tile body — reuses the LITERAL classic dashboard /plans screen instead
@@ -154,11 +277,7 @@
154
277
  // reachable at /plans even with slim-ux ON (only / is taken over). Built with
155
278
  // createElement (no innerHTML) to satisfy the dashboard no-unsanitized lint gate.
156
279
  function renderPlansBody(body) {
157
- var frame = document.createElement('iframe');
158
- frame.className = 'slim-plans-embed';
159
- frame.src = '/plans?embed=1';
160
- frame.title = 'Plans';
161
- body.appendChild(frame);
280
+ _showReusableEmbed('plans');
162
281
  }
163
282
 
164
283
  // Pull-requests tile body — reuses the LITERAL classic dashboard /prs screen
@@ -173,11 +292,7 @@
173
292
  // at /prs even with slim-ux ON (only / is taken over). Built with createElement
174
293
  // (no innerHTML) to satisfy the dashboard no-unsanitized lint gate.
175
294
  function renderPrsBody(body) {
176
- var frame = document.createElement('iframe');
177
- frame.className = 'slim-prs-embed';
178
- frame.src = '/prs?embed=1';
179
- frame.title = 'Pull Requests';
180
- body.appendChild(frame);
295
+ _showReusableEmbed('prs');
181
296
  }
182
297
 
183
298
  // Automation tile body — a tabbed composite (Watches · Schedules · Pipelines)
@@ -283,7 +398,12 @@
283
398
  var titleEl = document.getElementById('slim-tile-title');
284
399
  var body = document.getElementById('slim-tile-body');
285
400
  if (!modal || !body) return;
286
- body.textContent = '';
401
+ // Reuse contract: never wipe the whole body (that would destroy the cached,
402
+ // kept-alive embed iframes). Ensure the persistent host + transient
403
+ // containers, then clear ONLY the transient card container.
404
+ var containers = _ensureTileContainers();
405
+ var target = (containers && containers.transient) || body;
406
+ if (containers) containers.transient.textContent = '';
287
407
  if (titleEl) titleEl.textContent = view.title;
288
408
  // The "+ Link PR" header chip only applies to the PR list.
289
409
  var headerChip = document.getElementById('slim-tile-modal-linkpr');
@@ -298,7 +418,12 @@
298
418
  modal.classList.toggle('tile-modal--plans', key === 'plans');
299
419
  modal.classList.toggle('tile-modal--prs', key === 'prs');
300
420
  modal.classList.toggle('tile-modal--automation', key === 'automation');
301
- view.render(body, lastStatusData || {});
421
+ // Single-iframe embed tiles show their cached iframe (via _showReusableEmbed
422
+ // inside their render fn) and ignore `target`. Card + automation tiles render
423
+ // into the transient container, so pause/hide any shown embed behind them.
424
+ var isReusableEmbed = Object.prototype.hasOwnProperty.call(_EMBED_TILES, key);
425
+ if (!isReusableEmbed) _pauseActiveEmbed();
426
+ view.render(target, lastStatusData || {});
302
427
  modal.classList.add('open');
303
428
  }
304
429
 
@@ -25,6 +25,113 @@
25
25
  empty.textContent = 'No projects linked yet.';
26
26
  return empty;
27
27
  }
28
+ // ── Project-pill hover detail popover ────────────────────────
29
+ // Parity with the CLASSIC Projects bar (dashboard/js/render-other.js
30
+ // _renderProjectBranch / _renderWorktreeModePill): on hover/focus a pill shows
31
+ // the per-project git + dispatch detail — current branch, "→ main" drift,
32
+ // ahead/behind vs origin/main, dirty working tree, main-drift/behind warnings,
33
+ // and the checkout mode (Worktrees / Live checkout / Hybrid + validation types).
34
+ // All the semantics come from the SHARED, DOM-free summarizer
35
+ // window.MinionsProjectGitSummary.summarize (dashboard/shared/project-git-summary.js)
36
+ // so we don't fork the classic branching logic. Content is built with
37
+ // textContent/createElement only (no innerHTML — SEC-03 ratchet).
38
+ var _pillPopoverEl = null;
39
+ var _pillPopoverEscBound = false;
40
+
41
+ function _ensurePillPopover() {
42
+ if (_pillPopoverEl && document.body.contains(_pillPopoverEl)) return _pillPopoverEl;
43
+ var el = document.createElement('div');
44
+ el.className = 'slim-pill-popover';
45
+ el.id = 'slim-project-pill-popover';
46
+ el.setAttribute('role', 'tooltip');
47
+ el.hidden = true;
48
+ document.body.appendChild(el);
49
+ _pillPopoverEl = el;
50
+ if (!_pillPopoverEscBound) {
51
+ // Escape dismisses the popover (accessible non-blocking tooltip).
52
+ document.addEventListener('keydown', function(ev) {
53
+ if (ev.key === 'Escape' && _pillPopoverEl && !_pillPopoverEl.hidden) hidePillPopover();
54
+ });
55
+ _pillPopoverEscBound = true;
56
+ }
57
+ return el;
58
+ }
59
+
60
+ function _appendPopoverRow(parent, label, value, cls) {
61
+ var row = document.createElement('div');
62
+ row.className = 'slim-pill-popover-row' + (cls ? ' ' + cls : '');
63
+ if (label) {
64
+ var l = document.createElement('span');
65
+ l.className = 'slim-pill-popover-label';
66
+ l.textContent = label;
67
+ row.appendChild(l);
68
+ }
69
+ var v = document.createElement('span');
70
+ v.className = 'slim-pill-popover-value';
71
+ v.textContent = value;
72
+ row.appendChild(v);
73
+ parent.appendChild(row);
74
+ }
75
+
76
+ // Populate + position the popover under `pill` for the given status project
77
+ // object. No-op (and hides any open popover) when there is nothing to show.
78
+ function showPillPopover(pill, project) {
79
+ if (!pill || !project) return;
80
+ var summ = (window.MinionsProjectGitSummary && window.MinionsProjectGitSummary.summarize)
81
+ ? window.MinionsProjectGitSummary.summarize(project)
82
+ : null;
83
+ if (!summ) return;
84
+ var el = _ensurePillPopover();
85
+ el.textContent = '';
86
+
87
+ var header = document.createElement('div');
88
+ header.className = 'slim-pill-popover-header';
89
+ header.textContent = String(project.displayName || project.name || '');
90
+ el.appendChild(header);
91
+
92
+ if (summ.available) {
93
+ summ.lines.forEach(function(line) {
94
+ _appendPopoverRow(el, line.label, line.value);
95
+ });
96
+ if (summ.warn) {
97
+ _appendPopoverRow(el, '', summ.warn.text + ' — ' + summ.warn.detail, 'slim-pill-popover-warn');
98
+ }
99
+ } else if (summ.refreshing) {
100
+ _appendPopoverRow(el, '', summ.message || 'Refreshing…', 'slim-pill-popover-muted');
101
+ } else if (summ.message) {
102
+ _appendPopoverRow(el, '', summ.message, 'slim-pill-popover-muted');
103
+ _appendPopoverRow(el, 'Checkout', summ.mode.label);
104
+ } else {
105
+ // No git branch data (e.g. non-project path) — still show checkout mode.
106
+ _appendPopoverRow(el, 'Checkout', summ.mode.label);
107
+ }
108
+
109
+ // Link the pill to the popover for assistive tech while it's visible.
110
+ pill.setAttribute('aria-describedby', el.id);
111
+ el.hidden = false;
112
+
113
+ // Position below the pill, clamped to the viewport.
114
+ var r = pill.getBoundingClientRect();
115
+ var top = r.bottom + 6;
116
+ var left = r.left;
117
+ var maxLeft = window.innerWidth - el.offsetWidth - 8;
118
+ if (left > maxLeft) left = Math.max(8, maxLeft);
119
+ if (left < 8) left = 8;
120
+ el.style.top = top + 'px';
121
+ el.style.left = left + 'px';
122
+ }
123
+
124
+ function hidePillPopover() {
125
+ if (!_pillPopoverEl) return;
126
+ _pillPopoverEl.hidden = true;
127
+ // Drop stale aria-describedby links on all pills.
128
+ var group = document.getElementById('chat-context-pills');
129
+ if (group) {
130
+ var pills = group.querySelectorAll('.context-pill[aria-describedby]');
131
+ for (var i = 0; i < pills.length; i++) pills[i].removeAttribute('aria-describedby');
132
+ }
133
+ }
134
+
28
135
  // Strip variant: project pills. One selectable pill per project, single-select.
29
136
  // No auto-default: when nothing is explicitly chosen, no pill is active and the
30
137
  // indicator stays in the "Select project" state, so the picker never silently
@@ -32,7 +139,11 @@
32
139
  // choice back off. Returns a container of pill buttons built as real DOM nodes
33
140
  // (no innerHTML — SEC-03 ratchet, see test/unit.test.js DYNAMIC_INNERHTML_BASELINE);
34
141
  // the caller attaches a click listener via event delegation.
35
- function makeContextPills(projects, selected, displayByName) {
142
+ //
143
+ // `projectByName` maps project name → the full /api/status project object so
144
+ // each pill can surface its git + checkout detail on hover/focus (see
145
+ // showPillPopover). It is optional — pills still render name-only without it.
146
+ function makeContextPills(projects, selected, displayByName, projectByName) {
36
147
  var group = document.createElement('span');
37
148
  group.id = 'chat-context-pills';
38
149
  group.className = 'context-pills';
@@ -49,15 +160,37 @@
49
160
  var isActive = p === selected;
50
161
  pill.classList.toggle('active', isActive);
51
162
  pill.setAttribute('aria-checked', isActive ? 'true' : 'false');
163
+ var proj = projectByName && projectByName[p];
164
+ if (proj) {
165
+ // title= is the graceful fallback (works with no JS / reduced-motion);
166
+ // the richer popover is layered on via hover/focus below.
167
+ var summ = (window.MinionsProjectGitSummary && window.MinionsProjectGitSummary.summarize)
168
+ ? window.MinionsProjectGitSummary.summarize(proj)
169
+ : null;
170
+ if (summ && summ.titleText) pill.title = summ.titleText;
171
+ _wirePillHover(pill, proj);
172
+ }
52
173
  group.appendChild(pill);
53
174
  }
54
175
  return group;
55
176
  }
177
+
178
+ // Bind hover + keyboard-focus reveal of the detail popover for one pill.
179
+ // Kept in a helper so the closure captures the correct project object per
180
+ // iteration (the loop uses `var`).
181
+ function _wirePillHover(pill, proj) {
182
+ pill.addEventListener('mouseenter', function() { showPillPopover(pill, proj); });
183
+ pill.addEventListener('mouseleave', hidePillPopover);
184
+ pill.addEventListener('focus', function() { showPillPopover(pill, proj); });
185
+ pill.addEventListener('blur', hidePillPopover);
186
+ }
56
187
  async function loadProjectsAndRenderContext(opts) {
57
188
  var stripEl = document.getElementById('chat-context-strip');
58
189
  var controlsEl = document.getElementById('chat-context-controls');
59
190
  if (!stripEl || !controlsEl) return;
60
191
  var preferProject = (opts && opts.preferProject) || null;
192
+ // Any prior hover popover belongs to pills we're about to replace.
193
+ if (typeof hidePillPopover === 'function') hidePillPopover();
61
194
  try {
62
195
  var res = await fetch('/api/status');
63
196
  if (!res.ok) throw new Error('HTTP ' + res.status);
@@ -66,7 +199,12 @@
66
199
  .filter(function(p) { return p && p.name; });
67
200
  var projects = projectList.map(function(p) { return String(p.name); });
68
201
  var displayByName = {};
69
- projectList.forEach(function(p) { displayByName[String(p.name)] = String(p.displayName || p.name); });
202
+ var projectByName = {};
203
+ projectList.forEach(function(p) {
204
+ var key = String(p.name);
205
+ displayByName[key] = String(p.displayName || p.name);
206
+ projectByName[key] = p;
207
+ });
70
208
  if (projects.length === 0) {
71
209
  stripEl.style.display = '';
72
210
  controlsEl.replaceChildren(makeContextEmptyNode(), makeContextAddBtn());
@@ -84,7 +222,7 @@
84
222
  // deliberately do NOT fall back to projects[0] here (W-mqayzsj3) so CC
85
223
  // turns and project-scoped actions don't silently target the wrong
86
224
  // project.
87
- var pills = makeContextPills(projects, currentProject, displayByName);
225
+ var pills = makeContextPills(projects, currentProject, displayByName, projectByName);
88
226
  stripEl.style.display = '';
89
227
  controlsEl.replaceChildren(pills, makeContextAddBtn());
90
228
  pills.addEventListener('click', function(ev) {
@@ -576,6 +576,48 @@
576
576
  .chat-context-strip .context-pill.active:hover {
577
577
  background: var(--blue);
578
578
  }
579
+ /* Project-pill hover detail popover (parity with the classic Projects bar).
580
+ Fixed-position, pointer-events:none so it never traps the pointer or
581
+ focus — it's a passive tooltip dismissed on mouseleave/blur/Escape. */
582
+ .slim-pill-popover {
583
+ position: fixed;
584
+ z-index: 1200;
585
+ max-width: 320px;
586
+ pointer-events: none;
587
+ background: var(--surface2);
588
+ border: 1px solid var(--border);
589
+ border-radius: var(--radius, 8px);
590
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28);
591
+ padding: var(--space-2) var(--space-3);
592
+ font-size: var(--text-sm, 12px);
593
+ line-height: 1.4;
594
+ color: var(--text);
595
+ }
596
+ .slim-pill-popover[hidden] { display: none; }
597
+ .slim-pill-popover-header {
598
+ font-weight: 700;
599
+ color: var(--blue);
600
+ margin-bottom: 4px;
601
+ white-space: nowrap;
602
+ overflow: hidden;
603
+ text-overflow: ellipsis;
604
+ }
605
+ .slim-pill-popover-row {
606
+ display: flex;
607
+ gap: var(--space-2);
608
+ align-items: baseline;
609
+ }
610
+ .slim-pill-popover-label {
611
+ color: var(--muted);
612
+ flex: 0 0 auto;
613
+ min-width: 84px;
614
+ }
615
+ .slim-pill-popover-value {
616
+ color: var(--text);
617
+ word-break: break-word;
618
+ }
619
+ .slim-pill-popover-muted .slim-pill-popover-value { color: var(--muted); font-style: italic; }
620
+ .slim-pill-popover-warn .slim-pill-popover-value { color: var(--red); }
579
621
  .chat-context-strip .context-static {
580
622
  color: var(--text);
581
623
  font-weight: 500;
@@ -747,6 +789,14 @@
747
789
  display: block; width: 100%; height: calc(100vh - 96px); min-height: 420px;
748
790
  border: 0; background: var(--bg);
749
791
  }
792
+ /* Reusable embed host (W-ms3bbzry000i6612): the engine/queued/plans/prs
793
+ embed iframes are cached and kept mounted for an instant reopen, then
794
+ shown/hidden via the [hidden] attribute. The class rules above set
795
+ display:block, which outranks [hidden]'s UA display:none — so hidden
796
+ cached frames must be forced back to none. The id+type+attr selector
797
+ outranks the class rules (no !important needed). */
798
+ .slim-tile-embed-host, .slim-tile-transient { display: block; }
799
+ #slim-tile-embed-host iframe[hidden] { display: none; }
750
800
  /* Automation tile (Watches · Schedules · Pipelines) — a tabbed composite
751
801
  inside the shared wide tile-modal. The tab bar sits above the embedded
752
802
  classic screen, so the iframe height leaves room for it (vs the padless
@@ -8,7 +8,7 @@ const shared = require('./engine/shared');
8
8
  const { safeRead } = shared;
9
9
 
10
10
  const MINIONS_DIR = __dirname;
11
- const DASHBOARD_SHARED_JS = ['pr-merge-state', 'pr-filters', 'cc-suggestions', 'model-display', 'watches-source', 'welcome-popup'];
11
+ const DASHBOARD_SHARED_JS = ['pr-merge-state', 'pr-filters', 'cc-suggestions', 'model-display', 'watches-source', 'project-git-summary', 'welcome-popup'];
12
12
 
13
13
  function buildDashboardHtml() {
14
14
  const dashDir = path.join(MINIONS_DIR, 'dashboard');
package/dashboard.js CHANGED
@@ -3370,10 +3370,56 @@ async function _maxInputMtimeMs(inputs) {
3370
3370
  throw err;
3371
3371
  }
3372
3372
  }
3373
+ // ── Optional list pagination (W-ms3bbzry000i6612) ────────────────────────
3374
+ // Progressive-load foundation for large list endpoints (/api/work-items,
3375
+ // /api/pull-requests). Reads optional `limit`/`offset` query params; when
3376
+ // NEITHER is present the endpoint stays byte-identical to its legacy full-array
3377
+ // response (so /api/status count derivations and the classic 4s refresh loop
3378
+ // are unaffected). When present, the endpoint slices the SAME deterministically
3379
+ // -ordered, already-enriched+cached full list (guaranteeing `total` matches the
3380
+ // full response and pages never overlap or skip) and returns a paged envelope.
3381
+ //
3382
+ // Design note: we slice the cached enriched list at the endpoint rather than
3383
+ // pushing LIMIT/OFFSET into the per-scope stores because (a) the enriched list
3384
+ // is built once per ~1s and shared with /api/status, so store-level paging
3385
+ // would duplicate enrichment and risk diverging from the status counts, (b) the
3386
+ // cross-scope display order is post-enrichment (central + every project by
3387
+ // rowid), which per-scope SQL LIMIT/OFFSET cannot compose, and (c) slicing the
3388
+ // deterministic cached array is O(page) to serialize — the real transfer win.
3389
+ const _LIST_PAGE_MAX = 1000;
3390
+ function _parsePageParams(req) {
3391
+ const params = new URL((req && req.url) || '/', 'http://localhost').searchParams;
3392
+ const hasLimit = params.has('limit');
3393
+ const hasOffset = params.has('offset');
3394
+ if (!hasLimit && !hasOffset) return null; // legacy full-list path
3395
+ const rawLimit = Number(params.get('limit'));
3396
+ const rawOffset = Number(params.get('offset'));
3397
+ const limit = hasLimit && Number.isFinite(rawLimit)
3398
+ ? Math.max(1, Math.min(_LIST_PAGE_MAX, Math.floor(rawLimit)))
3399
+ : 50;
3400
+ const offset = hasOffset && Number.isFinite(rawOffset) ? Math.max(0, Math.floor(rawOffset)) : 0;
3401
+ return { limit, offset };
3402
+ }
3403
+ function _paginateList(list, page) {
3404
+ const arr = Array.isArray(list) ? list : [];
3405
+ const total = arr.length;
3406
+ const offset = Math.max(0, page.offset | 0);
3407
+ const limit = Math.max(1, page.limit | 0);
3408
+ const items = arr.slice(offset, offset + limit);
3409
+ return { items, total, offset, limit, hasMore: offset + items.length < total };
3410
+ }
3411
+
3373
3412
  async function serveFreshJson(req, res, opts) {
3374
3413
  const inputs = Array.isArray(opts && opts.inputs) ? opts.inputs : [];
3375
3414
  const builder = opts && typeof opts.builder === 'function' ? opts.builder : null;
3376
3415
  const tag = opts && opts.tag ? String(opts.tag) : 'v';
3416
+ // Optional response variant discriminator (e.g. pagination window). Folded
3417
+ // into the ETag so a paged request never gets served the full-list cache and
3418
+ // vice versa. Empty string keeps the ETag byte-identical to the legacy value,
3419
+ // so no-variant callers are unaffected (W-ms3bbzry000i6612).
3420
+ const variant = opts && opts.variant ? String(opts.variant) : '';
3421
+ // Optional post-builder transform (e.g. slice a page out of the full list).
3422
+ const transform = opts && typeof opts.transform === 'function' ? opts.transform : null;
3377
3423
  if (!builder) {
3378
3424
  res.statusCode = 500;
3379
3425
  res.setHeader('Content-Type', 'application/json');
@@ -3382,7 +3428,7 @@ async function serveFreshJson(req, res, opts) {
3382
3428
  }
3383
3429
  const mtime = await _maxInputMtimeMs(inputs);
3384
3430
  const eventVersion = _getCurrentEventVersion();
3385
- const etag = '"' + tag + '-' + mtime + '-' + eventVersion + '"';
3431
+ const etag = '"' + tag + (variant ? '-' + variant : '') + '-' + mtime + '-' + eventVersion + '"';
3386
3432
  res.setHeader('ETag', etag);
3387
3433
  res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
3388
3434
  if (req && req.headers && req.headers['if-none-match'] === etag) {
@@ -3393,6 +3439,7 @@ async function serveFreshJson(req, res, opts) {
3393
3439
  let payload;
3394
3440
  try {
3395
3441
  payload = builder();
3442
+ if (transform) payload = transform(payload);
3396
3443
  } catch (e) {
3397
3444
  res.statusCode = 500;
3398
3445
  res.setHeader('Content-Type', 'application/json');
@@ -14568,22 +14615,28 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14568
14615
  builder: () => getAgents(),
14569
14616
  });
14570
14617
  }},
14571
- { method: 'GET', path: '/api/work-items', desc: 'Fully-enriched SQL-backed work items joined with dispatch/PR state — fresh on every request; description hard-capped + acceptanceCriteria/references replaced with *Count integers (W-mq5xg5e9000nec0e); detail modal lazy-loads the full record via GET /api/work-items/<id>', handler: (req, res) => {
14618
+ { method: 'GET', path: '/api/work-items', desc: 'Fully-enriched SQL-backed work items joined with dispatch/PR state — fresh on every request; description hard-capped + acceptanceCriteria/references replaced with *Count integers (W-mq5xg5e9000nec0e); detail modal lazy-loads the full record via GET /api/work-items/<id>. Optional ?limit=&offset= returns a { items, total, hasMore, offset, limit } page (progressive load, W-ms3bbzry000i6612); with no paging params the full array is byte-identical to legacy.', handler: (req, res) => {
14619
+ const page = _parsePageParams(req);
14572
14620
  return serveFreshJson(req, res, {
14573
14621
  tag: 'work-items',
14574
14622
  inputs: [CONFIG_PATH],
14623
+ variant: page ? ('p' + page.offset + '.' + page.limit) : '',
14575
14624
  // W-mq5xg5e9000nec0e — Slim every item before stringify so the polled
14576
14625
  // refresh-loop payload stays < ~300 KB even when description fields
14577
14626
  // include 100+ KB transcripts. slimWorkItemForList shallow-copies so
14578
14627
  // queries.getWorkItems()'s in-memory cache is never mutated.
14579
14628
  builder: () => getWorkItems().map(slimWorkItemForList),
14629
+ transform: page ? (list) => _paginateList(list, page) : null,
14580
14630
  });
14581
14631
  }},
14582
- { method: 'GET', path: '/api/pull-requests', desc: 'Fully-enriched SQL-backed pull requests with URL backfill and project stamps', handler: (req, res) => {
14632
+ { method: 'GET', path: '/api/pull-requests', desc: 'Fully-enriched SQL-backed pull requests with URL backfill and project stamps. Optional ?limit=&offset= returns a { items, total, hasMore, offset, limit } page (progressive load, W-ms3bbzry000i6612); with no paging params the full array is byte-identical to legacy.', handler: (req, res) => {
14633
+ const page = _parsePageParams(req);
14583
14634
  return serveFreshJson(req, res, {
14584
14635
  tag: 'pull-requests',
14585
14636
  inputs: [CONFIG_PATH],
14637
+ variant: page ? ('p' + page.offset + '.' + page.limit) : '',
14586
14638
  builder: () => queries.getPullRequests(),
14639
+ transform: page ? (list) => _paginateList(list, page) : null,
14587
14640
  });
14588
14641
  }},
14589
14642
  // GET /api/prs/<id> — single fully-enriched PR record by canonical id
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2431",
3
+ "version": "0.1.2433",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"