botmux 3.10.0 → 3.11.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 (539) hide show
  1. package/dist/.runtime-build-id +1 -1
  2. package/dist/adapters/backend/herdr-backend.d.ts +4 -3
  3. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  4. package/dist/adapters/backend/herdr-backend.js +11 -8
  5. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  6. package/dist/adapters/backend/pty-backend.d.ts +1 -1
  7. package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
  8. package/dist/adapters/backend/pty-backend.js +4 -1
  9. package/dist/adapters/backend/pty-backend.js.map +1 -1
  10. package/dist/adapters/backend/riff-backend.d.ts +29 -5
  11. package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
  12. package/dist/adapters/backend/riff-backend.js +302 -31
  13. package/dist/adapters/backend/riff-backend.js.map +1 -1
  14. package/dist/adapters/backend/sandbox.d.ts +6 -0
  15. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  16. package/dist/adapters/backend/sandbox.js +7 -0
  17. package/dist/adapters/backend/sandbox.js.map +1 -1
  18. package/dist/adapters/backend/tmux-backend.d.ts +1 -1
  19. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  20. package/dist/adapters/backend/tmux-backend.js +4 -1
  21. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  22. package/dist/adapters/backend/tmux-pipe-backend.d.ts +1 -1
  23. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  24. package/dist/adapters/backend/tmux-pipe-backend.js +1 -1
  25. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  26. package/dist/adapters/backend/types.d.ts +37 -3
  27. package/dist/adapters/backend/types.d.ts.map +1 -1
  28. package/dist/adapters/backend/types.js.map +1 -1
  29. package/dist/adapters/backend/zellij-backend.d.ts +3 -3
  30. package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
  31. package/dist/adapters/backend/zellij-backend.js +9 -3
  32. package/dist/adapters/backend/zellij-backend.js.map +1 -1
  33. package/dist/adapters/backend/zellij-observe-backend.d.ts +3 -3
  34. package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -1
  35. package/dist/adapters/backend/zellij-observe-backend.js +12 -7
  36. package/dist/adapters/backend/zellij-observe-backend.js.map +1 -1
  37. package/dist/adapters/cli/codex-app.d.ts.map +1 -1
  38. package/dist/adapters/cli/codex-app.js +11 -2
  39. package/dist/adapters/cli/codex-app.js.map +1 -1
  40. package/dist/adapters/cli/read-isolation.d.ts +35 -2
  41. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  42. package/dist/adapters/cli/read-isolation.js +88 -7
  43. package/dist/adapters/cli/read-isolation.js.map +1 -1
  44. package/dist/adapters/cli/runner-input.d.ts +15 -14
  45. package/dist/adapters/cli/runner-input.d.ts.map +1 -1
  46. package/dist/adapters/cli/runner-input.js +64 -26
  47. package/dist/adapters/cli/runner-input.js.map +1 -1
  48. package/dist/adapters/cli/types.d.ts +20 -5
  49. package/dist/adapters/cli/types.d.ts.map +1 -1
  50. package/dist/bot-registry.d.ts +24 -0
  51. package/dist/bot-registry.d.ts.map +1 -1
  52. package/dist/bot-registry.js +110 -5
  53. package/dist/bot-registry.js.map +1 -1
  54. package/dist/cli/fleet-shutdown.d.ts +47 -0
  55. package/dist/cli/fleet-shutdown.d.ts.map +1 -0
  56. package/dist/cli/fleet-shutdown.js +387 -0
  57. package/dist/cli/fleet-shutdown.js.map +1 -0
  58. package/dist/cli/pm2-descriptor-guard.d.ts +19 -0
  59. package/dist/cli/pm2-descriptor-guard.d.ts.map +1 -0
  60. package/dist/cli/pm2-descriptor-guard.js +122 -0
  61. package/dist/cli/pm2-descriptor-guard.js.map +1 -0
  62. package/dist/cli/pm2-exact-start.d.ts +16 -0
  63. package/dist/cli/pm2-exact-start.d.ts.map +1 -0
  64. package/dist/cli/pm2-exact-start.js +63 -0
  65. package/dist/cli/pm2-exact-start.js.map +1 -0
  66. package/dist/cli/pm2-god-admission.d.ts +8 -0
  67. package/dist/cli/pm2-god-admission.d.ts.map +1 -0
  68. package/dist/cli/pm2-god-admission.js +25 -0
  69. package/dist/cli/pm2-god-admission.js.map +1 -0
  70. package/dist/cli/pm2-jlist.d.ts +13 -0
  71. package/dist/cli/pm2-jlist.d.ts.map +1 -0
  72. package/dist/cli/pm2-jlist.js +108 -0
  73. package/dist/cli/pm2-jlist.js.map +1 -0
  74. package/dist/cli/pm2-preflight.d.ts +10 -0
  75. package/dist/cli/pm2-preflight.d.ts.map +1 -0
  76. package/dist/cli/pm2-preflight.js +23 -0
  77. package/dist/cli/pm2-preflight.js.map +1 -0
  78. package/dist/cli/pm2-shutdown-capability.d.ts +27 -0
  79. package/dist/cli/pm2-shutdown-capability.d.ts.map +1 -0
  80. package/dist/cli/pm2-shutdown-capability.js +161 -0
  81. package/dist/cli/pm2-shutdown-capability.js.map +1 -0
  82. package/dist/cli/pm2-start-transaction.d.ts +55 -0
  83. package/dist/cli/pm2-start-transaction.d.ts.map +1 -0
  84. package/dist/cli/pm2-start-transaction.js +220 -0
  85. package/dist/cli/pm2-start-transaction.js.map +1 -0
  86. package/dist/cli/send-dispatch.d.ts +12 -0
  87. package/dist/cli/send-dispatch.d.ts.map +1 -1
  88. package/dist/cli/send-dispatch.js +31 -4
  89. package/dist/cli/send-dispatch.js.map +1 -1
  90. package/dist/cli/session-list-liveness.d.ts +1 -0
  91. package/dist/cli/session-list-liveness.d.ts.map +1 -1
  92. package/dist/cli/session-list-liveness.js +6 -0
  93. package/dist/cli/session-list-liveness.js.map +1 -1
  94. package/dist/cli/session-picker-layout.d.ts +26 -0
  95. package/dist/cli/session-picker-layout.d.ts.map +1 -0
  96. package/dist/cli/session-picker-layout.js +123 -0
  97. package/dist/cli/session-picker-layout.js.map +1 -0
  98. package/dist/cli/session-picker-viewport.d.ts +49 -0
  99. package/dist/cli/session-picker-viewport.d.ts.map +1 -0
  100. package/dist/cli/session-picker-viewport.js +46 -0
  101. package/dist/cli/session-picker-viewport.js.map +1 -0
  102. package/dist/cli/supervisor-shutdown-client.d.ts +31 -0
  103. package/dist/cli/supervisor-shutdown-client.d.ts.map +1 -0
  104. package/dist/cli/supervisor-shutdown-client.js +146 -0
  105. package/dist/cli/supervisor-shutdown-client.js.map +1 -0
  106. package/dist/cli/terminal-width.d.ts +48 -0
  107. package/dist/cli/terminal-width.d.ts.map +1 -0
  108. package/dist/cli/terminal-width.js +119 -0
  109. package/dist/cli/terminal-width.js.map +1 -0
  110. package/dist/cli/vc-agent.d.ts.map +1 -1
  111. package/dist/cli/vc-agent.js +1 -1
  112. package/dist/cli/vc-agent.js.map +1 -1
  113. package/dist/cli.d.ts.map +1 -1
  114. package/dist/cli.js +2269 -716
  115. package/dist/cli.js.map +1 -1
  116. package/dist/codex-app-runner.js +1630 -125
  117. package/dist/codex-app-runner.js.map +1 -1
  118. package/dist/core/ask-api.d.ts +8 -1
  119. package/dist/core/ask-api.d.ts.map +1 -1
  120. package/dist/core/ask-api.js +18 -0
  121. package/dist/core/ask-api.js.map +1 -1
  122. package/dist/core/ask-broker.d.ts +20 -0
  123. package/dist/core/ask-broker.d.ts.map +1 -1
  124. package/dist/core/ask-broker.js +467 -47
  125. package/dist/core/ask-broker.js.map +1 -1
  126. package/dist/core/ask-persist-store.d.ts +126 -0
  127. package/dist/core/ask-persist-store.d.ts.map +1 -0
  128. package/dist/core/ask-persist-store.js +188 -0
  129. package/dist/core/ask-persist-store.js.map +1 -0
  130. package/dist/core/ask-types.d.ts +80 -0
  131. package/dist/core/ask-types.d.ts.map +1 -1
  132. package/dist/core/ask-types.js +62 -0
  133. package/dist/core/ask-types.js.map +1 -1
  134. package/dist/core/cli-session-id-publisher.d.ts +20 -0
  135. package/dist/core/cli-session-id-publisher.d.ts.map +1 -0
  136. package/dist/core/cli-session-id-publisher.js +21 -0
  137. package/dist/core/cli-session-id-publisher.js.map +1 -0
  138. package/dist/core/command-handler.d.ts.map +1 -1
  139. package/dist/core/command-handler.js +427 -255
  140. package/dist/core/command-handler.js.map +1 -1
  141. package/dist/core/cost-calculator.d.ts +13 -0
  142. package/dist/core/cost-calculator.d.ts.map +1 -1
  143. package/dist/core/cost-calculator.js +385 -26
  144. package/dist/core/cost-calculator.js.map +1 -1
  145. package/dist/core/dashboard-ipc-server.d.ts +9 -0
  146. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  147. package/dist/core/dashboard-ipc-server.js +842 -288
  148. package/dist/core/dashboard-ipc-server.js.map +1 -1
  149. package/dist/core/explicit-session-backing-cleanup.d.ts +49 -0
  150. package/dist/core/explicit-session-backing-cleanup.d.ts.map +1 -0
  151. package/dist/core/explicit-session-backing-cleanup.js +76 -0
  152. package/dist/core/explicit-session-backing-cleanup.js.map +1 -0
  153. package/dist/core/idle-worker-sweeper.d.ts +20 -0
  154. package/dist/core/idle-worker-sweeper.d.ts.map +1 -1
  155. package/dist/core/idle-worker-sweeper.js +18 -0
  156. package/dist/core/idle-worker-sweeper.js.map +1 -1
  157. package/dist/core/inflight-input-tracker.d.ts +10 -0
  158. package/dist/core/inflight-input-tracker.d.ts.map +1 -1
  159. package/dist/core/inflight-input-tracker.js +7 -0
  160. package/dist/core/inflight-input-tracker.js.map +1 -1
  161. package/dist/core/managed-origin-attestation.d.ts +51 -0
  162. package/dist/core/managed-origin-attestation.d.ts.map +1 -0
  163. package/dist/core/managed-origin-attestation.js +208 -0
  164. package/dist/core/managed-origin-attestation.js.map +1 -0
  165. package/dist/core/managed-origin-capability.d.ts +62 -3
  166. package/dist/core/managed-origin-capability.d.ts.map +1 -1
  167. package/dist/core/managed-origin-capability.js +419 -9
  168. package/dist/core/managed-origin-capability.js.map +1 -1
  169. package/dist/core/pending-repo-journal.d.ts +6 -0
  170. package/dist/core/pending-repo-journal.d.ts.map +1 -0
  171. package/dist/core/pending-repo-journal.js +85 -0
  172. package/dist/core/pending-repo-journal.js.map +1 -0
  173. package/dist/core/persistent-backend.d.ts +6 -1
  174. package/dist/core/persistent-backend.d.ts.map +1 -1
  175. package/dist/core/persistent-backend.js +12 -6
  176. package/dist/core/persistent-backend.js.map +1 -1
  177. package/dist/core/process-start-identity.d.ts +3 -0
  178. package/dist/core/process-start-identity.d.ts.map +1 -0
  179. package/dist/core/process-start-identity.js +62 -0
  180. package/dist/core/process-start-identity.js.map +1 -0
  181. package/dist/core/raw-command-writer.d.ts +34 -0
  182. package/dist/core/raw-command-writer.d.ts.map +1 -0
  183. package/dist/core/raw-command-writer.js +48 -0
  184. package/dist/core/raw-command-writer.js.map +1 -0
  185. package/dist/core/reply-target.d.ts +85 -22
  186. package/dist/core/reply-target.d.ts.map +1 -1
  187. package/dist/core/reply-target.js +267 -32
  188. package/dist/core/reply-target.js.map +1 -1
  189. package/dist/core/resource-monitor/runtime.d.ts.map +1 -1
  190. package/dist/core/resource-monitor/runtime.js +4 -1
  191. package/dist/core/resource-monitor/runtime.js.map +1 -1
  192. package/dist/core/restart-report.d.ts +6 -1
  193. package/dist/core/restart-report.d.ts.map +1 -1
  194. package/dist/core/restart-report.js +22 -4
  195. package/dist/core/restart-report.js.map +1 -1
  196. package/dist/core/riff-shutdown-detach.d.ts +164 -0
  197. package/dist/core/riff-shutdown-detach.d.ts.map +1 -0
  198. package/dist/core/riff-shutdown-detach.js +787 -0
  199. package/dist/core/riff-shutdown-detach.js.map +1 -0
  200. package/dist/core/riff-worker-shutdown-readiness.d.ts +17 -0
  201. package/dist/core/riff-worker-shutdown-readiness.d.ts.map +1 -0
  202. package/dist/core/riff-worker-shutdown-readiness.js +21 -0
  203. package/dist/core/riff-worker-shutdown-readiness.js.map +1 -0
  204. package/dist/core/session-manager.d.ts +6 -6
  205. package/dist/core/session-manager.d.ts.map +1 -1
  206. package/dist/core/session-manager.js +1227 -594
  207. package/dist/core/session-manager.js.map +1 -1
  208. package/dist/core/session-marker.d.ts +1 -1
  209. package/dist/core/session-marker.d.ts.map +1 -1
  210. package/dist/core/session-marker.js +2 -2
  211. package/dist/core/session-marker.js.map +1 -1
  212. package/dist/core/session-mutation-guard.d.ts +17 -0
  213. package/dist/core/session-mutation-guard.d.ts.map +1 -0
  214. package/dist/core/session-mutation-guard.js +33 -0
  215. package/dist/core/session-mutation-guard.js.map +1 -0
  216. package/dist/core/shutdown-budgets.d.ts +27 -0
  217. package/dist/core/shutdown-budgets.d.ts.map +1 -0
  218. package/dist/core/shutdown-budgets.js +47 -0
  219. package/dist/core/shutdown-budgets.js.map +1 -0
  220. package/dist/core/supervisor-shutdown-ipc.d.ts +10 -0
  221. package/dist/core/supervisor-shutdown-ipc.d.ts.map +1 -0
  222. package/dist/core/supervisor-shutdown-ipc.js +10 -0
  223. package/dist/core/supervisor-shutdown-ipc.js.map +1 -0
  224. package/dist/core/supervisor-shutdown-protocol.d.ts +16 -0
  225. package/dist/core/supervisor-shutdown-protocol.d.ts.map +1 -0
  226. package/dist/core/supervisor-shutdown-protocol.js +16 -0
  227. package/dist/core/supervisor-shutdown-protocol.js.map +1 -0
  228. package/dist/core/trigger-final-suppression.d.ts +4 -0
  229. package/dist/core/trigger-final-suppression.d.ts.map +1 -1
  230. package/dist/core/trigger-final-suppression.js +6 -11
  231. package/dist/core/trigger-final-suppression.js.map +1 -1
  232. package/dist/core/trigger-session.d.ts +70 -0
  233. package/dist/core/trigger-session.d.ts.map +1 -1
  234. package/dist/core/trigger-session.js +923 -145
  235. package/dist/core/trigger-session.js.map +1 -1
  236. package/dist/core/types.d.ts +94 -1
  237. package/dist/core/types.d.ts.map +1 -1
  238. package/dist/core/types.js +9 -0
  239. package/dist/core/types.js.map +1 -1
  240. package/dist/core/worker-pool.d.ts +194 -13
  241. package/dist/core/worker-pool.d.ts.map +1 -1
  242. package/dist/core/worker-pool.js +2597 -547
  243. package/dist/core/worker-pool.js.map +1 -1
  244. package/dist/daemon.d.ts +76 -2
  245. package/dist/daemon.d.ts.map +1 -1
  246. package/dist/daemon.js +2255 -773
  247. package/dist/daemon.js.map +1 -1
  248. package/dist/dashboard/bot-payload.d.ts +20 -0
  249. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  250. package/dist/dashboard/bot-payload.js +24 -0
  251. package/dist/dashboard/bot-payload.js.map +1 -1
  252. package/dist/dashboard/feed-groups.d.ts +17 -0
  253. package/dist/dashboard/feed-groups.d.ts.map +1 -0
  254. package/dist/dashboard/feed-groups.js +75 -0
  255. package/dist/dashboard/feed-groups.js.map +1 -0
  256. package/dist/dashboard/session-card-model.d.ts +1 -1
  257. package/dist/dashboard/session-card-model.d.ts.map +1 -1
  258. package/dist/dashboard/session-card-model.js +8 -5
  259. package/dist/dashboard/session-card-model.js.map +1 -1
  260. package/dist/dashboard/web/bot-defaults-page.d.ts +4 -0
  261. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  262. package/dist/dashboard/web/bot-defaults-page.js +139 -46
  263. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  264. package/dist/dashboard/web/bot-defaults.d.ts +4 -0
  265. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  266. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  267. package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
  268. package/dist/dashboard/web/connectors-page.js +40 -3
  269. package/dist/dashboard/web/connectors-page.js.map +1 -1
  270. package/dist/dashboard/web/groups-page.d.ts.map +1 -1
  271. package/dist/dashboard/web/groups-page.js +95 -2
  272. package/dist/dashboard/web/groups-page.js.map +1 -1
  273. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  274. package/dist/dashboard/web/i18n.js +86 -40
  275. package/dist/dashboard/web/i18n.js.map +1 -1
  276. package/dist/dashboard/web/kanban-model.js +1 -1
  277. package/dist/dashboard/web/kanban-model.js.map +1 -1
  278. package/dist/dashboard/web/overview-page.d.ts.map +1 -1
  279. package/dist/dashboard/web/overview-page.js +3 -2
  280. package/dist/dashboard/web/overview-page.js.map +1 -1
  281. package/dist/dashboard/web/overview.d.ts +3 -0
  282. package/dist/dashboard/web/overview.d.ts.map +1 -1
  283. package/dist/dashboard/web/overview.js +2 -0
  284. package/dist/dashboard/web/overview.js.map +1 -1
  285. package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
  286. package/dist/dashboard/web/sessions-kanban.js +2 -0
  287. package/dist/dashboard/web/sessions-kanban.js.map +1 -1
  288. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  289. package/dist/dashboard/web/sessions-page.js +103 -2
  290. package/dist/dashboard/web/sessions-page.js.map +1 -1
  291. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  292. package/dist/dashboard/web/sessions.js +3 -2
  293. package/dist/dashboard/web/sessions.js.map +1 -1
  294. package/dist/dashboard/web/ui.d.ts +8 -0
  295. package/dist/dashboard/web/ui.d.ts.map +1 -1
  296. package/dist/dashboard/web/ui.js +15 -0
  297. package/dist/dashboard/web/ui.js.map +1 -1
  298. package/dist/dashboard-web/app.js +1 -1
  299. package/dist/dashboard-web/chunks/bot-defaults-page-GED4FEMS.js +10 -0
  300. package/dist/dashboard-web/chunks/chunk-6J5LS5AU.js +51 -0
  301. package/dist/dashboard-web/chunks/{chunk-ZU3UUKAR.js → chunk-HK7NIBJG.js} +1 -1
  302. package/dist/dashboard-web/chunks/{chunk-3UE7S77S.js → chunk-KRHWZEXU.js} +1 -1
  303. package/dist/dashboard-web/chunks/chunk-VBXJOJFH.js +1 -0
  304. package/dist/dashboard-web/chunks/connectors-page-EELM66G3.js +1 -0
  305. package/dist/dashboard-web/chunks/groups-page-WKUV6E42.js +8 -0
  306. package/dist/dashboard-web/chunks/{insights-page-T2ZX2RWL.js → insights-page-54UBPPOC.js} +1 -1
  307. package/dist/dashboard-web/chunks/{monitor-room-YNVBAGR6.js → monitor-room-PORHVKRH.js} +1 -1
  308. package/dist/dashboard-web/chunks/{monitoring-page-LHM55YWN.js → monitoring-page-XGA7AXNL.js} +1 -1
  309. package/dist/dashboard-web/chunks/{office-page-ATXVP7PW.js → office-page-AO3AFG45.js} +1 -1
  310. package/dist/dashboard-web/chunks/overview-page-F5BUTYR6.js +1 -0
  311. package/dist/dashboard-web/chunks/{roles-page-56VWM6CD.js → roles-page-M5GIASJU.js} +1 -1
  312. package/dist/dashboard-web/chunks/{schedules-page-S5K6U4EO.js → schedules-page-O232WQRR.js} +1 -1
  313. package/dist/dashboard-web/chunks/sessions-page-YFRPSTIL.js +6 -0
  314. package/dist/dashboard-web/chunks/{settings-page-QUZDQARR.js → settings-page-OJAZLZQT.js} +1 -1
  315. package/dist/dashboard-web/chunks/{skills-page-IFY7AEND.js → skills-page-EFZQPWV4.js} +1 -1
  316. package/dist/dashboard-web/chunks/{team-federation-page-C7FJQPFZ.js → team-federation-page-ZJVAP7HW.js} +1 -1
  317. package/dist/dashboard-web/chunks/{v3-page-2LMF3WWR.js → v3-page-GYWTRGO2.js} +1 -1
  318. package/dist/dashboard-web/chunks/{whiteboards-page-3XC6QRMY.js → whiteboards-page-MSDAG6P7.js} +1 -1
  319. package/dist/dashboard-web/style.css +255 -0
  320. package/dist/dashboard.js +172 -7
  321. package/dist/dashboard.js.map +1 -1
  322. package/dist/desktop/renderer/app.js +21 -0
  323. package/dist/desktop/renderer/app.js.map +1 -1
  324. package/dist/i18n/en.d.ts.map +1 -1
  325. package/dist/i18n/en.js +35 -9
  326. package/dist/i18n/en.js.map +1 -1
  327. package/dist/i18n/zh.d.ts.map +1 -1
  328. package/dist/i18n/zh.js +35 -9
  329. package/dist/i18n/zh.js.map +1 -1
  330. package/dist/im/lark/ask-card.d.ts +23 -0
  331. package/dist/im/lark/ask-card.d.ts.map +1 -1
  332. package/dist/im/lark/ask-card.js +94 -4
  333. package/dist/im/lark/ask-card.js.map +1 -1
  334. package/dist/im/lark/card-builder.d.ts +6 -1
  335. package/dist/im/lark/card-builder.d.ts.map +1 -1
  336. package/dist/im/lark/card-builder.js +112 -15
  337. package/dist/im/lark/card-builder.js.map +1 -1
  338. package/dist/im/lark/card-handler.d.ts +1 -1
  339. package/dist/im/lark/card-handler.d.ts.map +1 -1
  340. package/dist/im/lark/card-handler.js +496 -310
  341. package/dist/im/lark/card-handler.js.map +1 -1
  342. package/dist/im/lark/client.d.ts +7 -0
  343. package/dist/im/lark/client.d.ts.map +1 -1
  344. package/dist/im/lark/client.js +39 -26
  345. package/dist/im/lark/client.js.map +1 -1
  346. package/dist/im/lark/doc-comment.d.ts +7 -4
  347. package/dist/im/lark/doc-comment.d.ts.map +1 -1
  348. package/dist/im/lark/doc-comment.js +19 -6
  349. package/dist/im/lark/doc-comment.js.map +1 -1
  350. package/dist/im/lark/event-dispatcher.d.ts +16 -0
  351. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  352. package/dist/im/lark/event-dispatcher.js +88 -23
  353. package/dist/im/lark/event-dispatcher.js.map +1 -1
  354. package/dist/im/lark/forward-followup-content.d.ts.map +1 -1
  355. package/dist/im/lark/forward-followup-content.js +9 -1
  356. package/dist/im/lark/forward-followup-content.js.map +1 -1
  357. package/dist/im/lark/grant-command.d.ts.map +1 -1
  358. package/dist/im/lark/grant-command.js +7 -4
  359. package/dist/im/lark/grant-command.js.map +1 -1
  360. package/dist/im/lark/md-card.d.ts +11 -0
  361. package/dist/im/lark/md-card.d.ts.map +1 -1
  362. package/dist/im/lark/md-card.js +46 -0
  363. package/dist/im/lark/md-card.js.map +1 -1
  364. package/dist/im/lark/message-parser.d.ts +23 -2
  365. package/dist/im/lark/message-parser.d.ts.map +1 -1
  366. package/dist/im/lark/message-parser.js +58 -3
  367. package/dist/im/lark/message-parser.js.map +1 -1
  368. package/dist/im/lark/overview-card.d.ts.map +1 -1
  369. package/dist/im/lark/overview-card.js +2 -1
  370. package/dist/im/lark/overview-card.js.map +1 -1
  371. package/dist/services/async-trigger-state.d.ts +6 -1
  372. package/dist/services/async-trigger-state.d.ts.map +1 -1
  373. package/dist/services/async-trigger-state.js +21 -0
  374. package/dist/services/async-trigger-state.js.map +1 -1
  375. package/dist/services/async-trigger-store.d.ts +23 -1
  376. package/dist/services/async-trigger-store.d.ts.map +1 -1
  377. package/dist/services/async-trigger-store.js +96 -13
  378. package/dist/services/async-trigger-store.js.map +1 -1
  379. package/dist/services/bridge-fallback-gate.d.ts +40 -0
  380. package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
  381. package/dist/services/bridge-fallback-gate.js +124 -24
  382. package/dist/services/bridge-fallback-gate.js.map +1 -1
  383. package/dist/services/codex-app-runner-protocol.d.ts +8 -0
  384. package/dist/services/codex-app-runner-protocol.d.ts.map +1 -1
  385. package/dist/services/codex-app-runner-protocol.js +9 -2
  386. package/dist/services/codex-app-runner-protocol.js.map +1 -1
  387. package/dist/services/codex-bridge-queue.d.ts +1 -0
  388. package/dist/services/codex-bridge-queue.d.ts.map +1 -1
  389. package/dist/services/codex-bridge-queue.js +1 -0
  390. package/dist/services/codex-bridge-queue.js.map +1 -1
  391. package/dist/services/codex-service-tier.d.ts +2 -0
  392. package/dist/services/codex-service-tier.d.ts.map +1 -1
  393. package/dist/services/codex-service-tier.js +1 -0
  394. package/dist/services/codex-service-tier.js.map +1 -1
  395. package/dist/services/codex-transcript.d.ts +27 -0
  396. package/dist/services/codex-transcript.d.ts.map +1 -1
  397. package/dist/services/codex-transcript.js +340 -2
  398. package/dist/services/codex-transcript.js.map +1 -1
  399. package/dist/services/grant-policy.d.ts +3 -0
  400. package/dist/services/grant-policy.d.ts.map +1 -1
  401. package/dist/services/grant-policy.js +5 -0
  402. package/dist/services/grant-policy.js.map +1 -1
  403. package/dist/services/grant-prefs-store.d.ts +6 -3
  404. package/dist/services/grant-prefs-store.d.ts.map +1 -1
  405. package/dist/services/grant-prefs-store.js +42 -12
  406. package/dist/services/grant-prefs-store.js.map +1 -1
  407. package/dist/services/hook-runner.d.ts +20 -1
  408. package/dist/services/hook-runner.d.ts.map +1 -1
  409. package/dist/services/hook-runner.js +17 -13
  410. package/dist/services/hook-runner.js.map +1 -1
  411. package/dist/services/idempotency-store.d.ts +134 -0
  412. package/dist/services/idempotency-store.d.ts.map +1 -0
  413. package/dist/services/idempotency-store.js +299 -0
  414. package/dist/services/idempotency-store.js.map +1 -0
  415. package/dist/services/jsonl-cursor.d.ts +7 -3
  416. package/dist/services/jsonl-cursor.d.ts.map +1 -1
  417. package/dist/services/jsonl-cursor.js +55 -31
  418. package/dist/services/jsonl-cursor.js.map +1 -1
  419. package/dist/services/open-platform-rename.d.ts.map +1 -1
  420. package/dist/services/open-platform-rename.js +1 -108
  421. package/dist/services/open-platform-rename.js.map +1 -1
  422. package/dist/services/restart-intent-store.d.ts +22 -2
  423. package/dist/services/restart-intent-store.d.ts.map +1 -1
  424. package/dist/services/restart-intent-store.js +160 -18
  425. package/dist/services/restart-intent-store.js.map +1 -1
  426. package/dist/services/sandbox-store.d.ts +22 -0
  427. package/dist/services/sandbox-store.d.ts.map +1 -1
  428. package/dist/services/sandbox-store.js +37 -8
  429. package/dist/services/sandbox-store.js.map +1 -1
  430. package/dist/services/send-policy.d.ts +41 -33
  431. package/dist/services/send-policy.d.ts.map +1 -1
  432. package/dist/services/send-policy.js +39 -40
  433. package/dist/services/send-policy.js.map +1 -1
  434. package/dist/services/session-store.d.ts +66 -0
  435. package/dist/services/session-store.d.ts.map +1 -1
  436. package/dist/services/session-store.js +403 -67
  437. package/dist/services/session-store.js.map +1 -1
  438. package/dist/services/submit-notification.d.ts +3 -0
  439. package/dist/services/submit-notification.d.ts.map +1 -0
  440. package/dist/services/submit-notification.js +15 -0
  441. package/dist/services/submit-notification.js.map +1 -0
  442. package/dist/services/traex-transcript.d.ts +25 -2
  443. package/dist/services/traex-transcript.d.ts.map +1 -1
  444. package/dist/services/traex-transcript.js +133 -1
  445. package/dist/services/traex-transcript.js.map +1 -1
  446. package/dist/services/trigger-types.d.ts +13 -1
  447. package/dist/services/trigger-types.d.ts.map +1 -1
  448. package/dist/services/trigger-types.js +39 -0
  449. package/dist/services/trigger-types.js.map +1 -1
  450. package/dist/services/voice/index.d.ts +3 -2
  451. package/dist/services/voice/index.d.ts.map +1 -1
  452. package/dist/services/voice/index.js +5 -5
  453. package/dist/services/voice/index.js.map +1 -1
  454. package/dist/services/voice/openai.d.ts +2 -1
  455. package/dist/services/voice/openai.d.ts.map +1 -1
  456. package/dist/services/voice/openai.js +2 -1
  457. package/dist/services/voice/openai.js.map +1 -1
  458. package/dist/services/voice/sami.d.ts +5 -2
  459. package/dist/services/voice/sami.d.ts.map +1 -1
  460. package/dist/services/voice/sami.js +19 -4
  461. package/dist/services/voice/sami.js.map +1 -1
  462. package/dist/setup/bots-store.d.ts.map +1 -1
  463. package/dist/setup/bots-store.js +10 -4
  464. package/dist/setup/bots-store.js.map +1 -1
  465. package/dist/setup/lark-scopes.json +2 -0
  466. package/dist/setup/open-platform-automation.d.ts +9 -1
  467. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  468. package/dist/setup/open-platform-automation.js +44 -15
  469. package/dist/setup/open-platform-automation.js.map +1 -1
  470. package/dist/setup/open-platform-visibility.d.ts +42 -0
  471. package/dist/setup/open-platform-visibility.d.ts.map +1 -0
  472. package/dist/setup/open-platform-visibility.js +133 -0
  473. package/dist/setup/open-platform-visibility.js.map +1 -0
  474. package/dist/setup/verify-permissions.d.ts.map +1 -1
  475. package/dist/setup/verify-permissions.js +6 -0
  476. package/dist/setup/verify-permissions.js.map +1 -1
  477. package/dist/skills/definitions.js +2 -2
  478. package/dist/types.d.ts +290 -13
  479. package/dist/types.d.ts.map +1 -1
  480. package/dist/utils/bot-routing.d.ts +1 -0
  481. package/dist/utils/bot-routing.d.ts.map +1 -1
  482. package/dist/utils/bot-routing.js +1 -1
  483. package/dist/utils/bot-routing.js.map +1 -1
  484. package/dist/utils/child-env.d.ts +1 -1
  485. package/dist/utils/child-env.d.ts.map +1 -1
  486. package/dist/utils/child-env.js +9 -0
  487. package/dist/utils/child-env.js.map +1 -1
  488. package/dist/utils/codex-app-control.d.ts +438 -0
  489. package/dist/utils/codex-app-control.d.ts.map +1 -0
  490. package/dist/utils/codex-app-control.js +2336 -0
  491. package/dist/utils/codex-app-control.js.map +1 -0
  492. package/dist/utils/codex-app-dispatch-ledger.d.ts +78 -0
  493. package/dist/utils/codex-app-dispatch-ledger.d.ts.map +1 -0
  494. package/dist/utils/codex-app-dispatch-ledger.js +149 -0
  495. package/dist/utils/codex-app-dispatch-ledger.js.map +1 -0
  496. package/dist/utils/codex-app-turn-dispatch.d.ts +77 -0
  497. package/dist/utils/codex-app-turn-dispatch.d.ts.map +1 -0
  498. package/dist/utils/codex-app-turn-dispatch.js +141 -0
  499. package/dist/utils/codex-app-turn-dispatch.js.map +1 -0
  500. package/dist/utils/codex-app-turn-liveness.d.ts +125 -0
  501. package/dist/utils/codex-app-turn-liveness.d.ts.map +1 -0
  502. package/dist/utils/codex-app-turn-liveness.js +301 -0
  503. package/dist/utils/codex-app-turn-liveness.js.map +1 -0
  504. package/dist/utils/idle-detector.d.ts +12 -0
  505. package/dist/utils/idle-detector.d.ts.map +1 -1
  506. package/dist/utils/idle-detector.js +19 -0
  507. package/dist/utils/idle-detector.js.map +1 -1
  508. package/dist/utils/input-gate.d.ts +87 -0
  509. package/dist/utils/input-gate.d.ts.map +1 -1
  510. package/dist/utils/input-gate.js +86 -0
  511. package/dist/utils/input-gate.js.map +1 -1
  512. package/dist/utils/lark-upload.d.ts.map +1 -1
  513. package/dist/utils/lark-upload.js +52 -1
  514. package/dist/utils/lark-upload.js.map +1 -1
  515. package/dist/utils/pending-input-queue.d.ts +15 -0
  516. package/dist/utils/pending-input-queue.d.ts.map +1 -1
  517. package/dist/utils/pending-input-queue.js +5 -1
  518. package/dist/utils/pending-input-queue.js.map +1 -1
  519. package/dist/utils/user-token.d.ts.map +1 -1
  520. package/dist/utils/user-token.js +53 -2
  521. package/dist/utils/user-token.js.map +1 -1
  522. package/dist/worker-ipc-preload.d.ts +12 -0
  523. package/dist/worker-ipc-preload.d.ts.map +1 -0
  524. package/dist/worker-ipc-preload.js +44 -0
  525. package/dist/worker-ipc-preload.js.map +1 -0
  526. package/dist/worker.js +2604 -325
  527. package/dist/worker.js.map +1 -1
  528. package/dist/workflows/v3/session-relay-client.d.ts +1 -0
  529. package/dist/workflows/v3/session-relay-client.d.ts.map +1 -1
  530. package/dist/workflows/v3/session-relay-client.js +12 -3
  531. package/dist/workflows/v3/session-relay-client.js.map +1 -1
  532. package/package.json +1 -1
  533. package/dist/dashboard-web/chunks/bot-defaults-page-LTVOUDIS.js +0 -10
  534. package/dist/dashboard-web/chunks/chunk-DCS35UMD.js +0 -1
  535. package/dist/dashboard-web/chunks/chunk-MFQPCVHY.js +0 -51
  536. package/dist/dashboard-web/chunks/connectors-page-5KWDIMKC.js +0 -1
  537. package/dist/dashboard-web/chunks/groups-page-FHOKYXMW.js +0 -8
  538. package/dist/dashboard-web/chunks/overview-page-QYI3KBDD.js +0 -1
  539. package/dist/dashboard-web/chunks/sessions-page-WBP4N7D2.js +0 -6
package/dist/cli.js CHANGED
@@ -8,7 +8,10 @@
8
8
  * botmux setup list|add|configure|edit|remove — scripted (non-TUI) bot management, see `botmux setup help`
9
9
  * botmux start — start daemon and auto plugin services
10
10
  * botmux stop [--with-plugin] — stop daemon (optionally stop auto plugin services)
11
- * botmux restart [--include-pm2] [--with-plugin] — restart daemon, then ensure auto plugin services
11
+ * botmux restart [--include-pm2] [--with-plugin] — restart daemon, then ensure auto plugin services;
12
+ * --include-pm2 is a zero-live-God admission fence, not authority to signal an existing PM2 God
13
+ * botmux restart --bootstrap-shutdown-protocol --yes — operator-approved one-time retirement
14
+ * of a pre-protocol fleet after independently confirming all Session/Riff work is idle
12
15
  * botmux logs [--lines] — view daemon logs
13
16
  * botmux status — show daemon status
14
17
  * botmux upgrade|update — upgrade to latest version
@@ -21,21 +24,21 @@
21
24
  * botmux whiteboard status|enable|disable|current|list|read|update|write — local project whiteboard
22
25
  */
23
26
  import { execSync, execFileSync, spawnSync, spawn } from 'node:child_process';
24
- import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, renameSync, readdirSync, readlinkSync, appendFileSync, statSync, unlinkSync, rmSync, realpathSync } from 'node:fs';
27
+ import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, renameSync, readdirSync, readlinkSync, symlinkSync, appendFileSync, statSync, unlinkSync, rmSync, realpathSync } from 'node:fs';
25
28
  import { underReadIsolation, sendCredFilePath } from './adapters/cli/read-isolation.js';
26
29
  import { atomicWriteFileSync } from './utils/atomic-write.js';
27
30
  import { join, dirname, basename, resolve } from 'node:path';
28
- import { homedir } from 'node:os';
31
+ import { homedir, userInfo } from 'node:os';
29
32
  import { fileURLToPath } from 'node:url';
30
33
  import { createInterface } from 'node:readline';
31
34
  import { createRequire } from 'node:module';
32
- import { randomBytes } from 'node:crypto';
35
+ import { randomBytes, randomUUID } from 'node:crypto';
33
36
  import { validateWorkingDir } from './core/working-dir.js';
34
- import { resolveSessionContext } from './core/session-marker.js';
37
+ import { findAncestorSessionContext as findLiveAncestorSessionContext, resolveSessionContext, } from './core/session-marker.js';
35
38
  import { resolveBotmuxDataDir } from './core/data-dir.js';
36
39
  import { dashboardSecretPath } from './core/dashboard-secret.js';
37
40
  import { acceptedDispatchBotAppIds, activeConversationBotOpenIds, appendDispatchReportProtocol, appendLegacyDispatchReportProtocol, parseDispatchBotSpec, buildDispatchMessages, buildRepoPrimeText, buildReportContent, eligibleAutoMentionAliases, findDispatchRegistryEntry, foldableChatSessionAppIds, offTopicSubBotTopic, resolveReportPlacement, resolveReportRecipient, resolveReportTarget, resolveSendTarget, threadRootForReachability } from './core/dispatch.js';
38
- import { pickTurnReplyTarget } from './core/reply-target.js';
41
+ import { pickTurnReplyTarget, collectTurnWindowParticipants } from './core/reply-target.js';
39
42
  import { recordDispatchRegistryEntry } from './core/dispatch-registry.js';
40
43
  import { enableAutostart, disableAutostart, autostartStatus, refreshAutostart } from './autostart.js';
41
44
  import { tmuxEnv } from './setup/ensure-tmux.js';
@@ -48,38 +51,56 @@ import { blocksSetupBotStart, classifySetupOpenPlatformOutcome, scriptedSetupOpe
48
51
  import { buildBotFromAddFlags, editInputFromFlags, isScriptedSetupInvocation, maskAppSecret, parseSetupCommand, SETUP_CLI_USAGE, } from './setup/setup-args.js';
49
52
  import { interactiveSelect, pickChoice, pickCliSelection } from './setup/interactive-select.js';
50
53
  import { buildPreset, serializePreset, presetFilename } from './setup/agent-preset.js';
54
+ import { validateCodexAppManagedSendOrigin, } from './utils/codex-app-dispatch-ledger.js';
55
+ import { hasProtectedSessionMutationOwnership } from './core/session-mutation-guard.js';
51
56
  import { logger } from './utils/logger.js';
57
+ import { withFileLock, withFileLockSync } from './utils/file-lock.js';
52
58
  import { scrubClaudeSessionMarkerEnv, scrubSessionCliHomeEnv } from './utils/child-env.js';
53
59
  import { scheduleTimeZone } from './utils/timezone.js';
54
60
  import { expandHomePath, invalidWorkingDirs } from './utils/working-dir.js';
55
61
  import { firstPositional } from './cli/arg-utils.js';
56
62
  import { isColdResumeDormant, isRealManagedSession, sessionListDisposition } from './cli/session-list-liveness.js';
63
+ import { computeSessionPickerLayout, } from './cli/session-picker-layout.js';
64
+ import { computeSessionPickerScrollWindow } from './cli/session-picker-viewport.js';
65
+ import { terminalCellWidth } from './cli/terminal-width.js';
57
66
  import { attachFrozenManagedZmxSession, freezeManagedZmxAttachTarget, } from './cli/zmx-managed-attach.js';
67
+ import { readSupervisorProcessStartIdentity } from './core/process-start-identity.js';
68
+ import { FLEET_DAEMON_EXIT_WAIT_MS, FLEET_SUCCESSOR_SETTLE_MS, PM2_DAEMON_KILL_TIMEOUT_MS, PM2_DAEMON_RESTART_DELAY_MS, } from './core/shutdown-budgets.js';
69
+ import { isFleetEntryProvenFreeOfAutorestartTimer, signalAndAwaitFleet, } from './cli/fleet-shutdown.js';
70
+ import { startExactPm2ProcessIds, } from './cli/pm2-exact-start.js';
58
71
  import { dispatchPrimaryMessage, findStdinAliasAttachment, normalizeInteractiveCardInput, sendFileAttachments, sendVideoAttachments, shouldSendAsPureVideo, validateVideoAttachments } from './cli/send-dispatch.js';
59
72
  import { dispatchDeferredTopicSend, reusableDeferredTopicRoot } from './cli/deferred-topic-send.js';
60
73
  import { readDeferredTopicBinding } from './core/deferred-topic-binding.js';
61
74
  import { resolveDaemonEnv } from './cli/daemon-lifecycle-env.js';
62
75
  import { buildPm2SpawnCommand } from './cli/pm2-command.js';
63
76
  import { pm2ManagedExitConfig } from './pm2-graceful-exit.js';
77
+ import { parseCanonicalPm2Id, parsePm2JlistOutput, parsePm2JlistOutputStrict, parsePm2Integer, } from './cli/pm2-jlist.js';
78
+ import { assertLinuxPm2GodExecutableUsable } from './cli/pm2-preflight.js';
79
+ import { assertNoUnregisteredLiveDaemonDescriptorsIn } from './cli/pm2-descriptor-guard.js';
80
+ import { assertPm2DaemonShutdownCapabilitiesIn } from './cli/pm2-shutdown-capability.js';
81
+ import { assertIncludePm2RestartAdmission } from './cli/pm2-god-admission.js';
82
+ import { requestAttestedDaemonShutdown, requestAttestedDaemonShutdownBatch, } from './cli/supervisor-shutdown-client.js';
83
+ import { assertDaemonPm2GracefulExitPolicy, assertConfiguredPm2FleetReady, assertExactAttestedDaemonSet, classifyStartBotFleetAdmission, normalizeRawPm2StopExitCodes, reconcileLatePm2StartPublication, runBoundedPm2StartTransaction, } from './cli/pm2-start-transaction.js';
64
84
  import { callDashboard } from './cli/dashboard-endpoint.js';
65
85
  import { globalInstallUpdateLockTargetIn, installLatestBotmuxSync } from './core/maintenance.js';
66
- import { withFileLockSync } from './utils/file-lock.js';
67
86
  import { formatGlobalInstallCommand, resolveGlobalInstallPlan, UnsupportedGlobalInstallError, } from './utils/global-install.js';
68
87
  import { loadDashboardSecret } from './dashboard/auth.js';
69
88
  import { postWorkflowDaemonMutation, } from './workflows/v3/daemon-ipc-client.js';
70
89
  import { postWorkflowSessionRunMutation, readWorkflowSessionRelayContext, } from './workflows/v3/session-relay-client.js';
71
90
  import { fetchDaemonIpc, loadDaemonIpcSecret } from './core/daemon-ipc-auth.js';
72
- import { readManagedOriginCapability } from './core/managed-origin-capability.js';
91
+ import { isRetryableAskHttpStatus } from './core/ask-types.js';
92
+ import { hasManagedOriginIsolationMarker, managedOriginDataRootProbeAccess, managedOriginIsolationSentinelAccess, managedOriginLegacyIsolationProbeAccess, readManagedOriginRootLocator, readManagedOriginCapability, } from './core/managed-origin-capability.js';
93
+ import { attestManagedOrigin, } from './core/managed-origin-attestation.js';
73
94
  import { rejectLikelyWindowsStdinMojibake, decodeStdinBytes } from './cli/stdin-encoding.js';
74
95
  import { formatBotInfoEntriesForCli, formatChatBotsForCli, } from './cli/bots-list-output.js';
75
96
  import { ensureBotChatGrantMatrix, requestExactChatGrant } from './cli/exact-chat-grant-client.js';
76
97
  import { buildFooterAddressing, hasKnownBotMention, knownBotOpenIdsFromCrossRef, orderedFooterRecipients, stripCodeSpans, } from './utils/bot-routing.js';
77
- import { isLocale, localeForBot, setDefaultLocale, SUPPORTED_LOCALES } from './i18n/index.js';
98
+ import { isLocale, localeForBot, setDefaultLocale, SUPPORTED_LOCALES, t } from './i18n/index.js';
78
99
  import { chatAppLink, larkHosts, normalizeBrand, sdkDomain } from './im/lark/lark-hosts.js';
79
100
  import { mergeDashboardConfig, mergeGlobalConfig, readGlobalConfig, setGlobalLocale, globalConfigPath } from './global-config.js';
80
101
  import { createWhiteboard, ensureDefaultWhiteboard, getWhiteboard, listWhiteboards, readWhiteboard, whiteboardEnabled, whiteboardPath, } from './services/whiteboard-store.js';
81
102
  import { buildBridgeSendMarkerContent, buildBridgeSendPreviewText, } from './services/bridge-fallback-gate.js';
82
- import { bindRestartLeaseTo, writeManualIntentIfAbsentTo } from './services/restart-intent-store.js';
103
+ import { bindRestartLeaseTo, commitRestartIntentAttemptTo, consumeRestartIntentTo, removeRestartIntentAttemptTo, writeRestartAttemptIntentTo, } from './services/restart-intent-store.js';
83
104
  import { repairMissingChatScope, stripLegacyPendingCardFields } from './services/session-store.js';
84
105
  import { evaluateVcMeetingManagedSend, isTrustedVcMeetingHostRelayParent, resolveVcMeetingImTurnOrigin, } from './services/vc-meeting-send-policy.js';
85
106
  import { finishVcMeetingImReply, prepareVcMeetingDeliveryReply, prepareVcMeetingImReply, } from './services/vc-meeting-im-reply.js';
@@ -89,7 +110,7 @@ import { resolveEffectivePluginIds, updateBotPluginOverride } from './core/plugi
89
110
  import { assertPluginBindingTransition, describePluginDependencyError, enabledPluginDependents, } from './core/plugins/dependencies.js';
90
111
  import { authorizeV3DaemonCommand } from './workflows/v3/cli-daemon-command-authority.js';
91
112
  import { resolveDaemonIpcPort } from './utils/daemon-discovery.js';
92
- import { inspectBotmuxPm2Apps, isExactPm2BotActivationReceipt, managedActivationPm2Disposition, parsePm2JlistOutputStrict as parsePm2JlistOutput, stopExactPm2Process, } from './core/bot-live-control.js';
113
+ import { inspectBotmuxPm2Apps, isExactPm2BotActivationReceipt, managedActivationPm2Disposition, } from './core/bot-live-control.js';
93
114
  import { isSuspendableBackendType, killPersistentBackendTarget, probePersistentBackendTarget, probePersistentSessions, resolvePersistentBackendTarget, } from './core/persistent-backend.js';
94
115
  // Resolve the CLI's UI locale once from the global config file, so subsequent
95
116
  // CLI output (and any t() callers that don't pass an explicit locale) honour
@@ -124,6 +145,14 @@ const PM2_NAME = 'botmux';
124
145
  * when those external pm2 installations get moved or removed.
125
146
  */
126
147
  const PM2_HOME = join(CONFIG_DIR, 'pm2');
148
+ const PM2_FLEET_MUTATION_LOCK_TARGET = join(CONFIG_DIR, 'pm2-fleet-mutation');
149
+ const PM2_START_COMMAND_TIMEOUT_MS = 30_000;
150
+ const PM2_START_VERIFY_MIN_TIMEOUT_MS = 60_000;
151
+ const PM2_START_VERIFY_PER_PROCESS_MS = 2_000;
152
+ const PM2_START_LATE_PUBLICATION_SETTLE_MS = 10_000;
153
+ function pm2StartVerifyTimeoutMs(processCount) {
154
+ return Math.max(PM2_START_VERIFY_MIN_TIMEOUT_MS, Math.max(1, Math.floor(processCount)) * PM2_START_VERIFY_PER_PROCESS_MS);
155
+ }
127
156
  // ─── Helpers ─────────────────────────────────────────────────────────────────
128
157
  function ensureConfigDir() {
129
158
  for (const dir of [CONFIG_DIR, DATA_DIR, LOG_DIR, HEAPSHOT_DIR, PM2_HOME]) {
@@ -175,12 +204,17 @@ function pm2Env(home = PM2_HOME) {
175
204
  return env;
176
205
  }
177
206
  function listPm2GodDaemonPids(home = PM2_HOME) {
178
- if (process.platform !== 'linux')
179
- return [];
180
207
  const marker = `God Daemon (${home})`;
181
208
  const pids = [];
182
- try {
183
- for (const ent of readdirSync('/proc')) {
209
+ if (process.platform === 'linux') {
210
+ let entries;
211
+ try {
212
+ entries = readdirSync('/proc');
213
+ }
214
+ catch (err) {
215
+ throw new Error(`cannot inspect /proc for duplicate PM2 Gods: ${err instanceof Error ? err.message : err}`);
216
+ }
217
+ for (const ent of entries) {
184
218
  if (!/^\d+$/.test(ent))
185
219
  continue;
186
220
  const pid = parseInt(ent, 10);
@@ -191,48 +225,66 @@ function listPm2GodDaemonPids(home = PM2_HOME) {
191
225
  if (cmd.includes('PM2 v') && cmd.includes(marker))
192
226
  pids.push(pid);
193
227
  }
194
- catch { /* ignore unreadable proc entries */ }
228
+ catch { /* another user's or already-exited process */ }
195
229
  }
230
+ return pids.sort((a, b) => a - b);
196
231
  }
197
- catch { /* ignore proc scan failure */ }
198
- return pids.sort((a, b) => a - b);
199
- }
200
- function killDuplicatePm2GodDaemons(home = PM2_HOME) {
201
- const pids = listPm2GodDaemonPids(home);
202
- if (pids.length <= 1)
203
- return false;
204
- const pidFile = join(home, 'pm2.pid');
205
- let keepPid = 0;
206
- if (existsSync(pidFile)) {
207
- try {
208
- const parsed = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
209
- if (pids.includes(parsed))
210
- keepPid = parsed;
211
- }
212
- catch { /* ignore malformed pid file */ }
213
- }
214
- if (!keepPid)
215
- keepPid = pids[pids.length - 1];
216
- const dupes = pids.filter(pid => pid !== keepPid);
217
- if (dupes.length === 0)
218
- return false;
219
- for (const pid of dupes) {
220
- try {
221
- process.kill(pid, 'SIGTERM');
232
+ if (process.platform === 'win32') {
233
+ const windowsScan = spawnSync('powershell.exe', [
234
+ '-NoProfile',
235
+ '-NonInteractive',
236
+ '-Command',
237
+ "$needle = \"God Daemon ($env:BOTMUX_PM2_SCAN_HOME)\"; "
238
+ + "Get-CimInstance Win32_Process | Where-Object { "
239
+ + "$_.CommandLine -and $_.CommandLine.Contains('PM2 v') "
240
+ + "-and $_.CommandLine.Contains($needle) } | ForEach-Object { $_.ProcessId }",
241
+ ], {
242
+ encoding: 'utf8',
243
+ stdio: ['ignore', 'pipe', 'pipe'],
244
+ timeout: 4_000,
245
+ env: { ...process.env, BOTMUX_PM2_SCAN_HOME: home },
246
+ });
247
+ if (windowsScan.status !== 0 || windowsScan.error) {
248
+ throw new Error(`cannot inspect Windows process table for duplicate PM2 Gods: `
249
+ + `${windowsScan.error?.message ?? String(windowsScan.stderr || `status ${windowsScan.status}`).trim()}`);
222
250
  }
223
- catch { /* ignore */ }
224
- try {
225
- process.kill(pid, 0);
226
- process.kill(pid, 'SIGKILL');
251
+ for (const line of String(windowsScan.stdout).split(/\r?\n/)) {
252
+ const pid = parsePm2Integer(line.trim(), { nonNegative: true });
253
+ if (pid && pid > 1)
254
+ pids.push(pid);
227
255
  }
228
- catch { /* ignore */ }
256
+ return [...new Set(pids)].sort((a, b) => a - b);
229
257
  }
230
- try {
231
- atomicWriteFileSync(pidFile, `${keepPid}\n`);
258
+ const ps = spawnSync('ps', ['-axo', 'pid=,command='], {
259
+ encoding: 'utf8',
260
+ stdio: ['ignore', 'pipe', 'pipe'],
261
+ timeout: 2_000,
262
+ });
263
+ if (ps.status !== 0 || ps.error) {
264
+ throw new Error(`cannot inspect process table for duplicate PM2 Gods: `
265
+ + `${ps.error?.message ?? String(ps.stderr || `status ${ps.status}`).trim()}`);
232
266
  }
233
- catch { /* ignore */ }
234
- console.warn(`⚠️ 检测到同一 PM2_HOME (${home}) 下存在多个 PM2 God Daemon,已清理重复实例;保留 pid ${keepPid},移除: ${dupes.join(', ')}`);
235
- return true;
267
+ for (const line of String(ps.stdout).split(/\r?\n/)) {
268
+ if (!line.includes('PM2 v') || !line.includes(marker))
269
+ continue;
270
+ const match = line.match(/^\s*(\d+)\s+/);
271
+ if (match)
272
+ pids.push(Number(match[1]));
273
+ }
274
+ return pids.sort((a, b) => a - b);
275
+ }
276
+ function listSingletonPm2GodDaemonPidsForMutation(home = PM2_HOME) {
277
+ const pids = listPm2GodDaemonPids(home);
278
+ if (pids.length <= 1)
279
+ return pids;
280
+ // Never signal a duplicate God automatically. Its SIGTERM handler may
281
+ // serially stop/force-kill managed children, including a Riff generation
282
+ // whose lineage is not yet durable in the surviving God's registry.
283
+ throw new Error(`refusing PM2 mutation: multiple PM2 God daemons share ${home} `
284
+ + `(pids: ${pids.join(', ')}); no process was signalled`);
285
+ }
286
+ function assertNoDuplicatePm2GodDaemons(home = PM2_HOME) {
287
+ listSingletonPm2GodDaemonPidsForMutation(home);
236
288
  }
237
289
  function runPm2(args, inherit = true, home = PM2_HOME, timeoutMs) {
238
290
  const pm2 = buildPm2SpawnCommand(pm2Bin(), args);
@@ -277,6 +329,62 @@ function pm2Capture(args, home = PM2_HOME, timeoutMs = 10_000) {
277
329
  }
278
330
  return typeof r.stdout === 'string' ? r.stdout : '';
279
331
  }
332
+ async function cmdInternalPm2StartExact(args) {
333
+ const processIds = args.map(value => Number(value));
334
+ try {
335
+ const claimedParent = parsePm2Integer(process.env.BOTMUX_PM2_FLEET_LOCK_OWNER_PID, {
336
+ nonNegative: true,
337
+ });
338
+ if (claimedParent !== process.ppid) {
339
+ throw new Error('internal exact PM2 start requires its live parent fleet-lock owner');
340
+ }
341
+ const lockPayload = readFileSync(`${PM2_FLEET_MUTATION_LOCK_TARGET}.lock`, 'utf8').trim();
342
+ let lockPid;
343
+ try {
344
+ const parsed = JSON.parse(lockPayload);
345
+ lockPid = parsed && typeof parsed === 'object'
346
+ ? parsePm2Integer(parsed.pid, { nonNegative: true })
347
+ : undefined;
348
+ }
349
+ catch {
350
+ lockPid = parsePm2Integer(lockPayload, { nonNegative: true });
351
+ }
352
+ if (lockPid !== process.ppid) {
353
+ throw new Error('internal exact PM2 start could not verify the parent fleet lock');
354
+ }
355
+ const pm2 = require('pm2');
356
+ await startExactPm2ProcessIds(processIds, pm2.Client);
357
+ }
358
+ catch (err) {
359
+ console.error(err instanceof Error ? err.message : String(err));
360
+ process.exitCode = 1;
361
+ }
362
+ }
363
+ function runExactPm2Starts(entries, home, timeoutMs) {
364
+ const processIds = entries.map(entry => entry.pmId);
365
+ if (processIds.some(id => !Number.isInteger(id) || id < 0)) {
366
+ throw new Error('conditional PM2 compensation requires an exact pm_id for every entry');
367
+ }
368
+ const boundedTimeoutMs = Math.floor(timeoutMs);
369
+ if (boundedTimeoutMs <= 0) {
370
+ throw new Error('fleet deadline exhausted before conditional PM2 compensation');
371
+ }
372
+ const result = spawnSync(process.execPath, [__filename, '__pm2-start-exact', ...processIds.map(String)], {
373
+ stdio: 'pipe',
374
+ env: {
375
+ ...pm2Env(home),
376
+ BOTMUX_PM2_FLEET_LOCK_OWNER_PID: String(process.pid),
377
+ },
378
+ timeout: boundedTimeoutMs,
379
+ encoding: 'utf8',
380
+ });
381
+ if (result.status !== 0) {
382
+ const detail = result.error?.message
383
+ ?? result.stderr?.trim()
384
+ ?? `status ${result.status}`;
385
+ throw new Error(`conditional PM2 compensation failed: ${detail}`);
386
+ }
387
+ }
280
388
  function loadBotsJson() {
281
389
  // NOTE: this stays FATAL on a read error, deliberately. Several callers treat
282
390
  // an empty list as "nothing references this" and go on to delete things
@@ -419,35 +527,81 @@ async function cmdServe(args) {
419
527
  });
420
528
  });
421
529
  }
422
- function ecosystemConfig(activationAppId) {
530
+ /**
531
+ * pm2-safe interpreter path.
532
+ *
533
+ * pm2 (>=6, lib/Common.js) treats ANY interpreter path containing the
534
+ * substring `node@` as an nvm version handle and tries to `nvm install` it —
535
+ * so a Homebrew keg-only Node (e.g.
536
+ * `/home/linuxbrew/.linuxbrew/Cellar/node@22/22.23.1/bin/node`) is misread as
537
+ * version `22/22.23.1/bin/node`, and every `botmux start/restart` dies with
538
+ * `Version '22/22.23.1/bin/node' not found`.
539
+ *
540
+ * We can't just point at a different existing binary: the keg-only formula's
541
+ * only paths (Cellar/, opt/, and — when it's not the default — bin/) all
542
+ * contain `node@`. So when process.execPath carries `node@`, we materialize a
543
+ * stable `@`-free symlink under ~/.botmux and hand pm2 THAT. The symlink still
544
+ * resolves to the exact Node that launched this CLI (same version → native
545
+ * modules like node-pty keep working), but its path no longer trips pm2's
546
+ * nvm heuristic. Non-Homebrew installs keep using process.execPath verbatim.
547
+ */
548
+ function pm2SafeInterpreter() {
549
+ const exec = process.execPath;
550
+ if (!exec.includes('node@'))
551
+ return exec;
552
+ const link = join(CONFIG_DIR, 'node-interpreter');
553
+ try {
554
+ mkdirSync(CONFIG_DIR, { recursive: true });
555
+ // Refresh the link if missing or pointing at a stale Node (e.g. after a
556
+ // Homebrew upgrade bumped the patch version under the same install).
557
+ let current;
558
+ try {
559
+ current = readlinkSync(link);
560
+ }
561
+ catch { /* not a symlink / absent */ }
562
+ if (current !== exec) {
563
+ try {
564
+ unlinkSync(link);
565
+ }
566
+ catch { /* absent */ }
567
+ symlinkSync(exec, link);
568
+ }
569
+ // Sanity: the link must still resolve to a real node. If anything is off,
570
+ // fall back to the raw path rather than handing pm2 a dangling symlink.
571
+ if (realpathSync(link) && !link.includes('node@'))
572
+ return link;
573
+ }
574
+ catch {
575
+ /* fall through to raw execPath */
576
+ }
577
+ return exec;
578
+ }
579
+ function ecosystemConfig(bots = loadBotsJson(), activationAppId) {
423
580
  const daemonScript = join(PKG_ROOT, 'dist', 'index-daemon.js');
424
- const bots = loadBotsJson();
425
581
  ensureUniqueBotProcessNames(bots);
426
582
  const daemonEnv = resolveDaemonEnv(process.env, existsSync(ENV_FILE) ? readFileSync(ENV_FILE, 'utf-8') : undefined);
427
583
  const managedExit = pm2ManagedExitConfig();
584
+ // Node binary every managed process is pinned to (see pm2SafeInterpreter).
585
+ const interpreter = pm2SafeInterpreter();
428
586
  const baseApp = {
429
587
  script: daemonScript,
430
588
  // Pin every managed core process to the Node that invoked this CLI. This
431
589
  // keeps GUI/launchd starts independent from PATH and lets Desktop replace
432
590
  // an external fleet without also killing unrelated plugin services.
433
- interpreter: process.execPath,
591
+ interpreter,
434
592
  cwd: CONFIG_DIR,
435
593
  autorestart: true,
436
594
  max_restarts: 10,
437
- restart_delay: 3000,
438
- // A graceful PM2-managed shutdown exits with a dedicated sentinel code.
439
- // Tell pm2 that only this code is intentional so it does NOT autorestart the daemon
440
- // while `botmux restart` is tearing the fleet down — otherwise pm2 revives
441
- // each daemon (after restart_delay) the instant our parallel SIGTERM drains
442
- // it, and re-deleting those revivals one-by-one re-serializes the teardown
443
- // (~13s of churn for 31 bots). Do not use 0 here: PM2 normalizes a signal
444
- // exit's null code to 0, which would suppress autorestart after SIGKILL.
595
+ restart_delay: PM2_DAEMON_RESTART_DELAY_MS,
596
+ // PM2 maps signal-only death to exit_code=0 before applying
597
+ // stop_exit_codes. Zero cannot be a graceful sentinel: SIGKILL/OOM during
598
+ // a prepared Riff drain would otherwise suppress autorestart and look safe
599
+ // to delete. Only shutdown()'s fully committed success exits the reserved
600
+ // non-zero code. All signal deaths and ordinary failures still restart.
445
601
  stop_exit_codes: managedExit.stopExitCodes,
446
- // pm2's default kill_timeout (1.6s) is SHORTER than the daemon's own
447
- // SHUTDOWN_GRACE_MS (3s), so any daemon pm2 has to signal directly gets
448
- // SIGKILL'd mid-drain → orphaned (ppid=1) workers. Give pm2 headroom past
449
- // the daemon's graceful-drain budget so it never force-kills mid-shutdown.
450
- kill_timeout: 3500,
602
+ // Keep the supervisor outside every bounded Riff prepare/commit/refusal
603
+ // handshake so PM2 cannot SIGKILL a correct daemon mid-ACK.
604
+ kill_timeout: PM2_DAEMON_KILL_TIMEOUT_MS,
451
605
  log_date_format: 'YYYY-MM-DD HH:mm:ss',
452
606
  merge_logs: true,
453
607
  node_args: [
@@ -512,13 +666,13 @@ function ecosystemConfig(activationAppId) {
512
666
  apps.push({
513
667
  name: 'botmux-dashboard',
514
668
  script: join(PKG_ROOT, 'dist', 'dashboard.js'),
515
- interpreter: process.execPath,
669
+ interpreter,
516
670
  cwd: PKG_ROOT,
517
671
  autorestart: true,
518
672
  max_restarts: 10,
519
- restart_delay: 3000,
520
- // Same rationale as the bot daemons: don't let pm2 revive on graceful exit
521
- // during a fleet teardown, and don't SIGKILL mid-shutdown. (See baseApp.)
673
+ restart_delay: PM2_DAEMON_RESTART_DELAY_MS,
674
+ // Dashboard receives the same managed sentinel env and exits through
675
+ // gracefulProcessExitCode(), so its stop policy must match the daemon's.
522
676
  stop_exit_codes: managedExit.stopExitCodes,
523
677
  kill_timeout: 3500,
524
678
  error_file: join(LOG_DIR, 'dashboard-error.log'),
@@ -1499,7 +1653,7 @@ async function cmdSetupScripted(argv) {
1499
1653
  });
1500
1654
  return;
1501
1655
  }
1502
- const live = ensureBotDaemonStarted(bot.larkAppId, { quiet: cmd.json });
1656
+ const live = await ensureBotDaemonStarted(bot.larkAppId, { quiet: cmd.json });
1503
1657
  const next = live.ok ? 'live' : (live.reason === 'fleet_down' ? 'botmux start' : 'botmux restart');
1504
1658
  if (cmd.json) {
1505
1659
  console.log(JSON.stringify({
@@ -1746,7 +1900,7 @@ async function cmdSetupScripted(argv) {
1746
1900
  return;
1747
1901
  }
1748
1902
  // daemon 在跑就直接把新 bot 那一个进程拉起来,免整组 botmux restart。
1749
- const live = ensureBotDaemonStarted(bot.larkAppId, { quiet: cmd.json });
1903
+ const live = await ensureBotDaemonStarted(bot.larkAppId, { quiet: cmd.json });
1750
1904
  const next = live.ok ? 'live' : (live.reason === 'fleet_down' ? 'botmux start' : 'botmux restart');
1751
1905
  if (cmd.json) {
1752
1906
  console.log(JSON.stringify({
@@ -2068,7 +2222,7 @@ async function cmdSetup() {
2068
2222
  console.log(`\n✅ 已添加机器人 ${newBot.larkAppId},共 ${bots.length + 1} 个`);
2069
2223
  console.log(` 配置文件: ${BOTS_JSON_FILE}`);
2070
2224
  await finishOpenPlatformSetup(newBot.larkAppId, botBrand(newBot), { reuseOnly: hasSetupWebSession(newBot) });
2071
- printAddBotLiveHint(newBot.larkAppId);
2225
+ await printAddBotLiveHint(newBot.larkAppId);
2072
2226
  return;
2073
2227
  }
2074
2228
  }
@@ -2122,7 +2276,7 @@ async function cmdSetup() {
2122
2276
  console.log(` 配置文件: ${BOTS_JSON_FILE}`);
2123
2277
  console.log(` 旧配置已备份: ${ENV_FILE}.bak`);
2124
2278
  await finishOpenPlatformSetup(newBot.larkAppId, botBrand(newBot), { reuseOnly: hasSetupWebSession(newBot) });
2125
- printAddBotLiveHint(newBot.larkAppId);
2279
+ await printAddBotLiveHint(newBot.larkAppId);
2126
2280
  }
2127
2281
  else {
2128
2282
  // --- Fresh install ---
@@ -2140,50 +2294,19 @@ async function cmdSetup() {
2140
2294
  * the daemon, but the error gets buried in pm2 logs and the user sees
2141
2295
  * silence.
2142
2296
  *
2143
- * Detects two cases and either auto-heals or aborts with a clear message:
2144
- * 1. pm2 god daemon's running binary is deleted → auto `pm2 kill`
2297
+ * Detects two cases and aborts with a clear message:
2298
+ * 1. pm2 god daemon's running binary is deleted → fail closed; an automatic
2299
+ * kill could bypass a managed daemon's Riff shutdown protocol
2145
2300
  * 2. This package is installed under an nvm Node version that no longer
2146
2301
  * exists on disk → abort with reinstall instructions
2147
2302
  */
2148
- function preflightNodeSanity() {
2149
- // Case 1: pm2 god is alive but its Node binary has been deleted.
2150
- const pm2PidFile = join(PM2_HOME, 'pm2.pid');
2151
- if (existsSync(pm2PidFile)) {
2152
- let pm2Pid = 0;
2153
- try {
2154
- pm2Pid = parseInt(readFileSync(pm2PidFile, 'utf-8').trim(), 10);
2155
- }
2156
- catch { /* ignore */ }
2157
- if (pm2Pid) {
2158
- let pm2Alive = false;
2159
- try {
2160
- process.kill(pm2Pid, 0);
2161
- pm2Alive = true;
2162
- }
2163
- catch { /* not alive */ }
2164
- if (pm2Alive && process.platform === 'linux') {
2165
- // On Linux, /proc/<pid>/exe is a symlink to the running executable.
2166
- // readlink includes a " (deleted)" suffix when the on-disk file is gone.
2167
- try {
2168
- const exe = readlinkSync(`/proc/${pm2Pid}/exe`);
2169
- const cleanPath = exe.replace(/ \(deleted\)$/, '');
2170
- const exeDeleted = exe.endsWith(' (deleted)') || !existsSync(cleanPath);
2171
- if (exeDeleted) {
2172
- console.warn(`⚠️ pm2 god daemon (pid ${pm2Pid}) 使用的 Node 二进制已失效: ${cleanPath}`);
2173
- console.warn(` 自动杀掉 pm2 god 以便用当前 Node 重启...`);
2174
- try {
2175
- runPm2(['kill'], false, PM2_HOME, 10_000);
2176
- }
2177
- catch {
2178
- try {
2179
- process.kill(pm2Pid, 'SIGKILL');
2180
- }
2181
- catch { /* ignore */ }
2182
- }
2183
- }
2184
- }
2185
- catch { /* /proc not readable, skip */ }
2186
- }
2303
+ function preflightNodeSanity(home = PM2_HOME) {
2304
+ // `pm2.pid` is only a cache. Inspect every God actually enumerated for this
2305
+ // PM2_HOME and refuse rather than guessing which generation is authoritative.
2306
+ const actualGodPids = listPm2GodDaemonPids(home);
2307
+ if (process.platform === 'linux') {
2308
+ for (const pm2Pid of actualGodPids) {
2309
+ assertLinuxPm2GodExecutableUsable(pm2Pid);
2187
2310
  }
2188
2311
  }
2189
2312
  // Case 2: botmux installed under a dead nvm Node version.
@@ -2212,8 +2335,6 @@ async function cmdStart() {
2212
2335
  process.exit(1);
2213
2336
  }
2214
2337
  ensureConfigDir();
2215
- killDuplicatePm2GodDaemons();
2216
- preflightNodeSanity();
2217
2338
  await ensureSystemDependencies();
2218
2339
  // 启动前快速校验每个 bot 的凭证. Codex review 边界 #5: 凭证无效是
2219
2340
  // 唯一应该阻塞 start 的情况; scope/event 缺失在 daemon 起来后用 WARN
@@ -2249,9 +2370,51 @@ async function cmdStart() {
2249
2370
  process.exit(1);
2250
2371
  }
2251
2372
  }
2252
- cleanupLegacyPm2();
2253
- const cfg = ecosystemConfig();
2254
- runPm2(['start', cfg]);
2373
+ await withFileLock(PM2_FLEET_MUTATION_LOCK_TARGET, async () => {
2374
+ await withFileLock(BOTS_JSON_FILE, async () => {
2375
+ const lockedBots = loadBotsJson();
2376
+ if (JSON.stringify(lockedBots) !== JSON.stringify(botsForCheck)) {
2377
+ throw new Error('[start] bots.json changed during credential preflight; retry with the new configuration');
2378
+ }
2379
+ assertNoDuplicatePm2GodDaemons();
2380
+ preflightNodeSanity();
2381
+ cleanupLegacyPm2();
2382
+ const currentProjection = readVerifiedBotmuxPm2Projection('start');
2383
+ assertNoUnregisteredLiveDaemonDescriptors('start', currentProjection);
2384
+ assertCanonicalUniquePm2Rows('start', currentProjection);
2385
+ const configuredNames = configuredCoreProcessNames(lockedBots);
2386
+ const verifyTimeoutMs = pm2StartVerifyTimeoutMs(configuredNames.length);
2387
+ const cfg = ecosystemConfig(lockedBots);
2388
+ const liveEntries = currentProjection.filter(isLivePm2Entry);
2389
+ if (liveEntries.length > 0) {
2390
+ try {
2391
+ readAndAssertConfiguredFleetOnline('start-idempotent-ready', configuredNames, PM2_HOME, verifyTimeoutMs);
2392
+ return;
2393
+ }
2394
+ catch (error) {
2395
+ throw new Error(`[start] refusing PM2 start while a partial/live core fleet exists `
2396
+ + `(${liveEntries.map(entry => `${entry.name}:${entry.pid}`).join(', ')}): `
2397
+ + `${error instanceof Error ? error.message : String(error)}; `
2398
+ + 'use start-bot only for an exact one-missing-bot fleet, or restart');
2399
+ }
2400
+ }
2401
+ const unprovenDormant = currentProjection.filter(entry => !isFleetEntryProvenFreeOfAutorestartTimer(entry));
2402
+ if (unprovenDormant.length > 0) {
2403
+ throw new Error(`[start] refusing PM2 start: dormant row(s) may still have a restart timer `
2404
+ + `(${unprovenDormant.map(entry => `${entry.name}:${entry.status ?? 'unknown'}`).join(', ')})`);
2405
+ }
2406
+ runBoundedPm2StartTransaction('start', PM2_START_COMMAND_TIMEOUT_MS, verifyTimeoutMs, {
2407
+ start: timeoutMs => {
2408
+ assertBotsConfigSnapshotUnchanged('start', lockedBots);
2409
+ assertNoDuplicatePm2GodDaemons();
2410
+ preflightNodeSanity();
2411
+ runPm2(['start', cfg], true, PM2_HOME, timeoutMs);
2412
+ },
2413
+ verifyFresh: timeoutMs => readAndAssertConfiguredFleetOnline('start-after-launch', configuredNames, PM2_HOME, timeoutMs),
2414
+ rollback: () => rollbackPm2StartAttempt('start', currentProjection, configuredNames),
2415
+ });
2416
+ }, { maxWaitMs: 5_000 });
2417
+ }, { maxWaitMs: 5_000 });
2255
2418
  await reconcilePluginServicesForCli(undefined, { autoOnly: true });
2256
2419
  const bots = loadBotsJson();
2257
2420
  const count = bots.length || 1;
@@ -2301,98 +2464,511 @@ function sleepSyncMs(ms) {
2301
2464
  function isBotmuxCoreProcessName(name) {
2302
2465
  return name === PM2_NAME || (name.startsWith(`${PM2_NAME}-`) && !name.startsWith(`${PM2_NAME}-plugin-`));
2303
2466
  }
2304
- function deleteAllBotmuxProcesses(home = PM2_HOME) {
2305
- let entries;
2467
+ function isBotmuxDaemonProcessName(name) {
2468
+ return isBotmuxCoreProcessName(name) && name !== 'botmux-dashboard';
2469
+ }
2470
+ function toBotmuxPm2ProcessEntry(app) {
2471
+ const rawStopExitCodes = app?.pm2_env?.stop_exit_codes;
2472
+ const pmId = parseCanonicalPm2Id(app);
2473
+ const exitCode = parsePm2Integer(app?.pm2_env?.exit_code);
2474
+ return {
2475
+ name: String(app.name),
2476
+ ...(pmId !== undefined ? { pmId } : {}),
2477
+ pid: Number(app.pid) || 0,
2478
+ online: app?.pm2_env?.status === 'online',
2479
+ status: String(app?.pm2_env?.status ?? 'unknown'),
2480
+ autorestart: app?.pm2_env?.autorestart,
2481
+ stopExitCodes: normalizeRawPm2StopExitCodes(rawStopExitCodes),
2482
+ ...(exitCode !== undefined ? { exitCode } : {}),
2483
+ };
2484
+ }
2485
+ function readVerifiedBotmuxPm2Projection(operation, home = PM2_HOME, timeoutMs = 10_000) {
2306
2486
  try {
2307
- const apps = parsePm2JlistOutput(pm2Capture(['jlist'], home));
2308
- entries = (Array.isArray(apps) ? apps : [])
2309
- .filter(a => a && isBotmuxCoreProcessName(String(a.name)))
2310
- .map(a => ({ name: String(a.name), pid: Number(a.pid) || 0, online: a?.pm2_env?.status === 'online' }));
2487
+ return parsePm2JlistOutputStrict(pm2Capture(['jlist'], home, timeoutMs))
2488
+ .filter(app => app && isBotmuxCoreProcessName(String(app.name)))
2489
+ .map(toBotmuxPm2ProcessEntry);
2311
2490
  }
2312
- catch (e) {
2313
- console.error(`[restart] pm2 jlist failed (pm2 not running or no apps?): ${e instanceof Error ? e.message : e}`);
2314
- return;
2491
+ catch (err) {
2492
+ throw new Error(`[${operation}] pm2 jlist failed; refusing an unverified PM2 mutation: `
2493
+ + `${err instanceof Error ? err.message : String(err)}`);
2315
2494
  }
2316
- if (entries.length === 0)
2495
+ }
2496
+ function assertNoUnregisteredLiveDaemonDescriptors(operation, projections) {
2497
+ assertNoUnregisteredLiveDaemonDescriptorsIn(operation, projections, join(resolveDataDir(), 'dashboard-daemons'));
2498
+ }
2499
+ function duplicatePm2CoreNames(entries) {
2500
+ return [...new Set(entries.map(entry => entry.name))]
2501
+ .filter(name => entries.filter(entry => entry.name === name).length > 1);
2502
+ }
2503
+ function isLivePm2Entry(entry) {
2504
+ if (!Number.isInteger(entry.pid) || entry.pid <= 1)
2505
+ return false;
2506
+ try {
2507
+ process.kill(entry.pid, 0);
2508
+ return true;
2509
+ }
2510
+ catch {
2511
+ return false;
2512
+ }
2513
+ }
2514
+ function configuredCoreProcessNames(bots = loadBotsJson(), activationAppId) {
2515
+ const names = [];
2516
+ bots.forEach((bot, index) => {
2517
+ const appId = typeof bot?.larkAppId === 'string' ? bot.larkAppId : '';
2518
+ const starting = bot?.activationStarting;
2519
+ const committed = bot?.activationCommitted;
2520
+ const deactivating = bot?.activationDeactivating;
2521
+ const conflicting = starting !== undefined && committed !== undefined;
2522
+ const marker = starting ?? committed;
2523
+ const validMarker = (marker
2524
+ && typeof marker === 'object'
2525
+ && !Array.isArray(marker)
2526
+ && marker.appId === appId
2527
+ && typeof marker.jobId === 'string'
2528
+ && marker.jobId);
2529
+ if (bot?.activationPending === true
2530
+ || deactivating !== undefined
2531
+ || conflicting
2532
+ || ((starting !== undefined || committed !== undefined)
2533
+ && (!validMarker || activationAppId !== appId)))
2534
+ return;
2535
+ names.push(botProcessName(bot, index, PM2_NAME));
2536
+ });
2537
+ names.push('botmux-dashboard');
2538
+ return names;
2539
+ }
2540
+ function assertBotsConfigSnapshotUnchanged(operation, snapshot) {
2541
+ if (JSON.stringify(loadBotsJson()) === JSON.stringify(snapshot))
2317
2542
  return;
2318
- const names = entries.map(e => e.name);
2319
- // Parallel graceful shutdown. pm2's own delete stops apps one-at-a-time
2320
- // (async eachLimit, concurrency 1) and each botmux daemon's drain eats pm2's
2321
- // full kill_timeout (~1.6s) → ~N×1.6s serial (~38s for 31 bots). Instead we
2322
- // SIGTERM every online daemon AT ONCE so their graceful drains overlap (the
2323
- // daemon's SIGTERM handler detaches workers within SHUTDOWN_GRACE_MS), wait
2324
- // once for them all to exit, then let pm2 delete reap the now-dead entries
2325
- // instantly. Orphan-safe: each daemon runs its FULL graceful drain and we wait
2326
- // for real exit before pm2 touches it — avoiding the mid-drain SIGKILL the old
2327
- // path forced (pm2 kill_timeout 1.6s < daemon SHUTDOWN_GRACE_MS 3s).
2328
- const pids = entries.filter(e => e.online && e.pid > 0).map(e => e.pid);
2329
- for (const pid of pids) {
2543
+ throw new Error(`[${operation}] bots.json generation changed before PM2 start; no launch attempted`);
2544
+ }
2545
+ function readAndAssertConfiguredFleetOnline(operation, configuredNames, home = PM2_HOME, timeoutMs = PM2_START_VERIFY_MIN_TIMEOUT_MS) {
2546
+ const deadline = Date.now() + Math.max(1, Math.floor(timeoutMs));
2547
+ let lastError;
2548
+ while (Date.now() < deadline) {
2549
+ try {
2550
+ const remainingMs = Math.max(1, deadline - Date.now());
2551
+ const projection = readVerifiedBotmuxPm2Projection(operation, home, remainingMs);
2552
+ assertNoUnregisteredLiveDaemonDescriptors(operation, projection);
2553
+ assertConfiguredPm2FleetReady(operation, projection, configuredNames, pid => { try {
2554
+ process.kill(pid, 0);
2555
+ return true;
2556
+ }
2557
+ catch {
2558
+ return false;
2559
+ } }, readyEntries => {
2560
+ const daemonEntries = readyEntries.filter(entry => isBotmuxDaemonProcessName(entry.name));
2561
+ assertDaemonPm2GracefulExitPolicy(`${operation}-handler-ready-pm2-policy`, daemonEntries);
2562
+ const attested = assertPm2DaemonShutdownCapabilitiesIn(`${operation}-handler-ready`, daemonEntries.map(entry => ({ name: entry.name, pid: entry.pid })), join(resolveDataDir(), 'dashboard-daemons'));
2563
+ assertExactAttestedDaemonSet(`${operation}-handler-ready`, daemonEntries, attested.map(entry => entry.pid), pid => { try {
2564
+ process.kill(pid, 0);
2565
+ return true;
2566
+ }
2567
+ catch {
2568
+ return false;
2569
+ } });
2570
+ for (const target of attested) {
2571
+ if (readSupervisorProcessStartIdentity(target.pid) !== target.processStartIdentity) {
2572
+ throw new Error(`[${operation}-handler-ready] daemon generation changed after capability scan: `
2573
+ + `${target.name}/${target.pid}`);
2574
+ }
2575
+ }
2576
+ if (readyEntries
2577
+ .filter(entry => !isBotmuxDaemonProcessName(entry.name))
2578
+ .some(entry => !isLivePm2Entry(entry))) {
2579
+ throw new Error(`[${operation}] dashboard exited during handler-ready verification`);
2580
+ }
2581
+ });
2582
+ return projection;
2583
+ }
2584
+ catch (error) {
2585
+ lastError = error;
2586
+ const remainingMs = deadline - Date.now();
2587
+ if (remainingMs <= 0)
2588
+ break;
2589
+ sleepSyncMs(Math.min(100, remainingMs));
2590
+ }
2591
+ }
2592
+ throw new Error(`[${operation}] configured fleet never reached PM2-online plus handler-ready capability `
2593
+ + `within ${timeoutMs}ms: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
2594
+ }
2595
+ function assertCanonicalUniquePm2Rows(operation, entries) {
2596
+ const duplicateNames = duplicatePm2CoreNames(entries);
2597
+ const missingIds = entries.filter(entry => !Number.isSafeInteger(entry.pmId) || entry.pmId < 0);
2598
+ const duplicateIds = [...new Set(entries
2599
+ .map(entry => entry.pmId)
2600
+ .filter((id) => Number.isSafeInteger(id)))]
2601
+ .filter(id => entries.filter(entry => entry.pmId === id).length > 1);
2602
+ const duplicateLivePids = [...new Set(entries
2603
+ .map(entry => entry.pid)
2604
+ .filter(pid => Number.isSafeInteger(pid) && pid > 1))]
2605
+ .filter(pid => entries.filter(entry => entry.pid === pid).length > 1);
2606
+ if (duplicateNames.length === 0
2607
+ && missingIds.length === 0
2608
+ && duplicateIds.length === 0
2609
+ && duplicateLivePids.length === 0)
2610
+ return;
2611
+ throw new Error(`[${operation}] refusing PM2 mutation: canonical registry identity is ambiguous`
2612
+ + (duplicateNames.length > 0 ? ` (duplicate names: ${duplicateNames.join(', ')})` : '')
2613
+ + (duplicateIds.length > 0 ? ` (duplicate pm_id: ${duplicateIds.join(', ')})` : '')
2614
+ + (duplicateLivePids.length > 0
2615
+ ? ` (duplicate positive pid: ${duplicateLivePids.join(', ')})`
2616
+ : '')
2617
+ + (missingIds.length > 0
2618
+ ? ` (missing pm_id: ${missingIds.map(entry => entry.name).join(', ')})`
2619
+ : ''));
2620
+ }
2621
+ function exactQuiescentRowsForMutation(operation, originals, fresh) {
2622
+ const exact = [];
2623
+ for (const original of originals) {
2624
+ const rows = fresh.filter(entry => entry.name === original.name);
2625
+ if (rows.length === 0)
2626
+ continue;
2627
+ if (rows.length !== 1 || rows[0].pmId !== original.pmId) {
2628
+ throw new Error(`[${operation}] refusing PM2 mutation: registry row ${original.name} was recreated or duplicated`);
2629
+ }
2630
+ if (isLivePm2Entry(rows[0])) {
2631
+ throw new Error(`[${operation}] refusing PM2 mutation: live generation appeared for `
2632
+ + `${original.name}:${rows[0].pid}`);
2633
+ }
2634
+ if (!isFleetEntryProvenFreeOfAutorestartTimer(rows[0])) {
2635
+ throw new Error(`[${operation}] refusing PM2 mutation: ${original.name} may still publish a successor`);
2636
+ }
2637
+ exact.push(rows[0]);
2638
+ }
2639
+ return exact;
2640
+ }
2641
+ function revalidateExactQuiescentRowBeforeMutation(operation, original, allOriginals, home = PM2_HOME, additionalDescriptorAuthority = []) {
2642
+ assertNoDuplicatePm2GodDaemons(home);
2643
+ const fresh = readVerifiedBotmuxPm2Projection(operation, home);
2644
+ assertNoUnregisteredLiveDaemonDescriptors(operation, [...fresh, ...additionalDescriptorAuthority]);
2645
+ assertCanonicalUniquePm2Rows(operation, fresh);
2646
+ return exactQuiescentRowsForMutation(operation, allOriginals, fresh)
2647
+ .find(entry => entry.name === original.name);
2648
+ }
2649
+ function signalAndAwaitBotmuxProcesses(entries, operation, home = PM2_HOME, additionalDescriptorAuthority = []) {
2650
+ assertCanonicalUniquePm2Rows(operation, entries);
2651
+ const processNameByPid = new Map();
2652
+ const processEntryByPid = new Map();
2653
+ const processStartByPid = new Map();
2654
+ const rememberProcessIdentity = (identityOperation, entry) => {
2655
+ if (entry.pid <= 1)
2656
+ return;
2657
+ const identity = readSupervisorProcessStartIdentity(entry.pid);
2658
+ if (!identity) {
2659
+ if (!isLivePm2Entry(entry))
2660
+ return;
2661
+ throw new Error(`[${identityOperation}] cannot bind ${entry.name}/${entry.pid} to a process-start identity`);
2662
+ }
2663
+ processNameByPid.set(entry.pid, entry.name);
2664
+ processEntryByPid.set(entry.pid, entry);
2665
+ processStartByPid.set(entry.pid, identity);
2666
+ };
2667
+ for (const entry of entries)
2668
+ rememberProcessIdentity(`${operation}-initial-identity`, entry);
2669
+ const assertShutdownCapability = (capabilityOperation, targets) => {
2670
+ const daemonTargets = targets.filter(entry => isBotmuxDaemonProcessName(entry.name));
2671
+ assertDaemonPm2GracefulExitPolicy(capabilityOperation, daemonTargets);
2672
+ return assertPm2DaemonShutdownCapabilitiesIn(capabilityOperation, daemonTargets.map(entry => ({ name: entry.name, pid: entry.pid })), join(resolveDataDir(), 'dashboard-daemons'));
2673
+ };
2674
+ assertShutdownCapability(`${operation}-shutdown-capability-preflight`, entries.filter(entry => entry.online && entry.pid > 1));
2675
+ const list = (timeoutMs) => {
2676
+ const projection = parsePm2JlistOutputStrict(pm2Capture(['jlist'], home, Math.min(10_000, Math.max(1, Math.floor(timeoutMs)))))
2677
+ .filter(app => app && isBotmuxCoreProcessName(String(app.name)))
2678
+ .map(toBotmuxPm2ProcessEntry);
2679
+ assertNoUnregisteredLiveDaemonDescriptors(`${operation}-successor-projection`, [...projection, ...additionalDescriptorAuthority]);
2680
+ assertCanonicalUniquePm2Rows(`${operation}-successor-projection`, projection);
2681
+ for (const entry of projection) {
2682
+ rememberProcessIdentity(`${operation}-successor-identity`, entry);
2683
+ }
2684
+ return projection;
2685
+ };
2686
+ const shutdownRequestFailures = [];
2687
+ const recordShutdownFailure = (name, pid, error) => {
2688
+ shutdownRequestFailures.push(`${name}/${pid}: ${error instanceof Error ? error.message : String(error)}`);
2689
+ };
2690
+ const signalDashboardResidual = (name, pid) => {
2691
+ const expectedStart = processStartByPid.get(pid);
2692
+ const currentStart = readSupervisorProcessStartIdentity(pid);
2693
+ if (!currentStart)
2694
+ return;
2695
+ if (!expectedStart || currentStart !== expectedStart) {
2696
+ recordShutdownFailure(name, pid, 'dashboard process generation changed');
2697
+ return;
2698
+ }
2330
2699
  try {
2331
2700
  process.kill(pid, 'SIGTERM');
2332
2701
  }
2333
- catch { /* already gone */ }
2334
- }
2335
- // Poll until every signalled daemon has exited (bounded). SHUTDOWN_GRACE_MS is
2336
- // 3s; give headroom. Exits early the moment the last one dies.
2337
- const deadline = Date.now() + 5_000;
2338
- let alive = pids.slice();
2339
- while (alive.length > 0 && Date.now() < deadline) {
2340
- sleepSyncMs(50);
2341
- alive = alive.filter(pid => { try {
2342
- process.kill(pid, 0);
2343
- return true;
2702
+ catch (error) {
2703
+ if (error?.code !== 'ESRCH') {
2704
+ recordShutdownFailure(name, pid, error);
2705
+ }
2344
2706
  }
2345
- catch {
2346
- return false;
2347
- } });
2707
+ };
2708
+ try {
2709
+ signalAndAwaitFleet(entries, operation, FLEET_DAEMON_EXIT_WAIT_MS, {
2710
+ signal: pid => {
2711
+ const name = processNameByPid.get(pid);
2712
+ if (!name) {
2713
+ throw new Error(`[${operation}] refusing signal for unmapped PM2 daemon pid ${pid}`);
2714
+ }
2715
+ if (isBotmuxDaemonProcessName(name)) {
2716
+ try {
2717
+ const successor = processEntryByPid.get(pid);
2718
+ if (!successor) {
2719
+ recordShutdownFailure(name, pid, 'successor PM2 policy projection is missing');
2720
+ return;
2721
+ }
2722
+ const authorized = assertShutdownCapability(`${operation}-successor-immediately-before-request`, [successor]);
2723
+ const target = authorized.find(entry => entry.pid === pid);
2724
+ if (!target) {
2725
+ recordShutdownFailure(name, pid, 'daemon exited before exact IPC attestation');
2726
+ return;
2727
+ }
2728
+ requestAttestedDaemonShutdown(target, loadDaemonIpcSecret());
2729
+ }
2730
+ catch (error) {
2731
+ recordShutdownFailure(name, pid, error);
2732
+ }
2733
+ return;
2734
+ }
2735
+ signalDashboardResidual(name, pid);
2736
+ },
2737
+ signalInitial: targets => {
2738
+ let authorized;
2739
+ try {
2740
+ authorized = assertShutdownCapability(`${operation}-initial-immediately-before-batch-request`, targets);
2741
+ }
2742
+ catch (error) {
2743
+ for (const target of targets.filter(entry => isBotmuxDaemonProcessName(entry.name))) {
2744
+ recordShutdownFailure(target.name, target.pid, error);
2745
+ }
2746
+ return;
2747
+ }
2748
+ const expectedDaemonTargets = targets
2749
+ .filter(entry => isBotmuxDaemonProcessName(entry.name));
2750
+ const authorizedPids = new Set(authorized.map(entry => entry.pid));
2751
+ for (const target of expectedDaemonTargets) {
2752
+ if (!authorizedPids.has(target.pid)) {
2753
+ recordShutdownFailure(target.name, target.pid, 'daemon exited before initial exact IPC attestation');
2754
+ }
2755
+ }
2756
+ let attempts;
2757
+ try {
2758
+ attempts = requestAttestedDaemonShutdownBatch(authorized, loadDaemonIpcSecret());
2759
+ }
2760
+ catch (error) {
2761
+ attempts = authorized.map(target => ({ target, ok: false, error: String(error) }));
2762
+ }
2763
+ for (const attempt of attempts) {
2764
+ if (!attempt.ok) {
2765
+ recordShutdownFailure(attempt.target.name, attempt.target.pid, attempt.error ?? 'supervisor shutdown request refused');
2766
+ }
2767
+ }
2768
+ for (const target of targets.filter(entry => !isBotmuxDaemonProcessName(entry.name))) {
2769
+ signalDashboardResidual(target.name, target.pid);
2770
+ }
2771
+ },
2772
+ assertSignalAuthorityComplete: () => {
2773
+ if (shutdownRequestFailures.length > 0) {
2774
+ throw new Error(shutdownRequestFailures.join('; '));
2775
+ }
2776
+ },
2777
+ isAlive: pid => { try {
2778
+ process.kill(pid, 0);
2779
+ return true;
2780
+ }
2781
+ catch {
2782
+ return false;
2783
+ } },
2784
+ now: () => Date.now(),
2785
+ sleep: sleepSyncMs,
2786
+ startOffline: (offlineEntries, timeoutMs) => {
2787
+ runExactPm2Starts(offlineEntries, home, Math.min(10_000, timeoutMs));
2788
+ },
2789
+ list,
2790
+ successorSettleMs: FLEET_SUCCESSOR_SETTLE_MS,
2791
+ });
2348
2792
  }
2349
- // Reap pm2 entries. Processes are already dead → each delete is instant, and
2350
- // ONE batched `pm2 delete name1 name2 …` collapses N pm2 CLI cold-boots
2351
- // (~315ms each) into one. A revived (autorestart, gated by restart_delay)
2352
- // instance is still removed by name.
2353
- const batchTimeout = Math.max(15_000, names.length * 2_500);
2793
+ catch (error) {
2794
+ if (shutdownRequestFailures.length === 0)
2795
+ throw error;
2796
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; `
2797
+ + `shutdown request refusal(s): ${shutdownRequestFailures.join('; ')}`);
2798
+ }
2799
+ }
2800
+ function rollbackPm2StartAttempt(operation, before, candidateNames, home = PM2_HOME) {
2801
+ const candidateSet = new Set(candidateNames);
2802
+ reconcileLatePm2StartPublication(operation, PM2_START_LATE_PUBLICATION_SETTLE_MS, FLEET_DAEMON_EXIT_WAIT_MS + PM2_START_LATE_PUBLICATION_SETTLE_MS, {
2803
+ now: () => Date.now(),
2804
+ sleep: sleepSyncMs,
2805
+ reconcileOnce: () => {
2806
+ assertNoDuplicatePm2GodDaemons(home);
2807
+ const fresh = readVerifiedBotmuxPm2Projection(`${operation}-rollback-read`, home);
2808
+ assertNoUnregisteredLiveDaemonDescriptors(`${operation}-rollback-read`, fresh);
2809
+ assertCanonicalUniquePm2Rows(`${operation}-rollback-read`, fresh);
2810
+ const attemptedRows = fresh.filter(entry => candidateSet.has(entry.name));
2811
+ for (const row of attemptedRows) {
2812
+ const priorRows = before.filter(entry => entry.name === row.name);
2813
+ if (priorRows.length > 1
2814
+ || (priorRows.length === 1 && priorRows[0].pmId !== row.pmId)
2815
+ || (priorRows.length === 1 && isLivePm2Entry(priorRows[0]))) {
2816
+ throw new Error(`[${operation}] cannot prove ownership of partial-launch row ${row.name}/${row.pmId}`);
2817
+ }
2818
+ }
2819
+ const rowsNeedingCompensation = attemptedRows.filter(row => {
2820
+ const prior = before.find(entry => entry.name === row.name);
2821
+ if (!prior)
2822
+ return true;
2823
+ return isLivePm2Entry(row) || !isFleetEntryProvenFreeOfAutorestartTimer(row);
2824
+ });
2825
+ if (rowsNeedingCompensation.length > 0) {
2826
+ const shutdownRows = rowsNeedingCompensation.map(entry => {
2827
+ if (isLivePm2Entry(entry))
2828
+ return { ...entry, online: true };
2829
+ if (isFleetEntryProvenFreeOfAutorestartTimer(entry))
2830
+ return entry;
2831
+ return { ...entry, online: false, status: 'stopped', autorestart: false };
2832
+ });
2833
+ signalAndAwaitBotmuxProcesses(shutdownRows, 'stop', home);
2834
+ for (const original of rowsNeedingCompensation) {
2835
+ const exact = revalidateExactQuiescentRowBeforeMutation(`${operation}-rollback-before-mutation`, original, rowsNeedingCompensation, home);
2836
+ if (!exact)
2837
+ continue;
2838
+ const existedBefore = before.some(entry => entry.name === original.name && entry.pmId === original.pmId);
2839
+ runPm2([existedBefore ? 'stop' : 'delete', String(exact.pmId)], false, home, 10_000);
2840
+ }
2841
+ return false;
2842
+ }
2843
+ const restored = candidateNames.every(name => {
2844
+ const prior = before.find(entry => entry.name === name);
2845
+ const rows = attemptedRows.filter(entry => entry.name === name);
2846
+ if (!prior)
2847
+ return rows.length === 0;
2848
+ return rows.length === 1
2849
+ && rows[0].pmId === prior.pmId
2850
+ && !isLivePm2Entry(rows[0])
2851
+ && isFleetEntryProvenFreeOfAutorestartTimer(rows[0]);
2852
+ });
2853
+ if (!restored) {
2854
+ throw new Error(`[${operation}] rollback could not prove the pre-start registry shape`);
2855
+ }
2856
+ return true;
2857
+ },
2858
+ });
2859
+ }
2860
+ function deleteAllBotmuxProcesses(home = PM2_HOME, additionalDescriptorAuthority = []) {
2861
+ assertNoDuplicatePm2GodDaemons(home);
2862
+ let entries;
2354
2863
  try {
2355
- runPm2(['delete', ...names], false, home, batchTimeout);
2356
- return;
2864
+ entries = parsePm2JlistOutputStrict(pm2Capture(['jlist'], home))
2865
+ .filter(a => a && isBotmuxCoreProcessName(String(a.name)))
2866
+ .map(toBotmuxPm2ProcessEntry);
2357
2867
  }
2358
2868
  catch (e) {
2359
- // pm2's batched delete (async eachLimit) aborts on the first failed name,
2360
- // so a mid-batch failure can leave stragglers. Fall back to the resilient
2361
- // per-name loop that try/catches each name independently.
2362
- console.error(`[restart] batched pm2 delete failed, falling back to per-name: ${e instanceof Error ? e.message : e}`);
2869
+ throw new Error(`[restart] pm2 jlist failed; refusing to start a second fleet without a safe shutdown view: `
2870
+ + `${e instanceof Error ? e.message : e}`);
2363
2871
  }
2364
- for (const name of names) {
2872
+ assertNoUnregisteredLiveDaemonDescriptors('restart', [...entries, ...additionalDescriptorAuthority]);
2873
+ assertCanonicalUniquePm2Rows('restart', entries);
2874
+ if (entries.length === 0)
2875
+ return;
2876
+ const names = entries.map(e => e.name);
2877
+ signalAndAwaitBotmuxProcesses(entries, 'restart', home, additionalDescriptorAuthority);
2878
+ const deleteErrors = [];
2879
+ for (const entry of entries) {
2365
2880
  try {
2366
- runPm2(['delete', name], false, home, 10_000);
2881
+ const exact = revalidateExactQuiescentRowBeforeMutation('restart-before-delete', entry, entries, home, additionalDescriptorAuthority);
2882
+ if (!exact)
2883
+ continue;
2884
+ runPm2(['delete', String(exact.pmId)], false, home, 10_000);
2367
2885
  }
2368
2886
  catch (e) {
2369
- // Don't swallow silently — a failed delete here used to leave the
2370
- // restart half-done with no trace. Surface it (the auto-restart
2371
- // driver captures stderr to ~/.botmux/logs/maintenance-restart.log).
2372
- console.error(`[restart] pm2 delete ${name} failed: ${e instanceof Error ? e.message : e}`);
2887
+ const message = e instanceof Error ? e.message : String(e);
2888
+ deleteErrors.push(`${entry.name}: ${message}`);
2889
+ console.error(`[restart] pm2 delete ${entry.name}/${entry.pmId} failed: ${message}`);
2373
2890
  }
2374
2891
  }
2375
- }
2376
- function killPm2GodDaemon(home = PM2_HOME) {
2892
+ let remaining;
2377
2893
  try {
2378
- runPm2(['kill'], true, home, 15_000);
2379
- return;
2894
+ const targetNames = new Set(names);
2895
+ const freshProjection = parsePm2JlistOutputStrict(pm2Capture(['jlist'], home))
2896
+ .filter(app => app && isBotmuxCoreProcessName(String(app.name)))
2897
+ .map(toBotmuxPm2ProcessEntry);
2898
+ assertNoUnregisteredLiveDaemonDescriptors('restart-after-delete', [...freshProjection, ...additionalDescriptorAuthority]);
2899
+ exactQuiescentRowsForMutation('restart-after-delete', entries, freshProjection);
2900
+ remaining = freshProjection
2901
+ .filter(entry => targetNames.has(entry.name))
2902
+ .map(entry => entry.name);
2380
2903
  }
2381
- catch {
2382
- // Fall back to direct pid cleanup below.
2904
+ catch (err) {
2905
+ throw new Error(`[restart] PM2 delete verification failed; refusing to report a clean fleet: `
2906
+ + `${err instanceof Error ? err.message : String(err)}`);
2383
2907
  }
2384
- for (const pid of listPm2GodDaemonPids(home)) {
2385
- try {
2386
- process.kill(pid, 'SIGTERM');
2387
- }
2388
- catch { /* already gone */ }
2908
+ if (remaining.length > 0) {
2909
+ throw new Error(`[restart] PM2 delete left registry entries: ${[...new Set(remaining)].join(', ')}`
2910
+ + (deleteErrors.length > 0 ? ` (${deleteErrors.join('; ')})` : ''));
2389
2911
  }
2390
- for (const pid of listPm2GodDaemonPids(home)) {
2391
- try {
2392
- process.kill(pid, 0);
2393
- process.kill(pid, 'SIGKILL');
2912
+ }
2913
+ /**
2914
+ * Explicit first-upgrade escape hatch for a live fleet that predates the
2915
+ * authenticated shutdown protocol. This intentionally bypasses descriptor
2916
+ * capability attestation, but only behind a named flag plus --yes. Every PM2
2917
+ * delete is still bound to the exact name + pm_id + PID + process birth read
2918
+ * before the first mutation and revalidated immediately before that mutation.
2919
+ */
2920
+ function bootstrapDeleteAllBotmuxProcesses(operation, home = PM2_HOME) {
2921
+ assertNoDuplicatePm2GodDaemons(home);
2922
+ const readProjection = (phase) => {
2923
+ const apps = parsePm2JlistOutputStrict(pm2Capture(['jlist'], home));
2924
+ const projection = (Array.isArray(apps) ? apps : [])
2925
+ .filter(app => app && isBotmuxCoreProcessName(String(app.name)))
2926
+ .map(toBotmuxPm2ProcessEntry);
2927
+ assertCanonicalUniquePm2Rows(`${operation}-bootstrap-${phase}`, projection);
2928
+ return projection;
2929
+ };
2930
+ const entries = readProjection('initial');
2931
+ const identities = new Map();
2932
+ for (const entry of entries) {
2933
+ if (!Number.isInteger(entry.pmId)) {
2934
+ throw new Error(`[${operation}] bootstrap refused: ${entry.name} has no canonical PM2 id`);
2935
+ }
2936
+ if (entry.pid > 1 && isLivePm2Entry(entry)) {
2937
+ const identity = readSupervisorProcessStartIdentity(entry.pid);
2938
+ if (!identity) {
2939
+ throw new Error(`[${operation}] bootstrap refused: cannot bind ${entry.name}/${entry.pid} to a process birth`);
2940
+ }
2941
+ identities.set(entry.pmId, identity);
2942
+ }
2943
+ }
2944
+ for (const original of entries) {
2945
+ const fresh = readProjection(`before-delete-${original.pmId}`);
2946
+ const exact = fresh.filter(entry => entry.pmId === original.pmId);
2947
+ if (exact.length !== 1 || exact[0].name !== original.name) {
2948
+ throw new Error(`[${operation}] bootstrap refused: PM2 row ${original.name}/${original.pmId} changed before delete`);
2949
+ }
2950
+ const current = exact[0];
2951
+ const expectedBirth = identities.get(original.pmId);
2952
+ if (expectedBirth) {
2953
+ const currentBirth = current.pid > 1
2954
+ ? readSupervisorProcessStartIdentity(current.pid)
2955
+ : undefined;
2956
+ if (current.pid !== original.pid
2957
+ || !isLivePm2Entry(current)
2958
+ || currentBirth !== expectedBirth) {
2959
+ throw new Error(`[${operation}] bootstrap refused: process generation changed for `
2960
+ + `${original.name}/${original.pmId}`);
2961
+ }
2394
2962
  }
2395
- catch { /* already gone */ }
2963
+ else if (isLivePm2Entry(current)) {
2964
+ throw new Error(`[${operation}] bootstrap refused: dormant PM2 row ${original.name}/${original.pmId} became live`);
2965
+ }
2966
+ runPm2(['delete', String(current.pmId)], false, home, FLEET_DAEMON_EXIT_WAIT_MS);
2967
+ }
2968
+ const remaining = readProjection('after-delete');
2969
+ if (remaining.length > 0) {
2970
+ throw new Error(`[${operation}] bootstrap retirement incomplete: `
2971
+ + remaining.map(entry => `${entry.name}/${entry.pmId}`).join(', '));
2396
2972
  }
2397
2973
  }
2398
2974
  /**
@@ -2402,57 +2978,108 @@ function killPm2GodDaemon(home = PM2_HOME) {
2402
2978
  * truth. Only touches processes named `botmux` or `botmux-*` — the user's
2403
2979
  * unrelated pm2 apps are left untouched. No-op on fresh installs.
2404
2980
  */
2405
- function cleanupLegacyPm2() {
2981
+ function cleanupLegacyPm2(bootstrapOperation) {
2406
2982
  const legacyHome = join(homedir(), '.pm2');
2407
2983
  if (legacyHome === PM2_HOME)
2408
2984
  return false;
2409
- const legacyPidFile = join(legacyHome, 'pm2.pid');
2410
- if (!existsSync(legacyPidFile))
2411
- return false;
2412
- let legacyPid = 0;
2413
- try {
2414
- legacyPid = parseInt(readFileSync(legacyPidFile, 'utf-8').trim(), 10);
2415
- }
2416
- catch {
2417
- return false;
2418
- }
2419
- if (!legacyPid)
2420
- return false;
2421
- // If the legacy daemon isn't alive anymore there's nothing to clean.
2422
- try {
2423
- process.kill(legacyPid, 0);
2424
- }
2425
- catch {
2985
+ const legacyGodPids = listPm2GodDaemonPids(legacyHome);
2986
+ if (legacyGodPids.length === 0)
2426
2987
  return false;
2988
+ assertNoDuplicatePm2GodDaemons(legacyHome);
2989
+ preflightNodeSanity(legacyHome);
2990
+ assertNoDuplicatePm2GodDaemons(legacyHome);
2991
+ if (bootstrapOperation)
2992
+ bootstrapDeleteAllBotmuxProcesses(bootstrapOperation, legacyHome);
2993
+ else {
2994
+ const currentProjection = readVerifiedBotmuxPm2Projection('legacy-cleanup-authority');
2995
+ deleteAllBotmuxProcesses(legacyHome, currentProjection);
2427
2996
  }
2428
- deleteAllBotmuxProcesses(legacyHome);
2429
2997
  return true;
2430
2998
  }
2431
2999
  async function cmdStop() {
2432
3000
  const includePluginServices = process.argv.includes('--with-plugin');
2433
- killDuplicatePm2GodDaemons();
2434
- cleanupLegacyPm2();
2435
- let stopped = false;
2436
- try {
2437
- const output = pm2Capture(['jlist']);
2438
- const apps = parsePm2JlistOutput(output);
2439
- for (const app of apps) {
2440
- if (isBotmuxCoreProcessName(String(app.name))) {
2441
- try {
2442
- runPm2(['stop', app.name]);
2443
- stopped = true;
2444
- }
2445
- catch { /* */ }
3001
+ const bootstrapShutdownProtocol = process.argv.includes('--bootstrap-shutdown-protocol');
3002
+ const bootstrapConfirmed = process.argv.includes('--yes');
3003
+ if (bootstrapShutdownProtocol && !bootstrapConfirmed) {
3004
+ throw new Error('[stop] --bootstrap-shutdown-protocol requires --yes after confirming every Session/Riff workload is idle');
3005
+ }
3006
+ ensureConfigDir();
3007
+ await withFileLock(PM2_FLEET_MUTATION_LOCK_TARGET, async () => {
3008
+ assertNoDuplicatePm2GodDaemons();
3009
+ cleanupLegacyPm2(bootstrapShutdownProtocol ? 'stop' : undefined);
3010
+ if (bootstrapShutdownProtocol) {
3011
+ bootstrapDeleteAllBotmuxProcesses('stop');
3012
+ cleanupStaleDaemonDescriptors();
3013
+ if (includePluginServices) {
3014
+ await stopPluginServicesForCli(undefined, { autoOnly: true });
3015
+ }
3016
+ console.log('daemon 已通过一次性 shutdown-protocol bootstrap 安全边界停止。');
3017
+ return;
3018
+ }
3019
+ let entries;
3020
+ try {
3021
+ entries = parsePm2JlistOutputStrict(pm2Capture(['jlist']))
3022
+ .filter(app => app && isBotmuxCoreProcessName(String(app.name)))
3023
+ .map(toBotmuxPm2ProcessEntry);
3024
+ }
3025
+ catch (err) {
3026
+ throw new Error(`[stop] pm2 jlist failed; refusing an unverified stop: `
3027
+ + `${err instanceof Error ? err.message : String(err)}`);
3028
+ }
3029
+ assertNoUnregisteredLiveDaemonDescriptors('stop', entries);
3030
+ assertCanonicalUniquePm2Rows('stop', entries);
3031
+ if (entries.length === 0) {
3032
+ cleanupStaleDaemonDescriptors();
3033
+ if (includePluginServices) {
3034
+ await stopPluginServicesForCli(undefined, { autoOnly: true });
2446
3035
  }
3036
+ console.log('daemon 未在运行。');
3037
+ return;
2447
3038
  }
2448
- }
2449
- catch { /* */ }
2450
- // Wipe abandoned dashboard-daemon descriptors left behind by stopped daemons.
2451
- cleanupStaleDaemonDescriptors();
2452
- if (includePluginServices)
2453
- await stopPluginServicesForCli(undefined, { autoOnly: true });
2454
- if (!stopped)
2455
- console.log('daemon 未在运行。');
3039
+ signalAndAwaitBotmuxProcesses(entries, 'stop');
3040
+ const beforeStop = readVerifiedBotmuxPm2Projection('stop-before-registry-mutation');
3041
+ assertNoUnregisteredLiveDaemonDescriptors('stop-before-registry-mutation', beforeStop);
3042
+ exactQuiescentRowsForMutation('stop-before-registry-mutation', entries, beforeStop);
3043
+ const stopErrors = [];
3044
+ for (const entry of entries) {
3045
+ try {
3046
+ const exact = revalidateExactQuiescentRowBeforeMutation('stop-immediately-before-registry-mutation', entry, entries);
3047
+ if (!exact)
3048
+ continue;
3049
+ runPm2(['stop', String(exact.pmId)], false, PM2_HOME, 10_000);
3050
+ }
3051
+ catch (err) {
3052
+ const message = err instanceof Error ? err.message : String(err);
3053
+ stopErrors.push(`${entry.name}: ${message}`);
3054
+ console.error(`[stop] pm2 stop ${entry.name} failed: ${message}`);
3055
+ }
3056
+ }
3057
+ let nonStoppedResidual;
3058
+ try {
3059
+ const targetNames = new Set(entries.map(entry => entry.name));
3060
+ const freshProjection = parsePm2JlistOutputStrict(pm2Capture(['jlist']))
3061
+ .filter(app => app && isBotmuxCoreProcessName(String(app.name)))
3062
+ .map(toBotmuxPm2ProcessEntry);
3063
+ assertNoUnregisteredLiveDaemonDescriptors('stop-after-registry-mutation', freshProjection);
3064
+ exactQuiescentRowsForMutation('stop-after-registry-mutation', entries, freshProjection);
3065
+ nonStoppedResidual = freshProjection
3066
+ .filter(entry => targetNames.has(entry.name) && entry.status !== 'stopped')
3067
+ .map(entry => `${entry.name}:${entry.status ?? 'unknown'}`);
3068
+ }
3069
+ catch (err) {
3070
+ throw new Error(`[stop] PM2 stop verification failed: ${err instanceof Error ? err.message : String(err)}`);
3071
+ }
3072
+ if (stopErrors.length > 0 || nonStoppedResidual.length > 0) {
3073
+ throw new Error(`[stop] PM2 registry mutation incomplete`
3074
+ + (nonStoppedResidual.length > 0
3075
+ ? ` (not stopped: ${[...new Set(nonStoppedResidual)].join(', ')})`
3076
+ : '')
3077
+ + (stopErrors.length > 0 ? ` (errors: ${stopErrors.join('; ')})` : ''));
3078
+ }
3079
+ cleanupStaleDaemonDescriptors();
3080
+ if (includePluginServices)
3081
+ await stopPluginServicesForCli(undefined, { autoOnly: true });
3082
+ }, { maxWaitMs: 5_000 });
2456
3083
  }
2457
3084
  async function cmdRestart() {
2458
3085
  const restartLeaseId = process.env.BOTMUX_RESTART_LEASE_ID;
@@ -2475,70 +3102,157 @@ async function cmdRestart() {
2475
3102
  process.exit(1);
2476
3103
  }
2477
3104
  ensureConfigDir();
2478
- const includePm2 = process.argv.includes('--include-pm2');
2479
- const includePluginServices = process.argv.includes('--with-plugin');
2480
- // Drop a restart-intent breadcrumb so the fresh daemon knows this was an
2481
- // intentional restart and DMs the owner a summary. `IfAbsent` preserves a
2482
- // richer breadcrumb (update / auto-restart) already written by the
2483
- // maintenance timer that spawned this very `botmux restart`. A pm2
2484
- // crash-autorestart bypasses this path → no breadcrumb → silent.
2485
- try {
2486
- const now = Date.now();
2487
- writeManualIntentIfAbsentTo(resolveDataDir(), now, new Date(now).toISOString());
2488
- }
2489
- catch { /* breadcrumb is best-effort */ }
2490
- killDuplicatePm2GodDaemons();
2491
- preflightNodeSanity();
2492
- await ensureSystemDependencies();
2493
- const cfg = ecosystemConfig();
2494
- cleanupLegacyPm2();
2495
- // Delete all botmux processes (handles both old single-process and new multi-process)
2496
- deleteAllBotmuxProcesses();
2497
- if (includePluginServices)
2498
- await stopPluginServicesForCli(undefined, { autoOnly: true });
2499
- if (includePm2) {
2500
- killPm2GodDaemon();
2501
- }
2502
- // Wipe abandoned dashboard-daemon descriptors left behind by killed daemons.
2503
- cleanupStaleDaemonDescriptors();
2504
- runPm2(['start', cfg]);
2505
- // Default restart preserves running plugin services, then ensures every auto
2506
- // service is online. --with-plugin changes only the pre-restart side above:
2507
- // it explicitly stops auto services first, so this ensure becomes a restart.
2508
- await reconcilePluginServicesForCli(undefined, { autoOnly: true });
2509
- if (refreshAutostart({ pkgRoot: PKG_ROOT, configDir: CONFIG_DIR, logDir: LOG_DIR })) {
2510
- console.log(`autostart unit 已同步到当前 Node/cli.js 路径`);
2511
- }
2512
- await printDashboardHintWithRetry();
3105
+ await withFileLock(PM2_FLEET_MUTATION_LOCK_TARGET, async () => {
3106
+ const includePm2 = process.argv.includes('--include-pm2');
3107
+ const includePluginServices = process.argv.includes('--with-plugin');
3108
+ const bootstrapShutdownProtocol = process.argv.includes('--bootstrap-shutdown-protocol');
3109
+ const bootstrapConfirmed = process.argv.includes('--yes');
3110
+ if (bootstrapShutdownProtocol && !bootstrapConfirmed) {
3111
+ throw new Error('[restart] --bootstrap-shutdown-protocol requires --yes after confirming every Session/Riff workload is idle');
3112
+ }
3113
+ if (bootstrapShutdownProtocol && includePm2) {
3114
+ throw new Error('[restart] --bootstrap-shutdown-protocol cannot be combined with --include-pm2');
3115
+ }
3116
+ if (includePm2) {
3117
+ assertIncludePm2RestartAdmission(listPm2GodDaemonPids());
3118
+ }
3119
+ const restartIntentDir = resolveDataDir();
3120
+ let stagedRestartIntent = null;
3121
+ try {
3122
+ stagedRestartIntent = consumeRestartIntentTo(restartIntentDir, Date.now());
3123
+ }
3124
+ catch { /* intent reporting is best-effort */ }
3125
+ assertNoDuplicatePm2GodDaemons();
3126
+ preflightNodeSanity();
3127
+ await ensureSystemDependencies();
3128
+ cleanupLegacyPm2(bootstrapShutdownProtocol ? 'restart' : undefined);
3129
+ if (bootstrapShutdownProtocol)
3130
+ bootstrapDeleteAllBotmuxProcesses('restart');
3131
+ else
3132
+ deleteAllBotmuxProcesses();
3133
+ if (includePluginServices)
3134
+ await stopPluginServicesForCli(undefined, { autoOnly: true });
3135
+ cleanupStaleDaemonDescriptors();
3136
+ const retiredProjection = readVerifiedBotmuxPm2Projection('restart-start');
3137
+ assertNoUnregisteredLiveDaemonDescriptors('restart-start', retiredProjection);
3138
+ if (retiredProjection.length > 0) {
3139
+ throw new Error(`[restart-start] new PM2 core row(s) appeared after verified retirement: `
3140
+ + retiredProjection.map(entry => `${entry.name}:${entry.pid}`).join(', '));
3141
+ }
3142
+ await withFileLock(BOTS_JSON_FILE, async () => {
3143
+ const restartBots = loadBotsJson();
3144
+ const cfg = ecosystemConfig(restartBots);
3145
+ const restartAttemptId = randomBytes(16).toString('hex');
3146
+ let restartIntentPrepared = false;
3147
+ try {
3148
+ const now = Date.now();
3149
+ writeRestartAttemptIntentTo(restartIntentDir, stagedRestartIntent ?? { kind: 'manual', at: new Date(now).toISOString() }, now, restartAttemptId);
3150
+ restartIntentPrepared = true;
3151
+ }
3152
+ catch { /* breadcrumb is best-effort */ }
3153
+ try {
3154
+ const configuredNames = configuredCoreProcessNames(restartBots);
3155
+ const verifyTimeoutMs = pm2StartVerifyTimeoutMs(configuredNames.length);
3156
+ runBoundedPm2StartTransaction('restart-start', PM2_START_COMMAND_TIMEOUT_MS, verifyTimeoutMs, {
3157
+ start: timeoutMs => {
3158
+ assertBotsConfigSnapshotUnchanged('restart-start', restartBots);
3159
+ assertNoDuplicatePm2GodDaemons();
3160
+ preflightNodeSanity();
3161
+ runPm2(['start', cfg], true, PM2_HOME, timeoutMs);
3162
+ },
3163
+ verifyFresh: timeoutMs => readAndAssertConfiguredFleetOnline('restart-after-launch', configuredNames, PM2_HOME, timeoutMs),
3164
+ rollback: () => rollbackPm2StartAttempt('restart-start', retiredProjection, configuredNames),
3165
+ });
3166
+ }
3167
+ catch (err) {
3168
+ try {
3169
+ removeRestartIntentAttemptTo(restartIntentDir, restartAttemptId);
3170
+ }
3171
+ catch { /* best-effort */ }
3172
+ throw err;
3173
+ }
3174
+ if (restartIntentPrepared) {
3175
+ let committed = false;
3176
+ try {
3177
+ committed = commitRestartIntentAttemptTo(restartIntentDir, restartAttemptId);
3178
+ }
3179
+ catch { /* best-effort after verified healthy fleet */ }
3180
+ if (!committed) {
3181
+ try {
3182
+ removeRestartIntentAttemptTo(restartIntentDir, restartAttemptId);
3183
+ }
3184
+ catch { /* best-effort */ }
3185
+ console.warn('⚠️ daemon 已完整启动,但重启摘要凭据未能提交;本次不会发送重启摘要。');
3186
+ }
3187
+ }
3188
+ }, { maxWaitMs: 5_000 });
3189
+ await reconcilePluginServicesForCli(undefined, { autoOnly: true });
3190
+ if (refreshAutostart({ pkgRoot: PKG_ROOT, configDir: CONFIG_DIR, logDir: LOG_DIR })) {
3191
+ console.log(`autostart unit 已同步到当前 Node/cli.js 路径`);
3192
+ }
3193
+ await printDashboardHintWithRetry();
3194
+ }, { maxWaitMs: 5_000 });
2513
3195
  }
2514
3196
  /** Observe botmux PM2 rows. Unknown state is never represented as absence. */
2515
3197
  function listBotmuxPm2Apps() {
2516
3198
  return inspectBotmuxPm2Apps(() => parsePm2JlistOutput(pm2Capture(['jlist'])));
2517
3199
  }
2518
- function ensureBotDaemonStopped(appId, opts = {}) {
2519
- const bots = loadBotsJson();
2520
- const index = bots.findIndex(b => b?.larkAppId === appId);
2521
- if (index < 0) {
2522
- return { ok: false, reason: 'not_found', message: `appId ${appId} 不在 bots.json 中` };
3200
+ function retireExactBotmuxProcess(operation, target, fullProjection) {
3201
+ const peers = fullProjection.filter(entry => entry.name !== target.name);
3202
+ signalAndAwaitBotmuxProcesses([target], 'stop', PM2_HOME, peers);
3203
+ const exact = revalidateExactQuiescentRowBeforeMutation(`${operation}-before-delete`, target, [target], PM2_HOME, peers);
3204
+ if (exact) {
3205
+ runPm2(['delete', String(exact.pmId)], false, PM2_HOME, 10_000);
2523
3206
  }
2524
- const processName = botProcessName(bots[index], index, PM2_NAME);
2525
- const running = listBotmuxPm2Apps();
2526
- if (!running.ok) {
2527
- return { ok: false, reason: 'pm2_error', message: running.message };
3207
+ const fresh = readVerifiedBotmuxPm2Projection(`${operation}-after-delete`);
3208
+ assertNoUnregisteredLiveDaemonDescriptors(`${operation}-after-delete`, fresh);
3209
+ if (fresh.some(entry => entry.name === target.name)) {
3210
+ throw new Error(`[${operation}] PM2 row ${target.name} is still present after exact delete`);
2528
3211
  }
2529
- const named = running.apps.filter(app => app.name === processName);
2530
- if (named.length > 0 && !named.some(app => isExactPm2BotActivationReceipt(app, processName, index, appId))) {
3212
+ }
3213
+ async function ensureBotDaemonStopped(appId, _opts = {}) {
3214
+ ensureConfigDir();
3215
+ try {
3216
+ return await withFileLock(PM2_FLEET_MUTATION_LOCK_TARGET, async () => (withFileLock(BOTS_JSON_FILE, async () => {
3217
+ assertNoDuplicatePm2GodDaemons();
3218
+ preflightNodeSanity();
3219
+ cleanupLegacyPm2();
3220
+ const bots = loadBotsJson();
3221
+ const index = bots.findIndex(b => b?.larkAppId === appId);
3222
+ if (index < 0) {
3223
+ return { ok: false, reason: 'not_found', message: `appId ${appId} 不在 bots.json 中` };
3224
+ }
3225
+ const processName = botProcessName(bots[index], index, PM2_NAME);
3226
+ const inspection = listBotmuxPm2Apps();
3227
+ if (!inspection.ok) {
3228
+ return { ok: false, reason: 'pm2_error', message: inspection.message };
3229
+ }
3230
+ const named = inspection.apps.filter(app => app.name === processName);
3231
+ if (named.length > 0 && !named.every(app => isExactPm2BotActivationReceipt(app, processName, index, appId))) {
3232
+ return {
3233
+ ok: false,
3234
+ reason: 'pm2_error',
3235
+ message: `pm2 process ${processName} does not match bots.json slot ${index} / ${appId}`,
3236
+ };
3237
+ }
3238
+ const projection = readVerifiedBotmuxPm2Projection('stop-bot');
3239
+ assertNoUnregisteredLiveDaemonDescriptors('stop-bot', projection);
3240
+ assertCanonicalUniquePm2Rows('stop-bot', projection);
3241
+ const target = projection.find(entry => entry.name === processName);
3242
+ if (!target) {
3243
+ return { ok: true, state: 'already-stopped', processName };
3244
+ }
3245
+ retireExactBotmuxProcess('stop-bot', target, projection);
3246
+ return { ok: true, state: 'stopped', processName };
3247
+ }, { maxWaitMs: 5_000 })), { maxWaitMs: 5_000 });
3248
+ }
3249
+ catch (err) {
2531
3250
  return {
2532
3251
  ok: false,
2533
3252
  reason: 'pm2_error',
2534
- message: `pm2 process ${processName} does not match bots.json slot ${index} / ${appId}`,
3253
+ message: err instanceof Error ? err.message : String(err),
2535
3254
  };
2536
3255
  }
2537
- return stopExactPm2Process(processName, listBotmuxPm2Apps, exactName => {
2538
- // Delete only this exact PM2 entry. The later start-bot command recreates
2539
- // it from the current ecosystem config after activation is acknowledged.
2540
- runPm2(['delete', exactName], !opts.quiet);
2541
- });
2542
3256
  }
2543
3257
  /**
2544
3258
  * Bring a SINGLE bot's daemon online without touching any other bot's process.
@@ -2559,99 +3273,126 @@ function ensureBotDaemonStopped(appId, opts = {}) {
2559
3273
  * NOT start a lone bot; that case belongs to `botmux start`, which brings up the
2560
3274
  * entire ecosystem (all bots + dashboard).
2561
3275
  */
2562
- function ensureBotDaemonStarted(appId, opts = {}) {
2563
- const bots = loadBotsJson();
2564
- const index = bots.findIndex(b => b?.larkAppId === appId);
2565
- if (index < 0) {
2566
- return { ok: false, reason: 'not_found', message: `appId ${appId} 不在 bots.json 中` };
2567
- }
2568
- const bot = bots[index];
2569
- if (bot?.activationPending === true) {
2570
- return { ok: false, reason: 'not_ready', message: `appId ${appId} is still activation pending` };
2571
- }
2572
- const activationStarting = bot?.activationStarting;
2573
- const activationCommitted = bot?.activationCommitted;
2574
- const activationDeactivating = bot?.activationDeactivating;
2575
- if (activationDeactivating !== undefined) {
2576
- return { ok: false, reason: 'not_ready', message: `appId ${appId} is still deactivating` };
2577
- }
2578
- if (activationStarting !== undefined && activationCommitted !== undefined) {
2579
- return { ok: false, reason: 'not_ready', message: `appId ${appId} has conflicting activation markers` };
2580
- }
2581
- const activationMarker = activationStarting ?? activationCommitted;
2582
- const activationJobId = (activationMarker
2583
- && typeof activationMarker === 'object'
2584
- && !Array.isArray(activationMarker)
2585
- && activationMarker.appId === appId
2586
- && typeof activationMarker.jobId === 'string'
2587
- && activationMarker.jobId)
2588
- ? activationMarker.jobId
2589
- : undefined;
2590
- if (activationMarker !== undefined && !activationJobId) {
2591
- return { ok: false, reason: 'not_ready', message: `appId ${appId} has an invalid activation marker` };
2592
- }
2593
- const processName = botProcessName(bot, index, PM2_NAME);
2594
- const running = listBotmuxPm2Apps();
2595
- if (!running.ok) {
2596
- return { ok: false, reason: 'pm2_error', message: running.message };
2597
- }
2598
- if (running.apps.length === 0) {
2599
- return { ok: false, reason: 'fleet_down', message: 'daemon 未在运行,请先 botmux start' };
2600
- }
2601
- const named = running.apps.filter(app => app.name === processName);
2602
- if (!activationJobId && named.some(app => (isExactPm2BotActivationReceipt(app, processName, index, appId) && app.online))) {
2603
- return { ok: true, state: 'already-online', processName };
2604
- }
2605
- if (activationJobId && named.length > 0) {
2606
- const disposition = managedActivationPm2Disposition(named, processName, index, appId, activationJobId);
2607
- if (disposition === 'acknowledged') {
2608
- return { ok: true, state: 'already-online', processName };
2609
- }
2610
- if (disposition === 'identity_mismatch') {
2611
- return {
2612
- ok: false,
2613
- reason: 'pm2_error',
2614
- message: `pm2 process ${processName} does not match bots.json slot ${index} / ${appId}`,
2615
- };
2616
- }
2617
- }
2618
- else if (named.length > 0) {
2619
- return {
2620
- ok: false,
2621
- reason: 'pm2_error',
2622
- message: `pm2 process ${processName} does not match bots.json slot ${index} / ${appId}`,
2623
- };
2624
- }
2625
- if (activationJobId && named.length > 0) {
2626
- // A same App/index process without this durable receipt can be an old
2627
- // daemon already consuming Feishu traffic. Delete it and prove absence
2628
- // before the gated activation daemon is allowed to become the PM2 ACK.
2629
- const stopped = stopExactPm2Process(processName, listBotmuxPm2Apps, exactName => runPm2(['delete', exactName], !opts.quiet));
2630
- if (!stopped.ok) {
2631
- return { ok: false, reason: 'pm2_error', message: stopped.message };
2632
- }
2633
- }
2634
- const cfg = ecosystemConfig(appId);
3276
+ async function ensureBotDaemonStarted(appId, opts = {}) {
3277
+ ensureConfigDir();
2635
3278
  try {
2636
- // `--only <name>` filters the ecosystem to just this app, so pm2 starts only
2637
- // the new bot's daemon and never restarts the already-online ones.
2638
- runPm2(['start', cfg, '--only', processName], !opts.quiet);
2639
- }
2640
- catch (e) {
2641
- return { ok: false, reason: 'pm2_error', message: e instanceof Error ? e.message : String(e) };
2642
- }
2643
- const acknowledged = listBotmuxPm2Apps();
2644
- if (!acknowledged.ok) {
2645
- return { ok: false, reason: 'pm2_error', message: acknowledged.message };
3279
+ return await withFileLock(PM2_FLEET_MUTATION_LOCK_TARGET, async () => (withFileLock(BOTS_JSON_FILE, async () => {
3280
+ assertNoDuplicatePm2GodDaemons();
3281
+ preflightNodeSanity();
3282
+ cleanupLegacyPm2();
3283
+ const bots = loadBotsJson();
3284
+ const index = bots.findIndex(b => b?.larkAppId === appId);
3285
+ if (index < 0) {
3286
+ return { ok: false, reason: 'not_found', message: `appId ${appId} 不在 bots.json 中` };
3287
+ }
3288
+ const bot = bots[index];
3289
+ if (bot?.activationPending === true) {
3290
+ return { ok: false, reason: 'not_ready', message: `appId ${appId} is still activation pending` };
3291
+ }
3292
+ const activationStarting = bot?.activationStarting;
3293
+ const activationCommitted = bot?.activationCommitted;
3294
+ const activationDeactivating = bot?.activationDeactivating;
3295
+ if (activationDeactivating !== undefined) {
3296
+ return { ok: false, reason: 'not_ready', message: `appId ${appId} is still deactivating` };
3297
+ }
3298
+ if (activationStarting !== undefined && activationCommitted !== undefined) {
3299
+ return { ok: false, reason: 'not_ready', message: `appId ${appId} has conflicting activation markers` };
3300
+ }
3301
+ const activationMarker = activationStarting ?? activationCommitted;
3302
+ const activationJobId = (activationMarker
3303
+ && typeof activationMarker === 'object'
3304
+ && !Array.isArray(activationMarker)
3305
+ && activationMarker.appId === appId
3306
+ && typeof activationMarker.jobId === 'string'
3307
+ && activationMarker.jobId)
3308
+ ? String(activationMarker.jobId)
3309
+ : undefined;
3310
+ if (activationMarker !== undefined && !activationJobId) {
3311
+ return { ok: false, reason: 'not_ready', message: `appId ${appId} has an invalid activation marker` };
3312
+ }
3313
+ const processName = botProcessName(bot, index, PM2_NAME);
3314
+ const configuredNames = configuredCoreProcessNames(bots, activationJobId ? appId : undefined);
3315
+ const verifyTimeoutMs = pm2StartVerifyTimeoutMs(configuredNames.length);
3316
+ const inspection = listBotmuxPm2Apps();
3317
+ if (!inspection.ok) {
3318
+ return { ok: false, reason: 'pm2_error', message: inspection.message };
3319
+ }
3320
+ let projection = readVerifiedBotmuxPm2Projection('start-bot');
3321
+ assertNoUnregisteredLiveDaemonDescriptors('start-bot', projection);
3322
+ assertCanonicalUniquePm2Rows('start-bot', projection);
3323
+ const namedInspection = inspection.apps.filter(app => app.name === processName);
3324
+ if (activationJobId && namedInspection.length > 0) {
3325
+ const disposition = managedActivationPm2Disposition(namedInspection, processName, index, appId, activationJobId);
3326
+ if (disposition === 'identity_mismatch') {
3327
+ return {
3328
+ ok: false,
3329
+ reason: 'pm2_error',
3330
+ message: `pm2 process ${processName} does not match bots.json slot ${index} / ${appId}`,
3331
+ };
3332
+ }
3333
+ if (disposition === 'acknowledged') {
3334
+ readAndAssertConfiguredFleetOnline('start-bot-already-online-ready', configuredNames, PM2_HOME, verifyTimeoutMs);
3335
+ return { ok: true, state: 'already-online', processName };
3336
+ }
3337
+ const target = projection.find(entry => entry.name === processName);
3338
+ if (!target) {
3339
+ throw new Error(`[start-bot] PM2 identity view disagrees about ${processName}`);
3340
+ }
3341
+ retireExactBotmuxProcess('start-bot-replace', target, projection);
3342
+ projection = readVerifiedBotmuxPm2Projection('start-bot-after-replace');
3343
+ }
3344
+ else if (namedInspection.length > 0) {
3345
+ if (!namedInspection.every(app => isExactPm2BotActivationReceipt(app, processName, index, appId))) {
3346
+ return {
3347
+ ok: false,
3348
+ reason: 'pm2_error',
3349
+ message: `pm2 process ${processName} does not match bots.json slot ${index} / ${appId}`,
3350
+ };
3351
+ }
3352
+ }
3353
+ const admission = classifyStartBotFleetAdmission('start-bot', projection, configuredNames, processName, pid => { try {
3354
+ process.kill(pid, 0);
3355
+ return true;
3356
+ }
3357
+ catch {
3358
+ return false;
3359
+ } });
3360
+ if (admission.state === 'already-online') {
3361
+ readAndAssertConfiguredFleetOnline('start-bot-already-online-ready', configuredNames, PM2_HOME, verifyTimeoutMs);
3362
+ return { ok: true, state: 'already-online', processName };
3363
+ }
3364
+ if (admission.state === 'fleet-down') {
3365
+ return { ok: false, reason: 'fleet_down', message: 'daemon 未在运行,请先 botmux start' };
3366
+ }
3367
+ const beforeStart = projection;
3368
+ const cfg = ecosystemConfig(bots, activationJobId ? appId : undefined);
3369
+ runBoundedPm2StartTransaction('start-bot', PM2_START_COMMAND_TIMEOUT_MS, verifyTimeoutMs, {
3370
+ start: timeoutMs => {
3371
+ assertBotsConfigSnapshotUnchanged('start-bot', bots);
3372
+ assertNoDuplicatePm2GodDaemons();
3373
+ preflightNodeSanity();
3374
+ runPm2(['start', cfg, '--only', processName], !opts.quiet, PM2_HOME, timeoutMs);
3375
+ },
3376
+ verifyFresh: timeoutMs => {
3377
+ const fresh = readAndAssertConfiguredFleetOnline('start-bot-after-launch', configuredNames, PM2_HOME, timeoutMs);
3378
+ const acknowledged = listBotmuxPm2Apps();
3379
+ if (!acknowledged.ok || !acknowledged.apps.some(app => (isExactPm2BotActivationReceipt(app, processName, index, appId, activationJobId) && app.online))) {
3380
+ throw new Error(`pm2 start did not acknowledge ${processName} at bots.json slot ${index} / ${appId}`);
3381
+ }
3382
+ return fresh;
3383
+ },
3384
+ rollback: () => rollbackPm2StartAttempt('start-bot', beforeStart, [processName]),
3385
+ });
3386
+ return { ok: true, state: 'started', processName };
3387
+ }, { maxWaitMs: 5_000 })), { maxWaitMs: 5_000 });
2646
3388
  }
2647
- if (!acknowledged.apps.some(app => (isExactPm2BotActivationReceipt(app, processName, index, appId, activationJobId) && app.online))) {
3389
+ catch (err) {
2648
3390
  return {
2649
3391
  ok: false,
2650
3392
  reason: 'pm2_error',
2651
- message: `pm2 start did not acknowledge ${processName} at bots.json slot ${index} / ${appId}`,
3393
+ message: err instanceof Error ? err.message : String(err),
2652
3394
  };
2653
3395
  }
2654
- return { ok: true, state: 'started', processName };
2655
3396
  }
2656
3397
  /**
2657
3398
  * `botmux start-bot <larkAppId>` — bring one freshly-added bot online without a
@@ -2671,7 +3412,7 @@ async function cmdStartBot(argv) {
2671
3412
  process.exit(1);
2672
3413
  }
2673
3414
  ensureConfigDir();
2674
- const r = ensureBotDaemonStarted(appId, { quiet: wantsJson });
3415
+ const r = await ensureBotDaemonStarted(appId, { quiet: wantsJson });
2675
3416
  if (wantsJson) {
2676
3417
  console.log(JSON.stringify(r, null, 2));
2677
3418
  if (!r.ok)
@@ -2706,7 +3447,7 @@ async function cmdStopBot(argv) {
2706
3447
  process.exit(1);
2707
3448
  }
2708
3449
  ensureConfigDir();
2709
- const r = ensureBotDaemonStopped(appId, { quiet: wantsJson });
3450
+ const r = await ensureBotDaemonStopped(appId, { quiet: wantsJson });
2710
3451
  if (wantsJson) {
2711
3452
  console.log(JSON.stringify(r, null, 2));
2712
3453
  if (!r.ok)
@@ -2725,8 +3466,8 @@ async function cmdStopBot(argv) {
2725
3466
  /** Print the post-add "next step" line for interactive setup: auto-start the new
2726
3467
  * bot's own daemon when the fleet is up (no fleet-wide restart), else fall back
2727
3468
  * to the botmux start / restart hint. */
2728
- function printAddBotLiveHint(appId) {
2729
- const live = ensureBotDaemonStarted(appId);
3469
+ async function printAddBotLiveHint(appId) {
3470
+ const live = await ensureBotDaemonStarted(appId);
2730
3471
  if (live.ok) {
2731
3472
  console.log(`✅ 已自动上线(${live.processName}),无需重启其它机器人。\n`);
2732
3473
  }
@@ -2815,7 +3556,6 @@ function warnIfLegacyBotmuxAlive() {
2815
3556
  catch { /* ignore */ }
2816
3557
  }
2817
3558
  function cmdLogs() {
2818
- killDuplicatePm2GodDaemons();
2819
3559
  warnIfLegacyBotmuxAlive();
2820
3560
  const lines = process.argv.includes('--lines')
2821
3561
  ? process.argv[process.argv.indexOf('--lines') + 1] || '50'
@@ -2854,7 +3594,6 @@ function cmdLogs() {
2854
3594
  child.on('exit', code => process.exit(code ?? 0));
2855
3595
  }
2856
3596
  function cmdStatus() {
2857
- killDuplicatePm2GodDaemons();
2858
3597
  warnIfLegacyBotmuxAlive();
2859
3598
  runPm2(['status']);
2860
3599
  }
@@ -3028,86 +3767,339 @@ function loadSessions() {
3028
3767
  if (process.env.BOTMUX_LARK_APP_ID)
3029
3768
  loadPerBot(process.env.BOTMUX_LARK_APP_ID);
3030
3769
  }
3031
- // Migrate: remove sessions from legacy file if they have larkAppId (belong in per-bot files)
3032
- let legacyDirty = false;
3033
- for (const [k, v] of Object.entries(legacyData)) {
3034
- const s = v;
3035
- if (s.larkAppId) {
3036
- delete legacyData[k];
3037
- legacyDirty = true;
3038
- // Ensure the session exists in its per-bot file
3039
- const perBotFp = join(dataDir, `sessions-${s.larkAppId}.json`);
3040
- let perBotData = {};
3041
- if (existsSync(perBotFp)) {
3042
- try {
3043
- perBotData = JSON.parse(readFileSync(perBotFp, 'utf-8'));
3770
+ // Deliberately read-only. Older CLIs opportunistically migrated legacy rows
3771
+ // here, which made even `botmux list` a whole-file writer capable of racing a
3772
+ // daemon ledger save. Durable migration belongs to daemon startup under the
3773
+ // session-store lock; this generic discovery path only composes snapshots.
3774
+ return sessions;
3775
+ }
3776
+ function loadSessionFresh(session) {
3777
+ return loadSessions().get(session.sessionId);
3778
+ }
3779
+ /** Save a single session back to its appropriate file based on larkAppId. */
3780
+ function saveSession(session) {
3781
+ const dataDir = resolveDataDir();
3782
+ const fileName = session.larkAppId ? `sessions-${session.larkAppId}.json` : 'sessions.json';
3783
+ const fp = join(dataDir, fileName);
3784
+ // Read current file, update session, write back
3785
+ let data = {};
3786
+ if (existsSync(fp)) {
3787
+ try {
3788
+ data = JSON.parse(readFileSync(fp, 'utf-8'));
3789
+ }
3790
+ catch { /* start fresh */ }
3791
+ }
3792
+ data[session.sessionId] = session;
3793
+ // Clean up entries where file key doesn't match the entry's sessionId (data
3794
+ // corruption), and strip legacy placeholder-card fields so the file converges
3795
+ // to clean (see stripLegacyPendingCardFields in services/session-store).
3796
+ for (const [key, val] of Object.entries(data)) {
3797
+ if (val && typeof val === 'object' && 'sessionId' in val && val.sessionId !== key) {
3798
+ delete data[key];
3799
+ continue;
3800
+ }
3801
+ if (val && typeof val === 'object')
3802
+ stripLegacyPendingCardFields(val);
3803
+ }
3804
+ const tmpFp = fp + '.tmp';
3805
+ writeFileSync(tmpFp, JSON.stringify(data, null, 2), 'utf-8');
3806
+ renameSync(tmpFp, fp);
3807
+ // Remove duplicate from legacy file if session moved to per-bot file (or vice versa)
3808
+ const otherFile = session.larkAppId ? 'sessions.json' : null;
3809
+ if (otherFile) {
3810
+ const otherFp = join(dataDir, otherFile);
3811
+ if (existsSync(otherFp)) {
3812
+ try {
3813
+ const otherData = JSON.parse(readFileSync(otherFp, 'utf-8'));
3814
+ if (otherData[session.sessionId]) {
3815
+ delete otherData[session.sessionId];
3816
+ const otherTmp = otherFp + '.tmp';
3817
+ writeFileSync(otherTmp, JSON.stringify(otherData, null, 2), 'utf-8');
3818
+ renameSync(otherTmp, otherFp);
3819
+ }
3820
+ }
3821
+ catch { /* ignore */ }
3822
+ }
3823
+ }
3824
+ }
3825
+ /** Offline-only narrow session mutation. Callers must prefer the owning daemon
3826
+ * while it is available; rereading the exact row here prevents stale CLI
3827
+ * snapshots from copying FIFO fields during offline maintenance. */
3828
+ function mutateSessionOffline(session, mutate) {
3829
+ const dataDir = resolveDataDir();
3830
+ const fileName = session.larkAppId ? `sessions-${session.larkAppId}.json` : 'sessions.json';
3831
+ const fp = join(dataDir, fileName);
3832
+ return withFileLockSync(fp, () => {
3833
+ // The owning daemon uses the same session-file lock for every save. Check
3834
+ // absence while holding it so two CLIs cannot race each other and an
3835
+ // already-published daemon cannot be bypassed by the offline fallback.
3836
+ if (session.larkAppId) {
3837
+ try {
3838
+ if (findDaemon(session.larkAppId))
3839
+ return undefined;
3840
+ }
3841
+ catch { /* offline */ }
3842
+ }
3843
+ let data = {};
3844
+ if (existsSync(fp)) {
3845
+ try {
3846
+ data = JSON.parse(readFileSync(fp, 'utf-8'));
3847
+ }
3848
+ catch { /* start fresh */ }
3849
+ }
3850
+ const current = data[session.sessionId];
3851
+ if (!current || !mutate(current))
3852
+ return current;
3853
+ data[session.sessionId] = current;
3854
+ // Clean up entries where file key doesn't match the entry's sessionId
3855
+ // (data corruption), and strip removed placeholder-card fields.
3856
+ for (const [key, val] of Object.entries(data)) {
3857
+ if (val && typeof val === 'object' && 'sessionId' in val && val.sessionId !== key) {
3858
+ delete data[key];
3859
+ continue;
3860
+ }
3861
+ if (val && typeof val === 'object')
3862
+ stripLegacyPendingCardFields(val);
3863
+ }
3864
+ // Recheck immediately before publication. A daemon that appears during
3865
+ // the read/decision phase becomes authoritative; leave the file untouched.
3866
+ if (session.larkAppId) {
3867
+ try {
3868
+ if (findDaemon(session.larkAppId))
3869
+ return undefined;
3870
+ }
3871
+ catch { /* offline */ }
3872
+ }
3873
+ const tmpFp = `${fp}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
3874
+ writeFileSync(tmpFp, JSON.stringify(data, null, 2), 'utf-8');
3875
+ renameSync(tmpFp, fp);
3876
+ return current;
3877
+ });
3878
+ }
3879
+ /** Offline explicit abandon stops the exact Botmux-owned worker, then closes
3880
+ * the newest durable row under the shared session-file lock. Provider-specific
3881
+ * backing cleanup remains owned by the dedicated backend lifecycle changes. */
3882
+ async function abandonSessionOffline(session) {
3883
+ let current = mutateSessionOffline(session, () => false);
3884
+ if (!current)
3885
+ return { ok: false, error: 'owning_daemon_became_available' };
3886
+ const originalPid = adoptedCliPid(current);
3887
+ const ownedWorkerPid = current.pid && current.pid !== originalPid ? current.pid : undefined;
3888
+ if (ownedWorkerPid) {
3889
+ // Narrow the unavoidable descriptor race: do not signal a worker after an
3890
+ // owning daemon has become visible. The locked write below repeats this.
3891
+ if (current.larkAppId) {
3892
+ try {
3893
+ if (findDaemon(current.larkAppId)) {
3894
+ return { ok: false, error: 'owning_daemon_became_available' };
3895
+ }
3896
+ }
3897
+ catch { /* still offline */ }
3898
+ }
3899
+ if (isProcessAlive(ownedWorkerPid)) {
3900
+ const signalled = killProcess(ownedWorkerPid);
3901
+ if ((!signalled && isProcessAlive(ownedWorkerPid))
3902
+ || !(await waitForProcessExit(ownedWorkerPid))) {
3903
+ return { ok: false, error: `worker_pid_kill_failed:${ownedWorkerPid}` };
3904
+ }
3905
+ }
3906
+ // Persist worker-less state without touching FIFO authority. If a new
3907
+ // daemon/generation changed the row while SIGTERM settled, fail closed.
3908
+ let workerCleared = false;
3909
+ const afterStop = mutateSessionOffline(current, latest => {
3910
+ if (latest.pid !== ownedWorkerPid
3911
+ || isAdoptedSession(latest) !== isAdoptedSession(current))
3912
+ return false;
3913
+ delete latest.pid;
3914
+ workerCleared = true;
3915
+ return true;
3916
+ });
3917
+ if (!afterStop || !workerCleared) {
3918
+ return { ok: false, error: 'session_changed_while_stopping_worker' };
3919
+ }
3920
+ current = afterStop;
3921
+ }
3922
+ else {
3923
+ // Even without a Botmux worker pid, re-read after the first authority check
3924
+ // so the cleanup inputs are the newest locked backend/task lineage.
3925
+ const refreshed = mutateSessionOffline(current, () => false);
3926
+ if (!refreshed)
3927
+ return { ok: false, error: 'owning_daemon_became_available' };
3928
+ current = refreshed;
3929
+ }
3930
+ // Provider-specific backing teardown (no daemon to run killWorker()). Adopted
3931
+ // panes belong to the user and are never touched. This runs BEFORE the durable
3932
+ // close so a ZMX ownership-tag mismatch fails closed: refusing to hide the sole
3933
+ // control row while its CLI is still alive under a different owner.
3934
+ let cleanedBacking;
3935
+ if (!isAdoptedSession(current)) {
3936
+ if (isSuspendableBackendType(current.backendType)) {
3937
+ const target = resolvePersistentBackendTarget(current.backendType, current.sessionId, current.persistentBackendTarget);
3938
+ try {
3939
+ killPersistentBackendTarget(target, current.sessionId);
3940
+ cleanedBacking = `${target.backendType}:${target.sessionName}`;
3941
+ }
3942
+ catch (err) {
3943
+ // ZMX destruction is identity-verified against the complete botmux
3944
+ // session UUID and waits for confirmed disappearance. Swallowing an
3945
+ // inconclusive/mismatched kill would hide the sole control row while
3946
+ // its CLI remains alive. Older mux backends keep their historical
3947
+ // best-effort offline-close compatibility.
3948
+ if (target.backendType === 'zmx') {
3949
+ return {
3950
+ ok: false,
3951
+ error: `ZMX 离线删除未完成:${err instanceof Error ? err.message : String(err)}`,
3952
+ };
3044
3953
  }
3045
- catch { /* */ }
3046
3954
  }
3047
- // Only write if per-bot file doesn't already have this session
3048
- if (!perBotData[k]) {
3049
- perBotData[k] = s;
3050
- const tmpFp = perBotFp + '.tmp';
3051
- writeFileSync(tmpFp, JSON.stringify(perBotData, null, 2), 'utf-8');
3052
- renameSync(tmpFp, perBotFp);
3955
+ }
3956
+ else {
3957
+ // Legacy rows without backendType were historically tmux-backed.
3958
+ const tmuxName = `bmx-${current.sessionId.substring(0, 8)}`;
3959
+ try {
3960
+ execSync(`tmux kill-session -t '${tmuxName}' 2>/dev/null`, {
3961
+ stdio: 'ignore',
3962
+ env: tmuxEnv(),
3963
+ });
3053
3964
  }
3965
+ catch { /* no tmux session */ }
3054
3966
  }
3055
3967
  }
3056
- if (legacyDirty) {
3057
- const tmpFp = legacyFp + '.tmp';
3058
- writeFileSync(tmpFp, JSON.stringify(legacyData, null, 2), 'utf-8');
3059
- renameSync(tmpFp, legacyFp);
3968
+ let applied = false;
3969
+ const published = mutateSessionOffline(current, latest => {
3970
+ if (isAdoptedSession(latest) !== isAdoptedSession(current))
3971
+ return false;
3972
+ if (latest.status === 'closed') {
3973
+ applied = true;
3974
+ return false;
3975
+ }
3976
+ latest.status = 'closed';
3977
+ latest.closedAt = new Date().toISOString();
3978
+ delete latest.codexAppDispatchLedger;
3979
+ delete latest.codexAppGenerationCommits;
3980
+ delete latest.queuedActivationPending;
3981
+ delete latest.queuedActivationTail;
3982
+ delete latest.pendingRepoSetup;
3983
+ applied = true;
3984
+ return true;
3985
+ });
3986
+ if (!published || !applied) {
3987
+ return { ok: false, error: 'session_changed_during_offline_cleanup' };
3060
3988
  }
3061
- return sessions;
3989
+ return { ok: true, current: published, ...(cleanedBacking ? { cleanedBacking } : {}) };
3062
3990
  }
3063
- /** Save a single session back to its appropriate file based on larkAppId. */
3064
- function loadSessionFresh(session) {
3065
- return loadSessions().get(session.sessionId);
3991
+ function pruneSessionOfflineIfLedgerEmpty(session) {
3992
+ let pruned = false;
3993
+ mutateSessionOffline(session, current => {
3994
+ if (hasProtectedSessionMutationOwnership(current))
3995
+ return false;
3996
+ current.status = 'closed';
3997
+ current.closedAt = new Date().toISOString();
3998
+ delete current.codexAppDispatchLedger;
3999
+ delete current.codexAppGenerationCommits;
4000
+ pruned = true;
4001
+ return true;
4002
+ });
4003
+ return pruned;
3066
4004
  }
3067
- function saveSession(session) {
3068
- const dataDir = resolveDataDir();
3069
- const fileName = session.larkAppId ? `sessions-${session.larkAppId}.json` : 'sessions.json';
3070
- const fp = join(dataDir, fileName);
3071
- // Read current file, update session, write back
3072
- let data = {};
3073
- if (existsSync(fp)) {
3074
- try {
3075
- data = JSON.parse(readFileSync(fp, 'utf-8'));
3076
- }
3077
- catch { /* start fresh */ }
4005
+ function patchSessionWhiteboardOffline(session, whiteboardId) {
4006
+ return !!mutateSessionOffline(session, current => {
4007
+ current.whiteboardId = whiteboardId;
4008
+ return true;
4009
+ });
4010
+ }
4011
+ async function postOwningDaemonSessionMutation(session, suffix, body) {
4012
+ if (!session.larkAppId)
4013
+ return 'unavailable';
4014
+ let daemon;
4015
+ try {
4016
+ daemon = findDaemon(session.larkAppId);
3078
4017
  }
3079
- data[session.sessionId] = session;
3080
- // Clean up entries where file key doesn't match the entry's sessionId (data
3081
- // corruption), and strip legacy placeholder-card fields so the file converges
3082
- // to clean (see stripLegacyPendingCardFields in services/session-store).
3083
- for (const [key, val] of Object.entries(data)) {
3084
- if (val && typeof val === 'object' && 'sessionId' in val && val.sessionId !== key) {
3085
- delete data[key];
3086
- continue;
3087
- }
3088
- if (val && typeof val === 'object')
3089
- stripLegacyPendingCardFields(val);
4018
+ catch {
4019
+ return 'unavailable';
3090
4020
  }
3091
- const tmpFp = fp + '.tmp';
3092
- writeFileSync(tmpFp, JSON.stringify(data, null, 2), 'utf-8');
3093
- renameSync(tmpFp, fp);
3094
- // Remove duplicate from legacy file if session moved to per-bot file (or vice versa)
3095
- const otherFile = session.larkAppId ? 'sessions.json' : null;
3096
- if (otherFile) {
3097
- const otherFp = join(dataDir, otherFile);
3098
- if (existsSync(otherFp)) {
4021
+ if (!daemon)
4022
+ return 'unavailable';
4023
+ let secret;
4024
+ try {
4025
+ secret = loadDaemonIpcSecret();
4026
+ }
4027
+ catch {
4028
+ return 'unavailable';
4029
+ }
4030
+ const res = await fetchDaemonIpc(daemon.ipcPort, `/api/sessions/${encodeURIComponent(session.sessionId)}/${suffix}`, {
4031
+ method: 'POST',
4032
+ ...(body
4033
+ ? {
4034
+ headers: { 'content-type': 'application/json' },
4035
+ body: JSON.stringify(body),
4036
+ }
4037
+ : {}),
4038
+ }, secret);
4039
+ if (suffix === 'prune' && res.status === 409)
4040
+ return 'refused';
4041
+ if (!res.ok) {
4042
+ throw new Error(`owning daemon ${suffix} mutation failed: HTTP ${res.status}`);
4043
+ }
4044
+ return 'applied';
4045
+ }
4046
+ async function abandonSessionAuthoritatively(session, online = listOnlineDaemons()) {
4047
+ // Legacy larkAppId-less rows live in sessions.json; a per-bot daemon writes
4048
+ // only its own sessions-<appId>.json and silently no-ops on close, so keep
4049
+ // them on the offline path (which persists to the legacy file correctly).
4050
+ if (session.larkAppId) {
4051
+ const daemon = online.find(d => d.larkAppId === session.larkAppId);
4052
+ const isCurrentSession = process.env.BOTMUX_SESSION_ID === session.sessionId;
4053
+ const injectedPort = isCurrentSession
4054
+ ? resolveDaemonIpcPort(undefined, process.env.BOTMUX_DAEMON_IPC_PORT)
4055
+ : undefined;
4056
+ const ipcPort = daemon?.ipcPort ?? injectedPort;
4057
+ if (ipcPort) {
3099
4058
  try {
3100
- const otherData = JSON.parse(readFileSync(otherFp, 'utf-8'));
3101
- if (otherData[session.sessionId]) {
3102
- delete otherData[session.sessionId];
3103
- const otherTmp = otherFp + '.tmp';
3104
- writeFileSync(otherTmp, JSON.stringify(otherData, null, 2), 'utf-8');
3105
- renameSync(otherTmp, otherFp);
3106
- }
4059
+ // Explicit abandon boundary: route through the owning daemon so the
4060
+ // ledger FIFO is cleared atomically with close. postSessionCliIpc carries
4061
+ // the trusted-host HMAC (non-isolated) OR this session's rotating origin
4062
+ // capability (sandboxed/read-isolated) so a sandboxed `delete <other-id>`
4063
+ // stays fail-closed at the daemon's sessionCliIpcAuth check.
4064
+ const res = await postSessionCliIpc(ipcPort, session.sessionId, 'close', {});
4065
+ const body = await res.json().catch(() => ({}));
4066
+ if (res.ok && body.ok)
4067
+ return { ok: true, mode: 'daemon' };
4068
+ // Surface the daemon's own rejection reason (e.g. origin_unproven) and
4069
+ // never fall back to a partial local kill: a fresh descriptor means the
4070
+ // daemon may still hold authoritative in-memory state.
4071
+ return { ok: false, error: body.error ?? `HTTP ${res.status}` };
4072
+ }
4073
+ catch (err) {
4074
+ return { ok: false, error: `连接 daemon 失败: ${err instanceof Error ? err.message : String(err)}` };
3107
4075
  }
3108
- catch { /* ignore */ }
3109
4076
  }
3110
4077
  }
4078
+ const offline = await abandonSessionOffline(session);
4079
+ return offline.ok
4080
+ ? {
4081
+ ok: true,
4082
+ mode: 'offline',
4083
+ current: offline.current,
4084
+ ...(offline.cleanedBacking ? { cleanedBacking: offline.cleanedBacking } : {}),
4085
+ }
4086
+ : { ok: false, error: offline.error };
4087
+ }
4088
+ async function pruneSessionAuthoritatively(session) {
4089
+ const result = await postOwningDaemonSessionMutation(session, 'prune');
4090
+ if (result === 'applied')
4091
+ return true;
4092
+ if (result === 'refused')
4093
+ return false;
4094
+ return pruneSessionOfflineIfLedgerEmpty(session);
4095
+ }
4096
+ async function patchSessionWhiteboardAuthoritatively(session, whiteboardId) {
4097
+ const result = await postOwningDaemonSessionMutation(session, 'whiteboard', { whiteboardId });
4098
+ if (result === 'applied')
4099
+ return true;
4100
+ if (result === 'refused')
4101
+ return false;
4102
+ return patchSessionWhiteboardOffline(session, whiteboardId);
3111
4103
  }
3112
4104
  function isProcessAlive(pid) {
3113
4105
  try {
@@ -3127,6 +4119,15 @@ function killProcess(pid) {
3127
4119
  return false;
3128
4120
  }
3129
4121
  }
4122
+ async function waitForProcessExit(pid, timeoutMs = 2_000) {
4123
+ const deadline = Date.now() + timeoutMs;
4124
+ while (Date.now() < deadline) {
4125
+ if (!isProcessAlive(pid))
4126
+ return true;
4127
+ await new Promise(resolve => setTimeout(resolve, 50));
4128
+ }
4129
+ return !isProcessAlive(pid);
4130
+ }
3130
4131
  function formatDuration(ms) {
3131
4132
  const seconds = Math.floor(ms / 1000);
3132
4133
  if (seconds < 60)
@@ -3140,31 +4141,38 @@ function formatDuration(ms) {
3140
4141
  const days = Math.floor(hours / 24);
3141
4142
  return `${days}d${hours % 24}h`;
3142
4143
  }
3143
- /** Get display width of a string, accounting for CJK double-width characters. */
4144
+ /**
4145
+ * Get display width of a string in terminal cells.
4146
+ *
4147
+ * Delegates to a cross-terminal conservative width table (see terminal-width.ts):
4148
+ * for any real terminal the width returned is >= what it paints, so the picker's
4149
+ * `layoutWidth <= termWidth` genuinely implies rows never wrap. The previous
4150
+ * inline table only covered CJK/Hangul and under-counted emoji (🤖/🫠 as 1),
4151
+ * letting emoji session titles overflow a row, wrap onto a second physical line,
4152
+ * and push the pinned title off the alt-screen.
4153
+ *
4154
+ * NOTE: cursor-moving control chars (Tab/ESC/C0/C1) are NOT sized here — width
4155
+ * can't express a tab stop. Run text through `sanitizeCellText` first.
4156
+ */
3144
4157
  function displayWidth(str) {
3145
- let width = 0;
3146
- for (const ch of str) {
3147
- const code = ch.codePointAt(0);
3148
- // CJK Unified Ideographs, CJK Compatibility, Fullwidth forms, Hangul, Kana, etc.
3149
- if ((code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
3150
- (code >= 0x2e80 && code <= 0x303e) || // CJK Radicals, Kangxi, CJK Symbols
3151
- (code >= 0x3040 && code <= 0x33bf) || // Hiragana, Katakana, Bopomofo, CJK Compat
3152
- (code >= 0x3400 && code <= 0x4dbf) || // CJK Unified Ext A
3153
- (code >= 0x4e00 && code <= 0xa4cf) || // CJK Unified, Yi
3154
- (code >= 0xac00 && code <= 0xd7af) || // Hangul Syllables
3155
- (code >= 0xf900 && code <= 0xfaff) || // CJK Compat Ideographs
3156
- (code >= 0xfe30 && code <= 0xfe6f) || // CJK Compat Forms
3157
- (code >= 0xff01 && code <= 0xff60) || // Fullwidth Forms
3158
- (code >= 0xffe0 && code <= 0xffe6) || // Fullwidth Signs
3159
- (code >= 0x20000 && code <= 0x2fa1f) // CJK Unified Ext B-F, Compat Supplement
3160
- ) {
3161
- width += 2;
3162
- }
3163
- else {
3164
- width += 1;
3165
- }
3166
- }
3167
- return width;
4158
+ return terminalCellWidth(str);
4159
+ }
4160
+ /**
4161
+ * Strip everything that would move the cursor or otherwise desync column math
4162
+ * from dynamic text (session titles, working dirs, flash messages) before it is
4163
+ * measured or printed. A raw Tab jumps to the next tab stop and a raw ESC starts
4164
+ * a control sequence — both make a "one physical line" cell silently span more
4165
+ * columns (or lines) than displayWidth accounts for, wrapping the row and
4166
+ * pushing the pinned title off screen. Collapse them all to a single space:
4167
+ * - C0 controls incl. Tab/CR/LF (U+0000–U+001F) and DEL (U+007F);
4168
+ * - C1 controls (U+0080–U+009F);
4169
+ * - stray ESC (U+001B) is covered by the C0 range.
4170
+ * (ANSI colour sequences the picker itself emits are added AFTER sanitizing, so
4171
+ * they are never fed through here.)
4172
+ */
4173
+ function sanitizeCellText(str) {
4174
+ // eslint-disable-next-line no-control-regex
4175
+ return str.replace(/[\x00-\x1F\x7F-\x9F]+/g, " ");
3168
4176
  }
3169
4177
  /** Truncate string to fit within maxWidth display columns, append '…' if truncated. */
3170
4178
  function truncate(str, maxWidth) {
@@ -3530,27 +4538,7 @@ function interactiveSessionPicker(active, probeSnapshot) {
3530
4538
  const b = botConfigs[i];
3531
4539
  botLabels.set(b.larkAppId, `bot${i + 1} (${b.cliId ?? 'claude-code'})`);
3532
4540
  }
3533
- // Responsive column widths based on terminal width
3534
- const termWidth = process.stdout.columns || 100;
3535
- const PREFIX = 4; // " ❯ " or " "
3536
- const SEP_W = 3; // " │ "
3537
- const fixedCols = { id: 10, pid: 8, uptime: 7, status: 7, target: 26 };
3538
- const botW = multiBot ? 18 : 0;
3539
- const numSeps = (multiBot ? 8 : 7) - 1; // separators between columns
3540
- const fixedTotal = PREFIX + fixedCols.id + botW + fixedCols.pid + fixedCols.uptime + fixedCols.status + fixedCols.target + numSeps * SEP_W;
3541
- const flexTotal = Math.max(20, termWidth - fixedTotal);
3542
- const titleW = Math.floor(flexTotal * 0.4);
3543
- const dirW = flexTotal - titleW;
3544
- const cols = {
3545
- id: fixedCols.id,
3546
- ...(multiBot ? { bot: botW } : {}),
3547
- title: titleW,
3548
- dir: dirW,
3549
- pid: fixedCols.pid,
3550
- uptime: fixedCols.uptime,
3551
- status: fixedCols.status,
3552
- target: fixedCols.target,
3553
- };
4541
+ let layout = computeSessionPickerLayout(process.stdout.columns || 100, multiBot);
3554
4542
  // Build row data — use shortened paths for TUI
3555
4543
  function buildRows() {
3556
4544
  return active.map(s => {
@@ -3559,25 +4547,30 @@ function interactiveSessionPicker(active, probeSnapshot) {
3559
4547
  ? { probe: 'missing', label: adoptTargetLabel(s) }
3560
4548
  : sessionBackingInfo(s, probeSnapshot);
3561
4549
  const targetLabel = backing.label;
3562
- // Build row text with shortened dir
3563
- const id = padEndDisplay(s.sessionId.substring(0, 8), cols.id);
3564
- const parts = [id];
3565
- if (multiBot) {
3566
- const label = s.larkAppId ? (botLabels.get(s.larkAppId) ?? s.larkAppId.substring(0, 16)) : '-';
3567
- parts.push(padEndDisplay(truncate(label, cols.bot), cols.bot));
3568
- }
3569
- const title = padEndDisplay(truncate((s.title || '(untitled)').replace(/[\r\n]+/g, ' '), cols.title), cols.title);
3570
- const dir = padEndDisplay(truncate(shortenPath(s.workingDir || '-'), cols.dir), cols.dir);
3571
4550
  const displayPid = sessionDisplayPid(s);
3572
- const pid = displayPid ? String(displayPid).padEnd(cols.pid) : '-'.padEnd(cols.pid);
3573
- const uptime = formatDuration(Date.now() - new Date(s.createdAt).getTime()).padEnd(cols.uptime);
3574
4551
  const alive = isSessionAliveForList(s);
3575
- const status = padEndDisplay(sessionStatusLabel(s), cols.status);
3576
- const target = padEndDisplay(truncate(targetLabel, cols.target), cols.target);
3577
- parts.push(title, dir, pid, uptime, status, target);
4552
+ const botLabel = s.larkAppId ? (botLabels.get(s.larkAppId) ?? s.larkAppId.substring(0, 16)) : '-';
4553
+ const cells = {
4554
+ id: s.sessionId.substring(0, 8),
4555
+ bot: botLabel,
4556
+ title: s.title || '(untitled)',
4557
+ dir: shortenPath(s.workingDir || '-'),
4558
+ pid: displayPid ? String(displayPid) : '-',
4559
+ uptime: formatDuration(Date.now() - new Date(s.createdAt).getTime()),
4560
+ status: sessionStatusLabel(s),
4561
+ target: targetLabel,
4562
+ };
4563
+ const text = layout.columns
4564
+ .map(column => {
4565
+ // Sanitize every cell (not just title): a Tab/ESC/control char in any
4566
+ // dynamic field would move the cursor and desync the one-line-per-row math.
4567
+ const value = sanitizeCellText(cells[column.key]);
4568
+ return padEndDisplay(truncate(value, column.width), column.width);
4569
+ })
4570
+ .join(' │ ');
3578
4571
  return {
3579
4572
  session: s,
3580
- text: parts.join(' │ '),
4573
+ text,
3581
4574
  alive,
3582
4575
  backendTarget: 'target' in backing ? backing.target : undefined,
3583
4576
  backingProbe: backing.probe,
@@ -3594,62 +4587,168 @@ function interactiveSessionPicker(active, probeSnapshot) {
3594
4587
  let rows = buildRows();
3595
4588
  // Build header (same column layout as rows, no extra prefix in join)
3596
4589
  function buildHeader() {
3597
- const hParts = ['id'.padEnd(cols.id)];
3598
- if (multiBot)
3599
- hParts.push('bot'.padEnd(cols.bot));
3600
- hParts.push('title'.padEnd(cols.title), 'working dir'.padEnd(cols.dir), 'pid'.padEnd(cols.pid), 'uptime'.padEnd(cols.uptime), 'status'.padEnd(cols.status), 'target'.padEnd(cols.target));
3601
- return hParts.join(' │ ');
4590
+ const labels = {
4591
+ id: 'id',
4592
+ bot: 'bot',
4593
+ title: 'title',
4594
+ dir: 'working dir',
4595
+ pid: 'pid',
4596
+ uptime: 'uptime',
4597
+ status: 'status',
4598
+ target: 'target',
4599
+ };
4600
+ return layout.columns
4601
+ .map(column => padEndDisplay(truncate(labels[column.key], column.width), column.width))
4602
+ .join(' │ ');
3602
4603
  }
3603
- const header = buildHeader();
3604
- const separator = '─'.repeat(displayWidth(header));
4604
+ let header = buildHeader();
4605
+ let separator = '─'.repeat(displayWidth(header));
3605
4606
  let cursor = 0;
4607
+ let scrollTop = 0; // index of the first visible row (vertical scroll)
3606
4608
  let confirmDelete = false; // true when waiting for y/n confirmation
3607
- let flashMsg = '';
4609
+ let flash = null;
4610
+ // Fixed chrome around the scrolling row window: title(2) + header block(3) +
4611
+ // bottom separator(1) + target hint(2) + flash/confirm(2) + hints(2) + a
4612
+ // one-line safety margin so the final newline never scrolls the pinned title
4613
+ // off the alt-screen. Everything else is the row viewport.
4614
+ const CHROME_ROWS = 13;
4615
+ let sepWidth = displayWidth(separator);
4616
+ function rebuildLayout() {
4617
+ layout = computeSessionPickerLayout(process.stdout.columns || 100, multiBot);
4618
+ rows = buildRows();
4619
+ header = buildHeader();
4620
+ separator = '─'.repeat(displayWidth(header));
4621
+ sepWidth = displayWidth(separator);
4622
+ }
4623
+ // Overlay a "N 更多" marker onto a separator line without changing its display
4624
+ // width, so the up/down hidden-row counters cost no extra vertical lines.
4625
+ const sepWithMarker = (marker) => {
4626
+ const label = ` ${marker} `;
4627
+ const lw = displayWidth(label);
4628
+ if (lw + 4 > sepWidth)
4629
+ return separator;
4630
+ return `──${label}${'─'.repeat(sepWidth - 2 - lw)}`;
4631
+ };
4632
+ const fitLine = (text, width) => {
4633
+ if (width <= 0)
4634
+ return '';
4635
+ const singleLine = sanitizeCellText(text);
4636
+ return displayWidth(singleLine) <= width ? singleLine : truncate(singleLine, width);
4637
+ };
4638
+ // The ONLY place footer SGR colour is applied. `text` is treated as untrusted:
4639
+ // it is sanitized (control chars stripped) and width-fitted first, then wrapped
4640
+ // in a fixed whitelist SGR from `style`. No caller-supplied escape can survive,
4641
+ // and there is no fast path that returns a raw/mixed string — closing the two
4642
+ // bypasses where a session's target label / flash message could inject e.g.
4643
+ // `\x1b[2J` (clear screen) into the footer and scroll the pinned title away.
4644
+ const SGR = {
4645
+ error: '\x1b[31m',
4646
+ success: '\x1b[32m',
4647
+ warn: '\x1b[33m',
4648
+ dim: '\x1b[2m',
4649
+ };
4650
+ const styledFooter = (style, text, width) => `${SGR[style]}${fitLine(text, width)}\x1b[0m`;
4651
+ const blankRowPrefix = () => ' '.repeat(layout.prefixWidth);
4652
+ const rowPrefix = (selected) => {
4653
+ if (!selected)
4654
+ return blankRowPrefix();
4655
+ const pointer = '\x1b[36m❯\x1b[0m';
4656
+ if (layout.prefixWidth >= 4)
4657
+ return ` ${pointer} `;
4658
+ if (layout.prefixWidth === 3)
4659
+ return ` ${pointer} `;
4660
+ if (layout.prefixWidth === 2)
4661
+ return `${pointer} `;
4662
+ return layout.prefixWidth === 1 ? pointer : '';
4663
+ };
4664
+ const footerPrefixWidth = () => Math.min(2, layout.termWidth);
4665
+ const footerContentWidth = () => Math.max(0, layout.termWidth - footerPrefixWidth());
4666
+ const footerPrefix = () => ' '.repeat(footerPrefixWidth());
3608
4667
  function render() {
3609
4668
  process.stdout.write('\x1b[H\x1b[J');
3610
- process.stdout.write(`\x1b[1m botmux sessions\x1b[0m \x1b[2m(${rows.length})\x1b[0m\n\n`);
3611
- // Header + separator — use same 4-char prefix as rows
3612
- process.stdout.write(` ${separator}\n`);
3613
- process.stdout.write(` \x1b[2m${header}\x1b[0m\n`);
3614
- process.stdout.write(` ${separator}\n`);
4669
+ const posLabel = rows.length > 0 ? `${cursor + 1}/${rows.length}` : '0';
4670
+ const titleText = ` botmux sessions (${posLabel})`;
4671
+ if (displayWidth(titleText) <= layout.termWidth) {
4672
+ process.stdout.write(`\x1b[1m botmux sessions\x1b[0m \x1b[2m(${posLabel})\x1b[0m\n\n`);
4673
+ }
4674
+ else {
4675
+ process.stdout.write(`\x1b[1m${fitLine(titleText, layout.termWidth)}\x1b[0m\n\n`);
4676
+ }
3615
4677
  if (rows.length === 0) {
3616
- process.stdout.write(`\n \x1b[2m没有活跃会话\x1b[0m\n`);
3617
- process.stdout.write(` ${separator}\n`);
3618
- process.stdout.write(`\n \x1b[2mq 退出\x1b[0m\n`);
4678
+ process.stdout.write(`${blankRowPrefix()}${separator}\n`);
4679
+ process.stdout.write(`${blankRowPrefix()}\x1b[2m${header}\x1b[0m\n`);
4680
+ process.stdout.write(`${blankRowPrefix()}${separator}\n`);
4681
+ process.stdout.write(`\n${blankRowPrefix()}\x1b[2m${fitLine('没有活跃会话', Math.max(0, layout.termWidth - layout.prefixWidth))}\x1b[0m\n`);
4682
+ process.stdout.write(`${blankRowPrefix()}${separator}\n`);
4683
+ process.stdout.write(`\n${footerPrefix()}\x1b[2m${fitLine('q 退出', footerContentWidth())}\x1b[0m\n`);
3619
4684
  return;
3620
4685
  }
3621
- for (let i = 0; i < rows.length; i++) {
4686
+ // Vertical viewport: render only the window of rows that fits the terminal
4687
+ // height, scrolling to keep the cursor visible. Without this a long session
4688
+ // list overflows the alt-screen and pushes the title/header/top rows off it.
4689
+ const win = computeSessionPickerScrollWindow({
4690
+ cursor,
4691
+ scrollTop,
4692
+ rowCount: rows.length,
4693
+ termRows: process.stdout.rows || 24,
4694
+ chromeRows: CHROME_ROWS,
4695
+ });
4696
+ scrollTop = win.scrollTop;
4697
+ const { viewEnd, hiddenAbove, hiddenBelow } = win;
4698
+ // Header + separator — use the same responsive prefix as rows.
4699
+ process.stdout.write(`${blankRowPrefix()}${separator}\n`);
4700
+ process.stdout.write(`${blankRowPrefix()}\x1b[2m${header}\x1b[0m\n`);
4701
+ process.stdout.write(hiddenAbove > 0
4702
+ ? `${blankRowPrefix()}\x1b[36m${sepWithMarker(`↑ ${hiddenAbove} 更多`)}\x1b[0m\n`
4703
+ : `${blankRowPrefix()}${separator}\n`);
4704
+ for (let i = scrollTop; i < viewEnd; i++) {
3622
4705
  const r = rows[i];
3623
- const pointer = i === cursor ? '\x1b[36m❯\x1b[0m' : ' ';
3624
4706
  if (i === cursor) {
3625
- process.stdout.write(` ${pointer} \x1b[7m${r.text}\x1b[0m\n`);
4707
+ process.stdout.write(`${rowPrefix(true)}\x1b[7m${r.text}\x1b[0m\n`);
3626
4708
  }
3627
4709
  else {
3628
- process.stdout.write(` ${pointer} ${r.text}\n`);
4710
+ process.stdout.write(`${rowPrefix(false)}${r.text}\n`);
3629
4711
  }
3630
4712
  }
3631
- process.stdout.write(` ${separator}\n`);
3632
- // Footer info
4713
+ process.stdout.write(hiddenBelow > 0
4714
+ ? `${blankRowPrefix()}\x1b[36m${sepWithMarker(`↓ ${hiddenBelow} 更多`)}\x1b[0m\n`
4715
+ : `${blankRowPrefix()}${separator}\n`);
4716
+ // Footer info. Every dynamic field (targetLabel, backend session name) is
4717
+ // untrusted session metadata, so it only ever reaches the terminal via
4718
+ // styledFooter (sanitize → fit → whitelist SGR). The adopt hint's fixed
4719
+ // suffix is trusted static text, appended after the fitted label.
3633
4720
  const selected = rows[cursor];
3634
- const targetHint = selected.isAdopt
3635
- ? `\x1b[33m${selected.targetLabel}\x1b[0m \x1b[2mEnter 已禁用;请直接使用原 tmux/zellij/herdr 客户端。\x1b[0m`
3636
- : selected.canAttach
3637
- ? `\x1b[32m${selected.attachBackend}: ${selected.backendTarget?.sessionName}\x1b[0m`
3638
- : `\x1b[2m${selected.targetLabel}(不可连接)\x1b[0m`;
3639
- process.stdout.write(`\n ${targetHint}\n`);
4721
+ const width = footerContentWidth();
4722
+ let footerLine;
4723
+ if (selected.isAdopt) {
4724
+ const suffix = ' Enter 已禁用;请直接使用原 tmux/zellij/herdr 客户端。';
4725
+ const labelWidth = Math.max(0, width - displayWidth(suffix));
4726
+ footerLine = `${styledFooter('warn', selected.targetLabel, labelWidth)}\x1b[2m${fitLine(suffix, width)}\x1b[0m`;
4727
+ }
4728
+ else if (selected.canAttach) {
4729
+ footerLine = styledFooter('success', `${selected.attachBackend}: ${selected.backendTarget?.sessionName}`, width);
4730
+ }
4731
+ else {
4732
+ footerLine = styledFooter('dim', `${selected.targetLabel}(不可连接)`, width);
4733
+ }
4734
+ process.stdout.write(`\n${footerPrefix()}${footerLine}\n`);
3640
4735
  // Flash message or confirmation prompt
3641
4736
  if (confirmDelete) {
3642
4737
  const s = selected.session;
3643
- process.stdout.write(`\n \x1b[33m确认删除 ${s.sessionId.substring(0, 8)} "${truncate(s.title || '', 20)}"? (y/n)\x1b[0m\n`);
4738
+ const confirmation = `确认删除 ${s.sessionId.substring(0, 8)} "${truncate(s.title || '', 20)}"? (y/n)`;
4739
+ process.stdout.write(`\n${footerPrefix()}${styledFooter('warn', confirmation, width)}\n`);
3644
4740
  }
3645
- else if (flashMsg) {
3646
- process.stdout.write(`\n ${flashMsg}\n`);
4741
+ else if (flash) {
4742
+ process.stdout.write(`\n${footerPrefix()}${styledFooter(flash.style, flash.text, width)}\n`);
3647
4743
  }
3648
4744
  else {
3649
4745
  process.stdout.write('\n');
3650
4746
  }
3651
4747
  // Keybinding hints
3652
- process.stdout.write(`\n \x1b[2m↑/↓ 选择 ⏎ ${selected?.canAttach ? '连接' : '不可连接'} d 删除 q 退出\x1b[0m\n`);
4748
+ const fullHints = `↑/↓ 选择 ⏎ ${selected?.canAttach ? '连接' : '不可连接'} d 删除 q 退出`;
4749
+ const compactHints = '↑/↓ 选择 ⏎ d q';
4750
+ const hints = displayWidth(fullHints) <= width ? fullHints : compactHints;
4751
+ process.stdout.write(`\n${footerPrefix()}${styledFooter('dim', hints, width)}\n`);
3653
4752
  }
3654
4753
  return new Promise((resolve) => {
3655
4754
  process.stdin.setRawMode(true);
@@ -3658,7 +4757,15 @@ function interactiveSessionPicker(active, probeSnapshot) {
3658
4757
  process.stdout.write('\x1b[?25l'); // hide cursor
3659
4758
  process.stdout.write('\x1b[?1049h'); // alt screen
3660
4759
  render();
4760
+ // Rebuild both axes on resize: rows/header must use the current columns or
4761
+ // stale wide lines wrap and invalidate the vertical one-row-per-session math.
4762
+ const onResize = () => {
4763
+ rebuildLayout();
4764
+ render();
4765
+ };
4766
+ process.stdout.on('resize', onResize);
3661
4767
  function cleanup() {
4768
+ process.stdout.off('resize', onResize);
3662
4769
  process.stdin.setRawMode(false);
3663
4770
  process.stdin.pause();
3664
4771
  process.stdout.write('\x1b[?25h'); // show cursor
@@ -3668,11 +4775,17 @@ function interactiveSessionPicker(active, probeSnapshot) {
3668
4775
  async function deleteSession(idx) {
3669
4776
  const r = rows[idx];
3670
4777
  const s = r.session;
3671
- const result = await closeSessionForDelete(s);
4778
+ // Explicit delete is the abandon boundary. Prefer the owning daemon so
4779
+ // its current in-memory FIFO is cleared atomically with close; offline
4780
+ // fallback rereads the exact row and clears the latest ledger/commits.
4781
+ const result = await abandonSessionAuthoritatively(s);
3672
4782
  if (!result.ok) {
3673
- flashMsg = `\x1b[31m✗ 删除失败: ${result.error}\x1b[0m`;
4783
+ flash = { style: 'error', text: `✗ 删除失败: ${result.error}` };
3674
4784
  return;
3675
4785
  }
4786
+ // A live daemon owns process/pane teardown and has re-resolved the latest
4787
+ // generation. Only the locked offline path may clean up resources, using
4788
+ // the freshly-read row rather than this TUI's stale snapshot.
3676
4789
  // Remove from active list and TUI rows
3677
4790
  const activeIdx = active.indexOf(s);
3678
4791
  if (activeIdx >= 0)
@@ -3680,9 +4793,12 @@ function interactiveSessionPicker(active, probeSnapshot) {
3680
4793
  rows.splice(idx, 1);
3681
4794
  if (cursor >= rows.length)
3682
4795
  cursor = Math.max(0, rows.length - 1);
3683
- flashMsg = result.via === 'daemon'
3684
- ? `\x1b[32m✓ 已删除 ${s.sessionId.substring(0, 8)}\x1b[0m`
3685
- : `\x1b[33m✓ 已离线删除 ${s.sessionId.substring(0, 8)}\x1b[0m`;
4796
+ // master's flash-object footer; the daemon/offline discriminant on
4797
+ // abandonSessionAuthoritatively is `mode` (see its return type ~3573),
4798
+ // NOT master's `via` — that belongs to a different helper.
4799
+ flash = result.mode === 'daemon'
4800
+ ? { style: 'success', text: `✓ 已删除 ${s.sessionId.substring(0, 8)}` }
4801
+ : { style: 'warn', text: `✓ 已离线删除 ${s.sessionId.substring(0, 8)}` };
3686
4802
  }
3687
4803
  process.stdin.on('data', async (key) => {
3688
4804
  if (deleteInFlight)
@@ -3700,12 +4816,12 @@ function interactiveSessionPicker(active, probeSnapshot) {
3700
4816
  }
3701
4817
  }
3702
4818
  else {
3703
- flashMsg = '\x1b[2m取消删除\x1b[0m';
4819
+ flash = { style: 'dim', text: '取消删除' };
3704
4820
  }
3705
4821
  render();
3706
4822
  return;
3707
4823
  }
3708
- flashMsg = '';
4824
+ flash = null;
3709
4825
  // Ctrl-C or q or Esc
3710
4826
  if (key === '\x03' || key === 'q' || key === '\x1b') {
3711
4827
  cleanup();
@@ -3739,19 +4855,19 @@ function interactiveSessionPicker(active, probeSnapshot) {
3739
4855
  if (key === '\r' || key === '\n') {
3740
4856
  const selected = rows[cursor];
3741
4857
  if (selected.isAdopt) {
3742
- flashMsg = `\x1b[33m这是 adopt 会话;botmux 不 attach 用户 pane。目标: ${selected.targetLabel}\x1b[0m`;
4858
+ flash = { style: 'warn', text: `这是 adopt 会话;botmux 不 attach 用户 pane。目标: ${selected.targetLabel}` };
3743
4859
  render();
3744
4860
  return;
3745
4861
  }
3746
4862
  if (!selected.canAttach) {
3747
- flashMsg = '\x1b[33m该会话没有可连接的持久后端\x1b[0m';
4863
+ flash = { style: 'warn', text: '该会话没有可连接的持久后端' };
3748
4864
  render();
3749
4865
  return;
3750
4866
  }
3751
4867
  if (selected.attachBackend === 'zmx') {
3752
4868
  const target = selected.backendTarget;
3753
4869
  if (!target || target.backendType !== 'zmx') {
3754
- flashMsg = '\x1b[31mZMX attach target is missing or inconsistent\x1b[0m';
4870
+ flash = { style: 'error', text: 'ZMX attach target is missing or inconsistent' };
3755
4871
  render();
3756
4872
  return;
3757
4873
  }
@@ -3760,7 +4876,7 @@ function interactiveSessionPicker(active, probeSnapshot) {
3760
4876
  // cleanup and re-prove it immediately before attach.
3761
4877
  const frozen = freezeManagedZmxAttachTarget(target.sessionName, selected.session.sessionId);
3762
4878
  if (!frozen.ok) {
3763
- flashMsg = `\x1b[31m${frozen.message}\x1b[0m`;
4879
+ flash = { style: 'error', text: frozen.message };
3764
4880
  render();
3765
4881
  return;
3766
4882
  }
@@ -3772,7 +4888,7 @@ function interactiveSessionPicker(active, probeSnapshot) {
3772
4888
  else {
3773
4889
  const target = selected.backendTarget;
3774
4890
  if (!target || target.backendType !== 'tmux') {
3775
- flashMsg = '\x1b[31mtmux attach target is missing or inconsistent\x1b[0m';
4891
+ flash = { style: 'error', text: 'tmux attach target is missing or inconsistent' };
3776
4892
  render();
3777
4893
  return;
3778
4894
  }
@@ -3863,16 +4979,16 @@ async function cmdList() {
3863
4979
  const closeNow = async (arr, kind) => {
3864
4980
  let closed = 0;
3865
4981
  for (const s of arr) {
3866
- const result = await closeSessionForDelete(s);
3867
- if (result.ok) {
4982
+ if (await pruneSessionAuthoritatively(s)) {
3868
4983
  closed++;
3869
4984
  }
3870
4985
  else {
3871
- // Keep it visible: mutating only the store while a possible owner
3872
- // daemon still has the row in memory lets the next message resurrect
3873
- // exactly the session auto-prune claimed to close.
4986
+ // The owning daemon observed a newly unsettled FIFO after this CLI's
4987
+ // liveness snapshot, or still has the row in memory. Keep it visible
4988
+ // instead of abandoning it: mutating only the store here would let the
4989
+ // next message resurrect exactly the session auto-prune claimed to close.
3874
4990
  live.push(s);
3875
- console.warn(`⚠️ 未自动清理 ${kind === 'scratch' ? '占位' : '会话'} ${s.sessionId.substring(0, 8)}:${result.error}`);
4991
+ console.warn(`⚠️ 未自动清理 ${kind === 'scratch' ? '占位' : '会话'} ${s.sessionId.substring(0, 8)}:owner 仍持有该会话或存在未结算派发`);
3876
4992
  }
3877
4993
  }
3878
4994
  return closed;
@@ -3880,9 +4996,9 @@ async function cmdList() {
3880
4996
  // Scratches: close silently — they were placeholders, not dead sessions.
3881
4997
  await closeNow(prunedScratch, 'scratch');
3882
4998
  if (pruned.length > 0) {
3883
- const closed = await closeNow(pruned, 'real');
3884
- if (closed > 0) {
3885
- console.log(`已自动清理 ${closed} 个不可恢复的会话(进程已退出或无可恢复后端)`);
4999
+ const prunedCount = await closeNow(pruned, 'real');
5000
+ if (prunedCount > 0) {
5001
+ console.log(`已自动清理 ${prunedCount} 个不可恢复的会话(进程已退出或无可恢复后端)`);
3886
5002
  }
3887
5003
  }
3888
5004
  // Sort by creation time, newest first
@@ -3962,21 +5078,26 @@ async function cmdDelete() {
3962
5078
  if (currentSessionId && toDelete.length > 1) {
3963
5079
  toDelete.sort((a, b) => Number(a.sessionId === currentSessionId) - Number(b.sessionId === currentSessionId));
3964
5080
  }
3965
- const online = listOnlineDaemons();
3966
5081
  let closed = 0;
3967
5082
  let offline = 0;
3968
5083
  let failed = 0;
3969
5084
  for (const s of toDelete) {
3970
- const result = await closeSessionForDelete(s, online);
5085
+ // Explicit abandon boundary: route through the owning daemon so the ledger
5086
+ // FIFO is cleared atomically with close; offline fallback rereads the row
5087
+ // and clears the latest ledger/commits.
5088
+ const result = await abandonSessionAuthoritatively(s);
3971
5089
  if (!result.ok) {
3972
- console.error(`✗ ${s.sessionId.substring(0, 8)} ${s.title}: ${result.error}`);
5090
+ console.error(`✗ ${s.sessionId.substring(0, 8)} ${s.title}${result.error ? `: ${result.error}` : ''}`);
3973
5091
  failed++;
3974
5092
  continue;
3975
5093
  }
3976
5094
  closed++;
3977
- if (result.via === 'offline')
5095
+ if (result.mode === 'offline') {
3978
5096
  offline++;
3979
- console.log(`✓ ${s.sessionId.substring(0, 8)} ${s.title}${result.via === 'offline' ? '(daemon 离线,本地收口)' : ''}`);
5097
+ if (result.cleanedBacking)
5098
+ console.log(` killed ${result.cleanedBacking}`);
5099
+ }
5100
+ console.log(`✓ ${s.sessionId.substring(0, 8)} ${s.title}${result.mode === 'offline' ? '(daemon 离线,本地收口)' : ''}`);
3980
5101
  }
3981
5102
  console.log(`\n已关闭 ${closed} 个会话${offline ? `(${offline} 个离线收口)` : ''}${failed ? `,${failed} 个失败` : ''}`);
3982
5103
  if (failed > 0)
@@ -4104,7 +5225,7 @@ async function postSessionCliIpc(ipcPort, sessionId, route, payload) {
4104
5225
  catch { /* sandboxed/read-isolated: capability fallback below */ }
4105
5226
  }
4106
5227
  if (!hostSecret) {
4107
- const claim = readManagedOriginCapability(resolveDataDir(), sessionId, process.env.BOTMUX_SEND_RELAY);
5228
+ const claim = readManagedOriginCapability(resolveDataDir(), sessionId, process.env.BOTMUX_SEND_RELAY, process.env.BOTMUX_ORIGIN_CHANNEL_ID);
4108
5229
  if (claim) {
4109
5230
  requestBody.originCapability = claim.capability;
4110
5231
  if (claim.turnId)
@@ -4937,7 +6058,10 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接
4937
6058
  默认使用 botmux 内置 Feishu Web QR 登录尝试自动导入权限/redirect/发布版本;可加 --no-open-platform-auto 跳过
4938
6059
  start 启动 daemon,并启动 mode=auto 的插件 service
4939
6060
  stop 停止 daemon(默认不停止插件 service;--with-plugin 显式停止 mode=auto 的插件 service)
4940
- restart 重启 daemon(默认不停止插件 service,core 启动后确保 mode=auto 正在运行;--with-plugin 显式先停再启动 auto service;--include-pm2 同时重启 PM2 God)
6061
+ restart 重启 daemon(默认不停止插件 service,core 启动后确保 mode=auto 正在运行;--with-plugin 显式先停再启动 auto service)
6062
+ --include-pm2 仅允许“入场时没有 live PM2 God”的干净启动;若已有 live God,整条命令会在 fleet/breadcrumb 零改动处拒绝,且不会信号或重启现存 God
6063
+ 首次升级若旧 daemon 缺少 shutdown protocol:先独立确认所有 Session/Riff 工作均 idle,再一次性运行
6064
+ botmux restart --bootstrap-shutdown-protocol --yes;普通 stop/restart 仍保持 fail-closed
4941
6065
  logs 查看 daemon 日志(--lines N, --bot <0-based-index|pm2-name|appId>)
4942
6066
  status 查看 daemon 状态
4943
6067
  upgrade 升级到最新版本(别名:update)
@@ -5327,8 +6451,8 @@ Context flags: --session-id, --lark-app-id, --chat-id, --working-dir/--repo`);
5327
6451
  if (!meta && argFlag(rest, '--create')) {
5328
6452
  meta = ensureDefaultWhiteboard({ larkAppId: ctx.larkAppId, chatId: ctx.chatId, workingDir: ctx.workingDir, sessionId: ctx.sessionId });
5329
6453
  if (ctx.session) {
6454
+ await patchSessionWhiteboardAuthoritatively(ctx.session, meta.id);
5330
6455
  ctx.session.whiteboardId = meta.id;
5331
- saveSession(ctx.session);
5332
6456
  }
5333
6457
  }
5334
6458
  if (!meta) {
@@ -5343,8 +6467,8 @@ Context flags: --session-id, --lark-app-id, --chat-id, --working-dir/--repo`);
5343
6467
  const ctx = currentWhiteboardContext(rest);
5344
6468
  const meta = createWhiteboard({ id: argValue(rest, '--id'), title: argValue(rest, '--title'), larkAppId: ctx.larkAppId, chatId: ctx.chatId, workingDir: ctx.workingDir, sessionId: ctx.sessionId });
5345
6469
  if (ctx.session && !ctx.session.whiteboardId) {
6470
+ await patchSessionWhiteboardAuthoritatively(ctx.session, meta.id);
5346
6471
  ctx.session.whiteboardId = meta.id;
5347
- saveSession(ctx.session);
5348
6472
  }
5349
6473
  console.log(JSON.stringify({ board: meta, path: whiteboardPath(meta.id) }, null, 2));
5350
6474
  return;
@@ -5366,8 +6490,8 @@ Context flags: --session-id, --lark-app-id, --chat-id, --working-dir/--repo`);
5366
6490
  const meta = ensureDefaultWhiteboard({ larkAppId: ctx.larkAppId, chatId: ctx.chatId, workingDir: ctx.workingDir, sessionId: ctx.sessionId });
5367
6491
  id = meta.id;
5368
6492
  if (ctx.session) {
6493
+ await patchSessionWhiteboardAuthoritatively(ctx.session, id);
5369
6494
  ctx.session.whiteboardId = id;
5370
- saveSession(ctx.session);
5371
6495
  }
5372
6496
  }
5373
6497
  if (!id) {
@@ -6059,7 +7183,7 @@ import { renderBrandTemplate } from './im/lark/brand-template.js';
6059
7183
  import { resolveBrandLabel, resolveUsageDisplay } from './bot-registry.js';
6060
7184
  import { config } from './config.js';
6061
7185
  import { getSessionUsageSnapshot } from './core/cost-calculator.js';
6062
- import { resolveQuoteTarget, validateMentionDecision, shouldBlockMentionBackByParticipants, parseAttentionFlag, attentionUsageError, managedVcQuoteError, managedVcCustomCardError, managedVcSendControlError, managedVcSendPayloadError, containsLarkAtTag, } from './services/send-policy.js';
7186
+ import { resolveQuoteTarget, validateMentionDecision, mentionBackAmbiguity, mentionBackAmbiguityError, parseAttentionFlag, attentionUsageError, managedVcQuoteError, managedVcCustomCardError, managedVcSendControlError, managedVcSendPayloadError, containsLarkAtTag, } from './services/send-policy.js';
6063
7187
  /**
6064
7188
  * Sandbox relay mode for `botmux send`. Inside a file-sandbox the CLI cannot
6065
7189
  * read bots.json or reach Lark directly (creds are deliberately absent), so we
@@ -6113,7 +7237,7 @@ async function relaySend(rest, relayDir) {
6113
7237
  ? prepareCardMarkdown(extractCardText(content), process.cwd(), 'filesystem')
6114
7238
  : undefined;
6115
7239
  const id = randomBytes(8).toString('hex');
6116
- const originCapability = readManagedOriginCapability(resolveDataDir(), sid, relayDir)?.capability;
7240
+ const originCapability = readManagedOriginCapability(resolveDataDir(), sid, relayDir, process.env.BOTMUX_ORIGIN_CHANNEL_ID)?.capability;
6117
7241
  // Structured request: the daemon-side watcher rebuilds the argv from these
6118
7242
  // validated fields (it NEVER executes raw argv — see buildRelayHostArgs).
6119
7243
  // Content + attachments are written into the shared outbox as plain
@@ -6510,11 +7634,98 @@ async function cmdSend(rest) {
6510
7634
  // central session-capability gate — same hard door every Feishu-touching CLI
6511
7635
  // command consults.
6512
7636
  assertTurnTransportOrExit('send');
6513
- // Managed output attribution must come from the live process-tree marker,
6514
- // whose turn/attempt is refreshed by the worker. BOTMUX_TURN_ID is a
6515
- // spawn-time fallback and can be stale in a detached child after a later
6516
- // delivery starts, so it is never authority for receiver policy.
6517
- const liveMarkerCtx = resolveSessionContext(resolveDataDir(), process.env.BOTMUX_SESSION_ID);
7637
+ // Resolve isolation marker-first. A visible host marker always wins over a
7638
+ // leftover capability. Linux bwrap keeps its host-execution outbox; macOS
7639
+ // read isolation instead challenges the owning daemon and trusts only the
7640
+ // matching host-written read-only proof sidecar. The capability file itself
7641
+ // may survive worker SIGKILL and is never direct-send authority.
7642
+ let sendDataDir = resolveDataDir();
7643
+ let liveMarkerCtx = findLiveAncestorSessionContext(sendDataDir);
7644
+ const relayDir = process.env.BOTMUX_SEND_RELAY;
7645
+ const sessionIdArg = argValue(rest, '--session-id');
7646
+ const inheritedSessionId = process.env.BOTMUX_SESSION_ID?.trim();
7647
+ const inheritedOriginChannelId = process.env.BOTMUX_ORIGIN_CHANNEL_ID?.trim();
7648
+ const isolationSessionCandidates = [...new Set([
7649
+ inheritedSessionId,
7650
+ ancestorCtx?.sessionId,
7651
+ sessionIdArg,
7652
+ ].filter((value) => !!value))];
7653
+ const isolationMarkerPresent = isolationSessionCandidates
7654
+ .some(sessionId => !!inheritedOriginChannelId
7655
+ && hasManagedOriginIsolationMarker(sendDataDir, sessionId, inheritedOriginChannelId));
7656
+ let osUserHomeDir;
7657
+ try {
7658
+ osUserHomeDir = userInfo().homedir;
7659
+ }
7660
+ catch {
7661
+ console.error('botmux send refused: OS account home unavailable for isolation classification');
7662
+ process.exit(2);
7663
+ }
7664
+ if (!osUserHomeDir) {
7665
+ console.error('botmux send refused: OS account home unavailable for isolation classification');
7666
+ process.exit(2);
7667
+ }
7668
+ const kernelReadIsolationDetected = managedOriginLegacyIsolationProbeAccess(osUserHomeDir)
7669
+ === 'sandbox_denied'
7670
+ || managedOriginIsolationSentinelAccess(osUserHomeDir) === 'sandbox_denied';
7671
+ const isolatedSendRequired = !relayDir
7672
+ && (kernelReadIsolationDetected
7673
+ || process.env.BOTMUX_READ_ISOLATED === '1'
7674
+ || (!liveMarkerCtx?.sessionId && isolationMarkerPresent));
7675
+ let isolatedBoundSessionId;
7676
+ if (isolatedSendRequired) {
7677
+ if (!inheritedOriginChannelId || !/^[a-f0-9]{64}$/.test(inheritedOriginChannelId)) {
7678
+ console.error('botmux send refused: read-isolated pane authority channel is missing or invalid');
7679
+ process.exit(2);
7680
+ }
7681
+ const locators = isolationSessionCandidates.flatMap(sessionId => {
7682
+ const locator = readManagedOriginRootLocator(osUserHomeDir, sessionId);
7683
+ return locator ? [locator] : [];
7684
+ });
7685
+ if (locators.length !== 1) {
7686
+ console.error('botmux send refused: read-isolated owning data-root locator is missing or ambiguous');
7687
+ process.exit(2);
7688
+ }
7689
+ const locator = locators[0];
7690
+ if ((sessionIdArg && sessionIdArg !== locator.sessionId)
7691
+ || (inheritedSessionId && inheritedSessionId !== locator.sessionId)) {
7692
+ console.error('botmux send refused: read-isolated session does not match the owning data-root locator');
7693
+ process.exit(2);
7694
+ }
7695
+ isolatedBoundSessionId = locator.sessionId;
7696
+ sendDataDir = locator.dataDir;
7697
+ // A locator is data, not authority. Require the kernel to deny the probe
7698
+ // under the locator-selected *actual* Botmux root. Legacy Seatbelt profiles
7699
+ // already denied this dashboard-secret basename class at their real root;
7700
+ // a child-forged fake root remains readable and is therefore rejected.
7701
+ if (managedOriginDataRootProbeAccess(sendDataDir, locator.sessionId)
7702
+ !== 'sandbox_denied') {
7703
+ console.error('botmux send refused: locator-selected data root is not protected by the active sandbox');
7704
+ process.exit(2);
7705
+ }
7706
+ // All later session/credential/marker reads in this short-lived command
7707
+ // must use the host-bound root, never the child's mutable inherited value.
7708
+ process.env.SESSION_DATA_DIR = sendDataDir;
7709
+ liveMarkerCtx = findLiveAncestorSessionContext(sendDataDir);
7710
+ }
7711
+ const isolatedCapabilityCtx = !isolatedSendRequired && liveMarkerCtx?.sessionId
7712
+ ? null
7713
+ : readWorkflowSessionRelayContext({
7714
+ env: process.env,
7715
+ dataDir: sendDataDir,
7716
+ // Keep one marker snapshot for the whole decision. In particular, do
7717
+ // not let resolveSessionContext's protected-capability fallback get
7718
+ // mislabeled as a live process marker.
7719
+ findMarker: () => isolatedSendRequired ? null : liveMarkerCtx,
7720
+ });
7721
+ if (isolatedSendRequired
7722
+ && (isolatedCapabilityCtx?.sessionId !== isolatedBoundSessionId
7723
+ || isolatedCapabilityCtx?.originChannelId !== inheritedOriginChannelId)) {
7724
+ console.error('botmux send refused: read-isolated capability is not bound to this session');
7725
+ process.exit(2);
7726
+ }
7727
+ let isolatedAttestationContext;
7728
+ let isolatedManagedOriginCtx;
6518
7729
  const trustedHostRelay = process.env.BOTMUX_HOST_RELAY_AUTHORIZED === '1';
6519
7730
  const trustedRelayAttemptRaw = Number(process.env.BOTMUX_DISPATCH_ATTEMPT);
6520
7731
  const trustedRelayCandidate = trustedHostRelay && process.env.BOTMUX_SESSION_ID
@@ -6526,15 +7737,59 @@ async function cmdSend(rest) {
6526
7737
  : undefined,
6527
7738
  }
6528
7739
  : undefined;
6529
- const sessionIdArg = argValue(rest, '--session-id');
6530
7740
  // Inside bwrap the PID namespace makes host marker traversal impossible.
6531
7741
  // The relay watcher therefore binds a short-lived host-issued capability to
6532
7742
  // the worker's live turn and performs the authoritative policy check.
6533
- const relayDir = process.env.BOTMUX_SEND_RELAY;
6534
- if (relayDir) {
7743
+ if (relayDir && isolatedCapabilityCtx) {
6535
7744
  await relaySend(rest, relayDir);
6536
7745
  return;
6537
7746
  }
7747
+ if (relayDir && !liveMarkerCtx?.sessionId) {
7748
+ // The child may delete or replace its writable outbox capability, while
7749
+ // the immutable default snapshot remains visible. That snapshot is only a
7750
+ // routing hint and can survive worker death; never fall through to direct
7751
+ // Lark send when the live relay token is absent.
7752
+ console.error('botmux send refused: managed host relay capability is stale or missing');
7753
+ process.exit(2);
7754
+ }
7755
+ if (!relayDir && isolatedSendRequired && !isolatedCapabilityCtx) {
7756
+ // Isolation classification must not depend on successfully parsing the
7757
+ // rotating token. A missing/corrupt/revoked capability is an authorization
7758
+ // failure, never permission to downgrade into the ordinary direct path.
7759
+ console.error('botmux send refused: read-isolated managed origin capability is stale, corrupt, or missing');
7760
+ process.exit(2);
7761
+ }
7762
+ if (!relayDir && isolatedCapabilityCtx) {
7763
+ isolatedAttestationContext = {
7764
+ sessionId: isolatedCapabilityCtx.sessionId,
7765
+ channelId: isolatedCapabilityCtx.originChannelId,
7766
+ capability: isolatedCapabilityCtx.capability,
7767
+ dataDir: sendDataDir,
7768
+ ...(isolatedCapabilityCtx.larkAppId
7769
+ ? { larkAppId: isolatedCapabilityCtx.larkAppId }
7770
+ : {}),
7771
+ ...(isolatedCapabilityCtx.ipcPortFallback !== undefined
7772
+ ? { ipcPortFallback: isolatedCapabilityCtx.ipcPortFallback }
7773
+ : {}),
7774
+ };
7775
+ try {
7776
+ isolatedManagedOriginCtx = await attestManagedOrigin({
7777
+ context: isolatedAttestationContext,
7778
+ resolveIpcPort: (larkAppId) => {
7779
+ try {
7780
+ return larkAppId ? findDaemon(larkAppId)?.ipcPort : undefined;
7781
+ }
7782
+ catch {
7783
+ return undefined;
7784
+ }
7785
+ },
7786
+ });
7787
+ }
7788
+ catch (err) {
7789
+ console.error(`botmux send refused: ${err instanceof Error ? err.message : String(err)}`);
7790
+ process.exit(2);
7791
+ }
7792
+ }
6538
7793
  // Silent is an execution policy, not a prompt suggestion. During a durable
6539
7794
  // meeting turn, refuse botmux-mediated sends before either the direct-Lark or
6540
7795
  // sandbox-relay path can run. Explicit human IM turns have no dispatchAttempt
@@ -6549,23 +7804,54 @@ async function cmdSend(rest) {
6549
7804
  const trustedRelayCtx = trustedRelayCandidate && isTrustedVcMeetingHostRelayParent(trustedHostRelay, trustedRelaySession?.pid, process.ppid)
6550
7805
  ? trustedRelayCandidate
6551
7806
  : undefined;
7807
+ // Keep origin authority independent from the requested destination. In
7808
+ // particular, `--session-id <other>` may select where an ordinary Lark turn
7809
+ // is sent, but it must never replace the session whose durable delivery sink
7810
+ // governs this process. Session identity may fall back to the inherited env
7811
+ // for detached commands; the turn tuple may not. `ancestorCtx` deliberately
7812
+ // adds spawn-time BOTMUX_TURN_ID/ATTEMPT compatibility fallbacks, which can be
7813
+ // stale after a later turn, so only a parent-bound host relay or a fresh
7814
+ // marker/protected-capability tuple can authorize a durable dispatch.
6552
7815
  const originSessionId = trustedRelayCtx?.sessionId
6553
- || liveMarkerCtx?.sessionId
6554
- || ancestorCtx?.sessionId
6555
- || sessionIdArg;
7816
+ ?? liveMarkerCtx?.sessionId
7817
+ ?? isolatedManagedOriginCtx?.sessionId
7818
+ ?? ancestorCtx?.sessionId
7819
+ ?? process.env.BOTMUX_SESSION_ID;
7820
+ const authoritativeOriginTurnCtx = trustedRelayCtx
7821
+ ? (trustedRelayCtx.turnId ? trustedRelayCtx : undefined)
7822
+ : (liveMarkerCtx?.turnId
7823
+ ? liveMarkerCtx
7824
+ : isolatedManagedOriginCtx?.turnId
7825
+ ? isolatedManagedOriginCtx
7826
+ : undefined);
7827
+ const originTurnId = authoritativeOriginTurnCtx?.turnId;
7828
+ const originDispatchAttempt = authoritativeOriginTurnCtx?.dispatchAttempt;
7829
+ const originSession = originSessionId
7830
+ ? sessionsForOrigin.get(originSessionId)
7831
+ : undefined;
7832
+ const hostRelayRequiresCodexAppLedger = !!trustedRelayCtx
7833
+ && process.env.BOTMUX_HOST_RELAY_REQUIRES_CODEX_APP_LEDGER === '1';
7834
+ // The live worker authorizes a Codex App relay only while an exact durable
7835
+ // dispatch remains unsettled. Recheck that prerequisite before any other
7836
+ // managed-output policy or provider setup: terminal settlement can win the
7837
+ // race between watcher authorization and this host child loading the store,
7838
+ // and must never downgrade the send into an ordinary Lark message.
7839
+ if ((hostRelayRequiresCodexAppLedger || isolatedManagedOriginCtx?.requiresCodexAppLedger)
7840
+ && (originSession?.codexAppDispatchLedger?.length ?? 0) === 0) {
7841
+ console.error(`botmux send refused: authorized Codex App origin ${originSessionId}/${originTurnId} is no longer unsettled`);
7842
+ process.exit(2);
7843
+ }
6556
7844
  let explicitVcMeetingImOrigin;
6557
7845
  let vcMeetingListenerOutputOwner;
6558
7846
  let vcMeetingManagedSendOrigin;
6559
7847
  let vcMeetingDeliveryReplyOrigin;
6560
7848
  if (originSessionId) {
6561
- const originSession = sessionsForOrigin.get(originSessionId);
6562
- const originTurnId = trustedRelayCtx?.turnId ?? liveMarkerCtx?.turnId;
6563
7849
  const imOrigin = resolveVcMeetingImTurnOrigin(originSession, originTurnId);
6564
7850
  const managedOrigin = {
6565
7851
  receiverSessionId: originSessionId,
6566
7852
  receiverSession: !!originSession?.vcMeetingReceiver,
6567
7853
  turnId: originTurnId,
6568
- dispatchAttempt: trustedRelayCtx?.dispatchAttempt ?? liveMarkerCtx?.dispatchAttempt,
7854
+ dispatchAttempt: originDispatchAttempt,
6569
7855
  currentImTurnOrigin: imOrigin,
6570
7856
  };
6571
7857
  const decision = evaluateVcMeetingManagedSend(resolveDataDir(), managedOrigin);
@@ -6585,7 +7871,7 @@ async function cmdSend(rest) {
6585
7871
  }
6586
7872
  }
6587
7873
  if (decision.kind === 'listener_thread'
6588
- && (trustedRelayCtx?.dispatchAttempt ?? liveMarkerCtx?.dispatchAttempt) === undefined) {
7874
+ && originDispatchAttempt === undefined) {
6589
7875
  explicitVcMeetingImOrigin = imOrigin;
6590
7876
  }
6591
7877
  }
@@ -6728,7 +8014,7 @@ async function cmdSend(rest) {
6728
8014
  process.exit(1);
6729
8015
  }
6730
8016
  const sessions = loadSessions();
6731
- const currentTurnId = ancestorCtx?.turnId ?? process.env.BOTMUX_TURN_ID;
8017
+ const currentTurnId = originTurnId;
6732
8018
  let s = sessions.get(sid);
6733
8019
  // Riff (remote backend) sandbox: no local daemon/sessions.json/bots.json.
6734
8020
  // Fall back to env-var-only mode so `botmux send` works without a daemon.
@@ -6770,9 +8056,154 @@ async function cmdSend(rest) {
6770
8056
  assertSessionTransportOrExit({ chatId: s.chatId, larkAppId: s.larkAppId }, 'send');
6771
8057
  let deferredMaterializedByThisCommand = false;
6772
8058
  let deferredTopicRootMessageIdForOutput;
6773
- // 本轮的回复锚点:优先按 turnId 精确取 per-turn 落点(排队/并发轮次不再被
6774
- // 后到轮次覆盖 currentReplyTarget 而塌成群顶层 plain),无记录再退回单槽。
8059
+ // Prefer the exact per-turn reply anchor; the latest single slot is only a
8060
+ // compatibility fallback for sessions persisted before replyTargets.
6775
8061
  const turnReplyTarget = pickTurnReplyTarget(s, currentTurnId);
8062
+ const exactOriginDispatch = (() => {
8063
+ const unsettledOriginDispatches = originSession?.codexAppDispatchLedger ?? [];
8064
+ // The live worker authorized this host re-exec against an exact unsettled
8065
+ // Codex App entry. If terminal settlement/revocation won the race before
8066
+ // this child reloaded the store, never degrade into an ordinary Lark send
8067
+ // against mutable session state.
8068
+ if (unsettledOriginDispatches.length === 0) {
8069
+ return undefined;
8070
+ }
8071
+ // A detached/backgrounded command can still inherit the correct session id
8072
+ // but only a spawn-time (and now stale) turn env. If this session has any
8073
+ // unsettled durable output, absence of a fresh marker/capability tuple must
8074
+ // fail closed rather than silently falling through to ordinary Lark IM.
8075
+ if (!originTurnId) {
8076
+ console.error(`botmux send refused: origin session ${originSessionId} has unsettled durable output but no fresh authoritative dispatch identity`);
8077
+ process.exit(2);
8078
+ }
8079
+ const turnMatches = unsettledOriginDispatches
8080
+ .filter(entry => entry.turnId === originTurnId);
8081
+ const exactMatches = originDispatchAttempt === undefined
8082
+ ? turnMatches
8083
+ : turnMatches.filter(entry => entry.dispatchAttempt === originDispatchAttempt);
8084
+ // Once a durable turn exists, a supplied attempt must match it exactly.
8085
+ // Falling back to another attempt would let a stale/replayed process select
8086
+ // the wrong sink. With no attempt, multiple entries are equally unsafe.
8087
+ if (exactMatches.length !== 1) {
8088
+ const detail = originDispatchAttempt === undefined
8089
+ ? `${turnMatches.length} ledger entries share the turn id`
8090
+ : `${exactMatches.length} ledger entries match attempt ${originDispatchAttempt}`;
8091
+ console.error(`botmux send refused: origin dispatch ${originSessionId}/${originTurnId} is ambiguous (${detail})`);
8092
+ process.exit(2);
8093
+ }
8094
+ return exactMatches[0];
8095
+ })();
8096
+ // Frozen reply routing applies only when the selected destination is the
8097
+ // trusted origin session. A cross-session publish must not accidentally
8098
+ // reuse a coincidentally-equal turn id from the destination ledger.
8099
+ const frozenTurnDispatch = originSessionId === sid
8100
+ ? exactOriginDispatch
8101
+ : undefined;
8102
+ const frozenTurnReplyTarget = frozenTurnDispatch?.replyTarget;
8103
+ if (exactOriginDispatch?.deliverySink === 'http_wait'
8104
+ || exactOriginDispatch?.deliverySink === 'http_async'
8105
+ || exactOriginDispatch?.deliverySink === 'suppressed') {
8106
+ console.error(`botmux send refused: origin turn ${originTurnId} is bound to the `
8107
+ + `${exactOriginDispatch.deliverySink} host sink`);
8108
+ process.exit(2);
8109
+ }
8110
+ // A proof is a point-in-time liveness check, not a five-second send lease.
8111
+ // Re-challenge immediately before observable provider effects so lengthy
8112
+ // local parsing/card preparation cannot carry an old capability across a
8113
+ // worker restart, turn rotation, or Codex ledger settlement.
8114
+ const revalidateIsolatedOriginBeforeEffect = async () => {
8115
+ if (!isolatedAttestationContext || !isolatedManagedOriginCtx)
8116
+ return undefined;
8117
+ const fresh = await attestManagedOrigin({
8118
+ context: isolatedAttestationContext,
8119
+ resolveIpcPort: (larkAppId) => {
8120
+ try {
8121
+ return larkAppId ? findDaemon(larkAppId)?.ipcPort : undefined;
8122
+ }
8123
+ catch {
8124
+ return undefined;
8125
+ }
8126
+ },
8127
+ });
8128
+ if (fresh.sessionId !== isolatedManagedOriginCtx.sessionId
8129
+ || fresh.turnId !== isolatedManagedOriginCtx.turnId
8130
+ || fresh.dispatchAttempt !== isolatedManagedOriginCtx.dispatchAttempt
8131
+ || fresh.requiresCodexAppLedger !== isolatedManagedOriginCtx.requiresCodexAppLedger) {
8132
+ throw new Error('managed origin changed before provider effect');
8133
+ }
8134
+ const currentOriginSession = loadSessions().get(fresh.sessionId);
8135
+ const ledgerDecision = validateCodexAppManagedSendOrigin(currentOriginSession?.codexAppDispatchLedger, fresh, fresh.requiresCodexAppLedger);
8136
+ if (!ledgerDecision.ok
8137
+ || ledgerDecision.requiresLedger !== fresh.requiresCodexAppLedger) {
8138
+ throw new Error(`managed origin ledger changed before provider effect${ledgerDecision.ok ? '' : `: ${ledgerDecision.error}`}`);
8139
+ }
8140
+ return fresh;
8141
+ };
8142
+ const fenceIsolatedOriginBeforeEffect = async () => {
8143
+ await revalidateIsolatedOriginBeforeEffect();
8144
+ };
8145
+ const isolatedHookOrigin = isolatedAttestationContext?.ipcPortFallback
8146
+ && isolatedManagedOriginCtx
8147
+ ? {
8148
+ ipcPort: isolatedAttestationContext.ipcPortFallback,
8149
+ sessionId: isolatedManagedOriginCtx.sessionId,
8150
+ capability: isolatedAttestationContext.capability,
8151
+ turnId: isolatedManagedOriginCtx.turnId,
8152
+ ...(isolatedManagedOriginCtx.dispatchAttempt !== undefined
8153
+ ? { dispatchAttempt: isolatedManagedOriginCtx.dispatchAttempt }
8154
+ : {}),
8155
+ }
8156
+ : undefined;
8157
+ // Outbound hooks are a distinct post-provider effect. Preserve normal hook
8158
+ // behavior, but bind it to a fresh challenge of this command's original
8159
+ // protected claim. The Lark client treats fence failure as hook-only loss so
8160
+ // an already-delivered primary is never reported failed and duplicated.
8161
+ const outboundMessageOptions = (suppressHook = false) => suppressHook
8162
+ ? { suppressHook: true }
8163
+ : isolatedAttestationContext
8164
+ ? isolatedHookOrigin
8165
+ ? {
8166
+ beforeHook: fenceIsolatedOriginBeforeEffect,
8167
+ hookOrigin: isolatedHookOrigin,
8168
+ }
8169
+ : { suppressHook: true }
8170
+ : undefined;
8171
+ // A document-comment turn has exactly one supported observable effect: a
8172
+ // plain text reply to its frozen origin target. Validate the complete shape
8173
+ // before reading stdin/content/card files and before any TTS, upload, or Lark
8174
+ // provider call. In particular, an explicit destination session is not an
8175
+ // escape hatch from the origin sink.
8176
+ const docTarget = originTurnId
8177
+ ? originSession?.docCommentTargets?.[originTurnId]
8178
+ : undefined;
8179
+ // Codex App turns are governed exclusively by their exact dispatch ledger:
8180
+ // a settled/missing ledger entry must never fall back to mutable session
8181
+ // state. Other CLI adapters predate that ledger, so their frozen per-turn
8182
+ // docCommentTargets entry remains the authoritative origin sink.
8183
+ const isOriginDocCommentTurn = exactOriginDispatch?.deliverySink === 'doc_comment'
8184
+ || (!exactOriginDispatch && originSession?.cliId !== 'codex-app' && !!docTarget);
8185
+ if (isOriginDocCommentTurn) {
8186
+ if (!docTarget || !originSession?.larkAppId) {
8187
+ console.error('botmux send refused: this turn is bound to a document comment, but its exact origin target is no longer available');
8188
+ process.exit(2);
8189
+ }
8190
+ if (sid !== originSessionId
8191
+ || sendTopLevel
8192
+ || !!overrideChatId
8193
+ || !!sendInto
8194
+ || asVoice
8195
+ || images.length > 0
8196
+ || files.length > 0
8197
+ || videoAttachments.length > 0
8198
+ || videoCovers.length > 0
8199
+ || customCardRequested
8200
+ || attention.requested
8201
+ || explicitQuote !== undefined
8202
+ || noQuote) {
8203
+ console.error('botmux send refused: a document-comment turn supports only its exact plain-text comment reply');
8204
+ process.exit(2);
8205
+ }
8206
+ }
6776
8207
  // Read content from: --content-file > positional arg > stdin
6777
8208
  let content = '';
6778
8209
  let customCard;
@@ -6894,8 +8325,12 @@ async function cmdSend(rest) {
6894
8325
  const targetChatId = overrideChatId ?? s.chatId;
6895
8326
  let dir;
6896
8327
  try {
6897
- const out = await synthesizeVoiceOpus(appId, content);
8328
+ await revalidateIsolatedOriginBeforeEffect();
8329
+ const out = await synthesizeVoiceOpus(appId, content, {
8330
+ beforeProviderEffect: fenceIsolatedOriginBeforeEffect,
8331
+ });
6898
8332
  dir = out.dir;
8333
+ await revalidateIsolatedOriginBeforeEffect();
6899
8334
  const fileKey = await uploadFile(appId, out.path, { duration: out.durationMs });
6900
8335
  const sentAtMs = Date.now();
6901
8336
  const proposedOutput = {
@@ -6918,9 +8353,7 @@ async function cmdSend(rest) {
6918
8353
  }
6919
8354
  else {
6920
8355
  revalidateVcMeetingManagedSend();
6921
- const managedProviderOptions = prepared
6922
- ? { suppressHook: true }
6923
- : undefined;
8356
+ const managedProviderOptions = outboundMessageOptions(!!prepared);
6924
8357
  const deferred = !sendInto && (!overrideChatId || overrideChatId === s.chatId)
6925
8358
  ? await dispatchDeferredTopicSend({
6926
8359
  dataDir: resolveDataDir(),
@@ -6931,9 +8364,18 @@ async function cmdSend(rest) {
6931
8364
  content: canonicalOutput.content,
6932
8365
  msgType: canonicalOutput.msgType,
6933
8366
  uuid: prepared?.providerKey,
6934
- sendRoot: (body, type, uuid) => sendMessage(appId, targetChatId, body, type, uuid, undefined, managedProviderOptions),
6935
- sendTitleSeed: (title, uuid) => sendMessage(appId, targetChatId, title, 'text', uuid),
6936
- replyRoot: (root, body, type, uuid) => replyMessage(appId, root, body, type, true, uuid, undefined, managedProviderOptions),
8367
+ sendRoot: async (body, type, uuid) => {
8368
+ await revalidateIsolatedOriginBeforeEffect();
8369
+ return sendMessage(appId, targetChatId, body, type, uuid, undefined, managedProviderOptions);
8370
+ },
8371
+ sendTitleSeed: async (title, uuid) => {
8372
+ await revalidateIsolatedOriginBeforeEffect();
8373
+ return sendMessage(appId, targetChatId, title, 'text', uuid);
8374
+ },
8375
+ replyRoot: async (root, body, type, uuid) => {
8376
+ await revalidateIsolatedOriginBeforeEffect();
8377
+ return replyMessage(appId, root, body, type, true, uuid, undefined, managedProviderOptions);
8378
+ },
6937
8379
  })
6938
8380
  : { handled: false };
6939
8381
  if (deferred.handled && deferred.messageId) {
@@ -6942,17 +8384,20 @@ async function cmdSend(rest) {
6942
8384
  messageId = deferred.messageId;
6943
8385
  }
6944
8386
  else {
6945
- const canonicalTarget = resolveSendTarget({
6946
- into: sendInto,
6947
- topLevel: sendTopLevel,
6948
- chatScope: s.scope === 'chat',
6949
- chatId: canonicalOutput.targetChatId,
6950
- rootMessageId: s.rootMessageId,
6951
- replyTargetRootId: turnReplyTarget?.rootMessageId,
6952
- replyTargetTurnId: turnReplyTarget?.turnId,
6953
- replyTargetQuoteOnly: turnReplyTarget?.quoteOnly,
6954
- currentTurnId,
6955
- });
8387
+ const canonicalTarget = !sendInto && !sendTopLevel && !overrideChatId && frozenTurnReplyTarget
8388
+ ? frozenTurnReplyTarget
8389
+ : resolveSendTarget({
8390
+ into: sendInto,
8391
+ topLevel: sendTopLevel,
8392
+ chatScope: s.scope === 'chat',
8393
+ chatId: canonicalOutput.targetChatId,
8394
+ rootMessageId: s.rootMessageId,
8395
+ replyTargetRootId: turnReplyTarget?.rootMessageId,
8396
+ replyTargetTurnId: turnReplyTarget?.turnId,
8397
+ replyTargetQuoteOnly: turnReplyTarget?.quoteOnly,
8398
+ currentTurnId,
8399
+ });
8400
+ await revalidateIsolatedOriginBeforeEffect();
6956
8401
  messageId = canonicalTarget.mode === 'plain'
6957
8402
  ? await sendMessage(appId, canonicalTarget.chatId, canonicalOutput.content, canonicalOutput.msgType, prepared?.providerKey, undefined, managedProviderOptions)
6958
8403
  : await replyMessage(appId, canonicalTarget.rootMessageId, canonicalOutput.content, canonicalOutput.msgType, canonicalTarget.mode === 'thread', prepared?.providerKey, undefined, managedProviderOptions);
@@ -7010,17 +8455,12 @@ async function cmdSend(rest) {
7010
8455
  return;
7011
8456
  }
7012
8457
  // ── 文档评论入口分流(/watch-comment / /subscribe-lark-doc)─────────────────
7013
- // 本轮若由飞书文档评论触发(daemon 已把落点写进 session.docCommentTargets[turnId]),
7014
- // 把用户可见回复发表为飞书文档评论,而非发回飞书会话。绕过 @ 硬门(评论不 @ 飞书
7015
- // 用户)。显式改路由(--top-level / --chat-id / --into)时不分流,让模型仍能主动
7016
- // 用 BOTMUX_TURN_ID 从 per-turn map 取本轮落点;非文档轮的 turnId 不会命中 map,
7017
- // 天然不会误投。per-turn map 设计:并发评论之间互不覆盖,不会串线。
7018
- const docTarget = currentTurnId ? s.docCommentTargets?.[currentTurnId] : undefined;
7019
- if (docTarget && !sendTopLevel && !overrideChatId && !sendInto) {
7020
- if (customCardRequested) {
7021
- console.error('botmux send: 文档评论回复不支持 --card-file/--card-json;请改用普通文本,或显式 --top-level/--chat-id 发到飞书群');
7022
- process.exit(2);
7023
- }
8458
+ // The authority and payload-shape checks ran before content/provider work.
8459
+ // Use only the trusted origin session here; `--session-id` is a destination
8460
+ // selector for ordinary Lark turns and cannot retarget a document turn.
8461
+ if (isOriginDocCommentTurn) {
8462
+ const exactDocTarget = docTarget;
8463
+ const exactDocSession = originSession;
7024
8464
  const { registerBot, loadBotConfigs } = await import('./bot-registry.js');
7025
8465
  try {
7026
8466
  for (const cfg of loadBotConfigs())
@@ -7034,14 +8474,13 @@ async function cmdSend(rest) {
7034
8474
  catch { /* */ }
7035
8475
  }
7036
8476
  const { replyToDocComment, chunkCommentText, removeCommentReaction } = await import('./im/lark/doc-comment.js');
7037
- const appId = s.larkAppId;
7038
- const loc = localeForBot(appId);
8477
+ const appId = exactDocSession.larkAppId;
7039
8478
  try {
7040
8479
  // @ 落点:--mention-back → 回 @ 原评论人;--mention <open_id[:name]> → @ 指定人;
7041
8480
  // 否则(--no-mention / 无)不 @。文档评论里靠 person 元素渲染 @,仅首块加。
7042
8481
  let docMentionOpenId;
7043
8482
  if (mentionBack)
7044
- docMentionOpenId = docTarget.replyToOpenId;
8483
+ docMentionOpenId = exactDocTarget.replyToOpenId;
7045
8484
  else if (mentionArgs.length > 0) {
7046
8485
  const first = mentionArgs[0];
7047
8486
  const idx = first.indexOf(':');
@@ -7050,11 +8489,11 @@ async function cmdSend(rest) {
7050
8489
  // 嵌套回复到用户那条评论 thread(已挂其下,无需 ↪ 前缀)。
7051
8490
  const chunks = chunkCommentText(content);
7052
8491
  for (let i = 0; i < chunks.length; i++) {
7053
- await replyToDocComment(appId, { fileToken: docTarget.fileToken, fileType: docTarget.fileType }, docTarget.commentId, chunks[i], i === 0 ? docMentionOpenId : undefined);
8492
+ await replyToDocComment(appId, { fileToken: exactDocTarget.fileToken, fileType: exactDocTarget.fileType }, exactDocTarget.commentId, chunks[i], i === 0 ? docMentionOpenId : undefined, { beforeProviderEffect: fenceIsolatedOriginBeforeEffect });
7054
8493
  }
7055
8494
  // 清理 "Typing" reaction(bot 已回复完毕)。
7056
- if (docTarget.reactionId && docTarget.replyId) {
7057
- await removeCommentReaction(appId, { fileToken: docTarget.fileToken, fileType: docTarget.fileType }, docTarget.commentId, docTarget.replyId, docTarget.reactionId);
8495
+ if (exactDocTarget.reactionId && exactDocTarget.replyId) {
8496
+ await removeCommentReaction(appId, { fileToken: exactDocTarget.fileToken, fileType: exactDocTarget.fileType }, exactDocTarget.commentId, exactDocTarget.replyId, exactDocTarget.reactionId, { beforeProviderEffect: fenceIsolatedOriginBeforeEffect });
7058
8497
  }
7059
8498
  // 写 bridge send marker → 抑制 worker 的 final_output 兜底(否则会再补一条评论)。
7060
8499
  try {
@@ -7063,25 +8502,20 @@ async function cmdSend(rest) {
7063
8502
  mkdirSync(markerDir, { recursive: true });
7064
8503
  const marker = {
7065
8504
  sentAtMs: Date.now(),
7066
- messageId: `doc:${docTarget.commentId}`,
8505
+ messageId: `doc:${exactDocTarget.commentId}`,
7067
8506
  contentLength: content.length,
7068
8507
  };
7069
8508
  const previewText = buildBridgeSendPreviewText(content);
7070
8509
  if (previewText)
7071
8510
  marker.previewText = previewText;
7072
- appendFileSync(join(markerDir, `${sid}.jsonl`), JSON.stringify(marker) + '\n');
8511
+ appendFileSync(join(markerDir, `${originSessionId}.jsonl`), JSON.stringify(marker) + '\n');
7073
8512
  }
7074
8513
  catch { /* best-effort:漏记只多一条兜底 */ }
7075
- // 清理已消费的 per-turn 落点,避免 session 文件无限堆积。
7076
- if (s.docCommentTargets && currentTurnId && s.docCommentTargets[currentTurnId]) {
7077
- delete s.docCommentTargets[currentTurnId];
7078
- try {
7079
- saveSession(s);
7080
- }
7081
- catch { /* best-effort */ }
7082
- }
7083
- console.error(`✓ 已回复文档评论 ${docTarget.commentId.slice(0, 12)}(${chunks.length} 条)`);
7084
- console.log(JSON.stringify({ success: true, commentId: docTarget.commentId, sessionId: sid, kind: 'doc-comment', chunks: chunks.length }));
8514
+ // Do not write this startup snapshot back after the async provider calls:
8515
+ // the daemon may have advanced the dispatch ledger or accepted another
8516
+ // turn in the meantime. Daemon settlement owns exact target retirement.
8517
+ console.error(`✓ 已回复文档评论 ${exactDocTarget.commentId.slice(0, 12)}(${chunks.length} 条)`);
8518
+ console.log(JSON.stringify({ success: true, commentId: exactDocTarget.commentId, sessionId: originSessionId, kind: 'doc-comment', chunks: chunks.length }));
7085
8519
  }
7086
8520
  catch (e) {
7087
8521
  console.error(`文档评论发送失败:${e?.message ?? e}`);
@@ -7104,7 +8538,16 @@ async function cmdSend(rest) {
7104
8538
  }
7105
8539
  }
7106
8540
  const replyTargetSenderOpenId = explicitVcMeetingImOrigin?.replyTargetSenderOpenId
7107
- ?? s.quoteTargetSenderOpenId;
8541
+ ?? frozenTurnDispatch?.replyTargetSenderOpenId
8542
+ ?? turnReplyTarget?.senderOpenId
8543
+ // #750 exact-turn contract: the global latest-slot quote sender may ONLY be
8544
+ // borrowed as a legacy fallback when there is NO currentTurnId (true
8545
+ // legacy/no-turn send). With a turnId, an exact-turn map miss/eviction must
8546
+ // resolve to NO sender — never the global slot, which may have advanced to a
8547
+ // DIFFERENT turn B and would mis-@ B as A's --mention-back (the cross-turn
8548
+ // bug #750 fixed). pickTurnReplyTarget already enforces
8549
+ // quoteTargetId===currentTurnId for its own hit.
8550
+ ?? (currentTurnId ? undefined : s.quoteTargetSenderOpenId);
7108
8551
  // @ hard-gate (config.send.requireMentionDecision, default on): force the
7109
8552
  // model to make an explicit @ decision before sending. --top-level publish
7110
8553
  // is exempt. The error text adapts to who is being replied to (人 / bot).
@@ -7120,12 +8563,8 @@ async function cmdSend(rest) {
7120
8563
  console.error(mentionGate.error);
7121
8564
  process.exit(2);
7122
8565
  }
7123
- // Register bots so the Lark client works. MUST run before the participant
7124
- // gate below: that gate calls getGroupStats → getBotClient, which throws
7125
- // "Bot not registered" in this standalone `botmux send` process (a fresh
7126
- // process with an empty registry) — getGroupStats would then soft-fail to
7127
- // {999,999} and wrongly block --mention-back even in a true 1v1. registerBot
7128
- // is idempotent, so the downstream send path reuses these same clients.
8566
+ // Register bots so the downstream Lark client works. registerBot is
8567
+ // idempotent, so all send paths reuse these same clients.
7129
8568
  // envPinnedRiffBot is re-registered LAST so a remote env credential is never
7130
8569
  // clobbered by a stale bots.json entry for the same app.
7131
8570
  const { registerBot, loadBotConfigs, findOncallChatForAnyBot, getBot } = await import('./bot-registry.js');
@@ -7141,29 +8580,20 @@ async function cmdSend(rest) {
7141
8580
  }
7142
8581
  catch { /* */ }
7143
8582
  }
7144
- // Participant gate for --mention-back: in a true 1v1 the triggerer is the
7145
- // only counterpart so auto-@-ing them back is unambiguous, but once a third
7146
- // party joins (humans + bots > 2) "who triggered this turn" is no longer
7147
- // reliably "who should be addressed". Force an explicit --mention there.
7148
- // Symmetric with the inbound un-@ gate (event-dispatcher getGroupStats).
7149
- // Only fetch when mention-back is actually requested AND the chat isn't a p2p
7150
- // DM (inherently 1v1) — keeps the common send path free of an API round-trip.
7151
- if (mentionBack && s.chatType !== 'p2p' && s.larkAppId && s.chatId && !sendTopLevel) {
7152
- try {
7153
- const { getGroupStats } = await import('./im/lark/event-dispatcher.js');
7154
- const { userCount, botCount } = await getGroupStats(s.larkAppId, s.chatId);
7155
- if (shouldBlockMentionBackByParticipants({ chatType: s.chatType, userCount, botCount })) {
7156
- console.error(`--mention-back 在多人会话(当前 ${userCount} 人 + ${botCount} bot)里不可用:`
7157
- + '"回复触发这轮的人/bot" 在多方场景可能 @ 错对象。请改用 --mention <ou:Name> 显式点名,'
7158
- + '或 --no-mention 不 @。');
7159
- process.exit(2);
7160
- }
7161
- }
7162
- catch (err) {
7163
- // getGroupStats already soft-fails to {999,999} (→ block) internally, so
7164
- // reaching here means the dynamic import itself failed. Fail-closed to a
7165
- // clear error rather than silently letting a possibly-wrong @ through.
7166
- console.error(`无法确认会话人数以校验 --mention-back:${err?.message ?? err}。请改用 --mention <ou:Name> 或 --no-mention。`);
8583
+ // Ambiguity gate for --mention-back. --mention-back means "@ back the one
8584
+ // counterpart who triggered this turn"; that is only unambiguous when this
8585
+ // turn's window had a single counterpart. Once 2+ distinct people/bots took
8586
+ // part (a human + a peer bot, two humans, the triggerer plus someone they
8587
+ // @-ed, a type-ahead follow-up from a third party, …), block --mention-back
8588
+ // and hand the model the exact candidates so it can --mention <open_id> the
8589
+ // right one instead of guessing (or mis-@-ing the lone human). Reads the
8590
+ // persisted per-turn participant window — no group-stats round-trip. Explicit
8591
+ // VC turns carry their own single-target origin, so they skip this gate.
8592
+ if (mentionBack && !explicitVcMeetingImOrigin && !sendTopLevel) {
8593
+ const window = collectTurnWindowParticipants(s, currentTurnId);
8594
+ const ambiguity = mentionBackAmbiguity({ chatType: s.chatType, participants: window.participants, incomplete: window.incomplete });
8595
+ if (ambiguity.ambiguous) {
8596
+ console.error(mentionBackAmbiguityError(ambiguity.candidates, ambiguity.incomplete));
7167
8597
  process.exit(2);
7168
8598
  }
7169
8599
  }
@@ -7198,7 +8628,12 @@ async function cmdSend(rest) {
7198
8628
  // Compute the actual outbound anchor before the advisory guard. A chat-scope
7199
8629
  // sender can still reply into a per-turn topic, so sender scope alone does
7200
8630
  // not describe which peer sessions are reachable.
7201
- const sendTarget = resolveSendTarget({ into: sendInto, topLevel: sendTopLevel, chatScope: isChatScope, chatId: targetChatId, rootMessageId: s.rootMessageId, replyTargetRootId: turnReplyTarget?.rootMessageId, replyTargetTurnId: turnReplyTarget?.turnId, replyTargetQuoteOnly: turnReplyTarget?.quoteOnly, currentTurnId });
8631
+ // #597: a frozen per-turn reply target (from a Codex App dispatch) overrides
8632
+ // the nominal resolveSendTarget in chat scope so a steered/dispatched turn
8633
+ // replies to its own origin, not the session's latest human quote target.
8634
+ const sendTarget = !sendInto && !sendTopLevel && !overrideChatId && frozenTurnReplyTarget
8635
+ ? frozenTurnReplyTarget
8636
+ : resolveSendTarget({ into: sendInto, topLevel: sendTopLevel, chatScope: isChatScope, chatId: targetChatId, rootMessageId: s.rootMessageId, replyTargetRootId: turnReplyTarget?.rootMessageId, replyTargetTurnId: turnReplyTarget?.turnId, replyTargetQuoteOnly: turnReplyTarget?.quoteOnly, currentTurnId });
7202
8637
  const dataDir = resolveDataDir();
7203
8638
  const deferredBinding = !sendInto && (!overrideChatId || overrideChatId === s.chatId)
7204
8639
  ? readDeferredTopicBinding(dataDir, s.sessionId)
@@ -7318,7 +8753,9 @@ async function cmdSend(rest) {
7318
8753
  // Ordinary delivery uses the nominal target. Deferred delivery gets first
7319
8754
  // refusal below; the advisory mirrors its existing binding in
7320
8755
  // `reachabilityTarget` above so both paths agree about the effective root.
7321
- const dispatch = async (content, msgType, uuid, suppressHook) => {
8756
+ // (sendTarget itself is defined once above, carrying #597's frozen per-turn
8757
+ // reply-target override.)
8758
+ const dispatchAfterOriginGate = async (content, msgType, uuid, suppressHook) => {
7322
8759
  // This closure also carries attachments, so every Lark call re-checks the
7323
8760
  // exact durable attempt/member instead of inheriting the early cmd gate.
7324
8761
  revalidateVcMeetingManagedSend();
@@ -7332,12 +8769,21 @@ async function cmdSend(rest) {
7332
8769
  content,
7333
8770
  msgType,
7334
8771
  uuid,
7335
- sendRoot: (body, type, rootUuid) => sendMessage(appId, targetChatId, body, type, rootUuid, hookContext, suppressHook ? { suppressHook: true } : undefined),
8772
+ sendRoot: async (body, type, rootUuid) => {
8773
+ await revalidateIsolatedOriginBeforeEffect();
8774
+ return sendMessage(appId, targetChatId, body, type, rootUuid, hookContext, outboundMessageOptions(!!suppressHook));
8775
+ },
7336
8776
  // The optional title is the root seed and the actual alert follows as
7337
8777
  // its first reply. Do not emit a user outbound hook for presentation-
7338
8778
  // only seed text; the alert itself still goes through the hook path.
7339
- sendTitleSeed: (title, rootUuid) => sendMessage(appId, targetChatId, title, 'text', rootUuid),
7340
- replyRoot: (root, body, type, replyUuid) => replyMessage(appId, root, body, type, true, replyUuid, hookContext, suppressHook ? { suppressHook: true } : undefined),
8779
+ sendTitleSeed: async (title, rootUuid) => {
8780
+ await revalidateIsolatedOriginBeforeEffect();
8781
+ return sendMessage(appId, targetChatId, title, 'text', rootUuid);
8782
+ },
8783
+ replyRoot: async (root, body, type, replyUuid) => {
8784
+ await revalidateIsolatedOriginBeforeEffect();
8785
+ return replyMessage(appId, root, body, type, true, replyUuid, hookContext, outboundMessageOptions(!!suppressHook));
8786
+ },
7341
8787
  });
7342
8788
  if (deferred.handled && deferred.messageId) {
7343
8789
  deferredMaterializedByThisCommand ||= deferred.materializedNow === true;
@@ -7345,9 +8791,14 @@ async function cmdSend(rest) {
7345
8791
  return deferred.messageId;
7346
8792
  }
7347
8793
  }
8794
+ await revalidateIsolatedOriginBeforeEffect();
7348
8795
  return sendTarget.mode === 'plain'
7349
- ? sendMessage(appId, sendTarget.chatId, content, msgType, uuid, hookContext, suppressHook ? { suppressHook: true } : undefined)
7350
- : replyMessage(appId, sendTarget.rootMessageId, content, msgType, sendTarget.mode === 'thread', uuid, hookContext, suppressHook ? { suppressHook: true } : undefined);
8796
+ ? await sendMessage(appId, sendTarget.chatId, content, msgType, uuid, hookContext, outboundMessageOptions(!!suppressHook))
8797
+ : await replyMessage(appId, sendTarget.rootMessageId, content, msgType, sendTarget.mode === 'thread', uuid, hookContext, outboundMessageOptions(!!suppressHook));
8798
+ };
8799
+ const dispatch = async (content, msgType, uuid, suppressHook) => {
8800
+ await revalidateIsolatedOriginBeforeEffect();
8801
+ return dispatchAfterOriginGate(content, msgType, uuid, suppressHook);
7351
8802
  };
7352
8803
  const recordBridgeSendMarker = (sentAtMs, messageId, sentContent) => {
7353
8804
  try {
@@ -7372,11 +8823,13 @@ async function cmdSend(rest) {
7372
8823
  // belong to another queued IM turn and is not part of the delivery action.
7373
8824
  sessionQuoteTargetId: vcMeetingDeliveryReplyOrigin
7374
8825
  ? undefined
7375
- : explicitVcMeetingImOrigin?.larkMessageId ?? s.quoteTargetId,
8826
+ : explicitVcMeetingImOrigin?.larkMessageId
8827
+ ?? frozenTurnDispatch?.quoteTargetId
8828
+ ?? s.quoteTargetId,
7376
8829
  });
7377
8830
  let primaryQuotedId = null;
7378
8831
  let vcMeetingListenerReplyReplay = false;
7379
- const dispatchPrimary = async (content, msgType) => {
8832
+ const dispatchPrimary = async (content, msgType, originAlreadyRevalidated = false) => {
7380
8833
  // `dispatchPrimaryMessage` may call replyMessage directly for a quote, so
7381
8834
  // fence immediately before preparing/performing that primary effect too.
7382
8835
  revalidateVcMeetingManagedSend();
@@ -7422,16 +8875,28 @@ async function cmdSend(rest) {
7422
8875
  ...(prepared ? { suppressHook: true } : {}),
7423
8876
  hookContext,
7424
8877
  MessageWithdrawnError,
8878
+ ...(isolatedHookOrigin
8879
+ ? {
8880
+ beforeHook: fenceIsolatedOriginBeforeEffect,
8881
+ hookOrigin: isolatedHookOrigin,
8882
+ }
8883
+ : {}),
7425
8884
  // Explicit VC IM --into is rejected above, so an unquoted first send
7426
8885
  // retains the normal chat-scope dispatch semantics. On a mismatch the
7427
8886
  // frozen canonical target remains authoritative.
7428
8887
  dispatch: prepared?.outputMismatch
7429
- ? (body, type, uuid, suppressHook) => {
8888
+ ? async (body, type, uuid, suppressHook) => {
7430
8889
  revalidateVcMeetingManagedSend();
7431
- return sendMessage(appId, canonicalOutput.targetChatId, body, type, uuid, hookContext, suppressHook ? { suppressHook: true } : undefined);
8890
+ return sendMessage(appId, canonicalOutput.targetChatId, body, type, uuid, hookContext, outboundMessageOptions(!!suppressHook));
7432
8891
  }
7433
- : dispatch,
7434
- beforeQuoteFallback: revalidateVcMeetingManagedSend,
8892
+ : dispatchAfterOriginGate,
8893
+ beforeEffect: originAlreadyRevalidated
8894
+ ? undefined
8895
+ : fenceIsolatedOriginBeforeEffect,
8896
+ beforeQuoteFallback: async () => {
8897
+ revalidateVcMeetingManagedSend();
8898
+ await revalidateIsolatedOriginBeforeEffect();
8899
+ },
7435
8900
  onQuoteWithdrawn: (id) => {
7436
8901
  console.error(`引用目标 ${id} 已撤回,改为普通发送`);
7437
8902
  },
@@ -7477,8 +8942,10 @@ async function cmdSend(rest) {
7477
8942
  // managed side-effect gate above.
7478
8943
  const imageKeys = [];
7479
8944
  if (images.length > 0) {
7480
- const results = await Promise.all(images.map(p => uploadImage(appId, p)));
7481
- imageKeys.push(...results);
8945
+ for (const imagePath of images) {
8946
+ await revalidateIsolatedOriginBeforeEffect();
8947
+ imageKeys.push(await uploadImage(appId, imagePath));
8948
+ }
7482
8949
  }
7483
8950
  // Auto-detect @BotName in text and inject as mentions, using the sender
7484
8951
  // app's cross-ref file for per-app-scoped open_ids. Without this, a plain
@@ -7574,13 +9041,20 @@ async function cmdSend(rest) {
7574
9041
  // --no-mention 显式不 @ 任何人 → 连 footer 的"发送给/cc"寻址 <at> 也清空,
7575
9042
  // 否则 footer 仍会 @ 人,与 --no-mention 语义和"未@任何人"输出自相矛盾
7576
9043
  // (Codex review P2)。--top-level 同样无特定收件人。
9044
+ const frozenFooterAddressingSource = explicitVcMeetingImOrigin?.replyTargetSenderOpenId
9045
+ ? { ...s, lastCallerOpenId: explicitVcMeetingImOrigin.replyTargetSenderOpenId }
9046
+ : frozenTurnDispatch?.replyTargetSenderOpenId
9047
+ ? {
9048
+ ...s,
9049
+ lastCallerOpenId: frozenTurnDispatch.replyTargetSenderOpenId,
9050
+ lastCallerIsBot: frozenTurnDispatch.replyTargetSenderIsBot,
9051
+ }
9052
+ : s;
7577
9053
  const footerAddressing = (sendTopLevel || noMention)
7578
9054
  ? { sendTo: undefined, cc: [] }
7579
- : buildFooterAddressing(explicitVcMeetingImOrigin?.replyTargetSenderOpenId
7580
- ? { ...s, lastCallerOpenId: explicitVcMeetingImOrigin.replyTargetSenderOpenId }
7581
- : s, {
9055
+ : buildFooterAddressing(frozenFooterAddressingSource, {
7582
9056
  isOncall: !!oncallEntry,
7583
- isSubstitute: turnReplyTarget?.turnId === currentTurnId && turnReplyTarget?.substitute === true,
9057
+ isSubstitute: isChatScope && turnReplyTarget?.turnId === currentTurnId && turnReplyTarget?.substitute === true,
7584
9058
  hasExplicitBotMention: explicitKnownBotMention,
7585
9059
  knownBotOpenIds,
7586
9060
  });
@@ -7635,8 +9109,9 @@ async function cmdSend(rest) {
7635
9109
  const videoResult = await sendVideoAttachments({
7636
9110
  uploadFile,
7637
9111
  uploadImage,
7638
- dispatch,
7639
- primaryDispatch: dispatchPrimary,
9112
+ dispatch: dispatchAfterOriginGate,
9113
+ primaryDispatch: (body, type) => dispatchPrimary(body, type, true),
9114
+ beforeEffect: fenceIsolatedOriginBeforeEffect,
7640
9115
  ...(vcMeetingManagedSendOrigin ? { maxMessages: 1 } : {}),
7641
9116
  }, appId, videoAttachments);
7642
9117
  failedVideoAttachments = videoResult.failed;
@@ -7772,8 +9247,8 @@ async function cmdSend(rest) {
7772
9247
  // message above is the primary and failures before any media is sent still
7773
9248
  // surface as command failure.
7774
9249
  if (!pureVideoSend && !vcMeetingListenerReplyReplay) {
7775
- ({ failed: failedAttachments } = await sendFileAttachments({ uploadFile, dispatch }, appId, files));
7776
- const videoResult = await sendVideoAttachments({ uploadFile, uploadImage, dispatch }, appId, videoAttachments);
9250
+ ({ failed: failedAttachments } = await sendFileAttachments({ uploadFile, dispatch: dispatchAfterOriginGate, beforeEffect: fenceIsolatedOriginBeforeEffect }, appId, files));
9251
+ const videoResult = await sendVideoAttachments({ uploadFile, uploadImage, dispatch: dispatchAfterOriginGate, beforeEffect: fenceIsolatedOriginBeforeEffect }, appId, videoAttachments);
7777
9252
  failedVideoAttachments = videoResult.failed;
7778
9253
  }
7779
9254
  for (const f of failedAttachments) {
@@ -7791,6 +9266,14 @@ async function cmdSend(rest) {
7791
9266
  ? `@${mentions.map(m => m.name || m.open_id).join(',')}`
7792
9267
  : '未@任何人';
7793
9268
  console.error(`✓ 已发送 ${messageId} | ${primaryQuotedId ? `引用 ${primaryQuotedId}` : '未引用'} | ${atSummary}`);
9269
+ // Sentinel guidance is surfaced HERE — in the send-success output the model
9270
+ // reads back — rather than only in the injected system prompt. A model only
9271
+ // learns about BOTMUX_NOTHING_TO_SEND after it has actually sent, so it
9272
+ // cannot use the sentinel to end a turn where it did work but forgot to send
9273
+ // (the ghosting shape). The injected prompt keeps a one-line sentinel note
9274
+ // only for the genuine never-send silence case (message addressed to another
9275
+ // bot). See services/bridge-fallback-gate.ts for the matching strip-and-forward gate.
9276
+ console.error(t('ai.send.after_success_hint', undefined, localeForBot(appId)));
7794
9277
  // --attention: message is already delivered above; now flip the dashboard
7795
9278
  // needs-you state via the daemon (botmux send is direct-to-Lark, so the
7796
9279
  // daemon-held ds.agentAttention must be set out-of-band). Best-effort: a
@@ -7800,10 +9283,24 @@ async function cmdSend(rest) {
7800
9283
  let attentionError;
7801
9284
  if (attention.requested) {
7802
9285
  try {
7803
- const daemon = findDaemon(appId);
7804
- if (!daemon)
7805
- throw new Error(`找不到 daemon (larkAppId=${appId})`);
7806
- const originCapability = readManagedOriginCapability(resolveDataDir(), sid, process.env.BOTMUX_SEND_RELAY)?.capability;
9286
+ // Raising attention is a second externally visible effect after the
9287
+ // awaited Lark send. Bind it to a fresh proof of the same original
9288
+ // turn instead of re-reading a stable path that may now belong to a
9289
+ // successor worker generation.
9290
+ const freshIsolatedOrigin = await revalidateIsolatedOriginBeforeEffect();
9291
+ const attentionOrigin = freshIsolatedOrigin ?? liveMarkerCtx;
9292
+ const daemon = freshIsolatedOrigin ? undefined : findDaemon(appId);
9293
+ const attentionPort = freshIsolatedOrigin
9294
+ ? isolatedAttestationContext?.ipcPortFallback
9295
+ : daemon?.ipcPort;
9296
+ if (!attentionPort) {
9297
+ throw new Error(freshIsolatedOrigin
9298
+ ? '受保护的 managed-origin claim 未提供 owning daemon 端口'
9299
+ : `找不到 daemon (larkAppId=${appId})`);
9300
+ }
9301
+ const originCapability = freshIsolatedOrigin
9302
+ ? isolatedAttestationContext?.capability
9303
+ : readManagedOriginCapability(resolveDataDir(), sid, process.env.BOTMUX_SEND_RELAY, process.env.BOTMUX_ORIGIN_CHANNEL_ID)?.capability;
7807
9304
  const request = {
7808
9305
  method: 'POST',
7809
9306
  headers: { 'content-type': 'application/json' },
@@ -7814,8 +9311,8 @@ async function cmdSend(rest) {
7814
9311
  kind: attention.kind,
7815
9312
  reason: text.trim(),
7816
9313
  originCapability,
7817
- originTurnId: liveMarkerCtx?.turnId,
7818
- originDispatchAttempt: liveMarkerCtx?.dispatchAttempt,
9314
+ originTurnId: attentionOrigin?.turnId,
9315
+ originDispatchAttempt: attentionOrigin?.dispatchAttempt,
7819
9316
  }),
7820
9317
  };
7821
9318
  let secret;
@@ -7824,8 +9321,8 @@ async function cmdSend(rest) {
7824
9321
  }
7825
9322
  catch { /* Seatbelt/read-isolated CLI */ }
7826
9323
  const res = secret
7827
- ? await fetchDaemonIpc(daemon.ipcPort, '/api/attention', request, secret)
7828
- : await fetch(`http://127.0.0.1:${daemon.ipcPort}/api/attention`, request);
9324
+ ? await fetchDaemonIpc(attentionPort, '/api/attention', request, secret)
9325
+ : await fetch(`http://127.0.0.1:${attentionPort}/api/attention`, request);
7829
9326
  if (!res.ok)
7830
9327
  throw new Error(`daemon HTTP ${res.status}`);
7831
9328
  attentionRaised = true;
@@ -8914,16 +10411,20 @@ botmux create-group — 用一组机器人新建飞书群
8914
10411
  // the daemon dies). See /tmp/botmux-ask.md (or design memory).
8915
10412
  /**
8916
10413
  * postAsk: 找到 daemon → POST /api/asks → 返回 AskResult。
8917
- * 连接失败 / HTTP 错误时抛出带 exitCode 属性的 Error:
8918
- * - exitCode=3:daemon 不可达或 HTTP 错误
10414
+ * 连接失败 / HTTP 错误时抛出带 `exitCode` + `retryable` 属性的 Error:
10415
+ * - exitCode=3:daemon 不可达或 HTTP 错误(保持向后兼容)
10416
+ * - retryable=true:仅当 daemon 不可达 / 网络失败 / 明确的 transient HTTP
10417
+ * (502/503/504,含 daemon 启动尚未就绪) 时。这些正是"daemon 重启中"的信号,
10418
+ * runHook 会重连重试。确定性 4xx(bad body / capability 拒绝 / unsupported)
10419
+ * 与非 JSON 是 retryable=false —— 重试 24h 也不会变,应立即 passthrough。
8919
10420
  */
8920
10421
  async function postAsk(body) {
10422
+ const mkErr = (message, retryable) => Object.assign(new Error(message), { exitCode: 3, retryable });
8921
10423
  const larkAppId = body.larkAppId;
8922
10424
  const daemon = findDaemon(larkAppId);
8923
10425
  if (!daemon) {
8924
- const err = new Error(`botmux ask: 找不到 daemon (larkAppId=${larkAppId})。daemon 已停?exit 3.`);
8925
- err.exitCode = 3;
8926
- throw err;
10426
+ // No daemon record → it's (re)starting or momentarily gone → retryable.
10427
+ throw mkErr(`botmux ask: 找不到 daemon (larkAppId=${larkAppId})。daemon 已停?exit 3.`, true);
8927
10428
  }
8928
10429
  let res;
8929
10430
  try {
@@ -8932,7 +10433,7 @@ async function postAsk(body) {
8932
10433
  const sessionId = typeof requestBody.sessionId === 'string'
8933
10434
  ? requestBody.sessionId
8934
10435
  : undefined;
8935
- const claim = readManagedOriginCapability(resolveDataDir(), sessionId, process.env.BOTMUX_SEND_RELAY);
10436
+ const claim = readManagedOriginCapability(resolveDataDir(), sessionId, process.env.BOTMUX_SEND_RELAY, process.env.BOTMUX_ORIGIN_CHANNEL_ID);
8936
10437
  if (claim)
8937
10438
  requestBody.originCapability = claim.capability;
8938
10439
  }
@@ -8955,10 +10456,9 @@ async function postAsk(body) {
8955
10456
  : await fetch(`http://127.0.0.1:${daemon.ipcPort}/api/asks`, init);
8956
10457
  }
8957
10458
  catch (fetchErr) {
10459
+ // Socket refused / reset / timeout → daemon is down or restarting → retryable.
8958
10460
  const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
8959
- const err = new Error(`botmux ask: 无法连接 daemon (port=${daemon.ipcPort}): ${msg}`);
8960
- err.exitCode = 3;
8961
- throw err;
10461
+ throw mkErr(`botmux ask: 无法连接 daemon (port=${daemon.ipcPort}): ${msg}`, true);
8962
10462
  }
8963
10463
  if (!res.ok) {
8964
10464
  let errBody = '';
@@ -8966,17 +10466,19 @@ async function postAsk(body) {
8966
10466
  errBody = (await res.text()).slice(0, 200);
8967
10467
  }
8968
10468
  catch { /* */ }
8969
- const err = new Error(`botmux ask: daemon HTTP ${res.status}: ${errBody}`);
8970
- err.exitCode = 3;
8971
- throw err;
10469
+ // Only transient server states are retryable. A deterministic 4xx (bad
10470
+ // body, capability denied, unsupported chat) will fail identically forever;
10471
+ // 502/503/504 mean the daemon is up but not ready (startup window) → retry.
10472
+ // Shared pure classifier (unit-tested directly — codex P1-3 seam).
10473
+ const retryable = isRetryableAskHttpStatus(res.status);
10474
+ throw mkErr(`botmux ask: daemon HTTP ${res.status}: ${errBody}`, retryable);
8972
10475
  }
8973
10476
  try {
8974
10477
  return (await res.json());
8975
10478
  }
8976
10479
  catch (jsonErr) {
8977
- const err = new Error(`botmux ask: daemon 返回非 JSON: ${jsonErr}`);
8978
- err.exitCode = 3;
8979
- throw err;
10480
+ // A malformed body is not something a retry fixes.
10481
+ throw mkErr(`botmux ask: daemon 返回非 JSON: ${jsonErr}`, false);
8980
10482
  }
8981
10483
  }
8982
10484
  async function cmdAsk(sub, rest) {
@@ -9034,7 +10536,7 @@ async function cmdAsk(sub, rest) {
9034
10536
  const askSessionId = process.env.BOTMUX_SESSION_ID;
9035
10537
  const liveAskOrigin = resolveSessionContext(resolveDataDir(), askSessionId);
9036
10538
  const askRelayDir = process.env.BOTMUX_SEND_RELAY;
9037
- const askOriginCapability = readManagedOriginCapability(resolveDataDir(), askSessionId, askRelayDir)?.capability;
10539
+ const askOriginCapability = readManagedOriginCapability(resolveDataDir(), askSessionId, askRelayDir, process.env.BOTMUX_ORIGIN_CHANNEL_ID)?.capability;
9038
10540
  const body = {
9039
10541
  sessionId: askSessionId,
9040
10542
  chatId: process.env.BOTMUX_CHAT_ID,
@@ -9043,6 +10545,10 @@ async function cmdAsk(sub, rest) {
9043
10545
  options,
9044
10546
  prompt,
9045
10547
  timeoutMs,
10548
+ // Explicit `botmux ask buttons` has no reconnecting claimant (the CLI exits
10549
+ // on daemon restart), so mark it non-hook: the broker won't persist/handoff
10550
+ // it and can never confuse it with a hook ask's card (codex P1-4/P1-3).
10551
+ originKind: 'explicit',
9046
10552
  ...(liveAskOrigin?.turnId ? { originTurnId: liveAskOrigin.turnId } : {}),
9047
10553
  ...(liveAskOrigin?.dispatchAttempt !== undefined
9048
10554
  ? { originDispatchAttempt: liveAskOrigin.dispatchAttempt }
@@ -9163,8 +10669,14 @@ export async function runHook(payload, env, postAskFn, cliId, resolveAdoptRouteF
9163
10669
  routeLarkAppId = adopt.larkAppId;
9164
10670
  routeRoot = adopt.rootMessageId;
9165
10671
  }
9166
- // 解析 timeoutMs:默认 1 小时,可由 BOTMUX_ASK_TIMEOUT_MS 覆盖
9167
- const DEFAULT_TIMEOUT_MS = 3_600_000;
10672
+ // 解析 timeoutMs:默认 ~24h,可由 BOTMUX_ASK_TIMEOUT_MS 覆盖。
10673
+ // 为什么这么长:ask 超时不是良性兜底——broker settle 成 `timedOut` 会让 hook
10674
+ // 落到 passthrough,Claude 转而渲染原生 picker,而此后飞书回调已无通道把答案
10675
+ // 送回(picker 挂死、答案不生效)。所以默认值对齐 hook 安装侧的进程超时上限
10676
+ // (settings.json 里的 86400s),让 broker 不会 *早于* hook 进程本身超时;
10677
+ // 既避免"人回复慢→picker 卡死",又保留一个有限的进程级兜底(永不超时会让一次
10678
+ // CLI turn 无限阻塞,是更糟的失败)。
10679
+ const DEFAULT_TIMEOUT_MS = 86_400_000; // 24h — 对齐 hook 安装侧 timeout:86400s
9168
10680
  let timeoutMs = DEFAULT_TIMEOUT_MS;
9169
10681
  const timeoutEnv = env.BOTMUX_ASK_TIMEOUT_MS;
9170
10682
  if (timeoutEnv) {
@@ -9173,6 +10685,11 @@ export async function runHook(payload, env, postAskFn, cliId, resolveAdoptRouteF
9173
10685
  timeoutMs = parsed_timeout;
9174
10686
  }
9175
10687
  }
10688
+ // Per-invocation identity: generated ONCE here (outside the retry loop) and
10689
+ // reused across every reconnect POST, so a re-POST after a daemon restart
10690
+ // re-attaches to the same restored ask instead of posting a duplicate card.
10691
+ // originKind='hook' namespaces it away from an explicit `botmux ask buttons`.
10692
+ const requestId = randomUUID();
9176
10693
  const body = {
9177
10694
  sessionId: routeSessionId,
9178
10695
  chatId: routeChatId,
@@ -9180,14 +10697,47 @@ export async function runHook(payload, env, postAskFn, cliId, resolveAdoptRouteF
9180
10697
  rootMessageId: routeRoot,
9181
10698
  questions: parsed.questions,
9182
10699
  timeoutMs,
10700
+ requestId,
10701
+ originKind: 'hook',
9183
10702
  };
10703
+ // Post the ask, RETRYING across a daemon restart. The daemon holds pending
10704
+ // asks in memory only, so a restart between "card posted" and "user clicked"
10705
+ // drops the ask; historically postAskFn then threw (daemon unreachable) and
10706
+ // we fell straight to passthrough → the CLI rendered its native picker with no
10707
+ // way to receive the answer. Instead: while the daemon is unreachable (and
10708
+ // only then — an answered/timedOut/invalidated result returns normally), keep
10709
+ // reconnecting until the ask's own deadline. The daemon restores the pending
10710
+ // ask from disk on boot and re-attaches this reconnecting request to it by a
10711
+ // stable key, so the SAME card resolves through the normal hook directive.
10712
+ // Blocking here keeps Claude spinning; the native picker never renders.
10713
+ const deadline = Date.now() + timeoutMs;
9184
10714
  let result;
9185
- try {
9186
- result = await postAskFn(body);
9187
- }
9188
- catch {
9189
- // 任何失败(daemon 不可达、HTTP 错误等)→ passthrough 放行
9190
- return { stdout: adapter.passthrough(payload) };
10715
+ let attempt = 0;
10716
+ while (true) {
10717
+ try {
10718
+ result = await postAskFn(body);
10719
+ break;
10720
+ }
10721
+ catch (err) {
10722
+ // Retry ONLY genuinely transient failures (daemon unreachable / network /
10723
+ // 502-503-504 startup-not-ready — see postAsk's `retryable`). That is the
10724
+ // restart-in-progress case. A deterministic error (4xx bad body /
10725
+ // capability / unsupported, non-JSON) has retryable=false → passthrough
10726
+ // immediately rather than spin for 24h. A non-coded throw is also treated
10727
+ // as non-retryable.
10728
+ const retryable = err?.retryable === true;
10729
+ if (!retryable || Date.now() >= deadline) {
10730
+ return { stdout: adapter.passthrough(payload) };
10731
+ }
10732
+ attempt++;
10733
+ // Backoff: quick first reconnects (daemon usually returns in a few
10734
+ // seconds), capped at 5s. Never sleep past the deadline.
10735
+ const backoff = Math.min(5_000, 500 * attempt);
10736
+ const remaining = deadline - Date.now();
10737
+ if (remaining <= 0)
10738
+ return { stdout: adapter.passthrough(payload) };
10739
+ await new Promise((r) => setTimeout(r, Math.min(backoff, remaining)));
10740
+ }
9191
10741
  }
9192
10742
  if (result.kind === 'answered') {
9193
10743
  return { stdout: adapter.formatAnswer(result.answers, parsed, result.comment) };
@@ -9280,7 +10830,7 @@ async function cmdSessionReady() {
9280
10830
  if (ipcPort) {
9281
10831
  try {
9282
10832
  const relayDir = process.env.BOTMUX_SEND_RELAY;
9283
- const originCapability = readManagedOriginCapability(resolveDataDir(), sessionId, relayDir)?.capability;
10833
+ const originCapability = readManagedOriginCapability(resolveDataDir(), sessionId, relayDir, process.env.BOTMUX_ORIGIN_CHANNEL_ID)?.capability;
9284
10834
  const liveOrigin = resolveSessionContext(resolveDataDir(), sessionId);
9285
10835
  const envAttempt = Number(process.env.BOTMUX_DISPATCH_ATTEMPT);
9286
10836
  const originTurnId = liveOrigin?.turnId ?? process.env.BOTMUX_TURN_ID;
@@ -10430,6 +11980,9 @@ if (LARK_FACING_COMMANDS.has(command) && managedOriginHasNoTransport()) {
10430
11980
  process.exit(2);
10431
11981
  }
10432
11982
  switch (command) {
11983
+ case '__pm2-start-exact':
11984
+ await cmdInternalPm2StartExact(process.argv.slice(3));
11985
+ break;
10433
11986
  case '--version':
10434
11987
  case '-v':
10435
11988
  console.log(getVersion());