@yemi33/minions 0.1.2376 → 0.1.2377

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.
@@ -890,13 +890,11 @@ async function initRuntimeFleetUI(engineCfg, agentsCfg) {
890
890
  }
891
891
 
892
892
  /**
893
- * Replace the input/select at `inputId` with a dropdown when the runtime
894
- * exposes a model list, or a free-text input when `{ models: null }` (e.g.
895
- * Claude or model-discovery disabled). The "Default (CLI chooses)" option is
896
- * always present and submits empty string.
893
+ * Render an editable model field backed by discovered-model suggestions.
894
+ * Discovery is advisory: staged/future/custom model IDs must remain typeable.
897
895
  */
898
- async function loadModelsForRuntime(runtimeName, inputId, currentValue) {
899
- const wrap = document.getElementById(inputId)?.parentElement;
896
+ async function loadModelsForRuntime(runtimeName, inputId, currentValue, forceRefresh) {
897
+ const wrap = document.getElementById(inputId + '-wrap') || document.getElementById(inputId)?.parentElement;
900
898
  if (!wrap) return;
901
899
  const token = _nextModelLoadToken('runtime', inputId);
902
900
  if (!runtimeName) {
@@ -906,42 +904,37 @@ async function loadModelsForRuntime(runtimeName, inputId, currentValue) {
906
904
  }
907
905
  let payload = { models: null };
908
906
  try {
909
- const res = await fetch('/api/runtimes/' + encodeURIComponent(runtimeName) + '/models');
907
+ const suffix = forceRefresh ? '/models/refresh' : '/models';
908
+ const res = await fetch('/api/runtimes/' + encodeURIComponent(runtimeName) + suffix, forceRefresh ? { method: 'POST' } : undefined);
910
909
  if (res.ok) payload = await res.json();
911
910
  } catch { /* fall through to free-text */ }
912
911
 
913
912
  if (!_isCurrentModelLoad('runtime', inputId, token)) return;
914
913
  const models = Array.isArray(payload.models) ? payload.models : null;
915
- if (!models || models.length === 0) {
916
- // Free-text fallback — let the user type anything (custom Anthropic /
917
- // OpenAI model IDs, future models, etc.).
918
- // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: currentValue; inputId is an internal fixed DOM id)
919
- wrap.innerHTML = '<input id="' + inputId + '" value="' + escHtml(currentValue || '') + '" placeholder="Default (CLI chooses)" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">';
920
- return;
921
- }
922
- // Dropdown. The first option submits empty string → "Default (CLI chooses)".
923
- let opts = '<option value=""' + (!currentValue ? ' selected' : '') + '>Default (CLI chooses)</option>';
924
- for (const m of models) {
914
+ const listId = inputId + '-options';
915
+ let opts = '';
916
+ for (const m of (models || [])) {
925
917
  const id = m.id || m.name || '';
926
918
  if (!id) continue;
927
- const label = m.name && m.name !== id ? (id + ' — ' + m.name) : id;
928
- opts += '<option value="' + escHtml(id) + '"' + (id === currentValue ? ' selected' : '') + '>' + escHtml(label) + '</option>';
919
+ const label = m.name && m.name !== id ? m.name : '';
920
+ opts += '<option value="' + escHtml(id) + '"' + (label ? ' label="' + escHtml(label) + '"' : '') + '></option>';
929
921
  }
930
- // If the current value isn't in the model list (custom / older choice),
931
- // surface it as a selectable option so the user doesn't lose it on next save.
932
- if (currentValue && !models.some(m => (m.id || m.name) === currentValue)) {
933
- opts += '<option value="' + escHtml(currentValue) + '" selected>' + escHtml(currentValue) + ' (custom)</option>';
922
+ const title = forceRefresh ? 'Model list refreshed' : 'Refresh models from the selected CLI';
923
+ // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml()
924
+ wrap.innerHTML = '<div style="display:flex;gap:4px"><input id="' + inputId + '" list="' + listId + '" value="' + escHtml(currentValue || '') + '" placeholder="Default (CLI chooses)" autocomplete="off" style="min-width:0;flex:1;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)"><button type="button" data-model-refresh title="' + escHtml(title) + '" aria-label="Refresh model list" style="padding:3px 8px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--muted);cursor:pointer">↻</button></div><datalist id="' + listId + '">' + opts + '</datalist>';
925
+ const refresh = wrap.querySelector('[data-model-refresh]');
926
+ if (refresh) {
927
+ refresh.addEventListener('click', async function() {
928
+ const liveValue = document.getElementById(inputId)?.value || '';
929
+ refresh.disabled = true;
930
+ await loadModelsForRuntime(runtimeName, inputId, liveValue, true);
931
+ });
934
932
  }
935
- // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: model id, model label, currentValue; inputId is an internal fixed DOM id)
936
- 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:var(--text-md)">' + opts + '</select>';
937
933
  }
938
934
 
939
935
  /**
940
- * Per-agent model hydrator. Replaces the placeholder input in the cell
941
- * `[data-runtime-model="<agentId>"]` with a <select> of valid models for the
942
- * given runtime. Output element keeps `data-agent` + `data-field="model"` so
943
- * the existing save flow picks it up unchanged. Free-text input fallback
944
- * when the runtime returns no model list (Claude / discovery disabled).
936
+ * Per-agent model hydrator. The catalog is a datalist rather than a closed
937
+ * select so newly released and custom IDs stay available.
945
938
  */
946
939
  async function loadModelsForAgent(agentId, runtimeName, currentValue) {
947
940
  const cell = document.querySelector('[data-runtime-model="' + agentId + '"]');
package/dashboard.js CHANGED
@@ -4502,6 +4502,7 @@ async function _preflightModelCheck({ runtime: cliOverride, model: modelOverride
4502
4502
  const resolvedModel = llm._resolveModelForRuntime(adapter, { model: modelOverride, engineConfig });
4503
4503
  if (!resolvedModel) return null;
4504
4504
  if (!adapter.capabilities || adapter.capabilities.modelDiscovery !== true) return null;
4505
+ if (adapter.capabilities.strictModelCatalog === false) return null;
4505
4506
 
4506
4507
  let list;
4507
4508
  try {
@@ -10572,8 +10573,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10572
10573
  });
10573
10574
  writeCcEvent(envelope);
10574
10575
  liveState.donePayload = envelope;
10575
- _ccStreamEnded = true;
10576
10576
  if (liveState.endResponse) liveState.endResponse();
10577
+ _ccStreamEnded = true;
10577
10578
  _scheduleCcLiveCleanup(tabId);
10578
10579
  _logCcStreamEnd(_ccTelemetry, 'error-preflight-model-unavailable', { runtime: preflightFailure.runtime });
10579
10580
  return;
@@ -10621,8 +10622,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10621
10622
  if (initial.missingRuntime) return initial;
10622
10623
 
10623
10624
  // Handle failure — non-zero exit with text = max_turns or partial success, still usable
10624
- if (!initial.text && wasResume && initial.code !== 0 && !req.destroyed) {
10625
- // Resume failed (stale/expired session) — auto-retry as fresh session (skip if client already disconnected)
10625
+ if (!initial.text && wasResume && initial.code !== 0) {
10626
+ // Resume failed (stale/expired session) — auto-retry as fresh. A
10627
+ // briefly disconnected client can reattach to the live turn.
10626
10628
  console.log(`[CC-stream] Resume failed (code=${initial.code}) — retrying fresh`);
10627
10629
  const freshPreamble = buildCCStatePreamble();
10628
10630
  const freshCarryover = _buildTranscriptCarryover(body.transcript, { currentMessage: body.message });
@@ -10672,11 +10674,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10672
10674
  // treating an empty `text` as a failure. A genuinely empty turn has
10673
10675
  // both text and raw empty.
10674
10676
  if ((!result.text && !result.raw) || result.error) {
10675
- if (req.destroyed) {
10676
- _ccStreamEnded = true;
10677
- _logCcStreamEnd(_ccTelemetry, 'llm-empty-client-gone', { code: result.code });
10678
- return;
10679
- }
10680
10677
  // W-mpmwxni2000c25c7-b — surface the typed error envelope as a
10681
10678
  // distinct SSE `event: error` frame so the client renders a real
10682
10679
  // error UI (with a retry hint derived from `retriable`) instead of
@@ -11316,20 +11313,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11316
11313
  knownForResolved = new Set(list.models.map(m => m.id || m.name).filter(Boolean));
11317
11314
  }
11318
11315
  } catch { /* unknown runtime */ }
11319
- if (knownForResolved && !knownForResolved.has(runtimeModelStr)) {
11320
- return `not a valid model for runtime "${resolvedRuntime}" (known: ${[...knownForResolved].slice(0, 4).join(', ')}${knownForResolved.size > 4 ? '…' : ''})`;
11321
- }
11322
- if (!knownForResolved) {
11323
- // Free-text runtime (Claude). Reject only if the raw model belongs to a different runtime's published list.
11324
- for (const rt of _engineRuntimes.listRuntimes()) {
11325
- if (rt === resolvedRuntime) continue;
11326
- try {
11327
- const otherList = await _engineModelDiscovery.getRuntimeModels(rt, { config });
11328
- if (Array.isArray(otherList?.models) && otherList.models.some(m => (m.id || m.name) === modelStr)) {
11329
- return `belongs to runtime "${rt}" but resolved runtime is "${resolvedRuntime}" — incompatible combination`;
11330
- }
11331
- } catch { /* skip */ }
11332
- }
11316
+ if (knownForResolved && knownForResolved.has(runtimeModelStr)) return null;
11317
+ // Catalogs lag staged releases and local/custom providers. Unknown is
11318
+ // therefore allowed, but a model positively published by a different
11319
+ // runtime remains an actionable incompatible-combination error.
11320
+ for (const rt of _engineRuntimes.listRuntimes()) {
11321
+ if (rt === resolvedRuntime) continue;
11322
+ try {
11323
+ const otherList = await _engineModelDiscovery.getRuntimeModels(rt, { config });
11324
+ if (Array.isArray(otherList?.models) && otherList.models.some(m => (m.id || m.name) === modelStr)) {
11325
+ return `belongs to runtime "${rt}" but resolved runtime is "${resolvedRuntime}" — incompatible combination`;
11326
+ }
11327
+ } catch { /* skip */ }
11333
11328
  }
11334
11329
  return null;
11335
11330
  }
@@ -11486,15 +11481,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11486
11481
  const resolvedCli = config.agents[id].cli || config.engine.defaultCli || 'copilot';
11487
11482
  const runtimeModelStr = _resolveModelForRuntime(candidate, resolvedCli);
11488
11483
  const knownModels = await _modelsFor(resolvedCli);
11489
- // Two validation paths:
11490
- // 1. If the runtime publishes a model list, enforce membership.
11491
- // 2. If the runtime doesn't (Claude), still reject when the
11492
- // model belongs to a DIFFERENT runtime's list — that's how
11493
- // we catch claude+gpt-5.5 (gpt-5.5 is in Copilot's list).
11484
+ // Published membership is a positive signal, not a closed enum:
11485
+ // staged releases/custom providers can legitimately be absent.
11486
+ // Still reject IDs positively owned by a different runtime.
11494
11487
  let rejection = null;
11495
- if (knownModels && !knownModels.has(runtimeModelStr)) {
11496
- rejection = `not a valid model for runtime "${resolvedCli}" (known: ${[...knownModels].slice(0, 4).join(', ')}${knownModels.size > 4 ? '…' : ''})`;
11497
- } else if (!knownModels) {
11488
+ if (!knownModels || !knownModels.has(runtimeModelStr)) {
11498
11489
  const owner = await _ownerOfModel(candidate);
11499
11490
  if (owner && owner !== resolvedCli) {
11500
11491
  rejection = `belongs to runtime "${owner}" but agent uses "${resolvedCli}" — incompatible combination`;
package/engine/llm.js CHANGED
@@ -591,8 +591,9 @@ function _createStreamAccumulator({
591
591
  if (!id || !onToolUpdate) return;
592
592
  onToolUpdate(id, status);
593
593
  },
594
- toolUseAlreadySeen(name, input) {
594
+ toolUseAlreadySeen(name, input, id = null) {
595
595
  if (!name) return false;
596
+ if (id) return toolUses.some(t => t.id === id);
596
597
  const stringified = JSON.stringify(input || {});
597
598
  return toolUses.some(t => t.name === name && JSON.stringify(t.input) === stringified);
598
599
  },
@@ -99,6 +99,13 @@ async function getRuntimeModels(runtimeName, { force = false, ttlMs = MODEL_CACH
99
99
  }
100
100
 
101
101
  const cachePath = adapter.modelsCache || null;
102
+ // Runtime catalogs are versioned with the CLI that produced them. Codex in
103
+ // particular ships new bundled model IDs with CLI releases, so a one-hour
104
+ // time-only cache otherwise hides new models immediately after an upgrade.
105
+ let modelCacheKey = null;
106
+ if (typeof adapter.getModelCacheKey === 'function') {
107
+ try { modelCacheKey = await adapter.getModelCacheKey(); } catch { /* optional hint */ }
108
+ }
102
109
 
103
110
  // Cache hit path — only on non-forced reads. We accept any cached payload
104
111
  // whose `cachedAt` parses to a valid timestamp within the TTL window;
@@ -110,7 +117,8 @@ async function getRuntimeModels(runtimeName, { force = false, ttlMs = MODEL_CACH
110
117
  const ts = Date.parse(cached.cachedAt);
111
118
  if (Number.isFinite(ts)) {
112
119
  const age = Date.now() - ts;
113
- if (age >= 0 && age < ttlMs) {
120
+ const versionMatches = !modelCacheKey || cached.modelCacheKey === modelCacheKey;
121
+ if (age >= 0 && age < ttlMs && versionMatches) {
114
122
  const models = Array.isArray(cached.models) ? cached.models : null;
115
123
  return { runtime: runtimeName, models, cachedAt: cached.cachedAt };
116
124
  }
@@ -134,7 +142,12 @@ async function getRuntimeModels(runtimeName, { force = false, ttlMs = MODEL_CACH
134
142
 
135
143
  const cachedAt = new Date().toISOString();
136
144
  if (cachePath) {
137
- _writeCacheFile(cachePath, { runtime: runtimeName, models, cachedAt });
145
+ _writeCacheFile(cachePath, {
146
+ runtime: runtimeName,
147
+ models,
148
+ cachedAt,
149
+ ...(modelCacheKey ? { modelCacheKey } : {}),
150
+ });
138
151
  }
139
152
  return { runtime: runtimeName, models, cachedAt };
140
153
  }
@@ -320,6 +320,17 @@ function _normalizeConfigOverrides(configOverrides) {
320
320
  return [];
321
321
  }
322
322
 
323
+ const _MIME_EXT = {
324
+ 'image/png': 'png',
325
+ 'image/jpeg': 'jpg',
326
+ 'image/gif': 'gif',
327
+ 'image/webp': 'webp',
328
+ };
329
+
330
+ function _mimeToExt(mimeType) {
331
+ return _MIME_EXT[String(mimeType || '').toLowerCase()] || 'bin';
332
+ }
333
+
323
334
  function buildArgs(opts = {}) {
324
335
  const {
325
336
  model,
@@ -329,11 +340,13 @@ function buildArgs(opts = {}) {
329
340
  sandbox = 'workspace-write',
330
341
  skipGitRepoCheck = true,
331
342
  configOverrides,
343
+ images,
344
+ tmpDir,
332
345
  } = opts;
333
346
 
334
347
  const args = ['exec'];
335
- if (sessionId) args.push('resume', String(sessionId));
336
-
348
+ // These are `exec` options, not `exec resume` options. Codex's resume
349
+ // parser rejects flags such as --color and --sandbox after the subcommand.
337
350
  args.push('--json', '--color', 'never');
338
351
  if (model) args.push('--model', String(model));
339
352
  if (sandbox) args.push('--sandbox', String(sandbox));
@@ -347,6 +360,20 @@ function buildArgs(opts = {}) {
347
360
  args.push('--config', override);
348
361
  }
349
362
 
363
+ if (sessionId) args.push('resume', String(sessionId));
364
+
365
+ // Codex accepts repeatable --image paths on fresh and resumed turns. The
366
+ // call-scoped tmpDir is removed by engine/llm.js after the process exits.
367
+ if (Array.isArray(images) && images.length && tmpDir) {
368
+ for (let i = 0; i < images.length; i++) {
369
+ const img = images[i];
370
+ if (!img || !img.dataBase64) continue;
371
+ const filePath = path.join(tmpDir, `img-${i}.${_mimeToExt(img.mimeType)}`);
372
+ fs.writeFileSync(filePath, Buffer.from(img.dataBase64, 'base64'));
373
+ args.push('--image', filePath);
374
+ }
375
+ }
376
+
350
377
  args.push('-');
351
378
  return args;
352
379
  }
@@ -544,6 +571,21 @@ function _extractToolUse(obj) {
544
571
  const itemType = String(item?.type || '');
545
572
  if (!/tool|function|command|web_search/i.test(type) && !/tool|command|web_search/i.test(itemType)) return null;
546
573
  const data = item || obj.data || obj;
574
+ const id = _pickString(data.id, obj?.item_id, obj?.itemId, obj?.data?.item_id);
575
+ const rawStatus = String(data.status || obj?.status || '').toLowerCase();
576
+ const status = /fail|error|cancel/.test(rawStatus) ? 'failed'
577
+ : /complete|success/.test(rawStatus) ? 'completed'
578
+ : null;
579
+
580
+ if (/command_execution/i.test(itemType)) {
581
+ const command = _pickString(data.command, data.input);
582
+ if (!command) return null;
583
+ return { name: 'Bash', input: { command }, id: id || null, status };
584
+ }
585
+ if (/web_search/i.test(itemType)) {
586
+ const query = _pickString(data.query, data.input, data.arguments);
587
+ return { name: 'WebSearch', input: query ? { query } : {}, id: id || null, status };
588
+ }
547
589
  const name = _pickString(
548
590
  data.tool_name,
549
591
  data.toolName,
@@ -555,7 +597,7 @@ function _extractToolUse(obj) {
555
597
  );
556
598
  if (!name) return null;
557
599
  const input = data.arguments || data.args || data.input || data.function?.arguments || {};
558
- return { name, input };
600
+ return { name, input, id: id || null, status };
559
601
  }
560
602
 
561
603
  function _usageFrom(obj) {
@@ -675,6 +717,17 @@ function parseError(rawOutput) {
675
717
  if (!text) return { message: '', code: null, retriable: true };
676
718
  const lower = text.toLowerCase();
677
719
 
720
+ if (/requires a newer version of codex|upgrade to the latest (?:app or )?cli|codex (?:cli )?is (?:too old|out of date)/i.test(text)) {
721
+ const name = _extractInvalidModelName(text)
722
+ || (text.match(/['"`]([A-Za-z0-9._\/-]+)['"`]/)?.[1])
723
+ || 'configured model';
724
+ return {
725
+ message: `The installed Codex CLI is too old for model "${name}". Upgrade Codex, refresh the model list, and try again.`,
726
+ code: 'config-error',
727
+ retriable: false,
728
+ };
729
+ }
730
+
678
731
  const hasAuth = /not authenticated|login required|please.*log.*in|api key.*invalid|invalid api key|\bunauthorized\b|\b401\b|\b403\b/i.test(text);
679
732
  if (hasAuth) {
680
733
  return { message: 'Codex authentication failed - run `codex login` or configure an OpenAI API key, then try again.', code: 'auth-failure', retriable: false };
@@ -773,6 +826,20 @@ async function listModels({ env = process.env, timeoutMs = 10000 } = {}) {
773
826
  }
774
827
  }
775
828
 
829
+ function getModelCacheKey({ env = process.env, timeoutMs = 10000 } = {}) {
830
+ const resolved = resolveBinary({ env });
831
+ if (!resolved) return null;
832
+ try {
833
+ return _execFileCapture(resolved.bin, [...(resolved.leadingArgs || []), '--version'], {
834
+ env,
835
+ timeoutMs,
836
+ native: resolved.native,
837
+ }).trim() || null;
838
+ } catch {
839
+ return null;
840
+ }
841
+ }
842
+
776
843
  function createStreamConsumer(ctx) {
777
844
  let deltaText = '';
778
845
  let terminalText = '';
@@ -789,7 +856,11 @@ function createStreamConsumer(ctx) {
789
856
  if (/reasoning|thinking/i.test(type)) ctx.notifyThinking();
790
857
 
791
858
  const toolUse = _extractToolUse(obj);
792
- if (toolUse) ctx.pushToolUse(toolUse.name, toolUse.input);
859
+ if (toolUse) {
860
+ const seen = ctx.toolUseAlreadySeen(toolUse.name, toolUse.input, toolUse.id);
861
+ if (!seen) ctx.pushToolUse(toolUse.name, toolUse.input, toolUse.id);
862
+ if (toolUse.id && toolUse.status) ctx.updateToolUse(toolUse.id, toolUse.status);
863
+ }
793
864
 
794
865
  const delta = _extractDelta(obj);
795
866
  if (delta) {
@@ -823,6 +894,9 @@ const capabilities = {
823
894
  costTracking: false,
824
895
  modelShorthands: false,
825
896
  modelDiscovery: true,
897
+ // Bundled catalogs can lag staged/custom model availability. Let Codex
898
+ // validate unknown IDs so it can return its precise version/provider error.
899
+ strictModelCatalog: false,
826
900
  promptViaArg: false,
827
901
  budgetCap: false,
828
902
  bareMode: false,
@@ -830,9 +904,7 @@ const capabilities = {
830
904
  sessionPersistenceControl: false,
831
905
  resumePromptCarryover: true,
832
906
  streamConsumer: true,
833
- // P-7b2e4d01: no image-input mechanism (CLI not installed during spike
834
- // P-3f8a1c92). Engine code gates on this flag, never on runtime.name.
835
- imageInput: false,
907
+ imageInput: true,
836
908
  };
837
909
 
838
910
  const INSTALL_HINT = 'install Codex CLI with `npm install -g @openai/codex` or `brew install --cask codex`, then run `codex login`.';
@@ -844,6 +916,7 @@ module.exports = {
844
916
  resolveBinary,
845
917
  capsFile: CAPS_FILE,
846
918
  listModels,
919
+ getModelCacheKey,
847
920
  modelsCache: MODELS_CACHE,
848
921
  spawnScript: path.join(ENGINE_DIR, 'spawn-agent.js'),
849
922
  installHint: INSTALL_HINT,
@@ -878,5 +951,6 @@ module.exports = {
878
951
  _extractModelsFromCatalog,
879
952
  _readCatalogIds,
880
953
  _extractInvalidModelName,
954
+ _extractToolUse,
881
955
  CAPS_SCHEMA_VERSION,
882
956
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2376",
3
+ "version": "0.1.2377",
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"