@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/engine.js CHANGED
@@ -175,6 +175,13 @@ const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, reconci
175
175
  const diagnosticsMemory = require('./engine/diagnostics-memory');
176
176
  const DIAGNOSTICS_MEMORY_PATH = path.join(ENGINE_DIR, 'diagnostics-memory.json');
177
177
 
178
+ // P-e5f6a7b8 — sentinel consumed each tick; dashboard.js writes it after
179
+ // validating the operator confirm token. We pick up the iso the dashboard
180
+ // chose so the engine snapshot file lands at a predictable path the
181
+ // dashboard can directly poll for.
182
+ const DIAGNOSTICS_DIR = path.join(ENGINE_DIR, 'diagnostics');
183
+ const HEAP_SNAPSHOT_REQUEST_PATH = path.join(DIAGNOSTICS_DIR, 'heap-snapshot-request.json');
184
+
178
185
  // ─── Agent Spawner ──────────────────────────────────────────────────────────
179
186
 
180
187
  const activeProcesses = new Map(); // dispatchId → { proc, agentId, startedAt }
@@ -1701,9 +1708,54 @@ async function spawnAgent(dispatchItem, config) {
1701
1708
  // stages now short-circuit alongside any other read-only WI (see the gate at
1702
1709
  // `if (branchName && READ_ONLY_ROOT_TASK_TYPES.has(type))` below).
1703
1710
  const _preBranchName = meta?.branch ? sanitizeBranch(meta.branch) : null;
1711
+
1712
+ // ── Per-WI meta.workdir override (P-714ef144) ─────────────────────────
1713
+ // Optional relative POSIX subpath on the work item that lands the agent
1714
+ // at <base>/<workdir> instead of <base>. Used for monorepo subpackage
1715
+ // dispatches so the runtime CLI's cwd-rooted skill discovery surfaces
1716
+ // only the target package's `.claude/skills/<name>/SKILL.md` files.
1717
+ // Validation runs at dispatch time (the dashboard also validates on
1718
+ // create/update — this is defense-in-depth for ad-hoc dispatches and
1719
+ // upgrades that re-dispatch pre-validator WIs). Containment escapes are
1720
+ // non-retryable: the operator must fix the WI's meta.workdir or remove
1721
+ // it before a retry would succeed.
1722
+ const _rawWorkdir = meta?.item?.meta?.workdir;
1723
+ const _wdValidation = shared.validateWorkItemWorkdir(_rawWorkdir);
1724
+ if (!_wdValidation.valid) {
1725
+ const _wiId = meta?.item?.id || meta?.workItemId || id;
1726
+ log('warn', `spawnAgent: meta.workdir validation rejected dispatch ${id} (WI ${_wiId}): ${_wdValidation.error}`);
1727
+ try {
1728
+ writeInboxAlert(`invalid-workdir-${_wiId}`, [
1729
+ `# Invalid meta.workdir on ${_wiId}`,
1730
+ ``,
1731
+ `Dispatch \`${id}\` for agent \`${agentId}\` was rejected before spawn.`,
1732
+ ``,
1733
+ `**Reason:** ${_wdValidation.error}`,
1734
+ ``,
1735
+ `**Submitted value:** \`${typeof _rawWorkdir === 'string' ? _rawWorkdir : JSON.stringify(_rawWorkdir)}\``,
1736
+ ``,
1737
+ `**Project:** \`${project?.name || '(unknown)'}\` (localPath: \`${project?.localPath || '(none)'}\`)`,
1738
+ ``,
1739
+ `meta.workdir must be a relative POSIX subpath under the project root (or worktree, for code-mutating types). Examples: \`packages/foo\`, \`apps/dashboard\`. Absolute paths, drive-letter prefixes, \`..\` segments, and null bytes are rejected.`,
1740
+ ``,
1741
+ `Fix the WI's \`meta.workdir\` field (or remove it to dispatch at the project root) and re-dispatch.`,
1742
+ ].join('\n'));
1743
+ } catch (e) { log('warn', `invalid-workdir inbox alert write failed: ${e.message}`); }
1744
+ completeDispatch(
1745
+ id,
1746
+ DISPATCH_RESULT.ERROR,
1747
+ _wdValidation.error.slice(0, 800),
1748
+ 'meta.workdir validation rejected this dispatch — fix the WI subpath or remove it before re-dispatch.',
1749
+ { failureClass: FAILURE_CLASS.INVALID_WORKDIR, agentRetryable: false },
1750
+ );
1751
+ cleanupTempAgent(agentId);
1752
+ return null;
1753
+ }
1754
+ const validatedWorkdir = _wdValidation.value; // null when unset / empty
1755
+
1704
1756
  let cwd, worktreeRootDir, liveMode = false;
1705
1757
  try {
1706
- ({ cwd, worktreeRootDir, liveMode = false } = shared.resolveSpawnPaths(project, type, MINIONS_DIR));
1758
+ ({ cwd, worktreeRootDir, liveMode = false } = shared.resolveSpawnPaths(project, type, MINIONS_DIR, { workdir: validatedWorkdir }));
1707
1759
  } catch (rootErr) {
1708
1760
  if (rootErr?.code === 'WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT' || rootErr?.code === 'WORKTREE_ROOTDIR_MISSING_BASE') {
1709
1761
  log('error', `spawnAgent: project rootDir resolution failed for ${id}: ${rootErr.message}`);
@@ -1718,6 +1770,38 @@ async function spawnAgent(dispatchItem, config) {
1718
1770
  cleanupTempAgent(agentId);
1719
1771
  return null;
1720
1772
  }
1773
+ if (rootErr?.code === 'INVALID_WORKDIR') {
1774
+ // Post-resolve containment escape from live or read-only branch (the
1775
+ // pre-resolve validator above already caught the common cases; this
1776
+ // catches symlink-style attacks where the literal subpath looks fine
1777
+ // but path.resolve lands outside the base).
1778
+ const _wiId = meta?.item?.id || meta?.workItemId || id;
1779
+ log('warn', `spawnAgent: workdir post-resolve escape for ${id} (WI ${_wiId}): ${rootErr.message}`);
1780
+ try {
1781
+ writeInboxAlert(`invalid-workdir-${_wiId}`, [
1782
+ `# Invalid meta.workdir on ${_wiId} (post-resolve escape)`,
1783
+ ``,
1784
+ `Dispatch \`${id}\` for agent \`${agentId}\` was rejected by the resolver containment guard.`,
1785
+ ``,
1786
+ `**Reason:** ${rootErr.message}`,
1787
+ ``,
1788
+ `**Submitted value:** \`${validatedWorkdir || ''}\``,
1789
+ ``,
1790
+ `**Project:** \`${project?.name || '(unknown)'}\` (localPath: \`${project?.localPath || '(none)'}\`)`,
1791
+ ``,
1792
+ `The literal subpath passed shape validation but path.resolve landed outside the base directory — usually a symlink in the project root pointing elsewhere. Fix the WI's meta.workdir or remove the offending symlink before re-dispatch.`,
1793
+ ].join('\n'));
1794
+ } catch (e) { log('warn', `invalid-workdir inbox alert write failed: ${e.message}`); }
1795
+ completeDispatch(
1796
+ id,
1797
+ DISPATCH_RESULT.ERROR,
1798
+ rootErr.message.slice(0, 800),
1799
+ 'meta.workdir resolved outside the project/worktree base — fix the subpath or remove it.',
1800
+ { failureClass: FAILURE_CLASS.INVALID_WORKDIR, agentRetryable: false },
1801
+ );
1802
+ cleanupTempAgent(agentId);
1803
+ return null;
1804
+ }
1721
1805
  throw rootErr;
1722
1806
  }
1723
1807
  // Legacy local alias: downstream git ops (worktree add, prune, fetch) and
@@ -3086,6 +3170,15 @@ async function spawnAgent(dispatchItem, config) {
3086
3170
  // Other runtimes ignore the opt (their buildSpawnFlags don't read it), so no
3087
3171
  // runtime.name branch here.
3088
3172
  const resolvedDisabledMcpServers = shared.resolveCopilotAgentDisabledMcpServers(agentConfig, engineConfig);
3173
+ // P-49e1c8b7 — hermetic harness opt-out (per-agent override allowed).
3174
+ // When true, this dispatch:
3175
+ // - skips Claude workspace .mcp.json pre-approval (preApproveWorkspaceMcps),
3176
+ // - skips project-local-on-main `--project-harness-dir` propagation,
3177
+ // - forwards `--hermetic-harness` to spawn-agent.js so computeAddDirs
3178
+ // returns [minionsDir] only (user-asset dirs stripped from --add-dir).
3179
+ // Independent of copilotDisableBuiltinMcps and copilotSuppressAgentsMd,
3180
+ // which retain their existing semantics.
3181
+ const resolvedHermetic = shared.resolveAgentHermeticHarness(agentConfig, engineConfig);
3089
3182
 
3090
3183
  // W-mpg6isvy000xca4d — On retry after FAILURE_CLASS.MODEL_UNAVAILABLE, swap
3091
3184
  // to the runtime-appropriate fallback model. Two paths gated on
@@ -3145,6 +3238,39 @@ async function spawnAgent(dispatchItem, config) {
3145
3238
 
3146
3239
  // MCP servers: agents inherit from ~/.claude.json directly as Claude Code processes.
3147
3240
  // No --mcp-config needed — avoids redundant config and ensures agents always have latest servers.
3241
+ //
3242
+ // P-7d31a06b — When the runtime is Claude AND the worktree has a `.mcp.json`,
3243
+ // pre-warm `~/.claude.json` projects.<worktreePath>.enabledMcpjsonServers so
3244
+ // Claude's first call doesn't show the project-MCP trust prompt (which
3245
+ // --dangerously-skip-permissions silently suppresses → agent runs without the
3246
+ // workspace MCPs connected). Helper internally no-ops for non-Claude runtimes
3247
+ // and when engine.claudePreApproveWorkspaceMcps is false (no runtime.name
3248
+ // check at this call site, per CLAUDE.md rule). Best-effort: NEVER block dispatch.
3249
+ //
3250
+ // P-49e1c8b7 — Skipped entirely when resolvedHermetic is true: a hermetic
3251
+ // dispatch wants a known-empty harness surface, so the workspace .mcp.json
3252
+ // approval must not be written.
3253
+ if (resolvedHermetic) {
3254
+ log('debug', `Hermetic harness: skipping workspace MCP pre-approval for ${id}`);
3255
+ } else {
3256
+ try {
3257
+ const result = preApproveWorkspaceMcps({
3258
+ runtimeName,
3259
+ worktreePath,
3260
+ homeDir: os.homedir(),
3261
+ engineConfig,
3262
+ mutateJsonFileLocked: shared.mutateJsonFileLocked,
3263
+ });
3264
+ if (result.wrote) {
3265
+ log('info', `Pre-approved ${result.servers.length} workspace MCP server(s) in ~/.claude.json for ${worktreePath}: ${result.servers.join(', ')}`);
3266
+ } else if (result.reason !== 'no-workspace-mcp' && result.reason !== 'not-claude' && result.reason !== 'no-worktree') {
3267
+ log('debug', `Skipped workspace MCP pre-approval for ${id} (reason=${result.reason})`);
3268
+ }
3269
+ } catch (err) {
3270
+ log('warn', `Workspace MCP pre-approval failed for ${id} (non-fatal): ${err.message}`);
3271
+ }
3272
+ }
3273
+
3148
3274
  _phaseT.afterRuntime = Date.now();
3149
3275
 
3150
3276
  log('info', `Spawning agent: ${agentId} (${id}) in ${cwd}`);
@@ -3202,7 +3328,70 @@ async function spawnAgent(dispatchItem, config) {
3202
3328
  // Spawn via wrapper script — node directly (no bash intermediary)
3203
3329
  // spawn-agent.js handles CLAUDECODE env cleanup and claude binary resolution
3204
3330
  const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
3205
- const spawnArgs = [spawnScript, promptPath, sysPromptPath, ...args];
3331
+
3332
+ // ── P-08b62d49 — project-local-on-main harness propagation ──────────────
3333
+ // The worktree-uncommitted footgun (docs/harness-propagation.md): when a
3334
+ // mutating dispatch runs in a fresh `git worktree add` (worktreePath !==
3335
+ // project.localPath), uncommitted `<repo>/.claude/skills/foo/SKILL.md` etc.
3336
+ // on the operator's main checkout are invisible to the runtime CLI because
3337
+ // its native discovery is rooted at cwd. Union the project-scope harness
3338
+ // dirs (skills + commands) that exist under `project.localPath` but NOT
3339
+ // under `worktreePath`, and forward them to spawn-agent as
3340
+ // `--project-harness-dir <dir>` so `computeAddDirs` surfaces them via
3341
+ // `--add-dir`. Gated by engine.harnessPropagateProjectLocal (default true).
3342
+ // No-op for live-checkout mode (worktreePath === null) and read-only types
3343
+ // (also worktreePath === null per resolveSpawnPaths).
3344
+ //
3345
+ // P-49e1c8b7 — Also skipped entirely when resolvedHermetic is true: hermetic
3346
+ // dispatches want a known-empty harness surface so neither user-asset dirs
3347
+ // (filtered inside computeAddDirs) nor project-local-on-main dirs (filtered
3348
+ // here) reach the agent. We skip the queries.getProjectHarnesses(...) call
3349
+ // too — saves the fs scan when the result will be discarded.
3350
+ //
3351
+ // P-714ef144 — when meta.workdir is set, the filter helper clips both the
3352
+ // project-side and worktree-side anchors to <base>/<workdir>, so a
3353
+ // `packages/foo` dispatch never sees harness dirs from sibling packages.
3354
+ // The clipping is purely additive on top of the existing project-vs-worktree
3355
+ // disjoint filter; when workdir is null the helper produces the same result
3356
+ // as the legacy inline loop.
3357
+ const projectHarnessArgs = [];
3358
+ if (!resolvedHermetic
3359
+ && engineConfig.harnessPropagateProjectLocal !== false
3360
+ && worktreePath
3361
+ && project?.localPath
3362
+ && path.resolve(worktreePath) !== path.resolve(project.localPath)) {
3363
+ try {
3364
+ const harnesses = queries.getProjectHarnesses(project);
3365
+ const candidateDirs = [
3366
+ ...(harnesses.skills || []),
3367
+ ...(harnesses.commands || []),
3368
+ ]
3369
+ .map((entry) => entry?.dir)
3370
+ .filter((d) => typeof d === 'string' && d);
3371
+ const filteredDirs = shared.filterProjectHarnessDirsForWorkdir(candidateDirs, {
3372
+ projectLocalPath: project.localPath,
3373
+ worktreePath,
3374
+ workdir: validatedWorkdir,
3375
+ });
3376
+ for (const abs of filteredDirs) {
3377
+ if (!fs.existsSync(abs)) continue;
3378
+ projectHarnessArgs.push('--project-harness-dir', abs);
3379
+ }
3380
+ if (projectHarnessArgs.length) {
3381
+ log('debug', `Project-local harness propagation: ${projectHarnessArgs.length / 2} dir(s) for ${id} (${worktreePath}${validatedWorkdir ? ', workdir=' + validatedWorkdir : ''})`);
3382
+ }
3383
+ } catch (err) {
3384
+ log('warn', `Project-local harness propagation failed for ${id} (non-fatal): ${err.message}`);
3385
+ }
3386
+ }
3387
+
3388
+ // P-49e1c8b7 — hermetic harness opt-out forwarded to spawn-agent.js so
3389
+ // computeAddDirs returns [minionsDir] only (user-asset dirs stripped).
3390
+ // Append BEFORE projectHarnessArgs (which will be empty when hermetic, but
3391
+ // ordering keeps the intent explicit in process listings).
3392
+ const hermeticArgs = resolvedHermetic ? ['--hermetic-harness'] : [];
3393
+
3394
+ const spawnArgs = [spawnScript, promptPath, sysPromptPath, ...args, ...hermeticArgs, ...projectHarnessArgs];
3206
3395
 
3207
3396
  // Live output file — stamped BEFORE child process is spawned (#W-mo248lkjwgsu).
3208
3397
  // Writing the stub pre-spawn lets the orphan detector distinguish three failure modes
@@ -3589,7 +3778,12 @@ async function spawnAgent(dispatchItem, config) {
3589
3778
  let resumeProc;
3590
3779
  try {
3591
3780
  // detached so the resumed steering session also survives engine death (matches initial spawn)
3592
- resumeProc = runFile(process.execPath, [spawnScript, steerPromptPath, sysPromptPath, ...resumeArgs], {
3781
+ // P-08b62d49 — also include projectHarnessArgs so the resumed runtime
3782
+ // can still reach uncommitted project-local harness dirs (the runtime
3783
+ // CLI re-indexes asset dirs on every spawn, including this resume).
3784
+ // P-49e1c8b7 — same for --hermetic-harness so the resumed dispatch
3785
+ // keeps the known-empty harness contract.
3786
+ resumeProc = runFile(process.execPath, [spawnScript, steerPromptPath, sysPromptPath, ...resumeArgs, ...hermeticArgs, ...projectHarnessArgs], {
3593
3787
  cwd,
3594
3788
  stdio: ['pipe', 'pipe', 'pipe'],
3595
3789
  env: childEnv,
@@ -9237,6 +9431,41 @@ async function tickInner() {
9237
9431
  // cache (single-object latest sample) — gitignored and exempt from the
9238
9432
  // SQL-first state rule, same as engine/dashboard-port.json.
9239
9433
  safe('memoryBaseline', () => emitMemoryBaseline(tickCount));
9434
+
9435
+ // 7. Operator-driven heap snapshot capture (P-e5f6a7b8).
9436
+ // Dashboard's POST /api/diagnostics/heap-snapshot drops a sentinel in
9437
+ // engine/diagnostics/heap-snapshot-request.json after capturing its own
9438
+ // heap. We pick it up on the next tick, write the engine-side
9439
+ // .heapsnapshot, then remove the sentinel so the dashboard handler can
9440
+ // detect completion. The v8.writeHeapSnapshot call stalls THIS process
9441
+ // for several seconds — the dashboard polls with a 30s timeout.
9442
+ safe('heapSnapshotRequest', () => processHeapSnapshotRequest());
9443
+ }
9444
+
9445
+ function processHeapSnapshotRequest() {
9446
+ if (!fs.existsSync(HEAP_SNAPSHOT_REQUEST_PATH)) return;
9447
+ let req = null;
9448
+ try {
9449
+ req = JSON.parse(fs.readFileSync(HEAP_SNAPSHOT_REQUEST_PATH, 'utf8'));
9450
+ } catch (e) {
9451
+ log('warn', `heap-snapshot-request parse failed: ${e.message}`);
9452
+ try { safeUnlink(HEAP_SNAPSHOT_REQUEST_PATH); } catch { /* best effort */ }
9453
+ return;
9454
+ }
9455
+ const iso = (req && typeof req.requestedAt === 'string') ? req.requestedAt : new Date().toISOString();
9456
+ const safeIso = String(iso).replace(/[:.]/g, '-');
9457
+ const outPath = path.join(DIAGNOSTICS_DIR, `heap-engine-${safeIso}.heapsnapshot`);
9458
+ const v8 = require('v8');
9459
+ try {
9460
+ fs.mkdirSync(DIAGNOSTICS_DIR, { recursive: true });
9461
+ log('info', `HEAP_SNAPSHOT engine capturing -> ${outPath} (this stalls the engine for several seconds)`);
9462
+ v8.writeHeapSnapshot(outPath);
9463
+ log('info', `HEAP_SNAPSHOT engine wrote ${outPath}`);
9464
+ } catch (e) {
9465
+ log('warn', `heap-snapshot writeHeapSnapshot failed: ${e.message}`);
9466
+ } finally {
9467
+ try { safeUnlink(HEAP_SNAPSHOT_REQUEST_PATH); } catch { /* best effort */ }
9468
+ }
9240
9469
  }
9241
9470
 
9242
9471
  function emitMemoryBaseline(tickN) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2179",
3
+ "version": "0.1.2180",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"