@yemi33/minions 0.1.2448 → 0.1.2449

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.
Files changed (315) hide show
  1. package/bin/cli-api-client.js +1 -1
  2. package/bin/install-internal-minions.js +1382 -44
  3. package/bin/install-layout.js +150 -0
  4. package/bin/minions.js +460 -167
  5. package/dashboard/docs/typography.md +65 -12
  6. package/dashboard/js/command-center.js +58 -4
  7. package/dashboard/js/detail-panel.js +36 -0
  8. package/dashboard/js/memory-panel.js +59 -12
  9. package/dashboard/js/qa.js +179 -20
  10. package/dashboard/js/refresh.js +148 -12
  11. package/dashboard/js/render-dispatch.js +3 -4
  12. package/dashboard/js/render-inbox.js +2 -2
  13. package/dashboard/js/render-other.js +3 -3
  14. package/dashboard/js/render-pipelines.js +14 -0
  15. package/dashboard/js/render-plans.js +57 -9
  16. package/dashboard/js/render-prd.js +132 -23
  17. package/dashboard/js/render-prs.js +195 -166
  18. package/dashboard/js/render-schedules.js +63 -3
  19. package/dashboard/js/render-utils.js +3 -3
  20. package/dashboard/js/render-watches.js +19 -3
  21. package/dashboard/js/render-work-items.js +238 -30
  22. package/dashboard/js/settings.js +205 -54
  23. package/dashboard/js/utils.js +51 -1
  24. package/dashboard/pages/home.html +1 -1
  25. package/dashboard/pages/qa.html +1 -16
  26. package/dashboard/pages/work.html +40 -0
  27. package/dashboard/shared/cc-limits.js +79 -0
  28. package/dashboard/shared/pr-filters.js +21 -38
  29. package/dashboard/shared/project-git-summary.js +1 -1
  30. package/dashboard/shared/record-filters.js +169 -0
  31. package/dashboard/shared/watches-source.js +1 -1
  32. package/dashboard/shared/welcome-popup.js +1 -1
  33. package/dashboard/shared/wi-filters.js +302 -0
  34. package/dashboard/slim/body.html +1 -0
  35. package/dashboard/slim/js/command-send.js +26 -0
  36. package/dashboard/slim/js/modals-tiles.js +380 -39
  37. package/dashboard/slim/js/status.js +13 -21
  38. package/dashboard/slim/layout.html +1 -0
  39. package/dashboard/slim/panel-bootstrap.js +6 -2
  40. package/dashboard/slim/styles.css +38 -0
  41. package/dashboard/styles.css +159 -55
  42. package/dashboard-build.js +13 -2
  43. package/dashboard.js +956 -423
  44. package/docs/README.md +11 -6
  45. package/docs/api-errors.md +2 -2
  46. package/docs/architecture-review-2026-07-09.md +1 -1
  47. package/docs/architecture.excalidraw +2 -2
  48. package/docs/auto-discovery.md +18 -9
  49. package/docs/branch-derivation.md +4 -4
  50. package/docs/capture-demos.js +39 -2
  51. package/docs/ci-runner-canary.md +123 -0
  52. package/docs/claude-md-propagation.md +2 -2
  53. package/docs/cloud-agent-dispatch.md +204 -0
  54. package/docs/command-center.md +7 -7
  55. package/docs/completion-reports.md +43 -20
  56. package/docs/constants.md +10 -3
  57. package/docs/constellation-bridge.md +134 -6
  58. package/docs/constellation-style-telemetry.md +4 -4
  59. package/docs/contracts/capability-protocol.v1.json +165 -0
  60. package/docs/cooldown-merge-semantics.md +12 -12
  61. package/docs/copilot-cli-schema.md +7 -7
  62. package/docs/cross-repo-plans.md +10 -10
  63. package/docs/dead-code-audit-retractions.md +5 -5
  64. package/docs/default-branch-ci.md +173 -0
  65. package/docs/deprecated.json +31 -31
  66. package/docs/design-inbox-entries-schema.md +3 -3
  67. package/docs/design-language.md +1051 -0
  68. package/docs/design-state-storage.md +11 -11
  69. package/docs/diagnostics-crash-reports.md +9 -9
  70. package/docs/diagnostics-memory.md +5 -5
  71. package/docs/documentation-audit-2026-07-09.md +7 -7
  72. package/docs/engine-restart.md +90 -5
  73. package/docs/harness-mode.md +1 -1
  74. package/docs/internal-install.md +338 -39
  75. package/docs/kb-dedup-duplicate-pair-investigation.md +5 -5
  76. package/docs/kb-pr3223-cascade-archiving.md +1 -1
  77. package/docs/kb-pr696-merge-conflict-docs.md +6 -6
  78. package/docs/kb-sweep.md +35 -35
  79. package/docs/keep-processes.md +1 -1
  80. package/docs/live-checkout-mode.md +30 -30
  81. package/docs/managed-spawn.md +18 -14
  82. package/docs/named-agents.md +7 -7
  83. package/docs/plan-lifecycle.md +69 -2
  84. package/docs/pr-author-identity.md +114 -0
  85. package/docs/pr-auto-fix-dispatch.md +19 -4
  86. package/docs/pr-comment-followup.md +6 -6
  87. package/docs/pr-review-fix-loop.md +59 -10
  88. package/docs/process-termination.md +40 -0
  89. package/docs/proposals/repo-pool-for-live-checkout.md +13 -13
  90. package/docs/qa-runbook-lifecycle.md +367 -17
  91. package/docs/qa-runbooks.md +3 -3
  92. package/docs/rfc-completion-json.md +18 -18
  93. package/docs/runtime-adapters.md +26 -21
  94. package/docs/security.md +6 -6
  95. package/docs/self-improvement.md +4 -4
  96. package/docs/shared-lifecycle-module-map.md +473 -472
  97. package/docs/skills.md +52 -3
  98. package/docs/slim-ux/concepts.md +121 -116
  99. package/docs/specs/agent-configurability.md +18 -18
  100. package/docs/specs/agent-rename.md +18 -18
  101. package/docs/team-memory.md +38 -21
  102. package/docs/timeouts-and-liveness.md +118 -10
  103. package/docs/tutorials/01-install-and-connect.md +1 -1
  104. package/docs/watches.md +40 -39
  105. package/docs/workspace-manifests.md +4 -4
  106. package/docs/worktree-lifecycle.md +293 -14
  107. package/engine/README.md +46 -0
  108. package/engine/{ado-comment.js → ado/comment.js} +8 -8
  109. package/engine/{ado-git-auth.js → ado/git-auth.js} +4 -4
  110. package/engine/{ado.js → ado/index.js} +417 -63
  111. package/engine/{ado-status.js → ado/status.js} +6 -8
  112. package/engine/{ado-token.js → ado/token.js} +1 -1
  113. package/engine/{acp-transport.js → agents/acp-transport.js} +62 -22
  114. package/engine/{agent-worker-pool.js → agents/agent-worker-pool.js} +17 -8
  115. package/engine/{cc-worker-pool.js → agents/cc-worker-pool.js} +16 -6
  116. package/engine/{claude-md-context.js → agents/claude-md-context.js} +5 -5
  117. package/engine/{harness-context.js → agents/harness-context.js} +5 -5
  118. package/engine/{harness.js → agents/harness.js} +3 -3
  119. package/engine/{llm.js → agents/llm.js} +18 -14
  120. package/engine/{model-discovery.js → agents/model-discovery.js} +2 -2
  121. package/engine/{playbook.js → agents/playbook.js} +155 -22
  122. package/engine/{pooled-agent-process.js → agents/pooled-agent-process.js} +14 -12
  123. package/engine/{preflight.js → agents/preflight.js} +29 -10
  124. package/engine/{spawn-agent.js → agents/spawn-agent.js} +25 -14
  125. package/engine/{spawn-phase-watchdog.js → agents/spawn-phase-watchdog.js} +16 -7
  126. package/engine/{steering.js → agents/steering.js} +5 -5
  127. package/engine/{tools-inventory.js → agents/tools-inventory.js} +2 -2
  128. package/engine/{agent-api-validation.js → api/agent-api-validation.js} +2 -2
  129. package/engine/{api-validation.js → api/api-validation.js} +1 -1
  130. package/engine/api/bridge.js +787 -0
  131. package/engine/{cc-api-validation.js → api/cc-api-validation.js} +1 -1
  132. package/engine/api/companion.js +560 -0
  133. package/engine/{content-api-validation.js → api/content-api-validation.js} +2 -2
  134. package/engine/{pr-issue-validation.js → api/pr-issue-validation.js} +33 -6
  135. package/engine/{settings-validation.js → api/settings-validation.js} +32 -4
  136. package/engine/api-contracts/agent-content.js +4 -4
  137. package/engine/api-contracts/capability-manifest.js +236 -0
  138. package/engine/api-contracts/capability-protocol.js +333 -0
  139. package/engine/api-contracts/cc-ops.js +1 -1
  140. package/engine/api-contracts/config-runtime.js +5 -0
  141. package/engine/api-contracts/core.js +28 -1
  142. package/engine/api-contracts/index.js +100 -0
  143. package/engine/api-contracts/orchestration.js +18 -5
  144. package/engine/api-contracts/pull-requests.js +37 -6
  145. package/engine/api-contracts/qa-process.js +29 -6
  146. package/engine/api-contracts/work-plan-prd.js +21 -1
  147. package/engine/cloud/contract.js +212 -0
  148. package/engine/cloud/index.js +159 -0
  149. package/engine/{execution-model.js → core/execution-model.js} +1 -1
  150. package/engine/{features.js → core/features.js} +4 -4
  151. package/engine/{operator-identity.js → core/operator-identity.js} +1 -1
  152. package/engine/{queries.js → core/queries.js} +201 -36
  153. package/engine/{safe-expr.js → core/safe-expr.js} +1 -1
  154. package/engine/{shared.js → core/shared.js} +1637 -175
  155. package/engine/{stdio-timestamps.js → core/stdio-timestamps.js} +1 -1
  156. package/engine/{untrusted-fence.js → core/untrusted-fence.js} +3 -3
  157. package/engine/db/index.js +11 -2
  158. package/engine/db/migrations/002-dispatches.js +3 -3
  159. package/engine/db/migrations/003-work-items.js +1 -1
  160. package/engine/db/migrations/004-pull-requests.js +1 -1
  161. package/engine/db/migrations/006-metrics.js +1 -1
  162. package/engine/db/migrations/007-watches.js +2 -2
  163. package/engine/db/migrations/008-small-state.js +1 -1
  164. package/engine/db/migrations/009-qa.js +1 -1
  165. package/engine/db/migrations/010-pr-links.js +1 -1
  166. package/engine/db/migrations/011-remaining-state.js +1 -1
  167. package/engine/db/migrations/012-steering-deliveries.js +2 -2
  168. package/engine/db/migrations/013-backfill-broken-note-links.js +1 -1
  169. package/engine/db/migrations/014-pr-fix-target-prefs.js +2 -2
  170. package/engine/db/migrations/015-plans-prds.js +0 -0
  171. package/engine/db/migrations/018-sql-only-cutover.js +2 -2
  172. package/engine/db/migrations/021-archived-work-items.js +1 -1
  173. package/engine/db/migrations/022-global-cc-session.js +1 -1
  174. package/engine/db/migrations/023-engine-state.js +1 -1
  175. package/engine/db/migrations/025-malformed-work-item-phantoms.js +1 -1
  176. package/engine/db/migrations/027-review-learning-lifecycle.js +1 -1
  177. package/engine/db/migrations/029-repair-reused-versions.js +20 -0
  178. package/engine/db/migrations/031-pr-author-identity.js +137 -0
  179. package/engine/{consolidation.js → memory/consolidation.js} +6 -6
  180. package/engine/{kb-sweep-runner.js → memory/kb-sweep-runner.js} +2 -2
  181. package/engine/{kb-sweep.js → memory/kb-sweep.js} +9 -7
  182. package/engine/{memory-retrieval.js → memory/memory-retrieval.js} +46 -4
  183. package/engine/{memory-store.js → memory/memory-store.js} +3 -3
  184. package/engine/{promotion.js → memory/promotion.js} +3 -3
  185. package/engine/{review-learning-backfill.js → memory/review-learning-backfill.js} +6 -6
  186. package/engine/{review-learning.js → memory/review-learning.js} +10 -5
  187. package/engine/{diagnostics-memory.js → observability/diagnostics-memory.js} +1 -1
  188. package/engine/{logs-store.js → observability/logs-store.js} +5 -5
  189. package/engine/{metrics-store.js → observability/metrics-store.js} +4 -4
  190. package/engine/{check-status.js → operations/check-status.js} +3 -3
  191. package/engine/{cli.js → operations/cli.js} +271 -113
  192. package/engine/{distribution.js → operations/distribution.js} +5 -6
  193. package/engine/{cleanup.js → orchestration/cleanup.js} +72 -45
  194. package/engine/{cooldown.js → orchestration/cooldown.js} +5 -5
  195. package/engine/{dispatch-events.js → orchestration/dispatch-events.js} +2 -2
  196. package/engine/{dispatch.js → orchestration/dispatch.js} +129 -36
  197. package/engine/orchestration/failed-scheduled-cleanup.js +274 -0
  198. package/engine/{lifecycle.js → orchestration/lifecycle.js} +198 -90
  199. package/engine/{meeting.js → orchestration/meeting.js} +6 -16
  200. package/engine/{pipeline.js → orchestration/pipeline.js} +12 -12
  201. package/engine/{pre-dispatch-eval.js → orchestration/pre-dispatch-eval.js} +10 -9
  202. package/engine/{routing.js → orchestration/routing.js} +3 -3
  203. package/engine/{schedule-bootstrap.js → orchestration/schedule-bootstrap.js} +4 -4
  204. package/engine/{scheduler.js → orchestration/scheduler.js} +38 -8
  205. package/engine/{timeout.js → orchestration/timeout.js} +158 -109
  206. package/engine/{db-events.js → persistence/db-events.js} +2 -2
  207. package/engine/{dispatch-store.js → persistence/dispatch-store.js} +7 -7
  208. package/engine/{inbox-store.js → persistence/inbox-store.js} +2 -2
  209. package/engine/{note-link-backfill.js → persistence/note-link-backfill.js} +4 -4
  210. package/engine/{pr-fix-target-store.js → persistence/pr-fix-target-store.js} +8 -8
  211. package/engine/{pull-requests-store.js → persistence/pull-requests-store.js} +21 -7
  212. package/engine/{small-state-store.js → persistence/small-state-store.js} +31 -31
  213. package/engine/persistence/state-operations.js +350 -0
  214. package/engine/{steering-store.js → persistence/steering-store.js} +6 -6
  215. package/engine/{issues.js → planning/issues.js} +2 -2
  216. package/engine/{plan-prd-validation.js → planning/plan-prd-validation.js} +8 -2
  217. package/engine/planning/prd-result-sidecar.js +190 -0
  218. package/engine/{prd-store.js → planning/prd-store.js} +17 -17
  219. package/engine/{project-discovery.js → planning/project-discovery.js} +5 -5
  220. package/engine/{projects.js → planning/projects.js} +10 -10
  221. package/engine/{resolve-area.js → planning/resolve-area.js} +1 -1
  222. package/engine/{work-item-validation.js → planning/work-item-validation.js} +39 -3
  223. package/engine/{work-items-store.js → planning/work-items-store.js} +29 -21
  224. package/engine/{keep-process-sweep.js → processes/keep-process-sweep.js} +57 -17
  225. package/engine/{managed-spawn-launcher.js → processes/managed-spawn-launcher.js} +3 -3
  226. package/engine/{managed-spawn.js → processes/managed-spawn.js} +97 -46
  227. package/engine/{process-utils.js → processes/process-utils.js} +599 -55
  228. package/engine/{abandoned-pr-reconciliation.js → providers/abandoned-pr-reconciliation.js} +17 -7
  229. package/engine/{comment-classifier.js → providers/comment-classifier.js} +85 -17
  230. package/engine/{comment-format.js → providers/comment-format.js} +5 -5
  231. package/engine/{gh-comment.js → providers/gh-comment.js} +15 -15
  232. package/engine/{gh-token.js → providers/gh-token.js} +4 -4
  233. package/engine/{github.js → providers/github.js} +131 -54
  234. package/engine/{pr-action.js → providers/pr-action.js} +13 -12
  235. package/engine/{pr-clone-keep.js → providers/pr-clone-keep.js} +7 -7
  236. package/engine/{pr-devbox.js → providers/pr-devbox.js} +6 -6
  237. package/engine/{pr-fix-target.js → providers/pr-fix-target.js} +13 -13
  238. package/engine/{pr-remote-patch.js → providers/pr-remote-patch.js} +4 -4
  239. package/engine/{pr-resolve.js → providers/pr-resolve.js} +7 -7
  240. package/engine/{pr-temp-clone.js → providers/pr-temp-clone.js} +5 -5
  241. package/engine/{pr-track.js → providers/pr-track.js} +11 -13
  242. package/engine/{shared-branch-pr-reconcile.js → providers/shared-branch-pr-reconcile.js} +4 -4
  243. package/engine/qa/auto-prd-qa.js +313 -0
  244. package/engine/{qa-from-prd.js → qa/from-prd.js} +42 -12
  245. package/engine/qa/prd-session.js +240 -0
  246. package/engine/{qa-process-validation.js → qa/process-validation.js} +14 -9
  247. package/engine/{qa-runbooks.js → qa/runbooks.js} +1 -1
  248. package/engine/{qa-runs.js → qa/runs.js} +286 -15
  249. package/engine/{qa-sessions.js → qa/sessions.js} +595 -49
  250. package/engine/qa/visual-journey.js +654 -0
  251. package/engine/{qa-runners.js → qa-runners/index.js} +7 -7
  252. package/engine/qa-runners/maestro.js +3 -3
  253. package/engine/qa-runners/playwright.js +2 -2
  254. package/engine/{restart-health.js → recovery/restart-health.js} +48 -4
  255. package/engine/recovery/stop-stack.js +607 -0
  256. package/engine/{supervisor.js → recovery/supervisor.js} +105 -175
  257. package/engine/{watchdog.js → recovery/watchdog.js} +136 -13
  258. package/engine/runtimes/claude.js +14 -12
  259. package/engine/runtimes/codex.js +8 -6
  260. package/engine/runtimes/copilot.js +17 -16
  261. package/engine/{watch-actions.js → watches/actions.js} +13 -13
  262. package/engine/{watches.js → watches/index.js} +43 -32
  263. package/engine/{watches-store.js → watches/store.js} +4 -4
  264. package/engine/{create-pr-worktree.js → worktrees/create-pr.js} +1 -1
  265. package/engine/{worktree-gc.js → worktrees/gc.js} +70 -22
  266. package/engine/worktrees/inventory.js +671 -0
  267. package/engine/{live-checkout.js → worktrees/live-checkout.js} +4 -4
  268. package/engine/{worktree-pool.js → worktrees/pool.js} +2 -2
  269. package/engine/{worktree-preflight.js → worktrees/preflight.js} +1 -0
  270. package/engine/worktrees/quarantine-refs.js +173 -0
  271. package/engine.js +1137 -208
  272. package/minions.js +147 -77
  273. package/package.json +10 -6
  274. package/playbooks/_pr-description-audit.md +110 -78
  275. package/playbooks/build-fix-complex.md +2 -0
  276. package/playbooks/fix.md +16 -12
  277. package/playbooks/implement-shared.md +2 -0
  278. package/playbooks/implement.md +19 -20
  279. package/playbooks/plan-to-prd.md +18 -3
  280. package/playbooks/qa-session-draft.md +136 -1
  281. package/playbooks/qa-session-execute.md +80 -2
  282. package/playbooks/qa-session-setup.md +17 -1
  283. package/playbooks/qa-validate.md +1 -1
  284. package/playbooks/setup.md +2 -0
  285. package/playbooks/shared-rules.md +25 -32
  286. package/playbooks/templates/followup-dispatch.md +4 -3
  287. package/playbooks/verify.md +1 -1
  288. package/prompts/cc-system.md +19 -27
  289. package/watch-plugins/README.md +92 -0
  290. package/watch-plugins/ado-author-prs.js +336 -0
  291. package/watch-plugins/gh-author-prs.js +375 -0
  292. package/watch-plugins/http.js +474 -0
  293. package/watch-plugins/teams-channel.js +869 -0
  294. package/docs/dev-composite-workflow.md +0 -101
  295. package/docs/pr-screenshots/pr-886/after-single-header.png +0 -0
  296. package/docs/pr-screenshots/pr-886/before-duplicate-header.png +0 -0
  297. package/docs/pr-screenshots/pr-895/01-cancellation-reason-detail.png +0 -0
  298. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  299. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  300. package/docs/pr-screenshots/pr-901/projects-tab-default.png +0 -0
  301. package/docs/pr-screenshots/pr-901/projects-tab-fmf-selected.png +0 -0
  302. package/docs/pr-screenshots/pr-916/model-picker-AFTER-crop.png +0 -0
  303. package/docs/pr-screenshots/pr-916/model-picker-AFTER.png +0 -0
  304. package/docs/pr-screenshots/pr-916/model-picker-BEFORE-crop.png +0 -0
  305. package/docs/pr-screenshots/pr-916/model-picker-BEFORE.png +0 -0
  306. package/docs/pr-screenshots/pr-916/model-picker-dropdown-AFTER.png +0 -0
  307. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  308. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  309. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  310. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  311. package/docs/visual-evidence-ci.md +0 -103
  312. package/engine/bridge.js +0 -379
  313. package/engine/quarantine-refs.js +0 -103
  314. package/engine/state-operations.js +0 -178
  315. /package/engine/{steering-constraints.js → agents/steering-constraints.js} +0 -0
@@ -1,30 +1,66 @@
1
1
  /**
2
- * engine/process-utils.js — Cross-platform process-management helpers
2
+ * engine/processes/process-utils.js — Cross-platform process-management helpers
3
3
  * (kill trees, PID liveness, CPU sampling, process-table enumeration, cwd
4
4
  * holder detection, descendant/reachability BFS).
5
5
  *
6
- * Extracted from engine/shared.js (P-mrb8yg9x001k1401-b) — a self-contained
6
+ * Extracted from engine/core/shared.js (P-mrb8yg9x001k1401-b) — a self-contained
7
7
  * slice with no dependency on the rest of shared.js's state/JSON machinery.
8
8
  * shared.js re-exports these names so no caller needs to change its
9
- * `require('./shared')` call. Where a function needs MINIONS_DIR /
10
- * ENGINE_DEFAULTS from shared.js, it lazily `require('./shared')`s inside
9
+ * `require('../core/shared')` call. Where a function needs MINIONS_DIR /
10
+ * ENGINE_DEFAULTS from shared.js, it lazily `require('../core/shared')`s inside
11
11
  * the function body (not at module top-level) to avoid a load-time
12
12
  * circular-require deadlock with shared.js requiring this module back.
13
13
  */
14
14
 
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
- const { execSync: _execSync, execFile: _execFile } = require('child_process');
17
+ const { execSync: _execSync, execFile: _execFile, spawnSync: _spawnSync } = require('child_process');
18
+
19
+ // W-ms6dmv99005o26f4 — tri-state PID liveness.
20
+ //
21
+ // `process.kill(pid, 0)` has THREE outcomes, not two:
22
+ // - no throw → the signal was deliverable; the process exists.
23
+ // - ESRCH → the kernel found no such process; genuine, conclusive absence.
24
+ // - EPERM → the kernel FOUND the process and refused the signal. This is
25
+ // positive evidence of existence, and every helper here used to
26
+ // report it as death.
27
+ // - anything else (EINVAL, EACCES on exotic platforms, a thrown validate error
28
+ // from a caller-supplied probe) → we learned nothing.
29
+ //
30
+ // Collapsing all four into `false` is the third link of the 2026-07-29T17:44Z
31
+ // false-orphan storm: in engine/cli.js a `false` here zeroes `agentPid`, which is
32
+ // exactly the `hadPid && !agentPid` condition that flags a dispatch confirmed-dead
33
+ // at restart — bypassing both minAliveBeforeOrphanMs and the engine-restart grace
34
+ // window, so the dispatch is orphaned on the next tick after a few seconds of
35
+ // silence with failure_class `engine-restart`.
36
+ //
37
+ // `conclusive` lets destructive callers require proof before acting; the boolean
38
+ // facades below keep their historical "false == not known to be alive" contract
39
+ // for non-destructive callers.
40
+ function inspectPidLiveness(pid, opts = {}) {
41
+ const value = Number(pid);
42
+ if (!Number.isInteger(value) || value <= 0) {
43
+ return { alive: false, conclusive: true, reason: 'invalid-pid' };
44
+ }
45
+ const kill = typeof opts.kill === 'function' ? opts.kill : process.kill.bind(process);
46
+ try {
47
+ kill(value, 0);
48
+ return { alive: true, conclusive: true, reason: 'signalled' };
49
+ } catch (err) {
50
+ const code = String(err && err.code || '');
51
+ if (code === 'ESRCH') return { alive: false, conclusive: true, reason: 'esrch' };
52
+ if (code === 'EPERM') return { alive: true, conclusive: true, reason: 'eperm' };
53
+ return { alive: false, conclusive: false, reason: code ? code.toLowerCase() : 'probe-failed' };
54
+ }
55
+ }
18
56
 
19
57
  // Shared.js-local PID liveness check, moved here unchanged. Avoids a circular
20
- // require on engine/cli.js (which has its own isPidAlive) and engine/timeout.js
58
+ // require on engine/operations/cli.js (which has its own isPidAlive) and engine/orchestration/timeout.js
21
59
  // (which has isOsPidAliveForDispatch — but that one looks up pid from a
22
60
  // side-channel pid-file, whereas callers that already have the pid in-hand
23
61
  // use this simple check).
24
- function isPidAlive(pid) {
25
- if (!Number.isFinite(pid) || pid <= 0) return false;
26
- try { process.kill(pid, 0); return true; }
27
- catch { return false; }
62
+ function isPidAlive(pid, opts = {}) {
63
+ return inspectPidLiveness(pid, opts).alive;
28
64
  }
29
65
 
30
66
  // ─── Cross-Platform Process Kill Helpers ─────────────────────────────────────
@@ -61,9 +97,9 @@ function unrefTimer(timer) {
61
97
  return timer;
62
98
  }
63
99
 
64
- function killGracefully(proc, graceMs = 5000) {
100
+ function _killGracefully(proc, graceMs = 5000) {
65
101
  const pid = normalizeKillPid(proc);
66
- if (!pid) return;
102
+ if (!pid) return false;
67
103
  if (process.platform === 'win32') {
68
104
  try { _execSync(`taskkill /PID ${pid} /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); } catch { /* process may be dead */ }
69
105
  unrefTimer(setTimeout(() => {
@@ -75,9 +111,10 @@ function killGracefully(proc, graceMs = 5000) {
75
111
  killUnixProcessTree(pid, 'SIGKILL');
76
112
  }, graceMs));
77
113
  }
114
+ return true;
78
115
  }
79
116
 
80
- function killImmediate(proc) {
117
+ function _killImmediate(proc) {
81
118
  const pid = normalizeKillPid(proc);
82
119
  if (!pid) return false;
83
120
  if (process.platform === 'win32') {
@@ -91,12 +128,183 @@ function killImmediate(proc) {
91
128
  return killUnixProcessTree(pid, 'SIGKILL');
92
129
  }
93
130
 
131
+ // Kill one process without walking its descendants. Daemon replacement uses
132
+ // this because detached agents and managed services intentionally outlive the
133
+ // engine/dashboard process that launched them.
134
+ function _killProcessOnly(proc) {
135
+ const pid = normalizeKillPid(proc);
136
+ if (!pid) return false;
137
+ if (process.platform === 'win32') {
138
+ try {
139
+ _execSync(`taskkill /PID ${pid} /F`, { stdio: 'pipe', timeout: 3000, windowsHide: true });
140
+ return true;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+ try {
146
+ process.kill(pid, 'SIGKILL');
147
+ return true;
148
+ } catch {
149
+ return false;
150
+ }
151
+ }
152
+
153
+ function _normalizeProcessScriptPath(value, platform = process.platform) {
154
+ const normalized = String(value || '').replace(/\\/g, '/');
155
+ return platform === 'win32' ? normalized.toLowerCase() : normalized;
156
+ }
157
+
158
+ // `path.basename` binds to the HOST platform, but the helpers below are told
159
+ // which platform's process table they are parsing. Parsing a Windows path with
160
+ // POSIX semantics keeps the backslashes (breaking Windows enumeration from a
161
+ // Linux/macOS engine), and parsing a POSIX path with Windows semantics splits on
162
+ // a backslash that is a legal POSIX filename character. Always pick the flavour
163
+ // that matches the platform being enumerated. Call sites that have already run
164
+ // the raw value through `_normalizeProcessScriptPath` are separator-agnostic
165
+ // today, but they route through here too so the rule holds if that normalization
166
+ // ever stops collapsing backslashes.
167
+ function _processPathFlavor(platform) {
168
+ return platform === 'win32' ? path.win32 : path.posix;
169
+ }
170
+
171
+ function _tokenizeProcessCommandLine(commandLine) {
172
+ const tokens = [];
173
+ let current = '';
174
+ let quoted = false;
175
+ for (const char of String(commandLine || '')) {
176
+ if (char === '"') {
177
+ quoted = !quoted;
178
+ } else if (!quoted && /\s/.test(char)) {
179
+ if (current) {
180
+ tokens.push(current);
181
+ current = '';
182
+ }
183
+ } else {
184
+ current += char;
185
+ }
186
+ }
187
+ if (current) tokens.push(current);
188
+ return tokens;
189
+ }
190
+
191
+ function commandLineRunsScript(commandLine, scriptPath, platform = process.platform) {
192
+ const target = _normalizeProcessScriptPath(scriptPath, platform);
193
+ if (!target) return false;
194
+ const tokens = _tokenizeProcessCommandLine(commandLine);
195
+ const executable = _processPathFlavor(platform)
196
+ .basename(_normalizeProcessScriptPath(tokens[0], platform))
197
+ .toLowerCase();
198
+ if (executable !== 'node' && executable !== 'node.exe' && executable !== 'nodejs') return false;
199
+ const windows = platform === 'win32';
200
+ const isEngine = target === 'engine.js' || target.endsWith('/engine.js');
201
+ for (let i = 1; i < tokens.length; i++) {
202
+ const token = _normalizeProcessScriptPath(tokens[i], platform);
203
+ if (!token.endsWith('.js')) continue;
204
+ if (token !== target) {
205
+ if (windows) return false;
206
+ break;
207
+ }
208
+ if (!isEngine) return true;
209
+ return _normalizeProcessScriptPath(tokens[i + 1], platform) === 'start';
210
+ }
211
+ // `ps` flattens argv, so an unquoted POSIX path containing spaces cannot be
212
+ // reconstructed by tokenization. Match the exact path in the raw command
213
+ // only when no earlier JavaScript entrypoint exists.
214
+ if (!windows) {
215
+ const command = String(commandLine || '').replace(/\\/g, '/');
216
+ let offset = 0;
217
+ while (offset < command.length) {
218
+ const index = command.indexOf(target, offset);
219
+ if (index < 0) break;
220
+ const end = index + target.length;
221
+ const leftBoundary = index === 0 || /\s|["']/.test(command[index - 1]);
222
+ const rightBoundary = end === command.length || /\s|["']/.test(command[end]);
223
+ if (leftBoundary && rightBoundary) {
224
+ const prefix = command.slice(0, index);
225
+ if (!/\.js(?:\s|["']|$)/.test(prefix)) {
226
+ const suffix = command.slice(end).replace(/^["']/, '').trimStart();
227
+ return isEngine ? /^start(?:\s|$)/.test(suffix) : true;
228
+ }
229
+ }
230
+ offset = index + 1;
231
+ }
232
+ }
233
+ return false;
234
+ }
235
+
236
+ // Return Node PIDs whose executable script token exactly matches any requested
237
+ // path. Enumeration failures fail open: leaking a daemon is safer than killing
238
+ // an unrelated process from another checkout.
239
+ function listNodePidsMatchingScripts(scriptPaths, {
240
+ platform = process.platform,
241
+ spawnSync = _spawnSync,
242
+ execSync = _execSync,
243
+ } = {}) {
244
+ const targets = [...new Set((scriptPaths || []).map(value => String(value || '')).filter(Boolean))];
245
+ if (targets.length === 0) return [];
246
+ const flavor = _processPathFlavor(platform);
247
+ const basenames = [...new Set(targets.map(target => flavor.basename(target)))];
248
+ if (basenames.some(base => !/^[A-Za-z0-9._-]+$/.test(base))) return [];
249
+ let output = '';
250
+ try {
251
+ if (platform === 'win32') {
252
+ const commandLineFilter = basenames
253
+ .map(base => `CommandLine LIKE '%${base.replace(/'/g, "''")}%'`)
254
+ .join(' OR ');
255
+ const ps = `Get-CimInstance Win32_Process -Filter "name='node.exe' AND (${commandLineFilter})" | `
256
+ + 'ForEach-Object { "$($_.ProcessId)|$($_.CommandLine)" }';
257
+ const result = spawnSync('powershell', [
258
+ '-NoProfile',
259
+ '-NonInteractive',
260
+ '-EncodedCommand',
261
+ Buffer.from(ps, 'utf16le').toString('base64'),
262
+ ], {
263
+ encoding: 'utf8',
264
+ timeout: 8000,
265
+ windowsHide: true,
266
+ maxBuffer: 8 * 1024 * 1024,
267
+ });
268
+ if (result.error || result.status !== 0) return [];
269
+ output = String(result.stdout || '');
270
+ } else {
271
+ for (const base of basenames) {
272
+ try {
273
+ output += String(execSync(`ps -eo pid=,args= | grep -F -- '${base}'`, {
274
+ encoding: 'utf8',
275
+ timeout: 8000,
276
+ windowsHide: true,
277
+ maxBuffer: 8 * 1024 * 1024,
278
+ }) || '');
279
+ } catch { /* grep exit 1 means no candidate rows */ }
280
+ }
281
+ }
282
+ } catch {
283
+ return [];
284
+ }
285
+
286
+ const pids = new Set();
287
+ for (const line of output.split(/\r?\n/)) {
288
+ const match = platform === 'win32'
289
+ ? line.trim().match(/^(\d+)\|(.*)$/)
290
+ : line.trim().match(/^(\d+)\s+(.*)$/);
291
+ if (!match || !targets.some(target => commandLineRunsScript(match[2], target, platform))) continue;
292
+ const pid = Number(match[1]);
293
+ if (Number.isInteger(pid) && pid > 0) pids.add(pid);
294
+ }
295
+ return [...pids];
296
+ }
297
+
298
+ function listNodePidsMatchingScript(scriptPath, opts) {
299
+ return listNodePidsMatchingScripts([scriptPath], opts);
300
+ }
301
+
94
302
  // Decide whether a Windows `tasklist /FI "PID eq <pid>" /NH` output proves the
95
303
  // pid is alive. A bare `out.includes(String(pid))` can read a DEAD pid as alive
96
304
  // on a digit-substring collision — the pid appears inside another column (a
97
305
  // larger PID, a memory-KB figure, a session id). Require a word-boundary match
98
306
  // (`\b<pid>\b`) and, when an `imageName` is supplied, that the expected process
99
- // image is present too. Mirrors the hardened check in engine/restart-health.js.
307
+ // image is present too. Mirrors the hardened check in engine/recovery/restart-health.js.
100
308
  // Pure (no shell-out) so callers can pass captured output and unit tests can
101
309
  // feed crafted samples.
102
310
  function tasklistOutputShowsPid(out, pid, { imageName } = {}) {
@@ -160,10 +368,8 @@ function getProcessCpuSeconds(pid) {
160
368
  return null;
161
369
  }
162
370
 
163
- // Single-PID kill (no /T tree walk) — used by the orphan-MCP sweep where we
164
- // already enumerated descendants ourselves and the parent is dead, so /T would
165
- // be a no-op anyway.
166
- function killByPidImmediate(pid) {
371
+ // Single-PID kill (no tree walk) for identity-verified declarations.
372
+ function _killByPidImmediate(pid) {
167
373
  const n = Number(pid);
168
374
  if (!Number.isInteger(n) || n <= 0 || n === process.pid) return false;
169
375
  if (process.platform === 'win32') {
@@ -176,7 +382,7 @@ function killByPidImmediate(pid) {
176
382
  // Batched kill — one OS process for N PIDs. `taskkill` accepts repeated /PID
177
383
  // flags natively; on Unix we still loop process.kill, which is in-process and
178
384
  // cheap. Returns the count of successful kills.
179
- function killByPidsImmediate(pids) {
385
+ function _killByPidsImmediate(pids) {
180
386
  const valid = (Array.isArray(pids) ? pids : [])
181
387
  .map(Number)
182
388
  .filter(n => Number.isInteger(n) && n > 0 && n !== process.pid);
@@ -186,7 +392,7 @@ function killByPidsImmediate(pids) {
186
392
  try { _execSync(`taskkill /F ${flags}`, { stdio: 'pipe', timeout: 5000, windowsHide: true }); return valid.length; }
187
393
  catch {
188
394
  let killed = 0;
189
- for (const p of valid) { if (killByPidImmediate(p)) killed++; }
395
+ for (const p of valid) { if (_killByPidImmediate(p)) killed++; }
190
396
  return killed;
191
397
  }
192
398
  }
@@ -203,7 +409,7 @@ function killByPidsImmediate(pids) {
203
409
  // an attached child (e.g. managed-spawn's Node launcher shim, P-8a4d6f29): a
204
410
  // non-tree kill would reap the shim and orphan/leak the child. Returns the
205
411
  // count of pids for which the kill call was issued without throwing.
206
- function killByPidTreesImmediate(pids) {
412
+ function _killByPidTreesImmediate(pids) {
207
413
  const valid = (Array.isArray(pids) ? pids : [])
208
414
  .map(Number)
209
415
  .filter(n => Number.isInteger(n) && n > 0 && n !== process.pid);
@@ -229,6 +435,184 @@ function killByPidTreesImmediate(pids) {
229
435
  return killed;
230
436
  }
231
437
 
438
+ const PROCESS_TERMINATION_SITES = Object.freeze({
439
+ 'bin.daemon-stop-process': { mode: 'process-only', ownership: 'exact-scripts', description: 'Stop an exact-script Minions daemon without its detached children.' },
440
+ 'bin.daemon-stop-tree': { mode: 'tree-immediate', ownership: 'exact-scripts', description: 'Stop an exact-script daemon and its owned descendants during full shutdown.' },
441
+ 'dashboard.engine-restart': { mode: 'process-only', ownership: 'exact-script', description: 'Restart the engine while preserving detached agents.' },
442
+ 'dashboard.keep-process-manual': { mode: 'pid-immediate', ownership: 'exact-identity', description: 'Operator removes one identity-verified keep-process PID.' },
443
+ 'dashboard.tools-inventory-shutdown': { mode: 'tree-immediate', ownership: 'child-handle', description: 'Dashboard shutdown reaps its tools-inventory child.' },
444
+ 'dashboard.tools-inventory-watchdog': { mode: 'tree-immediate', ownership: 'child-handle', description: 'Tools-inventory child exceeded its watchdog.' },
445
+ 'engine.acp-worker-close': { mode: 'tree-immediate', ownership: 'child-handle', description: 'Close an owned ACP worker and descendants.' },
446
+ 'engine.acp-worker-root-close': { mode: 'process-only', ownership: 'child-handle', description: 'Close only an ACP worker root while preserving declared descendants.' },
447
+ 'engine.cleanup.stale-agent-pid': { mode: 'tree-immediate', ownership: 'recorded-agent', description: 'Reap an identity-verified stale agent PID file.' },
448
+ 'engine.cli.kill-command': { mode: 'tree-immediate', ownership: 'recorded-agent', description: 'Explicit CLI kill of an identity-verified agent PID.' },
449
+ 'engine.cli.shutdown-pooled': { mode: 'facade-cancel', ownership: 'facade', description: 'Graceful engine shutdown cancels an active pooled turn.' },
450
+ 'engine.cli.unsafe-reattach': { mode: 'tree-immediate', ownership: 'recorded-agent', description: 'Reject an identity-verified agent running from an unsafe execution path.' },
451
+ 'engine.dispatch.cancel': { mode: 'tree-graceful', ownership: 'recorded-agent', description: 'Explicitly cancel an identity-verified cold dispatch.' },
452
+ 'engine.git-status-probe-cleanup': { mode: 'pids-immediate', ownership: 'exact-identities', description: 'Reap git probes proven to target one owned worktree.' },
453
+ 'engine.keep-process.ttl': { mode: 'pid-immediate', ownership: 'declared-process', description: 'Expire a keep-process PID whose identity predates its declaration.' },
454
+ 'engine.lifecycle.hook-buffer-limit': { mode: 'tree-immediate', ownership: 'child-handle', description: 'Post-completion hook exceeded its output bound.' },
455
+ 'engine.lifecycle.hook-timeout-force': { mode: 'tree-immediate', ownership: 'child-handle', description: 'Post-completion hook ignored graceful timeout.' },
456
+ 'engine.lifecycle.hook-timeout-graceful': { mode: 'tree-graceful', ownership: 'child-handle', description: 'Post-completion hook exceeded its timeout.' },
457
+ 'engine.llm.abort': { mode: 'tree-immediate', ownership: 'child-handle', description: 'Abort an owned direct LLM child.' },
458
+ 'engine.llm.timeout': { mode: 'tree-immediate', ownership: 'child-handle', description: 'Owned direct LLM child exceeded its timeout.' },
459
+ 'engine.managed-launcher.self-signal': { mode: 'self-signal', ownership: 'self', description: 'Managed launcher forwards a termination signal to itself.' },
460
+ 'engine.managed-spawn.reap-failed': { mode: 'tree-immediate', ownership: 'exact-identity', description: 'A newly spawned managed runtime failed acceptance.' },
461
+ 'engine.managed-spawn.remove': { mode: 'tree-immediate', ownership: 'exact-identity', description: 'Remove an identity-verified managed process tree.' },
462
+ 'engine.pooled-agent.descendants': { mode: 'pids-immediate', ownership: 'exact-identities', description: 'Reap descendants proven to belong to one pooled session.' },
463
+ 'engine.query.git-probe-timeout': { mode: 'tree-immediate', ownership: 'child-handle', description: 'Git probe child exceeded its watchdog.' },
464
+ 'engine.spawn-agent.descendants': { mode: 'pids-immediate', ownership: 'exact-identities', description: 'Spawn wrapper reaps its snapshotted runtime descendants.' },
465
+ 'engine.spawn-agent.stdin-failure': { mode: 'tree-immediate', ownership: 'child-handle', description: 'Runtime child could not receive its startup prompt.' },
466
+ 'engine.spawn-partial-failure': { mode: 'tree-immediate', ownership: 'child-handle', description: 'Agent setup failed after creating a cold child.' },
467
+ 'engine.spawn-partial-pooled': { mode: 'facade-cancel', ownership: 'facade', description: 'Agent setup failed after acquiring a pooled turn.' },
468
+ 'engine.spawn-phase.stall': { mode: 'tree-immediate', ownership: 'tracked-process', description: 'Tracked cold runtime remained wedged in startup.' },
469
+ 'engine.spawn-phase.stall-pooled': { mode: 'facade-cancel', ownership: 'facade', description: 'Tracked pooled turn remained wedged in startup.' },
470
+ 'engine.supervisor.daemon-replace': { mode: 'process-only', ownership: 'exact-script', description: 'Supervisor replaces an exact-script daemon without descendants.' },
471
+ 'engine.timeout.tracked-cold': { mode: 'tree-immediate', ownership: 'tracked-process', description: 'Timeout, steering, or removed-dispatch cleanup stops a tracked cold runtime.' },
472
+ 'engine.timeout.tracked-cold-escalation': { mode: 'tree-immediate', ownership: 'tracked-process', description: 'Steering escalation force-stops a tracked cold runtime.' },
473
+ 'engine.timeout.tracked-cold-graceful': { mode: 'tree-graceful', ownership: 'tracked-process', description: 'Tracked cold runtime receives graceful timeout termination.' },
474
+ 'engine.timeout.tracked-pooled': { mode: 'facade-cancel', ownership: 'facade', description: 'Timeout or steering cancels a pooled turn without killing its worker.' },
475
+ 'engine.worktree-gc.orphan-holder': { mode: 'tree-immediate', ownership: 'exact-identity', description: 'Reap an aged process proven to hold an owned orphan worktree.' },
476
+ 'test.helper-child-watchdog': { mode: 'tree-immediate', ownership: 'child-handle', description: 'Shared test helper reaps a child that exceeded its watchdog.' },
477
+ 'test.process-descendants': { mode: 'pids-immediate', ownership: 'exact-identities', description: 'Test harness reaps descendants of its own worker process.' },
478
+ 'test.unit-worker-timeout': { mode: 'tree-immediate', ownership: 'child-handle', description: 'Parallel unit runner reaps a timed-out worker tree.' },
479
+ 'compat.tree-graceful': { mode: 'tree-graceful', ownership: 'compatibility', description: 'Legacy public helper; production call sites are forbidden.' },
480
+ 'compat.tree-immediate': { mode: 'tree-immediate', ownership: 'compatibility', description: 'Legacy public helper; production call sites are forbidden.' },
481
+ 'compat.process-only': { mode: 'process-only', ownership: 'compatibility', description: 'Legacy public helper; production call sites are forbidden.' },
482
+ 'compat.pid-immediate': { mode: 'pid-immediate', ownership: 'compatibility', description: 'Legacy public helper; production call sites are forbidden.' },
483
+ 'compat.pids-immediate': { mode: 'pids-immediate', ownership: 'compatibility', description: 'Legacy public helper; production call sites are forbidden.' },
484
+ 'compat.trees-immediate': { mode: 'trees-immediate', ownership: 'compatibility', description: 'Legacy public helper; production call sites are forbidden.' },
485
+ });
486
+
487
+ function _terminationOwnershipAllowed(policy, target, opts) {
488
+ if (policy.ownership === 'compatibility') return true;
489
+ if (policy.ownership === 'child-handle') {
490
+ return !!target
491
+ && typeof target === 'object'
492
+ && normalizeKillPid(target) !== null
493
+ && target.killed !== true
494
+ && (!Object.prototype.hasOwnProperty.call(target, 'exitCode') || target.exitCode === null);
495
+ }
496
+ if (policy.ownership === 'facade') return !!target && typeof target.kill === 'function';
497
+ if (policy.ownership === 'self') return Number(target?.pid) === process.pid;
498
+ if (policy.ownership === 'exact-script') {
499
+ const pid = normalizeKillPid(target);
500
+ return !!pid
501
+ && typeof opts.scriptPath === 'string'
502
+ && listNodePidsMatchingScript(opts.scriptPath).includes(pid);
503
+ }
504
+ if (policy.ownership === 'exact-scripts') {
505
+ const pid = normalizeKillPid(target);
506
+ return !!pid
507
+ && Array.isArray(opts.scriptPaths)
508
+ && listNodePidsMatchingScripts(opts.scriptPaths).includes(pid);
509
+ }
510
+ if (policy.ownership === 'exact-identity') {
511
+ const pid = normalizeKillPid(target);
512
+ const expected = opts.expectedIdentity;
513
+ if (!pid || !expected || Number(expected.pid) !== pid) return false;
514
+ const processes = Array.isArray(opts.processes) ? opts.processes : listAllProcesses();
515
+ return isSameProcessIdentity(expected, getProcessIdentity(pid, { processes }));
516
+ }
517
+ if (policy.ownership === 'declared-process') {
518
+ const pid = normalizeKillPid(target);
519
+ const processes = Array.isArray(opts.processes) ? opts.processes : listAllProcesses();
520
+ return !!pid
521
+ && Number(opts.recordedAtMs) > 0
522
+ && isProcessIdentityCompatible(
523
+ getProcessIdentity(pid, { processes }),
524
+ Number(opts.recordedAtMs),
525
+ opts.skewMs,
526
+ );
527
+ }
528
+ if (policy.ownership === 'tracked-process' || policy.ownership === 'recorded-agent') {
529
+ const processes = Array.isArray(opts.processes) ? opts.processes : listAllProcesses();
530
+ return (policy.ownership !== 'tracked-process' || opts.tracked === true)
531
+ && Number(opts.recordedAtMs) > 0
532
+ && isRecordedAgentProcess(normalizeKillPid(target), Number(opts.recordedAtMs), {
533
+ ...opts,
534
+ processes,
535
+ });
536
+ }
537
+ return false;
538
+ }
539
+
540
+ function _filterExactIdentityTargets(target, opts) {
541
+ const pids = (Array.isArray(target) ? target : [])
542
+ .map(Number)
543
+ .filter(pid => Number.isInteger(pid) && pid > 0);
544
+ const expected = opts.expectedIdentities instanceof Map
545
+ ? opts.expectedIdentities
546
+ : new Map();
547
+ if (pids.length === 0 || expected.size === 0) return [];
548
+ const processes = Array.isArray(opts.processes) ? opts.processes : listAllProcesses();
549
+ const currentByPid = new Map(processes.map(entry => [Number(entry.pid), entry]));
550
+ return pids.filter(pid => {
551
+ const startedAt = Number(expected.get(pid));
552
+ const current = currentByPid.get(pid);
553
+ return startedAt > 0
554
+ && Number(current?.startedAt) > 0
555
+ && Number(current.startedAt) === startedAt;
556
+ });
557
+ }
558
+
559
+ function terminateProcess(site, target, opts = {}) {
560
+ const policy = PROCESS_TERMINATION_SITES[site];
561
+ if (!policy) throw new Error(`Unknown process termination site: ${site}`);
562
+ let ownedTarget = target;
563
+ if (policy.ownership === 'exact-identities') {
564
+ ownedTarget = _filterExactIdentityTargets(target, opts);
565
+ if (ownedTarget.length === 0) return 0;
566
+ } else if (!_terminationOwnershipAllowed(policy, target, opts)) {
567
+ return false;
568
+ }
569
+ switch (policy.mode) {
570
+ case 'tree-graceful':
571
+ return _killGracefully(ownedTarget, opts.graceMs);
572
+ case 'tree-immediate':
573
+ return _killImmediate(ownedTarget);
574
+ case 'process-only':
575
+ return _killProcessOnly(ownedTarget);
576
+ case 'pid-immediate':
577
+ return _killByPidImmediate(ownedTarget?.pid ?? ownedTarget);
578
+ case 'pids-immediate':
579
+ return _killByPidsImmediate(ownedTarget);
580
+ case 'trees-immediate':
581
+ return _killByPidTreesImmediate(ownedTarget);
582
+ case 'facade-cancel':
583
+ return ownedTarget.kill() !== false;
584
+ case 'self-signal':
585
+ try { process.kill(process.pid, opts.signal); return true; }
586
+ catch { return false; }
587
+ default:
588
+ throw new Error(`Unsupported process termination mode: ${policy.mode}`);
589
+ }
590
+ }
591
+
592
+ function killGracefully(proc, graceMs = 5000) {
593
+ return terminateProcess('compat.tree-graceful', proc, { graceMs });
594
+ }
595
+
596
+ function killImmediate(proc) {
597
+ return terminateProcess('compat.tree-immediate', proc);
598
+ }
599
+
600
+ function killProcessOnly(proc) {
601
+ return terminateProcess('compat.process-only', proc);
602
+ }
603
+
604
+ function killByPidImmediate(pid) {
605
+ return terminateProcess('compat.pid-immediate', pid);
606
+ }
607
+
608
+ function killByPidsImmediate(pids) {
609
+ return terminateProcess('compat.pids-immediate', pids);
610
+ }
611
+
612
+ function killByPidTreesImmediate(pids) {
613
+ return terminateProcess('compat.trees-immediate', pids);
614
+ }
615
+
232
616
  // ─── Process Table Enumeration ───────────────────────────────────────────────
233
617
  // Cross-platform listing of every live process as { pid, ppid, name, cmd? }.
234
618
  // `cmd` is best-effort — included on Windows (via wmic) and Linux (/proc); may
@@ -315,6 +699,56 @@ function listAllProcesses() {
315
699
  return process.platform === 'win32' ? _winListProcesses() : _unixListProcesses();
316
700
  }
317
701
 
702
+ // W-ms6dmv99005o26f4 — "the enumerator failed" must not read as "those processes
703
+ // are gone".
704
+ //
705
+ // listAllProcesses() collapses EVERY failure — a PowerShell/wmic/ps timeout under
706
+ // fleet load, a maxBuffer overflow, a spawn EAGAIN — into `[]`. Callers that hand
707
+ // that `[]` to the helpers below then get a confident "no such process" for every
708
+ // PID they ask about. engine/cli.js takes exactly one snapshot for the whole fleet
709
+ // at engine start, so a single transient enumeration failure disproved every live
710
+ // agent at once, flagged them all confirmed-dead, and produced the fleet-wide
711
+ // false-orphan storm of 2026-07-29T17:44Z (a dozen dispatches reaped within ~10s,
712
+ // retries consumed and worktrees GC'd — while the agent processes were still
713
+ // running, since nothing had actually killed them).
714
+ //
715
+ // The snapshot is self-validating: a live OS always contains at least the process
716
+ // doing the observing, so a snapshot that is empty, or that omits `selfPid`
717
+ // (truncated read, partially parsed CSV), is known-incomplete and carries no
718
+ // evidence of absence at all.
719
+ const PROCESS_SNAPSHOT_UNAVAILABLE = 'process-snapshot-unavailable';
720
+
721
+ function captureProcessSnapshot(opts = {}) {
722
+ const list = typeof opts.list === 'function' ? opts.list : listAllProcesses;
723
+ const selfPid = Number.isInteger(opts.selfPid) ? opts.selfPid : process.pid;
724
+ let processes = [];
725
+ let error = null;
726
+ try { processes = list(); } catch (err) { error = err; }
727
+ if (!Array.isArray(processes)) processes = [];
728
+ const usable = processes.length > 0 && processes.some(entry => Number(entry && entry.pid) === selfPid);
729
+ if (usable) return { ok: true, processes };
730
+ return {
731
+ ok: false,
732
+ processes: [],
733
+ reason: error ? `${PROCESS_SNAPSHOT_UNAVAILABLE}: ${error.message}` : PROCESS_SNAPSHOT_UNAVAILABLE,
734
+ };
735
+ }
736
+
737
+ // Normalize an `opts.processes` value into a TRUSTED process array, or null when
738
+ // the caller supplied no usable snapshot. Returning null rather than [] is the
739
+ // whole point: [] asserts "these processes do not exist", null admits "unknown".
740
+ //
741
+ // A raw array keeps its historical trusted-input meaning so existing callers
742
+ // (engine/cleanup.js, engine/timeout.js, tests passing synthetic fleets) are
743
+ // unaffected; only a `captureProcessSnapshot()` result can report itself unusable.
744
+ function _resolveSnapshotProcesses(value) {
745
+ if (Array.isArray(value)) return value;
746
+ if (value && typeof value === 'object' && Array.isArray(value.processes)) {
747
+ return value.ok === false ? null : value.processes;
748
+ }
749
+ return null;
750
+ }
751
+
318
752
  function _parsePowerShellProcesses(out) {
319
753
  const parsed = JSON.parse(out);
320
754
  const arr = Array.isArray(parsed) ? parsed : [parsed];
@@ -350,13 +784,41 @@ function _parseUnixProcessesWithStart(out) {
350
784
  }).filter(Boolean);
351
785
  }
352
786
 
353
- function getProcessIdentity(pid, opts = {}) {
787
+ // W-ms6dmv99005o26f4 — tri-state identity observation.
788
+ //
789
+ // getProcessIdentity() returns null for three very different situations: the PID
790
+ // is invalid, the process is genuinely absent, and "we could not observe it".
791
+ // Only the first two are evidence. Callers that act destructively on a null —
792
+ // engine/cli.js refuses reattach and marks the dispatch confirmed-dead, which
793
+ // bypasses both minAliveBeforeOrphanMs and the engine-restart grace window in
794
+ // engine/timeout.js — need the distinction.
795
+ function inspectProcessIdentity(pid, opts = {}) {
354
796
  const value = Number(pid);
355
- if (!Number.isInteger(value) || value <= 0) return null;
356
- const processes = Array.isArray(opts.processes) ? opts.processes : listAllProcesses();
797
+ if (!Number.isInteger(value) || value <= 0) {
798
+ return { identity: null, conclusive: true, reason: 'invalid-pid' };
799
+ }
800
+ let processes = _resolveSnapshotProcesses(opts.processes);
801
+ if (!processes) {
802
+ // No usable caller snapshot — re-observe live rather than concluding absence.
803
+ const live = captureProcessSnapshot(
804
+ typeof opts._list === 'function' ? { list: opts._list, selfPid: opts.selfPid } : {},
805
+ );
806
+ if (!live.ok) return { identity: null, conclusive: false, reason: live.reason };
807
+ processes = live.processes;
808
+ }
357
809
  const match = processes.find(entry => Number(entry && entry.pid) === value);
358
- const startedAt = Number(match && match.startedAt);
359
- return startedAt > 0 ? { pid: value, startedAt } : null;
810
+ if (!match) return { identity: null, conclusive: true, reason: 'absent-from-snapshot' };
811
+ const startedAt = Number(match.startedAt);
812
+ if (!(startedAt > 0)) {
813
+ // The process exists but the platform did not report a creation time (e.g. a
814
+ // `ps` variant without lstart). Absence of a timestamp is not a mismatch.
815
+ return { identity: null, conclusive: false, reason: 'start-time-unavailable' };
816
+ }
817
+ return { identity: { pid: value, startedAt }, conclusive: true, reason: 'snapshot' };
818
+ }
819
+
820
+ function getProcessIdentity(pid, opts = {}) {
821
+ return inspectProcessIdentity(pid, opts).identity;
360
822
  }
361
823
 
362
824
  function isProcessIdentityCompatible(identity, recordedAtMs, skewMs = 1000) {
@@ -365,6 +827,27 @@ function isProcessIdentityCompatible(identity, recordedAtMs, skewMs = 1000) {
365
827
  && identity.startedAt <= recordedAtMs + skewMs;
366
828
  }
367
829
 
830
+ // The fail-closed inverse of isProcessIdentityCompatible is NOT proof of a
831
+ // foreign process: that gate also answers false when the identity could not be
832
+ // resolved or the record carries no usable start time (absence of evidence).
833
+ // This helper answers the narrower question — did we resolve a real creation
834
+ // time that is provably LATER than the child we recorded, i.e. is this PID
835
+ // demonstrably somebody else's? Only that is safe to act on destructively
836
+ // (W-ms8d10y3003t0588).
837
+ function isProcessIdentityConclusiveMismatch(identity, recordedAtMs, skewMs = 1000) {
838
+ const observedAt = Number(identity && identity.startedAt);
839
+ return Number.isFinite(observedAt)
840
+ && observedAt > 0
841
+ && Number.isFinite(recordedAtMs)
842
+ // A non-positive recorded time is not a real start time — callers coerce a
843
+ // missing `started_at` through Number(), and Number(null) / Number('') are
844
+ // both 0. Epoch 0 would make EVERY live process look provably later than
845
+ // ours, which is the exact absence-of-evidence collapse this helper exists
846
+ // to prevent.
847
+ && recordedAtMs > 0
848
+ && observedAt > recordedAtMs + skewMs;
849
+ }
850
+
368
851
  function isSameProcessIdentity(left, right) {
369
852
  return !!left
370
853
  && !!right
@@ -465,9 +948,9 @@ function findProcessesWithCwdInside(dir, opts = {}) {
465
948
  // Lazy require avoids a load-time circular-require deadlock: shared.js
466
949
  // requires this module at top-level, so shared's own module.exports isn't
467
950
  // populated yet at that point — but by the time this function actually
468
- // RUNS, shared.js has finished loading and require('./shared') resolves
951
+ // RUNS, shared.js has finished loading and require('../core/shared') resolves
469
952
  // to its full, cached exports object.
470
- const { ENGINE_DEFAULTS } = require('./shared');
953
+ const { ENGINE_DEFAULTS } = require('../core/shared');
471
954
  const timeoutMs = Number(opts.timeoutMs) > 0
472
955
  ? Number(opts.timeoutMs)
473
956
  : (ENGINE_DEFAULTS.orphanHolderScanTimeoutMs || 5000);
@@ -747,7 +1230,7 @@ function findProcessCwdHolders(worktreePath, opts = {}) {
747
1230
  try { resolved = path.resolve(worktreePath); } catch { return []; }
748
1231
  if (!resolved) return [];
749
1232
  // Lazy require — see comment in findProcessesWithCwdInside above.
750
- const { ENGINE_DEFAULTS, MINIONS_DIR } = require('./shared');
1233
+ const { ENGINE_DEFAULTS, MINIONS_DIR } = require('../core/shared');
751
1234
  const timeoutMs = Number(opts.timeoutMs) > 0
752
1235
  ? Number(opts.timeoutMs)
753
1236
  : (ENGINE_DEFAULTS.statusProbeKillTimeoutMs || 12000);
@@ -779,8 +1262,8 @@ function findProcessCwdHolders(worktreePath, opts = {}) {
779
1262
  // Cross-check a single PID's command line for a Minions agent invocation,
780
1263
  // including the shared spawn wrapper and every bundled runtime CLI. Used by
781
1264
  // orphan/recycled-PID safety:
782
- // - engine/cleanup.js: gate before killing a PID found in engine/tmp/pid-*.pid
783
- // - engine/timeout.js: gate before parking a dispatch as still-alive when
1265
+ // - engine/orchestration/cleanup.js: gate before killing a PID found in engine/tmp/pid-*.pid
1266
+ // - engine/orchestration/timeout.js: gate before parking a dispatch as still-alive when
784
1267
  // the OS PID is alive but may belong to an unrelated recycled-PID process
785
1268
  //
786
1269
  // Windows: PowerShell Get-CimInstance for the full CommandLine of one PID.
@@ -802,31 +1285,69 @@ function _commandLineMatchesAgent(cmdline) {
802
1285
  return AGENT_COMMAND_PATTERNS.some(pattern => pattern.test(value));
803
1286
  }
804
1287
 
805
- function isProcessCommandLineMatchingAgent(pid) {
1288
+ // W-ms6dmv99005o26f4 — tri-state command-line observation.
1289
+ //
1290
+ // `conclusive` answers "did we actually observe anything?", separately from
1291
+ // `match`. The old boolean form short-circuited on `Array.isArray(opts.processes)`
1292
+ // and returned false whenever the PID was missing from the array — including when
1293
+ // that array was the `[]` produced by a FAILED enumeration, so the per-PID
1294
+ // fallback probe below never ran and a live agent was reported as not-an-agent.
1295
+ function inspectProcessCommandLineForAgent(pid, opts = {}) {
806
1296
  const n = Number(pid);
807
- if (!Number.isInteger(n) || n <= 0) return false;
1297
+ if (!Number.isInteger(n) || n <= 0) return { match: false, conclusive: true, reason: 'invalid-pid' };
1298
+ const snapshot = _resolveSnapshotProcesses(opts.processes);
1299
+ if (snapshot) {
1300
+ const match = snapshot.find(entry => Number(entry?.pid) === n);
1301
+ if (!match) return { match: false, conclusive: true, reason: 'absent-from-snapshot' };
1302
+ if (match.cmd) {
1303
+ return { match: _commandLineMatchesAgent(match.cmd), conclusive: true, reason: 'snapshot-cmdline' };
1304
+ }
1305
+ // Snapshot has the process but no command line (wmic/ps enumerations omit it)
1306
+ // — fall through to the per-PID probe.
1307
+ }
808
1308
  let cmdline = '';
809
1309
  try {
810
- if (process.platform === 'win32') {
811
- const out = _execSync(
812
- `powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Process -Filter 'ProcessId=${n}').CommandLine"`,
813
- { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, windowsHide: true, encoding: 'utf8' }
814
- );
815
- cmdline = String(out || '').trim();
816
- } else {
817
- try {
818
- const buf = fs.readFileSync(`/proc/${n}/cmdline`);
819
- cmdline = buf.toString('utf8').replace(/\0/g, ' ').trim();
820
- } catch {
821
- try {
822
- cmdline = String(_execSync(`ps -p ${n} -o command=`,
823
- { stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000, encoding: 'utf8' }) || '').trim();
824
- } catch { return false; }
825
- }
826
- }
827
- } catch { return false; }
828
- if (!cmdline) return false;
829
- return _commandLineMatchesAgent(cmdline);
1310
+ cmdline = typeof opts._probe === 'function'
1311
+ ? String(opts._probe(n) || '').trim()
1312
+ : _readProcessCommandLine(n);
1313
+ } catch { return { match: false, conclusive: false, reason: 'probe-failed' }; }
1314
+ if (!cmdline) return { match: false, conclusive: false, reason: 'probe-empty' };
1315
+ return { match: _commandLineMatchesAgent(cmdline), conclusive: true, reason: 'live-cmdline' };
1316
+ }
1317
+
1318
+ // Read one PID's full command line, or throw when it cannot be observed.
1319
+ // Windows: PowerShell Get-CimInstance. Linux: /proc/<pid>/cmdline (NUL-separated).
1320
+ // macOS / when /proc isn't available: fallback `ps -p <pid> -o command=`.
1321
+ function _readProcessCommandLine(n) {
1322
+ if (process.platform === 'win32') {
1323
+ const out = _execSync(
1324
+ `powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Process -Filter 'ProcessId=${n}').CommandLine"`,
1325
+ { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, windowsHide: true, encoding: 'utf8' }
1326
+ );
1327
+ return String(out || '').trim();
1328
+ }
1329
+ try {
1330
+ const buf = fs.readFileSync(`/proc/${n}/cmdline`);
1331
+ return buf.toString('utf8').replace(/\0/g, ' ').trim();
1332
+ } catch {
1333
+ return String(_execSync(`ps -p ${n} -o command=`,
1334
+ { stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000, encoding: 'utf8' }) || '').trim();
1335
+ }
1336
+ }
1337
+
1338
+ // Boolean facade over inspectProcessCommandLineForAgent. Returns false when the
1339
+ // PID is invalid, the process doesn't exist, the command line can't be read, or
1340
+ // the cmdline contains no recognized agent marker. False stays the safe default
1341
+ // for both historical call sites: cleanup falls through to "skip kill" and
1342
+ // timeout falls through to "treat PID as dead".
1343
+ function isProcessCommandLineMatchingAgent(pid, opts = {}) {
1344
+ return inspectProcessCommandLineForAgent(pid, opts).match;
1345
+ }
1346
+
1347
+ function isRecordedAgentProcess(pid, recordedAtMs, opts = {}) {
1348
+ const identity = getProcessIdentity(pid, opts);
1349
+ return isProcessIdentityCompatible(identity, recordedAtMs, opts.skewMs)
1350
+ && isProcessCommandLineMatchingAgent(pid, opts);
830
1351
  }
831
1352
 
832
1353
  function _buildChildMap(processes) {
@@ -838,6 +1359,14 @@ function _buildChildMap(processes) {
838
1359
  return childMap;
839
1360
  }
840
1361
 
1362
+ function _isChronologicalChild(parentPid, childPid, byPid) {
1363
+ const parentStartedAt = Number(byPid.get(parentPid)?.startedAt) || 0;
1364
+ const childStartedAt = Number(byPid.get(childPid)?.startedAt) || 0;
1365
+ // Detached children retain their original PPID after the parent exits. If
1366
+ // that PID is later reused, the older process is not a child of the new one.
1367
+ return parentStartedAt <= 0 || childStartedAt <= 0 || childStartedAt >= parentStartedAt;
1368
+ }
1369
+
841
1370
  // BFS descendants of rootPid given a process snapshot. `allProcesses` is
842
1371
  // injectable for tests and for amortizing one snapshot across multiple
843
1372
  // `listProcessDescendants` calls in the same tick.
@@ -846,6 +1375,7 @@ function listProcessDescendants(rootPid, allProcesses = null) {
846
1375
  if (!Number.isInteger(root) || root <= 0) return [];
847
1376
  const procs = Array.isArray(allProcesses) ? allProcesses : listAllProcesses();
848
1377
  const childMap = _buildChildMap(procs);
1378
+ const byPid = new Map(procs.map(entry => [Number(entry.pid), entry]));
849
1379
  const result = [];
850
1380
  const seen = new Set([root]);
851
1381
  const queue = [root];
@@ -853,7 +1383,7 @@ function listProcessDescendants(rootPid, allProcesses = null) {
853
1383
  const cur = queue.shift();
854
1384
  const children = childMap.get(cur) || [];
855
1385
  for (const c of children) {
856
- if (seen.has(c)) continue;
1386
+ if (seen.has(c) || !_isChronologicalChild(cur, c, byPid)) continue;
857
1387
  seen.add(c);
858
1388
  result.push(c);
859
1389
  queue.push(c);
@@ -873,6 +1403,7 @@ function listProcessReachable(rootPids, allProcesses = null) {
873
1403
  if (!roots.length) return new Set();
874
1404
  const procs = Array.isArray(allProcesses) ? allProcesses : listAllProcesses();
875
1405
  const childMap = _buildChildMap(procs);
1406
+ const byPid = new Map(procs.map(entry => [Number(entry.pid), entry]));
876
1407
  const seen = new Set();
877
1408
  const queue = [];
878
1409
  for (const r of roots) {
@@ -882,7 +1413,7 @@ function listProcessReachable(rootPids, allProcesses = null) {
882
1413
  const cur = queue.shift();
883
1414
  const children = childMap.get(cur) || [];
884
1415
  for (const c of children) {
885
- if (seen.has(c)) continue;
1416
+ if (seen.has(c) || !_isChronologicalChild(cur, c, byPid)) continue;
886
1417
  seen.add(c);
887
1418
  queue.push(c);
888
1419
  }
@@ -891,11 +1422,17 @@ function listProcessReachable(rootPids, allProcesses = null) {
891
1422
  }
892
1423
 
893
1424
  module.exports = {
1425
+ PROCESS_TERMINATION_SITES,
1426
+ terminateProcess,
894
1427
  normalizeKillPid,
895
1428
  unixChildPids,
896
1429
  killUnixProcessTree,
897
1430
  killGracefully,
898
1431
  killImmediate,
1432
+ killProcessOnly,
1433
+ commandLineRunsScript,
1434
+ listNodePidsMatchingScript,
1435
+ listNodePidsMatchingScripts,
899
1436
  killByPidImmediate,
900
1437
  killByPidsImmediate,
901
1438
  killByPidTreesImmediate,
@@ -907,10 +1444,15 @@ module.exports = {
907
1444
  _unixListProcesses,
908
1445
  _parseUnixProcessesWithStart, // exported for testing
909
1446
  listAllProcesses,
1447
+ captureProcessSnapshot,
1448
+ PROCESS_SNAPSHOT_UNAVAILABLE,
910
1449
  listAllProcessesAsync,
911
1450
  getProcessIdentity,
1451
+ inspectProcessIdentity,
912
1452
  isProcessIdentityCompatible,
1453
+ isProcessIdentityConclusiveMismatch,
913
1454
  isSameProcessIdentity,
1455
+ isRecordedAgentProcess,
914
1456
  findProcessesWithCwdInside,
915
1457
  findProcessesWithCwdInsideAsync,
916
1458
  _findWindowsProcessesWithCwdInside,
@@ -920,9 +1462,11 @@ module.exports = {
920
1462
  _parseCwdHolderLines,
921
1463
  findProcessCwdHolders,
922
1464
  isProcessCommandLineMatchingAgent,
1465
+ inspectProcessCommandLineForAgent,
923
1466
  _commandLineMatchesAgent, // exported for testing
924
1467
  _buildChildMap,
925
1468
  listProcessDescendants,
926
1469
  listProcessReachable,
927
1470
  isPidAlive,
1471
+ inspectPidLiveness,
928
1472
  };