@yemi33/minions 0.1.2418 → 0.1.2420

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.
@@ -626,6 +626,15 @@ function ccAbort() {
626
626
  }
627
627
  }
628
628
 
629
+ function _ccSetButtonProcessing(on) {
630
+ // Toggle the animated gradient shimmer on the Command Center header button while
631
+ // CC is working. Driven purely by _ccSending so it reflects busy state whether the
632
+ // drawer is open or closed. Replaces the old top-right "..." processing notif-badge.
633
+ var btn = document.getElementById('cc-toggle-btn');
634
+ if (!btn) return;
635
+ btn.classList.toggle('cc-processing', !!on);
636
+ }
637
+
629
638
  function _ccRenderSuggestedPrompts() {
630
639
  var el = document.getElementById('cc-messages');
631
640
  var tab = _ccActiveTab();
@@ -656,9 +665,10 @@ function toggleCommandCenter() {
656
665
  ccRenderTabBar();
657
666
  document.getElementById('cc-input').focus();
658
667
  ccRestoreMessages();
659
- } else if (_ccSending) {
660
- showNotifBadge(document.getElementById('cc-toggle-btn'), 'processing');
661
668
  }
669
+ // Shimmer persists on the button element across open/close — it is driven by
670
+ // _ccSending (see _ccSetButtonProcessing call sites), so reopening while still
671
+ // sending keeps it consistent without any extra work here.
662
672
  }
663
673
 
664
674
 
@@ -770,6 +780,7 @@ function ccCloseTab(id) {
770
780
  closingTab._sending = false;
771
781
  closingTab._queue = [];
772
782
  _ccSending = (_ccTabs.some(function(t) { return t._sending; }));
783
+ _ccSetButtonProcessing(_ccSending);
773
784
  }
774
785
  // Tabs are non-expiring on the server — explicit close is the only path that
775
786
  // removes the persisted session. Fire-and-forget DELETE so closing a tab
@@ -915,6 +926,7 @@ function ccRestoreMessages() {
915
926
  // Only restore sending state if very recent (< 10s) — page refresh kills the SSE stream
916
927
  if (sendingState?.sending && (Date.now() - sendingState.startedAt) < 10000) {
917
928
  _ccSending = true;
929
+ _ccSetButtonProcessing(true);
918
930
  var elapsed = Date.now() - sendingState.startedAt;
919
931
  var thinking = document.createElement('div');
920
932
  thinking.id = 'cc-thinking';
@@ -1128,6 +1140,7 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1128
1140
  activeTab._abortController = new AbortController();
1129
1141
  activeTab._userAborted = false;
1130
1142
  _ccSending = true;
1143
+ _ccSetButtonProcessing(true);
1131
1144
  ccRenderTabBar();
1132
1145
  var _wasAborted = false;
1133
1146
  try { localStorage.setItem('cc-sending', JSON.stringify({ sending: true, startedAt: Date.now() })); } catch {}
@@ -1551,6 +1564,8 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1551
1564
  } finally {
1552
1565
  if (activeTab) { activeTab._sending = false; activeTab._abortController = null; activeTab._429retries = 0; delete activeTab._segments; delete activeTab._sendStartedAt; delete activeTab._userAborted; }
1553
1566
  _ccSending = (_ccTabs.some(function(t) { return t._sending; }));
1567
+ // Clear the button shimmer before the completion "done" red-dot badge below.
1568
+ _ccSetButtonProcessing(_ccSending);
1554
1569
  // Mark tab unread if response completed on a background tab or while drawer is closed
1555
1570
  if (activeTab && !_wasAborted && (activeTab.id !== _ccActiveTabId || !_ccOpen)) activeTab._unread = true;
1556
1571
  ccRenderTabBar();
@@ -981,6 +981,27 @@
981
981
  .try-slim-ux-btn:disabled { opacity: 0.6; cursor: default; animation: none; }
982
982
  @media (prefers-reduced-motion: reduce) { .try-slim-ux-btn { animation: none; } }
983
983
 
984
+ /* Command Center header button — clearly animated "working" indicator: a silver sheen
985
+ sweeps continuously across the button. Keeps the button's idle blue-on-dark identity
986
+ (blue text + blue border); the sweep is a translucent streak over the dark
987
+ background, not a solid fill.
988
+ Uses background-image (NOT the `background` shorthand): the shorthand also resets
989
+ background-position, and an !important on it pins position 0% 0% and beats the
990
+ keyframe — animations lose to !important author declarations in the cascade — so
991
+ the sweep would never move. background-image/-size keep their !important to
992
+ override the inline `background:none`, while background-position stays animatable. */
993
+ #cc-toggle-btn.cc-processing {
994
+ color: var(--blue) !important;
995
+ border-color: var(--blue) !important;
996
+ background-image: linear-gradient(90deg, transparent 0%, rgba(192, 192, 192, 0.5) 50%, transparent 100%) !important;
997
+ background-size: 200% auto !important;
998
+ animation: trySlimShimmer 1.8s linear infinite reverse;
999
+ }
1000
+ /* Deliberately exempt from prefers-reduced-motion: this sweep is the ONLY "Command
1001
+ Center is working" indicator (it replaced the old top-right "..." badge), so it is
1002
+ functional busy feedback, not decoration — suppressing it would leave no in-progress
1003
+ cue at all. Kept subtle (a slow, translucent silver sweep) to stay easy on the eyes. */
1004
+
984
1005
  /* Engine Status */
985
1006
  .engine-badge { padding: var(--space-2) var(--space-6); border-radius: var(--radius-xl); font-size: var(--text-base); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-right: var(--space-6); }
986
1007
  .engine-badge.running { background: rgba(63,185,80,0.15); color: var(--green); border: 1px solid var(--green); }
package/dashboard.js CHANGED
@@ -12280,7 +12280,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12280
12280
  // by a potentially-60s timeout_s. The loop self-flips state.healthy.
12281
12281
  const newSpec = managedSpawn.listManagedSpecs().find(s => s && s.name === name);
12282
12282
  if (newSpec && newSpec.healthcheck) {
12283
- managedSpawn.waitForFirstHealth(newSpec).catch((err) => {
12283
+ managedSpawn.waitForFirstHealth(newSpec, { exit: runtime.exit }).then((result) => {
12284
+ if (!result.healthy) {
12285
+ shared.log('warn', `managed-processes restart: first health failed for ${name}: ${result.error}`);
12286
+ }
12287
+ }).catch((err) => {
12284
12288
  shared.log('warn', `managed-processes restart: waitForFirstHealth failed for ${name}: ${err.message}`);
12285
12289
  });
12286
12290
  }
@@ -12,7 +12,7 @@ Before managed-spawn, Constellation (and similar multi-process projects) lost de
12
12
  3. `minions restart` killed the engine but left the child running — *on Node*. **On `bun` + Windows the child died with the parent** because the Windows detached-spawn semantics differ across runtimes.
13
13
  4. Next dispatch landed against a dead URL with no signal it had died.
14
14
 
15
- Managed-spawn moves the spawn ownership from the agent into the engine. Agents *describe* services in a sidecar; the engine spawns them using the proven [`bin/minions.js spawnDashboard`](../bin/minions.js) pattern (works for Node, bun, python, docker, …), runs healthchecks, gates dispatch SUCCESS on first-healthy, sweeps dead PIDs / expired TTL, auto-injects live processes into downstream agent prompts, and exposes everything through `/api/managed-processes` + the dashboard.
15
+ Managed-spawn moves the spawn ownership from the agent into the engine. Agents *describe* services in a sidecar; the engine detached-spawns an engine-owned Node launcher using the proven [`bin/minions.js spawnDashboard`](../bin/minions.js) pattern, and that launcher starts the declared executable non-detached. The launcher PID remains the managed process-tree root across engine restarts. The engine races its observed exit against first health, sweeps dead PIDs / expired TTL, auto-injects live processes into downstream agent prompts, and exposes everything through `/api/managed-processes` + the dashboard.
16
16
 
17
17
  ## When to use managed-spawn vs `keep_processes`
18
18
 
@@ -184,24 +184,25 @@ Single quotes (`'…'`) work the same way. No `powershell -Command "& '<path>' <
184
184
 
185
185
  ┌───────────────────────────────────────────────────────────────────┐
186
186
  │ 3. spawnManagedSpec(spec) per spec │
187
- │ - Uses the proven Windows detached-spawn pattern
188
- │ - Records each in the SQL managed-process store
187
+ │ - Detached Node launcher starts the target non-detached
188
+ │ - Records the launcher PID in the SQL managed-process store
189
189
  │ - Stdio → engine/managed-logs/<name>.log (append fd, NOT pipe) │
190
190
  └───────────────────────────────────┬───────────────────────────────┘
191
191
 
192
192
 
193
193
  ┌───────────────────────────────────────────────────────────────────┐
194
- │ 4. waitForFirstHealth(spec) per spec, in parallel
194
+ │ 4. waitForFirstHealth(spec,{exit}) per spec, in parallel
195
+ │ - Races probes against launcher/target exit │
195
196
  │ - Probes every interval_s, gives up at timeout_s │
196
197
  │ - First success → state.healthy = true, last_health_at = now │
197
- │ - Any failure → dispatch ERROR + sibling spawns left alive
198
- │ failure_class: managed-spawn-healthcheck (retryable)
198
+ │ - Exit/timeout → dispatch ERROR + sibling spawns left alive
199
+ │ failure_class: managed-spawn-healthcheck-failed
199
200
  └───────────────────────────────────┬───────────────────────────────┘
200
201
  │ all healthy
201
202
 
202
203
  ┌───────────────────────────────────────────────────────────────────┐
203
204
  │ 5. Dispatch SUCCESS │
204
- │ - Spec survives engine restart (PID detached + boot reconcile)
205
+ │ - Spec survives restart (launcher PID + boot reconcile)
205
206
  │ - Visible at /api/managed-processes + dashboard "Managed │
206
207
  │ Processes" panel │
207
208
  │ - Downstream agent prompts auto-inject a "## Live managed │
@@ -287,9 +288,9 @@ All knobs live under `engine.managedSpawn` in `engine/shared.js` (`ENGINE_DEFAUL
287
288
  | Symptom | Likely cause | Fix |
288
289
  |---|---|---|
289
290
  | Dispatch ERROR `failure_class: invalid-managed-spawn` | Sidecar schema/allowlist violation | Read inbox alert; the validator includes a precise reason. Non-retryable — fix and re-dispatch. |
290
- | Dispatch ERROR `failure_class: managed-spawn-healthcheck` | `timeout_s` elapsed before any spec became healthy | Check `engine/managed-logs/<name>.log` for the child's crash output. Sibling spawns are left alive. Retryable. |
291
+ | Dispatch ERROR `failure_class: managed-spawn-healthcheck-failed` | The target exited before first health, or `timeout_s` elapsed before it became healthy | Check the reported code/signal and bounded `engine/managed-logs/<name>.log` tail. Sibling spawns are left alive. |
291
292
  | WI shows yellow `⚠ managed-spawn: N/M healthy` chip on dashboard | Partial healthcheck failure: ≥1 spec passed, ≥1 failed. WI keeps `status: done` (the agent's primary work — getting an accepted sidecar — succeeded); annotation `_managedSpawnPartial = { healthy, failed[{name, reason, log_tail}], evaluated_at }` lives on the WI. Click the row to see per-spec failure reasons + last 20 log lines. Dispatch is still recorded ERROR with `failure_class: managed-spawn-healthcheck-failed`. Restart the failing spec via `POST /api/managed-processes/restart` once you've fixed the root cause (often: workspace deps not installed; see smoke-test rule in the hint). W-mpbpexrg00110661. |
292
- | Spec gone after `minions restart` | Bun child died with parent (the original failure mode) | Should be fixed by item 2's detached-spawn pattern. If it recurs, verify `bin/minions.js spawnDashboard` semantics still work for the runtime that's the canonical reference. |
293
+ | Spec gone after `minions restart` | The tracked Node launcher died or boot reconciliation dropped its PID | Inspect the managed log and verify `engine/managed-spawn-launcher.js` remained the process-tree root until the target exited. |
293
294
  | Spec listed `alive: true, healthy: false` for >30s | Healthcheck loop self-detected service degradation | The spec did not pass a subsequent healthcheck. Inspect the service; restart via API once recovered. |
294
295
  | Stale row sticks around with dead PID | Spec killed outside Minions | Wait one sweep cycle (~30 min) or call `POST /api/managed-processes/kill` manually. |
295
296
 
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { spawn } = require('child_process');
5
+
6
+ const [cmd, ...args] = process.argv.slice(2);
7
+ if (!cmd) {
8
+ console.error('[managed-spawn-launcher] target executable missing');
9
+ process.exitCode = 64;
10
+ } else {
11
+ let settled = false;
12
+ let target;
13
+ try {
14
+ target = spawn(cmd, args, {
15
+ cwd: process.cwd(),
16
+ env: process.env,
17
+ detached: false,
18
+ stdio: ['ignore', 'inherit', 'inherit'],
19
+ windowsHide: true,
20
+ });
21
+ } catch (error) {
22
+ settled = true;
23
+ console.error('[managed-spawn-launcher] target spawn failed: ' + error.message);
24
+ process.exitCode = 127;
25
+ }
26
+
27
+ if (target) {
28
+ target.once('error', (error) => {
29
+ if (settled) return;
30
+ settled = true;
31
+ console.error('[managed-spawn-launcher] target spawn failed: ' + error.message);
32
+ process.exitCode = 127;
33
+ });
34
+ target.once('exit', (code, signal) => {
35
+ if (settled) return;
36
+ settled = true;
37
+ console.error('[managed-spawn-launcher] target exited (code='
38
+ + (code == null ? 'null' : code) + ', signal=' + (signal || 'null') + ')');
39
+ if (signal) {
40
+ try {
41
+ process.kill(process.pid, signal);
42
+ return;
43
+ } catch {
44
+ process.exitCode = 1;
45
+ return;
46
+ }
47
+ }
48
+ process.exitCode = Number.isInteger(code) ? code : 1;
49
+ });
50
+ }
51
+ }
@@ -29,6 +29,7 @@ const { log, ENGINE_DEFAULTS } = shared;
29
29
 
30
30
  const MANAGED_SPAWN_FILENAME = 'managed-spawn.json';
31
31
  const MANAGED_LOGS_DIR = 'managed-logs';
32
+ const MANAGED_SPAWN_LAUNCHER_PATH = path.join(__dirname, 'managed-spawn-launcher.js');
32
33
  const INVALID_WORKDIR_REASON_PREFIX = 'invalid-workdir: ';
33
34
 
34
35
  const HEALTHCHECK_TYPES = ['http', 'command'];
@@ -777,9 +778,10 @@ function buildManagedSpawnHint(opts) {
777
778
  // stdio is wired to this fd (NOT a pipe)
778
779
  // so the detached process survives our
779
780
  // exit on Windows.
780
- // 2. spawnManagedSpec(spec, ctx) → uses the proven bin/minions.js
781
- // spawnDashboard detached-spawn pattern.
782
- // Returns {pid, started_at, log_path}.
781
+ // 2. spawnManagedSpec(spec, ctx) → detached-spawns an engine-owned Node
782
+ // launcher which owns the attached target.
783
+ // Returns serializable runtime facts plus
784
+ // in-memory startup/exit observations.
783
785
  // 3. recordManagedSpec(spec, → writes one entry to
784
786
  // runtime, ctx) SQL managed-process state via
785
787
  // mutateJsonFileLocked. Replaces any
@@ -836,6 +838,54 @@ function _buildChildEnv(specEnv) {
836
838
  return env;
837
839
  }
838
840
 
841
+ function _observeManagedLauncher(child) {
842
+ let startupSettled = false;
843
+ let exitSettled = false;
844
+ let resolveStartup;
845
+ let resolveExit;
846
+ const startup = new Promise((resolve) => { resolveStartup = resolve; });
847
+ const exit = new Promise((resolve) => { resolveExit = resolve; });
848
+
849
+ const settleStartup = (result) => {
850
+ if (startupSettled) return;
851
+ startupSettled = true;
852
+ resolveStartup(result);
853
+ };
854
+ const settleExit = (result) => {
855
+ if (exitSettled) return;
856
+ exitSettled = true;
857
+ resolveExit(Object.assign({ observed_at: Date.now() }, result));
858
+ };
859
+
860
+ child.once('spawn', () => {
861
+ settleStartup({ started: true, pid: child.pid, error: null });
862
+ });
863
+ child.once('error', (error) => {
864
+ const message = String((error && error.message) || error);
865
+ settleStartup({ started: false, pid: child.pid || null, error: message });
866
+ settleExit({ kind: 'error', code: null, signal: null, error: message });
867
+ });
868
+ child.once('exit', (code, signal) => {
869
+ settleStartup({ started: true, pid: child.pid, error: null });
870
+ settleExit({
871
+ kind: 'exit',
872
+ code: Number.isInteger(code) ? code : null,
873
+ signal: typeof signal === 'string' && signal ? signal : null,
874
+ error: null,
875
+ });
876
+ });
877
+ child.once('close', (code, signal) => {
878
+ settleExit({
879
+ kind: 'close',
880
+ code: Number.isInteger(code) ? code : null,
881
+ signal: typeof signal === 'string' && signal ? signal : null,
882
+ error: null,
883
+ });
884
+ });
885
+
886
+ return { startup, exit };
887
+ }
888
+
839
889
  function spawnManagedSpec(spec, ctx) {
840
890
  if (!spec || typeof spec !== 'object') throw new Error('spawnManagedSpec: spec required');
841
891
  if (typeof spec.cmd !== 'string' || spec.cmd.length === 0) {
@@ -854,10 +904,10 @@ function spawnManagedSpec(spec, ctx) {
854
904
  const argv = Array.isArray(spec.args) ? spec.args : [];
855
905
  let child;
856
906
  try {
857
- // Working Windows-correct detached spawn same shape as
858
- // bin/minions.js spawnDashboard. DO NOT switch stdio to 'pipe' or
859
- // 'inherit': pipes die on EPIPE once the parent exits.
860
- child = spawn(spec.cmd, argv, {
907
+ // Detach only process.execPath, whose Windows behavior is proven by the
908
+ // dashboard/engine daemon launchers. The shim starts the validated target
909
+ // attached and remains its process-tree root until that target exits.
910
+ child = spawn(process.execPath, [MANAGED_SPAWN_LAUNCHER_PATH, spec.cmd, ...argv], {
861
911
  cwd: cwd,
862
912
  env: env,
863
913
  detached: true,
@@ -873,12 +923,19 @@ function spawnManagedSpec(spec, ctx) {
873
923
  if (!child || !child.pid) {
874
924
  throw new Error('spawnManagedSpec: spawn failed for ' + spec.cmd);
875
925
  }
926
+ const observation = _observeManagedLauncher(child);
876
927
  child.unref();
877
928
  const startedAt = Date.now();
878
929
  log('info', 'managed-spawn born: name=' + spec.name + ' pid=' + child.pid
879
930
  + ' owner_project=' + (ctx.owner_project || '')
880
931
  + ' owner_wi=' + (ctx.owner_wi || ''));
881
- return { pid: child.pid, started_at: startedAt, log_path: logPath };
932
+ return {
933
+ pid: child.pid,
934
+ started_at: startedAt,
935
+ log_path: logPath,
936
+ startup: observation.startup,
937
+ exit: observation.exit,
938
+ };
882
939
  }
883
940
 
884
941
  function _initialStateShape() {
@@ -1117,7 +1174,46 @@ function waitForFirstHealth(spec, opts) {
1117
1174
  const deadline = Date.now() + timeoutMs;
1118
1175
  return new Promise((resolve) => {
1119
1176
  let stopped = false;
1177
+ let timer = null;
1120
1178
  let lastError = null;
1179
+ const finish = (result) => {
1180
+ if (stopped) return;
1181
+ stopped = true;
1182
+ if (timer) clearTimeout(timer);
1183
+ resolve(result);
1184
+ };
1185
+ const exitObservation = opts.exit;
1186
+ if (exitObservation && typeof exitObservation.then === 'function') {
1187
+ Promise.resolve(exitObservation).then((outcome) => {
1188
+ if (stopped) return;
1189
+ const code = outcome && Number.isInteger(outcome.code) ? outcome.code : null;
1190
+ const signal = outcome && typeof outcome.signal === 'string' && outcome.signal
1191
+ ? outcome.signal : null;
1192
+ const observerError = outcome && outcome.error ? String(outcome.error) : '';
1193
+ let logTail = '';
1194
+ try { logTail = (tailManagedLog(spec.name, 20) || '').slice(-2048); } catch {}
1195
+ finish({
1196
+ healthy: false,
1197
+ error: 'process exited before first health (code=' + (code == null ? 'null' : code)
1198
+ + ', signal=' + (signal || 'null') + ')'
1199
+ + (observerError ? ': ' + observerError : ''),
1200
+ log_tail: logTail,
1201
+ exit: outcome || null,
1202
+ lastCheckAt: Date.now(),
1203
+ });
1204
+ }, (error) => {
1205
+ if (stopped) return;
1206
+ let logTail = '';
1207
+ try { logTail = (tailManagedLog(spec.name, 20) || '').slice(-2048); } catch {}
1208
+ finish({
1209
+ healthy: false,
1210
+ error: 'process exit observation failed before first health (code=null, signal=null): '
1211
+ + String((error && error.message) || error),
1212
+ log_tail: logTail,
1213
+ lastCheckAt: Date.now(),
1214
+ });
1215
+ });
1216
+ }
1121
1217
  const tick = async () => {
1122
1218
  if (stopped) return;
1123
1219
  let result;
@@ -1130,26 +1226,23 @@ function waitForFirstHealth(spec, opts) {
1130
1226
  // hanging forever — resolve unhealthy so the caller's
1131
1227
  // Promise.allSettled/timeout path still progresses.
1132
1228
  if (stopped) return;
1133
- stopped = true;
1134
- return resolve({ healthy: false, error: String((e && e.message) || e), lastCheckAt: Date.now() });
1229
+ return finish({ healthy: false, error: String((e && e.message) || e), lastCheckAt: Date.now() });
1135
1230
  }
1136
1231
  if (stopped) return;
1137
1232
  if (result.healthy) {
1138
- stopped = true;
1139
1233
  try { _markHealthy(spec.name, result.lastCheckAt); }
1140
1234
  catch (e) { log('warn', 'managed-spawn waitForFirstHealth: state write failed for ' + spec.name + ': ' + e.message); }
1141
- return resolve({ healthy: true, error: null, lastCheckAt: result.lastCheckAt });
1235
+ return finish({ healthy: true, error: null, lastCheckAt: result.lastCheckAt });
1142
1236
  }
1143
1237
  lastError = result.error;
1144
1238
  if (Date.now() >= deadline) {
1145
- stopped = true;
1146
- return resolve({
1239
+ return finish({
1147
1240
  healthy: false,
1148
1241
  error: 'timeout: spec ' + spec.name + ' did not become healthy within ' + Math.round(timeoutMs / 1000) + 's (last: ' + (lastError || 'no probes ran') + ')',
1149
1242
  lastCheckAt: result.lastCheckAt,
1150
1243
  });
1151
1244
  }
1152
- setTimeout(tick, intervalMs);
1245
+ timer = setTimeout(tick, intervalMs);
1153
1246
  };
1154
1247
  // First probe fires immediately so a fast service doesn't pay an
1155
1248
  // interval_s delay.
@@ -1413,8 +1506,8 @@ function restartManagedSpec(name, opts) {
1413
1506
  // dropped from state (no kill —
1414
1507
  // the OS already reaped them).
1415
1508
  // 2. ttl_expires_at past now → batch
1416
- // kill via killByPidsImmediate +
1417
- // drop from state.
1509
+ // tree-kill via killByPidTreesImmediate
1510
+ // + drop from state.
1418
1511
  // 3. rotate log_path when size >
1419
1512
  // logRotateBytes (rename to .1,
1420
1513
  // overwrite any prior .1).
@@ -1474,7 +1567,7 @@ function _runManagedReconcile(opts) {
1474
1567
  : shared.isPidAlive;
1475
1568
  const killBatch = typeof opts.killBatch === 'function'
1476
1569
  ? opts.killBatch
1477
- : shared.killByPidsImmediate;
1570
+ : shared.killByPidTreesImmediate;
1478
1571
  let projects;
1479
1572
  try {
1480
1573
  projects = Array.isArray(opts.projects) ? opts.projects : shared.getProjects(opts.config);
@@ -1618,7 +1711,7 @@ function removeManagedSpecsForProject(projectName) {
1618
1711
  }, { defaultValue: _initialStateShape() });
1619
1712
  let killed = 0;
1620
1713
  if (toKill.length > 0) {
1621
- try { killed = shared.killByPidsImmediate(toKill); }
1714
+ try { killed = shared.killByPidTreesImmediate(toKill); }
1622
1715
  catch (e) { log('warn', 'managed-spawn removeForProject: kill batch failed for ' + projectName + ': ' + e.message); }
1623
1716
  }
1624
1717
  let unlinked = 0;
@@ -197,6 +197,38 @@ function killByPidsImmediate(pids) {
197
197
  return killed;
198
198
  }
199
199
 
200
+ // Batched FULL-PROCESS-TREE kill — like killByPidsImmediate but walks each
201
+ // pid's descendant tree (Windows `taskkill /F /T`; Unix killUnixProcessTree).
202
+ // Use this when the recorded pid is a launcher/shim whose real workload runs in
203
+ // an attached child (e.g. managed-spawn's Node launcher shim, P-8a4d6f29): a
204
+ // non-tree kill would reap the shim and orphan/leak the child. Returns the
205
+ // count of pids for which the kill call was issued without throwing.
206
+ function killByPidTreesImmediate(pids) {
207
+ const valid = (Array.isArray(pids) ? pids : [])
208
+ .map(Number)
209
+ .filter(n => Number.isInteger(n) && n > 0 && n !== process.pid);
210
+ if (!valid.length) return 0;
211
+ if (process.platform === 'win32') {
212
+ const flags = valid.map(p => `/PID ${p}`).join(' ');
213
+ try { _execSync(`taskkill /F /T ${flags}`, { stdio: 'pipe', timeout: 5000, windowsHide: true }); return valid.length; }
214
+ catch {
215
+ // taskkill fails wholesale if ANY pid is already dead — fall back to a
216
+ // per-pid tree kill so live siblings still get reaped.
217
+ let killed = 0;
218
+ for (const p of valid) {
219
+ try { _execSync(`taskkill /PID ${p} /F /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); killed++; }
220
+ catch { /* process may be dead */ }
221
+ }
222
+ return killed;
223
+ }
224
+ }
225
+ let killed = 0;
226
+ for (const p of valid) {
227
+ try { killUnixProcessTree(p, 'SIGKILL'); killed++; } catch { /* dead */ }
228
+ }
229
+ return killed;
230
+ }
231
+
200
232
  // ─── Process Table Enumeration ───────────────────────────────────────────────
201
233
  // Cross-platform listing of every live process as { pid, ppid, name, cmd? }.
202
234
  // `cmd` is best-effort — included on Windows (via wmic) and Linux (/proc); may
@@ -822,6 +854,7 @@ module.exports = {
822
854
  killImmediate,
823
855
  killByPidImmediate,
824
856
  killByPidsImmediate,
857
+ killByPidTreesImmediate,
825
858
  tasklistOutputShowsPid,
826
859
  getProcessCpuSeconds,
827
860
  _parseWmicCsv,
package/engine.js CHANGED
@@ -5854,7 +5854,7 @@ async function spawnAgent(dispatchItem, config) {
5854
5854
  // healthy:false, alive:true and the engine sweep / item-3 healthcheck
5855
5855
  // loop will drive its state from there.
5856
5856
  let managedSpawnAcceptanceFailure = null;
5857
- let managedSpawnSpawned = []; // [{name, pid, started_at, log_path}]
5857
+ let managedSpawnSpawned = []; // serializable runtime facts + in-memory exit promise
5858
5858
  {
5859
5859
  const _wiMeta = dispatchItem.meta?.item?.meta || {};
5860
5860
  const _msEnabled = !!_wiMeta.managed_spawn
@@ -5935,7 +5935,13 @@ async function spawnAgent(dispatchItem, config) {
5935
5935
  try {
5936
5936
  const runtime = managedSpawn.spawnManagedSpec(spec, ctx);
5937
5937
  spawnedItems.push({ spec, runtime });
5938
- managedSpawnSpawned.push({ name: spec.name, pid: runtime.pid, started_at: runtime.started_at, log_path: runtime.log_path });
5938
+ managedSpawnSpawned.push({
5939
+ name: spec.name,
5940
+ pid: runtime.pid,
5941
+ started_at: runtime.started_at,
5942
+ log_path: runtime.log_path,
5943
+ exit: runtime.exit,
5944
+ });
5939
5945
  } catch (specErr) {
5940
5946
  spawnFailureReason = `spawn failed for ${spec.name}: ${specErr.message}`;
5941
5947
  log('warn', `managed-spawn: ${spawnFailureReason}`);
@@ -5947,7 +5953,7 @@ async function spawnAgent(dispatchItem, config) {
5947
5953
  // state. This is consistent with the "all healthy or fail"
5948
5954
  // contract item 3 will enforce on healthcheck timeout.
5949
5955
  for (const item of spawnedItems) {
5950
- try { shared.killByPidImmediate(item.runtime.pid); } catch (_e) {}
5956
+ try { shared.killImmediate({ pid: item.runtime.pid }); } catch (_e) {}
5951
5957
  }
5952
5958
  managedSpawnSpawned = [];
5953
5959
  managedSpawnAcceptanceFailure = {
@@ -5999,7 +6005,8 @@ async function spawnAgent(dispatchItem, config) {
5999
6005
  if (!spec || !spec.healthcheck) {
6000
6006
  return Promise.resolve({ healthy: false, error: 'no healthcheck recorded for ' + spawned.name, _name: spawned.name });
6001
6007
  }
6002
- return managedSpawn.waitForFirstHealth(spec).then(r => Object.assign({ _name: spec.name, _pid: spawned.pid }, r));
6008
+ return managedSpawn.waitForFirstHealth(spec, { exit: spawned.exit })
6009
+ .then(r => Object.assign({ _name: spec.name, _pid: spawned.pid }, r));
6003
6010
  }));
6004
6011
  const failed = [];
6005
6012
  for (let i = 0; i < results.length; i++) {
@@ -6009,7 +6016,12 @@ async function spawnAgent(dispatchItem, config) {
6009
6016
  continue;
6010
6017
  }
6011
6018
  if (!r.value.healthy) {
6012
- failed.push({ name: r.value._name || items[i].name, pid: r.value._pid || items[i].pid, error: r.value.error || 'unhealthy' });
6019
+ failed.push({
6020
+ name: r.value._name || items[i].name,
6021
+ pid: r.value._pid || items[i].pid,
6022
+ error: r.value.error || 'unhealthy',
6023
+ log_tail: r.value.log_tail || '',
6024
+ });
6013
6025
  }
6014
6026
  }
6015
6027
  if (failed.length > 0) {
@@ -6024,8 +6036,8 @@ async function spawnAgent(dispatchItem, config) {
6024
6036
  // re-reading the log files later. Use the same tailManagedLog helper
6025
6037
  // the inbox alert uses; cap each tail to 2KB to bound the WI write.
6026
6038
  const _failedDetails = failed.map(f => {
6027
- let tail = '';
6028
- try { tail = (managedSpawn.tailManagedLog(f.name, 20) || '').slice(-2048); }
6039
+ let tail = f.log_tail || '';
6040
+ try { if (!tail) tail = (managedSpawn.tailManagedLog(f.name, 20) || '').slice(-2048); }
6029
6041
  catch (_e) { tail = ''; }
6030
6042
  return { name: f.name, reason: f.error, log_tail: tail };
6031
6043
  });
@@ -6051,7 +6063,7 @@ async function spawnAgent(dispatchItem, config) {
6051
6063
  try {
6052
6064
  const wiId = dispatchItem.meta?.item?.id || '';
6053
6065
  const logTails = failed.map(f => {
6054
- const tail = managedSpawn.tailManagedLog(f.name, 50) || '(log empty or unreadable)';
6066
+ const tail = f.log_tail || managedSpawn.tailManagedLog(f.name, 50) || '(log empty or unreadable)';
6055
6067
  return '### ' + f.name + ' (pid ' + (f.pid || '?') + ')\n\nReason: `' + f.error + '`\n\n```\n' + tail.slice(-2000) + '\n```';
6056
6068
  }).join('\n\n');
6057
6069
  const alertBody = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2418",
3
+ "version": "0.1.2420",
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"