@yemi33/minions 0.1.1621 → 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,10 +1,13 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1621 (2026-04-29)
3
+ ## 0.1.1622 (2026-04-29)
4
4
 
5
5
  ### Features
6
6
  - harden action block parsing (#1863)
7
7
 
8
+ ### Other
9
+ - refactor: move stream-event handling into runtime adapters
10
+
8
11
  ## 0.1.1620 (2026-04-29)
9
12
 
10
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,
@@ -4635,6 +4635,40 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4635
4635
  return out;
4636
4636
  }
4637
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
+
4638
4672
  async function handleCommandCenterStream(req, res) {
4639
4673
  // SSE Origin gate (belt-and-suspenders: the top-level dispatcher has
4640
4674
  // already rejected disallowed origins on POST, but validate again here
@@ -4702,7 +4736,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4702
4736
  for (const tool of live.tools || []) {
4703
4737
  writeCcEvent({ type: 'tool', name: tool.name, input: _lightToolInput(tool.input) });
4704
4738
  }
4705
- if (live.thinking && !live.text) writeCcEvent({ type: 'thinking', text: 'Thinking...' });
4739
+ if (live.thinkingSent && !live.text) writeCcEvent({ type: 'thinking', text: 'Thinking...' });
4706
4740
  if (live.text) writeCcEvent({ type: 'chunk', text: live.text });
4707
4741
  if (live.donePayload) {
4708
4742
  writeCcEvent(live.donePayload);
@@ -4773,33 +4807,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4773
4807
  const preamble = wasResume ? '' : buildCCStatePreamble();
4774
4808
  const prompt = (preamble ? preamble + '\n\n---\n\n' : '') + body.message;
4775
4809
 
4776
- const { callLLMStreaming, trackEngineUsage: trackUsage } = require('./engine/llm');
4810
+ const { trackEngineUsage: trackUsage } = require('./engine/llm');
4777
4811
  const streamModel = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
4778
4812
  const streamEffort = CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort;
4779
4813
  const ccMaxTurns = CONFIG.engine?.ccMaxTurns || shared.ENGINE_DEFAULTS.ccMaxTurns;
4780
4814
  let toolUses = [];
4781
- const llmPromise = callLLMStreaming(prompt, CC_STATIC_SYSTEM_PROMPT, {
4782
- timeout: 900000, label: 'command-center', model: streamModel, maxTurns: ccMaxTurns,
4783
- allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
4784
- sessionId, effort: streamEffort, direct: true,
4815
+ const llmPromise = _invokeCcStream({
4816
+ prompt, sessionId, liveState, toolUses,
4817
+ model: streamModel, effort: streamEffort, maxTurns: ccMaxTurns,
4785
4818
  engineConfig: CONFIG.engine,
4786
- onChunk: (text) => {
4787
- const display = stripCCActionsForStream(text);
4788
- liveState.text = display;
4789
- if (liveState.writer) liveState.writer({ type: 'chunk', text: display });
4790
- // Once text is flowing, the SSE-replay branch (live.thinking &&
4791
- // !live.text) shouldn't show stale "Thinking…" on reconnect.
4792
- if (liveState.thinking) liveState.thinking = false;
4793
- },
4794
- onToolUse: (name, input) => {
4795
- toolUses.push({ name, input: input || {} });
4796
- liveState.tools.push({ name, input: input || {} });
4797
- if (liveState.writer) liveState.writer({ type: 'tool', name, input: _lightToolInput(input) });
4798
- },
4799
- onThinking: () => {
4800
- liveState.thinking = true;
4801
- if (liveState.writer) liveState.writer({ type: 'thinking', text: 'Thinking...' });
4802
- }
4803
4819
  });
4804
4820
  _ccStreamAbort = llmPromise.abort;
4805
4821
  liveState.abortFn = _ccStreamAbort;
@@ -4814,33 +4830,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4814
4830
  const freshPreamble = buildCCStatePreamble();
4815
4831
  const freshPrompt = (freshPreamble ? freshPreamble + '\n\n---\n\n' : '') + body.message;
4816
4832
  toolUses = []; // discard stale metadata from the failed resume attempt
4817
- const retryPromise = callLLMStreaming(freshPrompt, CC_STATIC_SYSTEM_PROMPT, {
4818
- timeout: 900000, label: 'command-center', model: streamModel, maxTurns: ccMaxTurns,
4819
- allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
4820
- effort: streamEffort, direct: true,
4833
+ const retryPromise = _invokeCcStream({
4834
+ prompt: freshPrompt, sessionId: undefined, liveState, toolUses,
4835
+ model: streamModel, effort: streamEffort, maxTurns: ccMaxTurns,
4821
4836
  engineConfig: CONFIG.engine,
4822
- onChunk: (text) => {
4823
- const display = stripCCActionsForStream(text);
4824
- liveState.text = display;
4825
- if (liveState.writer) liveState.writer({ type: 'chunk', text: display });
4826
- // Same reset as the initial path so resume-fail retries don't
4827
- // leave a stale "Thinking…" frame visible on SSE reconnect.
4828
- if (liveState.thinking) liveState.thinking = false;
4829
- },
4830
- onToolUse: (name, input) => {
4831
- toolUses.push({ name, input: input || {} });
4832
- liveState.tools.push({ name, input: input || {} });
4833
- if (liveState.writer) liveState.writer({ type: 'tool', name, input: _lightToolInput(input) });
4834
- },
4835
- onThinking: () => {
4836
- liveState.thinking = true;
4837
- if (liveState.writer) liveState.writer({ type: 'thinking', text: 'Thinking...' });
4838
- }
4839
- });
4840
- _ccStreamAbort = retryPromise.abort;
4841
- liveState.abortFn = _ccStreamAbort;
4842
- ccInFlightAborts.set(tabId, _ccStreamAbort);
4843
- const retryResult = await retryPromise;
4837
+ });
4838
+ _ccStreamAbort = retryPromise.abort;
4839
+ liveState.abortFn = _ccStreamAbort;
4840
+ ccInFlightAborts.set(tabId, _ccStreamAbort);
4841
+ const retryResult = await retryPromise;
4844
4842
  trackUsage('command-center', retryResult.usage);
4845
4843
  if (retryResult.text) {
4846
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-29T11:01:36.483Z"
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.1621",
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"