@yemi33/minions 0.1.1896 → 0.1.1898

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.
@@ -85,18 +85,18 @@ function renderMcpServers(servers) {
85
85
  const countEl = document.getElementById('mcp-count');
86
86
  countEl.textContent = servers.length;
87
87
  if (!servers.length) {
88
- el.innerHTML = '<p class="empty">No MCP servers found. Add them to <code>~/.claude.json</code> or <code>~/.copilot/mcp-config.json</code> and they\'ll appear here automatically.</p>';
88
+ el.innerHTML = '<p class="empty">No MCP servers found. Add them via <code>claude mcp add</code> / <code>copilot mcp add</code>, install a plugin that ships one, or drop a <code>.mcp.json</code> into a registered project — they\'ll appear here automatically.</p>';
89
89
  return;
90
90
  }
91
91
  el.innerHTML = '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">' +
92
92
  servers.map(s =>
93
- '<div style="font-size:11px;padding:5px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text)" title="' + escHtml([s.source, s.args || s.command].filter(Boolean).join(': ')) + '">' +
93
+ '<div style="font-size:11px;padding:5px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text)" title="' + escHtml([s.source, s.status, s.args || s.command].filter(Boolean).join(' · ')) + '">' +
94
94
  escHtml(s.name) +
95
95
  (s.source ? ' <span style="color:var(--muted)">(' + escHtml(s.source) + ')</span>' : '') +
96
96
  '</div>'
97
97
  ).join('') +
98
98
  '</div>' +
99
- '<p style="font-size:10px;color:var(--muted);margin:0">Synced from <code style="color:var(--blue)">~/.claude.json</code> and <code style="color:var(--blue)">~/.copilot/mcp-config.json</code>.</p>';
99
+ '<p style="font-size:10px;color:var(--muted);margin:0">Mirrors <code style="color:var(--blue)">claude mcp list</code> + <code style="color:var(--blue)">copilot mcp list --json</code>, plus workspace <code>.mcp.json</code> from each registered project. Cached ~5 min.</p>';
100
100
  }
101
101
 
102
102
  function renderMetrics(metrics) {
@@ -412,13 +412,16 @@ async function planExecute(file, project, btn) {
412
412
  const data = await res.json();
413
413
  if (res.ok) {
414
414
  // Inject into local work items so the next render immediately hides Execute
415
- if (!data.alreadyQueued) {
415
+ if (!data.alreadyQueued && data.id) {
416
416
  (window._lastWorkItems = window._lastWorkItems || []).push({
417
417
  id: data.id, type: 'plan-to-prd', status: 'pending', planFile: file
418
418
  });
419
419
  }
420
420
  closeModal();
421
- showToast('cmd-toast', data.alreadyQueued ? 'Already queued (' + data.id + ')' : 'Queued ' + data.id + ' — agent will convert plan to PRD', true);
421
+ // Fall back to a clean message if the server didn't return an id (#2375).
422
+ const idLabel = data.id ? ' (' + data.id + ')' : '';
423
+ const queuedLabel = data.id ? 'Queued ' + data.id + ' — agent will convert plan to PRD' : 'Plan queued — agent will convert plan to PRD';
424
+ showToast('cmd-toast', data.alreadyQueued ? 'Already queued' + idLabel : queuedLabel, true);
422
425
  wakeEngine();
423
426
  refreshPlans();
424
427
  } else {
package/dashboard.js CHANGED
@@ -1198,27 +1198,146 @@ function getDiskVersion() {
1198
1198
  return _diskVersionCache;
1199
1199
  }
1200
1200
 
1201
- function readMcpServersFromConfig(configPath, source) {
1202
- const data = safeJsonObj(configPath);
1203
- const servers = data.mcpServers || {};
1204
- return Object.entries(servers).map(([name, cfg]) => ({
1205
- name,
1206
- source,
1207
- command: cfg.command || cfg.url || '',
1208
- args: (cfg.args || []).slice(-1)[0] || '',
1209
- }));
1201
+ // ── MCP server discovery ─────────────────────────────────────────────────────
1202
+ // Mirrors what each CLI actually loads at runtime:
1203
+ // • `claude mcp list` → user + plugin (+ workspace if cwd in project)
1204
+ // • `copilot mcp list --json` → user + workspace + plugin + builtin (merged)
1205
+ // • Each registered project's `<localPath>/.mcp.json` → workspace scope
1206
+ //
1207
+ // CLI calls are slow (claude health-checks every server on stdio), so we cache
1208
+ // for ~5 minutes and refresh in the background. getMcpServers() always returns
1209
+ // synchronously from cache; first call before background fill returns [].
1210
+
1211
+ const _MCP_CACHE_TTL = 5 * 60 * 1000;
1212
+ const _MCP_CLAUDE_TIMEOUT = 30000;
1213
+ const _MCP_COPILOT_TIMEOUT = 15000;
1214
+ let _mcpServersCache = null;
1215
+ let _mcpServersCacheTs = 0;
1216
+ let _mcpRefreshInflight = null;
1217
+
1218
+ function _parseClaudeMcpListLine(line) {
1219
+ const trimmed = (line || '').trim();
1220
+ if (!trimmed) return null;
1221
+ if (/^Checking\b/i.test(trimmed)) return null;
1222
+ if (/^No MCP servers/i.test(trimmed)) return null;
1223
+ const nameSep = trimmed.indexOf(': ');
1224
+ if (nameSep < 0) return null;
1225
+ const rawName = trimmed.slice(0, nameSep);
1226
+ let rest = trimmed.slice(nameSep + 2);
1227
+ let status = '';
1228
+ const statusSep = rest.lastIndexOf(' - ');
1229
+ if (statusSep >= 0) {
1230
+ status = rest.slice(statusSep + 3).trim();
1231
+ rest = rest.slice(0, statusSep).trim();
1232
+ }
1233
+ let source = 'Claude user';
1234
+ let displayName = rawName;
1235
+ if (rawName.startsWith('plugin:')) {
1236
+ const parts = rawName.split(':');
1237
+ source = parts.length >= 3 ? `Claude plugin: ${parts[1]}` : 'Claude plugin';
1238
+ displayName = parts.slice(2).join(':') || parts[1];
1239
+ } else if (rawName.startsWith('project:')) {
1240
+ const parts = rawName.split(':');
1241
+ source = parts.length >= 3 ? `Claude workspace: ${parts[1]}` : 'Claude workspace';
1242
+ displayName = parts.slice(2).join(':') || parts[1];
1243
+ }
1244
+ return { name: displayName, source, command: rest, args: '', status };
1245
+ }
1246
+
1247
+ function _parseCopilotMcpListJson(raw) {
1248
+ try {
1249
+ const data = JSON.parse(raw);
1250
+ const servers = data?.mcpServers || {};
1251
+ return Object.entries(servers).map(([name, cfg]) => ({
1252
+ name,
1253
+ source: 'Copilot',
1254
+ command: cfg.command || cfg.url || '',
1255
+ args: Array.isArray(cfg.args) ? cfg.args.join(' ') : (cfg.args || ''),
1256
+ }));
1257
+ } catch { return []; }
1258
+ }
1259
+
1260
+ function _runCliAsync(cmd, args, timeoutMs) {
1261
+ return new Promise(resolve => {
1262
+ try {
1263
+ const { exec } = require('child_process');
1264
+ // exec() runs through the shell so Windows resolves .cmd/.exe shims off
1265
+ // PATH. Args are static so there's no injection surface.
1266
+ const line = [cmd, ...args].join(' ');
1267
+ exec(line, {
1268
+ timeout: timeoutMs, windowsHide: true, encoding: 'utf8',
1269
+ maxBuffer: 1024 * 1024,
1270
+ }, (err, stdout) => resolve(err ? null : stdout));
1271
+ } catch { resolve(null); }
1272
+ });
1273
+ }
1274
+
1275
+ function _readWorkspaceMcpServers(projects) {
1276
+ const out = [];
1277
+ for (const p of (projects || [])) {
1278
+ if (!p?.localPath) continue;
1279
+ const data = safeJsonObj(path.join(p.localPath, '.mcp.json'));
1280
+ const servers = data.mcpServers || {};
1281
+ for (const [name, cfg] of Object.entries(servers)) {
1282
+ out.push({
1283
+ name,
1284
+ source: `Workspace: ${p.name}`,
1285
+ command: cfg.command || cfg.url || '',
1286
+ args: Array.isArray(cfg.args) ? cfg.args.join(' ') : (cfg.args || ''),
1287
+ });
1288
+ }
1289
+ }
1290
+ return out;
1291
+ }
1292
+
1293
+ function _dedupeMcpServers(entries) {
1294
+ const seen = new Set();
1295
+ const out = [];
1296
+ for (const e of entries) {
1297
+ const key = `${e.source}::${e.name}`;
1298
+ if (seen.has(key)) continue;
1299
+ seen.add(key);
1300
+ out.push(e);
1301
+ }
1302
+ return out;
1303
+ }
1304
+
1305
+ async function _refreshMcpServersCache() {
1306
+ if (_mcpRefreshInflight) return _mcpRefreshInflight;
1307
+ _mcpRefreshInflight = (async () => {
1308
+ try {
1309
+ const [claudeOut, copilotOut] = await Promise.all([
1310
+ _runCliAsync('claude', ['mcp', 'list'], _MCP_CLAUDE_TIMEOUT),
1311
+ _runCliAsync('copilot', ['mcp', 'list', '--json'], _MCP_COPILOT_TIMEOUT),
1312
+ ]);
1313
+ const claudeEntries = (claudeOut || '').split(/\r?\n/).map(_parseClaudeMcpListLine).filter(Boolean);
1314
+ const copilotEntries = _parseCopilotMcpListJson(copilotOut || '{}');
1315
+ const workspaceEntries = _readWorkspaceMcpServers(PROJECTS);
1316
+ _mcpServersCache = _dedupeMcpServers([...claudeEntries, ...copilotEntries, ...workspaceEntries]);
1317
+ _mcpServersCacheTs = Date.now();
1318
+ } catch (e) {
1319
+ console.error('[mcp-discovery] refresh failed:', e.message?.split('\n')?.[0]);
1320
+ if (!_mcpServersCache) _mcpServersCache = [];
1321
+ _mcpServersCacheTs = Date.now();
1322
+ } finally {
1323
+ _mcpRefreshInflight = null;
1324
+ }
1325
+ })();
1326
+ return _mcpRefreshInflight;
1210
1327
  }
1211
1328
 
1212
1329
  function getMcpServers() {
1213
- try {
1214
- const home = os.homedir();
1215
- return [
1216
- ...readMcpServersFromConfig(path.join(home, '.claude.json'), 'Claude'),
1217
- ...readMcpServersFromConfig(path.join(home, '.copilot', 'mcp-config.json'), 'Copilot'),
1218
- ];
1219
- } catch { return []; }
1330
+ const now = Date.now();
1331
+ if (!_mcpServersCache || (now - _mcpServersCacheTs) >= _MCP_CACHE_TTL) {
1332
+ _refreshMcpServersCache().catch(() => {});
1333
+ }
1334
+ return _mcpServersCache || [];
1220
1335
  }
1221
1336
 
1337
+ // Kick off first discovery on startup so the dashboard has data before the
1338
+ // first slow-state rebuild.
1339
+ _refreshMcpServersCache().catch(() => {});
1340
+
1222
1341
  function parsePinnedEntries(content) {
1223
1342
  if (!content) return [];
1224
1343
  const entries = [];
@@ -4574,7 +4693,7 @@ const server = http.createServer(async (req, res) => {
4574
4693
  const projectTarget = resolveProjectSourceTarget(projectName, PROJECTS, { allowCentral: false });
4575
4694
  const targetProject = projectTarget.project || (PROJECTS.length === 1 ? PROJECTS[0] : null);
4576
4695
  if (targetProject) {
4577
- diffAwareQueued = shared.queuePlanToPrd({
4696
+ const diffAwareResult = shared.queuePlanToPrd({
4578
4697
  planFile: plan.source_plan, prdFile: body.file,
4579
4698
  title: `Update PRD from revised plan: ${plan.source_plan}`,
4580
4699
  description: `mode: diff-aware-update\nPlan file: plans/${plan.source_plan}\nPRD file: prd/${body.file}\n\n` +
@@ -4584,6 +4703,7 @@ const server = http.createServer(async (req, res) => {
4584
4703
  project: targetProject.name, createdBy: 'dashboard:plan-resume',
4585
4704
  extra: { _existingPrdFile: body.file },
4586
4705
  });
4706
+ diffAwareQueued = diffAwareResult.queued;
4587
4707
  }
4588
4708
  }
4589
4709
 
@@ -4731,15 +4851,15 @@ const server = http.createServer(async (req, res) => {
4731
4851
  '\n\nA prior PRD already exists for this plan. Read it, apply this feedback, and overwrite the PRD with the corrected version.';
4732
4852
  }
4733
4853
 
4734
- const queued = shared.queuePlanToPrd({
4854
+ const queueResult = shared.queuePlanToPrd({
4735
4855
  planFile: body.file,
4736
4856
  title: 'Convert plan to PRD: ' + body.file.replace('.md', ''),
4737
4857
  description,
4738
4858
  project: declaredProject || body.project || '', createdBy: 'dashboard:execute',
4739
4859
  });
4740
- if (!queued) return jsonReply(res, 200, { ok: true, alreadyQueued: true });
4860
+ if (!queueResult.queued) return jsonReply(res, 200, { ok: true, alreadyQueued: true, id: queueResult.id });
4741
4861
  invalidateStatusCache();
4742
- return jsonReply(res, 200, { ok: true });
4862
+ return jsonReply(res, 200, { ok: true, id: queueResult.id });
4743
4863
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
4744
4864
  }
4745
4865
 
@@ -7782,6 +7902,10 @@ function _installCrashHandlers() {
7782
7902
  // Production entry points use the closures directly; tests import via require('./dashboard').
7783
7903
  module.exports = {
7784
7904
  getMcpServers,
7905
+ _parseClaudeMcpListLine,
7906
+ _parseCopilotMcpListJson,
7907
+ _readWorkspaceMcpServers,
7908
+ _dedupeMcpServers,
7785
7909
  readBody,
7786
7910
  _filterCcTabSessions,
7787
7911
  _getVersionCheckInterval,
package/engine/shared.js CHANGED
@@ -1628,17 +1628,30 @@ function trackReviewMetric(pr, newReviewStatus, config) {
1628
1628
  } catch (err) { log('warn', `Metrics update: ${err.message}`); }
1629
1629
  }
1630
1630
 
1631
- /** Queue a plan-to-prd work item with dedup check inside lock. Returns true if queued. */
1631
+ /**
1632
+ * Queue a plan-to-prd work item with dedup check inside lock.
1633
+ *
1634
+ * Returns { queued, alreadyQueued, id, item } so callers (e.g. POST /api/plans/execute)
1635
+ * can surface the new or existing work item id back to the dashboard. Returning
1636
+ * just a boolean caused the toast to render "queued undefined" (issue #2375).
1637
+ */
1632
1638
  function queuePlanToPrd({ planFile, prdFile, title, description, project, createdBy, extra }) {
1633
1639
  // Use MINIONS_DIR (honors MINIONS_TEST_DIR override) instead of resolving from
1634
1640
  // __dirname — otherwise tests that exercise this helper leak work items into
1635
1641
  // the real package-root work-items.json even after createTestMinionsDir().
1636
1642
  const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
1637
1643
  let queued = false;
1644
+ let id = null;
1645
+ let item = null;
1638
1646
  mutateJsonFileLocked(centralWiPath, items => {
1639
1647
  if (!Array.isArray(items)) items = [];
1640
- if (items.some(w => w.type === 'plan-to-prd' && w.planFile === planFile && (w.status === 'pending' || w.status === 'dispatched'))) return items;
1641
- items.push({
1648
+ const existing = items.find(w => w.type === 'plan-to-prd' && w.planFile === planFile && (w.status === 'pending' || w.status === 'dispatched'));
1649
+ if (existing) {
1650
+ id = existing.id;
1651
+ item = existing;
1652
+ return items;
1653
+ }
1654
+ const newItem = {
1642
1655
  id: 'W-' + uid(),
1643
1656
  title,
1644
1657
  type: 'plan-to-prd',
@@ -1650,11 +1663,14 @@ function queuePlanToPrd({ planFile, prdFile, title, description, project, create
1650
1663
  project,
1651
1664
  planFile,
1652
1665
  ...(extra || {}),
1653
- });
1666
+ };
1667
+ items.push(newItem);
1654
1668
  queued = true;
1669
+ id = newItem.id;
1670
+ item = newItem;
1655
1671
  return items;
1656
1672
  }, { defaultValue: [] });
1657
- return queued;
1673
+ return { queued, alreadyQueued: !queued, id, item };
1658
1674
  }
1659
1675
 
1660
1676
  function extractPlanDeclaredProject(planContent) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1896",
3
+ "version": "0.1.1898",
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"