oh-my-codex 0.20.2 → 0.20.3

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 (367) hide show
  1. package/Cargo.lock +7 -6
  2. package/Cargo.toml +1 -1
  3. package/README.md +26 -0
  4. package/crates/omx-runtime/Cargo.toml +1 -0
  5. package/crates/omx-runtime/src/main.rs +25 -2
  6. package/crates/omx-runtime/tests/execution.rs +219 -0
  7. package/crates/omx-runtime-core/src/dispatch.rs +96 -10
  8. package/crates/omx-runtime-core/src/engine.rs +839 -29
  9. package/crates/omx-runtime-core/src/lib.rs +8 -0
  10. package/dist/agents/__tests__/definitions.test.js +81 -0
  11. package/dist/agents/__tests__/definitions.test.js.map +1 -1
  12. package/dist/agents/__tests__/native-config.test.js +25 -0
  13. package/dist/agents/__tests__/native-config.test.js.map +1 -1
  14. package/dist/agents/native-config.d.ts +2 -1
  15. package/dist/agents/native-config.d.ts.map +1 -1
  16. package/dist/agents/native-config.js.map +1 -1
  17. package/dist/auth/hotswap.d.ts +6 -0
  18. package/dist/auth/hotswap.d.ts.map +1 -1
  19. package/dist/auth/hotswap.js +85 -15
  20. package/dist/auth/hotswap.js.map +1 -1
  21. package/dist/autopilot/__tests__/ralplan-gate.test.js +1 -1
  22. package/dist/autopilot/__tests__/ralplan-gate.test.js.map +1 -1
  23. package/dist/cli/__tests__/auth.test.js +60 -6
  24. package/dist/cli/__tests__/auth.test.js.map +1 -1
  25. package/dist/cli/__tests__/codex-plugin-layout.test.js +80 -0
  26. package/dist/cli/__tests__/codex-plugin-layout.test.js.map +1 -1
  27. package/dist/cli/__tests__/doctor-warning-copy.test.js +103 -1
  28. package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
  29. package/dist/cli/__tests__/exec.test.js +136 -0
  30. package/dist/cli/__tests__/exec.test.js.map +1 -1
  31. package/dist/cli/__tests__/index.test.js +258 -4
  32. package/dist/cli/__tests__/index.test.js.map +1 -1
  33. package/dist/cli/__tests__/launch-fallback.test.js +287 -1
  34. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  35. package/dist/cli/__tests__/mcp-parity.test.js +4 -1
  36. package/dist/cli/__tests__/mcp-parity.test.js.map +1 -1
  37. package/dist/cli/__tests__/native-hook-claim-journal.test.js +84 -6
  38. package/dist/cli/__tests__/native-hook-claim-journal.test.js.map +1 -1
  39. package/dist/cli/__tests__/ralph-goal-mode-contract.test.js +3 -3
  40. package/dist/cli/__tests__/ralph-goal-mode-contract.test.js.map +1 -1
  41. package/dist/cli/__tests__/ralph.test.js +3 -3
  42. package/dist/cli/__tests__/ralph.test.js.map +1 -1
  43. package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js +56 -171
  44. package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js.map +1 -1
  45. package/dist/cli/__tests__/ralplan.test.js +37 -111
  46. package/dist/cli/__tests__/ralplan.test.js.map +1 -1
  47. package/dist/cli/__tests__/resume.test.js +11 -10
  48. package/dist/cli/__tests__/resume.test.js.map +1 -1
  49. package/dist/cli/__tests__/session-search-help.test.js +25 -1
  50. package/dist/cli/__tests__/session-search-help.test.js.map +1 -1
  51. package/dist/cli/__tests__/setup-install-mode.test.js +42 -4
  52. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
  53. package/dist/cli/__tests__/team.test.js +105 -7
  54. package/dist/cli/__tests__/team.test.js.map +1 -1
  55. package/dist/cli/__tests__/uninstall.test.js +46 -0
  56. package/dist/cli/__tests__/uninstall.test.js.map +1 -1
  57. package/dist/cli/__tests__/windows-popup-loop-contract.test.js +2 -1
  58. package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
  59. package/dist/cli/doctor.d.ts +3 -0
  60. package/dist/cli/doctor.d.ts.map +1 -1
  61. package/dist/cli/doctor.js +15 -2
  62. package/dist/cli/doctor.js.map +1 -1
  63. package/dist/cli/index.d.ts +4 -1
  64. package/dist/cli/index.d.ts.map +1 -1
  65. package/dist/cli/index.js +138 -38
  66. package/dist/cli/index.js.map +1 -1
  67. package/dist/cli/mcp-parity.js +2 -2
  68. package/dist/cli/mcp-parity.js.map +1 -1
  69. package/dist/cli/native-hook-claim-journal.d.ts +16 -5
  70. package/dist/cli/native-hook-claim-journal.d.ts.map +1 -1
  71. package/dist/cli/native-hook-claim-journal.js +42 -29
  72. package/dist/cli/native-hook-claim-journal.js.map +1 -1
  73. package/dist/cli/ralph.js +2 -2
  74. package/dist/cli/ralph.js.map +1 -1
  75. package/dist/cli/ralplan.d.ts +4 -8
  76. package/dist/cli/ralplan.d.ts.map +1 -1
  77. package/dist/cli/ralplan.js +19 -112
  78. package/dist/cli/ralplan.js.map +1 -1
  79. package/dist/cli/session-search.d.ts +5 -0
  80. package/dist/cli/session-search.d.ts.map +1 -1
  81. package/dist/cli/session-search.js +87 -0
  82. package/dist/cli/session-search.js.map +1 -1
  83. package/dist/cli/setup.d.ts +5 -0
  84. package/dist/cli/setup.d.ts.map +1 -1
  85. package/dist/cli/setup.js +80 -31
  86. package/dist/cli/setup.js.map +1 -1
  87. package/dist/cli/uninstall.d.ts +9 -0
  88. package/dist/cli/uninstall.d.ts.map +1 -1
  89. package/dist/cli/uninstall.js +61 -19
  90. package/dist/cli/uninstall.js.map +1 -1
  91. package/dist/compat/__tests__/rust-runtime-compat.test.js +1 -1
  92. package/dist/config/__tests__/generator-idempotent.test.js +330 -1
  93. package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
  94. package/dist/config/__tests__/generator-notify.test.js +2 -2
  95. package/dist/config/__tests__/generator-notify.test.js.map +1 -1
  96. package/dist/config/__tests__/models.test.js +67 -3
  97. package/dist/config/__tests__/models.test.js.map +1 -1
  98. package/dist/config/generator.d.ts +4 -20
  99. package/dist/config/generator.d.ts.map +1 -1
  100. package/dist/config/generator.js +499 -119
  101. package/dist/config/generator.js.map +1 -1
  102. package/dist/config/models.d.ts +12 -2
  103. package/dist/config/models.d.ts.map +1 -1
  104. package/dist/config/models.js +15 -1
  105. package/dist/config/models.js.map +1 -1
  106. package/dist/hooks/__tests__/agents-overlay.test.js +2 -2
  107. package/dist/hooks/__tests__/agents-overlay.test.js.map +1 -1
  108. package/dist/hooks/__tests__/keyword-detector.test.js +4 -4
  109. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  110. package/dist/hooks/__tests__/notify-fallback-watcher.test.js +238 -543
  111. package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
  112. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js +38 -4
  113. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js.map +1 -1
  114. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js +96 -9
  115. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js.map +1 -1
  116. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js +531 -12
  117. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js.map +1 -1
  118. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +155 -23
  119. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  120. package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js +482 -32
  121. package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js.map +1 -1
  122. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js +68 -1
  123. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js.map +1 -1
  124. package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js +7 -1
  125. package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js.map +1 -1
  126. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js +43 -4
  127. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js.map +1 -1
  128. package/dist/hooks/__tests__/session.test.js +348 -2
  129. package/dist/hooks/__tests__/session.test.js.map +1 -1
  130. package/dist/hooks/agents-overlay.js +1 -1
  131. package/dist/hooks/session.d.ts +26 -2
  132. package/dist/hooks/session.d.ts.map +1 -1
  133. package/dist/hooks/session.js +169 -13
  134. package/dist/hooks/session.js.map +1 -1
  135. package/dist/leader/__tests__/contract.test.js +57 -7
  136. package/dist/leader/__tests__/contract.test.js.map +1 -1
  137. package/dist/leader/contract.d.ts +16 -0
  138. package/dist/leader/contract.d.ts.map +1 -1
  139. package/dist/leader/contract.js +79 -4
  140. package/dist/leader/contract.js.map +1 -1
  141. package/dist/mcp/__tests__/code-intel-server.test.js +31 -3
  142. package/dist/mcp/__tests__/code-intel-server.test.js.map +1 -1
  143. package/dist/mcp/__tests__/wiki-server.test.js +18 -0
  144. package/dist/mcp/__tests__/wiki-server.test.js.map +1 -1
  145. package/dist/mcp/code-intel-server.d.ts.map +1 -1
  146. package/dist/mcp/code-intel-server.js +13 -3
  147. package/dist/mcp/code-intel-server.js.map +1 -1
  148. package/dist/mcp/wiki-server.d.ts +11 -0
  149. package/dist/mcp/wiki-server.d.ts.map +1 -1
  150. package/dist/mcp/wiki-server.js +9 -2
  151. package/dist/mcp/wiki-server.js.map +1 -1
  152. package/dist/modes/__tests__/base-tmux-pane.test.js +4 -4
  153. package/dist/modes/__tests__/base-tmux-pane.test.js.map +1 -1
  154. package/dist/notifications/temp-contract.d.ts.map +1 -1
  155. package/dist/notifications/temp-contract.js +4 -0
  156. package/dist/notifications/temp-contract.js.map +1 -1
  157. package/dist/pipeline/__tests__/orchestrator.test.js +4 -4
  158. package/dist/pipeline/__tests__/orchestrator.test.js.map +1 -1
  159. package/dist/pipeline/__tests__/stages.test.js +8 -8
  160. package/dist/pipeline/__tests__/stages.test.js.map +1 -1
  161. package/dist/ralplan/__tests__/consensus-gate.test.js +134 -10
  162. package/dist/ralplan/__tests__/consensus-gate.test.js.map +1 -1
  163. package/dist/ralplan/__tests__/documented-leader-preflight.test.d.ts +2 -0
  164. package/dist/ralplan/__tests__/documented-leader-preflight.test.d.ts.map +1 -0
  165. package/dist/ralplan/__tests__/documented-leader-preflight.test.js +46 -0
  166. package/dist/ralplan/__tests__/documented-leader-preflight.test.js.map +1 -0
  167. package/dist/ralplan/__tests__/runtime.test.js +8 -4
  168. package/dist/ralplan/__tests__/runtime.test.js.map +1 -1
  169. package/dist/ralplan/consensus-gate.d.ts.map +1 -1
  170. package/dist/ralplan/consensus-gate.js +58 -50
  171. package/dist/ralplan/consensus-gate.js.map +1 -1
  172. package/dist/ralplan/documented-leader-preflight.d.ts +34 -0
  173. package/dist/ralplan/documented-leader-preflight.d.ts.map +1 -0
  174. package/dist/ralplan/documented-leader-preflight.js +58 -0
  175. package/dist/ralplan/documented-leader-preflight.js.map +1 -0
  176. package/dist/ralplan/runtime.d.ts +1 -0
  177. package/dist/ralplan/runtime.d.ts.map +1 -1
  178. package/dist/ralplan/runtime.js +11 -7
  179. package/dist/ralplan/runtime.js.map +1 -1
  180. package/dist/runtime/__tests__/bridge.test.js +42 -1
  181. package/dist/runtime/__tests__/bridge.test.js.map +1 -1
  182. package/dist/runtime/bridge.d.ts +12 -0
  183. package/dist/runtime/bridge.d.ts.map +1 -1
  184. package/dist/runtime/bridge.js +52 -1
  185. package/dist/runtime/bridge.js.map +1 -1
  186. package/dist/scripts/__tests__/codex-native-hook.test.js +2620 -487
  187. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  188. package/dist/scripts/__tests__/reasoning-artifact-contract.test.d.ts +2 -0
  189. package/dist/scripts/__tests__/reasoning-artifact-contract.test.d.ts.map +1 -0
  190. package/dist/scripts/__tests__/reasoning-artifact-contract.test.js +108 -0
  191. package/dist/scripts/__tests__/reasoning-artifact-contract.test.js.map +1 -0
  192. package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js +46 -173
  193. package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js.map +1 -1
  194. package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js +39 -159
  195. package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js.map +1 -1
  196. package/dist/scripts/__tests__/smoke-packed-install.test.js +254 -2
  197. package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
  198. package/dist/scripts/codex-native-hook.d.ts +4 -1
  199. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  200. package/dist/scripts/codex-native-hook.js +11013 -1153
  201. package/dist/scripts/codex-native-hook.js.map +1 -1
  202. package/dist/scripts/notify-fallback-watcher.js +82 -108
  203. package/dist/scripts/notify-fallback-watcher.js.map +1 -1
  204. package/dist/scripts/notify-hook/auto-nudge.d.ts.map +1 -1
  205. package/dist/scripts/notify-hook/auto-nudge.js +74 -3
  206. package/dist/scripts/notify-hook/auto-nudge.js.map +1 -1
  207. package/dist/scripts/notify-hook/team-dispatch.d.ts +9 -0
  208. package/dist/scripts/notify-hook/team-dispatch.d.ts.map +1 -1
  209. package/dist/scripts/notify-hook/team-dispatch.js +234 -79
  210. package/dist/scripts/notify-hook/team-dispatch.js.map +1 -1
  211. package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
  212. package/dist/scripts/notify-hook/team-leader-nudge.js +39 -13
  213. package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
  214. package/dist/scripts/notify-hook/team-tmux-guard.d.ts +12 -4
  215. package/dist/scripts/notify-hook/team-tmux-guard.d.ts.map +1 -1
  216. package/dist/scripts/notify-hook/team-tmux-guard.js +365 -44
  217. package/dist/scripts/notify-hook/team-tmux-guard.js.map +1 -1
  218. package/dist/scripts/notify-hook/team-worker-stop.d.ts.map +1 -1
  219. package/dist/scripts/notify-hook/team-worker-stop.js +19 -18
  220. package/dist/scripts/notify-hook/team-worker-stop.js.map +1 -1
  221. package/dist/scripts/notify-hook/team-worker.d.ts +3 -0
  222. package/dist/scripts/notify-hook/team-worker.d.ts.map +1 -1
  223. package/dist/scripts/notify-hook/team-worker.js +37 -28
  224. package/dist/scripts/notify-hook/team-worker.js.map +1 -1
  225. package/dist/scripts/notify-hook/tmux-injection.d.ts.map +1 -1
  226. package/dist/scripts/notify-hook/tmux-injection.js +28 -2
  227. package/dist/scripts/notify-hook/tmux-injection.js.map +1 -1
  228. package/dist/scripts/notify-hook.js +1 -0
  229. package/dist/scripts/notify-hook.js.map +1 -1
  230. package/dist/scripts/smoke-packed-install.d.ts +48 -3
  231. package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
  232. package/dist/scripts/smoke-packed-install.js +2796 -13
  233. package/dist/scripts/smoke-packed-install.js.map +1 -1
  234. package/dist/state/__tests__/mode-state-context.test.js +54 -0
  235. package/dist/state/__tests__/mode-state-context.test.js.map +1 -1
  236. package/dist/state/__tests__/operations.test.js +2 -0
  237. package/dist/state/__tests__/operations.test.js.map +1 -1
  238. package/dist/state/mode-state-context.d.ts +5 -0
  239. package/dist/state/mode-state-context.d.ts.map +1 -1
  240. package/dist/state/mode-state-context.js +59 -0
  241. package/dist/state/mode-state-context.js.map +1 -1
  242. package/dist/subagents/__tests__/leader-bootstrap-3181.test.js +24 -245
  243. package/dist/subagents/__tests__/leader-bootstrap-3181.test.js.map +1 -1
  244. package/dist/subagents/tracker.d.ts +1 -82
  245. package/dist/subagents/tracker.d.ts.map +1 -1
  246. package/dist/subagents/tracker.js +3 -275
  247. package/dist/subagents/tracker.js.map +1 -1
  248. package/dist/team/__tests__/api-interop.test.js +91 -1
  249. package/dist/team/__tests__/api-interop.test.js.map +1 -1
  250. package/dist/team/__tests__/delivery-e2e-smoke.test.js +52 -10
  251. package/dist/team/__tests__/delivery-e2e-smoke.test.js.map +1 -1
  252. package/dist/team/__tests__/idle-nudge.test.js +45 -15
  253. package/dist/team/__tests__/idle-nudge.test.js.map +1 -1
  254. package/dist/team/__tests__/mcp-comm.test.js +93 -27
  255. package/dist/team/__tests__/mcp-comm.test.js.map +1 -1
  256. package/dist/team/__tests__/model-contract.test.js +110 -3
  257. package/dist/team/__tests__/model-contract.test.js.map +1 -1
  258. package/dist/team/__tests__/runtime-cli.test.js +9 -0
  259. package/dist/team/__tests__/runtime-cli.test.js.map +1 -1
  260. package/dist/team/__tests__/runtime.test.js +3204 -335
  261. package/dist/team/__tests__/runtime.test.js.map +1 -1
  262. package/dist/team/__tests__/scaling.test.js +2359 -142
  263. package/dist/team/__tests__/scaling.test.js.map +1 -1
  264. package/dist/team/__tests__/state.test.js +804 -36
  265. package/dist/team/__tests__/state.test.js.map +1 -1
  266. package/dist/team/__tests__/team-ops-contract.test.js +6 -0
  267. package/dist/team/__tests__/team-ops-contract.test.js.map +1 -1
  268. package/dist/team/__tests__/tmux-session.test.js +3049 -784
  269. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  270. package/dist/team/__tests__/worker-runtime-identity.test.js +38 -2
  271. package/dist/team/__tests__/worker-runtime-identity.test.js.map +1 -1
  272. package/dist/team/__tests__/worktree.test.js +6 -0
  273. package/dist/team/__tests__/worktree.test.js.map +1 -1
  274. package/dist/team/api-interop.d.ts.map +1 -1
  275. package/dist/team/api-interop.js +15 -9
  276. package/dist/team/api-interop.js.map +1 -1
  277. package/dist/team/exact-pane.d.ts +23 -0
  278. package/dist/team/exact-pane.d.ts.map +1 -0
  279. package/dist/team/exact-pane.js +133 -0
  280. package/dist/team/exact-pane.js.map +1 -0
  281. package/dist/team/idle-nudge.d.ts +12 -1
  282. package/dist/team/idle-nudge.d.ts.map +1 -1
  283. package/dist/team/idle-nudge.js +41 -14
  284. package/dist/team/idle-nudge.js.map +1 -1
  285. package/dist/team/model-contract.d.ts +2 -5
  286. package/dist/team/model-contract.d.ts.map +1 -1
  287. package/dist/team/model-contract.js +59 -17
  288. package/dist/team/model-contract.js.map +1 -1
  289. package/dist/team/runtime.d.ts +1 -0
  290. package/dist/team/runtime.d.ts.map +1 -1
  291. package/dist/team/runtime.js +1244 -180
  292. package/dist/team/runtime.js.map +1 -1
  293. package/dist/team/scaling.d.ts +13 -1
  294. package/dist/team/scaling.d.ts.map +1 -1
  295. package/dist/team/scaling.js +1430 -384
  296. package/dist/team/scaling.js.map +1 -1
  297. package/dist/team/state/dispatch.d.ts.map +1 -1
  298. package/dist/team/state/dispatch.js +26 -8
  299. package/dist/team/state/dispatch.js.map +1 -1
  300. package/dist/team/state/locks.d.ts.map +1 -1
  301. package/dist/team/state/locks.js +61 -13
  302. package/dist/team/state/locks.js.map +1 -1
  303. package/dist/team/state/tasks.d.ts.map +1 -1
  304. package/dist/team/state/tasks.js +4 -3
  305. package/dist/team/state/tasks.js.map +1 -1
  306. package/dist/team/state.d.ts +67 -1
  307. package/dist/team/state.d.ts.map +1 -1
  308. package/dist/team/state.js +636 -129
  309. package/dist/team/state.js.map +1 -1
  310. package/dist/team/team-ops.d.ts +5 -0
  311. package/dist/team/team-ops.d.ts.map +1 -1
  312. package/dist/team/team-ops.js +4 -0
  313. package/dist/team/team-ops.js.map +1 -1
  314. package/dist/team/tmux-session.d.ts +123 -23
  315. package/dist/team/tmux-session.d.ts.map +1 -1
  316. package/dist/team/tmux-session.js +1250 -291
  317. package/dist/team/tmux-session.js.map +1 -1
  318. package/dist/team/worktree.d.ts.map +1 -1
  319. package/dist/team/worktree.js +4 -0
  320. package/dist/team/worktree.js.map +1 -1
  321. package/dist/utils/__tests__/file-durability.test.d.ts +2 -0
  322. package/dist/utils/__tests__/file-durability.test.d.ts.map +1 -0
  323. package/dist/utils/__tests__/file-durability.test.js +57 -0
  324. package/dist/utils/__tests__/file-durability.test.js.map +1 -0
  325. package/dist/utils/file-durability.d.ts +15 -0
  326. package/dist/utils/file-durability.d.ts.map +1 -0
  327. package/dist/utils/file-durability.js +37 -0
  328. package/dist/utils/file-durability.js.map +1 -0
  329. package/dist/verification/__tests__/dev-merge-issue-close-workflow.test.js +3 -0
  330. package/dist/verification/__tests__/dev-merge-issue-close-workflow.test.js.map +1 -1
  331. package/dist/wiki/lint.d.ts +4 -1
  332. package/dist/wiki/lint.d.ts.map +1 -1
  333. package/dist/wiki/lint.js +3 -2
  334. package/dist/wiki/lint.js.map +1 -1
  335. package/package.json +1 -1
  336. package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
  337. package/plugins/oh-my-codex/hooks/codex-native-hook.mjs +24 -0
  338. package/plugins/oh-my-codex/skills/ralph/SKILL.md +6 -4
  339. package/plugins/oh-my-codex/skills/ralplan/SKILL.md +3 -2
  340. package/plugins/oh-my-codex/skills/team/SKILL.md +13 -9
  341. package/skills/ralph/SKILL.md +6 -4
  342. package/skills/ralplan/SKILL.md +3 -2
  343. package/skills/team/SKILL.md +13 -9
  344. package/src/scripts/__tests__/codex-native-hook.test.ts +2826 -497
  345. package/src/scripts/__tests__/reasoning-artifact-contract.test.ts +129 -0
  346. package/src/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.ts +48 -185
  347. package/src/scripts/__tests__/role-intent-durable-recovery-3181.test.ts +44 -177
  348. package/src/scripts/__tests__/smoke-packed-install.test.ts +320 -2
  349. package/src/scripts/codex-native-hook.ts +10715 -1069
  350. package/src/scripts/notify-fallback-watcher.ts +84 -115
  351. package/src/scripts/notify-hook/auto-nudge.ts +71 -3
  352. package/src/scripts/notify-hook/team-dispatch.ts +242 -75
  353. package/src/scripts/notify-hook/team-leader-nudge.ts +40 -18
  354. package/src/scripts/notify-hook/team-tmux-guard.ts +366 -39
  355. package/src/scripts/notify-hook/team-worker-stop.ts +20 -16
  356. package/src/scripts/notify-hook/team-worker.ts +39 -26
  357. package/src/scripts/notify-hook/tmux-injection.ts +28 -2
  358. package/src/scripts/notify-hook.ts +1 -0
  359. package/src/scripts/smoke-packed-install.ts +3196 -14
  360. package/dist/subagents/__tests__/adapted-role-binding.test.d.ts +0 -2
  361. package/dist/subagents/__tests__/adapted-role-binding.test.d.ts.map +0 -1
  362. package/dist/subagents/__tests__/adapted-role-binding.test.js +0 -988
  363. package/dist/subagents/__tests__/adapted-role-binding.test.js.map +0 -1
  364. package/dist/subagents/adapted-role-binding.d.ts +0 -17
  365. package/dist/subagents/adapted-role-binding.d.ts.map +0 -1
  366. package/dist/subagents/adapted-role-binding.js +0 -75
  367. package/dist/subagents/adapted-role-binding.js.map +0 -1
@@ -1,10 +1,14 @@
1
+ import { createHash } from 'crypto';
1
2
  import { join, resolve, dirname } from 'path';
2
- import { existsSync, appendFileSync, mkdirSync } from 'fs';
3
+ import { existsSync, appendFileSync, mkdirSync, readFileSync } from 'fs';
3
4
  import { mkdir, readdir, readFile, writeFile } from 'fs/promises';
4
5
  import { performance } from 'perf_hooks';
5
6
  import { spawn, spawnSync } from 'child_process';
6
- import { sanitizeTeamName, isTmuxAvailable, hasCurrentTmuxClientContext, createTeamSession, buildWorkerProcessLaunchSpec, scrubTeamWorkerHudOwnershipEnv, resolveTeamWorkerCli, resolveTeamWorkerCliForResolvedLaunchArgs, assertTeamWorkerCliPolicyCompatibility, resolveTeamWorkerLaunchMode, waitForWorkerReady, waitForWorkerReadyAsync, dismissTrustPromptIfPresent, evaluateStartupDirectTriggerSafety, sendToWorker, sendToWorkerStdin, isWorkerAlive, isWorkerPaneOpen, getWorkerPanePid, killWorkerByPaneIdAsync, paneHasOmxInstanceTag, readPaneTeamOwnerTagResult, restoreStandaloneHudPane, teardownWorkerPanes, unregisterResizeHook, destroyTeamSession, listPaneIds, listTeamSessions, resolveSharedSessionShutdownTopology, } from './tmux-session.js';
7
- import { teamInit as initTeamState, DEFAULT_MAX_WORKERS, teamReadConfig as readTeamConfig, teamWriteWorkerIdentity as writeWorkerIdentity, teamReadWorkerHeartbeat as readWorkerHeartbeat, teamReadWorkerStatus as readWorkerStatus, teamWriteWorkerInbox as writeWorkerInbox, teamCreateTask as createStateTask, teamReadTask as readTask, teamListTasks as listTasks, teamUpdateTask as updateTask, teamReadManifest as readTeamManifestV2, teamNormalizeGovernance as normalizeTeamGovernance, teamNormalizePolicy as normalizeTeamPolicy, teamClaimTask as claimTask, teamReleaseTaskClaim as releaseTaskClaim, teamReclaimExpiredTaskClaim as reclaimExpiredTaskClaim, teamAppendEvent as appendTeamEvent, teamReadTaskApproval as readTaskApproval, teamListMailbox as listMailboxMessages, teamMarkMessageDelivered as markMessageDelivered, teamMarkMessageNotified as markMessageNotified, teamEnqueueDispatchRequest as enqueueDispatchRequest, teamMarkDispatchRequestNotified as markDispatchRequestNotified, teamTransitionDispatchRequest as transitionDispatchRequest, teamReadDispatchRequest as readDispatchRequest, teamCleanup as cleanupTeamState, teamSaveConfig as saveTeamConfig, teamWriteShutdownRequest as writeShutdownRequest, teamReadShutdownAck as readShutdownAck, teamReadMonitorSnapshot as readMonitorSnapshot, teamWriteMonitorSnapshot as writeMonitorSnapshot, teamReadPhase as readTeamPhaseState, teamWritePhase as writeTeamPhaseState, teamWriteWorkerStatus as writeWorkerStatus, writeAtomic, } from './team-ops.js';
7
+ import { sanitizeTeamName, isTmuxAvailable, hasCurrentTmuxClientContext, createTeamSession, CreateTeamSessionPartialError, buildWorkerProcessLaunchSpec, scrubTeamWorkerHudOwnershipEnv, resolveTeamWorkerCli, resolveTeamWorkerCliForResolvedLaunchArgs, assertTeamWorkerCliPolicyCompatibility, resolveTeamWorkerLaunchMode, waitForWorkerReady, waitForWorkerReadyAsync, dismissTrustPromptIfPresent, evaluateStartupDirectTriggerSafety, sendToWorker, sendToWorkerStdin, isWorkerAlive, isWorkerPaneOpen, paneHasOmxInstanceTag, readPaneTeamOwnerTagResult, restoreStandaloneHudPane, finalizeRestoredHudCleanupDebtSync, reconcileRestoredHudCleanupDebtSync, teardownWorkerPanes, unregisterResizeHook, queryDetachedTeamSession, requestDetachedTeamSessionDestroy, queryDetachedSessionLeaderBinding, listPaneIds, listTeamSessions, resolveSharedSessionShutdownTopology, } from './tmux-session.js';
8
+ import { readExactPaneProofSync, readExactPaneProofsSync } from './exact-pane.js';
9
+ import { reconcileScaleDownCleanupDebt } from './scaling.js';
10
+ import { teamInit as initTeamState, DEFAULT_MAX_WORKERS, teamReadConfig as readTeamConfig, teamWriteWorkerIdentity as writeWorkerIdentity, teamReadWorkerHeartbeat as readWorkerHeartbeat, teamReadWorkerStatus as readWorkerStatus, teamWriteWorkerInbox as writeWorkerInbox, teamCreateTask as createStateTask, teamReadTask as readTask, teamListTasks as listTasks, teamUpdateTask as updateTask, teamReadManifest as readTeamManifestV2, teamNormalizeGovernance as normalizeTeamGovernance, teamNormalizePolicy as normalizeTeamPolicy, teamClaimTask as claimTask, teamReleaseTaskClaim as releaseTaskClaim, teamReclaimExpiredTaskClaim as reclaimExpiredTaskClaim, teamAppendEvent as appendTeamEvent, teamReadTaskApproval as readTaskApproval, teamListMailbox as listMailboxMessages, teamMarkMessageDelivered as markMessageDelivered, teamMarkMessageNotified as markMessageNotified, teamEnqueueDispatchRequest as enqueueDispatchRequest, teamMarkDispatchRequestNotified as markDispatchRequestNotified, teamTransitionDispatchRequest as transitionDispatchRequest, teamReadDispatchRequest as readDispatchRequest, teamCleanup as cleanupTeamState, teamSaveConfig as saveTeamConfig, teamWriteShutdownRequest as writeShutdownRequest, teamReadShutdownAck as readShutdownAck, teamReadMonitorSnapshot as readMonitorSnapshot, teamWriteMonitorSnapshot as writeMonitorSnapshot, teamReadPhase as readTeamPhaseState, teamWritePhase as writeTeamPhaseState, teamWriteWorkerStatus as writeWorkerStatus, writeAtomic, teamWithTaskMembershipBarrier as withTeamTaskMembershipBarrier, teamRemoveDurableFile as removeDurableFile, } from './team-ops.js';
11
+ import { commitTeamMembershipTaskTransaction, writeTeamManifestV2 as writeTeamManifestV2State } from './state.js';
8
12
  import { queueInboxInstruction, queueDirectMailboxMessage, queueBroadcastMailboxMessage, waitForDispatchReceipt, } from './mcp-comm.js';
9
13
  import { appendTeamDeliveryLogForCwd } from './delivery-log.js';
10
14
  import { remapRepoAwareDecompositionMetadataToCreatedTasks, } from './repo-aware-decomposition.js';
@@ -109,6 +113,40 @@ async function syncTeamModeStateOnShutdown(teamName, cwd, leaderSessionId) {
109
113
  await syncExactTeamModeStateOnShutdown(teamName, cwd, normalizedLeaderSessionId);
110
114
  }
111
115
  }
116
+ async function mutateShutdownConfig(config, cwd, mutate) {
117
+ return await withTeamTaskMembershipBarrier(config.name, cwd, async () => {
118
+ const current = await readTeamConfig(config.name, cwd);
119
+ if (!current)
120
+ throw new Error(`shutdown_config_missing:${config.name}`);
121
+ const baseGeneration = current.config_generation ?? 0;
122
+ const teamRoot = join(current.team_state_root ?? resolveCanonicalTeamStateRoot(cwd), 'team', current.name);
123
+ const [currentConfigBytes, currentManifestBytes] = await Promise.all([
124
+ readFile(join(teamRoot, 'config.json'), 'utf8'),
125
+ existsSync(join(teamRoot, 'manifest.v2.json')) ? readFile(join(teamRoot, 'manifest.v2.json'), 'utf8') : null,
126
+ ]);
127
+ mutate(current);
128
+ const currentManifest = currentManifestBytes === null ? null : JSON.parse(currentManifestBytes);
129
+ await commitTeamMembershipTaskTransaction(current.name, cwd, {
130
+ baseGeneration,
131
+ tasks: [],
132
+ config: { oldBytes: currentConfigBytes, newBytes: JSON.stringify(current, null, 2) },
133
+ manifest: {
134
+ oldBytes: currentManifestBytes,
135
+ newBytes: currentManifest === null ? null : JSON.stringify({
136
+ ...currentManifest,
137
+ hud_pane_id: current.hud_pane_id,
138
+ hud_pane_pid: current.hud_pane_pid,
139
+ resize_hook_name: current.resize_hook_name,
140
+ resize_hook_target: current.resize_hook_target,
141
+ }, null, 2),
142
+ },
143
+ });
144
+ const committed = await readTeamConfig(current.name, cwd);
145
+ if (!committed)
146
+ throw new Error(`shutdown_config_missing_after_commit:${current.name}`);
147
+ return committed;
148
+ });
149
+ }
112
150
  async function assertTeamStartupIsNonDestructive(teamName, cwd, leaderSessionId) {
113
151
  const activeTeams = await findActiveTeams(cwd, leaderSessionId);
114
152
  if (activeTeams.length > 0) {
@@ -131,16 +169,44 @@ async function assertTeamStartupIsNonDestructive(teamName, cwd, leaderSessionId)
131
169
  }
132
170
  export function applyCreatedInteractiveSessionToConfig(config, createdSession, workerPaneIds) {
133
171
  config.tmux_session = createdSession.name;
172
+ config.tmux_session_id = createdSession.tmuxSessionId;
173
+ config.tmux_session_created = createdSession.tmuxSessionCreated;
134
174
  config.leader_pane_id = createdSession.leaderPaneId;
135
175
  config.hud_pane_id = createdSession.hudPaneId;
176
+ config.leader_pane_pid = typeof createdSession.leaderPanePid === 'number' && Number.isSafeInteger(createdSession.leaderPanePid) && createdSession.leaderPanePid > 0
177
+ ? createdSession.leaderPanePid
178
+ : null;
179
+ config.hud_pane_pid = typeof createdSession.hudPanePid === 'number' && Number.isSafeInteger(createdSession.hudPanePid) && createdSession.hudPanePid > 0
180
+ ? createdSession.hudPanePid
181
+ : null;
136
182
  config.tmux_pane_owner_id = createdSession.teamPaneOwnerId;
137
183
  config.resize_hook_name = createdSession.resizeHookName;
138
184
  config.resize_hook_target = createdSession.resizeHookTarget;
185
+ const paneIdsByIndex = createdSession.workerPaneIdsByIndex;
186
+ const panePidsByIndex = createdSession.workerPanePidsByIndex;
187
+ if (paneIdsByIndex) {
188
+ for (let i = 0; i < paneIdsByIndex.length; i++) {
189
+ const paneId = paneIdsByIndex[i];
190
+ if (!paneId)
191
+ continue;
192
+ workerPaneIds[i] = paneId;
193
+ if (config.workers[i]) {
194
+ config.workers[i].pane_id = paneId;
195
+ const panePid = panePidsByIndex?.[i];
196
+ if (typeof panePid === 'number' && Number.isSafeInteger(panePid) && panePid > 0)
197
+ config.workers[i].pid = panePid;
198
+ }
199
+ }
200
+ return;
201
+ }
139
202
  for (let i = 0; i < createdSession.workerPaneIds.length; i++) {
140
203
  const paneId = createdSession.workerPaneIds[i];
141
204
  workerPaneIds[i] = paneId;
142
205
  if (config.workers[i]) {
143
206
  config.workers[i].pane_id = paneId;
207
+ const panePid = createdSession.workerPanePidsByIndex?.[i];
208
+ if (typeof panePid === 'number' && Number.isSafeInteger(panePid) && panePid > 0)
209
+ config.workers[i].pid = panePid;
144
210
  }
145
211
  }
146
212
  }
@@ -167,25 +233,41 @@ function collectShutdownPaneIds(params) {
167
233
  }
168
234
  return [...paneIds];
169
235
  }
170
- function filterSharedSessionShutdownWorkerPaneIdsByOwner(paneIds, teamPaneOwnerId, legacyPersistedWorkerPaneIds = new Set(), onOwnerReadError) {
236
+ function collectAuthorizedSharedSessionWorkerPaneIds(paneIds, teamPaneOwnerId, canonicalWorkerPaneIds, initiallyTaggedWorkerPaneIds, authorizedTaggedWorkerPaneIds) {
171
237
  const expectedOwnerId = teamPaneOwnerId.trim();
172
- if (!expectedOwnerId)
173
- return [];
174
- return paneIds.filter((paneId) => {
175
- const actualOwnerId = readPaneTeamOwnerTagResult(paneId);
176
- if (actualOwnerId.status === 'value')
177
- return actualOwnerId.value === expectedOwnerId;
178
- if (actualOwnerId.status === 'missing') {
179
- // Legacy, already-running Team panes may not have @omx_team_pane_owner_id.
180
- // Keep that compatibility path explicitly bounded to panes that are both
181
- // live worker-command candidates and persisted in this team's state. This
182
- // preserves old Team cleanup without letting arbitrary worker-looking
183
- // panes become kill candidates merely because the owner tag is absent.
184
- return legacyPersistedWorkerPaneIds.has(paneId);
185
- }
186
- onOwnerReadError?.(paneId, actualOwnerId.error);
187
- return false;
188
- });
238
+ if (!expectedOwnerId && paneIds.length > 0) {
239
+ throw new Error('shutdown_shared_session_worker_owner_unavailable:missing_team_owner_id');
240
+ }
241
+ const authorized = new Set();
242
+ for (const paneId of paneIds) {
243
+ const owner = readPaneTeamOwnerTagResult(paneId);
244
+ if (initiallyTaggedWorkerPaneIds?.has(paneId)
245
+ && (owner.status !== 'value' || owner.value !== expectedOwnerId)) {
246
+ throw new Error(`shutdown_shared_session_worker_owner_changed:${paneId}`);
247
+ }
248
+ if (owner.status === 'error') {
249
+ // A read failure is authority ambiguity only for an explicit persisted
250
+ // worker that topology independently identifies as a worker candidate.
251
+ if (canonicalWorkerPaneIds.has(paneId)) {
252
+ throw new Error(`shutdown_shared_session_worker_owner_unavailable:${paneId}:${owner.error}`);
253
+ }
254
+ continue;
255
+ }
256
+ if (owner.status === 'value') {
257
+ if (owner.value === expectedOwnerId) {
258
+ authorized.add(paneId);
259
+ authorizedTaggedWorkerPaneIds?.add(paneId);
260
+ }
261
+ else if (canonicalWorkerPaneIds.has(paneId)) {
262
+ throw new Error(`shutdown_shared_session_worker_owner_changed:${paneId}`);
263
+ }
264
+ continue;
265
+ }
266
+ if (canonicalWorkerPaneIds.has(paneId)) {
267
+ throw new Error(`shutdown_shared_session_worker_owner_changed:${paneId}`);
268
+ }
269
+ }
270
+ return [...authorized];
189
271
  }
190
272
  function isSharedSessionHudPaneReclaimable(params) {
191
273
  const { paneId, persistedHudPaneId, leaderOwnedHudPaneIds, teamPaneOwnerId, onOwnerReadError } = params;
@@ -227,6 +309,209 @@ export function shouldPrekillInteractiveShutdownProcessTrees(sessionName) {
227
309
  // teardown alone is insufficient.
228
310
  return true;
229
311
  }
312
+ function canonicalDetachedStateRoot(config, cwd) {
313
+ return resolve(config?.team_state_root ?? resolveCanonicalTeamStateRoot(cwd));
314
+ }
315
+ function detachedSessionDestroyReceiptPath(teamName, cwd, config) {
316
+ return join(canonicalDetachedStateRoot(config ?? null, cwd), 'team', teamName, '.detached-session-destroy-receipt.json');
317
+ }
318
+ function detachedConfigIdentity(teamName, cwd, config) {
319
+ const ownerId = typeof config.tmux_pane_owner_id === 'string' ? config.tmux_pane_owner_id.trim() : '';
320
+ const paneId = typeof config.leader_pane_id === 'string' ? config.leader_pane_id.trim() : '';
321
+ const panePid = config.leader_pane_pid;
322
+ if (config.name !== teamName || !config.created_at || !config.tmux_session || !ownerId || !/^%[0-9]+$/.test(paneId)
323
+ || typeof panePid !== 'number' || !Number.isSafeInteger(panePid) || panePid <= 0)
324
+ return null;
325
+ // Bind the durable bytes of both canonical files, not only authorization
326
+ // fields. A worker/membership mutation invalidates a published receipt.
327
+ try {
328
+ const teamRoot = join(canonicalDetachedStateRoot(config, cwd), 'team', teamName);
329
+ const configBytes = readFileSync(join(teamRoot, 'config.json'), 'utf8');
330
+ const manifestBytes = readFileSync(join(teamRoot, 'manifest.v2.json'), 'utf8');
331
+ const parsedConfig = JSON.parse(configBytes);
332
+ const parsedManifest = JSON.parse(manifestBytes);
333
+ if (!Number.isSafeInteger(parsedConfig.config_generation)
334
+ || !Number.isSafeInteger(parsedManifest.config_generation)
335
+ || parsedConfig.config_generation !== parsedManifest.config_generation)
336
+ return null;
337
+ return createHash('sha256').update(JSON.stringify({
338
+ version: 2,
339
+ team_name: teamName,
340
+ state_root: canonicalDetachedStateRoot(config, cwd),
341
+ config_bytes: configBytes,
342
+ manifest_bytes: manifestBytes,
343
+ })).digest('hex');
344
+ }
345
+ catch {
346
+ return null;
347
+ }
348
+ }
349
+ /** Deterministic test seam for mutations after durable intent publication. */
350
+ let detachedSessionDestroyAfterJournalHook = null;
351
+ export function setDetachedSessionDestroyAfterJournalHookForTest(hook) {
352
+ detachedSessionDestroyAfterJournalHook = hook;
353
+ }
354
+ function isDetachedSessionDestroyReceipt(value) {
355
+ if (!value || typeof value !== 'object')
356
+ return false;
357
+ const receipt = value;
358
+ return receipt.schema_version === 2 && receipt.schema === 'omx.detached_session_destroy.v2'
359
+ && receipt.operation === 'detached_session_destroy' && (receipt.status === 'intent' || receipt.status === 'accepted')
360
+ && typeof receipt.team_name === 'string' && receipt.team_name.length > 0
361
+ && typeof receipt.state_root === 'string' && receipt.state_root.length > 0 && resolve(receipt.state_root) === receipt.state_root
362
+ && receipt.config_identity_version === 2 && typeof receipt.config_identity_digest === 'string' && /^[a-f0-9]{64}$/.test(receipt.config_identity_digest)
363
+ && typeof receipt.session_name === 'string' && receipt.session_name.length > 0 && !receipt.session_name.includes(':')
364
+ && typeof receipt.session_id === 'string' && receipt.session_id.length > 0 && !/\s/.test(receipt.session_id)
365
+ && typeof receipt.session_created === 'string' && /^[0-9]+$/.test(receipt.session_created)
366
+ && typeof receipt.leader_pane_id === 'string' && /^%[0-9]+$/.test(receipt.leader_pane_id)
367
+ && typeof receipt.leader_pane_pid === 'number' && Number.isSafeInteger(receipt.leader_pane_pid) && receipt.leader_pane_pid > 0
368
+ && typeof receipt.owner_id === 'string' && receipt.owner_id.length > 0;
369
+ }
370
+ async function readDetachedSessionDestroyReceipt(teamName, cwd, config) {
371
+ const path = detachedSessionDestroyReceiptPath(teamName, cwd, config);
372
+ if (!existsSync(path))
373
+ return null;
374
+ try {
375
+ const parsed = JSON.parse(await readFile(path, 'utf-8'));
376
+ if (!isDetachedSessionDestroyReceipt(parsed) || parsed.team_name !== teamName || parsed.state_root !== canonicalDetachedStateRoot(config, cwd))
377
+ throw new Error('invalid receipt');
378
+ return parsed;
379
+ }
380
+ catch {
381
+ throw new Error(`detached_session_destroy_receipt_malformed:${config?.tmux_session || 'missing_session'}`);
382
+ }
383
+ }
384
+ async function writeDetachedSessionDestroyReceipt(teamName, cwd, config, receipt) {
385
+ await writeAtomic(detachedSessionDestroyReceiptPath(teamName, cwd, config), `${JSON.stringify(receipt, null, 2)}\n`);
386
+ }
387
+ function validateDetachedReceiptConfig(teamName, cwd, config, receipt) {
388
+ if (receipt.team_name !== teamName || receipt.state_root !== canonicalDetachedStateRoot(config, cwd)
389
+ || receipt.config_identity_version !== 2 || detachedConfigIdentity(teamName, cwd, config) !== receipt.config_identity_digest
390
+ || config.tmux_session !== receipt.session_name || config.tmux_session_id !== receipt.session_id
391
+ || config.tmux_session_created !== receipt.session_created || config.leader_pane_id !== receipt.leader_pane_id
392
+ || config.leader_pane_pid !== receipt.leader_pane_pid || config.tmux_pane_owner_id !== receipt.owner_id) {
393
+ throw new Error(`detached_session_destroy_receipt_config_mismatch:${receipt.session_name}`);
394
+ }
395
+ }
396
+ function assertDetachedSessionDestroyAuthority(receipt) {
397
+ const expected = { sessionId: receipt.session_id, sessionCreated: receipt.session_created };
398
+ const query = queryDetachedTeamSession(receipt.session_name, expected);
399
+ if (query.status !== 'exact'
400
+ || !queryDetachedSessionLeaderBinding(receipt.leader_pane_id, receipt.leader_pane_pid, receipt.session_name, expected)) {
401
+ throw new Error(`detached_session_destroy_authorization_unavailable:${receipt.session_name}`);
402
+ }
403
+ const proof = readExactPaneProofSync(receipt.leader_pane_id);
404
+ if (proof.status !== 'live' || proof.pid !== receipt.leader_pane_pid)
405
+ throw new Error(`detached_session_destroy_authorization_unavailable:${receipt.session_name}`);
406
+ const owner = readPaneTeamOwnerTagResult(proof.paneId);
407
+ if (owner.status !== 'value' || owner.value !== receipt.owner_id)
408
+ throw new Error(`detached_session_destroy_authorization_unavailable:${receipt.session_name}`);
409
+ }
410
+ function requestDetachedSessionDestroyAtFinalSink(receipt) {
411
+ return requestDetachedTeamSessionDestroy(receipt.session_name, {
412
+ sessionId: receipt.session_id,
413
+ sessionCreated: receipt.session_created,
414
+ leaderPaneId: receipt.leader_pane_id,
415
+ leaderPanePid: receipt.leader_pane_pid,
416
+ ownerId: receipt.owner_id,
417
+ });
418
+ }
419
+ async function withDetachedSessionDestroyLock(teamName, cwd, fn) {
420
+ return await withTeamTaskMembershipBarrier(teamName, cwd, fn);
421
+ }
422
+ async function convergeDetachedSessionDestroyReceiptUnlocked(teamName, cwd, config, receipt) {
423
+ const query = queryDetachedTeamSession(receipt.session_name, { sessionId: receipt.session_id, sessionCreated: receipt.session_created });
424
+ if (query.status === 'unavailable' || query.status === 'replacement')
425
+ throw new Error(`detached_session_destroy_unresolved:${receipt.session_name}`);
426
+ if (query.status === 'absent') {
427
+ if (config) {
428
+ validateDetachedReceiptConfig(teamName, cwd, config, receipt);
429
+ config.tmux_session = '';
430
+ await saveTeamConfig(config, cwd);
431
+ }
432
+ else if (receipt.status !== 'accepted') {
433
+ throw new Error(`detached_session_destroy_authorization_unavailable:${receipt.session_name}`);
434
+ }
435
+ return true;
436
+ }
437
+ if (!config)
438
+ throw new Error(`detached_session_destroy_authorization_unavailable:${receipt.session_name}`);
439
+ validateDetachedReceiptConfig(teamName, cwd, config, receipt);
440
+ assertDetachedSessionDestroyAuthority(receipt);
441
+ if (!requestDetachedSessionDestroyAtFinalSink(receipt)) {
442
+ throw new Error(`detached_session_destroy_unresolved:${receipt.session_name}`);
443
+ }
444
+ await writeDetachedSessionDestroyReceipt(teamName, cwd, config, { ...receipt, status: 'accepted' });
445
+ return await convergeDetachedSessionDestroyReceiptUnlocked(teamName, cwd, config, { ...receipt, status: 'accepted' });
446
+ }
447
+ async function reconcileDetachedSessionDestroyReceipt(teamName, cwd, config) {
448
+ return await withDetachedSessionDestroyLock(teamName, cwd, async () => {
449
+ const canonical = await readTeamConfig(teamName, cwd);
450
+ const receipt = await readDetachedSessionDestroyReceipt(teamName, cwd, canonical ?? config);
451
+ if (!receipt)
452
+ return false;
453
+ if (!canonical) {
454
+ // Name/file presence is not authority. Accepted absence requires a
455
+ // successfully parsed current canonical generation.
456
+ throw new Error(`detached_session_destroy_authorization_unavailable:${receipt.session_name}`);
457
+ }
458
+ return await convergeDetachedSessionDestroyReceiptUnlocked(teamName, cwd, canonical, receipt);
459
+ });
460
+ }
461
+ async function destroyConfiguredDetachedTeamSession(teamName, cwd, config) {
462
+ if (!config.tmux_session)
463
+ return;
464
+ await withDetachedSessionDestroyLock(teamName, cwd, async () => {
465
+ const canonical = await readTeamConfig(teamName, cwd);
466
+ if (!canonical)
467
+ throw new Error(`detached_session_destroy_authorization_unavailable:${config.tmux_session}`);
468
+ const existing = await readDetachedSessionDestroyReceipt(teamName, cwd, canonical);
469
+ if (existing) {
470
+ await convergeDetachedSessionDestroyReceiptUnlocked(teamName, cwd, canonical, existing);
471
+ return;
472
+ }
473
+ const sessionName = canonical.tmux_session;
474
+ const sessionId = typeof canonical.tmux_session_id === 'string' ? canonical.tmux_session_id : '';
475
+ const sessionCreated = typeof canonical.tmux_session_created === 'string' ? canonical.tmux_session_created : '';
476
+ const digest = detachedConfigIdentity(teamName, cwd, canonical);
477
+ const ownerId = typeof canonical.tmux_pane_owner_id === 'string' ? canonical.tmux_pane_owner_id.trim() : '';
478
+ const leaderPaneId = typeof canonical.leader_pane_id === 'string' ? canonical.leader_pane_id.trim() : '';
479
+ const leaderPanePid = canonical.leader_pane_pid;
480
+ if (!digest || !sessionName || !/^\$[0-9]+$/.test(sessionId) || !/^[0-9]+$/.test(sessionCreated) || !ownerId || !/^%[0-9]+$/.test(leaderPaneId)
481
+ || typeof leaderPanePid !== 'number' || !Number.isSafeInteger(leaderPanePid) || leaderPanePid <= 0) {
482
+ throw new Error(`detached_session_destroy_authorization_unavailable:${sessionName || 'missing_session'}`);
483
+ }
484
+ const receipt = {
485
+ schema_version: 2, schema: 'omx.detached_session_destroy.v2', operation: 'detached_session_destroy', status: 'intent',
486
+ team_name: teamName, state_root: canonicalDetachedStateRoot(canonical, cwd), config_identity_version: 2, config_identity_digest: digest,
487
+ session_name: sessionName, session_id: sessionId, session_created: sessionCreated,
488
+ leader_pane_id: leaderPaneId, leader_pane_pid: leaderPanePid, owner_id: ownerId,
489
+ };
490
+ assertDetachedSessionDestroyAuthority(receipt);
491
+ await writeDetachedSessionDestroyReceipt(teamName, cwd, canonical, receipt);
492
+ const afterJournalHook = detachedSessionDestroyAfterJournalHook;
493
+ detachedSessionDestroyAfterJournalHook = null;
494
+ await afterJournalHook?.();
495
+ const current = await readTeamConfig(teamName, cwd);
496
+ if (!current)
497
+ throw new Error(`detached_session_destroy_authorization_unavailable:${sessionName}`);
498
+ validateDetachedReceiptConfig(teamName, cwd, current, receipt);
499
+ assertDetachedSessionDestroyAuthority(receipt);
500
+ if (!requestDetachedSessionDestroyAtFinalSink(receipt)) {
501
+ throw new Error(`detached_session_destroy_unresolved:${sessionName}`);
502
+ }
503
+ await writeDetachedSessionDestroyReceipt(teamName, cwd, current, { ...receipt, status: 'accepted' });
504
+ await convergeDetachedSessionDestroyReceiptUnlocked(teamName, cwd, current, { ...receipt, status: 'accepted' });
505
+ });
506
+ }
507
+ function assertPaneTeardownProofsAvailable(operation, unavailableProofs) {
508
+ if (unavailableProofs.length === 0)
509
+ return;
510
+ const detail = unavailableProofs
511
+ .map((proof) => `${proof.paneId}:${proof.reason}${proof.detail ? `:${proof.detail}` : ''}`)
512
+ .join(',');
513
+ throw new Error(`${operation}_pane_proof_unavailable:${detail}`);
514
+ }
230
515
  export async function cleanupTeamWorkerLaunchOrphanedMcpProcesses(dependencies = {}) {
231
516
  const cleanup = dependencies.cleanup ?? (() => cleanupOmxMcpProcesses([], {
232
517
  selectCandidates: findLaunchSafeCleanupCandidates,
@@ -1384,7 +1669,7 @@ function registerPromptWorkerHandle(teamName, workerName, child) {
1384
1669
  if (!teamHandles)
1385
1670
  return;
1386
1671
  const handle = teamHandles.get(workerName);
1387
- if (handle?.processGroupId && isProcessGroupAlive(handle.processGroupId)) {
1672
+ if (handle?.processGroupId && probeProcessGroupLiveness(handle.processGroupId) !== 'gone') {
1388
1673
  return;
1389
1674
  }
1390
1675
  teamHandles.delete(workerName);
@@ -1403,34 +1688,70 @@ function removePromptWorkerHandle(teamName, workerName) {
1403
1688
  if (teamHandles.size === 0)
1404
1689
  promptWorkerRegistry.delete(teamName);
1405
1690
  }
1406
- function isPidAlive(pid) {
1691
+ function probePidLiveness(pid) {
1407
1692
  if (!Number.isFinite(pid) || pid <= 0)
1408
- return false;
1693
+ return 'gone';
1409
1694
  try {
1410
1695
  process.kill(pid, 0);
1411
- return true;
1696
+ return 'alive';
1412
1697
  }
1413
1698
  catch (err) {
1414
1699
  if (err.code === 'ESRCH')
1415
- return false;
1700
+ return 'gone';
1416
1701
  process.stderr.write(`[team/runtime] operation failed: ${err}\n`);
1417
- return false;
1702
+ return 'unknown';
1703
+ }
1704
+ }
1705
+ function isPidAlive(pid) {
1706
+ return probePidLiveness(pid) === 'alive';
1707
+ }
1708
+ function isPidGone(pid) {
1709
+ return probePidLiveness(pid) === 'gone';
1710
+ }
1711
+ async function captureProcessIdentity(pid) {
1712
+ // There is no portable process birth identifier exposed by Node. Refuse to
1713
+ // persist replayable PID debt on platforms without Linux's stable proc stat
1714
+ // start-time field rather than later treating a reused PID as authoritative.
1715
+ if (process.platform !== 'linux' || !Number.isSafeInteger(pid) || pid <= 0)
1716
+ return null;
1717
+ try {
1718
+ const stat = await readFile(`/proc/${pid}/stat`, 'utf8');
1719
+ const close = stat.lastIndexOf(')');
1720
+ const fields = close >= 0 ? stat.slice(close + 2).trim().split(/\s+/) : [];
1721
+ // field 22 is starttime; fields begin at field 3 after the comm value.
1722
+ const startTime = fields[19];
1723
+ return typeof startTime === 'string' && /^[0-9]+$/.test(startTime)
1724
+ ? { pid, start_time: startTime }
1725
+ : null;
1418
1726
  }
1727
+ catch {
1728
+ return null;
1729
+ }
1730
+ }
1731
+ async function captureProcessIdentities(pids) {
1732
+ const identities = await Promise.all([...new Set(pids)].map((pid) => captureProcessIdentity(pid)));
1733
+ return identities.every((identity) => identity !== null) ? identities : null;
1419
1734
  }
1420
- function isProcessGroupAlive(processGroupId) {
1735
+ async function probeProcessIdentity(identity) {
1736
+ const current = await captureProcessIdentity(identity.pid);
1737
+ if (current === null)
1738
+ return probePidLiveness(identity.pid) === 'gone' ? 'gone' : 'reused_or_unknown';
1739
+ return current.start_time === identity.start_time ? 'same' : 'reused_or_unknown';
1740
+ }
1741
+ function probeProcessGroupLiveness(processGroupId) {
1421
1742
  if (process.platform === 'win32')
1422
- return false;
1743
+ return 'gone';
1423
1744
  if (!Number.isFinite(processGroupId) || processGroupId <= 0)
1424
- return false;
1745
+ return 'gone';
1425
1746
  try {
1426
1747
  process.kill(-processGroupId, 0);
1427
- return true;
1748
+ return 'alive';
1428
1749
  }
1429
1750
  catch (err) {
1430
1751
  if (err.code === 'ESRCH')
1431
- return false;
1752
+ return 'gone';
1432
1753
  process.stderr.write(`[team/runtime] operation failed: ${err}\n`);
1433
- return false;
1754
+ return 'unknown';
1434
1755
  }
1435
1756
  }
1436
1757
  function listProcessTreeEntries() {
@@ -1491,11 +1812,11 @@ async function waitForTrackedPidsExit(pids, timeoutMs) {
1491
1812
  return true;
1492
1813
  const deadline = Date.now() + Math.max(0, timeoutMs);
1493
1814
  while (Date.now() < deadline) {
1494
- if (tracked.every((pid) => !isPidAlive(pid)))
1815
+ if (tracked.every(isPidGone))
1495
1816
  return true;
1496
1817
  await new Promise((resolve) => setTimeout(resolve, PROMPT_WORKER_EXIT_POLL_MS));
1497
1818
  }
1498
- return tracked.every((pid) => !isPidAlive(pid));
1819
+ return tracked.every(isPidGone);
1499
1820
  }
1500
1821
  async function terminateTrackedProcessTree(rootPid, processGroupId = null, graceMs = PROMPT_WORKER_SIGTERM_WAIT_MS, killWaitMs = PROMPT_WORKER_SIGKILL_WAIT_MS) {
1501
1822
  if (processGroupId && process.platform !== 'win32') {
@@ -1522,9 +1843,9 @@ async function terminateTrackedProcessTree(rootPid, processGroupId = null, grace
1522
1843
  }
1523
1844
  const groupDeadline = Date.now() + Math.max(0, graceMs);
1524
1845
  while (Date.now() < groupDeadline) {
1525
- const groupAlive = isProcessGroupAlive(processGroupId);
1526
- const descendantsAlive = trackedPids.some((pid) => isPidAlive(pid));
1527
- if (!groupAlive && !descendantsAlive) {
1846
+ const groupGone = probeProcessGroupLiveness(processGroupId) === 'gone';
1847
+ const descendantsGone = trackedPids.every(isPidGone);
1848
+ if (groupGone && descendantsGone) {
1528
1849
  return { terminated: true, forcedKill: false, trackedPids };
1529
1850
  }
1530
1851
  await new Promise((resolve) => setTimeout(resolve, PROMPT_WORKER_EXIT_POLL_MS));
@@ -1551,15 +1872,15 @@ async function terminateTrackedProcessTree(rootPid, processGroupId = null, grace
1551
1872
  }
1552
1873
  const killDeadline = Date.now() + Math.max(0, killWaitMs);
1553
1874
  while (Date.now() < killDeadline) {
1554
- const groupAlive = isProcessGroupAlive(processGroupId);
1555
- const descendantsAlive = trackedPids.some((pid) => isPidAlive(pid));
1556
- if (!groupAlive && !descendantsAlive) {
1875
+ const groupGone = probeProcessGroupLiveness(processGroupId) === 'gone';
1876
+ const descendantsGone = trackedPids.every(isPidGone);
1877
+ if (groupGone && descendantsGone) {
1557
1878
  return { terminated: true, forcedKill: true, trackedPids };
1558
1879
  }
1559
1880
  await new Promise((resolve) => setTimeout(resolve, PROMPT_WORKER_EXIT_POLL_MS));
1560
1881
  }
1561
1882
  return {
1562
- terminated: !isProcessGroupAlive(processGroupId) && trackedPids.every((pid) => !isPidAlive(pid)),
1883
+ terminated: probeProcessGroupLiveness(processGroupId) === 'gone' && trackedPids.every(isPidGone),
1563
1884
  forcedKill: true,
1564
1885
  trackedPids,
1565
1886
  };
@@ -1567,7 +1888,7 @@ async function terminateTrackedProcessTree(rootPid, processGroupId = null, grace
1567
1888
  const trackedPids = collectProcessTreePids(rootPid);
1568
1889
  if (trackedPids.length === 0) {
1569
1890
  return {
1570
- terminated: !isPidAlive(rootPid),
1891
+ terminated: isPidGone(rootPid),
1571
1892
  forcedKill: false,
1572
1893
  trackedPids: [],
1573
1894
  };
@@ -1603,6 +1924,327 @@ async function terminateTrackedProcessTree(rootPid, processGroupId = null, grace
1603
1924
  trackedPids,
1604
1925
  };
1605
1926
  }
1927
+ /**
1928
+ * Every operating-system effect attributed to an explicit pane must re-read
1929
+ * the pane's global row immediately before that effect. A changed/dead row is
1930
+ * not a fallback opportunity: it stops this pane's process-tree teardown.
1931
+ */
1932
+ function reproveExactPaneAfterProcessEsrcH(paneId) {
1933
+ const proof = readExactPaneProofSync(paneId);
1934
+ if (proof.status === 'unavailable')
1935
+ return { status: 'unavailable', proof };
1936
+ if (proof.status === 'gone')
1937
+ return { status: 'gone' };
1938
+ // ESRCH establishes only that the process was absent. A still-live pane row
1939
+ // is not authority to advance the pane lifecycle without an absent reproof.
1940
+ return { status: 'stopped' };
1941
+ }
1942
+ function signalExactPaneProcess(paneId, authorizedPanePid, pid, signal) {
1943
+ const proof = readExactPaneProofSync(paneId);
1944
+ if (proof.status === 'unavailable')
1945
+ return { status: 'unavailable', proof };
1946
+ if (proof.status === 'live' && proof.pid !== authorizedPanePid)
1947
+ return { status: 'stopped' };
1948
+ if (proof.status === 'gone')
1949
+ return { status: 'stopped' };
1950
+ try {
1951
+ process.kill(pid, signal);
1952
+ return { status: 'alive' };
1953
+ }
1954
+ catch (error) {
1955
+ return error.code === 'ESRCH'
1956
+ ? reproveExactPaneAfterProcessEsrcH(paneId)
1957
+ : { status: 'unknown' };
1958
+ }
1959
+ }
1960
+ function probeExactPaneProcess(paneId, authorizedPanePid, pid) {
1961
+ const proof = readExactPaneProofSync(paneId);
1962
+ if (proof.status === 'unavailable')
1963
+ return { status: 'unavailable', proof };
1964
+ if (proof.status === 'live' && proof.pid !== authorizedPanePid)
1965
+ return { status: 'stopped' };
1966
+ try {
1967
+ process.kill(pid, 0);
1968
+ return proof.status === 'gone' ? { status: 'stopped' } : { status: 'alive' };
1969
+ }
1970
+ catch (error) {
1971
+ return error.code === 'ESRCH'
1972
+ ? reproveExactPaneAfterProcessEsrcH(paneId)
1973
+ : { status: 'unknown' };
1974
+ }
1975
+ }
1976
+ async function waitForExactPaneTrackedPidsExit(paneId, authorizedPanePid, pids, timeoutMs) {
1977
+ const trackedPids = [...pids];
1978
+ const deadline = Date.now() + Math.max(0, timeoutMs);
1979
+ do {
1980
+ let anyAlive = false;
1981
+ for (const pid of trackedPids) {
1982
+ const probe = probeExactPaneProcess(paneId, authorizedPanePid, pid);
1983
+ if (probe.status === 'unavailable')
1984
+ return { terminated: false, stopped: true, trackedPids, authorizedPanePid, proofUnavailable: probe.proof };
1985
+ if (probe.status === 'stopped')
1986
+ return { terminated: false, stopped: true, trackedPids, authorizedPanePid };
1987
+ if (probe.status === 'unknown')
1988
+ return { terminated: false, stopped: true, trackedPids, authorizedPanePid };
1989
+ if (probe.status === 'alive')
1990
+ anyAlive = true;
1991
+ }
1992
+ if (!anyAlive)
1993
+ return { terminated: true, stopped: false, trackedPids, authorizedPanePid };
1994
+ if (Date.now() >= deadline)
1995
+ break;
1996
+ await new Promise((resolve) => setTimeout(resolve, PROMPT_WORKER_EXIT_POLL_MS));
1997
+ } while (Date.now() < deadline);
1998
+ for (const pid of trackedPids) {
1999
+ const probe = probeExactPaneProcess(paneId, authorizedPanePid, pid);
2000
+ if (probe.status === 'unavailable')
2001
+ return { terminated: false, stopped: true, trackedPids, authorizedPanePid, proofUnavailable: probe.proof };
2002
+ if (probe.status === 'stopped' || probe.status === 'unknown')
2003
+ return { terminated: false, stopped: true, trackedPids, authorizedPanePid };
2004
+ if (probe.status === 'alive')
2005
+ return { terminated: false, stopped: false, trackedPids, authorizedPanePid };
2006
+ }
2007
+ return { terminated: true, stopped: false, trackedPids, authorizedPanePid };
2008
+ }
2009
+ async function terminateExactPaneProcessTree(paneId, expectedPanePid, graceMs = PROMPT_WORKER_SIGTERM_WAIT_MS, killWaitMs = PROMPT_WORKER_SIGKILL_WAIT_MS, authorizePaneEffect) {
2010
+ const authorization = readExactPaneProofSync(paneId);
2011
+ if (authorization.status === 'unavailable')
2012
+ return { terminated: false, stopped: true, trackedPids: [], proofUnavailable: authorization };
2013
+ if (authorization.status === 'gone')
2014
+ return { terminated: true, stopped: true, trackedPids: [] };
2015
+ if (typeof expectedPanePid === 'number' && authorization.pid !== expectedPanePid) {
2016
+ return { terminated: false, stopped: true, trackedPids: [], authorizedPanePid: authorization.pid, proofUnavailable: {
2017
+ status: 'unavailable', paneId, reason: 'pane_pid_changed', detail: `expected ${expectedPanePid}, got ${authorization.pid}`,
2018
+ } };
2019
+ }
2020
+ if (authorizePaneEffect && !authorizePaneEffect(paneId, authorization.pid)) {
2021
+ return {
2022
+ terminated: false,
2023
+ stopped: true,
2024
+ trackedPids: [],
2025
+ authorizedPanePid: authorization.pid,
2026
+ proofUnavailable: {
2027
+ status: 'unavailable',
2028
+ paneId,
2029
+ reason: 'pane_pid_changed',
2030
+ detail: 'pane owner authorization changed',
2031
+ },
2032
+ };
2033
+ }
2034
+ const trackedPids = collectProcessTreePids(authorization.pid);
2035
+ if (trackedPids.length === 0)
2036
+ return { terminated: true, stopped: false, trackedPids, authorizedPanePid: authorization.pid };
2037
+ // Birth evidence is mandatory for descendant signals. A pane's pinned PID
2038
+ // authorizes its root process, but a numeric descendant PID is never enough:
2039
+ // its /proc start time must still match immediately before each signal.
2040
+ const trackedProcessIdentities = await captureProcessIdentities(trackedPids) ?? undefined;
2041
+ const trackedProcessIdentityByPid = new Map((trackedProcessIdentities ?? []).map((identity) => [identity.pid, identity]));
2042
+ const revalidateDescendantIdentity = async (pid) => {
2043
+ if (pid === authorization.pid)
2044
+ return true;
2045
+ const identity = trackedProcessIdentityByPid.get(pid);
2046
+ return identity !== undefined && (await probeProcessIdentity(identity)) === 'same';
2047
+ };
2048
+ for (const pid of trackedPids) {
2049
+ if (authorizePaneEffect && !authorizePaneEffect(paneId, authorization.pid)) {
2050
+ return {
2051
+ terminated: false,
2052
+ stopped: true,
2053
+ trackedPids,
2054
+ trackedProcessIdentities,
2055
+ authorizedPanePid: authorization.pid,
2056
+ proofUnavailable: {
2057
+ status: 'unavailable',
2058
+ paneId,
2059
+ reason: 'pane_pid_changed',
2060
+ detail: 'pane owner authorization changed',
2061
+ },
2062
+ };
2063
+ }
2064
+ if (!await revalidateDescendantIdentity(pid)) {
2065
+ return { terminated: false, stopped: true, trackedPids, trackedProcessIdentities, authorizedPanePid: authorization.pid };
2066
+ }
2067
+ const signal = signalExactPaneProcess(paneId, authorization.pid, pid, 'SIGTERM');
2068
+ if (signal.status === 'unavailable')
2069
+ return { terminated: false, stopped: true, trackedPids, trackedProcessIdentities, authorizedPanePid: authorization.pid, proofUnavailable: signal.proof };
2070
+ if (signal.status === 'stopped' || signal.status === 'unknown')
2071
+ return { terminated: false, stopped: true, trackedPids, trackedProcessIdentities, authorizedPanePid: authorization.pid };
2072
+ }
2073
+ const graceful = await waitForExactPaneTrackedPidsExit(paneId, authorization.pid, trackedPids, graceMs);
2074
+ if (graceful.terminated || graceful.stopped)
2075
+ return { ...graceful, trackedProcessIdentities };
2076
+ for (const pid of trackedPids) {
2077
+ const probe = probeExactPaneProcess(paneId, authorization.pid, pid);
2078
+ if (probe.status === 'unavailable')
2079
+ return { terminated: false, stopped: true, trackedPids, trackedProcessIdentities, authorizedPanePid: authorization.pid, proofUnavailable: probe.proof };
2080
+ if (probe.status === 'stopped')
2081
+ return { terminated: false, stopped: true, trackedPids, trackedProcessIdentities, authorizedPanePid: authorization.pid };
2082
+ if (probe.status === 'gone')
2083
+ continue;
2084
+ if (authorizePaneEffect && !authorizePaneEffect(paneId, authorization.pid)) {
2085
+ return {
2086
+ terminated: false,
2087
+ stopped: true,
2088
+ trackedPids,
2089
+ trackedProcessIdentities,
2090
+ authorizedPanePid: authorization.pid,
2091
+ proofUnavailable: {
2092
+ status: 'unavailable',
2093
+ paneId,
2094
+ reason: 'pane_pid_changed',
2095
+ detail: 'pane owner authorization changed',
2096
+ },
2097
+ };
2098
+ }
2099
+ if (!await revalidateDescendantIdentity(pid)) {
2100
+ return { terminated: false, stopped: true, trackedPids, trackedProcessIdentities, authorizedPanePid: authorization.pid };
2101
+ }
2102
+ const signal = signalExactPaneProcess(paneId, authorization.pid, pid, 'SIGKILL');
2103
+ if (signal.status === 'unavailable')
2104
+ return { terminated: false, stopped: true, trackedPids, trackedProcessIdentities, authorizedPanePid: authorization.pid, proofUnavailable: signal.proof };
2105
+ if (signal.status === 'stopped' || signal.status === 'unknown')
2106
+ return { terminated: false, stopped: true, trackedPids, trackedProcessIdentities, authorizedPanePid: authorization.pid };
2107
+ }
2108
+ return { ...(await waitForExactPaneTrackedPidsExit(paneId, authorization.pid, trackedPids, killWaitMs)), trackedProcessIdentities };
2109
+ }
2110
+ function gonePaneDescendantCleanupDebtPath(teamName, cwd, config) {
2111
+ const stateRoot = config?.team_state_root ?? resolveCanonicalTeamStateRoot(resolve(cwd));
2112
+ return join(stateRoot, 'team', teamName, '.gone-pane-descendant-cleanup-debt.json');
2113
+ }
2114
+ function trackedPidsFromGonePaneDebtEntry(entry) {
2115
+ return 'tracked_pids' in entry
2116
+ ? entry.tracked_pids
2117
+ : entry.tracked_processes.map((identity) => identity.pid);
2118
+ }
2119
+ async function persistGonePaneDescendantCleanupDebt(params) {
2120
+ const { teamName, cwd, config, paneId, teardown } = params;
2121
+ const unresolvedTrackedPids = teardown.trackedPids.filter((pid) => probePidLiveness(pid) !== 'gone');
2122
+ if (unresolvedTrackedPids.length === 0 || typeof teardown.authorizedPanePid !== 'number')
2123
+ return;
2124
+ const liveTrackedProcesses = (teardown.trackedProcessIdentities ?? []).filter((identity) => (unresolvedTrackedPids.includes(identity.pid)));
2125
+ const debtPath = gonePaneDescendantCleanupDebtPath(teamName, cwd, config);
2126
+ let prior = { schema_version: 1, operation: 'gone_pane_descendant_cleanup', entries: [] };
2127
+ try {
2128
+ prior = JSON.parse(await readFile(debtPath, 'utf8'));
2129
+ }
2130
+ catch (error) {
2131
+ if (error.code !== 'ENOENT')
2132
+ throw new Error('gone_pane_descendant_cleanup_debt_unreadable');
2133
+ }
2134
+ if (!isValidGonePaneDescendantCleanupDebt(prior))
2135
+ throw new Error('gone_pane_descendant_cleanup_debt_malformed');
2136
+ const priorEntry = prior.entries.find((entry) => entry.pane_id === paneId);
2137
+ const entries = prior.entries.filter((entry) => entry.pane_id !== paneId);
2138
+ if (liveTrackedProcesses.length !== unresolvedTrackedPids.length) {
2139
+ const priorIdentities = priorEntry?.tracked_processes ?? [];
2140
+ const identitiesByPid = new Map(priorIdentities.map((identity) => [identity.pid, identity]));
2141
+ for (const identity of liveTrackedProcesses) {
2142
+ if (!identitiesByPid.has(identity.pid))
2143
+ identitiesByPid.set(identity.pid, identity);
2144
+ }
2145
+ entries.push({
2146
+ pane_id: paneId,
2147
+ authorized_pane_pid: teardown.authorizedPanePid,
2148
+ tracked_pids: [...new Set([...(priorEntry ? trackedPidsFromGonePaneDebtEntry(priorEntry) : []), ...unresolvedTrackedPids])],
2149
+ ...(identitiesByPid.size > 0 ? { tracked_processes: [...identitiesByPid.values()] } : {}),
2150
+ evidence: 'process_identity_unavailable',
2151
+ });
2152
+ }
2153
+ else {
2154
+ entries.push({
2155
+ pane_id: paneId,
2156
+ authorized_pane_pid: teardown.authorizedPanePid,
2157
+ tracked_processes: liveTrackedProcesses,
2158
+ evidence: teardown.proofUnavailable
2159
+ ? `${teardown.proofUnavailable.reason}:${teardown.proofUnavailable.detail ?? ''}`
2160
+ : 'pane_authority_lost_during_descendant_teardown',
2161
+ });
2162
+ }
2163
+ await writeAtomic(debtPath, JSON.stringify({ schema_version: 1, operation: 'gone_pane_descendant_cleanup', entries }, null, 2));
2164
+ }
2165
+ function isValidProcessIdentity(value) {
2166
+ return typeof value === 'object' && value !== null
2167
+ && Number.isSafeInteger(value.pid)
2168
+ && value.pid > 0
2169
+ && typeof value.start_time === 'string'
2170
+ && /^[0-9]+$/.test(value.start_time);
2171
+ }
2172
+ function isValidGonePaneDescendantCleanupDebt(value) {
2173
+ if (typeof value !== 'object' || value === null)
2174
+ return false;
2175
+ const debt = value;
2176
+ if (debt.schema_version !== 1 || debt.operation !== 'gone_pane_descendant_cleanup' || !Array.isArray(debt.entries))
2177
+ return false;
2178
+ if (new Set(debt.entries.map((entry) => entry?.pane_id)).size !== debt.entries.length)
2179
+ return false;
2180
+ return debt.entries.every((entry) => {
2181
+ if (typeof entry !== 'object' || entry === null)
2182
+ return false;
2183
+ const candidate = entry;
2184
+ if (typeof candidate.pane_id !== 'string' || !/^%[0-9]+$/.test(candidate.pane_id)
2185
+ || !Number.isSafeInteger(candidate.authorized_pane_pid)
2186
+ || candidate.authorized_pane_pid <= 0)
2187
+ return false;
2188
+ if (candidate.evidence === 'process_identity_unavailable') {
2189
+ const trackedPids = candidate.tracked_pids;
2190
+ if (!Array.isArray(candidate.tracked_pids) || candidate.tracked_pids.length === 0
2191
+ || trackedPids.some((pid) => !Number.isSafeInteger(pid) || pid <= 0)
2192
+ || new Set(trackedPids).size !== trackedPids.length)
2193
+ return false;
2194
+ if (candidate.tracked_processes === undefined)
2195
+ return true;
2196
+ if (!Array.isArray(candidate.tracked_processes) || !candidate.tracked_processes.every(isValidProcessIdentity))
2197
+ return false;
2198
+ const trackedProcesses = candidate.tracked_processes;
2199
+ return trackedProcesses.every((identity) => trackedPids.includes(identity.pid))
2200
+ && new Set(trackedProcesses.map((identity) => identity.pid)).size === trackedProcesses.length;
2201
+ }
2202
+ if (typeof candidate.evidence !== 'string' || candidate.evidence.length === 0
2203
+ || candidate.tracked_pids !== undefined
2204
+ || !Array.isArray(candidate.tracked_processes) || candidate.tracked_processes.length === 0
2205
+ || !candidate.tracked_processes.every(isValidProcessIdentity))
2206
+ return false;
2207
+ const trackedProcesses = candidate.tracked_processes;
2208
+ return new Set(trackedProcesses.map((identity) => identity.pid)).size === trackedProcesses.length;
2209
+ });
2210
+ }
2211
+ async function reconcileGonePaneDescendantCleanupDebt(teamName, cwd, config) {
2212
+ const debtPath = gonePaneDescendantCleanupDebtPath(teamName, cwd, config);
2213
+ if (!existsSync(debtPath))
2214
+ return;
2215
+ let debt;
2216
+ try {
2217
+ debt = JSON.parse(await readFile(debtPath, 'utf8'));
2218
+ }
2219
+ catch {
2220
+ throw new Error('gone_pane_descendant_cleanup_debt_unreadable');
2221
+ }
2222
+ if (!isValidGonePaneDescendantCleanupDebt(debt))
2223
+ throw new Error('gone_pane_descendant_cleanup_debt_malformed');
2224
+ const unresolved = [];
2225
+ for (const entry of debt.entries) {
2226
+ if ('tracked_pids' in entry) {
2227
+ const trackedProcesses = entry.tracked_processes ?? [];
2228
+ const knownIdentities = new Map(trackedProcesses.map((identity) => [identity.pid, identity]));
2229
+ const states = await Promise.all(entry.tracked_pids.map((pid) => {
2230
+ const identity = knownIdentities.get(pid);
2231
+ return identity ? probeProcessIdentity(identity) : Promise.resolve(probePidLiveness(pid) === 'gone' ? 'gone' : 'reused_or_unknown');
2232
+ }));
2233
+ if (states.some((state) => state !== 'gone'))
2234
+ unresolved.push(entry);
2235
+ continue;
2236
+ }
2237
+ const states = await Promise.all(entry.tracked_processes.map(probeProcessIdentity));
2238
+ if (states.some((state) => state !== 'gone'))
2239
+ unresolved.push(entry);
2240
+ }
2241
+ if (unresolved.length === 0) {
2242
+ await removeDurableFile(debtPath);
2243
+ return;
2244
+ }
2245
+ await writeAtomic(debtPath, JSON.stringify({ ...debt, entries: unresolved }, null, 2));
2246
+ throw new Error(`gone_pane_descendant_cleanup_debt_unresolved:${unresolved.map((entry) => entry.pane_id).join(',')}`);
2247
+ }
1606
2248
  async function teardownPromptWorker(teamName, workerName, fallbackPid, cwd, context) {
1607
2249
  const handle = getPromptWorkerHandle(teamName, workerName);
1608
2250
  const handlePid = handle?.pid;
@@ -1615,7 +2257,9 @@ async function teardownPromptWorker(teamName, workerName, fallbackPid, cwd, cont
1615
2257
  return { terminated: true, forcedKill: false, pid: null };
1616
2258
  }
1617
2259
  const teardown = await terminateTrackedProcessTree(pid ?? 0, processGroupId);
1618
- const processGone = processGroupId ? !isProcessGroupAlive(processGroupId) : !isPidAlive(pid);
2260
+ const processGone = processGroupId
2261
+ ? probeProcessGroupLiveness(processGroupId) === 'gone'
2262
+ : isPidGone(pid);
1619
2263
  if (teardown.terminated && processGone) {
1620
2264
  removePromptWorkerHandle(teamName, workerName);
1621
2265
  return { terminated: true, forcedKill: teardown.forcedKill, pid };
@@ -1625,31 +2269,27 @@ async function teardownPromptWorker(teamName, workerName, fallbackPid, cwd, cont
1625
2269
  worker: workerName,
1626
2270
  reason: `prompt_force_kill:${context}:pid=${pid}`,
1627
2271
  }, cwd).catch(() => { });
1628
- if (!teardown.terminated) {
1629
- await appendTeamEvent(teamName, {
1630
- type: 'worker_stopped',
1631
- worker: workerName,
1632
- reason: `prompt_teardown_failed:${context}:pid=${pid}`,
1633
- }, cwd).catch(() => { });
1634
- return {
1635
- terminated: false,
1636
- forcedKill: teardown.forcedKill,
1637
- pid,
1638
- error: 'still_alive_after_sigkill',
1639
- };
1640
- }
1641
- removePromptWorkerHandle(teamName, workerName);
1642
- return { terminated: true, forcedKill: teardown.forcedKill, pid };
2272
+ await appendTeamEvent(teamName, {
2273
+ type: 'worker_stopped',
2274
+ worker: workerName,
2275
+ reason: `prompt_teardown_failed:${context}:pid=${pid}`,
2276
+ }, cwd).catch(() => { });
2277
+ return {
2278
+ terminated: false,
2279
+ forcedKill: teardown.forcedKill,
2280
+ pid,
2281
+ error: 'still_alive_after_sigkill',
2282
+ };
1643
2283
  }
1644
2284
  function isPromptWorkerAlive(config, worker) {
1645
2285
  const handle = getPromptWorkerHandle(config.name, worker.name);
1646
2286
  if (handle?.child.exitCode === null && !handle.child.killed)
1647
2287
  return true;
1648
- if (handle?.processGroupId && isProcessGroupAlive(handle.processGroupId))
2288
+ if (handle?.processGroupId && probeProcessGroupLiveness(handle.processGroupId) !== 'gone')
1649
2289
  return true;
1650
- if (process.platform !== 'win32' && isProcessGroupAlive(worker.pid))
2290
+ if (process.platform !== 'win32' && probeProcessGroupLiveness(worker.pid) !== 'gone')
1651
2291
  return true;
1652
- return isPidAlive(worker.pid);
2292
+ return probePidLiveness(worker.pid) !== 'gone';
1653
2293
  }
1654
2294
  export { TEAM_LOW_COMPLEXITY_DEFAULT_MODEL };
1655
2295
  export { resolveCanonicalTeamStateRoot };
@@ -1774,7 +2414,7 @@ async function writeDecompositionArtifacts(teamName, cwd, metadata) {
1774
2414
  await writeFile(join(root, 'decomposition-report.json'), JSON.stringify(metadata, null, 2), 'utf8');
1775
2415
  const manifest = await readTeamManifestV2(teamName, cwd);
1776
2416
  if (manifest) {
1777
- await writeFile(join(root, 'manifest.v2.json'), JSON.stringify({ ...manifest, team_decomposition: metadata }, null, 2), 'utf8');
2417
+ await writeTeamManifestV2State({ ...manifest, team_decomposition: { ...metadata } }, cwd);
1778
2418
  }
1779
2419
  }
1780
2420
  export async function settleStartupAttemptResults(attempts) {
@@ -1820,6 +2460,8 @@ export async function startTeam(teamName, task, agentType, workerCount, tasks, c
1820
2460
  : rawIdentityScope;
1821
2461
  const sanitized = buildInternalTeamName(displayName, identityScope);
1822
2462
  const leaderSessionId = identityScope.sessionId || identityScope.paneId || identityScope.tmuxTarget || identityScope.runId;
2463
+ const existingStartupConfig = await readTeamConfig(sanitized, leaderCwd);
2464
+ await reconcileGonePaneDescendantCleanupDebt(sanitized, leaderCwd, existingStartupConfig ?? undefined);
1823
2465
  await assertTeamStartupIsNonDestructive(sanitized, leaderCwd, leaderSessionId);
1824
2466
  if (displayName !== sanitized) {
1825
2467
  await assertTeamStartupIsNonDestructive(displayName, leaderCwd, leaderSessionId);
@@ -1913,7 +2555,18 @@ export async function startTeam(teamName, task, agentType, workerCount, tasks, c
1913
2555
  let sessionCreated = false;
1914
2556
  const createdWorkerPaneIds = [];
1915
2557
  let createdLeaderPaneId;
2558
+ // A returned interactive session has successfully tagged these panes. Rollback
2559
+ // must retain that ownership authorization through its final kill proof.
2560
+ const startupTaggedPaneOwnerIds = new Map();
2561
+ const authorizeStartupRollbackPaneEffect = (paneId) => {
2562
+ const expectedOwnerId = startupTaggedPaneOwnerIds.get(paneId);
2563
+ if (!expectedOwnerId)
2564
+ return true;
2565
+ const currentOwner = readPaneTeamOwnerTagResult(paneId);
2566
+ return currentOwner.status === 'value' && currentOwner.value === expectedOwnerId;
2567
+ };
1916
2568
  let config = null;
2569
+ const workerPaneIds = Array.from({ length: workerCount }, () => undefined);
1917
2570
  const workerReadyTimeoutMs = resolveWorkerReadyTimeoutMs(launchEnv);
1918
2571
  const workerStartupEvidenceTimeoutMs = resolveWorkerStartupEvidenceTimeoutMs(launchEnv, workerReadyTimeoutMs);
1919
2572
  const startupDispatchRetries = resolveStartupDispatchRetries(launchEnv);
@@ -2111,7 +2764,6 @@ export async function startTeam(teamName, task, agentType, workerCount, tasks, c
2111
2764
  workerRole: plan.workerRole,
2112
2765
  };
2113
2766
  });
2114
- const workerPaneIds = Array.from({ length: workerCount }, () => undefined);
2115
2767
  const materializeWorkerStartupState = async (bootstrapPlan, workerIndex, paneId) => {
2116
2768
  const workerWorkspace = bootstrapPlan.workerWorkspace;
2117
2769
  const identity = {
@@ -2128,13 +2780,19 @@ export async function startTeam(teamName, task, agentType, workerCount, tasks, c
2128
2780
  worktree_created: workerWorkspace.worktreeCreated,
2129
2781
  team_state_root: teamStateRoot,
2130
2782
  };
2131
- if (workerLaunchMode === 'interactive') {
2132
- const panePid = getWorkerPanePid(sessionName, workerIndex, paneId);
2133
- if (panePid)
2134
- identity.pid = panePid;
2783
+ const persistedPanePid = config?.workers[workerIndex - 1]?.pid;
2784
+ if (workerLaunchMode === 'interactive' && paneId) {
2785
+ if (typeof persistedPanePid !== 'number' || !Number.isSafeInteger(persistedPanePid) || persistedPanePid <= 0) {
2786
+ throw new Error(`startup_worker_pane_pid_unavailable:${paneId}`);
2787
+ }
2788
+ const paneProof = readExactPaneProofSync(paneId);
2789
+ if (paneProof.status !== 'live' || paneProof.pid !== persistedPanePid) {
2790
+ throw new Error(`startup_worker_pane_identity_changed:${paneId}`);
2791
+ }
2792
+ identity.pid = persistedPanePid;
2135
2793
  }
2136
- else if (config?.workers[workerIndex - 1]?.pid) {
2137
- identity.pid = config.workers[workerIndex - 1].pid;
2794
+ else if (typeof persistedPanePid === 'number') {
2795
+ identity.pid = persistedPanePid;
2138
2796
  }
2139
2797
  if (paneId)
2140
2798
  identity.pane_id = paneId;
@@ -2179,6 +2837,20 @@ export async function startTeam(teamName, task, agentType, workerCount, tasks, c
2179
2837
  createdWorkerPaneIds.push(...createdSession.workerPaneIds);
2180
2838
  createdLeaderPaneId = createdSession.leaderPaneId;
2181
2839
  applyCreatedInteractiveSessionToConfig(config, createdSession, workerPaneIds);
2840
+ const createdOwnerId = typeof createdSession.teamPaneOwnerId === 'string'
2841
+ ? createdSession.teamPaneOwnerId.trim()
2842
+ : '';
2843
+ if (createdOwnerId) {
2844
+ for (const paneId of [
2845
+ createdSession.leaderPaneId,
2846
+ ...createdSession.workerPaneIds,
2847
+ createdSession.hudPaneId,
2848
+ ]) {
2849
+ if (typeof paneId === 'string' && paneId.trim().startsWith('%')) {
2850
+ startupTaggedPaneOwnerIds.set(paneId, createdOwnerId);
2851
+ }
2852
+ }
2853
+ }
2182
2854
  for (const [index, paneId] of createdSession.workerPaneIds.entries()) {
2183
2855
  startupTiming.mark('split_returned', { worker: `worker-${index + 1}`, pane_id: paneId });
2184
2856
  }
@@ -2187,6 +2859,8 @@ export async function startTeam(teamName, task, agentType, workerCount, tasks, c
2187
2859
  config.tmux_session = `prompt-${sanitized}`;
2188
2860
  config.leader_pane_id = null;
2189
2861
  config.hud_pane_id = null;
2862
+ config.leader_pane_pid = null;
2863
+ config.hud_pane_pid = null;
2190
2864
  config.resize_hook_name = null;
2191
2865
  config.resize_hook_target = null;
2192
2866
  for (let i = 1; i <= workerCount; i++) {
@@ -2259,10 +2933,10 @@ export async function startTeam(teamName, task, agentType, workerCount, tasks, c
2259
2933
  let startupReadyPromptObserved = false;
2260
2934
  if (workerLaunchMode === 'interactive' && !skipWorkerReadyWait && !initialPrompt && !startupDirectOutcome?.ok) {
2261
2935
  startupTiming.mark('ready_wait_start', { worker: workerName, pane_id: paneId });
2262
- const ready = await waitForWorkerReadyAsync(sessionName, workerIndex, workerReadyTimeoutMs, paneId);
2936
+ const ready = await waitForWorkerReadyAsync(sessionName, workerIndex, workerReadyTimeoutMs, paneId, config.workers[workerIndex - 1]?.pid, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined);
2263
2937
  startupTiming.mark('ready_wait_end', { worker: workerName, pane_id: paneId, ok: ready });
2264
2938
  if (!ready) {
2265
- const workerAlive = isWorkerPaneOpen(sessionName, workerIndex, paneId);
2939
+ const workerAlive = isWorkerPaneOpen(sessionName, workerIndex, paneId, config.workers[workerIndex - 1]?.pid, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined);
2266
2940
  if (workerAlive) {
2267
2941
  await recordRecoverableStartupIssue({
2268
2942
  teamName: sanitized,
@@ -2323,8 +2997,8 @@ export async function startTeam(teamName, task, agentType, workerCount, tasks, c
2323
2997
  break;
2324
2998
  if (attempt < startupDispatchRetries) {
2325
2999
  if (workerLaunchMode === 'interactive') {
2326
- if (dismissTrustPromptIfPresent(sessionName, workerIndex, paneId)) {
2327
- await waitForWorkerReadyAsync(sessionName, workerIndex, workerReadyTimeoutMs, paneId);
3000
+ if (dismissTrustPromptIfPresent(sessionName, workerIndex, paneId, config.workers[workerIndex - 1]?.pid, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined)) {
3001
+ await waitForWorkerReadyAsync(sessionName, workerIndex, workerReadyTimeoutMs, paneId, config.workers[workerIndex - 1]?.pid, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined);
2328
3002
  }
2329
3003
  else {
2330
3004
  await new Promise((resolve) => setTimeout(resolve, Math.max(0, startupRetryDelayS * 1000)));
@@ -2339,7 +3013,7 @@ export async function startTeam(teamName, task, agentType, workerCount, tasks, c
2339
3013
  if (!dispatchOutcome.ok) {
2340
3014
  const workerAlive = workerLaunchMode === 'prompt'
2341
3015
  ? isPromptWorkerAlive(config, config.workers[workerIndex - 1])
2342
- : isWorkerPaneOpen(sessionName, workerIndex, paneId);
3016
+ : isWorkerPaneOpen(sessionName, workerIndex, paneId, config.workers[workerIndex - 1]?.pid, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined);
2343
3017
  if (workerLaunchMode === 'prompt' && !workerAlive) {
2344
3018
  await recordPromptStartupWorkerStopped({
2345
3019
  teamName: sanitized,
@@ -2387,6 +3061,52 @@ export async function startTeam(teamName, task, agentType, workerCount, tasks, c
2387
3061
  }
2388
3062
  catch (error) {
2389
3063
  const rollbackErrors = [];
3064
+ let preserveRollbackState = false;
3065
+ if (error instanceof CreateTeamSessionPartialError && config) {
3066
+ const partialSession = error.partialSession;
3067
+ sessionName = partialSession.name;
3068
+ sessionCreated = true;
3069
+ createdWorkerPaneIds.push(...partialSession.workerPaneIds);
3070
+ createdLeaderPaneId = partialSession.leaderPaneId;
3071
+ applyCreatedInteractiveSessionToConfig(config, partialSession, workerPaneIds);
3072
+ await saveTeamConfig(config, leaderCwd);
3073
+ const cleanupDebt = [
3074
+ ...error.cleanupErrors,
3075
+ ...error.proofUnavailable.map((proof) => `pane_proof_unavailable:${proof.paneId}:${proof.reason}`),
3076
+ ];
3077
+ if (cleanupDebt.length > 0) {
3078
+ await appendTeamEvent(sanitized, {
3079
+ type: 'team_leader_nudge',
3080
+ worker: 'leader-fixed',
3081
+ reason: `startup_create_session_cleanup_debt:${cleanupDebt.join(';')}; retry preserved session resources`,
3082
+ }, leaderCwd).catch(() => { });
3083
+ }
3084
+ // CreateTeamSessionPartialError means create-time teardown did not reach a
3085
+ // proven-gone state. Preserve its config, hook registration, state and
3086
+ // worktrees exactly as saved above for a later retry; generic rollback
3087
+ // would otherwise destroy the retry evidence.
3088
+ assertPaneTeardownProofsAvailable('startup_rollback', error.proofUnavailable);
3089
+ throw error;
3090
+ }
3091
+ if (config && error instanceof Error && error.message.startsWith('startup_worker_pane_identity_changed:')) {
3092
+ const startupCleanupDebt = config.workers.flatMap((worker) => {
3093
+ if (!worker.pane_id || typeof worker.pid !== 'number' || !Number.isSafeInteger(worker.pid) || worker.pid <= 0)
3094
+ return [];
3095
+ const proof = readExactPaneProofSync(worker.pane_id);
3096
+ return proof.status === 'live' && proof.pid !== worker.pid
3097
+ ? [{ status: 'unavailable', paneId: worker.pane_id, reason: 'pane_pid_changed', detail: `expected ${worker.pid}, got ${proof.pid}` }]
3098
+ : [];
3099
+ });
3100
+ if (startupCleanupDebt.length > 0) {
3101
+ await saveTeamConfig(config, leaderCwd);
3102
+ await appendTeamEvent(sanitized, {
3103
+ type: 'team_leader_nudge',
3104
+ worker: 'leader-fixed',
3105
+ reason: `startup_worker_pane_cleanup_debt:${startupCleanupDebt.map((proof) => `${proof.paneId}:${proof.reason}`).join(',')}; retry preserved session resources`,
3106
+ }, leaderCwd).catch(() => { });
3107
+ assertPaneTeardownProofsAvailable('startup_rollback', startupCleanupDebt);
3108
+ }
3109
+ }
2390
3110
  if (sessionCreated) {
2391
3111
  if (config?.resize_hook_name && config.resize_hook_target) {
2392
3112
  try {
@@ -2399,44 +3119,87 @@ export async function startTeam(teamName, task, agentType, workerCount, tasks, c
2399
3119
  rollbackErrors.push(`unregisterResizeHook: ${String(cleanupError)}`);
2400
3120
  }
2401
3121
  }
2402
- if (config) {
2403
- config.resize_hook_name = null;
2404
- config.resize_hook_target = null;
2405
- try {
2406
- await saveTeamConfig(config, leaderCwd);
2407
- }
2408
- catch (cleanupError) {
2409
- rollbackErrors.push(`saveTeamConfig(clear resize hook): ${String(cleanupError)}`);
2410
- }
2411
- }
2412
3122
  // In split-pane topology, we must not kill the entire tmux session; kill only created panes.
2413
3123
  if (sessionName.includes(':')) {
2414
- for (const [index, paneId] of createdWorkerPaneIds.entries()) {
2415
- const panePid = getWorkerPanePid(sessionName, index + 1, paneId);
2416
- if (panePid) {
2417
- await terminateTrackedProcessTree(panePid);
2418
- }
2419
- try {
2420
- await killWorkerByPaneIdAsync(paneId, createdLeaderPaneId);
3124
+ const paneProcessProofUnavailable = [];
3125
+ for (const paneId of createdWorkerPaneIds) {
3126
+ const teardown = await terminateExactPaneProcessTree(paneId, config?.workers.find((worker) => worker.pane_id === paneId)?.pid, undefined, undefined, authorizeStartupRollbackPaneEffect);
3127
+ if (teardown.proofUnavailable) {
3128
+ if (config)
3129
+ await persistGonePaneDescendantCleanupDebt({ teamName: sanitized, cwd: leaderCwd, config, paneId, teardown });
3130
+ paneProcessProofUnavailable.push(teardown.proofUnavailable);
3131
+ break;
2421
3132
  }
2422
- catch (err) {
2423
- process.stderr.write(`[team/runtime] operation failed: ${err}\n`);
3133
+ if (!teardown.terminated && teardown.stopped) {
3134
+ if (config)
3135
+ await persistGonePaneDescendantCleanupDebt({ teamName: sanitized, cwd: leaderCwd, config, paneId, teardown });
3136
+ paneProcessProofUnavailable.push({
3137
+ status: 'unavailable',
3138
+ paneId,
3139
+ reason: 'pane_proof_lost_during_process_teardown',
3140
+ detail: 'exact pane authority was lost before the tracked process tree was resolved',
3141
+ });
3142
+ break;
2424
3143
  }
2425
3144
  }
2426
- if (config?.hud_pane_id) {
2427
- try {
2428
- await killWorkerByPaneIdAsync(config.hud_pane_id, createdLeaderPaneId);
3145
+ if (paneProcessProofUnavailable.length > 0) {
3146
+ if (config)
3147
+ await saveTeamConfig(config, leaderCwd);
3148
+ assertPaneTeardownProofsAvailable('startup_rollback', paneProcessProofUnavailable);
3149
+ }
3150
+ const rollbackPanePids = Object.fromEntries((config?.workers ?? [])
3151
+ .filter((worker) => typeof worker.pane_id === 'string' && typeof worker.pid === 'number')
3152
+ .map((worker) => [worker.pane_id, worker.pid]));
3153
+ if (config?.hud_pane_id && typeof config.hud_pane_pid === 'number') {
3154
+ rollbackPanePids[config.hud_pane_id] = config.hud_pane_pid;
3155
+ }
3156
+ const rollbackPaneIds = [
3157
+ ...createdWorkerPaneIds,
3158
+ ...(config?.hud_pane_id && typeof config.hud_pane_pid === 'number' ? [config.hud_pane_id] : []),
3159
+ ].filter((paneId) => typeof paneId === 'string' && paneId.trim().startsWith('%'));
3160
+ const rollbackPaneTeardown = await teardownWorkerPanes(rollbackPaneIds, {
3161
+ leaderPaneId: createdLeaderPaneId,
3162
+ expectedPanePids: rollbackPanePids,
3163
+ authorizePaneKill: (paneId) => authorizeStartupRollbackPaneEffect(paneId),
3164
+ });
3165
+ const resolvedRollbackPaneIds = new Set([
3166
+ ...rollbackPaneTeardown.provenGonePaneIds,
3167
+ ...rollbackPaneTeardown.killedPaneIds,
3168
+ ]);
3169
+ if (config && (rollbackPaneTeardown.proofUnavailable.length > 0 || rollbackPaneTeardown.kill.failed > 0)) {
3170
+ config.workers = config.workers.filter((worker) => !worker.pane_id || !resolvedRollbackPaneIds.has(worker.pane_id));
3171
+ config.worker_count = config.workers.length;
3172
+ if (config.hud_pane_id && resolvedRollbackPaneIds.has(config.hud_pane_id)) {
3173
+ config.hud_pane_id = null;
3174
+ config.hud_pane_pid = null;
2429
3175
  }
2430
- catch (err) {
2431
- process.stderr.write(`[team/runtime] operation failed: ${err}\n`);
3176
+ }
3177
+ if (rollbackPaneTeardown.proofUnavailable.length > 0) {
3178
+ if (config) {
3179
+ try {
3180
+ await saveTeamConfig(config, leaderCwd);
3181
+ }
3182
+ catch (cleanupError) {
3183
+ rollbackErrors.push(`saveTeamConfig(preserve rollback state): ${String(cleanupError)}`);
3184
+ }
2432
3185
  }
3186
+ assertPaneTeardownProofsAvailable('startup_rollback', rollbackPaneTeardown.proofUnavailable);
3187
+ }
3188
+ if (rollbackPaneTeardown.kill.failed > 0) {
3189
+ if (config)
3190
+ await saveTeamConfig(config, leaderCwd);
3191
+ const originalMessage = error instanceof Error ? error.message : String(error);
3192
+ throw new Error(`${originalMessage}; rollback encountered errors: paneTeardown:${rollbackPaneTeardown.kill.failedPaneIds.join(',')}`);
2433
3193
  }
2434
3194
  }
2435
3195
  else {
2436
3196
  try {
2437
- destroyTeamSession(sessionName);
3197
+ if (!config)
3198
+ throw new Error('detached_session_destroy_authorization_unavailable:missing_config');
3199
+ await destroyConfiguredDetachedTeamSession(sanitized, leaderCwd, config);
2438
3200
  }
2439
3201
  catch (cleanupError) {
3202
+ preserveRollbackState = true;
2440
3203
  rollbackErrors.push(`destroyTeamSession: ${String(cleanupError)}`);
2441
3204
  }
2442
3205
  }
@@ -2474,11 +3237,21 @@ export async function startTeam(teamName, task, agentType, workerCount, tasks, c
2474
3237
  }
2475
3238
  }
2476
3239
  restoreTeamModelInstructionsFile(sanitized);
3240
+ let descendantCleanupDebtUnresolved = false;
2477
3241
  try {
2478
- await cleanupTeamState(sanitized, leaderCwd);
3242
+ await reconcileGonePaneDescendantCleanupDebt(sanitized, leaderCwd, config ?? undefined);
2479
3243
  }
2480
3244
  catch (cleanupError) {
2481
- rollbackErrors.push(`cleanupTeamState: ${String(cleanupError)}`);
3245
+ descendantCleanupDebtUnresolved = true;
3246
+ rollbackErrors.push(`reconcileGonePaneDescendantCleanupDebt: ${String(cleanupError)}`);
3247
+ }
3248
+ if (!descendantCleanupDebtUnresolved && !preserveRollbackState) {
3249
+ try {
3250
+ await cleanupTeamState(sanitized, leaderCwd);
3251
+ }
3252
+ catch (cleanupError) {
3253
+ rollbackErrors.push(`cleanupTeamState: ${String(cleanupError)}`);
3254
+ }
2482
3255
  }
2483
3256
  if (provisionedWorktrees.length > 0) {
2484
3257
  try {
@@ -2541,7 +3314,7 @@ export async function monitorTeam(teamName, cwd) {
2541
3314
  const workerSignals = await Promise.all(config.workers.map(async (worker) => {
2542
3315
  const alive = config.worker_launch_mode === 'prompt'
2543
3316
  ? isPromptWorkerAlive(config, worker)
2544
- : isWorkerAlive(sessionName, worker.index, worker.pane_id);
3317
+ : isWorkerAlive(sessionName, worker.index, worker.pane_id, worker.pid, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined);
2545
3318
  const [status, heartbeat] = await Promise.all([
2546
3319
  readWorkerStatus(sanitized, worker.name, cwd),
2547
3320
  readWorkerHeartbeat(sanitized, worker.name, cwd),
@@ -2791,8 +3564,8 @@ export async function assignTask(teamName, workerName, taskId, cwd) {
2791
3564
  if (outcome.ok)
2792
3565
  break;
2793
3566
  if (attempt < maxAssignRetries && config.worker_launch_mode === 'interactive' && config.tmux_session) {
2794
- if (dismissTrustPromptIfPresent(config.tmux_session, workerInfo.index, workerInfo.pane_id)) {
2795
- waitForWorkerReady(config.tmux_session, workerInfo.index, resolveWorkerReadyTimeoutMs(process.env), workerInfo.pane_id);
3567
+ if (dismissTrustPromptIfPresent(config.tmux_session, workerInfo.index, workerInfo.pane_id, workerInfo.pid, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined)) {
3568
+ waitForWorkerReady(config.tmux_session, workerInfo.index, resolveWorkerReadyTimeoutMs(process.env), workerInfo.pane_id, workerInfo.pid, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined);
2796
3569
  }
2797
3570
  else {
2798
3571
  await new Promise(r => setTimeout(r, assignRetryDelayS * 1000));
@@ -2857,20 +3630,62 @@ export async function shutdownTeam(teamName, cwd, options = {}) {
2857
3630
  const confirmIssues = options.confirmIssues === true;
2858
3631
  let skipWorkerAcks = false;
2859
3632
  const sanitized = resolveTeamNameForCurrentContext(teamName, cwd);
2860
- const config = await readTeamConfig(sanitized, cwd);
3633
+ let config = await readTeamConfig(sanitized, cwd);
2861
3634
  if (!config) {
2862
- // No config -- just try to kill tmux session and clean up
2863
- try {
2864
- destroyTeamSession(`omx-team-${sanitized}`);
2865
- }
2866
- catch (err) {
2867
- process.stderr.write(`[team/runtime] operation failed: ${err}\n`);
2868
- }
3635
+ await reconcileDetachedSessionDestroyReceipt(sanitized, cwd, null);
3636
+ await reconcileGonePaneDescendantCleanupDebt(sanitized, cwd);
3637
+ // A missing config is not authority over a conventionally named tmux
3638
+ // session. It may be another team's or a recycled user session.
2869
3639
  await cleanupTeamState(sanitized, cwd);
2870
3640
  await syncTeamModeStateOnShutdown(sanitized, cwd);
2871
3641
  restoreTeamModelInstructionsFile(sanitized);
2872
3642
  return { commitHygieneArtifacts: null };
2873
3643
  }
3644
+ // Reconcile accepted detached-session destruction before any other shutdown
3645
+ // effect. A receipt is the only durable authority across the kill/query crash
3646
+ // window and is deliberately fail-closed on malformed or unavailable input.
3647
+ await reconcileDetachedSessionDestroyReceipt(sanitized, cwd, config);
3648
+ config = await readTeamConfig(sanitized, cwd);
3649
+ if (!config)
3650
+ throw new Error(`shutdown_config_missing_after_detached_reconciliation:${sanitized}`);
3651
+ const restoredHudDebtRoot = join(config.team_state_root ?? resolveCanonicalTeamStateRoot(cwd), 'team', sanitized);
3652
+ const configuredRestoredHudPaneId = typeof config.hud_pane_id === 'string' && /^%[0-9]+$/.test(config.hud_pane_id)
3653
+ ? config.hud_pane_id
3654
+ : null;
3655
+ const configuredRestoredHudPanePid = typeof config.hud_pane_pid === 'number'
3656
+ && Number.isSafeInteger(config.hud_pane_pid)
3657
+ && config.hud_pane_pid > 0
3658
+ ? config.hud_pane_pid
3659
+ : null;
3660
+ if (configuredRestoredHudPaneId && configuredRestoredHudPanePid) {
3661
+ try {
3662
+ // A restored-HUD debt is finalized only when this canonical config
3663
+ // transaction records its exact frozen pane identity. A stale config may
3664
+ // point to a different HUD after a crash, so replay the pinned debt
3665
+ // rather than treating that mismatch as successful finalization.
3666
+ finalizeRestoredHudCleanupDebtSync(cwd, configuredRestoredHudPaneId, configuredRestoredHudPanePid, restoredHudDebtRoot, true);
3667
+ }
3668
+ catch (error) {
3669
+ if (!(error instanceof Error) || !error.message.startsWith('restored_hud_cleanup_debt_unresolved:')) {
3670
+ throw error;
3671
+ }
3672
+ // The config identity is not the debt identity. Remove it durably before
3673
+ // replaying the pinned obligation: a later detached-session teardown must
3674
+ // not treat a live, same-PID/owner non-HUD pane as the restored HUD.
3675
+ config = await mutateShutdownConfig(config, cwd, (current) => {
3676
+ current.hud_pane_id = null;
3677
+ current.hud_pane_pid = undefined;
3678
+ });
3679
+ reconcileRestoredHudCleanupDebtSync(cwd, restoredHudDebtRoot);
3680
+ }
3681
+ }
3682
+ else {
3683
+ reconcileRestoredHudCleanupDebtSync(cwd, restoredHudDebtRoot);
3684
+ }
3685
+ const priorScaleDownCleanup = await reconcileScaleDownCleanupDebt(sanitized, cwd, config);
3686
+ if (!priorScaleDownCleanup.ok)
3687
+ throw new Error(priorScaleDownCleanup.error);
3688
+ await reconcileGonePaneDescendantCleanupDebt(sanitized, cwd, config);
2874
3689
  const manifest = await readTeamManifestV2(sanitized, cwd);
2875
3690
  const leaderSessionId = typeof manifest?.leader?.session_id === 'string'
2876
3691
  ? manifest.leader.session_id.trim()
@@ -2909,6 +3724,19 @@ export async function shutdownTeam(teamName, cwd, options = {}) {
2909
3724
  skipWorkerAcks = true;
2910
3725
  }
2911
3726
  const sessionName = config.tmux_session;
3727
+ const sharedSessionTopology = config.worker_launch_mode === 'interactive' && sessionName.includes(':')
3728
+ ? resolveSharedSessionShutdownTopology(sessionName, config.leader_pane_id, sanitized)
3729
+ : null;
3730
+ if (sharedSessionTopology?.status === 'unavailable') {
3731
+ throw new Error(`shutdown_shared_session_topology_unavailable:${sharedSessionTopology.detail}`);
3732
+ }
3733
+ if (typeof config.hud_pane_id === 'string' && /^%[0-9]+$/.test(config.hud_pane_id)
3734
+ && typeof config.hud_pane_pid === 'number' && Number.isSafeInteger(config.hud_pane_pid) && config.hud_pane_pid > 0) {
3735
+ const persistedHudProof = readExactPaneProofSync(config.hud_pane_id);
3736
+ if (persistedHudProof.status === 'live' && persistedHudProof.pid !== config.hud_pane_pid) {
3737
+ throw new Error(`shutdown_shared_session_HUD_pane_identity_changed:${config.hud_pane_id}`);
3738
+ }
3739
+ }
2912
3740
  const dispatchPolicy = resolveDispatchPolicy(manifest?.policy, config.worker_launch_mode);
2913
3741
  const shutdownRequestTimes = new Map();
2914
3742
  if (!skipWorkerAcks) {
@@ -2964,7 +3792,7 @@ export async function shutdownTeam(teamName, cwd, options = {}) {
2964
3792
  }
2965
3793
  const anyAlive = config.workers.some((w) => (config.worker_launch_mode === 'prompt'
2966
3794
  ? isPromptWorkerAlive(config, w)
2967
- : isWorkerAlive(sessionName, w.index, w.pane_id)));
3795
+ : isWorkerAlive(sessionName, w.index, w.pane_id, w.pid, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined)));
2968
3796
  if (!anyAlive)
2969
3797
  break;
2970
3798
  // Sleep 2s
@@ -2972,7 +3800,7 @@ export async function shutdownTeam(teamName, cwd, options = {}) {
2972
3800
  }
2973
3801
  const anyAliveAfterWait = config.workers.some((w) => (config.worker_launch_mode === 'prompt'
2974
3802
  ? isPromptWorkerAlive(config, w)
2975
- : isWorkerAlive(sessionName, w.index, w.pane_id)));
3803
+ : isWorkerAlive(sessionName, w.index, w.pane_id, w.pid, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined)));
2976
3804
  if (anyAliveAfterWait && !force) {
2977
3805
  // Workers may have accepted shutdown but not exited (Codex TUI requires explicit exit).
2978
3806
  // In this case, proceed to force kill panes (next step) rather than failing and leaving state around.
@@ -2981,15 +3809,13 @@ export async function shutdownTeam(teamName, cwd, options = {}) {
2981
3809
  // 3. Force kill remaining workers
2982
3810
  const leaderPaneId = config.leader_pane_id;
2983
3811
  const hudPaneId = config.hud_pane_id;
3812
+ const leaderPanePid = config.leader_pane_pid;
3813
+ const hudPanePid = config.hud_pane_pid;
2984
3814
  if (config.worker_launch_mode === 'interactive') {
2985
- const sharedSessionTopology = sessionName.includes(':')
2986
- ? resolveSharedSessionShutdownTopology(sessionName, leaderPaneId, sanitized)
2987
- : null;
2988
- const effectiveLeaderPaneId = sharedSessionTopology ? sharedSessionTopology.leaderPaneId : leaderPaneId;
3815
+ const effectiveLeaderPaneId = sharedSessionTopology?.status === 'available'
3816
+ ? sharedSessionTopology.leaderPaneId
3817
+ : leaderPaneId;
2989
3818
  const tmuxPaneOwnerId = typeof config.tmux_pane_owner_id === 'string' ? config.tmux_pane_owner_id.trim() : '';
2990
- const legacyPersistedWorkerPaneIds = new Set(config.workers
2991
- .map((worker) => (typeof worker.pane_id === 'string' ? worker.pane_id.trim() : ''))
2992
- .filter((paneId) => paneId.startsWith('%')));
2993
3819
  const ownerReadWarnings = new Set();
2994
3820
  const warnOwnerReadError = (kind, paneId, error) => {
2995
3821
  const key = `${kind}:${paneId}:${error}`;
@@ -3005,57 +3831,253 @@ export async function shutdownTeam(teamName, cwd, options = {}) {
3005
3831
  legacyInstanceId: leaderSessionId,
3006
3832
  }) ? effectiveLeaderPaneId : null)
3007
3833
  : effectiveLeaderPaneId;
3008
- const effectiveHudPaneId = sharedSessionTopology
3009
- ? (sharedSessionTopology.hudPaneIds.find((paneId) => isSharedSessionHudPaneReclaimable({
3834
+ const reclaimableHudPaneIds = sharedSessionTopology
3835
+ ? sharedSessionTopology.hudPaneIds.filter((paneId) => isSharedSessionHudPaneReclaimable({
3010
3836
  paneId,
3011
3837
  persistedHudPaneId: hudPaneId,
3012
3838
  leaderOwnedHudPaneIds: sharedSessionTopology.leaderOwnedHudPaneIds,
3013
3839
  teamPaneOwnerId: tmuxPaneOwnerId,
3014
- onOwnerReadError: (hudPaneId, error) => warnOwnerReadError('HUD pane', hudPaneId, error),
3015
- })) ?? null)
3016
- : hudPaneId;
3017
- const shutdownCandidatePaneIds = sharedSessionTopology
3018
- ? filterSharedSessionShutdownWorkerPaneIdsByOwner(sharedSessionTopology.teamWorkerPaneIds, tmuxPaneOwnerId, legacyPersistedWorkerPaneIds, (paneId, error) => warnOwnerReadError('worker pane', paneId, error))
3019
- : listPaneIds(sessionName);
3020
- let shutdownPaneIds = collectShutdownPaneIds({
3021
- config,
3022
- candidatePaneIds: shutdownCandidatePaneIds,
3023
- includePersistedWorkerPaneIds: !sharedSessionTopology,
3024
- leaderPaneId: effectiveLeaderPaneId,
3025
- hudPaneId: effectiveHudPaneId,
3026
- });
3027
- if (shouldPrekillInteractiveShutdownProcessTrees(sessionName)) {
3028
- const workerPanePids = shutdownPaneIds
3029
- .map((paneId) => getWorkerPanePid(sessionName, 1, paneId))
3030
- .filter((pid) => typeof pid === 'number' && Number.isFinite(pid) && pid > 0);
3031
- for (const panePid of workerPanePids) {
3032
- await terminateTrackedProcessTree(panePid);
3840
+ onOwnerReadError: (candidateHudPaneId, error) => {
3841
+ warnOwnerReadError('HUD pane', candidateHudPaneId, error);
3842
+ },
3843
+ }))
3844
+ : (hudPaneId ? [hudPaneId] : []);
3845
+ const effectiveHudPaneId = reclaimableHudPaneIds[0] ?? null;
3846
+ const explicitPersistedWorkerPaneIds = new Set(config.workers
3847
+ .map((worker) => (typeof worker.pane_id === 'string' ? worker.pane_id.trim() : ''))
3848
+ .filter((paneId) => /^%[0-9]+$/.test(paneId))
3849
+ .filter((paneId) => paneId !== leaderPaneId && paneId !== hudPaneId));
3850
+ // Every persisted worker pane is a canonical shutdown member. Command markers
3851
+ // only discover additional candidates; they cannot erase canonical membership.
3852
+ const canonicalExplicitWorkerPaneIds = new Set(explicitPersistedWorkerPaneIds);
3853
+ const initiallyTaggedWorkerPaneIds = new Set();
3854
+ const authorizedDiscoveredWorkerPaneIds = sharedSessionTopology
3855
+ ? collectAuthorizedSharedSessionWorkerPaneIds([...new Set([
3856
+ ...canonicalExplicitWorkerPaneIds,
3857
+ ...sharedSessionTopology.teamWorkerPaneIds,
3858
+ ])], tmuxPaneOwnerId, canonicalExplicitWorkerPaneIds, undefined, initiallyTaggedWorkerPaneIds)
3859
+ : (sessionName ? listPaneIds(sessionName) : []);
3860
+ const excludedSharedWorkerPaneIds = new Set([effectiveLeaderPaneId, effectiveHudPaneId]
3861
+ .filter((paneId) => typeof paneId === 'string' && /^%[0-9]+$/.test(paneId)));
3862
+ const sharedWorkerPaneIds = [...new Set(authorizedDiscoveredWorkerPaneIds)];
3863
+ if (sharedSessionTopology && sharedWorkerPaneIds.some((paneId) => excludedSharedWorkerPaneIds.has(paneId))) {
3864
+ throw new Error(`shutdown_shared_session_worker_target_invalid:${sharedWorkerPaneIds.filter((paneId) => excludedSharedWorkerPaneIds.has(paneId)).join(',')}`);
3865
+ }
3866
+ // Freeze this union before any shared-session topology effect. HUD teardown
3867
+ // and restoration must never cause later worker target rediscovery.
3868
+ const shutdownPaneIds = sharedSessionTopology
3869
+ ? sharedWorkerPaneIds
3870
+ : collectShutdownPaneIds({
3871
+ config,
3872
+ candidatePaneIds: authorizedDiscoveredWorkerPaneIds,
3873
+ leaderPaneId: effectiveLeaderPaneId,
3874
+ hudPaneId: effectiveHudPaneId,
3875
+ });
3876
+ const canonicalWorkerPaneIds = [...shutdownPaneIds];
3877
+ const expectedSharedWorkerPanePids = new Map();
3878
+ const prekillResolvedWorkerPaneIds = new Set();
3879
+ const freezeSharedPaneAuthorization = (paneId, kind, expectedPid) => {
3880
+ const proof = readExactPaneProofSync(paneId);
3881
+ if (proof.status !== 'live') {
3882
+ throw new Error(`shutdown_shared_session_${kind.replaceAll(' ', '_')}_proof_unavailable:${paneId}`);
3883
+ }
3884
+ if (typeof expectedPid === 'number') {
3885
+ if (!Number.isSafeInteger(expectedPid) || expectedPid <= 0) {
3886
+ throw new Error(`shutdown_shared_session_${kind.replaceAll(' ', '_')}_pid_missing:${paneId}`);
3887
+ }
3888
+ if (proof.pid !== expectedPid) {
3889
+ throw new Error(`shutdown_shared_session_${kind.replaceAll(' ', '_')}_identity_changed:${paneId}`);
3890
+ }
3891
+ }
3892
+ const owner = readPaneTeamOwnerTagResult(paneId);
3893
+ if (owner.status === 'error') {
3894
+ throw new Error(`shutdown_shared_session_${kind.replaceAll(' ', '_')}_owner_unavailable:${paneId}:${owner.error}`);
3895
+ }
3896
+ if (owner.status !== 'value' || !tmuxPaneOwnerId || owner.value !== tmuxPaneOwnerId) {
3897
+ throw new Error(`shutdown_shared_session_${kind.replaceAll(' ', '_')}_owner_changed:${paneId}`);
3898
+ }
3899
+ return { paneId, pid: proof.pid, owner: owner.value };
3900
+ };
3901
+ const assertSharedPaneAuthorizationContinuous = (authorization, kind) => {
3902
+ const scope = sharedSessionTopology ? 'shared_session' : 'detached_session';
3903
+ const proof = readExactPaneProofSync(authorization.paneId);
3904
+ if (proof.status !== 'live' || proof.pid !== authorization.pid) {
3905
+ throw new Error(`shutdown_${scope}_${kind.replaceAll(' ', '_')}_identity_changed:${authorization.paneId}`);
3906
+ }
3907
+ const owner = readPaneTeamOwnerTagResult(authorization.paneId);
3908
+ if (owner.status === 'error') {
3909
+ throw new Error(`shutdown_${scope}_${kind.replaceAll(' ', '_')}_owner_unavailable:${authorization.paneId}:${owner.error}`);
3910
+ }
3911
+ const currentOwner = owner.status === 'value' ? owner.value : null;
3912
+ if (currentOwner !== authorization.owner) {
3913
+ throw new Error(`shutdown_${scope}_${kind.replaceAll(' ', '_')}_owner_changed:${authorization.paneId}`);
3033
3914
  }
3915
+ };
3916
+ const persistedHudPaneId = typeof hudPaneId === 'string' && /^%[0-9]+$/.test(hudPaneId)
3917
+ ? hudPaneId
3918
+ : null;
3919
+ const persistedLeaderPaneId = typeof leaderPaneId === 'string' && /^%[0-9]+$/.test(leaderPaneId)
3920
+ ? leaderPaneId
3921
+ : null;
3922
+ if (persistedHudPaneId && effectiveHudPaneId === persistedHudPaneId && (typeof hudPanePid !== 'number' || !Number.isSafeInteger(hudPanePid) || hudPanePid <= 0)) {
3923
+ const scope = sharedSessionTopology ? 'shared_session' : 'detached_session';
3924
+ throw new Error(`shutdown_${scope}_HUD_pane_pid_missing:${effectiveHudPaneId}`);
3034
3925
  }
3035
- let resizeHookWarning = null;
3036
- if (config.resize_hook_name && config.resize_hook_target) {
3037
- const resizeHookName = config.resize_hook_name;
3038
- const unregistered = unregisterResizeHook(config.resize_hook_target, resizeHookName);
3039
- if (!unregistered && isTmuxAvailable()) {
3040
- const baseSession = sessionName.split(':')[0];
3041
- const sessionStillActive = listTeamSessions().includes(baseSession);
3042
- if (sessionStillActive) {
3043
- resizeHookWarning = `failed to unregister resize hook ${resizeHookName}`;
3926
+ const frozenHudAuthorization = effectiveHudPaneId
3927
+ ? (sharedSessionTopology
3928
+ ? freezeSharedPaneAuthorization(effectiveHudPaneId, 'HUD pane', effectiveHudPaneId === persistedHudPaneId ? hudPanePid : undefined)
3929
+ : (() => {
3930
+ if (!persistedHudPaneId || effectiveHudPaneId !== persistedHudPaneId || !tmuxPaneOwnerId
3931
+ || typeof hudPanePid !== 'number' || !Number.isSafeInteger(hudPanePid) || hudPanePid <= 0) {
3932
+ throw new Error(`shutdown_detached_session_HUD_pane_authorization_unavailable:${effectiveHudPaneId}`);
3933
+ }
3934
+ const proof = readExactPaneProofSync(effectiveHudPaneId);
3935
+ if (proof.status !== 'live' || proof.pid !== hudPanePid) {
3936
+ throw new Error(`shutdown_detached_session_HUD_pane_identity_changed:${effectiveHudPaneId}`);
3937
+ }
3938
+ const owner = readPaneTeamOwnerTagResult(proof.paneId);
3939
+ if (owner.status !== 'value' || owner.value !== tmuxPaneOwnerId) {
3940
+ throw new Error(`shutdown_detached_session_HUD_pane_owner_changed:${effectiveHudPaneId}`);
3941
+ }
3942
+ const finalProof = readExactPaneProofSync(proof.paneId);
3943
+ if (finalProof.status !== 'live' || finalProof.pid !== hudPanePid) {
3944
+ throw new Error(`shutdown_detached_session_HUD_pane_identity_changed:${effectiveHudPaneId}`);
3945
+ }
3946
+ return { paneId: finalProof.paneId, pid: hudPanePid, owner: tmuxPaneOwnerId };
3947
+ })())
3948
+ : null;
3949
+ if (persistedLeaderPaneId && trustedHudRestoreLeaderPaneId === persistedLeaderPaneId && (typeof leaderPanePid !== 'number' || !Number.isSafeInteger(leaderPanePid) || leaderPanePid <= 0)) {
3950
+ throw new Error(`shutdown_shared_session_restore_leader_pane_pid_missing:${trustedHudRestoreLeaderPaneId}`);
3951
+ }
3952
+ const frozenRestoreLeaderAuthorization = sharedSessionTopology && trustedHudRestoreLeaderPaneId
3953
+ ? freezeSharedPaneAuthorization(trustedHudRestoreLeaderPaneId, 'restore leader pane', trustedHudRestoreLeaderPaneId === persistedLeaderPaneId ? leaderPanePid : undefined)
3954
+ : null;
3955
+ const assertCompleteSharedWorkerPaneProofs = (allowResolvedGone = false) => {
3956
+ const unavailable = readExactPaneProofsSync(canonicalWorkerPaneIds).flatMap((proof) => {
3957
+ if (proof.status !== 'live') {
3958
+ if (allowResolvedGone && proof.status === 'gone' && prekillResolvedWorkerPaneIds.has(proof.paneId))
3959
+ return [];
3960
+ return proof.status === 'unavailable'
3961
+ ? [proof]
3962
+ : [{ status: 'unavailable', paneId: proof.paneId, reason: 'pane_proof_lost_during_process_teardown' }];
3963
+ }
3964
+ const persistedPid = config.workers.find((worker) => worker.pane_id?.trim() === proof.paneId)?.pid;
3965
+ if (canonicalExplicitWorkerPaneIds.has(proof.paneId)
3966
+ && (typeof persistedPid !== 'number' || !Number.isSafeInteger(persistedPid) || persistedPid <= 0)) {
3967
+ return [{ status: 'unavailable', paneId: proof.paneId, reason: 'pane_pid_changed', detail: 'canonical persisted pane PID is missing' }];
3968
+ }
3969
+ const expectedPid = typeof persistedPid === 'number' ? persistedPid : expectedSharedWorkerPanePids.get(proof.paneId);
3970
+ if (typeof expectedPid === 'number' && proof.pid !== expectedPid) {
3971
+ return [{ status: 'unavailable', paneId: proof.paneId, reason: 'pane_pid_changed', detail: `expected ${expectedPid}, got ${proof.pid}` }];
3972
+ }
3973
+ expectedSharedWorkerPanePids.set(proof.paneId, proof.pid);
3974
+ return [];
3975
+ });
3976
+ assertPaneTeardownProofsAvailable('shutdown', unavailable);
3977
+ };
3978
+ const frozenSharedWorkerOwnerIds = new Map();
3979
+ if (sharedSessionTopology) {
3980
+ assertCompleteSharedWorkerPaneProofs();
3981
+ for (const paneId of canonicalWorkerPaneIds) {
3982
+ const owner = readPaneTeamOwnerTagResult(paneId);
3983
+ if (owner.status === 'error') {
3984
+ throw new Error(`shutdown_shared_session_worker_owner_unavailable:${paneId}:${owner.error}`);
3044
3985
  }
3986
+ if (owner.status !== 'value' || !tmuxPaneOwnerId || owner.value !== tmuxPaneOwnerId) {
3987
+ throw new Error(`shutdown_shared_session_worker_owner_changed:${paneId}`);
3988
+ }
3989
+ frozenSharedWorkerOwnerIds.set(paneId, owner.value);
3045
3990
  }
3046
3991
  }
3047
- config.resize_hook_name = null;
3048
- config.resize_hook_target = null;
3049
- await saveTeamConfig(config, cwd);
3050
- if (resizeHookWarning) {
3051
- console.warn(`[team shutdown] ${sanitized}: ${resizeHookWarning}; continuing teardown`);
3992
+ const authorizeFrozenSharedWorkerProcessSignal = (paneId, panePid) => {
3993
+ const expectedPid = expectedSharedWorkerPanePids.get(paneId)
3994
+ ?? config.workers.find((worker) => worker.pane_id === paneId)?.pid;
3995
+ if (expectedPid !== panePid)
3996
+ return false;
3997
+ if (sharedSessionTopology && frozenSharedWorkerOwnerIds.has(paneId)) {
3998
+ const expectedOwner = frozenSharedWorkerOwnerIds.get(paneId);
3999
+ const owner = readPaneTeamOwnerTagResult(paneId);
4000
+ return owner.status === 'value' && owner.value === expectedOwner && expectedOwner === tmuxPaneOwnerId;
4001
+ }
4002
+ const owner = readPaneTeamOwnerTagResult(paneId);
4003
+ return owner.status === 'value' && owner.value === tmuxPaneOwnerId;
4004
+ };
4005
+ if (shouldPrekillInteractiveShutdownProcessTrees(sessionName)) {
4006
+ const paneProcessProofUnavailable = [];
4007
+ for (const paneId of shutdownPaneIds) {
4008
+ const teardown = await terminateExactPaneProcessTree(paneId, config.workers.find((worker) => worker.pane_id === paneId)?.pid ?? expectedSharedWorkerPanePids.get(paneId), undefined, undefined, authorizeFrozenSharedWorkerProcessSignal);
4009
+ if (teardown.proofUnavailable) {
4010
+ await persistGonePaneDescendantCleanupDebt({ teamName: sanitized, cwd, config, paneId, teardown });
4011
+ paneProcessProofUnavailable.push(teardown.proofUnavailable);
4012
+ break;
4013
+ }
4014
+ if (!teardown.terminated && teardown.stopped) {
4015
+ await persistGonePaneDescendantCleanupDebt({ teamName: sanitized, cwd, config, paneId, teardown });
4016
+ paneProcessProofUnavailable.push({
4017
+ status: 'unavailable',
4018
+ paneId,
4019
+ reason: 'pane_proof_lost_during_process_teardown',
4020
+ detail: 'exact pane authority was lost before the tracked process tree was resolved',
4021
+ });
4022
+ break;
4023
+ }
4024
+ if (teardown.terminated && readExactPaneProofSync(paneId).status === 'gone') {
4025
+ prekillResolvedWorkerPaneIds.add(paneId);
4026
+ }
4027
+ }
4028
+ if (paneProcessProofUnavailable.length > 0) {
4029
+ assertPaneTeardownProofsAvailable('shutdown', paneProcessProofUnavailable);
4030
+ }
4031
+ }
4032
+ // Revalidate ownership of the frozen discovered members, then re-prove the
4033
+ // frozen complete worker set in one snapshot immediately before the first
4034
+ // HUD topology effect. This never admits a newly discovered pane.
4035
+ if (sharedSessionTopology) {
4036
+ const reauthorizedDiscoveredWorkerPaneIds = collectAuthorizedSharedSessionWorkerPaneIds(authorizedDiscoveredWorkerPaneIds, tmuxPaneOwnerId, canonicalExplicitWorkerPaneIds, initiallyTaggedWorkerPaneIds);
4037
+ if (reauthorizedDiscoveredWorkerPaneIds.length !== authorizedDiscoveredWorkerPaneIds.length
4038
+ || reauthorizedDiscoveredWorkerPaneIds.some((paneId) => !authorizedDiscoveredWorkerPaneIds.includes(paneId))) {
4039
+ throw new Error('shutdown_shared_session_worker_owner_changed');
4040
+ }
4041
+ assertCompleteSharedWorkerPaneProofs(true);
3052
4042
  }
3053
4043
  let restoredHudPaneId = null;
3054
4044
  if (effectiveHudPaneId) {
3055
- await killWorkerByPaneIdAsync(effectiveHudPaneId, effectiveLeaderPaneId ?? undefined);
4045
+ // Confirm restoration authority before removing the shared HUD, then
4046
+ // check it again immediately before the split below.
4047
+ if (sessionName.includes(':') && frozenRestoreLeaderAuthorization) {
4048
+ assertSharedPaneAuthorizationContinuous(frozenRestoreLeaderAuthorization, 'restore leader pane');
4049
+ }
4050
+ if (frozenHudAuthorization) {
4051
+ assertSharedPaneAuthorizationContinuous(frozenHudAuthorization, 'HUD pane');
4052
+ }
4053
+ const hudTeardown = await teardownWorkerPanes([effectiveHudPaneId], {
4054
+ leaderPaneId: effectiveLeaderPaneId,
4055
+ expectedPanePids: frozenHudAuthorization
4056
+ ? { [frozenHudAuthorization.paneId]: frozenHudAuthorization.pid }
4057
+ : undefined,
4058
+ authorizePaneKill: frozenHudAuthorization
4059
+ ? () => {
4060
+ assertSharedPaneAuthorizationContinuous(frozenHudAuthorization, 'HUD pane');
4061
+ return true;
4062
+ }
4063
+ : undefined,
4064
+ });
4065
+ assertPaneTeardownProofsAvailable('shutdown', hudTeardown.proofUnavailable);
4066
+ if (hudTeardown.kill.failed > 0) {
4067
+ throw new Error(`shutdown_pane_teardown_failed:${hudTeardown.kill.failedPaneIds.join(',')}`);
4068
+ }
3056
4069
  if (sessionName.includes(':')) {
4070
+ if (frozenRestoreLeaderAuthorization) {
4071
+ assertSharedPaneAuthorizationContinuous(frozenRestoreLeaderAuthorization, 'restore leader pane');
4072
+ }
3057
4073
  restoredHudPaneId = restoreStandaloneHudPane(trustedHudRestoreLeaderPaneId, cwd, {
3058
4074
  sessionId: leaderSessionId,
4075
+ expectedLeaderPanePid: frozenRestoreLeaderAuthorization?.pid,
4076
+ expectedLeaderPaneOwnerId: tmuxPaneOwnerId,
4077
+ stateRoot: join(config.team_state_root ?? resolveCanonicalTeamStateRoot(cwd), 'team', sanitized),
4078
+ assertLeaderPaneAuthorization: frozenRestoreLeaderAuthorization
4079
+ ? () => assertSharedPaneAuthorizationContinuous(frozenRestoreLeaderAuthorization, 'restore leader pane')
4080
+ : undefined,
3059
4081
  });
3060
4082
  if (!restoredHudPaneId) {
3061
4083
  const reason = trustedHudRestoreLeaderPaneId
@@ -3064,29 +4086,65 @@ export async function shutdownTeam(teamName, cwd, options = {}) {
3064
4086
  console.warn(`[team shutdown] ${sanitized}: ${reason}`);
3065
4087
  }
3066
4088
  }
4089
+ if (restoredHudPaneId) {
4090
+ const restoredHudProof = readExactPaneProofSync(restoredHudPaneId);
4091
+ if (restoredHudProof.status !== 'live') {
4092
+ throw new Error(`shutdown_restored_hud_pane_proof_unavailable:${restoredHudPaneId}`);
4093
+ }
4094
+ config = await mutateShutdownConfig(config, cwd, (current) => {
4095
+ current.hud_pane_id = restoredHudPaneId;
4096
+ current.hud_pane_pid = restoredHudProof.pid;
4097
+ });
4098
+ finalizeRestoredHudCleanupDebtSync(cwd, restoredHudPaneId, restoredHudProof.pid, join(config.team_state_root ?? resolveCanonicalTeamStateRoot(cwd), 'team', sanitized), true);
4099
+ }
3067
4100
  }
3068
- shutdownPaneIds = collectShutdownPaneIds({
3069
- config,
3070
- candidatePaneIds: sessionName.includes(':')
3071
- ? filterSharedSessionShutdownWorkerPaneIdsByOwner(resolveSharedSessionShutdownTopology(sessionName, effectiveLeaderPaneId, sanitized).teamWorkerPaneIds, tmuxPaneOwnerId, legacyPersistedWorkerPaneIds, (paneId, error) => warnOwnerReadError('worker pane', paneId, error))
3072
- : listPaneIds(sessionName),
3073
- includePersistedWorkerPaneIds: !sessionName.includes(':'),
3074
- restoredStandaloneHudPaneId: restoredHudPaneId,
3075
- leaderPaneId: effectiveLeaderPaneId,
3076
- hudPaneId: effectiveHudPaneId,
3077
- });
3078
- await teardownWorkerPanes(shutdownPaneIds, {
4101
+ let resizeHookWarning = null;
4102
+ if (config.resize_hook_name && config.resize_hook_target) {
4103
+ const resizeHookName = config.resize_hook_name;
4104
+ const unregistered = unregisterResizeHook(config.resize_hook_target, resizeHookName);
4105
+ if (!unregistered && isTmuxAvailable()) {
4106
+ const baseSession = sessionName.split(':')[0];
4107
+ const teamSessions = listTeamSessions();
4108
+ if (teamSessions === null || teamSessions.includes(baseSession)) {
4109
+ resizeHookWarning = `failed to unregister resize hook ${resizeHookName}`;
4110
+ }
4111
+ }
4112
+ }
4113
+ if (resizeHookWarning) {
4114
+ console.warn(`[team shutdown] ${sanitized}: ${resizeHookWarning}; continuing teardown`);
4115
+ }
4116
+ const workerTeardown = await teardownWorkerPanes(shutdownPaneIds.filter((paneId) => !prekillResolvedWorkerPaneIds.has(paneId)), {
3079
4117
  leaderPaneId: effectiveLeaderPaneId,
3080
4118
  hudPaneId: restoredHudPaneId ?? effectiveHudPaneId,
4119
+ expectedPanePids: {
4120
+ ...Object.fromEntries(expectedSharedWorkerPanePids),
4121
+ ...Object.fromEntries(config.workers
4122
+ .filter((worker) => typeof worker.pane_id === 'string' && typeof worker.pid === 'number')
4123
+ .map((worker) => [worker.pane_id, worker.pid])),
4124
+ },
4125
+ authorizePaneKill: sharedSessionTopology
4126
+ ? (paneId) => {
4127
+ const expectedOwner = frozenSharedWorkerOwnerIds.get(paneId);
4128
+ if (expectedOwner === undefined)
4129
+ return false;
4130
+ const currentOwner = readPaneTeamOwnerTagResult(paneId);
4131
+ return currentOwner.status === 'value'
4132
+ && currentOwner.value === expectedOwner
4133
+ && expectedOwner === tmuxPaneOwnerId;
4134
+ }
4135
+ : undefined,
3081
4136
  });
3082
- // 4. Destroy tmux session
4137
+ assertPaneTeardownProofsAvailable('shutdown', workerTeardown.proofUnavailable);
4138
+ if (workerTeardown.kill.failed > 0) {
4139
+ throw new Error(`shutdown_pane_teardown_failed:${workerTeardown.kill.failedPaneIds.join(',')}`);
4140
+ }
4141
+ config = await mutateShutdownConfig(config, cwd, (current) => {
4142
+ current.resize_hook_name = null;
4143
+ current.resize_hook_target = null;
4144
+ });
4145
+ // 4. Destroy a detached session only after an authoritative leader proof.
3083
4146
  if (!sessionName.includes(':')) {
3084
- try {
3085
- destroyTeamSession(sessionName);
3086
- }
3087
- catch (err) {
3088
- process.stderr.write(`[team/runtime] operation failed: ${err}\n`);
3089
- }
4147
+ await destroyConfiguredDetachedTeamSession(sanitized, cwd, config);
3090
4148
  }
3091
4149
  }
3092
4150
  else {
@@ -3229,7 +4287,7 @@ export async function resumeTeam(teamName, cwd) {
3229
4287
  .filter((worker) => {
3230
4288
  if (!Number.isFinite(worker.pid) || (worker.pid ?? 0) <= 0)
3231
4289
  return false;
3232
- return isPidAlive(worker.pid);
4290
+ return probePidLiveness(worker.pid) !== 'gone';
3233
4291
  })
3234
4292
  .filter((worker) => !getPromptWorkerHandle(sanitized, worker.name));
3235
4293
  if (missingHandles.length > 0) {
@@ -3261,7 +4319,10 @@ async function findActiveTeams(cwd, leaderSessionId) {
3261
4319
  const root = teamRuntimeTeamsRoot(cwd);
3262
4320
  if (!existsSync(root))
3263
4321
  return [];
3264
- const sessions = new Set(listTeamSessions());
4322
+ const listedSessions = listTeamSessions();
4323
+ if (listedSessions === null)
4324
+ throw new Error('tmux_session_query_unavailable');
4325
+ const sessions = new Set(listedSessions);
3265
4326
  const entries = await readdir(root, { withFileTypes: true });
3266
4327
  const active = [];
3267
4328
  for (const e of entries) {
@@ -3297,7 +4358,10 @@ async function detectAndCleanStaleTeam(teamName, leaderCwd, workerCount, confirm
3297
4358
  const stateDir = teamRuntimeTeamRoot(teamName, leaderCwd);
3298
4359
  if (!existsSync(stateDir))
3299
4360
  return;
3300
- const sessions = new Set(listTeamSessions());
4361
+ const listedSessions = listTeamSessions();
4362
+ if (listedSessions === null)
4363
+ return;
4364
+ const sessions = new Set(listedSessions);
3301
4365
  if (sessions.has(`omx-team-${teamName}`))
3302
4366
  return;
3303
4367
  const repoRootResult = spawnSync('git', ['rev-parse', '--show-toplevel'], {
@@ -3455,7 +4519,7 @@ async function notifyWorkerOutcome(config, workerIndex, message, workerPaneId) {
3455
4519
  return { ok: false, transport: 'tmux_send_keys', reason: 'tmux_unavailable' };
3456
4520
  }
3457
4521
  try {
3458
- await sendToWorker(config.tmux_session, workerIndex, message, workerPaneId, worker.worker_cli);
4522
+ await sendToWorker(config.tmux_session, workerIndex, message, workerPaneId, worker.worker_cli, worker.pid ?? undefined, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined);
3459
4523
  return { ok: true, transport: 'tmux_send_keys', reason: 'tmux_send_keys_sent' };
3460
4524
  }
3461
4525
  catch (error) {