@yemi33/minions 0.1.2188 → 0.1.2190

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
@@ -5058,10 +5058,10 @@ function _finalizeDocChatEdit({ filePath, fullPath, isJson, canEdit, originalCon
5058
5058
  function _makeDocChatStreamStripper(onChunk) {
5059
5059
  if (!onChunk) return undefined;
5060
5060
  let lastSent;
5061
- return (text) => {
5061
+ return (text, segmentId) => {
5062
5062
  if (text === lastSent) return;
5063
5063
  lastSent = text;
5064
- onChunk(text);
5064
+ onChunk(text, segmentId);
5065
5065
  };
5066
5066
  }
5067
5067
 
@@ -8160,12 +8160,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8160
8160
  }
8161
8161
  let wire;
8162
8162
  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';
8163
+ // W-mpmwxni2000c25c7-d / W-mqevl09s000i9989 — terminal error frames go
8164
+ // out as a named SSE `event: error` frame so consumers using
8165
+ // addEventListener('error', …) can target them, mirroring the
8166
+ // handleCommandCenterStream contract. The JSON payload still carries
8167
+ // `type: 'error'` for the data-line parser in modal-qa.js.
8168
+ wire = (type === 'error')
8169
+ ? `event: error\ndata: ${JSON.stringify(payload)}\n\n`
8170
+ : `data: ${JSON.stringify(payload)}\n\n`;
8169
8171
  } catch {
8170
8172
  _logFail('json-serialize-failed');
8171
8173
  return false;
@@ -8286,7 +8288,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8286
8288
  freshSession: !!body.freshSession,
8287
8289
  transcript: body.transcript,
8288
8290
  onAbortReady: (abort) => { _docAbort = abort; },
8289
- onChunk: (text) => { bumpTimer(); writeDocEvent({ type: 'chunk', text }); },
8291
+ onChunk: (text, segmentId) => { bumpTimer(); writeDocEvent({ type: 'chunk', text, segmentId }); },
8290
8292
  onToolUse: (name, input) => { bumpTimer(); writeDocEvent({ type: 'tool', name, input: _lightToolInput(input) }); },
8291
8293
  onRetry: (attempt) => { bumpTimer(); writeDocEvent({ type: 'progress', attempt }); },
8292
8294
  systemPrompt: turnSystemPrompt,
@@ -9063,10 +9065,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9063
9065
  allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
9064
9066
  sessionId, effort, direct: true,
9065
9067
  engineConfig,
9066
- onChunk: (text) => {
9068
+ onChunk: (text, segmentId) => {
9067
9069
  _touchCcLiveStream(liveState);
9068
9070
  liveState.text = text;
9069
- if (liveState.writer) liveState.writer({ type: 'chunk', text });
9071
+ if (liveState.writer) liveState.writer({ type: 'chunk', text, segmentId });
9070
9072
  },
9071
9073
  onToolUse: (name, input) => {
9072
9074
  _touchCcLiveStream(liveState);
@@ -13560,13 +13562,28 @@ if (require.main === module) {
13560
13562
  // (engine/shared.js#openUrlInBrowser) now owns the env-var check and
13561
13563
  // emits a debug-level SUPPRESSED log entry so we can prove the kill-
13562
13564
  // switch is firing.
13563
- const result = shared.openUrlInBrowser(`http://localhost:${PORT}`, {
13564
- reason: 'dashboard-self-open',
13565
- callerHint: 'dashboard.js:13124',
13566
- });
13567
- if (!result.ok && !result.suppressed) {
13568
- console.log(` Could not auto-open browser: ${result.error}`);
13569
- console.log(` Please open http://localhost:${PORT} manually.`);
13565
+ //
13566
+ // W-mqef-dashboard-tty — only self-open for an interactive human run
13567
+ // (`node dashboard.js` in a terminal). The CLI spawns the dashboard
13568
+ // DETACHED + non-TTY with MINIONS_NO_AUTO_OPEN=1 and orchestrates the open
13569
+ // itself, so it never relied on this branch. But the dashboard integration
13570
+ // tests — and any agent running `npm test` spawn dashboard.js with piped
13571
+ // stdio and NO env guard, so this fired for real on every test run, popping
13572
+ // a browser tab to a random localhost port that the test then tore down
13573
+ // (blank window on the operator's desktop). stdout.isTTY is false for every
13574
+ // programmatic spawn (tests, agents, CI, the detached CLI dashboard) and
13575
+ // true only for a real terminal session, so it's the correct discriminator.
13576
+ if (process.stdout.isTTY) {
13577
+ const result = shared.openUrlInBrowser(`http://localhost:${PORT}`, {
13578
+ reason: 'dashboard-self-open',
13579
+ callerHint: 'dashboard.js:13124',
13580
+ });
13581
+ if (!result.ok && !result.suppressed) {
13582
+ console.log(` Could not auto-open browser: ${result.error}`);
13583
+ console.log(` Please open http://localhost:${PORT} manually.`);
13584
+ }
13585
+ } else {
13586
+ console.log(` Open http://localhost:${PORT} in your browser.`);
13570
13587
  }
13571
13588
 
13572
13589
  // Warm the CC runtime binary cache off the request path so the first CC /
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 {
package/engine.js CHANGED
@@ -3306,6 +3306,14 @@ async function spawnAgent(dispatchItem, config) {
3306
3306
  // them unconditionally regardless of repo host.
3307
3307
  childEnv.GIT_TERMINAL_PROMPT = '0';
3308
3308
  childEnv.GCM_INTERACTIVE = 'never';
3309
+ // W-mqef-dashboard-tty — agents run headless and must never pop a browser on
3310
+ // the operator's desktop. Stamp the suppression env so anything an agent runs
3311
+ // that funnels through shared.openUrlInBrowser — notably the dashboard a
3312
+ // `npm test` run boots — inherits the guard. The engine's own process env did
3313
+ // NOT carry this flag, so agent-spawned dashboards were self-opening to a
3314
+ // random localhost port (blank window). Belt-and-suspenders with dashboard.js's
3315
+ // stdout.isTTY gate.
3316
+ childEnv.MINIONS_NO_AUTO_OPEN = '1';
3309
3317
 
3310
3318
  if (getRepoHost(project) === 'ado') {
3311
3319
  // Inject cached ADO token so ADO agents skip re-authentication (#998).
@@ -3756,6 +3764,9 @@ async function spawnAgent(dispatchItem, config) {
3756
3764
  // credential dialogs on `git push` against stale PATs.
3757
3765
  childEnv.GIT_TERMINAL_PROMPT = '0';
3758
3766
  childEnv.GCM_INTERACTIVE = 'never';
3767
+ // W-mqef-dashboard-tty — same browser-popup suppression on steering resume
3768
+ // (see the initial spawn site). Agents must never auto-open a browser.
3769
+ childEnv.MINIONS_NO_AUTO_OPEN = '1';
3759
3770
  if (getRepoHost(project) === 'ado') {
3760
3771
  // Inject cached ADO token for steering session too (#998)
3761
3772
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2188",
3
+ "version": "0.1.2190",
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"