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,8 +1,8 @@
1
1
  import { spawnSync, execFile } from 'child_process';
2
2
  import { promisify } from 'util';
3
- import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
3
+ import { closeSync, chmodSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'fs';
4
4
  import { homedir } from 'os';
5
- import { dirname, isAbsolute, join, resolve } from 'path';
5
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'path';
6
6
  import { CODEX_BYPASS_FLAG, CLAUDE_SKIP_PERMISSIONS_FLAG, MADMAX_FLAG, CONFIG_FLAG, LONG_CONFIG_FLAG, MODEL_FLAG, } from '../cli/constants.js';
7
7
  import { getAgent } from '../agents/definitions.js';
8
8
  import { buildCapturePaneArgv as sharedBuildCapturePaneArgv, buildVisibleCapturePaneArgv as sharedBuildVisibleCapturePaneArgv, normalizeTmuxCapture as sharedNormalizeTmuxCapture, paneHasActiveTask as sharedPaneHasActiveTask, paneIsBootstrapping as sharedPaneIsBootstrapping, paneShowsCodexViewport as sharedPaneShowsCodexViewport, paneLooksReady as sharedPaneLooksReady, } from '../scripts/tmux-hook-engine.js';
@@ -11,6 +11,8 @@ import { classifyTeamWorkerLaunchPolicy, extractModelProviderOverrideValue, norm
11
11
  import { sleep, sleepSync } from '../utils/sleep.js';
12
12
  import { buildPlatformCommandSpec, classifySpawnError, resolveCommandPathForPlatform, spawnPlatformCommandSync, } from '../utils/platform-command.js';
13
13
  import { resolveOmxCliEntryPath } from '../utils/paths.js';
14
+ import { readExactPaneProof, readExactPaneProofsSync, readExactPaneProofSync } from './exact-pane.js';
15
+ import { resolveCanonicalTeamStateRoot } from './state-root.js';
14
16
  const execFileAsync = promisify(execFile);
15
17
  import { HUD_RESIZE_RECONCILE_DELAY_SECONDS, HUD_TMUX_TEAM_HEIGHT_LINES } from '../hud/constants.js';
16
18
  import { OMX_TMUX_HUD_OWNER_ENV } from '../hud/reconcile.js';
@@ -18,6 +20,41 @@ import { findHudWatchPaneIds, hudPaneMatchesOwner, OMX_TMUX_HUD_LEADER_PANE_ENV
18
20
  const OMX_INSTANCE_OPTION = '@omx_instance_id';
19
21
  const OMX_PANE_INSTANCE_OPTION = '@omx_pane_instance_id';
20
22
  const OMX_TEAM_PANE_OWNER_OPTION = '@omx_team_pane_owner_id';
23
+ /** `kill(pid, 0)` establishes absence only on ESRCH. */
24
+ function probeProcessLiveness(pid) {
25
+ try {
26
+ process.kill(pid, 0);
27
+ return 'alive';
28
+ }
29
+ catch (error) {
30
+ return error.code === 'ESRCH' ? 'gone' : 'unknown';
31
+ }
32
+ }
33
+ /** Carries live tmux resources when creation cannot safely roll them back. */
34
+ export class CreateTeamSessionPartialError extends Error {
35
+ partialSession;
36
+ proofUnavailable;
37
+ originalError;
38
+ cleanupErrors;
39
+ constructor(partialSession, proofUnavailable, originalError,
40
+ /** Cleanup commands that failed after resources were created and must be retried. */
41
+ cleanupErrors = []) {
42
+ super('create_team_session_cleanup_incomplete');
43
+ this.partialSession = partialSession;
44
+ this.proofUnavailable = proofUnavailable;
45
+ this.originalError = originalError;
46
+ this.cleanupErrors = cleanupErrors;
47
+ this.name = 'CreateTeamSessionPartialError';
48
+ }
49
+ }
50
+ class ExactPaneProofUnavailableError extends Error {
51
+ proof;
52
+ constructor(proof) {
53
+ super(`exact_pane_proof_unavailable:${proof.paneId}:${proof.reason}`);
54
+ this.proof = proof;
55
+ this.name = 'ExactPaneProofUnavailableError';
56
+ }
57
+ }
21
58
  const INJECTION_MARKER = '[OMX_TMUX_INJECT]';
22
59
  const MODEL_INSTRUCTIONS_FILE_KEY = 'model_instructions_file';
23
60
  const OMX_BYPASS_DEFAULT_SYSTEM_PROMPT_ENV = 'OMX_BYPASS_DEFAULT_SYSTEM_PROMPT';
@@ -74,6 +111,206 @@ function runTmux(args) {
74
111
  }
75
112
  return { ok: true, stdout: (result.stdout || '').trim() };
76
113
  }
114
+ /** Preserve structured tmux fields, including an empty final field. */
115
+ /** Preserve structured tmux fields, including an empty final field. */
116
+ function runTmuxStructured(args) {
117
+ const { result } = spawnPlatformCommandSync('tmux', args, { encoding: 'utf-8' });
118
+ if (result.error)
119
+ return { ok: false, stderr: result.error.message };
120
+ if (result.status !== 0) {
121
+ return { ok: false, stderr: (result.stderr || '').trim() || `tmux exited ${result.status}` };
122
+ }
123
+ return { ok: true, stdout: (result.stdout || '').replace(/\r?\n$/, '') };
124
+ }
125
+ function restoredHudCleanupDebtPath(cwd, stateRoot) {
126
+ const canonicalStateRoot = resolveCanonicalTeamStateRoot(resolve(cwd));
127
+ if (typeof stateRoot !== 'string' || !stateRoot.trim()) {
128
+ return join(canonicalStateRoot, '.restored-hud-cleanup-debt.json');
129
+ }
130
+ const canonicalTeamsRoot = join(canonicalStateRoot, 'team');
131
+ const root = resolve(stateRoot);
132
+ const relativeRoot = relative(canonicalTeamsRoot, root);
133
+ if (!isAbsolute(stateRoot.trim())
134
+ || !relativeRoot
135
+ || relativeRoot === '..'
136
+ || relativeRoot.startsWith(`..${sep}`)
137
+ || isAbsolute(relativeRoot)
138
+ || relativeRoot.split(sep).length !== 1) {
139
+ throw new Error('restored_hud_cleanup_debt_state_root_invalid');
140
+ }
141
+ for (let candidate = root;; candidate = dirname(candidate)) {
142
+ try {
143
+ const metadata = lstatSync(candidate);
144
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
145
+ throw new Error('restored_hud_cleanup_debt_state_root_invalid');
146
+ }
147
+ }
148
+ catch (error) {
149
+ if (error.code !== 'ENOENT')
150
+ throw error;
151
+ }
152
+ if (candidate === canonicalTeamsRoot)
153
+ break;
154
+ }
155
+ return join(root, '.restored-hud-cleanup-debt.json');
156
+ }
157
+ function syncRestoredHudDebtParentSync(path) {
158
+ let descriptor;
159
+ try {
160
+ descriptor = openSync(dirname(path), 'r');
161
+ fsyncSync(descriptor);
162
+ }
163
+ catch (error) {
164
+ const code = error.code;
165
+ if (!(code === 'EPERM' && process.platform === 'win32'))
166
+ throw error;
167
+ }
168
+ finally {
169
+ if (descriptor !== undefined)
170
+ closeSync(descriptor);
171
+ }
172
+ }
173
+ function persistRestoredHudCleanupDebtSync(cwd, debt, stateRoot) {
174
+ const debtPath = restoredHudCleanupDebtPath(cwd, stateRoot);
175
+ mkdirSync(dirname(debtPath), { recursive: true });
176
+ // Recheck after mkdir: an untrusted Team root must still be canonical and
177
+ // non-symlinked immediately before the durable file mutation.
178
+ restoredHudCleanupDebtPath(cwd, stateRoot);
179
+ const temporaryPath = `${debtPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
180
+ writeFileSync(temporaryPath, `${JSON.stringify(debt)}\n`, { mode: 0o600 });
181
+ const temporaryDescriptor = openSync(temporaryPath, 'r');
182
+ try {
183
+ fsyncSync(temporaryDescriptor);
184
+ }
185
+ finally {
186
+ closeSync(temporaryDescriptor);
187
+ }
188
+ renameSync(temporaryPath, debtPath);
189
+ syncRestoredHudDebtParentSync(debtPath);
190
+ }
191
+ function parseRestoredHudCleanupDebtSync(cwd, stateRoot) {
192
+ const debtPath = restoredHudCleanupDebtPath(cwd, stateRoot);
193
+ if (!existsSync(debtPath))
194
+ return null;
195
+ let debt;
196
+ try {
197
+ debt = JSON.parse(readFileSync(debtPath, 'utf8'));
198
+ }
199
+ catch {
200
+ throw new Error('restored_hud_cleanup_debt_malformed');
201
+ }
202
+ if (debt.schema_version !== 1 || debt.operation !== 'restored_hud_cleanup'
203
+ || !hasExplicitWorkerPaneId(debt.pane_id)
204
+ || !hasExplicitWorkerPaneId(debt.leader_pane_id)
205
+ || !Number.isSafeInteger(debt.leader_pane_pid) || debt.leader_pane_pid <= 0
206
+ || !(typeof debt.leader_pane_owner_id === 'string' || debt.leader_pane_owner_id === null)
207
+ || debt.hud_owner_leader_pane_id !== debt.leader_pane_id
208
+ || (debt.pane_pid !== null && (!Number.isSafeInteger(debt.pane_pid) || debt.pane_pid <= 0))) {
209
+ throw new Error('restored_hud_cleanup_debt_malformed');
210
+ }
211
+ return { path: debtPath, debt };
212
+ }
213
+ function removeRestoredHudCleanupDebtSync(path) {
214
+ unlinkSync(path);
215
+ syncRestoredHudDebtParentSync(path);
216
+ }
217
+ /**
218
+ * Replays a prior restored-HUD obligation. A live pane is killable only when
219
+ * its recorded PID, HUD command identity, and tagged leader identity all
220
+ * still match; PID-less and ambiguous records remain durable debt.
221
+ */
222
+ export function reconcileRestoredHudCleanupDebtSync(cwd, stateRoot) {
223
+ const record = parseRestoredHudCleanupDebtSync(cwd, stateRoot);
224
+ if (!record)
225
+ return;
226
+ const { path, debt } = record;
227
+ const proof = readExactPaneProofSync(debt.pane_id);
228
+ if (proof.status === 'gone') {
229
+ removeRestoredHudCleanupDebtSync(path);
230
+ return;
231
+ }
232
+ if (proof.status !== 'live' || debt.pane_pid === null || proof.pid !== debt.pane_pid || !debt.leader_pane_owner_id) {
233
+ throw new Error(`restored_hud_cleanup_debt_unresolved:${debt.pane_id}`);
234
+ }
235
+ const leader = requireLiveTeamOwnedPaneSync(debt.leader_pane_id, debt.leader_pane_pid, debt.leader_pane_owner_id);
236
+ const topology = listPanesResult(leader);
237
+ const hudMatches = !topology.error && topology.panes.filter((pane) => pane.paneId === proof.paneId
238
+ && hudPaneMatchesOwner(pane, { leaderPaneId: debt.hud_owner_leader_pane_id })).length === 1;
239
+ if (!hudMatches)
240
+ throw new Error(`restored_hud_cleanup_debt_unresolved:${debt.pane_id}`);
241
+ killExactPaneSync(proof.paneId, debt.pane_pid, () => {
242
+ requireLiveTeamOwnedPaneSync(debt.leader_pane_id, debt.leader_pane_pid, debt.leader_pane_owner_id);
243
+ });
244
+ removeRestoredHudCleanupDebtSync(path);
245
+ }
246
+ /** Remove restored-HUD debt only after the canonical config transaction commits the same frozen identity. */
247
+ export function finalizeRestoredHudCleanupDebtSync(cwd, paneId, panePid, stateRoot, _requireFreshHudIdentity) {
248
+ const record = parseRestoredHudCleanupDebtSync(cwd, stateRoot);
249
+ if (!record)
250
+ return;
251
+ const { debt } = record;
252
+ if (debt.pane_id !== paneId || debt.pane_pid !== panePid || !debt.leader_pane_owner_id) {
253
+ throw new Error(`restored_hud_cleanup_debt_unresolved:${debt.pane_id}`);
254
+ }
255
+ const leader = requireLiveTeamOwnedPaneSync(debt.leader_pane_id, debt.leader_pane_pid, debt.leader_pane_owner_id);
256
+ const topology = listPanesResult(leader);
257
+ const hudMatches = !topology.error && topology.panes.filter((pane) => pane.paneId === debt.pane_id
258
+ && hudPaneMatchesOwner(pane, { leaderPaneId: debt.hud_owner_leader_pane_id })).length === 1;
259
+ if (!hudMatches)
260
+ throw new Error(`restored_hud_cleanup_debt_unresolved:${debt.pane_id}`);
261
+ const finalProof = readExactPaneProofSync(debt.pane_id);
262
+ if (finalProof.status !== 'live' || finalProof.pid !== debt.pane_pid) {
263
+ throw new Error(`restored_hud_cleanup_debt_unresolved:${debt.pane_id}`);
264
+ }
265
+ removeRestoredHudCleanupDebtSync(record.path);
266
+ }
267
+ /**
268
+ * Kill only a pane that a fresh global snapshot proves live. Callers treat
269
+ * gone/dead rows as already cleaned and unavailable snapshots as fail-closed.
270
+ */
271
+ function requireLiveExactPaneSync(paneId, expectedPid) {
272
+ const proof = readExactPaneProofSync(paneId);
273
+ if (proof.status === 'unavailable')
274
+ throw new ExactPaneProofUnavailableError(proof);
275
+ if (proof.status === 'gone')
276
+ throw new Error(`tmux pane is not proven live: ${paneId}`);
277
+ if (expectedPid !== undefined && proof.pid !== expectedPid) {
278
+ throw new Error(`tmux pane identity changed: ${paneId}`);
279
+ }
280
+ return proof.paneId;
281
+ }
282
+ function killExactPaneSync(paneId, expectedPid, assertAuthorization) {
283
+ const proof = readExactPaneProofSync(paneId);
284
+ if (proof.status === 'unavailable')
285
+ throw new ExactPaneProofUnavailableError(proof);
286
+ if (proof.status === 'gone')
287
+ return;
288
+ if (expectedPid !== undefined && proof.pid !== expectedPid) {
289
+ throw new Error(`tmux pane identity changed: ${paneId}`);
290
+ }
291
+ assertAuthorization?.();
292
+ // Authorization can read tmux. Re-prove immediately after it so a recycled
293
+ // pane ID cannot be targeted by the subsequent kill.
294
+ const finalProof = readExactPaneProofSync(proof.paneId);
295
+ if (finalProof.status === 'unavailable')
296
+ throw new ExactPaneProofUnavailableError(finalProof);
297
+ if (finalProof.status === 'gone')
298
+ return;
299
+ if (finalProof.pid !== proof.pid || (expectedPid !== undefined && finalProof.pid !== expectedPid)) {
300
+ throw new Error(`tmux pane identity changed: ${paneId}`);
301
+ }
302
+ const result = runTmux(['kill-pane', '-t', finalProof.paneId]);
303
+ if (!result.ok)
304
+ throw new Error(`failed to kill tmux pane ${finalProof.paneId}: ${result.stderr}`);
305
+ const afterKill = readExactPaneProofSync(finalProof.paneId);
306
+ if (afterKill.status === 'unavailable')
307
+ throw new ExactPaneProofUnavailableError(afterKill);
308
+ if (afterKill.status !== 'gone') {
309
+ if (afterKill.pid !== finalProof.pid)
310
+ throw new Error(`tmux pane identity changed: ${paneId}`);
311
+ throw new Error(`tmux pane remains live after kill: ${paneId}`);
312
+ }
313
+ }
77
314
  function appendNoUnderlineStyleFlags(style) {
78
315
  const normalized = style
79
316
  .split(/[,\s]+/)
@@ -86,7 +323,8 @@ function appendNoUnderlineStyleFlags(style) {
86
323
  }
87
324
  return combined.join(',');
88
325
  }
89
- function sanitizeTmuxStyleOption(sessionTarget, optionName) {
326
+ function sanitizeTmuxStyleOption(sessionTarget, optionName, beforeEffect) {
327
+ beforeEffect?.();
90
328
  const shown = runTmux(['show-options', '-gv', '-t', sessionTarget, optionName]);
91
329
  if (!shown.ok)
92
330
  return false;
@@ -96,36 +334,39 @@ function sanitizeTmuxStyleOption(sessionTarget, optionName) {
96
334
  const sanitized = appendNoUnderlineStyleFlags(current);
97
335
  if (sanitized === current)
98
336
  return true;
337
+ beforeEffect?.();
99
338
  const result = runTmux(['set-option', '-t', sessionTarget, optionName, sanitized]);
100
339
  return result.ok;
101
340
  }
102
- function tagPaneInstance(paneTarget, instanceId) {
341
+ function tagPaneInstance(paneTarget, instanceId, expectedPanePid) {
103
342
  const target = paneTarget.trim();
104
343
  const sanitized = instanceId.trim();
105
344
  if (!target || !sanitized)
106
345
  return;
107
- const result = runTmux(['set-option', '-p', '-t', target, OMX_PANE_INSTANCE_OPTION, sanitized]);
346
+ const provenTarget = requireLiveExactPaneSync(target, expectedPanePid);
347
+ const result = runTmux(['set-option', '-p', '-t', provenTarget, OMX_PANE_INSTANCE_OPTION, sanitized]);
108
348
  if (!result.ok) {
109
- throw new Error(`failed to tag tmux pane ${target}: ${result.stderr}`);
349
+ throw new Error(`failed to tag tmux pane ${provenTarget}: ${result.stderr}`);
110
350
  }
111
351
  }
112
- export function tagPaneTeamOwner(paneTarget, teamOwnerId) {
352
+ export function tagPaneTeamOwner(paneTarget, teamOwnerId, expectedPanePid) {
113
353
  const target = paneTarget.trim();
114
354
  const sanitized = teamOwnerId.trim();
115
355
  if (!target || !sanitized)
116
356
  return;
117
- const result = runTmux(['set-option', '-p', '-t', target, OMX_TEAM_PANE_OWNER_OPTION, sanitized]);
357
+ const provenTarget = requireLiveExactPaneSync(target, expectedPanePid);
358
+ const result = runTmux(['set-option', '-p', '-t', provenTarget, OMX_TEAM_PANE_OWNER_OPTION, sanitized]);
118
359
  if (!result.ok) {
119
- throw new Error(`failed to tag tmux pane ${target}: ${result.stderr}`);
360
+ throw new Error(`failed to tag tmux pane ${provenTarget}: ${result.stderr}`);
120
361
  }
121
362
  }
122
- export function mitigateCopyModeUnderlineArtifacts(sessionTarget) {
363
+ export function mitigateCopyModeUnderlineArtifacts(sessionTarget, beforeEffect) {
123
364
  const normalizedTarget = sessionTarget.trim();
124
365
  if (normalizedTarget === '')
125
366
  return false;
126
367
  let applied = false;
127
368
  for (const optionName of TMUX_COPY_MODE_STYLE_OPTIONS) {
128
- if (sanitizeTmuxStyleOption(normalizedTarget, optionName)) {
369
+ if (sanitizeTmuxStyleOption(normalizedTarget, optionName, beforeEffect)) {
129
370
  applied = true;
130
371
  }
131
372
  }
@@ -181,18 +422,97 @@ function baseSessionName(target) {
181
422
  return target.split(':')[0] || target;
182
423
  }
183
424
  function listPanes(target) {
184
- const result = runTmux(['list-panes', '-t', target, '-F', '#{pane_id}\t#{pane_current_command}\t#{pane_start_command}']);
425
+ return listPanesResult(target).panes;
426
+ }
427
+ function listPanesResult(target) {
428
+ const result = runTmuxStructured(['list-panes', '-t', target, '-F', '#{pane_id}\t#{pane_current_command}\t#{pane_start_command}']);
185
429
  if (!result.ok)
186
- return [];
187
- return result.stdout
188
- .split('\n')
189
- .map((line) => line.trim())
190
- .filter((line) => line.length > 0)
191
- .map((line) => {
192
- const [paneId = '', currentCommand = '', startCommand = ''] = line.split('\t');
193
- return { paneId, currentCommand, startCommand };
194
- })
195
- .filter((pane) => pane.paneId.startsWith('%'));
430
+ return { panes: [], error: result.stderr };
431
+ const panes = [];
432
+ for (const rawLine of result.stdout.split('\n')) {
433
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
434
+ if (line.length === 0)
435
+ continue;
436
+ const firstSeparator = line.indexOf('\t');
437
+ const secondSeparator = firstSeparator >= 0 ? line.indexOf('\t', firstSeparator + 1) : -1;
438
+ const paneId = firstSeparator >= 0 ? line.slice(0, firstSeparator) : line;
439
+ if (firstSeparator < 0 || secondSeparator < 0 || !/^%[0-9]+$/.test(paneId)) {
440
+ return { panes: [], error: 'malformed pane topology' };
441
+ }
442
+ if (panes.some((pane) => pane.paneId === paneId)) {
443
+ return { panes: [], error: 'malformed pane topology' };
444
+ }
445
+ panes.push({
446
+ paneId,
447
+ currentCommand: line.slice(firstSeparator + 1, secondSeparator),
448
+ startCommand: line.slice(secondSeparator + 1),
449
+ });
450
+ }
451
+ return { panes, error: null };
452
+ }
453
+ /**
454
+ * Window-wide layout effects may only touch the exact startup pane set. A
455
+ * target-scoped topology read rejects foreign/new panes, and targeted global
456
+ * snapshots pin every surviving pane identity. The final scoped read happens
457
+ * immediately before the mutation so panes added during owner reads cannot
458
+ * inherit that authority.
459
+ */
460
+ /**
461
+ * Re-prove an explicitly Team-owned pane immediately before an effect. The
462
+ * double PID proof keeps ownership and process identity continuous across an
463
+ * untrusted tmux option read.
464
+ */
465
+ function requireLiveTeamOwnedPaneSync(paneId, expectedPanePid, expectedTeamOwnerId) {
466
+ const target = requireLiveExactPaneSync(paneId, expectedPanePid);
467
+ const owner = readPaneTeamOwnerTagResult(target);
468
+ if (owner.status !== 'value' || owner.value !== expectedTeamOwnerId) {
469
+ const detail = owner.status === 'error' ? owner.error : 'missing';
470
+ throw new Error(`tmux pane team owner changed: ${target}: ${detail}`);
471
+ }
472
+ return requireLiveExactPaneSync(target, expectedPanePid);
473
+ }
474
+ function requireFrozenWindowTopologySync(teamTarget, expectedPanePids, expectedPaneOwners) {
475
+ const topology = listPanesResult(teamTarget);
476
+ if (topology.error)
477
+ throw new Error(`failed to read tmux pane topology: ${topology.error}`);
478
+ const actualPaneIds = new Set(topology.panes.map((pane) => pane.paneId));
479
+ if (actualPaneIds.size !== expectedPanePids.size
480
+ || [...expectedPanePids.keys()].some((paneId) => !actualPaneIds.has(paneId))) {
481
+ throw new Error('tmux window topology changed before layout mutation');
482
+ }
483
+ for (const [paneId, expectedPanePid] of expectedPanePids) {
484
+ const expectedOwner = expectedPaneOwners?.get(paneId);
485
+ if (expectedOwner)
486
+ requireLiveTeamOwnedPaneSync(paneId, expectedPanePid, expectedOwner);
487
+ else {
488
+ const proof = readExactPaneProofSync(paneId);
489
+ if (proof.status === 'unavailable')
490
+ throw new ExactPaneProofUnavailableError(proof);
491
+ if (proof.status === 'gone')
492
+ throw new Error(`tmux pane is not proven live: ${proof.paneId}`);
493
+ if (proof.pid !== expectedPanePid)
494
+ throw new Error(`tmux pane identity changed: ${proof.paneId}`);
495
+ }
496
+ }
497
+ const finalProofs = readExactPaneProofsSync([...expectedPanePids.keys()]);
498
+ for (const [index, [paneId, expectedPanePid]] of [...expectedPanePids.entries()].entries()) {
499
+ const proof = finalProofs[index];
500
+ if (proof.status === 'unavailable')
501
+ throw new ExactPaneProofUnavailableError(proof);
502
+ if (proof.status === 'gone')
503
+ throw new Error(`tmux pane is not proven live: ${proof.paneId}`);
504
+ if (proof.paneId !== paneId || proof.pid !== expectedPanePid) {
505
+ throw new Error(`tmux pane identity changed: ${paneId}`);
506
+ }
507
+ }
508
+ const finalTopology = listPanesResult(teamTarget);
509
+ if (finalTopology.error)
510
+ throw new Error(`failed to read tmux pane topology: ${finalTopology.error}`);
511
+ const finalPaneIds = new Set(finalTopology.panes.map((pane) => pane.paneId));
512
+ if (finalPaneIds.size !== expectedPanePids.size
513
+ || [...expectedPanePids.keys()].some((paneId) => !finalPaneIds.has(paneId))) {
514
+ throw new Error('tmux window topology changed before layout mutation');
515
+ }
196
516
  }
197
517
  export function listPaneIds(target) {
198
518
  return listPanes(target).map((pane) => pane.paneId);
@@ -237,12 +557,6 @@ export function chooseTeamLeaderPaneId(panes, preferredPaneId) {
237
557
  return nonHud.paneId;
238
558
  return preferredPaneId;
239
559
  }
240
- function findHudPaneIds(target, leaderPaneId) {
241
- return findHudWatchPaneIds(listPanes(target), leaderPaneId, { leaderPaneId });
242
- }
243
- function findOwnedHudPaneIds(target, leaderPaneId) {
244
- return findHudWatchPaneIds(listPanes(target), leaderPaneId, { leaderPaneId });
245
- }
246
560
  function readPaneCurrentPath(paneId) {
247
561
  if (!paneId.startsWith('%'))
248
562
  return null;
@@ -281,7 +595,8 @@ function shouldAttemptRestoreCwdCandidate(candidate) {
281
595
  }
282
596
  return pathIsUsableDirectory(candidate.rawPath);
283
597
  }
284
- function resolveStandaloneHudRestoreCwdCandidates(leaderPaneId, fallbackCwd, explicitCwd) {
598
+ function resolveStandaloneHudRestoreCwdCandidates(leaderPaneId, fallbackCwd, explicitCwd, beforeReadLiveLeaderCwd) {
599
+ beforeReadLiveLeaderCwd?.();
285
600
  const liveLeaderCwd = readPaneCurrentPath(leaderPaneId);
286
601
  return uniqueRestoreCwdCandidates([
287
602
  { source: 'explicit', rawPath: explicitCwd },
@@ -318,105 +633,161 @@ async function runTmuxAsync(args) {
318
633
  return { ok: false, stderr: (err.stderr || err.message || '').trim() || 'tmux command failed' };
319
634
  }
320
635
  }
321
- async function sendKeyAsync(target, key) {
636
+ function hasExplicitWorkerPaneId(workerPaneId) {
637
+ return typeof workerPaneId === 'string' && workerPaneId.trim().length > 0;
638
+ }
639
+ function resolveWorkerPaneTargetSync(sessionName, workerIndex, workerPaneId) {
640
+ if (!hasExplicitWorkerPaneId(workerPaneId))
641
+ return paneTarget(sessionName, workerIndex);
642
+ const proof = readExactPaneProofSync(workerPaneId);
643
+ return proof.status === 'live' ? proof.paneId : null;
644
+ }
645
+ async function resolveWorkerPaneTargetAsync(sessionName, workerIndex, workerPaneId) {
646
+ if (!hasExplicitWorkerPaneId(workerPaneId))
647
+ return paneTarget(sessionName, workerIndex);
648
+ const proof = await readExactPaneProof(workerPaneId);
649
+ return proof.status === 'live' ? proof.paneId : null;
650
+ }
651
+ function createPinnedWorkerPaneTargetResolverSync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
652
+ if (!hasExplicitWorkerPaneId(workerPaneId)) {
653
+ return () => resolveWorkerPaneTargetSync(sessionName, workerIndex, workerPaneId);
654
+ }
655
+ const pinnedPid = expectedPanePid;
656
+ const expectedOwner = typeof expectedTeamOwnerId === 'string' ? expectedTeamOwnerId.trim() : '';
657
+ const canonicalHudPaneId = typeof hudPaneId === 'string' ? hudPaneId.trim() : '';
658
+ return () => {
659
+ if (typeof pinnedPid !== 'number' || !Number.isSafeInteger(pinnedPid) || pinnedPid <= 0)
660
+ return null;
661
+ if (!expectedOwner)
662
+ return null;
663
+ if (canonicalHudPaneId && workerPaneId === canonicalHudPaneId) {
664
+ throw new Error(`tmux worker pane is HUD target: ${workerPaneId}`);
665
+ }
666
+ const proof = readExactPaneProofSync(workerPaneId);
667
+ if (proof.status !== 'live')
668
+ return null;
669
+ if (proof.pid !== pinnedPid) {
670
+ throw new Error(`tmux pane identity changed: ${workerPaneId}`);
671
+ }
672
+ const owner = readPaneTeamOwnerTagResult(proof.paneId);
673
+ if (owner.status !== 'value' || owner.value !== expectedOwner)
674
+ return null;
675
+ const finalProof = readExactPaneProofSync(workerPaneId);
676
+ if (finalProof.status !== 'live')
677
+ return null;
678
+ if (finalProof.pid !== pinnedPid) {
679
+ throw new Error(`tmux pane identity changed: ${workerPaneId}`);
680
+ }
681
+ return finalProof.paneId;
682
+ };
683
+ }
684
+ function createPinnedWorkerPaneTargetResolver(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
685
+ if (!hasExplicitWorkerPaneId(workerPaneId)) {
686
+ return () => resolveWorkerPaneTargetAsync(sessionName, workerIndex, workerPaneId);
687
+ }
688
+ const pinnedPid = expectedPanePid;
689
+ const expectedOwner = typeof expectedTeamOwnerId === 'string' ? expectedTeamOwnerId.trim() : '';
690
+ const canonicalHudPaneId = typeof hudPaneId === 'string' ? hudPaneId.trim() : '';
691
+ let lastResolvedPid;
692
+ const resolveTarget = async () => {
693
+ if (typeof pinnedPid !== 'number' || !Number.isSafeInteger(pinnedPid) || pinnedPid <= 0)
694
+ return null;
695
+ if (!expectedOwner)
696
+ return null;
697
+ if (canonicalHudPaneId && workerPaneId === canonicalHudPaneId) {
698
+ throw new Error(`tmux worker pane is HUD target: ${workerPaneId}`);
699
+ }
700
+ const proof = await readExactPaneProof(workerPaneId);
701
+ if (proof.status !== 'live')
702
+ return null;
703
+ if (proof.pid !== pinnedPid) {
704
+ throw new Error(`tmux pane identity changed: ${workerPaneId}`);
705
+ }
706
+ const owner = readPaneTeamOwnerTagResult(proof.paneId);
707
+ if (owner.status !== 'value' || owner.value !== expectedOwner) {
708
+ const detail = owner.status === 'error' ? owner.error : 'missing';
709
+ throw new Error(`tmux pane team owner changed: ${workerPaneId}: ${detail}`);
710
+ }
711
+ // The option read is untrusted and can race pane ID reuse. Its immediately
712
+ // adjacent PID proof authorizes the following capture/control/input effect.
713
+ const finalProof = await readExactPaneProof(workerPaneId);
714
+ if (finalProof.status !== 'live')
715
+ return null;
716
+ if (finalProof.pid !== pinnedPid) {
717
+ throw new Error(`tmux pane identity changed: ${workerPaneId}`);
718
+ }
719
+ lastResolvedPid = finalProof.pid;
720
+ return finalProof.paneId;
721
+ };
722
+ resolveTarget.lastResolvedPid = () => lastResolvedPid;
723
+ return resolveTarget;
724
+ }
725
+ async function requireAsyncPaneTarget(resolveTarget) {
726
+ const target = await resolveTarget();
727
+ if (!target)
728
+ throw new Error('tmux pane is not proven live');
729
+ return target;
730
+ }
731
+ async function sendKeyAsync(resolveTarget, key) {
732
+ const target = await requireAsyncPaneTarget(resolveTarget);
322
733
  const result = await runTmuxAsync(['send-keys', '-t', target, key]);
323
734
  if (!result.ok) {
324
735
  throw new Error(`sendKeyAsync: failed to send ${key}: ${result.stderr}`);
325
736
  }
326
737
  }
327
- async function capturePaneAsync(target) {
738
+ async function capturePaneAsync(resolveTarget) {
739
+ const target = await resolveTarget();
740
+ if (!target)
741
+ return '';
328
742
  const result = await runTmuxAsync(sharedBuildCapturePaneArgv(target, 80));
329
743
  if (!result.ok)
330
744
  return '';
331
745
  return result.stdout;
332
746
  }
333
- async function captureVisiblePaneAsync(target) {
747
+ /** Capture an explicit Team worker pane only after PID-owner-PID authorization. */
748
+ export async function captureWorkerPane(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
749
+ const resolveTarget = createPinnedWorkerPaneTargetResolver(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId);
750
+ return capturePaneAsync(resolveTarget);
751
+ }
752
+ async function captureVisiblePaneAsync(resolveTarget) {
753
+ const target = await resolveTarget();
754
+ if (!target)
755
+ return '';
334
756
  const result = await runTmuxAsync(sharedBuildVisibleCapturePaneArgv(target));
335
757
  if (!result.ok)
336
758
  return '';
337
759
  return result.stdout;
338
760
  }
339
- async function isWorkerAliveAsync(sessionName, workerIndex, workerPaneId) {
340
- if (workerPaneId?.startsWith('%')) {
341
- const paneStatus = await readPaneLivenessByIdAsync(workerPaneId);
342
- if (paneStatus !== null)
343
- return paneStatus;
761
+ async function isWorkerAliveAsync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId, resolveTarget) {
762
+ if (hasExplicitWorkerPaneId(workerPaneId)) {
763
+ const resolver = resolveTarget ?? createPinnedWorkerPaneTargetResolver(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId);
764
+ const target = await resolver();
765
+ const pid = resolver.lastResolvedPid?.() ?? expectedPanePid;
766
+ if (!target || typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0)
767
+ return 'unknown';
768
+ return probeProcessLiveness(pid);
344
769
  }
345
770
  const result = await runTmuxAsync([
346
771
  'list-panes',
347
- '-t', paneTarget(sessionName, workerIndex, workerPaneId),
772
+ '-t', paneTarget(sessionName, workerIndex),
348
773
  '-F',
349
774
  '#{pane_dead} #{pane_pid}',
350
775
  ]);
351
776
  if (!result.ok)
352
- return false;
777
+ return 'unknown';
353
778
  const line = result.stdout.split('\n')[0]?.trim();
354
779
  if (!line)
355
- return false;
780
+ return 'unknown';
356
781
  const parts = line.split(/\s+/);
357
782
  if (parts.length < 2)
358
- return false;
783
+ return 'unknown';
359
784
  const paneDead = parts[0];
360
785
  const pid = Number.parseInt(parts[1], 10);
361
786
  if (paneDead === '1')
362
- return false;
363
- if (!Number.isFinite(pid))
364
- return false;
365
- try {
366
- process.kill(pid, 0);
367
- return true;
368
- }
369
- catch {
370
- return false;
371
- }
372
- }
373
- function parsePaneLivenessLine(line, allowMissingPid) {
374
- const parts = line.trim().split(/\s+/);
375
- if (parts.length < 1 || parts[0] === '')
376
- return null;
377
- const paneDead = parts[0];
378
- const pid = Number.parseInt(parts[1] ?? '', 10);
379
- if (paneDead === '1')
380
- return false;
787
+ return 'gone';
381
788
  if (!Number.isFinite(pid))
382
- return allowMissingPid ? true : false;
383
- try {
384
- process.kill(pid, 0);
385
- return true;
386
- }
387
- catch {
388
- return allowMissingPid;
389
- }
390
- }
391
- function readPaneLivenessById(paneId) {
392
- const result = runTmux(['list-panes', '-a', '-F', '#{pane_id} #{pane_dead} #{pane_pid}']);
393
- if (!result.ok)
394
- return null;
395
- for (const line of result.stdout.split('\n')) {
396
- const trimmed = line.trim();
397
- if (!trimmed)
398
- continue;
399
- const [id = '', ...rest] = trimmed.split(/\s+/);
400
- if (id !== paneId)
401
- continue;
402
- return parsePaneLivenessLine(rest.join(' '), true);
403
- }
404
- return null;
405
- }
406
- async function readPaneLivenessByIdAsync(paneId) {
407
- const result = await runTmuxAsync(['list-panes', '-a', '-F', '#{pane_id} #{pane_dead} #{pane_pid}']);
408
- if (!result.ok)
409
- return null;
410
- for (const line of result.stdout.split('\n')) {
411
- const trimmed = line.trim();
412
- if (!trimmed)
413
- continue;
414
- const [id = '', ...rest] = trimmed.split(/\s+/);
415
- if (id !== paneId)
416
- continue;
417
- return parsePaneLivenessLine(rest.join(' '), true);
418
- }
419
- return null;
789
+ return 'unknown';
790
+ return probeProcessLiveness(pid);
420
791
  }
421
792
  function shellQuoteSingle(value) {
422
793
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -510,6 +881,20 @@ function buildNestedTmuxShellCommand(command) {
510
881
  function buildBestEffortShellCommand(command) {
511
882
  return `${command} >/dev/null 2>&1 || true`;
512
883
  }
884
+ function buildAuthoritativeHudResizeShellCommand(hudPaneId, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES, expectedPanePid, expectedPaneOwnerId, effectCommand = buildNestedTmuxShellCommand(buildHudResizeCommand(buildHudPaneTarget(hudPaneId), heightLines))) {
885
+ const target = buildHudPaneTarget(hudPaneId);
886
+ const snapshot = buildNestedTmuxShellCommand("list-panes -a -F '#{pane_id}\\t#{pane_dead}\\t#{pane_pid}'");
887
+ const expectedPid = typeof expectedPanePid === 'number' && Number.isSafeInteger(expectedPanePid) && expectedPanePid > 0
888
+ ? ` && $3 == "${expectedPanePid}"`
889
+ : '';
890
+ const proof = `awk -F '\\t' -v pane='${target}' 'NF != 3 || $1 !~ /^%[0-9]+$/ || ($2 != "0" && $2 != "1") || (seen[$1]++) || ($1 == pane && ($3 !~ /^[1-9][0-9]*$/ || length($3) > 16 || (length($3) == 16 && ("x" $3) > "x9007199254740991"))) || ($1 != pane && $3 != "" && ($3 !~ /^[1-9][0-9]*$/ || length($3) > 16 || (length($3) == 16 && ("x" $3) > "x9007199254740991"))) { bad = 1 } $1 == pane && $2 == "0"${expectedPid} { live = 1 } END { exit bad || !live }'`;
891
+ const expectedOwner = expectedPaneOwnerId?.trim();
892
+ if (!expectedOwner) {
893
+ return `if snapshot=$(${snapshot}); then printf '%s\\n' "$snapshot" | ${proof} && ${effectCommand}; fi`;
894
+ }
895
+ const owner = buildNestedTmuxShellCommand(`show-option -qv -p -t ${target} ${OMX_TEAM_PANE_OWNER_OPTION}`);
896
+ return `if snapshot=$(${snapshot}); then printf '%s\\n' "$snapshot" | ${proof} && owner=$(${owner}) && [ "$owner" = ${shellQuoteSingle(expectedOwner)} ] && final_snapshot=$(${snapshot}) && printf '%s\\n' "$final_snapshot" | ${proof} && ${effectCommand}; fi`;
897
+ }
513
898
  /** Upper bound for tmux hook indices (signed 32-bit max). */
514
899
  const TMUX_HOOK_INDEX_MAX = 2147483647;
515
900
  function buildResizeHookSlot(hookName) {
@@ -526,8 +911,8 @@ function buildClientAttachedHookSlot(hookName) {
526
911
  }
527
912
  return `client-attached[${Math.abs(hash) % TMUX_HOOK_INDEX_MAX}]`;
528
913
  }
529
- export function buildRegisterResizeHookArgs(hookTarget, hookName, hudPaneId, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES) {
530
- const resizeCommand = buildBestEffortShellCommand(buildNestedTmuxShellCommand(buildHudResizeCommand(hudPaneId, heightLines)));
914
+ export function buildRegisterResizeHookArgs(hookTarget, hookName, hudPaneId, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES, expectedPanePid, expectedPaneOwnerId) {
915
+ const resizeCommand = buildBestEffortShellCommand(buildAuthoritativeHudResizeShellCommand(hudPaneId, heightLines, expectedPanePid, expectedPaneOwnerId));
531
916
  const hookCommand = shellQuoteSingle(`${resizeCommand}; sleep ${HUD_RESIZE_RECONCILE_DELAY_SECONDS}; ${resizeCommand}`);
532
917
  return ['set-hook', '-t', hookTarget, buildResizeHookSlot(hookName), `run-shell -b ${hookCommand}`];
533
918
  }
@@ -543,9 +928,11 @@ export function buildClientAttachedReconcileHookName(teamName, sessionName, wind
543
928
  normalizeHudPaneToken(hudPaneId),
544
929
  ].join('_');
545
930
  }
546
- export function buildRegisterClientAttachedReconcileArgs(hookTarget, hookName, hudPaneId, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES) {
931
+ export function buildRegisterClientAttachedReconcileArgs(hookTarget, hookName, hudPaneId, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES, expectedPanePid, expectedPaneOwnerId) {
547
932
  const hookSlot = buildClientAttachedHookSlot(hookName);
548
- const oneShotCommand = shellQuoteSingle(`${buildBestEffortShellCommand(buildNestedTmuxShellCommand(buildHudResizeCommand(hudPaneId, heightLines)))}; ${buildNestedTmuxShellCommand(`set-hook -u -t ${hookTarget} ${hookSlot}`)}`);
933
+ const resizeCommand = buildBestEffortShellCommand(buildAuthoritativeHudResizeShellCommand(hudPaneId, heightLines, expectedPanePid, expectedPaneOwnerId));
934
+ const unregisterCommand = buildBestEffortShellCommand(buildAuthoritativeHudResizeShellCommand(hudPaneId, heightLines, expectedPanePid, expectedPaneOwnerId, buildNestedTmuxShellCommand(`set-hook -u -t ${hookTarget} ${hookSlot}`)));
935
+ const oneShotCommand = shellQuoteSingle(`${resizeCommand}; ${unregisterCommand}`);
549
936
  return ['set-hook', '-t', hookTarget, hookSlot, `run-shell -b ${oneShotCommand}`];
550
937
  }
551
938
  export function buildUnregisterClientAttachedReconcileArgs(hookTarget, hookName) {
@@ -555,18 +942,19 @@ export function unregisterResizeHook(hookTarget, hookName) {
555
942
  const result = runTmux(buildUnregisterResizeHookArgs(hookTarget, hookName));
556
943
  return result.ok;
557
944
  }
558
- export function buildScheduleDelayedHudResizeArgs(hudPaneId, delaySeconds = HUD_RESIZE_RECONCILE_DELAY_SECONDS, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES) {
945
+ export function buildScheduleDelayedHudResizeArgs(hudPaneId, delaySeconds = HUD_RESIZE_RECONCILE_DELAY_SECONDS, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES, expectedPanePid, expectedPaneOwnerId) {
559
946
  const delay = Number.isFinite(delaySeconds) && delaySeconds > 0 ? delaySeconds : HUD_RESIZE_RECONCILE_DELAY_SECONDS;
560
- return ['run-shell', '-b', `sleep ${delay}; ${buildBestEffortShellCommand(buildNestedTmuxShellCommand(buildHudResizeCommand(hudPaneId, heightLines)))}`];
947
+ return ['run-shell', '-b', `sleep ${delay}; ${buildBestEffortShellCommand(buildAuthoritativeHudResizeShellCommand(hudPaneId, heightLines, expectedPanePid, expectedPaneOwnerId))}`];
561
948
  }
562
- export function buildReconcileHudResizeArgs(hudPaneId, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES) {
563
- return ['run-shell', buildBestEffortShellCommand(buildNestedTmuxShellCommand(buildHudResizeCommand(hudPaneId, heightLines)))];
949
+ export function buildReconcileHudResizeArgs(hudPaneId, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES, expectedPanePid, expectedPaneOwnerId) {
950
+ return ['run-shell', buildBestEffortShellCommand(buildAuthoritativeHudResizeShellCommand(hudPaneId, heightLines, expectedPanePid, expectedPaneOwnerId))];
564
951
  }
565
- function redrawLeaderPaneAfterTeamLayout(leaderPaneId) {
952
+ function redrawLeaderPaneAfterTeamLayout(leaderPaneId, expectedPanePid, expectedTeamOwnerId) {
566
953
  const target = leaderPaneId.trim();
567
954
  if (!target.startsWith('%'))
568
955
  return;
569
- runTmux(['send-keys', '-t', target, 'C-l']);
956
+ const provenTarget = requireLiveTeamOwnedPaneSync(target, expectedPanePid, expectedTeamOwnerId);
957
+ runTmux(['send-keys', '-t', provenTarget, 'C-l']);
570
958
  }
571
959
  const ZSH_CANDIDATE_PATHS = ['/bin/zsh', '/usr/bin/zsh', '/usr/local/bin/zsh', '/opt/local/bin/zsh', '/opt/homebrew/bin/zsh'];
572
960
  const BASH_CANDIDATE_PATHS = ['/bin/bash', '/usr/bin/bash'];
@@ -1217,7 +1605,19 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
1217
1605
  const safeTeamName = sanitizeTeamName(teamName);
1218
1606
  let registeredResizeHook = null;
1219
1607
  let registeredClientAttachedHook = null;
1220
- const rollbackPaneIds = [];
1608
+ const rollbackPanes = new Map();
1609
+ // An owner-tag command may have succeeded even if tmux reports an ambiguous
1610
+ // failure; rollback can only kill panes whose owner is re-authorized.
1611
+ const rollbackTaggedPaneOwnerIds = new Map();
1612
+ let partialTeamTarget = null;
1613
+ let partialLeaderPaneId = null;
1614
+ let partialLeaderPanePid;
1615
+ let partialTeamPaneOwnerId = '';
1616
+ const partialWorkerPaneIds = [];
1617
+ const partialWorkerPaneIdsByIndex = Array.from({ length: workerCount }, () => null);
1618
+ const partialWorkerPanePidsByIndex = Array.from({ length: workerCount }, () => null);
1619
+ let partialHudPaneId = null;
1620
+ let partialHudPanePid = null;
1221
1621
  try {
1222
1622
  const tmuxPaneTarget = process.env.TMUX_PANE;
1223
1623
  const displayArgs = tmuxPaneTarget
@@ -1234,31 +1634,60 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
1234
1634
  throw new Error(`failed to parse current tmux target: ${context.stdout}`);
1235
1635
  }
1236
1636
  const teamTarget = `${sessionName}:${windowIndex}`;
1637
+ partialTeamTarget = teamTarget;
1237
1638
  const ownerSessionId = (options.ownerSessionId ?? process.env.OMX_SESSION_ID ?? '').trim();
1238
1639
  const teamPaneOwnerId = (options.teamPaneOwnerId ?? `team:${safeTeamName}`).trim();
1640
+ partialTeamPaneOwnerId = teamPaneOwnerId;
1641
+ const paneListResult = listPanesResult(teamTarget);
1642
+ if (paneListResult.error)
1643
+ throw new Error(`failed to read tmux pane topology: ${paneListResult.error}`);
1644
+ const leaderPaneId = chooseTeamLeaderPaneId(paneListResult.panes, detectedLeaderPaneId);
1645
+ const leaderProof = readExactPaneProofSync(leaderPaneId);
1646
+ if (leaderProof.status === 'unavailable')
1647
+ throw new ExactPaneProofUnavailableError(leaderProof);
1648
+ if (leaderProof.status === 'gone')
1649
+ throw new Error(`tmux pane is not proven live: ${leaderPaneId}`);
1650
+ const leaderPanePid = leaderProof.pid;
1651
+ partialLeaderPaneId = leaderPaneId;
1652
+ partialLeaderPanePid = leaderPanePid;
1653
+ const initialHudPaneIds = findHudWatchPaneIds(paneListResult.panes, leaderPaneId, { leaderPaneId });
1654
+ const initialWindowPanePids = new Map([[leaderPaneId, leaderPanePid]]);
1655
+ const omxEntry = resolveOmxCliEntryPath();
1656
+ const canRecreateTeamHud = Boolean(omxEntry && omxEntry.trim() !== '');
1657
+ // Freeze every HUD owned by this leader even when startup cannot recreate
1658
+ // it; the untouched pane remains part of the authorized topology.
1659
+ for (const hudPaneId of initialHudPaneIds) {
1660
+ const proof = readExactPaneProofSync(hudPaneId);
1661
+ if (proof.status === 'unavailable')
1662
+ throw new ExactPaneProofUnavailableError(proof);
1663
+ if (proof.status === 'gone')
1664
+ throw new Error(`tmux pane is not proven live: ${hudPaneId}`);
1665
+ initialWindowPanePids.set(hudPaneId, proof.pid);
1666
+ }
1667
+ // Team mode prioritizes leader + worker visibility. Remove HUD panes only
1668
+ // when we can recreate the team HUD. Otherwise keep the existing HUD alive
1669
+ // instead of making it disappear on team startup failures or broken installs.
1670
+ requireFrozenWindowTopologySync(teamTarget, initialWindowPanePids);
1239
1671
  if (ownerSessionId) {
1240
1672
  const tagResult = runTmux(['set-option', '-t', sessionName, OMX_INSTANCE_OPTION, ownerSessionId]);
1241
1673
  if (!tagResult.ok) {
1242
1674
  throw new Error(`failed to tag tmux session ${sessionName}: ${tagResult.stderr}`);
1243
1675
  }
1244
1676
  }
1245
- const panes = listPanes(teamTarget);
1246
- const leaderPaneId = chooseTeamLeaderPaneId(panes, detectedLeaderPaneId);
1247
- tagPaneInstance(leaderPaneId, ownerSessionId);
1248
- tagPaneTeamOwner(leaderPaneId, teamPaneOwnerId);
1249
- const initialHudPaneIds = findHudPaneIds(teamTarget, leaderPaneId);
1250
- const omxEntry = resolveOmxCliEntryPath();
1251
- const canRecreateTeamHud = Boolean(omxEntry && omxEntry.trim() !== '');
1252
- // Team mode prioritizes leader + worker visibility. Remove HUD panes only
1253
- // when we can recreate the team HUD. Otherwise keep the existing HUD alive
1254
- // instead of making it disappear on team startup failures or broken installs.
1677
+ tagPaneInstance(leaderPaneId, ownerSessionId, leaderPanePid);
1678
+ tagPaneTeamOwner(leaderPaneId, teamPaneOwnerId, leaderPanePid);
1255
1679
  if (canRecreateTeamHud) {
1256
- for (const hudPaneId of initialHudPaneIds) {
1257
- runTmux(['kill-pane', '-t', hudPaneId]);
1680
+ for (const [hudPaneId, hudPanePid] of initialWindowPanePids) {
1681
+ if (hudPaneId !== leaderPaneId)
1682
+ killExactPaneSync(hudPaneId, hudPanePid);
1258
1683
  }
1259
1684
  }
1260
1685
  const workerPaneIds = [];
1686
+ const workerPanePidsByIndex = Array.from({ length: workerCount }, () => null);
1261
1687
  let rightStackRootPaneId = null;
1688
+ let rightStackRootPanePid = null;
1689
+ const frozenWindowPanePids = new Map([[leaderPaneId, leaderPanePid]]);
1690
+ const frozenWindowPaneOwners = new Map([[leaderPaneId, teamPaneOwnerId]]);
1262
1691
  for (let i = 1; i <= workerCount; i++) {
1263
1692
  const startup = workerStartups[i - 1] || {};
1264
1693
  const workerCwd = startup.cwd || cwd;
@@ -1269,7 +1698,7 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
1269
1698
  const cmd = writeWorkerStartupScriptCommand(safeTeamName, i, launchArgsForWorker, workerCwd, workerEnv, workerCliPlan[i - 1], startup.initialPrompt, startup.workerRole) ?? buildWorkerStartupCommand(safeTeamName, i, launchArgsForWorker, workerCwd, workerEnv, workerCliPlan[i - 1], startup.initialPrompt, startup.workerRole);
1270
1699
  // First split creates the right side from leader. Remaining splits stack on the right.
1271
1700
  const splitDirection = i === 1 ? '-h' : '-v';
1272
- const splitTarget = i === 1 ? leaderPaneId : (rightStackRootPaneId ?? leaderPaneId);
1701
+ const splitTarget = requireLiveTeamOwnedPaneSync(i === 1 ? leaderPaneId : (rightStackRootPaneId ?? leaderPaneId), i === 1 ? leaderPanePid : (rightStackRootPanePid ?? leaderPanePid), teamPaneOwnerId);
1273
1702
  const split = runTmux([
1274
1703
  'split-window',
1275
1704
  splitDirection,
@@ -1290,17 +1719,37 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
1290
1719
  if (!paneId || !paneId.startsWith('%')) {
1291
1720
  throw new Error(`failed to capture worker pane id for worker ${i}`);
1292
1721
  }
1293
- rollbackPaneIds.push(paneId);
1722
+ // The pane exists once split-window returns its concrete ID. Persist it in
1723
+ // rollback/partial state before its first proof: a malformed or unavailable
1724
+ // topology must retain cleanup debt, but cannot authorize an effect.
1725
+ rollbackPanes.set(paneId, null);
1726
+ partialWorkerPaneIds.push(paneId);
1727
+ partialWorkerPaneIdsByIndex[i - 1] = paneId;
1728
+ const paneProof = readExactPaneProofSync(paneId);
1729
+ if (paneProof.status === 'unavailable')
1730
+ throw new ExactPaneProofUnavailableError(paneProof);
1731
+ if (paneProof.status === 'gone')
1732
+ throw new Error(`tmux pane is not proven live: ${paneId}`);
1733
+ const panePid = paneProof.pid;
1734
+ rollbackPanes.set(paneId, panePid);
1735
+ partialWorkerPanePidsByIndex[i - 1] = panePid;
1736
+ workerPanePidsByIndex[i - 1] = panePid;
1737
+ frozenWindowPanePids.set(paneId, panePid);
1294
1738
  if (isNativeWindows() && !waitForPaneToRemainPresent(teamTarget, paneId)) {
1295
1739
  throw new Error(`worker pane ${i} did not remain present after tmux split-window returned ${paneId}`);
1296
1740
  }
1297
- tagPaneInstance(paneId, ownerSessionId);
1298
- tagPaneTeamOwner(paneId, teamPaneOwnerId);
1741
+ tagPaneInstance(paneId, ownerSessionId, panePid);
1742
+ rollbackTaggedPaneOwnerIds.set(paneId, teamPaneOwnerId);
1743
+ tagPaneTeamOwner(paneId, teamPaneOwnerId, panePid);
1744
+ frozenWindowPaneOwners.set(paneId, teamPaneOwnerId);
1299
1745
  workerPaneIds.push(paneId);
1300
- if (i === 1)
1746
+ if (i === 1) {
1301
1747
  rightStackRootPaneId = paneId;
1748
+ rightStackRootPanePid = panePid;
1749
+ }
1302
1750
  }
1303
1751
  // Keep leader as full left/main pane; workers stay stacked on the right.
1752
+ requireFrozenWindowTopologySync(teamTarget, frozenWindowPanePids, frozenWindowPaneOwners);
1304
1753
  runTmux(['select-layout', '-t', teamTarget, 'main-vertical']);
1305
1754
  // Force leader pane to use half the window width.
1306
1755
  const windowWidthResult = runTmux(['display-message', '-p', '-t', teamTarget, '#{window_width}']);
@@ -1308,7 +1757,9 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
1308
1757
  const width = Number.parseInt(windowWidthResult.stdout.split('\n')[0]?.trim() || '', 10);
1309
1758
  if (Number.isFinite(width) && width >= 40) {
1310
1759
  const half = String(Math.floor(width / 2));
1760
+ requireFrozenWindowTopologySync(teamTarget, frozenWindowPanePids, frozenWindowPaneOwners);
1311
1761
  runTmux(['set-window-option', '-t', teamTarget, 'main-pane-width', half]);
1762
+ requireFrozenWindowTopologySync(teamTarget, frozenWindowPanePids, frozenWindowPaneOwners);
1312
1763
  runTmux(['select-layout', '-t', teamTarget, 'main-vertical']);
1313
1764
  }
1314
1765
  }
@@ -1322,23 +1773,39 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
1322
1773
  if (canRecreateTeamHud && omxEntry) {
1323
1774
  const hudCmd = `exec env ${formatHudEnvAssignments(process.env, { sessionId: ownerSessionId, leaderPaneId })} node ${shellQuoteSingle(translatePathForMsys(omxEntry))} hud --watch`;
1324
1775
  const hudCwd = translatePathForMsys(cwd);
1776
+ requireFrozenWindowTopologySync(teamTarget, frozenWindowPanePids, frozenWindowPaneOwners);
1777
+ const hudSplitTarget = leaderPaneId;
1325
1778
  const hudResult = runTmux([
1326
- 'split-window', '-v', '-f', '-l', String(HUD_TMUX_TEAM_HEIGHT_LINES), '-t', teamTarget, '-d', '-P', '-F', '#{pane_id}', '-c', hudCwd, hudCmd,
1779
+ 'split-window', '-v', '-f', '-l', String(HUD_TMUX_TEAM_HEIGHT_LINES), '-t', hudSplitTarget, '-d', '-P', '-F', '#{pane_id}', '-c', hudCwd, hudCmd,
1327
1780
  ]);
1328
1781
  if (hudResult.ok) {
1329
1782
  const id = hudResult.stdout.split('\n')[0]?.trim() ?? '';
1330
1783
  if (id.startsWith('%')) {
1331
- rollbackPaneIds.push(id);
1784
+ // split-window has created a concrete pane even though no proof has
1785
+ // authorized an effect yet. Retain PID-less cleanup debt first.
1786
+ rollbackPanes.set(id, null);
1787
+ partialHudPaneId = id;
1788
+ partialHudPanePid = null;
1789
+ const hudProof = readExactPaneProofSync(id);
1790
+ if (hudProof.status === 'unavailable')
1791
+ throw new ExactPaneProofUnavailableError(hudProof);
1792
+ if (hudProof.status === 'gone')
1793
+ throw new Error(`tmux pane is not proven live: ${id}`);
1794
+ const hudPanePid = hudProof.pid;
1795
+ frozenWindowPanePids.set(id, hudPanePid);
1796
+ rollbackPanes.set(id, hudPanePid);
1797
+ partialHudPanePid = hudPanePid;
1332
1798
  if (isNativeWindows() && !waitForPaneToRemainPresent(teamTarget, id)) {
1333
1799
  throw new Error(`HUD pane did not remain present after tmux split-window returned ${id}`);
1334
1800
  }
1335
- tagPaneInstance(id, ownerSessionId);
1336
- tagPaneTeamOwner(id, teamPaneOwnerId);
1801
+ tagPaneInstance(id, ownerSessionId, hudPanePid);
1802
+ rollbackTaggedPaneOwnerIds.set(id, teamPaneOwnerId);
1803
+ tagPaneTeamOwner(id, teamPaneOwnerId, hudPanePid);
1804
+ frozenWindowPaneOwners.set(id, teamPaneOwnerId);
1337
1805
  hudPaneId = id;
1338
1806
  if (isNativeWindows()) {
1339
- // Native Windows tmux support may flow through psmux; issuing a
1340
- // direct control-plane resize avoids nested run-shell PATH drift.
1341
- const reconcile = runTmux(buildHudResizeArgs(hudPaneId));
1807
+ const provenHudPaneId = requireLiveTeamOwnedPaneSync(hudPaneId, hudPanePid, teamPaneOwnerId);
1808
+ const reconcile = runTmux(buildHudResizeArgs(provenHudPaneId));
1342
1809
  if (!reconcile.ok) {
1343
1810
  throw new Error(`failed to reconcile HUD resize: ${reconcile.stderr}`);
1344
1811
  }
@@ -1346,36 +1813,50 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
1346
1813
  else {
1347
1814
  const hookTarget = buildResizeHookTarget(sessionName, windowIndex);
1348
1815
  const hookName = buildResizeHookName(safeTeamName, sessionName, windowIndex, hudPaneId);
1349
- const registerHook = runTmux(buildRegisterResizeHookArgs(hookTarget, hookName, hudPaneId));
1816
+ requireLiveTeamOwnedPaneSync(hudPaneId, hudPanePid, teamPaneOwnerId);
1817
+ const registerHook = runTmux(buildRegisterResizeHookArgs(hookTarget, hookName, hudPaneId, HUD_TMUX_TEAM_HEIGHT_LINES, hudPanePid, teamPaneOwnerId));
1350
1818
  const clientAttachedHookName = buildClientAttachedReconcileHookName(safeTeamName, sessionName, windowIndex, hudPaneId);
1351
1819
  if (registerHook.ok) {
1352
1820
  resizeHookTarget = hookTarget;
1353
1821
  resizeHookName = hookName;
1354
- registeredResizeHook = { name: resizeHookName, target: resizeHookTarget };
1822
+ registeredResizeHook = {
1823
+ name: resizeHookName,
1824
+ target: resizeHookTarget,
1825
+ leaderPaneId,
1826
+ leaderPanePid,
1827
+ hudPaneId,
1828
+ hudPanePid,
1829
+ teamPaneOwnerId,
1830
+ };
1355
1831
  }
1356
1832
  else {
1357
- // tmux versions/builds that reject indexed client-resized hooks should not
1358
- // abort madmax/team startup after panes were successfully created. Keep the
1359
- // fallback narrow: skip only the long-lived resize hook metadata, then
1360
- // still try the one-shot client-attached reconcile plus the explicit
1361
- // delayed/direct resize checks below so real tmux/run-shell failures
1362
- // still surface.
1363
1833
  console.warn(`[omx] tmux resize hook unavailable for ${hookTarget} (${hookName}): ${registerHook.stderr}; `
1364
1834
  + 'continuing with best-effort HUD resize fallback.');
1365
1835
  }
1366
- const registerClientAttachedHook = runTmux(buildRegisterClientAttachedReconcileArgs(hookTarget, clientAttachedHookName, hudPaneId));
1836
+ requireLiveTeamOwnedPaneSync(hudPaneId, hudPanePid, teamPaneOwnerId);
1837
+ const registerClientAttachedHook = runTmux(buildRegisterClientAttachedReconcileArgs(hookTarget, clientAttachedHookName, hudPaneId, HUD_TMUX_TEAM_HEIGHT_LINES, hudPanePid, teamPaneOwnerId));
1367
1838
  if (registerClientAttachedHook.ok) {
1368
- registeredClientAttachedHook = { name: clientAttachedHookName, target: hookTarget };
1839
+ registeredClientAttachedHook = {
1840
+ name: clientAttachedHookName,
1841
+ target: hookTarget,
1842
+ leaderPaneId,
1843
+ leaderPanePid,
1844
+ hudPaneId,
1845
+ hudPanePid,
1846
+ teamPaneOwnerId,
1847
+ };
1369
1848
  }
1370
1849
  else {
1371
1850
  console.warn(`[omx] tmux client-attached resize fallback unavailable for ${hookTarget} `
1372
1851
  + `(${clientAttachedHookName}): ${registerClientAttachedHook.stderr}; continuing with delayed HUD resize fallback.`);
1373
1852
  }
1374
- const delayed = runTmux(buildScheduleDelayedHudResizeArgs(hudPaneId));
1853
+ requireLiveTeamOwnedPaneSync(hudPaneId, hudPanePid, teamPaneOwnerId);
1854
+ const delayed = runTmux(buildScheduleDelayedHudResizeArgs(hudPaneId, HUD_RESIZE_RECONCILE_DELAY_SECONDS, HUD_TMUX_TEAM_HEIGHT_LINES, hudPanePid, teamPaneOwnerId));
1375
1855
  if (!delayed.ok) {
1376
1856
  console.warn(`[omx] tmux delayed HUD resize unavailable for ${hudPaneId}: ${delayed.stderr}; continuing.`);
1377
1857
  }
1378
- const reconcile = runTmux(buildReconcileHudResizeArgs(hudPaneId));
1858
+ requireLiveTeamOwnedPaneSync(hudPaneId, hudPanePid, teamPaneOwnerId);
1859
+ const reconcile = runTmux(buildReconcileHudResizeArgs(hudPaneId, HUD_TMUX_TEAM_HEIGHT_LINES, hudPanePid, teamPaneOwnerId));
1379
1860
  if (!reconcile.ok) {
1380
1861
  console.warn(`[omx] tmux HUD resize reconcile unavailable for ${hudPaneId}: ${reconcile.stderr}; continuing.`);
1381
1862
  }
@@ -1383,21 +1864,31 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
1383
1864
  }
1384
1865
  }
1385
1866
  }
1386
- runTmux(['select-pane', '-t', leaderPaneId]);
1387
- redrawLeaderPaneAfterTeamLayout(leaderPaneId);
1867
+ runTmux(['select-pane', '-t', requireLiveTeamOwnedPaneSync(leaderPaneId, leaderPanePid, teamPaneOwnerId)]);
1868
+ redrawLeaderPaneAfterTeamLayout(leaderPaneId, leaderPanePid, teamPaneOwnerId);
1388
1869
  sleepSeconds(0.5);
1389
1870
  // Enable mouse scrolling so agent output panes can be scrolled with the
1390
1871
  // mouse wheel without conflicting with keyboard up/down arrow-key input
1391
1872
  // history navigation in the Codex CLI input field. (issue #103)
1392
1873
  // Opt-out: set OMX_TEAM_MOUSE=0 in the environment.
1393
1874
  if (process.env.OMX_TEAM_MOUSE !== '0') {
1394
- enableMouseScrolling(sessionName);
1875
+ enableMouseScrolling(sessionName, () => { requireLiveTeamOwnedPaneSync(leaderPaneId, leaderPanePid, teamPaneOwnerId); });
1876
+ }
1877
+ const sessionIncarnation = teamTarget.includes(':') ? null : queryDetachedTeamSession(sessionName);
1878
+ if (sessionIncarnation && sessionIncarnation.status !== 'exact') {
1879
+ throw new Error(`tmux_session_incarnation_unavailable:${sessionName}`);
1395
1880
  }
1396
1881
  return {
1397
1882
  name: teamTarget,
1883
+ tmuxSessionId: sessionIncarnation?.incarnation.sessionId,
1884
+ tmuxSessionCreated: sessionIncarnation?.incarnation.sessionCreated,
1398
1885
  workerCount,
1399
1886
  cwd,
1400
1887
  workerPaneIds,
1888
+ workerPaneIdsByIndex: [...workerPaneIds],
1889
+ workerPanePidsByIndex,
1890
+ leaderPanePid,
1891
+ hudPanePid: partialHudPanePid,
1401
1892
  leaderPaneId,
1402
1893
  hudPaneId,
1403
1894
  resizeHookName,
@@ -1406,14 +1897,82 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
1406
1897
  };
1407
1898
  }
1408
1899
  catch (error) {
1409
- if (registeredClientAttachedHook) {
1410
- runTmux(buildUnregisterClientAttachedReconcileArgs(registeredClientAttachedHook.target, registeredClientAttachedHook.name));
1900
+ const cleanupErrors = [];
1901
+ const unregisterAuthorizedHook = (hook, unregister, label) => {
1902
+ try {
1903
+ requireLiveTeamOwnedPaneSync(hook.leaderPaneId, hook.leaderPanePid, hook.teamPaneOwnerId);
1904
+ requireLiveTeamOwnedPaneSync(hook.hudPaneId, hook.hudPanePid, hook.teamPaneOwnerId);
1905
+ const unregistered = runTmux(unregister(hook.target, hook.name));
1906
+ if (unregistered.ok)
1907
+ return true;
1908
+ cleanupErrors.push(`failed to unregister tmux ${label} hook ${hook.name}: ${unregistered.stderr}`);
1909
+ }
1910
+ catch (cleanupError) {
1911
+ cleanupErrors.push(`unable to authorize tmux ${label} hook cleanup ${hook.name}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
1912
+ }
1913
+ return false;
1914
+ };
1915
+ if (registeredClientAttachedHook && unregisterAuthorizedHook(registeredClientAttachedHook, buildUnregisterClientAttachedReconcileArgs, 'client-attached')) {
1916
+ registeredClientAttachedHook = null;
1917
+ }
1918
+ if (registeredResizeHook && unregisterAuthorizedHook(registeredResizeHook, buildUnregisterResizeHookArgs, 'resize')) {
1919
+ registeredResizeHook = null;
1411
1920
  }
1412
- if (registeredResizeHook) {
1413
- runTmux(buildUnregisterResizeHookArgs(registeredResizeHook.target, registeredResizeHook.name));
1921
+ const proofUnavailable = [];
1922
+ if (error instanceof ExactPaneProofUnavailableError)
1923
+ proofUnavailable.push(error.proof);
1924
+ const unresolvedPaneIds = new Set(rollbackPanes.keys());
1925
+ for (const [paneId, panePid] of rollbackPanes) {
1926
+ // split-window IDs without a positive exact PID proof are cleanup debt,
1927
+ // never authorization to affect a potentially recycled pane ID.
1928
+ if (panePid === null)
1929
+ continue;
1930
+ try {
1931
+ const expectedOwnerId = rollbackTaggedPaneOwnerIds.get(paneId);
1932
+ killExactPaneSync(paneId, panePid, expectedOwnerId
1933
+ ? () => { requireLiveTeamOwnedPaneSync(paneId, panePid, expectedOwnerId); }
1934
+ : undefined);
1935
+ unresolvedPaneIds.delete(paneId);
1936
+ }
1937
+ catch (cleanupError) {
1938
+ if (cleanupError instanceof ExactPaneProofUnavailableError) {
1939
+ proofUnavailable.push(cleanupError.proof);
1940
+ break;
1941
+ }
1942
+ else {
1943
+ cleanupErrors.push(cleanupError instanceof Error ? cleanupError.message : String(cleanupError));
1944
+ }
1945
+ }
1414
1946
  }
1415
- for (const paneId of rollbackPaneIds) {
1416
- runTmux(['kill-pane', '-t', paneId]);
1947
+ const unresolvedWorkerPaneIds = partialWorkerPaneIds.filter((paneId) => unresolvedPaneIds.has(paneId));
1948
+ const unresolvedWorkerPaneIdsByIndex = partialWorkerPaneIdsByIndex
1949
+ .map((paneId) => paneId && unresolvedPaneIds.has(paneId) ? paneId : null);
1950
+ const unresolvedWorkerPanePidsByIndex = partialWorkerPanePidsByIndex
1951
+ .map((panePid, index) => partialWorkerPaneIdsByIndex[index] && unresolvedPaneIds.has(partialWorkerPaneIdsByIndex[index]) ? panePid : null);
1952
+ const unresolvedHudPaneId = partialHudPaneId && (unresolvedPaneIds.has(partialHudPaneId)
1953
+ || registeredResizeHook !== null
1954
+ || registeredClientAttachedHook !== null)
1955
+ ? partialHudPaneId
1956
+ : null;
1957
+ const hasRecoverablePartialArtifact = unresolvedPaneIds.size > 0
1958
+ || registeredResizeHook !== null
1959
+ || registeredClientAttachedHook !== null;
1960
+ if (hasRecoverablePartialArtifact && partialTeamTarget && partialLeaderPaneId) {
1961
+ throw new CreateTeamSessionPartialError({
1962
+ name: partialTeamTarget,
1963
+ workerCount,
1964
+ cwd,
1965
+ workerPaneIds: unresolvedWorkerPaneIds,
1966
+ workerPaneIdsByIndex: unresolvedWorkerPaneIdsByIndex,
1967
+ workerPanePidsByIndex: unresolvedWorkerPanePidsByIndex,
1968
+ leaderPanePid: partialLeaderPanePid,
1969
+ hudPanePid: unresolvedHudPaneId ? partialHudPanePid : null,
1970
+ leaderPaneId: partialLeaderPaneId,
1971
+ hudPaneId: unresolvedHudPaneId,
1972
+ resizeHookName: registeredResizeHook?.name ?? null,
1973
+ resizeHookTarget: registeredResizeHook?.target ?? null,
1974
+ teamPaneOwnerId: partialTeamPaneOwnerId,
1975
+ }, proofUnavailable, error, cleanupErrors);
1417
1976
  }
1418
1977
  throw error;
1419
1978
  }
@@ -1422,34 +1981,73 @@ export function restoreStandaloneHudPane(leaderPaneId, cwd, options = {}) {
1422
1981
  const normalizedLeaderPaneId = normalizePaneTarget(leaderPaneId);
1423
1982
  if (!normalizedLeaderPaneId)
1424
1983
  return null;
1984
+ // The split is irreversible without a durable cleanup obligation. Validate
1985
+ // its canonical Team-root location before authorizing any pane effect.
1986
+ restoredHudCleanupDebtPath(cwd, options.stateRoot);
1425
1987
  const omxEntry = resolveOmxCliEntryPath();
1426
1988
  if (!omxEntry || omxEntry.trim() === '')
1427
1989
  return null;
1428
- const [existingHudPaneId, ...duplicateHudPaneIds] = findOwnedHudPaneIds(normalizedLeaderPaneId, normalizedLeaderPaneId);
1990
+ const leaderPanePid = (() => {
1991
+ const proof = readExactPaneProofSync(normalizedLeaderPaneId);
1992
+ if (proof.status === 'unavailable')
1993
+ throw new ExactPaneProofUnavailableError(proof);
1994
+ if (proof.status === 'gone')
1995
+ throw new Error(`tmux pane is not proven live: ${normalizedLeaderPaneId}`);
1996
+ if (options.expectedLeaderPanePid !== undefined && proof.pid !== options.expectedLeaderPanePid) {
1997
+ throw new Error(`tmux pane identity changed: ${normalizedLeaderPaneId}`);
1998
+ }
1999
+ return proof.pid;
2000
+ })();
2001
+ const requireAuthorizedLeaderPane = () => {
2002
+ options.assertLeaderPaneAuthorization?.();
2003
+ return requireLiveExactPaneSync(normalizedLeaderPaneId, options.expectedLeaderPanePid ?? leaderPanePid);
2004
+ };
2005
+ requireAuthorizedLeaderPane();
2006
+ const paneListResult = listPanesResult(normalizedLeaderPaneId);
2007
+ if (paneListResult.error)
2008
+ throw new Error(`failed to read tmux pane topology: ${paneListResult.error}`);
2009
+ const [existingHudPaneId, ...duplicateHudPaneIds] = findHudWatchPaneIds(paneListResult.panes, normalizedLeaderPaneId, { leaderPaneId: normalizedLeaderPaneId });
2010
+ const ownedHudPanePids = new Map();
2011
+ for (const paneId of [existingHudPaneId, ...duplicateHudPaneIds]) {
2012
+ if (!paneId)
2013
+ continue;
2014
+ const proof = readExactPaneProofSync(paneId);
2015
+ if (proof.status === 'unavailable')
2016
+ throw new ExactPaneProofUnavailableError(proof);
2017
+ if (proof.status === 'gone')
2018
+ throw new Error(`tmux pane is not proven live: ${paneId}`);
2019
+ ownedHudPanePids.set(paneId, proof.pid);
2020
+ }
1429
2021
  for (const paneId of duplicateHudPaneIds) {
1430
- runTmux(['kill-pane', '-t', paneId]);
2022
+ killExactPaneSync(paneId, ownedHudPanePids.get(paneId), requireAuthorizedLeaderPane);
1431
2023
  }
1432
2024
  if (existingHudPaneId) {
1433
2025
  if (isNativeWindows()) {
1434
- runTmux(buildHudResizeArgs(existingHudPaneId));
2026
+ const exactHudPaneId = requireLiveExactPaneSync(existingHudPaneId, ownedHudPanePids.get(existingHudPaneId));
2027
+ requireAuthorizedLeaderPane();
2028
+ const reconcile = runTmux(buildHudResizeArgs(exactHudPaneId));
2029
+ if (!reconcile.ok)
2030
+ throw new Error(`failed to reconcile standalone HUD resize: ${reconcile.stderr}`);
1435
2031
  }
1436
2032
  else {
1437
- runTmux(buildScheduleDelayedHudResizeArgs(existingHudPaneId));
1438
- runTmux(buildReconcileHudResizeArgs(existingHudPaneId));
2033
+ requireAuthorizedLeaderPane();
2034
+ runTmux(buildScheduleDelayedHudResizeArgs(existingHudPaneId, HUD_RESIZE_RECONCILE_DELAY_SECONDS, HUD_TMUX_TEAM_HEIGHT_LINES, ownedHudPanePids.get(existingHudPaneId)));
2035
+ requireAuthorizedLeaderPane();
2036
+ runTmux(buildReconcileHudResizeArgs(existingHudPaneId, HUD_TMUX_TEAM_HEIGHT_LINES, ownedHudPanePids.get(existingHudPaneId)));
1439
2037
  }
1440
- runTmux(['select-pane', '-t', normalizedLeaderPaneId]);
2038
+ runTmux(['select-pane', '-t', requireAuthorizedLeaderPane()]);
1441
2039
  return existingHudPaneId;
1442
2040
  }
1443
2041
  const hudCmd = `exec env ${formatHudEnvAssignments(process.env, { sessionId: options.sessionId, leaderPaneId: normalizedLeaderPaneId })} ${shellQuoteSingle(translatePathForMsys(resolveLeaderNodePath()))} ${shellQuoteSingle(translatePathForMsys(omxEntry))} hud --watch`;
1444
2042
  let hudResult = null;
1445
- for (const restoreCwd of resolveStandaloneHudRestoreCwdCandidates(normalizedLeaderPaneId, cwd, options.cwd)) {
2043
+ for (const restoreCwd of resolveStandaloneHudRestoreCwdCandidates(normalizedLeaderPaneId, cwd, options.cwd, requireAuthorizedLeaderPane)) {
1446
2044
  const candidateResult = runTmux([
1447
2045
  'split-window',
1448
2046
  '-v',
1449
2047
  '-l',
1450
2048
  String(HUD_TMUX_TEAM_HEIGHT_LINES),
1451
2049
  '-t',
1452
- normalizedLeaderPaneId,
2050
+ requireAuthorizedLeaderPane(),
1453
2051
  '-d',
1454
2052
  '-P',
1455
2053
  '-F',
@@ -1468,14 +2066,48 @@ export function restoreStandaloneHudPane(leaderPaneId, cwd, options = {}) {
1468
2066
  const paneId = hudResult.stdout.split('\n')[0]?.trim() ?? '';
1469
2067
  if (!paneId.startsWith('%'))
1470
2068
  return null;
2069
+ persistRestoredHudCleanupDebtSync(cwd, {
2070
+ schema_version: 1,
2071
+ operation: 'restored_hud_cleanup',
2072
+ pane_id: paneId,
2073
+ pane_pid: null,
2074
+ leader_pane_id: normalizedLeaderPaneId,
2075
+ leader_pane_pid: options.expectedLeaderPanePid ?? leaderPanePid,
2076
+ leader_pane_owner_id: options.expectedLeaderPaneOwnerId?.trim() || null,
2077
+ hud_owner_leader_pane_id: normalizedLeaderPaneId,
2078
+ }, options.stateRoot);
2079
+ const hudPanePid = (() => {
2080
+ const proof = readExactPaneProofSync(paneId);
2081
+ if (proof.status === 'unavailable')
2082
+ throw new ExactPaneProofUnavailableError(proof);
2083
+ if (proof.status === 'gone')
2084
+ throw new Error(`tmux pane is not proven live: ${paneId}`);
2085
+ return proof.pid;
2086
+ })();
2087
+ persistRestoredHudCleanupDebtSync(cwd, {
2088
+ schema_version: 1,
2089
+ operation: 'restored_hud_cleanup',
2090
+ pane_id: paneId,
2091
+ pane_pid: hudPanePid,
2092
+ leader_pane_id: normalizedLeaderPaneId,
2093
+ leader_pane_pid: options.expectedLeaderPanePid ?? leaderPanePid,
2094
+ leader_pane_owner_id: options.expectedLeaderPaneOwnerId?.trim() || null,
2095
+ hud_owner_leader_pane_id: normalizedLeaderPaneId,
2096
+ }, options.stateRoot);
1471
2097
  if (isNativeWindows()) {
1472
- runTmux(buildHudResizeArgs(paneId));
2098
+ const exactHudPaneId = requireLiveExactPaneSync(paneId, hudPanePid);
2099
+ requireAuthorizedLeaderPane();
2100
+ const reconcile = runTmux(buildHudResizeArgs(exactHudPaneId));
2101
+ if (!reconcile.ok)
2102
+ throw new Error(`failed to reconcile standalone HUD resize: ${reconcile.stderr}`);
1473
2103
  }
1474
2104
  else {
1475
- runTmux(buildScheduleDelayedHudResizeArgs(paneId));
1476
- runTmux(buildReconcileHudResizeArgs(paneId));
2105
+ requireAuthorizedLeaderPane();
2106
+ runTmux(buildScheduleDelayedHudResizeArgs(paneId, HUD_RESIZE_RECONCILE_DELAY_SECONDS, HUD_TMUX_TEAM_HEIGHT_LINES, hudPanePid));
2107
+ requireAuthorizedLeaderPane();
2108
+ runTmux(buildReconcileHudResizeArgs(paneId, HUD_TMUX_TEAM_HEIGHT_LINES, hudPanePid));
1477
2109
  }
1478
- runTmux(['select-pane', '-t', normalizedLeaderPaneId]);
2110
+ runTmux(['select-pane', '-t', requireAuthorizedLeaderPane()]);
1479
2111
  return paneId;
1480
2112
  }
1481
2113
  /**
@@ -1487,21 +2119,21 @@ export function restoreStandaloneHudPane(leaderPaneId, cwd, options = {}) {
1487
2119
  * oh-my-tmux, or other sessions. Returns true if the session mouse option
1488
2120
  * was set successfully, false otherwise.
1489
2121
  */
1490
- export function enableMouseScrolling(sessionTarget) {
2122
+ export function enableMouseScrolling(sessionTarget, beforeEffect) {
2123
+ beforeEffect?.();
1491
2124
  const result = runTmux(['set-option', '-t', sessionTarget, 'mouse', 'on']);
1492
2125
  if (!result.ok)
1493
2126
  return false;
1494
2127
  // Enable OSC 52 so copy-selection-and-cancel propagates selected text to
1495
2128
  // the terminal's clipboard without requiring xclip or pbcopy. (closes #206)
2129
+ beforeEffect?.();
1496
2130
  runTmux(['set-option', '-t', sessionTarget, 'set-clipboard', 'on']);
1497
2131
  // Mouse selection enters tmux copy-mode. Keep the mitigation session-scoped
1498
2132
  // so OMX does not mutate users' global tmux style defaults. (issue #1448)
1499
- mitigateCopyModeUnderlineArtifacts(sessionTarget);
2133
+ mitigateCopyModeUnderlineArtifacts(sessionTarget, beforeEffect);
1500
2134
  return true;
1501
2135
  }
1502
- function paneTarget(sessionName, workerIndex, workerPaneId) {
1503
- if (workerPaneId && workerPaneId.startsWith('%'))
1504
- return workerPaneId;
2136
+ function paneTarget(sessionName, workerIndex) {
1505
2137
  if (sessionName.includes(':')) {
1506
2138
  return `${sessionName}.${workerIndex}`;
1507
2139
  }
@@ -1547,24 +2179,50 @@ export function evaluateStartupDirectTriggerSafetyCapture(captured, workerCli) {
1547
2179
  export async function evaluateStartupDirectTriggerSafety(sessionName, workerIndex, workerPaneId, workerCli) {
1548
2180
  if (!isTmuxAvailable())
1549
2181
  return { safe: false, reason: 'tmux_unavailable' };
1550
- const target = paneTarget(sessionName, workerIndex, workerPaneId);
2182
+ const target = await resolveWorkerPaneTargetAsync(sessionName, workerIndex, workerPaneId);
2183
+ if (!target)
2184
+ return { safe: false, reason: 'capture_failed' };
1551
2185
  const result = await runTmuxAsync(sharedBuildVisibleCapturePaneArgv(target));
1552
2186
  if (!result.ok)
1553
2187
  return { safe: false, reason: 'capture_failed' };
1554
2188
  return evaluateStartupDirectTriggerSafetyCapture(result.stdout, workerCli);
1555
2189
  }
1556
- function acceptClaudeBypassPermissionsPrompt(target) {
1557
- runTmux(['send-keys', '-t', target, '-l', '--', '2']);
2190
+ function acceptClaudeBypassPermissionsPrompt(resolveTarget) {
2191
+ const literalTarget = resolveTarget();
2192
+ if (!literalTarget)
2193
+ return false;
2194
+ runTmux(['send-keys', '-t', literalTarget, '-l', '--', '2']);
1558
2195
  sleepFractionalSeconds(0.12);
1559
- runTmux(['send-keys', '-t', target, 'C-m']);
2196
+ const submitTarget = resolveTarget();
2197
+ if (!submitTarget)
2198
+ return false;
2199
+ runTmux(['send-keys', '-t', submitTarget, 'C-m']);
2200
+ return true;
1560
2201
  }
1561
- function dismissClaudeBypassPermissionsPromptIfPresent(target, captured) {
2202
+ function dismissClaudeBypassPermissionsPromptIfPresent(resolveTarget, captured) {
1562
2203
  if (process.env.OMX_TEAM_AUTO_ACCEPT_BYPASS === '0')
1563
2204
  return false;
1564
2205
  if (!paneHasClaudeBypassPermissionsPrompt(captured))
1565
2206
  return false;
1566
- acceptClaudeBypassPermissionsPrompt(target);
1567
- return true;
2207
+ return acceptClaudeBypassPermissionsPrompt(resolveTarget);
2208
+ }
2209
+ async function dismissClaudeBypassPermissionsPromptIfPresentAsync(resolveTarget, captured) {
2210
+ if (process.env.OMX_TEAM_AUTO_ACCEPT_BYPASS === '0')
2211
+ return false;
2212
+ if (!paneHasClaudeBypassPermissionsPrompt(captured))
2213
+ return false;
2214
+ const literalTarget = await resolveTarget();
2215
+ if (!literalTarget)
2216
+ return false;
2217
+ const literalSend = await runTmuxAsync(['send-keys', '-t', literalTarget, '-l', '--', '2']);
2218
+ if (!literalSend.ok)
2219
+ return false;
2220
+ await sleep(120);
2221
+ const submitTarget = await resolveTarget();
2222
+ if (!submitTarget)
2223
+ return false;
2224
+ const submitSend = await runTmuxAsync(['send-keys', '-t', submitTarget, 'C-m']);
2225
+ return submitSend.ok;
1568
2226
  }
1569
2227
  export const paneHasActiveTask = sharedPaneHasActiveTask;
1570
2228
  export function classifyWorkerStartupInjectSafety(captured) {
@@ -1581,8 +2239,8 @@ export function classifyWorkerStartupInjectSafety(captured) {
1581
2239
  return 'safe';
1582
2240
  }
1583
2241
  export async function checkWorkerStartupInjectSafety(sessionName, workerIndex, workerPaneId) {
1584
- const target = paneTarget(sessionName, workerIndex, workerPaneId);
1585
- const visibleCapture = await captureVisiblePaneAsync(target);
2242
+ const resolveTarget = () => resolveWorkerPaneTargetAsync(sessionName, workerIndex, workerPaneId);
2243
+ const visibleCapture = await captureVisiblePaneAsync(resolveTarget);
1586
2244
  const visibleSafety = classifyWorkerStartupInjectSafety(visibleCapture);
1587
2245
  if (visibleSafety === 'safe')
1588
2246
  return { safe: true, reason: 'safe' };
@@ -1591,7 +2249,7 @@ export async function checkWorkerStartupInjectSafety(sessionName, workerIndex, w
1591
2249
  if (!sharedPaneShowsCodexViewport(visibleCapture)) {
1592
2250
  return { safe: false, reason: visibleSafety };
1593
2251
  }
1594
- const scrollbackCapture = await capturePaneAsync(target);
2252
+ const scrollbackCapture = await capturePaneAsync(resolveTarget);
1595
2253
  const scrollbackSafety = classifyWorkerStartupInjectSafety(scrollbackCapture);
1596
2254
  return scrollbackSafety === 'safe'
1597
2255
  ? { safe: true, reason: 'safe' }
@@ -1669,8 +2327,9 @@ export function shouldAttemptAdaptiveRetry(strategy, paneBusyAtStart, allowAdapt
1669
2327
  return false;
1670
2328
  return true;
1671
2329
  }
1672
- function sendLiteralTextOrThrow(target, text) {
1673
- const send = runTmux(['send-keys', '-t', target, '-l', '--', text]);
2330
+ async function sendLiteralTextOrThrow(resolveTarget, text) {
2331
+ const target = await requireAsyncPaneTarget(resolveTarget);
2332
+ const send = await runTmuxAsync(['send-keys', '-t', target, '-l', '--', text]);
1674
2333
  if (!send.ok) {
1675
2334
  throw new Error(`sendToWorker: failed to send text: ${send.stderr}`);
1676
2335
  }
@@ -1682,18 +2341,18 @@ function paneHasQueuedCodexSubmission(captured) {
1682
2341
  return /messages to be submitted after next tool call/i.test(normalized)
1683
2342
  || /press esc to interrupt and send immediately/i.test(normalized);
1684
2343
  }
1685
- async function attemptSubmitRounds(target, text, rounds, queueFirstRound, submitKeyPressesPerRound) {
2344
+ async function attemptSubmitRounds(resolveTarget, text, rounds, queueFirstRound, submitKeyPressesPerRound) {
1686
2345
  const presses = Math.max(1, Math.floor(submitKeyPressesPerRound));
1687
2346
  for (let round = 0; round < rounds; round++) {
1688
2347
  await sleep(100);
1689
2348
  if (round === 0 && queueFirstRound) {
1690
- await sendKeyAsync(target, 'Tab');
2349
+ await sendKeyAsync(resolveTarget, 'Tab');
1691
2350
  await sleep(80);
1692
- await sendKeyAsync(target, 'C-m');
2351
+ await sendKeyAsync(resolveTarget, 'C-m');
1693
2352
  }
1694
2353
  else {
1695
2354
  for (let press = 0; press < presses; press++) {
1696
- await sendKeyAsync(target, 'C-m');
2355
+ await sendKeyAsync(resolveTarget, 'C-m');
1697
2356
  if (press < presses - 1) {
1698
2357
  await sleep(200);
1699
2358
  }
@@ -1701,8 +2360,8 @@ async function attemptSubmitRounds(target, text, rounds, queueFirstRound, submit
1701
2360
  }
1702
2361
  await sleep(140);
1703
2362
  const [captured, visibleCapture] = await Promise.all([
1704
- capturePaneAsync(target),
1705
- captureVisiblePaneAsync(target),
2363
+ capturePaneAsync(resolveTarget),
2364
+ captureVisiblePaneAsync(resolveTarget),
1706
2365
  ]);
1707
2366
  const normalizedCapture = normalizeWorkerTriggerForDraftMatch(captured);
1708
2367
  if (!normalizedCapture.includes(normalizeWorkerTriggerForDraftMatch(text))
@@ -1713,26 +2372,34 @@ async function attemptSubmitRounds(target, text, rounds, queueFirstRound, submit
1713
2372
  }
1714
2373
  return false;
1715
2374
  }
1716
- export function waitForWorkerReady(sessionName, workerIndex, timeoutMs = 30_000, workerPaneId) {
2375
+ export function waitForWorkerReady(sessionName, workerIndex, timeoutMs = 30_000, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
1717
2376
  const initialBackoffMs = 150;
1718
2377
  const maxBackoffMs = 8000;
1719
2378
  const startedAt = Date.now();
1720
2379
  let blockedByTrustPrompt = false;
1721
2380
  let promptDismissed = false;
2381
+ const resolveTarget = createPinnedWorkerPaneTargetResolverSync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId);
1722
2382
  const sendRobustEnter = () => {
1723
- const target = paneTarget(sessionName, workerIndex, workerPaneId);
1724
2383
  // Trust + follow-up splash can require two submits in Codex TUI.
1725
2384
  // Use C-m (carriage return) for raw-mode compatibility.
1726
- runTmux(['send-keys', '-t', target, 'C-m']);
2385
+ const firstTarget = resolveTarget();
2386
+ if (!firstTarget)
2387
+ return;
2388
+ runTmux(['send-keys', '-t', firstTarget, 'C-m']);
1727
2389
  sleepFractionalSeconds(0.12);
1728
- runTmux(['send-keys', '-t', target, 'C-m']);
2390
+ const secondTarget = resolveTarget();
2391
+ if (!secondTarget)
2392
+ return;
2393
+ runTmux(['send-keys', '-t', secondTarget, 'C-m']);
1729
2394
  };
1730
2395
  const check = () => {
1731
- const target = paneTarget(sessionName, workerIndex, workerPaneId);
2396
+ const target = resolveTarget();
2397
+ if (!target)
2398
+ return false;
1732
2399
  const result = runTmux(sharedBuildVisibleCapturePaneArgv(target));
1733
2400
  if (!result.ok)
1734
2401
  return false;
1735
- if (dismissClaudeBypassPermissionsPromptIfPresent(target, result.stdout)) {
2402
+ if (dismissClaudeBypassPermissionsPromptIfPresent(resolveTarget, result.stdout)) {
1736
2403
  promptDismissed = true;
1737
2404
  return false;
1738
2405
  }
@@ -1757,7 +2424,10 @@ export function waitForWorkerReady(sessionName, workerIndex, timeoutMs = 30_000,
1757
2424
  // scrollback for the prompt/helper text that may have slipped below the fold.
1758
2425
  if (!sharedPaneShowsCodexViewport(result.stdout))
1759
2426
  return false;
1760
- const scrollbackResult = runTmux(sharedBuildCapturePaneArgv(target, 80));
2427
+ const scrollbackTarget = resolveTarget();
2428
+ if (!scrollbackTarget)
2429
+ return false;
2430
+ const scrollbackResult = runTmux(sharedBuildCapturePaneArgv(scrollbackTarget, 80));
1761
2431
  if (!scrollbackResult.ok)
1762
2432
  return false;
1763
2433
  return paneLooksReady(scrollbackResult.stdout);
@@ -1785,26 +2455,34 @@ export function waitForWorkerReady(sessionName, workerIndex, timeoutMs = 30_000,
1785
2455
  // Async twin of waitForWorkerReady for team startup fan-out. Keep the readiness
1786
2456
  // semantics mirrored with the synchronous helper above, but yield between polls
1787
2457
  // so one slow worker pane cannot block later workers' startup attempts.
1788
- export async function waitForWorkerReadyAsync(sessionName, workerIndex, timeoutMs = 30_000, workerPaneId) {
2458
+ export async function waitForWorkerReadyAsync(sessionName, workerIndex, timeoutMs = 30_000, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
1789
2459
  const initialBackoffMs = 150;
1790
2460
  const maxBackoffMs = 8000;
1791
2461
  const startedAt = Date.now();
1792
2462
  let blockedByTrustPrompt = false;
1793
2463
  let promptDismissed = false;
2464
+ const resolveTarget = createPinnedWorkerPaneTargetResolver(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId);
1794
2465
  const sendRobustEnter = async () => {
1795
- const target = paneTarget(sessionName, workerIndex, workerPaneId);
1796
2466
  // Trust + follow-up splash can require two submits in Codex TUI.
1797
2467
  // Use C-m (carriage return) for raw-mode compatibility.
1798
- await runTmuxAsync(['send-keys', '-t', target, 'C-m']);
2468
+ const firstTarget = await resolveTarget();
2469
+ if (!firstTarget)
2470
+ return;
2471
+ await runTmuxAsync(['send-keys', '-t', firstTarget, 'C-m']);
1799
2472
  await sleep(120);
1800
- await runTmuxAsync(['send-keys', '-t', target, 'C-m']);
2473
+ const secondTarget = await resolveTarget();
2474
+ if (!secondTarget)
2475
+ return;
2476
+ await runTmuxAsync(['send-keys', '-t', secondTarget, 'C-m']);
1801
2477
  };
1802
2478
  const check = async () => {
1803
- const target = paneTarget(sessionName, workerIndex, workerPaneId);
2479
+ const target = await resolveTarget();
2480
+ if (!target)
2481
+ return false;
1804
2482
  const result = await runTmuxAsync(sharedBuildVisibleCapturePaneArgv(target));
1805
2483
  if (!result.ok)
1806
2484
  return false;
1807
- if (dismissClaudeBypassPermissionsPromptIfPresent(target, result.stdout)) {
2485
+ if (await dismissClaudeBypassPermissionsPromptIfPresentAsync(resolveTarget, result.stdout)) {
1808
2486
  promptDismissed = true;
1809
2487
  return false;
1810
2488
  }
@@ -1829,7 +2507,10 @@ export async function waitForWorkerReadyAsync(sessionName, workerIndex, timeoutM
1829
2507
  // scrollback for the prompt/helper text that may have slipped below the fold.
1830
2508
  if (!sharedPaneShowsCodexViewport(result.stdout))
1831
2509
  return false;
1832
- const scrollbackResult = await runTmuxAsync(sharedBuildCapturePaneArgv(target, 80));
2510
+ const scrollbackTarget = await resolveTarget();
2511
+ if (!scrollbackTarget)
2512
+ return false;
2513
+ const scrollbackResult = await runTmuxAsync(sharedBuildCapturePaneArgv(scrollbackTarget, 80));
1833
2514
  if (!scrollbackResult.ok)
1834
2515
  return false;
1835
2516
  return paneLooksReady(scrollbackResult.stdout);
@@ -1859,21 +2540,30 @@ export async function waitForWorkerReadyAsync(sessionName, workerIndex, timeoutM
1859
2540
  * Returns true if a trust prompt was found and dismissed, false otherwise.
1860
2541
  * Opt-out: set OMX_TEAM_AUTO_TRUST=0 to disable auto-dismissal.
1861
2542
  */
1862
- export function dismissTrustPromptIfPresent(sessionName, workerIndex, workerPaneId) {
2543
+ export function dismissTrustPromptIfPresent(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
1863
2544
  if (process.env.OMX_TEAM_AUTO_TRUST === '0')
1864
2545
  return false;
1865
2546
  if (!isTmuxAvailable())
1866
2547
  return false;
1867
- const target = paneTarget(sessionName, workerIndex, workerPaneId);
1868
- const result = runTmux(sharedBuildVisibleCapturePaneArgv(target));
2548
+ const resolveTarget = createPinnedWorkerPaneTargetResolverSync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId);
2549
+ const captureTarget = resolveTarget();
2550
+ if (!captureTarget)
2551
+ return false;
2552
+ const result = runTmux(sharedBuildVisibleCapturePaneArgv(captureTarget));
1869
2553
  if (!result.ok)
1870
2554
  return false;
1871
2555
  if (!paneHasTrustPrompt(result.stdout))
1872
2556
  return false;
1873
2557
  // Trust prompt detected; send C-m twice to dismiss (trust + follow-up splash)
1874
- runTmux(['send-keys', '-t', target, 'C-m']);
2558
+ const firstTarget = resolveTarget();
2559
+ if (!firstTarget)
2560
+ return false;
2561
+ runTmux(['send-keys', '-t', firstTarget, 'C-m']);
1875
2562
  sleepFractionalSeconds(0.12);
1876
- runTmux(['send-keys', '-t', target, 'C-m']);
2563
+ const secondTarget = resolveTarget();
2564
+ if (!secondTarget)
2565
+ return false;
2566
+ runTmux(['send-keys', '-t', secondTarget, 'C-m']);
1877
2567
  return true;
1878
2568
  }
1879
2569
  export const normalizeTmuxCapture = sharedNormalizeTmuxCapture;
@@ -1904,49 +2594,49 @@ export function sendToWorkerStdin(stdin, text) {
1904
2594
  // Send SHORT text (<200 chars) to worker via tmux send-keys
1905
2595
  // Validates: text < 200 chars, no injection marker
1906
2596
  // Throws on violation
1907
- export async function sendToWorker(sessionName, workerIndex, text, workerPaneId, workerCli) {
2597
+ export async function sendToWorker(sessionName, workerIndex, text, workerPaneId, workerCli, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
1908
2598
  assertWorkerTriggerText(text);
1909
- const target = paneTarget(sessionName, workerIndex, workerPaneId);
2599
+ const resolveTarget = createPinnedWorkerPaneTargetResolver(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId);
1910
2600
  const strategy = resolveSendStrategyFromEnv();
1911
2601
  const resolvedWorkerCli = resolveWorkerCliForSend(workerIndex, workerCli);
1912
2602
  // Guard: if the trust prompt is still present, advance it first so our trigger text
1913
2603
  // doesn't get typed into the trust screen and ignored.
1914
- const capturedStr = await capturePaneAsync(target);
2604
+ const capturedStr = await capturePaneAsync(resolveTarget);
1915
2605
  const paneBusy = paneHasActiveTask(capturedStr);
1916
- if (dismissClaudeBypassPermissionsPromptIfPresent(target, capturedStr)) {
2606
+ if (await dismissClaudeBypassPermissionsPromptIfPresentAsync(resolveTarget, capturedStr)) {
1917
2607
  await sleep(200);
1918
2608
  }
1919
2609
  if (paneHasTrustPrompt(capturedStr)) {
1920
- await sendKeyAsync(target, 'C-m');
2610
+ await sendKeyAsync(resolveTarget, 'C-m');
1921
2611
  await sleep(120);
1922
- await sendKeyAsync(target, 'C-m');
2612
+ await sendKeyAsync(resolveTarget, 'C-m');
1923
2613
  await sleep(200);
1924
2614
  }
1925
- sendLiteralTextOrThrow(target, text);
2615
+ await sendLiteralTextOrThrow(resolveTarget, text);
1926
2616
  // Allow the input buffer to settle before sending C-m
1927
2617
  await sleep(150);
1928
2618
  const allowAutoInterruptRetry = process.env[OMX_TEAM_AUTO_INTERRUPT_RETRY_ENV] !== '0';
1929
2619
  const submitPlan = buildWorkerSubmitPlan(strategy, resolvedWorkerCli, paneBusy, allowAutoInterruptRetry);
1930
2620
  if (submitPlan.shouldInterrupt) {
1931
2621
  // Explicit interrupt mode: abort current turn first, then submit the new command.
1932
- await sendKeyAsync(target, 'C-c');
2622
+ await sendKeyAsync(resolveTarget, 'C-c');
1933
2623
  await sleep(100);
1934
2624
  }
1935
2625
  // Submit deterministically using CLI-specific plan:
1936
2626
  // - Codex: queue-first Tab+C-m when configured/busy, then double C-m rounds.
1937
2627
  // - Claude: direct C-m rounds only (never queue-first Tab).
1938
- if (await attemptSubmitRounds(target, text, submitPlan.rounds, submitPlan.queueFirstRound, submitPlan.submitKeyPressesPerRound))
2628
+ if (await attemptSubmitRounds(resolveTarget, text, submitPlan.rounds, submitPlan.queueFirstRound, submitPlan.submitKeyPressesPerRound))
1939
2629
  return;
1940
2630
  // Adaptive escalation for "likely unsent trigger text at ready prompt" cases:
1941
2631
  // clear line, re-send trigger, then re-submit with deterministic C-m rounds.
1942
- const latestCapture = await capturePaneAsync(target);
2632
+ const latestCapture = await capturePaneAsync(resolveTarget);
1943
2633
  if (shouldAttemptAdaptiveRetry(strategy, paneBusy, submitPlan.allowAdaptiveRetry, latestCapture || null, text)) {
1944
2634
  // Keep this branch non-interrupting to avoid canceling active turns on false positives.
1945
- await sendKeyAsync(target, 'C-u');
2635
+ await sendKeyAsync(resolveTarget, 'C-u');
1946
2636
  await sleep(80);
1947
- sendLiteralTextOrThrow(target, text);
2637
+ await sendLiteralTextOrThrow(resolveTarget, text);
1948
2638
  await sleep(120);
1949
- if (await attemptSubmitRounds(target, text, 4, false, submitPlan.submitKeyPressesPerRound))
2639
+ if (await attemptSubmitRounds(resolveTarget, text, 4, false, submitPlan.submitKeyPressesPerRound))
1950
2640
  return;
1951
2641
  }
1952
2642
  // Fail-open by default: Codex may keep the last submitted line visible even after executing it.
@@ -1956,15 +2646,15 @@ export async function sendToWorker(sessionName, workerIndex, text, workerPaneId,
1956
2646
  throw new Error('sendToWorker: submit_failed (trigger text still visible after retries)');
1957
2647
  }
1958
2648
  // One last best-effort double C-m nudge, then verify.
1959
- await sendKeyAsync(target, 'C-m');
2649
+ await sendKeyAsync(resolveTarget, 'C-m');
1960
2650
  await sleep(120);
1961
- await sendKeyAsync(target, 'C-m');
2651
+ await sendKeyAsync(resolveTarget, 'C-m');
1962
2652
  // Post-submit verification: wait briefly and confirm the worker consumed the
1963
2653
  // trigger (draft disappeared or active-task indicator appeared). Fixes #391.
1964
2654
  await sleep(300);
1965
2655
  const [verifyCapture, verifyVisibleCapture] = await Promise.all([
1966
- capturePaneAsync(target),
1967
- captureVisiblePaneAsync(target),
2656
+ capturePaneAsync(resolveTarget),
2657
+ captureVisiblePaneAsync(resolveTarget),
1968
2658
  ]);
1969
2659
  if (verifyCapture) {
1970
2660
  if (paneHasActiveTask(verifyCapture))
@@ -1974,14 +2664,14 @@ export async function sendToWorker(sessionName, workerIndex, text, workerPaneId,
1974
2664
  return;
1975
2665
  }
1976
2666
  // Draft still visible and no active task — one more C-m attempt.
1977
- await sendKeyAsync(target, 'C-m');
2667
+ await sendKeyAsync(resolveTarget, 'C-m');
1978
2668
  await sleep(150);
1979
- await sendKeyAsync(target, 'C-m');
1980
- const finalVisibleCapture = await captureVisiblePaneAsync(target);
2669
+ await sendKeyAsync(resolveTarget, 'C-m');
2670
+ const finalVisibleCapture = await captureVisiblePaneAsync(resolveTarget);
1981
2671
  if (paneHasQueuedCodexSubmission(finalVisibleCapture)) {
1982
2672
  throw new Error('sendToWorker: submit_queued_after_tool_call');
1983
2673
  }
1984
- const finalCapture = await capturePaneAsync(target);
2674
+ const finalCapture = await capturePaneAsync(resolveTarget);
1985
2675
  if (normalizeWorkerTriggerForDraftMatch(finalCapture).includes(normalizeWorkerTriggerForDraftMatch(text))
1986
2676
  && !paneHasActiveTask(finalCapture)
1987
2677
  && paneLooksReady(finalCapture)) {
@@ -2000,8 +2690,12 @@ export function notifyLeaderStatus(sessionName, message) {
2000
2690
  return result.ok;
2001
2691
  }
2002
2692
  // Get PID of the shell process in a worker's tmux pane
2003
- export function getWorkerPanePid(sessionName, workerIndex, workerPaneId) {
2004
- const result = runTmux(['list-panes', '-t', paneTarget(sessionName, workerIndex, workerPaneId), '-F', '#{pane_pid}']);
2693
+ export function getWorkerPanePid(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
2694
+ if (hasExplicitWorkerPaneId(workerPaneId)) {
2695
+ const target = createPinnedWorkerPaneTargetResolverSync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId)();
2696
+ return target ? expectedPanePid ?? null : null;
2697
+ }
2698
+ const result = runTmux(['list-panes', '-t', paneTarget(sessionName, workerIndex), '-F', '#{pane_pid}']);
2005
2699
  if (!result.ok)
2006
2700
  return null;
2007
2701
  const firstLine = result.stdout.split('\n')[0]?.trim();
@@ -2013,15 +2707,16 @@ export function getWorkerPanePid(sessionName, workerIndex, workerPaneId) {
2013
2707
  return pid;
2014
2708
  }
2015
2709
  // Check if worker's tmux pane has a running process
2016
- export function isWorkerAlive(sessionName, workerIndex, workerPaneId) {
2017
- if (workerPaneId?.startsWith('%')) {
2018
- const paneStatus = readPaneLivenessById(workerPaneId);
2019
- if (paneStatus !== null)
2020
- return paneStatus;
2710
+ export function isWorkerAlive(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
2711
+ if (hasExplicitWorkerPaneId(workerPaneId)) {
2712
+ const target = createPinnedWorkerPaneTargetResolverSync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId)();
2713
+ if (!target || typeof expectedPanePid !== 'number')
2714
+ return false;
2715
+ return probeProcessLiveness(expectedPanePid) !== 'gone';
2021
2716
  }
2022
2717
  const result = runTmux([
2023
2718
  'list-panes',
2024
- '-t', paneTarget(sessionName, workerIndex, workerPaneId),
2719
+ '-t', paneTarget(sessionName, workerIndex),
2025
2720
  '-F',
2026
2721
  '#{pane_dead} #{pane_pid}',
2027
2722
  ]);
@@ -2039,23 +2734,17 @@ export function isWorkerAlive(sessionName, workerIndex, workerPaneId) {
2039
2734
  return false;
2040
2735
  if (!Number.isFinite(pid))
2041
2736
  return false;
2042
- try {
2043
- process.kill(pid, 0);
2044
- return true;
2045
- }
2046
- catch {
2047
- return false;
2048
- }
2737
+ // Unknown is conservatively live so callers cannot classify a permission or
2738
+ // transient failure as stale cleanup authority.
2739
+ return probeProcessLiveness(pid) !== 'gone';
2049
2740
  }
2050
- export function isWorkerPaneOpen(sessionName, workerIndex, workerPaneId) {
2051
- if (workerPaneId?.startsWith('%')) {
2052
- const paneStatus = readPaneLivenessById(workerPaneId);
2053
- if (paneStatus !== null)
2054
- return paneStatus;
2741
+ export function isWorkerPaneOpen(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
2742
+ if (hasExplicitWorkerPaneId(workerPaneId)) {
2743
+ return createPinnedWorkerPaneTargetResolverSync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId)() !== null;
2055
2744
  }
2056
2745
  const result = runTmux([
2057
2746
  'list-panes',
2058
- '-t', paneTarget(sessionName, workerIndex, workerPaneId),
2747
+ '-t', paneTarget(sessionName, workerIndex),
2059
2748
  '-F',
2060
2749
  '#{pane_dead}',
2061
2750
  ]);
@@ -2068,28 +2757,69 @@ export function isWorkerPaneOpen(sessionName, workerIndex, workerPaneId) {
2068
2757
  }
2069
2758
  // Kill a specific worker: send C-c, then C-d, then kill-pane if still alive.
2070
2759
  // leaderPaneId: when provided, the kill is skipped entirely if workerPaneId matches it.
2071
- export async function killWorker(sessionName, workerIndex, workerPaneId, leaderPaneId) {
2760
+ export async function killWorker(sessionName, workerIndex, workerPaneId, leaderPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
2072
2761
  // Guard: never kill the leader's own pane.
2073
2762
  if (leaderPaneId && workerPaneId === leaderPaneId)
2074
2763
  return;
2075
- await runTmuxAsync(['send-keys', '-t', paneTarget(sessionName, workerIndex, workerPaneId), 'C-c']);
2764
+ const resolveTarget = createPinnedWorkerPaneTargetResolver(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId);
2765
+ const initialTarget = await resolveTarget();
2766
+ if (!initialTarget)
2767
+ return;
2768
+ if (await isWorkerAliveAsync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId, resolveTarget) !== 'alive')
2769
+ return;
2770
+ await runTmuxAsync(['send-keys', '-t', initialTarget, 'C-c']);
2076
2771
  await sleep(1000);
2077
- if (await isWorkerAliveAsync(sessionName, workerIndex, workerPaneId)) {
2078
- await runTmuxAsync(['send-keys', '-t', paneTarget(sessionName, workerIndex, workerPaneId), 'C-d']);
2079
- await sleep(1000);
2080
- }
2081
- if (await isWorkerAliveAsync(sessionName, workerIndex, workerPaneId)) {
2082
- await runTmuxAsync(['kill-pane', '-t', paneTarget(sessionName, workerIndex, workerPaneId)]);
2772
+ if (await isWorkerAliveAsync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId, resolveTarget) === 'alive') {
2773
+ const exitTarget = await resolveTarget();
2774
+ if (exitTarget) {
2775
+ await runTmuxAsync(['send-keys', '-t', exitTarget, 'C-d']);
2776
+ await sleep(1000);
2777
+ }
2083
2778
  }
2084
- }
2085
- // leaderPaneId: when provided, the kill is skipped if workerPaneId matches it.
2086
- export function killWorkerByPaneId(workerPaneId, leaderPaneId) {
2087
- if (!workerPaneId.startsWith('%'))
2779
+ if (await isWorkerAliveAsync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId, resolveTarget) === 'alive') {
2780
+ const killTarget = await resolveTarget();
2781
+ if (!killTarget)
2782
+ return;
2783
+ const killed = await runTmuxAsync(['kill-pane', '-t', killTarget]);
2784
+ if (!killed.ok)
2785
+ throw new Error(`failed to kill tmux pane ${killTarget}: ${killed.stderr}`);
2786
+ const absence = await readExactPaneProof(killTarget);
2787
+ if (absence.status === 'unavailable')
2788
+ throw new ExactPaneProofUnavailableError(absence);
2789
+ if (absence.status !== 'gone')
2790
+ throw new Error(`tmux pane remains live after kill: ${killTarget}`);
2791
+ }
2792
+ }
2793
+ // Explicit pane targets require frozen PID, canonical Team owner, and HUD
2794
+ // exclusion. A successful kill is only accepted after a fresh absence proof.
2795
+ export function killWorkerByPaneId(workerPaneId, expectedPanePid, leaderPaneId, expectedTeamOwnerId, hudPaneId) {
2796
+ const expectedOwner = typeof expectedTeamOwnerId === 'string' ? expectedTeamOwnerId.trim() : '';
2797
+ if (!hasExplicitWorkerPaneId(workerPaneId)
2798
+ || typeof expectedPanePid !== 'number'
2799
+ || !Number.isSafeInteger(expectedPanePid)
2800
+ || expectedPanePid <= 0
2801
+ || !expectedOwner
2802
+ || workerPaneId === hudPaneId)
2088
2803
  return;
2089
- // Guard: never kill the leader's own pane.
2090
2804
  if (leaderPaneId && workerPaneId === leaderPaneId)
2091
2805
  return;
2092
- runTmux(['kill-pane', '-t', workerPaneId]);
2806
+ const proof = readExactPaneProofSync(workerPaneId);
2807
+ if (proof.status !== 'live' || proof.pid !== expectedPanePid)
2808
+ return;
2809
+ const owner = readPaneTeamOwnerTagResult(proof.paneId);
2810
+ if (owner.status !== 'value' || owner.value !== expectedOwner)
2811
+ return;
2812
+ const finalProof = readExactPaneProofSync(proof.paneId);
2813
+ if (finalProof.status !== 'live' || finalProof.pid !== expectedPanePid)
2814
+ return;
2815
+ const result = runTmux(['kill-pane', '-t', finalProof.paneId]);
2816
+ if (!result.ok)
2817
+ throw new Error(`failed to kill tmux pane ${finalProof.paneId}: ${result.stderr}`);
2818
+ const absence = readExactPaneProofSync(finalProof.paneId);
2819
+ if (absence.status === 'unavailable')
2820
+ throw new ExactPaneProofUnavailableError(absence);
2821
+ if (absence.status !== 'gone')
2822
+ throw new Error(`tmux pane remains live after kill: ${finalProof.paneId}`);
2093
2823
  }
2094
2824
  export function paneHasOmxInstanceTag(paneId, instanceId) {
2095
2825
  const normalizedPaneId = normalizePaneTarget(paneId);
@@ -2140,13 +2870,34 @@ export function readPaneTeamOwnerTagResult(paneId) {
2140
2870
  return { status: 'missing' };
2141
2871
  return { status: 'error', error: stderr || `tmux show-option exited ${result.status ?? 'unknown'}` };
2142
2872
  }
2143
- export async function killWorkerByPaneIdAsync(workerPaneId, leaderPaneId) {
2144
- if (!workerPaneId.startsWith('%'))
2873
+ export async function killWorkerByPaneIdAsync(workerPaneId, expectedPanePid, leaderPaneId, expectedTeamOwnerId, hudPaneId) {
2874
+ const expectedOwner = typeof expectedTeamOwnerId === 'string' ? expectedTeamOwnerId.trim() : '';
2875
+ if (!hasExplicitWorkerPaneId(workerPaneId)
2876
+ || typeof expectedPanePid !== 'number'
2877
+ || !Number.isSafeInteger(expectedPanePid)
2878
+ || expectedPanePid <= 0
2879
+ || !expectedOwner
2880
+ || workerPaneId === hudPaneId)
2145
2881
  return;
2146
- // Guard: never kill the leader's own pane.
2147
2882
  if (leaderPaneId && workerPaneId === leaderPaneId)
2148
2883
  return;
2149
- await runTmuxAsync(['kill-pane', '-t', workerPaneId]);
2884
+ const proof = await readExactPaneProof(workerPaneId);
2885
+ if (proof.status !== 'live' || proof.pid !== expectedPanePid)
2886
+ return;
2887
+ const owner = readPaneTeamOwnerTagResult(proof.paneId);
2888
+ if (owner.status !== 'value' || owner.value !== expectedOwner)
2889
+ return;
2890
+ const finalProof = await readExactPaneProof(proof.paneId);
2891
+ if (finalProof.status !== 'live' || finalProof.pid !== expectedPanePid)
2892
+ return;
2893
+ const result = await runTmuxAsync(['kill-pane', '-t', finalProof.paneId]);
2894
+ if (!result.ok)
2895
+ throw new Error(`failed to kill tmux pane ${finalProof.paneId}: ${result.stderr}`);
2896
+ const absence = await readExactPaneProof(finalProof.paneId);
2897
+ if (absence.status === 'unavailable')
2898
+ throw new ExactPaneProofUnavailableError(absence);
2899
+ if (absence.status !== 'gone')
2900
+ throw new Error(`tmux pane remains live after kill: ${finalProof.paneId}`);
2150
2901
  }
2151
2902
  function normalizePaneTarget(value) {
2152
2903
  if (typeof value !== 'string')
@@ -2184,7 +2935,10 @@ function normalizePaneTargets(paneIds, options = {}) {
2184
2935
  return { killablePaneIds, excluded };
2185
2936
  }
2186
2937
  export function resolveSharedSessionShutdownTopology(sessionName, preferredLeaderPaneId, teamName) {
2187
- const panes = listPanes(sessionName);
2938
+ const paneList = listPanesResult(sessionName);
2939
+ if (paneList.error !== null)
2940
+ return { status: 'unavailable', detail: paneList.error };
2941
+ const panes = paneList.panes;
2188
2942
  const livePaneIds = panes
2189
2943
  .map((pane) => normalizePaneTarget(pane.paneId))
2190
2944
  .filter((paneId) => Boolean(paneId));
@@ -2192,6 +2946,7 @@ export function resolveSharedSessionShutdownTopology(sessionName, preferredLeade
2192
2946
  if (panes.length === 0) {
2193
2947
  return {
2194
2948
  livePaneIds,
2949
+ status: 'available',
2195
2950
  teamWorkerPaneIds: [],
2196
2951
  leaderPaneId: fallbackLeaderPaneId,
2197
2952
  hudPaneIds: [],
@@ -2222,6 +2977,7 @@ export function resolveSharedSessionShutdownTopology(sessionName, preferredLeade
2222
2977
  : [];
2223
2978
  return {
2224
2979
  livePaneIds,
2980
+ status: 'available',
2225
2981
  teamWorkerPaneIds: normalizedTeamWorkerPaneIds,
2226
2982
  leaderPaneId: resolvedLeaderPaneId,
2227
2983
  hudPaneIds,
@@ -2336,7 +3092,8 @@ function escapeRegExp(value) {
2336
3092
  }
2337
3093
  /**
2338
3094
  * Shared pane-id-direct teardown primitive for worker pane cleanup.
2339
- * Must remain liveness-agnostic: do not gate on isWorkerAlive/killWorker.
3095
+ * A fresh exact global proof is required before each kill; proven-gone panes
3096
+ * are teardown-compatible while unavailable proofs remain fail-closed.
2340
3097
  */
2341
3098
  export async function teardownWorkerPanes(paneIds, options = {}) {
2342
3099
  const { killablePaneIds, excluded } = normalizePaneTargets(paneIds, options);
@@ -2347,45 +3104,247 @@ export async function teardownWorkerPanes(paneIds, options = {}) {
2347
3104
  const summary = {
2348
3105
  attemptedPaneIds: killablePaneIds,
2349
3106
  excluded,
3107
+ provenGonePaneIds: [],
3108
+ killedPaneIds: [],
3109
+ proofUnavailable: [],
2350
3110
  kill: {
2351
- attempted: killablePaneIds.length,
3111
+ attempted: 0,
2352
3112
  succeeded: 0,
2353
3113
  failed: 0,
3114
+ failedPaneIds: [],
2354
3115
  },
2355
3116
  };
2356
3117
  for (const paneId of killablePaneIds) {
2357
- const result = await runTmuxAsync(['kill-pane', '-t', paneId]);
2358
- if (result.ok)
3118
+ const proof = await readExactPaneProof(paneId);
3119
+ const expectedPid = options.expectedPanePids?.[paneId];
3120
+ if (proof.status === 'gone') {
3121
+ summary.provenGonePaneIds.push(proof.paneId);
3122
+ continue;
3123
+ }
3124
+ if (proof.status === 'unavailable') {
3125
+ summary.proofUnavailable.push(proof);
3126
+ break;
3127
+ }
3128
+ if (typeof expectedPid !== 'number' || !Number.isSafeInteger(expectedPid) || expectedPid <= 0) {
3129
+ summary.proofUnavailable.push({
3130
+ status: 'unavailable',
3131
+ paneId,
3132
+ reason: 'pane_pid_changed',
3133
+ detail: 'expected positive pane PID is required before teardown kill',
3134
+ });
3135
+ break;
3136
+ }
3137
+ if (proof.pid !== expectedPid) {
3138
+ summary.proofUnavailable.push({
3139
+ status: 'unavailable',
3140
+ paneId,
3141
+ reason: 'pane_pid_changed',
3142
+ detail: `expected ${expectedPid}, got ${proof.pid}`,
3143
+ });
3144
+ break;
3145
+ }
3146
+ if (options.authorizePaneKill && !options.authorizePaneKill(proof.paneId, proof)) {
3147
+ summary.proofUnavailable.push({
3148
+ status: 'unavailable',
3149
+ paneId: proof.paneId,
3150
+ reason: 'pane_pid_changed',
3151
+ detail: 'pane owner authorization changed',
3152
+ });
3153
+ break;
3154
+ }
3155
+ // Owner authorization can read tmux. Re-prove the persisted identity after
3156
+ // it and immediately before kill-pane.
3157
+ const finalProof = await readExactPaneProof(proof.paneId);
3158
+ if (finalProof.status === 'gone') {
3159
+ summary.provenGonePaneIds.push(finalProof.paneId);
3160
+ continue;
3161
+ }
3162
+ if (finalProof.status === 'unavailable') {
3163
+ summary.proofUnavailable.push(finalProof);
3164
+ break;
3165
+ }
3166
+ if (finalProof.pid !== expectedPid) {
3167
+ summary.proofUnavailable.push({
3168
+ status: 'unavailable',
3169
+ paneId: finalProof.paneId,
3170
+ reason: 'pane_pid_changed',
3171
+ detail: `expected ${expectedPid}, got ${finalProof.pid}`,
3172
+ });
3173
+ break;
3174
+ }
3175
+ summary.kill.attempted += 1;
3176
+ const result = await runTmuxAsync(['kill-pane', '-t', finalProof.paneId]);
3177
+ if (!result.ok) {
3178
+ summary.kill.failed += 1;
3179
+ summary.kill.failedPaneIds.push(proof.paneId);
3180
+ await sleep(perPaneGrace);
3181
+ break;
3182
+ }
3183
+ const afterKill = await readExactPaneProof(finalProof.paneId);
3184
+ if (afterKill.status === 'gone') {
2359
3185
  summary.kill.succeeded += 1;
2360
- else
3186
+ summary.killedPaneIds.push(proof.paneId);
3187
+ }
3188
+ else if (afterKill.status === 'unavailable') {
3189
+ summary.proofUnavailable.push(afterKill);
3190
+ break;
3191
+ }
3192
+ else {
2361
3193
  summary.kill.failed += 1;
3194
+ summary.kill.failedPaneIds.push(proof.paneId);
3195
+ await sleep(perPaneGrace);
3196
+ break;
3197
+ }
2362
3198
  await sleep(perPaneGrace);
2363
3199
  }
2364
3200
  return summary;
2365
3201
  }
2366
- export async function killWorkerPanes(paneIds, leaderPaneId, graceMs = 2000, hudPaneId) {
2367
- return teardownWorkerPanes(paneIds, { leaderPaneId, hudPaneId: hudPaneId ?? null, graceMs });
2368
- }
2369
- // Kill entire tmux session. Tolerates already-dead sessions.
2370
- export function destroyTeamSession(sessionName) {
2371
- try {
2372
- runTmux(['kill-session', '-t', sessionName]);
2373
- }
2374
- catch {
2375
- // tolerate
2376
- }
3202
+ export async function killWorkerPanes(paneIds, leaderPaneId, graceMs = 2000, hudPaneId, expectedPanePids) {
3203
+ return teardownWorkerPanes(paneIds, {
3204
+ leaderPaneId,
3205
+ hudPaneId: hudPaneId ?? null,
3206
+ graceMs,
3207
+ expectedPanePids,
3208
+ });
2377
3209
  }
2378
- // List all tmux sessions matching omx-team-* pattern
2379
- export function listTeamSessions() {
2380
- const result = runTmux(['list-sessions', '-F', '#{session_name}']);
2381
- if (!result.ok)
2382
- return [];
2383
- return result.stdout
3210
+ // Kill an entire detached tmux session only when the caller has already
3211
+ // established ownership. Success requires tmux to accept the kill and a fresh
3212
+ // session-list proof that the exact base session is absent.
3213
+ function parseTeamSessionNames(stdout) {
3214
+ return stdout
2384
3215
  .split('\n')
2385
3216
  .map(line => line.trim())
2386
3217
  .filter(Boolean)
2387
3218
  .map(baseSessionName);
2388
3219
  }
3220
+ function isFinalTmuxServerGone(result) {
3221
+ return !result.ok && /^no server running on .+$/i.test(result.stderr);
3222
+ }
3223
+ /**
3224
+ * Reads pane and session incarnation in one tmux snapshot. This prevents a
3225
+ * same-name replacement from combining a stale session proof with a live pane.
3226
+ */
3227
+ export function queryDetachedSessionLeaderBinding(paneId, pid, sessionName, expected) {
3228
+ const result = runTmuxStructured(['list-panes', '-a', '-F', '#{pane_id}\t#{pane_dead}\t#{pane_pid}\t#{session_name}\t#{session_id}\t#{session_created}']);
3229
+ if (!result.ok)
3230
+ return null;
3231
+ const seenPaneIds = new Set();
3232
+ let binding = null;
3233
+ for (const line of result.stdout.split('\n').filter(Boolean)) {
3234
+ const fields = line.split('\t');
3235
+ if (fields.length !== 6)
3236
+ return null;
3237
+ const [currentPaneId, dead, panePid, currentSessionName, sessionId, sessionCreated] = fields;
3238
+ if (!/^%[0-9]+$/.test(currentPaneId) || seenPaneIds.has(currentPaneId) || (dead !== '0' && dead !== '1')
3239
+ || !/^[0-9]+$/.test(panePid) || !Number.isSafeInteger(Number(panePid)) || Number(panePid) <= 0
3240
+ || !currentSessionName || !/^\$[0-9]+$/.test(sessionId) || !/^[0-9]+$/.test(sessionCreated))
3241
+ return null;
3242
+ seenPaneIds.add(currentPaneId);
3243
+ if (currentPaneId !== paneId)
3244
+ continue;
3245
+ if (dead !== '0' || Number(panePid) !== pid || currentSessionName !== sessionName
3246
+ || sessionId !== expected.sessionId || sessionCreated !== expected.sessionCreated)
3247
+ return null;
3248
+ binding = { paneId: currentPaneId, pid: Number(panePid), sessionName: currentSessionName, incarnation: { sessionId, sessionCreated } };
3249
+ }
3250
+ return binding;
3251
+ }
3252
+ function parseDetachedSessionIncarnation(stdout, sessionName) {
3253
+ const baseName = baseSessionName(sessionName);
3254
+ const matches = [];
3255
+ for (const line of stdout.split('\n').filter(Boolean)) {
3256
+ const fields = line.split('\t');
3257
+ if (fields.length !== 3)
3258
+ return 'malformed';
3259
+ const [name, sessionId, sessionCreated] = fields;
3260
+ if (!name || !sessionId || !sessionCreated || !/^\$[0-9]+$/.test(sessionId) || !/^[0-9]+$/.test(sessionCreated))
3261
+ return 'malformed';
3262
+ if (baseSessionName(name) === baseName)
3263
+ matches.push({ sessionId, sessionCreated });
3264
+ }
3265
+ return matches.length === 0 ? null : matches.length === 1 ? matches[0] : 'malformed';
3266
+ }
3267
+ /**
3268
+ * Reads stable tmux incarnation evidence. Name-only presence is never effect
3269
+ * authority: malformed or unavailable evidence fails closed, and an expected
3270
+ * incarnation distinguishes an exact survivor from a recycled same-name session.
3271
+ */
3272
+ export function queryDetachedTeamSession(sessionName, expected) {
3273
+ const sessions = runTmuxStructured(['list-sessions', '-F', '#{session_name}\t#{session_id}\t#{session_created}']);
3274
+ if (!sessions.ok) {
3275
+ return isFinalTmuxServerGone(sessions)
3276
+ ? { status: 'absent' }
3277
+ : { status: 'unavailable', detail: sessions.stderr };
3278
+ }
3279
+ const incarnation = parseDetachedSessionIncarnation(sessions.stdout, sessionName);
3280
+ if (incarnation === 'malformed')
3281
+ return { status: 'unavailable', detail: 'malformed_session_incarnation' };
3282
+ if (incarnation === null)
3283
+ return { status: 'absent' };
3284
+ if (expected && (incarnation.sessionId !== expected.sessionId || incarnation.sessionCreated !== expected.sessionCreated)) {
3285
+ return { status: 'replacement', incarnation };
3286
+ }
3287
+ return { status: 'exact', incarnation };
3288
+ }
3289
+ /**
3290
+ * Re-proves every stable authorization component at the destructive sink. The
3291
+ * final tmux command queues an in-server predicate with the kill, so no client
3292
+ * can replace the session or pane between authorization and the effect.
3293
+ */
3294
+ function buildDetachedSessionDestroyArgs(expected) {
3295
+ const leaderPaneId = expected.leaderPaneId.trim();
3296
+ const ownerId = expected.ownerId.trim();
3297
+ if (!/^%[0-9]+$/.test(leaderPaneId)
3298
+ || !Number.isSafeInteger(expected.leaderPanePid)
3299
+ || expected.leaderPanePid <= 0
3300
+ || !/^\$[0-9]+$/.test(expected.sessionId)
3301
+ || !/^[0-9]+$/.test(expected.sessionCreated)
3302
+ || !/^[A-Za-z0-9._:@/-]+$/.test(ownerId))
3303
+ return null;
3304
+ const predicate = [
3305
+ '#{==:#{pane_dead},0}',
3306
+ `#{==:#{pane_id},${leaderPaneId}}`,
3307
+ `#{==:#{pane_pid},${expected.leaderPanePid}}`,
3308
+ `#{==:#{${OMX_TEAM_PANE_OWNER_OPTION}},${ownerId}}`,
3309
+ `#{==:#{session_id},${expected.sessionId}}`,
3310
+ `#{==:#{session_created},${expected.sessionCreated}}`,
3311
+ ].reduce((combined, condition) => `#{&&:${combined},${condition}}`);
3312
+ return ['if-shell', '-F', '-t', leaderPaneId, predicate, `kill-session -t ${expected.sessionId}`, 'run-shell "exit 1"'];
3313
+ }
3314
+ export function requestDetachedTeamSessionDestroy(sessionName, expected) {
3315
+ const incarnation = { sessionId: expected.sessionId, sessionCreated: expected.sessionCreated };
3316
+ if (queryDetachedTeamSession(sessionName, incarnation).status !== 'exact')
3317
+ return false;
3318
+ if (!queryDetachedSessionLeaderBinding(expected.leaderPaneId, expected.leaderPanePid, sessionName, incarnation))
3319
+ return false;
3320
+ const pane = readExactPaneProofSync(expected.leaderPaneId);
3321
+ if (pane.status !== 'live' || pane.pid !== expected.leaderPanePid)
3322
+ return false;
3323
+ const owner = readPaneTeamOwnerTagResult(expected.leaderPaneId);
3324
+ if (owner.status !== 'value' || owner.value !== expected.ownerId)
3325
+ return false;
3326
+ const args = buildDetachedSessionDestroyArgs(expected);
3327
+ return args !== null && runTmux(args).ok;
3328
+ }
3329
+ export function destroyTeamSession(sessionName) {
3330
+ const result = runTmux(['kill-session', '-t', sessionName]);
3331
+ if (!result.ok)
3332
+ return false;
3333
+ const sessions = runTmux(['list-sessions', '-F', '#{session_name}']);
3334
+ // Killing tmux's final session also terminates its server. Its documented
3335
+ // no-server response is the only post-kill query failure that proves absence.
3336
+ if (!sessions.ok)
3337
+ return isFinalTmuxServerGone(sessions);
3338
+ return !parseTeamSessionNames(sessions.stdout).includes(baseSessionName(sessionName));
3339
+ }
3340
+ // A failed query is not a successful empty session list. Destructive callers
3341
+ // must require the latter before treating a detached session as absent.
3342
+ export function listTeamSessions() {
3343
+ const result = runTmux(['list-sessions', '-F', '#{session_name}']);
3344
+ if (!result.ok)
3345
+ return null;
3346
+ return parseTeamSessionNames(result.stdout);
3347
+ }
2389
3348
  /**
2390
3349
  * Notify the leader through durable mailbox state only.
2391
3350
  *