@yemi33/minions 0.1.2046 → 0.1.2048

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.
@@ -22,6 +22,16 @@ function _countPrFollowups(pr) {
22
22
  return n;
23
23
  }
24
24
 
25
+ // W-mpmwxkzm0009ba0b — Parse a canonical PR id like `github:octo/repo#123`
26
+ // or `ado:org/proj/repo#456` into the {host, slug, number} triple used by
27
+ // POST /api/pull-requests/observe. Returns null when the id doesn't match.
28
+ function _parseCanonicalPrId(id) {
29
+ if (!id || typeof id !== 'string') return null;
30
+ var m = id.match(/^(github|ado):(.+)#(\d+)$/);
31
+ if (!m) return null;
32
+ return { host: m[1], slug: m[2], number: parseInt(m[3], 10) };
33
+ }
34
+
25
35
  function prRow(pr) {
26
36
  // Minions review (agent) state — separate from ADO human review
27
37
  const sq = pr.minionsReview || {};
@@ -61,6 +71,29 @@ function prRow(pr) {
61
71
  ? '<span class="pr-agent" title="' + escapeHtml(pr.reviewedBy.join(', ')) + '">' + escapeHtml(pr.reviewedBy.join(', ')) + '</span>'
62
72
  : '<span style="color:var(--muted);font-size:11px">—</span>';
63
73
  const createdLabel = (pr.created || '—').slice(0, 16).replace('T', ' ');
74
+ // Per-row auto-observe toggle (W-mpmwxkzm0009ba0b). _contextOnly === true
75
+ // means the engine polls status/comments but does NOT dispatch review/fix
76
+ // agents against this PR. Default (undefined / false) = observed.
77
+ var observe = pr._contextOnly !== true;
78
+ var observeClass = observe ? 'pr-observe-on' : 'pr-observe-off';
79
+ var observeLabel = observe ? 'observed' : 'context';
80
+ var observeTitle = observe
81
+ ? 'Auto-observe ON — engine reviews this PR and dispatches fix agents on review/build feedback. Click to switch to context-only.'
82
+ : 'Context only — engine polls status and comments but will not dispatch review/fix agents. Click to enable auto-observe.';
83
+ // pr.id is canonical: "<host>:<slug>#<number>" (engine/shared.js getCanonicalPrId).
84
+ // Parse here so the toggle handler can post {host, slug, number} without
85
+ // shipping additional fields per row.
86
+ var observeBtn = '';
87
+ var idMatch = typeof pr.id === 'string' ? pr.id.match(/^([^:]+):(.+)#(\d+)$/) : null;
88
+ if (idMatch) {
89
+ observeBtn = '<button class="pr-observe-toggle ' + observeClass + '" '
90
+ + 'data-pr-host="' + escapeHtml(idMatch[1]) + '" '
91
+ + 'data-pr-slug="' + escapeHtml(idMatch[2]) + '" '
92
+ + 'data-pr-number="' + escapeHtml(idMatch[3]) + '" '
93
+ + 'data-pr-observe="' + (observe ? '1' : '0') + '" '
94
+ + 'title="' + escapeHtml(observeTitle) + '" '
95
+ + 'onclick="event.stopPropagation();togglePrObserve(this)">' + observeLabel + '</button> ';
96
+ }
64
97
  // Title attrs live on the inner element (link/span/badge) so hovering the
65
98
  // ellipsis-truncated content reveals the full text. Cell tags stay bare so
66
99
  // the header-to-cell count assertion in test/unit.test.js continues to
@@ -74,6 +107,7 @@ function prRow(pr) {
74
107
  '<td>' + reviewerCell + '</td>' +
75
108
  '<td><span class="pr-badge ' + buildClass + '" title="' + escapeHtml(buildTitle || buildLabel) + '">' + escapeHtml(buildLabel) + '</span></td>' +
76
109
  '<td><span class="pr-badge ' + statusClass + '" title="' + escapeHtml(statusLabel) + '">' + escapeHtml(statusLabel) + '</span></td>' +
110
+ '<td>' + (observeBtn || '<span style="color:var(--muted);font-size:11px">—</span>') + '</td>' +
77
111
  '<td><span class="pr-date" title="' + escapeHtml(createdLabel) + '">' + escapeHtml(createdLabel) + '</span></td>' +
78
112
  '<td><button class="pr-pager-btn" style="font-size:9px;padding:1px 5px;color:var(--red);border-color:var(--red)" data-pr-id="' + escapeHtml(String(prId)) + '" onclick="event.stopPropagation();unlinkPr(this.dataset.prId)" title="Remove from tracking">x</button></td>' +
79
113
  '</tr>';
@@ -93,6 +127,7 @@ const PRS_COLGROUP =
93
127
  '<col style="width:140px">' + // Signed Off By
94
128
  '<col style="width:130px">' + // Build
95
129
  '<col style="width:110px">' + // Status
130
+ '<col style="width:100px">' + // Observe
96
131
  '<col style="width:130px">' + // Created
97
132
  '<col style="width:50px">' + // Actions
98
133
  '</colgroup>';
@@ -101,7 +136,7 @@ function prTableHtml(rows) {
101
136
  return '<div class="pr-table-wrap pr-table-wrap--prs"><table class="pr-table pr-table--prs">' +
102
137
  PRS_COLGROUP +
103
138
  '<thead><tr>' +
104
- '<th>PR</th><th>Title</th><th>Agent</th><th>Branch</th><th>Review</th><th>Signed Off By</th><th>Build</th><th>Status</th><th>Created</th><th></th>' +
139
+ '<th>PR</th><th>Title</th><th>Agent</th><th>Branch</th><th>Review</th><th>Signed Off By</th><th>Build</th><th>Status</th><th>Observe</th><th>Created</th><th></th>' +
105
140
  '</tr></thead><tbody>' + rows + '</tbody></table></div>';
106
141
  }
107
142
 
@@ -249,4 +284,49 @@ async function unlinkPr(id) {
249
284
  } catch (e) { clearDeleted('pr:' + id); showToast('pr-toast', 'Error: ' + e.message, false); refresh(); }
250
285
  }
251
286
 
252
- window.MinionsPrs = { prRow, prTableHtml, renderPrs, prPrev, prNext, openAllPrs, openModal, openAddPrModal, unlinkPr };
287
+ // Per-row auto-observe toggle (W-mpmwxkzm0009ba0b). Optimistic UI: flip
288
+ // the button immediately, only revert on API error. The engine consumes
289
+ // `_contextOnly` to gate review/fix dispatch (engine/shared.js
290
+ // isAutoManagedPrRecord + discoverFromPrs).
291
+ async function togglePrObserve(btn) {
292
+ if (!btn || btn.disabled) return;
293
+ const host = btn.dataset.prHost;
294
+ const slug = btn.dataset.prSlug;
295
+ const number = parseInt(btn.dataset.prNumber, 10);
296
+ if (!host || !slug || !Number.isFinite(number)) return;
297
+ const wasObserve = btn.dataset.prObserve === '1';
298
+ const next = !wasObserve;
299
+ // Optimistic flip
300
+ btn.dataset.prObserve = next ? '1' : '0';
301
+ btn.classList.toggle('pr-observe-on', next);
302
+ btn.classList.toggle('pr-observe-off', !next);
303
+ btn.textContent = next ? 'observed' : 'context';
304
+ btn.disabled = true;
305
+ try {
306
+ const res = await fetch('/api/pull-requests/observe', {
307
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
308
+ body: JSON.stringify({ host, slug, number, observe: next })
309
+ });
310
+ if (!res.ok) {
311
+ const d = await res.json().catch(() => ({}));
312
+ // Revert
313
+ btn.dataset.prObserve = wasObserve ? '1' : '0';
314
+ btn.classList.toggle('pr-observe-on', wasObserve);
315
+ btn.classList.toggle('pr-observe-off', !wasObserve);
316
+ btn.textContent = wasObserve ? 'observed' : 'context';
317
+ showToast('pr-toast', 'Failed: ' + (d.error || 'unknown'), false);
318
+ return;
319
+ }
320
+ showToast('pr-toast', next ? 'Auto-observe enabled' : 'Switched to context only', true);
321
+ } catch (e) {
322
+ btn.dataset.prObserve = wasObserve ? '1' : '0';
323
+ btn.classList.toggle('pr-observe-on', wasObserve);
324
+ btn.classList.toggle('pr-observe-off', !wasObserve);
325
+ btn.textContent = wasObserve ? 'observed' : 'context';
326
+ showToast('pr-toast', 'Error: ' + e.message, false);
327
+ } finally {
328
+ btn.disabled = false;
329
+ }
330
+ }
331
+
332
+ window.MinionsPrs = { prRow, prTableHtml, renderPrs, prPrev, prNext, openAllPrs, openModal, openAddPrModal, unlinkPr, togglePrObserve };
@@ -322,6 +322,17 @@
322
322
  .pr-page-info { font-size: var(--text-base); color: var(--muted); }
323
323
  .pr-date { font-size: var(--text-base); color: var(--muted); }
324
324
 
325
+ .pr-observe-toggle {
326
+ font-size: 9px; padding: 1px 6px; border-radius: var(--radius-sm); cursor: pointer;
327
+ text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600;
328
+ background: var(--surface2); transition: all var(--transition-base);
329
+ }
330
+ .pr-observe-toggle:disabled { opacity: 0.6; cursor: wait; }
331
+ .pr-observe-toggle.pr-observe-on { color: var(--green); border: 1px solid var(--green); background: rgba(63,185,80,0.12); }
332
+ .pr-observe-toggle.pr-observe-on:hover:not(:disabled) { background: rgba(63,185,80,0.22); }
333
+ .pr-observe-toggle.pr-observe-off { color: var(--muted); border: 1px dashed var(--border); }
334
+ .pr-observe-toggle.pr-observe-off:hover:not(:disabled) { color: var(--text); border-color: var(--text); }
335
+
325
336
  .archive-btn {
326
337
  background: var(--surface2); border: 1px solid var(--border); color: var(--muted);
327
338
  font-size: var(--text-base); padding: var(--space-2) var(--space-5); border-radius: var(--radius-sm); cursor: pointer; transition: all var(--transition-base); margin-left: auto;
package/dashboard.js CHANGED
@@ -855,6 +855,67 @@ function linkPullRequestForTracking({ url, title, project: projectName, autoObse
855
855
  return { ...result, prPath, targetProject, projectResolution, prNum };
856
856
  }
857
857
 
858
+ // W-mpmwxkzm0009ba0b — Per-row auto-observe toggle backing helper for
859
+ // POST /api/pull-requests/observe. Flips `_contextOnly` / `_autoObserve` on
860
+ // an existing tracked PR record under a lock (per CLAUDE.md mutate convention).
861
+ // Body shape: { host: 'github'|'ado', slug, number, observe: boolean }.
862
+ // Returns the updated record + the PR path that was touched. Throws an
863
+ // Error with `statusCode` for the route handler to map to an HTTP status.
864
+ function updatePullRequestObserveFlag({ host, slug, number, observe } = {}, config = CONFIG, minionsDir = MINIONS_DIR) {
865
+ const hostStr = String(host || '').trim().toLowerCase();
866
+ const slugStr = String(slug || '').trim();
867
+ const numberInt = Number.parseInt(number, 10);
868
+ if (!hostStr || (hostStr !== 'github' && hostStr !== 'ado')) {
869
+ const err = new Error('host must be "github" or "ado"');
870
+ err.statusCode = 400;
871
+ throw err;
872
+ }
873
+ if (!slugStr) {
874
+ const err = new Error('slug required');
875
+ err.statusCode = 400;
876
+ throw err;
877
+ }
878
+ if (!Number.isFinite(numberInt) || numberInt <= 0) {
879
+ const err = new Error('number must be a positive integer');
880
+ err.statusCode = 400;
881
+ throw err;
882
+ }
883
+ if (typeof observe !== 'boolean') {
884
+ const err = new Error('observe must be a boolean');
885
+ err.statusCode = 400;
886
+ throw err;
887
+ }
888
+
889
+ const canonicalId = `${hostStr}:${slugStr}#${numberInt}`;
890
+ const projects = shared.getProjects(config);
891
+ const prPaths = [
892
+ ...projects.map(p => shared.projectPrPath(p)),
893
+ shared.centralPullRequestsPath(minionsDir),
894
+ ];
895
+
896
+ let updated = null;
897
+ let updatedPath = null;
898
+ for (const prPath of prPaths) {
899
+ if (updated) break;
900
+ shared.mutatePullRequests(prPath, (prs) => {
901
+ const pr = prs.find(p => p && p.id === canonicalId);
902
+ if (!pr) return prs;
903
+ pr._contextOnly = !observe;
904
+ pr._autoObserve = !!observe;
905
+ updated = { id: pr.id, _contextOnly: pr._contextOnly, _autoObserve: pr._autoObserve };
906
+ updatedPath = prPath;
907
+ return prs;
908
+ });
909
+ }
910
+
911
+ if (!updated) {
912
+ const err = new Error(`pull request ${canonicalId} not found`);
913
+ err.statusCode = 404;
914
+ throw err;
915
+ }
916
+ return { ...updated, prPath: updatedPath };
917
+ }
918
+
858
919
  function _normalizeSkillDirForCompare(dir) {
859
920
  const resolved = path.resolve(String(dir || '').replace(/\//g, path.sep));
860
921
  return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
@@ -1173,48 +1234,13 @@ function _getDashboardBrowserPresence(now = Date.now()) {
1173
1234
  return { active: activeTabs.length > 0, activeTabs: activeTabs.length, maxAgeMs: DASHBOARD_BROWSER_PRESENCE_MAX_AGE_MS };
1174
1235
  }
1175
1236
 
1176
- // Hot-reload: watch dashboard/ directory for changes, rebuild, and push reload to browsers
1177
- const _hotReloadClients = new Set();
1178
-
1179
- function rebuildDashboardHtml() {
1180
- try {
1181
- const newRaw = buildDashboardHtml();
1182
- if (newRaw === HTML_RAW) return; // no changes
1183
- HTML_RAW = newRaw;
1184
- HTML = HTML_RAW;
1185
- HTML_GZ = zlib.gzipSync(HTML);
1186
- HTML_ETAG = '"' + require('crypto').createHash('md5').update(HTML).digest('hex') + '"';
1187
- // Bust the /api/status cache so the new dashboardBuildId propagates on the
1188
- // next poll — refresh.js compares it against its first-observed value and
1189
- // hard-reloads on mismatch (R3, W-mpgb0xgc000hf1d3).
1190
- try { invalidateStatusCache(); } catch { /* status cache may not be initialized yet */ }
1191
- console.log(' Dashboard hot-reloaded');
1192
- // Push reload to all connected browsers via status-stream (saves a connection)
1193
- for (const res of _statusStreamClients) {
1194
- try { res.write('event: reload\ndata: reload\n\n'); } catch { _removeSseClient(_statusStreamClients, res); }
1195
- }
1196
- // Legacy hot-reload clients
1197
- for (const res of _hotReloadClients) {
1198
- try { res.write('data: reload\n\n'); } catch { _removeSseClient(_hotReloadClients, res); }
1199
- }
1200
- } catch (e) { console.error(' Hot-reload error:', e.message); }
1201
- }
1202
-
1203
- const dashDir = path.join(MINIONS_DIR, 'dashboard');
1204
- if (fs.existsSync(dashDir)) {
1205
- let _reloadTimer = null;
1206
- const scheduleReload = () => {
1207
- if (_reloadTimer) clearTimeout(_reloadTimer);
1208
- _reloadTimer = setTimeout(rebuildDashboardHtml, 300); // debounce 300ms
1209
- };
1210
- // Watch top-level files (styles.css, layout.html)
1211
- try { fs.watch(dashDir, scheduleReload); } catch { /* optional */ }
1212
- // Watch subdirectories (pages/, js/)
1213
- for (const sub of ['pages', 'js']) {
1214
- const subDir = path.join(dashDir, sub);
1215
- if (fs.existsSync(subDir)) try { fs.watch(subDir, scheduleReload); } catch { /* optional */ }
1216
- }
1217
- }
1237
+ // Hot-reload removed (2026-05-26): file-watch-driven rebuilds force-reloaded
1238
+ // every connected browser on any change under dashboard/, which surprised
1239
+ // users when parallel agent merges touched dashboard.js or dashboard/js/*.
1240
+ // The dashboard HTML is now assembled once at process boot. Code changes on
1241
+ // disk land in the running process only after an explicit `minions restart`;
1242
+ // the buildId/startedAt mismatch in refresh.js then triggers a one-shot
1243
+ // browser reload on that next poll — driven by user-initiated restart only.
1218
1244
 
1219
1245
  // -- Data Collectors (most moved to engine/queries.js) --
1220
1246
 
@@ -4095,7 +4121,13 @@ function _recoverPartialDocChatResponse(result, sessionKey) {
4095
4121
 
4096
4122
  function _shouldSuppressDocChatPostPatchError(ccError, finalize) {
4097
4123
  if (!finalize || finalize.edited !== true) return false;
4098
- if (!ccError || ccError.errorClass !== 'unknown-model') return false;
4124
+ if (!ccError) return false;
4125
+ // W-mpmwxni2000c25c7-a — accept both the legacy 'unknown-model' errorClass
4126
+ // (still emitted by the dashboard preflight at _preflightModelCheck) and
4127
+ // the new 'model-unavailable' code returned by copilot.parseError for
4128
+ // invalid-model responses. Suppress in either case — the edit already
4129
+ // landed, so the stale model error shouldn't surface to the user.
4130
+ if (ccError.errorClass !== 'unknown-model' && ccError.errorClass !== 'model-unavailable') return false;
4099
4131
  return String(ccError.runtime || '').toLowerCase() === 'copilot';
4100
4132
  }
4101
4133
 
@@ -9994,12 +10026,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9994
10026
  { method: 'GET', path: '/api/qa/runs', desc: 'List QA validation runs (newest first). Optional ?limit=N and ?status=pending|running|passed|failed|errored filters.', handler: handleQaRunsList },
9995
10027
  { method: 'GET', path: /^\/api\/qa\/runs\/([^/?]+)$/, template: '/api/qa/runs/<id>', desc: 'Fetch a single QA run record by id.', handler: handleQaRunsById },
9996
10028
  { method: 'GET', path: /^\/api\/qa\/artifacts\/([^/?]+)\/([^?]+)$/, template: '/api/qa/artifacts/<runId>/<file>', desc: 'Serve a QA artifact file (image/video/log). Sandboxed to engine/qa-artifacts/; rejects path traversal with 403.', handler: handleQaArtifact },
9997
- { method: 'GET', path: '/api/hot-reload', desc: 'SSE stream for dashboard hot-reload notifications', handler: (req, res) => {
9998
- res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
9999
- res.write('data: connected\n\n');
10000
- _trackSseClient(_hotReloadClients, req, res);
10001
- }},
10002
-
10003
10029
  // QA Runbooks (W-mpeiwz6k0005bf34-a) — per-project test plans stored at
10004
10030
  // <MINIONS_DIR>/projects/<name>/runbooks/<id>.json. Pure persistence —
10005
10031
  // dispatch + run records + UI live in follow-up plan items.
@@ -10222,6 +10248,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10222
10248
  })();
10223
10249
  }},
10224
10250
 
10251
+ { method: 'POST', path: '/api/pull-requests/observe', desc: 'Toggle auto-observe (_contextOnly flag) on a tracked PR', params: 'host (github|ado), slug, number, observe (boolean)', handler: async (req, res) => {
10252
+ const body = await readBody(req);
10253
+ reloadConfig();
10254
+ try {
10255
+ const result = updatePullRequestObserveFlag(body, CONFIG);
10256
+ invalidateStatusCache();
10257
+ return jsonReply(res, 200, { ok: true, ...result, observe: !result._contextOnly });
10258
+ } catch (e) {
10259
+ return jsonReply(res, e.statusCode || 400, { error: e.message });
10260
+ }
10261
+ }},
10262
+
10225
10263
  { method: 'POST', path: '/api/pull-requests/delete', desc: 'Remove a PR from tracking', params: 'id, project?', handler: async (req, res) => {
10226
10264
  const body = await readBody(req);
10227
10265
  const { id } = body;
@@ -10831,6 +10869,7 @@ module.exports = {
10831
10869
  _buildDocChatResponsePayload,
10832
10870
  _inferDocChatProject,
10833
10871
  _linkPullRequestForTracking: linkPullRequestForTracking,
10872
+ _updatePullRequestObserveFlag: updatePullRequestObserveFlag,
10834
10873
  _resolveSkillReadPath,
10835
10874
  // Per-CC-turn correlation surface
10836
10875
  _ccTurnCreations,
@@ -614,8 +614,9 @@ When implementing `engine/runtimes/copilot.js`:
614
614
  should still parse cleanly — let the consumer decide to ignore.
615
615
  9. `parseError(rawOutput)` patterns:
616
616
  - `auth-failure`: `/not authenticated|copilot login|401|403/i`
617
+ - `model-unavailable` (retriable=false): `/unknown model|model not found|invalid model|model_not_found|400.*model/i` — message includes the cached model catalog (`_warmModelCache` populates `_modelDiscoveryResults` from `listModels()` ahead of time so the error path stays sync). Falls back to "Configure a valid model in Settings → Engine." when the cache is empty.
618
+ - `model-unavailable` (retriable=true): `/overloaded_error|service_unavailable|503|temporarily unavailable/i` — engine retries with `engine.copilotFallbackModel`.
617
619
  - `rate-limit`: `/rate limit|too many requests|429/i`
618
- - `unknown-model`: `/unknown model|model not found|model.*invalid/i`
619
620
  - `crash`: `/internal error|panic|uncaught/i`
620
621
  10. `listModels()` per §6 — return `null` on any failure (network, parse, auth).
621
622
  `modelsCache` path: `engine/copilot-models.json`.
@@ -49,7 +49,7 @@ methods that genuinely differ.
49
49
  | `modelLooksFamiliar(model)` | boolean | Heuristic powering the preflight "stale model after CLI switch" warning. |
50
50
  | `parseOutput(raw)` | `{ text, usage, sessionId, model }` | Final-event parser. |
51
51
  | `parseStreamChunk(line)` | event object or null | Single JSONL line → typed event. |
52
- | `parseError(rawOutput)` | `{ message, code, retriable }` | Codes: `auth-failure`, `context-limit`, `budget-exceeded`, `crash`, null. |
52
+ | `parseError(rawOutput)` | `{ message, code, retriable }` | Codes: `auth-failure`, `context-limit`, `budget-exceeded`, `model-unavailable` (retriable=true for upstream overload/503; retriable=false for invalid/typo'd model id — Copilot enriches the message via `_warmModelCache()` so it lists the available models), `crash`, null. |
53
53
  | `createStreamConsumer(ctx)` | consumer object | Stream accumulator used by `engine/llm.js`. |
54
54
  | `detectPermissionGate`, `getPromptDeliveryMode`, `usesSystemPromptFile`, `classifyFailure` | misc | Adapter-owned policy that engine code reads through accessors instead of branching on `runtime.name`. |
55
55
 
package/engine/cleanup.js CHANGED
@@ -1075,7 +1075,30 @@ async function runCleanup(config, verbose = false) {
1075
1075
  mutateWorkItems(wiPath, items => {
1076
1076
  for (const item of items) if (_migrateLegacyItem(item)) migrated++;
1077
1077
  });
1078
- if (migrated > 0) log('info', `Migrated ${migrated} legacy status(es) in ${label}`);
1078
+ if (migrated > 0) {
1079
+ log('info', `Migrated ${migrated} legacy status(es) in ${label}`);
1080
+ // P-dcc27d-legacy-status-counter — make the docs/deprecated.json
1081
+ // gate ("legacy-done-aliases" removal after 30 consecutive days zero
1082
+ // migrations) measurable by bumping a rolling-daily counter under
1083
+ // `_engine.legacyStatusMigrations[YYYY-MM-DD]`. Parallel to
1084
+ // `_engine.completionFallbacks` at engine/lifecycle.js:3870-3871.
1085
+ // Inline mutateJsonFileLocked by design — promote to a shared
1086
+ // `mutateMetrics(key, date)` helper if more N-day-silence gates land.
1087
+ try {
1088
+ const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
1089
+ const dateKey = new Date().toISOString().slice(0, 10);
1090
+ mutateJsonFileLocked(metricsPath, (metrics) => {
1091
+ metrics = metrics || {};
1092
+ if (!metrics._engine) metrics._engine = {};
1093
+ if (!metrics._engine.legacyStatusMigrations) metrics._engine.legacyStatusMigrations = {};
1094
+ metrics._engine.legacyStatusMigrations[dateKey] =
1095
+ (metrics._engine.legacyStatusMigrations[dateKey] || 0) + migrated;
1096
+ return metrics;
1097
+ });
1098
+ } catch (err) {
1099
+ log('warn', `telemetry: legacy-status-migration metrics write failed: ${err.message}`);
1100
+ }
1101
+ }
1079
1102
  } catch (e) { log('warn', `migrate legacy statuses (${label}): ${e.message}`); }
1080
1103
  }
1081
1104
  for (const project of projects) _migrateLegacyItemsAt(projectWorkItemsPath(project), `${project.name} work items`);
package/engine/queries.js CHANGED
@@ -1930,14 +1930,25 @@ function getProjectGitStatus(localPath, configuredMainBranch = null) {
1930
1930
  // the pre-pull ahead/behind counts for up to 15s + one SPA poll (~19s
1931
1931
  // user-visible lag) because the rebuilt fast-state still hits this
1932
1932
  // cache and never schedules a refresh until the TTL itself expires.
1933
- if (cached && cached.ts && (now - cached.ts) < PROJECT_GIT_STATUS_TTL
1933
+ // Revalidate a cached MISSING value via a cheap existsSync. The snapshot-
1934
+ // based freshness check below can't detect "directory came back" because
1935
+ // there was no `.git` to snapshot when we wrote MISSING — without this
1936
+ // gate the cache pins MISSING for the full 15s TTL after the path is
1937
+ // recreated.
1938
+ const cachedIsMissing = cached && cached.value === PROJECT_GIT_STATUS_MISSING;
1939
+ if (cachedIsMissing && fs.existsSync(localPath)) {
1940
+ // Path came back — fall through to schedule a fresh probe.
1941
+ } else if (cached && cached.ts && (now - cached.ts) < PROJECT_GIT_STATUS_TTL
1934
1942
  && !_projectGitRefsAdvancedSince(localPath, configuredMainBranch, cached.refMtimes)) {
1935
1943
  return cached.value;
1936
1944
  }
1937
1945
  // Cheap synchronous existsSync — short-circuits a path that just disappeared
1938
- // (project removed) without scheduling a useless git probe.
1946
+ // (project removed) without scheduling a useless git probe. `refMtimes: null`
1947
+ // keeps the entry shape uniform with entries produced by
1948
+ // `_scheduleProjectGitStatusRefresh` so the freshness check above always
1949
+ // sees a defined field.
1939
1950
  if (!fs.existsSync(localPath)) {
1940
- _projectGitStatusCache.set(key, { ts: now, value: PROJECT_GIT_STATUS_MISSING, promise: null });
1951
+ _projectGitStatusCache.set(key, { ts: now, value: PROJECT_GIT_STATUS_MISSING, promise: null, refMtimes: null });
1941
1952
  return PROJECT_GIT_STATUS_MISSING;
1942
1953
  }
1943
1954
  // Stale or never-populated — kick off a background refresh and return the
@@ -522,6 +522,27 @@ function parseStreamChunk(line) {
522
522
 
523
523
  // ── Error Normalization ──────────────────────────────────────────────────────
524
524
 
525
+ // Pull a model id out of an Anthropic-style invalid-model error string. Claude
526
+ // CLI surfaces these as either plain stderr ("Unknown model: claude-x") or as
527
+ // the API's structured error body ({"type":"error","error":{"message":
528
+ // "model: claude-x: not a valid model"}}). Returns null when no obvious model
529
+ // token is present — parseError falls back to "unknown" in that case.
530
+ function _extractInvalidModelName(text) {
531
+ if (!text) return null;
532
+ const patterns = [
533
+ /(?:unknown|invalid)\s+model(?:\s+id)?\s*[:=]?\s*['"`]?([A-Za-z0-9._\/-]+)['"`]?/i,
534
+ /model\s+['"`]([^'"`]+)['"`]\s+(?:not\s+found|is\s+invalid|is\s+unknown|invalid)/i,
535
+ /model\s*[:=]\s*['"`]?([A-Za-z0-9._\/-]+)['"`]?\s*[:,]\s*(?:not\s+a\s+valid\s+model|not\s+found|invalid)/i,
536
+ /model\s+(?:not\s+found|invalid|unknown)\s*[:=]?\s*['"`]?([A-Za-z0-9._\/-]+)['"`]?/i,
537
+ /"model"\s*:\s*"([^"]+)"/i,
538
+ ];
539
+ for (const re of patterns) {
540
+ const m = text.match(re);
541
+ if (m && m[1]) return m[1];
542
+ }
543
+ return null;
544
+ }
545
+
525
546
  /**
526
547
  * Inspect raw agent output (stdout/stderr concatenated by the caller) and map
527
548
  * common Claude error patterns onto a normalized shape:
@@ -531,6 +552,11 @@ function parseStreamChunk(line) {
531
552
  * - 'auth-failure' — invalid API key / credit-card / org-blocked
532
553
  * - 'context-limit' — context window exhausted
533
554
  * - 'budget-exceeded' — `--max-budget-usd` ceiling hit
555
+ * - 'model-unavailable' — model not available. Two flavors distinguished by
556
+ * `retriable`: retriable=true for transient upstream
557
+ * overload (503/overloaded_error — engine retries
558
+ * with `--fallback-model`); retriable=false for an
559
+ * invalid/typo'd model id (user must fix config).
534
560
  * - 'crash' — CLI crashed (segfault, panic, "Internal error")
535
561
  * - null — no recognised pattern
536
562
  *
@@ -553,6 +579,20 @@ function parseError(rawOutput) {
553
579
  if (/budget.*exceed|max.budget.usd.*reach|cost.*limit.*exceed/i.test(lower)) {
554
580
  return { message: 'Claude budget cap exceeded — check your Claude account spending limit.', code: 'budget-exceeded', retriable: false };
555
581
  }
582
+ // W-mpmwxni2000c25c7-a — Configured model isn't a valid Anthropic id (typo,
583
+ // deprecated id, invalid_request_error on `model:` field). Claude has no
584
+ // listModels() catalog (capabilities.modelDiscovery === false), so the
585
+ // message points the operator at Settings instead of enumerating ids.
586
+ // Match BEFORE the overload branch — the overload regex matches
587
+ // `model.*unavailable` and would otherwise swallow "model X is invalid".
588
+ if (/unknown\s+model|model\s+not\s+found|invalid\s+model|model_not_found|not\s+a\s+valid\s+model|400[^a-z]+(?:bad\s+request|invalid|model)/i.test(text)) {
589
+ const name = _extractInvalidModelName(text) || 'unknown';
590
+ return {
591
+ message: `Model "${name}" not available for runtime claude. Configure a valid model in Settings → Engine.`,
592
+ code: 'model-unavailable',
593
+ retriable: false,
594
+ };
595
+ }
556
596
  // W-mpg6isvy000xca4d — Anthropic overload / 503 / service-unavailable. Claude's
557
597
  // own `--fallback-model` only fires on 429 (rate-limit); these failure modes
558
598
  // hang the agent until the 5h timeout. Classify as MODEL_UNAVAILABLE so the
@@ -828,5 +868,6 @@ module.exports = {
828
868
  permissionBypassFlags: PERMISSION_BYPASS_FLAGS,
829
869
  // Exposed for unit tests — never imported by engine code
830
870
  _CLAUDE_SHORTHANDS,
871
+ _extractInvalidModelName,
831
872
  THINKING_BLOCK_TYPES,
832
873
  };
@@ -785,6 +785,58 @@ function parseStreamChunk(line) {
785
785
 
786
786
  // ── Error Normalization ─────────────────────────────────────────────────────
787
787
 
788
+ // In-memory model-discovery cache used by parseError's invalid-model branch
789
+ // (W-mpmwxni2000c25c7-a). parseError is synchronous — to enrich the error
790
+ // message with the live model catalog without adding a per-error HTTP round
791
+ // trip, the engine pre-warms this cache via `_warmModelCache()` (e.g. during
792
+ // preflight or the first listModels() call from the dashboard). Concurrent
793
+ // callers share the in-flight promise so the API is only hit once even when
794
+ // multiple agents fail simultaneously. `_modelDiscoveryResults` stores the
795
+ // resolved catalog (or `[]` on failure); parseError reads it synchronously.
796
+ let _modelDiscoveryResults = null;
797
+ let _modelDiscoveryPromise = null;
798
+
799
+ async function _warmModelCache(opts = {}) {
800
+ if (_modelDiscoveryPromise) return _modelDiscoveryPromise;
801
+ if (Array.isArray(_modelDiscoveryResults)) return _modelDiscoveryResults;
802
+ _modelDiscoveryPromise = listModels(opts).then(
803
+ (models) => { _modelDiscoveryResults = Array.isArray(models) ? models : []; return _modelDiscoveryResults; },
804
+ () => { _modelDiscoveryResults = []; return _modelDiscoveryResults; },
805
+ );
806
+ try {
807
+ return await _modelDiscoveryPromise;
808
+ } finally {
809
+ _modelDiscoveryPromise = null;
810
+ }
811
+ }
812
+
813
+ function _resetModelCache({ models = null } = {}) {
814
+ _modelDiscoveryResults = Array.isArray(models) ? models : (models === null ? null : []);
815
+ _modelDiscoveryPromise = null;
816
+ }
817
+
818
+ // Pull a model id out of a Copilot/Anthropic-style invalid-model error string.
819
+ // Handles the shapes observed in the wild:
820
+ // "Unknown model: banana"
821
+ // "model not found: gpt-5.4"
822
+ // "model 'gpt-x' is invalid"
823
+ // "Invalid model id: claude-sonnet"
824
+ // {"error":"model_not_found","model":"gpt-x"} / {"model":"gpt-x"}
825
+ function _extractInvalidModelName(text) {
826
+ if (!text) return null;
827
+ const patterns = [
828
+ /(?:unknown|invalid)\s+model(?:\s+id)?\s*[:=]?\s*['"`]?([A-Za-z0-9._\/-]+)['"`]?/i,
829
+ /model\s+['"`]([^'"`]+)['"`]\s+(?:not\s+found|is\s+invalid|is\s+unknown|invalid)/i,
830
+ /model\s+(?:not\s+found|invalid|unknown)\s*[:=]?\s*['"`]?([A-Za-z0-9._\/-]+)['"`]?/i,
831
+ /"model"\s*:\s*"([^"]+)"/i,
832
+ ];
833
+ for (const re of patterns) {
834
+ const m = text.match(re);
835
+ if (m && m[1]) return m[1];
836
+ }
837
+ return null;
838
+ }
839
+
788
840
  function _collectErrorSignal(rawOutput) {
789
841
  const text = rawOutput == null ? '' : String(rawOutput);
790
842
  if (!text) return '';
@@ -835,6 +887,27 @@ function _collectErrorSignal(rawOutput) {
835
887
  return sawJsonLine ? '' : text;
836
888
  }
837
889
 
890
+ function _readModelIdsFromDiskCache() {
891
+ try {
892
+ const text = fs.readFileSync(MODELS_CACHE, 'utf8');
893
+ const obj = JSON.parse(text);
894
+ if (!obj || !Array.isArray(obj.models)) return null;
895
+ const ids = obj.models.map(m => m && m.id ? String(m.id) : null).filter(Boolean);
896
+ return ids.length > 0 ? ids : null;
897
+ } catch { return null; }
898
+ }
899
+
900
+ function _resolveCachedModelIds() {
901
+ // In-memory cache (seeded by `_warmModelCache()` or test `_resetModelCache`)
902
+ // wins over disk so unit tests stay hermetic. Production code that hasn't
903
+ // warmed the in-memory cache still gets the catalog via the disk file that
904
+ // `engine/model-discovery.js#getRuntimeModels` populates during preflight.
905
+ if (Array.isArray(_modelDiscoveryResults) && _modelDiscoveryResults.length > 0) {
906
+ return _modelDiscoveryResults.map(m => m && m.id ? String(m.id) : null).filter(Boolean);
907
+ }
908
+ return _readModelIdsFromDiskCache();
909
+ }
910
+
838
911
  function parseError(rawOutput) {
839
912
  const text = _collectErrorSignal(rawOutput);
840
913
  if (!text) return { message: '', code: null, retriable: true };
@@ -845,6 +918,24 @@ function parseError(rawOutput) {
845
918
  if (hasExplicitAuthFailure || hasAuthStatusCode) {
846
919
  return { message: text, code: 'auth-failure', retriable: false };
847
920
  }
921
+ // W-mpmwxni2000c25c7-a — Configured model isn't in the Copilot catalog
922
+ // (typo, deprecated id, 400-style invalid-model response). Classify as
923
+ // MODEL_UNAVAILABLE with retriable: false so the engine surfaces an
924
+ // actionable error instead of looping the dispatch. Must match BEFORE the
925
+ // overload branch — the overload regex catches `model.*unavailable`,
926
+ // which would otherwise swallow legitimate "model not found" responses.
927
+ if (/unknown\s+model|model\s+not\s+found|invalid\s+model|model_not_found|400[^a-z]+(?:bad\s+request|invalid|model)|model.*\b400\b/i.test(text)) {
928
+ const name = _extractInvalidModelName(text) || 'unknown';
929
+ const ids = _resolveCachedModelIds();
930
+ const tail = (ids && ids.length > 0)
931
+ ? `Available models: ${ids.join(', ')}`
932
+ : 'Configure a valid model in Settings → Engine.';
933
+ return {
934
+ message: `Model "${name}" not available for runtime copilot. ${tail}`,
935
+ code: 'model-unavailable',
936
+ retriable: false,
937
+ };
938
+ }
848
939
  // W-mpg6isvy000xca4d — Copilot has no --fallback-model flag; classify
849
940
  // overloaded / 503 / service_unavailable as MODEL_UNAVAILABLE so the engine
850
941
  // retry can OVERRIDE --model with engine.copilotFallbackModel. Match before
@@ -856,9 +947,6 @@ function parseError(rawOutput) {
856
947
  if (/rate limit|too many requests|\b429\b/i.test(text)) {
857
948
  return { message: text, code: 'rate-limit', retriable: true };
858
949
  }
859
- if (/unknown model|model not found|model.*invalid|invalid model/i.test(text)) {
860
- return { message: text, code: 'unknown-model', retriable: false };
861
- }
862
950
  if (/budget.*exceed|premium.*limit.*reach|quota.*exceed/i.test(lower)) {
863
951
  return { message: text, code: 'budget-exceeded', retriable: false };
864
952
  }
@@ -1198,6 +1286,12 @@ module.exports = {
1198
1286
  _pickStandaloneCopilotFromOutput,
1199
1287
  _resolveNpmCopilotJsEntry,
1200
1288
  _isCachedBinUsable,
1289
+ // W-mpmwxni2000c25c7-a — invalid-model error-path helpers. `_warmModelCache`
1290
+ // populates the in-memory model catalog so parseError can enrich its
1291
+ // "Model X not available" message without a per-error HTTP round trip.
1292
+ _warmModelCache,
1293
+ _resetModelCache,
1294
+ _extractInvalidModelName,
1201
1295
  CAPS_SCHEMA_VERSION,
1202
1296
  KNOWN_EVENT_TYPES,
1203
1297
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2046",
3
+ "version": "0.1.2048",
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"