@yemi33/minions 0.1.1621 → 0.1.1623

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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1623 (2026-04-29)
4
+
5
+ ### Features
6
+ - resilient file-bug label preflight (#1840) (#1867)
7
+
3
8
  ## 0.1.1621 (2026-04-29)
4
9
 
5
10
  ### Features
@@ -1126,10 +1126,11 @@ async function ccExecuteAction(action, targetTabId) {
1126
1126
  case 'file-bug': {
1127
1127
  var res10 = await _ccFetch('/api/issues/create', { title: action.title, description: action.description, labels: action.labels });
1128
1128
  var d10 = await res10.json();
1129
+ var labelWarning = d10.warning ? ' <span style="color:var(--orange)">(' + escHtml(d10.warning) + ')</span>' : '';
1129
1130
  if (d10.url) {
1130
- status.innerHTML = '&#128027; Bug filed: <a href="' + escHtml(d10.url) + '" target="_blank" style="color:var(--blue)">' + escHtml(action.title) + '</a>';
1131
+ status.innerHTML = '&#128027; Bug filed: <a href="' + escHtml(d10.url) + '" target="_blank" style="color:var(--blue)">' + escHtml(action.title) + '</a>' + labelWarning;
1131
1132
  } else {
1132
- status.innerHTML = '&#128027; Bug filed: <strong>' + escHtml(action.title) + '</strong> — <a href="https://github.com/yemi33/minions/issues" target="_blank" style="color:var(--blue)">view issues</a>';
1133
+ status.innerHTML = '&#128027; Bug filed: <strong>' + escHtml(action.title) + '</strong> — <a href="https://github.com/yemi33/minions/issues" target="_blank" style="color:var(--blue)">view issues</a>' + labelWarning;
1133
1134
  }
1134
1135
  status.style.color = 'var(--green)';
1135
1136
  break;
package/dashboard.js CHANGED
@@ -23,6 +23,7 @@ const queries = require('./engine/queries');
23
23
  const teams = require('./engine/teams');
24
24
  const ado = require('./engine/ado');
25
25
  const gh = require('./engine/github');
26
+ const issues = require('./engine/issues');
26
27
  const watchesMod = require('./engine/watches');
27
28
  const os = require('os');
28
29
 
@@ -584,7 +585,7 @@ function _ensureCcLiveStream(tabId) {
584
585
  tabId,
585
586
  text: '',
586
587
  tools: [],
587
- thinking: false,
588
+ thinkingSent: false,
588
589
  donePayload: null,
589
590
  writer: null,
590
591
  endResponse: null,
@@ -4467,40 +4468,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4467
4468
  try {
4468
4469
  const body = await readBody(req);
4469
4470
  if (!body.title) return jsonReply(res, 400, { error: 'title required' });
4470
-
4471
- // Check gh CLI is available
4472
- try { shared.exec('gh --version', { encoding: 'utf-8', timeout: 5000, windowsHide: true }); }
4473
- catch { return jsonReply(res, 500, { error: 'gh CLI not installed. Run: npm install -g gh' }); }
4474
-
4475
- const repo = 'yemi33/minions';
4476
- const labels = (body.labels || ['bug']).join(',');
4477
- const bugBody = (body.description || '') + '\n\n---\n_Filed via Minions dashboard_';
4478
-
4479
- // Write body to temp file to avoid shell escaping issues with quotes, backticks, newlines
4480
- const tmpBody = path.join(ENGINE_DIR, 'tmp', `bug-body-${Date.now()}.md`);
4481
- safeWrite(tmpBody, bugBody);
4482
- const safeTitle = body.title.replace(/["`$\\]/g, '');
4483
- try {
4484
- const cmd = `gh issue create --repo "${repo}" --title "${safeTitle}" --body-file "${tmpBody}" --label "${labels}" 2>&1`;
4485
- const result = shared.exec(cmd, { encoding: 'utf-8', timeout: 30000, windowsHide: true });
4486
- shared.safeUnlink(tmpBody);
4487
- // Detect gh errors in output
4488
- if (result.includes('authentication') || result.includes('auth login')) {
4489
- return jsonReply(res, 401, { error: 'GitHub auth required. Run: gh auth login' });
4490
- }
4491
- const urlMatch = result.match(/https:\/\/github\.com\/\S+/);
4492
- if (!urlMatch) {
4493
- return jsonReply(res, 500, { error: 'Issue may not have been created: ' + result.trim().slice(0, 200) });
4494
- }
4495
- return jsonReply(res, 200, { ok: true, url: urlMatch[0], output: result.trim() });
4496
- } catch (e) {
4497
- shared.safeUnlink(tmpBody);
4498
- throw e;
4499
- }
4471
+ const result = issues.createGitHubIssue({
4472
+ title: body.title,
4473
+ description: body.description || '',
4474
+ labels: body.labels,
4475
+ repo: 'yemi33/minions',
4476
+ tmpDir: path.join(ENGINE_DIR, 'tmp'),
4477
+ });
4478
+ return jsonReply(res, 200, result);
4500
4479
  } catch (e) {
4501
- const msg = e.message || '';
4502
- if (msg.includes('ENOENT') || msg.includes('not found')) return jsonReply(res, 500, { error: 'gh CLI not found. Install from https://cli.github.com/' });
4503
- return jsonReply(res, 500, { error: msg });
4480
+ return jsonReply(res, e.statusCode || 500, { error: e.message || 'Issue creation failed' });
4504
4481
  }
4505
4482
  }
4506
4483
 
@@ -4635,6 +4612,40 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4635
4612
  return out;
4636
4613
  }
4637
4614
 
4615
+ /**
4616
+ * Build the callLLMStreaming invocation for the SSE Command Center path.
4617
+ * Both the initial call and the post-resume-fail retry share the same
4618
+ * onChunk/onToolUse/onThinking shape — only `sessionId` differs (set on
4619
+ * initial call, undefined on retry). Hoisted to keep the two call sites
4620
+ * in lock-step.
4621
+ */
4622
+ function _invokeCcStream({ prompt, sessionId, liveState, toolUses, model, effort, maxTurns, engineConfig }) {
4623
+ const { callLLMStreaming } = require('./engine/llm');
4624
+ return callLLMStreaming(prompt, CC_STATIC_SYSTEM_PROMPT, {
4625
+ timeout: 900000, label: 'command-center', model, maxTurns,
4626
+ allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
4627
+ sessionId, effort, direct: true,
4628
+ engineConfig,
4629
+ onChunk: (text) => {
4630
+ const display = stripCCActionsForStream(text);
4631
+ liveState.text = display;
4632
+ // Once text is flowing, the SSE-replay branch (live.thinkingSent &&
4633
+ // !live.text) shouldn't show stale "Thinking…" on reconnect.
4634
+ if (liveState.thinkingSent) liveState.thinkingSent = false;
4635
+ if (liveState.writer) liveState.writer({ type: 'chunk', text: display });
4636
+ },
4637
+ onToolUse: (name, input) => {
4638
+ toolUses.push({ name, input: input || {} });
4639
+ liveState.tools.push({ name, input: input || {} });
4640
+ if (liveState.writer) liveState.writer({ type: 'tool', name, input: _lightToolInput(input) });
4641
+ },
4642
+ onThinking: () => {
4643
+ liveState.thinkingSent = true;
4644
+ if (liveState.writer) liveState.writer({ type: 'thinking', text: 'Thinking...' });
4645
+ },
4646
+ });
4647
+ }
4648
+
4638
4649
  async function handleCommandCenterStream(req, res) {
4639
4650
  // SSE Origin gate (belt-and-suspenders: the top-level dispatcher has
4640
4651
  // already rejected disallowed origins on POST, but validate again here
@@ -4702,7 +4713,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4702
4713
  for (const tool of live.tools || []) {
4703
4714
  writeCcEvent({ type: 'tool', name: tool.name, input: _lightToolInput(tool.input) });
4704
4715
  }
4705
- if (live.thinking && !live.text) writeCcEvent({ type: 'thinking', text: 'Thinking...' });
4716
+ if (live.thinkingSent && !live.text) writeCcEvent({ type: 'thinking', text: 'Thinking...' });
4706
4717
  if (live.text) writeCcEvent({ type: 'chunk', text: live.text });
4707
4718
  if (live.donePayload) {
4708
4719
  writeCcEvent(live.donePayload);
@@ -4773,33 +4784,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4773
4784
  const preamble = wasResume ? '' : buildCCStatePreamble();
4774
4785
  const prompt = (preamble ? preamble + '\n\n---\n\n' : '') + body.message;
4775
4786
 
4776
- const { callLLMStreaming, trackEngineUsage: trackUsage } = require('./engine/llm');
4787
+ const { trackEngineUsage: trackUsage } = require('./engine/llm');
4777
4788
  const streamModel = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
4778
4789
  const streamEffort = CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort;
4779
4790
  const ccMaxTurns = CONFIG.engine?.ccMaxTurns || shared.ENGINE_DEFAULTS.ccMaxTurns;
4780
4791
  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,
4792
+ const llmPromise = _invokeCcStream({
4793
+ prompt, sessionId, liveState, toolUses,
4794
+ model: streamModel, effort: streamEffort, maxTurns: ccMaxTurns,
4785
4795
  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
4796
  });
4804
4797
  _ccStreamAbort = llmPromise.abort;
4805
4798
  liveState.abortFn = _ccStreamAbort;
@@ -4814,33 +4807,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4814
4807
  const freshPreamble = buildCCStatePreamble();
4815
4808
  const freshPrompt = (freshPreamble ? freshPreamble + '\n\n---\n\n' : '') + body.message;
4816
4809
  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,
4810
+ const retryPromise = _invokeCcStream({
4811
+ prompt: freshPrompt, sessionId: undefined, liveState, toolUses,
4812
+ model: streamModel, effort: streamEffort, maxTurns: ccMaxTurns,
4821
4813
  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;
4814
+ });
4815
+ _ccStreamAbort = retryPromise.abort;
4816
+ liveState.abortFn = _ccStreamAbort;
4817
+ ccInFlightAborts.set(tabId, _ccStreamAbort);
4818
+ const retryResult = await retryPromise;
4844
4819
  trackUsage('command-center', retryResult.usage);
4845
4820
  if (retryResult.text) {
4846
4821
  // 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-29T15:03:46.217Z"
5
5
  }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * engine/issues.js — GitHub issue creation helpers.
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { execFileSync: _execFileSync } = require('child_process');
8
+
9
+ const DEFAULT_REPO = 'yemi33/minions';
10
+ const DEFAULT_LABELS = ['bug'];
11
+
12
+ class GitHubIssueError extends Error {
13
+ constructor(message, statusCode = 500) {
14
+ super(message);
15
+ this.name = 'GitHubIssueError';
16
+ this.statusCode = statusCode;
17
+ }
18
+ }
19
+
20
+ function normalizeLabels(labels, defaultLabels = DEFAULT_LABELS) {
21
+ let raw;
22
+ if (labels == null) raw = defaultLabels;
23
+ else if (Array.isArray(labels)) raw = labels;
24
+ else if (typeof labels === 'string') raw = labels.split(',');
25
+ else raw = [];
26
+
27
+ const seen = new Set();
28
+ const out = [];
29
+ for (const label of raw) {
30
+ const value = String(label || '').trim();
31
+ if (!value) continue;
32
+ const key = value.toLowerCase();
33
+ if (seen.has(key)) continue;
34
+ seen.add(key);
35
+ out.push(value);
36
+ }
37
+ return out;
38
+ }
39
+
40
+ function ghMessage(err) {
41
+ if (!err) return '';
42
+ return [err.message, err.stdout, err.stderr]
43
+ .filter(Boolean)
44
+ .map(String)
45
+ .join('\n');
46
+ }
47
+
48
+ function conciseGhMessage(errOrText) {
49
+ const text = typeof errOrText === 'string' ? errOrText : ghMessage(errOrText);
50
+ return text.replace(/\s+/g, ' ').trim().slice(0, 240);
51
+ }
52
+
53
+ function isAuthError(errOrText) {
54
+ return /(authentication|auth login|not authenticated|bad credentials|http 401|requires authentication)/i.test(
55
+ typeof errOrText === 'string' ? errOrText : ghMessage(errOrText)
56
+ );
57
+ }
58
+
59
+ function isLabelUnavailableError(errOrText) {
60
+ const msg = typeof errOrText === 'string' ? errOrText : ghMessage(errOrText);
61
+ return /label/i.test(msg) && /(not found|not exist|unavailable|invalid|could not add|could not resolve|does not exist)/i.test(msg);
62
+ }
63
+
64
+ function extractIssueUrl(output) {
65
+ const match = String(output || '').match(/https:\/\/github\.com\/\S+\/issues\/\d+/);
66
+ return match ? match[0] : null;
67
+ }
68
+
69
+ function runGh(execFileSync, args, timeout) {
70
+ return execFileSync('gh', args, {
71
+ encoding: 'utf8',
72
+ timeout,
73
+ windowsHide: true,
74
+ });
75
+ }
76
+
77
+ function listRepoLabels({ repo, execFileSync }) {
78
+ const output = runGh(execFileSync, ['label', 'list', '--repo', repo, '--json', 'name', '--limit', '1000'], 15000);
79
+ const parsed = JSON.parse(output || '[]');
80
+ if (!Array.isArray(parsed)) {
81
+ throw new GitHubIssueError('GitHub label list returned an unexpected response shape');
82
+ }
83
+ const labelsByLower = new Map();
84
+ for (const item of parsed) {
85
+ if (!item || typeof item.name !== 'string') continue;
86
+ labelsByLower.set(item.name.toLowerCase(), item.name);
87
+ }
88
+ return labelsByLower;
89
+ }
90
+
91
+ function resolveLabels({ labels, repo, execFileSync }) {
92
+ const requested = normalizeLabels(labels);
93
+ if (requested.length === 0) {
94
+ return { requested, labelsToApply: [], labelsSkipped: [], validationUnavailable: false };
95
+ }
96
+
97
+ try {
98
+ const available = listRepoLabels({ repo, execFileSync });
99
+ const labelsToApply = [];
100
+ const labelsSkipped = [];
101
+ for (const label of requested) {
102
+ const matched = available.get(label.toLowerCase());
103
+ if (matched) labelsToApply.push(matched);
104
+ else labelsSkipped.push(label);
105
+ }
106
+ return { requested, labelsToApply, labelsSkipped, validationUnavailable: false };
107
+ } catch (e) {
108
+ if (e instanceof GitHubIssueError) throw e;
109
+ if (isAuthError(e)) throw new GitHubIssueError('GitHub auth required. Run: gh auth login', 401);
110
+ return { requested, labelsToApply: requested, labelsSkipped: [], validationUnavailable: true };
111
+ }
112
+ }
113
+
114
+ function buildWarning(labelsSkipped, filedWithoutLabels) {
115
+ if (!labelsSkipped.length) return undefined;
116
+ const base = `Skipped unavailable GitHub label(s): ${labelsSkipped.join(', ')}.`;
117
+ return filedWithoutLabels ? `${base} Filed without labels.` : base;
118
+ }
119
+
120
+ function createIssueWithLabels({ title, bodyFile, repo, labels, execFileSync }) {
121
+ const args = ['issue', 'create', '--repo', repo, '--title', title, '--body-file', bodyFile];
122
+ if (labels.length > 0) args.push('--label', labels.join(','));
123
+ const output = runGh(execFileSync, args, 30000);
124
+ const url = extractIssueUrl(output);
125
+ if (!url) {
126
+ throw new GitHubIssueError(`Issue may not have been created: ${conciseGhMessage(output)}`);
127
+ }
128
+ return { url, output: String(output || '').trim() };
129
+ }
130
+
131
+ function createGitHubIssue({
132
+ title,
133
+ description = '',
134
+ labels,
135
+ repo = DEFAULT_REPO,
136
+ tmpDir,
137
+ execFileSync = _execFileSync,
138
+ } = {}) {
139
+ if (!title) throw new GitHubIssueError('title required', 400);
140
+
141
+ try {
142
+ runGh(execFileSync, ['--version'], 5000);
143
+ } catch (e) {
144
+ throw new GitHubIssueError('gh CLI not found. Install from https://cli.github.com/');
145
+ }
146
+
147
+ const issueBody = `${description || ''}\n\n---\n_Filed via Minions dashboard_`;
148
+ const dir = tmpDir || path.join(__dirname, 'tmp');
149
+ fs.mkdirSync(dir, { recursive: true });
150
+ const bodyFile = path.join(dir, `bug-body-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
151
+ fs.writeFileSync(bodyFile, issueBody);
152
+
153
+ let resolved;
154
+ try {
155
+ resolved = resolveLabels({ labels, repo, execFileSync });
156
+ const created = createIssueWithLabels({
157
+ title,
158
+ bodyFile,
159
+ repo,
160
+ labels: resolved.labelsToApply,
161
+ execFileSync,
162
+ });
163
+ const filedWithoutLabels = resolved.requested.length > 0 && resolved.labelsToApply.length === 0;
164
+ return {
165
+ ok: true,
166
+ url: created.url,
167
+ output: created.output,
168
+ labelsRequested: resolved.requested,
169
+ labelsApplied: resolved.labelsToApply,
170
+ labelsSkipped: resolved.labelsSkipped,
171
+ warning: buildWarning(resolved.labelsSkipped, filedWithoutLabels),
172
+ };
173
+ } catch (e) {
174
+ if (e instanceof GitHubIssueError) throw e;
175
+ if (isAuthError(e)) throw new GitHubIssueError('GitHub auth required. Run: gh auth login', 401);
176
+ if (resolved && resolved.labelsToApply.length > 0 && isLabelUnavailableError(e)) {
177
+ try {
178
+ const created = createIssueWithLabels({ title, bodyFile, repo, labels: [], execFileSync });
179
+ const skipped = normalizeLabels([...resolved.labelsSkipped, ...resolved.labelsToApply], []);
180
+ return {
181
+ ok: true,
182
+ url: created.url,
183
+ output: created.output,
184
+ labelsRequested: resolved.requested,
185
+ labelsApplied: [],
186
+ labelsSkipped: skipped,
187
+ warning: buildWarning(skipped, true),
188
+ };
189
+ } catch (retryErr) {
190
+ if (isAuthError(retryErr)) throw new GitHubIssueError('GitHub auth required. Run: gh auth login', 401);
191
+ throw new GitHubIssueError(`GitHub issue creation failed after retrying without labels: ${conciseGhMessage(retryErr)}`);
192
+ }
193
+ }
194
+ throw new GitHubIssueError(`GitHub issue creation failed: ${conciseGhMessage(e)}`);
195
+ } finally {
196
+ try { fs.unlinkSync(bodyFile); } catch {}
197
+ }
198
+ }
199
+
200
+ module.exports = {
201
+ DEFAULT_LABELS,
202
+ GitHubIssueError,
203
+ normalizeLabels,
204
+ isLabelUnavailableError,
205
+ createGitHubIssue,
206
+ };
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.1623",
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"