@yemi33/minions 0.1.2179 → 0.1.2180

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/dashboard.js CHANGED
@@ -14,6 +14,7 @@ const http = require('http');
14
14
  const zlib = require('zlib');
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
+ const v8 = require('v8');
17
18
  const llm = require('./engine/llm');
18
19
  const { resolveRuntime } = require('./engine/runtimes');
19
20
 
@@ -48,7 +49,7 @@ const os = require('os');
48
49
 
49
50
  const { safeRead, safeReadOrNull, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeUnlink, mutateJsonFileLocked, mutateTextFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, WORKTREE_REQUIRING_TYPES, reopenWorkItem } = shared;
50
51
  const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
51
- getSkills, getInbox, getNotesWithMeta, getPullRequests,
52
+ getSkills, getCommands, getInbox, getNotesWithMeta, getPullRequests,
52
53
  getEngineLog, getMetrics, getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getProjectGitStatus, timeSince,
53
54
  MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, DISPATCH_PATH, PRD_DIR } = queries;
54
55
 
@@ -174,6 +175,53 @@ function _resetDiagnosticsMemoryForTesting() {
174
175
  try { diagnosticsMemory._resetForTest(); } catch { /* ignore */ }
175
176
  }
176
177
 
178
+ // P-e5f6a7b8 — operator-driven heap snapshot capture for both dashboard
179
+ // and engine processes. v8.writeHeapSnapshot stalls the calling process
180
+ // for several seconds and emits a 50–200 MB file; the endpoint is
181
+ // guarded by a literal confirm token + per-process rate limit + retention
182
+ // cap so a runaway operator can't fill the disk or pin the engine.
183
+ const DIAGNOSTICS_DIR = path.join(ENGINE_DIR, 'diagnostics');
184
+ const HEAP_SNAPSHOT_REQUEST_PATH = path.join(DIAGNOSTICS_DIR, 'heap-snapshot-request.json');
185
+ const HEAP_SNAPSHOT_CONFIRM_TOKEN = 'YES_I_UNDERSTAND_THIS_STALLS_THE_ENGINE';
186
+ const HEAP_SNAPSHOT_RATE_LIMIT_MS = 60_000;
187
+ const HEAP_SNAPSHOT_ENGINE_TIMEOUT_MS = 30_000;
188
+ const HEAP_SNAPSHOT_ENGINE_POLL_MS = 250;
189
+ const HEAP_SNAPSHOT_RETAIN_COUNT = 5;
190
+ let _heapSnapshotLastCapturedAt = 0;
191
+
192
+ // Pure: compose the on-disk path for a heap snapshot. ISO timestamps
193
+ // contain colons (and a dot before the millis), which are illegal in
194
+ // NTFS filenames — replace both with `-` so Windows isn't surprised.
195
+ function _heapSnapshotPathFor(processLabel, iso) {
196
+ const safeIso = String(iso).replace(/[:.]/g, '-');
197
+ return path.join(DIAGNOSTICS_DIR, `heap-${processLabel}-${safeIso}.heapsnapshot`);
198
+ }
199
+
200
+ // Prune `heap-<processLabel>-*.heapsnapshot` files in `dir` down to the
201
+ // most-recent `retain` by mtime. Best-effort: missing dir / unreadable
202
+ // stat / failed unlink are swallowed (the next successful capture
203
+ // retries the prune).
204
+ function _heapSnapshotPrune(dir, processLabel, retain) {
205
+ let names;
206
+ try { names = fs.readdirSync(dir); }
207
+ catch { return; }
208
+ const prefix = `heap-${processLabel}-`;
209
+ const candidates = names.filter(n => n.startsWith(prefix) && n.endsWith('.heapsnapshot'));
210
+ if (candidates.length <= retain) return;
211
+ const stamped = candidates.map(n => {
212
+ try { return { n, mtime: fs.statSync(path.join(dir, n)).mtimeMs }; }
213
+ catch { return { n, mtime: 0 }; }
214
+ });
215
+ stamped.sort((a, b) => b.mtime - a.mtime); // newest first
216
+ for (const s of stamped.slice(retain)) {
217
+ try { fs.unlinkSync(path.join(dir, s.n)); } catch { /* best effort */ }
218
+ }
219
+ }
220
+
221
+ function _resetHeapSnapshotRateLimitForTesting() {
222
+ _heapSnapshotLastCapturedAt = 0;
223
+ }
224
+
177
225
  function ensureConfiguredProjectStateFiles() {
178
226
  for (const p of PROJECTS) {
179
227
  const root = p.localPath ? path.resolve(p.localPath) : null;
@@ -1133,6 +1181,251 @@ function _resolveSkillReadPath({ file, dir, source, config, skillFiles } = {}) {
1133
1181
  return null;
1134
1182
  }
1135
1183
 
1184
+ // ── Harness propagation diagnostic (P-c601f9a2) ──────────────────────────────
1185
+ // Per-runtime view of where the CLI looks for skills/commands/MCPs, what the
1186
+ // engine actually attaches via --add-dir, which assets are explicitly
1187
+ // suppressed (e.g. AGENTS.md auto-load, Copilot's bundled github-mcp-server),
1188
+ // and the canonical footgun: per-project assets that live as uncommitted files
1189
+ // in the operator's main checkout but won't propagate to fresh worktrees.
1190
+ //
1191
+ // Surfaced via GET /api/harness/diagnostics and rendered on the Tools page so
1192
+ // operators can spot a missing or unpropagated harness asset without leaving
1193
+ // the dashboard. See docs/harness-propagation.md for the full contract.
1194
+ const HARNESS_FOOTGUN_DIRS = [
1195
+ '.claude/skills',
1196
+ '.claude/commands',
1197
+ '.copilot/skills',
1198
+ '.copilot/commands',
1199
+ '.agents/skills',
1200
+ '.agents/commands',
1201
+ ];
1202
+ const HARNESS_FOOTGUN_FILES = [
1203
+ '.claude/.mcp.json',
1204
+ '.copilot/.mcp.json',
1205
+ '.mcp.json',
1206
+ ];
1207
+
1208
+ const _PROJECT_LOCAL_FOOTGUN_WARNING =
1209
+ 'These files exist in your main checkout but are uncommitted or untracked in ' +
1210
+ 'git. A fresh `git worktree add` for a mutating dispatch will NOT see them, so ' +
1211
+ 'the dispatched agent silently underperforms. Fix: commit them, move them to ' +
1212
+ 'user scope (~/.claude/skills/..., ~/.copilot/skills/...), or flip the project ' +
1213
+ 'to live-checkout mode (worktreeMode: live).';
1214
+
1215
+ function _walkUncommittedHarnessAssets(absStart, rootDir, projectName) {
1216
+ const results = [];
1217
+ const stack = [absStart];
1218
+ let count = 0;
1219
+ while (stack.length > 0 && count < 500) {
1220
+ const cur = stack.pop();
1221
+ let entries;
1222
+ try { entries = fs.readdirSync(cur, { withFileTypes: true }); }
1223
+ catch { continue; }
1224
+ for (const ent of entries) {
1225
+ if (ent.name === '.git' || ent.name === 'node_modules') continue;
1226
+ const full = path.join(cur, ent.name);
1227
+ if (ent.isDirectory()) { stack.push(full); continue; }
1228
+ if (!ent.isFile()) continue;
1229
+ const rel = path.relative(rootDir, full);
1230
+ const normalized = rel.replace(/\\/g, '/');
1231
+ let kind = null;
1232
+ if (
1233
+ normalized.startsWith('.claude/skills/') ||
1234
+ normalized.startsWith('.copilot/skills/') ||
1235
+ normalized.startsWith('.agents/skills/')
1236
+ ) kind = 'skill';
1237
+ else if (
1238
+ normalized.startsWith('.claude/commands/') ||
1239
+ normalized.startsWith('.copilot/commands/') ||
1240
+ normalized.startsWith('.agents/commands/')
1241
+ ) kind = 'command';
1242
+ else if (
1243
+ normalized === '.mcp.json' ||
1244
+ normalized === '.claude/.mcp.json' ||
1245
+ normalized === '.copilot/.mcp.json'
1246
+ ) kind = 'mcp';
1247
+ if (!kind) continue;
1248
+ results.push({ file: rel, kind, scope: `project:${projectName}` });
1249
+ count++;
1250
+ if (count >= 500) break;
1251
+ }
1252
+ }
1253
+ return results;
1254
+ }
1255
+
1256
+ function _scanProjectLocalHarnessFootgun(project) {
1257
+ const out = {
1258
+ project: project && project.name ? project.name : '',
1259
+ localPath: project && project.localPath ? project.localPath : '',
1260
+ uncommittedAssets: [],
1261
+ footgunWarning: _PROJECT_LOCAL_FOOTGUN_WARNING,
1262
+ };
1263
+ if (!project || !project.localPath) return out;
1264
+ let raw = '';
1265
+ try {
1266
+ const { execFileSync } = require('child_process');
1267
+ raw = execFileSync('git', ['status', '--porcelain', '-z'], {
1268
+ cwd: project.localPath,
1269
+ encoding: 'utf8',
1270
+ stdio: ['ignore', 'pipe', 'ignore'],
1271
+ timeout: 10000,
1272
+ });
1273
+ } catch { return out; }
1274
+ if (!raw) return out;
1275
+ const records = raw.split('\0').filter(Boolean);
1276
+ const seen = new Set();
1277
+ for (const rec of records) {
1278
+ if (rec.length < 4) continue;
1279
+ const filePath = rec.slice(3);
1280
+ const normalized = filePath.replace(/\\/g, '/');
1281
+ const isDirRecord = normalized.endsWith('/');
1282
+ const trimmed = isDirRecord ? normalized.slice(0, -1) : normalized;
1283
+ let overlaps = false;
1284
+ for (const dir of HARNESS_FOOTGUN_DIRS) {
1285
+ if (
1286
+ trimmed === dir ||
1287
+ trimmed.startsWith(dir + '/') ||
1288
+ dir.startsWith(trimmed + '/')
1289
+ ) { overlaps = true; break; }
1290
+ }
1291
+ if (!overlaps) {
1292
+ for (const file of HARNESS_FOOTGUN_FILES) {
1293
+ if (trimmed === file || file.startsWith(trimmed + '/')) { overlaps = true; break; }
1294
+ }
1295
+ }
1296
+ if (!overlaps) continue;
1297
+
1298
+ const absStart = path.join(project.localPath, filePath);
1299
+ let stat;
1300
+ try { stat = fs.statSync(absStart); } catch { continue; }
1301
+ if (stat.isDirectory()) {
1302
+ for (const asset of _walkUncommittedHarnessAssets(absStart, project.localPath, out.project)) {
1303
+ if (seen.has(asset.file)) continue;
1304
+ seen.add(asset.file);
1305
+ out.uncommittedAssets.push(asset);
1306
+ }
1307
+ } else {
1308
+ let kind = 'other';
1309
+ if (normalized.includes('/skills/')) kind = 'skill';
1310
+ else if (normalized.includes('/commands/')) kind = 'command';
1311
+ else if (normalized.endsWith('.mcp.json')) kind = 'mcp';
1312
+ if (seen.has(filePath)) continue;
1313
+ seen.add(filePath);
1314
+ out.uncommittedAssets.push({ file: filePath, kind, scope: `project:${out.project}` });
1315
+ }
1316
+ }
1317
+ return out;
1318
+ }
1319
+
1320
+ function _buildHarnessDiagnostics(opts = {}) {
1321
+ const homeDir = opts.homeDir || os.homedir();
1322
+ const engineConfig = opts.engineConfig || (CONFIG && CONFIG.engine) || {};
1323
+ const projects = (opts.projects || PROJECTS || []).filter(
1324
+ p => p && p.name && p.localPath && !String(p.name).startsWith('YOUR_'),
1325
+ );
1326
+ const existsFn = fs.existsSync;
1327
+
1328
+ let preflight, registry;
1329
+ try { preflight = require('./engine/preflight'); }
1330
+ catch { preflight = null; }
1331
+ try { registry = require('./engine/runtimes'); }
1332
+ catch { registry = null; }
1333
+
1334
+ const fleetDefaultCli = shared.resolveAgentCli(null, engineConfig);
1335
+ const runtimeNames = registry ? registry.listRuntimes() : [];
1336
+ const runtimes = [];
1337
+ const missingDirs = [];
1338
+
1339
+ const slimRow = r => ({ path: r.path, scope: r.scope, exists: !!r.exists });
1340
+ const collectMissing = (rows, runtimeName, kind) => {
1341
+ for (const r of rows) {
1342
+ if (r && !r.exists) {
1343
+ missingDirs.push({ runtime: runtimeName, path: r.path, scope: r.scope, kind });
1344
+ }
1345
+ }
1346
+ };
1347
+
1348
+ for (const runtimeName of runtimeNames) {
1349
+ if (!preflight) break;
1350
+ let runtime;
1351
+ try { runtime = registry.resolveRuntime(runtimeName); }
1352
+ catch { continue; }
1353
+ if (!runtime) continue;
1354
+ let rows;
1355
+ try { rows = preflight._runtimeHarnessRows(runtime, { homeDir, projects, existsFn }); }
1356
+ catch { continue; }
1357
+ const userAssetDirs = rows.userAssetDirs.map(slimRow);
1358
+ const skillRoots = [...rows.skillRootsUser, ...rows.skillRootsProject].map(slimRow);
1359
+ const skillWriteTargets = rows.skillWriteTargets.map(slimRow);
1360
+ const commandRoots = [...rows.commandRootsUser, ...rows.commandRootsProject].map(slimRow);
1361
+ const mcpConfigPaths = [...rows.mcpConfigUser, ...rows.mcpConfigProject].map(slimRow);
1362
+
1363
+ runtimes.push({
1364
+ name: runtimeName,
1365
+ userAssetDirs,
1366
+ skillRoots,
1367
+ skillWriteTargets,
1368
+ commandRoots,
1369
+ mcpConfigPaths,
1370
+ });
1371
+
1372
+ collectMissing(userAssetDirs, runtimeName, 'asset');
1373
+ collectMissing(skillRoots, runtimeName, 'skill');
1374
+ collectMissing(skillWriteTargets, runtimeName, 'skill-write');
1375
+ collectMissing(commandRoots, runtimeName, 'command');
1376
+ collectMissing(mcpConfigPaths, runtimeName, 'mcp');
1377
+ }
1378
+
1379
+ let addDirSnapshot = [];
1380
+ if (preflight && registry) {
1381
+ let fleetRuntime = null;
1382
+ try { fleetRuntime = registry.resolveRuntime(fleetDefaultCli); }
1383
+ catch { fleetRuntime = null; }
1384
+ if (fleetRuntime) {
1385
+ const snapshot = preflight._computeAddDirSnapshot(fleetRuntime, {
1386
+ minionsHome: MINIONS_DIR,
1387
+ homeDir,
1388
+ existsFn,
1389
+ });
1390
+ if (Array.isArray(snapshot)) addDirSnapshot = snapshot.map(slimRow);
1391
+ }
1392
+ }
1393
+
1394
+ const suppressed = [];
1395
+ if (engineConfig.copilotSuppressAgentsMd !== false) {
1396
+ suppressed.push({
1397
+ flag: 'copilotSuppressAgentsMd',
1398
+ value: true,
1399
+ effect: 'Copilot --no-custom-instructions: in-tree AGENTS.md is NOT auto-loaded, so Minions playbook prompts are not overridden by repo-local instructions.',
1400
+ });
1401
+ }
1402
+ if (engineConfig.copilotDisableBuiltinMcps !== false) {
1403
+ suppressed.push({
1404
+ flag: 'copilotDisableBuiltinMcps',
1405
+ value: true,
1406
+ effect: 'Copilot --disable-builtin-mcps: bundled github-mcp-server is stripped so dispatched agents do not open a parallel PR alongside the Minions PR.',
1407
+ });
1408
+ }
1409
+ if (engineConfig.hermeticHarness === true) {
1410
+ suppressed.push({
1411
+ flag: 'hermeticHarness',
1412
+ value: true,
1413
+ effect: 'Hermetic harness mode: only the engine playbook + system prompt are surfaced; user-scope skills, commands, and MCP configs are NOT propagated to this dispatch.',
1414
+ });
1415
+ }
1416
+
1417
+ const projectLocalOnMain = projects.map(_scanProjectLocalHarnessFootgun);
1418
+
1419
+ return {
1420
+ fleetDefaultCli,
1421
+ runtimes,
1422
+ addDirSnapshot,
1423
+ suppressed,
1424
+ projectLocalOnMain,
1425
+ missingDirs,
1426
+ };
1427
+ }
1428
+
1136
1429
  function _agentSessionIsDraining(agentId) {
1137
1430
  const activeForAgent = (getDispatchQueue().active || []).some(d => d.agent === agentId);
1138
1431
  if (!activeForAgent) return false;
@@ -2040,6 +2333,7 @@ function _buildStatusSlowState() {
2040
2333
  // initialized + installId, and the version banner.
2041
2334
  return {
2042
2335
  skills: getSkills(),
2336
+ commands: getCommands(CONFIG),
2043
2337
  mcpServers: getMcpServers(),
2044
2338
  projects: PROJECTS.map(p => {
2045
2339
  const status = getProjectGitStatus(p.localPath, p.mainBranch);
@@ -5877,11 +6171,24 @@ const server = http.createServer(async (req, res) => {
5877
6171
  if (!followupCheck.valid) {
5878
6172
  return jsonReply(res, 400, { error: followupCheck.error });
5879
6173
  }
6174
+ // P-714ef144 — per-WI meta.workdir override validation. Same shape
6175
+ // contract the engine enforces at dispatch time (shared.validateWorkItemWorkdir);
6176
+ // running it here means the operator gets an immediate 400 instead
6177
+ // of a deferred non-retryable INVALID_WORKDIR failure at dispatch.
6178
+ const workdirCheck = shared.validateWorkItemWorkdir(body.meta.workdir);
6179
+ if (!workdirCheck.valid) {
6180
+ return jsonReply(res, 400, { error: `meta.workdir: ${workdirCheck.error}` });
6181
+ }
5880
6182
  item.meta = { ...body.meta };
5881
6183
  if (followupCheck.value) {
5882
6184
  item.meta.pr_followup = followupCheck.value;
5883
6185
  validatedFollowup = followupCheck.value;
5884
6186
  }
6187
+ if (workdirCheck.value === null) {
6188
+ delete item.meta.workdir;
6189
+ } else {
6190
+ item.meta.workdir = workdirCheck.value;
6191
+ }
5885
6192
  }
5886
6193
  // PR follow-up traceability headers (W-mpej3cox00099466).
5887
6194
  const originAgent = extractMinionsAgentHeader(req);
@@ -5962,6 +6269,24 @@ const server = http.createServer(async (req, res) => {
5962
6269
  if (!Array.isArray(body.depends_on)) return jsonReply(res, 400, { error: 'depends_on must be an array of strings' });
5963
6270
  if (!body.depends_on.every(s => typeof s === 'string')) return jsonReply(res, 400, { error: 'depends_on entries must be strings' });
5964
6271
  }
6272
+ // P-714ef144 — pre-validate meta.workdir on the request body (same
6273
+ // contract the engine enforces). Operators get an immediate 400
6274
+ // instead of a deferred INVALID_WORKDIR at dispatch time. We tolerate
6275
+ // three shapes here:
6276
+ // - body.workdir: "packages/foo" (top-level convenience)
6277
+ // - body.meta: { workdir: "..." } (full meta replace — currently
6278
+ // never sent by the UI, but kept for symmetry with create)
6279
+ // - neither: no change to existing item.meta.workdir
6280
+ let workdirUpdate; // undefined = no change, null = unset, string = set
6281
+ if (body.workdir !== undefined) {
6282
+ const wdCheck = shared.validateWorkItemWorkdir(body.workdir);
6283
+ if (!wdCheck.valid) return jsonReply(res, 400, { error: `workdir: ${wdCheck.error}` });
6284
+ workdirUpdate = wdCheck.value; // null or normalized string
6285
+ } else if (body.meta && typeof body.meta === 'object' && 'workdir' in body.meta) {
6286
+ const wdCheck = shared.validateWorkItemWorkdir(body.meta.workdir);
6287
+ if (!wdCheck.valid) return jsonReply(res, 400, { error: `meta.workdir: ${wdCheck.error}` });
6288
+ workdirUpdate = wdCheck.value;
6289
+ }
5965
6290
 
5966
6291
  const target = resolveProjectSourceTarget(source, PROJECTS);
5967
6292
  if (target.error) return jsonReply(res, 404, { error: target.error });
@@ -5988,6 +6313,21 @@ const server = http.createServer(async (req, res) => {
5988
6313
  if (Array.isArray(body.depends_on)) {
5989
6314
  item.depends_on = body.depends_on.map(s => s.trim()).filter(Boolean);
5990
6315
  }
6316
+ if (workdirUpdate !== undefined) {
6317
+ // P-714ef144 — apply the workdir update. null clears the field
6318
+ // (and the meta object if it becomes empty after the delete);
6319
+ // a string value writes onto item.meta.workdir creating meta if
6320
+ // it doesn't already exist.
6321
+ if (workdirUpdate === null) {
6322
+ if (item.meta && typeof item.meta === 'object') {
6323
+ delete item.meta.workdir;
6324
+ if (Object.keys(item.meta).length === 0) delete item.meta;
6325
+ }
6326
+ } else {
6327
+ if (!item.meta || typeof item.meta !== 'object') item.meta = {};
6328
+ item.meta.workdir = workdirUpdate;
6329
+ }
6330
+ }
5991
6331
  item.updatedAt = new Date().toISOString();
5992
6332
  result = { code: 200, body: { ok: true, item } };
5993
6333
  return items;
@@ -8056,6 +8396,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8056
8396
  return;
8057
8397
  }
8058
8398
 
8399
+ async function handleHarnessDiagnostics(req, res) {
8400
+ try {
8401
+ const out = _buildHarnessDiagnostics();
8402
+ return jsonReply(res, 200, out, req);
8403
+ } catch (e) {
8404
+ return jsonReply(res, 500, { error: e && e.message ? e.message : String(e) }, req);
8405
+ }
8406
+ }
8407
+
8059
8408
  async function handleProjectsBrowse(req, res) {
8060
8409
  try {
8061
8410
  // Async child_process helpers — the picker dialog can sit on screen
@@ -10281,6 +10630,105 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10281
10630
  }
10282
10631
  }
10283
10632
 
10633
+ // P-e5f6a7b8 — POST /api/diagnostics/heap-snapshot.
10634
+ // Guarded by a literal confirm token + a 60s rate limit. On a valid
10635
+ // request the handler synchronously calls v8.writeHeapSnapshot for the
10636
+ // dashboard process (stalls THIS process for seconds, writes 50–200 MB),
10637
+ // then drops a sentinel JSON in engine/diagnostics/ that engine.js
10638
+ // consumes on its next tick to capture the engine-side snapshot. The
10639
+ // handler polls for the engine snapshot for up to 30 s (sentinel
10640
+ // removal + new file appearance) before returning. Retains the 5
10641
+ // most-recent snapshots per process; oldest are auto-pruned on every
10642
+ // successful capture.
10643
+ async function handleDiagnosticsHeapSnapshot(req, res) {
10644
+ try {
10645
+ const u = new URL(req.url, 'http://x');
10646
+ const confirm = (u.searchParams.get('confirm') || '').trim();
10647
+ if (confirm !== HEAP_SNAPSHOT_CONFIRM_TOKEN) {
10648
+ return jsonReply(res, 400, {
10649
+ error: 'confirm token required',
10650
+ hint:
10651
+ `POST again with ?confirm=${HEAP_SNAPSHOT_CONFIRM_TOKEN}. ` +
10652
+ `WARNING: capturing a heap snapshot stalls each process for several ` +
10653
+ `seconds and writes a 50–200 MB file per process under engine/diagnostics/.`,
10654
+ }, req);
10655
+ }
10656
+ const now = Date.now();
10657
+ if (_heapSnapshotLastCapturedAt && (now - _heapSnapshotLastCapturedAt) < HEAP_SNAPSHOT_RATE_LIMIT_MS) {
10658
+ const retryAfter = Math.ceil((HEAP_SNAPSHOT_RATE_LIMIT_MS - (now - _heapSnapshotLastCapturedAt)) / 1000);
10659
+ res.setHeader('Retry-After', String(Math.max(1, retryAfter)));
10660
+ return jsonReply(res, 429, {
10661
+ error: `rate limit: at most 1 successful heap snapshot per ${HEAP_SNAPSHOT_RATE_LIMIT_MS / 1000}s`,
10662
+ retryAfterSeconds: Math.max(1, retryAfter),
10663
+ }, req);
10664
+ }
10665
+
10666
+ try { fs.mkdirSync(DIAGNOSTICS_DIR, { recursive: true }); }
10667
+ catch (e) { return jsonReply(res, 500, { error: `mkdir diagnostics dir failed: ${e.message}` }, req); }
10668
+
10669
+ const iso = new Date().toISOString();
10670
+ const dashboardSnapshotPath = _heapSnapshotPathFor('dashboard', iso);
10671
+ const expectedEnginePath = _heapSnapshotPathFor('engine', iso);
10672
+
10673
+ // Dashboard side — synchronous; this stalls the event loop for seconds.
10674
+ try { v8.writeHeapSnapshot(dashboardSnapshotPath); }
10675
+ catch (e) { return jsonReply(res, 500, { error: `dashboard writeHeapSnapshot failed: ${e.message}` }, req); }
10676
+
10677
+ // Sentinel for engine.js. Engine reads its own ISO from the sentinel
10678
+ // so the engine snapshot lands at the predictable predicted path.
10679
+ try {
10680
+ fs.writeFileSync(HEAP_SNAPSHOT_REQUEST_PATH, JSON.stringify({ requestedAt: iso }));
10681
+ } catch (e) {
10682
+ return jsonReply(res, 500, { error: `sentinel write failed: ${e.message}` }, req);
10683
+ }
10684
+
10685
+ // Poll for engine snapshot — sentinel removed AND the predicted file
10686
+ // exists. Fall back to the newest engine snapshot if the predicted
10687
+ // path isn't there (engine wrote a different ISO somehow).
10688
+ const deadline = Date.now() + HEAP_SNAPSHOT_ENGINE_TIMEOUT_MS;
10689
+ let engineSnapshotPath = null;
10690
+ while (Date.now() < deadline) {
10691
+ let sentinelGone = false;
10692
+ try { sentinelGone = !fs.existsSync(HEAP_SNAPSHOT_REQUEST_PATH); } catch { sentinelGone = false; }
10693
+ if (sentinelGone) {
10694
+ if (fs.existsSync(expectedEnginePath)) {
10695
+ engineSnapshotPath = expectedEnginePath;
10696
+ break;
10697
+ }
10698
+ try {
10699
+ const newest = fs.readdirSync(DIAGNOSTICS_DIR)
10700
+ .filter(n => n.startsWith('heap-engine-') && n.endsWith('.heapsnapshot'))
10701
+ .map(n => ({ n, m: (() => { try { return fs.statSync(path.join(DIAGNOSTICS_DIR, n)).mtimeMs; } catch { return 0; } })() }))
10702
+ .sort((a, b) => b.m - a.m)[0];
10703
+ if (newest) {
10704
+ engineSnapshotPath = path.join(DIAGNOSTICS_DIR, newest.n);
10705
+ break;
10706
+ }
10707
+ } catch { /* directory race */ }
10708
+ }
10709
+ await new Promise(r => setTimeout(r, HEAP_SNAPSHOT_ENGINE_POLL_MS));
10710
+ }
10711
+ const engineTimedOut = !engineSnapshotPath;
10712
+
10713
+ // Stamp the rate-limit window on completion, regardless of engine
10714
+ // timeout — the operator already paid the dashboard-stall cost.
10715
+ _heapSnapshotLastCapturedAt = Date.now();
10716
+
10717
+ // Prune oldest snapshots per process down to the retention cap.
10718
+ try { _heapSnapshotPrune(DIAGNOSTICS_DIR, 'dashboard', HEAP_SNAPSHOT_RETAIN_COUNT); } catch { /* best effort */ }
10719
+ try { _heapSnapshotPrune(DIAGNOSTICS_DIR, 'engine', HEAP_SNAPSHOT_RETAIN_COUNT); } catch { /* best effort */ }
10720
+
10721
+ return jsonReply(res, 200, {
10722
+ dashboardSnapshot: path.resolve(dashboardSnapshotPath),
10723
+ engineSnapshot: engineSnapshotPath ? path.resolve(engineSnapshotPath) : null,
10724
+ engineTimedOut,
10725
+ timeoutMs: HEAP_SNAPSHOT_ENGINE_TIMEOUT_MS,
10726
+ }, req);
10727
+ } catch (e) {
10728
+ return jsonReply(res, e.statusCode || 500, { error: e.message }, req);
10729
+ }
10730
+ }
10731
+
10284
10732
  // Slim UX surface for the experimental redesigned dashboard.
10285
10733
  // The markup/CSS/JS live as fragments under dashboard/slim/ (layout.html +
10286
10734
  // styles.css + body.html + js/*.js) and are assembled by buildSlimHtml() —
@@ -11783,8 +12231,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11783
12231
  { method: 'POST', path: '/api/qa/runners/reload', desc: 'Clear the in-process runner registry, re-register built-ins, and re-scan qa-runners.d/ for plugin edits.', handler: handleQaRunnersReload },
11784
12232
 
11785
12233
  // Work items
11786
- { method: 'POST', path: '/api/work-items', desc: 'Create a new work item', params: 'title, type?, description?, priority?, project?, agent?, agents?, scope?, references?, acceptanceCriteria?, skipPr?, oneShot?, meta?, meta.pr_followup?, X-Minions-Agent?, X-Minions-Origin-Wi?', handler: handleWorkItemsCreate },
11787
- { method: 'POST', path: '/api/work-items/update', desc: 'Edit a pending/failed work item', params: 'id, source?, title?, description?, type?, priority?, agent?, references?, acceptanceCriteria?', handler: handleWorkItemsUpdate },
12234
+ { method: 'POST', path: '/api/work-items', desc: 'Create a new work item', params: 'title, type?, description?, priority?, project?, agent?, agents?, scope?, references?, acceptanceCriteria?, skipPr?, oneShot?, meta?, meta.pr_followup?, meta.workdir?, X-Minions-Agent?, X-Minions-Origin-Wi?', handler: handleWorkItemsCreate },
12235
+ { method: 'POST', path: '/api/work-items/update', desc: 'Edit a pending/failed work item', params: 'id, source?, title?, description?, type?, priority?, agent?, references?, acceptanceCriteria?, depends_on?, workdir?, meta?', handler: handleWorkItemsUpdate },
11788
12236
  { method: 'POST', path: '/api/work-items/retry', desc: 'Reset a failed/dispatched item to pending', params: 'id, source?', handler: handleWorkItemsRetry },
11789
12237
  { method: 'POST', path: '/api/work-items/delete', desc: 'Remove a work item, kill agent, clear dispatch', params: 'id, source?', handler: handleWorkItemsDelete },
11790
12238
  { method: 'POST', path: '/api/work-items/cancel', desc: 'Cancel a work item, kill agent, clear dispatch', params: 'id, source?, reason?', handler: handleWorkItemsCancel },
@@ -12358,6 +12806,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12358
12806
  // Skills
12359
12807
  { method: 'GET', path: '/api/skill', desc: 'Read a skill file', params: 'file, source?, dir?', handler: handleSkillRead },
12360
12808
 
12809
+ // Harness propagation diagnostic (P-c601f9a2)
12810
+ { method: 'GET', path: '/api/harness/diagnostics', desc: 'Per-runtime harness diagnostic (add-dirs, suppressed flags, project-local-on-main footgun, missing dirs)', handler: handleHarnessDiagnostics },
12811
+
12361
12812
  // Projects
12362
12813
  { method: 'POST', path: '/api/projects/browse', desc: 'Open folder picker dialog, return selected path', handler: handleProjectsBrowse },
12363
12814
  { method: 'POST', path: '/api/projects/scan', desc: 'Scan a directory for git repos', params: 'path?, depth?', handler: handleProjectsScan },
@@ -12660,6 +13111,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12660
13111
  // Diagnostics — engine + dashboard memory baseline (P-c3d4e5f6).
12661
13112
  { method: 'GET', path: '/api/diagnostics/memory', desc: 'Latest in-process dashboard memory sample plus the most-recent engine sample read from engine/diagnostics-memory.json. engineStale=true when the sidecar is missing or its capturedAt is > 5 min old.', handler: handleDiagnosticsMemory },
12662
13113
  { method: 'GET', path: '/api/diagnostics/memory/history', desc: 'In-memory ring buffer of memory samples. process=dashboard returns the dashboard\'s own collector (populated by startPeriodicSampling on boot). process=engine returns the dashboard\'s polled accumulation of engine/diagnostics-memory.json — engine.js only persists the latest sample to the sidecar, so engine-side history is rebuilt by the dashboard poller (dedup by capturedAt). Optional limit caps returned newest-N samples.', params: 'process (engine|dashboard), limit?', handler: handleDiagnosticsMemoryHistory },
13114
+ // Diagnostics — operator-driven heap snapshot capture (P-e5f6a7b8).
13115
+ { method: 'POST', path: '/api/diagnostics/heap-snapshot', desc: 'Capture v8 heap snapshots for both the dashboard and engine processes. Requires ?confirm=YES_I_UNDERSTAND_THIS_STALLS_THE_ENGINE because v8.writeHeapSnapshot stalls each process for several seconds and writes a 50–200 MB file. Rate-limited to 1 successful call per 60s; retains the 5 most-recent snapshots per process under engine/diagnostics/.', params: 'confirm=YES_I_UNDERSTAND_THIS_STALLS_THE_ENGINE', handler: handleDiagnosticsHeapSnapshot },
12663
13116
  ];
12664
13117
 
12665
13118
  // ── Route Dispatcher ────────────────────────────────────────────────────────
@@ -12817,6 +13270,7 @@ module.exports = {
12817
13270
  _linkPullRequestForTracking: linkPullRequestForTracking,
12818
13271
  _updatePullRequestObserveFlag: updatePullRequestObserveFlag,
12819
13272
  _resolveSkillReadPath,
13273
+ _buildHarnessDiagnostics,
12820
13274
  // exported for testing — see test/unit/plans-archive-warnings.test.js
12821
13275
  _archivePrdPostProcess,
12822
13276
  // Per-CC-turn correlation surface
@@ -12897,6 +13351,15 @@ module.exports = {
12897
13351
  DIAGNOSTICS_MEMORY_STALE_MS,
12898
13352
  DIAGNOSTICS_MEMORY_SAMPLE_INTERVAL_MS,
12899
13353
  DIAGNOSTICS_MEMORY_ENGINE_RING_CAP,
13354
+ // P-e5f6a7b8 — heap snapshot helpers exposed for direct unit testing.
13355
+ _heapSnapshotPathFor,
13356
+ _heapSnapshotPrune,
13357
+ _resetHeapSnapshotRateLimitForTesting,
13358
+ HEAP_SNAPSHOT_REQUEST_PATH,
13359
+ HEAP_SNAPSHOT_CONFIRM_TOKEN,
13360
+ HEAP_SNAPSHOT_RATE_LIMIT_MS,
13361
+ HEAP_SNAPSHOT_ENGINE_TIMEOUT_MS,
13362
+ HEAP_SNAPSHOT_RETAIN_COUNT,
12900
13363
  };
12901
13364
 
12902
13365
  // Start the HTTP server only when run directly (node dashboard.js).