@yemi33/minions 0.1.2432 → 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;
@@ -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 =
@@ -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
 
@@ -789,6 +789,14 @@
789
789
  display: block; width: 100%; height: calc(100vh - 96px); min-height: 420px;
790
790
  border: 0; background: var(--bg);
791
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; }
792
800
  /* Automation tile (Watches · Schedules · Pipelines) — a tabbed composite
793
801
  inside the shared wide tile-modal. The tab bar sits above the embedded
794
802
  classic screen, so the iframe height leaves room for it (vs the padless
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.2432",
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"