@yemi33/minions 0.1.419 → 0.1.421

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,12 +1,15 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.419 (2026-04-06)
3
+ ## 0.1.421 (2026-04-06)
4
4
 
5
5
  ### Features
6
+ - Dashboard robustness — raw string fixes, plan steering clarity, safeWrite race fix
7
+ - Low-priority cleanup: dedupe regex, consolidate streaming parse, add path validation alignment
6
8
  - Fix 6 medium bugs: dispatch pruning, null guards, skill regex, meeting advancement, CLI PID check, pipeline retry
7
9
  - Convert remaining lifecycle.js safeWrite calls to mutateJsonFileLocked
8
10
 
9
11
  ### Fixes
12
+ - CC retry now drains queued messages after success
10
13
  - address review feedback — pipeline.js socket leak and magic numbers
11
14
 
12
15
  ## 0.1.417 (2026-04-06)
@@ -344,8 +344,14 @@ function ccRetryLast() {
344
344
  const el = document.getElementById('cc-messages');
345
345
  if (el?.lastElementChild) el.lastElementChild.remove();
346
346
  _ccMessages = _ccMessages.slice(0, -1); // remove error from history
347
- // Resend
348
- _ccDoSend(text.trim());
347
+ // Resend, then drain queue
348
+ _ccDoSend(text.trim()).then(async () => {
349
+ while (_ccQueue.length > 0) {
350
+ const next = _ccQueue.shift();
351
+ _renderQueueIndicator();
352
+ await _ccDoSend(next);
353
+ }
354
+ });
349
355
  }
350
356
 
351
357
  async function _ccFetch(url, body) {
package/dashboard.js CHANGED
@@ -792,8 +792,13 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
792
792
  function readBody(req) {
793
793
  return new Promise((resolve, reject) => {
794
794
  let body = '';
795
- req.on('data', chunk => { body += chunk; if (body.length > 1e6) reject(new Error('Too large')); });
796
- req.on('end', () => { try { resolve(JSON.parse(body)); } catch(e) { reject(e); } });
795
+ const timeout = setTimeout(() => {
796
+ req.destroy();
797
+ reject(new Error('Request body timeout after 30s'));
798
+ }, 30000);
799
+ req.on('data', chunk => { body += chunk; if (body.length > 1e6) { clearTimeout(timeout); reject(new Error('Too large')); } });
800
+ req.on('end', () => { clearTimeout(timeout); try { resolve(JSON.parse(body)); } catch(e) { reject(e); } });
801
+ req.on('error', (e) => { clearTimeout(timeout); reject(e); });
797
802
  });
798
803
  }
799
804
 
@@ -1321,17 +1326,19 @@ const server = http.createServer(async (req, res) => {
1321
1326
  let item;
1322
1327
  mutateJsonFileLocked(planPath, (plan) => {
1323
1328
  const target = (plan.missing_features || []).find(f => f.id === body.itemId);
1324
- if (target) {
1325
- if (body.name !== undefined) target.name = body.name;
1326
- if (body.description !== undefined) target.description = body.description;
1327
- if (body.priority !== undefined) target.priority = body.priority;
1328
- if (body.estimated_complexity !== undefined) target.estimated_complexity = body.estimated_complexity;
1329
- if (body.status !== undefined) target.status = body.status;
1330
- item = target;
1331
- }
1329
+ if (!target) return plan; // TOCTOU: item deleted between pre-check and lock acquisition
1330
+ if (body.name !== undefined) target.name = body.name;
1331
+ if (body.description !== undefined) target.description = body.description;
1332
+ if (body.priority !== undefined) target.priority = body.priority;
1333
+ if (body.estimated_complexity !== undefined) target.estimated_complexity = body.estimated_complexity;
1334
+ if (body.status !== undefined) target.status = body.status;
1335
+ item = target;
1332
1336
  return plan;
1333
1337
  }, { defaultValue: preCheck });
1334
1338
 
1339
+ // If item was deleted between pre-check and lock, return 404
1340
+ if (!item) return jsonReply(res, 404, { error: 'item not found in plan (deleted concurrently)' });
1341
+
1335
1342
  // Feature 3: Sync edits to materialized work item if still pending
1336
1343
  let workItemSynced = false;
1337
1344
  const wiSyncPaths = [path.join(MINIONS_DIR, 'work-items.json')];
@@ -1929,8 +1936,8 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
1929
1936
  mutateJsonFileLocked(wiPath, (items) => {
1930
1937
  if (!Array.isArray(items)) return items;
1931
1938
  for (const w of items) {
1932
- if (w.sourcePlan === body.file && w.status === 'paused' && w._pausedBy === 'prd-pause') {
1933
- w.status = 'pending';
1939
+ if (w.sourcePlan === body.file && w.status === WI_STATUS.PAUSED && w._pausedBy === 'prd-pause') {
1940
+ w.status = WI_STATUS.PENDING;
1934
1941
  delete w._pausedBy;
1935
1942
  w._resumedAt = new Date().toISOString();
1936
1943
  resumedItemIds.push(w.id);
@@ -1991,7 +1998,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
1991
1998
  // Keep completed items as-is, reset everything else to pending.
1992
1999
  if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
1993
2000
 
1994
- if (w.status === 'dispatched') {
2001
+ if (w.status === WI_STATUS.DISPATCHED) {
1995
2002
  // Kill the agent working on this item, if any.
1996
2003
  const activeEntry = (dispatch.active || []).find(d => d.meta?.item?.id === w.id || d.meta?.dispatchKey?.includes(w.id));
1997
2004
  if (activeEntry) {
@@ -2017,8 +2024,8 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2017
2024
  }
2018
2025
  }
2019
2026
 
2020
- if (w.status !== 'paused') reset++;
2021
- w.status = 'paused';
2027
+ if (w.status !== WI_STATUS.PAUSED) reset++;
2028
+ w.status = WI_STATUS.PAUSED;
2022
2029
  w._pausedBy = 'prd-pause';
2023
2030
  delete w._resumedAt;
2024
2031
  delete w.dispatched_at;
@@ -2329,9 +2336,8 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2329
2336
  try {
2330
2337
  const body = await readBody(req);
2331
2338
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
2332
- if (body.file.includes('..') || body.file.includes('\0') || body.file.includes('/') || body.file.includes('\\')) {
2333
- return jsonReply(res, 400, { error: 'invalid filename' });
2334
- }
2339
+ try { shared.sanitizePath(body.file, body.file.endsWith('.json') ? PRD_DIR : PLANS_DIR); }
2340
+ catch { return jsonReply(res, 400, { error: 'invalid filename' }); }
2335
2341
  const isJson = body.file.endsWith('.json');
2336
2342
  const targetDir = isJson ? PRD_DIR : PLANS_DIR;
2337
2343
  const archivePath = path.join(targetDir, 'archive', body.file);
@@ -2675,18 +2681,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2675
2681
  try {
2676
2682
  const prdDir = path.join(MINIONS_DIR, 'prd');
2677
2683
  if (fs.existsSync(prdDir)) {
2678
- for (const f of fs.readdirSync(prdDir)) {
2679
- if (!f.endsWith('.json')) continue;
2680
- const prd = safeJson(path.join(prdDir, f));
2684
+ for (const prdFile of fs.readdirSync(prdDir)) {
2685
+ if (!prdFile.endsWith('.json')) continue;
2686
+ const prd = safeJson(path.join(prdDir, prdFile));
2681
2687
  if (!prd || prd.source_plan !== planFile) continue;
2682
2688
  if (prd.status === 'paused' || prd.status === 'rejected') continue;
2683
2689
  // Found an active PRD linked to this plan — pause it
2684
2690
  prd.status = 'paused';
2685
2691
  prd.pausedAt = new Date().toISOString();
2686
2692
  prd.pausedBy = 'plan-steering';
2687
- safeWrite(path.join(prdDir, f), prd);
2688
- pausedPrd = f;
2689
- // Pause work items (reuse pause logic inline)
2693
+ safeWrite(path.join(prdDir, prdFile), prd);
2694
+ pausedPrd = prdFile;
2695
+ // Pause work items linked to this PRD (sourcePlan = PRD filename)
2690
2696
  const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
2691
2697
  for (const proj of PROJECTS) wiPaths.push(shared.projectWorkItemsPath(proj));
2692
2698
  const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
@@ -2695,45 +2701,45 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2695
2701
  const resetItemIds = new Set();
2696
2702
  for (const wiPath of wiPaths) {
2697
2703
  try {
2698
- const items = safeJson(wiPath);
2699
- if (!items) continue;
2700
- let changed = false;
2701
- for (const w of items) {
2702
- if (w.sourcePlan !== f) continue;
2703
- if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
2704
- if (w.status === 'dispatched') {
2705
- const activeEntry = (dispatch.active || []).find(d => d.meta?.item?.id === w.id || d.meta?.dispatchKey?.includes(w.id));
2706
- if (activeEntry) {
2707
- const statusPath = path.join(MINIONS_DIR, 'agents', activeEntry.agent, 'status.json');
2708
- try {
2709
- const agentStatus = JSON.parse(safeRead(statusPath) || '{}');
2710
- if (agentStatus.pid) {
2711
- try {
2712
- const safePid = shared.validatePid(agentStatus.pid);
2713
- if (process.platform === 'win32') {
2714
- require('child_process').execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
2715
- } else {
2716
- process.kill(safePid, 'SIGTERM');
2717
- }
2718
- } catch { /* process may be dead or invalid PID */ }
2719
- }
2720
- agentStatus.status = 'idle';
2721
- delete agentStatus.currentTask;
2722
- delete agentStatus.dispatched;
2723
- safeWrite(statusPath, agentStatus);
2724
- } catch { /* agent reset */ }
2725
- killedAgents.add(activeEntry.agent);
2704
+ mutateJsonFileLocked(wiPath, (items) => {
2705
+ if (!Array.isArray(items)) return items;
2706
+ for (const w of items) {
2707
+ if (w.sourcePlan !== prdFile) continue;
2708
+ if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
2709
+ if (w.status === WI_STATUS.DISPATCHED) {
2710
+ const activeEntry = (dispatch.active || []).find(d => d.meta?.item?.id === w.id || d.meta?.dispatchKey?.includes(w.id));
2711
+ if (activeEntry) {
2712
+ const statusPath = path.join(MINIONS_DIR, 'agents', activeEntry.agent, 'status.json');
2713
+ try {
2714
+ const agentStatus = JSON.parse(safeRead(statusPath) || '{}');
2715
+ if (agentStatus.pid) {
2716
+ try {
2717
+ const safePid = shared.validatePid(agentStatus.pid);
2718
+ if (process.platform === 'win32') {
2719
+ require('child_process').execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
2720
+ } else {
2721
+ process.kill(safePid, 'SIGTERM');
2722
+ }
2723
+ } catch { /* process may be dead or invalid PID */ }
2724
+ }
2725
+ agentStatus.status = 'idle';
2726
+ delete agentStatus.currentTask;
2727
+ delete agentStatus.dispatched;
2728
+ safeWrite(statusPath, agentStatus);
2729
+ } catch { /* agent reset */ }
2730
+ killedAgents.add(activeEntry.agent);
2731
+ }
2726
2732
  }
2733
+ w.status = WI_STATUS.PAUSED;
2734
+ w._pausedBy = 'plan-steering';
2735
+ delete w.dispatched_at;
2736
+ delete w.dispatched_to;
2737
+ delete w.failReason;
2738
+ delete w.failedAt;
2739
+ if (w.id) resetItemIds.add(w.id);
2727
2740
  }
2728
- w.status = 'pending';
2729
- delete w.dispatched_at;
2730
- delete w.dispatched_to;
2731
- delete w.failReason;
2732
- delete w.failedAt;
2733
- changed = true;
2734
- if (w.id) resetItemIds.add(w.id);
2735
- }
2736
- if (changed) safeWrite(wiPath, items);
2741
+ return items;
2742
+ }, { defaultValue: [] });
2737
2743
  } catch { /* reset work items */ }
2738
2744
  }
2739
2745
  if (resetItemIds.size > 0 || killedAgents.size > 0) {
package/engine/ado.js CHANGED
@@ -381,7 +381,7 @@ async function reconcilePrs(config) {
381
381
  const title = adoPr.title || '';
382
382
  // Extract item ID from branch name or PR title (e.g., feat(P-2cafdc2a): ...)
383
383
  const branchMatch = branch.match(/(P-[a-z0-9]{6,})/i) || branch.match(/(W-[a-z0-9]{6,})/i) || branch.match(/(PL-[a-z0-9]{6,})/i);
384
- const titleMatch = title.match(/\((P-[a-z0-9]{6,})\)/) || title.match(/\((W-[a-z0-9]{6,})\)/) || title.match(/\((W-[a-z0-9]{6,})\)/) || title.match(/\((PL-[a-z0-9]{6,})\)/);
384
+ const titleMatch = title.match(/\((P-[a-z0-9]{6,})\)/) || title.match(/\((W-[a-z0-9]{6,})\)/) || title.match(/\((PL-[a-z0-9]{6,})\)/);
385
385
  const linkedItemId = branchMatch?.[1] || titleMatch?.[1] || null;
386
386
  const linkedItem = linkedItemId ? allItems.find(i => i.id === linkedItemId) : null;
387
387
  const confirmedItemId = linkedItem ? linkedItemId : null;
package/engine/llm.js CHANGED
@@ -160,25 +160,12 @@ function callLLMStreaming(promptText, sysPromptText, { timeout = 120000, label =
160
160
  if (block.type === 'text' && block.text && block.text !== lastTextSent) {
161
161
  lastTextSent = block.text;
162
162
  onChunk(block.text);
163
- }
164
- }
165
- }
166
- } catch { /* incomplete JSON or non-JSON line */ }
167
- }
168
- // Also emit tool_use events so the frontend can show "Using tool: Read..."
169
- for (const line of lines) {
170
- const trimmed = line.trim();
171
- if (!trimmed || !trimmed.startsWith('{')) continue;
172
- try {
173
- const obj = JSON.parse(trimmed);
174
- if (obj.type === 'assistant' && obj.message?.content) {
175
- for (const block of obj.message.content) {
176
- if (block.type === 'tool_use' && block.name && onToolUse) {
163
+ } else if (block.type === 'tool_use' && block.name && onToolUse) {
177
164
  onToolUse(block.name, block.input);
178
165
  }
179
166
  }
180
167
  }
181
- } catch {}
168
+ } catch { /* incomplete JSON or non-JSON line */ }
182
169
  }
183
170
  });
184
171
  proc.stderr.on('data', d => { stderr += d.toString(); });
@@ -27,7 +27,13 @@ function findClaudeBinary() {
27
27
  path.join(path.dirname(process.execPath), '..', 'lib', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'),
28
28
  // fnm / volta — sibling to the node binary
29
29
  path.join(path.dirname(process.execPath), 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'),
30
- ].filter(Boolean);
30
+ ].filter(p => {
31
+ if (!p) {
32
+ if (process.env.MINIONS_DEBUG) console.log('[preflight] Dropped empty CLI search path entry');
33
+ return false;
34
+ }
35
+ return true;
36
+ });
31
37
  for (const p of searchPaths) {
32
38
  try { if (fs.existsSync(p)) return p; } catch {}
33
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.419",
3
+ "version": "0.1.421",
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"