@yemi33/minions 0.1.1588 → 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.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * engine/model-discovery.js — Per-runtime model catalog cache + REST helpers.
3
+ *
4
+ * Backs the dashboard endpoints:
5
+ * GET /api/runtimes → listAllRuntimes()
6
+ * GET /api/runtimes/:name/models → getRuntimeModels(name)
7
+ * POST /api/runtimes/:name/models/refresh → invalidateRuntimeModelsCache(name)
8
+ * → getRuntimeModels(name, { force: true })
9
+ *
10
+ * Cache shape (per-runtime file at `adapter.modelsCache`):
11
+ * { runtime: 'copilot', models: [{ id, name, provider }, ...] | null, cachedAt: ISO }
12
+ *
13
+ * Returning `{ models: null }` is the universal "free-text fallback" signal —
14
+ * the settings UI renders a free-text input instead of a dropdown. It happens in
15
+ * four cases:
16
+ * 1. `config.engine.disableModelDiscovery === true` (fleet-wide opt-out)
17
+ * 2. `adapter.capabilities.modelDiscovery !== true` (e.g. Claude — no API)
18
+ * 3. `adapter.listModels()` resolves to null/empty (no token, network error)
19
+ * 4. `adapter.listModels()` throws (transient API error — caller treats as null)
20
+ *
21
+ * Cache is persisted only when discovery actually ran (cases 3+4 still write a
22
+ * `{ models: null }` cache so we don't hammer the API on every refresh — TTL
23
+ * eventually re-tries). Cases 1+2 skip the cache write entirely.
24
+ */
25
+
26
+ const fs = require('fs');
27
+ const { resolveRuntime, listRuntimes } = require('./runtimes');
28
+
29
+ // 1 hour — matches the spec ("< 1hr TTL"). Keep separate from ENGINE_DEFAULTS
30
+ // so the test suite can override per-call without polluting the global config.
31
+ const MODEL_CACHE_TTL_MS = 60 * 60 * 1000;
32
+
33
+ function _readCacheFile(p) {
34
+ try {
35
+ const text = fs.readFileSync(p, 'utf8');
36
+ const obj = JSON.parse(text);
37
+ if (obj && typeof obj === 'object') return obj;
38
+ } catch { /* missing / malformed → cache miss */ }
39
+ return null;
40
+ }
41
+
42
+ function _writeCacheFile(p, obj) {
43
+ try { fs.writeFileSync(p, JSON.stringify(obj, null, 2)); } catch { /* best effort */ }
44
+ }
45
+
46
+ /**
47
+ * Return the registered runtime catalog for `GET /api/runtimes`.
48
+ *
49
+ * Shape: `[{ name, capabilities }, ...]`. Capabilities are spread into a fresh
50
+ * object so callers can't mutate the adapter's authoritative capability block.
51
+ */
52
+ function listAllRuntimes() {
53
+ const names = listRuntimes();
54
+ const runtimes = [];
55
+ for (const name of names) {
56
+ let adapter;
57
+ try { adapter = resolveRuntime(name); } catch { continue; }
58
+ runtimes.push({
59
+ name,
60
+ capabilities: { ...(adapter && adapter.capabilities ? adapter.capabilities : {}) },
61
+ });
62
+ }
63
+ return runtimes;
64
+ }
65
+
66
+ /**
67
+ * Read or refresh the cached model list for a runtime.
68
+ *
69
+ * @param {string} runtimeName Must be a registered runtime — caller maps the
70
+ * `Unknown runtime` throw onto an HTTP 404.
71
+ * @param {object} opts
72
+ * @param {boolean} opts.force Skip the cache and call `listModels()` directly.
73
+ * @param {number} opts.ttlMs Cache freshness window (default 1h). Tests use a
74
+ * tiny window to exercise the miss path.
75
+ * @param {object} opts.config Pass `getConfig()` result so the helper can
76
+ * honour `config.engine.disableModelDiscovery`
77
+ * without re-reading the file on every request.
78
+ * @returns {Promise<{runtime: string, models: object[]|null, cachedAt: string|null}>}
79
+ */
80
+ async function getRuntimeModels(runtimeName, { force = false, ttlMs = MODEL_CACHE_TTL_MS, config = null } = {}) {
81
+ // Throws "Unknown runtime ..." for unregistered names. Caller turns this into
82
+ // a 404; bubbling the throw is intentional so misconfigurations surface loudly
83
+ // (mirrors the registry contract documented in engine/runtimes/index.js).
84
+ const adapter = resolveRuntime(runtimeName);
85
+
86
+ // Case 1: fleet-wide opt-out. Skip the adapter entirely — never call its
87
+ // listModels() (which can hit the network) when the user explicitly disabled
88
+ // discovery. No cache write either; flipping the flag back on must produce a
89
+ // fresh fetch, not a stale `null` cache hit.
90
+ if (config && config.engine && config.engine.disableModelDiscovery === true) {
91
+ return { runtime: runtimeName, models: null, cachedAt: null };
92
+ }
93
+
94
+ // Case 2: adapter doesn't support discovery (Claude). Same short-circuit —
95
+ // don't write a cache file (`engine/claude-models.json` stays absent / empty
96
+ // per the spec: "engine/claude-models.json (always null)").
97
+ if (!adapter.capabilities || adapter.capabilities.modelDiscovery !== true) {
98
+ return { runtime: runtimeName, models: null, cachedAt: null };
99
+ }
100
+
101
+ const cachePath = adapter.modelsCache || null;
102
+
103
+ // Cache hit path — only on non-forced reads. We accept any cached payload
104
+ // whose `cachedAt` parses to a valid timestamp within the TTL window;
105
+ // `models: null` cached entries also count as fresh so we don't re-hit the
106
+ // API every page load when the token is missing.
107
+ if (!force && cachePath) {
108
+ const cached = _readCacheFile(cachePath);
109
+ if (cached && typeof cached.cachedAt === 'string') {
110
+ const ts = Date.parse(cached.cachedAt);
111
+ if (Number.isFinite(ts)) {
112
+ const age = Date.now() - ts;
113
+ if (age >= 0 && age < ttlMs) {
114
+ const models = Array.isArray(cached.models) ? cached.models : null;
115
+ return { runtime: runtimeName, models, cachedAt: cached.cachedAt };
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ // Cache miss / forced refresh — call the adapter. Any failure (null return,
122
+ // empty array, throw) collapses to `models: null` so the dashboard falls back
123
+ // to free-text input. We do NOT distinguish "API unreachable" from "API
124
+ // returned an empty list" — both are unactionable from a UI standpoint.
125
+ let models = null;
126
+ try {
127
+ const result = await adapter.listModels();
128
+ if (Array.isArray(result) && result.length > 0) {
129
+ // Defensive copy — adapter caches its own result internally and we don't
130
+ // want that to be mutable through the response we return.
131
+ models = result.map(m => ({ ...m }));
132
+ }
133
+ } catch { /* swallow — network/transient errors map to null */ }
134
+
135
+ const cachedAt = new Date().toISOString();
136
+ if (cachePath) {
137
+ _writeCacheFile(cachePath, { runtime: runtimeName, models, cachedAt });
138
+ }
139
+ return { runtime: runtimeName, models, cachedAt };
140
+ }
141
+
142
+ /**
143
+ * Delete the cached models file for a runtime. Returns `true` if a file was
144
+ * removed, `false` if there was nothing to remove. Throws on unknown runtime
145
+ * (caller maps to 404). Filesystem errors other than ENOENT propagate so the
146
+ * route handler can surface a real I/O failure to the operator instead of
147
+ * silently no-oping.
148
+ */
149
+ function invalidateRuntimeModelsCache(runtimeName) {
150
+ const adapter = resolveRuntime(runtimeName);
151
+ const cachePath = adapter && adapter.modelsCache;
152
+ if (!cachePath) return false;
153
+ try {
154
+ fs.unlinkSync(cachePath);
155
+ return true;
156
+ } catch (e) {
157
+ if (e && e.code === 'ENOENT') return false;
158
+ throw e;
159
+ }
160
+ }
161
+
162
+ module.exports = {
163
+ listAllRuntimes,
164
+ getRuntimeModels,
165
+ invalidateRuntimeModelsCache,
166
+ MODEL_CACHE_TTL_MS,
167
+ };
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * engine/preflight.js — Prerequisite and health checks for Minions.
3
3
  * Used by `minions init`, `minions start`, and `minions doctor`.
4
+ *
5
+ * Per-runtime binary + model-discovery checks (P-9e8a3f1d) — for every distinct
6
+ * CLI in use across `engine.defaultCli`, `engine.ccCli`, and `agents.<id>.cli`,
7
+ * we resolve the adapter from the registry and run its `resolveBinary()`. The
8
+ * cache is warmed via `listModels()` when the runtime supports discovery.
4
9
  */
5
10
 
6
11
  const fs = require('fs');
@@ -8,9 +13,12 @@ const path = require('path');
8
13
  const { execSync } = require('child_process');
9
14
 
10
15
  /**
11
- * Resolve the Claude Code CLI binary path.
16
+ * Resolve the Claude Code CLI binary path. Legacy helper preserved for back-
17
+ * compat — the runtime registry's `resolveRuntime('claude').resolveBinary()`
18
+ * is now the canonical resolver. This wrapper only exists so external tooling
19
+ * that still calls `findClaudeBinary()` keeps working until cleanup ships.
20
+ *
12
21
  * Returns the path if found, null otherwise.
13
- * Reuses the same search logic as spawn-agent.js.
14
22
  */
15
23
  function findClaudeBinary() {
16
24
  const searchPaths = [
@@ -71,6 +79,91 @@ function findClaudeBinary() {
71
79
  return null;
72
80
  }
73
81
 
82
+ // ─── Runtime fleet enumeration (P-9e8a3f1d) ─────────────────────────────────
83
+
84
+ /**
85
+ * Collect the unique set of CLI runtimes that any part of the fleet would
86
+ * spawn given a config. Mirrors the union scanned by
87
+ * `shared.runtimeConfigWarnings` so unknown-CLI warnings and binary checks
88
+ * always cover the same surface.
89
+ *
90
+ * Without a config (legacy callers), returns just `['claude']` — the
91
+ * historical default.
92
+ */
93
+ function _distinctRuntimes(config) {
94
+ const set = new Set();
95
+ if (!config || typeof config !== 'object') {
96
+ set.add('claude');
97
+ return Array.from(set);
98
+ }
99
+ const engine = config.engine || {};
100
+ set.add(engine.defaultCli ? String(engine.defaultCli) : 'claude');
101
+ if (engine.ccCli) set.add(String(engine.ccCli));
102
+ for (const agent of Object.values(config.agents || {})) {
103
+ if (agent && agent.cli) set.add(String(agent.cli));
104
+ }
105
+ return Array.from(set).sort();
106
+ }
107
+
108
+ /**
109
+ * Try to resolve a runtime's binary via the registry. Returns a preflight
110
+ * result entry — never throws. Unknown-runtime errors collapse to a single
111
+ * warn entry so the rest of the loop keeps running.
112
+ */
113
+ function _checkRuntimeBinary(runtimeName) {
114
+ let adapter;
115
+ try {
116
+ adapter = require('./runtimes').resolveRuntime(runtimeName);
117
+ } catch (e) {
118
+ return {
119
+ name: `Runtime: ${runtimeName}`,
120
+ ok: false,
121
+ message: `unknown runtime — ${e.message}`,
122
+ };
123
+ }
124
+ let resolved = null;
125
+ try { resolved = adapter.resolveBinary({ env: process.env }); }
126
+ catch { /* defensive — treat any throw as "not found" so the loop keeps running */ }
127
+ if (resolved && resolved.bin) {
128
+ const shim = resolved.native === false ? ' (node shim)' : '';
129
+ const lead = Array.isArray(resolved.leadingArgs) && resolved.leadingArgs.length
130
+ ? ` (leadingArgs: ${resolved.leadingArgs.join(' ')})` : '';
131
+ return {
132
+ name: `Runtime: ${runtimeName}`,
133
+ ok: true,
134
+ message: `${resolved.bin}${shim}${lead}`,
135
+ };
136
+ }
137
+ const hint = (typeof adapter.installHint === 'string' && adapter.installHint)
138
+ ? adapter.installHint
139
+ : `${runtimeName} CLI binary not found on PATH`;
140
+ return {
141
+ name: `Runtime: ${runtimeName}`,
142
+ ok: false,
143
+ message: `not found — ${hint}`,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Fire-and-forget cache warm. We never await: cache warming is a side effect
149
+ * for the next dashboard / doctor read, not something runPreflight should
150
+ * block on. Errors are swallowed — discovery is best-effort.
151
+ *
152
+ * Silent no-op when the runtime can't enumerate models (Claude has no public
153
+ * mechanism, hence `capabilities.modelDiscovery: false`) or when the user
154
+ * explicitly disabled discovery via `engine.disableModelDiscovery`. Those
155
+ * branches live inside `model-discovery.getRuntimeModels`, so we just delegate.
156
+ */
157
+ function _warmModelCache(runtimeName, config) {
158
+ if (!runtimeName) return;
159
+ let md;
160
+ try { md = require('./model-discovery'); }
161
+ catch { return; /* legacy installs may not have model-discovery yet */ }
162
+ Promise.resolve()
163
+ .then(() => md.getRuntimeModels(runtimeName, { config }))
164
+ .catch(() => { /* swallow — best effort */ });
165
+ }
166
+
74
167
  /**
75
168
  * Run prerequisite checks. Returns { passed, results } where results is an
76
169
  * array of { name, ok, message } objects.
@@ -78,6 +171,7 @@ function findClaudeBinary() {
78
171
  * Options:
79
172
  * - warnOnly: if true, missing items don't cause passed=false (for init)
80
173
  * - verbose: include extra detail in messages
174
+ * - config: fleet config for runtime checks + runtime-config warnings
81
175
  */
82
176
  function runPreflight(opts = {}) {
83
177
  const results = [];
@@ -102,18 +196,37 @@ function runPreflight(opts = {}) {
102
196
  allOk = false;
103
197
  }
104
198
 
105
- // 3. Claude Code CLI
106
- const claudeBin = findClaudeBinary();
107
- if (claudeBin) {
108
- const isNative = !claudeBin.endsWith('cli.js');
109
- const label = isNative ? 'native' : path.basename(path.dirname(path.dirname(claudeBin)));
110
- results.push({ name: 'Claude Code CLI', ok: true, message: label });
111
- } else {
112
- results.push({ name: 'Claude Code CLI', ok: false, message: 'not found — install from https://claude.ai/download or: npm install -g @anthropic-ai/claude-code' });
113
- allOk = false;
199
+ // 3. Per-runtime binary check (P-9e8a3f1d). Legacy single-runtime callers
200
+ // (no config passed) still get exactly one entry — for `claude` — so the
201
+ // historical "3 results" shape is preserved.
202
+ const runtimes = _distinctRuntimes(opts.config);
203
+ for (const runtimeName of runtimes) {
204
+ const r = _checkRuntimeBinary(runtimeName);
205
+ if (r.ok === false) allOk = false;
206
+ results.push(r);
207
+ // Warm the model cache in the background — silent no-op when the
208
+ // runtime can't enumerate or the user disabled discovery.
209
+ if (opts.config) _warmModelCache(runtimeName, opts.config);
114
210
  }
115
211
 
116
- // Auth is handled by Claude Code itself (API key, Claude Max, etc.) — no check needed here.
212
+ // Auth is handled by each runtime CLI itself (Claude API key, GH_TOKEN for
213
+ // Copilot, etc.) — preflight doesn't probe credentials.
214
+
215
+ // 4. Runtime fleet config warnings (P-3b8e5f1d) — only when the caller hands
216
+ // us the config. checkOrExit() / cli start() / doctor() pass it; legacy
217
+ // callers don't, in which case we skip silently.
218
+ if (opts && opts.config && typeof opts.config === 'object') {
219
+ try {
220
+ const shared = require('./shared');
221
+ let runtimeNames = [];
222
+ try { runtimeNames = require('./runtimes').listRuntimes(); }
223
+ catch { /* registry may be missing during partial installs */ }
224
+ const warns = shared.runtimeConfigWarnings(opts.config, runtimeNames);
225
+ for (const w of warns) {
226
+ results.push({ name: `Runtime config (${w.id})`, ok: 'warn', message: w.message });
227
+ }
228
+ } catch { /* defensive — preflight must never throw */ }
229
+ }
117
230
 
118
231
  return { passed: allOk, results };
119
232
  }
@@ -125,7 +238,7 @@ function printPreflight(results, { label = 'Preflight checks' } = {}) {
125
238
  console.log(`\n ${label}:\n`);
126
239
  let allOk = true;
127
240
  for (const r of results) {
128
- const icon = r.ok === true ? '\u2713' : r.ok === 'warn' ? '!' : '\u2717';
241
+ const icon = r.ok === true ? '' : r.ok === 'warn' ? '!' : '';
129
242
  const prefix = r.ok === true ? ' ' : r.ok === 'warn' ? ' ' : ' ';
130
243
  console.log(`${prefix} ${icon} ${r.name}: ${r.message}`);
131
244
  if (r.ok === false) allOk = false;
@@ -148,12 +261,108 @@ function checkOrExit({ exitOnFail = false, label = 'Preflight checks' } = {}) {
148
261
  return ok;
149
262
  }
150
263
 
264
+ // ─── Doctor extras (P-9e8a3f1d) ─────────────────────────────────────────────
265
+
266
+ const _FEATURE_FLAG_DEFAULTS = {
267
+ claudeBareMode: false,
268
+ claudeFallbackModel: undefined,
269
+ copilotDisableBuiltinMcps: true,
270
+ copilotSuppressAgentsMd: true,
271
+ copilotStreamMode: 'on',
272
+ copilotReasoningSummaries: false,
273
+ maxBudgetUsd: undefined,
274
+ disableModelDiscovery: false,
275
+ };
276
+
277
+ /**
278
+ * Build the fleet-defaults summary entries surfaced by `minions doctor`.
279
+ * Three classes of output:
280
+ * 1. `Fleet` — `defaultCli` + `defaultModel`
281
+ * 2. `CC overrides` — only when `ccCli` or `ccModel` is set
282
+ * 3. `Active fleet flags` — only when any feature flag deviates from default
283
+ */
284
+ function _fleetSummaryResults(config) {
285
+ const results = [];
286
+ if (!config || typeof config !== 'object') return results;
287
+ const engine = config.engine || {};
288
+ const defaultCli = engine.defaultCli ? String(engine.defaultCli) : 'claude';
289
+ const defaultModel = engine.defaultModel ? String(engine.defaultModel) : '(runtime default)';
290
+ results.push({ name: 'Fleet', ok: true, message: `defaultCli=${defaultCli} defaultModel=${defaultModel}` });
291
+
292
+ const ccBits = [];
293
+ if (engine.ccCli) ccBits.push(`ccCli=${engine.ccCli}`);
294
+ if (engine.ccModel) ccBits.push(`ccModel=${engine.ccModel}`);
295
+ if (ccBits.length > 0) {
296
+ results.push({ name: 'CC overrides', ok: true, message: ccBits.join(' ') });
297
+ }
298
+
299
+ const nonDefault = [];
300
+ for (const [k, def] of Object.entries(_FEATURE_FLAG_DEFAULTS)) {
301
+ if (engine[k] === undefined) continue;
302
+ if (engine[k] !== def) nonDefault.push(`${k}=${JSON.stringify(engine[k])}`);
303
+ }
304
+ if (nonDefault.length > 0) {
305
+ results.push({ name: 'Active fleet flags', ok: true, message: nonDefault.join(' ') });
306
+ }
307
+ return results;
308
+ }
309
+
151
310
  /**
152
- * Run extended doctor checks (preflight + runtime health).
311
+ * Build the per-runtime model-discovery entries surfaced by `minions doctor`.
312
+ * Emits one entry per distinct runtime in the fleet:
313
+ * - "discovery disabled (engine.disableModelDiscovery)" — fleet-wide opt-out
314
+ * - "discovery unavailable (no enumeration mechanism)" — adapter doesn't support it
315
+ * - "<N> models cached" — listModels returned a non-empty array
316
+ * - "discovery unavailable (...)" — listModels returned null/threw (no token, transient API error)
317
+ */
318
+ async function _modelDiscoveryResults(config) {
319
+ const results = [];
320
+ if (!config || typeof config !== 'object') return results;
321
+ let md;
322
+ try { md = require('./model-discovery'); } catch { return results; }
323
+ let registry;
324
+ try { registry = require('./runtimes'); } catch { return results; }
325
+ const fleetDisabled = config.engine && config.engine.disableModelDiscovery === true;
326
+ const runtimes = _distinctRuntimes(config);
327
+ for (const runtimeName of runtimes) {
328
+ let adapter;
329
+ try { adapter = registry.resolveRuntime(runtimeName); }
330
+ catch { continue; /* unknown-cli warning was already emitted by runtimeConfigWarnings */ }
331
+
332
+ if (fleetDisabled) {
333
+ results.push({ name: `Models: ${runtimeName}`, ok: 'warn', message: 'discovery disabled (engine.disableModelDiscovery)' });
334
+ continue;
335
+ }
336
+ if (!adapter.capabilities || adapter.capabilities.modelDiscovery !== true) {
337
+ results.push({ name: `Models: ${runtimeName}`, ok: 'warn', message: 'discovery unavailable (no enumeration mechanism)' });
338
+ continue;
339
+ }
340
+ try {
341
+ const out = await md.getRuntimeModels(runtimeName, { config });
342
+ if (Array.isArray(out.models) && out.models.length > 0) {
343
+ results.push({ name: `Models: ${runtimeName}`, ok: true, message: `${out.models.length} models cached` });
344
+ } else {
345
+ results.push({ name: `Models: ${runtimeName}`, ok: 'warn', message: 'discovery unavailable (API returned no models — check token)' });
346
+ }
347
+ } catch (e) {
348
+ results.push({ name: `Models: ${runtimeName}`, ok: 'warn', message: `discovery error — ${e && e.message ? e.message : 'unknown'}` });
349
+ }
350
+ }
351
+ return results;
352
+ }
353
+
354
+ /**
355
+ * Run extended doctor checks (preflight + runtime health + fleet summary +
356
+ * per-runtime model discovery).
153
357
  * Requires minionsHome path for runtime checks.
154
358
  */
155
359
  function doctor(minionsHome) {
156
- const { passed, results } = runPreflight();
360
+ // Read config first so preflight can include runtime-fleet warnings.
361
+ const configPathForPreflight = path.join(minionsHome, 'config.json');
362
+ let preflightConfig = null;
363
+ try { preflightConfig = JSON.parse(fs.readFileSync(configPathForPreflight, 'utf8')); }
364
+ catch { /* missing/invalid config is its own check below */ }
365
+ const { passed, results } = runPreflight({ config: preflightConfig });
157
366
 
158
367
  // Runtime checks
159
368
  const runtimeResults = [];
@@ -248,12 +457,11 @@ function doctor(minionsHome) {
248
457
  req.on('timeout', () => { req.destroy(); resolve({ name: 'Dashboard', ok: 'warn', message: 'not reachable on :7331 — run: minions dash' }); });
249
458
  });
250
459
 
251
- return dashCheck.then(dashResult => {
460
+ return dashCheck.then(async dashResult => {
252
461
  runtimeResults.push(dashResult);
253
462
 
254
463
  // Check playbooks
255
464
  const playbooksDir = path.join(minionsHome, 'playbooks');
256
- // Discover all .md playbooks in the directory — don't hardcode
257
465
  let playbooks = [];
258
466
  try { playbooks = fs.readdirSync(playbooksDir).filter(f => f.endsWith('.md')); } catch { /* dir may not exist */ }
259
467
  if (playbooks.length > 0) {
@@ -264,10 +472,17 @@ function doctor(minionsHome) {
264
472
 
265
473
  // Check port 7331 availability (only if dashboard isn't running)
266
474
  if (dashResult.ok !== true) {
267
- // Dashboard isn't running, port should be free
268
475
  runtimeResults.push({ name: 'Port 7331', ok: 'warn', message: 'dashboard not running — port status unknown' });
269
476
  }
270
477
 
478
+ // Fleet defaults + per-runtime model discovery (P-9e8a3f1d). Both depend
479
+ // on the config that we already loaded above; re-using `preflightConfig`
480
+ // avoids a second JSON.parse round-trip.
481
+ const fleetSummary = _fleetSummaryResults(preflightConfig);
482
+ runtimeResults.push(...fleetSummary);
483
+ const modelResults = await _modelDiscoveryResults(preflightConfig);
484
+ runtimeResults.push(...modelResults);
485
+
271
486
  // Print all
272
487
  const allResults = [...results, ...runtimeResults];
273
488
  const ok = printPreflight(allResults, { label: 'Minions Doctor' });
@@ -286,4 +501,17 @@ function doctor(minionsHome) {
286
501
  });
287
502
  }
288
503
 
289
- module.exports = { findClaudeBinary, runPreflight, printPreflight, checkOrExit, doctor };
504
+ module.exports = {
505
+ findClaudeBinary,
506
+ runPreflight,
507
+ printPreflight,
508
+ checkOrExit,
509
+ doctor,
510
+ // Exposed for unit tests (P-9e8a3f1d) — engine code MUST go through
511
+ // runPreflight/doctor, never these helpers directly.
512
+ _distinctRuntimes,
513
+ _checkRuntimeBinary,
514
+ _warmModelCache,
515
+ _fleetSummaryResults,
516
+ _modelDiscoveryResults,
517
+ };