botmux 3.4.3 → 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 (350) 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.d.ts.map +1 -1
  19. package/dist/adapters/cli/pi-initial-prompt.js +5 -1
  20. package/dist/adapters/cli/pi-initial-prompt.js.map +1 -1
  21. package/dist/adapters/cli/traex.d.ts.map +1 -1
  22. package/dist/adapters/cli/traex.js +5 -0
  23. package/dist/adapters/cli/traex.js.map +1 -1
  24. package/dist/adapters/cli/types.d.ts +3 -3
  25. package/dist/adapters/cli/types.d.ts.map +1 -1
  26. package/dist/adapters/hook-installer.d.ts +7 -0
  27. package/dist/adapters/hook-installer.d.ts.map +1 -1
  28. package/dist/adapters/hook-installer.js +56 -0
  29. package/dist/adapters/hook-installer.js.map +1 -1
  30. package/dist/bot-registry.d.ts +13 -0
  31. package/dist/bot-registry.d.ts.map +1 -1
  32. package/dist/bot-registry.js +118 -2
  33. package/dist/bot-registry.js.map +1 -1
  34. package/dist/cli/capabilities.d.ts +24 -0
  35. package/dist/cli/capabilities.d.ts.map +1 -0
  36. package/dist/cli/capabilities.js +25 -0
  37. package/dist/cli/capabilities.js.map +1 -0
  38. package/dist/cli/create-group-resolver.d.ts +24 -0
  39. package/dist/cli/create-group-resolver.d.ts.map +1 -1
  40. package/dist/cli/create-group-resolver.js +23 -0
  41. package/dist/cli/create-group-resolver.js.map +1 -1
  42. package/dist/cli/daemon-lifecycle-env.d.ts +5 -7
  43. package/dist/cli/daemon-lifecycle-env.d.ts.map +1 -1
  44. package/dist/cli/daemon-lifecycle-env.js +13 -7
  45. package/dist/cli/daemon-lifecycle-env.js.map +1 -1
  46. package/dist/cli/exact-chat-grant-client.d.ts +48 -0
  47. package/dist/cli/exact-chat-grant-client.d.ts.map +1 -0
  48. package/dist/cli/exact-chat-grant-client.js +108 -0
  49. package/dist/cli/exact-chat-grant-client.js.map +1 -0
  50. package/dist/cli/exact-chat-grant.d.ts +18 -0
  51. package/dist/cli/exact-chat-grant.d.ts.map +1 -0
  52. package/dist/cli/exact-chat-grant.js +97 -0
  53. package/dist/cli/exact-chat-grant.js.map +1 -0
  54. package/dist/cli.d.ts +10 -1
  55. package/dist/cli.d.ts.map +1 -1
  56. package/dist/cli.js +770 -126
  57. package/dist/cli.js.map +1 -1
  58. package/dist/codex-rpc-engine.d.ts +26 -0
  59. package/dist/codex-rpc-engine.d.ts.map +1 -1
  60. package/dist/codex-rpc-engine.js +113 -2
  61. package/dist/codex-rpc-engine.js.map +1 -1
  62. package/dist/config.d.ts +16 -0
  63. package/dist/config.d.ts.map +1 -1
  64. package/dist/config.js +16 -0
  65. package/dist/config.js.map +1 -1
  66. package/dist/core/bot-live-control.d.ts +41 -0
  67. package/dist/core/bot-live-control.d.ts.map +1 -0
  68. package/dist/core/bot-live-control.js +135 -0
  69. package/dist/core/bot-live-control.js.map +1 -0
  70. package/dist/core/command-handler.d.ts.map +1 -1
  71. package/dist/core/command-handler.js +28 -8
  72. package/dist/core/command-handler.js.map +1 -1
  73. package/dist/core/daemon-config-fence.d.ts +8 -0
  74. package/dist/core/daemon-config-fence.d.ts.map +1 -0
  75. package/dist/core/daemon-config-fence.js +13 -0
  76. package/dist/core/daemon-config-fence.js.map +1 -0
  77. package/dist/core/dashboard-events.d.ts +5 -0
  78. package/dist/core/dashboard-events.d.ts.map +1 -1
  79. package/dist/core/dashboard-events.js.map +1 -1
  80. package/dist/core/dashboard-ipc-server.d.ts +21 -2
  81. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  82. package/dist/core/dashboard-ipc-server.js +232 -5
  83. package/dist/core/dashboard-ipc-server.js.map +1 -1
  84. package/dist/core/dispatch-registry.d.ts +12 -0
  85. package/dist/core/dispatch-registry.d.ts.map +1 -0
  86. package/dist/core/dispatch-registry.js +33 -0
  87. package/dist/core/dispatch-registry.js.map +1 -0
  88. package/dist/core/dispatch.d.ts +99 -0
  89. package/dist/core/dispatch.d.ts.map +1 -1
  90. package/dist/core/dispatch.js +136 -0
  91. package/dist/core/dispatch.js.map +1 -1
  92. package/dist/core/maintenance.d.ts.map +1 -1
  93. package/dist/core/maintenance.js +10 -3
  94. package/dist/core/maintenance.js.map +1 -1
  95. package/dist/core/persistent-backend.d.ts +16 -2
  96. package/dist/core/persistent-backend.d.ts.map +1 -1
  97. package/dist/core/persistent-backend.js +40 -0
  98. package/dist/core/persistent-backend.js.map +1 -1
  99. package/dist/core/session-discovery.d.ts +8 -1
  100. package/dist/core/session-discovery.d.ts.map +1 -1
  101. package/dist/core/session-discovery.js +140 -8
  102. package/dist/core/session-discovery.js.map +1 -1
  103. package/dist/core/session-manager.d.ts.map +1 -1
  104. package/dist/core/session-manager.js +12 -8
  105. package/dist/core/session-manager.js.map +1 -1
  106. package/dist/core/session-title.d.ts +8 -0
  107. package/dist/core/session-title.d.ts.map +1 -1
  108. package/dist/core/session-title.js +54 -0
  109. package/dist/core/session-title.js.map +1 -1
  110. package/dist/core/terminal-write-auth.d.ts +15 -0
  111. package/dist/core/terminal-write-auth.d.ts.map +1 -1
  112. package/dist/core/terminal-write-auth.js +20 -0
  113. package/dist/core/terminal-write-auth.js.map +1 -1
  114. package/dist/core/trigger-session.d.ts.map +1 -1
  115. package/dist/core/trigger-session.js +3 -2
  116. package/dist/core/trigger-session.js.map +1 -1
  117. package/dist/core/types.d.ts +33 -0
  118. package/dist/core/types.d.ts.map +1 -1
  119. package/dist/core/types.js.map +1 -1
  120. package/dist/core/worker-pool.d.ts +27 -8
  121. package/dist/core/worker-pool.d.ts.map +1 -1
  122. package/dist/core/worker-pool.js +389 -19
  123. package/dist/core/worker-pool.js.map +1 -1
  124. package/dist/daemon.d.ts +4 -2
  125. package/dist/daemon.d.ts.map +1 -1
  126. package/dist/daemon.js +397 -26
  127. package/dist/daemon.js.map +1 -1
  128. package/dist/dashboard/bot-onboarding.d.ts +127 -1
  129. package/dist/dashboard/bot-onboarding.d.ts.map +1 -1
  130. package/dist/dashboard/bot-onboarding.js +1104 -25
  131. package/dist/dashboard/bot-onboarding.js.map +1 -1
  132. package/dist/dashboard/debug-terminal.d.ts +18 -0
  133. package/dist/dashboard/debug-terminal.d.ts.map +1 -0
  134. package/dist/dashboard/debug-terminal.js +338 -0
  135. package/dist/dashboard/debug-terminal.js.map +1 -0
  136. package/dist/dashboard/public-redact.d.ts +2 -0
  137. package/dist/dashboard/public-redact.d.ts.map +1 -1
  138. package/dist/dashboard/public-redact.js +12 -3
  139. package/dist/dashboard/public-redact.js.map +1 -1
  140. package/dist/dashboard/registry.d.ts +8 -0
  141. package/dist/dashboard/registry.d.ts.map +1 -1
  142. package/dist/dashboard/registry.js +13 -0
  143. package/dist/dashboard/registry.js.map +1 -1
  144. package/dist/dashboard/settings-write-applier.d.ts +47 -1
  145. package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
  146. package/dist/dashboard/settings-write-applier.js +102 -1
  147. package/dist/dashboard/settings-write-applier.js.map +1 -1
  148. package/dist/dashboard/web/app.d.ts.map +1 -1
  149. package/dist/dashboard/web/app.js +2 -0
  150. package/dist/dashboard/web/app.js.map +1 -1
  151. package/dist/dashboard/web/bot-defaults-page.d.ts +1 -0
  152. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  153. package/dist/dashboard/web/bot-defaults-page.js +24 -5
  154. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  155. package/dist/dashboard/web/bot-defaults.d.ts +15 -0
  156. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  157. package/dist/dashboard/web/bot-defaults.js +19 -0
  158. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  159. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  160. package/dist/dashboard/web/i18n.js +76 -6
  161. package/dist/dashboard/web/i18n.js.map +1 -1
  162. package/dist/dashboard/web/icon-tooltip.d.ts +3 -0
  163. package/dist/dashboard/web/icon-tooltip.d.ts.map +1 -0
  164. package/dist/dashboard/web/icon-tooltip.js +108 -0
  165. package/dist/dashboard/web/icon-tooltip.js.map +1 -0
  166. package/dist/dashboard/web/sessions-kanban.js +2 -2
  167. package/dist/dashboard/web/sessions-kanban.js.map +1 -1
  168. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  169. package/dist/dashboard/web/sessions-page.js +48 -9
  170. package/dist/dashboard/web/sessions-page.js.map +1 -1
  171. package/dist/dashboard/web/sessions.d.ts +2 -0
  172. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  173. package/dist/dashboard/web/sessions.js +46 -2
  174. package/dist/dashboard/web/sessions.js.map +1 -1
  175. package/dist/dashboard/web/settings-page.d.ts +78 -0
  176. package/dist/dashboard/web/settings-page.d.ts.map +1 -1
  177. package/dist/dashboard/web/settings-page.js +88 -1
  178. package/dist/dashboard/web/settings-page.js.map +1 -1
  179. package/dist/dashboard/web/store.d.ts +2 -0
  180. package/dist/dashboard/web/store.d.ts.map +1 -1
  181. package/dist/dashboard/web/store.js +13 -1
  182. package/dist/dashboard/web/store.js.map +1 -1
  183. package/dist/dashboard-web/app.js +1 -1
  184. package/dist/dashboard-web/chunks/bot-defaults-page-O376WSER.js +5 -0
  185. package/dist/dashboard-web/chunks/{chunk-JNRSPKLS.js → chunk-EJDYUINZ.js} +1 -1
  186. package/dist/dashboard-web/chunks/{chunk-CUV65BIQ.js → chunk-H7P5UV4W.js} +6 -6
  187. package/dist/dashboard-web/chunks/{chunk-P6GO5KT4.js → chunk-QCPPKCX3.js} +1 -1
  188. package/dist/dashboard-web/chunks/{chunk-UBOLHQCK.js → chunk-WXPMNEYV.js} +1 -1
  189. package/dist/dashboard-web/chunks/{connectors-page-KTC2QWNW.js → connectors-page-IR54AOAI.js} +1 -1
  190. package/dist/dashboard-web/chunks/{groups-page-B3EKGOLY.js → groups-page-3P4TLUMF.js} +1 -1
  191. package/dist/dashboard-web/chunks/{insights-page-X6RXNCWX.js → insights-page-TDCZV56A.js} +1 -1
  192. package/dist/dashboard-web/chunks/{monitor-room-LLRA5LQW.js → monitor-room-QWCWL3SX.js} +1 -1
  193. package/dist/dashboard-web/chunks/{monitoring-page-7MT5FEOS.js → monitoring-page-IOWFSNVC.js} +1 -1
  194. package/dist/dashboard-web/chunks/{office-page-KD46D5TD.js → office-page-5AUPEHZP.js} +1 -1
  195. package/dist/dashboard-web/chunks/{overview-page-JUWNH32O.js → overview-page-QJMI6NLW.js} +1 -1
  196. package/dist/dashboard-web/chunks/{roles-page-P6F7NLOI.js → roles-page-WJVB52ET.js} +1 -1
  197. package/dist/dashboard-web/chunks/{schedules-page-XIHWZST4.js → schedules-page-RVRWHP4V.js} +1 -1
  198. package/dist/dashboard-web/chunks/sessions-page-KWLGBX6G.js +3 -0
  199. package/dist/dashboard-web/chunks/settings-page-TNZEPC7V.js +2 -0
  200. package/dist/dashboard-web/chunks/{skills-page-A46KVWBL.js → skills-page-7YT5TIX5.js} +1 -1
  201. package/dist/dashboard-web/chunks/{team-federation-page-SDUC56DF.js → team-federation-page-NJZKTU7A.js} +1 -1
  202. package/dist/dashboard-web/chunks/{v3-page-J5JOTUSQ.js → v3-page-NQ73UA2Q.js} +1 -1
  203. package/dist/dashboard-web/chunks/{whiteboards-page-U7LSOAWI.js → whiteboards-page-DSM3MDMN.js} +1 -1
  204. package/dist/dashboard-web/style.css +198 -7
  205. package/dist/dashboard.js +294 -7
  206. package/dist/dashboard.js.map +1 -1
  207. package/dist/features/codex-notifier/adoption.d.ts +11 -0
  208. package/dist/features/codex-notifier/adoption.d.ts.map +1 -0
  209. package/dist/features/codex-notifier/adoption.js +11 -0
  210. package/dist/features/codex-notifier/adoption.js.map +1 -0
  211. package/dist/features/codex-notifier/app-opener.d.ts +21 -0
  212. package/dist/features/codex-notifier/app-opener.d.ts.map +1 -0
  213. package/dist/features/codex-notifier/app-opener.js +33 -0
  214. package/dist/features/codex-notifier/app-opener.js.map +1 -0
  215. package/dist/features/codex-notifier/card-action.d.ts +27 -0
  216. package/dist/features/codex-notifier/card-action.d.ts.map +1 -0
  217. package/dist/features/codex-notifier/card-action.js +84 -0
  218. package/dist/features/codex-notifier/card-action.js.map +1 -0
  219. package/dist/features/codex-notifier/card.d.ts +12 -0
  220. package/dist/features/codex-notifier/card.d.ts.map +1 -0
  221. package/dist/features/codex-notifier/card.js +150 -0
  222. package/dist/features/codex-notifier/card.js.map +1 -0
  223. package/dist/features/codex-notifier/codex-context.d.ts +12 -0
  224. package/dist/features/codex-notifier/codex-context.d.ts.map +1 -0
  225. package/dist/features/codex-notifier/codex-context.js +161 -0
  226. package/dist/features/codex-notifier/codex-context.js.map +1 -0
  227. package/dist/features/codex-notifier/config.d.ts +9 -0
  228. package/dist/features/codex-notifier/config.d.ts.map +1 -0
  229. package/dist/features/codex-notifier/config.js +11 -0
  230. package/dist/features/codex-notifier/config.js.map +1 -0
  231. package/dist/features/codex-notifier/confirmed-turn.d.ts +20 -0
  232. package/dist/features/codex-notifier/confirmed-turn.d.ts.map +1 -0
  233. package/dist/features/codex-notifier/confirmed-turn.js +130 -0
  234. package/dist/features/codex-notifier/confirmed-turn.js.map +1 -0
  235. package/dist/features/codex-notifier/emitter.d.ts +13 -0
  236. package/dist/features/codex-notifier/emitter.d.ts.map +1 -0
  237. package/dist/features/codex-notifier/emitter.js +33 -0
  238. package/dist/features/codex-notifier/emitter.js.map +1 -0
  239. package/dist/features/codex-notifier/event-store.d.ts +29 -0
  240. package/dist/features/codex-notifier/event-store.d.ts.map +1 -0
  241. package/dist/features/codex-notifier/event-store.js +217 -0
  242. package/dist/features/codex-notifier/event-store.js.map +1 -0
  243. package/dist/features/codex-notifier/event.d.ts +46 -0
  244. package/dist/features/codex-notifier/event.d.ts.map +1 -0
  245. package/dist/features/codex-notifier/event.js +188 -0
  246. package/dist/features/codex-notifier/event.js.map +1 -0
  247. package/dist/features/codex-notifier/hook-cli.d.ts +23 -0
  248. package/dist/features/codex-notifier/hook-cli.d.ts.map +1 -0
  249. package/dist/features/codex-notifier/hook-cli.js +130 -0
  250. package/dist/features/codex-notifier/hook-cli.js.map +1 -0
  251. package/dist/features/codex-notifier/hook-installer.d.ts +19 -0
  252. package/dist/features/codex-notifier/hook-installer.d.ts.map +1 -0
  253. package/dist/features/codex-notifier/hook-installer.js +158 -0
  254. package/dist/features/codex-notifier/hook-installer.js.map +1 -0
  255. package/dist/features/codex-notifier/index.d.ts +21 -0
  256. package/dist/features/codex-notifier/index.d.ts.map +1 -0
  257. package/dist/features/codex-notifier/index.js +19 -0
  258. package/dist/features/codex-notifier/index.js.map +1 -0
  259. package/dist/features/codex-notifier/internal-turn.d.ts +5 -0
  260. package/dist/features/codex-notifier/internal-turn.d.ts.map +1 -0
  261. package/dist/features/codex-notifier/internal-turn.js +43 -0
  262. package/dist/features/codex-notifier/internal-turn.js.map +1 -0
  263. package/dist/features/codex-notifier/outbox-worker.d.ts +71 -0
  264. package/dist/features/codex-notifier/outbox-worker.d.ts.map +1 -0
  265. package/dist/features/codex-notifier/outbox-worker.js +290 -0
  266. package/dist/features/codex-notifier/outbox-worker.js.map +1 -0
  267. package/dist/features/codex-notifier/outbox.d.ts +25 -0
  268. package/dist/features/codex-notifier/outbox.d.ts.map +1 -0
  269. package/dist/features/codex-notifier/outbox.js +225 -0
  270. package/dist/features/codex-notifier/outbox.js.map +1 -0
  271. package/dist/features/codex-notifier/paths.d.ts +7 -0
  272. package/dist/features/codex-notifier/paths.d.ts.map +1 -0
  273. package/dist/features/codex-notifier/paths.js +20 -0
  274. package/dist/features/codex-notifier/paths.js.map +1 -0
  275. package/dist/features/codex-notifier/screen-lock.d.ts +12 -0
  276. package/dist/features/codex-notifier/screen-lock.d.ts.map +1 -0
  277. package/dist/features/codex-notifier/screen-lock.js +47 -0
  278. package/dist/features/codex-notifier/screen-lock.js.map +1 -0
  279. package/dist/features/codex-notifier/side-conversation-monitor.d.ts +102 -0
  280. package/dist/features/codex-notifier/side-conversation-monitor.d.ts.map +1 -0
  281. package/dist/features/codex-notifier/side-conversation-monitor.js +774 -0
  282. package/dist/features/codex-notifier/side-conversation-monitor.js.map +1 -0
  283. package/dist/features/codex-notifier/types.d.ts +42 -0
  284. package/dist/features/codex-notifier/types.d.ts.map +1 -0
  285. package/dist/features/codex-notifier/types.js +2 -0
  286. package/dist/features/codex-notifier/types.js.map +1 -0
  287. package/dist/features/codex-notifier/worker-lock.d.ts +8 -0
  288. package/dist/features/codex-notifier/worker-lock.d.ts.map +1 -0
  289. package/dist/features/codex-notifier/worker-lock.js +108 -0
  290. package/dist/features/codex-notifier/worker-lock.js.map +1 -0
  291. package/dist/global-config.d.ts +13 -0
  292. package/dist/global-config.d.ts.map +1 -1
  293. package/dist/global-config.js +30 -0
  294. package/dist/global-config.js.map +1 -1
  295. package/dist/im/lark/card-builder.js +2 -2
  296. package/dist/im/lark/card-builder.js.map +1 -1
  297. package/dist/im/lark/card-handler.d.ts +2 -0
  298. package/dist/im/lark/card-handler.d.ts.map +1 -1
  299. package/dist/im/lark/card-handler.js +165 -34
  300. package/dist/im/lark/card-handler.js.map +1 -1
  301. package/dist/im/lark/client.d.ts +62 -4
  302. package/dist/im/lark/client.d.ts.map +1 -1
  303. package/dist/im/lark/client.js +237 -29
  304. package/dist/im/lark/client.js.map +1 -1
  305. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  306. package/dist/im/lark/event-dispatcher.js +20 -30
  307. package/dist/im/lark/event-dispatcher.js.map +1 -1
  308. package/dist/services/codex-app-threads.d.ts +47 -0
  309. package/dist/services/codex-app-threads.d.ts.map +1 -1
  310. package/dist/services/codex-app-threads.js +502 -10
  311. package/dist/services/codex-app-threads.js.map +1 -1
  312. package/dist/services/exact-chat-grant.d.ts +83 -0
  313. package/dist/services/exact-chat-grant.d.ts.map +1 -0
  314. package/dist/services/exact-chat-grant.js +249 -0
  315. package/dist/services/exact-chat-grant.js.map +1 -0
  316. package/dist/services/group-creator.d.ts +11 -0
  317. package/dist/services/group-creator.d.ts.map +1 -1
  318. package/dist/services/group-creator.js +18 -9
  319. package/dist/services/group-creator.js.map +1 -1
  320. package/dist/services/local-cli-opener.d.ts.map +1 -1
  321. package/dist/services/local-cli-opener.js +10 -5
  322. package/dist/services/local-cli-opener.js.map +1 -1
  323. package/dist/services/traex-user-input.d.ts +19 -0
  324. package/dist/services/traex-user-input.d.ts.map +1 -0
  325. package/dist/services/traex-user-input.js +57 -0
  326. package/dist/services/traex-user-input.js.map +1 -0
  327. package/dist/setup/open-platform-automation.d.ts +24 -3
  328. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  329. package/dist/setup/open-platform-automation.js +45 -8
  330. package/dist/setup/open-platform-automation.js.map +1 -1
  331. package/dist/setup/verify-permissions.d.ts +18 -0
  332. package/dist/setup/verify-permissions.d.ts.map +1 -1
  333. package/dist/setup/verify-permissions.js +60 -0
  334. package/dist/setup/verify-permissions.js.map +1 -1
  335. package/dist/types.d.ts +70 -4
  336. package/dist/types.d.ts.map +1 -1
  337. package/dist/utils/stuck-detector.d.ts +45 -0
  338. package/dist/utils/stuck-detector.d.ts.map +1 -0
  339. package/dist/utils/stuck-detector.js +188 -0
  340. package/dist/utils/stuck-detector.js.map +1 -0
  341. package/dist/utils/stuck-key-guard.d.ts +75 -0
  342. package/dist/utils/stuck-key-guard.d.ts.map +1 -0
  343. package/dist/utils/stuck-key-guard.js +77 -0
  344. package/dist/utils/stuck-key-guard.js.map +1 -0
  345. package/dist/worker.js +705 -93
  346. package/dist/worker.js.map +1 -1
  347. package/package.json +1 -1
  348. package/dist/dashboard-web/chunks/bot-defaults-page-B5AY4B2J.js +0 -5
  349. package/dist/dashboard-web/chunks/sessions-page-2VECQ27T.js +0 -3
  350. package/dist/dashboard-web/chunks/settings-page-HMIUE2JA.js +0 -2
package/dist/worker.js CHANGED
@@ -15,11 +15,11 @@
15
15
  import { randomBytes } from 'node:crypto';
16
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';
@@ -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';
@@ -250,6 +257,238 @@ async function codexRolloutProbe(cliId, threadId, promptText, timeoutMs) {
250
257
  } while (Date.now() < deadline);
251
258
  return false;
252
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
+ }
253
492
  /** Stand up (or re-establish) the per-session codex app-server + botmux-owned
254
493
  * thread and point remote{WsUrl,ThreadId} at it, so the next spawnCli launches
255
494
  * `codex --remote <ws> resume <thread>` and input flows over JSON-RPC. Fully
@@ -276,7 +515,21 @@ async function engageCodexRpc(cfg) {
276
515
  const engineEnv = { ...redactChildEnv(process.env) };
277
516
  engineEnv.PATH = `${join(homedir(), '.botmux', 'bin')}:${engineEnv.PATH ?? ''}`;
278
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;
279
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;
280
533
  // The app-server owns model execution in RPC mode. Its MCP gateway child
281
534
  // must inherit the trusted host socket just like a native CLI process does.
282
535
  if (sessionMcpGatewayHost) {
@@ -295,6 +548,10 @@ async function engageCodexRpc(cfg) {
295
548
  engine = new CodexRpcEngine({
296
549
  cliBin, cwd: cfg.workingDir, env: engineEnv, sessionId: cfg.sessionId,
297
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,
298
555
  onDead: () => {
299
556
  if (codexRpcEngine === engine) {
300
557
  log('Codex RPC app-server died; replacing the tmux session and re-engaging the thread');
@@ -348,10 +605,13 @@ async function engageCodexRpc(cfg) {
348
605
  codexBridgeMarkPendingTurn(cfg.prompt, cfg.turnId);
349
606
  outcome = first; // 'accepted' | 'ambiguous' — both stay engaged, prompt never re-queued
350
607
  }
351
- persistCliSessionId(threadId);
352
608
  codexRpcEngine = engine;
353
609
  remoteWsUrl = engine.wsUrl;
354
610
  remoteThreadId = threadId;
611
+ persistCliSessionId(threadId);
612
+ if (!wantResume && cfg.prompt && outcome === 'accepted') {
613
+ void syncFreshCodexNativeSessionTitle(threadId, engine);
614
+ }
355
615
  log(`Codex RPC input engaged (${outcome}${wantResume ? '/resume' : '/fresh'}): app-server ${engine.wsUrl} thread ${threadId}`);
356
616
  return outcome;
357
617
  }
@@ -739,7 +999,12 @@ const authedClients = new WeakSet();
739
999
  const clientPtys = new Map();
740
1000
  /** Managed-Herdr viewers survive an in-worker /restart while backend changes. */
741
1001
  const herdrWebBindings = new Map();
742
- 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');
743
1008
  // Standalone/test fallback. Production replaces this after init with a stable
744
1009
  // per-session HMAC derived from the host-only dashboard secret.
745
1010
  let viewToken = randomBytes(32).toString('base64url');
@@ -754,6 +1019,15 @@ function refreshTerminalViewToken() {
754
1019
  if (secret && sessionId)
755
1020
  viewToken = deriveTerminalViewToken(secret, sessionId);
756
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
+ }
757
1031
  /**
758
1032
  * Resolve terminal write permission for one request. The platform-injected
759
1033
  * `X-Botmux-Role` header is trusted only when this machine is bound to a central
@@ -790,6 +1064,10 @@ function ensureZellijAttachConfig() {
790
1064
  }
791
1065
  let sessionId = '';
792
1066
  let lastInitConfig = null;
1067
+ /** Dashboard「复现命令」:session 冷启时最终交给 backend.spawn 的真实调用
1068
+ * (bin + argv + cwd + 关键 env)。原样保留,worker `ready` 时随消息上报给 daemon
1069
+ * 持久化。仅有写权限的 dashboard 视图可见。 */
1070
+ let capturedSpawnCommand = null;
793
1071
  let deferredTopicOutputTail = '';
794
1072
  const reportedDeferredTopicRoots = new Set();
795
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' };
@@ -1073,6 +1351,21 @@ const pendingRawInputs = [];
1073
1351
  * administrative TUI command: never type-ahead while the agent is busy, never
1074
1352
  * open a model turn, and latest-wins if several renames arrive before idle. */
1075
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
+ }
1076
1369
  /** True after the rename command's Enter lands and until the TUI returns to its
1077
1370
  * prompt. Blocks type-ahead user messages from racing into the command UI. */
1078
1371
  let sessionRenameInFlight = false;
@@ -3734,6 +4027,7 @@ function parkCrashDiagnosticTerminal(code, signal) {
3734
4027
  // both when the next message respawns the CLI.
3735
4028
  stopScreenUpdates();
3736
4029
  stopScreenAnalyzer();
4030
+ stopStuckDetector();
3737
4031
  log(`Crash diagnostic tmux session parked at ${TmuxBackend.diagnosticSessionName(sessionId)}`);
3738
4032
  return true;
3739
4033
  }
@@ -3849,6 +4143,68 @@ function stopScreenAnalyzer() {
3849
4143
  screenAnalyzer = null;
3850
4144
  tuiPromptBlocking = false;
3851
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
+ }
3852
4208
  // ─── Screenshot Capture (PNG → Feishu image_key) ────────────────────────────
3853
4209
  const SCREENSHOT_INTERVAL_MS = 10_000;
3854
4210
  const POST_ACTION_DELAY_MS = 1_000;
@@ -4096,24 +4452,32 @@ const KEY_TO_ANSI = {
4096
4452
  * Execute an AI-provided key sequence with delays between each key.
4097
4453
  * @param keys — key names like ["Down","Down","Space","Up","Up"]
4098
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).
4099
4457
  */
4100
4458
  async function handleTuiKeys(keys, isFinal) {
4101
4459
  if (!backend || keys.length === 0)
4102
- return;
4103
- if ('sendSpecialKeys' in backend) {
4104
- const b = backend;
4105
- // Send each key individually with 100ms delay for TUI state processing
4106
- for (const key of keys) {
4107
- b.sendSpecialKeys(key);
4108
- 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
+ }
4109
4469
  }
4110
- }
4111
- else {
4112
- for (const key of keys) {
4113
- backend.write(KEY_TO_ANSI[key] ?? key);
4114
- 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
+ }
4115
4475
  }
4116
4476
  }
4477
+ catch (e) {
4478
+ logError(`handleTuiKeys write failed: ${e?.message ?? e}`);
4479
+ return false;
4480
+ }
4117
4481
  if (isFinal) {
4118
4482
  tuiPromptBlocking = false;
4119
4483
  if (isPromptReady) {
@@ -4123,6 +4487,7 @@ async function handleTuiKeys(keys, isFinal) {
4123
4487
  screenAnalyzer?.notifySelection('final');
4124
4488
  }
4125
4489
  log(`TUI keys: ${keys.join(' ')}${isFinal ? ' (final)' : ''}`);
4490
+ return true;
4126
4491
  }
4127
4492
  // 待注入的 TUI 命令队列。生命周期绑定当前 CLI 进程:killCli() 会清空它,
4128
4493
  // 防止 restart 后残留命令重放进新 CLI——新增清理状态时记得同步那里。
@@ -4630,6 +4995,7 @@ function markPromptReady() {
4630
4995
  // worker-local replay of the durable attempt.
4631
4996
  if (!durableTurnInFlight)
4632
4997
  inflightInputs.onTurnComplete();
4998
+ stuckDetector?.disarm();
4633
4999
  maybeEmitWorkflowTranscriptOutput();
4634
5000
  if (awaitingFirstPrompt) {
4635
5001
  awaitingFirstPrompt = false;
@@ -4814,6 +5180,7 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
4814
5180
  persistCliSessionId(cliSessionId);
4815
5181
  if (codexBridgeFallbackActive())
4816
5182
  codexBridgeNotifyCliSessionId(cliSessionId);
5183
+ void syncFreshCodexNativeSessionTitle(cliSessionId, codexRpcEngine);
4817
5184
  }
4818
5185
  log(`Deferred recheck found submit in ${transcriptLabel} — suppressing warning. preview="${preview}"`);
4819
5186
  return;
@@ -5098,8 +5465,10 @@ async function flushPending() {
5098
5465
  // auto-re-queued: a lost/late ack surfaces as a submit-failure the user can
5099
5466
  // resend manually, and the stable clientUserMessageId lets codex dedupe a
5100
5467
  // resend (Codex delta P1-1).
5101
- if (!codexRpcEngine)
5468
+ if (!codexRpcEngine) {
5102
5469
  inflightInputs.onWrite(item);
5470
+ stuckDetector?.arm();
5471
+ }
5103
5472
  const msg = item.content;
5104
5473
  const logicalMsg = item.logicalContent ?? msg;
5105
5474
  currentBotmuxTurnId = item.turnId;
@@ -5197,6 +5566,13 @@ async function flushPending() {
5197
5566
  if (codexBridgeActive)
5198
5567
  codexBridgeNotifyCliSessionId(result.cliSessionId);
5199
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
+ }
5200
5576
  // `&& backend`: if the CLI exited during this write (pane gone → onExit
5201
5577
  // nulled backend) the user already got a "CLI exited" notice; don't also
5202
5578
  // nag that the submit wasn't confirmed.
@@ -5222,7 +5598,7 @@ async function flushPending() {
5222
5598
  }
5223
5599
  function sendToPty(content, turnId, opts = {}) {
5224
5600
  if (!cliAdapter)
5225
- return;
5601
+ return false;
5226
5602
  const next = {
5227
5603
  content,
5228
5604
  turnId,
@@ -5239,7 +5615,7 @@ function sendToPty(content, turnId, opts = {}) {
5239
5615
  if (cliRestartInProgress || !backend) {
5240
5616
  pendingMessages.push(next);
5241
5617
  log(`Queued message while CLI backend is restarting (${pendingMessages.length} pending)`);
5242
- return;
5618
+ return true;
5243
5619
  }
5244
5620
  const supportsTypeAhead = pendingInputAllowsTypeAhead(cliAdapter.supportsTypeAhead === true, durableTurnInFlight, next);
5245
5621
  const shouldMergeQueued = opts.dispatchAttempt === undefined && !durableTurnInFlight
@@ -5296,6 +5672,7 @@ function sendToPty(content, turnId, opts = {}) {
5296
5672
  log(`Queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — ${cliName()} ${awaitingFirstPrompt ? 'still booting' : 'is busy'}`);
5297
5673
  scheduleBusyPatternIdleProbe(`${cliName()} queued-message`);
5298
5674
  }
5675
+ return true;
5299
5676
  }
5300
5677
  // ─── Screen Update Timer ─────────────────────────────────────────────────────
5301
5678
  function startScreenUpdates() {
@@ -5733,6 +6110,7 @@ async function spawnCli(cfg, opts = {}) {
5733
6110
  });
5734
6111
  effectiveBackendType = 'herdr';
5735
6112
  backend = herdrBe;
6113
+ cliLifetimeNonce++;
5736
6114
  // Same as tmux/zellij adopt: writeInput (grok preferSessionId via
5737
6115
  // findGrokSessionByPid, claude pid-state) needs cliPid/cliCwd on the
5738
6116
  // PtyHandle. spawn() overwrites cliCwd from opts.cwd — use adoptCwd when
@@ -5791,6 +6169,7 @@ async function spawnCli(cfg, opts = {}) {
5791
6169
  : new TmuxPipeBackend(cfg.adoptTmuxTarget, { cliPid: cfg.adoptCliPid });
5792
6170
  effectiveBackendType = cfg.adoptZellijPaneId ? 'zellij' : 'tmux';
5793
6171
  backend = observeBe;
6172
+ cliLifetimeNonce++;
5794
6173
  // writeInput (grok concurrent prompt_history binding, claude pid-state)
5795
6174
  // reads these fields off the PtyHandle — constructor only stores
5796
6175
  // watchCliPid for liveness, so surface them explicitly for adopt.
@@ -5970,11 +6349,6 @@ async function spawnCli(cfg, opts = {}) {
5970
6349
  }
5971
6350
  }
5972
6351
  }
5973
- const selectedBackend = selectSessionBackend({ sessionId: cfg.sessionId, backendType: effectiveBackend, backendConfig: riffBackendConfig });
5974
- isTmuxMode = selectedBackend.isTmuxMode;
5975
- isPipeMode = selectedBackend.isPipeMode;
5976
- isZellijMode = selectedBackend.isZellijMode;
5977
- backend = selectedBackend.backend;
5978
6352
  const adapterSessionId = cfg.resume
5979
6353
  ? (cfg.originalSessionId ?? cfg.sessionId)
5980
6354
  : cfg.sessionId;
@@ -6110,6 +6484,31 @@ async function spawnCli(cfg, opts = {}) {
6110
6484
  currentCliCredentialIsolated = appliedIsolationCapabilities.includes('credential');
6111
6485
  const isolationRuntimeDataDir = process.env.SESSION_DATA_DIR
6112
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++;
6113
6512
  // Every bot — isolated OR not — gets its own BOT_HOME dir as a ready-made private-
6114
6513
  // storage slot. An isolated sibling denies this path regardless of whether the owner
6115
6514
  // is isolated (deny uses the full bots.json), so a non-isolated bot can drop private
@@ -6175,13 +6574,7 @@ async function spawnCli(cfg, opts = {}) {
6175
6574
  // on EVERY restart, e.g. for a sandboxed session whose transcript lives in
6176
6575
  // an ephemeral overlay upper that the probe can't see). Computed here (not at
6177
6576
  // the spawn site below) so the pre-flight can short-circuit on it.
6178
- const persistentSessionName = effectiveBackendType === 'tmux'
6179
- ? TmuxBackend.sessionName(cfg.sessionId)
6180
- : effectiveBackendType === 'herdr'
6181
- ? HerdrBackend.sessionName(cfg.sessionId)
6182
- : effectiveBackendType === 'zellij'
6183
- ? ZellijBackend.sessionName(cfg.sessionId)
6184
- : undefined;
6577
+ let persistentSessionName = selectedBackend.persistentSessionName;
6185
6578
  // [read-isolation] Before we decide to reattach a persistent pane: a pane can
6186
6579
  // survive a daemon restart still running a CLI that may NOT be isolated (e.g.
6187
6580
  // spawned before isolation was enabled, or by an old build). Isolation is only
@@ -6192,11 +6585,10 @@ async function spawnCli(cfg, opts = {}) {
6192
6585
  // policy pane survives daemon restarts and suspend→resume safely because the
6193
6586
  // confinement remains attached to the live process.
6194
6587
  if (appliedIsolationCapabilities.length > 0 && persistentSessionName && effectiveBackendType !== 'pty') {
6195
- const paneLive = effectiveBackendType === 'tmux'
6196
- ? TmuxBackend.hasSession(persistentSessionName)
6197
- : effectiveBackendType === 'zellij'
6198
- ? ZellijBackend.hasSession(persistentSessionName)
6199
- : HerdrBackend.hasSession(persistentSessionName);
6588
+ const persistentTarget = selectedBackend.persistentBackendTarget;
6589
+ const paneLive = persistentTarget
6590
+ ? probePersistentBackendTarget(persistentTarget) === 'exists'
6591
+ : false;
6200
6592
  if (paneLive) {
6201
6593
  let marker = null;
6202
6594
  marker = readRegularHostFileNoFollow(join(isolationRuntimeDataDir, 'read-isolation', `${cfg.sessionId}.boot`));
@@ -6211,7 +6603,18 @@ async function spawnCli(cfg, opts = {}) {
6211
6603
  // obsolete permissions. Kill it before publishing any new capability.
6212
6604
  log(`[read-isolation] legacy/unmarked persistent pane for ${cfg.sessionId} — killing + cold-spawning with current policy`);
6213
6605
  try {
6214
- 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;
6215
6618
  }
6216
6619
  catch (e) {
6217
6620
  throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: could not kill stale persistent pane (${e.message})`);
@@ -6219,28 +6622,32 @@ async function spawnCli(cfg, opts = {}) {
6219
6622
  }
6220
6623
  }
6221
6624
  }
6222
- let mcpRuntimeManifest = readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir);
6625
+ let willReattachPersistent = selectedBackend.isReattach === true;
6223
6626
  if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length && persistentSessionName && effectiveBackendType !== 'pty') {
6224
- const paneLive = effectiveBackendType === 'tmux'
6225
- ? TmuxBackend.hasSession(persistentSessionName)
6226
- : effectiveBackendType === 'zellij'
6227
- ? ZellijBackend.hasSession(persistentSessionName)
6228
- : HerdrBackend.hasSession(persistentSessionName);
6627
+ const persistentTarget = selectedBackend.persistentBackendTarget;
6628
+ const paneLive = persistentTarget
6629
+ ? probePersistentBackendTarget(persistentTarget) === 'exists'
6630
+ : false;
6229
6631
  if (paneLive) {
6230
6632
  // The trusted Gateway host belongs to the worker and cannot survive a
6231
6633
  // worker/daemon replacement. Cold-resume the CLI so its MCP client gets a
6232
6634
  // fresh relay socket instead of reattaching to a dead connection.
6233
6635
  log(`[mcp-gateway] persistent pane ${cfg.sessionId} has plugin MCP state — cold-resuming with a fresh host`);
6234
- 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;
6235
6649
  }
6236
6650
  }
6237
- const willReattachPersistent = persistentSessionName
6238
- ? effectiveBackendType === 'tmux'
6239
- ? TmuxBackend.hasSession(persistentSessionName)
6240
- : effectiveBackendType === 'zellij'
6241
- ? ZellijBackend.hasSession(persistentSessionName)
6242
- : HerdrBackend.hasSession(persistentSessionName)
6243
- : false;
6244
6651
  // The plugin set is stable only for the lifetime of one real CLI process.
6245
6652
  // A warm worker reattach keeps the existing Gateway and catalog untouched;
6246
6653
  // every fresh/resumed CLI spawn atomically refreshes both from current Bot config.
@@ -6694,6 +7101,13 @@ async function spawnCli(cfg, opts = {}) {
6694
7101
  let spawnBin = cliAdapter.resolvedBin;
6695
7102
  let spawnArgs = args;
6696
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];
6697
7111
  // Read isolation (macOS): wrap the whole CLI process in a Seatbelt sandbox that
6698
7112
  // denies reads of the sensitive paths (blocklist). The CLI bypasses its OWN
6699
7113
  // sandbox (see adapter) so the outer wrapper is the sole enforcer. DARWIN ONLY —
@@ -7087,6 +7501,19 @@ async function spawnCli(cfg, opts = {}) {
7087
7501
  }
7088
7502
  }
7089
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
+ });
7090
7517
  // Mandatory credential-only confinement is the OUTERMOST launch wrapper so
7091
7518
  // wrapperCli and every descendant it starts inherit the boundary. Full
7092
7519
  // Seatbelt/bwrap sessions were already wrapped above and never enter these
@@ -7224,6 +7651,32 @@ async function spawnCli(cfg, opts = {}) {
7224
7651
  log(`[device-credential-isolation] wrapping ${cliAdapter.id} in credential-only bwrap `
7225
7652
  + `(${hideDirectories.size} authority dir(s), ${hideFiles.size} exact file(s))`);
7226
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
+ }
7227
7680
  backend.spawn(spawnBin, spawnArgs, {
7228
7681
  cwd: spawnCwd,
7229
7682
  cols: PTY_COLS,
@@ -7232,6 +7685,13 @@ async function spawnCli(cfg, opts = {}) {
7232
7685
  injectEnv: perBotInjectKeys.length ? perBotInjectEnv : undefined,
7233
7686
  launchShell: lastInitConfig?.launchShell,
7234
7687
  });
7688
+ if (selectedBackend.createdHerdrSessionName) {
7689
+ send({
7690
+ type: 'user_notify',
7691
+ turnId: currentBotmuxTurnId,
7692
+ message: `已创建 Botmux 专属 Herdr 会话:\`${selectedBackend.createdHerdrSessionName}\``,
7693
+ });
7694
+ }
7235
7695
  // Write CLI PID marker so agent-facing subcommands (`botmux send`, etc.)
7236
7696
  // can verify they were spawned inside a botmux session by walking the
7237
7697
  // process tree and looking for a matching pid file in this directory.
@@ -7519,44 +7979,59 @@ async function spawnCli(cfg, opts = {}) {
7519
7979
  }, READY_SIGNAL_TIMEOUT_MS);
7520
7980
  readySignalTimer.unref?.();
7521
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
+ };
7522
8008
  // Set up idle detection. Riff (remote HTTP backend) has no PTY output and
7523
8009
  // is marked ready immediately after spawn (see below), so the idle detector
7524
8010
  // is unnecessary — and without a readyPattern it would fire on every
7525
8011
  // quiescence, repeatedly triggering markPromptReady() and duplicate cards.
7526
8012
  if (effectiveBackendType !== 'riff') {
7527
8013
  idleDetector = new IdleDetector(cliAdapter);
7528
- idleDetector.onIdle(async (evidenceSource) => {
8014
+ idleDetector.onIdle((evidenceSource) => {
7529
8015
  log('Prompt detected (idle)');
7530
- // Bridge drain MUST run before markPromptReady() — the latter calls
7531
- // flushPending() which can immediately fire the next queued message
7532
- // (type-ahead adapters), shifting bridgeQueue's notion of "current
7533
- // turn" before we've had a chance to emit the previous one.
7534
- if (bridgeJsonlPath) {
7535
- try {
7536
- bridgeDrainAndMaybeEmit();
7537
- }
7538
- catch (err) {
7539
- log(`Bridge emit error: ${err.message}`);
7540
- }
7541
- }
7542
- if (codexBridgeFallbackActive()) {
7543
- try {
7544
- codexBridgeDrainAndMaybeEmit();
7545
- }
7546
- catch (err) {
7547
- log(`Codex bridge emit error: ${err.message}`);
7548
- }
7549
- }
7550
- if (evidenceSource === 'screen') {
7551
- markPromptReadyFromPty();
7552
- }
7553
- else {
7554
- markPromptReady();
7555
- }
8016
+ drainBridgesThenMarkReady(evidenceSource);
7556
8017
  });
7557
8018
  }
7558
8019
  backend.onData(onPtyData);
7559
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
+ }
7560
8035
  backend.onAccessUrl?.((url) => {
7561
8036
  send({
7562
8037
  type: 'riff_access_url',
@@ -7744,6 +8219,7 @@ async function spawnCli(cfg, opts = {}) {
7744
8219
  }
7745
8220
  function killCli(opts = {}) {
7746
8221
  currentCliCredentialIsolated = false;
8222
+ stopNativeSessionTitleSync();
7747
8223
  stopSessionMcpGatewayHost();
7748
8224
  stopCodexRpcEngine();
7749
8225
  if (!opts.preservePending)
@@ -7767,6 +8243,7 @@ function killCli(opts = {}) {
7767
8243
  readyPatternSeenDuringHold = false;
7768
8244
  awaitingPostSessionStartPromptEvidence = false;
7769
8245
  stopScreenAnalyzer();
8246
+ stopStuckDetector();
7770
8247
  stopScreenUpdates();
7771
8248
  backend?.kill();
7772
8249
  backend = null;
@@ -7883,6 +8360,7 @@ async function restartCliProcess(reason, opts = {}) {
7883
8360
  if (lastInitConfig) {
7884
8361
  startScreenUpdates();
7885
8362
  startScreenAnalyzer();
8363
+ startStuckDetector();
7886
8364
  try {
7887
8365
  const restartCfg = { ...lastInitConfig, resume: true, prompt: '', cliSessionId: rpcThreadId ?? lastInitConfig.cliSessionId };
7888
8366
  // Re-engage RPC so the new --remote pane binds to the CURRENT app-server
@@ -7896,6 +8374,7 @@ async function restartCliProcess(reason, opts = {}) {
7896
8374
  await engageCodexRpc(restartCfg);
7897
8375
  }
7898
8376
  await spawnCli(restartCfg, { pluginGenerationPrepared: rpcPluginGenerationPrepared });
8377
+ await prepareCodexNativeTitleGeneration(restartCfg, codexRpcEngine);
7899
8378
  if (codexRpcEngine)
7900
8379
  armRpcStartupDialogDismiss();
7901
8380
  }
@@ -7960,8 +8439,24 @@ function startWebServer(host, preferredPort) {
7960
8439
  // normal buffer until resize causes a redraw. Preserve that mode so
7961
8440
  // scroll gestures continue to target the CLI's own transcript.
7962
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';
7963
8458
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
7964
- res.end(getTerminalHtml(hasWrite, platformReadonly, loginUrl, forceRemoteScroll));
8459
+ res.end(getTerminalHtml(hasWrite, platformReadonly, loginUrl, forceRemoteScroll, localTerminalBackend));
7965
8460
  });
7966
8461
  wss = new WebSocketServer({
7967
8462
  server: httpServer,
@@ -8258,7 +8753,7 @@ function startWebServer(host, preferredPort) {
8258
8753
  .then(resolve, reject);
8259
8754
  });
8260
8755
  }
8261
- function getTerminalHtml(hasWrite, platformReadonly = false, loginUrl = '', forceRemoteScroll = false) {
8756
+ function getTerminalHtml(hasWrite, platformReadonly = false, loginUrl = '', forceRemoteScroll = false, localTerminalBackend = false) {
8262
8757
  const label = sessionId.substring(0, 8);
8263
8758
  return `<!DOCTYPE html>
8264
8759
  <html>
@@ -8395,6 +8890,7 @@ if(isTouch){document.body.classList.add('touch');}
8395
8890
  var hasToken=${hasWrite};
8396
8891
  var platformReadonly=${platformReadonly};
8397
8892
  var remoteScroll=${forceRemoteScroll};
8893
+ var localTerminalBackend=${localTerminalBackend};
8398
8894
  if(!hasToken){
8399
8895
  if(platformReadonly){var _lb=document.getElementById('login-banner');_lb.classList.add('show');}
8400
8896
  else{var _rb=document.getElementById('readonly-banner');_rb.classList.add('show');_rb.addEventListener('click',function(){_rb.classList.remove('show')});}
@@ -8601,8 +9097,21 @@ window.addEventListener('resize',onViewportResize);
8601
9097
  // A high-resolution trackpad emits dozens of wheel events for one gesture, so
8602
9098
  // cap the whole continuous burst — not each browser event — then require an idle
8603
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.
8604
9113
  var _scrollAccum=0,_scrollBurstTicks=0,_scrollBurstDir=0,_scrollBurstT=0;
8605
- 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;
8606
9115
  function _endScrollBurst(){
8607
9116
  clearTimeout(_scrollBurstT);_scrollBurstT=0;
8608
9117
  _scrollAccum=0;_scrollBurstTicks=0;_scrollBurstDir=0;
@@ -9058,6 +9567,10 @@ function send(msg) {
9058
9567
  }
9059
9568
  process.send?.(payload);
9060
9569
  }
9570
+ function acknowledgeTurnInputCommitted(turnId) {
9571
+ if (turnId)
9572
+ send({ type: 'turn_input_committed', turnId });
9573
+ }
9061
9574
  function publishLocalProcessAttestation(cliPid) {
9062
9575
  const cliProcStart = cliPid ? readProcessStartIdentity(cliPid) : undefined;
9063
9576
  send({
@@ -9115,11 +9628,13 @@ process.on('message', async (raw) => {
9115
9628
  const msg = raw;
9116
9629
  switch (msg.type) {
9117
9630
  case 'init': {
9631
+ const initStartedAtMs = Date.now();
9118
9632
  if (lastInitConfig)
9119
9633
  return; // already initialized
9120
9634
  lastInitConfig = msg;
9121
9635
  sessionId = msg.sessionId;
9122
9636
  refreshTerminalViewToken();
9637
+ refreshTerminalWriteToken();
9123
9638
  if (msg.ownerOpenId)
9124
9639
  process.env.__OWNER_OPEN_ID = msg.ownerOpenId;
9125
9640
  // Pin this worker's i18n locale early so every t() call below resolves
@@ -9163,6 +9678,7 @@ process.on('message', async (raw) => {
9163
9678
  port = await startWebServer(config.web.workerHost, msg.webPort);
9164
9679
  startScreenUpdates();
9165
9680
  startScreenAnalyzer();
9681
+ startStuckDetector();
9166
9682
  }
9167
9683
  else {
9168
9684
  // Workflow attempts still expose a read-only web terminal so the
@@ -9206,6 +9722,7 @@ process.on('message', async (raw) => {
9206
9722
  throw new Error('codex RPC resume: could not replace stale --remote pane; aborting init');
9207
9723
  }
9208
9724
  await spawnCli(msg, { pluginGenerationPrepared: rpcPluginGenerationPrepared });
9725
+ await prepareCodexNativeTitleGeneration(msg, codexRpcEngine);
9209
9726
  if (codexRpcEngine)
9210
9727
  armRpcStartupDialogDismiss(); // boundary #4: keep the --remote pane from freezing on a startup dialog
9211
9728
  // Queue the initial prompt — flushed when CLI shows idle.
@@ -9237,6 +9754,7 @@ process.on('message', async (raw) => {
9237
9754
  // execution — exactly-once, Codex P1-1). Ambiguous never reaches here.
9238
9755
  // - RPC RESUME: queuePrompt=true → queue for post-ready flush (bridge
9239
9756
  // marked at flush time, P0-1).
9757
+ let initialInputCommitted = false;
9240
9758
  if (shouldQueueInitialPrompt({
9241
9759
  hasPrompt: !!msg.prompt,
9242
9760
  rpcEngineActive: !!codexRpcEngine,
@@ -9254,13 +9772,22 @@ process.on('message', async (raw) => {
9254
9772
  vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
9255
9773
  codexAppInput: msg.promptCodexAppInput,
9256
9774
  });
9775
+ initialInputCommitted = true;
9776
+ }
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;
9257
9781
  }
9258
- // Riff (remote HTTP backends): spawnCli already marked the prompt ready
9259
- // (no local boot → isPromptReady=true immediately), but the initial prompt
9260
- // was queued AFTER spawnCli returned, so flushPending() ran on an empty
9261
- // queue. Flush now that the prompt is enqueued. isPromptReady is already
9262
- // true so the flush gates all pass.
9263
- if (effectiveBackendType === 'riff' && pendingMessages.length > 0) {
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) {
9264
9791
  flushPending();
9265
9792
  }
9266
9793
  send({
@@ -9268,6 +9795,11 @@ process.on('message', async (raw) => {
9268
9795
  port,
9269
9796
  token: writeToken,
9270
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),
9271
9803
  turnId: currentBotmuxTurnId,
9272
9804
  dispatchAttempt: currentBotmuxDispatchAttempt,
9273
9805
  });
@@ -9282,6 +9814,21 @@ process.on('message', async (raw) => {
9282
9814
  // Mark new turn baseline so the streaming card only shows this turn's content
9283
9815
  renderer?.markNewTurn();
9284
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
+ }
9285
9832
  // Cancel any active tmux copy-mode scroll so user input reaches the CLI.
9286
9833
  if (tmuxScrolledHalfPages > 0)
9287
9834
  exitTmuxScrollMode();
@@ -9299,12 +9846,16 @@ process.on('message', async (raw) => {
9299
9846
  log('Message received after crash-loop stop; retrying CLI start');
9300
9847
  destroyCrashDiagnosticTerminal('retry after message');
9301
9848
  stopScreenAnalyzer();
9849
+ stopStuckDetector();
9302
9850
  stopScreenUpdates();
9303
9851
  awaitingFirstPrompt = true;
9304
9852
  startScreenUpdates();
9305
9853
  startScreenAnalyzer();
9854
+ startStuckDetector();
9306
9855
  try {
9307
- await spawnCli({ ...lastInitConfig, resume: true, prompt: '' });
9856
+ const restartCfg = { ...lastInitConfig, resume: true, prompt: '' };
9857
+ await spawnCli(restartCfg);
9858
+ await prepareCodexNativeTitleGeneration(restartCfg, codexRpcEngine);
9308
9859
  }
9309
9860
  catch (err) {
9310
9861
  // Pass the message's own attempt (not the stale currentBotmux* from a
@@ -9386,6 +9937,9 @@ process.on('message', async (raw) => {
9386
9937
  if (result && result.submitted === false) {
9387
9938
  scheduleSubmitFailureNotify(content, result.recheck, 'submit history', undefined, result.failureReason, turnSeq);
9388
9939
  }
9940
+ else {
9941
+ acknowledgeTurnInputCommitted(msg.turnId);
9942
+ }
9389
9943
  }
9390
9944
  catch (err) {
9391
9945
  log(`Adopt writeInput error (${lastInitConfig?.cliId}): ${err.message}`);
@@ -9403,9 +9957,11 @@ process.on('message', async (raw) => {
9403
9957
  // command (raw_input) fix.
9404
9958
  await new Promise(r => setTimeout(r, 200));
9405
9959
  backend.sendSpecialKeys('Enter');
9960
+ acknowledgeTurnInputCommitted(msg.turnId);
9406
9961
  }
9407
9962
  else {
9408
9963
  backend.write(content + '\r');
9964
+ acknowledgeTurnInputCommitted(msg.turnId);
9409
9965
  }
9410
9966
  isPromptReady = false;
9411
9967
  idleDetector?.reset();
@@ -9417,11 +9973,13 @@ process.on('message', async (raw) => {
9417
9973
  // arrival. Marking now would race with a still-running previous
9418
9974
  // turn whose `botmux send` could sneak its sentAtMs past this
9419
9975
  // turn's markTimeMs and falsely suppress its fallback.
9420
- sendToPty(content, msg.turnId, {
9976
+ const inputCommitted = sendToPty(content, msg.turnId, {
9421
9977
  codexAppInput,
9422
9978
  dispatchAttempt: msg.dispatchAttempt,
9423
9979
  vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
9424
9980
  });
9981
+ if (inputCommitted)
9982
+ acknowledgeTurnInputCommitted(msg.turnId);
9425
9983
  }
9426
9984
  break;
9427
9985
  }
@@ -9449,6 +10007,13 @@ process.on('message', async (raw) => {
9449
10007
  // IPC handlers are concurrent with async init, so queue first even when
9450
10008
  // the adapter/backend has not finished initializing. flushPending will
9451
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();
9452
10017
  pendingSessionRename = msg.title;
9453
10018
  log(`Queued native session rename: ${msg.title}`);
9454
10019
  void flushPending();
@@ -9545,7 +10110,49 @@ process.on('message', async (raw) => {
9545
10110
  break;
9546
10111
  }
9547
10112
  case 'tui_keys': {
9548
- 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();
9549
10156
  break;
9550
10157
  }
9551
10158
  case 'inject_command': {
@@ -9710,6 +10317,7 @@ process.on('message', async (raw) => {
9710
10317
  });
9711
10318
  // ─── Cleanup ─────────────────────────────────────────────────────────────────
9712
10319
  function cleanup() {
10320
+ stopNativeSessionTitleSync();
9713
10321
  cleanupPiInitialPromptFiles();
9714
10322
  stopSessionMcpGatewayHost();
9715
10323
  if (tmuxRestartTimer) {
@@ -9814,7 +10422,11 @@ function teardownSandboxBestEffort() {
9814
10422
  // and process.exit(1), killing a live session over a dropped log write. Install
9815
10423
  // the guard before any further stdout writes (log() writes to process.stdout).
9816
10424
  installStdioEpipeGuard();
9817
- process.on('exit', () => { teardownSandboxBestEffort(); stopCodexRpcEngine(); });
10425
+ process.on('exit', () => {
10426
+ stopNativeSessionTitleSync();
10427
+ teardownSandboxBestEffort();
10428
+ stopCodexRpcEngine();
10429
+ });
9818
10430
  process.on('uncaughtException', (err) => {
9819
10431
  // A broken pipe on stdout/stderr (or any socket) must not tear down a live
9820
10432
  // session — the stdio guard handles those it can; this is the backstop.