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
@@ -7,7 +7,7 @@ import { join, dirname } from 'node:path';
7
7
  import { homedir } from 'node:os';
8
8
  import { readFileSync, readdirSync, mkdirSync, existsSync, realpathSync, unlinkSync } from 'node:fs';
9
9
  import { atomicWriteFileSync } from '../utils/atomic-write.js';
10
- import { fileURLToPath } from 'node:url';
10
+ import { fileURLToPath, pathToFileURL } from 'node:url';
11
11
  import { ensureSkills, ensureAskSkill, ensurePluginSkills, ensureWhiteboardSkill, removeGlobalBotmuxSkills } from '../skills/installer.js';
12
12
  import { shouldInstallGlobalSkills } from '../skills/injection-mode.js';
13
13
  import { whiteboardEnabled } from '../services/whiteboard-store.js';
@@ -20,12 +20,12 @@ import * as sessionStore from '../services/session-store.js';
20
20
  import * as asyncTriggerStore from '../services/async-trigger-store.js';
21
21
  import { markMessageListenerRunPreviewFailed, markMessageListenerRunPreviewReplied, markMessageListenerRunPreviewRunning, } from '../services/message-listener-run-preview-store.js';
22
22
  import { persistStreamCardState, rememberLastCliInput } from './session-manager.js';
23
- import { fallbackTurnId, isSubstituteTurn } from './reply-target.js';
23
+ import { fallbackTurnId, frozenReplyContextForTurn, isSubstituteTurn } from './reply-target.js';
24
24
  import { updateMessage, deleteMessage, sendEphemeralCard, sendUserMessage, addReaction, removeReaction, getMessageChatId, MessageWithdrawnError } from '../im/lark/client.js';
25
25
  import { buildStreamingCard, buildPrivateSnapshotCard, buildSessionCard, buildTuiPromptCard, buildTuiPromptResolvedCard, buildTuiPromptFailedCard, buildRelayedFrozenCard, getCliDisplayName } from '../im/lark/card-builder.js';
26
26
  import { codexServiceTierBadge } from '../services/codex-service-tier.js';
27
27
  import { loadFrozenCards, saveFrozenCards } from '../services/frozen-card-store.js';
28
- import { hashUrlForLog, cancelRiffTaskById } from '../adapters/backend/riff-backend.js';
28
+ import { hashUrlForLog } from '../adapters/backend/riff-backend.js';
29
29
  import { logger } from '../utils/logger.js';
30
30
  import { createCliAdapterSync } from '../adapters/cli/registry.js';
31
31
  import { resolveCliRuntime, runtimeInstallationKey, runtimePathOverride, snapshotCliRuntime, } from '../adapters/cli/runtime.js';
@@ -44,7 +44,8 @@ import { ZmxBackend } from '../adapters/backend/zmx-backend.js';
44
44
  import { backendSupportsWebTerminal } from '../adapters/backend/capabilities.js';
45
45
  import { sandboxEnabled } from '../adapters/backend/sandbox.js';
46
46
  import { isStrongManagedHerdrAgentName, managedHerdrAgentName, } from '../adapters/backend/session-backend-selector.js';
47
- import { isSuspendableBackendType, getSessionPersistentBackendType, persistentBackendTargetForSession, persistentSessionName, killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, resolvePairedSpawnBackendType, resolvePersistentBackendTarget } from './persistent-backend.js';
47
+ import { isRiffBackendSession, isSuspendableBackendType, getSessionPersistentBackendType, persistentBackendTargetForSession, persistentSessionName, killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, resolvePairedSpawnBackendType, resolvePersistentBackendTarget } from './persistent-backend.js';
48
+ import { withBotTurnMutation } from './bot-turn-mutation-gate.js';
48
49
  import { getBot, getAllBots, resolveBrandLabel, getLoadedConfigPath, resolveUsageDisplay } from '../bot-registry.js';
49
50
  import { RestartCoordinator } from './restart-coordinator.js';
50
51
  import { runtimeBuildIdentity } from '../utils/runtime-build-id.js';
@@ -55,6 +56,11 @@ const DAEMON_BOOT_ID = randomUUID();
55
56
  const restartCoordinator = new RestartCoordinator();
56
57
  const lifecycleRetiringWorkers = new WeakMap();
57
58
  const transferRetiringWorkers = new WeakSet();
59
+ /** 在完整 Worker 模块加载前接住首条 IPC,避免冷启动耗时被误判为投递失败。 */
60
+ function workerForkExecArgv() {
61
+ const preloadPath = join(__dirname, '..', 'worker-ipc-preload.js');
62
+ return [...process.execArgv, '--import', pathToFileURL(preloadPath).href];
63
+ }
58
64
  function trackLifecycleRetirement(ds, worker) {
59
65
  let workers = lifecycleRetiringWorkers.get(ds);
60
66
  if (!workers) {
@@ -163,15 +169,37 @@ export function getDaemonReplyCardUsageSnapshot(ds, effectiveCliId) {
163
169
  * boundaries (turn end / idle); intra-turn ticks leave it false to ride the
164
170
  * reader's reparse throttle and stay off the disk. */
165
171
  export function getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId, opts) {
172
+ // Runtime identity is derived from in-memory fields only, so it is available
173
+ // without reading the transcript. The disk read (getDaemonSessionUsageSnapshot)
174
+ // is deferred until we know usage will actually render — a footer/off bot must
175
+ // not pay a per-tick transcript parse just to throw the tokens away.
176
+ const runtimeModel = ds.activeModel?.trim() || ds.session.model?.trim();
177
+ const reasoningEffort = ds.activeReasoningEffort?.trim()
178
+ || ds.session.reasoningEffort?.trim();
166
179
  try {
167
180
  if (resolveUsageDisplay(ds.larkAppId) !== 'streaming') {
168
- return { context: null, tokens: null };
181
+ return {
182
+ context: null,
183
+ tokens: null,
184
+ ...(runtimeModel ? { model: runtimeModel } : {}),
185
+ ...(reasoningEffort ? { reasoningEffort } : {}),
186
+ };
169
187
  }
170
188
  }
171
189
  catch {
172
190
  // Missing runtime config → default 'streaming' → show usage (best-effort).
173
191
  }
174
- return getDaemonSessionUsageSnapshot(ds, effectiveCliId, { fresh: opts?.fresh ?? false });
192
+ const snapshot = getDaemonSessionUsageSnapshot(ds, effectiveCliId, { fresh: opts?.fresh ?? false });
193
+ // Model comes only from an explicitly-wired executor runtime (TRAE/Codex set
194
+ // ds.activeModel from their rollout settings) or the user-configured launch
195
+ // model — never snapshot.tokens.model. That field is the RAW transcript model
196
+ // and for relay-style CLIs is an internal routing code (e.g. `ark/relay-code`)
197
+ // that must not surface on a user card.
198
+ return {
199
+ ...snapshot,
200
+ ...(runtimeModel ? { model: runtimeModel } : {}),
201
+ ...(reasoningEffort ? { reasoningEffort } : {}),
202
+ };
175
203
  }
176
204
  import { normalizeBrand } from '../im/lark/lark-hosts.js';
177
205
  import { dashboardEventBus } from './dashboard-events.js';
@@ -182,7 +210,9 @@ import { emitSessionLifecycleHook, emitSessionStateTransitionHook } from '../ser
182
210
  import { anchorUsageForDaemonSession, recordOwnershipForDaemonSession, recordUsageForDaemonSession, reconcileUsageForDaemonSession } from '../services/usage-ledger.js';
183
211
  import { isStructuredBridgeAdoptCli } from '../services/structured-bridge-clis.js';
184
212
  import { ensureGatewayEntry } from './plugins/mcp/gateway-installer.js';
185
- import { activeSessionKey, sessionKey, sessionAnchorId, storedSessionAnchorId, isDocNativeSession, larkTransportEnabled } from './types.js';
213
+ import { appendAcceptedCodexAppDispatch, cancelCodexAppDispatch, committedCodexAppSequence, hasUnsettledCodexAppDispatch, prepareCodexAppDispatch, retryPreparedCodexAppDispatch, retainFreshCodexAppGeneration, settleCodexAppDispatch, } from '../utils/codex-app-dispatch-ledger.js';
214
+ import { activeSessionKey, sessionKey, sessionAnchorId, storedSessionAnchorId, isDocNativeSession, larkTransportEnabled, riffRetirementAdmissionPhase, } from './types.js';
215
+ import { hasProtectedSessionMutationOwnership } from './session-mutation-guard.js';
186
216
  import { DONE_REACTION_EMOJI_TYPE } from './pending-response.js';
187
217
  import { buildTerminalUrl } from './terminal-url.js';
188
218
  import { prependBotmuxBin, resolveBotmuxWrapperBinDir } from './botmux-wrapper.js';
@@ -203,6 +233,9 @@ import { buildBotmuxLarkNativeSessionTitle, extractBotmuxLarkNativeSessionTitleP
203
233
  import { acknowledgeSessionReady } from './session-ready-handshake.js';
204
234
  import { recordDispatchInputCommit } from './dispatch.js';
205
235
  import { sendWorkerIpc } from './worker-ipc.js';
236
+ import { cleanupExplicitSessionBacking } from './explicit-session-backing-cleanup.js';
237
+ import { RIFF_ADMISSION_RESTORE_TIMEOUT_MS } from './shutdown-budgets.js';
238
+ import { managedOriginCapabilityPath, replaceManagedOriginCapabilityFile, } from './managed-origin-capability.js';
206
239
  const __filename = fileURLToPath(import.meta.url);
207
240
  const __dirname = dirname(__filename);
208
241
  const WORKER_SIGTERM_BACKSTOP_MS = 2_000;
@@ -273,6 +306,7 @@ function requireCallbacks() {
273
306
  // daemon registers its Map here at boot. Helpers below return a snapshot or
274
307
  // linear-scan by sessionId.
275
308
  let activeSessionsRegistry;
309
+ const pendingRiffWorkerCloses = new Map();
276
310
  export function setActiveSessionsRegistry(m) {
277
311
  activeSessionsRegistry = m;
278
312
  }
@@ -449,6 +483,38 @@ function flushPendingLocalCliOpenReadinessPatch(ds) {
449
483
  ds.pendingLocalCliButtonRefresh = undefined;
450
484
  scheduleLocalCliOpenReadinessPatch(ds);
451
485
  }
486
+ /** PATCH the live card when the executor reports a different active runtime.
487
+ * Runtime identity stays attached to the streaming usage line. */
488
+ function scheduleActiveRuntimePatch(ds) {
489
+ if (ds.session.vcMeetingReceiver || streamingCardDisabled(ds) || ds.suppressRecoveryCard) {
490
+ ds.pendingActiveRuntimeCardRefresh = undefined;
491
+ return;
492
+ }
493
+ if (ds.streamCardNonce && ds.parkedStreamCardNonce === ds.streamCardNonce) {
494
+ ds.pendingActiveRuntimeCardRefresh = undefined;
495
+ return;
496
+ }
497
+ if (ds.streamCardId === CARD_POSTING_SENTINEL) {
498
+ ds.pendingActiveRuntimeCardRefresh = true;
499
+ return;
500
+ }
501
+ if (!ds.streamCardId || !workerHasInitialized(ds)) {
502
+ ds.pendingActiveRuntimeCardRefresh = undefined;
503
+ return;
504
+ }
505
+ ds.pendingActiveRuntimeCardRefresh = undefined;
506
+ const botCfg = getBot(ds.larkAppId).config;
507
+ const effectiveCliId = sessionCliId(ds, botCfg);
508
+ const status = ds.usageLimit ? 'limited' : (ds.lastScreenStatus ?? 'starting');
509
+ const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readableTerminalUrlFor(ds), ds.currentTurnTitle || ds.session.title || sessionCliDisplayName(ds, botCfg), ds.lastScreenContent ?? '', status, effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, !!ds.adoptedFrom, false, localeForBot(ds.larkAppId), status === 'limited' ? ds.usageLimit : undefined, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId), sessionRuntimeDisplayName(ds, botCfg), codexServiceTierBadge(effectiveCliId, ds.codexServiceTier));
510
+ scheduleCardPatch(ds, cardJson);
511
+ }
512
+ function flushPendingActiveRuntimePatch(ds) {
513
+ if (!ds.pendingActiveRuntimeCardRefresh)
514
+ return;
515
+ ds.pendingActiveRuntimeCardRefresh = undefined;
516
+ scheduleActiveRuntimePatch(ds);
517
+ }
452
518
  /** PATCH a live card when rollout settings change, even if the PTY is static. */
453
519
  function scheduleCodexServiceTierPatch(ds) {
454
520
  if (ds.session.vcMeetingReceiver || streamingCardDisabled(ds) || ds.suppressRecoveryCard) {
@@ -977,6 +1043,7 @@ export async function postFreshStreamingCard(ds, sessionReply) {
977
1043
  recallFrozenCards(ds);
978
1044
  flushPendingLocalCliOpenReadinessPatch(ds);
979
1045
  flushPendingRiffUrlPatch(ds);
1046
+ flushPendingActiveRuntimePatch(ds);
980
1047
  flushPendingCodexServiceTierPatch(ds);
981
1048
  // Manual /card during a working turn lands a live card whose subsequent
982
1049
  // screen_updates are working→working (no status edge) — arm the periodic
@@ -991,6 +1058,7 @@ export async function postFreshStreamingCard(ds, sessionReply) {
991
1058
  ds.streamCardPending = prevPending;
992
1059
  flushPendingLocalCliOpenReadinessPatch(ds);
993
1060
  flushPendingRiffUrlPatch(ds);
1061
+ flushPendingActiveRuntimePatch(ds);
994
1062
  flushPendingCodexServiceTierPatch(ds);
995
1063
  // Rolled back to the prior card identity — re-sync so a restored still-live
996
1064
  // working card keeps (or resumes) its refresh rather than losing the timer.
@@ -1335,6 +1403,31 @@ function flushCardPatch(ds) {
1335
1403
  }
1336
1404
  });
1337
1405
  }
1406
+ /** Root withdrawal is not an explicit abandon boundary. Preserve a Codex App
1407
+ * owner (including its live worker) while any durable FIFO entry is unsettled;
1408
+ * only ledger-empty sessions retain the historical stale-root auto-close. */
1409
+ async function closeWithdrawnSessionIfLedgerEmpty(ds, context) {
1410
+ if (hasProtectedSessionMutationOwnership(ds)) {
1411
+ logger.warn(`[${tag(ds)}] ${context}; preserving session because Codex App dispatch is unsettled`);
1412
+ return false;
1413
+ }
1414
+ logger.warn(`[${tag(ds)}] ${context}; closing ledger-empty stale session`);
1415
+ // The callback routes through the authoritative async closeSession helper;
1416
+ // never pre-kill a worker before that helper settles its backend boundary.
1417
+ try {
1418
+ const closed = await requireCallbacks().closeSession(ds);
1419
+ if (closed === false) {
1420
+ logger.warn(`[${tag(ds)}] ${context}; authoritative close failed, session retained for retry`);
1421
+ return false;
1422
+ }
1423
+ return true;
1424
+ }
1425
+ catch (err) {
1426
+ logger.warn(`[${tag(ds)}] ${context}; authoritative close threw, session retained for retry: `
1427
+ + `${err instanceof Error ? err.message : String(err)}`);
1428
+ return false;
1429
+ }
1430
+ }
1338
1431
  // ─── Restart rate-limiting ──────────────────────────────────────────────────
1339
1432
  export const restartCounts = new Map();
1340
1433
  // ─── Skills installation ────────────────────────────────────────────────────
@@ -1519,7 +1612,18 @@ export function ensureClaudeFolderTrust(workingDir, stateJsonPath = join(homedir
1519
1612
  }
1520
1613
  }
1521
1614
  // ─── Kill worker ────────────────────────────────────────────────────────────
1522
- export function killWorker(ds) {
1615
+ export function killWorker(ds, opts = {}) {
1616
+ const closeFrozenType = ds.initConfig?.backendType ?? ds.session.backendType;
1617
+ if (ds.worker && !ds.worker.killed
1618
+ && closeFrozenType === 'riff'
1619
+ && !opts.riffCloseCommitRequestId) {
1620
+ // A generic synchronous retirement cannot safely detach Riff. An accepted
1621
+ // create/follow-up may still be waiting for the task id that becomes the
1622
+ // only durable lineage anchor.
1623
+ logger.error(`[${tag(ds)}] Refused unprepared live Riff worker retirement; `
1624
+ + 'preserving worker and remote-task lineage');
1625
+ return;
1626
+ }
1523
1627
  restartCoordinator.cancelSession(ds.session.sessionId);
1524
1628
  clearUsageLimitState(ds);
1525
1629
  ds.workerReady = false;
@@ -1543,12 +1647,28 @@ export function killWorker(ds) {
1543
1647
  // session would leave an orphaned CLI running in tmux that still replies.
1544
1648
  // Destroy the backing session directly here so /close always terminates it.
1545
1649
  destroyOrphanedBackingSession(ds);
1650
+ if (opts.riffCloseCommitRequestId
1651
+ && ds.riffCloseState?.requestId === opts.riffCloseCommitRequestId) {
1652
+ ds.riffCloseState = undefined;
1653
+ }
1546
1654
  return;
1547
1655
  }
1548
1656
  try {
1549
- ds.worker.send({ type: 'close' });
1657
+ if (opts.riffCloseCommitRequestId) {
1658
+ ds.worker.send({
1659
+ type: 'close_commit',
1660
+ requestId: opts.riffCloseCommitRequestId,
1661
+ });
1662
+ }
1663
+ else {
1664
+ ds.worker.send({ type: 'close' });
1665
+ }
1550
1666
  }
1551
1667
  catch { /* IPC already closed */ }
1668
+ if (opts.riffCloseCommitRequestId
1669
+ && ds.riffCloseState?.requestId === opts.riffCloseCommitRequestId) {
1670
+ ds.riffCloseState = undefined;
1671
+ }
1552
1672
  const w = ds.worker;
1553
1673
  trackLifecycleRetirement(ds, w);
1554
1674
  armCloseFence(ds, w);
@@ -1556,7 +1676,6 @@ export function killWorker(ds) {
1556
1676
  // 外层 race 8s)。默认 2s SIGTERM backstop 会在取消发出前掐死进程,已关闭话题
1557
1677
  // 的远端任务照跑——冻结为 riff 的会话放宽到 24s(层级:destroy 20s < worker 22s
1558
1678
  // < SIGTERM 24s < SIGKILL 29s;正常路径 worker 自行 exit,不会等满)。
1559
- const closeFrozenType = ds.initConfig?.backendType ?? ds.session.backendType;
1560
1679
  armWorkerKillBackstop(w, tag(ds), closeFrozenType === 'riff' ? 24_000 : WORKER_SIGTERM_BACKSTOP_MS);
1561
1680
  ds.worker = null;
1562
1681
  ds.workerPort = null;
@@ -1840,24 +1959,15 @@ function destroyOrphanedBackingSession(ds) {
1840
1959
  if (ds.initConfig?.adoptMode || ds.adoptedFrom)
1841
1960
  return;
1842
1961
  reclaimParkedCrashDiagnostic(ds);
1843
- // riff:worker 已死时 /close 仍要取消持久化血缘指向的远端任务——否则已关闭
1844
- // 话题的远端 agent 继续拿着注入凭证发消息。fire-and-forget(内部有界+重试)。
1962
+ // Riff cancellation is asynchronous and cannot be made safe from this
1963
+ // synchronous best-effort helper. The authoritative closeSession path awaits
1964
+ // cancellation before publishing the closed row and retains lineage on
1965
+ // failure.
1845
1966
  const frozenType = ds.initConfig?.backendType ?? ds.session.backendType;
1846
1967
  if (frozenType === 'riff') {
1847
- const taskId = ds.session.riffParentTaskId;
1848
- if (taskId) {
1849
- try {
1850
- const riffCfg = getBot(ds.larkAppId).config.riff;
1851
- if (riffCfg?.baseUrl) {
1852
- void cancelRiffTaskById(riffCfg, taskId).then((ok) => {
1853
- if (ok)
1854
- logger.info(`[${tag(ds)}] killWorker: orphan riff task ${taskId} cancelled`);
1855
- });
1856
- }
1857
- }
1858
- catch { /* bot deregistered — nothing to cancel with */ }
1859
- ds.session.riffParentTaskId = undefined;
1860
- sessionStore.updateSession(ds.session);
1968
+ if (ds.session.riffParentTaskId) {
1969
+ logger.warn(`[${tag(ds)}] worker-less Riff teardown requires awaited explicit close; `
1970
+ + `retaining task ${ds.session.riffParentTaskId} for retry`);
1861
1971
  }
1862
1972
  return;
1863
1973
  }
@@ -1872,6 +1982,266 @@ function destroyOrphanedBackingSession(ds) {
1872
1982
  logger.warn(`[${tag(ds)}] killWorker: failed to destroy orphaned ${backendType} backing session: ${err}`);
1873
1983
  }
1874
1984
  }
1985
+ async function abortLiveRiffWorkerClose(ds, requestId, opts = {}) {
1986
+ if (ds.riffCloseState?.requestId !== requestId)
1987
+ return false;
1988
+ const worker = ds.worker;
1989
+ if (!worker || worker.killed) {
1990
+ if (opts.allowAbsentAfterProvenRestore) {
1991
+ ds.riffCloseState = undefined;
1992
+ return true;
1993
+ }
1994
+ ds.riffCloseState = { ...ds.riffCloseState, phase: 'uncertain' };
1995
+ return false;
1996
+ }
1997
+ const restored = await new Promise(resolve => {
1998
+ let settled = false;
1999
+ const finish = (result) => {
2000
+ if (settled)
2001
+ return;
2002
+ settled = true;
2003
+ clearTimeout(timer);
2004
+ worker.removeListener?.('message', onMessage);
2005
+ worker.removeListener?.('exit', onExit);
2006
+ resolve(result);
2007
+ };
2008
+ const onMessage = (raw) => {
2009
+ const msg = raw;
2010
+ if (msg?.type !== 'close_abort_result' || msg.requestId !== requestId)
2011
+ return;
2012
+ if (ds.worker !== worker) {
2013
+ finish({ ok: false, error: 'stale_worker_generation' });
2014
+ return;
2015
+ }
2016
+ finish({ ok: msg.ok, ...(msg.error ? { error: msg.error } : {}) });
2017
+ };
2018
+ const onExit = () => finish({
2019
+ ok: false,
2020
+ error: 'worker_exited_before_close_abort_result',
2021
+ });
2022
+ const timer = setTimeout(() => finish({ ok: false, error: 'close_abort_result_timeout' }), RIFF_ADMISSION_RESTORE_TIMEOUT_MS);
2023
+ timer.unref?.();
2024
+ worker.on?.('message', onMessage);
2025
+ worker.once?.('exit', onExit);
2026
+ try {
2027
+ worker.send({ type: 'close_abort', requestId });
2028
+ }
2029
+ catch (err) {
2030
+ finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
2031
+ }
2032
+ });
2033
+ if (restored.ok && ds.riffCloseState?.requestId === requestId) {
2034
+ ds.riffCloseState = undefined;
2035
+ return true;
2036
+ }
2037
+ if (opts.allowAbsentAfterProvenRestore
2038
+ && ds.riffCloseState?.requestId === requestId) {
2039
+ ds.riffCloseState = undefined;
2040
+ return true;
2041
+ }
2042
+ if (ds.riffCloseState?.requestId === requestId) {
2043
+ ds.riffCloseState = { ...ds.riffCloseState, phase: 'uncertain' };
2044
+ }
2045
+ logger.warn(`[${tag(ds)}] Riff close abort was not acknowledged (${restored.error ?? 'unknown'}); `
2046
+ + 'retaining admission fence pending explicit lineage reconciliation');
2047
+ return false;
2048
+ }
2049
+ async function prepareLiveRiffWorkerClose(ds) {
2050
+ const worker = ds.worker;
2051
+ if (!worker || worker.killed) {
2052
+ return { ok: false, error: 'riff_worker_close_failed', retryable: true };
2053
+ }
2054
+ if (ds.riffCloseState || ds.riffShutdownState) {
2055
+ return {
2056
+ ok: false,
2057
+ error: 'riff_worker_close_failed',
2058
+ retryable: true,
2059
+ ...((ds.riffCloseState?.taskId ?? ds.riffShutdownState?.taskId)
2060
+ ? { taskId: (ds.riffCloseState?.taskId ?? ds.riffShutdownState?.taskId) }
2061
+ : {}),
2062
+ };
2063
+ }
2064
+ const requestId = randomUUID();
2065
+ ds.riffCloseState = {
2066
+ phase: 'preparing',
2067
+ requestId,
2068
+ ...(ds.session.riffParentTaskId ? { taskId: ds.session.riffParentTaskId } : {}),
2069
+ };
2070
+ let matchedCloseResult = false;
2071
+ const result = await new Promise((resolve) => {
2072
+ let settled = false;
2073
+ const finish = (value) => {
2074
+ if (settled)
2075
+ return;
2076
+ settled = true;
2077
+ clearTimeout(timer);
2078
+ worker.removeListener?.('exit', onExit);
2079
+ pendingRiffWorkerCloses.delete(requestId);
2080
+ resolve(value);
2081
+ };
2082
+ const onExit = () => finish({
2083
+ ok: false,
2084
+ error: 'worker_exited_before_close_result',
2085
+ });
2086
+ const timer = setTimeout(() => finish({ ok: false, error: 'worker_close_result_timeout' }), 23_000);
2087
+ timer.unref?.();
2088
+ worker.once?.('exit', onExit);
2089
+ pendingRiffWorkerCloses.set(requestId, {
2090
+ sessionId: ds.session.sessionId,
2091
+ worker,
2092
+ resolve: value => {
2093
+ matchedCloseResult = true;
2094
+ finish(value);
2095
+ },
2096
+ });
2097
+ try {
2098
+ worker.send({ type: 'close', requestId });
2099
+ }
2100
+ catch (err) {
2101
+ finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
2102
+ }
2103
+ });
2104
+ const taskId = result.taskId ?? ds.session.riffParentTaskId;
2105
+ if (result.taskId) {
2106
+ ds.session.riffParentTaskId = result.taskId;
2107
+ try {
2108
+ sessionStore.updateSession(ds.session);
2109
+ }
2110
+ catch (err) {
2111
+ await abortLiveRiffWorkerClose(ds, requestId);
2112
+ logger.error(`[${tag(ds)}] Riff close lineage persistence failed; close aborted: `
2113
+ + `${err instanceof Error ? err.message : String(err)}`);
2114
+ return {
2115
+ ok: false,
2116
+ error: 'riff_durable_close_failed',
2117
+ retryable: true,
2118
+ taskId: result.taskId,
2119
+ };
2120
+ }
2121
+ }
2122
+ if (!result.ok) {
2123
+ await abortLiveRiffWorkerClose(ds, requestId, {
2124
+ allowAbsentAfterProvenRestore: matchedCloseResult,
2125
+ });
2126
+ logger.warn(`[${tag(ds)}] Riff worker close prepare failed: ${result.error ?? 'unknown'}; `
2127
+ + `session remains active${taskId ? ` (task ${taskId})` : ''}`);
2128
+ return {
2129
+ ok: false,
2130
+ error: 'riff_worker_close_failed',
2131
+ retryable: true,
2132
+ ...(taskId ? { taskId } : {}),
2133
+ };
2134
+ }
2135
+ ds.riffCloseState = {
2136
+ phase: 'prepared',
2137
+ requestId,
2138
+ ...(taskId ? { taskId } : {}),
2139
+ };
2140
+ logger.info(`[${tag(ds)}] Riff worker close prepared and remote task cancellation confirmed`);
2141
+ return { ok: true, ...(taskId ? { taskId } : {}) };
2142
+ }
2143
+ /** Await remote cancellation for any Riff owner before its durable row is
2144
+ * closed. Live workers use prepare/commit; worker-less rows cancel their exact
2145
+ * persisted task through current authoritative bot config. */
2146
+ async function prepareRiffExplicitClose(ds, stored) {
2147
+ const session = ds?.session ?? stored;
2148
+ if (!session)
2149
+ return { ok: true };
2150
+ const backendType = ds?.initConfig?.backendType ?? session.backendType;
2151
+ const taskId = session.riffParentTaskId;
2152
+ if (ds?.riffShutdownState) {
2153
+ const fencedTaskId = Object.prototype.hasOwnProperty.call(ds.riffShutdownState, 'taskId')
2154
+ ? ds.riffShutdownState.taskId
2155
+ : taskId;
2156
+ return {
2157
+ ok: false,
2158
+ error: 'riff_shutdown_fence_in_progress',
2159
+ retryable: true,
2160
+ ...(typeof fencedTaskId === 'string' && fencedTaskId ? { taskId: fencedTaskId } : {}),
2161
+ };
2162
+ }
2163
+ if (ds?.riffCloseState) {
2164
+ return {
2165
+ ok: false,
2166
+ error: 'riff_close_reconciliation_required',
2167
+ retryable: true,
2168
+ ...(ds.riffCloseState.taskId ? { taskId: ds.riffCloseState.taskId } : {}),
2169
+ };
2170
+ }
2171
+ if (backendType !== 'riff')
2172
+ return { ok: true };
2173
+ if (ds?.initConfig?.adoptMode || ds?.adoptedFrom || session.adoptedFrom)
2174
+ return { ok: true };
2175
+ if (ds?.worker && !ds.worker.killed) {
2176
+ if (!stored || stored.status !== 'active') {
2177
+ return {
2178
+ ok: false,
2179
+ error: 'riff_row_inconsistent',
2180
+ retryable: true,
2181
+ ...(taskId ? { taskId } : {}),
2182
+ };
2183
+ }
2184
+ return prepareLiveRiffWorkerClose(ds);
2185
+ }
2186
+ const durableBefore = sessionStore.getSession(session.sessionId);
2187
+ if (ds && durableBefore
2188
+ && ds.session.riffParentTaskId !== durableBefore.riffParentTaskId) {
2189
+ const authoritativeTaskId = durableBefore.riffParentTaskId ?? ds.session.riffParentTaskId;
2190
+ logger.warn(`[${tag(ds)}] explicit close refused before cancellation: runtime/durable Riff lineage differs `
2191
+ + `(${ds.session.riffParentTaskId ?? 'none'}/${durableBefore.riffParentTaskId ?? 'none'})`);
2192
+ return {
2193
+ ok: false,
2194
+ error: 'riff_task_changed',
2195
+ retryable: true,
2196
+ ...(authoritativeTaskId ? { taskId: authoritativeTaskId } : {}),
2197
+ };
2198
+ }
2199
+ if (!taskId)
2200
+ return { ok: true };
2201
+ let riffConfig;
2202
+ const larkAppId = ds?.larkAppId ?? session.larkAppId;
2203
+ try {
2204
+ if (larkAppId)
2205
+ riffConfig = getBot(larkAppId).config.riff;
2206
+ }
2207
+ catch { /* bot removed */ }
2208
+ const cleanup = await cleanupExplicitSessionBacking({
2209
+ sessionId: session.sessionId,
2210
+ backendType,
2211
+ riffParentTaskId: taskId,
2212
+ riffConfig,
2213
+ });
2214
+ const closeLabel = ds ? tag(ds) : session.sessionId.slice(0, 8);
2215
+ if (!cleanup.ok) {
2216
+ logger.warn(`[${closeLabel}] explicit close refused: ${cleanup.kind}; `
2217
+ + `Riff task ${taskId} remains active and retryable`);
2218
+ return {
2219
+ ok: false,
2220
+ error: cleanup.kind === 'riff_config_missing' ? 'riff_config_missing' : 'riff_cancel_failed',
2221
+ retryable: true,
2222
+ taskId,
2223
+ };
2224
+ }
2225
+ if (cleanup.kind !== 'cancelled_riff')
2226
+ return { ok: true };
2227
+ const latest = sessionStore.getSession(session.sessionId);
2228
+ const latestTaskId = latest?.riffParentTaskId;
2229
+ const runtimeTaskId = ds?.session.riffParentTaskId;
2230
+ if (!latest || latest.status !== 'active' || latestTaskId !== cleanup.taskId
2231
+ || (ds && runtimeTaskId !== cleanup.taskId)) {
2232
+ logger.warn(`[${closeLabel}] explicit close cancelled stale Riff task ${cleanup.taskId}, `
2233
+ + `but runtime/durable lineage or status changed to `
2234
+ + `${runtimeTaskId ?? 'none'}/${latestTaskId ?? 'none'}/${latest?.status ?? 'missing'}; retry required`);
2235
+ return {
2236
+ ok: false,
2237
+ error: 'riff_task_changed',
2238
+ retryable: true,
2239
+ taskId: latestTaskId ?? runtimeTaskId ?? cleanup.taskId,
2240
+ };
2241
+ }
2242
+ logger.info(`[${closeLabel}] Riff task ${cleanup.taskId} cancellation prepared for explicit close`);
2243
+ return { ok: true, taskId: cleanup.taskId };
2244
+ }
1875
2245
  /**
1876
2246
  * Reclaim a session's parked crash-diagnostic shell (`bmx-diag-<sid>`) and its
1877
2247
  * captured `.ansi` file. The worker normally tears these down itself (killCli /
@@ -1893,6 +2263,10 @@ function reclaimParkedCrashDiagnostic(ds) {
1893
2263
  catch { /* absent — benign */ }
1894
2264
  }
1895
2265
  export function suspendWorker(ds, reason = 'suspended_idle') {
2266
+ if (hasProtectedSessionMutationOwnership(ds)) {
2267
+ logger.warn(`[${tag(ds)}] Refused worker suspend (${reason}) while Codex App dispatch ownership is non-empty`);
2268
+ return false;
2269
+ }
1896
2270
  if (isSessionTransferring(ds)) {
1897
2271
  logger.warn(`[${tag(ds)}] Suspend refused while routing transfer is in progress`);
1898
2272
  return false;
@@ -2154,14 +2528,6 @@ function teardownAuthoritativePersistentBackingBeforeCloseImpl(target, closeWins
2154
2528
  export function teardownAuthoritativePersistentBackingBeforeClose(target) {
2155
2529
  teardownAuthoritativePersistentBackingBeforeCloseImpl(target, false);
2156
2530
  }
2157
- /**
2158
- * Idempotent close: kill worker if alive, mark Session status='closed' + closedAt,
2159
- * publish session.exited (if a live worker was killed) and session.update
2160
- * (if the persistence row transitioned to closed).
2161
- *
2162
- * Calling this on an unknown sessionId, an already-closed session, or a session
2163
- * whose worker died asynchronously must still resolve with `{ ok: true }`.
2164
- */
2165
2531
  export async function closeSession(sessionId) {
2166
2532
  const ds = findActiveBySessionId(sessionId);
2167
2533
  const stored = sessionStore.getOwnedSession(sessionId);
@@ -2171,10 +2537,11 @@ export async function closeSession(sessionId) {
2171
2537
  if (teardownTarget) {
2172
2538
  teardownAuthoritativePersistentBackingBeforeCloseImpl(teardownTarget, true);
2173
2539
  }
2540
+ const isOwnedRiffClose = !ds?.initConfig?.adoptMode
2541
+ && !ds?.adoptedFrom
2542
+ && !stored?.adoptedFrom
2543
+ && (ds?.initConfig?.backendType ?? ds?.session.backendType ?? stored?.backendType) === 'riff';
2174
2544
  let killedLive = false;
2175
- // 会话关闭即可回收其崩溃重启计数;否则每个曾崩溃过的 session 会在 daemon
2176
- // 生命周期内永久占位(restartCounts 此前无任何 delete)。
2177
- restartCounts.delete(sessionId);
2178
2545
  const hadLiveWorker = !!ds?.worker && !ds.worker.killed;
2179
2546
  const closeWorkerGeneration = ds ? closeFenceGeneration(ds) : undefined;
2180
2547
  // Snapshot ownership + transition state before mutating the live object:
@@ -2183,6 +2550,26 @@ export async function closeSession(sessionId) {
2183
2550
  const wasOpen = !!stored && stored.status !== 'closed';
2184
2551
  const storedHadDocCommentTargets = Object.keys(stored?.docCommentTargets ?? {}).length > 0;
2185
2552
  const docReactionTargets = collectDocCommentReactionTargets(ds, stored);
2553
+ if (ds) {
2554
+ // Usage ledger: flush the final delta before the worker goes away (a
2555
+ // crash/limited turn may never have reached an idle edge).
2556
+ recordUsageForDaemonSession(ds);
2557
+ }
2558
+ // Riff owns a remote credential-bearing process. Prove cancellation before
2559
+ // mutating routing, transient capabilities, or the durable row. Non-Riff
2560
+ // closes retain master's synchronous state-transition path.
2561
+ const prepared = isOwnedRiffClose
2562
+ ? await prepareRiffExplicitClose(ds, stored)
2563
+ : { ok: true };
2564
+ if (!prepared.ok) {
2565
+ return { ...prepared, alreadyClosed: false };
2566
+ }
2567
+ const preparedRiffRequestId = ds?.riffCloseState?.phase === 'prepared'
2568
+ ? ds.riffCloseState.requestId
2569
+ : undefined;
2570
+ // 会话关闭即可回收其崩溃重启计数;否则每个曾崩溃过的 session 会在 daemon
2571
+ // 生命周期内永久占位(restartCounts 此前无任何 delete)。
2572
+ restartCounts.delete(sessionId);
2186
2573
  // Per-turn comment routes are transient capabilities. Clear them from both
2187
2574
  // live and owner-scoped persisted objects inside the synchronous close
2188
2575
  // critical section, before any best-effort Lark cleanup can yield.
@@ -2192,30 +2579,33 @@ export async function closeSession(sessionId) {
2192
2579
  if (stored && !wasOpen && storedHadDocCommentTargets) {
2193
2580
  sessionStore.updateSession(stored);
2194
2581
  }
2195
- if (ds) {
2196
- // Usage ledger: flush the final delta before the worker goes away (a
2197
- // crash/limited turn may never have reached an idle edge).
2198
- recordUsageForDaemonSession(ds);
2199
- killWorker(ds);
2200
- const activeKey = activeSessionKey(ds);
2201
- if (activeSessionsRegistry?.get(activeKey) === ds) {
2202
- activeSessionsRegistry.delete(activeKey);
2203
- }
2204
- // Mark the captured object too. Async message/card paths may already hold a
2205
- // reference to `ds`; deleting only the registry entry would not stop one of
2206
- // those continuations from re-forking the closed session after its await.
2207
- ds.session.status = 'closed';
2208
- ds.session.closedAt ??= new Date().toISOString();
2209
- killedLive = true;
2210
- }
2211
2582
  // Mutations are bot-owner scoped. getSession() has a read-only cross-file
2212
2583
  // fallback for agent CLI discovery, so it must not authorize close.
2213
2584
  if (wasOpen) {
2214
- if (!ds && stored)
2585
+ if (!ds && stored && !isOwnedRiffClose)
2215
2586
  destroyUnregisteredPersistentBacking(stored);
2216
- sessionStore.closeSession(sessionId, {
2217
- cleanupBridgeMarkers: !hadLiveWorker,
2218
- });
2587
+ try {
2588
+ sessionStore.closeSession(sessionId, {
2589
+ cleanupBridgeMarkers: !hadLiveWorker,
2590
+ ...(isOwnedRiffClose ? { clearRiffParentTaskId: true } : {}),
2591
+ });
2592
+ }
2593
+ catch (err) {
2594
+ if (!isOwnedRiffClose)
2595
+ throw err;
2596
+ if (ds && preparedRiffRequestId) {
2597
+ await abortLiveRiffWorkerClose(ds, preparedRiffRequestId);
2598
+ }
2599
+ logger.error(`[${sessionId.slice(0, 8)}] Durable session close failed after Riff prepare; `
2600
+ + `worker admission restored: ${err instanceof Error ? err.message : String(err)}`);
2601
+ return {
2602
+ ok: false,
2603
+ alreadyClosed: false,
2604
+ error: 'riff_durable_close_failed',
2605
+ retryable: true,
2606
+ ...(prepared.taskId ? { taskId: prepared.taskId } : {}),
2607
+ };
2608
+ }
2219
2609
  const after = sessionStore.getOwnedSession(sessionId);
2220
2610
  if (ds) {
2221
2611
  ds.session.status = 'closed';
@@ -2223,6 +2613,23 @@ export async function closeSession(sessionId) {
2223
2613
  }
2224
2614
  }
2225
2615
  if (ds) {
2616
+ killWorker(ds, {
2617
+ ...(preparedRiffRequestId ? { riffCloseCommitRequestId: preparedRiffRequestId } : {}),
2618
+ });
2619
+ // A transferred/restored exact object may remain under more than one alias.
2620
+ // Remove only identity matches, never a same-key successor.
2621
+ if (activeSessionsRegistry) {
2622
+ for (const [registeredKey, candidate] of activeSessionsRegistry) {
2623
+ if (candidate === ds)
2624
+ activeSessionsRegistry.delete(registeredKey);
2625
+ }
2626
+ }
2627
+ // Mark the captured object too. Async message/card paths may already hold a
2628
+ // reference to `ds`; deleting only the registry entry would not stop one of
2629
+ // those continuations from re-forking the closed session after its await.
2630
+ ds.session.status = 'closed';
2631
+ ds.session.closedAt ??= new Date().toISOString();
2632
+ killedLive = true;
2226
2633
  if (!ds.exitEventEmitted) {
2227
2634
  ds.exitEventEmitted = true;
2228
2635
  dashboardEventBus.publish({
@@ -2327,23 +2734,62 @@ export function destroyUnregisteredPersistentBacking(session, kill = killPersist
2327
2734
  return false;
2328
2735
  }
2329
2736
  }
2330
- /**
2331
- * Compare-and-set an entry on an active-sessions Map. A different current
2332
- * occupant always wins; callers must roll back the rejected incoming row.
2333
- * Replaces bare `activeSessions.set(key, ds)` at sites where a silent overwrite
2334
- * would leak the prior entry's worker + leave its store row stuck active.
2335
- *
2336
- * The Map is passed explicitly so callers operate on the same instance they
2337
- * already hold (restoreActiveSessions takes the daemon's Map as a parameter;
2338
- * transferSession reaches it through `activeSessionsRegistry`). In production
2339
- * both refer to the same object — the daemon registers its Map at boot — but
2340
- * decoupling avoids module-state assumptions in tests.
2341
- *
2342
- * Registration is compare-and-set: a different current occupant always wins.
2343
- * The caller owns rollback of its rejected incoming row. This is deliberately
2344
- * non-destructive because the occupant may be a fresh live session created
2345
- * while an older async restore/create continuation was in flight.
2346
- */
2737
+ const activeSessionKeyLocks = new WeakMap();
2738
+ /** Serialize asynchronous ownership decisions for one registry key. Ordinary
2739
+ * bot turn admissions are intentionally concurrent, so a read/await/set helper
2740
+ * is not a CAS unless every contender shares this lock. */
2741
+ export async function withActiveSessionKeyLock(map, key, action) {
2742
+ let locks = activeSessionKeyLocks.get(map);
2743
+ if (!locks) {
2744
+ locks = new Map();
2745
+ activeSessionKeyLocks.set(map, locks);
2746
+ }
2747
+ const previous = locks.get(key) ?? Promise.resolve();
2748
+ let release;
2749
+ const hold = new Promise(resolve => { release = resolve; });
2750
+ const tail = previous.catch(() => { }).then(() => hold);
2751
+ locks.set(key, tail);
2752
+ await previous.catch(() => { });
2753
+ try {
2754
+ return await action();
2755
+ }
2756
+ finally {
2757
+ release();
2758
+ if (locks.get(key) === tail)
2759
+ locks.delete(key);
2760
+ }
2761
+ }
2762
+ async function closeUnregisteredCollisionLoser(map, loser) {
2763
+ // Operate on exact object identity in the caller's registry. Looking up by
2764
+ // session id through the daemon-global registry can conflate a non-global
2765
+ // restore map (or a stale duplicate object) with an unrelated canonical
2766
+ // owner and close the wrong generation.
2767
+ const sameIdDifferentOwner = [...map.values()].some(candidate => candidate !== loser && candidate.session.sessionId === loser.session.sessionId)
2768
+ || (!!activeSessionsRegistry && activeSessionsRegistry !== map
2769
+ && [...activeSessionsRegistry.values()].some(candidate => candidate !== loser && candidate.session.sessionId === loser.session.sessionId));
2770
+ if (sameIdDifferentOwner) {
2771
+ logger.error(`[setActiveSessionSafe] refusing persistence close for ambiguous session id `
2772
+ + `${loser.session.sessionId}; preserving every owner`);
2773
+ return { ok: false, error: 'ambiguous_session_id' };
2774
+ }
2775
+ const stored = sessionStore.getSession(loser.session.sessionId);
2776
+ try {
2777
+ if (stored?.status !== 'closed') {
2778
+ sessionStore.closeSession(loser.session.sessionId);
2779
+ }
2780
+ }
2781
+ catch (err) {
2782
+ logger.error(`[setActiveSessionSafe] durable loser close failed for ${loser.session.sessionId}: `
2783
+ + `${err instanceof Error ? err.message : String(err)}`);
2784
+ return { ok: false, error: 'durable_close_failed' };
2785
+ }
2786
+ killWorker(loser);
2787
+ for (const [registeredKey, candidate] of map) {
2788
+ if (candidate === loser)
2789
+ map.delete(registeredKey);
2790
+ }
2791
+ return { ok: true };
2792
+ }
2347
2793
  function removeInactiveRegistration(map, key, ds) {
2348
2794
  if (ds.session.status === 'active')
2349
2795
  return false;
@@ -2385,6 +2831,22 @@ export function findQuarantinedRoutingConflict(key, larkAppId, candidateSessionI
2385
2831
  return undefined;
2386
2832
  }
2387
2833
  }
2834
+ /** Clear a durable quarantine marker once a live owner has claimed the route.
2835
+ * Runtime ownership (the Map + lock) is authoritative for this process even if
2836
+ * the persistence write fails; a later update or restore attempt converges the
2837
+ * stale on-disk marker. */
2838
+ function clearRestoreQuarantineMarker(ds) {
2839
+ if (!ds.session.restoreQuarantinedAt)
2840
+ return;
2841
+ delete ds.session.restoreQuarantinedAt;
2842
+ try {
2843
+ sessionStore.updateSession(ds.session);
2844
+ }
2845
+ catch (err) {
2846
+ logger.warn(`[${tag(ds)}] Registered quarantined session but could not clear its persisted marker: `
2847
+ + `${err instanceof Error ? err.message : String(err)}`);
2848
+ }
2849
+ }
2388
2850
  /**
2389
2851
  * Synchronous registration gate for creation paths that previously used a
2390
2852
  * bare Map.set(). A daemon close can update the shared Session object while
@@ -2407,23 +2869,115 @@ export function setActiveSessionIfActive(map, key, ds) {
2407
2869
  return false;
2408
2870
  }
2409
2871
  map.set(key, ds);
2410
- if (ds.session.restoreQuarantinedAt) {
2411
- delete ds.session.restoreQuarantinedAt;
2412
- try {
2413
- sessionStore.updateSession(ds.session);
2414
- }
2415
- catch (err) {
2416
- // Runtime ownership is now authoritative and the Map prevents duplicates
2417
- // for this process. Keep running; a later persistence update (or the next
2418
- // restore attempt) will converge the stale on-disk marker.
2419
- logger.warn(`[${tag(ds)}] Registered quarantined session but could not clear its persisted marker: `
2420
- + `${err instanceof Error ? err.message : String(err)}`);
2421
- }
2422
- }
2872
+ clearRestoreQuarantineMarker(ds);
2423
2873
  return true;
2424
2874
  }
2425
2875
  export async function setActiveSessionSafe(map, key, ds) {
2426
- return setActiveSessionIfActive(map, key, ds);
2876
+ const canonicalKey = activeSessionKey(ds);
2877
+ if (canonicalKey !== key) {
2878
+ throw new Error(`refusing noncanonical active-session registration: requested=${key} canonical=${canonicalKey}`);
2879
+ }
2880
+ return withActiveSessionKeyLock(map, key, async () => {
2881
+ // Reboot-resume guard (master): a daemon close can flip the shared Session
2882
+ // to a non-active status while an async creator/restore awaited Lark/project
2883
+ // metadata. Never publish that now-inactive row back into the live map; drop
2884
+ // only our exact stale object so a newer owner of the same key is untouched.
2885
+ if (ds.session.status !== 'active') {
2886
+ if (map.get(key) === ds)
2887
+ map.delete(key);
2888
+ logger.warn(`[setActiveSessionSafe] refusing to register inactive session `
2889
+ + `${ds.session.sessionId.substring(0, 8)} (status=${ds.session.status})`);
2890
+ return {
2891
+ accepted: false,
2892
+ reason: 'inactive_incoming',
2893
+ keptSessionId: ds.session.sessionId,
2894
+ preservedIncomingSessionId: ds.session.sessionId,
2895
+ };
2896
+ }
2897
+ const prev = map.get(key);
2898
+ if (prev && prev !== ds) {
2899
+ if (prev.session.sessionId === ds.session.sessionId) {
2900
+ logger.error(`[setActiveSessionSafe] refusing collision between distinct owners of session id `
2901
+ + `${prev.session.sessionId}; preserving both objects and the durable row`);
2902
+ return {
2903
+ accepted: false,
2904
+ reason: 'cleanup_failed',
2905
+ keptSessionId: prev.session.sessionId,
2906
+ preservedIncomingSessionId: ds.session.sessionId,
2907
+ cleanupSessionId: prev.session.sessionId,
2908
+ error: 'ambiguous_session_id',
2909
+ };
2910
+ }
2911
+ const prevPending = hasProtectedSessionMutationOwnership(prev);
2912
+ const incomingPending = hasProtectedSessionMutationOwnership(ds);
2913
+ if (prevPending && incomingPending) {
2914
+ logger.error(`[setActiveSessionSafe] refusing collision between two unsettled Codex App owners `
2915
+ + `${prev.session.sessionId} and ${ds.session.sessionId}; preserving both rows/panes`);
2916
+ return {
2917
+ accepted: false,
2918
+ reason: 'both_pending',
2919
+ keptSessionId: prev.session.sessionId,
2920
+ preservedIncomingSessionId: ds.session.sessionId,
2921
+ };
2922
+ }
2923
+ if (prevPending) {
2924
+ logger.warn(`[setActiveSessionSafe] keeping unsettled owner ${prev.session.sessionId.substring(0, 8)}; `
2925
+ + `closing ledger-empty incoming ${ds.session.sessionId.substring(0, 8)}`);
2926
+ const cleanup = await closeUnregisteredCollisionLoser(map, ds);
2927
+ if (!cleanup.ok) {
2928
+ return {
2929
+ accepted: false,
2930
+ reason: 'cleanup_failed',
2931
+ keptSessionId: prev.session.sessionId,
2932
+ preservedIncomingSessionId: ds.session.sessionId,
2933
+ cleanupSessionId: ds.session.sessionId,
2934
+ error: cleanup.error,
2935
+ };
2936
+ }
2937
+ return {
2938
+ accepted: false,
2939
+ reason: 'kept_pending_owner',
2940
+ keptSessionId: prev.session.sessionId,
2941
+ closedIncomingSessionId: ds.session.sessionId,
2942
+ };
2943
+ }
2944
+ logger.warn(`[setActiveSessionSafe] key already occupied by ${prev.session.sessionId.substring(0, 8)} ` +
2945
+ `(worker=${prev.worker ? 'live' : 'null'}); closing it before set`);
2946
+ const cleanup = await closeUnregisteredCollisionLoser(map, prev);
2947
+ if (!cleanup.ok) {
2948
+ return {
2949
+ accepted: false,
2950
+ reason: 'cleanup_failed',
2951
+ keptSessionId: prev.session.sessionId,
2952
+ preservedIncomingSessionId: ds.session.sessionId,
2953
+ cleanupSessionId: prev.session.sessionId,
2954
+ error: cleanup.error,
2955
+ };
2956
+ }
2957
+ }
2958
+ // Reboot-resume guard (master): a durable quarantine reservation for this
2959
+ // route (an inconclusive exact-backend teardown) must block a second runtime
2960
+ // beside a possibly-live persistent target, even after the in-memory prev
2961
+ // occupant above was closed. Closed rows never match, so an operator close
2962
+ // immediately releases the route.
2963
+ const quarantined = findQuarantinedRoutingConflict(key, ds.larkAppId, ds.session.sessionId);
2964
+ if (quarantined) {
2965
+ logger.warn(`[setActiveSessionSafe] refusing to occupy routing key reserved by quarantined session `
2966
+ + `${quarantined.sessionId.substring(0, 8)}`);
2967
+ return {
2968
+ accepted: false,
2969
+ reason: 'quarantine_reserved',
2970
+ keptSessionId: quarantined.sessionId,
2971
+ preservedIncomingSessionId: ds.session.sessionId,
2972
+ };
2973
+ }
2974
+ map.set(key, ds);
2975
+ clearRestoreQuarantineMarker(ds);
2976
+ return {
2977
+ accepted: true,
2978
+ ...(prev && prev !== ds ? { closedSessionId: prev.session.sessionId } : {}),
2979
+ };
2980
+ });
2427
2981
  }
2428
2982
  /**
2429
2983
  * Roll back a freshly-created row rejected by the registration CAS, then read
@@ -2857,8 +3411,18 @@ targetScope, opts) {
2857
3411
  if (targetChatType !== 'group' && targetChatType !== 'p2p') {
2858
3412
  return { ok: false, error: 'target_chat_type_unsupported' };
2859
3413
  }
3414
+ const initial = findActiveBySessionId(sessionId);
3415
+ if (!initial)
3416
+ return { ok: false, error: 'session_not_active' };
3417
+ if (!opts?.mutationHeld) {
3418
+ // Transfer rewrites both the source and target routing identities and may
3419
+ // await scratch cleanup. Drain every admitted turn for this bot first so
3420
+ // no source prompt can become durable mid-transfer and no target creator
3421
+ // can slip into the cleanup window.
3422
+ return withBotTurnMutation(initial.larkAppId, () => transferSession(sessionId, targetChatId, targetRootMessageId, targetChatType, targetScope, { ...opts, mutationHeld: true }));
3423
+ }
2860
3424
  const ds = findActiveBySessionId(sessionId);
2861
- if (!ds)
3425
+ if (!ds || ds.session.status !== 'active')
2862
3426
  return { ok: false, error: 'session_not_active' };
2863
3427
  const sourceSession = ds.session;
2864
3428
  const sourceLifecycleIdentity = JSON.stringify({
@@ -2875,6 +3439,9 @@ targetScope, opts) {
2875
3439
  });
2876
3440
  if (ds.session.vcMeetingReceiver)
2877
3441
  return { ok: false, error: 'vc_receiver_not_relayable' };
3442
+ if (hasProtectedSessionMutationOwnership(ds)) {
3443
+ return { ok: false, error: 'codex_app_dispatch_pending' };
3444
+ }
2878
3445
  // Anchor-based identity. A thread-scope session in the SAME chat (different
2879
3446
  // root) is a legitimate cross-topic move, so we refuse only when the target
2880
3447
  // anchor equals the source anchor (relaying a session onto itself). Replaces
@@ -3002,7 +3569,11 @@ targetScope, opts) {
3002
3569
  continue;
3003
3570
  if (sessionAnchorId(existing) !== targetAnchor)
3004
3571
  continue;
3005
- if (isDisposableCommandScratch(existing)) {
3572
+ // A durable dispatch ledger / activation journal makes the occupant a
3573
+ // real conflict even when it currently has no worker; only a proven
3574
+ // ledger-empty command scratch may be retired.
3575
+ if (isDisposableCommandScratch(existing)
3576
+ && !hasProtectedSessionMutationOwnership(existing)) {
3006
3577
  scratchesToClose.push(existing.session.sessionId);
3007
3578
  continue;
3008
3579
  }
@@ -3012,12 +3583,43 @@ targetScope, opts) {
3012
3583
  for (const sid of scratchesToClose) {
3013
3584
  await closeSession(sid);
3014
3585
  }
3586
+ // A partially hydrated daemon can have an active target row absent from the
3587
+ // runtime map. Durable ownership makes that row a real conflict even when it
3588
+ // has worker:null and no legacy CLI markers; only proven ledger-empty
3589
+ // scratch rows may be retired.
3590
+ const runtimeIds = new Set(activeSessionsRegistry
3591
+ ? [...activeSessionsRegistry.values()].map(item => item.session.sessionId)
3592
+ : []);
3593
+ const persistedTargetConflicts = sessionStore.listSessions().filter(item => item.sessionId !== ds.session.sessionId
3594
+ && item.status === 'active'
3595
+ && (!item.larkAppId || item.larkAppId === ds.larkAppId)
3596
+ && !item.vcMeetingReceiver
3597
+ && (item.scope === 'chat' ? item.chatId : item.rootMessageId) === targetAnchor
3598
+ && !runtimeIds.has(item.sessionId));
3599
+ if (persistedTargetConflicts.some(item => hasProtectedSessionMutationOwnership(item)
3600
+ || !!item.cliId
3601
+ || !!item.lastCliInput
3602
+ || item.queued === true)) {
3603
+ return { ok: false, error: 'target_chat_has_session' };
3604
+ }
3605
+ for (const scratch of persistedTargetConflicts) {
3606
+ await closeSession(scratch.sessionId);
3607
+ }
3015
3608
  const postScratchStateError = validateAfterAwait();
3016
3609
  if (postScratchStateError)
3017
3610
  return postScratchStateError;
3018
3611
  const tagPrefix = sessionId.substring(0, 8);
3019
3612
  const oldAnchor = sessionAnchorId(ds);
3020
3613
  const oldChatId = ds.chatId;
3614
+ const oldStreamCardId = ds.streamCardId;
3615
+ const oldCurrentImageKey = ds.currentImageKey;
3616
+ // Scratch/store cleanup above awaited. A fresh source turn may have been
3617
+ // admitted during that window; recheck the durable dispatch ledger in the
3618
+ // same synchronous section that precedes the destructive detach so transfer
3619
+ // never abandons newly durable work.
3620
+ if (hasProtectedSessionMutationOwnership(ds)) {
3621
+ return { ok: false, error: 'codex_app_dispatch_pending' };
3622
+ }
3021
3623
  // Detach only the worker/observer. Persistent backends and Riff keep the
3022
3624
  // owned CLI/task alive so the replacement can reattach below. PTY cannot
3023
3625
  // survive a worker exit and therefore retains its historical cold-resume.
@@ -3420,6 +4022,28 @@ function resolvesToHome(p) {
3420
4022
  return p === homedir();
3421
4023
  }
3422
4024
  }
4025
+ export function codexAppCleanInputAcceptedForSession(ds) {
4026
+ try {
4027
+ const botCfg = getBot(ds.larkAppId).config;
4028
+ const effectiveCliId = ds.session.cliId ?? botCfg.cliId;
4029
+ return effectiveCliId === 'codex-app'
4030
+ && botCfg.codexAppCleanInput === true
4031
+ && !ds.adoptedFrom;
4032
+ }
4033
+ catch {
4034
+ // Admission metadata is optional for every other CLI. If bot config is
4035
+ // temporarily unavailable, fail closed instead of leaking a clean sidecar.
4036
+ return false;
4037
+ }
4038
+ }
4039
+ function cloneFrozenCodexAppInput(input, turnId) {
4040
+ if (!input)
4041
+ return undefined;
4042
+ const cloned = structuredClone(input);
4043
+ if (turnId && !cloned.clientUserMessageId)
4044
+ cloned.clientUserMessageId = turnId;
4045
+ return cloned;
4046
+ }
3423
4047
  function canForkRegisteredSession(ds) {
3424
4048
  const key = activeSessionKey(ds);
3425
4049
  const registered = activeSessionsRegistry ? activeSessionsRegistry.get(key) : ds;
@@ -3435,60 +4059,647 @@ function canForkRegisteredSession(ds) {
3435
4059
  function codexAppInputForSession(ds, input, turnId) {
3436
4060
  if (!input)
3437
4061
  return undefined;
4062
+ if (!codexAppCleanInputAcceptedForSession(ds))
4063
+ return undefined;
4064
+ return cloneFrozenCodexAppInput(input, turnId);
4065
+ }
4066
+ function codexAppDeliverySinkForTurn(ds, turnId, dispatchAttempt) {
4067
+ const armedThrough = ds.suppressedFinalOutputTurns?.get(turnId);
4068
+ if (dispatchAttempt !== undefined
4069
+ && armedThrough !== undefined
4070
+ && dispatchAttempt <= armedThrough)
4071
+ return 'suppressed';
4072
+ if (ds.session.docCommentTargets?.[turnId] || ds.docCommentTurns?.has(turnId)) {
4073
+ return 'doc_comment';
4074
+ }
4075
+ if (ds.pendingWaitPromises?.has(turnId))
4076
+ return 'http_wait';
4077
+ if (ds.asyncTriggerResults?.has(turnId))
4078
+ return 'http_async';
4079
+ return 'lark';
4080
+ }
4081
+ /** A recovered transient/non-IM sink has no safe provider to replay into.
4082
+ * Treat it as consumed so the runner can advance, but never fall through to a
4083
+ * Lark card. Doc-comment replay also fails closed: chunk posting has no durable
4084
+ * per-chunk effect journal yet, so blind crash replay could duplicate comments. */
4085
+ function codexAppDeliveryMustFailClosed(ds, entry) {
4086
+ const sink = entry.deliverySink
4087
+ ?? (ds.session.docCommentTargets?.[entry.turnId]
4088
+ ? 'doc_comment'
4089
+ : ds.chatId.startsWith('http_wait_')
4090
+ ? 'http_wait'
4091
+ : ds.chatId.startsWith('http_async_')
4092
+ ? 'http_async'
4093
+ : 'lark');
4094
+ if (sink === 'suppressed')
4095
+ return true;
4096
+ if (sink === 'doc_comment')
4097
+ return !ds.docCommentTurns?.has(entry.turnId);
4098
+ if (sink === 'http_wait')
4099
+ return !ds.pendingWaitPromises?.has(entry.turnId);
4100
+ if (sink === 'http_async')
4101
+ return !ds.asyncTriggerResults?.has(entry.turnId);
4102
+ return false;
4103
+ }
4104
+ function acceptCodexAppDispatch(ds, payload, turnId, dispatchAttempt, vcMeetingImTurnOrigin, opts = {}) {
3438
4105
  const botCfg = getBot(ds.larkAppId).config;
3439
4106
  const effectiveCliId = ds.session.cliId ?? botCfg.cliId;
3440
- if (effectiveCliId !== 'codex-app' || botCfg.codexAppCleanInput !== true || ds.adoptedFrom)
4107
+ if (effectiveCliId !== 'codex-app' || !turnId)
3441
4108
  return undefined;
3442
- return turnId && !input.clientUserMessageId
3443
- ? { ...input, clientUserMessageId: turnId }
3444
- : input;
3445
- }
3446
- /** Send one normal (non-raw) worker turn while applying the per-bot Codex App
3447
- * clean-input gate at message acceptance time. This freezes the sidecar onto
3448
- * the IPC item, so later config flips do not mutate an already queued turn. */
3449
- export function sendWorkerInput(ds, payload, turnId, opts = {}) {
3450
- const transferGate = transferInputGates.get(ds);
3451
- if ((!ds.worker || ds.worker.killed) && !transferGate)
3452
- return false;
3453
- const normalized = typeof payload === 'string' ? { content: payload } : payload;
3454
- const codexAppInput = codexAppInputForSession(ds, normalized.codexAppInput, turnId);
3455
- let nativeSessionTitlePrompt;
3456
- let nativeSessionTitle;
3457
- if (ds.session.nativeSessionTitleAwaitingContent && !ds.session.nativeSessionTitleUserDefined && !ds.adoptedFrom) {
3458
- const bot = getBot(ds.larkAppId);
3459
- const effectiveCliId = ds.session.cliId ?? bot.config.cliId;
3460
- if (effectiveCliId === 'codex') {
3461
- nativeSessionTitlePrompt = extractBotmuxLarkNativeSessionTitlePrompt(normalized.codexAppInput?.text ?? normalized.content, bot.botName ? [{ name: bot.botName }] : undefined);
3462
- if (nativeSessionTitlePrompt) {
3463
- nativeSessionTitle = buildBotmuxLarkNativeSessionTitle(nativeSessionTitlePrompt);
3464
- ds.session.nativeSessionTitle = nativeSessionTitle;
3465
- ds.session.nativeSessionTitleAwaitingContent = undefined;
3466
- if (ds.initConfig) {
3467
- ds.initConfig.nativeSessionTitle = nativeSessionTitle;
3468
- ds.initConfig.nativeSessionTitlePrompt = nativeSessionTitlePrompt;
3469
- }
3470
- sessionStore.updateSession(ds.session);
3471
- }
4109
+ const dispatchId = randomUUID();
4110
+ const priorLedger = ds.session.codexAppDispatchLedger;
4111
+ ds.session.codexAppDispatchLedger = appendAcceptedCodexAppDispatch(ds.session.codexAppDispatchLedger ?? [], {
4112
+ dispatchId,
4113
+ turnId,
4114
+ ...(payload.queuedActivationToken
4115
+ ? { queuedActivationToken: payload.queuedActivationToken }
4116
+ : {}),
4117
+ ...(payload.replyTurnId ? { replyTurnId: payload.replyTurnId } : {}),
4118
+ ...(payload.replyTarget ? { replyTarget: payload.replyTarget } : {}),
4119
+ ...(payload.quoteTargetId ? { quoteTargetId: payload.quoteTargetId } : {}),
4120
+ ...(payload.replyTargetSenderOpenId
4121
+ ? { replyTargetSenderOpenId: payload.replyTargetSenderOpenId }
4122
+ : {}),
4123
+ ...(payload.replyTargetSenderIsBot !== undefined
4124
+ ? { replyTargetSenderIsBot: payload.replyTargetSenderIsBot }
4125
+ : {}),
4126
+ deliverySink: codexAppDeliverySinkForTurn(ds, turnId, dispatchAttempt),
4127
+ ...(payload.codexAppSteerable ? { codexAppSteerable: true } : {}),
4128
+ ...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
4129
+ content: payload.content,
4130
+ ...(payload.codexAppInput ? { codexAppInput: payload.codexAppInput } : {}),
4131
+ ...(vcMeetingImTurnOrigin ? { vcMeetingImTurnOrigin } : {}),
4132
+ });
4133
+ if (opts.persist !== false) {
4134
+ try {
4135
+ sessionStore.updateSession(ds.session);
4136
+ }
4137
+ catch (err) {
4138
+ ds.session.codexAppDispatchLedger = priorLedger;
4139
+ throw err;
3472
4140
  }
3473
4141
  }
3474
- const vcMeetingImTurnOrigin = resolveVcMeetingImTurnOrigin(ds.session, turnId);
4142
+ return dispatchId;
4143
+ }
4144
+ function rollbackAcceptedCodexAppDispatch(ds, dispatchId, turnId, dispatchAttempt) {
4145
+ if (!dispatchId || !turnId)
4146
+ return;
4147
+ const cancelled = cancelCodexAppDispatch(ds.session.codexAppDispatchLedger ?? [], {
4148
+ dispatchId,
4149
+ turnId,
4150
+ ...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
4151
+ });
4152
+ if (!cancelled.ok)
4153
+ return;
4154
+ const priorLedger = ds.session.codexAppDispatchLedger;
4155
+ ds.session.codexAppDispatchLedger = cancelled.ledger;
4156
+ try {
4157
+ sessionStore.updateSession(ds.session);
4158
+ if (!hasUnsettledCodexAppDispatch(ds.session.codexAppDispatchLedger)) {
4159
+ void Promise.resolve(callbacks?.onCodexAppLedgerDrained?.(ds)).catch(err => {
4160
+ logger.error(`[${ds.session.sessionId.slice(0, 8)}] post-rollback ledger-drain cleanup failed: `
4161
+ + `${err instanceof Error ? err.message : String(err)}`);
4162
+ });
4163
+ }
4164
+ }
4165
+ catch {
4166
+ ds.session.codexAppDispatchLedger = priorLedger;
4167
+ }
4168
+ }
4169
+ function snapshotQueuedActivationJournal(session) {
4170
+ return {
4171
+ queuedActivationPending: session.queuedActivationPending,
4172
+ queuedActivationToken: session.queuedActivationToken,
4173
+ queuedActivationInput: session.queuedActivationInput,
4174
+ queuedActivationTurnId: session.queuedActivationTurnId,
4175
+ queuedActivationDispatchAttempt: session.queuedActivationDispatchAttempt,
4176
+ queuedActivationResume: session.queuedActivationResume,
4177
+ queuedPrompt: session.queuedPrompt,
4178
+ queuedCodexAppText: session.queuedCodexAppText,
4179
+ queuedCodexAppMessageContext: session.queuedCodexAppMessageContext,
4180
+ pendingRepoSetup: session.pendingRepoSetup
4181
+ ? structuredClone(session.pendingRepoSetup)
4182
+ : undefined,
4183
+ };
4184
+ }
4185
+ function restoreQueuedActivationJournal(session, snapshot) {
4186
+ Object.assign(session, snapshot);
4187
+ }
4188
+ function clearQueuedActivationJournal(session) {
4189
+ session.queuedActivationPending = undefined;
4190
+ session.queuedActivationToken = undefined;
4191
+ session.queuedActivationInput = undefined;
4192
+ session.queuedActivationTurnId = undefined;
4193
+ session.queuedActivationDispatchAttempt = undefined;
4194
+ session.queuedActivationResume = undefined;
4195
+ session.queuedPrompt = undefined;
4196
+ session.queuedCodexAppText = undefined;
4197
+ session.queuedCodexAppMessageContext = undefined;
4198
+ session.pendingRepoSetup = undefined;
4199
+ }
4200
+ /** A worker disappeared before the exact activation ACK. Re-park non-Codex
4201
+ * work from the retained exact input; ACK loss may duplicate, but N can never
4202
+ * be silently replaced by a later inbound turn in the same daemon lifetime. */
4203
+ function reparkUnsubmittedQueuedActivation(ds, reason) {
4204
+ if (!ds.session.queuedActivationPending
4205
+ || !ds.session.queuedActivationToken
4206
+ || ds.session.cliId === 'codex-app')
4207
+ return false;
4208
+ ds.session.queued = true;
4209
+ ds.session.queuedActivationPending = undefined;
4210
+ ds.session.queuedActivationToken = undefined;
4211
+ ds.initialStartPending = false;
4212
+ ds.initialStartClaimToken = undefined;
4213
+ ds.pendingPrompt ??= ds.session.queuedPrompt;
4214
+ try {
4215
+ sessionStore.updateSession(ds.session);
4216
+ }
4217
+ catch (err) {
4218
+ // Memory remains explicitly parked while the old durable marker still
4219
+ // provides restart recovery on disk.
4220
+ logger.error(`[${tag(ds)}] Failed to persist queued activation re-park after ${reason}: `
4221
+ + `${err instanceof Error ? err.message : String(err)}`);
4222
+ }
4223
+ logger.warn(`[${tag(ds)}] Re-parked unacknowledged queued activation after ${reason}`);
4224
+ return true;
4225
+ }
4226
+ /** The opening activation was ACKed, but one of the turns held behind its
4227
+ * runtime reservation was not accepted before that worker exited. Promote the
4228
+ * exact FIFO head into a new durable queued activation so the next inbound or
4229
+ * Dashboard activation reforks it before every remaining tail item. */
4230
+ function reparkQueuedActivationFollowUpTail(ds, reason) {
4231
+ if (!ds.initialStartPending || ds.session.queuedActivationPending)
4232
+ return false;
4233
+ const next = ds.pendingQueuedActivationFollowUps?.[0];
4234
+ if (!next)
4235
+ return false;
4236
+ const matchingCodexEntries = ds.session.cliId === 'codex-app'
4237
+ ? (ds.session.codexAppDispatchLedger ?? []).filter(entry => (entry.state === 'accepted' || entry.state === 'prepared')
4238
+ && entry.turnId === next.turnId
4239
+ && entry.dispatchAttempt === next.dispatchAttempt)
4240
+ : [];
4241
+ if (matchingCodexEntries.length > 1) {
4242
+ logger.error(`[${tag(ds)}] Cannot re-park queued follow-up after ${reason}: `
4243
+ + `${matchingCodexEntries.length} Codex entries match turn ${next.turnId}`);
4244
+ return false;
4245
+ }
4246
+ const retainedCodexEntry = matchingCodexEntries[0];
4247
+ const retainedCodexToken = retainedCodexEntry
4248
+ ? (retainedCodexEntry.queuedActivationToken ?? randomUUID())
4249
+ : undefined;
4250
+ // A failed daemon→worker IPC normally rolls its newly accepted Codex entry
4251
+ // back. If that rollback persistence itself failed, the durable FIFO remains
4252
+ // authoritative: recover it through a tokened ACK journal instead of
4253
+ // creating an invalid queued+unsettled hybrid.
4254
+ ds.session.queued = !retainedCodexEntry;
4255
+ ds.session.queuedPrompt = next.cliInput.content;
4256
+ ds.session.queuedCodexAppText = next.cliInput.codexAppInput?.text;
4257
+ ds.session.queuedCodexAppMessageContext = undefined;
4258
+ ds.session.queuedActivationInput = next.cliInput;
4259
+ ds.session.queuedActivationTurnId = next.turnId;
4260
+ ds.session.queuedActivationDispatchAttempt = next.dispatchAttempt;
4261
+ ds.session.queuedActivationPending = retainedCodexEntry ? true : undefined;
4262
+ ds.session.queuedActivationToken = retainedCodexToken;
4263
+ if (retainedCodexEntry && retainedCodexToken) {
4264
+ retainedCodexEntry.queuedActivationToken = retainedCodexToken;
4265
+ }
4266
+ ds.pendingQueuedActivationFollowUps.shift();
4267
+ if (ds.pendingQueuedActivationFollowUps.length === 0) {
4268
+ ds.pendingQueuedActivationFollowUps = undefined;
4269
+ }
4270
+ ds.pendingPrompt = next.cliInput.content;
4271
+ ds.initialStartPending = false;
4272
+ ds.initialStartClaimToken = undefined;
4273
+ try {
4274
+ sessionStore.updateSession(ds.session);
4275
+ }
4276
+ catch (err) {
4277
+ // Keep the exact head parked in memory. The caller has already fenced the
4278
+ // dead worker, so a later inbound can safely retry this owner even if the
4279
+ // durable projection is temporarily unavailable.
4280
+ logger.error(`[${tag(ds)}] Failed to persist queued follow-up re-park after ${reason}: `
4281
+ + `${err instanceof Error ? err.message : String(err)}`);
4282
+ }
4283
+ logger.warn(`[${tag(ds)}] Re-parked unaccepted queued activation follow-up after ${reason}`);
4284
+ return true;
4285
+ }
4286
+ export const __testOnly_reparkQueuedActivationFollowUpTail = reparkQueuedActivationFollowUpTail;
4287
+ /** Compensate only the durable mutations made before a worker accepts its init
4288
+ * IPC. This is one synchronous call stack, so removing the exact dispatch that
4289
+ * this fork appended cannot race a later worker transition or damage the FIFO
4290
+ * that existed before the attempt. Persist the queued payload and ledger in one
4291
+ * write so a daemon crash cannot observe only half of the compensation. */
4292
+ function rollbackWorkerForkPreInit(ds, queuedSnapshot, acceptedDispatch) {
4293
+ const exactActivationInput = ds.session.queuedActivationInput;
4294
+ const exactActivationTurnId = ds.session.queuedActivationTurnId;
4295
+ const exactActivationDispatchAttempt = ds.session.queuedActivationDispatchAttempt;
4296
+ const exactActivationResume = ds.session.queuedActivationResume;
4297
+ let changed = false;
4298
+ if (acceptedDispatch) {
4299
+ const cancelled = cancelCodexAppDispatch(ds.session.codexAppDispatchLedger ?? [], acceptedDispatch);
4300
+ if (!cancelled.ok) {
4301
+ throw new Error(`failed to cancel pre-init Codex App dispatch: ${cancelled.error}`);
4302
+ }
4303
+ ds.session.codexAppDispatchLedger = cancelled.ledger;
4304
+ changed = true;
4305
+ }
4306
+ if (queuedSnapshot) {
4307
+ ds.session.queued = queuedSnapshot.queued;
4308
+ restoreQueuedActivationJournal(ds.session, queuedSnapshot);
4309
+ // The activation may have folded a triggering group reply into the final
4310
+ // init payload. Retain that exact retry body even though the in-flight
4311
+ // marker/token are rolled back with the fenced child.
4312
+ ds.session.queuedActivationPending = undefined;
4313
+ ds.session.queuedActivationToken = undefined;
4314
+ ds.session.queuedActivationInput = exactActivationInput;
4315
+ ds.session.queuedActivationTurnId = exactActivationTurnId;
4316
+ ds.session.queuedActivationDispatchAttempt = exactActivationDispatchAttempt;
4317
+ ds.session.queuedActivationResume = exactActivationResume;
4318
+ ds.initialStartPending = queuedSnapshot.initialStartPending;
4319
+ changed = true;
4320
+ }
4321
+ if (changed)
4322
+ sessionStore.updateSession(ds.session);
4323
+ }
4324
+ /** Send one normal (non-raw) worker turn while applying the per-bot Codex App
4325
+ * clean-input gate at message acceptance time. This freezes the sidecar onto
4326
+ * the IPC item, so later config flips do not mutate an already queued turn. */
4327
+ export function sendWorkerInput(ds, payload, turnId, opts = {}) {
4328
+ const riffRetirementPhase = riffRetirementAdmissionPhase(ds);
4329
+ if (riffRetirementPhase) {
4330
+ logger.warn(`[${tag(ds)}] Rejected turn ${turnId ?? '?'} while Riff retirement fence is ${riffRetirementPhase}`);
4331
+ void callbacks?.sessionReply(sessionAnchorId(ds), tr('worker.riff_close_in_progress', undefined, localeForBot(ds.larkAppId)), 'text', ds.larkAppId, turnId).catch(err => {
4332
+ logger.warn(`[${tag(ds)}] Failed to notify rejected Riff close-race turn: `
4333
+ + `${err instanceof Error ? err.message : String(err)}`);
4334
+ });
4335
+ return false;
4336
+ }
4337
+ const transferGate = transferInputGates.get(ds);
4338
+ if ((!ds.worker || ds.worker.killed) && !transferGate)
4339
+ return false;
4340
+ const normalized = typeof payload === 'string' ? { content: payload } : payload;
4341
+ const effectiveCliId = ds.session.cliId ?? getBot(ds.larkAppId).config.cliId;
4342
+ const effectiveTurnId = turnId ?? (effectiveCliId === 'codex-app'
4343
+ ? `codex-app-dispatch-${randomUUID()}`
4344
+ : undefined);
4345
+ const replyTurnId = turnId ? undefined : ds.currentReplyTarget?.turnId;
4346
+ const routingTurnId = turnId ?? replyTurnId;
4347
+ const replyContext = frozenReplyContextForTurn(ds, routingTurnId);
4348
+ const vcMeetingImTurnOrigin = resolveVcMeetingImTurnOrigin(ds.session, routingTurnId);
4349
+ let nativeSessionTitlePrompt;
4350
+ let nativeSessionTitle;
4351
+ if (ds.session.nativeSessionTitleAwaitingContent && !ds.session.nativeSessionTitleUserDefined && !ds.adoptedFrom) {
4352
+ const bot = getBot(ds.larkAppId);
4353
+ if (effectiveCliId === 'codex') {
4354
+ nativeSessionTitlePrompt = extractBotmuxLarkNativeSessionTitlePrompt(normalized.codexAppInput?.text ?? normalized.content, bot.botName ? [{ name: bot.botName }] : undefined);
4355
+ if (nativeSessionTitlePrompt) {
4356
+ nativeSessionTitle = buildBotmuxLarkNativeSessionTitle(nativeSessionTitlePrompt);
4357
+ ds.session.nativeSessionTitle = nativeSessionTitle;
4358
+ ds.session.nativeSessionTitleAwaitingContent = undefined;
4359
+ if (ds.initConfig) {
4360
+ ds.initConfig.nativeSessionTitle = nativeSessionTitle;
4361
+ ds.initConfig.nativeSessionTitlePrompt = nativeSessionTitlePrompt;
4362
+ }
4363
+ sessionStore.updateSession(ds.session);
4364
+ }
4365
+ }
4366
+ }
4367
+ if (hasQueuedActivationAdmissionGate(ds)) {
4368
+ const queuedTurnId = effectiveTurnId
4369
+ ?? routingTurnId
4370
+ ?? `queued-activation-followup-${randomUUID()}`;
4371
+ try {
4372
+ admitQueuedActivationTail(ds, {
4373
+ userPrompt: normalized.content,
4374
+ cliInput: {
4375
+ content: normalized.content,
4376
+ ...(normalized.codexAppInput
4377
+ ? { codexAppInput: normalized.codexAppInput }
4378
+ : {}),
4379
+ // R4-B1: freeze the admission-time steer authorization into the queued
4380
+ // tail's frozen payload so promote/repark/restore COPY it verbatim
4381
+ // (admission computed once; never re-inferred downstream).
4382
+ ...(opts.codexAppSteerable ? { codexAppSteerable: true } : {}),
4383
+ },
4384
+ turnId: queuedTurnId,
4385
+ ...(opts.dispatchAttempt !== undefined
4386
+ ? { dispatchAttempt: opts.dispatchAttempt }
4387
+ : {}),
4388
+ });
4389
+ logger.info(`[${tag(ds)}] Staged turn ${queuedTurnId} behind queued activation ACK`);
4390
+ return true;
4391
+ }
4392
+ catch (err) {
4393
+ logger.error(`[${tag(ds)}] Failed to durably stage turn ${queuedTurnId} behind activation: `
4394
+ + `${err instanceof Error ? err.message : String(err)}`);
4395
+ return false;
4396
+ }
4397
+ }
4398
+ const codexAppInput = codexAppInputForSession(ds, normalized.codexAppInput, effectiveTurnId);
4399
+ const codexAppDispatchId = acceptCodexAppDispatch(ds, {
4400
+ content: normalized.content,
4401
+ ...(codexAppInput ? { codexAppInput } : {}),
4402
+ ...(replyTurnId ? { replyTurnId } : {}),
4403
+ replyTarget: replyContext.target,
4404
+ ...(replyContext.quoteTargetId ? { quoteTargetId: replyContext.quoteTargetId } : {}),
4405
+ ...(replyContext.replyTargetSenderOpenId
4406
+ ? { replyTargetSenderOpenId: replyContext.replyTargetSenderOpenId }
4407
+ : {}),
4408
+ ...(replyContext.replyTargetSenderIsBot !== undefined
4409
+ ? { replyTargetSenderIsBot: replyContext.replyTargetSenderIsBot }
4410
+ : {}),
4411
+ ...(opts.codexAppSteerable ? { codexAppSteerable: true } : {}),
4412
+ }, effectiveTurnId, opts.dispatchAttempt, vcMeetingImTurnOrigin);
4413
+ // Route through sendWorkerSessionInput so an in-flight routing transfer
4414
+ // buffers this turn instead of dropping it (master's transfer input gate),
4415
+ // while preserving PR #597's accepted-dispatch ledger + reply-context wiring.
3475
4416
  const message = {
3476
4417
  type: 'message',
3477
4418
  content: normalized.content,
3478
4419
  ...(codexAppInput ? { codexAppInput } : {}),
3479
4420
  ...(nativeSessionTitle ? { nativeSessionTitle } : {}),
3480
4421
  ...(nativeSessionTitlePrompt ? { nativeSessionTitlePrompt } : {}),
3481
- ...(turnId ? { turnId } : {}),
4422
+ ...(effectiveTurnId ? { turnId: effectiveTurnId } : {}),
4423
+ ...(replyTurnId ? { replyTurnId } : {}),
3482
4424
  ...(opts.dispatchAttempt !== undefined ? { dispatchAttempt: opts.dispatchAttempt } : {}),
4425
+ ...(codexAppDispatchId ? { codexAppDispatchId } : {}),
4426
+ ...(opts.codexAppSteerable ? { codexAppSteerable: true } : {}),
3483
4427
  ...(vcMeetingImTurnOrigin
3484
4428
  ? { vcMeetingImTurnOrigin }
3485
4429
  : {}),
3486
4430
  };
3487
- if (!transferGate && shouldTrackOrdinaryImDelivery(ds, message)) {
3488
- return sendOrdinaryImDeliveryTracked(ds, message);
4431
+ // #597: the accept-ledger entry is already appended above; if the IPC send
4432
+ // fails (returns false or throws) we MUST roll it back or the dispatch ledger
4433
+ // keeps a phantom accepted turn. master's ordinary-IM-delivery tracking runs
4434
+ // inside the same protected send so a tracked delivery still rolls back.
4435
+ const performSend = () => {
4436
+ if (!transferGate && shouldTrackOrdinaryImDelivery(ds, message)) {
4437
+ return sendOrdinaryImDeliveryTracked(ds, message);
4438
+ }
4439
+ return sendWorkerSessionInput(ds, message);
4440
+ };
4441
+ try {
4442
+ if (!performSend()) {
4443
+ rollbackAcceptedCodexAppDispatch(ds, codexAppDispatchId, effectiveTurnId, opts.dispatchAttempt);
4444
+ return false;
4445
+ }
3489
4446
  }
3490
- return sendWorkerSessionInput(ds, message);
4447
+ catch {
4448
+ rollbackAcceptedCodexAppDispatch(ds, codexAppDispatchId, effectiveTurnId, opts.dispatchAttempt);
4449
+ return false;
4450
+ }
4451
+ return true;
3491
4452
  }
4453
+ /** Promote the oldest durable activation successor into a fresh tokened
4454
+ * journal and (for Codex App) its single accepted-ledger owner in one store
4455
+ * update, then hand it to the current worker. The tail is never shifted merely
4456
+ * because ChildProcess.send returned: the promoted journal survives until the
4457
+ * adapter ACK carrying this token arrives. */
4458
+ export function promoteQueuedActivationTail(ds, opts = {}) {
4459
+ if (ds.session.queuedActivationPending)
4460
+ return true;
4461
+ if (opts.send !== false
4462
+ && (!ds.worker || ds.worker.killed))
4463
+ return false;
4464
+ const ordered = [...(ds.session.queuedActivationTail ?? [])]
4465
+ .sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
4466
+ const head = ordered[0];
4467
+ if (!head)
4468
+ return false;
4469
+ const priorJournal = snapshotQueuedActivationJournal(ds.session);
4470
+ const priorTail = ds.session.queuedActivationTail?.map(entry => ({
4471
+ ...entry,
4472
+ cliInput: {
4473
+ ...entry.cliInput,
4474
+ ...(entry.cliInput.codexAppInput
4475
+ ? { codexAppInput: structuredClone(entry.cliInput.codexAppInput) }
4476
+ : {}),
4477
+ },
4478
+ }));
4479
+ const priorLedger = ds.session.codexAppDispatchLedger?.map(entry => ({ ...entry }));
4480
+ const priorPendingPrompt = ds.pendingPrompt;
4481
+ const token = randomUUID();
4482
+ const replyContext = frozenReplyContextForTurn(ds, head.turnId);
4483
+ // The tail entry crossed its admission boundary with the clean-input gate
4484
+ // already frozen. Do not re-run that immediate bot-config gate at promotion:
4485
+ // a true→false toggle between N+1 admission and N's ACK would otherwise
4486
+ // discard N+1's exact sidecar/hidden context. Only fill the deterministic id
4487
+ // for legacy entries persisted before admission began stamping it.
4488
+ const codexAppInput = cloneFrozenCodexAppInput(head.cliInput.codexAppInput, head.turnId);
4489
+ const exactInput = {
4490
+ content: head.cliInput.content,
4491
+ ...(codexAppInput ? { codexAppInput } : {}),
4492
+ // R5-B1-2: preserve the frozen steer authorization onto the promoted
4493
+ // queuedActivationInput so the ensuing fork/accept-ledger COPY (below) sees
4494
+ // it. Only `=== true`; a missing/false tail head stays forced-serial.
4495
+ ...(head.cliInput.codexAppSteerable === true ? { codexAppSteerable: true } : {}),
4496
+ };
4497
+ const vcMeetingImTurnOrigin = resolveVcMeetingImTurnOrigin(ds.session, head.turnId);
4498
+ ds.session.queued = false;
4499
+ ds.session.queuedActivationPending = true;
4500
+ ds.session.queuedActivationToken = token;
4501
+ ds.session.queuedActivationInput = exactInput;
4502
+ ds.session.queuedActivationTurnId = head.turnId;
4503
+ ds.session.queuedActivationDispatchAttempt = head.dispatchAttempt;
4504
+ ds.session.queuedActivationResume = ds.hasHistory;
4505
+ ds.session.queuedActivationTail = ordered.slice(1);
4506
+ if (ds.session.queuedActivationTail.length === 0) {
4507
+ ds.session.queuedActivationTail = undefined;
4508
+ }
4509
+ ds.pendingPrompt = exactInput.content;
4510
+ ds.initialStartPending = true;
4511
+ let codexAppDispatchId;
4512
+ try {
4513
+ codexAppDispatchId = acceptCodexAppDispatch(ds, {
4514
+ content: exactInput.content,
4515
+ ...(codexAppInput ? { codexAppInput } : {}),
4516
+ queuedActivationToken: token,
4517
+ replyTarget: replyContext.target,
4518
+ ...(replyContext.quoteTargetId ? { quoteTargetId: replyContext.quoteTargetId } : {}),
4519
+ ...(replyContext.replyTargetSenderOpenId
4520
+ ? { replyTargetSenderOpenId: replyContext.replyTargetSenderOpenId }
4521
+ : {}),
4522
+ ...(replyContext.replyTargetSenderIsBot !== undefined
4523
+ ? { replyTargetSenderIsBot: replyContext.replyTargetSenderIsBot }
4524
+ : {}),
4525
+ // R4-B1: COPY the frozen steer authorization from the promoted tail
4526
+ // entry's payload onto the accept-ledger (admission computed once; never
4527
+ // re-inferred at promote time).
4528
+ ...(exactInput.codexAppSteerable ? { codexAppSteerable: true } : {}),
4529
+ }, head.turnId, head.dispatchAttempt, vcMeetingImTurnOrigin, { persist: false });
4530
+ sessionStore.updateSession(ds.session);
4531
+ }
4532
+ catch (err) {
4533
+ restoreQueuedActivationJournal(ds.session, priorJournal);
4534
+ ds.session.queuedActivationTail = priorTail;
4535
+ ds.session.codexAppDispatchLedger = priorLedger;
4536
+ ds.pendingPrompt = priorPendingPrompt;
4537
+ logger.error(`[${tag(ds)}] Failed to atomically promote queued activation tail ${head.id}: `
4538
+ + `${err instanceof Error ? err.message : String(err)}`);
4539
+ return false;
4540
+ }
4541
+ if (opts.send === false)
4542
+ return true;
4543
+ try {
4544
+ ds.worker.send({
4545
+ type: 'message',
4546
+ content: exactInput.content,
4547
+ ...(codexAppInput ? { codexAppInput } : {}),
4548
+ turnId: head.turnId,
4549
+ ...(head.dispatchAttempt !== undefined
4550
+ ? { dispatchAttempt: head.dispatchAttempt }
4551
+ : {}),
4552
+ ...(codexAppDispatchId ? { codexAppDispatchId } : {}),
4553
+ // R6-B2: COPY the frozen steer authorization onto the live-worker IPC — the
4554
+ // last hop. Without it a promoted N+1 reaches a live worker with the daemon
4555
+ // ledger head steerable=true but the worker reservation false, so a
4556
+ // legitimate superseded settlement would be wrongly rejected.
4557
+ ...(exactInput.codexAppSteerable === true ? { codexAppSteerable: true } : {}),
4558
+ queuedActivationToken: token,
4559
+ ...(vcMeetingImTurnOrigin ? { vcMeetingImTurnOrigin } : {}),
4560
+ });
4561
+ }
4562
+ catch (err) {
4563
+ // Durable ownership already moved to the journal (and Codex ledger). Never
4564
+ // append another owner on retry; fence this IPC generation and let recovery
4565
+ // replay the exact tokened head.
4566
+ logger.error(`[${tag(ds)}] Worker IPC rejected promoted activation ${head.id}; retaining journal: `
4567
+ + `${err instanceof Error ? err.message : String(err)}`);
4568
+ try {
4569
+ ds.worker.kill();
4570
+ }
4571
+ catch { /* exit/error path will fence runtime */ }
4572
+ }
4573
+ return true;
4574
+ }
4575
+ /** Reserve arrival order synchronously, before any caller-specific prompt
4576
+ * rendering may await. Gaps are harmless; reusing an order after a failed
4577
+ * durable admission would not be. */
4578
+ export function reserveQueuedActivationTailAdmission(ds) {
4579
+ const order = (ds.session.queuedActivationTailNextOrder ?? 0) + 1;
4580
+ ds.session.queuedActivationTailNextOrder = order;
4581
+ return {
4582
+ id: randomUUID(),
4583
+ order,
4584
+ codexAppInputAccepted: codexAppCleanInputAcceptedForSession(ds),
4585
+ };
4586
+ }
4587
+ /** Admit one exact successor behind a queued activation. The response boundary
4588
+ * is the session-store write: callers must not report acceptance or use live
4589
+ * worker IPC unless this returns. `reservation` lets routes reserve FIFO order
4590
+ * before asynchronous prompt construction without duplicating persistence
4591
+ * logic. */
4592
+ export function admitQueuedActivationTail(ds, entry, reservation = reserveQueuedActivationTailAdmission(ds), opts = {}) {
4593
+ const acceptedCodexAppInput = opts.codexAppInputGateFrozen === true
4594
+ ? cloneFrozenCodexAppInput(entry.cliInput.codexAppInput, entry.turnId)
4595
+ : reservation.codexAppInputAccepted === true
4596
+ ? cloneFrozenCodexAppInput(entry.cliInput.codexAppInput, entry.turnId)
4597
+ : undefined;
4598
+ const admitted = {
4599
+ id: reservation.id,
4600
+ order: reservation.order,
4601
+ ...entry,
4602
+ cliInput: {
4603
+ content: entry.cliInput.content,
4604
+ ...(acceptedCodexAppInput ? { codexAppInput: acceptedCodexAppInput } : {}),
4605
+ // R5-B1-2: preserve the frozen steer authorization across the tail rebuild
4606
+ // (only `=== true`, never truthy). Dropping it here silently un-authorized
4607
+ // every queued/opening turn — the strip point that defeated the R4 fix.
4608
+ ...(entry.cliInput.codexAppSteerable === true ? { codexAppSteerable: true } : {}),
4609
+ },
4610
+ };
4611
+ const priorTail = ds.session.queuedActivationTail;
4612
+ const next = [...(priorTail ?? [])];
4613
+ if (!next.some(candidate => candidate.id === admitted.id))
4614
+ next.push(admitted);
4615
+ next.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
4616
+ ds.session.queuedActivationTail = next;
4617
+ try {
4618
+ sessionStore.updateSession(ds.session);
4619
+ }
4620
+ catch (err) {
4621
+ ds.session.queuedActivationTail = priorTail;
4622
+ throw err;
4623
+ }
4624
+ return admitted;
4625
+ }
4626
+ /** True while a live worker's opening activation still owns submission order.
4627
+ * Every ingress that sees this state must use admitQueuedActivationTail rather
4628
+ * than ordinary worker IPC. */
4629
+ export function hasQueuedActivationAdmissionGate(ds) {
4630
+ return ds.session.queuedActivationPending === true
4631
+ || (ds.session.queuedActivationTail?.length ?? 0) > 0
4632
+ || (ds.queuedActivationTailAdmissionsOutstanding ?? 0) > 0
4633
+ || ds.queuedActivationTailReleasePending !== undefined
4634
+ || (ds.initialStartPending === true
4635
+ && ds.session.queuedActivationInput !== undefined);
4636
+ }
4637
+ /** Central quarantine decision for one fork boundary — the SINGLE authority that
4638
+ * keeps a restore-time "tail-only quarantine" from being forked incorrectly.
4639
+ *
4640
+ * A quarantined owner (see restoreActiveSessions) has an old activation-tail head
4641
+ * that failed to promote transiently: the head sits un-promoted in the tail with
4642
+ * the admission gate held (worker:null). The invariant across EVERY fork boundary
4643
+ * (restore reattach, IM inbound refork, web-terminal lazy wake, and any future
4644
+ * one) is enforced here so callers cannot each miss it:
4645
+ *
4646
+ * - Not quarantined → pass the caller's args through unchanged.
4647
+ * - Quarantined + NON-empty prompt → REFUSE (`fork:false`). forkWorker cannot
4648
+ * verify the caller durably staged this turn behind the old head, so letting
4649
+ * it through could overtake the head. The inbound path must durable-admit the
4650
+ * turn into the tail first, then blank-recover (empty prompt) through here.
4651
+ * - Quarantined + empty prompt → retry the old head's promotion. On failure keep
4652
+ * the flag/gate/worker:null and REFUSE (a blank fork would leave a live worker
4653
+ * beside an unpromoted tail and permanently wedge the FIFO gate). On success,
4654
+ * clear the flag and rewrite the fork to recover the PROMOTED OLD HEAD (never
4655
+ * the caller's turn): Codex App recovers through its ledger with an empty
4656
+ * prompt; a non-Codex CLI resubmits the exact `queuedActivationInput` with the
4657
+ * persisted resume/turn/attempt — mirroring the daemon activation-recovery fork.
4658
+ *
4659
+ * Extracted (and exported) so the orchestration can be unit-tested without a real
4660
+ * tmux/worker: mock `promoteQueuedActivationTail` and assert refuse vs. recover
4661
+ * args, then that forkWorker applies them.
4662
+ */
4663
+ export function resolveQuarantinedForkPlan(ds, promptInput, resumeOrTurnId) {
4664
+ if (!ds.quarantinedActivationTailPromotion) {
4665
+ return { fork: true, promptInput, resumeOrTurnId };
4666
+ }
4667
+ const content = typeof promptInput === 'string' ? promptInput : promptInput.content;
4668
+ if (content.length > 0) {
4669
+ logger.warn(`[${tag(ds)}] Refused non-empty fork of a quarantined tail-only owner; `
4670
+ + `caller must durable-admit the turn behind the old head, then blank-recover`);
4671
+ return { fork: false, promptInput, resumeOrTurnId };
4672
+ }
4673
+ if (!promoteQueuedActivationTail(ds, { send: false })) {
4674
+ logger.warn(`[${tag(ds)}] Quarantined activation-tail promotion still failing at fork boundary; `
4675
+ + `keeping worker:null quarantined owner (no fork) to avoid a live worker beside an unpromoted tail`);
4676
+ return { fork: false, promptInput, resumeOrTurnId };
4677
+ }
4678
+ ds.quarantinedActivationTailPromotion = undefined;
4679
+ // Promotion succeeded: the old head is now the tokened queued activation. Fork
4680
+ // THAT, exactly like the daemon's queuedActivation recovery — Codex App through
4681
+ // its dispatch ledger (empty prompt), non-Codex by resubmitting the exact input.
4682
+ const recoverThroughCodexLedger = (ds.session.cliId ?? getBot(ds.larkAppId).config.cliId) === 'codex-app';
4683
+ return {
4684
+ fork: true,
4685
+ promptInput: recoverThroughCodexLedger ? '' : (ds.session.queuedActivationInput ?? ''),
4686
+ resumeOrTurnId: {
4687
+ resume: ds.session.queuedActivationResume ?? ds.hasHistory,
4688
+ turnId: ds.session.queuedActivationTurnId,
4689
+ dispatchAttempt: ds.session.queuedActivationDispatchAttempt,
4690
+ },
4691
+ };
4692
+ }
4693
+ /**
4694
+ * Fork (or re-attach) a worker for `ds`.
4695
+ *
4696
+ * Returns `false` ONLY when a quarantined tail-only owner's promotion could not
4697
+ * be recovered at this fork boundary (see resolveQuarantinedForkPlan): the caller
4698
+ * MUST treat the session as still worker-less (no live worker was started) and
4699
+ * leave the admission gate held. Every other outcome — forked, re-attached,
4700
+ * staged behind an ACK, routed through a live owner, or spawn-deferred during
4701
+ * device isolation — returns `true`.
4702
+ */
3492
4703
  export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
3493
4704
  const gatedPrompt = typeof promptInput === 'string' ? { content: promptInput } : promptInput;
3494
4705
  const transferGate = transferInputGates.get(ds);
@@ -3506,6 +4717,11 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
3506
4717
  ...(gatedDispatchAttempt !== undefined
3507
4718
  ? { dispatchAttempt: gatedDispatchAttempt }
3508
4719
  : {}),
4720
+ // R6-B3 (transfer-gate sibling): sendWorkerInput reads steer
4721
+ // authorization from OPTS, not the payload. A steerable opening/refork
4722
+ // rerouted through the transfer gate must carry the flag here too, or it
4723
+ // silently downgrades true → false like the direct-route branch did.
4724
+ ...(gatedPrompt.codexAppSteerable === true ? { codexAppSteerable: true } : {}),
3509
4725
  });
3510
4726
  }
3511
4727
  else {
@@ -3519,7 +4735,10 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
3519
4735
  }
3520
4736
  });
3521
4737
  }
3522
- return;
4738
+ // Buffered/deferred behind the routing transfer: no live worker started
4739
+ // now, but the turn is durably staged, so this is not the quarantine
4740
+ // refusal that boolean-false signals to callers.
4741
+ return true;
3523
4742
  }
3524
4743
  // Device enrollment briefly freezes every daemon before the one-way host
3525
4744
  // marker is installed and legacy local CLIs are torn down. Do this before
@@ -3532,34 +4751,34 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
3532
4751
  }
3533
4752
  })) {
3534
4753
  logger.info(`[${tag(ds)}] worker spawn deferred during device credential activation`);
3535
- return;
4754
+ return true;
3536
4755
  }
4756
+ // Central quarantine guard — the single source of truth for the tail-only
4757
+ // restore quarantine invariant (documented on resolveQuarantinedForkPlan). Runs
4758
+ // before ANY prompt derivation or session mutation so a refusal is side-effect
4759
+ // free, and so a recovered plan rewrites the prompt/resume args used below.
4760
+ const quarantinePlan = resolveQuarantinedForkPlan(ds, promptInput, resumeOrTurnId);
4761
+ if (!quarantinePlan.fork)
4762
+ return false;
4763
+ promptInput = quarantinePlan.promptInput;
4764
+ resumeOrTurnId = quarantinePlan.resumeOrTurnId;
4765
+ // Refuse a closed or superseded session before any mutation (master guard).
3537
4766
  if (!canForkRegisteredSession(ds))
3538
- return;
3539
- const cb = requireCallbacks();
3540
- const bot = getBot(ds.larkAppId);
3541
- const botCfg = bot.config;
4767
+ return false;
3542
4768
  const promptPayload = typeof promptInput === 'string' ? { content: promptInput } : promptInput;
3543
4769
  const prompt = promptPayload.content;
3544
- // 不变式:一旦真正起 CLI,会话就不再是「待办池(queued)」parked 态。无论由哪条
3545
- // 路径触发(激活按钮 / 拖到进行中 / 群里来消息抢先起会话),都在此清掉 queued
3546
- // 标记并落盘——否则重启后会被当 parked 恢复成 hasHistory:false 而丢掉真历史。
3547
- if (ds.session.queued) {
3548
- ds.session.queued = false;
3549
- ds.session.queuedPrompt = undefined;
3550
- ds.session.queuedCodexAppText = undefined;
3551
- ds.session.queuedCodexAppMessageContext = undefined;
3552
- sessionStore.updateSession(ds.session);
3553
- }
3554
- // worker.js lives in the same directory as daemon.js (src/)
3555
- const workerPath = join(__dirname, '..', 'worker.js');
3556
- const t = tag(ds);
3557
- ds.localProcessAttestation = undefined;
3558
- ds.workerReady = false;
4770
+ // R4-B1: the frozen steer authorization rides on the CliTurnPayload (computed
4771
+ // once by the daemon at admission, COPIED here). A bare-string promptInput or a
4772
+ // system/recovery opening carries no flag ⇒ forced serial. Never re-inferred.
4773
+ const initCodexAppSteerable = typeof promptInput === 'object'
4774
+ && promptInput !== null
4775
+ && promptInput.codexAppSteerable === true;
3559
4776
  let resume = false;
3560
4777
  let initTurnId;
3561
4778
  let initDispatchAttempt;
3562
4779
  let restartAttemptId;
4780
+ let initCodexAppInputGateFrozen = promptInput === ds.session.queuedActivationInput;
4781
+ let initAtMostOnce;
3563
4782
  if (typeof resumeOrTurnId === 'string') {
3564
4783
  initTurnId = resumeOrTurnId;
3565
4784
  }
@@ -3568,406 +4787,728 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
3568
4787
  initTurnId = resumeOrTurnId.turnId;
3569
4788
  initDispatchAttempt = resumeOrTurnId.dispatchAttempt;
3570
4789
  restartAttemptId = resumeOrTurnId.restartAttemptId;
4790
+ initCodexAppInputGateFrozen ||= resumeOrTurnId.codexAppInputGateFrozen === true;
4791
+ initAtMostOnce = resumeOrTurnId.atMostOnce;
3571
4792
  }
3572
4793
  else {
3573
4794
  resume = resumeOrTurnId;
3574
4795
  }
3575
- // Per-turn authority is never inferred from mutable session state. Human
3576
- // message routes pass their accepted Lark message id explicitly; restore,
3577
- // scheduler, card retry and other system starts stay unattributed. Falling
3578
- // back to currentReplyTarget here would let a later system prompt reuse an
3579
- // older human turn after a worker replacement.
3580
- const initAttributionTurnId = initTurnId;
3581
- // A fork() whose cwd no longer exists emits an unhandled 'error' (spawn
3582
- // ENOENT) that crashes the WHOLE daemon ( pm2 crash-loop). Fall back to
3583
- // home so a stale session workingDir can never take the daemon down.
3584
- const rawCwd = cb.getSessionWorkingDir(ds);
3585
- const cwd = rawCwd && existsSync(rawCwd) ? rawCwd : homedir();
3586
- if (cwd !== rawCwd)
3587
- logger.warn(`[${t}] workingDir "${rawCwd}" does not exist — falling back to ${cwd}`);
3588
- // Materialise the resolved launch dir on the live session. getSessionWorkingDir()
3589
- // falls back to the bot-default workingDir, but the usage ledger and dashboard read
3590
- // `ds.workingDir ?? s.workingDir` RAW (without that fallback). A session that inherits
3591
- // the bot-default workingDir — i.e. one never pinned via /repo or /cd — therefore leaves
3592
- // ds.workingDir undefined, so getSessionTokenUsage() is handed cwd=undefined, cannot
3593
- // locate the CLI transcript, and the session's token usage silently never records.
3594
- // Pinning the resolved cwd here (it equals what the worker actually forked into) closes
3595
- // that gap without touching the persisted session.workingDir "unset = follow default"
3596
- // semantics: this is re-derived on every fork/restore.
3597
- ds.workingDir = cwd;
3598
- // Also persist the effective launch dir onto the SESSION record so a sibling
3599
- // bot @-ed into the same anchor can inherit it (inherit-peer reads the
3600
- // persisted session.workingDir cross-process, even across daemons). Without
3601
- // this, a session running on the bot-default/fallback dir leaves
3602
- // session.workingDir empty and is invisible to cross-bot same-dir inheritance.
3603
- // Only FILL IN a missing workingDir (default/fallback-spawned sessions) — never
3604
- // overwrite an already-pinned value (oncall/repo-card sessions keep their stored
3605
- // form). Persist only a genuinely-resolved dir, never the homedir() crash-fallback
3606
- // (cwd !== rawCwd → a transiently-missing dir can't pin to ~). Also exclude a
3607
- // LEGITIMATELY-resolved homedir: a bot whose workingDir is unset/`~` resolves to
3608
- // $HOME, and pinning that would let a sibling bot inherit $HOME (launch in the home
3609
- // dir with no repo context) instead of getting its own repo card. Compared via
3610
- // realpath so a symlinked/aliased $HOME is excluded too, not just the literal string.
3611
- if (!ds.session.workingDir && cwd === rawCwd && !resolvesToHome(cwd)) {
3612
- ds.session.workingDir = cwd;
3613
- sessionStore.updateSession(ds.session);
3614
- }
3615
- // Sandbox decision is RECORDED ON THE SESSION at creation and reused on
3616
- // restore — so toggling the live bot flag never retroactively (un)sandboxes a
3617
- // historical session. A brand-new session (resume=false) with no recorded
3618
- // decision adopts the live bot flag; a restore (resume=true) with no recorded
3619
- // decision predates the sandbox feature → stays NOT sandboxed.
3620
- if (ds.session.sandbox === undefined) {
3621
- if (!resume) {
3622
- ds.session.sandbox = botCfg.sandbox === true;
3623
- ds.session.sandboxPaths = botCfg.sandboxPaths;
3624
- ds.session.sandboxHidePaths = botCfg.sandboxHidePaths ?? [];
3625
- ds.session.sandboxReadonlyPaths = botCfg.sandboxReadonlyPaths ?? [];
3626
- ds.session.sandboxNetwork = botCfg.sandboxNetwork !== false;
4796
+ if (ds.session.queuedActivationPending && ds.session.queuedActivationResume !== undefined) {
4797
+ resume = ds.session.queuedActivationResume;
4798
+ }
4799
+ // Central double-fork guard. A live worker with durable ownership must never
4800
+ // be killed and replaced: empty reforks are rejected, while a real follow-up
4801
+ // is appended through the existing worker's ordinary durable FIFO. Keep this
4802
+ // before queued/cwd/sandbox mutations so a rejected refork is side-effect free.
4803
+ if (ds.worker && !ds.worker.killed
4804
+ && hasProtectedSessionMutationOwnership(ds)) {
4805
+ if (prompt.length === 0) {
4806
+ logger.warn(`[${tag(ds)}] Refused empty worker refork while durable activation ownership is non-empty`);
4807
+ return true;
3627
4808
  }
3628
- else {
3629
- ds.session.sandbox = false;
3630
- ds.session.sandboxHidePaths = [];
3631
- ds.session.sandboxReadonlyPaths = [];
3632
- ds.session.sandboxNetwork = true;
4809
+ if (hasQueuedActivationAdmissionGate(ds)) {
4810
+ const turnId = initTurnId
4811
+ ?? ds.currentReplyTarget?.turnId
4812
+ ?? `queued-activation-followup-${randomUUID()}`;
4813
+ admitQueuedActivationTail(ds, {
4814
+ userPrompt: prompt,
4815
+ cliInput: {
4816
+ content: prompt,
4817
+ ...(promptPayload.codexAppInput
4818
+ ? { codexAppInput: promptPayload.codexAppInput }
4819
+ : {}),
4820
+ // R5-B1-2: preserve the frozen steer authorization on the double-fork
4821
+ // staged tail entry (only `=== true`).
4822
+ ...(initCodexAppSteerable ? { codexAppSteerable: true } : {}),
4823
+ },
4824
+ turnId,
4825
+ ...(initDispatchAttempt !== undefined
4826
+ ? { dispatchAttempt: initDispatchAttempt }
4827
+ : {}),
4828
+ });
4829
+ logger.info(`[${tag(ds)}] Staged double-fork prompt behind queued activation ACK `
4830
+ + `(turn=${turnId})`);
4831
+ return true;
3633
4832
  }
3634
- sessionStore.updateSession(ds.session);
4833
+ const routed = sendWorkerInput(ds, promptPayload, initTurnId, {
4834
+ ...(initDispatchAttempt !== undefined ? { dispatchAttempt: initDispatchAttempt } : {}),
4835
+ // R6-B3: sendWorkerInput reads steer authorization from OPTS (not the
4836
+ // payload), so the direct-route double-fork (protected ownership, no
4837
+ // activation gate — e.g. only an unsettled ledger) must pass it through
4838
+ // opts too. The gate branch above already admits with the flag; this
4839
+ // sibling branch would otherwise silently downgrade true → false.
4840
+ ...(initCodexAppSteerable ? { codexAppSteerable: true } : {}),
4841
+ });
4842
+ logger[routed ? 'info' : 'warn'](`[${tag(ds)}] ${routed ? 'Routed' : 'Failed to route'} double-fork prompt through existing durable owner`);
4843
+ return true;
3635
4844
  }
3636
- // Reserve and durably publish the replacement lifetime before killing an
3637
- // existing worker. A failed reservation leaves the old worker untouched;
3638
- // a successful reservation immediately invalidates any late old-worker ACK.
3639
- const workerGeneration = reserveWorkerGeneration(ds);
3640
- // Guard against double-fork: if a worker is already running, kill it first
3641
- if (ds.worker && !ds.worker.killed) {
3642
- const replacedWorker = ds.worker;
3643
- logger.warn(`[${t}] Worker already running (pid: ${replacedWorker.pid}), killing before re-fork`);
3644
- trackLifecycleRetirement(ds, replacedWorker);
4845
+ const cb = requireCallbacks();
4846
+ const bot = getBot(ds.larkAppId);
4847
+ const botCfg = bot.config;
4848
+ // A bare /repo placeholder (and a non-Codex empty group-join setup) owns no
4849
+ // model turn. Starting its CLI with an empty prompt must not mint a queued
4850
+ // activation token: the worker has nothing to submit and could never ACK it.
4851
+ // Real raw openings and clean Codex sidecars remain tokened.
4852
+ if (ds.session.queued
4853
+ && ds.session.pendingRepoSetup
4854
+ && prompt.length === 0
4855
+ && !promptPayload.codexAppInput
4856
+ && !ds.pendingRawInput
4857
+ && (ds.session.queuedActivationTail?.length ?? 0) === 0) {
4858
+ const priorJournal = snapshotQueuedActivationJournal(ds.session);
4859
+ ds.session.queued = false;
4860
+ clearQueuedActivationJournal(ds.session);
3645
4861
  try {
3646
- replacedWorker.send({ type: 'close' });
4862
+ sessionStore.updateSession(ds.session);
3647
4863
  }
3648
- catch { /* ignore */ }
3649
- try {
3650
- replacedWorker.kill();
4864
+ catch (err) {
4865
+ ds.session.queued = true;
4866
+ restoreQueuedActivationJournal(ds.session, priorJournal);
4867
+ throw err;
3651
4868
  }
3652
- catch { /* ignore */ }
3653
- ds.worker = null;
3654
- ds.workerReady = false;
3655
- ds.workerPort = null;
3656
- ds.workerToken = null;
3657
- ds.workerViewToken = null;
3658
- ds.managedTurnOrigin = undefined;
3659
- }
3660
- // Re-establishing a worker ends the cold-resume-suspended state: clear the
3661
- // persisted marker so a future restart no longer treats this session's
3662
- // backing as a deliberate-missing (a genuine later zombie must still close).
3663
- if (ds.session.suspendedColdResume) {
3664
- ds.session.suspendedColdResume = undefined;
3665
- sessionStore.updateSession(ds.session);
3666
4869
  }
3667
- // Re-establishing a worker also reclaims any crash-diagnostic shell a prior
3668
- // worker left parked but couldn't clean (hard-killed while parked, daemon
3669
- // still alive → next message reforks here). The fresh CLI spawns under the
3670
- // real bmx-<sid>; without this, bmx-diag-<sid> + its .ansi file would leak.
3671
- if (!ds.initConfig?.adoptMode && !ds.adoptedFrom)
3672
- reclaimParkedCrashDiagnostic(ds);
3673
- const agentCfg = sessionAgentConfig(ds, botCfg);
3674
- ensureCliEnv(agentCfg.cliId, agentCfg.cliPathOverride);
3675
- let nativeSessionTitle;
3676
- let nativeSessionTitlePrompt;
3677
- if (agentCfg.cliId === 'codex' && !ds.adoptedFrom) {
3678
- const isFreshNativeSession = !resume && !ds.session.cliSessionId;
3679
- const titlePrompt = extractBotmuxLarkNativeSessionTitlePrompt(promptPayload.codexAppInput?.text ?? prompt, bot.botName ? [{ name: bot.botName }] : undefined);
3680
- if (isFreshNativeSession && !ds.session.nativeSessionTitleUserDefined) {
3681
- ds.session.nativeSessionTitle = buildBotmuxLarkNativeSessionTitle(titlePrompt ? ds.session.title : undefined, bot.botName ? [{ name: bot.botName }] : undefined, ds.chatType === 'group' ? ds.session.chatDisplayName : undefined);
3682
- ds.session.nativeSessionTitleAwaitingContent = titlePrompt ? undefined : true;
3683
- nativeSessionTitlePrompt = titlePrompt;
4870
+ const queuedForkSnapshot = ds.session.queued
4871
+ ? {
4872
+ queued: true,
4873
+ queuedPrompt: ds.session.queuedPrompt,
4874
+ queuedCodexAppText: ds.session.queuedCodexAppText,
4875
+ queuedCodexAppMessageContext: ds.session.queuedCodexAppMessageContext,
4876
+ queuedActivationPending: ds.session.queuedActivationPending,
4877
+ queuedActivationToken: ds.session.queuedActivationToken,
4878
+ queuedActivationInput: ds.session.queuedActivationInput,
4879
+ queuedActivationTurnId: ds.session.queuedActivationTurnId,
4880
+ queuedActivationDispatchAttempt: ds.session.queuedActivationDispatchAttempt,
4881
+ queuedActivationResume: ds.session.queuedActivationResume,
4882
+ initialStartPending: ds.initialStartPending,
4883
+ }
4884
+ : undefined;
4885
+ let acceptedForkDispatch;
4886
+ let spawnedWorker;
4887
+ let worker;
4888
+ let startupState;
4889
+ let initMsg;
4890
+ let agentCfg;
4891
+ const t = tag(ds);
4892
+ ds.localProcessAttestation = undefined;
4893
+ try {
4894
+ // worker.js lives in the same directory as daemon.js (src/)
4895
+ const workerPath = join(__dirname, '..', 'worker.js');
4896
+ // Per-turn authority is never inferred from mutable session state. Human
4897
+ // message routes pass their accepted Lark message id explicitly; restore,
4898
+ // scheduler, card retry and other system starts stay unattributed. Falling
4899
+ // back to currentReplyTarget here would let a later system prompt reuse an
4900
+ // older human turn after a worker replacement.
4901
+ let initAttributionTurnId = initTurnId
4902
+ ?? (queuedForkSnapshot ? ds.session.pendingRepoSetup?.turnId : undefined);
4903
+ // Reply routing is frozen only from the same explicitly accepted turn. A
4904
+ // system prompt must not borrow an older mutable currentReplyTarget.
4905
+ const initReplyTurnId = initTurnId;
4906
+ const initReplyContext = prompt.length > 0
4907
+ ? frozenReplyContextForTurn(ds, initReplyTurnId)
4908
+ : undefined;
4909
+ // A fork() whose cwd no longer exists emits an unhandled 'error' (spawn
4910
+ // ENOENT) that crashes the WHOLE daemon (→ pm2 crash-loop). Fall back to
4911
+ // home so a stale session workingDir can never take the daemon down.
4912
+ const rawCwd = cb.getSessionWorkingDir(ds);
4913
+ const cwd = rawCwd && existsSync(rawCwd) ? rawCwd : homedir();
4914
+ if (cwd !== rawCwd)
4915
+ logger.warn(`[${t}] workingDir "${rawCwd}" does not exist — falling back to ${cwd}`);
4916
+ // Materialise the resolved launch dir on the live session. getSessionWorkingDir()
4917
+ // falls back to the bot-default workingDir, but the usage ledger and dashboard read
4918
+ // `ds.workingDir ?? s.workingDir` RAW (without that fallback). A session that inherits
4919
+ // the bot-default workingDir — i.e. one never pinned via /repo or /cd — therefore leaves
4920
+ // ds.workingDir undefined, so getSessionTokenUsage() is handed cwd=undefined, cannot
4921
+ // locate the CLI transcript, and the session's token usage silently never records.
4922
+ // Pinning the resolved cwd here (it equals what the worker actually forked into) closes
4923
+ // that gap without touching the persisted session.workingDir "unset = follow default"
4924
+ // semantics: this is re-derived on every fork/restore.
4925
+ ds.workingDir = cwd;
4926
+ // Also persist the effective launch dir onto the SESSION record so a sibling
4927
+ // bot @-ed into the same anchor can inherit it (inherit-peer reads the
4928
+ // persisted session.workingDir cross-process, even across daemons). Without
4929
+ // this, a session running on the bot-default/fallback dir leaves
4930
+ // session.workingDir empty and is invisible to cross-bot same-dir inheritance.
4931
+ // Only FILL IN a missing workingDir (default/fallback-spawned sessions) — never
4932
+ // overwrite an already-pinned value (oncall/repo-card sessions keep their stored
4933
+ // form). Persist only a genuinely-resolved dir, never the homedir() crash-fallback
4934
+ // (cwd !== rawCwd → a transiently-missing dir can't pin to ~). Also exclude a
4935
+ // LEGITIMATELY-resolved homedir: a bot whose workingDir is unset/`~` resolves to
4936
+ // $HOME, and pinning that would let a sibling bot inherit $HOME (launch in the home
4937
+ // dir with no repo context) instead of getting its own repo card. Compared via
4938
+ // realpath so a symlinked/aliased $HOME is excluded too, not just the literal string.
4939
+ if (!ds.session.workingDir && cwd === rawCwd && !resolvesToHome(cwd)) {
4940
+ ds.session.workingDir = cwd;
3684
4941
  sessionStore.updateSession(ds.session);
3685
4942
  }
3686
- else if (ds.session.nativeSessionTitleAwaitingContent
3687
- && !ds.session.nativeSessionTitleUserDefined
3688
- && titlePrompt) {
3689
- ds.session.nativeSessionTitle = buildBotmuxLarkNativeSessionTitle(titlePrompt);
3690
- ds.session.nativeSessionTitleAwaitingContent = undefined;
3691
- nativeSessionTitlePrompt = titlePrompt;
4943
+ // Sandbox decision is RECORDED ON THE SESSION at creation and reused on
4944
+ // restore — so toggling the live bot flag never retroactively (un)sandboxes a
4945
+ // historical session. A brand-new session (resume=false) with no recorded
4946
+ // decision adopts the live bot flag; a restore (resume=true) with no recorded
4947
+ // decision predates the sandbox feature → stays NOT sandboxed.
4948
+ if (ds.session.sandbox === undefined) {
4949
+ if (!resume) {
4950
+ ds.session.sandbox = botCfg.sandbox === true;
4951
+ ds.session.sandboxPaths = botCfg.sandboxPaths;
4952
+ ds.session.sandboxHidePaths = botCfg.sandboxHidePaths ?? [];
4953
+ ds.session.sandboxReadonlyPaths = botCfg.sandboxReadonlyPaths ?? [];
4954
+ ds.session.sandboxNetwork = botCfg.sandboxNetwork !== false;
4955
+ }
4956
+ else {
4957
+ ds.session.sandbox = false;
4958
+ ds.session.sandboxHidePaths = [];
4959
+ ds.session.sandboxReadonlyPaths = [];
4960
+ ds.session.sandboxNetwork = true;
4961
+ }
3692
4962
  sessionStore.updateSession(ds.session);
3693
4963
  }
3694
- else if (isFreshNativeSession && !ds.session.nativeSessionTitle) {
3695
- ds.session.nativeSessionTitle = ds.session.title;
4964
+ // Reserve and durably publish the replacement lifetime before killing an
4965
+ // existing worker. A failed reservation leaves the old worker untouched;
4966
+ // a successful reservation immediately invalidates any late old-worker ACK.
4967
+ const workerGeneration = reserveWorkerGeneration(ds);
4968
+ // Guard against double-fork: if a worker is already running, kill it first
4969
+ if (ds.worker && !ds.worker.killed) {
4970
+ const replacedWorker = ds.worker;
4971
+ logger.warn(`[${t}] Worker already running (pid: ${replacedWorker.pid}), killing before re-fork`);
4972
+ trackLifecycleRetirement(ds, replacedWorker);
4973
+ try {
4974
+ replacedWorker.send({ type: 'close' });
4975
+ }
4976
+ catch { /* ignore */ }
4977
+ try {
4978
+ replacedWorker.kill();
4979
+ }
4980
+ catch { /* ignore */ }
4981
+ ds.worker = null;
4982
+ ds.workerReady = false;
4983
+ ds.workerPort = null;
4984
+ ds.workerToken = null;
4985
+ ds.workerViewToken = null;
4986
+ ds.managedTurnOrigin = undefined;
4987
+ }
4988
+ // Re-establishing a worker ends the cold-resume-suspended state: clear the
4989
+ // persisted marker so a future restart no longer treats this session's
4990
+ // backing as a deliberate-missing (a genuine later zombie must still close).
4991
+ if (ds.session.suspendedColdResume) {
4992
+ ds.session.suspendedColdResume = undefined;
3696
4993
  sessionStore.updateSession(ds.session);
3697
4994
  }
3698
- if (isFreshNativeSession
3699
- || (resume && !!ds.session.cliSessionId && !!ds.session.nativeSessionTitle)
3700
- || (resume
3701
- && !!ds.session.nativeSessionTitleAwaitingContent
3702
- && !!ds.session.nativeSessionTitle)
3703
- || (!!nativeSessionTitlePrompt && !!ds.session.nativeSessionTitle)) {
3704
- nativeSessionTitle = ds.session.nativeSessionTitle;
3705
- }
3706
- }
3707
- // Claude Code blocks on the interactive folder-trust dialog the first time
3708
- // it runs in an untrusted workingDir; pre-accept it so the spawn doesn't hang.
3709
- // Seed CLI (Claude Code fork) has the same dialog — drive both off the
3710
- // adapter's claude-family fields, writing to each variant's own .claude.json
3711
- // (`~/.claude.json` for claude, `.claude-runtime/.claude.json` for seed).
3712
- const familyAdapter = createCliAdapterSync(agentCfg.cliId, agentCfg.cliPathOverride);
3713
- if (familyAdapter.claudeStateJsonPath)
3714
- ensureClaudeFolderTrust(cwd, familyAdapter.claudeStateJsonPath);
3715
- // Prepend the botmux wrapper bin dir to PATH so CLIs can call `botmux send` etc.
3716
- // Single source of truth (resolveBotmuxWrapperBinDir): core-only → dedicated
3717
- // `<SESSION_DATA_DIR>/bin` (not the shared ~/.botmux/bin), matching where the
3718
- // daemon WROTE the wrapper — else the worker PATH would miss it or a same-HOME
3719
- // fleet wrapper would shadow it (codex P1).
3720
- const botmuxBinDir = resolveBotmuxWrapperBinDir(process.env);
3721
- const pathWithBotmux = prependBotmuxBin(botmuxBinDir, process.env.PATH);
3722
- const forkEnv = workerForkEnv(process.env);
3723
- const worker = fork(workerPath, [], {
3724
- windowsHide: true,
3725
- stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
3726
- cwd,
3727
- env: {
3728
- ...forkEnv,
3729
- PATH: pathWithBotmux,
3730
- CLAUDECODE: undefined,
3731
- BOTMUX: '1', // Marker so user scripts/skills can detect a botmux-spawned CLI
3732
- SESSION_DATA_DIR: config.session.dataDir,
3733
- BOTMUX_SESSION_ID: ds.session.sessionId,
3734
- LARK_APP_ID: botCfg.larkAppId,
3735
- // Withhold the real secret from the worker's own CLI env for a no-transport
3736
- // session (apiOnly bot or HTTP virtual chat). SEPARATE leak from the
3737
- // init-message larkAppSecret the spawned CLI env carries it directly from
3738
- // botCfg. Empty it here too so no secret reaches the worker/sandbox.
3739
- LARK_APP_SECRET: larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }) ? botCfg.larkAppSecret : '',
3740
- },
3741
- });
3742
- const startupState = {
3743
- ready: false, failureNotified: false,
3744
- initTurnId: initAttributionTurnId,
3745
- initDispatchAttempt,
3746
- };
3747
- // A fork-level failure (spawn ENOENT, etc.) emits 'error'; without a handler
3748
- // the unhandled event crashes the daemon. It also happens before worker IPC
3749
- // exists, so this daemon-side branch must be the user-visible fallback.
3750
- worker.on('error', (err) => {
3751
- const reason = err?.message ?? String(err);
3752
- logger.error(`[${t}] Worker fork error: ${reason}`);
3753
- if (startupState.failureNotified)
3754
- return;
3755
- startupState.failureNotified = true;
3756
- emitSessionLifecycleHook(ds, 'session.requires_attention', {
3757
- reason: 'worker_fork_error',
3758
- message: reason,
4995
+ // Re-establishing a worker also reclaims any crash-diagnostic shell a prior
4996
+ // worker left parked but couldn't clean (hard-killed while parked, daemon
4997
+ // still alive → next message reforks here). The fresh CLI spawns under the
4998
+ // real bmx-<sid>; without this, bmx-diag-<sid> + its .ansi file would leak.
4999
+ if (!ds.initConfig?.adoptMode && !ds.adoptedFrom)
5000
+ reclaimParkedCrashDiagnostic(ds);
5001
+ agentCfg = sessionAgentConfig(ds, botCfg);
5002
+ if (!initTurnId && prompt.length > 0 && agentCfg.cliId === 'codex-app') {
5003
+ initAttributionTurnId = `codex-app-dispatch-${randomUUID()}`;
5004
+ }
5005
+ ensureCliEnv(agentCfg.cliId, agentCfg.cliPathOverride);
5006
+ let nativeSessionTitle;
5007
+ let nativeSessionTitlePrompt;
5008
+ if (agentCfg.cliId === 'codex' && !ds.adoptedFrom) {
5009
+ const isFreshNativeSession = !resume && !ds.session.cliSessionId;
5010
+ const titlePrompt = extractBotmuxLarkNativeSessionTitlePrompt(promptPayload.codexAppInput?.text ?? prompt, bot.botName ? [{ name: bot.botName }] : undefined);
5011
+ if (isFreshNativeSession && !ds.session.nativeSessionTitleUserDefined) {
5012
+ ds.session.nativeSessionTitle = buildBotmuxLarkNativeSessionTitle(titlePrompt ? ds.session.title : undefined, bot.botName ? [{ name: bot.botName }] : undefined, ds.chatType === 'group' ? ds.session.chatDisplayName : undefined);
5013
+ ds.session.nativeSessionTitleAwaitingContent = titlePrompt ? undefined : true;
5014
+ nativeSessionTitlePrompt = titlePrompt;
5015
+ sessionStore.updateSession(ds.session);
5016
+ }
5017
+ else if (ds.session.nativeSessionTitleAwaitingContent
5018
+ && !ds.session.nativeSessionTitleUserDefined
5019
+ && titlePrompt) {
5020
+ ds.session.nativeSessionTitle = buildBotmuxLarkNativeSessionTitle(titlePrompt);
5021
+ ds.session.nativeSessionTitleAwaitingContent = undefined;
5022
+ nativeSessionTitlePrompt = titlePrompt;
5023
+ sessionStore.updateSession(ds.session);
5024
+ }
5025
+ else if (isFreshNativeSession && !ds.session.nativeSessionTitle) {
5026
+ ds.session.nativeSessionTitle = ds.session.title;
5027
+ sessionStore.updateSession(ds.session);
5028
+ }
5029
+ if (isFreshNativeSession
5030
+ || (resume && !!ds.session.cliSessionId && !!ds.session.nativeSessionTitle)
5031
+ || (resume
5032
+ && !!ds.session.nativeSessionTitleAwaitingContent
5033
+ && !!ds.session.nativeSessionTitle)
5034
+ || (!!nativeSessionTitlePrompt && !!ds.session.nativeSessionTitle)) {
5035
+ nativeSessionTitle = ds.session.nativeSessionTitle;
5036
+ }
5037
+ }
5038
+ // Claude Code blocks on the interactive folder-trust dialog the first time
5039
+ // it runs in an untrusted workingDir; pre-accept it so the spawn doesn't hang.
5040
+ // Seed CLI (Claude Code fork) has the same dialog — drive both off the
5041
+ // adapter's claude-family fields, writing to each variant's own .claude.json
5042
+ // (`~/.claude.json` for claude, `.claude-runtime/.claude.json` for seed).
5043
+ const familyAdapter = createCliAdapterSync(agentCfg.cliId, agentCfg.cliPathOverride);
5044
+ if (familyAdapter.claudeStateJsonPath)
5045
+ ensureClaudeFolderTrust(cwd, familyAdapter.claudeStateJsonPath);
5046
+ const resolvedBackendType = resolvePairedSpawnBackendType(agentCfg.cliId, ds.session.backendType, botCfg.backendType, config.daemon.backendType);
5047
+ if (ds.session.cliId !== agentCfg.cliId || ds.session.backendType !== resolvedBackendType) {
5048
+ ds.session.cliId = agentCfg.cliId;
5049
+ ds.session.backendType = resolvedBackendType;
5050
+ sessionStore.updateSession(ds.session);
5051
+ }
5052
+ // Prepend the botmux wrapper bin dir to PATH so CLIs can call `botmux send` etc.
5053
+ // Single source of truth (resolveBotmuxWrapperBinDir): core-only → dedicated
5054
+ // `<SESSION_DATA_DIR>/bin` (not the shared ~/.botmux/bin), matching where the
5055
+ // daemon WROTE the wrapper — else the worker PATH would miss it or a same-HOME
5056
+ // fleet wrapper would shadow it (codex P1).
5057
+ const botmuxBinDir = resolveBotmuxWrapperBinDir(process.env);
5058
+ const pathWithBotmux = prependBotmuxBin(botmuxBinDir, process.env.PATH);
5059
+ const forkEnv = workerForkEnv(process.env);
5060
+ // Dequeue only after every earlier launch-preparation write has finished.
5061
+ // The exact input remains journaled until the worker confirms its adapter
5062
+ // submission boundary; daemon send/ready are intentionally too early.
5063
+ const recoveredActivationLedgerEntry = ds.session.queuedActivationPending
5064
+ ? [...(ds.session.codexAppDispatchLedger ?? [])]
5065
+ .reverse()
5066
+ .find(entry => entry.state === 'accepted' || entry.state === 'prepared')
5067
+ : undefined;
5068
+ const queuedActivationToken = queuedForkSnapshot
5069
+ ? randomUUID()
5070
+ : ds.session.queuedActivationPending
5071
+ ? (ds.session.queuedActivationToken
5072
+ ?? recoveredActivationLedgerEntry?.queuedActivationToken
5073
+ ?? randomUUID())
5074
+ : undefined;
5075
+ if (queuedForkSnapshot) {
5076
+ ds.session.queued = false;
5077
+ ds.session.queuedActivationPending = true;
5078
+ ds.session.queuedActivationToken = queuedActivationToken;
5079
+ ds.session.queuedActivationResume = resume;
5080
+ ds.initialStartPending = true;
5081
+ }
5082
+ else if (ds.session.queuedActivationPending && queuedActivationToken) {
5083
+ // Migrate journals written before the token/ACK protocol. The queued
5084
+ // activation is the newest unsettled FIFO entry; stamp the same token onto
5085
+ // both journal and entry before the recovery worker can submit it.
5086
+ let migrated = ds.session.queuedActivationToken !== queuedActivationToken;
5087
+ ds.session.queuedActivationToken = queuedActivationToken;
5088
+ ds.initialStartPending = true;
5089
+ if (recoveredActivationLedgerEntry
5090
+ && recoveredActivationLedgerEntry.queuedActivationToken !== queuedActivationToken) {
5091
+ recoveredActivationLedgerEntry.queuedActivationToken = queuedActivationToken;
5092
+ migrated = true;
5093
+ }
5094
+ if (migrated)
5095
+ sessionStore.updateSession(ds.session);
5096
+ }
5097
+ // Snapshot the prior durable FIFO before accepting this fork's new prompt.
5098
+ // A pure reattach receives the full snapshot; a refork carrying N+1 restores
5099
+ // old N first and reserves N+1 through the normal worker write path.
5100
+ const codexAppRecoveredDispatches = agentCfg.cliId === 'codex-app'
5101
+ ? (ds.session.codexAppDispatchLedger ?? []).map(entry => ({ ...entry }))
5102
+ : [];
5103
+ const promptCodexAppInput = initCodexAppInputGateFrozen
5104
+ ? cloneFrozenCodexAppInput(promptPayload.codexAppInput, initAttributionTurnId)
5105
+ : codexAppInputForSession(ds, promptPayload.codexAppInput, initAttributionTurnId);
5106
+ if (queuedForkSnapshot) {
5107
+ ds.session.queuedActivationInput = {
5108
+ content: prompt,
5109
+ ...(promptCodexAppInput ? { codexAppInput: promptCodexAppInput } : {}),
5110
+ // R4-B1: preserve the frozen steer authorization when re-parking N+1 behind
5111
+ // a recovery fork (COPY, never re-infer).
5112
+ ...(initCodexAppSteerable ? { codexAppSteerable: true } : {}),
5113
+ };
5114
+ ds.session.queuedActivationTurnId = initAttributionTurnId;
5115
+ ds.session.queuedActivationDispatchAttempt = initDispatchAttempt;
5116
+ }
5117
+ const initVcMeetingImTurnOrigin = resolveVcMeetingImTurnOrigin(ds.session, initTurnId ?? initReplyTurnId);
5118
+ const codexAppDispatchId = prompt.length > 0
5119
+ ? acceptCodexAppDispatch(ds, {
5120
+ content: prompt,
5121
+ ...(promptCodexAppInput ? { codexAppInput: promptCodexAppInput } : {}),
5122
+ ...(queuedActivationToken ? { queuedActivationToken } : {}),
5123
+ ...(initReplyTurnId ? { replyTurnId: initReplyTurnId } : {}),
5124
+ ...(initReplyContext ? {
5125
+ replyTarget: initReplyContext.target,
5126
+ ...(initReplyContext.quoteTargetId
5127
+ ? { quoteTargetId: initReplyContext.quoteTargetId }
5128
+ : {}),
5129
+ ...(initReplyContext.replyTargetSenderOpenId
5130
+ ? { replyTargetSenderOpenId: initReplyContext.replyTargetSenderOpenId }
5131
+ : {}),
5132
+ ...(initReplyContext.replyTargetSenderIsBot !== undefined
5133
+ ? { replyTargetSenderIsBot: initReplyContext.replyTargetSenderIsBot }
5134
+ : {}),
5135
+ } : {}),
5136
+ // R4-B1: COPY the frozen steer authorization onto the fork's
5137
+ // accept-ledger entry so a recovered/opening first turn is steerable
5138
+ // exactly as the daemon admitted it (admission computed once; COPIED).
5139
+ ...(initCodexAppSteerable ? { codexAppSteerable: true } : {}),
5140
+ }, initAttributionTurnId, initDispatchAttempt, initVcMeetingImTurnOrigin)
5141
+ : undefined;
5142
+ if (codexAppDispatchId && initAttributionTurnId) {
5143
+ acceptedForkDispatch = {
5144
+ dispatchId: codexAppDispatchId,
5145
+ turnId: initAttributionTurnId,
5146
+ ...(initDispatchAttempt !== undefined
5147
+ ? { dispatchAttempt: initDispatchAttempt }
5148
+ : {}),
5149
+ };
5150
+ }
5151
+ if (queuedForkSnapshot && !codexAppDispatchId) {
5152
+ sessionStore.updateSession(ds.session);
5153
+ }
5154
+ worker = fork(workerPath, [], {
5155
+ windowsHide: true,
5156
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
5157
+ execArgv: workerForkExecArgv(),
5158
+ cwd,
5159
+ env: {
5160
+ ...forkEnv,
5161
+ PATH: pathWithBotmux,
5162
+ CLAUDECODE: undefined,
5163
+ BOTMUX: '1', // Marker so user scripts/skills can detect a botmux-spawned CLI
5164
+ SESSION_DATA_DIR: config.session.dataDir,
5165
+ BOTMUX_SESSION_ID: ds.session.sessionId,
5166
+ LARK_APP_ID: botCfg.larkAppId,
5167
+ // Withhold the real secret from the worker's own CLI env for a no-transport
5168
+ // session (apiOnly bot or HTTP virtual chat). SEPARATE leak from the
5169
+ // init-message larkAppSecret — the spawned CLI env carries it directly from
5170
+ // botCfg. Empty it here too so no secret reaches the worker/sandbox.
5171
+ LARK_APP_SECRET: larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }) ? botCfg.larkAppSecret : '',
5172
+ },
3759
5173
  });
3760
- // A dedicated VC receiver and a silent schedule have no auxiliary Lark
3761
- // output channel. Keep lifecycle/dashboard state above, but never leak a
3762
- // fork diagnostic into the chat.
3763
- if (ds.session.vcMeetingReceiver || isSilentScheduledTurn(ds, initAttributionTurnId)) {
3764
- logger.info(`[${t}] Managed/silent fork failure kept out of auxiliary Lark UI `
3765
- + `turn=${initAttributionTurnId?.slice(0, 12) ?? '-'} attempt=${initDispatchAttempt}: ${reason}`);
3766
- return;
5174
+ spawnedWorker = worker;
5175
+ startupState = {
5176
+ ready: false, failureNotified: false,
5177
+ initTurnId: initAttributionTurnId,
5178
+ initDispatchAttempt,
5179
+ };
5180
+ // A fork-level failure (spawn ENOENT, etc.) emits 'error'; without a handler
5181
+ // the unhandled event crashes the daemon. It also happens before worker IPC
5182
+ // exists, so this daemon-side branch must be the user-visible fallback.
5183
+ worker.on('error', (err) => {
5184
+ const reason = err?.message ?? String(err);
5185
+ logger.error(`[${t}] Worker fork error: ${reason}`);
5186
+ if (ds.worker === worker) {
5187
+ if (ds.session.queuedActivationPending) {
5188
+ // The durable opening journal remains authoritative until the adapter
5189
+ // proves submission. A failed child must release only its runtime
5190
+ // claim so the next generation can replay that exact head.
5191
+ ds.initialStartPending = false;
5192
+ ds.initialStartClaimToken = undefined;
5193
+ }
5194
+ else {
5195
+ reparkQueuedActivationFollowUpTail(ds, 'worker error during activation follow-up handoff');
5196
+ }
5197
+ const retainExactRetirementGeneration = ds.riffShutdownState !== undefined
5198
+ || ds.riffCloseState !== undefined;
5199
+ if (!retainExactRetirementGeneration) {
5200
+ ds.worker = null;
5201
+ ds.workerPort = null;
5202
+ ds.workerToken = null;
5203
+ ds.workerViewToken = null;
5204
+ ds.managedTurnOrigin = undefined;
5205
+ ds.riffCloseState = undefined;
5206
+ }
5207
+ // The retirement coordinator owns a prepared generation. Keep the exact
5208
+ // ChildProcess pointer until exit so it can decide whether abort/commit
5209
+ // was durably verified.
5210
+ try {
5211
+ worker.kill();
5212
+ }
5213
+ catch { /* best-effort failed-child fence */ }
5214
+ }
5215
+ if (startupState.failureNotified)
5216
+ return;
5217
+ startupState.failureNotified = true;
5218
+ emitSessionLifecycleHook(ds, 'session.requires_attention', {
5219
+ reason: 'worker_fork_error',
5220
+ message: reason,
5221
+ });
5222
+ // A dedicated VC receiver and a silent schedule have no auxiliary Lark
5223
+ // output channel. Keep lifecycle/dashboard state above, but never leak a
5224
+ // fork diagnostic into the chat.
5225
+ if (ds.session.vcMeetingReceiver || isSilentScheduledTurn(ds, initAttributionTurnId)) {
5226
+ logger.info(`[${t}] Managed/silent fork failure kept out of auxiliary Lark UI `
5227
+ + `turn=${initAttributionTurnId?.slice(0, 12) ?? '-'} attempt=${initDispatchAttempt}: ${reason}`);
5228
+ return;
5229
+ }
5230
+ const cliName = sessionCliDisplayName(ds, botCfg);
5231
+ const message = tr('worker.start_failed', { cliName, reason }, botLocale(botCfg));
5232
+ void cb.sessionReply(sessionAnchorId(ds), message, 'text', ds.larkAppId, fallbackTurnId(ds, initAttributionTurnId), ds.session.vcMeetingReceiver
5233
+ ? { sourceSessionId: ds.session.sessionId }
5234
+ : undefined).catch(replyErr => logger.error(`[${t}] Failed to deliver worker fork error to Lark: ${replyErr}`));
5235
+ });
5236
+ // Pipe worker stdout/stderr to daemon logger.
5237
+ // Both go through logger.info → daemon.log (not error.log). Worker stderr
5238
+ // is NOT necessarily an error: CLI adapters (claude, codex, etc.) write
5239
+ // progress, version banners, deprecation warnings, etc. there. The line
5240
+ // is still visible (tagged `:err`) for triage. Real worker faults arrive
5241
+ // separately via the IPC `Worker error` branch and stay as logger.error.
5242
+ worker.stdout?.on('data', (data) => {
5243
+ for (const line of data.toString().split('\n')) {
5244
+ const trimmed = line.trim();
5245
+ if (trimmed)
5246
+ logger.info(`[${t}:out] ${trimmed}`);
5247
+ }
5248
+ });
5249
+ worker.stderr?.on('data', (data) => {
5250
+ for (const line of data.toString().split('\n')) {
5251
+ logWorkerStderr(t, line.trim());
5252
+ }
5253
+ });
5254
+ // Send init config — use per-bot settings
5255
+ const runtimeIdentity = runtimeBuildIdentity();
5256
+ initMsg = {
5257
+ type: 'init',
5258
+ sessionId: ds.session.sessionId,
5259
+ chatId: ds.chatId,
5260
+ chatType: ds.chatType,
5261
+ rootMessageId: sessionAnchorId(ds),
5262
+ workingDir: cwd,
5263
+ cliId: agentCfg.cliId,
5264
+ cliRuntime: agentCfg.cliRuntime,
5265
+ cliPathOverride: agentCfg.cliPathOverride,
5266
+ wrapperCli: agentCfg.wrapperCli,
5267
+ launchShell: botCfg.launchShell,
5268
+ model: agentCfg.model,
5269
+ reasoningEffort: agentCfg.reasoningEffort,
5270
+ disableCliBypass: botCfg.disableCliBypass === true,
5271
+ codexRpcInput: botCfg.codexRpcInput === true || config.codexRpcInputDefault,
5272
+ // Startup commands run on every fresh spawn (incl. resume) so session-only
5273
+ // settings like `/effort ultracode` are re-established. Adopt sessions are
5274
+ // observed, not driven — forkAdoptWorker intentionally omits this.
5275
+ startupCommands: botCfg.startupCommands,
5276
+ // Per-bot env (bots.json `env`) — injected into the CLI process only (e.g.
5277
+ // ANTHROPIC_BASE_URL/AUTH_TOKEN for a GLM/3rd-party bot). Adopt sessions are
5278
+ // observed, not driven, so forkAdoptWorker intentionally omits it.
5279
+ env: botCfg.env,
5280
+ // Use the decision recorded on the session (above), NOT the live bot flag, so
5281
+ // historical sessions never get retroactively sandboxed on restart.
5282
+ sandbox: ds.session.sandbox === true,
5283
+ sandboxPaths: ds.session.sandboxPaths ?? botCfg.sandboxPaths,
5284
+ sandboxHidePaths: ds.session.sandboxHidePaths ?? [],
5285
+ sandboxReadonlyPaths: ds.session.sandboxReadonlyPaths ?? [],
5286
+ sandboxNetwork: ds.session.sandboxNetwork !== false,
5287
+ // Per-bot local read isolation (enforced worker-side; the worker gates it).
5288
+ // Sibling data needs no app-id enumeration: per-bot dirs are denied wholesale
5289
+ // and per-bot session files by filename pattern (see buildV2DenyPaths).
5290
+ // HARD credential boundary for a no-transport session (apiOnly bot OR HTTP
5291
+ // virtual chat): force read isolation so the CLI physically cannot read the
5292
+ // full bots.json / sibling BOT_HOME / send-cred / lark-cli store — a model
5293
+ // that deletes/forges the ancestry marker or bypasses the CLI still cannot
5294
+ // build ANY (sibling) Lark client. The pid-marker gate is only friendly
5295
+ // early-reject; THIS is the fail-closed boundary. Reuses the existing unified
5296
+ // fs-policy (mac+Linux fail-closed); a backend that can't isolate locally
5297
+ // refuses to spawn rather than leak creds.
5298
+ readIsolation: botCfg.readIsolation === true
5299
+ || !larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }),
5300
+ readDenyExtraPaths: botCfg.readDenyExtraPaths ?? [],
5301
+ // Identifies THIS daemon lifetime. Stamped onto isolated panes so the worker
5302
+ // can tell a suspend→resume reattach (same boot id, still isolated) from a
5303
+ // stale pane surviving a daemon restart (different id → kill + cold-spawn).
5304
+ daemonBootId: DAEMON_BOOT_ID,
5305
+ // Freeze-once: an already-running session keeps the backend stamped at spawn
5306
+ // (ds.session.backendType) even if the bot's live `backendType` changed since —
5307
+ // otherwise a cold-resume/refork would re-derive from live config and strand
5308
+ // the real persistent pane (the stamp is written below; restore reads it via
5309
+ // getSessionPersistentBackendType). A brand-new session (no stamp) resolves
5310
+ // from live config, so a dashboard backend switch only affects NEW sessions.
5311
+ backendType: resolvePairedSpawnBackendType(agentCfg.cliId, ds.session.backendType, botCfg.backendType, config.daemon.backendType),
5312
+ // Shared Herdr is not derivable from sessionId: preserve the exact host +
5313
+ // managed-agent affinity across daemon/worker replacement.
5314
+ persistentBackendTarget: ds.session.persistentBackendTarget,
5315
+ backendConfig: botCfg.riff,
5316
+ riffParentTaskId: ds.session.riffParentTaskId,
5317
+ riffRepoDirs: ds.session.riffRepoDirs,
5318
+ deferredScheduleRun: ds.session.deferredScheduleRun,
5319
+ ...(nativeSessionTitle ? { nativeSessionTitle } : {}),
5320
+ ...(nativeSessionTitlePrompt ? { nativeSessionTitlePrompt } : {}),
5321
+ prompt,
5322
+ ...(promptCodexAppInput ? { promptCodexAppInput } : {}),
5323
+ ...(queuedActivationToken ? { queuedActivationToken } : {}),
5324
+ resume,
5325
+ // One-shot native fork intent (see Session.pendingForkSession). Only the
5326
+ // child's FIRST spawn resumes the SOURCE transcript (cliSessionId still
5327
+ // points at the parent's CLI id here) while forking forward into a new id;
5328
+ // the worker clears the marker + persists the child's own new id, so a
5329
+ // later refork resumes the child normally (pendingForkSession=false).
5330
+ forkSession: ds.session.pendingForkSession === true,
5331
+ cliSessionId: ds.session.cliSessionId,
5332
+ ownerOpenId: ds.ownerOpenId,
5333
+ webPort: ds.session.webPort,
5334
+ larkAppId: botCfg.larkAppId,
5335
+ // Freeze on the session transport capability: a no-transport session
5336
+ // (apiOnly bot OR HTTP virtual chat) must not even RECEIVE the real secret —
5337
+ // withholding it removes the capability rather than gating a tamperable flag
5338
+ // the sandboxed agent could flip. The worker then physically cannot dial
5339
+ // Feishu (uploader/cred-write are also skipped downstream on the same test).
5340
+ larkAppSecret: larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }) ? botCfg.larkAppSecret : '',
5341
+ apiOnly: botCfg.apiOnly,
5342
+ // Freeze the ACTUAL loaded bots-config path (getLoadedConfigPath) so a
5343
+ // no-transport worker's fs-policy denies it from a HOST-owned fact, not a
5344
+ // guess off BOTS_CONFIG env (which the agent could see/forge). When it lives
5345
+ // outside every botmux authority root, buildFsPolicy fails the spawn closed
5346
+ // rather than silently masking an arbitrary parent dir (codex P1). Omitted
5347
+ // from forkAdoptWorker below — its observe branch returns before fs-policy.
5348
+ loadedBotsConfigPath: getLoadedConfigPath(),
5349
+ brand: normalizeBrand(botCfg.brand),
5350
+ botName: bot.botName,
5351
+ botOpenId: bot.botOpenId,
5352
+ locale: botLocale(botCfg),
5353
+ turnId: initAttributionTurnId,
5354
+ ...(initReplyTurnId ? { replyTurnId: initReplyTurnId } : {}),
5355
+ dispatchAttempt: initDispatchAttempt,
5356
+ ...(codexAppDispatchId ? { codexAppDispatchId } : {}),
5357
+ // R4-B1: COPY the frozen steer authorization onto the first-turn init so the
5358
+ // worker can fold follow-ups into this opening turn (canSteer requires the
5359
+ // group root to be steerable). System/recovery openings carry no flag.
5360
+ ...(initCodexAppSteerable ? { codexAppSteerable: true } : {}),
5361
+ ...(codexAppRecoveredDispatches.length > 0
5362
+ ? { codexAppRecoveredDispatches }
5363
+ : {}),
5364
+ ...((ds.session.codexAppGenerationCommits?.length ?? 0) > 0
5365
+ ? { codexAppGenerationCommits: ds.session.codexAppGenerationCommits }
5366
+ : {}),
5367
+ // At-most-once (idempotency lease): ride the flag on the init message so the
5368
+ // worker tags the keyed init prompt no-replay (codex #776 round-7 #1).
5369
+ ...(initAtMostOnce ? { atMostOnce: true } : {}),
5370
+ vcMeetingImTurnOrigin: initVcMeetingImTurnOrigin,
5371
+ pluginBindings: botCfg.plugins,
5372
+ skillPolicy: botCfg.skills,
5373
+ ...(runtimeIdentity.status === 'known'
5374
+ ? { runnerBuildId: runtimeIdentity.id }
5375
+ : {}),
5376
+ ...(ds.session.runnerBuildId ? { persistedRunnerBuildId: ds.session.runnerBuildId } : {}),
5377
+ ...(restartAttemptId ? { restartAttemptId } : {}),
5378
+ };
5379
+ // A persisted port from an older ZMX implementation must never revive the
5380
+ // removed Web TUI or get forwarded as a preferred listen port. The worker
5381
+ // will signal lifecycle readiness with port=0 instead.
5382
+ if (!backendSupportsWebTerminal(initMsg.backendType)) {
5383
+ initMsg.webPort = undefined;
5384
+ if (ds.session.webPort !== undefined) {
5385
+ ds.session.webPort = undefined;
5386
+ sessionStore.updateSession(ds.session);
5387
+ }
3767
5388
  }
3768
- const cliName = sessionCliDisplayName(ds, botCfg);
3769
- const message = tr('worker.start_failed', { cliName, reason }, botLocale(botCfg));
3770
- void cb.sessionReply(sessionAnchorId(ds), message, 'text', ds.larkAppId, fallbackTurnId(ds, initAttributionTurnId), ds.session.vcMeetingReceiver
3771
- ? { sourceSessionId: ds.session.sessionId }
3772
- : undefined).catch(replyErr => logger.error(`[${t}] Failed to deliver worker fork error to Lark: ${replyErr}`));
3773
- });
3774
- // Pipe worker stdout/stderr to daemon logger.
3775
- // Both go through logger.info → daemon.log (not error.log). Worker stderr
3776
- // is NOT necessarily an error: CLI adapters (claude, codex, etc.) write
3777
- // progress, version banners, deprecation warnings, etc. there. The line
3778
- // is still visible (tagged `:err`) for triage. Real worker faults arrive
3779
- // separately via the IPC `Worker error` branch and stay as logger.error.
3780
- worker.stdout?.on('data', (data) => {
3781
- for (const line of data.toString().split('\n')) {
3782
- const trimmed = line.trim();
3783
- if (trimmed)
3784
- logger.info(`[${t}:out] ${trimmed}`);
5389
+ ds.initConfig = initMsg;
5390
+ // Stamp cliId on the persisted session so the dashboard can show a CLI badge
5391
+ // even after the session is closed. Do this before installing worker handlers:
5392
+ // a fast worker can emit `ready` immediately after init, and card rendering
5393
+ // must see the session-level CLI identity rather than the bot default.
5394
+ if (ds.session.cliId !== agentCfg.cliId) {
5395
+ ds.session.cliId = agentCfg.cliId;
5396
+ sessionStore.updateSession(ds.session);
5397
+ }
5398
+ // Stamp the resolved backend on the persisted session. Since PTY退役, the
5399
+ // worker no longer silently downgrades an unavailable backend (it hard-gates
5400
+ // instead), so the requested backend here IS the effective one for any
5401
+ // session that actually runs. Restore reads this back (see
5402
+ // getSessionPersistentBackendType) so an upgraded daemon doesn't re-derive a
5403
+ // session's backend from the now-always-tmux default and misclassify a legacy
5404
+ // PTY session as a tmux zombie.
5405
+ if (ds.session.backendType !== initMsg.backendType) {
5406
+ ds.session.backendType = initMsg.backendType;
5407
+ sessionStore.updateSession(ds.session);
5408
+ }
5409
+ // Use shared handler for IPC messages and exit
5410
+ setupWorkerHandlers(ds, worker, startupState, workerGeneration);
5411
+ ds.worker = worker;
5412
+ if (shouldTrackOrdinaryImDelivery(ds, initMsg)) {
5413
+ sendOrdinaryImDeliveryTracked(ds, initMsg);
5414
+ }
5415
+ else {
5416
+ worker.send(initMsg);
5417
+ }
5418
+ ds.spawnedAt = Date.now();
5419
+ // master: per-runtime-key CLI version (the init send already happened above via
5420
+ // the tracked-delivery if/else — do NOT re-send initMsg here).
5421
+ ds.cliVersion = getCurrentCliVersion(runtimeInstallationKey({
5422
+ cliId: agentCfg.cliId,
5423
+ cliRuntime: agentCfg.cliRuntime,
5424
+ cliPathOverride: agentCfg.cliPathOverride,
5425
+ }));
5426
+ // #597: init has been dispatched. A later child 'error' event is ambiguous
5427
+ // (init may already be executing), so only synchronous failures before this
5428
+ // point are safe to compensate; clear the pre-init compensation handle.
5429
+ spawnedWorker = undefined;
5430
+ }
5431
+ catch (err) {
5432
+ if (ds.worker === spawnedWorker) {
5433
+ ds.worker = null;
5434
+ ds.workerPort = null;
5435
+ ds.workerToken = null;
5436
+ ds.workerViewToken = null;
3785
5437
  }
3786
- });
3787
- worker.stderr?.on('data', (data) => {
3788
- for (const line of data.toString().split('\n')) {
3789
- logWorkerStderr(t, line.trim());
5438
+ if (spawnedWorker) {
5439
+ try {
5440
+ spawnedWorker.kill();
5441
+ }
5442
+ catch { /* best-effort pre-init child fence */ }
3790
5443
  }
3791
- });
3792
- // Send init config — use per-bot settings
3793
- const promptCodexAppInput = codexAppInputForSession(ds, promptPayload.codexAppInput, initAttributionTurnId);
3794
- const runtimeIdentity = runtimeBuildIdentity();
3795
- const initMsg = {
3796
- type: 'init',
3797
- sessionId: ds.session.sessionId,
3798
- chatId: ds.chatId,
3799
- chatType: ds.chatType,
3800
- rootMessageId: sessionAnchorId(ds),
3801
- workingDir: cwd,
3802
- cliId: agentCfg.cliId,
3803
- cliRuntime: agentCfg.cliRuntime,
3804
- cliPathOverride: agentCfg.cliPathOverride,
3805
- wrapperCli: agentCfg.wrapperCli,
3806
- launchShell: botCfg.launchShell,
3807
- model: agentCfg.model,
3808
- reasoningEffort: agentCfg.reasoningEffort,
3809
- disableCliBypass: botCfg.disableCliBypass === true,
3810
- codexRpcInput: botCfg.codexRpcInput === true || config.codexRpcInputDefault,
3811
- // Startup commands run on every fresh spawn (incl. resume) so session-only
3812
- // settings like `/effort ultracode` are re-established. Adopt sessions are
3813
- // observed, not driven — forkAdoptWorker intentionally omits this.
3814
- startupCommands: botCfg.startupCommands,
3815
- // Per-bot env (bots.json `env`) — injected into the CLI process only (e.g.
3816
- // ANTHROPIC_BASE_URL/AUTH_TOKEN for a GLM/3rd-party bot). Adopt sessions are
3817
- // observed, not driven, so forkAdoptWorker intentionally omits it.
3818
- env: botCfg.env,
3819
- // Use the decision recorded on the session (above), NOT the live bot flag, so
3820
- // historical sessions never get retroactively sandboxed on restart.
3821
- sandbox: ds.session.sandbox === true,
3822
- sandboxPaths: ds.session.sandboxPaths ?? botCfg.sandboxPaths,
3823
- sandboxHidePaths: ds.session.sandboxHidePaths ?? [],
3824
- sandboxReadonlyPaths: ds.session.sandboxReadonlyPaths ?? [],
3825
- sandboxNetwork: ds.session.sandboxNetwork !== false,
3826
- // Per-bot local read isolation (enforced worker-side; the worker gates it).
3827
- // Sibling data needs no app-id enumeration: per-bot dirs are denied wholesale
3828
- // and per-bot session files by filename pattern (see buildV2DenyPaths).
3829
- // HARD credential boundary for a no-transport session (apiOnly bot OR HTTP
3830
- // virtual chat): force read isolation so the CLI physically cannot read the
3831
- // full bots.json / sibling BOT_HOME / send-cred / lark-cli store — a model
3832
- // that deletes/forges the ancestry marker or bypasses the CLI still cannot
3833
- // build ANY (sibling) Lark client. The pid-marker gate is only friendly
3834
- // early-reject; THIS is the fail-closed boundary. Reuses the existing unified
3835
- // fs-policy (mac+Linux fail-closed); a backend that can't isolate locally
3836
- // refuses to spawn rather than leak creds.
3837
- readIsolation: botCfg.readIsolation === true
3838
- || !larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }),
3839
- readDenyExtraPaths: botCfg.readDenyExtraPaths ?? [],
3840
- // Identifies THIS daemon lifetime. Stamped onto isolated panes so the worker
3841
- // can tell a suspend→resume reattach (same boot id, still isolated) from a
3842
- // stale pane surviving a daemon restart (different id → kill + cold-spawn).
3843
- daemonBootId: DAEMON_BOOT_ID,
3844
- // Freeze-once: an already-running session keeps the backend stamped at spawn
3845
- // (ds.session.backendType) even if the bot's live `backendType` changed since —
3846
- // otherwise a cold-resume/refork would re-derive from live config and strand
3847
- // the real persistent pane (the stamp is written below; restore reads it via
3848
- // getSessionPersistentBackendType). A brand-new session (no stamp) resolves
3849
- // from live config, so a dashboard backend switch only affects NEW sessions.
3850
- backendType: resolvePairedSpawnBackendType(agentCfg.cliId, ds.session.backendType, botCfg.backendType, config.daemon.backendType),
3851
- // Shared Herdr is not derivable from sessionId: preserve the exact host +
3852
- // managed-agent affinity across daemon/worker replacement.
3853
- persistentBackendTarget: ds.session.persistentBackendTarget,
3854
- backendConfig: botCfg.riff,
3855
- riffParentTaskId: ds.session.riffParentTaskId,
3856
- riffRepoDirs: ds.session.riffRepoDirs,
3857
- deferredScheduleRun: ds.session.deferredScheduleRun,
3858
- ...(nativeSessionTitle ? { nativeSessionTitle } : {}),
3859
- ...(nativeSessionTitlePrompt ? { nativeSessionTitlePrompt } : {}),
3860
- prompt,
3861
- ...(promptCodexAppInput ? { promptCodexAppInput } : {}),
3862
- resume,
3863
- // One-shot native fork intent (see Session.pendingForkSession). Only the
3864
- // child's FIRST spawn resumes the SOURCE transcript (cliSessionId still
3865
- // points at the parent's CLI id here) while forking forward into a new id;
3866
- // the worker clears the marker + persists the child's own new id, so a
3867
- // later refork resumes the child normally (pendingForkSession=false).
3868
- forkSession: ds.session.pendingForkSession === true,
3869
- cliSessionId: ds.session.cliSessionId,
3870
- ownerOpenId: ds.ownerOpenId,
3871
- webPort: ds.session.webPort,
3872
- larkAppId: botCfg.larkAppId,
3873
- // Freeze on the session transport capability: a no-transport session
3874
- // (apiOnly bot OR HTTP virtual chat) must not even RECEIVE the real secret —
3875
- // withholding it removes the capability rather than gating a tamperable flag
3876
- // the sandboxed agent could flip. The worker then physically cannot dial
3877
- // Feishu (uploader/cred-write are also skipped downstream on the same test).
3878
- larkAppSecret: larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }) ? botCfg.larkAppSecret : '',
3879
- apiOnly: botCfg.apiOnly,
3880
- // Freeze the ACTUAL loaded bots-config path (getLoadedConfigPath) so a
3881
- // no-transport worker's fs-policy denies it from a HOST-owned fact, not a
3882
- // guess off BOTS_CONFIG env (which the agent could see/forge). When it lives
3883
- // outside every botmux authority root, buildFsPolicy fails the spawn closed
3884
- // rather than silently masking an arbitrary parent dir (codex P1). Omitted
3885
- // from forkAdoptWorker below — its observe branch returns before fs-policy.
3886
- loadedBotsConfigPath: getLoadedConfigPath(),
3887
- brand: normalizeBrand(botCfg.brand),
3888
- botName: bot.botName,
3889
- botOpenId: bot.botOpenId,
3890
- locale: botLocale(botCfg),
3891
- turnId: initAttributionTurnId,
3892
- dispatchAttempt: initDispatchAttempt,
3893
- vcMeetingImTurnOrigin: resolveVcMeetingImTurnOrigin(ds.session, initAttributionTurnId),
3894
- pluginBindings: botCfg.plugins,
3895
- skillPolicy: botCfg.skills,
3896
- ...(runtimeIdentity.status === 'known'
3897
- ? { runnerBuildId: runtimeIdentity.id }
3898
- : {}),
3899
- ...(ds.session.runnerBuildId ? { persistedRunnerBuildId: ds.session.runnerBuildId } : {}),
3900
- ...(restartAttemptId ? { restartAttemptId } : {}),
3901
- };
3902
- // A persisted port from an older ZMX implementation must never revive the
3903
- // removed Web TUI or get forwarded as a preferred listen port. The worker
3904
- // will signal lifecycle readiness with port=0 instead.
3905
- if (!backendSupportsWebTerminal(initMsg.backendType)) {
3906
- initMsg.webPort = undefined;
3907
- if (ds.session.webPort !== undefined) {
3908
- ds.session.webPort = undefined;
3909
- sessionStore.updateSession(ds.session);
5444
+ try {
5445
+ rollbackWorkerForkPreInit(ds, queuedForkSnapshot, acceptedForkDispatch);
3910
5446
  }
5447
+ catch (rollbackErr) {
5448
+ logger.error(`[${tag(ds)}] Worker pre-init failure could not durably restore queued/FIFO state: `
5449
+ + `${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`);
5450
+ throw new AggregateError([err, rollbackErr], `Worker pre-init failed and durable rollback failed for ${ds.session.sessionId}`);
5451
+ }
5452
+ throw err;
3911
5453
  }
3912
5454
  ds.initConfig = initMsg;
3913
- // Stamp cliId on the persisted session so the dashboard can show a CLI badge
3914
- // even after the session is closed. Do this before installing worker handlers:
3915
- // a fast worker can emit `ready` immediately after init, and card rendering
3916
- // must see the session-level CLI identity rather than the bot default.
3917
- if (ds.session.cliId !== agentCfg.cliId) {
3918
- ds.session.cliId = agentCfg.cliId;
3919
- sessionStore.updateSession(ds.session);
3920
- }
3921
- // Stamp the resolved backend on the persisted session. Since PTY退役, the
3922
- // worker no longer silently downgrades an unavailable backend (it hard-gates
3923
- // instead), so the requested backend here IS the effective one for any
3924
- // session that actually runs. Restore reads this back (see
3925
- // getSessionPersistentBackendType) so an upgraded daemon doesn't re-derive a
3926
- // session's backend from the now-always-tmux default and misclassify a legacy
3927
- // PTY session as a tmux zombie.
3928
- if (ds.session.backendType !== initMsg.backendType) {
3929
- ds.session.backendType = initMsg.backendType;
3930
- sessionStore.updateSession(ds.session);
3931
- }
3932
- // Use shared handler for IPC messages and exit
3933
- setupWorkerHandlers(ds, worker, startupState, workerGeneration);
3934
- ds.worker = worker;
3935
- if (shouldTrackOrdinaryImDelivery(ds, initMsg)) {
3936
- sendOrdinaryImDeliveryTracked(ds, initMsg);
5455
+ try {
5456
+ sessionStore.updateSessionPid(ds.session.sessionId, worker.pid ?? null);
3937
5457
  }
3938
- else {
3939
- worker.send(initMsg);
5458
+ catch (err) {
5459
+ // Init may already be executing. PID bookkeeping failure must not turn an
5460
+ // accepted activation into a retryable error or orphan the attached child.
5461
+ logger.error(`[${t}] Failed to persist attached worker pid: ${err instanceof Error ? err.message : String(err)}`);
3940
5462
  }
3941
- ds.spawnedAt = Date.now();
3942
- ds.cliVersion = getCurrentCliVersion(runtimeInstallationKey({
3943
- cliId: agentCfg.cliId,
3944
- cliRuntime: agentCfg.cliRuntime,
3945
- cliPathOverride: agentCfg.cliPathOverride,
3946
- }));
3947
- sessionStore.updateSessionPid(ds.session.sessionId, worker.pid ?? null);
3948
5463
  logger.info(`[${t}] Worker forked (pid: ${worker.pid}, active: ${cb.getActiveCount()})`);
3949
5464
  // Reset the exit-emit flag for the freshly spawned worker so a subsequent
3950
5465
  // exit publishes again (the previous lifecycle's flag would otherwise mask it).
3951
5466
  ds.exitEventEmitted = false;
3952
5467
  // Notify dashboard SSE subscribers a new session is live.
3953
- dashboardEventBus.publish({
3954
- type: 'session.spawned',
3955
- body: { session: composeRowFromActive(ds) },
3956
- });
3957
- cb.enforceLiveSessionCap?.();
3958
- emitSessionLifecycleHook(ds, 'session.start', {
3959
- reason: resume ? 'resume' : 'worker_spawn',
3960
- pid: worker.pid ?? null,
3961
- });
5468
+ try {
5469
+ dashboardEventBus.publish({
5470
+ type: 'session.spawned',
5471
+ body: { session: composeRowFromActive(ds) },
5472
+ });
5473
+ }
5474
+ catch (err) {
5475
+ logger.error(`[${t}] Failed to publish attached worker state: ${err instanceof Error ? err.message : String(err)}`);
5476
+ }
5477
+ try {
5478
+ cb.enforceLiveSessionCap?.();
5479
+ }
5480
+ catch (err) {
5481
+ logger.error(`[${t}] Failed to enforce live-session cap after attach: ${err instanceof Error ? err.message : String(err)}`);
5482
+ }
5483
+ try {
5484
+ emitSessionLifecycleHook(ds, 'session.start', {
5485
+ reason: resume ? 'resume' : 'worker_spawn',
5486
+ pid: worker.pid ?? null,
5487
+ });
5488
+ }
5489
+ catch (err) {
5490
+ logger.error(`[${t}] Failed to emit attached worker lifecycle hook: ${err instanceof Error ? err.message : String(err)}`);
5491
+ }
3962
5492
  // Usage ledger: fresh spawns anchor the baseline so pre-existing transcript
3963
5493
  // history is never billed. Restores reconcile instead — an in-flight turn
3964
5494
  // may have completed inside tmux while the daemon was down, and that work
3965
5495
  // was submitted by botmux (anchoring would swallow it).
3966
- if (resume)
3967
- reconcileUsageForDaemonSession(ds);
3968
- else
3969
- anchorUsageForDaemonSession(ds);
3970
- recordOwnershipForDaemonSession(ds);
5496
+ try {
5497
+ if (resume)
5498
+ reconcileUsageForDaemonSession(ds);
5499
+ else
5500
+ anchorUsageForDaemonSession(ds);
5501
+ }
5502
+ catch (err) {
5503
+ logger.error(`[${t}] Failed to initialize attached worker usage state: ${err instanceof Error ? err.message : String(err)}`);
5504
+ }
5505
+ try {
5506
+ recordOwnershipForDaemonSession(ds);
5507
+ }
5508
+ catch (err) {
5509
+ logger.error(`[${t}] Failed to record attached worker ownership: ${err instanceof Error ? err.message : String(err)}`);
5510
+ }
5511
+ return true;
3971
5512
  }
3972
5513
  // ─── Shared worker IPC handler ──────────────────────────────────────────────
3973
5514
  /**
@@ -4064,6 +5605,13 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
4064
5605
  // Codex badge before a role switch starts a non-Codex worker.
4065
5606
  ds.codexServiceTier = undefined;
4066
5607
  ds.pendingCodexTierCardRefresh = undefined;
5608
+ // Active runtime is likewise authority of this exact worker generation. The
5609
+ // new worker republishes it via active_runtime after re-reading the rollout;
5610
+ // clear it here so the window between respawn and that first observation
5611
+ // cannot leave a stale model/effort tail on the card.
5612
+ ds.activeModel = undefined;
5613
+ ds.activeReasoningEffort = undefined;
5614
+ ds.pendingActiveRuntimeCardRefresh = undefined;
4067
5615
  const handlerSession = ds.session;
4068
5616
  const handlerAnchor = sessionAnchorId(ds);
4069
5617
  const handlerLarkAppId = ds.larkAppId;
@@ -4266,6 +5814,67 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
4266
5814
  };
4267
5815
  break;
4268
5816
  }
5817
+ case 'queued_activation_submitted': {
5818
+ if (ds.worker !== worker || msg.sessionId !== ds.session.sessionId)
5819
+ break;
5820
+ if (!ds.session.queuedActivationPending
5821
+ || !ds.session.queuedActivationToken
5822
+ || ds.session.queuedActivationToken !== msg.activationToken) {
5823
+ logger.warn(`[${t}] Ignored stale queued activation ACK ${msg.activationToken.substring(0, 8)}`);
5824
+ break;
5825
+ }
5826
+ const releaseReservation = async (attempt) => {
5827
+ if (ds.worker !== worker || !ds.initialStartPending)
5828
+ return;
5829
+ try {
5830
+ if (cb.onQueuedActivationSubmitted) {
5831
+ const released = await cb.onQueuedActivationSubmitted(ds, msg.activationToken);
5832
+ if (released === false && ds.worker === worker && ds.initialStartPending) {
5833
+ const delayMs = Math.min(100 * (2 ** Math.min(attempt, 6)), 5_000);
5834
+ logger.warn(`[${t}] Queued activation follow-up was not accepted (attempt ${attempt + 1}); `
5835
+ + `retrying in ${delayMs}ms`);
5836
+ const timer = setTimeout(() => { void releaseReservation(attempt + 1); }, delayMs);
5837
+ timer.unref?.();
5838
+ }
5839
+ }
5840
+ else {
5841
+ ds.initialStartPending = false;
5842
+ ds.initialStartClaimToken = undefined;
5843
+ }
5844
+ }
5845
+ catch (err) {
5846
+ logger.error(`[${t}] Queued activation follow-up release failed unexpectedly; `
5847
+ + `delivery was not retried because acceptance is unknown: `
5848
+ + `${err instanceof Error ? err.message : String(err)}`);
5849
+ }
5850
+ };
5851
+ const journal = snapshotQueuedActivationJournal(ds.session);
5852
+ const persistActivationAck = async (attempt) => {
5853
+ if (ds.worker !== worker
5854
+ || !ds.session.queuedActivationPending
5855
+ || ds.session.queuedActivationToken !== msg.activationToken)
5856
+ return;
5857
+ clearQueuedActivationJournal(ds.session);
5858
+ try {
5859
+ sessionStore.updateSession(ds.session);
5860
+ }
5861
+ catch (err) {
5862
+ restoreQueuedActivationJournal(ds.session, journal);
5863
+ const delayMs = Math.min(100 * (2 ** Math.min(attempt, 6)), 5_000);
5864
+ logger.error(`[${t}] Failed to persist queued activation ACK; retained journal and `
5865
+ + `will retry in ${delayMs}ms: ${err instanceof Error ? err.message : String(err)}`);
5866
+ const timer = setTimeout(() => { void persistActivationAck(attempt + 1); }, delayMs);
5867
+ timer.unref?.();
5868
+ return;
5869
+ }
5870
+ // Crossing the adapter boundary means this session now owns CLI
5871
+ // history even if the worker dies before it can publish a session id.
5872
+ ds.hasHistory = true;
5873
+ await releaseReservation(0);
5874
+ };
5875
+ await persistActivationAck(0);
5876
+ break;
5877
+ }
4269
5878
  case 'ready': {
4270
5879
  if (!ownsLifecycleMutation()) {
4271
5880
  logger.warn(`[${t}] Ignored ready from stale/transferring worker generation`);
@@ -4468,6 +6077,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
4468
6077
  recallFrozenCards(ds);
4469
6078
  flushPendingLocalCliOpenReadinessPatch(ds);
4470
6079
  flushPendingRiffUrlPatch(ds);
6080
+ flushPendingActiveRuntimePatch(ds);
4471
6081
  flushPendingCodexServiceTierPatch(ds);
4472
6082
  // Fresh ready POST: if this turn is already `working` (e.g. relay
4473
6083
  // resume where the CLI kept running), arm here — same authorized arm
@@ -4478,9 +6088,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
4478
6088
  if (!ownsLifecycleMutation())
4479
6089
  break;
4480
6090
  if (err instanceof MessageWithdrawnError) {
4481
- logger.warn(`[${t}] Root message withdrawn, closing stale session`);
4482
- killWorker(ds);
4483
- cb.closeSession(ds);
6091
+ await closeWithdrawnSessionIfLedgerEmpty(ds, 'Root message withdrawn while creating worker-ready card');
4484
6092
  break;
4485
6093
  }
4486
6094
  logger.warn(`[${t}] Failed to send streaming card, falling back to static card: ${err}`);
@@ -4513,9 +6121,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
4513
6121
  if (!ownsLifecycleMutation())
4514
6122
  break;
4515
6123
  if (fallbackErr instanceof MessageWithdrawnError) {
4516
- logger.warn(`[${t}] Root message withdrawn, closing stale session`);
4517
- killWorker(ds);
4518
- cb.closeSession(ds);
6124
+ await closeWithdrawnSessionIfLedgerEmpty(ds, 'Root message withdrawn while creating fallback worker-ready card');
4519
6125
  break;
4520
6126
  }
4521
6127
  throw fallbackErr;
@@ -4556,10 +6162,15 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
4556
6162
  sendWorkerSessionInput(ds, {
4557
6163
  type: 'raw_input',
4558
6164
  content: rawInput,
4559
- ...(rawTurnId ? { turnId: rawTurnId } : {}),
6165
+ ...((ds.session.queuedActivationTurnId ?? rawTurnId)
6166
+ ? { turnId: ds.session.queuedActivationTurnId ?? rawTurnId }
6167
+ : {}),
4560
6168
  followUpContent: followUp?.cliInput,
4561
6169
  ...(followUp?.turnId ? { followUpTurnId: followUp.turnId } : {}),
4562
6170
  ...(followUpCodexAppInput ? { followUpCodexAppInput } : {}),
6171
+ ...(ds.session.queuedActivationToken
6172
+ ? { queuedActivationToken: ds.session.queuedActivationToken }
6173
+ : {}),
4563
6174
  });
4564
6175
  logger.info(`[${t}] Sent pending raw input after prompt_ready: ${rawInput.substring(0, 80)}${followUp ? ` (+follow-up ${followUp.cliInput.length} chars)` : ''}`);
4565
6176
  if (followUp)
@@ -4638,6 +6249,24 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
4638
6249
  sessionStore.updateSession(ds.session);
4639
6250
  break;
4640
6251
  }
6252
+ case 'active_runtime': {
6253
+ if (ds.worker !== worker
6254
+ || ds.workerGeneration !== workerGeneration
6255
+ || ds.session.workerGeneration !== workerGeneration) {
6256
+ logger.warn(`[${t}] Ignored active_runtime from stale worker generation`);
6257
+ break;
6258
+ }
6259
+ const model = msg.model?.trim() || undefined;
6260
+ const reasoningEffort = msg.reasoningEffort?.trim() || undefined;
6261
+ if (ds.activeModel === model
6262
+ && ds.activeReasoningEffort === reasoningEffort) {
6263
+ break;
6264
+ }
6265
+ ds.activeModel = model;
6266
+ ds.activeReasoningEffort = reasoningEffort;
6267
+ scheduleActiveRuntimePatch(ds);
6268
+ break;
6269
+ }
4641
6270
  case 'codex_service_tier': {
4642
6271
  if (ds.worker !== worker
4643
6272
  || ds.workerGeneration !== workerGeneration
@@ -4648,6 +6277,13 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
4648
6277
  ds.codexServiceTier = effectiveCliId === 'codex'
4649
6278
  ? (msg.snapshot ?? undefined)
4650
6279
  : undefined;
6280
+ // Model/effort now flow through the active_runtime channel (Codex
6281
+ // publishes them from every turn_context, same as TRAE), so this
6282
+ // handler is scoped to the ⚡ service-tier badge only and must NOT
6283
+ // also write activeModel/activeReasoningEffort — doing so would race
6284
+ // the active_runtime writer and, since thread_settings_applied is not
6285
+ // emitted in many sessions, could clobber the good value with a stale
6286
+ // one.
4651
6287
  scheduleCodexServiceTierPatch(ds);
4652
6288
  break;
4653
6289
  }
@@ -4783,6 +6419,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
4783
6419
  recallFrozenCards(ds);
4784
6420
  flushPendingLocalCliOpenReadinessPatch(ds);
4785
6421
  flushPendingRiffUrlPatch(ds);
6422
+ flushPendingActiveRuntimePatch(ds);
4786
6423
  flushPendingCodexServiceTierPatch(ds);
4787
6424
  // New-turn POST is the FIRST working screen_update of the turn —
4788
6425
  // the else (same-turn PATCH) branch never runs for it, so arm the
@@ -4790,18 +6427,17 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
4790
6427
  // the POSTING sentinel). syncUsageRefreshTimer re-checks state.
4791
6428
  syncUsageRefreshTimer(ds);
4792
6429
  })
4793
- .catch(err => {
6430
+ .catch(async (err) => {
4794
6431
  if (!ownsLifecycleMutation())
4795
6432
  return;
4796
6433
  if (err instanceof MessageWithdrawnError) {
4797
- logger.warn(`[${t}] Root message withdrawn, closing stale session`);
4798
- killWorker(ds);
4799
- cb.closeSession(ds);
6434
+ await closeWithdrawnSessionIfLedgerEmpty(ds, 'Root message withdrawn while creating streaming card');
4800
6435
  return;
4801
6436
  }
4802
6437
  logger.debug(`[${t}] Failed to create streaming card: ${err}`);
4803
6438
  ds.streamCardId = undefined;
4804
6439
  clearPendingLocalCliOpenReadinessPatch(ds);
6440
+ ds.pendingActiveRuntimeCardRefresh = undefined;
4805
6441
  ds.pendingCodexTierCardRefresh = undefined;
4806
6442
  persistStreamCardState(ds);
4807
6443
  });
@@ -5202,6 +6838,30 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
5202
6838
  }
5203
6839
  break;
5204
6840
  }
6841
+ // Riff is a remote lineage-owning backend, not a local CLI process.
6842
+ // The worker intentionally refuses restart because tearing it down can
6843
+ // destroy or orphan the remote sandbox. Stop before crash-loop
6844
+ // accounting and tell the user how to recover, rather than logging an
6845
+ // "auto-restart" whose IPC is guaranteed to be a no-op.
6846
+ if (isRiffBackendSession(ds)) {
6847
+ const retirementPhase = riffRetirementAdmissionPhase(ds);
6848
+ logger.warn(`[${t}] Riff backend exited; automatic restart is unsupported`
6849
+ + (retirementPhase ? ` (${retirementPhase})` : ''));
6850
+ // Explicit /close and daemon shutdown already own the user-visible
6851
+ // lifecycle. Only an unexpected backend exit needs recovery guidance.
6852
+ if (!retirementPhase && !suppressExitUi) {
6853
+ try {
6854
+ await scopedReply(tr('cmd.restart.riff_unsupported', undefined, loc), 'text', undefined);
6855
+ }
6856
+ catch (replyErr) {
6857
+ if (replyErr instanceof MessageWithdrawnError) {
6858
+ logger.warn(`[${t}] Root message withdrawn, closing stale session`);
6859
+ cb.closeSession(ds);
6860
+ }
6861
+ }
6862
+ }
6863
+ break;
6864
+ }
5205
6865
  // Rate-limit auto-restart to prevent crash loops
5206
6866
  const key = ds.session.sessionId;
5207
6867
  const rc = restartCounts.get(key) ?? { count: 0, lastAt: 0 };
@@ -5266,8 +6926,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
5266
6926
  }
5267
6927
  catch (replyErr) {
5268
6928
  if (replyErr instanceof MessageWithdrawnError && ownsLifecycleMutation()) {
5269
- logger.warn(`[${t}] Root message withdrawn, closing stale session`);
5270
- cb.closeSession(ds);
6929
+ await closeWithdrawnSessionIfLedgerEmpty(ds, 'Root message withdrawn while sending crash diagnostic');
5271
6930
  }
5272
6931
  }
5273
6932
  }
@@ -5279,7 +6938,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
5279
6938
  if (ds.worker && !ds.worker.killed) {
5280
6939
  logger.info(`[${t}] Auto-restarting ${sessionCliDisplayName(ds, botCfg)}...`);
5281
6940
  ds.workerReady = false;
5282
- ds.worker.send({ type: 'restart', env: latestPerBotEnvForRestart(ds) });
6941
+ ds.worker.send({ type: 'restart', reason: 'cli_crash', env: latestPerBotEnvForRestart(ds) });
5283
6942
  }
5284
6943
  break;
5285
6944
  }
@@ -5317,8 +6976,15 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
5317
6976
  if (msg.taskId === null) {
5318
6977
  // follow-up 血缘断裂:清掉持久化锚点,否则 daemon 重启会复活已判坏的 parent。
5319
6978
  if (ds.session.riffParentTaskId) {
6979
+ const priorTaskId = ds.session.riffParentTaskId;
5320
6980
  ds.session.riffParentTaskId = undefined;
5321
- sessionStore.updateSession(ds.session);
6981
+ try {
6982
+ sessionStore.updateSession(ds.session);
6983
+ }
6984
+ catch (err) {
6985
+ ds.session.riffParentTaskId = priorTaskId;
6986
+ logger.error(`[${t}] Failed to clear Riff lineage: ${err instanceof Error ? err.message : String(err)}`);
6987
+ }
5322
6988
  }
5323
6989
  break;
5324
6990
  }
@@ -5329,7 +6995,32 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
5329
6995
  // next message continues the riff conversation in the warm sandbox
5330
6996
  // instead of cold-booting a context-less fresh task (4-5 min).
5331
6997
  ds.session.riffParentTaskId = msg.taskId;
5332
- sessionStore.updateSession(ds.session);
6998
+ try {
6999
+ sessionStore.updateSession(ds.session);
7000
+ }
7001
+ catch (err) {
7002
+ // Retain the newest runtime lineage. A close_result or later task-id
7003
+ // event retries persistence; reverting here would make a follow-up
7004
+ // target the stale parent while the worker owns the new child.
7005
+ logger.error(`[${t}] Failed to persist Riff lineage ${msg.taskId}: ${err instanceof Error ? err.message : String(err)}`);
7006
+ }
7007
+ break;
7008
+ }
7009
+ case 'close_result': {
7010
+ const pending = pendingRiffWorkerCloses.get(msg.requestId);
7011
+ if (!pending
7012
+ || pending.worker !== worker
7013
+ || pending.sessionId !== ds.session.sessionId
7014
+ || ds.worker !== worker) {
7015
+ logger.warn(`[${t}] Ignored stale/unmatched Riff close result ${msg.requestId}`);
7016
+ break;
7017
+ }
7018
+ pendingRiffWorkerCloses.delete(msg.requestId);
7019
+ pending.resolve({
7020
+ ok: msg.ok,
7021
+ ...(msg.taskId ? { taskId: msg.taskId } : {}),
7022
+ ...(msg.error ? { error: msg.error } : {}),
7023
+ });
5333
7024
  break;
5334
7025
  }
5335
7026
  case 'deferred_topic_materialized': {
@@ -5507,8 +7198,41 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
5507
7198
  logger.warn(`[${t}] Dropped managed_turn_origin with mismatched sessionId`);
5508
7199
  break;
5509
7200
  }
7201
+ // macOS uses one stable per-session pathname visible inside Seatbelt.
7202
+ // Only the daemon handler for the CURRENT ChildProcess generation may
7203
+ // replace it. Stale workers can still emit IPC, but the identity guard
7204
+ // above drops them before filesystem mutation, so they cannot overwrite
7205
+ // a successor capability (or unlink it during teardown).
7206
+ if (process.platform === 'darwin' && msg.originChannelId) {
7207
+ if (!/^[a-f0-9]{64}$/.test(msg.originChannelId)) {
7208
+ ds.managedTurnOrigin = undefined;
7209
+ logger.error(`[${t}] Refused managed origin publication with an invalid pane channel`);
7210
+ break;
7211
+ }
7212
+ try {
7213
+ const ipcPort = Number(process.env.BOTMUX_DAEMON_IPC_PORT);
7214
+ replaceManagedOriginCapabilityFile(managedOriginCapabilityPath(config.session.dataDir, msg.sessionId, msg.originChannelId), JSON.stringify({
7215
+ sessionId: msg.sessionId,
7216
+ channelId: msg.originChannelId,
7217
+ capability: msg.capability,
7218
+ ...(Number.isSafeInteger(ipcPort) && ipcPort > 0 && ipcPort <= 65_535
7219
+ ? { ipcPort }
7220
+ : {}),
7221
+ ...(msg.turnId ? { turnId: msg.turnId } : {}),
7222
+ ...(msg.dispatchAttempt !== undefined
7223
+ ? { dispatchAttempt: msg.dispatchAttempt }
7224
+ : {}),
7225
+ }));
7226
+ }
7227
+ catch (err) {
7228
+ ds.managedTurnOrigin = undefined;
7229
+ logger.error(`[${t}] Failed to publish daemon-owned managed origin capability: ${err instanceof Error ? err.message : String(err)}`);
7230
+ break;
7231
+ }
7232
+ }
5510
7233
  ds.managedTurnOrigin = {
5511
7234
  capability: msg.capability,
7235
+ ...(msg.originChannelId ? { originChannelId: msg.originChannelId } : {}),
5512
7236
  ...(msg.turnId ? { turnId: msg.turnId } : {}),
5513
7237
  ...(msg.dispatchAttempt !== undefined
5514
7238
  ? { dispatchAttempt: msg.dispatchAttempt }
@@ -5533,6 +7257,12 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
5533
7257
  logger.warn(`[${t}] Ignored stale managed turn origin revoke after capability rotation`);
5534
7258
  break;
5535
7259
  }
7260
+ if (msg.originChannelId
7261
+ && ds.managedTurnOrigin?.originChannelId
7262
+ && ds.managedTurnOrigin.originChannelId !== msg.originChannelId) {
7263
+ logger.warn(`[${t}] Ignored managed_turn_origin_revoked for a different pane channel`);
7264
+ break;
7265
+ }
5536
7266
  if (!msg.capability && ds.managedTurnOrigin
5537
7267
  && (ds.managedTurnOrigin.turnId !== msg.turnId
5538
7268
  || ds.managedTurnOrigin.dispatchAttempt !== msg.dispatchAttempt)) {
@@ -5542,11 +7272,252 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
5542
7272
  ds.managedTurnOrigin = undefined;
5543
7273
  break;
5544
7274
  }
7275
+ case 'codex_app_dispatch_transition': {
7276
+ const acknowledge = (ok, error) => {
7277
+ try {
7278
+ worker.send({
7279
+ type: 'codex_app_dispatch_persisted',
7280
+ requestId: msg.requestId,
7281
+ ok,
7282
+ ...(error ? { error } : {}),
7283
+ });
7284
+ }
7285
+ catch { /* worker exit makes the prepared/final state replayable */ }
7286
+ };
7287
+ if (ds.worker !== worker) {
7288
+ acknowledge(false, 'stale_worker_generation');
7289
+ break;
7290
+ }
7291
+ if (msg.sessionId !== ds.session.sessionId) {
7292
+ acknowledge(false, 'session_mismatch');
7293
+ break;
7294
+ }
7295
+ const ledger = ds.session.codexAppDispatchLedger ?? [];
7296
+ let next;
7297
+ if (msg.operation === 'submit') {
7298
+ if (msg.entries.length !== 1) {
7299
+ acknowledge(false, 'submit_requires_one_entry');
7300
+ break;
7301
+ }
7302
+ next = prepareCodexAppDispatch(ledger, msg.entries[0]);
7303
+ }
7304
+ else if (msg.operation === 'retry') {
7305
+ if (msg.entries.length !== 1) {
7306
+ acknowledge(false, 'retry_requires_one_entry');
7307
+ break;
7308
+ }
7309
+ next = retryPreparedCodexAppDispatch(ledger, msg.entries[0]);
7310
+ }
7311
+ else {
7312
+ if (msg.entries.length !== 1) {
7313
+ acknowledge(false, 'cancel_requires_one_entry');
7314
+ break;
7315
+ }
7316
+ next = cancelCodexAppDispatch(ledger, msg.entries[0]);
7317
+ }
7318
+ if (!next.ok) {
7319
+ acknowledge(false, next.error);
7320
+ break;
7321
+ }
7322
+ const priorLedger = ds.session.codexAppDispatchLedger;
7323
+ ds.session.codexAppDispatchLedger = next.ledger;
7324
+ let persisted = false;
7325
+ try {
7326
+ sessionStore.updateSession(ds.session);
7327
+ persisted = true;
7328
+ acknowledge(true);
7329
+ }
7330
+ catch (err) {
7331
+ ds.session.codexAppDispatchLedger = priorLedger;
7332
+ acknowledge(false, err?.message ?? 'session_store_write_failed');
7333
+ }
7334
+ if (persisted && !hasUnsettledCodexAppDispatch(ds.session.codexAppDispatchLedger)) {
7335
+ try {
7336
+ await cb.onCodexAppLedgerDrained?.(ds);
7337
+ }
7338
+ catch (err) {
7339
+ logger.error(`[${t}] post-drain cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
7340
+ }
7341
+ }
7342
+ break;
7343
+ }
7344
+ case 'codex_app_generation_active': {
7345
+ if (ds.worker !== worker || msg.sessionId !== ds.session.sessionId)
7346
+ break;
7347
+ if (!msg.fresh)
7348
+ break;
7349
+ const priorCommits = ds.session.codexAppGenerationCommits;
7350
+ ds.session.codexAppGenerationCommits = retainFreshCodexAppGeneration(ds.session.codexAppGenerationCommits ?? [], msg.generation);
7351
+ try {
7352
+ sessionStore.updateSession(ds.session);
7353
+ }
7354
+ catch (err) {
7355
+ ds.session.codexAppGenerationCommits = priorCommits;
7356
+ logger.error(`[${t}] Failed to persist fresh Codex App generation: ${err instanceof Error ? err.message : String(err)}`);
7357
+ }
7358
+ break;
7359
+ }
5545
7360
  case 'session_close_ready': {
5546
7361
  resolveCloseFence(msg.sessionId, workerGeneration);
5547
7362
  break;
5548
7363
  }
5549
7364
  case 'final_output': {
7365
+ if (msg.codexAppSettlement) {
7366
+ const settlement = msg.codexAppSettlement;
7367
+ const acknowledge = (ok, error) => {
7368
+ try {
7369
+ worker.send({
7370
+ type: 'codex_app_dispatch_persisted',
7371
+ requestId: settlement.requestId,
7372
+ ok,
7373
+ ...(error ? { error } : {}),
7374
+ });
7375
+ }
7376
+ catch { /* runner keeps the final unacknowledged for replacement */ }
7377
+ };
7378
+ if (ds.worker !== worker) {
7379
+ acknowledge(false, 'stale_worker_generation');
7380
+ break;
7381
+ }
7382
+ if (msg.sessionId !== ds.session.sessionId) {
7383
+ acknowledge(false, 'session_mismatch');
7384
+ break;
7385
+ }
7386
+ if (committedCodexAppSequence(ds.session.codexAppGenerationCommits ?? [], settlement.generation, settlement.seq)) {
7387
+ acknowledge(true);
7388
+ if (!hasUnsettledCodexAppDispatch(ds.session.codexAppDispatchLedger)) {
7389
+ try {
7390
+ await cb.onCodexAppLedgerDrained?.(ds);
7391
+ }
7392
+ catch (err) {
7393
+ logger.error(`[${t}] post-drain cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
7394
+ }
7395
+ }
7396
+ break;
7397
+ }
7398
+ const identity = {
7399
+ dispatchId: settlement.dispatchId,
7400
+ turnId: msg.turnId,
7401
+ ...(msg.dispatchAttempt !== undefined
7402
+ ? { dispatchAttempt: msg.dispatchAttempt }
7403
+ : {}),
7404
+ };
7405
+ const preview = settleCodexAppDispatch(ds.session.codexAppDispatchLedger ?? [], ds.session.codexAppGenerationCommits ?? [], identity, settlement.generation, settlement.seq);
7406
+ if (!preview.ok) {
7407
+ acknowledge(false, preview.error);
7408
+ break;
7409
+ }
7410
+ // R4/R5-B4 defense-in-depth: a `steer_superseded` settlement silently
7411
+ // advances the FIFO without delivering, so it is ONLY legitimate for a
7412
+ // genuine plain-Lark steerable head that STILL has a successor. Reject
7413
+ // (ACK=false, no pop, no receiver completion, no mutation) BEFORE the
7414
+ // commit block below if the settled entry is not steerable, not an
7415
+ // explicit Lark sink, belongs to a VC / durable-receiver / special
7416
+ // channel, or is the SOLE remaining head. R5: `deliverySink` must be
7417
+ // exactly 'lark' — admission always writes both sink and steerable
7418
+ // together, so "steerable=true + sink missing" can only be a
7419
+ // mixed/corrupt/legacy ledger and must fail closed, not be treated as
7420
+ // safe Lark. And a lone forged superseded (no successor) must never
7421
+ // silently commit the only head — the real final would then find no
7422
+ // pending turn. preview.ledger is the post-settle remainder.
7423
+ if (msg.disposition === 'steer_superseded') {
7424
+ const entry = preview.settledEntry;
7425
+ const supersededHeadOk = entry.codexAppSteerable === true
7426
+ && entry.deliverySink === 'lark'
7427
+ && entry.vcMeetingImTurnOrigin === undefined
7428
+ && ds.session.vcMeetingReceiver === undefined
7429
+ && preview.ledger.length > 0;
7430
+ if (!supersededHeadOk) {
7431
+ logger.warn(`[${t}] Rejected steer_superseded for non-steerable/special/last head `
7432
+ + `(turn ${msg.turnId.substring(0, 8)}, sink=${entry.deliverySink ?? 'legacy'}, `
7433
+ + `steerable=${entry.codexAppSteerable === true}, successors=${preview.ledger.length})`);
7434
+ acknowledge(false, 'superseded_head_not_plain_lark_steerable_with_successor');
7435
+ break;
7436
+ }
7437
+ }
7438
+ const key = `${ds.session.sessionId}:${settlement.generation}:${settlement.seq}`;
7439
+ let inFlight = codexAppFinalSettlementInFlight.get(key);
7440
+ if (!inFlight) {
7441
+ inFlight = (async () => {
7442
+ const unavailableSinkFailClosed = codexAppDeliveryMustFailClosed(ds, preview.settledEntry);
7443
+ const deliverySuppressed = msg.suppressDelivery === true
7444
+ || managedFinalOutputSuppressed(msg.turnId, msg.dispatchAttempt)
7445
+ || unavailableSinkFailClosed;
7446
+ if (unavailableSinkFailClosed
7447
+ && preview.settledEntry.deliverySink !== 'suppressed'
7448
+ && msg.suppressDelivery !== true) {
7449
+ logger.warn(`[${t}] Codex App recovery suppressed unavailable `
7450
+ + `${preview.settledEntry.deliverySink ?? 'legacy non-Lark'} sink `
7451
+ + `(turn ${msg.turnId.substring(0, 8)})`);
7452
+ }
7453
+ const alreadyDelivered = ds.lastBridgeEmittedUuid === finalOutputDedupeKey(ds, msg);
7454
+ const owned = deliverySuppressed || !msg.content.trim() || alreadyDelivered
7455
+ ? true
7456
+ : await new Promise(resolve => {
7457
+ deliverFinalOutput(ds, msg, t, 0, resolve, () => ds.worker === worker
7458
+ && ds.session.sessionId === msg.sessionId, preview.settledEntry.replyTarget);
7459
+ });
7460
+ if (!owned)
7461
+ return false;
7462
+ // Re-read after the asynchronous external delivery. A concurrent
7463
+ // replacement may already have committed this signed sequence;
7464
+ // that is idempotent success, never a second FIFO pop.
7465
+ if (committedCodexAppSequence(ds.session.codexAppGenerationCommits ?? [], settlement.generation, settlement.seq))
7466
+ return true;
7467
+ const committed = settleCodexAppDispatch(ds.session.codexAppDispatchLedger ?? [], ds.session.codexAppGenerationCommits ?? [], identity, settlement.generation, settlement.seq);
7468
+ if (!committed.ok)
7469
+ return false;
7470
+ if (msg.dispatchAttempt !== undefined) {
7471
+ try {
7472
+ // Durable receivers must not depend on the worker surviving
7473
+ // the daemon ACK long enough to emit a later terminal IPC.
7474
+ // Persist the exact completed attempt first; the worker's
7475
+ // ordered duplicate terminal remains idempotent.
7476
+ await cb.onTurnTerminal?.(ds, {
7477
+ type: 'turn_terminal',
7478
+ sessionId: ds.session.sessionId,
7479
+ turnId: msg.turnId,
7480
+ dispatchAttempt: msg.dispatchAttempt,
7481
+ status: 'completed',
7482
+ }, { workerGeneration });
7483
+ }
7484
+ catch (err) {
7485
+ logger.error(`[${t}] Failed to persist Codex App settlement terminal: ${err instanceof Error ? err.message : String(err)}`);
7486
+ return false;
7487
+ }
7488
+ }
7489
+ const priorLedger = ds.session.codexAppDispatchLedger;
7490
+ const priorCommits = ds.session.codexAppGenerationCommits;
7491
+ ds.session.codexAppDispatchLedger = committed.ledger;
7492
+ ds.session.codexAppGenerationCommits = committed.commits;
7493
+ try {
7494
+ // One atomic sessions-file replacement owns both the exact FIFO
7495
+ // pop and cumulative runner ACK boundary. Only after this write
7496
+ // may the worker acknowledge final-end to the runner.
7497
+ sessionStore.updateSession(ds.session);
7498
+ return true;
7499
+ }
7500
+ catch (err) {
7501
+ ds.session.codexAppDispatchLedger = priorLedger;
7502
+ ds.session.codexAppGenerationCommits = priorCommits;
7503
+ logger.error(`[${t}] Failed to persist Codex App final settlement: ${err instanceof Error ? err.message : String(err)}`);
7504
+ return false;
7505
+ }
7506
+ })().finally(() => codexAppFinalSettlementInFlight.delete(key));
7507
+ codexAppFinalSettlementInFlight.set(key, inFlight);
7508
+ }
7509
+ const persisted = await inFlight;
7510
+ acknowledge(persisted, persisted ? undefined : 'final_settlement_failed');
7511
+ if (persisted && !hasUnsettledCodexAppDispatch(ds.session.codexAppDispatchLedger)) {
7512
+ try {
7513
+ await cb.onCodexAppLedgerDrained?.(ds);
7514
+ }
7515
+ catch (err) {
7516
+ logger.error(`[${t}] post-drain cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
7517
+ }
7518
+ }
7519
+ break;
7520
+ }
5550
7521
  // Adopt-bridge: worker harvested the assistant turn from Claude Code's
5551
7522
  // transcript JSONL and forwarded it to us. Dedup with a session-scoped
5552
7523
  // key so a re-drain can't re-send the same answer or cross-suppress
@@ -5586,7 +7557,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
5586
7557
  if (msg.turnId.startsWith('mlrp_turn_')) {
5587
7558
  markMessageListenerRunPreviewRunning(msg.turnId);
5588
7559
  }
5589
- deliverFinalOutput(ds, msg, t, 0, ownsLifecycleMutation);
7560
+ deliverFinalOutput(ds, msg, t, 0, undefined, ownsLifecycleMutation);
5590
7561
  break;
5591
7562
  }
5592
7563
  case 'adopt_preamble': {
@@ -5648,13 +7619,29 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
5648
7619
  // old worker's exit fires AFTER the new worker has been assigned.
5649
7620
  if (ds.worker === worker) {
5650
7621
  restartCoordinator.failSession(ds.session.sessionId);
7622
+ if (ds.session.queuedActivationPending) {
7623
+ // Journal ownership is backend-independent. The next worker replays
7624
+ // this exact head with queuedActivationResume before durable tail N+1.
7625
+ ds.initialStartPending = false;
7626
+ ds.initialStartClaimToken = undefined;
7627
+ }
7628
+ else {
7629
+ reparkQueuedActivationFollowUpTail(ds, 'worker exit during activation follow-up handoff');
7630
+ }
5651
7631
  ds.worker = null;
5652
7632
  ds.workerReady = false;
5653
7633
  ds.workerPort = null;
5654
7634
  // Dead worker generation — stop the periodic usage refresh immediately
5655
7635
  // instead of waiting a tick for it to self-clear on !workerHasInitialized.
5656
7636
  clearUsageRefreshTimer(ds);
7637
+ ds.workerToken = null;
7638
+ ds.workerViewToken = null;
5657
7639
  ds.managedTurnOrigin = undefined;
7640
+ if (ds.riffCloseState) {
7641
+ ds.riffCloseState = { ...ds.riffCloseState, phase: 'uncertain' };
7642
+ }
7643
+ // Do not clear riffShutdownState here. Only the shutdown coordinator can
7644
+ // release a generation after lineage persistence or admission restore.
5658
7645
  // This worker generation is gone. Invalidate any stuck-warning card it
5659
7646
  // posted so a late click cannot inject keys into a replacement worker.
5660
7647
  invalidateStuckWarning(ds, 'worker_exit');
@@ -5710,6 +7697,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
5710
7697
  }
5711
7698
  // ─── Bridge final-output delivery (with retry) ──────────────────────────────
5712
7699
  const FINAL_OUTPUT_RETRY_BACKOFF_MS = [0, 5000, 15000]; // immediate, +5s, +15s
7700
+ const codexAppFinalSettlementInFlight = new Map();
5713
7701
  function finalOutputDedupeKey(ds, msg) {
5714
7702
  return `${msg.sessionId ?? ds.session.sessionId}:${msg.lastUuid || msg.turnId}`;
5715
7703
  }
@@ -5784,14 +7772,16 @@ async function finishTurnReactions(ds) {
5784
7772
  }
5785
7773
  }
5786
7774
  }
5787
- /** Deliver a bridge `final_output` to Lark. The worker emits each turn
5788
- * exactly once (it pops the turn off its queue at emit time), so the
5789
- * daemon owns retries on transient failures. After 3 attempts we log
7775
+ /** Deliver a bridge `final_output` to Lark. The current worker generation pops
7776
+ * the turn at emit time, while replacement recovery may replay it with the
7777
+ * same provider key; the daemon owns bounded transient retries. After 3 attempts we log
5790
7778
  * and give up — the user's answer is lost; better than leaking memory
5791
7779
  * via an unbounded retry loop. */
5792
- function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, frozenUsage) {
5793
- if (!stillCurrent())
7780
+ function deliverFinalOutput(ds, msg, t, attempt, onComplete, isStillOwned = () => true, frozenReplyTarget, frozenUsage) {
7781
+ if (!isStillOwned()) {
7782
+ onComplete?.(false);
5794
7783
  return;
7784
+ }
5795
7785
  let cardUsage = frozenUsage;
5796
7786
  const managedReceiver = !!ds.session.vcMeetingReceiver;
5797
7787
  // Wait Mode / HTTP Sync Override:
@@ -5804,6 +7794,7 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
5804
7794
  waitPromise.resolve(msg.content);
5805
7795
  ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
5806
7796
  logger.info(`[${t}] Intercepted final_output for Wait Mode HTTP request (turn ${msg.turnId.substring(0, 8)})`);
7797
+ onComplete?.(true);
5807
7798
  return;
5808
7799
  }
5809
7800
  const asyncResult = managedReceiver ? undefined : ds.asyncTriggerResults?.get(msg.turnId);
@@ -5818,8 +7809,14 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
5818
7809
  // (with content + usage) after a daemon restart drops the in-memory Map.
5819
7810
  // Stamp the owning bot for cross-bot isolation.
5820
7811
  asyncTriggerStore.recordCompleted(ds.session.sessionId, msg.turnId, msg.content, completedAt, ds.larkAppId, msg.usage);
7812
+ // This idempotent async turn produced its terminal output — clear the
7813
+ // worker-exit convergence stamp so a later graceful exit of this generation
7814
+ // is not retro-failed (codex #776 round-6 finding #1).
7815
+ if (ds.idempotentAsyncTurn?.triggerId === msg.turnId)
7816
+ ds.idempotentAsyncTurn = undefined;
5821
7817
  ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
5822
7818
  logger.info(`[${t}] Captured final_output for Async HTTP request (turn ${msg.turnId.substring(0, 8)})`);
7819
+ onComplete?.(true);
5823
7820
  return;
5824
7821
  }
5825
7822
  const cb = requireCallbacks();
@@ -5828,13 +7825,17 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
5828
7825
  ? { ...opts, sourceSessionId: ds.session.sessionId }
5829
7826
  : opts);
5830
7827
  setTimeout(async () => {
5831
- if (!stillCurrent())
7828
+ if (!isStillOwned()) {
7829
+ logger.info(`[${t}] Bridge final_output abandoned — worker/session ownership changed`);
7830
+ onComplete?.(false);
5832
7831
  return;
7832
+ }
5833
7833
  // Guard: if the user closed the session (or it was torn down for any
5834
7834
  // other reason) between attempts, don't post a stale final answer to
5835
7835
  // a closed thread.
5836
7836
  if (ds.session.status === 'closed') {
5837
7837
  logger.info(`[${t}] Bridge final_output abandoned — session closed (turn ${msg.turnId.substring(0, 8)})`);
7838
+ onComplete?.(true);
5838
7839
  return;
5839
7840
  }
5840
7841
  try {
@@ -5846,11 +7847,15 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
5846
7847
  // (模型没显式 botmux send),默认 @ 回原评论人,仅首块加。
5847
7848
  const chunks = chunkCommentText(msg.content);
5848
7849
  for (let i = 0; i < chunks.length; i++) {
5849
- if (!stillCurrent())
7850
+ if (!isStillOwned()) {
7851
+ onComplete?.(false);
5850
7852
  return;
7853
+ }
5851
7854
  await replyToDocComment(ds.larkAppId, { fileToken: docTurn.fileToken, fileType: docTurn.fileType }, docTurn.commentId, chunks[i], i === 0 ? docTurn.replyToOpenId : undefined);
5852
- if (!stillCurrent())
7855
+ if (!isStillOwned()) {
7856
+ onComplete?.(false);
5853
7857
  return;
7858
+ }
5854
7859
  }
5855
7860
  // The user-visible reply is committed. Consume the route and dedupe
5856
7861
  // marker BEFORE best-effort reaction cleanup: a missing/expired
@@ -5866,6 +7871,7 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
5866
7871
  }
5867
7872
  }
5868
7873
  logger.info(`[${t}] doc-comment final_output → posted ${chunks.length} comment(s) on file=${docTurn.fileToken.slice(0, 12)} (turn ${msg.turnId.substring(0, 8)})`);
7874
+ onComplete?.(true);
5869
7875
  return;
5870
7876
  }
5871
7877
  // A doc-native session has a virtual `doc:<token>` chat anchor, not a
@@ -5876,6 +7882,7 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
5876
7882
  ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
5877
7883
  logger.error(`[${t}] Suppressed doc-native final_output without comment target ` +
5878
7884
  `(turn ${msg.turnId.substring(0, 8)})`);
7885
+ onComplete?.(true);
5879
7886
  return;
5880
7887
  }
5881
7888
  // Wrap the model's reply in the same card chrome `botmux send` uses
@@ -5906,6 +7913,7 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
5906
7913
  ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
5907
7914
  logger.warn(`[${t}] VC final_output lost current membership authority `
5908
7915
  + `(${managedDecision.errorCode}) turn=${msg.turnId.substring(0, 8)}`);
7916
+ onComplete?.(true);
5909
7917
  return;
5910
7918
  }
5911
7919
  const revalidateManagedSend = () => {
@@ -6019,10 +8027,23 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
6019
8027
  }, proposedOutput)
6020
8028
  : undefined;
6021
8029
  const preparedListenerReply = preparedImReply ?? preparedDeliveryReply;
8030
+ // Codex App final settlement is delivery-before-commit. If the daemon
8031
+ // dies after Lark accepts the reply but before the FIFO/sequence commit,
8032
+ // the replacement must retry with the same provider key. Keep the key
8033
+ // dispatch-stable (not generation/seq-stable), so the user-visible Lark
8034
+ // message is idempotent across crash reconciliation. The existing
8035
+ // outbound-hook contract remains unchanged; it is a separate best-effort
8036
+ // side effect and is not covered by the provider UUID.
8037
+ const codexAppSettlementReply = msg.codexAppSettlement
8038
+ ? {
8039
+ uuid: `ca_${msg.codexAppSettlement.dispatchId}`.slice(0, 50),
8040
+ }
8041
+ : undefined;
6022
8042
  if (preparedListenerReply?.kind === 'conflict') {
6023
8043
  ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
6024
8044
  logger.error(`[${t}] VC listener fallback suppressed (${preparedListenerReply.reason}) `
6025
8045
  + `turn=${msg.turnId.substring(0, 8)}: ${preparedListenerReply.detail}`);
8046
+ onComplete?.(true);
6026
8047
  return;
6027
8048
  }
6028
8049
  const canonicalOutput = preparedListenerReply?.canonicalOutput ?? proposedOutput;
@@ -6062,13 +8083,18 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
6062
8083
  ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
6063
8084
  logger.info(`[${t}] VC listener fallback replayed existing provider result `
6064
8085
  + `(turn ${msg.turnId.substring(0, 8)})`);
8086
+ onComplete?.(true);
6065
8087
  return;
6066
8088
  }
6067
8089
  // Always deliver the answer as a fresh message — never PATCH a card in
6068
8090
  // place. message.patch is silent (no Feishu notification / unread), which
6069
8091
  // used to swallow the answer; a brand-new message always pings.
6070
8092
  revalidateManagedSend();
6071
- const messageId = await scopedReply(canonicalOutput.content, canonicalOutput.msgType, msg.turnId, preparedListenerReply
8093
+ if (!isStillOwned()) {
8094
+ onComplete?.(false);
8095
+ return;
8096
+ }
8097
+ const deliveryReplyOptions = preparedListenerReply
6072
8098
  ? {
6073
8099
  uuid: preparedListenerReply.providerKey,
6074
8100
  quoteMessageId: canonicalOutput.quoteTargetId,
@@ -6084,9 +8110,14 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
6084
8110
  }
6085
8111
  : {}),
6086
8112
  }
6087
- : undefined);
6088
- if (!stillCurrent())
8113
+ : codexAppSettlementReply;
8114
+ const messageId = await scopedReply(canonicalOutput.content, canonicalOutput.msgType, msg.replyTurnId ?? msg.turnId, frozenReplyTarget && !managedReceiver
8115
+ ? { ...deliveryReplyOptions, replyTarget: frozenReplyTarget }
8116
+ : deliveryReplyOptions);
8117
+ if (!isStillOwned()) {
8118
+ onComplete?.(true);
6089
8119
  return;
8120
+ }
6090
8121
  recordPrimaryOutput(messageId);
6091
8122
  if (msg.turnId.startsWith('mlrp_turn_')) {
6092
8123
  markMessageListenerRunPreviewReplied(msg.turnId, {
@@ -6099,16 +8130,24 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
6099
8130
  }
6100
8131
  ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
6101
8132
  logger.info(`[${t}] Bridge final_output forwarded (turn ${msg.turnId.substring(0, 8)}, ${msg.content.length} chars, kind=${msg.kind ?? 'bridge'}, attempt ${attempt + 1})`);
8133
+ onComplete?.(true);
6102
8134
  }
6103
8135
  catch (err) {
6104
- if (!stillCurrent())
8136
+ if (!isStillOwned()) {
8137
+ onComplete?.(false);
6105
8138
  return;
8139
+ }
6106
8140
  if (err instanceof MessageWithdrawnError) {
6107
- // Root message gone no point retrying. Mark as emitted so any
6108
- // duplicate IPC is correctly deduped, and tear the session down.
6109
- ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
6110
- logger.warn(`[${t}] Root message withdrawn while forwarding final_output, closing session`);
6111
- cb.closeSession(ds);
8141
+ // Withdrawal is permanent for this target, but it is not explicit
8142
+ // abandon. Keep an unsettled FIFO owner recoverable; only ledger-empty
8143
+ // sessions may commit the dedupe marker and auto-close.
8144
+ if (await closeWithdrawnSessionIfLedgerEmpty(ds, 'Root message withdrawn while forwarding final_output')) {
8145
+ ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
8146
+ onComplete?.(true);
8147
+ }
8148
+ else {
8149
+ onComplete?.(false);
8150
+ }
6112
8151
  return;
6113
8152
  }
6114
8153
  const next = attempt + 1;
@@ -6122,10 +8161,11 @@ function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, froz
6122
8161
  }
6123
8162
  // Don't commit the dedup marker — leave room for any future
6124
8163
  // retransmit (e.g. daemon restart that re-fires the IPC).
8164
+ onComplete?.(false);
6125
8165
  return;
6126
8166
  }
6127
8167
  logger.warn(`[${t}] Bridge final_output attempt ${next} failed (${err.message}); retrying in ${FINAL_OUTPUT_RETRY_BACKOFF_MS[next]}ms`);
6128
- deliverFinalOutput(ds, msg, t, next, stillCurrent, cardUsage);
8168
+ deliverFinalOutput(ds, msg, t, next, onComplete, isStillOwned, frozenReplyTarget, cardUsage);
6129
8169
  }
6130
8170
  }, FINAL_OUTPUT_RETRY_BACKOFF_MS[attempt] ?? 0);
6131
8171
  }
@@ -6272,6 +8312,7 @@ export function forkAdoptWorker(ds, opts) {
6272
8312
  const worker = fork(workerPath, [], {
6273
8313
  windowsHide: true,
6274
8314
  stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
8315
+ execArgv: workerForkExecArgv(),
6275
8316
  cwd: adoptCwd,
6276
8317
  env: {
6277
8318
  ...forkEnv,
@@ -6694,6 +8735,11 @@ function cleanupPersistentBackendSessions(backendType, activeSessions_, runtimeS
6694
8735
  }
6695
8736
  catch { /* first run */ }
6696
8737
  const currentCliId = config.daemon.cliId;
8738
+ // Codex App sessions with an unsettled dispatch ledger must reconcile before
8739
+ // any CLI-change sweep tears their backing pane down.
8740
+ const unsettledNames = new Set(activeSessions_
8741
+ .filter(hasProtectedSessionMutationOwnership)
8742
+ .map(session => backend.sessionName(session.sessionId)));
6697
8743
  const belongsToBackend = (session) => session.backendType === backendType ||
6698
8744
  (session.backendType === undefined && backendType === 'tmux');
6699
8745
  const activeNames = new Set([...activeSessions_, ...runtimeSessionRows]
@@ -6822,6 +8868,10 @@ function cleanupPersistentBackendSessions(backendType, activeSessions_, runtimeS
6822
8868
  logger.info(`CLI_ID changed (${lastCliId} → ${currentCliId}), killing all ${backendType} sessions`);
6823
8869
  // Legacy per-topic hosts are still enumerable by bmx-* name.
6824
8870
  for (const name of backend.listBotmuxSessions()) {
8871
+ if (unsettledNames.has(name)) {
8872
+ logger.warn(`Preserving ${backendType} ${name}: unsettled Codex App dispatch must reconcile first`);
8873
+ continue;
8874
+ }
6825
8875
  // A dispatcher-created runtime session already runs the current CLI.
6826
8876
  // Never let the stale last-cli marker tear it down during restore.
6827
8877
  if (runtimeNames.has(name))