@yemi33/minions 0.1.10 → 0.1.11

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.11 (2026-03-26)
4
+
5
+ ### Engine
6
+ - engine.js
7
+ - engine/lifecycle.js
8
+ - engine/scheduler.js
9
+
10
+ ### Dashboard
11
+ - dashboard.html
12
+ - dashboard.js
13
+
14
+ ### Other
15
+ - test/unit.test.js
16
+
3
17
  ## 0.1.9 (2026-03-26)
4
18
 
5
19
  ### Engine
package/dashboard.html CHANGED
@@ -714,6 +714,13 @@
714
714
  <div id="mcp-list"><p class="empty">No MCP servers synced.</p></div>
715
715
  </section>
716
716
 
717
+ <section id="scheduled-section">
718
+ <h2>Scheduled Tasks <span class="count" id="scheduled-count">0</span>
719
+ <button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openCreateScheduleModal()">+ New</button>
720
+ </h2>
721
+ <div id="scheduled-content"><p class="empty">No scheduled tasks. Add one to automate recurring work.</p></div>
722
+ </section>
723
+
717
724
  <section>
718
725
  <h2>Dispatch Queue</h2>
719
726
  <div class="dispatch-stats" id="dispatch-stats"></div>
@@ -2380,6 +2387,7 @@ async function refresh() {
2380
2387
  renderWorkItems(data.workItems || []);
2381
2388
  renderSkills(data.skills || []);
2382
2389
  renderMcpServers(data.mcpServers || []);
2390
+ renderSchedules(data.schedules || []);
2383
2391
  // Refresh KB and plans less frequently (every 3rd cycle = ~12s)
2384
2392
  if (!window._kbRefreshCount) window._kbRefreshCount = 0;
2385
2393
  if (window._kbRefreshCount++ % 3 === 0) { refreshKnowledgeBase(); refreshPlans(); }
@@ -2448,6 +2456,164 @@ function renderMcpServers(servers) {
2448
2456
  '<p style="font-size:10px;color:var(--muted);margin:0">Synced from <code style="color:var(--blue)">~/.claude.json</code> — add MCP servers there to make them available to all agents.</p>';
2449
2457
  }
2450
2458
 
2459
+ // -- Scheduled Tasks --
2460
+ function renderSchedules(schedules) {
2461
+ const el = document.getElementById('scheduled-content');
2462
+ const countEl = document.getElementById('scheduled-count');
2463
+ countEl.textContent = schedules.length;
2464
+ if (!schedules.length) {
2465
+ el.innerHTML = '<p class="empty">No scheduled tasks. Add one to automate recurring work.</p>';
2466
+ return;
2467
+ }
2468
+ let html = '<div class="pr-table-wrap"><table class="pr-table"><thead><tr><th>ID</th><th>Title</th><th>Cron</th><th>Type</th><th>Project</th><th>Agent</th><th>Enabled</th><th>Last Run</th><th></th></tr></thead><tbody>';
2469
+ for (const s of schedules) {
2470
+ const enabledBadge = s.enabled
2471
+ ? '<span class="pr-badge approved">enabled</span>'
2472
+ : '<span class="pr-badge rejected">disabled</span>';
2473
+ const lastRun = s._lastRun ? timeAgo(s._lastRun) : 'never';
2474
+ const typeBadge = '<span class="dispatch-type ' + escHtml(s.type || 'implement') + '">' + escHtml(s.type || 'implement') + '</span>';
2475
+ html += '<tr>' +
2476
+ '<td><span class="pr-id">' + escHtml(s.id || '') + '</span></td>' +
2477
+ '<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml(s.title || '') + '">' + escHtml(s.title || '') + '</td>' +
2478
+ '<td><code style="font-size:10px;color:var(--blue)">' + escHtml(s.cron || '') + '</code></td>' +
2479
+ '<td>' + typeBadge + '</td>' +
2480
+ '<td><span style="font-size:10px;color:var(--muted)">' + escHtml(s.project || '') + '</span></td>' +
2481
+ '<td><span class="pr-agent">' + escHtml(s.agent || 'auto') + '</span></td>' +
2482
+ '<td>' + enabledBadge + '</td>' +
2483
+ '<td><span class="pr-date">' + escHtml(lastRun) + '</span></td>' +
2484
+ '<td style="white-space:nowrap">' +
2485
+ '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:' + (s.enabled ? 'var(--yellow)' : 'var(--green)') + ';border-color:' + (s.enabled ? 'var(--yellow)' : 'var(--green)') + ';margin-right:4px" onclick="event.stopPropagation();toggleScheduleEnabled(\'' + escHtml(s.id) + '\',' + !s.enabled + ')" title="' + (s.enabled ? 'Disable' : 'Enable') + '">' + (s.enabled ? '&#x23F8;' : '&#x25B6;') + '</button>' +
2486
+ '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--blue);border-color:var(--blue);margin-right:4px" onclick="event.stopPropagation();openEditScheduleModal(\'' + escHtml(s.id) + '\')" title="Edit">&#x270E;</button>' +
2487
+ '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--red);border-color:var(--red)" onclick="event.stopPropagation();deleteSchedule(\'' + escHtml(s.id) + '\')" title="Delete">&#x2715;</button>' +
2488
+ '</td>' +
2489
+ '</tr>';
2490
+ }
2491
+ html += '</tbody></table></div>';
2492
+ el.innerHTML = html;
2493
+ window._lastSchedules = schedules;
2494
+ }
2495
+
2496
+ function _scheduleFormHtml(sched, isEdit) {
2497
+ const types = ['implement', 'test', 'explore', 'ask', 'review', 'fix'];
2498
+ const priorities = ['high', 'medium', 'low'];
2499
+ const typeOpts = types.map(t => '<option value="' + t + '"' + ((sched.type || 'implement') === t ? ' selected' : '') + '>' + t + '</option>').join('');
2500
+ const priOpts = priorities.map(p => '<option value="' + p + '"' + ((sched.priority || 'medium') === p ? ' selected' : '') + '>' + p + '</option>').join('');
2501
+ const projOpts = '<option value="">Any</option>' + cmdProjects.map(p => '<option value="' + escHtml(p.name) + '"' + (sched.project === p.name ? ' selected' : '') + '>' + escHtml(p.name) + '</option>').join('');
2502
+ const agentOpts = '<option value="">Auto</option>' + cmdAgents.map(a => '<option value="' + escHtml(a.id) + '"' + (sched.agent === a.id ? ' selected' : '') + '>' + escHtml(a.name) + '</option>').join('');
2503
+
2504
+ const inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
2505
+
2506
+ return '<div style="display:flex;flex-direction:column;gap:12px;font-family:inherit">' +
2507
+ (isEdit ? '' :
2508
+ '<label style="color:var(--text);font-size:var(--text-md)">ID (unique slug)' +
2509
+ '<input id="sched-edit-id" value="' + escHtml(sched.id || '') + '" placeholder="e.g. nightly-tests" style="' + inputStyle + '">' +
2510
+ '</label>') +
2511
+ '<label style="color:var(--text);font-size:var(--text-md)">Title' +
2512
+ '<input id="sched-edit-title" value="' + escHtml(sched.title || '') + '" style="' + inputStyle + '">' +
2513
+ '</label>' +
2514
+ '<label style="color:var(--text);font-size:var(--text-md)">Cron <span style="font-size:10px;color:var(--muted)">(minute hour dayOfWeek)</span>' +
2515
+ '<input id="sched-edit-cron" value="' + escHtml(sched.cron || '') + '" placeholder="0 2 *" style="' + inputStyle + '">' +
2516
+ '</label>' +
2517
+ '<div style="display:flex;gap:12px">' +
2518
+ '<label style="color:var(--text);font-size:var(--text-md);flex:1">Type' +
2519
+ '<select id="sched-edit-type" style="' + inputStyle + '">' + typeOpts + '</select>' +
2520
+ '</label>' +
2521
+ '<label style="color:var(--text);font-size:var(--text-md);flex:1">Priority' +
2522
+ '<select id="sched-edit-priority" style="' + inputStyle + '">' + priOpts + '</select>' +
2523
+ '</label>' +
2524
+ '</div>' +
2525
+ '<div style="display:flex;gap:12px">' +
2526
+ '<label style="color:var(--text);font-size:var(--text-md);flex:1">Project' +
2527
+ '<select id="sched-edit-project" style="' + inputStyle + '">' + projOpts + '</select>' +
2528
+ '</label>' +
2529
+ '<label style="color:var(--text);font-size:var(--text-md);flex:1">Agent' +
2530
+ '<select id="sched-edit-agent" style="' + inputStyle + '">' + agentOpts + '</select>' +
2531
+ '</label>' +
2532
+ '</div>' +
2533
+ '<label style="color:var(--text);font-size:var(--text-md)">Description' +
2534
+ '<textarea id="sched-edit-desc" rows="3" style="' + inputStyle + ';resize:vertical">' + escHtml(sched.description || '') + '</textarea>' +
2535
+ '</label>' +
2536
+ '<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px">' +
2537
+ '<button onclick="closeModal()" class="pr-pager-btn" style="padding:6px 16px;font-size:var(--text-md)">Cancel</button>' +
2538
+ '<button onclick="submitSchedule(' + isEdit + ')" style="padding:6px 16px;font-size:var(--text-md);background:var(--blue);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer">' + (isEdit ? 'Save' : 'Create') + '</button>' +
2539
+ '</div>' +
2540
+ '</div>';
2541
+ }
2542
+
2543
+ function openCreateScheduleModal() {
2544
+ document.getElementById('modal-title').textContent = 'New Scheduled Task';
2545
+ document.getElementById('modal-body').style.whiteSpace = 'normal';
2546
+ document.getElementById('modal-body').style.fontFamily = '';
2547
+ document.getElementById('modal-body').innerHTML = _scheduleFormHtml({}, false);
2548
+ document.getElementById('modal').classList.add('open');
2549
+ }
2550
+
2551
+ function openEditScheduleModal(id) {
2552
+ const sched = (window._lastSchedules || []).find(s => s.id === id);
2553
+ if (!sched) return;
2554
+ document.getElementById('modal-title').textContent = 'Edit Schedule: ' + id;
2555
+ document.getElementById('modal-body').style.whiteSpace = 'normal';
2556
+ document.getElementById('modal-body').style.fontFamily = '';
2557
+ document.getElementById('modal-body').innerHTML = _scheduleFormHtml(sched, true);
2558
+ window._editScheduleId = id;
2559
+ document.getElementById('modal').classList.add('open');
2560
+ }
2561
+
2562
+ async function submitSchedule(isEdit) {
2563
+ const title = document.getElementById('sched-edit-title').value.trim();
2564
+ const cron = document.getElementById('sched-edit-cron').value.trim();
2565
+ const type = document.getElementById('sched-edit-type').value;
2566
+ const priority = document.getElementById('sched-edit-priority').value;
2567
+ const project = document.getElementById('sched-edit-project').value;
2568
+ const agent = document.getElementById('sched-edit-agent').value;
2569
+ const description = document.getElementById('sched-edit-desc').value;
2570
+ const id = isEdit ? window._editScheduleId : (document.getElementById('sched-edit-id') ? document.getElementById('sched-edit-id').value.trim() : '');
2571
+
2572
+ if (!id) { alert('ID is required'); return; }
2573
+ if (!title) { alert('Title is required'); return; }
2574
+ if (!cron) { alert('Cron expression is required'); return; }
2575
+
2576
+ const payload = { id, title, cron, type, priority, project: project || undefined, agent: agent || undefined, description: description || undefined, enabled: true };
2577
+ const url = isEdit ? '/api/schedules/update' : '/api/schedules';
2578
+ try {
2579
+ const res = await fetch(url, {
2580
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2581
+ body: JSON.stringify(payload)
2582
+ });
2583
+ if (res.ok) { closeModal(); refresh(); showToast('cmd-toast', isEdit ? 'Schedule updated' : 'Schedule created', true); } else {
2584
+ const d = await res.json().catch(() => ({}));
2585
+ alert((isEdit ? 'Update' : 'Create') + ' failed: ' + (d.error || 'unknown'));
2586
+ }
2587
+ } catch (e) { alert('Error: ' + e.message); }
2588
+ }
2589
+
2590
+ async function toggleScheduleEnabled(id, enabled) {
2591
+ try {
2592
+ const res = await fetch('/api/schedules/update', {
2593
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2594
+ body: JSON.stringify({ id, enabled })
2595
+ });
2596
+ if (res.ok) { refresh(); } else {
2597
+ const d = await res.json().catch(() => ({}));
2598
+ alert('Toggle failed: ' + (d.error || 'unknown'));
2599
+ }
2600
+ } catch (e) { alert('Toggle error: ' + e.message); }
2601
+ }
2602
+
2603
+ async function deleteSchedule(id) {
2604
+ if (!confirm('Delete scheduled task "' + id + '"?')) return;
2605
+ try {
2606
+ const res = await fetch('/api/schedules/delete', {
2607
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2608
+ body: JSON.stringify({ id })
2609
+ });
2610
+ if (res.ok) { refresh(); showToast('cmd-toast', 'Schedule deleted', true); } else {
2611
+ const d = await res.json().catch(() => ({}));
2612
+ alert('Delete failed: ' + (d.error || 'unknown'));
2613
+ }
2614
+ } catch (e) { alert('Delete error: ' + e.message); }
2615
+ }
2616
+
2451
2617
  // -- Minions Skills --
2452
2618
  let _skillsTab = 'all';
2453
2619
  let _skillsPage = 0;
package/dashboard.js CHANGED
@@ -118,6 +118,11 @@ function getStatus() {
118
118
  workItems: getWorkItems(),
119
119
  skills: getSkills(),
120
120
  mcpServers: getMcpServers(),
121
+ schedules: (() => {
122
+ const scheds = CONFIG.schedules || [];
123
+ const runs = shared.safeJson(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')) || {};
124
+ return scheds.map(s => ({ ...s, _lastRun: runs[s.id] || null }));
125
+ })(),
121
126
  projects: PROJECTS.map(p => ({ name: p.name, path: p.localPath, description: p.description || '' })),
122
127
  initialized: !!(CONFIG.agents && Object.keys(CONFIG.agents).length > 0),
123
128
  installId: safeRead(path.join(MINIONS_DIR, '.install-id')).trim() || null,
@@ -270,6 +275,11 @@ function buildCCStatePreamble() {
270
275
 
271
276
  const planFiles = [...safeReadDir(PLANS_DIR), ...safeReadDir(PRD_DIR)].filter(f => f.endsWith('.md') || f.endsWith('.json'));
272
277
 
278
+ const schedules = CONFIG.schedules || [];
279
+ const schedSummary = schedules.length > 0
280
+ ? schedules.map(s => `- ${s.id}: "${s.title}" (cron: ${s.cron}, type: ${s.type || 'implement'}, ${s.enabled === false ? 'disabled' : 'enabled'})`).join('\n')
281
+ : '(none configured)';
282
+
273
283
  return `### Agents
274
284
  ${agents}
275
285
 
@@ -278,11 +288,16 @@ ${active}
278
288
  Pending: ${pending}
279
289
 
280
290
  ### Quick Counts
281
- PRs: ${prCount} | Work items: ${wiCount} | Plans/PRDs on disk: ${planFiles.length}
291
+ PRs: ${prCount} | Work items: ${wiCount} | Plans/PRDs on disk: ${planFiles.length} | Schedules: ${schedules.length}
282
292
 
283
293
  ### Projects
284
294
  ${projects}
285
295
 
296
+ ### Scheduled Tasks
297
+ ${schedSummary}
298
+
299
+ To discover all available dashboard APIs, fetch GET http://localhost:7331/api/routes — it returns every endpoint with method, path, description, and accepted parameters.
300
+
286
301
  For details on any of the above, use your tools to read files under \`${MINIONS_DIR}\`.`;
287
302
  }
288
303
 
@@ -609,8 +624,9 @@ const server = http.createServer(async (req, res) => {
609
624
  return;
610
625
  }
611
626
 
612
- // POST /api/plans/trigger-verify manually trigger verification for a completed plan
613
- if (req.method === 'POST' && req.url === '/api/plans/trigger-verify') {
627
+ // ── Route Handler Functions ───────────────────────────────────────────────
628
+
629
+ async function handlePlansTriggerVerify(req, res) {
614
630
  try {
615
631
  const body = await readBody(req);
616
632
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
@@ -656,8 +672,7 @@ const server = http.createServer(async (req, res) => {
656
672
  } catch (e) { return jsonReply(res, 500, { error: e.message }); }
657
673
  }
658
674
 
659
- // POST /api/work-items/retry reset a failed/dispatched item to pending
660
- if (req.method === 'POST' && req.url === '/api/work-items/retry') {
675
+ async function handleWorkItemsRetry(req, res) {
661
676
  try {
662
677
  const body = await readBody(req);
663
678
  const { id, source } = body;
@@ -715,8 +730,7 @@ const server = http.createServer(async (req, res) => {
715
730
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
716
731
  }
717
732
 
718
- // POST /api/work-items/delete — remove a work item, kill agent, clear dispatch
719
- if (req.method === 'POST' && req.url === '/api/work-items/delete') {
733
+ async function handleWorkItemsDelete(req, res) {
720
734
  try {
721
735
  const body = await readBody(req);
722
736
  const { id, source } = body;
@@ -757,8 +771,7 @@ const server = http.createServer(async (req, res) => {
757
771
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
758
772
  }
759
773
 
760
- // POST /api/work-items/archive move a completed/failed work item to archive
761
- if (req.method === 'POST' && req.url === '/api/work-items/archive') {
774
+ async function handleWorkItemsArchive(req, res) {
762
775
  try {
763
776
  const body = await readBody(req);
764
777
  const { id, source } = body;
@@ -802,8 +815,7 @@ const server = http.createServer(async (req, res) => {
802
815
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
803
816
  }
804
817
 
805
- // GET /api/work-items/archive list archived work items
806
- if (req.method === 'GET' && req.url === '/api/work-items/archive') {
818
+ async function handleWorkItemsArchiveList(req, res) {
807
819
  try {
808
820
  let allArchived = [];
809
821
  // Central archive
@@ -820,8 +832,7 @@ const server = http.createServer(async (req, res) => {
820
832
  } catch (e) { console.error('Archive fetch error:', e.message); return jsonReply(res, 500, { error: e.message }); }
821
833
  }
822
834
 
823
- // POST /api/work-items
824
- if (req.method === 'POST' && req.url === '/api/work-items') {
835
+ async function handleWorkItemsCreate(req, res) {
825
836
  try {
826
837
  const body = await readBody(req);
827
838
  if (!body.title || !body.title.trim()) return jsonReply(res, 400, { error: 'title is required' });
@@ -852,8 +863,7 @@ const server = http.createServer(async (req, res) => {
852
863
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
853
864
  }
854
865
 
855
- // POST /api/work-items/update edit a pending/failed work item
856
- if (req.method === 'POST' && req.url === '/api/work-items/update') {
866
+ async function handleWorkItemsUpdate(req, res) {
857
867
  try {
858
868
  const body = await readBody(req);
859
869
  const { id, source, title, description, type, priority, agent } = body;
@@ -890,8 +900,7 @@ const server = http.createServer(async (req, res) => {
890
900
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
891
901
  }
892
902
 
893
- // POST /api/notes write to inbox so it flows through normal consolidation
894
- if (req.method === 'POST' && req.url === '/api/notes') {
903
+ async function handleNotesCreate(req, res) {
895
904
  try {
896
905
  const body = await readBody(req);
897
906
  if (!body.title || !body.title.trim()) return jsonReply(res, 400, { error: 'title is required' });
@@ -907,8 +916,7 @@ const server = http.createServer(async (req, res) => {
907
916
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
908
917
  }
909
918
 
910
- // POST /api/plan create a plan work item that chains to PRD on completion
911
- if (req.method === 'POST' && req.url === '/api/plan') {
919
+ async function handlePlanCreate(req, res) {
912
920
  try {
913
921
  const body = await readBody(req);
914
922
  if (!body.title || !body.title.trim()) return jsonReply(res, 400, { error: 'title is required' });
@@ -932,8 +940,7 @@ const server = http.createServer(async (req, res) => {
932
940
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
933
941
  }
934
942
 
935
- // POST /api/prd-items create a PRD item as a plan file in prd/ (auto-approved)
936
- if (req.method === 'POST' && req.url === '/api/prd-items') {
943
+ async function handlePrdItemsCreate(req, res) {
937
944
  try {
938
945
  const body = await readBody(req);
939
946
  if (!body.name || !body.name.trim()) return jsonReply(res, 400, { error: 'name is required' });
@@ -963,8 +970,7 @@ const server = http.createServer(async (req, res) => {
963
970
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
964
971
  }
965
972
 
966
- // POST /api/prd-items/update edit a PRD item in its source plan JSON
967
- if (req.method === 'POST' && req.url === '/api/prd-items/update') {
973
+ async function handlePrdItemsUpdate(req, res) {
968
974
  try {
969
975
  const body = await readBody(req);
970
976
  if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
@@ -1020,8 +1026,7 @@ const server = http.createServer(async (req, res) => {
1020
1026
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1021
1027
  }
1022
1028
 
1023
- // POST /api/prd-items/remove remove a PRD item from plan + cancel materialized work item
1024
- if (req.method === 'POST' && req.url === '/api/prd-items/remove') {
1029
+ async function handlePrdItemsRemove(req, res) {
1025
1030
  try {
1026
1031
  const body = await readBody(req);
1027
1032
  if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
@@ -1066,8 +1071,7 @@ const server = http.createServer(async (req, res) => {
1066
1071
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1067
1072
  }
1068
1073
 
1069
- // POST /api/agents/cancel cancel an active agent by ID or task substring
1070
- if (req.method === 'POST' && req.url === '/api/agents/cancel') {
1074
+ async function handleAgentsCancel(req, res) {
1071
1075
  try {
1072
1076
  const body = await readBody(req);
1073
1077
  const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
@@ -1114,11 +1118,19 @@ const server = http.createServer(async (req, res) => {
1114
1118
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1115
1119
  }
1116
1120
 
1117
- // GET /api/agent/:id/live-stream SSE real-time live output streaming
1118
- const liveStreamMatch = req.url.match(/^\/api\/agent\/([\w-]+)\/live-stream(?:\?.*)?$/);
1119
- if (liveStreamMatch && req.method === 'GET') {
1120
- const agentId = liveStreamMatch[1];
1121
- const liveLogPath = path.join(MINIONS_DIR, 'agents', agentId, 'live-output.log');
1121
+ async function handleAgentLiveStream(req, res, match) {
1122
+ const agentId = match[1];
1123
+ const agentDir = path.join(MINIONS_DIR, 'agents', agentId);
1124
+ const liveLogPath = path.join(agentDir, 'live-output.log');
1125
+
1126
+ // Check if agent directory exists — avoid dangling watchers on nonexistent paths
1127
+ if (!fs.existsSync(agentDir)) {
1128
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
1129
+ res.write(`data: ${JSON.stringify('Agent not found: ' + agentId)}\n\n`);
1130
+ res.write(`event: done\ndata: not-found\n\n`);
1131
+ res.end();
1132
+ return;
1133
+ }
1122
1134
 
1123
1135
  res.writeHead(200, {
1124
1136
  'Content-Type': 'text/event-stream',
@@ -1177,10 +1189,8 @@ const server = http.createServer(async (req, res) => {
1177
1189
  return;
1178
1190
  }
1179
1191
 
1180
- // GET /api/agent/:id/live tail live output for a working agent
1181
- const liveMatch = req.url.match(/^\/api\/agent\/([\w-]+)\/live(?:\?.*)?$/);
1182
- if (liveMatch && req.method === 'GET') {
1183
- const agentId = liveMatch[1];
1192
+ async function handleAgentLive(req, res, match) {
1193
+ const agentId = match[1];
1184
1194
  const livePath = path.join(MINIONS_DIR, 'agents', agentId, 'live-output.log');
1185
1195
  const content = safeRead(livePath);
1186
1196
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
@@ -1196,10 +1206,8 @@ const server = http.createServer(async (req, res) => {
1196
1206
  return;
1197
1207
  }
1198
1208
 
1199
- // GET /api/agent/:id/output fetch final output.log for an agent
1200
- const outputMatch = req.url.match(/^\/api\/agent\/([\w-]+)\/output(?:\?.*)?$/);
1201
- if (outputMatch && req.method === 'GET') {
1202
- const agentId = outputMatch[1];
1209
+ async function handleAgentOutput(req, res, match) {
1210
+ const agentId = match[1];
1203
1211
  const outputPath = path.join(MINIONS_DIR, 'agents', agentId, 'output.log');
1204
1212
  const content = safeRead(outputPath);
1205
1213
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
@@ -1208,8 +1216,7 @@ const server = http.createServer(async (req, res) => {
1208
1216
  return;
1209
1217
  }
1210
1218
 
1211
- // GET /api/notes-full return full notes.md content
1212
- if (req.method === 'GET' && req.url === '/api/notes-full') {
1219
+ async function handleNotesFull(req, res) {
1213
1220
  const content = safeRead(path.join(MINIONS_DIR, 'notes.md'));
1214
1221
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
1215
1222
  res.setHeader('Access-Control-Allow-Origin', '*');
@@ -1217,8 +1224,7 @@ const server = http.createServer(async (req, res) => {
1217
1224
  return;
1218
1225
  }
1219
1226
 
1220
- // POST /api/notes-save save edited notes.md content
1221
- if (req.method === 'POST' && req.url === '/api/notes-save') {
1227
+ async function handleNotesSave(req, res) {
1222
1228
  try {
1223
1229
  const body = await readBody(req);
1224
1230
  if (!body.content && body.content !== '') return jsonReply(res, 400, { error: 'content required' });
@@ -1230,8 +1236,7 @@ const server = http.createServer(async (req, res) => {
1230
1236
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1231
1237
  }
1232
1238
 
1233
- // GET /api/knowledge list all knowledge base entries grouped by category
1234
- if (req.method === 'GET' && req.url === '/api/knowledge') {
1239
+ async function handleKnowledgeList(req, res) {
1235
1240
  const entries = getKnowledgeBaseEntries();
1236
1241
  const result = {};
1237
1242
  for (const cat of shared.KB_CATEGORIES) result[cat] = [];
@@ -1244,11 +1249,9 @@ const server = http.createServer(async (req, res) => {
1244
1249
  return jsonReply(res, 200, result);
1245
1250
  }
1246
1251
 
1247
- // GET /api/knowledge/:category/:file read a specific knowledge base entry
1248
- const kbMatch = req.url.match(/^\/api\/knowledge\/([^/]+)\/([^?]+)/);
1249
- if (kbMatch && req.method === 'GET') {
1250
- const cat = kbMatch[1];
1251
- const file = decodeURIComponent(kbMatch[2]);
1252
+ async function handleKnowledgeRead(req, res, match) {
1253
+ const cat = match[1];
1254
+ const file = decodeURIComponent(match[2]);
1252
1255
  // Prevent path traversal
1253
1256
  if (file.includes('..') || file.includes('/') || file.includes('\\')) {
1254
1257
  return jsonReply(res, 400, { error: 'invalid file name' });
@@ -1261,8 +1264,7 @@ const server = http.createServer(async (req, res) => {
1261
1264
  return;
1262
1265
  }
1263
1266
 
1264
- // POST /api/knowledge/sweep — deduplicate, consolidate, and reorganize knowledge base
1265
- if (req.method === 'POST' && req.url === '/api/knowledge/sweep') {
1267
+ async function handleKnowledgeSweep(req, res) {
1266
1268
  if (global._kbSweepInFlight) return jsonReply(res, 409, { error: 'sweep already in progress' });
1267
1269
  global._kbSweepInFlight = true;
1268
1270
  try {
@@ -1409,8 +1411,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1409
1411
  } catch (e) { return jsonReply(res, 500, { error: e.message }); } finally { global._kbSweepInFlight = false; }
1410
1412
  }
1411
1413
 
1412
- // GET /api/plans — list plan files (.md drafts from plans/ + .json PRDs from prd/)
1413
- if (req.method === 'GET' && req.url === '/api/plans') {
1414
+ async function handlePlansList(req, res) {
1414
1415
  const dirs = [
1415
1416
  { dir: PLANS_DIR, archived: false },
1416
1417
  { dir: path.join(PLANS_DIR, 'archive'), archived: true },
@@ -1481,10 +1482,8 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1481
1482
  return jsonReply(res, 200, plans);
1482
1483
  }
1483
1484
 
1484
- // GET /api/plans/archive/:file — read archived plan (checks prd/archive/ and plans/archive/)
1485
- const archiveFileMatch = req.url.match(/^\/api\/plans\/archive\/([^?]+)$/);
1486
- if (archiveFileMatch && req.method === 'GET') {
1487
- const file = decodeURIComponent(archiveFileMatch[1]);
1485
+ async function handlePlansArchiveRead(req, res, match) {
1486
+ const file = decodeURIComponent(match[1]);
1488
1487
  if (file.includes('..')) return jsonReply(res, 400, { error: 'invalid' });
1489
1488
  // Check prd/archive/ first for .json, then plans/archive/ for .md
1490
1489
  const archiveDir = file.endsWith('.json') ? path.join(PRD_DIR, 'archive') : path.join(PLANS_DIR, 'archive');
@@ -1499,10 +1498,8 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1499
1498
  return;
1500
1499
  }
1501
1500
 
1502
- // GET /api/plans/:file — read full plan (JSON from prd/ or markdown from plans/)
1503
- const planFileMatch = req.url.match(/^\/api\/plans\/([^?]+)$/);
1504
- if (planFileMatch && req.method === 'GET') {
1505
- const file = decodeURIComponent(planFileMatch[1]);
1501
+ async function handlePlansRead(req, res, match) {
1502
+ const file = decodeURIComponent(match[1]);
1506
1503
  if (file.includes('..') || file.includes('/') || file.includes('\\')) return jsonReply(res, 400, { error: 'invalid' });
1507
1504
  let content = safeRead(resolvePlanPath(file));
1508
1505
  // Fallback: check all directories (prd/, plans/, guides/, archives)
@@ -1523,8 +1520,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1523
1520
  return;
1524
1521
  }
1525
1522
 
1526
- // POST /api/plans/approve approve a plan for execution
1527
- if (req.method === 'POST' && req.url === '/api/plans/approve') {
1523
+ async function handlePlansApprove(req, res) {
1528
1524
  try {
1529
1525
  const body = await readBody(req);
1530
1526
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
@@ -1578,8 +1574,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1578
1574
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1579
1575
  }
1580
1576
 
1581
- // POST /api/plans/pause — pause a plan (stops new item materialization + resets active items to pending)
1582
- if (req.method === 'POST' && req.url === '/api/plans/pause') {
1577
+ async function handlePlansPause(req, res) {
1583
1578
  try {
1584
1579
  const body = await readBody(req);
1585
1580
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
@@ -1668,8 +1663,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1668
1663
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1669
1664
  }
1670
1665
 
1671
- // POST /api/prd/regenerate regenerate PRD from revised source plan
1672
- if (req.method === 'POST' && req.url === '/api/prd/regenerate') {
1666
+ async function handlePrdRegenerate(req, res) {
1673
1667
  try {
1674
1668
  const body = await readBody(req);
1675
1669
  if (!body.file) return jsonReply(res, 400, { error: 'file is required' });
@@ -1736,8 +1730,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1736
1730
  } catch (e) { return jsonReply(res, 500, { error: e.message }); }
1737
1731
  }
1738
1732
 
1739
- // POST /api/plans/execute queue plan-to-prd conversion for a .md plan
1740
- if (req.method === 'POST' && req.url === '/api/plans/execute') {
1733
+ async function handlePlansExecute(req, res) {
1741
1734
  try {
1742
1735
  const body = await readBody(req);
1743
1736
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
@@ -1766,8 +1759,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1766
1759
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1767
1760
  }
1768
1761
 
1769
- // POST /api/plans/reject reject a plan
1770
- if (req.method === 'POST' && req.url === '/api/plans/reject') {
1762
+ async function handlePlansReject(req, res) {
1771
1763
  try {
1772
1764
  const body = await readBody(req);
1773
1765
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
@@ -1782,8 +1774,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1782
1774
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1783
1775
  }
1784
1776
 
1785
- // POST /api/plans/regenerate reset pending/failed work items for a plan so they re-materialize
1786
- if (req.method === 'POST' && req.url === '/api/plans/regenerate') {
1777
+ async function handlePlansRegenerate(req, res) {
1787
1778
  try {
1788
1779
  const body = await readBody(req);
1789
1780
  if (!body.source) return jsonReply(res, 400, { error: 'source required' });
@@ -1846,8 +1837,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1846
1837
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1847
1838
  }
1848
1839
 
1849
- // POST /api/plans/delete delete a plan file
1850
- if (req.method === 'POST' && req.url === '/api/plans/delete') {
1840
+ async function handlePlansDelete(req, res) {
1851
1841
  try {
1852
1842
  const body = await readBody(req);
1853
1843
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
@@ -1909,8 +1899,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1909
1899
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1910
1900
  }
1911
1901
 
1912
- // POST /api/plans/revise — request revision with feedback, dispatches agent to revise
1913
- if (req.method === 'POST' && req.url === '/api/plans/revise') {
1902
+ async function handlePlansRevise(req, res) {
1914
1903
  try {
1915
1904
  const body = await readBody(req);
1916
1905
  if (!body.file || !body.feedback) return jsonReply(res, 400, { error: 'file and feedback required' });
@@ -1943,7 +1932,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1943
1932
 
1944
1933
  // POST /api/plans/revise-and-regenerate — REMOVED: plan versioning now handled by /api/doc-chat
1945
1934
  // The "Replace old PRD" flow uses qaReplacePrd (frontend) which calls /api/plans/pause + /api/plans/regenerate + planExecute
1946
- if (false && req.method === 'POST' && req.url === '/api/plans/revise-and-regenerate') {
1935
+ async function handlePlansReviseAndRegenerate(req, res) {
1947
1936
  try {
1948
1937
  const body = await readBody(req);
1949
1938
  if (!body.source || !body.instruction) return jsonReply(res, 400, { error: 'source and instruction required' });
@@ -2083,8 +2072,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
2083
2072
  } catch (e) { return jsonReply(res, 500, { error: e.message }); }
2084
2073
  }
2085
2074
 
2086
- // POST /api/plans/discuss generate a plan discussion session script
2087
- if (req.method === 'POST' && req.url === '/api/plans/discuss') {
2075
+ async function handlePlansDiscuss(req, res) {
2088
2076
  try {
2089
2077
  const body = await readBody(req);
2090
2078
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
@@ -2179,8 +2167,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2179
2167
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2180
2168
  }
2181
2169
 
2182
- // POST /api/doc-chat routes through CC session for minions-aware doc Q&A + editing
2183
- if (req.method === 'POST' && req.url === '/api/doc-chat') {
2170
+ async function handleDocChat(req, res) {
2184
2171
  try {
2185
2172
  const body = await readBody(req);
2186
2173
  if (!body.message) return jsonReply(res, 400, { error: 'message required' });
@@ -2219,8 +2206,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2219
2206
  } catch (e) { return jsonReply(res, 500, { error: e.message }); }
2220
2207
  }
2221
2208
 
2222
- // POST /api/inbox/persist promote an inbox item to team notes
2223
- if (req.method === 'POST' && req.url === '/api/inbox/persist') {
2209
+ async function handleInboxPersist(req, res) {
2224
2210
  try {
2225
2211
  const body = await readBody(req);
2226
2212
  const { name } = body;
@@ -2259,8 +2245,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2259
2245
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2260
2246
  }
2261
2247
 
2262
- // POST /api/inbox/promote-kb promote an inbox item to the knowledge base
2263
- if (req.method === 'POST' && req.url === '/api/inbox/promote-kb') {
2248
+ async function handleInboxPromoteKb(req, res) {
2264
2249
  try {
2265
2250
  const body = await readBody(req);
2266
2251
  const { name, category } = body;
@@ -2297,8 +2282,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2297
2282
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2298
2283
  }
2299
2284
 
2300
- // POST /api/inbox/open open inbox file in Windows explorer
2301
- if (req.method === 'POST' && req.url === '/api/inbox/open') {
2285
+ async function handleInboxOpen(req, res) {
2302
2286
  try {
2303
2287
  const body = await readBody(req);
2304
2288
  const { name } = body;
@@ -2324,8 +2308,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2324
2308
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2325
2309
  }
2326
2310
 
2327
- // POST /api/inbox/delete delete an inbox note
2328
- if (req.method === 'POST' && req.url === '/api/inbox/delete') {
2311
+ async function handleInboxDelete(req, res) {
2329
2312
  try {
2330
2313
  const body = await readBody(req);
2331
2314
  const { name } = body;
@@ -2339,8 +2322,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2339
2322
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2340
2323
  }
2341
2324
 
2342
- // GET /api/skill?file=<name>&source=claude-code|project:<name>&dir=<path>
2343
- if (req.method === 'GET' && req.url.startsWith('/api/skill?')) {
2325
+ async function handleSkillRead(req, res) {
2344
2326
  const params = new URL(req.url, 'http://localhost').searchParams;
2345
2327
  const file = params.get('file');
2346
2328
  const dir = params.get('dir');
@@ -2371,8 +2353,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2371
2353
  return;
2372
2354
  }
2373
2355
 
2374
- // POST /api/projects/browse — open folder picker dialog, return selected path
2375
- if (req.method === 'POST' && req.url === '/api/projects/browse') {
2356
+ async function handleProjectsBrowse(req, res) {
2376
2357
  try {
2377
2358
  const { execSync } = require('child_process');
2378
2359
  let selectedPath = '';
@@ -2390,8 +2371,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2390
2371
  } catch (e) { return jsonReply(res, 500, { error: e.message }); }
2391
2372
  }
2392
2373
 
2393
- // POST /api/projects/add auto-discover and add a project to config
2394
- if (req.method === 'POST' && req.url === '/api/projects/add') {
2374
+ async function handleProjectsAdd(req, res) {
2395
2375
  try {
2396
2376
  const body = await readBody(req);
2397
2377
  if (!body.path) return jsonReply(res, 400, { error: 'path required' });
@@ -2475,18 +2455,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2475
2455
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2476
2456
  }
2477
2457
 
2478
- // ── Command Center: persistent multi-turn session ──────────────────────────
2479
-
2480
- // POST /api/command-center/new-session — clear active CC session
2481
- if (req.method === 'POST' && req.url === '/api/command-center/new-session') {
2458
+ async function handleCommandCenterNewSession(req, res) {
2482
2459
  ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
2483
2460
  ccInFlight = false; // Reset concurrency guard so a stuck request doesn't block new sessions
2484
2461
  safeWrite(path.join(ENGINE_DIR, 'cc-session.json'), ccSession);
2485
2462
  return jsonReply(res, 200, { ok: true });
2486
2463
  }
2487
2464
 
2488
- // POST /api/command-center conversational command center with full minions context
2489
- if (req.method === 'POST' && req.url === '/api/command-center') {
2465
+ async function handleCommandCenter(req, res) {
2490
2466
  try {
2491
2467
  const body = await readBody(req);
2492
2468
  if (!body.message) return jsonReply(res, 400, { error: 'message required' });
@@ -2529,16 +2505,83 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2529
2505
  } catch (e) { ccInFlight = false; return jsonReply(res, 500, { error: e.message }); }
2530
2506
  }
2531
2507
 
2532
- // POST /api/engine/restart force-kill engine and restart immediately
2533
- if (req.method === 'POST' && req.url === '/api/engine/restart') {
2508
+ async function handleSchedulesList(req, res) {
2509
+ reloadConfig();
2510
+ const schedules = CONFIG.schedules || [];
2511
+ const runs = shared.safeJson(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')) || {};
2512
+ const result = schedules.map(s => ({ ...s, _lastRun: runs[s.id] || null }));
2513
+ return jsonReply(res, 200, { schedules: result });
2514
+ }
2515
+
2516
+ async function handleSchedulesCreate(req, res) {
2517
+ const body = await readBody(req);
2518
+ const { id, cron, title, type, project, agent, description, priority, enabled } = body;
2519
+ if (!id || !cron || !title) return jsonReply(res, 400, { error: 'id, cron, and title are required' });
2520
+
2521
+ reloadConfig();
2522
+ if (!CONFIG.schedules) CONFIG.schedules = [];
2523
+ if (CONFIG.schedules.some(s => s.id === id)) return jsonReply(res, 400, { error: 'Schedule ID already exists' });
2524
+
2525
+ const sched = { id, cron, title, type: type || 'implement', enabled: enabled !== false };
2526
+ if (project) sched.project = project;
2527
+ if (agent) sched.agent = agent;
2528
+ if (description) sched.description = description;
2529
+ if (priority) sched.priority = priority;
2530
+
2531
+ CONFIG.schedules.push(sched);
2532
+ safeWrite(path.join(MINIONS_DIR, 'config.json'), CONFIG);
2533
+ invalidateStatusCache();
2534
+ return jsonReply(res, 200, { ok: true, schedule: sched });
2535
+ }
2536
+
2537
+ async function handleSchedulesUpdate(req, res) {
2538
+ const body = await readBody(req);
2539
+ const { id, cron, title, type, project, agent, description, priority, enabled } = body;
2540
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
2541
+
2542
+ reloadConfig();
2543
+ if (!CONFIG.schedules) return jsonReply(res, 404, { error: 'No schedules configured' });
2544
+ const sched = CONFIG.schedules.find(s => s.id === id);
2545
+ if (!sched) return jsonReply(res, 404, { error: 'Schedule not found' });
2546
+
2547
+ if (cron !== undefined) sched.cron = cron;
2548
+ if (title !== undefined) sched.title = title;
2549
+ if (type !== undefined) sched.type = type;
2550
+ if (project !== undefined) sched.project = project || null;
2551
+ if (agent !== undefined) sched.agent = agent || null;
2552
+ if (description !== undefined) sched.description = description;
2553
+ if (priority !== undefined) sched.priority = priority;
2554
+ if (enabled !== undefined) sched.enabled = enabled;
2555
+
2556
+ safeWrite(path.join(MINIONS_DIR, 'config.json'), CONFIG);
2557
+ invalidateStatusCache();
2558
+ return jsonReply(res, 200, { ok: true, schedule: sched });
2559
+ }
2560
+
2561
+ async function handleSchedulesDelete(req, res) {
2562
+ const body = await readBody(req);
2563
+ const { id } = body;
2564
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
2565
+
2566
+ reloadConfig();
2567
+ if (!CONFIG.schedules) return jsonReply(res, 404, { error: 'No schedules configured' });
2568
+ const idx = CONFIG.schedules.findIndex(s => s.id === id);
2569
+ if (idx < 0) return jsonReply(res, 404, { error: 'Schedule not found' });
2570
+
2571
+ CONFIG.schedules.splice(idx, 1);
2572
+ safeWrite(path.join(MINIONS_DIR, 'config.json'), CONFIG);
2573
+ invalidateStatusCache();
2574
+ return jsonReply(res, 200, { ok: true });
2575
+ }
2576
+
2577
+ async function handleEngineRestart(req, res) {
2534
2578
  try {
2535
2579
  const newPid = restartEngine();
2536
2580
  return jsonReply(res, 200, { ok: true, pid: newPid });
2537
2581
  } catch (e) { return jsonReply(res, 500, { error: e.message }); }
2538
2582
  }
2539
2583
 
2540
- // GET /api/settings return current engine + claude + routing config
2541
- if (req.method === 'GET' && req.url === '/api/settings') {
2584
+ async function handleSettingsRead(req, res) {
2542
2585
  try {
2543
2586
  const config = queries.getConfig();
2544
2587
  const routing = safeRead(path.join(MINIONS_DIR, 'routing.md')) || '';
@@ -2551,8 +2594,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2551
2594
  } catch (e) { return jsonReply(res, 500, { error: e.message }); }
2552
2595
  }
2553
2596
 
2554
- // POST /api/settings update engine + claude + agent config
2555
- if (req.method === 'POST' && req.url === '/api/settings') {
2597
+ async function handleSettingsUpdate(req, res) {
2556
2598
  try {
2557
2599
  const body = await readBody(req);
2558
2600
  const configPath = path.join(MINIONS_DIR, 'config.json');
@@ -2590,8 +2632,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2590
2632
  } catch (e) { return jsonReply(res, 500, { error: e.message }); }
2591
2633
  }
2592
2634
 
2593
- // POST /api/settings/routing update routing.md
2594
- if (req.method === 'POST' && req.url === '/api/settings/routing') {
2635
+ async function handleSettingsRouting(req, res) {
2595
2636
  try {
2596
2637
  const body = await readBody(req);
2597
2638
  if (!body.content) return jsonReply(res, 400, { error: 'content required' });
@@ -2600,8 +2641,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2600
2641
  } catch (e) { return jsonReply(res, 500, { error: e.message }); }
2601
2642
  }
2602
2643
 
2603
- // GET /api/health lightweight health check for monitoring
2604
- if (req.method === 'GET' && req.url === '/api/health') {
2644
+ async function handleHealth(req, res) {
2605
2645
  const engine = getEngineState();
2606
2646
  const agents = getAgents();
2607
2647
  const health = {
@@ -2615,12 +2655,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2615
2655
  return jsonReply(res, 200, health);
2616
2656
  }
2617
2657
 
2618
- const agentMatch = req.url.match(/^\/api\/agent\/([\w-]+)$/);
2619
- if (agentMatch) {
2658
+ async function handleAgentDetail(req, res, match) {
2620
2659
  res.setHeader('Content-Type', 'application/json');
2621
2660
  res.setHeader('Access-Control-Allow-Origin', '*');
2622
2661
  try {
2623
- res.end(JSON.stringify(getAgentDetail(agentMatch[1])));
2662
+ res.end(JSON.stringify(getAgentDetail(match[1])));
2624
2663
  } catch (e) {
2625
2664
  res.statusCode = 500;
2626
2665
  res.end(JSON.stringify({ error: e.message }));
@@ -2628,7 +2667,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2628
2667
  return;
2629
2668
  }
2630
2669
 
2631
- if (req.url === '/api/status') {
2670
+ async function handleStatus(req, res) {
2632
2671
  try {
2633
2672
  return jsonReply(res, 200, getStatus(), req);
2634
2673
  } catch (e) {
@@ -2636,7 +2675,123 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2636
2675
  }
2637
2676
  }
2638
2677
 
2639
- // (duplicate /api/health removed — first handler above is the canonical one)
2678
+ // ── Route Registry ──────────────────────────────────────────────────────────
2679
+ // Order matters: specific routes before general ones (e.g., /api/plans/approve before /api/plans/:file)
2680
+
2681
+ const ROUTES = [
2682
+ // Routes endpoint (self-describing API)
2683
+ { method: 'GET', path: '/api/routes', desc: 'List all available API endpoints', handler: (req, res) => {
2684
+ const list = ROUTES.map(r => ({
2685
+ method: r.method,
2686
+ path: typeof r.path === 'string' ? r.path : r.path.toString(),
2687
+ description: r.desc,
2688
+ params: r.params || null
2689
+ }));
2690
+ return jsonReply(res, 200, { routes: list });
2691
+ }},
2692
+
2693
+ // Status & health
2694
+ { method: 'GET', path: '/api/status', desc: 'Full dashboard status snapshot (agents, PRDs, work items, dispatch, etc.)', handler: handleStatus },
2695
+ { method: 'GET', path: '/api/health', desc: 'Lightweight health check for monitoring', handler: handleHealth },
2696
+
2697
+ // Work items
2698
+ { method: 'POST', path: '/api/work-items', desc: 'Create a new work item', params: 'title, type?, description?, priority?, project?, agent?, agents?, scope?', handler: handleWorkItemsCreate },
2699
+ { method: 'POST', path: '/api/work-items/update', desc: 'Edit a pending/failed work item', params: 'id, source?, title?, description?, type?, priority?, agent?', handler: handleWorkItemsUpdate },
2700
+ { method: 'POST', path: '/api/work-items/retry', desc: 'Reset a failed/dispatched item to pending', params: 'id, source?', handler: handleWorkItemsRetry },
2701
+ { method: 'POST', path: '/api/work-items/delete', desc: 'Remove a work item, kill agent, clear dispatch', params: 'id, source?', handler: handleWorkItemsDelete },
2702
+ { method: 'POST', path: '/api/work-items/archive', desc: 'Move a completed/failed work item to archive', params: 'id, source?', handler: handleWorkItemsArchive },
2703
+ { method: 'GET', path: '/api/work-items/archive', desc: 'List archived work items', handler: handleWorkItemsArchiveList },
2704
+
2705
+ // Notes
2706
+ { method: 'POST', path: '/api/notes', desc: 'Write a note to inbox for consolidation', params: 'title, what, why?, author?', handler: handleNotesCreate },
2707
+ { method: 'GET', path: '/api/notes-full', desc: 'Return full notes.md content', handler: handleNotesFull },
2708
+ { method: 'POST', path: '/api/notes-save', desc: 'Save edited notes.md content', params: 'content, file?', handler: handleNotesSave },
2709
+
2710
+ // Plans
2711
+ { method: 'POST', path: '/api/plan', desc: 'Create a plan work item that chains to PRD on completion', params: 'title, description?, priority?, project?, agent?, branch_strategy?', handler: handlePlanCreate },
2712
+ { method: 'GET', path: '/api/plans', desc: 'List plan files (.md drafts + .json PRDs)', handler: handlePlansList },
2713
+ { method: 'POST', path: '/api/plans/trigger-verify', desc: 'Manually trigger verification for a completed plan', params: 'file', handler: handlePlansTriggerVerify },
2714
+ { method: 'POST', path: '/api/plans/approve', desc: 'Approve a plan for execution', params: 'file, approvedBy?', handler: handlePlansApprove },
2715
+ { method: 'POST', path: '/api/plans/pause', desc: 'Pause a plan (stops materialization + resets active items)', params: 'file', handler: handlePlansPause },
2716
+ { method: 'POST', path: '/api/plans/execute', desc: 'Queue plan-to-prd conversion for a .md plan', params: 'file, project?', handler: handlePlansExecute },
2717
+ { method: 'POST', path: '/api/plans/reject', desc: 'Reject a plan', params: 'file, rejectedBy?, reason?', handler: handlePlansReject },
2718
+ { method: 'POST', path: '/api/plans/regenerate', desc: 'Reset pending/failed work items for a plan so they re-materialize', params: 'source', handler: handlePlansRegenerate },
2719
+ { method: 'POST', path: '/api/plans/delete', desc: 'Delete a plan file and clean up work items', params: 'file', handler: handlePlansDelete },
2720
+ { method: 'POST', path: '/api/plans/revise', desc: 'Request revision with feedback, dispatches agent to revise', params: 'file, feedback, requestedBy?', handler: handlePlansRevise },
2721
+ { method: 'POST', path: '/api/plans/discuss', desc: 'Generate a plan discussion session script for Claude CLI', params: 'file', handler: handlePlansDiscuss },
2722
+ { method: 'GET', path: /^\/api\/plans\/archive\/([^?]+)$/, desc: 'Read an archived plan file', handler: handlePlansArchiveRead },
2723
+ { method: 'GET', path: /^\/api\/plans\/([^?]+)$/, desc: 'Read a full plan (JSON from prd/ or markdown from plans/)', handler: handlePlansRead },
2724
+
2725
+ // PRD items
2726
+ { method: 'POST', path: '/api/prd-items', desc: 'Create a PRD item as a plan file in prd/ (auto-approved)', params: 'name, description?, priority?, estimated_complexity?, project?, id?', handler: handlePrdItemsCreate },
2727
+ { method: 'POST', path: '/api/prd-items/update', desc: 'Edit a PRD item in its source plan JSON', params: 'source, itemId, name?, description?, priority?, estimated_complexity?, status?', handler: handlePrdItemsUpdate },
2728
+ { method: 'POST', path: '/api/prd-items/remove', desc: 'Remove a PRD item from plan + cancel materialized work item', params: 'source, itemId', handler: handlePrdItemsRemove },
2729
+ { method: 'POST', path: '/api/prd/regenerate', desc: 'Regenerate PRD from revised source plan', params: 'file', handler: handlePrdRegenerate },
2730
+
2731
+ // Agents
2732
+ { method: 'POST', path: '/api/agents/cancel', desc: 'Cancel an active agent by ID or task substring', params: 'agent?, task?', handler: handleAgentsCancel },
2733
+ { method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live-stream(?:\?.*)?$/, desc: 'SSE real-time live output streaming', handler: handleAgentLiveStream },
2734
+ { method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live(?:\?.*)?$/, desc: 'Tail live output for a working agent', params: 'tail? (bytes, default 8192)', handler: handleAgentLive },
2735
+ { method: 'GET', path: /^\/api\/agent\/([\w-]+)\/output(?:\?.*)?$/, desc: 'Fetch final output.log for an agent', handler: handleAgentOutput },
2736
+ { method: 'GET', path: /^\/api\/agent\/([\w-]+)$/, desc: 'Get detailed agent info', handler: handleAgentDetail },
2737
+
2738
+ // Knowledge base
2739
+ { method: 'GET', path: '/api/knowledge', desc: 'List all knowledge base entries grouped by category', handler: handleKnowledgeList },
2740
+ { method: 'POST', path: '/api/knowledge/sweep', desc: 'Deduplicate, consolidate, and reorganize knowledge base', handler: handleKnowledgeSweep },
2741
+ { method: 'GET', path: /^\/api\/knowledge\/([^/]+)\/([^?]+)/, desc: 'Read a specific knowledge base entry', handler: handleKnowledgeRead },
2742
+
2743
+ // Doc chat
2744
+ { method: 'POST', path: '/api/doc-chat', desc: 'Minions-aware doc Q&A + editing via CC session', params: 'message, document, title?, filePath?, selection?', handler: handleDocChat },
2745
+
2746
+ // Inbox
2747
+ { method: 'POST', path: '/api/inbox/persist', desc: 'Promote an inbox item to team notes', params: 'name', handler: handleInboxPersist },
2748
+ { method: 'POST', path: '/api/inbox/promote-kb', desc: 'Promote an inbox item to the knowledge base', params: 'name, category', handler: handleInboxPromoteKb },
2749
+ { method: 'POST', path: '/api/inbox/open', desc: 'Open inbox file in file manager', params: 'name', handler: handleInboxOpen },
2750
+ { method: 'POST', path: '/api/inbox/delete', desc: 'Delete an inbox note', params: 'name', handler: handleInboxDelete },
2751
+
2752
+ // Skills
2753
+ { method: 'GET', path: '/api/skill', desc: 'Read a skill file', params: 'file, source?, dir?', handler: handleSkillRead },
2754
+
2755
+ // Projects
2756
+ { method: 'POST', path: '/api/projects/browse', desc: 'Open folder picker dialog, return selected path', handler: handleProjectsBrowse },
2757
+ { method: 'POST', path: '/api/projects/add', desc: 'Auto-discover and add a project to config', params: 'path, name?', handler: handleProjectsAdd },
2758
+
2759
+ // Command Center
2760
+ { method: 'POST', path: '/api/command-center/new-session', desc: 'Clear active CC session', handler: handleCommandCenterNewSession },
2761
+ { method: 'POST', path: '/api/command-center', desc: 'Conversational command center with full minions context', params: 'message, sessionId?', handler: handleCommandCenter },
2762
+
2763
+ // Schedules
2764
+ { method: 'GET', path: '/api/schedules', desc: 'Return schedules from config + last-run times', handler: handleSchedulesList },
2765
+ { method: 'POST', path: '/api/schedules', desc: 'Create a new schedule', params: 'id, cron, title, type?, project?, agent?, description?, priority?, enabled?', handler: handleSchedulesCreate },
2766
+ { method: 'POST', path: '/api/schedules/update', desc: 'Update an existing schedule', params: 'id, cron?, title?, type?, project?, agent?, description?, priority?, enabled?', handler: handleSchedulesUpdate },
2767
+ { method: 'POST', path: '/api/schedules/delete', desc: 'Delete a schedule', params: 'id', handler: handleSchedulesDelete },
2768
+
2769
+ // Engine
2770
+ { method: 'POST', path: '/api/engine/restart', desc: 'Force-kill engine and restart immediately', handler: handleEngineRestart },
2771
+
2772
+ // Settings
2773
+ { method: 'GET', path: '/api/settings', desc: 'Return current engine + claude + routing config', handler: handleSettingsRead },
2774
+ { method: 'POST', path: '/api/settings', desc: 'Update engine + claude + agent config', params: 'engine?, claude?, agents?', handler: handleSettingsUpdate },
2775
+ { method: 'POST', path: '/api/settings/routing', desc: 'Update routing.md', params: 'content', handler: handleSettingsRouting },
2776
+ ];
2777
+
2778
+ // ── Route Dispatcher ────────────────────────────────────────────────────────
2779
+
2780
+ const pathname = req.url.split('?')[0];
2781
+ for (const route of ROUTES) {
2782
+ if (route.method !== req.method) continue;
2783
+ if (typeof route.path === 'string') {
2784
+ // For /api/skill, match with query string prefix since it has no fixed path variant
2785
+ if (route.path === '/api/skill') {
2786
+ if (!req.url.startsWith('/api/skill?') && req.url !== '/api/skill') continue;
2787
+ return await route.handler(req, res, {});
2788
+ }
2789
+ if (pathname !== route.path) continue;
2790
+ return await route.handler(req, res, {});
2791
+ }
2792
+ const m = pathname.match(route.path);
2793
+ if (m) return await route.handler(req, res, m);
2794
+ }
2640
2795
 
2641
2796
  // Serve dashboard HTML with gzip + caching
2642
2797
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
@@ -1021,16 +1021,14 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1021
1021
  const { resultSummary, taskUsage } = parseAgentOutput(stdout);
1022
1022
 
1023
1023
  // Handle decomposition results — create sub-items from decompose agent output
1024
+ let skipDoneStatus = false;
1024
1025
  if (type === 'decompose' && isSuccess && meta?.item?.id) {
1025
1026
  const subCount = handleDecompositionResult(stdout, meta, config);
1026
- if (subCount > 0) {
1027
- // Parent is marked 'decomposed' by handler don't overwrite with 'done'
1028
- return { resultSummary: `Decomposed into ${subCount} sub-items`, taskUsage };
1029
- }
1030
- // Fallback: if decomposition produced nothing, mark parent as done to avoid stuck state
1027
+ if (subCount > 0) skipDoneStatus = true; // parent already marked 'decomposed' by handler
1028
+ // If decomposition produced nothing, fall through to mark parent as done
1031
1029
  }
1032
1030
 
1033
- if (isSuccess && meta?.item?.id) updateWorkItemStatus(meta, 'done', '');
1031
+ if (isSuccess && meta?.item?.id && !skipDoneStatus) updateWorkItemStatus(meta, 'done', '');
1034
1032
  if (!isSuccess && meta?.item?.id) {
1035
1033
  // Auto-retry: read fresh _retryCount from file (not stale dispatch-time snapshot)
1036
1034
  let retries = (meta.item._retryCount || 0);
@@ -1055,12 +1053,29 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1055
1053
  if (wiPath) {
1056
1054
  const items = safeJson(wiPath) || [];
1057
1055
  const wi = items.find(i => i.id === meta.item.id);
1058
- if (wi) { wi._retryCount = retries + 1; wi.status = 'pending'; delete wi.dispatched_at; delete wi.dispatched_to; shared.safeWrite(wiPath, items); }
1056
+ if (wi) {
1057
+ wi._retryCount = retries + 1; wi.status = 'pending'; delete wi.dispatched_at; delete wi.dispatched_to;
1058
+ if (type === 'decompose') delete wi._decomposing; // clear so item can retry decomposition
1059
+ shared.safeWrite(wiPath, items);
1060
+ }
1059
1061
  }
1060
1062
  } catch {}
1061
1063
  } else {
1062
1064
  updateWorkItemStatus(meta, 'failed', 'Agent failed (3 retries exhausted)');
1063
1065
  }
1066
+ // Clear _decomposing flag on failure so item doesn't get permanently stuck
1067
+ if (type === 'decompose') {
1068
+ try {
1069
+ const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
1070
+ ? path.join(MINIONS_DIR, 'work-items.json')
1071
+ : meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
1072
+ if (wiPath) {
1073
+ const items = safeJson(wiPath) || [];
1074
+ const wi = items.find(i => i.id === meta.item.id);
1075
+ if (wi) { delete wi._decomposing; shared.safeWrite(wiPath, items); }
1076
+ }
1077
+ } catch {}
1078
+ }
1064
1079
  }
1065
1080
  // Plan chaining removed — user must explicitly execute plan-to-prd after reviewing the plan
1066
1081
  if (isSuccess && meta?.item?.sourcePlan) checkPlanCompletion(meta, config);
@@ -96,16 +96,11 @@ function shouldRunNow(schedule, lastRunAt) {
96
96
  const now = new Date();
97
97
  if (!cron.matches(now)) return false;
98
98
 
99
- // Don't fire again if already ran in this minute window
99
+ // Don't fire again if already ran within the last 55 seconds
100
+ // (uses elapsed time instead of field comparison to handle DST/clock adjustments)
100
101
  if (lastRunAt) {
101
102
  const last = new Date(lastRunAt);
102
- if (last.getFullYear() === now.getFullYear() &&
103
- last.getMonth() === now.getMonth() &&
104
- last.getDate() === now.getDate() &&
105
- last.getHours() === now.getHours() &&
106
- last.getMinutes() === now.getMinutes()) {
107
- return false; // already fired this minute
108
- }
103
+ if (Date.now() - last.getTime() < 55000) return false;
109
104
  }
110
105
 
111
106
  return true;
@@ -120,38 +115,34 @@ function discoverScheduledWork(config) {
120
115
  const schedules = config.schedules;
121
116
  if (!Array.isArray(schedules) || schedules.length === 0) return [];
122
117
 
123
- const runs = safeJson(SCHEDULE_RUNS_PATH) || {};
118
+ // Use file-locked mutation to prevent race conditions on rapid calls
124
119
  const work = [];
125
-
126
- for (const sched of schedules) {
127
- if (!sched.id || !sched.cron || !sched.title) continue;
128
- if (sched.enabled === false) continue;
129
-
130
- const lastRun = runs[sched.id] || null;
131
- if (!shouldRunNow(sched, lastRun)) continue;
132
-
133
- work.push({
134
- id: `sched-${sched.id}-${Date.now()}`,
135
- title: sched.title,
136
- type: sched.type || 'implement',
137
- priority: sched.priority || 'medium',
138
- description: sched.description || sched.title,
139
- status: 'pending',
140
- created: new Date().toISOString(),
141
- createdBy: 'scheduler',
142
- agent: sched.agent || null,
143
- project: sched.project || null,
144
- _scheduleId: sched.id,
145
- });
146
-
147
- // Record run time
148
- runs[sched.id] = new Date().toISOString();
149
- }
150
-
151
- // Persist run times if any schedules fired
152
- if (work.length > 0) {
153
- safeWrite(SCHEDULE_RUNS_PATH, runs);
154
- }
120
+ mutateJsonFileLocked(SCHEDULE_RUNS_PATH, (runs) => {
121
+ for (const sched of schedules) {
122
+ if (!sched.id || !sched.cron || !sched.title) continue;
123
+ if (sched.enabled === false) continue;
124
+
125
+ const lastRun = runs[sched.id] || null;
126
+ if (!shouldRunNow(sched, lastRun)) continue;
127
+
128
+ work.push({
129
+ id: `sched-${sched.id}-${Date.now()}`,
130
+ title: sched.title,
131
+ type: sched.type || 'implement',
132
+ priority: sched.priority || 'medium',
133
+ description: sched.description || sched.title,
134
+ status: 'pending',
135
+ created: new Date().toISOString(),
136
+ createdBy: 'scheduler',
137
+ agent: sched.agent || null,
138
+ project: sched.project || null,
139
+ _scheduleId: sched.id,
140
+ });
141
+
142
+ // Record run time inside the lock
143
+ runs[sched.id] = new Date().toISOString();
144
+ }
145
+ }, { defaultValue: {} });
155
146
 
156
147
  return work;
157
148
  }
package/engine.js CHANGED
@@ -2626,8 +2626,8 @@ function discoverFromWorkItems(config, project) {
2626
2626
  newWork.push({
2627
2627
  type: workType,
2628
2628
  agent: agentId,
2629
- agentName: config.agents[agentId]?.name,
2630
- agentRole: config.agents[agentId]?.role,
2629
+ agentName: config.agents[agentId]?.name || tempAgents.get(agentId)?.name || agentId,
2630
+ agentRole: config.agents[agentId]?.role || tempAgents.get(agentId)?.role || 'Agent',
2631
2631
  task: `[${project?.name || 'project'}] ${item.title || item.description?.slice(0, 80) || item.id}`,
2632
2632
  prompt,
2633
2633
  meta: { dispatchKey: key, source: 'work-item', branch: branchName, branchStrategy: item.branchStrategy || 'parallel', useExistingBranch: !!(item.branchStrategy === 'shared-branch' && item.featureBranch), item, project: { name: project?.name, localPath: project?.localPath } }
@@ -3064,16 +3064,18 @@ function discoverWork(config) {
3064
3064
  try {
3065
3065
  const { discoverScheduledWork } = require('./engine/scheduler');
3066
3066
  const scheduledWork = discoverScheduledWork(config);
3067
- for (const item of scheduledWork) {
3068
- // Write scheduled items to central work-items.json so they persist across ticks
3067
+ if (scheduledWork.length > 0) {
3069
3068
  const centralPath = path.join(MINIONS_DIR, 'work-items.json');
3070
3069
  const items = safeJson(centralPath) || [];
3071
- // Dedupe: don't re-create if same schedule already has a pending/dispatched item
3072
- if (!items.some(i => i._scheduleId === item._scheduleId && i.status !== 'done' && i.status !== 'failed')) {
3073
- items.push(item);
3074
- safeWrite(centralPath, items);
3075
- log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
3070
+ let added = 0;
3071
+ for (const item of scheduledWork) {
3072
+ if (!items.some(i => i._scheduleId === item._scheduleId && i.status !== 'done' && i.status !== 'failed')) {
3073
+ items.push(item);
3074
+ added++;
3075
+ log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
3076
+ }
3076
3077
  }
3078
+ if (added > 0) safeWrite(centralPath, items);
3077
3079
  }
3078
3080
  } catch {}
3079
3081
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
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"