@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.
- package/dashboard/js/settings.js +23 -30
- package/dashboard.js +21 -30
- package/engine/github.js +97 -73
- package/engine/llm.js +2 -1
- package/engine/model-discovery.js +15 -2
- package/engine/runtimes/codex.js +81 -7
- package/package.json +1 -1
package/dashboard/js/settings.js
CHANGED
|
@@ -890,13 +890,11 @@ async function initRuntimeFleetUI(engineCfg, agentsCfg) {
|
|
|
890
890
|
}
|
|
891
891
|
|
|
892
892
|
/**
|
|
893
|
-
*
|
|
894
|
-
*
|
|
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
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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 ?
|
|
928
|
-
opts += '<option value="' + escHtml(id) + '"' + (
|
|
919
|
+
const label = m.name && m.name !== id ? m.name : '';
|
|
920
|
+
opts += '<option value="' + escHtml(id) + '"' + (label ? ' label="' + escHtml(label) + '"' : '') + '></option>';
|
|
929
921
|
}
|
|
930
|
-
|
|
931
|
-
//
|
|
932
|
-
|
|
933
|
-
|
|
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.
|
|
941
|
-
*
|
|
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
|
|
10625
|
-
// Resume failed (stale/expired session) — auto-retry as fresh
|
|
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 &&
|
|
11320
|
-
|
|
11321
|
-
|
|
11322
|
-
|
|
11323
|
-
|
|
11324
|
-
|
|
11325
|
-
|
|
11326
|
-
|
|
11327
|
-
|
|
11328
|
-
|
|
11329
|
-
|
|
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
|
-
//
|
|
11490
|
-
//
|
|
11491
|
-
//
|
|
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
|
|
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(
|
|
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
|
-
|
|
1445
|
-
const
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
//
|
|
1866
|
-
//
|
|
1867
|
-
//
|
|
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 =>
|
|
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
|
-
|
|
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, {
|
|
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
|
}
|
package/engine/runtimes/codex.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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"
|