botmux 3.7.1 → 3.8.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 (622) hide show
  1. package/dist/.runtime-build-id +1 -1
  2. package/dist/adapters/backend/capabilities.d.ts +17 -0
  3. package/dist/adapters/backend/capabilities.d.ts.map +1 -0
  4. package/dist/adapters/backend/capabilities.js +22 -0
  5. package/dist/adapters/backend/capabilities.js.map +1 -0
  6. package/dist/adapters/backend/critical-control-key.d.ts +19 -0
  7. package/dist/adapters/backend/critical-control-key.d.ts.map +1 -0
  8. package/dist/adapters/backend/critical-control-key.js +34 -0
  9. package/dist/adapters/backend/critical-control-key.js.map +1 -0
  10. package/dist/adapters/backend/herdr-backend.d.ts +9 -0
  11. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  12. package/dist/adapters/backend/herdr-backend.js +23 -4
  13. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  14. package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
  15. package/dist/adapters/backend/riff-backend.js +3 -2
  16. package/dist/adapters/backend/riff-backend.js.map +1 -1
  17. package/dist/adapters/backend/session-backend-selector.d.ts +61 -6
  18. package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -1
  19. package/dist/adapters/backend/session-backend-selector.js +173 -9
  20. package/dist/adapters/backend/session-backend-selector.js.map +1 -1
  21. package/dist/adapters/backend/tmux-backend.d.ts +10 -3
  22. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  23. package/dist/adapters/backend/tmux-backend.js +25 -8
  24. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  25. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  26. package/dist/adapters/backend/tmux-pipe-backend.js +3 -2
  27. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  28. package/dist/adapters/backend/types.d.ts +45 -4
  29. package/dist/adapters/backend/types.d.ts.map +1 -1
  30. package/dist/adapters/backend/types.js +11 -0
  31. package/dist/adapters/backend/types.js.map +1 -1
  32. package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
  33. package/dist/adapters/backend/zellij-backend.js +3 -2
  34. package/dist/adapters/backend/zellij-backend.js.map +1 -1
  35. package/dist/adapters/backend/zmx-backend.d.ts +286 -0
  36. package/dist/adapters/backend/zmx-backend.d.ts.map +1 -0
  37. package/dist/adapters/backend/zmx-backend.js +2396 -0
  38. package/dist/adapters/backend/zmx-backend.js.map +1 -0
  39. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  40. package/dist/adapters/cli/claude-code.js +3 -0
  41. package/dist/adapters/cli/claude-code.js.map +1 -1
  42. package/dist/adapters/cli/codex-app.d.ts.map +1 -1
  43. package/dist/adapters/cli/codex-app.js +5 -1
  44. package/dist/adapters/cli/codex-app.js.map +1 -1
  45. package/dist/adapters/cli/codex.d.ts.map +1 -1
  46. package/dist/adapters/cli/codex.js +26 -1
  47. package/dist/adapters/cli/codex.js.map +1 -1
  48. package/dist/adapters/cli/fs-policy.d.ts +79 -1
  49. package/dist/adapters/cli/fs-policy.d.ts.map +1 -1
  50. package/dist/adapters/cli/fs-policy.js +290 -13
  51. package/dist/adapters/cli/fs-policy.js.map +1 -1
  52. package/dist/adapters/cli/grok.d.ts.map +1 -1
  53. package/dist/adapters/cli/grok.js +4 -1
  54. package/dist/adapters/cli/grok.js.map +1 -1
  55. package/dist/adapters/cli/oh-my-pi.d.ts.map +1 -1
  56. package/dist/adapters/cli/oh-my-pi.js +5 -3
  57. package/dist/adapters/cli/oh-my-pi.js.map +1 -1
  58. package/dist/adapters/cli/shared-hints.d.ts +7 -4
  59. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  60. package/dist/adapters/cli/shared-hints.js +52 -35
  61. package/dist/adapters/cli/shared-hints.js.map +1 -1
  62. package/dist/adapters/cli/strict-input-handle.d.ts +13 -0
  63. package/dist/adapters/cli/strict-input-handle.d.ts.map +1 -0
  64. package/dist/adapters/cli/strict-input-handle.js +39 -0
  65. package/dist/adapters/cli/strict-input-handle.js.map +1 -0
  66. package/dist/adapters/cli/traex.d.ts.map +1 -1
  67. package/dist/adapters/cli/traex.js +6 -4
  68. package/dist/adapters/cli/traex.js.map +1 -1
  69. package/dist/adapters/cli/types.d.ts +16 -0
  70. package/dist/adapters/cli/types.d.ts.map +1 -1
  71. package/dist/bot-registry.d.ts +137 -8
  72. package/dist/bot-registry.d.ts.map +1 -1
  73. package/dist/bot-registry.js +298 -12
  74. package/dist/bot-registry.js.map +1 -1
  75. package/dist/cli/session-list-liveness.d.ts +1 -1
  76. package/dist/cli/session-list-liveness.js +1 -1
  77. package/dist/cli/zmx-managed-attach.d.ts +41 -0
  78. package/dist/cli/zmx-managed-attach.d.ts.map +1 -0
  79. package/dist/cli/zmx-managed-attach.js +72 -0
  80. package/dist/cli/zmx-managed-attach.js.map +1 -0
  81. package/dist/cli.d.ts.map +1 -1
  82. package/dist/cli.js +780 -131
  83. package/dist/cli.js.map +1 -1
  84. package/dist/codex-app-runner.js +90 -2
  85. package/dist/codex-app-runner.js.map +1 -1
  86. package/dist/codex-rpc-engine.d.ts.map +1 -1
  87. package/dist/codex-rpc-engine.js +22 -6
  88. package/dist/codex-rpc-engine.js.map +1 -1
  89. package/dist/config.d.ts +5 -0
  90. package/dist/config.d.ts.map +1 -1
  91. package/dist/config.js +29 -1
  92. package/dist/config.js.map +1 -1
  93. package/dist/core/ask-broker.d.ts +22 -1
  94. package/dist/core/ask-broker.d.ts.map +1 -1
  95. package/dist/core/ask-broker.js +12 -5
  96. package/dist/core/ask-broker.js.map +1 -1
  97. package/dist/core/botmux-wrapper.d.ts +18 -0
  98. package/dist/core/botmux-wrapper.d.ts.map +1 -1
  99. package/dist/core/botmux-wrapper.js +25 -1
  100. package/dist/core/botmux-wrapper.js.map +1 -1
  101. package/dist/core/chat-rename.d.ts +27 -0
  102. package/dist/core/chat-rename.d.ts.map +1 -0
  103. package/dist/core/chat-rename.js +53 -0
  104. package/dist/core/chat-rename.js.map +1 -0
  105. package/dist/core/command-handler.d.ts.map +1 -1
  106. package/dist/core/command-handler.js +160 -12
  107. package/dist/core/command-handler.js.map +1 -1
  108. package/dist/core/cost-calculator.d.ts +25 -1
  109. package/dist/core/cost-calculator.d.ts.map +1 -1
  110. package/dist/core/cost-calculator.js +102 -4
  111. package/dist/core/cost-calculator.js.map +1 -1
  112. package/dist/core/dashboard-ipc-server.d.ts +16 -2
  113. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  114. package/dist/core/dashboard-ipc-server.js +611 -62
  115. package/dist/core/dashboard-ipc-server.js.map +1 -1
  116. package/dist/core/dashboard-rows.d.ts +22 -1
  117. package/dist/core/dashboard-rows.d.ts.map +1 -1
  118. package/dist/core/dashboard-rows.js +61 -0
  119. package/dist/core/dashboard-rows.js.map +1 -1
  120. package/dist/core/dashboard-url.d.ts +6 -0
  121. package/dist/core/dashboard-url.d.ts.map +1 -1
  122. package/dist/core/dashboard-url.js +11 -1
  123. package/dist/core/dashboard-url.js.map +1 -1
  124. package/dist/core/device-isolation-daemon.d.ts +28 -6
  125. package/dist/core/device-isolation-daemon.d.ts.map +1 -1
  126. package/dist/core/device-isolation-daemon.js +96 -13
  127. package/dist/core/device-isolation-daemon.js.map +1 -1
  128. package/dist/core/host-overload-alert.d.ts +75 -0
  129. package/dist/core/host-overload-alert.d.ts.map +1 -1
  130. package/dist/core/host-overload-alert.js +103 -0
  131. package/dist/core/host-overload-alert.js.map +1 -1
  132. package/dist/core/idle-worker-sweeper.js +1 -1
  133. package/dist/core/idle-worker-sweeper.js.map +1 -1
  134. package/dist/core/inflight-input-tracker.d.ts +3 -0
  135. package/dist/core/inflight-input-tracker.d.ts.map +1 -1
  136. package/dist/core/inflight-input-tracker.js +9 -0
  137. package/dist/core/inflight-input-tracker.js.map +1 -1
  138. package/dist/core/passthrough-commands.d.ts.map +1 -1
  139. package/dist/core/passthrough-commands.js +10 -0
  140. package/dist/core/passthrough-commands.js.map +1 -1
  141. package/dist/core/persistent-backend.d.ts +32 -4
  142. package/dist/core/persistent-backend.d.ts.map +1 -1
  143. package/dist/core/persistent-backend.js +75 -6
  144. package/dist/core/persistent-backend.js.map +1 -1
  145. package/dist/core/plugins/mcp/gateway.js +2 -2
  146. package/dist/core/plugins/mcp/gateway.js.map +1 -1
  147. package/dist/core/plugins/runtime.d.ts +3 -0
  148. package/dist/core/plugins/runtime.d.ts.map +1 -1
  149. package/dist/core/plugins/runtime.js.map +1 -1
  150. package/dist/core/plugins/service-manager.d.ts +4 -1
  151. package/dist/core/plugins/service-manager.d.ts.map +1 -1
  152. package/dist/core/plugins/service-manager.js +11 -6
  153. package/dist/core/plugins/service-manager.js.map +1 -1
  154. package/dist/core/role-library.d.ts +48 -1
  155. package/dist/core/role-library.d.ts.map +1 -1
  156. package/dist/core/role-library.js +111 -3
  157. package/dist/core/role-library.js.map +1 -1
  158. package/dist/core/role-resolver.d.ts +14 -2
  159. package/dist/core/role-resolver.d.ts.map +1 -1
  160. package/dist/core/role-resolver.js +47 -4
  161. package/dist/core/role-resolver.js.map +1 -1
  162. package/dist/core/session-activity.d.ts +14 -0
  163. package/dist/core/session-activity.d.ts.map +1 -1
  164. package/dist/core/session-activity.js +46 -1
  165. package/dist/core/session-activity.js.map +1 -1
  166. package/dist/core/session-board.d.ts +5 -2
  167. package/dist/core/session-board.d.ts.map +1 -1
  168. package/dist/core/session-board.js +5 -2
  169. package/dist/core/session-board.js.map +1 -1
  170. package/dist/core/session-liveness.d.ts +6 -5
  171. package/dist/core/session-liveness.d.ts.map +1 -1
  172. package/dist/core/session-liveness.js +34 -13
  173. package/dist/core/session-liveness.js.map +1 -1
  174. package/dist/core/session-manager.d.ts +3 -2
  175. package/dist/core/session-manager.d.ts.map +1 -1
  176. package/dist/core/session-manager.js +397 -103
  177. package/dist/core/session-manager.js.map +1 -1
  178. package/dist/core/session-marker.d.ts +11 -2
  179. package/dist/core/session-marker.d.ts.map +1 -1
  180. package/dist/core/session-marker.js +50 -11
  181. package/dist/core/session-marker.js.map +1 -1
  182. package/dist/core/session-message-preview.d.ts +20 -0
  183. package/dist/core/session-message-preview.d.ts.map +1 -0
  184. package/dist/core/session-message-preview.js +188 -0
  185. package/dist/core/session-message-preview.js.map +1 -0
  186. package/dist/core/session-title.d.ts +5 -1
  187. package/dist/core/session-title.d.ts.map +1 -1
  188. package/dist/core/session-title.js +20 -3
  189. package/dist/core/session-title.js.map +1 -1
  190. package/dist/core/startup-commands.d.ts +1 -1
  191. package/dist/core/startup-commands.js +1 -1
  192. package/dist/core/terminal-url.d.ts.map +1 -1
  193. package/dist/core/terminal-url.js +3 -2
  194. package/dist/core/terminal-url.js.map +1 -1
  195. package/dist/core/trigger-session.d.ts.map +1 -1
  196. package/dist/core/trigger-session.js +75 -12
  197. package/dist/core/trigger-session.js.map +1 -1
  198. package/dist/core/types.d.ts +35 -0
  199. package/dist/core/types.d.ts.map +1 -1
  200. package/dist/core/types.js +23 -0
  201. package/dist/core/types.js.map +1 -1
  202. package/dist/core/worker-ipc.d.ts +11 -0
  203. package/dist/core/worker-ipc.d.ts.map +1 -0
  204. package/dist/core/worker-ipc.js +26 -0
  205. package/dist/core/worker-ipc.js.map +1 -0
  206. package/dist/core/worker-pool.d.ts +179 -29
  207. package/dist/core/worker-pool.d.ts.map +1 -1
  208. package/dist/core/worker-pool.js +2118 -331
  209. package/dist/core/worker-pool.js.map +1 -1
  210. package/dist/daemon.d.ts +132 -3
  211. package/dist/daemon.d.ts.map +1 -1
  212. package/dist/daemon.js +1327 -449
  213. package/dist/daemon.js.map +1 -1
  214. package/dist/dashboard/auth.d.ts +2 -0
  215. package/dist/dashboard/auth.d.ts.map +1 -1
  216. package/dist/dashboard/auth.js +9 -0
  217. package/dist/dashboard/auth.js.map +1 -1
  218. package/dist/dashboard/bot-payload.d.ts +4 -0
  219. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  220. package/dist/dashboard/bot-payload.js +5 -0
  221. package/dist/dashboard/bot-payload.js.map +1 -1
  222. package/dist/dashboard/federation-api.d.ts.map +1 -1
  223. package/dist/dashboard/federation-api.js +4 -0
  224. package/dist/dashboard/federation-api.js.map +1 -1
  225. package/dist/dashboard/federation-spoke-api.d.ts.map +1 -1
  226. package/dist/dashboard/federation-spoke-api.js +32 -5
  227. package/dist/dashboard/federation-spoke-api.js.map +1 -1
  228. package/dist/dashboard/public-redact.d.ts +8 -2
  229. package/dist/dashboard/public-redact.d.ts.map +1 -1
  230. package/dist/dashboard/public-redact.js +34 -7
  231. package/dist/dashboard/public-redact.js.map +1 -1
  232. package/dist/dashboard/session-card-model.d.ts.map +1 -1
  233. package/dist/dashboard/session-card-model.js +10 -2
  234. package/dist/dashboard/session-card-model.js.map +1 -1
  235. package/dist/dashboard/settings-write-applier.d.ts +35 -1
  236. package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
  237. package/dist/dashboard/settings-write-applier.js +88 -1
  238. package/dist/dashboard/settings-write-applier.js.map +1 -1
  239. package/dist/dashboard/web/app.d.ts.map +1 -1
  240. package/dist/dashboard/web/app.js +0 -1
  241. package/dist/dashboard/web/app.js.map +1 -1
  242. package/dist/dashboard/web/bot-defaults-page.d.ts +15 -0
  243. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  244. package/dist/dashboard/web/bot-defaults-page.js +212 -58
  245. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  246. package/dist/dashboard/web/bot-defaults.d.ts +3 -0
  247. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  248. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  249. package/dist/dashboard/web/bot-onboarding.d.ts +2 -1
  250. package/dist/dashboard/web/bot-onboarding.d.ts.map +1 -1
  251. package/dist/dashboard/web/bot-onboarding.js.map +1 -1
  252. package/dist/dashboard/web/clipboard.d.ts +2 -0
  253. package/dist/dashboard/web/clipboard.d.ts.map +1 -0
  254. package/dist/dashboard/web/clipboard.js +42 -0
  255. package/dist/dashboard/web/clipboard.js.map +1 -0
  256. package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
  257. package/dist/dashboard/web/connectors-page.js +12 -8
  258. package/dist/dashboard/web/connectors-page.js.map +1 -1
  259. package/dist/dashboard/web/dashboard-components.d.ts +17 -16
  260. package/dist/dashboard/web/dashboard-components.d.ts.map +1 -1
  261. package/dist/dashboard/web/dashboard-components.js.map +1 -1
  262. package/dist/dashboard/web/groups-page.d.ts.map +1 -1
  263. package/dist/dashboard/web/groups-page.js +7 -3
  264. package/dist/dashboard/web/groups-page.js.map +1 -1
  265. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  266. package/dist/dashboard/web/i18n.js +205 -12
  267. package/dist/dashboard/web/i18n.js.map +1 -1
  268. package/dist/dashboard/web/icon-tooltip.d.ts.map +1 -1
  269. package/dist/dashboard/web/icon-tooltip.js +16 -1
  270. package/dist/dashboard/web/icon-tooltip.js.map +1 -1
  271. package/dist/dashboard/web/listener-filters.d.ts +23 -0
  272. package/dist/dashboard/web/listener-filters.d.ts.map +1 -0
  273. package/dist/dashboard/web/listener-filters.js +36 -0
  274. package/dist/dashboard/web/listener-filters.js.map +1 -0
  275. package/dist/dashboard/web/overview-page.d.ts.map +1 -1
  276. package/dist/dashboard/web/overview-page.js.map +1 -1
  277. package/dist/dashboard/web/plugin-page.d.ts.map +1 -1
  278. package/dist/dashboard/web/plugin-page.js.map +1 -1
  279. package/dist/dashboard/web/preview-markdown.d.ts +5 -0
  280. package/dist/dashboard/web/preview-markdown.d.ts.map +1 -0
  281. package/dist/dashboard/web/preview-markdown.js +52 -0
  282. package/dist/dashboard/web/preview-markdown.js.map +1 -0
  283. package/dist/dashboard/web/roles-page.d.ts.map +1 -1
  284. package/dist/dashboard/web/roles-page.js +535 -10
  285. package/dist/dashboard/web/roles-page.js.map +1 -1
  286. package/dist/dashboard/web/roles.d.ts +77 -0
  287. package/dist/dashboard/web/roles.d.ts.map +1 -1
  288. package/dist/dashboard/web/roles.js +98 -0
  289. package/dist/dashboard/web/roles.js.map +1 -1
  290. package/dist/dashboard/web/session-terminal.d.ts.map +1 -1
  291. package/dist/dashboard/web/session-terminal.js +6 -4
  292. package/dist/dashboard/web/session-terminal.js.map +1 -1
  293. package/dist/dashboard/web/sessions-kanban.d.ts +4 -1
  294. package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
  295. package/dist/dashboard/web/sessions-kanban.js +1 -1
  296. package/dist/dashboard/web/sessions-kanban.js.map +1 -1
  297. package/dist/dashboard/web/sessions-page.d.ts +3 -2
  298. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  299. package/dist/dashboard/web/sessions-page.js +163 -28
  300. package/dist/dashboard/web/sessions-page.js.map +1 -1
  301. package/dist/dashboard/web/sessions.d.ts +25 -0
  302. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  303. package/dist/dashboard/web/sessions.js +58 -7
  304. package/dist/dashboard/web/sessions.js.map +1 -1
  305. package/dist/dashboard/web/settings-page.d.ts +28 -3
  306. package/dist/dashboard/web/settings-page.d.ts.map +1 -1
  307. package/dist/dashboard/web/settings-page.js +61 -1
  308. package/dist/dashboard/web/settings-page.js.map +1 -1
  309. package/dist/dashboard/web/skills-page.d.ts +6 -5
  310. package/dist/dashboard/web/skills-page.d.ts.map +1 -1
  311. package/dist/dashboard/web/skills-page.js +2 -0
  312. package/dist/dashboard/web/skills-page.js.map +1 -1
  313. package/dist/dashboard/web/v3-components.d.ts +3 -2
  314. package/dist/dashboard/web/v3-components.d.ts.map +1 -1
  315. package/dist/dashboard/web/v3-components.js.map +1 -1
  316. package/dist/dashboard/web/vc-consumer-profiles-section.d.ts +12 -2
  317. package/dist/dashboard/web/vc-consumer-profiles-section.d.ts.map +1 -1
  318. package/dist/dashboard/web/vc-consumer-profiles-section.js +0 -9
  319. package/dist/dashboard/web/vc-consumer-profiles-section.js.map +1 -1
  320. package/dist/dashboard/web/webhook-logs-page.d.ts +2 -1
  321. package/dist/dashboard/web/webhook-logs-page.d.ts.map +1 -1
  322. package/dist/dashboard/web/webhook-logs-page.js +4 -1
  323. package/dist/dashboard/web/webhook-logs-page.js.map +1 -1
  324. package/dist/dashboard-web/app.js +1 -1
  325. package/dist/dashboard-web/chunks/bot-defaults-page-FAPIMQV3.js +10 -0
  326. package/dist/dashboard-web/chunks/{chunk-QFMH6ITB.js → chunk-5CJMHTT4.js} +1 -1
  327. package/dist/dashboard-web/chunks/{chunk-KQB42ML5.js → chunk-DLZK52JU.js} +14 -12
  328. package/dist/dashboard-web/chunks/chunk-FDZ7WOBW.js +16 -0
  329. package/dist/dashboard-web/chunks/{chunk-3QAUJXAS.js → chunk-GTAJV5YC.js} +1 -1
  330. package/dist/dashboard-web/chunks/{chunk-FNPGOZMX.js → chunk-IHNG6KE6.js} +1 -1
  331. package/dist/dashboard-web/chunks/{chunk-FLGXZJTD.js → chunk-VA6QWSGQ.js} +1 -1
  332. package/dist/dashboard-web/chunks/chunk-XJ7BGPJH.js +1 -0
  333. package/dist/dashboard-web/chunks/chunk-ZJVTGU5X.js +1 -0
  334. package/dist/dashboard-web/chunks/connectors-page-LKWRT3GS.js +1 -0
  335. package/dist/dashboard-web/chunks/groups-page-V6654ICQ.js +8 -0
  336. package/dist/dashboard-web/chunks/insights-page-237RMFQ5.js +7 -0
  337. package/dist/dashboard-web/chunks/{monitor-room-7IVIS75W.js → monitor-room-OO5Y4FRQ.js} +1 -1
  338. package/dist/dashboard-web/chunks/{monitoring-page-5SSJRQZF.js → monitoring-page-OUEITM5P.js} +1 -1
  339. package/dist/dashboard-web/chunks/{office-page-EOND3L5D.js → office-page-4KNAHQ4Q.js} +1 -1
  340. package/dist/dashboard-web/chunks/{overview-page-NISBVBBJ.js → overview-page-QDP4YK2S.js} +1 -1
  341. package/dist/dashboard-web/chunks/{plugin-page-JXC5F5YV.js → plugin-page-VEGTCEAS.js} +1 -1
  342. package/dist/dashboard-web/chunks/roles-page-PGXNZZNP.js +1 -0
  343. package/dist/dashboard-web/chunks/{schedules-page-AVOOKLUH.js → schedules-page-BJNC5TID.js} +1 -1
  344. package/dist/dashboard-web/chunks/sessions-page-M2ZYYCMO.js +6 -0
  345. package/dist/dashboard-web/chunks/settings-page-IJFZKUUW.js +2 -0
  346. package/dist/dashboard-web/chunks/{skills-page-5MW54PCC.js → skills-page-YRAIRZOQ.js} +3 -3
  347. package/dist/dashboard-web/chunks/{team-federation-page-2SNIMVXI.js → team-federation-page-DEU27GRU.js} +1 -1
  348. package/dist/dashboard-web/chunks/{v3-page-W6SBXXQC.js → v3-page-6KHEW2YL.js} +5 -5
  349. package/dist/dashboard-web/chunks/{whiteboards-page-STC54KTP.js → whiteboards-page-VDECAALH.js} +1 -1
  350. package/dist/dashboard-web/style.css +12545 -11432
  351. package/dist/dashboard.js +215 -36
  352. package/dist/dashboard.js.map +1 -1
  353. package/dist/global-config.d.ts +41 -2
  354. package/dist/global-config.d.ts.map +1 -1
  355. package/dist/global-config.js +47 -0
  356. package/dist/global-config.js.map +1 -1
  357. package/dist/i18n/en.d.ts.map +1 -1
  358. package/dist/i18n/en.js +71 -19
  359. package/dist/i18n/en.js.map +1 -1
  360. package/dist/i18n/zh.d.ts.map +1 -1
  361. package/dist/i18n/zh.js +71 -19
  362. package/dist/i18n/zh.js.map +1 -1
  363. package/dist/im/lark/callback-button-marker.d.ts +34 -0
  364. package/dist/im/lark/callback-button-marker.d.ts.map +1 -0
  365. package/dist/im/lark/callback-button-marker.js +99 -0
  366. package/dist/im/lark/callback-button-marker.js.map +1 -0
  367. package/dist/im/lark/card-builder.d.ts +46 -10
  368. package/dist/im/lark/card-builder.d.ts.map +1 -1
  369. package/dist/im/lark/card-builder.js +299 -78
  370. package/dist/im/lark/card-builder.js.map +1 -1
  371. package/dist/im/lark/card-handler.d.ts +3 -4
  372. package/dist/im/lark/card-handler.d.ts.map +1 -1
  373. package/dist/im/lark/card-handler.js +380 -148
  374. package/dist/im/lark/card-handler.js.map +1 -1
  375. package/dist/im/lark/client.d.ts +24 -1
  376. package/dist/im/lark/client.d.ts.map +1 -1
  377. package/dist/im/lark/client.js +101 -8
  378. package/dist/im/lark/client.js.map +1 -1
  379. package/dist/im/lark/doc-comment.d.ts.map +1 -1
  380. package/dist/im/lark/doc-comment.js +6 -1
  381. package/dist/im/lark/doc-comment.js.map +1 -1
  382. package/dist/im/lark/event-dispatcher.d.ts +115 -3
  383. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  384. package/dist/im/lark/event-dispatcher.js +534 -147
  385. package/dist/im/lark/event-dispatcher.js.map +1 -1
  386. package/dist/im/lark/grant-command.d.ts +16 -12
  387. package/dist/im/lark/grant-command.d.ts.map +1 -1
  388. package/dist/im/lark/grant-command.js +48 -151
  389. package/dist/im/lark/grant-command.js.map +1 -1
  390. package/dist/im/lark/grant-pending.d.ts +12 -4
  391. package/dist/im/lark/grant-pending.d.ts.map +1 -1
  392. package/dist/im/lark/grant-pending.js +31 -8
  393. package/dist/im/lark/grant-pending.js.map +1 -1
  394. package/dist/im/lark/invite-command.d.ts +20 -0
  395. package/dist/im/lark/invite-command.d.ts.map +1 -0
  396. package/dist/im/lark/invite-command.js +239 -0
  397. package/dist/im/lark/invite-command.js.map +1 -0
  398. package/dist/im/lark/md-card.d.ts +66 -4
  399. package/dist/im/lark/md-card.d.ts.map +1 -1
  400. package/dist/im/lark/md-card.js +201 -31
  401. package/dist/im/lark/md-card.js.map +1 -1
  402. package/dist/im/lark/mention-targets.d.ts +43 -0
  403. package/dist/im/lark/mention-targets.d.ts.map +1 -0
  404. package/dist/im/lark/mention-targets.js +190 -0
  405. package/dist/im/lark/mention-targets.js.map +1 -0
  406. package/dist/im/lark/message-parser.d.ts +31 -0
  407. package/dist/im/lark/message-parser.d.ts.map +1 -1
  408. package/dist/im/lark/message-parser.js +305 -32
  409. package/dist/im/lark/message-parser.js.map +1 -1
  410. package/dist/im/lark/reply-card-footer-signature.d.ts +7 -0
  411. package/dist/im/lark/reply-card-footer-signature.d.ts.map +1 -0
  412. package/dist/im/lark/reply-card-footer-signature.js +7 -0
  413. package/dist/im/lark/reply-card-footer-signature.js.map +1 -0
  414. package/dist/im/lark/sessions-card.d.ts.map +1 -1
  415. package/dist/im/lark/sessions-card.js +4 -1
  416. package/dist/im/lark/sessions-card.js.map +1 -1
  417. package/dist/im/lark/substitute-command.d.ts.map +1 -1
  418. package/dist/im/lark/substitute-command.js +8 -0
  419. package/dist/im/lark/substitute-command.js.map +1 -1
  420. package/dist/im/lark/topic-root-context.d.ts +35 -0
  421. package/dist/im/lark/topic-root-context.d.ts.map +1 -0
  422. package/dist/im/lark/topic-root-context.js +37 -0
  423. package/dist/im/lark/topic-root-context.js.map +1 -0
  424. package/dist/im/lark/v3-blocked-card.d.ts.map +1 -1
  425. package/dist/im/lark/v3-blocked-card.js +2 -1
  426. package/dist/im/lark/v3-blocked-card.js.map +1 -1
  427. package/dist/im/lark/v3-gate-card.d.ts.map +1 -1
  428. package/dist/im/lark/v3-gate-card.js +2 -1
  429. package/dist/im/lark/v3-gate-card.js.map +1 -1
  430. package/dist/im/lark/v3-loop-grant-card.d.ts.map +1 -1
  431. package/dist/im/lark/v3-loop-grant-card.js +2 -1
  432. package/dist/im/lark/v3-loop-grant-card.js.map +1 -1
  433. package/dist/im/lark/v3-progress-card.d.ts.map +1 -1
  434. package/dist/im/lark/v3-progress-card.js +2 -1
  435. package/dist/im/lark/v3-progress-card.js.map +1 -1
  436. package/dist/im/lark/v3-revisit-grant-card.d.ts.map +1 -1
  437. package/dist/im/lark/v3-revisit-grant-card.js +2 -1
  438. package/dist/im/lark/v3-revisit-grant-card.js.map +1 -1
  439. package/dist/index-core-only.d.ts +3 -0
  440. package/dist/index-core-only.d.ts.map +1 -0
  441. package/dist/index-core-only.js +107 -0
  442. package/dist/index-core-only.js.map +1 -0
  443. package/dist/services/async-trigger-state.d.ts +8 -0
  444. package/dist/services/async-trigger-state.d.ts.map +1 -1
  445. package/dist/services/async-trigger-state.js +3 -2
  446. package/dist/services/async-trigger-state.js.map +1 -1
  447. package/dist/services/async-trigger-store.d.ts +10 -2
  448. package/dist/services/async-trigger-store.d.ts.map +1 -1
  449. package/dist/services/async-trigger-store.js +3 -2
  450. package/dist/services/async-trigger-store.js.map +1 -1
  451. package/dist/services/backend-availability.d.ts +3 -1
  452. package/dist/services/backend-availability.d.ts.map +1 -1
  453. package/dist/services/backend-availability.js +14 -1
  454. package/dist/services/backend-availability.js.map +1 -1
  455. package/dist/services/backend-type-store.d.ts.map +1 -1
  456. package/dist/services/backend-type-store.js +1 -1
  457. package/dist/services/backend-type-store.js.map +1 -1
  458. package/dist/services/bot-config-store.d.ts.map +1 -1
  459. package/dist/services/bot-config-store.js +2 -1
  460. package/dist/services/bot-config-store.js.map +1 -1
  461. package/dist/services/bridge-fallback-gate.d.ts +14 -1
  462. package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
  463. package/dist/services/bridge-fallback-gate.js +65 -1
  464. package/dist/services/bridge-fallback-gate.js.map +1 -1
  465. package/dist/services/card-prefs-store.d.ts +7 -2
  466. package/dist/services/card-prefs-store.d.ts.map +1 -1
  467. package/dist/services/card-prefs-store.js +30 -4
  468. package/dist/services/card-prefs-store.js.map +1 -1
  469. package/dist/services/codex-app-runner-protocol.d.ts +9 -0
  470. package/dist/services/codex-app-runner-protocol.d.ts.map +1 -1
  471. package/dist/services/codex-app-runner-protocol.js +17 -0
  472. package/dist/services/codex-app-runner-protocol.js.map +1 -1
  473. package/dist/services/codex-app-token-usage.d.ts +93 -0
  474. package/dist/services/codex-app-token-usage.d.ts.map +1 -0
  475. package/dist/services/codex-app-token-usage.js +190 -0
  476. package/dist/services/codex-app-token-usage.js.map +1 -0
  477. package/dist/services/codex-transcript.d.ts +6 -1
  478. package/dist/services/codex-transcript.d.ts.map +1 -1
  479. package/dist/services/codex-transcript.js +103 -32
  480. package/dist/services/codex-transcript.js.map +1 -1
  481. package/dist/services/federation-roster.d.ts +4 -0
  482. package/dist/services/federation-roster.d.ts.map +1 -1
  483. package/dist/services/federation-roster.js +3 -0
  484. package/dist/services/federation-roster.js.map +1 -1
  485. package/dist/services/federation-store.d.ts +6 -0
  486. package/dist/services/federation-store.d.ts.map +1 -1
  487. package/dist/services/federation-store.js.map +1 -1
  488. package/dist/services/grant-policy.d.ts +13 -0
  489. package/dist/services/grant-policy.d.ts.map +1 -0
  490. package/dist/services/grant-policy.js +40 -0
  491. package/dist/services/grant-policy.js.map +1 -0
  492. package/dist/services/grant-store.d.ts +24 -3
  493. package/dist/services/grant-store.d.ts.map +1 -1
  494. package/dist/services/grant-store.js +196 -12
  495. package/dist/services/grant-store.js.map +1 -1
  496. package/dist/services/groups-store.d.ts +36 -0
  497. package/dist/services/groups-store.d.ts.map +1 -1
  498. package/dist/services/groups-store.js +123 -1
  499. package/dist/services/groups-store.js.map +1 -1
  500. package/dist/services/jsonl-cursor.d.ts.map +1 -1
  501. package/dist/services/jsonl-cursor.js +8 -2
  502. package/dist/services/jsonl-cursor.js.map +1 -1
  503. package/dist/services/local-cli-opener.d.ts.map +1 -1
  504. package/dist/services/local-cli-opener.js +32 -0
  505. package/dist/services/local-cli-opener.js.map +1 -1
  506. package/dist/services/message-listener-run-preview-store.d.ts +43 -0
  507. package/dist/services/message-listener-run-preview-store.d.ts.map +1 -0
  508. package/dist/services/message-listener-run-preview-store.js +152 -0
  509. package/dist/services/message-listener-run-preview-store.js.map +1 -0
  510. package/dist/services/message-listener-store.d.ts +26 -0
  511. package/dist/services/message-listener-store.d.ts.map +1 -0
  512. package/dist/services/message-listener-store.js +138 -0
  513. package/dist/services/message-listener-store.js.map +1 -0
  514. package/dist/services/message-listener.d.ts +112 -0
  515. package/dist/services/message-listener.d.ts.map +1 -0
  516. package/dist/services/message-listener.js +391 -0
  517. package/dist/services/message-listener.js.map +1 -0
  518. package/dist/services/overload-alert-migration.d.ts +15 -0
  519. package/dist/services/overload-alert-migration.d.ts.map +1 -0
  520. package/dist/services/overload-alert-migration.js +115 -0
  521. package/dist/services/overload-alert-migration.js.map +1 -0
  522. package/dist/services/send-policy.d.ts +38 -0
  523. package/dist/services/send-policy.d.ts.map +1 -1
  524. package/dist/services/send-policy.js +34 -0
  525. package/dist/services/send-policy.js.map +1 -1
  526. package/dist/services/session-store.d.ts +12 -1
  527. package/dist/services/session-store.d.ts.map +1 -1
  528. package/dist/services/session-store.js +44 -2
  529. package/dist/services/session-store.js.map +1 -1
  530. package/dist/services/skill-registry-store.d.ts.map +1 -1
  531. package/dist/services/skill-registry-store.js +87 -12
  532. package/dist/services/skill-registry-store.js.map +1 -1
  533. package/dist/services/substitute-mode-normalize.d.ts +3 -0
  534. package/dist/services/substitute-mode-normalize.d.ts.map +1 -1
  535. package/dist/services/substitute-mode-normalize.js +8 -0
  536. package/dist/services/substitute-mode-normalize.js.map +1 -1
  537. package/dist/services/substitute-mode-store.d.ts.map +1 -1
  538. package/dist/services/substitute-mode-store.js +8 -1
  539. package/dist/services/substitute-mode-store.js.map +1 -1
  540. package/dist/services/transcript-resolver.d.ts +6 -1
  541. package/dist/services/transcript-resolver.d.ts.map +1 -1
  542. package/dist/services/transcript-resolver.js +145 -29
  543. package/dist/services/transcript-resolver.js.map +1 -1
  544. package/dist/services/trigger-types.d.ts +16 -0
  545. package/dist/services/trigger-types.d.ts.map +1 -1
  546. package/dist/services/trigger-types.js +6 -0
  547. package/dist/services/trigger-types.js.map +1 -1
  548. package/dist/setup/bot-config-editor.js +2 -2
  549. package/dist/setup/bot-config-editor.js.map +1 -1
  550. package/dist/setup/ensure-zmx.d.ts +31 -0
  551. package/dist/setup/ensure-zmx.d.ts.map +1 -0
  552. package/dist/setup/ensure-zmx.js +161 -0
  553. package/dist/setup/ensure-zmx.js.map +1 -0
  554. package/dist/setup/index.d.ts +1 -1
  555. package/dist/setup/index.js +1 -1
  556. package/dist/setup/open-platform-automation.d.ts +9 -0
  557. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  558. package/dist/setup/open-platform-automation.js +15 -2
  559. package/dist/setup/open-platform-automation.js.map +1 -1
  560. package/dist/setup/setup-args.d.ts +1 -1
  561. package/dist/setup/setup-args.d.ts.map +1 -1
  562. package/dist/setup/setup-args.js +1 -1
  563. package/dist/skills/definitions.d.ts.map +1 -1
  564. package/dist/skills/definitions.js +32 -0
  565. package/dist/skills/definitions.js.map +1 -1
  566. package/dist/skills/injection-mode.d.ts +6 -2
  567. package/dist/skills/injection-mode.d.ts.map +1 -1
  568. package/dist/skills/injection-mode.js +10 -5
  569. package/dist/skills/injection-mode.js.map +1 -1
  570. package/dist/types.d.ts +79 -3
  571. package/dist/types.d.ts.map +1 -1
  572. package/dist/utils/child-env.d.ts +1 -1
  573. package/dist/utils/child-env.d.ts.map +1 -1
  574. package/dist/utils/child-env.js +4 -0
  575. package/dist/utils/child-env.js.map +1 -1
  576. package/dist/utils/effort-confirm-dialog.d.ts +40 -0
  577. package/dist/utils/effort-confirm-dialog.d.ts.map +1 -0
  578. package/dist/utils/effort-confirm-dialog.js +74 -0
  579. package/dist/utils/effort-confirm-dialog.js.map +1 -0
  580. package/dist/utils/pending-input-queue.d.ts +56 -0
  581. package/dist/utils/pending-input-queue.d.ts.map +1 -1
  582. package/dist/utils/pending-input-queue.js +80 -0
  583. package/dist/utils/pending-input-queue.js.map +1 -1
  584. package/dist/utils/render-dimensions.d.ts +8 -0
  585. package/dist/utils/render-dimensions.d.ts.map +1 -1
  586. package/dist/utils/render-dimensions.js +10 -0
  587. package/dist/utils/render-dimensions.js.map +1 -1
  588. package/dist/utils/stuck-key-guard.d.ts +3 -2
  589. package/dist/utils/stuck-key-guard.d.ts.map +1 -1
  590. package/dist/utils/stuck-key-guard.js +3 -3
  591. package/dist/utils/stuck-key-guard.js.map +1 -1
  592. package/dist/utils/terminal-renderer.d.ts +6 -0
  593. package/dist/utils/terminal-renderer.d.ts.map +1 -1
  594. package/dist/utils/terminal-renderer.js +15 -0
  595. package/dist/utils/terminal-renderer.js.map +1 -1
  596. package/dist/utils/tui-input-delivery.d.ts +32 -0
  597. package/dist/utils/tui-input-delivery.d.ts.map +1 -0
  598. package/dist/utils/tui-input-delivery.js +53 -0
  599. package/dist/utils/tui-input-delivery.js.map +1 -0
  600. package/dist/utils/xml.d.ts +19 -0
  601. package/dist/utils/xml.d.ts.map +1 -0
  602. package/dist/utils/xml.js +26 -0
  603. package/dist/utils/xml.js.map +1 -0
  604. package/dist/worker.js +1852 -374
  605. package/dist/worker.js.map +1 -1
  606. package/dist/workflows/definition.d.ts +8 -8
  607. package/dist/workflows/events/payloads.d.ts +8 -8
  608. package/dist/workflows/events/schema.d.ts +160 -160
  609. package/dist/workflows/v3/session-relay-client.js +1 -1
  610. package/dist/workflows/v3/session-relay-client.js.map +1 -1
  611. package/dist/workflows/v3/worker-fence.d.ts.map +1 -1
  612. package/dist/workflows/v3/worker-fence.js +15 -10
  613. package/dist/workflows/v3/worker-fence.js.map +1 -1
  614. package/package.json +1 -1
  615. package/dist/dashboard-web/chunks/bot-defaults-page-UKA7K4JV.js +0 -10
  616. package/dist/dashboard-web/chunks/chunk-DFLNXQNT.js +0 -1
  617. package/dist/dashboard-web/chunks/connectors-page-A77YAB5Y.js +0 -1
  618. package/dist/dashboard-web/chunks/groups-page-RUZNVXQB.js +0 -8
  619. package/dist/dashboard-web/chunks/insights-page-RF7U7RTA.js +0 -22
  620. package/dist/dashboard-web/chunks/roles-page-BUMKD7Y4.js +0 -1
  621. package/dist/dashboard-web/chunks/sessions-page-6HLSEAB7.js +0 -2
  622. package/dist/dashboard-web/chunks/settings-page-MJDZHQ4X.js +0 -2
package/dist/daemon.js CHANGED
@@ -14,17 +14,17 @@ import { buildDashboardUrls } from './core/dashboard-url.js';
14
14
  import { resolveBotmuxDataDir } from './core/data-dir.js';
15
15
  import { reloadExactDaemonBotConfig } from './core/daemon-config-fence.js';
16
16
  import { writeHeartbeat } from './core/daemon-heartbeat.js';
17
- import { botmuxWrapperFiles } from './core/botmux-wrapper.js';
18
- import { evaluateOverload, formatOverloadAlert, buildOverloadAlertCard, buildOverloadRecoveredCard, initialOverloadCardState, DEFAULT_OVERLOAD_THRESHOLDS, INITIAL_OVERLOAD_STATE, } from './core/host-overload-alert.js';
17
+ import { botmuxWrapperFiles, resolveBotmuxWrapperBinDir } from './core/botmux-wrapper.js';
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
20
  import { countHostOverload } from './im/lark/card-handler.js';
21
21
  import { startMaintenance, stopMaintenance } from './core/maintenance.js';
22
22
  import { selectCodexRuntimeUpdateTargets, startCliRuntimeUpdateMonitor, stopCliRuntimeUpdateMonitor, } from './core/cli-runtime-update.js';
23
23
  import { sendRestartReportIfPending } from './core/restart-report.js';
24
24
  import { statSync } from 'node:fs';
25
- import { addReaction, getChatMode, getChatNameAndMode, getMessageChatId, listChatMemberOpenIds, MessageWithdrawnError, replyMessage, resolveAllowedUsersWithMap, sendMessage, sendUserMessage, updateMessage } from './im/lark/client.js';
25
+ import { addReaction, deleteMessage, getChatMode, getChatNameAndMode, getMessageChatId, listChatMemberOpenIds, MessageWithdrawnError, replyMessage, resolveAllowedUsersWithMap, sendMessage, sendUserMessage, updateMessage } from './im/lark/client.js';
26
26
  import { resolveGroupJoinPrompt, waitForAllowedUserInChat } from './core/auto-start.js';
27
- import { loadBotConfigAtIndex, loadBotConfigs, isManagedActivationStartingAtIndex, registerBot, getBot, getAllBots, getOwnerOpenId, findOncallChat, effectiveDefaultWorkingDir, effectiveBotDisplayName, resolveVcMeetingConsumerProfiles, setResolvedAllowedUsersRepublishHook, setAllowedUsersResolveRetryHook, } from './bot-registry.js';
27
+ import { loadBotConfigAtIndex, loadBotConfigs, isManagedActivationStartingAtIndex, registerBot, getBot, getAllBots, getOwnerOpenId, findOncallChat, effectiveDefaultWorkingDir, effectiveBotDisplayName, resolveVcMeetingConsumerProfiles, setResolvedAllowedUsersRepublishHook, setAllowedUsersResolveRetryHook, vcMeetingAgentConfigActive, } from './bot-registry.js';
28
28
  import { setDisplayNameRefresher, findConfigField, applyConfigField } from './services/bot-config-store.js';
29
29
  import { resolveRegularGroupMode } from './services/chat-reply-mode-store.js';
30
30
  import { renameBotOnOpenPlatform, changeBotAvatarOnOpenPlatform } from './services/open-platform-rename.js';
@@ -34,6 +34,7 @@ import * as chatFirstSeenStore from './services/chat-first-seen-store.js';
34
34
  import { ensureDefaultOncallBound } from './services/oncall-store.js';
35
35
  import * as scheduleStore from './services/schedule-store.js';
36
36
  import { migrateSharedSchedulesAtStartup } from './services/schedule-split-migration.js';
37
+ import { migrateOverloadAlertAtStartup } from './services/overload-alert-migration.js';
37
38
  import * as messageQueue from './services/message-queue.js';
38
39
  import { emitHookEvent, emitHookEventLocal, HOOK_EVENTS } from './services/hook-runner.js';
39
40
  import { setSessionLifecycleShutdown } from './services/session-lifecycle-hooks.js';
@@ -41,6 +42,7 @@ import { createImgNumberer, parseEventMessage, resolveNonsupportMessage, stripLe
41
42
  import { expandMergeForward } from './im/lark/merge-forward.js';
42
43
  import { bindResourcesToMessage, composeForwardFollowupContent, mergeMessageMentions } from './im/lark/forward-followup-content.js';
43
44
  import { buildQuoteHint } from './im/lark/quote-hint.js';
45
+ import { buildTopicThreadContext } from './im/lark/topic-root-context.js';
44
46
  import { logger } from './utils/logger.js';
45
47
  import { applyAllowedUsersResolve } from './utils/allowed-users-apply.js';
46
48
  import { withFileLock, withFileLockSync } from './utils/file-lock.js';
@@ -52,8 +54,8 @@ import { scrubTmuxServerGlobalEnv } from './setup/ensure-tmux.js';
52
54
  import { entryNeedsContactResolve } from './setup/bot-config-editor.js';
53
55
  import { invalidWorkingDirs } from './utils/working-dir.js';
54
56
  import { validateWorkingDir } from './core/working-dir.js';
55
- import { activeSessionKey, sessionKey, sessionAnchorId, storedSessionAnchorId } from './core/types.js';
56
- import { buildTerminalUrl, setTerminalProxyPort, setTerminalExternalPort } from './core/terminal-url.js';
57
+ import { activeSessionKey, sessionKey, sessionAnchorId, storedSessionAnchorId, larkTransportEnabled } from './core/types.js';
58
+ import { setTerminalProxyPort, setTerminalExternalPort } from './core/terminal-url.js';
57
59
  import { startTerminalProxy } from './core/terminal-proxy.js';
58
60
  import * as scheduler from './core/scheduler.js';
59
61
  import { scanMultipleProjects } from './services/project-scanner.js';
@@ -62,8 +64,8 @@ import { isLocalCliOpenReady } from './services/local-cli-opener.js';
62
64
  import { RECEIVED_REACTION_EMOJI_TYPE, SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE } from './core/pending-response.js';
63
65
  import { t as tr, localeForBot } from './i18n/index.js';
64
66
  import { createCliAdapterSync } from './adapters/cli/registry.js';
65
- import { initWorkerPool, setActiveSessionsRegistry, forkWorker, forkAdoptWorker, sendWorkerInput, killWorker, reapOrphanWorkers, scheduleCardPatch, setCurrentCliVersion, getCurrentCliVersion, CARD_POSTING_SENTINEL, parkStreamCard, closeSession as closeSessionHelper, ensureCliEnv, sweepGlobalBotmuxSkills, writableTerminalLinkFor, findActiveBySessionId, getDaemonBootId, } from './core/worker-pool.js';
66
- import { AbortDeadlineError, hasExactSafeJsonKeys, ipcRoute, isTrustedHostIpcRequest, JsonBodyTooLargeError, jsonRes, readJsonBody, runWithAbortDeadline, setBotName, setLarkAppId, startIpcServer, setBotRenamer, setBotAvatarChanger } from './core/dashboard-ipc-server.js';
67
+ import { initWorkerPool, setActiveSessionsRegistry, forkWorker, forkAdoptWorker, sendWorkerInput, sendWorkerSessionInput, killWorker, reapOrphanWorkers, scheduleCardPatch, setCurrentCliVersion, getCurrentCliVersion, CARD_POSTING_SENTINEL, parkStreamCard, closeSession as closeSessionHelper, setActiveSessionIfActive, rollbackRejectedSessionAndGetWinner, ensureCliEnv, sweepGlobalBotmuxSkills, writableTerminalLinkFor, workerHasInitialized, sessionSupportsWebTerminal, readableTerminalUrlFor, findActiveBySessionId, getDaemonBootId, getDaemonStreamingCardUsageSnapshot, isSessionTransferring, } from './core/worker-pool.js';
68
+ import { AbortDeadlineError, hasExactSafeJsonKeys, ipcRoute, isTrustedHostIpcRequest, JsonBodyTooLargeError, jsonRes, readJsonBody, runWithAbortDeadline, setBotName, setLarkAppId, startIpcServer, setBotRenamer, setBotAvatarChanger, armCoreOnlyReadinessGate, setCoreOnlyReady } from './core/dashboard-ipc-server.js';
67
69
  import { setDeviceIsolationDaemonIdentity } from './core/device-isolation-daemon.js';
68
70
  import { cancelSessionReadyAck, waitForSessionReadyAck, } from './core/session-ready-handshake.js';
69
71
  import { loadOrCreateDashboardSecret } from './dashboard/auth.js';
@@ -86,10 +88,12 @@ import { beginReplyTargetTurn, fallbackTurnId, isSubstituteTurn, resolveSessionR
86
88
  import { readDeferredTopicBinding } from './core/deferred-topic-binding.js';
87
89
  import { buildBotmuxLarkNativeSessionTitle, extractBotmuxLarkNativeSessionTitlePrompt, } from './core/session-title.js';
88
90
  import { settleDeferredScheduleRun } from './core/deferred-schedule-settlement.js';
91
+ import { renderMessageListenerPrompt } from './services/message-listener.js';
89
92
  import { sweepOrphanSandboxes } from './adapters/backend/sandbox.js';
90
93
  import { TmuxBackend } from './adapters/backend/tmux-backend.js';
91
94
  import { HerdrBackend } from './adapters/backend/herdr-backend.js';
92
95
  import { ZellijBackend } from './adapters/backend/zellij-backend.js';
96
+ import { ZmxBackend } from './adapters/backend/zmx-backend.js';
93
97
  import { sweepIdleWorkers, DEFAULT_MAX_LIVE_WORKERS } from './core/idle-worker-sweeper.js';
94
98
  import { getSessionPersistentBackendType, killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, probePersistentSession, resolvePersistentBackendTarget, resolvePairedSpawnBackendType, } from './core/persistent-backend.js';
95
99
  import { handleCardAction, runAutoWorktreeCommit } from './im/lark/card-handler.js';
@@ -148,9 +152,9 @@ function republishResolvedAllowedUsers(larkAppId, resolved) {
148
152
  catch { /* best effort */ }
149
153
  }
150
154
  let vcMeetingTerminalReconciler;
151
- import { isBotMentioned, probeBotOpenId, startLarkEventDispatcher, markForwardFollowupsSessionsReady, writeBotInfoFile, canOperate, canRunDaemonCommand, evaluateTalk, grantCommandRestriction, isKnownPeerBot, checkRequiredScopes } from './im/lark/event-dispatcher.js';
152
- import { getDocSubscription, listAllDocSubscriptions, removeDocSubscription, setDocCommentPollCursor } from './services/doc-subs-store.js';
153
- import { BOT_REPLY_SENTINEL, subscribeDocFile, unsubscribeDocFile, addCommentReaction, hasBotSentinel, isBotAuthoredReply, listDocComments } from './im/lark/doc-comment.js';
155
+ import { isBotMentioned, probeBotOpenId, startLarkEventDispatcher, markForwardFollowupsSessionsReady, writeBotInfoFile, canOperate, canRunDaemonCommand, evaluateTalk, evaluateBotTalk, evaluateAskAnswerTalk, grantCommandRestriction, isKnownPeerBot, checkRequiredScopes } from './im/lark/event-dispatcher.js';
156
+ import { getDocSubscription, listAllDocSubscriptions, putDocSubscription, removeDocSubscription, setDocCommentPollCursor } from './services/doc-subs-store.js';
157
+ import { BOT_REPLY_SENTINEL, subscribeDocFile, unsubscribeDocFile, addCommentReaction, removeCommentReaction, hasBotSentinel, isBotAuthoredReply, listDocComments } from './im/lark/doc-comment.js';
154
158
  import { learnFromMentions, resolveSender, flushIdentityCacheSync } from './im/lark/identity-cache.js';
155
159
  import { normalizeBrand } from './im/lark/lark-hosts.js';
156
160
  import { buildDocCommentTurnInput, buildDocWatchWarmupTurnInput } from './core/doc-comment-prompt.js';
@@ -158,7 +162,7 @@ import { advanceDocCommentCursor, docCommentRepliesAfterCursor, latestDocComment
158
162
  import { renderBufferedSenderBlock } from './core/session-manager.js';
159
163
  import { shutdownBackendDisposition } from './core/persistent-backend.js';
160
164
  import { evaluateVcMeetingConsumerIsolation } from './services/vc-meeting-consumer-isolation.js';
161
- import { markSessionActivity, announcePendingRepoSession, publishAttentionPatch, publishLastInputFromBotPatch, clearAgentAttention, } from './core/session-activity.js';
165
+ import { markSessionActivity, announcePendingRepoSession, publishAttentionPatch, publishLastInputFromBotPatch, publishSessionMessagePreviewPatch, publishClosedSessionPatch, clearAgentAttention, } from './core/session-activity.js';
162
166
  import { emitSessionLifecycleHook } from './services/session-lifecycle-hooks.js';
163
167
  import { botAutoWorktreeEnabled } from './services/default-worktree.js';
164
168
  import { setCardDispatcher as setAskCardDispatcher, setCanTalkChecker as setAskCanTalkChecker, registerAsk as registerAskBroker, findPendingAskByAnchor, submitCustomReply, } from './core/ask-broker.js';
@@ -197,7 +201,7 @@ const VC_MEETING_DELIVERY_LEASE_SCAN_MS = 60_000;
197
201
  const VC_MEETING_RUNTIME_EXPIRY_ACK_TIMEOUT_MS = 3_000;
198
202
  const VC_MEETING_RUNTIME_EXPIRY_TEARDOWN_MS = 8_000;
199
203
  const VC_MEETING_RUNTIME_EXPIRY_REPROBE_MS = 5_000;
200
- const VC_MEETING_PERSISTENT_BACKENDS = ['tmux', 'herdr', 'zellij'];
204
+ const VC_MEETING_PERSISTENT_BACKENDS = ['tmux', 'herdr', 'zellij', 'zmx'];
201
205
  function vcMeetingDeliveryStreamKey(scope) {
202
206
  return [scope.listenerAppId, scope.meetingId, scope.memberId, scope.memberEpoch].join('\u0000');
203
207
  }
@@ -268,7 +272,9 @@ function vcMeetingPersistentBackendAvailable(backendType) {
268
272
  return TmuxBackend.isAvailable();
269
273
  if (backendType === 'herdr')
270
274
  return HerdrBackend.isAvailable();
271
- return ZellijBackend.isAvailable();
275
+ if (backendType === 'zellij')
276
+ return ZellijBackend.isAvailable();
277
+ return ZmxBackend.isAvailable();
272
278
  }
273
279
  let testOnlyVcMeetingBootBackingMissing;
274
280
  function vcMeetingBootBackingMissing(sessionId, destroy) {
@@ -289,7 +295,8 @@ function vcMeetingBootBackingMissing(sessionId, destroy) {
289
295
  const persistedBackend = liveBackend ?? persistedSession?.backendType;
290
296
  if (persistedBackend === 'pty')
291
297
  return true;
292
- const backendTypes = persistedBackend === 'tmux' || persistedBackend === 'herdr' || persistedBackend === 'zellij'
298
+ const backendTypes = persistedBackend === 'tmux' || persistedBackend === 'herdr'
299
+ || persistedBackend === 'zellij' || persistedBackend === 'zmx'
293
300
  ? [persistedBackend]
294
301
  : VC_MEETING_PERSISTENT_BACKENDS.filter(vcMeetingPersistentBackendAvailable);
295
302
  let allMissing = true;
@@ -298,7 +305,7 @@ function vcMeetingBootBackingMissing(sessionId, destroy) {
298
305
  const name = target.sessionName;
299
306
  if (destroy) {
300
307
  try {
301
- killPersistentBackendTarget(target);
308
+ killPersistentBackendTarget(target, sessionId);
302
309
  }
303
310
  catch (err) {
304
311
  logger.warn(`[vc-delivery] boot recovery backing kill failed backend=${backendType} `
@@ -480,7 +487,7 @@ function createVcMeetingRuntimeLeaseRecovery(deps) {
480
487
  : persistentScope === 'unknown'
481
488
  // An unavailable client binary cannot own/reach a live mux session on
482
489
  // this host. Excluding it avoids permanent unknown fences on machines
483
- // that only install one of the three supported backends.
490
+ // that only install one of the supported persistent backends.
484
491
  ? VC_MEETING_PERSISTENT_BACKENDS.filter(deps.backendAvailable)
485
492
  : [persistentScope];
486
493
  let allMissing = true;
@@ -489,7 +496,7 @@ function createVcMeetingRuntimeLeaseRecovery(deps) {
489
496
  const target = resolvePersistentBackendTarget(backendType, fence.receiverSessionId, persistedTarget);
490
497
  const name = target.sessionName;
491
498
  try {
492
- deps.killPersistent(backendType, name, target);
499
+ deps.killPersistent(backendType, name, fence.receiverSessionId, target);
493
500
  }
494
501
  catch (err) {
495
502
  deps.warn(`[vc-delivery] runtime lease backing kill failed backend=${backendType} `
@@ -721,7 +728,8 @@ const vcMeetingRuntimeLeaseRecovery = createVcMeetingRuntimeLeaseRecovery({
721
728
  resolveMissingPersistentScope(sessionId) {
722
729
  const persisted = sessionStore.getSession(sessionId);
723
730
  const backendType = persisted?.backendType;
724
- if (backendType === 'tmux' || backendType === 'herdr' || backendType === 'zellij') {
731
+ if (backendType === 'tmux' || backendType === 'herdr'
732
+ || backendType === 'zellij' || backendType === 'zmx') {
725
733
  return backendType;
726
734
  }
727
735
  return backendType === 'pty' ? 'none' : 'unknown';
@@ -731,15 +739,144 @@ const vcMeetingRuntimeLeaseRecovery = createVcMeetingRuntimeLeaseRecovery({
731
739
  ?? sessionStore.getSession(sessionId)?.persistentBackendTarget;
732
740
  },
733
741
  backendAvailable: vcMeetingPersistentBackendAvailable,
734
- killPersistent: (backendType, sessionName, target) => target
735
- ? killPersistentBackendTarget(target)
736
- : killPersistentSession(backendType, sessionName),
742
+ killPersistent: (backendType, sessionName, sessionId, target) => target
743
+ ? killPersistentBackendTarget(target, sessionId)
744
+ : killPersistentSession(backendType, sessionName, sessionId),
737
745
  probePersistent: (backendType, sessionName, target) => target
738
746
  ? probePersistentBackendTarget(target)
739
747
  : probePersistentSession(backendType, sessionName),
740
748
  warn: message => logger.warn(message),
741
749
  error: message => logger.error(message),
742
750
  });
751
+ function captureRoutingGeneration(ds) {
752
+ return {
753
+ key: sessionKey(sessionAnchorId(ds), ds.larkAppId),
754
+ ds,
755
+ session: ds.session,
756
+ };
757
+ }
758
+ function isCurrentRoutingGeneration(generation) {
759
+ const { key, ds, session } = generation;
760
+ return session.status === 'active'
761
+ && ds.session === session
762
+ && sessionKey(sessionAnchorId(ds), ds.larkAppId) === key
763
+ && activeSessions.get(key) === ds;
764
+ }
765
+ function ensureCurrentRoutingGeneration(generation, phase) {
766
+ if (isCurrentRoutingGeneration(generation))
767
+ return;
768
+ throw new Error(`session routing generation changed during ${phase}`);
769
+ }
770
+ /** Register one freshly-created doc-native session without overwriting a
771
+ * concurrent winner. The rejected row is closed before the winner is returned,
772
+ * so its persisted record cannot survive as a ghost-active session. */
773
+ async function registerDocSessionCandidate(key, candidate, rollback = rollbackRejectedSessionAndGetWinner) {
774
+ if (setActiveSessionIfActive(activeSessions, key, candidate))
775
+ return candidate;
776
+ return await rollback(activeSessions, key, candidate) ?? null;
777
+ }
778
+ function sameDocBindingRoute(a, b) {
779
+ return a.sessionAnchor === b.sessionAnchor
780
+ && a.sessionId === b.sessionId
781
+ && a.scope === b.scope
782
+ && a.chatId === b.chatId;
783
+ }
784
+ /**
785
+ * Move one subscription onto the authoritative route owned by `selected`.
786
+ *
787
+ * The store is re-read immediately before the synchronous write so cursor and
788
+ * metadata updates made from another event are retained. A newer explicit
789
+ * rebind is never overwritten: the only route change accepted since `sub` was
790
+ * read is the same selected route (the normal concurrent auto-create winner
791
+ * case). Mutating the caller's snapshot keeps the current comment turn on the
792
+ * same route as the persisted subscription.
793
+ */
794
+ function persistDocBindingToSession(sub, larkAppId, selected, deps = {}) {
795
+ const dataDir = deps.dataDir ?? config.session.dataDir;
796
+ const read = deps.read ?? getDocSubscription;
797
+ const write = deps.write ?? putDocSubscription;
798
+ const current = read(dataDir, larkAppId, sub.fileToken);
799
+ if (!current) {
800
+ throw new Error(`document subscription was removed during routing: ${sub.fileToken.slice(0, 12)}`);
801
+ }
802
+ const selectedRoute = {
803
+ sessionAnchor: sessionAnchorId(selected),
804
+ sessionId: selected.session.sessionId,
805
+ scope: selected.scope,
806
+ chatId: selected.chatId,
807
+ };
808
+ const currentTargetsSelected = current.sessionAnchor === selectedRoute.sessionAnchor
809
+ && current.sessionId === selectedRoute.sessionId
810
+ && current.scope === selectedRoute.scope
811
+ && current.chatId === selectedRoute.chatId;
812
+ if (!sameDocBindingRoute(current, sub) && !currentTargetsSelected) {
813
+ throw new Error(`document subscription was rebound during routing: ${sub.fileToken.slice(0, 12)}`);
814
+ }
815
+ const rebound = { ...current, ...selectedRoute };
816
+ write(dataDir, larkAppId, rebound);
817
+ Object.assign(sub, rebound);
818
+ }
819
+ /** Persist the route selected by registration. A candidate that won the CAS
820
+ * is now daemon-owned, so a failed subscription write must close only that
821
+ * exact occupant. A rejected candidate must never close the concurrent
822
+ * winner whose route it received from registerDocSessionCandidate(). */
823
+ async function persistSelectedDocBinding(routingKey, sub, larkAppId, selected, candidate, persist = persistDocBindingToSession, rollback = closeSessionHelper) {
824
+ try {
825
+ persist(sub, larkAppId, selected);
826
+ }
827
+ catch (err) {
828
+ if (selected === candidate && activeSessions.get(routingKey) === candidate) {
829
+ try {
830
+ await rollback(candidate.session.sessionId);
831
+ }
832
+ catch (rollbackErr) {
833
+ logger.error(`[doc-comment] failed to roll back auto-created session ` +
834
+ `${candidate.session.sessionId.slice(0, 8)} after binding error: ` +
835
+ `${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`);
836
+ }
837
+ finally {
838
+ // closeSessionHelper removes this synchronously in production. Keep a
839
+ // final identity guard for injected/test rollback failures so no newer
840
+ // route owner can be evicted.
841
+ if (activeSessions.get(routingKey) === candidate)
842
+ activeSessions.delete(routingKey);
843
+ candidate.session.status = 'closed';
844
+ candidate.session.closedAt ??= new Date().toISOString();
845
+ }
846
+ }
847
+ throw err;
848
+ }
849
+ }
850
+ function ownsCurrentRoute(ds, larkAppId) {
851
+ return ds.larkAppId === larkAppId
852
+ && ds.session.status === 'active'
853
+ && activeSessions.get(sessionKey(sessionAnchorId(ds), larkAppId)) === ds;
854
+ }
855
+ /** Resolve a binding without ever handing a stale anchor's replacement the
856
+ * comment. If the subscribed session was relayed, find it by stable sessionId
857
+ * and lazily migrate the subscription to its new authoritative route. */
858
+ function resolveBoundDocSession(sub, larkAppId, persist = persistDocBindingToSession) {
859
+ const direct = activeSessions.get(sessionKey(sub.sessionAnchor, larkAppId));
860
+ if (direct
861
+ && ownsCurrentRoute(direct, larkAppId)
862
+ && (!sub.sessionId || direct.session.sessionId === sub.sessionId)) {
863
+ return direct;
864
+ }
865
+ if (!sub.sessionId)
866
+ return undefined;
867
+ const relocated = [...new Set(activeSessions.values())].find(candidate => candidate.session.sessionId === sub.sessionId
868
+ && ownsCurrentRoute(candidate, larkAppId));
869
+ if (!relocated)
870
+ return undefined;
871
+ persist(sub, larkAppId, relocated);
872
+ return relocated;
873
+ }
874
+ export const __testOnly_captureRoutingGeneration = captureRoutingGeneration;
875
+ export const __testOnly_isCurrentRoutingGeneration = isCurrentRoutingGeneration;
876
+ export const __testOnly_registerDocSessionCandidate = registerDocSessionCandidate;
877
+ export const __testOnly_persistDocBindingToSession = persistDocBindingToSession;
878
+ export const __testOnly_persistSelectedDocBinding = persistSelectedDocBinding;
879
+ export const __testOnly_resolveBoundDocSession = resolveBoundDocSession;
743
880
  let vcMeetingOutputTextSenderForTest;
744
881
  let vcMeetingTextOutputAvailableForTest;
745
882
  const vcMeetingSessions = new Map();
@@ -1900,6 +2037,24 @@ async function sessionReply(anchor, content, msgType = 'text', larkAppId, turnId
1900
2037
  const appId = larkAppId ?? ds?.larkAppId ?? getAllBots()[0]?.config.larkAppId;
1901
2038
  if (!appId)
1902
2039
  throw new Error('No bot configured');
2040
+ // Central Lark-transport gate (fail-closed): a core-only (apiOnly) bot or an
2041
+ // HTTP virtual session (http_async_* / http_wait_*) has no real Feishu chat.
2042
+ // Auxiliary worker UI is already suppressed at its source (worker-pool
2043
+ // managedAuxUiSuppressed) and HTTP final_output is intercepted upstream in
2044
+ // deliverFinalOutput, so this path is normally unreachable for such sessions;
2045
+ // this is a defense-in-depth backstop. Return '' (NOT the synthetic anchor) —
2046
+ // an empty message id reads as "nothing posted" to every caller that stores
2047
+ // the result as a card id, so the falsy-guarded scheduleCardPatch/updateMessage
2048
+ // never fire on a synthetic id. Returning `anchor` would be a latent bug: it
2049
+ // would be stored as streamCardId and a later PATCH would dial Feishu.
2050
+ const transportAllowed = larkTransportEnabled({
2051
+ chatId: ds?.chatId ?? anchor,
2052
+ apiOnly: getBot(appId).config.apiOnly,
2053
+ });
2054
+ if (!transportAllowed) {
2055
+ logger.debug(`[lark-transport] suppressed reply for no-transport session (app=${appId} anchor=${anchor.substring(0, 16)})`);
2056
+ return '';
2057
+ }
1903
2058
  const hookContext = ds ? {
1904
2059
  sessionId: ds.session.sessionId,
1905
2060
  scope: ds.scope,
@@ -2016,12 +2171,22 @@ async function notifyQuotaExhausted(larkAppId, anchor, senderOpenId, limit) {
2016
2171
  logger.warn(`[quota:${larkAppId}] quota exhausted notify failed: ${err}`);
2017
2172
  }
2018
2173
  }
2019
- export async function enforceMessageQuotaForCliInput(larkAppId, chatId, senderOpenId, messageId, anchor, senderUnionId, memberUnionId, chatType) {
2174
+ export async function enforceMessageQuotaForCliInput(larkAppId, chatId, senderOpenId, messageId, anchor, senderUnionId, memberUnionId, chatType, botSender, opts) {
2175
+ if (opts?.listenerAuthorized)
2176
+ return true;
2020
2177
  // senderUnionId(bot-locked)让 evaluateTalk 认出跨部署团队 peer bot(teamBot 腿);
2021
2178
  // memberUnionId(可为真人 union)走 teamMember 腿——否则外部闸门/群闸门放进来的
2022
2179
  // 团队 bot 或团队成员消息会在这里复查处被静默丢弃(#332 端到端断点,人腿同理)。
2023
2180
  // chatType 同理:不传的话,p2pOpen 放行的私聊消息会在这道复查里被丢掉(同一个断点)。
2024
- const ev = evaluateTalk(larkAppId, chatId, senderOpenId, senderUnionId, memberUnionId, chatType);
2181
+ //
2182
+ // botSender:这条消息的发送方是飞书盖章的 bot(sender_type ∈ app|bot,或 cross-ref
2183
+ // 认识的自家 peer)。命中时必须与 dispatcher 的外层闸用**同一个谓词** evaluateBotTalk,
2184
+ // 否则「团队拉群 + sender 没带 union_id」这条路径会外层放行、这里静默丢弃——比弹卡
2185
+ // 更糟:owner 以为授权生效了,消息凭空消失(#332 同款断点的最后一截)。
2186
+ // 不传(人的路径)→ 原样走 evaluateTalk,语义不变。
2187
+ const ev = botSender
2188
+ ? evaluateBotTalk(larkAppId, chatId, senderOpenId, senderUnionId)
2189
+ : evaluateTalk(larkAppId, chatId, senderOpenId, senderUnionId, memberUnionId, chatType);
2025
2190
  if (!ev.allowed) {
2026
2191
  logger.debug(`[quota:${larkAppId}] dropping message ${messageId.substring(0, 12)} from non-allowed sender ${senderOpenId?.substring(0, 12) ?? '?'}`);
2027
2192
  return false;
@@ -2039,8 +2204,18 @@ export async function enforceMessageQuotaForCliInput(larkAppId, chatId, senderOp
2039
2204
  return false;
2040
2205
  let quota;
2041
2206
  try {
2042
- const def = getBot(larkAppId).config.messageQuota?.defaultLimit;
2043
- quota = await consumeQuota(larkAppId, ev.quotaKey, def);
2207
+ // oncall chatGrant 交集的额度决策**全部收口进 consumeQuota 的同一把锁**,杜绝跨 await
2208
+ // 用陈旧 ev 决策:
2209
+ // • explicitGrantOverride(live 显式授权)→ 不兜 default(undefined)。
2210
+ // • expiredGrantCleanup(观察到过期 chatGrant)→ 透传给 consumeQuota,锁内以**当前 expiry**
2211
+ // 为权威:当前 expiry<=now → 原子清「成员+quota+expiry」并回落 default;当前无/未来 expiry
2212
+ // → grant live(成员在→不兜 default 按现有/不限;成员已清→回落 default)。
2213
+ // • 其余 oncall(非成员)→ 兜 default。
2214
+ // 传入的 def 是「拟回落值」;是否真用由 consumeQuota 锁内定夺(有 expiredGrant 时以 CAS 为准)。
2215
+ const def = ev.reason === 'oncall' && !ev.explicitGrantOverride
2216
+ ? getBot(larkAppId).config.messageQuota?.defaultLimit
2217
+ : undefined;
2218
+ quota = await consumeQuota(larkAppId, ev.quotaKey, def, ev.expiredGrantCleanup);
2044
2219
  }
2045
2220
  catch (err) {
2046
2221
  logger.warn(`[quota:${larkAppId}] consume failed; dropping message ${messageId.substring(0, 12)}: ${err}`);
@@ -2099,21 +2274,32 @@ function getPidFile() {
2099
2274
  return join(config.session.dataDir, name);
2100
2275
  }
2101
2276
  /** Path to the wrapper bin directory — injected into worker PATH so CLIs
2102
- * can call `botmux send` / `botmux schedule` without a global npm install. */
2103
- const BOTMUX_BIN_DIR = join(homedir(), '.botmux', 'bin');
2277
+ * can call `botmux send` / `botmux schedule` without a global npm install.
2278
+ * Single source of truth: resolveBotmuxWrapperBinDir (core-only dedicated
2279
+ * `<SESSION_DATA_DIR>/bin`, never the shared `~/.botmux/bin`, so a same-HOME
2280
+ * fleet's wrapper/PATH is not clobbered and left unrestored on exit — codex P1).
2281
+ * Every consumer (worker-pool fork PATH, worker.ts child-env, tmux pane scripts)
2282
+ * MUST resolve the SAME dir. */
2283
+ const BOTMUX_BIN_DIR = resolveBotmuxWrapperBinDir(process.env);
2104
2284
  function writePidFile() {
2105
2285
  const dir = config.session.dataDir;
2106
2286
  if (!existsSync(dir)) {
2107
2287
  mkdirSync(dir, { recursive: true });
2108
2288
  }
2109
2289
  atomicWriteFileSync(getPidFile(), String(process.pid));
2110
- // Write breadcrumb so CLI tools (botmux list/delete) can find the active data dir
2111
- const breadcrumb = join(homedir(), '.botmux', '.data-dir');
2112
- try {
2113
- mkdirSync(join(homedir(), '.botmux'), { recursive: true });
2114
- atomicWriteFileSync(breadcrumb, config.session.dataDir);
2290
+ // Write breadcrumb so CLI tools (botmux list/delete) can find the active data dir.
2291
+ // SKIP in core-only (codex P1): the global `~/.botmux/.data-dir` is a shared-HOME
2292
+ // signpost for bare-shell CLIs; a core-only service must not rewrite it to point
2293
+ // a same-HOME host operator at the core-only store. Core-only's own workers get
2294
+ // the data dir via the frozen SESSION_DATA_DIR env, not this breadcrumb.
2295
+ if (process.env.BOTMUX_CORE_ONLY !== '1') {
2296
+ const breadcrumb = join(homedir(), '.botmux', '.data-dir');
2297
+ try {
2298
+ mkdirSync(join(homedir(), '.botmux'), { recursive: true });
2299
+ atomicWriteFileSync(breadcrumb, config.session.dataDir);
2300
+ }
2301
+ catch { /* best effort */ }
2115
2302
  }
2116
- catch { /* best effort */ }
2117
2303
  // Write a thin wrapper script so `botmux` is always in PATH for CLI sessions,
2118
2304
  // regardless of whether the package was installed globally. The wrapper
2119
2305
  // points at THIS daemon's dist/cli.js, so it's always the same version.
@@ -2548,7 +2734,7 @@ async function recoverV3DistillationProposalsForBot(larkAppId) {
2548
2734
  }
2549
2735
  }
2550
2736
  async function handleV3SavedWorkflowCommandIfAny(invocation) {
2551
- const { content, anchor, replyRootId, messageId, chatId, chatType, larkAppId, initiatorOpenId, teamTrustUnionId, memberUnionId, } = invocation;
2737
+ const { content, anchor, replyRootId, messageId, chatId, chatType, larkAppId, initiatorOpenId, teamTrustUnionId, memberUnionId, botSender, } = invocation;
2552
2738
  const command = parseV3SavedWorkflowCommand(content);
2553
2739
  if (!command)
2554
2740
  return false;
@@ -2585,7 +2771,7 @@ async function handleV3SavedWorkflowCommandIfAny(invocation) {
2585
2771
  const operatorCanOperate = canOperate(larkAppId, chatId, initiatorOpenId, teamTrustUnionId);
2586
2772
  const policy = await authorizeV3SavedWorkflowInvocation(command, {
2587
2773
  canPublishGlobal: () => operatorCanOperate,
2588
- consumeMessageQuotaOnce: () => enforceMessageQuotaForCliInput(larkAppId, chatId, initiatorOpenId, targets.quotaMessageId, targets.replyAnchor, teamTrustUnionId, memberUnionId, chatType),
2774
+ consumeMessageQuotaOnce: () => enforceMessageQuotaForCliInput(larkAppId, chatId, initiatorOpenId, targets.quotaMessageId, targets.replyAnchor, teamTrustUnionId, memberUnionId, chatType, botSender),
2589
2775
  });
2590
2776
  if (!policy.ok) {
2591
2777
  if (policy.reason === 'global_requires_operate') {
@@ -2693,12 +2879,12 @@ function beginNewTurn(ds, title) {
2693
2879
  ds.streamingCardForced = undefined;
2694
2880
  const previousUsageLimit = ds.usageLimit;
2695
2881
  const previousStatus = ds.lastScreenStatus === 'limited' && previousUsageLimit ? 'limited' : 'idle';
2696
- if (ds.streamCardId && ds.workerPort) {
2697
- const readUrl = buildTerminalUrl(ds);
2882
+ if (ds.streamCardId && workerHasInitialized(ds)) {
2883
+ const readUrl = readableTerminalUrlFor(ds);
2698
2884
  const dsBotCfg = getBot(ds.larkAppId).config;
2699
2885
  const prevTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(dsBotCfg.cliId);
2700
2886
  const prevMode = ds.displayMode ?? 'hidden';
2701
- const frozenCard = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, prevTitle, ds.lastScreenContent ?? '', previousStatus, dsBotCfg.cliId, prevMode, ds.streamCardNonce, ds.currentImageKey, !!ds.adoptedFrom, false, localeForBot(ds.larkAppId), previousUsageLimit, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: dsBotCfg.cliId }));
2887
+ const frozenCard = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, prevTitle, ds.lastScreenContent ?? '', previousStatus, dsBotCfg.cliId, prevMode, ds.streamCardNonce, ds.currentImageKey, !!ds.adoptedFrom, false, localeForBot(ds.larkAppId), previousUsageLimit, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: dsBotCfg.cliId }), getDaemonStreamingCardUsageSnapshot(ds, dsBotCfg.cliId, { fresh: true }));
2702
2888
  scheduleCardPatch(ds, frozenCard);
2703
2889
  if (ds.streamCardNonce && ds.streamCardId !== CARD_POSTING_SENTINEL) {
2704
2890
  if (!ds.frozenCards)
@@ -2735,6 +2921,8 @@ function isSessionlessCommandInvocation(cmd, content) {
2735
2921
  return !docWatchCommandNeedsSession(content);
2736
2922
  }
2737
2923
  async function prewarmDocCommentSession(ds, sub) {
2924
+ const generation = captureRoutingGeneration(ds);
2925
+ ensureCurrentRoutingGeneration(generation, 'prewarm:start');
2738
2926
  const bot = getBot(ds.larkAppId);
2739
2927
  const botCfg = bot.config;
2740
2928
  const loc = localeForBot(ds.larkAppId);
@@ -2748,6 +2936,7 @@ async function prewarmDocCommentSession(ds, sub) {
2748
2936
  const title = `[Doc Watch] ${sub.fileToken.slice(0, 12)}`;
2749
2937
  const turnId = `doc-watch-${Date.now()}-${sub.fileToken.slice(0, 8)}`;
2750
2938
  const sender = sub.ownerOpenId ? await resolveSender(ds.larkAppId, sub.ownerOpenId, 'user') : undefined;
2939
+ ensureCurrentRoutingGeneration(generation, 'prewarm:sender');
2751
2940
  beginNewTurn(ds, title);
2752
2941
  ds.lastMessageAt = Date.now();
2753
2942
  ds.session.lastMessageAt = new Date(ds.lastMessageAt).toISOString();
@@ -3059,6 +3248,92 @@ async function deliverCodexNotifierEvent(larkAppId, event) {
3059
3248
  codexNotifierDeliveries.delete(deliveryKey);
3060
3249
  }
3061
3250
  }
3251
+ /**
3252
+ * P1 revalidation for the Codex-notifier「继续处理」takeover. After the dynamic
3253
+ * import + AbortSignal await inside adoptCodexNotifierEvent, a concurrent path may
3254
+ * have moved the ground: a `/relay` transfer opened its input gate (the starter
3255
+ * would then fail-closed with a bare `return` our no-op sessionReply swallows,
3256
+ * and the outer handler would render a bogus green「已接管」over a half-rewritten
3257
+ * session), or the session was /close'd / swapped / re-created under this key.
3258
+ * Returns true when the takeover MUST abort before mutating any state — so the
3259
+ * caller can throw with zero side effects and no buffered input dropped.
3260
+ */
3261
+ function notifierAdoptStaleOrTransferring(ds, sessions, activeKey, genSessionId) {
3262
+ return (sessions.get(activeKey) !== ds
3263
+ || ds.session.sessionId !== genSessionId
3264
+ || ds.session.status !== 'active'
3265
+ || isSessionTransferring(ds));
3266
+ }
3267
+ /**
3268
+ * P2 predicate: would clearPendingRepoStateForNotifierAdopt drop user input that
3269
+ * was accepted but not yet delivered to the CLI? Two DISTINCT windows with
3270
+ * different gating — do NOT collapse them:
3271
+ *
3272
+ * (1) repo-select pending buffer — ONLY meaningful while pendingRepo===true.
3273
+ * pendingPrompt / pendingCodexAppText / pendingAttachments / pendingFollowUps
3274
+ * are the message stashed for the deferred post-repo-selection fork. But the
3275
+ * pinned/defaultWorkingDir immediate-launch path (daemon.ts ~16031/17325)
3276
+ * ALSO seeds these same mirror fields, then forks and clears only
3277
+ * pendingTurnId (~16097/17387) — leaving pendingPrompt/etc behind on an
3278
+ * already-delivered session with pendingRepo=false. Gating on pendingRepo
3279
+ * here is what stops us from warning "message not delivered, resend" about
3280
+ * a prompt the worker already ran (which would make the user re-run a
3281
+ * non-idempotent command).
3282
+ *
3283
+ * (2) just-committed launch window — INDEPENDENT of pendingRepo. commitRepoSelection
3284
+ * set pendingRepo=false and forked, but pendingRawInput / pendingFollowUpInput
3285
+ * still wait on the new worker's prompt_ready to actually send. These are the
3286
+ * only fields that legitimately represent undelivered input while
3287
+ * pendingRepo=false, so they are checked unconditionally. (The immediate-launch
3288
+ * path never populates them — it forks with the prompt as a direct argument.)
3289
+ */
3290
+ function notifierAdoptWouldDropInput(ds) {
3291
+ const repoSelectBuffered = ds.pendingRepo === true && ((ds.pendingPrompt?.trim().length ?? 0) > 0
3292
+ || (ds.pendingFollowUps?.length ?? 0) > 0
3293
+ || (ds.pendingAttachments?.length ?? 0) > 0
3294
+ || (ds.pendingCodexAppText?.trim().length ?? 0) > 0);
3295
+ // Undelivered regardless of pendingRepo (awaiting prompt_ready on a forked worker).
3296
+ const undeliveredLaunchInput = (ds.pendingRawInput?.trim().length ?? 0) > 0
3297
+ || (ds.pendingFollowUpInput?.cliInput?.trim().length ?? 0) > 0;
3298
+ return repoSelectBuffered || undeliveredLaunchInput;
3299
+ }
3300
+ /**
3301
+ * Fully retire an in-memory repo-select placeholder when the Codex-notifier
3302
+ * 「继续处理」callback takes over the DM session. The legacy inline clear only
3303
+ * reset six fields and left `repoCardMessageId`, `pendingFollowUps`,
3304
+ * `pendingRepoCommitInFlight`, `worktreeCreating`, attachments, mentions, raw
3305
+ * input, etc. behind — a stale repo-card click or a late auto-worktree
3306
+ * completion could then act on the just-adopted session. Clear the whole
3307
+ * pending-repo surface so no residue survives the takeover. Buffered/undelivered
3308
+ * user input (repo-select buffer AND the pendingRawInput/pendingFollowUpInput a
3309
+ * just-committed session is still waiting to send on prompt_ready) is dropped
3310
+ * here — the drop is terminal (never rolled back, since concurrent background
3311
+ * tasks may already have observed pendingRepo=false); the caller tells the user
3312
+ * their input was cancelled on both the success and failure card.
3313
+ */
3314
+ function clearPendingRepoStateForNotifierAdopt(ds) {
3315
+ ds.pendingRepo = false;
3316
+ ds.pendingRepoCommitInFlight = false;
3317
+ ds.worktreeCreating = false;
3318
+ ds.repoCardMessageId = undefined;
3319
+ ds.pendingPrompt = undefined;
3320
+ ds.pendingTurnId = undefined;
3321
+ ds.pendingRawInput = undefined;
3322
+ ds.pendingRawTurnId = undefined;
3323
+ ds.pendingFollowUpInput = undefined;
3324
+ ds.pendingAttachments = undefined;
3325
+ ds.pendingMentions = undefined;
3326
+ ds.pendingSubstituteTrigger = undefined;
3327
+ ds.pendingSubstituteControlCard = undefined;
3328
+ ds.pendingSender = undefined;
3329
+ ds.pendingFollowUps = undefined;
3330
+ ds.pendingFollowUpTurnId = undefined;
3331
+ ds.pendingCodexAppText = undefined;
3332
+ ds.pendingCodexAppApplicationContext = undefined;
3333
+ ds.pendingCodexAppMessageContext = undefined;
3334
+ ds.pendingCodexAppFollowUps = undefined;
3335
+ ds.pendingCodexAppFollowUpContexts = undefined;
3336
+ }
3062
3337
  async function adoptCodexNotifierEvent(larkAppId, event, cardMessageId, ownerOpenId, signal, deadlineAt) {
3063
3338
  signal.throwIfAborted();
3064
3339
  const chatId = await getMessageChatId(larkAppId, cardMessageId, {
@@ -3103,9 +3378,40 @@ async function adoptCodexNotifierEvent(larkAppId, event, cardMessageId, ownerOpe
3103
3378
  workingDir: event.cwd,
3104
3379
  ownerOpenId,
3105
3380
  };
3106
- activeSessions.set(activeKey, ds);
3107
- }
3381
+ if (!setActiveSessionIfActive(activeSessions, activeKey, ds)) {
3382
+ await closeSessionHelper(session.sessionId);
3383
+ throw new Error('通知所在会话路由被一条待处理的持久会话占用');
3384
+ }
3385
+ }
3386
+ // Transfer guard OUTSIDE the launch branch. If the session is already on the
3387
+ // target thread with a live worker, the branch below is skipped entirely —
3388
+ // but a concurrent /relay could still be moving this session's routing away.
3389
+ // Returning a green「已接管,可继续」while the route is migrating is a lie.
3390
+ // Fail-closed here first (a freshly-created ds has no transfer gate, so this
3391
+ // is a no-op for the new-session path); the launch branch keeps its own
3392
+ // post-await revalidation for the drift that can open during its awaits.
3393
+ if (isSessionTransferring(ds)) {
3394
+ throw new Error('该会话正在转移,暂时无法接管;请转移完成后在完成通知卡上重试');
3395
+ }
3396
+ let bufferedInputDropped = false;
3397
+ // Snapshot the session identity BEFORE the throwable/deadline-bound awaits
3398
+ // below. If the session is closed, swapped, or re-created under this active
3399
+ // key while we await, the post-await revalidation must detect the drift.
3400
+ const adoptGenSessionId = ds.session.sessionId;
3108
3401
  if (ds.session.cliSessionId !== event.threadId || !ds.worker || ds.worker.killed) {
3402
+ // Do the two throwable, deadline-sensitive steps FIRST, before mutating any
3403
+ // session/pending state: the dynamic import and the 2.2s AbortSignal check.
3404
+ // If either fails here nothing has been touched, so no rollback is needed
3405
+ // and the buffered repo-select input is left intact for a retry.
3406
+ const { startCodexAppThreadSession } = await import('./core/command-handler.js');
3407
+ signal.throwIfAborted();
3408
+ // Re-validate AFTER the awaits and BEFORE mutating anything (see
3409
+ // notifierAdoptStaleOrTransferring): a concurrent /relay transfer or a
3410
+ // /close/swap/re-create under this key must fail the takeover explicitly
3411
+ // rather than half-rewrite the session and report a bogus green success.
3412
+ if (notifierAdoptStaleOrTransferring(ds, activeSessions, activeKey, adoptGenSessionId)) {
3413
+ throw new Error('该会话正在转移或已变更,无法接管;请稍后在完成通知卡上重试');
3414
+ }
3109
3415
  // 接管原生 Codex App 线程时固定为纯 codex-app 启动,不能继承通知 Bot 的
3110
3416
  // wrapper/model;这些配置针对普通 CLI,会错误包装 app-server runner。
3111
3417
  ds.session.cliId = 'codex-app';
@@ -3114,12 +3420,27 @@ async function adoptCodexNotifierEvent(larkAppId, event, cardMessageId, ownerOpe
3114
3420
  delete ds.session.model;
3115
3421
  ds.session.agentFrozen = true;
3116
3422
  sessionStore.updateSession(ds.session);
3117
- ds.pendingRepo = false;
3118
- ds.pendingPrompt = undefined;
3119
- ds.pendingTurnId = undefined;
3120
- ds.pendingCodexAppText = undefined;
3121
- ds.pendingCodexAppApplicationContext = undefined;
3122
- ds.pendingCodexAppMessageContext = undefined;
3423
+ // 默认 flat DM(p2pMode=chat)下这条按钮回调复用同一私聊的现有会话。若该会话
3424
+ // 恰好停在「待选仓库」挂起态且已缓冲一条尚未提交的输入,接管会消费掉
3425
+ // pendingRepo(#669 的守卫要求接管前 pendingRepo 必须为 false)。旧逻辑只清了
3426
+ // 部分字段并静默丢弃缓冲输入。这里完整清空全部 pending 状态(否则残留的
3427
+ // repoCardMessageId/pendingFollowUps/worktreeCreating 等会让旧选仓卡二次点击、
3428
+ // 迟到 auto-worktree 完成误操作已接管会话——见 runAutoWorktreeCommit 的
3429
+ // pendingRepo 护栏),并记录「缓冲输入已丢弃」以便如实告知用户。
3430
+ //
3431
+ // 不做 pending 快照回滚:clear 之后 startCodexAppThreadSession 的 async guard
3432
+ // 会让出微任务,迟到 auto-worktree / 正在 prepare 的 commit 可能已观察到
3433
+ // pendingRepo=false 而正常结束并在 finally 清掉 in-flight 标志;此时若把
3434
+ // pendingRepo=true/worktreeCreating=true 快照复活,后台任务已不在,会话会永久
3435
+ // 卡在「正在创建/提交」。因此丢弃一旦发布即为终态——用「取消并请重发」的显式
3436
+ // 语义告知用户,而非假装能恢复。
3437
+ // 判断是否有「已收下但尚未真正送达 CLI」的用户输入会被下面的 clear 抹掉
3438
+ // (见 notifierAdoptWouldDropInput:repo-select 镜像字段只在 pendingRepo===true
3439
+ // 计入——立即启动路径也播种这些字段但已 fork 送达,不能误判;pendingRawInput/
3440
+ // pendingFollowUpInput 独立于 pendingRepo,覆盖选仓刚提交等 prompt_ready 的启动窗)。
3441
+ // 有则记录已丢弃,如实告知用户。
3442
+ bufferedInputDropped = notifierAdoptWouldDropInput(ds);
3443
+ clearPendingRepoStateForNotifierAdopt(ds);
3123
3444
  // 完成事件已经携带恢复所需的原生 threadId/cwd。按钮回调不再额外启动一次
3124
3445
  // app-server 做非必要的元数据刷新,避免子进程把飞书回调拖过截止时间。
3125
3446
  const target = {
@@ -3129,19 +3450,36 @@ async function adoptCodexNotifierEvent(larkAppId, event, cardMessageId, ownerOpe
3129
3450
  cwd: event.cwd,
3130
3451
  status: event.status,
3131
3452
  };
3132
- const { startCodexAppThreadSession } = await import('./core/command-handler.js');
3133
- signal.throwIfAborted();
3134
- await startCodexNotifierAdoptionSession(startCodexAppThreadSession, target, ds, {
3135
- activeSessions,
3136
- sessionReply,
3137
- getActiveCount,
3138
- lastRepoScan,
3139
- }, larkAppId, cardMessageId);
3453
+ try {
3454
+ await startCodexNotifierAdoptionSession(startCodexAppThreadSession, target, ds, {
3455
+ activeSessions,
3456
+ sessionReply,
3457
+ getActiveCount,
3458
+ lastRepoScan,
3459
+ }, larkAppId, cardMessageId);
3460
+ }
3461
+ catch (err) {
3462
+ // 接管失败/超时:pending 已清空且不回滚(见上)。若曾丢弃过缓冲/未送达输入,把「已取消、
3463
+ // 请重发」前置到抛给外层的错误里——外层用它渲染失败卡,用户就不会以为原消息还
3464
+ // 在队列里等着(否则失败卡只说"接管失败请重试",缓冲却已没了=静默丢失)。
3465
+ if (bufferedInputDropped) {
3466
+ const detail = err instanceof Error ? err.message : String(err);
3467
+ throw new Error(`你此前发送但尚未送达的消息已取消,请在接管成功后重新发送。(接管未完成:${detail})`);
3468
+ }
3469
+ throw err;
3470
+ }
3140
3471
  }
3141
- return buildCodexNotifierResultCard('已接管 Codex App 任务', scope === 'chat'
3472
+ const baseAdoptNotice = scope === 'chat'
3142
3473
  ? '现在可以直接在当前私聊继续发送指令;需要操作终端时,请点击会话卡内的「获取操作链接」。'
3143
- : '请在本卡片的话题中继续发送指令;需要操作终端时,请点击话题会话卡内的「获取操作链接」。', 'green');
3144
- }
3474
+ : '请在本卡片的话题中继续发送指令;需要操作终端时,请点击话题会话卡内的「获取操作链接」。';
3475
+ return buildCodexNotifierResultCard('已接管 Codex App 任务', bufferedInputDropped
3476
+ ? `⚠️ 你此前发送但尚未送达的消息未随本次接管发送,请重新发送一次。\n\n${baseAdoptNotice}`
3477
+ : baseAdoptNotice, 'green');
3478
+ }
3479
+ export const __testOnly_notifierAdoptStaleOrTransferring = notifierAdoptStaleOrTransferring;
3480
+ export const __testOnly_notifierAdoptWouldDropInput = notifierAdoptWouldDropInput;
3481
+ export const __testOnly_clearPendingRepoStateForNotifierAdopt = clearPendingRepoStateForNotifierAdopt;
3482
+ export const __testOnly_adoptCodexNotifierEvent = adoptCodexNotifierEvent;
3145
3483
  const handleCodexNotifierCardAction = createCodexNotifierCardActionHandler({
3146
3484
  getExpectedOwnerOpenId: larkAppId => getOwnerOpenId(larkAppId) ?? resolvePrimaryOwnerOpenId(larkAppId),
3147
3485
  getEventRecord: (larkAppId, eventId) => codexNotifierStore(larkAppId).get(eventId),
@@ -3599,6 +3937,18 @@ ipcRoute('POST', '/api/asks', async (req, res) => {
3599
3937
  detail: 'meeting receiver asks are not an idempotent managed action',
3600
3938
  });
3601
3939
  }
3940
+ // No-transport session (apiOnly bot or HTTP virtual chat): `botmux ask` posts
3941
+ // an interactive Lark card and blocks the turn on a human click — impossible
3942
+ // without a real Feishu chat. Fail fast with `unsupported` instead of letting
3943
+ // it fall into the Lark dispatcher and hang the agent on a card that can never
3944
+ // render. (Structured HTTP awaiting_input is a separate, future capability.)
3945
+ if (askSession && !larkTransportEnabled({ chatId: askSession.chatId, apiOnly: getBot(askSession.larkAppId).config.apiOnly })) {
3946
+ return jsonRes(res, 400, {
3947
+ ok: false,
3948
+ error: 'unsupported',
3949
+ detail: 'botmux ask requires a Feishu chat; this session has no Lark transport (apiOnly / HTTP virtual session)',
3950
+ });
3951
+ }
3602
3952
  // 谁能答复 = 谁能在该 chat 跟 bot 说话(canTalk)。鉴权在 broker 点击时按注入的
3603
3953
  // canTalkChecker 判定(见下方 setAskCanTalkChecker),daemon 这里不再预解析 approver。
3604
3954
  // chatType 随 ask 一起记下:p2pOpen 的 bot 在私聊里提问时,答复权靠这一腿才判得出
@@ -3645,7 +3995,12 @@ ipcRoute('POST', '/api/asks', async (req, res) => {
3645
3995
  else {
3646
3996
  navKeys = computeCocoPickerKeys(parsed.questions, result.answers).navKeys;
3647
3997
  }
3648
- cocoDs.worker.send({ type: 'coco_drive_picker', navKeys, needsReviewSubmit, comment });
3998
+ sendWorkerSessionInput(cocoDs, {
3999
+ type: 'coco_drive_picker',
4000
+ navKeys,
4001
+ needsReviewSubmit,
4002
+ comment,
4003
+ });
3649
4004
  logger.info(`[${cocoDs.session.sessionId.slice(0, 8)}] CoCo picker drive: ${navKeys.length} keys, review=${needsReviewSubmit}, comment=${comment ? 'yes' : 'no'}`);
3650
4005
  }
3651
4006
  catch (err) {
@@ -4365,8 +4720,15 @@ ipcRoute('POST', '/api/vc-meetings/consumer-catch-up', async (req, res) => {
4365
4720
  }
4366
4721
  });
4367
4722
  function effectiveVcMeetingAgentConfig(larkAppId) {
4368
- const cfg = getBot(larkAppId)?.config.vcMeetingAgent;
4369
- return cfg?.enabled === true ? cfg : undefined;
4723
+ // Delegate to the pure predicate (bot-registry) so the apiOnly fail-close and the
4724
+ // enabled check live in ONE tested place. apiOnly (core-only) bots have no Feishu
4725
+ // connection — a VC listener drives `lark-cli vc +meeting-events --as bot`, which
4726
+ // breaks the zero-Feishu-network contract. This accessor gates every VC entry
4727
+ // (config read / event fetch / sync) INCLUDING the boot-time
4728
+ // `restoreVcMeetingRuntimeSessionsForBot`, whose call site sits OUTSIDE the
4729
+ // `!cfg.apiOnly` boot block — so a migrated bots.json (normal VC bot flipped to
4730
+ // apiOnly, stale runtime record on disk) can no longer re-spawn lark-cli at boot.
4731
+ return vcMeetingAgentConfigActive(getBot(larkAppId)?.config);
4370
4732
  }
4371
4733
  function configuredVcMeetingListenerChatId(cfg) {
4372
4734
  return cfg.listenerChatId ?? cfg.notificationChatId;
@@ -12365,6 +12727,21 @@ function resolveBotDefaultWorkingDir(larkAppId) {
12365
12727
  * both honor the defaultOncall auto-bind the same way.
12366
12728
  */
12367
12729
  async function resolvePinnedWorkingDir(ctx) {
12730
+ if (ctx.listenerWorkingDir) {
12731
+ const resolved = expandHome(ctx.listenerWorkingDir);
12732
+ try {
12733
+ if (statSync(resolved).isDirectory()) {
12734
+ return {
12735
+ pinnedWorkingDir: resolved,
12736
+ oncallEntry: undefined,
12737
+ inheritedFrom: null,
12738
+ pinnedFromBotDefault: false,
12739
+ };
12740
+ }
12741
+ }
12742
+ catch { /* fall through to normal resolution */ }
12743
+ logger.warn(`[message-listener:${ctx.larkAppId}] listener workingDir invalid (${resolved}); falling back to normal resolution`);
12744
+ }
12368
12745
  let oncallEntry = findOncallChat(ctx.larkAppId, ctx.chatId);
12369
12746
  if (!oncallEntry) {
12370
12747
  oncallEntry = await maybeAutoBindDefaultOncall(ctx.larkAppId, ctx.chatId, ctx.chatType);
@@ -12438,6 +12815,7 @@ async function replyInvalidWorkingDirs(anchor, larkAppId, ds) {
12438
12815
  ds.pendingRepo = false;
12439
12816
  activeSessions.delete(sessionKey(anchor, larkAppId));
12440
12817
  sessionStore.closeSession(ds.session.sessionId);
12818
+ publishClosedSessionPatch(ds.session.sessionId, ds.session.closedAt ? Date.parse(ds.session.closedAt) : undefined);
12441
12819
  const msg = tr('cmd.repo.working_dir_not_exist', {
12442
12820
  dirs: invalid.map(d => `\`${d}\``).join(', '),
12443
12821
  }, localeForBot(larkAppId));
@@ -12448,9 +12826,51 @@ async function replyInvalidWorkingDirs(anchor, larkAppId, ds) {
12448
12826
  function isInitialSessionPassthrough(larkAppId, cmd) {
12449
12827
  return resolveAdapterDefaultPassthroughCommands(larkAppId).includes(cmd);
12450
12828
  }
12829
+ /** Preserve the established mid-session passthrough semantics when a cold-start
12830
+ * scratch loses its registration race to a concurrently-created real session. */
12831
+ function deliverPassthroughToExistingSession(ds, cmd, commandContent, anchor, larkAppId, turn) {
12832
+ if ((ds.worker && !ds.worker.killed) || isSessionTransferring(ds)) {
12833
+ // Passthrough commands bypass the normal message-forwarding block, so bind
12834
+ // the accepted Lark turn before the worker rotates its marker at the PTY
12835
+ // write boundary. This helper also covers a cold-start registration race.
12836
+ ds.session.quoteTargetId = turn.messageId;
12837
+ ds.session.quoteTargetSenderOpenId = turn.senderOpenId;
12838
+ ds.session.quoteTargetSenderIsBot = turn.senderIsBot;
12839
+ const substituteReplyMode = turn.substitute
12840
+ ? (getBot(larkAppId).config.substituteMode?.replyMode ?? 'thread')
12841
+ : 'thread';
12842
+ beginReplyTargetTurn(ds, turn.replyRootId, turn.messageId, new Date().toISOString(), {
12843
+ quoteOnly: substituteReplyMode === 'quote',
12844
+ substitute: turn.substitute,
12845
+ });
12846
+ if (turn.senderOpenId && ds.session.lastCallerOpenId !== turn.senderOpenId) {
12847
+ ds.session.lastCallerOpenId = turn.senderOpenId;
12848
+ }
12849
+ sessionStore.updateSession(ds.session);
12850
+ // Compatibility note (empty-start opening): a raw passthrough is a
12851
+ // LITERAL CLI command — its contract is that the CLI sees exactly the
12852
+ // bytes the user typed, never a botmux XML envelope. So it deliberately
12853
+ // does NOT consume `Session.initialUserTurnPending`: wrapping `/model`
12854
+ // in `<user_message>` would break the literal contract, and silently
12855
+ // clearing the marker would lose the opening for the next real turn.
12856
+ // `/model` on an empty-started session therefore stays literal and the
12857
+ // FOLLOWING business message still opens as a new topic.
12858
+ beginNewTurn(ds, commandContent);
12859
+ sendWorkerSessionInput(ds, {
12860
+ type: 'raw_input',
12861
+ content: commandContent,
12862
+ turnId: turn.messageId,
12863
+ });
12864
+ markSessionActivity(ds);
12865
+ logger.info(`[${anchor.substring(0, 12)}] Passthrough ${cmd} → worker`);
12866
+ return;
12867
+ }
12868
+ void sessionReply(anchor, tr('daemon.cmd_needs_active_cli', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
12869
+ }
12870
+ export const __testOnly_deliverPassthroughToExistingSession = deliverPassthroughToExistingSession;
12451
12871
  async function startInitialPassthroughSession(args) {
12452
- const { larkAppId, chatId, chatType, scope, anchor, messageId, replyRootId, parsed, commandContent, senderOpenId, senderUnionId, memberUnionId, ownerOpenId, ownerUnionId, creatorOpenId, } = args;
12453
- if (!await enforceMessageQuotaForCliInput(larkAppId, chatId, senderOpenId, messageId, anchor, senderUnionId, memberUnionId, chatType)) {
12872
+ const { larkAppId, chatId, chatType, scope, anchor, messageId, replyRootId, parsed, cmd, commandContent, senderOpenId, substitute, senderUnionId, memberUnionId, ownerOpenId, ownerUnionId, creatorOpenId, botSender, } = args;
12873
+ if (!await enforceMessageQuotaForCliInput(larkAppId, chatId, senderOpenId, messageId, anchor, senderUnionId, memberUnionId, chatType, botSender)) {
12454
12874
  return;
12455
12875
  }
12456
12876
  const botCfg = getBot(larkAppId).config;
@@ -12516,7 +12936,25 @@ async function startInitialPassthroughSession(args) {
12516
12936
  }
12517
12937
  beginReplyTargetTurn(ds, replyRootId, messageId);
12518
12938
  sessionStore.updateSession(ds.session);
12519
- activeSessions.set(sessionKey(anchor, larkAppId), ds);
12939
+ const creationKey = sessionKey(anchor, larkAppId);
12940
+ if (!setActiveSessionIfActive(activeSessions, creationKey, ds)) {
12941
+ const winner = await rollbackRejectedSessionAndGetWinner(activeSessions, creationKey, ds);
12942
+ if (winner) {
12943
+ logger.info(`[${session.sessionId.substring(0, 8)}] Initial passthrough registration lost to ` +
12944
+ `${winner.session.sessionId.substring(0, 8)}; handing ${cmd} to routing winner`);
12945
+ deliverPassthroughToExistingSession(winner, cmd, commandContent, anchor, larkAppId, {
12946
+ messageId,
12947
+ replyRootId,
12948
+ senderOpenId,
12949
+ senderIsBot: parsed.senderType === 'app' || parsed.senderType === 'bot',
12950
+ substitute,
12951
+ });
12952
+ }
12953
+ else {
12954
+ void sessionReply(anchor, tr('daemon.cmd_needs_active_cli', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
12955
+ }
12956
+ return;
12957
+ }
12520
12958
  if (pinnedWorkingDir && autoWt) {
12521
12959
  if (await replyInvalidWorkingDirs(anchor, larkAppId, ds))
12522
12960
  return;
@@ -12574,7 +13012,7 @@ function mergeVcMeetingApplicationContext(existing, incoming) {
12574
13012
  return `${[...new Set(lines)].join('\n')}\n`;
12575
13013
  }
12576
13014
  async function handleNewTopic(data, ctx) {
12577
- const { chatId, messageId, chatType, larkAppId, replyRootId, substituteTrigger } = ctx;
13015
+ const { chatId, messageId, chatType, larkAppId, replyRootId, substituteTrigger, messageListener } = ctx;
12578
13016
  // scope/anchor are mutable here: `/t` / `/topic` may flip a 普通群 chat-scope
12579
13017
  // routing into thread-scope so the bot's first reply seeds a Lark thread.
12580
13018
  let scope = ctx.scope;
@@ -12645,8 +13083,18 @@ async function handleNewTopic(data, ctx) {
12645
13083
  // operate),破坏「人绝不继承 bot 信任」的边界。旧联邦 team-bots 表结构上只收
12646
13084
  // bot(学习入口限 bot sender),这里对齐同一不变量。senderUnionId 本身保持原义
12647
13085
  //(人类会话的 ownerUnionId 还靠它)。
12648
- const teamTrustUnionId = (data.sender?.sender_type === 'app' || data.sender?.sender_type === 'bot') ? senderUnionId : undefined;
13086
+ // 飞书盖章的 bot 发送方。既锁 union 信任腿(下面的 teamTrustUnionId),也决定
13087
+ // talk 复查走哪个谓词(evaluateBotTalk vs evaluateTalk)——两者必须同源,见
13088
+ // enforceMessageQuotaForCliInput 的 botSender 说明。
13089
+ const isBotSenderType = data.sender?.sender_type === 'app' || data.sender?.sender_type === 'bot';
13090
+ const teamTrustUnionId = isBotSenderType ? senderUnionId : undefined;
12649
13091
  const botCfg = getBot(larkAppId).config;
13092
+ const listenerPrompt = messageListener ? renderMessageListenerPrompt(messageListener) : undefined;
13093
+ if (listenerPrompt) {
13094
+ content = listenerPrompt;
13095
+ parsed.content = listenerPrompt;
13096
+ cmdContent = listenerPrompt;
13097
+ }
12650
13098
  logger.info(`New session: "${content.substring(0, 60)}" (scope=${scope}, anchor=${anchor.substring(0, 12)}, resources: ${resources.length}, active: ${getActiveCount()}, messageId: ${messageId}, chatId: ${chatId})`);
12651
13099
  emitHookEvent('topic.new', {
12652
13100
  larkAppId,
@@ -12673,6 +13121,7 @@ async function handleNewTopic(data, ctx) {
12673
13121
  initiatorOpenId: senderOpenId,
12674
13122
  teamTrustUnionId,
12675
13123
  memberUnionId: senderUnionId,
13124
+ botSender: isBotSenderType,
12676
13125
  })) {
12677
13126
  return;
12678
13127
  }
@@ -12754,10 +13203,13 @@ async function handleNewTopic(data, ctx) {
12754
13203
  messageId,
12755
13204
  replyRootId,
12756
13205
  parsed,
13206
+ cmd,
12757
13207
  commandContent,
12758
13208
  senderOpenId,
13209
+ substitute: !!substituteTrigger,
12759
13210
  senderUnionId: teamTrustUnionId,
12760
13211
  memberUnionId: senderUnionId, // 原始 union(人腿),不锁 bot
13212
+ botSender: isBotSenderType,
12761
13213
  // New-topic senders are humans here (mirrors the normal new-topic
12762
13214
  // spawn path, which assigns ownership unconditionally too).
12763
13215
  ownerOpenId: senderOpenId,
@@ -12777,7 +13229,7 @@ async function handleNewTopic(data, ctx) {
12777
13229
  // which left a hole once per-chat grants flow through canTalk.
12778
13230
  // canRunDaemonCommand = canOperate ∪(cmd ∈ canTalkDaemonCommands && canTalk):
12779
13231
  // bot 可通过名单把选定命令(如 /status)降到 canTalk;未配置时与 canOperate 全等。
12780
- if (!canRunDaemonCommand(larkAppId, chatId, senderOpenId, teamTrustUnionId, cmd, senderUnionId, chatType)) {
13232
+ if (!canRunDaemonCommand(larkAppId, chatId, senderOpenId, teamTrustUnionId, cmd, senderUnionId, chatType, isBotSenderType)) {
12781
13233
  await sessionReply(anchor, tr('daemon.cmd_allowed_users_only', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
12782
13234
  return;
12783
13235
  }
@@ -12836,7 +13288,7 @@ async function handleNewTopic(data, ctx) {
12836
13288
  cmdPending = { pendingRepo: true, pendingPrompt: '', workingDir: pinnedWorkingDir };
12837
13289
  }
12838
13290
  sessionStore.updateSession(session);
12839
- activeSessions.set(sessionKey(anchor, larkAppId), {
13291
+ const commandDs = {
12840
13292
  session,
12841
13293
  worker: null,
12842
13294
  workerPort: null,
@@ -12851,13 +13303,23 @@ async function handleNewTopic(data, ctx) {
12851
13303
  hasHistory: false,
12852
13304
  ownerOpenId: senderOpenId,
12853
13305
  ...cmdPending,
12854
- });
13306
+ };
13307
+ const commandKey = sessionKey(anchor, larkAppId);
13308
+ if (!setActiveSessionIfActive(activeSessions, commandKey, commandDs)) {
13309
+ const winner = await rollbackRejectedSessionAndGetWinner(activeSessions, commandKey, commandDs);
13310
+ if (!winner)
13311
+ return;
13312
+ logger.info(`[${session.sessionId.substring(0, 8)}] Daemon-command registration lost to ` +
13313
+ `${winner.session.sessionId.substring(0, 8)}; handing ${cmd} to routing winner`);
13314
+ }
12855
13315
  // Pass mention-stripped content so /command argument parsing works.
12856
13316
  await handleCommand(cmd, anchor, { ...parsed, content: commandContent }, commandDeps, larkAppId);
12857
13317
  return;
12858
13318
  }
12859
13319
  }
12860
- if (!await enforceMessageQuotaForCliInput(larkAppId, chatId, senderOpenId, messageId, anchor, teamTrustUnionId, senderUnionId, chatType)) {
13320
+ if (!await enforceMessageQuotaForCliInput(larkAppId, chatId, senderOpenId, messageId, anchor, teamTrustUnionId, senderUnionId, chatType, isBotSenderType, {
13321
+ listenerAuthorized: !!messageListener,
13322
+ })) {
12861
13323
  return;
12862
13324
  }
12863
13325
  // Download attachments
@@ -12879,9 +13341,21 @@ async function handleNewTopic(data, ctx) {
12879
13341
  const codexAppQuoteContext = ctx.forwardSeedData
12880
13342
  ? ''
12881
13343
  : buildQuoteHint(parsed, scope, anchor, localeForBot(larkAppId));
12882
- const codexAppMessageContext = codexAppQuoteContext + (workflowGrillPrompt ?? '');
12883
13344
  const codexAppApplicationContext = vcMeetingApplicationContext(ctx);
12884
- const promptContent = codexAppQuoteContext + codexAppApplicationContext + content;
13345
+ // 普通群「对已有消息发起话题再 @」:入站是话题内回复(root_id 指向另一条更早
13346
+ // 的话题根消息),bot 从未留存该话题历史,且正文里没有任何信号表明存在话题根+
13347
+ // 前情。首轮注入一行 hint(非全文、零网络请求),提示 CLI 可用 `botmux history`
13348
+ // 按需拉取话题历史(对齐 quote hint 的「提示+按需」模式,避免无条件全量预取+
13349
+ // 下载所有历史附件+长话题只取最旧 50 条且把 50 当精确总数的问题)。gate 已证明
13350
+ // 这是话题回复,无需再发网络探测。coalesced forward 已自带上下文不注入。仅首轮
13351
+ // — 后续 handleThreadReply 回合 CLI 已在首轮拿到该 hint,不重复注入。
13352
+ const topicThreadContext = (parsed.rootId && parsed.rootId !== parsed.messageId && !ctx.forwardSeedData)
13353
+ ? buildTopicThreadContext(localeForBot(larkAppId))
13354
+ : '';
13355
+ // 话题 hint 同样前置到 codex-app 结构化 sidecar lane(与 quote hint 一致双 lane
13356
+ // 下发),否则 codex-app(clean input)bot 走 sidecar 时会静默丢掉该 hint。
13357
+ const codexAppMessageContext = topicThreadContext + codexAppQuoteContext + (workflowGrillPrompt ?? '');
13358
+ const promptContent = topicThreadContext + codexAppQuoteContext + codexAppApplicationContext + content;
12885
13359
  // Resolve sender identity for <sender> tag injection. The first call to
12886
13360
  // resolveSender for an unseen open_id may await contact.v3.user.get with a
12887
13361
  // short budget; subsequent calls hit the cache and are sync-fast.
@@ -12891,7 +13365,14 @@ async function handleNewTopic(data, ctx) {
12891
13365
  // Pin the working dir via the layered oncall / inherit / default lookup
12892
13366
  // (auto-binds a defaultOncall chat as a side effect). Shared with the
12893
13367
  // first-message `/repo` command branch so both paths stay consistent.
12894
- const { pinnedWorkingDir, oncallEntry, inheritedFrom, pinnedFromBotDefault } = await resolvePinnedWorkingDir({ scope, anchor, chatId, chatType, larkAppId });
13368
+ const { pinnedWorkingDir, oncallEntry, inheritedFrom, pinnedFromBotDefault } = await resolvePinnedWorkingDir({
13369
+ scope,
13370
+ anchor,
13371
+ chatId,
13372
+ chatType,
13373
+ larkAppId,
13374
+ listenerWorkingDir: messageListener?.workingDir,
13375
+ });
12895
13376
  // Auto-worktree: register PENDING (router buffers concurrent msgs, no force-fork)
12896
13377
  // and build the worktree off the critical path (willAutoWorktree / runAutoWorktreeCommit).
12897
13378
  const autoWt = willAutoWorktree(larkAppId, pinnedWorkingDir, pinnedFromBotDefault);
@@ -12905,7 +13386,8 @@ async function handleNewTopic(data, ctx) {
12905
13386
  // For chat-scope, rootMessageId stores the seed message_id (audit only);
12906
13387
  // routing keys off chatId via sessionAnchorId(), so any value works.
12907
13388
  const rootIdForStore = scope === 'thread' ? anchor : messageId;
12908
- const session = sessionStore.createSession(chatId, rootIdForStore, parsed.content.substring(0, 50), chatType);
13389
+ const initialTurnTitle = (messageListener?.replyCardTitle ?? (ctx.forwardSeedData ? followupContent : content)).substring(0, 50);
13390
+ const session = sessionStore.createSession(chatId, rootIdForStore, initialTurnTitle, chatType);
12909
13391
  const now = Date.now();
12910
13392
  setDirectChatDisplayNameFromSender(session, chatType, newTopicSender);
12911
13393
  const groupChatName = await groupChatNamePromise;
@@ -12960,7 +13442,7 @@ async function handleNewTopic(data, ctx) {
12960
13442
  pendingSubstituteControlCard: shouldSendSubstituteControlCard,
12961
13443
  pendingSender: newTopicSender,
12962
13444
  ownerOpenId: senderOpenId,
12963
- currentTurnTitle: (ctx.forwardSeedData ? followupContent : content).substring(0, 50),
13445
+ currentTurnTitle: initialTurnTitle,
12964
13446
  workingDir: pinnedWorkingDir,
12965
13447
  };
12966
13448
  if (pinnedWorkingDir) {
@@ -12972,7 +13454,24 @@ async function handleNewTopic(data, ctx) {
12972
13454
  : 'thread';
12973
13455
  beginReplyTargetTurn(ds, replyRootId, messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger });
12974
13456
  sessionStore.updateSession(ds.session);
12975
- activeSessions.set(sessionKey(anchor, larkAppId), ds);
13457
+ const creationKey = sessionKey(anchor, larkAppId);
13458
+ if (!setActiveSessionIfActive(activeSessions, creationKey, ds)) {
13459
+ const winner = await rollbackRejectedSessionAndGetWinner(activeSessions, creationKey, ds);
13460
+ if (winner) {
13461
+ logger.info(`[${session.sessionId.substring(0, 8)}] New-topic registration lost to ` +
13462
+ `${winner.session.sessionId.substring(0, 8)}; handing message to routing winner`);
13463
+ await handleThreadReply(data, { ...ctx, scope, anchor }, {
13464
+ parsed,
13465
+ resources,
13466
+ attachments,
13467
+ quotaChecked: true,
13468
+ queueAlreadyAppended: true,
13469
+ senderResolved: true,
13470
+ sender: newTopicSender,
13471
+ });
13472
+ }
13473
+ return;
13474
+ }
12976
13475
  // Auto-worktree: session is registered PENDING; build the worktree off the
12977
13476
  // critical path, then commitRepoSelection pins it + forks (folding in any
12978
13477
  // messages buffered during creation). detach → return immediately.
@@ -13034,6 +13533,74 @@ async function handleNewTopic(data, ctx) {
13034
13533
  // for the same chat (reconnect replay / double-delivery) can't both spawn —
13035
13534
  // claimed synchronously before the first await, released in `finally` (FR-13).
13036
13535
  const autoStartJoinInFlight = new Set();
13536
+ // A join candidate becomes visible in activeSessions before its seed/reply
13537
+ // target and synchronous bootstrap are ready. Inbound turns for that exact
13538
+ // routing key must wait; otherwise they can re-fork the worker-null session and
13539
+ // then be replaced by the join handler's own fork. Deferred pendingRepo /
13540
+ // auto-worktree paths release after publishing their stable buffered state.
13541
+ const AUTO_START_JOIN_READY_MAX_WAIT_MS = 5_000;
13542
+ let testOnlyAutoStartJoinReadyMaxWaitMs;
13543
+ const autoStartJoinReadyBySessionKey = new Map();
13544
+ function beginAutoStartJoinReadyBarrier(routingKey, onCancel) {
13545
+ let resolveReady;
13546
+ const ready = new Promise((resolve) => {
13547
+ resolveReady = resolve;
13548
+ });
13549
+ let settled = false;
13550
+ let barrier;
13551
+ const settle = () => {
13552
+ if (settled)
13553
+ return;
13554
+ settled = true;
13555
+ resolveReady();
13556
+ if (autoStartJoinReadyBySessionKey.get(routingKey) === barrier) {
13557
+ autoStartJoinReadyBySessionKey.delete(routingKey);
13558
+ }
13559
+ };
13560
+ const cancel = () => {
13561
+ if (settled)
13562
+ return;
13563
+ barrier.cancelled = true;
13564
+ try {
13565
+ onCancel();
13566
+ }
13567
+ catch (err) {
13568
+ logger.warn(`[auto-start:入群] bootstrap timeout cleanup failed for ${routingKey}: ${err}`);
13569
+ }
13570
+ finally {
13571
+ settle();
13572
+ }
13573
+ };
13574
+ barrier = { ready, cancelled: false, settle, cancel };
13575
+ autoStartJoinReadyBySessionKey.set(routingKey, barrier);
13576
+ return barrier;
13577
+ }
13578
+ async function waitForAutoStartJoinReady(larkAppId, anchor) {
13579
+ const routingKey = sessionKey(anchor, larkAppId);
13580
+ while (true) {
13581
+ const barrier = autoStartJoinReadyBySessionKey.get(routingKey);
13582
+ if (!barrier)
13583
+ return;
13584
+ const maxWaitMs = testOnlyAutoStartJoinReadyMaxWaitMs ?? AUTO_START_JOIN_READY_MAX_WAIT_MS;
13585
+ let timer;
13586
+ const outcome = await Promise.race([
13587
+ barrier.ready.then(() => 'ready'),
13588
+ new Promise((resolve) => {
13589
+ timer = setTimeout(() => resolve('timeout'), maxWaitMs);
13590
+ }),
13591
+ ]);
13592
+ if (timer)
13593
+ clearTimeout(timer);
13594
+ if (outcome === 'timeout' && autoStartJoinReadyBySessionKey.get(routingKey) === barrier) {
13595
+ logger.warn(`[auto-start:入群] bootstrap exceeded ${maxWaitMs}ms for ${anchor.substring(0, 12)}; ` +
13596
+ `yielding registration to the waiting turn`);
13597
+ barrier.cancel();
13598
+ }
13599
+ }
13600
+ }
13601
+ export function __testOnly_setAutoStartJoinReadyMaxWaitMs(value) {
13602
+ testOnlyAutoStartJoinReadyMaxWaitMs = value;
13603
+ }
13037
13604
  // 主动开工 — 场景①: `${appId}:${chatId}` → the activeSessions key of the
13038
13605
  // auto-started join session. Needed because in a 话题群 the session is keyed at
13039
13606
  // the seed message id (not chatId), so a plain `activeSessions.has(chatId)`
@@ -13102,6 +13669,7 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId) {
13102
13669
  if (priorAnchorKey)
13103
13670
  groupJoinAnchorByChat.delete(chatLiveKey); // stale entry, will re-register below
13104
13671
  autoStartJoinInFlight.add(lockKey);
13672
+ let joinReady;
13105
13673
  try {
13106
13674
  // D7 gate: an allowedUser must be a member of the chat. Alarm/oncall
13107
13675
  // platforms (e.g. Nexus) create the chat, add bots first and the human
@@ -13156,9 +13724,8 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId) {
13156
13724
  logger.info(`[auto-start:入群] ${chatId.substring(0, 12)} 锚点已有会话,跳过`);
13157
13725
  return;
13158
13726
  }
13159
- const sharedReplyRootId = mode === 'group' && resolveRegularGroupMode(larkAppId, chatId) === 'shared'
13160
- ? await sendMessage(larkAppId, chatId, tr('daemon.auto_start_join_seed', undefined, localeForBot(larkAppId)), 'text')
13161
- : undefined;
13727
+ const needsSharedReply = mode === 'group'
13728
+ && resolveRegularGroupMode(larkAppId, chatId) === 'shared';
13162
13729
  const { pinnedWorkingDir, pinnedFromBotDefault } = await resolvePinnedWorkingDir({ scope, anchor, chatId, chatType, larkAppId });
13163
13730
  const autoWt = willAutoWorktree(larkAppId, pinnedWorkingDir, pinnedFromBotDefault);
13164
13731
  refreshCliVersion(botCfg.cliId, botCfg.cliPathOverride);
@@ -13192,13 +13759,103 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId) {
13192
13759
  ownerOpenId: operatorOpenId,
13193
13760
  currentTurnTitle: title,
13194
13761
  workingDir: pinnedWorkingDir,
13195
- pendingTurnId: sharedReplyRootId,
13762
+ pendingTurnId: undefined,
13196
13763
  };
13197
- if (sharedReplyRootId) {
13764
+ if (!setActiveSessionIfActive(activeSessions, dsKey, ds)) {
13765
+ await closeSessionHelper(session.sessionId);
13766
+ return;
13767
+ }
13768
+ const joinBootstrapExternallyTakenOver = () => (activeSessions.get(dsKey) !== ds
13769
+ || ds.session.status !== 'active'
13770
+ || !!(ds.worker && !ds.worker.killed));
13771
+ const joinBootstrapWasTakenOver = () => {
13772
+ const takenOver = (joinReady?.cancelled === true
13773
+ || joinBootstrapExternallyTakenOver());
13774
+ if (takenOver && ds.worker && !ds.worker.killed) {
13775
+ logger.warn(`[auto-start:入群] ${chatId.substring(0, 12)} bootstrap 期间会话已由其它入口启动,让位且不再 refork`);
13776
+ }
13777
+ return takenOver;
13778
+ };
13779
+ // Publish the barrier only after this candidate wins registration. There is
13780
+ // no await between the CAS and this write, so a turn can never observe the
13781
+ // worker-null session without also observing the barrier. Publishing earlier
13782
+ // would unnecessarily stall real messages during membership retry and
13783
+ // mode/working-dir discovery, where their own session may safely win.
13784
+ joinReady = beginAutoStartJoinReadyBarrier(dsKey, () => {
13785
+ // Scheduler/dashboard/trigger paths do not wait on the inbound-message
13786
+ // barrier and may legitimately start this exact ds while join bootstrap
13787
+ // is awaiting Lark/network I/O. Once that happens, timeout cancellation
13788
+ // must yield to the new worker instead of closing its active turn.
13789
+ if (joinBootstrapExternallyTakenOver()) {
13790
+ logger.warn(`[auto-start:入群] ${chatId.substring(0, 12)} bootstrap timeout 时会话已被接管,跳过候选回收`);
13791
+ return;
13792
+ }
13793
+ // A user turn waited longer than the serializer's normal safety cap.
13794
+ // Relinquish this half-bootstrapped registration instead of letting the
13795
+ // turn fall back to the old "refork now, then get killed by late join"
13796
+ // behavior. closeSessionHelper completes map/status/store mutation before
13797
+ // its first await; the late best-effort cleanup must not hold the turn.
13798
+ const closing = closeSessionHelper(session.sessionId);
13799
+ if (activeSessions.get(dsKey) === ds)
13800
+ activeSessions.delete(dsKey);
13801
+ void closing.catch((err) => {
13802
+ logger.warn(`[auto-start:入群] timeout close failed for ${session.sessionId.substring(0, 8)}: ${err}`);
13803
+ });
13804
+ });
13805
+ const rollbackRegisteredJoinSession = async () => {
13806
+ // closeSession transitions the row before its first await, but this
13807
+ // handler owns a caller-supplied registry too. Drop only our identity so
13808
+ // slow best-effort cleanup cannot block a retry or erase a replacement.
13809
+ const closing = closeSessionHelper(session.sessionId);
13810
+ if (activeSessions.get(dsKey) === ds)
13811
+ activeSessions.delete(dsKey);
13812
+ await closing;
13813
+ };
13814
+ // Shared-mode seed/reply authority is externally visible. Publish it only
13815
+ // after this candidate owns the routing key; a CAS loser must leave no
13816
+ // orphaned "joined" topic and must not silently consume the first prompt.
13817
+ let sharedReplyRootId;
13818
+ const withdrawSharedReplySeed = () => {
13819
+ if (!sharedReplyRootId)
13820
+ return;
13821
+ const messageId = sharedReplyRootId;
13822
+ sharedReplyRootId = undefined;
13823
+ void deleteMessage(larkAppId, messageId);
13824
+ };
13825
+ const armSharedReplyTarget = () => {
13826
+ if (!sharedReplyRootId)
13827
+ return;
13828
+ ds.pendingTurnId = sharedReplyRootId;
13198
13829
  beginReplyTargetTurn(ds, sharedReplyRootId, sharedReplyRootId, new Date(now).toISOString());
13199
13830
  sessionStore.updateSession(ds.session);
13831
+ };
13832
+ if (needsSharedReply) {
13833
+ try {
13834
+ sharedReplyRootId = await sendMessage(larkAppId, chatId, tr('daemon.auto_start_join_seed', undefined, localeForBot(larkAppId)), 'text');
13835
+ }
13836
+ catch (err) {
13837
+ logger.warn(`[auto-start:入群] ${chatId.substring(0, 12)} shared seed 发送失败:${err?.message ?? err}`);
13838
+ await rollbackRegisteredJoinSession();
13839
+ return;
13840
+ }
13841
+ if (joinReady.cancelled) {
13842
+ // The send may have reached Lark even though its response exceeded the
13843
+ // local wait cap. Best-effort withdraw the now-ownerless shared seed.
13844
+ withdrawSharedReplySeed();
13845
+ return;
13846
+ }
13847
+ if (activeSessions.get(dsKey) !== ds || ds.session.status !== 'active') {
13848
+ logger.warn(`[auto-start:入群] ${chatId.substring(0, 12)} shared seed 返回时会话已被替换,停止首轮`);
13849
+ withdrawSharedReplySeed();
13850
+ await rollbackRegisteredJoinSession();
13851
+ return;
13852
+ }
13853
+ if (ds.worker && !ds.worker.killed) {
13854
+ withdrawSharedReplySeed();
13855
+ logger.warn(`[auto-start:入群] ${chatId.substring(0, 12)} shared seed 返回时会话已由其它入口启动,让位`);
13856
+ return;
13857
+ }
13200
13858
  }
13201
- activeSessions.set(dsKey, ds);
13202
13859
  // Register the anchor so a later duplicate bot.added for this chat is deduped
13203
13860
  // even in 话题群 (where dsKey is the seed id, not chatId).
13204
13861
  groupJoinAnchorByChat.set(chatLiveKey, dsKey);
@@ -13206,18 +13863,32 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId) {
13206
13863
  const buildPrompt = async () => buildNewTopicCliInput(promptBody, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, undefined, undefined, await getAvailableBots(larkAppId, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), undefined, { larkAppId, chatId, whiteboardId: ds.session.whiteboardId, codexAppText });
13207
13864
  // Auto-worktree: register PENDING, build worktree off-path, commit+fork later.
13208
13865
  if (pinnedWorkingDir && autoWt) {
13209
- if (await replyInvalidWorkingDirs(anchor, larkAppId, ds))
13866
+ if (await replyInvalidWorkingDirs(anchor, larkAppId, ds) || joinBootstrapWasTakenOver()) {
13867
+ withdrawSharedReplySeed();
13210
13868
  return;
13869
+ }
13870
+ armSharedReplyTarget();
13211
13871
  startAutoWorktreePending(ds, { anchor, baseDir: pinnedWorkingDir, title, prompt: promptBody, operatorOpenId });
13212
13872
  return;
13213
13873
  }
13214
13874
  // Pinned working dir → spawn immediately.
13215
13875
  if (pinnedWorkingDir) {
13216
- if (await replyInvalidWorkingDirs(anchor, larkAppId, ds))
13876
+ if (await replyInvalidWorkingDirs(anchor, larkAppId, ds) || joinBootstrapWasTakenOver()) {
13877
+ withdrawSharedReplySeed();
13217
13878
  return;
13879
+ }
13218
13880
  ensureSessionWhiteboard(ds);
13219
13881
  const prompt = await buildPrompt();
13882
+ if (joinBootstrapWasTakenOver()) {
13883
+ withdrawSharedReplySeed();
13884
+ return;
13885
+ }
13220
13886
  await noteTurnReceived(ds, anchor, promptBody);
13887
+ if (joinBootstrapWasTakenOver()) {
13888
+ withdrawSharedReplySeed();
13889
+ return;
13890
+ }
13891
+ armSharedReplyTarget();
13221
13892
  rememberLastCliInput(ds, promptBody, prompt);
13222
13893
  forkWorker(ds, prompt, sharedReplyRootId ? { turnId: sharedReplyRootId } : false);
13223
13894
  ds.pendingTurnId = undefined;
@@ -13225,14 +13896,30 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId) {
13225
13896
  return;
13226
13897
  }
13227
13898
  // No default dir → degrade to repo-selection card (D6 / FR-4).
13228
- if (await replyInvalidWorkingDirs(anchor, larkAppId, ds))
13899
+ if (await replyInvalidWorkingDirs(anchor, larkAppId, ds) || joinBootstrapWasTakenOver()) {
13900
+ withdrawSharedReplySeed();
13229
13901
  return;
13902
+ }
13230
13903
  const scanDirs = getProjectScanDirs(ds).filter(d => existsSync(d));
13231
13904
  const projects = scanDirs.length > 0 ? scanMultipleProjects(scanDirs, 3, repoPickerScanOptions()) : [];
13232
13905
  if (projects.length > 0) {
13233
13906
  lastRepoScan.set(chatId, projects);
13234
13907
  const cardJson = buildRepoSelectCard(projects, getSessionWorkingDir(ds), anchor, localeForBot(larkAppId), getBot(larkAppId).config.worktreeMultiPicker);
13235
- ds.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
13908
+ // The repo card itself belongs under the shared join seed. Arm the target
13909
+ // immediately before its single network await; pendingRepo already makes
13910
+ // this a stable buffered state for normal inbound/scheduler paths.
13911
+ armSharedReplyTarget();
13912
+ const repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
13913
+ if (joinBootstrapWasTakenOver()) {
13914
+ void deleteMessage(larkAppId, repoCardMessageId);
13915
+ // If another entry actually started this same ds, its output still owns
13916
+ // the shared seed we just armed. Preserve that root; closed/replaced
13917
+ // candidates may withdraw it.
13918
+ if (!ds.worker || ds.worker.killed)
13919
+ withdrawSharedReplySeed();
13920
+ return;
13921
+ }
13922
+ ds.repoCardMessageId = repoCardMessageId;
13236
13923
  announcePendingRepoSession(ds);
13237
13924
  logger.info(`[auto-start:入群] ${chatId.substring(0, 12)} 无默认目录,弹 repo 选择卡(${projects.length} 个项目)`);
13238
13925
  }
@@ -13240,7 +13927,16 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId) {
13240
13927
  ds.pendingRepo = false;
13241
13928
  ensureSessionWhiteboard(ds);
13242
13929
  const prompt = await buildPrompt();
13930
+ if (joinBootstrapWasTakenOver()) {
13931
+ withdrawSharedReplySeed();
13932
+ return;
13933
+ }
13243
13934
  await noteTurnReceived(ds, anchor, promptBody);
13935
+ if (joinBootstrapWasTakenOver()) {
13936
+ withdrawSharedReplySeed();
13937
+ return;
13938
+ }
13939
+ armSharedReplyTarget();
13244
13940
  rememberLastCliInput(ds, promptBody, prompt);
13245
13941
  forkWorker(ds, prompt, sharedReplyRootId ? { turnId: sharedReplyRootId } : false);
13246
13942
  ds.pendingTurnId = undefined;
@@ -13248,6 +13944,7 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId) {
13248
13944
  }
13249
13945
  }
13250
13946
  finally {
13947
+ joinReady?.settle();
13251
13948
  autoStartJoinInFlight.delete(lockKey);
13252
13949
  }
13253
13950
  }
@@ -13287,16 +13984,21 @@ function lookupForeignBotName(senderOpenId, larkAppId) {
13287
13984
  catch { /* */ }
13288
13985
  return 'Bot';
13289
13986
  }
13290
- async function handleThreadReply(data, ctx) {
13987
+ async function handleThreadReply(data, ctx, prepared) {
13291
13988
  const { chatId: ctxChatId, chatType: ctxChatType, scope, anchor, larkAppId, replyRootId, substituteTrigger } = ctx;
13292
- await resolveNonsupportMessage(data, larkAppId);
13293
- const { parsed, resources } = parseEventMessage(data);
13989
+ await waitForAutoStartJoinReady(larkAppId, anchor);
13990
+ if (!prepared)
13991
+ await resolveNonsupportMessage(data, larkAppId);
13992
+ const parsedResult = prepared ?? parseEventMessage(data);
13993
+ const parsed = parsedResult.parsed;
13994
+ const resources = parsedResult.resources;
13294
13995
  // Expand merge_forward: fetch sub-messages and collect their resources
13295
- if (parsed.msgType === 'merge_forward') {
13996
+ if (!prepared && parsed.msgType === 'merge_forward') {
13296
13997
  const { extraResources } = await expandMergeForward(larkAppId, parsed.messageId, parsed);
13297
13998
  resources.push(...extraResources);
13298
13999
  }
13299
- learnFromMentions(larkAppId, parsed.mentions);
14000
+ if (!prepared)
14001
+ learnFromMentions(larkAppId, parsed.mentions);
13300
14002
  // Foreign bot @mention prefix: when sender is another botmux bot,把内容包成
13301
14003
  // [来自 X 的 @mention]\n<原文> 喂给 worker,让 CLI 知道这是另一个 bot 发的——
13302
14004
  // 不是用户直接发的——后续不需要按"对话用户"的方式处理。signal-file 路径
@@ -13330,33 +14032,35 @@ async function handleThreadReply(data, ctx) {
13330
14032
  + parsed.content;
13331
14033
  let promptContent = initialPromptContent;
13332
14034
  let rewrittenCodexAppMessageContext;
13333
- const existingHookSession = activeSessions.get(sessionKey(anchor, larkAppId));
13334
- emitHookEvent('thread.reply', {
13335
- larkAppId,
13336
- chatId: ctxChatId,
13337
- chatType: ctxChatType,
13338
- scope,
13339
- anchor,
13340
- messageId: parsed.messageId,
13341
- rootId: parsed.rootId,
13342
- parentId: parsed.parentId,
13343
- senderOpenId: senderOpenIdForPrefix,
13344
- senderType: parsed.senderType,
13345
- msgType: parsed.msgType,
13346
- sessionId: existingHookSession?.session.sessionId,
13347
- content: parsed.content,
13348
- });
13349
- if (isForeignBot) {
13350
- logger.info(`[${larkAppId}] foreign-bot @mention prefix attached: sender=${senderOpenIdForPrefix?.substring(0, 12)} ` +
13351
- `senderType=${parsed.senderType} via=${isBotSenderType ? 'sender_type' : 'cross-ref'}`);
14035
+ if (!prepared) {
14036
+ const existingHookSession = activeSessions.get(sessionKey(anchor, larkAppId));
14037
+ emitHookEvent('thread.reply', {
14038
+ larkAppId,
14039
+ chatId: ctxChatId,
14040
+ chatType: ctxChatType,
14041
+ scope,
14042
+ anchor,
14043
+ messageId: parsed.messageId,
14044
+ rootId: parsed.rootId,
14045
+ parentId: parsed.parentId,
14046
+ senderOpenId: senderOpenIdForPrefix,
14047
+ senderType: parsed.senderType,
14048
+ msgType: parsed.msgType,
14049
+ sessionId: existingHookSession?.session.sessionId,
14050
+ content: parsed.content,
14051
+ });
14052
+ if (isForeignBot) {
14053
+ logger.info(`[${larkAppId}] foreign-bot @mention prefix attached: sender=${senderOpenIdForPrefix?.substring(0, 12)} ` +
14054
+ `senderType=${parsed.senderType} via=${isBotSenderType ? 'sender_type' : 'cross-ref'}`);
14055
+ }
13352
14056
  }
13353
14057
  // resolveSender is deferred until we know the message actually needs prompt
13354
14058
  // injection. callback URLs, daemon commands, and "other bot owns this
13355
14059
  // anchor" all return early; routing them through resolveSender first would
13356
14060
  // tack the 800ms budget onto paths that never see the sender tag. Use the
13357
14061
  // helper below at every actual injection point.
13358
- let threadSenderCached;
13359
- let threadSenderResolved = false;
14062
+ let threadSenderCached = prepared?.sender;
14063
+ let threadSenderResolved = prepared?.senderResolved ?? false;
13360
14064
  const getThreadSender = async () => {
13361
14065
  if (threadSenderResolved)
13362
14066
  return threadSenderCached;
@@ -13428,6 +14132,7 @@ async function handleThreadReply(data, ctx) {
13428
14132
  initiatorOpenId: threadSenderOpenId,
13429
14133
  teamTrustUnionId: threadTeamTrustUnionId,
13430
14134
  memberUnionId: threadSenderUnionId,
14135
+ botSender: isBotSenderType || isForeignBot,
13431
14136
  })) {
13432
14137
  return;
13433
14138
  }
@@ -13497,10 +14202,13 @@ async function handleThreadReply(data, ctx) {
13497
14202
  messageId: parsed.messageId,
13498
14203
  replyRootId,
13499
14204
  parsed,
14205
+ cmd,
13500
14206
  commandContent,
13501
14207
  senderOpenId: threadSenderOpenId,
14208
+ substitute: !!substituteTrigger,
13502
14209
  senderUnionId: threadTeamTrustUnionId,
13503
14210
  memberUnionId: threadSenderUnionId, // 原始 union(人腿),不锁 bot
14211
+ botSender: isBotSenderType || isForeignBot,
13504
14212
  // Bot-started cold starts get no human owner (mirrors the auto-create
13505
14213
  // path) — see the ownership note on startInitialPassthroughSession.
13506
14214
  ownerOpenId: isForeignBot ? undefined : threadSenderOpenId,
@@ -13517,48 +14225,17 @@ async function handleThreadReply(data, ctx) {
13517
14225
  // 拉起)。TODO(后续产品决策):是否把 CLI passthrough 也纳入 canOperate,
13518
14226
  // 收紧到与 daemon 命令同档;这会同时改变真人 oncall 成员的现有行为,应单独评估。
13519
14227
  const ds = existingDs;
13520
- if (ds?.worker && !ds.worker.killed) {
13521
- // Passthrough commands bypass the normal message-forwarding block
13522
- // below, so bind this accepted Lark turn here before the worker rotates
13523
- // its marker at the actual PTY write boundary.
13524
- ds.session.quoteTargetId = parsed.messageId;
13525
- ds.session.quoteTargetSenderOpenId = threadSenderOpenId;
13526
- ds.session.quoteTargetSenderIsBot = isForeignBot;
13527
- const substituteReplyMode = substituteTrigger
13528
- ? (getBot(larkAppId).config.substituteMode?.replyMode ?? 'thread')
13529
- : 'thread';
13530
- beginReplyTargetTurn(ds, replyRootId, parsed.messageId, new Date().toISOString(), {
13531
- quoteOnly: substituteReplyMode === 'quote',
14228
+ if (ds) {
14229
+ deliverPassthroughToExistingSession(ds, cmd, commandContent, anchor, larkAppId, {
14230
+ messageId: parsed.messageId,
14231
+ replyRootId,
14232
+ senderOpenId: threadSenderOpenId,
14233
+ senderIsBot: isForeignBot,
13532
14234
  substitute: !!substituteTrigger,
13533
14235
  });
13534
- if (threadSenderOpenId && ds.session.lastCallerOpenId !== threadSenderOpenId) {
13535
- ds.session.lastCallerOpenId = threadSenderOpenId;
13536
- }
13537
- sessionStore.updateSession(ds.session);
13538
- // Mark a new turn so the CLI's response to /model, /clear, /compact, etc.
13539
- // shows up as a fresh streaming card instead of silently PATCH-ing the
13540
- // previous turn's card.
13541
- //
13542
- // Compatibility note (empty-start opening): a raw passthrough is a
13543
- // LITERAL CLI command — its contract is that the CLI sees exactly the
13544
- // bytes the user typed, never a botmux XML envelope. So it deliberately
13545
- // does NOT consume `Session.initialUserTurnPending`: wrapping `/model`
13546
- // in `<user_message>` would break the literal contract, and silently
13547
- // clearing the marker would lose the opening for the next real turn.
13548
- // `/model` on an empty-started session therefore stays literal and the
13549
- // FOLLOWING business message still opens as a new topic.
13550
- beginNewTurn(ds, commandContent);
13551
- ds.worker.send({
13552
- type: 'raw_input',
13553
- content: commandContent,
13554
- turnId: parsed.messageId,
13555
- });
13556
- markSessionActivity(ds);
13557
- logger.info(`[${anchor.substring(0, 12)}] Passthrough ${cmd} → worker`);
13558
- }
13559
- else {
13560
- sessionReply(anchor, tr('daemon.cmd_needs_active_cli', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
13561
14236
  }
14237
+ else
14238
+ void sessionReply(anchor, tr('daemon.cmd_needs_active_cli', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
13562
14239
  return;
13563
14240
  }
13564
14241
  if (DAEMON_COMMANDS.has(cmd)) {
@@ -13576,7 +14253,7 @@ async function handleThreadReply(data, ctx) {
13576
14253
  // (see spawn-path gate above). Denies chat-granted users management commands.
13577
14254
  // canRunDaemonCommand:canTalkDaemonCommands 名单内的命令降到 canTalk,
13578
14255
  // 与 new-topic 路径的统一闸同款(未配置时与 canOperate 全等)。
13579
- if (!canRunDaemonCommand(larkAppId, effectiveThreadChatId, threadSenderOpenId, threadTeamTrustUnionId, cmd, threadSenderUnionId, ctxChatType)) {
14256
+ if (!canRunDaemonCommand(larkAppId, effectiveThreadChatId, threadSenderOpenId, threadTeamTrustUnionId, cmd, threadSenderUnionId, ctxChatType, isBotSenderType || isForeignBot)) {
13580
14257
  sessionReply(anchor, tr('daemon.cmd_allowed_users_only', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
13581
14258
  return;
13582
14259
  }
@@ -13611,7 +14288,7 @@ async function handleThreadReply(data, ctx) {
13611
14288
  cmdPending = { pendingRepo: true, pendingPrompt: '', workingDir: pinnedWorkingDir };
13612
14289
  }
13613
14290
  sessionStore.updateSession(session);
13614
- activeSessions.set(sessionKey(anchor, larkAppId), {
14291
+ const threadCommandDs = {
13615
14292
  session,
13616
14293
  worker: null,
13617
14294
  workerPort: null,
@@ -13626,7 +14303,15 @@ async function handleThreadReply(data, ctx) {
13626
14303
  hasHistory: false,
13627
14304
  ownerOpenId: threadSenderOpenId,
13628
14305
  ...cmdPending,
13629
- });
14306
+ };
14307
+ const commandKey = sessionKey(anchor, larkAppId);
14308
+ if (!setActiveSessionIfActive(activeSessions, commandKey, threadCommandDs)) {
14309
+ const winner = await rollbackRejectedSessionAndGetWinner(activeSessions, commandKey, threadCommandDs);
14310
+ if (!winner)
14311
+ return;
14312
+ logger.info(`[${session.sessionId.substring(0, 8)}] Thread daemon-command registration lost to ` +
14313
+ `${winner.session.sessionId.substring(0, 8)}; handing ${cmd} to routing winner`);
14314
+ }
13630
14315
  }
13631
14316
  // Pass mention-stripped content so /command argument parsing works.
13632
14317
  // chatId lets session-less handlers (e.g. /group) reach the chat roster.
@@ -13658,6 +14343,16 @@ async function handleThreadReply(data, ctx) {
13658
14343
  askId: pendingAsk.askId,
13659
14344
  by: threadSenderOpenId,
13660
14345
  text: askReplyText,
14346
+ // Actor context so the broker's talk check uses the same predicate as
14347
+ // the dispatcher gate / quota recheck: a bot text-reply → evaluateBotTalk
14348
+ // (covers team-拉群 with no union_id), a platform teamMember human →
14349
+ // evaluateTalk's teamMember union leg. Omitting it (card clicks) degrades
14350
+ // to the plain evaluateTalk(openId, chatType).
14351
+ actor: {
14352
+ botSender: isBotSenderType || isForeignBot,
14353
+ senderUnionId: threadTeamTrustUnionId,
14354
+ memberUnionId: threadSenderUnionId,
14355
+ },
13661
14356
  });
13662
14357
  if (outcome === 'accepted') {
13663
14358
  logger.info(`[${anchor.substring(0, 12)}] ask custom reply accepted from ${threadSenderOpenId.substring(0, 12)}`);
@@ -13688,7 +14383,7 @@ async function handleThreadReply(data, ctx) {
13688
14383
  }
13689
14384
  }
13690
14385
  const quotaSenderOpenId = threadSenderOpenId;
13691
- if (!await enforceMessageQuotaForCliInput(larkAppId, ctxChatId ?? data?.message?.chat_id, quotaSenderOpenId, parsed.messageId, anchor, threadTeamTrustUnionId, threadSenderUnionId, ctxChatType)) {
14386
+ if (!prepared?.quotaChecked && !await enforceMessageQuotaForCliInput(larkAppId, ctxChatId ?? data?.message?.chat_id, quotaSenderOpenId, parsed.messageId, anchor, threadTeamTrustUnionId, threadSenderUnionId, ctxChatType, isBotSenderType || isForeignBot)) {
13692
14387
  return;
13693
14388
  }
13694
14389
  // When a command path rewrites the model prompt (for example /workflow),
@@ -13700,12 +14395,19 @@ async function handleThreadReply(data, ctx) {
13700
14395
  const codexAppApplicationContext = initialCodexAppApplicationContext;
13701
14396
  // Download attachments
13702
14397
  const effectiveAppId = ds?.larkAppId ?? larkAppId;
13703
- const { attachments, needLogin } = await downloadResources(effectiveAppId, parsed.messageId, resources);
13704
- if (attachments.length > 0) {
13705
- parsed.attachments = attachments;
14398
+ let attachments;
14399
+ if (prepared) {
14400
+ attachments = prepared.attachments;
13706
14401
  }
13707
- if (needLogin) {
13708
- sessionReply(anchor, tr('daemon.download_failed_need_login', undefined, localeForBot(effectiveAppId)), 'text', effectiveAppId);
14402
+ else {
14403
+ const downloaded = await downloadResources(effectiveAppId, parsed.messageId, resources);
14404
+ attachments = downloaded.attachments;
14405
+ if (attachments.length > 0) {
14406
+ parsed.attachments = attachments;
14407
+ }
14408
+ if (downloaded.needLogin) {
14409
+ sessionReply(anchor, tr('daemon.download_failed_need_login', undefined, localeForBot(effectiveAppId)), 'text', effectiveAppId);
14410
+ }
13709
14411
  }
13710
14412
  // Update last message time + last caller (used by `botmux send` to address
13711
14413
  // reply cards to whoever triggered this turn — matters in oncall groups
@@ -13837,7 +14539,10 @@ async function handleThreadReply(data, ctx) {
13837
14539
  }
13838
14540
  // Route to file queue (keyed by anchor: rootMessageId for thread, chatId for chat)
13839
14541
  messageQueue.ensureQueue(anchor);
13840
- messageQueue.appendMessage(anchor, parsed);
14542
+ if (!prepared?.queueAlreadyAppended)
14543
+ messageQueue.appendMessage(anchor, parsed);
14544
+ if (ds)
14545
+ publishSessionMessagePreviewPatch(ds);
13841
14546
  if (!ds) {
13842
14547
  // No active session at this anchor — auto-create. This branch is mostly a
13843
14548
  // safety net; the dispatcher routes here only when isSessionOwner() returns
@@ -13942,7 +14647,24 @@ async function handleThreadReply(data, ctx) {
13942
14647
  : 'thread';
13943
14648
  beginReplyTargetTurn(newDs, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger });
13944
14649
  sessionStore.updateSession(newDs.session);
13945
- activeSessions.set(sessionKey(anchor, larkAppId), newDs);
14650
+ const creationKey = sessionKey(anchor, larkAppId);
14651
+ if (!setActiveSessionIfActive(activeSessions, creationKey, newDs)) {
14652
+ const winner = await rollbackRejectedSessionAndGetWinner(activeSessions, creationKey, newDs);
14653
+ if (winner) {
14654
+ logger.info(`[${session.sessionId.substring(0, 8)}] Reply auto-create registration lost to ` +
14655
+ `${winner.session.sessionId.substring(0, 8)}; handing message to routing winner`);
14656
+ await handleThreadReply(data, ctx, {
14657
+ parsed,
14658
+ resources,
14659
+ attachments,
14660
+ quotaChecked: true,
14661
+ queueAlreadyAppended: true,
14662
+ senderResolved: true,
14663
+ sender: autoCreateSender,
14664
+ });
14665
+ }
14666
+ return;
14667
+ }
13946
14668
  // Auto-worktree: register PENDING, build worktree off-path, commit+fork later.
13947
14669
  if (pinnedWorkingDir && autoWt) {
13948
14670
  if (await replyInvalidWorkingDirs(anchor, larkAppId, newDs))
@@ -14241,20 +14963,27 @@ async function handleThreadReply(data, ctx) {
14241
14963
  * 2) bot 的 defaultWorkingDir / workingDir 配置
14242
14964
  * 3) fallback 到 ~
14243
14965
  *
14244
- * 创建后立即 fork worker 并把评论内容作为首轮输入。
14245
- * 返回创建好的 DaemonSession(已加入 activeSessions),失败返回 null。
14966
+ * 本函数只同步建立 session skeleton 并竞争 routing ownership;不解析评论
14967
+ * 作者、不写 per-turn target、也不 fork。胜出的 authoritative
14968
+ * handleDocComment delivery owner 统一完成这些动作。
14969
+ * 返回胜出的 DaemonSession(已加入 activeSessions),失败返回 null。
14246
14970
  */
14247
14971
  async function autoCreateDocSession(sub, larkAppId, ctx) {
14248
14972
  const botCfg = getBot(larkAppId).config;
14973
+ const virtualChatId = `doc:${sub.fileToken}`;
14974
+ const virtualAnchor = virtualChatId;
14975
+ const routingKey = sessionKey(virtualAnchor, larkAppId);
14976
+ const existing = activeSessions.get(routingKey);
14977
+ if (existing?.session.status === 'active' && ownsCurrentRoute(existing, larkAppId)) {
14978
+ persistDocBindingToSession(sub, larkAppId, existing);
14979
+ return existing;
14980
+ }
14249
14981
  // 解析 workingDir:订阅时指定的 > bot 配置 defaultWorkingDir > bot 配置 workingDir > ~
14250
14982
  const workingDir = sub.workingDir
14251
14983
  ?? effectiveDefaultWorkingDir(botCfg)
14252
14984
  ?? botCfg.workingDir
14253
14985
  ?? '~';
14254
- const sender = ctx.authorOpenId ? await resolveSender(larkAppId, ctx.authorOpenId, 'user') : undefined;
14255
14986
  const title = `[Doc] ${sub.fileToken.slice(0, 8)}: ${ctx.text.slice(0, 40)}`;
14256
- const virtualChatId = `doc:${sub.fileToken}`;
14257
- const virtualAnchor = sub.sessionAnchor;
14258
14987
  const now = Date.now();
14259
14988
  const session = sessionStore.createSession(virtualChatId, virtualAnchor, title, 'group');
14260
14989
  session.larkAppId = larkAppId;
@@ -14281,36 +15010,57 @@ async function autoCreateDocSession(sub, larkAppId, ctx) {
14281
15010
  ownerOpenId: ctx.authorOpenId || sub.ownerOpenId,
14282
15011
  currentTurnTitle: ctx.text.substring(0, 50),
14283
15012
  };
14284
- // 记录本轮回评论落点
14285
- const turnId = ctx.replyId || ctx.commentId;
14286
- (ds.docCommentTurns ??= new Map()).set(turnId, {
14287
- fileToken: sub.fileToken,
14288
- fileType: sub.fileType,
14289
- commentId: ctx.commentId,
14290
- replyToOpenId: ctx.authorOpenId,
14291
- replyToName: sender?.name,
14292
- replyId: ctx.replyId,
14293
- reactionId: undefined, // 由调用方在加 reaction 后回填
14294
- });
14295
- const docTarget = {
14296
- fileToken: sub.fileToken,
14297
- fileType: sub.fileType,
14298
- commentId: ctx.commentId,
14299
- replyToName: sender?.name,
14300
- replyToOpenId: ctx.authorOpenId,
14301
- turnId,
14302
- replyId: ctx.replyId,
14303
- };
14304
- (ds.session.docCommentTargets ??= {})[turnId] = docTarget;
14305
- try {
14306
- sessionStore.updateSession(ds.session);
15013
+ const selected = await registerDocSessionCandidate(routingKey, ds);
15014
+ if (!selected) {
15015
+ logger.warn(`[doc-comment] auto-create registration lost without an active winner file=${sub.fileToken.slice(0, 12)}`);
15016
+ return null;
15017
+ }
15018
+ await persistSelectedDocBinding(routingKey, sub, larkAppId, selected, ds);
15019
+ if (selected !== ds) {
15020
+ logger.info(`[doc-comment] auto-create ${session.sessionId.slice(0, 8)} lost to ` +
15021
+ `${selected.session.sessionId.slice(0, 8)}; handing comment to routing winner`);
15022
+ return selected;
14307
15023
  }
14308
- catch { /* best-effort */ }
14309
- activeSessions.set(sessionKey(virtualAnchor, larkAppId), ds);
14310
- // 不在这里 forkWorker —— handleDocComment 会统一处理(它会检查 worker 状态、
14311
- // 加 reaction、设 docCommentTurns、然后 fork 或 send)。这里只建好 session 骨架。
15024
+ // 不在这里 forkWorker —— handleDocComment 会统一处理 reaction、per-turn
15025
+ // 回复落点及 fork/send。这里只建好 session skeleton。
14312
15026
  logger.info(`[doc-comment] auto-created session for file=${sub.fileToken.slice(0, 12)} (wd=${workingDir}, cli=${botCfg.cliId})`);
14313
- return ds;
15027
+ return selected;
15028
+ }
15029
+ /**
15030
+ * One authoritative delivery result per document-comment turn. Event delivery
15031
+ * and the --all poller can observe the same reply concurrently; followers must
15032
+ * await the owner's real result rather than treating an in-flight claim as a
15033
+ * success and advancing the poll cursor past a later failure.
15034
+ */
15035
+ const handledDocCommentTurns = new BoundedMap(5_000);
15036
+ async function runClaimedDocCommentTurn(claimKey, work, onOwnerFailure) {
15037
+ const existing = handledDocCommentTurns.get(claimKey);
15038
+ if (existing)
15039
+ return await existing;
15040
+ let resolveClaim;
15041
+ const claim = new Promise((resolve) => { resolveClaim = resolve; });
15042
+ handledDocCommentTurns.set(claimKey, claim);
15043
+ try {
15044
+ await work();
15045
+ resolveClaim(true);
15046
+ return true;
15047
+ }
15048
+ catch (err) {
15049
+ try {
15050
+ await onOwnerFailure?.(err);
15051
+ }
15052
+ finally {
15053
+ if (handledDocCommentTurns.get(claimKey) === claim) {
15054
+ handledDocCommentTurns.delete(claimKey);
15055
+ }
15056
+ resolveClaim(false);
15057
+ }
15058
+ throw err;
15059
+ }
15060
+ }
15061
+ export const __testOnly_runClaimedDocCommentTurn = runClaimedDocCommentTurn;
15062
+ export function __testOnly_resetDocCommentClaims() {
15063
+ handledDocCommentTurns.clear();
14314
15064
  }
14315
15065
  /**
14316
15066
  * 文档评论入口(/watch-comment / /subscribe-lark-doc):一条命中绑定的文档评论喂进会话。
@@ -14327,85 +15077,143 @@ async function handleDocComment(ctx) {
14327
15077
  const { larkAppId, sub, commentId, text } = ctx;
14328
15078
  const turnId = ctx.replyId || commentId;
14329
15079
  const claimKey = `${larkAppId}:${sub.fileToken}:${turnId}`;
14330
- if (handledDocCommentTurns.has(claimKey)) {
14331
- logger.info(`[doc-comment] duplicate turn skipped file=${sub.fileToken.slice(0, 12)} turn=${turnId.slice(0, 12)}`);
14332
- return true; // 已处理过,算成功(让 poller 推进游标)
14333
- }
14334
- const loc = localeForBot(larkAppId);
14335
- let ds = activeSessions.get(sessionKey(sub.sessionAnchor, larkAppId));
14336
- if (!ds) {
14337
- // 无活跃 session → 自动为该文档创建一个(用虚拟 anchor = doc:{fileToken})
14338
- logger.info(`[doc-comment] no active session for anchor=${sub.sessionAnchor.slice(0, 12)}; auto-creating for file=${sub.fileToken.slice(0, 12)}`);
14339
- ds = await autoCreateDocSession(sub, larkAppId, ctx);
14340
- if (!ds) {
14341
- // auto-create 失败:不设 claim(允许后续重试),返回 false 让 poller 不推进游标
14342
- logger.warn(`[doc-comment] auto-create session failed for file=${sub.fileToken.slice(0, 12)}; will retry comment ${commentId.slice(0, 12)}`);
14343
- return false;
15080
+ const userReplyId = ctx.replyId;
15081
+ let reactionId;
15082
+ let deliveryDs;
15083
+ let deliverySession;
15084
+ const targetMatchesThisTurn = (target) => !!target
15085
+ && target.fileToken === sub.fileToken
15086
+ && target.fileType === sub.fileType
15087
+ && target.commentId === commentId
15088
+ && target.replyId === userReplyId;
15089
+ const cleanupFailedDelivery = async () => {
15090
+ if (deliveryDs) {
15091
+ const runtimeTarget = deliveryDs.docCommentTurns?.get(turnId);
15092
+ if (targetMatchesThisTurn(runtimeTarget)) {
15093
+ deliveryDs.docCommentTurns?.delete(turnId);
15094
+ }
14344
15095
  }
14345
- }
14346
- // ds 确认有效后才设 claim——auto-create 失败时不占坑,允许重试。
14347
- handledDocCommentTurns.set(claimKey, Date.now());
15096
+ let removedPersistedTarget = false;
15097
+ if (deliverySession) {
15098
+ const persistedTarget = deliverySession.docCommentTargets?.[turnId];
15099
+ if (targetMatchesThisTurn(persistedTarget)) {
15100
+ delete deliverySession.docCommentTargets?.[turnId];
15101
+ removedPersistedTarget = true;
15102
+ }
15103
+ }
15104
+ if (removedPersistedTarget && deliverySession) {
15105
+ try {
15106
+ // Persist only the exact object still owned by this bot store. A stale
15107
+ // continuation must not reinsert a displaced/cross-file Session row.
15108
+ if (sessionStore.getOwnedSession(deliverySession.sessionId) === deliverySession) {
15109
+ sessionStore.updateSession(deliverySession);
15110
+ }
15111
+ }
15112
+ catch (persistErr) {
15113
+ logger.debug(`[doc-comment] failed to persist aborted target cleanup ` +
15114
+ `file=${sub.fileToken.slice(0, 12)} turn=${turnId.slice(0, 12)}: ` +
15115
+ `${persistErr instanceof Error ? persistErr.message : String(persistErr)}`);
15116
+ }
15117
+ }
15118
+ if (reactionId && userReplyId) {
15119
+ try {
15120
+ await removeCommentReaction(larkAppId, { fileToken: sub.fileToken, fileType: sub.fileType }, commentId, userReplyId, reactionId);
15121
+ }
15122
+ catch (reactionErr) {
15123
+ logger.debug(`[doc-comment] failed to remove Typing after aborted delivery ` +
15124
+ `file=${sub.fileToken.slice(0, 12)} turn=${turnId.slice(0, 12)}: ` +
15125
+ `${reactionErr instanceof Error ? reactionErr.message : String(reactionErr)}`);
15126
+ }
15127
+ }
15128
+ };
14348
15129
  try {
14349
- // 给用户的回复加 "Typing" reaction,让评论者知道 bot 正在处理。
14350
- const userReplyId = ctx.replyId;
14351
- let reactionId;
14352
- if (userReplyId) {
14353
- reactionId = await addCommentReaction(larkAppId, { fileToken: sub.fileToken, fileType: sub.fileType }, commentId, userReplyId, 'Typing');
14354
- }
14355
- const sender = ctx.authorOpenId ? await resolveSender(larkAppId, ctx.authorOpenId, 'user') : undefined;
14356
- const authorName = sender?.name || ctx.authorOpenId?.slice(0, 8) || '?';
14357
- const dsBotCfg = getBot(ds.larkAppId).config;
14358
- const promptInput = {
14359
- fileToken: sub.fileToken,
14360
- fileType: sub.fileType,
14361
- question: text,
14362
- author: authorName,
14363
- selectedText: ctx.selectedText,
14364
- priorReplies: ctx.priorReplies?.map(reply => ({
14365
- author: reply.authorOpenId?.slice(0, 12),
14366
- text: reply.text,
14367
- })),
14368
- projectDir: ds.workingDir ?? sub.workingDir,
14369
- brand: normalizeBrand(dsBotCfg.brand),
14370
- locale: loc,
14371
- };
14372
- // 记录本轮回评论的落点。两条路都要覆盖:
14373
- // • ds.docCommentTurns(内存,按 turnId)→ deliverFinalOutput「兜底」分流用
14374
- // • session.docCommentTargets(磁盘,per-turn map)→ `botmux send`「主回复」分流用
14375
- // (botmux send 跑在独立子进程,只能从磁盘读会话态;per-turn 避免并发评论串线)
14376
- (ds.docCommentTurns ??= new Map()).set(turnId, {
14377
- fileToken: sub.fileToken,
14378
- fileType: sub.fileType,
14379
- commentId,
14380
- replyToOpenId: ctx.authorOpenId,
14381
- replyToName: sender?.name,
14382
- replyId: userReplyId,
14383
- reactionId,
14384
- });
14385
- const docTarget = { fileToken: sub.fileToken, fileType: sub.fileType, commentId, replyToName: sender?.name, replyToOpenId: ctx.authorOpenId, turnId, replyId: userReplyId, reactionId };
14386
- const selfBot = getBot(ds.larkAppId);
14387
- if (ds.worker && !ds.worker.killed) {
14388
- const isBridge = !!ds.adoptedFrom;
14389
- if (!isBridge)
14390
- ensureSessionWhiteboard(ds);
14391
- const { promptContent, cliInput } = buildDocCommentTurnInput({
14392
- ds,
14393
- promptInput,
14394
- botCliId: dsBotCfg.cliId,
14395
- botCliPathOverride: dsBotCfg.cliPathOverride,
14396
- botIdentity: { name: selfBot.botName, openId: selfBot.botOpenId },
14397
- sender,
14398
- mode: 'live',
15130
+ const duplicate = handledDocCommentTurns.has(claimKey);
15131
+ if (duplicate) {
15132
+ logger.info(`[doc-comment] duplicate turn awaiting owner file=${sub.fileToken.slice(0, 12)} turn=${turnId.slice(0, 12)}`);
15133
+ }
15134
+ return await runClaimedDocCommentTurn(claimKey, async () => {
15135
+ const loc = localeForBot(larkAppId);
15136
+ let ds = resolveBoundDocSession(sub, larkAppId);
15137
+ if (!ds) {
15138
+ // 无活跃 session → 自动为该文档创建一个(用虚拟 anchor = doc:{fileToken})
15139
+ logger.info(`[doc-comment] no active session for anchor=${sub.sessionAnchor.slice(0, 12)}; auto-creating for file=${sub.fileToken.slice(0, 12)}`);
15140
+ ds = await autoCreateDocSession(sub, larkAppId, ctx);
15141
+ if (!ds) {
15142
+ throw new Error(`auto-create session failed for ${sub.fileToken.slice(0, 12)}`);
15143
+ }
15144
+ }
15145
+ deliveryDs = ds;
15146
+ const generation = captureRoutingGeneration(ds);
15147
+ deliverySession = generation.session;
15148
+ ensureCurrentRoutingGeneration(generation, 'comment:start');
15149
+ // 给用户的回复加 "Typing" reaction,让评论者知道 bot 正在处理。
15150
+ if (userReplyId) {
15151
+ reactionId = await addCommentReaction(larkAppId, { fileToken: sub.fileToken, fileType: sub.fileType }, commentId, userReplyId, 'Typing');
15152
+ }
15153
+ ensureCurrentRoutingGeneration(generation, 'comment:reaction');
15154
+ const sender = ctx.authorOpenId ? await resolveSender(larkAppId, ctx.authorOpenId, 'user') : undefined;
15155
+ ensureCurrentRoutingGeneration(generation, 'comment:sender');
15156
+ const authorName = sender?.name || ctx.authorOpenId?.slice(0, 8) || '?';
15157
+ const dsBotCfg = getBot(ds.larkAppId).config;
15158
+ const promptInput = {
15159
+ fileToken: sub.fileToken,
15160
+ fileType: sub.fileType,
15161
+ question: text,
15162
+ author: authorName,
15163
+ selectedText: ctx.selectedText,
15164
+ priorReplies: ctx.priorReplies?.map(reply => ({
15165
+ author: reply.authorOpenId?.slice(0, 12),
15166
+ text: reply.text,
15167
+ })),
15168
+ projectDir: ds.workingDir ?? sub.workingDir,
15169
+ brand: normalizeBrand(dsBotCfg.brand),
15170
+ locale: loc,
15171
+ };
15172
+ // 记录本轮回评论的落点。两条路都要覆盖:
15173
+ // • ds.docCommentTurns(内存,按 turnId)→ deliverFinalOutput「兜底」分流用
15174
+ // • session.docCommentTargets(磁盘,per-turn map)→ `botmux send`「主回复」分流用
15175
+ // (botmux send 跑在独立子进程,只能从磁盘读会话态;per-turn 避免并发评论串线)
15176
+ (ds.docCommentTurns ??= new Map()).set(turnId, {
15177
+ fileToken: sub.fileToken,
15178
+ fileType: sub.fileType,
15179
+ commentId,
15180
+ replyToOpenId: ctx.authorOpenId,
15181
+ replyToName: sender?.name,
15182
+ replyId: userReplyId,
15183
+ reactionId,
14399
15184
  });
14400
- beginNewTurn(ds, text);
14401
- (ds.session.docCommentTargets ??= {})[turnId] = docTarget; // per-turn map,不覆盖其他并发轮
14402
- sessionStore.updateSession(ds.session); // 先落盘,botmux send 子进程才读得到落点
14403
- await noteTurnReceived(ds, commentId, text, sender, turnId);
14404
- rememberLastCliInput(ds, promptContent, cliInput);
14405
- sendWorkerInput(ds, cliInput, turnId);
14406
- logger.info(`[${tag(ds)}] doc-comment turn injected (turn ${turnId.slice(0, 8)})`);
14407
- }
14408
- else {
15185
+ const docTarget = { fileToken: sub.fileToken, fileType: sub.fileType, commentId, replyToName: sender?.name, replyToOpenId: ctx.authorOpenId, turnId, replyId: userReplyId, reactionId };
15186
+ const selfBot = getBot(ds.larkAppId);
15187
+ if (ds.worker && !ds.worker.killed) {
15188
+ const targetWorker = ds.worker;
15189
+ const isBridge = !!ds.adoptedFrom;
15190
+ if (!isBridge)
15191
+ ensureSessionWhiteboard(ds);
15192
+ const { promptContent, cliInput } = buildDocCommentTurnInput({
15193
+ ds,
15194
+ promptInput,
15195
+ botCliId: dsBotCfg.cliId,
15196
+ botCliPathOverride: dsBotCfg.cliPathOverride,
15197
+ botIdentity: { name: selfBot.botName, openId: selfBot.botOpenId },
15198
+ sender,
15199
+ mode: 'live',
15200
+ });
15201
+ beginNewTurn(ds, text);
15202
+ (ds.session.docCommentTargets ??= {})[turnId] = docTarget; // per-turn map,不覆盖其他并发轮
15203
+ // rememberLastCliInput persists both the exact comment target and the
15204
+ // structured sidecar before any worker-visible delivery can occur.
15205
+ rememberLastCliInput(ds, promptContent, cliInput);
15206
+ await noteTurnReceived(ds, commentId, text, sender, turnId);
15207
+ ensureCurrentRoutingGeneration(generation, 'comment:live-note');
15208
+ if (ds.worker !== targetWorker || targetWorker.killed) {
15209
+ throw new Error('worker generation changed during comment:live-note');
15210
+ }
15211
+ if (!sendWorkerInput(ds, cliInput, turnId)) {
15212
+ throw new Error('worker became unavailable during comment:live-send');
15213
+ }
15214
+ logger.info(`[${tag(ds)}] doc-comment turn injected (turn ${turnId.slice(0, 8)})`);
15215
+ return;
15216
+ }
14409
15217
  // Worker 挂起 / 已退出 —— resume 重 fork(与 handleThreadReply 同路)。
14410
15218
  logger.info(`[${tag(ds)}] Worker not running for doc-comment, re-forking...`);
14411
15219
  if (ds.usageLimitRetryTimer) {
@@ -14434,33 +15242,26 @@ async function handleDocComment(ctx) {
14434
15242
  mode: 'refork',
14435
15243
  });
14436
15244
  (ds.session.docCommentTargets ??= {})[turnId] = docTarget; // per-turn map,不覆盖其他并发轮
14437
- await noteTurnReceived(ds, commentId, text, sender, turnId);
14438
15245
  rememberLastCliInput(ds, promptContent, wrappedInput);
15246
+ await noteTurnReceived(ds, commentId, text, sender, turnId);
15247
+ ensureCurrentRoutingGeneration(generation, 'comment:refork-note');
15248
+ if (ds.worker && !ds.worker.killed) {
15249
+ throw new Error('worker became active during comment:refork-note');
15250
+ }
14439
15251
  sessionStore.updateSession(ds.session);
14440
- // Same adopt re-fork routing as handleThreadReply: an adopt session whose
14441
- // bridge worker exited must come back through forkAdoptWorker (observe +
14442
- // bridge), never forkWorker. buildDocCommentTurnInput(mode:'refork')
14443
- // delegates to buildReforkCliInput, which already bridge-formats the content
14444
- // when ds.adoptedFrom is set, so the doc-comment turn reaches the observed
14445
- // pane without a <user_message> wrapper.
14446
15252
  if (ds.adoptedFrom) {
14447
15253
  forkAdoptWorker(ds, { prompt: wrappedInput.content, turnId });
14448
15254
  }
14449
15255
  else {
14450
15256
  forkWorker(ds, wrappedInput, { resume: ds.hasHistory, turnId });
14451
15257
  }
14452
- }
14453
- return true;
15258
+ }, cleanupFailedDelivery);
14454
15259
  }
14455
15260
  catch (err) {
14456
- // 投递失败:清理 claim 允许重试,返回 false 让 poller 不推进游标。
14457
- handledDocCommentTurns.delete(claimKey);
14458
15261
  logger.warn(`[doc-comment] delivery failed, claim released for retry file=${sub.fileToken.slice(0, 12)} turn=${turnId.slice(0, 12)} err=${err instanceof Error ? err.message : String(err)}`);
14459
15262
  return false;
14460
15263
  }
14461
15264
  }
14462
- /** 同一条评论可能同时被长连接通知和 --all 轮询看到;daemon 内统一去重。 */
14463
- const handledDocCommentTurns = new BoundedMap(5_000);
14464
15265
  let docCommentPollRunning = false;
14465
15266
  /**
14466
15267
  * `/watch-comment --all` 的应用身份增量轮询。
@@ -14561,7 +15362,7 @@ async function restoreDocSubscriptions(_sessions) {
14561
15362
  // • 有 sessionId 且其会话 status==='closed' → 真的关了 → 退订 + 删表
14562
15363
  // • 会话不存在(被清理)→ 同上
14563
15364
  // • 会话 active / 缺 sessionId(老订阅,无从判定)→ 保留 + 重订阅(保守,不误删)
14564
- const stored = sub.sessionId ? sessionStore.getSession(sub.sessionId) : undefined;
15365
+ const stored = sub.sessionId ? sessionStore.getOwnedSession(sub.sessionId) : undefined;
14565
15366
  const definitelyClosed = sub.sessionId && (!stored || stored.status === 'closed');
14566
15367
  if (definitelyClosed) {
14567
15368
  if (sub.managedBy !== 'watch-comment')
@@ -14599,14 +15400,6 @@ function resolvePrimaryOwnerOpenId(larkAppId) {
14599
15400
  return undefined;
14600
15401
  }
14601
15402
  }
14602
- /** Parse a positive float env override, falling back to `dflt` when unset/invalid. */
14603
- function envFloat(name, dflt) {
14604
- const raw = process.env[name];
14605
- if (raw === undefined || raw.trim() === '')
14606
- return dflt;
14607
- const n = Number(raw);
14608
- return Number.isFinite(n) && n >= 0 ? n : dflt;
14609
- }
14610
15403
  /**
14611
15404
  * Host-overload thresholds, seeded from module defaults and overridable via env
14612
15405
  * so an operator can tune sensitivity without a code change:
@@ -14618,37 +15411,24 @@ function envFloat(name, dflt) {
14618
15411
  * but are inert (reading passes swap=undefined).
14619
15412
  */
14620
15413
  function resolveOverloadThresholds() {
14621
- const cpuCount = Math.max(1, cpus().length || 1);
14622
- const enterLoadRatio = envFloat('BOTMUX_OVERLOAD_ENTER_LOAD_RATIO', DEFAULT_OVERLOAD_THRESHOLDS.enterLoadRatio);
14623
- let exitLoadRatio = envFloat('BOTMUX_OVERLOAD_EXIT_LOAD_RATIO', DEFAULT_OVERLOAD_THRESHOLDS.exitLoadRatio);
14624
- const enterMemUsedFrac = envFloat('BOTMUX_OVERLOAD_ENTER_MEM_FRAC', DEFAULT_OVERLOAD_THRESHOLDS.enterMemUsedFrac);
14625
- let exitMemUsedFrac = envFloat('BOTMUX_OVERLOAD_EXIT_MEM_FRAC', DEFAULT_OVERLOAD_THRESHOLDS.exitMemUsedFrac);
14626
- // Hysteresis only works when the recover line sits STRICTLY below the enter
14627
- // line. Clamping a misconfigured exit down to *equal* enter is not enough: at
14628
- // enter == exit the enter test (mem `>=`) and the recover test (mem `<=`) both
14629
- // fire at the exact threshold, so a reading pinned there flaps entered/
14630
- // recovered every tick. Force a small gap below enter (5%) and warn.
14631
- const HYSTERESIS_MARGIN = 0.95; // exit = 95% of enter when misconfigured
14632
- if (exitLoadRatio >= enterLoadRatio) {
14633
- const clamped = enterLoadRatio * HYSTERESIS_MARGIN;
14634
- logger.warn(`[overload] BOTMUX_OVERLOAD_EXIT_LOAD_RATIO (${exitLoadRatio}) >= ENTER (${enterLoadRatio}); clamping exit to ${clamped}`);
14635
- exitLoadRatio = clamped;
14636
- }
14637
- if (exitMemUsedFrac >= enterMemUsedFrac) {
14638
- const clamped = enterMemUsedFrac * HYSTERESIS_MARGIN;
14639
- logger.warn(`[overload] BOTMUX_OVERLOAD_EXIT_MEM_FRAC (${exitMemUsedFrac}) >= ENTER (${enterMemUsedFrac}); clamping exit to ${clamped}`);
14640
- exitMemUsedFrac = clamped;
14641
- }
14642
- return {
14643
- cpuCount,
14644
- enterLoadRatio,
14645
- exitLoadRatio,
14646
- enterMemUsedFrac,
14647
- exitMemUsedFrac,
14648
- enterSwapUsedFrac: DEFAULT_OVERLOAD_THRESHOLDS.enterSwapUsedFrac,
14649
- exitSwapUsedFrac: DEFAULT_OVERLOAD_THRESHOLDS.exitSwapUsedFrac,
14650
- minReAlertMs: envFloat('BOTMUX_OVERLOAD_MIN_REALERT_MS', DEFAULT_OVERLOAD_THRESHOLDS.minReAlertMs),
14651
- };
15414
+ // Enter thresholds priority (env > global config > default) + hysteresis-safe
15415
+ // exit derivation live in the pure computeOverloadThresholds() so they can be
15416
+ // unit-tested without importing this side-effectful module. We only gather the
15417
+ // impure inputs (CPU count, persisted config, env strings) here.
15418
+ const cfg = readGlobalConfig().hostOverloadAlert ?? {};
15419
+ return computeOverloadThresholds({
15420
+ cpuCount: cpus().length || 1,
15421
+ configEnterLoadRatio: cfg.enterLoadRatio,
15422
+ configEnterMemUsedFrac: cfg.enterMemUsedFrac,
15423
+ env: {
15424
+ enterLoadRatio: process.env.BOTMUX_OVERLOAD_ENTER_LOAD_RATIO,
15425
+ exitLoadRatio: process.env.BOTMUX_OVERLOAD_EXIT_LOAD_RATIO,
15426
+ enterMemUsedFrac: process.env.BOTMUX_OVERLOAD_ENTER_MEM_FRAC,
15427
+ exitMemUsedFrac: process.env.BOTMUX_OVERLOAD_EXIT_MEM_FRAC,
15428
+ minReAlertMs: process.env.BOTMUX_OVERLOAD_MIN_REALERT_MS,
15429
+ },
15430
+ warn: (message) => logger.warn(message),
15431
+ });
14652
15432
  }
14653
15433
  /**
14654
15434
  * Machine-wide de-dup for the host-overload alert. load/mem are host-wide, so if
@@ -14763,7 +15543,12 @@ export async function startDaemon(botIndex) {
14763
15543
  // sandboxReadonlyPaths / readDenyExtraPaths → sandbox + sandboxPaths) BEFORE
14764
15544
  // loading, so the parsed configs below already carry the new model. Writes
14765
15545
  // new fields, keeps old ones (downgrade = zero-op), backs up once. Idempotent.
14766
- await migrateSandboxConfigAtStartup();
15546
+ // SKIP in core-only (codex P1-2): this reads + backs-up + rewrites the on-disk
15547
+ // fleet bots.json. A headless core-only service must never touch an ambient
15548
+ // host fleet config — its identity is a synthesized in-memory apiOnly bot.
15549
+ if (process.env.BOTMUX_CORE_ONLY !== '1') {
15550
+ await migrateSandboxConfigAtStartup();
15551
+ }
14767
15552
  // Load the assigned bot (one daemon per bot)
14768
15553
  let botConfigs = loadBotConfigs();
14769
15554
  const idx = botIndex ?? 0;
@@ -14910,18 +15695,34 @@ export async function startDaemon(botIndex) {
14910
15695
  // running in a separate node process) so dashboard event bus stays in sync.
14911
15696
  scheduleStore.setScheduleScope(cfg.larkAppId);
14912
15697
  migrateSharedSchedulesAtStartup(botConfigs.map(b => b.larkAppId), botConfigs[0]?.larkAppId ?? cfg.larkAppId);
15698
+ void migrateOverloadAlertAtStartup(botConfigs.map(b => ({ larkAppId: b.larkAppId, apiOnly: b.apiOnly })));
14913
15699
  scheduleStore.startExternalWriteWatcher();
14914
15700
  logger.info(`Bot ${idx}/${botConfigs.length}: ${cfg.larkAppId} (cli: ${cfg.cliId})`);
14915
15701
  setAskCardDispatcher(createLarkAskCardDispatcher());
14916
15702
  // Honour the bot's canTalk gate for `botmux ask` answers: a clicker who may
14917
15703
  // address the bot in this chat may answer an implicit-approver ask.
14918
- setAskCanTalkChecker((appId, chatId, openId, chatType) => evaluateTalk(appId, chatId, openId, undefined, undefined, chatType).allowed);
15704
+ //
15705
+ // 分派逻辑在 evaluateAskAnswerTalk(生产共用真源):actor 透传时(文字作答路径)
15706
+ // bot → evaluateBotTalk、人 → evaluateTalk 的完整模型(teamMember 走 memberUnionId
15707
+ // 腿);不传 actor(卡片点击路径,飞书 card-action 回调无 sender union/bot 标记)→
15708
+ // 退化为纯 evaluateTalk(openId, chatType),与改动前语义一致。三条分派由
15709
+ // test/ask-answer-talk-dispatch.test.ts 直接咬住。
15710
+ setAskCanTalkChecker((appId, chatId, openId, chatType, actor) => evaluateAskAnswerTalk(appId, chatId, openId, chatType, actor));
14919
15711
  writePidFile();
14920
15712
  const memoryDiagnostics = startMemoryDiagnostics();
14921
15713
  // Publish self-descriptor for the dashboard registry. The dashboard sibling
14922
15714
  // process discovers running daemons by scanning <resolvedDataDir>/dashboard-daemons/
14923
15715
  // and watching for mtime updates (heartbeat) / file removal (shutdown).
14924
- const ipcPort = config.dashboard.ipcBasePort + idx;
15716
+ //
15717
+ // Core-only (in-sandbox, single service): riff's task-runner is handed ONE
15718
+ // fixed port and dials 127.0.0.1:<port> directly, so the port must be exactly
15719
+ // BOTMUX_API_PORT and must NOT drift via the fleet's upward EADDRINUSE probe —
15720
+ // a silent drift would leave the client dialing a dead port. Fleet daemons
15721
+ // keep the ipcBasePort+idx scheme + probe (a port race must not crash boot).
15722
+ const coreOnlyApiPort = process.env.BOTMUX_CORE_ONLY === '1'
15723
+ ? Number(process.env.BOTMUX_API_PORT) || 0
15724
+ : 0;
15725
+ const ipcPort = coreOnlyApiPort || config.dashboard.ipcBasePort + idx;
14925
15726
  // Worker/CLI descendants use this only to reach the current daemon's
14926
15727
  // agent-facing, live-origin-gated endpoints. Internal control endpoints use
14927
15728
  // a separate daemon-to-daemon credential and never trust this port marker.
@@ -14968,55 +15769,61 @@ export async function startDaemon(botIndex) {
14968
15769
  }
14969
15770
  };
14970
15771
  setDisplayNameRefresher(refreshBotNameState);
14971
- // 机器人真·改名(dashboard 档案头 ✎):开放平台自动化改飞书应用名并发布新版本
14972
- // (群内显示名跟随已发布版本,见 services/open-platform-rename.ts)。成功后同步
14973
- // 内存 botName / bots-info 名册 / descriptor,并清掉冗余的 displayName 别名——
14974
- // 飞书名已经是新名,保持单一事实来源。
14975
- setBotRenamer(async (newName) => {
14976
- const r = await renameBotOnOpenPlatform(cfg.larkAppId, newName, cfg.brand);
14977
- if (!r.ok)
14978
- return r;
14979
- const bot = getBot(cfg.larkAppId);
14980
- bot.botName = newName;
14981
- const spec = findConfigField('displayName');
14982
- if (spec && bot.config.displayName) {
14983
- // applyConfigField 的 displayName 钩子会顺带触发 refreshBotNameState。
14984
- await applyConfigField(cfg.larkAppId, spec, null);
14985
- }
14986
- else {
14987
- refreshBotNameState();
14988
- }
14989
- try {
14990
- writeBotInfoFile(config.session.dataDir);
14991
- }
14992
- catch { /* best effort */ }
14993
- return r;
14994
- });
14995
- // 机器人真·改头像(dashboard 档案头头像入口):开放平台自动化换飞书应用头像并
14996
- // 发布新版本(群内头像跟随已发布版本,同 rename)。成功后用上传返回的 url
14997
- // 同步内存 botAvatarUrl / descriptor / bots-info 让 dashboard 立即换图——该 url
14998
- // 是飞书 CDN 上的持久资源,与 /bot/v3/info 的 canonical avatar_url 指向同一张图,
14999
- // 会一直沿用到下次 daemon 重启时 probeBotOpenId 重新拉取(probe 在 botOpenId
15000
- // 已知时不会重复请求,进程内没有周期性刷新)。
15001
- setBotAvatarChanger(async (image) => {
15002
- const r = await changeBotAvatarOnOpenPlatform(cfg.larkAppId, image, cfg.brand);
15003
- if (!r.ok)
15772
+ // apiOnly (core-only) bots have no Feishu app to rename / re-avatar. These
15773
+ // handlers drive the open-platform console (browser web-session, NOT
15774
+ // getBotClient so the bot-level gate can't catch them); skip registering
15775
+ // them entirely so the dashboard profile actions are inert for a core-only bot.
15776
+ if (!cfg.apiOnly) {
15777
+ // 机器人真·改名(dashboard 档案头 ✎):开放平台自动化改飞书应用名并发布新版本
15778
+ // (群内显示名跟随已发布版本,见 services/open-platform-rename.ts)。成功后同步
15779
+ // 内存 botName / bots-info 名册 / descriptor,并清掉冗余的 displayName 别名——
15780
+ // 飞书名已经是新名,保持单一事实来源。
15781
+ setBotRenamer(async (newName) => {
15782
+ const r = await renameBotOnOpenPlatform(cfg.larkAppId, newName, cfg.brand);
15783
+ if (!r.ok)
15784
+ return r;
15785
+ const bot = getBot(cfg.larkAppId);
15786
+ bot.botName = newName;
15787
+ const spec = findConfigField('displayName');
15788
+ if (spec && bot.config.displayName) {
15789
+ // applyConfigField 的 displayName 钩子会顺带触发 refreshBotNameState。
15790
+ await applyConfigField(cfg.larkAppId, spec, null);
15791
+ }
15792
+ else {
15793
+ refreshBotNameState();
15794
+ }
15795
+ try {
15796
+ writeBotInfoFile(config.session.dataDir);
15797
+ }
15798
+ catch { /* best effort */ }
15004
15799
  return r;
15005
- const bot = getBot(cfg.larkAppId);
15006
- bot.botAvatarUrl = r.avatarUrl;
15007
- if (desc.botAvatarUrl !== r.avatarUrl) {
15008
- desc.botAvatarUrl = r.avatarUrl;
15800
+ });
15801
+ // 机器人真·改头像(dashboard 档案头头像入口):开放平台自动化换飞书应用头像并
15802
+ // 发布新版本(群内头像跟随已发布版本,同 rename)。成功后用上传返回的 url
15803
+ // 同步内存 botAvatarUrl / descriptor / bots-info 让 dashboard 立即换图——该 url
15804
+ // 是飞书 CDN 上的持久资源,与 /bot/v3/info 的 canonical avatar_url 指向同一张图,
15805
+ // 会一直沿用到下次 daemon 重启时 probeBotOpenId 重新拉取(probe 在 botOpenId
15806
+ // 已知时不会重复请求,进程内没有周期性刷新)。
15807
+ setBotAvatarChanger(async (image) => {
15808
+ const r = await changeBotAvatarOnOpenPlatform(cfg.larkAppId, image, cfg.brand);
15809
+ if (!r.ok)
15810
+ return r;
15811
+ const bot = getBot(cfg.larkAppId);
15812
+ bot.botAvatarUrl = r.avatarUrl;
15813
+ if (desc.botAvatarUrl !== r.avatarUrl) {
15814
+ desc.botAvatarUrl = r.avatarUrl;
15815
+ try {
15816
+ writeDaemonDescriptor(desc);
15817
+ }
15818
+ catch { /* best effort */ }
15819
+ }
15009
15820
  try {
15010
- writeDaemonDescriptor(desc);
15821
+ writeBotInfoFile(config.session.dataDir);
15011
15822
  }
15012
15823
  catch { /* best effort */ }
15013
- }
15014
- try {
15015
- writeBotInfoFile(config.session.dataDir);
15016
- }
15017
- catch { /* best effort */ }
15018
- return r;
15019
- });
15824
+ return r;
15825
+ });
15826
+ } // end !cfg.apiOnly (open-platform rename/avatar handlers)
15020
15827
  // One cap implementation shared by event-driven checks (process start / idle
15021
15828
  // edge) and the 60s safety-net timer below. Each daemon owns exactly one
15022
15829
  // bot's activeSessions map, so the configured limit is per bot.
@@ -15112,10 +15919,27 @@ export async function startDaemon(botIndex) {
15112
15919
  // host-only shared secret on every daemon IPC route except the tiny
15113
15920
  // capability-gated receiver/readiness apertures in dashboard-ipc-server.
15114
15921
  loadOrCreateDashboardSecret(join(homedir(), '.botmux', '.dashboard-secret'));
15922
+ const coreOnly = process.env.BOTMUX_CORE_ONLY === '1';
15923
+ // Core-only keeps the trusted-host HMAC ON (codex P1: authRequired:false opened
15924
+ // ALL 96 IPC routes — a co-resident model turn could read/perturb sessions,
15925
+ // scheduler, mutations). Instead we allowlist ONLY the tight riff-facing routes
15926
+ // (routeIsCoreOnlyPublic: /api/trigger + /api/sessions/:id/{trigger-result,insight}
15927
+ // + the always-public /healthz) as no-HMAC; every other route still requires it.
15928
+ // Arm the readiness gate BEFORE the bind (codex P1): between listen() and a
15929
+ // later arm there'd be a window where the port answers unarmed and a trigger
15930
+ // could slip past the 503 barrier. Armed-first means the very first accepted
15931
+ // connection already sees the not-ready gate.
15932
+ if (coreOnly)
15933
+ armCoreOnlyReadinessGate();
15115
15934
  const ipcHandle = await startIpcServer({
15116
15935
  port: ipcPort,
15117
15936
  host: '127.0.0.1',
15118
15937
  authRequired: true,
15938
+ coreOnlyPublicRoutes: coreOnly,
15939
+ // Fleet: probe upward so a port race can't crash boot. Core-only: BIND-OR-FAIL
15940
+ // on the exact BOTMUX_API_PORT — riff was handed that port and the service must
15941
+ // never silently drift to another (client would dial a dead port).
15942
+ ...(coreOnly ? { maxProbe: 0 } : {}),
15119
15943
  });
15120
15944
  // startIpcServer probes upward on EADDRINUSE (e.g. a second botmux instance on
15121
15945
  // this host already holds ipcBasePort+idx), so the bound port may differ from
@@ -15124,19 +15948,30 @@ export async function startDaemon(botIndex) {
15124
15948
  desc.ipcPort = ipcHandle.port;
15125
15949
  process.env.BOTMUX_DAEMON_IPC_PORT = String(ipcHandle.port);
15126
15950
  logger.info(`[dashboard-ipc] listening on 127.0.0.1:${ipcHandle.port} (bot ${idx})`);
15951
+ // NOTE: the core-only ready line + readiness release (setCoreOnlyReady) are
15952
+ // emitted LATER, AFTER restoreActiveSessions completes — until then BOTH
15953
+ // /healthz AND the public control routes (trigger/result/insight) return 503,
15954
+ // so riff never triggers into a racing durable restore (codex P1).
15127
15955
  // Single reverse-proxy port that fronts every session's web terminal under
15128
15956
  // /s/{sessionId}, so dev-machine users forward one port (proxyBasePort+idx)
15129
15957
  // instead of one per topic. Bound on the public host so `ssh -L` can reach it.
15130
15958
  const proxyPort = config.web.proxyBasePort + idx;
15959
+ // Core-only (codex P1-4): the web terminal proxy + worker web ports default to
15960
+ // config.web.host (0.0.0.0). In riff's sandbox the whole surface must stay
15961
+ // loopback — an in-sandbox headless service has no reason to expose terminals
15962
+ // on all interfaces. Force 127.0.0.1 so nothing but the local task-runner can
15963
+ // reach it (the IPC server is already 127.0.0.1-bound above).
15964
+ const terminalProxyHost = coreOnly ? '127.0.0.1' : config.web.host;
15131
15965
  let terminalProxy = null;
15132
15966
  try {
15133
15967
  terminalProxy = await startTerminalProxy({
15134
15968
  port: proxyPort,
15135
- host: config.web.host,
15969
+ host: terminalProxyHost,
15136
15970
  resolvePort: (sessionId) => {
15137
15971
  for (const ds of activeSessions.values()) {
15138
- if (ds.session.sessionId === sessionId && ds.workerPort)
15972
+ if (ds.session.sessionId === sessionId && sessionSupportsWebTerminal(ds) && ds.workerPort) {
15139
15973
  return ds.workerPort;
15974
+ }
15140
15975
  }
15141
15976
  return undefined;
15142
15977
  },
@@ -15154,7 +15989,7 @@ export async function startDaemon(botIndex) {
15154
15989
  // falls back to the worker's own port so links stay reachable if the port
15155
15990
  // was taken (e.g. EADDRINUSE).
15156
15991
  setTerminalProxyPort(terminalProxy.port);
15157
- logger.info(`[terminal-proxy] listening on ${config.web.host}:${terminalProxy.port} (bot ${idx}) — session terminals at /s/{sessionId}`);
15992
+ logger.info(`[terminal-proxy] listening on ${terminalProxyHost}:${terminalProxy.port} (bot ${idx}) — session terminals at /s/{sessionId}`);
15158
15993
  }
15159
15994
  catch (err) {
15160
15995
  logger.error(`[terminal-proxy] failed to bind port ${proxyPort} — falling back to direct worker ports for terminal links: ${err.message}`);
@@ -15201,8 +16036,11 @@ export async function startDaemon(botIndex) {
15201
16036
  const cfg = bot.config;
15202
16037
  // Refresh CLI version per bot's cliId
15203
16038
  refreshCliVersion(cfg.cliId, cfg.cliPathOverride);
15204
- // Resolve allowed users per bot
15205
- if ((bot.config.allowedUsers?.length ?? 0) > 0 || bot.resolvedAllowedUsers.length > 0) {
16039
+ // Resolve allowed users per bot. Skipped for apiOnly (core-only) bots:
16040
+ // their HTTP control-API triggers authenticate via the dashboard token, not
16041
+ // allowedUsers, and resolving email/union_id entries would call the Feishu
16042
+ // contact API — a network round-trip a no-Feishu bot must never make.
16043
+ if (!cfg.apiOnly && ((bot.config.allowedUsers?.length ?? 0) > 0 || bot.resolvedAllowedUsers.length > 0)) {
15206
16044
  // 含邮箱或 union_id(on_) 都要重解析成本 app 的 open_id —— 否则 canTalk/canOperate
15207
16045
  // 拿 sender 的 ou_ 对不上 on_,owner 会被自己的 bot 锁死(PR#72)。
15208
16046
  // literal ou_ 也走 best-effort 校验,用于诊断把其他 app 视角 open_id
@@ -15277,45 +16115,61 @@ export async function startDaemon(botIndex) {
15277
16115
  catch { /* best effort */ }
15278
16116
  }
15279
16117
  checkAllowedChatGroupsConfig(bot);
15280
- // Probe bot open_id and persist to bots-info.json. When the friendly
15281
- // botName comes back from /bot/v3/info, refresh the dashboard descriptor
15282
- // so the registry shows "Claude" / "Codex" instead of the raw app id.
15283
- // A custom displayName (bots.json) beats the probed Lark name — the probe
15284
- // must not overwrite a rename seeded at startup.
15285
- probeBotOpenId(cfg.larkAppId).then(() => {
15286
- writeBotInfoFile(config.session.dataDir);
15287
- const probedName = cfg.displayName ?? bot.botName;
15288
- const probedAvatar = bot.botAvatarUrl;
15289
- let descChanged = false;
15290
- if (probedName && probedName !== desc.botName) {
15291
- desc.botName = probedName;
15292
- descChanged = true;
15293
- }
15294
- if (probedAvatar && probedAvatar !== desc.botAvatarUrl) {
15295
- desc.botAvatarUrl = probedAvatar;
15296
- descChanged = true;
15297
- }
15298
- if (descChanged) {
15299
- try {
15300
- writeDaemonDescriptor(desc);
16118
+ // apiOnly (core-only) bots never connect to Feishu: skip the open_id probe,
16119
+ // the required-scope check, and the WSClient event subscription. They are
16120
+ // driven purely over the HTTP control API (trigger spawn trigger-result),
16121
+ // whose async path early-returns in deliverFinalOutput before any Feishu send.
16122
+ // Seed a synthetic identity so downstream reads (worker botOpenId, dashboard
16123
+ // roster) get a stable non-undefined value instead of the never-probed one.
16124
+ if (cfg.apiOnly) {
16125
+ bot.botOpenId ||= `bot_${cfg.larkAppId}`;
16126
+ bot.botName ||= cfg.displayName ?? cfg.larkAppId;
16127
+ logger.info(`[api-only] ${cfg.larkAppId} core-only 模式启动:跳过飞书 open_id 探测 / scope 校验 / WSClient 订阅,仅 HTTP 控制 API 驱动`);
16128
+ }
16129
+ else {
16130
+ // Probe bot open_id and persist to bots-info.json. When the friendly
16131
+ // botName comes back from /bot/v3/info, refresh the dashboard descriptor
16132
+ // so the registry shows "Claude" / "Codex" instead of the raw app id.
16133
+ // A custom displayName (bots.json) beats the probed Lark name — the probe
16134
+ // must not overwrite a rename seeded at startup.
16135
+ probeBotOpenId(cfg.larkAppId).then(() => {
16136
+ writeBotInfoFile(config.session.dataDir);
16137
+ const probedName = cfg.displayName ?? bot.botName;
16138
+ const probedAvatar = bot.botAvatarUrl;
16139
+ let descChanged = false;
16140
+ if (probedName && probedName !== desc.botName) {
16141
+ desc.botName = probedName;
16142
+ descChanged = true;
15301
16143
  }
15302
- catch { /* best effort */ }
15303
- }
15304
- // SessionRow.botName 同步换成友好名——否则 dashboard 会话行一直显示
15305
- // 启动时 seed 的 larkAppId(web 端有注册表映射兜底,这里是根因修复)。
15306
- if (probedName)
15307
- setBotName(probedName);
15308
- }).catch(err => {
15309
- // Probe runs in background and is retried by the periodic heartbeat;
15310
- // a single failure here is not actionable. Surface as debug only.
15311
- logger.debug(`[${cfg.larkAppId}] Bot open_id probe failed (will retry): ${err.message}`);
15312
- });
16144
+ if (probedAvatar && probedAvatar !== desc.botAvatarUrl) {
16145
+ desc.botAvatarUrl = probedAvatar;
16146
+ descChanged = true;
16147
+ }
16148
+ if (descChanged) {
16149
+ try {
16150
+ writeDaemonDescriptor(desc);
16151
+ }
16152
+ catch { /* best effort */ }
16153
+ }
16154
+ // SessionRow.botName 同步换成友好名——否则 dashboard 会话行一直显示
16155
+ // 启动时 seed 的 larkAppId(web 端有注册表映射兜底,这里是根因修复)。
16156
+ if (probedName)
16157
+ setBotName(probedName);
16158
+ }).catch(err => {
16159
+ // Probe runs in background and is retried by the periodic heartbeat;
16160
+ // a single failure here is not actionable. Surface as debug only.
16161
+ logger.debug(`[${cfg.larkAppId}] Bot open_id probe failed (will retry): ${err.message}`);
16162
+ });
16163
+ } // end !cfg.apiOnly (open_id probe)
15313
16164
  // Required-scope check: 启动后 best-effort 校验
15314
16165
  // im:message.group_at_msg.include_bot:readonly。缺失会 logger.error +
15315
16166
  // 私信 allowedUsers[0]。校验异步,跑失败不影响 daemon。
15316
- checkRequiredScopes(cfg.larkAppId).catch(err => {
15317
- logger.debug(`[${cfg.larkAppId}] required-scope check failed: ${err?.message ?? err}`);
15318
- });
16167
+ // apiOnly 无飞书连接 → 无 scope 概念,跳过。
16168
+ if (!cfg.apiOnly) {
16169
+ checkRequiredScopes(cfg.larkAppId).catch(err => {
16170
+ logger.debug(`[${cfg.larkAppId}] required-scope check failed: ${err?.message ?? err}`);
16171
+ });
16172
+ }
15319
16173
  // 主动开工 — 场景①: the bot.added event can't be self-verified via API, and
15320
16174
  // if it isn't subscribed the handler simply never fires (no runtime signal).
15321
16175
  // Surface a startup breadcrumb whenever the toggle is on so a misconfigured
@@ -15351,7 +16205,11 @@ export async function startDaemon(botIndex) {
15351
16205
  };
15352
16206
  // 存起来供授权成功后重放消息用(replayGrantedMessage → replayMessageEvent)。
15353
16207
  botHandlers.set(cfg.larkAppId, botEventHandlers);
15354
- startLarkEventDispatcher(cfg.larkAppId, cfg.larkAppSecret, botEventHandlers, normalizeBrand(cfg.brand));
16208
+ // apiOnly bots never subscribe to Feishu events → no WSClient. This is the
16209
+ // core decoupling: the daemon serves the HTTP control API only.
16210
+ if (!cfg.apiOnly) {
16211
+ startLarkEventDispatcher(cfg.larkAppId, cfg.larkAppSecret, botEventHandlers, normalizeBrand(cfg.brand));
16212
+ }
15355
16213
  // A distillation command is durably prepared before its model run/card
15356
16214
  // delivery. Resume active prepared/proposed allocations after a daemon
15357
16215
  // crash; deterministic Lark UUIDs suppress duplicate cards in the
@@ -15453,16 +16311,22 @@ export async function startDaemon(botIndex) {
15453
16311
  catch (err) {
15454
16312
  logger.warn(`[skills] post-restore global sweep failed: ${err instanceof Error ? err.message : String(err)}`);
15455
16313
  }
15456
- // 文档订阅恢复:重启后订阅可能已失效,给仍活跃的会话重订阅;会话没恢复
15457
- // (已关/丢失)的订阅则退订 + 清表,避免「命中订阅但无会话」的孤儿。
15458
- await restoreDocSubscriptions(activeSessions);
15459
- // `drive.notice.comment_add_v1` 只可靠推送 @Bot 通知;--all 通过应用身份轮询
15460
- // 评论列表补齐普通评论。先立即建/续基线,之后每 5 秒增量检查。
15461
- const docCommentPollTimer = setInterval(() => {
16314
+ // 文档订阅恢复 + 评论轮询:都是主动调飞书文档 API 的路径。apiOnly(core-only)
16315
+ // bot 从不连飞书,且其合成身份不会有真实文档订阅——整块跳过,否则非 pristine
16316
+ // dataDir 上的遗留订阅会让「无飞书连接」的 bot 每 5 秒主动打飞书。
16317
+ let docCommentPollTimer;
16318
+ if (!cfg.apiOnly) {
16319
+ // 文档订阅恢复:重启后订阅可能已失效,给仍活跃的会话重订阅;会话没恢复
16320
+ // (已关/丢失)的订阅则退订 + 清表,避免「命中订阅但无会话」的孤儿。
16321
+ await restoreDocSubscriptions(activeSessions);
16322
+ // `drive.notice.comment_add_v1` 只可靠推送 @Bot 通知;--all 通过应用身份轮询
16323
+ // 评论列表补齐普通评论。先立即建/续基线,之后每 5 秒增量检查。
16324
+ docCommentPollTimer = setInterval(() => {
16325
+ void pollWatchedDocComments(cfg.larkAppId);
16326
+ }, 5_000);
16327
+ docCommentPollTimer.unref?.();
15462
16328
  void pollWatchedDocComments(cfg.larkAppId);
15463
- }, 5_000);
15464
- docCommentPollTimer.unref?.();
15465
- void pollWatchedDocComments(cfg.larkAppId);
16329
+ }
15466
16330
  // Sweep orphan sandbox trees left by a previous run's crash/kill: any
15467
16331
  // <dataDir>/sandboxes/<sid> whose session is no longer active gets its
15468
16332
  // deny-mask mountpoints reclaimed and its dirs removed.
@@ -15529,8 +16393,12 @@ export async function startDaemon(botIndex) {
15529
16393
  const maintenanceHeartbeat = setInterval(writeBusyHeartbeat, 15_000);
15530
16394
  maintenanceHeartbeat.unref?.();
15531
16395
  // Auto-update / auto-restart and the restart-report DM run only on the
15532
- // primary daemon (bot-0) — a restart is host-wide.
15533
- if (idx === 0) {
16396
+ // primary daemon (bot-0) — a restart is host-wide. NEVER in core-only (codex
16397
+ // P1): a headless in-sandbox service synthesizes its single bot at idx=0, but
16398
+ // it must NOT own host-wide fleet maintenance — auto-update could rewrite the
16399
+ // global botmux install and a detached `botmux restart` would tear down the
16400
+ // real fleet. Core-only manages only its own single process.
16401
+ if (idx === 0 && !coreOnly) {
15534
16402
  startMaintenance();
15535
16403
  startCliRuntimeUpdateMonitor({
15536
16404
  dataDir: config.session.dataDir,
@@ -15555,34 +16423,33 @@ export async function startDaemon(botIndex) {
15555
16423
  });
15556
16424
  }, 5_000).unref?.();
15557
16425
  }
15558
- // Host-overload watcher. Enabled per-bot via the `overloadAlert` config toggle
15559
- // (dashboard → Groups & Bots → bot card), NOT hardcoded to the primary daemon:
15560
- // any one bot can be designated the machine's alerter. load/mem are host-wide,
15561
- // so if more than one bot has it on, a cross-process file lock keyed on the
15562
- // overload episode ensures the machine only DMs once per edge (whichever bot's
15563
- // daemon wins the lock speaks). Reads os.loadavg()[2] + mem every 30s; on a
15564
- // healthy→overloaded or overloaded→healthy edge it DMs that bot's owner.
15565
- // Hysteresis + a min re-alert window (see host-overload-alert.ts) keep a load
15566
- // hovering near the line from spamming. Set BOTMUX_OVERLOAD_ALERT=0 to force
15567
- // the whole feature off regardless of per-bot config.
16426
+ // Host-overload watcher. Machine-level: enabled via the GLOBAL config
16427
+ // `hostOverloadAlert` (dashboard → Global Settings), which names one "notifier
16428
+ // bot". ONLY that bot's own daemon samples + advances the state machine + DMs
16429
+ // (it runs on this host, so no cross-daemon delivery queue is needed) — other
16430
+ // daemons no-op and keep their state cleared. load/mem are host-wide; the
16431
+ // cross-process episode lock is kept purely as a belt-and-suspenders guard
16432
+ // (e.g. a stale duplicate daemon for the same bot). Reads os.loadavg()[2] +
16433
+ // mem every 30s; on a healthy→overloaded or overloaded→healthy edge it DMs the
16434
+ // notifier bot's admin. Hysteresis + a min re-alert window (see
16435
+ // host-overload-alert.ts) keep a load near the line from spamming. Set
16436
+ // BOTMUX_OVERLOAD_ALERT=0 to force the whole feature off regardless of config.
15568
16437
  if (process.env.BOTMUX_OVERLOAD_ALERT !== '0') {
15569
- const overloadThresholds = resolveOverloadThresholds();
15570
16438
  let overloadState = INITIAL_OVERLOAD_STATE;
15571
16439
  const overloadTimer = setInterval(() => {
15572
16440
  void (async () => {
15573
16441
  try {
15574
- // Per-bot热开关:关了就不采样,但 timer 常驻,用户在 web 打开后下个 tick 即生效。
15575
- let enabled = false;
15576
- try {
15577
- enabled = getBot(cfg.larkAppId).config.overloadAlert === true;
15578
- }
15579
- catch {
15580
- enabled = false;
15581
- }
15582
- if (!enabled) {
16442
+ // Global hot switch + target gating: only the selected notifier bot's
16443
+ // daemon samples. When disabled, or the target is another bot / unset,
16444
+ // reset local state so a later re-enable starts clean (no stale edge).
16445
+ const alertCfg = readGlobalConfig().hostOverloadAlert ?? {};
16446
+ if (!isOverloadAlertTarget(alertCfg, { larkAppId: cfg.larkAppId, apiOnly: cfg.apiOnly })) {
15583
16447
  overloadState = INITIAL_OVERLOAD_STATE;
15584
16448
  return;
15585
16449
  }
16450
+ // Re-read thresholds each tick so live config edits (enter load/mem)
16451
+ // take effect on the next sample without a restart.
16452
+ const overloadThresholds = resolveOverloadThresholds();
15586
16453
  const reading = {
15587
16454
  load15: loadavg()[2] ?? 0,
15588
16455
  memTotalBytes: totalmem(),
@@ -15592,11 +16459,11 @@ export async function startDaemon(botIndex) {
15592
16459
  overloadState = nextState;
15593
16460
  if (!action)
15594
16461
  return;
15595
- // Machine-wide de-dup: multiple bots may have the toggle on, but the
15596
- // host is one machine. Claim a short-lived episode lock so only one bot
15597
- // DMs per edge. Losing the claim = another bot already alerted this edge.
16462
+ // Belt-and-suspenders de-dup: normally only the target daemon reaches
16463
+ // here, but a stale duplicate daemon for the same bot could double-fire.
16464
+ // Claim a short-lived episode lock so the host DMs once per edge.
15598
16465
  if (!claimOverloadEpisode(action.kind)) {
15599
- logger.info(`[overload] ${action.kind} edge already claimed by a sibling bot; skipping DM (${cfg.larkAppId})`);
16466
+ logger.info(`[overload] ${action.kind} edge already claimed; skipping DM (${cfg.larkAppId})`);
15600
16467
  return;
15601
16468
  }
15602
16469
  const ownerOpenId = resolvePrimaryOwnerOpenId(cfg.larkAppId);
@@ -15713,7 +16580,7 @@ export async function startDaemon(botIndex) {
15713
16580
  // detach-preserve a "herdr" session whose real pane is tmux (freeze-once).
15714
16581
  // undefined (frozen pty, or unresolvable legacy) → non-persistent → killWorker.
15715
16582
  if (shutdownBackendDisposition(ds) === 'detach') {
15716
- // Persistent backends (tmux / herdr / zellij): just kill the worker process —
16583
+ // Persistent backends (tmux / herdr / zellij / zmx): just kill the worker process —
15717
16584
  // the multiplexer session survives for re-attach. The worker's SIGTERM
15718
16585
  // handler calls backend.kill(), which only DETACHES. Going through
15719
16586
  // killWorker() instead would send {type:'close'} → destroySession() →
@@ -15776,5 +16643,16 @@ export async function startDaemon(botIndex) {
15776
16643
  flushIdentityCacheSync();
15777
16644
  });
15778
16645
  logger.info('Daemon is running. Press Ctrl+C to stop.');
16646
+ if (coreOnly) {
16647
+ // Readiness barrier release (codex P1-3): restoreActiveSessions + v3 attach +
16648
+ // scheduler + signal handlers are all wired now, so the HTTP surface is safe
16649
+ // to drive. Flip /healthz → 200 THEN print the machine-parseable ready line.
16650
+ // riff's launcher waits for either signal before pointing its client here, so
16651
+ // a trigger can't race a durable restore (transient not_found / re-fire).
16652
+ // Exact ready-line text is a locked contract (regex ^\[core-only\] listening on ).
16653
+ setCoreOnlyReady();
16654
+ // eslint-disable-next-line no-console
16655
+ console.log(`[core-only] listening on 127.0.0.1:${ipcHandle.port} (bot ${cfg.larkAppId}, cli ${cfg.cliId})`);
16656
+ }
15779
16657
  }
15780
16658
  //# sourceMappingURL=daemon.js.map