@yemi33/minions 0.1.2375 → 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/github.js CHANGED
@@ -99,6 +99,28 @@ function getRepoSlug(project) {
99
99
  return `${org}/${repo}`;
100
100
  }
101
101
 
102
+ function getPrRepoSlug(project, pr) {
103
+ const scope = shared.getPrScopeInfo(pr, pr?.url || '')?.scope || '';
104
+ if (scope && scope === shared.getProjectPrScope(project)) return getRepoSlug(project);
105
+ if (scope.startsWith('github:')) return scope.slice('github:'.length);
106
+ return getRepoSlug(project);
107
+ }
108
+
109
+ function getProjectRepoSlugs(project) {
110
+ const primary = getRepoSlug(project);
111
+ const slugs = primary ? [primary] : [];
112
+ const seen = new Set(slugs.map(slug => slug.toLowerCase()));
113
+ for (const scope of shared.getProjectAllPrScopes(project)) {
114
+ if (!scope.startsWith('github:')) continue;
115
+ const slug = scope.slice('github:'.length);
116
+ if (!seen.has(slug)) {
117
+ slugs.push(slug);
118
+ seen.add(slug);
119
+ }
120
+ }
121
+ return slugs;
122
+ }
123
+
102
124
  function getConfiguredGitHubAuthorLogins(config = {}) {
103
125
  const accounts = config?.engine?.ghAccounts;
104
126
  if (!accounts || typeof accounts !== 'object') return new Set();
@@ -520,40 +542,40 @@ async function forEachActiveGhPr(config, callback) {
520
542
  const src = project?.workSources?.pullRequests || config?.workSources?.pullRequests;
521
543
  if (src && src.enabled === false) continue;
522
544
 
523
- const slug = getRepoSlug(project);
524
- if (!slug) continue;
525
-
526
- // Skip projects in backoff (inaccessible repo)
527
- if (isSlugInBackoff(slug)) continue;
528
-
529
545
  const prs = getPrs(project);
530
546
  const activePrs = prs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status)
531
547
  && shared.isPrCompatibleWithProject(project, pr, pr.url || ''));
532
548
  if (activePrs.length === 0) continue;
533
549
 
534
- // Probe repo accessibility before iterating PRs — avoids N warnings per inaccessible repo.
535
- // W-mp5trwh60008386d: ghApi returns the GH_NOT_FOUND sentinel on 404 (a frozen object,
536
- // *not* null). The pre-fix gate only matched `null`, so a 404 on the base repo (caused by
537
- // a multi-account `gh auth` switch, network blip, or token rotation) fell through and every
538
- // per-PR call below 404'd, permanently flipping all active PRs to `abandoned`. We now treat
539
- // both null and GH_NOT_FOUND as "skip the project for this tick" and explicitly do NOT
540
- // increment per-PR `_consecutive404s` counters since no per-PR call was made.
541
- const probe = await ghApi('', slug);
542
- if (probe === null || probe === GH_NOT_FOUND) {
543
- if (probe === GH_NOT_FOUND) {
544
- log('warn', `GitHub repo probe for ${slug} returned 404 — skipping all per-PR polls for this project this tick (avoids spurious abandonments). Per-PR 404 counters NOT incremented.`);
545
- }
546
- recordSlugFailure(slug);
547
- continue;
548
- }
549
- resetSlugBackoff(slug);
550
-
551
550
  let projectUpdated = 0;
552
551
  const updatedRecords = [];
552
+ const slugProbes = new Map();
553
553
 
554
554
  for (const pr of activePrs) {
555
555
  const prNum = shared.getPrNumber(pr);
556
556
  if (!prNum) continue;
557
+ const slug = getPrRepoSlug(project, pr);
558
+ if (!slug) continue;
559
+
560
+ let probeResult = slugProbes.get(slug);
561
+ if (probeResult === undefined) {
562
+ if (isSlugInBackoff(slug)) {
563
+ probeResult = 'fail';
564
+ } else {
565
+ const probe = await ghApi('', slug);
566
+ probeResult = (probe === null || probe === GH_NOT_FOUND) ? 'fail' : 'ok';
567
+ if (probeResult === 'fail') {
568
+ if (probe === GH_NOT_FOUND) {
569
+ log('warn', `GitHub repo probe for ${slug} returned 404 — skipping all per-PR polls for this repository this tick (avoids spurious abandonments). Per-PR 404 counters NOT incremented.`);
570
+ }
571
+ recordSlugFailure(slug);
572
+ } else {
573
+ resetSlugBackoff(slug);
574
+ }
575
+ }
576
+ slugProbes.set(slug, probeResult);
577
+ }
578
+ if (probeResult !== 'ok') continue;
557
579
 
558
580
  try {
559
581
  const before = shared.snapshotPrRecord(pr);
@@ -612,7 +634,10 @@ async function forEachActiveGhPr(config, callback) {
612
634
  const centralPrs = safeJsonArr(centralPath);
613
635
  // Build a slug→project map for configured GitHub projects so we can detect
614
636
  // PRs that are actually owned by the project loop (present in per-project file).
615
- const configuredGhProjectsBySlug = new Map(projects.map(p => [getRepoSlug(p), p]));
637
+ const configuredGhProjectsBySlug = new Map();
638
+ for (const project of projects) {
639
+ for (const slug of getProjectRepoSlugs(project)) configuredGhProjectsBySlug.set(slug.toLowerCase(), project);
640
+ }
616
641
  const activeCentral = centralPrs.filter(pr => {
617
642
  if (!PR_POLLABLE_STATUSES.has(pr.status)) return false;
618
643
  if (!pr.url) return false;
@@ -621,7 +646,7 @@ async function forEachActiveGhPr(config, callback) {
621
646
  // the project loop will never see it — the central branch must poll it.
622
647
  const ghMatch = pr.url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/\d+/);
623
648
  if (ghMatch) {
624
- const owningProject = configuredGhProjectsBySlug.get(ghMatch[1]);
649
+ const owningProject = configuredGhProjectsBySlug.get(ghMatch[1].toLowerCase());
625
650
  if (owningProject) {
626
651
  const projectPrs = safeJsonArr(projectPrPath(owningProject));
627
652
  if (projectPrs.some(p => p.id === pr.id)) return false; // present → project loop handles it
@@ -1425,12 +1450,6 @@ async function reconcilePrs(config) {
1425
1450
  let totalAdded = 0;
1426
1451
 
1427
1452
  for (const project of projects) {
1428
- const slug = getRepoSlug(project);
1429
- if (!slug) continue;
1430
-
1431
- // Skip projects in backoff (inaccessible repo)
1432
- if (isSlugInBackoff(slug)) continue;
1433
-
1434
1453
  // Skip projects with no tracked PRs and no work items — nothing to reconcile
1435
1454
  const existingPrs = getPrs(project);
1436
1455
  if (existingPrs.length === 0) {
@@ -1441,18 +1460,21 @@ async function reconcilePrs(config) {
1441
1460
  } catch { continue; }
1442
1461
  }
1443
1462
 
1444
- // Fetch open PRs — paginate to handle repos with >100 open PRs
1445
- const prsData = await ghApi('/pulls?state=open&per_page=100', slug, { paginate: true, timeout: 60000 });
1446
- if (!prsData || !Array.isArray(prsData)) {
1447
- recordSlugFailure(slug);
1448
- continue;
1463
+ const ghPrs = [];
1464
+ for (const slug of getProjectRepoSlugs(project)) {
1465
+ if (isSlugInBackoff(slug)) continue;
1466
+ // Fetch open PRs — paginate to handle repos with >100 open PRs.
1467
+ const prsData = await ghApi('/pulls?state=open&per_page=100', slug, { paginate: true, timeout: 60000 });
1468
+ if (!prsData || !Array.isArray(prsData)) {
1469
+ recordSlugFailure(slug);
1470
+ continue;
1471
+ }
1472
+ resetSlugBackoff(slug);
1473
+ for (const pr of prsData) {
1474
+ const branch = pr.head?.ref || '';
1475
+ if (branchPatterns.some(pat => pat.test(branch))) ghPrs.push({ pr, slug });
1476
+ }
1449
1477
  }
1450
- resetSlugBackoff(slug);
1451
-
1452
- const ghPrs = prsData.filter(pr => {
1453
- const branch = pr.head?.ref || '';
1454
- return branchPatterns.some(pat => pat.test(branch));
1455
- });
1456
1478
 
1457
1479
  if (ghPrs.length === 0) continue;
1458
1480
 
@@ -1470,8 +1492,12 @@ async function reconcilePrs(config) {
1470
1492
  const centralItems = safeJsonArr(centralWiPath);
1471
1493
  const allItems = [...workItems, ...centralItems];
1472
1494
 
1473
- for (const ghPr of ghPrs) {
1474
- const prUrl = project.prUrlBase ? project.prUrlBase + ghPr.number : ghPr.html_url || '';
1495
+ for (const { pr: ghPr, slug } of ghPrs) {
1496
+ const primarySlug = getRepoSlug(project);
1497
+ const prUrl = ghPr.html_url
1498
+ || (slug.toLowerCase() === primarySlug?.toLowerCase() && project.prUrlBase
1499
+ ? project.prUrlBase + ghPr.number
1500
+ : `https://github.com/${slug}/pull/${ghPr.number}`);
1475
1501
  const prId = shared.getCanonicalPrId(project, ghPr.number, prUrl);
1476
1502
  // P-a7c4d2e8 (F3): validate API-derived branch ref before persistence
1477
1503
  // / regex matching. Defensive degrade — log and treat as missing on
@@ -1604,7 +1630,7 @@ async function reconcilePrs(config) {
1604
1630
  */
1605
1631
  async function checkLiveReviewStatus(pr, project) {
1606
1632
  try {
1607
- const slug = getRepoSlug(project);
1633
+ const slug = getPrRepoSlug(project, pr);
1608
1634
  if (!slug) return null;
1609
1635
  const prNum = shared.getPrNumber(pr);
1610
1636
  if (!prNum) return null;
@@ -1654,7 +1680,7 @@ async function checkLiveReviewStatus(pr, project) {
1654
1680
  async function dismissPriorViewerChangesRequestedReviews(pr, project) {
1655
1681
  let tmpFile = null;
1656
1682
  try {
1657
- const slug = getRepoSlug(project);
1683
+ const slug = getPrRepoSlug(project, pr);
1658
1684
  if (!slug) return null;
1659
1685
  const prNum = shared.getPrNumber(pr);
1660
1686
  if (!prNum) return null;
@@ -1758,7 +1784,7 @@ async function dismissPriorViewerChangesRequestedReviews(pr, project) {
1758
1784
  */
1759
1785
  async function checkLiveBuildAndConflict(pr, project) {
1760
1786
  try {
1761
- const slug = getRepoSlug(project);
1787
+ const slug = getPrRepoSlug(project, pr);
1762
1788
  if (!slug) return null;
1763
1789
  const prNum = shared.getPrNumber(pr);
1764
1790
  if (!prNum) return null;
@@ -1846,9 +1872,6 @@ async function reconcileAbandonedPrs(config) {
1846
1872
  const slugProbeCache = new Map(); // slug → 'ok' | 'fail'
1847
1873
 
1848
1874
  for (const project of projects) {
1849
- const slug = getRepoSlug(project);
1850
- if (!slug) continue;
1851
-
1852
1875
  const prPath = projectPrPath(project);
1853
1876
  const prs = safeJsonArr(prPath);
1854
1877
  // Filter: only abandoned PRs that don't already have the confirmed-404
@@ -1862,37 +1885,35 @@ async function reconcileAbandonedPrs(config) {
1862
1885
  );
1863
1886
  if (abandonedPrs.length === 0) continue;
1864
1887
 
1865
- // Probe base repo (cached). Failure skip ALL of this slug's abandoned
1866
- // PRs and increment skipped counter — auth/access issue at boot, retry
1867
- // next restart.
1868
- let probeResult = slugProbeCache.get(slug);
1869
- if (probeResult === undefined) {
1870
- const probe = await ghApi('', slug);
1871
- probeResult = (probe === null || probe === GH_NOT_FOUND) ? 'fail' : 'ok';
1872
- slugProbeCache.set(slug, probeResult);
1873
- }
1874
- if (probeResult === 'fail') {
1875
- log('warn', `Abandoned PR reconciliation: skipping ${slug} (${abandonedPrs.length} PR${abandonedPrs.length === 1 ? '' : 's'}) — base-repo probe failed, retry next startup`);
1876
- skipped += abandonedPrs.length;
1877
- continue;
1878
- }
1879
-
1880
- // Per-PR re-probe. Collect updates first, then apply via mutatePullRequests
1881
- // for atomic single-writer semantics. We match by prNumber on writeback
1882
- // (not id) because mutatePullRequests calls normalizePrRecords which can
1883
- // lowercase the id slug — prNumber is the stable key within a project's
1884
- // pull-requests.json.
1885
- const updates = []; // { prNumber, action, newStatus?, mergedAt? }
1888
+ // Per-PR re-probe. Canonical ids keep same-number PRs from different
1889
+ // repository scopes isolated during writeback.
1890
+ const updates = []; // { prId, prNumber, action, newStatus?, mergedAt? }
1886
1891
  for (const pr of abandonedPrs) {
1887
1892
  const prNum = shared.getPrNumber(pr);
1888
1893
  if (!prNum) continue;
1894
+ const slug = getPrRepoSlug(project, pr);
1895
+ if (!slug) continue;
1896
+ const prId = shared.getCanonicalPrId(project, pr, pr.url || '');
1897
+ if (!prId) continue;
1898
+
1899
+ let probeResult = slugProbeCache.get(slug);
1900
+ if (probeResult === undefined) {
1901
+ const probe = await ghApi('', slug);
1902
+ probeResult = (probe === null || probe === GH_NOT_FOUND) ? 'fail' : 'ok';
1903
+ slugProbeCache.set(slug, probeResult);
1904
+ }
1905
+ if (probeResult === 'fail') {
1906
+ log('warn', `Abandoned PR reconciliation: skipping ${slug} — base-repo probe failed, retry next startup`);
1907
+ skipped++;
1908
+ continue;
1909
+ }
1889
1910
 
1890
1911
  try {
1891
1912
  const prData = await ghApi(`/pulls/${prNum}`, slug);
1892
1913
  if (prData === GH_NOT_FOUND) {
1893
1914
  // 404 with base-probe OK → genuinely deleted. Mark so we don't
1894
1915
  // re-probe this PR on future startups.
1895
- updates.push({ prNumber: prNum, action: 'confirm404' });
1916
+ updates.push({ prId, prNumber: prNum, action: 'confirm404' });
1896
1917
  confirmedDeleted++;
1897
1918
  log('info', `Confirmed PR #${prNum} (${slug}): truly deleted, leaving abandoned`);
1898
1919
  } else if (prData) {
@@ -1915,6 +1936,7 @@ async function reconcileAbandonedPrs(config) {
1915
1936
  continue;
1916
1937
  }
1917
1938
  updates.push({
1939
+ prId,
1918
1940
  prNumber: prNum,
1919
1941
  action: 'flip',
1920
1942
  newStatus,
@@ -1938,7 +1960,9 @@ async function reconcileAbandonedPrs(config) {
1938
1960
  const reconciledAt = ts();
1939
1961
  mutatePullRequests(prPath, (currentPrs) => {
1940
1962
  for (const upd of updates) {
1941
- const pr = currentPrs.find(p => shared.getPrNumber(p) === upd.prNumber);
1963
+ const pr = currentPrs.find(p =>
1964
+ shared.getCanonicalPrId(project, p, p.url || '') === upd.prId
1965
+ );
1942
1966
  if (!pr) continue;
1943
1967
  // Defensive: never downgrade a merged record. Should already be
1944
1968
  // filtered by the abandoned-only scan above, but a concurrent writer
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.2375",
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"