botmux 3.4.3 → 3.5.2

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 (415) 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/sandbox.d.ts +99 -279
  12. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  13. package/dist/adapters/backend/sandbox.js +424 -826
  14. package/dist/adapters/backend/sandbox.js.map +1 -1
  15. package/dist/adapters/backend/session-backend-selector.d.ts +10 -1
  16. package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -1
  17. package/dist/adapters/backend/session-backend-selector.js +73 -4
  18. package/dist/adapters/backend/session-backend-selector.js.map +1 -1
  19. package/dist/adapters/backend/types.d.ts +16 -0
  20. package/dist/adapters/backend/types.d.ts.map +1 -1
  21. package/dist/adapters/backend/types.js.map +1 -1
  22. package/dist/adapters/cli/codex.d.ts.map +1 -1
  23. package/dist/adapters/cli/codex.js +4 -0
  24. package/dist/adapters/cli/codex.js.map +1 -1
  25. package/dist/adapters/cli/fs-policy.d.ts +244 -0
  26. package/dist/adapters/cli/fs-policy.d.ts.map +1 -0
  27. package/dist/adapters/cli/fs-policy.js +539 -0
  28. package/dist/adapters/cli/fs-policy.js.map +1 -0
  29. package/dist/adapters/cli/pi-initial-prompt.d.ts.map +1 -1
  30. package/dist/adapters/cli/pi-initial-prompt.js +5 -1
  31. package/dist/adapters/cli/pi-initial-prompt.js.map +1 -1
  32. package/dist/adapters/cli/read-isolation.d.ts +14 -200
  33. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  34. package/dist/adapters/cli/read-isolation.js +18 -477
  35. package/dist/adapters/cli/read-isolation.js.map +1 -1
  36. package/dist/adapters/cli/traex.d.ts.map +1 -1
  37. package/dist/adapters/cli/traex.js +5 -0
  38. package/dist/adapters/cli/traex.js.map +1 -1
  39. package/dist/adapters/cli/types.d.ts +3 -3
  40. package/dist/adapters/cli/types.d.ts.map +1 -1
  41. package/dist/adapters/hook-installer.d.ts +7 -0
  42. package/dist/adapters/hook-installer.d.ts.map +1 -1
  43. package/dist/adapters/hook-installer.js +56 -0
  44. package/dist/adapters/hook-installer.js.map +1 -1
  45. package/dist/bot-registry.d.ts +39 -23
  46. package/dist/bot-registry.d.ts.map +1 -1
  47. package/dist/bot-registry.js +134 -2
  48. package/dist/bot-registry.js.map +1 -1
  49. package/dist/cli/capabilities.d.ts +24 -0
  50. package/dist/cli/capabilities.d.ts.map +1 -0
  51. package/dist/cli/capabilities.js +25 -0
  52. package/dist/cli/capabilities.js.map +1 -0
  53. package/dist/cli/create-group-resolver.d.ts +24 -0
  54. package/dist/cli/create-group-resolver.d.ts.map +1 -1
  55. package/dist/cli/create-group-resolver.js +23 -0
  56. package/dist/cli/create-group-resolver.js.map +1 -1
  57. package/dist/cli/daemon-lifecycle-env.d.ts +5 -7
  58. package/dist/cli/daemon-lifecycle-env.d.ts.map +1 -1
  59. package/dist/cli/daemon-lifecycle-env.js +13 -7
  60. package/dist/cli/daemon-lifecycle-env.js.map +1 -1
  61. package/dist/cli/exact-chat-grant-client.d.ts +48 -0
  62. package/dist/cli/exact-chat-grant-client.d.ts.map +1 -0
  63. package/dist/cli/exact-chat-grant-client.js +108 -0
  64. package/dist/cli/exact-chat-grant-client.js.map +1 -0
  65. package/dist/cli/exact-chat-grant.d.ts +18 -0
  66. package/dist/cli/exact-chat-grant.d.ts.map +1 -0
  67. package/dist/cli/exact-chat-grant.js +97 -0
  68. package/dist/cli/exact-chat-grant.js.map +1 -0
  69. package/dist/cli.d.ts +10 -1
  70. package/dist/cli.d.ts.map +1 -1
  71. package/dist/cli.js +795 -144
  72. package/dist/cli.js.map +1 -1
  73. package/dist/codex-rpc-engine.d.ts +26 -0
  74. package/dist/codex-rpc-engine.d.ts.map +1 -1
  75. package/dist/codex-rpc-engine.js +113 -2
  76. package/dist/codex-rpc-engine.js.map +1 -1
  77. package/dist/config.d.ts +16 -0
  78. package/dist/config.d.ts.map +1 -1
  79. package/dist/config.js +16 -0
  80. package/dist/config.js.map +1 -1
  81. package/dist/core/bot-live-control.d.ts +41 -0
  82. package/dist/core/bot-live-control.d.ts.map +1 -0
  83. package/dist/core/bot-live-control.js +135 -0
  84. package/dist/core/bot-live-control.js.map +1 -0
  85. package/dist/core/command-handler.d.ts.map +1 -1
  86. package/dist/core/command-handler.js +44 -66
  87. package/dist/core/command-handler.js.map +1 -1
  88. package/dist/core/daemon-config-fence.d.ts +8 -0
  89. package/dist/core/daemon-config-fence.d.ts.map +1 -0
  90. package/dist/core/daemon-config-fence.js +13 -0
  91. package/dist/core/daemon-config-fence.js.map +1 -0
  92. package/dist/core/dashboard-events.d.ts +5 -0
  93. package/dist/core/dashboard-events.d.ts.map +1 -1
  94. package/dist/core/dashboard-events.js.map +1 -1
  95. package/dist/core/dashboard-ipc-server.d.ts +21 -2
  96. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  97. package/dist/core/dashboard-ipc-server.js +258 -32
  98. package/dist/core/dashboard-ipc-server.js.map +1 -1
  99. package/dist/core/dispatch-registry.d.ts +12 -0
  100. package/dist/core/dispatch-registry.d.ts.map +1 -0
  101. package/dist/core/dispatch-registry.js +33 -0
  102. package/dist/core/dispatch-registry.js.map +1 -0
  103. package/dist/core/dispatch.d.ts +99 -0
  104. package/dist/core/dispatch.d.ts.map +1 -1
  105. package/dist/core/dispatch.js +136 -0
  106. package/dist/core/dispatch.js.map +1 -1
  107. package/dist/core/maintenance.d.ts.map +1 -1
  108. package/dist/core/maintenance.js +10 -3
  109. package/dist/core/maintenance.js.map +1 -1
  110. package/dist/core/passthrough-commands.d.ts.map +1 -1
  111. package/dist/core/passthrough-commands.js +1 -1
  112. package/dist/core/passthrough-commands.js.map +1 -1
  113. package/dist/core/persistent-backend.d.ts +16 -2
  114. package/dist/core/persistent-backend.d.ts.map +1 -1
  115. package/dist/core/persistent-backend.js +40 -0
  116. package/dist/core/persistent-backend.js.map +1 -1
  117. package/dist/core/plugins/mcp/gateway-installer.d.ts.map +1 -1
  118. package/dist/core/plugins/mcp/gateway-installer.js +14 -2
  119. package/dist/core/plugins/mcp/gateway-installer.js.map +1 -1
  120. package/dist/core/session-discovery.d.ts +8 -1
  121. package/dist/core/session-discovery.d.ts.map +1 -1
  122. package/dist/core/session-discovery.js +140 -8
  123. package/dist/core/session-discovery.js.map +1 -1
  124. package/dist/core/session-manager.d.ts.map +1 -1
  125. package/dist/core/session-manager.js +102 -68
  126. package/dist/core/session-manager.js.map +1 -1
  127. package/dist/core/session-title.d.ts +8 -0
  128. package/dist/core/session-title.d.ts.map +1 -1
  129. package/dist/core/session-title.js +54 -0
  130. package/dist/core/session-title.js.map +1 -1
  131. package/dist/core/terminal-write-auth.d.ts +15 -0
  132. package/dist/core/terminal-write-auth.d.ts.map +1 -1
  133. package/dist/core/terminal-write-auth.js +20 -0
  134. package/dist/core/terminal-write-auth.js.map +1 -1
  135. package/dist/core/trigger-session.d.ts.map +1 -1
  136. package/dist/core/trigger-session.js +3 -2
  137. package/dist/core/trigger-session.js.map +1 -1
  138. package/dist/core/types.d.ts +33 -0
  139. package/dist/core/types.d.ts.map +1 -1
  140. package/dist/core/types.js.map +1 -1
  141. package/dist/core/worker-pool.d.ts +51 -8
  142. package/dist/core/worker-pool.d.ts.map +1 -1
  143. package/dist/core/worker-pool.js +439 -25
  144. package/dist/core/worker-pool.js.map +1 -1
  145. package/dist/daemon.d.ts +4 -2
  146. package/dist/daemon.d.ts.map +1 -1
  147. package/dist/daemon.js +403 -26
  148. package/dist/daemon.js.map +1 -1
  149. package/dist/dashboard/bot-onboarding.d.ts +127 -1
  150. package/dist/dashboard/bot-onboarding.d.ts.map +1 -1
  151. package/dist/dashboard/bot-onboarding.js +1104 -25
  152. package/dist/dashboard/bot-onboarding.js.map +1 -1
  153. package/dist/dashboard/bot-payload.d.ts +5 -0
  154. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  155. package/dist/dashboard/bot-payload.js +7 -0
  156. package/dist/dashboard/bot-payload.js.map +1 -1
  157. package/dist/dashboard/debug-terminal.d.ts +18 -0
  158. package/dist/dashboard/debug-terminal.d.ts.map +1 -0
  159. package/dist/dashboard/debug-terminal.js +338 -0
  160. package/dist/dashboard/debug-terminal.js.map +1 -0
  161. package/dist/dashboard/public-redact.d.ts +2 -0
  162. package/dist/dashboard/public-redact.d.ts.map +1 -1
  163. package/dist/dashboard/public-redact.js +12 -3
  164. package/dist/dashboard/public-redact.js.map +1 -1
  165. package/dist/dashboard/registry.d.ts +8 -0
  166. package/dist/dashboard/registry.d.ts.map +1 -1
  167. package/dist/dashboard/registry.js +13 -0
  168. package/dist/dashboard/registry.js.map +1 -1
  169. package/dist/dashboard/settings-write-applier.d.ts +47 -1
  170. package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
  171. package/dist/dashboard/settings-write-applier.js +102 -1
  172. package/dist/dashboard/settings-write-applier.js.map +1 -1
  173. package/dist/dashboard/web/app.d.ts.map +1 -1
  174. package/dist/dashboard/web/app.js +2 -0
  175. package/dist/dashboard/web/app.js.map +1 -1
  176. package/dist/dashboard/web/bot-defaults-page.d.ts +17 -0
  177. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  178. package/dist/dashboard/web/bot-defaults-page.js +249 -11
  179. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  180. package/dist/dashboard/web/bot-defaults.d.ts +22 -0
  181. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  182. package/dist/dashboard/web/bot-defaults.js +19 -0
  183. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  184. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  185. package/dist/dashboard/web/i18n.js +114 -28
  186. package/dist/dashboard/web/i18n.js.map +1 -1
  187. package/dist/dashboard/web/icon-tooltip.d.ts +3 -0
  188. package/dist/dashboard/web/icon-tooltip.d.ts.map +1 -0
  189. package/dist/dashboard/web/icon-tooltip.js +108 -0
  190. package/dist/dashboard/web/icon-tooltip.js.map +1 -0
  191. package/dist/dashboard/web/sessions-kanban.js +2 -2
  192. package/dist/dashboard/web/sessions-kanban.js.map +1 -1
  193. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  194. package/dist/dashboard/web/sessions-page.js +49 -53
  195. package/dist/dashboard/web/sessions-page.js.map +1 -1
  196. package/dist/dashboard/web/sessions.d.ts +2 -0
  197. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  198. package/dist/dashboard/web/sessions.js +46 -2
  199. package/dist/dashboard/web/sessions.js.map +1 -1
  200. package/dist/dashboard/web/settings-page.d.ts +78 -0
  201. package/dist/dashboard/web/settings-page.d.ts.map +1 -1
  202. package/dist/dashboard/web/settings-page.js +88 -1
  203. package/dist/dashboard/web/settings-page.js.map +1 -1
  204. package/dist/dashboard/web/store.d.ts +2 -0
  205. package/dist/dashboard/web/store.d.ts.map +1 -1
  206. package/dist/dashboard/web/store.js +13 -1
  207. package/dist/dashboard/web/store.js.map +1 -1
  208. package/dist/dashboard-web/app.js +1 -1
  209. package/dist/dashboard-web/chunks/bot-defaults-page-AUKV7P4L.js +10 -0
  210. package/dist/dashboard-web/chunks/{chunk-P6GO5KT4.js → chunk-KPQAUX7V.js} +1 -1
  211. package/dist/dashboard-web/chunks/{chunk-UBOLHQCK.js → chunk-NRL6CVAS.js} +1 -1
  212. package/dist/dashboard-web/chunks/{chunk-JNRSPKLS.js → chunk-R7IAKAYX.js} +1 -1
  213. package/dist/dashboard-web/chunks/{chunk-CUV65BIQ.js → chunk-X2L65IOM.js} +8 -8
  214. package/dist/dashboard-web/chunks/{connectors-page-KTC2QWNW.js → connectors-page-HDQFEXZI.js} +1 -1
  215. package/dist/dashboard-web/chunks/{groups-page-B3EKGOLY.js → groups-page-6DKXBYOM.js} +1 -1
  216. package/dist/dashboard-web/chunks/{insights-page-X6RXNCWX.js → insights-page-NWARTJ6J.js} +1 -1
  217. package/dist/dashboard-web/chunks/{monitor-room-LLRA5LQW.js → monitor-room-AGX3XRJX.js} +1 -1
  218. package/dist/dashboard-web/chunks/{monitoring-page-7MT5FEOS.js → monitoring-page-GGGIWN4C.js} +1 -1
  219. package/dist/dashboard-web/chunks/{office-page-KD46D5TD.js → office-page-6SIVKSK3.js} +1 -1
  220. package/dist/dashboard-web/chunks/{overview-page-JUWNH32O.js → overview-page-FVR3NKAF.js} +1 -1
  221. package/dist/dashboard-web/chunks/{roles-page-P6F7NLOI.js → roles-page-WE2YB7KN.js} +1 -1
  222. package/dist/dashboard-web/chunks/{schedules-page-XIHWZST4.js → schedules-page-TB3I24XN.js} +1 -1
  223. package/dist/dashboard-web/chunks/sessions-page-KOOOASIL.js +2 -0
  224. package/dist/dashboard-web/chunks/settings-page-IEBR7TGN.js +2 -0
  225. package/dist/dashboard-web/chunks/{skills-page-A46KVWBL.js → skills-page-3QAT2IWD.js} +1 -1
  226. package/dist/dashboard-web/chunks/{team-federation-page-SDUC56DF.js → team-federation-page-K4VVAVAK.js} +1 -1
  227. package/dist/dashboard-web/chunks/{v3-page-J5JOTUSQ.js → v3-page-V6IOJR2B.js} +1 -1
  228. package/dist/dashboard-web/chunks/{whiteboards-page-U7LSOAWI.js → whiteboards-page-V6CGKRNX.js} +1 -1
  229. package/dist/dashboard-web/style.css +218 -10
  230. package/dist/dashboard.js +380 -8
  231. package/dist/dashboard.js.map +1 -1
  232. package/dist/features/codex-notifier/adoption.d.ts +11 -0
  233. package/dist/features/codex-notifier/adoption.d.ts.map +1 -0
  234. package/dist/features/codex-notifier/adoption.js +11 -0
  235. package/dist/features/codex-notifier/adoption.js.map +1 -0
  236. package/dist/features/codex-notifier/app-opener.d.ts +21 -0
  237. package/dist/features/codex-notifier/app-opener.d.ts.map +1 -0
  238. package/dist/features/codex-notifier/app-opener.js +33 -0
  239. package/dist/features/codex-notifier/app-opener.js.map +1 -0
  240. package/dist/features/codex-notifier/card-action.d.ts +27 -0
  241. package/dist/features/codex-notifier/card-action.d.ts.map +1 -0
  242. package/dist/features/codex-notifier/card-action.js +84 -0
  243. package/dist/features/codex-notifier/card-action.js.map +1 -0
  244. package/dist/features/codex-notifier/card.d.ts +12 -0
  245. package/dist/features/codex-notifier/card.d.ts.map +1 -0
  246. package/dist/features/codex-notifier/card.js +150 -0
  247. package/dist/features/codex-notifier/card.js.map +1 -0
  248. package/dist/features/codex-notifier/codex-context.d.ts +12 -0
  249. package/dist/features/codex-notifier/codex-context.d.ts.map +1 -0
  250. package/dist/features/codex-notifier/codex-context.js +161 -0
  251. package/dist/features/codex-notifier/codex-context.js.map +1 -0
  252. package/dist/features/codex-notifier/config.d.ts +9 -0
  253. package/dist/features/codex-notifier/config.d.ts.map +1 -0
  254. package/dist/features/codex-notifier/config.js +11 -0
  255. package/dist/features/codex-notifier/config.js.map +1 -0
  256. package/dist/features/codex-notifier/confirmed-turn.d.ts +20 -0
  257. package/dist/features/codex-notifier/confirmed-turn.d.ts.map +1 -0
  258. package/dist/features/codex-notifier/confirmed-turn.js +130 -0
  259. package/dist/features/codex-notifier/confirmed-turn.js.map +1 -0
  260. package/dist/features/codex-notifier/emitter.d.ts +13 -0
  261. package/dist/features/codex-notifier/emitter.d.ts.map +1 -0
  262. package/dist/features/codex-notifier/emitter.js +33 -0
  263. package/dist/features/codex-notifier/emitter.js.map +1 -0
  264. package/dist/features/codex-notifier/event-store.d.ts +29 -0
  265. package/dist/features/codex-notifier/event-store.d.ts.map +1 -0
  266. package/dist/features/codex-notifier/event-store.js +217 -0
  267. package/dist/features/codex-notifier/event-store.js.map +1 -0
  268. package/dist/features/codex-notifier/event.d.ts +46 -0
  269. package/dist/features/codex-notifier/event.d.ts.map +1 -0
  270. package/dist/features/codex-notifier/event.js +188 -0
  271. package/dist/features/codex-notifier/event.js.map +1 -0
  272. package/dist/features/codex-notifier/hook-cli.d.ts +23 -0
  273. package/dist/features/codex-notifier/hook-cli.d.ts.map +1 -0
  274. package/dist/features/codex-notifier/hook-cli.js +130 -0
  275. package/dist/features/codex-notifier/hook-cli.js.map +1 -0
  276. package/dist/features/codex-notifier/hook-installer.d.ts +19 -0
  277. package/dist/features/codex-notifier/hook-installer.d.ts.map +1 -0
  278. package/dist/features/codex-notifier/hook-installer.js +158 -0
  279. package/dist/features/codex-notifier/hook-installer.js.map +1 -0
  280. package/dist/features/codex-notifier/index.d.ts +21 -0
  281. package/dist/features/codex-notifier/index.d.ts.map +1 -0
  282. package/dist/features/codex-notifier/index.js +19 -0
  283. package/dist/features/codex-notifier/index.js.map +1 -0
  284. package/dist/features/codex-notifier/internal-turn.d.ts +5 -0
  285. package/dist/features/codex-notifier/internal-turn.d.ts.map +1 -0
  286. package/dist/features/codex-notifier/internal-turn.js +43 -0
  287. package/dist/features/codex-notifier/internal-turn.js.map +1 -0
  288. package/dist/features/codex-notifier/outbox-worker.d.ts +71 -0
  289. package/dist/features/codex-notifier/outbox-worker.d.ts.map +1 -0
  290. package/dist/features/codex-notifier/outbox-worker.js +290 -0
  291. package/dist/features/codex-notifier/outbox-worker.js.map +1 -0
  292. package/dist/features/codex-notifier/outbox.d.ts +25 -0
  293. package/dist/features/codex-notifier/outbox.d.ts.map +1 -0
  294. package/dist/features/codex-notifier/outbox.js +225 -0
  295. package/dist/features/codex-notifier/outbox.js.map +1 -0
  296. package/dist/features/codex-notifier/paths.d.ts +7 -0
  297. package/dist/features/codex-notifier/paths.d.ts.map +1 -0
  298. package/dist/features/codex-notifier/paths.js +20 -0
  299. package/dist/features/codex-notifier/paths.js.map +1 -0
  300. package/dist/features/codex-notifier/screen-lock.d.ts +12 -0
  301. package/dist/features/codex-notifier/screen-lock.d.ts.map +1 -0
  302. package/dist/features/codex-notifier/screen-lock.js +47 -0
  303. package/dist/features/codex-notifier/screen-lock.js.map +1 -0
  304. package/dist/features/codex-notifier/side-conversation-monitor.d.ts +102 -0
  305. package/dist/features/codex-notifier/side-conversation-monitor.d.ts.map +1 -0
  306. package/dist/features/codex-notifier/side-conversation-monitor.js +774 -0
  307. package/dist/features/codex-notifier/side-conversation-monitor.js.map +1 -0
  308. package/dist/features/codex-notifier/types.d.ts +42 -0
  309. package/dist/features/codex-notifier/types.d.ts.map +1 -0
  310. package/dist/features/codex-notifier/types.js +2 -0
  311. package/dist/features/codex-notifier/types.js.map +1 -0
  312. package/dist/features/codex-notifier/worker-lock.d.ts +8 -0
  313. package/dist/features/codex-notifier/worker-lock.d.ts.map +1 -0
  314. package/dist/features/codex-notifier/worker-lock.js +108 -0
  315. package/dist/features/codex-notifier/worker-lock.js.map +1 -0
  316. package/dist/global-config.d.ts +13 -0
  317. package/dist/global-config.d.ts.map +1 -1
  318. package/dist/global-config.js +34 -1
  319. package/dist/global-config.js.map +1 -1
  320. package/dist/i18n/en.d.ts.map +1 -1
  321. package/dist/i18n/en.js +1 -19
  322. package/dist/i18n/en.js.map +1 -1
  323. package/dist/i18n/zh.d.ts.map +1 -1
  324. package/dist/i18n/zh.js +1 -19
  325. package/dist/i18n/zh.js.map +1 -1
  326. package/dist/im/lark/card-builder.d.ts +0 -13
  327. package/dist/im/lark/card-builder.d.ts.map +1 -1
  328. package/dist/im/lark/card-builder.js +2 -36
  329. package/dist/im/lark/card-builder.js.map +1 -1
  330. package/dist/im/lark/card-handler.d.ts +2 -0
  331. package/dist/im/lark/card-handler.d.ts.map +1 -1
  332. package/dist/im/lark/card-handler.js +166 -64
  333. package/dist/im/lark/card-handler.js.map +1 -1
  334. package/dist/im/lark/client.d.ts +98 -4
  335. package/dist/im/lark/client.d.ts.map +1 -1
  336. package/dist/im/lark/client.js +317 -29
  337. package/dist/im/lark/client.js.map +1 -1
  338. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  339. package/dist/im/lark/event-dispatcher.js +48 -33
  340. package/dist/im/lark/event-dispatcher.js.map +1 -1
  341. package/dist/services/codex-app-threads.d.ts +47 -0
  342. package/dist/services/codex-app-threads.d.ts.map +1 -1
  343. package/dist/services/codex-app-threads.js +502 -10
  344. package/dist/services/codex-app-threads.js.map +1 -1
  345. package/dist/services/exact-chat-grant.d.ts +83 -0
  346. package/dist/services/exact-chat-grant.d.ts.map +1 -0
  347. package/dist/services/exact-chat-grant.js +249 -0
  348. package/dist/services/exact-chat-grant.js.map +1 -0
  349. package/dist/services/group-creator.d.ts +11 -0
  350. package/dist/services/group-creator.d.ts.map +1 -1
  351. package/dist/services/group-creator.js +18 -9
  352. package/dist/services/group-creator.js.map +1 -1
  353. package/dist/services/local-cli-opener.d.ts.map +1 -1
  354. package/dist/services/local-cli-opener.js +10 -5
  355. package/dist/services/local-cli-opener.js.map +1 -1
  356. package/dist/services/sandbox-migration.d.ts +14 -0
  357. package/dist/services/sandbox-migration.d.ts.map +1 -0
  358. package/dist/services/sandbox-migration.js +85 -0
  359. package/dist/services/sandbox-migration.js.map +1 -0
  360. package/dist/services/sandbox-store.d.ts +28 -0
  361. package/dist/services/sandbox-store.d.ts.map +1 -1
  362. package/dist/services/sandbox-store.js +82 -0
  363. package/dist/services/sandbox-store.js.map +1 -1
  364. package/dist/services/traex-user-input.d.ts +19 -0
  365. package/dist/services/traex-user-input.d.ts.map +1 -0
  366. package/dist/services/traex-user-input.js +57 -0
  367. package/dist/services/traex-user-input.js.map +1 -0
  368. package/dist/setup/open-platform-automation.d.ts +24 -3
  369. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  370. package/dist/setup/open-platform-automation.js +45 -8
  371. package/dist/setup/open-platform-automation.js.map +1 -1
  372. package/dist/setup/verify-permissions.d.ts +18 -0
  373. package/dist/setup/verify-permissions.d.ts.map +1 -1
  374. package/dist/setup/verify-permissions.js +60 -0
  375. package/dist/setup/verify-permissions.js.map +1 -1
  376. package/dist/types.d.ts +83 -5
  377. package/dist/types.d.ts.map +1 -1
  378. package/dist/utils/child-env.d.ts +2 -2
  379. package/dist/utils/child-env.d.ts.map +1 -1
  380. package/dist/utils/child-env.js +14 -0
  381. package/dist/utils/child-env.js.map +1 -1
  382. package/dist/utils/stuck-detector.d.ts +45 -0
  383. package/dist/utils/stuck-detector.d.ts.map +1 -0
  384. package/dist/utils/stuck-detector.js +188 -0
  385. package/dist/utils/stuck-detector.js.map +1 -0
  386. package/dist/utils/stuck-key-guard.d.ts +75 -0
  387. package/dist/utils/stuck-key-guard.d.ts.map +1 -0
  388. package/dist/utils/stuck-key-guard.js +77 -0
  389. package/dist/utils/stuck-key-guard.js.map +1 -0
  390. package/dist/worker.js +1119 -588
  391. package/dist/worker.js.map +1 -1
  392. package/dist/workflows/events/payloads.d.ts +56 -0
  393. package/dist/workflows/events/payloads.d.ts.map +1 -1
  394. package/dist/workflows/events/payloads.js +9 -0
  395. package/dist/workflows/events/payloads.js.map +1 -1
  396. package/dist/workflows/events/schema.d.ts +86 -0
  397. package/dist/workflows/events/schema.d.ts.map +1 -1
  398. package/dist/workflows/shared/sandbox-policy.d.ts +10 -1
  399. package/dist/workflows/shared/sandbox-policy.d.ts.map +1 -1
  400. package/dist/workflows/shared/sandbox-policy.js +12 -0
  401. package/dist/workflows/shared/sandbox-policy.js.map +1 -1
  402. package/dist/workflows/v3/bot-resolve.d.ts.map +1 -1
  403. package/dist/workflows/v3/bot-resolve.js +36 -0
  404. package/dist/workflows/v3/bot-resolve.js.map +1 -1
  405. package/dist/workflows/v3/contract.d.ts +8 -0
  406. package/dist/workflows/v3/contract.d.ts.map +1 -1
  407. package/dist/workflows/v3/contract.js.map +1 -1
  408. package/package.json +1 -1
  409. package/dist/dashboard-web/chunks/bot-defaults-page-B5AY4B2J.js +0 -5
  410. package/dist/dashboard-web/chunks/sessions-page-2VECQ27T.js +0 -3
  411. package/dist/dashboard-web/chunks/settings-page-HMIUE2JA.js +0 -2
  412. package/dist/services/sandbox-land.d.ts +0 -45
  413. package/dist/services/sandbox-land.d.ts.map +0 -1
  414. package/dist/services/sandbox-land.js +0 -394
  415. package/dist/services/sandbox-land.js.map +0 -1
package/dist/worker.js CHANGED
@@ -15,11 +15,12 @@
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
- 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';
21
+ import { evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, isolationPaneMarkerContent, } from './adapters/cli/read-isolation.js';
22
+ import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields } from './adapters/cli/fs-policy.js';
23
+ import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget } from './core/persistent-backend.js';
23
24
  import { readProcessStartIdentity } from './core/session-marker.js';
24
25
  import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff } from './services/claude-transcript.js';
25
26
  import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
@@ -40,16 +41,19 @@ import { sessionMcpGatewayPathRegex, startSessionMcpGatewayHost, } from './core/
40
41
  import { MCP_GATEWAY_REQUIRED_ENV, MCP_GATEWAY_SOCKET_ENV, } from './core/plugins/mcp/environment.js';
41
42
  import { readSessionMcpRuntimeManifest, sessionMcpRuntimeHostOnlyPaths, } from './core/plugins/mcp/session-runtime.js';
42
43
  import { prepareCliPluginGeneration } from './core/plugins/cli-generation.js';
43
- import { loadBotConfigs } from './bot-registry.js';
44
+ import { loadBotConfigs, resolveBrandLabel } from './bot-registry.js';
44
45
  import { readGlobalConfig } from './global-config.js';
45
- import { deriveTerminalViewToken, resolveTerminalAccessForRequest, safeTerminalTokenEqual, } from './core/terminal-write-auth.js';
46
+ import { deriveTerminalViewToken, deriveTerminalWriteToken, resolveTerminalAccessForRequest, safeTerminalTokenEqual, } from './core/terminal-write-auth.js';
46
47
  import { readPlatformBinding } from './platform/binding.js';
47
48
  import { loadDashboardSecret, loadPersistedToken } from './dashboard/auth.js';
48
49
  import { InflightInputTracker } from './core/inflight-input-tracker.js';
49
50
  import { shouldRunQuietRotation, evaluatePidResolverPullback, decideFingerprintSwitch, shouldHealAbsentBaseline, sessionIdFromJsonlPath, SESSION_ID_FILENAME_RE, } from './services/bridge-rotation-policy.js';
50
51
  import { CodexBridgeQueue } from './services/codex-bridge-queue.js';
52
+ import { generateCodexAppThreadTitle, readCodexAppThreadMetadata, setCodexAppThreadName, } from './services/codex-app-threads.js';
53
+ import { buildBotmuxLarkNativeSessionTitle } from './core/session-title.js';
51
54
  import { drainCodexRollout, findCodexRolloutBySessionId, findCodexRolloutByPid, splitCodexEventsByCutoff, extractLastCodexTurn, codexSessionIdFromRolloutPath } from './services/codex-transcript.js';
52
55
  import { drainTraexRollout, findTraexRolloutBySessionId, findTraexRolloutByPid } from './services/traex-transcript.js';
56
+ import { parseTraexUserInputQuestions } from './services/traex-user-input.js';
53
57
  import { cocoEventsPathForSession, drainCocoEvents, findCocoSessionByPid } from './services/coco-transcript.js';
54
58
  import { currentHermesStateOffset, drainHermesStateDb, resolveHermesStateDbPath } from './services/hermes-transcript.js';
55
59
  import { filterHermesEventsForBotmuxSession } from './services/hermes-session-filter.js';
@@ -62,6 +66,7 @@ import { drainCursorTranscript, findCursorChatIdByPid, findCursorTranscriptByCha
62
66
  import { shouldObserveCursorChatId, shouldPersistObservedCursorChatId } from './services/cursor-resume-policy.js';
63
67
  import { extractKiroSessionIdFromOutput } from './services/kiro-session.js';
64
68
  import { baselineJsonlCursor } from './services/jsonl-cursor.js';
69
+ import { fileURLToPath } from 'node:url';
65
70
  import { createServer as createHttpServer } from 'node:http';
66
71
  import { WebSocketServer, WebSocket } from 'ws';
67
72
  import { listenWebTerminalWithFallback } from './utils/web-terminal-listen.js';
@@ -87,13 +92,16 @@ import { ZellijObserveBackend } from './adapters/backend/zellij-observe-backend.
87
92
  import { zellijEnv } from './setup/ensure-zellij.js';
88
93
  import { isObserveBackend } from './adapters/backend/types.js';
89
94
  import { selectSessionBackend, decideBackendGate, backendGateUserMessage } from './adapters/backend/session-backend-selector.js';
95
+ import { buildReproduceCommand, selectReproduceLaunch } from './adapters/backend/reproduce-command.js';
90
96
  import { deriveRiffReposFromDirs, deriveRiffRepoFromWorkingDir, isValidRiffBaseUrl, isValidRiffSandboxCluster, } from './adapters/backend/riff-backend.js';
91
- import { prepareSandbox, prepareCredentialOnlySandbox, credentialOnlySandboxAvailable, probeHostCredentialIsolationMechanism, attachSandboxOutbox, startOutboxWatcher, sandboxEnabled, sandboxedClaudeDataDir, localSandboxApplies, } from './adapters/backend/sandbox.js';
97
+ import { prepareDirectSandbox, prepareCredentialOnlySandbox, credentialOnlySandboxAvailable, probeHostCredentialIsolationMechanism, attachSandboxOutbox, startOutboxWatcher, sandboxEnabled, localSandboxApplies, } from './adapters/backend/sandbox.js';
92
98
  import { DEVICE_AUTHORITY_DIRECTORY, DEVICE_CREDENTIAL_FILE, } from './platform/device-paths.js';
93
99
  import { tmuxEnv, probeTmuxFunctionalWithRetry } from './setup/ensure-tmux.js';
94
100
  import { tmuxRestartJitterMs } from './core/tmux-recovery.js';
95
101
  import { IdleDetector } from './utils/idle-detector.js';
96
102
  import { ScreenAnalyzer } from './utils/screen-analyzer.js';
103
+ import { StuckDetector, matchHookReviewScreen } from './utils/stuck-detector.js';
104
+ import { processStuckWarningTuiKeys, shouldRearmStuckDetector } from './utils/stuck-key-guard.js';
97
105
  import { captureToPng } from './utils/screenshot-renderer.js';
98
106
  import { snapshotToPng, snapshotToText, shouldCaptureScreen, isScreenSelfDriven } from './utils/transient-snapshot.js';
99
107
  import { chooseWebTerminalSeed } from './utils/web-terminal-seed.js';
@@ -109,7 +117,8 @@ import * as pty from 'node-pty';
109
117
  import { createHash } from 'node:crypto';
110
118
  import { hasInstalledSessionReadyHook, installHook, } from './adapters/hook-installer.js';
111
119
  import { hookCommandFor } from './adapters/hook-command.js';
112
- import { parseDaemonIpcPort } from './utils/daemon-discovery.js';
120
+ import { findOnlineDaemon, parseDaemonIpcPort } from './utils/daemon-discovery.js';
121
+ import { fetchDaemonIpc } from './core/daemon-ipc-auth.js';
113
122
  import { withCodexAppContext } from './utils/codex-app-context.js';
114
123
  import { resolveCodexAppFinalTurnIdentity } from './adapters/cli/codex-app-turn.js';
115
124
  import { RunnerControlDecoder } from './adapters/cli/runner-control-channel.js';
@@ -250,6 +259,238 @@ async function codexRolloutProbe(cliId, threadId, promptText, timeoutMs) {
250
259
  } while (Date.now() < deadline);
251
260
  return false;
252
261
  }
262
+ function codexNativeTitleEnv(cfg) {
263
+ const env = {
264
+ ...redactChildEnv(process.env),
265
+ ...sanitizePerBotEnv(cfg.env),
266
+ };
267
+ env.PATH = `${join(homedir(), '.botmux', 'bin')}${delimiter}${env.PATH ?? ''}`;
268
+ return env;
269
+ }
270
+ function registerNativeTitleForceClose(forceClose) {
271
+ nativeSessionTitleSyncForceClosers.add(forceClose);
272
+ return () => nativeSessionTitleSyncForceClosers.delete(forceClose);
273
+ }
274
+ async function applyCodexNativeSessionTitle(threadId, title, cfg, engine, wait, expectedRevision) {
275
+ if (expectedRevision !== nativeSessionTitleRevision)
276
+ return false;
277
+ if (engine?.activeThreadId === threadId) {
278
+ if (wait === 'resume') {
279
+ await engine.waitForThreadUpdatedAfter(nativeSessionTitleResumeUpdatedAt ?? Math.floor(Date.now() / 1000), 10_000);
280
+ }
281
+ else if (wait === 'preview') {
282
+ await engine.waitForThreadPreview(10_000);
283
+ }
284
+ if (expectedRevision !== nativeSessionTitleRevision)
285
+ return false;
286
+ await engine.setThreadName(title);
287
+ return true;
288
+ }
289
+ const adapter = createCliAdapterSync('codex', cfg.cliPathOverride);
290
+ const abortController = new AbortController();
291
+ nativeSessionTitleSyncAbortControllers.add(abortController);
292
+ try {
293
+ await setCodexAppThreadName({
294
+ threadId,
295
+ name: title,
296
+ codexBin: adapter.resolvedBin,
297
+ cwd: cfg.workingDir,
298
+ env: codexNativeTitleEnv(cfg),
299
+ timeoutMs: 10_000,
300
+ signal: abortController.signal,
301
+ detached: true,
302
+ waitForExistingPreview: wait === 'preview',
303
+ ...(wait === 'resume' ? {
304
+ waitForUpdatedAfter: nativeSessionTitleResumeUpdatedAt ?? Math.floor(Date.now() / 1000),
305
+ } : {}),
306
+ registerForceClose: registerNativeTitleForceClose,
307
+ });
308
+ return expectedRevision === nativeSessionTitleRevision;
309
+ }
310
+ finally {
311
+ nativeSessionTitleSyncAbortControllers.delete(abortController);
312
+ }
313
+ }
314
+ /** 首条 UserMessage 落盘后先写 fallback,再用隔离的临时 turn 生成语义标题。 */
315
+ async function syncFreshCodexNativeSessionTitle(threadId, engine) {
316
+ const cfg = lastInitConfig;
317
+ const fallbackTitle = cfg?.nativeSessionTitle?.trim();
318
+ if (!cfg || cfg.cliId !== 'codex' || cfg.adoptMode || !fallbackTitle || !threadId)
319
+ return;
320
+ if (nativeSessionTitleAppliedThreadId === threadId || nativeSessionTitleSyncInFlight === threadId)
321
+ return;
322
+ const revision = nativeSessionTitleRevision;
323
+ const resumeGeneration = nativeSessionTitleCurrentGenerationResume;
324
+ let syncLatestTitle = false;
325
+ nativeSessionTitleSyncInFlight = threadId;
326
+ try {
327
+ const fallbackApplied = await applyCodexNativeSessionTitle(threadId, fallbackTitle, cfg, engine, resumeGeneration ? 'resume' : 'preview', revision);
328
+ if (!fallbackApplied || revision !== nativeSessionTitleRevision) {
329
+ syncLatestTitle = true;
330
+ return;
331
+ }
332
+ nativeSessionTitleAppliedThreadId = threadId;
333
+ log(`Applied native Codex session title: ${fallbackTitle}`);
334
+ const sourceText = cfg.nativeSessionTitlePrompt?.trim() ?? '';
335
+ cfg.nativeSessionTitlePrompt = undefined;
336
+ if (!sourceText)
337
+ return;
338
+ const adapter = createCliAdapterSync('codex', cfg.cliPathOverride);
339
+ const abortController = new AbortController();
340
+ nativeSessionTitleSyncAbortControllers.add(abortController);
341
+ let semanticCore;
342
+ try {
343
+ semanticCore = await generateCodexAppThreadTitle({
344
+ sourceText,
345
+ codexBin: adapter.resolvedBin,
346
+ env: codexNativeTitleEnv(cfg),
347
+ model: cfg.model,
348
+ timeoutMs: 30_000,
349
+ signal: abortController.signal,
350
+ detached: true,
351
+ registerForceClose: registerNativeTitleForceClose,
352
+ });
353
+ }
354
+ finally {
355
+ nativeSessionTitleSyncAbortControllers.delete(abortController);
356
+ }
357
+ if (revision !== nativeSessionTitleRevision) {
358
+ syncLatestTitle = true;
359
+ return;
360
+ }
361
+ if (!semanticCore) {
362
+ log('Native Codex semantic title generation unavailable; keeping fallback title');
363
+ return;
364
+ }
365
+ const semanticTitle = buildBotmuxLarkNativeSessionTitle(semanticCore);
366
+ if (semanticTitle === fallbackTitle)
367
+ return;
368
+ const semanticApplied = await applyCodexNativeSessionTitle(threadId, semanticTitle, cfg, engine, 'none', revision);
369
+ if (!semanticApplied || revision !== nativeSessionTitleRevision) {
370
+ syncLatestTitle = true;
371
+ return;
372
+ }
373
+ cfg.nativeSessionTitle = semanticTitle;
374
+ nativeSessionTitleAppliedThreadId = threadId;
375
+ send({ type: 'native_session_title_generated', title: semanticTitle });
376
+ log(`Applied generated native Codex session title: ${semanticTitle}`);
377
+ }
378
+ catch (err) {
379
+ if (revision !== nativeSessionTitleRevision)
380
+ syncLatestTitle = true;
381
+ else
382
+ log(`Native Codex session title sync failed: ${err?.message ?? err}`);
383
+ }
384
+ finally {
385
+ if (nativeSessionTitleSyncInFlight === threadId)
386
+ nativeSessionTitleSyncInFlight = undefined;
387
+ if (syncLatestTitle) {
388
+ void syncFreshCodexNativeSessionTitle(threadId, engine);
389
+ }
390
+ }
391
+ }
392
+ /** 在 resume 首条输入前记录 updatedAt,后续用其确认历史派生标题已完成回写。 */
393
+ async function captureCodexResumeTitleBaseline(threadId, engine) {
394
+ const cfg = lastInitConfig;
395
+ if (!cfg || cfg.cliId !== 'codex' || cfg.adoptMode || !cfg.nativeSessionTitle)
396
+ return;
397
+ nativeSessionTitleResumeUpdatedAt = Math.floor(Date.now() / 1000);
398
+ try {
399
+ if (engine?.activeThreadId === threadId) {
400
+ const metadata = await engine.readThreadMetadata(7000);
401
+ if (metadata.updatedAt !== undefined)
402
+ nativeSessionTitleResumeUpdatedAt = metadata.updatedAt;
403
+ return;
404
+ }
405
+ const adapter = createCliAdapterSync('codex', cfg.cliPathOverride);
406
+ const env = {
407
+ ...redactChildEnv(process.env),
408
+ ...sanitizePerBotEnv(cfg.env),
409
+ };
410
+ env.PATH = `${join(homedir(), '.botmux', 'bin')}${delimiter}${env.PATH ?? ''}`;
411
+ const abortController = new AbortController();
412
+ nativeSessionTitleSyncAbortControllers.add(abortController);
413
+ try {
414
+ const metadata = await readCodexAppThreadMetadata({
415
+ threadId,
416
+ codexBin: adapter.resolvedBin,
417
+ cwd: cfg.workingDir,
418
+ env,
419
+ timeoutMs: 7000,
420
+ signal: abortController.signal,
421
+ detached: true,
422
+ registerForceClose: registerNativeTitleForceClose,
423
+ });
424
+ if (metadata.updatedAt !== undefined)
425
+ nativeSessionTitleResumeUpdatedAt = metadata.updatedAt;
426
+ }
427
+ finally {
428
+ nativeSessionTitleSyncAbortControllers.delete(abortController);
429
+ }
430
+ }
431
+ catch (err) {
432
+ log(`Could not capture Codex resume title baseline: ${err?.message ?? err}`);
433
+ }
434
+ }
435
+ /** 每次 CLI spawn 后重建当前 generation 的标题同步状态。 */
436
+ async function prepareCodexNativeTitleGeneration(cfg, engine) {
437
+ if (cfg.cliId !== 'codex' || cfg.adoptMode || !cfg.nativeSessionTitle)
438
+ return;
439
+ nativeSessionTitleAppliedThreadId = undefined;
440
+ nativeSessionTitleResumeUpdatedAt = undefined;
441
+ nativeSessionTitleCurrentGenerationResume = lastSpawnEffectiveResume;
442
+ if (!lastSpawnEffectiveResume)
443
+ return;
444
+ const threadId = engine?.activeThreadId ?? lastSpawnEffectiveCliSessionId ?? cfg.cliSessionId;
445
+ if (threadId)
446
+ await captureCodexResumeTitleBaseline(threadId, engine);
447
+ }
448
+ /** Bridge TRAE app-server's native request_user_input request to botmux's
449
+ * existing Lark ask broker. The app-server owns tool execution in RPC mode, so
450
+ * returning this response resumes the same turn without terminal key driving. */
451
+ async function bridgeTraexUserInput(cfg, params) {
452
+ const parsed = parseTraexUserInputQuestions(params);
453
+ if (parsed.kind === 'unsupported') {
454
+ // Returning empty answers makes TraeX silently complete the tool as if no
455
+ // one answered, dropping the whole batch. Throw instead so the RPC engine
456
+ // replies with a JSON-RPC error and the failure is visible on the turn.
457
+ throw new Error(`requestUserInput cannot be represented as an ask card: ${parsed.reason}`);
458
+ }
459
+ const { questions } = parsed;
460
+ const daemon = findOnlineDaemon(cfg.larkAppId);
461
+ if (!daemon)
462
+ throw new Error(`daemon not found for larkAppId=${cfg.larkAppId}`);
463
+ const response = await fetchDaemonIpc(daemon.ipcPort, '/api/asks', {
464
+ method: 'POST',
465
+ headers: { 'content-type': 'application/json' },
466
+ body: JSON.stringify({
467
+ sessionId: cfg.sessionId,
468
+ chatId: cfg.chatId,
469
+ larkAppId: cfg.larkAppId,
470
+ rootMessageId: cfg.rootMessageId || null,
471
+ questions: questions.map(entry => entry.question),
472
+ timeoutMs: 3_600_000,
473
+ }),
474
+ });
475
+ if (!response.ok) {
476
+ throw new Error(`ask broker HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`);
477
+ }
478
+ const result = await response.json();
479
+ // Timeout/cancel/invalidated — surface as an error rather than an empty answer
480
+ // that TraeX would treat as "no one answered" and silently skip.
481
+ if (result.kind !== 'answered') {
482
+ throw new Error(`ask not answered (${result.kind ?? 'unknown'})`);
483
+ }
484
+ const customText = result.comment?.trim() ?? '';
485
+ const answers = {};
486
+ questions.forEach((entry, index) => {
487
+ const selected = result.answers?.[index] ?? [];
488
+ const values = selected.length > 0 ? [...selected] : customText ? [customText] : [];
489
+ if (values.length > 0)
490
+ answers[entry.id] = { answers: values };
491
+ });
492
+ return { answers };
493
+ }
253
494
  /** Stand up (or re-establish) the per-session codex app-server + botmux-owned
254
495
  * thread and point remote{WsUrl,ThreadId} at it, so the next spawnCli launches
255
496
  * `codex --remote <ws> resume <thread>` and input flows over JSON-RPC. Fully
@@ -276,7 +517,21 @@ async function engageCodexRpc(cfg) {
276
517
  const engineEnv = { ...redactChildEnv(process.env) };
277
518
  engineEnv.PATH = `${join(homedir(), '.botmux', 'bin')}:${engineEnv.PATH ?? ''}`;
278
519
  engineEnv.BOTMUX_SESSION_ID = cfg.sessionId;
520
+ // In Codex/TraeX RPC mode the app-server, not the remote viewer TUI, runs
521
+ // model shell tools. Give that process the same non-secret route binding as
522
+ // spawnCli so `botmux ask` can post into the current Lark thread.
523
+ engineEnv.BOTMUX_CHAT_ID = cfg.chatId;
524
+ if (cfg.chatType)
525
+ engineEnv.BOTMUX_CHAT_TYPE = cfg.chatType;
526
+ else
527
+ delete engineEnv.BOTMUX_CHAT_TYPE;
279
528
  engineEnv.BOTMUX_LARK_APP_ID = cfg.larkAppId;
529
+ engineEnv.BOTMUX_ROOT_MESSAGE_ID = cfg.rootMessageId;
530
+ engineEnv.BOTMUX_SESSION_SCOPE = cfg.rootMessageId?.startsWith('om_') ? 'thread' : 'chat';
531
+ if (cfg.ownerOpenId)
532
+ engineEnv.BOTMUX_OWNER_OPEN_ID = cfg.ownerOpenId;
533
+ else
534
+ delete engineEnv.BOTMUX_OWNER_OPEN_ID;
280
535
  // The app-server owns model execution in RPC mode. Its MCP gateway child
281
536
  // must inherit the trusted host socket just like a native CLI process does.
282
537
  if (sessionMcpGatewayHost) {
@@ -295,6 +550,10 @@ async function engageCodexRpc(cfg) {
295
550
  engine = new CodexRpcEngine({
296
551
  cliBin, cwd: cfg.workingDir, env: engineEnv, sessionId: cfg.sessionId,
297
552
  model: cfg.model, log: (m) => log(m),
553
+ appServerFeatures: cfg.cliId === 'traex' ? ['default_mode_request_user_input'] : undefined,
554
+ onRequestUserInput: cfg.cliId === 'traex'
555
+ ? (params) => bridgeTraexUserInput(cfg, params)
556
+ : undefined,
298
557
  onDead: () => {
299
558
  if (codexRpcEngine === engine) {
300
559
  log('Codex RPC app-server died; replacing the tmux session and re-engaging the thread');
@@ -348,10 +607,13 @@ async function engageCodexRpc(cfg) {
348
607
  codexBridgeMarkPendingTurn(cfg.prompt, cfg.turnId);
349
608
  outcome = first; // 'accepted' | 'ambiguous' — both stay engaged, prompt never re-queued
350
609
  }
351
- persistCliSessionId(threadId);
352
610
  codexRpcEngine = engine;
353
611
  remoteWsUrl = engine.wsUrl;
354
612
  remoteThreadId = threadId;
613
+ persistCliSessionId(threadId);
614
+ if (!wantResume && cfg.prompt && outcome === 'accepted') {
615
+ void syncFreshCodexNativeSessionTitle(threadId, engine);
616
+ }
355
617
  log(`Codex RPC input engaged (${outcome}${wantResume ? '/resume' : '/fresh'}): app-server ${engine.wsUrl} thread ${threadId}`);
356
618
  return outcome;
357
619
  }
@@ -739,7 +1001,12 @@ const authedClients = new WeakSet();
739
1001
  const clientPtys = new Map();
740
1002
  /** Managed-Herdr viewers survive an in-worker /restart while backend changes. */
741
1003
  const herdrWebBindings = new Map();
742
- const writeToken = randomBytes(16).toString('hex');
1004
+ // Standalone/test fallback. Production replaces this after init with a stable
1005
+ // per-session HMAC derived from the host-only dashboard secret, so an
1006
+ // already-issued 「操作链接」/write link survives a worker restart (a silent
1007
+ // daemon restart re-forks every worker — a per-process random token would 403
1008
+ // every previously-issued operate link).
1009
+ let writeToken = randomBytes(16).toString('hex');
743
1010
  // Standalone/test fallback. Production replaces this after init with a stable
744
1011
  // per-session HMAC derived from the host-only dashboard secret.
745
1012
  let viewToken = randomBytes(32).toString('base64url');
@@ -754,6 +1021,15 @@ function refreshTerminalViewToken() {
754
1021
  if (secret && sessionId)
755
1022
  viewToken = deriveTerminalViewToken(secret, sessionId);
756
1023
  }
1024
+ /** Re-derive the stable write (operate) token from the host-only dashboard
1025
+ * secret so a restarted worker mints the SAME token — keeping already-issued
1026
+ * 「操作链接」/write links valid across restarts. Falls back to the random
1027
+ * boot token when the secret is unavailable (standalone/test). */
1028
+ function refreshTerminalWriteToken() {
1029
+ const secret = loadDashboardSecret(DASHBOARD_SECRET_PATH);
1030
+ if (secret && sessionId)
1031
+ writeToken = deriveTerminalWriteToken(secret, sessionId);
1032
+ }
757
1033
  /**
758
1034
  * Resolve terminal write permission for one request. The platform-injected
759
1035
  * `X-Botmux-Role` header is trusted only when this machine is bound to a central
@@ -790,6 +1066,10 @@ function ensureZellijAttachConfig() {
790
1066
  }
791
1067
  let sessionId = '';
792
1068
  let lastInitConfig = null;
1069
+ /** Dashboard「复现命令」:session 冷启时最终交给 backend.spawn 的真实调用
1070
+ * (bin + argv + cwd + 关键 env)。原样保留,worker `ready` 时随消息上报给 daemon
1071
+ * 持久化。仅有写权限的 dashboard 视图可见。 */
1072
+ let capturedSpawnCommand = null;
793
1073
  let deferredTopicOutputTail = '';
794
1074
  const reportedDeferredTopicRoots = new Set();
795
1075
  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 +1353,21 @@ const pendingRawInputs = [];
1073
1353
  * administrative TUI command: never type-ahead while the agent is busy, never
1074
1354
  * open a model turn, and latest-wins if several renames arrive before idle. */
1075
1355
  let pendingSessionRename = null;
1356
+ let nativeSessionTitleSyncInFlight;
1357
+ let nativeSessionTitleAppliedThreadId;
1358
+ let nativeSessionTitleRevision = 0;
1359
+ let nativeSessionTitleResumeUpdatedAt;
1360
+ let nativeSessionTitleCurrentGenerationResume = false;
1361
+ const nativeSessionTitleSyncAbortControllers = new Set();
1362
+ const nativeSessionTitleSyncForceClosers = new Set();
1363
+ function stopNativeSessionTitleSync() {
1364
+ for (const abortController of nativeSessionTitleSyncAbortControllers)
1365
+ abortController.abort();
1366
+ nativeSessionTitleSyncAbortControllers.clear();
1367
+ for (const forceClose of nativeSessionTitleSyncForceClosers)
1368
+ forceClose();
1369
+ nativeSessionTitleSyncForceClosers.clear();
1370
+ }
1076
1371
  /** True after the rename command's Enter lands and until the TUI returns to its
1077
1372
  * prompt. Blocks type-ahead user messages from racing into the command UI. */
1078
1373
  let sessionRenameInFlight = false;
@@ -3734,6 +4029,7 @@ function parkCrashDiagnosticTerminal(code, signal) {
3734
4029
  // both when the next message respawns the CLI.
3735
4030
  stopScreenUpdates();
3736
4031
  stopScreenAnalyzer();
4032
+ stopStuckDetector();
3737
4033
  log(`Crash diagnostic tmux session parked at ${TmuxBackend.diagnosticSessionName(sessionId)}`);
3738
4034
  return true;
3739
4035
  }
@@ -3849,6 +4145,68 @@ function stopScreenAnalyzer() {
3849
4145
  screenAnalyzer = null;
3850
4146
  tuiPromptBlocking = false;
3851
4147
  }
4148
+ // ─── Stuck Detector (AI-free fallback for blocked CLI states) ───────────────
4149
+ let stuckDetector = null;
4150
+ // Monotonic counter bumped on every CLI/backend (re)start within this worker.
4151
+ // Paired with the backend object identity, it lets the worker verify that a
4152
+ // stuck-warning card click still targets the same CLI instance that was stuck —
4153
+ // a restart within the same Node worker must invalidate outstanding cards.
4154
+ let cliLifetimeNonce = 0;
4155
+ function startStuckDetector() {
4156
+ const sd = config.stuckDetector;
4157
+ if (!sd.enabled)
4158
+ return;
4159
+ stopStuckDetector();
4160
+ stuckDetector = new StuckDetector(sd.timeoutMs, {
4161
+ isActuallyStuck: () => {
4162
+ // Scope gate: this PR only handles the Codex PreToolUse hook-review
4163
+ // screen. Other CLIs (Claude Code, Gemini, ...) must never see the
4164
+ // Codex-specific t/Enter/Esc card, even if their output happens to
4165
+ // contain the same strings.
4166
+ if (lastInitConfig?.cliId !== 'codex')
4167
+ return false;
4168
+ // Only warn if the CLI is not at its idle prompt AND no TUI prompt card
4169
+ // is already posted. A long legitimate turn (model thinking, tool calls)
4170
+ // must not trigger this.
4171
+ if (isPromptReady)
4172
+ return false;
4173
+ if (tuiPromptBlocking)
4174
+ return false;
4175
+ // Anti-false-positive: if the PTY produced output recently the CLI is
4176
+ // still actively working (model streaming, tool output, spinner) — not
4177
+ // stuck. Require quiescence before firing.
4178
+ const sincePty = Date.now() - lastPtyActivityAtMs;
4179
+ if (sincePty < 15_000)
4180
+ return false;
4181
+ // Do NOT gate on durableTurnInFlight: the original hook-review incident
4182
+ // ran on a non-durable (ordinary IM) turn, and a durable turn's 20s
4183
+ // submit-recheck failure clears durableTurnInFlight before the 45s
4184
+ // detector fires. The PTY-quiescence + !isPromptReady combination is
4185
+ // sufficient to detect a genuinely stalled turn.
4186
+ return true;
4187
+ },
4188
+ onStuck: (elapsedMs, matchedLabel) => {
4189
+ // Prefer lastAnalyzerSnapshot (the capture-pane authoritative current
4190
+ // screen) over the long-lived renderer, which can drift under tmux.
4191
+ const snapshot = lastAnalyzerSnapshot || renderer?.rawSnapshot() || '';
4192
+ log(`StuckDetector: turn unresolved for ${Math.round(elapsedMs / 1000)}s${matchedLabel ? ` (${matchedLabel})` : ''}`);
4193
+ send({
4194
+ type: 'stuck_warning',
4195
+ elapsedMs,
4196
+ snapshot: snapshot.slice(-3000),
4197
+ matchedPattern: matchedLabel,
4198
+ turnId: currentBotmuxTurnId,
4199
+ dispatchAttempt: currentBotmuxDispatchAttempt,
4200
+ cliLifetime: cliLifetimeNonce,
4201
+ });
4202
+ },
4203
+ getSnapshot: () => lastAnalyzerSnapshot || renderer?.rawSnapshot() || '',
4204
+ });
4205
+ }
4206
+ function stopStuckDetector() {
4207
+ stuckDetector?.dispose();
4208
+ stuckDetector = null;
4209
+ }
3852
4210
  // ─── Screenshot Capture (PNG → Feishu image_key) ────────────────────────────
3853
4211
  const SCREENSHOT_INTERVAL_MS = 10_000;
3854
4212
  const POST_ACTION_DELAY_MS = 1_000;
@@ -4096,24 +4454,32 @@ const KEY_TO_ANSI = {
4096
4454
  * Execute an AI-provided key sequence with delays between each key.
4097
4455
  * @param keys — key names like ["Down","Down","Space","Up","Up"]
4098
4456
  * @param isFinal — if true, this action ends the prompt (clear blocking state)
4457
+ * @returns true if all keys were written successfully; false if backend is gone
4458
+ * or a write threw (caller must NOT send tui_keys_delivered on false).
4099
4459
  */
4100
4460
  async function handleTuiKeys(keys, isFinal) {
4101
4461
  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));
4462
+ return false;
4463
+ try {
4464
+ if ('sendSpecialKeys' in backend) {
4465
+ const b = backend;
4466
+ // Send each key individually with 100ms delay for TUI state processing
4467
+ for (const key of keys) {
4468
+ b.sendSpecialKeys(key);
4469
+ await new Promise(r => setTimeout(r, 100));
4470
+ }
4109
4471
  }
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));
4472
+ else {
4473
+ for (const key of keys) {
4474
+ backend.write(KEY_TO_ANSI[key] ?? key);
4475
+ await new Promise(r => setTimeout(r, 100));
4476
+ }
4115
4477
  }
4116
4478
  }
4479
+ catch (e) {
4480
+ logError(`handleTuiKeys write failed: ${e?.message ?? e}`);
4481
+ return false;
4482
+ }
4117
4483
  if (isFinal) {
4118
4484
  tuiPromptBlocking = false;
4119
4485
  if (isPromptReady) {
@@ -4123,6 +4489,7 @@ async function handleTuiKeys(keys, isFinal) {
4123
4489
  screenAnalyzer?.notifySelection('final');
4124
4490
  }
4125
4491
  log(`TUI keys: ${keys.join(' ')}${isFinal ? ' (final)' : ''}`);
4492
+ return true;
4126
4493
  }
4127
4494
  // 待注入的 TUI 命令队列。生命周期绑定当前 CLI 进程:killCli() 会清空它,
4128
4495
  // 防止 restart 后残留命令重放进新 CLI——新增清理状态时记得同步那里。
@@ -4630,6 +4997,7 @@ function markPromptReady() {
4630
4997
  // worker-local replay of the durable attempt.
4631
4998
  if (!durableTurnInFlight)
4632
4999
  inflightInputs.onTurnComplete();
5000
+ stuckDetector?.disarm();
4633
5001
  maybeEmitWorkflowTranscriptOutput();
4634
5002
  if (awaitingFirstPrompt) {
4635
5003
  awaitingFirstPrompt = false;
@@ -4814,6 +5182,7 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
4814
5182
  persistCliSessionId(cliSessionId);
4815
5183
  if (codexBridgeFallbackActive())
4816
5184
  codexBridgeNotifyCliSessionId(cliSessionId);
5185
+ void syncFreshCodexNativeSessionTitle(cliSessionId, codexRpcEngine);
4817
5186
  }
4818
5187
  log(`Deferred recheck found submit in ${transcriptLabel} — suppressing warning. preview="${preview}"`);
4819
5188
  return;
@@ -5098,8 +5467,10 @@ async function flushPending() {
5098
5467
  // auto-re-queued: a lost/late ack surfaces as a submit-failure the user can
5099
5468
  // resend manually, and the stable clientUserMessageId lets codex dedupe a
5100
5469
  // resend (Codex delta P1-1).
5101
- if (!codexRpcEngine)
5470
+ if (!codexRpcEngine) {
5102
5471
  inflightInputs.onWrite(item);
5472
+ stuckDetector?.arm();
5473
+ }
5103
5474
  const msg = item.content;
5104
5475
  const logicalMsg = item.logicalContent ?? msg;
5105
5476
  currentBotmuxTurnId = item.turnId;
@@ -5197,6 +5568,13 @@ async function flushPending() {
5197
5568
  if (codexBridgeActive)
5198
5569
  codexBridgeNotifyCliSessionId(result.cliSessionId);
5199
5570
  }
5571
+ if (lastInitConfig?.cliId === 'codex' && result?.submitted !== false) {
5572
+ const threadId = result?.cliSessionId
5573
+ ?? codexRpcEngine?.activeThreadId
5574
+ ?? lastInitConfig.cliSessionId;
5575
+ if (threadId)
5576
+ void syncFreshCodexNativeSessionTitle(threadId, codexRpcEngine);
5577
+ }
5200
5578
  // `&& backend`: if the CLI exited during this write (pane gone → onExit
5201
5579
  // nulled backend) the user already got a "CLI exited" notice; don't also
5202
5580
  // nag that the submit wasn't confirmed.
@@ -5222,7 +5600,7 @@ async function flushPending() {
5222
5600
  }
5223
5601
  function sendToPty(content, turnId, opts = {}) {
5224
5602
  if (!cliAdapter)
5225
- return;
5603
+ return false;
5226
5604
  const next = {
5227
5605
  content,
5228
5606
  turnId,
@@ -5239,7 +5617,7 @@ function sendToPty(content, turnId, opts = {}) {
5239
5617
  if (cliRestartInProgress || !backend) {
5240
5618
  pendingMessages.push(next);
5241
5619
  log(`Queued message while CLI backend is restarting (${pendingMessages.length} pending)`);
5242
- return;
5620
+ return true;
5243
5621
  }
5244
5622
  const supportsTypeAhead = pendingInputAllowsTypeAhead(cliAdapter.supportsTypeAhead === true, durableTurnInFlight, next);
5245
5623
  const shouldMergeQueued = opts.dispatchAttempt === undefined && !durableTurnInFlight
@@ -5296,6 +5674,7 @@ function sendToPty(content, turnId, opts = {}) {
5296
5674
  log(`Queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — ${cliName()} ${awaitingFirstPrompt ? 'still booting' : 'is busy'}`);
5297
5675
  scheduleBusyPatternIdleProbe(`${cliName()} queued-message`);
5298
5676
  }
5677
+ return true;
5299
5678
  }
5300
5679
  // ─── Screen Update Timer ─────────────────────────────────────────────────────
5301
5680
  function startScreenUpdates() {
@@ -5733,6 +6112,7 @@ async function spawnCli(cfg, opts = {}) {
5733
6112
  });
5734
6113
  effectiveBackendType = 'herdr';
5735
6114
  backend = herdrBe;
6115
+ cliLifetimeNonce++;
5736
6116
  // Same as tmux/zellij adopt: writeInput (grok preferSessionId via
5737
6117
  // findGrokSessionByPid, claude pid-state) needs cliPid/cliCwd on the
5738
6118
  // PtyHandle. spawn() overwrites cliCwd from opts.cwd — use adoptCwd when
@@ -5791,6 +6171,7 @@ async function spawnCli(cfg, opts = {}) {
5791
6171
  : new TmuxPipeBackend(cfg.adoptTmuxTarget, { cliPid: cfg.adoptCliPid });
5792
6172
  effectiveBackendType = cfg.adoptZellijPaneId ? 'zellij' : 'tmux';
5793
6173
  backend = observeBe;
6174
+ cliLifetimeNonce++;
5794
6175
  // writeInput (grok concurrent prompt_history binding, claude pid-state)
5795
6176
  // reads these fields off the PtyHandle — constructor only stores
5796
6177
  // watchCliPid for liveness, so surface them explicitly for adopt.
@@ -5970,11 +6351,6 @@ async function spawnCli(cfg, opts = {}) {
5970
6351
  }
5971
6352
  }
5972
6353
  }
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
6354
  const adapterSessionId = cfg.resume
5979
6355
  ? (cfg.originalSessionId ?? cfg.sessionId)
5980
6356
  : cfg.sessionId;
@@ -5990,81 +6366,20 @@ async function spawnCli(cfg, opts = {}) {
5990
6366
  // so a fork inherits the whole submit-confirm + bridge-fallback machinery.
5991
6367
  let claudeDataDir = cliAdapter.claudeDataDir;
5992
6368
  let effectiveReadyHookInstall = cliAdapter.hookInstall;
5993
- // When this session will be file-sandboxed, the CLI's session jsonl is written
5994
- // into the overlay's EPHEMERAL home upper (CLAUDE_CONFIG_DIR lives under $HOME),
5995
- // invisible at the real path the bridge normally watches → "Bridge mark expired"
5996
- // and the turn never relays (2026-06-10 incident). Redirect every jsonl/pid/
5997
- // bridge gate below to the upper copy where the sandboxed CLI actually writes.
5998
- const willFileSandbox = process.platform === 'linux' && // bwrap overlay is Linux-only; macOS uses the Seatbelt write-sandbox below
5999
- (cfg.sandbox === true || sandboxEnabled()) &&
6000
- (effectiveBackendType === 'pty' || effectiveBackendType === 'tmux') &&
6001
- !!process.env.SESSION_DATA_DIR;
6002
- if (claudeDataDir && willFileSandbox) {
6003
- const redirected = sandboxedClaudeDataDir(cfg.sessionId, claudeDataDir, {
6004
- sourceWorkingDir: cfg.workingDir,
6005
- dataDir: process.env.SESSION_DATA_DIR,
6006
- });
6007
- log(`[sandbox] redirecting Claude bridge dataDir → overlay upper: ${redirected}`);
6008
- claudeDataDir = redirected;
6009
- }
6010
- // v2 read isolation: relocate the CLI's data root into the per-bot BOT_HOME
6011
- // (`<BOTMUX_HOME>/bots/<appId>/{claude,codex}`) so each bot's transcripts/memory
6012
- // land in its OWN (Seatbelt-allowed) dir, NOT the shared/global ~/.claude|~/.codex
6013
- // (which v2 denies wholesale). Decided EARLY — like willFileSandbox above — so
6014
- // every JSONL/bridge/resume path below already targets the per-bot dir. The
6015
- // matching CLAUDE_CONFIG_DIR/CODEX_HOME env, per-bot provisioning and Seatbelt
6016
- // wrapper are applied at spawn time further down. This gate is the SINGLE
6017
- // decision point: configured-but-unenforceable fail-closes HERE (never run a
6018
- // session unisolated that asked for isolation).
6019
- // UNIFIED file sandbox: read isolation engages with the same toggle as write
6020
- // isolation. On darwin the legacy standalone `readIsolation` flag ALSO engages it
6021
- // (Seatbelt can read-deny without a write overlay). On Linux read isolation RIDES
6022
- // the bwrap sandbox (its masks go into the same bwrap plan), so it requires the
6023
- // sandbox to be on — hence the trigger is the sandbox toggle there, never the bare
6024
- // legacy flag (which would redirect the data dir but apply no masks → a hole).
6025
- // riff runs in its own REMOTE sandbox with no local CLI process — every
6026
- // local isolation flavor (Linux bwrap, macOS Seatbelt write-sandbox, read
6027
- // isolation incl. the legacy fail-closed flag) is meaningless for it and
6028
- // must be bypassed on ALL platforms, or a sandbox/readIsolation-enabled bot
6029
- // bricks the moment it switches to riff.
6030
- const riffRemoteBackend = effectiveBackendType === 'riff';
6369
+ // ── UNIFIED file sandbox (fs-policy, 2026-07-16 refactor) ──
6370
+ // ONE toggle, BOTH platforms, identical three-tier deny-by-default semantics
6371
+ // (adapters/cli/fs-policy.ts). Legacy `readIsolation` is auto-migrated to
6372
+ // `sandbox` at daemon startup; honored here too for an unmigrated read-only
6373
+ // BOTS_CONFIG. riff runs in its own REMOTE sandbox with no local CLI process
6374
+ // local confinement is meaningless there and must be bypassed on ALL
6375
+ // platforms, or a sandbox-enabled bot bricks the moment it switches to riff.
6376
+ const riffRemoteBackend = !localSandboxApplies(effectiveBackendType);
6031
6377
  if (riffRemoteBackend && (cfg.sandbox === true || cfg.readIsolation === true)) {
6032
- log('Sandbox/read-isolation flags set but backend is riff (remote sandbox, no local process) — local isolation bypassed');
6033
- }
6034
- const wantsFileSandbox = !riffRemoteBackend && (cfg.sandbox === true || sandboxEnabled());
6035
- // Legacy EXPLICIT read-isolation opt-in (macOS only Linux read-iso rides the
6036
- // bwrap sandbox, so it's driven solely by the sandbox toggle there). Keeps
6037
- // FAIL-CLOSED semantics: you asked for read isolation → refuse to start without it.
6038
- const explicitLegacyReadIso = process.platform === 'darwin' && cfg.readIsolation === true && !riffRemoteBackend;
6039
- const readIsolationGate = evaluateReadIsolationGate({
6040
- configured: wantsFileSandbox || explicitLegacyReadIso,
6041
- adapterSupports: cliAdapter.supportsReadIsolation === true,
6042
- wrapperCliSet: !!cfg.wrapperCli,
6043
- platform: process.platform,
6044
- sessionDataDirSet: !!process.env.SESSION_DATA_DIR,
6045
- });
6046
- // Fail-closed ONLY for the explicit legacy flag. Under the UNIFIED sandbox toggle
6047
- // read isolation is BEST-EFFORT: a CLI/platform that can't enforce the read-deny
6048
- // (e.g. a non-supporting adapter) still runs WRITE-isolated — it just doesn't get
6049
- // the read masks. Never brick a sandboxed session just because read-iso is N/A.
6050
- if (readIsolationGate.failClosedReason && explicitLegacyReadIso) {
6051
- throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ${readIsolationGate.failClosedReason}`);
6052
- }
6053
- const willReadIsolate = readIsolationGate.enabled;
6054
- // macOS FILE SANDBOX — the Seatbelt WRITE-isolation twin of the Linux bwrap
6055
- // overlay. On darwin, `sandbox: true` (or the global sandboxEnabled()) confines
6056
- // WRITES via the same `sandbox-exec` wrapper as read isolation instead of bwrap
6057
- // (Linux-only). Reads stay open, matching the Linux "read-all / write-isolated"
6058
- // model; if read isolation is ALSO on, its read-deny set layers into the SAME
6059
- // profile. Like bwrap this is FAIL-SAFE: a requested sandbox that can't be
6060
- // established (unsandboxable backend, no SESSION_DATA_DIR) is a hard error at the
6061
- // spawn site below, never a silent unconfined run.
6062
- const willWriteSandbox = process.platform === 'darwin' && wantsFileSandbox;
6063
- if (willWriteSandbox && !process.env.SESSION_DATA_DIR) {
6064
- throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: missing SESSION_DATA_DIR`);
6065
- }
6066
- const fullIsolationCoversCredentials = !riffRemoteBackend && ((process.platform === 'darwin' && (willReadIsolate || willWriteSandbox))
6067
- || (process.platform === 'linux' && wantsFileSandbox));
6378
+ log('Sandbox flag set but backend is riff (remote sandbox, no local process) — local sandbox bypassed');
6379
+ }
6380
+ const sandboxRequested = !riffRemoteBackend
6381
+ && (cfg.sandbox === true || cfg.readIsolation === true || sandboxEnabled());
6382
+ const fullIsolationCoversCredentials = sandboxRequested;
6068
6383
  let credentialMechanismAvailable = true;
6069
6384
  let credentialMechanismExecutable;
6070
6385
  if (mandatoryCredentialIsolation && !riffRemoteBackend && !fullIsolationCoversCredentials) {
@@ -6097,19 +6412,50 @@ async function spawnCli(cfg, opts = {}) {
6097
6412
  const credentialOnlySeatbelt = credentialIsolationGate.mode === 'seatbelt';
6098
6413
  const credentialOnlyBwrap = credentialIsolationGate.mode === 'bwrap';
6099
6414
  const appliedIsolationCapabilities = [];
6100
- if (credentialBoundaryActive
6101
- || willReadIsolate
6102
- || (process.platform === 'linux' && wantsFileSandbox)) {
6415
+ if (credentialBoundaryActive || sandboxRequested) {
6103
6416
  appliedIsolationCapabilities.push('credential');
6104
6417
  }
6105
- if (willReadIsolate)
6106
- appliedIsolationCapabilities.push('read');
6107
- if (willWriteSandbox || (process.platform === 'linux' && wantsFileSandbox)) {
6108
- appliedIsolationCapabilities.push('write');
6109
- }
6418
+ if (sandboxRequested)
6419
+ appliedIsolationCapabilities.push('read', 'write');
6110
6420
  currentCliCredentialIsolated = appliedIsolationCapabilities.includes('credential');
6111
6421
  const isolationRuntimeDataDir = process.env.SESSION_DATA_DIR
6112
6422
  ?? join(defaultBotmuxHome, 'data');
6423
+ let mcpRuntimeManifest = readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir);
6424
+ const hasMcpRuntimeEntries = !!cliAdapter.mcpGateway && !!mcpRuntimeManifest?.entries.length;
6425
+ const selectBackend = () => selectSessionBackend({
6426
+ sessionId: cfg.sessionId,
6427
+ backendType: effectiveBackend,
6428
+ backendConfig: riffBackendConfig,
6429
+ persistentBackendTarget: cfg.persistentBackendTarget,
6430
+ // Old builds could place managed agents in a user's shared Herdr session.
6431
+ // Preserve that recorded target for compatibility unless this incarnation
6432
+ // requires an isolation/MCP boundary that only a Botmux-owned session can
6433
+ // safely provide. Fresh tasks use distinct agents in one machine-wide host.
6434
+ reuseRecordedHerdrTarget: !sandboxRequested
6435
+ && !hasMcpRuntimeEntries,
6436
+ });
6437
+ let selectedBackend = selectBackend();
6438
+ isTmuxMode = selectedBackend.isTmuxMode;
6439
+ isPipeMode = selectedBackend.isPipeMode;
6440
+ isZellijMode = selectedBackend.isZellijMode;
6441
+ backend = selectedBackend.backend;
6442
+ // BOT_HOME CLI-data redirect: sandboxed bots whose adapter supports it keep
6443
+ // their CLI data (transcripts/memory/auth) in their own BOT_HOME via
6444
+ // CLAUDE_CONFIG_DIR/CODEX_HOME — under deny-by-default the global ~/.claude|
6445
+ // ~/.codex are simply not exposed. Best-effort: a non-supporting adapter
6446
+ // keeps its REAL data dirs instead, which the policy exposes readWrite (the
6447
+ // sandbox itself still applies). Decided EARLY so every JSONL/bridge/resume
6448
+ // path below already targets the right dir. wrapperCli strips spawn args, so
6449
+ // the redirect (and its env) can't be guaranteed there → not redirected.
6450
+ const willRedirectCliData = sandboxRequested
6451
+ && cliAdapter.supportsReadIsolation === true
6452
+ && !cfg.wrapperCli
6453
+ && !!process.env.SESSION_DATA_DIR;
6454
+ // Bump the CLI-lifetime nonce: any stuck-warning card posted by a previous
6455
+ // backend instance (within this same worker) must not inject keys into the
6456
+ // new one. The worker echoes this nonce in stuck_warning and re-checks it on
6457
+ // tui_keys, alongside the backend object identity.
6458
+ cliLifetimeNonce++;
6113
6459
  // Every bot — isolated OR not — gets its own BOT_HOME dir as a ready-made private-
6114
6460
  // storage slot. An isolated sibling denies this path regardless of whether the owner
6115
6461
  // is isolated (deny uses the full bots.json), so a non-isolated bot can drop private
@@ -6128,7 +6474,7 @@ async function spawnCli(cfg, opts = {}) {
6128
6474
  }
6129
6475
  let isolationBotHome;
6130
6476
  let isolatedCodexHome;
6131
- if (willReadIsolate) {
6477
+ if (willRedirectCliData) {
6132
6478
  isolationBotHome = ownBotHome;
6133
6479
  const isClaudeFam = !!claudeDataDir;
6134
6480
  if (isClaudeFam)
@@ -6175,28 +6521,21 @@ async function spawnCli(cfg, opts = {}) {
6175
6521
  // on EVERY restart, e.g. for a sandboxed session whose transcript lives in
6176
6522
  // an ephemeral overlay upper that the probe can't see). Computed here (not at
6177
6523
  // 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;
6524
+ let persistentSessionName = selectedBackend.persistentSessionName;
6185
6525
  // [read-isolation] Before we decide to reattach a persistent pane: a pane can
6186
6526
  // survive a daemon restart still running a CLI that may NOT be isolated (e.g.
6187
6527
  // spawned before isolation was enabled, or by an old build). Isolation is only
6188
6528
  // injectable at spawn time, so reattaching such a pane would silently run
6189
- // unisolated. We stamp a versioned policy marker when we spawn an isolated
6190
- // pane; if an existing pane was not spawned under the current policy, kill it
6191
- // so the probe below sees no pane and we cold-spawn fresh isolated. A current-
6192
- // policy pane survives daemon restarts and suspendresume safely because the
6193
- // confinement remains attached to the live process.
6529
+ // unisolated. We stamp a boot-id marker when we spawn an isolated pane; if this
6530
+ // isolated bot's existing pane is NOT stamped by THIS daemon lifetime, kill it
6531
+ // so the probe below sees no pane and we cold-spawn fresh isolated. A pane from
6532
+ // this lifetime (suspend→resume) keeps its markerreattaches normally (it is
6533
+ // still the isolated process). This lets isolated bots use tmux/zellij/herdr.
6194
6534
  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);
6535
+ const persistentTarget = selectedBackend.persistentBackendTarget;
6536
+ const paneLive = persistentTarget
6537
+ ? probePersistentBackendTarget(persistentTarget) === 'exists'
6538
+ : false;
6200
6539
  if (paneLive) {
6201
6540
  let marker = null;
6202
6541
  marker = readRegularHostFileNoFollow(join(isolationRuntimeDataDir, 'read-isolation', `${cfg.sessionId}.boot`));
@@ -6211,7 +6550,18 @@ async function spawnCli(cfg, opts = {}) {
6211
6550
  // obsolete permissions. Kill it before publishing any new capability.
6212
6551
  log(`[read-isolation] legacy/unmarked persistent pane for ${cfg.sessionId} — killing + cold-spawning with current policy`);
6213
6552
  try {
6214
- killPersistentSession(effectiveBackendType, persistentSessionName);
6553
+ const persistentTarget = selectedBackend.persistentBackendTarget;
6554
+ if (persistentTarget)
6555
+ killPersistentBackendTarget(persistentTarget);
6556
+ else
6557
+ killPersistentSession(effectiveBackendType, persistentSessionName);
6558
+ selectedBackend = selectBackend();
6559
+ isTmuxMode = selectedBackend.isTmuxMode;
6560
+ isPipeMode = selectedBackend.isPipeMode;
6561
+ isZellijMode = selectedBackend.isZellijMode;
6562
+ backend = selectedBackend.backend;
6563
+ cliLifetimeNonce++;
6564
+ persistentSessionName = selectedBackend.persistentSessionName;
6215
6565
  }
6216
6566
  catch (e) {
6217
6567
  throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: could not kill stale persistent pane (${e.message})`);
@@ -6219,28 +6569,32 @@ async function spawnCli(cfg, opts = {}) {
6219
6569
  }
6220
6570
  }
6221
6571
  }
6222
- let mcpRuntimeManifest = readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir);
6572
+ let willReattachPersistent = selectedBackend.isReattach === true;
6223
6573
  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);
6574
+ const persistentTarget = selectedBackend.persistentBackendTarget;
6575
+ const paneLive = persistentTarget
6576
+ ? probePersistentBackendTarget(persistentTarget) === 'exists'
6577
+ : false;
6229
6578
  if (paneLive) {
6230
6579
  // The trusted Gateway host belongs to the worker and cannot survive a
6231
6580
  // worker/daemon replacement. Cold-resume the CLI so its MCP client gets a
6232
6581
  // fresh relay socket instead of reattaching to a dead connection.
6233
6582
  log(`[mcp-gateway] persistent pane ${cfg.sessionId} has plugin MCP state — cold-resuming with a fresh host`);
6234
- killPersistentSession(effectiveBackendType, persistentSessionName);
6583
+ const persistentTarget = selectedBackend.persistentBackendTarget;
6584
+ if (persistentTarget)
6585
+ killPersistentBackendTarget(persistentTarget);
6586
+ else
6587
+ killPersistentSession(effectiveBackendType, persistentSessionName);
6588
+ selectedBackend = selectBackend();
6589
+ isTmuxMode = selectedBackend.isTmuxMode;
6590
+ isPipeMode = selectedBackend.isPipeMode;
6591
+ isZellijMode = selectedBackend.isZellijMode;
6592
+ backend = selectedBackend.backend;
6593
+ cliLifetimeNonce++;
6594
+ persistentSessionName = selectedBackend.persistentSessionName;
6595
+ willReattachPersistent = selectedBackend.isReattach === true;
6235
6596
  }
6236
6597
  }
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
6598
  // The plugin set is stable only for the lifetime of one real CLI process.
6245
6599
  // A warm worker reattach keeps the existing Gateway and catalog untouched;
6246
6600
  // every fresh/resumed CLI spawn atomically refreshes both from current Bot config.
@@ -6428,81 +6782,39 @@ async function spawnCli(cfg, opts = {}) {
6428
6782
  lastSpawnDeferInitialPrompt = deferInitialPrompt;
6429
6783
  kiroSessionIdCaptureArmed = cfg.cliId === 'kiro-cli' && !effectiveCliSessionId && !willReattachPersistent;
6430
6784
  kiroSessionIdCaptureBuffer = '';
6431
- // Per-bot local read isolation: assemble the Seatbelt profile context (the gate
6432
- // already fail-closed above reaching here with willReadIsolate means it is
6433
- // enforceable). The worker is on the host (NOT sandboxed), so it holds the
6434
- // secret; only the spawned CLI child is confined. A reattach that reaches here
6435
- // is safe: the stale-pane guard above already killed any persistent pane not
6436
- // stamped as isolated, so `willReattachPersistent` can now only be true for a
6437
- // pane spawned isolated (still the confined process).
6438
- let readIsolationCtx;
6439
- if (willReadIsolate) {
6440
- const sessionDataDir = process.env.SESSION_DATA_DIR;
6441
- readIsolationCtx = {
6442
- homeDir: homedir(),
6443
- botmuxHome: dirname(sessionDataDir),
6444
- defaultBotmuxHome: join(homedir(), '.botmux'),
6445
- sessionDataDir,
6446
- currentAppId: cfg.larkAppId,
6447
- currentSessionId: cfg.sessionId,
6448
- extraDenyPaths: cfg.readDenyExtraPaths,
6449
- };
6450
- readIsolationOriginCapabilityFile = process.platform === 'darwin'
6451
- ? managedOriginCapabilityPath(sessionDataDir, cfg.sessionId)
6452
- : null;
6453
- // Replace any legacy child-planted symlink before Seatbelt canonicalizes
6454
- // carve-outs. A failure is fatal: spawning with a missing/ambiguous
6455
- // capability transport would either break all IPC or reopen an attacker-
6456
- // selected realpath in the generated profile.
6457
- if (readIsolationOriginCapabilityFile) {
6458
- publishSandboxRelayCapability({ failClosed: true });
6459
- }
6460
- // Write this bot's OWN send-credential into its BOT_HOME (the same per-bot
6461
- // private storage as its CLI data; siblings' BOT_HOMEs are whole-denied).
6462
- // `botmux send` reads the secret from here instead of bots.json — so the
6463
- // secret never travels via env/argv (no cross-bot `ps aux` leak) and the CLI
6464
- // never needs to escape the sandbox.
6785
+ // Sandboxed sessions: write this bot's OWN send-credential into its BOT_HOME.
6786
+ // `botmux send` reads the secret from here instead of bots.json (which
6787
+ // deny-by-default never exposes) the secret never travels via env/argv (no
6788
+ // cross-bot `ps aux` leak) and the CLI never needs to escape the sandbox.
6789
+ // The worker itself runs on the host (NOT sandboxed) and keeps full access.
6790
+ if (sandboxRequested && process.env.SESSION_DATA_DIR) {
6465
6791
  try {
6466
- const credPath = sendCredFilePath(sessionDataDir, cfg.larkAppId);
6792
+ const credPath = sendCredFilePath(process.env.SESSION_DATA_DIR, cfg.larkAppId);
6467
6793
  mkdirSync(dirname(credPath), { recursive: true });
6468
6794
  writeFileSync(credPath, JSON.stringify({ larkAppId: cfg.larkAppId, larkAppSecret: cfg.larkAppSecret, brand: cfg.brand }), { mode: 0o600 });
6469
6795
  }
6470
6796
  catch (e) {
6471
- log(`[read-isolation] WARN could not write send-cred file: ${e.message}`);
6797
+ log(`[sandbox] WARN could not write send-cred file: ${e.message}`);
6472
6798
  }
6473
6799
  }
6474
- // macOS write-sandbox rules (pure): the writable-zone allow-list + crown-jewel
6475
- // re-denies. Realpath'd + emitted into the Seatbelt profile at the spawn site.
6476
- let writeSandboxRules;
6477
- if (willWriteSandbox && process.env.SESSION_DATA_DIR) {
6478
- const sessionDataDir = process.env.SESSION_DATA_DIR;
6479
- // Regex rules cannot be realpath'd after construction, so build their home
6480
- // prefix from the canonical path up front (Seatbelt matches canonical paths).
6481
- const canonicalSandboxPath = (path) => {
6482
- try {
6483
- return realpathSync(path);
6484
- }
6485
- catch {
6486
- return path;
6487
- }
6488
- };
6489
- const sandboxHome = canonicalSandboxPath(homedir());
6490
- writeSandboxRules = buildWriteSandboxRules({
6491
- homeDir: sandboxHome,
6492
- botmuxHome: canonicalSandboxPath(dirname(sessionDataDir)),
6493
- defaultBotmuxHome: canonicalSandboxPath(join(homedir(), '.botmux')),
6494
- sessionDataDir: canonicalSandboxPath(sessionDataDir),
6495
- workingDir: cfg.workingDir,
6496
- currentAppId: cfg.larkAppId,
6497
- // TMPDIR on macOS resolves under /private/var/folders (already allowed), but
6498
- // pass it explicitly so a customized TMPDIR is covered too.
6499
- extraWritePaths: process.env.TMPDIR ? [process.env.TMPDIR] : [],
6500
- });
6501
- }
6800
+ // In the file sandbox, canonicalize the workingDir handed to buildArgs. CLIs
6801
+ // that pass it as a chdir arg (codex/traex `-C`) fail otherwise on a symlinked
6802
+ // $HOME host: the lexical /home/u/... prefix doesn't exist inside the bwrap
6803
+ // root (only canonical /data00/... is bound), so the CLI's chdir/readlink
6804
+ // ENOENTs and it aborts with "No such file or directory (os error 2)". Off
6805
+ // sandbox this is a no-op (same dir); best-effort if unresolvable.
6806
+ const buildArgsWorkingDir = sandboxRequested
6807
+ ? (() => { try {
6808
+ return realpathSync(cfg.workingDir);
6809
+ }
6810
+ catch {
6811
+ return cfg.workingDir;
6812
+ } })()
6813
+ : cfg.workingDir;
6502
6814
  const args = cliAdapter.buildArgs({
6503
6815
  sessionId: effectiveAdapterSessionId,
6504
6816
  resume: effectiveResume,
6505
- workingDir: cfg.workingDir,
6817
+ workingDir: buildArgsWorkingDir,
6506
6818
  resumeSessionId: effectiveCliSessionId,
6507
6819
  initialPrompt: preparedInitialPrompt,
6508
6820
  botName: cfg.botName,
@@ -6512,12 +6824,7 @@ async function spawnCli(cfg, opts = {}) {
6512
6824
  model: ttadkGateway ? undefined : cfg.model,
6513
6825
  disableCliBypass: cfg.disableCliBypass === true,
6514
6826
  skillPluginDir: cfg.skillPluginDir,
6515
- readIsolation: willReadIsolate,
6516
- // Set (as a pair) by the init handler when hybrid RPC input is engaged;
6517
- // makes buildArgs launch `codex --remote <ws> resume <thread>` instead of
6518
- // the normal paste-mode codex. Undefined for every other bot/CLI.
6519
- remoteWsUrl,
6520
- remoteThreadId,
6827
+ readIsolation: willRedirectCliData,
6521
6828
  });
6522
6829
  // Pi's deferred long-first-prompt command is implemented by a session-scoped
6523
6830
  // extension. Keep its launch args across owned process restarts while the
@@ -6616,15 +6923,18 @@ async function spawnCli(cfg, opts = {}) {
6616
6923
  delete childEnv.BOTMUX_CHAT_TYPE;
6617
6924
  childEnv.BOTMUX_LARK_APP_ID = cfg.larkAppId;
6618
6925
  childEnv.BOTMUX_ROOT_MESSAGE_ID = cfg.rootMessageId;
6619
- // Session owner under the standard BOTMUX_* name (the riff backend already
6620
- // exposes it; `__OWNER_OPEN_ID` stays for back-compat). Custom CLI wrappers
6621
- // use it for per-user permission isolation. Advisory identity only botmux's
6622
- // own authz paths (v3 command authority, host.ts) ignore env owner values and
6623
- // resolve the current-turn caller from turn provenance instead.
6624
- if (cfg.ownerOpenId)
6625
- childEnv.BOTMUX_OWNER_OPEN_ID = cfg.ownerOpenId;
6626
- else
6627
- delete childEnv.BOTMUX_OWNER_OPEN_ID;
6926
+ // This bot's resolved brandLabel template, injected so a SANDBOXED `botmux
6927
+ // send` renders the role-name footer without reading bots.json (deny-by-
6928
+ // default EPERM role footer would silently fall back to the default
6929
+ // [botmux] label). resolveBrandLabel honours this env first (gated on the
6930
+ // own appId). Only set the key when a brandLabel is configured (present-but-
6931
+ // empty '' = suppress is preserved; unset key → the CLI falls through). It is
6932
+ // a cosmetic markdown template, not a credential, so env-passing is safe.
6933
+ {
6934
+ const bl = resolveBrandLabel(cfg.larkAppId);
6935
+ if (typeof bl === 'string')
6936
+ childEnv.BOTMUX_BRAND_LABEL = bl;
6937
+ }
6628
6938
  // NOTE: under read isolation `botmux send` gets this bot's secret from the worker-
6629
6939
  // written cred FILE in its BOT_HOME (send-cred.json, see sendCredFilePath) located
6630
6940
  // via the BOTMUX_LARK_APP_ID above — NOT from the env. The secret is deliberately kept OUT
@@ -6668,10 +6978,27 @@ async function spawnCli(cfg, opts = {}) {
6668
6978
  // already guarantee the pane starts clean, and the CLI's built-in default
6669
6979
  // preserves stock semantics.
6670
6980
  if (isolationBotHome) {
6981
+ // In the file sandbox, bwrap binds only CANONICAL paths (the fs-policy
6982
+ // realpaths every rule). On a symlinked-$HOME host (/home/u → /data00/home/u)
6983
+ // the lexical BOT_HOME (/home/u/.botmux/bots/…/claude) does NOT exist in the
6984
+ // fresh bwrap root, so the CLI can't open its own CLAUDE_CONFIG_DIR/CODEX_HOME
6985
+ // → settings.json (with ANTHROPIC_BASE_URL/token) is unreadable → it falls
6986
+ // back to the public endpoint and fails to connect. Canonicalize so the env
6987
+ // points at the same path bwrap bound. Best-effort: keep lexical if unresolved.
6988
+ const canonicalizeForSandbox = (p) => {
6989
+ if (!sandboxRequested)
6990
+ return p;
6991
+ try {
6992
+ return realpathSync(p);
6993
+ }
6994
+ catch {
6995
+ return p;
6996
+ }
6997
+ };
6671
6998
  if (claudeDataDir)
6672
- childEnv.CLAUDE_CONFIG_DIR = claudeDataDir; // = <BOT_HOME>/claude
6999
+ childEnv.CLAUDE_CONFIG_DIR = canonicalizeForSandbox(claudeDataDir); // = <BOT_HOME>/claude
6673
7000
  else
6674
- childEnv.CODEX_HOME = isolatedCodexHome;
7001
+ childEnv.CODEX_HOME = canonicalizeForSandbox(isolatedCodexHome);
6675
7002
  }
6676
7003
  // Per-bot env (bots.json `env`): extra vars for THIS bot's CLI only — e.g.
6677
7004
  // ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN to run a bot on GLM/a 3rd-party
@@ -6694,356 +7021,362 @@ async function spawnCli(cfg, opts = {}) {
6694
7021
  let spawnBin = cliAdapter.resolvedBin;
6695
7022
  let spawnArgs = args;
6696
7023
  let spawnCwd = cfg.workingDir;
6697
- // Read isolation (macOS): wrap the whole CLI process in a Seatbelt sandbox that
6698
- // denies reads of the sensitive paths (blocklist). The CLI bypasses its OWN
6699
- // sandbox (see adapter) so the outer wrapper is the sole enforcer. DARWIN ONLY —
6700
- // on Linux read isolation is enforced by bwrap masks fed into the overlay sandbox
6701
- // below (see readIsoLinuxMasks), NOT sandbox-exec (which doesn't exist there).
6702
- if (process.platform === 'darwin' && (readIsolationCtx || writeSandboxRules)) {
6703
- // Seatbelt matches CANONICAL paths (it resolves symlinks), so realpath every
6704
- // deny/allow before emitting the profile otherwise a sensitive root reached
6705
- // through a symlinked prefix (e.g. a symlinked home / SESSION_DATA_DIR) would
6706
- // silently fail-open (the /tmp→/private/tmp class of miss). realpath-if-exists:
6707
- // a non-existent path has nothing to read, so its literal form is harmless.
6708
- // The ROOT dirs are canonicalized FIRST (profileCtx) so the regex patterns —
6709
- // which can't be realpath'd as a result — are built on canonical prefixes too.
7024
+ // Dashboard「复现命令」:在**任何** sandbox 包装(下方 macOS Seatbelt / Linux bwrap /
7025
+ // credential-only)之前,记下**基础 CLI** bin/args(cliAdapter.resolvedBin +
7026
+ // buildArgs 产出)。独立维护、绝不从已被外层包装的 spawnBin/spawnArgs 回推。最终
7027
+ // 复现形态(是否套 wrapperCli)由 selectReproduceLaunch spawn 时统一决策——见
7028
+ // reproduce-command.ts。这里只锁定"包装前的基础"这个事实。
7029
+ const reproduceBaseBin = spawnBin;
7030
+ const reproduceBaseArgs = [...spawnArgs];
7031
+ // ── UNIFIED file sandbox (fs-policy): ONE policy source, BOTH platforms. ──
7032
+ // Three-tier deny-by-default whitelist compiled to Seatbelt (darwin) or bwrap
7033
+ // (linux). Cross-bot read isolation is inherent (siblings' data simply isn't
7034
+ // exposed) no enumeration, no deny-list to keep in sync.
7035
+ if (sandboxRequested) {
7036
+ const dataDir = process.env.SESSION_DATA_DIR;
7037
+ // FAIL-SAFE (not fail-open): a requested sandbox that can't be established
7038
+ // must be a HARD ERROR, never a silent unconfined run.
7039
+ if (effectiveBackendType !== 'pty' && effectiveBackendType !== 'tmux') {
7040
+ const msg = `Sandbox ENABLED but backend "${effectiveBackendType}" is not sandboxable (only pty/tmux) — aborting spawn to avoid an unsandboxed run`;
7041
+ log(msg);
7042
+ throw new Error(msg);
7043
+ }
7044
+ if (!dataDir) {
7045
+ const msg = 'Sandbox ENABLED but SESSION_DATA_DIR is unset — aborting spawn to avoid an unsandboxed run';
7046
+ log(msg);
7047
+ throw new Error(msg);
7048
+ }
7049
+ // Both engines match CANONICAL paths (Seatbelt resolves symlinks, bwrap
7050
+ // resolves mount sources) — realpath everything, or a symlinked prefix
7051
+ // (the /tmp→/private/tmp class) silently fail-opens.
6710
7052
  const canonical = (p) => { try {
6711
7053
  return realpathSync(p);
6712
7054
  }
6713
7055
  catch {
6714
7056
  return p;
6715
7057
  } };
6716
- // READ rules — only when read isolation is on. v2 HYBRID model: whole-deny
6717
- // ~/.claude|~/.codex (F1 fix own CLI data is redirected into BOT_HOME,
6718
- // readable) + surgical-deny only the cross-bot-SENSITIVE parts of the otherwise-
6719
- // readable ~/.botmux + system creds; the own slice is re-opened via carve-outs.
6720
- // For a WRITE-sandbox-only session these stay empty → reads wide open (Linux parity).
6721
- let denyPaths = [], denyRegexes = [], allowPaths = [], finalDenyPaths = [], traverseDirs = [];
6722
- let protectedWrites;
6723
- if (readIsolationCtx) {
6724
- const profileCtx = {
6725
- ...readIsolationCtx,
6726
- homeDir: canonical(readIsolationCtx.homeDir),
6727
- botmuxHome: canonical(readIsolationCtx.botmuxHome),
6728
- defaultBotmuxHome: canonical(readIsolationCtx.defaultBotmuxHome ?? join(readIsolationCtx.homeDir, '.botmux')),
6729
- sessionDataDir: canonical(readIsolationCtx.sessionDataDir),
6730
- };
6731
- denyPaths = buildV2DenyPaths(profileCtx).map(canonical);
6732
- denyRegexes = buildV2DenyRegexes(profileCtx);
6733
- const carve = buildV2CarveOuts(profileCtx);
6734
- const capabilityCarvePath = managedOriginCapabilityPath(profileCtx.sessionDataDir, profileCtx.currentSessionId);
6735
- allowPaths = [
6736
- // Never realpath the capability leaf: another legacy sandbox can race
6737
- // a symlink into that shared host directory while multiple persistent
6738
- // sessions recover. Keeping the exact path may fail closed on a raced
6739
- // symlink, but can never turn its target into a Seatbelt allow rule.
6740
- ...carve.allowPaths.map(path => path === capabilityCarvePath ? path : canonical(path)),
6741
- ...buildCliExecutableReadCarveOuts({
6742
- homeDir: profileCtx.homeDir,
6743
- cliId: cliAdapter.id,
6744
- resolvedBin: canonical(cliAdapter.resolvedBin),
6745
- }),
6746
- // buildV2DenyPaths masks the shared pi-initial-prompts root. Re-open
6747
- // only this session's private child directory for Pi's @file/extension.
6748
- ...piInitialPromptReadonlyRoots.map(canonical),
6749
- ];
6750
- finalDenyPaths = carve.finalDenyPaths.map(canonical);
6751
- traverseDirs = carve.traverseDirs.map(canonical);
6752
- protectedWrites = buildReadIsolationProtectedWriteRules(profileCtx, {
6753
- // Keep lexical roots as well as their canonical targets. Canonical
6754
- // rules protect contents reached through a symlink; lexical literals
6755
- // prevent replacing the symlink/root directory entry itself.
6756
- dashboardRoots: [
6757
- readIsolationCtx.defaultBotmuxHome ?? join(readIsolationCtx.homeDir, '.botmux'),
6758
- readIsolationCtx.botmuxHome,
6759
- ],
6760
- sessionDataDirs: [readIsolationCtx.sessionDataDir],
6761
- });
6762
- }
6763
- if (credentialBoundaryActive) {
6764
- const credentialRules = buildCredentialIsolationRules({
6765
- homeDir: canonical(hostHomeDir),
6766
- botmuxHome: canonical(configuredBotmuxHome),
6767
- defaultBotmuxHome: canonical(defaultBotmuxHome),
6768
- });
6769
- denyPaths = [...new Set([...denyPaths, ...credentialRules.denyPaths.map(canonical)])];
6770
- denyRegexes = [...new Set([...denyRegexes, ...credentialRules.denyRegexes])];
6771
- protectedWrites = {
6772
- denyWritePaths: [...new Set([
6773
- ...(protectedWrites?.denyWritePaths ?? []),
6774
- ...credentialRules.denyWritePaths.map(canonical),
6775
- ])],
6776
- denyWriteRegexes: [...new Set([
6777
- ...(protectedWrites?.denyWriteRegexes ?? []),
6778
- ...credentialRules.denyWriteRegexes,
6779
- ])],
6780
- denyWriteLiterals: [...new Set([
6781
- ...(protectedWrites?.denyWriteLiterals ?? []),
6782
- ...credentialRules.denyWriteLiterals.map(canonical),
6783
- defaultBotmuxHome,
6784
- configuredBotmuxHome,
6785
- ])],
6786
- };
6787
- }
6788
- if (mcpRuntimeManifest) {
6789
- finalDenyPaths.push(...sessionMcpRuntimeHostOnlyPaths(mcpRuntimeManifest, config.session.dataDir).map(canonical));
6790
- }
6791
- // Every isolated macOS CLI is denied access to all same-UID Gateway socket
6792
- // directories. A session with its own Gateway gets one narrow, later READ
6793
- // carve-out for its token; writes stay denied so the CLI cannot unlink or
6794
- // replace even its own worker-managed socket. Sessions without plugin MCP
6795
- // state get no read carve-out at all.
6796
- const gatewaySocketRoot = canonical(sessionMcpGatewayHost ? dirname(sessionMcpGatewayHost.socketDir) : tmpdir());
6797
- const gatewaySocketRegex = sessionMcpGatewayPathRegex(gatewaySocketRoot);
6798
- denyRegexes.push(gatewaySocketRegex);
6799
- if (sessionMcpGatewayHost)
6800
- allowPaths.push(canonical(sessionMcpGatewayHost.socketDir));
6801
- protectedWrites = {
6802
- denyWritePaths: protectedWrites?.denyWritePaths ?? [],
6803
- denyWriteRegexes: [...(protectedWrites?.denyWriteRegexes ?? []), gatewaySocketRegex],
6804
- denyWriteLiterals: protectedWrites?.denyWriteLiterals ?? [],
7058
+ const sandboxHome = canonical(homedir());
7059
+ const expandTilde = (raw) => raw.replace(/^~(?=\/|$)/, sandboxHome);
7060
+ const keepExisting = (paths) => {
7061
+ const out = [];
7062
+ for (const raw of paths) {
7063
+ if (!raw || typeof raw !== 'string')
7064
+ continue;
7065
+ const p = expandTilde(raw);
7066
+ try {
7067
+ if (existsSync(p))
7068
+ out.push(canonical(p));
7069
+ }
7070
+ catch { /* */ }
7071
+ }
7072
+ return out;
6805
7073
  };
6806
- // WRITE rules the macOS file-sandbox (Linux-bwrap twin). Realpath the writable
6807
- // zones + crown-jewel re-denies for the same symlink-safety reason as reads.
6808
- const writeRules = writeSandboxRules
6809
- ? {
6810
- allowWritePaths: writeSandboxRules.allowWritePaths.map(canonical),
6811
- allowWriteRegexes: writeSandboxRules.allowWriteRegexes,
6812
- denyWritePaths: writeSandboxRules.denyWritePaths.map(canonical),
6813
- denyWriteRegexes: writeSandboxRules.denyWriteRegexes,
7074
+ // User three-tier lists: the new sandboxPaths field, or a pre-migration
7075
+ // session/config's legacy fields mapped through the SAME lossless mapping
7076
+ // the startup migration uses.
7077
+ const legacyMapped = migrateLegacySandboxFields({
7078
+ sandbox: cfg.sandbox,
7079
+ readIsolation: cfg.readIsolation,
7080
+ sandboxReadonlyPaths: cfg.sandboxReadonlyPaths,
7081
+ sandboxHidePaths: cfg.sandboxHidePaths,
7082
+ readDenyExtraPaths: cfg.readDenyExtraPaths,
7083
+ sandboxPaths: cfg.sandboxPaths,
7084
+ });
7085
+ const userLists = cfg.sandboxPaths ?? legacyMapped?.sandboxPaths;
7086
+ const droppedUser = [];
7087
+ const resolveUser = (paths) => {
7088
+ const out = [];
7089
+ for (const raw of paths ?? []) {
7090
+ if (!raw || typeof raw !== 'string')
7091
+ continue;
7092
+ const p = expandTilde(raw);
7093
+ if (existsSync(p))
7094
+ out.push(canonical(p));
7095
+ else
7096
+ droppedUser.push(raw);
6814
7097
  }
7098
+ return out;
7099
+ };
7100
+ // deny paths are NOT existence-filtered on EITHER platform: a deny must guard
7101
+ // a path that does not exist YET (else the agent creates it under an exposed
7102
+ // rw parent and the deny is bypassed — codex review finding). Seatbelt keeps
7103
+ // the rule literally; bwrap masks it with a tmpfs the mount creates (writes
7104
+ // land in the ephemeral tmpfs, the real path is never created).
7105
+ const resolveDeny = (paths) => (paths ?? []).filter((p) => typeof p === 'string' && !!p).map(p => canonical(expandTilde(p)));
7106
+ const userPaths = {
7107
+ readWrite: resolveUser(userLists?.readWrite),
7108
+ readOnly: resolveUser(userLists?.readOnly),
7109
+ deny: resolveDeny(userLists?.deny),
7110
+ };
7111
+ if (droppedUser.length)
7112
+ log(`[sandbox] sandboxPaths entries dropped (path not found): ${droppedUser.join(', ')}`);
7113
+ // Every executable spawned INSIDE the sandbox must be readable: the CLI
7114
+ // binary's dir, the daemon's own node (fnm farms under /run land here),
7115
+ // adapter second-stage bins, plus the standalone-codex package tree.
7116
+ const execDirs = [cliAdapter.resolvedBin, process.execPath, ...(cliAdapter.sandboxExtraExecPaths?.() ?? [])]
7117
+ .filter((p) => typeof p === 'string' && !!p)
7118
+ .map(p => dirname(canonical(p)));
7119
+ const execCarve = buildCliExecutableReadCarveOuts({
7120
+ homeDir: sandboxHome,
7121
+ cliId: cliAdapter.id,
7122
+ resolvedBin: canonical(cliAdapter.resolvedBin),
7123
+ }).map(canonical);
7124
+ // Linux relay outbox (created by prepareDirectSandbox; in the policy so the
7125
+ // compiled plan binds it read-write).
7126
+ const outbox = process.platform === 'linux'
7127
+ ? join(canonical(dataDir), 'sandboxes', cfg.sessionId, 'outbox')
6815
7128
  : undefined;
6816
- if (!locateOnPath('sandbox-exec')) {
6817
- throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: sandbox-exec not found`);
6818
- }
6819
- const profileDir = join(isolationRuntimeDataDir, 'read-isolation');
6820
- mkdirSync(profileDir, { recursive: true });
6821
- const profilePath = join(profileDir, `${cfg.sessionId}.sb`);
6822
- replaceManagedOriginCapabilityFile(profilePath, buildSeatbeltProfile(denyPaths, allowPaths, finalDenyPaths, traverseDirs, denyRegexes, writeRules, protectedWrites));
6823
- seatbeltProfilePath = profilePath;
6824
- spawnArgs = ['-f', profilePath, spawnBin, ...spawnArgs];
6825
- spawnBin = credentialMechanismExecutable ?? '/usr/bin/sandbox-exec';
6826
- log(`[file-sandbox] wrapping ${cliAdapter.id} in Seatbelt (read-isolation=${!!readIsolationCtx}, write-sandbox=${!!writeRules}): sandbox-exec -f ${profilePath}`);
6827
- }
6828
- // Fresh sandboxed spawn on a persistent backend: stamp the pane with this daemon's
6829
- // policy version + boot id so a later suspend→resume reattach can be trusted (see the stale-pane
6830
- // guard above). Applies to read-isolation AND write-sandbox panes (both carry the
6831
- // Seatbelt confinement on the live process). pty needs no marker (never reattached).
6832
- if (appliedIsolationCapabilities.length > 0 && persistentSessionName && !willReattachPersistent) {
7129
+ // Pre-create the outbox BEFORE buildFsPolicy so it survives the allow-rule
7130
+ // existence-filter below (a not-yet-existing readWrite path is dropped, and
7131
+ // bwrap can't bind a missing source → the sandboxed `botmux send` relay
7132
+ // would EPERM/ENOENT writing its <hash>.content into an unbound dir).
7133
+ // prepareDirectSandbox re-mkdirs it too; recursive make is idempotent.
7134
+ if (outbox) {
7135
+ try {
7136
+ mkdirSync(outbox, { recursive: true });
7137
+ }
7138
+ catch { /* best-effort; prepareDirectSandbox retries */ }
7139
+ }
7140
+ // The botmux install/checkout root (dir containing dist/ + node_modules).
7141
+ // This module compiles to <checkout>/dist/worker.js, so `../../` from here is
7142
+ // the checkout root. Exposed readOnly so `botmux` + claude hooks can exec
7143
+ // `node <checkout>/dist/cli.js`.
7144
+ const botmuxInstallRoot = canonical(dirname(dirname(fileURLToPath(import.meta.url))));
7145
+ // Pre-create the OWN writable dirs/files the sandboxed CLI creates on
7146
+ // demand, so they EXIST at spawn and survive the existence-filter below
7147
+ // (bwrap can't bind a nonexistent source; a dropped rule → the CLI's
7148
+ // mkdir/write EPERMs).
7149
+ // - data/turn-sends/<sessionId>.jsonl: `botmux send` appends its dedup
7150
+ // marker. The policy grants ONLY this exact file (not the whole dir) so a
7151
+ // sandboxed CLI can't rewrite another session's marker — pre-create the
7152
+ // file itself (touch) so the single-file bind has a source.
7153
+ // - data/attachments/<self>: `botmux quoted`/downloadResources writes here
7154
+ // (BOT_HOME + outbox are created elsewhere.) Best-effort — a failure just
7155
+ // reverts to the pre-existing drop behaviour.
6833
7156
  try {
6834
- const markerDir = join(isolationRuntimeDataDir, 'read-isolation');
6835
- mkdirSync(markerDir, { recursive: true });
6836
- replaceManagedOriginCapabilityFile(join(markerDir, `${cfg.sessionId}.boot`), isolationPaneMarkerContent(cfg.daemonBootId ?? '', appliedIsolationCapabilities));
7157
+ const tsDir = join(dataDir, 'turn-sends');
7158
+ mkdirSync(tsDir, { recursive: true });
7159
+ const tsFile = join(tsDir, `${cfg.sessionId}.jsonl`);
7160
+ if (!existsSync(tsFile))
7161
+ writeFileSync(tsFile, '');
6837
7162
  }
6838
- catch { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ }
6839
- }
6840
- // Sandbox wraps the spawned binary in bwrap. Works for pty (PtyBackend runs
6841
- // bwrap directly) and tmux (the tmux pane's command becomes `bwrap … -- cli`);
6842
- // env is carried via bwrap --setenv (see prepareSandbox), not the backend.
6843
- // Linux ONLY — on macOS the same `sandbox: true` is enforced by the Seatbelt
6844
- // write-sandbox above (willWriteSandbox), so bwrap must not run here.
6845
- //
6846
- // riff bypass: there is NO local CLI process to wrap — execution happens in
6847
- // riff's own remote sandbox, and the fail-safe "backend not sandboxable"
6848
- // hard error below would otherwise brick every sandbox-enabled bot the
6849
- // moment it switches to riff (the dashboard agent switch clears only
6850
- // readIsolation, not `sandbox`). Bypassing is safe (nothing local runs);
6851
- // log it so the state is visible.
6852
- if (cfg.sandbox === true && effectiveBackendType === 'riff') {
6853
- log('Sandbox flag set but backend is riff (remote sandbox, no local process) — local file sandbox bypassed');
6854
- }
6855
- const sandboxOn = localSandboxApplies(process.platform, effectiveBackendType) && (cfg.sandbox === true || sandboxEnabled());
6856
- // Linux read isolation: build the bwrap MASK set (the Seatbelt read-deny twin) and
6857
- // fold it into the SAME overlay sandbox plan below. Enumerate sibling bots from the
6858
- // FILESYSTEM (the worker runs unsandboxed on the host; bots.json may be denied) —
6859
- // spawn-time-static, so a bot added after this spawn is covered on its next cold
6860
- // start (the accepted Linux tradeoff vs the macOS regex). Only when read isolation
6861
- // is enforceable here (willReadIsolate ⇒ readIsolationCtx set) AND on Linux.
6862
- let readIsoLinuxMasks;
6863
- if (readIsolationCtx && process.platform === 'linux') {
6864
- const canon = (p) => { try {
6865
- return realpathSync(p);
7163
+ catch { /* */ }
7164
+ try {
7165
+ mkdirSync(join(dataDir, 'attachments', cfg.larkAppId), { recursive: true });
6866
7166
  }
6867
- catch {
6868
- return p;
6869
- } };
6870
- // Canonicalize the root prefixes FIRST so every derived mask path is symlink-safe
6871
- // (bwrap masks resolve symlinks — a symlinked home/data root would else fail-open).
6872
- const ctxReal = {
6873
- ...readIsolationCtx,
6874
- homeDir: canon(readIsolationCtx.homeDir),
6875
- botmuxHome: canon(readIsolationCtx.botmuxHome),
6876
- defaultBotmuxHome: canon(readIsolationCtx.defaultBotmuxHome ?? join(readIsolationCtx.homeDir, '.botmux')),
6877
- sessionDataDir: canon(readIsolationCtx.sessionDataDir),
6878
- };
6879
- const ids = new Set();
6880
- const scan = (dir, pick) => {
6881
- try {
6882
- for (const name of readdirSync(dir)) {
6883
- const id = pick(name);
6884
- if (id)
6885
- ids.add(id);
6886
- }
6887
- }
6888
- catch { /* dir absent → no siblings there */ }
6889
- };
6890
- scan(join(ctxReal.botmuxHome, 'bots'), n => n); // sibling BOT_HOME dirs
6891
- scan(join(ctxReal.homeDir, '.lark-cli-bots'), n => n); // sibling lark configs
6892
- scan(ctxReal.sessionDataDir, n => { const m = /^sessions-(.+)\.json$/.exec(n); return m ? m[1] : null; });
6893
- ids.delete(cfg.larkAppId); // never mask own
6894
- let sidecars = [];
7167
+ catch { /* */ }
7168
+ // schedules.json is a shared RMW store (`botmux schedule`); pre-create as an
7169
+ // empty map if absent (same content schedule-store itself writes) so a fresh
7170
+ // install's first in-sandbox `schedule add` can bind+write it on bwrap too.
6895
7171
  try {
6896
- sidecars = readdirSync(ctxReal.botmuxHome).filter(n => n.startsWith('bots.json.')).map(n => join(ctxReal.botmuxHome, n));
7172
+ const sf = join(dataDir, 'schedules.json');
7173
+ if (!existsSync(sf))
7174
+ writeFileSync(sf, '{}');
6897
7175
  }
6898
7176
  catch { /* */ }
6899
- const m = buildLinuxReadIsolationMasks({ ctx: ctxReal, siblingAppIds: [...ids], botsJsonSidecars: sidecars });
6900
- // Same execvp carve-out as macOS (Codex 342a3e1c): a standalone Codex whose
6901
- // binary lives UNDER the now-masked ~/.codex would fail execvp. Re-expose ONLY
6902
- // its executable package tree (read-only, after the masks) — auth/config/sessions
6903
- // stay masked. No-op for npm/system installs (binary not under ~/.codex) + non-codex.
6904
- const execCarveOuts = buildCliExecutableReadCarveOuts({
6905
- homeDir: ctxReal.homeDir,
6906
- cliId: cliAdapter.id,
6907
- resolvedBin: canon(cliAdapter.resolvedBin),
6908
- }).map(canon);
6909
- readIsoLinuxMasks = {
6910
- hidePaths: m.hidePaths.map(canon),
6911
- ownReadWritePaths: m.ownReadWritePaths.map(canon),
6912
- ownReadOnlyPaths: [...m.ownReadOnlyPaths.map(canon), ...execCarveOuts],
6913
- };
6914
- log(`[read-isolation] linux bwrap masks: ${m.hidePaths.length} hide, ${[...ids].length} siblings enumerated${execCarveOuts.length ? ', +codex-standalone exec carve' : ''}`);
6915
- }
6916
- if (sandboxOn) {
6917
- // FAIL-SAFE (not fail-open): when the sandbox is requested, a missing
6918
- // precondition (no SESSION_DATA_DIR, or a backend we can't wrap) must be a
6919
- // HARD ERROR, never a silent skip — otherwise the CLI would spawn UNSANDBOXED
6920
- // with full host write access and no log, the exact opposite of the
6921
- // oncall-untrusted-agent invariant. In normal operation worker-pool sets
6922
- // SESSION_DATA_DIR and the backend is pty/tmux, so this never trips.
6923
- const dataDir = process.env.SESSION_DATA_DIR;
6924
- if (effectiveBackendType !== 'pty' && effectiveBackendType !== 'tmux') {
6925
- const msg = `Sandbox ENABLED but backend "${effectiveBackendType}" is not sandboxable (only pty/tmux) — aborting spawn to avoid an unsandboxed run`;
6926
- log(msg);
6927
- throw new Error(msg);
7177
+ const mandatoryDenyPaths = [];
7178
+ const mandatoryDenyRegexes = [];
7179
+ const mandatoryReadOnlyPaths = [];
7180
+ // Linux: the per-session sandbox tree (`sandboxes/<sid>`) holds the deny-mask
7181
+ // cleanup manifest + the mode-000 empty ro-bind SOURCES. If SESSION_DATA_DIR
7182
+ // is configured INSIDE the working dir (a custom data dir under a RW-bound
7183
+ // project), the project's readWrite rule would otherwise cover the whole
7184
+ // tree and let the sandboxed CLI tamper with the manifest (→ trick teardown
7185
+ // into deleting arbitrary host paths) or the empty sources. Deny the tree
7186
+ // as a MANDATORY rule (user paths can't override); the outbox is re-granted
7187
+ // readWrite via ctx.outbox as a deeper carve-out (the compiler masks the
7188
+ // tree with a tmpfs that hosts the nested outbox bind, then remounts it RO —
7189
+ // manifest/empties stay unreadable AND unwritable in-sandbox). bwrap reads
7190
+ // ro-bind SOURCES from the host FS, so the empty masks still work even though
7191
+ // the child can't see them.
7192
+ if (process.platform === 'linux') {
7193
+ mandatoryDenyPaths.push(join(canonical(dataDir), 'sandboxes', cfg.sessionId));
6928
7194
  }
6929
- if (!dataDir) {
6930
- const msg = 'Sandbox ENABLED but SESSION_DATA_DIR is unset — aborting spawn to avoid an unsandboxed run';
6931
- log(msg);
6932
- throw new Error(msg);
7195
+ readIsolationOriginCapabilityFile = process.platform === 'darwin'
7196
+ ? managedOriginCapabilityPath(dataDir, cfg.sessionId)
7197
+ : null;
7198
+ // The macOS child reads the per-session rotating capability directly.
7199
+ // Materialize it before the policy's existence filter, and make the exact
7200
+ // file a mandatory read-only carve-out that user rules cannot shadow.
7201
+ if (readIsolationOriginCapabilityFile) {
7202
+ publishSandboxRelayCapability({ failClosed: true });
7203
+ mandatoryReadOnlyPaths.push(readIsolationOriginCapabilityFile);
6933
7204
  }
6934
- try {
6935
- if (willReattachPersistent) {
6936
- // Daemon-restart reattach to a persistent (tmux/herdr/zellij) pane whose
6937
- // bwrap'd CLI is STILL ALIVE. backend.spawn() ignores bin/args here and
6938
- // just re-attaches, and the live CLI is bound to its own namespace-pinned
6939
- // overlay — so we must NOT unmount/remount (prepareSandbox would leave a
6940
- // duplicate host-side overlay the CLI isn't using). We only re-wire the
6941
- // outbox watcher (so the live CLI's `botmux send` keeps being serviced)
6942
- // and the cleanup ref (so close/exit reclaims the residue). No re-prep.
6943
- const att = attachSandboxOutbox({ sessionId: cfg.sessionId, dataDir });
6944
- if (att) {
6945
- if (sandboxStopWatcher) {
6946
- try {
6947
- sandboxStopWatcher();
6948
- }
6949
- catch { /* */ }
6950
- }
6951
- if (sandboxCleanup) {
6952
- try {
6953
- sandboxCleanup();
7205
+ if (credentialBoundaryActive) {
7206
+ const credentialRules = buildCredentialIsolationRules({
7207
+ homeDir: sandboxHome,
7208
+ botmuxHome: canonical(configuredBotmuxHome),
7209
+ defaultBotmuxHome: canonical(defaultBotmuxHome),
7210
+ });
7211
+ mandatoryDenyPaths.push(...credentialRules.denyPaths.map(canonical));
7212
+ mandatoryDenyRegexes.push(...credentialRules.denyRegexes);
7213
+ // Materialize every existing atomic sidecar as a concrete Linux mask.
7214
+ // Seatbelt additionally keeps the regexes above for files created later.
7215
+ for (const root of [...new Set([defaultBotmuxHome, configuredBotmuxHome])]) {
7216
+ try {
7217
+ for (const name of readdirSync(root)) {
7218
+ if (isCredentialIsolationReservedBasename(name)) {
7219
+ mandatoryDenyPaths.push(canonical(join(root, name)));
6954
7220
  }
6955
- catch { /* */ }
6956
7221
  }
6957
- sandboxCleanup = att.cleanup;
6958
- sandboxRelayOutbox = att.outbox;
6959
- sandboxStopWatcher = startOutboxWatcher(att.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
6960
- publishSandboxRelayCapability();
6961
- log(`Sandbox REATTACH (${cfg.cliId}): live pane CLI kept, re-wired outbox=${att.outbox} (no remount)`);
6962
7222
  }
6963
- else {
6964
- // No sandbox tree on disk for a session we're reattaching to: the
6965
- // pane's CLI may be unsandboxed (sandbox enabled after it spawned). Do
6966
- // NOT remount under a live CLI; just continue the reattach as-is.
6967
- log(`Sandbox REATTACH (${cfg.cliId}): no on-disk sandbox tree — reattaching live pane without re-prep`);
7223
+ catch { /* absent authority root */ }
7224
+ }
7225
+ }
7226
+ if (mcpRuntimeManifest) {
7227
+ mandatoryDenyPaths.push(...sessionMcpRuntimeHostOnlyPaths(mcpRuntimeManifest, config.session.dataDir).map(canonical));
7228
+ }
7229
+ if (process.platform === 'darwin') {
7230
+ const gatewaySocketRoot = canonical(sessionMcpGatewayHost ? dirname(sessionMcpGatewayHost.socketDir) : tmpdir());
7231
+ mandatoryDenyRegexes.push(sessionMcpGatewayPathRegex(gatewaySocketRoot));
7232
+ if (sessionMcpGatewayHost) {
7233
+ mandatoryReadOnlyPaths.push(canonical(sessionMcpGatewayHost.socketDir));
7234
+ }
7235
+ }
7236
+ const policy = buildFsPolicy({
7237
+ platform: process.platform,
7238
+ homeDir: sandboxHome,
7239
+ botmuxHome: canonical(dirname(dataDir)),
7240
+ sessionDataDir: canonical(dataDir),
7241
+ workingDir: canonical(cfg.workingDir),
7242
+ currentAppId: cfg.larkAppId,
7243
+ sessionId: cfg.sessionId,
7244
+ botHome: canonical(ownBotHome),
7245
+ redirectedCliData: willRedirectCliData,
7246
+ cliDataPaths: willRedirectCliData ? undefined : keepExisting([
7247
+ cliAdapter.claudeDataDir,
7248
+ claudeDataDir ? `${sandboxHome}/.claude.json` : undefined,
7249
+ claudeDataDir ? `${sandboxHome}/.claude.json.lock` : undefined,
7250
+ claudeDataDir ? `${sandboxHome}/.claude.lock` : undefined,
7251
+ claudeDataDir ? `${sandboxHome}/.local/state/claude` : undefined,
7252
+ ]),
7253
+ authPaths: keepExisting([...(cliAdapter.authPaths ?? [])]),
7254
+ execPaths: keepExisting([...execDirs, ...execCarve]),
7255
+ readonlyRoots: keepExisting([
7256
+ ...(cfg.skillReadonlyRoots ?? []),
7257
+ ...piInitialPromptReadonlyRoots,
7258
+ ]),
7259
+ botmuxInstallRoot,
7260
+ outbox,
7261
+ extraWritePaths: keepExisting([process.env.TMPDIR]),
7262
+ userPaths,
7263
+ mandatoryDenyPaths,
7264
+ mandatoryDenyRegexes,
7265
+ mandatoryReadOnlyPaths,
7266
+ net: cfg.sandboxNetwork !== false,
7267
+ // Claude Code saves ~/.claude.json atomically via a PID/random-suffixed
7268
+ // sibling — only relevant when the data dir is NOT redirected to BOT_HOME.
7269
+ writeRegexes: process.platform === 'darwin' && !willRedirectCliData && claudeDataDir
7270
+ ? [`^${sandboxHome.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/\\.claude\\.json\\.tmp\\.[^/]+$`]
7271
+ : [],
7272
+ });
7273
+ // Existence-filter only the ALLOW rules (baseline entries are candidates,
7274
+ // not guarantees): a non-existent allow path has nothing to expose, and
7275
+ // bwrap cannot bind a non-existent SOURCE. DENY rules are kept regardless —
7276
+ // they must guard a path created later in the session (Seatbelt keeps the
7277
+ // literal rule; bwrap masks with a tmpfs whose mountpoint the mount creates).
7278
+ policy.rules = policy.rules.filter(r => {
7279
+ if (r.access === 'deny')
7280
+ return true;
7281
+ try {
7282
+ return existsSync(r.path);
7283
+ }
7284
+ catch {
7285
+ return false;
7286
+ }
7287
+ });
7288
+ if (process.platform === 'darwin') {
7289
+ if (!locateOnPath('sandbox-exec')) {
7290
+ throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: sandbox-exec not found`);
7291
+ }
7292
+ const profileDir = join(dataDir, 'read-isolation');
7293
+ mkdirSync(profileDir, { recursive: true });
7294
+ const profilePath = join(profileDir, `${cfg.sessionId}.sb`);
7295
+ writeFileSync(profilePath, compileToSeatbelt(policy), { mode: 0o600 });
7296
+ seatbeltProfilePath = profilePath;
7297
+ spawnArgs = ['-f', profilePath, spawnBin, ...spawnArgs];
7298
+ spawnBin = 'sandbox-exec';
7299
+ log(`[file-sandbox] wrapping ${cliAdapter.id} in Seatbelt (fs-policy, ${policy.rules.length} rules): sandbox-exec -f ${profilePath}`);
7300
+ }
7301
+ else if (willReattachPersistent) {
7302
+ // Daemon-restart reattach to a live bwrap'd pane: backend.spawn() ignores
7303
+ // bin/args and just re-attaches. Only re-wire the outbox watcher (so the
7304
+ // live CLI's `botmux send` keeps being serviced) + the cleanup ref. The
7305
+ // direct model has NO mounts — cleanup is a plain rm at close/exit.
7306
+ const att = attachSandboxOutbox({ sessionId: cfg.sessionId, dataDir });
7307
+ if (att) {
7308
+ if (sandboxStopWatcher) {
7309
+ try {
7310
+ sandboxStopWatcher();
7311
+ }
7312
+ catch { /* */ }
6968
7313
  }
7314
+ sandboxCleanup = att.cleanup;
7315
+ sandboxRelayOutbox = att.outbox;
7316
+ sandboxStopWatcher = startOutboxWatcher(att.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
7317
+ publishSandboxRelayCapability();
7318
+ log(`Sandbox REATTACH (${cfg.cliId}): live pane CLI kept, re-wired outbox=${att.outbox}`);
6969
7319
  }
6970
7320
  else {
6971
- const sbx = prepareSandbox({
6972
- enabled: sandboxOn,
6973
- cliId: cfg.cliId,
6974
- sessionId: cfg.sessionId,
6975
- sourceWorkingDir: cfg.workingDir,
6976
- dataDir,
6977
- cliBin: cliAdapter.resolvedBin,
6978
- cliArgs: args,
6979
- // Read-isolation masks (Linux) fold into the same plan: hide the cross-bot
6980
- // sensitive set, keep the own BOT_HOME real+writable (authPaths), and re-
6981
- // expose the own attachments bucket read-only after the masks (readonlyRoots).
6982
- hidePaths: [...(cfg.sandboxHidePaths ?? []), ...(readIsoLinuxMasks?.hidePaths ?? [])],
6983
- authPaths: [...(cliAdapter.authPaths ?? [])],
6984
- // BOTMUX_HOME is credential-masked wholesale. Re-open only this
6985
- // bot's daemon-derived private CLI home after that parent mask, then
6986
- // re-mask its send credential so an untrusted receiver cannot bypass
6987
- // the host-authorized relay with a direct Lark API call.
6988
- trustedWritablePaths: readIsoLinuxMasks?.ownReadWritePaths ?? [],
6989
- finalHidePaths: readIsoLinuxMasks
6990
- ? [sendCredFilePath(dataDir, cfg.larkAppId)]
6991
- : [],
6992
- extraExecPaths: cliAdapter.sandboxExtraExecPaths?.(),
6993
- readonlyRoots: [
6994
- ...(cfg.skillReadonlyRoots ?? []),
6995
- ...piInitialPromptReadonlyRoots,
6996
- ...(readIsoLinuxMasks?.ownReadOnlyPaths ?? []),
6997
- ],
6998
- mcpGatewaySocketPath: sessionMcpGatewayHost?.socketPath,
6999
- trustedBotmuxCommandPaths: [defaultGatewayEntry().command],
7000
- userReadonlyPaths: cfg.sandboxReadonlyPaths ?? [],
7001
- net: cfg.sandboxNetwork !== false,
7002
- });
7003
- if (sbx) {
7004
- spawnBin = sbx.bin;
7005
- spawnArgs = sbx.args;
7006
- // In the overlay model the child still chdirs to projectMount (via bwrap
7007
- // --chdir), so spawnCwd stays the real workingDir; the overlay merged dir
7008
- // is bound there. sbx.workDir is the UPPER changeset (for landing), NOT a cwd.
7009
- Object.assign(childEnv, sbx.env);
7010
- if (sandboxStopWatcher) {
7011
- try {
7012
- sandboxStopWatcher();
7013
- }
7014
- catch { /* */ }
7015
- }
7016
- if (sandboxCleanup) {
7017
- try {
7018
- sandboxCleanup();
7019
- }
7020
- catch { /* */ }
7021
- }
7022
- sandboxCleanup = sbx.cleanup;
7023
- sandboxRelayOutbox = sbx.outbox;
7024
- // session-id is FORCED here so a relayed send can't target another session.
7025
- sandboxStopWatcher = startOutboxWatcher(sbx.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
7026
- publishSandboxRelayCapability();
7027
- log(`Sandbox ON (${cfg.cliId}): upper=${sbx.workDir} outbox=${sbx.outbox}`);
7321
+ log(`Sandbox REATTACH (${cfg.cliId}): no on-disk sandbox tree — reattaching live pane as-is`);
7322
+ }
7323
+ }
7324
+ else {
7325
+ const sbx = prepareDirectSandbox({
7326
+ sessionId: cfg.sessionId,
7327
+ dataDir,
7328
+ policy,
7329
+ chdir: canonical(cfg.workingDir),
7330
+ home: sandboxHome,
7331
+ cliBin: cliAdapter.resolvedBin,
7332
+ cliArgs: args,
7333
+ trustedBotmuxCommandPaths: [defaultGatewayEntry().command],
7334
+ mcpGatewaySocketPath: sessionMcpGatewayHost?.socketPath,
7335
+ });
7336
+ if (!sbx) {
7337
+ // FAIL-SAFE: never silently run unsandboxed.
7338
+ const msg = 'sandbox requested but could not be established (bwrap missing or setup failed) — aborting spawn';
7339
+ log(msg);
7340
+ throw new Error(msg);
7341
+ }
7342
+ spawnBin = sbx.bin;
7343
+ spawnArgs = sbx.args;
7344
+ Object.assign(childEnv, sbx.env);
7345
+ if (sandboxStopWatcher) {
7346
+ try {
7347
+ sandboxStopWatcher();
7028
7348
  }
7029
- else {
7030
- // Sandbox was requested but prepareSandbox returned null (a required
7031
- // overlay mount failed, or non-Linux). Fail safe: do NOT silently run
7032
- // unsandboxed — surface a hard error so the session doesn't leak.
7033
- log(`Sandbox ENABLED but prepare returned null (mount failed / unsupported) — aborting spawn to avoid an unsandboxed run`);
7034
- throw new Error('sandbox requested but could not be established');
7349
+ catch { /* */ }
7350
+ }
7351
+ if (sandboxCleanup) {
7352
+ try {
7353
+ sandboxCleanup();
7035
7354
  }
7355
+ catch { /* */ }
7036
7356
  }
7357
+ sandboxCleanup = sbx.cleanup;
7358
+ sandboxRelayOutbox = sbx.outbox;
7359
+ // session-id is FORCED here so a relayed send can't target another session.
7360
+ sandboxStopWatcher = startOutboxWatcher(sbx.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
7361
+ publishSandboxRelayCapability();
7362
+ log(`Sandbox ON (${cfg.cliId}, fs-policy ${policy.rules.length} rules): outbox=${sbx.outbox}`);
7037
7363
  }
7038
- catch (err) {
7039
- log(`Sandbox prepare failed (${err.message}) aborting (sandbox is a hard requirement when enabled)`);
7040
- throw err;
7364
+ }
7365
+ // Fresh sandboxed spawn on a persistent backend: stamp the pane with this
7366
+ // daemon's boot id so a later reattach can be trusted (see the stale-pane
7367
+ // guard above). pty needs no marker (never reattached).
7368
+ if (appliedIsolationCapabilities.length > 0 && persistentSessionName && !willReattachPersistent) {
7369
+ try {
7370
+ const markerDir = join(isolationRuntimeDataDir, 'read-isolation');
7371
+ mkdirSync(markerDir, { recursive: true });
7372
+ writeFileSync(join(markerDir, `${cfg.sessionId}.boot`), isolationPaneMarkerContent(cfg.daemonBootId ?? '', appliedIsolationCapabilities), { mode: 0o600 });
7041
7373
  }
7374
+ catch { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ }
7042
7375
  }
7043
7376
  // 通用启动前缀(wrapperCli):把启动命令重写成 `<wrapperCli> <CLI 参数>`(首 token 当
7044
7377
  // bin 走 PATH 解析),无需 wrapper 脚本、跨系统。aiden x claude 形态会剥掉 aiden 拒收的
7045
7378
  // --settings(见 buildWrappedLaunch)。与文件沙盒互斥:沙盒已把命令重写成 bwrap,叠加
7046
- // 前缀会破坏隔离,故 sandboxOn 时跳过并告警(网关 + oncall 沙盒本就不是合理组合)。
7379
+ // 前缀会破坏隔离,故沙盒开启时跳过并告警(网关 + oncall 沙盒本就不是合理组合)。
7047
7380
  // CJADK_INTERACTIVE is a cjadk-only knob we set on the cjadk wrapper branch
7048
7381
  // below. Strip any value inherited from the daemon's own env first so a
7049
7382
  // daemon launched under `cjadk feishu` (which exports it) can't leak it via
@@ -7052,8 +7385,8 @@ async function spawnCli(cfg, opts = {}) {
7052
7385
  // keeps the behaviour intentional rather than ambient. (Codex review note.)
7053
7386
  delete childEnv.CJADK_INTERACTIVE;
7054
7387
  if (cfg.wrapperCli && cfg.wrapperCli.trim()) {
7055
- if (sandboxOn) {
7056
- log(`wrapperCli="${cfg.wrapperCli}" ignored: file sandbox enabled and takes precedence (cannot combine launch prefix with bwrap)`);
7388
+ if (sandboxRequested) {
7389
+ log(`wrapperCli="${cfg.wrapperCli}" ignored: file sandbox enabled and takes precedence (cannot combine launch prefix with the sandbox wrapper)`);
7057
7390
  }
7058
7391
  else {
7059
7392
  const launch = buildWrappedLaunch(cfg.wrapperCli, spawnArgs, (b) => locateOnPath(b) ?? b, {
@@ -7087,6 +7420,19 @@ async function spawnCli(cfg, opts = {}) {
7087
7420
  }
7088
7421
  }
7089
7422
  }
7423
+ // Publish the exact selected resource BEFORE spawn. This both restores host
7424
+ // affinity for later in-worker restarts and closes the crash window where a
7425
+ // shared Herdr agent could be created but the daemon still knew only bmx-*.
7426
+ // A failed spawn leaving an intent stamp is safe: lifecycle probes see the
7427
+ // missing agent and close/cold-resume instead of leaking an untracked pane.
7428
+ cfg.persistentBackendTarget = selectedBackend.persistentBackendTarget;
7429
+ if (lastInitConfig) {
7430
+ lastInitConfig.persistentBackendTarget = selectedBackend.persistentBackendTarget;
7431
+ }
7432
+ send({
7433
+ type: 'persistent_backend_target',
7434
+ target: selectedBackend.persistentBackendTarget,
7435
+ });
7090
7436
  // Mandatory credential-only confinement is the OUTERMOST launch wrapper so
7091
7437
  // wrapperCli and every descendant it starts inherit the boundary. Full
7092
7438
  // Seatbelt/bwrap sessions were already wrapped above and never enter these
@@ -7224,6 +7570,32 @@ async function spawnCli(cfg, opts = {}) {
7224
7570
  log(`[device-credential-isolation] wrapping ${cliAdapter.id} in credential-only bwrap `
7225
7571
  + `(${hideDirectories.size} authority dir(s), ${hideFiles.size} exact file(s))`);
7226
7572
  }
7573
+ // Dashboard「复现命令」:算出本次冷启的**近似**可复现命令(bin + argv + cwd +
7574
+ // 权威注入 env),随 ready 上报、只驻 daemon 内存(含凭证,绝不落盘)。基础 CLI
7575
+ // bin/args 取 sandbox 包装前的快照,最终形态(含/不含 wrapperCli)由
7576
+ // selectReproduceLaunch 决策——绝不含 sandbox-exec/bwrap 外层。riff 返回 null。
7577
+ try {
7578
+ const reproduceLaunch = selectReproduceLaunch({
7579
+ baseBin: reproduceBaseBin,
7580
+ baseArgs: reproduceBaseArgs,
7581
+ wrapperCli: cfg.wrapperCli,
7582
+ sandboxOn: sandboxRequested,
7583
+ binResolver: (b) => locateOnPath(b) ?? b,
7584
+ ttadkModel: cfg.model,
7585
+ });
7586
+ capturedSpawnCommand = buildReproduceCommand({
7587
+ backendType: effectiveBackendType,
7588
+ bin: reproduceLaunch.bin,
7589
+ args: reproduceLaunch.args,
7590
+ cwd: spawnCwd,
7591
+ env: childEnv,
7592
+ injectEnv: perBotInjectKeys.length ? perBotInjectEnv : undefined,
7593
+ });
7594
+ }
7595
+ catch (err) {
7596
+ capturedSpawnCommand = null;
7597
+ log(`Failed to capture reproduce command: ${err?.message ?? err}`);
7598
+ }
7227
7599
  backend.spawn(spawnBin, spawnArgs, {
7228
7600
  cwd: spawnCwd,
7229
7601
  cols: PTY_COLS,
@@ -7232,6 +7604,13 @@ async function spawnCli(cfg, opts = {}) {
7232
7604
  injectEnv: perBotInjectKeys.length ? perBotInjectEnv : undefined,
7233
7605
  launchShell: lastInitConfig?.launchShell,
7234
7606
  });
7607
+ if (selectedBackend.createdHerdrSessionName) {
7608
+ send({
7609
+ type: 'user_notify',
7610
+ turnId: currentBotmuxTurnId,
7611
+ message: `已创建 Botmux 专属 Herdr 会话:\`${selectedBackend.createdHerdrSessionName}\``,
7612
+ });
7613
+ }
7235
7614
  // Write CLI PID marker so agent-facing subcommands (`botmux send`, etc.)
7236
7615
  // can verify they were spawned inside a botmux session by walking the
7237
7616
  // process tree and looking for a matching pid file in this directory.
@@ -7266,7 +7645,7 @@ async function spawnCli(cfg, opts = {}) {
7266
7645
  // MARKER inference is unaffected (the launcher-pid marker is still a valid
7267
7646
  // ancestor of an in-CLI `botmux send`, and the env fallback covers it too).
7268
7647
  const startWrapperRealPidResolve = (launcherPid) => {
7269
- if (!cfg.wrapperCli || !cfg.wrapperCli.trim() || sandboxOn || !claudeDataDir)
7648
+ if (!cfg.wrapperCli || !cfg.wrapperCli.trim() || sandboxRequested || !claudeDataDir)
7270
7649
  return;
7271
7650
  const targetCliId = cfg.cliId;
7272
7651
  scheduleWrapperRealCliPid(launcherPid, {
@@ -7488,7 +7867,7 @@ async function spawnCli(cfg, opts = {}) {
7488
7867
  const readyHookAvailable = effectiveReadyHookInstall
7489
7868
  ? hasInstalledSessionReadyHook(effectiveReadyHookInstall)
7490
7869
  : true; // Hermes emits BOTMUX_READY_COMMAND directly instead of a config hook.
7491
- const isolatedReadyTransportRequired = sandboxOn || willReadIsolate;
7870
+ const isolatedReadyTransportRequired = sandboxRequested || credentialBoundaryActive;
7492
7871
  const readyPortAvailable = !isolatedReadyTransportRequired
7493
7872
  || parseDaemonIpcPort(childEnv.BOTMUX_DAEMON_IPC_PORT) !== undefined;
7494
7873
  const readyCapabilityAvailable = !isolatedReadyTransportRequired
@@ -7519,44 +7898,59 @@ async function spawnCli(cfg, opts = {}) {
7519
7898
  }, READY_SIGNAL_TIMEOUT_MS);
7520
7899
  readySignalTimer.unref?.();
7521
7900
  }
7901
+ // A settled signal is a turn boundary. Drain structured transcripts before
7902
+ // markPromptReady(): that call may synchronously flush a type-ahead turn and
7903
+ // advance bridge attribution. Both screen-idle and authoritative Herdr status
7904
+ // must preserve this ordering.
7905
+ const drainBridgesThenMarkReady = (evidenceSource) => {
7906
+ if (bridgeJsonlPath) {
7907
+ try {
7908
+ bridgeDrainAndMaybeEmit();
7909
+ }
7910
+ catch (err) {
7911
+ log(`Bridge emit error: ${err.message}`);
7912
+ }
7913
+ }
7914
+ if (codexBridgeFallbackActive()) {
7915
+ try {
7916
+ codexBridgeDrainAndMaybeEmit();
7917
+ }
7918
+ catch (err) {
7919
+ log(`Codex bridge emit error: ${err.message}`);
7920
+ }
7921
+ }
7922
+ if (evidenceSource === 'screen')
7923
+ markPromptReadyFromPty();
7924
+ else
7925
+ markPromptReady();
7926
+ };
7522
7927
  // Set up idle detection. Riff (remote HTTP backend) has no PTY output and
7523
7928
  // is marked ready immediately after spawn (see below), so the idle detector
7524
7929
  // is unnecessary — and without a readyPattern it would fire on every
7525
7930
  // quiescence, repeatedly triggering markPromptReady() and duplicate cards.
7526
7931
  if (effectiveBackendType !== 'riff') {
7527
7932
  idleDetector = new IdleDetector(cliAdapter);
7528
- idleDetector.onIdle(async (evidenceSource) => {
7933
+ idleDetector.onIdle((evidenceSource) => {
7529
7934
  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
- }
7935
+ drainBridgesThenMarkReady(evidenceSource);
7556
7936
  });
7557
7937
  }
7558
7938
  backend.onData(onPtyData);
7559
7939
  const observedBackend = backend;
7940
+ if (observedBackend instanceof HerdrBackend) {
7941
+ observedBackend.onAgentStatus((status) => {
7942
+ if (backend !== observedBackend)
7943
+ return;
7944
+ if (status === 'idle' || status === 'done') {
7945
+ log(`Herdr agent ${status} — draining bridges before marking prompt ready`);
7946
+ drainBridgesThenMarkReady('structured');
7947
+ }
7948
+ else if (status === 'working') {
7949
+ isPromptReady = false;
7950
+ idleDetector?.reset();
7951
+ }
7952
+ });
7953
+ }
7560
7954
  backend.onAccessUrl?.((url) => {
7561
7955
  send({
7562
7956
  type: 'riff_access_url',
@@ -7744,6 +8138,7 @@ async function spawnCli(cfg, opts = {}) {
7744
8138
  }
7745
8139
  function killCli(opts = {}) {
7746
8140
  currentCliCredentialIsolated = false;
8141
+ stopNativeSessionTitleSync();
7747
8142
  stopSessionMcpGatewayHost();
7748
8143
  stopCodexRpcEngine();
7749
8144
  if (!opts.preservePending)
@@ -7767,6 +8162,7 @@ function killCli(opts = {}) {
7767
8162
  readyPatternSeenDuringHold = false;
7768
8163
  awaitingPostSessionStartPromptEvidence = false;
7769
8164
  stopScreenAnalyzer();
8165
+ stopStuckDetector();
7770
8166
  stopScreenUpdates();
7771
8167
  backend?.kill();
7772
8168
  backend = null;
@@ -7883,6 +8279,7 @@ async function restartCliProcess(reason, opts = {}) {
7883
8279
  if (lastInitConfig) {
7884
8280
  startScreenUpdates();
7885
8281
  startScreenAnalyzer();
8282
+ startStuckDetector();
7886
8283
  try {
7887
8284
  const restartCfg = { ...lastInitConfig, resume: true, prompt: '', cliSessionId: rpcThreadId ?? lastInitConfig.cliSessionId };
7888
8285
  // Re-engage RPC so the new --remote pane binds to the CURRENT app-server
@@ -7896,6 +8293,7 @@ async function restartCliProcess(reason, opts = {}) {
7896
8293
  await engageCodexRpc(restartCfg);
7897
8294
  }
7898
8295
  await spawnCli(restartCfg, { pluginGenerationPrepared: rpcPluginGenerationPrepared });
8296
+ await prepareCodexNativeTitleGeneration(restartCfg, codexRpcEngine);
7899
8297
  if (codexRpcEngine)
7900
8298
  armRpcStartupDialogDismiss();
7901
8299
  }
@@ -7960,8 +8358,24 @@ function startWebServer(host, preferredPort) {
7960
8358
  // normal buffer until resize causes a redraw. Preserve that mode so
7961
8359
  // scroll gestures continue to target the CLI's own transcript.
7962
8360
  const forceRemoteScroll = effectiveBackendType === 'herdr' && cliAdapter?.altScreen === true;
8361
+ // The wheel burst cap throttles backends where a forwarded wheel is
8362
+ // EXPENSIVE or has no local terminal to drive:
8363
+ // • Herdr — each forwarded wheel → pane send-text + snapshot re-render.
8364
+ // • Riff — has NO drivable terminal; writes become remote task/follow-up
8365
+ // creations (handleTermAction even rejects ANSI to Riff), so an
8366
+ // uncapped spin would flood remote task creation.
8367
+ // This is a BACKEND property, independent of the adapter's declared
8368
+ // altScreen: an altScreen:false CLI (Claude/Codex) that enters the
8369
+ // alternate buffer at runtime (vim/less, or the CLI's own alt-screen) still
8370
+ // forwards via _fwdScroll and must inherit its backend's cap. So gate the
8371
+ // cap on a POSITIVE allowlist of cheap, locally-drivable terminal backends
8372
+ // (pty/tmux/zellij); every other backend — Herdr, Riff, and any future one
8373
+ // — stays safely capped by default.
8374
+ const localTerminalBackend = effectiveBackendType === 'pty'
8375
+ || effectiveBackendType === 'tmux'
8376
+ || effectiveBackendType === 'zellij';
7963
8377
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
7964
- res.end(getTerminalHtml(hasWrite, platformReadonly, loginUrl, forceRemoteScroll));
8378
+ res.end(getTerminalHtml(hasWrite, platformReadonly, loginUrl, forceRemoteScroll, localTerminalBackend));
7965
8379
  });
7966
8380
  wss = new WebSocketServer({
7967
8381
  server: httpServer,
@@ -8258,7 +8672,7 @@ function startWebServer(host, preferredPort) {
8258
8672
  .then(resolve, reject);
8259
8673
  });
8260
8674
  }
8261
- function getTerminalHtml(hasWrite, platformReadonly = false, loginUrl = '', forceRemoteScroll = false) {
8675
+ function getTerminalHtml(hasWrite, platformReadonly = false, loginUrl = '', forceRemoteScroll = false, localTerminalBackend = false) {
8262
8676
  const label = sessionId.substring(0, 8);
8263
8677
  return `<!DOCTYPE html>
8264
8678
  <html>
@@ -8395,6 +8809,7 @@ if(isTouch){document.body.classList.add('touch');}
8395
8809
  var hasToken=${hasWrite};
8396
8810
  var platformReadonly=${platformReadonly};
8397
8811
  var remoteScroll=${forceRemoteScroll};
8812
+ var localTerminalBackend=${localTerminalBackend};
8398
8813
  if(!hasToken){
8399
8814
  if(platformReadonly){var _lb=document.getElementById('login-banner');_lb.classList.add('show');}
8400
8815
  else{var _rb=document.getElementById('readonly-banner');_rb.classList.add('show');_rb.addEventListener('click',function(){_rb.classList.remove('show')});}
@@ -8601,8 +9016,21 @@ window.addEventListener('resize',onViewportResize);
8601
9016
  // A high-resolution trackpad emits dozens of wheel events for one gesture, so
8602
9017
  // cap the whole continuous burst — not each browser event — then require an idle
8603
9018
  // gap (or direction reversal) before loading the next history chunk.
9019
+ //
9020
+ // The burst ceiling throttles backends where a forwarded wheel is EXPENSIVE or
9021
+ // has no local terminal to drive: Herdr (each tick → pane send-text + snapshot
9022
+ // re-render) and Riff (no drivable terminal — writes become remote task/follow-up
9023
+ // creations, so an uncapped spin would flood remote task creation). That cost is
9024
+ // a BACKEND property, not the adapter's declared altScreen — an altScreen:false
9025
+ // CLI (Claude/Codex) that enters the alternate buffer at runtime (vim/less, the
9026
+ // CLI's own alt-screen) still hits this path and inherits its backend's cap. A
9027
+ // local PTY/tmux/zellij backend repaints its own alt-screen cheaply, so capping
9028
+ // it at 6 ticks is what froze a continuous wheel spin after ~2 notches — the
9029
+ // "not smooth / stuck" symptom. Gate the release on a POSITIVE allowlist of
9030
+ // cheap local terminal backends (localTerminalBackend); every other backend —
9031
+ // Herdr, Riff, and any future one — stays safely capped by default.
8604
9032
  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;
9033
+ var _SCROLL_STEP=33;var _SCROLL_BURST_MAX=localTerminalBackend?Infinity:6;var _SCROLL_BURST_IDLE_MS=250;
8606
9034
  function _endScrollBurst(){
8607
9035
  clearTimeout(_scrollBurstT);_scrollBurstT=0;
8608
9036
  _scrollAccum=0;_scrollBurstTicks=0;_scrollBurstDir=0;
@@ -9058,6 +9486,10 @@ function send(msg) {
9058
9486
  }
9059
9487
  process.send?.(payload);
9060
9488
  }
9489
+ function acknowledgeTurnInputCommitted(turnId) {
9490
+ if (turnId)
9491
+ send({ type: 'turn_input_committed', turnId });
9492
+ }
9061
9493
  function publishLocalProcessAttestation(cliPid) {
9062
9494
  const cliProcStart = cliPid ? readProcessStartIdentity(cliPid) : undefined;
9063
9495
  send({
@@ -9115,11 +9547,13 @@ process.on('message', async (raw) => {
9115
9547
  const msg = raw;
9116
9548
  switch (msg.type) {
9117
9549
  case 'init': {
9550
+ const initStartedAtMs = Date.now();
9118
9551
  if (lastInitConfig)
9119
9552
  return; // already initialized
9120
9553
  lastInitConfig = msg;
9121
9554
  sessionId = msg.sessionId;
9122
9555
  refreshTerminalViewToken();
9556
+ refreshTerminalWriteToken();
9123
9557
  if (msg.ownerOpenId)
9124
9558
  process.env.__OWNER_OPEN_ID = msg.ownerOpenId;
9125
9559
  // Pin this worker's i18n locale early so every t() call below resolves
@@ -9163,6 +9597,7 @@ process.on('message', async (raw) => {
9163
9597
  port = await startWebServer(config.web.workerHost, msg.webPort);
9164
9598
  startScreenUpdates();
9165
9599
  startScreenAnalyzer();
9600
+ startStuckDetector();
9166
9601
  }
9167
9602
  else {
9168
9603
  // Workflow attempts still expose a read-only web terminal so the
@@ -9206,6 +9641,7 @@ process.on('message', async (raw) => {
9206
9641
  throw new Error('codex RPC resume: could not replace stale --remote pane; aborting init');
9207
9642
  }
9208
9643
  await spawnCli(msg, { pluginGenerationPrepared: rpcPluginGenerationPrepared });
9644
+ await prepareCodexNativeTitleGeneration(msg, codexRpcEngine);
9209
9645
  if (codexRpcEngine)
9210
9646
  armRpcStartupDialogDismiss(); // boundary #4: keep the --remote pane from freezing on a startup dialog
9211
9647
  // Queue the initial prompt — flushed when CLI shows idle.
@@ -9237,6 +9673,7 @@ process.on('message', async (raw) => {
9237
9673
  // execution — exactly-once, Codex P1-1). Ambiguous never reaches here.
9238
9674
  // - RPC RESUME: queuePrompt=true → queue for post-ready flush (bridge
9239
9675
  // marked at flush time, P0-1).
9676
+ let initialInputCommitted = false;
9240
9677
  if (shouldQueueInitialPrompt({
9241
9678
  hasPrompt: !!msg.prompt,
9242
9679
  rpcEngineActive: !!codexRpcEngine,
@@ -9254,13 +9691,22 @@ process.on('message', async (raw) => {
9254
9691
  vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
9255
9692
  codexAppInput: msg.promptCodexAppInput,
9256
9693
  });
9694
+ initialInputCommitted = true;
9257
9695
  }
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) {
9696
+ else if (msg.prompt) {
9697
+ // A successful spawn with a non-queued prompt means the adapter baked
9698
+ // it into argv or the RPC engine already accepted it.
9699
+ initialInputCommitted = true;
9700
+ }
9701
+ if (initialInputCommitted)
9702
+ acknowledgeTurnInputCommitted(msg.turnId);
9703
+ // A backend may become prompt-ready before spawnCli() returns. The
9704
+ // initial prompt is queued only afterwards, so the earlier
9705
+ // markPromptReady() necessarily flushed an empty queue. This is normal
9706
+ // for riff (ready immediately) and can also happen when Herdr reports a
9707
+ // fast-starting TUI as idle during spawn. Flush again after enqueueing;
9708
+ // the ready flag keeps booting/busy backends gated.
9709
+ if (isPromptReady && pendingMessages.length > 0) {
9264
9710
  flushPending();
9265
9711
  }
9266
9712
  send({
@@ -9268,6 +9714,11 @@ process.on('message', async (raw) => {
9268
9714
  port,
9269
9715
  token: writeToken,
9270
9716
  viewToken,
9717
+ ...(capturedSpawnCommand ? { spawnCommand: capturedSpawnCommand } : {}),
9718
+ // A fast initial turn can complete via `botmux send` before Herdr
9719
+ // reports idle and this ready IPC is emitted. Tell the daemon not to
9720
+ // post a stale Starting card after the final reply is already visible.
9721
+ replyAlreadySent: readSendMarkers().some(marker => marker.sentAtMs >= initStartedAtMs),
9271
9722
  turnId: currentBotmuxTurnId,
9272
9723
  dispatchAttempt: currentBotmuxDispatchAttempt,
9273
9724
  });
@@ -9282,6 +9733,21 @@ process.on('message', async (raw) => {
9282
9733
  // Mark new turn baseline so the streaming card only shows this turn's content
9283
9734
  renderer?.markNewTurn();
9284
9735
  const turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
9736
+ if (msg.nativeSessionTitlePrompt
9737
+ && msg.nativeSessionTitle
9738
+ && lastInitConfig?.cliId === 'codex'
9739
+ && !lastInitConfig.adoptMode) {
9740
+ nativeSessionTitleRevision += 1;
9741
+ nativeSessionTitleAppliedThreadId = undefined;
9742
+ lastInitConfig.nativeSessionTitle = msg.nativeSessionTitle;
9743
+ lastInitConfig.nativeSessionTitlePrompt = msg.nativeSessionTitlePrompt;
9744
+ stopNativeSessionTitleSync();
9745
+ const threadId = codexRpcEngine?.activeThreadId
9746
+ ?? lastSpawnEffectiveCliSessionId
9747
+ ?? lastInitConfig.cliSessionId;
9748
+ if (threadId)
9749
+ void syncFreshCodexNativeSessionTitle(threadId, codexRpcEngine);
9750
+ }
9285
9751
  // Cancel any active tmux copy-mode scroll so user input reaches the CLI.
9286
9752
  if (tmuxScrolledHalfPages > 0)
9287
9753
  exitTmuxScrollMode();
@@ -9299,12 +9765,16 @@ process.on('message', async (raw) => {
9299
9765
  log('Message received after crash-loop stop; retrying CLI start');
9300
9766
  destroyCrashDiagnosticTerminal('retry after message');
9301
9767
  stopScreenAnalyzer();
9768
+ stopStuckDetector();
9302
9769
  stopScreenUpdates();
9303
9770
  awaitingFirstPrompt = true;
9304
9771
  startScreenUpdates();
9305
9772
  startScreenAnalyzer();
9773
+ startStuckDetector();
9306
9774
  try {
9307
- await spawnCli({ ...lastInitConfig, resume: true, prompt: '' });
9775
+ const restartCfg = { ...lastInitConfig, resume: true, prompt: '' };
9776
+ await spawnCli(restartCfg);
9777
+ await prepareCodexNativeTitleGeneration(restartCfg, codexRpcEngine);
9308
9778
  }
9309
9779
  catch (err) {
9310
9780
  // Pass the message's own attempt (not the stale currentBotmux* from a
@@ -9386,6 +9856,9 @@ process.on('message', async (raw) => {
9386
9856
  if (result && result.submitted === false) {
9387
9857
  scheduleSubmitFailureNotify(content, result.recheck, 'submit history', undefined, result.failureReason, turnSeq);
9388
9858
  }
9859
+ else {
9860
+ acknowledgeTurnInputCommitted(msg.turnId);
9861
+ }
9389
9862
  }
9390
9863
  catch (err) {
9391
9864
  log(`Adopt writeInput error (${lastInitConfig?.cliId}): ${err.message}`);
@@ -9403,9 +9876,11 @@ process.on('message', async (raw) => {
9403
9876
  // command (raw_input) fix.
9404
9877
  await new Promise(r => setTimeout(r, 200));
9405
9878
  backend.sendSpecialKeys('Enter');
9879
+ acknowledgeTurnInputCommitted(msg.turnId);
9406
9880
  }
9407
9881
  else {
9408
9882
  backend.write(content + '\r');
9883
+ acknowledgeTurnInputCommitted(msg.turnId);
9409
9884
  }
9410
9885
  isPromptReady = false;
9411
9886
  idleDetector?.reset();
@@ -9417,11 +9892,13 @@ process.on('message', async (raw) => {
9417
9892
  // arrival. Marking now would race with a still-running previous
9418
9893
  // turn whose `botmux send` could sneak its sentAtMs past this
9419
9894
  // turn's markTimeMs and falsely suppress its fallback.
9420
- sendToPty(content, msg.turnId, {
9895
+ const inputCommitted = sendToPty(content, msg.turnId, {
9421
9896
  codexAppInput,
9422
9897
  dispatchAttempt: msg.dispatchAttempt,
9423
9898
  vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
9424
9899
  });
9900
+ if (inputCommitted)
9901
+ acknowledgeTurnInputCommitted(msg.turnId);
9425
9902
  }
9426
9903
  break;
9427
9904
  }
@@ -9449,6 +9926,13 @@ process.on('message', async (raw) => {
9449
9926
  // IPC handlers are concurrent with async init, so queue first even when
9450
9927
  // the adapter/backend has not finished initializing. flushPending will
9451
9928
  // capability-check and deliver the latest title once a real prompt is idle.
9929
+ nativeSessionTitleRevision += 1;
9930
+ nativeSessionTitleAppliedThreadId = undefined;
9931
+ if (lastInitConfig) {
9932
+ lastInitConfig.nativeSessionTitle = msg.title;
9933
+ lastInitConfig.nativeSessionTitlePrompt = undefined;
9934
+ }
9935
+ stopNativeSessionTitleSync();
9452
9936
  pendingSessionRename = msg.title;
9453
9937
  log(`Queued native session rename: ${msg.title}`);
9454
9938
  void flushPending();
@@ -9545,7 +10029,49 @@ process.on('message', async (raw) => {
9545
10029
  break;
9546
10030
  }
9547
10031
  case 'tui_keys': {
9548
- handleTuiKeys(msg.keys, msg.isFinal);
10032
+ // Stale-card guard: if this key press came from a stuck-warning card,
10033
+ // delegate to processStuckWarningTuiKeys which does a FRESH capture (not
10034
+ // the 2s-cached lastAnalyzerSnapshot) and re-verifies the current screen
10035
+ // still matches the page type the card was built for. Fail-closed: any
10036
+ // mismatch (lifetime, capture, backend, page type, write failure) sends
10037
+ // stuck_warning_expired and drops the keys. ScreenAnalyzer TUI cards
10038
+ // (no stuckNonce) bypass the guard and write keys directly.
10039
+ let wroteKeys = false;
10040
+ if (msg.stuckNonce !== undefined && msg.stuckPageType) {
10041
+ const result = await processStuckWarningTuiKeys({
10042
+ stuckNonce: msg.stuckNonce,
10043
+ stuckPageType: msg.stuckPageType,
10044
+ stuckCliLifetime: msg.stuckCliLifetime,
10045
+ keys: msg.keys,
10046
+ isFinal: msg.isFinal,
10047
+ }, {
10048
+ getBackend: () => backend,
10049
+ getCurrentLifetime: () => cliLifetimeNonce,
10050
+ renderCols,
10051
+ renderRows,
10052
+ turnId: currentBotmuxTurnId,
10053
+ dispatchAttempt: currentBotmuxDispatchAttempt,
10054
+ capture: snapshotToText,
10055
+ match: matchHookReviewScreen,
10056
+ writeKeys: handleTuiKeys,
10057
+ sendExpired: (nonce, turnId, dispatchAttempt) => send({ type: 'stuck_warning_expired', nonce, turnId, dispatchAttempt }),
10058
+ sendDelivered: (nonce, turnId, dispatchAttempt) => send({ type: 'tui_keys_delivered', nonce, turnId, dispatchAttempt }),
10059
+ log,
10060
+ });
10061
+ wroteKeys = result.wroteKeys;
10062
+ }
10063
+ else {
10064
+ await handleTuiKeys(msg.keys, msg.isFinal);
10065
+ wroteKeys = true;
10066
+ }
10067
+ // Re-arm the stuck detector ONLY when the card-handler explicitly flags
10068
+ // this as a stuck-warning card's Enter action (advances to the next
10069
+ // review layer) AND keys were actually written. An expired click (CLI
10070
+ // recovered, page changed) must NOT re-arm — the detector should stay
10071
+ // disarmed until the next real stall. t/Esc and all ScreenAnalyzer cards
10072
+ // never set this flag.
10073
+ if (shouldRearmStuckDetector(!!msg.rearmStuckDetector, wroteKeys))
10074
+ stuckDetector?.arm();
9549
10075
  break;
9550
10076
  }
9551
10077
  case 'inject_command': {
@@ -9710,6 +10236,7 @@ process.on('message', async (raw) => {
9710
10236
  });
9711
10237
  // ─── Cleanup ─────────────────────────────────────────────────────────────────
9712
10238
  function cleanup() {
10239
+ stopNativeSessionTitleSync();
9713
10240
  cleanupPiInitialPromptFiles();
9714
10241
  stopSessionMcpGatewayHost();
9715
10242
  if (tmuxRestartTimer) {
@@ -9814,7 +10341,11 @@ function teardownSandboxBestEffort() {
9814
10341
  // and process.exit(1), killing a live session over a dropped log write. Install
9815
10342
  // the guard before any further stdout writes (log() writes to process.stdout).
9816
10343
  installStdioEpipeGuard();
9817
- process.on('exit', () => { teardownSandboxBestEffort(); stopCodexRpcEngine(); });
10344
+ process.on('exit', () => {
10345
+ stopNativeSessionTitleSync();
10346
+ teardownSandboxBestEffort();
10347
+ stopCodexRpcEngine();
10348
+ });
9818
10349
  process.on('uncaughtException', (err) => {
9819
10350
  // A broken pipe on stdout/stderr (or any socket) must not tear down a live
9820
10351
  // session — the stdio guard handles those it can; this is the backstop.