@yemi33/minions 0.1.1764 → 0.1.1766

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1766 (2026-05-07)
4
+
5
+ ### Features
6
+ - suppress duplicate browser tabs on restart/update
7
+ - pre-flight model compat check for ccCall/ccCallStreaming + Copilot resolveModel
8
+ - probe every registered runtime adapter, not just claude
9
+
10
+ ### Fixes
11
+ - silence 'couldn't find remote ref' warnings on worktree reuse for never-pushed branches (#2155)
12
+ - close remaining test isolation leaks + add end-of-run regression guard (#2152)
13
+ - surface legacy .minions dir removal in logs
14
+ - clear session.json when conversation jsonl missing (W-mouugzow00068741) (#2153)
15
+ - stop archived PRDs from resurrecting via safeJson .backup restore (#2144)
16
+
3
17
  ## 0.1.1764 (2026-05-07)
4
18
 
5
19
  ### Other
package/bin/minions.js CHANGED
@@ -28,9 +28,11 @@ const os = require('os');
28
28
  const { spawn, spawnSync, execSync } = require('child_process');
29
29
 
30
30
  const PKG_ROOT = path.resolve(__dirname, '..');
31
+ const DASH_PORT = 7331;
31
32
 
32
- /** Kill process(es) listening on a given port. Works cross-platform. */
33
- function killByPort(port) {
33
+ /** Returns PIDs (as strings) of processes LISTENING on `port`. Empty on no match
34
+ * or when the platform tool (netstat/findstr/lsof) is unavailable. */
35
+ function getListeningPids(port) {
34
36
  try {
35
37
  if (process.platform === 'win32') {
36
38
  const out = execSync(`netstat -ano | findstr ":${port} " | findstr LISTENING`, { encoding: 'utf8', timeout: 5000, windowsHide: true });
@@ -39,13 +41,25 @@ function killByPort(port) {
39
41
  const pid = line.trim().split(/\s+/).pop();
40
42
  if (pid && /^\d+$/.test(pid) && pid !== '0' && pid !== String(process.pid)) pids.add(pid);
41
43
  }
42
- for (const pid of pids) try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000, windowsHide: true }); } catch {}
43
- } else {
44
- execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null`, { timeout: 5000 });
44
+ return [...pids];
45
45
  }
46
- } catch {}
46
+ const out = execSync(`lsof -ti:${port}`, { encoding: 'utf8', timeout: 5000 });
47
+ return out.split('\n').map(s => s.trim()).filter(Boolean);
48
+ } catch { return []; }
49
+ }
50
+
51
+ /** Kill process(es) listening on a given port. Works cross-platform. */
52
+ function killByPort(port) {
53
+ const pids = getListeningPids(port);
54
+ if (process.platform === 'win32') {
55
+ for (const pid of pids) try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000, windowsHide: true }); } catch {}
56
+ } else {
57
+ for (const pid of pids) try { process.kill(Number(pid), 'SIGKILL'); } catch {}
58
+ }
47
59
  }
48
60
 
61
+ const isPortListening = (port) => getListeningPids(port).length > 0;
62
+
49
63
  /**
50
64
  * Read the engine's recorded PID from engine/control.json. Returns null if
51
65
  * the file is missing/corrupt or the PID isn't a positive integer.
@@ -102,6 +116,18 @@ function killMinionsProcesses(patterns) {
102
116
  }
103
117
  } catch {}
104
118
  }
119
+
120
+ /** Spawn a detached dashboard. When `suppressOpen` is true, the new dashboard
121
+ * skips its auto-open of the browser — the existing tab will SSE-reconnect. */
122
+ function spawnDashboard(suppressOpen) {
123
+ const env = suppressOpen ? { ...process.env, MINIONS_NO_AUTO_OPEN: '1' } : process.env;
124
+ const proc = spawn(process.execPath, [path.join(MINIONS_HOME, 'dashboard.js')], {
125
+ cwd: MINIONS_HOME, stdio: 'ignore', detached: true, windowsHide: true, env
126
+ });
127
+ proc.unref();
128
+ return proc;
129
+ }
130
+
105
131
  const DEFAULT_MINIONS_HOME = path.join(os.homedir(), '.minions');
106
132
  const ROOT_POINTER_PATH = path.join(os.homedir(), '.minions-root');
107
133
  const LEGACY_DEFAULT_SQUAD_HOME = path.join(os.homedir(), '.squad');
@@ -353,10 +379,17 @@ function init() {
353
379
  showChangelog(installedVersion);
354
380
  }
355
381
 
356
- // Run preflight checks (warn only — don't block init)
382
+ // Run preflight checks (warn only — don't block init).
383
+ // includeAllRegistered: probe every registered runtime adapter (claude AND
384
+ // copilot today) so the user sees availability of both, even if their
385
+ // current config only references one. Missing optional runtimes surface as
386
+ // warns, not failures.
357
387
  try {
358
388
  const { runPreflight, printPreflight } = require(path.join(MINIONS_HOME, 'engine', 'preflight'));
359
- const { results } = runPreflight();
389
+ let preflightConfig = null;
390
+ try { preflightConfig = JSON.parse(fs.readFileSync(path.join(MINIONS_HOME, 'config.json'), 'utf8')); }
391
+ catch { /* config may not exist on first init — fine, preflight handles null */ }
392
+ const { results } = runPreflight({ config: preflightConfig, includeAllRegistered: true });
360
393
  printPreflight(results, { label: 'Preflight checks' });
361
394
  } catch {}
362
395
 
@@ -364,8 +397,14 @@ function init() {
364
397
  if (isUpgrade && skipStart) return;
365
398
 
366
399
  // Auto-start on fresh install; direct force-upgrade restarts automatically.
400
+ // Probe before kill so we can suppress the new dashboard's auto-open when an
401
+ // existing tab is already live (it'll SSE-reconnect to the new dashboard).
402
+ const dashWasUp = isPortListening(DASH_PORT);
367
403
  if (isUpgrade) {
368
404
  try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME, timeout: 10000, windowsHide: true }); } catch {}
405
+ // Free the dashboard port too — without this the new dashboard EADDRINUSE-dies
406
+ // silently and the user keeps running stale code from the old dashboard process.
407
+ killByPort(DASH_PORT);
369
408
  }
370
409
  console.log(isUpgrade
371
410
  ? `\n Upgrade complete (${pkgVersion}). Restarting engine and dashboard...\n`
@@ -376,12 +415,9 @@ function init() {
376
415
  engineProc.unref();
377
416
  console.log(` Engine started (PID: ${engineProc.pid})`);
378
417
 
379
- const dashProc = spawn(process.execPath, [path.join(MINIONS_HOME, 'dashboard.js')], {
380
- cwd: MINIONS_HOME, stdio: 'ignore', detached: true, windowsHide: true
381
- });
382
- dashProc.unref();
418
+ const dashProc = spawnDashboard(dashWasUp);
383
419
  console.log(` Dashboard started (PID: ${dashProc.pid})`);
384
- console.log(' Dashboard: http://localhost:7331');
420
+ console.log(` Dashboard: http://localhost:${DASH_PORT}`);
385
421
 
386
422
  // Next steps guidance
387
423
  console.log(`
@@ -634,6 +670,9 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
634
670
  // `--cli` / `--model` flags forward to `engine.js start` so the runtime
635
671
  // fleet flips before the daemon spawns (P-6b3f9c2e AC: works on restart).
636
672
  ensureInstalled();
673
+ // Probe before kill so we can suppress the new dashboard's auto-open when an
674
+ // existing tab is already live (it'll SSE-reconnect to the new dashboard).
675
+ const dashWasUp = isPortListening(DASH_PORT);
637
676
  // Layered kill — each step is best-effort, layered so the next still runs if
638
677
  // one fails. Goal: the old engine is gone before we spawn a new one, even if
639
678
  // PowerShell is unavailable, the engine is hung, or its cmdline doesn't match.
@@ -644,7 +683,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
644
683
  // survive so the new engine can re-attach them via PID files).
645
684
  killPidOnly(oldEnginePid);
646
685
  // 3. Free dashboard port (catches orphan dashboards with no recorded PID).
647
- killByPort(7331);
686
+ killByPort(DASH_PORT);
648
687
  // 4. Belt-and-suspenders cmdline match for anything still alive.
649
688
  killMinionsProcesses(['engine.js', 'dashboard.js']);
650
689
  const engineProc = spawn(process.execPath, [path.join(MINIONS_HOME, 'engine.js'), 'start', ...rest], {
@@ -652,12 +691,9 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
652
691
  });
653
692
  engineProc.unref();
654
693
  console.log(`\n Engine started (PID: ${engineProc.pid})`);
655
- const dashProc = spawn(process.execPath, [path.join(MINIONS_HOME, 'dashboard.js')], {
656
- cwd: MINIONS_HOME, stdio: 'ignore', detached: true, windowsHide: true
657
- });
658
- dashProc.unref();
694
+ const dashProc = spawnDashboard(dashWasUp);
659
695
  console.log(` Dashboard started (PID: ${dashProc.pid})`);
660
- console.log(' Dashboard: http://localhost:7331\n');
696
+ console.log(` Dashboard: http://localhost:${DASH_PORT}\n`);
661
697
  } else if (cmd === 'nuke') {
662
698
  ensureInstalled();
663
699
  if (!rest.includes('--confirm')) {
@@ -688,7 +724,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
688
724
 
689
725
  // 1. Kill all processes
690
726
  try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME }); } catch {}
691
- killByPort(7331);
727
+ killByPort(DASH_PORT);
692
728
  killMinionsProcesses(['engine.js', 'dashboard.js', 'spawn-agent.js']);
693
729
  console.log(' Killed all processes');
694
730
 
@@ -774,7 +810,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
774
810
 
775
811
  // 1. Kill all processes
776
812
  try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME, timeout: 10000 }); } catch {}
777
- killByPort(7331);
813
+ killByPort(DASH_PORT);
778
814
  killMinionsProcesses(['engine.js', 'dashboard.js', 'spawn-agent.js']);
779
815
  console.log(' Killed all processes');
780
816
 
@@ -816,7 +852,6 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
816
852
  ensureInstalled();
817
853
  // If dashboard is already running, just open the browser
818
854
  const net = require('net');
819
- const dashPort = 7331;
820
855
  const sock = new net.Socket();
821
856
  let handled = false;
822
857
  sock.setTimeout(1000);
@@ -824,7 +859,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
824
859
  sock.destroy();
825
860
  if (handled) return;
826
861
  handled = true;
827
- const url = `http://localhost:${dashPort}`;
862
+ const url = `http://localhost:${DASH_PORT}`;
828
863
  console.log(`\n Dashboard already running: ${url}\n`);
829
864
  try {
830
865
  const openCmd = process.platform === 'win32' ? `start ${url}` : process.platform === 'darwin' ? `open ${url}` : `xdg-open ${url}`;
@@ -843,7 +878,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
843
878
  handled = true;
844
879
  delegate('dashboard.js', rest);
845
880
  });
846
- sock.connect(dashPort, '127.0.0.1');
881
+ sock.connect(DASH_PORT, '127.0.0.1');
847
882
  } else if (engineCmds.has(cmd)) {
848
883
  delegate('engine.js', [cmd, ...rest]);
849
884
  } else {
package/dashboard.js CHANGED
@@ -33,7 +33,7 @@ const projectDiscovery = require('./engine/project-discovery');
33
33
  const features = require('./engine/features');
34
34
  const os = require('os');
35
35
 
36
- const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, reopenWorkItem } = shared;
36
+ const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeUnlink, mutateJsonFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, reopenWorkItem } = shared;
37
37
  const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
38
38
  getSkills, getInbox, getNotesWithMeta, getPullRequests,
39
39
  getEngineLog, getMetrics, getKnowledgeBaseEntries, timeSince,
@@ -66,7 +66,17 @@ function ensureConfiguredProjectStateFiles() {
66
66
  const root = p.localPath ? path.resolve(p.localPath) : null;
67
67
  if (!root || !fs.existsSync(root)) continue;
68
68
  try {
69
- shared.ensureProjectStateFiles(p, { migrateLegacy: true, removeLegacy: true });
69
+ const state = shared.ensureProjectStateFiles(p, { migrateLegacy: true, removeLegacy: true });
70
+ if (state.migrated.length > 0 || state.removedLegacy.length > 0 || state.legacyDirRemoved) {
71
+ const parts = [];
72
+ if (state.migrated.length > 0) parts.push(`merged ${state.migrated.join(', ')}`);
73
+ if (state.removedLegacy.length > 0) parts.push(`removed ${state.removedLegacy.join(', ')}`);
74
+ if (state.legacyDirRemoved) parts.push(`removed legacy .minions dir`);
75
+ console.log(`[dashboard] migrated project state for "${p.name}" → projects/${p.name} (${parts.join('; ')})`);
76
+ }
77
+ if (state.legacyDirRemoveError) {
78
+ console.warn(`[dashboard] failed to remove legacy .minions dir for "${p.name}": ${state.legacyDirRemoveError}`);
79
+ }
70
80
  } catch (e) {
71
81
  console.warn(`[dashboard] project state migration failed for "${p.name}": ${e.message}`);
72
82
  }
@@ -2300,6 +2310,50 @@ function updateSession(store, key, sessionId, existing) {
2300
2310
  }
2301
2311
  }
2302
2312
 
2313
+ // Pre-flight check: when the resolved runtime publishes a model catalog, reject
2314
+ // configs whose model isn't in it BEFORE spawning. Without this guard, Copilot
2315
+ // crashes mid-call ("Copilot rejected the requested model"), which empties the
2316
+ // stream, drops the doc-chat session, and surfaces as a confusing
2317
+ // "agent exited unexpectedly" banner with no actionable cause. We pull the
2318
+ // adapter via llm._resolveRuntimeFor + llm._resolveModelForRuntime so the same
2319
+ // resolution path llm.callLLM uses produces the same final model string.
2320
+ // Returns a structured error result ({code, errorClass, errorMessage}) on
2321
+ // rejection, or null when the call should proceed. Catalog-less runtimes
2322
+ // (Claude) always proceed — no opinion.
2323
+ async function _preflightModelCheck({ runtime: cliOverride, model: modelOverride, engineConfig }) {
2324
+ let adapter;
2325
+ try {
2326
+ adapter = llm._resolveRuntimeFor({ cli: cliOverride, engineConfig });
2327
+ } catch { return null; }
2328
+ if (!adapter) return null;
2329
+ const resolvedModel = llm._resolveModelForRuntime(adapter, { model: modelOverride, engineConfig });
2330
+ if (!resolvedModel) return null;
2331
+ if (!adapter.capabilities || adapter.capabilities.modelDiscovery !== true) return null;
2332
+
2333
+ let list;
2334
+ try {
2335
+ const md = require('./engine/model-discovery');
2336
+ list = await md.getRuntimeModels(adapter.name, { config: { engine: engineConfig || {} } });
2337
+ } catch { return null; }
2338
+ if (!list || !Array.isArray(list.models) || list.models.length === 0) return null;
2339
+
2340
+ const known = new Set(list.models.map(m => m && (m.id || m.name)).filter(Boolean));
2341
+ if (known.has(resolvedModel)) return null;
2342
+
2343
+ const sample = [...known].slice(0, 4).join(', ') + (known.size > 4 ? '…' : '');
2344
+ return {
2345
+ text: null,
2346
+ code: 1,
2347
+ errorClass: 'unknown-model',
2348
+ errorMessage: `Configured model "${resolvedModel}" is not valid for runtime "${adapter.name}" (known: ${sample}). Update engine.ccModel or engine.defaultModel.`,
2349
+ runtime: adapter.name,
2350
+ sessionId: null,
2351
+ usage: null,
2352
+ stderr: '',
2353
+ toolUses: [],
2354
+ };
2355
+ }
2356
+
2303
2357
  /**
2304
2358
  * Core LLM call — shared by CC panel and doc modals.
2305
2359
  * @param {string} message - User message
@@ -2316,6 +2370,13 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
2316
2370
  if (!maxTurns) maxTurns = CONFIG.engine?.ccMaxTurns || shared.ENGINE_DEFAULTS.ccMaxTurns;
2317
2371
  if (!model) model = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
2318
2372
  const ccEffort = CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort;
2373
+
2374
+ const preflight = await _preflightModelCheck({ model, engineConfig: CONFIG.engine });
2375
+ if (preflight) {
2376
+ console.warn(`[${label}] Pre-flight rejected: ${preflight.errorMessage}`);
2377
+ return preflight;
2378
+ }
2379
+
2319
2380
  const existing = resolveSession(store, sessionKey);
2320
2381
  let sessionId = existing ? existing.sessionId : null;
2321
2382
 
@@ -2405,6 +2466,13 @@ async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext
2405
2466
  if (!maxTurns) maxTurns = CONFIG.engine?.ccMaxTurns || shared.ENGINE_DEFAULTS.ccMaxTurns;
2406
2467
  if (!model) model = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
2407
2468
  const ccEffort = CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort;
2469
+
2470
+ const preflight = await _preflightModelCheck({ model, engineConfig: CONFIG.engine });
2471
+ if (preflight) {
2472
+ console.warn(`[${label}] Pre-flight rejected: ${preflight.errorMessage}`);
2473
+ return preflight;
2474
+ }
2475
+
2408
2476
  const existing = resolveSession(store, sessionKey);
2409
2477
  let sessionId = existing ? existing.sessionId : null;
2410
2478
 
@@ -2574,6 +2642,7 @@ function _docChatErrorMessage(errorClass, sessionPreserved = false, toolUses = [
2574
2642
  if (errorClass === 'context-limit') return 'Session context is too long. Click "Clear" to start a fresh conversation.' + toolHint;
2575
2643
  if (errorClass === 'budget-exceeded') return (errorMessage || 'Runtime budget exceeded — check your account or quota.') + toolHint;
2576
2644
  if (errorClass === 'crash') return (errorMessage || 'Runtime crashed unexpectedly. Try again.') + toolHint;
2645
+ if (errorClass === 'unknown-model') return (errorMessage || 'Configured model is not valid for the active runtime. Update engine.ccModel or engine.defaultModel.') + toolHint;
2577
2646
  if (sessionPreserved) return 'Temporary connection issue — your conversation is intact, send your message again.' + toolHint;
2578
2647
  if (tools.length > 0) return 'The agent stopped responding before producing a final answer.' + toolHint;
2579
2648
  return 'Failed to process request. Try again.';
@@ -3161,7 +3230,11 @@ const server = http.createServer(async (req, res) => {
3161
3230
 
3162
3231
  const config = queries.getConfig();
3163
3232
  const project = PROJECTS.find(p => {
3164
- const plan = safeJson(activePath) || safeJson(prdPath);
3233
+ // safeJsonNoRestore never resurrect an archived PRD's .backup
3234
+ // sidecar during project resolution (W-mouptdh1000h9f39). The
3235
+ // active-from-archive write above (mutateJsonFileLocked) already
3236
+ // created activePath when needed.
3237
+ const plan = safeJsonNoRestore(activePath) || safeJsonNoRestore(prdPath);
3165
3238
  return plan && p.name?.toLowerCase() === (plan.project || '').toLowerCase();
3166
3239
  }) || PROJECTS[0] || null;
3167
3240
 
@@ -3241,9 +3314,13 @@ const server = http.createServer(async (req, res) => {
3241
3314
  const prdFile = body.prdFile;
3242
3315
  if (!prdFile) return jsonReply(res, 404, { error: 'work item not found in any source' });
3243
3316
 
3244
- // Look up PRD item to create a new work item on-demand
3317
+ // Look up PRD item to create a new work item on-demand.
3318
+ // safeJsonNoRestore — PRDs are terminal artifacts; a stale .backup
3319
+ // sidecar from an archived PRD must not resurrect the active PRD
3320
+ // just because a user clicked retry on a stray work item
3321
+ // (W-mouptdh1000h9f39).
3245
3322
  const prdPath = path.join(PRD_DIR, prdFile);
3246
- const plan = shared.safeJson(prdPath);
3323
+ const plan = shared.safeJsonNoRestore(prdPath);
3247
3324
  if (!plan?.missing_features) return jsonReply(res, 404, { error: 'PRD file not found or invalid' });
3248
3325
  const prdItem = plan.missing_features.find(f => f.id === id);
3249
3326
  if (!prdItem) return jsonReply(res, 404, { error: 'PRD item not found in ' + prdFile });
@@ -7571,18 +7648,24 @@ if (require.main === module) {
7571
7648
  console.log(` Projects: ${PROJECTS.map(p => `${p.name} (${p.localPath})`).join(', ')}`);
7572
7649
  console.log(`\n Auto-refreshes every 4s. Ctrl+C to stop.\n`);
7573
7650
 
7574
- const { exec } = require('child_process');
7575
- try {
7576
- if (process.platform === 'win32') {
7577
- exec(`start "" "http://localhost:${PORT}"`);
7578
- } else if (process.platform === 'darwin') {
7579
- exec(`open http://localhost:${PORT}`);
7580
- } else {
7581
- exec(`xdg-open http://localhost:${PORT}`);
7651
+ // Auto-open the browser unless suppressed. `minions restart` and the
7652
+ // upgrade path set MINIONS_NO_AUTO_OPEN=1 when the previous dashboard was
7653
+ // already listening the existing tab will SSE-reconnect, so a new tab
7654
+ // would just be a duplicate.
7655
+ if (!process.env.MINIONS_NO_AUTO_OPEN) {
7656
+ const { exec } = require('child_process');
7657
+ try {
7658
+ if (process.platform === 'win32') {
7659
+ exec(`start "" "http://localhost:${PORT}"`);
7660
+ } else if (process.platform === 'darwin') {
7661
+ exec(`open http://localhost:${PORT}`);
7662
+ } else {
7663
+ exec(`xdg-open http://localhost:${PORT}`);
7664
+ }
7665
+ } catch (e) {
7666
+ console.log(` Could not auto-open browser: ${e.message}`);
7667
+ console.log(` Please open http://localhost:${PORT} manually.`);
7582
7668
  }
7583
- } catch (e) {
7584
- console.log(` Could not auto-open browser: ${e.message}`);
7585
- console.log(` Please open http://localhost:${PORT} manually.`);
7586
7669
  }
7587
7670
 
7588
7671
  // ─── Engine Watchdog ─────────────────────────────────────────────────────
package/engine/cleanup.js CHANGED
@@ -9,7 +9,7 @@ const shared = require('./shared');
9
9
  const queries = require('./queries');
10
10
 
11
11
  const { exec, execAsync, execSilent, log, ts, ENGINE_DEFAULTS } = shared;
12
- const { safeJson, safeWrite, safeReadDir, mutateCooldowns, mutateWorkItems, mutateJsonFileLocked, getProjects, projectWorkItemsPath, projectPrPath,
12
+ const { safeJson, safeJsonNoRestore, safeWrite, safeReadDir, mutateCooldowns, mutateWorkItems, mutateJsonFileLocked, getProjects, projectWorkItemsPath, projectPrPath,
13
13
  sanitizeBranch, KB_CATEGORIES } = shared;
14
14
  const { getDispatch, getAgentStatus } = queries;
15
15
 
@@ -688,7 +688,10 @@ async function runCleanup(config, verbose = false) {
688
688
  const prdPath = path.join(PRD_DIR, pf);
689
689
  let migrated = 0;
690
690
  shared.withFileLock(`${prdPath}.lock`, () => {
691
- const prd = safeJson(prdPath);
691
+ // safeJsonNoRestore: PRDs are terminal artifacts. If the active PRD
692
+ // was archived but a stale .backup sidecar remains, never resurrect
693
+ // it during a routine migration sweep (W-mouptdh1000h9f39).
694
+ const prd = safeJsonNoRestore(prdPath);
692
695
  if (!prd?.missing_features) return;
693
696
  for (const feat of prd.missing_features) {
694
697
  if (LEGACY_DONE_ALIASES.has(feat.status)) {
@@ -725,7 +728,9 @@ async function runCleanup(config, verbose = false) {
725
728
  catch { orphanPrdEntries = []; }
726
729
  for (const pf of orphanPrdEntries) {
727
730
  const prdPath = path.join(PRD_DIR, pf);
728
- const peek = safeJson(prdPath);
731
+ // safeJsonNoRestore — peek must not auto-restore archived PRDs
732
+ // from a stale .backup sidecar (W-mouptdh1000h9f39).
733
+ const peek = safeJsonNoRestore(prdPath);
729
734
  if (!peek?.missing_features) continue;
730
735
  let reset = 0;
731
736
  mutateJsonFileLocked(prdPath, (prd) => {
package/engine/cli.js CHANGED
@@ -431,8 +431,16 @@ const commands = {
431
431
  } else {
432
432
  try {
433
433
  const state = shared.ensureProjectStateFiles(p, { migrateLegacy: true, removeLegacy: true });
434
- if (state.migrated.length > 0 || state.removedLegacy.length > 0) {
435
- e.log('info', `Migrated project state for "${p.name}" to projects/${p.name}`);
434
+ if (state.migrated.length > 0 || state.removedLegacy.length > 0 || state.legacyDirRemoved) {
435
+ const parts = [];
436
+ if (state.migrated.length > 0) parts.push(`merged ${state.migrated.join(', ')}`);
437
+ if (state.removedLegacy.length > 0) parts.push(`removed ${state.removedLegacy.join(', ')}`);
438
+ if (state.legacyDirRemoved) parts.push(`removed legacy .minions dir`);
439
+ e.log('info', `Migrated project state for "${p.name}" → projects/${p.name} (${parts.join('; ')})`);
440
+ }
441
+ if (state.legacyDirRemoveError) {
442
+ e.log('warn', `Failed to remove legacy .minions dir for "${p.name}": ${state.legacyDirRemoveError}`);
443
+ console.log(` WARNING: legacy .minions dir for "${p.name}" could not be removed: ${state.legacyDirRemoveError}`);
436
444
  }
437
445
  } catch (err) {
438
446
  e.log('warn', `Project state migration failed for "${p.name}": ${err.message}`);
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-07T01:23:41.823Z"
4
+ "cachedAt": "2026-05-07T03:30:56.716Z"
5
5
  }
@@ -7,7 +7,7 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
9
  const shared = require('./shared');
10
- const { safeRead, safeJson, safeWrite, mutateJsonFileLocked, mutateWorkItems, execSilent, execAsync, projectPrPath, getPrLinks,
10
+ const { safeRead, safeJson, safeJsonNoRestore, safeWrite, mutateJsonFileLocked, mutateWorkItems, execSilent, execAsync, projectPrPath, getPrLinks,
11
11
  log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PR_STATUS, DISPATCH_RESULT,
12
12
  ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
13
13
  const { trackEngineUsage } = require('./llm');
@@ -24,7 +24,11 @@ function checkPlanCompletion(meta, config) {
24
24
  const planFile = meta.item?.sourcePlan;
25
25
  if (!planFile) return;
26
26
  const planPath = path.join(PRD_DIR, planFile);
27
- const plan = safeJson(planPath);
27
+ // Use safeJsonNoRestore — if the active PRD has been archived, the .backup
28
+ // sidecar may still be sitting in prd/. safeJson would auto-restore it and
29
+ // resurrect the archived PRD as active (W-mouptdh1000h9f39). PRDs are
30
+ // terminal artifacts; missing primary means "gone", not "needs recovery".
31
+ const plan = safeJsonNoRestore(planPath);
28
32
  if (!plan?.missing_features) return;
29
33
  if (plan.status === PLAN_STATUS.COMPLETED) {
30
34
  if (plan._completionNotified) return;
@@ -580,7 +584,9 @@ function syncPrdItemStatus(itemId, status, sourcePlan) {
580
584
  for (const pf of files) {
581
585
  const fpath = path.join(prdDir, pf);
582
586
  // Lock-free peek: most PRDs won't contain the ID, so skip the lock cost.
583
- const plan = safeJson(fpath);
587
+ // safeJsonNoRestore so an archived PRD's .backup sidecar can't resurrect
588
+ // the active PRD just because a done WI still references it.
589
+ const plan = safeJsonNoRestore(fpath);
584
590
  const feature = plan?.missing_features?.find(f => f.id === itemId);
585
591
  if (!feature || feature.status === status) continue;
586
592
  let updated = false;
@@ -8,7 +8,7 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const shared = require('./shared');
10
10
  const queries = require('./queries');
11
- const { safeJson, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
11
+ const { safeJson, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
12
12
  const routing = require('./routing');
13
13
  const http = require('http');
14
14
  const { parseCronExpr, shouldRunNow } = require('./scheduler');
@@ -388,7 +388,9 @@ function _findExistingPrdForPlan(planFile, prdDir) {
388
388
  if (!fs.existsSync(prdDir)) return null;
389
389
  const prdFiles = safeReadDir(prdDir).filter(f => f.endsWith('.json'));
390
390
  for (const pf of prdFiles) {
391
- const prd = safeJson(path.join(prdDir, pf));
391
+ // safeJsonNoRestore: PRDs are terminal artifacts — never restore archived
392
+ // PRDs from a stale .backup sidecar (W-mouptdh1000h9f39).
393
+ const prd = safeJsonNoRestore(path.join(prdDir, pf));
392
394
  if (prd?.source_plan === planFile) return pf;
393
395
  }
394
396
  return null;
@@ -709,7 +711,8 @@ function isStageComplete(stage, stageState, run, config) {
709
711
  const prdFiles = fs.existsSync(prdDir) ? safeReadDir(prdDir).filter(f => f.endsWith('.json')) : [];
710
712
  for (const planFile of plans) {
711
713
  for (const pf of prdFiles) {
712
- const prd = safeJson(path.join(prdDir, pf));
714
+ // safeJsonNoRestore see _findExistingPrdForPlan above (W-mouptdh1000h9f39).
715
+ const prd = safeJsonNoRestore(path.join(prdDir, pf));
713
716
  if (prd?.source_plan === planFile && !(artifacts.prds || []).includes(pf) && !discoveredPrds.includes(pf)) {
714
717
  discoveredPrds.push(pf);
715
718
  }
@@ -109,8 +109,13 @@ function _distinctRuntimes(config) {
109
109
  * Try to resolve a runtime's binary via the registry. Returns a preflight
110
110
  * result entry — never throws. Unknown-runtime errors collapse to a single
111
111
  * warn entry so the rest of the loop keeps running.
112
+ *
113
+ * When `optional` is true, a missing binary surfaces as `ok: 'warn'` instead
114
+ * of `ok: false`. Used for runtimes the user hasn't configured but that are
115
+ * registered in the adapter registry — surfacing availability without making
116
+ * the check itself fail when the user only uses one runtime.
112
117
  */
113
- function _checkRuntimeBinary(runtimeName) {
118
+ function _checkRuntimeBinary(runtimeName, { optional = false } = {}) {
114
119
  let adapter;
115
120
  try {
116
121
  adapter = require('./runtimes').resolveRuntime(runtimeName);
@@ -139,8 +144,10 @@ function _checkRuntimeBinary(runtimeName) {
139
144
  : `${runtimeName} CLI binary not found on PATH`;
140
145
  return {
141
146
  name: `Runtime: ${runtimeName}`,
142
- ok: false,
143
- message: `not found — ${hint}`,
147
+ ok: optional ? 'warn' : false,
148
+ message: optional
149
+ ? `not installed (optional) — ${hint}`
150
+ : `not found — ${hint}`,
144
151
  };
145
152
  }
146
153
 
@@ -172,6 +179,11 @@ function _warmModelCache(runtimeName, config) {
172
179
  * - warnOnly: if true, missing items don't cause passed=false (for init)
173
180
  * - verbose: include extra detail in messages
174
181
  * - config: fleet config for runtime checks + runtime-config warnings
182
+ * - includeAllRegistered: when true, every adapter registered in the runtime
183
+ * registry is probed even if the user hasn't
184
+ * configured it. Configured runtimes still fail
185
+ * critically when missing; non-configured ones
186
+ * surface as warn-level "not installed (optional)".
175
187
  */
176
188
  function runPreflight(opts = {}) {
177
189
  const results = [];
@@ -198,10 +210,20 @@ function runPreflight(opts = {}) {
198
210
 
199
211
  // 3. Per-runtime binary check (P-9e8a3f1d). Legacy single-runtime callers
200
212
  // (no config passed) still get exactly one entry — for `claude` — so the
201
- // historical "3 results" shape is preserved.
202
- const runtimes = _distinctRuntimes(opts.config);
213
+ // historical "3 results" shape is preserved. With includeAllRegistered,
214
+ // add every registered adapter (e.g. copilot) as an optional probe so the
215
+ // CLI surfaces availability without forcing the user to configure it.
216
+ const configuredRuntimes = _distinctRuntimes(opts.config);
217
+ let runtimes = configuredRuntimes;
218
+ if (opts.includeAllRegistered) {
219
+ let registered = [];
220
+ try { registered = require('./runtimes').listRuntimes(); }
221
+ catch { /* registry may be missing during partial installs */ }
222
+ runtimes = Array.from(new Set([...configuredRuntimes, ...registered])).sort();
223
+ }
224
+ const configuredSet = new Set(configuredRuntimes);
203
225
  for (const runtimeName of runtimes) {
204
- const r = _checkRuntimeBinary(runtimeName);
226
+ const r = _checkRuntimeBinary(runtimeName, { optional: !configuredSet.has(runtimeName) });
205
227
  if (r.ok === false) allOk = false;
206
228
  results.push(r);
207
229
  // Warm the model cache in the background — silent no-op when the
@@ -294,7 +294,40 @@ function getSkillWriteTargets({ homeDir = os.homedir(), project = null } = {}) {
294
294
  // engine.js:1195 fires. See W-mot9fwya000d09cb.
295
295
  const RUNTIME_NAME = 'claude';
296
296
 
297
- function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60 * 60 * 1000, logger = console } = {}) {
297
+ /**
298
+ * Compute the directory Claude CLI uses to persist a project's conversation
299
+ * jsonl files: `~/.claude/projects/<dashed-cwd>/`. The hash algorithm replaces
300
+ * any character outside `[a-zA-Z0-9-]` with `-` (verified empirically against
301
+ * existing project dirs on multiple platforms — preserves dashes, dashes out
302
+ * separators, drive letters, dots, and spaces).
303
+ */
304
+ function _claudeProjectHashDir(cwd, homeDir) {
305
+ if (!cwd || !homeDir) return null;
306
+ const dashed = String(cwd).replace(/[^a-zA-Z0-9-]/g, '-');
307
+ return path.join(homeDir, '.claude', 'projects', dashed);
308
+ }
309
+
310
+ /**
311
+ * Pre-spawn resume lookup. Returns the session ID to pass to `--resume`, or
312
+ * null when the cached session is unusable (stale by age, runtime mismatch,
313
+ * or — see W-mouugzow00068741 — references a UUID Claude never persisted to
314
+ * its on-disk conversation log).
315
+ *
316
+ * Stale-resume-target case (the W-mouugzow00068741 bug): the engine writes
317
+ * `session.json` as soon as Claude emits its first session_id, but Claude only
318
+ * persists the conversation to `~/.claude/projects/<hash>/<uuid>.jsonl` on a
319
+ * stable checkpoint. If the agent dies before checkpoint, session.json holds
320
+ * a UUID that no jsonl file backs. On retry, `--resume <uuid>` fails with
321
+ * "No conversation found", the retry burns instantly, and (because the failed
322
+ * retry never reaches saveSession) session.json never gets overwritten — so
323
+ * every subsequent retry hits the same dead UUID until the 2h TTL kicks in.
324
+ *
325
+ * Fix: when `cwd` is provided, probe for the jsonl. If it's absent, treat the
326
+ * session as never-persisted, clear session.json, and return null so the
327
+ * caller spawns fresh. The probe is opt-in (cwd-gated) — older callers that
328
+ * don't pass cwd keep the pre-fix behavior unchanged.
329
+ */
330
+ function getResumeSessionId({ agentId, branchName, agentsDir, cwd = null, homeDir = os.homedir(), maxAgeMs = 2 * 60 * 60 * 1000, logger = console } = {}) {
298
331
  if (!agentId || agentId.startsWith('temp-') || !agentsDir) return null;
299
332
  const sessionPath = path.join(agentsDir, agentId, 'session.json');
300
333
  try {
@@ -314,6 +347,23 @@ function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60
314
347
  return null;
315
348
  }
316
349
 
350
+ // Stale-resume-target invalidation (W-mouugzow00068741). Claude only writes
351
+ // the jsonl on stable checkpoint; a session ID stamped before checkpoint
352
+ // dies with the agent. Probe for the jsonl and clear session.json when
353
+ // it's missing so the next dispatch spawns fresh instead of looping
354
+ // through `--resume <dead-uuid>` failures.
355
+ if (cwd) {
356
+ const projectDir = _claudeProjectHashDir(cwd, homeDir);
357
+ const jsonlPath = projectDir ? path.join(projectDir, `${sessionFile.sessionId}.jsonl`) : null;
358
+ if (jsonlPath && !fs.existsSync(jsonlPath)) {
359
+ if (logger && typeof logger.info === 'function') {
360
+ logger.info(`Skipping resume for ${agentId}: no conversation jsonl at ${jsonlPath} (stale resume target — agent died before checkpoint) — clearing session.json`);
361
+ }
362
+ try { fs.unlinkSync(sessionPath); } catch {}
363
+ return null;
364
+ }
365
+ }
366
+
317
367
  const sessionAge = Date.now() - new Date(sessionFile.savedAt).getTime();
318
368
  const sameBranch = branchName && sessionFile.branch && sessionFile.branch === branchName;
319
369
  if (sessionAge < maxAgeMs && sameBranch) {
@@ -158,6 +158,15 @@ function resolveBinary({ env = process.env } = {}) {
158
158
  // adapter maps those aliases to Copilot model IDs while passing all other input
159
159
  // through verbatim. The capability remains false: Copilot CLI does not accept
160
160
  // these aliases directly.
161
+ //
162
+ // `resolveModel` ALSO normalizes Anthropic-style hyphenated IDs that callers may
163
+ // have inherited from a Claude-runtime config. `claude-opus-4-7` and
164
+ // `claude-opus-4-7-1m` are passed through verbatim by Claude (which has no
165
+ // catalog), so when a user runs `minions config set-cli copilot` without
166
+ // updating `defaultModel` the hyphenated ID lands in Copilot's --model arg and
167
+ // the CLI rejects it. We translate the inter-version hyphen to a dot and fall
168
+ // through to catalog matching when one is cached, which is the common case for
169
+ // installs that have run model discovery at least once.
161
170
 
162
171
  const _MINIONS_MODEL_ALIASES = {
163
172
  haiku: 'claude-haiku-4.5',
@@ -165,12 +174,76 @@ const _MINIONS_MODEL_ALIASES = {
165
174
  opus: 'claude-opus-4.5',
166
175
  };
167
176
 
177
+ // Read the cached catalog (engine/copilot-models.json) and return a Set of IDs.
178
+ // Returns null when the cache is missing/empty so callers can fall through to
179
+ // passthrough behavior — never throws, never reaches the network.
180
+ function _readCatalogIds() {
181
+ const cached = _safeJson(MODELS_CACHE);
182
+ const list = cached && Array.isArray(cached.models) ? cached.models : null;
183
+ if (!list || list.length === 0) return null;
184
+ const ids = new Set();
185
+ for (const m of list) {
186
+ const id = m && (m.id || m.name);
187
+ if (id) ids.add(String(id));
188
+ }
189
+ return ids.size > 0 ? ids : null;
190
+ }
191
+
192
+ // Translate Anthropic-style hyphenated `claude-(family)-X-Y[-suffix]` to
193
+ // Copilot's dot form `claude-(family)-X.Y[-suffix]`. Returns the transformed
194
+ // string (or the original when the pattern doesn't match) — a separate catalog
195
+ // match check then decides whether the result is usable.
196
+ function _hyphenToDotVersion(s) {
197
+ return s.replace(/^(claude-(?:opus|sonnet|haiku))-(\d+)-(\d+)(.*)$/i, '$1-$2.$3$4');
198
+ }
199
+
168
200
  function resolveModel(input) {
169
201
  if (input == null || input === '') return undefined;
170
202
  const s = String(input);
171
203
  const mapped = _MINIONS_MODEL_ALIASES[s.toLowerCase()];
172
204
  if (mapped) return mapped;
173
- return s;
205
+
206
+ // Call through module.exports so tests can monkey-patch _readCatalogIds
207
+ // without writing to the live engine/copilot-models.json cache. Same
208
+ // runtime behavior as a direct call when the export hasn't been replaced.
209
+ const catalog = module.exports._readCatalogIds();
210
+ if (catalog && catalog.has(s)) return s;
211
+
212
+ const dotted = _hyphenToDotVersion(s);
213
+ if (dotted !== s && catalog) {
214
+ if (catalog.has(dotted)) return dotted;
215
+ // `-1m` Anthropic suffix maps to Copilot's `-1m-internal` variant when
216
+ // present in the catalog. Other suffixes pass through unchanged.
217
+ if (/-1m$/i.test(dotted) && catalog.has(`${dotted}-internal`)) {
218
+ return `${dotted}-internal`;
219
+ }
220
+ }
221
+ // No catalog (test env, no token) OR no match — passthrough so the original
222
+ // input still reaches the CLI for an authoritative reject. Hyphen→dot is
223
+ // applied even without a catalog, since the dot form is universally what
224
+ // Copilot expects and the alternative is a guaranteed reject.
225
+ return dotted !== s && !catalog ? dotted : s;
226
+ }
227
+
228
+ // Heuristic: does `model` look like a Copilot-namespace identifier? Mirrors
229
+ // engine/runtimes/claude.js#modelLooksFamiliar so engine/cli.js#_modelLooksIncompatible
230
+ // can warn when a fleet/CC switch strands an incompatible model. Accepts the
231
+ // three Minions short aliases (`sonnet`/`opus`/`haiku`) since the adapter
232
+ // translates them to valid Copilot IDs at argv-build time. Returning false
233
+ // means "this looks wrong for Copilot" — anything outside Anthropic / OpenAI /
234
+ // catalog membership is flagged.
235
+ function modelLooksFamiliar(model) {
236
+ if (!model) return true;
237
+ const m = String(model).toLowerCase();
238
+ if (m === 'sonnet' || m === 'opus' || m === 'haiku') return true;
239
+ if (m.startsWith('claude-')) return true;
240
+ if (m.startsWith('gpt-')) return true;
241
+ if (m.startsWith('o3') || m.startsWith('o4')) return true;
242
+ // Mirror resolveModel: route through module.exports so test monkey-patching
243
+ // of _readCatalogIds reaches this call site too.
244
+ const catalog = module.exports._readCatalogIds();
245
+ if (catalog && catalog.has(m)) return true;
246
+ return false;
174
247
  }
175
248
 
176
249
  /**
@@ -872,6 +945,7 @@ module.exports = {
872
945
  usesSystemPromptFile,
873
946
  classifyFailure,
874
947
  resolveModel,
948
+ modelLooksFamiliar,
875
949
  parseOutput,
876
950
  parseStreamChunk,
877
951
  parseError,
@@ -881,5 +955,7 @@ module.exports = {
881
955
  _MINIONS_MODEL_ALIASES,
882
956
  _mapEffort,
883
957
  _copilotAssistantMessageHasTools,
958
+ _readCatalogIds,
959
+ _hyphenToDotVersion,
884
960
  KNOWN_EVENT_TYPES,
885
961
  };
package/engine/shared.js CHANGED
@@ -283,6 +283,40 @@ function safeJsonObj(p) { return safeJson(p) || {}; }
283
283
  /** Null-safe safeJson wrapper — returns [] when file is missing/corrupt. */
284
284
  function safeJsonArr(p) { return safeJson(p) || []; }
285
285
 
286
+ /**
287
+ * Sibling of safeJson for terminal-artifact reads (PRDs in `prd/`, archived
288
+ * plans, anything where a missing primary should NOT auto-restore from a
289
+ * stale `.backup` sidecar). Returns the parsed JSON on success, or null when
290
+ * the primary is missing or unparseable.
291
+ *
292
+ * Why a separate primitive: safeJson's restore-on-miss is correct for live
293
+ * state files (work-items.json, dispatch.json, pull-requests.json, etc.) but
294
+ * actively harmful for terminal artifacts. Archived PRDs leave a `.backup`
295
+ * sidecar in `prd/`; if any caller reads the active path with safeJson, the
296
+ * .backup is silently restored and the dashboard sees a phantom "active" PRD
297
+ * (W-mouptdh1000h9f39). PRDs are end-state — no automatic resurrection.
298
+ *
299
+ * Parse errors are logged so silent corruption still surfaces (mirrors
300
+ * safeJson's contract). Read errors other than ENOENT are also logged.
301
+ */
302
+ function safeJsonNoRestore(p) {
303
+ let raw;
304
+ try {
305
+ raw = fs.readFileSync(p, 'utf8');
306
+ } catch (e) {
307
+ if (e && e.code !== 'ENOENT') {
308
+ console.warn(`[safeJsonNoRestore] read failed for ${path.basename(p)}: ${e.message}`);
309
+ }
310
+ return null;
311
+ }
312
+ try {
313
+ return JSON.parse(raw);
314
+ } catch (parseErr) {
315
+ console.error(`[safeJsonNoRestore] parse failure for ${path.basename(p)}: ${parseErr.message}`);
316
+ return null;
317
+ }
318
+ }
319
+
286
320
  /**
287
321
  * Monotonic counter for generating unique temp file names within this process.
288
322
  * Assumes single-thread execution (no worker_threads). If worker_threads are
@@ -1614,12 +1648,15 @@ function sameResolvedPath(a, b) {
1614
1648
 
1615
1649
  function removeLegacyProjectStateDir(project) {
1616
1650
  const dir = legacyProjectStateDir(project);
1617
- if (!dir) return false;
1618
- if (sameResolvedPath(dir, MINIONS_DIR)) return false;
1651
+ if (!dir) return { removed: false, error: null };
1652
+ if (sameResolvedPath(dir, MINIONS_DIR)) return { removed: false, error: null };
1653
+ if (!fs.existsSync(dir)) return { removed: false, error: null };
1619
1654
  try {
1620
1655
  fs.rmSync(dir, { recursive: true, force: true });
1621
- return true;
1622
- } catch { return false; }
1656
+ return { removed: true, error: null };
1657
+ } catch (err) {
1658
+ return { removed: false, error: err && err.message ? err.message : String(err) };
1659
+ }
1623
1660
  }
1624
1661
 
1625
1662
  function ensureProjectStateFiles(project, options = {}) {
@@ -1629,7 +1666,7 @@ function ensureProjectStateFiles(project, options = {}) {
1629
1666
  { name: 'pull-requests.json', centralPath: projectPrPath(project) },
1630
1667
  { name: 'work-items.json', centralPath: projectWorkItemsPath(project) },
1631
1668
  ];
1632
- const result = { created: [], migrated: [], removedLegacy: [], legacyDirRemoved: false };
1669
+ const result = { created: [], migrated: [], removedLegacy: [], legacyDirRemoved: false, legacyDirRemoveError: null };
1633
1670
 
1634
1671
  projectStateDirEnsure(project);
1635
1672
  for (const file of files) {
@@ -1662,7 +1699,11 @@ function ensureProjectStateFiles(project, options = {}) {
1662
1699
  }
1663
1700
  }
1664
1701
 
1665
- if (removeLegacy) result.legacyDirRemoved = removeLegacyProjectStateDir(project);
1702
+ if (removeLegacy) {
1703
+ const res = removeLegacyProjectStateDir(project);
1704
+ result.legacyDirRemoved = res.removed;
1705
+ result.legacyDirRemoveError = res.error;
1706
+ }
1666
1707
  return result;
1667
1708
  }
1668
1709
 
@@ -2963,7 +3004,7 @@ module.exports = {
2963
3004
  log,
2964
3005
  safeRead,
2965
3006
  safeReadDir,
2966
- safeJson, safeJsonObj, safeJsonArr,
3007
+ safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore,
2967
3008
  safeWrite,
2968
3009
  safeUnlink,
2969
3010
  neutralizeJsonBackupSidecar,
package/engine.js CHANGED
@@ -91,6 +91,7 @@ const { getProjects, projectRoot, projectStateDir, projectWorkItemsPath, project
91
91
  // ─── Utilities ──────────────────────────────────────────────────────────────
92
92
 
93
93
  const safeJson = shared.safeJson;
94
+ const safeJsonNoRestore = shared.safeJsonNoRestore;
94
95
  const safeRead = shared.safeRead;
95
96
  const safeWrite = shared.safeWrite;
96
97
  const safeUnlink = shared.safeUnlink;
@@ -430,6 +431,45 @@ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
430
431
  return results;
431
432
  }
432
433
 
434
+ /**
435
+ * Sync an existing worktree from origin on reuse: probe with
436
+ * `git ls-remote --exit-code --heads origin <branch>` first so that locally
437
+ * created branches whose first attempt died before push (agent timeout / orphan
438
+ * retry) skip fetch+pull silently instead of emitting a warn-level
439
+ * "couldn't find remote ref" pair on every reuse.
440
+ *
441
+ * Genuine fetch/pull failures (network, auth, conflict) still surface as warn.
442
+ * Never throws — the dispatch must continue regardless of sync outcome.
443
+ *
444
+ * Exported for testing; production callers ignore the return value.
445
+ *
446
+ * @returns {Promise<{skipped: boolean, reason?: string}>}
447
+ */
448
+ async function syncReusedWorktree(rootDir, worktreePath, branchName, gitOpts = {}) {
449
+ // ls-remote --exit-code returns 2 when no matching refs are found on the
450
+ // remote. The probe only lists refs (no object transfer), so it's cheap
451
+ // even on slow links.
452
+ let onOrigin = true;
453
+ try {
454
+ await execAsync(
455
+ `git ls-remote --exit-code --heads origin "${branchName}"`,
456
+ { ...gitOpts, cwd: rootDir, timeout: 5000 },
457
+ );
458
+ } catch (e) {
459
+ // Exit code 2 = ref not on remote (the noisy case we want to silence).
460
+ // Any other failure (network, auth, timeout) we let pass through so the
461
+ // subsequent fetch surfaces a real warn with its native error message.
462
+ if (e && e.code === 2) onOrigin = false;
463
+ }
464
+ if (!onOrigin) {
465
+ log('info', `Branch ${branchName} not on origin yet — first push pending; skipping fetch/pull`);
466
+ return { skipped: true, reason: 'no-upstream' };
467
+ }
468
+ try { await execAsync(`git fetch origin "${branchName}"`, { ...gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
469
+ try { await execAsync(`git pull origin "${branchName}"`, { ...gitOpts, cwd: worktreePath }); } catch (e) { log('warn', 'git: ' + e.message); }
470
+ return { skipped: false };
471
+ }
472
+
433
473
  // Find an existing worktree already checked out on a given branch
434
474
  async function findExistingWorktree(repoDir, branchName) {
435
475
  try {
@@ -591,8 +631,10 @@ async function spawnAgent(dispatchItem, config) {
591
631
  if (existingWt) {
592
632
  worktreePath = existingWt;
593
633
  log('info', `Reusing existing worktree for ${branchName}: ${existingWt}`);
594
- try { await execAsync(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
595
- try { await execAsync(`git pull origin "${branchName}"`, { ..._gitOpts, cwd: existingWt }); } catch (e) { log('warn', 'git: ' + e.message); }
634
+ // Probe origin first locally-created branches that were never pushed
635
+ // (orphan/timeout retry before first push) would otherwise emit a
636
+ // "couldn't find remote ref" warn pair on every reuse.
637
+ await syncReusedWorktree(rootDir, existingWt, branchName, _gitOpts);
596
638
  } else if (['meeting', 'ask', 'explore', 'plan-to-prd', 'plan'].includes(type)) {
597
639
  // Read-only tasks — no worktree needed, run in rootDir
598
640
  log('info', `${type}: read-only task, no worktree needed — running in rootDir`);
@@ -1015,6 +1057,10 @@ async function spawnAgent(dispatchItem, config) {
1015
1057
  agentId,
1016
1058
  branchName,
1017
1059
  agentsDir: AGENTS_DIR,
1060
+ // Pass the working directory so the Claude adapter can probe for the
1061
+ // conversation jsonl and avoid `--resume <dead-uuid>` retry loops when
1062
+ // the agent died before checkpoint (W-mouugzow00068741).
1063
+ cwd,
1018
1064
  logger: _runtimeLogger(),
1019
1065
  });
1020
1066
  }
@@ -1697,9 +1743,12 @@ function areDependenciesMet(item, config) {
1697
1743
  for (const depId of deps) {
1698
1744
  const depItem = allWorkItems.find(w => w.id === depId);
1699
1745
  if (!depItem) {
1700
- // Fallback: check PRD JSON — plan-to-prd agents may pre-set items to done
1746
+ // Fallback: check PRD JSON — plan-to-prd agents may pre-set items to done.
1747
+ // safeJsonNoRestore — if the PRD has been archived, treat the dep as
1748
+ // unmet rather than resurrecting the active PRD from .backup
1749
+ // (W-mouptdh1000h9f39).
1701
1750
  try {
1702
- const plan = safeJson(path.join(PRD_DIR, sourcePlan));
1751
+ const plan = safeJsonNoRestore(path.join(PRD_DIR, sourcePlan));
1703
1752
  const prdItem = (plan?.missing_features || []).find(f => f.id === depId);
1704
1753
  if (prdItem && PRD_MET_STATUSES.has(prdItem.status)) continue; // PRD says done — treat as met
1705
1754
  } catch (e) { log('warn', 'check PRD dep status: ' + e.message); }
@@ -1951,7 +2000,10 @@ function materializePlansAsWorkItems(config) {
1951
2000
  const SEQUENTIAL_ID_RE = /^P-?\d+$/;
1952
2001
 
1953
2002
  for (const file of planFiles) {
1954
- let plan = safeJson(path.join(PRD_DIR, file));
2003
+ // safeJsonNoRestore if a PRD was archived between readdir and this
2004
+ // read, do not auto-resurrect it from a stale .backup sidecar
2005
+ // (W-mouptdh1000h9f39).
2006
+ let plan = safeJsonNoRestore(path.join(PRD_DIR, file));
1955
2007
  if (!plan?.missing_features) continue;
1956
2008
 
1957
2009
  // ID collision prevention: remap sequential IDs (P-001, P-002) to globally unique P-<uid> IDs.
@@ -3771,6 +3823,39 @@ function discoverCentralWorkItems(config) {
3771
3823
  }
3772
3824
 
3773
3825
 
3826
+ /**
3827
+ * Sweep stale `.backup` sidecars in `prd/` whose archived counterpart already
3828
+ * lives in `prd/archive/<name>.json` and the active `prd/<name>.json` is
3829
+ * absent. Pre-neutralize-era archives left these sidecars behind; without this
3830
+ * sweep, any caller that touched `prd/<name>.json` with the restore-enabled
3831
+ * `safeJson` would resurrect the archived PRD as active (W-mouptdh1000h9f39).
3832
+ *
3833
+ * Idempotent and best-effort: readdir / existsSync / unlink failures are
3834
+ * swallowed so a single weird filesystem state never blocks the discovery
3835
+ * tick. Returns the number of sidecars purged (for tests / logging).
3836
+ */
3837
+ function sweepStaleArchivedPrdBackups(prdDir, prdArchiveDir) {
3838
+ let purged = 0;
3839
+ if (!fs.existsSync(prdArchiveDir)) return purged;
3840
+ let archivedNames;
3841
+ try { archivedNames = fs.readdirSync(prdArchiveDir).filter(f => f.endsWith('.json')); }
3842
+ catch { return purged; }
3843
+ for (const f of archivedNames) {
3844
+ const activePath = path.join(prdDir, f);
3845
+ const backupPath = activePath + '.backup';
3846
+ // Active PRD wins — the dedicated ghost-PRD purge in discoverWork handles
3847
+ // the case where both active.json and active.json.backup are present.
3848
+ if (fs.existsSync(activePath)) continue;
3849
+ if (!fs.existsSync(backupPath)) continue;
3850
+ try {
3851
+ fs.unlinkSync(backupPath);
3852
+ purged++;
3853
+ log('info', `Purged stale .backup sidecar for archived PRD: ${f}`);
3854
+ } catch { /* best-effort */ }
3855
+ }
3856
+ return purged;
3857
+ }
3858
+
3774
3859
  /**
3775
3860
  * Run all work discovery sources and queue new items
3776
3861
  * Priority: fix (0) > ask (1) > review (1) > implement (2) > work-items (3) > central (4)
@@ -3865,17 +3950,29 @@ async function discoverWork(config) {
3865
3950
  try {
3866
3951
  const lifecycle = require('./engine/lifecycle');
3867
3952
  const prdDir = path.join(MINIONS_DIR, 'prd');
3953
+ const prdArchiveDir = path.join(prdDir, 'archive');
3868
3954
  if (fs.existsSync(prdDir)) {
3955
+ // Pass 1 — Burn the landmine: stale .backup sidecars whose archived
3956
+ // counterpart already lives in prd/archive/ but no active prd/<f>.json.
3957
+ // Without this, any future safeJson(prd/<f>.json) (or stray legacy
3958
+ // call) would auto-restore the .backup and resurrect the archived PRD
3959
+ // (W-mouptdh1000h9f39). Idempotent and fast — readdir + existsSync.
3960
+ sweepStaleArchivedPrdBackups(prdDir, prdArchiveDir);
3961
+
3962
+ // Pass 2 — Existing orphan ghost-PRD purge + completion sweep.
3869
3963
  for (const f of fs.readdirSync(prdDir).filter(f => f.endsWith('.json'))) {
3870
3964
  if (completedPlanCache.has(f)) continue;
3871
- if (fs.existsSync(path.join(prdDir, 'archive', f))) {
3965
+ if (fs.existsSync(path.join(prdArchiveDir, f))) {
3872
3966
  // Orphaned backup restore — plan is already archived. Purge the ghost copy.
3873
3967
  try { fs.unlinkSync(path.join(prdDir, f)); } catch { }
3874
3968
  try { fs.unlinkSync(path.join(prdDir, f + '.backup')); } catch { }
3875
3969
  completedPlanCache.add(f);
3876
3970
  continue;
3877
3971
  }
3878
- const plan = safeJson(path.join(prdDir, f));
3972
+ // safeJsonNoRestore defense in depth: if the file vanished between
3973
+ // readdir and read (e.g. concurrent archive), do not resurrect it
3974
+ // from a stale .backup sidecar (W-mouptdh1000h9f39).
3975
+ const plan = safeJsonNoRestore(path.join(prdDir, f));
3879
3976
  if (!plan?.missing_features || plan.status === 'completed') {
3880
3977
  if (plan?.status === 'completed') completedPlanCache.add(f);
3881
3978
  continue;
@@ -4605,11 +4702,12 @@ module.exports = {
4605
4702
  // Discovery
4606
4703
  discoverWork, discoverFromPrs, discoverFromWorkItems, discoverCentralWorkItems,
4607
4704
  materializePlansAsWorkItems,
4705
+ sweepStaleArchivedPrdBackups, // exported for testing
4608
4706
 
4609
4707
  // Shared helpers (used by lifecycle.js and tests)
4610
4708
  reconcileItemsWithPrs, detectDependencyCycles,
4611
4709
  parseConflictFiles, pruneAncestorDeps, preflightMergeSimulation, // exported for testing
4612
- isWorktreeRetryableError, removeStaleIndexLock, // exported for testing
4710
+ isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, // exported for testing
4613
4711
  _maxTurnsForType, buildProjectContext, normalizeAc, _buildAgentSpawnFlags, _classifyAgentFailure, // exported for testing
4614
4712
 
4615
4713
  // Playbooks
package/minions.js CHANGED
@@ -435,7 +435,7 @@ async function initMinions({ skipScan = false, scanRoot, scanDepth } = {}) {
435
435
  }
436
436
 
437
437
  if (skipScan) {
438
- console.log(' Run "minions scan" or "minions add <dir>" to link projects.\n');
438
+ console.log(' Run "minions scan" or "minions add <dir>" to link projects.');
439
439
  rl.close();
440
440
  } else {
441
441
  // Offer to scan for repos (optional)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1764",
3
+ "version": "0.1.1766",
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"