@yemi33/minions 0.1.2454 → 0.1.2455

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 (41) hide show
  1. package/bin/install-internal-minions.js +136 -6
  2. package/bin/minions.js +27 -13
  3. package/dashboard/js/agent-identity.js +121 -0
  4. package/dashboard/js/modal.js +4 -0
  5. package/dashboard/js/refresh.js +38 -3
  6. package/dashboard/js/render-agents.js +14 -2
  7. package/dashboard/js/render-dispatch.js +2 -2
  8. package/dashboard/js/render-other.js +147 -13
  9. package/dashboard/js/render-prd.js +6 -5
  10. package/dashboard/js/render-prs.js +1 -1
  11. package/dashboard/js/render-work-items.js +436 -21
  12. package/dashboard/js/utils.js +40 -0
  13. package/dashboard/pages/tools.html +1 -0
  14. package/dashboard/pages/work.html +13 -0
  15. package/dashboard/styles.css +73 -0
  16. package/dashboard-build.js +2 -2
  17. package/dashboard.js +93 -7
  18. package/docs/README.md +1 -0
  19. package/docs/copilot-cli-schema.md +1 -0
  20. package/docs/engine-restart.md +4 -2
  21. package/docs/internal-install.md +44 -5
  22. package/docs/named-agents.md +48 -0
  23. package/docs/runtime-adapters.md +39 -0
  24. package/docs/temporary-agents.md +172 -0
  25. package/engine/ado/comment.js +261 -4
  26. package/engine/agents/llm.js +26 -0
  27. package/engine/agents/playbook.js +2 -1
  28. package/engine/core/queries.js +27 -2
  29. package/engine/core/shared.js +148 -1
  30. package/engine/operations/cli.js +102 -1
  31. package/engine/orchestration/lifecycle.js +7 -0
  32. package/engine/orchestration/routing.js +4 -1
  33. package/engine/providers/gh-comment.js +159 -0
  34. package/engine/recovery/stop-stack.js +16 -2
  35. package/engine/runtimes/claude.js +3 -0
  36. package/engine/runtimes/codex.js +4 -0
  37. package/engine/runtimes/copilot.js +23 -2
  38. package/engine.js +2 -2
  39. package/package.json +1 -1
  40. package/playbooks/fix.md +23 -2
  41. package/playbooks/shared-rules.md +24 -2
@@ -6,6 +6,66 @@ let wiPage = 0;
6
6
  let wiFilterOptionsSignature = '';
7
7
  const WI_PER_PAGE = 20;
8
8
 
9
+ // ── Bulk-operations mode (W-mscjvm33007r79ef) ─────────────────────────────
10
+ // OFF by default: the normal Work Items UI shows no selection affordances and
11
+ // cannot multi-select. An explicit "Bulk select" control flips wiBulkMode on,
12
+ // which renders per-row checkboxes + a bulk action bar. wiSelectedIds tracks
13
+ // the selection by work-item id so it stays coherent across the 4s refresh
14
+ // poll, filtering, paging, and item state changes — bulk actions resolve ids
15
+ // to LIVE items at apply time and skip any that vanished (never act on stale
16
+ // or nonexistent items).
17
+ let wiBulkMode = false;
18
+ const wiSelectedIds = new Set();
19
+
20
+ // Bulk actions reuse the EXISTING single-item POST endpoints and their exact
21
+ // availability rules, so bulk never broadens or invents an action. `statuses:
22
+ // null` means "any status" (Delete). Each spec drives eligibility, the
23
+ // endpoint, the confirm gate, and whether the row is optimistically removed.
24
+ //
25
+ // `destructive` IS the bulk confirmation policy (it is the only gate on the
26
+ // single confirm() in wiBulkAction), so it must mirror whether the single-item
27
+ // counterpart confirms: cancelWorkItem/deleteWorkItem confirm, so bulk Cancel
28
+ // and Delete confirm too. A bulk action multiplies the blast radius, so it can
29
+ // never be LESS guarded than acting on one item. Cancel is destructive because
30
+ // the server kills the running agent (handleWorkItemsCancel) and there is no
31
+ // undo — retry only accepts `failed`, so a cancelled `dispatched` item cannot
32
+ // be restored. Parity is enforced by test/unit/dashboard-work-items-bulk-actions.test.js.
33
+ const WI_BULK_ACTIONS = {
34
+ cancel: { label: 'Cancel', endpoint: '/api/work-items/cancel', statuses: ['pending', 'dispatched', 'queued', 'failed'], destructive: true, optimisticRemove: true, wake: false },
35
+ archive: { label: 'Archive', endpoint: '/api/work-items/archive', statuses: ['done', 'failed'], destructive: false, optimisticRemove: true, wake: false },
36
+ retry: { label: 'Retry', endpoint: '/api/work-items/retry', statuses: ['failed'], destructive: false, optimisticRemove: false, wake: true },
37
+ delete: { label: 'Delete', endpoint: '/api/work-items/delete', statuses: null, destructive: true, optimisticRemove: true, wake: false },
38
+ };
39
+
40
+ // Pure eligibility predicate — mirrors the per-item button visibility in
41
+ // wiRow() so a bulk action is only offered for the same statuses. Exported for
42
+ // unit testing (no DOM deps).
43
+ function wiActionEligible(item, action) {
44
+ const spec = WI_BULK_ACTIONS[action];
45
+ if (!spec || !item) return false;
46
+ if (spec.statuses === null) return true;
47
+ return spec.statuses.indexOf(item.status || 'pending') !== -1;
48
+ }
49
+
50
+ // Pure planner — resolves a selection (ids) against the LIVE item list and
51
+ // splits it into { eligible, ineligible, missing } for a given action. This is
52
+ // the anti-stale core: an id no longer present in `items` lands in `missing`
53
+ // and is never acted on. Exported for unit testing.
54
+ function wiBulkPlan(items, selectedIds, action) {
55
+ const idSet = selectedIds instanceof Set ? selectedIds : new Set(selectedIds || []);
56
+ const byId = new Map((items || []).map(function(i) { return [i.id, i]; }));
57
+ const eligible = [];
58
+ const ineligible = [];
59
+ const missing = [];
60
+ idSet.forEach(function(id) {
61
+ const it = byId.get(id);
62
+ if (!it) { missing.push(id); return; }
63
+ if (wiActionEligible(it, action)) eligible.push(it);
64
+ else ineligible.push(it);
65
+ });
66
+ return { eligible: eligible, ineligible: ineligible, missing: missing };
67
+ }
68
+
9
69
  // Filter + sort controls modeled on the PR table (dashboard/js/render-prs.js).
10
70
  // Each dimension maps to a <select> in dashboard/pages/work.html; options are
11
71
  // populated dynamically from the current data set with a signature guard so the
@@ -184,7 +244,7 @@ function wiRetryBtn(item) {
184
244
  return '<button class="pr-pager-btn" style="font-size:var(--text-sm);padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-left:4px" onclick="event.stopPropagation();retryWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')">Retry</button>';
185
245
  }
186
246
 
187
- function wiRow(item) {
247
+ function wiRow(item, bulk) {
188
248
  const statusBadge = (s) => {
189
249
  const cls = s === 'failed' ? 'rejected' : s === 'dispatched' ? 'building' : s === 'pending' || s === 'queued' ? 'active' : s === 'done' ? 'approved' : s === 'decomposed' ? 'approved' : 'draft';
190
250
  return '<span class="pr-badge ' + cls + '">' + escapeHtml(s) + '</span>';
@@ -217,7 +277,15 @@ function wiRow(item) {
217
277
  : '';
218
278
  followupChip = ' <a class="pr-badge draft" style="font-size:var(--text-sm);text-decoration:none" target="_blank" rel="noopener" href="' + escapeHtml(prFollowup.parent_pr_url) + '" title="Follow-up dispatched from ' + escapeHtml(prRef) + (followupBy ? ' by ' + escapeHtml(followupBy) : '') + '" onclick="event.stopPropagation()">&#8617; from ' + escapeHtml(prLabel) + '</a>';
219
279
  }
220
- return '<tr data-wi-id="' + escapeHtml(item.id) + '" style="cursor:pointer" onclick="if(shouldIgnoreSelectionClick(event))return;openWorkItemDetail(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')">' +
280
+ var _wiSelected = bulk && wiSelectedIds.has(item.id);
281
+ var bulkCell = bulk
282
+ ? '<td class="wi-bulk-cell"><input type="checkbox" class="wi-bulk-cb" data-wi-id="' + escapeHtml(item.id) + '" data-wi-source="' + escapeHtml(item._source || '') + '" aria-label="Select ' + escapeHtml((item.title || item.id || '').slice(0, 80)) + '"' + (_wiSelected ? ' checked' : '') + ' onclick="event.stopPropagation();wiToggleSelect(this)"></td>'
283
+ : '';
284
+ // The click handler receives the SAME `bulk` flag the row was rendered with
285
+ // (see wiRowClick) — a row drawn without a checkbox must never toggle a
286
+ // selection the operator cannot see.
287
+ return '<tr data-wi-id="' + escapeHtml(item.id) + '"' + (_wiSelected ? ' class="wi-row-selected"' : '') + ' style="cursor:pointer" onclick="wiRowClick(event,\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\',' + (bulk ? 'true' : 'false') + ')">' +
288
+ bulkCell +
221
289
  '<td style="min-width:280px;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escapeHtml((item.title || '').slice(0, 200)) + '">' + escapeHtml(item.title || '') + followupChip + '</td>' +
222
290
  '<td><span style="font-size:var(--text-sm);color:var(--muted)">' + escapeHtml(item._source || '') + '</span>' +
223
291
  (item.scope === 'fan-out' ? ' <span class="pr-badge ' + (item.status === 'done' || item.status === 'failed' ? 'draft' : 'building') + '" style="font-size:var(--text-sm)">fan-out</span>' : '') + '</td>' +
@@ -250,8 +318,8 @@ function wiRow(item) {
250
318
  '</td>' +
251
319
  '<td>' +
252
320
  (item.completedAgents && item.completedAgents.length > 0
253
- ? '<span class="pr-agent">' + escapeHtml(item.completedAgents.join(', ')) + '</span>'
254
- : '<span class="pr-agent">' + escapeHtml(item.dispatched_to || item.agent || '—') + '</span>') +
321
+ ? '<span class="pr-agent">' + item.completedAgents.map(function(a) { return MinionsAgentIdentity.nameWithBadgeHtml({ id: a }); }).join(', ') + '</span>'
322
+ : '<span class="pr-agent">' + ((item.dispatched_to || item.agent) ? MinionsAgentIdentity.nameWithBadgeHtml({ id: item.dispatched_to || item.agent }) : '&mdash;') + '</span>') +
255
323
  '</td>' +
256
324
  '<td>' + prLink + (failSnippet ? failSnippet : '') + '</td>' +
257
325
  '<td><span class="pr-date">' + escapeHtml((item.created || '').slice(0, 16).replace('T', ' ')) + '</span></td>' +
@@ -272,15 +340,18 @@ function wiRow(item) {
272
340
  (acCount ? '<span title="' + acCount + ' acceptance criteria">&#x2611;' + acCount + '</span>' : '');
273
341
  })() +
274
342
  '</td>' +
275
- '<td style="white-space:nowrap;text-align:right">' +
276
- // W-mqqa76pi — "View live output" opens the agent slide-in panel (live-output
277
- // tab auto-selected when the agent is working). Only for genuinely active
278
- // dispatches with a resolvable assigned agent id.
279
- ((item.status === 'dispatched' && (item.dispatched_to || item.agent)) ? '<button class="pr-pager-btn" style="font-size:var(--text-sm);padding:1px 6px;color:var(--green);border-color:var(--green);margin-right:4px" onclick="event.stopPropagation();openArtifact(\'agent\',\'' + escapeHtml(item.dispatched_to || item.agent) + '\')" title="View live output">&#x1F4FA;</button>' : '') +
280
- ((item.status === 'pending' || item.status === 'failed') ? '<button class="pr-pager-btn" style="font-size:var(--text-sm);padding:1px 6px;color:var(--blue);border-color:var(--blue);margin-right:4px" onclick="event.stopPropagation();editWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Edit work item">&#x270E;</button>' : '') +
281
- ((item.status === 'done' || item.status === 'failed') ? '<button class="pr-pager-btn" style="font-size:var(--text-sm);padding:1px 6px;color:var(--muted);border-color:var(--muted);margin-right:4px" onclick="event.stopPropagation();archiveWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Archive work item">&#x1F4E6;</button>' : '') +
282
- ((item.status === 'done' || item.status === 'failed') && !item._humanFeedback ? '<button class="btn-action" style="margin-right:4px" onclick="event.stopPropagation();feedbackWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Give feedback">&#x1F44D;&#x1F44E;</button>' : (item._humanFeedback ? '<span style="font-size:var(--text-sm)" title="Feedback given">' + (item._humanFeedback.rating === 'up' ? '&#x1F44D;' : '&#x1F44E;') + '</span> ' : '')) +
283
- ((item.status === 'pending' || item.status === 'dispatched' || item.status === 'queued' || item.status === 'failed') ? '<button class="pr-pager-btn" style="font-size:var(--text-sm);padding:1px 6px;color:var(--orange);border-color:var(--orange);margin-right:4px" onclick="event.stopPropagation();cancelWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Cancel work item and kill agent">&#x1F6AB;</button>' : '') +
343
+ '<td class="wi-actions-cell" style="white-space:nowrap;text-align:right">' +
344
+ // W-mscjvm33007r79ef — every per-item action EXCEPT Delete now lives in an
345
+ // overflow menu (wiOpenOverflowMenu), built on demand from the live item so
346
+ // it always reflects current availability/disabled states. The ⋯ button is
347
+ // omitted when the item has no eligible menu action (see
348
+ // _wiOverflowActionsFor), so an actionless item shows only Delete.
349
+ // The "feedback given" indicator is informational (not an action) and is
350
+ // preserved inline so operators still see prior feedback at a glance.
351
+ (item._humanFeedback ? '<span style="font-size:var(--text-sm);margin-right:4px" title="Feedback given">' + (item._humanFeedback.rating === 'up' ? '&#x1F44D;' : '&#x1F44E;') + '</span>' : '') +
352
+ (_wiOverflowActionsFor(item).length
353
+ ? '<button class="wi-overflow-btn pr-pager-btn" style="font-size:var(--text-sm);padding:1px 8px;margin-right:4px" data-wi-id="' + escapeHtml(item.id) + '" data-wi-source="' + escapeHtml(item._source || '') + '" aria-haspopup="menu" aria-expanded="false" aria-label="More actions for ' + escapeHtml((item.title || item.id || '').slice(0, 80)) + '" title="More actions" onclick="event.stopPropagation();wiOpenOverflowMenu(this)">&#x22EF;</button>'
354
+ : '') +
284
355
  '<button class="btn-destructive" onclick="event.stopPropagation();deleteWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Delete work item and kill agent">&#x2715;</button>' +
285
356
  '</td>' +
286
357
  '</tr>';
@@ -432,6 +503,9 @@ function renderWorkItems(items, opts) {
432
503
  const view = computed.view;
433
504
  filteredWorkItems = view;
434
505
  const el = document.getElementById('work-items-content');
506
+ // Every return path below replaces el.innerHTML, so drop any overflow menu
507
+ // anchored inside it first (see _wiCloseOverflowMenuIn).
508
+ _wiCloseOverflowMenuIn(el);
435
509
  const countEl = document.getElementById('wi-count');
436
510
  const summaryEl = document.getElementById('wi-filter-summary');
437
511
  if (countEl) countEl.textContent = view.length;
@@ -442,12 +516,15 @@ function renderWorkItems(items, opts) {
442
516
  }
443
517
  if (!allWorkItems.length) {
444
518
  wiPage = 0;
519
+ if (wiSelectedIds.size) wiSelectedIds.clear();
445
520
  el.innerHTML = '<p class="empty">No work items. Add tasks via Command Center above.</p>';
521
+ _wiSyncBulkUI();
446
522
  return;
447
523
  }
448
524
  if (!view.length) {
449
525
  wiPage = 0;
450
526
  el.innerHTML = '<p class="empty">No work items match the selected filters.</p>';
527
+ _wiSyncBulkUI();
451
528
  return;
452
529
  }
453
530
 
@@ -457,8 +534,21 @@ function renderWorkItems(items, opts) {
457
534
  const start = wiPage * WI_PER_PAGE;
458
535
  const pageItems = view.slice(start, start + WI_PER_PAGE);
459
536
 
460
- let html = '<div class="pr-table-wrap"><table class="pr-table"><thead><tr><th>Title</th><th>Project</th><th>Type</th><th>Priority</th><th>Status</th><th>Agent</th><th>PR</th><th>Created</th><th>Updated</th><th></th><th></th></tr></thead><tbody>';
461
- html += pageItems.map(wiRow).join('');
537
+ // Keep the bulk selection coherent as items appear/disappear across refreshes,
538
+ // filtering, and paging: drop any selected id that no longer exists in the
539
+ // full list. Selection is by id (not row index), so it survives re-sorts and
540
+ // page changes; bulk actions still re-resolve to live items at apply time.
541
+ if (wiSelectedIds.size) {
542
+ const liveIds = new Set(allWorkItems.map(function(w) { return w.id; }));
543
+ wiSelectedIds.forEach(function(id) { if (!liveIds.has(id)) wiSelectedIds.delete(id); });
544
+ }
545
+
546
+ const bulkHeadCell = wiBulkMode ? '<th class="wi-bulk-cell"></th>' : '';
547
+ let html = '<div class="pr-table-wrap"><table class="pr-table"><thead><tr>' + bulkHeadCell + '<th>Title</th><th>Project</th><th>Type</th><th>Priority</th><th>Status</th><th>Agent</th><th>PR</th><th>Created</th><th>Updated</th><th></th><th></th></tr></thead><tbody>';
548
+ // Wrap so wiRow gets (item, wiBulkMode) — NOT (item, index, array) from a bare
549
+ // .map(wiRow), which would pass a truthy index as `bulk` for every row after
550
+ // the first and render stray checkboxes.
551
+ html += pageItems.map(function(it) { return wiRow(it, wiBulkMode); }).join('');
462
552
  html += '</tbody></table></div>';
463
553
 
464
554
  if (view.length > WI_PER_PAGE) {
@@ -478,6 +568,7 @@ function renderWorkItems(items, opts) {
478
568
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() by wiRow() (fields: work item id/title/source/status/agent/PR links/dates/follow-up metadata)
479
569
  el.innerHTML = html;
480
570
  restoreDashboardScrollState(el, scrollState);
571
+ _wiSyncBulkUI();
481
572
  }
482
573
 
483
574
  async function editWorkItem(id, source) {
@@ -686,7 +777,7 @@ async function toggleWorkItemArchive() {
686
777
  '<td style="min-width:240px;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escapeHtml(i.title || '') + '</td>' +
687
778
  '<td><span class="dispatch-type ' + (i.type || '') + '">' + escapeHtml(i.type || '') + '</span></td>' +
688
779
  '<td style="color:' + (i.status === 'done' ? 'var(--green)' : 'var(--red)') + '">' + escapeHtml(i.status || '') + '</td>' +
689
- '<td>' + escapeHtml(i.dispatched_to || '—') + '</td>' +
780
+ '<td>' + (i.dispatched_to ? MinionsAgentIdentity.nameWithBadgeHtml({ id: i.dispatched_to }) : '&mdash;') + '</td>' +
690
781
  '<td class="pr-date">' + shortTime(i.archivedAt) + '</td>' +
691
782
  '</tr>';
692
783
  }).join('') + '</tbody></table></div>';
@@ -977,7 +1068,7 @@ function _wiRenderDetail(item) {
977
1068
  _descHtml = escapeHtml(item.title || '—');
978
1069
  }
979
1070
  html += field('Description', '<div id="wi-detail-desc" style="font-size:var(--text-md);max-height:320px;overflow-y:auto;padding:8px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm)">' + _descHtml + '</div>');
980
- html += field('Agent', escapeHtml(item.dispatched_to || item.agent || 'Auto'));
1071
+ html += field('Agent', (item.dispatched_to || item.agent) ? MinionsAgentIdentity.nameWithBadgeHtml({ id: item.dispatched_to || item.agent }) : escapeHtml('Auto'));
981
1072
  // W-ms0ytfdd000d5a26 — the model this work item actually ran on. Resolved
982
1073
  // server-side (engine/core/queries.js#resolveWorkItemModelInfo) so persisted
983
1074
  // dispatch evidence — what the runtime reported, else what Minions requested
@@ -1003,7 +1094,7 @@ function _wiRenderDetail(item) {
1003
1094
  html += field('Working directory', '<code style="font-size:var(--text-sm);background:var(--surface2);padding:2px 6px;border-radius:var(--radius-sm)">' + escapeHtml(item.meta.workdir) + '</code>');
1004
1095
  }
1005
1096
  if (item.created) html += field('Created', escapeHtml(formatLocalDateTime(item.created)));
1006
- if (item.dispatched_at) html += field('Dispatched', escapeHtml(formatLocalDateTime(item.dispatched_at)) + ' to ' + escapeHtml(item.dispatched_to || '?'));
1097
+ if (item.dispatched_at) html += field('Dispatched', escapeHtml(formatLocalDateTime(item.dispatched_at)) + ' to ' + (item.dispatched_to ? MinionsAgentIdentity.nameWithBadgeHtml({ id: item.dispatched_to }) : escapeHtml('?')));
1007
1098
  if (item.completedAt) html += field('Completed', escapeHtml(formatLocalDateTime(item.completedAt)));
1008
1099
  if (item.failReason) html += field('Failure Reason', '<span style="color:var(--red)">' + escapeHtml(item.failReason) + '</span>');
1009
1100
  // W-mrtdet41000h935a — surface the cancellation reason for CANCELLED items.
@@ -1294,9 +1385,13 @@ function openWorkItemDetail(id, source) {
1294
1385
  }
1295
1386
 
1296
1387
  function openAllWorkItems() {
1388
+ // Re-opening the modal replaces its rows; defence-in-depth so the menu can
1389
+ // never outlive its anchor even when the outside-click dismissal is bypassed
1390
+ // (e.g. a programmatic open).
1391
+ _wiCloseOverflowMenuIn(document.getElementById('modal-body'));
1297
1392
  document.getElementById('modal-title').textContent = 'All Work Items (' + filteredWorkItems.length + ')';
1298
1393
  const html = '<div class="pr-table-wrap"><table class="pr-table"><thead><tr><th>Title</th><th>Project</th><th>Type</th><th>Priority</th><th>Status</th><th>Agent</th><th>PR</th><th>Created</th><th>Updated</th><th></th><th></th></tr></thead><tbody>' +
1299
- filteredWorkItems.map(wiRow).join('') + '</tbody></table></div>';
1394
+ filteredWorkItems.map(function(it) { return wiRow(it, false); }).join('') + '</tbody></table></div>';
1300
1395
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() by wiRow() (fields: work item id/title/source/status/agent/PR links/dates/follow-up metadata)
1301
1396
  document.getElementById('modal-body').innerHTML = html;
1302
1397
  document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
@@ -1422,10 +1517,330 @@ function _wiInjectSourceChipFromFrontmatter(content, containerId) {
1422
1517
  bodyEl.insertAdjacentHTML('afterbegin', sourceBlock);
1423
1518
  }
1424
1519
 
1520
+ // ════════════════════════════════════════════════════════════════════════
1521
+ // Overflow (⋯) menu + bulk-operations mode — W-mscjvm33007r79ef
1522
+ // ════════════════════════════════════════════════════════════════════════
1523
+
1524
+ // Actions offered in a row's overflow menu, derived from the LIVE item so the
1525
+ // menu always matches current status. Each entry invokes the existing per-item
1526
+ // action fn, preserving its confirmation/optimistic/toast/error behavior. Used
1527
+ // both to build the menu and (via .length) to decide whether to render the ⋯
1528
+ // button at all. Also drives the source-inspection guarantee that Delete is NOT
1529
+ // in here (Delete stays a directly-visible button).
1530
+ function _wiOverflowActionsFor(item) {
1531
+ if (!item) return [];
1532
+ var src = item._source || '';
1533
+ var st = item.status;
1534
+ var agentId = item.dispatched_to || item.agent;
1535
+ var out = [];
1536
+ if (st === 'dispatched' && agentId) {
1537
+ out.push({ label: 'View live output', fn: function() { openArtifact('agent', agentId); } });
1538
+ }
1539
+ if (st === 'pending' || st === 'failed') {
1540
+ out.push({ label: 'Edit', fn: function() { editWorkItem(item.id, src); } });
1541
+ }
1542
+ if (st === 'done' || st === 'failed') {
1543
+ out.push({ label: 'Archive', fn: function() { archiveWorkItem(item.id, src); } });
1544
+ }
1545
+ if ((st === 'done' || st === 'failed') && !item._humanFeedback) {
1546
+ out.push({ label: 'Give feedback', fn: function() { feedbackWorkItem(item.id, src); } });
1547
+ }
1548
+ if (st === 'pending' || st === 'dispatched' || st === 'queued' || st === 'failed') {
1549
+ out.push({ label: 'Cancel', fn: function() { cancelWorkItem(item.id, src); } });
1550
+ }
1551
+ return out;
1552
+ }
1553
+
1554
+ let _wiOverflowEl = null;
1555
+ let _wiOverflowAnchor = null;
1556
+
1557
+ // Accessible popover built with pure DOM construction (createElement /
1558
+ // textContent — never innerHTML), mirroring _openCheckoutModeMenu in
1559
+ // render-other.js so it sidesteps the XSS lint gate. role=menu + menuitem,
1560
+ // arrow-key navigation, Escape/outside-click dismissal, focus management.
1561
+ function wiOpenOverflowMenu(btn) {
1562
+ if (_wiOverflowAnchor === btn) { wiCloseOverflowMenu(true); return; }
1563
+ wiCloseOverflowMenu(false);
1564
+ var id = btn.getAttribute('data-wi-id');
1565
+ var src = btn.getAttribute('data-wi-source') || '';
1566
+ var item = allWorkItems.find(function(w) { return w.id === id && (!src || (w._source || '') === src); });
1567
+ var actions = _wiOverflowActionsFor(item);
1568
+ if (!actions.length) return;
1569
+ var menu = document.createElement('div');
1570
+ menu.className = 'wi-overflow-menu';
1571
+ menu.setAttribute('role', 'menu');
1572
+ menu.setAttribute('aria-label', 'Work item actions');
1573
+ actions.forEach(function(a) {
1574
+ var mi = document.createElement('button');
1575
+ mi.type = 'button';
1576
+ mi.className = 'wi-overflow-item';
1577
+ mi.setAttribute('role', 'menuitem');
1578
+ mi.tabIndex = -1;
1579
+ mi.textContent = a.label;
1580
+ mi.addEventListener('click', function(ev) {
1581
+ ev.stopPropagation();
1582
+ wiCloseOverflowMenu(true);
1583
+ a.fn();
1584
+ });
1585
+ menu.appendChild(mi);
1586
+ });
1587
+ document.body.appendChild(menu);
1588
+ // Position after append so offsetWidth is known; right-align under the button.
1589
+ var r = btn.getBoundingClientRect();
1590
+ var top = r.bottom + window.scrollY + 2;
1591
+ var left = r.right + window.scrollX - menu.offsetWidth;
1592
+ if (left < 4) left = 4;
1593
+ menu.style.top = top + 'px';
1594
+ menu.style.left = left + 'px';
1595
+ btn.setAttribute('aria-expanded', 'true');
1596
+ _wiOverflowEl = menu;
1597
+ _wiOverflowAnchor = btn;
1598
+ // Defer listener attach so the opening click doesn't immediately close it.
1599
+ setTimeout(function() {
1600
+ document.addEventListener('mousedown', _wiOverflowOutside, true);
1601
+ document.addEventListener('keydown', _wiOverflowKeydown, true);
1602
+ }, 0);
1603
+ var first = menu.querySelector('.wi-overflow-item');
1604
+ if (first) first.focus();
1605
+ }
1606
+
1607
+ function wiCloseOverflowMenu(returnFocus) {
1608
+ var anchor = _wiOverflowAnchor;
1609
+ if (_wiOverflowEl && _wiOverflowEl.parentNode) _wiOverflowEl.parentNode.removeChild(_wiOverflowEl);
1610
+ if (anchor) anchor.setAttribute('aria-expanded', 'false');
1611
+ _wiOverflowEl = null;
1612
+ _wiOverflowAnchor = null;
1613
+ document.removeEventListener('mousedown', _wiOverflowOutside, true);
1614
+ document.removeEventListener('keydown', _wiOverflowKeydown, true);
1615
+ if (returnFocus && anchor) { try { anchor.focus(); } catch (e) { /* anchor gone after re-render */ } }
1616
+ }
1617
+
1618
+ function _wiOverflowOutside(e) {
1619
+ if (_wiOverflowEl && !_wiOverflowEl.contains(e.target) && e.target !== _wiOverflowAnchor) {
1620
+ wiCloseOverflowMenu(false);
1621
+ }
1622
+ }
1623
+
1624
+ // The menu is appended to document.body but is ANCHORED to a ⋯ button living
1625
+ // inside a container that gets re-rendered wholesale — the work-items table on
1626
+ // every non-304 /api/work-items poll, the all-items modal on every open.
1627
+ // Replacing that container's innerHTML destroys the anchor and strands the
1628
+ // menu, which keeps the action closures built from the item as it looked when
1629
+ // the menu opened: a row that has since flipped dispatched → done would still
1630
+ // offer Cancel, breaking the "menu always reflects current availability"
1631
+ // invariant. Close it BEFORE the swap, scoped to the container actually being
1632
+ // replaced so a re-render of one surface cannot dismiss a menu opened from the
1633
+ // other. Pass returnFocus=false — a background poll must not yank focus to an
1634
+ // anchor that is about to vanish.
1635
+ function _wiCloseOverflowMenuIn(container) {
1636
+ if (container && _wiOverflowAnchor && container.contains(_wiOverflowAnchor)) {
1637
+ wiCloseOverflowMenu(false);
1638
+ }
1639
+ }
1640
+
1641
+ // The modal is torn down by paths this module never sees — browser Back /
1642
+ // Alt+Left (popstate), sidebar navigation, a frame pop — and none of them fire
1643
+ // the mousedown/keydown dismissal above. Opt the menu into the shared modal
1644
+ // teardown so a menu opened from a row inside the "See all" modal can't outlive
1645
+ // it. Registering the SCOPED closer keeps a menu anchored outside the modal
1646
+ // unaffected. typeof-guarded: this module is also require()d by unit tests.
1647
+ if (typeof registerTransientPopupCloser === 'function') registerTransientPopupCloser(_wiCloseOverflowMenuIn);
1648
+
1649
+ function _wiOverflowKeydown(e) {
1650
+ if (!_wiOverflowEl) return;
1651
+ var items = Array.prototype.slice.call(_wiOverflowEl.querySelectorAll('.wi-overflow-item'));
1652
+ if (!items.length) return;
1653
+ var idx = items.indexOf(document.activeElement);
1654
+ if (e.key === 'Escape') {
1655
+ e.preventDefault();
1656
+ wiCloseOverflowMenu(true);
1657
+ } else if (e.key === 'ArrowDown') {
1658
+ e.preventDefault();
1659
+ items[(idx + 1) % items.length].focus();
1660
+ } else if (e.key === 'ArrowUp') {
1661
+ e.preventDefault();
1662
+ items[(idx - 1 + items.length) % items.length].focus();
1663
+ } else if (e.key === 'Home') {
1664
+ e.preventDefault();
1665
+ items[0].focus();
1666
+ } else if (e.key === 'End') {
1667
+ e.preventDefault();
1668
+ items[items.length - 1].focus();
1669
+ } else if (e.key === 'Tab') {
1670
+ wiCloseOverflowMenu(false);
1671
+ }
1672
+ }
1673
+
1674
+ // ── Row click + selection ─────────────────────────────────────────────────
1675
+ // A row behaves the way it was RENDERED: `bulk` is the per-render flag wiRow()
1676
+ // baked into the handler, NOT the module-global wiBulkMode. They diverge —
1677
+ // openAllWorkItems() renders wiRow(it, false) while bulk mode can be on — and
1678
+ // reading the global there let a click on a checkbox-less modal row silently
1679
+ // toggle wiSelectedIds: invisible state feeding the destructive bulk Delete,
1680
+ // with the detail view never opening. Fail closed: only an explicit bulk row
1681
+ // toggles selection; anything else opens the detail (preserving the
1682
+ // pre-existing text-selection guard).
1683
+ function wiRowClick(event, id, source, bulk) {
1684
+ if (shouldIgnoreSelectionClick(event)) return;
1685
+ if (bulk === true) { _wiToggleId(id); return; }
1686
+ openWorkItemDetail(id, source);
1687
+ }
1688
+
1689
+ function _wiToggleId(id) {
1690
+ if (wiSelectedIds.has(id)) wiSelectedIds.delete(id);
1691
+ else wiSelectedIds.add(id);
1692
+ _wiReflectRowSelection(id);
1693
+ _wiSyncBulkUI();
1694
+ }
1695
+
1696
+ function wiToggleSelect(cb) {
1697
+ var id = cb.getAttribute('data-wi-id');
1698
+ if (!id) return;
1699
+ if (cb.checked) wiSelectedIds.add(id);
1700
+ else wiSelectedIds.delete(id);
1701
+ _wiReflectRowSelection(id);
1702
+ _wiSyncBulkUI();
1703
+ }
1704
+
1705
+ function _wiReflectRowSelection(id) {
1706
+ var sel = wiSelectedIds.has(id);
1707
+ var content = document.getElementById('work-items-content');
1708
+ if (!content) return;
1709
+ var esc = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : String(id).replace(/"/g, '\\"');
1710
+ content.querySelectorAll('tr[data-wi-id="' + esc + '"]').forEach(function(row) {
1711
+ row.classList.toggle('wi-row-selected', sel);
1712
+ var cb = row.querySelector('.wi-bulk-cb');
1713
+ if (cb) cb.checked = sel;
1714
+ });
1715
+ }
1716
+
1717
+ // ── Bulk mode enter/exit ──────────────────────────────────────────────────
1718
+ function wiToggleBulkMode() {
1719
+ wiBulkMode = !wiBulkMode;
1720
+ if (!wiBulkMode) wiSelectedIds.clear();
1721
+ wiCloseOverflowMenu(false);
1722
+ _wiSyncBulkToggleBtn();
1723
+ renderWorkItems(allWorkItems, { preserveScroll: true });
1724
+ }
1725
+
1726
+ function wiExitBulkMode() {
1727
+ if (!wiBulkMode) return;
1728
+ wiBulkMode = false;
1729
+ wiSelectedIds.clear();
1730
+ wiCloseOverflowMenu(false);
1731
+ _wiSyncBulkToggleBtn();
1732
+ renderWorkItems(allWorkItems, { preserveScroll: true });
1733
+ }
1734
+
1735
+ function wiClearSelection() {
1736
+ if (!wiSelectedIds.size) { _wiSyncBulkUI(); return; }
1737
+ var ids = Array.from(wiSelectedIds);
1738
+ wiSelectedIds.clear();
1739
+ ids.forEach(_wiReflectRowSelection);
1740
+ _wiSyncBulkUI();
1741
+ }
1742
+
1743
+ // Select-all targets the CURRENT filtered set (every page of the active
1744
+ // filter), keyed by id so it stays coherent across paging and re-sorts.
1745
+ function wiToggleSelectAll(cb) {
1746
+ if (cb.checked) filteredWorkItems.forEach(function(w) { wiSelectedIds.add(w.id); });
1747
+ else wiSelectedIds.clear();
1748
+ filteredWorkItems.forEach(function(w) { _wiReflectRowSelection(w.id); });
1749
+ _wiSyncBulkUI();
1750
+ }
1751
+
1752
+ function _wiSyncBulkToggleBtn() {
1753
+ var btn = document.getElementById('wi-bulk-toggle');
1754
+ if (!btn) return;
1755
+ btn.setAttribute('aria-pressed', wiBulkMode ? 'true' : 'false');
1756
+ btn.classList.toggle('active', wiBulkMode);
1757
+ btn.textContent = wiBulkMode ? 'Done' : 'Bulk select';
1758
+ }
1759
+
1760
+ // Re-sync the bulk bar to current state after every render: bar visibility,
1761
+ // selected count, select-all checkbox/indeterminate against the filtered set,
1762
+ // and per-action button enablement + eligible counts.
1763
+ function _wiSyncBulkUI() {
1764
+ _wiSyncBulkToggleBtn();
1765
+ var bar = document.getElementById('wi-bulk-bar');
1766
+ if (bar) bar.hidden = !wiBulkMode;
1767
+ if (!wiBulkMode) return;
1768
+ var count = wiSelectedIds.size;
1769
+ var countEl = document.getElementById('wi-bulk-count');
1770
+ if (countEl) countEl.textContent = count + ' selected';
1771
+ var selAll = document.getElementById('wi-bulk-selectall-cb');
1772
+ if (selAll) {
1773
+ var total = filteredWorkItems.length;
1774
+ var selectedInView = filteredWorkItems.reduce(function(n, w) { return n + (wiSelectedIds.has(w.id) ? 1 : 0); }, 0);
1775
+ selAll.checked = total > 0 && selectedInView === total;
1776
+ selAll.indeterminate = selectedInView > 0 && selectedInView < total;
1777
+ }
1778
+ Object.keys(WI_BULK_ACTIONS).forEach(function(action) {
1779
+ var b = document.querySelector('#wi-bulk-bar [data-bulk-action="' + action + '"]');
1780
+ if (!b) return;
1781
+ var plan = wiBulkPlan(allWorkItems, wiSelectedIds, action);
1782
+ var n = plan.eligible.length;
1783
+ b.disabled = count === 0 || n === 0;
1784
+ var base = WI_BULK_ACTIONS[action].label;
1785
+ b.textContent = n > 0 ? base + ' (' + n + ')' : base;
1786
+ });
1787
+ }
1788
+
1789
+ // Apply a bulk action. Resolves the selection to LIVE items, applies only to
1790
+ // eligible ones, confirms destructive actions, reports partial failures
1791
+ // accurately (never claims success if any failed), and never acts on
1792
+ // stale/missing ids.
1793
+ async function wiBulkAction(action) {
1794
+ var spec = WI_BULK_ACTIONS[action];
1795
+ if (!spec) return;
1796
+ var plan = wiBulkPlan(allWorkItems, wiSelectedIds, action);
1797
+ var targets = plan.eligible;
1798
+ if (!targets.length) {
1799
+ showToast('cmd-toast', 'No selected items are eligible for ' + spec.label + '.', false);
1800
+ return;
1801
+ }
1802
+ if (spec.destructive) {
1803
+ if (!confirm(spec.label + ' ' + targets.length + ' work item' + (targets.length === 1 ? '' : 's') + '? This will kill any running agents and cannot be undone.')) return;
1804
+ }
1805
+ var results = await Promise.allSettled(targets.map(function(it) {
1806
+ return fetch(spec.endpoint, {
1807
+ method: 'POST',
1808
+ headers: { 'Content-Type': 'application/json' },
1809
+ body: JSON.stringify({ id: it.id, source: it._source || undefined })
1810
+ }).then(function(r) {
1811
+ if (!r.ok) throw new Error('HTTP ' + r.status);
1812
+ return r.json().catch(function() { return {}; });
1813
+ });
1814
+ }));
1815
+ var succeeded = [];
1816
+ var failed = [];
1817
+ results.forEach(function(res, i) {
1818
+ if (res.status === 'fulfilled') succeeded.push(targets[i]);
1819
+ else failed.push(targets[i]);
1820
+ });
1821
+ if (spec.optimisticRemove) {
1822
+ succeeded.forEach(function(it) { markDeleted('wi:' + it.id); _removeWiRow(it.id); });
1823
+ }
1824
+ succeeded.forEach(function(it) { wiSelectedIds.delete(it.id); });
1825
+ if (spec.wake && succeeded.length) wakeEngine();
1826
+ var skipped = plan.ineligible.length + plan.missing.length;
1827
+ if (failed.length) {
1828
+ // Roll back optimistic hides for the failed ids and refresh to the truth.
1829
+ failed.forEach(function(it) { clearDeleted('wi:' + it.id); });
1830
+ showToast('cmd-toast', spec.label + ': ' + succeeded.length + ' succeeded, ' + failed.length + ' failed' + (skipped ? ', ' + skipped + ' skipped' : '') + '.', false);
1831
+ refresh();
1832
+ } else {
1833
+ showToast('cmd-toast', spec.label + ': ' + succeeded.length + ' item' + (succeeded.length === 1 ? '' : 's') + (skipped ? ' (' + skipped + ' skipped)' : '') + '.', true);
1834
+ if (!spec.optimisticRemove) refresh();
1835
+ }
1836
+ _wiSyncBulkUI();
1837
+ }
1838
+
1839
+
1425
1840
  if (typeof window !== 'undefined') {
1426
- window.MinionsWork = { wiRow, renderWorkItems, editWorkItem, submitWorkItemEdit, deleteWorkItem, archiveWorkItem, toggleWorkItemArchive, retryWorkItem, wiPrev, wiNext, feedbackWorkItem, openCreateWorkItemModal, openWorkItemDetail, openAllWorkItems, viewAgentOutput, openInboxNote, needsAttentionInfo, applyWiFilters, resetWiFilters, toggleWiSortDir };
1841
+ window.MinionsWork = { wiRow, renderWorkItems, editWorkItem, submitWorkItemEdit, deleteWorkItem, archiveWorkItem, toggleWorkItemArchive, retryWorkItem, wiPrev, wiNext, feedbackWorkItem, openCreateWorkItemModal, openWorkItemDetail, openAllWorkItems, viewAgentOutput, openInboxNote, needsAttentionInfo, applyWiFilters, resetWiFilters, toggleWiSortDir, wiOpenOverflowMenu, wiCloseOverflowMenu, wiRowClick, wiToggleSelect, wiToggleBulkMode, wiExitBulkMode, wiClearSelection, wiToggleSelectAll, wiBulkAction };
1427
1842
  }
1428
1843
  // exported for testing — pure helpers safe to require under Node (no DOM deps).
1429
1844
  if (typeof module !== 'undefined' && module.exports) {
1430
- module.exports = { needsAttentionInfo, formatPendingReason };
1845
+ module.exports = { needsAttentionInfo, formatPendingReason, wiActionEligible, wiBulkPlan, WI_BULK_ACTIONS };
1431
1846
  }
@@ -269,6 +269,10 @@ function withTopFrame(type, id, fn) {
269
269
  function _restoreFrameToVisible(frame) {
270
270
  // Synchronously restore a snapshotted frame as the visible modal layer.
271
271
  // scrollY is restored after a double-rAF so layout settles first.
272
+ // The modal stays open but its whole body is replaced, so any popup anchored
273
+ // inside it is about to lose its anchor — dismiss BEFORE the swap, while
274
+ // contains() can still identify it.
275
+ _dismissModalTransientPopups();
272
276
  var titleEl = document.getElementById('modal-title');
273
277
  var bodyEl = document.getElementById('modal-body');
274
278
  if (titleEl) {
@@ -308,10 +312,46 @@ function _popOneFrameInternal() {
308
312
  _restoreFrameToVisible(frame);
309
313
  }
310
314
 
315
+ // ─── Transient popup teardown ────────────────────────────────────────────────
316
+ // An anchored popover (the work-item ⋯ overflow menu, the checkout-mode picker,
317
+ // any future dropdown) is appended to document.body but ANCHORED to a trigger
318
+ // that lives inside some container. Hide or replace that container and the
319
+ // popover is stranded: it floats over the page with its anchor gone, still
320
+ // holding the action closures built when it opened.
321
+ //
322
+ // The modal is the worst offender because it is torn down by paths that never
323
+ // touch the DOM a popover listens to — browser Back / Alt+Left (popstate),
324
+ // sidebar navigation (switchPage), and a frame pop all bypass the
325
+ // mousedown/keydown dismissal the popover installed on itself.
326
+ //
327
+ // Owners register a SCOPED closer: one that closes only when its own anchor is
328
+ // inside the container being torn down, so tearing down the modal can never
329
+ // dismiss a popover opened elsewhere on the page. Every modal teardown and
330
+ // every wholesale replacement of the modal body funnels through
331
+ // dismissTransientPopupsIn(), so a new popup gets this for free by registering.
332
+ var _transientPopupClosers = [];
333
+ function registerTransientPopupCloser(fn) {
334
+ if (typeof fn !== 'function') return;
335
+ if (_transientPopupClosers.indexOf(fn) === -1) _transientPopupClosers.push(fn);
336
+ }
337
+ function dismissTransientPopupsIn(container) {
338
+ // No container means nothing to scope against — a closer could not tell
339
+ // whether its anchor is affected, so it must not fire.
340
+ if (!container) return;
341
+ for (var i = 0; i < _transientPopupClosers.length; i++) {
342
+ // Per-closer isolation: one popup owner must never block modal teardown.
343
+ try { _transientPopupClosers[i](container); } catch (e) { try { console.error(e); } catch {} }
344
+ }
345
+ }
346
+ function _dismissModalTransientPopups() {
347
+ dismissTransientPopupsIn(document.getElementById('modal'));
348
+ }
349
+
311
350
  function _physicallyCloseModal() {
312
351
  // Close the modal DOM without going through closeModal() (which would itself
313
352
  // call popModalFrame and recurse). Mirrors the visible side-effects of
314
353
  // closeModal but skips stack management.
354
+ _dismissModalTransientPopups();
315
355
  var modalEl = document.getElementById('modal');
316
356
  if (modalEl) modalEl.classList.remove('open');
317
357
  var inner = document.querySelector('#modal .modal');
@@ -1,4 +1,5 @@
1
1
  <section>
2
+ <p id="tools-refresh-status" class="empty" role="status" aria-live="polite" style="display:none"></p>
2
3
  <h2>Minions Skills <span class="count" id="skills-count">0</span> <span class="qa-section-subtitle">discovered from runtime native dirs, plugin installs, and configured project repos</span></h2>
3
4
  <div id="skills-list"><p class="empty">No skills yet. Agents create these when they discover repeatable workflows.</p></div>
4
5
  </section>