@yemi33/minions 0.1.1813 → 0.1.1815

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,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1815 (2026-05-09)
4
+
5
+ ### Features
6
+ - verify restart health (#2258)
7
+
8
+ ## 0.1.1814 (2026-05-09)
9
+
10
+ ### Features
11
+ - harden PRD and playbook paths (#2247)
12
+
3
13
  ## 0.1.1813 (2026-05-09)
4
14
 
5
15
  ### Features
package/bin/minions.js CHANGED
@@ -40,6 +40,7 @@ const { spawn, spawnSync, execSync } = require('child_process');
40
40
 
41
41
  const PKG_ROOT = path.resolve(__dirname, '..');
42
42
  const shared = require(path.join(PKG_ROOT, 'engine', 'shared'));
43
+ const { waitForRestartHealth, formatRestartHealthError } = require(path.join(PKG_ROOT, 'engine', 'restart-health'));
43
44
  const DASH_PORT = 7331;
44
45
  const DASHBOARD_BROWSER_PRESENCE_MAX_AGE_MS = 45000;
45
46
 
@@ -708,7 +709,22 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
708
709
  console.log(`\n Engine started (PID: ${engineProc.pid})`);
709
710
  const dashProc = spawnDashboard(suppressDashboardOpen);
710
711
  console.log(` Dashboard started (PID: ${dashProc.pid})`);
711
- console.log(` Dashboard: http://localhost:${DASH_PORT}\n`);
712
+ console.log(` Dashboard: http://localhost:${DASH_PORT}`);
713
+ console.log(' Verifying restart health...');
714
+ void (async () => {
715
+ const result = await waitForRestartHealth({
716
+ minionsHome: MINIONS_HOME,
717
+ dashboardUrl: `http://127.0.0.1:${DASH_PORT}/api/health`,
718
+ });
719
+ if (!result.ok) {
720
+ console.error(formatRestartHealthError(result));
721
+ process.exit(1);
722
+ }
723
+ console.log(` Restart verified: engine PID ${result.engine.pid}; dashboard healthy.\n`);
724
+ })().catch(err => {
725
+ console.error(`\n ERROR: Restart verification failed: ${err.message}\n`);
726
+ process.exit(1);
727
+ });
712
728
  } else if (cmd === 'nuke') {
713
729
  ensureInstalled();
714
730
  if (!rest.includes('--confirm')) {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-09T09:32:10.535Z"
4
+ "cachedAt": "2026-05-09T15:24:57.566Z"
5
5
  }
@@ -390,11 +390,10 @@ function executeTaskStage(stage, stageState, run, config, pipeline = {}) {
390
390
  touchedProjects.push(project?.name || null);
391
391
  }
392
392
  }
393
- const projects = [...new Set(touchedProjects)];
394
- return {
395
- status: PIPELINE_STATUS.RUNNING,
396
- artifacts: { workItems: [...new Set(createdIds)], projects },
397
- };
393
+ const artifacts = { workItems: [...new Set(createdIds)] };
394
+ const projectArtifacts = [...new Set(touchedProjects.filter(Boolean))];
395
+ if (projectArtifacts.length > 0) artifacts.projects = projectArtifacts;
396
+ return { status: PIPELINE_STATUS.RUNNING, artifacts };
398
397
  }
399
398
 
400
399
  function executeTaskStageLegacy(stage, stageState, run, config) {
@@ -342,21 +342,41 @@ function isSafeProjectPlaybookName(projectName) {
342
342
  !/^[a-zA-Z]:/.test(name);
343
343
  }
344
344
 
345
+ function isSafePlaybookType(playbookType) {
346
+ const name = String(playbookType || '').trim();
347
+ return !!name &&
348
+ !name.includes('\0') &&
349
+ !name.includes('..') &&
350
+ !/[\\/]/.test(name) &&
351
+ !path.isAbsolute(name) &&
352
+ !/^[a-zA-Z]:/.test(name);
353
+ }
354
+
345
355
  function resolvePlaybookPath(projectName, playbookType) {
356
+ const playbookTypeName = String(playbookType || '').trim();
357
+ if (!isSafePlaybookType(playbookTypeName)) {
358
+ log('warn', `Skipping unsafe playbook type lookup: ${playbookTypeName.replace(/\0/g, '')}`);
359
+ return path.join(PLAYBOOKS_DIR, '__invalid__.md');
360
+ }
346
361
  if (projectName) {
347
362
  const projectDirName = String(projectName).trim();
348
363
  if (isSafeProjectPlaybookName(projectDirName)) {
349
- const localPath = path.join(MINIONS_DIR, 'projects', projectDirName, 'playbooks', `${playbookType}.md`);
364
+ const projectsDir = path.join(MINIONS_DIR, 'projects');
365
+ const localPath = path.resolve(projectsDir, projectDirName, 'playbooks', `${playbookTypeName}.md`);
366
+ if (!shared.isPathInside(localPath, projectsDir)) {
367
+ log('warn', `Skipping project-local playbook outside projects/: ${projectDirName}`);
368
+ return path.join(PLAYBOOKS_DIR, `${playbookTypeName}.md`);
369
+ }
350
370
  try {
351
371
  fs.accessSync(localPath, fs.constants.R_OK);
352
- log('info', `Using project-local playbook: projects/${projectDirName}/playbooks/${playbookType}.md`);
372
+ log('info', `Using project-local playbook: projects/${projectDirName}/playbooks/${playbookTypeName}.md`);
353
373
  return localPath;
354
374
  } catch { /* no local override — fall through to global */ }
355
375
  } else {
356
376
  log('warn', `Skipping project-local playbook lookup for unsafe project name: ${projectDirName}`);
357
377
  }
358
378
  }
359
- return path.join(PLAYBOOKS_DIR, `${playbookType}.md`);
379
+ return path.join(PLAYBOOKS_DIR, `${playbookTypeName}.md`);
360
380
  }
361
381
 
362
382
 
@@ -11,13 +11,84 @@ const shared = require('./shared');
11
11
  const dispatch = require('./dispatch');
12
12
  const { MINIONS_DIR } = shared;
13
13
 
14
- /**
15
- * @param {object} d - dispatch entry
16
- * @returns {string} project name from any of the three meta shapes the engine
17
- * uses (item.project, project.name, project string)
18
- */
19
- function _dispatchProjectName(d) {
20
- return d?.meta?.item?.project || d?.meta?.project?.name || d?.meta?.project || '';
14
+ function _sameProjectName(a, b) {
15
+ return String(a || '').toLowerCase() === String(b || '').toLowerCase();
16
+ }
17
+
18
+ function _projectRefMatches(projectRef, removedProject, projects) {
19
+ if (!projectRef) return false;
20
+ const refs = (projectRef && typeof projectRef === 'object')
21
+ ? [projectRef.name, projectRef.localPath]
22
+ : [projectRef];
23
+ return refs.some(ref => {
24
+ if (!ref) return false;
25
+ const result = shared.resolveConfiguredProject(ref, projects);
26
+ return !!(result.project && _sameProjectName(result.project.name, removedProject.name));
27
+ });
28
+ }
29
+
30
+ function _workItemMatchesProject(item, removedProject, projects) {
31
+ const refs = [
32
+ item?.project,
33
+ item?._project,
34
+ item?.planProject,
35
+ item?._planProject,
36
+ item?._declaredPlanProject,
37
+ ];
38
+ return refs.some(ref => _projectRefMatches(ref, removedProject, projects));
39
+ }
40
+
41
+ function _isCentralDispatchSource(source) {
42
+ return source === 'central-work-item' || source === 'central-work-item-fanout';
43
+ }
44
+
45
+ function _dispatchMatchesProject(d, removedProject, projects) {
46
+ const meta = d?.meta || {};
47
+ if (_isCentralDispatchSource(meta.source)) {
48
+ return _workItemMatchesProject(meta.item, removedProject, projects) ||
49
+ _projectRefMatches(meta.project, removedProject, projects);
50
+ }
51
+ return _workItemMatchesProject(meta.item, removedProject, projects) ||
52
+ _projectRefMatches(meta.project, removedProject, projects);
53
+ }
54
+
55
+ function _centralDispatchDefaultedToProject(d, removedProject, projects) {
56
+ const meta = d?.meta || {};
57
+ return _isCentralDispatchSource(meta.source) &&
58
+ meta.item?.id &&
59
+ !_workItemMatchesProject(meta.item, removedProject, projects) &&
60
+ _projectRefMatches(meta.project, removedProject, projects);
61
+ }
62
+
63
+ function _collectProjectlessCentralDispatchItemIds(removedProject, projects) {
64
+ const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
65
+ const ids = new Set();
66
+ const state = shared.safeJson(dispatchPath) || {};
67
+ for (const queue of ['pending', 'active']) {
68
+ for (const d of Array.isArray(state?.[queue]) ? state[queue] : []) {
69
+ if (_centralDispatchDefaultedToProject(d, removedProject, projects)) ids.add(d.meta.item.id);
70
+ }
71
+ }
72
+ return ids;
73
+ }
74
+
75
+ function _requeueProjectlessCentralWorkItems(itemIds) {
76
+ if (!itemIds || itemIds.size === 0) return 0;
77
+ const wiPath = path.join(MINIONS_DIR, 'work-items.json');
78
+ if (!fs.existsSync(wiPath)) return 0;
79
+ let requeued = 0;
80
+ shared.mutateWorkItems(wiPath, items => {
81
+ if (!Array.isArray(items)) return items;
82
+ for (const w of items) {
83
+ if (!itemIds.has(w?.id) || w.status !== shared.WI_STATUS.DISPATCHED) continue;
84
+ w.status = shared.WI_STATUS.PENDING;
85
+ delete w.dispatched_at;
86
+ delete w.dispatched_to;
87
+ requeued++;
88
+ }
89
+ return items;
90
+ });
91
+ return requeued;
21
92
  }
22
93
 
23
94
  /**
@@ -56,9 +127,10 @@ function removeProject(target, options = {}) {
56
127
  try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
57
128
  catch (e) { return { ...summary, error: 'Failed to read config: ' + e.message }; }
58
129
 
59
- const project = shared.findProjectByNameOrPath(config.projects || [], target);
130
+ const projects = shared.getProjects(config);
131
+ const project = shared.resolveConfiguredProject(target, projects).project;
60
132
  if (!project) {
61
- const available = (config.projects || []).map(p => p.name).join(', ') || '(none)';
133
+ const available = projects.map(p => p.name).join(', ') || '(none)';
62
134
  return { ...summary, error: `No project linked matching: ${target}. Available: ${available}` };
63
135
  }
64
136
  summary.project = { name: project.name, localPath: project.localPath };
@@ -72,15 +144,17 @@ function removeProject(target, options = {}) {
72
144
  );
73
145
  summary.cancelledItems += dispatch.cancelPendingWorkItems(
74
146
  path.join(MINIONS_DIR, 'work-items.json'),
75
- w => String(w.project || '').toLowerCase() === String(project.name || '').toLowerCase(),
147
+ w => _workItemMatchesProject(w, project, projects),
76
148
  'project-removed',
77
149
  );
78
150
 
79
151
  // 2. Drain dispatch — also kills active agent processes and unlinks pid +
80
152
  // prompt sidecars in engine/tmp/, matching what plan delete does.
153
+ const projectlessCentralItemIds = _collectProjectlessCentralDispatchItemIds(project, projects);
81
154
  summary.drainedDispatches = dispatch.cleanDispatchEntries(
82
- d => String(_dispatchProjectName(d) || '').toLowerCase() === String(project.name || '').toLowerCase(),
155
+ d => _dispatchMatchesProject(d, project, projects),
83
156
  );
157
+ _requeueProjectlessCentralWorkItems(projectlessCentralItemIds);
84
158
 
85
159
  // 3. Clean up worktrees under this project's worktree root, honoring
86
160
  // config.engine.worktreeRoot (mirrors lifecycle.js cleanupPlanWorktrees).
@@ -103,7 +177,7 @@ function removeProject(target, options = {}) {
103
177
  // specifically. Don't touch schedules with project='any' or unset.
104
178
  if (Array.isArray(config.schedules)) {
105
179
  for (const s of config.schedules) {
106
- if (shared.resolveProjectSource(s.project, [project], { allowCentral: false }).project && s.enabled !== false) {
180
+ if (_projectRefMatches(s.project, project, projects) && s.enabled !== false) {
107
181
  s.enabled = false;
108
182
  summary.disabledSchedules++;
109
183
  }
@@ -129,7 +203,7 @@ function removeProject(target, options = {}) {
129
203
  for (const f of fs.readdirSync(prdDir).filter(f => f.endsWith('.json'))) {
130
204
  try {
131
205
  const prd = JSON.parse(fs.readFileSync(path.join(prdDir, f), 'utf8'));
132
- if (prd?.project !== project.name) continue;
206
+ if (!_projectRefMatches(prd?.project, project, projects)) continue;
133
207
  archivePlan(f, prd, [project], config);
134
208
  summary.archivedPlans.push('prd/' + f);
135
209
  if (prd.source_plan) archivedSourcePlans.add(prd.source_plan);
@@ -164,14 +238,17 @@ function removeProject(target, options = {}) {
164
238
  ...(p.monitoredResources || []),
165
239
  ...((p.stages || []).flatMap(s => s.monitoredResources || [])),
166
240
  ];
167
- if (refs.some(r => r && shared.resolveProjectSource(r.project || r._project, [project], { allowCentral: false }).project)) {
241
+ if (refs.some(r => r && (
242
+ _projectRefMatches(r.project, project, projects) ||
243
+ _projectRefMatches(r._project, project, projects)
244
+ ))) {
168
245
  summary.pipelineRefs.push(p.id);
169
246
  }
170
247
  }
171
248
  } catch { /* pipelines optional */ }
172
249
 
173
250
  // 7. Remove from config.json (and persist any schedule disables)
174
- config.projects = (config.projects || []).filter(p => !shared.resolveProjectSource(p, [project], { allowCentral: false }).project);
251
+ config.projects = (config.projects || []).filter(p => !_sameProjectName(p?.name, project.name));
175
252
  try { fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); }
176
253
  catch (e) { return { ...summary, error: 'Failed to write config: ' + e.message }; }
177
254
 
@@ -0,0 +1,169 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const http = require('http');
4
+ const https = require('https');
5
+ const { execSync } = require('child_process');
6
+
7
+ const DEFAULT_RESTART_HEALTH_TIMEOUT_MS = 15000;
8
+ const DEFAULT_RESTART_HEALTH_INTERVAL_MS = 250;
9
+
10
+ function sleep(ms) {
11
+ return new Promise(resolve => setTimeout(resolve, ms));
12
+ }
13
+
14
+ function readEngineControl(minionsHome) {
15
+ try {
16
+ return JSON.parse(fs.readFileSync(path.join(minionsHome, 'engine', 'control.json'), 'utf8'));
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ function normalizePid(pid) {
23
+ const n = Number(pid);
24
+ return Number.isInteger(n) && n > 0 ? n : null;
25
+ }
26
+
27
+ function isProcessAlive(pid) {
28
+ const n = normalizePid(pid);
29
+ if (!n || n === process.pid) return false;
30
+ try {
31
+ if (process.platform === 'win32') {
32
+ const out = execSync(`tasklist /FI "PID eq ${n}" /NH`, {
33
+ encoding: 'utf8',
34
+ windowsHide: true,
35
+ timeout: 3000,
36
+ });
37
+ return new RegExp(`\\b${n}\\b`).test(out) && out.toLowerCase().includes('node');
38
+ }
39
+ process.kill(n, 0);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ function httpGetJson(url, timeoutMs = 1000) {
47
+ return new Promise(resolve => {
48
+ let settled = false;
49
+ const parsed = new URL(url);
50
+ const client = parsed.protocol === 'https:' ? https : http;
51
+ const req = client.get(parsed, { timeout: timeoutMs }, res => {
52
+ let body = '';
53
+ res.setEncoding('utf8');
54
+ res.on('data', chunk => { body += chunk; });
55
+ res.on('end', () => {
56
+ if (settled) return;
57
+ settled = true;
58
+ let json = null;
59
+ try { json = body ? JSON.parse(body) : null; }
60
+ catch (err) {
61
+ resolve({ ok: false, statusCode: res.statusCode, error: err, body });
62
+ return;
63
+ }
64
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, statusCode: res.statusCode, json, body });
65
+ });
66
+ });
67
+ req.on('timeout', () => {
68
+ if (settled) return;
69
+ settled = true;
70
+ req.destroy();
71
+ resolve({ ok: false, error: new Error(`timed out after ${timeoutMs}ms`) });
72
+ });
73
+ req.on('error', err => {
74
+ if (settled) return;
75
+ settled = true;
76
+ resolve({ ok: false, error: err });
77
+ });
78
+ });
79
+ }
80
+
81
+ async function checkRestartHealth(options = {}) {
82
+ const {
83
+ minionsHome,
84
+ dashboardUrl = 'http://127.0.0.1:7331/api/health',
85
+ readControl = readEngineControl,
86
+ isProcessAlive: isAlive = isProcessAlive,
87
+ httpGetJson: getJson = httpGetJson,
88
+ } = options;
89
+
90
+ const control = readControl(minionsHome);
91
+ const pid = normalizePid(control && control.pid);
92
+ const engineAlive = pid ? isAlive(pid) : false;
93
+ const engineOk = control && control.state === 'running' && engineAlive;
94
+
95
+ const dashboard = await getJson(dashboardUrl, 1000);
96
+ const dashboardStatus = dashboard && dashboard.json && dashboard.json.status;
97
+ const dashboardOk = !!(dashboard && dashboard.ok && dashboardStatus === 'healthy');
98
+
99
+ const errors = [];
100
+ if (!engineOk) {
101
+ const state = control && control.state ? control.state : 'unknown';
102
+ const pidLabel = pid || 'none';
103
+ errors.push(`Engine is not healthy (state=${state}, pid=${pidLabel}, alive=${engineAlive ? 'yes' : 'no'})`);
104
+ }
105
+ if (!dashboardOk) {
106
+ const detail = dashboard && dashboard.error
107
+ ? dashboard.error.message
108
+ : dashboard && dashboard.statusCode
109
+ ? `HTTP ${dashboard.statusCode}${dashboardStatus ? `, status=${dashboardStatus}` : ''}`
110
+ : 'no response';
111
+ errors.push(`Dashboard failed health check at ${dashboardUrl} (${detail})`);
112
+ }
113
+
114
+ return {
115
+ ok: engineOk && dashboardOk,
116
+ engine: { state: control && control.state, pid, alive: engineAlive },
117
+ dashboard: { url: dashboardUrl, ok: !!(dashboard && dashboard.ok), statusCode: dashboard && dashboard.statusCode, status: dashboardStatus },
118
+ errors,
119
+ };
120
+ }
121
+
122
+ async function waitForRestartHealth(options = {}) {
123
+ const timeoutMs = options.timeoutMs ?? DEFAULT_RESTART_HEALTH_TIMEOUT_MS;
124
+ const intervalMs = options.intervalMs ?? DEFAULT_RESTART_HEALTH_INTERVAL_MS;
125
+ const maxAttempts = normalizePid(options.maxAttempts);
126
+ const started = Date.now();
127
+ let attempts = 0;
128
+ let last = null;
129
+
130
+ while (true) {
131
+ attempts++;
132
+ last = await checkRestartHealth(options);
133
+ last.attempts = attempts;
134
+ last.elapsedMs = Date.now() - started;
135
+ if (last.ok) return last;
136
+ if (maxAttempts && attempts >= maxAttempts) break;
137
+ const remainingMs = timeoutMs - (Date.now() - started);
138
+ if (!maxAttempts && remainingMs <= 0) break;
139
+ await sleep(maxAttempts ? intervalMs : Math.min(intervalMs, remainingMs));
140
+ }
141
+
142
+ return last || {
143
+ ok: false,
144
+ attempts,
145
+ elapsedMs: Date.now() - started,
146
+ errors: ['Restart health check did not run'],
147
+ };
148
+ }
149
+
150
+ function formatRestartHealthError(result) {
151
+ const elapsed = typeof result.elapsedMs === 'number' ? `${result.elapsedMs}ms` : 'unknown time';
152
+ const attempts = result.attempts || 0;
153
+ const details = (result.errors || ['Unknown restart verification failure']).map(err => ` - ${err}`).join('\n');
154
+ return `\n ERROR: Restart verification failed after ${elapsed} (${attempts} attempt${attempts === 1 ? '' : 's'}).\n${details}\n`;
155
+ }
156
+
157
+ module.exports = {
158
+ DEFAULT_RESTART_HEALTH_TIMEOUT_MS,
159
+ DEFAULT_RESTART_HEALTH_INTERVAL_MS,
160
+ checkRestartHealth,
161
+ waitForRestartHealth,
162
+ formatRestartHealthError,
163
+ _private: {
164
+ httpGetJson,
165
+ isProcessAlive,
166
+ readEngineControl,
167
+ normalizePid,
168
+ },
169
+ };
package/engine/shared.js CHANGED
@@ -2209,7 +2209,8 @@ function sanitizePath(file, baseDir) {
2209
2209
  if (path.isAbsolute(file) || /^[a-zA-Z]:/.test(file)) throw new Error('invalid file path: absolute path not allowed');
2210
2210
  const resolved = path.resolve(baseDir, file);
2211
2211
  const normalizedBase = path.resolve(baseDir);
2212
- if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) {
2212
+ const rel = path.relative(normalizedBase, resolved);
2213
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
2213
2214
  throw new Error('invalid file path: outside allowed directory');
2214
2215
  }
2215
2216
  return resolved;
package/engine.js CHANGED
@@ -2015,8 +2015,11 @@ function safePrdProjectSlug(projectName) {
2015
2015
 
2016
2016
  function safePrdFilenameForProject(projectName, suffix) {
2017
2017
  const fileName = `${safePrdProjectSlug(projectName)}-${suffix}.json`;
2018
- shared.sanitizePath(fileName, PRD_DIR);
2019
- return fileName;
2018
+ const resolved = shared.sanitizePath(fileName, PRD_DIR);
2019
+ if (path.dirname(resolved) !== path.resolve(PRD_DIR)) {
2020
+ throw new Error('invalid PRD filename: nested paths are not allowed');
2021
+ }
2022
+ return path.basename(resolved);
2020
2023
  }
2021
2024
 
2022
2025
  function materializePlansAsWorkItems(config) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1813",
3
+ "version": "0.1.1815",
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"