@yemi33/minions 0.1.2189 → 0.1.2191

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.
@@ -549,12 +549,12 @@ function ccSwitchTab(id) {
549
549
  var phases = [[0,'Thinking...'],[3000,'Reading minions context...'],[8000,'Analyzing...'],[15000,'Using tools to dig deeper...'],[30000,'Still working (multi-turn)...'],[60000,'Deep research in progress...']];
550
550
  function _restoreStreamHtml() {
551
551
  var html = '';
552
- var tools = tab._toolsUsed || [];
553
- if (tools.length > 0) {
554
- html += '<div style="margin-bottom:6px">' + tools.map(renderToolChip).join('') + '</div>';
555
- }
556
- var text = tab._streamedText || '';
557
- if (text) html += renderMd(text);
552
+ // Restore the same interleaved text↔tool layout the live stream renders,
553
+ // so chips and partial text keep their chronological order (and chip
554
+ // status styling) after a tab switch / reload. renderToolChip is reached
555
+ // via ccSegmentsRender.
556
+ var text = ccSegmentsText(tab._segments || []);
557
+ html += ccSegmentsRender(tab._segments || [], { keyPrefix: tab.id });
558
558
  var ms = Date.now() - restoreStart;
559
559
  var label = 'Thinking...';
560
560
  for (var pi = phases.length - 1; pi >= 0; pi--) { if (ms >= phases[pi][0]) { label = phases[pi][1]; break; } }
@@ -633,7 +633,7 @@ function ccRenderTabBar() {
633
633
  var isActive = t.id === _ccActiveTabId;
634
634
  // draggable="true" + DnD handlers enable click-and-drag reorder of tabs.
635
635
  // The handlers below splice _ccTabs in place (preserving per-tab in-flight
636
- // state: _sending, _queue, _abortController, _streamedText, _toolsUsed)
636
+ // state: _sending, _queue, _abortController, _segments)
637
637
  // and persist via ccSaveState. The close X / new-tab + opt out of drag
638
638
  // with draggable="false" + ondragstart preventDefault so the affordances
639
639
  // don't accidentally start a drag.
@@ -709,7 +709,7 @@ function ccTabDrop(ev, targetId) {
709
709
  var toIdx = _ccTabs.findIndex(function(t) { return t.id === targetId; });
710
710
  if (fromIdx === -1 || toIdx === -1) return;
711
711
  // Splice the existing tab reference so per-tab in-flight state survives
712
- // (_sending, _queue, _abortController, _streamedText, _toolsUsed,
712
+ // (_sending, _queue, _abortController, _segments,
713
713
  // _retryRequests, _sendStartedAt, etc.). Do NOT clone/replace the object.
714
714
  var moved = _ccTabs.splice(fromIdx, 1)[0];
715
715
  _ccTabs.splice(toIdx, 0, moved);
@@ -979,9 +979,12 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
979
979
 
980
980
  // Streaming state — declared before try so updateStreamDiv works during fetch
981
981
  // Also saved on tab for restore when switching back
982
- var streamedText = '';
983
- var toolsUsed = [];
984
- if (activeTab) { activeTab._streamedText = ''; activeTab._toolsUsed = []; }
982
+ // Ordered text↔tool segments for this turn (replaces the old flat
983
+ // streamedText + toolsUsed buckets). Rendered in arrival order so prose and
984
+ // tool activity stay interleaved instead of welded into one blob. See
985
+ // render-utils.js ccSegments* helpers.
986
+ var segments = [];
987
+ if (activeTab) activeTab._segments = segments;
985
988
 
986
989
  // Get active tab's sessionId to send with request
987
990
  var tabSessionId = activeTab ? activeTab.sessionId : null;
@@ -1020,16 +1023,15 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1020
1023
  if (re) { streamDiv = re; re.removeAttribute('id'); } else return;
1021
1024
  }
1022
1025
  var html = '';
1023
- if (toolsUsed.length > 0) {
1024
- html += '<div style="margin-bottom:6px">' + toolsUsed.map(renderToolChip).join('') + '</div>';
1025
- }
1026
- if (streamedText) {
1027
- html += renderMd(streamedText);
1028
- }
1026
+ // ccSegmentsRender interleaves tool chips and text blocks in arrival order
1027
+ // (delegates each chip to renderToolChip). keyPrefix=tab id keeps any
1028
+ // expanded tool group open across the per-frame re-render.
1029
+ var bodyHtml = ccSegmentsRender(segments, { keyPrefix: activeTabId });
1030
+ if (bodyHtml) html += bodyHtml;
1029
1031
  if (streamStatusNote) {
1030
1032
  html += '<div style="margin-top:6px;font-size:var(--text-sm);color:var(--muted)">' + escHtml(streamStatusNote) + '</div>';
1031
1033
  }
1032
- html += '<div style="margin-top:' + (streamedText ? '6px' : '0') + '">' + _getThinkingHtml() + '</div>';
1034
+ html += '<div style="margin-top:' + (bodyHtml ? '6px' : '0') + '">' + _getThinkingHtml() + '</div>';
1033
1035
  // eslint-disable-next-line no-unsanitized/property -- reason: renderMd() and renderToolChip() escape streamed text/tool fields before assembling live stream HTML
1034
1036
  streamDiv.innerHTML = html;
1035
1037
  // Re-append queue indicators so they stay below the streaming content
@@ -1098,24 +1100,22 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1098
1100
 
1099
1101
  async function _handleEvent(evt) {
1100
1102
  if (evt.type === 'chunk') {
1101
- streamedText = _ccMergeStreamText(streamedText, evt.text || '');
1102
- if (activeTab) activeTab._streamedText = streamedText;
1103
+ // evt.segmentId (from engine/llm.js) marks distinct assistant text
1104
+ // blocks; ccSegmentsApplyChunk falls back to a heuristic when it's
1105
+ // absent (Copilot pool path / live replay).
1106
+ ccSegmentsApplyChunk(segments, evt.text || '', evt.segmentId);
1107
+ if (activeTab) activeTab._segments = segments;
1103
1108
  updateStreamDiv();
1104
1109
  } else if (evt.type === 'heartbeat') {
1105
1110
  return;
1106
1111
  } else if (evt.type === 'tool') {
1107
- toolsUsed.push({ name: evt.name, input: evt.input || {}, id: evt.id || null, status: evt.id ? 'pending' : null });
1108
- if (activeTab) activeTab._toolsUsed = toolsUsed.slice();
1112
+ ccSegmentsApplyTool(segments, { name: evt.name, input: evt.input || {}, id: evt.id || null });
1113
+ if (activeTab) activeTab._segments = segments;
1109
1114
  updateStreamDiv();
1110
1115
  if (msgs.scrollHeight - msgs.scrollTop - msgs.clientHeight < 150) msgs.scrollTop = msgs.scrollHeight;
1111
1116
  } else if (evt.type === 'tool-update') {
1112
- for (var ti = 0; ti < toolsUsed.length; ti++) {
1113
- if (toolsUsed[ti] && toolsUsed[ti].id === evt.id) {
1114
- toolsUsed[ti].status = evt.status;
1115
- break;
1116
- }
1117
- }
1118
- if (activeTab) activeTab._toolsUsed = toolsUsed.slice();
1117
+ ccSegmentsApplyToolUpdate(segments, evt.id, evt.status);
1118
+ if (activeTab) activeTab._segments = segments;
1119
1119
  updateStreamDiv();
1120
1120
  } else if (evt.type === 'done') {
1121
1121
  terminalEventSeen = true;
@@ -1131,10 +1131,13 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1131
1131
  }
1132
1132
  addMsg('system', '<div style="text-align:center;padding:6px 12px;font-size:var(--text-base);color:var(--muted);background:var(--surface2);border-radius:6px;margin:4px 0">' + resetText + '</div>', false, activeTabId);
1133
1133
  }
1134
- var finalText = _ccMergeStreamText(streamedText, evt.text || '');
1134
+ // Reconcile streamed segments with the authoritative terminal text
1135
+ // (Claude's `result` — the last assistant message), preserving earlier
1136
+ // interleaved text/tool segments, then render the whole turn in order.
1137
+ ccSegmentsFinalize(segments, evt.text || '');
1135
1138
  if (evt.actions && evt.actions.length > 0) _tagServerExecuted(evt.actions, evt.actionResults);
1136
- var rendered = renderMd(finalText || streamedText || '');
1137
1139
  var assistantMessageId = _ccNewMessageId('cc-turn');
1140
+ var rendered = ccSegmentsRender(segments, { keyPrefix: assistantMessageId });
1138
1141
  addMsg('assistant', rendered + _ccElapsedFooter('{seconds}s'), false, { messageId: assistantMessageId });
1139
1142
  // Surface each server-executed action as a standalone chip OUTSIDE the
1140
1143
  // assistant bubble — matches the pattern used by ccExecuteAction for
@@ -1267,8 +1270,9 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1267
1270
  _cleanupStreamDiv();
1268
1271
  var streamEndedHint = '<div style="font-size:var(--text-sm);color:var(--muted);margin-top:4px">The response stream ended before completion. Retry to resend the interrupted message.</div>';
1269
1272
  var streamEndedRetry = _ccStoreRetryRequest(activeTab, activeTabId, message);
1270
- if (streamedText) {
1271
- addMsg('assistant', renderMd(streamedText) + _ccElapsedFooter('Stream interrupted after {seconds}s') + _ccRetryControls(streamEndedRetry, streamEndedHint, false), false, { retryId: streamEndedRetry.id });
1273
+ var streamEndedPartial = ccSegmentsRender(segments, { keyPrefix: activeTabId });
1274
+ if (streamEndedPartial) {
1275
+ addMsg('assistant', streamEndedPartial + _ccElapsedFooter('Stream interrupted after {seconds}s') + _ccRetryControls(streamEndedRetry, streamEndedHint, false), false, { retryId: streamEndedRetry.id });
1272
1276
  } else {
1273
1277
  addMsg('assistant', '<span style="color:var(--red)">The response stream ended before completion.</span>' + _ccRetryControls(streamEndedRetry, streamEndedHint, false), false, { retryId: streamEndedRetry.id });
1274
1278
  }
@@ -1281,14 +1285,14 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1281
1285
  ? '<div style="font-size:var(--text-sm);color:var(--muted);margin-top:4px">Dashboard restarted while this response was streaming. Restart Minions to reconnect to the new instance.</div>'
1282
1286
  : '<div style="font-size:var(--text-sm);color:var(--muted);margin-top:4px">The request stream was interrupted, but the dashboard is still reachable. Retry or start a new session.</div>';
1283
1287
  var reconnectRetry = _ccStoreRetryRequest(activeTab, activeTabId, message);
1284
- addMsg('assistant', (streamedText ? renderMd(streamedText) + _ccElapsedFooter('Stream interrupted after {seconds}s') : '') +
1288
+ var reconnectPartial = ccSegmentsRender(segments, { keyPrefix: activeTabId });
1289
+ addMsg('assistant', (reconnectPartial ? reconnectPartial + _ccElapsedFooter('Stream interrupted after {seconds}s') : '') +
1285
1290
  _ccRetryControls(reconnectRetry, reconnectHint, reconnectHealth.restarted), false, { retryId: reconnectRetry.id });
1286
1291
  break;
1287
1292
  }
1288
1293
  reconnectAttempts++;
1289
- streamedText = '';
1290
- toolsUsed = [];
1291
- if (activeTab) { activeTab._streamedText = ''; activeTab._toolsUsed = []; }
1294
+ segments = [];
1295
+ if (activeTab) activeTab._segments = segments;
1292
1296
  streamStatusNote = 'Connection interrupted — reattaching to the live response...';
1293
1297
  updateStreamDiv();
1294
1298
  await new Promise(function(r) { setTimeout(r, 1000 * reconnectAttempts); });
@@ -1297,8 +1301,9 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1297
1301
  _cleanupStreamDiv();
1298
1302
  if (activeTab && activeTab._userAborted) {
1299
1303
  _wasAborted = true;
1300
- if (streamedText) {
1301
- addMsg('assistant', renderMd(streamedText) + _ccElapsedFooter('Stopped after {seconds}s'));
1304
+ var abortedPartial = ccSegmentsRender(segments, { keyPrefix: activeTabId });
1305
+ if (abortedPartial) {
1306
+ addMsg('assistant', abortedPartial + _ccElapsedFooter('Stopped after {seconds}s'));
1302
1307
  } else {
1303
1308
  addMsg('assistant', '<span style="color:var(--red);font-size:var(--text-base)">Stopped</span>');
1304
1309
  }
@@ -1332,12 +1337,13 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1332
1337
  } else {
1333
1338
  errorRendered = '<span style="color:var(--red)">Error: ' + escHtml(e.message) + '</span>';
1334
1339
  }
1335
- addMsg('assistant', (streamedText ? renderMd(streamedText) + _ccElapsedFooter('Stream interrupted after {seconds}s') : '') +
1340
+ var errorPartial = ccSegmentsRender(segments, { keyPrefix: activeTabId });
1341
+ addMsg('assistant', (errorPartial ? errorPartial + _ccElapsedFooter('Stream interrupted after {seconds}s') : '') +
1336
1342
  errorRendered +
1337
1343
  _ccRetryControls(errorRetry, connectionHint, isNetworkError && (!dashboardHealth.reachable || dashboardHealth.restarted)), false, { retryId: errorRetry.id });
1338
1344
  }
1339
1345
  } finally {
1340
- if (activeTab) { activeTab._sending = false; activeTab._abortController = null; activeTab._429retries = 0; delete activeTab._streamedText; delete activeTab._toolsUsed; delete activeTab._sendStartedAt; delete activeTab._userAborted; }
1346
+ if (activeTab) { activeTab._sending = false; activeTab._abortController = null; activeTab._429retries = 0; delete activeTab._segments; delete activeTab._sendStartedAt; delete activeTab._userAborted; }
1341
1347
  _ccSending = (_ccTabs.some(function(t) { return t._sending; }));
1342
1348
  // Mark tab unread if response completed on a background tab or while drawer is closed
1343
1349
  if (activeTab && !_wasAborted && (activeTab.id !== _ccActiveTabId || !_ccOpen)) activeTab._unread = true;
@@ -378,23 +378,16 @@ function _qaBuildActionFeedbackHtml(actionFeedback) {
378
378
  }).join('');
379
379
  }
380
380
 
381
- function _qaBuildLiveProgressHtml(loadingId, label, elapsedSeconds, streamedText, toolsUsed, queueCount) {
381
+ function _qaBuildLiveProgressHtml(loadingId, label, elapsedSeconds, segments, queueCount) {
382
382
  const qaQueueBadge = queueCount > 0 ? ' <span style="font-size:var(--text-xs);color:var(--muted);background:var(--surface);padding:1px 5px;border-radius:8px;border:1px solid var(--border)">+' + queueCount + ' queued</span>' : '';
383
- // Wrap in a column-flex container so chain-of-thought (tool calls) stack
384
- // vertically on top and the progress block sits at the bottom. Overrides the
385
- // parent .modal-qa-loading row-flex (which is right for the simple
386
- // "Thinking..." initial state but wrong once tools/streamed text appear).
383
+ // Wrap in a column-flex container so the interleaved chain-of-thought (tool
384
+ // calls + partial text in arrival order, via ccSegmentsRender) stacks on top
385
+ // and the progress block sits at the bottom. Overrides the parent
386
+ // .modal-qa-loading row-flex (right for the simple "Thinking..." state but
387
+ // wrong once tools/streamed text appear).
387
388
  let html = '<div style="display:flex;flex-direction:column;align-items:stretch;gap:6px;width:100%">';
388
- if (toolsUsed && toolsUsed.length > 0) {
389
- html += '<div style="display:flex;flex-direction:column;gap:2px">';
390
- toolsUsed.forEach(function(t) {
391
- const name = typeof t === 'string' ? t : t.name;
392
- const input = typeof t === 'string' ? {} : (t.input || {});
393
- html += '<div style="color:var(--muted);font-size:var(--text-sm);font-family:monospace;display:flex;align-items:flex-start;gap:6px"><span style="flex-shrink:0">&#9679;</span><span style="word-break:break-all">' + formatToolSummary(name, input) + '</span></div>';
394
- });
395
- html += '</div>';
396
- }
397
- if (streamedText) html += '<div>' + renderMd(streamedText) + '</div>';
389
+ const bodyHtml = ccSegmentsRender(segments || [], { keyPrefix: loadingId });
390
+ if (bodyHtml) html += bodyHtml;
398
391
  html += '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">' +
399
392
  '<span class="dot-pulse"><span></span><span></span><span></span></span>' +
400
393
  '<span id="' + loadingId + '-text">' + escHtml(label) + '</span>' +
@@ -629,8 +622,11 @@ async function _processQaMessage(message, selection, opts) {
629
622
  const qaPhases = isPlanEdit
630
623
  ? [[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...']]
631
624
  : [[0,'Thinking...'],[3000,'Reading document...'],[8000,'Analyzing...'],[20000,'Still working...'],[60000,'Taking a while...']];
632
- let streamedText = '';
633
- let toolsUsed = [];
625
+ // Ordered text↔tool segments for the live progress view (replaces the old
626
+ // flat streamedText + toolsUsed buckets). Rendered interleaved via
627
+ // ccSegmentsRender; see render-utils.js. The final answer bubble still
628
+ // collapses to the authoritative evt.text (doc-chat returns one answer).
629
+ let segments = [];
634
630
  let _qaStreamStalled = false;
635
631
  let _qaStallTimer = null;
636
632
  function _clearQaStreamWatchdog() {
@@ -666,8 +662,7 @@ async function _processQaMessage(message, selection, opts) {
666
662
  loadingId,
667
663
  _qaProgressLabel(elapsed),
668
664
  Math.floor(elapsed / 1000),
669
- streamedText,
670
- toolsUsed,
665
+ segments,
671
666
  runtime.queue.length
672
667
  );
673
668
  });
@@ -730,13 +725,19 @@ async function _processQaMessage(message, selection, opts) {
730
725
  }
731
726
  if (evt.type === 'chunk') {
732
727
  _resetQaStreamWatchdog();
733
- streamedText = evt.text || '';
728
+ ccSegmentsApplyChunk(segments, evt.text || '', evt.segmentId);
734
729
  _qaRenderProgress(true);
735
730
  return;
736
731
  }
737
732
  if (evt.type === 'tool') {
738
733
  _resetQaStreamWatchdog();
739
- toolsUsed.push({ name: evt.name, input: evt.input || {} });
734
+ ccSegmentsApplyTool(segments, { name: evt.name, input: evt.input || {}, id: evt.id || null });
735
+ _qaRenderProgress(true);
736
+ return;
737
+ }
738
+ if (evt.type === 'tool-update') {
739
+ _resetQaStreamWatchdog();
740
+ ccSegmentsApplyToolUpdate(segments, evt.id, evt.status);
740
741
  _qaRenderProgress(true);
741
742
  return;
742
743
  }
@@ -760,7 +761,7 @@ async function _processQaMessage(message, selection, opts) {
760
761
  const suffix = evt.edited ? '\n\n\u2713 Document saved.' : '';
761
762
  // Fall back to the live-streamed text when the backend produced no final
762
763
  // answer \u2014 covers the "stream had visible chunks then returned empty" case.
763
- const finalText = (evt.text && evt.text.trim()) ? evt.text : (streamedText || '');
764
+ const finalText = (evt.text && evt.text.trim()) ? evt.text : ccSegmentsText(segments);
764
765
  let bodyText = finalText + suffix;
765
766
  if (evt.partial && evt.warning) {
766
767
  bodyText += '\n\n_' + evt.warning + '_';
@@ -872,23 +873,24 @@ async function _processQaMessage(message, selection, opts) {
872
873
  _clearQaStreamWatchdog();
873
874
  const qaElapsedExc = Math.round((Date.now() - qaStartTime) / 1000);
874
875
  const stallMessage = 'Doc chat stalled with no tool or text progress for 6 minutes.';
876
+ const qaPartialText = ccSegmentsText(segments);
875
877
  const messageHtml = _qaStreamStalled
876
- ? (streamedText
877
- ? _qaBuildAssistantHtml(streamedText + '\n\nError: ' + stallMessage, { borderColor: 'var(--red)', elapsed: qaElapsedExc })
878
+ ? (qaPartialText
879
+ ? _qaBuildAssistantHtml(qaPartialText + '\n\nError: ' + stallMessage, { borderColor: 'var(--red)', elapsed: qaElapsedExc })
878
880
  : _qaBuildAssistantHtml('Error: ' + stallMessage, { color: 'var(--red)', isError: true, elapsed: qaElapsedExc }))
879
881
  : e.name === 'AbortError'
880
- ? (streamedText
881
- ? _qaBuildAssistantHtml(streamedText + '\n\n_Stopped._', { borderColor: 'var(--muted)', elapsed: qaElapsedExc })
882
+ ? (qaPartialText
883
+ ? _qaBuildAssistantHtml(qaPartialText + '\n\n_Stopped._', { borderColor: 'var(--muted)', elapsed: qaElapsedExc })
882
884
  : _qaBuildAssistantHtml('Stopped', { color: 'var(--muted)', isError: true, elapsed: qaElapsedExc }))
883
- : (streamedText
884
- ? _qaBuildAssistantHtml(streamedText + '\n\nError: ' + e.message, { borderColor: 'var(--red)', elapsed: qaElapsedExc })
885
+ : (qaPartialText
886
+ ? _qaBuildAssistantHtml(qaPartialText + '\n\nError: ' + e.message, { borderColor: 'var(--red)', elapsed: qaElapsedExc })
885
887
  : _qaBuildAssistantHtml('Error: ' + e.message, { color: 'var(--red)', isError: true, elapsed: qaElapsedExc }));
886
888
  const updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
887
889
  const loadingEl = tmp.querySelector('#' + loadingId);
888
890
  if (loadingEl) loadingEl.remove();
889
891
  _qaInsertBeforeQueued(tmp, messageHtml);
890
892
  });
891
- if (e.name === 'AbortError' && _qaRecordAbortedPartial(runtime, message, streamedText) && _qaIsActiveSession(sessionKey)) {
893
+ if (e.name === 'AbortError' && _qaRecordAbortedPartial(runtime, message, qaPartialText) && _qaIsActiveSession(sessionKey)) {
892
894
  _qaHistory = runtime.history.slice();
893
895
  }
894
896
  _qaFlushPersistDebounce(sessionKey);
@@ -71,6 +71,144 @@ function formatToolSummary(name, input) {
71
71
  }
72
72
  }
73
73
 
74
+ // ─── Ordered text↔tool segments (CC + doc-chat live streams) ───────────────
75
+ //
76
+ // A single assistant turn can interleave many text blocks with tool calls
77
+ // (text → tool → text → tool → final text, up to ccMaxTurns). The old model
78
+ // flattened that into "all tool chips first, then one merged text blob", which
79
+ // destroyed the chronological structure and welded back-to-back assistant
80
+ // messages together. The segment model preserves arrival order: each entry is
81
+ // a { kind:'text', id, text } or { kind:'tool', id, name, input, status }
82
+ // record, rendered in sequence so prose and tool activity stay interleaved.
83
+ //
84
+ // Text-segment boundaries come from the server `segmentId` on chunk events
85
+ // (engine/llm.js increments it on a tool boundary or a non-extending text
86
+ // push). When segmentId is absent (Copilot pool path, live-stream replay,
87
+ // older engine) ccSegmentsApplyChunk falls back to a heuristic: a chunk that
88
+ // arrives after a tool starts a new text segment; a chunk that extends the
89
+ // current text segment updates it in place. Boundaries are never inferred from
90
+ // character overlap, so unrelated chunks can never weld (cf. W-mq1jwwqo).
91
+
92
+ function _ccSegMergeText(prev, next) {
93
+ if (!prev) return next || '';
94
+ if (!next) return prev;
95
+ if (next === prev) return prev;
96
+ if (next.indexOf(prev) === 0) return next; // accumulated buffer grew
97
+ if (prev.indexOf(next) === 0) return prev; // stale/short repaint of same text
98
+ return prev + '\n\n' + next; // distinct text — separate, never weld
99
+ }
100
+
101
+ // The trailing text segment of the current run, or null if a tool closed the
102
+ // run (so the next text belongs to a fresh segment).
103
+ function ccSegmentsLastText(segments) {
104
+ for (var i = segments.length - 1; i >= 0; i--) {
105
+ if (segments[i].kind === 'text') return segments[i];
106
+ if (segments[i].kind === 'tool') return null;
107
+ }
108
+ return null;
109
+ }
110
+
111
+ function ccSegmentsApplyChunk(segments, text, segmentId) {
112
+ if (!text) return segments;
113
+ var hasId = segmentId !== undefined && segmentId !== null;
114
+ var last = segments.length ? segments[segments.length - 1] : null;
115
+ if (hasId) {
116
+ if (last && last.kind === 'text' && last.id === segmentId) { last.text = text; return segments; }
117
+ segments.push({ kind: 'text', id: segmentId, text: text });
118
+ return segments;
119
+ }
120
+ // Heuristic fallback: extend the open text segment, else open a new one.
121
+ if (last && last.kind === 'text') { last.text = _ccSegMergeText(last.text, text); return segments; }
122
+ segments.push({ kind: 'text', id: null, text: text });
123
+ return segments;
124
+ }
125
+
126
+ function ccSegmentsApplyTool(segments, tool) {
127
+ segments.push({
128
+ kind: 'tool',
129
+ id: (tool && tool.id) || null,
130
+ name: tool && tool.name,
131
+ input: (tool && tool.input) || {},
132
+ status: tool && (tool.id ? (tool.status || 'pending') : (tool.status || null)),
133
+ });
134
+ return segments;
135
+ }
136
+
137
+ function ccSegmentsApplyToolUpdate(segments, id, status) {
138
+ for (var i = segments.length - 1; i >= 0; i--) {
139
+ if (segments[i].kind === 'tool' && segments[i].id === id) { segments[i].status = status; break; }
140
+ }
141
+ return segments;
142
+ }
143
+
144
+ // Reconcile streamed segments with the authoritative final text (Claude's
145
+ // `result` event carries the LAST assistant message and arrives via setText
146
+ // with no chunk). Ensures the trailing text segment matches the final answer
147
+ // without discarding earlier interleaved segments.
148
+ function ccSegmentsFinalize(segments, finalText) {
149
+ if (!finalText) return segments;
150
+ var last = ccSegmentsLastText(segments);
151
+ if (last) last.text = _ccSegMergeText(last.text, finalText);
152
+ else segments.push({ kind: 'text', id: null, text: finalText });
153
+ return segments;
154
+ }
155
+
156
+ // Expanded-state for collapsible tool groups, keyed by a caller-supplied stable
157
+ // key (tab id / message id + group ordinal). Persisted here — outside the DOM —
158
+ // so the live stream's full re-render every frame doesn't reset a group the user
159
+ // expanded mid-stream. Native <details> drives the toggle; this just remembers.
160
+ var _ccToolGroupOpen = Object.create(null);
161
+ function ccToolGroupToggle(el, key) {
162
+ if (!el) return;
163
+ if (el.open) _ccToolGroupOpen[key] = true; else delete _ccToolGroupOpen[key];
164
+ }
165
+
166
+ // Render segments in chronological order. Each text segment becomes its own
167
+ // markdown block. A run of >1 tool chips collapses into a <details> showing only
168
+ // the first chip (+ "N more"); expanding reveals the rest. opts.keyPrefix gives
169
+ // each group a stable key so the expanded state survives live-stream re-renders.
170
+ function ccSegmentsRender(segments, opts) {
171
+ if (!segments || !segments.length) return '';
172
+ var keyPrefix = (opts && opts.keyPrefix) || '';
173
+ var html = '';
174
+ var i = 0;
175
+ var groupOrdinal = 0;
176
+ while (i < segments.length) {
177
+ if (segments[i].kind === 'tool') {
178
+ var chips = [];
179
+ while (i < segments.length && segments[i].kind === 'tool') { chips.push(renderToolChip(segments[i])); i++; }
180
+ if (chips.length <= 1) {
181
+ html += '<div class="cc-tool-group">' + chips.join('') + '</div>';
182
+ } else {
183
+ var groupKey = keyPrefix + ':tg' + groupOrdinal;
184
+ var openAttr = _ccToolGroupOpen[groupKey] ? ' open' : '';
185
+ html += '<details class="cc-tool-group cc-tool-collapsible"' + openAttr +
186
+ ' ontoggle="ccToolGroupToggle(this,\'' + groupKey + '\')">' +
187
+ '<summary class="cc-tool-summary">' + chips[0] +
188
+ '<span class="cc-tool-more">+' + (chips.length - 1) + ' more</span>' +
189
+ '<span class="cc-tool-less">show less</span></summary>' +
190
+ '<div class="cc-tool-rest">' + chips.slice(1).join('') + '</div>' +
191
+ '</details>';
192
+ }
193
+ groupOrdinal++;
194
+ } else {
195
+ if (segments[i].text) html += '<div class="cc-text-segment">' + renderMd(segments[i].text) + '</div>';
196
+ i++;
197
+ }
198
+ }
199
+ return html;
200
+ }
201
+
202
+ // Plain-text join of all text segments — for tab titles, copy, transcripts.
203
+ function ccSegmentsText(segments) {
204
+ if (!segments || !segments.length) return '';
205
+ var parts = [];
206
+ for (var i = 0; i < segments.length; i++) {
207
+ if (segments[i].kind === 'text' && segments[i].text) parts.push(segments[i].text);
208
+ }
209
+ return parts.join('\n\n');
210
+ }
211
+
74
212
  /**
75
213
  * Internal helper: renders a single parsed JSON object into an HTML fragment.
76
214
  * @param {object} obj - Parsed JSON object from agent JSONL output
@@ -301,10 +301,47 @@ function _renderMdCore(s) {
301
301
  // 3. Block-level processing (line by line)
302
302
  var lines = html.split('\n');
303
303
  var out = [];
304
- var inList = false;
305
- var listType = '';
306
-
307
- function closeList() { if (inList) { out.push(listType === 'ol' ? '</ol>' : '</ul>'); inList = false; } }
304
+ // Nested-list state: a stack of { tag:'ul'|'ol', indent } frames, each owning
305
+ // one currently-open <li>. Deeper-indented items nest inside the parent <li>;
306
+ // shallower items pop levels. Single-level lists behave exactly as before.
307
+ var listStack = [];
308
+ // Paragraph buffer: consecutive plain-text lines coalesce into one <p>-style
309
+ // block (soft line breaks via <br>) so paragraphs get real vertical spacing
310
+ // instead of each wrapped line becoming its own gap-less <div>.
311
+ var paraBuf = [];
312
+
313
+ function flushPara() {
314
+ if (!paraBuf.length) return;
315
+ out.push('<div class="md-p">' + paraBuf.join('<br>') + '</div>');
316
+ paraBuf = [];
317
+ }
318
+ function closeLevel() {
319
+ var top = listStack.pop();
320
+ out.push('</li>');
321
+ out.push(top.tag === 'ol' ? '</ol>' : '</ul>');
322
+ }
323
+ function closeList() { while (listStack.length) closeLevel(); }
324
+ function _openListTag(tag, isCb) {
325
+ var style = tag === 'ol'
326
+ ? 'margin:2px 0 2px 20px;padding:0'
327
+ : (isCb ? 'margin:2px 0 2px 16px;padding:0;list-style:none' : 'margin:2px 0 2px 16px;padding:0');
328
+ return '<' + tag + ' style="' + style + '">';
329
+ }
330
+ // Append a list item at the given indent, opening/closing/nesting lists as
331
+ // needed. `isCb` only affects styling of a freshly opened <ul>.
332
+ function pushListItem(indent, tag, liInner, isCb) {
333
+ flushPara();
334
+ while (listStack.length && listStack[listStack.length - 1].indent > indent) closeLevel();
335
+ if (listStack.length && listStack[listStack.length - 1].indent === indent) {
336
+ if (listStack[listStack.length - 1].tag === tag) {
337
+ out.push('</li>'); out.push('<li>' + liInner); return;
338
+ }
339
+ closeLevel(); // same indent, different list type → switch
340
+ }
341
+ out.push(_openListTag(tag, isCb));
342
+ listStack.push({ tag: tag, indent: indent });
343
+ out.push('<li>' + liInner);
344
+ }
308
345
  function nextNonEmptyLine(startIdx) {
309
346
  for (var ni = startIdx; ni < lines.length; ni++) {
310
347
  if (lines[ni].trim()) return lines[ni];
@@ -312,28 +349,22 @@ function _renderMdCore(s) {
312
349
  return '';
313
350
  }
314
351
  function continuesCurrentList(nextLine) {
315
- if (!inList || !nextLine) return false;
316
- if (listType === 'ol') return /^(\s*)\d+\.\s+(.+)/.test(nextLine);
317
- return /^(\s*)[-*]\s+(.+)/.test(nextLine);
318
- }
319
- function openList(type) {
320
- if (inList && listType !== type) closeList();
321
- if (!inList) {
322
- var style = type === 'ol' ? 'margin:2px 0 2px 20px;padding:0' : type === 'cb' ? 'margin:2px 0 2px 16px;padding:0;list-style:none' : 'margin:2px 0 2px 16px;padding:0';
323
- out.push('<' + (type === 'ol' ? 'ol' : 'ul') + ' style="' + style + '">');
324
- inList = true; listType = type === 'cb' ? 'ul' : type;
325
- }
352
+ if (!listStack.length || !nextLine) return false;
353
+ // Any list item (of any type/indent) keeps the list region open across a
354
+ // blank line; the item handler manages level transitions.
355
+ return /^(\s*)\d+\.\s+(.+)/.test(nextLine) || /^(\s*)[-*]\s+(.+)/.test(nextLine);
326
356
  }
327
357
 
328
358
  for (var i = 0; i < lines.length; i++) {
329
359
  var line = lines[i];
330
360
 
331
361
  // Code block placeholder — pass through as-is
332
- if (line.match(/^\x00CB\d+\x00$/)) { closeList(); out.push(line); continue; }
362
+ if (line.match(/^\x00CB\d+\x00$/)) { flushPara(); closeList(); out.push(line); continue; }
333
363
 
334
364
  // Headings
335
365
  var headMatch = line.match(/^(#{1,4})\s+(.+)/);
336
366
  if (headMatch) {
367
+ flushPara();
337
368
  closeList();
338
369
  var sizes = { 1: '16px', 2: '14px', 3: '13px', 4: '12px' };
339
370
  out.push('<div style="font-weight:600;font-size:' + sizes[headMatch[1].length] + ';margin:8px 0 4px">' + headMatch[2] + '</div>');
@@ -342,6 +373,7 @@ function _renderMdCore(s) {
342
373
 
343
374
  // Horizontal rule (only bare ---, ***, ___ lines)
344
375
  if (/^[-*_]{3,}\s*$/.test(line) && !/\S/.test(line.replace(/[-*_]/g, ''))) {
376
+ flushPara();
345
377
  closeList();
346
378
  out.push('<hr style="border:none;border-top:1px solid var(--border);margin:8px 0">');
347
379
  continue;
@@ -349,6 +381,7 @@ function _renderMdCore(s) {
349
381
 
350
382
  // Blockquote
351
383
  if (line.match(/^&gt;\s?/)) {
384
+ flushPara();
352
385
  closeList();
353
386
  out.push('<div style="border-left:3px solid var(--border);padding-left:8px;color:var(--muted);margin:2px 0">' + line.replace(/^(&gt;\s?)+/, '') + '</div>');
354
387
  continue;
@@ -356,6 +389,7 @@ function _renderMdCore(s) {
356
389
 
357
390
  // Table: detect | col | col | rows, consume until non-table line
358
391
  if (line.match(/^\|.+\|/)) {
392
+ flushPara();
359
393
  closeList();
360
394
  var tableRows = [];
361
395
  var sepIdx = -1;
@@ -384,40 +418,39 @@ function _renderMdCore(s) {
384
418
  // Checkbox list (must come before UL — both start with - )
385
419
  var cbMatch = line.match(/^(\s*)[-*]\s\[([ xX])\]\s(.+)/);
386
420
  if (cbMatch) {
387
- openList('cb');
388
- out.push('<li>' + (cbMatch[2] !== ' ' ? '\u2611' : '\u2610') + ' ' + cbMatch[3] + '</li>');
421
+ pushListItem(cbMatch[1].length, 'ul', (cbMatch[2] !== ' ' ? '\u2611' : '\u2610') + ' ' + cbMatch[3], true);
389
422
  continue;
390
423
  }
391
424
 
392
425
  // Unordered list (- or * followed by space and content, not bare --- or ***)
393
426
  var ulMatch = line.match(/^(\s*)[-*]\s+(.+)/);
394
427
  if (ulMatch) {
395
- openList('ul');
396
- out.push('<li>' + ulMatch[2] + '</li>');
428
+ pushListItem(ulMatch[1].length, 'ul', ulMatch[2], false);
397
429
  continue;
398
430
  }
399
431
 
400
432
  // Ordered list
401
433
  var olMatch = line.match(/^(\s*)\d+\.\s+(.+)/);
402
434
  if (olMatch) {
403
- openList('ol');
404
- out.push('<li>' + olMatch[2] + '</li>');
435
+ pushListItem(olMatch[1].length, 'ol', olMatch[2], false);
405
436
  continue;
406
437
  }
407
438
 
408
439
  // Blank line → spacer
409
440
  if (!line.trim()) {
441
+ flushPara();
410
442
  if (continuesCurrentList(nextNonEmptyLine(i + 1))) continue;
411
443
  closeList();
412
444
  out.push('<div style="height:4px"></div>');
413
445
  continue;
414
446
  }
415
447
 
416
- // Non-list line — close any open list
448
+ // Non-list text line — close any open list, then buffer into the current
449
+ // paragraph (flushed at the next block boundary / blank line).
417
450
  closeList();
418
-
419
- out.push('<div>' + line + '</div>');
451
+ paraBuf.push(line);
420
452
  }
453
+ flushPara();
421
454
  closeList();
422
455
  html = out.join('\n');
423
456
 
@@ -995,6 +995,35 @@
995
995
  .md-content th, .md-content td { padding: 4px 8px; border: 1px solid var(--border); text-align: left; font-size: var(--text-base); white-space: nowrap; }
996
996
  .md-content td { white-space: normal; min-width: 60px; }
997
997
  .md-content th { background: var(--surface); font-weight: 600; }
998
+ /* Real paragraph spacing (md-p groups consecutive text lines) + tightened
999
+ nested-list margins so multi-level lists read cleanly in the narrow drawer. */
1000
+ .md-content .md-p { margin: 0 0 6px; }
1001
+ .md-content .md-p:last-child { margin-bottom: 0; }
1002
+ .md-content li { margin: 1px 0; }
1003
+ .md-content ul ul, .md-content ul ol, .md-content ol ul, .md-content ol ol { margin-top: 2px; margin-bottom: 2px; }
1004
+ .md-content pre { max-width: 100%; }
1005
+ /* Interleaved CC / doc-chat segments: prose is the primary content; tool
1006
+ activity is grouped, dimmed and indented behind a rule so it recedes. */
1007
+ .cc-tool-group { margin: 4px 0; padding-left: 8px; border-left: 2px solid var(--border); opacity: 0.85; display: flex; flex-direction: column; gap: 2px; }
1008
+ .cc-tool-group > div { line-height: 1.4; }
1009
+ /* Collapsible tool group: <details> shows only the first chip; expanding
1010
+ reveals the rest. Native <details> drives the toggle so the final (static)
1011
+ message needs no JS; the live stream persists open state via ccToolGroupToggle. */
1012
+ details.cc-tool-group { display: block; }
1013
+ .cc-tool-summary { list-style: none; cursor: pointer; display: flex; align-items: center; gap: 8px; line-height: 1.4; padding: 5px 6px; margin: -3px -6px; border-radius: 6px; min-height: 28px; -webkit-tap-highlight-color: transparent; }
1014
+ .cc-tool-summary:hover { background: var(--surface2); }
1015
+ .cc-tool-summary::-webkit-details-marker { display: none; }
1016
+ /* Bigger tap targets — the hints are small text, so pad them into pill-sized
1017
+ hit areas (the whole summary row is clickable too). */
1018
+ .cc-tool-more, .cc-tool-less { font-size: var(--text-sm); white-space: nowrap; padding: 3px 9px; border-radius: 11px; border: 1px solid var(--border); background: var(--surface2); }
1019
+ .cc-tool-more { color: var(--muted); }
1020
+ .cc-tool-less { display: none; color: var(--blue); }
1021
+ details.cc-tool-group[open] .cc-tool-more { display: none; }
1022
+ details.cc-tool-group[open] .cc-tool-less { display: inline-block; }
1023
+ .cc-tool-rest { display: flex; flex-direction: column; gap: 2px; margin-top: 2px; }
1024
+ .cc-text-segment { margin: 2px 0; }
1025
+ .cc-text-segment:first-child { margin-top: 0; }
1026
+ .cc-text-segment + .cc-tool-group, .cc-tool-group + .cc-text-segment { margin-top: 6px; }
998
1027
  .status-line { display: flex; align-items: center; gap: var(--space-5); padding: var(--space-5) var(--space-7); background: var(--bg); border-bottom: 1px solid var(--border); font-size: var(--text-md); }
999
1028
 
1000
1029
  /* Modal for inbox detail */
package/dashboard.js CHANGED
@@ -1269,6 +1269,12 @@ function _scanProjectLocalHarnessFootgun(project) {
1269
1269
  encoding: 'utf8',
1270
1270
  stdio: ['ignore', 'pipe', 'ignore'],
1271
1271
  timeout: 10000,
1272
+ // W-mqecdoot — MUST set windowsHide. This runs per-project on every
1273
+ // GET /api/harness/diagnostics hit (Tools/Harness page, polled), and a
1274
+ // detached dashboard has no console — so without this each call pops a
1275
+ // visible git.exe console window per project ("infinite git windows").
1276
+ // This was the only git spawn in the codebase missing windowsHide.
1277
+ windowsHide: true,
1272
1278
  });
1273
1279
  } catch { return out; }
1274
1280
  if (!raw) return out;
@@ -5058,10 +5064,10 @@ function _finalizeDocChatEdit({ filePath, fullPath, isJson, canEdit, originalCon
5058
5064
  function _makeDocChatStreamStripper(onChunk) {
5059
5065
  if (!onChunk) return undefined;
5060
5066
  let lastSent;
5061
- return (text) => {
5067
+ return (text, segmentId) => {
5062
5068
  if (text === lastSent) return;
5063
5069
  lastSent = text;
5064
- onChunk(text);
5070
+ onChunk(text, segmentId);
5065
5071
  };
5066
5072
  }
5067
5073
 
@@ -8160,12 +8166,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8160
8166
  }
8161
8167
  let wire;
8162
8168
  try {
8163
- // W-mpmwxni2000c25c7-d — terminal error frames go out as `event: error`
8164
- // so SSE consumers using addEventListener('error', …) can target them.
8165
- // The JSON payload still carries `type: 'error'` for the data-line
8166
- // parser in modal-qa.js.
8167
- const eventLine = (type === 'error') ? 'event: error\n' : '';
8168
- wire = eventLine + 'data: ' + JSON.stringify(payload) + '\n\n';
8169
+ // W-mpmwxni2000c25c7-d / W-mqevl09s000i9989 — terminal error frames go
8170
+ // out as a named SSE `event: error` frame so consumers using
8171
+ // addEventListener('error', …) can target them, mirroring the
8172
+ // handleCommandCenterStream contract. The JSON payload still carries
8173
+ // `type: 'error'` for the data-line parser in modal-qa.js.
8174
+ wire = (type === 'error')
8175
+ ? `event: error\ndata: ${JSON.stringify(payload)}\n\n`
8176
+ : `data: ${JSON.stringify(payload)}\n\n`;
8169
8177
  } catch {
8170
8178
  _logFail('json-serialize-failed');
8171
8179
  return false;
@@ -8286,7 +8294,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8286
8294
  freshSession: !!body.freshSession,
8287
8295
  transcript: body.transcript,
8288
8296
  onAbortReady: (abort) => { _docAbort = abort; },
8289
- onChunk: (text) => { bumpTimer(); writeDocEvent({ type: 'chunk', text }); },
8297
+ onChunk: (text, segmentId) => { bumpTimer(); writeDocEvent({ type: 'chunk', text, segmentId }); },
8290
8298
  onToolUse: (name, input) => { bumpTimer(); writeDocEvent({ type: 'tool', name, input: _lightToolInput(input) }); },
8291
8299
  onRetry: (attempt) => { bumpTimer(); writeDocEvent({ type: 'progress', attempt }); },
8292
8300
  systemPrompt: turnSystemPrompt,
@@ -9063,10 +9071,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9063
9071
  allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
9064
9072
  sessionId, effort, direct: true,
9065
9073
  engineConfig,
9066
- onChunk: (text) => {
9074
+ onChunk: (text, segmentId) => {
9067
9075
  _touchCcLiveStream(liveState);
9068
9076
  liveState.text = text;
9069
- if (liveState.writer) liveState.writer({ type: 'chunk', text });
9077
+ if (liveState.writer) liveState.writer({ type: 'chunk', text, segmentId });
9070
9078
  },
9071
9079
  onToolUse: (name, input) => {
9072
9080
  _touchCcLiveStream(liveState);
package/engine/cleanup.js CHANGED
@@ -637,7 +637,30 @@ async function runCleanup(config, verbose = false) {
637
637
  for (const entry of entries) {
638
638
  if (!entry.isDirectory()) continue;
639
639
  const full = path.resolve(wtRoot, entry.name);
640
+ // Defensive boundary: only ever delete strictly inside the worktree
641
+ // root we enumerated. readdirSync never yields `..`, so this is always
642
+ // true today — but it makes the "never escape wtRoot" invariant
643
+ // explicit and immune to a future refactor of how `full` is derived.
644
+ if (full !== wtRoot && !full.startsWith(wtRoot + path.sep)) continue;
640
645
  if (registered.has(full)) continue;
646
+ // W-mqecdoot — ownership gate, parity with the out-of-root worktree GC
647
+ // (worktree-gc.pruneOrphanWorktreesFromGitRegistry). Only ever reap a
648
+ // dir the engine positively created (carries the `.minions-worktree`
649
+ // marker stamped right after `git worktree add`). A dir with NO marker
650
+ // is a human's hand-made worktree (`git worktree add ../worktrees/mine`)
651
+ // or some unrelated folder dropped into the worktree root — KEEP it,
652
+ // never delete. Fail-open: leak a husk rather than nuke someone's
653
+ // unpushed work. This closes the same data-loss class the marker fixed
654
+ // for the registry sweep, for this on-disk-dir sweep too. The cost is
655
+ // that empty leftover husks whose marker was already removed leak
656
+ // instead of being reaped — an acceptable trade for "absolutely never
657
+ // delete foreign data".
658
+ let owned = false;
659
+ try { owned = !!shared.hasWorktreeOwnerMarker(full); } catch { owned = false; }
660
+ if (!owned) {
661
+ log('debug', `Cleanup: keeping orphan worktree dir ${full} — no engine ownership marker (foreign/hand-made worktree)`);
662
+ continue;
663
+ }
641
664
  let stat; try { stat = fs.statSync(full); } catch { continue; }
642
665
  if (stat.mtimeMs >= _twoHoursAgo) continue;
643
666
  // W-mq5rwwss000f30a7 — even an "orphan" dir (one git doesn't know
package/engine/llm.js CHANGED
@@ -478,6 +478,14 @@ function _createStreamAccumulator({
478
478
  let taskCompleteFired = false;
479
479
  let terminalResultFired = false;
480
480
  let lastTaskCompleteSummary = '';
481
+ // Ordered text-segment index threaded to onChunk so the client can interleave
482
+ // distinct assistant text blocks with tool calls instead of welding them into
483
+ // one bubble. Bumped when a tool ran since the last text push (text after a
484
+ // tool is a new block) or when a push doesn't extend the current segment
485
+ // (a fresh assistant message after a stream reset). Consumed by the CC /
486
+ // doc-chat segment renderer (dashboard/js/render-utils.js ccSegments*).
487
+ let segmentSeq = 0;
488
+ let sawToolSinceText = false;
481
489
  const toolUses = [];
482
490
 
483
491
  function _streamText(value) {
@@ -492,8 +500,12 @@ function _createStreamAccumulator({
492
500
  const next = _streamText(value);
493
501
  text = next;
494
502
  if (onChunk && next !== lastTextSent) {
503
+ // New segment when a tool ran since the last text, or this push is not a
504
+ // continuation of the current segment (a fresh message after a reset).
505
+ if (lastTextSent && (sawToolSinceText || !next.startsWith(lastTextSent))) segmentSeq++;
506
+ sawToolSinceText = false;
495
507
  lastTextSent = next;
496
- onChunk(next);
508
+ onChunk(next, segmentSeq);
497
509
  }
498
510
  },
499
511
  setText(value) {
@@ -518,6 +530,7 @@ function _createStreamAccumulator({
518
530
  if (!name) return;
519
531
  const toolUse = { name, input: input || {} };
520
532
  toolUses.push(toolUse);
533
+ sawToolSinceText = true;
521
534
  if (onToolUse) onToolUse(toolUse.name, toolUse.input);
522
535
  },
523
536
  toolUseAlreadySeen(name, input) {
@@ -539,8 +552,10 @@ function _createStreamAccumulator({
539
552
  if (!text) {
540
553
  text = finalSummary;
541
554
  if (onChunk && finalSummary !== lastTextSent) {
555
+ if (lastTextSent && (sawToolSinceText || !finalSummary.startsWith(lastTextSent))) segmentSeq++;
556
+ sawToolSinceText = false;
542
557
  lastTextSent = finalSummary;
543
- onChunk(finalSummary);
558
+ onChunk(finalSummary, segmentSeq);
544
559
  }
545
560
  }
546
561
  if (!alreadySeen && onTaskComplete) {
@@ -795,7 +810,9 @@ function callLLM(promptText, sysPromptText, opts = {}) {
795
810
  /**
796
811
  * Streaming variant of callLLM — emits text chunks via onChunk callback.
797
812
  * Returns the same result object as callLLM when the process completes.
798
- * onChunk(text) is called for each assistant text block as it arrives.
813
+ * onChunk(text, segmentIndex) is called for each assistant text block as it
814
+ * arrives; segmentIndex increments across distinct text blocks so consumers can
815
+ * keep them visually separate instead of welding them together.
799
816
  */
800
817
  function callLLMStreaming(promptText, sysPromptText, opts = {}) {
801
818
  const {
@@ -27,6 +27,7 @@ function _execImpl(cmd) {
27
27
  encoding: 'utf8',
28
28
  stdio: ['ignore', 'pipe', 'ignore'],
29
29
  timeout: 5000,
30
+ windowsHide: true,
30
31
  })).trim();
31
32
  } catch {
32
33
  return '';
package/engine/shared.js CHANGED
@@ -7715,6 +7715,37 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
7715
7715
  log('warn', `removeWorktree: refusing to remove ${wtPath} — not under ${worktreeRoot}`);
7716
7716
  return false;
7717
7717
  }
7718
+ // ── Data-loss hardening (W-mqecdoot) — NEVER recursively delete a real git
7719
+ // main repository. ──────────────────────────────────────────────────────
7720
+ // The containment check above only proves `resolved` is *under* the
7721
+ // caller-supplied `worktreeRoot`. Several callers derive that root from
7722
+ // `config.engine.worktreeRoot` (default '../worktrees') — e.g. the cleanup
7723
+ // orphan-reaper and projects.removeProject §3 readdir the root and call us on
7724
+ // every child. A misconfigured `worktreeRoot` like '..' (or a project
7725
+ // localPath placed one level too high) makes a sibling REAL repo pass
7726
+ // containment; `git worktree remove --force` then FAILS (it isn't a linked
7727
+ // worktree) and we fall through to fs.rmSync / `rd /s /q`, nuking the repo
7728
+ // and its history. This is the reported incident (.git + repo dir gone). Two
7729
+ // refusals below, both with zero false positives for legitimate linked-
7730
+ // worktree removal:
7731
+ // 1. The target IS the git root we were handed (deleting the repo itself).
7732
+ // 2. The target carries its own `.git` DIRECTORY. A linked worktree's
7733
+ // `.git` is ALWAYS a FILE (a `gitdir:` pointer); only a main checkout
7734
+ // has a `.git` directory — so this can never block a real worktree wipe.
7735
+ try {
7736
+ if (gitRoot && resolved === path.resolve(String(gitRoot))) {
7737
+ log('warn', `removeWorktree: refusing to remove ${wtPath} — it is the project git root, not a worktree`);
7738
+ return false;
7739
+ }
7740
+ } catch { /* bad gitRoot — fall through to the .git probe */ }
7741
+ try {
7742
+ const st = fs.lstatSync(path.join(resolved, '.git'));
7743
+ if (st && st.isDirectory()) {
7744
+ log('warn', `removeWorktree: refusing to remove ${wtPath} — it is a real git repo (.git is a directory, not a linked-worktree pointer)`);
7745
+ try { bumpWorktreeGcMetric('refusedRealRepo'); } catch { /* metric optional */ }
7746
+ return false;
7747
+ }
7748
+ } catch { /* no .git, or unreadable — normal worktree husk; continue */ }
7718
7749
  // W-mq5rwwss000f30a7 — never wipe a worktree while an agent is actively
7719
7750
  // dispatched inside it. isWorktreePathLive fails OPEN (returns true) when
7720
7751
  // the dispatches table is unreachable, so we err on the side of leaking
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2189",
3
+ "version": "0.1.2191",
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"