botmux 3.4.2 → 3.5.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 (378) hide show
  1. package/README.en.md +10 -7
  2. package/README.md +11 -6
  3. package/dist/adapters/backend/herdr-backend.d.ts +43 -6
  4. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  5. package/dist/adapters/backend/herdr-backend.js +362 -28
  6. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  7. package/dist/adapters/backend/reproduce-command.d.ts +39 -0
  8. package/dist/adapters/backend/reproduce-command.d.ts.map +1 -0
  9. package/dist/adapters/backend/reproduce-command.js +63 -0
  10. package/dist/adapters/backend/reproduce-command.js.map +1 -0
  11. package/dist/adapters/backend/session-backend-selector.d.ts +10 -1
  12. package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -1
  13. package/dist/adapters/backend/session-backend-selector.js +73 -4
  14. package/dist/adapters/backend/session-backend-selector.js.map +1 -1
  15. package/dist/adapters/backend/types.d.ts +16 -0
  16. package/dist/adapters/backend/types.d.ts.map +1 -1
  17. package/dist/adapters/backend/types.js.map +1 -1
  18. package/dist/adapters/cli/pi-initial-prompt-extension.d.ts +27 -0
  19. package/dist/adapters/cli/pi-initial-prompt-extension.d.ts.map +1 -0
  20. package/dist/adapters/cli/pi-initial-prompt-extension.js +38 -0
  21. package/dist/adapters/cli/pi-initial-prompt-extension.js.map +1 -0
  22. package/dist/adapters/cli/pi-initial-prompt.d.ts +21 -0
  23. package/dist/adapters/cli/pi-initial-prompt.d.ts.map +1 -0
  24. package/dist/adapters/cli/pi-initial-prompt.js +59 -0
  25. package/dist/adapters/cli/pi-initial-prompt.js.map +1 -0
  26. package/dist/adapters/cli/pi.d.ts.map +1 -1
  27. package/dist/adapters/cli/pi.js +15 -4
  28. package/dist/adapters/cli/pi.js.map +1 -1
  29. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  30. package/dist/adapters/cli/read-isolation.js +5 -0
  31. package/dist/adapters/cli/read-isolation.js.map +1 -1
  32. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  33. package/dist/adapters/cli/shared-hints.js +8 -0
  34. package/dist/adapters/cli/shared-hints.js.map +1 -1
  35. package/dist/adapters/cli/traex.d.ts.map +1 -1
  36. package/dist/adapters/cli/traex.js +5 -0
  37. package/dist/adapters/cli/traex.js.map +1 -1
  38. package/dist/adapters/cli/types.d.ts +23 -3
  39. package/dist/adapters/cli/types.d.ts.map +1 -1
  40. package/dist/adapters/hook-installer.d.ts +7 -0
  41. package/dist/adapters/hook-installer.d.ts.map +1 -1
  42. package/dist/adapters/hook-installer.js +56 -0
  43. package/dist/adapters/hook-installer.js.map +1 -1
  44. package/dist/bot-registry.d.ts +13 -0
  45. package/dist/bot-registry.d.ts.map +1 -1
  46. package/dist/bot-registry.js +118 -2
  47. package/dist/bot-registry.js.map +1 -1
  48. package/dist/cli/capabilities.d.ts +24 -0
  49. package/dist/cli/capabilities.d.ts.map +1 -0
  50. package/dist/cli/capabilities.js +25 -0
  51. package/dist/cli/capabilities.js.map +1 -0
  52. package/dist/cli/create-group-resolver.d.ts +24 -0
  53. package/dist/cli/create-group-resolver.d.ts.map +1 -1
  54. package/dist/cli/create-group-resolver.js +23 -0
  55. package/dist/cli/create-group-resolver.js.map +1 -1
  56. package/dist/cli/daemon-lifecycle-env.d.ts +5 -7
  57. package/dist/cli/daemon-lifecycle-env.d.ts.map +1 -1
  58. package/dist/cli/daemon-lifecycle-env.js +13 -7
  59. package/dist/cli/daemon-lifecycle-env.js.map +1 -1
  60. package/dist/cli/exact-chat-grant-client.d.ts +48 -0
  61. package/dist/cli/exact-chat-grant-client.d.ts.map +1 -0
  62. package/dist/cli/exact-chat-grant-client.js +108 -0
  63. package/dist/cli/exact-chat-grant-client.js.map +1 -0
  64. package/dist/cli/exact-chat-grant.d.ts +18 -0
  65. package/dist/cli/exact-chat-grant.d.ts.map +1 -0
  66. package/dist/cli/exact-chat-grant.js +97 -0
  67. package/dist/cli/exact-chat-grant.js.map +1 -0
  68. package/dist/cli.d.ts +10 -1
  69. package/dist/cli.d.ts.map +1 -1
  70. package/dist/cli.js +770 -126
  71. package/dist/cli.js.map +1 -1
  72. package/dist/codex-rpc-engine.d.ts +26 -0
  73. package/dist/codex-rpc-engine.d.ts.map +1 -1
  74. package/dist/codex-rpc-engine.js +113 -2
  75. package/dist/codex-rpc-engine.js.map +1 -1
  76. package/dist/config.d.ts +16 -0
  77. package/dist/config.d.ts.map +1 -1
  78. package/dist/config.js +16 -0
  79. package/dist/config.js.map +1 -1
  80. package/dist/core/bot-live-control.d.ts +41 -0
  81. package/dist/core/bot-live-control.d.ts.map +1 -0
  82. package/dist/core/bot-live-control.js +135 -0
  83. package/dist/core/bot-live-control.js.map +1 -0
  84. package/dist/core/command-handler.d.ts.map +1 -1
  85. package/dist/core/command-handler.js +28 -8
  86. package/dist/core/command-handler.js.map +1 -1
  87. package/dist/core/daemon-config-fence.d.ts +8 -0
  88. package/dist/core/daemon-config-fence.d.ts.map +1 -0
  89. package/dist/core/daemon-config-fence.js +13 -0
  90. package/dist/core/daemon-config-fence.js.map +1 -0
  91. package/dist/core/dashboard-events.d.ts +5 -0
  92. package/dist/core/dashboard-events.d.ts.map +1 -1
  93. package/dist/core/dashboard-events.js.map +1 -1
  94. package/dist/core/dashboard-ipc-server.d.ts +21 -2
  95. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  96. package/dist/core/dashboard-ipc-server.js +232 -5
  97. package/dist/core/dashboard-ipc-server.js.map +1 -1
  98. package/dist/core/dispatch-registry.d.ts +12 -0
  99. package/dist/core/dispatch-registry.d.ts.map +1 -0
  100. package/dist/core/dispatch-registry.js +33 -0
  101. package/dist/core/dispatch-registry.js.map +1 -0
  102. package/dist/core/dispatch.d.ts +99 -0
  103. package/dist/core/dispatch.d.ts.map +1 -1
  104. package/dist/core/dispatch.js +136 -0
  105. package/dist/core/dispatch.js.map +1 -1
  106. package/dist/core/inflight-input-tracker.d.ts +1 -0
  107. package/dist/core/inflight-input-tracker.d.ts.map +1 -1
  108. package/dist/core/inflight-input-tracker.js.map +1 -1
  109. package/dist/core/maintenance.d.ts.map +1 -1
  110. package/dist/core/maintenance.js +10 -3
  111. package/dist/core/maintenance.js.map +1 -1
  112. package/dist/core/persistent-backend.d.ts +16 -2
  113. package/dist/core/persistent-backend.d.ts.map +1 -1
  114. package/dist/core/persistent-backend.js +40 -0
  115. package/dist/core/persistent-backend.js.map +1 -1
  116. package/dist/core/session-discovery.d.ts +8 -1
  117. package/dist/core/session-discovery.d.ts.map +1 -1
  118. package/dist/core/session-discovery.js +140 -8
  119. package/dist/core/session-discovery.js.map +1 -1
  120. package/dist/core/session-manager.d.ts.map +1 -1
  121. package/dist/core/session-manager.js +12 -8
  122. package/dist/core/session-manager.js.map +1 -1
  123. package/dist/core/session-title.d.ts +8 -0
  124. package/dist/core/session-title.d.ts.map +1 -1
  125. package/dist/core/session-title.js +54 -0
  126. package/dist/core/session-title.js.map +1 -1
  127. package/dist/core/terminal-write-auth.d.ts +15 -0
  128. package/dist/core/terminal-write-auth.d.ts.map +1 -1
  129. package/dist/core/terminal-write-auth.js +20 -0
  130. package/dist/core/terminal-write-auth.js.map +1 -1
  131. package/dist/core/trigger-session.d.ts.map +1 -1
  132. package/dist/core/trigger-session.js +3 -2
  133. package/dist/core/trigger-session.js.map +1 -1
  134. package/dist/core/types.d.ts +33 -0
  135. package/dist/core/types.d.ts.map +1 -1
  136. package/dist/core/types.js.map +1 -1
  137. package/dist/core/worker-pool.d.ts +27 -8
  138. package/dist/core/worker-pool.d.ts.map +1 -1
  139. package/dist/core/worker-pool.js +389 -19
  140. package/dist/core/worker-pool.js.map +1 -1
  141. package/dist/daemon.d.ts +4 -2
  142. package/dist/daemon.d.ts.map +1 -1
  143. package/dist/daemon.js +397 -26
  144. package/dist/daemon.js.map +1 -1
  145. package/dist/dashboard/bot-onboarding.d.ts +127 -1
  146. package/dist/dashboard/bot-onboarding.d.ts.map +1 -1
  147. package/dist/dashboard/bot-onboarding.js +1104 -25
  148. package/dist/dashboard/bot-onboarding.js.map +1 -1
  149. package/dist/dashboard/debug-terminal.d.ts +18 -0
  150. package/dist/dashboard/debug-terminal.d.ts.map +1 -0
  151. package/dist/dashboard/debug-terminal.js +338 -0
  152. package/dist/dashboard/debug-terminal.js.map +1 -0
  153. package/dist/dashboard/public-redact.d.ts +2 -0
  154. package/dist/dashboard/public-redact.d.ts.map +1 -1
  155. package/dist/dashboard/public-redact.js +12 -3
  156. package/dist/dashboard/public-redact.js.map +1 -1
  157. package/dist/dashboard/registry.d.ts +8 -0
  158. package/dist/dashboard/registry.d.ts.map +1 -1
  159. package/dist/dashboard/registry.js +13 -0
  160. package/dist/dashboard/registry.js.map +1 -1
  161. package/dist/dashboard/settings-write-applier.d.ts +47 -1
  162. package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
  163. package/dist/dashboard/settings-write-applier.js +102 -1
  164. package/dist/dashboard/settings-write-applier.js.map +1 -1
  165. package/dist/dashboard/web/app.d.ts.map +1 -1
  166. package/dist/dashboard/web/app.js +2 -0
  167. package/dist/dashboard/web/app.js.map +1 -1
  168. package/dist/dashboard/web/bot-defaults-page.d.ts +1 -0
  169. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  170. package/dist/dashboard/web/bot-defaults-page.js +24 -5
  171. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  172. package/dist/dashboard/web/bot-defaults.d.ts +15 -0
  173. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  174. package/dist/dashboard/web/bot-defaults.js +19 -0
  175. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  176. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  177. package/dist/dashboard/web/i18n.js +76 -6
  178. package/dist/dashboard/web/i18n.js.map +1 -1
  179. package/dist/dashboard/web/icon-tooltip.d.ts +3 -0
  180. package/dist/dashboard/web/icon-tooltip.d.ts.map +1 -0
  181. package/dist/dashboard/web/icon-tooltip.js +108 -0
  182. package/dist/dashboard/web/icon-tooltip.js.map +1 -0
  183. package/dist/dashboard/web/sessions-kanban.js +2 -2
  184. package/dist/dashboard/web/sessions-kanban.js.map +1 -1
  185. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  186. package/dist/dashboard/web/sessions-page.js +48 -9
  187. package/dist/dashboard/web/sessions-page.js.map +1 -1
  188. package/dist/dashboard/web/sessions.d.ts +2 -0
  189. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  190. package/dist/dashboard/web/sessions.js +46 -2
  191. package/dist/dashboard/web/sessions.js.map +1 -1
  192. package/dist/dashboard/web/settings-page.d.ts +78 -0
  193. package/dist/dashboard/web/settings-page.d.ts.map +1 -1
  194. package/dist/dashboard/web/settings-page.js +88 -1
  195. package/dist/dashboard/web/settings-page.js.map +1 -1
  196. package/dist/dashboard/web/store.d.ts +2 -0
  197. package/dist/dashboard/web/store.d.ts.map +1 -1
  198. package/dist/dashboard/web/store.js +13 -1
  199. package/dist/dashboard/web/store.js.map +1 -1
  200. package/dist/dashboard-web/app.js +1 -1
  201. package/dist/dashboard-web/chunks/bot-defaults-page-O376WSER.js +5 -0
  202. package/dist/dashboard-web/chunks/{chunk-JNRSPKLS.js → chunk-EJDYUINZ.js} +1 -1
  203. package/dist/dashboard-web/chunks/{chunk-CUV65BIQ.js → chunk-H7P5UV4W.js} +6 -6
  204. package/dist/dashboard-web/chunks/{chunk-P6GO5KT4.js → chunk-QCPPKCX3.js} +1 -1
  205. package/dist/dashboard-web/chunks/{chunk-UBOLHQCK.js → chunk-WXPMNEYV.js} +1 -1
  206. package/dist/dashboard-web/chunks/{connectors-page-KTC2QWNW.js → connectors-page-IR54AOAI.js} +1 -1
  207. package/dist/dashboard-web/chunks/{groups-page-B3EKGOLY.js → groups-page-3P4TLUMF.js} +1 -1
  208. package/dist/dashboard-web/chunks/{insights-page-X6RXNCWX.js → insights-page-TDCZV56A.js} +1 -1
  209. package/dist/dashboard-web/chunks/{monitor-room-LLRA5LQW.js → monitor-room-QWCWL3SX.js} +1 -1
  210. package/dist/dashboard-web/chunks/{monitoring-page-7MT5FEOS.js → monitoring-page-IOWFSNVC.js} +1 -1
  211. package/dist/dashboard-web/chunks/{office-page-KD46D5TD.js → office-page-5AUPEHZP.js} +1 -1
  212. package/dist/dashboard-web/chunks/{overview-page-JUWNH32O.js → overview-page-QJMI6NLW.js} +1 -1
  213. package/dist/dashboard-web/chunks/{roles-page-P6F7NLOI.js → roles-page-WJVB52ET.js} +1 -1
  214. package/dist/dashboard-web/chunks/{schedules-page-XIHWZST4.js → schedules-page-RVRWHP4V.js} +1 -1
  215. package/dist/dashboard-web/chunks/sessions-page-KWLGBX6G.js +3 -0
  216. package/dist/dashboard-web/chunks/settings-page-TNZEPC7V.js +2 -0
  217. package/dist/dashboard-web/chunks/{skills-page-A46KVWBL.js → skills-page-7YT5TIX5.js} +1 -1
  218. package/dist/dashboard-web/chunks/{team-federation-page-SDUC56DF.js → team-federation-page-NJZKTU7A.js} +1 -1
  219. package/dist/dashboard-web/chunks/{v3-page-J5JOTUSQ.js → v3-page-NQ73UA2Q.js} +1 -1
  220. package/dist/dashboard-web/chunks/{whiteboards-page-U7LSOAWI.js → whiteboards-page-DSM3MDMN.js} +1 -1
  221. package/dist/dashboard-web/style.css +198 -7
  222. package/dist/dashboard.js +294 -7
  223. package/dist/dashboard.js.map +1 -1
  224. package/dist/features/codex-notifier/adoption.d.ts +11 -0
  225. package/dist/features/codex-notifier/adoption.d.ts.map +1 -0
  226. package/dist/features/codex-notifier/adoption.js +11 -0
  227. package/dist/features/codex-notifier/adoption.js.map +1 -0
  228. package/dist/features/codex-notifier/app-opener.d.ts +21 -0
  229. package/dist/features/codex-notifier/app-opener.d.ts.map +1 -0
  230. package/dist/features/codex-notifier/app-opener.js +33 -0
  231. package/dist/features/codex-notifier/app-opener.js.map +1 -0
  232. package/dist/features/codex-notifier/card-action.d.ts +27 -0
  233. package/dist/features/codex-notifier/card-action.d.ts.map +1 -0
  234. package/dist/features/codex-notifier/card-action.js +84 -0
  235. package/dist/features/codex-notifier/card-action.js.map +1 -0
  236. package/dist/features/codex-notifier/card.d.ts +12 -0
  237. package/dist/features/codex-notifier/card.d.ts.map +1 -0
  238. package/dist/features/codex-notifier/card.js +150 -0
  239. package/dist/features/codex-notifier/card.js.map +1 -0
  240. package/dist/features/codex-notifier/codex-context.d.ts +12 -0
  241. package/dist/features/codex-notifier/codex-context.d.ts.map +1 -0
  242. package/dist/features/codex-notifier/codex-context.js +161 -0
  243. package/dist/features/codex-notifier/codex-context.js.map +1 -0
  244. package/dist/features/codex-notifier/config.d.ts +9 -0
  245. package/dist/features/codex-notifier/config.d.ts.map +1 -0
  246. package/dist/features/codex-notifier/config.js +11 -0
  247. package/dist/features/codex-notifier/config.js.map +1 -0
  248. package/dist/features/codex-notifier/confirmed-turn.d.ts +20 -0
  249. package/dist/features/codex-notifier/confirmed-turn.d.ts.map +1 -0
  250. package/dist/features/codex-notifier/confirmed-turn.js +130 -0
  251. package/dist/features/codex-notifier/confirmed-turn.js.map +1 -0
  252. package/dist/features/codex-notifier/emitter.d.ts +13 -0
  253. package/dist/features/codex-notifier/emitter.d.ts.map +1 -0
  254. package/dist/features/codex-notifier/emitter.js +33 -0
  255. package/dist/features/codex-notifier/emitter.js.map +1 -0
  256. package/dist/features/codex-notifier/event-store.d.ts +29 -0
  257. package/dist/features/codex-notifier/event-store.d.ts.map +1 -0
  258. package/dist/features/codex-notifier/event-store.js +217 -0
  259. package/dist/features/codex-notifier/event-store.js.map +1 -0
  260. package/dist/features/codex-notifier/event.d.ts +46 -0
  261. package/dist/features/codex-notifier/event.d.ts.map +1 -0
  262. package/dist/features/codex-notifier/event.js +188 -0
  263. package/dist/features/codex-notifier/event.js.map +1 -0
  264. package/dist/features/codex-notifier/hook-cli.d.ts +23 -0
  265. package/dist/features/codex-notifier/hook-cli.d.ts.map +1 -0
  266. package/dist/features/codex-notifier/hook-cli.js +130 -0
  267. package/dist/features/codex-notifier/hook-cli.js.map +1 -0
  268. package/dist/features/codex-notifier/hook-installer.d.ts +19 -0
  269. package/dist/features/codex-notifier/hook-installer.d.ts.map +1 -0
  270. package/dist/features/codex-notifier/hook-installer.js +158 -0
  271. package/dist/features/codex-notifier/hook-installer.js.map +1 -0
  272. package/dist/features/codex-notifier/index.d.ts +21 -0
  273. package/dist/features/codex-notifier/index.d.ts.map +1 -0
  274. package/dist/features/codex-notifier/index.js +19 -0
  275. package/dist/features/codex-notifier/index.js.map +1 -0
  276. package/dist/features/codex-notifier/internal-turn.d.ts +5 -0
  277. package/dist/features/codex-notifier/internal-turn.d.ts.map +1 -0
  278. package/dist/features/codex-notifier/internal-turn.js +43 -0
  279. package/dist/features/codex-notifier/internal-turn.js.map +1 -0
  280. package/dist/features/codex-notifier/outbox-worker.d.ts +71 -0
  281. package/dist/features/codex-notifier/outbox-worker.d.ts.map +1 -0
  282. package/dist/features/codex-notifier/outbox-worker.js +290 -0
  283. package/dist/features/codex-notifier/outbox-worker.js.map +1 -0
  284. package/dist/features/codex-notifier/outbox.d.ts +25 -0
  285. package/dist/features/codex-notifier/outbox.d.ts.map +1 -0
  286. package/dist/features/codex-notifier/outbox.js +225 -0
  287. package/dist/features/codex-notifier/outbox.js.map +1 -0
  288. package/dist/features/codex-notifier/paths.d.ts +7 -0
  289. package/dist/features/codex-notifier/paths.d.ts.map +1 -0
  290. package/dist/features/codex-notifier/paths.js +20 -0
  291. package/dist/features/codex-notifier/paths.js.map +1 -0
  292. package/dist/features/codex-notifier/screen-lock.d.ts +12 -0
  293. package/dist/features/codex-notifier/screen-lock.d.ts.map +1 -0
  294. package/dist/features/codex-notifier/screen-lock.js +47 -0
  295. package/dist/features/codex-notifier/screen-lock.js.map +1 -0
  296. package/dist/features/codex-notifier/side-conversation-monitor.d.ts +102 -0
  297. package/dist/features/codex-notifier/side-conversation-monitor.d.ts.map +1 -0
  298. package/dist/features/codex-notifier/side-conversation-monitor.js +774 -0
  299. package/dist/features/codex-notifier/side-conversation-monitor.js.map +1 -0
  300. package/dist/features/codex-notifier/types.d.ts +42 -0
  301. package/dist/features/codex-notifier/types.d.ts.map +1 -0
  302. package/dist/features/codex-notifier/types.js +2 -0
  303. package/dist/features/codex-notifier/types.js.map +1 -0
  304. package/dist/features/codex-notifier/worker-lock.d.ts +8 -0
  305. package/dist/features/codex-notifier/worker-lock.d.ts.map +1 -0
  306. package/dist/features/codex-notifier/worker-lock.js +108 -0
  307. package/dist/features/codex-notifier/worker-lock.js.map +1 -0
  308. package/dist/global-config.d.ts +13 -0
  309. package/dist/global-config.d.ts.map +1 -1
  310. package/dist/global-config.js +30 -0
  311. package/dist/global-config.js.map +1 -1
  312. package/dist/im/lark/card-builder.js +2 -2
  313. package/dist/im/lark/card-builder.js.map +1 -1
  314. package/dist/im/lark/card-handler.d.ts +2 -0
  315. package/dist/im/lark/card-handler.d.ts.map +1 -1
  316. package/dist/im/lark/card-handler.js +165 -34
  317. package/dist/im/lark/card-handler.js.map +1 -1
  318. package/dist/im/lark/client.d.ts +62 -4
  319. package/dist/im/lark/client.d.ts.map +1 -1
  320. package/dist/im/lark/client.js +237 -29
  321. package/dist/im/lark/client.js.map +1 -1
  322. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  323. package/dist/im/lark/event-dispatcher.js +20 -30
  324. package/dist/im/lark/event-dispatcher.js.map +1 -1
  325. package/dist/platform/secure-host-file.d.ts.map +1 -1
  326. package/dist/platform/secure-host-file.js +173 -54
  327. package/dist/platform/secure-host-file.js.map +1 -1
  328. package/dist/services/codex-app-threads.d.ts +47 -0
  329. package/dist/services/codex-app-threads.d.ts.map +1 -1
  330. package/dist/services/codex-app-threads.js +502 -10
  331. package/dist/services/codex-app-threads.js.map +1 -1
  332. package/dist/services/exact-chat-grant.d.ts +83 -0
  333. package/dist/services/exact-chat-grant.d.ts.map +1 -0
  334. package/dist/services/exact-chat-grant.js +249 -0
  335. package/dist/services/exact-chat-grant.js.map +1 -0
  336. package/dist/services/group-creator.d.ts +11 -0
  337. package/dist/services/group-creator.d.ts.map +1 -1
  338. package/dist/services/group-creator.js +18 -9
  339. package/dist/services/group-creator.js.map +1 -1
  340. package/dist/services/local-cli-opener.d.ts.map +1 -1
  341. package/dist/services/local-cli-opener.js +10 -5
  342. package/dist/services/local-cli-opener.js.map +1 -1
  343. package/dist/services/traex-user-input.d.ts +19 -0
  344. package/dist/services/traex-user-input.d.ts.map +1 -0
  345. package/dist/services/traex-user-input.js +57 -0
  346. package/dist/services/traex-user-input.js.map +1 -0
  347. package/dist/setup/open-platform-automation.d.ts +24 -3
  348. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  349. package/dist/setup/open-platform-automation.js +45 -8
  350. package/dist/setup/open-platform-automation.js.map +1 -1
  351. package/dist/setup/verify-permissions.d.ts +18 -0
  352. package/dist/setup/verify-permissions.d.ts.map +1 -1
  353. package/dist/setup/verify-permissions.js +60 -0
  354. package/dist/setup/verify-permissions.js.map +1 -1
  355. package/dist/types.d.ts +70 -4
  356. package/dist/types.d.ts.map +1 -1
  357. package/dist/utils/child-env.d.ts +1 -1
  358. package/dist/utils/child-env.d.ts.map +1 -1
  359. package/dist/utils/child-env.js +2 -0
  360. package/dist/utils/child-env.js.map +1 -1
  361. package/dist/utils/pending-input-queue.d.ts +19 -0
  362. package/dist/utils/pending-input-queue.d.ts.map +1 -1
  363. package/dist/utils/pending-input-queue.js +21 -1
  364. package/dist/utils/pending-input-queue.js.map +1 -1
  365. package/dist/utils/stuck-detector.d.ts +45 -0
  366. package/dist/utils/stuck-detector.d.ts.map +1 -0
  367. package/dist/utils/stuck-detector.js +188 -0
  368. package/dist/utils/stuck-detector.js.map +1 -0
  369. package/dist/utils/stuck-key-guard.d.ts +75 -0
  370. package/dist/utils/stuck-key-guard.d.ts.map +1 -0
  371. package/dist/utils/stuck-key-guard.js +77 -0
  372. package/dist/utils/stuck-key-guard.js.map +1 -0
  373. package/dist/worker.js +807 -117
  374. package/dist/worker.js.map +1 -1
  375. package/package.json +1 -1
  376. package/dist/dashboard-web/chunks/bot-defaults-page-B5AY4B2J.js +0 -5
  377. package/dist/dashboard-web/chunks/sessions-page-2VECQ27T.js +0 -3
  378. package/dist/dashboard-web/chunks/settings-page-HMIUE2JA.js +0 -2
package/dist/worker.js CHANGED
@@ -13,13 +13,13 @@
13
13
  * 7. On 'restart', kills CLI and re-spawns with --resume
14
14
  */
15
15
  import { randomBytes } from 'node:crypto';
16
- import { mkdirSync, writeFileSync, unlinkSync, existsSync, statSync, lstatSync, readdirSync, readlinkSync, readFileSync, realpathSync, copyFileSync, watch as fsWatch, createWriteStream, openSync, closeSync, fstatSync, constants as fsConstants } from 'node:fs';
16
+ import { mkdirSync, writeFileSync, unlinkSync, rmdirSync, existsSync, statSync, lstatSync, readdirSync, readlinkSync, readFileSync, realpathSync, copyFileSync, watch as fsWatch, createWriteStream, openSync, closeSync, fstatSync, constants as fsConstants } from 'node:fs';
17
17
  import { atomicWriteFileSync } from './utils/atomic-write.js';
18
- import { join, basename, dirname } from 'node:path';
18
+ import { join, basename, dirname, delimiter } from 'node:path';
19
19
  import { homedir, tmpdir } from 'node:os';
20
20
  import { spawnSync } from 'node:child_process';
21
21
  import { evaluateReadIsolationGate, evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, buildV2DenyPaths, buildV2DenyRegexes, buildV2CarveOuts, buildReadIsolationProtectedWriteRules, buildCliExecutableReadCarveOuts, buildWriteSandboxRules, buildLinuxReadIsolationMasks, isolationPaneMarkerContent, } from './adapters/cli/read-isolation.js';
22
- import { killPersistentSession } from './core/persistent-backend.js';
22
+ import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget } from './core/persistent-backend.js';
23
23
  import { readProcessStartIdentity } from './core/session-marker.js';
24
24
  import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff } from './services/claude-transcript.js';
25
25
  import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
@@ -29,7 +29,7 @@ import { canStartInjectionFlush, shouldDeferUserFlush, shouldFlushInjectionsFirs
29
29
  import { stripAnsiForLog, tailChars } from './utils/crash-log.js';
30
30
  import { CodexUpdateDialogGuard } from './utils/codex-update-dialog.js';
31
31
  import { installStdioEpipeGuard, isIgnorableStreamError } from './utils/stdio-epipe-guard.js';
32
- import { mergeQueuedCliInput, pendingInputMayFlush, pendingInputAllowsTypeAhead, shouldDeferArgsBakedDurablePrompt, shouldDeferInitialPromptForArgLimit, shouldStopPendingBatch, terminalReleasesDurableTurn, } from './utils/pending-input-queue.js';
32
+ import { mergeQueuedCliInput, pendingInputMayFlush, pendingInputAllowsTypeAhead, resolveInitialPromptDelivery, shouldDeferArgsBakedDurablePrompt, shouldDeferInitialPromptForArgLimit, shouldStopPendingBatch, terminalReleasesDurableTurn, } from './utils/pending-input-queue.js';
33
33
  import { ReadyGate, shouldArmReadyGate } from './utils/ready-gate.js';
34
34
  import { shouldRunStartupCommandsOnSpawn, shouldDeferInitialPromptForStartup } from './core/startup-commands.js';
35
35
  import { sanitizePerBotEnv } from './core/per-bot-env.js';
@@ -42,14 +42,17 @@ import { readSessionMcpRuntimeManifest, sessionMcpRuntimeHostOnlyPaths, } from '
42
42
  import { prepareCliPluginGeneration } from './core/plugins/cli-generation.js';
43
43
  import { loadBotConfigs } from './bot-registry.js';
44
44
  import { readGlobalConfig } from './global-config.js';
45
- import { deriveTerminalViewToken, resolveTerminalAccessForRequest, safeTerminalTokenEqual, } from './core/terminal-write-auth.js';
45
+ import { deriveTerminalViewToken, deriveTerminalWriteToken, resolveTerminalAccessForRequest, safeTerminalTokenEqual, } from './core/terminal-write-auth.js';
46
46
  import { readPlatformBinding } from './platform/binding.js';
47
47
  import { loadDashboardSecret, loadPersistedToken } from './dashboard/auth.js';
48
48
  import { InflightInputTracker } from './core/inflight-input-tracker.js';
49
49
  import { shouldRunQuietRotation, evaluatePidResolverPullback, decideFingerprintSwitch, shouldHealAbsentBaseline, sessionIdFromJsonlPath, SESSION_ID_FILENAME_RE, } from './services/bridge-rotation-policy.js';
50
50
  import { CodexBridgeQueue } from './services/codex-bridge-queue.js';
51
+ import { generateCodexAppThreadTitle, readCodexAppThreadMetadata, setCodexAppThreadName, } from './services/codex-app-threads.js';
52
+ import { buildBotmuxLarkNativeSessionTitle } from './core/session-title.js';
51
53
  import { drainCodexRollout, findCodexRolloutBySessionId, findCodexRolloutByPid, splitCodexEventsByCutoff, extractLastCodexTurn, codexSessionIdFromRolloutPath } from './services/codex-transcript.js';
52
54
  import { drainTraexRollout, findTraexRolloutBySessionId, findTraexRolloutByPid } from './services/traex-transcript.js';
55
+ import { parseTraexUserInputQuestions } from './services/traex-user-input.js';
53
56
  import { cocoEventsPathForSession, drainCocoEvents, findCocoSessionByPid } from './services/coco-transcript.js';
54
57
  import { currentHermesStateOffset, drainHermesStateDb, resolveHermesStateDbPath } from './services/hermes-transcript.js';
55
58
  import { filterHermesEventsForBotmuxSession } from './services/hermes-session-filter.js';
@@ -87,6 +90,7 @@ import { ZellijObserveBackend } from './adapters/backend/zellij-observe-backend.
87
90
  import { zellijEnv } from './setup/ensure-zellij.js';
88
91
  import { isObserveBackend } from './adapters/backend/types.js';
89
92
  import { selectSessionBackend, decideBackendGate, backendGateUserMessage } from './adapters/backend/session-backend-selector.js';
93
+ import { buildReproduceCommand, selectReproduceLaunch } from './adapters/backend/reproduce-command.js';
90
94
  import { deriveRiffReposFromDirs, deriveRiffRepoFromWorkingDir, isValidRiffBaseUrl, isValidRiffSandboxCluster, } from './adapters/backend/riff-backend.js';
91
95
  import { prepareSandbox, prepareCredentialOnlySandbox, credentialOnlySandboxAvailable, probeHostCredentialIsolationMechanism, attachSandboxOutbox, startOutboxWatcher, sandboxEnabled, sandboxedClaudeDataDir, localSandboxApplies, } from './adapters/backend/sandbox.js';
92
96
  import { DEVICE_AUTHORITY_DIRECTORY, DEVICE_CREDENTIAL_FILE, } from './platform/device-paths.js';
@@ -94,6 +98,8 @@ import { tmuxEnv, probeTmuxFunctionalWithRetry } from './setup/ensure-tmux.js';
94
98
  import { tmuxRestartJitterMs } from './core/tmux-recovery.js';
95
99
  import { IdleDetector } from './utils/idle-detector.js';
96
100
  import { ScreenAnalyzer } from './utils/screen-analyzer.js';
101
+ import { StuckDetector, matchHookReviewScreen } from './utils/stuck-detector.js';
102
+ import { processStuckWarningTuiKeys, shouldRearmStuckDetector } from './utils/stuck-key-guard.js';
97
103
  import { captureToPng } from './utils/screenshot-renderer.js';
98
104
  import { snapshotToPng, snapshotToText, shouldCaptureScreen, isScreenSelfDriven } from './utils/transient-snapshot.js';
99
105
  import { chooseWebTerminalSeed } from './utils/web-terminal-seed.js';
@@ -109,7 +115,8 @@ import * as pty from 'node-pty';
109
115
  import { createHash } from 'node:crypto';
110
116
  import { hasInstalledSessionReadyHook, installHook, } from './adapters/hook-installer.js';
111
117
  import { hookCommandFor } from './adapters/hook-command.js';
112
- import { parseDaemonIpcPort } from './utils/daemon-discovery.js';
118
+ import { findOnlineDaemon, parseDaemonIpcPort } from './utils/daemon-discovery.js';
119
+ import { fetchDaemonIpc } from './core/daemon-ipc-auth.js';
113
120
  import { withCodexAppContext } from './utils/codex-app-context.js';
114
121
  import { resolveCodexAppFinalTurnIdentity } from './adapters/cli/codex-app-turn.js';
115
122
  import { RunnerControlDecoder } from './adapters/cli/runner-control-channel.js';
@@ -144,6 +151,36 @@ let remoteWsUrl;
144
151
  let remoteThreadId;
145
152
  let rpcDialogDismissTimer = null;
146
153
  let rpcEnginePidMarker = null;
154
+ const piInitialPromptCleanupPaths = [];
155
+ const piInitialPromptCleanupDirs = [];
156
+ let piInitialPromptReadonlyRoots = [];
157
+ let piInitialPromptAdditionalArgs = [];
158
+ let piInitialPromptEnv = {};
159
+ function cleanupPiInitialPromptFiles() {
160
+ while (piInitialPromptCleanupPaths.length > 0) {
161
+ const p = piInitialPromptCleanupPaths.pop();
162
+ if (!p)
163
+ continue;
164
+ try {
165
+ unlinkSync(p);
166
+ }
167
+ catch { /* best effort */ }
168
+ }
169
+ while (piInitialPromptCleanupDirs.length > 0) {
170
+ const dir = piInitialPromptCleanupDirs.pop();
171
+ if (!dir)
172
+ continue;
173
+ // Non-recursive on purpose: remove only an empty session-owned directory,
174
+ // never a shared root or a path that unexpectedly gained other content.
175
+ try {
176
+ rmdirSync(dir);
177
+ }
178
+ catch { /* best effort */ }
179
+ }
180
+ piInitialPromptReadonlyRoots = [];
181
+ piInitialPromptAdditionalArgs = [];
182
+ piInitialPromptEnv = {};
183
+ }
147
184
  function stopCodexRpcEngine() {
148
185
  const engine = codexRpcEngine;
149
186
  codexRpcEngine = undefined;
@@ -220,6 +257,238 @@ async function codexRolloutProbe(cliId, threadId, promptText, timeoutMs) {
220
257
  } while (Date.now() < deadline);
221
258
  return false;
222
259
  }
260
+ function codexNativeTitleEnv(cfg) {
261
+ const env = {
262
+ ...redactChildEnv(process.env),
263
+ ...sanitizePerBotEnv(cfg.env),
264
+ };
265
+ env.PATH = `${join(homedir(), '.botmux', 'bin')}${delimiter}${env.PATH ?? ''}`;
266
+ return env;
267
+ }
268
+ function registerNativeTitleForceClose(forceClose) {
269
+ nativeSessionTitleSyncForceClosers.add(forceClose);
270
+ return () => nativeSessionTitleSyncForceClosers.delete(forceClose);
271
+ }
272
+ async function applyCodexNativeSessionTitle(threadId, title, cfg, engine, wait, expectedRevision) {
273
+ if (expectedRevision !== nativeSessionTitleRevision)
274
+ return false;
275
+ if (engine?.activeThreadId === threadId) {
276
+ if (wait === 'resume') {
277
+ await engine.waitForThreadUpdatedAfter(nativeSessionTitleResumeUpdatedAt ?? Math.floor(Date.now() / 1000), 10_000);
278
+ }
279
+ else if (wait === 'preview') {
280
+ await engine.waitForThreadPreview(10_000);
281
+ }
282
+ if (expectedRevision !== nativeSessionTitleRevision)
283
+ return false;
284
+ await engine.setThreadName(title);
285
+ return true;
286
+ }
287
+ const adapter = createCliAdapterSync('codex', cfg.cliPathOverride);
288
+ const abortController = new AbortController();
289
+ nativeSessionTitleSyncAbortControllers.add(abortController);
290
+ try {
291
+ await setCodexAppThreadName({
292
+ threadId,
293
+ name: title,
294
+ codexBin: adapter.resolvedBin,
295
+ cwd: cfg.workingDir,
296
+ env: codexNativeTitleEnv(cfg),
297
+ timeoutMs: 10_000,
298
+ signal: abortController.signal,
299
+ detached: true,
300
+ waitForExistingPreview: wait === 'preview',
301
+ ...(wait === 'resume' ? {
302
+ waitForUpdatedAfter: nativeSessionTitleResumeUpdatedAt ?? Math.floor(Date.now() / 1000),
303
+ } : {}),
304
+ registerForceClose: registerNativeTitleForceClose,
305
+ });
306
+ return expectedRevision === nativeSessionTitleRevision;
307
+ }
308
+ finally {
309
+ nativeSessionTitleSyncAbortControllers.delete(abortController);
310
+ }
311
+ }
312
+ /** 首条 UserMessage 落盘后先写 fallback,再用隔离的临时 turn 生成语义标题。 */
313
+ async function syncFreshCodexNativeSessionTitle(threadId, engine) {
314
+ const cfg = lastInitConfig;
315
+ const fallbackTitle = cfg?.nativeSessionTitle?.trim();
316
+ if (!cfg || cfg.cliId !== 'codex' || cfg.adoptMode || !fallbackTitle || !threadId)
317
+ return;
318
+ if (nativeSessionTitleAppliedThreadId === threadId || nativeSessionTitleSyncInFlight === threadId)
319
+ return;
320
+ const revision = nativeSessionTitleRevision;
321
+ const resumeGeneration = nativeSessionTitleCurrentGenerationResume;
322
+ let syncLatestTitle = false;
323
+ nativeSessionTitleSyncInFlight = threadId;
324
+ try {
325
+ const fallbackApplied = await applyCodexNativeSessionTitle(threadId, fallbackTitle, cfg, engine, resumeGeneration ? 'resume' : 'preview', revision);
326
+ if (!fallbackApplied || revision !== nativeSessionTitleRevision) {
327
+ syncLatestTitle = true;
328
+ return;
329
+ }
330
+ nativeSessionTitleAppliedThreadId = threadId;
331
+ log(`Applied native Codex session title: ${fallbackTitle}`);
332
+ const sourceText = cfg.nativeSessionTitlePrompt?.trim() ?? '';
333
+ cfg.nativeSessionTitlePrompt = undefined;
334
+ if (!sourceText)
335
+ return;
336
+ const adapter = createCliAdapterSync('codex', cfg.cliPathOverride);
337
+ const abortController = new AbortController();
338
+ nativeSessionTitleSyncAbortControllers.add(abortController);
339
+ let semanticCore;
340
+ try {
341
+ semanticCore = await generateCodexAppThreadTitle({
342
+ sourceText,
343
+ codexBin: adapter.resolvedBin,
344
+ env: codexNativeTitleEnv(cfg),
345
+ model: cfg.model,
346
+ timeoutMs: 30_000,
347
+ signal: abortController.signal,
348
+ detached: true,
349
+ registerForceClose: registerNativeTitleForceClose,
350
+ });
351
+ }
352
+ finally {
353
+ nativeSessionTitleSyncAbortControllers.delete(abortController);
354
+ }
355
+ if (revision !== nativeSessionTitleRevision) {
356
+ syncLatestTitle = true;
357
+ return;
358
+ }
359
+ if (!semanticCore) {
360
+ log('Native Codex semantic title generation unavailable; keeping fallback title');
361
+ return;
362
+ }
363
+ const semanticTitle = buildBotmuxLarkNativeSessionTitle(semanticCore);
364
+ if (semanticTitle === fallbackTitle)
365
+ return;
366
+ const semanticApplied = await applyCodexNativeSessionTitle(threadId, semanticTitle, cfg, engine, 'none', revision);
367
+ if (!semanticApplied || revision !== nativeSessionTitleRevision) {
368
+ syncLatestTitle = true;
369
+ return;
370
+ }
371
+ cfg.nativeSessionTitle = semanticTitle;
372
+ nativeSessionTitleAppliedThreadId = threadId;
373
+ send({ type: 'native_session_title_generated', title: semanticTitle });
374
+ log(`Applied generated native Codex session title: ${semanticTitle}`);
375
+ }
376
+ catch (err) {
377
+ if (revision !== nativeSessionTitleRevision)
378
+ syncLatestTitle = true;
379
+ else
380
+ log(`Native Codex session title sync failed: ${err?.message ?? err}`);
381
+ }
382
+ finally {
383
+ if (nativeSessionTitleSyncInFlight === threadId)
384
+ nativeSessionTitleSyncInFlight = undefined;
385
+ if (syncLatestTitle) {
386
+ void syncFreshCodexNativeSessionTitle(threadId, engine);
387
+ }
388
+ }
389
+ }
390
+ /** 在 resume 首条输入前记录 updatedAt,后续用其确认历史派生标题已完成回写。 */
391
+ async function captureCodexResumeTitleBaseline(threadId, engine) {
392
+ const cfg = lastInitConfig;
393
+ if (!cfg || cfg.cliId !== 'codex' || cfg.adoptMode || !cfg.nativeSessionTitle)
394
+ return;
395
+ nativeSessionTitleResumeUpdatedAt = Math.floor(Date.now() / 1000);
396
+ try {
397
+ if (engine?.activeThreadId === threadId) {
398
+ const metadata = await engine.readThreadMetadata(7000);
399
+ if (metadata.updatedAt !== undefined)
400
+ nativeSessionTitleResumeUpdatedAt = metadata.updatedAt;
401
+ return;
402
+ }
403
+ const adapter = createCliAdapterSync('codex', cfg.cliPathOverride);
404
+ const env = {
405
+ ...redactChildEnv(process.env),
406
+ ...sanitizePerBotEnv(cfg.env),
407
+ };
408
+ env.PATH = `${join(homedir(), '.botmux', 'bin')}${delimiter}${env.PATH ?? ''}`;
409
+ const abortController = new AbortController();
410
+ nativeSessionTitleSyncAbortControllers.add(abortController);
411
+ try {
412
+ const metadata = await readCodexAppThreadMetadata({
413
+ threadId,
414
+ codexBin: adapter.resolvedBin,
415
+ cwd: cfg.workingDir,
416
+ env,
417
+ timeoutMs: 7000,
418
+ signal: abortController.signal,
419
+ detached: true,
420
+ registerForceClose: registerNativeTitleForceClose,
421
+ });
422
+ if (metadata.updatedAt !== undefined)
423
+ nativeSessionTitleResumeUpdatedAt = metadata.updatedAt;
424
+ }
425
+ finally {
426
+ nativeSessionTitleSyncAbortControllers.delete(abortController);
427
+ }
428
+ }
429
+ catch (err) {
430
+ log(`Could not capture Codex resume title baseline: ${err?.message ?? err}`);
431
+ }
432
+ }
433
+ /** 每次 CLI spawn 后重建当前 generation 的标题同步状态。 */
434
+ async function prepareCodexNativeTitleGeneration(cfg, engine) {
435
+ if (cfg.cliId !== 'codex' || cfg.adoptMode || !cfg.nativeSessionTitle)
436
+ return;
437
+ nativeSessionTitleAppliedThreadId = undefined;
438
+ nativeSessionTitleResumeUpdatedAt = undefined;
439
+ nativeSessionTitleCurrentGenerationResume = lastSpawnEffectiveResume;
440
+ if (!lastSpawnEffectiveResume)
441
+ return;
442
+ const threadId = engine?.activeThreadId ?? lastSpawnEffectiveCliSessionId ?? cfg.cliSessionId;
443
+ if (threadId)
444
+ await captureCodexResumeTitleBaseline(threadId, engine);
445
+ }
446
+ /** Bridge TRAE app-server's native request_user_input request to botmux's
447
+ * existing Lark ask broker. The app-server owns tool execution in RPC mode, so
448
+ * returning this response resumes the same turn without terminal key driving. */
449
+ async function bridgeTraexUserInput(cfg, params) {
450
+ const parsed = parseTraexUserInputQuestions(params);
451
+ if (parsed.kind === 'unsupported') {
452
+ // Returning empty answers makes TraeX silently complete the tool as if no
453
+ // one answered, dropping the whole batch. Throw instead so the RPC engine
454
+ // replies with a JSON-RPC error and the failure is visible on the turn.
455
+ throw new Error(`requestUserInput cannot be represented as an ask card: ${parsed.reason}`);
456
+ }
457
+ const { questions } = parsed;
458
+ const daemon = findOnlineDaemon(cfg.larkAppId);
459
+ if (!daemon)
460
+ throw new Error(`daemon not found for larkAppId=${cfg.larkAppId}`);
461
+ const response = await fetchDaemonIpc(daemon.ipcPort, '/api/asks', {
462
+ method: 'POST',
463
+ headers: { 'content-type': 'application/json' },
464
+ body: JSON.stringify({
465
+ sessionId: cfg.sessionId,
466
+ chatId: cfg.chatId,
467
+ larkAppId: cfg.larkAppId,
468
+ rootMessageId: cfg.rootMessageId || null,
469
+ questions: questions.map(entry => entry.question),
470
+ timeoutMs: 3_600_000,
471
+ }),
472
+ });
473
+ if (!response.ok) {
474
+ throw new Error(`ask broker HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`);
475
+ }
476
+ const result = await response.json();
477
+ // Timeout/cancel/invalidated — surface as an error rather than an empty answer
478
+ // that TraeX would treat as "no one answered" and silently skip.
479
+ if (result.kind !== 'answered') {
480
+ throw new Error(`ask not answered (${result.kind ?? 'unknown'})`);
481
+ }
482
+ const customText = result.comment?.trim() ?? '';
483
+ const answers = {};
484
+ questions.forEach((entry, index) => {
485
+ const selected = result.answers?.[index] ?? [];
486
+ const values = selected.length > 0 ? [...selected] : customText ? [customText] : [];
487
+ if (values.length > 0)
488
+ answers[entry.id] = { answers: values };
489
+ });
490
+ return { answers };
491
+ }
223
492
  /** Stand up (or re-establish) the per-session codex app-server + botmux-owned
224
493
  * thread and point remote{WsUrl,ThreadId} at it, so the next spawnCli launches
225
494
  * `codex --remote <ws> resume <thread>` and input flows over JSON-RPC. Fully
@@ -246,7 +515,21 @@ async function engageCodexRpc(cfg) {
246
515
  const engineEnv = { ...redactChildEnv(process.env) };
247
516
  engineEnv.PATH = `${join(homedir(), '.botmux', 'bin')}:${engineEnv.PATH ?? ''}`;
248
517
  engineEnv.BOTMUX_SESSION_ID = cfg.sessionId;
518
+ // In Codex/TraeX RPC mode the app-server, not the remote viewer TUI, runs
519
+ // model shell tools. Give that process the same non-secret route binding as
520
+ // spawnCli so `botmux ask` can post into the current Lark thread.
521
+ engineEnv.BOTMUX_CHAT_ID = cfg.chatId;
522
+ if (cfg.chatType)
523
+ engineEnv.BOTMUX_CHAT_TYPE = cfg.chatType;
524
+ else
525
+ delete engineEnv.BOTMUX_CHAT_TYPE;
249
526
  engineEnv.BOTMUX_LARK_APP_ID = cfg.larkAppId;
527
+ engineEnv.BOTMUX_ROOT_MESSAGE_ID = cfg.rootMessageId;
528
+ engineEnv.BOTMUX_SESSION_SCOPE = cfg.rootMessageId?.startsWith('om_') ? 'thread' : 'chat';
529
+ if (cfg.ownerOpenId)
530
+ engineEnv.BOTMUX_OWNER_OPEN_ID = cfg.ownerOpenId;
531
+ else
532
+ delete engineEnv.BOTMUX_OWNER_OPEN_ID;
250
533
  // The app-server owns model execution in RPC mode. Its MCP gateway child
251
534
  // must inherit the trusted host socket just like a native CLI process does.
252
535
  if (sessionMcpGatewayHost) {
@@ -265,6 +548,10 @@ async function engageCodexRpc(cfg) {
265
548
  engine = new CodexRpcEngine({
266
549
  cliBin, cwd: cfg.workingDir, env: engineEnv, sessionId: cfg.sessionId,
267
550
  model: cfg.model, log: (m) => log(m),
551
+ appServerFeatures: cfg.cliId === 'traex' ? ['default_mode_request_user_input'] : undefined,
552
+ onRequestUserInput: cfg.cliId === 'traex'
553
+ ? (params) => bridgeTraexUserInput(cfg, params)
554
+ : undefined,
268
555
  onDead: () => {
269
556
  if (codexRpcEngine === engine) {
270
557
  log('Codex RPC app-server died; replacing the tmux session and re-engaging the thread');
@@ -318,10 +605,13 @@ async function engageCodexRpc(cfg) {
318
605
  codexBridgeMarkPendingTurn(cfg.prompt, cfg.turnId);
319
606
  outcome = first; // 'accepted' | 'ambiguous' — both stay engaged, prompt never re-queued
320
607
  }
321
- persistCliSessionId(threadId);
322
608
  codexRpcEngine = engine;
323
609
  remoteWsUrl = engine.wsUrl;
324
610
  remoteThreadId = threadId;
611
+ persistCliSessionId(threadId);
612
+ if (!wantResume && cfg.prompt && outcome === 'accepted') {
613
+ void syncFreshCodexNativeSessionTitle(threadId, engine);
614
+ }
325
615
  log(`Codex RPC input engaged (${outcome}${wantResume ? '/resume' : '/fresh'}): app-server ${engine.wsUrl} thread ${threadId}`);
326
616
  return outcome;
327
617
  }
@@ -672,6 +962,9 @@ let reattachIdleProbeTimer = null;
672
962
  * adapter's hermesBridgeAttach reads the correct mode. */
673
963
  let lastSpawnEffectiveResume = false;
674
964
  let lastSpawnEffectiveCliSessionId;
965
+ let lastSpawnDeferInitialPrompt = false;
966
+ let lastSpawnQueuedInitialPrompt;
967
+ let lastSpawnQueuedInitialPromptLogicalContent;
675
968
  let idleDetector = null;
676
969
  let isTmuxMode = false;
677
970
  /** True once a crash diagnostic tmux shell (bmx-diag-<sid>) is live. */
@@ -706,7 +999,12 @@ const authedClients = new WeakSet();
706
999
  const clientPtys = new Map();
707
1000
  /** Managed-Herdr viewers survive an in-worker /restart while backend changes. */
708
1001
  const herdrWebBindings = new Map();
709
- const writeToken = randomBytes(16).toString('hex');
1002
+ // Standalone/test fallback. Production replaces this after init with a stable
1003
+ // per-session HMAC derived from the host-only dashboard secret, so an
1004
+ // already-issued 「操作链接」/write link survives a worker restart (a silent
1005
+ // daemon restart re-forks every worker — a per-process random token would 403
1006
+ // every previously-issued operate link).
1007
+ let writeToken = randomBytes(16).toString('hex');
710
1008
  // Standalone/test fallback. Production replaces this after init with a stable
711
1009
  // per-session HMAC derived from the host-only dashboard secret.
712
1010
  let viewToken = randomBytes(32).toString('base64url');
@@ -721,6 +1019,15 @@ function refreshTerminalViewToken() {
721
1019
  if (secret && sessionId)
722
1020
  viewToken = deriveTerminalViewToken(secret, sessionId);
723
1021
  }
1022
+ /** Re-derive the stable write (operate) token from the host-only dashboard
1023
+ * secret so a restarted worker mints the SAME token — keeping already-issued
1024
+ * 「操作链接」/write links valid across restarts. Falls back to the random
1025
+ * boot token when the secret is unavailable (standalone/test). */
1026
+ function refreshTerminalWriteToken() {
1027
+ const secret = loadDashboardSecret(DASHBOARD_SECRET_PATH);
1028
+ if (secret && sessionId)
1029
+ writeToken = deriveTerminalWriteToken(secret, sessionId);
1030
+ }
724
1031
  /**
725
1032
  * Resolve terminal write permission for one request. The platform-injected
726
1033
  * `X-Botmux-Role` header is trusted only when this machine is bound to a central
@@ -757,6 +1064,10 @@ function ensureZellijAttachConfig() {
757
1064
  }
758
1065
  let sessionId = '';
759
1066
  let lastInitConfig = null;
1067
+ /** Dashboard「复现命令」:session 冷启时最终交给 backend.spawn 的真实调用
1068
+ * (bin + argv + cwd + 关键 env)。原样保留,worker `ready` 时随消息上报给 daemon
1069
+ * 持久化。仅有写权限的 dashboard 视图可见。 */
1070
+ let capturedSpawnCommand = null;
760
1071
  let deferredTopicOutputTail = '';
761
1072
  const reportedDeferredTopicRoots = new Set();
762
1073
  const CLI_DISPLAY_NAMES = { 'claude-code': 'Claude', seed: 'Seed', relay: 'Relay', aiden: 'Aiden', coco: 'CoCo', codex: 'Codex', 'codex-app': 'Codex App', cursor: 'Cursor', gemini: 'Gemini', genius: 'Genius', opencode: 'OpenCode', antigravity: 'Antigravity', mtr: 'MTR', hermes: 'Hermes', mira: 'Mira', mir: 'Mir CLI', traex: 'TRAE', pi: 'Pi', copilot: 'Copilot', 'oh-my-pi': 'Oh My Pi', kimi: 'Kimi', grok: 'Grok Build', 'kiro-cli': 'Kiro', riff: 'Riff' };
@@ -1040,6 +1351,21 @@ const pendingRawInputs = [];
1040
1351
  * administrative TUI command: never type-ahead while the agent is busy, never
1041
1352
  * open a model turn, and latest-wins if several renames arrive before idle. */
1042
1353
  let pendingSessionRename = null;
1354
+ let nativeSessionTitleSyncInFlight;
1355
+ let nativeSessionTitleAppliedThreadId;
1356
+ let nativeSessionTitleRevision = 0;
1357
+ let nativeSessionTitleResumeUpdatedAt;
1358
+ let nativeSessionTitleCurrentGenerationResume = false;
1359
+ const nativeSessionTitleSyncAbortControllers = new Set();
1360
+ const nativeSessionTitleSyncForceClosers = new Set();
1361
+ function stopNativeSessionTitleSync() {
1362
+ for (const abortController of nativeSessionTitleSyncAbortControllers)
1363
+ abortController.abort();
1364
+ nativeSessionTitleSyncAbortControllers.clear();
1365
+ for (const forceClose of nativeSessionTitleSyncForceClosers)
1366
+ forceClose();
1367
+ nativeSessionTitleSyncForceClosers.clear();
1368
+ }
1043
1369
  /** True after the rename command's Enter lands and until the TUI returns to its
1044
1370
  * prompt. Blocks type-ahead user messages from racing into the command UI. */
1045
1371
  let sessionRenameInFlight = false;
@@ -3701,6 +4027,7 @@ function parkCrashDiagnosticTerminal(code, signal) {
3701
4027
  // both when the next message respawns the CLI.
3702
4028
  stopScreenUpdates();
3703
4029
  stopScreenAnalyzer();
4030
+ stopStuckDetector();
3704
4031
  log(`Crash diagnostic tmux session parked at ${TmuxBackend.diagnosticSessionName(sessionId)}`);
3705
4032
  return true;
3706
4033
  }
@@ -3816,6 +4143,68 @@ function stopScreenAnalyzer() {
3816
4143
  screenAnalyzer = null;
3817
4144
  tuiPromptBlocking = false;
3818
4145
  }
4146
+ // ─── Stuck Detector (AI-free fallback for blocked CLI states) ───────────────
4147
+ let stuckDetector = null;
4148
+ // Monotonic counter bumped on every CLI/backend (re)start within this worker.
4149
+ // Paired with the backend object identity, it lets the worker verify that a
4150
+ // stuck-warning card click still targets the same CLI instance that was stuck —
4151
+ // a restart within the same Node worker must invalidate outstanding cards.
4152
+ let cliLifetimeNonce = 0;
4153
+ function startStuckDetector() {
4154
+ const sd = config.stuckDetector;
4155
+ if (!sd.enabled)
4156
+ return;
4157
+ stopStuckDetector();
4158
+ stuckDetector = new StuckDetector(sd.timeoutMs, {
4159
+ isActuallyStuck: () => {
4160
+ // Scope gate: this PR only handles the Codex PreToolUse hook-review
4161
+ // screen. Other CLIs (Claude Code, Gemini, ...) must never see the
4162
+ // Codex-specific t/Enter/Esc card, even if their output happens to
4163
+ // contain the same strings.
4164
+ if (lastInitConfig?.cliId !== 'codex')
4165
+ return false;
4166
+ // Only warn if the CLI is not at its idle prompt AND no TUI prompt card
4167
+ // is already posted. A long legitimate turn (model thinking, tool calls)
4168
+ // must not trigger this.
4169
+ if (isPromptReady)
4170
+ return false;
4171
+ if (tuiPromptBlocking)
4172
+ return false;
4173
+ // Anti-false-positive: if the PTY produced output recently the CLI is
4174
+ // still actively working (model streaming, tool output, spinner) — not
4175
+ // stuck. Require quiescence before firing.
4176
+ const sincePty = Date.now() - lastPtyActivityAtMs;
4177
+ if (sincePty < 15_000)
4178
+ return false;
4179
+ // Do NOT gate on durableTurnInFlight: the original hook-review incident
4180
+ // ran on a non-durable (ordinary IM) turn, and a durable turn's 20s
4181
+ // submit-recheck failure clears durableTurnInFlight before the 45s
4182
+ // detector fires. The PTY-quiescence + !isPromptReady combination is
4183
+ // sufficient to detect a genuinely stalled turn.
4184
+ return true;
4185
+ },
4186
+ onStuck: (elapsedMs, matchedLabel) => {
4187
+ // Prefer lastAnalyzerSnapshot (the capture-pane authoritative current
4188
+ // screen) over the long-lived renderer, which can drift under tmux.
4189
+ const snapshot = lastAnalyzerSnapshot || renderer?.rawSnapshot() || '';
4190
+ log(`StuckDetector: turn unresolved for ${Math.round(elapsedMs / 1000)}s${matchedLabel ? ` (${matchedLabel})` : ''}`);
4191
+ send({
4192
+ type: 'stuck_warning',
4193
+ elapsedMs,
4194
+ snapshot: snapshot.slice(-3000),
4195
+ matchedPattern: matchedLabel,
4196
+ turnId: currentBotmuxTurnId,
4197
+ dispatchAttempt: currentBotmuxDispatchAttempt,
4198
+ cliLifetime: cliLifetimeNonce,
4199
+ });
4200
+ },
4201
+ getSnapshot: () => lastAnalyzerSnapshot || renderer?.rawSnapshot() || '',
4202
+ });
4203
+ }
4204
+ function stopStuckDetector() {
4205
+ stuckDetector?.dispose();
4206
+ stuckDetector = null;
4207
+ }
3819
4208
  // ─── Screenshot Capture (PNG → Feishu image_key) ────────────────────────────
3820
4209
  const SCREENSHOT_INTERVAL_MS = 10_000;
3821
4210
  const POST_ACTION_DELAY_MS = 1_000;
@@ -4063,24 +4452,32 @@ const KEY_TO_ANSI = {
4063
4452
  * Execute an AI-provided key sequence with delays between each key.
4064
4453
  * @param keys — key names like ["Down","Down","Space","Up","Up"]
4065
4454
  * @param isFinal — if true, this action ends the prompt (clear blocking state)
4455
+ * @returns true if all keys were written successfully; false if backend is gone
4456
+ * or a write threw (caller must NOT send tui_keys_delivered on false).
4066
4457
  */
4067
4458
  async function handleTuiKeys(keys, isFinal) {
4068
4459
  if (!backend || keys.length === 0)
4069
- return;
4070
- if ('sendSpecialKeys' in backend) {
4071
- const b = backend;
4072
- // Send each key individually with 100ms delay for TUI state processing
4073
- for (const key of keys) {
4074
- b.sendSpecialKeys(key);
4075
- await new Promise(r => setTimeout(r, 100));
4460
+ return false;
4461
+ try {
4462
+ if ('sendSpecialKeys' in backend) {
4463
+ const b = backend;
4464
+ // Send each key individually with 100ms delay for TUI state processing
4465
+ for (const key of keys) {
4466
+ b.sendSpecialKeys(key);
4467
+ await new Promise(r => setTimeout(r, 100));
4468
+ }
4076
4469
  }
4077
- }
4078
- else {
4079
- for (const key of keys) {
4080
- backend.write(KEY_TO_ANSI[key] ?? key);
4081
- await new Promise(r => setTimeout(r, 100));
4470
+ else {
4471
+ for (const key of keys) {
4472
+ backend.write(KEY_TO_ANSI[key] ?? key);
4473
+ await new Promise(r => setTimeout(r, 100));
4474
+ }
4082
4475
  }
4083
4476
  }
4477
+ catch (e) {
4478
+ logError(`handleTuiKeys write failed: ${e?.message ?? e}`);
4479
+ return false;
4480
+ }
4084
4481
  if (isFinal) {
4085
4482
  tuiPromptBlocking = false;
4086
4483
  if (isPromptReady) {
@@ -4090,6 +4487,7 @@ async function handleTuiKeys(keys, isFinal) {
4090
4487
  screenAnalyzer?.notifySelection('final');
4091
4488
  }
4092
4489
  log(`TUI keys: ${keys.join(' ')}${isFinal ? ' (final)' : ''}`);
4490
+ return true;
4093
4491
  }
4094
4492
  // 待注入的 TUI 命令队列。生命周期绑定当前 CLI 进程:killCli() 会清空它,
4095
4493
  // 防止 restart 后残留命令重放进新 CLI——新增清理状态时记得同步那里。
@@ -4597,6 +4995,7 @@ function markPromptReady() {
4597
4995
  // worker-local replay of the durable attempt.
4598
4996
  if (!durableTurnInFlight)
4599
4997
  inflightInputs.onTurnComplete();
4998
+ stuckDetector?.disarm();
4600
4999
  maybeEmitWorkflowTranscriptOutput();
4601
5000
  if (awaitingFirstPrompt) {
4602
5001
  awaitingFirstPrompt = false;
@@ -4781,6 +5180,7 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
4781
5180
  persistCliSessionId(cliSessionId);
4782
5181
  if (codexBridgeFallbackActive())
4783
5182
  codexBridgeNotifyCliSessionId(cliSessionId);
5183
+ void syncFreshCodexNativeSessionTitle(cliSessionId, codexRpcEngine);
4784
5184
  }
4785
5185
  log(`Deferred recheck found submit in ${transcriptLabel} — suppressing warning. preview="${preview}"`);
4786
5186
  return;
@@ -5065,9 +5465,12 @@ async function flushPending() {
5065
5465
  // auto-re-queued: a lost/late ack surfaces as a submit-failure the user can
5066
5466
  // resend manually, and the stable clientUserMessageId lets codex dedupe a
5067
5467
  // resend (Codex delta P1-1).
5068
- if (!codexRpcEngine)
5468
+ if (!codexRpcEngine) {
5069
5469
  inflightInputs.onWrite(item);
5470
+ stuckDetector?.arm();
5471
+ }
5070
5472
  const msg = item.content;
5473
+ const logicalMsg = item.logicalContent ?? msg;
5071
5474
  currentBotmuxTurnId = item.turnId;
5072
5475
  currentBotmuxDispatchAttempt = item.dispatchAttempt;
5073
5476
  currentVcMeetingImTurnOrigin = item.vcMeetingImTurnOrigin;
@@ -5087,14 +5490,14 @@ async function flushPending() {
5087
5490
  bridgeIngest();
5088
5491
  }
5089
5492
  catch { /* best-effort */ }
5090
- bridgeTurnId = bridgeMarkPendingTurn(msg, item.turnId, item.dispatchAttempt);
5493
+ bridgeTurnId = bridgeMarkPendingTurn(logicalMsg, item.turnId, item.dispatchAttempt);
5091
5494
  }
5092
5495
  else if (codexBridgeActive) {
5093
5496
  // Codex mark works even before the rollout path is known: the
5094
5497
  // queue is path-agnostic, and the late-attach below will start
5095
5498
  // ingest from offset 0 so the user_message that lands shortly
5096
5499
  // after still fingerprint-matches this turn.
5097
- codexBridgeMarkPendingTurn(msg, item.turnId, item.dispatchAttempt);
5500
+ codexBridgeMarkPendingTurn(logicalMsg, item.turnId, item.dispatchAttempt);
5098
5501
  }
5099
5502
  if (durableWrite
5100
5503
  && cliAdapter.reliableTurnTerminal === true
@@ -5148,7 +5551,7 @@ async function flushPending() {
5148
5551
  // do. Otherwise surface it as a submit failure so the message isn't
5149
5552
  // silently lost.
5150
5553
  if (backend)
5151
- scheduleSubmitFailureNotify(msg, undefined, '会话 JSONL', bridgeTurnId, undefined, turnSeq, item);
5554
+ scheduleSubmitFailureNotify(logicalMsg, undefined, '会话 JSONL', bridgeTurnId, undefined, turnSeq, item);
5152
5555
  break;
5153
5556
  }
5154
5557
  // Persist any sessionId the adapter observed via authoritative sources
@@ -5163,11 +5566,18 @@ async function flushPending() {
5163
5566
  if (codexBridgeActive)
5164
5567
  codexBridgeNotifyCliSessionId(result.cliSessionId);
5165
5568
  }
5569
+ if (lastInitConfig?.cliId === 'codex' && result?.submitted !== false) {
5570
+ const threadId = result?.cliSessionId
5571
+ ?? codexRpcEngine?.activeThreadId
5572
+ ?? lastInitConfig.cliSessionId;
5573
+ if (threadId)
5574
+ void syncFreshCodexNativeSessionTitle(threadId, codexRpcEngine);
5575
+ }
5166
5576
  // `&& backend`: if the CLI exited during this write (pane gone → onExit
5167
5577
  // nulled backend) the user already got a "CLI exited" notice; don't also
5168
5578
  // nag that the submit wasn't confirmed.
5169
5579
  if (result && result.submitted === false && backend) {
5170
- scheduleSubmitFailureNotify(msg, result.recheck, '会话 JSONL', bridgeTurnId, result.failureReason, turnSeq, item);
5580
+ scheduleSubmitFailureNotify(logicalMsg, result.recheck, '会话 JSONL', bridgeTurnId, result.failureReason, turnSeq, item);
5171
5581
  }
5172
5582
  // All structured bridges now drain every pending message in one flush:
5173
5583
  // Claude's BridgeTurnQueue handles `attachment(queued_command)` events
@@ -5188,7 +5598,7 @@ async function flushPending() {
5188
5598
  }
5189
5599
  function sendToPty(content, turnId, opts = {}) {
5190
5600
  if (!cliAdapter)
5191
- return;
5601
+ return false;
5192
5602
  const next = {
5193
5603
  content,
5194
5604
  turnId,
@@ -5205,7 +5615,7 @@ function sendToPty(content, turnId, opts = {}) {
5205
5615
  if (cliRestartInProgress || !backend) {
5206
5616
  pendingMessages.push(next);
5207
5617
  log(`Queued message while CLI backend is restarting (${pendingMessages.length} pending)`);
5208
- return;
5618
+ return true;
5209
5619
  }
5210
5620
  const supportsTypeAhead = pendingInputAllowsTypeAhead(cliAdapter.supportsTypeAhead === true, durableTurnInFlight, next);
5211
5621
  const shouldMergeQueued = opts.dispatchAttempt === undefined && !durableTurnInFlight
@@ -5262,6 +5672,7 @@ function sendToPty(content, turnId, opts = {}) {
5262
5672
  log(`Queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — ${cliName()} ${awaitingFirstPrompt ? 'still booting' : 'is busy'}`);
5263
5673
  scheduleBusyPatternIdleProbe(`${cliName()} queued-message`);
5264
5674
  }
5675
+ return true;
5265
5676
  }
5266
5677
  // ─── Screen Update Timer ─────────────────────────────────────────────────────
5267
5678
  function startScreenUpdates() {
@@ -5699,6 +6110,7 @@ async function spawnCli(cfg, opts = {}) {
5699
6110
  });
5700
6111
  effectiveBackendType = 'herdr';
5701
6112
  backend = herdrBe;
6113
+ cliLifetimeNonce++;
5702
6114
  // Same as tmux/zellij adopt: writeInput (grok preferSessionId via
5703
6115
  // findGrokSessionByPid, claude pid-state) needs cliPid/cliCwd on the
5704
6116
  // PtyHandle. spawn() overwrites cliCwd from opts.cwd — use adoptCwd when
@@ -5757,6 +6169,7 @@ async function spawnCli(cfg, opts = {}) {
5757
6169
  : new TmuxPipeBackend(cfg.adoptTmuxTarget, { cliPid: cfg.adoptCliPid });
5758
6170
  effectiveBackendType = cfg.adoptZellijPaneId ? 'zellij' : 'tmux';
5759
6171
  backend = observeBe;
6172
+ cliLifetimeNonce++;
5760
6173
  // writeInput (grok concurrent prompt_history binding, claude pid-state)
5761
6174
  // reads these fields off the PtyHandle — constructor only stores
5762
6175
  // watchCliPid for liveness, so surface them explicitly for adopt.
@@ -5936,11 +6349,6 @@ async function spawnCli(cfg, opts = {}) {
5936
6349
  }
5937
6350
  }
5938
6351
  }
5939
- const selectedBackend = selectSessionBackend({ sessionId: cfg.sessionId, backendType: effectiveBackend, backendConfig: riffBackendConfig });
5940
- isTmuxMode = selectedBackend.isTmuxMode;
5941
- isPipeMode = selectedBackend.isPipeMode;
5942
- isZellijMode = selectedBackend.isZellijMode;
5943
- backend = selectedBackend.backend;
5944
6352
  const adapterSessionId = cfg.resume
5945
6353
  ? (cfg.originalSessionId ?? cfg.sessionId)
5946
6354
  : cfg.sessionId;
@@ -6076,6 +6484,31 @@ async function spawnCli(cfg, opts = {}) {
6076
6484
  currentCliCredentialIsolated = appliedIsolationCapabilities.includes('credential');
6077
6485
  const isolationRuntimeDataDir = process.env.SESSION_DATA_DIR
6078
6486
  ?? join(defaultBotmuxHome, 'data');
6487
+ let mcpRuntimeManifest = readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir);
6488
+ const hasMcpRuntimeEntries = !!cliAdapter.mcpGateway && !!mcpRuntimeManifest?.entries.length;
6489
+ const selectBackend = () => selectSessionBackend({
6490
+ sessionId: cfg.sessionId,
6491
+ backendType: effectiveBackend,
6492
+ backendConfig: riffBackendConfig,
6493
+ persistentBackendTarget: cfg.persistentBackendTarget,
6494
+ // Old builds could place managed agents in a user's shared Herdr session.
6495
+ // Preserve that recorded target for compatibility unless this incarnation
6496
+ // requires an isolation/MCP boundary that only a Botmux-owned session can
6497
+ // safely provide. Fresh tasks use distinct agents in one machine-wide host.
6498
+ reuseRecordedHerdrTarget: !willReadIsolate
6499
+ && !willWriteSandbox
6500
+ && !hasMcpRuntimeEntries,
6501
+ });
6502
+ let selectedBackend = selectBackend();
6503
+ isTmuxMode = selectedBackend.isTmuxMode;
6504
+ isPipeMode = selectedBackend.isPipeMode;
6505
+ isZellijMode = selectedBackend.isZellijMode;
6506
+ backend = selectedBackend.backend;
6507
+ // Bump the CLI-lifetime nonce: any stuck-warning card posted by a previous
6508
+ // backend instance (within this same worker) must not inject keys into the
6509
+ // new one. The worker echoes this nonce in stuck_warning and re-checks it on
6510
+ // tui_keys, alongside the backend object identity.
6511
+ cliLifetimeNonce++;
6079
6512
  // Every bot — isolated OR not — gets its own BOT_HOME dir as a ready-made private-
6080
6513
  // storage slot. An isolated sibling denies this path regardless of whether the owner
6081
6514
  // is isolated (deny uses the full bots.json), so a non-isolated bot can drop private
@@ -6141,13 +6574,7 @@ async function spawnCli(cfg, opts = {}) {
6141
6574
  // on EVERY restart, e.g. for a sandboxed session whose transcript lives in
6142
6575
  // an ephemeral overlay upper that the probe can't see). Computed here (not at
6143
6576
  // the spawn site below) so the pre-flight can short-circuit on it.
6144
- const persistentSessionName = effectiveBackendType === 'tmux'
6145
- ? TmuxBackend.sessionName(cfg.sessionId)
6146
- : effectiveBackendType === 'herdr'
6147
- ? HerdrBackend.sessionName(cfg.sessionId)
6148
- : effectiveBackendType === 'zellij'
6149
- ? ZellijBackend.sessionName(cfg.sessionId)
6150
- : undefined;
6577
+ let persistentSessionName = selectedBackend.persistentSessionName;
6151
6578
  // [read-isolation] Before we decide to reattach a persistent pane: a pane can
6152
6579
  // survive a daemon restart still running a CLI that may NOT be isolated (e.g.
6153
6580
  // spawned before isolation was enabled, or by an old build). Isolation is only
@@ -6158,11 +6585,10 @@ async function spawnCli(cfg, opts = {}) {
6158
6585
  // policy pane survives daemon restarts and suspend→resume safely because the
6159
6586
  // confinement remains attached to the live process.
6160
6587
  if (appliedIsolationCapabilities.length > 0 && persistentSessionName && effectiveBackendType !== 'pty') {
6161
- const paneLive = effectiveBackendType === 'tmux'
6162
- ? TmuxBackend.hasSession(persistentSessionName)
6163
- : effectiveBackendType === 'zellij'
6164
- ? ZellijBackend.hasSession(persistentSessionName)
6165
- : HerdrBackend.hasSession(persistentSessionName);
6588
+ const persistentTarget = selectedBackend.persistentBackendTarget;
6589
+ const paneLive = persistentTarget
6590
+ ? probePersistentBackendTarget(persistentTarget) === 'exists'
6591
+ : false;
6166
6592
  if (paneLive) {
6167
6593
  let marker = null;
6168
6594
  marker = readRegularHostFileNoFollow(join(isolationRuntimeDataDir, 'read-isolation', `${cfg.sessionId}.boot`));
@@ -6177,7 +6603,18 @@ async function spawnCli(cfg, opts = {}) {
6177
6603
  // obsolete permissions. Kill it before publishing any new capability.
6178
6604
  log(`[read-isolation] legacy/unmarked persistent pane for ${cfg.sessionId} — killing + cold-spawning with current policy`);
6179
6605
  try {
6180
- killPersistentSession(effectiveBackendType, persistentSessionName);
6606
+ const persistentTarget = selectedBackend.persistentBackendTarget;
6607
+ if (persistentTarget)
6608
+ killPersistentBackendTarget(persistentTarget);
6609
+ else
6610
+ killPersistentSession(effectiveBackendType, persistentSessionName);
6611
+ selectedBackend = selectBackend();
6612
+ isTmuxMode = selectedBackend.isTmuxMode;
6613
+ isPipeMode = selectedBackend.isPipeMode;
6614
+ isZellijMode = selectedBackend.isZellijMode;
6615
+ backend = selectedBackend.backend;
6616
+ cliLifetimeNonce++;
6617
+ persistentSessionName = selectedBackend.persistentSessionName;
6181
6618
  }
6182
6619
  catch (e) {
6183
6620
  throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: could not kill stale persistent pane (${e.message})`);
@@ -6185,28 +6622,32 @@ async function spawnCli(cfg, opts = {}) {
6185
6622
  }
6186
6623
  }
6187
6624
  }
6188
- let mcpRuntimeManifest = readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir);
6625
+ let willReattachPersistent = selectedBackend.isReattach === true;
6189
6626
  if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length && persistentSessionName && effectiveBackendType !== 'pty') {
6190
- const paneLive = effectiveBackendType === 'tmux'
6191
- ? TmuxBackend.hasSession(persistentSessionName)
6192
- : effectiveBackendType === 'zellij'
6193
- ? ZellijBackend.hasSession(persistentSessionName)
6194
- : HerdrBackend.hasSession(persistentSessionName);
6627
+ const persistentTarget = selectedBackend.persistentBackendTarget;
6628
+ const paneLive = persistentTarget
6629
+ ? probePersistentBackendTarget(persistentTarget) === 'exists'
6630
+ : false;
6195
6631
  if (paneLive) {
6196
6632
  // The trusted Gateway host belongs to the worker and cannot survive a
6197
6633
  // worker/daemon replacement. Cold-resume the CLI so its MCP client gets a
6198
6634
  // fresh relay socket instead of reattaching to a dead connection.
6199
6635
  log(`[mcp-gateway] persistent pane ${cfg.sessionId} has plugin MCP state — cold-resuming with a fresh host`);
6200
- killPersistentSession(effectiveBackendType, persistentSessionName);
6636
+ const persistentTarget = selectedBackend.persistentBackendTarget;
6637
+ if (persistentTarget)
6638
+ killPersistentBackendTarget(persistentTarget);
6639
+ else
6640
+ killPersistentSession(effectiveBackendType, persistentSessionName);
6641
+ selectedBackend = selectBackend();
6642
+ isTmuxMode = selectedBackend.isTmuxMode;
6643
+ isPipeMode = selectedBackend.isPipeMode;
6644
+ isZellijMode = selectedBackend.isZellijMode;
6645
+ backend = selectedBackend.backend;
6646
+ cliLifetimeNonce++;
6647
+ persistentSessionName = selectedBackend.persistentSessionName;
6648
+ willReattachPersistent = selectedBackend.isReattach === true;
6201
6649
  }
6202
6650
  }
6203
- const willReattachPersistent = persistentSessionName
6204
- ? effectiveBackendType === 'tmux'
6205
- ? TmuxBackend.hasSession(persistentSessionName)
6206
- : effectiveBackendType === 'zellij'
6207
- ? ZellijBackend.hasSession(persistentSessionName)
6208
- : HerdrBackend.hasSession(persistentSessionName)
6209
- : false;
6210
6651
  // The plugin set is stable only for the lifetime of one real CLI process.
6211
6652
  // A warm worker reattach keeps the existing Gateway and catalog untouched;
6212
6653
  // every fresh/resumed CLI spawn atomically refreshes both from current Bot config.
@@ -6340,6 +6781,30 @@ async function spawnCli(cfg, opts = {}) {
6340
6781
  // baking it into args would drop the message that triggered the resume.
6341
6782
  // Finally, defer adapter-declared over-limit prompts to avoid backend command
6342
6783
  // string limits (tmux "command too long") while preserving short argv prompts.
6784
+ let preparedInitialPrompt;
6785
+ let promptArgPreparationChanged = false;
6786
+ let preparedDeferredInput;
6787
+ if (cfg.prompt) {
6788
+ const prepared = cliAdapter.prepareInitialPromptArg?.({
6789
+ initialPrompt: cfg.prompt,
6790
+ sessionId: effectiveAdapterSessionId,
6791
+ sessionDataDir: process.env.SESSION_DATA_DIR,
6792
+ });
6793
+ if (prepared?.readonlyRoots?.length) {
6794
+ piInitialPromptReadonlyRoots = [
6795
+ ...new Set([...piInitialPromptReadonlyRoots, ...prepared.readonlyRoots]),
6796
+ ];
6797
+ }
6798
+ if (prepared?.cleanupPaths?.length) {
6799
+ piInitialPromptCleanupPaths.push(...prepared.cleanupPaths);
6800
+ }
6801
+ if (prepared?.cleanupDirs?.length) {
6802
+ piInitialPromptCleanupDirs.push(...prepared.cleanupDirs);
6803
+ }
6804
+ preparedDeferredInput = prepared?.deferredInput;
6805
+ preparedInitialPrompt = prepared?.initialPrompt ?? cfg.prompt;
6806
+ promptArgPreparationChanged = preparedInitialPrompt !== cfg.prompt;
6807
+ }
6343
6808
  const deferInitialPrompt = shouldDeferInitialPromptForStartup({
6344
6809
  hasStartupCommands: !!cfg.startupCommands?.length,
6345
6810
  adoptMode: cfg.adoptMode === true,
@@ -6349,11 +6814,25 @@ async function spawnCli(cfg, opts = {}) {
6349
6814
  adoptMode: cfg.adoptMode === true,
6350
6815
  dispatchAttempt: cfg.dispatchAttempt,
6351
6816
  }) || (effectiveResume && cliAdapter.initialPromptArgsIgnoredOnResume === true)
6352
- || shouldDeferInitialPromptForArgLimit({
6817
+ || (!promptArgPreparationChanged && shouldDeferInitialPromptForArgLimit({
6353
6818
  passesInitialPromptViaArgs: cliAdapter.passesInitialPromptViaArgs === true,
6354
6819
  prompt: cfg.prompt,
6355
6820
  maxInitialPromptArgBytes: cliAdapter.maxInitialPromptArgBytes,
6356
- });
6821
+ }));
6822
+ const initialPromptDelivery = resolveInitialPromptDelivery({
6823
+ originalPrompt: cfg.prompt,
6824
+ preparedArg: preparedInitialPrompt,
6825
+ preparedDeferredContent: preparedDeferredInput?.content,
6826
+ defer: deferInitialPrompt,
6827
+ });
6828
+ preparedInitialPrompt = initialPromptDelivery.argvPrompt;
6829
+ lastSpawnQueuedInitialPrompt = initialPromptDelivery.queuedContent;
6830
+ lastSpawnQueuedInitialPromptLogicalContent = initialPromptDelivery.logicalContent;
6831
+ if (deferInitialPrompt && preparedDeferredInput) {
6832
+ piInitialPromptAdditionalArgs = [...(preparedDeferredInput.additionalArgs ?? [])];
6833
+ piInitialPromptEnv = { ...(preparedDeferredInput.env ?? {}) };
6834
+ }
6835
+ lastSpawnDeferInitialPrompt = deferInitialPrompt;
6357
6836
  kiroSessionIdCaptureArmed = cfg.cliId === 'kiro-cli' && !effectiveCliSessionId && !willReattachPersistent;
6358
6837
  kiroSessionIdCaptureBuffer = '';
6359
6838
  // Per-bot local read isolation: assemble the Seatbelt profile context (the gate
@@ -6432,7 +6911,7 @@ async function spawnCli(cfg, opts = {}) {
6432
6911
  resume: effectiveResume,
6433
6912
  workingDir: cfg.workingDir,
6434
6913
  resumeSessionId: effectiveCliSessionId,
6435
- initialPrompt: deferInitialPrompt ? undefined : (cfg.prompt || undefined),
6914
+ initialPrompt: preparedInitialPrompt,
6436
6915
  botName: cfg.botName,
6437
6916
  botOpenId: cfg.botOpenId,
6438
6917
  larkAppId: cfg.larkAppId,
@@ -6447,6 +6926,12 @@ async function spawnCli(cfg, opts = {}) {
6447
6926
  remoteWsUrl,
6448
6927
  remoteThreadId,
6449
6928
  });
6929
+ // Pi's deferred long-first-prompt command is implemented by a session-scoped
6930
+ // extension. Keep its launch args across owned process restarts while the
6931
+ // queued/in-flight command may still need replay.
6932
+ if (piInitialPromptAdditionalArgs.length > 0) {
6933
+ args.unshift(...piInitialPromptAdditionalArgs);
6934
+ }
6450
6935
  // Extra args from env (CLI_DISABLE_DEFAULT_ARGS is removed — adapters own their defaults)
6451
6936
  const extra = (process.env.CLI_EXTRA_ARGS ?? '').trim();
6452
6937
  if (extra)
@@ -6576,6 +7061,9 @@ async function spawnCli(cfg, opts = {}) {
6576
7061
  // them past the server's global env.
6577
7062
  if (cliAdapter.spawnEnv)
6578
7063
  Object.assign(childEnv, cliAdapter.spawnEnv);
7064
+ if (Object.keys(piInitialPromptEnv).length > 0) {
7065
+ Object.assign(childEnv, piInitialPromptEnv);
7066
+ }
6579
7067
  // v2 read isolation: point the CLI at its PER-BOT config dir (set AFTER spawnEnv
6580
7068
  // so it overrides any adapter default). claude → CLAUDE_CONFIG_DIR, codex →
6581
7069
  // CODEX_HOME. Both are in BOTMUX_INJECTED_ENV_KEYS so the tmux backend forwards
@@ -6613,6 +7101,13 @@ async function spawnCli(cfg, opts = {}) {
6613
7101
  let spawnBin = cliAdapter.resolvedBin;
6614
7102
  let spawnArgs = args;
6615
7103
  let spawnCwd = cfg.workingDir;
7104
+ // Dashboard「复现命令」:在**任何** sandbox 包装(下方 macOS Seatbelt / Linux bwrap /
7105
+ // credential-only)之前,记下**基础 CLI** 的 bin/args(cliAdapter.resolvedBin +
7106
+ // buildArgs 产出)。独立维护、绝不从已被外层包装的 spawnBin/spawnArgs 回推。最终
7107
+ // 复现形态(是否套 wrapperCli)由 selectReproduceLaunch 在 spawn 时统一决策——见
7108
+ // reproduce-command.ts。这里只锁定"包装前的基础"这个事实。
7109
+ const reproduceBaseBin = spawnBin;
7110
+ const reproduceBaseArgs = [...spawnArgs];
6616
7111
  // Read isolation (macOS): wrap the whole CLI process in a Seatbelt sandbox that
6617
7112
  // denies reads of the sensitive paths (blocklist). The CLI bypasses its OWN
6618
7113
  // sandbox (see adapter) so the outer wrapper is the sole enforcer. DARWIN ONLY —
@@ -6662,6 +7157,9 @@ async function spawnCli(cfg, opts = {}) {
6662
7157
  cliId: cliAdapter.id,
6663
7158
  resolvedBin: canonical(cliAdapter.resolvedBin),
6664
7159
  }),
7160
+ // buildV2DenyPaths masks the shared pi-initial-prompts root. Re-open
7161
+ // only this session's private child directory for Pi's @file/extension.
7162
+ ...piInitialPromptReadonlyRoots.map(canonical),
6665
7163
  ];
6666
7164
  finalDenyPaths = carve.finalDenyPaths.map(canonical);
6667
7165
  traverseDirs = carve.traverseDirs.map(canonical);
@@ -6908,6 +7406,7 @@ async function spawnCli(cfg, opts = {}) {
6908
7406
  extraExecPaths: cliAdapter.sandboxExtraExecPaths?.(),
6909
7407
  readonlyRoots: [
6910
7408
  ...(cfg.skillReadonlyRoots ?? []),
7409
+ ...piInitialPromptReadonlyRoots,
6911
7410
  ...(readIsoLinuxMasks?.ownReadOnlyPaths ?? []),
6912
7411
  ],
6913
7412
  mcpGatewaySocketPath: sessionMcpGatewayHost?.socketPath,
@@ -7002,6 +7501,19 @@ async function spawnCli(cfg, opts = {}) {
7002
7501
  }
7003
7502
  }
7004
7503
  }
7504
+ // Publish the exact selected resource BEFORE spawn. This both restores host
7505
+ // affinity for later in-worker restarts and closes the crash window where a
7506
+ // shared Herdr agent could be created but the daemon still knew only bmx-*.
7507
+ // A failed spawn leaving an intent stamp is safe: lifecycle probes see the
7508
+ // missing agent and close/cold-resume instead of leaking an untracked pane.
7509
+ cfg.persistentBackendTarget = selectedBackend.persistentBackendTarget;
7510
+ if (lastInitConfig) {
7511
+ lastInitConfig.persistentBackendTarget = selectedBackend.persistentBackendTarget;
7512
+ }
7513
+ send({
7514
+ type: 'persistent_backend_target',
7515
+ target: selectedBackend.persistentBackendTarget,
7516
+ });
7005
7517
  // Mandatory credential-only confinement is the OUTERMOST launch wrapper so
7006
7518
  // wrapperCli and every descendant it starts inherit the boundary. Full
7007
7519
  // Seatbelt/bwrap sessions were already wrapped above and never enter these
@@ -7139,6 +7651,32 @@ async function spawnCli(cfg, opts = {}) {
7139
7651
  log(`[device-credential-isolation] wrapping ${cliAdapter.id} in credential-only bwrap `
7140
7652
  + `(${hideDirectories.size} authority dir(s), ${hideFiles.size} exact file(s))`);
7141
7653
  }
7654
+ // Dashboard「复现命令」:算出本次冷启的**近似**可复现命令(bin + argv + cwd +
7655
+ // 权威注入 env),随 ready 上报、只驻 daemon 内存(含凭证,绝不落盘)。基础 CLI
7656
+ // bin/args 取 sandbox 包装前的快照,最终形态(含/不含 wrapperCli)由
7657
+ // selectReproduceLaunch 决策——绝不含 sandbox-exec/bwrap 外层。riff 返回 null。
7658
+ try {
7659
+ const reproduceLaunch = selectReproduceLaunch({
7660
+ baseBin: reproduceBaseBin,
7661
+ baseArgs: reproduceBaseArgs,
7662
+ wrapperCli: cfg.wrapperCli,
7663
+ sandboxOn,
7664
+ binResolver: (b) => locateOnPath(b) ?? b,
7665
+ ttadkModel: cfg.model,
7666
+ });
7667
+ capturedSpawnCommand = buildReproduceCommand({
7668
+ backendType: effectiveBackendType,
7669
+ bin: reproduceLaunch.bin,
7670
+ args: reproduceLaunch.args,
7671
+ cwd: spawnCwd,
7672
+ env: childEnv,
7673
+ injectEnv: perBotInjectKeys.length ? perBotInjectEnv : undefined,
7674
+ });
7675
+ }
7676
+ catch (err) {
7677
+ capturedSpawnCommand = null;
7678
+ log(`Failed to capture reproduce command: ${err?.message ?? err}`);
7679
+ }
7142
7680
  backend.spawn(spawnBin, spawnArgs, {
7143
7681
  cwd: spawnCwd,
7144
7682
  cols: PTY_COLS,
@@ -7147,6 +7685,13 @@ async function spawnCli(cfg, opts = {}) {
7147
7685
  injectEnv: perBotInjectKeys.length ? perBotInjectEnv : undefined,
7148
7686
  launchShell: lastInitConfig?.launchShell,
7149
7687
  });
7688
+ if (selectedBackend.createdHerdrSessionName) {
7689
+ send({
7690
+ type: 'user_notify',
7691
+ turnId: currentBotmuxTurnId,
7692
+ message: `已创建 Botmux 专属 Herdr 会话:\`${selectedBackend.createdHerdrSessionName}\``,
7693
+ });
7694
+ }
7150
7695
  // Write CLI PID marker so agent-facing subcommands (`botmux send`, etc.)
7151
7696
  // can verify they were spawned inside a botmux session by walking the
7152
7697
  // process tree and looking for a matching pid file in this directory.
@@ -7434,44 +7979,59 @@ async function spawnCli(cfg, opts = {}) {
7434
7979
  }, READY_SIGNAL_TIMEOUT_MS);
7435
7980
  readySignalTimer.unref?.();
7436
7981
  }
7982
+ // A settled signal is a turn boundary. Drain structured transcripts before
7983
+ // markPromptReady(): that call may synchronously flush a type-ahead turn and
7984
+ // advance bridge attribution. Both screen-idle and authoritative Herdr status
7985
+ // must preserve this ordering.
7986
+ const drainBridgesThenMarkReady = (evidenceSource) => {
7987
+ if (bridgeJsonlPath) {
7988
+ try {
7989
+ bridgeDrainAndMaybeEmit();
7990
+ }
7991
+ catch (err) {
7992
+ log(`Bridge emit error: ${err.message}`);
7993
+ }
7994
+ }
7995
+ if (codexBridgeFallbackActive()) {
7996
+ try {
7997
+ codexBridgeDrainAndMaybeEmit();
7998
+ }
7999
+ catch (err) {
8000
+ log(`Codex bridge emit error: ${err.message}`);
8001
+ }
8002
+ }
8003
+ if (evidenceSource === 'screen')
8004
+ markPromptReadyFromPty();
8005
+ else
8006
+ markPromptReady();
8007
+ };
7437
8008
  // Set up idle detection. Riff (remote HTTP backend) has no PTY output and
7438
8009
  // is marked ready immediately after spawn (see below), so the idle detector
7439
8010
  // is unnecessary — and without a readyPattern it would fire on every
7440
8011
  // quiescence, repeatedly triggering markPromptReady() and duplicate cards.
7441
8012
  if (effectiveBackendType !== 'riff') {
7442
8013
  idleDetector = new IdleDetector(cliAdapter);
7443
- idleDetector.onIdle(async (evidenceSource) => {
8014
+ idleDetector.onIdle((evidenceSource) => {
7444
8015
  log('Prompt detected (idle)');
7445
- // Bridge drain MUST run before markPromptReady() — the latter calls
7446
- // flushPending() which can immediately fire the next queued message
7447
- // (type-ahead adapters), shifting bridgeQueue's notion of "current
7448
- // turn" before we've had a chance to emit the previous one.
7449
- if (bridgeJsonlPath) {
7450
- try {
7451
- bridgeDrainAndMaybeEmit();
7452
- }
7453
- catch (err) {
7454
- log(`Bridge emit error: ${err.message}`);
7455
- }
7456
- }
7457
- if (codexBridgeFallbackActive()) {
7458
- try {
7459
- codexBridgeDrainAndMaybeEmit();
7460
- }
7461
- catch (err) {
7462
- log(`Codex bridge emit error: ${err.message}`);
7463
- }
7464
- }
7465
- if (evidenceSource === 'screen') {
7466
- markPromptReadyFromPty();
7467
- }
7468
- else {
7469
- markPromptReady();
7470
- }
8016
+ drainBridgesThenMarkReady(evidenceSource);
7471
8017
  });
7472
8018
  }
7473
8019
  backend.onData(onPtyData);
7474
8020
  const observedBackend = backend;
8021
+ if (observedBackend instanceof HerdrBackend) {
8022
+ observedBackend.onAgentStatus((status) => {
8023
+ if (backend !== observedBackend)
8024
+ return;
8025
+ if (status === 'idle' || status === 'done') {
8026
+ log(`Herdr agent ${status} — draining bridges before marking prompt ready`);
8027
+ drainBridgesThenMarkReady('structured');
8028
+ }
8029
+ else if (status === 'working') {
8030
+ isPromptReady = false;
8031
+ idleDetector?.reset();
8032
+ }
8033
+ });
8034
+ }
7475
8035
  backend.onAccessUrl?.((url) => {
7476
8036
  send({
7477
8037
  type: 'riff_access_url',
@@ -7659,8 +8219,11 @@ async function spawnCli(cfg, opts = {}) {
7659
8219
  }
7660
8220
  function killCli(opts = {}) {
7661
8221
  currentCliCredentialIsolated = false;
8222
+ stopNativeSessionTitleSync();
7662
8223
  stopSessionMcpGatewayHost();
7663
8224
  stopCodexRpcEngine();
8225
+ if (!opts.preservePending)
8226
+ cleanupPiInitialPromptFiles();
7664
8227
  destroyCrashDiagnosticTerminal('killCli');
7665
8228
  idleDetector?.dispose();
7666
8229
  idleDetector = null;
@@ -7680,6 +8243,7 @@ function killCli(opts = {}) {
7680
8243
  readyPatternSeenDuringHold = false;
7681
8244
  awaitingPostSessionStartPromptEvidence = false;
7682
8245
  stopScreenAnalyzer();
8246
+ stopStuckDetector();
7683
8247
  stopScreenUpdates();
7684
8248
  backend?.kill();
7685
8249
  backend = null;
@@ -7796,6 +8360,7 @@ async function restartCliProcess(reason, opts = {}) {
7796
8360
  if (lastInitConfig) {
7797
8361
  startScreenUpdates();
7798
8362
  startScreenAnalyzer();
8363
+ startStuckDetector();
7799
8364
  try {
7800
8365
  const restartCfg = { ...lastInitConfig, resume: true, prompt: '', cliSessionId: rpcThreadId ?? lastInitConfig.cliSessionId };
7801
8366
  // Re-engage RPC so the new --remote pane binds to the CURRENT app-server
@@ -7809,6 +8374,7 @@ async function restartCliProcess(reason, opts = {}) {
7809
8374
  await engageCodexRpc(restartCfg);
7810
8375
  }
7811
8376
  await spawnCli(restartCfg, { pluginGenerationPrepared: rpcPluginGenerationPrepared });
8377
+ await prepareCodexNativeTitleGeneration(restartCfg, codexRpcEngine);
7812
8378
  if (codexRpcEngine)
7813
8379
  armRpcStartupDialogDismiss();
7814
8380
  }
@@ -7873,8 +8439,24 @@ function startWebServer(host, preferredPort) {
7873
8439
  // normal buffer until resize causes a redraw. Preserve that mode so
7874
8440
  // scroll gestures continue to target the CLI's own transcript.
7875
8441
  const forceRemoteScroll = effectiveBackendType === 'herdr' && cliAdapter?.altScreen === true;
8442
+ // The wheel burst cap throttles backends where a forwarded wheel is
8443
+ // EXPENSIVE or has no local terminal to drive:
8444
+ // • Herdr — each forwarded wheel → pane send-text + snapshot re-render.
8445
+ // • Riff — has NO drivable terminal; writes become remote task/follow-up
8446
+ // creations (handleTermAction even rejects ANSI to Riff), so an
8447
+ // uncapped spin would flood remote task creation.
8448
+ // This is a BACKEND property, independent of the adapter's declared
8449
+ // altScreen: an altScreen:false CLI (Claude/Codex) that enters the
8450
+ // alternate buffer at runtime (vim/less, or the CLI's own alt-screen) still
8451
+ // forwards via _fwdScroll and must inherit its backend's cap. So gate the
8452
+ // cap on a POSITIVE allowlist of cheap, locally-drivable terminal backends
8453
+ // (pty/tmux/zellij); every other backend — Herdr, Riff, and any future one
8454
+ // — stays safely capped by default.
8455
+ const localTerminalBackend = effectiveBackendType === 'pty'
8456
+ || effectiveBackendType === 'tmux'
8457
+ || effectiveBackendType === 'zellij';
7876
8458
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
7877
- res.end(getTerminalHtml(hasWrite, platformReadonly, loginUrl, forceRemoteScroll));
8459
+ res.end(getTerminalHtml(hasWrite, platformReadonly, loginUrl, forceRemoteScroll, localTerminalBackend));
7878
8460
  });
7879
8461
  wss = new WebSocketServer({
7880
8462
  server: httpServer,
@@ -8171,7 +8753,7 @@ function startWebServer(host, preferredPort) {
8171
8753
  .then(resolve, reject);
8172
8754
  });
8173
8755
  }
8174
- function getTerminalHtml(hasWrite, platformReadonly = false, loginUrl = '', forceRemoteScroll = false) {
8756
+ function getTerminalHtml(hasWrite, platformReadonly = false, loginUrl = '', forceRemoteScroll = false, localTerminalBackend = false) {
8175
8757
  const label = sessionId.substring(0, 8);
8176
8758
  return `<!DOCTYPE html>
8177
8759
  <html>
@@ -8308,6 +8890,7 @@ if(isTouch){document.body.classList.add('touch');}
8308
8890
  var hasToken=${hasWrite};
8309
8891
  var platformReadonly=${platformReadonly};
8310
8892
  var remoteScroll=${forceRemoteScroll};
8893
+ var localTerminalBackend=${localTerminalBackend};
8311
8894
  if(!hasToken){
8312
8895
  if(platformReadonly){var _lb=document.getElementById('login-banner');_lb.classList.add('show');}
8313
8896
  else{var _rb=document.getElementById('readonly-banner');_rb.classList.add('show');_rb.addEventListener('click',function(){_rb.classList.remove('show')});}
@@ -8514,8 +9097,21 @@ window.addEventListener('resize',onViewportResize);
8514
9097
  // A high-resolution trackpad emits dozens of wheel events for one gesture, so
8515
9098
  // cap the whole continuous burst — not each browser event — then require an idle
8516
9099
  // gap (or direction reversal) before loading the next history chunk.
9100
+ //
9101
+ // The burst ceiling throttles backends where a forwarded wheel is EXPENSIVE or
9102
+ // has no local terminal to drive: Herdr (each tick → pane send-text + snapshot
9103
+ // re-render) and Riff (no drivable terminal — writes become remote task/follow-up
9104
+ // creations, so an uncapped spin would flood remote task creation). That cost is
9105
+ // a BACKEND property, not the adapter's declared altScreen — an altScreen:false
9106
+ // CLI (Claude/Codex) that enters the alternate buffer at runtime (vim/less, the
9107
+ // CLI's own alt-screen) still hits this path and inherits its backend's cap. A
9108
+ // local PTY/tmux/zellij backend repaints its own alt-screen cheaply, so capping
9109
+ // it at 6 ticks is what froze a continuous wheel spin after ~2 notches — the
9110
+ // "not smooth / stuck" symptom. Gate the release on a POSITIVE allowlist of
9111
+ // cheap local terminal backends (localTerminalBackend); every other backend —
9112
+ // Herdr, Riff, and any future one — stays safely capped by default.
8517
9113
  var _scrollAccum=0,_scrollBurstTicks=0,_scrollBurstDir=0,_scrollBurstT=0;
8518
- var _SCROLL_STEP=33;var _SCROLL_BURST_MAX=6;var _SCROLL_BURST_IDLE_MS=250;
9114
+ var _SCROLL_STEP=33;var _SCROLL_BURST_MAX=localTerminalBackend?Infinity:6;var _SCROLL_BURST_IDLE_MS=250;
8519
9115
  function _endScrollBurst(){
8520
9116
  clearTimeout(_scrollBurstT);_scrollBurstT=0;
8521
9117
  _scrollAccum=0;_scrollBurstTicks=0;_scrollBurstDir=0;
@@ -8971,6 +9567,10 @@ function send(msg) {
8971
9567
  }
8972
9568
  process.send?.(payload);
8973
9569
  }
9570
+ function acknowledgeTurnInputCommitted(turnId) {
9571
+ if (turnId)
9572
+ send({ type: 'turn_input_committed', turnId });
9573
+ }
8974
9574
  function publishLocalProcessAttestation(cliPid) {
8975
9575
  const cliProcStart = cliPid ? readProcessStartIdentity(cliPid) : undefined;
8976
9576
  send({
@@ -9028,11 +9628,13 @@ process.on('message', async (raw) => {
9028
9628
  const msg = raw;
9029
9629
  switch (msg.type) {
9030
9630
  case 'init': {
9631
+ const initStartedAtMs = Date.now();
9031
9632
  if (lastInitConfig)
9032
9633
  return; // already initialized
9033
9634
  lastInitConfig = msg;
9034
9635
  sessionId = msg.sessionId;
9035
9636
  refreshTerminalViewToken();
9637
+ refreshTerminalWriteToken();
9036
9638
  if (msg.ownerOpenId)
9037
9639
  process.env.__OWNER_OPEN_ID = msg.ownerOpenId;
9038
9640
  // Pin this worker's i18n locale early so every t() call below resolves
@@ -9076,6 +9678,7 @@ process.on('message', async (raw) => {
9076
9678
  port = await startWebServer(config.web.workerHost, msg.webPort);
9077
9679
  startScreenUpdates();
9078
9680
  startScreenAnalyzer();
9681
+ startStuckDetector();
9079
9682
  }
9080
9683
  else {
9081
9684
  // Workflow attempts still expose a read-only web terminal so the
@@ -9119,6 +9722,7 @@ process.on('message', async (raw) => {
9119
9722
  throw new Error('codex RPC resume: could not replace stale --remote pane; aborting init');
9120
9723
  }
9121
9724
  await spawnCli(msg, { pluginGenerationPrepared: rpcPluginGenerationPrepared });
9725
+ await prepareCodexNativeTitleGeneration(msg, codexRpcEngine);
9122
9726
  if (codexRpcEngine)
9123
9727
  armRpcStartupDialogDismiss(); // boundary #4: keep the --remote pane from freezing on a startup dialog
9124
9728
  // Queue the initial prompt — flushed when CLI shows idle.
@@ -9133,20 +9737,7 @@ process.on('message', async (raw) => {
9133
9737
  // Tier-1/Tier-2 fresh demotion, which clears the flag). Adopt spawns
9134
9738
  // return from spawnCli before that write — exclude them explicitly so
9135
9739
  // the stale module-level value can't leak in.
9136
- const deferInitialPrompt = shouldDeferInitialPromptForStartup({
9137
- hasStartupCommands: !!msg.startupCommands?.length,
9138
- adoptMode: msg.adoptMode === true,
9139
- passesInitialPromptViaArgs: cliAdapter?.passesInitialPromptViaArgs === true,
9140
- }) || shouldDeferArgsBakedDurablePrompt({
9141
- passesInitialPromptViaArgs: cliAdapter?.passesInitialPromptViaArgs === true,
9142
- adoptMode: msg.adoptMode === true,
9143
- dispatchAttempt: msg.dispatchAttempt,
9144
- }) || (msg.adoptMode !== true && lastSpawnEffectiveResume && cliAdapter?.initialPromptArgsIgnoredOnResume === true)
9145
- || shouldDeferInitialPromptForArgLimit({
9146
- passesInitialPromptViaArgs: cliAdapter?.passesInitialPromptViaArgs === true,
9147
- prompt: msg.prompt,
9148
- maxInitialPromptArgBytes: cliAdapter?.maxInitialPromptArgBytes,
9149
- });
9740
+ const deferInitialPrompt = lastSpawnDeferInitialPrompt;
9150
9741
  if (msg.prompt && cliAdapter?.passesInitialPromptViaArgs && !deferInitialPrompt && codexBridgeFallbackActive()) {
9151
9742
  // Args-baked first prompts (notably Pi) never pass through the normal
9152
9743
  // 'message' IPC path, so the structured bridge would otherwise see the
@@ -9163,6 +9754,7 @@ process.on('message', async (raw) => {
9163
9754
  // execution — exactly-once, Codex P1-1). Ambiguous never reaches here.
9164
9755
  // - RPC RESUME: queuePrompt=true → queue for post-ready flush (bridge
9165
9756
  // marked at flush time, P0-1).
9757
+ let initialInputCommitted = false;
9166
9758
  if (shouldQueueInitialPrompt({
9167
9759
  hasPrompt: !!msg.prompt,
9168
9760
  rpcEngineActive: !!codexRpcEngine,
@@ -9171,19 +9763,31 @@ process.on('message', async (raw) => {
9171
9763
  deferInitialPrompt,
9172
9764
  })) {
9173
9765
  pendingMessages.push({
9174
- content: msg.prompt,
9766
+ content: lastSpawnQueuedInitialPrompt ?? msg.prompt,
9767
+ ...(lastSpawnQueuedInitialPromptLogicalContent
9768
+ ? { logicalContent: lastSpawnQueuedInitialPromptLogicalContent }
9769
+ : {}),
9175
9770
  turnId: msg.turnId,
9176
9771
  dispatchAttempt: msg.dispatchAttempt,
9177
9772
  vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
9178
9773
  codexAppInput: msg.promptCodexAppInput,
9179
9774
  });
9775
+ initialInputCommitted = true;
9180
9776
  }
9181
- // Riff (remote HTTP backends): spawnCli already marked the prompt ready
9182
- // (no local boot isPromptReady=true immediately), but the initial prompt
9183
- // was queued AFTER spawnCli returned, so flushPending() ran on an empty
9184
- // queue. Flush now that the prompt is enqueued. isPromptReady is already
9185
- // true so the flush gates all pass.
9186
- if (effectiveBackendType === 'riff' && pendingMessages.length > 0) {
9777
+ else if (msg.prompt) {
9778
+ // A successful spawn with a non-queued prompt means the adapter baked
9779
+ // it into argv or the RPC engine already accepted it.
9780
+ initialInputCommitted = true;
9781
+ }
9782
+ if (initialInputCommitted)
9783
+ acknowledgeTurnInputCommitted(msg.turnId);
9784
+ // A backend may become prompt-ready before spawnCli() returns. The
9785
+ // initial prompt is queued only afterwards, so the earlier
9786
+ // markPromptReady() necessarily flushed an empty queue. This is normal
9787
+ // for riff (ready immediately) and can also happen when Herdr reports a
9788
+ // fast-starting TUI as idle during spawn. Flush again after enqueueing;
9789
+ // the ready flag keeps booting/busy backends gated.
9790
+ if (isPromptReady && pendingMessages.length > 0) {
9187
9791
  flushPending();
9188
9792
  }
9189
9793
  send({
@@ -9191,6 +9795,11 @@ process.on('message', async (raw) => {
9191
9795
  port,
9192
9796
  token: writeToken,
9193
9797
  viewToken,
9798
+ ...(capturedSpawnCommand ? { spawnCommand: capturedSpawnCommand } : {}),
9799
+ // A fast initial turn can complete via `botmux send` before Herdr
9800
+ // reports idle and this ready IPC is emitted. Tell the daemon not to
9801
+ // post a stale Starting card after the final reply is already visible.
9802
+ replyAlreadySent: readSendMarkers().some(marker => marker.sentAtMs >= initStartedAtMs),
9194
9803
  turnId: currentBotmuxTurnId,
9195
9804
  dispatchAttempt: currentBotmuxDispatchAttempt,
9196
9805
  });
@@ -9205,6 +9814,21 @@ process.on('message', async (raw) => {
9205
9814
  // Mark new turn baseline so the streaming card only shows this turn's content
9206
9815
  renderer?.markNewTurn();
9207
9816
  const turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
9817
+ if (msg.nativeSessionTitlePrompt
9818
+ && msg.nativeSessionTitle
9819
+ && lastInitConfig?.cliId === 'codex'
9820
+ && !lastInitConfig.adoptMode) {
9821
+ nativeSessionTitleRevision += 1;
9822
+ nativeSessionTitleAppliedThreadId = undefined;
9823
+ lastInitConfig.nativeSessionTitle = msg.nativeSessionTitle;
9824
+ lastInitConfig.nativeSessionTitlePrompt = msg.nativeSessionTitlePrompt;
9825
+ stopNativeSessionTitleSync();
9826
+ const threadId = codexRpcEngine?.activeThreadId
9827
+ ?? lastSpawnEffectiveCliSessionId
9828
+ ?? lastInitConfig.cliSessionId;
9829
+ if (threadId)
9830
+ void syncFreshCodexNativeSessionTitle(threadId, codexRpcEngine);
9831
+ }
9208
9832
  // Cancel any active tmux copy-mode scroll so user input reaches the CLI.
9209
9833
  if (tmuxScrolledHalfPages > 0)
9210
9834
  exitTmuxScrollMode();
@@ -9222,12 +9846,16 @@ process.on('message', async (raw) => {
9222
9846
  log('Message received after crash-loop stop; retrying CLI start');
9223
9847
  destroyCrashDiagnosticTerminal('retry after message');
9224
9848
  stopScreenAnalyzer();
9849
+ stopStuckDetector();
9225
9850
  stopScreenUpdates();
9226
9851
  awaitingFirstPrompt = true;
9227
9852
  startScreenUpdates();
9228
9853
  startScreenAnalyzer();
9854
+ startStuckDetector();
9229
9855
  try {
9230
- await spawnCli({ ...lastInitConfig, resume: true, prompt: '' });
9856
+ const restartCfg = { ...lastInitConfig, resume: true, prompt: '' };
9857
+ await spawnCli(restartCfg);
9858
+ await prepareCodexNativeTitleGeneration(restartCfg, codexRpcEngine);
9231
9859
  }
9232
9860
  catch (err) {
9233
9861
  // Pass the message's own attempt (not the stale currentBotmux* from a
@@ -9309,6 +9937,9 @@ process.on('message', async (raw) => {
9309
9937
  if (result && result.submitted === false) {
9310
9938
  scheduleSubmitFailureNotify(content, result.recheck, 'submit history', undefined, result.failureReason, turnSeq);
9311
9939
  }
9940
+ else {
9941
+ acknowledgeTurnInputCommitted(msg.turnId);
9942
+ }
9312
9943
  }
9313
9944
  catch (err) {
9314
9945
  log(`Adopt writeInput error (${lastInitConfig?.cliId}): ${err.message}`);
@@ -9326,9 +9957,11 @@ process.on('message', async (raw) => {
9326
9957
  // command (raw_input) fix.
9327
9958
  await new Promise(r => setTimeout(r, 200));
9328
9959
  backend.sendSpecialKeys('Enter');
9960
+ acknowledgeTurnInputCommitted(msg.turnId);
9329
9961
  }
9330
9962
  else {
9331
9963
  backend.write(content + '\r');
9964
+ acknowledgeTurnInputCommitted(msg.turnId);
9332
9965
  }
9333
9966
  isPromptReady = false;
9334
9967
  idleDetector?.reset();
@@ -9340,11 +9973,13 @@ process.on('message', async (raw) => {
9340
9973
  // arrival. Marking now would race with a still-running previous
9341
9974
  // turn whose `botmux send` could sneak its sentAtMs past this
9342
9975
  // turn's markTimeMs and falsely suppress its fallback.
9343
- sendToPty(content, msg.turnId, {
9976
+ const inputCommitted = sendToPty(content, msg.turnId, {
9344
9977
  codexAppInput,
9345
9978
  dispatchAttempt: msg.dispatchAttempt,
9346
9979
  vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
9347
9980
  });
9981
+ if (inputCommitted)
9982
+ acknowledgeTurnInputCommitted(msg.turnId);
9348
9983
  }
9349
9984
  break;
9350
9985
  }
@@ -9372,6 +10007,13 @@ process.on('message', async (raw) => {
9372
10007
  // IPC handlers are concurrent with async init, so queue first even when
9373
10008
  // the adapter/backend has not finished initializing. flushPending will
9374
10009
  // capability-check and deliver the latest title once a real prompt is idle.
10010
+ nativeSessionTitleRevision += 1;
10011
+ nativeSessionTitleAppliedThreadId = undefined;
10012
+ if (lastInitConfig) {
10013
+ lastInitConfig.nativeSessionTitle = msg.title;
10014
+ lastInitConfig.nativeSessionTitlePrompt = undefined;
10015
+ }
10016
+ stopNativeSessionTitleSync();
9375
10017
  pendingSessionRename = msg.title;
9376
10018
  log(`Queued native session rename: ${msg.title}`);
9377
10019
  void flushPending();
@@ -9468,7 +10110,49 @@ process.on('message', async (raw) => {
9468
10110
  break;
9469
10111
  }
9470
10112
  case 'tui_keys': {
9471
- handleTuiKeys(msg.keys, msg.isFinal);
10113
+ // Stale-card guard: if this key press came from a stuck-warning card,
10114
+ // delegate to processStuckWarningTuiKeys which does a FRESH capture (not
10115
+ // the 2s-cached lastAnalyzerSnapshot) and re-verifies the current screen
10116
+ // still matches the page type the card was built for. Fail-closed: any
10117
+ // mismatch (lifetime, capture, backend, page type, write failure) sends
10118
+ // stuck_warning_expired and drops the keys. ScreenAnalyzer TUI cards
10119
+ // (no stuckNonce) bypass the guard and write keys directly.
10120
+ let wroteKeys = false;
10121
+ if (msg.stuckNonce !== undefined && msg.stuckPageType) {
10122
+ const result = await processStuckWarningTuiKeys({
10123
+ stuckNonce: msg.stuckNonce,
10124
+ stuckPageType: msg.stuckPageType,
10125
+ stuckCliLifetime: msg.stuckCliLifetime,
10126
+ keys: msg.keys,
10127
+ isFinal: msg.isFinal,
10128
+ }, {
10129
+ getBackend: () => backend,
10130
+ getCurrentLifetime: () => cliLifetimeNonce,
10131
+ renderCols,
10132
+ renderRows,
10133
+ turnId: currentBotmuxTurnId,
10134
+ dispatchAttempt: currentBotmuxDispatchAttempt,
10135
+ capture: snapshotToText,
10136
+ match: matchHookReviewScreen,
10137
+ writeKeys: handleTuiKeys,
10138
+ sendExpired: (nonce, turnId, dispatchAttempt) => send({ type: 'stuck_warning_expired', nonce, turnId, dispatchAttempt }),
10139
+ sendDelivered: (nonce, turnId, dispatchAttempt) => send({ type: 'tui_keys_delivered', nonce, turnId, dispatchAttempt }),
10140
+ log,
10141
+ });
10142
+ wroteKeys = result.wroteKeys;
10143
+ }
10144
+ else {
10145
+ await handleTuiKeys(msg.keys, msg.isFinal);
10146
+ wroteKeys = true;
10147
+ }
10148
+ // Re-arm the stuck detector ONLY when the card-handler explicitly flags
10149
+ // this as a stuck-warning card's Enter action (advances to the next
10150
+ // review layer) AND keys were actually written. An expired click (CLI
10151
+ // recovered, page changed) must NOT re-arm — the detector should stay
10152
+ // disarmed until the next real stall. t/Esc and all ScreenAnalyzer cards
10153
+ // never set this flag.
10154
+ if (shouldRearmStuckDetector(!!msg.rearmStuckDetector, wroteKeys))
10155
+ stuckDetector?.arm();
9472
10156
  break;
9473
10157
  }
9474
10158
  case 'inject_command': {
@@ -9633,6 +10317,8 @@ process.on('message', async (raw) => {
9633
10317
  });
9634
10318
  // ─── Cleanup ─────────────────────────────────────────────────────────────────
9635
10319
  function cleanup() {
10320
+ stopNativeSessionTitleSync();
10321
+ cleanupPiInitialPromptFiles();
9636
10322
  stopSessionMcpGatewayHost();
9637
10323
  if (tmuxRestartTimer) {
9638
10324
  clearTimeout(tmuxRestartTimer);
@@ -9736,7 +10422,11 @@ function teardownSandboxBestEffort() {
9736
10422
  // and process.exit(1), killing a live session over a dropped log write. Install
9737
10423
  // the guard before any further stdout writes (log() writes to process.stdout).
9738
10424
  installStdioEpipeGuard();
9739
- process.on('exit', () => { teardownSandboxBestEffort(); stopCodexRpcEngine(); });
10425
+ process.on('exit', () => {
10426
+ stopNativeSessionTitleSync();
10427
+ teardownSandboxBestEffort();
10428
+ stopCodexRpcEngine();
10429
+ });
9740
10430
  process.on('uncaughtException', (err) => {
9741
10431
  // A broken pipe on stdout/stderr (or any socket) must not tear down a live
9742
10432
  // session — the stdio guard handles those it can; this is the backstop.