@yemi33/minions 0.1.1774 → 0.1.1776
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/CHANGELOG.md +5 -0
- package/bin/minions.js +3 -21
- package/dashboard.js +132 -22
- package/engine/cleanup.js +27 -2
- package/engine/copilot-models.json +1 -1
- package/engine/shared.js +97 -1
- package/engine.js +17 -6
- package/package.json +1 -1
- package/prompts/cc-system.md +2 -2
package/CHANGELOG.md
CHANGED
package/bin/minions.js
CHANGED
|
@@ -39,6 +39,7 @@ const os = require('os');
|
|
|
39
39
|
const { spawn, spawnSync, execSync } = require('child_process');
|
|
40
40
|
|
|
41
41
|
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
42
|
+
const shared = require(path.join(PKG_ROOT, 'engine', 'shared'));
|
|
42
43
|
const DASH_PORT = 7331;
|
|
43
44
|
const DASHBOARD_BROWSER_PRESENCE_MAX_AGE_MS = 45000;
|
|
44
45
|
|
|
@@ -152,8 +153,6 @@ function spawnDashboard(suppressOpen) {
|
|
|
152
153
|
return proc;
|
|
153
154
|
}
|
|
154
155
|
|
|
155
|
-
const DEFAULT_MINIONS_HOME = path.join(os.homedir(), '.minions');
|
|
156
|
-
const ROOT_POINTER_PATH = path.join(os.homedir(), '.minions-root');
|
|
157
156
|
const LEGACY_DEFAULT_SQUAD_HOME = path.join(os.homedir(), '.squad');
|
|
158
157
|
const LEGACY_ROOT_POINTER_PATH = path.join(os.homedir(), '.squad-root');
|
|
159
158
|
|
|
@@ -171,13 +170,6 @@ function isLegacyInstalledRoot(dir) {
|
|
|
171
170
|
fs.existsSync(path.join(dir, 'squad.js'));
|
|
172
171
|
}
|
|
173
172
|
|
|
174
|
-
function readRootPointer() {
|
|
175
|
-
try {
|
|
176
|
-
const p = fs.readFileSync(ROOT_POINTER_PATH, 'utf8').trim();
|
|
177
|
-
return p ? path.resolve(p) : null;
|
|
178
|
-
} catch { return null; }
|
|
179
|
-
}
|
|
180
|
-
|
|
181
173
|
function readLegacyRootPointer() {
|
|
182
174
|
try {
|
|
183
175
|
const p = fs.readFileSync(LEGACY_ROOT_POINTER_PATH, 'utf8').trim();
|
|
@@ -186,7 +178,7 @@ function readLegacyRootPointer() {
|
|
|
186
178
|
}
|
|
187
179
|
|
|
188
180
|
function saveRootPointer(root) {
|
|
189
|
-
|
|
181
|
+
shared.saveMinionsRootPointer(root);
|
|
190
182
|
}
|
|
191
183
|
|
|
192
184
|
function findLegacySquadRoot() {
|
|
@@ -254,17 +246,7 @@ function migrateLegacyInstallIfNeeded(targetHome) {
|
|
|
254
246
|
}
|
|
255
247
|
|
|
256
248
|
function resolveMinionsHome(forInit = false) {
|
|
257
|
-
|
|
258
|
-
if (envHome) return envHome;
|
|
259
|
-
|
|
260
|
-
if (forInit) return DEFAULT_MINIONS_HOME;
|
|
261
|
-
|
|
262
|
-
const pointerRoot = readRootPointer();
|
|
263
|
-
if (isInstalledRoot(pointerRoot)) return pointerRoot;
|
|
264
|
-
|
|
265
|
-
if (isInstalledRoot(DEFAULT_MINIONS_HOME)) return DEFAULT_MINIONS_HOME;
|
|
266
|
-
|
|
267
|
-
return DEFAULT_MINIONS_HOME;
|
|
249
|
+
return shared.resolveMinionsHome(forInit);
|
|
268
250
|
}
|
|
269
251
|
|
|
270
252
|
const [cmd, ...rest] = process.argv.slice(2);
|
package/dashboard.js
CHANGED
|
@@ -1334,13 +1334,116 @@ const PREAMBLE_TTL = 30000; // 30s — longer TTL since preamble is lightweight
|
|
|
1334
1334
|
// captures no-op via the truthy guard.
|
|
1335
1335
|
let _ccApiRoutesMeta = null;
|
|
1336
1336
|
|
|
1337
|
+
function _routePathForMeta(route) {
|
|
1338
|
+
if (!route) return '';
|
|
1339
|
+
if (typeof route.path === 'string') return route.path;
|
|
1340
|
+
return route.template || route.pathTemplate || String(route.path);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function _parseRouteParamHint(paramHint, method) {
|
|
1344
|
+
if (!paramHint || typeof paramHint !== 'string') return [];
|
|
1345
|
+
const paramLocation = ['GET', 'HEAD', 'DELETE'].includes(String(method || '').toUpperCase()) ? 'query' : 'body';
|
|
1346
|
+
const parts = [];
|
|
1347
|
+
let current = '';
|
|
1348
|
+
let parenDepth = 0;
|
|
1349
|
+
for (const ch of paramHint) {
|
|
1350
|
+
if (ch === '(') parenDepth++;
|
|
1351
|
+
if (ch === ')' && parenDepth > 0) parenDepth--;
|
|
1352
|
+
if (ch === ',' && parenDepth === 0) {
|
|
1353
|
+
parts.push(current);
|
|
1354
|
+
current = '';
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
current += ch;
|
|
1358
|
+
}
|
|
1359
|
+
if (current) parts.push(current);
|
|
1360
|
+
return parts
|
|
1361
|
+
.map(part => {
|
|
1362
|
+
const raw = part.trim();
|
|
1363
|
+
if (!raw) return null;
|
|
1364
|
+
let text = raw;
|
|
1365
|
+
let description = '';
|
|
1366
|
+
text = text.replace(/\(([^)]*)\)/g, (_, desc) => {
|
|
1367
|
+
description = desc.trim();
|
|
1368
|
+
return '';
|
|
1369
|
+
}).trim();
|
|
1370
|
+
const required = !text.includes('?');
|
|
1371
|
+
const array = text.includes('[]');
|
|
1372
|
+
const name = text
|
|
1373
|
+
.replace(/\?/g, '')
|
|
1374
|
+
.replace(/\[\]/g, '')
|
|
1375
|
+
.trim()
|
|
1376
|
+
.split(/\s+/)[0];
|
|
1377
|
+
if (!name) return null;
|
|
1378
|
+
return {
|
|
1379
|
+
name,
|
|
1380
|
+
in: paramLocation,
|
|
1381
|
+
required,
|
|
1382
|
+
...(description ? { description } : {}),
|
|
1383
|
+
...(array ? { type: 'array' } : {}),
|
|
1384
|
+
};
|
|
1385
|
+
})
|
|
1386
|
+
.filter(Boolean);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function _routePathParams(pathTemplate) {
|
|
1390
|
+
if (!pathTemplate || typeof pathTemplate !== 'string') return [];
|
|
1391
|
+
const params = [];
|
|
1392
|
+
for (const match of pathTemplate.matchAll(/:([A-Za-z][A-Za-z0-9_]*)/g)) {
|
|
1393
|
+
params.push({ name: match[1], in: 'path', required: true });
|
|
1394
|
+
}
|
|
1395
|
+
return params;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function _routeParametersForMeta(route, pathTemplate) {
|
|
1399
|
+
const seen = new Set();
|
|
1400
|
+
const params = [..._routePathParams(pathTemplate), ..._parseRouteParamHint(route.params, route.method)];
|
|
1401
|
+
return params.filter(param => {
|
|
1402
|
+
const key = `${param.in}:${param.name}`;
|
|
1403
|
+
if (seen.has(key)) return false;
|
|
1404
|
+
seen.add(key);
|
|
1405
|
+
return true;
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function _routeReadOnly(route) {
|
|
1410
|
+
if (typeof route.readOnly === 'boolean') return route.readOnly;
|
|
1411
|
+
return ['GET', 'HEAD', 'OPTIONS'].includes(String(route.method || '').toUpperCase());
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function _routeDestructive(route, pathTemplate, readOnly) {
|
|
1415
|
+
if (typeof route.destructive === 'boolean') return route.destructive;
|
|
1416
|
+
if (readOnly) return false;
|
|
1417
|
+
if (String(route.method || '').toUpperCase() === 'DELETE') return true;
|
|
1418
|
+
const haystack = `${pathTemplate || ''} ${route.desc || ''}`.toLowerCase();
|
|
1419
|
+
return /\b(delete|remove|cancel|kill|reset|restart|archive|unarchive|abort|purge|reject|pause|unlink|clear)\b/.test(haystack);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
function _routeGenericFallback(route, pathTemplate) {
|
|
1423
|
+
if (typeof route.genericFallback === 'boolean') return route.genericFallback;
|
|
1424
|
+
if (!pathTemplate || !pathTemplate.startsWith('/api/')) return false;
|
|
1425
|
+
if (String(route.method || '').toUpperCase() !== 'POST') return false;
|
|
1426
|
+
const haystack = `${pathTemplate} ${route.desc || ''}`.toLowerCase();
|
|
1427
|
+
if (haystack.includes('sse') || /\bstreaming\b/.test(haystack) || /\/stream(?:$|[/?#])/.test(pathTemplate)) return false;
|
|
1428
|
+
return true;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1337
1431
|
function _routesAsMeta(routes) {
|
|
1338
|
-
return routes.map(r =>
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1432
|
+
return routes.map(r => {
|
|
1433
|
+
const pathTemplate = _routePathForMeta(r);
|
|
1434
|
+
const readOnly = _routeReadOnly(r);
|
|
1435
|
+
const destructive = _routeDestructive(r, pathTemplate, readOnly);
|
|
1436
|
+
return {
|
|
1437
|
+
method: r.method,
|
|
1438
|
+
path: pathTemplate,
|
|
1439
|
+
desc: r.desc || '',
|
|
1440
|
+
params: r.params || null,
|
|
1441
|
+
parameters: _routeParametersForMeta(r, pathTemplate),
|
|
1442
|
+
readOnly,
|
|
1443
|
+
destructive,
|
|
1444
|
+
genericFallback: _routeGenericFallback(r, pathTemplate),
|
|
1445
|
+
};
|
|
1446
|
+
});
|
|
1344
1447
|
}
|
|
1345
1448
|
|
|
1346
1449
|
function _captureApiRoutesMeta(routes) {
|
|
@@ -1354,7 +1457,13 @@ function _formatCcApiRoutesIndex() {
|
|
|
1354
1457
|
.filter(r => r.path.startsWith('/api/'))
|
|
1355
1458
|
.map(r => {
|
|
1356
1459
|
const params = r.params ? ` — params: ${r.params}` : '';
|
|
1357
|
-
|
|
1460
|
+
const flags = [
|
|
1461
|
+
r.readOnly ? 'read-only' : null,
|
|
1462
|
+
r.destructive ? 'destructive' : null,
|
|
1463
|
+
r.genericFallback ? 'generic-fallback' : null,
|
|
1464
|
+
].filter(Boolean);
|
|
1465
|
+
const flagText = flags.length ? ` — flags: ${flags.join(', ')}` : '';
|
|
1466
|
+
return `- \`${r.method} ${r.path}\` — ${r.desc}${params}${flagText}`;
|
|
1358
1467
|
})
|
|
1359
1468
|
.join('\n');
|
|
1360
1469
|
}
|
|
@@ -1405,7 +1514,7 @@ ${apiIndex || '(routes not yet captured — first request still pending)'}
|
|
|
1405
1514
|
### CLI Index (auto-generated from engine/cli.js CLI_COMMAND_DOCS — single source of truth)
|
|
1406
1515
|
${cliIndex || '(unavailable)'}
|
|
1407
1516
|
|
|
1408
|
-
For
|
|
1517
|
+
For \`POST /api/...\` endpoints marked \`generic-fallback\` and not covered by a named CC action, use the generic fallback:
|
|
1409
1518
|
\`{"type":"<descriptive>","endpoint":"/api/...","params":{...}}\`.` : '';
|
|
1410
1519
|
|
|
1411
1520
|
const result = `### Agents
|
|
@@ -7152,8 +7261,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7152
7261
|
{ method: 'POST', path: '/api/plans/unarchive', desc: 'Restore a plan/PRD from archive', params: 'file', handler: handlePlansUnarchive },
|
|
7153
7262
|
{ method: 'POST', path: '/api/plans/revise', desc: 'Request revision with feedback, dispatches agent to revise', params: 'file, feedback, requestedBy?', handler: handlePlansRevise },
|
|
7154
7263
|
{ method: 'POST', path: '/api/plans/discuss', desc: 'Generate a plan discussion session script for Claude CLI', params: 'file', handler: handlePlansDiscuss },
|
|
7155
|
-
{ method: 'GET', path: /^\/api\/plans\/archive\/([^?]+)$/, desc: 'Read an archived plan file', handler: handlePlansArchiveRead },
|
|
7156
|
-
{ method: 'GET', path: /^\/api\/plans\/([^?]+)$/, desc: 'Read a full plan (JSON from prd/ or markdown from plans/)', handler: handlePlansRead },
|
|
7264
|
+
{ method: 'GET', path: /^\/api\/plans\/archive\/([^?]+)$/, template: '/api/plans/archive/:file', desc: 'Read an archived plan file', handler: handlePlansArchiveRead },
|
|
7265
|
+
{ method: 'GET', path: /^\/api\/plans\/([^?]+)$/, template: '/api/plans/:file', desc: 'Read a full plan (JSON from prd/ or markdown from plans/)', handler: handlePlansRead },
|
|
7157
7266
|
|
|
7158
7267
|
// PRD items
|
|
7159
7268
|
{ method: 'POST', path: '/api/prd-items', desc: 'Create a PRD item as a plan file in prd/ (auto-approved)', params: 'name, description?, priority?, estimated_complexity?, project?, id?', handler: handlePrdItemsCreate },
|
|
@@ -7325,13 +7434,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7325
7434
|
});
|
|
7326
7435
|
}},
|
|
7327
7436
|
{ method: 'POST', path: '/api/agents/cancel', desc: 'Cancel an active agent by ID or task substring', params: 'agent?, task?', handler: handleAgentsCancel },
|
|
7328
|
-
{ method: 'POST', path: /^\/api\/agent\/([\w-]+)\/kill$/, desc: 'Kill a running agent: stop process, clear dispatch, reset work items to pending', handler: handleAgentKill },
|
|
7329
|
-
{ method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live-stream(?:\?.*)?$/, desc: 'SSE real-time live output streaming', handler: handleAgentLiveStream },
|
|
7330
|
-
{ method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live(?:\?.*)?$/, desc: 'Tail live output for a working agent', params: 'tail? (bytes, default 8192)', handler: handleAgentLive },
|
|
7331
|
-
{ method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live-output(?:\?.*)?$/, desc: 'Tail live output for a working agent (alias for /live)', params: 'tail? (bytes, default 8192)', handler: handleAgentLive },
|
|
7332
|
-
{ method: 'GET', path: /^\/api\/agent\/([\w-]+)\/output(?:\?.*)?$/, desc: 'Fetch final output.log for an agent', handler: handleAgentOutput },
|
|
7333
|
-
{ method: 'GET', path: /^\/api\/agent\/([\w-]+)$/, desc: 'Get detailed agent info', handler: handleAgentDetail },
|
|
7334
|
-
{ method: 'GET', path: /^\/api\/dispatch\/([\w.-]+)\/completion-report$/, desc: 'Read structured completion report for a dispatch', handler: (req, res, match) => {
|
|
7437
|
+
{ method: 'POST', path: /^\/api\/agent\/([\w-]+)\/kill$/, template: '/api/agent/:id/kill', desc: 'Kill a running agent: stop process, clear dispatch, reset work items to pending', handler: handleAgentKill },
|
|
7438
|
+
{ method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live-stream(?:\?.*)?$/, template: '/api/agent/:id/live-stream', desc: 'SSE real-time live output streaming', handler: handleAgentLiveStream },
|
|
7439
|
+
{ method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live(?:\?.*)?$/, template: '/api/agent/:id/live', desc: 'Tail live output for a working agent', params: 'tail? (bytes, default 8192)', handler: handleAgentLive },
|
|
7440
|
+
{ method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live-output(?:\?.*)?$/, template: '/api/agent/:id/live-output', desc: 'Tail live output for a working agent (alias for /live)', params: 'tail? (bytes, default 8192)', handler: handleAgentLive },
|
|
7441
|
+
{ method: 'GET', path: /^\/api\/agent\/([\w-]+)\/output(?:\?.*)?$/, template: '/api/agent/:id/output', desc: 'Fetch final output.log for an agent', handler: handleAgentOutput },
|
|
7442
|
+
{ method: 'GET', path: /^\/api\/agent\/([\w-]+)$/, template: '/api/agent/:id', desc: 'Get detailed agent info', handler: handleAgentDetail },
|
|
7443
|
+
{ method: 'GET', path: /^\/api\/dispatch\/([\w.-]+)\/completion-report$/, template: '/api/dispatch/:id/completion-report', desc: 'Read structured completion report for a dispatch', handler: (req, res, match) => {
|
|
7335
7444
|
const id = match && match[1];
|
|
7336
7445
|
const payload = queries.getDispatchCompletionReport(id);
|
|
7337
7446
|
if (!payload) return jsonReply(res, 404, { error: 'completion report not found' }, req);
|
|
@@ -7367,7 +7476,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7367
7476
|
}},
|
|
7368
7477
|
{ method: 'POST', path: '/api/knowledge/sweep', desc: 'Trigger async KB sweep (returns 202)', handler: handleKnowledgeSweep },
|
|
7369
7478
|
{ method: 'GET', path: '/api/knowledge/sweep/status', desc: 'Poll KB sweep status', handler: handleKnowledgeSweepStatus },
|
|
7370
|
-
{ method: 'GET', path: /^\/api\/knowledge\/([^/]+)\/([^?]+)/, desc: 'Read a specific knowledge base entry', handler: handleKnowledgeRead },
|
|
7479
|
+
{ method: 'GET', path: /^\/api\/knowledge\/([^/]+)\/([^?]+)/, template: '/api/knowledge/:category/:file', desc: 'Read a specific knowledge base entry', handler: handleKnowledgeRead },
|
|
7371
7480
|
|
|
7372
7481
|
// Doc chat
|
|
7373
7482
|
{ method: 'POST', path: '/api/doc-chat', desc: 'Minions-aware doc Q&A + editing via CC session', params: 'message, document, title?, filePath?, selection?, contentHash?', handler: handleDocChat },
|
|
@@ -7398,7 +7507,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7398
7507
|
{ method: 'POST', path: '/api/command-center', desc: 'Conversational command center with full minions context', params: 'message, sessionId?', handler: handleCommandCenter },
|
|
7399
7508
|
{ method: 'POST', path: '/api/command-center/stream', desc: 'Streaming CC — SSE with text chunks as they arrive', params: 'message, tabId?', handler: handleCommandCenterStream },
|
|
7400
7509
|
{ method: 'GET', path: '/api/cc-sessions', desc: 'List CC session metadata for all tabs', handler: handleCCSessionsList },
|
|
7401
|
-
{ method: 'DELETE', path: /^\/api\/cc-sessions\/([\w-]+)$/, desc: 'Delete a CC session by tab ID', handler: handleCCSessionDelete },
|
|
7510
|
+
{ method: 'DELETE', path: /^\/api\/cc-sessions\/([\w-]+)$/, template: '/api/cc-sessions/:id', desc: 'Delete a CC session by tab ID', handler: handleCCSessionDelete },
|
|
7402
7511
|
|
|
7403
7512
|
// Schedules
|
|
7404
7513
|
{ method: 'POST', path: '/api/schedules/parse-natural', desc: 'Parse natural language schedule text into cron expression', params: 'text', handler: handleSchedulesParseNatural },
|
|
@@ -7544,7 +7653,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7544
7653
|
return jsonReply(res, 200, { meetings: getMeetings() });
|
|
7545
7654
|
}},
|
|
7546
7655
|
|
|
7547
|
-
{ method: 'GET', path: /^\/api\/meetings\/(MTG-[\w]+)$/, desc: 'Get meeting detail', handler: async (req, res, match) => {
|
|
7656
|
+
{ method: 'GET', path: /^\/api\/meetings\/(MTG-[\w]+)$/, template: '/api/meetings/:id', desc: 'Get meeting detail', handler: async (req, res, match) => {
|
|
7548
7657
|
const { getMeeting } = require('./engine/meeting');
|
|
7549
7658
|
const meeting = getMeeting(match[1]);
|
|
7550
7659
|
if (!meeting) return jsonReply(res, 404, { error: 'Meeting not found' });
|
|
@@ -7619,7 +7728,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7619
7728
|
const md = require('./engine/model-discovery');
|
|
7620
7729
|
return jsonReply(res, 200, { runtimes: md.listAllRuntimes() }, req);
|
|
7621
7730
|
}},
|
|
7622
|
-
{ method: 'POST', path: /^\/api\/runtimes\/([\w-]+)\/models\/refresh$/, desc: 'Invalidate the models cache for a runtime and re-fetch', handler: async (req, res, match) => {
|
|
7731
|
+
{ method: 'POST', path: /^\/api\/runtimes\/([\w-]+)\/models\/refresh$/, template: '/api/runtimes/:runtime/models/refresh', desc: 'Invalidate the models cache for a runtime and re-fetch', handler: async (req, res, match) => {
|
|
7623
7732
|
const md = require('./engine/model-discovery');
|
|
7624
7733
|
const name = match[1];
|
|
7625
7734
|
try {
|
|
@@ -7638,7 +7747,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7638
7747
|
}
|
|
7639
7748
|
return jsonReply(res, 200, payload, req);
|
|
7640
7749
|
}},
|
|
7641
|
-
{ method: 'GET', path: /^\/api\/runtimes\/([\w-]+)\/models$/, desc: 'Get cached or fresh model list for a runtime', handler: async (req, res, match) => {
|
|
7750
|
+
{ method: 'GET', path: /^\/api\/runtimes\/([\w-]+)\/models$/, template: '/api/runtimes/:runtime/models', desc: 'Get cached or fresh model list for a runtime', handler: async (req, res, match) => {
|
|
7642
7751
|
const md = require('./engine/model-discovery');
|
|
7643
7752
|
const name = match[1];
|
|
7644
7753
|
let payload;
|
|
@@ -7798,6 +7907,7 @@ module.exports = {
|
|
|
7798
7907
|
_createPipelineFromAction: createPipelineFromAction,
|
|
7799
7908
|
executeCCActions,
|
|
7800
7909
|
buildCCStatePreamble,
|
|
7910
|
+
_routesAsMeta,
|
|
7801
7911
|
_buildTranscriptCarryover,
|
|
7802
7912
|
_ccRuntimeNeedsResumeCarryover,
|
|
7803
7913
|
_joinCcPromptParts,
|
package/engine/cleanup.js
CHANGED
|
@@ -366,6 +366,31 @@ async function runCleanup(config, verbose = false) {
|
|
|
366
366
|
}
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
// 2b. Detect git worktrees registered inside any linked project's working tree.
|
|
370
|
+
// Nested worktrees cause glob/grep tools running with cwd=projectRoot to match
|
|
371
|
+
// BOTH copies of every file; a single Edit/MultiEdit then writes the same
|
|
372
|
+
// change to both locations, producing "mirror dirty file" leaks (W-cc-doc-chat-continuity).
|
|
373
|
+
// We only WARN here — removing someone else's worktree without consent could
|
|
374
|
+
// destroy in-flight work. The operator runs `git worktree remove <path>`.
|
|
375
|
+
cleaned.nestedWorktrees = 0;
|
|
376
|
+
for (const project of projects) {
|
|
377
|
+
const root = project.localPath ? path.resolve(project.localPath) : null;
|
|
378
|
+
if (!root || !fs.existsSync(root)) continue;
|
|
379
|
+
let raw;
|
|
380
|
+
try {
|
|
381
|
+
raw = String(shared.execSilent('git worktree list --porcelain', { cwd: root, timeout: 10000, windowsHide: true }) || '');
|
|
382
|
+
} catch (e) { log('warn', `nested-worktree scan for ${project.name || root}: ${e.message}`); continue; }
|
|
383
|
+
for (const line of raw.split('\n')) {
|
|
384
|
+
if (!line.startsWith('worktree ')) continue;
|
|
385
|
+
const wt = line.slice('worktree '.length).trim();
|
|
386
|
+
if (!wt) continue;
|
|
387
|
+
if (path.resolve(wt) === root) continue; // main worktree — expected
|
|
388
|
+
if (!shared.isPathInsideOrEqual(wt, root)) continue;
|
|
389
|
+
cleaned.nestedWorktrees++;
|
|
390
|
+
log('warn', `Nested worktree in project "${project.name || root}": "${wt}" is inside "${root}". This causes glob tools to match both copies and produces mirror writes. Run: git worktree remove "${wt}"`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
369
394
|
// 3. Clean git worktrees for merged/abandoned PRs
|
|
370
395
|
const _attemptedWorktreePaths = new Set(); // dedup across projects sharing a worktreeRoot
|
|
371
396
|
for (const project of projects) {
|
|
@@ -664,8 +689,8 @@ async function runCleanup(config, verbose = false) {
|
|
|
664
689
|
}
|
|
665
690
|
} catch (e) { log('warn', 'prune orphaned dispatches: ' + e.message); }
|
|
666
691
|
|
|
667
|
-
if (cleaned.tempFiles + cleaned.liveOutputs + cleaned.worktrees + cleaned.zombies + (cleaned.files || 0) + cleaned.orphanedDispatches > 0) {
|
|
668
|
-
log('info', `Cleanup: ${cleaned.tempFiles} temp, ${cleaned.liveOutputs} live outputs, ${cleaned.worktrees} worktrees, ${cleaned.zombies} zombies, ${cleaned.files || 0} archives, ${cleaned.orphanedDispatches} orphaned dispatches`);
|
|
692
|
+
if (cleaned.tempFiles + cleaned.liveOutputs + cleaned.worktrees + cleaned.zombies + (cleaned.files || 0) + cleaned.orphanedDispatches + (cleaned.nestedWorktrees || 0) > 0) {
|
|
693
|
+
log('info', `Cleanup: ${cleaned.tempFiles} temp, ${cleaned.liveOutputs} live outputs, ${cleaned.worktrees} worktrees, ${cleaned.zombies} zombies, ${cleaned.files || 0} archives, ${cleaned.orphanedDispatches} orphaned dispatches, ${cleaned.nestedWorktrees || 0} nested worktrees flagged`);
|
|
669
694
|
}
|
|
670
695
|
|
|
671
696
|
// 8. Clean swept KB files older than 7 days
|
package/engine/shared.js
CHANGED
|
@@ -5,9 +5,58 @@
|
|
|
5
5
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
8
9
|
const crypto = require('crypto');
|
|
9
10
|
|
|
10
|
-
const
|
|
11
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
12
|
+
const DEFAULT_MINIONS_HOME = path.join(os.homedir(), '.minions');
|
|
13
|
+
const ROOT_POINTER_PATH = path.join(os.homedir(), '.minions-root');
|
|
14
|
+
|
|
15
|
+
function isInstalledRoot(dir) {
|
|
16
|
+
if (!dir) return false;
|
|
17
|
+
return fs.existsSync(path.join(dir, 'engine.js')) &&
|
|
18
|
+
fs.existsSync(path.join(dir, 'dashboard.js')) &&
|
|
19
|
+
fs.existsSync(path.join(dir, 'minions.js'));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isSourceCheckoutRoot(dir) {
|
|
23
|
+
return !!dir && fs.existsSync(path.join(dir, '.git'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readMinionsRootPointer() {
|
|
27
|
+
try {
|
|
28
|
+
const p = fs.readFileSync(ROOT_POINTER_PATH, 'utf8').trim();
|
|
29
|
+
return p ? path.resolve(p) : null;
|
|
30
|
+
} catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function saveMinionsRootPointer(root) {
|
|
34
|
+
try { fs.writeFileSync(ROOT_POINTER_PATH, path.resolve(root)); } catch {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveMinionsHome(forInit = false, options = {}) {
|
|
38
|
+
const envHome = process.env.MINIONS_HOME ? path.resolve(process.env.MINIONS_HOME) : null;
|
|
39
|
+
if (envHome) return envHome;
|
|
40
|
+
|
|
41
|
+
if (forInit) return DEFAULT_MINIONS_HOME;
|
|
42
|
+
|
|
43
|
+
const packageRoot = options.packageRoot ? path.resolve(options.packageRoot) : PACKAGE_ROOT;
|
|
44
|
+
if (options.preferSourceCheckout && isSourceCheckoutRoot(packageRoot)) return packageRoot;
|
|
45
|
+
|
|
46
|
+
const pointerRoot = readMinionsRootPointer();
|
|
47
|
+
if (isInstalledRoot(pointerRoot)) return pointerRoot;
|
|
48
|
+
|
|
49
|
+
if (isInstalledRoot(DEFAULT_MINIONS_HOME)) return DEFAULT_MINIONS_HOME;
|
|
50
|
+
|
|
51
|
+
if (options.allowPackageRootFallback && isInstalledRoot(packageRoot)) return packageRoot;
|
|
52
|
+
|
|
53
|
+
return DEFAULT_MINIONS_HOME;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const MINIONS_DIR = process.env.MINIONS_TEST_DIR || resolveMinionsHome(false, {
|
|
57
|
+
allowPackageRootFallback: true,
|
|
58
|
+
preferSourceCheckout: true,
|
|
59
|
+
});
|
|
11
60
|
const ENGINE_DIR = path.join(MINIONS_DIR, 'engine');
|
|
12
61
|
const CONTROL_PATH = path.join(ENGINE_DIR, 'control.json');
|
|
13
62
|
const COOLDOWNS_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
|
|
@@ -2072,6 +2121,49 @@ function buildWorktreeDirName({
|
|
|
2072
2121
|
return `${projectSlug}-${sanitizeBranch(branchName || 'worktree')}-${suffix}`;
|
|
2073
2122
|
}
|
|
2074
2123
|
|
|
2124
|
+
/**
|
|
2125
|
+
* True when `childPath` is the same as or nested within `parentPath`. Uses
|
|
2126
|
+
* `path.relative` so it's cross-platform and resilient to mixed separators
|
|
2127
|
+
* (Windows worktree paths often arrive with forward slashes from git output).
|
|
2128
|
+
* Returns false when paths refer to different roots/drives or `childPath`
|
|
2129
|
+
* escapes via `..`.
|
|
2130
|
+
*
|
|
2131
|
+
* Why this helper exists: a git worktree placed inside the parent repo's
|
|
2132
|
+
* working tree causes glob/grep tools running with `cwd = projectRoot` to
|
|
2133
|
+
* match BOTH copies of every file. A single Edit/MultiEdit then writes the
|
|
2134
|
+
* same change to both locations, producing the "mirror dirty file" pattern.
|
|
2135
|
+
* Worktrees must always be siblings/cousins of the project root, never
|
|
2136
|
+
* descendants.
|
|
2137
|
+
*/
|
|
2138
|
+
function isPathInsideOrEqual(childPath, parentPath) {
|
|
2139
|
+
if (!childPath || !parentPath) return false;
|
|
2140
|
+
const childAbs = path.resolve(String(childPath));
|
|
2141
|
+
const parentAbs = path.resolve(String(parentPath));
|
|
2142
|
+
const rel = path.relative(parentAbs, childAbs);
|
|
2143
|
+
if (rel === '') return true;
|
|
2144
|
+
if (rel.startsWith('..')) return false;
|
|
2145
|
+
if (path.isAbsolute(rel)) return false;
|
|
2146
|
+
return true;
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
/**
|
|
2150
|
+
* Throws when `worktreePath` would land inside (or equal) `projectRoot`.
|
|
2151
|
+
* Called by the engine spawn path before `git worktree add`, and by the
|
|
2152
|
+
* cleanup sweep that audits already-registered worktrees per linked project.
|
|
2153
|
+
* The thrown Error has `code: 'WORKTREE_NESTED_IN_PROJECT'` so callers can
|
|
2154
|
+
* branch on it without parsing the message.
|
|
2155
|
+
*/
|
|
2156
|
+
function assertWorktreeOutsideProject(worktreePath, projectRoot) {
|
|
2157
|
+
if (!isPathInsideOrEqual(worktreePath, projectRoot)) return;
|
|
2158
|
+
const err = new Error(
|
|
2159
|
+
`Refusing to use worktree path "${worktreePath}" — it is inside project root "${projectRoot}". ` +
|
|
2160
|
+
`A worktree nested in its parent project causes glob/grep tools to match both copies and ` +
|
|
2161
|
+
`produces "mirror" dirty files. Place worktrees outside the project (engine.worktreeRoot default: "../worktrees").`
|
|
2162
|
+
);
|
|
2163
|
+
err.code = 'WORKTREE_NESTED_IN_PROJECT';
|
|
2164
|
+
throw err;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2075
2167
|
// ── HTTP Origin Allowlist & Security Headers ─────────────────────────────────
|
|
2076
2168
|
// Pure helpers used by dashboard.js to gate mutating requests against an
|
|
2077
2169
|
// explicit allowlist of local origins and to attach uniform security response
|
|
@@ -3055,6 +3147,8 @@ module.exports = {
|
|
|
3055
3147
|
safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore,
|
|
3056
3148
|
safeWrite,
|
|
3057
3149
|
safeUnlink,
|
|
3150
|
+
resolveMinionsHome,
|
|
3151
|
+
saveMinionsRootPointer,
|
|
3058
3152
|
neutralizeJsonBackupSidecar,
|
|
3059
3153
|
PROMPT_CONTEXTS_DIR,
|
|
3060
3154
|
dispatchPromptSidecarPath,
|
|
@@ -3141,6 +3235,8 @@ module.exports = {
|
|
|
3141
3235
|
sanitizePath,
|
|
3142
3236
|
sanitizeBranch,
|
|
3143
3237
|
buildWorktreeDirName, // exported for testing
|
|
3238
|
+
isPathInsideOrEqual,
|
|
3239
|
+
assertWorktreeOutsideProject,
|
|
3144
3240
|
isLiveCommandCenterPath,
|
|
3145
3241
|
describeCcProtectedPaths,
|
|
3146
3242
|
renderCcSystemPrompt,
|
package/engine.js
CHANGED
|
@@ -625,10 +625,18 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
625
625
|
branchName,
|
|
626
626
|
});
|
|
627
627
|
worktreePath = path.resolve(rootDir, engineConfig.worktreeRoot || '../worktrees', wtDirName);
|
|
628
|
+
// Refuse to spawn into a worktree path that's inside the project root —
|
|
629
|
+
// nested worktrees cause glob/grep to match both copies (mirror writes).
|
|
630
|
+
// Throws on violation; caught by the outer try/catch which fails dispatch.
|
|
631
|
+
shared.assertWorktreeOutsideProject(worktreePath, rootDir);
|
|
628
632
|
|
|
629
633
|
// If branch is already checked out in an existing worktree, reuse it
|
|
630
634
|
const existingWt = await findExistingWorktree(rootDir, branchName);
|
|
631
635
|
if (existingWt) {
|
|
636
|
+
// Same guard for reuse — a previously-created bad worktree must not
|
|
637
|
+
// be silently reused either; the cleanup sweep flags these so the
|
|
638
|
+
// operator can remove them.
|
|
639
|
+
shared.assertWorktreeOutsideProject(existingWt, rootDir);
|
|
632
640
|
worktreePath = existingWt;
|
|
633
641
|
log('info', `Reusing existing worktree for ${branchName}: ${existingWt}`);
|
|
634
642
|
// Probe origin first — locally-created branches that were never pushed
|
|
@@ -3543,6 +3551,7 @@ function discoverCentralWorkItems(config) {
|
|
|
3543
3551
|
const items = safeJson(centralPath) || [];
|
|
3544
3552
|
const projects = getProjects(config);
|
|
3545
3553
|
const dispatchProjects = getCentralDispatchProjects(projects);
|
|
3554
|
+
const projectsByName = new Map(dispatchProjects.map(p => [p.name, p]));
|
|
3546
3555
|
const newWork = [];
|
|
3547
3556
|
// Collect mutations to apply atomically inside lock callback (avoids TOCTOU)
|
|
3548
3557
|
const mutations = new Map(); // item.id → { field: value, ... }
|
|
@@ -3673,6 +3682,8 @@ function discoverCentralWorkItems(config) {
|
|
|
3673
3682
|
const agentName = config.agents[agentId]?.name || agentId;
|
|
3674
3683
|
const agentRole = config.agents[agentId]?.role || 'Agent';
|
|
3675
3684
|
const firstProject = dispatchProjects[0];
|
|
3685
|
+
const requestedProjectName = typeof item.project === 'string' ? item.project : item.project?.name;
|
|
3686
|
+
const targetProject = (requestedProjectName && projectsByName.get(requestedProjectName)) || firstProject;
|
|
3676
3687
|
|
|
3677
3688
|
// Branch mutex: skip if target branch is locked by an active dispatch
|
|
3678
3689
|
const centralBranch = item.branch || item.featureBranch || `work/${item.id}`;
|
|
@@ -3683,7 +3694,7 @@ function discoverCentralWorkItems(config) {
|
|
|
3683
3694
|
}
|
|
3684
3695
|
|
|
3685
3696
|
const vars = {
|
|
3686
|
-
...buildBaseVars(agentId, config,
|
|
3697
|
+
...buildBaseVars(agentId, config, targetProject),
|
|
3687
3698
|
item_id: item.id,
|
|
3688
3699
|
item_name: item.title || item.id,
|
|
3689
3700
|
item_priority: item.priority || 'medium',
|
|
@@ -3694,11 +3705,11 @@ function discoverCentralWorkItems(config) {
|
|
|
3694
3705
|
work_type: workType,
|
|
3695
3706
|
additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
|
|
3696
3707
|
scope_section: buildProjectContext(dispatchProjects, null, false, agentName, agentRole),
|
|
3697
|
-
project_path:
|
|
3708
|
+
project_path: targetProject?.localPath || '',
|
|
3698
3709
|
branch_name: centralBranch,
|
|
3699
3710
|
};
|
|
3700
|
-
const centralWtPath =
|
|
3701
|
-
? path.resolve(
|
|
3711
|
+
const centralWtPath = targetProject?.localPath
|
|
3712
|
+
? path.resolve(targetProject.localPath, config.engine?.worktreeRoot || '../worktrees', centralBranch)
|
|
3702
3713
|
: '';
|
|
3703
3714
|
const cpResult = buildWorkItemDispatchVars(item, vars, config, {
|
|
3704
3715
|
worktreePath: centralWtPath || undefined,
|
|
@@ -3749,7 +3760,7 @@ function discoverCentralWorkItems(config) {
|
|
|
3749
3760
|
}
|
|
3750
3761
|
vars.plan_summary = (item.title || item.planFile).substring(0, 80);
|
|
3751
3762
|
vars.plan_file = item.planFile || '';
|
|
3752
|
-
vars.project_name_lower = (
|
|
3763
|
+
vars.project_name_lower = (targetProject?.name || 'project').toLowerCase();
|
|
3753
3764
|
// Default empty string so the {{existing_prd_json}} token always resolves —
|
|
3754
3765
|
// playbook treats empty as "no existing PRD, fresh run". Without this default
|
|
3755
3766
|
// the renderPlaybook pass logs an "unresolved template variables" warning
|
|
@@ -3804,7 +3815,7 @@ function discoverCentralWorkItems(config) {
|
|
|
3804
3815
|
agentRole,
|
|
3805
3816
|
task: item.title || item.description?.slice(0, 80) || item.id,
|
|
3806
3817
|
prompt,
|
|
3807
|
-
meta: { dispatchKey: key, source: 'central-work-item', item: { ...item, ...mutations.get(item.id) }, planFileName: item.planFile || mutations.get(item.id)?._planFileName || null, branch: item.branch || item.featureBranch || `work/${item.id}`, project: { name:
|
|
3818
|
+
meta: { dispatchKey: key, source: 'central-work-item', item: { ...item, ...mutations.get(item.id) }, planFileName: item.planFile || mutations.get(item.id)?._planFileName || null, branch: item.branch || item.featureBranch || `work/${item.id}`, project: { name: targetProject.name, localPath: targetProject.localPath } }
|
|
3808
3819
|
});
|
|
3809
3820
|
|
|
3810
3821
|
setCooldown(key);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1776",
|
|
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"
|
package/prompts/cc-system.md
CHANGED
|
@@ -159,8 +159,8 @@ Terms like schedules, pipelines, agents, inbox, work items, plans, PRD, PRs, dis
|
|
|
159
159
|
## API & CLI Index (auto-injected)
|
|
160
160
|
Your state preamble (delivered alongside this prompt at session start) carries an auto-generated **API Index** rendered from `dashboard.js` `ROUTES` and a **CLI Index** rendered from `engine/cli.js` `CLI_COMMAND_DOCS`. Both are single-source-of-truth — adding a new HTTP endpoint or CLI command auto-surfaces it in your preamble; do not memorize the named action shorthand list above as exhaustive.
|
|
161
161
|
|
|
162
|
-
For
|
|
162
|
+
For a `POST /api/...` endpoint marked `generic-fallback` in the API Index that doesn't have a matching named action above, emit the generic fallback shape:
|
|
163
163
|
`{"type":"<short-descriptor>","endpoint":"/api/...","params":{...}}`
|
|
164
|
-
The action runner
|
|
164
|
+
The action runner POSTs `params` as JSON; do not use the fallback for read-only GET routes, DELETE routes, or endpoints not marked generic-fallback.
|
|
165
165
|
|
|
166
166
|
For CLI commands (`minions <cmd>`), use Bash to invoke them when delegating would be heavier than just running the command.
|