@yemi33/minions 0.1.2401 → 0.1.2403

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.
@@ -79,8 +79,9 @@ const _pageCounters = {
79
79
  return sch.length + '|' + sch.map(function(s) { return (s.id || '') + ':' + (s.enabled === false ? '0' : '1') + ':' + (s.cron || ''); }).sort().join(',');
80
80
  },
81
81
  tools: function(d) {
82
- // skills + mcpServers are on /api/status directly, no cache to wait for.
83
- return (d.skills || []).length + '|' + (d.mcpServers || []).length;
82
+ const tools = window._lastToolsInventory;
83
+ if (!tools) return null;
84
+ return (tools.skills || []).length + '|' + (tools.mcpServers || []).length;
84
85
  },
85
86
  engine: function(d) {
86
87
  const dispatch = window._lastDispatch;
@@ -447,6 +448,22 @@ function _refreshSidebarCounterSummaries() {
447
448
  // entry read it — drop the dead endpoint + fetch. (Review finding #9.)
448
449
  }
449
450
 
451
+ function _renderToolsInventory(tools) {
452
+ if (!tools || !Array.isArray(tools.skills)
453
+ || !Array.isArray(tools.commands)
454
+ || !Array.isArray(tools.mcpServers)) return false;
455
+ if (_changed('skills', tools.skills)) {
456
+ _safeRender('skills', function() { renderSkills(tools.skills); });
457
+ }
458
+ if (_changed('commands', tools.commands)) {
459
+ _safeRender('commands', function() { renderCommands(tools.commands); });
460
+ }
461
+ if (_changed('mcpServers', tools.mcpServers)) {
462
+ _safeRender('mcpServers', function() { renderMcpServers(tools.mcpServers); });
463
+ }
464
+ return true;
465
+ }
466
+
450
467
  function _processStatusUpdate(data, opts) {
451
468
  opts = opts || {};
452
469
  // Kick off the dedicated-endpoint summary refreshes (issue #2949). They
@@ -890,12 +907,25 @@ function _processStatusUpdate(data, opts) {
890
907
  }
891
908
  });
892
909
  });
893
- _changed('skills', data.skills);
894
- _safeRender('skills', function() { renderSkills(data.skills || []); });
895
- _changed('commands', data.commands);
896
- _safeRender('commands', function() { renderCommands(data.commands || []); });
897
- _changed('mcpServers', data.mcpServers);
898
- _safeRender('mcpServers', function() { renderMcpServers(data.mcpServers || []); });
910
+ // Skills/commands/MCP discovery runs in a child process behind /api/tools.
911
+ // It is only consumed by the Tools page, so every other page skips the
912
+ // request and /api/status stays a small, non-blocking health envelope.
913
+ _safeRender('toolsInventory', function() {
914
+ if (typeof currentPage !== 'undefined' && currentPage !== 'tools') return;
915
+ const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
916
+ window._lastRequestedSeq = window._lastRequestedSeq || {};
917
+ window._lastRequestedSeq.toolsInventory = seq;
918
+ _condFetchJson('/api/tools')
919
+ .then(function(resp) {
920
+ if (seq < (window._lastRequestedSeq.toolsInventory || 0) || resp.notModified) return;
921
+ const tools = resp.data;
922
+ if (_renderToolsInventory(tools)) window._lastToolsInventory = tools;
923
+ })
924
+ .catch(function() {
925
+ const tools = window._lastToolsInventory;
926
+ if (tools) _renderToolsInventory(tools);
927
+ });
928
+ });
899
929
  // Harness propagation diagnostic comes from /api/harness/diagnostics.
900
930
  // HEAVY ENDPOINT: the handler runs a synchronous `git status` per linked
901
931
  // project (execFileSync, 10s timeout) which hard-blocks the dashboard event
package/dashboard.js CHANGED
@@ -22,9 +22,11 @@ require('./engine/stdio-timestamps').installIfNotInstalled();
22
22
 
23
23
  const http = require('http');
24
24
  const zlib = require('zlib');
25
+ const crypto = require('crypto');
25
26
  const fs = require('fs');
26
27
  const path = require('path');
27
28
  const v8 = require('v8');
29
+ const { execFile } = require('child_process');
28
30
  const llm = require('./engine/llm');
29
31
  const { resolveRuntime, shouldSuppressPostMutationError } = require('./engine/runtimes');
30
32
 
@@ -69,11 +71,12 @@ const ccWorkerPool = require('./engine/cc-worker-pool');
69
71
  const diagnosticsMemory = require('./engine/diagnostics-memory');
70
72
  const memoryStore = require('./engine/memory-store');
71
73
  const smallStateStore = require('./engine/small-state-store');
74
+ const toolsInventory = require('./engine/tools-inventory');
72
75
  const os = require('os');
73
76
 
74
77
  const { safeRead, safeReadOrNull, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeUnlink, mutateJsonFileLocked, mutateTextFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, WORKTREE_REQUIRING_TYPES, reopenWorkItem } = shared;
75
78
  const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
76
- getSkills, getCommands, getInbox, getNotesWithMeta, getPullRequests,
79
+ getInbox, getNotesWithMeta, getPullRequests,
77
80
  getEngineLog, getMetrics, getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getProjectGitStatus, timeSince,
78
81
  MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, PRD_DIR } = queries;
79
82
 
@@ -230,16 +233,31 @@ function _resetHeapSnapshotRateLimitForTesting() {
230
233
  _heapSnapshotLastCapturedAt = 0;
231
234
  }
232
235
 
236
+ const _initializedProjectStateKeys = new Set();
237
+
233
238
  function ensureConfiguredProjectStateFiles() {
239
+ const configuredKeys = new Set();
234
240
  for (const p of PROJECTS) {
235
241
  const root = p.localPath ? path.resolve(p.localPath) : null;
242
+ const key = `${String(p.name || '').toLowerCase()}\0${shared._normalizeWorktreePath(root || '')}`;
243
+ configuredKeys.add(key);
244
+ if (_initializedProjectStateKeys.has(key)) continue;
236
245
  if (!root || !fs.existsSync(root)) continue;
237
246
  try {
238
247
  shared.initializeProjectState(p);
248
+ _initializedProjectStateKeys.add(key);
239
249
  } catch (e) {
240
250
  console.warn(`[dashboard] project state setup failed for "${p.name}": ${e.message}`);
241
251
  }
242
252
  }
253
+
254
+ for (const key of _initializedProjectStateKeys) {
255
+ if (!configuredKeys.has(key)) _initializedProjectStateKeys.delete(key);
256
+ }
257
+ }
258
+
259
+ function _resetInitializedProjectStatesForTesting() {
260
+ _initializedProjectStateKeys.clear();
243
261
  }
244
262
 
245
263
  function reloadConfig() {
@@ -2308,74 +2326,8 @@ function _parseCopilotMcpListJson(raw) {
2308
2326
  } catch { return []; }
2309
2327
  }
2310
2328
 
2311
- // Read Claude's user-scope MCP servers from `~/.claude.json` directly.
2312
- // Shelling out to `claude mcp list` is unreliable from the dashboard daemon —
2313
- // the CLI's MCP health probes block on a TTY check when the parent has no
2314
- // console (bin/minions.js launches the dashboard with windowsHide:true), and
2315
- // neither exec() nor spawn(..., {detached:true}) reliably sidesteps it.
2316
- // Config-file reads are zero-spawn, instant, and the file is the authoritative
2317
- // source for which servers are registered.
2318
- function _readClaudeUserMcpServers() {
2319
- try {
2320
- const data = safeJsonObj(path.join(os.homedir(), '.claude.json'));
2321
- const servers = data?.mcpServers || {};
2322
- return Object.entries(servers).map(([name, cfg]) => ({
2323
- name,
2324
- source: 'Claude user',
2325
- command: cfg.command || cfg.url || '',
2326
- args: Array.isArray(cfg.args) ? cfg.args.join(' ') : (cfg.args || ''),
2327
- }));
2328
- } catch { return []; }
2329
- }
2330
-
2331
- // Copilot stores its MCP config at `~/.copilot/mcp-config.json` (may have a
2332
- // UTF-8 BOM — Windows tooling tends to write one).
2333
- function _readCopilotUserMcpServers() {
2334
- try {
2335
- const configPath = path.join(os.homedir(), '.copilot', 'mcp-config.json');
2336
- if (!fs.existsSync(configPath)) return [];
2337
- let raw = fs.readFileSync(configPath, 'utf8');
2338
- if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
2339
- const data = JSON.parse(raw);
2340
- const servers = data?.mcpServers || {};
2341
- return Object.entries(servers).map(([name, cfg]) => ({
2342
- name,
2343
- source: 'Copilot',
2344
- command: cfg.command || cfg.url || '',
2345
- args: Array.isArray(cfg.args) ? cfg.args.join(' ') : (cfg.args || ''),
2346
- }));
2347
- } catch { return []; }
2348
- }
2349
-
2350
- function _readWorkspaceMcpServers(projects) {
2351
- const out = [];
2352
- for (const p of (projects || [])) {
2353
- if (!p?.localPath) continue;
2354
- const data = safeJsonObj(path.join(p.localPath, '.mcp.json'));
2355
- const servers = data.mcpServers || {};
2356
- for (const [name, cfg] of Object.entries(servers)) {
2357
- out.push({
2358
- name,
2359
- source: `Workspace: ${p.name}`,
2360
- command: cfg.command || cfg.url || '',
2361
- args: Array.isArray(cfg.args) ? cfg.args.join(' ') : (cfg.args || ''),
2362
- });
2363
- }
2364
- }
2365
- return out;
2366
- }
2367
-
2368
- function _dedupeMcpServers(entries) {
2369
- const seen = new Set();
2370
- const out = [];
2371
- for (const e of entries) {
2372
- const key = `${e.source}::${e.name}`;
2373
- if (seen.has(key)) continue;
2374
- seen.add(key);
2375
- out.push(e);
2376
- }
2377
- return out;
2378
- }
2329
+ const _readWorkspaceMcpServers = toolsInventory.readWorkspaceMcpServers;
2330
+ const _dedupeMcpServers = toolsInventory.dedupeMcpServers;
2379
2331
 
2380
2332
  function getMcpServers() {
2381
2333
  const now = Date.now();
@@ -2383,12 +2335,7 @@ function getMcpServers() {
2383
2335
  return _mcpServersCache;
2384
2336
  }
2385
2337
  try {
2386
- const entries = [
2387
- ..._readClaudeUserMcpServers(),
2388
- ..._readCopilotUserMcpServers(),
2389
- ..._readWorkspaceMcpServers(PROJECTS),
2390
- ];
2391
- _mcpServersCache = _dedupeMcpServers(entries);
2338
+ _mcpServersCache = toolsInventory.getMcpServers(PROJECTS);
2392
2339
  } catch (e) {
2393
2340
  console.error('[mcp-discovery] refresh failed:', e.message?.split('\n')?.[0]);
2394
2341
  if (!_mcpServersCache) _mcpServersCache = [];
@@ -2397,6 +2344,135 @@ function getMcpServers() {
2397
2344
  return _mcpServersCache;
2398
2345
  }
2399
2346
 
2347
+ const TOOLS_INVENTORY_CACHE_TTL_MS = 30000;
2348
+ const TOOLS_INVENTORY_CHILD_TIMEOUT_MS = 30000;
2349
+ const TOOLS_INVENTORY_CHILD_WATCHDOG_MS = 35000;
2350
+ const TOOLS_INVENTORY_MAX_BYTES = 4 * 1024 * 1024;
2351
+ let _toolsInventoryCache = null;
2352
+ let _toolsInventoryCacheJson = null;
2353
+ let _toolsInventoryCacheEtag = null;
2354
+ let _toolsInventoryCacheTs = 0;
2355
+ let _toolsInventoryRefreshPromise = null;
2356
+ let _toolsInventoryChild = null;
2357
+ let _toolsInventoryScanner = _scanToolsInventoryInChild;
2358
+
2359
+ function _validateToolsInventoryPayload(payload) {
2360
+ if (!payload || typeof payload !== 'object'
2361
+ || !Array.isArray(payload.skills)
2362
+ || !Array.isArray(payload.commands)
2363
+ || !Array.isArray(payload.mcpServers)) {
2364
+ throw new Error('tools inventory child returned an invalid payload');
2365
+ }
2366
+ return {
2367
+ skills: payload.skills,
2368
+ commands: payload.commands,
2369
+ mcpServers: payload.mcpServers,
2370
+ };
2371
+ }
2372
+
2373
+ function _scanToolsInventoryInChild() {
2374
+ const childPath = path.join(__dirname, 'engine', 'tools-inventory.js');
2375
+ return new Promise((resolve, reject) => {
2376
+ let settled = false;
2377
+ let watchdog = null;
2378
+ const child = execFile(process.execPath, [childPath], {
2379
+ cwd: MINIONS_DIR,
2380
+ env: { ...process.env, MINIONS_HOME: MINIONS_DIR },
2381
+ encoding: 'utf8',
2382
+ maxBuffer: TOOLS_INVENTORY_MAX_BYTES,
2383
+ timeout: TOOLS_INVENTORY_CHILD_TIMEOUT_MS,
2384
+ windowsHide: true,
2385
+ }, (error, stdout, stderr) => {
2386
+ if (settled) return;
2387
+ settled = true;
2388
+ if (watchdog) clearTimeout(watchdog);
2389
+ if (_toolsInventoryChild === child) _toolsInventoryChild = null;
2390
+ if (error) {
2391
+ const detail = String(stderr || error.message || '').trim().slice(0, 500);
2392
+ reject(new Error(`tools inventory scan failed${detail ? `: ${detail}` : ''}`));
2393
+ return;
2394
+ }
2395
+ try {
2396
+ resolve(_validateToolsInventoryPayload(JSON.parse(stdout)));
2397
+ } catch (parseError) {
2398
+ reject(new Error(`tools inventory scan returned invalid JSON: ${parseError.message}`));
2399
+ }
2400
+ });
2401
+ _toolsInventoryChild = child;
2402
+ watchdog = setTimeout(() => {
2403
+ if (settled) return;
2404
+ settled = true;
2405
+ if (_toolsInventoryChild === child) _toolsInventoryChild = null;
2406
+ try { shared.killImmediate(child); } catch { /* child may already be gone */ }
2407
+ reject(new Error(`tools inventory scan exceeded ${TOOLS_INVENTORY_CHILD_WATCHDOG_MS}ms watchdog`));
2408
+ }, TOOLS_INVENTORY_CHILD_WATCHDOG_MS);
2409
+ watchdog.unref();
2410
+ });
2411
+ }
2412
+
2413
+ function _terminateToolsInventoryChild() {
2414
+ const child = _toolsInventoryChild;
2415
+ _toolsInventoryChild = null;
2416
+ if (!child) return;
2417
+ try { shared.killImmediate(child); } catch { /* child may already be gone */ }
2418
+ }
2419
+
2420
+ function _refreshToolsInventory() {
2421
+ const now = Date.now();
2422
+ if (_toolsInventoryCache && now - _toolsInventoryCacheTs < TOOLS_INVENTORY_CACHE_TTL_MS) {
2423
+ return Promise.resolve(_toolsInventoryCache);
2424
+ }
2425
+ if (_toolsInventoryRefreshPromise) return _toolsInventoryRefreshPromise;
2426
+
2427
+ _toolsInventoryRefreshPromise = Promise.resolve()
2428
+ .then(() => _toolsInventoryScanner())
2429
+ .then(payload => {
2430
+ const next = _validateToolsInventoryPayload(payload);
2431
+ const json = JSON.stringify(next);
2432
+ _toolsInventoryCache = next;
2433
+ _toolsInventoryCacheJson = json;
2434
+ _toolsInventoryCacheEtag = `"tools-${crypto.createHash('sha256').update(json).digest('hex')}"`;
2435
+ _toolsInventoryCacheTs = Date.now();
2436
+ return next;
2437
+ })
2438
+ .finally(() => {
2439
+ _toolsInventoryRefreshPromise = null;
2440
+ });
2441
+ return _toolsInventoryRefreshPromise;
2442
+ }
2443
+
2444
+ async function handleToolsInventory(req, res) {
2445
+ try {
2446
+ await _refreshToolsInventory();
2447
+ res.setHeader('ETag', _toolsInventoryCacheEtag);
2448
+ res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
2449
+ if (_ifNoneMatchHasEtag(req?.headers?.['if-none-match'], _toolsInventoryCacheEtag)) {
2450
+ res.statusCode = 304;
2451
+ res.end();
2452
+ return;
2453
+ }
2454
+ res.statusCode = 200;
2455
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
2456
+ res.end(_toolsInventoryCacheJson);
2457
+ } catch (error) {
2458
+ jsonReply(res, 503, { error: error.message }, req);
2459
+ }
2460
+ }
2461
+
2462
+ function _setToolsInventoryScannerForTesting(scanner) {
2463
+ _toolsInventoryScanner = typeof scanner === 'function' ? scanner : _scanToolsInventoryInChild;
2464
+ }
2465
+
2466
+ function _resetToolsInventoryCacheForTesting() {
2467
+ _terminateToolsInventoryChild();
2468
+ _toolsInventoryCache = null;
2469
+ _toolsInventoryCacheJson = null;
2470
+ _toolsInventoryCacheEtag = null;
2471
+ _toolsInventoryCacheTs = 0;
2472
+ _toolsInventoryRefreshPromise = null;
2473
+ _toolsInventoryScanner = _scanToolsInventoryInChild;
2474
+ }
2475
+
2400
2476
  function parsePinnedEntries(content) {
2401
2477
  if (!content) return [];
2402
2478
  const entries = [];
@@ -2492,9 +2568,9 @@ function _ifNoneMatchHasEtag(headerValue, currentEtag) {
2492
2568
  // We union them here. Adding a new tracked file means adding ONE line to
2493
2569
  // the appropriate list in queries.js — the dashboard side stays a thin
2494
2570
  // delegate that doesn't need to know about per-source semantics anymore.
2495
- function _mtimeTrackedFiles() {
2571
+ async function _mtimeTrackedFiles() {
2496
2572
  const fast = queries.getStatusFastStateMtimePaths(CONFIG);
2497
- const slow = queries.getStatusSlowStateMtimePaths(CONFIG);
2573
+ const slow = await queries.getStatusSlowStateMtimePathsAsync(CONFIG);
2498
2574
  // Dedup with a Set — many paths (per-project work-items, git refs) appear
2499
2575
  // in both registries since both fast (workItems slice) and slow (PRD
2500
2576
  // progress derived from same data) read them.
@@ -2519,37 +2595,68 @@ function _getCurrentEventVersion() {
2519
2595
  }
2520
2596
  }
2521
2597
 
2522
- // Stat a tracked path with transient-error tolerance. ENOENT (file/dir doesn't
2523
- // exist) is normal — fresh installs, deleted projects, empty PRD dirs all hit
2524
- // this and maps to 0 so the entry just doesn't bust the cache. EBUSY /
2525
- // EACCES / EPERM are transient on Windows (OneDrive sync, antivirus scan,
2526
- // file replication service); falling through to 0 would produce oscillating
2527
- // mtimes (real, then 0, then real again) that look like a real change on
2528
- // every other poll. Preserving the previous successful mtime on those errors
2529
- // keeps the cache stable across short lock windows.
2530
- function _statMtimeMs(fp, prevSnapshot) {
2598
+ const STATUS_MTIME_REFRESH_TTL_MS = 1000;
2599
+ let _currentMtimes = {};
2600
+ let _currentMtimesTs = 0;
2601
+ let _mtimeRefreshPromise = null;
2602
+
2603
+ // Async transient-tolerant equivalent of the former statSync hot path. ENOENT
2604
+ // maps to 0; lock/access errors preserve the prior value so antivirus or a
2605
+ // temporarily unavailable project root cannot oscillate the cache.
2606
+ async function _statMtimeMs(fp, prevSnapshot) {
2531
2607
  try {
2532
- return fs.statSync(fp).mtimeMs;
2608
+ return (await fs.promises.stat(fp)).mtimeMs;
2533
2609
  } catch (e) {
2534
- if (e && e.code === 'ENOENT') return 0;
2610
+ if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return 0;
2535
2611
  return prevSnapshot && (fp in prevSnapshot) ? prevSnapshot[fp] : 0;
2536
2612
  }
2537
2613
  }
2538
2614
 
2615
+ async function _scanStatusMtimes(paths, previous) {
2616
+ const entries = await Promise.all(paths.map(async fp => [
2617
+ fp,
2618
+ await _statMtimeMs(fp, previous),
2619
+ ]));
2620
+ return Object.fromEntries(entries);
2621
+ }
2622
+
2623
+ function _refreshStatusMtimes() {
2624
+ if (_mtimeRefreshPromise) return _mtimeRefreshPromise;
2625
+ const previous = _currentMtimes;
2626
+ const hadSnapshot = _currentMtimesTs > 0;
2627
+ _mtimeRefreshPromise = _mtimeTrackedFiles()
2628
+ .then(paths => _scanStatusMtimes(paths, previous))
2629
+ .then(next => {
2630
+ const changed = hadSnapshot && _mtimesChanged(previous, next);
2631
+ _currentMtimes = next;
2632
+ _currentMtimesTs = Date.now();
2633
+ if (changed && (_statusCache || _statusRebuildPromise)) invalidateStatusCache();
2634
+ return next;
2635
+ })
2636
+ .finally(() => {
2637
+ _mtimeRefreshPromise = null;
2638
+ });
2639
+ return _mtimeRefreshPromise;
2640
+ }
2641
+
2539
2642
  function _getMtimes() {
2540
- const result = {};
2541
- for (const fp of _mtimeTrackedFiles()) {
2542
- result[fp] = _statMtimeMs(fp, _lastMtimes);
2643
+ if (!_currentMtimesTs || Date.now() - _currentMtimesTs >= STATUS_MTIME_REFRESH_TTL_MS) {
2644
+ _refreshStatusMtimes().catch(error => {
2645
+ console.warn(`[dashboard] status mtime refresh failed: ${error.message}`);
2646
+ });
2543
2647
  }
2544
- return result;
2648
+ return _currentMtimes;
2545
2649
  }
2546
2650
 
2547
- // Reset per-source caches whose TTL would otherwise survive an mtime-triggered
2548
- // status rebuild and serve stale data.
2651
+ function _resetStatusMtimeCacheForTesting() {
2652
+ _currentMtimes = {};
2653
+ _currentMtimesTs = 0;
2654
+ _mtimeRefreshPromise = null;
2655
+ }
2656
+
2657
+ // Version data remains in /api/status and must reflect package/git mtime
2658
+ // changes. Tools inventory has its own child-process cache and endpoint.
2549
2659
  function _invalidateInnerCachesForRebuild() {
2550
- try { queries.invalidateSkillsCache(); } catch { /* optional */ }
2551
- _mcpServersCache = null;
2552
- _mcpServersCacheTs = 0;
2553
2660
  _diskVersionCache = null;
2554
2661
  _diskVersionCacheTs = 0;
2555
2662
  }
@@ -2588,10 +2695,9 @@ function invalidateStatusCache(_opts) {
2588
2695
  _statusPushTimer = setTimeout(() => {
2589
2696
  _statusPushTimer = null;
2590
2697
  if (_statusStreamClients.size === 0) return;
2591
- const data = getStatusJson();
2592
- for (const res of _statusStreamClients) {
2593
- try { res.write('data: ' + data + '\n\n'); } catch { _removeSseClient(_statusStreamClients, res); }
2594
- }
2698
+ _pushStatusToSseClients(true).catch(error => {
2699
+ console.warn(`[dashboard] status SSE push failed: ${error.message}`);
2700
+ });
2595
2701
  }, 500);
2596
2702
  }
2597
2703
 
@@ -2649,12 +2755,10 @@ function _buildStatusSlowState() {
2649
2755
  // /api/schedules, pipelines → /api/pipelines, pinned → /api/pinned.
2650
2756
  // What's left here is genuine "shape of the install" state that has no
2651
2757
  // staleness problem: projects + git status (server-only compute), runtime
2652
- // capability roll-ups (skills + mcpServers), config-derived autoMode +
2653
- // initialized + installId, and the version banner.
2758
+ // config-derived autoMode + initialized + installId, and the version banner.
2759
+ // Skills, commands, and MCP servers live on /api/tools because their
2760
+ // filesystem discovery runs in a child process.
2654
2761
  return {
2655
- skills: getSkills(),
2656
- commands: getCommands(CONFIG),
2657
- mcpServers: getMcpServers(),
2658
2762
  projects: PROJECTS.map(p => {
2659
2763
  const status = getProjectGitStatus(p.localPath, p.mainBranch);
2660
2764
  const mainBranch = p.mainBranch || null;
@@ -2831,6 +2935,10 @@ function refreshStatusAsync() {
2831
2935
  if (_statusRebuildPromise) return _statusRebuildPromise;
2832
2936
 
2833
2937
  _statusRebuildPromise = (async () => {
2938
+ // A cache hit can otherwise complete synchronously before the assignment
2939
+ // above stores the Promise, so the finally block clears null and the outer
2940
+ // assignment then resurrects an already-resolved single-flight forever.
2941
+ await Promise.resolve();
2834
2942
  const profile = _statusTimingActive();
2835
2943
  const tOverall = profile ? process.hrtime.bigint() : null;
2836
2944
  let fastBuildMs = 0;
@@ -2914,6 +3022,18 @@ function refreshStatusAsync() {
2914
3022
  return _statusRebuildPromise;
2915
3023
  }
2916
3024
 
3025
+ async function _ensureStatusCache() {
3026
+ if (_statusCache) return _statusCache;
3027
+ // A request can inherit an older single-flight rebuild and then race both a
3028
+ // worktree-count and mtime refresh. Allow several cooperative retries rather
3029
+ // than falling back to the synchronous builder or returning a transient 500.
3030
+ for (let attempt = 0; attempt < 5 && !_statusCache; attempt++) {
3031
+ await refreshStatusAsync();
3032
+ }
3033
+ if (!_statusCache) throw new Error('status cache invalidated during rebuild; retry');
3034
+ return _statusCache;
3035
+ }
3036
+
2917
3037
  // Test seams (W-mpehsyhv0017085a) — exported via module.exports so the unit
2918
3038
  // stress test can drive the rebuild deterministically without standing up an
2919
3039
  // http server. None of these are referenced by production code paths.
@@ -2935,9 +3055,9 @@ function _resetStatusCacheForTesting() {
2935
3055
  _lastEventVersion = -1;
2936
3056
  }
2937
3057
 
2938
- /** Return cached JSON string of status single stringify, reused by SSE and /api/status */
3058
+ /** Serialize the current cache once. Callers must refresh asynchronously first. */
2939
3059
  function getStatusJson() {
2940
- getStatus(); // ensure _statusCache is fresh
3060
+ if (!_statusCache) throw new Error('status cache is not ready');
2941
3061
  if (!_statusCacheJson) {
2942
3062
  const profile = _statusTimingActive();
2943
3063
  const tStringify = profile ? process.hrtime.bigint() : null;
@@ -3361,17 +3481,9 @@ async function _handleStatusRequest(req, res) {
3361
3481
  if (_statusCache) {
3362
3482
  Promise.resolve().then(refreshStatusAsync).catch(() => { /* next poll + SSE push retry */ });
3363
3483
  } else {
3364
- // Cold start or post-hard-invalidation: nothing to serve → await one
3365
- // rebuild. refreshStatusAsync yields the event loop mid-rebuild so CC
3366
- // SSE heartbeats keep flowing during a slow build.
3367
- try { await refreshStatusAsync(); } catch { /* fall through — getStatusJson will sync-rebuild */ }
3368
- // Race-guard fallback: if refresh discarded its result because
3369
- // invalidateStatusCache() fired mid-rebuild, _statusCache is still null.
3370
- // Trigger a synchronous rebuild here so the ETag we compute reflects
3371
- // freshly-built post-invalidate state, not the stale pre-invalidate one.
3372
- if (!_statusCache) {
3373
- try { getStatus(); } catch { /* getStatusJson below will retry */ }
3374
- }
3484
+ // Cold start or post-hard-invalidation: retry an invalidation-raced
3485
+ // rebuild asynchronously. Never fall back to the synchronous builder.
3486
+ await _ensureStatusCache();
3375
3487
  }
3376
3488
 
3377
3489
  // ETag = monotonic cache version. Bumped on every successful rebuild and
@@ -3405,15 +3517,24 @@ async function _handleStatusRequest(req, res) {
3405
3517
  }
3406
3518
  }
3407
3519
 
3408
- // Periodic push for engine-driven SQL state changes that bypass invalidateStatusCache.
3409
- setInterval(() => {
3520
+ async function _pushStatusToSseClients(force = false) {
3410
3521
  if (_statusStreamClients.size === 0) return;
3522
+ await refreshStatusAsync();
3523
+ await _ensureStatusCache();
3411
3524
  const data = getStatusJson();
3412
- if (data === _lastStatusPushRef) return; // O(1) reference comparison — new string ref means content changed
3525
+ if (!force && data === _lastStatusPushRef) return;
3413
3526
  _lastStatusPushRef = data;
3414
3527
  for (const res of _statusStreamClients) {
3415
3528
  try { res.write('data: ' + data + '\n\n'); } catch { _removeSseClient(_statusStreamClients, res); }
3416
3529
  }
3530
+ }
3531
+
3532
+ // Periodic push for engine-driven SQL state changes that bypass invalidateStatusCache.
3533
+ setInterval(() => {
3534
+ if (_statusStreamClients.size === 0) return;
3535
+ _pushStatusToSseClients().catch(error => {
3536
+ console.warn(`[dashboard] periodic status SSE push failed: ${error.message}`);
3537
+ });
3417
3538
  }, 10000).unref();
3418
3539
 
3419
3540
 
@@ -13401,10 +13522,22 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13401
13522
  });
13402
13523
  return jsonReply(res, 200, { ok: true }, req);
13403
13524
  }},
13404
- { method: 'GET', path: '/api/status-stream', desc: 'SSE stream of real-time status updates', handler: (req, res) => {
13405
- res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
13406
- res.write('data: ' + getStatusJson() + '\n\n');
13407
- _trackSseClient(_statusStreamClients, req, res);
13525
+ { method: 'GET', path: '/api/tools', desc: 'Skills, commands, and MCP inventory scanned outside the dashboard event loop', handler: handleToolsInventory },
13526
+ { method: 'GET', path: '/api/status-stream', desc: 'SSE stream of real-time status updates', handler: async (req, res) => {
13527
+ try {
13528
+ try {
13529
+ await refreshStatusAsync();
13530
+ } catch (error) {
13531
+ if (!_statusCache) throw error;
13532
+ }
13533
+ await _ensureStatusCache();
13534
+ const data = getStatusJson();
13535
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
13536
+ res.write('data: ' + data + '\n\n');
13537
+ _trackSseClient(_statusStreamClients, req, res);
13538
+ } catch (error) {
13539
+ jsonReply(res, 503, { error: error.message }, req);
13540
+ }
13408
13541
  }},
13409
13542
  { method: 'GET', path: '/api/health', desc: 'Lightweight health check for monitoring', handler: handleHealth },
13410
13543
  { method: 'GET', path: '/api/keep-processes', desc: 'List all active agents/<id>/keep-pids.json entries (W-mp68q6ke0010de68)', handler: handleKeepProcessesList },
@@ -14909,10 +15042,10 @@ module.exports = {
14909
15042
  refreshStatusAsync,
14910
15043
  handleStatus: _handleStatusRequest,
14911
15044
  invalidateStatusCache,
15045
+ _ensureStatusCache, getStatusJson, _pushStatusToSseClients, // exported for testing
14912
15046
  // exported for testing — see test/unit/status-snapshot-budget.test.js (P-f2a3b4c5).
14913
- // The slim snapshot builder is the synchronous assembler used by getStatusJson()
14914
- // and refreshStatusAsync(); the budget test calls it directly to measure byte
14915
- // size and rebuild-time without standing up an HTTP server.
15047
+ // The budget test calls the synchronous builder directly to measure byte size
15048
+ // and rebuild time; production request/SSE paths use refreshStatusAsync().
14916
15049
  getStatus,
14917
15050
  // Raw state-file passthrough — exported for direct unit testing.
14918
15051
  handleStateRead,
@@ -14927,6 +15060,9 @@ module.exports = {
14927
15060
  _setStatusRefreshHook,
14928
15061
  _resetStatusCacheForTesting,
14929
15062
  _ifNoneMatchHasEtag,
15063
+ _scanStatusMtimes, _refreshStatusMtimes, _resetStatusMtimeCacheForTesting, // exported for testing
15064
+ handleToolsInventory, _refreshToolsInventory, _setToolsInventoryScannerForTesting, _resetToolsInventoryCacheForTesting, // exported for testing
15065
+ _ensureConfiguredProjectStateFiles: ensureConfiguredProjectStateFiles, _resetInitializedProjectStatesForTesting, // exported for testing
14930
15066
  _countWorktrees, _refreshWorktreeCount, _scanWorktreeCount, _resetWorktreeCountCacheForTesting, // exported for testing
14931
15067
  // W-status-swr — exported for direct unit tests of the subprocess-free git
14932
15068
  // HEAD parser and the prefix-tolerant short-SHA comparison. No production
@@ -14999,6 +15135,9 @@ if (require.main === module) {
14999
15135
  .catch(error => {
15000
15136
  console.warn(`[boot] worktree count pre-warm failed: ${error && error.message}`);
15001
15137
  });
15138
+ _refreshStatusMtimes().catch(error => {
15139
+ console.warn(`[boot] status mtime pre-warm failed: ${error && error.message}`);
15140
+ });
15002
15141
  try {
15003
15142
  await warmProjectGitStatusCache();
15004
15143
  const ms = Date.now() - _warmStartedAt;
@@ -15124,7 +15263,9 @@ if (require.main === module) {
15124
15263
  // and stalls the freshly-reloaded page in a spinner. Warming on listen
15125
15264
  // means the first request hits the cached buffer.
15126
15265
  setImmediate(() => {
15127
- try { getStatusJson(); } catch { /* warm-up is best-effort */ }
15266
+ refreshStatusAsync()
15267
+ .then(() => getStatusJson())
15268
+ .catch(error => console.warn(`[boot] status cache warm-up failed: ${error.message}`));
15128
15269
  });
15129
15270
 
15130
15271
  // ─── Engine Watchdog ─────────────────────────────────────────────────────
@@ -15214,6 +15355,7 @@ if (require.main === module) {
15214
15355
  function _gracefulShutdown() {
15215
15356
  try { flushPendingDocSessions(); } catch {}
15216
15357
  try { shared.clearDashboardPortFile(MINIONS_DIR); } catch {}
15358
+ _terminateToolsInventoryChild();
15217
15359
  // P-c3d4e5f6 — stop the diagnostics-memory sampler cleanly so the
15218
15360
  // setInterval handle doesn't keep the event loop alive on SIGTERM.
15219
15361
  if (_memorySamplerStop) {
package/docs/README.md CHANGED
@@ -69,7 +69,7 @@ Architecture, design proposals, and lifecycle references for people working on t
69
69
  - [skills.md](skills.md) — Skill block format: how agents emit reusable `\`\`\`skill` blocks and how the engine extracts them into native personal-skill directories.
70
70
  - [slim-ux/concepts.md](slim-ux/concepts.md) — Slim-UX design notes: simplified surface concepts driving the project picker, inline project link, and decoupled folder picker.
71
71
  - [slim-ux/architecture-suggestions.md](slim-ux/architecture-suggestions.md) — Slim-UX follow-up architecture suggestions paired with `concepts.md`.
72
- - [team-memory.md](team-memory.md) — Per-agent memory layer (`knowledge/agents/<id>.md`) and the consolidation/routing rules that populate it from `notes/inbox/`.
72
+ - [team-memory.md](team-memory.md) — End-to-end hybrid memory system: file-backed inputs and consolidation, SQL/FTS5 records, retrieval and fallback, prompt bounds, episodic capture, security, APIs, and operations.
73
73
  - [timeouts-and-liveness.md](timeouts-and-liveness.md) — What kills (or doesn't kill) a live tracked agent: the wall-clock vs steering kill invariants, spawn-phase watchdog gates, steering safety nets, and stale-orphan detection ladder.
74
74
  - [watches.md](watches.md) — Persistent monitoring jobs: target-type registry, conditions, follow-up actions, and the `watches.d/` plugin folder.
75
75
  - [workspace-manifests.md](workspace-manifests.md) — Declarative per-agent permission scoping: `allowed_tools` / `allowed_repos` / `allowed_external_urls` / `memory_scope`, dispatch-time repo gate, and runtime `--allowedTools` narrowing.