@yemi33/minions 0.1.1587 → 0.1.1589

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.
@@ -1484,8 +1484,8 @@ function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount
1484
1484
 
1485
1485
  // ─── Agent Output Parsing ────────────────────────────────────────────────────
1486
1486
 
1487
- function parseAgentOutput(stdout) {
1488
- const { text, usage, sessionId, model } = shared.parseStreamJsonOutput(stdout, { maxTextLength: 2000 });
1487
+ function parseAgentOutput(stdout, runtimeName) {
1488
+ const { text, usage, sessionId, model } = shared.parseStreamJsonOutput(stdout, runtimeName, { maxTextLength: 2000 });
1489
1489
  return { resultSummary: text, taskUsage: usage, sessionId, model };
1490
1490
  }
1491
1491
 
@@ -1495,14 +1495,14 @@ function parseAgentOutput(stdout) {
1495
1495
  * Returns parsed object or null if not found / malformed.
1496
1496
  * If multiple blocks exist, the last one wins (agent may retry).
1497
1497
  */
1498
- function parseStructuredCompletion(stdout) {
1498
+ function parseStructuredCompletion(stdout, runtimeName) {
1499
1499
  if (!stdout || typeof stdout !== 'string') return null;
1500
1500
 
1501
1501
  // Extract text from stream-json output if needed
1502
1502
  let text = stdout;
1503
1503
  if (stdout.includes('"type":')) {
1504
1504
  try {
1505
- const parsed = shared.parseStreamJsonOutput(stdout);
1505
+ const parsed = shared.parseStreamJsonOutput(stdout, runtimeName);
1506
1506
  if (parsed.text) text = parsed.text;
1507
1507
  } catch {}
1508
1508
  }
@@ -1536,13 +1536,13 @@ function parseStructuredCompletion(stdout) {
1536
1536
  * Handle decomposition result — parse sub-items from agent output and create child work items.
1537
1537
  * Called from runPostCompletionHooks when type === 'decompose'.
1538
1538
  */
1539
- function handleDecompositionResult(stdout, meta, config) {
1539
+ function handleDecompositionResult(stdout, meta, config, runtimeName) {
1540
1540
 
1541
1541
  const parentId = meta?.item?.id;
1542
1542
  if (!parentId) return 0;
1543
1543
 
1544
1544
  // Parse sub-items JSON from agent output
1545
- const { text } = shared.parseStreamJsonOutput(stdout);
1545
+ const { text } = shared.parseStreamJsonOutput(stdout, runtimeName);
1546
1546
  const jsonMatch = text.match(/```json\s*\n([\s\S]*?)```/);
1547
1547
  if (!jsonMatch) {
1548
1548
  log('warn', `Decomposition for ${parentId}: no JSON block found in output`);
@@ -1628,10 +1628,15 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1628
1628
  const meta = dispatchItem.meta;
1629
1629
  const isSuccess = code === 0;
1630
1630
  const result = isSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR;
1631
- const { resultSummary, taskUsage, sessionId, model } = parseAgentOutput(stdout);
1631
+ // Runtime name comes from the dispatch entry (set when the agent was spawned).
1632
+ // Defaults to 'claude' when missing — preserves behavior for existing dispatches
1633
+ // and for the foundation-only state of this plan item; downstream items
1634
+ // (P-2a6d9c4f, P-9c4f2d6a) populate dispatchItem.meta.runtimeName at spawn time.
1635
+ const runtimeName = dispatchItem.meta?.runtimeName || dispatchItem.runtimeName || 'claude';
1636
+ const { resultSummary, taskUsage, sessionId, model } = parseAgentOutput(stdout, runtimeName);
1632
1637
 
1633
1638
  // Try structured completion protocol first (```completion block from agent output)
1634
- const structuredCompletion = parseStructuredCompletion(stdout);
1639
+ const structuredCompletion = parseStructuredCompletion(stdout, runtimeName);
1635
1640
  if (structuredCompletion) {
1636
1641
  log('info', `Structured completion from ${agentId}: status=${structuredCompletion.status}, pr=${structuredCompletion.pr || 'N/A'}`);
1637
1642
  }
@@ -1669,7 +1674,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1669
1674
  // Handle decomposition results — create sub-items from decompose agent output
1670
1675
  let skipDoneStatus = false;
1671
1676
  if (type === WORK_TYPE.DECOMPOSE && effectiveSuccess && meta?.item?.id) {
1672
- const subCount = handleDecompositionResult(stdout, meta, config);
1677
+ const subCount = handleDecompositionResult(stdout, meta, config, runtimeName);
1673
1678
  if (subCount > 0) skipDoneStatus = true; // parent already marked 'decomposed' by handler
1674
1679
  // If decomposition produced nothing, fall through to mark parent as done
1675
1680
  }
package/engine/llm.js CHANGED
@@ -1,15 +1,30 @@
1
1
  /**
2
- * engine/llm.js — Shared LLM utilities for Minions engine + dashboard
3
- * Provides callLLM() (with optional session resume) and trackEngineUsage().
2
+ * engine/llm.js — Shared LLM utilities for Minions engine + dashboard.
3
+ *
4
+ * Provides callLLM() / callLLMStreaming() (with optional session resume) and
5
+ * trackEngineUsage(). As of P-5e1b7a3c the CC / doc-chat direct-spawn path
6
+ * goes through the runtime adapter registry — same model used by the agent
7
+ * dispatch path (P-2a6d9c4f). This file holds zero `runtime.name === ...`
8
+ * branches; conditional behavior gates exclusively on `runtime.capabilities.*`
9
+ * flags or on event-shape inspection inside the streaming accumulator.
4
10
  */
5
11
 
12
+ const fs = require('fs');
6
13
  const path = require('path');
7
14
  const shared = require('./shared');
8
- const { safeWrite, safeUnlink, uid, ts, runFile, cleanChildEnv, parseStreamJsonOutput, mutateJsonFileLocked, appendTextTail, ENGINE_DEFAULTS } = shared;
15
+ const {
16
+ safeWrite, safeUnlink, uid, ts, runFile, cleanChildEnv,
17
+ parseStreamJsonOutput, mutateJsonFileLocked, appendTextTail,
18
+ ENGINE_DEFAULTS,
19
+ resolveCcCli, resolveCcModel,
20
+ } = shared;
21
+ const { resolveRuntime } = require('./runtimes');
9
22
 
10
23
  const MINIONS_DIR = shared.MINIONS_DIR;
11
24
  const ENGINE_DIR = path.join(MINIONS_DIR, 'engine');
12
25
 
26
+ // ─── Engine-Usage Metrics ────────────────────────────────────────────────────
27
+
13
28
  function trackEngineUsage(category, usage) {
14
29
  if (!usage) return;
15
30
  if (category && (category.startsWith('_test') || category.startsWith('test-'))) return;
@@ -46,88 +61,198 @@ function trackEngineUsage(category, usage) {
46
61
  } catch (e) { console.error('metrics update:', e.message); }
47
62
  }
48
63
 
49
- // ── Claude Binary Resolution (cached by spawn-agent.js) ─────────────────────
50
-
51
- let _claudeBinCache = null;
52
- let _claudeBinCacheTs = 0;
53
- const _CLAUDE_BIN_TTL = 1800000; // 30 min binary path rarely changes during a session
54
- function _resolveClaudeBin() {
55
- if (_claudeBinCache && Date.now() - _claudeBinCacheTs < _CLAUDE_BIN_TTL) return _claudeBinCache;
56
- _claudeBinCache = null;
57
- const caps = shared.safeJson(path.join(ENGINE_DIR, 'claude-caps.json'));
58
- if (caps?.claudeBin && require('fs').existsSync(caps.claudeBin)) {
59
- _claudeBinCache = { bin: caps.claudeBin, native: !!caps.claudeIsNative };
60
- _claudeBinCacheTs = Date.now();
61
- return _claudeBinCache;
64
+ // ─── Runtime Binary Resolution (TTL-cached) ──────────────────────────────────
65
+ //
66
+ // Replaces the legacy `_resolveClaudeBin()`. Each adapter's `resolveBinary()`
67
+ // already encapsulates its own disk-cache + PATH probe + npm probe (Claude) or
68
+ // PATH probe + gh-extension fallback (Copilot). We layer a per-process,
69
+ // per-runtime in-memory TTL cache on top so a busy CC session doesn't pay
70
+ // the tiny disk-read cost on every call.
71
+ //
72
+ // `runtime.capsFile` (an adapter-exported absolute path) is the on-disk cache
73
+ // path the adapter owns. We don't read it directly here — the adapter does
74
+ // that inside resolveBinary() but the test surface inspects `runtime.capsFile`
75
+ // to verify each adapter has its own file.
76
+
77
+ const _binCache = new Map(); // runtime.name → { bin, native, leadingArgs, ts }
78
+ const _BIN_TTL = 1800000; // 30 min
79
+
80
+ function _resolveBin(runtime) {
81
+ if (!runtime) return null;
82
+ const key = runtime.name;
83
+ const cached = _binCache.get(key);
84
+ if (cached && Date.now() - cached.ts < _BIN_TTL && fs.existsSync(cached.bin)) {
85
+ return { bin: cached.bin, native: cached.native, leadingArgs: cached.leadingArgs };
62
86
  }
63
- return null;
87
+ let resolved = null;
88
+ try { resolved = runtime.resolveBinary({ env: cleanChildEnv() }); }
89
+ catch { return null; }
90
+ if (!resolved) return null;
91
+ const leadingArgs = Array.isArray(resolved.leadingArgs) ? resolved.leadingArgs : [];
92
+ _binCache.set(key, { bin: resolved.bin, native: !!resolved.native, leadingArgs, ts: Date.now() });
93
+ return { bin: resolved.bin, native: !!resolved.native, leadingArgs };
64
94
  }
65
95
 
66
- // ── Spawn Helpers ───────────────────────────────────────────────────────────
96
+ function _resetBinCache() { _binCache.clear(); }
97
+
98
+ // ─── Spawn Helpers ───────────────────────────────────────────────────────────
67
99
 
68
- function _buildCliArgs({ model, maxTurns, allowedTools, effort, sessionId, sysPromptFile }) {
69
- const args = ['-p', '--output-format', 'stream-json', '--max-turns', String(maxTurns), '--model', model, '--verbose'];
70
- if (sysPromptFile) args.push('--system-prompt-file', sysPromptFile);
71
- if (allowedTools) args.push('--allowedTools', allowedTools);
72
- if (effort) args.push('--effort', effort);
73
- args.push('--permission-mode', 'bypassPermissions');
74
- if (sessionId) args.push('--resume', sessionId);
75
- return args;
100
+ /**
101
+ * Translate the unified opts bag into the named CLI flags consumed by
102
+ * `engine/spawn-agent.js`. spawn-agent.js parses these back into an opts
103
+ * object and calls `runtime.buildArgs(opts)` once — keeping the adapter as
104
+ * the single source of truth and avoiding double-flag emission.
105
+ *
106
+ * Capability gating (matches engine.js _buildAgentSpawnFlags from P-2a6d9c4f):
107
+ * - effort/sessionId/maxBudget/bare/fallbackModel are dropped when the
108
+ * runtime's matching capability is false.
109
+ * - Copilot-specific opts (stream, disableBuiltinMcps, suppressAgentsMd,
110
+ * reasoningSummaries) are emitted unconditionally; the Claude adapter
111
+ * ignores them via the "tolerate unknown opts" rule.
112
+ */
113
+ function _buildSpawnAgentFlags(runtime, opts = {}) {
114
+ const caps = (runtime && runtime.capabilities) || {};
115
+ const flags = ['--runtime', String(runtime?.name || 'claude')];
116
+
117
+ if (opts.maxTurns != null) flags.push('--max-turns', String(opts.maxTurns));
118
+ if (opts.model) flags.push('--model', String(opts.model));
119
+ if (opts.allowedTools) flags.push('--allowedTools', String(opts.allowedTools));
120
+
121
+ if (caps.effortLevels && opts.effort) flags.push('--effort', String(opts.effort));
122
+ if (caps.sessionResume && opts.sessionId) flags.push('--resume', String(opts.sessionId));
123
+ if (caps.budgetCap && opts.maxBudget != null) flags.push('--max-budget-usd', String(opts.maxBudget));
124
+ if (caps.bareMode && opts.bare === true) flags.push('--bare');
125
+ if (caps.fallbackModel && opts.fallbackModel) flags.push('--fallback-model', String(opts.fallbackModel));
126
+
127
+ if (opts.stream === 'on' || opts.stream === 'off') flags.push('--stream', opts.stream);
128
+ if (opts.disableBuiltinMcps === true) flags.push('--disable-builtin-mcps');
129
+ if (opts.suppressAgentsMd === true) flags.push('--no-custom-instructions');
130
+ if (opts.reasoningSummaries === true) flags.push('--enable-reasoning-summaries');
131
+
132
+ return flags;
76
133
  }
77
134
 
78
135
  /**
79
- * Spawn a claude CLI process. Returns { proc, cleanupFiles } or null if binary not cached.
80
- * When direct=true, spawns claude CLI directly (fewer syscalls). Otherwise uses spawn-agent.js.
136
+ * Spawn a runtime CLI process. Returns `{ proc, cleanupFiles }` or null when
137
+ * the runtime can't even be resolved.
138
+ *
139
+ * Direct path (`direct: true`): bypasses spawn-agent.js, spawns the runtime
140
+ * binary directly. Fewer file syscalls. Used by CC and doc-chat.
141
+ *
142
+ * Indirect path: uses engine/spawn-agent.js — mostly a fallback when the
143
+ * direct path can't resolve the binary cache. spawn-agent.js handles
144
+ * adapter resolution itself; we just hand it `--runtime <name>` plus the
145
+ * named flags it knows how to parse.
81
146
  */
82
- function _spawnProcess(promptText, sysPromptText, { direct, label, model, maxTurns, allowedTools, effort, sessionId }) {
83
- const fs = require('fs');
147
+ function _spawnProcess(promptText, sysPromptText, callOpts) {
148
+ const {
149
+ direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
150
+ maxBudget, bare, fallbackModel,
151
+ stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
152
+ } = callOpts;
153
+
84
154
  const id = uid();
85
155
  const tmpDir = path.join(ENGINE_DIR, 'tmp');
86
156
  if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
87
157
 
88
158
  const cleanupFiles = [];
89
- const resolved = direct ? _resolveClaudeBin() : null;
90
-
159
+ const caps = (runtime && runtime.capabilities) || {};
160
+ const adapterOpts = {
161
+ model, maxTurns, allowedTools, effort, sessionId,
162
+ maxBudget, bare, fallbackModel,
163
+ stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
164
+ };
165
+ const finalPrompt = runtime.buildPrompt(promptText, sysPromptText);
166
+
167
+ // ── Direct path ──
168
+ const resolved = direct ? _resolveBin(runtime) : null;
91
169
  if (resolved) {
92
170
  let sysTmpPath = null;
93
- if (!sessionId && sysPromptText) {
171
+ // Only write a sys-prompt tmp file when the runtime actually consumes one
172
+ // via --system-prompt-file (Claude) AND we're not resuming (resumed sessions
173
+ // already have the sys prompt baked in).
174
+ if (!sessionId && sysPromptText && caps.systemPromptFile) {
94
175
  sysTmpPath = path.join(tmpDir, `direct-sys-${id}.md`);
95
176
  fs.writeFileSync(sysTmpPath, sysPromptText);
96
177
  cleanupFiles.push(sysTmpPath);
178
+ adapterOpts.sysPromptFile = sysTmpPath;
179
+ }
180
+ // Capability-gate per-flag opts so the Claude path keeps emitting its
181
+ // historical flag set while Copilot only sees what it understands.
182
+ if (!caps.effortLevels) adapterOpts.effort = undefined;
183
+ if (!caps.sessionResume) adapterOpts.sessionId = undefined;
184
+ if (!caps.budgetCap) adapterOpts.maxBudget = undefined;
185
+ if (!caps.bareMode) adapterOpts.bare = undefined;
186
+ if (!caps.fallbackModel) adapterOpts.fallbackModel = undefined;
187
+ // promptViaArg=true: the adapter splices `--prompt <text>` into args itself.
188
+ if (caps.promptViaArg) adapterOpts.prompt = finalPrompt;
189
+
190
+ const cliArgs = runtime.buildArgs(adapterOpts);
191
+ const execArgs = resolved.native
192
+ ? [...resolved.leadingArgs, ...cliArgs]
193
+ : [resolved.bin, ...resolved.leadingArgs, ...cliArgs];
194
+ const execBin = resolved.native ? resolved.bin : process.execPath;
195
+ const proc = runFile(execBin, execArgs, {
196
+ cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv(),
197
+ });
198
+ if (caps.promptViaArg) {
199
+ // Adapter has already spliced the prompt into argv; close stdin so the
200
+ // child doesn't wait on it indefinitely.
201
+ try { proc.stdin.end(); } catch { /* may already be closed */ }
202
+ } else {
203
+ try { proc.stdin.write(finalPrompt); proc.stdin.end(); } catch { /* broken pipe */ }
97
204
  }
98
- const cliArgs = _buildCliArgs({ model, maxTurns, allowedTools, effort, sessionId, sysPromptFile: sysTmpPath });
99
- const proc = resolved.native
100
- ? runFile(resolved.bin, cliArgs, { cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv() })
101
- : runFile(process.execPath, [resolved.bin, ...cliArgs], { cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv() });
102
- try { proc.stdin.write(promptText); proc.stdin.end(); } catch { /* broken pipe */ }
103
205
  return { proc, cleanupFiles };
104
206
  }
105
207
 
106
- // Indirect: use spawn-agent.js
208
+ // Indirect: use spawn-agent.js (when direct=false or binary cache miss)
107
209
  const promptPath = path.join(tmpDir, `${label}-prompt-${id}.md`);
108
210
  const sysPath = path.join(tmpDir, `${label}-sys-${id}.md`);
109
- safeWrite(promptPath, promptText);
110
- safeWrite(sysPath, sysPromptText || '');
111
- // spawn-agent.js derives a PID file from prompt path include it in cleanup to prevent leaks
211
+ // The wrapper merges sys prompt into the user prompt for runtimes without
212
+ // --system-prompt-file (Copilot) write the user prompt as `finalPrompt`
213
+ // (system block already prepended by buildPrompt) for those, and just the
214
+ // raw user text for runtimes that take sys via a separate file (Claude).
215
+ if (caps.systemPromptFile) {
216
+ safeWrite(promptPath, promptText == null ? '' : String(promptText));
217
+ safeWrite(sysPath, sysPromptText || '');
218
+ } else {
219
+ safeWrite(promptPath, finalPrompt);
220
+ safeWrite(sysPath, '');
221
+ }
222
+ // spawn-agent.js derives a PID file from prompt path — include it in cleanup
223
+ // to prevent leaks even if the spawned process never writes one.
112
224
  const pidPath = promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid');
113
225
  cleanupFiles.push(promptPath, sysPath, pidPath);
114
226
 
115
227
  const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
116
- const args = [
117
- spawnScript, promptPath, sysPath,
118
- '--output-format', 'stream-json', '--max-turns', String(maxTurns), '--model', model,
119
- '--verbose',
120
- ];
121
- if (allowedTools) args.push('--allowedTools', allowedTools);
122
- if (effort) args.push('--effort', effort);
123
- args.push('--permission-mode', 'bypassPermissions');
124
- if (sessionId) args.push('--resume', sessionId);
125
-
126
- const proc = runFile(process.execPath, args, { cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv() });
228
+ const adapterFlags = _buildSpawnAgentFlags(runtime, {
229
+ model, maxTurns, allowedTools, effort, sessionId,
230
+ maxBudget, bare, fallbackModel,
231
+ stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
232
+ });
233
+ const args = [spawnScript, promptPath, sysPath, ...adapterFlags];
234
+
235
+ const proc = runFile(process.execPath, args, {
236
+ cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv(),
237
+ });
127
238
  return { proc, cleanupFiles };
128
239
  }
129
240
 
241
+ // ─── Streaming Accumulator ───────────────────────────────────────────────────
242
+ //
243
+ // Reads JSONL events as they stream in. JSON parsing is delegated to
244
+ // `runtime.parseStreamChunk()` — that gives us the runtime's defensive
245
+ // guarantees (e.g. Copilot rewrapping unknown event types as type:'ignore').
246
+ //
247
+ // Text / tool extraction branches on event SHAPE rather than runtime identity.
248
+ // Both Claude and Copilot events flow through here; for any given object only
249
+ // one branch matches because the event type strings don't collide.
250
+ // Final reconciliation calls `runtime.parseOutput(stdout)` so per-runtime
251
+ // finalization quirks (Copilot's premiumRequests, Claude's session_id) stay
252
+ // inside the adapter.
253
+
130
254
  function _createStreamAccumulator({
255
+ runtime,
131
256
  maxRawBytes,
132
257
  maxStderrBytes,
133
258
  maxLineBufferBytes,
@@ -144,15 +269,20 @@ function _createStreamAccumulator({
144
269
  let lastTextSent = '';
145
270
  const toolUses = [];
146
271
 
147
- function captureResult(obj) {
272
+ // Copilot streams `assistant.message_delta` with `data.deltaContent` chunks
273
+ // before emitting the final `assistant.message`. We accumulate the deltas
274
+ // for the live onChunk feed; the final `assistant.message.data.content`
275
+ // value is the authoritative text.
276
+ let copilotMessageBuffer = '';
277
+
278
+ function captureEvent(obj) {
148
279
  if (!obj || typeof obj !== 'object') return;
280
+
281
+ // ── Claude shape ────────────────────────────────────────────────────────
149
282
  if (obj.session_id) sessionId = obj.session_id;
150
- if (obj.type === 'result') {
151
- if (typeof obj.result === 'string') {
152
- // Tail-slice: VERDICTs, completion blocks, and PR URLs live at the END
153
- // of agent output. Head-slicing dropped them (#1234).
154
- text = maxTextLength ? obj.result.slice(-maxTextLength) : obj.result;
155
- }
283
+ if (obj.type === 'result' && typeof obj.result === 'string') {
284
+ // Claude result event: terminal text + usage.
285
+ text = maxTextLength ? obj.result.slice(-maxTextLength) : obj.result;
156
286
  if (obj.total_cost_usd || obj.usage) {
157
287
  usage = {
158
288
  costUsd: obj.total_cost_usd || 0,
@@ -166,9 +296,9 @@ function _createStreamAccumulator({
166
296
  }
167
297
  }
168
298
  if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
299
+ // Claude assistant turn: content blocks (text + tool_use).
169
300
  for (const block of obj.message.content) {
170
301
  if (block?.type === 'text' && block.text) {
171
- // Tail-slice for consistency with the result branch (see #1234).
172
302
  text = maxTextLength ? block.text.slice(-maxTextLength) : block.text;
173
303
  if (onChunk && block.text !== lastTextSent) {
174
304
  lastTextSent = block.text;
@@ -181,6 +311,43 @@ function _createStreamAccumulator({
181
311
  }
182
312
  }
183
313
  }
314
+
315
+ // ── Copilot shape ───────────────────────────────────────────────────────
316
+ if (obj.type === 'result' && typeof obj.sessionId === 'string') sessionId = obj.sessionId;
317
+ if (obj.type === 'assistant.message_delta' && typeof obj.data?.deltaContent === 'string') {
318
+ copilotMessageBuffer += obj.data.deltaContent;
319
+ if (onChunk && copilotMessageBuffer !== lastTextSent) {
320
+ lastTextSent = copilotMessageBuffer;
321
+ onChunk(copilotMessageBuffer);
322
+ }
323
+ }
324
+ if (obj.type === 'assistant.message' && typeof obj.data?.content === 'string') {
325
+ // Authoritative final assistant text for this turn.
326
+ const content = obj.data.content;
327
+ if (content) {
328
+ text = maxTextLength ? content.slice(-maxTextLength) : content;
329
+ copilotMessageBuffer = '';
330
+ }
331
+ if (Array.isArray(obj.data.toolRequests)) {
332
+ for (const tr of obj.data.toolRequests) {
333
+ if (tr && tr.name) {
334
+ const toolUse = { name: tr.name, input: tr.arguments || {} };
335
+ toolUses.push(toolUse);
336
+ if (onToolUse) onToolUse(toolUse.name, toolUse.input);
337
+ }
338
+ }
339
+ }
340
+ }
341
+ if (obj.type === 'tool.execution_start' && obj.data?.toolName) {
342
+ const toolUse = { name: obj.data.toolName, input: obj.data.arguments || {} };
343
+ // Dedup: assistant.message.toolRequests already adds this — only push if
344
+ // we haven't seen it yet (toolCallId would be the unique key, but we
345
+ // compare by name+input shape since not every consumer cares).
346
+ if (!toolUses.some(t => t.name === toolUse.name && JSON.stringify(t.input) === JSON.stringify(toolUse.input))) {
347
+ toolUses.push(toolUse);
348
+ if (onToolUse) onToolUse(toolUse.name, toolUse.input);
349
+ }
350
+ }
184
351
  }
185
352
 
186
353
  function ingestStdout(chunk) {
@@ -190,9 +357,8 @@ function _createStreamAccumulator({
190
357
  const lines = lineBuf.split('\n');
191
358
  lineBuf = lines.pop() || '';
192
359
  for (const line of lines) {
193
- const trimmed = line.trim();
194
- if (!trimmed || !trimmed.startsWith('{')) continue;
195
- try { captureResult(JSON.parse(trimmed)); } catch { /* incomplete JSON or non-JSON line */ }
360
+ const ev = runtime.parseStreamChunk(line);
361
+ if (ev) captureEvent(ev);
196
362
  }
197
363
  }
198
364
 
@@ -202,11 +368,15 @@ function _createStreamAccumulator({
202
368
 
203
369
  function finalize() {
204
370
  const trimmed = lineBuf.trim();
205
- if (trimmed.startsWith('{')) {
206
- try { captureResult(JSON.parse(trimmed)); } catch { /* incomplete trailing JSON */ }
371
+ if (trimmed) {
372
+ const ev = runtime.parseStreamChunk(trimmed);
373
+ if (ev) captureEvent(ev);
207
374
  }
375
+ // Reconciliation: if any field is still missing, ask the runtime adapter
376
+ // to re-parse the whole stdout. parseOutput() may catch a result event
377
+ // that was malformed when streamed in chunks.
208
378
  if (!text || !usage || !sessionId) {
209
- const parsedTail = parseStreamJsonOutput(stdout, maxTextLength ? { maxTextLength } : {});
379
+ const parsedTail = runtime.parseOutput(stdout, maxTextLength ? { maxTextLength } : {});
210
380
  if (!text && parsedTail.text) text = parsedTail.text;
211
381
  if (!usage && parsedTail.usage) usage = parsedTail.usage;
212
382
  if (!sessionId && parsedTail.sessionId) sessionId = parsedTail.sessionId;
@@ -217,14 +387,54 @@ function _createStreamAccumulator({
217
387
  return { ingestStdout, ingestStderr, finalize };
218
388
  }
219
389
 
220
- // ── Core LLM Call ───────────────────────────────────────────────────────────
390
+ // ─── Resolution Helpers (local, kept private) ───────────────────────────────
391
+
392
+ function _resolveRuntimeFor(callOpts) {
393
+ // Explicit `cli` opt wins; otherwise fall to `engineConfig` resolution;
394
+ // otherwise default to claude (the historical behavior).
395
+ let runtimeName = callOpts.cli;
396
+ if (!runtimeName && callOpts.engineConfig) runtimeName = resolveCcCli(callOpts.engineConfig);
397
+ if (!runtimeName) runtimeName = 'claude';
398
+ return resolveRuntime(runtimeName);
399
+ }
400
+
401
+ function _resolveModelFor(callOpts) {
402
+ // Explicit `model` opt wins (current behavior of every internal caller —
403
+ // kb-sweep, pipeline.js, dashboard CC paths). When unset and engineConfig is
404
+ // provided, resolve via shared.resolveCcModel — that's the new fleet path.
405
+ if (callOpts.model) return callOpts.model;
406
+ if (callOpts.engineConfig) return resolveCcModel(callOpts.engineConfig);
407
+ return undefined;
408
+ }
409
+
410
+ // ─── Core LLM Call ───────────────────────────────────────────────────────────
411
+
412
+ function callLLM(promptText, sysPromptText, opts = {}) {
413
+ const {
414
+ timeout = 120000, label = 'llm', maxTurns = 1, allowedTools = '',
415
+ sessionId = null, effort = null, direct = false,
416
+ // Backward-compat opt (overrides resolution):
417
+ model: modelOverride,
418
+ cli: cliOverride,
419
+ engineConfig,
420
+ // Cross-runtime + Copilot opts:
421
+ maxBudget, bare, fallbackModel,
422
+ stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
423
+ } = opts;
424
+
425
+ const runtime = _resolveRuntimeFor({ cli: cliOverride, engineConfig });
426
+ const model = _resolveModelFor({ model: modelOverride, engineConfig });
221
427
 
222
- function callLLM(promptText, sysPromptText, { timeout = 120000, label = 'llm', model = 'sonnet', maxTurns = 1, allowedTools = '', sessionId = null, effort = null, direct = false } = {}) {
223
428
  let _abort = null;
224
429
  const promise = new Promise((resolve) => {
225
430
  const _startMs = Date.now();
226
- const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, { direct, label, model, maxTurns, allowedTools, effort, sessionId });
431
+ const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, {
432
+ direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
433
+ maxBudget, bare, fallbackModel,
434
+ stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
435
+ });
227
436
  const acc = _createStreamAccumulator({
437
+ runtime,
228
438
  maxRawBytes: ENGINE_DEFAULTS.maxLlmRawBytes,
229
439
  maxStderrBytes: ENGINE_DEFAULTS.maxLlmStderrBytes,
230
440
  maxLineBufferBytes: ENGINE_DEFAULTS.maxLlmLineBufferBytes,
@@ -243,48 +453,68 @@ function callLLM(promptText, sysPromptText, { timeout = 120000, label = 'llm', m
243
453
  const parsed = acc.finalize();
244
454
  const durationMs = Date.now() - _startMs;
245
455
  const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
246
- resolve({ text: parsed.text || '', usage, sessionId: parsed.sessionId || null, code, stderr: parsed.stderr, raw: parsed.raw, toolUses: parsed.toolUses });
456
+ // parseError lets the adapter classify obvious failure modes (auth /
457
+ // context-limit / rate-limit / crash). Callers can ignore the field
458
+ // when they don't need it.
459
+ const errInfo = code !== 0
460
+ ? runtime.parseError([parsed.raw, parsed.stderr].filter(Boolean).join('\n'))
461
+ : { message: '', code: null, retriable: true };
462
+ resolve({
463
+ text: parsed.text || '',
464
+ usage,
465
+ sessionId: parsed.sessionId || null,
466
+ code,
467
+ stderr: parsed.stderr,
468
+ raw: parsed.raw,
469
+ toolUses: parsed.toolUses,
470
+ runtime: runtime.name,
471
+ errorClass: errInfo.code,
472
+ });
247
473
  });
248
474
 
249
475
  proc.on('error', (err) => {
250
476
  clearTimeout(timer);
251
477
  for (const f of cleanupFiles) safeUnlink(f);
252
478
  shared.log('error', `LLM spawn error (${label}): ${err.message}`);
253
- resolve({ text: '', usage: null, sessionId: null, code: 1, stderr: err.message, raw: '', toolUses: [] });
479
+ resolve({
480
+ text: '', usage: null, sessionId: null, code: 1,
481
+ stderr: err.message, raw: '', toolUses: [],
482
+ runtime: runtime.name, errorClass: null,
483
+ });
254
484
  });
255
485
  });
256
486
  promise.abort = () => { if (_abort) _abort(); };
257
487
  return promise;
258
488
  }
259
489
 
260
- /**
261
- * After a --resume call fails (non-zero exit or empty text), determine whether
262
- * the underlying session still exists (e.g. a tool timeout mid-turn) vs the
263
- * session is truly dead (expired, invalid ID, etc.).
264
- *
265
- * When the session still exists we should preserve it so the user can retry
266
- * with "try again" and resume into the same conversation.
267
- */
268
- function isResumeSessionStillValid(result) {
269
- if (!result) return false;
270
- // If the CLI returned a session_id in the parsed output or raw stream,
271
- // the session is alive — the call just failed mid-execution.
272
- if (result.sessionId) return true;
273
- if (result.raw && result.raw.includes('"session_id"')) return true;
274
- return false;
275
- }
276
-
277
490
  /**
278
491
  * Streaming variant of callLLM — emits text chunks via onChunk callback.
279
492
  * Returns the same result object as callLLM when the process completes.
280
493
  * onChunk(text) is called for each assistant text block as it arrives.
281
494
  */
282
- function callLLMStreaming(promptText, sysPromptText, { timeout = 120000, label = 'llm', model = 'sonnet', maxTurns = 1, allowedTools = '', sessionId = null, onChunk = () => {}, onToolUse = null, effort = null, direct = false } = {}) {
495
+ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
496
+ const {
497
+ timeout = 120000, label = 'llm', maxTurns = 1, allowedTools = '',
498
+ sessionId = null, onChunk = () => {}, onToolUse = null,
499
+ effort = null, direct = false,
500
+ model: modelOverride, cli: cliOverride, engineConfig,
501
+ maxBudget, bare, fallbackModel,
502
+ stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
503
+ } = opts;
504
+
505
+ const runtime = _resolveRuntimeFor({ cli: cliOverride, engineConfig });
506
+ const model = _resolveModelFor({ model: modelOverride, engineConfig });
507
+
283
508
  let _abort = null;
284
509
  const promise = new Promise((resolve) => {
285
510
  const _startMs = Date.now();
286
- const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, { direct, label, model, maxTurns, allowedTools, effort, sessionId });
511
+ const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, {
512
+ direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
513
+ maxBudget, bare, fallbackModel,
514
+ stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
515
+ });
287
516
  const acc = _createStreamAccumulator({
517
+ runtime,
288
518
  maxRawBytes: ENGINE_DEFAULTS.maxLlmRawBytes,
289
519
  maxStderrBytes: ENGINE_DEFAULTS.maxLlmStderrBytes,
290
520
  maxLineBufferBytes: ENGINE_DEFAULTS.maxLlmLineBufferBytes,
@@ -305,14 +535,31 @@ function callLLMStreaming(promptText, sysPromptText, { timeout = 120000, label =
305
535
  const parsed = acc.finalize();
306
536
  const durationMs = Date.now() - _startMs;
307
537
  const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
308
- resolve({ text: parsed.text || '', usage, sessionId: parsed.sessionId || null, code, stderr: parsed.stderr, raw: parsed.raw, toolUses: parsed.toolUses });
538
+ const errInfo = code !== 0
539
+ ? runtime.parseError([parsed.raw, parsed.stderr].filter(Boolean).join('\n'))
540
+ : { message: '', code: null, retriable: true };
541
+ resolve({
542
+ text: parsed.text || '',
543
+ usage,
544
+ sessionId: parsed.sessionId || null,
545
+ code,
546
+ stderr: parsed.stderr,
547
+ raw: parsed.raw,
548
+ toolUses: parsed.toolUses,
549
+ runtime: runtime.name,
550
+ errorClass: errInfo.code,
551
+ });
309
552
  });
310
553
 
311
554
  proc.on('error', (err) => {
312
555
  clearTimeout(timer);
313
556
  for (const f of cleanupFiles) safeUnlink(f);
314
557
  shared.log('error', `LLM-stream spawn error (${label}): ${err.message}`);
315
- resolve({ text: '', usage: null, sessionId: null, code: 1, stderr: err.message, raw: '', toolUses: [] });
558
+ resolve({
559
+ text: '', usage: null, sessionId: null, code: 1,
560
+ stderr: err.message, raw: '', toolUses: [],
561
+ runtime: runtime.name, errorClass: null,
562
+ });
316
563
  });
317
564
  });
318
565
  promise.abort = () => { if (_abort) _abort(); };
@@ -323,5 +570,10 @@ module.exports = {
323
570
  callLLM,
324
571
  callLLMStreaming,
325
572
  trackEngineUsage,
326
- isResumeSessionStillValid,
573
+ // Exposed for unit tests — engine code MUST use the runtime adapter contract.
574
+ _buildSpawnAgentFlags,
575
+ _resolveBin,
576
+ _resetBinCache,
577
+ _resolveRuntimeFor,
578
+ _resolveModelFor,
327
579
  };