@yemi33/minions 0.1.2453 → 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 (48) 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 +44 -25
  11. package/dashboard/js/render-work-items.js +442 -22
  12. package/dashboard/js/settings.js +8 -0
  13. package/dashboard/js/utils.js +40 -0
  14. package/dashboard/pages/tools.html +1 -0
  15. package/dashboard/pages/work.html +13 -0
  16. package/dashboard/shared/pr-author.js +75 -0
  17. package/dashboard/shared/pr-filters.js +28 -5
  18. package/dashboard/styles.css +85 -0
  19. package/dashboard-build.js +3 -3
  20. package/dashboard.js +93 -7
  21. package/docs/README.md +1 -0
  22. package/docs/copilot-cli-schema.md +1 -0
  23. package/docs/engine-restart.md +4 -2
  24. package/docs/internal-install.md +44 -5
  25. package/docs/named-agents.md +48 -0
  26. package/docs/pr-author-identity.md +63 -10
  27. package/docs/runtime-adapters.md +39 -0
  28. package/docs/temporary-agents.md +172 -0
  29. package/engine/ado/comment.js +261 -4
  30. package/engine/agents/llm.js +26 -0
  31. package/engine/agents/playbook.js +2 -1
  32. package/engine/api/settings-validation.js +25 -0
  33. package/engine/core/operator-identity.js +23 -1
  34. package/engine/core/queries.js +53 -2
  35. package/engine/core/shared.js +286 -9
  36. package/engine/db/migrations/032-review-enrolled-pr-context-only.js +95 -0
  37. package/engine/operations/cli.js +102 -1
  38. package/engine/orchestration/lifecycle.js +7 -0
  39. package/engine/orchestration/routing.js +4 -1
  40. package/engine/providers/gh-comment.js +159 -0
  41. package/engine/recovery/stop-stack.js +16 -2
  42. package/engine/runtimes/claude.js +3 -0
  43. package/engine/runtimes/codex.js +4 -0
  44. package/engine/runtimes/copilot.js +23 -2
  45. package/engine.js +2 -2
  46. package/package.json +1 -1
  47. package/playbooks/fix.md +23 -2
  48. 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>';
@@ -210,9 +270,22 @@ function wiRow(item) {
210
270
  var prRef = prFollowup.parent_pr_id || prFollowup.parent_pr_url;
211
271
  var prNumMatch = String(prRef).match(/(\d+)(?!.*\d)/);
212
272
  var prLabel = prNumMatch ? ('PR #' + prNumMatch[1]) : 'parent PR';
213
- 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) + (prFollowup.parent_comment_author ? ' by ' + escapeHtml(prFollowup.parent_comment_author) : '') + '" onclick="event.stopPropagation()">&#8617; from ' + escapeHtml(prLabel) + '</a>';
273
+ var followupBy = prFollowup.parent_comment_author
274
+ ? ((window.MinionsPrAuthor && window.MinionsPrAuthor.name)
275
+ ? window.MinionsPrAuthor.name(prFollowup.parent_comment_author)
276
+ : prFollowup.parent_comment_author)
277
+ : '';
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>';
214
279
  }
215
- 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 +
216
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>' +
217
290
  '<td><span style="font-size:var(--text-sm);color:var(--muted)">' + escapeHtml(item._source || '') + '</span>' +
218
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>' +
@@ -245,8 +318,8 @@ function wiRow(item) {
245
318
  '</td>' +
246
319
  '<td>' +
247
320
  (item.completedAgents && item.completedAgents.length > 0
248
- ? '<span class="pr-agent">' + escapeHtml(item.completedAgents.join(', ')) + '</span>'
249
- : '<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>') +
250
323
  '</td>' +
251
324
  '<td>' + prLink + (failSnippet ? failSnippet : '') + '</td>' +
252
325
  '<td><span class="pr-date">' + escapeHtml((item.created || '').slice(0, 16).replace('T', ' ')) + '</span></td>' +
@@ -267,15 +340,18 @@ function wiRow(item) {
267
340
  (acCount ? '<span title="' + acCount + ' acceptance criteria">&#x2611;' + acCount + '</span>' : '');
268
341
  })() +
269
342
  '</td>' +
270
- '<td style="white-space:nowrap;text-align:right">' +
271
- // W-mqqa76pi — "View live output" opens the agent slide-in panel (live-output
272
- // tab auto-selected when the agent is working). Only for genuinely active
273
- // dispatches with a resolvable assigned agent id.
274
- ((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>' : '') +
275
- ((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>' : '') +
276
- ((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>' : '') +
277
- ((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> ' : '')) +
278
- ((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
+ : '') +
279
355
  '<button class="btn-destructive" onclick="event.stopPropagation();deleteWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Delete work item and kill agent">&#x2715;</button>' +
280
356
  '</td>' +
281
357
  '</tr>';
@@ -427,6 +503,9 @@ function renderWorkItems(items, opts) {
427
503
  const view = computed.view;
428
504
  filteredWorkItems = view;
429
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);
430
509
  const countEl = document.getElementById('wi-count');
431
510
  const summaryEl = document.getElementById('wi-filter-summary');
432
511
  if (countEl) countEl.textContent = view.length;
@@ -437,12 +516,15 @@ function renderWorkItems(items, opts) {
437
516
  }
438
517
  if (!allWorkItems.length) {
439
518
  wiPage = 0;
519
+ if (wiSelectedIds.size) wiSelectedIds.clear();
440
520
  el.innerHTML = '<p class="empty">No work items. Add tasks via Command Center above.</p>';
521
+ _wiSyncBulkUI();
441
522
  return;
442
523
  }
443
524
  if (!view.length) {
444
525
  wiPage = 0;
445
526
  el.innerHTML = '<p class="empty">No work items match the selected filters.</p>';
527
+ _wiSyncBulkUI();
446
528
  return;
447
529
  }
448
530
 
@@ -452,8 +534,21 @@ function renderWorkItems(items, opts) {
452
534
  const start = wiPage * WI_PER_PAGE;
453
535
  const pageItems = view.slice(start, start + WI_PER_PAGE);
454
536
 
455
- 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>';
456
- 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('');
457
552
  html += '</tbody></table></div>';
458
553
 
459
554
  if (view.length > WI_PER_PAGE) {
@@ -473,6 +568,7 @@ function renderWorkItems(items, opts) {
473
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)
474
569
  el.innerHTML = html;
475
570
  restoreDashboardScrollState(el, scrollState);
571
+ _wiSyncBulkUI();
476
572
  }
477
573
 
478
574
  async function editWorkItem(id, source) {
@@ -681,7 +777,7 @@ async function toggleWorkItemArchive() {
681
777
  '<td style="min-width:240px;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escapeHtml(i.title || '') + '</td>' +
682
778
  '<td><span class="dispatch-type ' + (i.type || '') + '">' + escapeHtml(i.type || '') + '</span></td>' +
683
779
  '<td style="color:' + (i.status === 'done' ? 'var(--green)' : 'var(--red)') + '">' + escapeHtml(i.status || '') + '</td>' +
684
- '<td>' + escapeHtml(i.dispatched_to || '—') + '</td>' +
780
+ '<td>' + (i.dispatched_to ? MinionsAgentIdentity.nameWithBadgeHtml({ id: i.dispatched_to }) : '&mdash;') + '</td>' +
685
781
  '<td class="pr-date">' + shortTime(i.archivedAt) + '</td>' +
686
782
  '</tr>';
687
783
  }).join('') + '</tbody></table></div>';
@@ -972,7 +1068,7 @@ function _wiRenderDetail(item) {
972
1068
  _descHtml = escapeHtml(item.title || '—');
973
1069
  }
974
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>');
975
- 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'));
976
1072
  // W-ms0ytfdd000d5a26 — the model this work item actually ran on. Resolved
977
1073
  // server-side (engine/core/queries.js#resolveWorkItemModelInfo) so persisted
978
1074
  // dispatch evidence — what the runtime reported, else what Minions requested
@@ -998,7 +1094,7 @@ function _wiRenderDetail(item) {
998
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>');
999
1095
  }
1000
1096
  if (item.created) html += field('Created', escapeHtml(formatLocalDateTime(item.created)));
1001
- 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('?')));
1002
1098
  if (item.completedAt) html += field('Completed', escapeHtml(formatLocalDateTime(item.completedAt)));
1003
1099
  if (item.failReason) html += field('Failure Reason', '<span style="color:var(--red)">' + escapeHtml(item.failReason) + '</span>');
1004
1100
  // W-mrtdet41000h935a — surface the cancellation reason for CANCELLED items.
@@ -1289,9 +1385,13 @@ function openWorkItemDetail(id, source) {
1289
1385
  }
1290
1386
 
1291
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'));
1292
1392
  document.getElementById('modal-title').textContent = 'All Work Items (' + filteredWorkItems.length + ')';
1293
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>' +
1294
- filteredWorkItems.map(wiRow).join('') + '</tbody></table></div>';
1394
+ filteredWorkItems.map(function(it) { return wiRow(it, false); }).join('') + '</tbody></table></div>';
1295
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)
1296
1396
  document.getElementById('modal-body').innerHTML = html;
1297
1397
  document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
@@ -1417,10 +1517,330 @@ function _wiInjectSourceChipFromFrontmatter(content, containerId) {
1417
1517
  bodyEl.insertAdjacentHTML('afterbegin', sourceBlock);
1418
1518
  }
1419
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
+
1420
1840
  if (typeof window !== 'undefined') {
1421
- 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 };
1422
1842
  }
1423
1843
  // exported for testing — pure helpers safe to require under Node (no DOM deps).
1424
1844
  if (typeof module !== 'undefined' && module.exports) {
1425
- module.exports = { needsAttentionInfo, formatPendingReason };
1845
+ module.exports = { needsAttentionInfo, formatPendingReason, wiActionEligible, wiBulkPlan, WI_BULK_ACTIONS };
1426
1846
  }
@@ -780,6 +780,10 @@ async function openSettings() {
780
780
  settingsField('Operator login (used in branch names)', 'set-operatorLogin', e.operatorLogin || '', '', 'Override the human operator login used in user/<loginname>/<wi-id>-<slug> branches. Empty = auto-resolve via gh / git email / OS username (currently resolves to: ' + (e._resolvedOperatorLogin || 'unknown') + ')') +
781
781
  settingsField('Ignored Comment Authors', 'set-ignoredCommentAuthors', (e.ignoredCommentAuthors || []).join(', '), '', 'Comma-separated usernames — comments auto-closed, never trigger fixes') +
782
782
  '</div>' +
783
+ '<div class="settings-grid-2">' +
784
+ settingsField('ADO identity id', 'set-operatorAdoIdentityId', (e.operatorAdoIdentity && e.operatorAdoIdentity.id) || '', '', 'Your Azure DevOps identity GUID. Used ONLY to determine whether an auto-fix-enabled ADO PR is your own vs another author\'s (foreign-author warning). Empty = ADO ownership shows as "unknown". Never an email.') +
785
+ settingsField('ADO identity descriptor', 'set-operatorAdoIdentityDescriptor', (e.operatorAdoIdentity && e.operatorAdoIdentity.descriptor) || '', '', 'Your Azure DevOps identity descriptor (e.g. aad.<base64>). Alternative stable identity for the foreign-author ownership check. Never an email.') +
786
+ '</div>' +
783
787
  '<h4>Experimental Behavior</h4>' +
784
788
  '<div class="settings-stack">' +
785
789
  settingsToggle('Allow Temp Agents', 'set-allowTempAgents', !!e.allowTempAgents, 'Spawn ephemeral agents when all permanent agents are busy') +
@@ -1724,6 +1728,10 @@ async function saveSettings() {
1724
1728
  spawnPhaseGraceMs: secToMs('set-spawnPhaseGraceMs'),
1725
1729
  spawnPhaseMaxCpuSeconds: document.getElementById('set-spawnPhaseMaxCpuSeconds').value,
1726
1730
  operatorLogin: (document.getElementById('set-operatorLogin')?.value ?? '').trim(),
1731
+ operatorAdoIdentity: {
1732
+ id: (document.getElementById('set-operatorAdoIdentityId')?.value ?? '').trim(),
1733
+ descriptor: (document.getElementById('set-operatorAdoIdentityDescriptor')?.value ?? '').trim(),
1734
+ },
1727
1735
  autoApprovePlans: document.getElementById('set-autoApprovePlans').checked,
1728
1736
  evalLoop: document.getElementById('set-evalLoop').checked,
1729
1737
  autoDecompose: document.getElementById('set-autoDecompose').checked,