botmux 3.10.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (539) hide show
  1. package/dist/.runtime-build-id +1 -1
  2. package/dist/adapters/backend/herdr-backend.d.ts +4 -3
  3. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  4. package/dist/adapters/backend/herdr-backend.js +11 -8
  5. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  6. package/dist/adapters/backend/pty-backend.d.ts +1 -1
  7. package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
  8. package/dist/adapters/backend/pty-backend.js +4 -1
  9. package/dist/adapters/backend/pty-backend.js.map +1 -1
  10. package/dist/adapters/backend/riff-backend.d.ts +29 -5
  11. package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
  12. package/dist/adapters/backend/riff-backend.js +302 -31
  13. package/dist/adapters/backend/riff-backend.js.map +1 -1
  14. package/dist/adapters/backend/sandbox.d.ts +6 -0
  15. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  16. package/dist/adapters/backend/sandbox.js +7 -0
  17. package/dist/adapters/backend/sandbox.js.map +1 -1
  18. package/dist/adapters/backend/tmux-backend.d.ts +1 -1
  19. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  20. package/dist/adapters/backend/tmux-backend.js +4 -1
  21. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  22. package/dist/adapters/backend/tmux-pipe-backend.d.ts +1 -1
  23. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  24. package/dist/adapters/backend/tmux-pipe-backend.js +1 -1
  25. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  26. package/dist/adapters/backend/types.d.ts +37 -3
  27. package/dist/adapters/backend/types.d.ts.map +1 -1
  28. package/dist/adapters/backend/types.js.map +1 -1
  29. package/dist/adapters/backend/zellij-backend.d.ts +3 -3
  30. package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
  31. package/dist/adapters/backend/zellij-backend.js +9 -3
  32. package/dist/adapters/backend/zellij-backend.js.map +1 -1
  33. package/dist/adapters/backend/zellij-observe-backend.d.ts +3 -3
  34. package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -1
  35. package/dist/adapters/backend/zellij-observe-backend.js +12 -7
  36. package/dist/adapters/backend/zellij-observe-backend.js.map +1 -1
  37. package/dist/adapters/cli/codex-app.d.ts.map +1 -1
  38. package/dist/adapters/cli/codex-app.js +11 -2
  39. package/dist/adapters/cli/codex-app.js.map +1 -1
  40. package/dist/adapters/cli/read-isolation.d.ts +35 -2
  41. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  42. package/dist/adapters/cli/read-isolation.js +88 -7
  43. package/dist/adapters/cli/read-isolation.js.map +1 -1
  44. package/dist/adapters/cli/runner-input.d.ts +15 -14
  45. package/dist/adapters/cli/runner-input.d.ts.map +1 -1
  46. package/dist/adapters/cli/runner-input.js +64 -26
  47. package/dist/adapters/cli/runner-input.js.map +1 -1
  48. package/dist/adapters/cli/types.d.ts +20 -5
  49. package/dist/adapters/cli/types.d.ts.map +1 -1
  50. package/dist/bot-registry.d.ts +24 -0
  51. package/dist/bot-registry.d.ts.map +1 -1
  52. package/dist/bot-registry.js +110 -5
  53. package/dist/bot-registry.js.map +1 -1
  54. package/dist/cli/fleet-shutdown.d.ts +47 -0
  55. package/dist/cli/fleet-shutdown.d.ts.map +1 -0
  56. package/dist/cli/fleet-shutdown.js +387 -0
  57. package/dist/cli/fleet-shutdown.js.map +1 -0
  58. package/dist/cli/pm2-descriptor-guard.d.ts +19 -0
  59. package/dist/cli/pm2-descriptor-guard.d.ts.map +1 -0
  60. package/dist/cli/pm2-descriptor-guard.js +122 -0
  61. package/dist/cli/pm2-descriptor-guard.js.map +1 -0
  62. package/dist/cli/pm2-exact-start.d.ts +16 -0
  63. package/dist/cli/pm2-exact-start.d.ts.map +1 -0
  64. package/dist/cli/pm2-exact-start.js +63 -0
  65. package/dist/cli/pm2-exact-start.js.map +1 -0
  66. package/dist/cli/pm2-god-admission.d.ts +8 -0
  67. package/dist/cli/pm2-god-admission.d.ts.map +1 -0
  68. package/dist/cli/pm2-god-admission.js +25 -0
  69. package/dist/cli/pm2-god-admission.js.map +1 -0
  70. package/dist/cli/pm2-jlist.d.ts +13 -0
  71. package/dist/cli/pm2-jlist.d.ts.map +1 -0
  72. package/dist/cli/pm2-jlist.js +108 -0
  73. package/dist/cli/pm2-jlist.js.map +1 -0
  74. package/dist/cli/pm2-preflight.d.ts +10 -0
  75. package/dist/cli/pm2-preflight.d.ts.map +1 -0
  76. package/dist/cli/pm2-preflight.js +23 -0
  77. package/dist/cli/pm2-preflight.js.map +1 -0
  78. package/dist/cli/pm2-shutdown-capability.d.ts +27 -0
  79. package/dist/cli/pm2-shutdown-capability.d.ts.map +1 -0
  80. package/dist/cli/pm2-shutdown-capability.js +161 -0
  81. package/dist/cli/pm2-shutdown-capability.js.map +1 -0
  82. package/dist/cli/pm2-start-transaction.d.ts +55 -0
  83. package/dist/cli/pm2-start-transaction.d.ts.map +1 -0
  84. package/dist/cli/pm2-start-transaction.js +220 -0
  85. package/dist/cli/pm2-start-transaction.js.map +1 -0
  86. package/dist/cli/send-dispatch.d.ts +12 -0
  87. package/dist/cli/send-dispatch.d.ts.map +1 -1
  88. package/dist/cli/send-dispatch.js +31 -4
  89. package/dist/cli/send-dispatch.js.map +1 -1
  90. package/dist/cli/session-list-liveness.d.ts +1 -0
  91. package/dist/cli/session-list-liveness.d.ts.map +1 -1
  92. package/dist/cli/session-list-liveness.js +6 -0
  93. package/dist/cli/session-list-liveness.js.map +1 -1
  94. package/dist/cli/session-picker-layout.d.ts +26 -0
  95. package/dist/cli/session-picker-layout.d.ts.map +1 -0
  96. package/dist/cli/session-picker-layout.js +123 -0
  97. package/dist/cli/session-picker-layout.js.map +1 -0
  98. package/dist/cli/session-picker-viewport.d.ts +49 -0
  99. package/dist/cli/session-picker-viewport.d.ts.map +1 -0
  100. package/dist/cli/session-picker-viewport.js +46 -0
  101. package/dist/cli/session-picker-viewport.js.map +1 -0
  102. package/dist/cli/supervisor-shutdown-client.d.ts +31 -0
  103. package/dist/cli/supervisor-shutdown-client.d.ts.map +1 -0
  104. package/dist/cli/supervisor-shutdown-client.js +146 -0
  105. package/dist/cli/supervisor-shutdown-client.js.map +1 -0
  106. package/dist/cli/terminal-width.d.ts +48 -0
  107. package/dist/cli/terminal-width.d.ts.map +1 -0
  108. package/dist/cli/terminal-width.js +119 -0
  109. package/dist/cli/terminal-width.js.map +1 -0
  110. package/dist/cli/vc-agent.d.ts.map +1 -1
  111. package/dist/cli/vc-agent.js +1 -1
  112. package/dist/cli/vc-agent.js.map +1 -1
  113. package/dist/cli.d.ts.map +1 -1
  114. package/dist/cli.js +2269 -716
  115. package/dist/cli.js.map +1 -1
  116. package/dist/codex-app-runner.js +1630 -125
  117. package/dist/codex-app-runner.js.map +1 -1
  118. package/dist/core/ask-api.d.ts +8 -1
  119. package/dist/core/ask-api.d.ts.map +1 -1
  120. package/dist/core/ask-api.js +18 -0
  121. package/dist/core/ask-api.js.map +1 -1
  122. package/dist/core/ask-broker.d.ts +20 -0
  123. package/dist/core/ask-broker.d.ts.map +1 -1
  124. package/dist/core/ask-broker.js +467 -47
  125. package/dist/core/ask-broker.js.map +1 -1
  126. package/dist/core/ask-persist-store.d.ts +126 -0
  127. package/dist/core/ask-persist-store.d.ts.map +1 -0
  128. package/dist/core/ask-persist-store.js +188 -0
  129. package/dist/core/ask-persist-store.js.map +1 -0
  130. package/dist/core/ask-types.d.ts +80 -0
  131. package/dist/core/ask-types.d.ts.map +1 -1
  132. package/dist/core/ask-types.js +62 -0
  133. package/dist/core/ask-types.js.map +1 -1
  134. package/dist/core/cli-session-id-publisher.d.ts +20 -0
  135. package/dist/core/cli-session-id-publisher.d.ts.map +1 -0
  136. package/dist/core/cli-session-id-publisher.js +21 -0
  137. package/dist/core/cli-session-id-publisher.js.map +1 -0
  138. package/dist/core/command-handler.d.ts.map +1 -1
  139. package/dist/core/command-handler.js +427 -255
  140. package/dist/core/command-handler.js.map +1 -1
  141. package/dist/core/cost-calculator.d.ts +13 -0
  142. package/dist/core/cost-calculator.d.ts.map +1 -1
  143. package/dist/core/cost-calculator.js +385 -26
  144. package/dist/core/cost-calculator.js.map +1 -1
  145. package/dist/core/dashboard-ipc-server.d.ts +9 -0
  146. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  147. package/dist/core/dashboard-ipc-server.js +842 -288
  148. package/dist/core/dashboard-ipc-server.js.map +1 -1
  149. package/dist/core/explicit-session-backing-cleanup.d.ts +49 -0
  150. package/dist/core/explicit-session-backing-cleanup.d.ts.map +1 -0
  151. package/dist/core/explicit-session-backing-cleanup.js +76 -0
  152. package/dist/core/explicit-session-backing-cleanup.js.map +1 -0
  153. package/dist/core/idle-worker-sweeper.d.ts +20 -0
  154. package/dist/core/idle-worker-sweeper.d.ts.map +1 -1
  155. package/dist/core/idle-worker-sweeper.js +18 -0
  156. package/dist/core/idle-worker-sweeper.js.map +1 -1
  157. package/dist/core/inflight-input-tracker.d.ts +10 -0
  158. package/dist/core/inflight-input-tracker.d.ts.map +1 -1
  159. package/dist/core/inflight-input-tracker.js +7 -0
  160. package/dist/core/inflight-input-tracker.js.map +1 -1
  161. package/dist/core/managed-origin-attestation.d.ts +51 -0
  162. package/dist/core/managed-origin-attestation.d.ts.map +1 -0
  163. package/dist/core/managed-origin-attestation.js +208 -0
  164. package/dist/core/managed-origin-attestation.js.map +1 -0
  165. package/dist/core/managed-origin-capability.d.ts +62 -3
  166. package/dist/core/managed-origin-capability.d.ts.map +1 -1
  167. package/dist/core/managed-origin-capability.js +419 -9
  168. package/dist/core/managed-origin-capability.js.map +1 -1
  169. package/dist/core/pending-repo-journal.d.ts +6 -0
  170. package/dist/core/pending-repo-journal.d.ts.map +1 -0
  171. package/dist/core/pending-repo-journal.js +85 -0
  172. package/dist/core/pending-repo-journal.js.map +1 -0
  173. package/dist/core/persistent-backend.d.ts +6 -1
  174. package/dist/core/persistent-backend.d.ts.map +1 -1
  175. package/dist/core/persistent-backend.js +12 -6
  176. package/dist/core/persistent-backend.js.map +1 -1
  177. package/dist/core/process-start-identity.d.ts +3 -0
  178. package/dist/core/process-start-identity.d.ts.map +1 -0
  179. package/dist/core/process-start-identity.js +62 -0
  180. package/dist/core/process-start-identity.js.map +1 -0
  181. package/dist/core/raw-command-writer.d.ts +34 -0
  182. package/dist/core/raw-command-writer.d.ts.map +1 -0
  183. package/dist/core/raw-command-writer.js +48 -0
  184. package/dist/core/raw-command-writer.js.map +1 -0
  185. package/dist/core/reply-target.d.ts +85 -22
  186. package/dist/core/reply-target.d.ts.map +1 -1
  187. package/dist/core/reply-target.js +267 -32
  188. package/dist/core/reply-target.js.map +1 -1
  189. package/dist/core/resource-monitor/runtime.d.ts.map +1 -1
  190. package/dist/core/resource-monitor/runtime.js +4 -1
  191. package/dist/core/resource-monitor/runtime.js.map +1 -1
  192. package/dist/core/restart-report.d.ts +6 -1
  193. package/dist/core/restart-report.d.ts.map +1 -1
  194. package/dist/core/restart-report.js +22 -4
  195. package/dist/core/restart-report.js.map +1 -1
  196. package/dist/core/riff-shutdown-detach.d.ts +164 -0
  197. package/dist/core/riff-shutdown-detach.d.ts.map +1 -0
  198. package/dist/core/riff-shutdown-detach.js +787 -0
  199. package/dist/core/riff-shutdown-detach.js.map +1 -0
  200. package/dist/core/riff-worker-shutdown-readiness.d.ts +17 -0
  201. package/dist/core/riff-worker-shutdown-readiness.d.ts.map +1 -0
  202. package/dist/core/riff-worker-shutdown-readiness.js +21 -0
  203. package/dist/core/riff-worker-shutdown-readiness.js.map +1 -0
  204. package/dist/core/session-manager.d.ts +6 -6
  205. package/dist/core/session-manager.d.ts.map +1 -1
  206. package/dist/core/session-manager.js +1227 -594
  207. package/dist/core/session-manager.js.map +1 -1
  208. package/dist/core/session-marker.d.ts +1 -1
  209. package/dist/core/session-marker.d.ts.map +1 -1
  210. package/dist/core/session-marker.js +2 -2
  211. package/dist/core/session-marker.js.map +1 -1
  212. package/dist/core/session-mutation-guard.d.ts +17 -0
  213. package/dist/core/session-mutation-guard.d.ts.map +1 -0
  214. package/dist/core/session-mutation-guard.js +33 -0
  215. package/dist/core/session-mutation-guard.js.map +1 -0
  216. package/dist/core/shutdown-budgets.d.ts +27 -0
  217. package/dist/core/shutdown-budgets.d.ts.map +1 -0
  218. package/dist/core/shutdown-budgets.js +47 -0
  219. package/dist/core/shutdown-budgets.js.map +1 -0
  220. package/dist/core/supervisor-shutdown-ipc.d.ts +10 -0
  221. package/dist/core/supervisor-shutdown-ipc.d.ts.map +1 -0
  222. package/dist/core/supervisor-shutdown-ipc.js +10 -0
  223. package/dist/core/supervisor-shutdown-ipc.js.map +1 -0
  224. package/dist/core/supervisor-shutdown-protocol.d.ts +16 -0
  225. package/dist/core/supervisor-shutdown-protocol.d.ts.map +1 -0
  226. package/dist/core/supervisor-shutdown-protocol.js +16 -0
  227. package/dist/core/supervisor-shutdown-protocol.js.map +1 -0
  228. package/dist/core/trigger-final-suppression.d.ts +4 -0
  229. package/dist/core/trigger-final-suppression.d.ts.map +1 -1
  230. package/dist/core/trigger-final-suppression.js +6 -11
  231. package/dist/core/trigger-final-suppression.js.map +1 -1
  232. package/dist/core/trigger-session.d.ts +70 -0
  233. package/dist/core/trigger-session.d.ts.map +1 -1
  234. package/dist/core/trigger-session.js +923 -145
  235. package/dist/core/trigger-session.js.map +1 -1
  236. package/dist/core/types.d.ts +94 -1
  237. package/dist/core/types.d.ts.map +1 -1
  238. package/dist/core/types.js +9 -0
  239. package/dist/core/types.js.map +1 -1
  240. package/dist/core/worker-pool.d.ts +194 -13
  241. package/dist/core/worker-pool.d.ts.map +1 -1
  242. package/dist/core/worker-pool.js +2597 -547
  243. package/dist/core/worker-pool.js.map +1 -1
  244. package/dist/daemon.d.ts +76 -2
  245. package/dist/daemon.d.ts.map +1 -1
  246. package/dist/daemon.js +2255 -773
  247. package/dist/daemon.js.map +1 -1
  248. package/dist/dashboard/bot-payload.d.ts +20 -0
  249. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  250. package/dist/dashboard/bot-payload.js +24 -0
  251. package/dist/dashboard/bot-payload.js.map +1 -1
  252. package/dist/dashboard/feed-groups.d.ts +17 -0
  253. package/dist/dashboard/feed-groups.d.ts.map +1 -0
  254. package/dist/dashboard/feed-groups.js +75 -0
  255. package/dist/dashboard/feed-groups.js.map +1 -0
  256. package/dist/dashboard/session-card-model.d.ts +1 -1
  257. package/dist/dashboard/session-card-model.d.ts.map +1 -1
  258. package/dist/dashboard/session-card-model.js +8 -5
  259. package/dist/dashboard/session-card-model.js.map +1 -1
  260. package/dist/dashboard/web/bot-defaults-page.d.ts +4 -0
  261. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  262. package/dist/dashboard/web/bot-defaults-page.js +139 -46
  263. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  264. package/dist/dashboard/web/bot-defaults.d.ts +4 -0
  265. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  266. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  267. package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
  268. package/dist/dashboard/web/connectors-page.js +40 -3
  269. package/dist/dashboard/web/connectors-page.js.map +1 -1
  270. package/dist/dashboard/web/groups-page.d.ts.map +1 -1
  271. package/dist/dashboard/web/groups-page.js +95 -2
  272. package/dist/dashboard/web/groups-page.js.map +1 -1
  273. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  274. package/dist/dashboard/web/i18n.js +86 -40
  275. package/dist/dashboard/web/i18n.js.map +1 -1
  276. package/dist/dashboard/web/kanban-model.js +1 -1
  277. package/dist/dashboard/web/kanban-model.js.map +1 -1
  278. package/dist/dashboard/web/overview-page.d.ts.map +1 -1
  279. package/dist/dashboard/web/overview-page.js +3 -2
  280. package/dist/dashboard/web/overview-page.js.map +1 -1
  281. package/dist/dashboard/web/overview.d.ts +3 -0
  282. package/dist/dashboard/web/overview.d.ts.map +1 -1
  283. package/dist/dashboard/web/overview.js +2 -0
  284. package/dist/dashboard/web/overview.js.map +1 -1
  285. package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
  286. package/dist/dashboard/web/sessions-kanban.js +2 -0
  287. package/dist/dashboard/web/sessions-kanban.js.map +1 -1
  288. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  289. package/dist/dashboard/web/sessions-page.js +103 -2
  290. package/dist/dashboard/web/sessions-page.js.map +1 -1
  291. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  292. package/dist/dashboard/web/sessions.js +3 -2
  293. package/dist/dashboard/web/sessions.js.map +1 -1
  294. package/dist/dashboard/web/ui.d.ts +8 -0
  295. package/dist/dashboard/web/ui.d.ts.map +1 -1
  296. package/dist/dashboard/web/ui.js +15 -0
  297. package/dist/dashboard/web/ui.js.map +1 -1
  298. package/dist/dashboard-web/app.js +1 -1
  299. package/dist/dashboard-web/chunks/bot-defaults-page-GED4FEMS.js +10 -0
  300. package/dist/dashboard-web/chunks/chunk-6J5LS5AU.js +51 -0
  301. package/dist/dashboard-web/chunks/{chunk-ZU3UUKAR.js → chunk-HK7NIBJG.js} +1 -1
  302. package/dist/dashboard-web/chunks/{chunk-3UE7S77S.js → chunk-KRHWZEXU.js} +1 -1
  303. package/dist/dashboard-web/chunks/chunk-VBXJOJFH.js +1 -0
  304. package/dist/dashboard-web/chunks/connectors-page-EELM66G3.js +1 -0
  305. package/dist/dashboard-web/chunks/groups-page-WKUV6E42.js +8 -0
  306. package/dist/dashboard-web/chunks/{insights-page-T2ZX2RWL.js → insights-page-54UBPPOC.js} +1 -1
  307. package/dist/dashboard-web/chunks/{monitor-room-YNVBAGR6.js → monitor-room-PORHVKRH.js} +1 -1
  308. package/dist/dashboard-web/chunks/{monitoring-page-LHM55YWN.js → monitoring-page-XGA7AXNL.js} +1 -1
  309. package/dist/dashboard-web/chunks/{office-page-ATXVP7PW.js → office-page-AO3AFG45.js} +1 -1
  310. package/dist/dashboard-web/chunks/overview-page-F5BUTYR6.js +1 -0
  311. package/dist/dashboard-web/chunks/{roles-page-56VWM6CD.js → roles-page-M5GIASJU.js} +1 -1
  312. package/dist/dashboard-web/chunks/{schedules-page-S5K6U4EO.js → schedules-page-O232WQRR.js} +1 -1
  313. package/dist/dashboard-web/chunks/sessions-page-YFRPSTIL.js +6 -0
  314. package/dist/dashboard-web/chunks/{settings-page-QUZDQARR.js → settings-page-OJAZLZQT.js} +1 -1
  315. package/dist/dashboard-web/chunks/{skills-page-IFY7AEND.js → skills-page-EFZQPWV4.js} +1 -1
  316. package/dist/dashboard-web/chunks/{team-federation-page-C7FJQPFZ.js → team-federation-page-ZJVAP7HW.js} +1 -1
  317. package/dist/dashboard-web/chunks/{v3-page-2LMF3WWR.js → v3-page-GYWTRGO2.js} +1 -1
  318. package/dist/dashboard-web/chunks/{whiteboards-page-3XC6QRMY.js → whiteboards-page-MSDAG6P7.js} +1 -1
  319. package/dist/dashboard-web/style.css +255 -0
  320. package/dist/dashboard.js +172 -7
  321. package/dist/dashboard.js.map +1 -1
  322. package/dist/desktop/renderer/app.js +21 -0
  323. package/dist/desktop/renderer/app.js.map +1 -1
  324. package/dist/i18n/en.d.ts.map +1 -1
  325. package/dist/i18n/en.js +35 -9
  326. package/dist/i18n/en.js.map +1 -1
  327. package/dist/i18n/zh.d.ts.map +1 -1
  328. package/dist/i18n/zh.js +35 -9
  329. package/dist/i18n/zh.js.map +1 -1
  330. package/dist/im/lark/ask-card.d.ts +23 -0
  331. package/dist/im/lark/ask-card.d.ts.map +1 -1
  332. package/dist/im/lark/ask-card.js +94 -4
  333. package/dist/im/lark/ask-card.js.map +1 -1
  334. package/dist/im/lark/card-builder.d.ts +6 -1
  335. package/dist/im/lark/card-builder.d.ts.map +1 -1
  336. package/dist/im/lark/card-builder.js +112 -15
  337. package/dist/im/lark/card-builder.js.map +1 -1
  338. package/dist/im/lark/card-handler.d.ts +1 -1
  339. package/dist/im/lark/card-handler.d.ts.map +1 -1
  340. package/dist/im/lark/card-handler.js +496 -310
  341. package/dist/im/lark/card-handler.js.map +1 -1
  342. package/dist/im/lark/client.d.ts +7 -0
  343. package/dist/im/lark/client.d.ts.map +1 -1
  344. package/dist/im/lark/client.js +39 -26
  345. package/dist/im/lark/client.js.map +1 -1
  346. package/dist/im/lark/doc-comment.d.ts +7 -4
  347. package/dist/im/lark/doc-comment.d.ts.map +1 -1
  348. package/dist/im/lark/doc-comment.js +19 -6
  349. package/dist/im/lark/doc-comment.js.map +1 -1
  350. package/dist/im/lark/event-dispatcher.d.ts +16 -0
  351. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  352. package/dist/im/lark/event-dispatcher.js +88 -23
  353. package/dist/im/lark/event-dispatcher.js.map +1 -1
  354. package/dist/im/lark/forward-followup-content.d.ts.map +1 -1
  355. package/dist/im/lark/forward-followup-content.js +9 -1
  356. package/dist/im/lark/forward-followup-content.js.map +1 -1
  357. package/dist/im/lark/grant-command.d.ts.map +1 -1
  358. package/dist/im/lark/grant-command.js +7 -4
  359. package/dist/im/lark/grant-command.js.map +1 -1
  360. package/dist/im/lark/md-card.d.ts +11 -0
  361. package/dist/im/lark/md-card.d.ts.map +1 -1
  362. package/dist/im/lark/md-card.js +46 -0
  363. package/dist/im/lark/md-card.js.map +1 -1
  364. package/dist/im/lark/message-parser.d.ts +23 -2
  365. package/dist/im/lark/message-parser.d.ts.map +1 -1
  366. package/dist/im/lark/message-parser.js +58 -3
  367. package/dist/im/lark/message-parser.js.map +1 -1
  368. package/dist/im/lark/overview-card.d.ts.map +1 -1
  369. package/dist/im/lark/overview-card.js +2 -1
  370. package/dist/im/lark/overview-card.js.map +1 -1
  371. package/dist/services/async-trigger-state.d.ts +6 -1
  372. package/dist/services/async-trigger-state.d.ts.map +1 -1
  373. package/dist/services/async-trigger-state.js +21 -0
  374. package/dist/services/async-trigger-state.js.map +1 -1
  375. package/dist/services/async-trigger-store.d.ts +23 -1
  376. package/dist/services/async-trigger-store.d.ts.map +1 -1
  377. package/dist/services/async-trigger-store.js +96 -13
  378. package/dist/services/async-trigger-store.js.map +1 -1
  379. package/dist/services/bridge-fallback-gate.d.ts +40 -0
  380. package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
  381. package/dist/services/bridge-fallback-gate.js +124 -24
  382. package/dist/services/bridge-fallback-gate.js.map +1 -1
  383. package/dist/services/codex-app-runner-protocol.d.ts +8 -0
  384. package/dist/services/codex-app-runner-protocol.d.ts.map +1 -1
  385. package/dist/services/codex-app-runner-protocol.js +9 -2
  386. package/dist/services/codex-app-runner-protocol.js.map +1 -1
  387. package/dist/services/codex-bridge-queue.d.ts +1 -0
  388. package/dist/services/codex-bridge-queue.d.ts.map +1 -1
  389. package/dist/services/codex-bridge-queue.js +1 -0
  390. package/dist/services/codex-bridge-queue.js.map +1 -1
  391. package/dist/services/codex-service-tier.d.ts +2 -0
  392. package/dist/services/codex-service-tier.d.ts.map +1 -1
  393. package/dist/services/codex-service-tier.js +1 -0
  394. package/dist/services/codex-service-tier.js.map +1 -1
  395. package/dist/services/codex-transcript.d.ts +27 -0
  396. package/dist/services/codex-transcript.d.ts.map +1 -1
  397. package/dist/services/codex-transcript.js +340 -2
  398. package/dist/services/codex-transcript.js.map +1 -1
  399. package/dist/services/grant-policy.d.ts +3 -0
  400. package/dist/services/grant-policy.d.ts.map +1 -1
  401. package/dist/services/grant-policy.js +5 -0
  402. package/dist/services/grant-policy.js.map +1 -1
  403. package/dist/services/grant-prefs-store.d.ts +6 -3
  404. package/dist/services/grant-prefs-store.d.ts.map +1 -1
  405. package/dist/services/grant-prefs-store.js +42 -12
  406. package/dist/services/grant-prefs-store.js.map +1 -1
  407. package/dist/services/hook-runner.d.ts +20 -1
  408. package/dist/services/hook-runner.d.ts.map +1 -1
  409. package/dist/services/hook-runner.js +17 -13
  410. package/dist/services/hook-runner.js.map +1 -1
  411. package/dist/services/idempotency-store.d.ts +134 -0
  412. package/dist/services/idempotency-store.d.ts.map +1 -0
  413. package/dist/services/idempotency-store.js +299 -0
  414. package/dist/services/idempotency-store.js.map +1 -0
  415. package/dist/services/jsonl-cursor.d.ts +7 -3
  416. package/dist/services/jsonl-cursor.d.ts.map +1 -1
  417. package/dist/services/jsonl-cursor.js +55 -31
  418. package/dist/services/jsonl-cursor.js.map +1 -1
  419. package/dist/services/open-platform-rename.d.ts.map +1 -1
  420. package/dist/services/open-platform-rename.js +1 -108
  421. package/dist/services/open-platform-rename.js.map +1 -1
  422. package/dist/services/restart-intent-store.d.ts +22 -2
  423. package/dist/services/restart-intent-store.d.ts.map +1 -1
  424. package/dist/services/restart-intent-store.js +160 -18
  425. package/dist/services/restart-intent-store.js.map +1 -1
  426. package/dist/services/sandbox-store.d.ts +22 -0
  427. package/dist/services/sandbox-store.d.ts.map +1 -1
  428. package/dist/services/sandbox-store.js +37 -8
  429. package/dist/services/sandbox-store.js.map +1 -1
  430. package/dist/services/send-policy.d.ts +41 -33
  431. package/dist/services/send-policy.d.ts.map +1 -1
  432. package/dist/services/send-policy.js +39 -40
  433. package/dist/services/send-policy.js.map +1 -1
  434. package/dist/services/session-store.d.ts +66 -0
  435. package/dist/services/session-store.d.ts.map +1 -1
  436. package/dist/services/session-store.js +403 -67
  437. package/dist/services/session-store.js.map +1 -1
  438. package/dist/services/submit-notification.d.ts +3 -0
  439. package/dist/services/submit-notification.d.ts.map +1 -0
  440. package/dist/services/submit-notification.js +15 -0
  441. package/dist/services/submit-notification.js.map +1 -0
  442. package/dist/services/traex-transcript.d.ts +25 -2
  443. package/dist/services/traex-transcript.d.ts.map +1 -1
  444. package/dist/services/traex-transcript.js +133 -1
  445. package/dist/services/traex-transcript.js.map +1 -1
  446. package/dist/services/trigger-types.d.ts +13 -1
  447. package/dist/services/trigger-types.d.ts.map +1 -1
  448. package/dist/services/trigger-types.js +39 -0
  449. package/dist/services/trigger-types.js.map +1 -1
  450. package/dist/services/voice/index.d.ts +3 -2
  451. package/dist/services/voice/index.d.ts.map +1 -1
  452. package/dist/services/voice/index.js +5 -5
  453. package/dist/services/voice/index.js.map +1 -1
  454. package/dist/services/voice/openai.d.ts +2 -1
  455. package/dist/services/voice/openai.d.ts.map +1 -1
  456. package/dist/services/voice/openai.js +2 -1
  457. package/dist/services/voice/openai.js.map +1 -1
  458. package/dist/services/voice/sami.d.ts +5 -2
  459. package/dist/services/voice/sami.d.ts.map +1 -1
  460. package/dist/services/voice/sami.js +19 -4
  461. package/dist/services/voice/sami.js.map +1 -1
  462. package/dist/setup/bots-store.d.ts.map +1 -1
  463. package/dist/setup/bots-store.js +10 -4
  464. package/dist/setup/bots-store.js.map +1 -1
  465. package/dist/setup/lark-scopes.json +2 -0
  466. package/dist/setup/open-platform-automation.d.ts +9 -1
  467. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  468. package/dist/setup/open-platform-automation.js +44 -15
  469. package/dist/setup/open-platform-automation.js.map +1 -1
  470. package/dist/setup/open-platform-visibility.d.ts +42 -0
  471. package/dist/setup/open-platform-visibility.d.ts.map +1 -0
  472. package/dist/setup/open-platform-visibility.js +133 -0
  473. package/dist/setup/open-platform-visibility.js.map +1 -0
  474. package/dist/setup/verify-permissions.d.ts.map +1 -1
  475. package/dist/setup/verify-permissions.js +6 -0
  476. package/dist/setup/verify-permissions.js.map +1 -1
  477. package/dist/skills/definitions.js +2 -2
  478. package/dist/types.d.ts +290 -13
  479. package/dist/types.d.ts.map +1 -1
  480. package/dist/utils/bot-routing.d.ts +1 -0
  481. package/dist/utils/bot-routing.d.ts.map +1 -1
  482. package/dist/utils/bot-routing.js +1 -1
  483. package/dist/utils/bot-routing.js.map +1 -1
  484. package/dist/utils/child-env.d.ts +1 -1
  485. package/dist/utils/child-env.d.ts.map +1 -1
  486. package/dist/utils/child-env.js +9 -0
  487. package/dist/utils/child-env.js.map +1 -1
  488. package/dist/utils/codex-app-control.d.ts +438 -0
  489. package/dist/utils/codex-app-control.d.ts.map +1 -0
  490. package/dist/utils/codex-app-control.js +2336 -0
  491. package/dist/utils/codex-app-control.js.map +1 -0
  492. package/dist/utils/codex-app-dispatch-ledger.d.ts +78 -0
  493. package/dist/utils/codex-app-dispatch-ledger.d.ts.map +1 -0
  494. package/dist/utils/codex-app-dispatch-ledger.js +149 -0
  495. package/dist/utils/codex-app-dispatch-ledger.js.map +1 -0
  496. package/dist/utils/codex-app-turn-dispatch.d.ts +77 -0
  497. package/dist/utils/codex-app-turn-dispatch.d.ts.map +1 -0
  498. package/dist/utils/codex-app-turn-dispatch.js +141 -0
  499. package/dist/utils/codex-app-turn-dispatch.js.map +1 -0
  500. package/dist/utils/codex-app-turn-liveness.d.ts +125 -0
  501. package/dist/utils/codex-app-turn-liveness.d.ts.map +1 -0
  502. package/dist/utils/codex-app-turn-liveness.js +301 -0
  503. package/dist/utils/codex-app-turn-liveness.js.map +1 -0
  504. package/dist/utils/idle-detector.d.ts +12 -0
  505. package/dist/utils/idle-detector.d.ts.map +1 -1
  506. package/dist/utils/idle-detector.js +19 -0
  507. package/dist/utils/idle-detector.js.map +1 -1
  508. package/dist/utils/input-gate.d.ts +87 -0
  509. package/dist/utils/input-gate.d.ts.map +1 -1
  510. package/dist/utils/input-gate.js +86 -0
  511. package/dist/utils/input-gate.js.map +1 -1
  512. package/dist/utils/lark-upload.d.ts.map +1 -1
  513. package/dist/utils/lark-upload.js +52 -1
  514. package/dist/utils/lark-upload.js.map +1 -1
  515. package/dist/utils/pending-input-queue.d.ts +15 -0
  516. package/dist/utils/pending-input-queue.d.ts.map +1 -1
  517. package/dist/utils/pending-input-queue.js +5 -1
  518. package/dist/utils/pending-input-queue.js.map +1 -1
  519. package/dist/utils/user-token.d.ts.map +1 -1
  520. package/dist/utils/user-token.js +53 -2
  521. package/dist/utils/user-token.js.map +1 -1
  522. package/dist/worker-ipc-preload.d.ts +12 -0
  523. package/dist/worker-ipc-preload.d.ts.map +1 -0
  524. package/dist/worker-ipc-preload.js +44 -0
  525. package/dist/worker-ipc-preload.js.map +1 -0
  526. package/dist/worker.js +2604 -325
  527. package/dist/worker.js.map +1 -1
  528. package/dist/workflows/v3/session-relay-client.d.ts +1 -0
  529. package/dist/workflows/v3/session-relay-client.d.ts.map +1 -1
  530. package/dist/workflows/v3/session-relay-client.js +12 -3
  531. package/dist/workflows/v3/session-relay-client.js.map +1 -1
  532. package/package.json +1 -1
  533. package/dist/dashboard-web/chunks/bot-defaults-page-LTVOUDIS.js +0 -10
  534. package/dist/dashboard-web/chunks/chunk-DCS35UMD.js +0 -1
  535. package/dist/dashboard-web/chunks/chunk-MFQPCVHY.js +0 -51
  536. package/dist/dashboard-web/chunks/connectors-page-5KWDIMKC.js +0 -1
  537. package/dist/dashboard-web/chunks/groups-page-FHOKYXMW.js +0 -8
  538. package/dist/dashboard-web/chunks/overview-page-QYI3KBDD.js +0 -1
  539. package/dist/dashboard-web/chunks/sessions-page-WBP4N7D2.js +0 -6
@@ -1,7 +1,7 @@
1
1
  // src/core/dashboard-ipc-server.ts
2
2
  import { createServer } from 'node:http';
3
3
  import { execFileSync } from 'node:child_process';
4
- import { readFileSync } from 'node:fs';
4
+ import { readFileSync, unlinkSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { logger } from '../utils/logger.js';
7
7
  import { cliAuthBind, verifyHmac } from '../dashboard/auth.js';
@@ -9,6 +9,7 @@ import { WORKFLOW_DAEMON_IPC_ROUTE_PREFIX } from '../workflows/v3/daemon-ipc-aut
9
9
  import { V3_SESSION_RUN_MUTATION_ROUTE_PREFIX } from '../workflows/v3/session-relay.js';
10
10
  import { listenWithProbe } from '../utils/listen-with-probe.js';
11
11
  import { dashboardSecretPath } from './dashboard-secret.js';
12
+ import { MANAGED_ORIGIN_ATTEST_ROUTE, MANAGED_ORIGIN_PROOF_DOMAIN, MANAGED_ORIGIN_PROOF_TTL_MS, writeManagedOriginAttestationProof, } from './managed-origin-attestation.js';
12
13
  import * as sessionStore from '../services/session-store.js';
13
14
  import { cliSupportsNativeUsage } from '../services/transcript-resolver.js';
14
15
  import * as asyncTriggerStore from '../services/async-trigger-store.js';
@@ -22,6 +23,7 @@ import * as sandboxStore from '../services/sandbox-store.js';
22
23
  import * as backendTypeStore from '../services/backend-type-store.js';
23
24
  import { isValidRiffBaseUrl, isValidRiffSandboxCluster } from '../adapters/backend/riff-backend.js';
24
25
  import { ensureBackendAvailable } from '../services/backend-availability.js';
26
+ import * as persistentBackend from './persistent-backend.js';
25
27
  import * as cardPrefsStore from '../services/card-prefs-store.js';
26
28
  import * as substituteModeStore from '../services/substitute-mode-store.js';
27
29
  import { createCliAdapterSync } from '../adapters/cli/registry.js';
@@ -77,7 +79,7 @@ import { isSessionStopped } from './session-liveness.js';
77
79
  import { isSuspendableBackendType } from './persistent-backend.js';
78
80
  import { getChatMode, replyMessage, sendMessage, resolveUnionIdFromOpenId, listThreadMessages, listChatMessages, listChatMessagesUntil, listChatBotMembers, getUserProfile, getUserProfileStrict, resolveAllowedUsersWithMap } from '../im/lark/client.js';
79
81
  import { parseApiMessage, cardContentHasUpgradeFallback, resolveMergedCardContent, messageMentionsBot } from '../im/lark/message-parser.js';
80
- import { resumeSession, spawnDashboardSession, activateQueuedSession, closeCliMismatchedSessionsForBot, suspendActiveSessionsForBot } from './session-manager.js';
82
+ import { resumeSession, spawnDashboardSession, activateQueuedSession, closeCliMismatchedSessionsForBot } from './session-manager.js';
81
83
  import { parseSpawnRequest } from './session-create.js';
82
84
  import { cleanupMaterializedDashboardImages, materializeDashboardImages } from './dashboard-images.js';
83
85
  import { getCliDisplayName } from '../im/lark/card-builder.js';
@@ -93,12 +95,16 @@ import { validateTriggerRequest } from '../services/trigger-types.js';
93
95
  import { resolveCliSelection, selectionKeyForBot } from '../setup/cli-selection.js';
94
96
  import { checkCliAvailability } from '../setup/cli-availability.js';
95
97
  import { enrichHistorySenders } from '../dashboard/history-senders.js';
98
+ import { validateCodexAppManagedSendOrigin, } from '../utils/codex-app-dispatch-ledger.js';
99
+ import { withBotTurnAdmission, withBotTurnMutation } from './bot-turn-mutation-gate.js';
100
+ import { protectedSessionMutationReasons, } from './session-mutation-guard.js';
96
101
  import { listPendingAsks, submitAskFromDesktop } from './ask-broker.js';
97
102
  import { getMessageListenerConfig, sanitizeMessageListenerUpdate, updateMessageListenerConfig, validateMessageListenerUpdate } from '../services/message-listener-store.js';
98
103
  import { MAX_MESSAGE_LISTENER_PROMPT_BYTES, normalizeMessageListenerPreviewLimit, previewMessageListenerMatches, buildListenerBotAppIdToOpenId, collectListenerBotAppIds, renderMessageListenerInstruction, } from '../services/message-listener.js';
99
104
  import { createMessageListenerRunPreview, createMessageListenerRunPreviewTurnId, getMessageListenerRunPreview, markMessageListenerRunPreviewFailed, markMessageListenerRunPreviewTriggered, } from '../services/message-listener-run-preview-store.js';
100
105
  import { listChatMemberDisplays } from '../services/groups-store.js';
101
106
  const MESSAGE_LISTENER_PREVIEW_WINDOW_MS = 24 * 60 * 60 * 1000;
107
+ import { SUPERVISOR_SHUTDOWN_ROUTE, isExactSupervisorShutdownRequest, } from './supervisor-shutdown-ipc.js';
102
108
  let exactChatGrantHandler = applyExactChatGrantRequest;
103
109
  /** Test seam: replace the exact-grant service without touching live Feishu/config state. */
104
110
  export function setExactChatGrantHandler(handler) {
@@ -112,6 +118,10 @@ let botAvatarChanger = null;
112
118
  export function setBotAvatarChanger(fn) {
113
119
  botAvatarChanger = fn;
114
120
  }
121
+ let supervisorShutdownRegistration = null;
122
+ export function setSupervisorShutdownHandler(registration) {
123
+ supervisorShutdownRegistration = registration;
124
+ }
115
125
  import { composeRowFromActive, composeRowFromClosed, composeRowFromPersistedActive, feishuChatLink, setBotName as setRowsBotName, getBotName, } from './dashboard-rows.js';
116
126
  import { getBotBrand, getBot, getBotOpenId, loadBotConfigs, readBotSkillPolicy, getBotTuiSlashAllow } from '../bot-registry.js';
117
127
  import { normalizeKanbanColumn, normalizeKanbanPosition, normalizeSessionTitle } from './session-board.js';
@@ -123,6 +133,7 @@ import { normalizeSessionTitleSource, updateSessionTitle } from './session-title
123
133
  import { requestAgentSessionRename } from './session-rename.js';
124
134
  import { ChatRenameCooldown, ChatRenameSerialQueue, normalizeLarkChatName } from './chat-rename.js';
125
135
  import { sessionAnchorId, larkTransportEnabled } from './types.js';
136
+ import { isRiffBackendSession } from './persistent-backend.js';
126
137
  import { attachSkillPolicy, detachSkillPolicy } from './skills/im-command.js';
127
138
  import { readSkillRegistry } from '../services/skill-registry-store.js';
128
139
  import { commitDeviceIsolationActivation, DEVICE_ISOLATION_COMMIT_PATH, DEVICE_ISOLATION_PREPARE_PATH, DEVICE_ISOLATION_RELEASE_PATH, logDeviceIsolationActivationError, prepareDeviceIsolationActivation, releaseDeviceIsolationActivation, } from './device-isolation-daemon.js';
@@ -149,6 +160,73 @@ export function jsonRes(res, status, body) {
149
160
  res.writeHead(status, { 'content-type': 'application/json' });
150
161
  res.end(JSON.stringify(body));
151
162
  }
163
+ function rejectProtectedSessionMutation(res, values) {
164
+ const bySessionId = new Map();
165
+ for (const value of values) {
166
+ const session = 'session' in value ? value.session : value;
167
+ const reasons = protectedSessionMutationReasons(value);
168
+ if (reasons.length === 0)
169
+ continue;
170
+ const existing = bySessionId.get(session.sessionId);
171
+ if (existing) {
172
+ existing.reasons = [...new Set([...existing.reasons, ...reasons])];
173
+ continue;
174
+ }
175
+ bySessionId.set(session.sessionId, {
176
+ sessionId: session.sessionId,
177
+ ...(session.cliId ? { cliId: session.cliId } : {}),
178
+ reasons,
179
+ });
180
+ }
181
+ const blockingSessions = [...bySessionId.values()];
182
+ if (blockingSessions.length === 0)
183
+ return false;
184
+ const codexDispatchOnly = blockingSessions.every(blocker => blocker.reasons.every(reason => reason === 'codex_app_dispatch'));
185
+ jsonRes(res, 409, {
186
+ ok: false,
187
+ error: codexDispatchOnly
188
+ ? 'codex_app_dispatch_pending'
189
+ : 'session_mutation_pending',
190
+ blockingSessions,
191
+ });
192
+ return true;
193
+ }
194
+ ipcRoute('POST', SUPERVISOR_SHUTDOWN_ROUTE, async (req, res) => {
195
+ // The production server-wide HMAC gate records trusted requests here. Keep
196
+ // an explicit route-local check: shutdown is never a bare loopback API.
197
+ if (!isTrustedHostIpcRequest(req)) {
198
+ return jsonRes(res, 403, { ok: false, error: 'supervisor_shutdown_unauthorized' });
199
+ }
200
+ const registration = supervisorShutdownRegistration;
201
+ if (!registration) {
202
+ return jsonRes(res, 503, { ok: false, error: 'supervisor_shutdown_not_ready' });
203
+ }
204
+ let body;
205
+ try {
206
+ body = await readJsonBody(req);
207
+ }
208
+ catch {
209
+ return jsonRes(res, 400, { ok: false, error: 'invalid_json' });
210
+ }
211
+ if (!isExactSupervisorShutdownRequest(registration, body)) {
212
+ return jsonRes(res, 409, { ok: false, error: 'supervisor_shutdown_generation_mismatch' });
213
+ }
214
+ // ACK means this exact in-memory generation accepted the request; the CLI
215
+ // still proves OS/PM2 quiescence. Start after flushing the ACK so a long Riff
216
+ // drain cannot turn a valid request into an ambiguous transport timeout.
217
+ jsonRes(res, 202, {
218
+ ok: true,
219
+ accepted: true,
220
+ larkAppId: registration.larkAppId,
221
+ bootInstanceId: registration.bootInstanceId,
222
+ processStartIdentity: registration.processStartIdentity,
223
+ });
224
+ setImmediate(() => {
225
+ void registration.shutdown().catch(error => {
226
+ logger.error(`supervisor shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
227
+ });
228
+ });
229
+ });
152
230
  export class JsonBodyTooLargeError extends Error {
153
231
  maxBytes;
154
232
  constructor(maxBytes) {
@@ -285,6 +363,81 @@ export async function readJsonBody(req, maxBytes) {
285
363
  return {};
286
364
  return JSON.parse(Buffer.concat(chunks).toString('utf8'));
287
365
  }
366
+ class IpcBodyTooLargeError extends Error {
367
+ }
368
+ class IpcBodyTimeoutError extends Error {
369
+ }
370
+ /** Strict reader for the one unauthenticated capability challenge route. The
371
+ * generic IPC reader intentionally has no cap for trusted-host endpoints; a
372
+ * loopback-confined process must not be able to buffer arbitrary chunked input
373
+ * before its capability is checked. */
374
+ async function readBoundedJsonBody(req, maxBytes, timeoutMs) {
375
+ const contentLength = req.headers['content-length'];
376
+ if (typeof contentLength === 'string') {
377
+ if (!/^\d+$/.test(contentLength) || Number(contentLength) > maxBytes) {
378
+ throw new IpcBodyTooLargeError('request body too large');
379
+ }
380
+ }
381
+ return await new Promise((resolve, reject) => {
382
+ const chunks = [];
383
+ let total = 0;
384
+ let settled = false;
385
+ const cleanup = () => {
386
+ clearTimeout(timer);
387
+ req.off('data', onData);
388
+ req.off('end', onEnd);
389
+ req.off('error', onError);
390
+ req.off('aborted', onAborted);
391
+ };
392
+ const fail = (err) => {
393
+ if (settled)
394
+ return;
395
+ settled = true;
396
+ req.pause();
397
+ cleanup();
398
+ reject(err);
399
+ };
400
+ const onData = (raw) => {
401
+ const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
402
+ total += chunk.length;
403
+ if (total > maxBytes) {
404
+ fail(new IpcBodyTooLargeError('request body too large'));
405
+ return;
406
+ }
407
+ chunks.push(chunk);
408
+ };
409
+ const onEnd = () => {
410
+ if (settled)
411
+ return;
412
+ settled = true;
413
+ cleanup();
414
+ try {
415
+ resolve(chunks.length === 0
416
+ ? {}
417
+ : JSON.parse(Buffer.concat(chunks, total).toString('utf8')));
418
+ }
419
+ catch (err) {
420
+ reject(err);
421
+ }
422
+ };
423
+ const onError = (err) => fail(err);
424
+ const onAborted = () => fail(new Error('request aborted'));
425
+ const timer = setTimeout(() => fail(new IpcBodyTimeoutError('request body timed out')), timeoutMs);
426
+ timer.unref?.();
427
+ req.on('data', onData);
428
+ req.once('end', onEnd);
429
+ req.once('error', onError);
430
+ req.once('aborted', onAborted);
431
+ });
432
+ }
433
+ function closeUntrustedRequestAfterResponse(req, res) {
434
+ res.setHeader('connection', 'close');
435
+ res.once('finish', () => req.destroy());
436
+ // Drain whatever is already buffered until the response has been flushed;
437
+ // the finish hook then closes a partial/slow body instead of reusing it as a
438
+ // keep-alive request.
439
+ req.resume();
440
+ }
288
441
  // ─── Trusted-host auth (loopback + route-bound HMAC) ────────────────────────
289
442
  //
290
443
  // Production start enables a server-wide gate: loopback is connectivity, not
@@ -381,6 +534,11 @@ function routeHasNarrowUntrustedAuth(method, pathname) {
381
534
  return true;
382
535
  if (method === 'POST' && pathname === '/api/attention')
383
536
  return true;
537
+ // macOS read-isolated `botmux send` presents a rotating worker capability;
538
+ // the handler writes the authoritative tuple into a host-owned read-only
539
+ // proof sidecar, so loopback response spoofing cannot confer authority.
540
+ if (method === 'POST' && pathname === MANAGED_ORIGIN_ATTEST_ROUTE)
541
+ return true;
384
542
  // Workflow v3 mutations carry their own domain-separated full-envelope
385
543
  // protocol (request signature over method/path/exact body with nonce
386
544
  // anti-replay + boot audience, signed response), keyed on the same host
@@ -439,6 +597,152 @@ ipcRoute('GET', '/healthz', (_req, res) => {
439
597
  }
440
598
  jsonRes(res, 200, { ok: true });
441
599
  });
600
+ const MANAGED_ORIGIN_ATTEST_BODY_MAX_BYTES = 2 * 1024;
601
+ const MANAGED_ORIGIN_ATTEST_BODY_TIMEOUT_MS = 1_000;
602
+ const MANAGED_ORIGIN_ATTEST_MAX_PREAUTH_IN_FLIGHT = 128;
603
+ const MANAGED_ORIGIN_ATTEST_MAX_OUTSTANDING_PER_SESSION = 64;
604
+ const managedOriginOutstandingProofs = new Map();
605
+ let managedOriginPreauthInFlight = 0;
606
+ async function handleManagedOriginAttestation(req, res) {
607
+ let body;
608
+ try {
609
+ body = await readBoundedJsonBody(req, MANAGED_ORIGIN_ATTEST_BODY_MAX_BYTES, MANAGED_ORIGIN_ATTEST_BODY_TIMEOUT_MS);
610
+ }
611
+ catch (err) {
612
+ if (err instanceof IpcBodyTooLargeError || err instanceof IpcBodyTimeoutError) {
613
+ closeUntrustedRequestAfterResponse(req, res);
614
+ }
615
+ return jsonRes(res, err instanceof IpcBodyTooLargeError
616
+ ? 413
617
+ : err instanceof IpcBodyTimeoutError
618
+ ? 408
619
+ : 400, {
620
+ ok: false,
621
+ error: err instanceof IpcBodyTooLargeError
622
+ ? 'body_too_large'
623
+ : err instanceof IpcBodyTimeoutError
624
+ ? 'body_timeout'
625
+ : 'bad_json',
626
+ });
627
+ }
628
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
629
+ return jsonRes(res, 400, { ok: false, error: 'bad_attestation_request' });
630
+ }
631
+ const sessionId = typeof body.sessionId === 'string' && body.sessionId.length <= 256
632
+ ? body.sessionId
633
+ : '';
634
+ const capability = typeof body.originCapability === 'string'
635
+ && /^[a-f0-9]{32,128}$/i.test(body.originCapability)
636
+ ? body.originCapability
637
+ : '';
638
+ const channelId = typeof (body.originChannelId ?? body.channelId) === 'string'
639
+ && /^[a-f0-9]{64}$/.test((body.originChannelId ?? body.channelId))
640
+ ? (body.originChannelId ?? body.channelId)
641
+ : '';
642
+ const nonce = typeof body.nonce === 'string' && /^[a-f0-9]{64}$/.test(body.nonce)
643
+ ? body.nonce
644
+ : '';
645
+ if (!sessionId || !channelId || !capability || !nonce) {
646
+ return jsonRes(res, 400, { ok: false, error: 'bad_attestation_request' });
647
+ }
648
+ const ds = findActiveBySessionId(sessionId);
649
+ const worker = ds?.worker;
650
+ let workerPidLive = false;
651
+ if (worker && Number.isSafeInteger(worker.pid) && (worker.pid ?? 0) > 0) {
652
+ try {
653
+ process.kill(worker.pid, 0);
654
+ workerPidLive = true;
655
+ }
656
+ catch { /* ESRCH/EPERM/invalid pid all fail closed */ }
657
+ }
658
+ const workerLive = !!worker
659
+ && worker.connected === true
660
+ && !worker.killed
661
+ && worker.exitCode === null
662
+ && worker.signalCode === null
663
+ && workerPidLive;
664
+ const liveTurnId = ds?.managedTurnOrigin?.turnId;
665
+ const verified = authorizeSessionScopedIpc({
666
+ trustedHost: false,
667
+ sessionExists: !!ds,
668
+ receiverSession: !!ds?.session.vcMeetingReceiver,
669
+ allowReceiver: true,
670
+ sessionId,
671
+ liveOrigin: ds?.managedTurnOrigin,
672
+ claimedCapability: capability,
673
+ });
674
+ if (!verified.ok || !ds?.managedTurnOrigin || !liveTurnId || !workerLive) {
675
+ return jsonRes(res, 403, { ok: false, error: 'origin_unproven' });
676
+ }
677
+ const origin = ds.managedTurnOrigin;
678
+ if (!origin.originChannelId || !/^[a-f0-9]{64}$/.test(origin.originChannelId)) {
679
+ return jsonRes(res, 403, { ok: false, error: 'origin_channel_unproven' });
680
+ }
681
+ if (channelId !== origin.originChannelId) {
682
+ return jsonRes(res, 403, { ok: false, error: 'origin_channel_unproven' });
683
+ }
684
+ const codexDecision = validateCodexAppManagedSendOrigin(ds.session.codexAppDispatchLedger, origin, ds.initConfig?.cliId === 'codex-app' || ds.session.cliId === 'codex-app');
685
+ if (!codexDecision.ok) {
686
+ return jsonRes(res, 409, { ok: false, error: 'origin_not_sendable' });
687
+ }
688
+ const outstanding = managedOriginOutstandingProofs.get(sessionId) ?? 0;
689
+ if (outstanding >= MANAGED_ORIGIN_ATTEST_MAX_OUTSTANDING_PER_SESSION) {
690
+ return jsonRes(res, 429, { ok: false, error: 'too_many_attestations' });
691
+ }
692
+ let proofPath;
693
+ try {
694
+ proofPath = writeManagedOriginAttestationProof({
695
+ dataDir: config.session.dataDir,
696
+ proof: {
697
+ domain: MANAGED_ORIGIN_PROOF_DOMAIN,
698
+ version: 1,
699
+ nonce,
700
+ channelId: origin.originChannelId,
701
+ sessionId,
702
+ turnId: liveTurnId,
703
+ ...(origin.dispatchAttempt !== undefined
704
+ ? { dispatchAttempt: origin.dispatchAttempt }
705
+ : {}),
706
+ requiresCodexAppLedger: codexDecision.requiresLedger,
707
+ issuedAtMs: Date.now(),
708
+ },
709
+ });
710
+ }
711
+ catch (err) {
712
+ logger.warn(`[managed-origin] could not write attestation proof: ${err}`);
713
+ return jsonRes(res, 409, { ok: false, error: 'proof_unavailable' });
714
+ }
715
+ managedOriginOutstandingProofs.set(sessionId, outstanding + 1);
716
+ const cleanupTimer = setTimeout(() => {
717
+ try {
718
+ unlinkSync(proofPath);
719
+ }
720
+ catch { /* expired/already gone */ }
721
+ const remaining = (managedOriginOutstandingProofs.get(sessionId) ?? 1) - 1;
722
+ if (remaining > 0)
723
+ managedOriginOutstandingProofs.set(sessionId, remaining);
724
+ else
725
+ managedOriginOutstandingProofs.delete(sessionId);
726
+ }, MANAGED_ORIGIN_PROOF_TTL_MS + 1_000);
727
+ cleanupTimer.unref?.();
728
+ return jsonRes(res, 200, { ok: true });
729
+ }
730
+ ipcRoute('POST', MANAGED_ORIGIN_ATTEST_ROUTE, async (req, res) => {
731
+ // This counter is acquired before parsing or capability lookup. Per-session
732
+ // proof quotas cannot protect the unauthenticated slow-body phase because a
733
+ // session id is not trustworthy until the complete request has been read.
734
+ if (managedOriginPreauthInFlight >= MANAGED_ORIGIN_ATTEST_MAX_PREAUTH_IN_FLIGHT) {
735
+ closeUntrustedRequestAfterResponse(req, res);
736
+ return jsonRes(res, 429, { ok: false, error: 'too_many_attestation_requests' });
737
+ }
738
+ managedOriginPreauthInFlight += 1;
739
+ try {
740
+ await handleManagedOriginAttestation(req, res);
741
+ }
742
+ finally {
743
+ managedOriginPreauthInFlight -= 1;
744
+ }
745
+ });
442
746
  export { composeRowFromActive, composeRowFromClosed, composeRowFromPersistedActive };
443
747
  // Re-export setBotName for backwards-compatible imports (daemon.ts). Both
444
748
  // callers (this module's cachedBotName + dashboard-rows' cachedBotName) need
@@ -562,8 +866,48 @@ ipcRoute('POST', '/api/sessions/:sessionId/close', async (req, res, params) => {
562
866
  const auth = sessionCliIpcAuth(req, ds, params.sessionId, body);
563
867
  if (!auth.ok)
564
868
  return jsonRes(res, 403, { ok: false, error: auth.error });
565
- const r = await closeSession(params.sessionId);
566
- jsonRes(res, 200, r);
869
+ const initial = findSessionRecord(params.sessionId);
870
+ // Resolve the owning bot for the FIFO-drain gate from the live DaemonSession
871
+ // first (it carries larkAppId even before a persisted record exists), then the
872
+ // stored row, then this daemon's own identity. A trusted-host close of an
873
+ // already-missing session has no owner to key the gate on, so close directly:
874
+ // closeSession is idempotent and reports alreadyClosed.
875
+ const larkAppId = ds?.larkAppId || initial?.larkAppId || cachedLarkAppId;
876
+ if (!larkAppId) {
877
+ const r = await closeSession(params.sessionId);
878
+ return jsonRes(res, r.ok ? 200 : 502, r);
879
+ }
880
+ return withBotTurnMutation(larkAppId, async () => {
881
+ // Re-resolve only after every earlier admitted turn has drained. Explicit
882
+ // close is the abandon boundary and may clear accepted FIFO, but it must
883
+ // never clear a stale pre-drain snapshot while a turn is still preparing.
884
+ const current = findSessionRecord(params.sessionId);
885
+ if (current && current.status === 'closed') {
886
+ return jsonRes(res, 200, { ok: true, alreadyClosed: true });
887
+ }
888
+ const r = await closeSession(params.sessionId);
889
+ jsonRes(res, r.ok ? 200 : 502, r);
890
+ });
891
+ });
892
+ // `botmux list` zombie pruning is maintenance, not explicit abandon. Serialize
893
+ // against inbound admission and refuse if any backend-neutral durable owner
894
+ // became unsettled after the CLI took its liveness snapshot.
895
+ ipcRoute('POST', '/api/sessions/:sessionId/prune', async (_req, res, params) => {
896
+ const initial = findSessionRecord(params.sessionId);
897
+ if (!initial)
898
+ return jsonRes(res, 404, { ok: false, error: 'session_not_found' });
899
+ const larkAppId = initial.larkAppId || cachedLarkAppId;
900
+ if (!larkAppId)
901
+ return jsonRes(res, 503, { ok: false, error: 'bot_not_found' });
902
+ return withBotTurnMutation(larkAppId, async () => {
903
+ const current = findSessionRecord(params.sessionId);
904
+ if (!current)
905
+ return jsonRes(res, 404, { ok: false, error: 'session_not_found' });
906
+ if (rejectProtectedSessionMutation(res, [current]))
907
+ return;
908
+ const r = await closeSession(params.sessionId);
909
+ jsonRes(res, r.ok ? 200 : 502, r);
910
+ });
567
911
  });
568
912
  /** Post a scope-aware "restarting" notice into the session's Lark thread/chat,
569
913
  * mirroring the /resume route — so a Feishu-side observer sees why the CLI just
@@ -599,69 +943,93 @@ function postRestartNotice(ds, fresh) {
599
943
  .catch(err => logger.debug(`[restart] failed to post thread-scope restart notice: ${err}`));
600
944
  }
601
945
  }
602
- ipcRoute('POST', '/api/sessions/:sessionId/restart', (_req, res, params) => {
603
- const ds = findActiveBySessionId(params.sessionId);
604
- if (!ds)
946
+ ipcRoute('POST', '/api/sessions/:sessionId/restart', async (_req, res, params) => {
947
+ const initial = findActiveBySessionId(params.sessionId);
948
+ if (!initial)
605
949
  return jsonRes(res, 404, { ok: false, error: 'session_not_active' });
606
- if (isSessionTransferring(ds)) {
607
- return jsonRes(res, 409, { ok: false, error: 'session_transferring' });
608
- }
609
- // Adopt/observed sessions: botmux never owned the CLI — restarting would kill
610
- // the user's real tmux/zellij pane. Hard-reject (the worker self-guards too).
611
- if (ds.adoptedFrom || ds.initConfig?.adoptMode) {
612
- return jsonRes(res, 409, { ok: false, error: 'adopt_restart_unsupported' });
613
- }
614
- const cliId = ds.session.cliId ?? 'unknown';
615
- if (ds.worker && !ds.worker.killed) {
616
- // Live worker → in-place CLI restart (kills the CLI, respawns with --resume).
617
- // 捎带最新 per-bot env:dashboard 改完 env 后重启才真正生效(与 /restart 同逻辑)。
618
- try {
619
- ds.workerReady = false;
620
- ds.worker.send({ type: 'restart', env: latestPerBotEnvForRestart(ds) });
950
+ return withBotTurnMutation(initial.larkAppId, () => {
951
+ const ds = findActiveBySessionId(params.sessionId);
952
+ if (!ds)
953
+ return jsonRes(res, 404, { ok: false, error: 'session_not_active' });
954
+ if (isSessionTransferring(ds)) {
955
+ return jsonRes(res, 409, { ok: false, error: 'session_transferring' });
621
956
  }
622
- catch (err) {
623
- return jsonRes(res, 502, { ok: false, error: String(err) });
957
+ // Adopt/observed sessions: botmux never owned the CLI — restarting would kill
958
+ // the user's real tmux/zellij pane. Hard-reject (the worker self-guards too).
959
+ if (ds.adoptedFrom || ds.initConfig?.adoptMode) {
960
+ return jsonRes(res, 409, { ok: false, error: 'adopt_restart_unsupported' });
961
+ }
962
+ // Riff owns a remote task lineage. Its worker deliberately refuses restart
963
+ // because destroy + respawn would sever or replace that lineage. Reject at
964
+ // the daemon boundary so the dashboard never reports HTTP 200 for a no-op.
965
+ if (isRiffBackendSession(ds)) {
966
+ return jsonRes(res, 409, {
967
+ ok: false,
968
+ error: 'riff_restart_unsupported',
969
+ message: t('cmd.restart.riff_unsupported', undefined, localeForBot(ds.larkAppId)),
970
+ });
624
971
  }
625
- postRestartNotice(ds, false);
626
- return jsonRes(res, 200, { ok: true, sessionId: params.sessionId, cliId, revived: false });
627
- }
628
- // Worker is gone but the session is still active — idle-suspended (over the
629
- // per-bot cap), lazy-restored after a daemon restart, or crash-loop-stopped.
630
- // Revive it the same way the Feishu card restart does (forkWorker), so the
631
- // dashboard isn't a dead-end: a 409 here would leave NO working control to
632
- // bring the CLI back (the resume button only shows for closed sessions).
633
- forkWorker(ds, '', ds.hasHistory);
634
- postRestartNotice(ds, true);
635
- jsonRes(res, 200, { ok: true, sessionId: params.sessionId, cliId, revived: true });
972
+ if (rejectProtectedSessionMutation(res, [ds]))
973
+ return;
974
+ const cliId = ds.session.cliId ?? 'unknown';
975
+ if (ds.worker && !ds.worker.killed) {
976
+ // Live worker → in-place CLI restart (kills the CLI, respawns with --resume).
977
+ // 捎带最新 per-bot env:dashboard 改完 env 后重启才真正生效(与 /restart 同逻辑)。
978
+ try {
979
+ ds.workerReady = false;
980
+ ds.worker.send({ type: 'restart', reason: 'operator', env: latestPerBotEnvForRestart(ds) });
981
+ }
982
+ catch (err) {
983
+ return jsonRes(res, 502, { ok: false, error: String(err) });
984
+ }
985
+ postRestartNotice(ds, false);
986
+ return jsonRes(res, 200, { ok: true, sessionId: params.sessionId, cliId, revived: false });
987
+ }
988
+ // Worker is gone but the session is still active — idle-suspended (over the
989
+ // per-bot cap), lazy-restored after a daemon restart, or crash-loop-stopped.
990
+ // Revive it the same way the Feishu card restart does (forkWorker), so the
991
+ // dashboard isn't a dead-end: a 409 here would leave NO working control to
992
+ // bring the CLI back (the resume button only shows for closed sessions).
993
+ forkWorker(ds, '', ds.hasHistory);
994
+ postRestartNotice(ds, true);
995
+ jsonRes(res, 200, { ok: true, sessionId: params.sessionId, cliId, revived: true });
996
+ });
636
997
  });
637
998
  /** Manually suspend one active session: kill the worker + CLI/pane, session
638
999
  * stays active and cold-resumes from its transcript on the next message —
639
1000
  * the same semantics the idle-worker sweeper applies over the live cap.
640
1001
  * Primary use: `botmux suspend --isolated` after a credential rotation, so
641
1002
  * isolated bots' next cold spawn re-provisions the freshest creds. */
642
- ipcRoute('POST', '/api/sessions/:sessionId/suspend', (_req, res, params) => {
643
- const ds = findActiveBySessionId(params.sessionId);
644
- if (!ds)
1003
+ ipcRoute('POST', '/api/sessions/:sessionId/suspend', async (_req, res, params) => {
1004
+ const initial = findActiveBySessionId(params.sessionId);
1005
+ if (!initial)
645
1006
  return jsonRes(res, 404, { ok: false, error: 'session_not_active' });
646
- if (isSessionTransferring(ds)) {
647
- return jsonRes(res, 409, { ok: false, error: 'session_transferring' });
648
- }
649
- // Adopt/observed sessions: botmux never owned the CLI — suspending would kill
650
- // the user's real tmux/zellij pane. Same guard as /restart.
651
- if (ds.adoptedFrom || ds.initConfig?.adoptMode) {
652
- return jsonRes(res, 409, { ok: false, error: 'adopt_suspend_unsupported' });
653
- }
654
- if (!ds.worker || ds.worker.killed) {
655
- // Worker already gone (idle-suspended / crash-stopped) — the goal state is
656
- // already reached, so report idempotent success without a live kill.
657
- return jsonRes(res, 200, { ok: true, sessionId: params.sessionId, suspended: false, reason: 'no_live_worker' });
658
- }
659
- if (!suspendWorker(ds, 'manual_suspend')) {
660
- // Live worker but a non-suspendable (pty) backend: killing it would drop the
661
- // in-memory conversation with no persistent pane to resume from lazily.
662
- return jsonRes(res, 409, { ok: false, error: 'backend_not_suspendable' });
663
- }
664
- jsonRes(res, 200, { ok: true, sessionId: params.sessionId, suspended: true });
1007
+ return withBotTurnMutation(initial.larkAppId, () => {
1008
+ const ds = findActiveBySessionId(params.sessionId);
1009
+ if (!ds)
1010
+ return jsonRes(res, 404, { ok: false, error: 'session_not_active' });
1011
+ if (isSessionTransferring(ds)) {
1012
+ return jsonRes(res, 409, { ok: false, error: 'session_transferring' });
1013
+ }
1014
+ // Adopt/observed sessions: botmux never owned the CLI — suspending would kill
1015
+ // the user's real tmux/zellij pane. Same guard as /restart.
1016
+ if (ds.adoptedFrom || ds.initConfig?.adoptMode) {
1017
+ return jsonRes(res, 409, { ok: false, error: 'adopt_suspend_unsupported' });
1018
+ }
1019
+ if (rejectProtectedSessionMutation(res, [ds]))
1020
+ return;
1021
+ if (!ds.worker || ds.worker.killed) {
1022
+ // Worker already gone (idle-suspended / crash-stopped) the goal state is
1023
+ // already reached, so report idempotent success without a live kill.
1024
+ return jsonRes(res, 200, { ok: true, sessionId: params.sessionId, suspended: false, reason: 'no_live_worker' });
1025
+ }
1026
+ if (!suspendWorker(ds, 'manual_suspend')) {
1027
+ // Live worker but a non-suspendable (pty) backend: killing it would drop the
1028
+ // in-memory conversation with no persistent pane to resume from lazily.
1029
+ return jsonRes(res, 409, { ok: false, error: 'backend_not_suspendable' });
1030
+ }
1031
+ jsonRes(res, 200, { ok: true, sessionId: params.sessionId, suspended: true });
1032
+ });
665
1033
  });
666
1034
  /**
667
1035
  * Count host-overload降压 candidates for THIS daemon's scope, so the alert card
@@ -916,6 +1284,16 @@ ipcRoute('POST', '/api/sessions/:sessionId/cd', async (req, res, params) => {
916
1284
  if (ds.adoptedFrom || ds.initConfig?.adoptMode) {
917
1285
  return jsonRes(res, 409, { ok: false, error: 'adopt_cd_unsupported' });
918
1286
  }
1287
+ // Riff restart/kill is intentionally refused to preserve remote task
1288
+ // lineage. Reject before validation/repin so the persisted cwd cannot drift
1289
+ // from the still-running sandbox.
1290
+ if (isRiffBackendSession(ds)) {
1291
+ return jsonRes(res, 409, {
1292
+ ok: false,
1293
+ error: 'riff_cd_unsupported',
1294
+ message: t('cmd.cd.riff_unsupported', undefined, localeForBot(ds.larkAppId)),
1295
+ });
1296
+ }
919
1297
  // ownAppId 收窄到本 bot 自己的角色库子树:不收窄就能切进别的 bot 的角色目录,
920
1298
  // 下面 repinSessionWorkingDir 把 ds.workingDir 钉过去之后,那个 bot 的沙盒会话
921
1299
  // 就拿到了对方整棵角色库的 readWrite(打穿 fs-policy 的跨 bot 隔离)。
@@ -1085,45 +1463,103 @@ ipcRoute('POST', '/api/sessions/:sessionId/board', async (req, res, params) => {
1085
1463
  const session = findOwnedSessionRecord(params.sessionId);
1086
1464
  if (!session)
1087
1465
  return jsonRes(res, 404, { ok: false, error: 'session_not_found' });
1088
- // 待办池(queued)会话被拖到「进行中」= 激活:把暂存内容当首轮发给 CLI 开跑。
1089
- // activateQueuedSession 内部会清 queued + 把列设成 in_progress + forkWorker。
1090
- const activeDs = findActiveBySessionId(params.sessionId);
1091
- if (column === 'in_progress' && activeDs?.session.queued) {
1092
- await activateQueuedSession(activeDs);
1466
+ const larkAppId = session.larkAppId || cachedLarkAppId;
1467
+ if (!larkAppId)
1468
+ return jsonRes(res, 503, { ok: false, error: 'bot_not_found' });
1469
+ return withBotTurnAdmission(larkAppId, async () => {
1470
+ const currentSession = findSessionRecord(params.sessionId);
1471
+ if (!currentSession)
1472
+ return jsonRes(res, 404, { ok: false, error: 'session_not_found' });
1473
+ // 待办池(queued)会话被拖到「进行中」= 激活:把暂存内容当首轮发给 CLI 开跑。
1474
+ // activateQueuedSession 内部会清 queued + 把列设成 in_progress + forkWorker。
1475
+ const activeDs = findActiveBySessionId(params.sessionId);
1476
+ let activationTransferred = false;
1477
+ if (column === 'in_progress' && activeDs?.session.queued) {
1478
+ const activated = await activateQueuedSession(activeDs);
1479
+ if (!activated.ok)
1480
+ return jsonRes(res, 500, activated);
1481
+ activationTransferred = true;
1482
+ }
1483
+ else if (column) {
1484
+ currentSession.kanbanColumn = column;
1485
+ }
1486
+ if (position !== null)
1487
+ currentSession.kanbanPosition = position;
1488
+ try {
1489
+ sessionStore.updateSession(currentSession);
1490
+ }
1491
+ catch (err) {
1492
+ if (!activationTransferred)
1493
+ throw err;
1494
+ logger.error(`[dashboard] board metadata persistence failed after queued activation ownership transferred: `
1495
+ + `${err instanceof Error ? err.message : String(err)}`);
1496
+ }
1497
+ dashboardEventBus.publish({
1498
+ type: 'session.update',
1499
+ body: {
1500
+ sessionId: params.sessionId,
1501
+ // queued 一并下发:激活后 session.queued 已为 false,前端浅合并若不带这个字段
1502
+ // 会残留 queued=true(卡片仍显示「开始」、再点 409)。!!session.queued 始终反映现态。
1503
+ patch: { kanbanColumn: currentSession.kanbanColumn, kanbanPosition: currentSession.kanbanPosition, queued: !!currentSession.queued },
1504
+ },
1505
+ });
1506
+ jsonRes(res, 200, { ok: true });
1507
+ });
1508
+ });
1509
+ // Narrow CLI whiteboard binding mutation. Keeping this daemon-side avoids a
1510
+ // short-lived `botmux whiteboard` process rewriting a stale whole Session row
1511
+ // over a concurrent Codex App FIFO transition.
1512
+ ipcRoute('POST', '/api/sessions/:sessionId/whiteboard', async (req, res, params) => {
1513
+ let body;
1514
+ try {
1515
+ body = await readJsonBody(req);
1093
1516
  }
1094
- else if (column) {
1095
- session.kanbanColumn = column;
1517
+ catch {
1518
+ return jsonRes(res, 400, { ok: false, error: 'bad_json' });
1096
1519
  }
1097
- if (position !== null)
1098
- session.kanbanPosition = position;
1099
- sessionStore.updateSession(session);
1100
- dashboardEventBus.publish({
1101
- type: 'session.update',
1102
- body: {
1103
- sessionId: params.sessionId,
1104
- // queued 一并下发:激活后 session.queued 已为 false,前端浅合并若不带这个字段
1105
- // 会残留 queued=true(卡片仍显示「开始」、再点 409)。!!session.queued 始终反映现态。
1106
- patch: { kanbanColumn: session.kanbanColumn, kanbanPosition: session.kanbanPosition, queued: !!session.queued },
1107
- },
1520
+ if (typeof body.whiteboardId !== 'string'
1521
+ || body.whiteboardId.length === 0
1522
+ || body.whiteboardId.length > 256) {
1523
+ return jsonRes(res, 400, { ok: false, error: 'bad_whiteboard_id' });
1524
+ }
1525
+ const session = findSessionRecord(params.sessionId);
1526
+ if (!session)
1527
+ return jsonRes(res, 404, { ok: false, error: 'session_not_found' });
1528
+ const larkAppId = session.larkAppId || cachedLarkAppId;
1529
+ if (!larkAppId)
1530
+ return jsonRes(res, 503, { ok: false, error: 'bot_not_found' });
1531
+ return withBotTurnAdmission(larkAppId, async () => {
1532
+ const current = findSessionRecord(params.sessionId);
1533
+ if (!current)
1534
+ return jsonRes(res, 404, { ok: false, error: 'session_not_found' });
1535
+ current.whiteboardId = body.whiteboardId;
1536
+ sessionStore.updateSession(current);
1537
+ jsonRes(res, 200, { ok: true, whiteboardId: current.whiteboardId });
1108
1538
  });
1109
- jsonRes(res, 200, { ok: true });
1110
1539
  });
1111
1540
  // 待办池会话「开始」:把 parked 会话激活(发首轮、起 CLI),与拖到「进行中」同义。
1112
1541
  ipcRoute('POST', '/api/sessions/:sessionId/start', async (_req, res, params) => {
1113
- const ds = findActiveBySessionId(params.sessionId);
1114
- if (!ds)
1542
+ const initial = findActiveBySessionId(params.sessionId);
1543
+ if (!initial)
1115
1544
  return jsonRes(res, 404, { ok: false, error: 'session_not_found' });
1116
- if (!ds.session.queued)
1117
- return jsonRes(res, 409, { ok: false, error: 'not_queued' });
1118
- const r = await activateQueuedSession(ds);
1119
- if (!r.ok)
1120
- return jsonRes(res, 500, r);
1121
- sessionStore.updateSession(ds.session);
1122
- dashboardEventBus.publish({
1123
- type: 'session.update',
1124
- body: { sessionId: params.sessionId, patch: { kanbanColumn: ds.session.kanbanColumn, queued: false } },
1545
+ return withBotTurnAdmission(initial.larkAppId, async () => {
1546
+ const ds = findActiveBySessionId(params.sessionId);
1547
+ if (!ds)
1548
+ return jsonRes(res, 404, { ok: false, error: 'session_not_found' });
1549
+ if (!ds.session.queued)
1550
+ return jsonRes(res, 409, { ok: false, error: 'not_queued' });
1551
+ const r = await activateQueuedSession(ds);
1552
+ if (!r.ok)
1553
+ return jsonRes(res, 500, r);
1554
+ dashboardEventBus.publish({
1555
+ type: 'session.update',
1556
+ body: {
1557
+ sessionId: params.sessionId,
1558
+ patch: { kanbanColumn: ds.session.kanbanColumn, queued: !!ds.session.queued },
1559
+ },
1560
+ });
1561
+ jsonRes(res, 200, { ok: true });
1125
1562
  });
1126
- jsonRes(res, 200, { ok: true });
1127
1563
  });
1128
1564
  // Dashboard「创建会话」spawn:在新建的群里为本 daemon 的 bot 拉起/暂存一条 chat-scope
1129
1565
  // 会话。aggregator 建完群后按模式(一起开工/lead 分配)对每个目标 bot 的 daemon 调一次。
@@ -1144,32 +1580,34 @@ ipcRoute('POST', '/api/sessions/spawn', async (req, res) => {
1144
1580
  if (!parsed.ok)
1145
1581
  return jsonRes(res, 400, { ok: false, error: parsed.error });
1146
1582
  const postBanner = !!body.postBanner;
1147
- let attachments;
1148
- try {
1149
- attachments = materializeDashboardImages(cachedLarkAppId, parsed.value.images);
1150
- }
1151
- catch (err) {
1152
- logger.error(`[createSession] failed to persist Dashboard images: ${err?.message ?? err}`);
1153
- return jsonRes(res, 500, { ok: false, error: 'image_store_failed' });
1154
- }
1155
- const r = await spawnDashboardSession(activeSessions, undefined, {
1156
- larkAppId: cachedLarkAppId,
1157
- chatId: parsed.value.chatId,
1158
- content: parsed.value.content,
1159
- column: parsed.value.column,
1160
- role: parsed.value.role,
1161
- coworkers: parsed.value.coworkers,
1162
- attachments,
1163
- title: parsed.value.title,
1164
- postBanner,
1165
- ownerOpenId: parsed.value.ownerOpenId,
1166
- ownerUnionId: parsed.value.ownerUnionId,
1583
+ return withBotTurnAdmission(cachedLarkAppId, async () => {
1584
+ let attachments;
1585
+ try {
1586
+ attachments = materializeDashboardImages(cachedLarkAppId, parsed.value.images);
1587
+ }
1588
+ catch (err) {
1589
+ logger.error(`[createSession] failed to persist Dashboard images: ${err?.message ?? err}`);
1590
+ return jsonRes(res, 500, { ok: false, error: 'image_store_failed' });
1591
+ }
1592
+ const r = await spawnDashboardSession(activeSessions, undefined, {
1593
+ larkAppId: cachedLarkAppId,
1594
+ chatId: parsed.value.chatId,
1595
+ content: parsed.value.content,
1596
+ column: parsed.value.column,
1597
+ role: parsed.value.role,
1598
+ coworkers: parsed.value.coworkers,
1599
+ attachments,
1600
+ title: parsed.value.title,
1601
+ postBanner,
1602
+ ownerOpenId: parsed.value.ownerOpenId,
1603
+ ownerUnionId: parsed.value.ownerUnionId,
1604
+ });
1605
+ if (!r.ok) {
1606
+ cleanupMaterializedDashboardImages(cachedLarkAppId, attachments);
1607
+ return jsonRes(res, r.error === 'session_exists' ? 409 : 500, r);
1608
+ }
1609
+ jsonRes(res, 200, r);
1167
1610
  });
1168
- if (!r.ok) {
1169
- cleanupMaterializedDashboardImages(cachedLarkAppId, attachments);
1170
- return jsonRes(res, r.error === 'session_exists' ? 409 : 500, r);
1171
- }
1172
- jsonRes(res, 200, r);
1173
1611
  });
1174
1612
  ipcRoute('POST', '/api/chat-reply-mode', async (req, res) => {
1175
1613
  if (!cachedLarkAppId)
@@ -1558,71 +1996,80 @@ function workingDirForSession(sessionId) {
1558
1996
  */
1559
1997
  ipcRoute('POST', '/api/sessions/:sessionId/resume', async (req, res, params) => {
1560
1998
  const sessionId = params.sessionId;
1561
- const reg = getActiveSessionsRegistry();
1562
- if (!reg)
1563
- return jsonRes(res, 503, { ok: false, error: 'registry_unavailable' });
1564
- const result = await resumeSession(sessionId, reg);
1565
- if (!result.ok) {
1566
- const status = result.error === 'not_found' ? 404 : 409;
1567
- return jsonRes(res, status, { ok: false, error: result.error, activeSessionId: result.activeSessionId });
1568
- }
1569
- const ds = result.ds;
1570
- // `?wake=1` is an opt-in operational hook (no UI/CLI caller wires it today —
1571
- // it's meant for direct `curl` recovery): instead of the default lazy
1572
- // cold-resume on the next inbound message, fork the worker immediately so the
1573
- // session is usable right away. Off by default keeps every existing caller's
1574
- // behaviour unchanged.
1575
- const wake = new URL(req.url ?? '/', 'http://localhost').searchParams.get('wake') === '1';
1576
- // Tell the dashboard the row flipped back to active (mirror of session.update
1577
- // emitted by closeSession). Use `null` for closedAt — `undefined` would be
1578
- // dropped by JSON.stringify on the SSE wire and the aggregator's spread
1579
- // (`{...cur, ...patch}`) would leave the stale closedAt in place.
1580
- dashboardEventBus.publish({
1581
- type: 'session.update',
1582
- body: {
1583
- sessionId,
1584
- patch: { status: 'active', closedAt: null },
1585
- },
1586
- });
1587
- // Notify the original chat so humans see why the session is alive again.
1588
- // Routing follows session.scope — thread-scope replies into the thread root
1589
- // (reply_in_thread=true), chat-scope posts a plain message to the chat (any
1590
- // reply_in_thread call would silently get rejected or land on a stale root).
1591
- const cliId = ds.session.cliId;
1592
- const botCfg = ds.larkAppId ? getBot(ds.larkAppId).config : undefined;
1593
- const cliName = sessionConfiguredRuntimeDisplayName(ds.session, botCfg?.cliRuntime)
1594
- ?? getCliDisplayName(cliId ?? botCfg?.cliId ?? 'claude-code');
1595
- const notice = JSON.stringify({ text: `🔄 会话已通过命令行恢复,发条消息继续与 ${cliName} 对话。` });
1596
- if (ds.larkAppId && !sessionTransportDisabled(ds)) {
1597
- if (ds.scope === 'chat' && ds.chatId) {
1598
- getChatMode(ds.larkAppId, ds.chatId, { forceRefresh: true })
1599
- .then((mode) => mode === 'topic' && ds.session.rootMessageId
1600
- ? replyMessage(ds.larkAppId, ds.session.rootMessageId, notice, 'text', true)
1601
- : sendMessage(ds.larkAppId, ds.chatId, notice, 'text'))
1602
- .catch(err => logger.debug(`[resume] failed to post chat-scope resume notice: ${err}`));
1999
+ const sourceSession = findSessionRecord(sessionId);
2000
+ if (!sourceSession)
2001
+ return jsonRes(res, 404, { ok: false, error: 'not_found' });
2002
+ // Legacy persisted sessions may carry an empty larkAppId and are hydrated
2003
+ // with this daemon's identity by resumeSession. Use the same fallback for
2004
+ // admission instead of rejecting an otherwise valid recovery record.
2005
+ const larkAppId = sourceSession.larkAppId || cachedLarkAppId || '__legacy_unbound__';
2006
+ return withBotTurnAdmission(larkAppId, async () => {
2007
+ const reg = getActiveSessionsRegistry();
2008
+ if (!reg)
2009
+ return jsonRes(res, 503, { ok: false, error: 'registry_unavailable' });
2010
+ const result = await resumeSession(sessionId, reg);
2011
+ if (!result.ok) {
2012
+ const status = result.error === 'not_found' ? 404 : 409;
2013
+ return jsonRes(res, status, { ok: false, error: result.error, activeSessionId: result.activeSessionId });
2014
+ }
2015
+ const ds = result.ds;
2016
+ // `?wake=1` is an opt-in operational hook (no UI/CLI caller wires it today —
2017
+ // it's meant for direct `curl` recovery): instead of the default lazy
2018
+ // cold-resume on the next inbound message, fork the worker immediately so the
2019
+ // session is usable right away. Off by default keeps every existing caller's
2020
+ // behaviour unchanged.
2021
+ const wake = new URL(req.url ?? '/', 'http://localhost').searchParams.get('wake') === '1';
2022
+ // Tell the dashboard the row flipped back to active (mirror of session.update
2023
+ // emitted by closeSession). Use `null` for closedAt — `undefined` would be
2024
+ // dropped by JSON.stringify on the SSE wire and the aggregator's spread
2025
+ // (`{...cur, ...patch}`) would leave the stale closedAt in place.
2026
+ dashboardEventBus.publish({
2027
+ type: 'session.update',
2028
+ body: {
2029
+ sessionId,
2030
+ patch: { status: 'active', closedAt: null },
2031
+ },
2032
+ });
2033
+ // Notify the original chat so humans see why the session is alive again.
2034
+ // Routing follows session.scope thread-scope replies into the thread root
2035
+ // (reply_in_thread=true), chat-scope posts a plain message to the chat (any
2036
+ // reply_in_thread call would silently get rejected or land on a stale root).
2037
+ const cliId = ds.session.cliId;
2038
+ const botCfg = ds.larkAppId ? getBot(ds.larkAppId).config : undefined;
2039
+ const cliName = sessionConfiguredRuntimeDisplayName(ds.session, botCfg?.cliRuntime)
2040
+ ?? getCliDisplayName(cliId ?? botCfg?.cliId ?? 'claude-code');
2041
+ const notice = JSON.stringify({ text: `🔄 会话已通过命令行恢复,发条消息继续与 ${cliName} 对话。` });
2042
+ if (ds.larkAppId && !sessionTransportDisabled(ds)) {
2043
+ if (ds.scope === 'chat' && ds.chatId) {
2044
+ getChatMode(ds.larkAppId, ds.chatId, { forceRefresh: true })
2045
+ .then((mode) => mode === 'topic' && ds.session.rootMessageId
2046
+ ? replyMessage(ds.larkAppId, ds.session.rootMessageId, notice, 'text', true)
2047
+ : sendMessage(ds.larkAppId, ds.chatId, notice, 'text'))
2048
+ .catch(err => logger.debug(`[resume] failed to post chat-scope resume notice: ${err}`));
2049
+ }
2050
+ else if (ds.session.rootMessageId) {
2051
+ replyMessage(ds.larkAppId, ds.session.rootMessageId, notice, 'text', true)
2052
+ .catch(err => logger.debug(`[resume] failed to post thread-scope resume notice: ${err}`));
2053
+ }
1603
2054
  }
1604
- else if (ds.session.rootMessageId) {
1605
- replyMessage(ds.larkAppId, ds.session.rootMessageId, notice, 'text', true)
1606
- .catch(err => logger.debug(`[resume] failed to post thread-scope resume notice: ${err}`));
2055
+ // Report the EFFECTIVE action, not the raw request flag: only fork when wake
2056
+ // was asked AND there's no live worker to clobber. (resumeSession always hands
2057
+ // back a worker:null ds today, so this matches `wake` in practice — but
2058
+ // reporting the action keeps the response honest if the guard ever broadens.)
2059
+ const woke = wake && (!ds.worker || ds.worker.killed);
2060
+ if (woke) {
2061
+ forkWorker(ds, '', true);
1607
2062
  }
1608
- }
1609
- // Report the EFFECTIVE action, not the raw request flag: only fork when wake
1610
- // was asked AND there's no live worker to clobber. (resumeSession always hands
1611
- // back a worker:null ds today, so this matches `wake` in practice — but
1612
- // reporting the action keeps the response honest if the guard ever broadens.)
1613
- const woke = wake && (!ds.worker || ds.worker.killed);
1614
- if (woke) {
1615
- forkWorker(ds, '', true);
1616
- }
1617
- jsonRes(res, 200, {
1618
- ok: true,
1619
- sessionId,
1620
- wake: woke,
1621
- title: ds.session.title,
1622
- chatId: ds.chatId,
1623
- rootMessageId: ds.session.rootMessageId,
1624
- workingDir: ds.session.workingDir,
1625
- cliId,
2063
+ jsonRes(res, 200, {
2064
+ ok: true,
2065
+ sessionId,
2066
+ wake: woke,
2067
+ title: ds.session.title,
2068
+ chatId: ds.chatId,
2069
+ rootMessageId: ds.session.rootMessageId,
2070
+ workingDir: ds.session.workingDir,
2071
+ cliId,
2072
+ });
1626
2073
  });
1627
2074
  });
1628
2075
  /**
@@ -2074,15 +2521,22 @@ ipcRoute('POST', '/api/trigger', async (req, res) => {
2074
2521
  const result = await triggerSessionTurn(valid.request, { larkAppId: cachedLarkAppId, activeSessions });
2075
2522
  const status = result.ok
2076
2523
  ? 200
2077
- : result.errorCode === 'bot_not_in_chat'
2078
- ? 403
2079
- : result.errorCode === 'session_not_found'
2080
- ? 404
2081
- : result.errorCode === 'wait_timeout'
2082
- ? 504
2083
- : result.errorCode === 'target_required' || result.errorCode === 'bad_request'
2084
- ? 400
2085
- : 500;
2524
+ // An idempotent retry that resolves to a durable `failed` async state is a
2525
+ // successful HTTP call reporting a terminal outcome (like a 200 completed/
2526
+ // queued), not a request error — surface it 200 so the caller reads `state`.
2527
+ : result.state === 'failed'
2528
+ ? 200
2529
+ : result.errorCode === 'idempotency_conflict'
2530
+ ? 409
2531
+ : result.errorCode === 'bot_not_in_chat'
2532
+ ? 403
2533
+ : result.errorCode === 'session_not_found'
2534
+ ? 404
2535
+ : result.errorCode === 'wait_timeout'
2536
+ ? 504
2537
+ : result.errorCode === 'target_required' || result.errorCode === 'bad_request'
2538
+ ? 400
2539
+ : 500;
2086
2540
  return jsonRes(res, status, result);
2087
2541
  }
2088
2542
  catch (e) {
@@ -3021,6 +3475,7 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => {
3021
3475
  restrictGrantCommands: grantPrefs.restrictGrantCommands,
3022
3476
  autoGrantRequestCards: grantPrefs.autoGrantRequestCards,
3023
3477
  messageQuotaDefaultLimit: grantPrefs.messageQuotaDefaultLimit,
3478
+ grantDefaultDurationMs: grantPrefs.grantDefaultDurationMs,
3024
3479
  p2pMode,
3025
3480
  skillInjection,
3026
3481
  skillInjectionSupport,
@@ -3190,7 +3645,8 @@ ipcRoute('PUT', '/api/bot-summary-trigger', async (req, res) => {
3190
3645
  // Per-bot 授权偏好。Body 任意子集:
3191
3646
  // • restrictGrantCommands: boolean — 限制被授权人只能纯对话
3192
3647
  // • autoGrantRequestCards: boolean — 未授权 @ 被挡住时是否发 grant 申请卡
3193
- // • messageQuotaDefaultLimit: number|null — 卡片默认额度覆盖(null = 产品默认 3 条)
3648
+ // • messageQuotaDefaultLimit: number|null — 卡片/Oncall 额度覆盖(null = 卡片内置 3 条、Oncall 不限)
3649
+ // • grantDefaultDurationMs: number|null — 新授权默认有限时长(null = 产品默认 1 小时)
3194
3650
  ipcRoute('PUT', '/api/bot-grant-prefs', async (req, res) => {
3195
3651
  if (!cachedLarkAppId)
3196
3652
  return jsonRes(res, 503, { error: 'larkAppId_not_set' });
@@ -3211,11 +3667,16 @@ ipcRoute('PUT', '/api/bot-grant-prefs', async (req, res) => {
3211
3667
  patch.restrictGrantCommands = body.restrictGrantCommands;
3212
3668
  if (typeof body.autoGrantRequestCards === 'boolean')
3213
3669
  patch.autoGrantRequestCards = body.autoGrantRequestCards;
3214
- // null(含 JSON null)= 关闭默认额度;number = 设定(store 内再校验正整数)。
3670
+ // null(含 JSON null)= 恢复内置额度策略;number = 设定覆盖值(store 内校验 1–1000)。
3215
3671
  if (body.messageQuotaDefaultLimit === null)
3216
3672
  patch.messageQuotaDefaultLimit = null;
3217
3673
  else if (typeof body.messageQuotaDefaultLimit === 'number')
3218
3674
  patch.messageQuotaDefaultLimit = body.messageQuotaDefaultLimit;
3675
+ // null = 恢复产品默认 1 小时;number 由 store 按卡片有限选项白名单校验。
3676
+ if (body.grantDefaultDurationMs === null)
3677
+ patch.grantDefaultDurationMs = null;
3678
+ else if (typeof body.grantDefaultDurationMs === 'number')
3679
+ patch.grantDefaultDurationMs = body.grantDefaultDurationMs;
3219
3680
  if (Object.keys(patch).length === 0)
3220
3681
  return jsonRes(res, 400, { ok: false, error: 'no_valid_fields' });
3221
3682
  const r = await grantPrefsStore.updateBotGrantPrefs(cachedLarkAppId, patch);
@@ -3343,6 +3804,7 @@ ipcRoute('PUT', '/api/bot-avatar', async (req, res) => {
3343
3804
  ipcRoute('PUT', '/api/bot-agent', async (req, res) => {
3344
3805
  if (!cachedLarkAppId)
3345
3806
  return jsonRes(res, 503, { error: 'larkAppId_not_set' });
3807
+ const larkAppId = cachedLarkAppId;
3346
3808
  let body;
3347
3809
  try {
3348
3810
  body = await readJsonBody(req);
@@ -3361,7 +3823,7 @@ ipcRoute('PUT', '/api/bot-agent', async (req, res) => {
3361
3823
  return jsonRes(res, 400, { ok: false, error: 'invalid_cli', message: err?.message ?? String(err) });
3362
3824
  }
3363
3825
  const model = typeof body.model === 'string' ? body.model.trim() : '';
3364
- const currentBotConfig = getBot(cachedLarkAppId).config;
3826
+ const currentBotConfig = getBot(larkAppId).config;
3365
3827
  const runtimeFieldPresent = Object.prototype.hasOwnProperty.call(body, 'cliRuntime');
3366
3828
  const currentSelectionKey = selectionKeyForBot(currentBotConfig.cliId, currentBotConfig.wrapperCli);
3367
3829
  const selectionChanged = key !== currentSelectionKey;
@@ -3436,94 +3898,109 @@ ipcRoute('PUT', '/api/bot-agent', async (req, res) => {
3436
3898
  const availabilityWarning = availability.available
3437
3899
  ? undefined
3438
3900
  : `配置已保存,但所选 Agent 当前无法启动:${availability.reason ?? '本地启动依赖不可用'}。请先在 daemon 所在机器安装或修正 PATH / CLI 路径。`;
3439
- // If the new CLI/wrapper can no longer enforce a currently-on read isolation,
3440
- // auto-clear the flag here so the next session doesn't fail-close on it. (The
3441
- // read-isolation toggle validates at enable time; changing the agent afterwards
3442
- // is the other way a bot could end up configured-but-unenforceable.)
3443
- let readIsolationCleared = false;
3444
- const r = await rmwBotEntry(cachedLarkAppId, (entry) => {
3445
- entry.cliId = selected.cliId;
3901
+ return withBotTurnMutation(larkAppId, async () => {
3902
+ // Agent selection can replace every live worker generation and may also
3903
+ // auto-clear readIsolation. Close admission and drain in-flight acceptance
3904
+ // before inspecting both the registry and restart source of truth. A
3905
+ // settings mutation is not an explicit abandon boundary: an unsettled
3906
+ // Codex App FIFO must survive unchanged for recovery.
3907
+ const activeBotSessions = listActiveSessions().filter(ds => ds.larkAppId === larkAppId);
3908
+ const persistedActiveBotSessions = sessionStore.listSessions().filter(session => session.status === 'active'
3909
+ && (session.larkAppId === larkAppId || !session.larkAppId));
3910
+ if (rejectProtectedSessionMutation(res, [
3911
+ ...activeBotSessions,
3912
+ ...persistedActiveBotSessions,
3913
+ ]))
3914
+ return;
3915
+ // If the new CLI/wrapper can no longer enforce a currently-on read isolation,
3916
+ // auto-clear the flag here so the next session doesn't fail-close on it. (The
3917
+ // read-isolation toggle validates at enable time; changing the agent afterwards
3918
+ // is the other way a bot could end up configured-but-unenforceable.)
3919
+ let readIsolationCleared = false;
3920
+ const r = await rmwBotEntry(larkAppId, (entry) => {
3921
+ entry.cliId = selected.cliId;
3922
+ if (selected.wrapperCli)
3923
+ entry.wrapperCli = selected.wrapperCli;
3924
+ else
3925
+ delete entry.wrapperCli;
3926
+ if (nextRuntime) {
3927
+ entry.cliRuntime = nextRuntime;
3928
+ // Downgrade shadow: older BotMux versions ignore cliRuntime but retain
3929
+ // cliPathOverride, so a rollback still launches this distribution.
3930
+ entry.cliPathOverride = nextRuntime.executable;
3931
+ }
3932
+ else if (nextLegacyPath) {
3933
+ entry.cliPathOverride = nextLegacyPath;
3934
+ delete entry.cliRuntime;
3935
+ }
3936
+ else {
3937
+ delete entry.cliRuntime;
3938
+ delete entry.cliPathOverride;
3939
+ }
3940
+ if (model)
3941
+ entry.model = model;
3942
+ else
3943
+ delete entry.model;
3944
+ if (entry.readIsolation === true &&
3945
+ !readIsolationEnforceableFor({ cliId: selected.cliId, cliPathOverride: effectivePath, wrapperCli: selected.wrapperCli })) {
3946
+ delete entry.readIsolation;
3947
+ readIsolationCleared = true;
3948
+ }
3949
+ // cliId=riff → backendType 自动设为 riff(否则 spawn 走 pty 后端找不到本地二进制)。
3950
+ if (selected.cliId === 'riff') {
3951
+ entry.backendType = 'riff';
3952
+ }
3953
+ else if (entry.backendType === 'riff') {
3954
+ // 从 riff 切回其它 CLI:清掉这个自动配对的 backend override,回落 daemon
3955
+ // 默认后端——否则新 CLI 会跑在 RiffBackend 上(PTY 分块输入被当成一串 riff
3956
+ // 任务)。手动的 pty/tmux/herdr/zellij override 不受影响(它们不会是 riff)。
3957
+ delete entry.backendType;
3958
+ }
3959
+ return { write: true, result: null };
3960
+ });
3961
+ if (!r.ok)
3962
+ return jsonRes(res, 400, { ok: false, error: r.reason });
3963
+ const bot = getBot(larkAppId);
3964
+ bot.config.cliId = selected.cliId;
3965
+ bot.config.cliRuntime = nextRuntime;
3966
+ bot.config.cliPathOverride = nextRuntime?.executable ?? nextLegacyPath;
3446
3967
  if (selected.wrapperCli)
3447
- entry.wrapperCli = selected.wrapperCli;
3968
+ bot.config.wrapperCli = selected.wrapperCli;
3448
3969
  else
3449
- delete entry.wrapperCli;
3450
- if (nextRuntime) {
3451
- entry.cliRuntime = nextRuntime;
3452
- // Downgrade shadow: older BotMux versions ignore cliRuntime but retain
3453
- // cliPathOverride, so a rollback still launches this distribution.
3454
- entry.cliPathOverride = nextRuntime.executable;
3455
- }
3456
- else if (nextLegacyPath) {
3457
- entry.cliPathOverride = nextLegacyPath;
3458
- delete entry.cliRuntime;
3459
- }
3460
- else {
3461
- delete entry.cliRuntime;
3462
- delete entry.cliPathOverride;
3463
- }
3464
- if (model)
3465
- entry.model = model;
3466
- else
3467
- delete entry.model;
3468
- if (entry.readIsolation === true &&
3469
- !readIsolationEnforceableFor({ cliId: selected.cliId, cliPathOverride: effectivePath, wrapperCli: selected.wrapperCli })) {
3470
- delete entry.readIsolation;
3471
- readIsolationCleared = true;
3472
- }
3473
- // cliId=riff → backendType 自动设为 riff(否则 spawn 走 pty 后端找不到本地二进制)。
3970
+ bot.config.wrapperCli = undefined;
3971
+ bot.config.model = model || undefined;
3972
+ if (readIsolationCleared)
3973
+ bot.config.readIsolation = false;
3474
3974
  if (selected.cliId === 'riff') {
3475
- entry.backendType = 'riff';
3975
+ bot.config.backendType = 'riff';
3476
3976
  }
3477
- else if (entry.backendType === 'riff') {
3478
- // riff 切回其它 CLI:清掉这个自动配对的 backend override,回落 daemon
3479
- // 默认后端——否则新 CLI 会跑在 RiffBackend 上(PTY 分块输入被当成一串 riff
3480
- // 任务)。手动的 pty/tmux/herdr/zellij override 不受影响(它们不会是 riff)。
3481
- delete entry.backendType;
3977
+ else if (bot.config.backendType === 'riff') {
3978
+ bot.config.backendType = undefined;
3482
3979
  }
3483
- return { write: true, result: null };
3484
- });
3485
- if (!r.ok)
3486
- return jsonRes(res, 400, { ok: false, error: r.reason });
3487
- const bot = getBot(cachedLarkAppId);
3488
- bot.config.cliId = selected.cliId;
3489
- bot.config.cliRuntime = nextRuntime;
3490
- bot.config.cliPathOverride = nextRuntime?.executable ?? nextLegacyPath;
3491
- if (selected.wrapperCli)
3492
- bot.config.wrapperCli = selected.wrapperCli;
3493
- else
3494
- bot.config.wrapperCli = undefined;
3495
- bot.config.model = model || undefined;
3496
- if (readIsolationCleared)
3497
- bot.config.readIsolation = false;
3498
- if (selected.cliId === 'riff') {
3499
- bot.config.backendType = 'riff';
3500
- }
3501
- else if (bot.config.backendType === 'riff') {
3502
- bot.config.backendType = undefined;
3503
- }
3504
- // 热切后立刻清掉本 bot 名下失配的存量会话——否则它们冻结的旧 CLI 会被下一条
3505
- // 消息 lazy resume 复活,要等下次 daemon 重启才被 restore 守卫清理。
3506
- const closedMismatchedSessions = await closeCliMismatchedSessionsForBot(cachedLarkAppId);
3507
- const selectionKey = selectionKeyForBot(selected.cliId, selected.wrapperCli);
3508
- jsonRes(res, 200, {
3509
- ok: true,
3510
- cliId: selected.cliId,
3511
- cliRuntime: nextRuntime ?? null,
3512
- cliPathOverride: nextRuntime ? null : nextLegacyPath ?? null,
3513
- wrapperCli: selected.wrapperCli ?? null,
3514
- model: model || null,
3515
- selectionKey,
3516
- closedMismatchedSessions,
3517
- // Report the (possibly auto-cleared) read-isolation state + whether the new
3518
- // agent can still enforce it, so the dashboard updates its toggle immediately
3519
- // instead of showing a stale enabled/supported state until a full refetch.
3520
- readIsolation: bot.config.readIsolation === true,
3521
- readIsolationSupported: readIsolationEnforceableFor(bot.config),
3522
- readIsolationCleared,
3523
- agentAvailable: availability.available,
3524
- availabilityWarning,
3525
- requiredCommand: availability.command,
3526
- runtimeProbe,
3980
+ // 热切后立刻清掉本 bot 名下失配的存量会话——否则它们冻结的旧 CLI 会被下一条
3981
+ // 消息 lazy resume 复活,要等下次 daemon 重启才被 restore 守卫清理。
3982
+ const closedMismatchedSessions = await closeCliMismatchedSessionsForBot(larkAppId);
3983
+ const selectionKey = selectionKeyForBot(selected.cliId, selected.wrapperCli);
3984
+ jsonRes(res, 200, {
3985
+ ok: true,
3986
+ cliId: selected.cliId,
3987
+ cliRuntime: nextRuntime ?? null,
3988
+ cliPathOverride: nextRuntime ? null : nextLegacyPath ?? null,
3989
+ wrapperCli: selected.wrapperCli ?? null,
3990
+ model: model || null,
3991
+ selectionKey,
3992
+ closedMismatchedSessions,
3993
+ // Report the (possibly auto-cleared) read-isolation state + whether the new
3994
+ // agent can still enforce it, so the dashboard updates its toggle immediately
3995
+ // instead of showing a stale enabled/supported state until a full refetch.
3996
+ readIsolation: bot.config.readIsolation === true,
3997
+ readIsolationSupported: readIsolationEnforceableFor(bot.config),
3998
+ readIsolationCleared,
3999
+ agentAvailable: availability.available,
4000
+ availabilityWarning,
4001
+ requiredCommand: availability.command,
4002
+ runtimeProbe,
4003
+ });
3527
4004
  });
3528
4005
  });
3529
4006
  // Per-bot 私聊单聊模式 p2pMode。Body `{ p2pMode: 'chat' | 'thread' }`:
@@ -3905,6 +4382,9 @@ ipcRoute('PUT', '/api/bot-sandbox', async (req, res) => {
3905
4382
  catch {
3906
4383
  return jsonRes(res, 400, { ok: false, error: 'bad_json' });
3907
4384
  }
4385
+ // File-sandbox policy is frozen onto each Session at creation and reused on
4386
+ // restore; this toggle is intentionally next-session-only and cannot mutate
4387
+ // a live pane's profile.
3908
4388
  const r = await sandboxStore.updateBotSandbox(cachedLarkAppId, body.enabled === true);
3909
4389
  if (!r.ok)
3910
4390
  return jsonRes(res, 400, { ok: false, error: r.reason });
@@ -3941,6 +4421,7 @@ ipcRoute('PUT', '/api/bot-sandbox-paths', async (req, res) => {
3941
4421
  ipcRoute('PUT', '/api/bot-read-isolation', async (req, res) => {
3942
4422
  if (!cachedLarkAppId)
3943
4423
  return jsonRes(res, 503, { error: 'larkAppId_not_set' });
4424
+ const larkAppId = cachedLarkAppId;
3944
4425
  let body;
3945
4426
  try {
3946
4427
  body = await readJsonBody(req);
@@ -3949,23 +4430,94 @@ ipcRoute('PUT', '/api/bot-read-isolation', async (req, res) => {
3949
4430
  return jsonRes(res, 400, { ok: false, error: 'bad_json' });
3950
4431
  }
3951
4432
  const enable = body.enabled === true;
3952
- // The worker FAIL-CLOSES (refuses to start the session) for a configured
3953
- // readIsolation that can't be enforced: non-darwin, an adapter without
3954
- // supportsReadIsolation, or a wrapperCli gateway. Reject enabling it in exactly
3955
- // those cases so the toggle can never brick the bot's next session. (Turning it
3956
- // OFF is always allowed — recovers a flag that became unenforceable.)
3957
- if (enable && !readIsolationEnforceable(cachedLarkAppId)) {
3958
- return jsonRes(res, 400, { ok: false, error: 'read_isolation_unenforceable' });
3959
- }
3960
- const r = await sandboxStore.updateBotReadIsolation(cachedLarkAppId, enable);
3961
- if (!r.ok)
3962
- return jsonRes(res, 400, { ok: false, error: r.reason });
3963
- // Read isolation only takes effect at COLD spawn (provisionIsolatedBotHome +
3964
- // Seatbelt wrapper run then). Suspend this bot's active sessions so the next
3965
- // message cold-restarts under the new state otherwise close+resume would keep
3966
- // running the old, un-provisioned state and the toggle would silently no-op.
3967
- const suspendedSessions = await suspendActiveSessionsForBot(cachedLarkAppId);
3968
- jsonRes(res, 200, { ok: true, readIsolation: r.readIsolation, suspendedSessions });
4433
+ return withBotTurnMutation(larkAppId, async () => {
4434
+ // An idempotent request changes neither the durable policy nor any pane.
4435
+ // Return before pending/active/teardown guards so a dashboard refresh that
4436
+ // repeats the authoritative value cannot be rejected merely because the
4437
+ // bot is doing work.
4438
+ if (sandboxStore.getBotReadIsolation(larkAppId) === enable) {
4439
+ return jsonRes(res, 200, {
4440
+ ok: true,
4441
+ readIsolation: enable,
4442
+ suspendedSessions: 0,
4443
+ changed: false,
4444
+ });
4445
+ }
4446
+ // Close admission first and drain handlers that may already be awaiting
4447
+ // downloads/noteTurnReceived. Ledger preflight alone cannot see those
4448
+ // pre-accept turns; draining prevents a post-sweep send into a killed ds.
4449
+ const activeBotSessions = listActiveSessions().filter(ds => ds.larkAppId === larkAppId);
4450
+ // Registry state alone is insufficient: partial restore, an anchor
4451
+ // collision, or a failed staggered reattach can omit a durable active row
4452
+ // while its persistent pane still survives. Consult the same persisted
4453
+ // session source a restart will hydrate. Legacy unscoped active rows are
4454
+ // conservatively treated as this daemon's until explicitly closed.
4455
+ const persistedBotSessions = sessionStore.listSessions().filter(session => session.larkAppId === larkAppId || !session.larkAppId);
4456
+ const persistedActiveBotSessions = persistedBotSessions.filter(session => session.status === 'active');
4457
+ if (rejectProtectedSessionMutation(res, [
4458
+ ...activeBotSessions,
4459
+ ...persistedActiveBotSessions,
4460
+ ]))
4461
+ return;
4462
+ // Crash-transactional safety boundary: bots.json is the restart source of
4463
+ // truth, while a live tmux/herdr/zellij pane retains its old in-memory
4464
+ // Seatbelt profile. Persisting the new flag before tearing those panes down
4465
+ // creates an unrecoverable crash window because the restart path cannot
4466
+ // prove which exact read/write isolation profile a surviving pane runs.
4467
+ // Require explicit close first; with no active logical session there is no
4468
+ // owned pane a restart can reattach under the newly persisted policy.
4469
+ if (activeBotSessions.length > 0 || persistedActiveBotSessions.length > 0) {
4470
+ return jsonRes(res, 409, {
4471
+ ok: false,
4472
+ error: 'read_isolation_active_sessions',
4473
+ });
4474
+ }
4475
+ // `/close` intentionally returns after sending worker close IPC and marking
4476
+ // the row closed; persistent-pane destruction can lag. A closed row's pid
4477
+ // is deliberately not probed: PID alone has no birth identity and may have
4478
+ // been reused by an unrelated process. closeSession clears it atomically.
4479
+ // For current rows, the stamped persistent backend is the teardown proof.
4480
+ // Pre-stamp closed rows are not synchronously probed across three CLIs here:
4481
+ // that legacy shell fan-out blocks the daemon event loop, while any active
4482
+ // legacy row has already failed the active-session guard above.
4483
+ for (const session of persistedBotSessions) {
4484
+ if (session.adoptedFrom || session.title?.startsWith('Adopt:'))
4485
+ continue;
4486
+ const backendTypes = persistentBackend.isSuspendableBackendType(session.backendType)
4487
+ ? [session.backendType]
4488
+ : [];
4489
+ for (const backendType of backendTypes) {
4490
+ const backingName = persistentBackend.persistentSessionName(backendType, session.sessionId);
4491
+ if (persistentBackend.probePersistentSession(backendType, backingName) !== 'missing') {
4492
+ return jsonRes(res, 409, {
4493
+ ok: false,
4494
+ error: 'read_isolation_teardown_unverified',
4495
+ });
4496
+ }
4497
+ }
4498
+ }
4499
+ // The worker FAIL-CLOSES (refuses to start the session) for a configured
4500
+ // readIsolation that cannot be enforced. Check this after the active-session
4501
+ // safety boundary so even an unsupported enable cannot obscure a surviving
4502
+ // old-policy pane with a less important validation error.
4503
+ if (enable && !readIsolationEnforceable(larkAppId)) {
4504
+ return jsonRes(res, 400, { ok: false, error: 'read_isolation_unenforceable' });
4505
+ }
4506
+ // With the gate closed and no active logical session, persistence is the
4507
+ // only mutation. updateBotReadIsolation writes bots.json atomically and
4508
+ // then publishes the same value to the daemon runtime before resolving.
4509
+ // A crash at any point can only lead to a cold spawn under the old or new
4510
+ // durable policy; there is no owned pane to reattach.
4511
+ const r = await sandboxStore.updateBotReadIsolation(larkAppId, enable);
4512
+ if (!r.ok)
4513
+ return jsonRes(res, 400, { ok: false, error: r.reason });
4514
+ jsonRes(res, 200, {
4515
+ ok: true,
4516
+ readIsolation: r.readIsolation,
4517
+ suspendedSessions: 0,
4518
+ changed: true,
4519
+ });
4520
+ });
3969
4521
  });
3970
4522
  // Per-bot session backend override (pty | tmux | herdr | zellij | zmx), or clear it
3971
4523
  // ('' / 'auto' / null → follow the daemon default). next-session 生效:running
@@ -4357,6 +4909,8 @@ export function startIpcServer(opts) {
4357
4909
  return jsonRes(res, 401, { ok: false, error: 'unauthorized', reason: auth.reason });
4358
4910
  }
4359
4911
  }
4912
+ if (!publicRoute && opts.ready)
4913
+ await opts.ready;
4360
4914
  for (const r of routes) {
4361
4915
  if (r.method !== req.method)
4362
4916
  continue;