@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
@@ -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>
@@ -1,6 +1,7 @@
1
1
  <section id="work-items-section" style="overflow:visible">
2
2
  <h2>Work Items <span class="count" id="wi-count">0</span>
3
3
  <button class="btn-add" style="margin-left:8px" onclick="openCreateWorkItemModal()">+ New</button>
4
+ <button id="wi-bulk-toggle" type="button" class="pr-pager-btn" style="font-size:var(--text-sm);padding:2px 8px;margin-left:4px" onclick="wiToggleBulkMode()" aria-pressed="false" aria-controls="wi-bulk-bar" title="Enter bulk-selection mode">Bulk select</button>
4
5
  <button id="work-archive-toggle" type="button" class="pr-pager-btn" style="font-size:var(--text-sm);padding:2px 8px;margin-left:4px" onclick="toggleWorkItemArchive()" aria-expanded="false" aria-controls="work-items-archive">See Archive</button>
5
6
  <span style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">tasks dispatched to agents — auto-created from PRDs or added manually</span>
6
7
  </h2>
@@ -44,6 +45,18 @@
44
45
  <button class="pr-filter-reset" type="button" onclick="resetWiFilters()">Reset filters</button>
45
46
  <span class="pr-filter-summary" id="wi-filter-summary" aria-live="polite"></span>
46
47
  </div>
48
+ <div id="wi-bulk-bar" class="wi-bulk-bar" role="toolbar" aria-label="Bulk work-item actions" hidden>
49
+ <label class="wi-bulk-selectall"><input type="checkbox" id="wi-bulk-selectall-cb" onchange="wiToggleSelectAll(this)" aria-label="Select all filtered work items"> Select all</label>
50
+ <span id="wi-bulk-count" aria-live="polite">0 selected</span>
51
+ <span class="wi-bulk-actions">
52
+ <button type="button" class="pr-pager-btn" data-bulk-action="cancel" onclick="wiBulkAction('cancel')" disabled>Cancel</button>
53
+ <button type="button" class="pr-pager-btn" data-bulk-action="archive" onclick="wiBulkAction('archive')" disabled>Archive</button>
54
+ <button type="button" class="pr-pager-btn" data-bulk-action="retry" onclick="wiBulkAction('retry')" disabled>Retry</button>
55
+ <button type="button" class="btn-destructive" data-bulk-action="delete" onclick="wiBulkAction('delete')" disabled>Delete</button>
56
+ </span>
57
+ <button type="button" class="pr-filter-reset" onclick="wiClearSelection()">Clear</button>
58
+ <button type="button" class="pr-filter-reset" onclick="wiExitBulkMode()">Exit bulk mode</button>
59
+ </div>
47
60
  <div id="work-items-content"><p class="empty">No work items yet.</p></div>
48
61
  <div id="work-items-archive" style="display:none;margin-top:12px"></div>
49
62
  </section>
@@ -0,0 +1,75 @@
1
+ // Shared PR author display formatter (browser). Single source of truth for
2
+ // turning a structured PR author identity into a human-readable NAME that never
3
+ // exposes an email address, reused by every dashboard surface that renders a PR
4
+ // author (the classic Pull Requests table cell + detail panel, and the shared
5
+ // author filter — which slim embeds too). Mirrors engine/core/shared.js
6
+ // prAuthorDisplayName so the label the operator sees matches the projected SQL
7
+ // label; keep the two in sync (pinned by test/unit/pr-author.test.js).
8
+ //
9
+ // The structured identity fields (login/id/descriptor/url) are left untouched:
10
+ // this is a presentation-only transform, not destructive normalization of the
11
+ // machine identity used for filtering, matching, and reconciliation.
12
+
13
+ (function () {
14
+ function _text(value) {
15
+ if (value == null) return '';
16
+ return typeof value === 'string' ? value.trim() : String(value).trim();
17
+ }
18
+
19
+ // A human-readable name from ONE identity fragment that never contains an
20
+ // email address. Any '@' forces local-part extraction ("Name <addr>" keeps the
21
+ // display part; a bare/embedded address drops the domain). '' when unusable.
22
+ function _fragmentName(raw) {
23
+ var s = _text(raw);
24
+ if (!s) return '';
25
+ var m = s.match(/^\s*"?([^"<]*?)"?\s*<[^>]*>\s*$/);
26
+ if (m && m[1].trim() && m[1].indexOf('@') === -1) return m[1].trim();
27
+ if (s.indexOf('@') !== -1) {
28
+ s = s.replace(/<([^>]*)>/, '$1');
29
+ return s.split('@')[0].replace(/^["'<\s]+|["'>\s]+$/g, '').trim();
30
+ }
31
+ return s;
32
+ }
33
+
34
+ // Accept the canonical author shape, a raw provider createdBy object, or a
35
+ // bare string. `login` also reads ADO's `uniqueName`; `displayName` its `name`.
36
+ function _fields(author) {
37
+ if (author && typeof author === 'object') {
38
+ return {
39
+ displayName: _text(author.displayName) || _text(author.name),
40
+ login: _text(author.login) || _text(author.uniqueName),
41
+ id: _text(author.id),
42
+ url: _text(author.url),
43
+ };
44
+ }
45
+ if (typeof author === 'string') return { displayName: '', login: _text(author), id: '', url: '' };
46
+ return { displayName: '', login: '', id: '', url: '' };
47
+ }
48
+
49
+ // Human-readable name that NEVER exposes an email address. '' → render Unknown.
50
+ function name(author) {
51
+ var f = _fields(author);
52
+ return _fragmentName(f.displayName) || _fragmentName(f.login) || f.id || '';
53
+ }
54
+
55
+ // Optional secondary handle (rendered as "@handle"). Only a real login with no
56
+ // '@' that adds information beyond the name — never an email address.
57
+ function handle(author) {
58
+ var f = _fields(author);
59
+ if (f.login && f.login.indexOf('@') === -1 && f.login !== name(author)) return f.login;
60
+ return '';
61
+ }
62
+
63
+ // A validated http(s) profile URL, or '' — so a caller never links an
64
+ // unvalidated href.
65
+ function url(author) {
66
+ var f = _fields(author);
67
+ return /^https?:\/\//i.test(f.url) ? f.url : '';
68
+ }
69
+
70
+ function display(author) {
71
+ return { name: name(author), handle: handle(author), url: url(author) };
72
+ }
73
+
74
+ window.MinionsPrAuthor = { name: name, handle: handle, url: url, display: display };
75
+ })();
@@ -35,14 +35,37 @@ function _prFilterKey(value) {
35
35
  return window.MinionsRecordFilters.key(value);
36
36
  }
37
37
 
38
+ function _prAuthorSource(pr) {
39
+ if (!pr || typeof pr !== 'object') return null;
40
+ return pr.author || pr.createdBy || pr.created_by || null;
41
+ }
42
+
43
+ // Stable machine identity for the author filter VALUE (grouping key). Prefers a
44
+ // non-email provider id/descriptor/login so two people who happen to share a
45
+ // display name do not collapse, and an email is never used as a visible label.
46
+ // The displayed option text stays the email-free name from normalizePrAuthor.
47
+ function _prAuthorMachineKey(source) {
48
+ if (!source) return '';
49
+ if (typeof source === 'string') return source;
50
+ if (typeof source !== 'object') return '';
51
+ return _prFilterText(source.id)
52
+ || _prFilterText(source.descriptor)
53
+ || _prFilterText(source.login)
54
+ || _prFilterText(source.uniqueName)
55
+ || _prFilterText(source.displayName)
56
+ || _prFilterText(source.name);
57
+ }
58
+
38
59
  function normalizePrAuthor(pr) {
39
- if (!pr || typeof pr !== 'object') return '';
40
60
  // Real author only. Deliberately NOT falling back to pr.agent: the Minions
41
61
  // agent is a separate identity (W-mscibegy005e8b74) and a legacy record with
42
62
  // no author must group/render as Unknown rather than borrow the agent name.
43
- return _prFilterName(pr.author)
44
- || _prFilterName(pr.createdBy)
45
- || _prFilterName(pr.created_by);
63
+ // The email-free display name comes from the shared formatter so the filter
64
+ // dropdown matches the PR table's Author column (W-msd8ls85).
65
+ var src = _prAuthorSource(pr);
66
+ if (!src) return '';
67
+ if (window.MinionsPrAuthor && window.MinionsPrAuthor.name) return window.MinionsPrAuthor.name(src);
68
+ return _prFilterName(src);
46
69
  }
47
70
 
48
71
  function normalizePrLifecycleStatus(pr) {
@@ -96,7 +119,7 @@ function _prFilterEntry(pr, dimension) {
96
119
  var value = '';
97
120
  if (dimension === 'author') {
98
121
  label = normalizePrAuthor(pr);
99
- value = _prFilterKey(label);
122
+ value = _prFilterKey(_prAuthorMachineKey(_prAuthorSource(pr)) || label);
100
123
  } else if (dimension === 'lifecycle') {
101
124
  value = normalizePrLifecycleStatus(pr);
102
125
  label = PR_LIFECYCLE_FILTER_LABELS[value] || _formatPrFilterLabel(value);
@@ -126,6 +126,10 @@
126
126
  .agent-name { font-weight: 600; font-size: var(--text-xl); }
127
127
  .agent-role { font-size: var(--text-base); color: var(--muted); margin-bottom: var(--space-4); }
128
128
  .status-badge { font-size: var(--text-sm); font-weight: 600; padding: var(--space-1) var(--space-4); border-radius: var(--radius-xl); text-transform: uppercase; letter-spacing: 0.5px; }
129
+ /* Temporary-agent marker — additive display identity next to a friendly call
130
+ sign. Neutral, low-emphasis pill so the temp status stays unmistakable
131
+ without competing with the agent name (W-msd8zdzb00i550a8). */
132
+ .agent-temp-badge { display: inline-block; font-size: var(--text-sm); font-weight: 600; padding: 0 var(--space-2); margin-left: var(--space-2); border-radius: var(--radius-sm); text-transform: uppercase; letter-spacing: 0.4px; vertical-align: middle; color: var(--muted); background: var(--surface); border: 1px solid var(--border); }
129
133
  .status-badge.idle { background: var(--surface); color: var(--muted); border: 1px solid var(--border); }
130
134
  .status-badge.working { background: rgba(210,153,34,0.15); color: var(--yellow); border: 1px solid var(--yellow); animation: pulse 1.5s infinite; }
131
135
  .status-badge.done { background: rgba(63,185,80,0.15); color: var(--green); border: 1px solid var(--green); }
@@ -518,6 +522,18 @@
518
522
  outline: 2px solid var(--blue); outline-offset: 2px;
519
523
  }
520
524
 
525
+ /* W-msd8pr2300ev95e1 — AutoFix foreign-author guardrail warning icon. Sits
526
+ beside the Auto Fix toggle when auto-fix is enabled on a PR the current
527
+ identity did not author (foreign), or when ownership can't be established
528
+ (unknown). Cursor:help so the accessible tooltip is discoverable. */
529
+ .pr-autofix-warning {
530
+ display: inline-block; margin-left: var(--space-3);
531
+ vertical-align: middle; cursor: help; font-size: var(--text-base);
532
+ line-height: 1;
533
+ }
534
+ .pr-autofix-warning--foreign { color: var(--red, #f85149); }
535
+ .pr-autofix-warning--unknown { color: var(--yellow, #d29922); }
536
+
521
537
  .archive-btn {
522
538
  background: var(--surface2); border: 1px solid var(--border); color: var(--muted);
523
539
  font-size: var(--text-base); padding: var(--space-2) var(--space-5); border-radius: var(--radius-sm); cursor: pointer; transition: all var(--transition-base); margin-left: auto;
@@ -1996,3 +2012,72 @@
1996
2012
  height (no magic 64px, no vh). */
1997
2013
  html.embed-settings .modal-body.settings-body { flex: 1 1 auto; min-height: 0; }
1998
2014
  html.embed-settings .settings-layout { height: 100%; }
2015
+
2016
+ /* ── Work-item overflow menu + bulk-operations mode (W-mscjvm33007r79ef) ──
2017
+ * The overflow (⋯) menu is rendered via document.createElement in
2018
+ * wiOpenOverflowMenu (no innerHTML), appended to <body> and positioned under
2019
+ * the clicked button — mirroring .checkout-mode-menu above. The bulk bar and
2020
+ * selection affordances only appear when bulk mode is explicitly enabled. */
2021
+ .wi-overflow-btn { line-height: 1; }
2022
+ .wi-overflow-menu {
2023
+ position: absolute;
2024
+ z-index: 1000;
2025
+ min-width: 180px;
2026
+ background: var(--surface2);
2027
+ border: 1px solid var(--border);
2028
+ border-radius: var(--radius-md, 6px);
2029
+ box-shadow: 0 4px 16px rgba(0,0,0,0.3);
2030
+ padding: 4px;
2031
+ display: flex;
2032
+ flex-direction: column;
2033
+ }
2034
+ .wi-overflow-item {
2035
+ display: block;
2036
+ width: 100%;
2037
+ text-align: left;
2038
+ padding: 6px 10px;
2039
+ border: none;
2040
+ background: none;
2041
+ border-radius: 4px;
2042
+ color: var(--text);
2043
+ font-size: var(--text-sm);
2044
+ font-family: inherit;
2045
+ cursor: pointer;
2046
+ }
2047
+ .wi-overflow-item:hover,
2048
+ .wi-overflow-item:focus {
2049
+ background: var(--surface);
2050
+ outline: none;
2051
+ }
2052
+ .wi-overflow-item.wi-overflow-item-destructive { color: var(--red); }
2053
+
2054
+ .wi-bulk-bar {
2055
+ display: flex;
2056
+ align-items: center;
2057
+ gap: var(--space-4, 8px);
2058
+ flex-wrap: wrap;
2059
+ margin: 8px 0;
2060
+ padding: 8px 10px;
2061
+ background: var(--surface2);
2062
+ border: 1px solid var(--border);
2063
+ border-radius: var(--radius-sm, 4px);
2064
+ font-size: var(--text-sm);
2065
+ }
2066
+ .wi-bulk-bar[hidden] { display: none; }
2067
+ .wi-bulk-selectall {
2068
+ display: inline-flex;
2069
+ align-items: center;
2070
+ gap: 6px;
2071
+ cursor: pointer;
2072
+ color: var(--text);
2073
+ }
2074
+ #wi-bulk-count { color: var(--muted); min-width: 80px; }
2075
+ .wi-bulk-actions { display: inline-flex; gap: 6px; flex-wrap: wrap; }
2076
+ .wi-bulk-actions button:disabled { opacity: 0.5; cursor: default; }
2077
+ #wi-bulk-toggle.active {
2078
+ background: var(--blue);
2079
+ color: #fff;
2080
+ border-color: var(--blue);
2081
+ }
2082
+ .wi-bulk-cell { width: 28px; text-align: center; }
2083
+ tr.wi-row-selected > td { background: rgba(80,140,255,0.12); }
@@ -13,7 +13,7 @@ const MINIONS_DIR = __dirname;
13
13
  // 'wi-filters' publishes window.MinionsWorkItemFilters (the Work Items table's
14
14
  // filter + sort semantics), consumed by render-work-items.js — hence it precedes
15
15
  // the renderer bundle (DASHBOARD_JS_FILES) too.
16
- const DASHBOARD_SHARED_JS = ['record-filters', 'pr-merge-state', 'pr-filters', 'wi-filters', 'cc-suggestions', 'cc-limits', 'cc-queue-store', 'model-display', 'watches-source', 'project-git-summary', 'welcome-popup'];
16
+ const DASHBOARD_SHARED_JS = ['record-filters', 'pr-merge-state', 'pr-author', 'pr-filters', 'wi-filters', 'cc-suggestions', 'cc-limits', 'cc-queue-store', 'model-display', 'watches-source', 'project-git-summary', 'welcome-popup'];
17
17
 
18
18
  // ── Canonical classic-dashboard assembly manifest ──────────────────────────
19
19
  // Single source of truth, consumed by BOTH assemblers: buildDashboardHtml()
@@ -38,7 +38,7 @@ const DASHBOARD_PAGE_SUB_FRAGMENTS = {
38
38
  // Every dashboard/js/*.js file belongs here unless it is lazy-loaded through a
39
39
  // dedicated route (only memory-search.js today, served at /assets/memory-search.js).
40
40
  const DASHBOARD_JS_FILES = [
41
- 'utils', 'state', 'features-client', 'render-utils', 'charter-editor', 'detail-panel', 'live-stream',
41
+ 'utils', 'agent-identity', 'state', 'features-client', 'render-utils', 'charter-editor', 'detail-panel', 'live-stream',
42
42
  'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
43
43
  'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
44
44
  'render-other', 'render-managed', 'memory-panel', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
@@ -151,7 +151,7 @@ const SLIM_JS_ORDER = [
151
151
  // charter-editor MUST precede detail-panel (see the "charter-editor registered
152
152
  // before detail-panel" test).
153
153
  const SLIM_CLASSIC_PANEL_JS = [
154
- 'utils', 'render-utils', 'charter-editor', 'live-stream', 'render-agents', 'detail-panel',
154
+ 'utils', 'agent-identity', 'render-utils', 'charter-editor', 'live-stream', 'render-agents', 'detail-panel',
155
155
  ];
156
156
 
157
157
  // Cache for the assembled slim source fragments (layout/css/body/js). Keyed on
package/dashboard.js CHANGED
@@ -2538,6 +2538,9 @@ let _toolsInventoryCacheTs = 0;
2538
2538
  let _toolsInventoryRefreshPromise = null;
2539
2539
  let _toolsInventoryChild = null;
2540
2540
  let _toolsInventoryScanner = _scanToolsInventoryInChild;
2541
+ // Most recent failed refresh ({ message, ts }); cleared on the next success.
2542
+ // Surfaced as a response header so a stale-serve still exposes refresh failure.
2543
+ let _toolsInventoryLastError = null;
2541
2544
 
2542
2545
  function _validateToolsInventoryPayload(payload) {
2543
2546
  if (!payload || typeof payload !== 'object'
@@ -2600,11 +2603,12 @@ function _terminateToolsInventoryChild() {
2600
2603
  try { shared.terminateProcess('dashboard.tools-inventory-shutdown', child); } catch { /* child may already be gone */ }
2601
2604
  }
2602
2605
 
2603
- function _refreshToolsInventory() {
2604
- const now = Date.now();
2605
- if (_toolsInventoryCache && now - _toolsInventoryCacheTs < TOOLS_INVENTORY_CACHE_TTL_MS) {
2606
- return Promise.resolve(_toolsInventoryCache);
2607
- }
2606
+ // Start (or join) the single-flight child-process scan. Concurrent callers
2607
+ // share one in-flight promise so a burst of /api/tools polls never spawns
2608
+ // duplicate children. On success the cache + payload-derived ETag are swapped
2609
+ // in and the last-error is cleared; on failure the error is recorded (so a
2610
+ // stale-serve can still surface it) and re-thrown to the caller that awaited.
2611
+ function _startToolsInventoryScan() {
2608
2612
  if (_toolsInventoryRefreshPromise) return _toolsInventoryRefreshPromise;
2609
2613
 
2610
2614
  _toolsInventoryRefreshPromise = Promise.resolve()
@@ -2616,19 +2620,68 @@ function _refreshToolsInventory() {
2616
2620
  _toolsInventoryCacheJson = json;
2617
2621
  _toolsInventoryCacheEtag = `"tools-${crypto.createHash('sha256').update(json).digest('hex')}"`;
2618
2622
  _toolsInventoryCacheTs = Date.now();
2623
+ _toolsInventoryLastError = null;
2619
2624
  return next;
2620
2625
  })
2626
+ .catch(err => {
2627
+ _toolsInventoryLastError = { message: String((err && err.message) || err), ts: Date.now() };
2628
+ throw err;
2629
+ })
2621
2630
  .finally(() => {
2622
2631
  _toolsInventoryRefreshPromise = null;
2623
2632
  });
2624
2633
  return _toolsInventoryRefreshPromise;
2625
2634
  }
2626
2635
 
2636
+ function _toolsInventoryAgeMs() {
2637
+ return _toolsInventoryCacheTs ? Date.now() - _toolsInventoryCacheTs : Infinity;
2638
+ }
2639
+
2640
+ // Resolve the tools inventory. Default (no opts) preserves the original
2641
+ // block-on-expiry contract used by the CC warm path. `{ allowStale: true }`
2642
+ // opts into stale-while-revalidate: once a cache exists it is always returned
2643
+ // immediately while a background single-flight refresh runs, so the request
2644
+ // never blocks on the multi-second child scan again. Only a truly cold cache
2645
+ // (no inventory yet) blocks so callers never see missing data. A persistently
2646
+ // failing background refresh stays visible via _toolsInventoryLastError (and
2647
+ // the X-Tools-Inventory-Error header) rather than re-introducing a blocking
2648
+ // rescan — a rescan that is itself likely to fail would just restore the very
2649
+ // ~8-10s hang this path removed.
2650
+ function _refreshToolsInventory(opts) {
2651
+ const allowStale = !!(opts && opts.allowStale);
2652
+ const age = _toolsInventoryAgeMs();
2653
+
2654
+ // Fresh — serve without a scan.
2655
+ if (_toolsInventoryCache && age < TOOLS_INVENTORY_CACHE_TTL_MS) {
2656
+ return Promise.resolve(_toolsInventoryCache);
2657
+ }
2658
+
2659
+ // Stale-while-revalidate: any cache, however old, is served now while a
2660
+ // single-flight background refresh runs (errors swallowed here — the
2661
+ // recorded last-error still surfaces via the response header).
2662
+ if (allowStale && _toolsInventoryCache) {
2663
+ const bg = _startToolsInventoryScan();
2664
+ if (bg && typeof bg.catch === 'function') bg.catch(() => { /* surfaced via header */ });
2665
+ return Promise.resolve(_toolsInventoryCache);
2666
+ }
2667
+
2668
+ // Cold cache (nothing to serve) — block on a fresh scan.
2669
+ return _startToolsInventoryScan();
2670
+ }
2671
+
2627
2672
  async function handleToolsInventory(req, res) {
2628
2673
  try {
2629
- await _refreshToolsInventory();
2674
+ await _refreshToolsInventory({ allowStale: true });
2675
+ const age = _toolsInventoryAgeMs();
2676
+ const refreshing = _toolsInventoryRefreshPromise != null;
2630
2677
  res.setHeader('ETag', _toolsInventoryCacheEtag);
2631
2678
  res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
2679
+ // Freshness/error signals so the page can represent stale/refresh/error
2680
+ // state without changing the inventory payload shape.
2681
+ res.setHeader('X-Tools-Inventory-Age-Ms', String(Number.isFinite(age) ? age : 0));
2682
+ if (age >= TOOLS_INVENTORY_CACHE_TTL_MS) res.setHeader('X-Tools-Inventory-Stale', '1');
2683
+ if (refreshing) res.setHeader('X-Tools-Inventory-Refreshing', '1');
2684
+ if (_toolsInventoryLastError) res.setHeader('X-Tools-Inventory-Error', '1');
2632
2685
  if (_ifNoneMatchHasEtag(req?.headers?.['if-none-match'], _toolsInventoryCacheEtag)) {
2633
2686
  res.statusCode = 304;
2634
2687
  res.end();
@@ -2653,9 +2706,26 @@ function _resetToolsInventoryCacheForTesting() {
2653
2706
  _toolsInventoryCacheEtag = null;
2654
2707
  _toolsInventoryCacheTs = 0;
2655
2708
  _toolsInventoryRefreshPromise = null;
2709
+ _toolsInventoryLastError = null;
2656
2710
  _toolsInventoryScanner = _scanToolsInventoryInChild;
2657
2711
  }
2658
2712
 
2713
+ // Age the cache just past the fresh-TTL so the next request exercises the
2714
+ // stale-while-revalidate path (serve cached now, refresh in the background).
2715
+ function _expireToolsInventoryCacheForTesting() {
2716
+ if (_toolsInventoryCacheTs) {
2717
+ _toolsInventoryCacheTs = Date.now() - TOOLS_INVENTORY_CACHE_TTL_MS - 1;
2718
+ }
2719
+ }
2720
+
2721
+ // Age the cache back by an arbitrary amount so a test can drive it far past any
2722
+ // prior staleness window and assert the serve-cached-now contract still holds.
2723
+ function _ageToolsInventoryCacheForTesting(ageMs) {
2724
+ if (_toolsInventoryCacheTs) {
2725
+ _toolsInventoryCacheTs = Date.now() - ageMs;
2726
+ }
2727
+ }
2728
+
2659
2729
  function parsePinnedEntries(content) {
2660
2730
  if (!content) return [];
2661
2731
  const entries = [];
@@ -17170,7 +17240,7 @@ module.exports = {
17170
17240
  _resetStatusCacheForTesting,
17171
17241
  _ifNoneMatchHasEtag,
17172
17242
  _scanStatusMtimes, _refreshStatusMtimes, _resetStatusMtimeCacheForTesting, // exported for testing
17173
- handleToolsInventory, _refreshToolsInventory, _setToolsInventoryScannerForTesting, _resetToolsInventoryCacheForTesting, // exported for testing
17243
+ handleToolsInventory, _refreshToolsInventory, _setToolsInventoryScannerForTesting, _resetToolsInventoryCacheForTesting, _expireToolsInventoryCacheForTesting, _ageToolsInventoryCacheForTesting, // exported for testing
17174
17244
  collectCcProjectSkills, _mapProjectSkillsForCc, // exported for testing
17175
17245
  _ensureConfiguredProjectStateFiles: ensureConfiguredProjectStateFiles, _resetInitializedProjectStatesForTesting, // exported for testing
17176
17246
  _countWorktrees, _refreshWorktreeCount, _scanWorktreeCount, _resetWorktreeCountCacheForTesting, // exported for testing
@@ -17326,6 +17396,22 @@ if (require.main === module) {
17326
17396
  Promise.resolve(queries.getKnowledgeBaseEntries())
17327
17397
  .catch(err => console.warn(`[dashboard] KB cache warm failed: ${err && err.message}`));
17328
17398
 
17399
+ // W-msdbk1rg — warm the tools inventory (skills/commands/MCP) shortly after
17400
+ // boot so the first Skills & MCP page open renders instantly instead of
17401
+ // blocking ~8-10s on the cold child scan (the nested project-embedded skill
17402
+ // walk over large monorepos dominates). Deferred + unref'd so it never
17403
+ // competes with boot or the first /api/status snapshot, and single-flighted
17404
+ // inside _refreshToolsInventory so it can't duplicate an in-flight scan.
17405
+ const _toolsWarmTimer = setTimeout(() => {
17406
+ try {
17407
+ const pending = _refreshToolsInventory();
17408
+ if (pending && typeof pending.catch === 'function') {
17409
+ pending.catch(err => console.warn(`[dashboard] tools inventory warm failed: ${err && err.message}`));
17410
+ }
17411
+ } catch (err) { console.warn(`[dashboard] tools inventory warm failed: ${err && err.message}`); }
17412
+ }, 3000);
17413
+ if (typeof _toolsWarmTimer.unref === 'function') _toolsWarmTimer.unref();
17414
+
17329
17415
  // Auto-open the browser. `minions restart` and the upgrade path set
17330
17416
  // MINIONS_NO_AUTO_OPEN=1 because the CLI orchestrates the open itself
17331
17417
  // after observing whether an existing tab reconnected; the primitive
package/docs/README.md CHANGED
@@ -78,6 +78,7 @@ Architecture, design proposals, and lifecycle references for people working on t
78
78
  - [slim-ux/concepts.md](slim-ux/concepts.md) — Slim-UX design notes: simplified surface concepts driving the project picker, inline project link, and decoupled folder picker.
79
79
  - [slim-ux/architecture-suggestions.md](slim-ux/architecture-suggestions.md) — Slim-UX follow-up architecture suggestions paired with `concepts.md`.
80
80
  - [team-memory.md](team-memory.md) — End-to-end hybrid memory system: file-backed inputs and consolidation, SQL/FTS5 records, retrieval and fallback, prompt bounds, episodic capture, the review-learning lifecycle (capture/recall/promotion/contradiction/diagnostics) and its rollout history + acceptance measurement, security, APIs, and operations.
81
+ - [temporary-agents.md](temporary-agents.md) — Ephemeral `temp-<uid>` fallback agents: the opt-in `engine.allowTempAgents` + per-tick concurrency gates, in-memory naming/state, why they carry no charter/persona and no personal memory (but do get pinned/team/project memory), fleet-default runtime/model/tool/manifest resolution, routing/retry behavior, lifecycle/cleanup across restart, and isolation implications.
81
82
  - [timeouts-and-liveness.md](timeouts-and-liveness.md) — What kills (or doesn't kill) a live tracked agent: the wall-clock vs steering kill invariants, spawn-phase watchdog gates, steering safety nets, and stale-orphan detection ladder.
82
83
  - [visual-evidence-ci.md](visual-evidence-ci.md) — GitHub Actions before/after dashboard capture: path and label triggers, deterministic base/head fixtures, artifact/comment lifecycle, trust boundary, and local reproduction.
83
84
  - [watches.md](watches.md) — Persistent monitoring jobs: target-type registry, conditions, follow-up actions, and the `watch-plugins/` extension folder.
@@ -22,6 +22,7 @@
22
22
  | `capabilities.systemPromptFile` | **`false`** | No `--system-prompt-file` flag exists. Inject system prompt via a `<system>` block prepended to stdin. |
23
23
  | `capabilities.effortLevels` | **`true`** | `--effort` accepts `low|medium|high|xhigh` (no `max`). Adapter must map `'max' → 'xhigh'`. |
24
24
  | `capabilities.costTracking` | **`false`** | `result.usage` contains `premiumRequests` (count, not USD), no token counts, no cost. |
25
+ | `capabilities.billableUnit` | **`'premiumRequests'`** | Native billable unit. The adapter emits `usage.billable = { unit: 'premiumRequests', value, reported }` and the dashboard renders premium requests in the same position a USD runtime shows dollar cost. When the CLI does not report the field, `value` is `null` and `reported` is `false` (recorded as unavailable, never a fake `0`). See [runtime-adapters.md → Billable-unit usage accounting](runtime-adapters.md#billable-unit-usage-accounting). |
25
26
  | `capabilities.modelShorthands` | **`false`** | The Copilot CLI requires full model IDs (`claude-sonnet-4.5`, `gpt-5.4`). Minions may accept internal aliases (`haiku`, `sonnet`, `opus`), but the adapter translates them to Copilot model IDs before invoking the CLI. |
26
27
  | `capabilities.budgetCap` | **`false`** | No `--max-budget-usd` flag. |
27
28
  | `capabilities.bareMode` | **`false`** | No `--bare` equivalent. |
@@ -134,8 +134,10 @@ Stopping is deliberately two commands, because "ask the engine to shut down" and
134
134
  | `minions stop --all` | Ordered whole-stack teardown: stop-intent → supervisor → dashboard → graceful engine drain → identity-verified reap → late-respawn sweep → verification. | The daemons being verified down. It reports the `engine/state.db-shm` state but does not wait for it. |
135
135
  | `minions stop --all --wait` | The same teardown, and then waits for `engine/state.db-shm` to be released. | The database handles being released, or the budget expiring. |
136
136
 
137
- `--timeout <ms>` sets the budget for the whole sequence (default 60000, matching
138
- the internal installer's quiescence window).
137
+ `--timeout <ms>` sets the budget for the whole sequence (default 60000, the
138
+ floor of the internal installer's quiescence window — that window is derived from
139
+ the target runtime's own `engine.shutdownTimeout` and only ever widens from
140
+ here, so a stop is never shorter than the wait that follows it).
139
141
 
140
142
  Bare `minions stop` is unchanged and will stay that way: recovery paths, operator
141
143
  scripts, and the engine's own callers depend on "request shutdown and return".
@@ -164,16 +164,55 @@ by `test/unit/install-internal-minions.test.js`.
164
164
  until the database handles are actually released. Release requires **both**
165
165
  signals: the processes are dead *and* `engine/state.db-shm` is absent or
166
166
  empty — a live shared-memory index means a connection still holds the WAL. If
167
- the handles are not released within the bounded timeout the run **fails
168
- closed**, before any uninstall or package mutation, and prints which stop path
169
- was taken, the exact argv it issued, and the exact PIDs still holding the
170
- database. Whether services were actually stopped is recorded so a rollback can
171
- restart them. Skipped on a machine with no existing runtime.
167
+ the handles are not released within the derived budget (see *How long the gate
168
+ waits* below) the run **fails closed**, before any uninstall or package
169
+ mutation, and prints which stop path was taken, the exact argv it issued, the
170
+ budget that expired and where it came from, and the exact PIDs still holding
171
+ the database. Whether services were actually stopped is recorded so a rollback
172
+ can restart them. Skipped on a machine with no existing runtime.
172
173
 
173
174
  When the run does fail closed here, end the holders it names yourself — the
174
175
  refusal prints each surviving process by name and PID, and the dashboard is
175
176
  the usual one — then re-run the installer.
176
177
 
178
+ ### How long the gate waits
179
+
180
+ The wait budget is **derived from the runtime being stopped**, not fixed. It is
181
+ the pinned root's own `engine.shutdownTimeout` — the budget the engine gives
182
+ itself to drain pooled leases before exiting — plus the same grace
183
+ `minions restart` applies when it derives a teardown deadline the same way
184
+ (`engine/recovery/stop-stack.js#DRAIN_GRACE_MS`, 5 s).
185
+
186
+ | Input | Budget |
187
+ |-------|--------|
188
+ | `engine.shutdownTimeout` in the pinned root's `config.json` | that value + 5 s grace |
189
+ | config missing, unreadable, malformed, or nonsensical (negative, zero, non-numeric, `NaN`) | `ENGINE_DEFAULTS.shutdownTimeout` (300 s) + 5 s grace |
190
+ | derived budget below the floor | **floor 60 s** |
191
+ | derived budget above the ceiling | **ceiling 900 s (15 min)** |
192
+
193
+ A fixed window could not be right: the *runtime* decides how long a teardown
194
+ legitimately takes. The previous fixed 60 s was shorter than the **default**
195
+ drain budget of five minutes, so a busy stack was declared stuck while it was
196
+ still shutting down exactly as designed.
197
+
198
+ Bounded on both sides because the input is operator-supplied data this gate
199
+ does not own. The **floor** is `stop-stack.js#DEFAULT_STOP_TIMEOUT_MS`, so the
200
+ change can only ever widen the window — a runtime configured with a very short
201
+ drain never makes the gate stricter than it was — and the wait after a stop is
202
+ never shorter than the stop's own budget. The **ceiling** means a corrupt or
203
+ hostile `config.json` cannot hang an unattended migration indefinitely; at ~3x
204
+ the default runtime's budget it clamps nothing a real stack needs. A clamp is
205
+ named in the refusal output, so "it gave up too early" stays an answerable
206
+ question.
207
+
208
+ The budget is always read from the **pinned** root — the one `MINIONS_HOME` is
209
+ pinned to and the one whose services are being stopped — never from the
210
+ installing process's own root. The installer is deliberately standalone (Node
211
+ built-ins only, so it runs on a machine with no Minions to import from), so the
212
+ engine default and the grace are *mirrored* constants; a drift test in
213
+ `test/unit/install-internal-minions.test.js` binds both to their engine-side
214
+ originals so the copies cannot silently diverge.
215
+
177
216
  ### Which stop verb is issued
178
217
 
179
218
  The installer prefers `minions stop --all --wait`, and falls back to the bare