@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 +14 -0
- package/dashboard.html +166 -0
- package/dashboard.js +274 -119
- package/engine/lifecycle.js +22 -7
- package/engine/scheduler.js +30 -39
- package/engine.js +11 -9
- package/package.json +1 -1
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 ? '⏸' : '▶') + '</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">✎</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">✕</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
|
-
//
|
|
613
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
const
|
|
1121
|
-
|
|
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
|
-
|
|
1181
|
-
|
|
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
|
-
|
|
1200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1485
|
-
|
|
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
|
-
|
|
1503
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2533
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
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');
|
package/engine/lifecycle.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
|
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);
|
package/engine/scheduler.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
-
|
|
118
|
+
// Use file-locked mutation to prevent race conditions on rapid calls
|
|
124
119
|
const work = [];
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3072
|
-
|
|
3073
|
-
items.
|
|
3074
|
-
|
|
3075
|
-
|
|
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