@yemi33/minions 0.1.576 → 0.1.578

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/CHANGELOG.md CHANGED
@@ -1,8 +1,14 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.576 (2026-04-08)
3
+ ## 0.1.578 (2026-04-08)
4
4
 
5
5
  ### Other
6
+ - refactor: extract _spawnProcess helper, fix dep fetch error tracking, cleanup file leak
7
+
8
+ ## 0.1.577 (2026-04-08)
9
+
10
+ ### Other
11
+ - perf: parallelize worktree/prompt, direct CLI spawn, parallel dep fetch
6
12
  - chore: remove orphaned comment, fix PID write test assertion
7
13
 
8
14
  ## 0.1.575 (2026-04-08)
package/dashboard.js CHANGED
@@ -788,7 +788,7 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
788
788
  // Attempt 1: resume existing session — skip preamble (session already has context)
789
789
  if (sessionId && maxTurns > 1) {
790
790
  result = await llm.callLLM(buildPrompt({ includePreamble: false }), '', {
791
- timeout, label, model, maxTurns, allowedTools, sessionId, effort: ccEffort,
791
+ timeout, label, model, maxTurns, allowedTools, sessionId, effort: ccEffort, direct: true,
792
792
  });
793
793
  llm.trackEngineUsage(label, result.usage);
794
794
 
@@ -823,7 +823,7 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
823
823
  // Attempt 2: fresh session (include preamble for full context)
824
824
  const freshPrompt = buildPrompt();
825
825
  result = await llm.callLLM(freshPrompt, CC_STATIC_SYSTEM_PROMPT, {
826
- timeout, label, model, maxTurns, allowedTools, effort: ccEffort,
826
+ timeout, label, model, maxTurns, allowedTools, effort: ccEffort, direct: true,
827
827
  });
828
828
  llm.trackEngineUsage(label, result.usage);
829
829
 
@@ -837,7 +837,7 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
837
837
  console.log(`[${label}] Fresh call also failed (code=${result.code}, empty=${!result.text}), retrying once more...`);
838
838
  await new Promise(r => setTimeout(r, 2000));
839
839
  result = await llm.callLLM(freshPrompt, CC_STATIC_SYSTEM_PROMPT, {
840
- timeout, label, model, maxTurns, allowedTools, effort: ccEffort,
840
+ timeout, label, model, maxTurns, allowedTools, effort: ccEffort, direct: true,
841
841
  });
842
842
  llm.trackEngineUsage(label, result.usage);
843
843
 
@@ -3350,7 +3350,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3350
3350
  const llmPromise = callLLMStreaming(prompt, CC_STATIC_SYSTEM_PROMPT, {
3351
3351
  timeout: 900000, label: 'command-center', model: streamModel, maxTurns: 50,
3352
3352
  allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
3353
- sessionId, effort: streamEffort,
3353
+ sessionId, effort: streamEffort, direct: true,
3354
3354
  onChunk: (text) => {
3355
3355
  try { res.write('data: ' + JSON.stringify({ type: 'chunk', text }) + '\n\n'); } catch {}
3356
3356
  },
package/engine/llm.js CHANGED
@@ -43,32 +43,87 @@ function trackEngineUsage(category, usage) {
43
43
  } catch (e) { console.error('metrics update:', e.message); }
44
44
  }
45
45
 
46
+ // ── Claude Binary Resolution (cached by spawn-agent.js) ─────────────────────
47
+
48
+ let _claudeBinCache = null;
49
+ function _resolveClaudeBin() {
50
+ if (_claudeBinCache) return _claudeBinCache;
51
+ const caps = shared.safeJson(path.join(ENGINE_DIR, 'claude-caps.json'));
52
+ if (caps?.claudeBin && require('fs').existsSync(caps.claudeBin)) {
53
+ _claudeBinCache = { bin: caps.claudeBin, native: !!caps.claudeIsNative };
54
+ return _claudeBinCache;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ // ── Spawn Helpers ───────────────────────────────────────────────────────────
60
+
61
+ function _buildCliArgs({ model, maxTurns, allowedTools, effort, sessionId, sysPromptFile }) {
62
+ const args = ['-p', '--output-format', 'stream-json', '--max-turns', String(maxTurns), '--model', model, '--verbose'];
63
+ if (sysPromptFile) args.push('--system-prompt-file', sysPromptFile);
64
+ if (allowedTools) args.push('--allowedTools', allowedTools);
65
+ if (effort) args.push('--effort', effort);
66
+ args.push('--permission-mode', 'bypassPermissions');
67
+ if (sessionId) args.push('--resume', sessionId);
68
+ return args;
69
+ }
70
+
71
+ /**
72
+ * Spawn a claude CLI process. Returns { proc, cleanupFiles } or null if binary not cached.
73
+ * When direct=true, spawns claude CLI directly (fewer syscalls). Otherwise uses spawn-agent.js.
74
+ */
75
+ function _spawnProcess(promptText, sysPromptText, { direct, label, model, maxTurns, allowedTools, effort, sessionId }) {
76
+ const fs = require('fs');
77
+ const id = uid();
78
+ const tmpDir = path.join(ENGINE_DIR, 'tmp');
79
+ if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
80
+
81
+ const cleanupFiles = [];
82
+ const resolved = direct ? _resolveClaudeBin() : null;
83
+
84
+ if (resolved) {
85
+ let sysTmpPath = null;
86
+ if (!sessionId && sysPromptText) {
87
+ sysTmpPath = path.join(tmpDir, `direct-sys-${id}.md`);
88
+ fs.writeFileSync(sysTmpPath, sysPromptText);
89
+ cleanupFiles.push(sysTmpPath);
90
+ }
91
+ const cliArgs = _buildCliArgs({ model, maxTurns, allowedTools, effort, sessionId, sysPromptFile: sysTmpPath });
92
+ const proc = resolved.native
93
+ ? runFile(resolved.bin, cliArgs, { cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv() })
94
+ : runFile(process.execPath, [resolved.bin, ...cliArgs], { cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv() });
95
+ try { proc.stdin.write(promptText); proc.stdin.end(); } catch { /* broken pipe */ }
96
+ return { proc, cleanupFiles };
97
+ }
98
+
99
+ // Indirect: use spawn-agent.js
100
+ const promptPath = path.join(tmpDir, `${label}-prompt-${id}.md`);
101
+ const sysPath = path.join(tmpDir, `${label}-sys-${id}.md`);
102
+ safeWrite(promptPath, promptText);
103
+ safeWrite(sysPath, sysPromptText || '');
104
+ cleanupFiles.push(promptPath, sysPath);
105
+
106
+ const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
107
+ const args = [
108
+ spawnScript, promptPath, sysPath,
109
+ '--output-format', 'stream-json', '--max-turns', String(maxTurns), '--model', model,
110
+ '--verbose',
111
+ ];
112
+ if (allowedTools) args.push('--allowedTools', allowedTools);
113
+ if (effort) args.push('--effort', effort);
114
+ args.push('--permission-mode', 'bypassPermissions');
115
+ if (sessionId) args.push('--resume', sessionId);
116
+
117
+ const proc = runFile(process.execPath, args, { cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv() });
118
+ return { proc, cleanupFiles };
119
+ }
120
+
46
121
  // ── Core LLM Call ───────────────────────────────────────────────────────────
47
122
 
48
- function callLLM(promptText, sysPromptText, { timeout = 120000, label = 'llm', model = 'sonnet', maxTurns = 1, allowedTools = '', sessionId = null, effort = null } = {}) {
123
+ function callLLM(promptText, sysPromptText, { timeout = 120000, label = 'llm', model = 'sonnet', maxTurns = 1, allowedTools = '', sessionId = null, effort = null, direct = false } = {}) {
49
124
  return new Promise((resolve) => {
50
- const id = uid();
51
- const tmpDir = path.join(ENGINE_DIR, 'tmp');
52
- if (!require('fs').existsSync(tmpDir)) require('fs').mkdirSync(tmpDir, { recursive: true });
53
- const promptPath = path.join(tmpDir, `${label}-prompt-${id}.md`);
54
- const sysPath = path.join(tmpDir, `${label}-sys-${id}.md`);
55
- safeWrite(promptPath, promptText);
56
- safeWrite(sysPath, sysPromptText || '');
57
-
58
- const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
59
- const args = [
60
- spawnScript, promptPath, sysPath,
61
- '--output-format', 'stream-json', '--max-turns', String(maxTurns), '--model', model,
62
- '--verbose',
63
- ];
64
- if (allowedTools) args.push('--allowedTools', allowedTools);
65
- if (effort) args.push('--effort', effort);
66
- args.push('--permission-mode', 'bypassPermissions');
67
-
68
- if (sessionId) args.push('--resume', sessionId);
69
-
70
125
  const _startMs = Date.now();
71
- const proc = runFile(process.execPath, args, { cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv() });
126
+ const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, { direct, label, model, maxTurns, allowedTools, effort, sessionId });
72
127
 
73
128
  let stdout = '';
74
129
  let stderr = '';
@@ -79,8 +134,7 @@ function callLLM(promptText, sysPromptText, { timeout = 120000, label = 'llm', m
79
134
 
80
135
  proc.on('close', (code) => {
81
136
  clearTimeout(timer);
82
- safeUnlink(promptPath);
83
- safeUnlink(sysPath);
137
+ for (const f of cleanupFiles) safeUnlink(f);
84
138
  const parsed = parseStreamJsonOutput(stdout);
85
139
  const durationMs = Date.now() - _startMs;
86
140
  const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
@@ -89,8 +143,7 @@ function callLLM(promptText, sysPromptText, { timeout = 120000, label = 'llm', m
89
143
 
90
144
  proc.on('error', (err) => {
91
145
  clearTimeout(timer);
92
- safeUnlink(promptPath);
93
- safeUnlink(sysPath);
146
+ for (const f of cleanupFiles) safeUnlink(f);
94
147
  resolve({ text: '', usage: null, sessionId: null, code: 1, stderr: err.message, raw: '' });
95
148
  });
96
149
  });
@@ -118,30 +171,11 @@ function isResumeSessionStillValid(result) {
118
171
  * Returns the same result object as callLLM when the process completes.
119
172
  * onChunk(text) is called for each assistant text block as it arrives.
120
173
  */
121
- function callLLMStreaming(promptText, sysPromptText, { timeout = 120000, label = 'llm', model = 'sonnet', maxTurns = 1, allowedTools = '', sessionId = null, onChunk = () => {}, onToolUse = null, effort = null } = {}) {
174
+ function callLLMStreaming(promptText, sysPromptText, { timeout = 120000, label = 'llm', model = 'sonnet', maxTurns = 1, allowedTools = '', sessionId = null, onChunk = () => {}, onToolUse = null, effort = null, direct = false } = {}) {
122
175
  let _abort = null;
123
176
  const promise = new Promise((resolve) => {
124
- const id = uid();
125
- const tmpDir = path.join(ENGINE_DIR, 'tmp');
126
- if (!require('fs').existsSync(tmpDir)) require('fs').mkdirSync(tmpDir, { recursive: true });
127
- const promptPath = path.join(tmpDir, `${label}-prompt-${id}.md`);
128
- const sysPath = path.join(tmpDir, `${label}-sys-${id}.md`);
129
- safeWrite(promptPath, promptText);
130
- safeWrite(sysPath, sysPromptText || '');
131
-
132
- const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
133
- const args = [
134
- spawnScript, promptPath, sysPath,
135
- '--output-format', 'stream-json', '--max-turns', String(maxTurns), '--model', model,
136
- '--verbose',
137
- ];
138
- if (allowedTools) args.push('--allowedTools', allowedTools);
139
- if (effort) args.push('--effort', effort);
140
- args.push('--permission-mode', 'bypassPermissions');
141
- if (sessionId) args.push('--resume', sessionId);
142
-
143
177
  const _startMs = Date.now();
144
- const proc = runFile(process.execPath, args, { cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv() });
178
+ const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, { direct, label, model, maxTurns, allowedTools, effort, sessionId });
145
179
 
146
180
  _abort = () => { shared.killImmediate(proc); };
147
181
 
@@ -181,8 +215,7 @@ function callLLMStreaming(promptText, sysPromptText, { timeout = 120000, label =
181
215
 
182
216
  proc.on('close', (code) => {
183
217
  clearTimeout(timer);
184
- safeUnlink(promptPath);
185
- safeUnlink(sysPath);
218
+ for (const f of cleanupFiles) safeUnlink(f);
186
219
  const parsed = parseStreamJsonOutput(stdout);
187
220
  const durationMs = Date.now() - _startMs;
188
221
  const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
@@ -191,8 +224,7 @@ function callLLMStreaming(promptText, sysPromptText, { timeout = 120000, label =
191
224
 
192
225
  proc.on('error', (err) => {
193
226
  clearTimeout(timer);
194
- safeUnlink(promptPath);
195
- safeUnlink(sysPath);
227
+ for (const f of cleanupFiles) safeUnlink(f);
196
228
  resolve({ text: '', usage: null, sessionId: null, code: 1, stderr: err.message, raw: '' });
197
229
  });
198
230
  });
package/engine.js CHANGED
@@ -278,6 +278,22 @@ async function spawnAgent(dispatchItem, config) {
278
278
  const _gitOpts = { stdio: 'pipe', timeout: 30000, windowsHide: true, env: shared.gitEnv() };
279
279
  const _worktreeGitOpts = { ..._gitOpts, timeout: worktreeCreateTimeout };
280
280
 
281
+ // Build prompt before worktree setup — prompt doesn't depend on worktree path
282
+ // and this avoids blocking 200ms of file reads behind 20-60s of git operations
283
+ const systemPrompt = buildSystemPrompt(agentId, config, project);
284
+ const agentContext = buildAgentContext(agentId, config, project);
285
+ const fullTaskPrompt = agentContext
286
+ ? `## Agent Context\n\n${agentContext}\n---\n\n## Your Task\n\n${taskPrompt}`
287
+ : taskPrompt;
288
+ const tmpDir = path.join(ENGINE_DIR, 'tmp');
289
+ if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
290
+ const safeId = id.replace(/[:\\/*?"<>|]/g, '-');
291
+ const promptPath = path.join(tmpDir, `prompt-${safeId}.md`);
292
+ safeWrite(promptPath, fullTaskPrompt);
293
+ const sysPromptPath = path.join(tmpDir, `sysprompt-${safeId}.md`);
294
+ safeWrite(sysPromptPath, systemPrompt);
295
+ const _cleanupPromptFiles = () => { safeUnlink(promptPath); safeUnlink(sysPromptPath); };
296
+
281
297
  if (branchName) {
282
298
  const wtSuffix = id ? id.split('-').pop() : shared.uid();
283
299
  const projectSlug = (project.name || 'default').replace(/[^a-zA-Z0-9_-]/g, '-');
@@ -413,24 +429,41 @@ async function spawnAgent(dispatchItem, config) {
413
429
  try {
414
430
  const depBranches = resolveDependencyBranches(depIds, meta?.item?.sourcePlan, project, config);
415
431
  let depMergeFailed = false;
416
- for (const { branch: depBranch, prId } of depBranches) {
417
- // Skip refs already known to be missing this tick (avoids repeated 30s ETIMEDOUT)
418
- if (_failedRefCache.has(depBranch)) {
419
- log('warn', `Skipping dependency ${depBranch} already failed to fetch this tick`);
432
+ // Fetch all dependency branches in parallel (git fetches are independent)
433
+ const fetchable = depBranches.filter(d => !_failedRefCache.has(d.branch));
434
+ const unfetchable = depBranches.filter(d => _failedRefCache.has(d.branch));
435
+ for (const { branch: depBranch } of unfetchable) {
436
+ log('warn', `Skipping dependency ${depBranch} — already failed to fetch this tick`);
437
+ depMergeFailed = true;
438
+ }
439
+ const fetchResults = await Promise.allSettled(
440
+ fetchable.map(({ branch: depBranch }) =>
441
+ execAsync(`git fetch origin "${depBranch}"`, { ..._gitOpts, cwd: rootDir }).then(() => depBranch)
442
+ )
443
+ );
444
+ for (let i = 0; i < fetchResults.length; i++) {
445
+ if (fetchResults[i].status === 'rejected') {
446
+ const failedBranch = fetchable[i].branch;
447
+ _failedRefCache.add(failedBranch);
448
+ log('warn', `Failed to fetch dependency ${failedBranch}: ${fetchResults[i].reason?.message}`);
420
449
  depMergeFailed = true;
421
- continue;
422
450
  }
423
- try {
424
- await execAsync(`git fetch origin "${depBranch}"`, { ..._gitOpts, cwd: rootDir });
425
- await execAsync(`git merge "origin/${depBranch}" --no-edit`, { ..._gitOpts, cwd: worktreePath });
426
- log('info', `Merged dependency branch ${depBranch} (${prId}) into worktree ${branchName}`);
427
- } catch (mergeErr) {
428
- _failedRefCache.add(depBranch);
429
- log('warn', `Failed to merge dependency ${depBranch} into ${branchName}: ${mergeErr.message}`);
430
- depMergeFailed = true;
451
+ }
452
+ // Merge fetched branches sequentially (merges modify the worktree)
453
+ if (!depMergeFailed) {
454
+ for (const { branch: depBranch, prId } of fetchable) {
455
+ try {
456
+ await execAsync(`git merge "origin/${depBranch}" --no-edit`, { ..._gitOpts, cwd: worktreePath });
457
+ log('info', `Merged dependency branch ${depBranch} (${prId}) into worktree ${branchName}`);
458
+ } catch (mergeErr) {
459
+ log('warn', `Failed to merge dependency ${depBranch} into ${branchName}: ${mergeErr.message}`);
460
+ depMergeFailed = true;
461
+ break;
462
+ }
431
463
  }
432
464
  }
433
465
  if (depMergeFailed) {
466
+ _cleanupPromptFiles();
434
467
  completeDispatch(id, DISPATCH_RESULT.ERROR, `Dependency merge failed — will retry next tick`);
435
468
  return;
436
469
  }
@@ -441,30 +474,11 @@ async function spawnAgent(dispatchItem, config) {
441
474
  }
442
475
  }
443
476
 
444
- // Build lean system prompt (identity + rules, ~2-4KB) and bulk context (history, notes, skills)
445
- const systemPrompt = buildSystemPrompt(agentId, config, project);
446
- const agentContext = buildAgentContext(agentId, config, project);
447
-
448
477
  // Safety check: warn if a write-capable task is running in the main repo without a worktree
449
478
  if (cwd === rootDir && ['implement', 'implement:large', 'fix', 'test', 'verify', 'plan-to-prd'].includes(type)) {
450
479
  log('warn', `Agent ${agentId} running ${type} task in main repo (no worktree) for ${id} — changes may land on master directly`);
451
480
  }
452
481
 
453
- // Prepend bulk context to task prompt — keeps system prompt small and stable
454
- const fullTaskPrompt = agentContext
455
- ? `## Agent Context\n\n${agentContext}\n---\n\n## Your Task\n\n${taskPrompt}`
456
- : taskPrompt;
457
-
458
- // Write prompt and system prompt to temp files (avoids shell escaping issues)
459
- const tmpDir = path.join(ENGINE_DIR, 'tmp');
460
- if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
461
- const safeId = id.replace(/[:\\/*?"<>|]/g, '-');
462
- const promptPath = path.join(tmpDir, `prompt-${safeId}.md`);
463
- safeWrite(promptPath, fullTaskPrompt);
464
-
465
- const sysPromptPath = path.join(tmpDir, `sysprompt-${safeId}.md`);
466
- safeWrite(sysPromptPath, systemPrompt);
467
-
468
482
  // Build claude CLI args
469
483
  const args = [
470
484
  '--output-format', claudeConfig.outputFormat || 'stream-json',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.576",
3
+ "version": "0.1.578",
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"