@yemi33/minions 0.1.2178 → 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.
Files changed (39) hide show
  1. package/README.md +7 -5
  2. package/bin/minions.js +39 -17
  3. package/dashboard/js/command-parser.js +1 -1
  4. package/dashboard/js/memory-panel.js +324 -0
  5. package/dashboard/js/qa.js +2 -2
  6. package/dashboard/js/refresh.js +19 -1
  7. package/dashboard/js/render-other.js +143 -2
  8. package/dashboard/js/render-prs.js +2 -1
  9. package/dashboard/js/render-schedules.js +1 -1
  10. package/dashboard/js/render-watches.js +1 -1
  11. package/dashboard/js/render-work-items.js +18 -1
  12. package/dashboard/js/settings.js +23 -0
  13. package/dashboard/pages/engine-memory-panel.html +56 -0
  14. package/dashboard/pages/engine.html +1 -0
  15. package/dashboard/pages/tools.html +8 -0
  16. package/dashboard/slim/js/link-pr.js +5 -5
  17. package/dashboard/slim/js/modals-tiles.js +44 -3
  18. package/dashboard/slim/js/projects.js +8 -6
  19. package/dashboard/slim/styles.css +20 -0
  20. package/dashboard-build.js +17 -2
  21. package/dashboard.js +693 -19
  22. package/docs/branch-derivation.md +13 -1
  23. package/docs/diagnostics-memory.md +446 -0
  24. package/docs/harness-propagation.md +273 -0
  25. package/docs/human-vs-automated.md +1 -1
  26. package/docs/runtime-adapters.md +5 -0
  27. package/engine/cli.js +24 -5
  28. package/engine/diagnostics-memory.js +190 -0
  29. package/engine/lifecycle.js +111 -1
  30. package/engine/preflight.js +265 -0
  31. package/engine/queries.js +331 -19
  32. package/engine/runtimes/claude.js +36 -0
  33. package/engine/runtimes/codex.js +19 -0
  34. package/engine/runtimes/copilot.js +27 -36
  35. package/engine/shared.js +390 -15
  36. package/engine/spawn-agent.js +178 -12
  37. package/engine/watchdog.js +6 -0
  38. package/engine.js +277 -4
  39. package/package.json +2 -2
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
 
@@ -43,11 +44,12 @@ const steeringStore = require('./engine/steering-store');
43
44
  const projectDiscovery = require('./engine/project-discovery');
44
45
  const features = require('./engine/features');
45
46
  const ccWorkerPool = require('./engine/cc-worker-pool');
47
+ const diagnosticsMemory = require('./engine/diagnostics-memory');
46
48
  const os = require('os');
47
49
 
48
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;
49
51
  const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
50
- getSkills, getInbox, getNotesWithMeta, getPullRequests,
52
+ getSkills, getCommands, getInbox, getNotesWithMeta, getPullRequests,
51
53
  getEngineLog, getMetrics, getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getProjectGitStatus, timeSince,
52
54
  MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, DISPATCH_PATH, PRD_DIR } = queries;
53
55
 
@@ -104,6 +106,122 @@ const KB_PINS_PATH = shared.PINNED_ITEMS_PATH;
104
106
  const DASHBOARD_BROWSER_PRESENCE_PATH = path.join(ENGINE_DIR, 'dashboard-browser.json');
105
107
  const DASHBOARD_BROWSER_PRESENCE_MAX_AGE_MS = 45000;
106
108
 
109
+ // P-c3d4e5f6 — diagnostics-memory dashboard surface.
110
+ // The engine writes its latest sample to engine/diagnostics-memory.json on
111
+ // every memoryBaselineEveryTicks ticks; the dashboard reads it for
112
+ // /api/diagnostics/memory and accumulates polled engine samples into its
113
+ // own ring buffer (separate from diagnosticsMemory's own dashboard-self
114
+ // buffer) for /api/diagnostics/memory/history?process=engine. Both rings
115
+ // are in-process — restarting the dashboard zeroes them.
116
+ const DIAGNOSTICS_MEMORY_SIDECAR_PATH = path.join(ENGINE_DIR, 'diagnostics-memory.json');
117
+ const DIAGNOSTICS_MEMORY_STALE_MS = 5 * 60 * 1000;
118
+ const DIAGNOSTICS_MEMORY_SAMPLE_INTERVAL_MS = 60000;
119
+ const DIAGNOSTICS_MEMORY_ENGINE_RING_CAP = 1440;
120
+ const _engineMemoryRing = [];
121
+ let _lastEngineSampleCapturedAt = 0;
122
+ let _memorySamplerStop = null;
123
+
124
+ // Pure: assemble the /api/diagnostics/memory payload from already-read
125
+ // inputs. Exported for direct unit testing — handler callers feed it the
126
+ // live dashboard sample + the sidecar contents + Date.now().
127
+ function _buildMemoryDiagnostics({ dashboardSample, engineSample, now } = {}) {
128
+ const _now = Number.isFinite(now) ? now : Date.now();
129
+ const dashboard = dashboardSample && typeof dashboardSample === 'object' ? dashboardSample : null;
130
+ // engineSample may be the literal object from the sidecar OR null/{} when
131
+ // the sidecar is missing/unparseable. Treat anything without a finite
132
+ // capturedAt as stale (covers both "file missing" and "ancient sample").
133
+ let engine = null;
134
+ let engineStale = true;
135
+ if (engineSample && typeof engineSample === 'object' && Number.isFinite(engineSample.capturedAt)) {
136
+ engine = engineSample;
137
+ engineStale = (_now - engineSample.capturedAt) > DIAGNOSTICS_MEMORY_STALE_MS;
138
+ }
139
+ return { dashboard, engine, engineStale };
140
+ }
141
+
142
+ // Pure: slice the requested ring buffer. Returns up to `limit` newest
143
+ // samples (oldest first). `limit` defaults to the full buffer when missing
144
+ // or non-positive (matches diagnosticsMemory.getHistory semantics).
145
+ function _buildMemoryHistory({ ring, limit } = {}) {
146
+ if (!Array.isArray(ring)) return [];
147
+ const n = ring.length;
148
+ if (!Number.isInteger(limit) || limit <= 0 || limit >= n) return ring.slice();
149
+ return ring.slice(n - limit);
150
+ }
151
+
152
+ // Read the engine sidecar and, when it carries a never-seen-before
153
+ // capturedAt, push it onto the engine-side ring buffer. Dedup is by
154
+ // capturedAt so re-reads between engine baseline ticks don't bloat the
155
+ // ring. Best-effort: any failure leaves the ring untouched.
156
+ function _pollAndAccumulateEngineSample() {
157
+ let sample;
158
+ try { sample = safeJsonObj(DIAGNOSTICS_MEMORY_SIDECAR_PATH); }
159
+ catch { return; }
160
+ if (!sample || typeof sample !== 'object') return;
161
+ if (!Number.isFinite(sample.capturedAt)) return;
162
+ if (sample.capturedAt === _lastEngineSampleCapturedAt) return;
163
+ _lastEngineSampleCapturedAt = sample.capturedAt;
164
+ _engineMemoryRing.push(sample);
165
+ while (_engineMemoryRing.length > DIAGNOSTICS_MEMORY_ENGINE_RING_CAP) _engineMemoryRing.shift();
166
+ }
167
+
168
+ function _resetDiagnosticsMemoryForTesting() {
169
+ _engineMemoryRing.length = 0;
170
+ _lastEngineSampleCapturedAt = 0;
171
+ if (_memorySamplerStop) {
172
+ try { _memorySamplerStop(); } catch { /* ignore */ }
173
+ _memorySamplerStop = null;
174
+ }
175
+ try { diagnosticsMemory._resetForTest(); } catch { /* ignore */ }
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
+
107
225
  function ensureConfiguredProjectStateFiles() {
108
226
  for (const p of PROJECTS) {
109
227
  const root = p.localPath ? path.resolve(p.localPath) : null;
@@ -387,6 +505,25 @@ function inferActionPrRecord(action, prs, project = null) {
387
505
  }
388
506
 
389
507
  function copyWorkItemPrFields(item, input, pr = null) {
508
+ // W-mqbaby2a000pa8ee: Gate the LOOSE description/title scan in
509
+ // `shared.extractWorkItemPrRef` on `type: "fix"`. Without this gate,
510
+ // an implement/explore/test WI that merely mentions an existing PR
511
+ // in prose ("Class bug surfaced today on pull request 130") gets
512
+ // `targetPr` / `pr_id` / `prNumber` stamped, the engine then treats
513
+ // the WI as a fix against that PR, the PR-branch lookup fails, and
514
+ // the dispatch silently skips with `_pendingReason: null` forever.
515
+ //
516
+ // Structured PR pointers (`targetPr` / `pr_id` / `prUrl` / `prNumber` /
517
+ // `references[].url` / `meta.pr_followup.parent_pr_url`) are explicit
518
+ // operator intent — they stamp on EVERY type. An explicit `pr` record
519
+ // (caller already resolved the PR) also bypasses the gate. Only the
520
+ // last-resort description/title regex scan is type-gated.
521
+ if (!pr) {
522
+ const structuredRef = shared.extractStructuredWorkItemPrRef(input);
523
+ if (!structuredRef && String(item?.type || '').toLowerCase() !== WORK_TYPE.FIX) {
524
+ return;
525
+ }
526
+ }
390
527
  const prRef = getWorkItemPrRef(input);
391
528
  if (!prRef && !pr) return;
392
529
  const prNumber = pr ? shared.getPrNumber(pr) : shared.getPrNumber(prRef);
@@ -1044,6 +1181,251 @@ function _resolveSkillReadPath({ file, dir, source, config, skillFiles } = {}) {
1044
1181
  return null;
1045
1182
  }
1046
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
+
1047
1429
  function _agentSessionIsDraining(agentId) {
1048
1430
  const activeForAgent = (getDispatchQueue().active || []).some(d => d.agent === agentId);
1049
1431
  if (!activeForAgent) return false;
@@ -1951,6 +2333,7 @@ function _buildStatusSlowState() {
1951
2333
  // initialized + installId, and the version banner.
1952
2334
  return {
1953
2335
  skills: getSkills(),
2336
+ commands: getCommands(CONFIG),
1954
2337
  mcpServers: getMcpServers(),
1955
2338
  projects: PROJECTS.map(p => {
1956
2339
  const status = getProjectGitStatus(p.localPath, p.mainBranch);
@@ -1958,6 +2341,7 @@ function _buildStatusSlowState() {
1958
2341
  const branchMismatch = !!(mainBranch && status.remoteDefaultBranch && mainBranch !== status.remoteDefaultBranch);
1959
2342
  return {
1960
2343
  name: p.name,
2344
+ displayName: shared.projectDisplayName(p),
1961
2345
  path: p.localPath,
1962
2346
  description: p.description || '',
1963
2347
  ...status,
@@ -3497,13 +3881,25 @@ function getWorkItemPrRef(input) {
3497
3881
  // the dispatch to the existing PR branch instead of a fresh `work/<wi-id>`
3498
3882
  // parallel branch (issue #2999 / W-mpx6i5kh000ac040).
3499
3883
  //
3500
- // ASYMMETRY (W-mq18ec6h000p7b87): the engine's pr_not_found *gate* uses
3501
- // the strict `shared.extractStructuredWorkItemPrRef` gate uses
3502
- // structured-only; stamp uses loose. Rationale: operators creating fix
3503
- // WIs via API often paste the PR URL in description prose and expect
3504
- // `targetPr` to get auto-stamped here (best-effort, reversible). The gate
3505
- // blocks dispatch and must require explicit operator intent (a structured
3506
- // field) before doing so.
3884
+ // STRUCTURED-vs-LOOSE SPLIT (W-mq18ec6h000p7b87): two extractors exist
3885
+ // - `shared.extractStructuredWorkItemPrRef`: structured fields +
3886
+ // `references[].url` + `meta.pr_followup.parent_pr_url`. NO scan.
3887
+ // - `shared.extractWorkItemPrRef` (this helper): structured walk + a
3888
+ // last-resort first-paragraph/title scan.
3889
+ // The engine's `pr_not_found` dispatch gate uses the structured-only
3890
+ // variant. The original design intent was that the stamp path could
3891
+ // afford to be loose because gating would still require explicit
3892
+ // operator intent.
3893
+ //
3894
+ // FIX-TYPE GATE on the stamp path (W-mqbaby2a000pa8ee): in practice
3895
+ // the asymmetry leaked — the loose stamp writes to `item.pr_id` (a
3896
+ // canonical structured field), and the strict gate later reads
3897
+ // `item.pr_id` and sees the loose stamp AS IF it were structured
3898
+ // intent. To stop that leak without changing the loose detector
3899
+ // (which has other legitimate callers), `copyWorkItemPrFields` now
3900
+ // gates the loose result on `item.type === "fix"`. Non-fix WIs only
3901
+ // get stamped from structured fields (incl. references/follow-up).
3902
+ // See the comment block on `copyWorkItemPrFields` above for details.
3507
3903
  return shared.extractWorkItemPrRef(input);
3508
3904
  }
3509
3905
 
@@ -5775,11 +6171,24 @@ const server = http.createServer(async (req, res) => {
5775
6171
  if (!followupCheck.valid) {
5776
6172
  return jsonReply(res, 400, { error: followupCheck.error });
5777
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
+ }
5778
6182
  item.meta = { ...body.meta };
5779
6183
  if (followupCheck.value) {
5780
6184
  item.meta.pr_followup = followupCheck.value;
5781
6185
  validatedFollowup = followupCheck.value;
5782
6186
  }
6187
+ if (workdirCheck.value === null) {
6188
+ delete item.meta.workdir;
6189
+ } else {
6190
+ item.meta.workdir = workdirCheck.value;
6191
+ }
5783
6192
  }
5784
6193
  // PR follow-up traceability headers (W-mpej3cox00099466).
5785
6194
  const originAgent = extractMinionsAgentHeader(req);
@@ -5860,6 +6269,24 @@ const server = http.createServer(async (req, res) => {
5860
6269
  if (!Array.isArray(body.depends_on)) return jsonReply(res, 400, { error: 'depends_on must be an array of strings' });
5861
6270
  if (!body.depends_on.every(s => typeof s === 'string')) return jsonReply(res, 400, { error: 'depends_on entries must be strings' });
5862
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
+ }
5863
6290
 
5864
6291
  const target = resolveProjectSourceTarget(source, PROJECTS);
5865
6292
  if (target.error) return jsonReply(res, 404, { error: target.error });
@@ -5886,6 +6313,21 @@ const server = http.createServer(async (req, res) => {
5886
6313
  if (Array.isArray(body.depends_on)) {
5887
6314
  item.depends_on = body.depends_on.map(s => s.trim()).filter(Boolean);
5888
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
+ }
5889
6331
  item.updatedAt = new Date().toISOString();
5890
6332
  result = { code: 200, body: { ok: true, item } };
5891
6333
  return items;
@@ -7954,6 +8396,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7954
8396
  return;
7955
8397
  }
7956
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
+
7957
8408
  async function handleProjectsBrowse(req, res) {
7958
8409
  try {
7959
8410
  // Async child_process helpers — the picker dialog can sit on screen
@@ -9548,6 +9999,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9548
9999
  for (const key of Object.keys(childEnv)) {
9549
10000
  if (key === 'CLAUDECODE' || key.startsWith('CLAUDE_CODE') || key.startsWith('CLAUDECODE_')) delete childEnv[key];
9550
10001
  }
10002
+ // W-mqb9y83o — suppress auto-open in the respawned dashboard. The
10003
+ // restart endpoint is fired by CC or the dashboard UI; the operator
10004
+ // already has a tab open (that's how they hit the button), so the
10005
+ // post-restart hook must not pop a second one. Hard kill-switch read
10006
+ // by bin/minions.js#spawnFullStackAndVerify.
10007
+ childEnv.MINIONS_NO_AUTO_OPEN = '1';
9551
10008
  const proc = cpSpawn(process.execPath, [minionsBin, 'restart'], {
9552
10009
  cwd: MINIONS_DIR, stdio: 'ignore', detached: true, env: childEnv, windowsHide: true,
9553
10010
  });
@@ -10121,6 +10578,157 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10121
10578
  } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
10122
10579
  }
10123
10580
 
10581
+ // P-c3d4e5f6 — /api/diagnostics/memory.
10582
+ // Returns the latest in-process dashboard sample, the latest engine
10583
+ // sample (read fresh from engine/diagnostics-memory.json via safeJsonObj),
10584
+ // and an engineStale boolean. Engine staleness is true when the sidecar
10585
+ // is missing/unparseable OR its capturedAt is > 5 min old. The handler
10586
+ // does one small JSON read and one in-process sample — < 10ms warm.
10587
+ function handleDiagnosticsMemory(req, res) {
10588
+ try {
10589
+ let dashboardSample = null;
10590
+ try { dashboardSample = diagnosticsMemory.sampleSelf({ label: 'dashboard' }); }
10591
+ catch { /* leave dashboardSample null on sampler failure */ }
10592
+ const engineSample = safeJsonObj(DIAGNOSTICS_MEMORY_SIDECAR_PATH);
10593
+ const payload = _buildMemoryDiagnostics({
10594
+ dashboardSample,
10595
+ engineSample,
10596
+ now: Date.now(),
10597
+ });
10598
+ return jsonReply(res, 200, payload, req);
10599
+ } catch (e) {
10600
+ return jsonReply(res, 500, { error: e.message }, req);
10601
+ }
10602
+ }
10603
+
10604
+ // P-c3d4e5f6 — /api/diagnostics/memory/history?process=engine|dashboard&limit=N.
10605
+ // process=dashboard returns the diagnosticsMemory module's own ring
10606
+ // buffer (populated by startPeriodicSampling on dashboard boot).
10607
+ // process=engine returns the dashboard's accumulated polled snapshots
10608
+ // of engine/diagnostics-memory.json — engine.js only persists the
10609
+ // latest sample to the sidecar, so engine-side history is rebuilt by
10610
+ // the dashboard's poller (dedup by capturedAt). Limit defaults to the
10611
+ // full buffer when missing / non-positive.
10612
+ function handleDiagnosticsMemoryHistory(req, res) {
10613
+ try {
10614
+ const u = new URL(req.url, 'http://x');
10615
+ const proc = (u.searchParams.get('process') || '').trim().toLowerCase();
10616
+ const limitRaw = u.searchParams.get('limit');
10617
+ const limitParsed = limitRaw == null || limitRaw === '' ? null : parseInt(limitRaw, 10);
10618
+ const limit = Number.isInteger(limitParsed) && limitParsed > 0 ? limitParsed : null;
10619
+ let samples;
10620
+ if (proc === 'dashboard') {
10621
+ samples = diagnosticsMemory.getHistory(limit ? { limit } : {});
10622
+ } else if (proc === 'engine') {
10623
+ samples = _buildMemoryHistory({ ring: _engineMemoryRing, limit });
10624
+ } else {
10625
+ return jsonReply(res, 400, { error: 'process must be one of: engine, dashboard' }, req);
10626
+ }
10627
+ return jsonReply(res, 200, { process: proc, count: samples.length, samples }, req);
10628
+ } catch (e) {
10629
+ return jsonReply(res, 500, { error: e.message }, req);
10630
+ }
10631
+ }
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
+
10124
10732
  // Slim UX surface for the experimental redesigned dashboard.
10125
10733
  // The markup/CSS/JS live as fragments under dashboard/slim/ (layout.html +
10126
10734
  // styles.css + body.html + js/*.js) and are assembled by buildSlimHtml() —
@@ -11623,8 +12231,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11623
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 },
11624
12232
 
11625
12233
  // Work items
11626
- { 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 },
11627
- { 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 },
11628
12236
  { method: 'POST', path: '/api/work-items/retry', desc: 'Reset a failed/dispatched item to pending', params: 'id, source?', handler: handleWorkItemsRetry },
11629
12237
  { method: 'POST', path: '/api/work-items/delete', desc: 'Remove a work item, kill agent, clear dispatch', params: 'id, source?', handler: handleWorkItemsDelete },
11630
12238
  { method: 'POST', path: '/api/work-items/cancel', desc: 'Cancel a work item, kill agent, clear dispatch', params: 'id, source?, reason?', handler: handleWorkItemsCancel },
@@ -12198,6 +12806,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12198
12806
  // Skills
12199
12807
  { method: 'GET', path: '/api/skill', desc: 'Read a skill file', params: 'file, source?, dir?', handler: handleSkillRead },
12200
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
+
12201
12812
  // Projects
12202
12813
  { method: 'POST', path: '/api/projects/browse', desc: 'Open folder picker dialog, return selected path', handler: handleProjectsBrowse },
12203
12814
  { method: 'POST', path: '/api/projects/scan', desc: 'Scan a directory for git repos', params: 'path?, depth?', handler: handleProjectsScan },
@@ -12497,6 +13108,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12497
13108
  { method: 'POST', path: '/api/diagnostics/refresh', desc: 'Append a dashboard refresh-diagnostic ring buffer batch to engine/dashboard-diagnostics.log (rotated at 1 MB)', params: 'entries[]', handler: handleDiagnosticsRefresh },
12498
13109
  // Diagnostics — per-org ADO throttle state (W-mq03l6zh0006f0a1-d).
12499
13110
  { method: 'GET', path: '/api/diagnostics/ado-throttle', desc: 'Snapshot of per-org ADO throttle tracker state — { orgs: { [orgBase]: { throttled, retryAfter, consecutiveHits } } }. Falls back to a single `global` key when running against pre-per-org engines.', handler: handleDiagnosticsAdoThrottle },
13111
+ // Diagnostics — engine + dashboard memory baseline (P-c3d4e5f6).
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 },
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 },
12500
13116
  ];
12501
13117
 
12502
13118
  // ── Route Dispatcher ────────────────────────────────────────────────────────
@@ -12654,6 +13270,7 @@ module.exports = {
12654
13270
  _linkPullRequestForTracking: linkPullRequestForTracking,
12655
13271
  _updatePullRequestObserveFlag: updatePullRequestObserveFlag,
12656
13272
  _resolveSkillReadPath,
13273
+ _buildHarnessDiagnostics,
12657
13274
  // exported for testing — see test/unit/plans-archive-warnings.test.js
12658
13275
  _archivePrdPostProcess,
12659
13276
  // Per-CC-turn correlation surface
@@ -12695,6 +13312,11 @@ module.exports = {
12695
13312
  refreshStatusAsync,
12696
13313
  handleStatus: _handleStatusRequest,
12697
13314
  invalidateStatusCache,
13315
+ // exported for testing — see test/unit/status-snapshot-budget.test.js (P-f2a3b4c5).
13316
+ // The slim snapshot builder is the synchronous assembler used by getStatusJson()
13317
+ // and refreshStatusAsync(); the budget test calls it directly to measure byte
13318
+ // size and rebuild-time without standing up an HTTP server.
13319
+ getStatus,
12698
13320
  // Raw state-file passthrough — exported for direct unit testing.
12699
13321
  handleStateRead,
12700
13322
  STATE_READ_ALLOWED_DIRS,
@@ -12717,6 +13339,27 @@ module.exports = {
12717
13339
  // route's `builder` closure (getWorkItems().map(slimWorkItemForList)).
12718
13340
  _slimWorkItemForList: slimWorkItemForList,
12719
13341
  _WORK_ITEMS_SLIM_DESCRIPTION_CAP: WORK_ITEMS_SLIM_DESCRIPTION_CAP,
13342
+ // P-c3d4e5f6 — diagnostics-memory dashboard surface (handlers live in
13343
+ // the request-dispatch closure; expose the pure builders + constants so
13344
+ // unit tests can exercise the staleness gate and history slicing
13345
+ // without binding a server.)
13346
+ _buildMemoryDiagnostics,
13347
+ _buildMemoryHistory,
13348
+ _pollAndAccumulateEngineSample,
13349
+ _resetDiagnosticsMemoryForTesting,
13350
+ DIAGNOSTICS_MEMORY_SIDECAR_PATH,
13351
+ DIAGNOSTICS_MEMORY_STALE_MS,
13352
+ DIAGNOSTICS_MEMORY_SAMPLE_INTERVAL_MS,
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,
12720
13363
  };
12721
13364
 
12722
13365
  // Start the HTTP server only when run directly (node dashboard.js).
@@ -12807,15 +13450,19 @@ if (require.main === module) {
12807
13450
  Promise.resolve(queries.getKnowledgeBaseEntries())
12808
13451
  .catch(err => console.warn(`[dashboard] KB cache warm failed: ${err && err.message}`));
12809
13452
 
12810
- // Auto-open the browser unless suppressed. `minions restart` and the
12811
- // upgrade path set MINIONS_NO_AUTO_OPEN=1 because the CLI orchestrates the
12812
- // open itself after observing whether an existing tab reconnected.
12813
- if (!process.env.MINIONS_NO_AUTO_OPEN) {
12814
- const result = shared.openUrlInBrowser(`http://localhost:${PORT}`);
12815
- if (!result.ok) {
12816
- console.log(` Could not auto-open browser: ${result.error}`);
12817
- console.log(` Please open http://localhost:${PORT} manually.`);
12818
- }
13453
+ // Auto-open the browser. `minions restart` and the upgrade path set
13454
+ // MINIONS_NO_AUTO_OPEN=1 because the CLI orchestrates the open itself
13455
+ // after observing whether an existing tab reconnected; the primitive
13456
+ // (engine/shared.js#openUrlInBrowser) now owns the env-var check and
13457
+ // emits a debug-level SUPPRESSED log entry so we can prove the kill-
13458
+ // switch is firing.
13459
+ const result = shared.openUrlInBrowser(`http://localhost:${PORT}`, {
13460
+ reason: 'dashboard-self-open',
13461
+ callerHint: 'dashboard.js:13124',
13462
+ });
13463
+ if (!result.ok && !result.suppressed) {
13464
+ console.log(` Could not auto-open browser: ${result.error}`);
13465
+ console.log(` Please open http://localhost:${PORT} manually.`);
12819
13466
  }
12820
13467
 
12821
13468
  // Warm the CC runtime binary cache off the request path so the first CC /
@@ -12871,12 +13518,39 @@ if (require.main === module) {
12871
13518
  }
12872
13519
  }, 30000).unref();
12873
13520
  console.log(` Engine watchdog: active (checks every 30s)`);
13521
+
13522
+ // ─── Diagnostics: dashboard memory sampler (P-c3d4e5f6) ─────────────────
13523
+ // Drive the diagnosticsMemory ring buffer for /api/diagnostics/memory and
13524
+ // /api/diagnostics/memory/history?process=dashboard. The internal
13525
+ // setInterval already calls recordSample for us; the onSample callback
13526
+ // doubles as the engine-sidecar poller so /…/history?process=engine
13527
+ // accumulates one entry per fresh engine MEMORY_BASELINE write
13528
+ // (deduplicated by capturedAt).
13529
+ try {
13530
+ _memorySamplerStop = diagnosticsMemory.startPeriodicSampling({
13531
+ intervalMs: DIAGNOSTICS_MEMORY_SAMPLE_INTERVAL_MS,
13532
+ onSample: () => { try { _pollAndAccumulateEngineSample(); } catch { /* swallow */ } },
13533
+ });
13534
+ // Seed the engine ring immediately so the first call to
13535
+ // /api/diagnostics/memory/history?process=engine right after boot
13536
+ // already returns the latest sidecar sample without waiting a full
13537
+ // sampling interval.
13538
+ try { _pollAndAccumulateEngineSample(); } catch { /* swallow */ }
13539
+ } catch (e) {
13540
+ console.warn(`[dashboard] memory sampler failed to start: ${e && e.message}`);
13541
+ }
12874
13542
  })();
12875
13543
 
12876
13544
  // ── Graceful shutdown: flush debounced writes + clear runtime port file ──
12877
13545
  function _gracefulShutdown() {
12878
13546
  try { flushPendingDocSessions(); } catch {}
12879
13547
  try { shared.clearDashboardPortFile(MINIONS_DIR); } catch {}
13548
+ // P-c3d4e5f6 — stop the diagnostics-memory sampler cleanly so the
13549
+ // setInterval handle doesn't keep the event loop alive on SIGTERM.
13550
+ if (_memorySamplerStop) {
13551
+ try { _memorySamplerStop(); } catch { /* swallow */ }
13552
+ _memorySamplerStop = null;
13553
+ }
12880
13554
  }
12881
13555
  server.on('close', () => _gracefulShutdown());
12882
13556
  process.on('SIGTERM', () => { _gracefulShutdown(); process.exit(0); });