@yemi33/minions 0.1.2376 → 0.1.2378

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.
@@ -130,7 +130,8 @@ function _detectPageChanges(data) {
130
130
  // for the same input. Cross-restart safety lives in the dashboardBuildId
131
131
  // reload path below — RENDER_VERSIONS handles the within-process case.
132
132
  const RENDER_VERSIONS = {
133
- agents: 2,
133
+ // Bumped 2→3 for client-ticked relative last-action timestamps.
134
+ agents: 3,
134
135
  prdProgress: 2,
135
136
  prdPrs: 1,
136
137
  inbox: 2,
@@ -516,17 +517,19 @@ function _processStatusUpdate(data, opts) {
516
517
  // the /api/work-items + /api/pull-requests .then handlers
517
518
  // (_fireCrossSliceRender) where the FRESH fetched lists are available.
518
519
  // Agents now come from /api/agents — a dedicated fresh-JSON endpoint with
519
- // input-mtime ETag (issue #2949). No /api/status outer cache in the path.
520
+ // input-mtime ETag (issue #2949). Conditional requests keep unchanged polls
521
+ // from reparsing the roster and rebuilding every card/timer.
520
522
  // cmdUpdateAgentList consumes the same slice but renders the command-line
521
523
  // chip pop-up so we feed it the fresh array too.
522
524
  _safeRender('agents', function() {
523
525
  const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
524
526
  window._lastRequestedSeq = window._lastRequestedSeq || {};
525
527
  window._lastRequestedSeq.agents = seq;
526
- fetch('/api/agents')
527
- .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
528
- .then(function (fresh) {
528
+ _condFetchJson('/api/agents')
529
+ .then(function (resp) {
529
530
  if (seq < (window._lastRequestedSeq.agents || 0)) return;
531
+ if (resp.notModified) return;
532
+ const fresh = resp.data;
530
533
  const list = Array.isArray(fresh) ? fresh : window._lastAgents;
531
534
  if (list) window._lastAgents = list;
532
535
  _safeRender('agents', function() { renderAgents(list); });
@@ -44,6 +44,34 @@ function _modelChipHtml(model) {
44
44
  return '<span class="agent-model-tag" title="Model: ' + escapeHtml(model) + '" style="font-size:var(--text-xs);font-weight:600;letter-spacing:0.4px;padding:1px 5px;margin-left:4px;border:1px solid var(--muted);border-radius:3px;color:var(--muted);background:transparent">' + escapeHtml(model) + '</span>';
45
45
  }
46
46
 
47
+ function _agentHasTimedAction(agent) {
48
+ return !!(agent && agent.lastActionPrefix && Number.isFinite(Number(agent.lastActionAt)));
49
+ }
50
+
51
+ function _formatAgentAction(prefix, actionAt, fallback) {
52
+ var actionAtMs = Number(actionAt);
53
+ var text = prefix && Number.isFinite(actionAtMs)
54
+ ? String(prefix) + ' (' + timeAgo(actionAtMs) + ')'
55
+ : String(fallback || '');
56
+ return text.length > 120 ? text.slice(0, 120) + '...' : text;
57
+ }
58
+
59
+ function _agentLastActionText(agent) {
60
+ return _formatAgentAction(agent.lastActionPrefix, agent.lastActionAt, agent.lastAction);
61
+ }
62
+
63
+ function _agentTimedActionAttrs(agent) {
64
+ return _agentHasTimedAction(agent)
65
+ ? ' data-action-prefix="' + escapeHtml(agent.lastActionPrefix) + '" data-action-at="' + Number(agent.lastActionAt) + '"'
66
+ : '';
67
+ }
68
+
69
+ function _agentActionHtml(agent) {
70
+ var text = _agentLastActionText(agent);
71
+ var attrs = _agentTimedActionAttrs(agent);
72
+ return '<div class="agent-action"' + attrs + ' title="' + escapeHtml(text) + '">' + escapeHtml(text) + '</div>';
73
+ }
74
+
47
75
  function renderAgents(agents) {
48
76
  agentData = agents;
49
77
  const grid = document.getElementById('agents-grid');
@@ -56,7 +84,7 @@ function renderAgents(agents) {
56
84
  <span class="status-badge ${escapeHtml(a.status)}">${escapeHtml(a.status)}</span>
57
85
  </div>
58
86
  <div class="agent-role">${escapeHtml(a.role)}</div>
59
- <div class="agent-action" title="${escapeHtml(a.lastAction)}">${escapeHtml(a.lastAction)}</div>
87
+ ${_agentActionHtml(a)}
60
88
  ${(function() {
61
89
  var s = a.started_at, c = a.completed_at;
62
90
  if (s && c) { var d = new Date(c) - new Date(s); if (d > 0) { var sec = Math.floor(d/1000)%60, min = Math.floor(d/60000)%60, hr = Math.floor(d/3600000); return '<div style="font-size:var(--text-xs);color:var(--muted)">Last run: ' + (hr > 0 ? hr + 'h ' : '') + min + 'm ' + sec + 's</div>'; } }
@@ -144,7 +172,7 @@ async function openAgentDetail(id) {
144
172
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; status is an internal bounded enum and all user data is wrapped in escapeHtml()/renderMd() (fields: lastAction, blocking tool, resultSummary)
145
173
  document.getElementById('detail-status-line').innerHTML =
146
174
  '<span class="status-badge ' + badgeClass + '">' + agent.status.toUpperCase() + '</span> ' +
147
- '<span style="color:var(--muted)">' + escapeHtml(agent.lastAction) + '</span>' +
175
+ '<span class="agent-detail-action"' + _agentTimedActionAttrs(agent) + ' style="color:var(--muted)">' + escapeHtml(_agentLastActionText(agent)) + '</span>' +
148
176
  (agent._blockingToolCall ? '<div style="margin-top:4px;padding:4px 8px;background:rgba(130,160,210,0.13);border:1px solid rgba(130,160,210,0.3);border-radius:4px;font-size:var(--text-base);color:var(--muted)">&#x23F3; Blocking tool call (' + escapeHtml(agent._blockingToolCall.tool) + ') &mdash; silent ' + Math.round(agent._blockingToolCall.silentMs/60000) + 'min, timeout in ' + Math.round(agent._blockingToolCall.remainingMs/60000) + 'min</div>' : '') +
149
177
  (agent.resultSummary ? '<div style="margin-top:4px;font-size:var(--text-base);color:var(--text);line-height:1.4">' + renderMd(agent.resultSummary.slice(0, 300)) + '</div>' : '');
150
178
 
@@ -168,11 +196,19 @@ async function openAgentDetail(id) {
168
196
 
169
197
  }
170
198
 
171
- // Tick running agent timers on cards every second
199
+ // Tick wall-clock-derived card text every second.
172
200
  var _agentRuntimeTimer = null;
173
201
  function _tickAgentRuntimes() {
174
- var els = document.querySelectorAll('.agent-runtime-tick');
175
- if (els.length === 0) { if (_agentRuntimeTimer) { clearInterval(_agentRuntimeTimer); _agentRuntimeTimer = null; } return; }
202
+ var grid = document.getElementById('agents-grid');
203
+ var els = grid ? grid.querySelectorAll('.agent-runtime-tick') : [];
204
+ var actionEls = grid ? Array.from(grid.querySelectorAll('.agent-action[data-action-at]')) : [];
205
+ var detailStatus = document.getElementById('detail-status-line');
206
+ var detailAction = detailStatus && detailStatus.querySelector('.agent-detail-action[data-action-at]');
207
+ if (detailAction) actionEls.push(detailAction);
208
+ if (els.length === 0 && actionEls.length === 0) {
209
+ if (_agentRuntimeTimer) { clearInterval(_agentRuntimeTimer); _agentRuntimeTimer = null; }
210
+ return;
211
+ }
176
212
  var now = Date.now();
177
213
  els.forEach(function(el) {
178
214
  var ms = now - new Date(el.dataset.started).getTime();
@@ -180,13 +216,20 @@ function _tickAgentRuntimes() {
180
216
  var sec = Math.floor(ms / 1000) % 60, min = Math.floor(ms / 60000) % 60, hr = Math.floor(ms / 3600000);
181
217
  el.textContent = 'Running: ' + (hr > 0 ? hr + 'h ' : '') + min + 'm ' + sec + 's';
182
218
  });
219
+ actionEls.forEach(function(el) {
220
+ var text = _formatAgentAction(el.dataset.actionPrefix, Number(el.dataset.actionAt), el.textContent);
221
+ el.textContent = text;
222
+ el.title = text;
223
+ });
183
224
  }
184
- // Start ticker after each render if working agents exist
225
+ // Start ticker after each render if any card has wall-clock-derived text.
185
226
  var _origRenderAgents = renderAgents;
186
227
  renderAgents = function(agents) {
187
228
  _origRenderAgents(agents);
188
229
  if (_agentRuntimeTimer) { clearInterval(_agentRuntimeTimer); _agentRuntimeTimer = null; }
189
- if (agents.some(function(a) { return a.status === 'working' && a.started_at; })) {
230
+ if (agents.some(function(a) {
231
+ return (a.status === 'working' && a.started_at) || _agentHasTimedAction(a);
232
+ })) {
190
233
  _tickAgentRuntimes();
191
234
  _agentRuntimeTimer = setInterval(_tickAgentRuntimes, 1000);
192
235
  }
@@ -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
@@ -2996,68 +2996,56 @@ function _stateReadContentType(ext) {
2996
2996
  // issue #2949 documented. This pattern bypasses both:
2997
2997
  //
2998
2998
  // 1. Caller supplies an array of input file paths.
2999
- // 2. We statSync each one and take MAX(mtimeMs). That's the ETag.
2999
+ // 2. We asynchronously stat each one and take MAX(mtimeMs). That's the ETag.
3000
3000
  // 3. If the client's If-None-Match matches → 304 with no body.
3001
3001
  // 4. Otherwise call `builder()` to assemble the response from scratch
3002
3002
  // (no outer cache layer to go stale).
3003
3003
  // 5. Stamp ETag + Content-Type and send.
3004
3004
  //
3005
- // Cost per request when ETag matches: N statSync calls (microseconds).
3006
- // Cost on a miss: same N statSync + the builder, which is exactly the
3005
+ // Cost per request when ETag matches: N asynchronous stat calls.
3006
+ // Cost on a miss: the same stat walk + the builder, which is exactly the
3007
3007
  // existing queries.getAgents() / getMetrics() / etc. that /api/status
3008
3008
  // already pays. Net change: same compute, no caching, always-fresh.
3009
- function _maxInputMtimeMs(inputs) {
3010
- let max = 0;
3011
- for (const fp of inputs) {
3012
- try {
3013
- // Use lstatSync so a broken Windows junction (common after DevBox
3014
- // swap) or a circular symlink doesn't block the event loop on a
3015
- // ~30 s OS timeout trying to follow the link. Engine never writes
3016
- // symlinks into state dirs in normal operation, so an lstat that
3017
- // detects a symlink is treated as "skip" — the input is effectively
3018
- // not tracked. (Round-2 review finding #7.)
3019
- const s = fs.lstatSync(fp);
3020
- if (s.isSymbolicLink()) continue;
3021
- if (s.mtimeMs > max) max = s.mtimeMs;
3022
- // Directory inputs: dir mtime only advances on add/remove of
3023
- // immediate entries, not on in-place file edits. Walk two levels
3024
- // down so editing prd/<plan>.json in place (depth 1) and
3025
- // agents/<id>/live-output.log (depth 2) both bust the ETag.
3026
- // Steering edits at agents/<id>/inbox/steering-*.md (depth 3)
3027
- // currently rely on add/remove cadence rather than per-file content
3028
- // mtime — accept this lag rather than walking unbounded.
3029
- // (Review finding #6.)
3030
- if (s.isDirectory()) {
3031
- let level1;
3032
- try { level1 = fs.readdirSync(fp); } catch { level1 = []; }
3033
- for (const name of level1) {
3034
- const child = path.join(fp, name);
3035
- let cs;
3036
- try { cs = fs.lstatSync(child); } catch { continue; }
3037
- if (cs.isSymbolicLink()) continue;
3038
- if (cs.mtimeMs > max) max = cs.mtimeMs;
3039
- if (cs.isDirectory()) {
3040
- let level2;
3041
- try { level2 = fs.readdirSync(child); } catch { level2 = []; }
3042
- for (const inner of level2) {
3043
- try {
3044
- const is = fs.lstatSync(path.join(child, inner));
3045
- if (is.isSymbolicLink()) continue;
3046
- if (is.mtimeMs > max) max = is.mtimeMs;
3047
- } catch { /* skip unreadable */ }
3048
- }
3049
- }
3050
- }
3051
- }
3052
- } catch {
3053
- // Missing input = not yet on disk. Don't bust the ETag — a builder
3054
- // that legitimately depends on the file will surface its own empty
3055
- // state, and the ETag will move once the file appears.
3056
- }
3009
+ async function _freshnessEntryMtimeMs(fp, depth) {
3010
+ let stat;
3011
+ try {
3012
+ // lstat avoids following broken Windows junctions or circular symlinks,
3013
+ // which can incur long OS timeouts. State inputs never require symlinks.
3014
+ stat = await fs.promises.lstat(fp);
3015
+ } catch {
3016
+ return 0;
3017
+ }
3018
+ if (stat.isSymbolicLink()) return 0;
3019
+
3020
+ let max = stat.mtimeMs;
3021
+ if (!stat.isDirectory() || depth <= 0) return max;
3022
+
3023
+ let names;
3024
+ try {
3025
+ names = await fs.promises.readdir(fp);
3026
+ } catch {
3027
+ return max;
3057
3028
  }
3058
- return Math.floor(max);
3029
+ const childMtimes = await Promise.all(
3030
+ names.map(name => _freshnessEntryMtimeMs(path.join(fp, name), depth - 1)),
3031
+ );
3032
+ for (const childMtime of childMtimes) {
3033
+ if (childMtime > max) max = childMtime;
3034
+ }
3035
+ return max;
3036
+ }
3037
+
3038
+ async function _maxInputMtimeMs(inputs) {
3039
+ // Directory mtime only advances on add/remove, not in-place edits. Walk two
3040
+ // levels so prd/<plan>.json and agents/<id>/live-output.log both bust ETags.
3041
+ // Deeper steering edits still rely on add/remove cadence, matching the prior
3042
+ // bounded walk.
3043
+ const mtimes = await Promise.all(
3044
+ inputs.map(fp => _freshnessEntryMtimeMs(fp, 2)),
3045
+ );
3046
+ return Math.floor(mtimes.reduce((max, mtime) => Math.max(max, mtime), 0));
3059
3047
  }
3060
- function serveFreshJson(req, res, opts) {
3048
+ async function serveFreshJson(req, res, opts) {
3061
3049
  const inputs = Array.isArray(opts && opts.inputs) ? opts.inputs : [];
3062
3050
  const builder = opts && typeof opts.builder === 'function' ? opts.builder : null;
3063
3051
  const tag = opts && opts.tag ? String(opts.tag) : 'v';
@@ -3067,7 +3055,7 @@ function serveFreshJson(req, res, opts) {
3067
3055
  res.end(JSON.stringify({ error: 'serveFreshJson: builder is required' }));
3068
3056
  return;
3069
3057
  }
3070
- const mtime = _maxInputMtimeMs(inputs);
3058
+ const mtime = await _maxInputMtimeMs(inputs);
3071
3059
  const eventVersion = _getCurrentEventVersion();
3072
3060
  const etag = '"' + tag + '-' + mtime + '-' + eventVersion + '"';
3073
3061
  res.setHeader('ETag', etag);
@@ -4502,6 +4490,7 @@ async function _preflightModelCheck({ runtime: cliOverride, model: modelOverride
4502
4490
  const resolvedModel = llm._resolveModelForRuntime(adapter, { model: modelOverride, engineConfig });
4503
4491
  if (!resolvedModel) return null;
4504
4492
  if (!adapter.capabilities || adapter.capabilities.modelDiscovery !== true) return null;
4493
+ if (adapter.capabilities.strictModelCatalog === false) return null;
4505
4494
 
4506
4495
  let list;
4507
4496
  try {
@@ -7676,7 +7665,7 @@ const server = http.createServer(async (req, res) => {
7676
7665
  h = Math.imul(h ^ ((e.size || 0) >>> 0), 16777619);
7677
7666
  h = Math.imul(h ^ Math.floor(e.sortTs || 0), 16777619);
7678
7667
  }
7679
- const sweepMtime = _maxInputMtimeMs([
7668
+ const sweepMtime = await _maxInputMtimeMs([
7680
7669
  path.join(ENGINE_DIR, 'kb-swept.json'),
7681
7670
  path.join(ENGINE_DIR, 'kb-sweep-state.json'),
7682
7671
  ]);
@@ -10572,8 +10561,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10572
10561
  });
10573
10562
  writeCcEvent(envelope);
10574
10563
  liveState.donePayload = envelope;
10575
- _ccStreamEnded = true;
10576
10564
  if (liveState.endResponse) liveState.endResponse();
10565
+ _ccStreamEnded = true;
10577
10566
  _scheduleCcLiveCleanup(tabId);
10578
10567
  _logCcStreamEnd(_ccTelemetry, 'error-preflight-model-unavailable', { runtime: preflightFailure.runtime });
10579
10568
  return;
@@ -10621,8 +10610,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10621
10610
  if (initial.missingRuntime) return initial;
10622
10611
 
10623
10612
  // 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)
10613
+ if (!initial.text && wasResume && initial.code !== 0) {
10614
+ // Resume failed (stale/expired session) — auto-retry as fresh. A
10615
+ // briefly disconnected client can reattach to the live turn.
10626
10616
  console.log(`[CC-stream] Resume failed (code=${initial.code}) — retrying fresh`);
10627
10617
  const freshPreamble = buildCCStatePreamble();
10628
10618
  const freshCarryover = _buildTranscriptCarryover(body.transcript, { currentMessage: body.message });
@@ -10672,11 +10662,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10672
10662
  // treating an empty `text` as a failure. A genuinely empty turn has
10673
10663
  // both text and raw empty.
10674
10664
  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
10665
  // W-mpmwxni2000c25c7-b — surface the typed error envelope as a
10681
10666
  // distinct SSE `event: error` frame so the client renders a real
10682
10667
  // error UI (with a retry hint derived from `retriable`) instead of
@@ -11316,20 +11301,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11316
11301
  knownForResolved = new Set(list.models.map(m => m.id || m.name).filter(Boolean));
11317
11302
  }
11318
11303
  } 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
- }
11304
+ if (knownForResolved && knownForResolved.has(runtimeModelStr)) return null;
11305
+ // Catalogs lag staged releases and local/custom providers. Unknown is
11306
+ // therefore allowed, but a model positively published by a different
11307
+ // runtime remains an actionable incompatible-combination error.
11308
+ for (const rt of _engineRuntimes.listRuntimes()) {
11309
+ if (rt === resolvedRuntime) continue;
11310
+ try {
11311
+ const otherList = await _engineModelDiscovery.getRuntimeModels(rt, { config });
11312
+ if (Array.isArray(otherList?.models) && otherList.models.some(m => (m.id || m.name) === modelStr)) {
11313
+ return `belongs to runtime "${rt}" but resolved runtime is "${resolvedRuntime}" — incompatible combination`;
11314
+ }
11315
+ } catch { /* skip */ }
11333
11316
  }
11334
11317
  return null;
11335
11318
  }
@@ -11486,15 +11469,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11486
11469
  const resolvedCli = config.agents[id].cli || config.engine.defaultCli || 'copilot';
11487
11470
  const runtimeModelStr = _resolveModelForRuntime(candidate, resolvedCli);
11488
11471
  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).
11472
+ // Published membership is a positive signal, not a closed enum:
11473
+ // staged releases/custom providers can legitimately be absent.
11474
+ // Still reject IDs positively owned by a different runtime.
11494
11475
  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) {
11476
+ if (!knownModels || !knownModels.has(runtimeModelStr)) {
11498
11477
  const owner = await _ownerOfModel(candidate);
11499
11478
  if (owner && owner !== resolvedCli) {
11500
11479
  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
  }
package/engine/queries.js CHANGED
@@ -758,6 +758,8 @@ function getAgents(config) {
758
758
  const s = getAgentStatus(a.id); // derives from dispatch.json
759
759
 
760
760
  let lastAction = 'Waiting for assignment';
761
+ let lastActionPrefix = null;
762
+ let lastActionAt = null;
761
763
  // Show the dispatch task as a stable label for the whole run — don't swap
762
764
  // in the transient per-tool-call `_runningToolDescription`, which reads like
763
765
  // a task change to operators (e.g. an agent's own "Wait until temp dirs
@@ -768,17 +770,23 @@ function getAgents(config) {
768
770
  else if (s.status === 'error') lastAction = `Error: ${s.task}`;
769
771
  else if (steeringInboxFiles.length > 0) {
770
772
  const lastSteer = steeringInboxFiles[steeringInboxFiles.length - 1];
771
- lastAction = `Pending steering: ${lastSteer.file} (${timeSince(lastSteer.createdAtMs)})`;
773
+ lastActionPrefix = `Pending steering: ${lastSteer.file}`;
774
+ lastActionAt = lastSteer.createdAtMs;
775
+ lastAction = `${lastActionPrefix} (${timeSince(lastActionAt)})`;
772
776
  }
773
777
  else if (inboxFiles.length > 0) {
774
778
  const lastOutput = path.join(INBOX_DIR, inboxFiles[inboxFiles.length - 1]);
775
- try { lastAction = `Output: ${path.basename(lastOutput)} (${timeSince(fs.statSync(lastOutput).mtimeMs)})`; } catch { /* optional */ }
779
+ try {
780
+ lastActionPrefix = `Output: ${path.basename(lastOutput)}`;
781
+ lastActionAt = fs.statSync(lastOutput).mtimeMs;
782
+ lastAction = `${lastActionPrefix} (${timeSince(lastActionAt)})`;
783
+ } catch { /* optional */ }
776
784
  }
777
785
 
778
786
  const chartered = fs.existsSync(path.join(AGENTS_DIR, a.id, 'charter.md'));
779
787
  if (lastAction.length > 120) lastAction = lastAction.slice(0, 120) + '...';
780
788
  return {
781
- ...a, runtime, model, status: s.status, lastAction,
789
+ ...a, runtime, model, status: s.status, lastAction, lastActionPrefix, lastActionAt,
782
790
  // Descriptive capability tags; tolerate legacy `skills` configs by
783
791
  // normalizing to `expertise` for the dashboard/settings UI.
784
792
  expertise: a.expertise ?? a.skills ?? [],
@@ -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.2378",
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"