@yemi33/minions 0.1.1620 → 0.1.1622

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,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1622 (2026-04-29)
4
+
5
+ ### Features
6
+ - harden action block parsing (#1863)
7
+
8
+ ### Other
9
+ - refactor: move stream-event handling into runtime adapters
10
+
3
11
  ## 0.1.1620 (2026-04-29)
4
12
 
5
13
  ### Features
package/dashboard.js CHANGED
@@ -584,7 +584,7 @@ function _ensureCcLiveStream(tabId) {
584
584
  tabId,
585
585
  text: '',
586
586
  tools: [],
587
- thinking: false,
587
+ thinkingSent: false,
588
588
  donePayload: null,
589
589
  writer: null,
590
590
  endResponse: null,
@@ -766,6 +766,11 @@ For all state files, look under \`${MINIONS_DIR}\`.`;
766
766
  return result;
767
767
  }
768
768
 
769
+ function findCCActionsDelimiter(text) {
770
+ const header = findCCActionsHeader(text);
771
+ return header && header.parseable ? header.index : -1;
772
+ }
773
+
769
774
  // Single helper that handles both the strict (well-formed) and loose forms of
770
775
  // the ===ACTIONS=== delimiter. `parseable` is true only for the strict form
771
776
  // that parseCCActions can JSON.parse; loose matches still split display text
@@ -783,9 +788,9 @@ function findCCActionsHeader(text) {
783
788
  parseable: true,
784
789
  };
785
790
  }
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);
791
+ // Loose: sentinel-looking malformed delimiters such as ===ACTIONS -> should
792
+ // still be hidden, but prose like "===ACTIONS are documented" must render.
793
+ const loose = /(?:^|\r?\n)===ACTIONS(?:[ \t]*(?:[-=]>?|={1,}|$)|[^A-Za-z0-9_\s\r\n][^\r\n]*)(?=\r?\n|$)/m.exec(text);
789
794
  if (loose) {
790
795
  const headerStart = loose.index + loose[0].indexOf('===ACTIONS');
791
796
  return { index: headerStart, headerLength: 0, parseable: false };
@@ -816,6 +821,15 @@ function stripCCActionsForStream(text) {
816
821
  return text;
817
822
  }
818
823
 
824
+ function stripCCActionsForDisplay(text) {
825
+ if (!text) return '';
826
+ const header = findCCActionsHeader(text);
827
+ if (header) return text.slice(0, header.index).trim();
828
+ const partialIdx = findCCActionsPartialDelimiter(text);
829
+ if (partialIdx >= 0) return text.slice(0, partialIdx).trimEnd();
830
+ return text;
831
+ }
832
+
819
833
  // Issue #1834: non-Claude runtimes (Copilot/GPT) routinely wrap the action JSON
820
834
  // in ```json fences or append trailing prose ("Let me know if that helps!").
821
835
  // JSON.parse on the raw segment fails silently → actions dropped, user sees
@@ -861,7 +875,7 @@ function _extractActionsJson(segment) {
861
875
 
862
876
  function parseCCActions(text) {
863
877
  let actions = [];
864
- let displayText = text;
878
+ let displayText = stripCCActionsForDisplay(text);
865
879
  let parseError = null;
866
880
  const header = findCCActionsHeader(text);
867
881
  let segment = '';
@@ -4621,6 +4635,40 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4621
4635
  return out;
4622
4636
  }
4623
4637
 
4638
+ /**
4639
+ * Build the callLLMStreaming invocation for the SSE Command Center path.
4640
+ * Both the initial call and the post-resume-fail retry share the same
4641
+ * onChunk/onToolUse/onThinking shape — only `sessionId` differs (set on
4642
+ * initial call, undefined on retry). Hoisted to keep the two call sites
4643
+ * in lock-step.
4644
+ */
4645
+ function _invokeCcStream({ prompt, sessionId, liveState, toolUses, model, effort, maxTurns, engineConfig }) {
4646
+ const { callLLMStreaming } = require('./engine/llm');
4647
+ return callLLMStreaming(prompt, CC_STATIC_SYSTEM_PROMPT, {
4648
+ timeout: 900000, label: 'command-center', model, maxTurns,
4649
+ allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
4650
+ sessionId, effort, direct: true,
4651
+ engineConfig,
4652
+ onChunk: (text) => {
4653
+ const display = stripCCActionsForStream(text);
4654
+ liveState.text = display;
4655
+ // Once text is flowing, the SSE-replay branch (live.thinkingSent &&
4656
+ // !live.text) shouldn't show stale "Thinking…" on reconnect.
4657
+ if (liveState.thinkingSent) liveState.thinkingSent = false;
4658
+ if (liveState.writer) liveState.writer({ type: 'chunk', text: display });
4659
+ },
4660
+ onToolUse: (name, input) => {
4661
+ toolUses.push({ name, input: input || {} });
4662
+ liveState.tools.push({ name, input: input || {} });
4663
+ if (liveState.writer) liveState.writer({ type: 'tool', name, input: _lightToolInput(input) });
4664
+ },
4665
+ onThinking: () => {
4666
+ liveState.thinkingSent = true;
4667
+ if (liveState.writer) liveState.writer({ type: 'thinking', text: 'Thinking...' });
4668
+ },
4669
+ });
4670
+ }
4671
+
4624
4672
  async function handleCommandCenterStream(req, res) {
4625
4673
  // SSE Origin gate (belt-and-suspenders: the top-level dispatcher has
4626
4674
  // already rejected disallowed origins on POST, but validate again here
@@ -4688,7 +4736,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4688
4736
  for (const tool of live.tools || []) {
4689
4737
  writeCcEvent({ type: 'tool', name: tool.name, input: _lightToolInput(tool.input) });
4690
4738
  }
4691
- if (live.thinking && !live.text) writeCcEvent({ type: 'thinking', text: 'Thinking...' });
4739
+ if (live.thinkingSent && !live.text) writeCcEvent({ type: 'thinking', text: 'Thinking...' });
4692
4740
  if (live.text) writeCcEvent({ type: 'chunk', text: live.text });
4693
4741
  if (live.donePayload) {
4694
4742
  writeCcEvent(live.donePayload);
@@ -4759,33 +4807,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4759
4807
  const preamble = wasResume ? '' : buildCCStatePreamble();
4760
4808
  const prompt = (preamble ? preamble + '\n\n---\n\n' : '') + body.message;
4761
4809
 
4762
- const { callLLMStreaming, trackEngineUsage: trackUsage } = require('./engine/llm');
4810
+ const { trackEngineUsage: trackUsage } = require('./engine/llm');
4763
4811
  const streamModel = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
4764
4812
  const streamEffort = CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort;
4765
4813
  const ccMaxTurns = CONFIG.engine?.ccMaxTurns || shared.ENGINE_DEFAULTS.ccMaxTurns;
4766
4814
  let toolUses = [];
4767
- const llmPromise = callLLMStreaming(prompt, CC_STATIC_SYSTEM_PROMPT, {
4768
- timeout: 900000, label: 'command-center', model: streamModel, maxTurns: ccMaxTurns,
4769
- allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
4770
- sessionId, effort: streamEffort, direct: true,
4815
+ const llmPromise = _invokeCcStream({
4816
+ prompt, sessionId, liveState, toolUses,
4817
+ model: streamModel, effort: streamEffort, maxTurns: ccMaxTurns,
4771
4818
  engineConfig: CONFIG.engine,
4772
- onChunk: (text) => {
4773
- const display = stripCCActionsForStream(text);
4774
- liveState.text = display;
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;
4779
- },
4780
- onToolUse: (name, input) => {
4781
- toolUses.push({ name, input: input || {} });
4782
- liveState.tools.push({ name, input: input || {} });
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...' });
4788
- }
4789
4819
  });
4790
4820
  _ccStreamAbort = llmPromise.abort;
4791
4821
  liveState.abortFn = _ccStreamAbort;
@@ -4800,33 +4830,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4800
4830
  const freshPreamble = buildCCStatePreamble();
4801
4831
  const freshPrompt = (freshPreamble ? freshPreamble + '\n\n---\n\n' : '') + body.message;
4802
4832
  toolUses = []; // discard stale metadata from the failed resume attempt
4803
- const retryPromise = callLLMStreaming(freshPrompt, CC_STATIC_SYSTEM_PROMPT, {
4804
- timeout: 900000, label: 'command-center', model: streamModel, maxTurns: ccMaxTurns,
4805
- allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
4806
- effort: streamEffort, direct: true,
4833
+ const retryPromise = _invokeCcStream({
4834
+ prompt: freshPrompt, sessionId: undefined, liveState, toolUses,
4835
+ model: streamModel, effort: streamEffort, maxTurns: ccMaxTurns,
4807
4836
  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
- }
4825
- });
4826
- _ccStreamAbort = retryPromise.abort;
4827
- liveState.abortFn = _ccStreamAbort;
4828
- ccInFlightAborts.set(tabId, _ccStreamAbort);
4829
- const retryResult = await retryPromise;
4837
+ });
4838
+ _ccStreamAbort = retryPromise.abort;
4839
+ liveState.abortFn = _ccStreamAbort;
4840
+ ccInFlightAborts.set(tabId, _ccStreamAbort);
4841
+ const retryResult = await retryPromise;
4830
4842
  trackUsage('command-center', retryResult.usage);
4831
4843
  if (retryResult.text) {
4832
4844
  // Fresh session succeeded — use retryResult from here
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-04-29T04:06:55.678Z"
4
+ "cachedAt": "2026-04-29T11:02:21.813Z"
5
5
  }
package/engine/llm.js CHANGED
@@ -24,10 +24,6 @@ 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
-
31
27
  // ─── Engine-Usage Metrics ────────────────────────────────────────────────────
32
28
 
33
29
  function trackEngineUsage(category, usage) {
@@ -246,12 +242,12 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
246
242
  // ─── Streaming Accumulator ───────────────────────────────────────────────────
247
243
  //
248
244
  // Reads JSONL events as they stream in. JSON parsing is delegated to
249
- // `runtime.parseStreamChunk()` that gives us the runtime's defensive
250
- // guarantees (e.g. Copilot rewrapping unknown event types as type:'ignore').
245
+ // `runtime.parseStreamChunk()` and event-shape interpretation is delegated to
246
+ // `runtime.createStreamConsumer(ctx)`. This file stays runtime-agnostic it
247
+ // owns the global accumulator state (stdout/stderr/text dedup/toolUses) and
248
+ // exposes a `ctx` callback API the adapter calls when it sees Claude- or
249
+ // Copilot-shaped events.
251
250
  //
252
- // Text / tool extraction branches on event SHAPE rather than runtime identity.
253
- // Both Claude and Copilot events flow through here; for any given object only
254
- // one branch matches because the event type strings don't collide.
255
251
  // Final reconciliation calls `runtime.parseOutput(stdout)` so per-runtime
256
252
  // finalization quirks (Copilot's premiumRequests, Claude's session_id) stay
257
253
  // inside the adapter.
@@ -267,6 +263,10 @@ function _createStreamAccumulator({
267
263
  onTaskComplete = null,
268
264
  onThinking = null,
269
265
  }) {
266
+ if (!runtime?.capabilities?.streamConsumer || typeof runtime.createStreamConsumer !== 'function') {
267
+ throw new Error(`runtime ${runtime?.name || '<unknown>'} missing createStreamConsumer (capabilities.streamConsumer)`);
268
+ }
269
+
270
270
  let stdout = '';
271
271
  let stderr = '';
272
272
  let lineBuf = '';
@@ -274,217 +274,76 @@ function _createStreamAccumulator({
274
274
  let usage = null;
275
275
  let sessionId = null;
276
276
  let lastTextSent = '';
277
- const toolUses = [];
278
-
279
- // Copilot streams `assistant.message_delta` with `data.deltaContent` chunks
280
- // before emitting `assistant.message`. Tool-request messages can include
281
- // narration ("I'll inspect...") that is only progress text, so terminal text
282
- // comes from non-tool assistant messages or trailing deltas.
283
- let copilotMessageBuffer = '';
284
- let copilotTaskCompleteSeen = false;
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
277
  let thinkingSent = false;
278
+ let taskCompleteFired = false;
279
+ let lastTaskCompleteSummary = '';
280
+ const toolUses = [];
292
281
 
293
282
  function _streamText(value) {
294
283
  return (maxTextLength && value.length > maxTextLength) ? value.slice(-maxTextLength) : value;
295
284
  }
296
285
 
297
- function _copilotAssistantMessageHasTools(obj) {
298
- const requests = obj?.data?.toolRequests;
299
- return Array.isArray(requests) && requests.length > 0;
300
- }
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
-
375
- function _captureCopilotTaskComplete(summary, success = true) {
376
- if (typeof summary !== 'string' || !summary) return;
377
- const finalSummary = _streamText(summary);
378
- const alreadySeen = copilotTaskCompleteSeen && copilotTaskCompleteSummary === finalSummary;
379
- copilotTaskCompleteSeen = true;
380
- copilotTaskCompleteSummary = finalSummary;
381
- const hadText = !!text;
382
- if (!hadText) {
383
- text = finalSummary;
384
- if (onChunk && finalSummary !== lastTextSent) {
385
- lastTextSent = finalSummary;
386
- onChunk(finalSummary);
286
+ // ── ctx surface — the only API the runtime stream consumer sees ─────────
287
+ const ctx = {
288
+ maxTextLength,
289
+ pushText(value) {
290
+ if (typeof value !== 'string' || !value) return;
291
+ const next = _streamText(value);
292
+ text = next;
293
+ if (onChunk && next !== lastTextSent) {
294
+ lastTextSent = next;
295
+ onChunk(next);
387
296
  }
388
- }
389
- copilotMessageBuffer = '';
390
- if (!alreadySeen && onTaskComplete) onTaskComplete({ summary: finalSummary, success: success !== false });
391
- }
392
-
393
- function captureEvent(obj) {
394
- if (!obj || typeof obj !== 'object') return;
395
-
396
- // ── Claude shape ────────────────────────────────────────────────────────
397
- if (obj.session_id) sessionId = obj.session_id;
398
- if (obj.type === 'stream_event') {
399
- _captureClaudeStreamEvent(obj);
400
- }
401
- if (obj.type === 'result' && typeof obj.result === 'string') {
402
- // Claude result event: terminal text + usage.
403
- text = maxTextLength ? obj.result.slice(-maxTextLength) : obj.result;
404
- if (obj.total_cost_usd || obj.usage) {
405
- usage = {
406
- costUsd: obj.total_cost_usd || 0,
407
- inputTokens: obj.usage?.input_tokens || 0,
408
- outputTokens: obj.usage?.output_tokens || 0,
409
- cacheRead: obj.usage?.cache_read_input_tokens || obj.usage?.cacheReadInputTokens || 0,
410
- cacheCreation: obj.usage?.cache_creation_input_tokens || obj.usage?.cacheCreationInputTokens || 0,
411
- durationMs: obj.duration_ms || 0,
412
- numTurns: obj.num_turns || 0,
413
- };
414
- }
415
- }
416
- if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
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 = '';
422
- for (const block of obj.message.content) {
423
- if (block?.type === 'text' && block.text) {
424
- assistantText += block.text;
425
- } else if (THINKING_BLOCK_TYPES.has(block?.type)) {
426
- _notifyThinking();
427
- } else if (block?.type === 'tool_use' && block.name) {
428
- const toolUse = { name: block.name, input: block.input || {} };
429
- toolUses.push(toolUse);
430
- if (onToolUse) onToolUse(toolUse.name, toolUse.input);
431
- }
432
- }
433
- if (assistantText) _captureClaudeText(assistantText);
434
- }
435
-
436
- // ── Copilot shape ───────────────────────────────────────────────────────
437
- if (obj.type === 'result' && typeof obj.sessionId === 'string') sessionId = obj.sessionId;
438
- if (obj.type === 'session.task_complete') {
439
- _captureCopilotTaskComplete(obj.data?.summary, obj.data?.success);
440
- }
441
- if (obj.type === 'assistant.reasoning' || obj.type === 'assistant.reasoning_delta') {
442
- _notifyThinking();
443
- }
444
- if (obj.type === 'assistant.message_delta' && typeof obj.data?.deltaContent === 'string') {
445
- if (copilotTaskCompleteSeen) return;
446
- copilotMessageBuffer += obj.data.deltaContent;
447
- if (onChunk && copilotMessageBuffer !== lastTextSent) {
448
- lastTextSent = copilotMessageBuffer;
449
- onChunk(copilotMessageBuffer);
450
- }
451
- }
452
- if (obj.type === 'assistant.message' && typeof obj.data?.content === 'string') {
453
- // Tool-request narration ("I'll look into this...") is progress text, not
454
- // the final answer. Keep streaming it live, but don't let it become the
455
- // terminal result if the process exits before a final answer message.
456
- const content = obj.data.content;
457
- if (content && !_copilotAssistantMessageHasTools(obj)) text = _streamText(content);
458
- copilotMessageBuffer = '';
459
- if (Array.isArray(obj.data.toolRequests)) {
460
- for (const tr of obj.data.toolRequests) {
461
- if (tr && tr.name) {
462
- if (tr.name === 'task_complete') {
463
- _captureCopilotTaskComplete(tr.arguments?.summary || tr.intentionSummary);
464
- continue;
465
- }
466
- const toolUse = { name: tr.name, input: tr.arguments || {} };
467
- toolUses.push(toolUse);
468
- if (onToolUse) onToolUse(toolUse.name, toolUse.input);
469
- }
297
+ },
298
+ setText(value) {
299
+ // Hard-set text bypassing dedup for terminal events that should
300
+ // override any streamed text (Claude's `result`, Copilot's final
301
+ // assistant.message). onChunk is NOT fired here; this is the
302
+ // authoritative final-text path, not a streaming chunk.
303
+ if (typeof value !== 'string') return;
304
+ text = _streamText(value);
305
+ },
306
+ pushToolUse(name, input) {
307
+ if (!name) return;
308
+ const toolUse = { name, input: input || {} };
309
+ toolUses.push(toolUse);
310
+ if (onToolUse) onToolUse(toolUse.name, toolUse.input);
311
+ },
312
+ toolUseAlreadySeen(name, input) {
313
+ if (!name) return false;
314
+ const stringified = JSON.stringify(input || {});
315
+ return toolUses.some(t => t.name === name && JSON.stringify(t.input) === stringified);
316
+ },
317
+ notifyThinking() {
318
+ if (!onThinking || thinkingSent) return;
319
+ thinkingSent = true;
320
+ onThinking();
321
+ },
322
+ notifyTaskComplete(summary, success = true) {
323
+ if (typeof summary !== 'string' || !summary) return;
324
+ const finalSummary = _streamText(summary);
325
+ const alreadySeen = taskCompleteFired && lastTaskCompleteSummary === finalSummary;
326
+ lastTaskCompleteSummary = finalSummary;
327
+ // Surface as terminal text only if nothing streamed yet.
328
+ if (!text) {
329
+ text = finalSummary;
330
+ if (onChunk && finalSummary !== lastTextSent) {
331
+ lastTextSent = finalSummary;
332
+ onChunk(finalSummary);
470
333
  }
471
334
  }
472
- }
473
- if (obj.type === 'tool.execution_start' && obj.data?.toolName) {
474
- if (obj.data.toolName === 'task_complete') {
475
- _captureCopilotTaskComplete(obj.data.arguments?.summary);
476
- return;
477
- }
478
- const toolUse = { name: obj.data.toolName, input: obj.data.arguments || {} };
479
- // Dedup: assistant.message.toolRequests already adds this — only push if
480
- // we haven't seen it yet (toolCallId would be the unique key, but we
481
- // compare by name+input shape since not every consumer cares).
482
- if (!toolUses.some(t => t.name === toolUse.name && JSON.stringify(t.input) === JSON.stringify(toolUse.input))) {
483
- toolUses.push(toolUse);
484
- if (onToolUse) onToolUse(toolUse.name, toolUse.input);
335
+ if (!alreadySeen && onTaskComplete) {
336
+ taskCompleteFired = true;
337
+ onTaskComplete({ summary: finalSummary, success: success !== false });
338
+ } else {
339
+ taskCompleteFired = true;
485
340
  }
486
- }
487
- }
341
+ },
342
+ setUsage(u) { if (u) usage = u; },
343
+ setSessionId(id) { if (typeof id === 'string' && id) sessionId = id; },
344
+ };
345
+
346
+ const consumer = runtime.createStreamConsumer(ctx);
488
347
 
489
348
  function ingestStdout(chunk) {
490
349
  const str = chunk == null ? '' : chunk.toString();
@@ -494,7 +353,7 @@ function _createStreamAccumulator({
494
353
  lineBuf = lines.pop() || '';
495
354
  for (const line of lines) {
496
355
  const ev = runtime.parseStreamChunk(line);
497
- if (ev) captureEvent(ev);
356
+ if (ev) consumer.consume(ev);
498
357
  }
499
358
  }
500
359
 
@@ -506,12 +365,9 @@ function _createStreamAccumulator({
506
365
  const trimmed = lineBuf.trim();
507
366
  if (trimmed) {
508
367
  const ev = runtime.parseStreamChunk(trimmed);
509
- if (ev) captureEvent(ev);
510
- }
511
- if (copilotMessageBuffer && !copilotTaskCompleteSeen) {
512
- text = _streamText(copilotMessageBuffer);
368
+ if (ev) consumer.consume(ev);
513
369
  }
514
- if (!text && copilotTaskCompleteSummary) text = copilotTaskCompleteSummary;
370
+ if (!text && lastTaskCompleteSummary) text = lastTaskCompleteSummary;
515
371
  // Reconciliation: if any field is still missing, ask the runtime adapter
516
372
  // to re-parse the whole stdout. parseOutput() may catch a result event
517
373
  // that was malformed when streamed in chunks.
package/engine/routing.js CHANGED
@@ -156,7 +156,8 @@ function normalizeAgentHints(agentHints, authorAgent = null, agents = null) {
156
156
  return normalized;
157
157
  }
158
158
 
159
- function resolveAgent(workType, config, authorAgent = null, agentHints = null) {
159
+ function resolveAgent(workType, config, opts = {}) {
160
+ const { authorAgent = null, agentHints = null } = opts || {};
160
161
  const routes = getRoutingTableCached();
161
162
  const route = routes[workType] || routes['implement'] || { preferred: '_any_', fallback: '_any_' };
162
163
  const agents = config.agents || {};
@@ -360,6 +360,137 @@ function parseError(rawOutput) {
360
360
  return { message: '', code: null, retriable: true };
361
361
  }
362
362
 
363
+ // ── Stream Consumer ─────────────────────────────────────────────────────────
364
+ //
365
+ // Per-stream consumer factory invoked by engine/llm.js's accumulator. The
366
+ // accumulator owns global stream state (stdout/stderr/text dedup/tool dedup)
367
+ // and exposes the `ctx` API below; the consumer owns Claude-specific per-stream
368
+ // state (joined-text accumulator, content-block Map for tool/thinking
369
+ // tracking) and translates Claude event shapes into ctx callbacks.
370
+ //
371
+ // `ctx` shape (provided by accumulator):
372
+ // maxTextLength, pushText(value), pushToolUse(name, input),
373
+ // notifyThinking(), notifyTaskComplete(summary, success),
374
+ // setUsage(usage), setSessionId(id), setText(value),
375
+ // toolUseAlreadySeen(name, input)
376
+
377
+ const THINKING_BLOCK_TYPES = new Set(['thinking', 'redacted_thinking']);
378
+
379
+ function createStreamConsumer(ctx) {
380
+ // Per-stream local state. `claudeStreamBlocks` is kept for Map-based
381
+ // bookkeeping (tool-use blocks, thinking events, out-of-order text-block
382
+ // reassembly). The incremental `claudeJoinedText` string is the hot-path
383
+ // accumulator — appending one delta at a time keeps the stream loop O(n).
384
+ let claudeJoinedText = '';
385
+ const claudeStreamBlocks = new Map();
386
+
387
+ function _rebuildClaudeJoinedText() {
388
+ claudeJoinedText = Array.from(claudeStreamBlocks.keys()).sort((a, b) => a - b)
389
+ .map(index => claudeStreamBlocks.get(index))
390
+ .filter(block => block && block.type === 'text' && block.text)
391
+ .map(block => block.text)
392
+ .join('');
393
+ }
394
+
395
+ function _consumeStreamEvent(obj) {
396
+ const event = obj?.event;
397
+ if (!event || typeof event !== 'object') return;
398
+ if (event.type === 'message_start') {
399
+ claudeStreamBlocks.clear();
400
+ claudeJoinedText = '';
401
+ return;
402
+ }
403
+ if (event.type === 'content_block_start') {
404
+ const index = Number.isInteger(event.index) ? event.index : Number(event.index) || 0;
405
+ const block = event.content_block || {};
406
+ claudeStreamBlocks.set(index, { type: block.type || '', text: block.text || '' });
407
+ if (THINKING_BLOCK_TYPES.has(block.type)) ctx.notifyThinking();
408
+ // Out-of-order block landing: rebuild from the Map. Common case is
409
+ // monotonic in-order arrival, where the trailing-append branch wins.
410
+ const indices = Array.from(claudeStreamBlocks.keys());
411
+ const isTrailing = indices.every(i => i <= index);
412
+ if (!isTrailing) {
413
+ _rebuildClaudeJoinedText();
414
+ } else if (block.type === 'text' && block.text) {
415
+ claudeJoinedText += block.text;
416
+ }
417
+ if (claudeJoinedText) ctx.pushText(claudeJoinedText);
418
+ return;
419
+ }
420
+ if (event.type === 'content_block_delta') {
421
+ const index = Number.isInteger(event.index) ? event.index : Number(event.index) || 0;
422
+ const delta = event.delta || {};
423
+ if (delta.type === 'thinking_delta' || typeof delta.thinking === 'string') ctx.notifyThinking();
424
+ if (delta.type === 'text_delta' && typeof delta.text === 'string' && delta.text) {
425
+ const block = claudeStreamBlocks.get(index) || { type: 'text', text: '' };
426
+ block.type = 'text';
427
+ block.text = (block.text || '') + delta.text;
428
+ claudeStreamBlocks.set(index, block);
429
+ // Common case: deltas arrive monotonically per index — append directly.
430
+ claudeJoinedText += delta.text;
431
+ ctx.pushText(claudeJoinedText);
432
+ }
433
+ return;
434
+ }
435
+ // content_block_stop / message_delta / message_stop are observed but the
436
+ // accumulator doesn't need to act on them — terminal text comes via the
437
+ // result event below.
438
+ }
439
+
440
+ function consume(obj) {
441
+ if (!obj || typeof obj !== 'object') return;
442
+
443
+ if (obj.session_id) ctx.setSessionId(obj.session_id);
444
+
445
+ if (obj.type === 'stream_event') {
446
+ _consumeStreamEvent(obj);
447
+ return;
448
+ }
449
+
450
+ if (obj.type === 'result' && typeof obj.result === 'string') {
451
+ // Claude result event: terminal text + usage. Override any previously
452
+ // streamed text — this is the authoritative final answer.
453
+ ctx.setText(obj.result);
454
+ if (obj.total_cost_usd || obj.usage) {
455
+ ctx.setUsage({
456
+ costUsd: obj.total_cost_usd || 0,
457
+ inputTokens: obj.usage?.input_tokens || 0,
458
+ outputTokens: obj.usage?.output_tokens || 0,
459
+ cacheRead: obj.usage?.cache_read_input_tokens || obj.usage?.cacheReadInputTokens || 0,
460
+ cacheCreation: obj.usage?.cache_creation_input_tokens || obj.usage?.cacheCreationInputTokens || 0,
461
+ durationMs: obj.duration_ms || 0,
462
+ numTurns: obj.num_turns || 0,
463
+ });
464
+ }
465
+ return;
466
+ }
467
+
468
+ if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
469
+ // Claude assistant turn: content blocks (text + tool_use).
470
+ // Multi-text-block messages (with --include-partial-messages) need their
471
+ // text JOINED before pushText, otherwise each block overwrites the prior.
472
+ let assistantText = '';
473
+ for (const block of obj.message.content) {
474
+ if (block?.type === 'text' && block.text) {
475
+ assistantText += block.text;
476
+ } else if (THINKING_BLOCK_TYPES.has(block?.type)) {
477
+ ctx.notifyThinking();
478
+ } else if (block?.type === 'tool_use' && block.name) {
479
+ ctx.pushToolUse(block.name, block.input || {});
480
+ }
481
+ }
482
+ if (assistantText) ctx.pushText(assistantText);
483
+ }
484
+ }
485
+
486
+ function reset() {
487
+ claudeJoinedText = '';
488
+ claudeStreamBlocks.clear();
489
+ }
490
+
491
+ return { consume, reset };
492
+ }
493
+
363
494
  // ── Capability Block ────────────────────────────────────────────────────────
364
495
 
365
496
  const capabilities = {
@@ -387,6 +518,8 @@ const capabilities = {
387
518
  fallbackModel: true,
388
519
  // Engine controls session persistence (writes session.json on completion)
389
520
  sessionPersistenceControl: true,
521
+ // Adapter implements createStreamConsumer(ctx) — required by llm.js accumulator
522
+ streamConsumer: true,
390
523
  };
391
524
 
392
525
  // Install hint surfaced when `resolveBinary()` returns null. Consumed by
@@ -409,6 +542,8 @@ module.exports = {
409
542
  parseOutput,
410
543
  parseStreamChunk,
411
544
  parseError,
545
+ createStreamConsumer,
412
546
  // Exposed for unit tests — never imported by engine code
413
547
  _CLAUDE_SHORTHANDS,
548
+ THINKING_BLOCK_TYPES,
414
549
  };
@@ -529,6 +529,112 @@ async function listModels({ env = process.env, timeoutMs = 10000 } = {}) {
529
529
  return models;
530
530
  }
531
531
 
532
+ // ── Stream Consumer ─────────────────────────────────────────────────────────
533
+ //
534
+ // Per-stream consumer factory invoked by engine/llm.js's accumulator. Owns
535
+ // Copilot-specific per-stream state (delta-content buffer, task_complete
536
+ // signal). Translates Copilot event shapes into ctx callbacks.
537
+ //
538
+ // `ctx` shape (provided by accumulator):
539
+ // maxTextLength, pushText(value), pushToolUse(name, input),
540
+ // notifyThinking(), notifyTaskComplete(summary, success),
541
+ // setUsage(usage), setSessionId(id), setText(value),
542
+ // toolUseAlreadySeen(name, input)
543
+
544
+ function _copilotAssistantMessageHasTools(obj) {
545
+ const requests = obj?.data?.toolRequests;
546
+ return Array.isArray(requests) && requests.length > 0;
547
+ }
548
+
549
+ function createStreamConsumer(ctx) {
550
+ // Copilot streams `assistant.message_delta` with `data.deltaContent` chunks
551
+ // before emitting `assistant.message`. Tool-request narration ("I'll
552
+ // inspect...") is progress text only — terminal text comes from non-tool
553
+ // assistant messages or trailing deltas.
554
+ let copilotMessageBuffer = '';
555
+ let copilotTaskCompleteSeen = false;
556
+
557
+ function _captureTaskComplete(summary, success = true) {
558
+ if (typeof summary !== 'string' || !summary) return;
559
+ copilotTaskCompleteSeen = true;
560
+ copilotMessageBuffer = '';
561
+ ctx.notifyTaskComplete(summary, success !== false);
562
+ }
563
+
564
+ function consume(obj) {
565
+ if (!obj || typeof obj !== 'object') return;
566
+
567
+ if (obj.type === 'result' && typeof obj.sessionId === 'string') {
568
+ ctx.setSessionId(obj.sessionId);
569
+ }
570
+
571
+ if (obj.type === 'session.task_complete') {
572
+ _captureTaskComplete(obj.data?.summary, obj.data?.success);
573
+ return;
574
+ }
575
+
576
+ if (obj.type === 'assistant.reasoning' || obj.type === 'assistant.reasoning_delta') {
577
+ ctx.notifyThinking();
578
+ return;
579
+ }
580
+
581
+ if (obj.type === 'assistant.message_delta' && typeof obj.data?.deltaContent === 'string') {
582
+ if (copilotTaskCompleteSeen) return;
583
+ copilotMessageBuffer += obj.data.deltaContent;
584
+ ctx.pushText(copilotMessageBuffer);
585
+ return;
586
+ }
587
+
588
+ if (obj.type === 'assistant.message') {
589
+ // Process toolRequests EVEN WHEN data.content is undefined — tool-only
590
+ // assistant messages would otherwise be dropped (earlier review bug:
591
+ // the `typeof data.content === 'string'` gate skipped them entirely).
592
+ const data = obj.data || {};
593
+ const content = data.content;
594
+ const hasTools = _copilotAssistantMessageHasTools(obj);
595
+ if (typeof content === 'string') {
596
+ // Tool-request narration is progress text only — don't let it become
597
+ // the terminal answer. A non-tool assistant.message overrides any
598
+ // streamed deltas (Copilot's authoritative final text for the turn).
599
+ if (content && !hasTools) ctx.setText(content);
600
+ copilotMessageBuffer = '';
601
+ }
602
+ if (Array.isArray(data.toolRequests)) {
603
+ for (const tr of data.toolRequests) {
604
+ if (!tr || !tr.name) continue;
605
+ if (tr.name === 'task_complete') {
606
+ _captureTaskComplete(tr.arguments?.summary || tr.intentionSummary);
607
+ continue;
608
+ }
609
+ ctx.pushToolUse(tr.name, tr.arguments || {});
610
+ }
611
+ }
612
+ return;
613
+ }
614
+
615
+ if (obj.type === 'tool.execution_start' && obj.data?.toolName) {
616
+ if (obj.data.toolName === 'task_complete') {
617
+ _captureTaskComplete(obj.data.arguments?.summary);
618
+ return;
619
+ }
620
+ const name = obj.data.toolName;
621
+ const input = obj.data.arguments || {};
622
+ // Dedup against assistant.message.toolRequests — accumulator tracks
623
+ // the toolUses array and exposes a same-name+input check.
624
+ if (!ctx.toolUseAlreadySeen(name, input)) {
625
+ ctx.pushToolUse(name, input);
626
+ }
627
+ }
628
+ }
629
+
630
+ function reset() {
631
+ copilotMessageBuffer = '';
632
+ copilotTaskCompleteSeen = false;
633
+ }
634
+
635
+ return { consume, reset };
636
+ }
637
+
532
638
  // ── Capability Block ────────────────────────────────────────────────────────
533
639
 
534
640
  const capabilities = {
@@ -556,6 +662,8 @@ const capabilities = {
556
662
  fallbackModel: false,
557
663
  // Copilot manages session state internally in ~/.copilot/session-state/
558
664
  sessionPersistenceControl: false,
665
+ // Adapter implements createStreamConsumer(ctx) — required by llm.js accumulator
666
+ streamConsumer: true,
559
667
  };
560
668
 
561
669
  // Install hint surfaced when `resolveBinary()` returns null. Covers all
@@ -582,10 +690,12 @@ module.exports = {
582
690
  parseOutput,
583
691
  parseStreamChunk,
584
692
  parseError,
693
+ createStreamConsumer,
585
694
  // Exposed for unit tests — engine code MUST go through resolveRuntime + the
586
695
  // adapter contract; never reach into these helpers directly.
587
696
  _CLAUDE_SHORTHANDS,
588
697
  _resetShorthandWarning,
589
698
  _mapEffort,
699
+ _copilotAssistantMessageHasTools,
590
700
  KNOWN_EVENT_TYPES,
591
701
  };
package/engine.js CHANGED
@@ -2171,7 +2171,7 @@ async function discoverFromPrs(config, project) {
2171
2171
  if (evalLoopEnabled && reviewStatus === 'changes-requested' && !awaitingReReview && !evalEscalated) {
2172
2172
  const key = `fix-${project?.name || 'default'}-${prDisplayId}`;
2173
2173
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2174
- const agentId = resolveAgent('fix', config, pr.agent);
2174
+ const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
2175
2175
  if (!agentId) continue;
2176
2176
 
2177
2177
  const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
@@ -2210,7 +2210,7 @@ async function discoverFromPrs(config, project) {
2210
2210
  }
2211
2211
  continue;
2212
2212
  }
2213
- const agentId = resolveAgent('fix', config, pr.agent);
2213
+ const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
2214
2214
  if (!agentId) continue;
2215
2215
 
2216
2216
  const coalesced = [...staleCoalesced, ...getCoalescedContexts(key)];
@@ -2290,7 +2290,7 @@ async function discoverFromPrs(config, project) {
2290
2290
  }
2291
2291
  } catch (e) { log('warn', `Pre-dispatch build check for ${pr.id}: ${e.message} — skipping dispatch`); continue; }
2292
2292
 
2293
- const agentId = resolveAgent('fix', config, pr.agent);
2293
+ const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
2294
2294
  if (!agentId) continue;
2295
2295
 
2296
2296
  let reviewNote = `Build is failing: ${pr.buildFailReason || 'Check CI pipeline for details'}. Fix the build errors and push.`;
@@ -2365,7 +2365,7 @@ async function discoverFromPrs(config, project) {
2365
2365
  } catch (e) { log('warn', `Pre-dispatch conflict check for ${pr.id}: ${e.message} — skipping dispatch`); liveSkip = true; }
2366
2366
 
2367
2367
  if (!liveSkip) {
2368
- const agentId = resolveAgent('fix', config, pr.agent);
2368
+ const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
2369
2369
  if (agentId) {
2370
2370
  const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2371
2371
  pr_id: pr.id, pr_branch: pr.branch || '',
@@ -2502,7 +2502,7 @@ function discoverFromWorkItems(config, project) {
2502
2502
  needsWrite = true;
2503
2503
  }
2504
2504
  const agentHints = routing.extractAgentHints(item);
2505
- const agentId = item.agent || resolveAgent(workType, config, null, agentHints);
2505
+ const agentId = item.agent || resolveAgent(workType, config, { agentHints });
2506
2506
  if (!agentId) {
2507
2507
  // Check if reason is budget
2508
2508
  const cfgAgents = config.agents || {};
@@ -3022,7 +3022,7 @@ function discoverCentralWorkItems(config) {
3022
3022
  } else {
3023
3023
  // ─── Normal: single agent dispatch ──────────────────────────────
3024
3024
  const agentHints = routing.extractAgentHints(item);
3025
- const agentId = item.agent || resolveAgent(workType, config, null, agentHints);
3025
+ const agentId = item.agent || resolveAgent(workType, config, { agentHints });
3026
3026
  if (!agentId) continue;
3027
3027
 
3028
3028
  const agentName = config.agents[agentId]?.name || agentId;
@@ -3664,7 +3664,7 @@ async function tickInner() {
3664
3664
  // be of type string. Received undefined` and re-queues — every tick. Try to
3665
3665
  // resolve a fallback via routing; if none is available, skip this tick.
3666
3666
  if (!item.agent || typeof item.agent !== 'string') {
3667
- const fallback = resolveAgent(item.type || WORK_TYPE.FIX, config, null, routing.extractAgentHints(item.meta?.item));
3667
+ const fallback = resolveAgent(item.type || WORK_TYPE.FIX, config, { agentHints: 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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1620",
3
+ "version": "0.1.1622",
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"