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
@@ -3,10 +3,11 @@ import { config } from '../../config.js';
3
3
  import { getBot, getAllBots, getOwnerOpenId } from '../../bot-registry.js';
4
4
  import { canOperate, canTalk } from './event-dispatcher.js';
5
5
  import { updateMessage, deleteMessage, replyMessage, sendMessage, sendUserMessage, sendEphemeralCard, getMessageDetail, isHumanOpenId, resolveUserUnionId as defaultResolveUserUnionId } from './client.js';
6
- import { buildSessionCard, buildStreamingCard, buildTuiPromptCard, buildTuiPromptProcessingCard, buildGrantResultCard, getCliDisplayName, truncateContent, buildConfigCard, buildConfigTextCard, CONFIG_UNSET, buildRepoSelectCard } from './card-builder.js';
6
+ import { buildSessionCard, buildStreamingCard, buildTuiPromptCard, buildTuiPromptProcessingCard, buildGrantResultCard, getCliDisplayName, truncateContent, buildConfigCard, buildConfigQuotaCard, buildConfigTextCard, CONFIG_UNSET, buildRepoSelectCard } from './card-builder.js';
7
7
  import { codexServiceTierBadge } from '../../services/codex-service-tier.js';
8
8
  import { findConfigField, applyConfigField, coerceConfigValue, getConfigCardData, } from '../../services/bot-config-store.js';
9
9
  import { updateBotGrantPrefs } from '../../services/grant-prefs-store.js';
10
+ import { MAX_GRANT_QUOTA } from '../../services/grant-policy.js';
10
11
  import { writeTeamRoleFile, deleteTeamRoleFile } from '../../core/role-resolver.js';
11
12
  import { addChatGrant, addGlobalGrant } from '../../services/grant-store.js';
12
13
  import { checkNonce, clearPending, getPendingGrantLimits, getPendingMessage, markDenied, updatePendingGrantLimits, } from './grant-pending.js';
@@ -29,22 +30,25 @@ import { ttadkConfigModelChoices } from '../../setup/cli-selection.js';
29
30
  import { logger } from '../../utils/logger.js';
30
31
  import * as sessionStore from '../../services/session-store.js';
31
32
  import { loadFrozenCards, saveFrozenCards } from '../../services/frozen-card-store.js';
32
- import { forkWorker, sendWorkerInput, sendWorkerSessionInput, killWorker, closeSession, teardownAuthoritativePersistentBackingBeforeClose, scheduleCardPatch, parkStreamCard, clearUsageLimitState, cardUsageLimit, writableTerminalLinkFor, workerHasInitialized, sessionSupportsWebTerminal, readableTerminalUrlFor, resolvePrivateCardAudience, deliverWriteLinkCard, deliverEphemeralOrReply, CARD_POSTING_SENTINEL, requestSessionRestart, isSessionTransferring, getDaemonStreamingCardUsageSnapshot } from '../../core/worker-pool.js';
33
+ import { forkWorker, sendWorkerInput, sendWorkerSessionInput, closeSession as closeWorkerPoolSession, teardownAuthoritativePersistentBackingBeforeClose, scheduleCardPatch, parkStreamCard, clearUsageLimitState, cardUsageLimit, writableTerminalLinkFor, workerHasInitialized, sessionSupportsWebTerminal, readableTerminalUrlFor, resolvePrivateCardAudience, deliverWriteLinkCard, deliverEphemeralOrReply, CARD_POSTING_SENTINEL, requestSessionRestart, isSessionTransferring, getDaemonStreamingCardUsageSnapshot, withActiveSessionKeyLock } from '../../core/worker-pool.js';
33
34
  import { getSessionWorkingDir, buildNewTopicCliInput, getAvailableBots, persistStreamCardState, resumeSession, rememberLastCliInput, ensureSessionWhiteboard } from '../../core/session-manager.js';
34
35
  import { markInitialUserTurnPending } from '../../core/initial-user-turn.js';
35
- import { publishAttentionPatch, publishClosedSessionPatch, announcePendingRepoSession } from '../../core/session-activity.js';
36
+ import { publishAttentionPatch, announcePendingRepoSession } from '../../core/session-activity.js';
36
37
  import { fallbackTurnId } from '../../core/reply-target.js';
37
38
  import { sendWorkerIpc } from '../../core/worker-ipc.js';
38
39
  import { validateWorkingDir } from '../../core/working-dir.js';
39
- import { sessionKey, sessionAnchorId, frozenDisplayMode, markRepoCardConsumed, isActiveRepoCard, claimCurrentRepoCard } from '../../core/types.js';
40
+ import { activeSessionKey, sessionKey, sessionAnchorId, frozenDisplayMode, markRepoCardConsumed, isActiveRepoCard } from '../../core/types.js';
40
41
  import { buildTerminalUrl } from '../../core/terminal-url.js';
41
42
  import { createRepoWorktree, removeRepoWorktree, dirSuffixForBranch, pushWorktreeBranch } from '../../services/git-worktree.js';
42
43
  import { withCodexAppContext } from '../../utils/codex-app-context.js';
43
- import { resolvePairedSpawnBackendType } from '../../core/persistent-backend.js';
44
+ import { isRiffBackendSession, resolvePairedSpawnBackendType } from '../../core/persistent-backend.js';
44
45
  import { sessionConfiguredRuntimeDisplayName } from '../../core/cli-runtime-display.js';
45
46
  import { worktreeSlugFromContextAI } from '../../services/worktree-slug-ai.js';
46
47
  import { t, localeForBot, isLocale } from '../../i18n/index.js';
47
48
  import { isLocalCliOpenCapable, isLocalCliOpenConfigured, isLocalCliOpenReady, localCliOpenMode, openLocalCliInIterm, preflightLocalCliOpen, } from '../../services/local-cli-opener.js';
49
+ import { hasProtectedSessionMutationOwnership } from '../../core/session-mutation-guard.js';
50
+ import { persistPendingRepoCardMessageId } from '../../core/pending-repo-journal.js';
51
+ import { runDetachedBotTurnAdmission, withBotTurnMutation } from '../../core/bot-turn-mutation-gate.js';
48
52
  /**
49
53
  * Resolve the verified `union_id` of the operator who clicked a card button.
50
54
  *
@@ -235,193 +239,210 @@ export async function commitRepoSelection(ctx, dirPath, dirLabel,
235
239
  // The worktree flow already posted a precise "worktree 已创建:path 分支 …"
236
240
  // line before funnelling in here — suppress the redundant "已选择/已切换"
237
241
  // confirmation so the user sees a single message, not two.
238
- // pinWorkingDir=false: "直接开始"/skip keeps the default cwd for this launch
239
- // without persisting it (esp. $HOME) onto session.workingDir — sibling bots
240
- // must still get their own repo card instead of inheriting HOME.
241
- // confirmReplyText: optional confirmation to send under the same claim
242
- // (used by skip_repo so the post-fork reply window cannot race a second click).
243
242
  opts) {
244
243
  const { ds, rootId, cardMessageId, larkAppId, operatorOpenId, activeSessions, sessionReply } = ctx;
245
244
  const locTarget = localeForBot(ds.larkAppId);
246
245
  // `/close` deletes the active-map entry without touching sessionId or
247
246
  // pendingRepo — identity against the map is the only tell that the session
248
247
  // this flow captured is gone. Checked alongside the generation snapshots.
249
- const repoSessionKey = larkAppId ? sessionKey(rootId, larkAppId) : rootId;
248
+ const repoSessionKey = activeSessionKey(ds);
250
249
  const sessionStillActive = () => activeSessions.get(repoSessionKey) === ds;
251
250
  const commitGenSessionId = ds.session.sessionId;
252
251
  const pinWorkingDir = opts?.pinWorkingDir !== false;
252
+ // Card callbacks are replayable. Only the currently published picker may
253
+ // mutate this session; a withdrawn/replaced card must stay stale even after
254
+ // the activation FIFO eventually settles. Internal auto-worktree commits do
255
+ // not originate from a card and deliberately omit cardMessageId.
256
+ if (cardMessageId !== undefined
257
+ && (!ds.repoCardMessageId || cardMessageId !== ds.repoCardMessageId)) {
258
+ logger.warn(`[${tag(ds)}] Ignoring stale repo-card callback ${cardMessageId} `
259
+ + `(current=${ds.repoCardMessageId ?? 'none'})`);
260
+ return false;
261
+ }
262
+ // A live Riff generation cannot use the generic close-and-refork branch.
263
+ // Riff teardown is a remote prepare/commit protocol; a failed cancellation
264
+ // followed by forkWorker would reach the double-fork kill and orphan the
265
+ // still-live remote task. Require an explicit /close before any card,
266
+ // worktree, or manual-directory selection can replace this generation.
267
+ if (!ds.pendingRepo && isRiffBackendSession(ds)) {
268
+ await sessionReply(rootId, t('cmd.cd.riff_unsupported', undefined, locTarget));
269
+ logger.warn(`[${tag(ds)}] Repo switch refused: Riff session requires explicit close before replacement`);
270
+ return false;
271
+ }
272
+ if (!ds.pendingRepo
273
+ && hasProtectedSessionMutationOwnership(ds)) {
274
+ await sessionReply(rootId, '当前会话仍有待提交消息,暂不能切换仓库;请等待提交完成或关闭会话。');
275
+ return false;
276
+ }
253
277
  if (ds.pendingRepo) {
254
- // Card select/skip and auto-worktree converge here; the text /repo path
255
- // mirrors this transaction through the same in-memory claim. Only one may
256
- // own the pending -> worker transition, including prompt preparation.
257
- if (ds.pendingRepoCommitInFlight) {
258
- logger.info(`[${tag(ds)}] Pending repo commit already in flight — ignoring duplicate selection`);
259
- return;
260
- }
278
+ const targetSessionId = ds.session.sessionId;
261
279
  ds.pendingRepoCommitInFlight = true;
262
- let committed = false;
263
280
  try {
264
- // Claim before mutating the selected directory so two different card
265
- // choices cannot overwrite each other while one prompt is being built.
266
- // Skip/bare-start may intentionally leave workingDir unpinned so the
267
- // launch uses the bot default without persisting $HOME for siblings.
268
- if (pinWorkingDir) {
269
- ds.workingDir = dirPath;
270
- ds.session.workingDir = dirPath;
271
- }
272
- ds.session.riffRepoDirs = opts?.riffRepoDirs;
273
- sessionStore.updateSession(ds.session);
274
- const selfBot = getBot(ds.larkAppId);
275
- const botCfg = selfBot.config;
276
- const effectiveCliId = sessionCliId(ds);
277
- // Keep pendingRepo=true across this await. New topic messages therefore
278
- // remain buffered on this same session instead of racing a worker-null
279
- // safety-net fork. Snapshot every pending field only after it settles.
280
- const needsPromptContext = !ds.pendingRawInput ||
281
- (ds.pendingPrompt?.trim().length ?? 0) > 0 ||
282
- (ds.pendingAttachments?.length ?? 0) > 0 ||
283
- (ds.pendingFollowUps?.length ?? 0) > 0 ||
284
- ds.pendingChatContext !== undefined;
285
- const availableBots = needsPromptContext
286
- ? await getAvailableBots(ds.larkAppId, ds.chatId)
287
- : [];
288
- if (!sessionStillActive() || ds.session.sessionId !== commitGenSessionId ||
289
- !ds.pendingRepo || (ds.worker && !ds.worker.killed)) {
290
- logger.warn(`[${tag(ds)}] Session changed while preparing the pending-CLI prompt (${commitGenSessionId} → ${ds.session.sessionId}, active=${sessionStillActive()}, pending=${!!ds.pendingRepo}, worker=${!!ds.worker}) — aborting this fork`);
291
- return;
292
- }
293
- const pendingPrompt = ds.pendingPrompt ?? '';
294
- const pendingRawInput = ds.pendingRawInput;
295
- const hasBufferedInput = pendingPrompt.trim().length > 0 ||
296
- (ds.pendingAttachments?.length ?? 0) > 0 ||
297
- (ds.pendingFollowUps?.length ?? 0) > 0 ||
298
- ds.pendingChatContext !== undefined;
299
- // Nothing to submit at all (session created by a bare `/repo`, i.e. the
300
- // message IS the command). Boot the CLI idle instead of burning an empty
301
- // `<user_message>` opening on it, and mark the session so the user's NEXT
302
- // real message becomes the new-topic first turn. Mirrors the text
303
- // `/repo` path in command-handler's forkPendingCli.
304
- const emptyStart = !pendingRawInput && !hasBufferedInput;
305
- if (!pendingRawInput || hasBufferedInput)
306
- ensureSessionWhiteboard(ds);
307
- const wrappedInput = hasBufferedInput
308
- ? buildNewTopicCliInput(pendingPrompt, ds.session.sessionId, effectiveCliId, botCfg.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, availableBots, ds.pendingFollowUps, { name: selfBot.botName, openId: selfBot.botOpenId }, locTarget, ds.pendingSender, {
309
- larkAppId: ds.larkAppId,
310
- chatId: ds.chatId,
311
- whiteboardId: ds.session.whiteboardId,
312
- substituteTrigger: ds.pendingSubstituteTrigger,
313
- codexAppText: ds.pendingCodexAppText,
314
- codexAppApplicationContext: ds.pendingCodexAppApplicationContext,
315
- codexAppMessageContext: ds.pendingCodexAppMessageContext,
316
- codexAppFollowUps: ds.pendingCodexAppFollowUps,
317
- codexAppFollowUpContexts: ds.pendingCodexAppFollowUpContexts,
318
- chatContext: ds.pendingChatContext,
319
- })
320
- : { content: '' };
321
- const prompt = pendingRawInput ? '' : wrappedInput;
322
- const pendingTurnId = ds.pendingTurnId;
323
- const previousPendingFollowUpInput = ds.pendingFollowUpInput;
324
- if (pendingRawInput && hasBufferedInput) {
325
- ds.pendingFollowUpInput = {
326
- userPrompt: ds.pendingCodexAppText !== undefined || ds.pendingCodexAppFollowUps
327
- ? [ds.pendingCodexAppText ?? '', ...(ds.pendingCodexAppFollowUps ?? [])].filter(Boolean).join('\n\n')
328
- : pendingPrompt || ds.pendingFollowUps?.join('\n\n') || '',
329
- cliInput: wrappedInput.content,
330
- ...(ds.pendingFollowUpTurnId ? { turnId: ds.pendingFollowUpTurnId } : {}),
331
- ...(effectiveCliId === 'codex-app' && botCfg.codexAppCleanInput === true && wrappedInput.codexAppInput
332
- ? { codexAppInput: wrappedInput.codexAppInput }
333
- : {}),
334
- codexAppInputGateFrozen: true,
335
- };
336
- }
337
- // From here through forkWorker there is no await: publish the state
338
- // transition atomically from the router's point of view.
339
- ds.pendingRepo = false;
340
- publishAttentionPatch(ds);
341
- try {
342
- if (pendingRawInput) {
343
- // pendingRawTurnId is applied at the literal PTY write boundary.
344
- forkWorker(ds, '', false);
281
+ const started = await withBotTurnMutation(ds.larkAppId, async () => {
282
+ const current = [...activeSessions.values()].find(candidate => candidate.session.sessionId === targetSessionId
283
+ && candidate.session.status === 'active');
284
+ if (!current || current !== ds || !current.pendingRepo)
285
+ return false;
286
+ // "Start directly" launches in the resolved default cwd without pinning
287
+ // HOME onto the session for sibling-bot inheritance.
288
+ if (pinWorkingDir) {
289
+ ds.workingDir = dirPath;
290
+ ds.session.workingDir = dirPath;
345
291
  }
346
- else if (pendingTurnId && hasBufferedInput) {
347
- forkWorker(ds, prompt, { turnId: pendingTurnId });
292
+ // riff 多仓 stamp:只有多仓 worktree 流显式传入(保留用户选择顺序,首仓=primary);
293
+ // 其它选仓路径一律清除旧 stamp——workingDir 变了,旧的多仓组合不再成立。
294
+ ds.session.riffRepoDirs = opts?.riffRepoDirs;
295
+ sessionStore.updateSession(ds.session);
296
+ const selfBot = getBot(ds.larkAppId);
297
+ const botCfg = selfBot.config;
298
+ const effectiveCliId = sessionCliId(ds);
299
+ // Keep pendingRepo=true across this await. New topic messages therefore
300
+ // remain buffered on this same session instead of racing a worker-null
301
+ // safety-net fork. Snapshot every pending field only after it settles.
302
+ const needsPromptContext = !ds.pendingRawInput ||
303
+ (ds.pendingPrompt?.trim().length ?? 0) > 0 ||
304
+ (ds.pendingAttachments?.length ?? 0) > 0 ||
305
+ (ds.pendingFollowUps?.length ?? 0) > 0 ||
306
+ ds.pendingChatContext !== undefined;
307
+ const availableBots = needsPromptContext
308
+ ? await getAvailableBots(ds.larkAppId, ds.chatId)
309
+ : [];
310
+ if (!sessionStillActive() || ds.session.sessionId !== commitGenSessionId ||
311
+ !ds.pendingRepo || (ds.worker && !ds.worker.killed)) {
312
+ logger.warn(`[${tag(ds)}] Session changed while preparing the pending-CLI prompt (${commitGenSessionId} → ${ds.session.sessionId}, active=${sessionStillActive()}, pending=${!!ds.pendingRepo}, worker=${!!ds.worker}) — aborting this fork`);
313
+ return;
348
314
  }
349
- else if (hasBufferedInput) {
350
- forkWorker(ds, prompt);
315
+ const pendingPrompt = ds.pendingPrompt ?? '';
316
+ const pendingRawInput = ds.pendingRawInput;
317
+ // Raw-input cold start still wraps any input buffered while the repo card
318
+ // was pending — see the skip_repo branch for the rationale.
319
+ const hasBufferedInput = pendingPrompt.trim().length > 0 ||
320
+ ds.pendingCodexAppText !== undefined ||
321
+ (ds.pendingAttachments?.length ?? 0) > 0 ||
322
+ (ds.pendingFollowUps?.length ?? 0) > 0 ||
323
+ ds.pendingChatContext !== undefined;
324
+ // Nothing to submit at all (session created by a bare `/repo`, i.e. the
325
+ // message IS the command). Boot the CLI idle instead of burning an empty
326
+ // `<user_message>` opening on it, and mark the session so the user's NEXT
327
+ // real message becomes the new-topic first turn. Mirrors the text
328
+ // `/repo` path in command-handler's forkPendingCli.
329
+ const emptyStart = !pendingRawInput && !hasBufferedInput;
330
+ if (!pendingRawInput || hasBufferedInput)
331
+ ensureSessionWhiteboard(ds);
332
+ const wrappedInput = hasBufferedInput
333
+ ? buildNewTopicCliInput(pendingPrompt, ds.session.sessionId, effectiveCliId, botCfg.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, availableBots, ds.pendingFollowUps, { name: selfBot.botName, openId: selfBot.botOpenId }, locTarget, ds.pendingSender, {
334
+ larkAppId: ds.larkAppId,
335
+ chatId: ds.chatId,
336
+ whiteboardId: ds.session.whiteboardId,
337
+ substituteTrigger: ds.pendingSubstituteTrigger,
338
+ codexAppText: ds.pendingCodexAppText,
339
+ codexAppApplicationContext: ds.pendingCodexAppApplicationContext,
340
+ codexAppMessageContext: ds.pendingCodexAppMessageContext,
341
+ codexAppFollowUps: ds.pendingCodexAppFollowUps,
342
+ codexAppFollowUpContexts: ds.pendingCodexAppFollowUpContexts,
343
+ chatContext: ds.pendingChatContext,
344
+ })
345
+ : undefined;
346
+ const prompt = pendingRawInput ? '' : (wrappedInput ?? '');
347
+ // Last-line defence: prompt prep awaited above — if anything replaced
348
+ // OR closed the session in that window, forking now would clobber it
349
+ // (or resurrect a /close'd session).
350
+ if (!sessionStillActive() || ds.session.sessionId !== commitGenSessionId) {
351
+ logger.warn(`[${tag(ds)}] Session replaced or closed while preparing the pending-CLI prompt (${commitGenSessionId} → ${ds.session.sessionId}, active=${sessionStillActive()}) — aborting this fork`);
352
+ return false;
351
353
  }
352
- else {
353
- // Empty start — idle boot, no turn submitted.
354
- forkWorker(ds, '', false);
354
+ if (pendingRawInput && hasBufferedInput && wrappedInput) {
355
+ ds.pendingFollowUpInput = {
356
+ userPrompt: ds.pendingCodexAppText !== undefined || ds.pendingCodexAppFollowUps
357
+ ? [ds.pendingCodexAppText ?? '', ...(ds.pendingCodexAppFollowUps ?? [])].filter(Boolean).join('\n\n')
358
+ : pendingPrompt || ds.pendingFollowUps?.join('\n\n') || '',
359
+ cliInput: wrappedInput.content,
360
+ ...((ds.pendingFollowUpTurnIds?.at(-1) ?? ds.pendingFollowUpTurnId)
361
+ ? { turnId: ds.pendingFollowUpTurnIds?.at(-1) ?? ds.pendingFollowUpTurnId }
362
+ : {}),
363
+ ...(effectiveCliId === 'codex-app' && botCfg.codexAppCleanInput === true && wrappedInput.codexAppInput
364
+ ? { codexAppInput: wrappedInput.codexAppInput }
365
+ : {}),
366
+ codexAppInputGateFrozen: true,
367
+ };
355
368
  }
356
- }
357
- catch (e) {
358
- ds.pendingRepo = true;
359
- ds.pendingFollowUpInput = previousPendingFollowUpInput;
369
+ if (pendingRawInput)
370
+ rememberLastCliInput(ds, pendingRawInput, pendingRawInput);
371
+ else if (hasBufferedInput && wrappedInput)
372
+ rememberLastCliInput(ds, pendingPrompt, wrappedInput);
373
+ // Keep the reservation and every buffered opening field intact through
374
+ // forkWorker's synchronous pre-accept/write-ahead phase. If it throws,
375
+ // the user can retry this exact selection without losing the first turn.
376
+ const pendingTurnId = ds.pendingTurnId ?? ds.session.pendingRepoSetup?.turnId;
377
+ forkWorker(ds, prompt, !pendingRawInput && pendingTurnId ? { turnId: pendingTurnId } : false);
378
+ ds.pendingRepo = false;
379
+ // A queued activation owns the route through its adapter-level ACK. Every
380
+ // buffer below was synchronously folded into prompt N and is safe to clear;
381
+ // later inbounds observe this gate and enter the separate exact staged FIFO.
382
+ ds.initialStartPending = ds.session.queuedActivationPending === true;
360
383
  publishAttentionPatch(ds);
361
- throw e;
362
- }
363
- if (pendingRawInput || hasBufferedInput) {
364
- rememberLastCliInput(ds, pendingRawInput ?? pendingPrompt, pendingRawInput ?? wrappedInput);
365
- }
366
- // Durable, one-shot: the CLI is up but has never received a real user turn.
367
- // Set after the fork so a throwing fork leaves the session untouched.
368
- if (emptyStart)
369
- markInitialUserTurnPending(ds);
370
- ds.pendingPrompt = undefined;
371
- ds.pendingCodexAppText = undefined;
372
- ds.pendingCodexAppApplicationContext = undefined;
373
- ds.pendingCodexAppMessageContext = undefined;
374
- ds.pendingChatContext = undefined;
375
- ds.pendingAttachments = undefined;
376
- ds.pendingMentions = undefined;
377
- ds.pendingSubstituteTrigger = undefined;
378
- ds.pendingSender = undefined;
379
- ds.pendingFollowUps = undefined;
380
- ds.pendingCodexAppFollowUps = undefined;
381
- ds.pendingCodexAppFollowUpContexts = undefined;
382
- ds.pendingFollowUpTurnId = undefined;
383
- ds.pendingTurnId = undefined;
384
- committed = true;
385
- // Local one-shot consume BEFORE any network await. Feishu withdraw is
386
- // best-effort and may lag/fail; correctness for stale card clicks depends
387
- // on this mark so a second callback cannot mid-session-switch after claim
388
- // release (and so a sessionReply throw that jumps to finally still leaves
389
- // the card locally invalid).
384
+ // Durable, one-shot: the CLI is up but has never received a real user turn.
385
+ // Bare `/repo` idle-booted above (empty prompt, no turn); mark the session
386
+ // so the user's NEXT real message becomes the new-topic first turn.
387
+ if (emptyStart)
388
+ markInitialUserTurnPending(ds);
389
+ ds.pendingPrompt = undefined;
390
+ ds.pendingCodexAppText = undefined;
391
+ ds.pendingCodexAppApplicationContext = undefined;
392
+ ds.pendingCodexAppMessageContext = undefined;
393
+ ds.pendingChatContext = undefined;
394
+ ds.pendingAttachments = undefined;
395
+ ds.pendingMentions = undefined;
396
+ ds.pendingSubstituteTrigger = undefined;
397
+ ds.pendingSender = undefined;
398
+ ds.pendingFollowUps = undefined;
399
+ ds.pendingFollowUpTurnId = undefined;
400
+ ds.pendingFollowUpTurnIds = undefined;
401
+ ds.pendingCodexAppFollowUps = undefined;
402
+ ds.pendingCodexAppFollowUpContexts = undefined;
403
+ ds.pendingCodexAppFollowUpGateAccepted = undefined;
404
+ ds.pendingTurnId = undefined;
405
+ return true;
406
+ });
407
+ if (!started)
408
+ return false;
409
+ // Invalidate synchronously at the successful commit boundary. Card
410
+ // withdrawal and confirmation are best effort and may await/fail;
411
+ // neither may leave a replayable mutation capability behind.
390
412
  const cardToWithdraw = cardMessageId ?? ds.repoCardMessageId;
391
413
  markRepoCardConsumed(ds, cardToWithdraw);
392
414
  ds.repoCardMessageId = undefined;
393
- // Hold the claim through confirmation. Card withdrawal is deferred until
394
- // after the callback can ACK; stale clicks are blocked by the local
395
- // consume mark above rather than by the network request's lifetime.
415
+ // Keep the pending-selection claim until the confirmation attempt
416
+ // settles. This prevents a second picker action from reinterpreting the
417
+ // freshly-started session as a mid-session repository switch.
396
418
  try {
397
419
  if (!opts?.suppressConfirmReply) {
398
- // A card click has no turn of its own — anchor the confirmation to the
399
- // session's current reply-target turn so a shared fold-back topic keeps
400
- // it in-thread (same leak as the /repo command path).
401
420
  await sessionReply(rootId, t('cmd.repo.selected_in_pending', { name: dirLabel }, locTarget), undefined, fallbackTurnId(ds, undefined));
402
421
  }
403
422
  else if (opts.confirmReplyText) {
404
423
  await sessionReply(rootId, opts.confirmReplyText, undefined, fallbackTurnId(ds, undefined));
405
424
  }
406
425
  }
407
- catch (e) {
408
- logger.warn(`[${tag(ds)}] Confirm reply after pending repo commit failed: ${e instanceof Error ? e.message : e}`);
426
+ catch (err) {
427
+ logger.warn(`[${tag(ds)}] Confirm reply after pending repo commit failed: `
428
+ + `${err instanceof Error ? err.message : String(err)}`);
409
429
  }
430
+ // Withdrawal is deliberately detached so the card callback can ACK
431
+ // before Lark observes that the source message disappeared.
410
432
  deferRepoCardWithdraw(larkAppId, cardToWithdraw);
433
+ logger.info(`[${tag(ds)}] Repo selected: ${dirPath}, spawning CLI`);
434
+ return true;
411
435
  }
412
436
  finally {
413
437
  ds.pendingRepoCommitInFlight = false;
414
438
  }
415
- if (!committed)
416
- return;
417
- logger.info(`[${tag(ds)}] Repo selected: ${dirPath}, spawning CLI`);
418
- return;
419
439
  }
420
440
  else {
421
441
  // Mid-session repo switch — close old session, start fresh.
422
442
  // ZMX close is identity/generation verified and may refuse. Prove teardown
423
- // before claiming the card or mutating any old-session state; on refusal
424
- // the current session and card remain fully retryable.
443
+ // before mutating any old-session state; on refusal the current session and
444
+ // card remain fully retryable. (The in-lock closeWorkerPoolSession below
445
+ // repeats teardown close-wins; this pre-guard fail-closes on transfer.)
425
446
  try {
426
447
  teardownAuthoritativePersistentBackingBeforeClose(ds);
427
448
  }
@@ -434,17 +455,7 @@ opts) {
434
455
  catch (replyErr) {
435
456
  logger.warn(`[${tag(ds)}] Repo-switch teardown failure reply failed: ${replyErr instanceof Error ? replyErr.message : replyErr}`);
436
457
  }
437
- return;
438
- }
439
- // Claim the current card BEFORE killWorker / any await. A concurrent click
440
- // on the same Feishu card must not pass a second kill+fork while the first
441
- // switch is still awaiting confirm replies. Correctness does not depend on
442
- // deleteMessage succeeding.
443
- const claimedCard = claimCurrentRepoCard(ds, cardMessageId);
444
- if (cardMessageId && !claimedCard) {
445
- // Stale / already-claimed card (entry check races can still reach here).
446
- logger.info(`[${tag(ds)}] Ignoring stale mid-session repo card ${cardMessageId}`);
447
- return;
458
+ return false;
448
459
  }
449
460
  // Safety net (mirrors the `/repo` text-command path): build the same
450
461
  // "session closed" card `/close` emits BEFORE displacing the old session
@@ -457,69 +468,74 @@ opts) {
457
468
  // the displaced session's stored workingDir (and the closed card), so
458
469
  // `claude --resume` later would reopen the old context in the new repo's
459
470
  // cwd. The new repo is pinned onto the fresh session below instead.
460
- const oldSession = ds.session;
461
- const closedCard = buildClosedSessionCard(ds, locTarget);
462
- killWorker(ds);
463
- // Park the current card in `frozenCards` so the next POST under the new
464
- // session sweeps it via recall. closeSession() wipes the on-disk
465
- // frozen-cards file under the OLD sessionId, but the in-memory Map
466
- // travels with `ds` into the new session and still carries the
467
- // old messageId for deletion. If fork or POST fails, the parked card
468
- // stays in the thread instead of vanishing prematurely.
469
- parkStreamCard(ds);
470
- sessionStore.closeSession(ds.session.sessionId);
471
- publishClosedSessionPatch(ds.session.sessionId, ds.session.closedAt ? Date.parse(ds.session.closedAt) : undefined);
472
- await deliverEphemeralOrReply(ds, operatorOpenId, closedCard, 'interactive', () => sessionReply(rootId, closedCard, 'interactive'));
473
- // The close card delivery yields to inbound routing and dashboard IPC. If
474
- // the user closed/replaced this generation during that await, do not mint a
475
- // fresh active row from the stale continuation.
476
- if (!sessionStillActive() || ds.session !== oldSession) {
477
- logger.warn(`[${tag(ds)}] Repo switch cancelled after old-session close; routing generation changed during card delivery`);
478
- return;
471
+ const targetSessionId = ds.session.sessionId;
472
+ const switched = await withBotTurnMutation(ds.larkAppId, async () => {
473
+ const candidate = [...activeSessions.values()].find(candidate => candidate.session.sessionId === targetSessionId);
474
+ if (!candidate || candidate !== ds || candidate.session.status !== 'active') {
475
+ return { ok: false, error: 'session_replaced' };
476
+ }
477
+ const key = activeSessionKey(candidate);
478
+ return withActiveSessionKeyLock(activeSessions, key, async () => {
479
+ const current = [...activeSessions.values()].find(owner => owner.session.sessionId === targetSessionId);
480
+ if (!current || current !== candidate
481
+ || activeSessions.get(key) !== current
482
+ || current.session.status !== 'active') {
483
+ return { ok: false, error: 'session_replaced' };
484
+ }
485
+ if (hasProtectedSessionMutationOwnership(current)) {
486
+ return { ok: false, error: 'dispatch_pending' };
487
+ }
488
+ const closedCard = buildClosedSessionCard(current, locTarget);
489
+ // Preserve the old card in memory before durable close clears its old
490
+ // frozen-card file; it is re-keyed under the replacement session below.
491
+ parkStreamCard(current);
492
+ const oldSession = current.session;
493
+ await closeWorkerPoolSession(targetSessionId);
494
+ if (activeSessions.get(key) === current)
495
+ activeSessions.delete(key);
496
+ if (activeSessions.has(key)) {
497
+ return { ok: false, error: 'session_replaced' };
498
+ }
499
+ const cardToWithdraw = cardMessageId ?? current.repoCardMessageId;
500
+ markRepoCardConsumed(current, cardToWithdraw);
501
+ current.repoCardMessageId = undefined;
502
+ const session = sessionStore.createSession(current.chatId, current.scope === 'chat' ? oldSession.rootMessageId : rootId, dirLabel, current.chatType, current.scope);
503
+ current.session = session;
504
+ current.lastUserPrompt = undefined;
505
+ current.lastCliInput = undefined;
506
+ current.workingDir = dirPath;
507
+ session.workingDir = dirPath;
508
+ session.larkAppId = current.larkAppId;
509
+ session.chatDisplayName = oldSession.chatDisplayName;
510
+ session.ownerOpenId = oldSession.ownerOpenId;
511
+ session.creatorOpenId = oldSession.creatorOpenId;
512
+ session.lastCallerOpenId = oldSession.lastCallerOpenId;
513
+ session.riffRepoDirs = opts?.riffRepoDirs;
514
+ sessionStore.updateSession(session);
515
+ current.hasHistory = false;
516
+ if (current.frozenCards && current.frozenCards.size > 0) {
517
+ saveFrozenCards(session.sessionId, current.frozenCards);
518
+ }
519
+ current.streamCardId = undefined;
520
+ current.streamCardNonce = undefined;
521
+ current.streamCardPending = undefined;
522
+ current.lastScreenContent = undefined;
523
+ current.lastScreenStatus = undefined;
524
+ activeSessions.set(key, current);
525
+ forkWorker(current, '', false);
526
+ // Brand-new CLI in a brand-new session record: the next real business
527
+ // message is its new-topic first turn (same invariant as the pending path).
528
+ markInitialUserTurnPending(current);
529
+ return { ok: true, current, closedCard, cardToWithdraw };
530
+ });
531
+ });
532
+ if (!switched.ok) {
533
+ if (switched.error === 'dispatch_pending') {
534
+ await sessionReply(rootId, '当前 Codex App 仍有未结算消息,暂不能切换仓库;请等待本轮完成或关闭会话。');
535
+ }
536
+ return false;
479
537
  }
480
- // `rootId` is the routing anchor. For chat-scope sessions it is the
481
- // `oc_...` chat id, not the traceable `om_...` message root stored on
482
- // Session. Preserve the old identity and explicitly persist scope so card
483
- // switches cannot recreate the session as a legacy scope-less thread.
484
- const session = sessionStore.createSession(ds.chatId, ds.scope === 'chat' ? oldSession.rootMessageId : rootId, dirLabel, ds.chatType, ds.scope);
485
- ds.session = session;
486
- ds.lastUserPrompt = undefined;
487
- ds.lastCliInput = undefined;
488
- // Pin workingDir + larkAppId onto the new session before forkWorker.
489
- // Without this, a daemon restart restores the session with an empty
490
- // workingDir and the worker spawns in the bot's default cwd, so
491
- // `claude --resume` looks in the wrong .claude/projects/<hash>/ dir and
492
- // exits code 0 immediately, crash-looping until the rate-limiter trips.
493
- ds.workingDir = dirPath;
494
- ds.session.workingDir = dirPath;
495
- ds.session.larkAppId = ds.larkAppId;
496
- ds.session.chatDisplayName = oldSession.chatDisplayName;
497
- ds.session.ownerOpenId = oldSession.ownerOpenId;
498
- ds.session.creatorOpenId = oldSession.creatorOpenId;
499
- ds.session.lastCallerOpenId = oldSession.lastCallerOpenId;
500
- // Stamp the newly-created session, not the displaced session that was just
501
- // closed. Plain/single-repo switches pass undefined and clear stale state.
502
- ds.session.riffRepoDirs = opts?.riffRepoDirs;
503
- sessionStore.updateSession(ds.session);
504
- ds.hasHistory = false;
505
- // Re-persist the parked card under the NEW sessionId so a daemon crash
506
- // before the next POST doesn't strand it. closeSession() above wiped
507
- // the on-disk file under the OLD sessionId; without this re-save, the
508
- // in-memory Map only survives in process memory.
509
- if (ds.frozenCards && ds.frozenCards.size > 0) {
510
- saveFrozenCards(ds.session.sessionId, ds.frozenCards);
511
- }
512
- // Drop the old turn's streaming-card reference so worker_ready POSTs a
513
- // fresh card for the new session instead of PATCHing the previous one.
514
- ds.streamCardId = undefined;
515
- ds.streamCardNonce = undefined;
516
- ds.streamCardPending = undefined;
517
- ds.lastScreenContent = undefined;
518
- ds.lastScreenStatus = undefined;
519
- forkWorker(ds, '', false);
520
- // Brand-new CLI in a brand-new session record: the next real business
521
- // message is its new-topic first turn (same invariant as the pending path).
522
- markInitialUserTurnPending(ds);
538
+ await deliverEphemeralOrReply(switched.current, operatorOpenId, switched.closedCard, 'interactive', () => sessionReply(rootId, switched.closedCard, 'interactive'));
523
539
  if (!opts?.suppressConfirmReply) {
524
540
  try {
525
541
  await sessionReply(rootId, t('cmd.repo.switched_to', { name: dirLabel }, locTarget));
@@ -528,11 +544,13 @@ opts) {
528
544
  logger.warn(`[${tag(ds)}] Confirm reply after mid-session repo switch failed: ${e instanceof Error ? e.message : e}`);
529
545
  }
530
546
  }
531
- logger.info(`[${tag(ds)}] Repo switched to ${dirPath}, new session created`);
532
- // The card was claimed locally above; withdraw it only after this callback
533
- // can return its ACK so the delete request cannot race the callback frame.
534
- deferRepoCardWithdraw(larkAppId, claimedCard);
547
+ // Return the callback ACK before recalling the card. Lark can keep the
548
+ // delete request pending; awaiting it here stalls the card action and lets
549
+ // the client retry an otherwise successful repository switch.
550
+ deferRepoCardWithdraw(larkAppId, switched.cardToWithdraw);
551
+ logger.info(`[${tag(switched.current)}] Repo switched to ${dirPath}, new session created`);
535
552
  }
553
+ return true;
536
554
  }
537
555
  /**
538
556
  * 仅默认目录 + auto-worktree 的**异步**提交:`ds` 必须已注册进 activeSessions 且处于
@@ -578,11 +596,15 @@ export async function runAutoWorktreeCommit(deps) {
578
596
  // pendingRepo session, folds any messages buffered during creation (pendingPrompt
579
597
  // + pendingFollowUps) into the first turn. suppressConfirmReply: the worktree
580
598
  // helper already posted the '已创建/回退' line, so skip the '已选择' confirmation.
581
- await commitRepoSelection({
599
+ // The worktree build is intentionally detached from its caller's inbound
600
+ // admission. Re-enter with a fresh lease at the delayed commit/fork edge;
601
+ // the outer lease may have ended minutes ago and must not authorize this
602
+ // descendant across a bot-wide config mutation.
603
+ await runDetachedBotTurnAdmission(larkAppId, () => commitRepoSelection({
582
604
  ds, rootId: anchor, larkAppId, operatorOpenId, activeSessions,
583
605
  // Never reached under suppressConfirmReply for a pendingRepo session.
584
606
  sessionReply: async () => '',
585
- }, wt.dir, pathBasename(wt.dir), { suppressConfirmReply: true });
607
+ }, wt.dir, pathBasename(wt.dir), { suppressConfirmReply: true }));
586
608
  }
587
609
  catch (e) {
588
610
  // No recovery fork here: forking with an empty prompt would DROP the buffered
@@ -1129,8 +1151,11 @@ export async function handleCardAction(data, deps, larkAppId) {
1129
1151
  // in place rather than appending a new message.
1130
1152
  return JSON.parse(cardJson);
1131
1153
  }
1132
- // ─── /botconfig 交互卡片:切换布尔开关 / 选择 cli·model·lang / 消息额度,就地刷新 ──
1133
- const CONFIG_CARD_ACTIONS = ['config_toggle', 'config_set', 'config_quota', 'config_text_open', 'config_text_save'];
1154
+ // ─── /botconfig 交互卡片:切换布尔开关 / 选择 cli·model·lang / 编辑自由输入项 ──
1155
+ const CONFIG_CARD_ACTIONS = [
1156
+ 'config_toggle', 'config_set', 'config_quota', 'config_quota_open',
1157
+ 'config_quota_save', 'config_text_open', 'config_text_save',
1158
+ ];
1134
1159
  if (value?.action && larkAppId && CONFIG_CARD_ACTIONS.includes(value.action)) {
1135
1160
  // 卡片携带的渲染语言(`/botconfig en` 的覆盖)优先;缺省回落 bot 默认。
1136
1161
  const vLoc = value?.loc;
@@ -1216,11 +1241,57 @@ export async function handleCardAction(data, deps, larkAppId) {
1216
1241
  logger.info(`[config:${larkAppId}] text field ${spec.key} saved via card`);
1217
1242
  return { toast: { type: 'success', content: `✓ ${spec.key} = ${r.newText}` } };
1218
1243
  }
1219
- // 消息额度(grant-prefs,非 CONFIG_FIELDS 字段):'off' = 关闭,正整数 = 设定。
1244
+ // 消息额度使用独立私信子卡自由输入,与 Dashboard 1–1000 语义一致。
1245
+ if (value.action === 'config_quota_open') {
1246
+ const d = getConfigCardData(larkAppId, modelChoices);
1247
+ if (!d)
1248
+ return { toast: { type: 'error', content: t('cmd.config.no_bot', undefined, loc) } };
1249
+ try {
1250
+ await sendUserMessage(larkAppId, operatorOpenId, buildConfigQuotaCard(d, loc), 'interactive');
1251
+ return { toast: { type: 'success', content: t('card.config.quota_sent', undefined, loc) } };
1252
+ }
1253
+ catch {
1254
+ return { toast: { type: 'error', content: t('card.config.quota_send_fail', undefined, loc) } };
1255
+ }
1256
+ }
1257
+ if (value.action === 'config_quota_save') {
1258
+ const fv = action?.form_value ?? {};
1259
+ const raw = String(fv.messageQuota ?? '').trim();
1260
+ let limit = null;
1261
+ if (raw) {
1262
+ const n = Number(raw);
1263
+ if (!/^\d+$/.test(raw) || !Number.isSafeInteger(n) || n < 1 || n > MAX_GRANT_QUOTA) {
1264
+ return { toast: { type: 'error', content: t('card.config.quota_invalid', undefined, loc) } };
1265
+ }
1266
+ limit = n;
1267
+ }
1268
+ const qr = await updateBotGrantPrefs(larkAppId, { messageQuotaDefaultLimit: limit });
1269
+ if (!qr.ok)
1270
+ return { toast: { type: 'error', content: t('cmd.config.write_failed', { reason: qr.reason }, loc) } };
1271
+ return {
1272
+ toast: {
1273
+ type: 'success',
1274
+ content: limit == null
1275
+ ? t('card.config.quota_saved_default', undefined, loc)
1276
+ : t('card.config.quota_saved', { quota: limit }, loc),
1277
+ },
1278
+ };
1279
+ }
1280
+ // 兼容已发出的旧版下拉卡:'off' = 恢复内置策略,正整数 = 设定。
1220
1281
  if (value.action === 'config_quota') {
1221
1282
  const raw = action?.option ?? '';
1222
- const n = raw === 'off' ? null : Number(raw);
1223
- const limit = n && Number.isInteger(n) && n > 0 ? n : null;
1283
+ // legacy 是只读初始项,用来让历史 >1000 额度仍能生成合法下拉卡。
1284
+ if (raw === 'legacy') {
1285
+ return { toast: { type: 'info', content: t('card.config.quota_legacy_unchanged', undefined, loc) }, ...reRender() };
1286
+ }
1287
+ let limit = null;
1288
+ if (raw !== 'off') {
1289
+ const n = Number(raw);
1290
+ if (!/^\d+$/.test(raw) || !Number.isSafeInteger(n) || n < 1 || n > MAX_GRANT_QUOTA) {
1291
+ return { toast: { type: 'error', content: t('card.config.quota_invalid', undefined, loc) } };
1292
+ }
1293
+ limit = n;
1294
+ }
1224
1295
  const qr = await updateBotGrantPrefs(larkAppId, { messageQuotaDefaultLimit: limit });
1225
1296
  if (!qr.ok)
1226
1297
  return { toast: { type: 'error', content: t('cmd.config.write_failed', { reason: qr.reason }, loc) } };
@@ -1823,20 +1894,38 @@ export async function handleCardAction(data, deps, larkAppId) {
1823
1894
  logger.info(`[${tag(ds)}] voice_summary blocked because worker is busy: ${ds.lastScreenStatus ?? 'unknown'}`);
1824
1895
  return { toast: { type: 'warning', content: t('card.voice.toast_worker_busy', undefined, locDs) } };
1825
1896
  }
1826
- voicedCardIds.add(dedupeKey);
1827
- if (voicedCardIds.size > 5000) {
1828
- voicedCardIds.clear();
1829
- voicedCardIds.add(dedupeKey);
1897
+ if ((!ds.worker || ds.worker.killed) && hasProtectedSessionMutationOwnership(ds)) {
1898
+ logger.info(`[${tag(ds)}] voice_summary deferred behind durable opening ownership`);
1899
+ return { toast: { type: 'warning', content: t('card.voice.toast_worker_busy', undefined, locDs) } };
1830
1900
  }
1831
1901
  const instruction = voiceSummaryInstruction(locDs);
1832
1902
  const voiceInput = {
1833
1903
  content: instruction,
1834
1904
  codexAppInput: withCodexAppContext({ text: t('card.voice.user_message', undefined, locDs) }, 'botmux_voice_summary_instruction', instruction, 'application'),
1835
1905
  };
1836
- if (ds.worker && !ds.worker.killed)
1837
- sendWorkerInput(ds, voiceInput);
1838
- else
1839
- forkWorker(ds, voiceInput, ds.hasHistory);
1906
+ let accepted = false;
1907
+ try {
1908
+ if (ds.worker && !ds.worker.killed)
1909
+ accepted = sendWorkerInput(ds, voiceInput);
1910
+ else {
1911
+ forkWorker(ds, voiceInput, ds.hasHistory);
1912
+ accepted = true;
1913
+ }
1914
+ }
1915
+ catch (err) {
1916
+ logger.warn(`[${tag(ds)}] voice_summary failed before acceptance: `
1917
+ + `${err instanceof Error ? err.message : String(err)}`);
1918
+ }
1919
+ if (!accepted) {
1920
+ return { toast: { type: 'warning', content: t('card.voice.toast_worker_busy', undefined, locDs) } };
1921
+ }
1922
+ // Burn the replay key only after live IPC or the durable activation tail
1923
+ // accepted the exact instruction.
1924
+ voicedCardIds.add(dedupeKey);
1925
+ if (voicedCardIds.size > 5000) {
1926
+ voicedCardIds.clear();
1927
+ voicedCardIds.add(dedupeKey);
1928
+ }
1840
1929
  logger.info(`[${tag(ds)}] voice_summary triggered by ${operatorOpenId ?? '?'}`);
1841
1930
  return { toast: { type: 'success', content: t('card.voice.toast_wait', undefined, locDs) } };
1842
1931
  }
@@ -1847,11 +1936,27 @@ export async function handleCardAction(data, deps, larkAppId) {
1847
1936
  // already omits the restart button when adoptMode=true, but a stale
1848
1937
  // pre-fix card or a malformed action payload could still arrive.
1849
1938
  const locDs = localeForBot(ds.larkAppId);
1850
- if (ds.adoptedFrom) {
1939
+ if (ds.adoptedFrom || ds.initConfig?.adoptMode) {
1851
1940
  logger.warn(`[${tag(ds)}] Rejected restart on adopt session — would kill user's pane`);
1852
1941
  await sessionReply(rootId, t('card.action.adopt_no_restart', undefined, locDs));
1853
1942
  return;
1854
1943
  }
1944
+ // New Riff cards omit this button, but old/stale cards remain clickable.
1945
+ // Surface the same explicit close-and-recreate guidance as /restart
1946
+ // instead of forwarding an IPC the Riff worker must silently refuse.
1947
+ if (isRiffBackendSession(ds)) {
1948
+ logger.warn(`[${tag(ds)}] Rejected restart on Riff backend session`);
1949
+ const unsupported = t('cmd.restart.riff_unsupported', undefined, locDs);
1950
+ await deliverEphemeralOrReply(ds, operatorOpenId, unsupported, 'text', () => sessionReply(rootId, unsupported));
1951
+ return;
1952
+ }
1953
+ // Codex App: an accepted-but-unsettled dispatch still owns the turn route.
1954
+ // requestSessionRestart does not itself gate on dispatch ownership, so
1955
+ // reject here before the restart coordinator tears the worker down.
1956
+ if (hasProtectedSessionMutationOwnership(ds)) {
1957
+ await sessionReply(rootId, '当前 Codex App 仍有未结算消息,暂不能重启;请等待本轮完成或关闭会话。');
1958
+ return;
1959
+ }
1855
1960
  if (isSessionTransferring(ds)) {
1856
1961
  return {
1857
1962
  toast: {
@@ -1877,27 +1982,40 @@ export async function handleCardAction(data, deps, larkAppId) {
1877
1982
  // 会话」却静默无反应会让人以为按钮坏了,给一条失败 toast(成功路径不弹,已关卡即反馈)。
1878
1983
  return { toast: { type: 'warning', content: t('card.action.session_gone', undefined, localeForBot(larkAppId)) } };
1879
1984
  }
1880
- const botCfg = getBot(ds.larkAppId).config;
1881
- // Build the closed card BEFORE killWorker/closeSession it reads the
1882
- // live session's identity off `ds`.
1883
- const card = buildClosedSessionCard(ds, localeForBot(ds.larkAppId));
1884
- try {
1885
- await closeSession(ds.session.sessionId);
1985
+ const targetSessionId = ds.session.sessionId;
1986
+ const closed = await withBotTurnMutation(ds.larkAppId, async () => {
1987
+ // Card payload roots survive transfers. Re-resolve by immutable session
1988
+ // id after draining admissions, then let the pool remove the target's
1989
+ // CURRENT activeSessionKey. Never delete the stale payload root, which
1990
+ // may now belong to an unrelated pending FIFO owner.
1991
+ const current = [...activeSessions.values()].find(candidate => candidate.session.sessionId === targetSessionId);
1992
+ if (!current)
1993
+ return undefined;
1994
+ const botCfg = getBot(current.larkAppId).config;
1995
+ // Build the closed card BEFORE closeWorkerPoolSession — it reads the
1996
+ // live session's identity off `current`.
1997
+ const card = buildClosedSessionCard(current, localeForBot(current.larkAppId));
1998
+ try {
1999
+ await closeWorkerPoolSession(targetSessionId);
2000
+ }
2001
+ catch (err) {
2002
+ logger.error(`[${tag(current)}] Refused close because backing teardown was not verified: ${err}`);
2003
+ return { status: 'teardown_failed', err };
2004
+ }
2005
+ return { status: 'closed', current, botCfg, card };
2006
+ });
2007
+ if (!closed) {
2008
+ return { toast: { type: 'warning', content: t('card.action.session_gone', undefined, localeForBot(larkAppId)) } };
1886
2009
  }
1887
- catch (err) {
1888
- logger.error(`[${tag(ds)}] Refused close because backing teardown was not verified: ${err}`);
2010
+ if (closed.status === 'teardown_failed') {
1889
2011
  return {
1890
2012
  toast: {
1891
2013
  type: 'warning',
1892
- content: `会话关闭失败:${err instanceof Error ? err.message : String(err)}`,
2014
+ content: `会话关闭失败:${closed.err instanceof Error ? closed.err.message : String(closed.err)}`,
1893
2015
  },
1894
2016
  };
1895
2017
  }
1896
- // closeSession removes the captured ds before awaiting best-effort remote
1897
- // cleanup. A new session may claim the same route during that await; only
1898
- // delete here if this handler still owns the exact object it closed.
1899
- if (activeSessions.get(sKey) === ds)
1900
- activeSessions.delete(sKey);
2018
+ const { current, botCfg, card } = closed;
1901
2019
  // The closed card carries session title / CLI name / workingDir / resume
1902
2020
  // command. In private-card mode those must not leak to the group — send the
1903
2021
  // closed card ephemeral to the same owner audience instead. No group
@@ -1906,15 +2024,15 @@ export async function handleCardAction(data, deps, larkAppId) {
1906
2024
  // clicked, so a card built in private mode stays ephemeral even if the
1907
2025
  // bot's `privateCard` config was turned off in the meantime.
1908
2026
  if (value?.visibility === 'private' || botCfg.privateCard) {
1909
- const audience = resolvePrivateCardAudience(ds);
2027
+ const audience = resolvePrivateCardAudience(current);
1910
2028
  for (const openId of audience) {
1911
- await sendEphemeralCard(ds.larkAppId, ds.chatId, openId, card).catch(err => logger.warn(`[${tag(ds)}] private close card ephemeral send to ${openId.substring(0, 8)}… failed: ${err}`));
2029
+ await sendEphemeralCard(current.larkAppId, current.chatId, openId, card).catch(err => logger.warn(`[${tag(current)}] private close card ephemeral send to ${openId.substring(0, 8)}… failed: ${err}`));
1912
2030
  }
1913
- logger.info(`[${tag(ds)}] Closed via card button (private close card → ${audience.length} owner(s))`);
2031
+ logger.info(`[${tag(current)}] Closed via card button (private close card → ${audience.length} owner(s))`);
1914
2032
  }
1915
2033
  else {
1916
- await deliverEphemeralOrReply(ds, operatorOpenId, card, 'interactive', () => sessionReply(rootId, card, 'interactive'));
1917
- logger.info(`[${tag(ds)}] Closed via card button`);
2034
+ await deliverEphemeralOrReply(current, operatorOpenId, card, 'interactive', () => sessionReply(rootId, card, 'interactive'));
2035
+ logger.info(`[${tag(current)}] Closed via card button`);
1918
2036
  }
1919
2037
  }
1920
2038
  if (actionType === 'resume') {
@@ -1955,10 +2073,17 @@ export async function handleCardAction(data, deps, larkAppId) {
1955
2073
  }
1956
2074
  }
1957
2075
  if (actionType === 'disconnect' && ds) {
1958
- killWorker(ds);
1959
- sessionStore.closeSession(ds.session.sessionId);
1960
- publishClosedSessionPatch(ds.session.sessionId, ds.session.closedAt ? Date.parse(ds.session.closedAt) : undefined);
1961
- activeSessions.delete(sKey);
2076
+ const targetSessionId = ds.session.sessionId;
2077
+ const disconnected = await withBotTurnMutation(ds.larkAppId, async () => {
2078
+ const current = [...activeSessions.values()].find(candidate => candidate.session.sessionId === targetSessionId);
2079
+ if (!current)
2080
+ return undefined;
2081
+ await closeWorkerPoolSession(targetSessionId);
2082
+ return current;
2083
+ });
2084
+ if (!disconnected) {
2085
+ return { toast: { type: 'warning', content: t('card.action.session_gone', undefined, localeForBot(larkAppId)) } };
2086
+ }
1962
2087
  await sessionReply(rootId, t('card.action.disconnected', undefined, localeForBot(ds.larkAppId)));
1963
2088
  logger.info(`[${tag(ds)}] Disconnected (adopt) via card button`);
1964
2089
  }
@@ -1981,6 +2106,36 @@ export async function handleCardAction(data, deps, larkAppId) {
1981
2106
  await sessionReply(rootId, t('card.action.retry_last_task_not_ready', { retryLabel: ds.usageLimit.retryLabel }, locDs));
1982
2107
  return;
1983
2108
  }
2109
+ const retryCodexAppInput = ds.lastCodexAppInput
2110
+ ? (({ clientUserMessageId: _priorMessageId, ...input }) => input)(ds.lastCodexAppInput)
2111
+ : undefined;
2112
+ const retryInput = {
2113
+ content: cliInput,
2114
+ ...(retryCodexAppInput ? { codexAppInput: retryCodexAppInput } : {}),
2115
+ };
2116
+ if ((!ds.worker || ds.worker.killed) && hasProtectedSessionMutationOwnership(ds)) {
2117
+ await sessionReply(rootId, t('card.action.retry_last_task_unavailable', undefined, locDs));
2118
+ return;
2119
+ }
2120
+ let accepted = false;
2121
+ try {
2122
+ if (ds.worker && !ds.worker.killed)
2123
+ accepted = sendWorkerInput(ds, retryInput);
2124
+ else {
2125
+ forkWorker(ds, retryInput, ds.hasHistory);
2126
+ accepted = true;
2127
+ }
2128
+ }
2129
+ catch (err) {
2130
+ logger.warn(`[${tag(ds)}] retry_last_task failed before acceptance: `
2131
+ + `${err instanceof Error ? err.message : String(err)}`);
2132
+ }
2133
+ if (!accepted) {
2134
+ await sessionReply(rootId, t('card.action.retry_last_task_unavailable', undefined, locDs));
2135
+ return;
2136
+ }
2137
+ // Only now consume the one-shot retry state and advertise working: live
2138
+ // IPC or the durable activation tail already owns the exact retry input.
1984
2139
  clearUsageLimitState(ds);
1985
2140
  ds.lastScreenStatus = 'working';
1986
2141
  ds.streamCardPending = true;
@@ -1993,17 +2148,6 @@ export async function handleCardAction(data, deps, larkAppId) {
1993
2148
  cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, ds.currentTurnTitle, '', 'working', sessionCliId(ds), ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, !!ds.adoptedFrom, false, locDs, undefined, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: sessionCliId(ds) }), getDaemonStreamingCardUsageSnapshot(ds, sessionCliId(ds)), sessionRuntimeDisplayName(ds), codexServiceTierBadge(sessionCliId(ds), ds.codexServiceTier));
1994
2149
  scheduleCardPatch(ds, cardJson);
1995
2150
  }
1996
- const retryCodexAppInput = ds.lastCodexAppInput
1997
- ? (({ clientUserMessageId: _priorMessageId, ...input }) => input)(ds.lastCodexAppInput)
1998
- : undefined;
1999
- const retryInput = {
2000
- content: cliInput,
2001
- ...(retryCodexAppInput ? { codexAppInput: retryCodexAppInput } : {}),
2002
- };
2003
- if (ds.worker && !ds.worker.killed)
2004
- sendWorkerInput(ds, retryInput);
2005
- else
2006
- forkWorker(ds, retryInput, ds.hasHistory);
2007
2151
  logger.info(`[${tag(ds)}] Retrying last task after usage limit`);
2008
2152
  if (cardJson) {
2009
2153
  try {
@@ -2527,21 +2671,24 @@ export async function handleCardAction(data, deps, larkAppId) {
2527
2671
  }
2528
2672
  if (ds.pendingRepo) {
2529
2673
  if (ds.worktreeCreating || ds.pendingRepoCommitInFlight) {
2530
- return { toast: { type: 'info', content: t('cmd.repo.worktree_in_progress', undefined, locDs) } };
2674
+ return {
2675
+ toast: {
2676
+ type: 'info',
2677
+ content: t('cmd.repo.worktree_in_progress', undefined, locDs),
2678
+ },
2679
+ };
2531
2680
  }
2532
2681
  const cwd = getSessionWorkingDir(ds);
2533
- // Reuse the same claimed pending->worker transition as a normal repo
2534
- // selection. This keeps buffering active across prompt preparation and
2535
- // makes skip/select races single-winner. Do NOT pin the resolved default
2536
- // cwd (often $HOME) onto session.workingDir — that would let sibling
2537
- // bots inherit HOME instead of getting their own repo card. Confirmation
2538
- // + card withdraw run under the claim inside commitRepoSelection.
2539
- await commitRepoSelection({ ds, rootId, cardMessageId, larkAppId, operatorOpenId, activeSessions, sessionReply }, cwd, pathBasename(cwd) || cwd, {
2682
+ // "Start directly" is the same pending-start commit as choosing a
2683
+ // directory, just pinned to the current/default cwd. Reusing the shared
2684
+ // helper gives this card path the bot mutation, exact-owner recheck,
2685
+ // buffered-sidecar handling, and fork-before-release ordering.
2686
+ const started = await commitRepoSelection({ ds, rootId, cardMessageId, larkAppId: larkAppId ?? ds.larkAppId, operatorOpenId, activeSessions, sessionReply }, cwd, cwd, {
2540
2687
  suppressConfirmReply: true,
2541
2688
  confirmReplyText: t('cmd.skip.opened', { cwd }, locDs),
2542
2689
  pinWorkingDir: false,
2543
2690
  });
2544
- if (!ds.pendingRepo) {
2691
+ if (started) {
2545
2692
  logger.info(`[${tag(ds)}] Skip repo, spawning CLI in ${cwd}`);
2546
2693
  }
2547
2694
  }
@@ -2583,10 +2730,10 @@ export async function handleCardAction(data, deps, larkAppId) {
2583
2730
  // re-send a fresh repo card in the new mode — a form can't ride an
2584
2731
  // in-place patch, so the old card is withdrawn and a new one posted.
2585
2732
  const locDs = localeForBot(ds.larkAppId);
2586
- // Same active-card gate as skip/manual/worktree: a stale card must not
2587
- // flip bot config or replace the live repo card after claim/restart.
2588
- if (cardMessageId && !isActiveRepoCard(ds, cardMessageId)) {
2589
- return { toast: { type: 'info', content: t('cmd.repo.card_already_consumed', undefined, locDs) } };
2733
+ if (!cardMessageId || !ds.repoCardMessageId || cardMessageId !== ds.repoCardMessageId) {
2734
+ logger.warn(`[${tag(ds)}] Ignoring stale worktree-toggle picker ${cardMessageId ?? 'none'} `
2735
+ + `(current=${ds.repoCardMessageId ?? 'none'})`);
2736
+ return { toast: { type: 'warning', content: t('card.repo.toast_stale_picker', undefined, locDs) } };
2590
2737
  }
2591
2738
  const spec = findConfigField('worktreeMultiPicker');
2592
2739
  if (!spec)
@@ -2596,22 +2743,44 @@ export async function handleCardAction(data, deps, larkAppId) {
2596
2743
  if (!r.ok)
2597
2744
  return { toast: { type: 'error', content: t('cmd.config.write_failed', { reason: r.reason }, locDs) } };
2598
2745
  const projects = lastRepoScan.get(ds.chatId) ?? [];
2599
- // await so a rejected delete is caught here (not an unhandled rejection);
2600
- // a missing/already-gone card is fine — we post the fresh one regardless.
2601
- if (ds.repoCardMessageId && ds.larkAppId) {
2602
- try {
2603
- await deleteMessage(ds.larkAppId, ds.repoCardMessageId);
2604
- }
2605
- catch { /* card already gone */ }
2606
- }
2607
2746
  const newCard = buildRepoSelectCard(projects, getSessionWorkingDir(ds), rootId, locDs, next);
2608
- ds.repoCardMessageId = await sessionReply(rootId, newCard, 'interactive');
2747
+ const oldCardMessageId = ds.repoCardMessageId;
2748
+ let newCardMessageId;
2749
+ try {
2750
+ newCardMessageId = await sessionReply(rootId, newCard, 'interactive');
2751
+ }
2752
+ catch (err) {
2753
+ logger.warn(`[${tag(ds)}] Failed to publish replacement repo picker; old card remains authoritative: `
2754
+ + `${err instanceof Error ? err.message : String(err)}`);
2755
+ return { toast: { type: 'error', content: t('cmd.config.write_failed', { reason: err instanceof Error ? err.message : String(err) }, locDs) } };
2756
+ }
2757
+ try {
2758
+ // Switch durable authority before changing runtime identity or
2759
+ // withdrawing the old card. A failed write leaves the old picker fully
2760
+ // usable and makes the newly-published card stale by exact-ID guard.
2761
+ persistPendingRepoCardMessageId(ds, newCardMessageId);
2762
+ }
2763
+ catch (err) {
2764
+ logger.error(`[${tag(ds)}] Failed to persist replacement repo picker ${newCardMessageId}; `
2765
+ + `old picker ${oldCardMessageId} retained: `
2766
+ + `${err instanceof Error ? err.message : String(err)}`);
2767
+ return { toast: { type: 'error', content: t('cmd.config.write_failed', { reason: err instanceof Error ? err.message : String(err) }, locDs) } };
2768
+ }
2769
+ ds.repoCardMessageId = newCardMessageId;
2770
+ // The fresh ID is now durable. Withdrawal is best-effort and cannot
2771
+ // invalidate the new authority if Lark reports the old card missing.
2772
+ try {
2773
+ await deleteMessage(ds.larkAppId, oldCardMessageId);
2774
+ }
2775
+ catch { /* card already gone */ }
2609
2776
  return { toast: { type: 'info', content: t(next ? 'card.repo.toast_worktree_mode_switched' : 'card.repo.toast_worktree_mode_switched_back', undefined, locDs) } };
2610
2777
  }
2611
2778
  if (actionType === 'repo_worktree_submit' && ds) {
2612
2779
  const locDs = localeForBot(ds.larkAppId);
2613
- if (cardMessageId && !isActiveRepoCard(ds, cardMessageId)) {
2614
- return { toast: { type: 'info', content: t('cmd.repo.card_already_consumed', undefined, locDs) } };
2780
+ if (!cardMessageId || !ds.repoCardMessageId || cardMessageId !== ds.repoCardMessageId) {
2781
+ logger.warn(`[${tag(ds)}] Ignoring stale worktree-submit picker ${cardMessageId ?? 'none'} `
2782
+ + `(current=${ds.repoCardMessageId ?? 'none'})`);
2783
+ return { toast: { type: 'warning', content: t('card.repo.toast_stale_picker', undefined, locDs) } };
2615
2784
  }
2616
2785
  const selectedPaths = stringListFromLarkMultiSelect(action?.form_value?.repo_worktree_paths);
2617
2786
  if (selectedPaths.length === 0) {
@@ -2741,11 +2910,20 @@ export async function handleCardAction(data, deps, larkAppId) {
2741
2910
  logger.warn(`Card action: no active session found for root ${rootId}`);
2742
2911
  return;
2743
2912
  }
2744
- // Only the live posted card may drive selection. Covers: already-consumed,
2745
- // wrong/old card, and post-restart (repoCardMessageId is in-memory). Must
2746
- // reject before mid-session switch can killWorker.
2747
- if (cardMessageId && !isActiveRepoCard(targetDs, cardMessageId)) {
2748
- return { toast: { type: 'info', content: t('cmd.repo.card_already_consumed', undefined, localeForBot(targetDs.larkAppId)) } };
2913
+ // The picker message id is the capability for every repo dropdown. Check it
2914
+ // before slug generation, worktreeCreating, git creation/push, or commit.
2915
+ // In particular the direct single-select `repo_worktree` callback does not
2916
+ // pass through the form-submit branch above, so relying on the later commit
2917
+ // check would allow a stale card to create an orphan worktree first.
2918
+ if (!cardMessageId || !targetDs.repoCardMessageId || cardMessageId !== targetDs.repoCardMessageId) {
2919
+ logger.warn(`[${tag(targetDs)}] Ignoring stale ${repoKey} picker ${cardMessageId ?? 'none'} `
2920
+ + `(current=${targetDs.repoCardMessageId ?? 'none'})`);
2921
+ return {
2922
+ toast: {
2923
+ type: 'warning',
2924
+ content: t('card.repo.toast_stale_picker', undefined, localeForBot(targetDs.larkAppId)),
2925
+ },
2926
+ };
2749
2927
  }
2750
2928
  // 权限边界:pendingRepo(首次选 repo 才能 spawn)放行「会话发起人 或 canOperate」,
2751
2929
  // 让本群授权用户能完成自己的首次使用;非 pending 的 mid-session 切换是管理动作,要 canOperate。
@@ -2757,6 +2935,14 @@ export async function handleCardAction(data, deps, larkAppId) {
2757
2935
  logger.info(`Repo card action blocked for ${operatorOpenId} (pending=${targetDs.pendingRepo})`);
2758
2936
  return { toast: { type: 'error', content: t('card.grant.toast_no_repo_perm', undefined, localeForBot(targetDs.larkAppId)) } };
2759
2937
  }
2938
+ // Reject a live Riff repo/worktree replacement before slug generation or
2939
+ // any local/remote Git side effect. First-spawn pendingRepo selections stay
2940
+ // recoverable when a synchronous fork failure has already stamped Riff.
2941
+ if (!targetDs.pendingRepo && isRiffBackendSession(targetDs)) {
2942
+ await sessionReply(rootId, t('cmd.cd.riff_unsupported', undefined, localeForBot(targetDs.larkAppId)));
2943
+ logger.warn(`[${tag(targetDs)}] Repo switch refused before Git work: Riff session requires explicit close before replacement`);
2944
+ return;
2945
+ }
2760
2946
  // Resolve the project name from cached scan
2761
2947
  const cached = lastRepoScan.get(targetDs.chatId);
2762
2948
  const project = cached?.find(p => p.path === selectedPath);