botmux 3.10.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (539) hide show
  1. package/dist/.runtime-build-id +1 -1
  2. package/dist/adapters/backend/herdr-backend.d.ts +4 -3
  3. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  4. package/dist/adapters/backend/herdr-backend.js +11 -8
  5. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  6. package/dist/adapters/backend/pty-backend.d.ts +1 -1
  7. package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
  8. package/dist/adapters/backend/pty-backend.js +4 -1
  9. package/dist/adapters/backend/pty-backend.js.map +1 -1
  10. package/dist/adapters/backend/riff-backend.d.ts +29 -5
  11. package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
  12. package/dist/adapters/backend/riff-backend.js +302 -31
  13. package/dist/adapters/backend/riff-backend.js.map +1 -1
  14. package/dist/adapters/backend/sandbox.d.ts +6 -0
  15. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  16. package/dist/adapters/backend/sandbox.js +7 -0
  17. package/dist/adapters/backend/sandbox.js.map +1 -1
  18. package/dist/adapters/backend/tmux-backend.d.ts +1 -1
  19. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  20. package/dist/adapters/backend/tmux-backend.js +4 -1
  21. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  22. package/dist/adapters/backend/tmux-pipe-backend.d.ts +1 -1
  23. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  24. package/dist/adapters/backend/tmux-pipe-backend.js +1 -1
  25. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  26. package/dist/adapters/backend/types.d.ts +37 -3
  27. package/dist/adapters/backend/types.d.ts.map +1 -1
  28. package/dist/adapters/backend/types.js.map +1 -1
  29. package/dist/adapters/backend/zellij-backend.d.ts +3 -3
  30. package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
  31. package/dist/adapters/backend/zellij-backend.js +9 -3
  32. package/dist/adapters/backend/zellij-backend.js.map +1 -1
  33. package/dist/adapters/backend/zellij-observe-backend.d.ts +3 -3
  34. package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -1
  35. package/dist/adapters/backend/zellij-observe-backend.js +12 -7
  36. package/dist/adapters/backend/zellij-observe-backend.js.map +1 -1
  37. package/dist/adapters/cli/codex-app.d.ts.map +1 -1
  38. package/dist/adapters/cli/codex-app.js +11 -2
  39. package/dist/adapters/cli/codex-app.js.map +1 -1
  40. package/dist/adapters/cli/read-isolation.d.ts +35 -2
  41. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  42. package/dist/adapters/cli/read-isolation.js +88 -7
  43. package/dist/adapters/cli/read-isolation.js.map +1 -1
  44. package/dist/adapters/cli/runner-input.d.ts +15 -14
  45. package/dist/adapters/cli/runner-input.d.ts.map +1 -1
  46. package/dist/adapters/cli/runner-input.js +64 -26
  47. package/dist/adapters/cli/runner-input.js.map +1 -1
  48. package/dist/adapters/cli/types.d.ts +20 -5
  49. package/dist/adapters/cli/types.d.ts.map +1 -1
  50. package/dist/bot-registry.d.ts +24 -0
  51. package/dist/bot-registry.d.ts.map +1 -1
  52. package/dist/bot-registry.js +110 -5
  53. package/dist/bot-registry.js.map +1 -1
  54. package/dist/cli/fleet-shutdown.d.ts +47 -0
  55. package/dist/cli/fleet-shutdown.d.ts.map +1 -0
  56. package/dist/cli/fleet-shutdown.js +387 -0
  57. package/dist/cli/fleet-shutdown.js.map +1 -0
  58. package/dist/cli/pm2-descriptor-guard.d.ts +19 -0
  59. package/dist/cli/pm2-descriptor-guard.d.ts.map +1 -0
  60. package/dist/cli/pm2-descriptor-guard.js +122 -0
  61. package/dist/cli/pm2-descriptor-guard.js.map +1 -0
  62. package/dist/cli/pm2-exact-start.d.ts +16 -0
  63. package/dist/cli/pm2-exact-start.d.ts.map +1 -0
  64. package/dist/cli/pm2-exact-start.js +63 -0
  65. package/dist/cli/pm2-exact-start.js.map +1 -0
  66. package/dist/cli/pm2-god-admission.d.ts +8 -0
  67. package/dist/cli/pm2-god-admission.d.ts.map +1 -0
  68. package/dist/cli/pm2-god-admission.js +25 -0
  69. package/dist/cli/pm2-god-admission.js.map +1 -0
  70. package/dist/cli/pm2-jlist.d.ts +13 -0
  71. package/dist/cli/pm2-jlist.d.ts.map +1 -0
  72. package/dist/cli/pm2-jlist.js +108 -0
  73. package/dist/cli/pm2-jlist.js.map +1 -0
  74. package/dist/cli/pm2-preflight.d.ts +10 -0
  75. package/dist/cli/pm2-preflight.d.ts.map +1 -0
  76. package/dist/cli/pm2-preflight.js +23 -0
  77. package/dist/cli/pm2-preflight.js.map +1 -0
  78. package/dist/cli/pm2-shutdown-capability.d.ts +27 -0
  79. package/dist/cli/pm2-shutdown-capability.d.ts.map +1 -0
  80. package/dist/cli/pm2-shutdown-capability.js +161 -0
  81. package/dist/cli/pm2-shutdown-capability.js.map +1 -0
  82. package/dist/cli/pm2-start-transaction.d.ts +55 -0
  83. package/dist/cli/pm2-start-transaction.d.ts.map +1 -0
  84. package/dist/cli/pm2-start-transaction.js +220 -0
  85. package/dist/cli/pm2-start-transaction.js.map +1 -0
  86. package/dist/cli/send-dispatch.d.ts +12 -0
  87. package/dist/cli/send-dispatch.d.ts.map +1 -1
  88. package/dist/cli/send-dispatch.js +31 -4
  89. package/dist/cli/send-dispatch.js.map +1 -1
  90. package/dist/cli/session-list-liveness.d.ts +1 -0
  91. package/dist/cli/session-list-liveness.d.ts.map +1 -1
  92. package/dist/cli/session-list-liveness.js +6 -0
  93. package/dist/cli/session-list-liveness.js.map +1 -1
  94. package/dist/cli/session-picker-layout.d.ts +26 -0
  95. package/dist/cli/session-picker-layout.d.ts.map +1 -0
  96. package/dist/cli/session-picker-layout.js +123 -0
  97. package/dist/cli/session-picker-layout.js.map +1 -0
  98. package/dist/cli/session-picker-viewport.d.ts +49 -0
  99. package/dist/cli/session-picker-viewport.d.ts.map +1 -0
  100. package/dist/cli/session-picker-viewport.js +46 -0
  101. package/dist/cli/session-picker-viewport.js.map +1 -0
  102. package/dist/cli/supervisor-shutdown-client.d.ts +31 -0
  103. package/dist/cli/supervisor-shutdown-client.d.ts.map +1 -0
  104. package/dist/cli/supervisor-shutdown-client.js +146 -0
  105. package/dist/cli/supervisor-shutdown-client.js.map +1 -0
  106. package/dist/cli/terminal-width.d.ts +48 -0
  107. package/dist/cli/terminal-width.d.ts.map +1 -0
  108. package/dist/cli/terminal-width.js +119 -0
  109. package/dist/cli/terminal-width.js.map +1 -0
  110. package/dist/cli/vc-agent.d.ts.map +1 -1
  111. package/dist/cli/vc-agent.js +1 -1
  112. package/dist/cli/vc-agent.js.map +1 -1
  113. package/dist/cli.d.ts.map +1 -1
  114. package/dist/cli.js +2269 -716
  115. package/dist/cli.js.map +1 -1
  116. package/dist/codex-app-runner.js +1630 -125
  117. package/dist/codex-app-runner.js.map +1 -1
  118. package/dist/core/ask-api.d.ts +8 -1
  119. package/dist/core/ask-api.d.ts.map +1 -1
  120. package/dist/core/ask-api.js +18 -0
  121. package/dist/core/ask-api.js.map +1 -1
  122. package/dist/core/ask-broker.d.ts +20 -0
  123. package/dist/core/ask-broker.d.ts.map +1 -1
  124. package/dist/core/ask-broker.js +467 -47
  125. package/dist/core/ask-broker.js.map +1 -1
  126. package/dist/core/ask-persist-store.d.ts +126 -0
  127. package/dist/core/ask-persist-store.d.ts.map +1 -0
  128. package/dist/core/ask-persist-store.js +188 -0
  129. package/dist/core/ask-persist-store.js.map +1 -0
  130. package/dist/core/ask-types.d.ts +80 -0
  131. package/dist/core/ask-types.d.ts.map +1 -1
  132. package/dist/core/ask-types.js +62 -0
  133. package/dist/core/ask-types.js.map +1 -1
  134. package/dist/core/cli-session-id-publisher.d.ts +20 -0
  135. package/dist/core/cli-session-id-publisher.d.ts.map +1 -0
  136. package/dist/core/cli-session-id-publisher.js +21 -0
  137. package/dist/core/cli-session-id-publisher.js.map +1 -0
  138. package/dist/core/command-handler.d.ts.map +1 -1
  139. package/dist/core/command-handler.js +427 -255
  140. package/dist/core/command-handler.js.map +1 -1
  141. package/dist/core/cost-calculator.d.ts +13 -0
  142. package/dist/core/cost-calculator.d.ts.map +1 -1
  143. package/dist/core/cost-calculator.js +385 -26
  144. package/dist/core/cost-calculator.js.map +1 -1
  145. package/dist/core/dashboard-ipc-server.d.ts +9 -0
  146. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  147. package/dist/core/dashboard-ipc-server.js +842 -288
  148. package/dist/core/dashboard-ipc-server.js.map +1 -1
  149. package/dist/core/explicit-session-backing-cleanup.d.ts +49 -0
  150. package/dist/core/explicit-session-backing-cleanup.d.ts.map +1 -0
  151. package/dist/core/explicit-session-backing-cleanup.js +76 -0
  152. package/dist/core/explicit-session-backing-cleanup.js.map +1 -0
  153. package/dist/core/idle-worker-sweeper.d.ts +20 -0
  154. package/dist/core/idle-worker-sweeper.d.ts.map +1 -1
  155. package/dist/core/idle-worker-sweeper.js +18 -0
  156. package/dist/core/idle-worker-sweeper.js.map +1 -1
  157. package/dist/core/inflight-input-tracker.d.ts +10 -0
  158. package/dist/core/inflight-input-tracker.d.ts.map +1 -1
  159. package/dist/core/inflight-input-tracker.js +7 -0
  160. package/dist/core/inflight-input-tracker.js.map +1 -1
  161. package/dist/core/managed-origin-attestation.d.ts +51 -0
  162. package/dist/core/managed-origin-attestation.d.ts.map +1 -0
  163. package/dist/core/managed-origin-attestation.js +208 -0
  164. package/dist/core/managed-origin-attestation.js.map +1 -0
  165. package/dist/core/managed-origin-capability.d.ts +62 -3
  166. package/dist/core/managed-origin-capability.d.ts.map +1 -1
  167. package/dist/core/managed-origin-capability.js +419 -9
  168. package/dist/core/managed-origin-capability.js.map +1 -1
  169. package/dist/core/pending-repo-journal.d.ts +6 -0
  170. package/dist/core/pending-repo-journal.d.ts.map +1 -0
  171. package/dist/core/pending-repo-journal.js +85 -0
  172. package/dist/core/pending-repo-journal.js.map +1 -0
  173. package/dist/core/persistent-backend.d.ts +6 -1
  174. package/dist/core/persistent-backend.d.ts.map +1 -1
  175. package/dist/core/persistent-backend.js +12 -6
  176. package/dist/core/persistent-backend.js.map +1 -1
  177. package/dist/core/process-start-identity.d.ts +3 -0
  178. package/dist/core/process-start-identity.d.ts.map +1 -0
  179. package/dist/core/process-start-identity.js +62 -0
  180. package/dist/core/process-start-identity.js.map +1 -0
  181. package/dist/core/raw-command-writer.d.ts +34 -0
  182. package/dist/core/raw-command-writer.d.ts.map +1 -0
  183. package/dist/core/raw-command-writer.js +48 -0
  184. package/dist/core/raw-command-writer.js.map +1 -0
  185. package/dist/core/reply-target.d.ts +85 -22
  186. package/dist/core/reply-target.d.ts.map +1 -1
  187. package/dist/core/reply-target.js +267 -32
  188. package/dist/core/reply-target.js.map +1 -1
  189. package/dist/core/resource-monitor/runtime.d.ts.map +1 -1
  190. package/dist/core/resource-monitor/runtime.js +4 -1
  191. package/dist/core/resource-monitor/runtime.js.map +1 -1
  192. package/dist/core/restart-report.d.ts +6 -1
  193. package/dist/core/restart-report.d.ts.map +1 -1
  194. package/dist/core/restart-report.js +22 -4
  195. package/dist/core/restart-report.js.map +1 -1
  196. package/dist/core/riff-shutdown-detach.d.ts +164 -0
  197. package/dist/core/riff-shutdown-detach.d.ts.map +1 -0
  198. package/dist/core/riff-shutdown-detach.js +787 -0
  199. package/dist/core/riff-shutdown-detach.js.map +1 -0
  200. package/dist/core/riff-worker-shutdown-readiness.d.ts +17 -0
  201. package/dist/core/riff-worker-shutdown-readiness.d.ts.map +1 -0
  202. package/dist/core/riff-worker-shutdown-readiness.js +21 -0
  203. package/dist/core/riff-worker-shutdown-readiness.js.map +1 -0
  204. package/dist/core/session-manager.d.ts +6 -6
  205. package/dist/core/session-manager.d.ts.map +1 -1
  206. package/dist/core/session-manager.js +1227 -594
  207. package/dist/core/session-manager.js.map +1 -1
  208. package/dist/core/session-marker.d.ts +1 -1
  209. package/dist/core/session-marker.d.ts.map +1 -1
  210. package/dist/core/session-marker.js +2 -2
  211. package/dist/core/session-marker.js.map +1 -1
  212. package/dist/core/session-mutation-guard.d.ts +17 -0
  213. package/dist/core/session-mutation-guard.d.ts.map +1 -0
  214. package/dist/core/session-mutation-guard.js +33 -0
  215. package/dist/core/session-mutation-guard.js.map +1 -0
  216. package/dist/core/shutdown-budgets.d.ts +27 -0
  217. package/dist/core/shutdown-budgets.d.ts.map +1 -0
  218. package/dist/core/shutdown-budgets.js +47 -0
  219. package/dist/core/shutdown-budgets.js.map +1 -0
  220. package/dist/core/supervisor-shutdown-ipc.d.ts +10 -0
  221. package/dist/core/supervisor-shutdown-ipc.d.ts.map +1 -0
  222. package/dist/core/supervisor-shutdown-ipc.js +10 -0
  223. package/dist/core/supervisor-shutdown-ipc.js.map +1 -0
  224. package/dist/core/supervisor-shutdown-protocol.d.ts +16 -0
  225. package/dist/core/supervisor-shutdown-protocol.d.ts.map +1 -0
  226. package/dist/core/supervisor-shutdown-protocol.js +16 -0
  227. package/dist/core/supervisor-shutdown-protocol.js.map +1 -0
  228. package/dist/core/trigger-final-suppression.d.ts +4 -0
  229. package/dist/core/trigger-final-suppression.d.ts.map +1 -1
  230. package/dist/core/trigger-final-suppression.js +6 -11
  231. package/dist/core/trigger-final-suppression.js.map +1 -1
  232. package/dist/core/trigger-session.d.ts +70 -0
  233. package/dist/core/trigger-session.d.ts.map +1 -1
  234. package/dist/core/trigger-session.js +923 -145
  235. package/dist/core/trigger-session.js.map +1 -1
  236. package/dist/core/types.d.ts +94 -1
  237. package/dist/core/types.d.ts.map +1 -1
  238. package/dist/core/types.js +9 -0
  239. package/dist/core/types.js.map +1 -1
  240. package/dist/core/worker-pool.d.ts +194 -13
  241. package/dist/core/worker-pool.d.ts.map +1 -1
  242. package/dist/core/worker-pool.js +2597 -547
  243. package/dist/core/worker-pool.js.map +1 -1
  244. package/dist/daemon.d.ts +76 -2
  245. package/dist/daemon.d.ts.map +1 -1
  246. package/dist/daemon.js +2255 -773
  247. package/dist/daemon.js.map +1 -1
  248. package/dist/dashboard/bot-payload.d.ts +20 -0
  249. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  250. package/dist/dashboard/bot-payload.js +24 -0
  251. package/dist/dashboard/bot-payload.js.map +1 -1
  252. package/dist/dashboard/feed-groups.d.ts +17 -0
  253. package/dist/dashboard/feed-groups.d.ts.map +1 -0
  254. package/dist/dashboard/feed-groups.js +75 -0
  255. package/dist/dashboard/feed-groups.js.map +1 -0
  256. package/dist/dashboard/session-card-model.d.ts +1 -1
  257. package/dist/dashboard/session-card-model.d.ts.map +1 -1
  258. package/dist/dashboard/session-card-model.js +8 -5
  259. package/dist/dashboard/session-card-model.js.map +1 -1
  260. package/dist/dashboard/web/bot-defaults-page.d.ts +4 -0
  261. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  262. package/dist/dashboard/web/bot-defaults-page.js +139 -46
  263. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  264. package/dist/dashboard/web/bot-defaults.d.ts +4 -0
  265. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  266. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  267. package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
  268. package/dist/dashboard/web/connectors-page.js +40 -3
  269. package/dist/dashboard/web/connectors-page.js.map +1 -1
  270. package/dist/dashboard/web/groups-page.d.ts.map +1 -1
  271. package/dist/dashboard/web/groups-page.js +95 -2
  272. package/dist/dashboard/web/groups-page.js.map +1 -1
  273. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  274. package/dist/dashboard/web/i18n.js +86 -40
  275. package/dist/dashboard/web/i18n.js.map +1 -1
  276. package/dist/dashboard/web/kanban-model.js +1 -1
  277. package/dist/dashboard/web/kanban-model.js.map +1 -1
  278. package/dist/dashboard/web/overview-page.d.ts.map +1 -1
  279. package/dist/dashboard/web/overview-page.js +3 -2
  280. package/dist/dashboard/web/overview-page.js.map +1 -1
  281. package/dist/dashboard/web/overview.d.ts +3 -0
  282. package/dist/dashboard/web/overview.d.ts.map +1 -1
  283. package/dist/dashboard/web/overview.js +2 -0
  284. package/dist/dashboard/web/overview.js.map +1 -1
  285. package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
  286. package/dist/dashboard/web/sessions-kanban.js +2 -0
  287. package/dist/dashboard/web/sessions-kanban.js.map +1 -1
  288. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  289. package/dist/dashboard/web/sessions-page.js +103 -2
  290. package/dist/dashboard/web/sessions-page.js.map +1 -1
  291. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  292. package/dist/dashboard/web/sessions.js +3 -2
  293. package/dist/dashboard/web/sessions.js.map +1 -1
  294. package/dist/dashboard/web/ui.d.ts +8 -0
  295. package/dist/dashboard/web/ui.d.ts.map +1 -1
  296. package/dist/dashboard/web/ui.js +15 -0
  297. package/dist/dashboard/web/ui.js.map +1 -1
  298. package/dist/dashboard-web/app.js +1 -1
  299. package/dist/dashboard-web/chunks/bot-defaults-page-GED4FEMS.js +10 -0
  300. package/dist/dashboard-web/chunks/chunk-6J5LS5AU.js +51 -0
  301. package/dist/dashboard-web/chunks/{chunk-ZU3UUKAR.js → chunk-HK7NIBJG.js} +1 -1
  302. package/dist/dashboard-web/chunks/{chunk-3UE7S77S.js → chunk-KRHWZEXU.js} +1 -1
  303. package/dist/dashboard-web/chunks/chunk-VBXJOJFH.js +1 -0
  304. package/dist/dashboard-web/chunks/connectors-page-EELM66G3.js +1 -0
  305. package/dist/dashboard-web/chunks/groups-page-WKUV6E42.js +8 -0
  306. package/dist/dashboard-web/chunks/{insights-page-T2ZX2RWL.js → insights-page-54UBPPOC.js} +1 -1
  307. package/dist/dashboard-web/chunks/{monitor-room-YNVBAGR6.js → monitor-room-PORHVKRH.js} +1 -1
  308. package/dist/dashboard-web/chunks/{monitoring-page-LHM55YWN.js → monitoring-page-XGA7AXNL.js} +1 -1
  309. package/dist/dashboard-web/chunks/{office-page-ATXVP7PW.js → office-page-AO3AFG45.js} +1 -1
  310. package/dist/dashboard-web/chunks/overview-page-F5BUTYR6.js +1 -0
  311. package/dist/dashboard-web/chunks/{roles-page-56VWM6CD.js → roles-page-M5GIASJU.js} +1 -1
  312. package/dist/dashboard-web/chunks/{schedules-page-S5K6U4EO.js → schedules-page-O232WQRR.js} +1 -1
  313. package/dist/dashboard-web/chunks/sessions-page-YFRPSTIL.js +6 -0
  314. package/dist/dashboard-web/chunks/{settings-page-QUZDQARR.js → settings-page-OJAZLZQT.js} +1 -1
  315. package/dist/dashboard-web/chunks/{skills-page-IFY7AEND.js → skills-page-EFZQPWV4.js} +1 -1
  316. package/dist/dashboard-web/chunks/{team-federation-page-C7FJQPFZ.js → team-federation-page-ZJVAP7HW.js} +1 -1
  317. package/dist/dashboard-web/chunks/{v3-page-2LMF3WWR.js → v3-page-GYWTRGO2.js} +1 -1
  318. package/dist/dashboard-web/chunks/{whiteboards-page-3XC6QRMY.js → whiteboards-page-MSDAG6P7.js} +1 -1
  319. package/dist/dashboard-web/style.css +255 -0
  320. package/dist/dashboard.js +172 -7
  321. package/dist/dashboard.js.map +1 -1
  322. package/dist/desktop/renderer/app.js +21 -0
  323. package/dist/desktop/renderer/app.js.map +1 -1
  324. package/dist/i18n/en.d.ts.map +1 -1
  325. package/dist/i18n/en.js +35 -9
  326. package/dist/i18n/en.js.map +1 -1
  327. package/dist/i18n/zh.d.ts.map +1 -1
  328. package/dist/i18n/zh.js +35 -9
  329. package/dist/i18n/zh.js.map +1 -1
  330. package/dist/im/lark/ask-card.d.ts +23 -0
  331. package/dist/im/lark/ask-card.d.ts.map +1 -1
  332. package/dist/im/lark/ask-card.js +94 -4
  333. package/dist/im/lark/ask-card.js.map +1 -1
  334. package/dist/im/lark/card-builder.d.ts +6 -1
  335. package/dist/im/lark/card-builder.d.ts.map +1 -1
  336. package/dist/im/lark/card-builder.js +112 -15
  337. package/dist/im/lark/card-builder.js.map +1 -1
  338. package/dist/im/lark/card-handler.d.ts +1 -1
  339. package/dist/im/lark/card-handler.d.ts.map +1 -1
  340. package/dist/im/lark/card-handler.js +496 -310
  341. package/dist/im/lark/card-handler.js.map +1 -1
  342. package/dist/im/lark/client.d.ts +7 -0
  343. package/dist/im/lark/client.d.ts.map +1 -1
  344. package/dist/im/lark/client.js +39 -26
  345. package/dist/im/lark/client.js.map +1 -1
  346. package/dist/im/lark/doc-comment.d.ts +7 -4
  347. package/dist/im/lark/doc-comment.d.ts.map +1 -1
  348. package/dist/im/lark/doc-comment.js +19 -6
  349. package/dist/im/lark/doc-comment.js.map +1 -1
  350. package/dist/im/lark/event-dispatcher.d.ts +16 -0
  351. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  352. package/dist/im/lark/event-dispatcher.js +88 -23
  353. package/dist/im/lark/event-dispatcher.js.map +1 -1
  354. package/dist/im/lark/forward-followup-content.d.ts.map +1 -1
  355. package/dist/im/lark/forward-followup-content.js +9 -1
  356. package/dist/im/lark/forward-followup-content.js.map +1 -1
  357. package/dist/im/lark/grant-command.d.ts.map +1 -1
  358. package/dist/im/lark/grant-command.js +7 -4
  359. package/dist/im/lark/grant-command.js.map +1 -1
  360. package/dist/im/lark/md-card.d.ts +11 -0
  361. package/dist/im/lark/md-card.d.ts.map +1 -1
  362. package/dist/im/lark/md-card.js +46 -0
  363. package/dist/im/lark/md-card.js.map +1 -1
  364. package/dist/im/lark/message-parser.d.ts +23 -2
  365. package/dist/im/lark/message-parser.d.ts.map +1 -1
  366. package/dist/im/lark/message-parser.js +58 -3
  367. package/dist/im/lark/message-parser.js.map +1 -1
  368. package/dist/im/lark/overview-card.d.ts.map +1 -1
  369. package/dist/im/lark/overview-card.js +2 -1
  370. package/dist/im/lark/overview-card.js.map +1 -1
  371. package/dist/services/async-trigger-state.d.ts +6 -1
  372. package/dist/services/async-trigger-state.d.ts.map +1 -1
  373. package/dist/services/async-trigger-state.js +21 -0
  374. package/dist/services/async-trigger-state.js.map +1 -1
  375. package/dist/services/async-trigger-store.d.ts +23 -1
  376. package/dist/services/async-trigger-store.d.ts.map +1 -1
  377. package/dist/services/async-trigger-store.js +96 -13
  378. package/dist/services/async-trigger-store.js.map +1 -1
  379. package/dist/services/bridge-fallback-gate.d.ts +40 -0
  380. package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
  381. package/dist/services/bridge-fallback-gate.js +124 -24
  382. package/dist/services/bridge-fallback-gate.js.map +1 -1
  383. package/dist/services/codex-app-runner-protocol.d.ts +8 -0
  384. package/dist/services/codex-app-runner-protocol.d.ts.map +1 -1
  385. package/dist/services/codex-app-runner-protocol.js +9 -2
  386. package/dist/services/codex-app-runner-protocol.js.map +1 -1
  387. package/dist/services/codex-bridge-queue.d.ts +1 -0
  388. package/dist/services/codex-bridge-queue.d.ts.map +1 -1
  389. package/dist/services/codex-bridge-queue.js +1 -0
  390. package/dist/services/codex-bridge-queue.js.map +1 -1
  391. package/dist/services/codex-service-tier.d.ts +2 -0
  392. package/dist/services/codex-service-tier.d.ts.map +1 -1
  393. package/dist/services/codex-service-tier.js +1 -0
  394. package/dist/services/codex-service-tier.js.map +1 -1
  395. package/dist/services/codex-transcript.d.ts +27 -0
  396. package/dist/services/codex-transcript.d.ts.map +1 -1
  397. package/dist/services/codex-transcript.js +340 -2
  398. package/dist/services/codex-transcript.js.map +1 -1
  399. package/dist/services/grant-policy.d.ts +3 -0
  400. package/dist/services/grant-policy.d.ts.map +1 -1
  401. package/dist/services/grant-policy.js +5 -0
  402. package/dist/services/grant-policy.js.map +1 -1
  403. package/dist/services/grant-prefs-store.d.ts +6 -3
  404. package/dist/services/grant-prefs-store.d.ts.map +1 -1
  405. package/dist/services/grant-prefs-store.js +42 -12
  406. package/dist/services/grant-prefs-store.js.map +1 -1
  407. package/dist/services/hook-runner.d.ts +20 -1
  408. package/dist/services/hook-runner.d.ts.map +1 -1
  409. package/dist/services/hook-runner.js +17 -13
  410. package/dist/services/hook-runner.js.map +1 -1
  411. package/dist/services/idempotency-store.d.ts +134 -0
  412. package/dist/services/idempotency-store.d.ts.map +1 -0
  413. package/dist/services/idempotency-store.js +299 -0
  414. package/dist/services/idempotency-store.js.map +1 -0
  415. package/dist/services/jsonl-cursor.d.ts +7 -3
  416. package/dist/services/jsonl-cursor.d.ts.map +1 -1
  417. package/dist/services/jsonl-cursor.js +55 -31
  418. package/dist/services/jsonl-cursor.js.map +1 -1
  419. package/dist/services/open-platform-rename.d.ts.map +1 -1
  420. package/dist/services/open-platform-rename.js +1 -108
  421. package/dist/services/open-platform-rename.js.map +1 -1
  422. package/dist/services/restart-intent-store.d.ts +22 -2
  423. package/dist/services/restart-intent-store.d.ts.map +1 -1
  424. package/dist/services/restart-intent-store.js +160 -18
  425. package/dist/services/restart-intent-store.js.map +1 -1
  426. package/dist/services/sandbox-store.d.ts +22 -0
  427. package/dist/services/sandbox-store.d.ts.map +1 -1
  428. package/dist/services/sandbox-store.js +37 -8
  429. package/dist/services/sandbox-store.js.map +1 -1
  430. package/dist/services/send-policy.d.ts +41 -33
  431. package/dist/services/send-policy.d.ts.map +1 -1
  432. package/dist/services/send-policy.js +39 -40
  433. package/dist/services/send-policy.js.map +1 -1
  434. package/dist/services/session-store.d.ts +66 -0
  435. package/dist/services/session-store.d.ts.map +1 -1
  436. package/dist/services/session-store.js +403 -67
  437. package/dist/services/session-store.js.map +1 -1
  438. package/dist/services/submit-notification.d.ts +3 -0
  439. package/dist/services/submit-notification.d.ts.map +1 -0
  440. package/dist/services/submit-notification.js +15 -0
  441. package/dist/services/submit-notification.js.map +1 -0
  442. package/dist/services/traex-transcript.d.ts +25 -2
  443. package/dist/services/traex-transcript.d.ts.map +1 -1
  444. package/dist/services/traex-transcript.js +133 -1
  445. package/dist/services/traex-transcript.js.map +1 -1
  446. package/dist/services/trigger-types.d.ts +13 -1
  447. package/dist/services/trigger-types.d.ts.map +1 -1
  448. package/dist/services/trigger-types.js +39 -0
  449. package/dist/services/trigger-types.js.map +1 -1
  450. package/dist/services/voice/index.d.ts +3 -2
  451. package/dist/services/voice/index.d.ts.map +1 -1
  452. package/dist/services/voice/index.js +5 -5
  453. package/dist/services/voice/index.js.map +1 -1
  454. package/dist/services/voice/openai.d.ts +2 -1
  455. package/dist/services/voice/openai.d.ts.map +1 -1
  456. package/dist/services/voice/openai.js +2 -1
  457. package/dist/services/voice/openai.js.map +1 -1
  458. package/dist/services/voice/sami.d.ts +5 -2
  459. package/dist/services/voice/sami.d.ts.map +1 -1
  460. package/dist/services/voice/sami.js +19 -4
  461. package/dist/services/voice/sami.js.map +1 -1
  462. package/dist/setup/bots-store.d.ts.map +1 -1
  463. package/dist/setup/bots-store.js +10 -4
  464. package/dist/setup/bots-store.js.map +1 -1
  465. package/dist/setup/lark-scopes.json +2 -0
  466. package/dist/setup/open-platform-automation.d.ts +9 -1
  467. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  468. package/dist/setup/open-platform-automation.js +44 -15
  469. package/dist/setup/open-platform-automation.js.map +1 -1
  470. package/dist/setup/open-platform-visibility.d.ts +42 -0
  471. package/dist/setup/open-platform-visibility.d.ts.map +1 -0
  472. package/dist/setup/open-platform-visibility.js +133 -0
  473. package/dist/setup/open-platform-visibility.js.map +1 -0
  474. package/dist/setup/verify-permissions.d.ts.map +1 -1
  475. package/dist/setup/verify-permissions.js +6 -0
  476. package/dist/setup/verify-permissions.js.map +1 -1
  477. package/dist/skills/definitions.js +2 -2
  478. package/dist/types.d.ts +290 -13
  479. package/dist/types.d.ts.map +1 -1
  480. package/dist/utils/bot-routing.d.ts +1 -0
  481. package/dist/utils/bot-routing.d.ts.map +1 -1
  482. package/dist/utils/bot-routing.js +1 -1
  483. package/dist/utils/bot-routing.js.map +1 -1
  484. package/dist/utils/child-env.d.ts +1 -1
  485. package/dist/utils/child-env.d.ts.map +1 -1
  486. package/dist/utils/child-env.js +9 -0
  487. package/dist/utils/child-env.js.map +1 -1
  488. package/dist/utils/codex-app-control.d.ts +438 -0
  489. package/dist/utils/codex-app-control.d.ts.map +1 -0
  490. package/dist/utils/codex-app-control.js +2336 -0
  491. package/dist/utils/codex-app-control.js.map +1 -0
  492. package/dist/utils/codex-app-dispatch-ledger.d.ts +78 -0
  493. package/dist/utils/codex-app-dispatch-ledger.d.ts.map +1 -0
  494. package/dist/utils/codex-app-dispatch-ledger.js +149 -0
  495. package/dist/utils/codex-app-dispatch-ledger.js.map +1 -0
  496. package/dist/utils/codex-app-turn-dispatch.d.ts +77 -0
  497. package/dist/utils/codex-app-turn-dispatch.d.ts.map +1 -0
  498. package/dist/utils/codex-app-turn-dispatch.js +141 -0
  499. package/dist/utils/codex-app-turn-dispatch.js.map +1 -0
  500. package/dist/utils/codex-app-turn-liveness.d.ts +125 -0
  501. package/dist/utils/codex-app-turn-liveness.d.ts.map +1 -0
  502. package/dist/utils/codex-app-turn-liveness.js +301 -0
  503. package/dist/utils/codex-app-turn-liveness.js.map +1 -0
  504. package/dist/utils/idle-detector.d.ts +12 -0
  505. package/dist/utils/idle-detector.d.ts.map +1 -1
  506. package/dist/utils/idle-detector.js +19 -0
  507. package/dist/utils/idle-detector.js.map +1 -1
  508. package/dist/utils/input-gate.d.ts +87 -0
  509. package/dist/utils/input-gate.d.ts.map +1 -1
  510. package/dist/utils/input-gate.js +86 -0
  511. package/dist/utils/input-gate.js.map +1 -1
  512. package/dist/utils/lark-upload.d.ts.map +1 -1
  513. package/dist/utils/lark-upload.js +52 -1
  514. package/dist/utils/lark-upload.js.map +1 -1
  515. package/dist/utils/pending-input-queue.d.ts +15 -0
  516. package/dist/utils/pending-input-queue.d.ts.map +1 -1
  517. package/dist/utils/pending-input-queue.js +5 -1
  518. package/dist/utils/pending-input-queue.js.map +1 -1
  519. package/dist/utils/user-token.d.ts.map +1 -1
  520. package/dist/utils/user-token.js +53 -2
  521. package/dist/utils/user-token.js.map +1 -1
  522. package/dist/worker-ipc-preload.d.ts +12 -0
  523. package/dist/worker-ipc-preload.d.ts.map +1 -0
  524. package/dist/worker-ipc-preload.js +44 -0
  525. package/dist/worker-ipc-preload.js.map +1 -0
  526. package/dist/worker.js +2604 -325
  527. package/dist/worker.js.map +1 -1
  528. package/dist/workflows/v3/session-relay-client.d.ts +1 -0
  529. package/dist/workflows/v3/session-relay-client.d.ts.map +1 -1
  530. package/dist/workflows/v3/session-relay-client.js +12 -3
  531. package/dist/workflows/v3/session-relay-client.js.map +1 -1
  532. package/package.json +1 -1
  533. package/dist/dashboard-web/chunks/bot-defaults-page-LTVOUDIS.js +0 -10
  534. package/dist/dashboard-web/chunks/chunk-DCS35UMD.js +0 -1
  535. package/dist/dashboard-web/chunks/chunk-MFQPCVHY.js +0 -51
  536. package/dist/dashboard-web/chunks/connectors-page-5KWDIMKC.js +0 -1
  537. package/dist/dashboard-web/chunks/groups-page-FHOKYXMW.js +0 -8
  538. package/dist/dashboard-web/chunks/overview-page-QYI3KBDD.js +0 -1
  539. package/dist/dashboard-web/chunks/sessions-page-WBP4N7D2.js +0 -6
package/dist/daemon.js CHANGED
@@ -21,6 +21,8 @@ import { countHostOverload } from './im/lark/card-handler.js';
21
21
  import { startMaintenance, stopMaintenance } from './core/maintenance.js';
22
22
  import { selectCodexRuntimeUpdateTargets, startCliRuntimeUpdateMonitor, stopCliRuntimeUpdateMonitor, } from './core/cli-runtime-update.js';
23
23
  import { sendRestartReportIfPending } from './core/restart-report.js';
24
+ import { SUPERVISOR_SHUTDOWN_PROTOCOL, } from './core/supervisor-shutdown-protocol.js';
25
+ import { readSupervisorProcessStartIdentity } from './core/process-start-identity.js';
24
26
  import { statSync } from 'node:fs';
25
27
  import { addReaction, deleteMessage, getChatContext, getChatMode, getChatNameAndMode, getMessageChatId, listChatMemberOpenIds, MessageWithdrawnError, replyMessage, resolveAllowedUsersWithMap, sendMessage, sendUserMessage, updateMessage } from './im/lark/client.js';
26
28
  import { resolveGroupJoinPrompt, waitForAllowedUserInChat } from './core/auto-start.js';
@@ -38,7 +40,7 @@ import { migrateOverloadAlertAtStartup } from './services/overload-alert-migrati
38
40
  import * as messageQueue from './services/message-queue.js';
39
41
  import { emitHookEvent, emitHookEventLocal, HOOK_EVENTS } from './services/hook-runner.js';
40
42
  import { setSessionLifecycleShutdown } from './services/session-lifecycle-hooks.js';
41
- import { createImgNumberer, parseEventMessage, resolveNonsupportMessage, stripLeadingMentions } from './im/lark/message-parser.js';
43
+ import { createImgNumberer, extractPostAtParticipants, parseEventMessage, resolveNonsupportMessage, stripLeadingMentions } from './im/lark/message-parser.js';
42
44
  import { expandMergeForward } from './im/lark/merge-forward.js';
43
45
  import { bindResourcesToMessage, composeForwardFollowupContent, mergeMessageMentions } from './im/lark/forward-followup-content.js';
44
46
  import { buildQuoteHint } from './im/lark/quote-hint.js';
@@ -47,6 +49,8 @@ import { logger } from './utils/logger.js';
47
49
  import { gracefulProcessExitCode } from './pm2-graceful-exit.js';
48
50
  import { applyAllowedUsersResolve } from './utils/allowed-users-apply.js';
49
51
  import { withFileLock, withFileLockSync } from './utils/file-lock.js';
52
+ import { hasUnsettledCodexAppDispatch, retireCodexAppDispatchAfterBackingMissing, validateCodexAppManagedSendOrigin, } from './utils/codex-app-dispatch-ledger.js';
53
+ import { hasProtectedSessionMutationOwnership } from './core/session-mutation-guard.js';
50
54
  import { delay } from './utils/timing.js';
51
55
  import { BoundedMap } from './utils/bounded-map.js';
52
56
  import { checkAllowedChatGroupsConfig } from './services/allowed-chat-groups.js';
@@ -55,7 +59,8 @@ import { scrubTmuxServerGlobalEnv } from './setup/ensure-tmux.js';
55
59
  import { entryNeedsContactResolve } from './setup/bot-config-editor.js';
56
60
  import { invalidWorkingDirs } from './utils/working-dir.js';
57
61
  import { validateWorkingDir } from './core/working-dir.js';
58
- import { activeSessionKey, sessionKey, sessionAnchorId, storedSessionAnchorId, larkTransportEnabled } from './core/types.js';
62
+ import { activeSessionKey, sessionKey, sessionAnchorId, storedSessionAnchorId, larkTransportEnabled, } from './core/types.js';
63
+ import { stagePendingRepoSetup, persistPendingRepoCardMessageId } from './core/pending-repo-journal.js';
59
64
  import { setTerminalProxyPort, setTerminalExternalPort } from './core/terminal-url.js';
60
65
  import { startTerminalProxy } from './core/terminal-proxy.js';
61
66
  import { runtimeInstallationKey } from './adapters/cli/runtime.js';
@@ -68,8 +73,8 @@ import { isLocalCliOpenReady } from './services/local-cli-opener.js';
68
73
  import { RECEIVED_REACTION_EMOJI_TYPE, SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE } from './core/pending-response.js';
69
74
  import { t as tr, localeForBot } from './i18n/index.js';
70
75
  import { createCliAdapterSync } from './adapters/cli/registry.js';
71
- import { initWorkerPool, setActiveSessionsRegistry, forkWorker, forkAdoptWorker, sendWorkerInput, sendWorkerSessionInput, killWorker, reapOrphanWorkers, scheduleCardPatch, setCurrentCliVersion, getCurrentCliVersion, CARD_POSTING_SENTINEL, parkStreamCard, closeSession as closeSessionHelper, setActiveSessionIfActive, rollbackRejectedSessionAndGetWinner, ensureCliEnv, sweepGlobalBotmuxSkills, writableTerminalLinkFor, workerHasInitialized, sessionSupportsWebTerminal, readableTerminalUrlFor, findActiveBySessionId, getDaemonBootId, getDaemonStreamingCardUsageSnapshot, isSessionTransferring, } from './core/worker-pool.js';
72
- import { AbortDeadlineError, hasExactSafeJsonKeys, ipcRoute, isTrustedHostIpcRequest, JsonBodyTooLargeError, jsonRes, readJsonBody, runWithAbortDeadline, setBotName, setLarkAppId, startIpcServer, setBotRenamer, setBotAvatarChanger, armCoreOnlyReadinessGate, setCoreOnlyReady } from './core/dashboard-ipc-server.js';
76
+ import { initWorkerPool, setActiveSessionsRegistry, forkWorker, forkAdoptWorker, sendWorkerInput, promoteQueuedActivationTail, admitQueuedActivationTail, reserveQueuedActivationTailAdmission, codexAppCleanInputAcceptedForSession, hasQueuedActivationAdmissionGate, sendWorkerSessionInput, killWorker, reapOrphanWorkers, scheduleCardPatch, setCurrentCliVersion, getCurrentCliVersion, CARD_POSTING_SENTINEL, parkStreamCard, closeSession as closeSessionHelper, setActiveSessionIfActive, rollbackRejectedSessionAndGetWinner, ensureCliEnv, sweepGlobalBotmuxSkills, writableTerminalLinkFor, workerHasInitialized, sessionSupportsWebTerminal, readableTerminalUrlFor, findActiveBySessionId, withActiveSessionKeyLock, getDaemonBootId, getDaemonStreamingCardUsageSnapshot, isSessionTransferring, } from './core/worker-pool.js';
77
+ import { AbortDeadlineError, hasExactSafeJsonKeys, ipcRoute, isTrustedHostIpcRequest, JsonBodyTooLargeError, jsonRes, readJsonBody, runWithAbortDeadline, setBotName, setLarkAppId, startIpcServer, setBotRenamer, setBotAvatarChanger, armCoreOnlyReadinessGate, setCoreOnlyReady, setSupervisorShutdownHandler } from './core/dashboard-ipc-server.js';
73
78
  import { setDeviceIsolationDaemonIdentity } from './core/device-isolation-daemon.js';
74
79
  import { cancelSessionReadyAck, waitForSessionReadyAck, } from './core/session-ready-handshake.js';
75
80
  import { loadOrCreateDashboardSecret } from './dashboard/auth.js';
@@ -83,12 +88,13 @@ import { findInheritablePeer } from './core/inherit-peer.js';
83
88
  import { isCallbackUrl, handleCallbackUrl } from './utils/user-token.js';
84
89
  import { consumeQuota, removeChatGrant, removeGlobalGrant } from './services/grant-store.js';
85
90
  import { abortCharge, commitCharge, beginCharge } from './services/quota-dedup.js';
86
- import { getSessionWorkingDir, getProjectScanDirs, expandHome, downloadResources, formatAttachmentsHint, buildNewTopicCliInput, buildFollowUpCliInput, buildBridgeInputContent, buildReforkCliInput, getAvailableBots, restoreActiveSessions, executeScheduledTask, persistStreamCardState, rememberLastCliInput, ensureTerminalWorkerPort, ensureSessionWhiteboard, } from './core/session-manager.js';
87
- import { triggerSessionTurn } from './core/trigger-session.js';
91
+ import { getSessionWorkingDir, getProjectScanDirs, expandHome, downloadResources, formatAttachmentsHint, buildNewTopicCliInput, buildFollowUpCliInput, buildBridgeInputContent, buildReforkCliInput, getAvailableBots, restoreActiveSessions, executeScheduledTask, persistStreamCardState, rememberLastCliInput, ensureTerminalWorkerPort, ensureSessionWhiteboard, closeCliMismatchedSessionsForBot, } from './core/session-manager.js';
92
+ import { triggerSessionTurn, reconcileIdempotencyLeasesOnBoot, convergeIdempotentAsyncTurnOnWorkerExit } from './core/trigger-session.js';
93
+ import { runDetachedBotTurnMutation, tryWithBotTurnMutation, withBotTurnAdmission, withBotTurnMutation, } from './core/bot-turn-mutation-gate.js';
88
94
  import { claimInitialUserTurn, isInitialUserTurnPending, releaseInitialUserTurn } from './core/initial-user-turn.js';
89
95
  import { applyQueuedCodexAppLegacyFallback, mergeQueuedCodexAppTurn } from './core/session-create.js';
90
96
  import { findOnlineDaemon, listOnlineDaemons } from './utils/daemon-discovery.js';
91
- import { beginReplyTargetTurn, fallbackTurnId, isSubstituteTurn, resolveSessionReplyTarget, syncReplyTargetState } from './core/reply-target.js';
97
+ import { beginReplyTargetTurn, buildTurnParticipantsFrom, fallbackTurnId, isSubstituteTurn, resolveSessionReplyTarget, syncReplyTargetState } from './core/reply-target.js';
92
98
  import { readDeferredTopicBinding } from './core/deferred-topic-binding.js';
93
99
  import { buildBotmuxLarkNativeSessionTitle, extractBotmuxLarkNativeSessionTitlePrompt, } from './core/session-title.js';
94
100
  import { settleDeferredScheduleRun } from './core/deferred-schedule-settlement.js';
@@ -98,7 +104,7 @@ import { TmuxBackend } from './adapters/backend/tmux-backend.js';
98
104
  import { HerdrBackend } from './adapters/backend/herdr-backend.js';
99
105
  import { ZellijBackend } from './adapters/backend/zellij-backend.js';
100
106
  import { ZmxBackend } from './adapters/backend/zmx-backend.js';
101
- import { sweepIdleWorkers, DEFAULT_MAX_LIVE_WORKERS } from './core/idle-worker-sweeper.js';
107
+ import { sweepIdleWorkersAfterTurnDrain, DEFAULT_MAX_LIVE_WORKERS } from './core/idle-worker-sweeper.js';
102
108
  import { getSessionPersistentBackendType, killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, probePersistentSession, resolvePersistentBackendTarget, resolvePairedSpawnBackendType, } from './core/persistent-backend.js';
103
109
  import { handleCardAction, runAutoWorktreeCommit } from './im/lark/card-handler.js';
104
110
  import { setIssueActivate } from './im/lark/issue-command-deps.js';
@@ -167,12 +173,16 @@ import { buildDocCommentTurnInput, buildDocWatchWarmupTurnInput } from './core/d
167
173
  import { advanceDocCommentCursor, docCommentRepliesAfterCursor, latestDocCommentPollCursor } from './core/doc-comment-poller.js';
168
174
  import { renderBufferedSenderBlock } from './core/session-manager.js';
169
175
  import { shutdownBackendDisposition } from './core/persistent-backend.js';
176
+ import { abortRiffShutdownFleet, canAbortVerifiedExitedRiffPreparation, collectUniqueDaemonShutdownSessions, commitPreparedRiffShutdown, isPreparedRiffSessionCurrent, persistPreparedRiffShutdownFleet, prepareRiffFleetForShutdown, } from './core/riff-shutdown-detach.js';
177
+ import { BOT_TURN_MUTATION_SHUTDOWN_ACQUIRE_TIMEOUT_MS, DAEMON_SHUTDOWN_MAX_MS, DAEMON_WORKER_EXIT_GRACE_MS, } from './core/shutdown-budgets.js';
170
178
  import { evaluateVcMeetingConsumerIsolation } from './services/vc-meeting-consumer-isolation.js';
171
179
  import { markSessionActivity, announcePendingRepoSession, publishAttentionPatch, publishLastInputFromBotPatch, publishSessionMessagePreviewPatch, publishClosedSessionPatch, clearAgentAttention, } from './core/session-activity.js';
172
180
  import { emitSessionLifecycleHook } from './services/session-lifecycle-hooks.js';
173
181
  import { botAutoWorktreeEnabled } from './services/default-worktree.js';
174
- import { setCardDispatcher as setAskCardDispatcher, setCanTalkChecker as setAskCanTalkChecker, registerAsk as registerAskBroker, findPendingAskByAnchor, submitCustomReply, } from './core/ask-broker.js';
182
+ import { setCardDispatcher as setAskCardDispatcher, setCanTalkChecker as setAskCanTalkChecker, setAskPersistStore as setAskPersistStoreBroker, registerAsk as registerAskBroker, restorePersistedAsks as restorePersistedAsksBroker, findPendingAskByAnchor, submitCustomReply, } from './core/ask-broker.js';
183
+ import { createAskPersistStore } from './core/ask-persist-store.js';
175
184
  import { parseAskBody } from './core/ask-api.js';
185
+ import { shouldReturnAskStartupNotReady } from './core/ask-types.js';
176
186
  import { computeCocoPickerKeys } from './core/coco-picker-keys.js';
177
187
  import { createLarkAskCardDispatcher } from './im/lark/ask-card.js';
178
188
  import { normalizeVcMeetingEvents } from './vc-agent/normalizer.js';
@@ -202,6 +212,13 @@ import { authorizeVcMeetingDaemonControlRequest, ensureVcMeetingDaemonAuthToken,
202
212
  import { resolveDurableVcMeetingImRouting, runBoundedVcMeetingImCatchUp, } from './services/vc-meeting-im-routing.js';
203
213
  // ─── State ───────────────────────────────────────────────────────────────────
204
214
  const activeSessions = new Map();
215
+ /** False until restoreActiveSessions() finishes. During the startup window the
216
+ * IPC server is already listening but activeSessions is empty, so a reconnecting
217
+ * ask hook would fail session lookup and get a 403 origin_unproven — which the
218
+ * CLI would treat as permanent and passthrough into a stuck native picker
219
+ * (codex P1-2). While false, /api/asks returns a retryable 503 for unknown
220
+ * sessions instead, so the reconnecting hook keeps waiting through the restore. */
221
+ let sessionsRestored = false;
205
222
  const VC_MEETING_DELIVERY_LEASE_MS = 15 * 60_000;
206
223
  const VC_MEETING_DELIVERY_LEASE_SCAN_MS = 60_000;
207
224
  const VC_MEETING_RUNTIME_EXPIRY_ACK_TIMEOUT_MS = 3_000;
@@ -260,6 +277,62 @@ function addVcMeetingReceiverRecoveryPending(key, scope) {
260
277
  vcMeetingReceiverRecoveryScopes.set(key, scope);
261
278
  refreshVcMeetingReceiverRecoveryReady();
262
279
  }
280
+ let testOnlyVcMeetingBootDispatchRetirement;
281
+ /**
282
+ * Retire the exact daemon-side Codex App ownership record after the owned CLI
283
+ * backing has been proved absent. This is intentionally stronger than the
284
+ * ordinary worker `cancel` transition: a dead generation may have accepted a
285
+ * successor behind this VC attempt, so recovery must remove only the fenced
286
+ * identity without requiring the old worker to prove FIFO state.
287
+ *
288
+ * A missing entry is idempotent success (the live worker may already have
289
+ * durably cancelled it before exiting). Multiple matches are corruption and
290
+ * remain fail-closed. The in-memory mutation is rolled back if the session
291
+ * store write fails, so callers can keep the delivery fence armed and retry.
292
+ */
293
+ function retireVcMeetingCodexAppDispatchAfterBackingMissing(sessionId, turnId, dispatchAttempt) {
294
+ if (testOnlyVcMeetingBootDispatchRetirement) {
295
+ return testOnlyVcMeetingBootDispatchRetirement(sessionId, turnId, dispatchAttempt);
296
+ }
297
+ const session = findActiveBySessionId(sessionId)?.session ?? sessionStore.getSession(sessionId);
298
+ if (!session)
299
+ return true;
300
+ const ledger = session.codexAppDispatchLedger ?? [];
301
+ const retired = retireCodexAppDispatchAfterBackingMissing(ledger, turnId, dispatchAttempt);
302
+ if (!retired.ok) {
303
+ logger.error(`[vc-delivery] exact Codex App dispatch retirement failed (${retired.error}) `
304
+ + `session=${sessionId} turn=${turnId.slice(0, 12)} attempt=${dispatchAttempt}`);
305
+ return false;
306
+ }
307
+ if (retired.ledger.length === ledger.length)
308
+ return true;
309
+ const priorLedger = session.codexAppDispatchLedger;
310
+ session.codexAppDispatchLedger = retired.ledger;
311
+ try {
312
+ sessionStore.updateSession(session);
313
+ const cleanupAppId = session.larkAppId;
314
+ if (cleanupAppId && !hasUnsettledCodexAppDispatch(session.codexAppDispatchLedger)) {
315
+ // Backing-missing retirement bypasses the normal worker ACK path, but it
316
+ // still creates the same durable empty-ledger boundary. Run mismatch
317
+ // cleanup only after persistence; failure is logged and never rolls back
318
+ // an already-authoritative retirement.
319
+ void runDetachedBotTurnMutation(cleanupAppId, async () => {
320
+ await closeCliMismatchedSessionsForBot(cleanupAppId);
321
+ }).catch(err => {
322
+ logger.error(`[vc-delivery] post-retirement ledger-drain cleanup failed session=${sessionId}: `
323
+ + `${err instanceof Error ? err.message : String(err)}`);
324
+ });
325
+ }
326
+ return true;
327
+ }
328
+ catch (err) {
329
+ session.codexAppDispatchLedger = priorLedger;
330
+ logger.error(`[vc-delivery] failed to persist exact Codex App dispatch retirement `
331
+ + `session=${sessionId} turn=${turnId.slice(0, 12)} attempt=${dispatchAttempt}: `
332
+ + `${err instanceof Error ? err.message : String(err)}`);
333
+ return false;
334
+ }
335
+ }
263
336
  function isVcMeetingBootRecoveryBlocked(request) {
264
337
  // The brief pre-enumeration interval is the only global barrier. Once boot
265
338
  // has enumerated stale receipts, one broken receiver must not stall unrelated
@@ -339,9 +412,14 @@ function scheduleVcMeetingBootRecoveryProbe(key, sessionId, delayMs) {
339
412
  return;
340
413
  vcMeetingReceiverRecoveryTimers.delete(key);
341
414
  if (vcMeetingBootBackingMissing(sessionId, true)) {
342
- logger.error(`[vc-delivery] boot receiver force-fenced session=${sessionId}`);
343
- clearVcMeetingReceiverRecoveryPending(key);
344
- return;
415
+ const scope = vcMeetingReceiverRecoveryScopes.get(key);
416
+ if (scope && retireVcMeetingCodexAppDispatchAfterBackingMissing(sessionId, scope.turnId, scope.dispatchAttempt)) {
417
+ logger.error(`[vc-delivery] boot receiver force-fenced session=${sessionId}`);
418
+ clearVcMeetingReceiverRecoveryPending(key);
419
+ return;
420
+ }
421
+ logger.error(`[vc-delivery] boot receiver backing missing but dispatch retirement unproven; `
422
+ + `reprobe scheduled session=${sessionId}`);
345
423
  }
346
424
  logger.error(`[vc-delivery] boot receiver teardown unproven; reprobe scheduled session=${sessionId}`);
347
425
  scheduleVcMeetingBootRecoveryProbe(key, sessionId, VC_MEETING_RUNTIME_EXPIRY_REPROBE_MS);
@@ -375,8 +453,13 @@ function acknowledgeVcMeetingReceiverRecovery(key) {
375
453
  if (!sessionId)
376
454
  return false;
377
455
  if (vcMeetingBootBackingMissing(sessionId, false)) {
378
- clearVcMeetingReceiverRecoveryPending(key);
379
- return true;
456
+ const scope = vcMeetingReceiverRecoveryScopes.get(key);
457
+ if (scope && retireVcMeetingCodexAppDispatchAfterBackingMissing(sessionId, scope.turnId, scope.dispatchAttempt)) {
458
+ clearVcMeetingReceiverRecoveryPending(key);
459
+ return true;
460
+ }
461
+ logger.error(`[vc-delivery] boot receiver ACK has missing backing but dispatch retirement is unproven `
462
+ + `session=${sessionId}`);
380
463
  }
381
464
  logger.error(`[vc-delivery] boot receiver ACK lacked backing teardown proof session=${sessionId}`);
382
465
  escalateVcMeetingBootRecovery(key, sessionId);
@@ -402,6 +485,8 @@ export const __testOnly_vcMeetingReceiverRecovery = {
402
485
  meetingId: scope.meetingId ?? 'meeting_test',
403
486
  memberId: scope.memberId ?? 'member_test',
404
487
  memberEpoch: scope.memberEpoch ?? 1,
488
+ turnId,
489
+ dispatchAttempt,
405
490
  });
406
491
  armVcMeetingReceiverRecoveryTimeout(key, sessionId);
407
492
  return key;
@@ -425,6 +510,12 @@ export const __testOnly_vcMeetingReceiverRecovery = {
425
510
  },
426
511
  setBackingMissingProbe(probe) {
427
512
  testOnlyVcMeetingBootBackingMissing = probe;
513
+ // Test recovery lifecycles must not load or mutate the developer's real
514
+ // session store merely because they replaced the backing probe.
515
+ testOnlyVcMeetingBootDispatchRetirement ??= () => true;
516
+ },
517
+ setDispatchRetirement(retire) {
518
+ testOnlyVcMeetingBootDispatchRetirement = retire;
428
519
  },
429
520
  reset() {
430
521
  for (const timer of vcMeetingReceiverRecoveryTimers.values())
@@ -436,6 +527,7 @@ export const __testOnly_vcMeetingReceiverRecovery = {
436
527
  vcMeetingReceiverRecoveryReady = false;
437
528
  vcMeetingReceiverRecoverySchedulingComplete = false;
438
529
  testOnlyVcMeetingBootBackingMissing = undefined;
530
+ testOnlyVcMeetingBootDispatchRetirement = undefined;
439
531
  },
440
532
  };
441
533
  function createVcMeetingRuntimeLeaseRecovery(deps) {
@@ -524,8 +616,18 @@ function createVcMeetingRuntimeLeaseRecovery(deps) {
524
616
  }
525
617
  // PTY has no surviving backing process once its worker has crossed the
526
618
  // SIGKILL backstop. Persistent backends unlock only after every applicable
527
- // deterministic name probes authoritatively missing.
619
+ // deterministic name probes authoritatively missing. The backing proof is
620
+ // not itself enough: atomically retire this exact old daemon-ledger attempt
621
+ // before admitting hub replay, or accepted/prepared N can be restored next
622
+ // to replayed N+1 by a replacement worker.
528
623
  if (allMissing) {
624
+ if (!deps.retireDispatch(fence.receiverSessionId, fence.deliveryKey, fence.dispatchAttempt)) {
625
+ deps.error(`[vc-delivery] runtime lease backing missing but exact dispatch retirement failed; `
626
+ + `delivery remains gated session=${fence.receiverSessionId} `
627
+ + `attempt=${fence.dispatchAttempt}`);
628
+ scheduleReprobe(fence, `${reason} dispatch retirement`);
629
+ return false;
630
+ }
529
631
  clearFence(fence);
530
632
  deps.warn(`[vc-delivery] runtime lease fence cleared after ${reason} `
531
633
  + `session=${fence.receiverSessionId} attempt=${fence.dispatchAttempt}`);
@@ -751,6 +853,7 @@ const vcMeetingRuntimeLeaseRecovery = createVcMeetingRuntimeLeaseRecovery({
751
853
  probePersistent: (backendType, sessionName, target) => target
752
854
  ? probePersistentBackendTarget(target)
753
855
  : probePersistentSession(backendType, sessionName),
856
+ retireDispatch: retireVcMeetingCodexAppDispatchAfterBackingMissing,
754
857
  warn: message => logger.warn(message),
755
858
  error: message => logger.error(message),
756
859
  });
@@ -2104,6 +2207,23 @@ async function sessionReply(anchor, content, msgType = 'text', larkAppId, turnId
2104
2207
  return sendWithHookPolicy(chatId, content, msgType, opts.uuid);
2105
2208
  }
2106
2209
  }
2210
+ if (opts?.replyTarget?.mode === 'thread') {
2211
+ return replyWithHookPolicy(opts.replyTarget.rootMessageId, content, msgType, true, opts.uuid);
2212
+ }
2213
+ if (opts?.replyTarget?.mode === 'plain') {
2214
+ if (opts.replyTarget.chatId !== chatId) {
2215
+ throw new Error('frozen reply target does not match the session chat');
2216
+ }
2217
+ // Preserve the existing regular-group → topic conversion safety without
2218
+ // consulting the mutable per-turn target that may now belong to turn B.
2219
+ if (ds?.scope === 'chat' && ds.session.rootMessageId) {
2220
+ const mode = await getChatMode(appId, chatId, { forceRefresh: true });
2221
+ if (mode === 'topic') {
2222
+ return replyWithHookPolicy(ds.session.rootMessageId, content, msgType, true, opts.uuid);
2223
+ }
2224
+ }
2225
+ return sendWithHookPolicy(chatId, content, msgType, opts.uuid);
2226
+ }
2107
2227
  if (opts?.placement === 'chat') {
2108
2228
  return sendWithHookPolicy(chatId, content, msgType, opts.uuid);
2109
2229
  }
@@ -2149,6 +2269,12 @@ async function sessionReply(anchor, content, msgType = 'text', larkAppId, turnId
2149
2269
  return sendWithHookPolicy(chatId, content, msgType, opts?.uuid);
2150
2270
  }
2151
2271
  // Thread-scope (or unknown / legacy): reply in thread.
2272
+ if (opts?.replyTarget?.mode === 'plain') {
2273
+ throw new Error('plain frozen reply target is invalid for a thread-scoped session');
2274
+ }
2275
+ if (opts?.replyTarget?.mode === 'thread') {
2276
+ return replyWithHookPolicy(opts.replyTarget.rootMessageId, content, msgType, true, opts.uuid);
2277
+ }
2152
2278
  return replyWithHookPolicy(anchor, content, msgType, true, opts?.uuid);
2153
2279
  }
2154
2280
  // Test seams: drive the real sessionReply (the chat-scope thread/top-level
@@ -2955,6 +3081,12 @@ async function prewarmDocCommentSession(ds, sub) {
2955
3081
  const turnId = `doc-watch-${Date.now()}-${sub.fileToken.slice(0, 8)}`;
2956
3082
  const sender = sub.ownerOpenId ? await resolveSender(ds.larkAppId, sub.ownerOpenId, 'user') : undefined;
2957
3083
  ensureCurrentRoutingGeneration(generation, 'prewarm:sender');
3084
+ if ((!ds.worker || ds.worker.killed)
3085
+ && (ds.pendingRepo
3086
+ || ds.worktreeCreating
3087
+ || hasProtectedSessionMutationOwnership(ds))) {
3088
+ throw new Error(`doc-watch warmup deferred: session ${ds.session.sessionId} has durable opening ownership`);
3089
+ }
2958
3090
  beginNewTurn(ds, title);
2959
3091
  ds.lastMessageAt = Date.now();
2960
3092
  ds.session.lastMessageAt = new Date(ds.lastMessageAt).toISOString();
@@ -2977,7 +3109,9 @@ async function prewarmDocCommentSession(ds, sub) {
2977
3109
  });
2978
3110
  rememberLastCliInput(ds, promptContent, cliInput);
2979
3111
  sessionStore.updateSession(ds.session);
2980
- sendWorkerInput(ds, cliInput, turnId);
3112
+ if (!sendWorkerInput(ds, cliInput, turnId)) {
3113
+ throw new Error('doc-watch warmup worker input was not accepted');
3114
+ }
2981
3115
  markSessionActivity(ds);
2982
3116
  }
2983
3117
  else {
@@ -2997,6 +3131,7 @@ async function prewarmDocCommentSession(ds, sub) {
2997
3131
  }
2998
3132
  logger.info(`[${tag(ds)}] doc-comment watch prewarm injected file=${sub.fileToken.slice(0, 12)}`);
2999
3133
  }
3134
+ export const __testOnly_prewarmDocCommentSession = prewarmDocCommentSession;
3000
3135
  // Dependencies passed to command-handler
3001
3136
  const commandDeps = {
3002
3137
  activeSessions,
@@ -3904,6 +4039,21 @@ ipcRoute('POST', '/api/asks', async (req, res) => {
3904
4039
  if ('error' in parsed)
3905
4040
  return jsonRes(res, 400, { ok: false, error: parsed.error });
3906
4041
  const askSession = findActiveBySessionId(parsed.sessionId);
4042
+ // Startup window (codex P1-2): IPC is listening but sessions aren't restored
4043
+ // yet, so a reconnecting ask hook's session lookup misses and would otherwise
4044
+ // get a permanent 403. Return a RETRYABLE 503 so the hook keeps waiting
4045
+ // (blocking Claude, no native picker) until restore finishes and it can bind.
4046
+ // Predicate is a shared pure function (unit-tested directly — codex P1-2 seam).
4047
+ // NOTE: this gate does NOT exempt trusted-host callers — a normal unsandbox
4048
+ // hook IS the trusted-host path (HMAC fetchDaemonIpc), so exempting it would
4049
+ // let the very reconnecting hook we must protect register a lost
4050
+ // non-resumable ask during the restore window.
4051
+ if (shouldReturnAskStartupNotReady({
4052
+ hasSession: !!askSession,
4053
+ sessionsRestored,
4054
+ })) {
4055
+ return jsonRes(res, 503, { ok: false, error: 'startup_not_ready' });
4056
+ }
3907
4057
  let boundAsk = parsed;
3908
4058
  if (!isTrustedHostIpcRequest(req)) {
3909
4059
  const body = raw && typeof raw === 'object' && !Array.isArray(raw)
@@ -3987,6 +4137,14 @@ ipcRoute('POST', '/api/asks', async (req, res) => {
3987
4137
  questions: boundAsk.questions,
3988
4138
  timeoutMs: boundAsk.timeoutMs,
3989
4139
  chatType: askChatType,
4140
+ // Invocation identity (from the hook; enables cross-restart re-attach).
4141
+ requestId: boundAsk.requestId,
4142
+ originKind: boundAsk.originKind,
4143
+ // Authoritative persistence gate (codex P1-4): derive resumability from the
4144
+ // authenticated session's FROZEN backend, not the client's origin string. A
4145
+ // PTY-backed session dies with the daemon, so its ask must NOT persist; only
4146
+ // a restart-surviving mux backend (tmux/herdr/zellij/zmx) is resumable.
4147
+ backendSurvivesRestart: !!askSession && getSessionPersistentBackendType(askSession) !== undefined,
3990
4148
  });
3991
4149
  // CoCo 专属:它的 hook 不能用 directive 代答(hook 客户端永远 passthrough,CoCo 会
3992
4150
  // 渲染原生 picker)。这里在 ask 结算为「已作答」时,把答案翻成按键序列下发给该会话
@@ -4076,6 +4234,16 @@ ipcRoute('POST', '/api/attention', async (req, res) => {
4076
4234
  if (!verified.ok) {
4077
4235
  return jsonRes(res, 403, { ok: false, error: verified.error });
4078
4236
  }
4237
+ if (!ds?.managedTurnOrigin
4238
+ || typeof raw.originTurnId !== 'string'
4239
+ || raw.originTurnId !== ds.managedTurnOrigin.turnId
4240
+ || claimedAttempt !== ds.managedTurnOrigin.dispatchAttempt) {
4241
+ return jsonRes(res, 403, { ok: false, error: 'origin_identity_mismatch' });
4242
+ }
4243
+ const codexDecision = validateCodexAppManagedSendOrigin(ds.session.codexAppDispatchLedger, ds.managedTurnOrigin, ds.initConfig?.cliId === 'codex-app' || ds.session.cliId === 'codex-app');
4244
+ if (!codexDecision.ok) {
4245
+ return jsonRes(res, 409, { ok: false, error: 'origin_not_sendable' });
4246
+ }
4079
4247
  }
4080
4248
  if (!ds)
4081
4249
  return jsonRes(res, 404, { ok: false, error: 'session_not_found' });
@@ -4300,6 +4468,10 @@ ipcRoute('POST', '/api/hooks/emit', async (req, res) => {
4300
4468
  if (!verified.ok) {
4301
4469
  return jsonRes(res, 403, { ok: false, error: verified.error });
4302
4470
  }
4471
+ const codexDecision = validateCodexAppManagedSendOrigin(ds.session.codexAppDispatchLedger, ds.managedTurnOrigin, ds.initConfig?.cliId === 'codex-app' || ds.session.cliId === 'codex-app');
4472
+ if (!codexDecision.ok) {
4473
+ return jsonRes(res, 409, { ok: false, error: 'origin_not_sendable' });
4474
+ }
4303
4475
  // Do not let a valid capability for session A forge session B's identity
4304
4476
  // inside the hook payload. Hook-specific fields remain caller supplied;
4305
4477
  // routing/identity fields come only from the authenticated daemon session.
@@ -12797,6 +12969,86 @@ export const __testOnly_resolvePinnedWorkingDir = resolvePinnedWorkingDir;
12797
12969
  // which unit tests calling handleCommand directly can never catch.
12798
12970
  export const __testOnly_handleNewTopic = (data, ctx) => handleNewTopic(data, ctx);
12799
12971
  export const __testOnly_handleThreadReply = (data, ctx) => handleThreadReply(data, ctx);
12972
+ export const __testOnly_computeCodexAppSteerable = computeCodexAppSteerable;
12973
+ /**
12974
+ * Claim the canonical routing slot for a newly-created daemon session.
12975
+ *
12976
+ * Restore/transfer intentionally use setActiveSessionSafe's replacement
12977
+ * policy to clean historical collisions. Live creation is different: once a
12978
+ * caller has won a slot, a concurrent creator must never close that winner
12979
+ * while the first caller is still preparing/forking it. First owner wins;
12980
+ * ledger-empty losers are closed before the caller can observe rejection.
12981
+ * Any incoming owner with an unsettled durable turn is preserved and fails
12982
+ * closed because choosing either generation would risk dropping accepted
12983
+ * work.
12984
+ */
12985
+ async function claimNewDaemonSession(map, incoming) {
12986
+ const key = activeSessionKey(incoming);
12987
+ return withActiveSessionKeyLock(map, key, async () => {
12988
+ const incumbent = map.get(key);
12989
+ if (!incumbent || incumbent === incoming) {
12990
+ map.set(key, incoming);
12991
+ return { accepted: true, key, owner: incoming };
12992
+ }
12993
+ const incumbentPending = hasProtectedSessionMutationOwnership(incumbent);
12994
+ // A newly-created loser's transient initialStartPending is still owned by
12995
+ // this caller and can be rerouted to the incumbent after the empty row is
12996
+ // closed. Only durable session-level ownership makes the loser itself
12997
+ // irreplaceable here.
12998
+ const incomingPending = hasProtectedSessionMutationOwnership(incoming.session);
12999
+ if (incomingPending) {
13000
+ const reason = incumbentPending ? 'both_pending' : 'incoming_pending';
13001
+ logger.error(`[session-registration] refusing ${reason} collision key=${key} `
13002
+ + `incumbent=${incumbent.session.sessionId} incoming=${incoming.session.sessionId}; `
13003
+ + 'preserving both rows and failing closed');
13004
+ return {
13005
+ accepted: false,
13006
+ key,
13007
+ owner: incumbent,
13008
+ reason,
13009
+ preservedIncomingSessionId: incoming.session.sessionId,
13010
+ };
13011
+ }
13012
+ // All live creation paths reach this helper before a worker/worktree is
13013
+ // started, so persistence close is the complete loser cleanup. Do it while
13014
+ // holding the key lock: rejection never exposes an active ghost row.
13015
+ sessionStore.closeSession(incoming.session.sessionId);
13016
+ logger.warn(`[session-registration] canonical owner ${incumbent.session.sessionId.substring(0, 8)} `
13017
+ + `kept for key=${key}; closed incoming ${incoming.session.sessionId.substring(0, 8)}`);
13018
+ return {
13019
+ accepted: false,
13020
+ key,
13021
+ owner: incumbent,
13022
+ reason: 'existing_owner',
13023
+ closedIncomingSessionId: incoming.session.sessionId,
13024
+ };
13025
+ });
13026
+ }
13027
+ export const __testOnly_claimNewDaemonSession = (map, incoming) => claimNewDaemonSession(map, incoming);
13028
+ /** Persist pending-repo N immediately after the caller wins the canonical
13029
+ * runtime slot and before any card/worktree/fork publication. Staging before
13030
+ * the claim creates a durable same-key loser that can poison restart with two
13031
+ * protected owners. A failed write unpublishes the not-yet-durably-admitted
13032
+ * runtime owner and fails loudly. This remains inside the documented residual
13033
+ * WS-claim → durable-admission gap; no redelivery guarantee is asserted here. */
13034
+ function stageClaimedPendingRepoSetup(map, ds, args) {
13035
+ try {
13036
+ stagePendingRepoSetup(ds, args);
13037
+ }
13038
+ catch (err) {
13039
+ const key = activeSessionKey(ds);
13040
+ if (map.get(key) === ds)
13041
+ map.delete(key);
13042
+ try {
13043
+ sessionStore.closeSession(ds.session.sessionId);
13044
+ }
13045
+ catch (closeErr) {
13046
+ logger.error(`[session-registration] setup staging and loser cleanup both failed for `
13047
+ + `${ds.session.sessionId}: ${closeErr instanceof Error ? closeErr.message : String(closeErr)}`);
13048
+ }
13049
+ throw err;
13050
+ }
13051
+ }
12800
13052
  /**
12801
13053
  * 该新会话是否要走「仅默认目录 + 自动建 worktree」:pinned dir 来自本 bot 自己的
12802
13054
  * defaultWorkingDir (layer 3) 且开关打开。为真时,spawn 路径不再同步 fork,而是把会话登记
@@ -12823,6 +13075,324 @@ function startAutoWorktreePending(ds, args) {
12823
13075
  });
12824
13076
  logger.info(`[${tag(ds)}] auto-worktree → pending, building worktree off ${args.baseDir}`);
12825
13077
  }
13078
+ /** Materialize the opening input at the final synchronous boundary before
13079
+ * fork. All potentially-slow preparation must happen before this call; the
13080
+ * pendingFollowUps snapshot and fork then cannot be interleaved by a later
13081
+ * same-anchor handler. */
13082
+ function buildReservedInitialInput(ds, availableBots) {
13083
+ const selfBot = getBot(ds.larkAppId);
13084
+ const botCfg = selfBot.config;
13085
+ const built = buildNewTopicCliInput(ds.pendingPrompt ?? '', ds.session.sessionId, ds.session.cliId ?? botCfg.cliId, ds.session.cliPathOverride ?? botCfg.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, availableBots, ds.pendingFollowUps, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(ds.larkAppId), ds.pendingSender, {
13086
+ larkAppId: ds.larkAppId,
13087
+ chatId: ds.chatId,
13088
+ whiteboardId: ds.session.whiteboardId,
13089
+ substituteTrigger: ds.pendingSubstituteTrigger,
13090
+ codexAppText: ds.pendingCodexAppText,
13091
+ codexAppApplicationContext: ds.pendingCodexAppApplicationContext,
13092
+ codexAppMessageContext: ds.pendingCodexAppMessageContext,
13093
+ codexAppFollowUps: ds.pendingCodexAppFollowUps,
13094
+ codexAppFollowUpContexts: ds.pendingCodexAppFollowUpContexts,
13095
+ // master: thread the joined-chat context into the opening prompt (group-join
13096
+ // auto-start passes this via pendingChatContext).
13097
+ chatContext: ds.pendingChatContext,
13098
+ });
13099
+ // R5-B1-1: COPY the frozen new-topic steer authorization onto the opening
13100
+ // payload — never inferred here. Only an explicit upstream `true` carries over.
13101
+ if (ds.pendingCodexAppSteerable === true)
13102
+ built.codexAppSteerable = true;
13103
+ return built;
13104
+ }
13105
+ function clearInitialStartBuffers(ds) {
13106
+ ds.pendingPrompt = undefined;
13107
+ ds.pendingCodexAppText = undefined;
13108
+ ds.pendingCodexAppApplicationContext = undefined;
13109
+ ds.pendingCodexAppMessageContext = undefined;
13110
+ ds.pendingAttachments = undefined;
13111
+ ds.pendingMentions = undefined;
13112
+ ds.pendingSubstituteTrigger = undefined;
13113
+ ds.pendingSender = undefined;
13114
+ ds.pendingCodexAppSteerable = undefined;
13115
+ ds.pendingFollowUps = undefined;
13116
+ ds.pendingFollowUpTurnIds = undefined;
13117
+ ds.pendingCodexAppFollowUps = undefined;
13118
+ ds.pendingCodexAppFollowUpContexts = undefined;
13119
+ ds.pendingCodexAppFollowUpGateAccepted = undefined;
13120
+ }
13121
+ function clearInitialStartClaim(ds, token) {
13122
+ if (token !== undefined && ds.initialStartClaimToken !== token)
13123
+ return false;
13124
+ ds.initialStartClaimToken = undefined;
13125
+ ds.initialStartPending = false;
13126
+ if ((ds.queuedActivationTailAdmissionsOutstanding ?? 0) === 0) {
13127
+ ds.queuedActivationTailReleasePending = undefined;
13128
+ if (ds.queuedActivationTailReleaseRetryTimer) {
13129
+ clearTimeout(ds.queuedActivationTailReleaseRetryTimer);
13130
+ ds.queuedActivationTailReleaseRetryTimer = undefined;
13131
+ }
13132
+ }
13133
+ return true;
13134
+ }
13135
+ /** Fence an arrival before any sender/prompt await. Besides reserving durable
13136
+ * FIFO order, keep the opening route owned until this reservation either lands
13137
+ * in the durable tail or fails. */
13138
+ function reserveAsyncQueuedActivationTailAdmission(ds) {
13139
+ const reservation = reserveQueuedActivationTailAdmission(ds);
13140
+ ds.queuedActivationTailAdmissionsOutstanding =
13141
+ (ds.queuedActivationTailAdmissionsOutstanding ?? 0) + 1;
13142
+ return reservation;
13143
+ }
13144
+ function scheduleQueuedActivationTailReleaseRetry(ds, acknowledgedToken) {
13145
+ if (!ds.initialStartPending || ds.queuedActivationTailReleaseRetryTimer)
13146
+ return;
13147
+ const timer = setTimeout(() => {
13148
+ if (ds.queuedActivationTailReleaseRetryTimer !== timer)
13149
+ return;
13150
+ ds.queuedActivationTailReleaseRetryTimer = undefined;
13151
+ if (!ds.initialStartPending)
13152
+ return;
13153
+ if (!releaseQueuedActivationReservation(ds, acknowledgedToken)) {
13154
+ scheduleQueuedActivationTailReleaseRetry(ds, acknowledgedToken);
13155
+ }
13156
+ }, 100);
13157
+ timer.unref?.();
13158
+ ds.queuedActivationTailReleaseRetryTimer = timer;
13159
+ }
13160
+ /** Complete one async reservation. If the predecessor ACK arrived during its
13161
+ * await, the final settler performs the deferred handoff immediately. */
13162
+ function settleAsyncQueuedActivationTailAdmission(ds) {
13163
+ const outstanding = Math.max(0, (ds.queuedActivationTailAdmissionsOutstanding ?? 0) - 1);
13164
+ ds.queuedActivationTailAdmissionsOutstanding = outstanding || undefined;
13165
+ if (outstanding > 0 || !ds.queuedActivationTailReleasePending)
13166
+ return;
13167
+ const pending = ds.queuedActivationTailReleasePending;
13168
+ ds.queuedActivationTailReleasePending = undefined;
13169
+ if (!releaseQueuedActivationReservation(ds, pending.acknowledgedToken)) {
13170
+ scheduleQueuedActivationTailReleaseRetry(ds, pending.acknowledgedToken);
13171
+ }
13172
+ }
13173
+ export const __testOnly_reserveAsyncQueuedActivationTailAdmission = reserveAsyncQueuedActivationTailAdmission;
13174
+ export const __testOnly_settleAsyncQueuedActivationTailAdmission = settleAsyncQueuedActivationTailAdmission;
13175
+ /** Release a queued activation's runtime route reservation only after the
13176
+ * worker ACKs actual adapter submission. Turns that arrived meanwhile are sent
13177
+ * as one ordered follow-up, never allowed to overtake the opening item. */
13178
+ function releaseQueuedActivationReservation(ds, acknowledgedToken) {
13179
+ if ((ds.queuedActivationTailAdmissionsOutstanding ?? 0) > 0) {
13180
+ // The worker may ACK while N+1 is still awaiting sender/resource prompt
13181
+ // construction. Keep the route gate and replay this release when the last
13182
+ // reserved arrival either persists or fails.
13183
+ ds.queuedActivationTailReleasePending = acknowledgedToken === undefined
13184
+ ? {}
13185
+ : { acknowledgedToken };
13186
+ return false;
13187
+ }
13188
+ ds.queuedActivationTailReleasePending = undefined;
13189
+ // A prior callback retry may observe the successor already promoted. That is
13190
+ // success for the acknowledged predecessor; never append/send the same tail
13191
+ // head a second time while its fresh token owns the journal.
13192
+ if (ds.session.queuedActivationPending) {
13193
+ return acknowledgedToken !== undefined
13194
+ && ds.session.queuedActivationToken !== acknowledgedToken;
13195
+ }
13196
+ const buffered = [...(ds.pendingFollowUps ?? [])];
13197
+ if (buffered.length > 0) {
13198
+ const bot = getBot(ds.larkAppId);
13199
+ const cliId = ds.session.cliId ?? bot.config.cliId;
13200
+ const rawCodexText = (ds.pendingCodexAppFollowUps ?? []).join('\n\n');
13201
+ const followUp = buildFollowUpCliInput(buffered.join('\n\n'), ds.session.sessionId, {
13202
+ cliId,
13203
+ cliPathOverride: ds.session.cliPathOverride ?? bot.config.cliPathOverride,
13204
+ locale: localeForBot(ds.larkAppId),
13205
+ larkAppId: ds.larkAppId,
13206
+ chatId: ds.chatId,
13207
+ whiteboardId: ds.session.whiteboardId,
13208
+ codexAppText: rawCodexText || buffered.join('\n\n'),
13209
+ codexAppApplicationContext: ds.pendingCodexAppApplicationContext,
13210
+ codexAppMessageContext: (ds.pendingCodexAppFollowUpContexts ?? [])
13211
+ .filter(Boolean)
13212
+ .join('\n\n'),
13213
+ });
13214
+ const bufferedTurnIds = ds.pendingFollowUpTurnIds ?? [];
13215
+ const turnId = bufferedTurnIds[bufferedTurnIds.length - 1]
13216
+ ?? ds.currentReplyTarget?.turnId
13217
+ ?? `queued-activation-followup-${randomUUID()}`;
13218
+ const reservation = reserveQueuedActivationTailAdmission(ds);
13219
+ const bufferedGateDecisions = ds.pendingCodexAppFollowUpGateAccepted ?? [];
13220
+ // Runtime buffers are rolling-upgrade compatibility only. If they carry an
13221
+ // arrival-time decision, preserve it; otherwise fail closed rather than
13222
+ // sampling a later ON setting and inventing a sidecar at ACK time.
13223
+ //
13224
+ // R7-B1/coalesced (codex ruling): `codexAppInputAccepted` here is the
13225
+ // clean-input FEATURE gate, NOT a steer authorization — it is deliberately
13226
+ // never used to derive `codexAppSteerable`. Coalescing N buffered messages
13227
+ // into one prompt / one ledger reservation / one final (turnId is the last
13228
+ // one) has already lost each message's per-message identity, so a single
13229
+ // steer flag could not safely express the batch. This coalesced opening
13230
+ // successor therefore stays FORCED-SERIAL by construction: the admit below
13231
+ // carries no `codexAppSteerable`, and nothing downstream re-derives one.
13232
+ reservation.codexAppInputAccepted = bufferedGateDecisions.length > 0
13233
+ && bufferedGateDecisions.every(Boolean);
13234
+ try {
13235
+ admitQueuedActivationTail(ds, {
13236
+ userPrompt: rawCodexText || buffered.join('\n\n'),
13237
+ cliInput: followUp,
13238
+ turnId,
13239
+ }, reservation);
13240
+ }
13241
+ catch (err) {
13242
+ logger.error(`[${ds.session.sessionId.slice(0, 8)}] Failed to persist buffered activation successor: `
13243
+ + `${err instanceof Error ? err.message : String(err)}`);
13244
+ return false;
13245
+ }
13246
+ // Ownership has moved into the durable exact FIFO. Clear the runtime source
13247
+ // only after its store write succeeds.
13248
+ ds.pendingFollowUps = undefined;
13249
+ ds.pendingFollowUpTurnIds = undefined;
13250
+ ds.pendingCodexAppFollowUps = undefined;
13251
+ ds.pendingCodexAppFollowUpContexts = undefined;
13252
+ ds.pendingCodexAppFollowUpGateAccepted = undefined;
13253
+ ds.pendingCodexAppApplicationContext = undefined;
13254
+ }
13255
+ // Migrate pre-durable runtime tails (and tests/rolling-upgrade state) before
13256
+ // advancing. Persistence happens before the volatile cursor is cleared.
13257
+ for (const staged of ds.pendingQueuedActivationFollowUps ?? []) {
13258
+ const reservation = reserveQueuedActivationTailAdmission(ds);
13259
+ try {
13260
+ admitQueuedActivationTail(ds, {
13261
+ userPrompt: staged.userPrompt,
13262
+ cliInput: staged.cliInput,
13263
+ turnId: staged.turnId,
13264
+ dispatchAttempt: staged.dispatchAttempt,
13265
+ }, reservation, { codexAppInputGateFrozen: true });
13266
+ }
13267
+ catch (err) {
13268
+ logger.error(`[${ds.session.sessionId.slice(0, 8)}] Failed to migrate activation successor: `
13269
+ + `${err instanceof Error ? err.message : String(err)}`);
13270
+ return false;
13271
+ }
13272
+ }
13273
+ ds.pendingQueuedActivationFollowUps = undefined;
13274
+ if ((ds.session.queuedActivationTail?.length ?? 0) === 0) {
13275
+ clearInitialStartBuffers(ds);
13276
+ clearInitialStartClaim(ds);
13277
+ return true;
13278
+ }
13279
+ const head = [...ds.session.queuedActivationTail]
13280
+ .sort((a, b) => a.order - b.order || a.id.localeCompare(b.id))[0];
13281
+ const workerUnavailable = !ds.worker
13282
+ || ds.worker.killed;
13283
+ if (!promoteQueuedActivationTail(ds, workerUnavailable ? { send: false } : {}))
13284
+ return false;
13285
+ try {
13286
+ rememberLastCliInput(ds, head.userPrompt, head.cliInput, {
13287
+ codexAppInputAccepted: !!head.cliInput.codexAppInput,
13288
+ });
13289
+ }
13290
+ catch (err) {
13291
+ logger.error(`[${ds.session.sessionId.slice(0, 8)}] Failed to persist promoted activation metadata: `
13292
+ + `${err instanceof Error ? err.message : String(err)}`);
13293
+ }
13294
+ clearInitialStartBuffers(ds);
13295
+ if (workerUnavailable) {
13296
+ // N's child can exit after its ACK while N+1 is still being prepared. Park
13297
+ // the late exact head in the durable journal, but release the runtime claim
13298
+ // so the next inbound turn can refork that head before accepting N+2.
13299
+ clearInitialStartClaim(ds);
13300
+ }
13301
+ return true;
13302
+ }
13303
+ export const __testOnly_releaseQueuedActivationReservation = releaseQueuedActivationReservation;
13304
+ /** Exact production callback passed to initWorkerPool. Keep the boolean return:
13305
+ * false means the worker did not accept the staged FIFO head and the pool must
13306
+ * retry instead of silently dropping the activation reservation. */
13307
+ function onQueuedActivationSubmitted(ds, activationToken) {
13308
+ return releaseQueuedActivationReservation(ds, activationToken);
13309
+ }
13310
+ export const __testOnly_onQueuedActivationSubmitted = onQueuedActivationSubmitted;
13311
+ /** Fork an ordinary opening turn and release its route reservation. There is
13312
+ * deliberately no await between the final buffered-input snapshot, fork, and
13313
+ * release: a later handler either buffers before this block or observes the
13314
+ * live worker after it. */
13315
+ function forkReservedInitialSession(ds, availableBots) {
13316
+ const userPrompt = ds.pendingPrompt ?? '';
13317
+ const input = buildReservedInitialInput(ds, availableBots);
13318
+ rememberLastCliInput(ds, userPrompt, input);
13319
+ const turnId = ds.pendingTurnId ?? ds.session.pendingRepoSetup?.turnId;
13320
+ forkWorker(ds, input, turnId ? { turnId } : false);
13321
+ ds.pendingTurnId = undefined;
13322
+ // A no-project pendingRepo fallback enters forkWorker with `session.queued`
13323
+ // still set. forkWorker materializes the exact opening as a tokened
13324
+ // activation journal before returning; keep the route gate until the
13325
+ // adapter-level ACK so a live-worker follower cannot bypass its FIFO tail.
13326
+ const waitsForQueuedAck = ds.session.queuedActivationPending === true;
13327
+ ds.initialStartPending = waitsForQueuedAck
13328
+ || (ds.session.queuedActivationTail?.length ?? 0) > 0
13329
+ || (ds.queuedActivationTailAdmissionsOutstanding ?? 0) > 0;
13330
+ clearInitialStartBuffers(ds);
13331
+ if (!waitsForQueuedAck && ds.initialStartPending) {
13332
+ // A follower may have reserved FIFO order while the opening prompt was
13333
+ // still being built. Hand off now, or defer until its reservation settles.
13334
+ void releaseQueuedActivationReservation(ds);
13335
+ }
13336
+ }
13337
+ /** Raw slash-command cold starts type the raw command only after prompt_ready.
13338
+ * Buffer later same-anchor messages into the ordered follow-up payload carried
13339
+ * on that same raw_input IPC; a separate worker message could overtake the
13340
+ * command's text→Enter beat. */
13341
+ function forkReservedInitialRawSession(ds, availableBots) {
13342
+ const hasBufferedInput = (ds.pendingPrompt?.trim().length ?? 0) > 0
13343
+ || (ds.pendingAttachments?.length ?? 0) > 0
13344
+ || (ds.pendingFollowUps?.length ?? 0) > 0;
13345
+ if (hasBufferedInput) {
13346
+ ensureSessionWhiteboard(ds);
13347
+ const input = buildReservedInitialInput(ds, availableBots);
13348
+ const botCfg = getBot(ds.larkAppId).config;
13349
+ const bufferedGateDecisions = ds.pendingCodexAppFollowUpGateAccepted ?? [];
13350
+ const bufferedCodexAppInputAccepted = bufferedGateDecisions.length > 0
13351
+ ? bufferedGateDecisions.every(Boolean)
13352
+ : codexAppCleanInputAcceptedForSession(ds);
13353
+ ds.pendingFollowUpInput = {
13354
+ userPrompt: ds.pendingCodexAppText !== undefined || ds.pendingCodexAppFollowUps
13355
+ ? [ds.pendingCodexAppText ?? '', ...(ds.pendingCodexAppFollowUps ?? [])].filter(Boolean).join('\n\n')
13356
+ : ds.pendingPrompt || ds.pendingFollowUps?.join('\n\n') || '',
13357
+ cliInput: input.content,
13358
+ ...((ds.session.cliId === 'codex-app' || botCfg.cliId === 'codex-app')
13359
+ && bufferedCodexAppInputAccepted
13360
+ && input.codexAppInput
13361
+ ? { codexAppInput: input.codexAppInput }
13362
+ : {}),
13363
+ codexAppInputGateFrozen: true,
13364
+ };
13365
+ }
13366
+ const rawInput = ds.pendingRawInput ?? '';
13367
+ rememberLastCliInput(ds, rawInput, rawInput);
13368
+ // A non-owner turn can reserve/admit a durable successor while this literal
13369
+ // raw opening is still preparing. Arm the same tokened activation journal
13370
+ // used by pending-repo raw starts so that successor is released only after
13371
+ // both the raw text and Enter have crossed the adapter boundary.
13372
+ const armRawActivationAck = !ds.session.queuedActivationPending
13373
+ && ((ds.session.queuedActivationTail?.length ?? 0) > 0
13374
+ || (ds.queuedActivationTailAdmissionsOutstanding ?? 0) > 0);
13375
+ if (armRawActivationAck)
13376
+ ds.session.queued = true;
13377
+ try {
13378
+ forkWorker(ds, '', false);
13379
+ }
13380
+ catch (err) {
13381
+ // forkWorker's pre-init compensation restores its queued snapshot. This
13382
+ // synthetic marker belongs only to the raw ACK fence, not the Dashboard
13383
+ // backlog, so remove it while retaining pendingRawInput for a real retry.
13384
+ if (armRawActivationAck && ds.session.queued === true) {
13385
+ ds.session.queued = false;
13386
+ sessionStore.updateSession(ds.session);
13387
+ }
13388
+ throw err;
13389
+ }
13390
+ // Raw text and Enter are a single adapter-submission boundary. When this
13391
+ // cold start came through the durable pendingRepo fallback, retain the gate
13392
+ // through that ACK so a follower cannot be sent between those two beats.
13393
+ ds.initialStartPending = ds.session.queuedActivationPending === true;
13394
+ clearInitialStartBuffers(ds);
13395
+ }
12826
13396
  async function replyInvalidWorkingDirs(anchor, larkAppId, ds) {
12827
13397
  const bot = getBot(larkAppId);
12828
13398
  const invalid = invalidWorkingDirs({
@@ -12832,7 +13402,9 @@ async function replyInvalidWorkingDirs(anchor, larkAppId, ds) {
12832
13402
  if (invalid.length === 0)
12833
13403
  return false;
12834
13404
  ds.pendingRepo = false;
12835
- activeSessions.delete(sessionKey(anchor, larkAppId));
13405
+ const key = activeSessionKey(ds);
13406
+ if (activeSessions.get(key) === ds)
13407
+ activeSessions.delete(key);
12836
13408
  sessionStore.closeSession(ds.session.sessionId);
12837
13409
  publishClosedSessionPatch(ds.session.sessionId, ds.session.closedAt ? Date.parse(ds.session.closedAt) : undefined);
12838
13410
  const msg = tr('cmd.repo.working_dir_not_exist', {
@@ -12860,6 +13432,39 @@ function fastToggleUnsupportedBackend(ds) {
12860
13432
  return true;
12861
13433
  return ds.initConfig?.codexRpcInput === true;
12862
13434
  }
13435
+ /** Three-state is-bot for a turn's SENDER (candidate display, not routing):
13436
+ * true = provably a bot (platform sender_type app/bot, OR a known peer via the
13437
+ * foreign-bot signal); false = provably human (sender_type === 'user'); and
13438
+ * undefined = unknown (sender_type missing/other AND not a recognised peer) —
13439
+ * must NOT be coerced to "human". Distinct from the routing/owner boolean
13440
+ * `isForeignBot*`, which is false for both "human" and "unknown". */
13441
+ function senderIsBotTriState(senderType, isForeignBot) {
13442
+ if (isForeignBot || senderType === 'app' || senderType === 'bot')
13443
+ return true;
13444
+ if (senderType === 'user')
13445
+ return false;
13446
+ return undefined;
13447
+ }
13448
+ /** Extract post rich-text inline `at` participants from the given message(s),
13449
+ * concatenated (NOT key/name-merged — the core dedupes by open_id, and stable
13450
+ * cross-message identity is open/app id, never the per-message key). Pass the
13451
+ * current message and, when a forward seed folds into the same turn, the seed
13452
+ * too. Kept separate from buildTurnParticipants so a registration-race loser's
13453
+ * winner can carry the ALREADY-extracted seed+follow-up set through prepared
13454
+ * without re-depending on ctx.forwardSeedData. */
13455
+ function collectPostAtMentions(...messages) {
13456
+ return messages.flatMap(m => extractPostAtParticipants(m));
13457
+ }
13458
+ /** Build the turn-window counterparts contributed by ONE inbound turn via the
13459
+ * pure {@link buildTurnParticipantsFrom}, supplying the daemon deps (self
13460
+ * open_id + self app_id for self-exclusion of app_id-form self @s + the
13461
+ * peer-bot predicate). `postAtMentions` are post inline-@ participants already
13462
+ * extracted (see collectPostAtMentions) — concatenated with structured
13463
+ * `mentions`. See that helper for the contract. */
13464
+ function buildTurnParticipants(larkAppId, senderOpenId, senderIsBot, mentions, senderName, postAtMentions) {
13465
+ const selfBot = getBot(larkAppId);
13466
+ return buildTurnParticipantsFrom({ openId: senderOpenId, isBot: senderIsBot, name: senderName }, [...(mentions ?? []), ...(postAtMentions ?? [])], selfBot.botOpenId, (openId) => isKnownPeerBot(config.session.dataDir, larkAppId, openId), selfBot.config.larkAppId);
13467
+ }
12863
13468
  /** Preserve the established mid-session passthrough semantics when a cold-start
12864
13469
  * scratch loses its registration race to a concurrently-created real session. */
12865
13470
  function deliverPassthroughToExistingSession(ds, cmd, commandContent, anchor, larkAppId, turn) {
@@ -12873,9 +13478,14 @@ function deliverPassthroughToExistingSession(ds, cmd, commandContent, anchor, la
12873
13478
  const substituteReplyMode = turn.substitute
12874
13479
  ? (getBot(larkAppId).config.substituteMode?.replyMode ?? 'thread')
12875
13480
  : 'thread';
13481
+ // Passthrough is a raw CLI command (no @-mentions) — window is the sender only.
13482
+ const passthroughWindow = buildTurnParticipants(larkAppId, turn.senderOpenId, turn.senderIsBot, undefined);
12876
13483
  beginReplyTargetTurn(ds, turn.replyRootId, turn.messageId, new Date().toISOString(), {
12877
13484
  quoteOnly: substituteReplyMode === 'quote',
12878
13485
  substitute: turn.substitute,
13486
+ senderOpenId: turn.senderOpenId,
13487
+ participants: passthroughWindow.participants,
13488
+ participantsIncomplete: passthroughWindow.incomplete,
12879
13489
  });
12880
13490
  if (turn.senderOpenId && ds.session.lastCallerOpenId !== turn.senderOpenId) {
12881
13491
  ds.session.lastCallerOpenId = turn.senderOpenId;
@@ -12903,10 +13513,17 @@ function deliverPassthroughToExistingSession(ds, cmd, commandContent, anchor, la
12903
13513
  }
12904
13514
  export const __testOnly_deliverPassthroughToExistingSession = deliverPassthroughToExistingSession;
12905
13515
  async function startInitialPassthroughSession(args) {
12906
- const { larkAppId, chatId, chatType, scope, anchor, messageId, replyRootId, parsed, cmd, commandContent, senderOpenId, substitute, senderUnionId, memberUnionId, ownerOpenId, ownerUnionId, creatorOpenId, botSender, } = args;
13516
+ const { larkAppId, chatId, chatType, scope, anchor, messageId, replyRootId, parsed, cmd, commandContent, senderOpenId, substitute, senderUnionId, memberUnionId, ownerOpenId, ownerUnionId, creatorOpenId, botSender, senderIsBot, routeToCanonicalOwner, } = args;
12907
13517
  if (!await enforceMessageQuotaForCliInput(larkAppId, chatId, senderOpenId, messageId, anchor, senderUnionId, memberUnionId, chatType, botSender)) {
12908
13518
  return;
12909
13519
  }
13520
+ // Reply attribution's is-bot. `resolvedSenderIsBot` is a BOOLEAN for the
13521
+ // legacy consumers that need one (pendingSender.type, quoteTargetSenderIsBot,
13522
+ // loser-handoff turn.senderIsBot). `resolvedSenderIsBotTriState` keeps
13523
+ // "unknown" as undefined for the participant candidate label — never coerce an
13524
+ // unknown sender to "human". NOT for quota (that stays on botSender above).
13525
+ const resolvedSenderIsBotTriState = senderIsBotTriState(parsed.senderType, senderIsBot === true);
13526
+ const resolvedSenderIsBot = senderIsBot ?? (parsed.senderType === 'app' || parsed.senderType === 'bot');
12910
13527
  const botCfg = getBot(larkAppId).config;
12911
13528
  refreshCliVersion(botCfg);
12912
13529
  const directChatSender = chatType === 'p2p'
@@ -12918,7 +13535,7 @@ async function startInitialPassthroughSession(args) {
12918
13535
  const initialPassthroughSender = directChatSender ?? (senderOpenId
12919
13536
  ? {
12920
13537
  openId: senderOpenId,
12921
- type: parsed.senderType === 'app' || parsed.senderType === 'bot' ? 'bot' : 'user',
13538
+ type: resolvedSenderIsBot ? 'bot' : 'user',
12922
13539
  }
12923
13540
  : undefined);
12924
13541
  const rootIdForStore = scope === 'thread' ? anchor : messageId;
@@ -12932,12 +13549,10 @@ async function startInitialPassthroughSession(args) {
12932
13549
  session.lastCallerOpenId = senderOpenId;
12933
13550
  session.quoteTargetId = parsed.messageId;
12934
13551
  session.quoteTargetSenderOpenId = senderOpenId;
12935
- session.quoteTargetSenderIsBot = parsed.senderType === 'app' || parsed.senderType === 'bot';
13552
+ session.quoteTargetSenderIsBot = resolvedSenderIsBot;
12936
13553
  session.lastMessageAt = new Date(now).toISOString();
12937
13554
  session.scope = scope;
12938
13555
  sessionStore.updateSession(session);
12939
- messageQueue.ensureQueue(anchor);
12940
- messageQueue.appendMessage(anchor, { ...parsed, content: commandContent });
12941
13556
  const { pinnedWorkingDir, oncallEntry, inheritedFrom, pinnedFromBotDefault } = await resolvePinnedWorkingDir({ scope, anchor, chatId, chatType, larkAppId });
12942
13557
  // Auto-worktree: register PENDING (router buffers, no force-fork) and build the
12943
13558
  // worktree off the critical path (see willAutoWorktree / runAutoWorktreeCommit).
@@ -12955,6 +13570,7 @@ async function startInitialPassthroughSession(args) {
12955
13570
  cliVersion: cliVersionCache.get(cliRuntimeVersionKey(botCfg))?.version ?? 'unknown',
12956
13571
  lastMessageAt: now,
12957
13572
  hasHistory: false,
13573
+ initialStartPending: true,
12958
13574
  pendingRepo: !pinnedWorkingDir || autoWt,
12959
13575
  pendingPrompt: '',
12960
13576
  pendingRawInput: commandContent,
@@ -12968,30 +13584,30 @@ async function startInitialPassthroughSession(args) {
12968
13584
  ds.session.workingDir = pinnedWorkingDir;
12969
13585
  sessionStore.updateSession(ds.session);
12970
13586
  }
12971
- beginReplyTargetTurn(ds, replyRootId, messageId);
13587
+ const initialWindow = buildTurnParticipants(larkAppId, senderOpenId, resolvedSenderIsBotTriState, undefined, initialPassthroughSender?.name);
13588
+ beginReplyTargetTurn(ds, replyRootId, messageId, new Date().toISOString(), { senderOpenId, participants: initialWindow.participants, participantsIncomplete: initialWindow.incomplete });
12972
13589
  sessionStore.updateSession(ds.session);
12973
- const creationKey = sessionKey(anchor, larkAppId);
12974
- if (!setActiveSessionIfActive(activeSessions, creationKey, ds)) {
12975
- const winner = await rollbackRejectedSessionAndGetWinner(activeSessions, creationKey, ds);
12976
- if (winner) {
12977
- logger.info(`[${session.sessionId.substring(0, 8)}] Initial passthrough registration lost to ` +
12978
- `${winner.session.sessionId.substring(0, 8)}; handing ${cmd} to routing winner`);
12979
- deliverPassthroughToExistingSession(winner, cmd, commandContent, anchor, larkAppId, {
12980
- messageId,
12981
- replyRootId,
12982
- senderOpenId,
12983
- senderIsBot: parsed.senderType === 'app' || parsed.senderType === 'bot',
12984
- substitute,
12985
- });
12986
- }
12987
- else {
12988
- void sessionReply(anchor, tr('daemon.cmd_needs_active_cli', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
13590
+ const registration = await claimNewDaemonSession(activeSessions, ds);
13591
+ if (!registration.accepted) {
13592
+ if (registration.reason === 'existing_owner'
13593
+ && activeSessions.get(registration.key) === registration.owner) {
13594
+ await routeToCanonicalOwner();
12989
13595
  }
12990
13596
  return;
12991
13597
  }
13598
+ if (ds.pendingRepo) {
13599
+ stageClaimedPendingRepoSetup(activeSessions, ds, {
13600
+ mode: autoWt ? 'auto_worktree' : 'picker',
13601
+ ...(autoWt && pinnedWorkingDir ? { baseDir: pinnedWorkingDir } : {}),
13602
+ turnId: messageId,
13603
+ });
13604
+ }
13605
+ messageQueue.ensureQueue(anchor);
13606
+ messageQueue.appendMessage(anchor, { ...parsed, content: commandContent });
12992
13607
  if (pinnedWorkingDir && autoWt) {
12993
13608
  if (await replyInvalidWorkingDirs(anchor, larkAppId, ds))
12994
13609
  return;
13610
+ ds.initialStartPending = false; // pendingRepo/worktree now owns buffering
12995
13611
  // 挂起态提交:worktree 建好后经 runAutoWorktreeCommit → commitRepoSelection 拉起
12996
13612
  // pendingRawInput 冷启动会话。detach → 立即返回,不阻塞本条消息处理。
12997
13613
  startAutoWorktreePending(ds, { anchor, baseDir: pinnedWorkingDir, title: session.title, prompt: commandContent, operatorOpenId: ownerOpenId });
@@ -13000,10 +13616,8 @@ async function startInitialPassthroughSession(args) {
13000
13616
  if (pinnedWorkingDir) {
13001
13617
  if (await replyInvalidWorkingDirs(anchor, larkAppId, ds))
13002
13618
  return;
13003
- rememberLastCliInput(ds, commandContent, commandContent);
13004
- // Raw passthrough gets its human turn only at the literal PTY write
13005
- // boundary (prompt_ready -> raw_input), never during CLI startup.
13006
- forkWorker(ds, '', false);
13619
+ const availableBots = await getAvailableBots(larkAppId, chatId);
13620
+ forkReservedInitialRawSession(ds, availableBots);
13007
13621
  const reason = oncallEntry
13008
13622
  ? `oncall-bound chat ${chatId}`
13009
13623
  : inheritedFrom
@@ -13017,16 +13631,18 @@ async function startInitialPassthroughSession(args) {
13017
13631
  const scanDirs = getProjectScanDirs(ds).filter(d => existsSync(d));
13018
13632
  const projects = scanDirs.length > 0 ? scanMultipleProjects(scanDirs, 3, repoPickerScanOptions()) : [];
13019
13633
  if (projects.length > 0) {
13634
+ ds.initialStartPending = false; // pendingRepo/card now owns buffering
13020
13635
  lastRepoScan.set(chatId, projects);
13021
13636
  const cardJson = buildRepoSelectCard(projects, getSessionWorkingDir(ds), anchor, localeForBot(larkAppId), getBot(larkAppId).config.worktreeMultiPicker);
13022
13637
  ds.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
13638
+ persistPendingRepoCardMessageId(ds, ds.repoCardMessageId);
13023
13639
  announcePendingRepoSession(ds);
13024
13640
  logger.info(`[${tag(ds)}] Waiting for repo selection before initial raw passthrough (${projects.length} projects)`);
13025
13641
  return;
13026
13642
  }
13027
13643
  ds.pendingRepo = false;
13028
- rememberLastCliInput(ds, commandContent, commandContent);
13029
- forkWorker(ds, '', false);
13644
+ const availableBots = await getAvailableBots(larkAppId, chatId);
13645
+ forkReservedInitialRawSession(ds, availableBots);
13030
13646
  logger.info(`[${tag(ds)}] No projects to select, queued initial raw passthrough ${commandContent.substring(0, 40)}`);
13031
13647
  }
13032
13648
  function vcMeetingApplicationContext(ctx) {
@@ -13046,6 +13662,34 @@ function mergeVcMeetingApplicationContext(existing, incoming) {
13046
13662
  return `${[...new Set(lines)].join('\n')}\n`;
13047
13663
  }
13048
13664
  async function handleNewTopic(data, ctx) {
13665
+ return withBotTurnAdmission(ctx.larkAppId, () => handleNewTopicAdmitted(data, ctx));
13666
+ }
13667
+ /**
13668
+ * Codex App steer authorization (Blocking 1, decision A) from INBOUND SOURCE
13669
+ * FACTS only — the single source of truth shared by both admission twins
13670
+ * (handleNewTopicAdmitted + handleThreadReplyAdmitted), so a plain-human turn is
13671
+ * authorized identically whether it opens a new topic or continues one (R6/R7-B1).
13672
+ *
13673
+ * FAIL-CLOSED by construction (R7-B1): authorization requires a POSITIVE
13674
+ * `humanSender` (senderType === 'user' AND not a known peer bot) AND the absence
13675
+ * of every control-rewrite / dedicated-receiver signal. Excluding a list of
13676
+ * known non-human sources is NOT enough — an exclude-list is never complete, so
13677
+ * any un-enumerated non-user source would fail OPEN. The positive humanSender
13678
+ * gate makes the default deny: anything that is not provably a real human turn
13679
+ * with no special semantics stays forced-serial.
13680
+ */
13681
+ function computeCodexAppSteerable(facts) {
13682
+ return facts.humanSender
13683
+ && !facts.adopted
13684
+ && !facts.isForeignBot
13685
+ && !facts.isBotSenderType
13686
+ && !facts.substituteTrigger
13687
+ && !facts.controlRewrite
13688
+ && !facts.messageListener
13689
+ && !facts.vcMeetingReceiver
13690
+ && !facts.vcMeetingImTurnOrigin;
13691
+ }
13692
+ async function handleNewTopicAdmitted(data, ctx) {
13049
13693
  const { chatId, messageId, chatType, larkAppId, replyRootId, substituteTrigger, messageListener } = ctx;
13050
13694
  // scope/anchor are mutable here: `/t` / `/topic` may flip a 普通群 chat-scope
13051
13695
  // routing into thread-scope so the bot's first reply seeds a Lark thread.
@@ -13260,11 +13904,20 @@ async function handleNewTopic(data, ctx) {
13260
13904
  senderUnionId: teamTrustUnionId,
13261
13905
  memberUnionId: senderUnionId, // 原始 union(人腿),不锁 bot
13262
13906
  botSender: isBotSenderType,
13907
+ // Reply attribution uses the cross-ref-resolved is-bot (foreign peer
13908
+ // bots included), matching the twin new-topic spawn path so冷启动
13909
+ // passthrough 也能让 bot→bot 的 --mention-back 直通不对称门禁。
13910
+ senderIsBot: isForeignBotSender,
13263
13911
  // New-topic senders are humans here (mirrors the normal new-topic
13264
13912
  // spawn path, which assigns ownership unconditionally too).
13265
13913
  ownerOpenId: senderOpenId,
13266
13914
  ownerUnionId: senderUnionId,
13267
13915
  creatorOpenId: senderOpenId,
13916
+ routeToCanonicalOwner: () => handleThreadReplyAdmitted(data, {
13917
+ ...ctx,
13918
+ scope,
13919
+ anchor,
13920
+ }),
13268
13921
  });
13269
13922
  return;
13270
13923
  }
@@ -13338,7 +13991,7 @@ async function handleNewTopic(data, ctx) {
13338
13991
  cmdPending = { pendingRepo: true, pendingPrompt: '', workingDir: pinnedWorkingDir };
13339
13992
  }
13340
13993
  sessionStore.updateSession(session);
13341
- const commandDs = {
13994
+ const cmdDs = {
13342
13995
  session,
13343
13996
  worker: null,
13344
13997
  workerPort: null,
@@ -13354,13 +14007,16 @@ async function handleNewTopic(data, ctx) {
13354
14007
  ownerOpenId: senderOpenId,
13355
14008
  ...cmdPending,
13356
14009
  };
13357
- const commandKey = sessionKey(anchor, larkAppId);
13358
- if (!setActiveSessionIfActive(activeSessions, commandKey, commandDs)) {
13359
- const winner = await rollbackRejectedSessionAndGetWinner(activeSessions, commandKey, commandDs);
13360
- if (!winner)
14010
+ const registration = await claimNewDaemonSession(activeSessions, cmdDs);
14011
+ if (!registration.accepted) {
14012
+ if (registration.reason !== 'existing_owner')
13361
14013
  return;
13362
- logger.info(`[${session.sessionId.substring(0, 8)}] Daemon-command registration lost to ` +
13363
- `${winner.session.sessionId.substring(0, 8)}; handing ${cmd} to routing winner`);
14014
+ }
14015
+ else if (cmdDs.pendingRepo) {
14016
+ stageClaimedPendingRepoSetup(activeSessions, cmdDs, {
14017
+ mode: 'picker',
14018
+ turnId: messageId,
14019
+ });
13364
14020
  }
13365
14021
  // Pass mention-stripped content so /command argument parsing works.
13366
14022
  await handleCommand(cmd, anchor, { ...parsed, content: commandContent }, commandDeps, larkAppId);
@@ -13462,8 +14118,6 @@ async function handleNewTopic(data, ctx) {
13462
14118
  session.scope = scope;
13463
14119
  session.nativeSessionTitle = buildBotmuxLarkNativeSessionTitle(parsed.content, parsed.mentions, groupChatName);
13464
14120
  sessionStore.updateSession(session);
13465
- messageQueue.ensureQueue(anchor);
13466
- messageQueue.appendMessage(anchor, parsed);
13467
14121
  // Control card compensates for the card-less (avatar-style) chat-scope
13468
14122
  // substitute session. Topic-group substitute sessions (#475) are thread-scope
13469
14123
  // and keep their normal streaming card — no compensation needed there.
@@ -13484,6 +14138,7 @@ async function handleNewTopic(data, ctx) {
13484
14138
  cliVersion: cliVersionCache.get(cliRuntimeVersionKey(botCfg))?.version ?? 'unknown',
13485
14139
  lastMessageAt: now,
13486
14140
  hasHistory: false,
14141
+ initialStartPending: true,
13487
14142
  pendingRepo: !pinnedWorkingDir || autoWt,
13488
14143
  pendingPrompt: promptContent,
13489
14144
  pendingTurnId: messageId,
@@ -13495,6 +14150,29 @@ async function handleNewTopic(data, ctx) {
13495
14150
  pendingSubstituteTrigger: substituteTrigger,
13496
14151
  pendingSubstituteControlCard: shouldSendSubstituteControlCard,
13497
14152
  pendingSender: newTopicSender,
14153
+ // R6-B1: freeze the plain-human steer authorization into the NEW-TOPIC opening
14154
+ // metadata from inbound source facts (the same helper the follow-up twin uses).
14155
+ // A brand-new session has no VC receiver yet; foreign-bot maps to the bot-sender
14156
+ // fact here. buildReservedInitialInput COPIES this onto the opening payload;
14157
+ // forkReservedInitialSession never infers it.
14158
+ ...(computeCodexAppSteerable({
14159
+ // R7-B1: positive real-human gate — senderType must be 'user' AND not a
14160
+ // known peer bot (cross-ref fallback, matching the thread twin's foreign-bot
14161
+ // definition; an anomalous/missing sender_type from a known peer must not
14162
+ // be authorized). controlRewrite reflects the ACTUAL v3-grill trigger, not
14163
+ // a hardcoded false — a rewritten /workflow prompt stays serial.
14164
+ humanSender: parsed.senderType === 'user'
14165
+ && !isKnownPeerBot(config.session.dataDir, larkAppId, senderOpenId),
14166
+ adopted: false,
14167
+ isForeignBot: isBotSenderType
14168
+ || isKnownPeerBot(config.session.dataDir, larkAppId, senderOpenId),
14169
+ isBotSenderType,
14170
+ substituteTrigger: !!substituteTrigger,
14171
+ controlRewrite: !!newTopicGrill,
14172
+ messageListener: !!messageListener,
14173
+ vcMeetingReceiver: false,
14174
+ vcMeetingImTurnOrigin: !!ctx.vcMeetingImTurnOrigin,
14175
+ }) ? { pendingCodexAppSteerable: true } : {}),
13498
14176
  ownerOpenId: ownerOpenIdForSession,
13499
14177
  currentTurnTitle: initialTurnTitle,
13500
14178
  workingDir: pinnedWorkingDir,
@@ -13506,32 +14184,54 @@ async function handleNewTopic(data, ctx) {
13506
14184
  const substituteReplyMode = substituteTrigger
13507
14185
  ? (botCfg.substituteMode?.replyMode ?? 'thread')
13508
14186
  : 'thread';
13509
- beginReplyTargetTurn(ds, replyRootId, messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger });
14187
+ // Post inline-@ participants from BOTH the current message and a folded
14188
+ // forward seed — extracted once so the CAS-loser handoff below can carry the
14189
+ // exact same set (a race-losing scratch's seed @s must not vanish).
14190
+ const newTopicPostAt = collectPostAtMentions(data?.message, ctx.forwardSeedData?.message);
14191
+ const newTopicWindow = buildTurnParticipants(larkAppId, senderOpenId, senderIsBotTriState(parsed.senderType, isForeignBotSender), parsed.mentions, newTopicSender?.name, newTopicPostAt);
14192
+ beginReplyTargetTurn(ds, replyRootId, messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId, participants: newTopicWindow.participants, participantsIncomplete: newTopicWindow.incomplete });
13510
14193
  sessionStore.updateSession(ds.session);
13511
- const creationKey = sessionKey(anchor, larkAppId);
13512
- if (!setActiveSessionIfActive(activeSessions, creationKey, ds)) {
13513
- const winner = await rollbackRejectedSessionAndGetWinner(activeSessions, creationKey, ds);
13514
- if (winner) {
13515
- logger.info(`[${session.sessionId.substring(0, 8)}] New-topic registration lost to ` +
13516
- `${winner.session.sessionId.substring(0, 8)}; handing message to routing winner`);
13517
- await handleThreadReply(data, { ...ctx, scope, anchor }, {
14194
+ const registration = await claimNewDaemonSession(activeSessions, ds);
14195
+ if (!registration.accepted) {
14196
+ if (registration.reason === 'existing_owner'
14197
+ && activeSessions.get(registration.key) === registration.owner) {
14198
+ // Registration-race loser reroute to the canonical owner. Pass the FULLY
14199
+ // PREPARED reply (merged seed+follow-up structured mentions, resources,
14200
+ // attachments, resolved sender, pre-extracted post @s) so the winner rebinds
14201
+ // the turn with the COMPLETE window. Rerouting bare `handleThreadReplyAdmitted
14202
+ // (data, ctx)` would re-parse only `data` and drop the forward seed's
14203
+ // structured message.mentions[] (post @s alone are not enough) — under-
14204
+ // counting the turn-window and mis-releasing --mention-back. Direct-admitted
14205
+ // (not handleThreadReply) so we don't re-enter the #597 admission lock.
14206
+ await handleThreadReplyAdmitted(data, { ...ctx, scope, anchor }, {
13518
14207
  parsed,
13519
14208
  resources,
13520
14209
  attachments,
13521
14210
  quotaChecked: true,
13522
- queueAlreadyAppended: true,
14211
+ queueAlreadyAppended: false,
13523
14212
  senderResolved: true,
13524
14213
  sender: newTopicSender,
14214
+ postParticipantMentions: newTopicPostAt,
13525
14215
  });
13526
14216
  }
13527
14217
  return;
13528
14218
  }
14219
+ if (ds.pendingRepo) {
14220
+ stageClaimedPendingRepoSetup(activeSessions, ds, {
14221
+ mode: autoWt ? 'auto_worktree' : 'picker',
14222
+ ...(autoWt && pinnedWorkingDir ? { baseDir: pinnedWorkingDir } : {}),
14223
+ turnId: messageId,
14224
+ });
14225
+ }
14226
+ messageQueue.ensureQueue(anchor);
14227
+ messageQueue.appendMessage(anchor, parsed);
13529
14228
  // Auto-worktree: session is registered PENDING; build the worktree off the
13530
14229
  // critical path, then commitRepoSelection pins it + forks (folding in any
13531
14230
  // messages buffered during creation). detach → return immediately.
13532
14231
  if (pinnedWorkingDir && autoWt) {
13533
14232
  if (await replyInvalidWorkingDirs(anchor, larkAppId, ds))
13534
14233
  return;
14234
+ ds.initialStartPending = false; // pendingRepo/worktree now owns buffering
13535
14235
  startAutoWorktreePending(ds, { anchor, baseDir: pinnedWorkingDir, title: session.title, prompt: promptContent, operatorOpenId: senderOpenId });
13536
14236
  return;
13537
14237
  }
@@ -13539,13 +14239,10 @@ async function handleNewTopic(data, ctx) {
13539
14239
  if (pinnedWorkingDir) {
13540
14240
  if (await replyInvalidWorkingDirs(anchor, larkAppId, ds))
13541
14241
  return;
13542
- const selfBot = getBot(larkAppId);
13543
14242
  ensureSessionWhiteboard(ds);
13544
- const prompt = buildNewTopicCliInput(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), newTopicSender, { larkAppId, chatId, whiteboardId: ds.session.whiteboardId, substituteTrigger, codexAppText: codexAppVisibleText, codexAppApplicationContext, codexAppMessageContext });
14243
+ const availableBots = await getAvailableBots(larkAppId, chatId);
13545
14244
  await noteTurnReceived(ds, messageId, content, newTopicSender, messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
13546
- rememberLastCliInput(ds, promptContent, prompt);
13547
- forkWorker(ds, prompt, { turnId: messageId });
13548
- ds.pendingTurnId = undefined;
14245
+ forkReservedInitialSession(ds, availableBots);
13549
14246
  const reason = oncallEntry
13550
14247
  ? `oncall-bound chat ${chatId}`
13551
14248
  : inheritedFrom
@@ -13563,23 +14260,22 @@ async function handleNewTopic(data, ctx) {
13563
14260
  projects = scanMultipleProjects(scanDirs, 3, repoPickerScanOptions());
13564
14261
  }
13565
14262
  if (projects.length > 0) {
14263
+ ds.initialStartPending = false; // pendingRepo/card now owns buffering
13566
14264
  lastRepoScan.set(chatId, projects);
13567
14265
  const currentCwd = getSessionWorkingDir(ds);
13568
14266
  const cardJson = buildRepoSelectCard(projects, currentCwd, anchor, localeForBot(larkAppId), getBot(larkAppId).config.worktreeMultiPicker);
13569
14267
  ds.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
14268
+ persistPendingRepoCardMessageId(ds, ds.repoCardMessageId);
13570
14269
  announcePendingRepoSession(ds);
13571
14270
  logger.info(`[${tag(ds)}] Waiting for repo selection (${projects.length} projects)`);
13572
14271
  }
13573
14272
  else {
13574
14273
  // No projects found — skip repo selection, spawn directly
13575
14274
  ds.pendingRepo = false;
13576
- const selfBot = getBot(larkAppId);
13577
14275
  ensureSessionWhiteboard(ds);
13578
- const prompt = buildNewTopicCliInput(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), newTopicSender, { larkAppId, chatId, whiteboardId: ds.session.whiteboardId, substituteTrigger, codexAppText: codexAppVisibleText, codexAppApplicationContext, codexAppMessageContext });
14276
+ const availableBots = await getAvailableBots(larkAppId, chatId);
13579
14277
  await noteTurnReceived(ds, messageId, content, newTopicSender, messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
13580
- rememberLastCliInput(ds, promptContent, prompt);
13581
- forkWorker(ds, prompt, { turnId: messageId });
13582
- ds.pendingTurnId = undefined;
14278
+ forkReservedInitialSession(ds, availableBots);
13583
14279
  logger.info(`Session ${session.sessionId} ready (no projects to select), total active: ${getActiveCount()}`);
13584
14280
  }
13585
14281
  }
@@ -13814,6 +14510,7 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
13814
14510
  cliVersion: cliVersionCache.get(cliRuntimeVersionKey(botCfg))?.version ?? 'unknown',
13815
14511
  lastMessageAt: now,
13816
14512
  hasHistory: false,
14513
+ initialStartPending: true,
13817
14514
  pendingRepo: !pinnedWorkingDir || autoWt,
13818
14515
  pendingPrompt: promptBody,
13819
14516
  pendingCodexAppText: botCfg.cliId === 'codex-app' ? codexAppText : undefined,
@@ -13823,10 +14520,20 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
13823
14520
  workingDir: pinnedWorkingDir,
13824
14521
  pendingTurnId: undefined,
13825
14522
  };
13826
- if (!setActiveSessionIfActive(activeSessions, dsKey, ds)) {
13827
- await closeSessionHelper(session.sessionId);
14523
+ const registration = await claimNewDaemonSession(activeSessions, ds);
14524
+ if (!registration.accepted) {
14525
+ if (activeSessions.get(registration.key) === registration.owner) {
14526
+ groupJoinAnchorByChat.set(chatLiveKey, registration.key);
14527
+ }
13828
14528
  return;
13829
14529
  }
14530
+ if (ds.pendingRepo) {
14531
+ stageClaimedPendingRepoSetup(activeSessions, ds, {
14532
+ mode: autoWt ? 'auto_worktree' : 'picker',
14533
+ ...(autoWt && pinnedWorkingDir ? { baseDir: pinnedWorkingDir } : {}),
14534
+ turnId: anchor,
14535
+ });
14536
+ }
13830
14537
  const joinBootstrapExternallyTakenOver = () => (activeSessions.get(dsKey) !== ds
13831
14538
  || ds.session.status !== 'active'
13832
14539
  || !!(ds.worker && !ds.worker.killed));
@@ -13921,14 +14628,13 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
13921
14628
  // Register the anchor so a later duplicate bot.added for this chat is deduped
13922
14629
  // even in 话题群 (where dsKey is the seed id, not chatId).
13923
14630
  groupJoinAnchorByChat.set(chatLiveKey, dsKey);
13924
- const selfBot = getBot(larkAppId);
13925
- const buildPrompt = async () => buildNewTopicCliInput(promptBody, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, undefined, undefined, await getAvailableBots(larkAppId, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), undefined, { larkAppId, chatId, whiteboardId: ds.session.whiteboardId, codexAppText, chatContext });
13926
14631
  // Auto-worktree: register PENDING, build worktree off-path, commit+fork later.
13927
14632
  if (pinnedWorkingDir && autoWt) {
13928
14633
  if (await replyInvalidWorkingDirs(anchor, larkAppId, ds) || joinBootstrapWasTakenOver()) {
13929
14634
  withdrawSharedReplySeed();
13930
14635
  return;
13931
14636
  }
14637
+ ds.initialStartPending = false; // pendingRepo/worktree now owns buffering
13932
14638
  armSharedReplyTarget();
13933
14639
  startAutoWorktreePending(ds, { anchor, baseDir: pinnedWorkingDir, title, prompt: promptBody, operatorOpenId });
13934
14640
  return;
@@ -13940,7 +14646,7 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
13940
14646
  return;
13941
14647
  }
13942
14648
  ensureSessionWhiteboard(ds);
13943
- const prompt = await buildPrompt();
14649
+ const availableBots = await getAvailableBots(larkAppId, chatId);
13944
14650
  if (joinBootstrapWasTakenOver()) {
13945
14651
  withdrawSharedReplySeed();
13946
14652
  return;
@@ -13951,8 +14657,7 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
13951
14657
  return;
13952
14658
  }
13953
14659
  armSharedReplyTarget();
13954
- rememberLastCliInput(ds, promptBody, prompt);
13955
- forkWorker(ds, prompt, sharedReplyRootId ? { turnId: sharedReplyRootId } : false);
14660
+ forkReservedInitialSession(ds, availableBots);
13956
14661
  ds.pendingTurnId = undefined;
13957
14662
  ds.pendingChatContext = undefined;
13958
14663
  logger.info(`[auto-start:入群] ${chatId.substring(0, 12)} 自动开工(${mode}/${scope}),workingDir=${pinnedWorkingDir}`);
@@ -13966,6 +14671,7 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
13966
14671
  const scanDirs = getProjectScanDirs(ds).filter(d => existsSync(d));
13967
14672
  const projects = scanDirs.length > 0 ? scanMultipleProjects(scanDirs, 3, repoPickerScanOptions()) : [];
13968
14673
  if (projects.length > 0) {
14674
+ ds.initialStartPending = false; // pendingRepo/card now owns buffering
13969
14675
  lastRepoScan.set(chatId, projects);
13970
14676
  const cardJson = buildRepoSelectCard(projects, getSessionWorkingDir(ds), anchor, localeForBot(larkAppId), getBot(larkAppId).config.worktreeMultiPicker);
13971
14677
  // The repo card itself belongs under the shared join seed. Arm the target
@@ -13983,13 +14689,14 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
13983
14689
  return;
13984
14690
  }
13985
14691
  ds.repoCardMessageId = repoCardMessageId;
14692
+ persistPendingRepoCardMessageId(ds, ds.repoCardMessageId);
13986
14693
  announcePendingRepoSession(ds);
13987
14694
  logger.info(`[auto-start:入群] ${chatId.substring(0, 12)} 无默认目录,弹 repo 选择卡(${projects.length} 个项目)`);
13988
14695
  }
13989
14696
  else {
13990
14697
  ds.pendingRepo = false;
13991
14698
  ensureSessionWhiteboard(ds);
13992
- const prompt = await buildPrompt();
14699
+ const availableBots = await getAvailableBots(larkAppId, chatId);
13993
14700
  if (joinBootstrapWasTakenOver()) {
13994
14701
  withdrawSharedReplySeed();
13995
14702
  return;
@@ -14000,8 +14707,7 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
14000
14707
  return;
14001
14708
  }
14002
14709
  armSharedReplyTarget();
14003
- rememberLastCliInput(ds, promptBody, prompt);
14004
- forkWorker(ds, prompt, sharedReplyRootId ? { turnId: sharedReplyRootId } : false);
14710
+ forkReservedInitialSession(ds, availableBots);
14005
14711
  ds.pendingTurnId = undefined;
14006
14712
  ds.pendingChatContext = undefined;
14007
14713
  logger.info(`[auto-start:入群] ${chatId.substring(0, 12)} 无默认目录且无可选项目,直接开工`);
@@ -14049,6 +14755,14 @@ function lookupForeignBotName(senderOpenId, larkAppId) {
14049
14755
  return 'Bot';
14050
14756
  }
14051
14757
  async function handleThreadReply(data, ctx, prepared) {
14758
+ // Admission is bot-wide but intentionally concurrent. Add a narrower FIFO
14759
+ // before entering it so two same-anchor deliveries can never invert while
14760
+ // quota/resource/sender preparation awaits. Synthetic keys share the same
14761
+ // lock registry without occupying or mutating an active-session slot.
14762
+ const deliveryKey = `\u0000thread-delivery:${ctx.larkAppId}:${ctx.scope}:${ctx.anchor}`;
14763
+ return withActiveSessionKeyLock(activeSessions, deliveryKey, () => withBotTurnAdmission(ctx.larkAppId, () => handleThreadReplyAdmitted(data, ctx, prepared)));
14764
+ }
14765
+ async function handleThreadReplyAdmitted(data, ctx, prepared) {
14052
14766
  const { chatId: ctxChatId, chatType: ctxChatType, scope, anchor, larkAppId, replyRootId, substituteTrigger } = ctx;
14053
14767
  await waitForAutoStartJoinReady(larkAppId, anchor);
14054
14768
  if (!prepared)
@@ -14273,11 +14987,20 @@ async function handleThreadReply(data, ctx, prepared) {
14273
14987
  senderUnionId: threadTeamTrustUnionId,
14274
14988
  memberUnionId: threadSenderUnionId, // 原始 union(人腿),不锁 bot
14275
14989
  botSender: isBotSenderType || isForeignBot,
14990
+ // Reply attribution: platform-stamped OR cross-ref-resolved bot →
14991
+ // treat as bot for --mention-back (never mis-attribute a peer bot as
14992
+ // human when飞书 sender_type 缺失/变值但已识别 peer).
14993
+ senderIsBot: isBotSenderType || isForeignBot,
14276
14994
  // Bot-started cold starts get no human owner (mirrors the auto-create
14277
14995
  // path) — see the ownership note on startInitialPassthroughSession.
14278
14996
  ownerOpenId: isForeignBot ? undefined : threadSenderOpenId,
14279
14997
  ownerUnionId: isForeignBot ? undefined : data?.sender?.sender_id?.union_id,
14280
14998
  creatorOpenId: threadSenderOpenId,
14999
+ routeToCanonicalOwner: () => handleThreadReplyAdmitted(data, {
15000
+ ...ctx,
15001
+ scope,
15002
+ anchor,
15003
+ }),
14281
15004
  });
14282
15005
  return;
14283
15006
  }
@@ -14289,6 +15012,12 @@ async function handleThreadReply(data, ctx, prepared) {
14289
15012
  // 拉起)。TODO(后续产品决策):是否把 CLI passthrough 也纳入 canOperate,
14290
15013
  // 收紧到与 daemon 命令同档;这会同时改变真人 oncall 成员的现有行为,应单独评估。
14291
15014
  const ds = existingDs;
15015
+ if (ds?.worker && !ds.worker.killed && hasQueuedActivationAdmissionGate(ds)) {
15016
+ sessionReply(anchor, tr('daemon.cmd_activation_pending', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
15017
+ logger.warn(`[${anchor.substring(0, 12)}] Refused passthrough ${cmd} before acceptance `
15018
+ + 'while queued activation owns raw submission order');
15019
+ return;
15020
+ }
14292
15021
  if (ds) {
14293
15022
  // /fast fail-closed: on RPC-input / Riff backends the keystroke can't
14294
15023
  // reach Codex's executor (see fastToggleUnsupportedBackend). Reject with
@@ -14360,7 +15089,7 @@ async function handleThreadReply(data, ctx, prepared) {
14360
15089
  cmdPending = { pendingRepo: true, pendingPrompt: '', workingDir: pinnedWorkingDir };
14361
15090
  }
14362
15091
  sessionStore.updateSession(session);
14363
- const threadCommandDs = {
15092
+ const cmdDs = {
14364
15093
  session,
14365
15094
  worker: null,
14366
15095
  workerPort: null,
@@ -14376,13 +15105,16 @@ async function handleThreadReply(data, ctx, prepared) {
14376
15105
  ownerOpenId: threadSenderOpenId,
14377
15106
  ...cmdPending,
14378
15107
  };
14379
- const commandKey = sessionKey(anchor, larkAppId);
14380
- if (!setActiveSessionIfActive(activeSessions, commandKey, threadCommandDs)) {
14381
- const winner = await rollbackRejectedSessionAndGetWinner(activeSessions, commandKey, threadCommandDs);
14382
- if (!winner)
15108
+ const registration = await claimNewDaemonSession(activeSessions, cmdDs);
15109
+ if (!registration.accepted) {
15110
+ if (registration.reason !== 'existing_owner')
14383
15111
  return;
14384
- logger.info(`[${session.sessionId.substring(0, 8)}] Thread daemon-command registration lost to ` +
14385
- `${winner.session.sessionId.substring(0, 8)}; handing ${cmd} to routing winner`);
15112
+ }
15113
+ else if (cmdDs.pendingRepo) {
15114
+ stageClaimedPendingRepoSetup(activeSessions, cmdDs, {
15115
+ mode: 'picker',
15116
+ turnId: parsed.messageId,
15117
+ });
14386
15118
  }
14387
15119
  }
14388
15120
  // Pass mention-stripped content so /command argument parsing works.
@@ -14454,395 +15186,160 @@ async function handleThreadReply(data, ctx, prepared) {
14454
15186
  return;
14455
15187
  }
14456
15188
  }
14457
- const quotaSenderOpenId = threadSenderOpenId;
14458
- if (!prepared?.quotaChecked && !await enforceMessageQuotaForCliInput(larkAppId, ctxChatId ?? data?.message?.chat_id, quotaSenderOpenId, parsed.messageId, anchor, threadTeamTrustUnionId, threadSenderUnionId, ctxChatType, isBotSenderType || isForeignBot)) {
14459
- return;
14460
- }
14461
- // When a command path rewrites the model prompt (for example /workflow),
14462
- // keep the Lark-authored bytes visible and move the rewritten instruction
14463
- // into hidden untrusted context. Simple quote/bot prefixes use only the
14464
- // prefix as context, avoiding a duplicate copy of the user text.
14465
- const codexAppMessageContext = rewrittenCodexAppMessageContext
14466
- ?? initialCodexAppMessageContext;
14467
- const codexAppApplicationContext = initialCodexAppApplicationContext;
14468
- // Download attachments
14469
- const effectiveAppId = ds?.larkAppId ?? larkAppId;
14470
- let attachments;
14471
- if (prepared) {
14472
- attachments = prepared.attachments;
14473
- }
14474
- else {
14475
- const downloaded = await downloadResources(effectiveAppId, parsed.messageId, resources);
14476
- attachments = downloaded.attachments;
14477
- if (attachments.length > 0) {
14478
- parsed.attachments = attachments;
14479
- }
14480
- if (downloaded.needLogin) {
14481
- sessionReply(anchor, tr('daemon.download_failed_need_login', undefined, localeForBot(effectiveAppId)), 'text', effectiveAppId);
15189
+ // Reserve an existing worker:null owner before ANY post-routing await.
15190
+ // withBotTurnAdmission permits concurrent turns, so two
15191
+ // same-anchor handlers can both resolve the same cold owner and otherwise
15192
+ // race through resource/sender preparation into two forks. The token makes
15193
+ // one handler the opening owner; every other handler observes the gate and
15194
+ // buffers. Queued activations retain the token through adapter ACK, while an
15195
+ // ordinary refork releases it immediately after its buffered tail is handed
15196
+ // to the worker.
15197
+ let initialStartClaimToken;
15198
+ let initialStartClaimWaitsForQueuedAck = false;
15199
+ let retainInitialStartClaim = false;
15200
+ const tryAcquireInitialStartClaim = () => {
15201
+ if (initialStartClaimToken
15202
+ || !ds
15203
+ || (ds.worker && !ds.worker.killed)
15204
+ || ds.pendingRepo
15205
+ || ds.initialStartPending === true)
15206
+ return;
15207
+ initialStartClaimToken = randomUUID();
15208
+ ds.initialStartClaimToken = initialStartClaimToken;
15209
+ ds.initialStartPending = true;
15210
+ initialStartClaimWaitsForQueuedAck = ds.session.queued === true
15211
+ || ds.session.queuedActivationPending === true
15212
+ || ds.session.queuedActivationInput !== undefined;
15213
+ };
15214
+ tryAcquireInitialStartClaim();
15215
+ const ownsInitialStartClaim = () => !!(ds
15216
+ && initialStartClaimToken
15217
+ && ds.initialStartClaimToken === initialStartClaimToken);
15218
+ try {
15219
+ const quotaSenderOpenId = threadSenderOpenId;
15220
+ if (!prepared?.quotaChecked && !await enforceMessageQuotaForCliInput(larkAppId, ctxChatId ?? data?.message?.chat_id, quotaSenderOpenId, parsed.messageId, anchor, threadTeamTrustUnionId, threadSenderUnionId, ctxChatType, isBotSenderType || isForeignBot)) {
15221
+ return;
14482
15222
  }
14483
- }
14484
- // Update last message time + last caller (used by `botmux send` to address
14485
- // reply cards to whoever triggered this turn matters in oncall groups
14486
- // where the caller is often not the session owner).
14487
- const callerOpenId = parsed.senderId || data?.sender?.sender_id?.open_id;
14488
- if (ds) {
14489
- markSessionActivity(ds);
14490
- // quoteTargetId changes every inbound message (always a new message_id), so
14491
- // unlike lastCallerOpenId persist unconditionally. Powers `botmux send`'s
14492
- // default chat-scope quote chain + --mention-back.
14493
- ds.session.quoteTargetId = parsed.messageId;
14494
- ds.session.quoteTargetSenderOpenId = callerOpenId;
14495
- ds.session.quoteTargetSenderIsBot = isForeignBot;
14496
- publishLastInputFromBotPatch(ds);
14497
- if (ds.session.vcMeetingReceiver) {
14498
- ds.vcMeetingImTurnOrigin = ctx.vcMeetingImTurnOrigin;
14499
- if (ctx.vcMeetingImTurnOrigin) {
14500
- rememberVcMeetingImTurnOrigin(ds.session, ctx.vcMeetingImTurnOrigin);
14501
- }
14502
- }
14503
- const substituteReplyMode = substituteTrigger
14504
- ? (getBot(larkAppId).config.substituteMode?.replyMode ?? 'thread')
14505
- : 'thread';
14506
- beginReplyTargetTurn(ds, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger });
14507
- if (callerOpenId && ds.session.lastCallerOpenId !== callerOpenId) {
14508
- ds.session.lastCallerOpenId = callerOpenId;
15223
+ // When a command path rewrites the model prompt (for example /workflow),
15224
+ // keep the Lark-authored bytes visible and move the rewritten instruction
15225
+ // into hidden untrusted context. Simple quote/bot prefixes use only the
15226
+ // prefix as context, avoiding a duplicate copy of the user text.
15227
+ const codexAppMessageContext = rewrittenCodexAppMessageContext
15228
+ ?? initialCodexAppMessageContext;
15229
+ const codexAppApplicationContext = initialCodexAppApplicationContext;
15230
+ // Download attachments
15231
+ const effectiveAppId = ds?.larkAppId ?? larkAppId;
15232
+ let attachments;
15233
+ if (prepared) {
15234
+ attachments = prepared.attachments;
14509
15235
  }
14510
- sessionStore.updateSession(ds.session);
14511
- }
14512
- // If waiting for repo selection, buffer the message and remind user
14513
- if (ds?.pendingRepo || ds?.pendingRepoCommitInFlight) {
14514
- // Enrich content with attachment hints and mention metadata (same as normal send)
14515
- const codexAppFollowUpContextParts = [];
14516
- if (codexAppMessageContext)
14517
- codexAppFollowUpContextParts.push(codexAppMessageContext);
14518
- const attachmentHint = attachments.length > 0 ? formatAttachmentsHint(attachments) : '';
14519
- let enriched = attachmentHint
14520
- ? `${promptContent}${attachmentHint}`
14521
- : promptContent;
14522
- if (attachmentHint)
14523
- codexAppFollowUpContextParts.push(attachmentHint);
14524
- if (parsed.mentions && parsed.mentions.length > 0) {
14525
- const mentionLines = parsed.mentions.map(m => {
14526
- const idPart = m.openId ? ` → open_id: ${m.openId}` : '';
14527
- return `- @${m.name}${idPart}`;
14528
- });
14529
- const mentionContext = `${tr('daemon.enriched_mentions_label', undefined, localeForBot(larkAppId))}\n${mentionLines.join('\n')}`;
14530
- enriched += `\n\n${mentionContext}`;
14531
- codexAppFollowUpContextParts.push(mentionContext);
14532
- }
14533
- // Stamp a buffered follow-up with its own <sender> tag ONLY when it comes
14534
- // from a different user than the first message (ds.pendingSender) — the
14535
- // deferred spawn already carries that sender's <sender> block, and the
14536
- // follow-ups now fold into the same <user_message>, so a same-user tag is
14537
- // pure duplication. A differing sender still gets attributed so the CLI can
14538
- // tell multi-user buffered messages apart after repo selection unlocks.
14539
- // This branch is also the synchronization barrier for a pending first
14540
- // worker. Do not await contact/name resolution here: a commit waiting on
14541
- // getAvailableBots could otherwise resume first, snapshot stale buffers,
14542
- // and fork before this message is appended. Event identity is sufficient
14543
- // for caller equality and sender attribution; names remain optional.
14544
- const followUpSender = callerOpenId
14545
- ? { openId: callerOpenId, type: isForeignBot ? 'bot' : 'user' }
14546
- : undefined;
14547
- const hadBufferedFollowUps = (ds.pendingFollowUps?.length ?? 0) > 0;
14548
- if (followUpSender?.openId && followUpSender.openId !== ds.pendingSender?.openId) {
14549
- // This buffer folds into the opening <user_message> after repo selection,
14550
- // so pair the foreign sender tag with the cursor anti-echo note: without
14551
- // the adjacent note a cursor session sees an inline ou_xxx:name with no
14552
- // guard (the builder's own note only covers ds.pendingSender's top-level
14553
- // tag, and is absent entirely when pendingSender is undefined).
14554
- const followUpSenderBlock = renderBufferedSenderBlock(followUpSender, getBot(larkAppId).config.cliId, localeForBot(larkAppId));
14555
- if (followUpSenderBlock) {
14556
- enriched = `${followUpSenderBlock}\n${enriched}`;
14557
- codexAppFollowUpContextParts.unshift(followUpSenderBlock);
14558
- }
14559
- }
14560
- const sameInitialCaller = !!followUpSender?.openId
14561
- && followUpSender.openId === ds.pendingSender?.openId;
14562
- if (ds.pendingRawInput) {
14563
- if ((!hadBufferedFollowUps || ds.pendingFollowUpTurnId) && sameInitialCaller) {
14564
- // The persisted quote target is a single latest-turn pointer. Treat a
14565
- // same-caller raw command + buffered follow-ups as one batch and join
14566
- // every staged PTY input to that latest turn. Keeping the raw command
14567
- // on its older id would fail provenance as soon as the follow-up moves
14568
- // the durable pointer forward.
14569
- ds.pendingRawTurnId = parsed.messageId;
14570
- ds.pendingFollowUpTurnId = parsed.messageId;
15236
+ else {
15237
+ const downloaded = await downloadResources(effectiveAppId, parsed.messageId, resources);
15238
+ attachments = downloaded.attachments;
15239
+ if (attachments.length > 0) {
15240
+ parsed.attachments = attachments;
14571
15241
  }
14572
- else {
14573
- // Mixed callers cannot share one durable turn pointer. Revoke the
14574
- // entire batch rather than letting either identity borrow the other.
14575
- ds.pendingRawTurnId = undefined;
14576
- ds.pendingFollowUpTurnId = undefined;
15242
+ if (downloaded.needLogin) {
15243
+ sessionReply(anchor, tr('daemon.download_failed_need_login', undefined, localeForBot(effectiveAppId)), 'text', effectiveAppId);
14577
15244
  }
14578
15245
  }
14579
- else if (ds.pendingTurnId) {
14580
- // One deferred model prompt may fold several same-user messages together;
14581
- // bind it to the newest exact message so the durable quote and marker
14582
- // still join. If another user contributes, clear authority instead of
14583
- // letting either identity authorize the combined prompt.
14584
- if (sameInitialCaller) {
14585
- ds.pendingTurnId = parsed.messageId;
15246
+ // Update last message time + last caller (used by `botmux send` to address
15247
+ // reply cards to whoever triggered this turn matters in oncall groups
15248
+ // where the caller is often not the session owner).
15249
+ const callerOpenId = parsed.senderId || data?.sender?.sender_id?.open_id;
15250
+ if (ds) {
15251
+ markSessionActivity(ds);
15252
+ // quoteTargetId changes every inbound message (always a new message_id), so
15253
+ // — unlike lastCallerOpenId — persist unconditionally. Powers `botmux send`'s
15254
+ // default chat-scope quote chain + --mention-back.
15255
+ ds.session.quoteTargetId = parsed.messageId;
15256
+ ds.session.quoteTargetSenderOpenId = callerOpenId;
15257
+ ds.session.quoteTargetSenderIsBot = isForeignBot;
15258
+ publishLastInputFromBotPatch(ds);
15259
+ if (ds.session.vcMeetingReceiver) {
15260
+ ds.vcMeetingImTurnOrigin = ctx.vcMeetingImTurnOrigin;
15261
+ if (ctx.vcMeetingImTurnOrigin) {
15262
+ rememberVcMeetingImTurnOrigin(ds.session, ctx.vcMeetingImTurnOrigin);
15263
+ }
14586
15264
  }
14587
- else {
14588
- ds.pendingTurnId = undefined;
14589
- }
14590
- }
14591
- if (!ds.pendingFollowUps)
14592
- ds.pendingFollowUps = [];
14593
- ds.pendingFollowUps.push(enriched);
14594
- if (!ds.pendingCodexAppFollowUps)
14595
- ds.pendingCodexAppFollowUps = [];
14596
- ds.pendingCodexAppFollowUps.push(parsed.content);
14597
- if (!ds.pendingCodexAppFollowUpContexts)
14598
- ds.pendingCodexAppFollowUpContexts = [];
14599
- ds.pendingCodexAppFollowUpContexts.push(codexAppFollowUpContextParts.join('\n\n'));
14600
- if (codexAppApplicationContext) {
14601
- ds.pendingCodexAppApplicationContext = mergeVcMeetingApplicationContext(ds.pendingCodexAppApplicationContext, codexAppApplicationContext);
14602
- }
14603
- // Auto-worktree pending (worktreeCreating) has no repo card to point at — the
14604
- // message IS buffered (folded on commit), so just say "hold on, building worktree"
14605
- // instead of the misleading "pick a repo from the card above".
14606
- const pendingReplyKey = (ds.worktreeCreating || ds.pendingRepoCommitInFlight)
14607
- ? 'daemon.worktree_building_wait'
14608
- : 'daemon.choose_repo_first';
14609
- await sessionReply(anchor, tr(pendingReplyKey, undefined, localeForBot(larkAppId)), 'text', larkAppId);
14610
- return;
14611
- }
14612
- // Route to file queue (keyed by anchor: rootMessageId for thread, chatId for chat)
14613
- messageQueue.ensureQueue(anchor);
14614
- if (!prepared?.queueAlreadyAppended)
14615
- messageQueue.appendMessage(anchor, parsed);
14616
- if (ds)
14617
- publishSessionMessagePreviewPatch(ds);
14618
- if (!ds) {
14619
- // No active session at this anchor — auto-create. This branch is mostly a
14620
- // safety net; the dispatcher routes here only when isSessionOwner() returns
14621
- // true, but races (between check and execution, or session-closed events)
14622
- // can land us here.
14623
- if (activeSessions.has(sessionKey(anchor, larkAppId))) {
14624
- logger.info(`[${larkAppId}] Session already exists for ${scope}-scope ${anchor}, skipping auto-create`);
14625
- return;
15265
+ const substituteReplyMode = substituteTrigger
15266
+ ? (getBot(larkAppId).config.substituteMode?.replyMode ?? 'thread')
15267
+ : 'thread';
15268
+ // Sender name is best-effort here: getThreadSender() resolves later (this
15269
+ // hot path avoids an await before the buffering barrier), so the candidate
15270
+ // list may show open_id without a name — the ambiguity decision itself is
15271
+ // unaffected. Post inline-@s: on a prepared (registration-race loser) handoff
15272
+ // use the COMPLETE pre-extracted seed+follow-up set; otherwise extract from
15273
+ // this message. Include the forward-seed message too: a new-topic/auto-create
15274
+ // CAS loser routes here without re-passing prepared.postParticipantMentions, so
15275
+ // recompute from BOTH data and ctx.forwardSeedData or the seed's post @s vanish
15276
+ // on the double-race (matches the new-topic path's collectPostAtMentions args).
15277
+ const existingPostAt = prepared?.postParticipantMentions ?? collectPostAtMentions(data?.message, ctx.forwardSeedData?.message);
15278
+ const existingWindow = buildTurnParticipants(larkAppId, callerOpenId, senderIsBotTriState(parsed.senderType, isForeignBot), parsed.mentions, undefined, existingPostAt);
15279
+ beginReplyTargetTurn(ds, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId: callerOpenId, participants: existingWindow.participants, participantsIncomplete: existingWindow.incomplete });
15280
+ if (callerOpenId && ds.session.lastCallerOpenId !== callerOpenId) {
15281
+ ds.session.lastCallerOpenId = callerOpenId;
15282
+ }
15283
+ sessionStore.updateSession(ds.session);
14626
15284
  }
14627
- const autoCreateChatId = ctxChatId ?? data?.message?.chat_id ?? '';
14628
- const autoCreateChatType = ctxChatType ?? (data?.message?.chat_type === 'p2p' ? 'p2p' : 'group');
14629
- const botCfg = getBot(larkAppId).config;
14630
- const groupChatNamePromise = resolveGroupChatNameForNativeTitle(larkAppId, autoCreateChatId, autoCreateChatType, botCfg.cliId, parsed.content, parsed.mentions);
14631
- logger.info(`No active session for ${scope}-scope ${anchor}, auto-creating new session...`);
14632
- refreshCliVersion(botCfg);
14633
- const senderOId = data.sender?.sender_id?.open_id;
14634
- const senderUId = data.sender?.sender_id?.union_id;
14635
- // For thread-scope: rootMessageId = anchor (real thread root).
14636
- // For chat-scope: rootMessageId = the message_id that triggered this auto-create
14637
- // (used as audit trail; routing key is chatId).
14638
- const rootIdForStore = scope === 'thread' ? anchor : parsed.messageId;
14639
- const session = sessionStore.createSession(autoCreateChatId, rootIdForStore, parsed.content.substring(0, 50), autoCreateChatType);
14640
- const now = Date.now();
14641
- // Bot-started handoff sessions have no human owner; keeping the bot as
14642
- // owner makes daemon-generated footers wake that bot again.
14643
- const ownerOpenId = isForeignBot ? undefined : senderOId;
14644
- const ownerUnionId = isForeignBot ? undefined : senderUId;
14645
- session.larkAppId = larkAppId;
14646
- session.ownerOpenId = ownerOpenId;
14647
- // creatorOpenId is the raw creating sender set even for foreign-bot
14648
- // sessions (unlike ownerOpenId, nulled above) so `botmux report` can find the
14649
- // dispatch orchestrator on a no-`/repo` kickoff auto-create. See Session.creatorOpenId.
14650
- session.creatorOpenId = senderOId;
14651
- session.ownerUnionId = ownerUnionId;
14652
- session.lastCallerOpenId = senderOId;
14653
- session.quoteTargetId = parsed.messageId;
14654
- session.quoteTargetSenderOpenId = senderOId;
14655
- session.quoteTargetSenderIsBot = isForeignBot;
14656
- session.lastMessageAt = new Date(now).toISOString();
14657
- session.scope = scope;
14658
- const groupChatName = await groupChatNamePromise;
14659
- if (groupChatName)
14660
- session.chatDisplayName = groupChatName;
14661
- session.nativeSessionTitle = buildBotmuxLarkNativeSessionTitle(parsed.content, parsed.mentions, groupChatName);
14662
- sessionStore.updateSession(session);
14663
- // chat-scope only — see the handleNewTopic twin above (topic substitute
14664
- // sessions keep their streaming card, no control-card compensation).
14665
- const shouldSendSubstituteControlCard = substituteTrigger
14666
- && scope === 'chat'
14667
- && !botCfg.substituteMode?.disableControlCard
14668
- && !session.substituteControlCardSent;
14669
- // Use the same layered oncall / inherit / default lookup as handleNewTopic
14670
- // so stale inherited peers are ignored consistently in both spawn paths.
14671
- const { pinnedWorkingDir, oncallEntry, inheritedFrom, pinnedFromBotDefault } = await resolvePinnedWorkingDir({
14672
- scope,
14673
- anchor,
14674
- chatId: autoCreateChatId,
14675
- chatType: autoCreateChatType,
14676
- larkAppId,
15285
+ // The first owner may have failed while this handler was awaiting resource
15286
+ // preparation. Re-check once at the final routing boundary so the oldest
15287
+ // surviving follower atomically takes over instead of proceeding unclaimed.
15288
+ tryAcquireInitialStartClaim();
15289
+ // A published worker:null owner is not necessarily ready to refork: its
15290
+ // opening turn may still be preparing. Buffer both repo-pending and
15291
+ // initial-start-pending messages into that opening turn so a later prompt
15292
+ // cannot overtake it. Exception — a genuine external takeover: the worker went
15293
+ // LIVE via a reattach / concurrent start (e.g. before the group-join bootstrap
15294
+ // settled) with (a) no queued-activation submission in flight AND (b) no
15295
+ // active initial-start claim token (no concurrent turn is building an opening
15296
+ // for this session). Such a worker is ready to receive input directly;
15297
+ // buffering it into a queued-activation tail — only consumed by the worker:null
15298
+ // opening path — would silently drop it. When a claim token IS held (a
15299
+ // concurrent first turn owns the opening) or a queued-activation ACK is
15300
+ // pending, a live worker MUST keep buffering to preserve FIFO submission order.
15301
+ // Codex App steer authorization (Blocking 1, decision A) — computed ONCE here,
15302
+ // BEFORE every admission/fork branch below (R5-B1-1): the earliest tail-admit
15303
+ // (initialStartPending follower), pending-repo follower, worker-null refork,
15304
+ // and new-topic auto-create paths all need the same frozen value, and several
15305
+ // of them return before the later existing-owner branch. Explicit positive
15306
+ // ONLY for a plain-human-interactive turn (real human sender; none of
15307
+ // foreign-bot / bot-sender / substitute-rewrite / v3-grill / message-listener /
15308
+ // VC receiver / VC origin / adopt). Never inferred from the delivery sink;
15309
+ // ignored by acceptCodexAppDispatch for non-codex-app CLIs. A new-topic root
15310
+ // must FREEZE this into its opening payload — forkReservedInitialSession is
15311
+ // shared by bot-added / scheduler / system bootstrap and only COPIES an
15312
+ // explicit upstream true, never defaults it.
15313
+ const codexAppSteerable = computeCodexAppSteerable({
15314
+ // Positive real-human gate (R7-B1 fail-closed): senderType must be 'user'.
15315
+ // isForeignBot already folds in the isKnownPeerBot cross-ref fallback.
15316
+ humanSender: parsed.senderType === 'user',
15317
+ adopted: !!ds?.adoptedFrom,
15318
+ isForeignBot,
15319
+ isBotSenderType,
15320
+ substituteTrigger: !!substituteTrigger,
15321
+ controlRewrite: !!threadGrill,
15322
+ messageListener: !!ctx.messageListener,
15323
+ vcMeetingReceiver: ds?.session.vcMeetingReceiver !== undefined,
15324
+ vcMeetingImTurnOrigin: !!ctx.vcMeetingImTurnOrigin,
14677
15325
  });
14678
- const autoWt = willAutoWorktree(larkAppId, pinnedWorkingDir, pinnedFromBotDefault);
14679
- // Now we know the message will spawn or pend a real session — resolve
14680
- // sender (may await contact API budget) since every downstream branch
14681
- // injects it either into the immediate prompt or stashes it on
14682
- // pendingSender for the deferred spawn.
14683
- const autoCreateSender = await getThreadSender();
14684
- setDirectChatDisplayNameFromSender(session, autoCreateChatType, autoCreateSender);
14685
- const newDs = {
14686
- session,
14687
- worker: null,
14688
- workerPort: null,
14689
- workerToken: null,
14690
- larkAppId,
14691
- chatId: autoCreateChatId,
14692
- chatType: autoCreateChatType,
14693
- scope,
14694
- spawnedAt: Date.parse(session.createdAt) || now,
14695
- cliVersion: cliVersionCache.get(cliRuntimeVersionKey(botCfg))?.version ?? 'unknown',
14696
- lastMessageAt: now,
14697
- hasHistory: false,
14698
- pendingRepo: !pinnedWorkingDir || autoWt,
14699
- pendingPrompt: promptContent,
14700
- pendingTurnId: parsed.messageId,
14701
- pendingCodexAppText: parsed.content,
14702
- pendingCodexAppApplicationContext: codexAppApplicationContext,
14703
- pendingCodexAppMessageContext: codexAppMessageContext,
14704
- pendingAttachments: attachments.length > 0 ? attachments : undefined,
14705
- pendingMentions: parsed.mentions,
14706
- pendingSubstituteTrigger: substituteTrigger,
14707
- pendingSubstituteControlCard: shouldSendSubstituteControlCard,
14708
- pendingSender: autoCreateSender,
14709
- ownerOpenId,
14710
- currentTurnTitle: parsed.content.substring(0, 50),
14711
- workingDir: pinnedWorkingDir,
14712
- };
14713
- if (pinnedWorkingDir) {
14714
- newDs.session.workingDir = pinnedWorkingDir;
14715
- sessionStore.updateSession(newDs.session);
14716
- }
14717
- const substituteReplyMode = substituteTrigger
14718
- ? (botCfg.substituteMode?.replyMode ?? 'thread')
14719
- : 'thread';
14720
- beginReplyTargetTurn(newDs, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger });
14721
- sessionStore.updateSession(newDs.session);
14722
- const creationKey = sessionKey(anchor, larkAppId);
14723
- if (!setActiveSessionIfActive(activeSessions, creationKey, newDs)) {
14724
- const winner = await rollbackRejectedSessionAndGetWinner(activeSessions, creationKey, newDs);
14725
- if (winner) {
14726
- logger.info(`[${session.sessionId.substring(0, 8)}] Reply auto-create registration lost to ` +
14727
- `${winner.session.sessionId.substring(0, 8)}; handing message to routing winner`);
14728
- await handleThreadReply(data, ctx, {
14729
- parsed,
14730
- resources,
14731
- attachments,
14732
- quotaChecked: true,
14733
- queueAlreadyAppended: true,
14734
- senderResolved: true,
14735
- sender: autoCreateSender,
14736
- });
14737
- }
14738
- return;
14739
- }
14740
- // Auto-worktree: register PENDING, build worktree off-path, commit+fork later.
14741
- if (pinnedWorkingDir && autoWt) {
14742
- if (await replyInvalidWorkingDirs(anchor, larkAppId, newDs))
14743
- return;
14744
- startAutoWorktreePending(newDs, { anchor, baseDir: pinnedWorkingDir, title: parsed.content.substring(0, 50), prompt: promptContent, operatorOpenId: ownerOpenId });
14745
- return;
14746
- }
14747
- // Pinned (oncall binding or inherited from peer bot in same thread):
14748
- // spawn CLI immediately, skip repo selection.
14749
- if (pinnedWorkingDir) {
14750
- if (await replyInvalidWorkingDirs(anchor, larkAppId, newDs))
14751
- return;
14752
- const selfBot = getBot(larkAppId);
14753
- ensureSessionWhiteboard(newDs);
14754
- const prompt = buildNewTopicCliInput(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, autoCreateChatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), autoCreateSender, { larkAppId, chatId: autoCreateChatId, whiteboardId: newDs.session.whiteboardId, substituteTrigger, codexAppText: parsed.content, codexAppApplicationContext, codexAppMessageContext });
14755
- await noteTurnReceived(newDs, parsed.messageId, parsed.content, autoCreateSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
14756
- rememberLastCliInput(newDs, promptContent, prompt);
14757
- forkWorker(newDs, prompt, { turnId: parsed.messageId });
14758
- newDs.pendingTurnId = undefined;
14759
- const reason = oncallEntry
14760
- ? `oncall-bound chat ${autoCreateChatId}`
14761
- : inheritedFrom
14762
- ? `inherited from peer session ${inheritedFrom.sessionId.substring(0, 8)} (app=${inheritedFrom.larkAppId ?? 'unknown'})`
14763
- : `bot defaultWorkingDir`;
14764
- logger.info(`[${tag(newDs)}] ${reason} → workingDir=${pinnedWorkingDir}, skipped repo select`);
14765
- return;
14766
- }
14767
- // Show repo selection card (same as handleNewTopic)
14768
- if (await replyInvalidWorkingDirs(anchor, larkAppId, newDs))
14769
- return;
14770
- const scanDirs2 = getProjectScanDirs(newDs).filter(d => existsSync(d));
14771
- let projects = [];
14772
- if (scanDirs2.length > 0) {
14773
- projects = scanMultipleProjects(scanDirs2, 3, repoPickerScanOptions());
14774
- }
14775
- if (projects.length > 0) {
14776
- lastRepoScan.set(autoCreateChatId, projects);
14777
- const currentCwd = getSessionWorkingDir(newDs);
14778
- const cardJson = buildRepoSelectCard(projects, currentCwd, anchor, localeForBot(larkAppId), getBot(larkAppId).config.worktreeMultiPicker);
14779
- newDs.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
14780
- announcePendingRepoSession(newDs);
14781
- logger.info(`[${tag(newDs)}] Waiting for repo selection (${projects.length} projects)`);
14782
- }
14783
- else {
14784
- // No projects found — skip repo selection, spawn directly
14785
- newDs.pendingRepo = false;
14786
- const selfBot = getBot(larkAppId);
14787
- ensureSessionWhiteboard(newDs);
14788
- const prompt = buildNewTopicCliInput(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, autoCreateChatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), autoCreateSender, { larkAppId, chatId: autoCreateChatId, whiteboardId: newDs.session.whiteboardId, substituteTrigger, codexAppText: parsed.content, codexAppApplicationContext, codexAppMessageContext });
14789
- await noteTurnReceived(newDs, parsed.messageId, parsed.content, autoCreateSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
14790
- rememberLastCliInput(newDs, promptContent, prompt);
14791
- forkWorker(newDs, prompt, { turnId: parsed.messageId });
14792
- newDs.pendingTurnId = undefined;
14793
- }
14794
- return;
14795
- }
14796
- // Send message to worker via IPC
14797
- if (ds.worker && !ds.worker.killed) {
14798
- const dsBotCfgForMsg = getBot(ds.larkAppId).config;
14799
- // Adopt mode: the adopted CLI is the user's external process and was
14800
- // never injected with botmux's skill / system prompt. Sending it the
14801
- // `<user_message>` / `<botmux_reminder>` / `<session_id>` wrappers
14802
- // surfaces those tags verbatim in its UI (the user reported Codex
14803
- // showing raw XML on every Lark message). Use the bridge raw-input
14804
- // builder for ALL adopt sessions regardless of cliId — transcript
14805
- // harvest (Claude bridge or Codex bridge) handles the reply path
14806
- // out-of-band.
14807
- const isBridge = !!ds.adoptedFrom;
14808
- const selfBot = getBot(ds.larkAppId);
14809
- if (!isBridge)
14810
- ensureSessionWhiteboard(ds);
14811
- const effectiveCliId = ds.session.cliId ?? dsBotCfgForMsg.cliId;
14812
- // Empty-started session (repo select/skip/switch booted the CLI with no
14813
- // turn): a LIVE worker is not proof the CLI ever saw botmux's opening
14814
- // context — only `buildNewTopicCliInput` emits <botmux_routing> /
14815
- // <botmux_builtin_skills> / <identity>. Probe (non-consuming) before the
14816
- // awaits below, then claim SYNCHRONOUSLY right before building so two
14817
- // near-simultaneous first messages can only produce one opener; the loser
14818
- // degrades to an ordinary follow-up in queue order.
14819
- const wantsOpening = !isBridge && isInitialUserTurnPending(ds);
14820
- const openingBots = wantsOpening ? await getAvailableBots(larkAppId, ds.chatId) : undefined;
14821
- const turnSender = await getThreadSender();
14822
- const openingTurn = wantsOpening && claimInitialUserTurn(ds);
14823
- const cliInput = isBridge
14824
- ? { content: buildBridgeInputContent(promptContent, {
14825
- attachments,
14826
- mentions: parsed.mentions,
14827
- selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
14828
- }) }
14829
- : openingTurn
14830
- ? buildNewTopicCliInput(promptContent, ds.session.sessionId, effectiveCliId, ds.session.cliPathOverride ?? dsBotCfgForMsg.cliPathOverride, attachments, parsed.mentions, openingBots, undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), turnSender, {
14831
- larkAppId,
14832
- chatId: ds.session.chatId,
14833
- whiteboardId: ds.session.whiteboardId,
14834
- substituteTrigger,
14835
- codexAppText: parsed.content,
14836
- codexAppApplicationContext,
14837
- codexAppMessageContext,
14838
- })
14839
- : buildFollowUpCliInput(promptContent, ds.session.sessionId, {
15326
+ const liveTakeoverReady = !!ds?.worker && !ds.worker.killed
15327
+ && !hasQueuedActivationAdmissionGate(ds)
15328
+ && ds.initialStartClaimToken === undefined;
15329
+ const initialStartPending = ds?.initialStartPending === true
15330
+ && !ownsInitialStartClaim()
15331
+ && !liveTakeoverReady;
15332
+ if (ds && initialStartPending && !ds.pendingRepo) {
15333
+ const tailReservation = reserveAsyncQueuedActivationTailAdmission(ds);
15334
+ try {
15335
+ const botCfg = getBot(ds.larkAppId).config;
15336
+ const followUp = buildFollowUpCliInput(promptContent, ds.session.sessionId, {
14840
15337
  attachments,
14841
15338
  mentions: parsed.mentions,
14842
15339
  isAdoptMode: false,
14843
- cliId: effectiveCliId,
14844
- cliPathOverride: ds.session.cliPathOverride ?? dsBotCfgForMsg.cliPathOverride,
14845
- sender: turnSender,
15340
+ cliId: ds.session.cliId ?? botCfg.cliId,
15341
+ cliPathOverride: ds.session.cliPathOverride ?? botCfg.cliPathOverride,
15342
+ sender: await getThreadSender(),
14846
15343
  larkAppId,
14847
15344
  chatId: ds.session.chatId,
14848
15345
  whiteboardId: ds.session.whiteboardId,
@@ -14851,180 +15348,804 @@ async function handleThreadReply(data, ctx, prepared) {
14851
15348
  codexAppApplicationContext,
14852
15349
  codexAppMessageContext,
14853
15350
  });
14854
- beginNewTurn(ds, parsed.content);
14855
- await noteTurnReceived(ds, parsed.messageId, parsed.content, turnSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
14856
- let accepted = false;
14857
- try {
14858
- accepted = sendWorkerInput(ds, cliInput, parsed.messageId);
14859
- // Record the input as the session's last real CLI turn ONLY after the
14860
- // worker accepted it. Recording before delivery (the old order) persisted
14861
- // lastCliInput / lastUserPrompt / Codex-App sidecar for a turn that never
14862
- // reached the CLI; a rejected send then left that poison behind, and the
14863
- // next message's worker-null refork would read it as `hadPriorCliInput`
14864
- // and wrongly `--resume` a CLI that never took a real turn.
14865
- if (accepted)
14866
- rememberLastCliInput(ds, promptContent, cliInput);
15351
+ // R5-B1-1: freeze the admission-time steer authorization onto this earliest
15352
+ // (initialStartPending follower) tail entry the strip-proof admit rebuild
15353
+ // then preserves it through promote/repark. Only a plain-human turn is true.
15354
+ if (codexAppSteerable)
15355
+ followUp.codexAppSteerable = true;
15356
+ admitQueuedActivationTail(ds, {
15357
+ userPrompt: promptContent,
15358
+ cliInput: followUp,
15359
+ turnId: parsed.messageId,
15360
+ }, tailReservation);
15361
+ }
15362
+ finally {
15363
+ settleAsyncQueuedActivationTailAdmission(ds);
15364
+ }
15365
+ logger.info(`[${tag(ds)}] buffered same-anchor turn ${parsed.messageId.substring(0, 12)} `
15366
+ + 'behind queued activation submission ACK');
15367
+ // Quarantined tail-only owner (restore promotion failed transiently): the
15368
+ // current turn is now safely appended BEHIND the retained tail via its
15369
+ // reservation above, so recover the OLD HEAD — never the current turn. A
15370
+ // blank fork here routes through forkWorker's central quarantine guard: it
15371
+ // retries the old head's promotion and, on success, cold-forks the promoted
15372
+ // head via the exact recovery args (Codex App through its ledger, non-Codex
15373
+ // by resubmitting queuedActivationInput). On failure the guard refuses the
15374
+ // fork (returns false) and the session stays quarantined with the gate held,
15375
+ // the current turn parked in the tail for a later retry.
15376
+ //
15377
+ // This deliberately does NOT call forkReservedInitialSession: that builds a
15378
+ // synthetic opening turn from an empty pendingPrompt (a non-empty new-topic
15379
+ // envelope), which would be accepted as a fresh dispatch and overtake the
15380
+ // promoted old head — the exact defect this recovery must avoid.
15381
+ if (ds.quarantinedActivationTailPromotion && (!ds.worker || ds.worker.killed)) {
15382
+ if (forkWorker(ds, '', true)) {
15383
+ logger.info(`[${tag(ds)}] Quarantined activation-tail promotion recovered on inbound; cold-forked promoted head`);
15384
+ }
15385
+ else {
15386
+ logger.warn(`[${tag(ds)}] Quarantined activation-tail promotion still failing on inbound; current turn parked behind old tail, staying quarantined`);
15387
+ }
15388
+ }
15389
+ return;
14867
15390
  }
14868
- finally {
14869
- // The opening is one-shot: give it back when the worker died / refused,
14870
- // so the next message re-opens instead of silently losing the context.
14871
- if (openingTurn && !accepted)
14872
- releaseInitialUserTurn(ds);
15391
+ if (ds?.pendingRepo || initialStartPending) {
15392
+ const durableTailReservation = ds.pendingRepo
15393
+ ? reserveAsyncQueuedActivationTailAdmission(ds)
15394
+ : undefined;
15395
+ const bufferedCodexAppInputAccepted = initialStartPending && !ds.pendingRepo
15396
+ ? codexAppCleanInputAcceptedForSession(ds)
15397
+ : undefined;
15398
+ try {
15399
+ // Enrich content with attachment hints and mention metadata (same as normal send)
15400
+ const codexAppFollowUpContextParts = [];
15401
+ if (codexAppMessageContext)
15402
+ codexAppFollowUpContextParts.push(codexAppMessageContext);
15403
+ const attachmentHint = attachments.length > 0 ? formatAttachmentsHint(attachments) : '';
15404
+ let enriched = attachmentHint
15405
+ ? `${promptContent}${attachmentHint}`
15406
+ : promptContent;
15407
+ if (attachmentHint)
15408
+ codexAppFollowUpContextParts.push(attachmentHint);
15409
+ if (parsed.mentions && parsed.mentions.length > 0) {
15410
+ const mentionLines = parsed.mentions.map(m => {
15411
+ const idPart = m.openId ? ` → open_id: ${m.openId}` : '';
15412
+ return `- @${m.name}${idPart}`;
15413
+ });
15414
+ const mentionContext = `${tr('daemon.enriched_mentions_label', undefined, localeForBot(larkAppId))}\n${mentionLines.join('\n')}`;
15415
+ enriched += `\n\n${mentionContext}`;
15416
+ codexAppFollowUpContextParts.push(mentionContext);
15417
+ }
15418
+ // Stamp a buffered follow-up with its own <sender> tag ONLY when it comes
15419
+ // from a different user than the first message (ds.pendingSender) — the
15420
+ // deferred spawn already carries that sender's <sender> block, and the
15421
+ // follow-ups now fold into the same <user_message>, so a same-user tag is
15422
+ // pure duplication. A differing sender still gets attributed so the CLI can
15423
+ // tell multi-user buffered messages apart after repo selection unlocks.
15424
+ // This branch is also the synchronization barrier for a pending first
15425
+ // worker. Do not await contact/name resolution here: a commit waiting on
15426
+ // getAvailableBots could otherwise resume first, snapshot stale buffers,
15427
+ // and fork before this message is appended. Event identity is sufficient
15428
+ // for caller equality and sender attribution; names remain optional.
15429
+ const followUpSender = callerOpenId
15430
+ ? { openId: callerOpenId, type: isForeignBot ? 'bot' : 'user' }
15431
+ : undefined;
15432
+ const hadBufferedFollowUps = (ds.pendingFollowUps?.length ?? 0) > 0;
15433
+ const sameInitialCaller = !!followUpSender?.openId
15434
+ && followUpSender.openId === ds.pendingSender?.openId;
15435
+ if (followUpSender?.openId && followUpSender.openId !== ds.pendingSender?.openId) {
15436
+ // This buffer folds into the opening <user_message> after repo selection,
15437
+ // so pair the foreign sender tag with the cursor anti-echo note: without
15438
+ // the adjacent note a cursor session sees an inline ou_xxx:name with no
15439
+ // guard (the builder's own note only covers ds.pendingSender's top-level
15440
+ // tag, and is absent entirely when pendingSender is undefined).
15441
+ const followUpSenderBlock = renderBufferedSenderBlock(followUpSender, getBot(larkAppId).config.cliId, localeForBot(larkAppId));
15442
+ if (followUpSenderBlock) {
15443
+ enriched = `${followUpSenderBlock}\n${enriched}`;
15444
+ codexAppFollowUpContextParts.unshift(followUpSenderBlock);
15445
+ }
15446
+ }
15447
+ if (ds.pendingRepo) {
15448
+ const hasOpening = (ds.pendingPrompt?.trim().length ?? 0) > 0
15449
+ || (ds.pendingAttachments?.length ?? 0) > 0
15450
+ || !!ds.pendingRawInput;
15451
+ ds.session.queued = true;
15452
+ if (!hasOpening) {
15453
+ // A bare /repo placeholder has no N yet. This inbound becomes the
15454
+ // durable opening instead of an impossible successor to an empty ACK.
15455
+ ds.pendingPrompt = promptContent;
15456
+ ds.pendingTurnId = parsed.messageId;
15457
+ ds.pendingCodexAppText = parsed.content;
15458
+ ds.pendingCodexAppApplicationContext = codexAppApplicationContext;
15459
+ ds.pendingCodexAppMessageContext = codexAppMessageContext;
15460
+ ds.pendingAttachments = attachments.length > 0 ? attachments : undefined;
15461
+ ds.pendingMentions = parsed.mentions;
15462
+ ds.pendingSender = followUpSender;
15463
+ // R6-B1: this inbound becomes the durable opening — freeze its plain-human
15464
+ // steer authorization so the repo-selected opening isn't forced-serial.
15465
+ ds.pendingCodexAppSteerable = codexAppSteerable ? true : undefined;
15466
+ const existingSetup = ds.session.pendingRepoSetup;
15467
+ stagePendingRepoSetup(ds, {
15468
+ mode: existingSetup?.mode ?? 'picker',
15469
+ ...(existingSetup?.baseDir ? { baseDir: existingSetup.baseDir } : {}),
15470
+ turnId: parsed.messageId,
15471
+ });
15472
+ }
15473
+ else {
15474
+ const botCfg = getBot(ds.larkAppId).config;
15475
+ const exactFollowUp = buildFollowUpCliInput(promptContent, ds.session.sessionId, {
15476
+ attachments,
15477
+ mentions: parsed.mentions,
15478
+ isAdoptMode: false,
15479
+ cliId: ds.session.cliId ?? botCfg.cliId,
15480
+ cliPathOverride: ds.session.cliPathOverride ?? botCfg.cliPathOverride,
15481
+ sender: followUpSender,
15482
+ larkAppId,
15483
+ chatId: ds.session.chatId,
15484
+ whiteboardId: ds.session.whiteboardId,
15485
+ substituteTrigger,
15486
+ codexAppText: parsed.content,
15487
+ codexAppApplicationContext,
15488
+ codexAppMessageContext,
15489
+ });
15490
+ ds.session.queuedPrompt ??= ds.pendingPrompt;
15491
+ ds.session.queuedCodexAppText ??= ds.pendingCodexAppText;
15492
+ ds.session.queuedCodexAppMessageContext ??= ds.pendingCodexAppMessageContext;
15493
+ // R5-B1-1: freeze steer authorization onto the pending-repo follower tail.
15494
+ if (codexAppSteerable)
15495
+ exactFollowUp.codexAppSteerable = true;
15496
+ admitQueuedActivationTail(ds, {
15497
+ userPrompt: promptContent,
15498
+ cliInput: exactFollowUp,
15499
+ turnId: parsed.messageId,
15500
+ }, durableTailReservation);
15501
+ }
15502
+ const pendingReplyKey = ds.worktreeCreating
15503
+ ? 'daemon.worktree_building_wait'
15504
+ : 'daemon.choose_repo_first';
15505
+ await sessionReply(anchor, tr(pendingReplyKey, undefined, localeForBot(larkAppId)), 'text', larkAppId);
15506
+ return;
15507
+ }
15508
+ // Initial-start compatibility path after repo selection: only the durable
15509
+ // queued-activation gate belongs here. pendingRepoCommitInFlight protects a
15510
+ // second repo selection, but must not divert ordinary turns away from the
15511
+ // already-forked worker into source buffers that will never be consumed.
15512
+ // (sameInitialCaller is computed once above, before the pendingRepo branch.)
15513
+ if (ds.pendingRawInput) {
15514
+ if ((!hadBufferedFollowUps || ds.pendingFollowUpTurnId) && sameInitialCaller) {
15515
+ // The persisted quote target is a single latest-turn pointer. Treat a
15516
+ // same-caller raw command + buffered follow-ups as one batch and join
15517
+ // every staged PTY input to that latest turn. Keeping the raw command
15518
+ // on its older id would fail provenance as soon as the follow-up moves
15519
+ // the durable pointer forward.
15520
+ ds.pendingRawTurnId = parsed.messageId;
15521
+ ds.pendingFollowUpTurnId = parsed.messageId;
15522
+ }
15523
+ else {
15524
+ // Mixed callers cannot share one durable turn pointer. Revoke the
15525
+ // entire batch rather than letting either identity borrow the other.
15526
+ ds.pendingRawTurnId = undefined;
15527
+ ds.pendingFollowUpTurnId = undefined;
15528
+ }
15529
+ }
15530
+ else if (ds.pendingTurnId) {
15531
+ // One deferred model prompt may fold several same-user messages together;
15532
+ // bind it to the newest exact message so the durable quote and marker
15533
+ // still join. If another user contributes, clear authority instead of
15534
+ // letting either identity authorize the combined prompt.
15535
+ if (sameInitialCaller) {
15536
+ ds.pendingTurnId = parsed.messageId;
15537
+ }
15538
+ else {
15539
+ ds.pendingTurnId = undefined;
15540
+ }
15541
+ }
15542
+ if (!ds.pendingFollowUps)
15543
+ ds.pendingFollowUps = [];
15544
+ ds.pendingFollowUps.push(enriched);
15545
+ if (!ds.pendingFollowUpTurnIds)
15546
+ ds.pendingFollowUpTurnIds = [];
15547
+ ds.pendingFollowUpTurnIds.push(parsed.messageId);
15548
+ if (!ds.pendingCodexAppFollowUps)
15549
+ ds.pendingCodexAppFollowUps = [];
15550
+ ds.pendingCodexAppFollowUps.push(parsed.content);
15551
+ if (!ds.pendingCodexAppFollowUpContexts)
15552
+ ds.pendingCodexAppFollowUpContexts = [];
15553
+ ds.pendingCodexAppFollowUpContexts.push(codexAppFollowUpContextParts.join('\n\n'));
15554
+ if (!ds.pendingCodexAppFollowUpGateAccepted) {
15555
+ ds.pendingCodexAppFollowUpGateAccepted = [];
15556
+ }
15557
+ ds.pendingCodexAppFollowUpGateAccepted.push(bufferedCodexAppInputAccepted === true);
15558
+ if (codexAppApplicationContext) {
15559
+ ds.pendingCodexAppApplicationContext = mergeVcMeetingApplicationContext(ds.pendingCodexAppApplicationContext, codexAppApplicationContext);
15560
+ }
15561
+ if (initialStartPending && !ds.pendingRepo) {
15562
+ logger.info(`[${tag(ds)}] buffered same-anchor turn ${parsed.messageId.substring(0, 12)} `
15563
+ + 'behind reserved initial start');
15564
+ return;
15565
+ }
15566
+ // Auto-worktree pending (worktreeCreating) has no repo card to point at — the
15567
+ // message IS buffered (folded on commit), so just say "hold on, building worktree"
15568
+ // instead of the misleading "pick a repo from the card above".
15569
+ const pendingReplyKey = (ds.worktreeCreating || ds.pendingRepoCommitInFlight)
15570
+ ? 'daemon.worktree_building_wait'
15571
+ : 'daemon.choose_repo_first';
15572
+ await sessionReply(anchor, tr(pendingReplyKey, undefined, localeForBot(larkAppId)), 'text', larkAppId);
15573
+ return;
15574
+ }
15575
+ finally {
15576
+ if (durableTailReservation)
15577
+ settleAsyncQueuedActivationTailAdmission(ds);
15578
+ }
14873
15579
  }
14874
- }
14875
- else {
14876
- // Worker not running re-fork with resume. This is a NEW turn, so drop
14877
- // any restored streaming-card reference; worker_ready will POST a fresh
14878
- // card instead of PATCHing the previous turn's card in place.
14879
- logger.info(`[${tag(ds)}] Worker not running, re-forking...`);
14880
- // 飞书消息轮(非文档评论轮):docCommentTargets per-turn map,本轮 turnId
14881
- // 不会命中文档评论的 key,无需显式清盘。
14882
- if (ds.usageLimitRetryTimer) {
14883
- clearTimeout(ds.usageLimitRetryTimer);
14884
- ds.usageLimitRetryTimer = undefined;
14885
- }
14886
- ds.usageLimit = undefined;
14887
- ds.currentTurnTitle = parsed.content.substring(0, 50);
14888
- // The cosmetic freeze step (above) is gated on a live worker. With no
14889
- // worker we just park the current card in frozenCards — the upcoming
14890
- // new POST will recall it. Parking instead of deleting preserves the
14891
- // "old card stays until a new one is live" invariant: if fork /
14892
- // worker_ready / POST fails, the user still sees the previous card.
14893
- parkStreamCard(ds);
14894
- ds.streamCardId = undefined;
14895
- ds.streamCardNonce = undefined;
14896
- // This is a new turn even though the worker is currently down. Force the
14897
- // first screen_update from the re-forked worker to POST a fresh card and
14898
- // drop any persisted screenshot from the previous turn. Otherwise a stale
14899
- // image_key (for example an old Claude Code frame) can be reused on the
14900
- // new Worker card until the next screenshot upload, which makes a fresh
14901
- // @mention appear to resurrect the wrong CLI UI.
14902
- ds.streamCardPending = true;
14903
- ds.currentImageKey = undefined;
14904
- persistStreamCardState(ds);
14905
- // Wrap the user message in the same `<user_message>` / `<session_id>` /
14906
- // `<botmux_reminder>` envelope as live-worker turns. Without this, the
14907
- // initial prompt that worker queues for the freshly-spawned CLI is the
14908
- // raw user text — the CLI sees no botmux routing context and stops calling
14909
- // `botmux send`, posting answers to its own terminal instead. Hits resume
14910
- // (after /close) and daemon-restart paths; both go through this branch
14911
- // because worker=null at that point.
14912
- const dsBotCfgForFork = getBot(ds.larkAppId).config;
14913
- const selfBot = getBot(ds.larkAppId);
14914
- // Adopted (bridge) sessions are the user's external CLI — don't attach a
14915
- // botmux whiteboard on re-fork. The live-worker branch above skips ensure
14916
- // for bridge sessions (isBridge); the re-fork path must match, else a
14917
- // bridge session whose worker died would gain a whiteboard binding (and a
14918
- // <whiteboard> block in its refork prompt) that its live turns never had.
14919
- if (!ds.adoptedFrom)
14920
- ensureSessionWhiteboard(ds);
14921
- // 待办池(queued)会话:CLI 从没起过,暂存的任务内容(queuedPrompt,已按角色包装好)
14922
- // 必须当首轮发出去——否则群里来的这第一条消息会顶替掉它、把用户分配的任务丢掉。
14923
- // 把暂存任务前置、用户这条消息拼在后面,一并作为首轮。forkWorker 随后清 queued。
14924
- const queuedDashboardTurn = !!(ds.session.queued && ds.session.queuedPrompt);
14925
- const reforkContent = queuedDashboardTurn
14926
- ? `${ds.session.queuedPrompt}\n\n${promptContent}`
14927
- : promptContent;
14928
- const queuedCodexAppText = ds.session.queuedCodexAppText ?? ds.pendingCodexAppText;
14929
- const reforkCodexApp = mergeQueuedCodexAppTurn({
14930
- queued: queuedDashboardTurn,
14931
- queuedText: queuedCodexAppText,
14932
- queuedMessageContext: ds.session.queuedCodexAppMessageContext ?? ds.pendingCodexAppMessageContext,
14933
- currentText: parsed.content,
14934
- currentMessageContext: codexAppMessageContext,
14935
- });
14936
- // Empty-started session that lost its worker (daemon restart, idle sweep,
14937
- // CLI exit) — same rule as the live branch: this is still the FIRST real
14938
- // user turn, so it must be built as a new topic. A queued(待办池) activation
14939
- // is excluded: its queuedPrompt already owns the first turn.
14940
- const wantsOpening = !ds.adoptedFrom && !queuedDashboardTurn && isInitialUserTurnPending(ds);
14941
- const openingBots = wantsOpening ? await getAvailableBots(larkAppId, ds.chatId) : undefined;
14942
- const reforkSender = await getThreadSender();
14943
- // An empty-started CLI has nothing to resume: `hasHistory` is set
14944
- // unconditionally by restoreActiveSessions (and by claude_exit /
14945
- // suspendWorker), so it cannot tell "booted idle" from "has real history".
14946
- // `session.lastCliInput` can: rememberLastCliInput writes it on EVERY real
14947
- // CLI input, and the empty-start fork deliberately writes none. Snapshot it
14948
- // BEFORE this turn's own rememberLastCliInput below, so a session some
14949
- // non-IM path (scheduler / webhook trigger / doc comment) already fed keeps
14950
- // its normal `--resume` instead of being cold-spawned over.
14951
- const hadPriorCliInput = !!(ds.lastCliInput ?? ds.session.lastCliInput);
14952
- const openingTurn = wantsOpening && claimInitialUserTurn(ds);
14953
- const builtReforkInput = buildReforkCliInput(ds, reforkContent, {
14954
- attachments,
14955
- mentions: parsed.mentions,
14956
- cliId: ds.session.cliId ?? dsBotCfgForFork.cliId,
14957
- cliPathOverride: ds.session.cliPathOverride ?? dsBotCfgForFork.cliPathOverride,
14958
- selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
14959
- sender: reforkSender,
14960
- substituteTrigger,
14961
- codexAppText: reforkCodexApp.text,
14962
- codexAppApplicationContext,
14963
- codexAppMessageContext: reforkCodexApp.messageContext,
14964
- });
14965
- let wrappedInput = applyQueuedCodexAppLegacyFallback(builtReforkInput, {
14966
- queued: queuedDashboardTurn,
14967
- queuedText: queuedCodexAppText,
14968
- });
14969
- if (wrappedInput !== builtReforkInput && dsBotCfgForFork.codexAppCleanInput === true) {
14970
- // Backlog sessions persisted before clean-input have no raw queued text.
14971
- // Keep this activation entirely legacy: reforkContent already contains
14972
- // queuedPrompt + the current reply, whereas a structured turn could only
14973
- // contain the reply and would silently discard the original task.
14974
- logger.warn(`[${tag(ds)}] Legacy queued dashboard task has no clean-input text; using the full legacy activation prompt`);
14975
- }
14976
- if (openingTurn) {
14977
- // Replace the follow-up envelope built above with the real opening. The
14978
- // discarded build is pure string assembly (no side effects) — keeping the
14979
- // refork statement unconditional keeps the queued/substitute wiring, and
14980
- // its guard test, on a single code path.
14981
- wrappedInput = buildNewTopicCliInput(reforkContent, ds.session.sessionId, ds.session.cliId ?? dsBotCfgForFork.cliId, ds.session.cliPathOverride ?? dsBotCfgForFork.cliPathOverride, attachments, parsed.mentions, openingBots, undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), reforkSender, {
15580
+ if (!ds) {
15581
+ // No active session at this anchor — auto-create. This branch is mostly a
15582
+ // safety net; the dispatcher routes here only when isSessionOwner() returns
15583
+ // true, but races (between check and execution, or session-closed events)
15584
+ // can land us here.
15585
+ if (activeSessions.has(sessionKey(anchor, larkAppId))) {
15586
+ logger.info(`[${larkAppId}] Session already exists for ${scope}-scope ${anchor}, routing to canonical owner`);
15587
+ await handleThreadReplyAdmitted(data, ctx);
15588
+ return;
15589
+ }
15590
+ const autoCreateChatId = ctxChatId ?? data?.message?.chat_id ?? '';
15591
+ const autoCreateChatType = ctxChatType ?? (data?.message?.chat_type === 'p2p' ? 'p2p' : 'group');
15592
+ const botCfg = getBot(larkAppId).config;
15593
+ const groupChatNamePromise = resolveGroupChatNameForNativeTitle(larkAppId, autoCreateChatId, autoCreateChatType, botCfg.cliId, parsed.content, parsed.mentions);
15594
+ logger.info(`No active session for ${scope}-scope ${anchor}, auto-creating new session...`);
15595
+ refreshCliVersion(botCfg);
15596
+ const senderOId = data.sender?.sender_id?.open_id;
15597
+ const senderUId = data.sender?.sender_id?.union_id;
15598
+ // For thread-scope: rootMessageId = anchor (real thread root).
15599
+ // For chat-scope: rootMessageId = the message_id that triggered this auto-create
15600
+ // (used as audit trail; routing key is chatId).
15601
+ const rootIdForStore = scope === 'thread' ? anchor : parsed.messageId;
15602
+ const session = sessionStore.createSession(autoCreateChatId, rootIdForStore, parsed.content.substring(0, 50), autoCreateChatType);
15603
+ const now = Date.now();
15604
+ // Bot-started handoff sessions have no human owner; keeping the bot as
15605
+ // owner makes daemon-generated footers wake that bot again.
15606
+ const ownerOpenId = isForeignBot ? undefined : senderOId;
15607
+ const ownerUnionId = isForeignBot ? undefined : senderUId;
15608
+ session.larkAppId = larkAppId;
15609
+ session.ownerOpenId = ownerOpenId;
15610
+ // creatorOpenId is the raw creating sender — set even for foreign-bot
15611
+ // sessions (unlike ownerOpenId, nulled above) so `botmux report` can find the
15612
+ // dispatch orchestrator on a no-`/repo` kickoff auto-create. See Session.creatorOpenId.
15613
+ session.creatorOpenId = senderOId;
15614
+ session.ownerUnionId = ownerUnionId;
15615
+ session.lastCallerOpenId = senderOId;
15616
+ session.quoteTargetId = parsed.messageId;
15617
+ session.quoteTargetSenderOpenId = senderOId;
15618
+ session.quoteTargetSenderIsBot = isForeignBot;
15619
+ session.lastMessageAt = new Date(now).toISOString();
15620
+ session.scope = scope;
15621
+ const groupChatName = await groupChatNamePromise;
15622
+ if (groupChatName)
15623
+ session.chatDisplayName = groupChatName;
15624
+ session.nativeSessionTitle = buildBotmuxLarkNativeSessionTitle(parsed.content, parsed.mentions, groupChatName);
15625
+ sessionStore.updateSession(session);
15626
+ // chat-scope only — see the handleNewTopic twin above (topic substitute
15627
+ // sessions keep their streaming card, no control-card compensation).
15628
+ const shouldSendSubstituteControlCard = substituteTrigger
15629
+ && scope === 'chat'
15630
+ && !botCfg.substituteMode?.disableControlCard
15631
+ && !session.substituteControlCardSent;
15632
+ // Use the same layered oncall / inherit / default lookup as handleNewTopic
15633
+ // so stale inherited peers are ignored consistently in both spawn paths.
15634
+ const { pinnedWorkingDir, oncallEntry, inheritedFrom, pinnedFromBotDefault } = await resolvePinnedWorkingDir({
15635
+ scope,
15636
+ anchor,
15637
+ chatId: autoCreateChatId,
15638
+ chatType: autoCreateChatType,
14982
15639
  larkAppId,
14983
- chatId: ds.session.chatId,
14984
- whiteboardId: ds.session.whiteboardId,
14985
- substituteTrigger,
15640
+ });
15641
+ const autoWt = willAutoWorktree(larkAppId, pinnedWorkingDir, pinnedFromBotDefault);
15642
+ // Now we know the message will spawn or pend a real session — resolve
15643
+ // sender (may await contact API budget) since every downstream branch
15644
+ // injects it either into the immediate prompt or stashes it on
15645
+ // pendingSender for the deferred spawn.
15646
+ const autoCreateSender = await getThreadSender();
15647
+ setDirectChatDisplayNameFromSender(session, autoCreateChatType, autoCreateSender);
15648
+ const newDs = {
15649
+ session,
15650
+ worker: null,
15651
+ workerPort: null,
15652
+ workerToken: null,
15653
+ larkAppId,
15654
+ chatId: autoCreateChatId,
15655
+ chatType: autoCreateChatType,
15656
+ scope,
15657
+ spawnedAt: Date.parse(session.createdAt) || now,
15658
+ cliVersion: cliVersionCache.get(cliRuntimeVersionKey(botCfg))?.version ?? 'unknown',
15659
+ lastMessageAt: now,
15660
+ hasHistory: false,
15661
+ initialStartPending: true,
15662
+ pendingRepo: !pinnedWorkingDir || autoWt,
15663
+ pendingPrompt: promptContent,
15664
+ pendingTurnId: parsed.messageId,
15665
+ pendingCodexAppText: parsed.content,
15666
+ pendingCodexAppApplicationContext: codexAppApplicationContext,
15667
+ pendingCodexAppMessageContext: codexAppMessageContext,
15668
+ pendingAttachments: attachments.length > 0 ? attachments : undefined,
15669
+ pendingMentions: parsed.mentions,
15670
+ pendingSubstituteTrigger: substituteTrigger,
15671
+ pendingSubstituteControlCard: shouldSendSubstituteControlCard,
15672
+ pendingSender: autoCreateSender,
15673
+ // R5-B1-1: freeze the plain-human steer authorization into the new-topic
15674
+ // opening metadata. buildReservedInitialInput COPIES this onto the built
15675
+ // opening payload; forkReservedInitialSession never infers it (that shared
15676
+ // helper is also used by bot-added / scheduler / system bootstrap).
15677
+ ...(codexAppSteerable ? { pendingCodexAppSteerable: true } : {}),
15678
+ ownerOpenId,
15679
+ currentTurnTitle: parsed.content.substring(0, 50),
15680
+ workingDir: pinnedWorkingDir,
15681
+ };
15682
+ if (pinnedWorkingDir) {
15683
+ newDs.session.workingDir = pinnedWorkingDir;
15684
+ sessionStore.updateSession(newDs.session);
15685
+ }
15686
+ const substituteReplyMode = substituteTrigger
15687
+ ? (botCfg.substituteMode?.replyMode ?? 'thread')
15688
+ : 'thread';
15689
+ const autoCreatePostAt = prepared?.postParticipantMentions ?? collectPostAtMentions(data?.message, ctx.forwardSeedData?.message);
15690
+ const autoCreateWindow = buildTurnParticipants(larkAppId, senderOId, senderIsBotTriState(parsed.senderType, isForeignBot), parsed.mentions, autoCreateSender?.name, autoCreatePostAt);
15691
+ beginReplyTargetTurn(newDs, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId: senderOId, participants: autoCreateWindow.participants, participantsIncomplete: autoCreateWindow.incomplete });
15692
+ sessionStore.updateSession(newDs.session);
15693
+ const registration = await claimNewDaemonSession(activeSessions, newDs);
15694
+ if (!registration.accepted) {
15695
+ if (registration.reason === 'existing_owner'
15696
+ && activeSessions.get(registration.key) === registration.owner) {
15697
+ // Second CAS loser (auto-create). Reroute to the canonical owner with the
15698
+ // SAME fully-prepared reply — NOT a bare re-parse. On a double-race
15699
+ // (new-topic scratch → auto-create → auto-create loses again) a raw
15700
+ // reroute here would drop the seed's structured mentions + post @s that
15701
+ // survived the first prepared handoff. `parsed` already holds the merged
15702
+ // seed+follow-up mentions; autoCreatePostAt already folds
15703
+ // prepared.postParticipantMentions ?? forward-seed post @s.
15704
+ await handleThreadReplyAdmitted(data, ctx, {
15705
+ parsed,
15706
+ resources,
15707
+ attachments,
15708
+ quotaChecked: true,
15709
+ queueAlreadyAppended: false,
15710
+ senderResolved: true,
15711
+ sender: autoCreateSender,
15712
+ postParticipantMentions: autoCreatePostAt,
15713
+ });
15714
+ }
15715
+ return;
15716
+ }
15717
+ if (newDs.pendingRepo) {
15718
+ stageClaimedPendingRepoSetup(activeSessions, newDs, {
15719
+ mode: autoWt ? 'auto_worktree' : 'picker',
15720
+ ...(autoWt && pinnedWorkingDir ? { baseDir: pinnedWorkingDir } : {}),
15721
+ turnId: parsed.messageId,
15722
+ });
15723
+ }
15724
+ // Route to file queue only after ownership is committed. A rejected
15725
+ // creator re-enters the canonical route above, which appends exactly once.
15726
+ messageQueue.ensureQueue(anchor);
15727
+ messageQueue.appendMessage(anchor, parsed);
15728
+ // Auto-worktree: register PENDING, build worktree off-path, commit+fork later.
15729
+ if (pinnedWorkingDir && autoWt) {
15730
+ if (await replyInvalidWorkingDirs(anchor, larkAppId, newDs))
15731
+ return;
15732
+ newDs.initialStartPending = false; // pendingRepo/worktree now owns buffering
15733
+ startAutoWorktreePending(newDs, { anchor, baseDir: pinnedWorkingDir, title: parsed.content.substring(0, 50), prompt: promptContent, operatorOpenId: ownerOpenId });
15734
+ return;
15735
+ }
15736
+ // Pinned (oncall binding or inherited from peer bot in same thread):
15737
+ // spawn CLI immediately, skip repo selection.
15738
+ if (pinnedWorkingDir) {
15739
+ if (await replyInvalidWorkingDirs(anchor, larkAppId, newDs))
15740
+ return;
15741
+ ensureSessionWhiteboard(newDs);
15742
+ const availableBots = await getAvailableBots(larkAppId, autoCreateChatId);
15743
+ await noteTurnReceived(newDs, parsed.messageId, parsed.content, autoCreateSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
15744
+ forkReservedInitialSession(newDs, availableBots);
15745
+ const reason = oncallEntry
15746
+ ? `oncall-bound chat ${autoCreateChatId}`
15747
+ : inheritedFrom
15748
+ ? `inherited from peer session ${inheritedFrom.sessionId.substring(0, 8)} (app=${inheritedFrom.larkAppId ?? 'unknown'})`
15749
+ : `bot defaultWorkingDir`;
15750
+ logger.info(`[${tag(newDs)}] ${reason} → workingDir=${pinnedWorkingDir}, skipped repo select`);
15751
+ return;
15752
+ }
15753
+ // Show repo selection card (same as handleNewTopic)
15754
+ if (await replyInvalidWorkingDirs(anchor, larkAppId, newDs))
15755
+ return;
15756
+ const scanDirs2 = getProjectScanDirs(newDs).filter(d => existsSync(d));
15757
+ let projects = [];
15758
+ if (scanDirs2.length > 0) {
15759
+ projects = scanMultipleProjects(scanDirs2, 3, repoPickerScanOptions());
15760
+ }
15761
+ if (projects.length > 0) {
15762
+ newDs.initialStartPending = false; // pendingRepo/card now owns buffering
15763
+ lastRepoScan.set(autoCreateChatId, projects);
15764
+ const currentCwd = getSessionWorkingDir(newDs);
15765
+ const cardJson = buildRepoSelectCard(projects, currentCwd, anchor, localeForBot(larkAppId), getBot(larkAppId).config.worktreeMultiPicker);
15766
+ newDs.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
15767
+ persistPendingRepoCardMessageId(newDs, newDs.repoCardMessageId);
15768
+ announcePendingRepoSession(newDs);
15769
+ logger.info(`[${tag(newDs)}] Waiting for repo selection (${projects.length} projects)`);
15770
+ }
15771
+ else {
15772
+ // No projects found — skip repo selection, spawn directly
15773
+ newDs.pendingRepo = false;
15774
+ ensureSessionWhiteboard(newDs);
15775
+ const availableBots = await getAvailableBots(larkAppId, autoCreateChatId);
15776
+ await noteTurnReceived(newDs, parsed.messageId, parsed.content, autoCreateSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
15777
+ forkReservedInitialSession(newDs, availableBots);
15778
+ }
15779
+ return;
15780
+ }
15781
+ // Existing-owner route: append once after all pending-repo early returns.
15782
+ messageQueue.ensureQueue(anchor);
15783
+ messageQueue.appendMessage(anchor, parsed);
15784
+ publishSessionMessagePreviewPatch(ds);
15785
+ // codexAppSteerable was computed ONCE above (R5-B1-1), before every admission /
15786
+ // fork branch; reuse that frozen value for the live-worker / worker-null split.
15787
+ // Send message to worker via IPC
15788
+ if (ds.worker && !ds.worker.killed) {
15789
+ const dsBotCfgForMsg = getBot(ds.larkAppId).config;
15790
+ // Adopt mode: the adopted CLI is the user's external process and was
15791
+ // never injected with botmux's skill / system prompt. Sending it the
15792
+ // `<user_message>` / `<botmux_reminder>` / `<session_id>` wrappers
15793
+ // surfaces those tags verbatim in its UI (the user reported Codex
15794
+ // showing raw XML on every Lark message). Use the bridge raw-input
15795
+ // builder for ALL adopt sessions regardless of cliId — transcript
15796
+ // harvest (Claude bridge or Codex bridge) handles the reply path
15797
+ // out-of-band.
15798
+ const isBridge = !!ds.adoptedFrom;
15799
+ const selfBot = getBot(ds.larkAppId);
15800
+ if (!isBridge)
15801
+ ensureSessionWhiteboard(ds);
15802
+ const effectiveCliId = ds.session.cliId ?? dsBotCfgForMsg.cliId;
15803
+ // Empty-started session (repo select/skip/switch booted the CLI with no
15804
+ // turn): a LIVE worker is not proof the CLI ever saw botmux's opening
15805
+ // context — only `buildNewTopicCliInput` emits <botmux_routing> /
15806
+ // <botmux_builtin_skills> / <identity>. Probe (non-consuming) before the
15807
+ // awaits below, then claim SYNCHRONOUSLY right before building so two
15808
+ // near-simultaneous first messages can only produce one opener; the loser
15809
+ // degrades to an ordinary follow-up in queue order.
15810
+ const wantsOpening = !isBridge && isInitialUserTurnPending(ds);
15811
+ const openingBots = wantsOpening ? await getAvailableBots(larkAppId, ds.chatId) : undefined;
15812
+ const turnSender = await getThreadSender();
15813
+ const openingTurn = wantsOpening && claimInitialUserTurn(ds);
15814
+ const cliInput = isBridge
15815
+ ? { content: buildBridgeInputContent(promptContent, {
15816
+ attachments,
15817
+ mentions: parsed.mentions,
15818
+ selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
15819
+ }) }
15820
+ : openingTurn
15821
+ ? buildNewTopicCliInput(promptContent, ds.session.sessionId, effectiveCliId, ds.session.cliPathOverride ?? dsBotCfgForMsg.cliPathOverride, attachments, parsed.mentions, openingBots, undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), turnSender, {
15822
+ larkAppId,
15823
+ chatId: ds.session.chatId,
15824
+ whiteboardId: ds.session.whiteboardId,
15825
+ substituteTrigger,
15826
+ codexAppText: parsed.content,
15827
+ codexAppApplicationContext,
15828
+ codexAppMessageContext,
15829
+ })
15830
+ : buildFollowUpCliInput(promptContent, ds.session.sessionId, {
15831
+ attachments,
15832
+ mentions: parsed.mentions,
15833
+ isAdoptMode: false,
15834
+ cliId: effectiveCliId,
15835
+ cliPathOverride: ds.session.cliPathOverride ?? dsBotCfgForMsg.cliPathOverride,
15836
+ sender: turnSender,
15837
+ larkAppId,
15838
+ chatId: ds.session.chatId,
15839
+ whiteboardId: ds.session.whiteboardId,
15840
+ substituteTrigger,
15841
+ codexAppText: parsed.content,
15842
+ codexAppApplicationContext,
15843
+ codexAppMessageContext,
15844
+ });
15845
+ beginNewTurn(ds, parsed.content);
15846
+ await noteTurnReceived(ds, parsed.messageId, parsed.content, turnSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
15847
+ // Codex App steer authorization was computed ONCE before the branch split
15848
+ // above (R4-B1); reuse the same frozen value here for the live-worker path.
15849
+ let accepted = false;
15850
+ try {
15851
+ accepted = sendWorkerInput(ds, cliInput, parsed.messageId, codexAppSteerable ? { codexAppSteerable: true } : {});
15852
+ // Record the input as the session's last real CLI turn ONLY after the
15853
+ // worker accepted it. Recording before delivery (the old order) persisted
15854
+ // lastCliInput / lastUserPrompt / Codex-App sidecar for a turn that never
15855
+ // reached the CLI; a rejected send then left that poison behind, and the
15856
+ // next message's worker-null refork would read it as `hadPriorCliInput`
15857
+ // and wrongly `--resume` a CLI that never took a real turn.
15858
+ if (accepted)
15859
+ rememberLastCliInput(ds, promptContent, cliInput);
15860
+ else
15861
+ logger.warn(`[${tag(ds)}] Inbound ${parsed.messageId} was not accepted by the live worker`);
15862
+ }
15863
+ finally {
15864
+ // The opening is one-shot: give it back when the worker died / refused,
15865
+ // so the next message re-opens instead of silently losing the context.
15866
+ if (openingTurn && !accepted)
15867
+ releaseInitialUserTurn(ds);
15868
+ }
15869
+ }
15870
+ else {
15871
+ // Worker not running — re-fork with resume. This is a NEW turn, so drop
15872
+ // any restored streaming-card reference; worker_ready will POST a fresh
15873
+ // card instead of PATCHing the previous turn's card in place.
15874
+ logger.info(`[${tag(ds)}] Worker not running, re-forking...`);
15875
+ // 飞书消息轮(非文档评论轮):docCommentTargets 是 per-turn map,本轮 turnId
15876
+ // 不会命中文档评论的 key,无需显式清盘。
15877
+ if (ds.usageLimitRetryTimer) {
15878
+ clearTimeout(ds.usageLimitRetryTimer);
15879
+ ds.usageLimitRetryTimer = undefined;
15880
+ }
15881
+ ds.usageLimit = undefined;
15882
+ ds.currentTurnTitle = parsed.content.substring(0, 50);
15883
+ // The cosmetic freeze step (above) is gated on a live worker. With no
15884
+ // worker we just park the current card in frozenCards — the upcoming
15885
+ // new POST will recall it. Parking instead of deleting preserves the
15886
+ // "old card stays until a new one is live" invariant: if fork /
15887
+ // worker_ready / POST fails, the user still sees the previous card.
15888
+ parkStreamCard(ds);
15889
+ ds.streamCardId = undefined;
15890
+ ds.streamCardNonce = undefined;
15891
+ // This is a new turn even though the worker is currently down. Force the
15892
+ // first screen_update from the re-forked worker to POST a fresh card and
15893
+ // drop any persisted screenshot from the previous turn. Otherwise a stale
15894
+ // image_key (for example an old Claude Code frame) can be reused on the
15895
+ // new Worker card until the next screenshot upload, which makes a fresh
15896
+ // @mention appear to resurrect the wrong CLI UI.
15897
+ ds.streamCardPending = true;
15898
+ ds.currentImageKey = undefined;
15899
+ persistStreamCardState(ds);
15900
+ // Wrap the user message in the same `<user_message>` / `<session_id>` /
15901
+ // `<botmux_reminder>` envelope as live-worker turns. Without this, the
15902
+ // initial prompt that worker queues for the freshly-spawned CLI is the
15903
+ // raw user text — the CLI sees no botmux routing context and stops calling
15904
+ // `botmux send`, posting answers to its own terminal instead. Hits resume
15905
+ // (after /close) and daemon-restart paths; both go through this branch
15906
+ // because worker=null at that point.
15907
+ const dsBotCfgForFork = getBot(ds.larkAppId).config;
15908
+ const selfBot = getBot(ds.larkAppId);
15909
+ // Adopted (bridge) sessions are the user's external CLI — don't attach a
15910
+ // botmux whiteboard on re-fork. The live-worker branch above skips ensure
15911
+ // for bridge sessions (isBridge); the re-fork path must match, else a
15912
+ // bridge session whose worker died would gain a whiteboard binding (and a
15913
+ // <whiteboard> block in its refork prompt) that its live turns never had.
15914
+ if (!ds.adoptedFrom)
15915
+ ensureSessionWhiteboard(ds);
15916
+ const stageCurrentBehindQueuedActivation = async () => {
15917
+ const tailReservation = reserveAsyncQueuedActivationTailAdmission(ds);
15918
+ try {
15919
+ const currentFollowUp = buildFollowUpCliInput(promptContent, ds.session.sessionId, {
15920
+ attachments,
15921
+ mentions: parsed.mentions,
15922
+ isAdoptMode: false,
15923
+ cliId: ds.session.cliId ?? dsBotCfgForFork.cliId,
15924
+ cliPathOverride: ds.session.cliPathOverride ?? dsBotCfgForFork.cliPathOverride,
15925
+ sender: await getThreadSender(),
15926
+ larkAppId,
15927
+ chatId: ds.session.chatId,
15928
+ whiteboardId: ds.session.whiteboardId,
15929
+ substituteTrigger,
15930
+ codexAppText: parsed.content,
15931
+ codexAppApplicationContext,
15932
+ codexAppMessageContext,
15933
+ });
15934
+ // R4-B1: freeze the admission-time steer authorization onto the queued
15935
+ // opening payload so the worker-null re-fork path carries it exactly like
15936
+ // the live-worker path (admission computed once at line ~18431; COPIED
15937
+ // here, never re-inferred). System/recovery openings keep it absent.
15938
+ if (codexAppSteerable)
15939
+ currentFollowUp.codexAppSteerable = true;
15940
+ admitQueuedActivationTail(ds, {
15941
+ userPrompt: promptContent,
15942
+ cliInput: currentFollowUp,
15943
+ turnId: parsed.messageId,
15944
+ }, tailReservation);
15945
+ }
15946
+ finally {
15947
+ settleAsyncQueuedActivationTailAdmission(ds);
15948
+ }
15949
+ ds.initialStartPending = true;
15950
+ await noteTurnReceived(ds, parsed.messageId, parsed.content, await getThreadSender(), parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
15951
+ };
15952
+ // Codex App may retain an ACK journal/FIFO head after its worker dies. A
15953
+ // new inbound turn wakes a pure recovery fork first; it stays in the exact
15954
+ // runtime tail until that recovered head produces its submission ACK.
15955
+ if (ds.session.queuedActivationPending) {
15956
+ await stageCurrentBehindQueuedActivation();
15957
+ try {
15958
+ const recoverThroughCodexLedger = (ds.session.cliId ?? dsBotCfgForFork.cliId) === 'codex-app';
15959
+ forkWorker(ds, recoverThroughCodexLedger ? '' : (ds.session.queuedActivationInput ?? ''), {
15960
+ resume: ds.session.queuedActivationResume ?? ds.hasHistory,
15961
+ turnId: ds.session.queuedActivationTurnId,
15962
+ dispatchAttempt: ds.session.queuedActivationDispatchAttempt,
15963
+ });
15964
+ if (ownsInitialStartClaim())
15965
+ retainInitialStartClaim = true;
15966
+ }
15967
+ catch (err) {
15968
+ ds.initialStartPending = false;
15969
+ throw err;
15970
+ }
15971
+ return;
15972
+ }
15973
+ // A worker that died before submitting a queued activation leaves the
15974
+ // exact opening payload parked in the durable journal. Wake that opening
15975
+ // unchanged and stage this new inbound turn behind it as a separate FIFO
15976
+ // item; rebuilding `queuedPrompt + current` here would lose any reply that
15977
+ // had already been folded into the retained activation payload.
15978
+ const retainedQueuedActivation = ds.session.queued === true
15979
+ ? ds.session.queuedActivationInput
15980
+ : undefined;
15981
+ if (retainedQueuedActivation) {
15982
+ await stageCurrentBehindQueuedActivation();
15983
+ try {
15984
+ forkWorker(ds, retainedQueuedActivation, {
15985
+ resume: ds.hasHistory,
15986
+ turnId: ds.session.queuedActivationTurnId,
15987
+ dispatchAttempt: ds.session.queuedActivationDispatchAttempt,
15988
+ });
15989
+ if (ownsInitialStartClaim())
15990
+ retainInitialStartClaim = true;
15991
+ }
15992
+ catch (err) {
15993
+ // Keep the staged tail for a later activation attempt, but release the
15994
+ // route gate so another inbound turn can trigger that attempt.
15995
+ ds.initialStartPending = false;
15996
+ throw err;
15997
+ }
15998
+ return;
15999
+ }
16000
+ // 待办池(queued)会话:CLI 从没起过,暂存的任务内容(queuedPrompt,已按角色包装好)
16001
+ // 必须当首轮发出去——否则群里来的这第一条消息会顶替掉它、把用户分配的任务丢掉。
16002
+ // 把暂存任务前置、用户这条消息拼在后面,一并作为首轮。forkWorker 随后清 queued。
16003
+ const queuedDashboardTurn = !!(ds.session.queued && ds.session.queuedPrompt);
16004
+ // A restored pending-repo/backlog owner may already have durable N+1...
16005
+ // behind its unopened N. Appending this inbound to N would overtake those
16006
+ // persisted entries. Keep N exact and append the current turn after the
16007
+ // existing tail before starting the activation journal.
16008
+ const queuedHasDurableTail = queuedDashboardTurn
16009
+ && (ds.session.queuedActivationTail?.length ?? 0) > 0;
16010
+ if (queuedHasDurableTail)
16011
+ await stageCurrentBehindQueuedActivation();
16012
+ const reforkContent = queuedDashboardTurn && !queuedHasDurableTail
16013
+ ? `${ds.session.queuedPrompt}\n\n${promptContent}`
16014
+ : queuedHasDurableTail
16015
+ ? ds.session.queuedPrompt
16016
+ : promptContent;
16017
+ const queuedCodexAppText = ds.session.queuedCodexAppText ?? ds.pendingCodexAppText;
16018
+ const reforkCodexApp = mergeQueuedCodexAppTurn({
16019
+ queued: queuedDashboardTurn,
16020
+ queuedText: queuedCodexAppText,
16021
+ queuedMessageContext: ds.session.queuedCodexAppMessageContext ?? ds.pendingCodexAppMessageContext,
16022
+ currentText: queuedHasDurableTail ? '' : parsed.content,
16023
+ currentMessageContext: queuedHasDurableTail ? undefined : codexAppMessageContext,
16024
+ });
16025
+ // Empty-started session that lost its worker (daemon restart, idle sweep,
16026
+ // CLI exit) — same rule as the live branch: this is still the FIRST real
16027
+ // user turn, so it must be built as a new topic. A queued(待办池) activation
16028
+ // is excluded: its queuedPrompt already owns the first turn.
16029
+ const wantsOpening = !ds.adoptedFrom && !queuedDashboardTurn && isInitialUserTurnPending(ds);
16030
+ const openingBots = wantsOpening ? await getAvailableBots(larkAppId, ds.chatId) : undefined;
16031
+ const reforkSender = await getThreadSender();
16032
+ // An empty-started CLI has nothing to resume: `hasHistory` is set
16033
+ // unconditionally by restoreActiveSessions (and by claude_exit /
16034
+ // suspendWorker), so it cannot tell "booted idle" from "has real history".
16035
+ // `session.lastCliInput` can: rememberLastCliInput writes it on EVERY real
16036
+ // CLI input, and the empty-start fork deliberately writes none. Snapshot it
16037
+ // BEFORE this turn's own rememberLastCliInput below, so a session some
16038
+ // non-IM path (scheduler / webhook trigger / doc comment) already fed keeps
16039
+ // its normal `--resume` instead of being cold-spawned over.
16040
+ const hadPriorCliInput = !!(ds.lastCliInput ?? ds.session.lastCliInput);
16041
+ const openingTurn = wantsOpening && claimInitialUserTurn(ds);
16042
+ const builtReforkInput = buildReforkCliInput(ds, reforkContent, {
16043
+ attachments: queuedHasDurableTail ? undefined : attachments,
16044
+ mentions: queuedHasDurableTail ? undefined : parsed.mentions,
16045
+ cliId: ds.session.cliId ?? dsBotCfgForFork.cliId,
16046
+ cliPathOverride: ds.session.cliPathOverride ?? dsBotCfgForFork.cliPathOverride,
16047
+ selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
16048
+ sender: queuedHasDurableTail ? undefined : reforkSender,
16049
+ substituteTrigger: queuedHasDurableTail ? undefined : substituteTrigger,
14986
16050
  codexAppText: reforkCodexApp.text,
14987
- codexAppApplicationContext,
16051
+ codexAppApplicationContext: queuedHasDurableTail ? undefined : codexAppApplicationContext,
14988
16052
  codexAppMessageContext: reforkCodexApp.messageContext,
14989
16053
  });
14990
- }
14991
- await noteTurnReceived(ds, parsed.messageId, parsed.content, reforkSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
14992
- try {
14993
- // Adopt sessions must re-fork via forkAdoptWorker, NOT forkWorker: the
14994
- // latter would spawn a fresh botmux-managed bmx-* CLI in the adopt cwd,
14995
- // losing the observe/bridge semantics and typing the wrapped prompt into a
14996
- // brand-new CLI instead of the user's original external pane. This branch
14997
- // is reachable whenever an adopt session's bridge worker has exited (crash,
14998
- // or the "adopted session ended" kill path) and a new Lark turn arrives.
14999
- // The turn's input rides in on the init prompt (bridge-formatted by
15000
- // buildReforkCliInput above); forkAdoptWorker queues it and the adopt idle
15001
- // detector flushes it to the observed pane. Adopt never --resumes a botmux
15002
- // session, so the openingTurn/hadPriorCliInput resume logic doesn't apply.
15003
- if (ds.adoptedFrom) {
15004
- forkAdoptWorker(ds, { prompt: wrappedInput.content, turnId: parsed.messageId });
16054
+ let wrappedInput = applyQueuedCodexAppLegacyFallback(builtReforkInput, {
16055
+ queued: queuedDashboardTurn,
16056
+ queuedText: queuedCodexAppText,
16057
+ });
16058
+ if (wrappedInput !== builtReforkInput && dsBotCfgForFork.codexAppCleanInput === true) {
16059
+ // Backlog sessions persisted before clean-input have no raw queued text.
16060
+ // Keep this activation entirely legacy: reforkContent already contains
16061
+ // queuedPrompt + the current reply, whereas a structured turn could only
16062
+ // contain the reply and would silently discard the original task.
16063
+ logger.warn(`[${tag(ds)}] Legacy queued dashboard task has no clean-input text; using the full legacy activation prompt`);
15005
16064
  }
15006
- else {
15007
- forkWorker(ds, wrappedInput, {
15008
- // See `hadPriorCliInput` above an opening on a CLI that never took any
15009
- // input cold-spawns rather than `--resume`-ing an empty session.
15010
- resume: ds.hasHistory && !(openingTurn && !hadPriorCliInput),
15011
- turnId: parsed.messageId,
16065
+ if (openingTurn) {
16066
+ // Replace the follow-up envelope built above with the real opening. The
16067
+ // discarded build is pure string assembly (no side effects) keeping the
16068
+ // refork statement unconditional keeps the queued/substitute wiring, and
16069
+ // its guard test, on a single code path. (openingTurn is mutually
16070
+ // exclusive with queuedDashboardTurn/queuedHasDurableTail.)
16071
+ wrappedInput = buildNewTopicCliInput(reforkContent, ds.session.sessionId, ds.session.cliId ?? dsBotCfgForFork.cliId, ds.session.cliPathOverride ?? dsBotCfgForFork.cliPathOverride, attachments, parsed.mentions, openingBots, undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), reforkSender, {
16072
+ larkAppId,
16073
+ chatId: ds.session.chatId,
16074
+ whiteboardId: ds.session.whiteboardId,
16075
+ substituteTrigger,
16076
+ codexAppText: reforkCodexApp.text,
16077
+ codexAppApplicationContext,
16078
+ codexAppMessageContext: reforkCodexApp.messageContext,
15012
16079
  });
15013
16080
  }
16081
+ if (!queuedHasDurableTail) {
16082
+ await noteTurnReceived(ds, parsed.messageId, parsed.content, reforkSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
16083
+ }
16084
+ try {
16085
+ // Adopt sessions must re-fork via forkAdoptWorker, NOT forkWorker: the
16086
+ // latter would spawn a fresh botmux-managed bmx-* CLI in the adopt cwd,
16087
+ // losing the observe/bridge semantics and typing the wrapped prompt into a
16088
+ // brand-new CLI instead of the user's original external pane. This branch
16089
+ // is reachable whenever an adopt session's bridge worker has exited (crash,
16090
+ // or the "adopted session ended" kill path) and a new Lark turn arrives.
16091
+ // The turn's input rides in on the init prompt (bridge-formatted by
16092
+ // buildReforkCliInput above); forkAdoptWorker queues it and the adopt idle
16093
+ // detector flushes it to the observed pane. Adopt never --resumes a botmux
16094
+ // session, so the openingTurn/hadPriorCliInput resume logic doesn't apply.
16095
+ if (ds.adoptedFrom) {
16096
+ forkAdoptWorker(ds, { prompt: wrappedInput.content, turnId: parsed.messageId });
16097
+ }
16098
+ else {
16099
+ // R4-B1: freeze the admission-time steer authorization onto this opening
16100
+ // payload — but ONLY for a genuine interactive current turn, never a
16101
+ // queued-dashboard / durable-tail SYSTEM opening (those own the first
16102
+ // turn and are forced-serial). Admission computed once (~18431); COPIED.
16103
+ if (codexAppSteerable && !queuedDashboardTurn && !queuedHasDurableTail) {
16104
+ wrappedInput.codexAppSteerable = true;
16105
+ }
16106
+ forkWorker(ds, wrappedInput, {
16107
+ // See `hadPriorCliInput` above — an opening on a CLI that never took any
16108
+ // input cold-spawns rather than `--resume`-ing an empty session.
16109
+ resume: ds.hasHistory && !(openingTurn && !hadPriorCliInput),
16110
+ turnId: queuedHasDurableTail
16111
+ ? (ds.session.queuedActivationTurnId ?? `queued-opening:${ds.session.sessionId}`)
16112
+ : parsed.messageId,
16113
+ });
16114
+ }
16115
+ }
16116
+ catch (e) {
16117
+ if (openingTurn)
16118
+ releaseInitialUserTurn(ds);
16119
+ throw e;
16120
+ }
16121
+ // Record the input as the session's last real CLI turn ONLY after the fork
16122
+ // succeeded. Recording before the fork (the old order) persisted lastCliInput
16123
+ // for a turn that never launched when forkWorker threw; the retry then read
16124
+ // that poison as `hadPriorCliInput` and wrongly `--resume`d a CLI that never
16125
+ // took a real turn — breaking the empty-start invariant. forkWorker itself
16126
+ // persists the session (clearing queued); this records last* + reply state.
16127
+ rememberLastCliInput(ds, queuedHasDurableTail ? ds.session.queuedPrompt : promptContent, wrappedInput);
16128
+ sessionStore.updateSession(ds.session);
16129
+ if (ownsInitialStartClaim()) {
16130
+ if (initialStartClaimWaitsForQueuedAck) {
16131
+ retainInitialStartClaim = true;
16132
+ }
16133
+ else {
16134
+ // Ordinary cold reforks have no adapter-submission ACK. The child owns
16135
+ // the opening init after forkWorker returns, so hand any concurrently
16136
+ // buffered followers to its IPC queue now in exact arrival order.
16137
+ retainInitialStartClaim = !releaseQueuedActivationReservation(ds);
16138
+ }
16139
+ }
15014
16140
  }
15015
- catch (e) {
15016
- if (openingTurn)
15017
- releaseInitialUserTurn(ds);
15018
- throw e;
16141
+ }
16142
+ finally {
16143
+ // Pre-fork quota/resource/persistence failures and any non-queued route
16144
+ // that returned without accepting a worker must not strand the route gate.
16145
+ // Fence by token so an older handler can never clear a newer generation.
16146
+ if (ownsInitialStartClaim() && !retainInitialStartClaim) {
16147
+ clearInitialStartClaim(ds, initialStartClaimToken);
15019
16148
  }
15020
- // Record the input as the session's last real CLI turn ONLY after the fork
15021
- // succeeded. Recording before the fork (the old order) persisted lastCliInput
15022
- // for a turn that never launched when forkWorker threw; the retry then read
15023
- // that poison as `hadPriorCliInput` and wrongly `--resume`d a CLI that never
15024
- // took a real turn — breaking the empty-start invariant. forkWorker itself
15025
- // persists the session (clearing queued); this records last* + reply state.
15026
- rememberLastCliInput(ds, promptContent, wrappedInput);
15027
- sessionStore.updateSession(ds.session);
15028
16149
  }
15029
16150
  }
15030
16151
  /**
@@ -15105,6 +16226,20 @@ async function autoCreateDocSession(sub, larkAppId, ctx) {
15105
16226
  * success and advancing the poll cursor past a later failure.
15106
16227
  */
15107
16228
  const handledDocCommentTurns = new BoundedMap(5_000);
16229
+ /**
16230
+ * Thrown by a doc-comment turn that must be DEFERRED (not delivered) because the
16231
+ * bound session still holds durable opening ownership (worker:null repo/worktree
16232
+ * setup or a protected activation/dispatch ledger). Deferral is retryable: the
16233
+ * outer handler releases the claim and returns false so the provider cursor
16234
+ * re-attempts this exact comment after the opening owner reconciles. Distinct
16235
+ * from a real delivery failure so it is not logged/handled as an error.
16236
+ */
16237
+ class DocCommentDeferredError extends Error {
16238
+ constructor(reason) {
16239
+ super(reason);
16240
+ this.name = 'DocCommentDeferredError';
16241
+ }
16242
+ }
15108
16243
  async function runClaimedDocCommentTurn(claimKey, work, onOwnerFailure) {
15109
16244
  const existing = handledDocCommentTurns.get(claimKey);
15110
16245
  if (existing)
@@ -15146,6 +16281,9 @@ export function __testOnly_resetDocCommentClaims() {
15146
16281
  * 走 resume 重 fork);已 /close 的会话其订阅在关闭时已退订,这里查不到 ds 即跳过。
15147
16282
  */
15148
16283
  async function handleDocComment(ctx) {
16284
+ return withBotTurnAdmission(ctx.larkAppId, () => handleDocCommentAdmitted(ctx));
16285
+ }
16286
+ async function handleDocCommentAdmitted(ctx) {
15149
16287
  const { larkAppId, sub, commentId, text } = ctx;
15150
16288
  const turnId = ctx.replyId || commentId;
15151
16289
  const claimKey = `${larkAppId}:${sub.fileToken}:${turnId}`;
@@ -15218,6 +16356,19 @@ async function handleDocComment(ctx) {
15218
16356
  const generation = captureRoutingGeneration(ds);
15219
16357
  deliverySession = generation.session;
15220
16358
  ensureCurrentRoutingGeneration(generation, 'comment:start');
16359
+ // Durable opening ownership (PR #597): a worker:null owner that is still
16360
+ // in repo/worktree setup or holds a protected activation/dispatch ledger
16361
+ // must NOT be reforked by a doc comment. The provider cursor is the retry
16362
+ // owner; deferring (throw → outer catch returns false + releases the
16363
+ // claim) leaves this exact comment available after the opening owner
16364
+ // reconciles, instead of clobbering its setup prompt. This throws BEFORE
16365
+ // any reaction / noteTurnReceived side effect, so nothing needs undoing.
16366
+ if ((!ds.worker || ds.worker.killed)
16367
+ && (ds.pendingRepo
16368
+ || ds.worktreeCreating
16369
+ || hasProtectedSessionMutationOwnership(ds))) {
16370
+ throw new DocCommentDeferredError(`session ${ds.session.sessionId.slice(0, 8)} has durable opening ownership`);
16371
+ }
15221
16372
  // 给用户的回复加 "Typing" reaction,让评论者知道 bot 正在处理。
15222
16373
  if (userReplyId) {
15223
16374
  reactionId = await addCommentReaction(larkAppId, { fileToken: sub.fileToken, fileType: sub.fileType }, commentId, userReplyId, 'Typing');
@@ -15330,10 +16481,44 @@ async function handleDocComment(ctx) {
15330
16481
  }, cleanupFailedDelivery);
15331
16482
  }
15332
16483
  catch (err) {
16484
+ if (err instanceof DocCommentDeferredError) {
16485
+ // Retryable defer (durable opening ownership): claim was released above so
16486
+ // the provider cursor re-attempts this exact comment later. Not a failure.
16487
+ logger.warn(`[doc-comment] turn ${turnId.slice(0, 12)} deferred before acceptance `
16488
+ + `(${err.message}); provider cursor retains retry ownership`);
16489
+ return false;
16490
+ }
15333
16491
  logger.warn(`[doc-comment] delivery failed, claim released for retry file=${sub.fileToken.slice(0, 12)} turn=${turnId.slice(0, 12)} err=${err instanceof Error ? err.message : String(err)}`);
15334
16492
  return false;
15335
16493
  }
15336
16494
  }
16495
+ export const __testOnly_handleDocComment = (ctx) => handleDocComment(ctx);
16496
+ /** Preserve accepted/pending work when a normal group is converted to topic
16497
+ * mode. The converted event is routed to a new thread key, so keeping the old
16498
+ * chat owner does not block the new session; it only keeps the durable owner
16499
+ * addressable for completion or explicit close. */
16500
+ function handleChatModeConverted(chatId, larkAppId) {
16501
+ const key = sessionKey(chatId, larkAppId);
16502
+ const owner = activeSessions.get(key);
16503
+ if (!owner) {
16504
+ logger.info(`[chat-mode-converted] ${chatId.substring(0, 12)} evicted=false; no chat-scope owner`);
16505
+ return false;
16506
+ }
16507
+ // Conversion preprocessing can run outside bot-turn admission. Converted
16508
+ // turns route by their new thread key, so retaining a pending owner at the
16509
+ // old chat key does not block them.
16510
+ const pending = hasProtectedSessionMutationOwnership(owner)
16511
+ || owner.pendingRepo === true;
16512
+ if (pending) {
16513
+ logger.warn(`[chat-mode-converted] ${chatId.substring(0, 12)} preserved pending owner `
16514
+ + `${owner.session.sessionId.substring(0, 8)}; converted turns route by thread key`);
16515
+ return false;
16516
+ }
16517
+ const evicted = activeSessions.get(key) === owner && activeSessions.delete(key);
16518
+ logger.info(`[chat-mode-converted] ${chatId.substring(0, 12)} evicted=${evicted}; worker (if any) keeps running until /close`);
16519
+ return evicted;
16520
+ }
16521
+ export const __testOnly_handleChatModeConverted = handleChatModeConverted;
15337
16522
  let docCommentPollRunning = false;
15338
16523
  /**
15339
16524
  * `/watch-comment --all` 的应用身份增量轮询。
@@ -15806,6 +16991,21 @@ export async function startDaemon(botIndex) {
15806
16991
  // 退化为纯 evaluateTalk(openId, chatType),与改动前语义一致。三条分派由
15807
16992
  // test/ask-answer-talk-dispatch.test.ts 直接咬住。
15808
16993
  setAskCanTalkChecker((appId, chatId, openId, chatType, actor) => evaluateAskAnswerTalk(appId, chatId, openId, chatType, actor));
16994
+ // Resume pending `botmux ask` cards that outlived a daemon restart. Each is
16995
+ // restored as a DORMANT ask (card still live in Feishu, no waiter yet). The
16996
+ // surviving CLI hook — whose /api/asks connection dropped on restart and is
16997
+ // retrying — re-registers the same ask by its stable key and re-attaches a
16998
+ // waiter, so the answer flows back through the normal hook directive instead
16999
+ // of the CLI falling into a stuck native picker. Scoped to this daemon's bot.
17000
+ try {
17001
+ // Bind the durable store to the real data dir (dependency-injected so unit
17002
+ // tests use a temp dir and never touch live data — codex P1-4).
17003
+ setAskPersistStoreBroker(createAskPersistStore(join(config.session.dataDir, 'asks')));
17004
+ restorePersistedAsksBroker(Date.now(), cfg.larkAppId);
17005
+ }
17006
+ catch (e) {
17007
+ logger.warn(`[ask] restorePersistedAsks failed: ${e instanceof Error ? e.message : String(e)}`);
17008
+ }
15809
17009
  writePidFile();
15810
17010
  const memoryDiagnostics = startMemoryDiagnostics();
15811
17011
  // Publish self-descriptor for the dashboard registry. The dashboard sibling
@@ -15825,6 +17025,10 @@ export async function startDaemon(botIndex) {
15825
17025
  // agent-facing, live-origin-gated endpoints. Internal control endpoints use
15826
17026
  // a separate daemon-to-daemon credential and never trust this port marker.
15827
17027
  process.env.BOTMUX_DAEMON_IPC_PORT = String(ipcPort);
17028
+ const daemonProcessStartIdentity = readSupervisorProcessStartIdentity(process.pid);
17029
+ if (!daemonProcessStartIdentity) {
17030
+ throw new Error('cannot bind daemon descriptor to a process-start identity');
17031
+ }
15828
17032
  const desc = {
15829
17033
  larkAppId: cfg.larkAppId,
15830
17034
  botName: cfg.displayName ?? cfg.larkAppId,
@@ -15832,6 +17036,7 @@ export async function startDaemon(botIndex) {
15832
17036
  botIndex: idx,
15833
17037
  ipcPort,
15834
17038
  pid: process.pid,
17039
+ processStartIdentity: daemonProcessStartIdentity,
15835
17040
  startedAt: Date.now(),
15836
17041
  bootInstanceId: generateWorkflowDaemonBootInstanceId(),
15837
17042
  workflowIpcProtocol: 'v1',
@@ -15925,12 +17130,53 @@ export async function startDaemon(botIndex) {
15925
17130
  // One cap implementation shared by event-driven checks (process start / idle
15926
17131
  // edge) and the 60s safety-net timer below. Each daemon owns exactly one
15927
17132
  // bot's activeSessions map, so the configured limit is per bot.
17133
+ let liveSessionCapSweepPending = false;
15928
17134
  const enforceLiveSessionCap = (source) => {
15929
- const maxLiveWorkers = getBot(cfg.larkAppId).config.maxLiveWorkers;
15930
- const suspended = sweepIdleWorkers(activeSessions, { maxLiveWorkers });
15931
- if (suspended.length > 0) {
15932
- logger.info(`[idle-worker-sweeper] suspended ${suspended.length} session(s) over per-bot cap `
15933
- + `${maxLiveWorkers ?? DEFAULT_MAX_LIVE_WORKERS} source=${source}`);
17135
+ // Coalesce spawn/idle/timer bursts. The detached mutation evaluates the
17136
+ // current map and current config only after every admitted turn has drained,
17137
+ // so one pending sweep subsumes all triggers that arrive before it runs.
17138
+ if (liveSessionCapSweepPending)
17139
+ return;
17140
+ liveSessionCapSweepPending = true;
17141
+ void (async () => {
17142
+ try {
17143
+ const maxLiveWorkers = getBot(cfg.larkAppId).config.maxLiveWorkers;
17144
+ const suspended = await sweepIdleWorkersAfterTurnDrain(cfg.larkAppId, activeSessions, { maxLiveWorkers });
17145
+ if (suspended.length > 0) {
17146
+ logger.info(`[idle-worker-sweeper] suspended ${suspended.length} session(s) over per-bot cap `
17147
+ + `${maxLiveWorkers ?? DEFAULT_MAX_LIVE_WORKERS} source=${source}`);
17148
+ }
17149
+ }
17150
+ catch (err) {
17151
+ logger.warn(`[idle-worker-sweeper] cap enforcement failed: ${err instanceof Error ? err.message : String(err)}`);
17152
+ }
17153
+ finally {
17154
+ liveSessionCapSweepPending = false;
17155
+ }
17156
+ })();
17157
+ };
17158
+ // Converge an incomplete keyed idempotent async turn on CLI/worker exit, and if
17159
+ // the durable dispatch_unknown write itself FAILS (EIO/ENOSPC/foreign slot),
17160
+ // take the session to an OBSERVABLE fail-closed terminal by closing it —
17161
+ // trigger-result's closed-branch then resolves `failed` instead of stranding the
17162
+ // poller on `running` while the same Node worker auto-restarts to a healthy idle
17163
+ // CLI (codex #776 round-8 finding #2). Never throws into the exit handler.
17164
+ const failCloseIdempotentTurnIfConvergenceWriteFailed = (ds, exitingWorkerGeneration) => {
17165
+ let outcome;
17166
+ try {
17167
+ outcome = convergeIdempotentAsyncTurnOnWorkerExit(ds, exitingWorkerGeneration);
17168
+ }
17169
+ catch (err) {
17170
+ logger.error(`[idempotency] exit convergence threw for ${ds.session.sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : err}`);
17171
+ outcome = 'write_failed';
17172
+ }
17173
+ if (outcome === 'write_failed') {
17174
+ // Durable terminal could not be written. Close the session so its persisted
17175
+ // status becomes `closed` → trigger-result resolves `failed` (soft terminal),
17176
+ // and resolveIdempotencyHit sees a not-live session on retry. This is the
17177
+ // observable fail-closed path the round-8 finding requires.
17178
+ void closeSessionHelper(ds.session.sessionId).catch(() => { });
17179
+ logger.error(`[idempotency] fail-closed session ${ds.session.sessionId.slice(0, 8)} after exit-convergence write failure`);
15934
17180
  }
15935
17181
  };
15936
17182
  // Initialise worker pool with daemon callbacks
@@ -15942,10 +17188,26 @@ export async function startDaemon(botIndex) {
15942
17188
  // Route through the dashboard-aware helper so session.exited / session.update
15943
17189
  // events fire for withdrawn-message / crash / adopt-exit teardown paths too,
15944
17190
  // matching the dashboard-driven close.
15945
- void closeSessionHelper(ds.session.sessionId).catch(() => { });
15946
- logger.info(`[${ds.session.sessionId.substring(0, 8)}] Session auto-closed (message withdrawn)`);
17191
+ const sessionId = ds.session.sessionId;
17192
+ return runDetachedBotTurnMutation(ds.larkAppId, async () => {
17193
+ const current = findActiveBySessionId(sessionId);
17194
+ if (current !== ds)
17195
+ return false;
17196
+ if (hasProtectedSessionMutationOwnership(current)) {
17197
+ logger.info(`[${sessionId.substring(0, 8)}] Withdraw auto-close deferred: Codex App FIFO became unsettled`);
17198
+ return false;
17199
+ }
17200
+ await closeSessionHelper(sessionId);
17201
+ logger.info(`[${sessionId.substring(0, 8)}] Session auto-closed (message withdrawn)`);
17202
+ return true;
17203
+ }).catch((err) => {
17204
+ logger.warn(`[${sessionId.substring(0, 8)}] Withdraw auto-close mutation failed; session retained: `
17205
+ + `${err instanceof Error ? err.message : String(err)}`);
17206
+ return false;
17207
+ });
15947
17208
  },
15948
17209
  enforceLiveSessionCap: () => enforceLiveSessionCap('session_change'),
17210
+ onQueuedActivationSubmitted,
15949
17211
  onTurnTerminal(ds, terminal, context) {
15950
17212
  const enqueued = vcMeetingTerminalReconciler?.enqueue(terminal, context);
15951
17213
  if (terminal.dispatchAttempt !== undefined && enqueued?.accepted) {
@@ -15956,7 +17218,17 @@ export async function startDaemon(botIndex) {
15956
17218
  onDeferredScheduleTurnSettled(ds, context) {
15957
17219
  scheduleDeferredScheduleSettlement(ds, context);
15958
17220
  },
15959
- onCliExit(_ds, context) {
17221
+ onCliExit(ds, context) {
17222
+ // Same idempotent-async convergence as onWorkerExit: the MANAGED CLI can
17223
+ // exit inside a still-live Node worker (persistent-pane / codex-app
17224
+ // auto-restart), in which case onWorkerExit never fires. An incomplete
17225
+ // keyed async turn whose CLI died with no final_output must still converge
17226
+ // to a durable dispatch_unknown, or trigger-result polls `running` and a
17227
+ // same-key retry reuses the dead generation forever (codex #776 round-6
17228
+ // finding #1 — the onCliExit half of that path). Idempotent + generation-
17229
+ // gated: safe under the onCliExit/onWorkerExit double-callback race, and a
17230
+ // no-op once final_output cleared the stamp.
17231
+ failCloseIdempotentTurnIfConvergenceWriteFailed(ds, context.workerGeneration);
15960
17232
  const result = handleVcMeetingWorkerGenerationExit(context, {
15961
17233
  dataDir: config.session.dataDir,
15962
17234
  selfAppId: cfg.larkAppId,
@@ -15966,7 +17238,13 @@ export async function startDaemon(botIndex) {
15966
17238
  + `session=${context.sessionId.slice(0, 8)} generation=${context.workerGeneration}`);
15967
17239
  }
15968
17240
  },
15969
- onWorkerExit(_ds, context) {
17241
+ onWorkerExit(ds, context) {
17242
+ // Converge an incomplete idempotent async turn (options.idempotencyKey):
17243
+ // a worker that died with no final_output would otherwise poll `running`
17244
+ // and let a same-key retry `reuse` the dead session forever, until the next
17245
+ // boot reconcile (codex #776 round-6 finding #1). Best-effort + never throws
17246
+ // into the exit handler; a durable-write failure fail-closes the session.
17247
+ failCloseIdempotentTurnIfConvergenceWriteFailed(ds, context.workerGeneration);
15970
17248
  const result = handleVcMeetingWorkerGenerationExit(context, {
15971
17249
  dataDir: config.session.dataDir,
15972
17250
  selfAppId: cfg.larkAppId,
@@ -15991,10 +17269,39 @@ export async function startDaemon(botIndex) {
15991
17269
  onDurableExpiryReady(_ds, context) {
15992
17270
  vcMeetingRuntimeLeaseRecovery.acknowledge(context);
15993
17271
  },
17272
+ async onCodexAppLedgerDrained(ds) {
17273
+ await withBotTurnMutation(ds.larkAppId, async () => {
17274
+ await closeCliMismatchedSessionsForBot(ds.larkAppId);
17275
+ });
17276
+ },
15994
17277
  });
15995
17278
  // Expose the activeSessions Map (owned by daemon) to worker-pool readers,
15996
17279
  // so dashboard IPC and other consumers can list/lookup live sessions.
15997
17280
  setActiveSessionsRegistry(activeSessions);
17281
+ // Idempotency boot reconcile — MUST run before startIpcServer binds (a normal
17282
+ // fleet has no core-only readiness gate, so a live /api/trigger could otherwise
17283
+ // interleave with the sweep and have its fresh lease mistaken for stale) and is
17284
+ // scoped to THIS bot (the dataDir is shared across bots). Converges leases left
17285
+ // by a previous boot: `attempting` → durable failed(dispatch_unknown) + close;
17286
+ // `reserved` → CAS-remove + close. Returns the sessionIds it terminalized/closed
17287
+ // so restoreActiveSessions can quarantine them from re-attach (else a session the
17288
+ // poller now sees `failed` could be reattached and keep running — state/exec
17289
+ // divergence). sessionStore is init'd + worker pool is up by here.
17290
+ //
17291
+ // FAIL-CLOSED: if reconcile throws (a lease it could not prove converged — a
17292
+ // strict-failed write that failed, a corrupt lease, an unlink/close that
17293
+ // errored), we must NOT bind the IPC server and restore sessions as if
17294
+ // everything converged — that is exactly the "poller hangs running / orphan
17295
+ // re-attach" this feature exists to prevent. Abort this bot's startup so an
17296
+ // operator/supervisor sees it, rather than fail-open into an inconsistent state.
17297
+ let idempotencyQuarantinedSessionIds;
17298
+ try {
17299
+ idempotencyQuarantinedSessionIds = await reconcileIdempotencyLeasesOnBoot(cfg.larkAppId, getDaemonBootId());
17300
+ }
17301
+ catch (err) {
17302
+ logger.error(`[idempotency] boot reconcile failed to converge — aborting bot startup (fail-closed): ${err instanceof Error ? err.message : err}`);
17303
+ throw err instanceof Error ? err : new Error(String(err));
17304
+ }
15998
17305
  // Seed dashboard IPC botName with the custom displayName (falling back to the
15999
17306
  // bot's config id); the friendly name from /bot/v3/info is wired into the
16000
17307
  // registry descriptor (below) but the IPC server also needs its own copy for
@@ -16017,6 +17324,8 @@ export async function startDaemon(botIndex) {
16017
17324
  // host-only shared secret on every daemon IPC route except the tiny
16018
17325
  // capability-gated receiver/readiness apertures in dashboard-ipc-server.
16019
17326
  loadOrCreateDashboardSecret(join(homedir(), '.botmux', '.dashboard-secret'));
17327
+ let markIpcReady;
17328
+ const ipcReady = new Promise((resolve) => { markIpcReady = resolve; });
16020
17329
  const coreOnly = process.env.BOTMUX_CORE_ONLY === '1';
16021
17330
  // Core-only keeps the trusted-host HMAC ON (codex P1: authRequired:false opened
16022
17331
  // ALL 96 IPC routes — a co-resident model turn could read/perturb sessions,
@@ -16033,6 +17342,7 @@ export async function startDaemon(botIndex) {
16033
17342
  port: ipcPort,
16034
17343
  host: '127.0.0.1',
16035
17344
  authRequired: true,
17345
+ ready: ipcReady,
16036
17346
  coreOnlyPublicRoutes: coreOnly,
16037
17347
  // Fleet: probe upward so a port race can't crash boot. Core-only: BIND-OR-FAIL
16038
17348
  // on the exact BOTMUX_API_PORT — riff was handed that port and the service must
@@ -16050,6 +17360,23 @@ export async function startDaemon(botIndex) {
16050
17360
  // emitted LATER, AFTER restoreActiveSessions completes — until then BOTH
16051
17361
  // /healthz AND the public control routes (trigger/result/insight) return 503,
16052
17362
  // so riff never triggers into a racing durable restore (codex P1).
17363
+ // Publish daemon ownership immediately after IPC binds, then perform the
17364
+ // first session-file load under SessionStore's cross-process lock. An
17365
+ // offline CLI either observes this descriptor and delegates, or it already
17366
+ // holds the file lock; in the latter case this load waits and sees its atomic
17367
+ // mutation. Never load a stale cache in an unadvertised startup window.
17368
+ desc.lastHeartbeat = Date.now();
17369
+ writeDaemonDescriptor(desc);
17370
+ sessionStore.listSessions();
17371
+ const descriptorHeartbeat = setInterval(() => {
17372
+ desc.lastHeartbeat = Date.now();
17373
+ try {
17374
+ writeDaemonDescriptor(desc);
17375
+ }
17376
+ catch { /* best effort */ }
17377
+ }, 30_000);
17378
+ if (typeof descriptorHeartbeat.unref === 'function')
17379
+ descriptorHeartbeat.unref();
16053
17380
  // Single reverse-proxy port that fronts every session's web terminal under
16054
17381
  // /s/{sessionId}, so dev-machine users forward one port (proxyBasePort+idx)
16055
17382
  // instead of one per topic. Bound on the public host so `ssh -L` can reach it.
@@ -16076,11 +17403,26 @@ export async function startDaemon(botIndex) {
16076
17403
  // Quiet-restart leaves sessions registered but worker-less until messaged.
16077
17404
  // Wake the worker on terminal access so links open without a manual ping.
16078
17405
  ensureWorkerPort: async (sessionId) => {
17406
+ let owner;
16079
17407
  for (const ds of activeSessions.values()) {
16080
- if (ds.session.sessionId === sessionId)
16081
- return ensureTerminalWorkerPort(ds);
17408
+ if (ds.session.sessionId === sessionId) {
17409
+ owner = ds;
17410
+ break;
17411
+ }
16082
17412
  }
16083
- return undefined;
17413
+ if (!owner)
17414
+ return undefined;
17415
+ return withBotTurnAdmission(owner.larkAppId, () => {
17416
+ // The gate may have held this request behind a mutation/close. Resolve
17417
+ // the canonical record again before a lazy fork; never wake a stale ds
17418
+ // captured before the wait.
17419
+ for (const current of activeSessions.values()) {
17420
+ if (current.session.sessionId === sessionId) {
17421
+ return ensureTerminalWorkerPort(current);
17422
+ }
17423
+ }
17424
+ return undefined;
17425
+ });
16084
17426
  },
16085
17427
  });
16086
17428
  // Only mark the proxy live after a successful bind — buildTerminalUrl then
@@ -16100,20 +17442,6 @@ export async function startDaemon(botIndex) {
16100
17442
  if (config.web.externalPort) {
16101
17443
  logger.info(`[terminal-proxy] terminal links advertise external port ${config.web.externalPort + idx} (WEB_EXTERNAL_PORT ${config.web.externalPort} + bot ${idx})`);
16102
17444
  }
16103
- // Now that the IPC port is actually listening, publish the descriptor so
16104
- // the dashboard can discover us and successfully fetch /api/sessions etc.
16105
- desc.lastHeartbeat = Date.now();
16106
- writeDaemonDescriptor(desc);
16107
- const descriptorHeartbeat = setInterval(() => {
16108
- desc.lastHeartbeat = Date.now();
16109
- try {
16110
- writeDaemonDescriptor(desc);
16111
- }
16112
- catch { /* best effort */ }
16113
- }, 30_000);
16114
- // Don't keep the event loop alive on this interval alone.
16115
- if (typeof descriptorHeartbeat.unref === 'function')
16116
- descriptorHeartbeat.unref();
16117
17445
  // Reap a dead prior daemon's detached classifier before any prepared
16118
17446
  // proposal can be recovered. Per-proposal generation locks still serialize
16119
17447
  // live rolling-restart overlap; this sweep handles only dead-owner markers.
@@ -16129,6 +17457,10 @@ export async function startDaemon(botIndex) {
16129
17457
  catch (err) {
16130
17458
  logger.warn(`[v3-distillation] global commit recovery failed: ${stableV3DistillationErrorCode(err)}`);
16131
17459
  }
17460
+ // Do not start any Lark dispatcher until durable sessions have been restored
17461
+ // into the routing registry. Otherwise an inbound same-anchor turn can create
17462
+ // a second owner while the old unsettled row exists only on disk.
17463
+ const startEventDispatchers = [];
16132
17464
  // Per-bot initialization
16133
17465
  for (const bot of getAllBots()) {
16134
17466
  const cfg = bot.config;
@@ -16276,29 +17608,25 @@ export async function startDaemon(botIndex) {
16276
17608
  logger.info(`[auto-start:入群] ${cfg.larkAppId} autoStartOnGroupJoin 已开启 —— ` +
16277
17609
  `请确认飞书开放平台已订阅事件 im.chat.member.bot.added_v1 且开通群成员读取权限,否则被拉群不会触发。`);
16278
17610
  }
16279
- // Start event dispatcher for this bot
17611
+ // Build the dispatcher now for authorization replay, but start it only
17612
+ // after restore has published every durable route owner.
16280
17613
  const botEventHandlers = {
16281
- handleCardAction: (data, appId) => handleCardAction(data, cardDeps, appId),
17614
+ handleCardAction: (data, appId) => withBotTurnAdmission(appId, () => handleCardAction(data, cardDeps, appId)),
16282
17615
  handleNewTopic: (data, ctx) => handleNewTopic(data, ctx),
16283
17616
  handleThreadReply: (data, ctx) => handleThreadReply(data, ctx),
16284
- handleBotAdded: (chatId, operatorOpenId, appId) => handleBotAdded(chatId, operatorOpenId, appId),
17617
+ handleBotAdded: (chatId, operatorOpenId, appId) => withBotTurnAdmission(appId, () => handleBotAdded(chatId, operatorOpenId, appId)),
16285
17618
  handleDocComment: (ctx) => handleDocComment(ctx),
16286
- handleVcMeetingPush: (ctx) => handleVcMeetingPush(ctx),
17619
+ handleVcMeetingPush: (ctx) => withBotTurnAdmission(ctx.larkAppId, () => handleVcMeetingPush(ctx)),
16287
17620
  beforeSessionTurn: (data, ctx) => maybeCatchUpVcMeetingConsumerBeforeTurn(data, ctx),
16288
17621
  isSessionOwner: (anchor, appId) => activeSessions.has(sessionKey(anchor, appId)),
16289
17622
  resolveReplyThreadAlias: (rootId, chatId, appId) => findChatReplyAlias(rootId, chatId, appId),
16290
17623
  // Chat was converted 普通群 → 话题群 while we held a chat-scope session.
16291
- // Evict it from the routing map so subsequent inbound messages can land
16292
- // on a fresh thread-scope session (dispatcher already rerouted this turn
16293
- // to handleNewTopic). The worker is left running on purpose: the user may
16294
- // still have its web terminal open, and `/close` is the canonical cleanup
16295
- // path. Scheduler tasks tied to this session keep their `scope='chat'`
16296
- // semantics — that's an edge case worth following up on, not blocking
16297
- // the main fix.
17624
+ // Idle legacy owners are evicted so subsequent inbound messages land on
17625
+ // fresh thread-scope sessions. Owners with accepted/pending work remain
17626
+ // addressable; the dispatcher already reroutes converted turns by their
17627
+ // new thread root, so preserving that old chat key cannot steal them.
16298
17628
  onChatModeConverted: (chatId, appId) => {
16299
- const key = sessionKey(chatId, appId);
16300
- const evicted = activeSessions.delete(key);
16301
- logger.info(`[chat-mode-converted] ${chatId.substring(0, 12)} evicted=${evicted}; worker (if any) keeps running until /close`);
17629
+ handleChatModeConverted(chatId, appId);
16302
17630
  },
16303
17631
  };
16304
17632
  // 存起来供授权成功后重放消息用(replayGrantedMessage → replayMessageEvent)。
@@ -16306,7 +17634,7 @@ export async function startDaemon(botIndex) {
16306
17634
  // apiOnly bots never subscribe to Feishu events → no WSClient. This is the
16307
17635
  // core decoupling: the daemon serves the HTTP control API only.
16308
17636
  if (!cfg.apiOnly) {
16309
- startLarkEventDispatcher(cfg.larkAppId, cfg.larkAppSecret, botEventHandlers, normalizeBrand(cfg.brand));
17637
+ startEventDispatchers.push(() => startLarkEventDispatcher(cfg.larkAppId, cfg.larkAppSecret, botEventHandlers, normalizeBrand(cfg.brand)));
16310
17638
  }
16311
17639
  // A distillation command is durably prepared before its model run/card
16312
17640
  // delivery. Resume active prepared/proposed allocations after a daemon
@@ -16326,7 +17654,11 @@ export async function startDaemon(botIndex) {
16326
17654
  // See reapOrphanWorkers() in worker-pool.ts.
16327
17655
  reapOrphanWorkers();
16328
17656
  // Restore active sessions from previous run
16329
- await restoreActiveSessions(activeSessions);
17657
+ // Restore active sessions from previous run
17658
+ await restoreActiveSessions(activeSessions, idempotencyQuarantinedSessionIds);
17659
+ // Restore complete → /api/asks may now safely 403 unknown sessions again; a
17660
+ // reconnecting ask hook that raced the restore got retryable 503s until here.
17661
+ sessionsRestored = true;
16330
17662
  // Now that activeSessions is populated, release the forward-followup flush
16331
17663
  // barrier. Persisted seeds were loaded into the buffer at dispatcher startup
16332
17664
  // (so root-linked clarifications can pair with them), but their flushes wait
@@ -16334,6 +17666,12 @@ export async function startDaemon(botIndex) {
16334
17666
  for (const bot of getAllBots()) {
16335
17667
  markForwardFollowupsSessionsReady(bot.config.larkAppId);
16336
17668
  }
17669
+ // The descriptor was intentionally published before restore so offline CLI
17670
+ // mutations delegate to this daemon. Release those queued IPC calls only
17671
+ // after every durable owner is visible in the canonical registry.
17672
+ markIpcReady();
17673
+ for (const startDispatcher of startEventDispatchers)
17674
+ startDispatcher();
16337
17675
  try {
16338
17676
  await reconcileVcMeetingManagedActionsOnBoot(cfg.larkAppId);
16339
17677
  }
@@ -16367,7 +17705,11 @@ export async function startDaemon(botIndex) {
16367
17705
  || receiver.memberEpoch !== ref.memberEpoch) {
16368
17706
  // Corrupt identity must not restart an unrelated session or silently
16369
17707
  // un-gate delivery. Keep the daemon fail-closed for operator repair.
16370
- addVcMeetingReceiverRecoveryPending(recoveryKey, vcMeetingDeliveryScopeFromRef(ref));
17708
+ addVcMeetingReceiverRecoveryPending(recoveryKey, {
17709
+ ...vcMeetingDeliveryScopeFromRef(ref),
17710
+ turnId: ref.deliveryKey,
17711
+ dispatchAttempt: ref.dispatchAttempt,
17712
+ });
16371
17713
  logger.error(`[vc-delivery] boot receiver identity conflict; delivery remains gated `
16372
17714
  + `session=${ref.receiverSessionId}`);
16373
17715
  continue;
@@ -16378,7 +17720,11 @@ export async function startDaemon(botIndex) {
16378
17720
  vcMeetingRuntimeLeaseRecovery.arm(ref, cfg.larkAppId);
16379
17721
  continue;
16380
17722
  }
16381
- addVcMeetingReceiverRecoveryPending(recoveryKey, vcMeetingDeliveryScopeFromRef(ref));
17723
+ addVcMeetingReceiverRecoveryPending(recoveryKey, {
17724
+ ...vcMeetingDeliveryScopeFromRef(ref),
17725
+ turnId: ref.deliveryKey,
17726
+ dispatchAttempt: ref.dispatchAttempt,
17727
+ });
16382
17728
  armVcMeetingReceiverRecoveryTimeout(recoveryKey, ref.receiverSessionId);
16383
17729
  try {
16384
17730
  ds.worker.send({
@@ -16470,7 +17816,7 @@ export async function startDaemon(botIndex) {
16470
17816
  // each filters to only execute tasks whose `larkAppId` matches its bot
16471
17817
  // (unmatched tasks are handled by the owning bot's daemon instead; a
16472
17818
  // missing larkAppId falls through to bot-0 as a legacy fallback).
16473
- scheduler.setExecuteCallback((task) => executeScheduledTask(task, activeSessions, refreshCliVersion));
17819
+ scheduler.setExecuteCallback((task) => withBotTurnAdmission(task.larkAppId ?? cfg.larkAppId, () => executeScheduledTask(task, activeSessions, refreshCliVersion)));
16474
17820
  scheduler.setOwnerFilter(cfg.larkAppId, idx === 0);
16475
17821
  scheduler.startScheduler();
16476
17822
  // Cross-daemon busy heartbeat: each daemon reports how many of its sessions
@@ -16606,129 +17952,265 @@ export async function startDaemon(botIndex) {
16606
17952
  }, 30_000);
16607
17953
  overloadTimer.unref?.();
16608
17954
  }
16609
- // Graceful shutdown. Sends SIGTERM (or `{type:'close'}` IPC via killWorker)
16610
- // to every worker, then waits up to SHUTDOWN_GRACE_MS for them to exit
17955
+ // Graceful shutdown. Riff owners first run a three-phase non-cancelling drain
17956
+ // that durably ACKs every exact final task lineage as one fleet transaction.
17957
+ // No worker exits until every participant is prepared and fresh-verified.
17958
+ // Ordinary workers then receive SIGTERM / close IPC and the daemon waits up
17959
+ // to DAEMON_WORKER_EXIT_GRACE_MS for them to exit
16611
17960
  // before sending SIGKILL to stragglers. Without the wait, daemon
16612
- // `process.exit(0)` races worker signal delivery — and any worker whose
17961
+ // the final managed sentinel exit races worker signal delivery —
17962
+ // and any worker whose
16613
17963
  // main thread is in a sync code path (e.g. the bridge fingerprint scan
16614
17964
  // bug fixed in v2.9.2) loses the signal and survives as a ppid=1 orphan
16615
17965
  // forever (we'd accumulated 841 such orphans across daemon restarts,
16616
17966
  // consuming ~65 GB of RAM until manually SIGKILL'd).
16617
- const SHUTDOWN_GRACE_MS = 3000;
16618
17967
  let shuttingDown = false;
16619
17968
  const shutdown = async () => {
16620
17969
  if (shuttingDown)
16621
17970
  return;
16622
17971
  shuttingDown = true;
16623
- setSessionLifecycleShutdown(true);
16624
- logger.info(`Daemon shutting down... (active: ${getActiveCount()})`);
16625
- scheduler.stopScheduler();
16626
- stopMaintenance();
16627
- vcMeetingTerminalReconciler?.stop();
16628
- clearInterval(vcMeetingDeliveryLeaseTimer);
16629
- for (const timer of vcMeetingReceiverRecoveryTimers.values())
16630
- clearTimeout(timer);
16631
- vcMeetingReceiverRecoveryTimers.clear();
16632
- vcMeetingReceiverRecoveryPending.clear();
16633
- vcMeetingReceiverRecoveryEscalating.clear();
16634
- vcMeetingReceiverRecoveryScopes.clear();
16635
- vcMeetingRuntimeLeaseRecovery.reset();
16636
- for (const timer of deferredScheduleSettleTimers.values())
16637
- clearTimeout(timer);
16638
- deferredScheduleSettleTimers.clear();
16639
- vcMeetingReceiverRecoveryReady = false;
16640
- stopCliRuntimeUpdateMonitor();
16641
- v3ProgressCardManager.close();
16642
- clearInterval(maintenanceHeartbeat);
16643
- clearInterval(docCommentPollTimer);
16644
- for (const session of vcMeetingSessions.values())
16645
- cleanupVcMeetingDaemonSession(session, 'daemon-shutdown');
16646
- vcMeetingSessions.clear();
16647
- for (const closing of vcMeetingClosingConsumerSessions.values()) {
16648
- if (closing.timer)
16649
- clearTimeout(closing.timer);
16650
- }
16651
- vcMeetingClosingConsumerSessions.clear();
16652
- for (const key of [...vcMeetingPendingInvites.keys()])
16653
- deleteVcMeetingPendingInvite(key);
16654
- clearInterval(descriptorHeartbeat);
16655
- clearInterval(idleWorkerSweepTimer);
16656
- if (memoryDiagnostics)
16657
- clearInterval(memoryDiagnostics);
16658
- removeDaemonDescriptor(cfg.larkAppId);
16659
- ipcHandle.close().catch(() => { });
16660
- if (terminalProxy)
16661
- terminalProxy.close().catch(() => { });
16662
- const pendingExits = [];
16663
- const survivors = [];
16664
- for (const [, ds] of activeSessions) {
16665
- if (ds.worker && !ds.worker.killed) {
16666
- logger.info(`Shutting down worker for session ${ds.session.sessionId}`);
16667
- const w = ds.worker;
16668
- // Capture the exit promise BEFORE killWorker nulls ds.worker.
16669
- if (w.exitCode === null && w.signalCode === null) {
16670
- pendingExits.push(new Promise(resolve => {
16671
- w.once('exit', () => resolve());
16672
- }));
16673
- survivors.push(w);
17972
+ const shutdownDeadlineMs = Date.now() + DAEMON_SHUTDOWN_MAX_MS;
17973
+ const mutationResult = await tryWithBotTurnMutation(cfg.larkAppId, BOT_TURN_MUTATION_SHUTDOWN_ACQUIRE_TIMEOUT_MS, async () => {
17974
+ setSessionLifecycleShutdown(true);
17975
+ logger.info(`Daemon shutting down... (active: ${getActiveCount()})`);
17976
+ const initialShutdownFleet = collectUniqueDaemonShutdownSessions(activeSessions.values());
17977
+ if (!initialShutdownFleet.ok) {
17978
+ setSessionLifecycleShutdown(false);
17979
+ shuttingDown = false;
17980
+ logger.error(`Daemon remains online: ${initialShutdownFleet.error}`);
17981
+ return;
17982
+ }
17983
+ // Preflight Riff before stopping services/removing descriptors. A failed
17984
+ // drain or persistence write aborts every successfully prepared peer, so a
17985
+ // refused shutdown returns to a live fleet instead of leaving an early
17986
+ // participant workerless.
17987
+ const riffCandidates = initialShutdownFleet.sessions
17988
+ .filter(ds => shutdownBackendDisposition(ds) === 'riff-drain-detach');
17989
+ const riffPrepareResults = await prepareRiffFleetForShutdown(riffCandidates, {
17990
+ deadlineMs: shutdownDeadlineMs,
17991
+ });
17992
+ const riffPrepared = riffPrepareResults.filter((entry) => entry.result.ok);
17993
+ const riffFenced = riffPrepareResults.filter((entry) => entry.result.fence !== 'none');
17994
+ const abortRiffFleet = async (reason, retainFenced = new Set()) => {
17995
+ for (const ds of retainFenced) {
17996
+ logger.error(`[${ds.session.sessionId.slice(0, 8)}] Riff shutdown participant remains fenced: `
17997
+ + 'durable/runtime ownership could not be reconciled safely');
16674
17998
  }
16675
- // Branch by the session's FROZEN backend (stamped on Session.backendType
16676
- // at spawn), NOT the bot's live config — a dashboard backendType edit must
16677
- // not change how a running session is torn down, or we'd e.g. try to
16678
- // detach-preserve a "herdr" session whose real pane is tmux (freeze-once).
16679
- // undefined (frozen pty, or unresolvable legacy) → non-persistent → killWorker.
16680
- if (shutdownBackendDisposition(ds) === 'detach') {
16681
- // Persistent backends (tmux / herdr / zellij / zmx): just kill the worker process —
16682
- // the multiplexer session survives for re-attach. The worker's SIGTERM
16683
- // handler calls backend.kill(), which only DETACHES. Going through
16684
- // killWorker() instead would send {type:'close'} destroySession()
16685
- // `zellij delete-session -f`, permanently erasing the session and
16686
- // breaking daemon-restart reattach (the blocker Codex flagged).
16687
- try {
16688
- w.kill('SIGTERM');
16689
- }
16690
- catch { /* ignore */ }
16691
- ds.worker = null;
16692
- ds.workerPort = null;
16693
- ds.workerToken = null;
16694
- ds.workerViewToken = null;
16695
- ds.managedTurnOrigin = undefined;
17999
+ const aborts = await abortRiffShutdownFleet(riffFenced
18000
+ .filter(({ ds }) => !retainFenced.has(ds))
18001
+ .map(({ ds, result }) => ({ ds, result })), {
18002
+ deadlineMs: shutdownDeadlineMs,
18003
+ });
18004
+ for (const { ds, result } of aborts) {
18005
+ if (result.ok)
18006
+ continue;
18007
+ logger.error(`[${ds.session.sessionId.slice(0, 8)}] Riff shutdown rollback was not ACKed: `
18008
+ + `${result.error ?? 'unknown'}; admission remains fail-closed`);
16696
18009
  }
16697
- else {
16698
- killWorker(ds);
18010
+ setSessionLifecycleShutdown(false);
18011
+ shuttingDown = false;
18012
+ logger.error(`Daemon remains online: ${reason}`);
18013
+ };
18014
+ const riffPrepareFailures = riffPrepareResults.filter(entry => !entry.result.ok);
18015
+ if (riffPrepareFailures.length > 0) {
18016
+ for (const { ds, result } of riffPrepareFailures) {
18017
+ if (result.ok)
18018
+ continue;
18019
+ logger.error(`[${ds.session.sessionId.slice(0, 8)}] Daemon shutdown prepare refused: `
18020
+ + `${result.error}${result.taskId ? ` (task ${result.taskId})` : ''}`);
18021
+ }
18022
+ await abortRiffFleet(`${riffPrepareFailures.length} Riff owner(s) could not be safely drained`);
18023
+ return;
18024
+ }
18025
+ const riffPersistence = persistPreparedRiffShutdownFleet(riffPrepared, {
18026
+ deadlineMs: shutdownDeadlineMs,
18027
+ });
18028
+ if (!riffPersistence.ok) {
18029
+ const retainIds = new Set(riffPersistence.retainFencedSessionIds);
18030
+ const retainFenced = new Set(riffPrepared
18031
+ .filter(({ ds }) => retainIds.has(ds.session.sessionId))
18032
+ .map(({ ds }) => ds));
18033
+ logger.error(`Daemon shutdown lineage verification refused: ${riffPersistence.error}`);
18034
+ await abortRiffFleet(`${riffPersistence.sessionIds.length} Riff lineage row(s) could not be fresh-verified`, retainFenced);
18035
+ return;
18036
+ }
18037
+ // Recheck generation ownership while every participant is still fenced and
18038
+ // daemon services remain live.
18039
+ const currentShutdownFleet = collectUniqueDaemonShutdownSessions(activeSessions.values());
18040
+ if (!currentShutdownFleet.ok) {
18041
+ const ambiguousSessionId = currentShutdownFleet.sessionId;
18042
+ await abortRiffFleet(`Riff ownership became ambiguous after shutdown preflight: ${currentShutdownFleet.error}`, new Set(riffPrepared
18043
+ .filter(({ ds }) => ds.session.sessionId === ambiguousSessionId)
18044
+ .map(({ ds }) => ds)));
18045
+ return;
18046
+ }
18047
+ const currentRiffOwners = currentShutdownFleet.sessions
18048
+ .filter(ds => shutdownBackendDisposition(ds) === 'riff-drain-detach');
18049
+ const riffPreparedOwners = new Set(riffPrepared.map(entry => entry.ds));
18050
+ const riffGenerationMismatch = currentRiffOwners.some(ds => !riffPreparedOwners.has(ds))
18051
+ || riffPrepared.some(({ ds, result }) => !currentRiffOwners.includes(ds) || !isPreparedRiffSessionCurrent(ds, result));
18052
+ if (riffGenerationMismatch) {
18053
+ const retainFenced = new Set(riffPrepared
18054
+ .filter(({ ds, result }) => (!currentRiffOwners.includes(ds) || !isPreparedRiffSessionCurrent(ds, result))
18055
+ && !canAbortVerifiedExitedRiffPreparation(ds, result))
18056
+ .map(({ ds }) => ds));
18057
+ await abortRiffFleet('Riff ownership changed after shutdown preflight', retainFenced);
18058
+ return;
18059
+ }
18060
+ // Validate-all above, then commit-all synchronously with no await or
18061
+ // callback boundary between peers.
18062
+ const riffRetiredWorkers = [];
18063
+ for (const { ds, result } of riffPrepared) {
18064
+ if (!commitPreparedRiffShutdown(ds, result)) {
18065
+ throw new Error(`Riff shutdown commit invariant lost for ${ds.session.sessionId}`);
16699
18066
  }
18067
+ if (result.worker)
18068
+ riffRetiredWorkers.push(result.worker);
16700
18069
  }
16701
- }
16702
- if (pendingExits.length > 0) {
16703
- const timeout = new Promise(resolve => setTimeout(resolve, SHUTDOWN_GRACE_MS));
16704
- await Promise.race([Promise.all(pendingExits), timeout]);
16705
- let stragglers = 0;
16706
- for (const w of survivors) {
16707
- if (w.exitCode === null && w.signalCode === null) {
16708
- stragglers++;
16709
- try {
16710
- w.kill('SIGKILL');
18070
+ scheduler.stopScheduler();
18071
+ stopMaintenance();
18072
+ vcMeetingTerminalReconciler?.stop();
18073
+ clearInterval(vcMeetingDeliveryLeaseTimer);
18074
+ for (const timer of vcMeetingReceiverRecoveryTimers.values())
18075
+ clearTimeout(timer);
18076
+ vcMeetingReceiverRecoveryTimers.clear();
18077
+ vcMeetingReceiverRecoveryPending.clear();
18078
+ vcMeetingReceiverRecoveryEscalating.clear();
18079
+ vcMeetingReceiverRecoveryScopes.clear();
18080
+ vcMeetingRuntimeLeaseRecovery.reset();
18081
+ for (const timer of deferredScheduleSettleTimers.values())
18082
+ clearTimeout(timer);
18083
+ deferredScheduleSettleTimers.clear();
18084
+ vcMeetingReceiverRecoveryReady = false;
18085
+ stopCliRuntimeUpdateMonitor();
18086
+ v3ProgressCardManager.close();
18087
+ clearInterval(maintenanceHeartbeat);
18088
+ clearInterval(docCommentPollTimer);
18089
+ for (const session of vcMeetingSessions.values())
18090
+ cleanupVcMeetingDaemonSession(session, 'daemon-shutdown');
18091
+ vcMeetingSessions.clear();
18092
+ for (const closing of vcMeetingClosingConsumerSessions.values()) {
18093
+ if (closing.timer)
18094
+ clearTimeout(closing.timer);
18095
+ }
18096
+ vcMeetingClosingConsumerSessions.clear();
18097
+ for (const key of [...vcMeetingPendingInvites.keys()])
18098
+ deleteVcMeetingPendingInvite(key);
18099
+ clearInterval(descriptorHeartbeat);
18100
+ clearInterval(idleWorkerSweepTimer);
18101
+ if (memoryDiagnostics)
18102
+ clearInterval(memoryDiagnostics);
18103
+ removeDaemonDescriptor(cfg.larkAppId);
18104
+ ipcHandle.close().catch(() => { });
18105
+ if (terminalProxy)
18106
+ terminalProxy.close().catch(() => { });
18107
+ const pendingExits = [];
18108
+ const survivors = [];
18109
+ const trackWorkerExit = (w) => {
18110
+ if (w.exitCode !== null || w.signalCode !== null)
18111
+ return;
18112
+ pendingExits.push(new Promise(resolve => {
18113
+ w.once('exit', () => resolve());
18114
+ // Close the check/listener race if exit landed between the first
18115
+ // status read and once().
18116
+ if (w.exitCode !== null || w.signalCode !== null)
18117
+ resolve();
18118
+ }));
18119
+ survivors.push(w);
18120
+ };
18121
+ for (const worker of riffRetiredWorkers)
18122
+ trackWorkerExit(worker);
18123
+ for (const ds of currentShutdownFleet.sessions) {
18124
+ if (ds.worker && !ds.worker.killed) {
18125
+ logger.info(`Shutting down worker for session ${ds.session.sessionId}`);
18126
+ const w = ds.worker;
18127
+ const disposition = shutdownBackendDisposition(ds);
18128
+ if (disposition === 'riff-drain-detach') {
18129
+ throw new Error(`undrained Riff generation after atomic commit: ${ds.session.sessionId}`);
18130
+ }
18131
+ // Capture the exit promise BEFORE killWorker nulls ds.worker.
18132
+ trackWorkerExit(w);
18133
+ // Branch by the session's FROZEN backend (stamped on Session.backendType
18134
+ // at spawn), NOT the bot's live config — a dashboard backendType edit must
18135
+ // not change how a running session is torn down, or we'd e.g. try to
18136
+ // detach-preserve a "herdr" session whose real pane is tmux (freeze-once).
18137
+ // undefined (frozen pty, or unresolvable legacy) → non-persistent → killWorker.
18138
+ if (disposition === 'detach') {
18139
+ // Persistent backends (tmux / herdr / zellij / zmx): just kill the worker process —
18140
+ // the multiplexer session survives for re-attach. The worker's SIGTERM
18141
+ // handler calls backend.kill(), which only DETACHES. Going through
18142
+ // killWorker() instead would send {type:'close'} → destroySession() →
18143
+ // `zellij delete-session -f`, permanently erasing the session and
18144
+ // breaking daemon-restart reattach (the blocker Codex flagged).
18145
+ try {
18146
+ w.kill('SIGTERM');
18147
+ }
18148
+ catch { /* ignore */ }
18149
+ ds.worker = null;
18150
+ ds.workerPort = null;
18151
+ ds.workerToken = null;
18152
+ ds.workerViewToken = null;
18153
+ ds.managedTurnOrigin = undefined;
18154
+ }
18155
+ else {
18156
+ killWorker(ds);
16711
18157
  }
16712
- catch { /* already dead */ }
16713
18158
  }
16714
18159
  }
16715
- if (stragglers > 0) {
16716
- logger.warn(`${stragglers}/${survivors.length} worker(s) didn't exit within ${SHUTDOWN_GRACE_MS}ms — SIGKILL'd to prevent ppid=1 orphans.`);
18160
+ if (pendingExits.length > 0) {
18161
+ const exitGraceMs = Math.min(DAEMON_WORKER_EXIT_GRACE_MS, Math.max(0, shutdownDeadlineMs - Date.now()));
18162
+ const timeout = new Promise(resolve => setTimeout(resolve, exitGraceMs));
18163
+ await Promise.race([Promise.all(pendingExits), timeout]);
18164
+ let stragglers = 0;
18165
+ for (const w of survivors) {
18166
+ if (w.exitCode === null && w.signalCode === null) {
18167
+ stragglers++;
18168
+ try {
18169
+ w.kill('SIGKILL');
18170
+ }
18171
+ catch { /* already dead */ }
18172
+ }
18173
+ }
18174
+ if (stragglers > 0) {
18175
+ logger.warn(`${stragglers}/${survivors.length} worker(s) didn't exit within ${exitGraceMs}ms — SIGKILL'd to prevent ppid=1 orphans.`);
18176
+ }
16717
18177
  }
18178
+ // Flush any pending identity-cache writes before exit. The cache uses a
18179
+ // 2s debounce on disk persistence to dedupe writes from chatty groups; on
18180
+ // SIGTERM we want anything learned since the last flush to land.
18181
+ flushIdentityCacheSync();
18182
+ removePidFile();
18183
+ process.exit(gracefulProcessExitCode());
18184
+ });
18185
+ if (!mutationResult.acquired) {
18186
+ shuttingDown = false;
18187
+ logger.error(`Daemon remains online: could not acquire exclusive shutdown mutation lease `
18188
+ + `(${mutationResult.reason})`);
16718
18189
  }
16719
- // Flush any pending identity-cache writes before exit. The cache uses a
16720
- // 2s debounce on disk persistence to dedupe writes from chatty groups; on
16721
- // SIGTERM we want anything learned since the last flush to land.
16722
- flushIdentityCacheSync();
16723
- removePidFile();
16724
- process.exit(gracefulProcessExitCode());
16725
18190
  };
16726
18191
  process.on('SIGTERM', () => { shutdown().catch(err => { logger.error(`shutdown failed: ${err?.message ?? err}`); process.exit(1); }); });
16727
18192
  process.on('SIGINT', () => { shutdown().catch(err => { logger.error(`shutdown failed: ${err?.message ?? err}`); process.exit(1); }); });
18193
+ // Capability publication is the final startup commit for supervisor-driven
18194
+ // shutdown. The early descriptor intentionally lacks it: a new CLI that
18195
+ // observes this daemon before both handlers/state closures exist must refuse
18196
+ // to signal. Atomic rewrite makes the capability visible only afterward.
18197
+ if (readSupervisorProcessStartIdentity(process.pid) !== desc.processStartIdentity) {
18198
+ throw new Error('daemon process-start identity changed before shutdown capability commit');
18199
+ }
18200
+ setSupervisorShutdownHandler({
18201
+ larkAppId: cfg.larkAppId,
18202
+ bootInstanceId: desc.bootInstanceId,
18203
+ processStartIdentity: desc.processStartIdentity,
18204
+ shutdown,
18205
+ });
18206
+ desc.supervisorShutdownProtocol = SUPERVISOR_SHUTDOWN_PROTOCOL;
18207
+ desc.lastHeartbeat = Date.now();
18208
+ writeDaemonDescriptor(desc);
16728
18209
  // Best-effort cleanup on plain `exit` (e.g. uncaught fatal). No worker
16729
18210
  // shutdown here since the process is already on its way out — just remove
16730
18211
  // the descriptor so the dashboard doesn't see a phantom daemon.
16731
18212
  process.on('exit', () => {
18213
+ setSupervisorShutdownHandler(null);
16732
18214
  clearInterval(descriptorHeartbeat);
16733
18215
  clearInterval(idleWorkerSweepTimer);
16734
18216
  clearInterval(docCommentPollTimer);