botmux 3.7.1 → 3.8.0

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 (622) hide show
  1. package/dist/.runtime-build-id +1 -1
  2. package/dist/adapters/backend/capabilities.d.ts +17 -0
  3. package/dist/adapters/backend/capabilities.d.ts.map +1 -0
  4. package/dist/adapters/backend/capabilities.js +22 -0
  5. package/dist/adapters/backend/capabilities.js.map +1 -0
  6. package/dist/adapters/backend/critical-control-key.d.ts +19 -0
  7. package/dist/adapters/backend/critical-control-key.d.ts.map +1 -0
  8. package/dist/adapters/backend/critical-control-key.js +34 -0
  9. package/dist/adapters/backend/critical-control-key.js.map +1 -0
  10. package/dist/adapters/backend/herdr-backend.d.ts +9 -0
  11. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  12. package/dist/adapters/backend/herdr-backend.js +23 -4
  13. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  14. package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
  15. package/dist/adapters/backend/riff-backend.js +3 -2
  16. package/dist/adapters/backend/riff-backend.js.map +1 -1
  17. package/dist/adapters/backend/session-backend-selector.d.ts +61 -6
  18. package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -1
  19. package/dist/adapters/backend/session-backend-selector.js +173 -9
  20. package/dist/adapters/backend/session-backend-selector.js.map +1 -1
  21. package/dist/adapters/backend/tmux-backend.d.ts +10 -3
  22. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  23. package/dist/adapters/backend/tmux-backend.js +25 -8
  24. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  25. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  26. package/dist/adapters/backend/tmux-pipe-backend.js +3 -2
  27. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  28. package/dist/adapters/backend/types.d.ts +45 -4
  29. package/dist/adapters/backend/types.d.ts.map +1 -1
  30. package/dist/adapters/backend/types.js +11 -0
  31. package/dist/adapters/backend/types.js.map +1 -1
  32. package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
  33. package/dist/adapters/backend/zellij-backend.js +3 -2
  34. package/dist/adapters/backend/zellij-backend.js.map +1 -1
  35. package/dist/adapters/backend/zmx-backend.d.ts +286 -0
  36. package/dist/adapters/backend/zmx-backend.d.ts.map +1 -0
  37. package/dist/adapters/backend/zmx-backend.js +2396 -0
  38. package/dist/adapters/backend/zmx-backend.js.map +1 -0
  39. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  40. package/dist/adapters/cli/claude-code.js +3 -0
  41. package/dist/adapters/cli/claude-code.js.map +1 -1
  42. package/dist/adapters/cli/codex-app.d.ts.map +1 -1
  43. package/dist/adapters/cli/codex-app.js +5 -1
  44. package/dist/adapters/cli/codex-app.js.map +1 -1
  45. package/dist/adapters/cli/codex.d.ts.map +1 -1
  46. package/dist/adapters/cli/codex.js +26 -1
  47. package/dist/adapters/cli/codex.js.map +1 -1
  48. package/dist/adapters/cli/fs-policy.d.ts +79 -1
  49. package/dist/adapters/cli/fs-policy.d.ts.map +1 -1
  50. package/dist/adapters/cli/fs-policy.js +290 -13
  51. package/dist/adapters/cli/fs-policy.js.map +1 -1
  52. package/dist/adapters/cli/grok.d.ts.map +1 -1
  53. package/dist/adapters/cli/grok.js +4 -1
  54. package/dist/adapters/cli/grok.js.map +1 -1
  55. package/dist/adapters/cli/oh-my-pi.d.ts.map +1 -1
  56. package/dist/adapters/cli/oh-my-pi.js +5 -3
  57. package/dist/adapters/cli/oh-my-pi.js.map +1 -1
  58. package/dist/adapters/cli/shared-hints.d.ts +7 -4
  59. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  60. package/dist/adapters/cli/shared-hints.js +52 -35
  61. package/dist/adapters/cli/shared-hints.js.map +1 -1
  62. package/dist/adapters/cli/strict-input-handle.d.ts +13 -0
  63. package/dist/adapters/cli/strict-input-handle.d.ts.map +1 -0
  64. package/dist/adapters/cli/strict-input-handle.js +39 -0
  65. package/dist/adapters/cli/strict-input-handle.js.map +1 -0
  66. package/dist/adapters/cli/traex.d.ts.map +1 -1
  67. package/dist/adapters/cli/traex.js +6 -4
  68. package/dist/adapters/cli/traex.js.map +1 -1
  69. package/dist/adapters/cli/types.d.ts +16 -0
  70. package/dist/adapters/cli/types.d.ts.map +1 -1
  71. package/dist/bot-registry.d.ts +137 -8
  72. package/dist/bot-registry.d.ts.map +1 -1
  73. package/dist/bot-registry.js +298 -12
  74. package/dist/bot-registry.js.map +1 -1
  75. package/dist/cli/session-list-liveness.d.ts +1 -1
  76. package/dist/cli/session-list-liveness.js +1 -1
  77. package/dist/cli/zmx-managed-attach.d.ts +41 -0
  78. package/dist/cli/zmx-managed-attach.d.ts.map +1 -0
  79. package/dist/cli/zmx-managed-attach.js +72 -0
  80. package/dist/cli/zmx-managed-attach.js.map +1 -0
  81. package/dist/cli.d.ts.map +1 -1
  82. package/dist/cli.js +780 -131
  83. package/dist/cli.js.map +1 -1
  84. package/dist/codex-app-runner.js +90 -2
  85. package/dist/codex-app-runner.js.map +1 -1
  86. package/dist/codex-rpc-engine.d.ts.map +1 -1
  87. package/dist/codex-rpc-engine.js +22 -6
  88. package/dist/codex-rpc-engine.js.map +1 -1
  89. package/dist/config.d.ts +5 -0
  90. package/dist/config.d.ts.map +1 -1
  91. package/dist/config.js +29 -1
  92. package/dist/config.js.map +1 -1
  93. package/dist/core/ask-broker.d.ts +22 -1
  94. package/dist/core/ask-broker.d.ts.map +1 -1
  95. package/dist/core/ask-broker.js +12 -5
  96. package/dist/core/ask-broker.js.map +1 -1
  97. package/dist/core/botmux-wrapper.d.ts +18 -0
  98. package/dist/core/botmux-wrapper.d.ts.map +1 -1
  99. package/dist/core/botmux-wrapper.js +25 -1
  100. package/dist/core/botmux-wrapper.js.map +1 -1
  101. package/dist/core/chat-rename.d.ts +27 -0
  102. package/dist/core/chat-rename.d.ts.map +1 -0
  103. package/dist/core/chat-rename.js +53 -0
  104. package/dist/core/chat-rename.js.map +1 -0
  105. package/dist/core/command-handler.d.ts.map +1 -1
  106. package/dist/core/command-handler.js +160 -12
  107. package/dist/core/command-handler.js.map +1 -1
  108. package/dist/core/cost-calculator.d.ts +25 -1
  109. package/dist/core/cost-calculator.d.ts.map +1 -1
  110. package/dist/core/cost-calculator.js +102 -4
  111. package/dist/core/cost-calculator.js.map +1 -1
  112. package/dist/core/dashboard-ipc-server.d.ts +16 -2
  113. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  114. package/dist/core/dashboard-ipc-server.js +611 -62
  115. package/dist/core/dashboard-ipc-server.js.map +1 -1
  116. package/dist/core/dashboard-rows.d.ts +22 -1
  117. package/dist/core/dashboard-rows.d.ts.map +1 -1
  118. package/dist/core/dashboard-rows.js +61 -0
  119. package/dist/core/dashboard-rows.js.map +1 -1
  120. package/dist/core/dashboard-url.d.ts +6 -0
  121. package/dist/core/dashboard-url.d.ts.map +1 -1
  122. package/dist/core/dashboard-url.js +11 -1
  123. package/dist/core/dashboard-url.js.map +1 -1
  124. package/dist/core/device-isolation-daemon.d.ts +28 -6
  125. package/dist/core/device-isolation-daemon.d.ts.map +1 -1
  126. package/dist/core/device-isolation-daemon.js +96 -13
  127. package/dist/core/device-isolation-daemon.js.map +1 -1
  128. package/dist/core/host-overload-alert.d.ts +75 -0
  129. package/dist/core/host-overload-alert.d.ts.map +1 -1
  130. package/dist/core/host-overload-alert.js +103 -0
  131. package/dist/core/host-overload-alert.js.map +1 -1
  132. package/dist/core/idle-worker-sweeper.js +1 -1
  133. package/dist/core/idle-worker-sweeper.js.map +1 -1
  134. package/dist/core/inflight-input-tracker.d.ts +3 -0
  135. package/dist/core/inflight-input-tracker.d.ts.map +1 -1
  136. package/dist/core/inflight-input-tracker.js +9 -0
  137. package/dist/core/inflight-input-tracker.js.map +1 -1
  138. package/dist/core/passthrough-commands.d.ts.map +1 -1
  139. package/dist/core/passthrough-commands.js +10 -0
  140. package/dist/core/passthrough-commands.js.map +1 -1
  141. package/dist/core/persistent-backend.d.ts +32 -4
  142. package/dist/core/persistent-backend.d.ts.map +1 -1
  143. package/dist/core/persistent-backend.js +75 -6
  144. package/dist/core/persistent-backend.js.map +1 -1
  145. package/dist/core/plugins/mcp/gateway.js +2 -2
  146. package/dist/core/plugins/mcp/gateway.js.map +1 -1
  147. package/dist/core/plugins/runtime.d.ts +3 -0
  148. package/dist/core/plugins/runtime.d.ts.map +1 -1
  149. package/dist/core/plugins/runtime.js.map +1 -1
  150. package/dist/core/plugins/service-manager.d.ts +4 -1
  151. package/dist/core/plugins/service-manager.d.ts.map +1 -1
  152. package/dist/core/plugins/service-manager.js +11 -6
  153. package/dist/core/plugins/service-manager.js.map +1 -1
  154. package/dist/core/role-library.d.ts +48 -1
  155. package/dist/core/role-library.d.ts.map +1 -1
  156. package/dist/core/role-library.js +111 -3
  157. package/dist/core/role-library.js.map +1 -1
  158. package/dist/core/role-resolver.d.ts +14 -2
  159. package/dist/core/role-resolver.d.ts.map +1 -1
  160. package/dist/core/role-resolver.js +47 -4
  161. package/dist/core/role-resolver.js.map +1 -1
  162. package/dist/core/session-activity.d.ts +14 -0
  163. package/dist/core/session-activity.d.ts.map +1 -1
  164. package/dist/core/session-activity.js +46 -1
  165. package/dist/core/session-activity.js.map +1 -1
  166. package/dist/core/session-board.d.ts +5 -2
  167. package/dist/core/session-board.d.ts.map +1 -1
  168. package/dist/core/session-board.js +5 -2
  169. package/dist/core/session-board.js.map +1 -1
  170. package/dist/core/session-liveness.d.ts +6 -5
  171. package/dist/core/session-liveness.d.ts.map +1 -1
  172. package/dist/core/session-liveness.js +34 -13
  173. package/dist/core/session-liveness.js.map +1 -1
  174. package/dist/core/session-manager.d.ts +3 -2
  175. package/dist/core/session-manager.d.ts.map +1 -1
  176. package/dist/core/session-manager.js +397 -103
  177. package/dist/core/session-manager.js.map +1 -1
  178. package/dist/core/session-marker.d.ts +11 -2
  179. package/dist/core/session-marker.d.ts.map +1 -1
  180. package/dist/core/session-marker.js +50 -11
  181. package/dist/core/session-marker.js.map +1 -1
  182. package/dist/core/session-message-preview.d.ts +20 -0
  183. package/dist/core/session-message-preview.d.ts.map +1 -0
  184. package/dist/core/session-message-preview.js +188 -0
  185. package/dist/core/session-message-preview.js.map +1 -0
  186. package/dist/core/session-title.d.ts +5 -1
  187. package/dist/core/session-title.d.ts.map +1 -1
  188. package/dist/core/session-title.js +20 -3
  189. package/dist/core/session-title.js.map +1 -1
  190. package/dist/core/startup-commands.d.ts +1 -1
  191. package/dist/core/startup-commands.js +1 -1
  192. package/dist/core/terminal-url.d.ts.map +1 -1
  193. package/dist/core/terminal-url.js +3 -2
  194. package/dist/core/terminal-url.js.map +1 -1
  195. package/dist/core/trigger-session.d.ts.map +1 -1
  196. package/dist/core/trigger-session.js +75 -12
  197. package/dist/core/trigger-session.js.map +1 -1
  198. package/dist/core/types.d.ts +35 -0
  199. package/dist/core/types.d.ts.map +1 -1
  200. package/dist/core/types.js +23 -0
  201. package/dist/core/types.js.map +1 -1
  202. package/dist/core/worker-ipc.d.ts +11 -0
  203. package/dist/core/worker-ipc.d.ts.map +1 -0
  204. package/dist/core/worker-ipc.js +26 -0
  205. package/dist/core/worker-ipc.js.map +1 -0
  206. package/dist/core/worker-pool.d.ts +179 -29
  207. package/dist/core/worker-pool.d.ts.map +1 -1
  208. package/dist/core/worker-pool.js +2118 -331
  209. package/dist/core/worker-pool.js.map +1 -1
  210. package/dist/daemon.d.ts +132 -3
  211. package/dist/daemon.d.ts.map +1 -1
  212. package/dist/daemon.js +1327 -449
  213. package/dist/daemon.js.map +1 -1
  214. package/dist/dashboard/auth.d.ts +2 -0
  215. package/dist/dashboard/auth.d.ts.map +1 -1
  216. package/dist/dashboard/auth.js +9 -0
  217. package/dist/dashboard/auth.js.map +1 -1
  218. package/dist/dashboard/bot-payload.d.ts +4 -0
  219. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  220. package/dist/dashboard/bot-payload.js +5 -0
  221. package/dist/dashboard/bot-payload.js.map +1 -1
  222. package/dist/dashboard/federation-api.d.ts.map +1 -1
  223. package/dist/dashboard/federation-api.js +4 -0
  224. package/dist/dashboard/federation-api.js.map +1 -1
  225. package/dist/dashboard/federation-spoke-api.d.ts.map +1 -1
  226. package/dist/dashboard/federation-spoke-api.js +32 -5
  227. package/dist/dashboard/federation-spoke-api.js.map +1 -1
  228. package/dist/dashboard/public-redact.d.ts +8 -2
  229. package/dist/dashboard/public-redact.d.ts.map +1 -1
  230. package/dist/dashboard/public-redact.js +34 -7
  231. package/dist/dashboard/public-redact.js.map +1 -1
  232. package/dist/dashboard/session-card-model.d.ts.map +1 -1
  233. package/dist/dashboard/session-card-model.js +10 -2
  234. package/dist/dashboard/session-card-model.js.map +1 -1
  235. package/dist/dashboard/settings-write-applier.d.ts +35 -1
  236. package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
  237. package/dist/dashboard/settings-write-applier.js +88 -1
  238. package/dist/dashboard/settings-write-applier.js.map +1 -1
  239. package/dist/dashboard/web/app.d.ts.map +1 -1
  240. package/dist/dashboard/web/app.js +0 -1
  241. package/dist/dashboard/web/app.js.map +1 -1
  242. package/dist/dashboard/web/bot-defaults-page.d.ts +15 -0
  243. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  244. package/dist/dashboard/web/bot-defaults-page.js +212 -58
  245. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  246. package/dist/dashboard/web/bot-defaults.d.ts +3 -0
  247. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  248. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  249. package/dist/dashboard/web/bot-onboarding.d.ts +2 -1
  250. package/dist/dashboard/web/bot-onboarding.d.ts.map +1 -1
  251. package/dist/dashboard/web/bot-onboarding.js.map +1 -1
  252. package/dist/dashboard/web/clipboard.d.ts +2 -0
  253. package/dist/dashboard/web/clipboard.d.ts.map +1 -0
  254. package/dist/dashboard/web/clipboard.js +42 -0
  255. package/dist/dashboard/web/clipboard.js.map +1 -0
  256. package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
  257. package/dist/dashboard/web/connectors-page.js +12 -8
  258. package/dist/dashboard/web/connectors-page.js.map +1 -1
  259. package/dist/dashboard/web/dashboard-components.d.ts +17 -16
  260. package/dist/dashboard/web/dashboard-components.d.ts.map +1 -1
  261. package/dist/dashboard/web/dashboard-components.js.map +1 -1
  262. package/dist/dashboard/web/groups-page.d.ts.map +1 -1
  263. package/dist/dashboard/web/groups-page.js +7 -3
  264. package/dist/dashboard/web/groups-page.js.map +1 -1
  265. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  266. package/dist/dashboard/web/i18n.js +205 -12
  267. package/dist/dashboard/web/i18n.js.map +1 -1
  268. package/dist/dashboard/web/icon-tooltip.d.ts.map +1 -1
  269. package/dist/dashboard/web/icon-tooltip.js +16 -1
  270. package/dist/dashboard/web/icon-tooltip.js.map +1 -1
  271. package/dist/dashboard/web/listener-filters.d.ts +23 -0
  272. package/dist/dashboard/web/listener-filters.d.ts.map +1 -0
  273. package/dist/dashboard/web/listener-filters.js +36 -0
  274. package/dist/dashboard/web/listener-filters.js.map +1 -0
  275. package/dist/dashboard/web/overview-page.d.ts.map +1 -1
  276. package/dist/dashboard/web/overview-page.js.map +1 -1
  277. package/dist/dashboard/web/plugin-page.d.ts.map +1 -1
  278. package/dist/dashboard/web/plugin-page.js.map +1 -1
  279. package/dist/dashboard/web/preview-markdown.d.ts +5 -0
  280. package/dist/dashboard/web/preview-markdown.d.ts.map +1 -0
  281. package/dist/dashboard/web/preview-markdown.js +52 -0
  282. package/dist/dashboard/web/preview-markdown.js.map +1 -0
  283. package/dist/dashboard/web/roles-page.d.ts.map +1 -1
  284. package/dist/dashboard/web/roles-page.js +535 -10
  285. package/dist/dashboard/web/roles-page.js.map +1 -1
  286. package/dist/dashboard/web/roles.d.ts +77 -0
  287. package/dist/dashboard/web/roles.d.ts.map +1 -1
  288. package/dist/dashboard/web/roles.js +98 -0
  289. package/dist/dashboard/web/roles.js.map +1 -1
  290. package/dist/dashboard/web/session-terminal.d.ts.map +1 -1
  291. package/dist/dashboard/web/session-terminal.js +6 -4
  292. package/dist/dashboard/web/session-terminal.js.map +1 -1
  293. package/dist/dashboard/web/sessions-kanban.d.ts +4 -1
  294. package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
  295. package/dist/dashboard/web/sessions-kanban.js +1 -1
  296. package/dist/dashboard/web/sessions-kanban.js.map +1 -1
  297. package/dist/dashboard/web/sessions-page.d.ts +3 -2
  298. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  299. package/dist/dashboard/web/sessions-page.js +163 -28
  300. package/dist/dashboard/web/sessions-page.js.map +1 -1
  301. package/dist/dashboard/web/sessions.d.ts +25 -0
  302. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  303. package/dist/dashboard/web/sessions.js +58 -7
  304. package/dist/dashboard/web/sessions.js.map +1 -1
  305. package/dist/dashboard/web/settings-page.d.ts +28 -3
  306. package/dist/dashboard/web/settings-page.d.ts.map +1 -1
  307. package/dist/dashboard/web/settings-page.js +61 -1
  308. package/dist/dashboard/web/settings-page.js.map +1 -1
  309. package/dist/dashboard/web/skills-page.d.ts +6 -5
  310. package/dist/dashboard/web/skills-page.d.ts.map +1 -1
  311. package/dist/dashboard/web/skills-page.js +2 -0
  312. package/dist/dashboard/web/skills-page.js.map +1 -1
  313. package/dist/dashboard/web/v3-components.d.ts +3 -2
  314. package/dist/dashboard/web/v3-components.d.ts.map +1 -1
  315. package/dist/dashboard/web/v3-components.js.map +1 -1
  316. package/dist/dashboard/web/vc-consumer-profiles-section.d.ts +12 -2
  317. package/dist/dashboard/web/vc-consumer-profiles-section.d.ts.map +1 -1
  318. package/dist/dashboard/web/vc-consumer-profiles-section.js +0 -9
  319. package/dist/dashboard/web/vc-consumer-profiles-section.js.map +1 -1
  320. package/dist/dashboard/web/webhook-logs-page.d.ts +2 -1
  321. package/dist/dashboard/web/webhook-logs-page.d.ts.map +1 -1
  322. package/dist/dashboard/web/webhook-logs-page.js +4 -1
  323. package/dist/dashboard/web/webhook-logs-page.js.map +1 -1
  324. package/dist/dashboard-web/app.js +1 -1
  325. package/dist/dashboard-web/chunks/bot-defaults-page-FAPIMQV3.js +10 -0
  326. package/dist/dashboard-web/chunks/{chunk-QFMH6ITB.js → chunk-5CJMHTT4.js} +1 -1
  327. package/dist/dashboard-web/chunks/{chunk-KQB42ML5.js → chunk-DLZK52JU.js} +14 -12
  328. package/dist/dashboard-web/chunks/chunk-FDZ7WOBW.js +16 -0
  329. package/dist/dashboard-web/chunks/{chunk-3QAUJXAS.js → chunk-GTAJV5YC.js} +1 -1
  330. package/dist/dashboard-web/chunks/{chunk-FNPGOZMX.js → chunk-IHNG6KE6.js} +1 -1
  331. package/dist/dashboard-web/chunks/{chunk-FLGXZJTD.js → chunk-VA6QWSGQ.js} +1 -1
  332. package/dist/dashboard-web/chunks/chunk-XJ7BGPJH.js +1 -0
  333. package/dist/dashboard-web/chunks/chunk-ZJVTGU5X.js +1 -0
  334. package/dist/dashboard-web/chunks/connectors-page-LKWRT3GS.js +1 -0
  335. package/dist/dashboard-web/chunks/groups-page-V6654ICQ.js +8 -0
  336. package/dist/dashboard-web/chunks/insights-page-237RMFQ5.js +7 -0
  337. package/dist/dashboard-web/chunks/{monitor-room-7IVIS75W.js → monitor-room-OO5Y4FRQ.js} +1 -1
  338. package/dist/dashboard-web/chunks/{monitoring-page-5SSJRQZF.js → monitoring-page-OUEITM5P.js} +1 -1
  339. package/dist/dashboard-web/chunks/{office-page-EOND3L5D.js → office-page-4KNAHQ4Q.js} +1 -1
  340. package/dist/dashboard-web/chunks/{overview-page-NISBVBBJ.js → overview-page-QDP4YK2S.js} +1 -1
  341. package/dist/dashboard-web/chunks/{plugin-page-JXC5F5YV.js → plugin-page-VEGTCEAS.js} +1 -1
  342. package/dist/dashboard-web/chunks/roles-page-PGXNZZNP.js +1 -0
  343. package/dist/dashboard-web/chunks/{schedules-page-AVOOKLUH.js → schedules-page-BJNC5TID.js} +1 -1
  344. package/dist/dashboard-web/chunks/sessions-page-M2ZYYCMO.js +6 -0
  345. package/dist/dashboard-web/chunks/settings-page-IJFZKUUW.js +2 -0
  346. package/dist/dashboard-web/chunks/{skills-page-5MW54PCC.js → skills-page-YRAIRZOQ.js} +3 -3
  347. package/dist/dashboard-web/chunks/{team-federation-page-2SNIMVXI.js → team-federation-page-DEU27GRU.js} +1 -1
  348. package/dist/dashboard-web/chunks/{v3-page-W6SBXXQC.js → v3-page-6KHEW2YL.js} +5 -5
  349. package/dist/dashboard-web/chunks/{whiteboards-page-STC54KTP.js → whiteboards-page-VDECAALH.js} +1 -1
  350. package/dist/dashboard-web/style.css +12545 -11432
  351. package/dist/dashboard.js +215 -36
  352. package/dist/dashboard.js.map +1 -1
  353. package/dist/global-config.d.ts +41 -2
  354. package/dist/global-config.d.ts.map +1 -1
  355. package/dist/global-config.js +47 -0
  356. package/dist/global-config.js.map +1 -1
  357. package/dist/i18n/en.d.ts.map +1 -1
  358. package/dist/i18n/en.js +71 -19
  359. package/dist/i18n/en.js.map +1 -1
  360. package/dist/i18n/zh.d.ts.map +1 -1
  361. package/dist/i18n/zh.js +71 -19
  362. package/dist/i18n/zh.js.map +1 -1
  363. package/dist/im/lark/callback-button-marker.d.ts +34 -0
  364. package/dist/im/lark/callback-button-marker.d.ts.map +1 -0
  365. package/dist/im/lark/callback-button-marker.js +99 -0
  366. package/dist/im/lark/callback-button-marker.js.map +1 -0
  367. package/dist/im/lark/card-builder.d.ts +46 -10
  368. package/dist/im/lark/card-builder.d.ts.map +1 -1
  369. package/dist/im/lark/card-builder.js +299 -78
  370. package/dist/im/lark/card-builder.js.map +1 -1
  371. package/dist/im/lark/card-handler.d.ts +3 -4
  372. package/dist/im/lark/card-handler.d.ts.map +1 -1
  373. package/dist/im/lark/card-handler.js +380 -148
  374. package/dist/im/lark/card-handler.js.map +1 -1
  375. package/dist/im/lark/client.d.ts +24 -1
  376. package/dist/im/lark/client.d.ts.map +1 -1
  377. package/dist/im/lark/client.js +101 -8
  378. package/dist/im/lark/client.js.map +1 -1
  379. package/dist/im/lark/doc-comment.d.ts.map +1 -1
  380. package/dist/im/lark/doc-comment.js +6 -1
  381. package/dist/im/lark/doc-comment.js.map +1 -1
  382. package/dist/im/lark/event-dispatcher.d.ts +115 -3
  383. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  384. package/dist/im/lark/event-dispatcher.js +534 -147
  385. package/dist/im/lark/event-dispatcher.js.map +1 -1
  386. package/dist/im/lark/grant-command.d.ts +16 -12
  387. package/dist/im/lark/grant-command.d.ts.map +1 -1
  388. package/dist/im/lark/grant-command.js +48 -151
  389. package/dist/im/lark/grant-command.js.map +1 -1
  390. package/dist/im/lark/grant-pending.d.ts +12 -4
  391. package/dist/im/lark/grant-pending.d.ts.map +1 -1
  392. package/dist/im/lark/grant-pending.js +31 -8
  393. package/dist/im/lark/grant-pending.js.map +1 -1
  394. package/dist/im/lark/invite-command.d.ts +20 -0
  395. package/dist/im/lark/invite-command.d.ts.map +1 -0
  396. package/dist/im/lark/invite-command.js +239 -0
  397. package/dist/im/lark/invite-command.js.map +1 -0
  398. package/dist/im/lark/md-card.d.ts +66 -4
  399. package/dist/im/lark/md-card.d.ts.map +1 -1
  400. package/dist/im/lark/md-card.js +201 -31
  401. package/dist/im/lark/md-card.js.map +1 -1
  402. package/dist/im/lark/mention-targets.d.ts +43 -0
  403. package/dist/im/lark/mention-targets.d.ts.map +1 -0
  404. package/dist/im/lark/mention-targets.js +190 -0
  405. package/dist/im/lark/mention-targets.js.map +1 -0
  406. package/dist/im/lark/message-parser.d.ts +31 -0
  407. package/dist/im/lark/message-parser.d.ts.map +1 -1
  408. package/dist/im/lark/message-parser.js +305 -32
  409. package/dist/im/lark/message-parser.js.map +1 -1
  410. package/dist/im/lark/reply-card-footer-signature.d.ts +7 -0
  411. package/dist/im/lark/reply-card-footer-signature.d.ts.map +1 -0
  412. package/dist/im/lark/reply-card-footer-signature.js +7 -0
  413. package/dist/im/lark/reply-card-footer-signature.js.map +1 -0
  414. package/dist/im/lark/sessions-card.d.ts.map +1 -1
  415. package/dist/im/lark/sessions-card.js +4 -1
  416. package/dist/im/lark/sessions-card.js.map +1 -1
  417. package/dist/im/lark/substitute-command.d.ts.map +1 -1
  418. package/dist/im/lark/substitute-command.js +8 -0
  419. package/dist/im/lark/substitute-command.js.map +1 -1
  420. package/dist/im/lark/topic-root-context.d.ts +35 -0
  421. package/dist/im/lark/topic-root-context.d.ts.map +1 -0
  422. package/dist/im/lark/topic-root-context.js +37 -0
  423. package/dist/im/lark/topic-root-context.js.map +1 -0
  424. package/dist/im/lark/v3-blocked-card.d.ts.map +1 -1
  425. package/dist/im/lark/v3-blocked-card.js +2 -1
  426. package/dist/im/lark/v3-blocked-card.js.map +1 -1
  427. package/dist/im/lark/v3-gate-card.d.ts.map +1 -1
  428. package/dist/im/lark/v3-gate-card.js +2 -1
  429. package/dist/im/lark/v3-gate-card.js.map +1 -1
  430. package/dist/im/lark/v3-loop-grant-card.d.ts.map +1 -1
  431. package/dist/im/lark/v3-loop-grant-card.js +2 -1
  432. package/dist/im/lark/v3-loop-grant-card.js.map +1 -1
  433. package/dist/im/lark/v3-progress-card.d.ts.map +1 -1
  434. package/dist/im/lark/v3-progress-card.js +2 -1
  435. package/dist/im/lark/v3-progress-card.js.map +1 -1
  436. package/dist/im/lark/v3-revisit-grant-card.d.ts.map +1 -1
  437. package/dist/im/lark/v3-revisit-grant-card.js +2 -1
  438. package/dist/im/lark/v3-revisit-grant-card.js.map +1 -1
  439. package/dist/index-core-only.d.ts +3 -0
  440. package/dist/index-core-only.d.ts.map +1 -0
  441. package/dist/index-core-only.js +107 -0
  442. package/dist/index-core-only.js.map +1 -0
  443. package/dist/services/async-trigger-state.d.ts +8 -0
  444. package/dist/services/async-trigger-state.d.ts.map +1 -1
  445. package/dist/services/async-trigger-state.js +3 -2
  446. package/dist/services/async-trigger-state.js.map +1 -1
  447. package/dist/services/async-trigger-store.d.ts +10 -2
  448. package/dist/services/async-trigger-store.d.ts.map +1 -1
  449. package/dist/services/async-trigger-store.js +3 -2
  450. package/dist/services/async-trigger-store.js.map +1 -1
  451. package/dist/services/backend-availability.d.ts +3 -1
  452. package/dist/services/backend-availability.d.ts.map +1 -1
  453. package/dist/services/backend-availability.js +14 -1
  454. package/dist/services/backend-availability.js.map +1 -1
  455. package/dist/services/backend-type-store.d.ts.map +1 -1
  456. package/dist/services/backend-type-store.js +1 -1
  457. package/dist/services/backend-type-store.js.map +1 -1
  458. package/dist/services/bot-config-store.d.ts.map +1 -1
  459. package/dist/services/bot-config-store.js +2 -1
  460. package/dist/services/bot-config-store.js.map +1 -1
  461. package/dist/services/bridge-fallback-gate.d.ts +14 -1
  462. package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
  463. package/dist/services/bridge-fallback-gate.js +65 -1
  464. package/dist/services/bridge-fallback-gate.js.map +1 -1
  465. package/dist/services/card-prefs-store.d.ts +7 -2
  466. package/dist/services/card-prefs-store.d.ts.map +1 -1
  467. package/dist/services/card-prefs-store.js +30 -4
  468. package/dist/services/card-prefs-store.js.map +1 -1
  469. package/dist/services/codex-app-runner-protocol.d.ts +9 -0
  470. package/dist/services/codex-app-runner-protocol.d.ts.map +1 -1
  471. package/dist/services/codex-app-runner-protocol.js +17 -0
  472. package/dist/services/codex-app-runner-protocol.js.map +1 -1
  473. package/dist/services/codex-app-token-usage.d.ts +93 -0
  474. package/dist/services/codex-app-token-usage.d.ts.map +1 -0
  475. package/dist/services/codex-app-token-usage.js +190 -0
  476. package/dist/services/codex-app-token-usage.js.map +1 -0
  477. package/dist/services/codex-transcript.d.ts +6 -1
  478. package/dist/services/codex-transcript.d.ts.map +1 -1
  479. package/dist/services/codex-transcript.js +103 -32
  480. package/dist/services/codex-transcript.js.map +1 -1
  481. package/dist/services/federation-roster.d.ts +4 -0
  482. package/dist/services/federation-roster.d.ts.map +1 -1
  483. package/dist/services/federation-roster.js +3 -0
  484. package/dist/services/federation-roster.js.map +1 -1
  485. package/dist/services/federation-store.d.ts +6 -0
  486. package/dist/services/federation-store.d.ts.map +1 -1
  487. package/dist/services/federation-store.js.map +1 -1
  488. package/dist/services/grant-policy.d.ts +13 -0
  489. package/dist/services/grant-policy.d.ts.map +1 -0
  490. package/dist/services/grant-policy.js +40 -0
  491. package/dist/services/grant-policy.js.map +1 -0
  492. package/dist/services/grant-store.d.ts +24 -3
  493. package/dist/services/grant-store.d.ts.map +1 -1
  494. package/dist/services/grant-store.js +196 -12
  495. package/dist/services/grant-store.js.map +1 -1
  496. package/dist/services/groups-store.d.ts +36 -0
  497. package/dist/services/groups-store.d.ts.map +1 -1
  498. package/dist/services/groups-store.js +123 -1
  499. package/dist/services/groups-store.js.map +1 -1
  500. package/dist/services/jsonl-cursor.d.ts.map +1 -1
  501. package/dist/services/jsonl-cursor.js +8 -2
  502. package/dist/services/jsonl-cursor.js.map +1 -1
  503. package/dist/services/local-cli-opener.d.ts.map +1 -1
  504. package/dist/services/local-cli-opener.js +32 -0
  505. package/dist/services/local-cli-opener.js.map +1 -1
  506. package/dist/services/message-listener-run-preview-store.d.ts +43 -0
  507. package/dist/services/message-listener-run-preview-store.d.ts.map +1 -0
  508. package/dist/services/message-listener-run-preview-store.js +152 -0
  509. package/dist/services/message-listener-run-preview-store.js.map +1 -0
  510. package/dist/services/message-listener-store.d.ts +26 -0
  511. package/dist/services/message-listener-store.d.ts.map +1 -0
  512. package/dist/services/message-listener-store.js +138 -0
  513. package/dist/services/message-listener-store.js.map +1 -0
  514. package/dist/services/message-listener.d.ts +112 -0
  515. package/dist/services/message-listener.d.ts.map +1 -0
  516. package/dist/services/message-listener.js +391 -0
  517. package/dist/services/message-listener.js.map +1 -0
  518. package/dist/services/overload-alert-migration.d.ts +15 -0
  519. package/dist/services/overload-alert-migration.d.ts.map +1 -0
  520. package/dist/services/overload-alert-migration.js +115 -0
  521. package/dist/services/overload-alert-migration.js.map +1 -0
  522. package/dist/services/send-policy.d.ts +38 -0
  523. package/dist/services/send-policy.d.ts.map +1 -1
  524. package/dist/services/send-policy.js +34 -0
  525. package/dist/services/send-policy.js.map +1 -1
  526. package/dist/services/session-store.d.ts +12 -1
  527. package/dist/services/session-store.d.ts.map +1 -1
  528. package/dist/services/session-store.js +44 -2
  529. package/dist/services/session-store.js.map +1 -1
  530. package/dist/services/skill-registry-store.d.ts.map +1 -1
  531. package/dist/services/skill-registry-store.js +87 -12
  532. package/dist/services/skill-registry-store.js.map +1 -1
  533. package/dist/services/substitute-mode-normalize.d.ts +3 -0
  534. package/dist/services/substitute-mode-normalize.d.ts.map +1 -1
  535. package/dist/services/substitute-mode-normalize.js +8 -0
  536. package/dist/services/substitute-mode-normalize.js.map +1 -1
  537. package/dist/services/substitute-mode-store.d.ts.map +1 -1
  538. package/dist/services/substitute-mode-store.js +8 -1
  539. package/dist/services/substitute-mode-store.js.map +1 -1
  540. package/dist/services/transcript-resolver.d.ts +6 -1
  541. package/dist/services/transcript-resolver.d.ts.map +1 -1
  542. package/dist/services/transcript-resolver.js +145 -29
  543. package/dist/services/transcript-resolver.js.map +1 -1
  544. package/dist/services/trigger-types.d.ts +16 -0
  545. package/dist/services/trigger-types.d.ts.map +1 -1
  546. package/dist/services/trigger-types.js +6 -0
  547. package/dist/services/trigger-types.js.map +1 -1
  548. package/dist/setup/bot-config-editor.js +2 -2
  549. package/dist/setup/bot-config-editor.js.map +1 -1
  550. package/dist/setup/ensure-zmx.d.ts +31 -0
  551. package/dist/setup/ensure-zmx.d.ts.map +1 -0
  552. package/dist/setup/ensure-zmx.js +161 -0
  553. package/dist/setup/ensure-zmx.js.map +1 -0
  554. package/dist/setup/index.d.ts +1 -1
  555. package/dist/setup/index.js +1 -1
  556. package/dist/setup/open-platform-automation.d.ts +9 -0
  557. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  558. package/dist/setup/open-platform-automation.js +15 -2
  559. package/dist/setup/open-platform-automation.js.map +1 -1
  560. package/dist/setup/setup-args.d.ts +1 -1
  561. package/dist/setup/setup-args.d.ts.map +1 -1
  562. package/dist/setup/setup-args.js +1 -1
  563. package/dist/skills/definitions.d.ts.map +1 -1
  564. package/dist/skills/definitions.js +32 -0
  565. package/dist/skills/definitions.js.map +1 -1
  566. package/dist/skills/injection-mode.d.ts +6 -2
  567. package/dist/skills/injection-mode.d.ts.map +1 -1
  568. package/dist/skills/injection-mode.js +10 -5
  569. package/dist/skills/injection-mode.js.map +1 -1
  570. package/dist/types.d.ts +79 -3
  571. package/dist/types.d.ts.map +1 -1
  572. package/dist/utils/child-env.d.ts +1 -1
  573. package/dist/utils/child-env.d.ts.map +1 -1
  574. package/dist/utils/child-env.js +4 -0
  575. package/dist/utils/child-env.js.map +1 -1
  576. package/dist/utils/effort-confirm-dialog.d.ts +40 -0
  577. package/dist/utils/effort-confirm-dialog.d.ts.map +1 -0
  578. package/dist/utils/effort-confirm-dialog.js +74 -0
  579. package/dist/utils/effort-confirm-dialog.js.map +1 -0
  580. package/dist/utils/pending-input-queue.d.ts +56 -0
  581. package/dist/utils/pending-input-queue.d.ts.map +1 -1
  582. package/dist/utils/pending-input-queue.js +80 -0
  583. package/dist/utils/pending-input-queue.js.map +1 -1
  584. package/dist/utils/render-dimensions.d.ts +8 -0
  585. package/dist/utils/render-dimensions.d.ts.map +1 -1
  586. package/dist/utils/render-dimensions.js +10 -0
  587. package/dist/utils/render-dimensions.js.map +1 -1
  588. package/dist/utils/stuck-key-guard.d.ts +3 -2
  589. package/dist/utils/stuck-key-guard.d.ts.map +1 -1
  590. package/dist/utils/stuck-key-guard.js +3 -3
  591. package/dist/utils/stuck-key-guard.js.map +1 -1
  592. package/dist/utils/terminal-renderer.d.ts +6 -0
  593. package/dist/utils/terminal-renderer.d.ts.map +1 -1
  594. package/dist/utils/terminal-renderer.js +15 -0
  595. package/dist/utils/terminal-renderer.js.map +1 -1
  596. package/dist/utils/tui-input-delivery.d.ts +32 -0
  597. package/dist/utils/tui-input-delivery.d.ts.map +1 -0
  598. package/dist/utils/tui-input-delivery.js +53 -0
  599. package/dist/utils/tui-input-delivery.js.map +1 -0
  600. package/dist/utils/xml.d.ts +19 -0
  601. package/dist/utils/xml.d.ts.map +1 -0
  602. package/dist/utils/xml.js +26 -0
  603. package/dist/utils/xml.js.map +1 -0
  604. package/dist/worker.js +1852 -374
  605. package/dist/worker.js.map +1 -1
  606. package/dist/workflows/definition.d.ts +8 -8
  607. package/dist/workflows/events/payloads.d.ts +8 -8
  608. package/dist/workflows/events/schema.d.ts +160 -160
  609. package/dist/workflows/v3/session-relay-client.js +1 -1
  610. package/dist/workflows/v3/session-relay-client.js.map +1 -1
  611. package/dist/workflows/v3/worker-fence.d.ts.map +1 -1
  612. package/dist/workflows/v3/worker-fence.js +15 -10
  613. package/dist/workflows/v3/worker-fence.js.map +1 -1
  614. package/package.json +1 -1
  615. package/dist/dashboard-web/chunks/bot-defaults-page-UKA7K4JV.js +0 -10
  616. package/dist/dashboard-web/chunks/chunk-DFLNXQNT.js +0 -1
  617. package/dist/dashboard-web/chunks/connectors-page-A77YAB5Y.js +0 -1
  618. package/dist/dashboard-web/chunks/groups-page-RUZNVXQB.js +0 -8
  619. package/dist/dashboard-web/chunks/insights-page-RF7U7RTA.js +0 -22
  620. package/dist/dashboard-web/chunks/roles-page-BUMKD7Y4.js +0 -1
  621. package/dist/dashboard-web/chunks/sessions-page-6HLSEAB7.js +0 -2
  622. package/dist/dashboard-web/chunks/settings-page-MJDZHQ4X.js +0 -2
@@ -0,0 +1,2396 @@
1
+ import { execFile, execFileSync, spawn, spawnSync, } from 'node:child_process';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, rmSync, rmdirSync, writeFileSync, } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { AmbiguousSubmissionBlockedError, } from './types.js';
7
+ import { zmxEnv, probeZmxFunctional } from '../../setup/ensure-zmx.js';
8
+ import { buildBotmuxEnvAssignments, buildDebugKeepShellScript, resolveUserShell, shellWrapperScript, } from './tmux-backend.js';
9
+ import { resolveBotmuxWrapperBinDir } from '../../core/botmux-wrapper.js';
10
+ import { TERMINAL_CANCEL_COOLDOWN_MS } from './critical-control-key.js';
11
+ import { logger } from '../../utils/logger.js';
12
+ import { atomicWriteFileSync } from '../../utils/atomic-write.js';
13
+ import { fsyncDirectorySyncPortable } from '../../utils/fs-durability.js';
14
+ const EARLY_BUFFER_MAX = 1024 * 1024;
15
+ const HISTORY_TAIL_DEBOUNCE_MS = 50;
16
+ const HISTORY_HOT_POLL_MS = 250;
17
+ // Keep the worst-case safety poll below IdleDetector's 2s quiescence window:
18
+ // otherwise a pure-Unicode burst (which broken upstream tail may not signal)
19
+ // could be observed only after the worker had already declared the turn idle.
20
+ const HISTORY_COLD_POLL_MS = 1250;
21
+ const HISTORY_COLD_JITTER_MAX_MS = 250;
22
+ const HISTORY_STABLE_POLLS_BEFORE_COLD = 3;
23
+ const ZMX_HISTORY_TIMEOUT_MS = 3000;
24
+ const ZMX_HISTORY_SETTLE_TIMEOUT_MS = 4000;
25
+ const TAIL_RECOVERY_DELAY_MAX_MS = 2000;
26
+ const FRESH_READY_TIMEOUT_MS = 5000;
27
+ const FRESH_RELEASE_TIMEOUT_MS = 12_000;
28
+ const FRESH_CLI_PID_TIMEOUT_MS = 3000;
29
+ const FRESH_RELEASE_ACK_TIMEOUT_MS = 3000;
30
+ // ZMX removes history as soon as the PTY root exits. Keep the private launch
31
+ // shell alive briefly after the real CLI finishes so the history-only output
32
+ // path can publish the final bytes before the daemon unlinks the session.
33
+ const ZMX_EXIT_HISTORY_GRACE_SECONDS = 3;
34
+ const TAIL_CONNECT_TIMEOUT_MS = 3000;
35
+ const MANAGED_KILL_TIMEOUT_MS = 5000;
36
+ const ZMX_COMMAND_TIMEOUT_MS = 5000;
37
+ const ZMX_HISTORY_MAX_BYTES = 16 * 1024 * 1024;
38
+ // ZMX's daemon reads one 4096-byte IPC frame at a time and may observe HUP from
39
+ // the short-lived `send` client in the same poll iteration. Keep header+payload
40
+ // comfortably below that boundary so it can parse the complete message before
41
+ // closing the client (`zmx send` has no ACK/drain handshake as of v0.7.0).
42
+ const ZMX_SEND_CHUNK_BYTES = 1024;
43
+ // v0.7.0's daemon queues send input into a 256 KiB pty_write_buf and silently
44
+ // DROPS any payload that would overflow it (daemon-side log only, no ACK, so
45
+ // `zmx send` still exits 0). Reject
46
+ // one-shot payloads well below that ceiling before writing any prefix; adapters
47
+ // that intentionally stream larger input already split and throttle their calls.
48
+ const ZMX_SEND_MAX_BYTES = 64 * 1024;
49
+ const BRACKETED_PASTE_START_BYTES = Buffer.from('\x1b[200~');
50
+ const BRACKETED_PASTE_END = '\x1b[201~';
51
+ const BRACKETED_PASTE_END_BYTES = Buffer.from(BRACKETED_PASTE_END);
52
+ const ZMX_TRANSPORT_LABEL = 'botmux.transport';
53
+ const ZMX_TRANSPORT = 'tail-send-v1';
54
+ const ZMX_SESSION_LABEL = 'botmux.session';
55
+ const ZMX_LAUNCH_PID_LABEL = 'botmux.launch_pid';
56
+ const ZMX_GATE_NONCE_LABEL = 'botmux.gate_nonce';
57
+ const ZMX_GATE_PROCESS_MARKER_PREFIX = 'botmux-zmx-private-release-gate-v1:';
58
+ function bytesMatchAt(bytes, marker, cursor, boundary) {
59
+ if (cursor + marker.length > boundary)
60
+ return false;
61
+ for (let i = 0; i < marker.length; i += 1) {
62
+ if (bytes[cursor + i] !== marker[i])
63
+ return false;
64
+ }
65
+ return true;
66
+ }
67
+ function bracketedPasteOpenStatesAtChunkBoundaries(bytes) {
68
+ const states = [false];
69
+ let openPastes = 0;
70
+ let cursor = 0;
71
+ const shortestMarkerLength = Math.min(BRACKETED_PASTE_START_BYTES.length, BRACKETED_PASTE_END_BYTES.length);
72
+ for (let boundary = Math.min(ZMX_SEND_CHUNK_BYTES, bytes.length); boundary > 0; boundary = Math.min(boundary + ZMX_SEND_CHUNK_BYTES, bytes.length)) {
73
+ while (cursor + shortestMarkerLength <= boundary) {
74
+ if (bytesMatchAt(bytes, BRACKETED_PASTE_START_BYTES, cursor, boundary)) {
75
+ openPastes += 1;
76
+ cursor += BRACKETED_PASTE_START_BYTES.length;
77
+ continue;
78
+ }
79
+ if (bytesMatchAt(bytes, BRACKETED_PASTE_END_BYTES, cursor, boundary)) {
80
+ if (openPastes > 0)
81
+ openPastes -= 1;
82
+ cursor += BRACKETED_PASTE_END_BYTES.length;
83
+ continue;
84
+ }
85
+ cursor += 1;
86
+ }
87
+ states.push(openPastes > 0);
88
+ if (boundary === bytes.length)
89
+ break;
90
+ }
91
+ return states;
92
+ }
93
+ const syncSleepCell = new Int32Array(new SharedArrayBuffer(4));
94
+ function sleepSync(ms) {
95
+ Atomics.wait(syncSleepCell, 0, 0, ms);
96
+ }
97
+ /** Cross-platform direct-parent probe used only during the private fresh
98
+ * handshake and warm reattach. ZMX itself is Unix-only, so Linux `/proc` plus
99
+ * BSD-compatible `ps` covers its supported hosts without shell interpolation. */
100
+ function readProcessParentPid(pid) {
101
+ if (!Number.isSafeInteger(pid) || pid <= 0)
102
+ return null;
103
+ if (process.platform === 'linux') {
104
+ try {
105
+ const raw = readFileSync(`/proc/${pid}/stat`, 'utf8');
106
+ const closeParen = raw.lastIndexOf(')');
107
+ if (closeParen < 0)
108
+ return null;
109
+ const fields = raw.slice(closeParen + 2).trim().split(/\s+/);
110
+ const parent = Number(fields[1]);
111
+ return Number.isSafeInteger(parent) && parent > 0 ? parent : null;
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ for (const ps of ['/usr/bin/ps', '/bin/ps']) {
118
+ try {
119
+ const raw = execFileSync(ps, ['-o', 'ppid=', '-p', String(pid)], {
120
+ encoding: 'utf8',
121
+ stdio: ['ignore', 'pipe', 'ignore'],
122
+ timeout: 2000,
123
+ env: { PATH: '/usr/bin:/bin', LANG: 'C' },
124
+ }).trim();
125
+ const parent = Number(raw);
126
+ if (Number.isSafeInteger(parent) && parent > 0)
127
+ return parent;
128
+ }
129
+ catch { /* try the other standard BSD/GNU location */ }
130
+ }
131
+ return null;
132
+ }
133
+ /** Read one same-user process command line without a shell. The private gate
134
+ * marker is random per launch, so a substring check remains unambiguous on
135
+ * macOS where `ps` cannot preserve argv boundaries. */
136
+ function readProcessCommandLine(pid) {
137
+ if (!Number.isSafeInteger(pid) || pid <= 0)
138
+ return null;
139
+ if (process.platform === 'linux') {
140
+ try {
141
+ const raw = readFileSync(`/proc/${pid}/cmdline`);
142
+ if (raw.length === 0)
143
+ return null;
144
+ return raw.toString('utf8').replace(/\0/g, ' ');
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ }
150
+ for (const ps of ['/usr/bin/ps', '/bin/ps']) {
151
+ try {
152
+ const raw = execFileSync(ps, ['-ww', '-o', 'command=', '-p', String(pid)], {
153
+ encoding: 'utf8',
154
+ stdio: ['ignore', 'pipe', 'ignore'],
155
+ timeout: 2000,
156
+ env: { PATH: '/usr/bin:/bin', LANG: 'C' },
157
+ }).trim();
158
+ if (raw)
159
+ return raw;
160
+ }
161
+ catch { /* try the other standard BSD/GNU location */ }
162
+ }
163
+ return null;
164
+ }
165
+ /** Convert plain line feeds into terminal-safe CRLF without doubling CRLF. */
166
+ export function normaliseZmxHistory(text) {
167
+ // ZMX history emits LF while tail can expose CRLF, and high-throughput tail
168
+ // chunking can occasionally repeat the CR at a boundary. All represent the
169
+ // same terminal line break in this plain-text transport.
170
+ return text.replace(/\r*\n/g, '\r\n');
171
+ }
172
+ /**
173
+ * Persistent ZMX backend built from three non-leader primitives:
174
+ *
175
+ * - `zmx tail` is a low-latency change/liveness signal (never a byte source);
176
+ * - `zmx send` injects input without taking client leadership or resizing;
177
+ * - `zmx history` is the sole authoritative plain-text screen source.
178
+ *
179
+ * A one-shot `zmx attach ... /bin/sh <private-bootstrap>` is used only as the
180
+ * race-safe create primitive. Its stdin is /dev/null, so the client exits
181
+ * immediately and never remains as a synthetic leader. The private bootstrap
182
+ * does not launch the CLI until botmux has proved ownership, stamped the
183
+ * transport protocol labels and observed the tail client connected.
184
+ */
185
+ export class ZmxBackend {
186
+ sessionName;
187
+ opts;
188
+ tailProcess = null;
189
+ dataCbs = [];
190
+ screenResyncCbs = [];
191
+ exitCbs = [];
192
+ reattaching;
193
+ intentionalExit = false;
194
+ exited = false;
195
+ state = 'idle';
196
+ tailEpoch = 0;
197
+ reconnectAttempt = 0;
198
+ reconnectTimer = null;
199
+ stableTailTimer = null;
200
+ historyTimer = null;
201
+ historyTimerDueAt = 0;
202
+ historyProcess = null;
203
+ historyGeneration = 0;
204
+ historyCaptureSerial = 0;
205
+ historyInFlight = false;
206
+ historyAgain = false;
207
+ historyAgainActivity = false;
208
+ historyAgainForceResync = false;
209
+ tailActivitySinceCapture = false;
210
+ forceResyncOnNextSnapshot = false;
211
+ stableHistoryPolls = 0;
212
+ snapshotCache = '';
213
+ hasSnapshot = false;
214
+ pendingScreenResyncReplay = false;
215
+ historyColdJitterMs;
216
+ historySettleWaiters = [];
217
+ /** A same-name session failed ownership checks and must never be killed. */
218
+ preserveSessionOnDestroy = false;
219
+ pendingExit = null;
220
+ earlyBuffer = '';
221
+ lastOpts = null;
222
+ /** Frozen ZMX PTY-root PID for the session generation we may control. */
223
+ backingPid = null;
224
+ /** Stable direct child of the PTY root; wrapper resolution may refine cliPid. */
225
+ launchPid = null;
226
+ /** Monotonic generation of compatible, transport-ambiguous text writes. */
227
+ ambiguousTextFailureSequence = 0;
228
+ /** Highest ambiguous text generation confirmed covered by Ctrl+C. */
229
+ cancelledAmbiguousTextFailureSequence = 0;
230
+ /** Highest ambiguous text generation for which Ctrl+C was actually attempted. */
231
+ attemptedAmbiguousTextRecoverySequence = 0;
232
+ /** A recovery Ctrl+C may have landed, so retrying or appending is unsafe. */
233
+ ambiguousTextRecoveryUnconfirmed = false;
234
+ /** Recovery must leave bracketed-paste mode before injecting Ctrl+C. */
235
+ ambiguousTextRecoveryNeedsPasteClose = false;
236
+ /** Worker-level transaction spanning all adapter chunks and its submit key. */
237
+ activeSubmissionFence = null;
238
+ activeSubmissionGeneration = null;
239
+ activeSubmissionTextTouched = false;
240
+ activeSubmissionTransportFailed = false;
241
+ activeSubmissionControlAccepted = false;
242
+ /** Completion-time timestamp: a timed-out Ctrl+C may have landed just before return. */
243
+ lastInjectedCancelAtMs = 0;
244
+ claudeJsonlPath;
245
+ cliPid;
246
+ cliCwd;
247
+ constructor(sessionName, opts = {}) {
248
+ this.sessionName = sessionName;
249
+ this.opts = opts;
250
+ this.reattaching = opts.isReattach ?? false;
251
+ let hash = 0;
252
+ for (const char of sessionName)
253
+ hash = ((hash * 33) + char.charCodeAt(0)) >>> 0;
254
+ this.historyColdJitterMs = hash % (HISTORY_COLD_JITTER_MAX_MS + 1);
255
+ this.restoreAmbiguousRecoveryState();
256
+ }
257
+ static isAvailable() {
258
+ return probeZmxFunctional().ok;
259
+ }
260
+ static sessionName(sessionId) {
261
+ return `bmx-${sessionId.slice(0, 8)}`;
262
+ }
263
+ /**
264
+ * Pair the authoritative healthy-name surface (`list --short`) with the full
265
+ * list's `err=` rows. The full command field is not a line protocol (literal
266
+ * newlines in argv spill onto continuation lines), so it must never be the
267
+ * sole source of truth for a healthy session name.
268
+ */
269
+ static probeSessions(env = zmxEnv()) {
270
+ try {
271
+ const shortOut = execFileSync('zmx', ['list', '--short'], {
272
+ encoding: 'utf-8',
273
+ stdio: ['ignore', 'pipe', 'ignore'],
274
+ timeout: 3000,
275
+ env,
276
+ });
277
+ const out = execFileSync('zmx', ['list'], {
278
+ encoding: 'utf-8',
279
+ stdio: ['ignore', 'pipe', 'ignore'],
280
+ timeout: 3000,
281
+ env,
282
+ });
283
+ const short = parseZmxShortList(shortOut);
284
+ const parsed = parseZmxList(out);
285
+ if (short.malformedLines.length > 0 || parsed.malformedLines.length > 0)
286
+ return { ok: false };
287
+ if (short.sessions.length > 0 && parsed.sessions.length + parsed.unhealthySessions.length === 0) {
288
+ return { ok: false };
289
+ }
290
+ const healthy = new Set(short.sessions);
291
+ // A healthy-looking full row absent from --short is ambiguous (the row
292
+ // can have been forged by a multiline cmd field). Preserve it as unknown,
293
+ // never as authoritative existence or absence. Conversely --short wins
294
+ // over a forged err= continuation for a genuinely healthy name.
295
+ const unhealthy = new Set([
296
+ ...parsed.unhealthySessions.filter(name => !healthy.has(name)),
297
+ ...parsed.sessions.filter(name => !healthy.has(name)),
298
+ ]);
299
+ return {
300
+ ok: true,
301
+ sessions: short.sessions,
302
+ unhealthySessions: [...unhealthy],
303
+ raw: out,
304
+ };
305
+ }
306
+ catch {
307
+ return { ok: false };
308
+ }
309
+ }
310
+ static hasSession(name, env = zmxEnv()) {
311
+ return ZmxBackend.probeSession(name, env) === 'exists';
312
+ }
313
+ static probeSession(name, env = zmxEnv()) {
314
+ const probe = ZmxBackend.probeSessions(env);
315
+ if (!probe.ok)
316
+ return 'unknown';
317
+ if (probe.sessions.includes(name))
318
+ return 'exists';
319
+ if (probe.unhealthySessions.includes(name))
320
+ return 'unknown';
321
+ return 'missing';
322
+ }
323
+ /**
324
+ * Verify that a name still resolves to the botmux-owned transport for the
325
+ * complete session UUID. The PTY-root PID is sampled on both sides of the label
326
+ * reads so a same-name replacement cannot be mistaken for the process whose
327
+ * labels we just inspected.
328
+ */
329
+ static probeManagedSession(name, expectedSessionId, env = zmxEnv(), opts = {}) {
330
+ const beforeSnapshot = ZmxBackend.probeSessions(env);
331
+ if (!beforeSnapshot.ok) {
332
+ return { state: 'unknown', reason: `无法读取 ZMX 会话 ${name} 的进程信息` };
333
+ }
334
+ if (!beforeSnapshot.sessions.includes(name)) {
335
+ return beforeSnapshot.unhealthySessions.includes(name)
336
+ ? { state: 'unknown', reason: `ZMX 会话 ${name} 当前无响应` }
337
+ : { state: 'missing' };
338
+ }
339
+ const before = sessionDetailsFromSnapshot(beforeSnapshot, name);
340
+ if (!before)
341
+ return { state: 'unknown', reason: `ZMX 会话 ${name} 的进程信息不唯一` };
342
+ let transport;
343
+ let sessionId = '';
344
+ let launchPidRaw;
345
+ let gateNonce;
346
+ try {
347
+ transport = execFileSync('zmx', ['get', name, ZMX_TRANSPORT_LABEL], {
348
+ encoding: 'utf8',
349
+ stdio: ['ignore', 'pipe', 'pipe'],
350
+ timeout: 3000,
351
+ env,
352
+ }).trim();
353
+ if (expectedSessionId) {
354
+ sessionId = execFileSync('zmx', ['get', name, ZMX_SESSION_LABEL], {
355
+ encoding: 'utf8',
356
+ stdio: ['ignore', 'pipe', 'pipe'],
357
+ timeout: 3000,
358
+ env,
359
+ }).trim();
360
+ }
361
+ launchPidRaw = execFileSync('zmx', ['get', name, ZMX_LAUNCH_PID_LABEL], {
362
+ encoding: 'utf8',
363
+ stdio: ['ignore', 'pipe', 'pipe'],
364
+ timeout: 3000,
365
+ env,
366
+ }).trim();
367
+ }
368
+ catch (err) {
369
+ return {
370
+ state: 'unknown',
371
+ reason: `无法读取 ZMX 会话 ${name} 的所有权标签:${err instanceof Error ? err.message : String(err)}`,
372
+ };
373
+ }
374
+ try {
375
+ gateNonce = execFileSync('zmx', ['get', name, ZMX_GATE_NONCE_LABEL], {
376
+ encoding: 'utf8',
377
+ stdio: ['ignore', 'pipe', 'pipe'],
378
+ timeout: 3000,
379
+ env,
380
+ }).trim();
381
+ }
382
+ catch (err) {
383
+ return {
384
+ state: 'unknown',
385
+ // Sessions created before the gated-generation protocol cannot prove
386
+ // whether their labelled launch child is still waiting for a private
387
+ // release file. Preserve them for one-time manual cleanup rather than
388
+ // reattaching or deleting by name.
389
+ reason: `ZMX 会话 ${name} 缺少启动门禁标签;已保留,请手动关闭旧会话后重试:${err instanceof Error ? err.message : String(err)}`,
390
+ };
391
+ }
392
+ const afterSnapshot = ZmxBackend.probeSessions(env);
393
+ const after = afterSnapshot.ok ? sessionDetailsFromSnapshot(afterSnapshot, name) : null;
394
+ if (!after || after.pid !== before.pid) {
395
+ return { state: 'unknown', reason: `ZMX 会话 ${name} 在所有权校验期间发生变化` };
396
+ }
397
+ if (transport !== ZMX_TRANSPORT) {
398
+ return {
399
+ state: 'incompatible',
400
+ pid: after.pid,
401
+ clients: after.clients,
402
+ reason: 'transport-label',
403
+ };
404
+ }
405
+ if (expectedSessionId && sessionId !== expectedSessionId) {
406
+ return {
407
+ state: 'incompatible',
408
+ pid: after.pid,
409
+ clients: after.clients,
410
+ reason: 'session-label',
411
+ };
412
+ }
413
+ const launchPid = Number(launchPidRaw);
414
+ if (!Number.isSafeInteger(launchPid)
415
+ || launchPid <= 0
416
+ || readProcessParentPid(launchPid) !== after.pid) {
417
+ return {
418
+ state: 'unknown',
419
+ reason: `ZMX 会话 ${name} 的 launch PID 标签无效或已脱离 PTY root`,
420
+ };
421
+ }
422
+ if (!/^[0-9a-f]{32}$/.test(gateNonce)) {
423
+ return {
424
+ state: 'unknown',
425
+ reason: `ZMX 会话 ${name} 的启动门禁标签无效`,
426
+ };
427
+ }
428
+ if (!opts.allowGated) {
429
+ const commandLine = readProcessCommandLine(launchPid);
430
+ if (!commandLine) {
431
+ return {
432
+ state: 'unknown',
433
+ reason: `无法确认 ZMX 会话 ${name} 的 launch 子进程状态`,
434
+ };
435
+ }
436
+ if (commandLine.includes(`${ZMX_GATE_PROCESS_MARKER_PREFIX}${gateNonce}`)) {
437
+ return {
438
+ state: 'unknown',
439
+ reason: `ZMX 会话 ${name} 仍在等待私有启动门禁放行`,
440
+ };
441
+ }
442
+ }
443
+ return { state: 'compatible', pid: after.pid, clients: after.clients };
444
+ }
445
+ /** ZMX has one daemon per session rather than one shared server. */
446
+ static serverState() {
447
+ const probe = ZmxBackend.probeSessions();
448
+ if (!probe.ok)
449
+ return 'unknown';
450
+ if (probe.unhealthySessions.some(s => s.startsWith('bmx-')))
451
+ return 'unknown';
452
+ return probe.sessions.some(s => s.startsWith('bmx-')) ? 'running' : 'down';
453
+ }
454
+ static killSession(name) {
455
+ try {
456
+ execFileSync('zmx', ['kill', name, '--force'], {
457
+ stdio: 'ignore',
458
+ timeout: ZMX_COMMAND_TIMEOUT_MS,
459
+ env: zmxEnv(),
460
+ });
461
+ }
462
+ catch { /* already gone or daemon unavailable */ }
463
+ }
464
+ /** Kill only a session whose full botmux identity is still authoritative. */
465
+ static killManagedSession(name, expectedSessionId, expectedPid, env = zmxEnv()) {
466
+ const allowGated = expectedPid !== undefined;
467
+ const probe = ZmxBackend.probeManagedSession(name, expectedSessionId, env, { allowGated });
468
+ if (probe.state === 'missing')
469
+ return;
470
+ if (probe.state !== 'compatible') {
471
+ throw new Error(probe.state === 'unknown'
472
+ ? probe.reason
473
+ : `ZMX 会话 ${name} 的所有权标签不匹配,已拒绝删除`);
474
+ }
475
+ if (expectedPid !== undefined && probe.pid !== expectedPid) {
476
+ throw new Error(`ZMX 会话 ${name} 的 PTY root PID 已变化,已拒绝删除`);
477
+ }
478
+ execFileSync('zmx', ['kill', name, '--force'], {
479
+ encoding: 'utf8',
480
+ stdio: ['ignore', 'pipe', 'pipe'],
481
+ timeout: ZMX_COMMAND_TIMEOUT_MS,
482
+ env,
483
+ });
484
+ // Successful ZMX kills print `killed session <name>` (and forced stale
485
+ // cleanup also prints a status line). The exit status plus the generation-
486
+ // aware convergence probe below are authoritative, not empty stdout.
487
+ const deadline = Date.now() + MANAGED_KILL_TIMEOUT_MS;
488
+ let lastUnknownReason = null;
489
+ while (Date.now() < deadline) {
490
+ const after = ZmxBackend.probeManagedSession(name, expectedSessionId, env, { allowGated });
491
+ if (after.state === 'missing')
492
+ return;
493
+ // A just-removed socket may briefly remain visible while its control
494
+ // endpoint already rejects get/list. Treat that as convergence, not as a
495
+ // replacement; an incompatible label or changed PID still fails at once.
496
+ if (after.state === 'unknown') {
497
+ lastUnknownReason = after.reason;
498
+ sleepSync(25);
499
+ continue;
500
+ }
501
+ if (after.state === 'incompatible' || after.pid !== probe.pid) {
502
+ throw new Error(`ZMX 会话 ${name} 在删除确认期间被同名会话替换`);
503
+ }
504
+ sleepSync(25);
505
+ }
506
+ throw new Error(`ZMX 会话 ${name} 删除确认超时` +
507
+ (lastUnknownReason ? `:${lastUnknownReason}` : ''));
508
+ }
509
+ static listBotmuxSessions() {
510
+ const probe = ZmxBackend.probeSessions();
511
+ return probe.ok ? probe.sessions.filter(s => s.startsWith('bmx-')) : [];
512
+ }
513
+ static listDetails() {
514
+ const probe = ZmxBackend.probeSessions();
515
+ return probe.ok ? probe.raw : '';
516
+ }
517
+ get isReattach() {
518
+ return this.reattaching;
519
+ }
520
+ get lastInjectedCancelAt() {
521
+ return this.lastInjectedCancelAtMs;
522
+ }
523
+ captureAmbiguousSubmissionFence() {
524
+ const fence = this.ambiguousTextFailureSequence;
525
+ if (this.ambiguousTextRecoveryUnconfirmed) {
526
+ throw new AmbiguousSubmissionBlockedError('recovery-unconfirmed');
527
+ }
528
+ if (this.hasAmbiguousRecoveryDebt()) {
529
+ throw new AmbiguousSubmissionBlockedError('recovery-pending');
530
+ }
531
+ if (this.activeSubmissionGeneration != null) {
532
+ this.markActiveSubmissionUnconfirmed();
533
+ throw new AmbiguousSubmissionBlockedError('recovery-unconfirmed');
534
+ }
535
+ const generation = this.armAmbiguousTextGeneration();
536
+ if (generation == null) {
537
+ throw new AmbiguousSubmissionBlockedError('recovery-unconfirmed');
538
+ }
539
+ this.activeSubmissionFence = fence;
540
+ this.activeSubmissionGeneration = generation;
541
+ this.activeSubmissionTextTouched = false;
542
+ this.activeSubmissionTransportFailed = false;
543
+ this.activeSubmissionControlAccepted = false;
544
+ return fence;
545
+ }
546
+ confirmAmbiguousSubmission(fence) {
547
+ if (this.ambiguousTextRecoveryUnconfirmed)
548
+ return 'recovery-unconfirmed';
549
+ if (this.activeSubmissionGeneration != null
550
+ && this.activeSubmissionFence !== fence) {
551
+ // A stale caller must never commit another logical transaction. The
552
+ // worker mutex makes this unreachable in the normal path, but keeping the
553
+ // backend contract fail-closed prevents a future caller from silently
554
+ // leaving an armed generation writable.
555
+ this.markActiveSubmissionUnconfirmed();
556
+ return 'recovery-unconfirmed';
557
+ }
558
+ if (this.activeSubmissionFence !== fence
559
+ || this.activeSubmissionGeneration == null) {
560
+ return this.hasAmbiguousRecoveryDebt() ? 'recovery-pending' : undefined;
561
+ }
562
+ if (this.activeSubmissionTransportFailed)
563
+ return 'recovery-pending';
564
+ return this.markAmbiguousGenerationCovered(this.activeSubmissionGeneration)
565
+ ? undefined
566
+ : 'recovery-unconfirmed';
567
+ }
568
+ cancelAmbiguousSubmission(fence) {
569
+ if (this.ambiguousTextRecoveryUnconfirmed)
570
+ return 'recovery-unconfirmed';
571
+ if (this.activeSubmissionGeneration != null
572
+ && this.activeSubmissionFence !== fence) {
573
+ // Do not let a stale cancellation abort a newer transaction or inject
574
+ // Ctrl+C into its composer. Freeze it for explicit fresh-generation
575
+ // recovery instead.
576
+ this.markActiveSubmissionUnconfirmed();
577
+ return 'recovery-unconfirmed';
578
+ }
579
+ if (this.activeSubmissionFence === fence
580
+ && this.activeSubmissionGeneration != null) {
581
+ if (!this.activeSubmissionTextTouched) {
582
+ return this.markAmbiguousGenerationCovered(this.activeSubmissionGeneration)
583
+ ? undefined
584
+ : 'recovery-unconfirmed';
585
+ }
586
+ // Once a control key was accepted, a later adapter exception cannot
587
+ // prove whether that key submitted the prompt. Never inject Ctrl+C into
588
+ // a possibly running turn; freeze until the user inspects/restarts it.
589
+ if (this.activeSubmissionControlAccepted) {
590
+ this.markActiveSubmissionUnconfirmed();
591
+ return 'recovery-unconfirmed';
592
+ }
593
+ return this.abortPartialSend(false);
594
+ }
595
+ const failedThrough = this.ambiguousTextFailureSequence;
596
+ if (failedThrough <= this.cancelledAmbiguousTextFailureSequence)
597
+ return undefined;
598
+ if (failedThrough <= fence)
599
+ return 'recovery-pending';
600
+ return this.abortPartialSend(false);
601
+ }
602
+ /**
603
+ * Space terminal writes themselves so two valid cancellation debts cannot
604
+ * become OMP's double-Ctrl+C exit gesture.
605
+ */
606
+ waitForInjectedCancelCooldown() {
607
+ if (this.lastInjectedCancelAtMs > 0) {
608
+ const elapsed = Math.max(0, Date.now() - this.lastInjectedCancelAtMs);
609
+ const waitMs = TERMINAL_CANCEL_COOLDOWN_MS - elapsed;
610
+ if (waitMs > 0)
611
+ sleepSync(waitMs);
612
+ }
613
+ }
614
+ hasPendingAmbiguousTextRecovery() {
615
+ return this.ambiguousTextFailureSequence
616
+ > this.cancelledAmbiguousTextFailureSequence;
617
+ }
618
+ activeSubmissionCanWrite() {
619
+ return this.activeSubmissionGeneration != null
620
+ && !this.activeSubmissionTransportFailed
621
+ && !this.ambiguousTextRecoveryUnconfirmed;
622
+ }
623
+ hasAmbiguousRecoveryDebt() {
624
+ return this.hasPendingAmbiguousTextRecovery()
625
+ && !this.activeSubmissionCanWrite();
626
+ }
627
+ composerRecoveryStatePath() {
628
+ if (!this.opts.recoveryStateDir || !this.opts.sessionId)
629
+ return null;
630
+ const key = createHash('sha256').update(this.opts.sessionId).digest('hex');
631
+ return join(this.opts.recoveryStateDir, 'zmx-composer-recovery', `${key}.json`);
632
+ }
633
+ readComposerRecoveryState() {
634
+ const path = this.composerRecoveryStatePath();
635
+ if (!path)
636
+ return 'clean';
637
+ try {
638
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
639
+ if (parsed.version !== 1
640
+ || parsed.sessionId !== this.opts.sessionId
641
+ || !['clean', 'pending', 'unconfirmed'].includes(String(parsed.state))) {
642
+ return 'unconfirmed';
643
+ }
644
+ return parsed.state;
645
+ }
646
+ catch (err) {
647
+ if (err
648
+ && typeof err === 'object'
649
+ && 'code' in err
650
+ && err.code === 'ENOENT') {
651
+ return 'clean';
652
+ }
653
+ logger.error(`[zmx:${this.sessionName}] composer recovery journal unreadable; ` +
654
+ `failing closed: ${err instanceof Error ? err.message : String(err)}`);
655
+ return 'unconfirmed';
656
+ }
657
+ }
658
+ writeComposerRecoveryState(state) {
659
+ const path = this.composerRecoveryStatePath();
660
+ if (!path)
661
+ return true;
662
+ try {
663
+ const journalDir = join(this.opts.recoveryStateDir, 'zmx-composer-recovery');
664
+ const created = mkdirSync(journalDir, {
665
+ recursive: true,
666
+ mode: 0o700,
667
+ });
668
+ if (created !== undefined) {
669
+ // fsyncing the journal directory below makes its file entries durable,
670
+ // but a first-ever mkdir is itself an entry in recoveryStateDir. Publish
671
+ // that parent entry before arming the WAL, or a power loss could erase
672
+ // the entire pending journal and make reattach fail open as `clean`.
673
+ try {
674
+ fsyncDirectorySyncPortable(this.opts.recoveryStateDir);
675
+ }
676
+ catch (err) {
677
+ // Do not leave an empty, never-confirmed directory behind: a later
678
+ // retry would otherwise see mkdir as pre-existing, skip this parent
679
+ // durability barrier, and incorrectly trust it.
680
+ try {
681
+ rmdirSync(journalDir);
682
+ }
683
+ catch { /* fail-closed below */ }
684
+ throw err;
685
+ }
686
+ }
687
+ atomicWriteFileSync(path, `${JSON.stringify({ version: 1, sessionId: this.opts.sessionId, state })}\n`, {
688
+ mode: 0o600,
689
+ durable: true,
690
+ followTargetSymlink: false,
691
+ });
692
+ return true;
693
+ }
694
+ catch (err) {
695
+ logger.error(`[zmx:${this.sessionName}] unable to persist composer recovery state ` +
696
+ `${state}; failing closed: ${err instanceof Error ? err.message : String(err)}`);
697
+ return false;
698
+ }
699
+ }
700
+ restoreAmbiguousRecoveryState() {
701
+ const state = this.readComposerRecoveryState();
702
+ if (state === 'clean')
703
+ return;
704
+ this.ambiguousTextFailureSequence = 1;
705
+ // A new worker cannot reconstruct whether bracketed-paste mode was open
706
+ // or whether a cleanup key landed before the old process died. Restore
707
+ // every non-clean journal entry as sticky poison, never as retryable debt.
708
+ this.attemptedAmbiguousTextRecoverySequence = 1;
709
+ this.ambiguousTextRecoveryUnconfirmed = true;
710
+ logger.error(`[zmx:${this.sessionName}] restored ${state} composer recovery state; ` +
711
+ 'automatic input remains blocked until a fresh backend generation');
712
+ }
713
+ /**
714
+ * Write-ahead arm one text transport generation before `zmx send` can touch
715
+ * the PTY. A worker killed after the daemon consumes stdin but before the
716
+ * synchronous client returns will therefore leave `pending` for reattach to
717
+ * restore as poison instead of trusting a stale `clean` marker.
718
+ */
719
+ armAmbiguousTextGeneration() {
720
+ const generation = this.ambiguousTextFailureSequence + 1;
721
+ if (!this.writeComposerRecoveryState('pending')) {
722
+ // No bytes have been sent, but this worker can no longer uphold the
723
+ // recovery journal contract. Keep it fail-closed until a fresh process.
724
+ this.ambiguousTextFailureSequence = generation;
725
+ this.ambiguousTextRecoveryUnconfirmed = true;
726
+ return null;
727
+ }
728
+ this.ambiguousTextFailureSequence = generation;
729
+ return generation;
730
+ }
731
+ markArmedTextFailure(closeOpenPaste) {
732
+ this.ambiguousTextRecoveryNeedsPasteClose ||= closeOpenPaste;
733
+ if (this.activeSubmissionGeneration != null) {
734
+ this.activeSubmissionTransportFailed = true;
735
+ }
736
+ }
737
+ markActiveSubmissionUnconfirmed() {
738
+ // `pending` already survives a failed atomic replacement, so even a failed
739
+ // unconfirmed write remains fail-closed on the next worker.
740
+ this.writeComposerRecoveryState('unconfirmed');
741
+ this.ambiguousTextRecoveryUnconfirmed = true;
742
+ }
743
+ /** Record that a recovery Ctrl+C is now transport-ambiguous itself. */
744
+ markAmbiguousRecoveryAttempted() {
745
+ if (!this.writeComposerRecoveryState('unconfirmed')) {
746
+ this.ambiguousTextRecoveryUnconfirmed = true;
747
+ return null;
748
+ }
749
+ const generation = this.ambiguousTextFailureSequence;
750
+ this.attemptedAmbiguousTextRecoverySequence = Math.max(this.attemptedAmbiguousTextRecoverySequence, generation);
751
+ return generation;
752
+ }
753
+ /**
754
+ * Clear only the exact write-ahead generation whose send or recovery was
755
+ * accepted. Persistence is committed before the in-memory covered marker.
756
+ */
757
+ markAmbiguousGenerationCovered(generation) {
758
+ if (!this.writeComposerRecoveryState('clean')) {
759
+ this.ambiguousTextRecoveryUnconfirmed = true;
760
+ return false;
761
+ }
762
+ this.cancelledAmbiguousTextFailureSequence = Math.max(this.cancelledAmbiguousTextFailureSequence, generation);
763
+ this.ambiguousTextRecoveryUnconfirmed = false;
764
+ if (!this.hasPendingAmbiguousTextRecovery()) {
765
+ this.ambiguousTextRecoveryNeedsPasteClose = false;
766
+ }
767
+ if (this.activeSubmissionGeneration === generation) {
768
+ this.activeSubmissionFence = null;
769
+ this.activeSubmissionGeneration = null;
770
+ this.activeSubmissionTextTouched = false;
771
+ this.activeSubmissionTransportFailed = false;
772
+ this.activeSubmissionControlAccepted = false;
773
+ }
774
+ return true;
775
+ }
776
+ resetAmbiguousRecoveryForFreshSession() {
777
+ if (!this.writeComposerRecoveryState('clean')) {
778
+ throw new Error('ZMX composer recovery journal could not be reset');
779
+ }
780
+ this.ambiguousTextFailureSequence = 0;
781
+ this.cancelledAmbiguousTextFailureSequence = 0;
782
+ this.attemptedAmbiguousTextRecoverySequence = 0;
783
+ this.ambiguousTextRecoveryUnconfirmed = false;
784
+ this.ambiguousTextRecoveryNeedsPasteClose = false;
785
+ this.activeSubmissionFence = null;
786
+ this.activeSubmissionGeneration = null;
787
+ this.activeSubmissionTextTouched = false;
788
+ this.activeSubmissionTransportFailed = false;
789
+ this.activeSubmissionControlAccepted = false;
790
+ }
791
+ /** Start the next terminal-side cooldown after this attempt has settled. */
792
+ noteInjectedCancelAttemptSettled() {
793
+ this.lastInjectedCancelAtMs = Date.now();
794
+ }
795
+ spawn(bin, args, opts) {
796
+ const frozenOpts = {
797
+ ...opts,
798
+ env: { ...opts.env },
799
+ injectEnv: opts.injectEnv ? { ...opts.injectEnv } : undefined,
800
+ };
801
+ const controlEnv = zmxControlEnv(frozenOpts);
802
+ const probe = ZmxBackend.probeManagedSession(this.sessionName, this.opts.sessionId, controlEnv);
803
+ if (probe.state === 'unknown')
804
+ throw new Error(probe.reason);
805
+ if (probe.state === 'incompatible') {
806
+ this.preserveSessionOnDestroy = true;
807
+ throw new Error(probe.reason === 'transport-label'
808
+ ? `ZMX 会话 ${this.sessionName} 缺少 botmux 传输标签(tail 信号 + history 屏幕 + send 输入);已保留,请手动关闭旧会话后重试`
809
+ : `ZMX 会话 ${this.sessionName} 属于另一个完整 botmux session;已保留该会话`);
810
+ }
811
+ if (probe.state === 'compatible') {
812
+ // Reattach/fresh is frozen by worker before plugin, startup-command and
813
+ // isolation decisions. A late same-name winner must not silently turn a
814
+ // fresh launch into an attach to a process created under another policy.
815
+ if (!this.reattaching) {
816
+ this.preserveSessionOnDestroy = true;
817
+ throw new Error(`ZMX 会话 ${this.sessionName} 在启动前已出现;已保留并拒绝改变 fresh 决策`);
818
+ }
819
+ this.backingPid = probe.pid;
820
+ this.lastOpts = frozenOpts;
821
+ this.launchPid = this.readManagedLaunchPid(frozenOpts, probe.pid);
822
+ this.cliPid = this.launchPid;
823
+ }
824
+ else if (this.reattaching) {
825
+ // The daemon selected reattach from an earlier probe. Never turn that
826
+ // stale decision into a new CLI after the backing session disappears.
827
+ throw new Error(`ZMX 会话 ${this.sessionName} 在重连前已消失`);
828
+ }
829
+ else {
830
+ this.lastOpts = frozenOpts;
831
+ }
832
+ logger.debug(`[zmx:${this.sessionName}] spawn ${this.reattaching ? 'reattach' : 'fresh'} ` +
833
+ `bin=${bin} args=${JSON.stringify(args)} cwd=${opts.cwd}`);
834
+ if (this.reattaching) {
835
+ this.startTail();
836
+ if (!this.waitForTailClient()) {
837
+ this.stopTailAfterLaunchFailure();
838
+ this.preserveSessionOnDestroy = true;
839
+ throw new Error(`ZMX tail 未能连接会话 ${this.sessionName}`);
840
+ }
841
+ this.requestHistoryCapture(0, true, true);
842
+ return;
843
+ }
844
+ this.createFreshSession(bin, args, opts);
845
+ }
846
+ write(data) {
847
+ if (!this.sendText(data))
848
+ throw new Error(`ZMX 会话 ${this.sessionName} 输入发送失败`);
849
+ }
850
+ sendText(text) {
851
+ return this.sendBytes(Buffer.from(text, 'utf8'), false, 'text');
852
+ }
853
+ sendSpecialKeys(...keys) {
854
+ return this.sendBytes(Buffer.from(keys.map(tmuxKeyToBytes).join(''), 'utf8'), false, 'control');
855
+ }
856
+ pasteText(text) {
857
+ if (!this.sendBytes(Buffer.from(`\x1b[200~${text}\x1b[201~`, 'utf8'), true, 'text')) {
858
+ throw new Error(`ZMX 会话 ${this.sessionName} 粘贴发送失败`);
859
+ }
860
+ }
861
+ /**
862
+ * No-op by construction: zmx exposes no leaderless resize primitive. Size is
863
+ * set only by an attached client's TIOCGWINSZ, and `send` deliberately never
864
+ * becomes that client (upstream 8ba312d7). Since `createFreshSession` creates
865
+ * the session with a non-TTY stdio, zmx's `getTerminalSize` fallback applies
866
+ * and every botmux-owned session runs at a fixed 120x24 until a local
867
+ * `zmx attach` takes leadership. The CLI therefore wraps its TUI at 120
868
+ * columns, which is the width reflected in the history screen we relay.
869
+ */
870
+ resize(_cols, _rows) { }
871
+ onData(cb) {
872
+ this.dataCbs.push(cb);
873
+ if (this.earlyBuffer) {
874
+ const buffered = this.earlyBuffer;
875
+ this.earlyBuffer = '';
876
+ try {
877
+ cb(buffered);
878
+ }
879
+ catch { /* listener failure must not kill transport */ }
880
+ }
881
+ }
882
+ onScreenResync(cb) {
883
+ this.screenResyncCbs.push(cb);
884
+ if (this.pendingScreenResyncReplay) {
885
+ this.pendingScreenResyncReplay = false;
886
+ try {
887
+ cb(this.snapshotCache);
888
+ }
889
+ catch { /* listener failure must not kill transport */ }
890
+ }
891
+ }
892
+ onExit(cb) {
893
+ this.exitCbs.push(cb);
894
+ if (this.pendingExit) {
895
+ const exit = this.pendingExit;
896
+ try {
897
+ cb(exit.code, exit.signal);
898
+ }
899
+ catch { /* listener failure must not block teardown */ }
900
+ }
901
+ }
902
+ getChildPid() {
903
+ if (this.cliPid)
904
+ return this.cliPid;
905
+ const pid = this.backingPid ?? findSessionPid(this.sessionName);
906
+ if (pid)
907
+ this.cliPid = pid;
908
+ return pid;
909
+ }
910
+ /** Best-effort plain-text terminal snapshot supplied by ZMX history. */
911
+ captureCurrentScreen() {
912
+ return this.hasSnapshot ? this.snapshotCache : '';
913
+ }
914
+ settleCurrentScreen() {
915
+ if (this.exited || this.intentionalExit || !this.lastOpts)
916
+ return Promise.resolve(false);
917
+ // If a capture is already in flight, wait for the dirty-latch follow-up,
918
+ // not the older sample that may have started before the final output.
919
+ const targetSerial = this.historyCaptureSerial + 1;
920
+ return new Promise(resolve => {
921
+ const timer = setTimeout(() => {
922
+ const index = this.historySettleWaiters.findIndex(waiter => waiter.resolve === resolve);
923
+ if (index >= 0)
924
+ this.historySettleWaiters.splice(index, 1);
925
+ resolve(false);
926
+ }, ZMX_HISTORY_SETTLE_TIMEOUT_MS);
927
+ timer.unref?.();
928
+ this.historySettleWaiters.push({ targetSerial, resolve, timer });
929
+ this.requestHistoryCapture(0);
930
+ });
931
+ }
932
+ captureViewport() {
933
+ return this.captureCurrentScreen();
934
+ }
935
+ getPaneSize() {
936
+ return null;
937
+ }
938
+ isPaneAlive() {
939
+ return this.verifyBackingGeneration('liveness').state === 'compatible';
940
+ }
941
+ /** Detach the read-only observer while leaving the ZMX daemon and CLI alive. */
942
+ kill() {
943
+ if (this.state === 'stopped' || this.state === 'exited')
944
+ return;
945
+ this.intentionalExit = true;
946
+ this.state = 'stopped';
947
+ this.tailEpoch += 1;
948
+ this.clearReconnectTimer();
949
+ this.clearStableTailTimer();
950
+ this.stopHistoryPolling();
951
+ const tail = this.tailProcess;
952
+ this.tailProcess = null;
953
+ try {
954
+ tail?.kill('SIGTERM');
955
+ }
956
+ catch { /* already gone */ }
957
+ }
958
+ destroySession() {
959
+ this.kill();
960
+ if (!(this.opts.ownsSession ?? true) || this.preserveSessionOnDestroy)
961
+ return;
962
+ if (!this.opts.sessionId || !this.lastOpts || this.backingPid == null)
963
+ return;
964
+ try {
965
+ ZmxBackend.killManagedSession(this.sessionName, this.opts.sessionId, this.backingPid, zmxControlEnv(this.lastOpts));
966
+ }
967
+ catch (err) {
968
+ this.preserveSessionOnDestroy = true;
969
+ logger.warn(`[zmx:${this.sessionName}] refused unsafe destroy: ` +
970
+ `${err instanceof Error ? err.message : String(err)}`);
971
+ }
972
+ }
973
+ createFreshSession(bin, args, opts) {
974
+ const launch = createZmxLaunchPayload(bin, args, opts);
975
+ let managedIdentityVerified = false;
976
+ let protocolStampAttempted = false;
977
+ try {
978
+ const result = spawnSync('zmx', buildFreshAttachArgs(this.sessionName, launch.bootstrapPath), {
979
+ cwd: opts.cwd,
980
+ // /dev/null makes this a one-shot create client: it never remains as a
981
+ // fake terminal leader and never controls the backing PTY dimensions.
982
+ stdio: ['ignore', 'ignore', 'pipe'],
983
+ timeout: ZMX_COMMAND_TIMEOUT_MS,
984
+ env: zmxControlEnv(opts),
985
+ });
986
+ const ready = this.waitForFreshReady(launch);
987
+ if (!ready) {
988
+ const details = sessionDetails(this.sessionName, zmxControlEnv(opts));
989
+ if (!details?.command?.includes(launch.bootstrapPath)) {
990
+ this.preserveSessionOnDestroy = true;
991
+ }
992
+ const stderr = result.stderr?.toString('utf8').trim();
993
+ throw new Error(`ZMX 会话 ${this.sessionName} 启动握手超时` + (stderr ? `:${stderr}` : ''));
994
+ }
995
+ const created = sessionDetails(this.sessionName, zmxControlEnv(opts));
996
+ if (!created || !created.command?.includes(launch.bootstrapPath)) {
997
+ this.preserveSessionOnDestroy = true;
998
+ throw new Error(`ZMX 会话 ${this.sessionName} 的 fresh 所有权握手失效;已保留同名会话`);
999
+ }
1000
+ this.backingPid = created.pid;
1001
+ const launchPid = this.waitForFreshLaunchPid(launch);
1002
+ if (launchPid == null) {
1003
+ throw new Error(`ZMX 会话 ${this.sessionName} 未能确认稳定的 CLI launch 子进程`);
1004
+ }
1005
+ this.launchPid = launchPid;
1006
+ this.cliPid = launchPid;
1007
+ // `zmx set` is transport-ambiguous: the daemon may have committed every
1008
+ // label even if the one-shot client times out or reports an error. From
1009
+ // this point onward, failure cleanup must attempt the same exact
1010
+ // UUID/PID-guarded kill used after an explicit verification.
1011
+ protocolStampAttempted = true;
1012
+ this.stampProtocolLabels(opts, launchPid, launch.readyNonce);
1013
+ const managed = ZmxBackend.probeManagedSession(this.sessionName, this.opts.sessionId, zmxControlEnv(opts), { allowGated: true });
1014
+ if (managed.state !== 'compatible' || managed.pid !== this.backingPid) {
1015
+ this.preserveSessionOnDestroy = true;
1016
+ throw new Error(`ZMX 会话 ${this.sessionName} 在 release 前未通过完整身份校验`);
1017
+ }
1018
+ managedIdentityVerified = true;
1019
+ this.startTail();
1020
+ if (!this.waitForTailClient()) {
1021
+ throw new Error(`ZMX tail 未能连接会话 ${this.sessionName}`);
1022
+ }
1023
+ // Prove the fresh generation's clean journal can be persisted before the
1024
+ // bootstrap is allowed to exec the real CLI. Some adapters bake the first
1025
+ // prompt into argv; releasing first would let that prompt run before a
1026
+ // journal failure tears the session down, making an automatic retry
1027
+ // capable of repeating side effects. The full managed identity and tail
1028
+ // client are already verified above, so the old composer's debt belongs
1029
+ // to a generation that can no longer receive input.
1030
+ this.resetAmbiguousRecoveryForFreshSession();
1031
+ // The bootstrap performs one read after observing the release file. A
1032
+ // direct write has an open-before-content race, so publish a complete
1033
+ // token atomically within the same private directory.
1034
+ writeFileSync(launch.releaseTempPath, `${launch.releaseToken}\n`, { mode: 0o600, flag: 'wx' });
1035
+ renameSync(launch.releaseTempPath, launch.releasePath);
1036
+ if (!this.waitForFreshReleaseConsumption(launch, launchPid)) {
1037
+ throw new Error(`ZMX 会话 ${this.sessionName} 的 CLI release 未被稳定 launch 子进程确认`);
1038
+ }
1039
+ // The bootstrap can now launch the CLI. Polling is required even when
1040
+ // tail emits no bytes: upstream currently drops pure UTF-8 output while
1041
+ // stripping ANSI, whereas history preserves it.
1042
+ this.requestHistoryCapture(0, true, true);
1043
+ }
1044
+ catch (err) {
1045
+ this.stopTailAfterLaunchFailure();
1046
+ // Before full identity verification, never kill by name: the bounded
1047
+ // private bootstrap exits on its own and a same-name race winner must be
1048
+ // preserved. Once the complete UUID + frozen PTY-root PID have both been
1049
+ // verified, tear down that exact generation even if release was never
1050
+ // published. Otherwise an automatic retry can mistake the still-gated
1051
+ // bootstrap for a healthy reattach target and lose input in its unread
1052
+ // PTY before the release timeout expires.
1053
+ if ((managedIdentityVerified || protocolStampAttempted)
1054
+ && this.opts.sessionId
1055
+ && this.backingPid != null) {
1056
+ try {
1057
+ ZmxBackend.killManagedSession(this.sessionName, this.opts.sessionId, this.backingPid, zmxControlEnv(opts));
1058
+ }
1059
+ catch (killErr) {
1060
+ this.preserveSessionOnDestroy = true;
1061
+ logger.warn(`[zmx:${this.sessionName}] failed to tear down verified fresh launch after handshake error: ` +
1062
+ `${killErr instanceof Error ? killErr.message : String(killErr)}`);
1063
+ }
1064
+ }
1065
+ // Keep cli-pid available until the exact kill has signalled the PTY-root
1066
+ // bootstrap: its HUP trap uses that file to forward termination to the
1067
+ // gated/exec'd child. Cleanup before the kill could orphan that child.
1068
+ launch.cleanup();
1069
+ this.lastOpts = null;
1070
+ this.backingPid = null;
1071
+ this.launchPid = null;
1072
+ this.cliPid = undefined;
1073
+ throw err;
1074
+ }
1075
+ }
1076
+ waitForFreshReady(launch) {
1077
+ const deadline = Date.now() + FRESH_READY_TIMEOUT_MS;
1078
+ while (Date.now() < deadline) {
1079
+ try {
1080
+ const value = readFileSync(launch.readyPath, 'utf8').trim();
1081
+ if (value === launch.readyNonce)
1082
+ return true;
1083
+ }
1084
+ catch { /* bootstrap has not reached the gate yet */ }
1085
+ sleepSync(25);
1086
+ }
1087
+ return false;
1088
+ }
1089
+ waitForFreshLaunchPid(launch) {
1090
+ const deadline = Date.now() + FRESH_CLI_PID_TIMEOUT_MS;
1091
+ while (Date.now() < deadline) {
1092
+ try {
1093
+ const pid = Number(readFileSync(launch.cliPidPath, 'utf8').trim());
1094
+ if (Number.isSafeInteger(pid)
1095
+ && pid > 0
1096
+ && this.backingPid != null
1097
+ && readProcessParentPid(pid) === this.backingPid) {
1098
+ return pid;
1099
+ }
1100
+ }
1101
+ catch { /* bootstrap has not published a complete pid yet */ }
1102
+ sleepSync(25);
1103
+ }
1104
+ return null;
1105
+ }
1106
+ /**
1107
+ * Do not report a fresh spawn until the gate has consumed the atomically
1108
+ * published release token. The original child must remain attached to the
1109
+ * frozen PTY root throughout the wait; otherwise a dead gate can leave a
1110
+ * fully labelled, apparently compatible session that silently drops the
1111
+ * argv-baked first prompt on an automatic reattach.
1112
+ */
1113
+ waitForFreshReleaseConsumption(launch, expectedLaunchPid) {
1114
+ const deadline = Date.now() + FRESH_RELEASE_ACK_TIMEOUT_MS;
1115
+ while (Date.now() < deadline) {
1116
+ const releasePending = existsSync(launch.releasePath);
1117
+ try {
1118
+ const observedLaunchPid = Number(readFileSync(launch.cliPidPath, 'utf8').trim());
1119
+ if (observedLaunchPid !== expectedLaunchPid
1120
+ || this.backingPid == null
1121
+ || readProcessParentPid(observedLaunchPid) !== this.backingPid) {
1122
+ return false;
1123
+ }
1124
+ }
1125
+ catch {
1126
+ return false;
1127
+ }
1128
+ if (!releasePending) {
1129
+ const commandLine = readProcessCommandLine(expectedLaunchPid);
1130
+ if (!commandLine)
1131
+ return false;
1132
+ if (!commandLine.includes(`${ZMX_GATE_PROCESS_MARKER_PREFIX}${launch.readyNonce}`)) {
1133
+ return true;
1134
+ }
1135
+ }
1136
+ sleepSync(25);
1137
+ }
1138
+ return false;
1139
+ }
1140
+ readManagedLaunchPid(opts, expectedBackingPid) {
1141
+ let raw;
1142
+ try {
1143
+ raw = execFileSync('zmx', ['get', this.sessionName, ZMX_LAUNCH_PID_LABEL], {
1144
+ encoding: 'utf8',
1145
+ stdio: ['ignore', 'pipe', 'pipe'],
1146
+ timeout: 3000,
1147
+ env: zmxControlEnv(opts),
1148
+ }).trim();
1149
+ }
1150
+ catch (err) {
1151
+ throw new Error(`ZMX 会话 ${this.sessionName} 缺少 launch PID 标签:` +
1152
+ `${err instanceof Error ? err.message : String(err)}`);
1153
+ }
1154
+ const pid = Number(raw);
1155
+ if (!Number.isSafeInteger(pid)
1156
+ || pid <= 0
1157
+ || readProcessParentPid(pid) !== expectedBackingPid) {
1158
+ throw new Error(`ZMX 会话 ${this.sessionName} 的 launch PID 标签无效或已脱离 PTY root`);
1159
+ }
1160
+ const after = ZmxBackend.probeManagedSession(this.sessionName, this.opts.sessionId, zmxControlEnv(opts));
1161
+ if (after.state !== 'compatible' || after.pid !== expectedBackingPid) {
1162
+ throw new Error(`ZMX 会话 ${this.sessionName} 在 launch PID 校验期间发生变化`);
1163
+ }
1164
+ return pid;
1165
+ }
1166
+ /**
1167
+ * Wait until the session reports at least one connected client.
1168
+ *
1169
+ * Deliberately NOT a differential against a pre-tail baseline. zmx's
1170
+ * `clients=` is an aggregate — `main.zig` reports `clients.items.len - 1`,
1171
+ * subtracting only the `zmx list` connection that asked. A user's
1172
+ * `zmx attach` (which this integration actively encourages), and every
1173
+ * transient `zmx list`/`get`/`history`/`send` botmux itself issues, are all
1174
+ * counted identically to our `tail`. A differential therefore produces false
1175
+ * NEGATIVES: a user detaching inside this window keeps the net delta at 0 and
1176
+ * a perfectly healthy session fails to restore with "tail 未能连接".
1177
+ *
1178
+ * `>= 1` inverts the error direction. The residual false POSITIVE — someone
1179
+ * else holds a client while our tail failed — is cheap and self-correcting:
1180
+ * tail is only a wakeup signal (never a byte source), the authoritative
1181
+ * screen comes from `history` polling which does not need tail at all, and
1182
+ * `scheduleTailRecovery` reconnects a dead observer. Blocking a restore is
1183
+ * the far more expensive mistake.
1184
+ *
1185
+ * Note this cannot instead watch our own child: the wait is synchronous
1186
+ * (`sleepSync`), so the child's 'error'/'close' callbacks cannot run until it
1187
+ * returns. Session identity is still verified below.
1188
+ */
1189
+ waitForTailClient() {
1190
+ const deadline = Date.now() + TAIL_CONNECT_TIMEOUT_MS;
1191
+ while (Date.now() < deadline) {
1192
+ const details = sessionDetails(this.sessionName, this.lastOpts ? zmxControlEnv(this.lastOpts) : zmxEnv());
1193
+ if (details && this.backingPid != null && details.pid !== this.backingPid) {
1194
+ this.preserveSessionOnDestroy = true;
1195
+ return false;
1196
+ }
1197
+ if (details?.clients != null && details.clients >= 1)
1198
+ return true;
1199
+ if (!details && ZmxBackend.probeSession(this.sessionName, this.lastOpts ? zmxControlEnv(this.lastOpts) : zmxEnv()) === 'missing')
1200
+ return false;
1201
+ sleepSync(25);
1202
+ }
1203
+ return false;
1204
+ }
1205
+ stampProtocolLabels(opts, launchPid, gateNonce) {
1206
+ const labels = [`${ZMX_TRANSPORT_LABEL}=${ZMX_TRANSPORT}`];
1207
+ if (this.opts.sessionId)
1208
+ labels.push(`${ZMX_SESSION_LABEL}=${this.opts.sessionId}`);
1209
+ labels.push(`${ZMX_LAUNCH_PID_LABEL}=${launchPid}`);
1210
+ labels.push(`${ZMX_GATE_NONCE_LABEL}=${gateNonce}`);
1211
+ const stdout = execFileSync('zmx', ['set', this.sessionName, ...labels], {
1212
+ encoding: 'utf8',
1213
+ stdio: ['ignore', 'pipe', 'pipe'],
1214
+ timeout: ZMX_COMMAND_TIMEOUT_MS,
1215
+ env: zmxControlEnv(opts),
1216
+ });
1217
+ if (stdout.trim()) {
1218
+ throw new Error(`ZMX 协议标签写入返回异常:${stdout.trim()}`);
1219
+ }
1220
+ }
1221
+ verifyBackingIdentity(context) {
1222
+ if (!this.lastOpts || !this.opts.sessionId || this.backingPid == null) {
1223
+ return { state: 'unknown', reason: `ZMX ${context} 缺少已冻结的会话身份` };
1224
+ }
1225
+ const wasAlive = this.isBackingProcessAlive();
1226
+ let transport;
1227
+ let sessionId;
1228
+ try {
1229
+ const env = zmxControlEnv(this.lastOpts);
1230
+ transport = execFileSync('zmx', ['get', this.sessionName, ZMX_TRANSPORT_LABEL], {
1231
+ encoding: 'utf8',
1232
+ stdio: ['ignore', 'pipe', 'pipe'],
1233
+ timeout: 3000,
1234
+ env,
1235
+ }).trim();
1236
+ sessionId = execFileSync('zmx', ['get', this.sessionName, ZMX_SESSION_LABEL], {
1237
+ encoding: 'utf8',
1238
+ stdio: ['ignore', 'pipe', 'pipe'],
1239
+ timeout: 3000,
1240
+ env,
1241
+ }).trim();
1242
+ }
1243
+ catch (err) {
1244
+ if (!wasAlive || !this.isBackingProcessAlive()) {
1245
+ this.fireExit(0, null);
1246
+ return { state: 'missing' };
1247
+ }
1248
+ return {
1249
+ state: 'unknown',
1250
+ reason: `无法读取 ZMX 会话 ${this.sessionName} 的所有权标签:` +
1251
+ `${err instanceof Error ? err.message : String(err)}`,
1252
+ };
1253
+ }
1254
+ if (transport !== ZMX_TRANSPORT || sessionId !== this.opts.sessionId) {
1255
+ return this.rejectBackingReplacement(context, '完整 session / transport 标签已变化');
1256
+ }
1257
+ if (!wasAlive || !this.isBackingProcessAlive()) {
1258
+ this.fireExit(0, null);
1259
+ return { state: 'missing' };
1260
+ }
1261
+ return { state: 'compatible', clients: null };
1262
+ }
1263
+ /** Full list sampling is reserved for lifecycle edges that need client count.
1264
+ * Hot send/history paths use target-scoped labels + the frozen PTY-root PID,
1265
+ * so one unrelated unhealthy ZMX socket cannot freeze every session. */
1266
+ verifyBackingIdentityWithClients(context) {
1267
+ if (!this.lastOpts || !this.opts.sessionId || this.backingPid == null) {
1268
+ return { state: 'unknown', reason: `ZMX ${context} 缺少已冻结的会话身份` };
1269
+ }
1270
+ const probe = ZmxBackend.probeManagedSession(this.sessionName, this.opts.sessionId, zmxControlEnv(this.lastOpts));
1271
+ if (probe.state === 'missing') {
1272
+ this.fireExit(0, null);
1273
+ return probe;
1274
+ }
1275
+ if (probe.state === 'unknown')
1276
+ return probe;
1277
+ if (probe.state === 'incompatible' || probe.pid !== this.backingPid) {
1278
+ const reason = probe.state === 'incompatible'
1279
+ ? '完整 session / transport 标签已变化'
1280
+ : `PTY root PID 已从 ${this.backingPid} 变化为 ${probe.pid}`;
1281
+ return this.rejectBackingReplacement(context, reason);
1282
+ }
1283
+ return { state: 'compatible', clients: probe.clients };
1284
+ }
1285
+ /** Fast generation check for high-frequency read-only snapshots. */
1286
+ verifyBackingGeneration(context) {
1287
+ return this.verifyBackingIdentity(context);
1288
+ }
1289
+ isBackingProcessAlive() {
1290
+ if (this.backingPid == null)
1291
+ return false;
1292
+ try {
1293
+ process.kill(this.backingPid, 0);
1294
+ return true;
1295
+ }
1296
+ catch (err) {
1297
+ return !!err && typeof err === 'object' && 'code' in err && err.code === 'EPERM';
1298
+ }
1299
+ }
1300
+ rejectBackingReplacement(context, reason) {
1301
+ this.preserveSessionOnDestroy = true;
1302
+ logger.error(`[zmx:${this.sessionName}] ${context} refused: ${reason}`);
1303
+ this.fireExit(75, null);
1304
+ return { state: 'replaced', reason };
1305
+ }
1306
+ labelsMatchBacking(raw) {
1307
+ const labels = new Map();
1308
+ for (const pair of raw.trim().split(/\s+/)) {
1309
+ if (!pair)
1310
+ continue;
1311
+ const equals = pair.indexOf('=');
1312
+ if (equals <= 0)
1313
+ continue;
1314
+ labels.set(pair.slice(0, equals), pair.slice(equals + 1));
1315
+ }
1316
+ return labels.get(ZMX_TRANSPORT_LABEL) === ZMX_TRANSPORT
1317
+ && labels.get(ZMX_SESSION_LABEL) === this.opts.sessionId;
1318
+ }
1319
+ readLabelsAsync(generation) {
1320
+ return new Promise((resolve, reject) => {
1321
+ if (!this.lastOpts || generation !== this.historyGeneration) {
1322
+ reject(new Error('history capture generation changed'));
1323
+ return;
1324
+ }
1325
+ const child = execFile('zmx', ['get', this.sessionName], {
1326
+ encoding: 'utf8',
1327
+ timeout: ZMX_HISTORY_TIMEOUT_MS,
1328
+ maxBuffer: 64 * 1024,
1329
+ env: zmxControlEnv(this.lastOpts),
1330
+ }, (err, stdout, stderr) => {
1331
+ if (this.historyProcess === child)
1332
+ this.historyProcess = null;
1333
+ if (generation !== this.historyGeneration) {
1334
+ reject(new Error('history capture generation changed'));
1335
+ return;
1336
+ }
1337
+ const errorText = stderr?.toString().trim() ?? '';
1338
+ if (err || errorText) {
1339
+ reject(err ?? new Error(errorText));
1340
+ return;
1341
+ }
1342
+ resolve(stdout.toString());
1343
+ });
1344
+ this.historyProcess = child;
1345
+ });
1346
+ }
1347
+ captureHistoryFileAsync(generation) {
1348
+ return new Promise((resolve, reject) => {
1349
+ if (!this.lastOpts || generation !== this.historyGeneration) {
1350
+ reject(new Error('history capture generation changed'));
1351
+ return;
1352
+ }
1353
+ let historyDir;
1354
+ let historyPath;
1355
+ let historyFd;
1356
+ let child = null;
1357
+ let timeout = null;
1358
+ let settled = false;
1359
+ let stderrTail = '';
1360
+ const cleanup = () => {
1361
+ if (timeout)
1362
+ clearTimeout(timeout);
1363
+ timeout = null;
1364
+ if (this.historyProcess === child)
1365
+ this.historyProcess = null;
1366
+ if (historyFd !== undefined) {
1367
+ try {
1368
+ closeSync(historyFd);
1369
+ }
1370
+ catch { /* best effort */ }
1371
+ }
1372
+ if (historyPath) {
1373
+ try {
1374
+ rmSync(historyPath, { force: true });
1375
+ }
1376
+ catch { /* best effort */ }
1377
+ }
1378
+ if (historyDir) {
1379
+ try {
1380
+ rmdirSync(historyDir);
1381
+ }
1382
+ catch { /* best effort */ }
1383
+ }
1384
+ };
1385
+ const finish = (err) => {
1386
+ if (settled)
1387
+ return;
1388
+ settled = true;
1389
+ try {
1390
+ if (err)
1391
+ throw err;
1392
+ if (generation !== this.historyGeneration || historyFd === undefined) {
1393
+ throw new Error('history capture generation changed');
1394
+ }
1395
+ const size = fstatSync(historyFd).size;
1396
+ const length = Math.min(size, ZMX_HISTORY_MAX_BYTES);
1397
+ const data = Buffer.allocUnsafe(length);
1398
+ let bytesRead = 0;
1399
+ while (bytesRead < length) {
1400
+ const count = readSync(historyFd, data, bytesRead, length - bytesRead, size - length + bytesRead);
1401
+ if (count === 0)
1402
+ break;
1403
+ bytesRead += count;
1404
+ }
1405
+ let bounded = data.subarray(0, bytesRead);
1406
+ if (size > length) {
1407
+ const firstLf = bounded.indexOf(0x0a);
1408
+ if (firstLf >= 0)
1409
+ bounded = bounded.subarray(firstLf + 1);
1410
+ }
1411
+ resolve(normaliseZmxHistory(bounded.toString('utf8')));
1412
+ }
1413
+ catch (error) {
1414
+ reject(error);
1415
+ }
1416
+ finally {
1417
+ cleanup();
1418
+ }
1419
+ };
1420
+ try {
1421
+ historyDir = mkdtempSync(join(tmpdir(), 'botmux-zmx-history-'));
1422
+ chmodSync(historyDir, 0o700);
1423
+ historyPath = join(historyDir, 'history.txt');
1424
+ historyFd = openSync(historyPath, 'wx+', 0o600);
1425
+ // Keep transcript bytes reachable only through the open fd. A private
1426
+ // regular file also avoids `zmx history`'s single-write pipe truncation.
1427
+ rmSync(historyPath);
1428
+ child = spawn('zmx', ['history', this.sessionName], {
1429
+ stdio: ['ignore', historyFd, 'pipe'],
1430
+ env: zmxControlEnv(this.lastOpts),
1431
+ });
1432
+ this.historyProcess = child;
1433
+ child.stderr?.on('data', (chunk) => {
1434
+ stderrTail = (stderrTail + chunk.toString()).slice(-4096);
1435
+ });
1436
+ child.once('error', error => finish(error));
1437
+ child.once('close', (code, signal) => {
1438
+ const stderr = stderrTail.trim();
1439
+ if (code !== 0 || signal || stderr) {
1440
+ finish(new Error(stderr || `zmx history exited status=${code} signal=${signal}`));
1441
+ return;
1442
+ }
1443
+ finish();
1444
+ });
1445
+ timeout = setTimeout(() => {
1446
+ try {
1447
+ child?.kill('SIGKILL');
1448
+ }
1449
+ catch { /* already gone */ }
1450
+ finish(new Error(`zmx history timed out after ${ZMX_HISTORY_TIMEOUT_MS}ms`));
1451
+ }, ZMX_HISTORY_TIMEOUT_MS);
1452
+ timeout.unref?.();
1453
+ }
1454
+ catch (err) {
1455
+ finish(err instanceof Error ? err : new Error(String(err)));
1456
+ }
1457
+ });
1458
+ }
1459
+ async readHistorySnapshotAsync(generation) {
1460
+ if (this.exited || this.intentionalExit || !this.lastOpts)
1461
+ return null;
1462
+ try {
1463
+ if (!this.isBackingProcessAlive()) {
1464
+ this.fireExit(0, null);
1465
+ return null;
1466
+ }
1467
+ const before = await this.readLabelsAsync(generation);
1468
+ if (!this.labelsMatchBacking(before)) {
1469
+ this.rejectBackingReplacement('history', '完整 session / transport 标签已变化');
1470
+ return null;
1471
+ }
1472
+ const snapshot = await this.captureHistoryFileAsync(generation);
1473
+ const after = await this.readLabelsAsync(generation);
1474
+ if (!this.labelsMatchBacking(after) || !this.isBackingProcessAlive()) {
1475
+ if (!this.isBackingProcessAlive())
1476
+ this.fireExit(0, null);
1477
+ else
1478
+ this.rejectBackingReplacement('history completion', '完整 session / transport 标签已变化');
1479
+ return null;
1480
+ }
1481
+ return snapshot;
1482
+ }
1483
+ catch (err) {
1484
+ if (generation !== this.historyGeneration || this.intentionalExit || this.exited)
1485
+ return null;
1486
+ logger.warn(`[zmx:${this.sessionName}] history capture failed: ` +
1487
+ `${err instanceof Error ? err.message : String(err)}`);
1488
+ if (!this.isBackingProcessAlive())
1489
+ this.fireExit(0, null);
1490
+ return null;
1491
+ }
1492
+ }
1493
+ requestHistoryCapture(delayMs, activity = false, forceResync = false) {
1494
+ if (this.exited || this.intentionalExit || !this.lastOpts)
1495
+ return;
1496
+ if (activity) {
1497
+ this.tailActivitySinceCapture = true;
1498
+ this.stableHistoryPolls = 0;
1499
+ }
1500
+ if (forceResync)
1501
+ this.forceResyncOnNextSnapshot = true;
1502
+ if (this.historyInFlight) {
1503
+ this.historyAgain = true;
1504
+ this.historyAgainActivity ||= activity;
1505
+ this.historyAgainForceResync ||= forceResync;
1506
+ return;
1507
+ }
1508
+ const dueAt = Date.now() + Math.max(0, delayMs);
1509
+ if (this.historyTimer && this.historyTimerDueAt <= dueAt)
1510
+ return;
1511
+ if (this.historyTimer)
1512
+ clearTimeout(this.historyTimer);
1513
+ this.historyTimerDueAt = dueAt;
1514
+ this.historyTimer = setTimeout(() => {
1515
+ this.historyTimer = null;
1516
+ this.historyTimerDueAt = 0;
1517
+ void this.runHistoryCapture();
1518
+ }, Math.max(0, delayMs));
1519
+ this.historyTimer.unref?.();
1520
+ }
1521
+ async runHistoryCapture() {
1522
+ if (this.historyInFlight || this.exited || this.intentionalExit || !this.lastOpts)
1523
+ return;
1524
+ this.historyInFlight = true;
1525
+ const captureSerial = ++this.historyCaptureSerial;
1526
+ const generation = this.historyGeneration;
1527
+ const activity = this.tailActivitySinceCapture;
1528
+ const forceResync = this.forceResyncOnNextSnapshot;
1529
+ this.tailActivitySinceCapture = false;
1530
+ this.forceResyncOnNextSnapshot = false;
1531
+ let snapshot = null;
1532
+ let captureUsable = false;
1533
+ try {
1534
+ snapshot = await this.readHistorySnapshotAsync(generation);
1535
+ if (snapshot !== null && generation === this.historyGeneration) {
1536
+ captureUsable = this.publishHistorySnapshot(snapshot, activity, forceResync);
1537
+ }
1538
+ else {
1539
+ this.stableHistoryPolls = 0;
1540
+ }
1541
+ if (!captureUsable) {
1542
+ // A failed/ambiguous capture must not consume the wakeup that caused
1543
+ // it. Preserve both obligations for the next usable history sample.
1544
+ this.tailActivitySinceCapture ||= activity;
1545
+ this.forceResyncOnNextSnapshot ||= forceResync;
1546
+ }
1547
+ }
1548
+ finally {
1549
+ if (generation !== this.historyGeneration)
1550
+ return;
1551
+ const again = this.historyAgain;
1552
+ const againActivity = this.historyAgainActivity;
1553
+ const againForceResync = this.historyAgainForceResync;
1554
+ this.historyAgain = false;
1555
+ this.historyAgainActivity = false;
1556
+ this.historyAgainForceResync = false;
1557
+ // A capture dirtied while in flight predates output that arrived after
1558
+ // it started. Settle waiters must remain pending for the latched follow-
1559
+ // up rather than finalizing from this stale-but-otherwise-usable sample.
1560
+ if (!again)
1561
+ this.resolveHistorySettleWaiters(captureSerial, captureUsable);
1562
+ this.historyInFlight = false;
1563
+ if (this.exited || this.intentionalExit || !this.lastOpts)
1564
+ return;
1565
+ if (again) {
1566
+ // Preserve the dirty-latch obligation, but give the single-threaded
1567
+ // daemon a short breathing window before serializing history again.
1568
+ // Continuous tail chunks otherwise turn a long transcript into a
1569
+ // back-to-back history loop that can starve concurrent `send` probes.
1570
+ this.requestHistoryCapture(HISTORY_TAIL_DEBOUNCE_MS, againActivity, againForceResync);
1571
+ return;
1572
+ }
1573
+ const nextDelay = this.stableHistoryPolls >= HISTORY_STABLE_POLLS_BEFORE_COLD
1574
+ ? HISTORY_COLD_POLL_MS + this.historyColdJitterMs
1575
+ : HISTORY_HOT_POLL_MS;
1576
+ this.requestHistoryCapture(nextDelay);
1577
+ }
1578
+ }
1579
+ publishHistorySnapshot(snapshot, activity, forceResync) {
1580
+ // Current ZMX history failures can exit 0 with empty stdout. Never let an
1581
+ // ambiguous empty capture erase a previously non-empty authoritative view.
1582
+ if (snapshot.length === 0 && this.hasSnapshot && this.snapshotCache.length > 0) {
1583
+ this.stableHistoryPolls = 0;
1584
+ return false;
1585
+ }
1586
+ if (!this.hasSnapshot) {
1587
+ this.hasSnapshot = true;
1588
+ this.snapshotCache = snapshot;
1589
+ this.stableHistoryPolls = snapshot.length === 0 ? 1 : 0;
1590
+ if (forceResync)
1591
+ this.emitScreenResync(snapshot);
1592
+ else if (snapshot)
1593
+ this.emitData(snapshot);
1594
+ return true;
1595
+ }
1596
+ const previous = this.snapshotCache;
1597
+ if (snapshot === previous) {
1598
+ this.stableHistoryPolls += 1;
1599
+ // Tail can signal a cursor-only redraw that leaves plain history equal.
1600
+ // Rebase derived state so idle detection observes the activity without
1601
+ // trusting any (possibly UTF-8-corrupted) tail payload bytes.
1602
+ if (activity)
1603
+ this.emitScreenResync(snapshot);
1604
+ return true;
1605
+ }
1606
+ this.snapshotCache = snapshot;
1607
+ this.stableHistoryPolls = 0;
1608
+ if (!forceResync && snapshot.startsWith(previous)) {
1609
+ const delta = snapshot.slice(previous.length);
1610
+ if (delta)
1611
+ this.emitData(delta);
1612
+ return true;
1613
+ }
1614
+ this.emitScreenResync(snapshot);
1615
+ return true;
1616
+ }
1617
+ stopHistoryPolling() {
1618
+ this.historyGeneration += 1;
1619
+ if (this.historyTimer)
1620
+ clearTimeout(this.historyTimer);
1621
+ this.historyTimer = null;
1622
+ this.historyTimerDueAt = 0;
1623
+ const child = this.historyProcess;
1624
+ this.historyProcess = null;
1625
+ try {
1626
+ child?.kill('SIGKILL');
1627
+ }
1628
+ catch { /* already gone */ }
1629
+ this.historyInFlight = false;
1630
+ this.historyAgain = false;
1631
+ this.historyAgainActivity = false;
1632
+ this.historyAgainForceResync = false;
1633
+ this.tailActivitySinceCapture = false;
1634
+ this.forceResyncOnNextSnapshot = false;
1635
+ for (const waiter of this.historySettleWaiters.splice(0)) {
1636
+ clearTimeout(waiter.timer);
1637
+ waiter.resolve(false);
1638
+ }
1639
+ }
1640
+ resolveHistorySettleWaiters(captureSerial, success) {
1641
+ for (let i = this.historySettleWaiters.length - 1; i >= 0; i -= 1) {
1642
+ const waiter = this.historySettleWaiters[i];
1643
+ if (waiter.targetSerial > captureSerial)
1644
+ continue;
1645
+ this.historySettleWaiters.splice(i, 1);
1646
+ clearTimeout(waiter.timer);
1647
+ waiter.resolve(success);
1648
+ }
1649
+ }
1650
+ emitScreenResync(snapshot) {
1651
+ if (this.screenResyncCbs.length === 0) {
1652
+ this.pendingScreenResyncReplay = true;
1653
+ return;
1654
+ }
1655
+ for (const cb of this.screenResyncCbs) {
1656
+ try {
1657
+ cb(snapshot);
1658
+ }
1659
+ catch { /* listener failure must not kill recovery */ }
1660
+ }
1661
+ }
1662
+ sendBytes(bytes, bracketedPaste = false, intent = 'text') {
1663
+ if (bytes.length === 0)
1664
+ return true;
1665
+ if (this.exited || this.intentionalExit || !this.lastOpts)
1666
+ return false;
1667
+ if (bytes.length > ZMX_SEND_MAX_BYTES) {
1668
+ throw new Error(`ZMX 单次输入 ${bytes.length} 字节超过 ${ZMX_SEND_MAX_BYTES} 字节安全上限;` +
1669
+ '当前 send 协议没有 ACK/backpressure,已在写入任何前缀前拒绝');
1670
+ }
1671
+ const isRecoveryCtrlC = intent === 'control'
1672
+ && bytes.length === 1
1673
+ && bytes[0] === 0x03;
1674
+ if (this.hasAmbiguousRecoveryDebt()) {
1675
+ if (intent === 'text' || !isRecoveryCtrlC || this.ambiguousTextRecoveryUnconfirmed) {
1676
+ logger.error(`[zmx:${this.sessionName}] input blocked: ambiguous composer recovery is ` +
1677
+ `${this.ambiguousTextRecoveryUnconfirmed ? 'unconfirmed' : 'pending'}`);
1678
+ return false;
1679
+ }
1680
+ }
1681
+ const ownsTextGeneration = intent === 'text'
1682
+ && this.activeSubmissionGeneration == null;
1683
+ const textGeneration = intent === 'text'
1684
+ ? (this.activeSubmissionGeneration ?? this.armAmbiguousTextGeneration())
1685
+ : 0;
1686
+ if (textGeneration == null)
1687
+ return false;
1688
+ // Arm first, then perform the one complete backing-identity probe nearest
1689
+ // the PTY write. A crash before the probe only leaves a conservative
1690
+ // pending marker; no replacement session can receive the bytes.
1691
+ const identity = this.verifyBackingIdentity('send');
1692
+ if (identity.state !== 'compatible') {
1693
+ if (identity.state === 'unknown') {
1694
+ logger.warn(`[zmx:${this.sessionName}] send blocked: ${identity.reason}`);
1695
+ }
1696
+ if (ownsTextGeneration && textGeneration > 0) {
1697
+ this.markAmbiguousGenerationCovered(textGeneration);
1698
+ }
1699
+ return false;
1700
+ }
1701
+ const explicitPasteOpenAtBoundary = bracketedPaste
1702
+ ? null
1703
+ : bracketedPasteOpenStatesAtChunkBoundaries(bytes);
1704
+ for (let offset = 0; offset < bytes.length; offset += ZMX_SEND_CHUNK_BYTES) {
1705
+ const chunk = bytes.subarray(offset, Math.min(offset + ZMX_SEND_CHUNK_BYTES, bytes.length));
1706
+ // ZMX strips exactly one trailing LF from piped stdin. Appending our own
1707
+ // framing LF therefore preserves the caller's original bytes exactly,
1708
+ // including an original trailing LF, while keeping secrets out of argv.
1709
+ let input = Buffer.concat([chunk, Buffer.from('\n')]);
1710
+ // A failed send has no PTY-level ACK: the current chunk may or may not
1711
+ // have reached the CLI. Track both possible states. Looking only at the
1712
+ // confirmed prefix misses an opening marker in a first ambiguous frame;
1713
+ // looking only through the current frame misses an opening marker whose
1714
+ // matching close sits in that ambiguous frame.
1715
+ const chunkIndex = offset / ZMX_SEND_CHUNK_BYTES;
1716
+ const mayHaveOpenPasteAfterAmbiguousSend = bracketedPaste
1717
+ || explicitPasteOpenAtBoundary?.[chunkIndex] === true
1718
+ || explicitPasteOpenAtBoundary?.[chunkIndex + 1] === true;
1719
+ let injectedCancelAttempt = false;
1720
+ let recoveryGeneration = 0;
1721
+ try {
1722
+ if (intent === 'text' && this.activeSubmissionGeneration != null) {
1723
+ this.activeSubmissionTextTouched = true;
1724
+ }
1725
+ if (intent === 'control' && chunk.includes(0x03)) {
1726
+ this.waitForInjectedCancelCooldown();
1727
+ const postCooldownIdentity = this.verifyBackingIdentity('control-key cooldown');
1728
+ if (postCooldownIdentity.state !== 'compatible') {
1729
+ logger.warn(`[zmx:${this.sessionName}] skipped Ctrl+C after cooldown: ` +
1730
+ `backing identity is ${postCooldownIdentity.state}`);
1731
+ return false;
1732
+ }
1733
+ if (this.hasAmbiguousRecoveryDebt()) {
1734
+ if (this.attemptedAmbiguousTextRecoverySequence
1735
+ >= this.ambiguousTextFailureSequence) {
1736
+ logger.error(`[zmx:${this.sessionName}] refused duplicate ambiguous composer recovery`);
1737
+ return false;
1738
+ }
1739
+ const startedRecovery = this.markAmbiguousRecoveryAttempted();
1740
+ if (startedRecovery == null)
1741
+ return false;
1742
+ recoveryGeneration = startedRecovery;
1743
+ if (this.ambiguousTextRecoveryNeedsPasteClose) {
1744
+ input = Buffer.concat([
1745
+ Buffer.from(BRACKETED_PASTE_END),
1746
+ chunk,
1747
+ Buffer.from('\n'),
1748
+ ]);
1749
+ }
1750
+ }
1751
+ injectedCancelAttempt = true;
1752
+ }
1753
+ let stdout;
1754
+ try {
1755
+ stdout = execFileSync('zmx', ['send', this.sessionName], {
1756
+ input,
1757
+ encoding: 'utf8',
1758
+ stdio: ['pipe', 'pipe', 'pipe'],
1759
+ timeout: ZMX_COMMAND_TIMEOUT_MS,
1760
+ maxBuffer: 1024 * 1024,
1761
+ env: zmxControlEnv(this.lastOpts),
1762
+ });
1763
+ }
1764
+ finally {
1765
+ if (injectedCancelAttempt)
1766
+ this.noteInjectedCancelAttemptSettled();
1767
+ }
1768
+ // Several ZMX control-plane failures are reported on stdout with exit
1769
+ // status 0. Empty stdout is part of the transport contract.
1770
+ if (stdout.trim()) {
1771
+ if (recoveryGeneration > 0) {
1772
+ this.ambiguousTextRecoveryUnconfirmed = true;
1773
+ }
1774
+ else if (intent === 'control'
1775
+ && this.activeSubmissionGeneration != null) {
1776
+ this.markActiveSubmissionUnconfirmed();
1777
+ }
1778
+ logger.warn(`[zmx:${this.sessionName}] send rejected: ${stdout.trim()}`);
1779
+ if (intent === 'text') {
1780
+ this.markArmedTextFailure(mayHaveOpenPasteAfterAmbiguousSend);
1781
+ }
1782
+ const probe = this.verifyBackingIdentity('send rejection');
1783
+ const closeOpenPaste = mayHaveOpenPasteAfterAmbiguousSend;
1784
+ if (closeOpenPaste || offset > 0) {
1785
+ if (probe.state === 'compatible') {
1786
+ this.abortPartialSend(closeOpenPaste);
1787
+ }
1788
+ else {
1789
+ logger.warn(`[zmx:${this.sessionName}] skipped partial-send recovery: ` +
1790
+ `backing identity is ${probe.state}`);
1791
+ }
1792
+ }
1793
+ return false;
1794
+ }
1795
+ if (recoveryGeneration > 0) {
1796
+ // ZMX v0.7 reports only control-client acceptance, not a PTY ACK; it
1797
+ // may still drop a byte when its pty_write_buf is full. Keep the
1798
+ // durable state unconfirmed even after empty stdout so later prompts
1799
+ // cannot append to a composer that Ctrl+C may not have cleared.
1800
+ this.ambiguousTextRecoveryUnconfirmed = true;
1801
+ return false;
1802
+ }
1803
+ else if (intent === 'control'
1804
+ && this.activeSubmissionGeneration != null
1805
+ && this.activeSubmissionTextTouched) {
1806
+ this.activeSubmissionControlAccepted = true;
1807
+ }
1808
+ }
1809
+ catch (err) {
1810
+ if (recoveryGeneration > 0) {
1811
+ this.ambiguousTextRecoveryUnconfirmed = true;
1812
+ }
1813
+ else if (intent === 'control'
1814
+ && this.activeSubmissionGeneration != null) {
1815
+ this.markActiveSubmissionUnconfirmed();
1816
+ }
1817
+ if (intent === 'text') {
1818
+ this.markArmedTextFailure(mayHaveOpenPasteAfterAmbiguousSend);
1819
+ }
1820
+ const probe = this.verifyBackingIdentity('send failure');
1821
+ logger.warn(`[zmx:${this.sessionName}] send failed (${probe.state}): ` +
1822
+ `${err instanceof Error ? err.message : String(err)}`);
1823
+ // Never retry an ambiguous send: ZMX has no PTY-level ACK, so retrying
1824
+ // can duplicate a prompt that the daemon already queued.
1825
+ const closeOpenPaste = mayHaveOpenPasteAfterAmbiguousSend;
1826
+ if (closeOpenPaste || offset > 0) {
1827
+ if (probe.state === 'compatible') {
1828
+ this.abortPartialSend(closeOpenPaste);
1829
+ }
1830
+ else {
1831
+ logger.warn(`[zmx:${this.sessionName}] skipped partial-send recovery: ` +
1832
+ `backing identity is ${probe.state}`);
1833
+ }
1834
+ }
1835
+ return false;
1836
+ }
1837
+ }
1838
+ if (ownsTextGeneration
1839
+ && textGeneration > 0
1840
+ && !this.markAmbiguousGenerationCovered(textGeneration)) {
1841
+ return false;
1842
+ }
1843
+ this.requestHistoryCapture(HISTORY_TAIL_DEBOUNCE_MS, true);
1844
+ return true;
1845
+ }
1846
+ abortPartialSend(closeOpenPaste) {
1847
+ if (this.ambiguousTextRecoveryUnconfirmed)
1848
+ return 'recovery-unconfirmed';
1849
+ if (!this.hasPendingAmbiguousTextRecovery())
1850
+ return undefined;
1851
+ if (!this.lastOpts)
1852
+ return 'recovery-pending';
1853
+ if (this.attemptedAmbiguousTextRecoverySequence
1854
+ >= this.ambiguousTextFailureSequence) {
1855
+ this.ambiguousTextRecoveryUnconfirmed = true;
1856
+ return 'recovery-unconfirmed';
1857
+ }
1858
+ // A failed multi-frame paste may have delivered the opening marker but not
1859
+ // its close. Best-effort close it first, then cancel the partial composer
1860
+ // so a later retry cannot append to/trivially submit truncated input.
1861
+ const recovery = closeOpenPaste || this.ambiguousTextRecoveryNeedsPasteClose
1862
+ ? `${BRACKETED_PASTE_END}\x03`
1863
+ : '\x03';
1864
+ this.waitForInjectedCancelCooldown();
1865
+ const postCooldownIdentity = this.verifyBackingIdentity('partial-send recovery cooldown');
1866
+ if (postCooldownIdentity.state !== 'compatible') {
1867
+ logger.warn(`[zmx:${this.sessionName}] skipped partial-send recovery after cooldown: ` +
1868
+ `backing identity is ${postCooldownIdentity.state}`);
1869
+ return 'recovery-pending';
1870
+ }
1871
+ const recoveryGeneration = this.markAmbiguousRecoveryAttempted();
1872
+ if (recoveryGeneration == null)
1873
+ return 'recovery-unconfirmed';
1874
+ try {
1875
+ let stdout;
1876
+ try {
1877
+ stdout = execFileSync('zmx', ['send', this.sessionName], {
1878
+ input: Buffer.concat([Buffer.from(recovery), Buffer.from('\n')]),
1879
+ encoding: 'utf8',
1880
+ stdio: ['pipe', 'pipe', 'pipe'],
1881
+ timeout: ZMX_COMMAND_TIMEOUT_MS,
1882
+ maxBuffer: 1024 * 1024,
1883
+ env: zmxControlEnv(this.lastOpts),
1884
+ });
1885
+ }
1886
+ finally {
1887
+ // A timeout is ambiguous: the Ctrl+C may have landed at any point
1888
+ // before the command returned, so start downstream cooldown now.
1889
+ this.noteInjectedCancelAttemptSettled();
1890
+ }
1891
+ if (stdout.trim())
1892
+ throw new Error(stdout.trim());
1893
+ // Empty stdout confirms only that the one-shot client was accepted. ZMX
1894
+ // has no daemon→PTY ACK and can silently drop a full-buffer write, so the
1895
+ // recovery remains poisoned until a fresh backend generation.
1896
+ this.ambiguousTextRecoveryUnconfirmed = true;
1897
+ logger.warn(`[zmx:${this.sessionName}] attempted partial-input cancellation; ` +
1898
+ 'PTY delivery remains unconfirmed and automatic input stays blocked');
1899
+ }
1900
+ catch (err) {
1901
+ this.ambiguousTextRecoveryUnconfirmed = true;
1902
+ logger.error(`[zmx:${this.sessionName}] unable to cancel partially delivered input; ` +
1903
+ `manual session recovery may be required: ${err instanceof Error ? err.message : String(err)}`);
1904
+ this.requestHistoryCapture(0, true, true);
1905
+ return 'recovery-unconfirmed';
1906
+ }
1907
+ this.requestHistoryCapture(0, true, true);
1908
+ return 'recovery-unconfirmed';
1909
+ }
1910
+ startTail() {
1911
+ this.clearReconnectTimer();
1912
+ const epoch = ++this.tailEpoch;
1913
+ const child = spawn('zmx', ['tail', this.sessionName], {
1914
+ cwd: this.lastOpts?.cwd,
1915
+ stdio: ['ignore', 'pipe', 'pipe'],
1916
+ env: this.lastOpts ? zmxControlEnv(this.lastOpts) : zmxEnv(),
1917
+ });
1918
+ this.tailProcess = child;
1919
+ this.state = 'observing';
1920
+ this.clearStableTailTimer();
1921
+ this.stableTailTimer = setTimeout(() => {
1922
+ this.stableTailTimer = null;
1923
+ if (epoch === this.tailEpoch && this.tailProcess === child && this.state === 'observing') {
1924
+ this.reconnectAttempt = 0;
1925
+ }
1926
+ }, 5000);
1927
+ this.stableTailTimer.unref?.();
1928
+ let settled = false;
1929
+ let stderrTail = '';
1930
+ child.stdout?.on('data', (chunk) => {
1931
+ if (epoch !== this.tailEpoch || this.tailProcess !== child || this.intentionalExit || this.exited)
1932
+ return;
1933
+ // Drain the stream, but never expose its bytes. Upstream's ANSI stripper
1934
+ // currently deletes UTF-8 and can even consume following ASCII; only the
1935
+ // existence of a chunk is trustworthy enough to wake history capture.
1936
+ if ((typeof chunk === 'string' ? chunk.length : chunk.byteLength) > 0) {
1937
+ this.requestHistoryCapture(HISTORY_TAIL_DEBOUNCE_MS, true);
1938
+ }
1939
+ });
1940
+ child.stderr?.on('data', (chunk) => {
1941
+ stderrTail = (stderrTail + chunk.toString()).slice(-4096);
1942
+ });
1943
+ const finish = (code, signal, err) => {
1944
+ if (settled)
1945
+ return;
1946
+ settled = true;
1947
+ if (epoch !== this.tailEpoch || this.tailProcess !== child || this.intentionalExit || this.exited)
1948
+ return;
1949
+ this.tailProcess = null;
1950
+ this.state = 'recovering';
1951
+ this.clearStableTailTimer();
1952
+ if (err || stderrTail.trim()) {
1953
+ logger.warn(`[zmx:${this.sessionName}] tail ended: ` +
1954
+ `${err?.message ?? stderrTail.trim()}`);
1955
+ }
1956
+ this.scheduleTailRecovery(code, signal);
1957
+ };
1958
+ child.once('error', err => finish(null, null, err));
1959
+ child.once('close', (code, signal) => finish(code, signal));
1960
+ }
1961
+ scheduleTailRecovery(code, signal) {
1962
+ if (this.intentionalExit || this.exited || !this.lastOpts)
1963
+ return;
1964
+ const delay = Math.min(50 * (2 ** this.reconnectAttempt), TAIL_RECOVERY_DELAY_MAX_MS);
1965
+ this.reconnectAttempt += 1;
1966
+ this.clearReconnectTimer();
1967
+ this.reconnectTimer = setTimeout(() => {
1968
+ this.reconnectTimer = null;
1969
+ if (this.intentionalExit || this.exited || !this.lastOpts)
1970
+ return;
1971
+ const probe = this.verifyBackingIdentityWithClients('tail recovery');
1972
+ if (probe.state === 'missing' || probe.state === 'replaced')
1973
+ return;
1974
+ if (probe.state === 'unknown') {
1975
+ this.scheduleTailRecovery(code, signal);
1976
+ return;
1977
+ }
1978
+ try {
1979
+ logger.warn(`[zmx:${this.sessionName}] tail observer exited while session lives; reconnecting`);
1980
+ this.startTail();
1981
+ if (!this.waitForTailClient()) {
1982
+ throw new Error('replacement tail did not become a connected client');
1983
+ }
1984
+ this.requestHistoryCapture(0, true, true);
1985
+ }
1986
+ catch (err) {
1987
+ if (this.intentionalExit || this.exited)
1988
+ return;
1989
+ this.stopTailForRecovery();
1990
+ logger.warn(`[zmx:${this.sessionName}] tail restart failed: ` +
1991
+ `${err instanceof Error ? err.message : String(err)}`);
1992
+ this.scheduleTailRecovery(code, signal);
1993
+ }
1994
+ }, delay);
1995
+ this.reconnectTimer.unref?.();
1996
+ }
1997
+ stopTailAfterLaunchFailure() {
1998
+ this.tailEpoch += 1;
1999
+ this.clearStableTailTimer();
2000
+ this.stopHistoryPolling();
2001
+ const tail = this.tailProcess;
2002
+ this.tailProcess = null;
2003
+ this.state = 'idle';
2004
+ try {
2005
+ tail?.kill('SIGTERM');
2006
+ }
2007
+ catch { /* already gone */ }
2008
+ }
2009
+ stopTailForRecovery() {
2010
+ this.tailEpoch += 1;
2011
+ this.clearStableTailTimer();
2012
+ const tail = this.tailProcess;
2013
+ this.tailProcess = null;
2014
+ this.state = 'recovering';
2015
+ try {
2016
+ tail?.kill('SIGTERM');
2017
+ }
2018
+ catch { /* already gone */ }
2019
+ }
2020
+ clearReconnectTimer() {
2021
+ if (!this.reconnectTimer)
2022
+ return;
2023
+ clearTimeout(this.reconnectTimer);
2024
+ this.reconnectTimer = null;
2025
+ }
2026
+ clearStableTailTimer() {
2027
+ if (!this.stableTailTimer)
2028
+ return;
2029
+ clearTimeout(this.stableTailTimer);
2030
+ this.stableTailTimer = null;
2031
+ }
2032
+ emitData(data) {
2033
+ if (this.dataCbs.length === 0) {
2034
+ const next = this.earlyBuffer + data;
2035
+ if (next.length > EARLY_BUFFER_MAX && this.earlyBuffer.length <= EARLY_BUFFER_MAX) {
2036
+ logger.warn(`[zmx:${this.sessionName}] early output exceeded 1 MiB; keeping newest text`);
2037
+ }
2038
+ this.earlyBuffer = next.slice(-EARLY_BUFFER_MAX);
2039
+ return;
2040
+ }
2041
+ for (const cb of this.dataCbs) {
2042
+ try {
2043
+ cb(data);
2044
+ }
2045
+ catch { /* listener failure must not kill transport */ }
2046
+ }
2047
+ }
2048
+ fireExit(code, signal) {
2049
+ if (this.exited)
2050
+ return;
2051
+ // Once the backing daemon is gone, history is no longer queryable. Never
2052
+ // fall back to tail payload bytes here: upstream may already have deleted
2053
+ // or corrupted their UTF-8 content.
2054
+ this.exited = true;
2055
+ this.state = 'exited';
2056
+ this.tailEpoch += 1;
2057
+ this.clearReconnectTimer();
2058
+ this.clearStableTailTimer();
2059
+ this.stopHistoryPolling();
2060
+ const tail = this.tailProcess;
2061
+ this.tailProcess = null;
2062
+ try {
2063
+ tail?.kill('SIGTERM');
2064
+ }
2065
+ catch { /* already gone */ }
2066
+ this.pendingExit = { code, signal };
2067
+ if (this.exitCbs.length === 0)
2068
+ return;
2069
+ for (const cb of this.exitCbs) {
2070
+ try {
2071
+ cb(code, signal);
2072
+ }
2073
+ catch { /* listener failure must not block teardown */ }
2074
+ }
2075
+ }
2076
+ }
2077
+ export function buildFreshAttachArgs(sessionName, bootstrapPath) {
2078
+ // An existing session ignores this command, which makes attach safer than
2079
+ // `zmx run` (run is an upsert that can inject into a foreign race winner).
2080
+ return ['attach', sessionName, '/bin/sh', bootstrapPath];
2081
+ }
2082
+ function shellSingleQuote(value) {
2083
+ return `'${value.replace(/'/g, `'\\''`)}'`;
2084
+ }
2085
+ /** Render the private bootstrap and payload used by a fresh session. */
2086
+ export function buildZmxLaunchFiles(bin, args, opts, payloadPath, readyPath, readyNonce, releasePath, releaseToken) {
2087
+ const shellSpec = resolveUserShell(process.env, opts.launchShell);
2088
+ const envAssignments = buildBotmuxEnvAssignments(opts.env, opts.injectEnv)
2089
+ .filter(assignment => !/^ZMX_(?:SESSION|SESSION_PREFIX)=/.test(assignment));
2090
+ const debugKeepShell = process.env.BOTMUX_DEBUG_KEEP_SHELL === '1';
2091
+ const wrapperBinDir = resolveBotmuxWrapperBinDir(opts.env ?? process.env);
2092
+ const wrapped = debugKeepShell
2093
+ ? buildDebugKeepShellScript(shellSpec.shell, wrapperBinDir)
2094
+ : shellWrapperScript(wrapperBinDir);
2095
+ const payloadArgv = [opts.cwd, ...envAssignments, bin, ...args];
2096
+ const payload = `set -- ${payloadArgv.map(shellSingleQuote).join(' ')}\n`;
2097
+ const userScript = [
2098
+ 'payload=$1',
2099
+ 'if [ ! -r "$payload" ]; then printf "[botmux] ZMX launch payload unavailable\\n" >&2; exit 126; fi',
2100
+ '. "$payload" || exit 126',
2101
+ 'rm -f -- "$payload"',
2102
+ 'unset payload ZMX_SESSION ZMX_SESSION_PREFIX',
2103
+ wrapped,
2104
+ ].join('\n');
2105
+ const shellCommand = [
2106
+ shellSingleQuote(shellSpec.shell),
2107
+ ...shellSpec.flags.map(shellSingleQuote),
2108
+ '-c', shellSingleQuote(userScript),
2109
+ '_', shellSingleQuote(payloadPath),
2110
+ ].join(' ');
2111
+ const launchDir = payloadPath.slice(0, payloadPath.lastIndexOf('/'));
2112
+ const cliPidPath = join(launchDir, 'cli-pid');
2113
+ const gateScript = [
2114
+ 'payload_path=$1',
2115
+ 'ready_path=$2',
2116
+ 'release_path=$3',
2117
+ 'cli_pid_path=$4',
2118
+ 'ready_nonce=$5',
2119
+ 'release_token=$6',
2120
+ 'printf \'%s\\n\' "$$" > "$cli_pid_path" || exit 126',
2121
+ 'printf \'%s\\n\' "$ready_nonce" > "$ready_path" || exit 126',
2122
+ 'attempt=0',
2123
+ `while [ ! -r "$release_path" ] && [ "$attempt" -lt ${Math.ceil(FRESH_RELEASE_TIMEOUT_MS / 100)} ]; do`,
2124
+ ' sleep 0.1',
2125
+ ' attempt=$((attempt + 1))',
2126
+ 'done',
2127
+ '[ -r "$release_path" ] || exit 75',
2128
+ 'release_value=',
2129
+ 'IFS= read -r release_value < "$release_path" || [ -n "$release_value" ]',
2130
+ '[ "$release_value" = "$release_token" ] || exit 75',
2131
+ 'rm -f -- "$ready_path" "$release_path"',
2132
+ 'unset ready_path release_path release_value attempt cli_pid_path ready_nonce release_token',
2133
+ 'trap - 1 2 15',
2134
+ // Keep the ZMX forkpty slave inherited on fd 0. Reopening `/dev/tty`
2135
+ // produces a descriptor that Darwin kqueue rejects with EINVAL, so
2136
+ // crossterm/mio event readers fail to initialize even though isatty(0)
2137
+ // still reports true. The release-file read above redirects only that one
2138
+ // command and automatically restores the inherited PTY afterwards.
2139
+ `exec ${shellCommand}`,
2140
+ ].join('\n');
2141
+ const gateCommand = [
2142
+ '/bin/sh',
2143
+ '-c', shellSingleQuote(gateScript),
2144
+ shellSingleQuote(`${ZMX_GATE_PROCESS_MARKER_PREFIX}${readyNonce}`),
2145
+ '"$payload_path"',
2146
+ '"$ready_path"',
2147
+ '"$release_path"',
2148
+ '"$cli_pid_path"',
2149
+ '"$ready_nonce"',
2150
+ '"$release_token"',
2151
+ ].join(' ');
2152
+ const bootstrap = [
2153
+ '#!/bin/sh',
2154
+ 'umask 077',
2155
+ 'self=$0',
2156
+ 'rm -f -- "$self"',
2157
+ 'unset self ZMX_SESSION ZMX_SESSION_PREFIX',
2158
+ `payload_path=${shellSingleQuote(payloadPath)}`,
2159
+ `ready_path=${shellSingleQuote(readyPath)}`,
2160
+ `release_path=${shellSingleQuote(releasePath)}`,
2161
+ `cli_pid_path=${shellSingleQuote(cliPidPath)}`,
2162
+ `ready_nonce=${shellSingleQuote(readyNonce)}`,
2163
+ `release_token=${shellSingleQuote(releaseToken)}`,
2164
+ `launch_dir=${shellSingleQuote(launchDir)}`,
2165
+ 'cleanup_launch() {',
2166
+ ' rm -f -- "$ready_path" "$release_path" "$cli_pid_path" "$payload_path"',
2167
+ ' rmdir -- "$launch_dir" 2>/dev/null || true',
2168
+ '}',
2169
+ // Do not exec away the PTY-root bootstrap. ZMX destroys history together
2170
+ // with that root process, so retaining it for a short bounded grace after
2171
+ // the real CLI exits gives the authoritative history poller time to read
2172
+ // final output (including pure Unicode that tail cannot signal reliably).
2173
+ // A foreground gate child keeps the normal SIGINT disposition (POSIX async
2174
+ // jobs may inherit SIGINT ignored), while retaining one stable PID through
2175
+ // gate -> user shell -> env -> CLI exec. The parent validates and labels
2176
+ // that PID before atomically publishing release.
2177
+ 'is_direct_child() {',
2178
+ ' for ps_bin in /usr/bin/ps /bin/ps; do',
2179
+ ' [ -x "$ps_bin" ] || continue',
2180
+ ' child_parent=$("$ps_bin" -o ppid= -p "$child_pid" 2>/dev/null) || continue',
2181
+ ' [ "$child_parent" -eq "$$" ] 2>/dev/null && return 0',
2182
+ ' done',
2183
+ ' return 1',
2184
+ '}',
2185
+ 'forward_stop() {',
2186
+ ' trap - 1 15',
2187
+ ' child_pid=',
2188
+ ' IFS= read -r child_pid < "$cli_pid_path" 2>/dev/null || true',
2189
+ ' if [ -n "$child_pid" ] && is_direct_child; then',
2190
+ ' kill -HUP "$child_pid" 2>/dev/null || true',
2191
+ ' kill -HUP -- "-$child_pid" 2>/dev/null || true',
2192
+ ' sleep 0.15',
2193
+ ' if is_direct_child; then',
2194
+ ' kill -KILL "$child_pid" 2>/dev/null || true',
2195
+ ' kill -KILL -- "-$child_pid" 2>/dev/null || true',
2196
+ ' fi',
2197
+ ' fi',
2198
+ ' cleanup_launch',
2199
+ ' exit 75',
2200
+ '}',
2201
+ "trap 'forward_stop' 1 15",
2202
+ "trap ':' 2",
2203
+ gateCommand,
2204
+ 'cli_status=$?',
2205
+ // Do not retain a dead PID during the grace period: an external HUP must
2206
+ // never target an unrelated process that reused the numeric pid.
2207
+ 'rm -f -- "$cli_pid_path"',
2208
+ `while ! sleep ${ZMX_EXIT_HISTORY_GRACE_SECONDS}; do :; done`,
2209
+ 'cleanup_launch',
2210
+ 'trap - 1 2 15',
2211
+ 'exit "$cli_status"',
2212
+ '',
2213
+ ].join('\n');
2214
+ return { bootstrap, payload };
2215
+ }
2216
+ function createZmxLaunchPayload(bin, args, opts) {
2217
+ const dir = mkdtempSync(join(tmpdir(), 'botmux-zmx-launch-'));
2218
+ chmodSync(dir, 0o700);
2219
+ const bootstrapPath = join(dir, 'bootstrap.sh');
2220
+ const payloadPath = join(dir, 'payload.sh');
2221
+ const readyPath = join(dir, 'ready');
2222
+ const releasePath = join(dir, 'release');
2223
+ const releaseTempPath = join(dir, 'release.tmp');
2224
+ const cliPidPath = join(dir, 'cli-pid');
2225
+ const readyNonce = randomBytes(16).toString('hex');
2226
+ const releaseToken = randomBytes(16).toString('hex');
2227
+ const cleanup = () => {
2228
+ for (const path of [readyPath, releasePath, releaseTempPath, cliPidPath, payloadPath, bootstrapPath]) {
2229
+ try {
2230
+ rmSync(path, { force: true });
2231
+ }
2232
+ catch { /* already consumed */ }
2233
+ }
2234
+ try {
2235
+ rmdirSync(dir);
2236
+ }
2237
+ catch { /* live bootstrap still owns the directory */ }
2238
+ };
2239
+ try {
2240
+ const files = buildZmxLaunchFiles(bin, args, opts, payloadPath, readyPath, readyNonce, releasePath, releaseToken);
2241
+ writeFileSync(payloadPath, files.payload, { mode: 0o600, flag: 'wx' });
2242
+ writeFileSync(bootstrapPath, files.bootstrap, { mode: 0o600, flag: 'wx' });
2243
+ return {
2244
+ dir,
2245
+ bootstrapPath,
2246
+ readyPath,
2247
+ readyNonce,
2248
+ releasePath,
2249
+ releaseTempPath,
2250
+ cliPidPath,
2251
+ releaseToken,
2252
+ cleanup,
2253
+ };
2254
+ }
2255
+ catch (err) {
2256
+ cleanup();
2257
+ throw err;
2258
+ }
2259
+ }
2260
+ /** Strip every payload-delivered key from ZMX control subprocesses. */
2261
+ export function zmxControlEnv(opts) {
2262
+ const env = zmxEnv(opts.env);
2263
+ for (const assignment of buildBotmuxEnvAssignments(opts.env, opts.injectEnv)) {
2264
+ const equals = assignment.indexOf('=');
2265
+ if (equals > 0)
2266
+ delete env[assignment.slice(0, equals)];
2267
+ }
2268
+ if (opts.injectEnv) {
2269
+ for (const key of Object.keys(opts.injectEnv))
2270
+ delete env[key];
2271
+ }
2272
+ return env;
2273
+ }
2274
+ export function parseZmxList(output) {
2275
+ const sessions = [];
2276
+ const unhealthySessions = [];
2277
+ const malformedLines = [];
2278
+ let sawRecord = false;
2279
+ for (const line of output.split('\n')) {
2280
+ if (!line.trim())
2281
+ continue;
2282
+ const looksLikeRecord = /^\s*name=[^\t]*\t(?:pid=\d+(?:\t|$)|err=)/.test(line);
2283
+ if (!looksLikeRecord) {
2284
+ // Full-list cmd= is verbatim and may contain literal newlines. Once a
2285
+ // record starts, continuation text is opaque even when it says name=.
2286
+ if (!sawRecord)
2287
+ malformedLines.push(line);
2288
+ continue;
2289
+ }
2290
+ sawRecord = true;
2291
+ const row = parseZmxListRow(line);
2292
+ if (!row)
2293
+ malformedLines.push(line);
2294
+ else if (row.state === 'unhealthy')
2295
+ unhealthySessions.push(row.name);
2296
+ else
2297
+ sessions.push(row.name);
2298
+ }
2299
+ return { sessions, unhealthySessions, malformedLines };
2300
+ }
2301
+ export function parseZmxShortList(output) {
2302
+ const sessions = [];
2303
+ const malformedLines = [];
2304
+ const seen = new Set();
2305
+ for (const raw of output.split('\n')) {
2306
+ const name = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
2307
+ if (!name)
2308
+ continue;
2309
+ if (/[\t\x00-\x1f\x7f]/.test(name) || seen.has(name)) {
2310
+ malformedLines.push(raw);
2311
+ continue;
2312
+ }
2313
+ seen.add(name);
2314
+ sessions.push(name);
2315
+ }
2316
+ return { sessions, malformedLines };
2317
+ }
2318
+ function parseZmxListRow(line) {
2319
+ const fields = line.replace(/^\s*/, '').split('\t');
2320
+ const nameField = fields[0];
2321
+ const name = nameField?.startsWith('name=') ? nameField.slice('name='.length) : '';
2322
+ const status = fields[1];
2323
+ if (!name || /[\x00-\x1f\x7f]/.test(name) || !status)
2324
+ return null;
2325
+ const pid = status.match(/^pid=(\d+)$/)?.[1];
2326
+ if (pid) {
2327
+ const clients = fields.map(field => field.match(/^clients=(\d+)$/)?.[1]).find(Boolean);
2328
+ const command = fields.find(field => field.startsWith('cmd='))?.slice('cmd='.length);
2329
+ return {
2330
+ name,
2331
+ state: 'healthy',
2332
+ pid: Number(pid),
2333
+ clients: clients === undefined ? undefined : Number(clients),
2334
+ command,
2335
+ };
2336
+ }
2337
+ if (/^err=/.test(status))
2338
+ return { name, state: 'unhealthy' };
2339
+ return null;
2340
+ }
2341
+ function sessionDetails(sessionName, env = zmxEnv()) {
2342
+ const probe = ZmxBackend.probeSessions(env);
2343
+ return probe.ok ? sessionDetailsFromSnapshot(probe, sessionName) : null;
2344
+ }
2345
+ function sessionDetailsFromSnapshot(probe, sessionName) {
2346
+ if (!probe.sessions.includes(sessionName))
2347
+ return null;
2348
+ const rows = probe.raw
2349
+ .split('\n')
2350
+ .map(parseZmxListRow)
2351
+ .filter((row) => row?.name === sessionName && row.state === 'healthy' && !!row.pid);
2352
+ if (rows.length !== 1)
2353
+ return null;
2354
+ const row = rows[0];
2355
+ return {
2356
+ name: row.name,
2357
+ pid: row.pid,
2358
+ clients: row.clients ?? null,
2359
+ command: row.command ?? null,
2360
+ };
2361
+ }
2362
+ export function findSessionPid(sessionName) {
2363
+ return sessionDetails(sessionName)?.pid ?? null;
2364
+ }
2365
+ export function tmuxKeyToBytes(key) {
2366
+ const named = {
2367
+ Enter: '\r',
2368
+ Tab: '\t',
2369
+ Escape: '\x1b',
2370
+ Esc: '\x1b',
2371
+ Space: ' ',
2372
+ BSpace: '\x7f',
2373
+ Backspace: '\x7f',
2374
+ Up: '\x1b[A',
2375
+ Down: '\x1b[B',
2376
+ Right: '\x1b[C',
2377
+ Left: '\x1b[D',
2378
+ Home: '\x1b[H',
2379
+ End: '\x1b[F',
2380
+ PageUp: '\x1b[5~',
2381
+ PPage: '\x1b[5~',
2382
+ PageDown: '\x1b[6~',
2383
+ NPage: '\x1b[6~',
2384
+ 'M-Enter': '\x1b\r',
2385
+ };
2386
+ if (key in named)
2387
+ return named[key];
2388
+ const ctrl = key.match(/^C-([A-Za-z])$/);
2389
+ if (ctrl)
2390
+ return String.fromCharCode(ctrl[1].toLowerCase().charCodeAt(0) - 96);
2391
+ const meta = key.match(/^M-(.)$/);
2392
+ if (meta)
2393
+ return `\x1b${meta[1]}`;
2394
+ return key;
2395
+ }
2396
+ //# sourceMappingURL=zmx-backend.js.map