@yemi33/minions 0.1.1600 → 0.1.1602

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,12 +1,14 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1600 (2026-04-28)
3
+ ## 0.1.1602 (2026-04-28)
4
4
 
5
5
  ### Features
6
6
  - match runtime tags to actual logos (pixel-crab Claude, mascot Copilot)
7
7
  - replace runtime text tag with inline SVG logos
8
8
 
9
9
  ### Fixes
10
+ - guard runtime model races
11
+ - runtime-aware model picker + cross-runtime validation
10
12
  - keep cc stream final text complete
11
13
  - switch Copilot icon to outline style for cleaner inline read
12
14
  - un-invert Copilot — purple silhouette + white cutouts
@@ -1,6 +1,25 @@
1
1
  // settings.js — Settings panel functions extracted from dashboard.html
2
2
 
3
3
  let _settingsData = null;
4
+ // Async runtime/model discovery can resolve out of order when the operator
5
+ // flips between runtimes quickly. Without a per-target token, a slower Copilot
6
+ // response can repaint the Claude model field after the runtime already
7
+ // changed, which then leaks a stale cross-runtime model into saveSettings().
8
+ const _modelLoadEpochs = {
9
+ runtime: Object.create(null),
10
+ agent: Object.create(null),
11
+ };
12
+
13
+ function _nextModelLoadToken(scope, key) {
14
+ const bucket = _modelLoadEpochs[scope];
15
+ const next = (bucket[key] || 0) + 1;
16
+ bucket[key] = next;
17
+ return next;
18
+ }
19
+
20
+ function _isCurrentModelLoad(scope, key, token) {
21
+ return _modelLoadEpochs[scope][key] === token;
22
+ }
4
23
 
5
24
  async function openSettings() {
6
25
  document.getElementById('modal-title').textContent = 'Settings';
@@ -35,7 +54,11 @@ async function openSettings() {
35
54
  // <select> populated from /api/runtimes once the registry resolves.
36
55
  '<input value="' + escHtml(a.cli || '') + '" placeholder="' + escHtml(fleetCliLabel) + ' (fleet)" disabled style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:11px">' +
37
56
  '</td>' +
38
- '<td><input data-agent="' + escHtml(id) + '" data-field="model" value="' + escHtml(a.model || '') + '" placeholder="' + escHtml(fleetModelLabel) + ' (fleet)" style="width:120px;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:11px"></td>' +
57
+ '<td data-runtime-model="' + escHtml(id) + '" style="min-width:140px">' +
58
+ // Loading placeholder — initRuntimeFleetUI() replaces this with a
59
+ // <select> populated from /api/runtimes/<resolved-cli>/models.
60
+ '<input value="' + escHtml(a.model || '') + '" placeholder="' + escHtml(fleetModelLabel) + ' (fleet)" disabled style="width:120px;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:11px">' +
61
+ '</td>' +
39
62
  '<td><input data-agent="' + escHtml(id) + '" data-field="monthlyBudgetUsd" value="' + escHtml(a.monthlyBudgetUsd != null ? String(a.monthlyBudgetUsd) : '') + '" placeholder="unlimited" style="width:70px;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:11px;text-align:right"></td>' +
40
63
  '</tr>';
41
64
  }).join('');
@@ -347,6 +370,26 @@ async function initRuntimeFleetUI(engineCfg, agentsCfg) {
347
370
  ).join('') +
348
371
  '</select>';
349
372
  }
373
+ // Hydrate per-agent model dropdowns. The model list is keyed off the
374
+ // agent's RESOLVED runtime: per-agent override → fleet default. Without
375
+ // this the input was free-text and a user could (and did) save an agent
376
+ // with cli=claude + model=<some gpt> — invalid combination that crashed
377
+ // dispatch. Refreshing on CLI change clears stale model values.
378
+ const fleetDefaultCli = engineCfg.defaultCli || 'claude';
379
+ for (const cell of cliCells) {
380
+ const agentId = cell.getAttribute('data-runtime-cli');
381
+ const agent = (agentsCfg || {})[agentId] || {};
382
+ const resolvedCli = agent.cli || fleetDefaultCli;
383
+ loadModelsForAgent(agentId, resolvedCli, agent.model || '');
384
+ // CLI dropdown change → refresh that agent's model dropdown to match.
385
+ const sel = cell.querySelector('select[data-field="cli"]');
386
+ if (sel) {
387
+ sel.addEventListener('change', () => {
388
+ const newCli = sel.value || fleetDefaultCli;
389
+ loadModelsForAgent(agentId, newCli, ''); // clear value: previous model may not exist for the new runtime
390
+ });
391
+ }
392
+ }
350
393
 
351
394
  // Models load for the resolved default + CC CLIs. ccCli falls back to
352
395
  // defaultCli when unset — same rule as resolveCcCli().
@@ -376,6 +419,7 @@ async function initRuntimeFleetUI(engineCfg, agentsCfg) {
376
419
  async function loadModelsForRuntime(runtimeName, inputId, currentValue) {
377
420
  const wrap = document.getElementById(inputId)?.parentElement;
378
421
  if (!wrap) return;
422
+ const token = _nextModelLoadToken('runtime', inputId);
379
423
  if (!runtimeName) {
380
424
  wrap.innerHTML = '<input id="' + inputId + '" value="' + escHtml(currentValue || '') + '" placeholder="(no runtime selected)" disabled style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:12px">';
381
425
  return;
@@ -386,6 +430,7 @@ async function loadModelsForRuntime(runtimeName, inputId, currentValue) {
386
430
  if (res.ok) payload = await res.json();
387
431
  } catch { /* fall through to free-text */ }
388
432
 
433
+ if (!_isCurrentModelLoad('runtime', inputId, token)) return;
389
434
  const models = Array.isArray(payload.models) ? payload.models : null;
390
435
  if (!models || models.length === 0) {
391
436
  // Free-text fallback — let the user type anything (custom Anthropic /
@@ -409,6 +454,49 @@ async function loadModelsForRuntime(runtimeName, inputId, currentValue) {
409
454
  wrap.innerHTML = '<select id="' + inputId + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:12px">' + opts + '</select>';
410
455
  }
411
456
 
457
+ /**
458
+ * Per-agent model hydrator. Replaces the placeholder input in the cell
459
+ * `[data-runtime-model="<agentId>"]` with a <select> of valid models for the
460
+ * given runtime. Output element keeps `data-agent` + `data-field="model"` so
461
+ * the existing save flow picks it up unchanged. Free-text input fallback
462
+ * when the runtime returns no model list (Claude / discovery disabled).
463
+ */
464
+ async function loadModelsForAgent(agentId, runtimeName, currentValue) {
465
+ const cell = document.querySelector('[data-runtime-model="' + agentId + '"]');
466
+ if (!cell) return;
467
+ const baseAttrs = 'data-agent="' + escHtml(agentId) + '" data-field="model"';
468
+ const baseStyle = 'width:120px;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:11px';
469
+ const token = _nextModelLoadToken('agent', agentId);
470
+ if (!runtimeName) {
471
+ cell.innerHTML = '<input ' + baseAttrs + ' value="' + escHtml(currentValue || '') + '" placeholder="(no runtime)" disabled style="' + baseStyle + ';color:var(--muted)">';
472
+ return;
473
+ }
474
+ let payload = { models: null };
475
+ try {
476
+ const res = await fetch('/api/runtimes/' + encodeURIComponent(runtimeName) + '/models');
477
+ if (res.ok) payload = await res.json();
478
+ } catch { /* fall through to free-text */ }
479
+
480
+ if (!_isCurrentModelLoad('agent', agentId, token)) return;
481
+ const models = Array.isArray(payload.models) ? payload.models : null;
482
+ if (!models || models.length === 0) {
483
+ cell.innerHTML = '<input ' + baseAttrs + ' value="' + escHtml(currentValue || '') + '" placeholder="' + escHtml(runtimeName) + ' default" style="' + baseStyle + '">';
484
+ return;
485
+ }
486
+ let opts = '<option value=""' + (!currentValue ? ' selected' : '') + '>(fleet/default)</option>';
487
+ for (const m of models) {
488
+ const id = m.id || m.name || '';
489
+ if (!id) continue;
490
+ const label = m.name && m.name !== id ? (id + ' — ' + m.name) : id;
491
+ opts += '<option value="' + escHtml(id) + '"' + (id === currentValue ? ' selected' : '') + '>' + escHtml(label) + '</option>';
492
+ }
493
+ // Preserve unknown saved values so a user-set custom ID survives the next save.
494
+ if (currentValue && !models.some(m => (m.id || m.name) === currentValue)) {
495
+ opts += '<option value="' + escHtml(currentValue) + '" selected>' + escHtml(currentValue) + ' (custom — invalid for ' + escHtml(runtimeName) + '?)</option>';
496
+ }
497
+ cell.innerHTML = '<select ' + baseAttrs + ' style="' + baseStyle + '">' + opts + '</select>';
498
+ }
499
+
412
500
  function settingsToggle(label, id, checked, hint) {
413
501
  return '<div style="display:flex;align-items:center;gap:8px;padding:4px 0">' +
414
502
  '<input type="checkbox" id="' + id + '"' + (checked ? ' checked' : '') + ' style="accent-color:var(--blue);width:16px;height:16px;cursor:pointer">' +
package/dashboard.js CHANGED
@@ -4918,13 +4918,58 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4918
4918
  else if (_validCli(e.ccCli)) config.engine.ccCli = String(e.ccCli);
4919
4919
  else _clamped.push(`ccCli: "${e.ccCli}" not registered (kept previous value)`);
4920
4920
  }
4921
+ // Validate fleet-level model assignments against the resolved runtime.
4922
+ // This is where the bug bit: defaultCli=copilot + defaultModel=gpt-5.5
4923
+ // (where gpt-5.5 doesn't actually exist) cascaded into every agent
4924
+ // that didn't pin its own model. Reject when the model is known to
4925
+ // belong to a different runtime than the one it'll spawn against.
4926
+ const _engineModelDiscovery = require('./engine/model-discovery');
4927
+ const _engineRuntimes = require('./engine/runtimes');
4928
+ async function _validateFleetModel(modelStr, resolvedRuntime) {
4929
+ if (!modelStr) return null;
4930
+ let knownForResolved = null;
4931
+ try {
4932
+ const list = await _engineModelDiscovery.getRuntimeModels(resolvedRuntime, { config });
4933
+ if (Array.isArray(list?.models) && list.models.length > 0) {
4934
+ knownForResolved = new Set(list.models.map(m => m.id || m.name).filter(Boolean));
4935
+ }
4936
+ } catch { /* unknown runtime */ }
4937
+ if (knownForResolved && !knownForResolved.has(modelStr)) {
4938
+ return `not a valid model for runtime "${resolvedRuntime}" (known: ${[...knownForResolved].slice(0, 4).join(', ')}${knownForResolved.size > 4 ? '…' : ''})`;
4939
+ }
4940
+ if (!knownForResolved) {
4941
+ // Free-text runtime (Claude). Reject only if model belongs to a different runtime's published list.
4942
+ for (const rt of _engineRuntimes.listRuntimes()) {
4943
+ if (rt === resolvedRuntime) continue;
4944
+ try {
4945
+ const otherList = await _engineModelDiscovery.getRuntimeModels(rt, { config });
4946
+ if (Array.isArray(otherList?.models) && otherList.models.some(m => (m.id || m.name) === modelStr)) {
4947
+ return `belongs to runtime "${rt}" but resolved runtime is "${resolvedRuntime}" — incompatible combination`;
4948
+ }
4949
+ } catch { /* skip */ }
4950
+ }
4951
+ }
4952
+ return null;
4953
+ }
4921
4954
  if (e.defaultModel !== undefined) {
4922
4955
  if (_isClear(e.defaultModel)) delete config.engine.defaultModel;
4923
- else config.engine.defaultModel = String(e.defaultModel);
4956
+ else {
4957
+ const candidate = String(e.defaultModel);
4958
+ const resolvedCli = config.engine.defaultCli || 'claude';
4959
+ const rejection = await _validateFleetModel(candidate, resolvedCli);
4960
+ if (rejection) _clamped.push(`engine.defaultModel: "${candidate}" ${rejection} — kept previous value`);
4961
+ else config.engine.defaultModel = candidate;
4962
+ }
4924
4963
  }
4925
4964
  if (e.ccModel !== undefined) {
4926
4965
  if (_isClear(e.ccModel)) delete config.engine.ccModel;
4927
- else config.engine.ccModel = String(e.ccModel);
4966
+ else {
4967
+ const candidate = String(e.ccModel);
4968
+ const resolvedCli = config.engine.ccCli || config.engine.defaultCli || 'claude';
4969
+ const rejection = await _validateFleetModel(candidate, resolvedCli);
4970
+ if (rejection) _clamped.push(`engine.ccModel: "${candidate}" ${rejection} — kept previous value`);
4971
+ else config.engine.ccModel = candidate;
4972
+ }
4928
4973
  }
4929
4974
  if (e.claudeFallbackModel !== undefined) {
4930
4975
  if (_isClear(e.claudeFallbackModel)) delete config.engine.claudeFallbackModel;
@@ -4982,6 +5027,34 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4982
5027
  }
4983
5028
 
4984
5029
  if (body.agents) {
5030
+ // Cache cross-runtime model lists once per request so we can reject
5031
+ // claude+gpt-* / copilot+claude-* combinations before they crash a
5032
+ // dispatch (see #model-validation: a stray engine.defaultModel='gpt-5.5'
5033
+ // pinned every Claude agent into a 404 spawn loop).
5034
+ const _modelDiscovery = require('./engine/model-discovery');
5035
+ const _runtimeModelsCache = new Map(); // runtimeName → Set<modelId> (or null when unknown / Claude)
5036
+ async function _modelsFor(runtimeName) {
5037
+ if (_runtimeModelsCache.has(runtimeName)) return _runtimeModelsCache.get(runtimeName);
5038
+ let set = null;
5039
+ try {
5040
+ const list = await _modelDiscovery.getRuntimeModels(runtimeName, { config });
5041
+ if (Array.isArray(list?.models) && list.models.length > 0) {
5042
+ set = new Set(list.models.map(m => m.id || m.name).filter(Boolean));
5043
+ }
5044
+ } catch { /* unknown runtime → free-text */ }
5045
+ _runtimeModelsCache.set(runtimeName, set);
5046
+ return set;
5047
+ }
5048
+ // Returns the runtime that "owns" this model, or null if no other
5049
+ // runtime claims it. Catches "claude + gpt-5.5" by spotting that
5050
+ // gpt-5.5 belongs to copilot's list.
5051
+ async function _ownerOfModel(modelId) {
5052
+ for (const rt of require('./engine/runtimes').listRuntimes()) {
5053
+ const set = await _modelsFor(rt);
5054
+ if (set && set.has(modelId)) return rt;
5055
+ }
5056
+ return null;
5057
+ }
4985
5058
  for (const [id, updates] of Object.entries(body.agents)) {
4986
5059
  if (!config.agents[id]) continue;
4987
5060
  if (updates.role !== undefined) config.agents[id].role = String(updates.role);
@@ -5001,7 +5074,30 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5001
5074
  }
5002
5075
  if (updates.model !== undefined) {
5003
5076
  if (updates.model === '' || updates.model === null) delete config.agents[id].model;
5004
- else config.agents[id].model = String(updates.model);
5077
+ else {
5078
+ const candidate = String(updates.model);
5079
+ const resolvedCli = config.agents[id].cli || config.engine.defaultCli || 'claude';
5080
+ const knownModels = await _modelsFor(resolvedCli);
5081
+ // Two validation paths:
5082
+ // 1. If the runtime publishes a model list, enforce membership.
5083
+ // 2. If the runtime doesn't (Claude), still reject when the
5084
+ // model belongs to a DIFFERENT runtime's list — that's how
5085
+ // we catch claude+gpt-5.5 (gpt-5.5 is in Copilot's list).
5086
+ let rejection = null;
5087
+ if (knownModels && !knownModels.has(candidate)) {
5088
+ rejection = `not a valid model for runtime "${resolvedCli}" (known: ${[...knownModels].slice(0, 4).join(', ')}${knownModels.size > 4 ? '…' : ''})`;
5089
+ } else if (!knownModels) {
5090
+ const owner = await _ownerOfModel(candidate);
5091
+ if (owner && owner !== resolvedCli) {
5092
+ rejection = `belongs to runtime "${owner}" but agent uses "${resolvedCli}" — incompatible combination`;
5093
+ }
5094
+ }
5095
+ if (rejection) {
5096
+ _clamped.push(`agents.${id}.model: "${candidate}" ${rejection} — kept previous value`);
5097
+ } else {
5098
+ config.agents[id].model = candidate;
5099
+ }
5100
+ }
5005
5101
  }
5006
5102
  if (updates.maxBudgetUsd !== undefined) {
5007
5103
  if (updates.maxBudgetUsd === '' || updates.maxBudgetUsd === null) delete config.agents[id].maxBudgetUsd;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1600",
3
+ "version": "0.1.1602",
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"