@yemi33/minions 0.1.2179 → 0.1.2181
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/bin/minions.js +15 -6
- package/dashboard/js/memory-panel.js +62 -0
- package/dashboard/js/refresh.js +59 -61
- package/dashboard/js/render-other.js +142 -1
- package/dashboard/js/render-work-items.js +18 -1
- package/dashboard/js/settings.js +23 -0
- package/dashboard/pages/engine-memory-panel.html +7 -0
- package/dashboard/pages/tools.html +8 -0
- package/dashboard-build.js +44 -8
- package/dashboard.js +466 -3
- package/docs/diagnostics-memory.md +446 -0
- package/docs/harness-propagation.md +273 -0
- package/docs/human-vs-automated.md +1 -1
- package/docs/runtime-adapters.md +5 -0
- package/engine/cli.js +24 -5
- package/engine/features.js +0 -18
- package/engine/preflight.js +265 -0
- package/engine/queries.js +204 -19
- package/engine/runtimes/claude.js +36 -0
- package/engine/runtimes/codex.js +19 -0
- package/engine/runtimes/copilot.js +27 -36
- package/engine/shared.js +293 -38
- package/engine/spawn-agent.js +178 -12
- package/engine.js +232 -3
- package/package.json +1 -1
package/engine/spawn-agent.js
CHANGED
|
@@ -55,6 +55,15 @@ function parseSpawnArgs(argv) {
|
|
|
55
55
|
let runtimeName = 'claude';
|
|
56
56
|
const opts = {};
|
|
57
57
|
const passthrough = [];
|
|
58
|
+
// P-08b62d49 — accumulating array of project-local-on-main harness dirs that
|
|
59
|
+
// engine.js wants `computeAddDirs` to surface as additional `--add-dir`
|
|
60
|
+
// entries. Multiple `--project-harness-dir <path>` flags accumulate in
|
|
61
|
+
// declaration order; the dedup/exists filter happens inside `computeAddDirs`.
|
|
62
|
+
const projectHarnessDirs = [];
|
|
63
|
+
// P-49e1c8b7 — hermetic harness opt-out (boolean). Set by engine.js when
|
|
64
|
+
// shared.resolveAgentHermeticHarness(agent, engine) is true; propagates to
|
|
65
|
+
// computeAddDirs which then returns [minionsDir] only.
|
|
66
|
+
let hermetic = false;
|
|
58
67
|
|
|
59
68
|
for (let i = 2; i < args.length; i++) {
|
|
60
69
|
const a = args[i];
|
|
@@ -77,6 +86,17 @@ function parseSpawnArgs(argv) {
|
|
|
77
86
|
case '--disable-builtin-mcps': opts.disableBuiltinMcps = true; break;
|
|
78
87
|
case '--no-custom-instructions': opts.suppressAgentsMd = true; break;
|
|
79
88
|
case '--enable-reasoning-summaries': opts.reasoningSummaries = true; break;
|
|
89
|
+
case '--project-harness-dir': {
|
|
90
|
+
const v = peek();
|
|
91
|
+
if (typeof v === 'string' && v) projectHarnessDirs.push(v);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
// P-49e1c8b7 — hermetic harness opt-out. When set, computeAddDirs in
|
|
95
|
+
// main() returns [minionsDir] only (user-scope asset dirs from
|
|
96
|
+
// runtime.getUserAssetDirs AND any forwarded --project-harness-dir
|
|
97
|
+
// entries are dropped). Engine.js gates this flag via
|
|
98
|
+
// shared.resolveAgentHermeticHarness(agent, engine).
|
|
99
|
+
case '--hermetic-harness': hermetic = true; break;
|
|
80
100
|
// LEGACY: dropped — the runtime adapter emits its own permission flag.
|
|
81
101
|
// Pre-P-2a6d9c4f engine.js still passes `--permission-mode bypassPermissions`;
|
|
82
102
|
// letting it through would duplicate the permission flag for Claude.
|
|
@@ -85,7 +105,7 @@ function parseSpawnArgs(argv) {
|
|
|
85
105
|
}
|
|
86
106
|
}
|
|
87
107
|
|
|
88
|
-
return { promptFile, sysPromptFile, runtimeName, opts, passthrough };
|
|
108
|
+
return { promptFile, sysPromptFile, runtimeName, opts, passthrough, projectHarnessDirs, hermetic };
|
|
89
109
|
}
|
|
90
110
|
|
|
91
111
|
/**
|
|
@@ -299,18 +319,39 @@ function createParentPipeForwarder(stream, outputPath, prefix = '') {
|
|
|
299
319
|
|
|
300
320
|
/**
|
|
301
321
|
* Build the `--add-dir` list passed to the runtime CLI. Pure: takes
|
|
302
|
-
* `{ runtime, minionsDir, homeDir, exists }`
|
|
303
|
-
* array of dirs the agent should be able to
|
|
322
|
+
* `{ runtime, minionsDir, homeDir, projectHarnessDirs, hermetic, exists }`
|
|
323
|
+
* and returns an ordered, deduped array of dirs the agent should be able to
|
|
324
|
+
* read from outside its worktree.
|
|
325
|
+
*
|
|
326
|
+
* Order:
|
|
327
|
+
* 1. minionsDir (always first, so playbooks/system-prompt are reachable).
|
|
328
|
+
* 2. Every existing dir from `runtime.getUserAssetDirs({ homeDir })` —
|
|
329
|
+
* user-scope skill/command/MCP roots (`~/.claude`, `~/.copilot`,
|
|
330
|
+
* `~/.agents`, …) so worktree-bound agents inherit user-installed
|
|
331
|
+
* harnesses.
|
|
332
|
+
* 3. P-08b62d49 — Every existing dir in `projectHarnessDirs` (engine.js
|
|
333
|
+
* passes the union of `queries.getProjectHarnesses(project).{skills,
|
|
334
|
+
* commands}` dirs that live in `project.localPath` but NOT under the
|
|
335
|
+
* worktree, so uncommitted `<repo>/.claude/skills/…` etc. on the main
|
|
336
|
+
* checkout are reachable from worktree dispatches). See
|
|
337
|
+
* `docs/harness-propagation.md` → "The worktree-uncommitted footgun".
|
|
304
338
|
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
339
|
+
* Non-existent dirs are dropped at every layer — Claude CLI rejects unknown
|
|
340
|
+
* `--add-dir` entries. The dedup compares resolved paths so we never emit a
|
|
341
|
+
* dir twice (e.g. when a runtime asset dir IS the minions repo in unusual
|
|
342
|
+
* setups, or when a project-harness dir duplicates a user-asset dir).
|
|
343
|
+
*
|
|
344
|
+
* P-49e1c8b7 — When `hermetic === true`, the result is exactly `[minionsDir]`:
|
|
345
|
+
* both the runtime adapter's user-asset dirs AND `projectHarnessDirs` are
|
|
346
|
+
* dropped so the spawned agent runs with a known-empty harness surface.
|
|
347
|
+
* Engine.js sets this via `shared.resolveAgentHermeticHarness(agent, engine)`
|
|
348
|
+
* and forwards `--hermetic-harness` to spawn-agent so the flag survives the
|
|
349
|
+
* subprocess hop.
|
|
310
350
|
*
|
|
311
351
|
* `exists` is injectable for tests; defaults to `fs.existsSync`.
|
|
312
352
|
*/
|
|
313
|
-
function computeAddDirs({ runtime, minionsDir, homeDir, exists = fs.existsSync } = {}) {
|
|
353
|
+
function computeAddDirs({ runtime, minionsDir, homeDir, projectHarnessDirs, hermetic, exists = fs.existsSync } = {}) {
|
|
354
|
+
if (hermetic) return [minionsDir];
|
|
314
355
|
const out = [minionsDir];
|
|
315
356
|
const seen = new Set([path.resolve(minionsDir)]);
|
|
316
357
|
const assetDirs = typeof runtime?.getUserAssetDirs === 'function'
|
|
@@ -324,9 +365,121 @@ function computeAddDirs({ runtime, minionsDir, homeDir, exists = fs.existsSync }
|
|
|
324
365
|
out.push(d);
|
|
325
366
|
seen.add(resolved);
|
|
326
367
|
}
|
|
368
|
+
if (Array.isArray(projectHarnessDirs)) {
|
|
369
|
+
for (const d of projectHarnessDirs) {
|
|
370
|
+
if (!d) continue;
|
|
371
|
+
const resolved = path.resolve(d);
|
|
372
|
+
if (seen.has(resolved)) continue;
|
|
373
|
+
if (!exists(d)) continue;
|
|
374
|
+
out.push(d);
|
|
375
|
+
seen.add(resolved);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
327
378
|
return out;
|
|
328
379
|
}
|
|
329
380
|
|
|
381
|
+
/**
|
|
382
|
+
* P-7d31a06b — Pre-approve a worktree's `.mcp.json` servers in `~/.claude.json`
|
|
383
|
+
* so Claude Code's first invocation in a fresh worktree cwd doesn't show the
|
|
384
|
+
* project-scoped "trust this MCP server?" prompt (which `--dangerously-skip-permissions`
|
|
385
|
+
* silently suppresses → the agent runs without the workspace MCPs).
|
|
386
|
+
*
|
|
387
|
+
* Writes to `projects[worktreePath].enabledMcpjsonServers` (the field Claude
|
|
388
|
+
* Code actually reads — confirmed against the live `~/.claude.json` schema:
|
|
389
|
+
* `enabledMcpjsonServers` / `disabledMcpjsonServers` are siblings of
|
|
390
|
+
* `mcpServers`, indexed by absolute project path). Notes on the PRD wording
|
|
391
|
+
* "mcpServers.approved": that's the intent label; `enabledMcpjsonServers` is
|
|
392
|
+
* the on-disk reality.
|
|
393
|
+
*
|
|
394
|
+
* Pure-ish: all I/O (fs reads, mutator) is injected for tests. Best-effort —
|
|
395
|
+
* callers MUST swallow errors and never block dispatch on this.
|
|
396
|
+
*
|
|
397
|
+
* Returns `{ wrote: boolean, reason: string, servers: string[] }`:
|
|
398
|
+
* - reason ∈ { 'ok', 'flag-off', 'not-claude', 'no-worktree',
|
|
399
|
+
* 'no-workspace-mcp', 'invalid-workspace-mcp', 'no-servers' }
|
|
400
|
+
* - servers: keys discovered in the workspace `.mcp.json` (regardless of write)
|
|
401
|
+
*
|
|
402
|
+
* Args:
|
|
403
|
+
* runtimeName — `runtime.name` from `resolveRuntime(...)`. Helper no-ops if !== 'claude'.
|
|
404
|
+
* worktreePath — absolute path to the agent's cwd (the worktree). Used as both the file location
|
|
405
|
+
* (`<worktreePath>/.mcp.json`) and the `projects[...]` key in `~/.claude.json`.
|
|
406
|
+
* homeDir — `os.homedir()`. Injected for test isolation; helper does NOT call os.homedir directly.
|
|
407
|
+
* engineConfig — config.engine object; reads `claudePreApproveWorkspaceMcps` (default true via ENGINE_DEFAULTS).
|
|
408
|
+
* mutateJsonFileLocked — injected file-locked mutator (engine/shared.js#mutateJsonFileLocked).
|
|
409
|
+
* existsSync, readFileSync — injectable fs primitives (default to node:fs).
|
|
410
|
+
*/
|
|
411
|
+
function preApproveWorkspaceMcps({
|
|
412
|
+
runtimeName,
|
|
413
|
+
worktreePath,
|
|
414
|
+
homeDir,
|
|
415
|
+
engineConfig,
|
|
416
|
+
mutateJsonFileLocked,
|
|
417
|
+
existsSync = fs.existsSync,
|
|
418
|
+
readFileSync = fs.readFileSync,
|
|
419
|
+
} = {}) {
|
|
420
|
+
if (engineConfig && engineConfig.claudePreApproveWorkspaceMcps === false) {
|
|
421
|
+
return { wrote: false, reason: 'flag-off', servers: [] };
|
|
422
|
+
}
|
|
423
|
+
if (runtimeName !== 'claude') {
|
|
424
|
+
return { wrote: false, reason: 'not-claude', servers: [] };
|
|
425
|
+
}
|
|
426
|
+
if (!worktreePath || typeof worktreePath !== 'string') {
|
|
427
|
+
return { wrote: false, reason: 'no-worktree', servers: [] };
|
|
428
|
+
}
|
|
429
|
+
const workspaceMcpPath = path.join(worktreePath, '.mcp.json');
|
|
430
|
+
if (!existsSync(workspaceMcpPath)) {
|
|
431
|
+
return { wrote: false, reason: 'no-workspace-mcp', servers: [] };
|
|
432
|
+
}
|
|
433
|
+
let workspaceMcp;
|
|
434
|
+
try {
|
|
435
|
+
workspaceMcp = JSON.parse(readFileSync(workspaceMcpPath, 'utf8'));
|
|
436
|
+
} catch {
|
|
437
|
+
return { wrote: false, reason: 'invalid-workspace-mcp', servers: [] };
|
|
438
|
+
}
|
|
439
|
+
const serversObj = workspaceMcp && typeof workspaceMcp === 'object' ? workspaceMcp.mcpServers : null;
|
|
440
|
+
const servers = serversObj && typeof serversObj === 'object' && !Array.isArray(serversObj)
|
|
441
|
+
? Object.keys(serversObj).filter((k) => typeof k === 'string' && k.length > 0)
|
|
442
|
+
: [];
|
|
443
|
+
if (servers.length === 0) {
|
|
444
|
+
return { wrote: false, reason: 'no-servers', servers: [] };
|
|
445
|
+
}
|
|
446
|
+
if (!homeDir || typeof mutateJsonFileLocked !== 'function') {
|
|
447
|
+
return { wrote: false, reason: 'no-home-or-mutator', servers };
|
|
448
|
+
}
|
|
449
|
+
const claudeJsonPath = path.join(homeDir, '.claude.json');
|
|
450
|
+
let didWrite = false;
|
|
451
|
+
mutateJsonFileLocked(claudeJsonPath, (data) => {
|
|
452
|
+
const root = data && typeof data === 'object' ? data : {};
|
|
453
|
+
if (!root.projects || typeof root.projects !== 'object') root.projects = {};
|
|
454
|
+
const projectKey = worktreePath;
|
|
455
|
+
const proj = root.projects[projectKey] && typeof root.projects[projectKey] === 'object'
|
|
456
|
+
? root.projects[projectKey]
|
|
457
|
+
: {};
|
|
458
|
+
// enabledMcpjsonServers is Claude's actual on-disk approval field
|
|
459
|
+
const enabled = Array.isArray(proj.enabledMcpjsonServers) ? proj.enabledMcpjsonServers.slice() : [];
|
|
460
|
+
const disabled = Array.isArray(proj.disabledMcpjsonServers) ? proj.disabledMcpjsonServers.slice() : [];
|
|
461
|
+
const enabledSet = new Set(enabled.filter((s) => typeof s === 'string'));
|
|
462
|
+
let mutated = false;
|
|
463
|
+
for (const name of servers) {
|
|
464
|
+
if (!enabledSet.has(name)) {
|
|
465
|
+
enabledSet.add(name);
|
|
466
|
+
mutated = true;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// If a server was previously explicitly disabled, lift the disabled mark when we
|
|
470
|
+
// re-approve it — otherwise Claude treats the disabled entry as authoritative.
|
|
471
|
+
const nextDisabled = disabled.filter((s) => typeof s === 'string' && !servers.includes(s));
|
|
472
|
+
if (nextDisabled.length !== disabled.length) mutated = true;
|
|
473
|
+
if (!mutated) return data;
|
|
474
|
+
proj.enabledMcpjsonServers = [...enabledSet];
|
|
475
|
+
proj.disabledMcpjsonServers = nextDisabled;
|
|
476
|
+
root.projects[projectKey] = proj;
|
|
477
|
+
didWrite = true;
|
|
478
|
+
return root;
|
|
479
|
+
}, { defaultValue: {}, skipWriteIfUnchanged: true });
|
|
480
|
+
return { wrote: didWrite, reason: 'ok', servers };
|
|
481
|
+
}
|
|
482
|
+
|
|
330
483
|
// ─── Main script execution ──────────────────────────────────────────────────
|
|
331
484
|
|
|
332
485
|
function _installHint(name, runtime) {
|
|
@@ -345,7 +498,7 @@ function main() {
|
|
|
345
498
|
console.error('Usage: node spawn-agent.js <prompt-file> <sysprompt-file> [--runtime <name>] [args...]');
|
|
346
499
|
process.exit(1);
|
|
347
500
|
}
|
|
348
|
-
const { promptFile, sysPromptFile, runtimeName, opts, passthrough } = parsed;
|
|
501
|
+
const { promptFile, sysPromptFile, runtimeName, opts, passthrough, projectHarnessDirs, hermetic } = parsed;
|
|
349
502
|
|
|
350
503
|
const env = cleanChildEnv();
|
|
351
504
|
injectAdoTokenEnvForRepoHost(env);
|
|
@@ -382,8 +535,21 @@ function main() {
|
|
|
382
535
|
// Skill discovery dirs — agents run with CWD set to an external repo
|
|
383
536
|
// worktree, so runtime-native global assets would otherwise be invisible.
|
|
384
537
|
// The adapter owns both where those assets live and how to surface them.
|
|
538
|
+
// P-08b62d49: engine.js also forwards uncommitted project-local harness
|
|
539
|
+
// dirs from the operator's main checkout via `--project-harness-dir <path>`
|
|
540
|
+
// (one per dir, accumulated in `projectHarnessDirs`) so worktree-bound
|
|
541
|
+
// agents inherit them too. See docs/harness-propagation.md.
|
|
542
|
+
// P-49e1c8b7: when engine.js passes `--hermetic-harness`, computeAddDirs
|
|
543
|
+
// returns [minionsDir] only — both user-asset dirs and project harness
|
|
544
|
+
// dirs are dropped for a known-empty harness surface.
|
|
385
545
|
const minionsDir = path.resolve(__dirname, '..');
|
|
386
|
-
const addDirs = computeAddDirs({
|
|
546
|
+
const addDirs = computeAddDirs({
|
|
547
|
+
runtime,
|
|
548
|
+
minionsDir,
|
|
549
|
+
homeDir: os.homedir(),
|
|
550
|
+
projectHarnessDirs,
|
|
551
|
+
hermetic,
|
|
552
|
+
});
|
|
387
553
|
|
|
388
554
|
let resolved;
|
|
389
555
|
try { resolved = runtime.resolveBinary({ env }); }
|
|
@@ -639,6 +805,6 @@ function main() {
|
|
|
639
805
|
});
|
|
640
806
|
}
|
|
641
807
|
|
|
642
|
-
module.exports = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit, shouldInjectAdoTokenEnv, injectAdoTokenEnv, injectAdoTokenEnvForRepoHost, writeProcessExitSentinel, computeAddDirs, createParentPipeForwarder, assertStaleHeadOk };
|
|
808
|
+
module.exports = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit, shouldInjectAdoTokenEnv, injectAdoTokenEnv, injectAdoTokenEnvForRepoHost, writeProcessExitSentinel, computeAddDirs, preApproveWorkspaceMcps, createParentPipeForwarder, assertStaleHeadOk };
|
|
643
809
|
|
|
644
810
|
if (require.main === module) main();
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.2181",
|
|
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"
|