@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.
- package/bin/install-internal-minions.js +136 -6
- package/bin/minions.js +27 -13
- package/dashboard/js/agent-identity.js +121 -0
- package/dashboard/js/modal.js +4 -0
- package/dashboard/js/refresh.js +38 -3
- package/dashboard/js/render-agents.js +14 -2
- package/dashboard/js/render-dispatch.js +2 -2
- package/dashboard/js/render-other.js +147 -13
- package/dashboard/js/render-prd.js +6 -5
- package/dashboard/js/render-prs.js +44 -25
- package/dashboard/js/render-work-items.js +442 -22
- package/dashboard/js/settings.js +8 -0
- package/dashboard/js/utils.js +40 -0
- package/dashboard/pages/tools.html +1 -0
- package/dashboard/pages/work.html +13 -0
- package/dashboard/shared/pr-author.js +75 -0
- package/dashboard/shared/pr-filters.js +28 -5
- package/dashboard/styles.css +85 -0
- package/dashboard-build.js +3 -3
- package/dashboard.js +93 -7
- package/docs/README.md +1 -0
- package/docs/copilot-cli-schema.md +1 -0
- package/docs/engine-restart.md +4 -2
- package/docs/internal-install.md +44 -5
- package/docs/named-agents.md +48 -0
- package/docs/pr-author-identity.md +63 -10
- package/docs/runtime-adapters.md +39 -0
- package/docs/temporary-agents.md +172 -0
- package/engine/ado/comment.js +261 -4
- package/engine/agents/llm.js +26 -0
- package/engine/agents/playbook.js +2 -1
- package/engine/api/settings-validation.js +25 -0
- package/engine/core/operator-identity.js +23 -1
- package/engine/core/queries.js +53 -2
- package/engine/core/shared.js +286 -9
- package/engine/db/migrations/032-review-enrolled-pr-context-only.js +95 -0
- package/engine/operations/cli.js +102 -1
- package/engine/orchestration/lifecycle.js +7 -0
- package/engine/orchestration/routing.js +4 -1
- package/engine/providers/gh-comment.js +159 -0
- package/engine/recovery/stop-stack.js +16 -2
- package/engine/runtimes/claude.js +3 -0
- package/engine/runtimes/codex.js +4 -0
- package/engine/runtimes/copilot.js +23 -2
- package/engine.js +2 -2
- package/package.json +1 -1
- package/playbooks/fix.md +23 -2
- package/playbooks/shared-rules.md +24 -2
package/dashboard/js/utils.js
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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);
|
package/dashboard/styles.css
CHANGED
|
@@ -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); }
|
package/dashboard-build.js
CHANGED
|
@@ -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
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
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. |
|
package/docs/engine-restart.md
CHANGED
|
@@ -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,
|
|
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".
|
package/docs/internal-install.md
CHANGED
|
@@ -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
|
|
168
|
-
closed**, before any uninstall or package
|
|
169
|
-
was taken, the exact argv it issued,
|
|
170
|
-
|
|
171
|
-
|
|
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
|