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