botmux 3.13.0 → 3.15.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 (541) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +25 -1
  3. package/dist/.runtime-build-id +1 -1
  4. package/dist/adapters/adopt-route.d.ts +58 -0
  5. package/dist/adapters/adopt-route.d.ts.map +1 -1
  6. package/dist/adapters/adopt-route.js +136 -0
  7. package/dist/adapters/adopt-route.js.map +1 -1
  8. package/dist/adapters/backend/capabilities.js +1 -1
  9. package/dist/adapters/backend/capabilities.js.map +1 -1
  10. package/dist/adapters/backend/herdr-backend.d.ts +1 -1
  11. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  12. package/dist/adapters/backend/herdr-backend.js +2 -1
  13. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  14. package/dist/adapters/backend/riff-backend.d.ts +97 -0
  15. package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
  16. package/dist/adapters/backend/riff-backend.js +380 -66
  17. package/dist/adapters/backend/riff-backend.js.map +1 -1
  18. package/dist/adapters/backend/sandbox.d.ts +8 -0
  19. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  20. package/dist/adapters/backend/sandbox.js +16 -2
  21. package/dist/adapters/backend/sandbox.js.map +1 -1
  22. package/dist/adapters/backend/tmux-backend.d.ts +12 -13
  23. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  24. package/dist/adapters/backend/tmux-backend.js +113 -53
  25. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  26. package/dist/adapters/backend/tmux-pipe-backend.d.ts +34 -3
  27. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  28. package/dist/adapters/backend/tmux-pipe-backend.js +72 -9
  29. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  30. package/dist/adapters/backend/types.d.ts +7 -0
  31. package/dist/adapters/backend/types.d.ts.map +1 -1
  32. package/dist/adapters/backend/types.js.map +1 -1
  33. package/dist/adapters/backend/zellij-backend.d.ts +2 -3
  34. package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
  35. package/dist/adapters/backend/zellij-backend.js +6 -13
  36. package/dist/adapters/backend/zellij-backend.js.map +1 -1
  37. package/dist/adapters/backend/zellij-observe-backend.d.ts +2 -1
  38. package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -1
  39. package/dist/adapters/backend/zellij-observe-backend.js +6 -3
  40. package/dist/adapters/backend/zellij-observe-backend.js.map +1 -1
  41. package/dist/adapters/backend/zmx-backend.d.ts.map +1 -1
  42. package/dist/adapters/backend/zmx-backend.js +40 -18
  43. package/dist/adapters/backend/zmx-backend.js.map +1 -1
  44. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  45. package/dist/adapters/cli/claude-code.js +12 -1
  46. package/dist/adapters/cli/claude-code.js.map +1 -1
  47. package/dist/adapters/cli/codex.d.ts.map +1 -1
  48. package/dist/adapters/cli/codex.js +3 -2
  49. package/dist/adapters/cli/codex.js.map +1 -1
  50. package/dist/adapters/cli/cursor.d.ts.map +1 -1
  51. package/dist/adapters/cli/cursor.js +13 -1
  52. package/dist/adapters/cli/cursor.js.map +1 -1
  53. package/dist/adapters/cli/dsh.d.ts +4 -0
  54. package/dist/adapters/cli/dsh.d.ts.map +1 -0
  55. package/dist/adapters/cli/dsh.js +100 -0
  56. package/dist/adapters/cli/dsh.js.map +1 -0
  57. package/dist/adapters/cli/fs-policy.d.ts.map +1 -1
  58. package/dist/adapters/cli/fs-policy.js +5 -0
  59. package/dist/adapters/cli/fs-policy.js.map +1 -1
  60. package/dist/adapters/cli/opencode.d.ts +52 -1
  61. package/dist/adapters/cli/opencode.d.ts.map +1 -1
  62. package/dist/adapters/cli/opencode.js +117 -76
  63. package/dist/adapters/cli/opencode.js.map +1 -1
  64. package/dist/adapters/cli/opencode2.d.ts +4 -0
  65. package/dist/adapters/cli/opencode2.d.ts.map +1 -0
  66. package/dist/adapters/cli/opencode2.js +137 -0
  67. package/dist/adapters/cli/opencode2.js.map +1 -0
  68. package/dist/adapters/cli/pi.d.ts +14 -1
  69. package/dist/adapters/cli/pi.d.ts.map +1 -1
  70. package/dist/adapters/cli/pi.js +21 -1
  71. package/dist/adapters/cli/pi.js.map +1 -1
  72. package/dist/adapters/cli/read-isolation.d.ts +1 -1
  73. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  74. package/dist/adapters/cli/read-isolation.js +5 -2
  75. package/dist/adapters/cli/read-isolation.js.map +1 -1
  76. package/dist/adapters/cli/registry.d.ts +7 -1
  77. package/dist/adapters/cli/registry.d.ts.map +1 -1
  78. package/dist/adapters/cli/registry.js +19 -6
  79. package/dist/adapters/cli/registry.js.map +1 -1
  80. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  81. package/dist/adapters/cli/shared-hints.js +41 -23
  82. package/dist/adapters/cli/shared-hints.js.map +1 -1
  83. package/dist/adapters/cli/types.d.ts +28 -3
  84. package/dist/adapters/cli/types.d.ts.map +1 -1
  85. package/dist/adapters/hook-command.d.ts +8 -0
  86. package/dist/adapters/hook-command.d.ts.map +1 -1
  87. package/dist/adapters/hook-command.js +11 -0
  88. package/dist/adapters/hook-command.js.map +1 -1
  89. package/dist/adapters/hook-installer.d.ts +16 -1
  90. package/dist/adapters/hook-installer.d.ts.map +1 -1
  91. package/dist/adapters/hook-installer.js +232 -2
  92. package/dist/adapters/hook-installer.js.map +1 -1
  93. package/dist/bot-registry.d.ts +138 -8
  94. package/dist/bot-registry.d.ts.map +1 -1
  95. package/dist/bot-registry.js +76 -4
  96. package/dist/bot-registry.js.map +1 -1
  97. package/dist/cli/pm2-start-transaction.d.ts +14 -0
  98. package/dist/cli/pm2-start-transaction.d.ts.map +1 -1
  99. package/dist/cli/pm2-start-transaction.js +25 -9
  100. package/dist/cli/pm2-start-transaction.js.map +1 -1
  101. package/dist/cli/restart-failure-notification.d.ts +54 -0
  102. package/dist/cli/restart-failure-notification.d.ts.map +1 -0
  103. package/dist/cli/restart-failure-notification.js +142 -0
  104. package/dist/cli/restart-failure-notification.js.map +1 -0
  105. package/dist/cli/restart-failure-owner.d.ts +20 -0
  106. package/dist/cli/restart-failure-owner.d.ts.map +1 -0
  107. package/dist/cli/restart-failure-owner.js +27 -0
  108. package/dist/cli/restart-failure-owner.js.map +1 -0
  109. package/dist/cli/restart-shutdown-preflight.d.ts +24 -0
  110. package/dist/cli/restart-shutdown-preflight.d.ts.map +1 -0
  111. package/dist/cli/restart-shutdown-preflight.js +105 -0
  112. package/dist/cli/restart-shutdown-preflight.js.map +1 -0
  113. package/dist/cli/send-dispatch.d.ts +26 -0
  114. package/dist/cli/send-dispatch.d.ts.map +1 -1
  115. package/dist/cli/send-dispatch.js +30 -0
  116. package/dist/cli/send-dispatch.js.map +1 -1
  117. package/dist/cli.d.ts +4 -1
  118. package/dist/cli.d.ts.map +1 -1
  119. package/dist/cli.js +673 -402
  120. package/dist/cli.js.map +1 -1
  121. package/dist/codex-app-runner.js +38 -15
  122. package/dist/codex-app-runner.js.map +1 -1
  123. package/dist/codex-rpc-lifecycle.d.ts +14 -2
  124. package/dist/codex-rpc-lifecycle.d.ts.map +1 -1
  125. package/dist/codex-rpc-lifecycle.js +13 -3
  126. package/dist/codex-rpc-lifecycle.js.map +1 -1
  127. package/dist/core/ask-broker.d.ts +3 -0
  128. package/dist/core/ask-broker.d.ts.map +1 -1
  129. package/dist/core/ask-broker.js +29 -7
  130. package/dist/core/ask-broker.js.map +1 -1
  131. package/dist/core/ask-hook/registry.d.ts.map +1 -1
  132. package/dist/core/ask-hook/registry.js +4 -0
  133. package/dist/core/ask-hook/registry.js.map +1 -1
  134. package/dist/core/ask-types.d.ts +5 -1
  135. package/dist/core/ask-types.d.ts.map +1 -1
  136. package/dist/core/ask-types.js.map +1 -1
  137. package/dist/core/browser-restart.d.ts +112 -0
  138. package/dist/core/browser-restart.d.ts.map +1 -0
  139. package/dist/core/browser-restart.js +233 -0
  140. package/dist/core/browser-restart.js.map +1 -0
  141. package/dist/core/command-discovery.d.ts.map +1 -1
  142. package/dist/core/command-discovery.js +4 -1
  143. package/dist/core/command-discovery.js.map +1 -1
  144. package/dist/core/command-handler.d.ts +19 -11
  145. package/dist/core/command-handler.d.ts.map +1 -1
  146. package/dist/core/command-handler.js +237 -43
  147. package/dist/core/command-handler.js.map +1 -1
  148. package/dist/core/current-turn-provenance.d.ts.map +1 -1
  149. package/dist/core/current-turn-provenance.js +7 -27
  150. package/dist/core/current-turn-provenance.js.map +1 -1
  151. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  152. package/dist/core/dashboard-ipc-server.js +359 -11
  153. package/dist/core/dashboard-ipc-server.js.map +1 -1
  154. package/dist/core/dispatch-registry.d.ts.map +1 -1
  155. package/dist/core/dispatch-registry.js +6 -0
  156. package/dist/core/dispatch-registry.js.map +1 -1
  157. package/dist/core/dispatch-report-binding.d.ts +31 -0
  158. package/dist/core/dispatch-report-binding.d.ts.map +1 -0
  159. package/dist/core/dispatch-report-binding.js +88 -0
  160. package/dist/core/dispatch-report-binding.js.map +1 -0
  161. package/dist/core/doc-comment-prompt.d.ts +2 -0
  162. package/dist/core/doc-comment-prompt.d.ts.map +1 -1
  163. package/dist/core/doc-comment-prompt.js +6 -0
  164. package/dist/core/doc-comment-prompt.js.map +1 -1
  165. package/dist/core/host-overload-alert.d.ts +25 -1
  166. package/dist/core/host-overload-alert.d.ts.map +1 -1
  167. package/dist/core/host-overload-alert.js +69 -8
  168. package/dist/core/host-overload-alert.js.map +1 -1
  169. package/dist/core/local-terminal-opener.js +1 -1
  170. package/dist/core/local-terminal-opener.js.map +1 -1
  171. package/dist/core/managed-origin-capability.d.ts +7 -6
  172. package/dist/core/managed-origin-capability.d.ts.map +1 -1
  173. package/dist/core/managed-origin-capability.js +32 -1
  174. package/dist/core/managed-origin-capability.js.map +1 -1
  175. package/dist/core/passthrough-commands.d.ts.map +1 -1
  176. package/dist/core/passthrough-commands.js +3 -2
  177. package/dist/core/passthrough-commands.js.map +1 -1
  178. package/dist/core/plugins/materializer.d.ts.map +1 -1
  179. package/dist/core/plugins/materializer.js +1 -0
  180. package/dist/core/plugins/materializer.js.map +1 -1
  181. package/dist/core/producer-quiescence.d.ts +70 -0
  182. package/dist/core/producer-quiescence.d.ts.map +1 -0
  183. package/dist/core/producer-quiescence.js +90 -0
  184. package/dist/core/producer-quiescence.js.map +1 -0
  185. package/dist/core/raw-command-write-options.d.ts +10 -0
  186. package/dist/core/raw-command-write-options.d.ts.map +1 -0
  187. package/dist/core/raw-command-write-options.js +23 -0
  188. package/dist/core/raw-command-write-options.js.map +1 -0
  189. package/dist/core/raw-command-writer.d.ts +4 -0
  190. package/dist/core/raw-command-writer.d.ts.map +1 -1
  191. package/dist/core/raw-command-writer.js +15 -6
  192. package/dist/core/raw-command-writer.js.map +1 -1
  193. package/dist/core/reply-target.d.ts +11 -0
  194. package/dist/core/reply-target.d.ts.map +1 -1
  195. package/dist/core/reply-target.js +22 -7
  196. package/dist/core/reply-target.js.map +1 -1
  197. package/dist/core/report-session-relay.d.ts +53 -0
  198. package/dist/core/report-session-relay.d.ts.map +1 -0
  199. package/dist/core/report-session-relay.js +116 -0
  200. package/dist/core/report-session-relay.js.map +1 -0
  201. package/dist/core/session-discovery.d.ts +5 -0
  202. package/dist/core/session-discovery.d.ts.map +1 -1
  203. package/dist/core/session-discovery.js +13 -0
  204. package/dist/core/session-discovery.js.map +1 -1
  205. package/dist/core/session-group-birth.d.ts +7 -0
  206. package/dist/core/session-group-birth.d.ts.map +1 -0
  207. package/dist/core/session-group-birth.js +145 -0
  208. package/dist/core/session-group-birth.js.map +1 -0
  209. package/dist/core/session-manager.d.ts +16 -21
  210. package/dist/core/session-manager.d.ts.map +1 -1
  211. package/dist/core/session-manager.js +140 -21
  212. package/dist/core/session-manager.js.map +1 -1
  213. package/dist/core/skills/frontmatter.d.ts.map +1 -1
  214. package/dist/core/skills/frontmatter.js +6 -1
  215. package/dist/core/skills/frontmatter.js.map +1 -1
  216. package/dist/core/trigger-session.d.ts.map +1 -1
  217. package/dist/core/trigger-session.js +18 -3
  218. package/dist/core/trigger-session.js.map +1 -1
  219. package/dist/core/types.d.ts +18 -0
  220. package/dist/core/types.d.ts.map +1 -1
  221. package/dist/core/types.js.map +1 -1
  222. package/dist/core/worker-pool.d.ts +63 -11
  223. package/dist/core/worker-pool.d.ts.map +1 -1
  224. package/dist/core/worker-pool.js +262 -21
  225. package/dist/core/worker-pool.js.map +1 -1
  226. package/dist/daemon.d.ts +33 -0
  227. package/dist/daemon.d.ts.map +1 -1
  228. package/dist/daemon.js +904 -147
  229. package/dist/daemon.js.map +1 -1
  230. package/dist/dashboard/auth.d.ts +20 -0
  231. package/dist/dashboard/auth.d.ts.map +1 -1
  232. package/dist/dashboard/auth.js +86 -1
  233. package/dist/dashboard/auth.js.map +1 -1
  234. package/dist/dashboard/bot-payload.d.ts +9 -0
  235. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  236. package/dist/dashboard/bot-payload.js +5 -1
  237. package/dist/dashboard/bot-payload.js.map +1 -1
  238. package/dist/dashboard/federation-spoke-api.d.ts.map +1 -1
  239. package/dist/dashboard/federation-spoke-api.js +27 -3
  240. package/dist/dashboard/federation-spoke-api.js.map +1 -1
  241. package/dist/dashboard/feedback-analytics-api.d.ts +6 -0
  242. package/dist/dashboard/feedback-analytics-api.d.ts.map +1 -0
  243. package/dist/dashboard/feedback-analytics-api.js +39 -0
  244. package/dist/dashboard/feedback-analytics-api.js.map +1 -0
  245. package/dist/dashboard/session-action-timeout.d.ts +3 -0
  246. package/dist/dashboard/session-action-timeout.d.ts.map +1 -0
  247. package/dist/dashboard/session-action-timeout.js +12 -0
  248. package/dist/dashboard/session-action-timeout.js.map +1 -0
  249. package/dist/dashboard/vc-consumer-profiles-api.d.ts +9 -3
  250. package/dist/dashboard/vc-consumer-profiles-api.d.ts.map +1 -1
  251. package/dist/dashboard/vc-consumer-profiles-api.js +2 -1
  252. package/dist/dashboard/vc-consumer-profiles-api.js.map +1 -1
  253. package/dist/dashboard/web/app.d.ts.map +1 -1
  254. package/dist/dashboard/web/app.js +48 -5
  255. package/dist/dashboard/web/app.js.map +1 -1
  256. package/dist/dashboard/web/bot-defaults-page.d.ts +11 -0
  257. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  258. package/dist/dashboard/web/bot-defaults-page.js +368 -36
  259. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  260. package/dist/dashboard/web/bot-defaults.d.ts +8 -0
  261. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  262. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  263. package/dist/dashboard/web/dashboard-routes.d.ts.map +1 -1
  264. package/dist/dashboard/web/dashboard-routes.js +1 -0
  265. package/dist/dashboard/web/dashboard-routes.js.map +1 -1
  266. package/dist/dashboard/web/feedback-page.d.ts +3 -0
  267. package/dist/dashboard/web/feedback-page.d.ts.map +1 -0
  268. package/dist/dashboard/web/feedback-page.js +34 -0
  269. package/dist/dashboard/web/feedback-page.js.map +1 -0
  270. package/dist/dashboard/web/groups-page.d.ts.map +1 -1
  271. package/dist/dashboard/web/groups-page.js +7 -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 +122 -22
  275. package/dist/dashboard/web/i18n.js.map +1 -1
  276. package/dist/dashboard/web/skills-page.d.ts.map +1 -1
  277. package/dist/dashboard/web/skills-page.js +4 -2
  278. package/dist/dashboard/web/skills-page.js.map +1 -1
  279. package/dist/dashboard/web/team-federation-page.d.ts.map +1 -1
  280. package/dist/dashboard/web/team-federation-page.js +26 -2
  281. package/dist/dashboard/web/team-federation-page.js.map +1 -1
  282. package/dist/dashboard/web/team-federation.d.ts +19 -0
  283. package/dist/dashboard/web/team-federation.d.ts.map +1 -1
  284. package/dist/dashboard/web/team-federation.js +3 -0
  285. package/dist/dashboard/web/team-federation.js.map +1 -1
  286. package/dist/dashboard/web/update-action.d.ts +8 -0
  287. package/dist/dashboard/web/update-action.d.ts.map +1 -1
  288. package/dist/dashboard/web/update-action.js +10 -0
  289. package/dist/dashboard/web/update-action.js.map +1 -1
  290. package/dist/dashboard/web/vc-consumer-profiles-section.d.ts.map +1 -1
  291. package/dist/dashboard/web/vc-consumer-profiles-section.js +58 -9
  292. package/dist/dashboard/web/vc-consumer-profiles-section.js.map +1 -1
  293. package/dist/dashboard-web/app.js +1 -1
  294. package/dist/dashboard-web/chunks/bot-defaults-page-65SMZ4QY.js +10 -0
  295. package/dist/dashboard-web/chunks/chunk-G5XGBCM6.js +1 -0
  296. package/dist/dashboard-web/chunks/chunk-IKN6WURK.js +1 -0
  297. package/dist/dashboard-web/chunks/{chunk-6JTZJRQJ.js → chunk-VMWNR3SV.js} +15 -15
  298. package/dist/dashboard-web/chunks/{chunk-CU4SUKK3.js → chunk-Z7B3E3US.js} +1 -1
  299. package/dist/dashboard-web/chunks/{chunk-5QJB24I2.js → chunk-Z7DO42GJ.js} +1 -1
  300. package/dist/dashboard-web/chunks/connectors-page-CPH6VVFY.js +1 -0
  301. package/dist/dashboard-web/chunks/feedback-page-J2FKQ3LK.js +1 -0
  302. package/dist/dashboard-web/chunks/groups-page-VDOOLS4M.js +8 -0
  303. package/dist/dashboard-web/chunks/{insights-page-EVGGJI5L.js → insights-page-A2LWTKJD.js} +1 -1
  304. package/dist/dashboard-web/chunks/{monitor-room-HRGR2ENP.js → monitor-room-2YR752E3.js} +1 -1
  305. package/dist/dashboard-web/chunks/monitoring-page-BGYSKZGM.js +2 -0
  306. package/dist/dashboard-web/chunks/office-page-ZZHTEAE4.js +1 -0
  307. package/dist/dashboard-web/chunks/overview-page-7NYPKQSB.js +1 -0
  308. package/dist/dashboard-web/chunks/roles-page-YUC4YLJG.js +1 -0
  309. package/dist/dashboard-web/chunks/schedules-page-2L2NKTDJ.js +1 -0
  310. package/dist/dashboard-web/chunks/{sessions-page-DXBCDWCS.js → sessions-page-DYZZMKFL.js} +1 -1
  311. package/dist/dashboard-web/chunks/settings-page-LIULUSMH.js +2 -0
  312. package/dist/dashboard-web/chunks/{skills-page-2QAVB2SA.js → skills-page-DQPBBI3T.js} +2 -2
  313. package/dist/dashboard-web/chunks/team-federation-page-LQKLDV4K.js +1 -0
  314. package/dist/dashboard-web/chunks/{v3-page-AY35LKCX.js → v3-page-RVKQIMAP.js} +1 -1
  315. package/dist/dashboard-web/chunks/whiteboards-page-S5K55HRZ.js +1 -0
  316. package/dist/dashboard-web/style.css +35 -0
  317. package/dist/dashboard.js +210 -13
  318. package/dist/dashboard.js.map +1 -1
  319. package/dist/desktop/shared/shell-path-probes.d.ts +1 -1
  320. package/dist/desktop/shared/shell-path-probes.js +2 -2
  321. package/dist/desktop/shared/shell-path-probes.js.map +1 -1
  322. package/dist/dsh-runner.d.ts +3 -0
  323. package/dist/dsh-runner.d.ts.map +1 -0
  324. package/dist/dsh-runner.js +673 -0
  325. package/dist/dsh-runner.js.map +1 -0
  326. package/dist/global-config.d.ts +24 -0
  327. package/dist/global-config.d.ts.map +1 -1
  328. package/dist/global-config.js +35 -0
  329. package/dist/global-config.js.map +1 -1
  330. package/dist/i18n/en.d.ts.map +1 -1
  331. package/dist/i18n/en.js +39 -31
  332. package/dist/i18n/en.js.map +1 -1
  333. package/dist/i18n/zh.d.ts.map +1 -1
  334. package/dist/i18n/zh.js +39 -31
  335. package/dist/i18n/zh.js.map +1 -1
  336. package/dist/im/lark/ask-card.d.ts +3 -1
  337. package/dist/im/lark/ask-card.d.ts.map +1 -1
  338. package/dist/im/lark/ask-card.js +61 -5
  339. package/dist/im/lark/ask-card.js.map +1 -1
  340. package/dist/im/lark/card-builder.d.ts.map +1 -1
  341. package/dist/im/lark/card-builder.js +13 -6
  342. package/dist/im/lark/card-builder.js.map +1 -1
  343. package/dist/im/lark/card-handler.d.ts +2 -1
  344. package/dist/im/lark/card-handler.d.ts.map +1 -1
  345. package/dist/im/lark/card-handler.js +145 -5
  346. package/dist/im/lark/card-handler.js.map +1 -1
  347. package/dist/im/lark/event-dispatcher.d.ts +18 -0
  348. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  349. package/dist/im/lark/event-dispatcher.js +41 -28
  350. package/dist/im/lark/event-dispatcher.js.map +1 -1
  351. package/dist/im/lark/md-card.d.ts +18 -0
  352. package/dist/im/lark/md-card.d.ts.map +1 -1
  353. package/dist/im/lark/md-card.js +21 -0
  354. package/dist/im/lark/md-card.js.map +1 -1
  355. package/dist/im/lark/relay-target-routing.d.ts.map +1 -1
  356. package/dist/im/lark/relay-target-routing.js +3 -2
  357. package/dist/im/lark/relay-target-routing.js.map +1 -1
  358. package/dist/im/lark/reply-mode-command.d.ts.map +1 -1
  359. package/dist/im/lark/reply-mode-command.js +19 -8
  360. package/dist/im/lark/reply-mode-command.js.map +1 -1
  361. package/dist/im/lark/skill-feedback-card.d.ts +15 -0
  362. package/dist/im/lark/skill-feedback-card.d.ts.map +1 -0
  363. package/dist/im/lark/skill-feedback-card.js +149 -0
  364. package/dist/im/lark/skill-feedback-card.js.map +1 -0
  365. package/dist/services/bot-config-store.d.ts +10 -1
  366. package/dist/services/bot-config-store.d.ts.map +1 -1
  367. package/dist/services/bot-config-store.js +103 -3
  368. package/dist/services/bot-config-store.js.map +1 -1
  369. package/dist/services/bridge-fallback-gate.d.ts +29 -7
  370. package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
  371. package/dist/services/bridge-fallback-gate.js +58 -16
  372. package/dist/services/bridge-fallback-gate.js.map +1 -1
  373. package/dist/services/codex-reasoning-effort.d.ts +9 -0
  374. package/dist/services/codex-reasoning-effort.d.ts.map +1 -0
  375. package/dist/services/codex-reasoning-effort.js +23 -0
  376. package/dist/services/codex-reasoning-effort.js.map +1 -0
  377. package/dist/services/codex-service-tier.d.ts +1 -1
  378. package/dist/services/codex-service-tier.d.ts.map +1 -1
  379. package/dist/services/codex-transcript.d.ts +9 -0
  380. package/dist/services/codex-transcript.d.ts.map +1 -1
  381. package/dist/services/codex-transcript.js +13 -6
  382. package/dist/services/codex-transcript.js.map +1 -1
  383. package/dist/services/feed-group-tagger.d.ts +15 -0
  384. package/dist/services/feed-group-tagger.d.ts.map +1 -0
  385. package/dist/services/feed-group-tagger.js +348 -0
  386. package/dist/services/feed-group-tagger.js.map +1 -0
  387. package/dist/services/feedback-analytics.d.ts +82 -0
  388. package/dist/services/feedback-analytics.d.ts.map +1 -0
  389. package/dist/services/feedback-analytics.js +95 -0
  390. package/dist/services/feedback-analytics.js.map +1 -0
  391. package/dist/services/feedback-outbox.d.ts +24 -0
  392. package/dist/services/feedback-outbox.d.ts.map +1 -0
  393. package/dist/services/feedback-outbox.js +12 -0
  394. package/dist/services/feedback-outbox.js.map +1 -0
  395. package/dist/services/feedback-policy-resolver.d.ts +56 -0
  396. package/dist/services/feedback-policy-resolver.d.ts.map +1 -0
  397. package/dist/services/feedback-policy-resolver.js +156 -0
  398. package/dist/services/feedback-policy-resolver.js.map +1 -0
  399. package/dist/services/feedback-policy.d.ts +41 -0
  400. package/dist/services/feedback-policy.d.ts.map +1 -0
  401. package/dist/services/feedback-policy.js +102 -0
  402. package/dist/services/feedback-policy.js.map +1 -0
  403. package/dist/services/feedback-webhook-dispatcher.d.ts +76 -0
  404. package/dist/services/feedback-webhook-dispatcher.d.ts.map +1 -0
  405. package/dist/services/feedback-webhook-dispatcher.js +237 -0
  406. package/dist/services/feedback-webhook-dispatcher.js.map +1 -0
  407. package/dist/services/grant-prefs-store.d.ts +3 -0
  408. package/dist/services/grant-prefs-store.d.ts.map +1 -1
  409. package/dist/services/grant-prefs-store.js +30 -1
  410. package/dist/services/grant-prefs-store.js.map +1 -1
  411. package/dist/services/groups-store.d.ts +14 -0
  412. package/dist/services/groups-store.d.ts.map +1 -1
  413. package/dist/services/groups-store.js +23 -0
  414. package/dist/services/groups-store.js.map +1 -1
  415. package/dist/services/local-cli-opener.d.ts +1 -1
  416. package/dist/services/local-cli-opener.d.ts.map +1 -1
  417. package/dist/services/local-cli-opener.js +2 -0
  418. package/dist/services/local-cli-opener.js.map +1 -1
  419. package/dist/services/peer-cross-ref-store.d.ts +8 -0
  420. package/dist/services/peer-cross-ref-store.d.ts.map +1 -0
  421. package/dist/services/peer-cross-ref-store.js +95 -0
  422. package/dist/services/peer-cross-ref-store.js.map +1 -0
  423. package/dist/services/pi-transcript.d.ts.map +1 -1
  424. package/dist/services/pi-transcript.js +10 -6
  425. package/dist/services/pi-transcript.js.map +1 -1
  426. package/dist/services/prompt-context-store.d.ts +29 -0
  427. package/dist/services/prompt-context-store.d.ts.map +1 -0
  428. package/dist/services/prompt-context-store.js +205 -0
  429. package/dist/services/prompt-context-store.js.map +1 -0
  430. package/dist/services/session-group-avatar.d.ts +6 -0
  431. package/dist/services/session-group-avatar.d.ts.map +1 -0
  432. package/dist/services/session-group-avatar.js +96 -0
  433. package/dist/services/session-group-avatar.js.map +1 -0
  434. package/dist/services/session-group-title.d.ts +47 -0
  435. package/dist/services/session-group-title.d.ts.map +1 -0
  436. package/dist/services/session-group-title.js +226 -0
  437. package/dist/services/session-group-title.js.map +1 -0
  438. package/dist/services/session-groups-store.d.ts +30 -0
  439. package/dist/services/session-groups-store.d.ts.map +1 -0
  440. package/dist/services/session-groups-store.js +132 -0
  441. package/dist/services/session-groups-store.js.map +1 -0
  442. package/dist/services/session-store.d.ts +58 -0
  443. package/dist/services/session-store.d.ts.map +1 -1
  444. package/dist/services/session-store.js +174 -0
  445. package/dist/services/session-store.js.map +1 -1
  446. package/dist/services/skill-feedback-store.d.ts +252 -0
  447. package/dist/services/skill-feedback-store.d.ts.map +1 -0
  448. package/dist/services/skill-feedback-store.js +795 -0
  449. package/dist/services/skill-feedback-store.js.map +1 -0
  450. package/dist/services/skill-registry-store.d.ts +23 -0
  451. package/dist/services/skill-registry-store.d.ts.map +1 -1
  452. package/dist/services/skill-registry-store.js +93 -7
  453. package/dist/services/skill-registry-store.js.map +1 -1
  454. package/dist/services/structured-bridge-clis.d.ts +13 -4
  455. package/dist/services/structured-bridge-clis.d.ts.map +1 -1
  456. package/dist/services/structured-bridge-clis.js +13 -3
  457. package/dist/services/structured-bridge-clis.js.map +1 -1
  458. package/dist/services/team-store.d.ts +4 -0
  459. package/dist/services/team-store.d.ts.map +1 -1
  460. package/dist/services/team-store.js +16 -0
  461. package/dist/services/team-store.js.map +1 -1
  462. package/dist/services/traex-transcript.d.ts +32 -4
  463. package/dist/services/traex-transcript.d.ts.map +1 -1
  464. package/dist/services/traex-transcript.js +116 -8
  465. package/dist/services/traex-transcript.js.map +1 -1
  466. package/dist/services/trigger-types.d.ts +1 -1
  467. package/dist/services/trigger-types.d.ts.map +1 -1
  468. package/dist/services/trigger-types.js +2 -2
  469. package/dist/services/trigger-types.js.map +1 -1
  470. package/dist/services/turn-completion-events.d.ts +57 -0
  471. package/dist/services/turn-completion-events.d.ts.map +1 -0
  472. package/dist/services/turn-completion-events.js +179 -0
  473. package/dist/services/turn-completion-events.js.map +1 -0
  474. package/dist/services/vc-meeting-consumer-isolation.d.ts +21 -12
  475. package/dist/services/vc-meeting-consumer-isolation.d.ts.map +1 -1
  476. package/dist/services/vc-meeting-consumer-isolation.js +25 -19
  477. package/dist/services/vc-meeting-consumer-isolation.js.map +1 -1
  478. package/dist/services/vc-meeting-consumer-profile-bootstrap.d.ts +8 -2
  479. package/dist/services/vc-meeting-consumer-profile-bootstrap.d.ts.map +1 -1
  480. package/dist/services/vc-meeting-consumer-profile-bootstrap.js +18 -10
  481. package/dist/services/vc-meeting-consumer-profile-bootstrap.js.map +1 -1
  482. package/dist/setup/bot-config-editor.d.ts.map +1 -1
  483. package/dist/setup/bot-config-editor.js +6 -0
  484. package/dist/setup/bot-config-editor.js.map +1 -1
  485. package/dist/setup/cli-availability.d.ts +2 -1
  486. package/dist/setup/cli-availability.d.ts.map +1 -1
  487. package/dist/setup/cli-availability.js +2 -1
  488. package/dist/setup/cli-availability.js.map +1 -1
  489. package/dist/setup/cli-selection.d.ts +8 -0
  490. package/dist/setup/cli-selection.d.ts.map +1 -1
  491. package/dist/setup/cli-selection.js +31 -0
  492. package/dist/setup/cli-selection.js.map +1 -1
  493. package/dist/setup/ensure-fonts.d.ts +5 -0
  494. package/dist/setup/ensure-fonts.d.ts.map +1 -1
  495. package/dist/setup/ensure-fonts.js +26 -1
  496. package/dist/setup/ensure-fonts.js.map +1 -1
  497. package/dist/setup/lark-scopes.json +3 -1
  498. package/dist/setup/open-platform-automation.d.ts +1 -1
  499. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  500. package/dist/setup/open-platform-automation.js +15 -3
  501. package/dist/setup/open-platform-automation.js.map +1 -1
  502. package/dist/skills/definitions.d.ts +1 -1
  503. package/dist/skills/definitions.d.ts.map +1 -1
  504. package/dist/skills/definitions.js +9 -2
  505. package/dist/skills/definitions.js.map +1 -1
  506. package/dist/types.d.ts +11 -4
  507. package/dist/types.d.ts.map +1 -1
  508. package/dist/utils/bot-routing.d.ts +1 -1
  509. package/dist/utils/bot-routing.d.ts.map +1 -1
  510. package/dist/utils/bot-routing.js.map +1 -1
  511. package/dist/utils/user-token.d.ts +38 -0
  512. package/dist/utils/user-token.d.ts.map +1 -1
  513. package/dist/utils/user-token.js +76 -1
  514. package/dist/utils/user-token.js.map +1 -1
  515. package/dist/utils/web-terminal-scroll.d.ts +27 -0
  516. package/dist/utils/web-terminal-scroll.d.ts.map +1 -0
  517. package/dist/utils/web-terminal-scroll.js +60 -0
  518. package/dist/utils/web-terminal-scroll.js.map +1 -0
  519. package/dist/vc-agent/polling-source.d.ts +70 -0
  520. package/dist/vc-agent/polling-source.d.ts.map +1 -1
  521. package/dist/vc-agent/polling-source.js +115 -0
  522. package/dist/vc-agent/polling-source.js.map +1 -1
  523. package/dist/worker.js +469 -86
  524. package/dist/worker.js.map +1 -1
  525. package/dist/workflows/definition.d.ts +22 -22
  526. package/dist/workflows/events/payloads.d.ts +8 -8
  527. package/dist/workflows/events/schema.d.ts +20 -20
  528. package/dist/workflows/migration/v2-run-archive-schema.d.ts +20 -20
  529. package/package.json +5 -1
  530. package/dist/dashboard-web/chunks/bot-defaults-page-NQJFJ45Z.js +0 -10
  531. package/dist/dashboard-web/chunks/chunk-KEEYQ3WF.js +0 -1
  532. package/dist/dashboard-web/chunks/connectors-page-HADHZXXZ.js +0 -1
  533. package/dist/dashboard-web/chunks/groups-page-5TYF46RS.js +0 -8
  534. package/dist/dashboard-web/chunks/monitoring-page-LBU7TTTW.js +0 -2
  535. package/dist/dashboard-web/chunks/office-page-DF6RRBMN.js +0 -1
  536. package/dist/dashboard-web/chunks/overview-page-ZB6QH2EX.js +0 -1
  537. package/dist/dashboard-web/chunks/roles-page-ZBZGHIIF.js +0 -1
  538. package/dist/dashboard-web/chunks/schedules-page-NQVQC3QY.js +0 -1
  539. package/dist/dashboard-web/chunks/settings-page-E7NN4NVQ.js +0 -2
  540. package/dist/dashboard-web/chunks/team-federation-page-VGWL53AE.js +0 -1
  541. package/dist/dashboard-web/chunks/whiteboards-page-BNWUU4KG.js +0 -1
package/dist/daemon.js CHANGED
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { readFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
4
4
  import { atomicWriteFileSync } from './utils/atomic-write.js';
5
+ import { readPeerCrossRef } from './services/peer-cross-ref-store.js';
5
6
  import { readAllowedUsersResolveCache, writeAllowedUsersResolveCache } from './utils/allowed-users-cache.js';
6
7
  import { join, dirname } from 'node:path';
7
8
  import { homedir, loadavg, cpus, totalmem, freemem } from 'node:os';
@@ -17,6 +18,7 @@ import { writeHeartbeat } from './core/daemon-heartbeat.js';
17
18
  import { botmuxWrapperFiles, resolveBotmuxWrapperBinDir } from './core/botmux-wrapper.js';
18
19
  import { evaluateOverload, formatOverloadAlert, buildOverloadAlertCard, buildOverloadRecoveredCard, initialOverloadCardState, INITIAL_OVERLOAD_STATE, computeOverloadThresholds, isOverloadAlertTarget, } from './core/host-overload-alert.js';
19
20
  import { registerOverloadNonce } from './im/lark/overload-nonce.js';
21
+ import { resolveBrowserTargets, detectRunningBrowsers } from './core/browser-restart.js';
20
22
  import { countHostOverload } from './im/lark/card-handler.js';
21
23
  import { startMaintenance, stopMaintenance } from './core/maintenance.js';
22
24
  import { selectCodexRuntimeUpdateTargets, startCliRuntimeUpdateMonitor, stopCliRuntimeUpdateMonitor, } from './core/cli-runtime-update.js';
@@ -26,8 +28,11 @@ import { readSupervisorProcessStartIdentity } from './core/process-start-identit
26
28
  import { statSync } from 'node:fs';
27
29
  import { addReaction, deleteMessage, getChatContext, getChatMode, getChatNameAndMode, getMessageChatId, listChatMemberOpenIds, MessageWithdrawnError, replyMessage, resolveAllowedUsersWithMap, sendMessage, sendUserMessage, updateMessage } from './im/lark/client.js';
28
30
  import { resolveGroupJoinPrompt, waitForAllowedUserInChat } from './core/auto-start.js';
29
- import { loadBotConfigAtIndex, loadBotConfigs, isManagedActivationStartingAtIndex, registerBot, getBot, getAllBots, getOwnerOpenId, findOncallChat, effectiveDefaultWorkingDir, effectiveBotDisplayName, resolveVcMeetingConsumerProfiles, setResolvedAllowedUsersRepublishHook, setAllowedUsersResolveRetryHook, vcMeetingAgentConfigActive, } from './bot-registry.js';
31
+ import { loadBotConfigAtIndex, loadBotConfigs, isManagedActivationStartingAtIndex, registerBot, getBot, getAllBots, getOwnerOpenId, findOncallChat, effectiveDefaultWorkingDir, effectiveBotDisplayName, resolveVcMeetingConsumerProfiles, setResolvedAllowedUsersRepublishHook, setAllowedUsersResolveRetryHook, vcMeetingAgentConfigActive, botAcceptsSlashFromBots, } from './bot-registry.js';
30
32
  import { setDisplayNameRefresher, findConfigField, applyConfigField } from './services/bot-config-store.js';
33
+ import { getSkillFeedbackStore } from './services/skill-feedback-store.js';
34
+ import { enqueueTurnTerminal, drainTurnTerminalQueue } from './services/turn-completion-events.js';
35
+ import { FeedbackWebhookSecretStore, startFeedbackWebhookDispatcher } from './services/feedback-webhook-dispatcher.js';
31
36
  import { resolveRegularGroupMode } from './services/chat-reply-mode-store.js';
32
37
  import { renameBotOnOpenPlatform, changeBotAvatarOnOpenPlatform } from './services/open-platform-rename.js';
33
38
  import { migrateSandboxConfigAtStartup } from './services/sandbox-migration.js';
@@ -73,13 +78,17 @@ import { isLocalCliOpenReady } from './services/local-cli-opener.js';
73
78
  import { RECEIVED_REACTION_EMOJI_TYPE, SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE } from './core/pending-response.js';
74
79
  import { t as tr, localeForBot } from './i18n/index.js';
75
80
  import { createCliAdapterSync } from './adapters/cli/registry.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, postTurnStartingCard, isSessionTransferring, } from './core/worker-pool.js';
81
+ 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, postTurnStartingCard, isSessionTransferring, snapshotCodexAppFinalSettlements, codexAppFinalSettlementCount, } from './core/worker-pool.js';
82
+ import { waitAllWithin, trackProducerQuiet, trackProcessExited } from './core/producer-quiescence.js';
77
83
  import { AbortDeadlineError, hasExactSafeJsonKeys, ipcRoute, isTrustedHostIpcRequest, JsonBodyTooLargeError, jsonRes, readJsonBody, runWithAbortDeadline, setBotName, setLarkAppId, startIpcServer, setBotRenamer, setBotAvatarChanger, armCoreOnlyReadinessGate, setCoreOnlyReady, setSupervisorShutdownHandler } from './core/dashboard-ipc-server.js';
78
84
  import { setDeviceIsolationDaemonIdentity } from './core/device-isolation-daemon.js';
79
85
  import { cancelSessionReadyAck, waitForSessionReadyAck, } from './core/session-ready-handshake.js';
80
86
  import { loadOrCreateDashboardSecret, loadPersistedToken } from './dashboard/auth.js';
81
- import { daemonIpcAuthHeaders, loadDaemonIpcSecret } from './core/daemon-ipc-auth.js';
87
+ import { daemonIpcAuthHeaders, fetchDaemonIpc, loadDaemonIpcSecret } from './core/daemon-ipc-auth.js';
82
88
  import { authorizeSessionScopedIpc, bindSessionScopedIpcIdentity, } from './core/daemon-ipc-session-auth.js';
89
+ import { authorizeReportSessionRelayRequest, buildOrchestratorReportTrigger, REPORT_SESSION_RELAY_MAX_BYTES, REPORT_SESSION_RELAY_ROUTE, } from './core/report-session-relay.js';
90
+ import { createDispatchReportBinding, dispatchReportBindingSecretPath, DISPATCH_REPORT_REGISTER_MAX_BYTES, DISPATCH_REPORT_REGISTER_ROUTE, } from './core/dispatch-report-binding.js';
91
+ import { recordDispatchRegistryEntry } from './core/dispatch-registry.js';
83
92
  import { saveFrozenCards } from './services/frozen-card-store.js';
84
93
  import { DAEMON_COMMANDS, SESSIONLESS_DAEMON_COMMANDS, EXISTING_SESSION_ONLY_DAEMON_COMMANDS, resolvePassthroughCommands, resolveAdapterDefaultPassthroughCommands, handleCommand, handleCardCommand, handleTermLinkCommand, parseSlashCommandInvocation, parseForceTopicInvocation } from './core/command-handler.js';
85
94
  import { docWatchCommandNeedsSession } from './core/doc-watch-command.js';
@@ -88,13 +97,13 @@ import { findInheritablePeer } from './core/inherit-peer.js';
88
97
  import { isCallbackUrl, handleCallbackUrl } from './utils/user-token.js';
89
98
  import { consumeQuota, removeChatGrant, removeGlobalGrant } from './services/grant-store.js';
90
99
  import { abortCharge, commitCharge, beginCharge } from './services/quota-dedup.js';
91
- import { getSessionWorkingDir, getProjectScanDirs, getProjectScanDirsForBot, 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';
100
+ import { getSessionWorkingDir, getProjectScanDirs, getProjectScanDirsForBot, expandHome, downloadResources, formatAttachmentsHint, buildNewTopicCliInput, buildFollowUpCliInput, buildBridgeInputContent, buildReforkCliInput, getAvailableBots, restoreActiveSessions, executeScheduledTask, persistStreamCardState, rememberLastCliInput, ensureTerminalWorkerPort, ensureSessionWhiteboard, resumeSession, closeCliMismatchedSessionsForBot, } from './core/session-manager.js';
101
+ import { triggerSessionTurn, reconcileIdempotencyLeasesOnBoot, convergeIdempotentAsyncTurnOnWorkerExit, externalEventOpensOwnTopic } from './core/trigger-session.js';
93
102
  import { runDetachedBotTurnMutation, tryWithBotTurnMutation, withBotTurnAdmission, withBotTurnMutation, } from './core/bot-turn-mutation-gate.js';
94
103
  import { claimInitialUserTurn, isInitialUserTurnPending, releaseInitialUserTurn } from './core/initial-user-turn.js';
95
104
  import { applyQueuedCodexAppLegacyFallback, mergeQueuedCodexAppTurn } from './core/session-create.js';
96
105
  import { findOnlineDaemon, listOnlineDaemons } from './utils/daemon-discovery.js';
97
- import { beginReplyTargetTurn, buildTurnParticipantsFrom, fallbackTurnId, isSubstituteTurn, resolveSessionReplyTarget, syncReplyTargetState } from './core/reply-target.js';
106
+ import { beginReplyTargetTurn, buildTurnParticipantsFrom, fallbackTurnId, isSubstituteTurn, resolveInboundReplyTarget, resolveSessionReplyTarget, syncReplyTargetState } from './core/reply-target.js';
98
107
  import { readDeferredTopicBinding } from './core/deferred-topic-binding.js';
99
108
  import { buildBotmuxLarkNativeSessionTitle, extractBotmuxLarkNativeSessionTitlePrompt, } from './core/session-title.js';
100
109
  import { settleDeferredScheduleRun } from './core/deferred-schedule-settlement.js';
@@ -187,11 +196,14 @@ import { computeCocoPickerKeys } from './core/coco-picker-keys.js';
187
196
  import { createLarkAskCardDispatcher } from './im/lark/ask-card.js';
188
197
  import { normalizeVcMeetingEvents } from './vc-agent/normalizer.js';
189
198
  import { beginVcIngestionPass, collectStableTranscriptItems, createVcMeetingSessionState, ingestNormalizedVcMeetingItems, markVcTranscriptItemsFlushed, } from './vc-agent/meeting-state.js';
190
- import { fetchMeetingEventsAsBot, joinMeetingAsBot, sendMeetingTextMessageAsBot, } from './vc-agent/polling-source.js';
199
+ import { ensureLarkCliBotProfile, fetchMeetingEventsAsBot, joinMeetingAsBot, sendMeetingTextMessageAsBot, } from './vc-agent/polling-source.js';
191
200
  import { buildVcMeetingConfirmCard, buildVcMeetingConsumerCard, buildVcMeetingConsumerRecoveryCard, buildVcMeetingListenerRejoinCard, buildVcMeetingOutputReviewCard, } from './vc-agent/cards.js';
192
201
  import { connectRealtimeVoiceTransport, createProtoRealtimeVoiceProtocol, fetchRealtimeVoiceEndpoint, RealtimeVoiceSession, } from './vc-agent/realtime/index.js';
193
202
  import { createGroupWithBots } from './services/group-creator.js';
194
203
  import { addBotToChat, isInChat } from './services/groups-store.js';
204
+ import { initSessionGroups, isSessionGroup, getSessionGroup, touchSessionGroup } from './services/session-groups-store.js';
205
+ import { maybeBirthSessionGroup } from './core/session-group-birth.js';
206
+ import { scheduleSessionGroupTitle } from './services/session-group-title.js';
195
207
  import { setChatReplyMode } from './services/chat-reply-mode-store.js';
196
208
  import { hasVcMeetingEndedTombstone, listVcMeetingRuntimeSessionsByListenerAndAgent, listVcMeetingRuntimeSessions, pruneExpiredVcMeetingRuntimeSessions, recordVcMeetingEndedTombstone, recordVcMeetingRuntimeSession, removeVcMeetingRuntimeSession, } from './services/vc-meeting-runtime-store.js';
197
209
  import { computeVcMeetingConsumerProfileHash, normalizeVcMeetingProfileInstructions, } from './services/vc-meeting-profile-instructions.js';
@@ -1085,6 +1097,17 @@ const VC_MEETING_ACTIVATION_CONTEXT_FIELD = 'vc_meeting_activation_context';
1085
1097
  const VC_MEETING_ACTIVATION_CONTEXT_MAX_CHARS = 2_000;
1086
1098
  const vcMeetingEndedTombstones = new BoundedMap(2_000);
1087
1099
  const vcMeetingPendingInvites = new BoundedMap(2_000);
1100
+ /**
1101
+ * De-dupes the owner DM for an eager-join failure that happens BEFORE a session
1102
+ * exists (invite carries only a meeting_no, no meeting.id — so there is no
1103
+ * session to hang `inviteFailureNotified` on). Keyed by `${larkAppId}:${no}`;
1104
+ * bounded and best-effort. Cleared once the meeting is successfully joined so a
1105
+ * later genuine failure can still notify. */
1106
+ const vcMeetingEagerJoinFailureNotified = new BoundedMap(2_000);
1107
+ const VC_MEETING_EAGER_JOIN_FAILURE_TTL_MS = 30 * 60 * 1000;
1108
+ function vcMeetingEagerJoinFailureKey(larkAppId, meetingNo) {
1109
+ return `${larkAppId}:${meetingNo}`;
1110
+ }
1088
1111
  function vcMeetingSessionKey(larkAppId, meetingId) {
1089
1112
  return `${larkAppId}:${meetingId}`;
1090
1113
  }
@@ -1268,6 +1291,70 @@ function vcMeetingConfiguredBotConfigs() {
1268
1291
  function vcMeetingConfiguredBotConfig(larkAppId) {
1269
1292
  return vcMeetingConfiguredBotConfigs().find(cfg => cfg.larkAppId === larkAppId);
1270
1293
  }
1294
+ /**
1295
+ * Ensure the lark-cli bot profile used for `--as bot` meeting join actually
1296
+ * exists, provisioning it from the bot's stored appSecret when absent.
1297
+ *
1298
+ * Historically `larkCliProfile` defaulted to the bot's own appId (see the
1299
+ * dashboard managed-listener bootstrap) but nothing ever registered that
1300
+ * profile with lark-cli, so the first real invite failed with "profile not
1301
+ * found" and the bot was stuck ringing. This closes the gap in the trusted
1302
+ * daemon process (the secret only reaches lark-cli's encrypted store, never a
1303
+ * worker/sandbox and never argv). Idempotent: a present profile is a no-op.
1304
+ */
1305
+ function ensureVcMeetingJoinProfile(larkAppId, cfg) {
1306
+ const profile = cfg.larkCliProfile?.trim();
1307
+ if (!profile) {
1308
+ return { ok: false, reason: 'no_profile', error: '缺少 vcMeetingAgent.larkCliProfile' };
1309
+ }
1310
+ const botCfg = vcMeetingLocalBotConfig(larkAppId) ?? vcMeetingConfiguredBotConfig(larkAppId);
1311
+ const result = ensureLarkCliBotProfile({
1312
+ profileName: profile,
1313
+ appId: larkAppId,
1314
+ appSecret: botCfg?.larkAppSecret ?? '',
1315
+ ...(botCfg?.brand ? { brand: botCfg.brand } : {}),
1316
+ });
1317
+ if (result.ok) {
1318
+ if (result.created) {
1319
+ logger.info(`[vc-agent] auto-provisioned lark-cli profile ${profile} for ${larkAppId} before join`);
1320
+ }
1321
+ return { ok: true, profile, created: result.created };
1322
+ }
1323
+ return { ok: false, reason: result.reason, profile, error: result.error };
1324
+ }
1325
+ /** Actionable, operator-facing text for a failed join-profile provision. */
1326
+ function vcMeetingJoinProfileErrorText(larkAppId, result) {
1327
+ switch (result.reason) {
1328
+ case 'no_profile':
1329
+ return '缺少 vcMeetingAgent.larkCliProfile,拒绝使用 lark-cli 默认 profile 入会';
1330
+ case 'missing_secret':
1331
+ return `无法为 ${larkAppId} 自动创建 lark-cli profile「${result.profile}」:配置里没有该 bot 的 appSecret(apiOnly bot 无法入会)`;
1332
+ case 'add_failed':
1333
+ return `自动创建 lark-cli profile「${result.profile}」失败:${result.error}`;
1334
+ }
1335
+ }
1336
+ /**
1337
+ * DM the meeting-agent owner when an invite fails, so a broken join no longer
1338
+ * shows up only as a silently-ringing bot + one log line. Best-effort and
1339
+ * de-duped per session so retries don't spam. `errorText` is already the
1340
+ * actionable operator-facing message from startVcMeetingMonitoring.
1341
+ */
1342
+ function notifyVcMeetingInviteFailure(larkAppId, cfg, session, meeting, errorText) {
1343
+ if (session) {
1344
+ if (session.inviteFailureNotified)
1345
+ return;
1346
+ session.inviteFailureNotified = true;
1347
+ }
1348
+ const target = vcMeetingTargetOpenId(larkAppId, cfg);
1349
+ if (!target)
1350
+ return;
1351
+ const title = vcMeetingTitle(meeting);
1352
+ void sendUserMessage(larkAppId, target, `⚠️ 机器人未能加入会议「${title}」。\n\n原因:${errorText}\n\n`
1353
+ + '请到 dashboard 检查该会议 bot 的 lark-cli profile / appSecret / VC 权限后重新邀请。', 'text').catch((err) => {
1354
+ logger.warn(`[vc-agent] invite failure notice failed meeting=${meeting.id || '?'}: `
1355
+ + `${err instanceof Error ? err.message : String(err)}`);
1356
+ });
1357
+ }
1271
1358
  function vcMeetingConsumerBotOnline(larkAppId) {
1272
1359
  return !!vcMeetingLocalBotConfig(larkAppId) || !!findOnlineDaemon(larkAppId);
1273
1360
  }
@@ -1459,6 +1546,12 @@ async function ensureVcMeetingReceiverSession(request, existingSessionId, receiv
1459
1546
  if (!isolation.decision.ok) {
1460
1547
  throw new Error(`receiver agent ${selfAppId} has no managed side-effect isolation: ${isolation.decision.error}`);
1461
1548
  }
1549
+ // Plan B: the receiver runs sandboxed only when the bot actually opted in and
1550
+ // the boundary is deliverable. An unsandboxed opt-out (isolated:false) must
1551
+ // spawn WITHOUT the sandbox — hardcoding sandbox=true here would confine a bot
1552
+ // whose environment/working dir was never set up for bwrap, and the CLI would
1553
+ // fail to spawn or be unable to work (the "joined but never replies" bug).
1554
+ const receiverSandboxed = isolation.decision.isolated;
1462
1555
  const rawWorkingDir = findOncallChat(selfAppId, chatId)?.workingDir
1463
1556
  ?? effectiveDefaultWorkingDir(bot.config)
1464
1557
  ?? bot.config.workingDir
@@ -1481,11 +1574,12 @@ async function ensureVcMeetingReceiverSession(request, existingSessionId, receiv
1481
1574
  session.cliId = bot.config.cliId;
1482
1575
  // Freeze the security-critical launch decision at receiver creation. A
1483
1576
  // later live Bot-config edit must neither weaken this session nor make an
1484
- // old unisolated receiver appear eligible retroactively.
1485
- session.sandbox = true;
1486
- session.sandboxHidePaths = bot.config.sandboxHidePaths ?? [];
1487
- session.sandboxReadonlyPaths = bot.config.sandboxReadonlyPaths ?? [];
1488
- session.sandboxNetwork = bot.config.sandboxNetwork !== false;
1577
+ // old unisolated receiver appear eligible retroactively. Under plan B the
1578
+ // frozen value follows the bot's opt-in, not a hardcoded true.
1579
+ session.sandbox = receiverSandboxed;
1580
+ session.sandboxHidePaths = receiverSandboxed ? (bot.config.sandboxHidePaths ?? []) : [];
1581
+ session.sandboxReadonlyPaths = receiverSandboxed ? (bot.config.sandboxReadonlyPaths ?? []) : [];
1582
+ session.sandboxNetwork = receiverSandboxed ? (bot.config.sandboxNetwork !== false) : true;
1489
1583
  session.backendType = isolation.backendType;
1490
1584
  sessionStore.updateSession(session);
1491
1585
  const ds = {
@@ -2052,23 +2146,6 @@ function receivedReactionEmojiFor(ds) {
2052
2146
  return RECEIVED_REACTION_EMOJI_TYPE;
2053
2147
  }
2054
2148
  }
2055
- function readSessionFreshFromDisk(sessionId, larkAppId) {
2056
- const paths = [
2057
- join(config.session.dataDir, `sessions-${larkAppId}.json`),
2058
- join(config.session.dataDir, 'sessions.json'),
2059
- ];
2060
- for (const fp of paths) {
2061
- if (!existsSync(fp))
2062
- continue;
2063
- try {
2064
- const data = JSON.parse(readFileSync(fp, 'utf-8'));
2065
- if (data[sessionId])
2066
- return data[sessionId];
2067
- }
2068
- catch { /* ignore corrupt/racing session file */ }
2069
- }
2070
- return undefined;
2071
- }
2072
2149
  export async function noteTurnReceived(ds, triggerMessageId, _prompt, _sender, _turnId, receivedReactionEmoji) {
2073
2150
  // Replaces the old 「处理中」 placeholder card. That card existed only to be
2074
2151
  // PATCHed with the final answer, and `im.v1.message.patch` is silent (no Feishu
@@ -2239,7 +2316,7 @@ async function sessionReply(anchor, content, msgType = 'text', larkAppId, turnId
2239
2316
  return replyWithHookPolicy(root.rootMessageId, content, msgType, true, opts.uuid);
2240
2317
  }
2241
2318
  if (ds?.scope === 'chat') {
2242
- const fresh = readSessionFreshFromDisk(ds.session.sessionId, ds.larkAppId);
2319
+ const fresh = sessionStore.readSessionRowFromDisk(ds.session.sessionId, ds.larkAppId);
2243
2320
  if (fresh)
2244
2321
  syncReplyTargetState(ds, fresh);
2245
2322
  // Resolve through fallbackTurnId so daemon-side sends that carry no turn of
@@ -3127,6 +3204,7 @@ async function prewarmDocCommentSession(ds, sub) {
3127
3204
  botCliPathOverride: botCfg.cliPathOverride,
3128
3205
  sender,
3129
3206
  mode: 'live',
3207
+ turnId,
3130
3208
  });
3131
3209
  rememberLastCliInput(ds, promptContent, cliInput);
3132
3210
  sessionStore.updateSession(ds.session);
@@ -3145,6 +3223,7 @@ async function prewarmDocCommentSession(ds, sub) {
3145
3223
  botIdentity: { name: bot.botName, openId: bot.botOpenId },
3146
3224
  sender,
3147
3225
  mode: 'refork',
3226
+ turnId,
3148
3227
  });
3149
3228
  rememberLastCliInput(ds, promptContent, wrappedInput);
3150
3229
  sessionStore.updateSession(ds.session);
@@ -3161,6 +3240,24 @@ const commandDeps = {
3161
3240
  lastRepoScan,
3162
3241
  prewarmDocCommentSession,
3163
3242
  };
3243
+ /** Bind every reply produced by one slash-command invocation to the routing
3244
+ * decision already made for its inbound Lark message. Commands return before
3245
+ * the normal turn-registration path, and lifecycle commands may remove or
3246
+ * replace their session before replying, so mutable session state cannot be
3247
+ * the authority for placement here. */
3248
+ function commandDepsForInvocation(input) {
3249
+ const replyTarget = resolveInboundReplyTarget({
3250
+ scope: input.scope,
3251
+ chatId: input.chatId,
3252
+ threadRootId: input.anchor,
3253
+ replyRootId: input.replyRootId,
3254
+ });
3255
+ return {
3256
+ ...commandDeps,
3257
+ invocationReplyTarget: replyTarget,
3258
+ sessionReply: (rootId, content, msgType, larkAppId, turnId, opts) => sessionReply(rootId, content, msgType, larkAppId, turnId ?? input.messageId, opts?.replyTarget ? opts : { ...opts, replyTarget }),
3259
+ };
3260
+ }
3164
3261
  /**
3165
3262
  * Fire a session-less daemon command (`/group`, `/g`) WITHOUT blocking the Lark
3166
3263
  * event ACK on its slow work — the fast-ACK path.
@@ -3181,8 +3278,8 @@ const commandDeps = {
3181
3278
  * wraps its whole body in try/catch and replies failures to the chat; the `.catch`
3182
3279
  * here is only a last-resort backstop for anything that somehow escapes.
3183
3280
  */
3184
- function fireSessionlessCommandDetached(cmd, anchor, message, larkAppId) {
3185
- void handleCommand(cmd, anchor, message, commandDeps, larkAppId).catch((err) => logger.error(`[sessionless ${cmd}] ${anchor.substring(0, 12)} failed: ${err?.message ?? err}`));
3281
+ function fireSessionlessCommandDetached(cmd, anchor, message, larkAppId, deps) {
3282
+ void handleCommand(cmd, anchor, message, deps, larkAppId).catch((err) => logger.error(`[sessionless ${cmd}] ${anchor.substring(0, 12)} failed: ${err?.message ?? err}`));
3186
3283
  }
3187
3284
  // Dependencies passed to card-handler
3188
3285
  // v3 run-level progress is a best-effort IM projection. journal.ndjson remains
@@ -3975,6 +4072,187 @@ workflowDaemonMutationRoute('grant', async (reply, params, body, identity) => {
3975
4072
  v3GateRunner.driveDetached(runId);
3976
4073
  return reply(202, { ok: true, runId, ...outcome });
3977
4074
  });
4075
+ // ─── report session relay:隔离 CLI 的 dispatch 完成回注 ──────────────────
4076
+ //
4077
+ // Linux credential-only bwrap / macOS read isolation 都
4078
+ // 不允许 CLI 读取 `.dashboard-secret`。这里验证 source session 当前轮换的
4079
+ // capability,并把 dispatch root 与 daemon 自己的 live session 绑定;目标
4080
+ // app/session 只从宿主签名的 dispatch binding 派生,再由本 daemon 用 HMAC 转发。
4081
+ ipcRoute('POST', DISPATCH_REPORT_REGISTER_ROUTE, async (req, res) => {
4082
+ let raw;
4083
+ try {
4084
+ raw = await readJsonBody(req, DISPATCH_REPORT_REGISTER_MAX_BYTES);
4085
+ }
4086
+ catch (error) {
4087
+ if (error instanceof JsonBodyTooLargeError) {
4088
+ return jsonRes(res, 413, { ok: false, error: 'body_too_large' });
4089
+ }
4090
+ return jsonRes(res, 400, { ok: false, error: 'bad_json' });
4091
+ }
4092
+ const body = raw && typeof raw === 'object' && !Array.isArray(raw)
4093
+ ? raw
4094
+ : undefined;
4095
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId.trim() : '';
4096
+ const seedText = typeof body?.seedText === 'string' ? body.seedText.trim() : '';
4097
+ const targetChatId = typeof body?.targetChatId === 'string' ? body.targetChatId.trim() : '';
4098
+ const title = typeof body?.title === 'string' ? body.title.trim().slice(0, 200) : '';
4099
+ if (!sessionId)
4100
+ return jsonRes(res, 400, { ok: false, error: 'missing_session_id' });
4101
+ if (!seedText) {
4102
+ return jsonRes(res, 400, { ok: false, error: 'missing_seed_text' });
4103
+ }
4104
+ if (!/^oc_[A-Za-z0-9_-]{1,128}$/.test(targetChatId)) {
4105
+ return jsonRes(res, 400, { ok: false, error: 'bad_target_chat_id' });
4106
+ }
4107
+ const ds = findActiveBySessionId(sessionId);
4108
+ const verified = authorizeSessionScopedIpc({
4109
+ trustedHost: isTrustedHostIpcRequest(req),
4110
+ sessionExists: !!ds && ds.session.sessionId === sessionId,
4111
+ receiverSession: !!ds?.session.vcMeetingReceiver,
4112
+ allowReceiver: false,
4113
+ sessionId,
4114
+ ...(ds?.managedTurnOrigin ? { liveOrigin: ds.managedTurnOrigin } : {}),
4115
+ ...(typeof body?.originCapability === 'string'
4116
+ ? { claimedCapability: body.originCapability }
4117
+ : {}),
4118
+ ...(typeof body?.originTurnId === 'string' ? { claimedTurnId: body.originTurnId } : {}),
4119
+ ...(typeof body?.originDispatchAttempt === 'number'
4120
+ ? { claimedDispatchAttempt: body.originDispatchAttempt }
4121
+ : {}),
4122
+ });
4123
+ if (!verified.ok)
4124
+ return jsonRes(res, 403, { ok: false, error: verified.error });
4125
+ if (!ds || !ds.larkAppId || ds.larkAppId !== selfDaemonLarkAppId) {
4126
+ return jsonRes(res, 403, { ok: false, error: 'session_identity_incomplete' });
4127
+ }
4128
+ let dispatchRoot;
4129
+ try {
4130
+ dispatchRoot = await sendMessage(ds.larkAppId, targetChatId, seedText, 'text');
4131
+ }
4132
+ catch (error) {
4133
+ return jsonRes(res, 502, {
4134
+ ok: false,
4135
+ error: 'dispatch_seed_send_failed',
4136
+ detail: error instanceof Error ? error.message : String(error),
4137
+ });
4138
+ }
4139
+ const bindingSecret = loadOrCreateDashboardSecret(dispatchReportBindingSecretPath(config.session.dataDir));
4140
+ const issuedAt = new Date().toISOString();
4141
+ const reportBinding = createDispatchReportBinding(bindingSecret, {
4142
+ dispatchRoot,
4143
+ targetLarkAppId: ds.larkAppId,
4144
+ targetSessionId: ds.session.sessionId,
4145
+ sourceName: title || 'dispatched subtask',
4146
+ issuedAt,
4147
+ });
4148
+ const stringArray = (value) => Array.isArray(value)
4149
+ ? value.filter((item) => typeof item === 'string')
4150
+ .map(item => item.trim()).filter(Boolean).slice(0, 64)
4151
+ : [];
4152
+ try {
4153
+ await recordDispatchRegistryEntry(join(config.session.dataDir, 'orchestrate-dispatch.json'), dispatchRoot, {
4154
+ orchRoot: ds.session.rootMessageId ?? '',
4155
+ orchChatId: ds.session.chatId,
4156
+ orchScope: ds.scope ?? ds.session.scope ?? 'thread',
4157
+ orchAppId: ds.larkAppId,
4158
+ orchSessionId: ds.session.sessionId,
4159
+ targetChatId,
4160
+ targetAppIds: stringArray(body?.targetAppIds),
4161
+ title,
4162
+ bots: stringArray(body?.bots),
4163
+ createdAt: issuedAt,
4164
+ reportBinding,
4165
+ });
4166
+ }
4167
+ catch (error) {
4168
+ return jsonRes(res, 500, {
4169
+ ok: false,
4170
+ error: 'dispatch_registry_write_failed',
4171
+ detail: error instanceof Error ? error.message : String(error),
4172
+ });
4173
+ }
4174
+ return jsonRes(res, 201, { ok: true, dispatchRoot });
4175
+ });
4176
+ ipcRoute('POST', REPORT_SESSION_RELAY_ROUTE, async (req, res) => {
4177
+ let raw;
4178
+ try {
4179
+ raw = await readJsonBody(req, REPORT_SESSION_RELAY_MAX_BYTES);
4180
+ }
4181
+ catch (error) {
4182
+ if (error instanceof JsonBodyTooLargeError) {
4183
+ return jsonRes(res, 413, { ok: false, error: 'body_too_large' });
4184
+ }
4185
+ return jsonRes(res, 400, { ok: false, error: 'bad_json' });
4186
+ }
4187
+ const claimedSessionId = raw && typeof raw === 'object' && !Array.isArray(raw)
4188
+ ? raw.sessionId
4189
+ : undefined;
4190
+ const ds = typeof claimedSessionId === 'string'
4191
+ ? findActiveBySessionId(claimedSessionId)
4192
+ : undefined;
4193
+ let registry = {};
4194
+ try {
4195
+ const parsed = JSON.parse(readFileSync(join(config.session.dataDir, 'orchestrate-dispatch.json'), 'utf8'));
4196
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
4197
+ registry = parsed;
4198
+ }
4199
+ }
4200
+ catch { /* missing/malformed registry fails closed below */ }
4201
+ const decision = authorizeReportSessionRelayRequest({
4202
+ raw,
4203
+ trustedHost: isTrustedHostIpcRequest(req),
4204
+ session: ds
4205
+ ? {
4206
+ sessionId: ds.session.sessionId,
4207
+ larkAppId: ds.larkAppId,
4208
+ receiver: !!ds.session.vcMeetingReceiver,
4209
+ scope: ds.scope ?? ds.session.scope,
4210
+ rootMessageId: ds.session.rootMessageId,
4211
+ ...(ds.managedTurnOrigin ? { liveOrigin: ds.managedTurnOrigin } : {}),
4212
+ ...(ds.session.quoteTargetId ? { quoteTargetId: ds.session.quoteTargetId } : {}),
4213
+ ...(ds.session.replyTargets ? { replyTargets: ds.session.replyTargets } : {}),
4214
+ ...((ds.currentReplyTarget ?? ds.session.currentReplyTarget)
4215
+ ? { currentReplyTarget: ds.currentReplyTarget ?? ds.session.currentReplyTarget }
4216
+ : {}),
4217
+ }
4218
+ : undefined,
4219
+ selfLarkAppId: selfDaemonLarkAppId,
4220
+ registry,
4221
+ bindingSecret: loadOrCreateDashboardSecret(dispatchReportBindingSecretPath(config.session.dataDir)),
4222
+ });
4223
+ if (!decision.ok) {
4224
+ return jsonRes(res, decision.status, { ok: false, error: decision.error });
4225
+ }
4226
+ const targetDaemon = findOnlineDaemon(decision.target.larkAppId);
4227
+ if (!targetDaemon) {
4228
+ return jsonRes(res, 503, { ok: false, error: 'orchestrator_daemon_offline' });
4229
+ }
4230
+ const trigger = buildOrchestratorReportTrigger(decision, {
4231
+ requestId: `report:${decision.source.sessionId}:${Date.now()}`,
4232
+ receivedAt: new Date().toISOString(),
4233
+ });
4234
+ try {
4235
+ const response = await fetchDaemonIpc(targetDaemon.ipcPort, '/api/trigger', {
4236
+ method: 'POST',
4237
+ headers: { 'content-type': 'application/json' },
4238
+ body: JSON.stringify(trigger),
4239
+ });
4240
+ const responseBody = await response.json().catch(() => ({}));
4241
+ return jsonRes(res, response.status, {
4242
+ ...(responseBody && typeof responseBody === 'object' && !Array.isArray(responseBody)
4243
+ ? responseBody
4244
+ : {}),
4245
+ reportTarget: decision.target,
4246
+ });
4247
+ }
4248
+ catch (error) {
4249
+ return jsonRes(res, 502, {
4250
+ ok: false,
4251
+ error: 'orchestrator_daemon_unreachable',
4252
+ detail: error instanceof Error ? error.message : String(error),
4253
+ });
4254
+ }
4255
+ });
3978
4256
  // ─── v3 session relay:sandbox / read-isolation 会话的 workflow 变更通道 ─────
3979
4257
  //
3980
4258
  // 沙盒(Linux bwrap)/ read-isolation(macOS)里的 chat CLI 读不到宿主进程树
@@ -4532,6 +4810,57 @@ ipcRoute('GET', '/api/adopt-session/:pid', (_req, res, params) => {
4532
4810
  }
4533
4811
  return jsonRes(res, 404, { ok: false, error: 'no_adopt_session' });
4534
4812
  });
4813
+ /**
4814
+ * 按 (cliId, cliSessionId) 在活跃会话里反查 CLI 原生会话绑定。
4815
+ *
4816
+ * 反查 identity 必须是组合键:OpenCode V1→V2 迁移会原样保留 ses_* id(同一
4817
+ * id 同时存在于 V1 session 与 V2 session_v2),只按 cliSessionId 匹配会把
4818
+ * V1 会话(cliId=opencode)当成唯一 hit,错投到 V1 话题。本查询只服务
4819
+ * opencode2 共享托管 service,命中必须恰好一个:0 个 → miss,≥2 个
4820
+ * (两个话题/机器人并发导入同一外部会话的重复绑定)→ conflict。
4821
+ */
4822
+ export function matchCliSession(activeSessions, cliId, cliSessionId) {
4823
+ const matches = [...activeSessions].filter((ds) => ds.session.cliId === cliId && ds.session.cliSessionId === cliSessionId);
4824
+ if (matches.length === 0)
4825
+ return { kind: 'miss' };
4826
+ if (matches.length > 1)
4827
+ return { kind: 'conflict' };
4828
+ return { kind: 'hit', session: matches[0] };
4829
+ }
4830
+ // GET /api/session-by-cli/:cliId/:cliSessionId — 按 (cliId, cliSessionId) 反查
4831
+ // 活跃 botmux 会话路由。托管 service 场景(opencode2 的 ask 插件运行在所有
4832
+ // 客户端共用的 service 里):hook 子进程继承的是启动该 service 时固化的 ambient
4833
+ // env,与当前会话无关;必须用 payload 携带的 native sessionID 显式反查,否则
4834
+ // 跨会话错投(多 bot/多会话会放行或错投到首个启动 service 的会话)。
4835
+ // 反查 identity 必须是 (cliId, cliSessionId):OpenCode V1→V2 迁移会原样保留
4836
+ // ses_* id(真实环境验证同一 id 同时存在于 V1 session 与 V2 session_v2),
4837
+ // 只按 cliSessionId 匹配会把 V1 会话当成唯一 hit,V2 hook 的问题被投到 V1 话题。
4838
+ // 本端点只服务 opencode2 共享托管 service 的反查,强制 cliId === 'opencode2';
4839
+ // 多命中返回 409 conflict,让反查侧 fail closed(歧义时 passthrough)。
4840
+ ipcRoute('GET', '/api/session-by-cli/:cliId/:cliSessionId', (_req, res, params) => {
4841
+ const cliId = params.cliId;
4842
+ const cliSessionId = params.cliSessionId;
4843
+ if (cliId !== 'opencode2') {
4844
+ return jsonRes(res, 400, { ok: false, error: 'unsupported_cli' });
4845
+ }
4846
+ if (!cliSessionId || cliSessionId.length > 512) {
4847
+ return jsonRes(res, 400, { ok: false, error: 'bad_cli_session_id' });
4848
+ }
4849
+ const match = matchCliSession(activeSessions.values(), cliId, cliSessionId);
4850
+ if (match.kind === 'miss') {
4851
+ return jsonRes(res, 404, { ok: false, error: 'no_session' });
4852
+ }
4853
+ if (match.kind === 'conflict') {
4854
+ return jsonRes(res, 409, { ok: false, error: 'ambiguous_cli_session' });
4855
+ }
4856
+ const ds = match.session;
4857
+ return jsonRes(res, 200, {
4858
+ sessionId: ds.session.sessionId,
4859
+ chatId: ds.chatId,
4860
+ larkAppId: ds.larkAppId,
4861
+ rootMessageId: sessionAnchorId(ds),
4862
+ });
4863
+ });
4535
4864
  for (const path of ['/api/vc-meetings/members/register', '/api/vc-meetings/members/update']) {
4536
4865
  ipcRoute('POST', path, async (req, res) => {
4537
4866
  if (!guardVcMeetingDaemonControlRoute(req, res))
@@ -6375,6 +6704,7 @@ async function applyVcMeetingTemporaryAuthCommand(active, parsed, input) {
6375
6704
  await sendMessage(session.larkAppId, listenerChatId, vcMeetingTemporaryAuthUsage(), 'text');
6376
6705
  return true;
6377
6706
  }
6707
+ input.onMutating?.();
6378
6708
  const changed = [];
6379
6709
  const unchanged = [];
6380
6710
  for (const target of parsed.targets) {
@@ -6527,6 +6857,7 @@ async function handleVcMeetingTemporaryAuthCommand(input) {
6527
6857
  mentions: input.mentions,
6528
6858
  senderOpenId: input.senderOpenId,
6529
6859
  senderUnionId: input.senderUnionId,
6860
+ onMutating: input.onMutating,
6530
6861
  });
6531
6862
  }
6532
6863
  function clearVcMeetingOutputRequestTimer(req) {
@@ -11234,13 +11565,14 @@ async function startVcMeetingMonitoring(input) {
11234
11565
  throw new Error('meeting already ended');
11235
11566
  }
11236
11567
  if (input.forceJoin || !session.joined || session.listenerPresenceStale) {
11237
- if (!input.cfg.larkCliProfile) {
11238
- throw new Error('缺少 vcMeetingAgent.larkCliProfile,拒绝使用 lark-cli 默认 profile 入会');
11568
+ const provisioned = ensureVcMeetingJoinProfile(input.larkAppId, input.cfg);
11569
+ if (!provisioned.ok) {
11570
+ throw new Error(vcMeetingJoinProfileErrorText(input.larkAppId, provisioned));
11239
11571
  }
11240
11572
  if (!meeting.meetingNo) {
11241
11573
  throw new Error('会议事件没有 meeting_no,无法执行 BotJoinMeeting');
11242
11574
  }
11243
- const joined = joinMeetingAsBot({ meetingNumber: meeting.meetingNo, profile: input.cfg.larkCliProfile });
11575
+ const joined = joinMeetingAsBot({ meetingNumber: meeting.meetingNo, profile: provisioned.profile });
11244
11576
  if (joined.meetingId && joined.meetingId !== meeting.id) {
11245
11577
  if (input.forceJoin || session.listenerPresenceStale) {
11246
11578
  throw new Error(`rejoin meeting id mismatch expected=${meeting.id} joined=${joined.meetingId}`);
@@ -12542,15 +12874,43 @@ async function handleVcMeetingPush(ctx) {
12542
12874
  if (ctx.kind === 'meeting_invited') {
12543
12875
  let meeting = ctx.meeting;
12544
12876
  let joinedByInvite = false;
12545
- if (!meeting.id && meeting.meetingNo && cfg.larkCliProfile) {
12877
+ // Eager join: an invite that carries only a meeting_no (no meeting.id) must
12878
+ // be joined here to learn the id. A failure in THIS block used to only WARN
12879
+ // and then fall through to the silent "no meeting id yet" return below — so
12880
+ // the original "profile not found → bot rings forever" bug produced no user
12881
+ // signal at all (there is no session yet for the manual-invite DM path). We
12882
+ // now DM the owner here too, deduped per meeting_no so redelivered invites
12883
+ // don't spam.
12884
+ if (!meeting.id && meeting.meetingNo) {
12885
+ const eagerNo = meeting.meetingNo;
12886
+ const notifyEagerFailure = (errorText) => {
12887
+ const dmKey = vcMeetingEagerJoinFailureKey(ctx.larkAppId, eagerNo);
12888
+ const last = vcMeetingEagerJoinFailureNotified.get(dmKey);
12889
+ const now = Date.now();
12890
+ if (last !== undefined && now - last <= VC_MEETING_EAGER_JOIN_FAILURE_TTL_MS)
12891
+ return;
12892
+ vcMeetingEagerJoinFailureNotified.set(dmKey, now);
12893
+ // session is undefined here → notify skips per-session dedup and sends.
12894
+ notifyVcMeetingInviteFailure(ctx.larkAppId, cfg, undefined, meeting, errorText);
12895
+ };
12546
12896
  try {
12547
- const joined = joinMeetingAsBot({ meetingNumber: meeting.meetingNo, profile: cfg.larkCliProfile });
12897
+ // A missing larkCliProfile previously skipped this whole block (the old
12898
+ // `&& cfg.larkCliProfile` guard) and died silently; surface it instead.
12899
+ const provisioned = ensureVcMeetingJoinProfile(ctx.larkAppId, cfg);
12900
+ if (!provisioned.ok)
12901
+ throw new Error(vcMeetingJoinProfileErrorText(ctx.larkAppId, provisioned));
12902
+ const joined = joinMeetingAsBot({ meetingNumber: eagerNo, profile: provisioned.profile });
12548
12903
  meeting = { ...meeting, id: joined.meetingId };
12549
12904
  joinedByInvite = true;
12550
- logger.info(`[vc-agent] manual invite joined before session create meeting=${meeting.id} meetingNo=${meeting.meetingNo} profile=${cfg.larkCliProfile}`);
12905
+ // Success clear any prior failure tombstone so a later real failure
12906
+ // (e.g. rotated secret on a subsequent meeting) can notify again.
12907
+ vcMeetingEagerJoinFailureNotified.delete(vcMeetingEagerJoinFailureKey(ctx.larkAppId, eagerNo));
12908
+ logger.info(`[vc-agent] manual invite joined before session create meeting=${meeting.id} meetingNo=${eagerNo} profile=${provisioned.profile}`);
12551
12909
  }
12552
12910
  catch (err) {
12553
- logger.warn(`[vc-agent] manual invite join failed meetingNo=${meeting.meetingNo}: ${err instanceof Error ? err.message : String(err)}`);
12911
+ const message = err instanceof Error ? err.message : String(err);
12912
+ logger.warn(`[vc-agent] manual invite join failed meetingNo=${eagerNo}: ${message}`);
12913
+ notifyEagerFailure(message);
12554
12914
  }
12555
12915
  }
12556
12916
  if (!meeting.id) {
@@ -12575,6 +12935,7 @@ async function handleVcMeetingPush(ctx) {
12575
12935
  });
12576
12936
  if (!result.ok) {
12577
12937
  logger.warn(`[vc-agent] manual invite start failed meeting=${meeting.id}: ${result.error}`);
12938
+ notifyVcMeetingInviteFailure(ctx.larkAppId, cfg, session, meeting, result.error);
12578
12939
  }
12579
12940
  else {
12580
12941
  deleteVcMeetingPendingInvite(key);
@@ -12871,6 +13232,7 @@ export const __vcMeetingAgentTest = {
12871
13232
  vcMeetingEndedTombstones.clear();
12872
13233
  for (const key of [...vcMeetingPendingInvites.keys()])
12873
13234
  deleteVcMeetingPendingInvite(key);
13235
+ vcMeetingEagerJoinFailureNotified.clear();
12874
13236
  },
12875
13237
  };
12876
13238
  // ─── Event handling ──────────────────────────────────────────────────────────
@@ -13219,6 +13581,10 @@ function releaseQueuedActivationReservation(ds, acknowledgedToken) {
13219
13581
  const bot = getBot(ds.larkAppId);
13220
13582
  const cliId = ds.session.cliId ?? bot.config.cliId;
13221
13583
  const rawCodexText = (ds.pendingCodexAppFollowUps ?? []).join('\n\n');
13584
+ const bufferedTurnIds = ds.pendingFollowUpTurnIds ?? [];
13585
+ const turnId = bufferedTurnIds[bufferedTurnIds.length - 1]
13586
+ ?? ds.currentReplyTarget?.turnId
13587
+ ?? `queued-activation-followup-${randomUUID()}`;
13222
13588
  const followUp = buildFollowUpCliInput(buffered.join('\n\n'), ds.session.sessionId, {
13223
13589
  cliId,
13224
13590
  cliPathOverride: ds.session.cliPathOverride ?? bot.config.cliPathOverride,
@@ -13227,15 +13593,13 @@ function releaseQueuedActivationReservation(ds, acknowledgedToken) {
13227
13593
  chatId: ds.chatId,
13228
13594
  whiteboardId: ds.session.whiteboardId,
13229
13595
  codexAppText: rawCodexText || buffered.join('\n\n'),
13596
+ sessionBackendType: ds.session.backendType,
13597
+ turnId,
13230
13598
  codexAppApplicationContext: ds.pendingCodexAppApplicationContext,
13231
13599
  codexAppMessageContext: (ds.pendingCodexAppFollowUpContexts ?? [])
13232
13600
  .filter(Boolean)
13233
13601
  .join('\n\n'),
13234
13602
  });
13235
- const bufferedTurnIds = ds.pendingFollowUpTurnIds ?? [];
13236
- const turnId = bufferedTurnIds[bufferedTurnIds.length - 1]
13237
- ?? ds.currentReplyTarget?.turnId
13238
- ?? `queued-activation-followup-${randomUUID()}`;
13239
13603
  const reservation = reserveQueuedActivationTailAdmission(ds);
13240
13604
  const bufferedGateDecisions = ds.pendingCodexAppFollowUpGateAccepted ?? [];
13241
13605
  // Runtime buffers are rolling-upgrade compatibility only. If they carry an
@@ -13526,6 +13890,7 @@ function deliverPassthroughToExistingSession(ds, cmd, commandContent, anchor, la
13526
13890
  content: commandContent,
13527
13891
  turnId: turn.messageId,
13528
13892
  });
13893
+ turn.onDelivered?.();
13529
13894
  markSessionActivity(ds);
13530
13895
  logger.info(`[${anchor.substring(0, 12)}] Passthrough ${cmd} → worker`);
13531
13896
  return;
@@ -13534,7 +13899,7 @@ function deliverPassthroughToExistingSession(ds, cmd, commandContent, anchor, la
13534
13899
  }
13535
13900
  export const __testOnly_deliverPassthroughToExistingSession = deliverPassthroughToExistingSession;
13536
13901
  async function startInitialPassthroughSession(args) {
13537
- const { larkAppId, chatId, chatType, scope, anchor, messageId, replyRootId, parsed, cmd, commandContent, senderOpenId, substitute, senderUnionId, memberUnionId, ownerOpenId, ownerUnionId, creatorOpenId, botSender, senderIsBot, cardlessForceTopicSeed, routeToCanonicalOwner, } = args;
13902
+ const { larkAppId, chatId, chatType, scope, anchor, messageId, replyRootId, parsed, cmd, commandContent, senderOpenId, substitute, senderUnionId, memberUnionId, ownerOpenId, ownerUnionId, creatorOpenId, botSender, senderIsBot, cardlessForceTopicSeed, onDurablyAdmitted, routeToCanonicalOwner, } = args;
13538
13903
  if (!await enforceMessageQuotaForCliInput(larkAppId, chatId, senderOpenId, messageId, anchor, senderUnionId, memberUnionId, chatType, botSender)) {
13539
13904
  return;
13540
13905
  }
@@ -13625,9 +13990,14 @@ async function startInitialPassthroughSession(args) {
13625
13990
  }
13626
13991
  messageQueue.ensureQueue(anchor);
13627
13992
  messageQueue.appendMessage(anchor, { ...parsed, content: commandContent });
13993
+ // 接纳标记按分支下沉,不绑在 appendMessage 上:queues/*.jsonl 仅供 dashboard
13994
+ // 预览,无投递重放语义;且 replyInvalidWorkingDirs 是「放弃本轮」分支(关闭
13995
+ // 会话后才回复),放弃前的失败必须保持「请重发」提示。
13628
13996
  if (pinnedWorkingDir && autoWt) {
13629
13997
  if (await replyInvalidWorkingDirs(anchor, larkAppId, ds))
13630
13998
  return;
13999
+ // 已 durable staging(auto_worktree)且通过放弃闸:detached 建库流程接管投递。
14000
+ onDurablyAdmitted?.();
13631
14001
  ds.initialStartPending = false; // pendingRepo/worktree now owns buffering
13632
14002
  // 挂起态提交:worktree 建好后经 runAutoWorktreeCommit → commitRepoSelection 拉起
13633
14003
  // pendingRawInput 冷启动会话。detach → 立即返回,不阻塞本条消息处理。
@@ -13645,6 +14015,8 @@ async function startInitialPassthroughSession(args) {
13645
14015
  });
13646
14016
  const availableBots = await getAvailableBots(larkAppId, chatId);
13647
14017
  forkReservedInitialRawSession(ds, availableBots);
14018
+ // fork 成功即开场已交给 CLI;fork 抛错则本轮只存在于内存,保持重发提示。
14019
+ onDurablyAdmitted?.();
13648
14020
  const reason = oncallEntry
13649
14021
  ? `oncall-bound chat ${chatId}`
13650
14022
  : inheritedFrom
@@ -13660,6 +14032,9 @@ async function startInitialPassthroughSession(args) {
13660
14032
  if (projects.length > 0) {
13661
14033
  ds.initialStartPending = false; // pendingRepo/card now owns buffering
13662
14034
  lastRepoScan.set(chatId, projects);
14035
+ // 已 durable staging(picker)且通过放弃闸;接下来卡片发送失败不得诱导重发
14036
+ //(重发会在 pendingRepo 缓冲里再入一份)。
14037
+ onDurablyAdmitted?.();
13663
14038
  const cardJson = buildRepoSelectCard(projects, getSessionWorkingDir(ds), anchor, localeForBot(larkAppId), getBot(larkAppId).config.worktreeMultiPicker);
13664
14039
  ds.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
13665
14040
  persistPendingRepoCardMessageId(ds, ds.repoCardMessageId);
@@ -13667,6 +14042,9 @@ async function startInitialPassthroughSession(args) {
13667
14042
  logger.info(`[${tag(ds)}] Waiting for repo selection before initial raw passthrough (${projects.length} projects)`);
13668
14043
  return;
13669
14044
  }
14045
+ // 已 durable staging(picker,含 rawInput)且通过放弃闸:fork 失败后本条 raw
14046
+ // passthrough 仍 durable 挂在会话上,重发会重复执行非幂等命令——先打接纳标。
14047
+ onDurablyAdmitted?.();
13670
14048
  ds.pendingRepo = false;
13671
14049
  await maybeSeedCardlessForceTopicTurn({
13672
14050
  ds,
@@ -13694,8 +14072,49 @@ function mergeVcMeetingApplicationContext(existing, incoming) {
13694
14072
  return undefined;
13695
14073
  return `${[...new Set(lines)].join('\n')}\n`;
13696
14074
  }
14075
+ /** 本轮 inbound 已被真正接纳后打标:durable 写入(queuedActivationTail /
14076
+ * pendingRepo staging)且已通过 replyInvalidWorkingDirs 之类的放弃闸,或已实际
14077
+ * 送达 worker(live send 被接受 / refork·初次冷 fork 成功 / PTY raw input 已写入 /
14078
+ * vc-auth 真实变更开始)。标记落在 ctx.ingressAdmission 共享 box 上(见其字段
14079
+ * 注释),notifyOrdinaryIngressFailure 据此区分「没送达请重发」与「已接纳勿重发」。
14080
+ * messageQueue.appendMessage 不算接纳——queues/*.jsonl 仅供 dashboard 预览,
14081
+ * 没有投递重放语义;handleCommand 命令路径不参与(其内部 catch 吞掉一切异常)。 */
14082
+ function markIngressAdmitted(ctx) {
14083
+ if (ctx.ingressAdmission)
14084
+ ctx.ingressAdmission.admitted = true;
14085
+ else
14086
+ ctx.ingressAdmission = { admitted: true };
14087
+ }
14088
+ /**
14089
+ * 普通消息处理链的终态失败收口:transport 已 ACK(用户看到消息发出去了),但
14090
+ * 异常把整条投递链掀翻——此前只剩 dispatcher 的 log-only catch,用户视角就是
14091
+ * 机器人吞消息。best-effort 回一条可行动提示后原样重抛,dispatcher 既有的错误
14092
+ * 日志与调用方语义不变。rejected 类失败(quota/权限拦截)由各自路径回复后正常
14093
+ * return,不会走到这里。
14094
+ *
14095
+ * 提示文案按接纳阶段二选一:本轮一旦被 durable queue / worker 接纳
14096
+ * (ctx.ingressAdmission.admitted,各接纳点经 markIngressAdmitted 翻转),失败
14097
+ * 只可能出在接纳之后的状态回复等 UX 支路——此时绝不能提示「请重发」,否则一次
14098
+ * Lark 瞬时发送故障会被放大成同一任务再次入队、CLI 重复执行;改为「已接收,
14099
+ * 勿重发」的澄清。未接纳的失败保持原有重发提示。
14100
+ */
14101
+ async function notifyOrdinaryIngressFailure(ctx, err) {
14102
+ const replyAnchor = ctx.scope === 'thread' ? ctx.anchor : ctx.chatId;
14103
+ const noticeKey = ctx.ingressAdmission?.admitted
14104
+ ? 'daemon.ordinary_ingress_admitted_reply_failed'
14105
+ : 'daemon.ordinary_ingress_failed';
14106
+ try {
14107
+ await sessionReply(replyAnchor, tr(noticeKey, undefined, localeForBot(ctx.larkAppId)), 'text', ctx.larkAppId);
14108
+ }
14109
+ catch (noticeErr) {
14110
+ logger.warn(`[${ctx.larkAppId}] ordinary ingress failure notice for ${ctx.messageId.substring(0, 12)} `
14111
+ + `could not be delivered: ${noticeErr instanceof Error ? noticeErr.message : String(noticeErr)}`);
14112
+ }
14113
+ throw err;
14114
+ }
13697
14115
  async function handleNewTopic(data, ctx) {
13698
- return withBotTurnAdmission(ctx.larkAppId, () => handleNewTopicAdmitted(data, ctx));
14116
+ ctx.ingressAdmission ??= { admitted: false };
14117
+ return withBotTurnAdmission(ctx.larkAppId, () => handleNewTopicAdmitted(data, ctx)).catch(err => notifyOrdinaryIngressFailure(ctx, err));
13699
14118
  }
13700
14119
  /**
13701
14120
  * Codex App steer authorization (Blocking 1, decision A) from INBOUND SOURCE
@@ -13724,10 +14143,53 @@ function computeCodexAppSteerable(facts) {
13724
14143
  }
13725
14144
  async function handleNewTopicAdmitted(data, ctx) {
13726
14145
  const { chatId, messageId, chatType, larkAppId, replyRootId, substituteTrigger, messageListener } = ctx;
14146
+ // Session-group birth re-homes the turn into the new group: replies/quotes
14147
+ // anchor on the in-group intro message, while `messageId` (the ORIGINAL
14148
+ // inbound message) keeps serving resource downloads / merge-forward
14149
+ // expansion / dedup — their keys belong to the source message.
14150
+ const replyAnchorId = ctx.replyAnchorMessageId ?? messageId;
13727
14151
  // scope/anchor are mutable here: `/t` / `/topic` may flip a 普通群 chat-scope
13728
14152
  // routing into thread-scope so the bot's first reply seeds a Lark thread.
13729
14153
  let scope = ctx.scope;
13730
14154
  let anchor = ctx.anchor;
14155
+ // ─── p2pMode='group':会话群出生 ────────────────────────────────────────
14156
+ // group 模式下,顶层 DM 消息在任何会话机制启动前先改道:建一个专属会话群
14157
+ //(bot 保留群主),把本轮 context 重写到新群(chat-scope)后递归。返回 null
14158
+ // 表示跳过/失败(斜杠命令、建群失败已私聊提示),落回旧的 DM 话题行为。
14159
+ // sessionGroupBirth 防递归;scope/anchor 形状约束保证只有全新顶层种子会出生
14160
+ //(回复旧 DM 话题的消息仍归原会话)。thread_id 检查兜住「话题内发送 + 同时
14161
+ // 发送到会话」的消息形态:它的事件 root_id 为空(顶层形态)但携带 thread_id
14162
+ // ——语境属于既有话题,绝不能当全新会话去建群;放行后按旧 thread 行为在
14163
+ // 原 DM 落话题(用户实测反馈的误建群场景)。
14164
+ if (chatType === 'p2p'
14165
+ && getBot(larkAppId).config.p2pMode === 'group'
14166
+ && !ctx.sessionGroupBirth
14167
+ && !substituteTrigger
14168
+ && scope === 'thread'
14169
+ && anchor === messageId
14170
+ && !data?.message?.thread_id) {
14171
+ const reborn = await maybeBirthSessionGroup(data, ctx);
14172
+ if (reborn)
14173
+ return handleNewTopic(data, reborn);
14174
+ }
14175
+ // ─── 会话群同群续聊 ─────────────────────────────────────────────────────
14176
+ // 会话群里的新消息发现登记的会话已关闭时,自动 resume 原会话(与话题内续聊
14177
+ // 同款体验),而不是新开 CLI。恢复失败则照常走全新会话。
14178
+ if (chatType === 'group' && scope === 'chat' && !ctx.sessionGroupBirth) {
14179
+ const sgEntry = getSessionGroup(chatId);
14180
+ if (sgEntry?.lastSessionId) {
14181
+ const prevSession = sessionStore.getSession(sgEntry.lastSessionId);
14182
+ if (prevSession?.status === 'closed') {
14183
+ const resumed = await resumeSession(sgEntry.lastSessionId, activeSessions);
14184
+ if (resumed.ok) {
14185
+ touchSessionGroup(chatId);
14186
+ logger.info(`[session-group] resumed session=${sgEntry.lastSessionId.substring(0, 8)} in chat=${chatId.substring(0, 12)}`);
14187
+ return handleThreadReply(data, ctx);
14188
+ }
14189
+ logger.warn(`[session-group] resume failed (${resumed.error}) chat=${chatId.substring(0, 12)}; spawning fresh session`);
14190
+ }
14191
+ }
14192
+ }
13731
14193
  const numberer = createImgNumberer();
13732
14194
  let forwardSeedContent = '';
13733
14195
  let forwardSeedResources = [];
@@ -13883,17 +14345,31 @@ async function handleNewTopicAdmitted(data, ctx) {
13883
14345
  return;
13884
14346
  }
13885
14347
  // Intercept daemon commands in new topics (no session needed for some commands)
13886
- const invocation = parseSlashCommandInvocation(cmdContent);
14348
+ // acceptSlashFromBots gate: a bot sender's slash command is only routed as a
14349
+ // command when this bot opts in (default on). When off, fall through to
14350
+ // ordinary message handling — the peer bot can still talk, it just can't drive
14351
+ // /clear /model /close … into this bot. Human senders are never gated here.
14352
+ const senderIsBotForSlashGate = isBotSenderType || isForeignBotSender;
14353
+ const invocation = (senderIsBotForSlashGate && !botAcceptsSlashFromBots(larkAppId))
14354
+ ? null
14355
+ : parseSlashCommandInvocation(cmdContent);
13887
14356
  if (invocation) {
13888
14357
  const { cmd, content: commandContent } = invocation;
14358
+ const invocationDeps = commandDepsForInvocation({
14359
+ scope,
14360
+ chatId,
14361
+ anchor,
14362
+ messageId: parsed.messageId,
14363
+ replyRootId,
14364
+ });
13889
14365
  const restrictedText = grantRestrictedSlashCommandText(larkAppId, chatId, senderOpenId, cmd);
13890
14366
  if (restrictedText) {
13891
- await sessionReply(anchor, restrictedText, 'text', larkAppId);
14367
+ await invocationDeps.sessionReply(anchor, restrictedText, 'text', larkAppId);
13892
14368
  return;
13893
14369
  }
13894
14370
  if (cmd === '/vc-auth') {
13895
14371
  if (!canOperate(larkAppId, chatId, senderOpenId, teamTrustUnionId)) {
13896
- await sessionReply(anchor, tr('daemon.cmd_allowed_users_only', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
14372
+ await invocationDeps.sessionReply(anchor, tr('daemon.cmd_allowed_users_only', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
13897
14373
  return;
13898
14374
  }
13899
14375
  await handleVcMeetingTemporaryAuthCommand({
@@ -13904,6 +14380,9 @@ async function handleNewTopicAdmitted(data, ctx) {
13904
14380
  mentions: parsed.mentions,
13905
14381
  senderOpenId,
13906
14382
  senderUnionId,
14383
+ // help/invalid/无监听等纯拒绝分支不打接纳标(回复失败应保持「请重发」);
14384
+ // 只有真正开始改临时授权状态后,结果回复失败才不得诱导重发。
14385
+ onMutating: () => markIngressAdmitted(ctx),
13907
14386
  });
13908
14387
  return;
13909
14388
  }
@@ -13911,14 +14390,14 @@ async function handleNewTopicAdmitted(data, ctx) {
13911
14390
  // summon has nothing to show in a brand-new topic. Route here so the generic
13912
14391
  // daemon-command block below does not pre-create a worker=null session.
13913
14392
  if (cmd === '/card') {
13914
- await handleCardCommand(anchor, larkAppId, chatId, senderOpenId, commandContent, commandDeps);
14393
+ await handleCardCommand(anchor, larkAppId, chatId, senderOpenId, commandContent, invocationDeps);
13915
14394
  return;
13916
14395
  }
13917
14396
  // /term needs a live session's terminal; in a brand-new topic there's none.
13918
14397
  // Route here (own owner-gate inside) so the generic block below doesn't
13919
14398
  // pre-create a worker=null phantom session just to reply "no session".
13920
14399
  if (cmd === '/term') {
13921
- await handleTermLinkCommand(anchor, larkAppId, chatId, senderOpenId, commandContent, commandDeps);
14400
+ await handleTermLinkCommand(anchor, larkAppId, chatId, senderOpenId, commandContent, invocationDeps);
13922
14401
  return;
13923
14402
  }
13924
14403
  if (resolvePassthroughCommands(larkAppId).has(cmd)) {
@@ -13949,6 +14428,7 @@ async function handleNewTopicAdmitted(data, ctx) {
13949
14428
  ownerOpenId: senderOpenId,
13950
14429
  ownerUnionId: senderUnionId,
13951
14430
  creatorOpenId: senderOpenId,
14431
+ onDurablyAdmitted: () => markIngressAdmitted(ctx),
13952
14432
  routeToCanonicalOwner: () => handleThreadReplyAdmitted(data, {
13953
14433
  ...ctx,
13954
14434
  scope,
@@ -13957,7 +14437,7 @@ async function handleNewTopicAdmitted(data, ctx) {
13957
14437
  });
13958
14438
  return;
13959
14439
  }
13960
- await sessionReply(anchor, tr('daemon.cmd_requires_session', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
14440
+ await invocationDeps.sessionReply(anchor, tr('daemon.cmd_requires_session', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
13961
14441
  return;
13962
14442
  }
13963
14443
  if (DAEMON_COMMANDS.has(cmd)) {
@@ -13969,7 +14449,7 @@ async function handleNewTopicAdmitted(data, ctx) {
13969
14449
  // canRunDaemonCommand = canOperate ∪(cmd ∈ canTalkDaemonCommands && canTalk):
13970
14450
  // bot 可通过名单把选定命令(如 /status)降到 canTalk;未配置时与 canOperate 全等。
13971
14451
  if (!canRunDaemonCommand(larkAppId, chatId, senderOpenId, teamTrustUnionId, cmd, senderUnionId, chatType, isBotSenderType, isBotSenderType)) {
13972
- await sessionReply(anchor, tr('daemon.cmd_allowed_users_only', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
14452
+ await invocationDeps.sessionReply(anchor, tr('daemon.cmd_allowed_users_only', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
13973
14453
  return;
13974
14454
  }
13975
14455
  // `/group` (`/g`) doesn't open a conversation — creating a sessionStore
@@ -13980,7 +14460,7 @@ async function handleNewTopicAdmitted(data, ctx) {
13980
14460
  // Fast-ACK: run detached so the WS event ack isn't blocked on /group's
13981
14461
  // slow Lark API work → no Feishu redelivery → no duplicate group.
13982
14462
  // See fireSessionlessCommandDetached.
13983
- fireSessionlessCommandDetached(cmd, anchor, { ...parsed, content: commandContent, chatId }, larkAppId);
14463
+ fireSessionlessCommandDetached(cmd, anchor, { ...parsed, content: commandContent, chatId }, larkAppId, invocationDeps);
13984
14464
  return;
13985
14465
  }
13986
14466
  // These commands operate on an EXISTING session; a brand-new topic has none. Route
@@ -13991,7 +14471,7 @@ async function handleNewTopicAdmitted(data, ctx) {
13991
14471
  // permission gates inside their handlers) this branch MUST stay after
13992
14472
  // the canOperate gate above — their handlers do not repeat that gate.
13993
14473
  if (EXISTING_SESSION_ONLY_DAEMON_COMMANDS.has(cmd)) {
13994
- await handleCommand(cmd, anchor, { ...parsed, content: commandContent }, commandDeps, larkAppId);
14474
+ await handleCommand(cmd, anchor, { ...parsed, content: commandContent }, invocationDeps, larkAppId);
13995
14475
  return;
13996
14476
  }
13997
14477
  // Same rootMessageId reasoning as below in the main spawn path:
@@ -14051,11 +14531,13 @@ async function handleNewTopicAdmitted(data, ctx) {
14051
14531
  else if (cmdDs.pendingRepo) {
14052
14532
  stageClaimedPendingRepoSetup(activeSessions, cmdDs, {
14053
14533
  mode: 'picker',
14054
- turnId: messageId,
14534
+ // Turn identity unified on the reply anchor (slash commands never
14535
+ // birth a session group, so this always equals messageId today).
14536
+ turnId: replyAnchorId,
14055
14537
  });
14056
14538
  }
14057
14539
  // Pass mention-stripped content so /command argument parsing works.
14058
- await handleCommand(cmd, anchor, { ...parsed, content: commandContent }, commandDeps, larkAppId);
14540
+ await handleCommand(cmd, anchor, { ...parsed, content: commandContent }, invocationDeps, larkAppId);
14059
14541
  return;
14060
14542
  }
14061
14543
  }
@@ -14169,9 +14651,16 @@ async function handleNewTopicAdmitted(data, ctx) {
14169
14651
  // disconnect click silently no-ops.
14170
14652
  // For chat-scope, rootMessageId stores the seed message_id (audit only);
14171
14653
  // routing keys off chatId via sessionAnchorId(), so any value works.
14172
- const rootIdForStore = scope === 'thread' ? anchor : messageId;
14654
+ // Session-group births store the in-group intro (reply anchor) here so the
14655
+ // audit trail points at a message that actually lives in the session's chat.
14656
+ const rootIdForStore = scope === 'thread' ? anchor : replyAnchorId;
14173
14657
  const initialTurnTitle = (messageListener?.replyCardTitle ?? (ctx.forwardSeedData ? followupContent : content)).substring(0, 50);
14174
14658
  const session = sessionStore.createSession(chatId, rootIdForStore, initialTurnTitle, chatType);
14659
+ // Session-group registry: point the group at its (new) resident session so
14660
+ // same-group resume and the async AI title can find it.
14661
+ if (chatType === 'group' && isSessionGroup(chatId)) {
14662
+ touchSessionGroup(chatId, session.sessionId);
14663
+ }
14175
14664
  const now = Date.now();
14176
14665
  setDirectChatDisplayNameFromSender(session, chatType, newTopicSender);
14177
14666
  const groupChatName = await groupChatNamePromise;
@@ -14189,7 +14678,11 @@ async function handleNewTopicAdmitted(data, ctx) {
14189
14678
  // `botmux send` can --mention-back / 引用 the triggering message (chat scope).
14190
14679
  // Without this the first reply hits hasQuoteTargetSender=false (exit 2) and
14191
14680
  // chat-scope首条不引用. Use the event's sender open_id (correct app scope).
14192
- session.quoteTargetId = parsed.messageId;
14681
+ // Keyed off the REPLY anchor (== messageId except for session-group births,
14682
+ // where it is the in-group intro message so quotes stay in the group instead
14683
+ // of leaking to the source DM — while messageId itself keeps serving
14684
+ // resource/merge-forward lookups against the original inbound message).
14685
+ session.quoteTargetId = replyAnchorId;
14193
14686
  session.quoteTargetSenderOpenId = senderOpenId;
14194
14687
  session.quoteTargetSenderIsBot = isForeignBotSender || parsed.senderType === 'app' || parsed.senderType === 'bot';
14195
14688
  session.lastMessageAt = new Date(now).toISOString();
@@ -14219,7 +14712,10 @@ async function handleNewTopicAdmitted(data, ctx) {
14219
14712
  initialStartPending: true,
14220
14713
  pendingRepo: !pinnedWorkingDir || autoWt,
14221
14714
  pendingPrompt: promptContent,
14222
- pendingTurnId: messageId,
14715
+ // Turn identity must equal the reply anchor so current-turn provenance
14716
+ // (quoteTargetId === marker.turnId) holds on session-group first turns.
14717
+ // Outside a birth replyAnchorId === messageId, so plain paths are unchanged.
14718
+ pendingTurnId: replyAnchorId,
14223
14719
  pendingCodexAppText: codexAppVisibleText,
14224
14720
  pendingCodexAppApplicationContext: codexAppApplicationContext || undefined,
14225
14721
  pendingCodexAppMessageContext: codexAppMessageContext,
@@ -14267,7 +14763,10 @@ async function handleNewTopicAdmitted(data, ctx) {
14267
14763
  // exact same set (a race-losing scratch's seed @s must not vanish).
14268
14764
  const newTopicPostAt = collectPostAtMentions(data?.message, ctx.forwardSeedData?.message);
14269
14765
  const newTopicWindow = buildTurnParticipants(larkAppId, senderOpenId, senderIsBotTriState(parsed.senderType, isForeignBotSender), parsed.mentions, newTopicSender?.name, newTopicPostAt);
14270
- beginReplyTargetTurn(ds, replyRootId, messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId, participants: newTopicWindow.participants, participantsIncomplete: newTopicWindow.incomplete });
14766
+ // Turn key is the reply anchor (== messageId outside session-group births) so
14767
+ // the per-turn reply context and currentReplyTarget.turnId line up with the
14768
+ // worker's turn id — current-turn provenance requires that equality.
14769
+ beginReplyTargetTurn(ds, replyRootId, replyAnchorId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId, participants: newTopicWindow.participants, participantsIncomplete: newTopicWindow.incomplete });
14271
14770
  sessionStore.updateSession(ds.session);
14272
14771
  const registration = await claimNewDaemonSession(activeSessions, ds);
14273
14772
  if (!registration.accepted) {
@@ -14298,17 +14797,25 @@ async function handleNewTopicAdmitted(data, ctx) {
14298
14797
  stageClaimedPendingRepoSetup(activeSessions, ds, {
14299
14798
  mode: autoWt ? 'auto_worktree' : 'picker',
14300
14799
  ...(autoWt && pinnedWorkingDir ? { baseDir: pinnedWorkingDir } : {}),
14301
- turnId: messageId,
14800
+ // Persisted turn identity feeds the eventual fork's turnId; it must be the
14801
+ // reply anchor so provenance (quoteTargetId === marker.turnId) holds on
14802
+ // session-group first turns that go through the repo picker / worktree.
14803
+ turnId: replyAnchorId,
14302
14804
  });
14303
14805
  }
14304
14806
  messageQueue.ensureQueue(anchor);
14305
14807
  messageQueue.appendMessage(anchor, parsed);
14808
+ // 接纳标记按分支下沉,不绑在 appendMessage 上:queues/*.jsonl 仅供 dashboard
14809
+ // 预览,无投递重放语义;且 replyInvalidWorkingDirs 是「放弃本轮」分支(关闭
14810
+ // 会话后才回复),放弃前的失败必须保持「请重发」提示。
14306
14811
  // Auto-worktree: session is registered PENDING; build the worktree off the
14307
14812
  // critical path, then commitRepoSelection pins it + forks (folding in any
14308
14813
  // messages buffered during creation). detach → return immediately.
14309
14814
  if (pinnedWorkingDir && autoWt) {
14310
14815
  if (await replyInvalidWorkingDirs(anchor, larkAppId, ds))
14311
14816
  return;
14817
+ // 已 durable staging(auto_worktree)且通过放弃闸:detached 建库流程接管投递。
14818
+ markIngressAdmitted(ctx);
14312
14819
  ds.initialStartPending = false; // pendingRepo/worktree now owns buffering
14313
14820
  startAutoWorktreePending(ds, { anchor, baseDir: pinnedWorkingDir, title: session.title, prompt: promptContent, operatorOpenId: senderOpenId });
14314
14821
  return;
@@ -14325,8 +14832,12 @@ async function handleNewTopicAdmitted(data, ctx) {
14325
14832
  messageId,
14326
14833
  });
14327
14834
  const availableBots = await getAvailableBots(larkAppId, chatId);
14328
- await noteTurnReceived(ds, messageId, content, newTopicSender, messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
14835
+ // Ack reaction targets the ORIGINAL inbound message (2nd arg); the turn id
14836
+ // (5th arg) is the reply anchor so provenance holds on session-group births.
14837
+ await noteTurnReceived(ds, messageId, content, newTopicSender, replyAnchorId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
14329
14838
  forkReservedInitialSession(ds, availableBots);
14839
+ // fork 成功即开场已交给 CLI;fork 抛错则开场只存在于内存,保持重发提示。
14840
+ markIngressAdmitted(ctx);
14330
14841
  const reason = oncallEntry
14331
14842
  ? `oncall-bound chat ${chatId}`
14332
14843
  : inheritedFrom
@@ -14348,6 +14859,9 @@ async function handleNewTopicAdmitted(data, ctx) {
14348
14859
  if (projects.length > 0) {
14349
14860
  ds.initialStartPending = false; // pendingRepo/card now owns buffering
14350
14861
  lastRepoScan.set(chatId, projects);
14862
+ // 已 durable staging(picker)且通过放弃闸;接下来卡片发送失败不得诱导重发
14863
+ //(重发会在 pendingRepo 缓冲里再入一份)。
14864
+ markIngressAdmitted(ctx);
14351
14865
  const currentCwd = getSessionWorkingDir(ds);
14352
14866
  const cardJson = buildRepoSelectCard(projects, currentCwd, anchor, localeForBot(larkAppId), getBot(larkAppId).config.worktreeMultiPicker);
14353
14867
  ds.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
@@ -14356,7 +14870,11 @@ async function handleNewTopicAdmitted(data, ctx) {
14356
14870
  logger.info(`[${tag(ds)}] Waiting for repo selection (${projects.length} projects)`);
14357
14871
  }
14358
14872
  else {
14359
- // No projects found — skip repo selection, spawn directly
14873
+ // No projects found — skip repo selection, spawn directly.
14874
+ // 已 durable staging(picker)且通过放弃闸:即使下面 fork 失败,opening 仍由
14875
+ // 会话 durable 持有(pre-init 回滚恢复 queued journal,重启复活),重发会
14876
+ // 重复执行——先打接纳标。
14877
+ markIngressAdmitted(ctx);
14360
14878
  ds.pendingRepo = false;
14361
14879
  ensureSessionWhiteboard(ds);
14362
14880
  await maybeSeedCardlessForceTopicTurn({
@@ -14366,7 +14884,9 @@ async function handleNewTopicAdmitted(data, ctx) {
14366
14884
  messageId,
14367
14885
  });
14368
14886
  const availableBots = await getAvailableBots(larkAppId, chatId);
14369
- await noteTurnReceived(ds, messageId, content, newTopicSender, messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
14887
+ // Ack reaction targets the ORIGINAL inbound message (2nd arg); the turn id
14888
+ // (5th arg) is the reply anchor so provenance holds on session-group births.
14889
+ await noteTurnReceived(ds, messageId, content, newTopicSender, replyAnchorId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
14370
14890
  forkReservedInitialSession(ds, availableBots);
14371
14891
  logger.info(`Session ${session.sessionId} ready (no projects to select), total active: ${getActiveCount()}`);
14372
14892
  }
@@ -14555,11 +15075,16 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
14555
15075
  // synthetic instruction. Codex App still needs a non-blank visible
14556
15076
  // UserMessage; reuse the localized session title only in its clean sidecar.
14557
15077
  const codexAppText = promptBody || title;
14558
- // Pick scope + anchor. 话题群 → seed a topic and anchor thread-scope there;
14559
- // 普通群 → chat-scope anchored at chatId.
15078
+ // Pick scope + anchor. 话题群 或 普通群 new-topic 模式 → seed a topic and
15079
+ // anchor thread-scope there;其余普通群 → chat-scope anchored at chatId
15080
+ // 复用入站 @ 的路由判据(externalEventOpensOwnTopic):否则「自动进群主动开工」
15081
+ // 会无视群配的 new-topic,把首轮平铺进群顶层 chat-scope,而不是像用户手动 @
15082
+ // 那样开一个独立话题。
15083
+ const regularGroupMode = resolveRegularGroupMode(larkAppId, chatId);
15084
+ const opensOwnTopic = externalEventOpensOwnTopic(mode, regularGroupMode);
14560
15085
  let scope;
14561
15086
  let anchor;
14562
- if (mode === 'topic') {
15087
+ if (opensOwnTopic) {
14563
15088
  const seedText = tr('daemon.auto_start_join_seed', undefined, localeForBot(larkAppId));
14564
15089
  anchor = await sendMessage(larkAppId, chatId, seedText, 'text');
14565
15090
  scope = 'thread';
@@ -14574,7 +15099,7 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
14574
15099
  return;
14575
15100
  }
14576
15101
  const needsSharedReply = mode === 'group'
14577
- && resolveRegularGroupMode(larkAppId, chatId) === 'shared';
15102
+ && regularGroupMode === 'shared';
14578
15103
  const { pinnedWorkingDir, pinnedFromBotDefault } = await resolvePinnedWorkingDir({ scope, anchor, chatId, chatType, larkAppId });
14579
15104
  const autoWt = willAutoWorktree(larkAppId, pinnedWorkingDir, pinnedFromBotDefault);
14580
15105
  refreshCliVersion(botCfg);
@@ -14823,17 +15348,10 @@ export const __testOnly_handleBotAdded = handleBotAdded;
14823
15348
  * than blocking the message.
14824
15349
  */
14825
15350
  function lookupForeignBotName(senderOpenId, larkAppId) {
14826
- try {
14827
- const fp = join(config.session.dataDir, `bot-openids-${larkAppId}.json`);
14828
- if (existsSync(fp)) {
14829
- const data = JSON.parse(readFileSync(fp, 'utf-8'));
14830
- for (const [name, openId] of Object.entries(data)) {
14831
- if (openId === senderOpenId)
14832
- return name;
14833
- }
14834
- }
15351
+ for (const [name, openId] of Object.entries(readPeerCrossRef(config.session.dataDir, larkAppId))) {
15352
+ if (openId === senderOpenId)
15353
+ return name;
14835
15354
  }
14836
- catch { /* fall through */ }
14837
15355
  try {
14838
15356
  const infoPath = join(config.session.dataDir, 'bots-info.json');
14839
15357
  if (existsSync(infoPath)) {
@@ -14852,7 +15370,8 @@ async function handleThreadReply(data, ctx, prepared) {
14852
15370
  // quota/resource/sender preparation awaits. Synthetic keys share the same
14853
15371
  // lock registry without occupying or mutating an active-session slot.
14854
15372
  const deliveryKey = `\u0000thread-delivery:${ctx.larkAppId}:${ctx.scope}:${ctx.anchor}`;
14855
- return withActiveSessionKeyLock(activeSessions, deliveryKey, () => withBotTurnAdmission(ctx.larkAppId, () => handleThreadReplyAdmitted(data, ctx, prepared)));
15373
+ ctx.ingressAdmission ??= { admitted: false };
15374
+ return withActiveSessionKeyLock(activeSessions, deliveryKey, () => withBotTurnAdmission(ctx.larkAppId, () => handleThreadReplyAdmitted(data, ctx, prepared))).catch(err => notifyOrdinaryIngressFailure(ctx, err));
14856
15375
  }
14857
15376
  async function handleThreadReplyAdmitted(data, ctx, prepared) {
14858
15377
  const { chatId: ctxChatId, chatType: ctxChatType, scope, anchor, larkAppId, replyRootId, substituteTrigger } = ctx;
@@ -14869,6 +15388,15 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
14869
15388
  }
14870
15389
  if (!prepared)
14871
15390
  learnFromMentions(larkAppId, parsed.mentions);
15391
+ // 会话群自愈命名:出生时 AI 命名失败(CLI 抖动/超时/当时无模板)的群会停在
15392
+ // 截断占位名——任意后续文本消息触发一次补跑(title 服务内 in-flight 去重 +
15393
+ // titled 标记幂等),把偶发失败自动治愈,而不是永远留疤。
15394
+ if (ctxChatType === 'group' && isSessionGroup(ctxChatId)) {
15395
+ const sgEntry = getSessionGroup(ctxChatId);
15396
+ if (sgEntry && !sgEntry.titled && parsed.content.trim() && !parsed.content.trim().startsWith('/')) {
15397
+ scheduleSessionGroupTitle({ larkAppId, chatId: ctxChatId, userText: parsed.content });
15398
+ }
15399
+ }
14872
15400
  // Foreign bot @mention prefix: when sender is another botmux bot,把内容包成
14873
15401
  // [来自 X 的 @mention]\n<原文> 喂给 worker,让 CLI 知道这是另一个 bot 发的——
14874
15402
  // 不是用户直接发的——后续不需要按"对话用户"的方式处理。signal-file 路径
@@ -15035,19 +15563,31 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15035
15563
  return;
15036
15564
  }
15037
15565
  // Intercept daemon commands
15038
- const invocation = parseSlashCommandInvocation(cmdContent);
15566
+ // acceptSlashFromBots gate (mirror of the new-topic path): a bot sender's
15567
+ // slash is only routed as a command when this bot opts in (default on); when
15568
+ // off it falls through to ordinary message handling. Human senders unaffected.
15569
+ const invocation = ((isBotSenderType || isForeignBot) && !botAcceptsSlashFromBots(larkAppId))
15570
+ ? null
15571
+ : parseSlashCommandInvocation(cmdContent);
15039
15572
  if (invocation) {
15040
15573
  const { cmd, content: commandContent } = invocation;
15574
+ const invocationDeps = commandDepsForInvocation({
15575
+ scope,
15576
+ chatId: ctxChatId,
15577
+ anchor,
15578
+ messageId: parsed.messageId,
15579
+ replyRootId,
15580
+ });
15041
15581
  const existingDs = activeSessions.get(sessionKey(anchor, larkAppId));
15042
15582
  const effectiveThreadChatId = existingDs?.chatId ?? threadChatId;
15043
15583
  const restrictedText = grantRestrictedSlashCommandText(larkAppId, effectiveThreadChatId, threadSenderOpenId, cmd);
15044
15584
  if (restrictedText) {
15045
- await sessionReply(anchor, restrictedText, 'text', larkAppId);
15585
+ await invocationDeps.sessionReply(anchor, restrictedText, 'text', larkAppId);
15046
15586
  return;
15047
15587
  }
15048
15588
  if (cmd === '/vc-auth') {
15049
15589
  if (!canOperate(larkAppId, effectiveThreadChatId, threadSenderOpenId, threadTeamTrustUnionId)) {
15050
- await sessionReply(anchor, tr('daemon.cmd_allowed_users_only', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
15590
+ await invocationDeps.sessionReply(anchor, tr('daemon.cmd_allowed_users_only', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
15051
15591
  return;
15052
15592
  }
15053
15593
  await handleVcMeetingTemporaryAuthCommand({
@@ -15058,10 +15598,17 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15058
15598
  mentions: parsed.mentions,
15059
15599
  senderOpenId: threadSenderOpenId,
15060
15600
  senderUnionId: threadSenderUnionId,
15601
+ // 同 new-topic 路径:仅在真正开始改状态后打接纳标。
15602
+ onMutating: () => markIngressAdmitted(ctx),
15061
15603
  });
15062
15604
  return;
15063
15605
  }
15064
- if (resolvePassthroughCommands(larkAppId).has(cmd)) {
15606
+ // Existing sessions freeze their CLI independently from the bot's current
15607
+ // config. Route passthrough capability from that frozen runtime so changing
15608
+ // `/botconfig cli` cannot make an old Codex App session receive raw_input
15609
+ // (or make an old interactive TUI lose its native slash commands).
15610
+ const passthroughCliId = existingDs?.session.cliId ?? getBot(larkAppId).config.cliId;
15611
+ if (resolvePassthroughCommands(larkAppId, passthroughCliId).has(cmd)) {
15065
15612
  if (!existingDs && threadChatId && isInitialSessionPassthrough(larkAppId, cmd)) {
15066
15613
  await startInitialPassthroughSession({
15067
15614
  larkAppId,
@@ -15089,6 +15636,7 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15089
15636
  ownerOpenId: isForeignBot ? undefined : threadSenderOpenId,
15090
15637
  ownerUnionId: isForeignBot ? undefined : data?.sender?.sender_id?.union_id,
15091
15638
  creatorOpenId: threadSenderOpenId,
15639
+ onDurablyAdmitted: () => markIngressAdmitted(ctx),
15092
15640
  routeToCanonicalOwner: () => handleThreadReplyAdmitted(data, {
15093
15641
  ...ctx,
15094
15642
  scope,
@@ -15106,7 +15654,7 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15106
15654
  // 收紧到与 daemon 命令同档;这会同时改变真人 oncall 成员的现有行为,应单独评估。
15107
15655
  const ds = existingDs;
15108
15656
  if (ds?.worker && !ds.worker.killed && hasQueuedActivationAdmissionGate(ds)) {
15109
- sessionReply(anchor, tr('daemon.cmd_activation_pending', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
15657
+ invocationDeps.sessionReply(anchor, tr('daemon.cmd_activation_pending', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
15110
15658
  logger.warn(`[${anchor.substring(0, 12)}] Refused passthrough ${cmd} before acceptance `
15111
15659
  + 'while queued activation owns raw submission order');
15112
15660
  return;
@@ -15117,7 +15665,7 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15117
15665
  // a clear message rather than deliver a silent no-op (or spawn junk Riff
15118
15666
  // tasks). Other passthrough commands are unaffected.
15119
15667
  if (cmd === '/fast' && fastToggleUnsupportedBackend(ds)) {
15120
- await sessionReply(anchor, tr('daemon.fast_unsupported_backend', undefined, localeForBot(larkAppId)), 'text', larkAppId);
15668
+ await invocationDeps.sessionReply(anchor, tr('daemon.fast_unsupported_backend', undefined, localeForBot(larkAppId)), 'text', larkAppId);
15121
15669
  return;
15122
15670
  }
15123
15671
  deliverPassthroughToExistingSession(ds, cmd, commandContent, anchor, larkAppId, {
@@ -15126,10 +15674,11 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15126
15674
  senderOpenId: threadSenderOpenId,
15127
15675
  senderIsBot: isForeignBot,
15128
15676
  substitute: !!substituteTrigger,
15677
+ onDelivered: () => markIngressAdmitted(ctx),
15129
15678
  });
15130
15679
  }
15131
15680
  else
15132
- void sessionReply(anchor, tr('daemon.cmd_needs_active_cli', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
15681
+ void invocationDeps.sessionReply(anchor, tr('daemon.cmd_needs_active_cli', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
15133
15682
  return;
15134
15683
  }
15135
15684
  if (DAEMON_COMMANDS.has(cmd)) {
@@ -15140,7 +15689,7 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15140
15689
  // /term in a thread with no existingDs would spawn a worker:null phantom
15141
15690
  // session and pollute the dashboard before replying not_ready/owner_only.
15142
15691
  if (cmd === '/term') {
15143
- await handleTermLinkCommand(anchor, larkAppId, threadChatId ?? '', threadSenderOpenId, commandContent, commandDeps);
15692
+ await handleTermLinkCommand(anchor, larkAppId, threadChatId ?? '', threadSenderOpenId, commandContent, invocationDeps);
15144
15693
  return;
15145
15694
  }
15146
15695
  // canOperate gate for thread-reply daemon commands — required in every chat
@@ -15148,7 +15697,7 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15148
15697
  // canRunDaemonCommand:canTalkDaemonCommands 名单内的命令降到 canTalk,
15149
15698
  // 与 new-topic 路径的统一闸同款(未配置时与 canOperate 全等)。
15150
15699
  if (!canRunDaemonCommand(larkAppId, effectiveThreadChatId, threadSenderOpenId, threadTeamTrustUnionId, cmd, threadSenderUnionId, ctxChatType, isBotSenderType || isForeignBot, isBotSenderType)) {
15151
- sessionReply(anchor, tr('daemon.cmd_allowed_users_only', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
15700
+ invocationDeps.sessionReply(anchor, tr('daemon.cmd_allowed_users_only', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
15152
15701
  return;
15153
15702
  }
15154
15703
  // First message of a fresh thread carrying a session-needing daemon command
@@ -15215,10 +15764,12 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15215
15764
  const cmdMessage = { ...parsed, content: commandContent, chatId: threadChatId };
15216
15765
  if (isSessionlessCommandInvocation(cmd, commandContent)) {
15217
15766
  // Fast-ACK for /group invoked mid-thread. See fireSessionlessCommandDetached.
15218
- fireSessionlessCommandDetached(cmd, anchor, cmdMessage, larkAppId);
15767
+ fireSessionlessCommandDetached(cmd, anchor, cmdMessage, larkAppId, invocationDeps);
15219
15768
  return;
15220
15769
  }
15221
- await handleCommand(cmd, anchor, cmdMessage, commandDeps, larkAppId);
15770
+ // 命令路径不打接纳标:handleCommand 内部 catch 吞掉一切异常并记日志
15771
+ //(command-handler.ts 收口),异常到不了 ingress catch,标记永远读不到。
15772
+ await handleCommand(cmd, anchor, cmdMessage, invocationDeps, larkAppId);
15222
15773
  return;
15223
15774
  }
15224
15775
  }
@@ -15440,6 +15991,8 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15440
15991
  codexAppText: parsed.content,
15441
15992
  codexAppApplicationContext,
15442
15993
  codexAppMessageContext,
15994
+ sessionBackendType: ds.session.backendType,
15995
+ turnId: parsed.messageId,
15443
15996
  });
15444
15997
  // R5-B1-1: freeze the admission-time steer authorization onto this earliest
15445
15998
  // (initialStartPending follower) tail entry — the strip-proof admit rebuild
@@ -15451,6 +16004,7 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15451
16004
  cliInput: followUp,
15452
16005
  turnId: parsed.messageId,
15453
16006
  }, tailReservation);
16007
+ markIngressAdmitted(ctx);
15454
16008
  }
15455
16009
  finally {
15456
16010
  settleAsyncQueuedActivationTailAdmission(ds);
@@ -15579,6 +16133,8 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15579
16133
  codexAppText: parsed.content,
15580
16134
  codexAppApplicationContext,
15581
16135
  codexAppMessageContext,
16136
+ sessionBackendType: ds.session.backendType,
16137
+ turnId: parsed.messageId,
15582
16138
  });
15583
16139
  ds.session.queuedPrompt ??= ds.pendingPrompt;
15584
16140
  ds.session.queuedCodexAppText ??= ds.pendingCodexAppText;
@@ -15592,6 +16148,9 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15592
16148
  turnId: parsed.messageId,
15593
16149
  }, durableTailReservation);
15594
16150
  }
16151
+ // 本轮已持久化接纳(durable opening 或 durable tail);下面的状态回复再
16152
+ // 失败也不得诱导重发(PR #846 review)。
16153
+ markIngressAdmitted(ctx);
15595
16154
  const pendingReplyKey = ds.worktreeCreating
15596
16155
  ? 'daemon.worktree_building_wait'
15597
16156
  : 'daemon.choose_repo_first';
@@ -15818,10 +16377,13 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15818
16377
  // creator re-enters the canonical route above, which appends exactly once.
15819
16378
  messageQueue.ensureQueue(anchor);
15820
16379
  messageQueue.appendMessage(anchor, parsed);
16380
+ // 接纳标记按分支下沉(同 handleNewTopicAdmitted):append 仅供 dashboard 预览,
16381
+ // replyInvalidWorkingDirs 是放弃分支,放弃前的失败必须保持「请重发」提示。
15821
16382
  // Auto-worktree: register PENDING, build worktree off-path, commit+fork later.
15822
16383
  if (pinnedWorkingDir && autoWt) {
15823
16384
  if (await replyInvalidWorkingDirs(anchor, larkAppId, newDs))
15824
16385
  return;
16386
+ markIngressAdmitted(ctx);
15825
16387
  newDs.initialStartPending = false; // pendingRepo/worktree now owns buffering
15826
16388
  startAutoWorktreePending(newDs, { anchor, baseDir: pinnedWorkingDir, title: parsed.content.substring(0, 50), prompt: promptContent, operatorOpenId: ownerOpenId });
15827
16389
  return;
@@ -15835,6 +16397,8 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15835
16397
  const availableBots = await getAvailableBots(larkAppId, autoCreateChatId);
15836
16398
  await noteTurnReceived(newDs, parsed.messageId, parsed.content, autoCreateSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
15837
16399
  forkReservedInitialSession(newDs, availableBots);
16400
+ // fork 成功即开场已交给 CLI;fork 抛错则开场只存在于内存,保持重发提示。
16401
+ markIngressAdmitted(ctx);
15838
16402
  const reason = oncallEntry
15839
16403
  ? `oncall-bound chat ${autoCreateChatId}`
15840
16404
  : inheritedFrom
@@ -15854,6 +16418,8 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15854
16418
  if (projects.length > 0) {
15855
16419
  newDs.initialStartPending = false; // pendingRepo/card now owns buffering
15856
16420
  lastRepoScan.set(autoCreateChatId, projects);
16421
+ // 已 durable staging(picker)且通过放弃闸;卡片发送失败不得诱导重发。
16422
+ markIngressAdmitted(ctx);
15857
16423
  const currentCwd = getSessionWorkingDir(newDs);
15858
16424
  const cardJson = buildRepoSelectCard(projects, currentCwd, anchor, localeForBot(larkAppId), getBot(larkAppId).config.worktreeMultiPicker);
15859
16425
  newDs.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
@@ -15862,7 +16428,10 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15862
16428
  logger.info(`[${tag(newDs)}] Waiting for repo selection (${projects.length} projects)`);
15863
16429
  }
15864
16430
  else {
15865
- // No projects found — skip repo selection, spawn directly
16431
+ // No projects found — skip repo selection, spawn directly.
16432
+ // 已 durable staging(picker):fork 失败后 opening 仍由会话 durable 持有,
16433
+ // 先打接纳标(同 handleNewTopicAdmitted 的 no-projects 分支)。
16434
+ markIngressAdmitted(ctx);
15866
16435
  newDs.pendingRepo = false;
15867
16436
  ensureSessionWhiteboard(newDs);
15868
16437
  const availableBots = await getAvailableBots(larkAppId, autoCreateChatId);
@@ -15872,6 +16441,8 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15872
16441
  return;
15873
16442
  }
15874
16443
  // Existing-owner route: append once after all pending-repo early returns.
16444
+ // 接纳标记不打在这里:append 仅供 dashboard 预览,真正的接纳在下方 live worker
16445
+ // 送达 / durable tail staging / refork 成功之后。
15875
16446
  messageQueue.ensureQueue(anchor);
15876
16447
  messageQueue.appendMessage(anchor, parsed);
15877
16448
  publishSessionMessagePreviewPatch(ds);
@@ -15934,6 +16505,8 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15934
16505
  codexAppText: parsed.content,
15935
16506
  codexAppApplicationContext,
15936
16507
  codexAppMessageContext,
16508
+ sessionBackendType: ds.session.backendType,
16509
+ turnId: parsed.messageId,
15937
16510
  });
15938
16511
  beginNewTurn(ds, parsed.content, parsed.messageId);
15939
16512
  await noteTurnReceived(ds, parsed.messageId, parsed.content, turnSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
@@ -15948,8 +16521,11 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
15948
16521
  // reached the CLI; a rejected send then left that poison behind, and the
15949
16522
  // next message's worker-null refork would read it as `hadPriorCliInput`
15950
16523
  // and wrongly `--resume` a CLI that never took a real turn.
15951
- if (accepted)
16524
+ if (accepted) {
16525
+ // 消息已实际送入 live worker——之后的收尾失败不得再诱导重发。
16526
+ markIngressAdmitted(ctx);
15952
16527
  rememberLastCliInput(ds, promptContent, cliInput);
16528
+ }
15953
16529
  else
15954
16530
  logger.warn(`[${tag(ds)}] Inbound ${parsed.messageId} was not accepted by the live worker`);
15955
16531
  }
@@ -16025,6 +16601,8 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
16025
16601
  codexAppText: parsed.content,
16026
16602
  codexAppApplicationContext,
16027
16603
  codexAppMessageContext,
16604
+ sessionBackendType: ds.session.backendType,
16605
+ turnId: parsed.messageId,
16028
16606
  });
16029
16607
  // R4-B1: freeze the admission-time steer authorization onto the queued
16030
16608
  // opening payload so the worker-null re-fork path carries it exactly like
@@ -16037,6 +16615,9 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
16037
16615
  cliInput: currentFollowUp,
16038
16616
  turnId: parsed.messageId,
16039
16617
  }, tailReservation);
16618
+ // 本轮已进 durable tail——之后 fork/回执失败可由重启或下一条 inbound 恢复,
16619
+ // 不得再诱导重发。
16620
+ markIngressAdmitted(ctx);
16040
16621
  }
16041
16622
  finally {
16042
16623
  settleAsyncQueuedActivationTailAdmission(ds);
@@ -16134,35 +16715,12 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
16134
16715
  // its normal `--resume` instead of being cold-spawned over.
16135
16716
  const hadPriorCliInput = !!(ds.lastCliInput ?? ds.session.lastCliInput);
16136
16717
  const openingTurn = wantsOpening && claimInitialUserTurn(ds);
16137
- const builtReforkInput = buildReforkCliInput(ds, reforkContent, {
16138
- attachments: queuedHasDurableTail ? undefined : attachments,
16139
- mentions: queuedHasDurableTail ? undefined : parsed.mentions,
16140
- cliId: ds.session.cliId ?? dsBotCfgForFork.cliId,
16141
- cliPathOverride: ds.session.cliPathOverride ?? dsBotCfgForFork.cliPathOverride,
16142
- selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
16143
- sender: queuedHasDurableTail ? undefined : reforkSender,
16144
- substituteTrigger: queuedHasDurableTail ? undefined : substituteTrigger,
16145
- codexAppText: reforkCodexApp.text,
16146
- codexAppApplicationContext: queuedHasDurableTail ? undefined : codexAppApplicationContext,
16147
- codexAppMessageContext: reforkCodexApp.messageContext,
16148
- });
16149
- let wrappedInput = applyQueuedCodexAppLegacyFallback(builtReforkInput, {
16150
- queued: queuedDashboardTurn,
16151
- queuedText: queuedCodexAppText,
16152
- });
16153
- if (wrappedInput !== builtReforkInput && dsBotCfgForFork.codexAppCleanInput === true) {
16154
- // Backlog sessions persisted before clean-input have no raw queued text.
16155
- // Keep this activation entirely legacy: reforkContent already contains
16156
- // queuedPrompt + the current reply, whereas a structured turn could only
16157
- // contain the reply and would silently discard the original task.
16158
- logger.warn(`[${tag(ds)}] Legacy queued dashboard task has no clean-input text; using the full legacy activation prompt`);
16159
- }
16718
+ // 先选 builder:opening 轮用 buildNewTopicCliInput,非 opening 才 buildRefork。
16719
+ // 不能无条件先跑 buildRefork——它现在有副作用(写 follow-up sidecar),opening
16720
+ // 结果会被 buildNewTopicCliInput 覆盖丢弃,但 sidecar 已写入 opening 的 turnId,
16721
+ // opening hook 会领到这份从没发出去的 speculative reminder → 双注入。
16722
+ let wrappedInput;
16160
16723
  if (openingTurn) {
16161
- // Replace the follow-up envelope built above with the real opening. The
16162
- // discarded build is pure string assembly (no side effects) — keeping the
16163
- // refork statement unconditional keeps the queued/substitute wiring, and
16164
- // its guard test, on a single code path. (openingTurn is mutually
16165
- // exclusive with queuedDashboardTurn/queuedHasDurableTail.)
16166
16724
  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, {
16167
16725
  larkAppId,
16168
16726
  chatId: ds.session.chatId,
@@ -16173,9 +16731,36 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
16173
16731
  codexAppMessageContext: reforkCodexApp.messageContext,
16174
16732
  });
16175
16733
  }
16734
+ else {
16735
+ const builtReforkInput = buildReforkCliInput(ds, reforkContent, {
16736
+ attachments: queuedHasDurableTail ? undefined : attachments,
16737
+ mentions: queuedHasDurableTail ? undefined : parsed.mentions,
16738
+ cliId: ds.session.cliId ?? dsBotCfgForFork.cliId,
16739
+ cliPathOverride: ds.session.cliPathOverride ?? dsBotCfgForFork.cliPathOverride,
16740
+ selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
16741
+ sender: queuedHasDurableTail ? undefined : reforkSender,
16742
+ substituteTrigger: queuedHasDurableTail ? undefined : substituteTrigger,
16743
+ codexAppText: reforkCodexApp.text,
16744
+ codexAppApplicationContext: queuedHasDurableTail ? undefined : codexAppApplicationContext,
16745
+ codexAppMessageContext: reforkCodexApp.messageContext,
16746
+ turnId: parsed.messageId,
16747
+ });
16748
+ wrappedInput = applyQueuedCodexAppLegacyFallback(builtReforkInput, {
16749
+ queued: queuedDashboardTurn,
16750
+ queuedText: queuedCodexAppText,
16751
+ });
16752
+ if (wrappedInput !== builtReforkInput && dsBotCfgForFork.codexAppCleanInput === true) {
16753
+ // Backlog sessions persisted before clean-input have no raw queued text.
16754
+ // Keep this activation entirely legacy: reforkContent already contains
16755
+ // queuedPrompt + the current reply, whereas a structured turn could only
16756
+ // contain the reply and would silently discard the original task.
16757
+ logger.warn(`[${tag(ds)}] Legacy queued dashboard task has no clean-input text; using the full legacy activation prompt`);
16758
+ }
16759
+ }
16176
16760
  if (!queuedHasDurableTail) {
16177
16761
  await noteTurnReceived(ds, parsed.messageId, parsed.content, reforkSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
16178
16762
  }
16763
+ let reforkAccepted = true;
16179
16764
  try {
16180
16765
  // Adopt sessions must re-fork via forkAdoptWorker, NOT forkWorker: the
16181
16766
  // latter would spawn a fresh botmux-managed bmx-* CLI in the adopt cwd,
@@ -16198,7 +16783,7 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
16198
16783
  if (codexAppSteerable && !queuedDashboardTurn && !queuedHasDurableTail) {
16199
16784
  wrappedInput.codexAppSteerable = true;
16200
16785
  }
16201
- forkWorker(ds, wrappedInput, {
16786
+ reforkAccepted = forkWorker(ds, wrappedInput, {
16202
16787
  // See `hadPriorCliInput` above — an opening on a CLI that never took any
16203
16788
  // input cold-spawns rather than `--resume`-ing an empty session.
16204
16789
  resume: ds.hasHistory && !(openingTurn && !hadPriorCliInput),
@@ -16213,6 +16798,11 @@ async function handleThreadReplyAdmitted(data, ctx, prepared) {
16213
16798
  releaseInitialUserTurn(ds);
16214
16799
  throw e;
16215
16800
  }
16801
+ // refork 成功才算本轮已交给 CLI。fork 抛错(rethrow)或返回 false(quarantine /
16802
+ // 会话已非 active 的不抛错拒绝腿)时,本轮只存在于内存、无 durable 兜底,
16803
+ // 必须保持「请重发」提示——与 live 分支按 accepted 打标对称。
16804
+ if (reforkAccepted)
16805
+ markIngressAdmitted(ctx);
16216
16806
  // Record the input as the session's last real CLI turn ONLY after the fork
16217
16807
  // succeeded. Recording before the fork (the old order) persisted lastCliInput
16218
16808
  // for a turn that never launched when forkWorker threw; the retry then read
@@ -16515,6 +17105,7 @@ async function handleDocCommentAdmitted(ctx) {
16515
17105
  botIdentity: { name: selfBot.botName, openId: selfBot.botOpenId },
16516
17106
  sender,
16517
17107
  mode: 'live',
17108
+ turnId,
16518
17109
  });
16519
17110
  beginNewTurn(ds, text, turnId);
16520
17111
  (ds.session.docCommentTargets ??= {})[turnId] = docTarget; // per-turn map,不覆盖其他并发轮
@@ -16560,6 +17151,7 @@ async function handleDocCommentAdmitted(ctx) {
16560
17151
  botIdentity: { name: selfBot.botName, openId: selfBot.botOpenId },
16561
17152
  sender,
16562
17153
  mode: 'refork',
17154
+ turnId,
16563
17155
  });
16564
17156
  (ds.session.docCommentTargets ??= {})[turnId] = docTarget; // per-turn map,不覆盖其他并发轮
16565
17157
  rememberLastCliInput(ds, promptContent, wrappedInput);
@@ -16970,6 +17562,28 @@ export async function startDaemon(botIndex) {
16970
17562
  }
16971
17563
  registerBot(cfg);
16972
17564
  selfDaemonLarkAppId = cfg.larkAppId;
17565
+ // The final-answer feedback subsystem is OPTIONAL: a bot with feedback
17566
+ // disabled still opens the shared feedback DB here for turn-completion
17567
+ // indexing, but a bootstrap failure (shared-dataDir lock storm, corruption,
17568
+ // disk full) must NOT abort daemon startup for that bot — that would let one
17569
+ // optional subsystem take down every PM2 bot sharing the dataDir. Fail soft:
17570
+ // log and continue; delivery-time paths degrade to best-effort on their own.
17571
+ let feedbackWebhookDispatcher;
17572
+ try {
17573
+ const feedbackWebhookStore = await getSkillFeedbackStore(config.session.dataDir);
17574
+ const feedbackWebhookSecrets = new FeedbackWebhookSecretStore(config.session.dataDir);
17575
+ feedbackWebhookDispatcher = startFeedbackWebhookDispatcher({
17576
+ store: feedbackWebhookStore,
17577
+ readSecret: ref => feedbackWebhookSecrets.get(ref),
17578
+ onError: error => logger.warn(`[feedback-webhook] ${error instanceof Error ? error.message : String(error)}`),
17579
+ });
17580
+ await feedbackWebhookDispatcher.ready;
17581
+ }
17582
+ catch (error) {
17583
+ logger.error(`[feedback] subsystem bootstrap failed; continuing without feedback indexing/webhooks: `
17584
+ + `${error instanceof Error ? error.message : String(error)}`);
17585
+ feedbackWebhookDispatcher = undefined;
17586
+ }
16973
17587
  // Establish the target-scoped daemon control credential before publishing
16974
17588
  // the daemon descriptor or accepting IPC traffic. Corruption fails startup
16975
17589
  // closed; silently rotating here could strand peers on mismatched tokens.
@@ -17033,6 +17647,7 @@ export async function startDaemon(botIndex) {
17033
17647
  }
17034
17648
  sessionStore.init(cfg.larkAppId);
17035
17649
  chatFirstSeenStore.init(cfg.larkAppId);
17650
+ initSessionGroups(cfg.larkAppId);
17036
17651
  const ambiguousOnBoot = reconcileVcMeetingDeliveriesOnBoot(config.session.dataDir, { receiverBootId: getDaemonBootId(), agentAppId: cfg.larkAppId });
17037
17652
  if (ambiguousOnBoot.length > 0) {
17038
17653
  logger.warn(`[vc-delivery] reconciled ${ambiguousOnBoot.length} stale dispatched receipt(s) as ambiguous`);
@@ -17304,12 +17919,32 @@ export async function startDaemon(botIndex) {
17304
17919
  },
17305
17920
  enforceLiveSessionCap: () => enforceLiveSessionCap('session_change'),
17306
17921
  onQueuedActivationSubmitted,
17307
- onTurnTerminal(ds, terminal, context) {
17922
+ async onTurnTerminal(ds, terminal, context) {
17923
+ // VC reconcile first: it is in-memory and latency-sensitive, and must not
17924
+ // sit behind a synchronous SQLite write. (Master did only this enqueue.)
17308
17925
  const enqueued = vcMeetingTerminalReconciler?.enqueue(terminal, context);
17309
17926
  if (terminal.dispatchAttempt !== undefined && enqueued?.accepted) {
17310
17927
  logger.info(`[vc-delivery] terminal queued ${terminal.status} turn=${terminal.turnId.slice(0, 12)} `
17311
17928
  + `session=${ds.session.sessionId.slice(0, 8)} attempt=${terminal.dispatchAttempt}`);
17312
17929
  }
17930
+ // Feedback turn-completion persistence is a synchronous node:sqlite write.
17931
+ // Route it through the nonblocking, tracked retry queue so a cross-process
17932
+ // write-lock (a `botmux send --response-kind final` subprocess) can never
17933
+ // stall the daemon event loop up to busy_timeout — the queue fails fast on
17934
+ // contention and retries on a timer that yields the loop. apiOnly bots
17935
+ // never produce a feedback delivery, so skip them outright; everyone else
17936
+ // enqueues (a hosted-team may have enabled feedback with no bot-level
17937
+ // config, so we must not gate on bot config alone).
17938
+ if (!getBot(ds.larkAppId).config.apiOnly) {
17939
+ void enqueueTurnTerminal({
17940
+ dataDir: config.session.dataDir,
17941
+ botAppId: ds.larkAppId,
17942
+ sessionId: ds.session.sessionId,
17943
+ terminal: { turnId: terminal.turnId, dispatchAttempt: terminal.dispatchAttempt, status: terminal.status },
17944
+ onError: error => logger.error(`[turn-completion] persistence failed turn=${terminal.turnId.slice(0, 12)}: `
17945
+ + `${error instanceof Error ? error.message : String(error)}`),
17946
+ });
17947
+ }
17313
17948
  },
17314
17949
  onDeferredScheduleTurnSettled(ds, context) {
17315
17950
  scheduleDeferredScheduleSettlement(ds, context);
@@ -17420,6 +18055,11 @@ export async function startDaemon(botIndex) {
17420
18055
  // host-only shared secret on every daemon IPC route except the tiny
17421
18056
  // capability-gated receiver/readiness apertures in dashboard-ipc-server.
17422
18057
  loadOrCreateDashboardSecret(join(homedir(), '.botmux', '.dashboard-secret'));
18058
+ // Create the dispatch-binding key before any credential-only child spawns.
18059
+ // Linux bwrap snapshots `.dashboard-secret.*` authority leaves at launch;
18060
+ // creating this later would leave the new key visible through its writable
18061
+ // host-root bind until the pane cold-started.
18062
+ loadOrCreateDashboardSecret(dispatchReportBindingSecretPath(config.session.dataDir));
17423
18063
  let markIpcReady;
17424
18064
  const ipcReady = new Promise((resolve) => { markIpcReady = resolve; });
17425
18065
  const coreOnly = process.env.BOTMUX_CORE_ONLY === '1';
@@ -18029,7 +18669,20 @@ export async function startDaemon(botIndex) {
18029
18669
  catch (err) {
18030
18670
  logger.warn(`[overload] count failed, showing 0: ${err instanceof Error ? err.message : String(err)}`);
18031
18671
  }
18032
- cardJson = buildOverloadAlertCard(initialOverloadCardState(action, counts, nonce));
18672
+ // Probe which configured browsers are running + holding memory so the
18673
+ // card can offer a per-browser「♻️ 重启」button (the real memory hog on
18674
+ // a dev Mac is usually a browser, not botmux sessions). Best-effort:
18675
+ // any probe failure just yields no browser buttons.
18676
+ let browsers = [];
18677
+ try {
18678
+ const targets = resolveBrowserTargets(alertCfg.browserRestartTargets);
18679
+ const running = await detectRunningBrowsers(targets);
18680
+ browsers = running.map(b => ({ bundleId: b.bundleId, label: b.label, memMB: b.memMB }));
18681
+ }
18682
+ catch (err) {
18683
+ logger.warn(`[overload] browser probe failed, no restart buttons: ${err instanceof Error ? err.message : String(err)}`);
18684
+ }
18685
+ cardJson = buildOverloadAlertCard(initialOverloadCardState(action, counts, nonce, browsers));
18033
18686
  }
18034
18687
  else {
18035
18688
  cardJson = buildOverloadRecoveredCard(action);
@@ -18164,6 +18817,13 @@ export async function startDaemon(botIndex) {
18164
18817
  riffRetiredWorkers.push(result.worker);
18165
18818
  }
18166
18819
  scheduler.stopScheduler();
18820
+ // NOTE: turn-terminal queue drain + webhook dispatcher stop are deliberately
18821
+ // deferred until AFTER workers have stopped producing (see below). Draining
18822
+ // here would close queue admission while worker terminal IPC handlers are
18823
+ // still alive, so a turn completing during shutdown would have its terminal
18824
+ // refused and never persisted (delivery cannot be reconstructed to
18825
+ // completed/failed/cancelled after the fact). Keep the queue and dispatcher
18826
+ // live through worker teardown.
18167
18827
  stopMaintenance();
18168
18828
  vcMeetingTerminalReconciler?.stop();
18169
18829
  clearInterval(vcMeetingDeliveryLeaseTimer);
@@ -18200,32 +18860,65 @@ export async function startDaemon(botIndex) {
18200
18860
  ipcHandle.close().catch(() => { });
18201
18861
  if (terminalProxy)
18202
18862
  terminalProxy.close().catch(() => { });
18203
- const pendingExits = [];
18204
- const survivors = [];
18205
- const trackWorkerExit = (w) => {
18206
- if (w.exitCode !== null || w.signalCode !== null)
18207
- return;
18208
- pendingExits.push(new Promise(resolve => {
18209
- w.once('exit', () => resolve());
18210
- // Close the check/listener race if exit landed between the first
18211
- // status read and once().
18212
- if (w.exitCode !== null || w.signalCode !== null)
18213
- resolve();
18214
- }));
18215
- survivors.push(w);
18863
+ // Producer-closed fence. We must not close the turn-terminal queue admission
18864
+ // until every worker's IPC channel is truly closed — a worker whose 'exit'
18865
+ // Producer-quiescence fence. A worker whose 'exit' fired can still have
18866
+ // queued 'message' terminals in flight; the IPC `disconnect` event is the
18867
+ // real "no more terminals from this producer" signal (see producer-quiescence
18868
+ // helper it deliberately keys on disconnect, not 'close', which stdio
18869
+ // inherited by grandchildren can delay). Dedup by ChildProcess.
18870
+ // Two INDEPENDENT shutdown concerns, tracked over the same worker set but
18871
+ // never conflated (conflating them regresses process reaping):
18872
+ // • reaping (workersToReap / workerExited): every live worker PROCESS must
18873
+ // exit or be SIGKILLed — orphan prevention. Keys on process death, NOT
18874
+ // on `connected`.
18875
+ // • producer fence (producerClosed): every worker's IPC channel must
18876
+ // disconnect — the "no more terminal messages" signal that gates closing
18877
+ // the feedback queue admission. Keys on `disconnect`, NOT on exit.
18878
+ const producerClosed = [];
18879
+ const workerExited = [];
18880
+ const reapCandidates = [];
18881
+ const trackedProducers = new Set();
18882
+ const trackedReap = new Set();
18883
+ const trackWorker = (w) => {
18884
+ // Producer fence: disconnect-based, gates feedback admission only.
18885
+ if (!trackedProducers.has(w)) {
18886
+ trackedProducers.add(w);
18887
+ const q = trackProducerQuiet(w);
18888
+ if (!q.alreadyQuiet && q.done)
18889
+ producerClosed.push(q.done);
18890
+ }
18891
+ // Reaping: exit-based, orphan prevention. Registered BEFORE any signal so
18892
+ // a fast exit is not missed; dedup by ChildProcess.
18893
+ if (!trackedReap.has(w)) {
18894
+ trackedReap.add(w);
18895
+ const e = trackProcessExited(w);
18896
+ if (!e.alreadyExited && e.done) {
18897
+ workerExited.push(e.done);
18898
+ reapCandidates.push(w);
18899
+ }
18900
+ }
18216
18901
  };
18217
18902
  for (const worker of riffRetiredWorkers)
18218
- trackWorkerExit(worker);
18903
+ trackWorker(worker);
18219
18904
  for (const ds of currentShutdownFleet.sessions) {
18220
- if (ds.worker && !ds.worker.killed) {
18905
+ const w = ds.worker;
18906
+ if (!w)
18907
+ continue;
18908
+ // Track EVERY non-null worker (both concerns) unconditionally. `killed`
18909
+ // only means a signal was already sent — NOT that the process exited or
18910
+ // the IPC channel disconnected (a worker can be `killed=true &&
18911
+ // connected=true` right after .kill(), e.g. the promoted-activation
18912
+ // IPC-failure path that kills but defers ds.worker cleanup). `killed` only
18913
+ // decides whether we still need to SEND a stop signal; it never decides
18914
+ // whether the worker is tracked for reaping or the producer fence.
18915
+ trackWorker(w);
18916
+ if (!w.killed) {
18221
18917
  logger.info(`Shutting down worker for session ${ds.session.sessionId}`);
18222
- const w = ds.worker;
18223
18918
  const disposition = shutdownBackendDisposition(ds);
18224
18919
  if (disposition === 'riff-drain-detach') {
18225
18920
  throw new Error(`undrained Riff generation after atomic commit: ${ds.session.sessionId}`);
18226
18921
  }
18227
- // Capture the exit promise BEFORE killWorker nulls ds.worker.
18228
- trackWorkerExit(w);
18229
18922
  // Branch by the session's FROZEN backend (stamped on Session.backendType
18230
18923
  // at spawn), NOT the bot's live config — a dashboard backendType edit must
18231
18924
  // not change how a running session is torn down, or we'd e.g. try to
@@ -18253,12 +18946,34 @@ export async function startDaemon(botIndex) {
18253
18946
  }
18254
18947
  }
18255
18948
  }
18256
- if (pendingExits.length > 0) {
18257
- const exitGraceMs = Math.min(DAEMON_WORKER_EXIT_GRACE_MS, Math.max(0, shutdownDeadlineMs - Date.now()));
18258
- const timeout = new Promise(resolve => setTimeout(resolve, exitGraceMs));
18259
- await Promise.race([Promise.all(pendingExits), timeout]);
18949
+ // --- Staged producer quiescence before touching the feedback queue -------
18950
+ // Contract (fail-closed): the turn-terminal queue admission may be closed
18951
+ // (inside drainTurnTerminalQueue) ONLY after BOTH fences confirm quiescent
18952
+ // within the shared absolute deadline:
18953
+ // (1) every worker IPC channel disconnected — no NEW terminal message can
18954
+ // be delivered; and
18955
+ // (2) every in-flight Codex App final-settlement resolved — an already
18956
+ // delivered final_output whose handler is awaiting network delivery
18957
+ // has finished its cb.onTurnTerminal (which synchronously enqueues).
18958
+ // If either fence is not quiescent by the deadline we DO NOT close admission
18959
+ // (closing it would refuse a terminal a still-live producer may yet emit —
18960
+ // strictly worse than the pre-feature behaviour). We keep admission open,
18961
+ // warn loudly, and let the process exit on its budget.
18962
+ // Every wait shares the single absolute `shutdownDeadlineMs`; a ref'd
18963
+ // keepalive (waitAllWithin) holds the loop so a fire-and-forget SIGTERM
18964
+ // handler cannot exit mid-wait once workers/services have stopped.
18965
+ const remainingBudget = () => Math.max(0, shutdownDeadlineMs - Date.now());
18966
+ // --- Phase 1: process reaping (orphan prevention) ------------------------
18967
+ // Give every live worker PROCESS a normal grace to exit, then SIGKILL any
18968
+ // still alive, then confirm exit within the shared deadline. This is keyed
18969
+ // on process death (exitCode/signalCode), independent of IPC state, so a
18970
+ // worker that ignores SIGTERM — or that already disconnected its IPC but is
18971
+ // still running — is still reaped and cannot become a ppid=1 orphan.
18972
+ if (workerExited.length > 0) {
18973
+ const exitGraceDeadline = Math.min(shutdownDeadlineMs, Date.now() + DAEMON_WORKER_EXIT_GRACE_MS);
18974
+ await waitAllWithin(workerExited, exitGraceDeadline);
18260
18975
  let stragglers = 0;
18261
- for (const w of survivors) {
18976
+ for (const w of reapCandidates) {
18262
18977
  if (w.exitCode === null && w.signalCode === null) {
18263
18978
  stragglers++;
18264
18979
  try {
@@ -18268,9 +18983,51 @@ export async function startDaemon(botIndex) {
18268
18983
  }
18269
18984
  }
18270
18985
  if (stragglers > 0) {
18271
- logger.warn(`${stragglers}/${survivors.length} worker(s) didn't exit within ${exitGraceMs}ms — SIGKILL'd to prevent ppid=1 orphans.`);
18986
+ logger.warn(`${stragglers}/${reapCandidates.length} worker(s) didn't exit within grace — SIGKILL'd to prevent ppid=1 orphans.`);
18987
+ // Confirm the SIGKILLed processes actually exit, still within the deadline.
18988
+ await waitAllWithin(workerExited, shutdownDeadlineMs);
18989
+ }
18990
+ }
18991
+ // --- Phase 2: producer disconnect fence (gates feedback admission) -------
18992
+ // Separate from reaping: this only decides whether it is safe to close the
18993
+ // feedback queue admission. A late (but in-deadline) disconnect still counts
18994
+ // as quiescent — we do not force it via SIGKILL here (Phase 1 already
18995
+ // handled process liveness). By this point most workers have exited (which
18996
+ // also disconnects their IPC), so producerClosed is typically already
18997
+ // settled; we still await it explicitly under the shared deadline.
18998
+ const disconnectQuiesced = await waitAllWithin(producerClosed, shutdownDeadlineMs);
18999
+ // Settlement fence: only meaningful once IPC is confirmed disconnected (no
19000
+ // new settlement can be created). Await the snapshot, then re-read the count
19001
+ // — 0 confirms every in-flight settlement (and its enqueue) has completed.
19002
+ let settlementQuiesced = false;
19003
+ if (disconnectQuiesced) {
19004
+ await waitAllWithin(snapshotCodexAppFinalSettlements(), shutdownDeadlineMs);
19005
+ settlementQuiesced = codexAppFinalSettlementCount() === 0;
19006
+ }
19007
+ if (disconnectQuiesced && settlementQuiesced) {
19008
+ // Both fences quiescent: safe to close admission and drain the last
19009
+ // terminals into the durable outbox, bounded by the remaining budget.
19010
+ const drainBudget = remainingBudget();
19011
+ if (drainBudget > 0) {
19012
+ const undrained = await drainTurnTerminalQueue(drainBudget);
19013
+ if (undrained > 0)
19014
+ logger.warn(`[turn-completion] shutdown drain timed out with ${undrained} pending`);
19015
+ }
19016
+ else {
19017
+ logger.error('[turn-completion] shutdown deadline exhausted before drain — in-memory queued turn terminals were NOT persisted and are LOST (they are not yet in the durable store)');
18272
19018
  }
18273
19019
  }
19020
+ else {
19021
+ // Fail-closed: a producer may still emit a terminal. Do NOT close
19022
+ // admission (that would refuse and lose it). Hold open, warn, and let the
19023
+ // process exit on its budget; unfinished work stays in the durable store.
19024
+ logger.error(`[turn-completion] shutdown producer quiescence NOT confirmed `
19025
+ + `(ipcDisconnected=${disconnectQuiesced} settlementsSettled=${settlementQuiesced}); `
19026
+ + `leaving feedback queue admission OPEN to avoid refusing a late terminal`);
19027
+ }
19028
+ // Dispatcher stop always receives the hard-clamped remaining budget (never
19029
+ // its internal 5s default), success or failure path alike.
19030
+ await feedbackWebhookDispatcher?.stop(remainingBudget());
18274
19031
  // Flush any pending identity-cache writes before exit. The cache uses a
18275
19032
  // 2s debounce on disk persistence to dedupe writes from chatty groups; on
18276
19033
  // SIGTERM we want anything learned since the last flush to land.