@yemi33/minions 0.1.1660 → 0.1.1662

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.1662 (2026-05-01)
4
+
5
+ ### Other
6
+ - test(scheduler): add unit tests for cron parsing edge cases & discovery flow (#1948)
7
+
8
+ ## 0.1.1661 (2026-05-01)
9
+
10
+ ### Other
11
+ - Harden Copilot steering resume flow
12
+
3
13
  ## 0.1.1660 (2026-05-01)
4
14
 
5
15
  ### Fixes
package/engine/cli.js CHANGED
@@ -21,6 +21,17 @@ function engine() {
21
21
  let _dispatchModule = null;
22
22
  function dispatchModule() { if (!_dispatchModule) _dispatchModule = require('./dispatch'); return _dispatchModule; }
23
23
 
24
+ function normalizeSessionBranch(branch) {
25
+ if (!branch) return null;
26
+ return String(branch).replace(/^refs\/heads\//, '');
27
+ }
28
+
29
+ function dispatchSessionBranch(item) {
30
+ return normalizeSessionBranch(
31
+ item?.meta?.branch || item?.branch || item?.meta?.item?.branch || item?.meta?.item?.featureBranch
32
+ );
33
+ }
34
+
24
35
  function isEngineProcessAlive(control) {
25
36
  if (!control?.pid) return false;
26
37
  if (control.pid === process.pid) return true;
@@ -396,13 +407,21 @@ const commands = {
396
407
  }
397
408
 
398
409
  if (agentPid) {
399
- // Load sessionId from session.json for steering support
410
+ // Load sessionId from session.json for steering support, but only
411
+ // when its saved branch still matches the active dispatch branch.
400
412
  let sessionId = null;
401
413
  try {
402
414
  const sj = safeJson(path.join(AGENTS_DIR, agentId, 'session.json'));
403
- if (sj?.sessionId) sessionId = sj.sessionId;
415
+ const expectedBranch = dispatchSessionBranch(item);
416
+ const savedBranch = normalizeSessionBranch(sj?.branch);
417
+ if (sj?.sessionId && (!expectedBranch || savedBranch === expectedBranch)) {
418
+ sessionId = sj.sessionId;
419
+ } else if (sj?.sessionId && expectedBranch) {
420
+ shared.log('warn', `Reattach: ignoring session for ${agentId} on branch ${savedBranch || 'unknown'}; expected ${expectedBranch}`);
421
+ }
404
422
  } catch {}
405
- e.activeProcesses.set(item.id, { proc: { pid: agentPid > 0 ? agentPid : null }, agentId, startedAt: item.created_at, reattached: true, sessionId });
423
+ const runtimeName = item.runtimeName || item.runtime || item.meta?.runtimeName || item.meta?.runtime || null;
424
+ e.activeProcesses.set(item.id, { proc: { pid: agentPid > 0 ? agentPid : null }, agentId, startedAt: item.created_at, reattached: true, sessionId, runtimeName });
406
425
  // Sync work item status to dispatched — atomic write to avoid lifecycle lazy init issues
407
426
  if (item.meta?.item?.id && item.meta?.project?.localPath) {
408
427
  try {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-01T05:58:00.505Z"
4
+ "cachedAt": "2026-05-01T15:02:53.143Z"
5
5
  }
package/engine/llm.js CHANGED
@@ -163,7 +163,14 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
163
163
  maxBudget, bare, fallbackModel,
164
164
  stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
165
165
  };
166
- const finalPrompt = runtime.buildPrompt(promptText, sysPromptText);
166
+ // Capability-gate per-flag opts before prompt construction so adapters can
167
+ // make resume-aware prompt decisions from the same opts used for argv.
168
+ if (!caps.effortLevels) adapterOpts.effort = undefined;
169
+ if (!caps.sessionResume) adapterOpts.sessionId = undefined;
170
+ if (!caps.budgetCap) adapterOpts.maxBudget = undefined;
171
+ if (!caps.bareMode) adapterOpts.bare = undefined;
172
+ if (!caps.fallbackModel) adapterOpts.fallbackModel = undefined;
173
+ const finalPrompt = runtime.buildPrompt(promptText, sysPromptText, adapterOpts);
167
174
 
168
175
  // ── Direct path ──
169
176
  const resolved = direct ? _resolveBin(runtime) : null;
@@ -178,13 +185,6 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
178
185
  cleanupFiles.push(sysTmpPath);
179
186
  adapterOpts.sysPromptFile = sysTmpPath;
180
187
  }
181
- // Capability-gate per-flag opts so the Claude path keeps emitting its
182
- // historical flag set while Copilot only sees what it understands.
183
- if (!caps.effortLevels) adapterOpts.effort = undefined;
184
- if (!caps.sessionResume) adapterOpts.sessionId = undefined;
185
- if (!caps.budgetCap) adapterOpts.maxBudget = undefined;
186
- if (!caps.bareMode) adapterOpts.bare = undefined;
187
- if (!caps.fallbackModel) adapterOpts.fallbackModel = undefined;
188
188
  // promptViaArg=true: the adapter splices `--prompt <text>` into args itself.
189
189
  if (caps.promptViaArg) adapterOpts.prompt = finalPrompt;
190
190
 
@@ -581,6 +581,8 @@ const capabilities = {
581
581
  streaming: true,
582
582
  // `--resume <session-id>` resumes a previous turn
583
583
  sessionResume: true,
584
+ // Emits a resumable session ID before the terminal result event
585
+ midRunSessionId: true,
584
586
  // Accepts the system prompt via `--system-prompt-file`
585
587
  systemPromptFile: true,
586
588
  // Honours `--effort low|medium|high|xhigh`
@@ -344,8 +344,9 @@ function classifyFailure({ code, stdout = '', stderr = '', fallback } = {}) {
344
344
  // convention from Anthropic tool-use docs and is recognized as "system role"
345
345
  // content by every model in the Copilot catalog.
346
346
 
347
- function buildPrompt(promptText, sysPromptText) {
347
+ function buildPrompt(promptText, sysPromptText, opts = {}) {
348
348
  const user = promptText == null ? '' : String(promptText);
349
+ if (opts && opts.sessionId) return user;
349
350
  if (sysPromptText == null || sysPromptText === '') return user;
350
351
  return `<system>\n${String(sysPromptText)}\n</system>\n\n${user}`;
351
352
  }
@@ -722,6 +723,8 @@ const capabilities = {
722
723
  streaming: true,
723
724
  // --resume=<id> resumes a session
724
725
  sessionResume: true,
726
+ // Copilot only exposes sessionId on the terminal result event
727
+ midRunSessionId: false,
725
728
  // No --system-prompt-file flag — system prompt is merged into stdin
726
729
  systemPromptFile: false,
727
730
  // --effort low|medium|high|xhigh (no 'max' — adapter maps it)
package/engine/shared.js CHANGED
@@ -2094,27 +2094,60 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
2094
2094
 
2095
2095
  // ─── Cross-Platform Process Kill Helpers ─────────────────────────────────────
2096
2096
 
2097
+ function normalizeKillPid(proc) {
2098
+ const pid = Number(proc?.pid);
2099
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
2100
+ }
2101
+
2102
+ function unixChildPids(pid) {
2103
+ if (!Number.isInteger(pid) || pid <= 0) return [];
2104
+ try {
2105
+ return _execSync(`pgrep -P ${pid}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 })
2106
+ .split(/\r?\n/)
2107
+ .map(line => Number(line.trim()))
2108
+ .filter(childPid => Number.isInteger(childPid) && childPid > 0 && childPid !== pid);
2109
+ } catch {
2110
+ return [];
2111
+ }
2112
+ }
2113
+
2114
+ function killUnixProcessTree(pid, signal, seen = new Set()) {
2115
+ if (!Number.isInteger(pid) || pid <= 0 || seen.has(pid)) return;
2116
+ seen.add(pid);
2117
+ for (const childPid of unixChildPids(pid)) {
2118
+ killUnixProcessTree(childPid, signal, seen);
2119
+ }
2120
+ try { process.kill(pid, signal); } catch { /* process may be dead */ }
2121
+ }
2122
+
2123
+ function unrefTimer(timer) {
2124
+ if (timer && typeof timer.unref === 'function') timer.unref();
2125
+ return timer;
2126
+ }
2127
+
2097
2128
  function killGracefully(proc, graceMs = 5000) {
2098
- if (!proc || !proc.pid) return;
2129
+ const pid = normalizeKillPid(proc);
2130
+ if (!pid) return;
2099
2131
  if (process.platform === 'win32') {
2100
- try { _execSync(`taskkill /PID ${proc.pid} /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); } catch { /* process may be dead */ }
2101
- setTimeout(() => {
2102
- try { _execSync(`taskkill /PID ${proc.pid} /F /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); } catch { /* process may be dead */ }
2103
- }, graceMs);
2132
+ try { _execSync(`taskkill /PID ${pid} /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); } catch { /* process may be dead */ }
2133
+ unrefTimer(setTimeout(() => {
2134
+ try { _execSync(`taskkill /PID ${pid} /F /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); } catch { /* process may be dead */ }
2135
+ }, graceMs));
2104
2136
  } else {
2105
- try { proc.kill('SIGTERM'); } catch { /* process may be dead */ }
2106
- setTimeout(() => {
2107
- try { proc.kill('SIGKILL'); } catch { /* process may be dead */ }
2108
- }, graceMs);
2137
+ killUnixProcessTree(pid, 'SIGTERM');
2138
+ unrefTimer(setTimeout(() => {
2139
+ killUnixProcessTree(pid, 'SIGKILL');
2140
+ }, graceMs));
2109
2141
  }
2110
2142
  }
2111
2143
 
2112
2144
  function killImmediate(proc) {
2113
- if (!proc || !proc.pid) return;
2145
+ const pid = normalizeKillPid(proc);
2146
+ if (!pid) return;
2114
2147
  if (process.platform === 'win32') {
2115
- try { _execSync(`taskkill /PID ${proc.pid} /F /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); } catch { /* process may be dead */ }
2148
+ try { _execSync(`taskkill /PID ${pid} /F /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); } catch { /* process may be dead */ }
2116
2149
  } else {
2117
- try { proc.kill('SIGKILL'); } catch { /* process may be dead */ }
2150
+ killUnixProcessTree(pid, 'SIGKILL');
2118
2151
  }
2119
2152
  }
2120
2153
 
@@ -100,9 +100,9 @@ function parseSpawnArgs(argv) {
100
100
  * }
101
101
  */
102
102
  function buildSpawnInvocation({ runtime, resolved, promptText, sysPromptText, opts, passthrough, addDirs }) {
103
- const finalPrompt = runtime.buildPrompt(promptText, sysPromptText);
104
103
  const adapterOpts = { ...opts };
105
104
  if (Array.isArray(addDirs) && addDirs.length) adapterOpts.addDirs = addDirs;
105
+ const finalPrompt = runtime.buildPrompt(promptText, sysPromptText, adapterOpts);
106
106
  const deliveryMode = typeof runtime.getPromptDeliveryMode === 'function'
107
107
  ? runtime.getPromptDeliveryMode(adapterOpts)
108
108
  : (runtime.capabilities && runtime.capabilities.promptViaArg ? 'arg' : 'stdin');
package/engine/timeout.js CHANGED
@@ -57,6 +57,27 @@ function checkIdleThreshold(config) {
57
57
  // How long to wait for a steered agent to exit before retrying the kill
58
58
  const STEERING_KILL_RETRY_MS = 30000;
59
59
 
60
+ function runtimeSupportsMidRunSessionId(info) {
61
+ if (typeof info?.midRunSessionId === 'boolean') return info.midRunSessionId;
62
+ if (typeof info?.runtime?.capabilities?.midRunSessionId === 'boolean') return info.runtime.capabilities.midRunSessionId;
63
+ if (info?.runtimeName) {
64
+ try {
65
+ const { resolveRuntime } = require('./runtimes');
66
+ const runtime = resolveRuntime(info.runtimeName);
67
+ if (typeof runtime.capabilities?.midRunSessionId === 'boolean') return runtime.capabilities.midRunSessionId;
68
+ } catch {
69
+ return true;
70
+ }
71
+ }
72
+ return true;
73
+ }
74
+
75
+ function rememberDeferredSteering(info, steerEntry) {
76
+ const existing = new Set(Array.isArray(info._deferredSteeringFiles) ? info._deferredSteeringFiles : []);
77
+ if (steerEntry?.path) existing.add(steerEntry.path);
78
+ info._deferredSteeringFiles = Array.from(existing);
79
+ }
80
+
60
81
  function checkSteering(config) {
61
82
  const activeProcesses = engine().activeProcesses;
62
83
  for (const [id, info] of activeProcesses) {
@@ -79,7 +100,10 @@ function checkSteering(config) {
79
100
  // Skip if already being steered (prevents double-kill race)
80
101
  if (info._steeringMessage || info._steeringAt) continue;
81
102
 
82
- const alreadyPending = new Set((info._pendingSteeringFiles || []).map(entry => entry.path || entry));
103
+ const alreadyPending = new Set([
104
+ ...(info._pendingSteeringFiles || []).map(entry => entry.path || entry),
105
+ ...(info._deferredSteeringFiles || []),
106
+ ]);
83
107
  const unread = steering.listUnreadSteeringMessages(info.agentId);
84
108
  for (const empty of unread.filter(entry => !entry.message.trim())) {
85
109
  shared.safeUnlink(empty.path);
@@ -90,8 +114,19 @@ function checkSteering(config) {
90
114
 
91
115
  const sessionId = info.sessionId;
92
116
  if (!sessionId) {
93
- // No session to resume — kill agent and leave message unread in inbox for retry.
94
- // Previously this silently skipped for up to 5m then deleted the message (#627).
117
+ if (!runtimeSupportsMidRunSessionId(info)) {
118
+ log('info', `Steering: no mid-run sessionId for ${info.agentId} (${id}) queued until resumable checkpoint`);
119
+ rememberDeferredSteering(info, steerEntry);
120
+ try {
121
+ const liveLogPath = path.join(AGENTS_DIR, info.agentId, 'live-output.log');
122
+ fs.appendFileSync(liveLogPath, `\n[steering] Message received. This runtime has not emitted a resumable session yet, so the message is queued until the agent reaches a resumable checkpoint or the next dispatch.\n`);
123
+ } catch { /* optional */ }
124
+ continue;
125
+ }
126
+
127
+ // No session to resume for a runtime that should have emitted one — kill
128
+ // agent and leave message unread in inbox for retry. Previously this
129
+ // silently skipped for up to 5m then deleted the message (#627).
95
130
  log('info', `Steering: no sessionId for ${info.agentId} (${id}) — killing and keeping unread message in inbox`);
96
131
 
97
132
  // Append to live output so user sees confirmation in the dashboard
package/engine.js CHANGED
@@ -1043,6 +1043,16 @@ async function spawnAgent(dispatchItem, config) {
1043
1043
  fs.appendFileSync(liveOutputPath, `[${new Date().toISOString()}] pid: ${proc.pid ?? 'unknown'}\n`);
1044
1044
  } catch { /* log stamp is best-effort — don't block spawn on fs failure */ }
1045
1045
 
1046
+ const initialProcInfo = {
1047
+ proc,
1048
+ agentId,
1049
+ startedAt,
1050
+ runtimeName,
1051
+ sessionId: cachedSessionId,
1052
+ _pendingSteeringFiles: pendingSteering.entries,
1053
+ };
1054
+ activeProcesses.set(id, initialProcInfo);
1055
+
1046
1056
  const MAX_OUTPUT = 1024 * 1024; // 1MB
1047
1057
  let stdout = '';
1048
1058
  let stderr = '';
@@ -1204,6 +1214,7 @@ async function spawnAgent(dispatchItem, config) {
1204
1214
  proc: resumeProc,
1205
1215
  agentId,
1206
1216
  startedAt: procInfo.startedAt,
1217
+ runtimeName,
1207
1218
  sessionId: steerSessionId,
1208
1219
  lastRealOutputAt: Date.now(),
1209
1220
  _pendingSteeringFiles: mergePendingSteeringEntries(
@@ -1446,12 +1457,15 @@ async function spawnAgent(dispatchItem, config) {
1446
1457
  // realActivityMap was already seeded immediately after runFile() returned (#W-mo25loq8kjer);
1447
1458
  // don't re-seed here — the stdout/stderr handlers above can already have updated it with
1448
1459
  // a fresher timestamp, and overwriting would clobber the real "last activity" signal.
1460
+ const existingProcInfo = activeProcesses.get(id) || {};
1449
1461
  activeProcesses.set(id, {
1462
+ ...existingProcInfo,
1450
1463
  proc,
1451
1464
  agentId,
1452
1465
  startedAt,
1453
- sessionId: cachedSessionId,
1454
- _pendingSteeringFiles: pendingSteering.entries,
1466
+ runtimeName,
1467
+ sessionId: existingProcInfo.sessionId || cachedSessionId,
1468
+ _pendingSteeringFiles: mergePendingSteeringEntries(existingProcInfo._pendingSteeringFiles, pendingSteering.entries),
1455
1469
  });
1456
1470
 
1457
1471
  updateAgentStatus(id, AGENT_STATUS.RUNNING, `Process spawned for ${agentId}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1660",
3
+ "version": "0.1.1662",
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"