@yemi33/minions 0.1.176 → 0.1.177

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,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.177 (2026-04-02)
4
+
5
+ ### Engine
6
+ - engine/cli.js
7
+
8
+ ### Dashboard
9
+ - dashboard.js
10
+
3
11
  ## 0.1.176 (2026-04-02)
4
12
 
5
13
  ### Engine
package/dashboard.js CHANGED
@@ -667,7 +667,7 @@ function readBody(req) {
667
667
  return new Promise((resolve, reject) => {
668
668
  let body = '';
669
669
  req.on('data', chunk => { body += chunk; if (body.length > 1e6) reject(new Error('Too large')); });
670
- req.on('end', () => { try { resolve(JSON.parse(body)); } catch(e) { reject(e); } });
670
+ req.on('end', () => { try { resolve(body ? JSON.parse(body) : {}); } catch(e) { reject(e); } });
671
671
  });
672
672
  }
673
673
 
@@ -814,7 +814,7 @@ const server = http.createServer(async (req, res) => {
814
814
  // If archived, temporarily restore to active so checkPlanCompletion can find it
815
815
  const activePath = path.join(prdDir, body.file);
816
816
  if (fromArchive) {
817
- const plan = JSON.parse(safeRead(prdPath));
817
+ const plan = JSON.parse(safeRead(prdPath) || '{}');
818
818
  plan.status = 'approved';
819
819
  delete plan.completedAt;
820
820
  safeWrite(activePath, plan);
@@ -860,18 +860,22 @@ const server = http.createServer(async (req, res) => {
860
860
  }
861
861
  if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
862
862
 
863
- const items = JSON.parse(safeRead(wiPath) || '[]');
864
- const item = items.find(i => i.id === id);
865
- if (!item) return jsonReply(res, 404, { error: 'item not found' });
866
-
867
- item.status = 'pending';
868
- item._retryCount = 0; // Reset retry counter on manual retry
869
- delete item.dispatched_at;
870
- delete item.dispatched_to;
871
- delete item.failReason;
872
- delete item.failedAt;
873
- delete item.fanOutAgents;
874
- safeWrite(wiPath, items);
863
+ let found = false;
864
+ mutateJsonFileLocked(wiPath, (items) => {
865
+ if (!Array.isArray(items)) items = [];
866
+ const item = items.find(i => i.id === id);
867
+ if (!item) return items;
868
+ found = true;
869
+ item.status = 'pending';
870
+ item._retryCount = 0;
871
+ delete item.dispatched_at;
872
+ delete item.dispatched_to;
873
+ delete item.failReason;
874
+ delete item.failedAt;
875
+ delete item.fanOutAgents;
876
+ return items;
877
+ }, { defaultValue: [] });
878
+ if (!found) return jsonReply(res, 404, { error: 'item not found' });
875
879
 
876
880
  // Clear completed dispatch entries so the engine doesn't dedup this item
877
881
  const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
@@ -889,11 +893,10 @@ const server = http.createServer(async (req, res) => {
889
893
  // Clear cooldown so item isn't blocked by exponential backoff
890
894
  try {
891
895
  const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
892
- const cooldowns = JSON.parse(safeRead(cooldownPath) || '{}');
893
- if (cooldowns[dispatchKey]) {
894
- delete cooldowns[dispatchKey];
895
- safeWrite(cooldownPath, cooldowns);
896
- }
896
+ mutateJsonFileLocked(cooldownPath, (cooldowns) => {
897
+ if (cooldowns[dispatchKey]) delete cooldowns[dispatchKey];
898
+ return cooldowns;
899
+ });
897
900
  } catch (e) { console.error('cooldown cleanup:', e.message); }
898
901
 
899
902
  return jsonReply(res, 200, { ok: true, id });
@@ -918,15 +921,16 @@ const server = http.createServer(async (req, res) => {
918
921
  }
919
922
  if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
920
923
 
921
- const items = JSON.parse(safeRead(wiPath) || '[]');
922
- const idx = items.findIndex(i => i.id === id);
923
- if (idx === -1) return jsonReply(res, 404, { error: 'item not found' });
924
-
925
- const item = items[idx];
926
-
927
- // Remove item from work-items file
928
- items.splice(idx, 1);
929
- safeWrite(wiPath, items);
924
+ let found = false;
925
+ mutateJsonFileLocked(wiPath, (items) => {
926
+ if (!Array.isArray(items)) items = [];
927
+ const idx = items.findIndex(i => i.id === id);
928
+ if (idx === -1) return items;
929
+ found = true;
930
+ items.splice(idx, 1);
931
+ return items;
932
+ }, { defaultValue: [] });
933
+ if (!found) return jsonReply(res, 404, { error: 'item not found' });
930
934
 
931
935
  // Clean dispatch entries + kill running agent
932
936
  const dispatchRemoved = cleanDispatchEntries(d =>
@@ -937,12 +941,12 @@ const server = http.createServer(async (req, res) => {
937
941
  // Clean cooldown entries so item can be re-created immediately
938
942
  try {
939
943
  const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
940
- const cooldowns = JSON.parse(safeRead(cooldownPath) || '{}');
941
- let cleaned = false;
942
- for (const key of Object.keys(cooldowns)) {
943
- if (key.includes(id)) { delete cooldowns[key]; cleaned = true; }
944
- }
945
- if (cleaned) safeWrite(cooldownPath, cooldowns);
944
+ mutateJsonFileLocked(cooldownPath, (cooldowns) => {
945
+ for (const key of Object.keys(cooldowns)) {
946
+ if (key.includes(id)) delete cooldowns[key];
947
+ }
948
+ return cooldowns;
949
+ });
946
950
  } catch (e) { console.error('cooldown cleanup:', e.message); }
947
951
 
948
952
  invalidateStatusCache();
@@ -1024,9 +1028,6 @@ const server = http.createServer(async (req, res) => {
1024
1028
  // Write to central queue — agent decides which project
1025
1029
  wiPath = path.join(MINIONS_DIR, 'work-items.json');
1026
1030
  }
1027
- let items = [];
1028
- const existing = safeRead(wiPath);
1029
- if (existing) { try { items = JSON.parse(existing); } catch {} }
1030
1031
  const id = 'W-' + shared.uid();
1031
1032
  const item = {
1032
1033
  id, title: body.title, type: body.type || 'implement',
@@ -1039,8 +1040,11 @@ const server = http.createServer(async (req, res) => {
1039
1040
  if (body.references) item.references = body.references;
1040
1041
  if (body.acceptanceCriteria) item.acceptanceCriteria = body.acceptanceCriteria;
1041
1042
  if (body.skipPr === true) item.skipPr = true;
1042
- items.push(item);
1043
- safeWrite(wiPath, items);
1043
+ mutateJsonFileLocked(wiPath, (items) => {
1044
+ if (!Array.isArray(items)) items = [];
1045
+ items.push(item);
1046
+ return items;
1047
+ }, { defaultValue: [] });
1044
1048
  return jsonReply(res, 200, { ok: true, id });
1045
1049
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1046
1050
  }
@@ -1062,26 +1066,27 @@ const server = http.createServer(async (req, res) => {
1062
1066
  }
1063
1067
  if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
1064
1068
 
1065
- const items = JSON.parse(safeRead(wiPath) || '[]');
1066
- const item = items.find(i => i.id === id);
1067
- if (!item) return jsonReply(res, 404, { error: 'item not found' });
1068
-
1069
- if (item.status === 'dispatched') {
1070
- return jsonReply(res, 400, { error: 'Cannot edit dispatched items' });
1071
- }
1072
-
1073
- if (title !== undefined) item.title = title;
1074
- if (description !== undefined) item.description = description;
1075
- if (type !== undefined) item.type = type;
1076
- if (priority !== undefined) item.priority = priority;
1077
- if (agent !== undefined) item.agent = agent || null;
1078
- if (body.references !== undefined) item.references = body.references;
1079
- if (body.acceptanceCriteria !== undefined) item.acceptanceCriteria = body.acceptanceCriteria;
1080
- if (body.skipPr !== undefined) item.skipPr = body.skipPr === true;
1081
- item.updatedAt = new Date().toISOString();
1082
-
1083
- safeWrite(wiPath, items);
1084
- return jsonReply(res, 200, { ok: true, item });
1069
+ let result = null;
1070
+ let error = null;
1071
+ mutateJsonFileLocked(wiPath, (items) => {
1072
+ if (!Array.isArray(items)) items = [];
1073
+ const item = items.find(i => i.id === id);
1074
+ if (!item) { error = 'item not found'; return items; }
1075
+ if (item.status === 'dispatched') { error = 'Cannot edit dispatched items'; return items; }
1076
+ if (title !== undefined) item.title = title;
1077
+ if (description !== undefined) item.description = description;
1078
+ if (type !== undefined) item.type = type;
1079
+ if (priority !== undefined) item.priority = priority;
1080
+ if (agent !== undefined) item.agent = agent || null;
1081
+ if (body.references !== undefined) item.references = body.references;
1082
+ if (body.acceptanceCriteria !== undefined) item.acceptanceCriteria = body.acceptanceCriteria;
1083
+ if (body.skipPr !== undefined) item.skipPr = body.skipPr === true;
1084
+ item.updatedAt = new Date().toISOString();
1085
+ result = { ...item };
1086
+ return items;
1087
+ }, { defaultValue: [] });
1088
+ if (error) return jsonReply(res, error === 'item not found' ? 404 : 400, { error });
1089
+ return jsonReply(res, 200, { ok: true, item: result });
1085
1090
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1086
1091
  }
1087
1092
 
@@ -1165,24 +1170,18 @@ const server = http.createServer(async (req, res) => {
1165
1170
  const item = (plan.missing_features || []).find(f => f.id === body.itemId);
1166
1171
  if (!item) return jsonReply(res, 404, { error: 'item not found in plan' });
1167
1172
 
1168
- // Update allowed fields
1169
- if (body.name !== undefined) item.name = body.name;
1170
- if (body.description !== undefined) item.description = body.description;
1171
- if (body.priority !== undefined) item.priority = body.priority;
1172
- if (body.estimated_complexity !== undefined) item.estimated_complexity = body.estimated_complexity;
1173
- if (body.status !== undefined) item.status = body.status;
1174
-
1175
- // Re-read plan before writing to minimize race window with engine
1176
- const freshPlan = safeJson(planPath) || plan;
1177
- const freshItem = (freshPlan.missing_features || []).find(f => f.id === body.itemId);
1178
- if (freshItem) {
1179
- if (body.name !== undefined) freshItem.name = body.name;
1180
- if (body.description !== undefined) freshItem.description = body.description;
1181
- if (body.priority !== undefined) freshItem.priority = body.priority;
1182
- if (body.estimated_complexity !== undefined) freshItem.estimated_complexity = body.estimated_complexity;
1183
- if (body.status !== undefined) freshItem.status = body.status;
1184
- }
1185
- safeWrite(planPath, freshPlan);
1173
+ // Update plan item under lock
1174
+ mutateJsonFileLocked(planPath, (freshPlan) => {
1175
+ const freshItem = (freshPlan.missing_features || []).find(f => f.id === body.itemId);
1176
+ if (freshItem) {
1177
+ if (body.name !== undefined) freshItem.name = body.name;
1178
+ if (body.description !== undefined) freshItem.description = body.description;
1179
+ if (body.priority !== undefined) freshItem.priority = body.priority;
1180
+ if (body.estimated_complexity !== undefined) freshItem.estimated_complexity = body.estimated_complexity;
1181
+ if (body.status !== undefined) freshItem.status = body.status;
1182
+ }
1183
+ return freshPlan;
1184
+ });
1186
1185
 
1187
1186
  // Feature 3: Sync edits to materialized work item if still pending
1188
1187
  let workItemSynced = false;
@@ -1192,18 +1191,20 @@ const server = http.createServer(async (req, res) => {
1192
1191
  }
1193
1192
  for (const wiPath of wiSyncPaths) {
1194
1193
  try {
1195
- const items = safeJson(wiPath);
1196
- const wi = items.find(w => w.sourcePlan === body.source && w.id === body.itemId);
1197
- if (wi && wi.status === 'pending') {
1198
- if (body.name !== undefined) wi.title = 'Implement: ' + body.name;
1199
- if (body.description !== undefined) wi.description = body.description;
1200
- if (body.priority !== undefined) wi.priority = body.priority;
1201
- if (body.estimated_complexity !== undefined) {
1202
- wi.type = body.estimated_complexity === 'large' ? 'implement:large' : 'implement';
1194
+ mutateJsonFileLocked(wiPath, (items) => {
1195
+ if (!Array.isArray(items)) return items;
1196
+ const wi = items.find(w => w.sourcePlan === body.source && w.id === body.itemId);
1197
+ if (wi && wi.status === 'pending') {
1198
+ if (body.name !== undefined) wi.title = 'Implement: ' + body.name;
1199
+ if (body.description !== undefined) wi.description = body.description;
1200
+ if (body.priority !== undefined) wi.priority = body.priority;
1201
+ if (body.estimated_complexity !== undefined) {
1202
+ wi.type = body.estimated_complexity === 'large' ? 'implement:large' : 'implement';
1203
+ }
1204
+ workItemSynced = true;
1203
1205
  }
1204
- safeWrite(wiPath, items);
1205
- workItemSynced = true;
1206
- }
1206
+ return items;
1207
+ }, { defaultValue: [] });
1207
1208
  } catch (e) { console.error('work item sync:', e.message); }
1208
1209
  }
1209
1210
 
@@ -1338,6 +1339,7 @@ const server = http.createServer(async (req, res) => {
1338
1339
 
1339
1340
  // Watch for changes using fs.watchFile (cross-platform, works on Windows)
1340
1341
  const watcher = () => {
1342
+ if (res.writableEnded) return;
1341
1343
  try {
1342
1344
  const stat = fs.statSync(liveLogPath);
1343
1345
  if (stat.size > offset) {
@@ -1356,14 +1358,17 @@ const server = http.createServer(async (req, res) => {
1356
1358
 
1357
1359
  // Check if agent is still active (poll every 5s)
1358
1360
  const doneCheck = setInterval(() => {
1361
+ if (res.writableEnded) { clearInterval(doneCheck); return; }
1359
1362
  const dispatch = getDispatchQueue();
1360
1363
  const isActive = (dispatch.active || []).some(d => d.agent === agentId);
1361
1364
  if (!isActive) {
1362
1365
  watcher(); // flush final content
1363
- res.write(`event: done\ndata: complete\n\n`);
1364
1366
  clearInterval(doneCheck);
1365
1367
  fs.unwatchFile(liveLogPath, watcher);
1366
- res.end();
1368
+ if (!res.writableEnded) {
1369
+ res.write(`event: done\ndata: complete\n\n`);
1370
+ res.end();
1371
+ }
1367
1372
  }
1368
1373
  }, 5000);
1369
1374
 
@@ -1387,7 +1392,7 @@ const server = http.createServer(async (req, res) => {
1387
1392
  } else {
1388
1393
  // Return last N bytes via ?tail=N param (default last 8KB)
1389
1394
  const params = new URL(req.url, 'http://localhost').searchParams;
1390
- const tailBytes = parseInt(params.get('tail')) || 8192;
1395
+ const tailBytes = Math.min(parseInt(params.get('tail')) || 8192, 1024 * 1024);
1391
1396
  res.end(content.length > tailBytes ? content.slice(-tailBytes) : content);
1392
1397
  }
1393
1398
  return;
@@ -3072,24 +3077,34 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3072
3077
  const projects = shared.getProjects(CONFIG);
3073
3078
  const paths = [path.join(MINIONS_DIR, 'work-items.json')];
3074
3079
  for (const p of projects) paths.push(shared.projectWorkItemsPath(p));
3080
+ let found = false;
3081
+ let feedbackAgent = 'unknown';
3082
+ let feedbackTitle = id;
3075
3083
  for (const wiPath of paths) {
3076
- const items = JSON.parse(safeRead(wiPath) || '[]');
3077
- const item = items.find(i => i.id === id);
3078
- if (!item) continue;
3079
- item._humanFeedback = { rating, comment: comment || '', at: new Date().toISOString() };
3080
- safeWrite(wiPath, items);
3081
- const agent = item.dispatched_to || item.agent || 'unknown';
3082
- const feedbackNote = '# Human Feedback on ' + id + '\n\n' +
3083
- '**Rating:** ' + (rating === 'up' ? '👍 Good' : '👎 Needs improvement') + '\n' +
3084
- '**Item:** ' + (item.title || id) + '\n' +
3085
- '**Agent:** ' + agent + '\n' +
3086
- (comment ? '**Feedback:** ' + comment + '\n' : '');
3087
- const inboxPath = path.join(MINIONS_DIR, 'notes', 'inbox', agent + '-feedback-' + new Date().toISOString().slice(0, 10) + '-' + shared.uid().slice(0, 4) + '.md');
3088
- safeWrite(inboxPath, feedbackNote);
3089
- invalidateStatusCache();
3090
- return jsonReply(res, 200, { ok: true });
3091
- }
3092
- return jsonReply(res, 404, { error: 'Work item not found' });
3084
+ try {
3085
+ mutateJsonFileLocked(wiPath, (items) => {
3086
+ if (!Array.isArray(items)) return items;
3087
+ const item = items.find(i => i.id === id);
3088
+ if (!item) return items;
3089
+ found = true;
3090
+ item._humanFeedback = { rating, comment: comment || '', at: new Date().toISOString() };
3091
+ feedbackAgent = item.dispatched_to || item.agent || 'unknown';
3092
+ feedbackTitle = item.title || id;
3093
+ return items;
3094
+ }, { defaultValue: [] });
3095
+ } catch { /* optional */ }
3096
+ if (found) break;
3097
+ }
3098
+ if (!found) return jsonReply(res, 404, { error: 'Work item not found' });
3099
+ const feedbackNote = '# Human Feedback on ' + id + '\n\n' +
3100
+ '**Rating:** ' + (rating === 'up' ? '👍 Good' : '👎 Needs improvement') + '\n' +
3101
+ '**Item:** ' + feedbackTitle + '\n' +
3102
+ '**Agent:** ' + feedbackAgent + '\n' +
3103
+ (comment ? '**Feedback:** ' + comment + '\n' : '');
3104
+ const inboxPath = path.join(MINIONS_DIR, 'notes', 'inbox', feedbackAgent + '-feedback-' + new Date().toISOString().slice(0, 10) + '-' + shared.uid().slice(0, 4) + '.md');
3105
+ safeWrite(inboxPath, feedbackNote);
3106
+ invalidateStatusCache();
3107
+ return jsonReply(res, 200, { ok: true });
3093
3108
  }},
3094
3109
 
3095
3110
  // Pinned notes
@@ -3461,6 +3476,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3461
3476
 
3462
3477
  const pathname = req.url.split('?')[0];
3463
3478
  const _reqStart = Date.now();
3479
+ try {
3464
3480
  for (const route of ROUTES) {
3465
3481
  if (route.method !== req.method) continue;
3466
3482
  if (typeof route.path === 'string') {
@@ -3506,6 +3522,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3506
3522
  } else {
3507
3523
  res.end(HTML);
3508
3524
  }
3525
+ } catch (err) {
3526
+ console.error(`[ERROR] ${req.method} ${req.url}: ${err.message}`);
3527
+ if (!res.headersSent) {
3528
+ try { jsonReply(res, 500, { error: 'Internal server error' }); } catch { res.end(); }
3529
+ }
3530
+ }
3509
3531
  });
3510
3532
 
3511
3533
  server.listen(PORT, '127.0.0.1', () => {
package/engine/cli.js CHANGED
@@ -312,7 +312,7 @@ const commands = {
312
312
  const tickTimer = setInterval(() => e.tick(), interval);
313
313
 
314
314
  // Fast poll for immediate wakeup signals (checks control.json every 2s)
315
- setInterval(() => {
315
+ const wakeupTimer = setInterval(() => {
316
316
  const ctrl = getControl();
317
317
  if (ctrl._wakeupAt && Date.now() - ctrl._wakeupAt < 5000) {
318
318
  delete ctrl._wakeupAt;
@@ -370,6 +370,7 @@ const commands = {
370
370
  shuttingDown = true;
371
371
  console.log(`\n${signal} received — initiating graceful shutdown...`);
372
372
  clearInterval(tickTimer);
373
+ clearInterval(wakeupTimer);
373
374
  for (const f of _watchedFiles) { try { fs.unwatchFile(f); } catch { /* cleanup */ } }
374
375
  safeWrite(CONTROL_PATH, { state: 'stopping', pid: process.pid, stopping_at: e.ts() });
375
376
  e.log('info', `Graceful shutdown initiated (${signal})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.176",
3
+ "version": "0.1.177",
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"