@yemi33/minions 0.1.1924 → 0.1.1926

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.
Files changed (3) hide show
  1. package/dashboard.js +64 -53
  2. package/engine.js +39 -0
  3. package/package.json +1 -1
package/dashboard.js CHANGED
@@ -1190,21 +1190,24 @@ function getDiskVersion() {
1190
1190
  }
1191
1191
 
1192
1192
  // ── MCP server discovery ─────────────────────────────────────────────────────
1193
- // Mirrors what each CLI actually loads at runtime:
1194
- // • `claude mcp list` user + plugin (+ workspace if cwd in project)
1195
- // • `copilot mcp list --json` → user + workspace + plugin + builtin (merged)
1193
+ // Reads MCP server registrations directly from each CLI's config file:
1194
+ // • `~/.claude.json` `mcpServers` (Claude user scope)
1195
+ // • `~/.copilot/mcp-config.json` → `mcpServers` (Copilot user scope)
1196
1196
  // • Each registered project's `<localPath>/.mcp.json` → workspace scope
1197
1197
  //
1198
- // CLI calls are slow (claude health-checks every server on stdio), so we cache
1199
- // for ~5 minutes and refresh in the background. getMcpServers() always returns
1200
- // synchronously from cache; first call before background fill returns [].
1198
+ // We used to shell out to `claude mcp list` + `copilot mcp list --json`, but
1199
+ // `claude mcp list` blocks on a TTY check when the dashboard daemon has no
1200
+ // parent console (bin/minions.js spawns it with windowsHide:true), so every
1201
+ // refresh timed out and the cache stayed empty. File reads are zero-spawn and
1202
+ // the config files are the authoritative source for registration.
1203
+ //
1204
+ // Cost: we lose live connection status (✓ Connected / ! Needs auth) and the
1205
+ // Claude plugin: <plugin>:<server> rows. The UI shows status only in a hover
1206
+ // tooltip, and plugin servers are rare; reliability matters more.
1201
1207
 
1202
1208
  const _MCP_CACHE_TTL = 5 * 60 * 1000;
1203
- const _MCP_CLAUDE_TIMEOUT = 30000;
1204
- const _MCP_COPILOT_TIMEOUT = 15000;
1205
1209
  let _mcpServersCache = null;
1206
1210
  let _mcpServersCacheTs = 0;
1207
- let _mcpRefreshInflight = null;
1208
1211
 
1209
1212
  function _parseClaudeMcpListLine(line) {
1210
1213
  const trimmed = (line || '').trim();
@@ -1248,19 +1251,43 @@ function _parseCopilotMcpListJson(raw) {
1248
1251
  } catch { return []; }
1249
1252
  }
1250
1253
 
1251
- function _runCliAsync(cmd, args, timeoutMs) {
1252
- return new Promise(resolve => {
1253
- try {
1254
- const { exec } = require('child_process');
1255
- // exec() runs through the shell so Windows resolves .cmd/.exe shims off
1256
- // PATH. Args are static so there's no injection surface.
1257
- const line = [cmd, ...args].join(' ');
1258
- exec(line, {
1259
- timeout: timeoutMs, windowsHide: true, encoding: 'utf8',
1260
- maxBuffer: 1024 * 1024,
1261
- }, (err, stdout) => resolve(err ? null : stdout));
1262
- } catch { resolve(null); }
1263
- });
1254
+ // Read Claude's user-scope MCP servers from `~/.claude.json` directly.
1255
+ // Shelling out to `claude mcp list` is unreliable from the dashboard daemon —
1256
+ // the CLI's MCP health probes block on a TTY check when the parent has no
1257
+ // console (bin/minions.js launches the dashboard with windowsHide:true), and
1258
+ // neither exec() nor spawn(..., {detached:true}) reliably sidesteps it.
1259
+ // Config-file reads are zero-spawn, instant, and the file is the authoritative
1260
+ // source for which servers are registered.
1261
+ function _readClaudeUserMcpServers() {
1262
+ try {
1263
+ const data = safeJsonObj(path.join(os.homedir(), '.claude.json'));
1264
+ const servers = data?.mcpServers || {};
1265
+ return Object.entries(servers).map(([name, cfg]) => ({
1266
+ name,
1267
+ source: 'Claude user',
1268
+ command: cfg.command || cfg.url || '',
1269
+ args: Array.isArray(cfg.args) ? cfg.args.join(' ') : (cfg.args || ''),
1270
+ }));
1271
+ } catch { return []; }
1272
+ }
1273
+
1274
+ // Copilot stores its MCP config at `~/.copilot/mcp-config.json` (may have a
1275
+ // UTF-8 BOM — Windows tooling tends to write one).
1276
+ function _readCopilotUserMcpServers() {
1277
+ try {
1278
+ const configPath = path.join(os.homedir(), '.copilot', 'mcp-config.json');
1279
+ if (!fs.existsSync(configPath)) return [];
1280
+ let raw = fs.readFileSync(configPath, 'utf8');
1281
+ if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
1282
+ const data = JSON.parse(raw);
1283
+ const servers = data?.mcpServers || {};
1284
+ return Object.entries(servers).map(([name, cfg]) => ({
1285
+ name,
1286
+ source: 'Copilot',
1287
+ command: cfg.command || cfg.url || '',
1288
+ args: Array.isArray(cfg.args) ? cfg.args.join(' ') : (cfg.args || ''),
1289
+ }));
1290
+ } catch { return []; }
1264
1291
  }
1265
1292
 
1266
1293
  function _readWorkspaceMcpServers(projects) {
@@ -1293,42 +1320,26 @@ function _dedupeMcpServers(entries) {
1293
1320
  return out;
1294
1321
  }
1295
1322
 
1296
- async function _refreshMcpServersCache() {
1297
- if (_mcpRefreshInflight) return _mcpRefreshInflight;
1298
- _mcpRefreshInflight = (async () => {
1299
- try {
1300
- const [claudeOut, copilotOut] = await Promise.all([
1301
- _runCliAsync('claude', ['mcp', 'list'], _MCP_CLAUDE_TIMEOUT),
1302
- _runCliAsync('copilot', ['mcp', 'list', '--json'], _MCP_COPILOT_TIMEOUT),
1303
- ]);
1304
- const claudeEntries = (claudeOut || '').split(/\r?\n/).map(_parseClaudeMcpListLine).filter(Boolean);
1305
- const copilotEntries = _parseCopilotMcpListJson(copilotOut || '{}');
1306
- const workspaceEntries = _readWorkspaceMcpServers(PROJECTS);
1307
- _mcpServersCache = _dedupeMcpServers([...claudeEntries, ...copilotEntries, ...workspaceEntries]);
1308
- _mcpServersCacheTs = Date.now();
1309
- } catch (e) {
1310
- console.error('[mcp-discovery] refresh failed:', e.message?.split('\n')?.[0]);
1311
- if (!_mcpServersCache) _mcpServersCache = [];
1312
- _mcpServersCacheTs = Date.now();
1313
- } finally {
1314
- _mcpRefreshInflight = null;
1315
- }
1316
- })();
1317
- return _mcpRefreshInflight;
1318
- }
1319
-
1320
1323
  function getMcpServers() {
1321
1324
  const now = Date.now();
1322
- if (!_mcpServersCache || (now - _mcpServersCacheTs) >= _MCP_CACHE_TTL) {
1323
- _refreshMcpServersCache().catch(() => {});
1325
+ if (_mcpServersCache && (now - _mcpServersCacheTs) < _MCP_CACHE_TTL) {
1326
+ return _mcpServersCache;
1327
+ }
1328
+ try {
1329
+ const entries = [
1330
+ ..._readClaudeUserMcpServers(),
1331
+ ..._readCopilotUserMcpServers(),
1332
+ ..._readWorkspaceMcpServers(PROJECTS),
1333
+ ];
1334
+ _mcpServersCache = _dedupeMcpServers(entries);
1335
+ } catch (e) {
1336
+ console.error('[mcp-discovery] refresh failed:', e.message?.split('\n')?.[0]);
1337
+ if (!_mcpServersCache) _mcpServersCache = [];
1324
1338
  }
1325
- return _mcpServersCache || [];
1339
+ _mcpServersCacheTs = now;
1340
+ return _mcpServersCache;
1326
1341
  }
1327
1342
 
1328
- // Kick off first discovery on startup so the dashboard has data before the
1329
- // first slow-state rebuild.
1330
- _refreshMcpServersCache().catch(() => {});
1331
-
1332
1343
  function parsePinnedEntries(content) {
1333
1344
  if (!content) return [];
1334
1345
  const entries = [];
package/engine.js CHANGED
@@ -735,6 +735,11 @@ async function spawnAgent(dispatchItem, config) {
735
735
  const claudeConfig = config.claude || {};
736
736
  const engineConfig = config.engine || {};
737
737
  const startedAt = ts();
738
+ // Phase timing — raw timestamps, emitted as one structured `[spawn-timing]`
739
+ // log line after spawn succeeds. Use Date.now() not ts() so deltas are plain
740
+ // milliseconds. Keys are stamped at phase boundaries below.
741
+ const _phaseT = { start: Date.now() };
742
+ let _depCountForLog = 0;
738
743
 
739
744
  updateAgentStatus(id, AGENT_STATUS.SPAWNING, `Preparing ${type} task for ${agentId}`);
740
745
 
@@ -802,6 +807,7 @@ async function spawnAgent(dispatchItem, config) {
802
807
  const sysPromptPath = path.join(tmpDir, `sysprompt-${safeId}.md`);
803
808
  safeWrite(sysPromptPath, systemPrompt);
804
809
  const _cleanupPromptFiles = () => { safeUnlink(promptPath); safeUnlink(sysPromptPath); };
810
+ _phaseT.afterPrompt = Date.now();
805
811
 
806
812
  if (branchName) {
807
813
  updateAgentStatus(id, AGENT_STATUS.WORKTREE_SETUP, `Setting up worktree for branch ${branchName}`);
@@ -990,7 +996,9 @@ async function spawnAgent(dispatchItem, config) {
990
996
  if (worktreePath && fs.existsSync(worktreePath)) {
991
997
  cwd = worktreePath;
992
998
  const depIds = meta?.item?.depends_on || [];
999
+ _depCountForLog = depIds.length;
993
1000
  if (depIds.length > 0) {
1001
+ _phaseT.depFetchStart = Date.now();
994
1002
  try {
995
1003
  const depBranches = resolveDependencyBranches(depIds, meta?.item?.sourcePlan, project, config);
996
1004
  let depMergeFailed = false;
@@ -1047,6 +1055,8 @@ async function spawnAgent(dispatchItem, config) {
1047
1055
  depMergeFailed = true;
1048
1056
  }
1049
1057
  }
1058
+ _phaseT.depFetchEnd = Date.now();
1059
+ _phaseT.depPreflightStart = _phaseT.depFetchEnd;
1050
1060
  // Merge successfully-fetched + recovered (local-only pushed) branches sequentially
1051
1061
  const fetched = fetchable.filter((_, i) => fetchResults[i].status === 'fulfilled' || recoveredBranches.has(fetchable[i].branch));
1052
1062
  // Ancestor pruning: remove dep branches already contained in another (#958)
@@ -1098,6 +1108,8 @@ async function spawnAgent(dispatchItem, config) {
1098
1108
  log('warn', `Pre-flight simulation failed, proceeding with real merge: ${e.message}`);
1099
1109
  }
1100
1110
  }
1111
+ _phaseT.depPreflightEnd = Date.now();
1112
+ _phaseT.depMergeStart = _phaseT.depPreflightEnd;
1101
1113
  // Stash uncommitted changes before dep merge if worktree is dirty (#973)
1102
1114
  let stashed = false;
1103
1115
  if (!depMergeFailed && !skipDepMerge && prunedDeps.length > 0) {
@@ -1182,6 +1194,7 @@ async function spawnAgent(dispatchItem, config) {
1182
1194
  log('warn', `git stash pop failed in ${branchName}: ${popErr.message} — stash preserved for agent`);
1183
1195
  }
1184
1196
  }
1197
+ _phaseT.depMergeEnd = Date.now();
1185
1198
  if (depMergeFailed) {
1186
1199
  _cleanupPromptFiles();
1187
1200
  // Build actionable failReason identifying the conflicting branch and files (#958)
@@ -1291,6 +1304,7 @@ async function spawnAgent(dispatchItem, config) {
1291
1304
  if (cwd === rootDir && ['implement', 'implement:large', 'fix', 'test', 'verify', 'plan-to-prd'].includes(type)) {
1292
1305
  log('warn', `Agent ${agentId} running ${type} task in main repo (no worktree) for ${id} — changes may land on master directly`);
1293
1306
  }
1307
+ _phaseT.afterWorktree = Date.now();
1294
1308
 
1295
1309
  // ── Stale-HEAD guard for fix-task pushes (P-c8f2d5e3) ────────────────────
1296
1310
  // When a PR branch is rebased upstream (force-push), a reused worktree can
@@ -1302,6 +1316,7 @@ async function spawnAgent(dispatchItem, config) {
1302
1316
  // Read-only and non-fix dispatches are out of scope — implement tasks cut
1303
1317
  // their own branch from main, and review/verify don't push.
1304
1318
  if (type === WORK_TYPE.FIX && branchName && worktreePath && cwd === worktreePath) {
1319
+ _phaseT.staleHeadStart = Date.now();
1305
1320
  try {
1306
1321
  const guard = await assertStaleHeadOk({
1307
1322
  branch: branchName,
@@ -1325,6 +1340,7 @@ async function spawnAgent(dispatchItem, config) {
1325
1340
  // to skipped:'fetch-failed'). Log and continue.
1326
1341
  log('warn', `Stale-HEAD guard error for ${id} (${branchName}): ${err.message}`);
1327
1342
  }
1343
+ _phaseT.staleHeadEnd = Date.now();
1328
1344
  }
1329
1345
 
1330
1346
  // ── Runtime + opts resolution (P-2a6d9c4f) ────────────────────────────────
@@ -1371,6 +1387,7 @@ async function spawnAgent(dispatchItem, config) {
1371
1387
 
1372
1388
  // MCP servers: agents inherit from ~/.claude.json directly as Claude Code processes.
1373
1389
  // No --mcp-config needed — avoids redundant config and ensures agents always have latest servers.
1390
+ _phaseT.afterRuntime = Date.now();
1374
1391
 
1375
1392
  log('info', `Spawning agent: ${agentId} (${id}) in ${cwd}`);
1376
1393
  log('info', `Task type: ${type} | Branch: ${branchName || 'none'}`);
@@ -1428,6 +1445,7 @@ async function spawnAgent(dispatchItem, config) {
1428
1445
  }
1429
1446
 
1430
1447
  let proc;
1448
+ _phaseT.spawnCallStart = Date.now();
1431
1449
  try {
1432
1450
  // `detached: true` puts the agent in its own process group (POSIX) / job
1433
1451
  // object (Windows), so when the engine dies — gracefully via stop, abruptly
@@ -1441,6 +1459,7 @@ async function spawnAgent(dispatchItem, config) {
1441
1459
  env: childEnv,
1442
1460
  detached: true,
1443
1461
  });
1462
+ _phaseT.spawnCallEnd = Date.now();
1444
1463
  } catch (spawnErr) {
1445
1464
  // Synchronous spawn failure — record it to the (already-stamped) log so the
1446
1465
  // orphan detector's "logSize > stub-only" check can tell this apart from a
@@ -1476,6 +1495,26 @@ async function spawnAgent(dispatchItem, config) {
1476
1495
  };
1477
1496
  activeProcesses.set(id, initialProcInfo);
1478
1497
 
1498
+ // Emit per-phase timing for spawn-latency analysis. One structured line per
1499
+ // dispatch; grep `[spawn-timing]` to aggregate. Null phases didn't run for
1500
+ // this dispatch (e.g. stale_head only runs for fix tasks; dep_* only when
1501
+ // the item has depends_on).
1502
+ try {
1503
+ const _diff = (a, b) => (_phaseT[a] != null && _phaseT[b] != null) ? (_phaseT[b] - _phaseT[a]) : null;
1504
+ const timings = {
1505
+ prompt: _diff('start', 'afterPrompt'),
1506
+ worktree_total: _diff('afterPrompt', 'afterWorktree'),
1507
+ dep_fetch: _diff('depFetchStart', 'depFetchEnd'),
1508
+ dep_preflight: _diff('depPreflightStart', 'depPreflightEnd'),
1509
+ dep_merge: _diff('depMergeStart', 'depMergeEnd'),
1510
+ stale_head: _diff('staleHeadStart', 'staleHeadEnd'),
1511
+ runtime: _diff('afterWorktree', 'afterRuntime'),
1512
+ spawn_call: _diff('spawnCallStart', 'spawnCallEnd'),
1513
+ total: _diff('start', 'spawnCallEnd'),
1514
+ };
1515
+ log('info', `[spawn-timing] id=${id} agent=${agentId} type=${type} runtime=${runtimeName} branch=${branchName || '-'} deps=${_depCountForLog} ${JSON.stringify(timings)}`);
1516
+ } catch { /* telemetry is best-effort */ }
1517
+
1479
1518
  const MAX_OUTPUT = 1024 * 1024; // 1MB
1480
1519
  let stdout = '';
1481
1520
  let stderr = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1924",
3
+ "version": "0.1.1926",
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"