@yemi33/minions 0.1.1578 → 0.1.1580

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,10 +1,14 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1578 (2026-04-28)
3
+ ## 0.1.1580 (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
+ ### Other
10
+ - Keep CC streams reconnectable
11
+
8
12
  ## 0.1.1577 (2026-04-27)
9
13
 
10
14
  ### Fixes
@@ -94,6 +94,13 @@ function _ccFindPinTarget(query) {
94
94
  function ccAbort() {
95
95
  var tab = _ccActiveTab();
96
96
  if (tab && tab._abortController) {
97
+ try {
98
+ fetch('/api/command-center/abort', {
99
+ method: 'POST',
100
+ headers: { 'Content-Type': 'application/json' },
101
+ body: JSON.stringify({ tabId: tab.id })
102
+ }).catch(function() {});
103
+ } catch {}
97
104
  tab._abortController.abort();
98
105
  tab._abortController = null;
99
106
  }
@@ -211,6 +218,13 @@ function ccCloseTab(id) {
211
218
  var closingTab = _ccTabs.find(function(t) { return t.id === id; });
212
219
  if (closingTab && closingTab._sending) {
213
220
  if (!confirm('This tab has an active request. Close anyway?')) return;
221
+ try {
222
+ fetch('/api/command-center/abort', {
223
+ method: 'POST',
224
+ headers: { 'Content-Type': 'application/json' },
225
+ body: JSON.stringify({ tabId: id })
226
+ }).catch(function() {});
227
+ } catch {}
214
228
  if (closingTab._abortController) { closingTab._abortController.abort(); closingTab._abortController = null; }
215
229
  closingTab._sending = false;
216
230
  closingTab._queue = [];
@@ -445,6 +459,8 @@ async function _ccDoSend(message, skipUserMsg, forceTabId) {
445
459
  if (existingQueueEl) existingQueueEl.remove();
446
460
 
447
461
  var ccStartTime = Date.now();
462
+ var reconnectAttempts = 0;
463
+ var streamStatusNote = '';
448
464
  var phases = [
449
465
  [0, 'Thinking...'],
450
466
  [3000, 'Reading minions context...'],
@@ -511,6 +527,9 @@ async function _ccDoSend(message, skipUserMsg, forceTabId) {
511
527
  if (streamedText) {
512
528
  html += renderMd(streamedText);
513
529
  }
530
+ if (streamStatusNote) {
531
+ html += '<div style="margin-top:6px;font-size:10px;color:var(--muted)">' + escHtml(streamStatusNote) + '</div>';
532
+ }
514
533
  html += '<div style="margin-top:' + (streamedText ? '6px' : '0') + '">' + _getThinkingHtml() + '</div>';
515
534
  streamDiv.innerHTML = html;
516
535
  // Re-append queue indicators so they stay below the streaming content
@@ -531,35 +550,70 @@ async function _ccDoSend(message, skipUserMsg, forceTabId) {
531
550
  var phaseTimer = setInterval(updateStreamDiv, 1000);
532
551
  updateStreamDiv(); // render proper layout immediately (not raw "Thinking..." text)
533
552
 
534
- try {
535
- // Stream response via SSE — shows text as it arrives
553
+ async function _ccConsumeStream(requestBody, isReconnect) {
536
554
  var res = await fetch('/api/command-center/stream', {
537
555
  method: 'POST', headers: { 'Content-Type': 'application/json' },
538
- body: JSON.stringify({ message: message, tabId: activeTabId, sessionId: activeTab.sessionId || null }),
556
+ body: JSON.stringify(requestBody),
539
557
  signal: activeTab._abortController ? activeTab._abortController.signal : AbortSignal.timeout(960000)
540
558
  });
541
559
 
542
560
  if (!res.ok) {
543
- // 429 = server still releasing previous request (abort race) retry silently up to 3 times
544
- if (res.status === 429 && (!activeTab._429retries || activeTab._429retries < 3)) {
561
+ if (!isReconnect && res.status === 429 && (!activeTab._429retries || activeTab._429retries < 3)) {
545
562
  activeTab._429retries = (activeTab._429retries || 0) + 1;
546
- _cleanupStreamDiv(); // remove current thinking div — prevents stacking on each retry
547
563
  await new Promise(function(r) { setTimeout(r, 1500); });
548
- return await _ccDoSend(message, true, forceTabId || activeTabId); // retry pass tabId so timer closures don't fight
564
+ return await _ccConsumeStream({ message: message, tabId: activeTabId, sessionId: activeTab.sessionId || null }, false);
549
565
  }
550
566
  activeTab._429retries = 0;
551
- _cleanupStreamDiv();
552
567
  var errText = await res.text();
553
- addMsg('assistant', '<span style="color:var(--red)">' + escHtml(errText || 'CC error') + '</span>' +
554
- (errText.includes('busy') ? ' <button onclick="ccNewTab()" style="margin-top:4px;padding:3px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--blue);cursor:pointer;font-size:10px">Reset CC</button>' : ''));
555
- return;
568
+ if (isReconnect && res.status === 409) return { interrupted: true, reconnectable: false, reason: errText || 'No live stream' };
569
+ throw new Error(errText || 'CC error');
556
570
  }
557
571
 
572
+ activeTab._429retries = 0;
573
+ streamStatusNote = '';
574
+ updateStreamDiv();
575
+
558
576
  var reader = res.body.getReader();
559
577
  var decoder = new TextDecoder();
560
578
  var buf = '';
561
579
  var terminalEventSeen = false;
562
580
 
581
+ async function _handleEvent(evt) {
582
+ if (evt.type === 'chunk') {
583
+ streamedText = evt.text;
584
+ if (activeTab) activeTab._streamedText = streamedText;
585
+ updateStreamDiv();
586
+ } else if (evt.type === 'heartbeat') {
587
+ return;
588
+ } else if (evt.type === 'tool') {
589
+ toolsUsed.push({ name: evt.name, input: evt.input || {} });
590
+ if (activeTab) activeTab._toolsUsed = toolsUsed.slice();
591
+ updateStreamDiv();
592
+ if (msgs.scrollHeight - msgs.scrollTop - msgs.clientHeight < 150) msgs.scrollTop = msgs.scrollHeight;
593
+ } else if (evt.type === 'done') {
594
+ terminalEventSeen = true;
595
+ _cleanupStreamDiv();
596
+ if (evt.sessionReset) {
597
+ addMsg('system', '<div style="text-align:center;padding:6px 12px;font-size:11px;color:var(--muted);background:var(--surface2);border-radius:6px;margin:4px 0">Minions was updated — started a fresh session with latest context.</div>', false, activeTabId);
598
+ }
599
+ var rendered = renderMd(evt.text || streamedText || '');
600
+ addMsg('assistant', rendered + _ccElapsedFooter('{seconds}s'));
601
+ if (evt.sessionId !== undefined) {
602
+ var originTab = _ccTabs.find(function(t) { return t.id === activeTabId; });
603
+ if (originTab) { originTab.sessionId = evt.sessionId || null; }
604
+ ccSaveState(); ccUpdateSessionIndicator();
605
+ }
606
+ if (evt.actions && evt.actions.length > 0) {
607
+ _tagServerExecuted(evt.actions, evt.actionResults);
608
+ for (var ai = 0; ai < evt.actions.length; ai++) { await ccExecuteAction(evt.actions[ai], activeTabId); }
609
+ }
610
+ } else if (evt.type === 'error') {
611
+ terminalEventSeen = true;
612
+ _cleanupStreamDiv();
613
+ addMsg('assistant', '<span style="color:var(--red)">' + escHtml(evt.error) + '</span>');
614
+ }
615
+ }
616
+
563
617
  while (true) {
564
618
  var readResult = await reader.read();
565
619
  if (readResult.done) break;
@@ -569,93 +623,55 @@ async function _ccDoSend(message, skipUserMsg, forceTabId) {
569
623
  for (var li = 0; li < lines.length; li++) {
570
624
  var line = lines[li];
571
625
  if (!line.startsWith('data: ')) continue;
572
- try {
573
- var evt = JSON.parse(line.slice(6));
574
- if (evt.type === 'chunk') {
575
- streamedText = evt.text;
576
- if (activeTab) activeTab._streamedText = streamedText;
577
- updateStreamDiv();
578
- } else if (evt.type === 'heartbeat') {
579
- continue;
580
- } else if (evt.type === 'tool') {
581
- toolsUsed.push({ name: evt.name, input: evt.input || {} });
582
- if (activeTab) activeTab._toolsUsed = toolsUsed.slice();
583
- updateStreamDiv();
584
- if (msgs.scrollHeight - msgs.scrollTop - msgs.clientHeight < 150) msgs.scrollTop = msgs.scrollHeight;
585
- } else if (evt.type === 'done') {
586
- terminalEventSeen = true;
587
- _cleanupStreamDiv();
588
- // If system prompt changed, show a notice before the response
589
- if (evt.sessionReset) {
590
- addMsg('system', '<div style="text-align:center;padding:6px 12px;font-size:11px;color:var(--muted);background:var(--surface2);border-radius:6px;margin:4px 0">Minions was updated — started a fresh session with latest context.</div>', false, activeTabId);
591
- }
592
- // placeholder was added with skipSave=true — nothing to pop
593
- var rendered = renderMd(evt.text || streamedText || '');
594
- addMsg('assistant', rendered + _ccElapsedFooter('{seconds}s'));
595
- if (evt.sessionId !== undefined) {
596
- // Save session to the originating tab, not whichever tab is active now
597
- var originTab = _ccTabs.find(function(t) { return t.id === activeTabId; });
598
- if (originTab) { originTab.sessionId = evt.sessionId || null; }
599
- ccSaveState(); ccUpdateSessionIndicator();
600
- }
601
- if (evt.actions && evt.actions.length > 0) {
602
- _tagServerExecuted(evt.actions, evt.actionResults);
603
- for (var ai = 0; ai < evt.actions.length; ai++) { await ccExecuteAction(evt.actions[ai], activeTabId); }
604
- }
605
- } else if (evt.type === 'error') {
606
- terminalEventSeen = true;
607
- _cleanupStreamDiv();
608
- // placeholder was skipSave — no pop needed
609
- addMsg('assistant', '<span style="color:var(--red)">' + escHtml(evt.error) + '</span>');
610
- }
611
- } catch { /* incomplete JSON */ }
626
+ try { await _handleEvent(JSON.parse(line.slice(6))); } catch {}
612
627
  }
613
628
  }
614
- // Process any remaining buffered data after stream ends
615
629
  if (buf.trim()) {
616
630
  var remainingLines = buf.split('\n');
617
631
  for (var ri = 0; ri < remainingLines.length; ri++) {
618
632
  var rline = remainingLines[ri];
619
633
  if (!rline.startsWith('data: ')) continue;
620
- try {
621
- var revt = JSON.parse(rline.slice(6));
622
- if (revt.type === 'done') {
623
- terminalEventSeen = true;
624
- _cleanupStreamDiv();
625
- // placeholder was skipSave — no pop needed
626
- var rendered2 = renderMd(revt.text || streamedText || '');
627
- addMsg('assistant', rendered2 + _ccElapsedFooter('{seconds}s'));
628
- if (revt.sessionId !== undefined) {
629
- var originTab2 = _ccTabs.find(function(t) { return t.id === activeTabId; });
630
- if (originTab2) { originTab2.sessionId = revt.sessionId || null; }
631
- ccSaveState(); ccUpdateSessionIndicator();
632
- }
633
- if (revt.actions && revt.actions.length > 0) {
634
- _tagServerExecuted(revt.actions, revt.actionResults);
635
- for (var ai2 = 0; ai2 < revt.actions.length; ai2++) { await ccExecuteAction(revt.actions[ai2], activeTabId); }
636
- }
637
- } else if (revt.type === 'heartbeat') {
638
- continue;
639
- } else if (revt.type === 'error') {
640
- terminalEventSeen = true;
641
- _cleanupStreamDiv();
642
- addMsg('assistant', '<span style="color:var(--red)">' + escHtml(revt.error) + '</span>');
643
- } else if (revt.type === 'chunk') {
644
- streamedText = revt.text;
645
- updateStreamDiv();
646
- }
647
- } catch {}
634
+ try { await _handleEvent(JSON.parse(rline.slice(6))); } catch {}
648
635
  }
649
636
  }
650
- // If stream ended without a 'done' event, finalize with whatever we have
651
- if (!terminalEventSeen && (streamDiv.parentNode || document.getElementById('cc-restore-thinking') || document.querySelector('[data-stream-tab="' + activeTabId + '"]'))) {
652
- _cleanupStreamDiv();
653
- var streamEndedHint = '<div style="font-size:10px;color:var(--muted);margin-top:4px">The response stream ended before completion. Retry to continue from the last user message.</div>';
654
- if (streamedText) {
655
- addMsg('assistant', renderMd(streamedText) + _ccElapsedFooter('Stream interrupted after {seconds}s') + _ccRetryControls(streamEndedHint, false));
656
- } else {
657
- addMsg('assistant', '<span style="color:var(--red)">The response stream ended before completion.</span>' + _ccRetryControls(streamEndedHint, false));
637
+ return { interrupted: !terminalEventSeen, reconnectable: true };
638
+ }
639
+
640
+ try {
641
+ while (true) {
642
+ var consume = await _ccConsumeStream(
643
+ reconnectAttempts === 0
644
+ ? { message: message, tabId: activeTabId, sessionId: activeTab.sessionId || null }
645
+ : { tabId: activeTabId, sessionId: activeTab.sessionId || null, reconnect: true },
646
+ reconnectAttempts > 0
647
+ );
648
+ if (!consume.interrupted) break;
649
+ if (!consume.reconnectable || reconnectAttempts >= 2) {
650
+ _cleanupStreamDiv();
651
+ var streamEndedHint = '<div style="font-size:10px;color:var(--muted);margin-top:4px">The response stream ended before completion. Retry to continue from the last user message.</div>';
652
+ if (streamedText) {
653
+ addMsg('assistant', renderMd(streamedText) + _ccElapsedFooter('Stream interrupted after {seconds}s') + _ccRetryControls(streamEndedHint, false));
654
+ } else {
655
+ addMsg('assistant', '<span style="color:var(--red)">The response stream ended before completion.</span>' + _ccRetryControls(streamEndedHint, false));
656
+ }
657
+ break;
658
+ }
659
+ var reconnectHealth = await _ccDashboardHealth();
660
+ if (!reconnectHealth.reachable || reconnectHealth.restarted) {
661
+ _cleanupStreamDiv();
662
+ var reconnectHint = reconnectHealth.restarted
663
+ ? '<div style="font-size:10px;color:var(--muted);margin-top:4px">Dashboard restarted while this response was streaming. Reload the page to reconnect to the new instance.</div>'
664
+ : '<div style="font-size:10px;color:var(--muted);margin-top:4px">The request stream was interrupted, but the dashboard is still reachable. Retry or start a new session.</div>';
665
+ addMsg('assistant', (streamedText ? renderMd(streamedText) + _ccElapsedFooter('Stream interrupted after {seconds}s') : '') +
666
+ _ccRetryControls(reconnectHint, reconnectHealth.restarted));
667
+ break;
658
668
  }
669
+ reconnectAttempts++;
670
+ toolsUsed = [];
671
+ if (activeTab) activeTab._toolsUsed = [];
672
+ streamStatusNote = 'Connection interrupted — reattaching to the live response...';
673
+ updateStreamDiv();
674
+ await new Promise(function(r) { setTimeout(r, 1000 * reconnectAttempts); });
659
675
  }
660
676
  } catch (e) {
661
677
  _cleanupStreamDiv();
@@ -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
- body +
195
- '<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right;' + pad + '">' + opts.elapsed + 's</div>' +
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">&#9679;</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
- const qaTimer = setInterval(() => {
405
- const elapsed = Date.now() - qaStartTime;
406
- const timeEl = _qaIsActiveSession(sessionKey) ? document.getElementById(loadingId + '-time') : null;
407
- const textEl = _qaIsActiveSession(sessionKey) ? document.getElementById(loadingId + '-text') : null;
408
- if (timeEl) timeEl.textContent = Math.floor(elapsed / 1000) + 's';
409
- if (textEl) {
410
- for (let i = qaPhases.length - 1; i >= 0; i--) {
411
- if (elapsed >= qaPhases[i][0]) {
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
- if (data.ok) {
440
- const borderColor = data.edited ? 'var(--green)' : 'var(--blue)';
441
- const suffix = data.edited ? '\n\n\u2713 Document saved.' : '';
442
- const answerHtml = _qaBuildAssistantHtml(data.answer + suffix, { borderColor, elapsed: qaElapsed });
443
- const updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
444
- const loadingEl = tmp.querySelector('#' + loadingId);
445
- if (loadingEl) loadingEl.remove();
446
- tmp.insertAdjacentHTML('beforeend', answerHtml);
447
- });
448
-
449
- runtime.history.push({ role: 'user', text: message });
450
- runtime.history.push({ role: 'assistant', text: data.answer });
451
- if (_qaIsActiveSession(sessionKey)) _qaHistory = runtime.history.slice();
452
-
453
- _qaNotifySidebar(capturedFilePath);
454
- if (data.actions && data.actions.length > 0) {
455
- for (const action of data.actions) await ccExecuteAction(action);
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
- if (data.edited && data.content) {
459
- const display = data.content.replace(/^---[\s\S]*?---\n*/m, '');
460
- const isJson = capturedFilePath && capturedFilePath.endsWith('.json');
461
- sessionDocContext.content = display;
462
- sessionDocContext.selection = '';
463
- if (_qaIsActiveSession(sessionKey)) {
464
- const body = document.getElementById('modal-body');
465
- if (isJson) {
466
- body.textContent = display;
467
- } else {
468
- body.innerHTML = renderMd(display);
469
- body.style.fontFamily = "'Segoe UI', system-ui, sans-serif";
470
- body.style.whiteSpace = 'normal';
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
- _qaPersistSession(sessionKey, {
477
- threadHtml: updatedThreadHtml,
478
- docContext: sessionDocContext,
479
- filePath: capturedFilePath,
480
- history: runtime.history,
481
- queue: runtime.queue,
482
- });
483
- } else {
484
- const errorHtml = _qaBuildAssistantHtml('Error: ' + (data.error || 'Failed'), { color: 'var(--red)', isError: true, elapsed: qaElapsed });
485
- const updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
486
- const loadingEl = tmp.querySelector('#' + loadingId);
487
- if (loadingEl) loadingEl.remove();
488
- tmp.insertAdjacentHTML('beforeend', errorHtml);
489
- });
490
- _qaPersistSession(sessionKey, {
491
- threadHtml: updatedThreadHtml,
492
- docContext: sessionDocContext,
493
- filePath: capturedFilePath,
494
- history: runtime.history,
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
- ? _qaBuildAssistantHtml('Stopped', { color: 'var(--muted)', isError: true, elapsed: qaElapsedExc })
503
- : _qaBuildAssistantHtml('Error: ' + e.message, { color: 'var(--red)', isError: true, elapsed: qaElapsedExc });
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
@@ -544,10 +544,86 @@ const CC_SESSION_TTL_MS = shared.ENGINE_DEFAULTS.ccSessionTtlMs;
544
544
  let ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
545
545
  const ccInFlightTabs = new Map(); // tabId → timestamp — per-tab in-flight tracking for parallel CC requests
546
546
  const ccInFlightAborts = new Map(); // tabId → abortFn — lets a new request kill the stale LLM
547
+ const ccLiveStreams = new Map(); // tabId → buffered live stream state for reconnect-after-disconnect
547
548
  const CC_INFLIGHT_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes — auto-release if request hangs
548
549
  const CC_LOCK_WAIT_MS = 200; // grace period for previous handler's finally to release lock
549
550
  const CC_STREAM_HEARTBEAT_MS = 15000; // keep streaming responses alive across proxies/restart races
551
+ const CC_STREAM_REATTACH_GRACE_MS = 60000; // keep CC job alive briefly after disconnect so the UI can reattach
552
+ const CC_STREAM_DONE_RETENTION_MS = 30000; // retain final payload briefly so reconnect can still receive it
550
553
  function _releaseCCTab(tabId) { ccInFlightTabs.delete(tabId); ccInFlightAborts.delete(tabId); }
554
+ function _getCcLiveStream(tabId) {
555
+ return ccLiveStreams.get(tabId) || null;
556
+ }
557
+ function _clearCcLiveTimers(tabId) {
558
+ const state = _getCcLiveStream(tabId);
559
+ if (!state) return;
560
+ if (state.abortTimer) {
561
+ clearTimeout(state.abortTimer);
562
+ state.abortTimer = null;
563
+ }
564
+ if (state.cleanupTimer) {
565
+ clearTimeout(state.cleanupTimer);
566
+ state.cleanupTimer = null;
567
+ }
568
+ }
569
+ function _clearCcLiveStream(tabId) {
570
+ const state = _getCcLiveStream(tabId);
571
+ if (!state) return;
572
+ _clearCcLiveTimers(tabId);
573
+ ccLiveStreams.delete(tabId);
574
+ }
575
+ function _ensureCcLiveStream(tabId) {
576
+ let state = _getCcLiveStream(tabId);
577
+ if (state) return state;
578
+ state = {
579
+ tabId,
580
+ text: '',
581
+ tools: [],
582
+ donePayload: null,
583
+ writer: null,
584
+ endResponse: null,
585
+ abortFn: null,
586
+ abortTimer: null,
587
+ cleanupTimer: null,
588
+ };
589
+ ccLiveStreams.set(tabId, state);
590
+ return state;
591
+ }
592
+ function _attachCcLiveStream(tabId, writer, endResponse) {
593
+ const state = _ensureCcLiveStream(tabId);
594
+ _clearCcLiveTimers(tabId);
595
+ state.writer = writer;
596
+ state.endResponse = endResponse;
597
+ return state;
598
+ }
599
+ function _detachCcLiveStream(tabId, writer) {
600
+ const state = _getCcLiveStream(tabId);
601
+ if (!state) return;
602
+ if (!writer || state.writer === writer) {
603
+ state.writer = null;
604
+ state.endResponse = null;
605
+ }
606
+ }
607
+ function _scheduleCcLiveAbort(tabId) {
608
+ const state = _getCcLiveStream(tabId);
609
+ if (!state || state.donePayload) return;
610
+ _clearCcLiveTimers(tabId);
611
+ state.abortTimer = setTimeout(() => {
612
+ const live = _getCcLiveStream(tabId);
613
+ if (!live || live.donePayload || live.writer) return;
614
+ try { if (live.abortFn) live.abortFn(); } catch {}
615
+ }, CC_STREAM_REATTACH_GRACE_MS);
616
+ }
617
+ function _scheduleCcLiveCleanup(tabId, delayMs = CC_STREAM_DONE_RETENTION_MS) {
618
+ const state = _getCcLiveStream(tabId);
619
+ if (!state) return;
620
+ if (state.cleanupTimer) clearTimeout(state.cleanupTimer);
621
+ state.cleanupTimer = setTimeout(() => {
622
+ const live = _getCcLiveStream(tabId);
623
+ if (!live || live.writer) return;
624
+ _clearCcLiveStream(tabId);
625
+ }, delayMs);
626
+ }
551
627
  function _ccTabIsInFlight(tabId) {
552
628
  if (!ccInFlightTabs.has(tabId)) return false;
553
629
  // Auto-release stale locks — if a request has been in-flight longer than CC_INFLIGHT_TIMEOUT_MS,
@@ -1215,12 +1291,111 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
1215
1291
  return result;
1216
1292
  }
1217
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
+
1218
1376
  // Lightweight content fingerprint — same algorithm used browser-side (no crypto needed)
1219
1377
  function contentFingerprint(str) {
1220
1378
  if (!str) return '';
1221
1379
  return str.length + ':' + str.charCodeAt(0) + ':' + str.charCodeAt(str.length - 1);
1222
1380
  }
1223
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
+
1224
1399
  // Doc-specific wrapper — adds document context, parses ---DOCUMENT---
1225
1400
  async function ccDocCall({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, onAbortReady }) {
1226
1401
  const sessionKey = filePath || title;
@@ -1273,18 +1448,54 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
1273
1448
  return { answer: 'Failed to process request. Try again.', content: null, actions: [] };
1274
1449
  }
1275
1450
 
1276
- // Parse ---DOCUMENT--- BEFORE actions — document content may contain ===ACTIONS=== literally
1277
- const delimIdx = result.text.indexOf('---DOCUMENT---');
1278
- if (delimIdx >= 0) {
1279
- const answerPart = result.text.slice(0, delimIdx).trim();
1280
- const { text: answer, actions } = parseCCActions(answerPart);
1281
- let content = result.text.slice(delimIdx + '---DOCUMENT---'.length).trim();
1282
- content = content.replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim();
1283
- return { answer, content, actions };
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);
1284
1460
  }
1285
1461
 
1286
- const { text: stripped, actions } = parseCCActions(result.text);
1287
- return { answer: stripped, content: null, actions };
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);
1288
1499
  }
1289
1500
 
1290
1501
  // -- POST helpers --
@@ -3353,7 +3564,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3353
3564
  docChatInFlight.add(docKey);
3354
3565
  // Kill LLM process + release guard if client disconnects (abort/navigation)
3355
3566
  let _docAbort = null;
3356
- req.on('close', () => { docChatInFlight.delete(docKey); if (_docAbort) _docAbort(); });
3567
+ let _docDone = false;
3568
+ req.on('close', () => { if (!_docDone) { docChatInFlight.delete(docKey); if (_docAbort) _docAbort(); } });
3357
3569
 
3358
3570
  try {
3359
3571
  const canEdit = !!body.filePath;
@@ -3402,13 +3614,152 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3402
3614
 
3403
3615
  safeWrite(fullPath, content);
3404
3616
 
3617
+ _docDone = true;
3405
3618
  return jsonReply(res, 200, { ok: true, answer, edited: true, content, actions });
3406
3619
  }
3620
+ _docDone = true;
3407
3621
  return jsonReply(res, 200, { ok: true, answer: answer + '\n\n(Read-only — changes not saved)', edited: false, actions });
3408
- } finally { _docAbort = null; docChatInFlight.delete(docKey); }
3622
+ } finally { _docAbort = null; _docDone = true; docChatInFlight.delete(docKey); }
3409
3623
  } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
3410
3624
  }
3411
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
+
3412
3763
  async function handleInboxPersist(req, res) {
3413
3764
  try {
3414
3765
  const body = await readBody(req);
@@ -3900,10 +4251,31 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3900
4251
  async function handleCommandCenterNewSession(req, res) {
3901
4252
  ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
3902
4253
  ccInFlightTabs.clear(); // Reset all in-flight guards
4254
+ for (const [tabId, live] of ccLiveStreams.entries()) {
4255
+ try { if (live.abortFn) live.abortFn(); } catch {}
4256
+ _clearCcLiveStream(tabId);
4257
+ }
3903
4258
  safeWrite(path.join(ENGINE_DIR, 'cc-session.json'), ccSession);
3904
4259
  return jsonReply(res, 200, { ok: true });
3905
4260
  }
3906
4261
 
4262
+ async function handleCommandCenterAbort(req, res) {
4263
+ try {
4264
+ const body = await readBody(req);
4265
+ const tabId = body.tabId || 'default';
4266
+ const live = _getCcLiveStream(tabId);
4267
+ if (live?.abortFn) {
4268
+ try { live.abortFn(); } catch {}
4269
+ } else {
4270
+ const abort = ccInFlightAborts.get(tabId);
4271
+ if (abort) { try { abort(); } catch {} }
4272
+ }
4273
+ _clearCcLiveStream(tabId);
4274
+ _releaseCCTab(tabId);
4275
+ return jsonReply(res, 200, { ok: true });
4276
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
4277
+ }
4278
+
3907
4279
  async function handleCCSessionsList(req, res) {
3908
4280
  const sessions = _readCcTabSessions();
3909
4281
  return jsonReply(res, 200, { sessions });
@@ -4037,8 +4409,51 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4037
4409
  };
4038
4410
  try {
4039
4411
  const body = await readBody(req);
4040
- if (!body.message) { res.statusCode = 400; res.end('message required'); return; }
4412
+ if (!body.message && !body.reconnect) { res.statusCode = 400; res.end('message required'); return; }
4041
4413
  tabId = body.tabId || 'default';
4414
+ if (body.reconnect) {
4415
+ const live = _getCcLiveStream(tabId);
4416
+ if (!live) { res.statusCode = 409; res.end('No live command-center response to reconnect'); return; }
4417
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
4418
+ writeCcEvent({ type: 'heartbeat' });
4419
+ _ccHeartbeatTimer = setInterval(() => {
4420
+ if (_ccStreamEnded) {
4421
+ stopCcHeartbeat();
4422
+ return;
4423
+ }
4424
+ if (!writeCcEvent({ type: 'heartbeat' })) stopCcHeartbeat();
4425
+ }, CC_STREAM_HEARTBEAT_MS);
4426
+ let reconnectDone;
4427
+ const reconnectDonePromise = new Promise(resolve => { reconnectDone = resolve; });
4428
+ _attachCcLiveStream(tabId, writeCcEvent, () => {
4429
+ if (_ccStreamEnded) return;
4430
+ _ccStreamEnded = true;
4431
+ stopCcHeartbeat();
4432
+ try { res.end(); } catch {}
4433
+ reconnectDone();
4434
+ });
4435
+ req.on('close', () => {
4436
+ if (_ccStreamEnded) return;
4437
+ stopCcHeartbeat();
4438
+ _detachCcLiveStream(tabId, writeCcEvent);
4439
+ _scheduleCcLiveAbort(tabId);
4440
+ reconnectDone();
4441
+ });
4442
+ for (const tool of live.tools || []) {
4443
+ writeCcEvent({ type: 'tool', name: tool.name, input: _lightToolInput(tool.input) });
4444
+ }
4445
+ if (live.text) writeCcEvent({ type: 'chunk', text: live.text });
4446
+ if (live.donePayload) {
4447
+ writeCcEvent(live.donePayload);
4448
+ _ccStreamEnded = true;
4449
+ stopCcHeartbeat();
4450
+ try { res.end(); } catch {}
4451
+ _scheduleCcLiveCleanup(tabId);
4452
+ return;
4453
+ }
4454
+ await reconnectDonePromise;
4455
+ return;
4456
+ }
4042
4457
  if (_ccTabIsInFlight(tabId)) {
4043
4458
  // Previous request still in-flight — abort its LLM (handles keep-alive abort where close event didn't fire)
4044
4459
  const prevAbort = ccInFlightAborts.get(tabId);
@@ -4049,6 +4464,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4049
4464
  }
4050
4465
  }
4051
4466
  ccInFlightTabs.set(tabId, Date.now());
4467
+ _clearCcLiveStream(tabId);
4468
+ const liveState = _attachCcLiveStream(tabId, writeCcEvent, () => {
4469
+ if (_ccStreamEnded) return;
4470
+ _ccStreamEnded = true;
4471
+ stopCcHeartbeat();
4472
+ try { res.end(); } catch {}
4473
+ });
4052
4474
 
4053
4475
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
4054
4476
  writeCcEvent({ type: 'heartbeat' }); // flush headers quickly and keep intermediaries from idling out
@@ -4060,16 +4482,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4060
4482
  if (!writeCcEvent({ type: 'heartbeat' })) stopCcHeartbeat();
4061
4483
  }, CC_STREAM_HEARTBEAT_MS);
4062
4484
  // Kill LLM process immediately if client disconnects mid-stream.
4063
- // Guard with !_ccStreamEnded: when the stream ends normally, finally already released the lock;
4064
- // without the guard, this close event (which fires after res.end) could wipe a new request's lock.
4485
+ // Keep the LLM alive briefly after disconnect so the UI can reattach to the same in-flight turn.
4065
4486
  req.on('close', () => {
4066
4487
  if (!_ccStreamEnded) {
4067
4488
  stopCcHeartbeat();
4068
- _releaseCCTab(tabId);
4069
- if (_ccStreamAbort) {
4070
- console.log(`[CC-stream] Client disconnected for tab ${tabId} — aborting LLM`);
4071
- _ccStreamAbort();
4072
- }
4489
+ _detachCcLiveStream(tabId, writeCcEvent);
4490
+ _scheduleCcLiveAbort(tabId);
4073
4491
  }
4074
4492
  });
4075
4493
 
@@ -4106,14 +4524,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4106
4524
  onChunk: (text) => {
4107
4525
  const actIdx = findCCActionsDelimiter(text);
4108
4526
  const display = actIdx >= 0 ? text.slice(0, actIdx).trim() : text;
4109
- writeCcEvent({ type: 'chunk', text: display });
4527
+ liveState.text = display;
4528
+ if (liveState.writer) liveState.writer({ type: 'chunk', text: display });
4110
4529
  },
4111
4530
  onToolUse: (name, input) => {
4112
4531
  toolUses.push({ name, input: input || {} });
4113
- writeCcEvent({ type: 'tool', name, input: _lightToolInput(input) });
4532
+ liveState.tools.push({ name, input: input || {} });
4533
+ if (liveState.writer) liveState.writer({ type: 'tool', name, input: _lightToolInput(input) });
4114
4534
  }
4115
4535
  });
4116
4536
  _ccStreamAbort = llmPromise.abort;
4537
+ liveState.abortFn = _ccStreamAbort;
4117
4538
  ccInFlightAborts.set(tabId, _ccStreamAbort);
4118
4539
  const result = await llmPromise;
4119
4540
  trackUsage('command-center', result.usage);
@@ -4127,21 +4548,24 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4127
4548
  toolUses = []; // discard stale metadata from the failed resume attempt
4128
4549
  const retryPromise = callLLMStreaming(freshPrompt, CC_STATIC_SYSTEM_PROMPT, {
4129
4550
  timeout: 900000, label: 'command-center', model: streamModel, maxTurns: ccMaxTurns,
4130
- allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
4131
- effort: streamEffort, direct: true,
4132
- onChunk: (text) => {
4133
- const actIdx = findCCActionsDelimiter(text);
4134
- const display = actIdx >= 0 ? text.slice(0, actIdx).trim() : text;
4135
- writeCcEvent({ type: 'chunk', text: display });
4136
- },
4137
- onToolUse: (name, input) => {
4138
- toolUses.push({ name, input: input || {} });
4139
- writeCcEvent({ type: 'tool', name, input: _lightToolInput(input) });
4140
- }
4141
- });
4142
- _ccStreamAbort = retryPromise.abort;
4143
- ccInFlightAborts.set(tabId, _ccStreamAbort);
4144
- const retryResult = await retryPromise;
4551
+ allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
4552
+ effort: streamEffort, direct: true,
4553
+ onChunk: (text) => {
4554
+ const actIdx = findCCActionsDelimiter(text);
4555
+ const display = actIdx >= 0 ? text.slice(0, actIdx).trim() : text;
4556
+ liveState.text = display;
4557
+ if (liveState.writer) liveState.writer({ type: 'chunk', text: display });
4558
+ },
4559
+ onToolUse: (name, input) => {
4560
+ toolUses.push({ name, input: input || {} });
4561
+ liveState.tools.push({ name, input: input || {} });
4562
+ if (liveState.writer) liveState.writer({ type: 'tool', name, input: _lightToolInput(input) });
4563
+ }
4564
+ });
4565
+ _ccStreamAbort = retryPromise.abort;
4566
+ liveState.abortFn = _ccStreamAbort;
4567
+ ccInFlightAborts.set(tabId, _ccStreamAbort);
4568
+ const retryResult = await retryPromise;
4145
4569
  trackUsage('command-center', retryResult.usage);
4146
4570
  if (retryResult.text) {
4147
4571
  // Fresh session succeeded — use retryResult from here
@@ -4154,8 +4578,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4154
4578
  const stderrTail = (result.stderr || '').trim().split('\n').filter(Boolean).slice(-3).join(' | ');
4155
4579
  console.error(`[CC-stream] Failed: code=${result.code}, stderr=${(result.stderr || '').slice(0, 500)}, stdout_tail=${(result.raw || '').slice(-500)}`);
4156
4580
  const retryHint = 'Send your message again to retry.';
4157
- writeCcEvent({ type: 'done', text: `I had trouble processing that ${debugInfo}. ${stderrTail ? 'Detail: ' + stderrTail : ''}\n\n${retryHint}`, actions: [], sessionId: null });
4158
- _ccStreamEnded = true; res.end();
4581
+ liveState.donePayload = { type: 'done', text: `I had trouble processing that ${debugInfo}. ${stderrTail ? 'Detail: ' + stderrTail : ''}\n\n${retryHint}`, actions: [], sessionId: null };
4582
+ if (liveState.writer) liveState.writer(liveState.donePayload);
4583
+ if (liveState.endResponse) liveState.endResponse();
4584
+ _scheduleCcLiveCleanup(tabId);
4159
4585
  return;
4160
4586
  }
4161
4587
 
@@ -4197,7 +4623,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4197
4623
  }
4198
4624
  const donePayload = { type: 'done', text: displayText, actions, actionResults, sessionId: responseSessionId, newSession: !wasResume };
4199
4625
  if (sessionReset) donePayload.sessionReset = true;
4200
- writeCcEvent(donePayload);
4626
+ liveState.donePayload = donePayload;
4627
+ if (liveState.writer) liveState.writer(donePayload);
4201
4628
 
4202
4629
  // Mirror CC response to Teams (non-blocking, skip Teams-originated)
4203
4630
  const _streamTabId = body.tabId || 'default';
@@ -4205,7 +4632,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4205
4632
  teams.teamsPostCCResponse(body.message, result.text).catch(() => {});
4206
4633
  }
4207
4634
 
4208
- _ccStreamEnded = true; res.end();
4635
+ if (liveState.endResponse) liveState.endResponse();
4636
+ _scheduleCcLiveCleanup(tabId);
4209
4637
  } finally {
4210
4638
  stopCcHeartbeat();
4211
4639
  _releaseCCTab(tabId);
@@ -5077,6 +5505,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5077
5505
 
5078
5506
  // Doc chat
5079
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 },
5080
5509
 
5081
5510
  // Inbox
5082
5511
  { method: 'POST', path: '/api/inbox/persist', desc: 'Promote an inbox item to team notes', params: 'name', handler: handleInboxPersist },
@@ -5099,6 +5528,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5099
5528
 
5100
5529
  // Command Center
5101
5530
  { method: 'POST', path: '/api/command-center/new-session', desc: 'Clear active CC session', handler: handleCommandCenterNewSession },
5531
+ { method: 'POST', path: '/api/command-center/abort', desc: 'Abort an in-flight CC request for a tab', params: 'tabId?', handler: handleCommandCenterAbort },
5102
5532
  { method: 'POST', path: '/api/command-center', desc: 'Conversational command center with full minions context', params: 'message, sessionId?', handler: handleCommandCenter },
5103
5533
  { method: 'POST', path: '/api/command-center/stream', desc: 'Streaming CC — SSE with text chunks as they arrive', params: 'message, tabId?', handler: handleCommandCenterStream },
5104
5534
  { method: 'GET', path: '/api/cc-sessions', desc: 'List CC session metadata for all tabs', handler: handleCCSessionsList },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1578",
3
+ "version": "0.1.1580",
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"