@yemi33/minions 0.1.1779 → 0.1.1781

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 CHANGED
@@ -1,10 +1,18 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1779 (2026-05-07)
3
+ ## 0.1.1781 (2026-05-07)
4
+
5
+ ### Fixes
6
+ - plug Property-1 holes + consolidate isPathInside helper
7
+
8
+ ## 0.1.1780 (2026-05-07)
9
+
10
+ ### Features
11
+ - fix command center action parity (#2174)
12
+
13
+ ## 0.1.1778 (2026-05-07)
4
14
 
5
15
  ### Features
6
- - fix cc doc chat resume continuity (#2184)
7
- - consolidate CC dispatch action type (#2183)
8
16
  - harden dashboard state mutations (#2175)
9
17
 
10
18
  ## 0.1.1777 (2026-05-07)
@@ -980,8 +980,25 @@ function ccRetryLast(tabId, retryId) {
980
980
  });
981
981
  }
982
982
 
983
- async function _ccFetch(url, body) {
984
- var res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
983
+ async function _ccFetch(url, body, method) {
984
+ method = (method || 'POST').toUpperCase();
985
+ var fetchUrl = url;
986
+ var opts = { method: method, headers: { 'Content-Type': 'application/json' } };
987
+ if (method === 'GET') {
988
+ var qs = new URLSearchParams();
989
+ Object.entries(body || {}).forEach(function(entry) {
990
+ var key = entry[0], value = entry[1];
991
+ if (value === undefined || value === null) return;
992
+ if (Array.isArray(value)) value.forEach(function(v) { qs.append(key, String(v)); });
993
+ else if (typeof value === 'object') qs.append(key, JSON.stringify(value));
994
+ else qs.append(key, String(value));
995
+ });
996
+ var text = qs.toString();
997
+ if (text) fetchUrl += (fetchUrl.includes('?') ? '&' : '?') + text;
998
+ } else {
999
+ opts.body = JSON.stringify(body || {});
1000
+ }
1001
+ var res = await fetch(fetchUrl, opts);
985
1002
  if (!res.ok) {
986
1003
  var d = await res.json().catch(function() { return {}; });
987
1004
  var err = new Error(d.error || 'Request failed (' + res.status + ')');
@@ -1068,22 +1085,25 @@ async function ccExecuteAction(action, targetTabId) {
1068
1085
  if (notePageLink && !notePageLink.querySelector('.notif-badge')) { var noteCurPage = document.querySelector('.sidebar-link.active')?.getAttribute('data-page'); if (noteCurPage !== 'inbox') showNotifBadge(notePageLink); }
1069
1086
  break;
1070
1087
  }
1071
- case 'pin': {
1088
+ case 'pin':
1089
+ case 'pin-to-pinned': {
1072
1090
  await _ccFetch('/api/pinned', { title: action.title, content: action.content || action.description, level: action.level || '' });
1073
1091
  status.innerHTML = '&#x1F4CC; Pinned: <strong>' + escHtml(action.title) + '</strong> — visible to all agents';
1074
1092
  status.style.color = 'var(--green)';
1075
1093
  break;
1076
1094
  }
1077
1095
  case 'plan': {
1078
- await _ccFetch('/api/plan', { title: action.title, description: action.description, project: action.project, branchStrategy: action.branchStrategy || 'parallel' });
1096
+ var branchStrategy = action.branch_strategy || action.branchStrategy || 'parallel';
1097
+ await _ccFetch('/api/plan', { title: action.title, description: action.description, project: action.project, branch_strategy: branchStrategy, branchStrategy: branchStrategy });
1079
1098
  status.innerHTML = '&#10003; Plan queued: <strong>' + escHtml(action.title) + '</strong>';
1080
1099
  status.style.color = 'var(--green)';
1081
1100
  wakeEngine();
1082
1101
  break;
1083
1102
  }
1084
1103
  case 'cancel': {
1085
- await _ccFetch('/api/agents/cancel', { agentId: action.agent, reason: action.reason || 'Cancelled via command center' });
1086
- status.innerHTML = '&#10003; Cancelled agent: <strong>' + escHtml(action.agent) + '</strong>';
1104
+ var cancelAgent = action.agent || action.agentId || '';
1105
+ await _ccFetch('/api/agents/cancel', { agent: cancelAgent, agentId: cancelAgent, task: action.task || action.cancelTask || '', reason: action.reason || 'Cancelled via command center' });
1106
+ status.innerHTML = '&#10003; Cancelled agent: <strong>' + escHtml(cancelAgent || action.task || action.cancelTask || '') + '</strong>';
1087
1107
  status.style.color = 'var(--orange)';
1088
1108
  break;
1089
1109
  }
@@ -1475,8 +1495,9 @@ async function ccExecuteAction(action, targetTabId) {
1475
1495
  break;
1476
1496
  }
1477
1497
  case 'add-project': {
1478
- await _ccFetch('/api/projects/add', { localPath: action.localPath, name: action.name || '', repoHost: action.repoHost || 'github' });
1479
- status.innerHTML = '&#10003; Project added: <strong>' + escHtml(action.name || action.localPath) + '</strong>';
1498
+ var projectPath = action.path || action.localPath;
1499
+ await _ccFetch('/api/projects/add', { path: projectPath, localPath: projectPath, name: action.name || '', repoHost: action.repoHost || 'github', allowNonRepo: action.allowNonRepo, confirmToken: action.confirmToken });
1500
+ status.innerHTML = '&#10003; Project added: <strong>' + escHtml(action.name || projectPath) + '</strong>';
1480
1501
  status.style.color = 'var(--green)';
1481
1502
  break;
1482
1503
  }
@@ -1507,7 +1528,7 @@ async function ccExecuteAction(action, targetTabId) {
1507
1528
  default: {
1508
1529
  // Generic fallback: if action has an `endpoint` field, call it directly (local API only)
1509
1530
  if (action.endpoint && action.endpoint.startsWith('/api/') && !action.endpoint.includes('..') && !/\%2e/i.test(action.endpoint)) {
1510
- var genRes = await _ccFetch(action.endpoint, action.params || {});
1531
+ var genRes = await _ccFetch(action.endpoint, action.params || {}, action.method || 'POST');
1511
1532
  var genData = await genRes.json().catch(function() { return {}; });
1512
1533
  status.innerHTML = '&#10003; ' + escHtml(action.type) + ': ' + escHtml(genData.message || genData.id || 'done');
1513
1534
  status.style.color = 'var(--green)';
package/dashboard.js CHANGED
@@ -1449,6 +1449,13 @@ try {
1449
1449
  let _preambleCache = null;
1450
1450
  let _preambleCacheTs = 0;
1451
1451
  const PREAMBLE_TTL = 30000; // 30s — longer TTL since preamble is lightweight orientation, not real-time data
1452
+ const CC_API_FALLBACK_TIMEOUT_MS = 15000;
1453
+ const CC_API_FALLBACK_METHODS = new Set(['GET', 'POST', 'DELETE']);
1454
+ const CC_API_FALLBACK_BLOCKED_PREFIXES = [
1455
+ '/api/command-center',
1456
+ '/api/doc-chat',
1457
+ '/api/bot',
1458
+ ];
1452
1459
 
1453
1460
  // SoT for CC's runtime API index. Captured lazily on the first HTTP request
1454
1461
  // because ROUTES is closed over inside the request handler. Subsequent
@@ -1569,13 +1576,20 @@ function _routesAsMeta(routes) {
1569
1576
 
1570
1577
  function _captureApiRoutesMeta(routes) {
1571
1578
  if (_ccApiRoutesMeta || !Array.isArray(routes)) return;
1572
- _ccApiRoutesMeta = _routesAsMeta(routes);
1579
+ _ccApiRoutesMeta = routes.map(r => ({
1580
+ ..._routesAsMeta([r])[0],
1581
+ _pathRegex: r.path instanceof RegExp ? r.path : null,
1582
+ }));
1583
+ }
1584
+
1585
+ function _resetCcApiRoutesMetaForTest() {
1586
+ _ccApiRoutesMeta = null;
1573
1587
  }
1574
1588
 
1575
1589
  function _formatCcApiRoutesIndex() {
1576
1590
  if (!Array.isArray(_ccApiRoutesMeta) || _ccApiRoutesMeta.length === 0) return '';
1577
1591
  return _ccApiRoutesMeta
1578
- .filter(r => r.path.startsWith('/api/'))
1592
+ .filter(r => r.path.startsWith('/api/') || r.path.startsWith('/^\\/api'))
1579
1593
  .map(r => {
1580
1594
  const params = r.params ? ` — params: ${r.params}` : '';
1581
1595
  const flags = [
@@ -1635,8 +1649,8 @@ ${apiIndex || '(routes not yet captured — first request still pending)'}
1635
1649
  ### CLI Index (auto-generated from engine/cli.js CLI_COMMAND_DOCS — single source of truth)
1636
1650
  ${cliIndex || '(unavailable)'}
1637
1651
 
1638
- For \`POST /api/...\` endpoints marked \`generic-fallback\` and not covered by a named CC action, use the generic fallback:
1639
- \`{"type":"<descriptive>","endpoint":"/api/...","params":{...}}\`.` : '';
1652
+ For any safe local \`/api/...\` endpoint not covered by a named CC action, use the generic fallback:
1653
+ \`{"type":"<descriptive>","endpoint":"/api/...","method":"GET|POST|DELETE","params":{...}}\`.` : '';
1640
1654
 
1641
1655
  const result = `### Agents
1642
1656
  ${agents}
@@ -2232,6 +2246,292 @@ function _ccValidateAction(action) {
2232
2246
  }
2233
2247
  }
2234
2248
 
2249
+ let _ccLocalApiInvokerForTest = null;
2250
+
2251
+ function _setCcLocalApiInvokerForTest(fn) {
2252
+ _ccLocalApiInvokerForTest = typeof fn === 'function' ? fn : null;
2253
+ }
2254
+
2255
+ function _ccRouteMethodsForPath(pathname) {
2256
+ if (!Array.isArray(_ccApiRoutesMeta) || _ccApiRoutesMeta.length === 0) return null;
2257
+ const methods = new Set();
2258
+ for (const route of _ccApiRoutesMeta) {
2259
+ if (route._pathRegex instanceof RegExp) {
2260
+ route._pathRegex.lastIndex = 0;
2261
+ if (route._pathRegex.test(pathname)) methods.add(String(route.method || '').toUpperCase());
2262
+ } else if (route.path === pathname) {
2263
+ methods.add(String(route.method || '').toUpperCase());
2264
+ }
2265
+ }
2266
+ return methods;
2267
+ }
2268
+
2269
+ function _ccValidateLocalApiFallback(endpoint, method) {
2270
+ if (typeof endpoint !== 'string' || !endpoint.trim()) return 'generic API fallback requires endpoint';
2271
+ const raw = endpoint.trim();
2272
+ if (!(raw === '/api' || raw.startsWith('/api/'))) return 'generic API fallback endpoint must be a local /api/ path';
2273
+ if (/[\0\r\n\\]/.test(raw) || raw.includes('..') || /%2e/i.test(raw) || /%5c/i.test(raw)) {
2274
+ return 'generic API fallback endpoint is unsafe';
2275
+ }
2276
+ let parsed;
2277
+ try {
2278
+ parsed = new URL(raw, 'http://127.0.0.1');
2279
+ } catch {
2280
+ return 'generic API fallback endpoint is invalid';
2281
+ }
2282
+ if (parsed.origin !== 'http://127.0.0.1' || !(parsed.pathname === '/api' || parsed.pathname.startsWith('/api/'))) {
2283
+ return 'generic API fallback endpoint must be a local /api/ path';
2284
+ }
2285
+ if (CC_API_FALLBACK_BLOCKED_PREFIXES.some(prefix => parsed.pathname === prefix || parsed.pathname.startsWith(prefix + '/'))) {
2286
+ return 'generic API fallback cannot call Command Center, doc-chat, or bot endpoints';
2287
+ }
2288
+ if (/stream/i.test(parsed.pathname) || parsed.pathname === '/api/hot-reload') {
2289
+ return 'generic API fallback cannot call streaming endpoints';
2290
+ }
2291
+ const normalizedMethod = String(method || 'POST').toUpperCase();
2292
+ if (!CC_API_FALLBACK_METHODS.has(normalizedMethod)) {
2293
+ return `generic API fallback method ${normalizedMethod} is not allowed`;
2294
+ }
2295
+ const routeMethods = _ccRouteMethodsForPath(parsed.pathname);
2296
+ if (routeMethods && routeMethods.size > 0 && !routeMethods.has(normalizedMethod)) {
2297
+ return `API endpoint ${parsed.pathname} does not allow ${normalizedMethod}; allowed methods: ${[...routeMethods].join(', ')}`;
2298
+ }
2299
+ if (routeMethods && routeMethods.size === 0) {
2300
+ return `API endpoint ${parsed.pathname} is not in the local API index`;
2301
+ }
2302
+ return null;
2303
+ }
2304
+
2305
+ function _ccBuildQueryString(params) {
2306
+ if (!params || typeof params !== 'object' || Array.isArray(params)) return '';
2307
+ const search = new URLSearchParams();
2308
+ for (const [key, value] of Object.entries(params)) {
2309
+ if (value === undefined || value === null) continue;
2310
+ if (Array.isArray(value)) {
2311
+ for (const item of value) search.append(key, String(item));
2312
+ } else if (typeof value === 'object') {
2313
+ search.append(key, JSON.stringify(value));
2314
+ } else {
2315
+ search.append(key, String(value));
2316
+ }
2317
+ }
2318
+ const text = search.toString();
2319
+ return text ? '?' + text : '';
2320
+ }
2321
+
2322
+ function _ccRequestPath(endpoint, method, params) {
2323
+ const parsed = new URL(endpoint, 'http://127.0.0.1');
2324
+ if (method === 'GET') {
2325
+ const extra = _ccBuildQueryString(params);
2326
+ if (extra) {
2327
+ const glue = parsed.search ? '&' : '?';
2328
+ return parsed.pathname + parsed.search + glue + extra.slice(1);
2329
+ }
2330
+ }
2331
+ return parsed.pathname + parsed.search;
2332
+ }
2333
+
2334
+ async function _ccInvokeLocalApi({ method, endpoint, params }) {
2335
+ if (_ccLocalApiInvokerForTest) return _ccLocalApiInvokerForTest({ method, endpoint, params });
2336
+ const requestPath = _ccRequestPath(endpoint, method, params);
2337
+ return new Promise((resolve, reject) => {
2338
+ const body = method === 'GET' ? null : JSON.stringify(params || {});
2339
+ const req = http.request({
2340
+ hostname: '127.0.0.1',
2341
+ port: PORT,
2342
+ method,
2343
+ path: requestPath,
2344
+ timeout: CC_API_FALLBACK_TIMEOUT_MS,
2345
+ headers: body ? {
2346
+ 'Content-Type': 'application/json',
2347
+ 'Content-Length': Buffer.byteLength(body),
2348
+ } : {},
2349
+ }, res => {
2350
+ let text = '';
2351
+ res.setEncoding('utf8');
2352
+ res.on('data', chunk => { text += chunk; });
2353
+ res.on('end', () => {
2354
+ let data = text;
2355
+ try { data = text ? JSON.parse(text) : {}; } catch { /* non-JSON API response */ }
2356
+ resolve({ status: res.statusCode || 0, data });
2357
+ });
2358
+ });
2359
+ req.on('timeout', () => {
2360
+ req.destroy(new Error(`local API fallback timed out after ${CC_API_FALLBACK_TIMEOUT_MS}ms`));
2361
+ });
2362
+ req.on('error', reject);
2363
+ if (body) req.write(body);
2364
+ req.end();
2365
+ });
2366
+ }
2367
+
2368
+ function _ccApiRequest(endpoint, params = {}, method = 'POST') {
2369
+ return { endpoint, params, method };
2370
+ }
2371
+
2372
+ function _ccMappedApiRequests(action) {
2373
+ switch (action.type) {
2374
+ case 'pin':
2375
+ case 'pin-to-pinned':
2376
+ return _ccApiRequest('/api/pinned', { title: action.title, content: action.content || action.description, level: action.level || '' });
2377
+ case 'plan': {
2378
+ const branchStrategy = action.branch_strategy || action.branchStrategy || 'parallel';
2379
+ return _ccApiRequest('/api/plan', {
2380
+ title: action.title, description: action.description || '', priority: action.priority,
2381
+ project: action.project, agent: action.agent, branch_strategy: branchStrategy,
2382
+ });
2383
+ }
2384
+ case 'cancel':
2385
+ return _ccApiRequest('/api/agents/cancel', {
2386
+ agent: action.agent || action.agentId,
2387
+ task: action.task || action.cancelTask,
2388
+ reason: action.reason || 'Cancelled via command center',
2389
+ });
2390
+ case 'retry':
2391
+ return (action.ids || []).map(id => _ccApiRequest('/api/work-items/retry', { id, source: action.source || '' }));
2392
+ case 'pause-plan':
2393
+ return _ccApiRequest('/api/plans/pause', { file: action.file });
2394
+ case 'approve-plan':
2395
+ return _ccApiRequest('/api/plans/approve', { file: action.file });
2396
+ case 'reject-plan':
2397
+ return _ccApiRequest('/api/plans/reject', { file: action.file, reason: action.reason || '' });
2398
+ case 'archive-plan':
2399
+ return _ccApiRequest('/api/plans/archive', { file: action.file });
2400
+ case 'unarchive-plan':
2401
+ return _ccApiRequest('/api/plans/unarchive', { file: action.file });
2402
+ case 'execute-plan':
2403
+ return _ccApiRequest('/api/plans/execute', { file: action.file, project: action.project || '' });
2404
+ case 'trigger-verify':
2405
+ return _ccApiRequest('/api/plans/trigger-verify', { file: action.file });
2406
+ case 'regenerate-plan':
2407
+ return _ccApiRequest('/api/plans/approve', { file: action.file, forceRegen: true });
2408
+ case 'revise-plan':
2409
+ return _ccApiRequest('/api/plans/revise', { file: action.file, feedback: action.feedback || action.description, requestedBy: 'command-center' });
2410
+ case 'edit-prd-item':
2411
+ return _ccApiRequest('/api/prd-items/update', {
2412
+ source: action.source, itemId: action.itemId, name: action.name, description: action.description,
2413
+ priority: action.priority, estimated_complexity: action.estimated_complexity || action.complexity,
2414
+ });
2415
+ case 'remove-prd-item':
2416
+ return _ccApiRequest('/api/prd-items/remove', { source: action.source, itemId: action.itemId });
2417
+ case 'reopen-prd-item':
2418
+ return _ccApiRequest('/api/prd-items/update', { source: action.file, itemId: action.id, status: 'updated' });
2419
+ case 'delete-work-item':
2420
+ return _ccApiRequest('/api/work-items/delete', { id: action.id, source: action.source || '' });
2421
+ case 'cancel-work-item':
2422
+ return _ccApiRequest('/api/work-items/cancel', { id: action.id, source: action.source || '', reason: action.reason || 'cc' });
2423
+ case 'archive-work-item':
2424
+ return _ccApiRequest('/api/work-items/archive', { id: action.id });
2425
+ case 'work-item-feedback':
2426
+ return _ccApiRequest('/api/work-items/feedback', { id: action.id, rating: action.rating || 'up', comment: action.comment || '' });
2427
+ case 'schedule':
2428
+ return _ccApiRequest(action._update ? '/api/schedules/update' : '/api/schedules', {
2429
+ id: action.id, title: action.title, cron: action.cron, type: action.workType || 'implement',
2430
+ project: action.project, agent: action.agent, description: action.description,
2431
+ priority: action.priority, enabled: action.enabled !== false,
2432
+ });
2433
+ case 'delete-schedule':
2434
+ return _ccApiRequest('/api/schedules/delete', { id: action.id });
2435
+ case 'edit-pipeline':
2436
+ return _ccApiRequest('/api/pipelines/update', {
2437
+ id: action.id, title: action.title, stages: action.stages,
2438
+ trigger: action.trigger, enabled: action.enabled, stopWhen: action.stopWhen,
2439
+ monitoredResources: action.monitoredResources,
2440
+ });
2441
+ case 'delete-pipeline':
2442
+ return _ccApiRequest('/api/pipelines/delete', { id: action.id });
2443
+ case 'trigger-pipeline':
2444
+ return _ccApiRequest('/api/pipelines/trigger', { id: action.id });
2445
+ case 'continue-pipeline':
2446
+ return _ccApiRequest('/api/pipelines/continue', { id: action.id, stageId: action.stageId });
2447
+ case 'abort-pipeline':
2448
+ return _ccApiRequest('/api/pipelines/abort', { id: action.id });
2449
+ case 'retrigger-pipeline':
2450
+ return _ccApiRequest('/api/pipelines/retrigger', { id: action.id });
2451
+ case 'add-meeting-note':
2452
+ return _ccApiRequest('/api/meetings/note', { id: action.id, note: action.note || action.content });
2453
+ case 'advance-meeting':
2454
+ return _ccApiRequest('/api/meetings/advance', { id: action.id });
2455
+ case 'end-meeting':
2456
+ return _ccApiRequest('/api/meetings/end', { id: action.id });
2457
+ case 'archive-meeting':
2458
+ return _ccApiRequest('/api/meetings/archive', { id: action.id });
2459
+ case 'unarchive-meeting':
2460
+ return _ccApiRequest('/api/meetings/unarchive', { id: action.id });
2461
+ case 'delete-meeting':
2462
+ return _ccApiRequest('/api/meetings/delete', { id: action.id });
2463
+ case 'set-config':
2464
+ return _ccApiRequest('/api/settings', { engine: { [action.setting]: action.value } });
2465
+ case 'update-routing':
2466
+ return _ccApiRequest('/api/settings/routing', { content: action.content });
2467
+ case 'steer-agent':
2468
+ return _ccApiRequest('/api/agents/steer', { agent: action.agent, message: action.message || action.content });
2469
+ case 'link-pr':
2470
+ return _ccApiRequest('/api/pull-requests/link', { url: action.url, title: action.title || '', project: action.project || '', autoObserve: action.autoObserve !== false });
2471
+ case 'delete-pr':
2472
+ return _ccApiRequest('/api/pull-requests/delete', { id: action.id, project: action.project || '' });
2473
+ case 'file-bug':
2474
+ return _ccApiRequest('/api/issues/create', { title: action.title, description: action.description, labels: action.labels });
2475
+ case 'promote-to-kb':
2476
+ return _ccApiRequest('/api/inbox/promote-kb', { name: action.file, category: action.category || 'project-notes' });
2477
+ case 'kb-sweep':
2478
+ return _ccApiRequest('/api/knowledge/sweep', {});
2479
+ case 'toggle-kb-pin':
2480
+ return _ccApiRequest('/api/kb-pins/toggle', { key: action.key });
2481
+ case 'unpin':
2482
+ return _ccApiRequest('/api/pinned' + '/remove', { title: action.title });
2483
+ case 'add-project':
2484
+ return _ccApiRequest('/api/projects/add', {
2485
+ path: action.path || action.localPath, name: action.name || '',
2486
+ repoHost: action.repoHost || 'github', allowNonRepo: action.allowNonRepo,
2487
+ confirmToken: action.confirmToken,
2488
+ });
2489
+ case 'restart-engine':
2490
+ return _ccApiRequest('/api/engine/restart', {});
2491
+ case 'reset-settings':
2492
+ return _ccApiRequest('/api/settings/reset', {});
2493
+ default:
2494
+ if (action.endpoint) return _ccApiRequest(action.endpoint, action.params || {}, action.method || 'POST');
2495
+ return null;
2496
+ }
2497
+ }
2498
+
2499
+ async function _ccExecuteLocalApiAction(action) {
2500
+ const mapped = _ccMappedApiRequests(action);
2501
+ if (!mapped) return null;
2502
+ const requests = Array.isArray(mapped) ? mapped : [mapped];
2503
+ if (requests.length === 0) throw new Error(`${action.type} action has no API requests to execute`);
2504
+ const apiResults = [];
2505
+ for (const request of requests) {
2506
+ const method = String(request.method || 'POST').toUpperCase();
2507
+ const endpoint = String(request.endpoint || '').trim();
2508
+ const params = request.params || {};
2509
+ const validationError = _ccValidateLocalApiFallback(endpoint, method);
2510
+ if (validationError) throw new Error(validationError);
2511
+ const response = await _ccInvokeLocalApi({ method, endpoint, params });
2512
+ const status = Number(response?.status) || 0;
2513
+ const data = response?.data === undefined ? {} : response.data;
2514
+ if (status < 200 || status >= 300) {
2515
+ const detail = data && typeof data === 'object' && data.error ? data.error : `HTTP ${status}`;
2516
+ throw new Error(`${method} ${endpoint} failed: ${detail}`);
2517
+ }
2518
+ if (data && typeof data === 'object' && data.error) throw new Error(`${method} ${endpoint} failed: ${data.error}`);
2519
+ apiResults.push({ status, data, endpoint, method });
2520
+ }
2521
+ const firstData = apiResults[0]?.data && typeof apiResults[0].data === 'object' ? apiResults[0].data : {};
2522
+ return {
2523
+ type: action.type,
2524
+ ok: true,
2525
+ endpoint: apiResults[0]?.endpoint,
2526
+ method: apiResults[0]?.method,
2527
+ status: apiResults[0]?.status,
2528
+ ...(firstData.id ? { id: firstData.id } : {}),
2529
+ ...(firstData.file ? { file: firstData.file } : {}),
2530
+ ...(firstData.message ? { message: firstData.message } : {}),
2531
+ ...(apiResults.length > 1 ? { count: apiResults.length, results: apiResults.map(r => r.data) } : { data: firstData }),
2532
+ };
2533
+ }
2534
+
2235
2535
  async function executeCCActions(actions) {
2236
2536
  const results = [];
2237
2537
  for (const rawAction of actions) {
@@ -2496,10 +2796,16 @@ async function executeCCActions(actions) {
2496
2796
  results.push({ type: 'resume-watch', id: action.id, ok: !!resumed });
2497
2797
  break;
2498
2798
  }
2499
- default:
2500
- // Server didn't handle — frontend must execute
2501
- results.push({ type: action.type });
2799
+ default: {
2800
+ const apiResult = await _ccExecuteLocalApiAction(action);
2801
+ if (apiResult) {
2802
+ results.push(apiResult);
2803
+ } else {
2804
+ // Server didn't handle — frontend must execute.
2805
+ results.push({ type: action.type });
2806
+ }
2502
2807
  break;
2808
+ }
2503
2809
  }
2504
2810
  } catch (e) {
2505
2811
  results.push({ type: action.type, error: e.message });
@@ -3059,7 +3365,7 @@ async function _retryDocChatAfterResumeFailure({ result, initialPass, freshSessi
3059
3365
  function _buildDocChatErrorEnvelope(result) {
3060
3366
  return {
3061
3367
  code: result.code ?? null,
3062
- stderr: (result.stderr || '').slice(-2048),
3368
+ stderr: String(result.stderr || '').slice(-2048),
3063
3369
  errorClass: result.errorClass || null,
3064
3370
  errorMessage: result.errorMessage || null,
3065
3371
  runtime: result.runtime || null,
@@ -4173,7 +4479,7 @@ const server = http.createServer(async (req, res) => {
4173
4479
  id, title: body.title, type: 'plan',
4174
4480
  priority: body.priority || 'high', description: body.description || '',
4175
4481
  status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard',
4176
- branchStrategy: body.branch_strategy || 'parallel',
4482
+ branchStrategy: body.branch_strategy || body.branchStrategy || 'parallel',
4177
4483
  };
4178
4484
  if (body.project) item.project = body.project;
4179
4485
  if (body.agent) item.agent = body.agent;
@@ -4376,14 +4682,17 @@ const server = http.createServer(async (req, res) => {
4376
4682
  async function handleAgentsCancel(req, res) {
4377
4683
  try {
4378
4684
  const body = await readBody(req);
4685
+ const requestedAgent = body.agent || body.agentId;
4686
+ const requestedTask = body.task || body.cancelTask;
4687
+ if (!requestedAgent && !requestedTask) return jsonReply(res, 400, { error: 'agent or task required' });
4379
4688
  const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
4380
4689
  const dispatch = safeJsonObj(dispatchPath);
4381
4690
  const active = dispatch.active || [];
4382
4691
  const cancelled = [];
4383
4692
 
4384
4693
  for (const d of active) {
4385
- const matchAgent = body.agent && d.agent === body.agent;
4386
- const matchTask = body.task && (d.task || '').toLowerCase().includes((body.task || '').toLowerCase());
4694
+ const matchAgent = requestedAgent && d.agent === requestedAgent;
4695
+ const matchTask = requestedTask && (d.task || '').toLowerCase().includes(String(requestedTask).toLowerCase());
4387
4696
  if (!matchAgent && !matchTask) continue;
4388
4697
 
4389
4698
  // Kill agent process
@@ -7429,7 +7738,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7429
7738
  { method: 'POST', path: '/api/notes-save', desc: 'Save edited notes.md content', params: 'content, file?', handler: handleNotesSave },
7430
7739
 
7431
7740
  // Plans
7432
- { method: 'POST', path: '/api/plan', desc: 'Create a plan work item that chains to PRD on completion', params: 'title, description?, priority?, project?, agent?, branch_strategy?', handler: handlePlanCreate },
7741
+ { method: 'POST', path: '/api/plan', desc: 'Create a plan work item that chains to PRD on completion', params: 'title, description?, priority?, project?, agent?, branch_strategy? or branchStrategy?', handler: handlePlanCreate },
7433
7742
  { method: 'GET', path: '/api/plans', desc: 'List plan files (.md drafts + .json PRDs)', handler: handlePlansList },
7434
7743
  { method: 'POST', path: '/api/plans/trigger-verify', desc: 'Manually trigger verification for a completed plan', params: 'file', handler: handlePlansTriggerVerify },
7435
7744
  { method: 'POST', path: '/api/plans/approve', desc: 'Approve a plan for execution', params: 'file, approvedBy?', handler: handlePlansApprove },
@@ -7614,7 +7923,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7614
7923
  inboxCount: steering.listUnreadSteeringMessages(agentId).length,
7615
7924
  });
7616
7925
  }},
7617
- { method: 'POST', path: '/api/agents/cancel', desc: 'Cancel an active agent by ID or task substring', params: 'agent?, task?', handler: handleAgentsCancel },
7926
+ { method: 'POST', path: '/api/agents/cancel', desc: 'Cancel an active agent by ID or task substring', params: 'agent? or agentId?, task?', handler: handleAgentsCancel },
7618
7927
  { 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 },
7619
7928
  { method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live-stream(?:\?.*)?$/, template: '/api/agent/:id/live-stream', desc: 'SSE real-time live output streaming', handler: handleAgentLiveStream },
7620
7929
  { 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 },
@@ -8086,7 +8395,11 @@ module.exports = {
8086
8395
  _resolveWorkItemsCreateTarget: resolveWorkItemsCreateTarget,
8087
8396
  _collectArchivedWorkItems: collectArchivedWorkItems,
8088
8397
  _createPipelineFromAction: createPipelineFromAction,
8398
+ _setCcLocalApiInvokerForTest,
8399
+ _resetCcApiRoutesMetaForTest,
8400
+ _ccValidateLocalApiFallback,
8089
8401
  executeCCActions,
8402
+ executeDocChatActions,
8090
8403
  buildCCStatePreamble,
8091
8404
  _routesAsMeta,
8092
8405
  _buildTranscriptCarryover,
@@ -93,6 +93,8 @@ When you ask CC to *do* something, it includes structured action blocks in its r
93
93
  | `remove-prd-item` | Remove a PRD item | "Remove P011 from the plan" |
94
94
  | `delete-work-item` | Delete a work item | "Delete work item W025" |
95
95
 
96
+ For endpoints without a named action, CC may emit a local API fallback action with `endpoint`, `method`, and `params`. The server only invokes safe local `/api/...` paths, validates the requested method against the route index when available, sends GET params as query strings, and rejects streaming/recursive CC/doc-chat endpoints.
97
+
96
98
  ## Error Handling
97
99
 
98
100
  - **Frontend timeout**: 10-minute `AbortSignal` on the fetch — prevents infinite "thinking" spinner
@@ -155,4 +157,3 @@ Frontend
155
157
  ## Command Bar
156
158
 
157
159
  The command bar at the top of the dashboard routes all input to the CC panel. Typing in the command bar opens the CC drawer and sends the message as a CC turn.
158
-
package/engine/cleanup.js CHANGED
@@ -369,13 +369,23 @@ async function runCleanup(config, verbose = false) {
369
369
  // 2b. Detect git worktrees registered inside any linked project's working tree.
370
370
  // Nested worktrees cause glob/grep tools running with cwd=projectRoot to match
371
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).
372
+ // change to both locations, producing mirror-write leaks.
373
373
  // We only WARN here — removing someone else's worktree without consent could
374
374
  // destroy in-flight work. The operator runs `git worktree remove <path>`.
375
375
  cleaned.nestedWorktrees = 0;
376
+ const _scannedRoots = new Set(); // dedup projects sharing localPath
376
377
  for (const project of projects) {
377
- const root = project.localPath ? path.resolve(project.localPath) : null;
378
- if (!root || !fs.existsSync(root)) continue;
378
+ if (!project.localPath) continue;
379
+ const root = path.resolve(project.localPath);
380
+ if (_scannedRoots.has(root)) continue;
381
+ _scannedRoots.add(root);
382
+ if (!fs.existsSync(root)) {
383
+ // Configured project whose checkout has been moved or deleted — surface
384
+ // it so the operator knows their config is out of sync. A missing root
385
+ // means we cannot scan it, leaving nested worktrees there undetectable.
386
+ log('warn', `Project "${project.name || root}" has localPath "${root}" which does not exist on disk — skipping worktree scan`);
387
+ continue;
388
+ }
379
389
  let raw;
380
390
  try {
381
391
  raw = String(shared.execSilent('git worktree list --porcelain', { cwd: root, timeout: 10000, windowsHide: true }) || '');
@@ -384,8 +394,7 @@ async function runCleanup(config, verbose = false) {
384
394
  if (!line.startsWith('worktree ')) continue;
385
395
  const wt = line.slice('worktree '.length).trim();
386
396
  if (!wt) continue;
387
- if (path.resolve(wt) === root) continue; // main worktree expected
388
- if (!shared.isPathInsideOrEqual(wt, root)) continue;
397
+ if (!shared.isPathInside(wt, root)) continue; // strict — main worktree (equal path) is expected, descendants are the leak
389
398
  cleaned.nestedWorktrees++;
390
399
  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
400
  }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-07T23:23:28.356Z"
4
+ "cachedAt": "2026-05-07T23:33:01.432Z"
5
5
  }
package/engine/meeting.js CHANGED
@@ -155,17 +155,12 @@ function getStructuredNoteArtifacts(structuredCompletion) {
155
155
  );
156
156
  }
157
157
 
158
- function isPathInside(parent, child) {
159
- const rel = path.relative(parent, child);
160
- return Boolean(rel && !rel.startsWith('..') && !path.isAbsolute(rel));
161
- }
162
-
163
158
  function resolveMeetingNoteArtifactPath(artifactPath) {
164
159
  const raw = String(artifactPath || '').trim();
165
160
  if (!raw || raw.includes('\0')) return null;
166
161
  const resolved = path.resolve(path.isAbsolute(raw) ? raw : path.join(shared.MINIONS_DIR, raw));
167
162
  const root = path.resolve(MEETING_NOTE_ARTIFACT_ROOT);
168
- if (!isPathInside(root, resolved)) return null;
163
+ if (!shared.isPathInside(resolved, root)) return null;
169
164
  if (path.extname(resolved).toLowerCase() !== '.md') return null;
170
165
  return resolved;
171
166
  }
@@ -179,7 +174,7 @@ function readMeetingNoteArtifact(artifactPath) {
179
174
  try {
180
175
  const realRoot = fs.realpathSync(MEETING_NOTE_ARTIFACT_ROOT);
181
176
  const realPath = fs.realpathSync(resolved);
182
- if (!isPathInside(realRoot, realPath)) {
177
+ if (!shared.isPathInside(realPath, realRoot)) {
183
178
  log('warn', `Ignoring meeting note artifact outside notes/inbox: ${artifactPath}`);
184
179
  return '';
185
180
  }
@@ -914,7 +909,6 @@ module.exports = {
914
909
  // exported for testing — engine code MUST go through
915
910
  // getMeetings/discoverMeetingWork/collectMeetingFindings/checkMeetingTimeouts,
916
911
  // never these helpers directly.
917
- isPathInside,
918
912
  resolveMeetingNoteArtifactPath,
919
913
  cleanMeetingSummaryText,
920
914
  splitMeetingSummaryFragments,
@@ -275,10 +275,9 @@ function runPreflight(opts = {}) {
275
275
  // 5. worktreeRoot config check — for every linked project, verify that the
276
276
  // configured engine.worktreeRoot resolves OUTSIDE the project's
277
277
  // localPath. A nested worktreeRoot causes glob/grep to match both
278
- // copies of every file, producing silent mirror writes (the W-cc-doc-
279
- // chat-continuity leak class). Hard-fail at preflight so the operator
280
- // sees it before any agent dispatch — the runtime guard in spawnAgent
281
- // is the second line of defense.
278
+ // copies of every file, producing silent mirror writes. Hard-fail at
279
+ // preflight so the operator sees it before any agent dispatch — the
280
+ // runtime guard in spawnAgent is the second line of defense.
282
281
  try {
283
282
  const path = require('path');
284
283
  const projects = shared.getProjects(opts.config) || [];
package/engine/shared.js CHANGED
@@ -2143,30 +2143,40 @@ function buildWorktreeDirName({
2143
2143
  }
2144
2144
 
2145
2145
  /**
2146
- * True when `childPath` is the same as or nested within `parentPath`. Uses
2147
- * `path.relative` so it's cross-platform and resilient to mixed separators
2148
- * (Windows worktree paths often arrive with forward slashes from git output).
2149
- * Returns false when paths refer to different roots/drives or `childPath`
2150
- * escapes via `..`.
2151
- *
2152
- * Why this helper exists: a git worktree placed inside the parent repo's
2153
- * working tree causes glob/grep tools running with `cwd = projectRoot` to
2154
- * match BOTH copies of every file. A single Edit/MultiEdit then writes the
2155
- * same change to both locations, producing the "mirror dirty file" pattern.
2156
- * Worktrees must always be siblings/cousins of the project root, never
2157
- * descendants.
2146
+ * True when `childPath` is strictly nested within `parentPath` (descendant,
2147
+ * NOT the same path). Cross-platform via `path.relative`; resilient to mixed
2148
+ * separators. Returns false for equal paths, different roots/drives, or
2149
+ * `childPath` escapes via `..`.
2158
2150
  */
2159
- function isPathInsideOrEqual(childPath, parentPath) {
2151
+ function isPathInside(childPath, parentPath) {
2160
2152
  if (!childPath || !parentPath) return false;
2161
2153
  const childAbs = path.resolve(String(childPath));
2162
2154
  const parentAbs = path.resolve(String(parentPath));
2163
2155
  const rel = path.relative(parentAbs, childAbs);
2164
- if (rel === '') return true;
2156
+ if (rel === '') return false;
2165
2157
  if (rel.startsWith('..')) return false;
2166
2158
  if (path.isAbsolute(rel)) return false;
2167
2159
  return true;
2168
2160
  }
2169
2161
 
2162
+ /**
2163
+ * Same as `isPathInside` but ALSO returns true when paths are equal.
2164
+ *
2165
+ * Why this helper exists: a git worktree placed at — or inside — the parent
2166
+ * repo's working tree causes glob/grep tools running with `cwd = projectRoot`
2167
+ * to match BOTH copies of every file. A single Edit/MultiEdit then writes
2168
+ * the same change to both locations, producing the "mirror dirty file"
2169
+ * pattern. Worktrees must always be siblings/cousins of the project root,
2170
+ * never the root itself nor a descendant.
2171
+ */
2172
+ function isPathInsideOrEqual(childPath, parentPath) {
2173
+ if (!childPath || !parentPath) return false;
2174
+ const childAbs = path.resolve(String(childPath));
2175
+ const parentAbs = path.resolve(String(parentPath));
2176
+ if (childAbs === parentAbs) return true;
2177
+ return isPathInside(childAbs, parentAbs);
2178
+ }
2179
+
2170
2180
  /**
2171
2181
  * Throws when `worktreePath` would land inside (or equal) `projectRoot`.
2172
2182
  * Called by the engine spawn path before `git worktree add`, and by the
@@ -3257,6 +3267,7 @@ module.exports = {
3257
3267
  sanitizePath,
3258
3268
  sanitizeBranch,
3259
3269
  buildWorktreeDirName, // exported for testing
3270
+ isPathInside,
3260
3271
  isPathInsideOrEqual,
3261
3272
  assertWorktreeOutsideProject,
3262
3273
  isLiveCommandCenterPath,
package/engine.js CHANGED
@@ -666,6 +666,7 @@ async function spawnAgent(dispatchItem, config) {
666
666
  if (eShared.message?.includes('already used by worktree') || eShared.message?.includes('already checked out')) {
667
667
  const existingWtPath = await findExistingWorktree(rootDir, branchName);
668
668
  if (existingWtPath && fs.existsSync(existingWtPath)) {
669
+ shared.assertWorktreeOutsideProject(existingWtPath, rootDir);
669
670
  log('info', `Shared branch ${branchName} already checked out at ${existingWtPath} — reusing`);
670
671
  worktreePath = existingWtPath;
671
672
  } else { throw eShared; }
@@ -723,6 +724,7 @@ async function spawnAgent(dispatchItem, config) {
723
724
  log('warn', `Branch ${branchName} actively used by another agent at ${existingWtPath} — cannot create worktree`);
724
725
  throw e2;
725
726
  }
727
+ shared.assertWorktreeOutsideProject(existingWtPath, rootDir);
726
728
  log('info', `Branch ${branchName} already checked out at ${existingWtPath} — reusing`);
727
729
  worktreePath = existingWtPath;
728
730
  } else if (existingWtPath && !fs.existsSync(existingWtPath)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1779",
3
+ "version": "0.1.1781",
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"
@@ -81,7 +81,7 @@ I'll dispatch dallas to fix that bug.
81
81
  ===ACTIONS===
82
82
  [{"type": "dispatch", "title": "Fix login bug", "workType": "fix", "agents": ["dallas"], "project": "MyApp", "description": "..."}]
83
83
 
84
- **Generic fallback:** For any action not listed below, include `"endpoint": "/api/..."` and `"params": {...}` to call the API directly. Example: `{"type": "custom-op", "endpoint": "/api/some/endpoint", "params": {"key": "value"}}`.
84
+ **Generic fallback:** For any action not listed below, include `"endpoint": "/api/..."`, `"method": "GET|POST|DELETE"`, and `"params": {...}` to call the API directly. Omit `method` only for POST endpoints. Example: `{"type": "custom-op", "endpoint": "/api/some/endpoint", "method": "POST", "params": {"key": "value"}}`.
85
85
 
86
86
  **Required fields per action type — server rejects with an error if missing:**
87
87
 
@@ -143,7 +143,7 @@ Additional actions (all take `id` or `file` as primary key):
143
143
  - KB/Inbox: promote-to-kb (file, category), kb-sweep, toggle-kb-pin (key)
144
144
  - Plan lifecycle: revise-plan (file, feedback — dispatches agent to revise)
145
145
  - Pipeline: continue-pipeline (id — resume past wait stage)
146
- - Projects: add-project (localPath, name, repoHost)
146
+ - Projects: add-project (path or localPath, name, repoHost)
147
147
  - Engine: restart-engine, reset-settings
148
148
  - Other: unpin (title), link-pr (url, title, project, autoObserve), delete-pr (id, project), update-routing (content), file-bug (title, description, labels)
149
149
 
@@ -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 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
- `{"type":"<short-descriptor>","endpoint":"/api/...","params":{...}}`
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.
162
+ For any safe local `/api/...` endpoint that doesn't have a matching named action above, emit the generic fallback shape:
163
+ `{"type":"<short-descriptor>","endpoint":"/api/...","method":"GET|POST|DELETE","params":{...}}`
164
+ The action runner enforces the endpoint method from the API index when available, sends GET params as query strings, sends POST/DELETE params as JSON, and rejects Command Center, doc-chat, bot, or streaming endpoints.
165
165
 
166
166
  For CLI commands (`minions <cmd>`), use Bash to invoke them when delegating would be heavier than just running the command.