@yemi33/minions 0.1.1578 → 0.1.1579

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,13 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1578 (2026-04-28)
3
+ ## 0.1.1579 (2026-04-28)
4
4
 
5
5
  ### Features
6
6
  - hash-dedup, compress+normalize pass, dynamic stale-guard, rich result
7
7
 
8
+ ### Other
9
+ - Keep CC streams reconnectable
10
+
8
11
  ## 0.1.1577 (2026-04-27)
9
12
 
10
13
  ### 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();
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,
@@ -3900,10 +3976,31 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3900
3976
  async function handleCommandCenterNewSession(req, res) {
3901
3977
  ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
3902
3978
  ccInFlightTabs.clear(); // Reset all in-flight guards
3979
+ for (const [tabId, live] of ccLiveStreams.entries()) {
3980
+ try { if (live.abortFn) live.abortFn(); } catch {}
3981
+ _clearCcLiveStream(tabId);
3982
+ }
3903
3983
  safeWrite(path.join(ENGINE_DIR, 'cc-session.json'), ccSession);
3904
3984
  return jsonReply(res, 200, { ok: true });
3905
3985
  }
3906
3986
 
3987
+ async function handleCommandCenterAbort(req, res) {
3988
+ try {
3989
+ const body = await readBody(req);
3990
+ const tabId = body.tabId || 'default';
3991
+ const live = _getCcLiveStream(tabId);
3992
+ if (live?.abortFn) {
3993
+ try { live.abortFn(); } catch {}
3994
+ } else {
3995
+ const abort = ccInFlightAborts.get(tabId);
3996
+ if (abort) { try { abort(); } catch {} }
3997
+ }
3998
+ _clearCcLiveStream(tabId);
3999
+ _releaseCCTab(tabId);
4000
+ return jsonReply(res, 200, { ok: true });
4001
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
4002
+ }
4003
+
3907
4004
  async function handleCCSessionsList(req, res) {
3908
4005
  const sessions = _readCcTabSessions();
3909
4006
  return jsonReply(res, 200, { sessions });
@@ -4037,8 +4134,51 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4037
4134
  };
4038
4135
  try {
4039
4136
  const body = await readBody(req);
4040
- if (!body.message) { res.statusCode = 400; res.end('message required'); return; }
4137
+ if (!body.message && !body.reconnect) { res.statusCode = 400; res.end('message required'); return; }
4041
4138
  tabId = body.tabId || 'default';
4139
+ if (body.reconnect) {
4140
+ const live = _getCcLiveStream(tabId);
4141
+ if (!live) { res.statusCode = 409; res.end('No live command-center response to reconnect'); return; }
4142
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
4143
+ writeCcEvent({ type: 'heartbeat' });
4144
+ _ccHeartbeatTimer = setInterval(() => {
4145
+ if (_ccStreamEnded) {
4146
+ stopCcHeartbeat();
4147
+ return;
4148
+ }
4149
+ if (!writeCcEvent({ type: 'heartbeat' })) stopCcHeartbeat();
4150
+ }, CC_STREAM_HEARTBEAT_MS);
4151
+ let reconnectDone;
4152
+ const reconnectDonePromise = new Promise(resolve => { reconnectDone = resolve; });
4153
+ _attachCcLiveStream(tabId, writeCcEvent, () => {
4154
+ if (_ccStreamEnded) return;
4155
+ _ccStreamEnded = true;
4156
+ stopCcHeartbeat();
4157
+ try { res.end(); } catch {}
4158
+ reconnectDone();
4159
+ });
4160
+ req.on('close', () => {
4161
+ if (_ccStreamEnded) return;
4162
+ stopCcHeartbeat();
4163
+ _detachCcLiveStream(tabId, writeCcEvent);
4164
+ _scheduleCcLiveAbort(tabId);
4165
+ reconnectDone();
4166
+ });
4167
+ for (const tool of live.tools || []) {
4168
+ writeCcEvent({ type: 'tool', name: tool.name, input: _lightToolInput(tool.input) });
4169
+ }
4170
+ if (live.text) writeCcEvent({ type: 'chunk', text: live.text });
4171
+ if (live.donePayload) {
4172
+ writeCcEvent(live.donePayload);
4173
+ _ccStreamEnded = true;
4174
+ stopCcHeartbeat();
4175
+ try { res.end(); } catch {}
4176
+ _scheduleCcLiveCleanup(tabId);
4177
+ return;
4178
+ }
4179
+ await reconnectDonePromise;
4180
+ return;
4181
+ }
4042
4182
  if (_ccTabIsInFlight(tabId)) {
4043
4183
  // Previous request still in-flight — abort its LLM (handles keep-alive abort where close event didn't fire)
4044
4184
  const prevAbort = ccInFlightAborts.get(tabId);
@@ -4049,6 +4189,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4049
4189
  }
4050
4190
  }
4051
4191
  ccInFlightTabs.set(tabId, Date.now());
4192
+ _clearCcLiveStream(tabId);
4193
+ const liveState = _attachCcLiveStream(tabId, writeCcEvent, () => {
4194
+ if (_ccStreamEnded) return;
4195
+ _ccStreamEnded = true;
4196
+ stopCcHeartbeat();
4197
+ try { res.end(); } catch {}
4198
+ });
4052
4199
 
4053
4200
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
4054
4201
  writeCcEvent({ type: 'heartbeat' }); // flush headers quickly and keep intermediaries from idling out
@@ -4059,17 +4206,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4059
4206
  }
4060
4207
  if (!writeCcEvent({ type: 'heartbeat' })) stopCcHeartbeat();
4061
4208
  }, CC_STREAM_HEARTBEAT_MS);
4062
- // 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.
4209
+ // Keep the LLM alive briefly after disconnect so the UI can reattach to the same in-flight turn.
4065
4210
  req.on('close', () => {
4066
4211
  if (!_ccStreamEnded) {
4067
4212
  stopCcHeartbeat();
4068
- _releaseCCTab(tabId);
4069
- if (_ccStreamAbort) {
4070
- console.log(`[CC-stream] Client disconnected for tab ${tabId} — aborting LLM`);
4071
- _ccStreamAbort();
4072
- }
4213
+ _detachCcLiveStream(tabId, writeCcEvent);
4214
+ _scheduleCcLiveAbort(tabId);
4073
4215
  }
4074
4216
  });
4075
4217
 
@@ -4106,14 +4248,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4106
4248
  onChunk: (text) => {
4107
4249
  const actIdx = findCCActionsDelimiter(text);
4108
4250
  const display = actIdx >= 0 ? text.slice(0, actIdx).trim() : text;
4109
- writeCcEvent({ type: 'chunk', text: display });
4251
+ liveState.text = display;
4252
+ if (liveState.writer) liveState.writer({ type: 'chunk', text: display });
4110
4253
  },
4111
4254
  onToolUse: (name, input) => {
4112
4255
  toolUses.push({ name, input: input || {} });
4113
- writeCcEvent({ type: 'tool', name, input: _lightToolInput(input) });
4256
+ liveState.tools.push({ name, input: input || {} });
4257
+ if (liveState.writer) liveState.writer({ type: 'tool', name, input: _lightToolInput(input) });
4114
4258
  }
4115
4259
  });
4116
4260
  _ccStreamAbort = llmPromise.abort;
4261
+ liveState.abortFn = _ccStreamAbort;
4117
4262
  ccInFlightAborts.set(tabId, _ccStreamAbort);
4118
4263
  const result = await llmPromise;
4119
4264
  trackUsage('command-center', result.usage);
@@ -4127,21 +4272,24 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4127
4272
  toolUses = []; // discard stale metadata from the failed resume attempt
4128
4273
  const retryPromise = callLLMStreaming(freshPrompt, CC_STATIC_SYSTEM_PROMPT, {
4129
4274
  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;
4275
+ allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
4276
+ effort: streamEffort, direct: true,
4277
+ onChunk: (text) => {
4278
+ const actIdx = findCCActionsDelimiter(text);
4279
+ const display = actIdx >= 0 ? text.slice(0, actIdx).trim() : text;
4280
+ liveState.text = display;
4281
+ if (liveState.writer) liveState.writer({ type: 'chunk', text: display });
4282
+ },
4283
+ onToolUse: (name, input) => {
4284
+ toolUses.push({ name, input: input || {} });
4285
+ liveState.tools.push({ name, input: input || {} });
4286
+ if (liveState.writer) liveState.writer({ type: 'tool', name, input: _lightToolInput(input) });
4287
+ }
4288
+ });
4289
+ _ccStreamAbort = retryPromise.abort;
4290
+ liveState.abortFn = _ccStreamAbort;
4291
+ ccInFlightAborts.set(tabId, _ccStreamAbort);
4292
+ const retryResult = await retryPromise;
4145
4293
  trackUsage('command-center', retryResult.usage);
4146
4294
  if (retryResult.text) {
4147
4295
  // Fresh session succeeded — use retryResult from here
@@ -4154,8 +4302,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4154
4302
  const stderrTail = (result.stderr || '').trim().split('\n').filter(Boolean).slice(-3).join(' | ');
4155
4303
  console.error(`[CC-stream] Failed: code=${result.code}, stderr=${(result.stderr || '').slice(0, 500)}, stdout_tail=${(result.raw || '').slice(-500)}`);
4156
4304
  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();
4305
+ liveState.donePayload = { type: 'done', text: `I had trouble processing that ${debugInfo}. ${stderrTail ? 'Detail: ' + stderrTail : ''}\n\n${retryHint}`, actions: [], sessionId: null };
4306
+ if (liveState.writer) liveState.writer(liveState.donePayload);
4307
+ if (liveState.endResponse) liveState.endResponse();
4308
+ _scheduleCcLiveCleanup(tabId);
4159
4309
  return;
4160
4310
  }
4161
4311
 
@@ -4197,7 +4347,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4197
4347
  }
4198
4348
  const donePayload = { type: 'done', text: displayText, actions, actionResults, sessionId: responseSessionId, newSession: !wasResume };
4199
4349
  if (sessionReset) donePayload.sessionReset = true;
4200
- writeCcEvent(donePayload);
4350
+ liveState.donePayload = donePayload;
4351
+ if (liveState.writer) liveState.writer(donePayload);
4201
4352
 
4202
4353
  // Mirror CC response to Teams (non-blocking, skip Teams-originated)
4203
4354
  const _streamTabId = body.tabId || 'default';
@@ -4205,7 +4356,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4205
4356
  teams.teamsPostCCResponse(body.message, result.text).catch(() => {});
4206
4357
  }
4207
4358
 
4208
- _ccStreamEnded = true; res.end();
4359
+ if (liveState.endResponse) liveState.endResponse();
4360
+ _scheduleCcLiveCleanup(tabId);
4209
4361
  } finally {
4210
4362
  stopCcHeartbeat();
4211
4363
  _releaseCCTab(tabId);
@@ -5099,6 +5251,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5099
5251
 
5100
5252
  // Command Center
5101
5253
  { method: 'POST', path: '/api/command-center/new-session', desc: 'Clear active CC session', handler: handleCommandCenterNewSession },
5254
+ { method: 'POST', path: '/api/command-center/abort', desc: 'Abort an in-flight CC request for a tab', params: 'tabId?', handler: handleCommandCenterAbort },
5102
5255
  { method: 'POST', path: '/api/command-center', desc: 'Conversational command center with full minions context', params: 'message, sessionId?', handler: handleCommandCenter },
5103
5256
  { method: 'POST', path: '/api/command-center/stream', desc: 'Streaming CC — SSE with text chunks as they arrive', params: 'message, tabId?', handler: handleCommandCenterStream },
5104
5257
  { 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.1579",
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"