@yemi33/minions 0.1.1579 → 0.1.1581
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 +5 -1
- package/dashboard/js/modal-qa.js +166 -71
- package/dashboard.js +289 -12
- package/package.json +1 -1
- package/playbooks/shared-rules.md +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.1581 (2026-04-28)
|
|
4
4
|
|
|
5
5
|
### Features
|
|
6
|
+
- stream doc chat progress
|
|
6
7
|
- hash-dedup, compress+normalize pass, dynamic stale-guard, rich result
|
|
7
8
|
|
|
9
|
+
### Fixes
|
|
10
|
+
- prohibit grep-filtered Monitor for long builds (#1794) (#1797)
|
|
11
|
+
|
|
8
12
|
### Other
|
|
9
13
|
- Keep CC streams reconnectable
|
|
10
14
|
|
package/dashboard/js/modal-qa.js
CHANGED
|
@@ -191,9 +191,36 @@ function _qaBuildAssistantHtml(text, opts) {
|
|
|
191
191
|
const pad = opts?.isError ? '' : 'padding-right:24px;';
|
|
192
192
|
return '<div class="modal-qa-a" style="' + style + '">' +
|
|
193
193
|
(opts?.isError ? '' : llmCopyBtn()) +
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
body +
|
|
195
|
+
'<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right;' + pad + '">' + opts.elapsed + 's</div>' +
|
|
196
|
+
'</div>';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function _qaBuildLiveProgressHtml(loadingId, label, elapsedSeconds, streamedText, toolsUsed, queueCount) {
|
|
200
|
+
const qaQueueBadge = queueCount > 0 ? ' <span style="font-size:9px;color:var(--muted);background:var(--surface);padding:1px 5px;border-radius:8px;border:1px solid var(--border)">+' + queueCount + ' queued</span>' : '';
|
|
201
|
+
let html = '';
|
|
202
|
+
if (toolsUsed && toolsUsed.length > 0) {
|
|
203
|
+
html += '<div style="margin-bottom:6px">';
|
|
204
|
+
toolsUsed.forEach(function(t) {
|
|
205
|
+
const name = typeof t === 'string' ? t : t.name;
|
|
206
|
+
const input = typeof t === 'string' ? {} : (t.input || {});
|
|
207
|
+
html += '<div style="color:var(--muted);font-size:10px;font-family:monospace"><span style="flex-shrink:0">●</span> ' + formatToolSummary(name, input) + '</div>';
|
|
208
|
+
});
|
|
209
|
+
html += '</div>';
|
|
210
|
+
}
|
|
211
|
+
if (streamedText) html += '<div style="margin-bottom:6px">' + renderMd(streamedText) + '</div>';
|
|
212
|
+
html += '<div style="display:flex;flex-direction:column;align-items:flex-start;gap:6px">' +
|
|
213
|
+
'<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">' +
|
|
214
|
+
'<span class="dot-pulse"><span></span><span></span><span></span></span> ' +
|
|
215
|
+
'<span id="' + loadingId + '-text">' + escHtml(label) + '</span>' +
|
|
216
|
+
'</div>' +
|
|
217
|
+
'<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">' +
|
|
218
|
+
'<span id="' + loadingId + '-time" style="font-size:10px;color:var(--muted)">' + elapsedSeconds + 's</span>' +
|
|
219
|
+
'<button onclick="qaAbort()" style="font-size:9px;padding:2px 8px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--red);cursor:pointer">Stop</button>' +
|
|
220
|
+
qaQueueBadge +
|
|
221
|
+
'</div>' +
|
|
196
222
|
'</div>';
|
|
223
|
+
return html;
|
|
197
224
|
}
|
|
198
225
|
|
|
199
226
|
function _qaMutateThreadHtml(key, mutate) {
|
|
@@ -401,23 +428,49 @@ async function _processQaMessage(message, selection, opts) {
|
|
|
401
428
|
const qaPhases = isPlanEdit
|
|
402
429
|
? [[0,'Reading plan...'],[3000,'Analyzing structure...'],[8000,'Researching context...'],[15000,'Drafting revisions...'],[30000,'Writing updated plan...'],[60000,'Still working (large document)...'],[120000,'Deep edit in progress...'],[300000,'Almost there...']]
|
|
403
430
|
: [[0,'Thinking...'],[3000,'Reading document...'],[8000,'Analyzing...'],[20000,'Still working...'],[60000,'Taking a while...']];
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
textEl.textContent = qaPhases[i][1];
|
|
413
|
-
break;
|
|
414
|
-
}
|
|
431
|
+
let streamedText = '';
|
|
432
|
+
let toolsUsed = [];
|
|
433
|
+
function _qaProgressLabel(elapsed) {
|
|
434
|
+
let label = qaPhases[0][1];
|
|
435
|
+
for (let i = qaPhases.length - 1; i >= 0; i--) {
|
|
436
|
+
if (elapsed >= qaPhases[i][0]) {
|
|
437
|
+
label = qaPhases[i][1];
|
|
438
|
+
break;
|
|
415
439
|
}
|
|
416
440
|
}
|
|
441
|
+
return label;
|
|
442
|
+
}
|
|
443
|
+
function _qaRenderProgress(persist) {
|
|
444
|
+
const elapsed = Date.now() - qaStartTime;
|
|
445
|
+
const updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
|
|
446
|
+
const loadingEl = tmp.querySelector('#' + loadingId);
|
|
447
|
+
if (!loadingEl) return;
|
|
448
|
+
loadingEl.innerHTML = _qaBuildLiveProgressHtml(
|
|
449
|
+
loadingId,
|
|
450
|
+
_qaProgressLabel(elapsed),
|
|
451
|
+
Math.floor(elapsed / 1000),
|
|
452
|
+
streamedText,
|
|
453
|
+
toolsUsed,
|
|
454
|
+
runtime.queue.length
|
|
455
|
+
);
|
|
456
|
+
});
|
|
457
|
+
if (persist) {
|
|
458
|
+
_qaPersistSession(sessionKey, {
|
|
459
|
+
threadHtml: updatedThreadHtml,
|
|
460
|
+
docContext: capturedDocContext,
|
|
461
|
+
filePath: capturedFilePath,
|
|
462
|
+
history: runtime.history,
|
|
463
|
+
queue: runtime.queue,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const qaTimer = setInterval(() => {
|
|
468
|
+
_qaRenderProgress(false);
|
|
417
469
|
}, 500);
|
|
470
|
+
_qaRenderProgress(false);
|
|
418
471
|
|
|
419
472
|
try {
|
|
420
|
-
const res = await fetch('/api/doc-chat', {
|
|
473
|
+
const res = await fetch('/api/doc-chat/stream', {
|
|
421
474
|
method: 'POST',
|
|
422
475
|
headers: { 'Content-Type': 'application/json' },
|
|
423
476
|
signal: abortController.signal,
|
|
@@ -431,76 +484,118 @@ async function _processQaMessage(message, selection, opts) {
|
|
|
431
484
|
contentHash: capturedDocContext.content ? capturedDocContext.content.length + ':' + capturedDocContext.content.charCodeAt(0) + ':' + capturedDocContext.content.charCodeAt(capturedDocContext.content.length - 1) : undefined,
|
|
432
485
|
}),
|
|
433
486
|
});
|
|
434
|
-
const data = await res.json();
|
|
435
|
-
clearInterval(qaTimer);
|
|
436
|
-
const qaElapsed = Math.round((Date.now() - qaStartTime) / 1000);
|
|
437
487
|
let sessionDocContext = { ...capturedDocContext };
|
|
488
|
+
if (!res.ok) {
|
|
489
|
+
const errText = await res.text();
|
|
490
|
+
let errMsg = errText || 'Failed';
|
|
491
|
+
try {
|
|
492
|
+
const parsed = JSON.parse(errText);
|
|
493
|
+
errMsg = parsed.error || errMsg;
|
|
494
|
+
} catch { /* text/plain fallback */ }
|
|
495
|
+
throw new Error(errMsg);
|
|
496
|
+
}
|
|
438
497
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
498
|
+
const reader = res.body.getReader();
|
|
499
|
+
const decoder = new TextDecoder();
|
|
500
|
+
let buf = '';
|
|
501
|
+
let terminalEventSeen = false;
|
|
502
|
+
|
|
503
|
+
async function _qaHandleStreamEvent(evt) {
|
|
504
|
+
if (!evt || !evt.type) return;
|
|
505
|
+
if (evt.type === 'heartbeat') return;
|
|
506
|
+
if (evt.type === 'chunk') {
|
|
507
|
+
streamedText = evt.text || '';
|
|
508
|
+
_qaRenderProgress(true);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (evt.type === 'tool') {
|
|
512
|
+
toolsUsed.push({ name: evt.name, input: evt.input || {} });
|
|
513
|
+
_qaRenderProgress(true);
|
|
514
|
+
return;
|
|
456
515
|
}
|
|
516
|
+
if (evt.type === 'done') {
|
|
517
|
+
terminalEventSeen = true;
|
|
518
|
+
clearInterval(qaTimer);
|
|
519
|
+
const qaElapsed = Math.round((Date.now() - qaStartTime) / 1000);
|
|
520
|
+
const borderColor = evt.edited ? 'var(--green)' : 'var(--blue)';
|
|
521
|
+
const suffix = evt.edited ? '\n\n\u2713 Document saved.' : '';
|
|
522
|
+
const answerHtml = _qaBuildAssistantHtml((evt.text || '') + suffix, { borderColor, elapsed: qaElapsed });
|
|
523
|
+
const updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
|
|
524
|
+
const loadingEl = tmp.querySelector('#' + loadingId);
|
|
525
|
+
if (loadingEl) loadingEl.remove();
|
|
526
|
+
tmp.insertAdjacentHTML('beforeend', answerHtml);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
runtime.history.push({ role: 'user', text: message });
|
|
530
|
+
runtime.history.push({ role: 'assistant', text: evt.text || '' });
|
|
531
|
+
if (_qaIsActiveSession(sessionKey)) _qaHistory = runtime.history.slice();
|
|
532
|
+
|
|
533
|
+
_qaNotifySidebar(capturedFilePath);
|
|
534
|
+
if (evt.actions && evt.actions.length > 0) {
|
|
535
|
+
for (const action of evt.actions) await ccExecuteAction(action);
|
|
536
|
+
}
|
|
457
537
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
538
|
+
if (evt.edited && evt.content) {
|
|
539
|
+
const display = evt.content.replace(/^---[\s\S]*?---\n*/m, '');
|
|
540
|
+
const isJson = capturedFilePath && capturedFilePath.endsWith('.json');
|
|
541
|
+
sessionDocContext.content = display;
|
|
542
|
+
sessionDocContext.selection = '';
|
|
543
|
+
if (_qaIsActiveSession(sessionKey)) {
|
|
544
|
+
const body = document.getElementById('modal-body');
|
|
545
|
+
if (isJson) {
|
|
546
|
+
body.textContent = display;
|
|
547
|
+
} else {
|
|
548
|
+
body.innerHTML = renderMd(display);
|
|
549
|
+
body.style.fontFamily = "'Segoe UI', system-ui, sans-serif";
|
|
550
|
+
body.style.whiteSpace = 'normal';
|
|
551
|
+
}
|
|
552
|
+
_modalDocContext.content = display;
|
|
471
553
|
}
|
|
472
|
-
_modalDocContext.content = display;
|
|
473
554
|
}
|
|
555
|
+
|
|
556
|
+
_qaPersistSession(sessionKey, {
|
|
557
|
+
threadHtml: updatedThreadHtml,
|
|
558
|
+
docContext: sessionDocContext,
|
|
559
|
+
filePath: capturedFilePath,
|
|
560
|
+
history: runtime.history,
|
|
561
|
+
queue: runtime.queue,
|
|
562
|
+
});
|
|
563
|
+
return;
|
|
474
564
|
}
|
|
565
|
+
if (evt.type === 'error') throw new Error(evt.error || 'Failed');
|
|
566
|
+
}
|
|
475
567
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
queue: runtime.queue,
|
|
496
|
-
});
|
|
568
|
+
while (true) {
|
|
569
|
+
const readResult = await reader.read();
|
|
570
|
+
if (readResult.done) break;
|
|
571
|
+
buf += decoder.decode(readResult.value, { stream: true });
|
|
572
|
+
const lines = buf.split('\n');
|
|
573
|
+
buf = lines.pop();
|
|
574
|
+
for (let i = 0; i < lines.length; i++) {
|
|
575
|
+
const line = lines[i];
|
|
576
|
+
if (!line.startsWith('data: ')) continue;
|
|
577
|
+
await _qaHandleStreamEvent(JSON.parse(line.slice(6)));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (buf.trim()) {
|
|
581
|
+
const trailing = buf.split('\n');
|
|
582
|
+
for (let i = 0; i < trailing.length; i++) {
|
|
583
|
+
const line = trailing[i];
|
|
584
|
+
if (!line.startsWith('data: ')) continue;
|
|
585
|
+
await _qaHandleStreamEvent(JSON.parse(line.slice(6)));
|
|
586
|
+
}
|
|
497
587
|
}
|
|
588
|
+
if (!terminalEventSeen) throw new Error('The response stream ended before completion.');
|
|
498
589
|
} catch (e) {
|
|
499
590
|
clearInterval(qaTimer);
|
|
500
591
|
const qaElapsedExc = Math.round((Date.now() - qaStartTime) / 1000);
|
|
501
592
|
const messageHtml = e.name === 'AbortError'
|
|
502
|
-
?
|
|
503
|
-
|
|
593
|
+
? (streamedText
|
|
594
|
+
? _qaBuildAssistantHtml(streamedText + '\n\n_Stopped._', { borderColor: 'var(--muted)', elapsed: qaElapsedExc })
|
|
595
|
+
: _qaBuildAssistantHtml('Stopped', { color: 'var(--muted)', isError: true, elapsed: qaElapsedExc }))
|
|
596
|
+
: (streamedText
|
|
597
|
+
? _qaBuildAssistantHtml(streamedText + '\n\nError: ' + e.message, { borderColor: 'var(--red)', elapsed: qaElapsedExc })
|
|
598
|
+
: _qaBuildAssistantHtml('Error: ' + e.message, { color: 'var(--red)', isError: true, elapsed: qaElapsedExc }));
|
|
504
599
|
const updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
|
|
505
600
|
const loadingEl = tmp.querySelector('#' + loadingId);
|
|
506
601
|
if (loadingEl) loadingEl.remove();
|
package/dashboard.js
CHANGED
|
@@ -1291,12 +1291,111 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
|
|
|
1291
1291
|
return result;
|
|
1292
1292
|
}
|
|
1293
1293
|
|
|
1294
|
+
async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext, label = 'command-center', timeout = 900000, maxTurns, allowedTools = 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch', skipStatePreamble = false, model, onAbortReady, onChunk, onToolUse } = {}) {
|
|
1295
|
+
if (!maxTurns) maxTurns = CONFIG.engine?.ccMaxTurns || shared.ENGINE_DEFAULTS.ccMaxTurns;
|
|
1296
|
+
if (!model) model = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
|
|
1297
|
+
const ccEffort = CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort;
|
|
1298
|
+
const existing = resolveSession(store, sessionKey);
|
|
1299
|
+
let sessionId = existing ? existing.sessionId : null;
|
|
1300
|
+
|
|
1301
|
+
function buildPrompt({ includePreamble = true } = {}) {
|
|
1302
|
+
const parts = (!skipStatePreamble && includePreamble) ? [`## Current Minions State (${new Date().toISOString().slice(0, 16)})\n\n${buildCCStatePreamble()}`] : [];
|
|
1303
|
+
if (extraContext) parts.push(extraContext);
|
|
1304
|
+
parts.push(message);
|
|
1305
|
+
return parts.join('\n\n---\n\n');
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
let result;
|
|
1309
|
+
|
|
1310
|
+
if (sessionId && maxTurns > 1) {
|
|
1311
|
+
const p1 = llm.callLLMStreaming(buildPrompt({ includePreamble: false }), '', {
|
|
1312
|
+
timeout, label, model, maxTurns, allowedTools, sessionId, effort: ccEffort, direct: true,
|
|
1313
|
+
onChunk,
|
|
1314
|
+
onToolUse,
|
|
1315
|
+
});
|
|
1316
|
+
if (onAbortReady) onAbortReady(p1.abort);
|
|
1317
|
+
result = await p1;
|
|
1318
|
+
llm.trackEngineUsage(label, result.usage);
|
|
1319
|
+
|
|
1320
|
+
if (result.text) {
|
|
1321
|
+
updateSession(store, sessionKey, result.sessionId || sessionId, true);
|
|
1322
|
+
return result;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const sessionStillValid = llm.isResumeSessionStillValid(result);
|
|
1326
|
+
if (sessionStillValid) {
|
|
1327
|
+
console.log(`[${label}] Resume call failed (code=${result.code}, empty=${!result.text}) but session is still valid — preserving session for retry`);
|
|
1328
|
+
updateSession(store, sessionKey, result.sessionId || sessionId, true);
|
|
1329
|
+
return result;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
console.log(`[${label}] Resume failed — session appears dead (code=${result.code}, empty=${!result.text}), retrying fresh...`);
|
|
1333
|
+
sessionId = null;
|
|
1334
|
+
if (store === 'cc') {
|
|
1335
|
+
ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
|
|
1336
|
+
safeWrite(path.join(ENGINE_DIR, 'cc-session.json'), ccSession);
|
|
1337
|
+
} else if (sessionKey) {
|
|
1338
|
+
docSessions.delete(sessionKey);
|
|
1339
|
+
schedulePersistDocSessions();
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const freshPrompt = buildPrompt();
|
|
1344
|
+
const p2 = llm.callLLMStreaming(freshPrompt, CC_STATIC_SYSTEM_PROMPT, {
|
|
1345
|
+
timeout, label, model, maxTurns, allowedTools, effort: ccEffort, direct: true,
|
|
1346
|
+
onChunk,
|
|
1347
|
+
onToolUse,
|
|
1348
|
+
});
|
|
1349
|
+
if (onAbortReady) onAbortReady(p2.abort);
|
|
1350
|
+
result = await p2;
|
|
1351
|
+
llm.trackEngineUsage(label, result.usage);
|
|
1352
|
+
|
|
1353
|
+
if (result.text) {
|
|
1354
|
+
updateSession(store, sessionKey, result.sessionId, false);
|
|
1355
|
+
return result;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
if (maxTurns <= 1) return result;
|
|
1359
|
+
console.log(`[${label}] Fresh call also failed (code=${result.code}, empty=${!result.text}), retrying once more...`);
|
|
1360
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
1361
|
+
const p3 = llm.callLLMStreaming(freshPrompt, CC_STATIC_SYSTEM_PROMPT, {
|
|
1362
|
+
timeout, label, model, maxTurns, allowedTools, effort: ccEffort, direct: true,
|
|
1363
|
+
onChunk,
|
|
1364
|
+
onToolUse,
|
|
1365
|
+
});
|
|
1366
|
+
if (onAbortReady) onAbortReady(p3.abort);
|
|
1367
|
+
result = await p3;
|
|
1368
|
+
llm.trackEngineUsage(label, result.usage);
|
|
1369
|
+
|
|
1370
|
+
if (result.text) {
|
|
1371
|
+
updateSession(store, sessionKey, result.sessionId, false);
|
|
1372
|
+
}
|
|
1373
|
+
return result;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1294
1376
|
// Lightweight content fingerprint — same algorithm used browser-side (no crypto needed)
|
|
1295
1377
|
function contentFingerprint(str) {
|
|
1296
1378
|
if (!str) return '';
|
|
1297
1379
|
return str.length + ':' + str.charCodeAt(0) + ':' + str.charCodeAt(str.length - 1);
|
|
1298
1380
|
}
|
|
1299
1381
|
|
|
1382
|
+
function _parseDocChatResultText(text) {
|
|
1383
|
+
const delimIdx = text.indexOf('---DOCUMENT---');
|
|
1384
|
+
if (delimIdx >= 0) {
|
|
1385
|
+
const answerPart = text.slice(0, delimIdx).trim();
|
|
1386
|
+
const { text: answer, actions } = parseCCActions(answerPart);
|
|
1387
|
+
let content = text.slice(delimIdx + '---DOCUMENT---'.length).trim();
|
|
1388
|
+
content = content.replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim();
|
|
1389
|
+
return { answer, content, actions };
|
|
1390
|
+
}
|
|
1391
|
+
const { text: stripped, actions } = parseCCActions(text);
|
|
1392
|
+
return { answer: stripped, content: null, actions };
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function _docChatDisplayText(text) {
|
|
1396
|
+
return _parseDocChatResultText(text).answer;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1300
1399
|
// Doc-specific wrapper — adds document context, parses ---DOCUMENT---
|
|
1301
1400
|
async function ccDocCall({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, onAbortReady }) {
|
|
1302
1401
|
const sessionKey = filePath || title;
|
|
@@ -1349,18 +1448,54 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
|
|
|
1349
1448
|
return { answer: 'Failed to process request. Try again.', content: null, actions: [] };
|
|
1350
1449
|
}
|
|
1351
1450
|
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1451
|
+
return _parseDocChatResultText(result.text);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
async function ccDocCallStreaming({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, onAbortReady, onChunk, onToolUse }) {
|
|
1455
|
+
const sessionKey = filePath || title;
|
|
1456
|
+
const docSlice = document.slice(0, 20000);
|
|
1457
|
+
|
|
1458
|
+
if (freshSession && sessionKey) {
|
|
1459
|
+
docSessions.delete(sessionKey);
|
|
1360
1460
|
}
|
|
1361
1461
|
|
|
1362
|
-
const
|
|
1363
|
-
|
|
1462
|
+
const docHash = require('crypto').createHash('md5').update(docSlice).digest('hex').slice(0, 8);
|
|
1463
|
+
const existing = freshSession ? null : resolveSession('doc', sessionKey);
|
|
1464
|
+
const docUnchanged = existing?.sessionId && existing._docHash === docHash;
|
|
1465
|
+
|
|
1466
|
+
let docContext;
|
|
1467
|
+
if (docUnchanged) {
|
|
1468
|
+
docContext = `## Document: ${title || 'Document'}${filePath ? ' (`' + filePath + '`)' : ''}${selection ? '\n**Selected text:**\n> ' + selection.slice(0, 1500) : ''}${canEdit ? '\nIf editing: respond with your explanation, then \`---DOCUMENT---\` on its own line, then the COMPLETE updated file.' : ''}`;
|
|
1469
|
+
} else {
|
|
1470
|
+
docContext = `## Document Context\n**${title || 'Document'}**${filePath ? ' (`' + filePath + '`)' : ''}${isJson ? ' (JSON)' : ''}\n${selection ? '\n**Selected text:**\n> ' + selection.slice(0, 1500) + '\n' : ''}\n\`\`\`\n${docSlice}\n\`\`\`\n${canEdit ? '\nIf editing: respond with your explanation, then \`---DOCUMENT---\` on its own line, then the COMPLETE updated file.' : '\n(Read-only — answer questions only.)'}`;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
const result = await ccCallStreaming(message, {
|
|
1474
|
+
store: 'doc', sessionKey,
|
|
1475
|
+
extraContext: docContext, label: 'doc-chat',
|
|
1476
|
+
allowedTools: canEdit ? 'Read,Write,Edit,Glob,Grep' : 'Read,Glob,Grep',
|
|
1477
|
+
maxTurns: canEdit ? 25 : 10,
|
|
1478
|
+
skipStatePreamble: true,
|
|
1479
|
+
...(model ? { model } : {}),
|
|
1480
|
+
onAbortReady,
|
|
1481
|
+
onChunk: (text) => { if (onChunk) onChunk(_docChatDisplayText(text)); },
|
|
1482
|
+
onToolUse,
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
if (freshSession && sessionKey) {
|
|
1486
|
+
docSessions.delete(sessionKey);
|
|
1487
|
+
schedulePersistDocSessions();
|
|
1488
|
+
} else if (result.code === 0 && result.sessionId) {
|
|
1489
|
+
const session = resolveSession('doc', sessionKey);
|
|
1490
|
+
if (session) session._docHash = docHash;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
if (result.code !== 0 || !result.text) {
|
|
1494
|
+
console.error(`[doc-chat-stream] Failed: code=${result.code}, empty=${!result.text}, filePath=${filePath}, stderr=${(result.stderr || '').slice(0, 200)}`);
|
|
1495
|
+
return { answer: 'Failed to process request. Try again.', content: null, actions: [] };
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
return _parseDocChatResultText(result.text);
|
|
1364
1499
|
}
|
|
1365
1500
|
|
|
1366
1501
|
// -- POST helpers --
|
|
@@ -3429,7 +3564,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3429
3564
|
docChatInFlight.add(docKey);
|
|
3430
3565
|
// Kill LLM process + release guard if client disconnects (abort/navigation)
|
|
3431
3566
|
let _docAbort = null;
|
|
3432
|
-
|
|
3567
|
+
let _docDone = false;
|
|
3568
|
+
req.on('close', () => { if (!_docDone) { docChatInFlight.delete(docKey); if (_docAbort) _docAbort(); } });
|
|
3433
3569
|
|
|
3434
3570
|
try {
|
|
3435
3571
|
const canEdit = !!body.filePath;
|
|
@@ -3478,13 +3614,152 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3478
3614
|
|
|
3479
3615
|
safeWrite(fullPath, content);
|
|
3480
3616
|
|
|
3617
|
+
_docDone = true;
|
|
3481
3618
|
return jsonReply(res, 200, { ok: true, answer, edited: true, content, actions });
|
|
3482
3619
|
}
|
|
3620
|
+
_docDone = true;
|
|
3483
3621
|
return jsonReply(res, 200, { ok: true, answer: answer + '\n\n(Read-only — changes not saved)', edited: false, actions });
|
|
3484
|
-
} finally { _docAbort = null; docChatInFlight.delete(docKey); }
|
|
3622
|
+
} finally { _docAbort = null; _docDone = true; docChatInFlight.delete(docKey); }
|
|
3485
3623
|
} catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
|
|
3486
3624
|
}
|
|
3487
3625
|
|
|
3626
|
+
async function handleDocChatStream(req, res) {
|
|
3627
|
+
let docKey = null;
|
|
3628
|
+
let _docAbort = null;
|
|
3629
|
+
let _docStreamEnded = false;
|
|
3630
|
+
let _docHeartbeatTimer = null;
|
|
3631
|
+
const writeDocEvent = (payload) => {
|
|
3632
|
+
try {
|
|
3633
|
+
res.write('data: ' + JSON.stringify(payload) + '\n\n');
|
|
3634
|
+
return true;
|
|
3635
|
+
} catch {
|
|
3636
|
+
return false;
|
|
3637
|
+
}
|
|
3638
|
+
};
|
|
3639
|
+
const stopDocHeartbeat = () => {
|
|
3640
|
+
if (_docHeartbeatTimer) {
|
|
3641
|
+
clearInterval(_docHeartbeatTimer);
|
|
3642
|
+
_docHeartbeatTimer = null;
|
|
3643
|
+
}
|
|
3644
|
+
};
|
|
3645
|
+
try {
|
|
3646
|
+
const body = await readBody(req);
|
|
3647
|
+
if (!body.message) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'message required' })); return; }
|
|
3648
|
+
if (!body.document) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'document required' })); return; }
|
|
3649
|
+
|
|
3650
|
+
docKey = body.filePath || body.title || 'default';
|
|
3651
|
+
if (docChatInFlight.has(docKey)) {
|
|
3652
|
+
res.statusCode = 429;
|
|
3653
|
+
res.setHeader('Content-Type', 'application/json');
|
|
3654
|
+
res.end(JSON.stringify({ error: 'This document is already being processed — wait for the current response.' }));
|
|
3655
|
+
return;
|
|
3656
|
+
}
|
|
3657
|
+
docChatInFlight.add(docKey);
|
|
3658
|
+
|
|
3659
|
+
const canEdit = !!body.filePath;
|
|
3660
|
+
const isJson = body.filePath?.endsWith('.json');
|
|
3661
|
+
let currentContent = body.document;
|
|
3662
|
+
let fullPath = null;
|
|
3663
|
+
if (canEdit) {
|
|
3664
|
+
try { shared.sanitizePath(body.filePath, MINIONS_DIR); }
|
|
3665
|
+
catch { docChatInFlight.delete(docKey); res.statusCode = 400; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'path must be under minions directory' })); return; }
|
|
3666
|
+
fullPath = path.resolve(MINIONS_DIR, body.filePath);
|
|
3667
|
+
const diskContent = safeRead(fullPath);
|
|
3668
|
+
if (diskContent !== null) {
|
|
3669
|
+
if (!(body.contentHash && contentFingerprint(diskContent) === body.contentHash)) {
|
|
3670
|
+
currentContent = diskContent;
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
|
|
3675
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
|
|
3676
|
+
writeDocEvent({ type: 'heartbeat' });
|
|
3677
|
+
_docHeartbeatTimer = setInterval(() => {
|
|
3678
|
+
if (_docStreamEnded) {
|
|
3679
|
+
stopDocHeartbeat();
|
|
3680
|
+
return;
|
|
3681
|
+
}
|
|
3682
|
+
if (!writeDocEvent({ type: 'heartbeat' })) stopDocHeartbeat();
|
|
3683
|
+
}, CC_STREAM_HEARTBEAT_MS);
|
|
3684
|
+
|
|
3685
|
+
req.on('close', () => {
|
|
3686
|
+
if (!_docStreamEnded) {
|
|
3687
|
+
stopDocHeartbeat();
|
|
3688
|
+
docChatInFlight.delete(docKey);
|
|
3689
|
+
if (_docAbort) _docAbort();
|
|
3690
|
+
}
|
|
3691
|
+
});
|
|
3692
|
+
|
|
3693
|
+
try {
|
|
3694
|
+
|
|
3695
|
+
const { answer, content, actions } = await ccDocCallStreaming({
|
|
3696
|
+
message: body.message, document: currentContent, title: body.title,
|
|
3697
|
+
filePath: body.filePath, selection: body.selection, canEdit, isJson,
|
|
3698
|
+
model: body.model || undefined,
|
|
3699
|
+
freshSession: !!body.freshSession,
|
|
3700
|
+
onAbortReady: (abort) => { _docAbort = abort; },
|
|
3701
|
+
onChunk: (text) => { writeDocEvent({ type: 'chunk', text }); },
|
|
3702
|
+
onToolUse: (name, input) => { writeDocEvent({ type: 'tool', name, input: _lightToolInput(input) }); },
|
|
3703
|
+
});
|
|
3704
|
+
|
|
3705
|
+
if (!content) {
|
|
3706
|
+
writeDocEvent({ type: 'done', text: answer, edited: false, actions });
|
|
3707
|
+
_docStreamEnded = true;
|
|
3708
|
+
res.end();
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
|
|
3712
|
+
if (isJson) {
|
|
3713
|
+
try { JSON.parse(content); } catch (e) {
|
|
3714
|
+
writeDocEvent({ type: 'done', text: answer + '\n\n(JSON invalid — not saved: ' + e.message + ')', edited: false, actions });
|
|
3715
|
+
_docStreamEnded = true;
|
|
3716
|
+
res.end();
|
|
3717
|
+
return;
|
|
3718
|
+
}
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
if (canEdit && fullPath) {
|
|
3722
|
+
if (body.filePath && /^meetings\//.test(body.filePath) && isJson) {
|
|
3723
|
+
try {
|
|
3724
|
+
const mtg = safeJson(fullPath);
|
|
3725
|
+
if (mtg && (mtg.status === 'completed' || mtg.status === 'archived')) {
|
|
3726
|
+
writeDocEvent({ type: 'done', text: answer, edited: false, actions });
|
|
3727
|
+
_docStreamEnded = true;
|
|
3728
|
+
res.end();
|
|
3729
|
+
return;
|
|
3730
|
+
}
|
|
3731
|
+
} catch { /* proceed with write if can't read */ }
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3734
|
+
safeWrite(fullPath, content);
|
|
3735
|
+
writeDocEvent({ type: 'done', text: answer, edited: true, content, actions });
|
|
3736
|
+
_docStreamEnded = true;
|
|
3737
|
+
res.end();
|
|
3738
|
+
return;
|
|
3739
|
+
}
|
|
3740
|
+
|
|
3741
|
+
writeDocEvent({ type: 'done', text: answer + '\n\n(Read-only — changes not saved)', edited: false, actions });
|
|
3742
|
+
_docStreamEnded = true;
|
|
3743
|
+
res.end();
|
|
3744
|
+
} finally {
|
|
3745
|
+
stopDocHeartbeat();
|
|
3746
|
+
docChatInFlight.delete(docKey);
|
|
3747
|
+
}
|
|
3748
|
+
} catch (e) {
|
|
3749
|
+
stopDocHeartbeat();
|
|
3750
|
+
if (docKey) docChatInFlight.delete(docKey);
|
|
3751
|
+
if (!res.headersSent) {
|
|
3752
|
+
res.statusCode = e.statusCode || 500;
|
|
3753
|
+
res.setHeader('Content-Type', 'application/json');
|
|
3754
|
+
try { res.end(JSON.stringify({ error: e.message })); } catch {}
|
|
3755
|
+
} else {
|
|
3756
|
+
writeDocEvent({ type: 'error', error: e.message });
|
|
3757
|
+
_docStreamEnded = true;
|
|
3758
|
+
try { res.end(); } catch {}
|
|
3759
|
+
}
|
|
3760
|
+
}
|
|
3761
|
+
}
|
|
3762
|
+
|
|
3488
3763
|
async function handleInboxPersist(req, res) {
|
|
3489
3764
|
try {
|
|
3490
3765
|
const body = await readBody(req);
|
|
@@ -4206,6 +4481,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
4206
4481
|
}
|
|
4207
4482
|
if (!writeCcEvent({ type: 'heartbeat' })) stopCcHeartbeat();
|
|
4208
4483
|
}, CC_STREAM_HEARTBEAT_MS);
|
|
4484
|
+
// Kill LLM process immediately if client disconnects mid-stream.
|
|
4209
4485
|
// Keep the LLM alive briefly after disconnect so the UI can reattach to the same in-flight turn.
|
|
4210
4486
|
req.on('close', () => {
|
|
4211
4487
|
if (!_ccStreamEnded) {
|
|
@@ -5229,6 +5505,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
5229
5505
|
|
|
5230
5506
|
// Doc chat
|
|
5231
5507
|
{ method: 'POST', path: '/api/doc-chat', desc: 'Minions-aware doc Q&A + editing via CC session', params: 'message, document, title?, filePath?, selection?, contentHash?', handler: handleDocChat },
|
|
5508
|
+
{ method: 'POST', path: '/api/doc-chat/stream', desc: 'Streaming doc chat — SSE with text chunks and tool progress', params: 'message, document, title?, filePath?, selection?, contentHash?', handler: handleDocChatStream },
|
|
5232
5509
|
|
|
5233
5510
|
// Inbox
|
|
5234
5511
|
{ method: 'POST', path: '/api/inbox/persist', desc: 'Promote an inbox item to team notes', params: 'name', handler: handleInboxPersist },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1581",
|
|
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"
|
|
@@ -64,6 +64,8 @@ The engine kills agents that produce no stdout for `heartbeatTimeout` (default *
|
|
|
64
64
|
|
|
65
65
|
Why: each line that the build emits arrives as a notification, which resets the heartbeat. You see live progress in the dashboard. The Monitor call itself is recognised by the engine as a blocking tool (heartbeat extended ~30 min).
|
|
66
66
|
|
|
67
|
+
> ⚠️ **Never use `Monitor({ command: "tail -F <file> | grep ..." })` for long builds.** It looks tidy — only the lines you care about — but it is a heartbeat trap. Cold Gradle / MSBuild / `cargo build` spend 3–8 minutes in a startup + dependency-resolution phase that produces output that **does not match** typical filter terms (`BUILD SUCCESSFUL`, `BUILD FAILED`, `error:`). The grep filter swallows every line, Monitor emits zero notifications, the heartbeat fires at 300s, and the engine kills the agent mid-build. Always pass `bash_id` directly — every output line resets the heartbeat, and noisy output is the *whole point* of the pattern.
|
|
68
|
+
|
|
67
69
|
### Pattern B — Single Bash call with explicit `timeout`
|
|
68
70
|
|
|
69
71
|
```
|
|
@@ -75,6 +77,7 @@ The engine reads `input.timeout` from the tool call and extends the heartbeat to
|
|
|
75
77
|
### What NOT to do
|
|
76
78
|
|
|
77
79
|
- Do NOT run `./gradlew`, `mvn`, `dotnet test`, or any cold-cache build as a default `Bash` call (no `timeout`, no `run_in_background`). It will hit the 120s Bash default, then the 300s heartbeat, and the engine will kill you.
|
|
80
|
+
- Do NOT use `Monitor({ command: "tail | grep ..." })` for any build that has a silent startup phase (cold Gradle, MSBuild, fresh `npm install`, `cargo build`). The grep filter suppresses Gradle's startup output, Monitor emits nothing, heartbeat fires at 300s, agent is killed. Use `Monitor({ bash_id })` instead — noisy output is better than a dead agent.
|
|
78
81
|
- Do NOT loop `sleep` to "wait it out" — sleep produces no stdout and looks identical to a hang.
|
|
79
82
|
- Do NOT pipe through `tee` thinking that helps — heartbeat reads agent stdout, not the underlying file.
|
|
80
83
|
|