@yemi33/minions 0.1.1618 → 0.1.1620

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,11 +1,16 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1618 (2026-04-29)
3
+ ## 0.1.1620 (2026-04-29)
4
4
 
5
5
  ### Features
6
- - fix protected-file guard (#1858)
7
- - fix canonical active PR gate (#1857)
8
- - document PR auto-fix trigger precedence (#1855)
6
+ - progressive Claude token streaming + simplify-pass cleanup
7
+
8
+ ## 0.1.1619 (2026-04-29)
9
+
10
+ ### Features
11
+ - surface partial PR escalation state (#1856)
12
+
13
+ ## 0.1.1617 (2026-04-29)
9
14
 
10
15
  ### Fixes
11
16
  - guard stale build & conflict auto-fixes with live pre-dispatch check (#1851)
@@ -14,6 +14,22 @@ var _ccSending = false; // true if active tab is sending (UI indicator only)
14
14
  // Clear stale sending state on page load — SSE streams don't survive refresh
15
15
  try { localStorage.removeItem('cc-sending'); } catch {}
16
16
 
17
+ function _ccStripActionBlockFromText(value) {
18
+ var text = value || '';
19
+ if (!text) return text;
20
+ var full = /(?:^|\r?\n)===ACTIONS={0,3}[ \t]*(?=\r?\n|$)/m.exec(text);
21
+ if (full) return text.slice(0, full.index + full[0].indexOf('===ACTIONS')).trim();
22
+ var block = /(?:^|\r?\n)===ACTIONS\b[^\r\n]*(?=\r?\n|$)/m.exec(text);
23
+ if (block) return text.slice(0, block.index + block[0].indexOf('===ACTIONS')).trim();
24
+ var delimiter = '===ACTIONS===';
25
+ var lineStart = Math.max(text.lastIndexOf('\n'), text.lastIndexOf('\r')) + 1;
26
+ var trailingLine = text.slice(lineStart).trimEnd();
27
+ if (trailingLine.length >= 3 && trailingLine.length < delimiter.length && delimiter.indexOf(trailingLine) === 0) {
28
+ return text.slice(0, lineStart).trimEnd();
29
+ }
30
+ return text;
31
+ }
32
+
17
33
  // ── Migration from legacy single-session format ─────────────────────────────
18
34
  (function _ccMigrateLegacy() {
19
35
  try {
@@ -55,8 +71,12 @@ function _ccActiveTab() {
55
71
  }
56
72
 
57
73
  function _ccMergeStreamText(prev, incoming) {
74
+ // `prev` is already merged-clean from prior frames (server strips actions
75
+ // before SSE emission, and any leaked partial was sanitized by the previous
76
+ // _ccMergeStreamText call). Only strip `incoming` defensively — re-stripping
77
+ // `prev` every frame is O(n²) over the response length for nothing.
58
78
  var current = prev || '';
59
- var next = incoming || '';
79
+ var next = _ccStripActionBlockFromText(incoming || '');
60
80
  if (!current) return next;
61
81
  if (!next) return current;
62
82
  if (next === current) return current;
@@ -601,6 +621,10 @@ async function _ccDoSend(message, skipUserMsg, forceTabId) {
601
621
  updateStreamDiv();
602
622
  } else if (evt.type === 'heartbeat') {
603
623
  return;
624
+ } else if (evt.type === 'thinking') {
625
+ streamStatusNote = evt.text || 'Thinking...';
626
+ if (activeTab) activeTab._streamStatusNote = streamStatusNote;
627
+ updateStreamDiv();
604
628
  } else if (evt.type === 'tool') {
605
629
  toolsUsed.push({ name: evt.name, input: evt.input || {} });
606
630
  if (activeTab) activeTab._toolsUsed = toolsUsed.slice();
@@ -10,8 +10,10 @@ function prRow(pr) {
10
10
  // If PR is merged/abandoned, treat 'waiting' review as resolved
11
11
  const effectiveReviewStatus = (pr.status === 'merged' || pr.status === 'abandoned') && pr.reviewStatus === 'waiting' ? (pr.status === 'merged' ? 'approved' : 'pending') : pr.reviewStatus;
12
12
  const reviewSource = sq.status || effectiveReviewStatus || 'pending';
13
- const reviewClass = reviewSource === 'approved' ? 'approved' : (reviewSource === 'changes-requested' || reviewSource === 'rejected') ? 'rejected' : reviewSource === 'waiting' ? 'building' : 'draft';
14
- const reviewLabel = sq.status === 'waiting' ? 'reviewing (minions)' : sq.status ? sq.status + ' (minions)' : (effectiveReviewStatus || 'pending');
13
+ const reviewEscalated = !!pr._evalEscalated;
14
+ const reviewClass = reviewEscalated ? 'review-escalated' : reviewSource === 'approved' ? 'approved' : (reviewSource === 'changes-requested' || reviewSource === 'rejected') ? 'rejected' : reviewSource === 'waiting' ? 'building' : 'draft';
15
+ const reviewLabel = reviewEscalated ? 'review loop escalated (build/conflict may still run)' : sq.status === 'waiting' ? 'reviewing (minions)' : sq.status ? sq.status + ' (minions)' : (effectiveReviewStatus || 'pending');
16
+ const reviewTitle = reviewEscalated ? 'Review/re-review and review-fix automation stopped after evalMaxIterations; build-fix and conflict-fix automation may still run.' : '';
15
17
  const buildClass = pr.buildFixEscalated ? 'build-escalated' : pr._buildStatusStale ? 'build-stale' : pr.buildStatus === 'passing' ? 'build-pass' : pr.buildStatus === 'failing' ? 'build-fail' : pr.buildStatus === 'running' ? 'building' : 'no-build';
16
18
  const buildLabel = pr.buildFixEscalated ? 'escalated (' + (pr.buildFixAttempts || '?') + ' fixes)' : (pr.buildStatus || 'none') + (pr._buildStatusStale ? ' (stale)' : '');
17
19
  const statusClass = pr.status === 'merged' ? 'merged' : pr.status === 'abandoned' ? 'rejected' : pr.status === 'active' ? 'active' : 'draft';
@@ -23,7 +25,7 @@ function prRow(pr) {
23
25
  '<td><a class="pr-title" href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escapeHtml(pr.title || 'Untitled') + '</a>' + (pr.description ? '<div class="pr-desc">' + escapeHtml(pr.description.length > 120 ? pr.description.slice(0, 120) + '...' : pr.description) + '</div>' : '') + '</td>' +
24
26
  '<td><span class="pr-agent">' + escapeHtml(pr.agent || '—') + '</span></td>' +
25
27
  '<td><span class="pr-branch">' + escapeHtml(pr.branch || '—') + '</span></td>' +
26
- '<td><span class="pr-badge ' + reviewClass + '">' + escapeHtml(reviewLabel) + '</span></td>' +
28
+ '<td><span class="pr-badge ' + reviewClass + '"' + (reviewTitle ? ' title="' + escapeHtml(reviewTitle) + '"' : '') + '>' + escapeHtml(reviewLabel) + '</span></td>' +
27
29
  '<td>' + (sq.reviewer && sq.status !== 'waiting' ? '<span class="pr-agent" title="' + escapeHtml(sq.note || '') + '">' + escapeHtml(sq.reviewer) + '</span>' : sq.reviewer && sq.status === 'waiting' ? '<span class="pr-agent" style="color:var(--muted)" title="Vote pending confirmation">' + escapeHtml(sq.reviewer) + '…</span>' : pr.reviewedBy && pr.reviewedBy.length ? '<span class="pr-agent">' + escapeHtml(pr.reviewedBy.join(', ')) + '</span>' : '<span style="color:var(--muted);font-size:11px">—</span>') + '</td>' +
28
30
  '<td><span class="pr-badge ' + buildClass + '">' + escapeHtml(buildLabel) + '</span></td>' +
29
31
  '<td><span class="pr-badge ' + statusClass + '">' + escapeHtml(statusLabel) + '</span></td>' +
@@ -268,6 +268,7 @@
268
268
  .pr-badge.approved { background: rgba(63,185,80,0.15); color: var(--green); border: 1px solid var(--green); }
269
269
  .pr-badge.rejected { background: rgba(248,81,73,0.15); color: var(--red); border: 1px solid var(--red); }
270
270
  .pr-badge.needs-review { background: rgba(227,179,65,0.15); color: var(--orange); border: 1px solid var(--orange); }
271
+ .pr-badge.review-escalated { background: rgba(227,179,65,0.22); color: var(--orange); border: 1px dashed var(--orange); font-weight: 700; }
271
272
  .pr-badge.merged { background: rgba(188,140,255,0.15); color: var(--purple); border: 1px solid var(--purple); }
272
273
  .pr-badge.building { background: rgba(210,153,34,0.15); color: var(--yellow); border: 1px solid var(--yellow); animation: pulse 1.5s infinite; }
273
274
  .pr-badge.build-pass { background: rgba(63,185,80,0.15); color: var(--green); border: 1px solid var(--green); }
package/dashboard.js CHANGED
@@ -584,6 +584,7 @@ function _ensureCcLiveStream(tabId) {
584
584
  tabId,
585
585
  text: '',
586
586
  tools: [],
587
+ thinking: false,
587
588
  donePayload: null,
588
589
  writer: null,
589
590
  endResponse: null,
@@ -765,11 +766,54 @@ For all state files, look under \`${MINIONS_DIR}\`.`;
765
766
  return result;
766
767
  }
767
768
 
768
- function findCCActionsDelimiter(text) {
769
+ // Single helper that handles both the strict (well-formed) and loose forms of
770
+ // the ===ACTIONS=== delimiter. `parseable` is true only for the strict form
771
+ // that parseCCActions can JSON.parse; loose matches still split display text
772
+ // but are not parsed (they shouldn't reach the user as actions).
773
+ function findCCActionsHeader(text) {
774
+ if (!text) return null;
775
+ // Strict: ===ACTIONS={0,3} on its own line, optional trailing whitespace.
776
+ const strict = /(?:^|\r?\n)===ACTIONS={0,3}[ \t]*(?=\r?\n|$)/m.exec(text);
777
+ if (strict) {
778
+ const headerStart = strict.index + strict[0].indexOf('===ACTIONS');
779
+ const headerMatch = text.slice(headerStart).match(/^===ACTIONS={0,3}[ \t]*/);
780
+ return {
781
+ index: headerStart,
782
+ headerLength: headerMatch ? headerMatch[0].length : '===ACTIONS==='.length,
783
+ parseable: true,
784
+ };
785
+ }
786
+ // Loose: any ===ACTIONS<word-boundary>... line. Catches malformed delimiters
787
+ // like ===ACTIONS -> that should still be hidden from output.
788
+ const loose = /(?:^|\r?\n)===ACTIONS\b[^\r\n]*(?=\r?\n|$)/m.exec(text);
789
+ if (loose) {
790
+ const headerStart = loose.index + loose[0].indexOf('===ACTIONS');
791
+ return { index: headerStart, headerLength: 0, parseable: false };
792
+ }
793
+ return null;
794
+ }
795
+
796
+ function findCCActionsPartialDelimiter(text) {
769
797
  if (!text) return -1;
770
- const match = /(?:^|\r?\n)===ACTIONS===[ \t]*(?=\r?\n|$)/m.exec(text);
771
- if (!match) return -1;
772
- return match.index + match[0].indexOf('===ACTIONS===');
798
+ const delimiter = '===ACTIONS===';
799
+ const lineStart = Math.max(text.lastIndexOf('\n'), text.lastIndexOf('\r')) + 1;
800
+ const trailingLine = text.slice(lineStart).trimEnd();
801
+ if (trailingLine.length >= 3 && trailingLine.length < delimiter.length && delimiter.startsWith(trailingLine)) {
802
+ return lineStart;
803
+ }
804
+ return -1;
805
+ }
806
+
807
+ function stripCCActionsForStream(text) {
808
+ if (!text) return '';
809
+ // Fast path: 95% of streamed chunks contain no '=' before any actions block
810
+ // appears. Skip all regex work in that case.
811
+ if (text.indexOf('===') < 0) return text;
812
+ const header = findCCActionsHeader(text);
813
+ if (header) return text.slice(0, header.index).trim();
814
+ const partialIdx = findCCActionsPartialDelimiter(text);
815
+ if (partialIdx >= 0) return text.slice(0, partialIdx).trimEnd();
816
+ return text;
773
817
  }
774
818
 
775
819
  // Issue #1834: non-Claude runtimes (Copilot/GPT) routinely wrap the action JSON
@@ -819,20 +863,23 @@ function parseCCActions(text) {
819
863
  let actions = [];
820
864
  let displayText = text;
821
865
  let parseError = null;
822
- const delimIdx = findCCActionsDelimiter(text);
823
- if (delimIdx >= 0) {
824
- displayText = text.slice(0, delimIdx).trim();
825
- const segment = text.slice(delimIdx + '===ACTIONS==='.length);
826
- const jsonStr = _extractActionsJson(segment);
827
- if (jsonStr) {
828
- try {
829
- const parsed = JSON.parse(jsonStr);
830
- actions = Array.isArray(parsed) ? parsed : [parsed];
831
- } catch (e) {
832
- parseError = e.message || 'invalid JSON';
866
+ const header = findCCActionsHeader(text);
867
+ let segment = '';
868
+ if (header) {
869
+ displayText = text.slice(0, header.index).trim();
870
+ if (header.parseable) {
871
+ segment = text.slice(header.index + header.headerLength);
872
+ const jsonStr = _extractActionsJson(segment);
873
+ if (jsonStr) {
874
+ try {
875
+ const parsed = JSON.parse(jsonStr);
876
+ actions = Array.isArray(parsed) ? parsed : [parsed];
877
+ } catch (e) {
878
+ parseError = e.message || 'invalid JSON';
879
+ }
880
+ } else if (segment.trim()) {
881
+ parseError = 'no JSON value found after ===ACTIONS=== delimiter';
833
882
  }
834
- } else if (segment.trim()) {
835
- parseError = 'no JSON value found after ===ACTIONS=== delimiter';
836
883
  }
837
884
  }
838
885
  if (actions.length === 0) {
@@ -851,7 +898,7 @@ function parseCCActions(text) {
851
898
  result._actionParseError = parseError;
852
899
  // Visibility for the engine log — silent failure here previously masked issue #1834.
853
900
  try {
854
- const snippet = (text.slice(delimIdx + '===ACTIONS==='.length).trim() || '').slice(0, 200);
901
+ const snippet = (segment.trim() || '').slice(0, 200);
855
902
  console.warn(`[CC] action JSON parse failed (${parseError}); raw segment: ${snippet}`);
856
903
  if (typeof shared !== 'undefined' && shared && typeof shared.log === 'function') {
857
904
  shared.log('warn', `CC action JSON parse failed: ${parseError} — segment: ${snippet}`);
@@ -864,8 +911,8 @@ function parseCCActions(text) {
864
911
  function stripCCActionSyntax(text) {
865
912
  if (!text) return '';
866
913
  let displayText = text;
867
- const delimIdx = findCCActionsDelimiter(text);
868
- if (delimIdx >= 0) displayText = text.slice(0, delimIdx).trim();
914
+ const header = findCCActionsHeader(text);
915
+ if (header) displayText = text.slice(0, header.index).trim();
869
916
  return displayText.replace(/`{3,}\s*action\s*\r?\n[\s\S]*?`{3,}\n?/g, '').trim();
870
917
  }
871
918
 
@@ -4532,11 +4579,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4532
4579
  });
4533
4580
  }
4534
4581
 
4535
- // Mirror CC response to Teams (non-blocking, skip Teams-originated)
4536
- if (!tabId.startsWith('teams-')) {
4537
- teams.teamsPostCCResponse(body.message, result.text).catch(() => {});
4538
- }
4539
-
4540
4582
  const parsed = parseCCActions(result.text);
4541
4583
  const toolUses = Array.isArray(result.toolUses) ? result.toolUses : _extractToolUsesFromRaw(result.raw);
4542
4584
  // Safety net: detect /loop invocation and convert to create-watch
@@ -4549,6 +4591,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4549
4591
  if (parsed.actions.length > 0) {
4550
4592
  parsed.actionResults = await executeCCActions(parsed.actions);
4551
4593
  }
4594
+ // Mirror only user-facing text to Teams; never send the internal action block.
4595
+ if (!tabId.startsWith('teams-')) {
4596
+ teams.teamsPostCCResponse(body.message, parsed.text).catch(() => {});
4597
+ }
4552
4598
  // Issue #1834: rename _actionParseError → actionParseError (public field)
4553
4599
  // so the client can surface a warning when the model emitted ===ACTIONS===
4554
4600
  // but the JSON couldn't be recovered.
@@ -4642,6 +4688,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4642
4688
  for (const tool of live.tools || []) {
4643
4689
  writeCcEvent({ type: 'tool', name: tool.name, input: _lightToolInput(tool.input) });
4644
4690
  }
4691
+ if (live.thinking && !live.text) writeCcEvent({ type: 'thinking', text: 'Thinking...' });
4645
4692
  if (live.text) writeCcEvent({ type: 'chunk', text: live.text });
4646
4693
  if (live.donePayload) {
4647
4694
  writeCcEvent(live.donePayload);
@@ -4723,15 +4770,21 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4723
4770
  sessionId, effort: streamEffort, direct: true,
4724
4771
  engineConfig: CONFIG.engine,
4725
4772
  onChunk: (text) => {
4726
- const actIdx = findCCActionsDelimiter(text);
4727
- const display = actIdx >= 0 ? text.slice(0, actIdx).trim() : text;
4773
+ const display = stripCCActionsForStream(text);
4728
4774
  liveState.text = display;
4729
4775
  if (liveState.writer) liveState.writer({ type: 'chunk', text: display });
4776
+ // Once text is flowing, the SSE-replay branch (live.thinking &&
4777
+ // !live.text) shouldn't show stale "Thinking…" on reconnect.
4778
+ if (liveState.thinking) liveState.thinking = false;
4730
4779
  },
4731
4780
  onToolUse: (name, input) => {
4732
4781
  toolUses.push({ name, input: input || {} });
4733
4782
  liveState.tools.push({ name, input: input || {} });
4734
4783
  if (liveState.writer) liveState.writer({ type: 'tool', name, input: _lightToolInput(input) });
4784
+ },
4785
+ onThinking: () => {
4786
+ liveState.thinking = true;
4787
+ if (liveState.writer) liveState.writer({ type: 'thinking', text: 'Thinking...' });
4735
4788
  }
4736
4789
  });
4737
4790
  _ccStreamAbort = llmPromise.abort;
@@ -4749,20 +4802,26 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4749
4802
  toolUses = []; // discard stale metadata from the failed resume attempt
4750
4803
  const retryPromise = callLLMStreaming(freshPrompt, CC_STATIC_SYSTEM_PROMPT, {
4751
4804
  timeout: 900000, label: 'command-center', model: streamModel, maxTurns: ccMaxTurns,
4752
- allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
4753
- effort: streamEffort, direct: true,
4754
- engineConfig: CONFIG.engine,
4755
- onChunk: (text) => {
4756
- const actIdx = findCCActionsDelimiter(text);
4757
- const display = actIdx >= 0 ? text.slice(0, actIdx).trim() : text;
4758
- liveState.text = display;
4759
- if (liveState.writer) liveState.writer({ type: 'chunk', text: display });
4760
- },
4761
- onToolUse: (name, input) => {
4762
- toolUses.push({ name, input: input || {} });
4763
- liveState.tools.push({ name, input: input || {} });
4764
- if (liveState.writer) liveState.writer({ type: 'tool', name, input: _lightToolInput(input) });
4765
- }
4805
+ allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
4806
+ effort: streamEffort, direct: true,
4807
+ engineConfig: CONFIG.engine,
4808
+ onChunk: (text) => {
4809
+ const display = stripCCActionsForStream(text);
4810
+ liveState.text = display;
4811
+ if (liveState.writer) liveState.writer({ type: 'chunk', text: display });
4812
+ // Same reset as the initial path so resume-fail retries don't
4813
+ // leave a stale "Thinking…" frame visible on SSE reconnect.
4814
+ if (liveState.thinking) liveState.thinking = false;
4815
+ },
4816
+ onToolUse: (name, input) => {
4817
+ toolUses.push({ name, input: input || {} });
4818
+ liveState.tools.push({ name, input: input || {} });
4819
+ if (liveState.writer) liveState.writer({ type: 'tool', name, input: _lightToolInput(input) });
4820
+ },
4821
+ onThinking: () => {
4822
+ liveState.thinking = true;
4823
+ if (liveState.writer) liveState.writer({ type: 'thinking', text: 'Thinking...' });
4824
+ }
4766
4825
  });
4767
4826
  _ccStreamAbort = retryPromise.abort;
4768
4827
  liveState.abortFn = _ccStreamAbort;
@@ -4834,7 +4893,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4834
4893
  // Mirror CC response to Teams (non-blocking, skip Teams-originated)
4835
4894
  const _streamTabId = body.tabId || 'default';
4836
4895
  if (!_streamTabId.startsWith('teams-')) {
4837
- teams.teamsPostCCResponse(body.message, result.text).catch(() => {});
4896
+ teams.teamsPostCCResponse(body.message, displayText).catch(() => {});
4838
4897
  }
4839
4898
 
4840
4899
  if (liveState.endResponse) liveState.endResponse();
@@ -34,10 +34,11 @@ When multiple problems coexist, earlier triggers get the first chance to enqueue
34
34
 
35
35
  ### A. Review feedback (`changes-requested`)
36
36
 
37
- - Gate: `reviewStatus === 'changes-requested'` + `!awaitingReReview` + not dispatched + not on cooldown
37
+ - Gate: `reviewStatus === 'changes-requested'` + `!awaitingReReview` + `!evalEscalated` + not dispatched + not on cooldown
38
38
  - Routes to PR author via `_author_` routing token
39
39
  - `review_note` = reviewer's feedback
40
40
  - Sets `fixDispatched = true` — prevents human-feedback and conflict fixes from also firing this pass
41
+ - **Review-loop escalation**: after `evalMaxIterations` review→fix cycles (default 3), `_evalEscalated` is set on the PR and *only this trigger plus review/re-review* stop. Triggers B (human comments), C (build failures), and the merge-conflict fix path keep running. The dashboard PR row distinguishes the two states with separate badges (review badge `review-escalated` vs. build badge `build-escalated`).
41
42
 
42
43
  ### B. Human comments (`humanFeedback.pendingFix`)
43
44
 
@@ -45,13 +46,15 @@ When multiple problems coexist, earlier triggers get the first chance to enqueue
45
46
  - Agent comments filtered out via `/\bMinions\s*\(/i` regex on comment body
46
47
  - Coalesces multiple comments arriving during cooldown into single fix
47
48
  - Routes to author
49
+ - Not gated by `_evalEscalated` — humans can always force more fixes via PR comments even after the review loop escalates.
48
50
 
49
51
  ### C. Build failures (`buildStatus === 'failing'`)
50
52
 
51
53
  - Gate: `buildFixAttempts < maxBuildFixAttempts` (default 3) + grace period expired
52
54
  - **Grace period** (`_buildFixPushedAt`): after fix dispatches, waits `buildFixGracePeriod` (default 10min, configurable in `ENGINE_DEFAULTS`) for CI to run before re-dispatching. Cleared when poller detects build status transition (CI actually ran).
53
55
  - **Error logs**: GitHub fetches annotations (failures only, not warnings) + Actions job log (always). ADO queries builds API directly (not status checks), fetches build timeline → failed task logs (up to 10 per build, up to 10 failing pipelines).
54
- - **Escalation**: after 3 failed attempts, writes inbox alert, sets `buildFixEscalated = true`, stops auto-dispatch. Counter resets when build recovers.
56
+ - **Build-fix escalation**: after 3 failed attempts, writes an inbox alert, sets `buildFixEscalated = true`, and stops *only this trigger* (auto-dispatch for build fixes). The counter resets when the build recovers. Independent of `_evalEscalated`.
57
+ - Not gated by `_evalEscalated` — build-fix is mechanical and runs even if the review loop has escalated.
55
58
  - Sets `fixDispatched = true` after dispatch so the later conflict trigger is suppressed in the same pass.
56
59
 
57
60
  ### D. Merge conflicts (`_mergeConflict`)
@@ -116,8 +119,10 @@ When multiple problems coexist, earlier triggers get the first chance to enqueue
116
119
  | `reviewStatus` | Poller + post-completion | `pending` / `approved` / `changes-requested` / `waiting` |
117
120
  | `buildStatus` | Poller | `none` / `passing` / `failing` / `running` |
118
121
  | `buildErrorLog` | Poller | Actual CI error output for fix agents |
119
- | `buildFixAttempts` | Discovery (on dispatch) | Counter for escalation cap |
120
- | `buildFixEscalated` | Discovery (on cap) | Stops auto-dispatch |
122
+ | `buildFixAttempts` | Discovery (on dispatch) | Counter for build-fix escalation cap |
123
+ | `buildFixEscalated` | Discovery (on cap) | Stops *build-fix* auto-dispatch only (review/re-review and other fix triggers continue) |
124
+ | `_reviewFixCycles` | Discovery (on dispatch) | Counter for review→fix cycle cap (`evalMaxIterations`) |
125
+ | `_evalEscalated` | Discovery (on cap) | Stops *review/re-review and review-feedback fix* auto-dispatch only (build-fix, conflict-fix, and human-feedback fix continue). Cleared when reviewer eventually approves the PR. |
121
126
  | `_buildFixPushedAt` | Discovery (on dispatch) | Grace period timestamp |
122
127
  | `_buildFailNotified` | Discovery | Dedup for inbox alert |
123
128
  | `lastPushedAt` | Poller (new commit) | Tracks latest push for re-review logic |
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-04-29T01:10:58.853Z"
4
+ "cachedAt": "2026-04-29T04:06:55.678Z"
5
5
  }
package/engine/llm.js CHANGED
@@ -24,6 +24,10 @@ const MINIONS_DIR = shared.MINIONS_DIR;
24
24
  const ENGINE_DIR = path.join(MINIONS_DIR, 'engine');
25
25
  const COPILOT_TASK_COMPLETE_GRACE_MS = 3000;
26
26
 
27
+ // Claude content blocks come in two thinking variants; hoisted to module scope
28
+ // so the streaming accumulator's hot path doesn't recreate the set per event.
29
+ const THINKING_BLOCK_TYPES = new Set(['thinking', 'redacted_thinking']);
30
+
27
31
  // ─── Engine-Usage Metrics ────────────────────────────────────────────────────
28
32
 
29
33
  function trackEngineUsage(category, usage) {
@@ -261,6 +265,7 @@ function _createStreamAccumulator({
261
265
  onChunk = null,
262
266
  onToolUse = null,
263
267
  onTaskComplete = null,
268
+ onThinking = null,
264
269
  }) {
265
270
  let stdout = '';
266
271
  let stderr = '';
@@ -278,9 +283,15 @@ function _createStreamAccumulator({
278
283
  let copilotMessageBuffer = '';
279
284
  let copilotTaskCompleteSeen = false;
280
285
  let copilotTaskCompleteSummary = '';
286
+ const claudeStreamBlocks = new Map();
287
+ // Maintained accumulator of Claude text — incrementally appended on each
288
+ // text_delta so the hot path doesn't rebuild from the Map every chunk
289
+ // (rebuild was O(n) per delta → O(n²) over the response).
290
+ let claudeJoinedText = '';
291
+ let thinkingSent = false;
281
292
 
282
293
  function _streamText(value) {
283
- return maxTextLength ? value.slice(-maxTextLength) : value;
294
+ return (maxTextLength && value.length > maxTextLength) ? value.slice(-maxTextLength) : value;
284
295
  }
285
296
 
286
297
  function _copilotAssistantMessageHasTools(obj) {
@@ -288,6 +299,79 @@ function _createStreamAccumulator({
288
299
  return Array.isArray(requests) && requests.length > 0;
289
300
  }
290
301
 
302
+ function _notifyThinking() {
303
+ if (!onThinking || thinkingSent) return;
304
+ thinkingSent = true;
305
+ onThinking();
306
+ }
307
+
308
+ // Rebuild the joined text from the Map. Only used as a safety net when
309
+ // content blocks arrive out of order (a non-trailing index lands after a
310
+ // later one — rare but possible if events get reordered upstream).
311
+ function _rebuildClaudeJoinedText() {
312
+ claudeJoinedText = Array.from(claudeStreamBlocks.keys()).sort((a, b) => a - b)
313
+ .map(index => claudeStreamBlocks.get(index))
314
+ .filter(block => block && block.type === 'text' && block.text)
315
+ .map(block => block.text)
316
+ .join('');
317
+ }
318
+
319
+ function _captureClaudeText(value) {
320
+ if (typeof value !== 'string' || !value) return;
321
+ const nextText = _streamText(value);
322
+ text = nextText;
323
+ if (onChunk && nextText !== lastTextSent) {
324
+ lastTextSent = nextText;
325
+ onChunk(nextText);
326
+ }
327
+ }
328
+
329
+ function _captureClaudeStreamEvent(obj) {
330
+ const event = obj?.event;
331
+ if (!event || typeof event !== 'object') return false;
332
+ if (event.type === 'message_start') {
333
+ claudeStreamBlocks.clear();
334
+ claudeJoinedText = '';
335
+ thinkingSent = false;
336
+ return true;
337
+ }
338
+ if (event.type === 'content_block_start') {
339
+ const index = Number.isInteger(event.index) ? event.index : Number(event.index) || 0;
340
+ const block = event.content_block || {};
341
+ claudeStreamBlocks.set(index, { type: block.type || '', text: block.text || '' });
342
+ if (THINKING_BLOCK_TYPES.has(block.type)) _notifyThinking();
343
+ // If a block lands at a non-trailing index (out-of-order delivery), the
344
+ // monotonic-append path can't reconstruct the joined text — rebuild as
345
+ // a safety net. The common case is in-order arrival; rebuild is rare.
346
+ const indices = Array.from(claudeStreamBlocks.keys());
347
+ const isTrailing = indices.every(i => i <= index);
348
+ if (!isTrailing) {
349
+ _rebuildClaudeJoinedText();
350
+ } else if (block.type === 'text' && block.text) {
351
+ claudeJoinedText += block.text;
352
+ }
353
+ if (claudeJoinedText) _captureClaudeText(claudeJoinedText);
354
+ return true;
355
+ }
356
+ if (event.type === 'content_block_delta') {
357
+ const index = Number.isInteger(event.index) ? event.index : Number(event.index) || 0;
358
+ const delta = event.delta || {};
359
+ if (delta.type === 'thinking_delta' || typeof delta.thinking === 'string') _notifyThinking();
360
+ if (delta.type === 'text_delta' && typeof delta.text === 'string' && delta.text) {
361
+ const block = claudeStreamBlocks.get(index) || { type: 'text', text: '' };
362
+ block.type = 'text';
363
+ block.text = (block.text || '') + delta.text;
364
+ claudeStreamBlocks.set(index, block);
365
+ // Common case: deltas arrive monotonically per index, so appending to
366
+ // the joined accumulator directly is correct.
367
+ claudeJoinedText += delta.text;
368
+ _captureClaudeText(claudeJoinedText);
369
+ }
370
+ return true;
371
+ }
372
+ return event.type === 'content_block_stop' || event.type === 'message_delta' || event.type === 'message_stop';
373
+ }
374
+
291
375
  function _captureCopilotTaskComplete(summary, success = true) {
292
376
  if (typeof summary !== 'string' || !summary) return;
293
377
  const finalSummary = _streamText(summary);
@@ -311,6 +395,9 @@ function _createStreamAccumulator({
311
395
 
312
396
  // ── Claude shape ────────────────────────────────────────────────────────
313
397
  if (obj.session_id) sessionId = obj.session_id;
398
+ if (obj.type === 'stream_event') {
399
+ _captureClaudeStreamEvent(obj);
400
+ }
314
401
  if (obj.type === 'result' && typeof obj.result === 'string') {
315
402
  // Claude result event: terminal text + usage.
316
403
  text = maxTextLength ? obj.result.slice(-maxTextLength) : obj.result;
@@ -328,19 +415,22 @@ function _createStreamAccumulator({
328
415
  }
329
416
  if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
330
417
  // Claude assistant turn: content blocks (text + tool_use).
418
+ // Multi-text-block messages (common with --include-partial-messages) need
419
+ // their text joined before _captureClaudeText, otherwise each block
420
+ // overwrites the prior one.
421
+ let assistantText = '';
331
422
  for (const block of obj.message.content) {
332
423
  if (block?.type === 'text' && block.text) {
333
- text = maxTextLength ? block.text.slice(-maxTextLength) : block.text;
334
- if (onChunk && block.text !== lastTextSent) {
335
- lastTextSent = block.text;
336
- onChunk(block.text);
337
- }
424
+ assistantText += block.text;
425
+ } else if (THINKING_BLOCK_TYPES.has(block?.type)) {
426
+ _notifyThinking();
338
427
  } else if (block?.type === 'tool_use' && block.name) {
339
428
  const toolUse = { name: block.name, input: block.input || {} };
340
429
  toolUses.push(toolUse);
341
430
  if (onToolUse) onToolUse(toolUse.name, toolUse.input);
342
431
  }
343
432
  }
433
+ if (assistantText) _captureClaudeText(assistantText);
344
434
  }
345
435
 
346
436
  // ── Copilot shape ───────────────────────────────────────────────────────
@@ -348,6 +438,9 @@ function _createStreamAccumulator({
348
438
  if (obj.type === 'session.task_complete') {
349
439
  _captureCopilotTaskComplete(obj.data?.summary, obj.data?.success);
350
440
  }
441
+ if (obj.type === 'assistant.reasoning' || obj.type === 'assistant.reasoning_delta') {
442
+ _notifyThinking();
443
+ }
351
444
  if (obj.type === 'assistant.message_delta' && typeof obj.data?.deltaContent === 'string') {
352
445
  if (copilotTaskCompleteSeen) return;
353
446
  copilotMessageBuffer += obj.data.deltaContent;
@@ -611,6 +704,7 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
611
704
  onChunk,
612
705
  onToolUse,
613
706
  onTaskComplete: scheduleTaskCompleteClose,
707
+ onThinking: opts.onThinking || null,
614
708
  });
615
709
 
616
710
  _abort = () => { shared.killImmediate(proc); };
package/engine/routing.js CHANGED
@@ -28,7 +28,7 @@ let _routingCache = null;
28
28
  let _routingCacheMtime = 0;
29
29
 
30
30
  function parseRoutingTable() {
31
- const content = getRouting();
31
+ const content = getRouting() || '';
32
32
  const routes = {};
33
33
  const lines = content.split('\n');
34
34
  let inTable = false;
@@ -116,12 +116,44 @@ function setTempBudget(n) {
116
116
  }
117
117
  function getTempBudget() { return _tempBudget; }
118
118
 
119
- function normalizeAgentHints(agentHints, authorAgent = null) {
120
- const raw = Array.isArray(agentHints) ? agentHints : (agentHints ? String(agentHints).split(',') : []);
121
- return raw
122
- .map(id => String(id).trim().toLowerCase())
123
- .map(id => id === '_author_' && authorAgent ? String(authorAgent).trim().toLowerCase() : id)
119
+ // Centralizes the work-item shape used to derive routing hints. Engine code
120
+ // previously inlined `item.preferred_agent || item.agents || null` at four
121
+ // call sites; hoisting keeps the contract in one place.
122
+ function extractAgentHints(item) {
123
+ if (!item || typeof item !== 'object') return null;
124
+ return item.preferred_agent || item.agents || null;
125
+ }
126
+
127
+ // Normalize a list of agent-hint inputs. Accepts:
128
+ // - Comma-separated string ("dallas,ripley")
129
+ // - Array of strings
130
+ // - Single string
131
+ // Resolves the `_author_` token to authorAgent (when provided), validates
132
+ // each hint against the configured agents map (case-insensitive lookup,
133
+ // returning the canonical ID), dedups, and drops anything unknown.
134
+ function normalizeAgentHints(agentHints, authorAgent = null, agents = null) {
135
+ const raw = Array.isArray(agentHints)
136
+ ? agentHints
137
+ : (agentHints ? String(agentHints).split(',') : []);
138
+ const expanded = raw
139
+ .map(id => String(id).trim())
140
+ .map(id => id.toLowerCase() === '_author_' && authorAgent ? String(authorAgent).trim() : id)
124
141
  .filter(Boolean);
142
+ // When no agents map is supplied, return lowercased IDs (legacy behaviour
143
+ // used by tests and pre-validation callers).
144
+ if (!agents || typeof agents !== 'object') {
145
+ return expanded.map(id => id.toLowerCase());
146
+ }
147
+ const byLower = new Map(Object.keys(agents).map(id => [id.toLowerCase(), id]));
148
+ const seen = new Set();
149
+ const normalized = [];
150
+ for (const hint of expanded) {
151
+ const id = byLower.get(hint.toLowerCase());
152
+ if (!id || seen.has(id)) continue;
153
+ seen.add(id);
154
+ normalized.push(id);
155
+ }
156
+ return normalized;
125
157
  }
126
158
 
127
159
  function resolveAgent(workType, config, authorAgent = null, agentHints = null) {
@@ -153,7 +185,7 @@ function resolveAgent(workType, config, authorAgent = null, agentHints = null) {
153
185
  return null;
154
186
  };
155
187
 
156
- const hintedAgents = normalizeAgentHints(agentHints, authorAgent);
188
+ const hintedAgents = normalizeAgentHints(agentHints, authorAgent, agents);
157
189
  if (hintedAgents.length > 0) {
158
190
  for (const id of hintedAgents) {
159
191
  if (isAvailable(id)) { _claimedAgents.add(id); return id; }
@@ -203,10 +235,11 @@ module.exports = {
203
235
  getMonthlySpend,
204
236
  getAgentErrorRate,
205
237
  isAgentIdle,
238
+ normalizeAgentHints,
239
+ extractAgentHints,
206
240
  _claimedAgents,
207
241
  resetClaimedAgents,
208
242
  resolveAgent,
209
243
  setTempBudget,
210
244
  getTempBudget,
211
- normalizeAgentHints,
212
245
  };
@@ -209,6 +209,7 @@ function buildArgs(opts = {}) {
209
209
  } = opts;
210
210
 
211
211
  const args = ['-p', '--output-format', outputFormat];
212
+ if (outputFormat === 'stream-json') args.push('--include-partial-messages');
212
213
  if (maxTurns != null) args.push('--max-turns', String(maxTurns));
213
214
  if (model) args.push('--model', String(model));
214
215
  if (verbose) args.push('--verbose');
package/engine.js CHANGED
@@ -121,9 +121,10 @@ const { getConfig, getControl, getDispatch, getNotes,
121
121
 
122
122
  // ─── Routing (extracted to engine/routing.js) ───────────────────────────────
123
123
 
124
+ const routing = require('./engine/routing');
124
125
  const { getRouting, parseRoutingTable, getRoutingTableCached, getMonthlySpend,
125
126
  getAgentErrorRate, isAgentIdle, resolveAgent, resetClaimedAgents,
126
- setTempBudget, tempAgents } = require('./engine/routing');
127
+ setTempBudget, tempAgents } = routing;
127
128
 
128
129
  // ─── Playbook, system prompt, agent context (extracted to engine/playbook.js) ─
129
130
 
@@ -2081,7 +2082,7 @@ async function discoverFromPrs(config, project) {
2081
2082
  if (target) target._evalEscalated = true;
2082
2083
  });
2083
2084
  } catch (e) { log('warn', 'mark eval escalated: ' + e.message); }
2084
- log('warn', `PR ${pr.id}: review→fix escalated after ${evalCycles} cycles — suspending auto-dispatch`);
2085
+ log('warn', `PR ${pr.id}: review→fix escalated after ${evalCycles} cycles — suspending review/re-review and review-fix dispatch; build/conflict fixes may continue`);
2085
2086
  }
2086
2087
 
2087
2088
  // PRs needing review: evalLoop gates the entire review+fix cycle; pollEnabled ensures reviewStatus is fresh
@@ -2500,7 +2501,7 @@ function discoverFromWorkItems(config, project) {
2500
2501
  item._decomposing = true;
2501
2502
  needsWrite = true;
2502
2503
  }
2503
- const agentHints = item.preferred_agent || item.agents || null;
2504
+ const agentHints = routing.extractAgentHints(item);
2504
2505
  const agentId = item.agent || resolveAgent(workType, config, null, agentHints);
2505
2506
  if (!agentId) {
2506
2507
  // Check if reason is budget
@@ -3020,7 +3021,7 @@ function discoverCentralWorkItems(config) {
3020
3021
 
3021
3022
  } else {
3022
3023
  // ─── Normal: single agent dispatch ──────────────────────────────
3023
- const agentHints = item.preferred_agent || item.agents || null;
3024
+ const agentHints = routing.extractAgentHints(item);
3024
3025
  const agentId = item.agent || resolveAgent(workType, config, null, agentHints);
3025
3026
  if (!agentId) continue;
3026
3027
 
@@ -3663,8 +3664,7 @@ async function tickInner() {
3663
3664
  // be of type string. Received undefined` and re-queues — every tick. Try to
3664
3665
  // resolve a fallback via routing; if none is available, skip this tick.
3665
3666
  if (!item.agent || typeof item.agent !== 'string') {
3666
- const agentHints = item.meta?.item?.preferred_agent || item.meta?.item?.agents || null;
3667
- const fallback = resolveAgent(item.type || WORK_TYPE.FIX, config, null, agentHints);
3667
+ const fallback = resolveAgent(item.type || WORK_TYPE.FIX, config, null, routing.extractAgentHints(item.meta?.item));
3668
3668
  if (!fallback) {
3669
3669
  log('warn', `Pending dispatch ${item.id} has no agent and routing returned no fallback — skipping`);
3670
3670
  continue;
@@ -3722,7 +3722,7 @@ async function tickInner() {
3722
3722
  // Agent busy reassignment: if item has been waiting on a busy agent past the threshold,
3723
3723
  // try to find an alternative agent via routing. Skip explicitly assigned items.
3724
3724
  const reassignMs = config.engine?.agentBusyReassignMs ?? ENGINE_DEFAULTS.agentBusyReassignMs;
3725
- const isExplicitReassign = !!(item.meta?.item?.agent || item.meta?.item?.preferred_agent || item.meta?.item?.agents?.length);
3725
+ const isExplicitReassign = !!(item.meta?.item?.agent || routing.extractAgentHints(item.meta?.item));
3726
3726
  if (!isExplicitReassign && reassignMs > 0 && item._agentBusySince) {
3727
3727
  const busySinceMs = new Date(item._agentBusySince).getTime();
3728
3728
  if (Date.now() - busySinceMs > reassignMs) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1618",
3
+ "version": "0.1.1620",
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"