@yemi33/minions 0.1.2179 → 0.1.2181

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/README.md CHANGED
@@ -57,7 +57,7 @@ node ~/.minions/minions.js init
57
57
  | `minions remove <dir-or-name> [--keep-data \| --purge --force]` | Unlink a project: cancels pending work items, drains dispatch + kills active agents, cleans worktrees, disables linked schedules, archives `projects/<name>/` to `projects/.archived/<name>-YYYYMMDD/`. Use `--keep-data` to leave the data dir in place, or `--purge --force` to delete it. |
58
58
  | `minions list` | List all linked projects with descriptions |
59
59
  | `minions restart` | Start engine and dashboard together (recommended after reboot) |
60
- | `minions start` | Start engine daemon (ticks every 10s, auto-syncs MCP servers) |
60
+ | `minions start` | Start engine daemon (ticks every 10s) |
61
61
  | `minions stop` | Stop the engine |
62
62
  | `minions status` | Show agents, projects, dispatch queue, quality metrics |
63
63
  | `minions pause` / `resume` | Pause/resume dispatching |
@@ -71,7 +71,7 @@ node ~/.minions/minions.js init
71
71
  | `minions kill` | Kill all active agents and reset their dispatches to pending |
72
72
  | `minions complete <dispatch-id>` | Manually mark a dispatch as completed |
73
73
  | `minions config set-cli <R> [--model M]` | Persist the default runtime/model without starting the engine |
74
- | `minions mcp-sync` | Sync MCP servers from `~/.claude.json` |
74
+ | `minions mcp-sync` | Print harness propagation diagnostic (read-only; same source as `minions doctor --harness`) |
75
75
  | `minions cleanup` | Run cleanup manually (temp files, worktrees, zombies) |
76
76
  | `minions dash` | Open dashboard (starts if not already running, port 7331) |
77
77
  | `minions nuke --confirm` | Factory reset runtime state and reset config to defaults |
@@ -322,9 +322,11 @@ When dispatching agents, the engine reads each project's `CLAUDE.md` and injects
322
322
 
323
323
  Agents need repo-host tooling to create PRs, post review comments, check status, and handle review feedback. GitHub repos use `gh`. Azure DevOps repos should use the `az` CLI first and keep the Azure DevOps MCP server available only as a fallback when `az` is unavailable or does not support the required operation.
324
324
 
325
- Agents inherit MCP servers directly from `~/.claude.json` as Claude Code processes add fallback servers there and they're immediately available to all agents on next spawn.
325
+ Agents inherit MCP servers, skills, and slash-commands directly from each runtime's native config — Claude reads `~/.claude.json` + `~/.claude/{skills,commands}/`, Copilot reads `~/.copilot/` + `~/.agents/`, Codex reads `~/.codex/`. Add fallback servers, skills, or commands there and they're immediately available to all agents on next spawn. No sync step is required.
326
326
 
327
- Manually refresh with `minions mcp-sync`.
327
+ **What your agent sees.** On every dispatch, the engine merges three layers around the agent's worktree: (1) **user-scope** MCP servers, skills, and slash-commands from the runtime's home dir (e.g. `~/.claude/`, `~/.copilot/`); (2) **project-scope** equivalents from `<repo>/.claude/`, `<repo>/.copilot/`, `<repo>/.mcp.json` — propagated even when the worktree is on a different branch than your live checkout (gate: `engine.harnessPropagateProjectLocal`, default on); (3) **workspace MCPs** from the worktree's `.mcp.json`, pre-approved for Claude so the agent never has to prompt (gate: `engine.claudePreApproveWorkspaceMcps`, default on). Per-agent opt-out via `agent.hermeticHarness: true` (or fleet-wide `engine.hermeticHarness: true`) strips everything except `minionsDir`, giving you a known-empty harness for reproducible runs.
328
+
329
+ To audit which propagation surfaces (asset dirs, skill roots, slash-command dirs, MCP config files) each runtime sees on this host, run `minions mcp-sync` (or `minions doctor --harness` for the same diagnostic with a non-zero exit on registry errors). Both commands are read-only — they print the per-runtime survey without mutating any state. See [docs/harness-propagation.md](docs/harness-propagation.md) for the propagation contract.
328
330
 
329
331
  ### GitHub Users
330
332
 
@@ -346,7 +348,7 @@ az login
346
348
  az devops configure --defaults organization=https://dev.azure.com/YOUR_ORG project=YOUR_PROJECT
347
349
  ```
348
350
 
349
- Optionally add the [Azure DevOps MCP server](https://github.com/microsoft/azure-devops-mcp) to your Claude Code settings (`~/.claude.json`) as a fallback. The engine will auto-sync it to all agents on next start.
351
+ Optionally add the [Azure DevOps MCP server](https://github.com/microsoft/azure-devops-mcp) to your Claude Code settings (`~/.claude.json`) as a fallback. Agents inherit it on next spawn no sync step needed.
350
352
 
351
353
  ## Work Items
352
354
 
package/bin/minions.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * minions list List linked projects
11
11
  * minions update [--no-wait] Update to latest version (--no-wait backgrounds the post-update restart)
12
12
  * minions version Show installed and package versions
13
- * minions doctor Check prerequisites and runtime health
13
+ * minions doctor [--harness] Check prerequisites and runtime health (--harness: print harness propagation diagnostic)
14
14
  * minions restart [--open] Stop + start engine + dashboard (--open forces a new browser tab)
15
15
  * minions start [--open] Start engine + dashboard if not already running (no-op when up)
16
16
  * minions stop Stop the engine
@@ -29,7 +29,7 @@
29
29
  * minions config set-cli <R> [--model M] Persist default runtime/model
30
30
  * minions bridge <subcmd> Constellation bridge: status|health|enable|disable
31
31
  * minions plan <file|text> [proj] Run a plan
32
- * minions mcp-sync Sync MCP servers from ~/.claude.json
32
+ * minions mcp-sync Print harness propagation diagnostic (read-only; same source as `doctor --harness`)
33
33
  * minions nuke --confirm Factory reset runtime state/config
34
34
  * minions uninstall --confirm Remove Minions and uninstall package
35
35
  */
@@ -1100,7 +1100,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1100
1100
  minions init Bootstrap ~/.minions/ (first time)
1101
1101
  minions update [--no-wait] Update to latest version (--no-wait backgrounds the restart)
1102
1102
  minions version Show installed vs package version
1103
- minions doctor Check prerequisites and runtime health
1103
+ minions doctor [--harness] Check prerequisites and runtime health (--harness: print harness propagation diagnostic)
1104
1104
  minions add <project-dir> Link a project (interactive)
1105
1105
  minions remove <project-dir> Unlink a project
1106
1106
  minions list List linked projects
@@ -1123,7 +1123,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1123
1123
  minions config set-cli <R> [--model M]
1124
1124
  Persist default runtime/model without starting
1125
1125
  minions bridge <subcmd> Constellation bridge: status|health|enable|disable
1126
- minions mcp-sync Sync MCP servers from ~/.claude.json
1126
+ minions mcp-sync Print harness propagation diagnostic (read-only; same source as 'doctor --harness')
1127
1127
  minions cleanup Clean temp files, worktrees, zombies
1128
1128
  minions pr comment <repo> <n> Post a marker-prepended PR comment via gh
1129
1129
  --agent <id> --kind <k> [--wi <id>] (--body-file <f> | --body <text>)
@@ -1450,8 +1450,17 @@ ${fs.existsSync(path.join(PKG_ROOT, '.git')) ? `
1450
1450
  console.log(' npm install -g @yemi33/minions && minions init\n');
1451
1451
  } else if (cmd === 'doctor') {
1452
1452
  ensureInstalled();
1453
- const { doctor } = require(path.join(MINIONS_HOME, 'engine', 'preflight'));
1454
- doctor(MINIONS_HOME).then(ok => process.exit(ok ? 0 : 1));
1453
+ const { doctor, runHarnessDoctor } = require(path.join(MINIONS_HOME, 'engine', 'preflight'));
1454
+ if (rest.includes('--harness')) {
1455
+ // P-a3f9b2c1 — print the harness propagation diagnostic and exit. The
1456
+ // legacy `minions doctor` still runs the full preflight + runtime fleet
1457
+ // sweep; `--harness` is a one-shot survey of the asset-propagation
1458
+ // surfaces only (see docs/harness-propagation.md).
1459
+ const ok = runHarnessDoctor(MINIONS_HOME);
1460
+ process.exit(ok ? 0 : 1);
1461
+ } else {
1462
+ doctor(MINIONS_HOME).then(ok => process.exit(ok ? 0 : 1));
1463
+ }
1455
1464
  } else if (cmd === 'watchdog') {
1456
1465
  // External recovery scheduled by the OS. Survives cluster-kill scenarios
1457
1466
  // the in-process supervisor can't (Windows job-object teardown when the
@@ -211,6 +211,66 @@ async function _memRefreshHistory() {
211
211
  }));
212
212
  }
213
213
 
214
+ // P-e5f6a7b8 — operator-driven heap snapshot capture button. Gates the
215
+ // POST behind a window.confirm() that calls out the multi-second stall
216
+ // and the 50–200 MB per-process disk write. Renders the resulting paths
217
+ // (and any engine timeout) into #memory-heap-snapshot-result via
218
+ // textContent so the XSS gate remains clean.
219
+ const HEAP_SNAPSHOT_CONFIRM_TOKEN = 'YES_I_UNDERSTAND_THIS_STALLS_THE_ENGINE';
220
+ const HEAP_SNAPSHOT_CONFIRM_MSG =
221
+ 'Capture a heap snapshot from BOTH the engine and dashboard processes?\n\n' +
222
+ '⚠️ Each process will be stalled for several seconds and will write a 50–200 MB ' +
223
+ '.heapsnapshot file under engine/diagnostics/. Only do this when actively ' +
224
+ 'debugging a leak. Rate-limited to 1 capture per 60 seconds.';
225
+
226
+ function _memSetHeapResult(text) {
227
+ const el = document.getElementById('memory-heap-snapshot-result');
228
+ if (el) el.textContent = text;
229
+ }
230
+
231
+ async function _memCaptureHeapSnapshot() {
232
+ const btn = document.getElementById('memory-heap-snapshot-btn');
233
+ if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
234
+ if (!window.confirm(HEAP_SNAPSHOT_CONFIRM_MSG)) return;
235
+ }
236
+ if (btn) { btn.disabled = true; }
237
+ _memSetHeapResult('Capturing… (engine + dashboard each stall for several seconds)');
238
+ try {
239
+ const res = await fetch(
240
+ '/api/diagnostics/heap-snapshot?confirm=' + encodeURIComponent(HEAP_SNAPSHOT_CONFIRM_TOKEN),
241
+ { method: 'POST' });
242
+ let data = null;
243
+ try { data = await res.json(); } catch { /* empty body */ }
244
+ if (!res.ok) {
245
+ const msg = (data && (data.error || data.hint)) || ('HTTP ' + res.status);
246
+ _memSetHeapResult('Capture failed: ' + msg);
247
+ return;
248
+ }
249
+ const lines = [];
250
+ lines.push('Dashboard snapshot: ' + (data && data.dashboardSnapshot ? data.dashboardSnapshot : '(none)'));
251
+ if (data && data.engineTimedOut) {
252
+ lines.push('Engine snapshot: TIMED OUT after ' + ((data.timeoutMs || 30000) / 1000) + 's ' +
253
+ '— the engine may have captured it after the dashboard gave up; check engine/diagnostics/.');
254
+ } else {
255
+ lines.push('Engine snapshot: ' + (data && data.engineSnapshot ? data.engineSnapshot : '(none)'));
256
+ }
257
+ lines.push('');
258
+ lines.push('Load these in Chrome DevTools → Memory → Load to inspect.');
259
+ _memSetHeapResult(lines.join('\n'));
260
+ } catch (e) {
261
+ _memSetHeapResult('Capture failed: ' + (e && e.message ? e.message : String(e)));
262
+ } finally {
263
+ if (btn) { btn.disabled = false; }
264
+ }
265
+ }
266
+
267
+ function _memWireHeapSnapshotButton() {
268
+ const btn = document.getElementById('memory-heap-snapshot-btn');
269
+ if (!btn || btn.dataset.heapSnapshotWired === '1') return;
270
+ btn.dataset.heapSnapshotWired = '1';
271
+ btn.addEventListener('click', _memCaptureHeapSnapshot);
272
+ }
273
+
214
274
  function mountMemoryPanel() {
215
275
  if (_memoryPanelMounted) return;
216
276
  // No-op when the engine page hasn't been assembled yet (defensive — the
@@ -222,6 +282,7 @@ function mountMemoryPanel() {
222
282
  _memRefreshHistory();
223
283
  _memoryPanelLiveInterval = setInterval(_memRefreshLive, MEMORY_POLL_LIVE_MS);
224
284
  _memoryPanelHistoryInterval = setInterval(_memRefreshHistory, MEMORY_POLL_HISTORY_MS);
285
+ _memWireHeapSnapshotButton();
225
286
  }
226
287
 
227
288
  function unmountMemoryPanel() {
@@ -259,4 +320,5 @@ window.MinionsMemoryPanel = {
259
320
  mountMemoryPanel,
260
321
  unmountMemoryPanel,
261
322
  _buildSparkline: _memBuildSparkline,
323
+ _captureHeapSnapshot: _memCaptureHeapSnapshot,
262
324
  };
@@ -149,7 +149,9 @@ const RENDER_VERSIONS = {
149
149
  metrics: 1,
150
150
  workItems: 3,
151
151
  skills: 1,
152
+ commands: 1,
152
153
  mcpServers: 1,
154
+ harnessDiag: 1,
153
155
  schedules: 1,
154
156
  watches: 2,
155
157
  meetings: 1,
@@ -854,8 +856,24 @@ function _processStatusUpdate(data, opts) {
854
856
  });
855
857
  _changed('skills', data.skills);
856
858
  _safeRender('skills', function() { renderSkills(data.skills || []); });
859
+ _changed('commands', data.commands);
860
+ _safeRender('commands', function() { renderCommands(data.commands || []); });
857
861
  _changed('mcpServers', data.mcpServers);
858
862
  _safeRender('mcpServers', function() { renderMcpServers(data.mcpServers || []); });
863
+ // Harness propagation diagnostic comes from /api/harness/diagnostics
864
+ // (rare-change, fetched on each /api/status refresh so the tools page is
865
+ // always up to date when an operator drops a project-local skill).
866
+ _safeRender('harnessDiag', function() {
867
+ fetch('/api/harness/diagnostics')
868
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
869
+ .then(function (fresh) {
870
+ window._lastHarnessDiag = fresh;
871
+ _safeRender('harnessDiag', function() { renderHarnessDiagnostics(fresh); });
872
+ })
873
+ .catch(function () {
874
+ if (window._lastHarnessDiag) _safeRender('harnessDiag', function() { renderHarnessDiagnostics(window._lastHarnessDiag); });
875
+ });
876
+ });
859
877
  // Schedule definitions stay on /api/status (config-derived), but the
860
878
  // _lastRun/_lastResult overlay is re-applied client-side from
861
879
  // /state/engine/schedule-runs.json so a freshly-fired schedule lights up
@@ -1149,21 +1167,16 @@ document.addEventListener('visibilitychange', function() {
1149
1167
  // Ring buffer capturing the last 50 /api/status poll cycles so a user
1150
1168
  // reporting "the dashboard didn't auto-update when X changed" can paste
1151
1169
  // `window._refreshDiagnostics()` from devtools or click the "diag" footer
1152
- // chip to surface a table. Gated behind feature flag
1153
- // `dashboard-refresh-diagnostics` (engine/features.js) disabled in
1154
- // production, enabled in dev. When disabled, the helpers below are no-ops
1155
- // (the boolean check is the first line of every collection path) so the
1156
- // steady-state poll loop is byte-identical to its pre-flag behaviour.
1170
+ // chip to surface a table. The capture is cheap (in-memory ring buffer,
1171
+ // no network, no DOM writes) and always on (P-c1d4e8b6 deleted the
1172
+ // per-flag gate that had shipped default ON since v0.1.2034) so the next
1173
+ // staleness complaint can be diagnosed immediately from
1174
+ // window._refreshDiagnostics without a settings flip.
1157
1175
  const _DIAG_RING_SIZE = 50;
1158
1176
  const _refreshDiagBuf = [];
1159
1177
  let _prevRefreshTs = 0;
1160
1178
  let _diagHiddenSinceMs = 0; // ts when tab last went hidden; 0 when visible
1161
1179
  let _lastVisibilityChangeAt = 0;
1162
- function _isRefreshDiagOn() {
1163
- try {
1164
- return !!(window.MinionsFeatures && window.MinionsFeatures.isOn('dashboard-refresh-diagnostics'));
1165
- } catch { return false; }
1166
- }
1167
1180
  function _pushDiagEntry(entry) {
1168
1181
  _refreshDiagBuf.push(entry);
1169
1182
  if (_refreshDiagBuf.length > _DIAG_RING_SIZE) _refreshDiagBuf.shift();
@@ -1173,7 +1186,6 @@ function _pushDiagEntry(entry) {
1173
1186
  window._refreshDiagnostics = function() { return _refreshDiagBuf.slice(); };
1174
1187
  window._refreshDiagnosticsClear = function() { _refreshDiagBuf.length = 0; };
1175
1188
  document.addEventListener('visibilitychange', function() {
1176
- if (!_isRefreshDiagOn()) return;
1177
1189
  const now = Date.now();
1178
1190
  const vs = document.visibilityState;
1179
1191
  let hiddenForMs = null;
@@ -1202,11 +1214,10 @@ async function refresh(opts) {
1202
1214
  // console-spam and adds load to whatever's wedged).
1203
1215
  if (_nextPollAllowedAt && Date.now() < _nextPollAllowedAt) return;
1204
1216
  _refreshInFlight = true;
1205
- const _diagOn = _isRefreshDiagOn();
1206
- const _t0 = _diagOn ? Date.now() : 0;
1207
- const _gap = _diagOn && _prevRefreshTs ? _t0 - _prevRefreshTs : null;
1208
- if (_diagOn) _prevRefreshTs = _t0;
1209
- const _diagEntry = _diagOn ? {
1217
+ const _t0 = Date.now();
1218
+ const _gap = _prevRefreshTs ? _t0 - _prevRefreshTs : null;
1219
+ _prevRefreshTs = _t0;
1220
+ const _diagEntry = {
1210
1221
  ts: _t0,
1211
1222
  kind: 'refresh',
1212
1223
  gap_since_prev_ms: _gap,
@@ -1221,7 +1232,7 @@ async function refresh(opts) {
1221
1232
  changed: null,
1222
1233
  render_duration_ms: null,
1223
1234
  error_message: null,
1224
- } : null;
1235
+ };
1225
1236
  try {
1226
1237
  const headers = {};
1227
1238
  if (_lastStatusEtag) headers['If-None-Match'] = _lastStatusEtag;
@@ -1235,16 +1246,14 @@ async function refresh(opts) {
1235
1246
  if (res.status === 304 && _lastStatusData) {
1236
1247
  // Cache hit — reuse last payload, skip parsing entirely.
1237
1248
  data = _lastStatusData;
1238
- if (_diagEntry) {
1239
- _diagEntry.response_status = '304';
1240
- _diagEntry.bytes_received = 0;
1241
- // D2: capture etag on 304 so the diag table can show whether the
1242
- // server's ETag advanced even when we're reusing the cached body.
1243
- // Without this the "etag↓" column is blank on every 304 row and the
1244
- // operator can't tell server-side advancement from a pinned cache.
1245
- const etag304 = res.headers && (res.headers.get ? res.headers.get('etag') : null);
1246
- if (etag304) _diagEntry.etag_received = etag304;
1247
- }
1249
+ _diagEntry.response_status = '304';
1250
+ _diagEntry.bytes_received = 0;
1251
+ // D2: capture etag on 304 so the diag table can show whether the
1252
+ // server's ETag advanced even when we're reusing the cached body.
1253
+ // Without this the "etag↓" column is blank on every 304 row and the
1254
+ // operator can't tell server-side advancement from a pinned cache.
1255
+ const etag304 = res.headers && (res.headers.get ? res.headers.get('etag') : null);
1256
+ if (etag304) _diagEntry.etag_received = etag304;
1248
1257
  } else {
1249
1258
  data = await res.json();
1250
1259
  const etag = res.headers && (res.headers.get ? res.headers.get('etag') : null);
@@ -1252,12 +1261,10 @@ async function refresh(opts) {
1252
1261
  _lastStatusEtag = etag;
1253
1262
  _lastStatusData = data;
1254
1263
  }
1255
- if (_diagEntry) {
1256
- _diagEntry.response_status = String(res.status);
1257
- _diagEntry.etag_received = etag || null;
1258
- const cl = res.headers && (res.headers.get ? res.headers.get('content-length') : null);
1259
- _diagEntry.bytes_received = cl != null && cl !== '' ? Number(cl) : null;
1260
- }
1264
+ _diagEntry.response_status = String(res.status);
1265
+ _diagEntry.etag_received = etag || null;
1266
+ const cl = res.headers && (res.headers.get ? res.headers.get('content-length') : null);
1267
+ _diagEntry.bytes_received = cl != null && cl !== '' ? Number(cl) : null;
1261
1268
  }
1262
1269
  // Auto-reload policy (2026-05-29):
1263
1270
  // - dashboardStartedAt change → reload (covers `minions restart`)
@@ -1284,31 +1291,26 @@ async function refresh(opts) {
1284
1291
  _consecutiveStatusFails = 0;
1285
1292
  _nextPollAllowedAt = 0;
1286
1293
  if (_unreachableSince) _markDashboardReachable();
1287
- const _renderStart = _diagOn ? Date.now() : 0;
1288
- let _diagChanges = null;
1289
- if (_diagOn) {
1290
- _diagChanges = {};
1291
- _lastChangedFlags = _diagChanges;
1292
- }
1294
+ const _renderStart = Date.now();
1295
+ const _diagChanges = {};
1296
+ _lastChangedFlags = _diagChanges;
1293
1297
  try {
1294
1298
  _processStatusUpdate(data, opts);
1295
1299
  } finally {
1296
- if (_diagOn) {
1297
- _lastChangedFlags = null;
1298
- _diagEntry.render_duration_ms = Date.now() - _renderStart;
1299
- _diagEntry.changed = _diagChanges;
1300
- _diagEntry.workItems_changed = !!(_diagChanges && _diagChanges.workItems);
1301
- // Post-slim, data.workItems is undefined; read from the dedicated
1302
- // endpoint cache.
1303
- _diagEntry.workItems_count = (window._lastWorkItems || []).length;
1304
- _diagEntry.statusCacheVersion = (data.version && data.version.statusCacheVersion) != null
1305
- ? data.version.statusCacheVersion
1306
- : null;
1307
- }
1300
+ _lastChangedFlags = null;
1301
+ _diagEntry.render_duration_ms = Date.now() - _renderStart;
1302
+ _diagEntry.changed = _diagChanges;
1303
+ _diagEntry.workItems_changed = !!(_diagChanges && _diagChanges.workItems);
1304
+ // Post-slim, data.workItems is undefined; read from the dedicated
1305
+ // endpoint cache.
1306
+ _diagEntry.workItems_count = (window._lastWorkItems || []).length;
1307
+ _diagEntry.statusCacheVersion = (data.version && data.version.statusCacheVersion) != null
1308
+ ? data.version.statusCacheVersion
1309
+ : null;
1308
1310
  }
1309
1311
  } catch(e) {
1310
1312
  console.error('refresh error', e);
1311
- if (_diagEntry && !_diagEntry.response_status) {
1313
+ if (!_diagEntry.response_status) {
1312
1314
  _diagEntry.response_status = (e && e.name === 'AbortError') ? 'abort' : 'error';
1313
1315
  _diagEntry.error_message = String((e && e.message) || e);
1314
1316
  }
@@ -1328,7 +1330,7 @@ async function refresh(opts) {
1328
1330
  }
1329
1331
  finally {
1330
1332
  _refreshInFlight = false;
1331
- if (_diagEntry) _pushDiagEntry(_diagEntry);
1333
+ _pushDiagEntry(_diagEntry);
1332
1334
  }
1333
1335
  }
1334
1336
 
@@ -1376,15 +1378,11 @@ switchPage(currentPage);
1376
1378
  window.MinionsRefresh = { refresh };
1377
1379
 
1378
1380
  // ── Refresh-diagnostic footer chip + modal (W-mphejzx100081972) ───────────
1379
- // Injected only when the dashboard-refresh-diagnostics feature flag is on
1380
- // (engine/features.js → MINIONS_FEATURES.flags). The chip floats bottom-right
1381
- // and opens a simple table view of the ring buffer plus a "send to engine"
1382
- // button that POSTs the snapshot to /api/diagnostics/refresh. No DOM is
1383
- // touched (and no listeners installed beyond the visibilitychange handler
1384
- // above, which itself short-circuits when the flag is off) when the flag is
1385
- // disabled — production behaviour stays untouched.
1381
+ // Always installed (P-c1d4e8b6 deleted the per-flag gate that previously
1382
+ // guarded this). The chip floats bottom-left and opens a simple table view
1383
+ // of the ring buffer plus a "send to engine" button that POSTs the
1384
+ // snapshot to /api/diagnostics/refresh.
1386
1385
  (function installRefreshDiagChip() {
1387
- if (!_isRefreshDiagOn()) return;
1388
1386
  try {
1389
1387
  const chip = document.createElement('button');
1390
1388
  chip.id = 'refresh-diag-chip';
@@ -523,7 +523,148 @@ async function _addSelectedProjects() {
523
523
  }
524
524
  }
525
525
 
526
- window.MinionsOther = { renderProjects, optimisticallyAddProject, projectChipRemove, renderMcpServers, renderMetrics, renderLlmPerf, renderTokenUsage, _aggregateEngineUsageForTokenTile, openScanProjectsModal };
526
+ function _scopeBadge(scope, projectName, pluginName) {
527
+ // PRD-shaped scope label (user|project:<name>|plugin|agent-skill). The
528
+ // commands feed today only emits user-roots (per-runtime), plugin, and
529
+ // project; agent-skill is reserved so when a runtime starts shipping
530
+ // agent-scoped commands they don't need a new badge style.
531
+ let label = scope;
532
+ let color = 'var(--muted)';
533
+ if (scope === 'project' && projectName) { label = 'project:' + projectName; color = 'var(--blue)'; }
534
+ else if (scope === 'plugin') { label = pluginName ? 'plugin:' + pluginName : 'plugin'; color = 'var(--purple)'; }
535
+ else if (scope === 'agent-skill') { color = 'var(--orange)'; }
536
+ else if (scope === 'claude-code' || scope === 'copilot' || scope === 'codex') {
537
+ label = 'user:' + scope;
538
+ }
539
+ return '<span style="font-size:var(--text-xs);color:' + color + ';margin-left:6px;font-weight:500">[' + escHtml(label) + ']</span>';
540
+ }
541
+
542
+ function renderCommands(commands) {
543
+ const el = document.getElementById('commands-list');
544
+ const countEl = document.getElementById('commands-count');
545
+ const arr = Array.isArray(commands) ? commands : [];
546
+ if (countEl) countEl.textContent = arr.length;
547
+ if (!el) return;
548
+ if (!arr.length) {
549
+ el.innerHTML = '<p class="empty">No slash commands found. Drop a <code>.md</code> file into <code>~/.claude/commands/</code>, <code>~/.copilot/commands/</code>, a plugin\'s commands dir, or a registered project\'s <code>.claude/commands/</code> — they\'ll appear here automatically.</p>';
550
+ return;
551
+ }
552
+
553
+ // Group by scope bucket so the list reads "user roots first, then plugins, then projects".
554
+ const buckets = new Map();
555
+ for (const c of arr) {
556
+ const key = c.scope === 'project' ? 'project:' + (c.projectName || '?')
557
+ : c.scope === 'plugin' ? 'plugin:' + (c.pluginName || '?')
558
+ : c.scope; // user-roots use their runtime name
559
+ if (!buckets.has(key)) buckets.set(key, []);
560
+ buckets.get(key).push(c);
561
+ }
562
+
563
+ const sections = [];
564
+ for (const [key, list] of buckets) {
565
+ const header = '<div style="font-size:var(--text-sm);color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;margin:10px 0 4px">' + escHtml(key) + ' <span style="color:var(--muted);text-transform:none;letter-spacing:0">(' + list.length + ')</span></div>';
566
+ const rows = list.map(c =>
567
+ '<div style="font-size:var(--text-base);padding:4px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text);display:inline-flex;align-items:center;gap:6px;margin:2px 4px 2px 0" title="' + escHtml((c.title || '') + (c.rel ? ' • ' + c.rel : '')) + '">' +
568
+ '<code style="color:var(--blue);background:transparent;padding:0">/' + escHtml(c.commandName || '') + '</code>' +
569
+ _scopeBadge(c.scope, c.projectName, c.pluginName) +
570
+ '</div>'
571
+ ).join('');
572
+ sections.push(header + '<div style="display:flex;flex-wrap:wrap;gap:4px">' + rows + '</div>');
573
+ }
574
+ // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: scope key, command name, project name, plugin name, title, rel path)
575
+ el.innerHTML = sections.join('') +
576
+ '<p style="font-size:var(--text-sm);color:var(--muted);margin:10px 0 0">Mirrors the runtime-native command roots that adapters expose via <code>getCommandRoots()</code>. Projects and plugins are aggregated alongside user-scope dirs.</p>';
577
+ }
578
+
579
+ function _harnessRowsBlock(label, rows) {
580
+ if (!rows || !rows.length) {
581
+ return '<div style="font-size:var(--text-sm);color:var(--muted);margin:4px 0 0 12px">' + escHtml(label) + ': <em>(adapter does not expose this)</em></div>';
582
+ }
583
+ const body = rows.map(r => {
584
+ const marker = r.exists ? '<span style="color:var(--green)">✓</span>' : '<span style="color:var(--orange)">⚠</span>';
585
+ const missing = r.exists ? '' : ' <span style="color:var(--orange);font-size:var(--text-xs)">(missing on disk)</span>';
586
+ return '<div style="font-family:monospace;font-size:var(--text-sm);margin-left:18px;display:flex;gap:8px;align-items:baseline">' +
587
+ marker + ' <span>' + escHtml(r.path) + '</span> <span style="color:var(--muted)">[' + escHtml(r.scope) + ']</span>' + missing +
588
+ '</div>';
589
+ }).join('');
590
+ return '<div style="margin:4px 0 0 12px"><div style="font-size:var(--text-sm);color:var(--muted);margin-bottom:2px">' + escHtml(label) + ':</div>' + body + '</div>';
591
+ }
592
+
593
+ function renderHarnessDiagnostics(diag) {
594
+ const el = document.getElementById('harness-diag');
595
+ if (!el) return;
596
+ if (!diag || typeof diag !== 'object') {
597
+ el.innerHTML = '<p class="empty">Harness diagnostics unavailable.</p>';
598
+ return;
599
+ }
600
+ const parts = [];
601
+ parts.push('<div style="font-size:var(--text-sm);color:var(--muted);margin-bottom:8px">Fleet-default runtime: <code style="color:var(--blue)">' + escHtml(diag.fleetDefaultCli || '?') + '</code></div>');
602
+
603
+ // Per-runtime adapter rows
604
+ for (const r of diag.runtimes || []) {
605
+ parts.push('<details style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">Runtime: ' + escHtml(r.name) + '</summary>' +
606
+ _harnessRowsBlock('User asset dirs (--add-dir)', r.userAssetDirs) +
607
+ _harnessRowsBlock('Skill roots (CLI native discovery)', r.skillRoots) +
608
+ _harnessRowsBlock('Skill write targets', r.skillWriteTargets) +
609
+ _harnessRowsBlock('Slash command roots', r.commandRoots) +
610
+ _harnessRowsBlock('MCP config files', r.mcpConfigPaths) +
611
+ '</details>');
612
+ }
613
+
614
+ // --add-dir snapshot for fleet default
615
+ const snap = diag.addDirSnapshot || [];
616
+ parts.push('<details open style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">--add-dir snapshot for ' + escHtml(diag.fleetDefaultCli || 'fleet default') + ' <span style="color:var(--muted);font-weight:400">(' + snap.length + ' dir' + (snap.length === 1 ? '' : 's') + ')</span></summary>' +
617
+ (snap.length === 0
618
+ ? '<div style="font-size:var(--text-sm);color:var(--muted);margin:6px 0 0 12px"><em>(no dirs attached — engine/spawn-agent.js unavailable or adapter has no asset dirs)</em></div>'
619
+ : snap.map(d =>
620
+ '<div style="font-family:monospace;font-size:var(--text-sm);margin-left:18px;display:flex;gap:8px;align-items:baseline"><span style="color:var(--green)">✓</span> <span>' + escHtml(d.path) + '</span> <span style="color:var(--muted)">[' + escHtml(d.scope) + ']</span></div>'
621
+ ).join('')
622
+ ) +
623
+ '</details>');
624
+
625
+ // Suppressed assets
626
+ const suppressed = diag.suppressed || [];
627
+ if (suppressed.length > 0) {
628
+ parts.push('<details open style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">Suppressed assets <span style="color:var(--muted);font-weight:400">(' + suppressed.length + ')</span></summary>' +
629
+ suppressed.map(s =>
630
+ '<div style="margin:6px 0 0 12px"><code style="color:var(--orange)">' + escHtml(s.flag) + '</code> <span style="color:var(--muted);font-size:var(--text-sm)">(' + escHtml(String(s.value)) + ')</span>' +
631
+ '<div style="font-size:var(--text-sm);color:var(--muted);margin-left:18px">' + escHtml(s.effect || '') + '</div></div>'
632
+ ).join('') +
633
+ '</details>');
634
+ }
635
+
636
+ // Project-local-on-main footgun
637
+ const footgun = (diag.projectLocalOnMain || []).filter(e => e && e.uncommittedAssets && e.uncommittedAssets.length > 0);
638
+ if (footgun.length > 0) {
639
+ parts.push('<details open style="margin:6px 0;border:1px solid var(--orange);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer;color:var(--orange)">⚠ Project-local assets on main checkout (won\'t propagate to worktree)</summary>' +
640
+ footgun.map(entry =>
641
+ '<div style="margin:8px 0 0 12px">' +
642
+ '<div style="font-weight:600">' + escHtml(entry.project) + ' <span style="font-family:monospace;color:var(--muted);font-weight:400">' + escHtml(entry.localPath) + '</span></div>' +
643
+ '<div style="font-size:var(--text-sm);color:var(--muted);margin:4px 0 6px">' + escHtml(entry.footgunWarning || '') + '</div>' +
644
+ entry.uncommittedAssets.map(a =>
645
+ '<div style="font-family:monospace;font-size:var(--text-sm);margin-left:12px;display:flex;gap:8px;align-items:baseline"><span style="color:var(--orange)">⚠</span> <span>' + escHtml(a.file) + '</span> <span style="color:var(--muted)">[' + escHtml(a.kind) + ']</span></div>'
646
+ ).join('') +
647
+ '</div>'
648
+ ).join('') +
649
+ '</details>');
650
+ }
651
+
652
+ // Missing dirs summary (aggregate)
653
+ const missing = diag.missingDirs || [];
654
+ if (missing.length > 0) {
655
+ parts.push('<details style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">Missing on-disk dirs <span style="color:var(--muted);font-weight:400">(' + missing.length + ')</span></summary>' +
656
+ '<div style="font-size:var(--text-sm);color:var(--muted);margin:4px 0 6px 12px">Adapters declare these paths but they do not exist on this host. Warnings, not failures — create them if the runtime should look there.</div>' +
657
+ missing.map(m =>
658
+ '<div style="font-family:monospace;font-size:var(--text-sm);margin-left:18px"><span style="color:var(--orange)">⚠</span> ' + escHtml(m.path) + ' <span style="color:var(--muted)">[' + escHtml(m.scope) + ' · ' + escHtml(m.kind) + ' · ' + escHtml(m.runtime) + ']</span></div>'
659
+ ).join('') +
660
+ '</details>');
661
+ }
662
+
663
+ // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: runtime name, path, scope, flag, effect, project name, localPath, file, kind)
664
+ el.innerHTML = parts.join('');
665
+ }
666
+
667
+ window.MinionsOther = { renderProjects, optimisticallyAddProject, projectChipRemove, renderMcpServers, renderCommands, renderHarnessDiagnostics, renderMetrics, renderLlmPerf, renderTokenUsage, _aggregateEngineUsageForTokenTile, openScanProjectsModal };
527
668
 
528
669
  // ─── keep_processes panel (W-mp68q6ke0010de68) ─────────────────────────────
529
670
  // Polls /api/keep-processes every refresh (engine page only) and renders a
@@ -372,6 +372,10 @@ async function editWorkItem(id, source) {
372
372
  '<label style="color:var(--text);font-size:var(--text-md)">Depends On (work-item ids, comma- or newline-separated)' +
373
373
  '<textarea id="wi-edit-depends-on" rows="2" placeholder="W-foo, W-bar" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit;resize:vertical">' + escapeHtml((Array.isArray(item.depends_on) ? item.depends_on : []).join(', ')) + '</textarea>' +
374
374
  '</label>' +
375
+ '<label style="color:var(--text);font-size:var(--text-md)">Working directory (subpath, optional)' +
376
+ '<input id="wi-edit-workdir" placeholder="packages/foo" value="' + escapeHtml((item.meta && item.meta.workdir) || '') + '" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit">' +
377
+ '<small style="display:block;margin-top:4px;color:var(--muted);font-size:var(--text-sm)">Relative POSIX subpath under the project root (or worktree, for code-mutating types). Leave empty to dispatch at the project root. Examples: <code>packages/foo</code>, <code>apps/dashboard</code>. Absolute paths and <code>..</code> segments are rejected.</small>' +
378
+ '</label>' +
375
379
  '<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px">' +
376
380
  '<button onclick="closeModal()" class="pr-pager-btn" style="padding:6px 16px;font-size:var(--text-md)">Cancel</button>' +
377
381
  '<button onclick="submitWorkItemEdit(\'' + escapeHtml(id) + '\',\'' + escapeHtml(source || '') + '\',event)" class="btn-primary-lg">Save</button>' +
@@ -396,13 +400,14 @@ async function submitWorkItemEdit(id, source, e) {
396
400
  const acceptanceCriteria = acRaw.split('\n').filter(function(l) { return l.trim(); });
397
401
  const dependsRaw = document.getElementById('wi-edit-depends-on')?.value || '';
398
402
  const depends_on = dependsRaw.split(/[\n,]/).map(function(s) { return s.trim(); }).filter(Boolean);
403
+ const workdirRaw = (document.getElementById('wi-edit-workdir')?.value || '').trim();
399
404
  if (!title) { if (btn) { btn.disabled = false; btn.textContent = 'Save'; } alert('Title is required'); return; }
400
405
  try { closeModal(); } catch { /* may not be open */ }
401
406
  showToast('cmd-toast', 'Work item updated', true);
402
407
  try {
403
408
  const res = await fetch('/api/work-items/update', {
404
409
  method: 'POST', headers: { 'Content-Type': 'application/json' },
405
- body: JSON.stringify({ id, source: source || undefined, title, description, type, priority, agent, references, acceptanceCriteria, depends_on })
410
+ body: JSON.stringify({ id, source: source || undefined, title, description, type, priority, agent, references, acceptanceCriteria, depends_on, workdir: workdirRaw || null })
406
411
  });
407
412
  if (res.ok) { refresh(); } else { const d = await res.json().catch(() => ({})); alert('Update failed: ' + (d.error || 'unknown')); editWorkItem(id, source); }
408
413
  } catch (e) { alert('Update error: ' + e.message); editWorkItem(id, source); }
@@ -592,6 +597,9 @@ function openCreateWorkItemModal() {
592
597
  '<label style="color:var(--text);font-size:var(--text-md)">Acceptance Criteria <textarea id="wi-new-ac" rows="2" style="' + inputStyle + ';resize:vertical" placeholder="One criterion per line (optional)"></textarea></label>' +
593
598
  '<label style="color:var(--text);font-size:var(--text-md)">Depends On <textarea id="wi-new-depends-on" rows="2" style="' + inputStyle + ';resize:vertical" placeholder="W-foo, W-bar — comma- or newline-separated work-item ids (optional)"></textarea></label>' +
594
599
  '<label style="color:var(--text);font-size:var(--text-md)">References <textarea id="wi-new-refs" rows="2" style="' + inputStyle + ';resize:vertical" placeholder="url | title | type — one per line (optional)"></textarea></label>' +
600
+ '<label style="color:var(--text);font-size:var(--text-md)">Working directory (subpath, optional) <input id="wi-new-workdir" style="' + inputStyle + '" placeholder="packages/foo — leave empty for project root">' +
601
+ '<small style="display:block;margin-top:4px;color:var(--muted);font-size:var(--text-sm)">Relative subpath under the project root. Use for monorepo subpackage dispatches so the agent\'s cwd lands inside <code>packages/foo</code> instead of the project root.</small>' +
602
+ '</label>' +
595
603
  '<label id="wi-new-skippr-row" style="color:var(--text);font-size:var(--text-md);display:flex;gap:8px;align-items:center;cursor:pointer"><input type="checkbox" id="wi-new-skippr"> Skip PR creation (push branch only)</label>' +
596
604
  '<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:4px">' +
597
605
  '<button onclick="closeModal()" class="pr-pager-btn">Cancel</button>' +
@@ -629,6 +637,7 @@ async function _submitCreateWorkItem(e) {
629
637
  const parts = l.split('|').map(s => s.trim());
630
638
  return { url: parts[0] || '', title: parts[1] || parts[0] || '', type: parts[2] || 'link' };
631
639
  }).filter(r => r.url);
640
+ const workdir = (document.getElementById('wi-new-workdir')?.value || '').trim();
632
641
 
633
642
  try {
634
643
  const body = { title, description: desc, type, priority };
@@ -637,6 +646,7 @@ async function _submitCreateWorkItem(e) {
637
646
  if (acceptanceCriteria.length) body.acceptanceCriteria = acceptanceCriteria;
638
647
  if (references.length && references[0].url) body.references = references;
639
648
  if (depends_on.length) body.depends_on = depends_on;
649
+ if (workdir) body.meta = Object.assign({}, body.meta, { workdir: workdir });
640
650
  const skipPr = document.getElementById('wi-new-skippr')?.checked || false;
641
651
  if (skipPr) body.skipPr = true;
642
652
 
@@ -707,6 +717,13 @@ function _wiRenderDetail(item) {
707
717
  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>');
708
718
  html += field('Agent', escapeHtml(item.dispatched_to || item.agent || 'Auto'));
709
719
  html += field('Source', escapeHtml(item._source || 'central'));
720
+ // P-714ef144 — surface meta.workdir in the detail modal so operators can
721
+ // confirm the agent's actual cwd. Renders nothing when unset (which is
722
+ // the back-compat default = project root). Code-tagged so the path is
723
+ // visually clearly distinct from prose fields.
724
+ if (item.meta && typeof item.meta.workdir === 'string' && item.meta.workdir) {
725
+ 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>');
726
+ }
710
727
  if (item.created) html += field('Created', escapeHtml(formatLocalDateTime(item.created)));
711
728
  if (item.dispatched_at) html += field('Dispatched', escapeHtml(formatLocalDateTime(item.dispatched_at)) + ' to ' + escapeHtml(item.dispatched_to || '?'));
712
729
  if (item.completedAt) html += field('Completed', escapeHtml(formatLocalDateTime(item.completedAt)));