@yemi33/minions 0.1.2402 → 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.
- package/dashboard/js/refresh.js +38 -8
- package/dashboard.js +272 -130
- package/engine/queries.js +67 -68
- package/engine/tools-inventory.js +104 -0
- package/package.json +1 -1
package/dashboard/js/refresh.js
CHANGED
|
@@ -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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
_safeRender('
|
|
897
|
-
|
|
898
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2312
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
//
|
|
2528
|
-
//
|
|
2529
|
-
//
|
|
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.
|
|
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
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
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
|
|
2648
|
+
return _currentMtimes;
|
|
2545
2649
|
}
|
|
2546
2650
|
|
|
2547
|
-
|
|
2548
|
-
|
|
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
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
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
|
-
//
|
|
2653
|
-
//
|
|
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
|
-
/**
|
|
3058
|
+
/** Serialize the current cache once. Callers must refresh asynchronously first. */
|
|
2939
3059
|
function getStatusJson() {
|
|
2940
|
-
|
|
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:
|
|
3365
|
-
// rebuild.
|
|
3366
|
-
|
|
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
|
-
|
|
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;
|
|
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/
|
|
13405
|
-
|
|
13406
|
-
|
|
13407
|
-
|
|
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
|
|
14914
|
-
// and
|
|
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
|
-
|
|
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/engine/queries.js
CHANGED
|
@@ -1219,7 +1219,7 @@ let _skillIndexCache = null;
|
|
|
1219
1219
|
let _skillIndexCacheTs = 0;
|
|
1220
1220
|
let _skillIndexCacheKey = null;
|
|
1221
1221
|
// Top-level cache for getSkills (the read+parse + meta-build path layered on
|
|
1222
|
-
// top of collectSkillFiles). Without this, every /api/
|
|
1222
|
+
// top of collectSkillFiles). Without this, every /api/tools inventory scan
|
|
1223
1223
|
// rebuild re-read and re-parsed all ~80 SKILL.md files (~29ms warm even
|
|
1224
1224
|
// though collectSkillFiles itself was cached) because getSkills did the
|
|
1225
1225
|
// I/O on the cached file list. Shares the same 30s TTL + cacheKey so an
|
|
@@ -1465,8 +1465,8 @@ const SKILL_SOURCE_BY_SCOPE = {
|
|
|
1465
1465
|
};
|
|
1466
1466
|
|
|
1467
1467
|
function getSkills(config) {
|
|
1468
|
-
// Cache the entire read+parse round, not just the file list. /api/
|
|
1469
|
-
//
|
|
1468
|
+
// Cache the entire read+parse round, not just the file list. /api/tools
|
|
1469
|
+
// historically called getSkills on every rebuild; re-reading 80+ SKILL.md
|
|
1470
1470
|
// files + re-parsing their YAML frontmatter cost ~29ms each time (the
|
|
1471
1471
|
// single biggest hot-path offender after getPrdInfo cold). 30s TTL +
|
|
1472
1472
|
// shared cacheKey with collectSkillFiles so invalidateSkillsCache() still
|
|
@@ -1631,8 +1631,8 @@ function _commandTitle(content, fallback) {
|
|
|
1631
1631
|
}
|
|
1632
1632
|
|
|
1633
1633
|
// Dashboard-facing wrapper over `collectCommandFiles`. Returns a normalized
|
|
1634
|
-
// per-entry shape the tools.html "Slash commands" panel + `/api/
|
|
1635
|
-
//
|
|
1634
|
+
// per-entry shape the tools.html "Slash commands" panel + `/api/tools`
|
|
1635
|
+
// response renders directly. Includes the resolved title (from YAML
|
|
1636
1636
|
// `description:` frontmatter or first `# heading`) and a `dir`-with-forward-
|
|
1637
1637
|
// slashes copy so the renderer can build display strings without re-walking
|
|
1638
1638
|
// the file on the client. Sort matches getCommandIndex (project → plugin →
|
|
@@ -2838,6 +2838,31 @@ function _resolveCommonGitDir(gitDir) {
|
|
|
2838
2838
|
return path.isAbsolute(raw) ? raw : path.resolve(gitDir, raw);
|
|
2839
2839
|
}
|
|
2840
2840
|
|
|
2841
|
+
async function _resolveGitDirAsync(localPath) {
|
|
2842
|
+
if (!localPath) return null;
|
|
2843
|
+
const gitPath = path.join(localPath, '.git');
|
|
2844
|
+
let stat;
|
|
2845
|
+
try { stat = await fs.promises.stat(gitPath); }
|
|
2846
|
+
catch { return null; }
|
|
2847
|
+
if (stat.isDirectory()) return gitPath;
|
|
2848
|
+
if (!stat.isFile()) return null;
|
|
2849
|
+
let raw;
|
|
2850
|
+
try { raw = (await fs.promises.readFile(gitPath, 'utf8')).slice(0, 4096); }
|
|
2851
|
+
catch { return null; }
|
|
2852
|
+
const match = /^gitdir:\s*(.+?)\s*$/m.exec(raw);
|
|
2853
|
+
if (!match) return null;
|
|
2854
|
+
return path.isAbsolute(match[1]) ? match[1] : path.resolve(localPath, match[1]);
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
async function _resolveCommonGitDirAsync(gitDir) {
|
|
2858
|
+
if (!gitDir) return null;
|
|
2859
|
+
let raw;
|
|
2860
|
+
try { raw = (await fs.promises.readFile(path.join(gitDir, 'commondir'), 'utf8')).trim(); }
|
|
2861
|
+
catch { return gitDir; }
|
|
2862
|
+
if (!raw) return gitDir;
|
|
2863
|
+
return path.isAbsolute(raw) ? raw : path.resolve(gitDir, raw);
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2841
2866
|
// Enumerate the per-project git ref files we watch for cache-busting, split
|
|
2842
2867
|
// into two groups (W-mrb6qnuc000785b2):
|
|
2843
2868
|
// identityFiles — HEAD + logs/HEAD, resolved against the PER-WORKTREE
|
|
@@ -3084,6 +3109,8 @@ let _fastMtimePathsCache = null;
|
|
|
3084
3109
|
let _fastMtimePathsCacheKey = null;
|
|
3085
3110
|
let _slowMtimePathsCache = null;
|
|
3086
3111
|
let _slowMtimePathsCacheKey = null;
|
|
3112
|
+
let _slowMtimePathsAsyncCache = null;
|
|
3113
|
+
let _slowMtimePathsAsyncCacheKey = null;
|
|
3087
3114
|
function _mtimePathsCacheKey(config) {
|
|
3088
3115
|
const projects = getProjects(config).map(p => (p.name || '') + '|' + (p.localPath || ''));
|
|
3089
3116
|
return projects.join(';');
|
|
@@ -3102,26 +3129,10 @@ function getStatusFastStateMtimePaths(config) {
|
|
|
3102
3129
|
/**
|
|
3103
3130
|
* Slow-state mtime tracker — symmetric with the fast-state registry above.
|
|
3104
3131
|
*
|
|
3105
|
-
* Slow-state mtime tracking covers only
|
|
3106
|
-
*
|
|
3107
|
-
*
|
|
3108
|
-
*
|
|
3109
|
-
* - Skill discovery roots (`~/.claude/skills`, `~/.copilot/skills`,
|
|
3110
|
-
* `~/.agents/skills`, `<project>/.claude/skills`, `<project>/.github/skills`,
|
|
3111
|
-
* `<project>/.agents/skills`) plus plugin registries are tracked here
|
|
3112
|
-
* because manual `SKILL.md` drops bypass the agent-close
|
|
3113
|
-
* `invalidateStatusCache({includeSlow:true})` path that previously
|
|
3114
|
-
* covered the agent-extraction case. Directory mtime advances reliably
|
|
3115
|
-
* on Windows NTFS when a subdirectory is added/removed (verified via
|
|
3116
|
-
* INBOX_DIR). Tracking the user-home dirs means non-Minions skill
|
|
3117
|
-
* installs on this machine also bust the fleet's slow-state — but skill
|
|
3118
|
-
* dirs only mutate on rare events (install / manual edit), not on every
|
|
3119
|
-
* CLI command, so the steady-state noise is negligible.
|
|
3120
|
-
* - MCP config files (`~/.claude.json`, `~/.copilot/mcp-config.json`,
|
|
3121
|
-
* `<project>/.mcp.json`) feed `getMcpServers()`. `~/.claude.json`
|
|
3122
|
-
* stores more than `mcpServers`, but it only flips on intentional
|
|
3123
|
-
* Claude CLI mutations (mcp add/remove, settings edits), not on every
|
|
3124
|
-
* prompt, so whole-file tracking is acceptable.
|
|
3132
|
+
* Slow-state mtime tracking covers only file-backed inputs that remain in
|
|
3133
|
+
* /api/status: config, install/version files, and project git metadata.
|
|
3134
|
+
* Skills, commands, and MCP settings are discovered by the isolated
|
|
3135
|
+
* /api/tools child process and no longer invalidate the global status cache.
|
|
3125
3136
|
*
|
|
3126
3137
|
* Per-project `.git/logs/HEAD` + `.git/FETCH_HEAD` ARE tracked here in
|
|
3127
3138
|
* addition to the fast-state tracker. The project payload (`projects:`
|
|
@@ -3142,21 +3153,14 @@ function getStatusFastStateMtimePaths(config) {
|
|
|
3142
3153
|
* gitdir, not the per-worktree subdir, so tracking the per-worktree
|
|
3143
3154
|
* path would silently miss every fetch).
|
|
3144
3155
|
*
|
|
3145
|
-
* NOTE: Detecting a change here busts the dashboard's slow-state cache, but
|
|
3146
|
-
* the inner per-source caches (`queries._skillsCache` 30 s, dashboard's
|
|
3147
|
-
* `_mcpServersCache` 5 min) survive across `_buildStatusSlowState()` calls.
|
|
3148
|
-
* dashboard.js calls `queries.invalidateSkillsCache()` and resets
|
|
3149
|
-
* `_mcpServersCache` whenever this tracker fires, so the rebuild reads
|
|
3150
|
-
* fresh disk state. Keep that invalidation wired up if you add new sources.
|
|
3151
3156
|
*/
|
|
3152
3157
|
function getStatusSlowStateMtimePaths(config) {
|
|
3153
3158
|
config = config || getConfig();
|
|
3154
3159
|
const cacheKey = _mtimePathsCacheKey(config);
|
|
3155
3160
|
if (_slowMtimePathsCache && _slowMtimePathsCacheKey === cacheKey) return _slowMtimePathsCache;
|
|
3156
3161
|
const projects = getProjects(config);
|
|
3157
|
-
|
|
3158
|
-
//
|
|
3159
|
-
// autoMode, initialized, installId, version. PRD/PRDProgress/verifyGuides/
|
|
3162
|
+
// Issue #2949 — slow-state now contains ONLY projects, autoMode,
|
|
3163
|
+
// initialized, installId, and version. PRD/PRDProgress/verifyGuides/
|
|
3160
3164
|
// archivedPrds/schedules/pipelines/pinned/work-items all moved to dedicated
|
|
3161
3165
|
// /api/<x> endpoints which carry their own ETags; tracking their backing
|
|
3162
3166
|
// files here would bust the /api/status outer cache for slices that aren't
|
|
@@ -3171,40 +3175,6 @@ function getStatusSlowStateMtimePaths(config) {
|
|
|
3171
3175
|
files.push(path.join(MINIONS_DIR, '.install-id'));
|
|
3172
3176
|
files.push(path.join(MINIONS_DIR, 'package.json'));
|
|
3173
3177
|
|
|
3174
|
-
// Skill discovery roots (surfaced by _buildStatusSlowState → getSkills).
|
|
3175
|
-
// Mirrors collectSkillFiles' source enumeration so adding a new runtime
|
|
3176
|
-
// adapter automatically extends the tracker. ENOENT is tolerated by
|
|
3177
|
-
// dashboard._statMtimeMs (returns 0), so absent dirs cost nothing.
|
|
3178
|
-
try {
|
|
3179
|
-
const { listRuntimes, resolveRuntime } = require('./runtimes');
|
|
3180
|
-
for (const runtimeName of listRuntimes()) {
|
|
3181
|
-
const runtime = resolveRuntime(runtimeName);
|
|
3182
|
-
if (typeof runtime.getSkillRoots !== 'function') continue;
|
|
3183
|
-
for (const root of runtime.getSkillRoots({ homeDir })) {
|
|
3184
|
-
if (root && root.dir) files.push(root.dir);
|
|
3185
|
-
}
|
|
3186
|
-
for (const project of projects) {
|
|
3187
|
-
if (!project || !project.localPath) continue;
|
|
3188
|
-
for (const root of runtime.getSkillRoots({ homeDir, project })) {
|
|
3189
|
-
if (root && root.dir) files.push(root.dir);
|
|
3190
|
-
}
|
|
3191
|
-
}
|
|
3192
|
-
}
|
|
3193
|
-
} catch { /* runtime registry optional in partial installs */ }
|
|
3194
|
-
|
|
3195
|
-
// Plugin skill registries (also feed collectSkillFiles).
|
|
3196
|
-
files.push(path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json'));
|
|
3197
|
-
files.push(path.join(homeDir, '.copilot', 'installed-plugins'));
|
|
3198
|
-
|
|
3199
|
-
// MCP server config files (surfaced by _buildStatusSlowState → getMcpServers).
|
|
3200
|
-
files.push(path.join(homeDir, '.claude.json'));
|
|
3201
|
-
files.push(path.join(homeDir, '.copilot', 'mcp-config.json'));
|
|
3202
|
-
for (const project of projects) {
|
|
3203
|
-
if (project && project.localPath) {
|
|
3204
|
-
files.push(path.join(project.localPath, '.mcp.json'));
|
|
3205
|
-
}
|
|
3206
|
-
}
|
|
3207
|
-
|
|
3208
3178
|
// Per-project git refs — see the "Per-project .git/logs/HEAD" note in
|
|
3209
3179
|
// the header. Same pair the fast-state tracker watches; `logs/HEAD` is
|
|
3210
3180
|
// per-worktree (resolved via `_resolveGitDir`) while `FETCH_HEAD` lives
|
|
@@ -3230,6 +3200,34 @@ function getStatusSlowStateMtimePaths(config) {
|
|
|
3230
3200
|
return files;
|
|
3231
3201
|
}
|
|
3232
3202
|
|
|
3203
|
+
async function getStatusSlowStateMtimePathsAsync(config) {
|
|
3204
|
+
config = config || getConfig();
|
|
3205
|
+
const cacheKey = _mtimePathsCacheKey(config);
|
|
3206
|
+
if (_slowMtimePathsAsyncCache && _slowMtimePathsAsyncCacheKey === cacheKey) {
|
|
3207
|
+
return _slowMtimePathsAsyncCache;
|
|
3208
|
+
}
|
|
3209
|
+
const files = [
|
|
3210
|
+
CONFIG_PATH,
|
|
3211
|
+
path.join(MINIONS_DIR, '.install-id'),
|
|
3212
|
+
path.join(MINIONS_DIR, 'package.json'),
|
|
3213
|
+
];
|
|
3214
|
+
const projectPaths = await Promise.all(getProjects(config).map(async project => {
|
|
3215
|
+
if (!project?.localPath) return [];
|
|
3216
|
+
const gitDir = await _resolveGitDirAsync(project.localPath)
|
|
3217
|
+
|| path.join(project.localPath, '.git');
|
|
3218
|
+
const commonGitDir = await _resolveCommonGitDirAsync(gitDir);
|
|
3219
|
+
return [
|
|
3220
|
+
path.join(gitDir, 'HEAD'),
|
|
3221
|
+
path.join(gitDir, 'logs', 'HEAD'),
|
|
3222
|
+
path.join(commonGitDir, 'FETCH_HEAD'),
|
|
3223
|
+
];
|
|
3224
|
+
}));
|
|
3225
|
+
for (const paths of projectPaths) files.push(...paths);
|
|
3226
|
+
_slowMtimePathsAsyncCache = files;
|
|
3227
|
+
_slowMtimePathsAsyncCacheKey = cacheKey;
|
|
3228
|
+
return files;
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3233
3231
|
// ── Exports ─────────────────────────────────────────────────────────────────
|
|
3234
3232
|
|
|
3235
3233
|
module.exports = {
|
|
@@ -3255,6 +3253,7 @@ module.exports = {
|
|
|
3255
3253
|
// W-mpftp7na000td0f4 — engine→dashboard cache-invalidation registry
|
|
3256
3254
|
getStatusFastStateMtimePaths,
|
|
3257
3255
|
getStatusSlowStateMtimePaths,
|
|
3256
|
+
getStatusSlowStateMtimePathsAsync,
|
|
3258
3257
|
|
|
3259
3258
|
// Core state
|
|
3260
3259
|
getConfig, getControl, getDispatch, getDispatchQueue, getDispatchCompletionReport, invalidateDispatchCache,
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const shared = require('./shared');
|
|
7
|
+
const queries = require('./queries');
|
|
8
|
+
|
|
9
|
+
function readClaudeUserMcpServers(homeDir = os.homedir()) {
|
|
10
|
+
try {
|
|
11
|
+
const data = shared.safeJsonObj(path.join(homeDir, '.claude.json'));
|
|
12
|
+
const servers = data?.mcpServers || {};
|
|
13
|
+
return Object.entries(servers).map(([name, cfg]) => ({
|
|
14
|
+
name,
|
|
15
|
+
source: 'Claude user',
|
|
16
|
+
command: cfg.command || cfg.url || '',
|
|
17
|
+
args: Array.isArray(cfg.args) ? cfg.args.join(' ') : (cfg.args || ''),
|
|
18
|
+
}));
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readCopilotUserMcpServers(homeDir = os.homedir()) {
|
|
25
|
+
try {
|
|
26
|
+
const configPath = path.join(homeDir, '.copilot', 'mcp-config.json');
|
|
27
|
+
if (!fs.existsSync(configPath)) return [];
|
|
28
|
+
let raw = fs.readFileSync(configPath, 'utf8');
|
|
29
|
+
if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
|
|
30
|
+
const data = JSON.parse(raw);
|
|
31
|
+
const servers = data?.mcpServers || {};
|
|
32
|
+
return Object.entries(servers).map(([name, cfg]) => ({
|
|
33
|
+
name,
|
|
34
|
+
source: 'Copilot',
|
|
35
|
+
command: cfg.command || cfg.url || '',
|
|
36
|
+
args: Array.isArray(cfg.args) ? cfg.args.join(' ') : (cfg.args || ''),
|
|
37
|
+
}));
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readWorkspaceMcpServers(projects) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const project of projects || []) {
|
|
46
|
+
if (!project?.localPath) continue;
|
|
47
|
+
const data = shared.safeJsonObj(path.join(project.localPath, '.mcp.json'));
|
|
48
|
+
for (const [name, cfg] of Object.entries(data.mcpServers || {})) {
|
|
49
|
+
out.push({
|
|
50
|
+
name,
|
|
51
|
+
source: `Workspace: ${project.name}`,
|
|
52
|
+
command: cfg.command || cfg.url || '',
|
|
53
|
+
args: Array.isArray(cfg.args) ? cfg.args.join(' ') : (cfg.args || ''),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function dedupeMcpServers(entries) {
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
const key = `${entry.source}::${entry.name}`;
|
|
65
|
+
if (seen.has(key)) continue;
|
|
66
|
+
seen.add(key);
|
|
67
|
+
out.push(entry);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getMcpServers(projects, homeDir = os.homedir()) {
|
|
73
|
+
return dedupeMcpServers([
|
|
74
|
+
...readClaudeUserMcpServers(homeDir),
|
|
75
|
+
...readCopilotUserMcpServers(homeDir),
|
|
76
|
+
...readWorkspaceMcpServers(projects),
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildToolsInventory(config = queries.getConfig()) {
|
|
81
|
+
return {
|
|
82
|
+
skills: queries.getSkills(config),
|
|
83
|
+
commands: queries.getCommands(config),
|
|
84
|
+
mcpServers: getMcpServers(shared.getProjects(config)),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (require.main === module) {
|
|
89
|
+
try {
|
|
90
|
+
process.stdout.write(JSON.stringify(buildToolsInventory()));
|
|
91
|
+
} catch (error) {
|
|
92
|
+
process.stderr.write(`tools-inventory: ${error.message}\n`);
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
buildToolsInventory,
|
|
99
|
+
getMcpServers,
|
|
100
|
+
readClaudeUserMcpServers,
|
|
101
|
+
readCopilotUserMcpServers,
|
|
102
|
+
readWorkspaceMcpServers,
|
|
103
|
+
dedupeMcpServers,
|
|
104
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2403",
|
|
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"
|