botmux 3.7.1 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (622) hide show
  1. package/dist/.runtime-build-id +1 -1
  2. package/dist/adapters/backend/capabilities.d.ts +17 -0
  3. package/dist/adapters/backend/capabilities.d.ts.map +1 -0
  4. package/dist/adapters/backend/capabilities.js +22 -0
  5. package/dist/adapters/backend/capabilities.js.map +1 -0
  6. package/dist/adapters/backend/critical-control-key.d.ts +19 -0
  7. package/dist/adapters/backend/critical-control-key.d.ts.map +1 -0
  8. package/dist/adapters/backend/critical-control-key.js +34 -0
  9. package/dist/adapters/backend/critical-control-key.js.map +1 -0
  10. package/dist/adapters/backend/herdr-backend.d.ts +9 -0
  11. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  12. package/dist/adapters/backend/herdr-backend.js +23 -4
  13. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  14. package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
  15. package/dist/adapters/backend/riff-backend.js +3 -2
  16. package/dist/adapters/backend/riff-backend.js.map +1 -1
  17. package/dist/adapters/backend/session-backend-selector.d.ts +61 -6
  18. package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -1
  19. package/dist/adapters/backend/session-backend-selector.js +173 -9
  20. package/dist/adapters/backend/session-backend-selector.js.map +1 -1
  21. package/dist/adapters/backend/tmux-backend.d.ts +10 -3
  22. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  23. package/dist/adapters/backend/tmux-backend.js +25 -8
  24. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  25. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  26. package/dist/adapters/backend/tmux-pipe-backend.js +3 -2
  27. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  28. package/dist/adapters/backend/types.d.ts +45 -4
  29. package/dist/adapters/backend/types.d.ts.map +1 -1
  30. package/dist/adapters/backend/types.js +11 -0
  31. package/dist/adapters/backend/types.js.map +1 -1
  32. package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
  33. package/dist/adapters/backend/zellij-backend.js +3 -2
  34. package/dist/adapters/backend/zellij-backend.js.map +1 -1
  35. package/dist/adapters/backend/zmx-backend.d.ts +286 -0
  36. package/dist/adapters/backend/zmx-backend.d.ts.map +1 -0
  37. package/dist/adapters/backend/zmx-backend.js +2396 -0
  38. package/dist/adapters/backend/zmx-backend.js.map +1 -0
  39. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  40. package/dist/adapters/cli/claude-code.js +3 -0
  41. package/dist/adapters/cli/claude-code.js.map +1 -1
  42. package/dist/adapters/cli/codex-app.d.ts.map +1 -1
  43. package/dist/adapters/cli/codex-app.js +5 -1
  44. package/dist/adapters/cli/codex-app.js.map +1 -1
  45. package/dist/adapters/cli/codex.d.ts.map +1 -1
  46. package/dist/adapters/cli/codex.js +26 -1
  47. package/dist/adapters/cli/codex.js.map +1 -1
  48. package/dist/adapters/cli/fs-policy.d.ts +79 -1
  49. package/dist/adapters/cli/fs-policy.d.ts.map +1 -1
  50. package/dist/adapters/cli/fs-policy.js +290 -13
  51. package/dist/adapters/cli/fs-policy.js.map +1 -1
  52. package/dist/adapters/cli/grok.d.ts.map +1 -1
  53. package/dist/adapters/cli/grok.js +4 -1
  54. package/dist/adapters/cli/grok.js.map +1 -1
  55. package/dist/adapters/cli/oh-my-pi.d.ts.map +1 -1
  56. package/dist/adapters/cli/oh-my-pi.js +5 -3
  57. package/dist/adapters/cli/oh-my-pi.js.map +1 -1
  58. package/dist/adapters/cli/shared-hints.d.ts +7 -4
  59. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  60. package/dist/adapters/cli/shared-hints.js +52 -35
  61. package/dist/adapters/cli/shared-hints.js.map +1 -1
  62. package/dist/adapters/cli/strict-input-handle.d.ts +13 -0
  63. package/dist/adapters/cli/strict-input-handle.d.ts.map +1 -0
  64. package/dist/adapters/cli/strict-input-handle.js +39 -0
  65. package/dist/adapters/cli/strict-input-handle.js.map +1 -0
  66. package/dist/adapters/cli/traex.d.ts.map +1 -1
  67. package/dist/adapters/cli/traex.js +6 -4
  68. package/dist/adapters/cli/traex.js.map +1 -1
  69. package/dist/adapters/cli/types.d.ts +16 -0
  70. package/dist/adapters/cli/types.d.ts.map +1 -1
  71. package/dist/bot-registry.d.ts +137 -8
  72. package/dist/bot-registry.d.ts.map +1 -1
  73. package/dist/bot-registry.js +298 -12
  74. package/dist/bot-registry.js.map +1 -1
  75. package/dist/cli/session-list-liveness.d.ts +1 -1
  76. package/dist/cli/session-list-liveness.js +1 -1
  77. package/dist/cli/zmx-managed-attach.d.ts +41 -0
  78. package/dist/cli/zmx-managed-attach.d.ts.map +1 -0
  79. package/dist/cli/zmx-managed-attach.js +72 -0
  80. package/dist/cli/zmx-managed-attach.js.map +1 -0
  81. package/dist/cli.d.ts.map +1 -1
  82. package/dist/cli.js +780 -131
  83. package/dist/cli.js.map +1 -1
  84. package/dist/codex-app-runner.js +90 -2
  85. package/dist/codex-app-runner.js.map +1 -1
  86. package/dist/codex-rpc-engine.d.ts.map +1 -1
  87. package/dist/codex-rpc-engine.js +22 -6
  88. package/dist/codex-rpc-engine.js.map +1 -1
  89. package/dist/config.d.ts +5 -0
  90. package/dist/config.d.ts.map +1 -1
  91. package/dist/config.js +29 -1
  92. package/dist/config.js.map +1 -1
  93. package/dist/core/ask-broker.d.ts +22 -1
  94. package/dist/core/ask-broker.d.ts.map +1 -1
  95. package/dist/core/ask-broker.js +12 -5
  96. package/dist/core/ask-broker.js.map +1 -1
  97. package/dist/core/botmux-wrapper.d.ts +18 -0
  98. package/dist/core/botmux-wrapper.d.ts.map +1 -1
  99. package/dist/core/botmux-wrapper.js +25 -1
  100. package/dist/core/botmux-wrapper.js.map +1 -1
  101. package/dist/core/chat-rename.d.ts +27 -0
  102. package/dist/core/chat-rename.d.ts.map +1 -0
  103. package/dist/core/chat-rename.js +53 -0
  104. package/dist/core/chat-rename.js.map +1 -0
  105. package/dist/core/command-handler.d.ts.map +1 -1
  106. package/dist/core/command-handler.js +160 -12
  107. package/dist/core/command-handler.js.map +1 -1
  108. package/dist/core/cost-calculator.d.ts +25 -1
  109. package/dist/core/cost-calculator.d.ts.map +1 -1
  110. package/dist/core/cost-calculator.js +102 -4
  111. package/dist/core/cost-calculator.js.map +1 -1
  112. package/dist/core/dashboard-ipc-server.d.ts +16 -2
  113. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  114. package/dist/core/dashboard-ipc-server.js +611 -62
  115. package/dist/core/dashboard-ipc-server.js.map +1 -1
  116. package/dist/core/dashboard-rows.d.ts +22 -1
  117. package/dist/core/dashboard-rows.d.ts.map +1 -1
  118. package/dist/core/dashboard-rows.js +61 -0
  119. package/dist/core/dashboard-rows.js.map +1 -1
  120. package/dist/core/dashboard-url.d.ts +6 -0
  121. package/dist/core/dashboard-url.d.ts.map +1 -1
  122. package/dist/core/dashboard-url.js +11 -1
  123. package/dist/core/dashboard-url.js.map +1 -1
  124. package/dist/core/device-isolation-daemon.d.ts +28 -6
  125. package/dist/core/device-isolation-daemon.d.ts.map +1 -1
  126. package/dist/core/device-isolation-daemon.js +96 -13
  127. package/dist/core/device-isolation-daemon.js.map +1 -1
  128. package/dist/core/host-overload-alert.d.ts +75 -0
  129. package/dist/core/host-overload-alert.d.ts.map +1 -1
  130. package/dist/core/host-overload-alert.js +103 -0
  131. package/dist/core/host-overload-alert.js.map +1 -1
  132. package/dist/core/idle-worker-sweeper.js +1 -1
  133. package/dist/core/idle-worker-sweeper.js.map +1 -1
  134. package/dist/core/inflight-input-tracker.d.ts +3 -0
  135. package/dist/core/inflight-input-tracker.d.ts.map +1 -1
  136. package/dist/core/inflight-input-tracker.js +9 -0
  137. package/dist/core/inflight-input-tracker.js.map +1 -1
  138. package/dist/core/passthrough-commands.d.ts.map +1 -1
  139. package/dist/core/passthrough-commands.js +10 -0
  140. package/dist/core/passthrough-commands.js.map +1 -1
  141. package/dist/core/persistent-backend.d.ts +32 -4
  142. package/dist/core/persistent-backend.d.ts.map +1 -1
  143. package/dist/core/persistent-backend.js +75 -6
  144. package/dist/core/persistent-backend.js.map +1 -1
  145. package/dist/core/plugins/mcp/gateway.js +2 -2
  146. package/dist/core/plugins/mcp/gateway.js.map +1 -1
  147. package/dist/core/plugins/runtime.d.ts +3 -0
  148. package/dist/core/plugins/runtime.d.ts.map +1 -1
  149. package/dist/core/plugins/runtime.js.map +1 -1
  150. package/dist/core/plugins/service-manager.d.ts +4 -1
  151. package/dist/core/plugins/service-manager.d.ts.map +1 -1
  152. package/dist/core/plugins/service-manager.js +11 -6
  153. package/dist/core/plugins/service-manager.js.map +1 -1
  154. package/dist/core/role-library.d.ts +48 -1
  155. package/dist/core/role-library.d.ts.map +1 -1
  156. package/dist/core/role-library.js +111 -3
  157. package/dist/core/role-library.js.map +1 -1
  158. package/dist/core/role-resolver.d.ts +14 -2
  159. package/dist/core/role-resolver.d.ts.map +1 -1
  160. package/dist/core/role-resolver.js +47 -4
  161. package/dist/core/role-resolver.js.map +1 -1
  162. package/dist/core/session-activity.d.ts +14 -0
  163. package/dist/core/session-activity.d.ts.map +1 -1
  164. package/dist/core/session-activity.js +46 -1
  165. package/dist/core/session-activity.js.map +1 -1
  166. package/dist/core/session-board.d.ts +5 -2
  167. package/dist/core/session-board.d.ts.map +1 -1
  168. package/dist/core/session-board.js +5 -2
  169. package/dist/core/session-board.js.map +1 -1
  170. package/dist/core/session-liveness.d.ts +6 -5
  171. package/dist/core/session-liveness.d.ts.map +1 -1
  172. package/dist/core/session-liveness.js +34 -13
  173. package/dist/core/session-liveness.js.map +1 -1
  174. package/dist/core/session-manager.d.ts +3 -2
  175. package/dist/core/session-manager.d.ts.map +1 -1
  176. package/dist/core/session-manager.js +397 -103
  177. package/dist/core/session-manager.js.map +1 -1
  178. package/dist/core/session-marker.d.ts +11 -2
  179. package/dist/core/session-marker.d.ts.map +1 -1
  180. package/dist/core/session-marker.js +50 -11
  181. package/dist/core/session-marker.js.map +1 -1
  182. package/dist/core/session-message-preview.d.ts +20 -0
  183. package/dist/core/session-message-preview.d.ts.map +1 -0
  184. package/dist/core/session-message-preview.js +188 -0
  185. package/dist/core/session-message-preview.js.map +1 -0
  186. package/dist/core/session-title.d.ts +5 -1
  187. package/dist/core/session-title.d.ts.map +1 -1
  188. package/dist/core/session-title.js +20 -3
  189. package/dist/core/session-title.js.map +1 -1
  190. package/dist/core/startup-commands.d.ts +1 -1
  191. package/dist/core/startup-commands.js +1 -1
  192. package/dist/core/terminal-url.d.ts.map +1 -1
  193. package/dist/core/terminal-url.js +3 -2
  194. package/dist/core/terminal-url.js.map +1 -1
  195. package/dist/core/trigger-session.d.ts.map +1 -1
  196. package/dist/core/trigger-session.js +75 -12
  197. package/dist/core/trigger-session.js.map +1 -1
  198. package/dist/core/types.d.ts +35 -0
  199. package/dist/core/types.d.ts.map +1 -1
  200. package/dist/core/types.js +23 -0
  201. package/dist/core/types.js.map +1 -1
  202. package/dist/core/worker-ipc.d.ts +11 -0
  203. package/dist/core/worker-ipc.d.ts.map +1 -0
  204. package/dist/core/worker-ipc.js +26 -0
  205. package/dist/core/worker-ipc.js.map +1 -0
  206. package/dist/core/worker-pool.d.ts +179 -29
  207. package/dist/core/worker-pool.d.ts.map +1 -1
  208. package/dist/core/worker-pool.js +2118 -331
  209. package/dist/core/worker-pool.js.map +1 -1
  210. package/dist/daemon.d.ts +132 -3
  211. package/dist/daemon.d.ts.map +1 -1
  212. package/dist/daemon.js +1327 -449
  213. package/dist/daemon.js.map +1 -1
  214. package/dist/dashboard/auth.d.ts +2 -0
  215. package/dist/dashboard/auth.d.ts.map +1 -1
  216. package/dist/dashboard/auth.js +9 -0
  217. package/dist/dashboard/auth.js.map +1 -1
  218. package/dist/dashboard/bot-payload.d.ts +4 -0
  219. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  220. package/dist/dashboard/bot-payload.js +5 -0
  221. package/dist/dashboard/bot-payload.js.map +1 -1
  222. package/dist/dashboard/federation-api.d.ts.map +1 -1
  223. package/dist/dashboard/federation-api.js +4 -0
  224. package/dist/dashboard/federation-api.js.map +1 -1
  225. package/dist/dashboard/federation-spoke-api.d.ts.map +1 -1
  226. package/dist/dashboard/federation-spoke-api.js +32 -5
  227. package/dist/dashboard/federation-spoke-api.js.map +1 -1
  228. package/dist/dashboard/public-redact.d.ts +8 -2
  229. package/dist/dashboard/public-redact.d.ts.map +1 -1
  230. package/dist/dashboard/public-redact.js +34 -7
  231. package/dist/dashboard/public-redact.js.map +1 -1
  232. package/dist/dashboard/session-card-model.d.ts.map +1 -1
  233. package/dist/dashboard/session-card-model.js +10 -2
  234. package/dist/dashboard/session-card-model.js.map +1 -1
  235. package/dist/dashboard/settings-write-applier.d.ts +35 -1
  236. package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
  237. package/dist/dashboard/settings-write-applier.js +88 -1
  238. package/dist/dashboard/settings-write-applier.js.map +1 -1
  239. package/dist/dashboard/web/app.d.ts.map +1 -1
  240. package/dist/dashboard/web/app.js +0 -1
  241. package/dist/dashboard/web/app.js.map +1 -1
  242. package/dist/dashboard/web/bot-defaults-page.d.ts +15 -0
  243. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  244. package/dist/dashboard/web/bot-defaults-page.js +212 -58
  245. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  246. package/dist/dashboard/web/bot-defaults.d.ts +3 -0
  247. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  248. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  249. package/dist/dashboard/web/bot-onboarding.d.ts +2 -1
  250. package/dist/dashboard/web/bot-onboarding.d.ts.map +1 -1
  251. package/dist/dashboard/web/bot-onboarding.js.map +1 -1
  252. package/dist/dashboard/web/clipboard.d.ts +2 -0
  253. package/dist/dashboard/web/clipboard.d.ts.map +1 -0
  254. package/dist/dashboard/web/clipboard.js +42 -0
  255. package/dist/dashboard/web/clipboard.js.map +1 -0
  256. package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
  257. package/dist/dashboard/web/connectors-page.js +12 -8
  258. package/dist/dashboard/web/connectors-page.js.map +1 -1
  259. package/dist/dashboard/web/dashboard-components.d.ts +17 -16
  260. package/dist/dashboard/web/dashboard-components.d.ts.map +1 -1
  261. package/dist/dashboard/web/dashboard-components.js.map +1 -1
  262. package/dist/dashboard/web/groups-page.d.ts.map +1 -1
  263. package/dist/dashboard/web/groups-page.js +7 -3
  264. package/dist/dashboard/web/groups-page.js.map +1 -1
  265. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  266. package/dist/dashboard/web/i18n.js +205 -12
  267. package/dist/dashboard/web/i18n.js.map +1 -1
  268. package/dist/dashboard/web/icon-tooltip.d.ts.map +1 -1
  269. package/dist/dashboard/web/icon-tooltip.js +16 -1
  270. package/dist/dashboard/web/icon-tooltip.js.map +1 -1
  271. package/dist/dashboard/web/listener-filters.d.ts +23 -0
  272. package/dist/dashboard/web/listener-filters.d.ts.map +1 -0
  273. package/dist/dashboard/web/listener-filters.js +36 -0
  274. package/dist/dashboard/web/listener-filters.js.map +1 -0
  275. package/dist/dashboard/web/overview-page.d.ts.map +1 -1
  276. package/dist/dashboard/web/overview-page.js.map +1 -1
  277. package/dist/dashboard/web/plugin-page.d.ts.map +1 -1
  278. package/dist/dashboard/web/plugin-page.js.map +1 -1
  279. package/dist/dashboard/web/preview-markdown.d.ts +5 -0
  280. package/dist/dashboard/web/preview-markdown.d.ts.map +1 -0
  281. package/dist/dashboard/web/preview-markdown.js +52 -0
  282. package/dist/dashboard/web/preview-markdown.js.map +1 -0
  283. package/dist/dashboard/web/roles-page.d.ts.map +1 -1
  284. package/dist/dashboard/web/roles-page.js +535 -10
  285. package/dist/dashboard/web/roles-page.js.map +1 -1
  286. package/dist/dashboard/web/roles.d.ts +77 -0
  287. package/dist/dashboard/web/roles.d.ts.map +1 -1
  288. package/dist/dashboard/web/roles.js +98 -0
  289. package/dist/dashboard/web/roles.js.map +1 -1
  290. package/dist/dashboard/web/session-terminal.d.ts.map +1 -1
  291. package/dist/dashboard/web/session-terminal.js +6 -4
  292. package/dist/dashboard/web/session-terminal.js.map +1 -1
  293. package/dist/dashboard/web/sessions-kanban.d.ts +4 -1
  294. package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
  295. package/dist/dashboard/web/sessions-kanban.js +1 -1
  296. package/dist/dashboard/web/sessions-kanban.js.map +1 -1
  297. package/dist/dashboard/web/sessions-page.d.ts +3 -2
  298. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  299. package/dist/dashboard/web/sessions-page.js +163 -28
  300. package/dist/dashboard/web/sessions-page.js.map +1 -1
  301. package/dist/dashboard/web/sessions.d.ts +25 -0
  302. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  303. package/dist/dashboard/web/sessions.js +58 -7
  304. package/dist/dashboard/web/sessions.js.map +1 -1
  305. package/dist/dashboard/web/settings-page.d.ts +28 -3
  306. package/dist/dashboard/web/settings-page.d.ts.map +1 -1
  307. package/dist/dashboard/web/settings-page.js +61 -1
  308. package/dist/dashboard/web/settings-page.js.map +1 -1
  309. package/dist/dashboard/web/skills-page.d.ts +6 -5
  310. package/dist/dashboard/web/skills-page.d.ts.map +1 -1
  311. package/dist/dashboard/web/skills-page.js +2 -0
  312. package/dist/dashboard/web/skills-page.js.map +1 -1
  313. package/dist/dashboard/web/v3-components.d.ts +3 -2
  314. package/dist/dashboard/web/v3-components.d.ts.map +1 -1
  315. package/dist/dashboard/web/v3-components.js.map +1 -1
  316. package/dist/dashboard/web/vc-consumer-profiles-section.d.ts +12 -2
  317. package/dist/dashboard/web/vc-consumer-profiles-section.d.ts.map +1 -1
  318. package/dist/dashboard/web/vc-consumer-profiles-section.js +0 -9
  319. package/dist/dashboard/web/vc-consumer-profiles-section.js.map +1 -1
  320. package/dist/dashboard/web/webhook-logs-page.d.ts +2 -1
  321. package/dist/dashboard/web/webhook-logs-page.d.ts.map +1 -1
  322. package/dist/dashboard/web/webhook-logs-page.js +4 -1
  323. package/dist/dashboard/web/webhook-logs-page.js.map +1 -1
  324. package/dist/dashboard-web/app.js +1 -1
  325. package/dist/dashboard-web/chunks/bot-defaults-page-FAPIMQV3.js +10 -0
  326. package/dist/dashboard-web/chunks/{chunk-QFMH6ITB.js → chunk-5CJMHTT4.js} +1 -1
  327. package/dist/dashboard-web/chunks/{chunk-KQB42ML5.js → chunk-DLZK52JU.js} +14 -12
  328. package/dist/dashboard-web/chunks/chunk-FDZ7WOBW.js +16 -0
  329. package/dist/dashboard-web/chunks/{chunk-3QAUJXAS.js → chunk-GTAJV5YC.js} +1 -1
  330. package/dist/dashboard-web/chunks/{chunk-FNPGOZMX.js → chunk-IHNG6KE6.js} +1 -1
  331. package/dist/dashboard-web/chunks/{chunk-FLGXZJTD.js → chunk-VA6QWSGQ.js} +1 -1
  332. package/dist/dashboard-web/chunks/chunk-XJ7BGPJH.js +1 -0
  333. package/dist/dashboard-web/chunks/chunk-ZJVTGU5X.js +1 -0
  334. package/dist/dashboard-web/chunks/connectors-page-LKWRT3GS.js +1 -0
  335. package/dist/dashboard-web/chunks/groups-page-V6654ICQ.js +8 -0
  336. package/dist/dashboard-web/chunks/insights-page-237RMFQ5.js +7 -0
  337. package/dist/dashboard-web/chunks/{monitor-room-7IVIS75W.js → monitor-room-OO5Y4FRQ.js} +1 -1
  338. package/dist/dashboard-web/chunks/{monitoring-page-5SSJRQZF.js → monitoring-page-OUEITM5P.js} +1 -1
  339. package/dist/dashboard-web/chunks/{office-page-EOND3L5D.js → office-page-4KNAHQ4Q.js} +1 -1
  340. package/dist/dashboard-web/chunks/{overview-page-NISBVBBJ.js → overview-page-QDP4YK2S.js} +1 -1
  341. package/dist/dashboard-web/chunks/{plugin-page-JXC5F5YV.js → plugin-page-VEGTCEAS.js} +1 -1
  342. package/dist/dashboard-web/chunks/roles-page-PGXNZZNP.js +1 -0
  343. package/dist/dashboard-web/chunks/{schedules-page-AVOOKLUH.js → schedules-page-BJNC5TID.js} +1 -1
  344. package/dist/dashboard-web/chunks/sessions-page-M2ZYYCMO.js +6 -0
  345. package/dist/dashboard-web/chunks/settings-page-IJFZKUUW.js +2 -0
  346. package/dist/dashboard-web/chunks/{skills-page-5MW54PCC.js → skills-page-YRAIRZOQ.js} +3 -3
  347. package/dist/dashboard-web/chunks/{team-federation-page-2SNIMVXI.js → team-federation-page-DEU27GRU.js} +1 -1
  348. package/dist/dashboard-web/chunks/{v3-page-W6SBXXQC.js → v3-page-6KHEW2YL.js} +5 -5
  349. package/dist/dashboard-web/chunks/{whiteboards-page-STC54KTP.js → whiteboards-page-VDECAALH.js} +1 -1
  350. package/dist/dashboard-web/style.css +12545 -11432
  351. package/dist/dashboard.js +215 -36
  352. package/dist/dashboard.js.map +1 -1
  353. package/dist/global-config.d.ts +41 -2
  354. package/dist/global-config.d.ts.map +1 -1
  355. package/dist/global-config.js +47 -0
  356. package/dist/global-config.js.map +1 -1
  357. package/dist/i18n/en.d.ts.map +1 -1
  358. package/dist/i18n/en.js +71 -19
  359. package/dist/i18n/en.js.map +1 -1
  360. package/dist/i18n/zh.d.ts.map +1 -1
  361. package/dist/i18n/zh.js +71 -19
  362. package/dist/i18n/zh.js.map +1 -1
  363. package/dist/im/lark/callback-button-marker.d.ts +34 -0
  364. package/dist/im/lark/callback-button-marker.d.ts.map +1 -0
  365. package/dist/im/lark/callback-button-marker.js +99 -0
  366. package/dist/im/lark/callback-button-marker.js.map +1 -0
  367. package/dist/im/lark/card-builder.d.ts +46 -10
  368. package/dist/im/lark/card-builder.d.ts.map +1 -1
  369. package/dist/im/lark/card-builder.js +299 -78
  370. package/dist/im/lark/card-builder.js.map +1 -1
  371. package/dist/im/lark/card-handler.d.ts +3 -4
  372. package/dist/im/lark/card-handler.d.ts.map +1 -1
  373. package/dist/im/lark/card-handler.js +380 -148
  374. package/dist/im/lark/card-handler.js.map +1 -1
  375. package/dist/im/lark/client.d.ts +24 -1
  376. package/dist/im/lark/client.d.ts.map +1 -1
  377. package/dist/im/lark/client.js +101 -8
  378. package/dist/im/lark/client.js.map +1 -1
  379. package/dist/im/lark/doc-comment.d.ts.map +1 -1
  380. package/dist/im/lark/doc-comment.js +6 -1
  381. package/dist/im/lark/doc-comment.js.map +1 -1
  382. package/dist/im/lark/event-dispatcher.d.ts +115 -3
  383. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  384. package/dist/im/lark/event-dispatcher.js +534 -147
  385. package/dist/im/lark/event-dispatcher.js.map +1 -1
  386. package/dist/im/lark/grant-command.d.ts +16 -12
  387. package/dist/im/lark/grant-command.d.ts.map +1 -1
  388. package/dist/im/lark/grant-command.js +48 -151
  389. package/dist/im/lark/grant-command.js.map +1 -1
  390. package/dist/im/lark/grant-pending.d.ts +12 -4
  391. package/dist/im/lark/grant-pending.d.ts.map +1 -1
  392. package/dist/im/lark/grant-pending.js +31 -8
  393. package/dist/im/lark/grant-pending.js.map +1 -1
  394. package/dist/im/lark/invite-command.d.ts +20 -0
  395. package/dist/im/lark/invite-command.d.ts.map +1 -0
  396. package/dist/im/lark/invite-command.js +239 -0
  397. package/dist/im/lark/invite-command.js.map +1 -0
  398. package/dist/im/lark/md-card.d.ts +66 -4
  399. package/dist/im/lark/md-card.d.ts.map +1 -1
  400. package/dist/im/lark/md-card.js +201 -31
  401. package/dist/im/lark/md-card.js.map +1 -1
  402. package/dist/im/lark/mention-targets.d.ts +43 -0
  403. package/dist/im/lark/mention-targets.d.ts.map +1 -0
  404. package/dist/im/lark/mention-targets.js +190 -0
  405. package/dist/im/lark/mention-targets.js.map +1 -0
  406. package/dist/im/lark/message-parser.d.ts +31 -0
  407. package/dist/im/lark/message-parser.d.ts.map +1 -1
  408. package/dist/im/lark/message-parser.js +305 -32
  409. package/dist/im/lark/message-parser.js.map +1 -1
  410. package/dist/im/lark/reply-card-footer-signature.d.ts +7 -0
  411. package/dist/im/lark/reply-card-footer-signature.d.ts.map +1 -0
  412. package/dist/im/lark/reply-card-footer-signature.js +7 -0
  413. package/dist/im/lark/reply-card-footer-signature.js.map +1 -0
  414. package/dist/im/lark/sessions-card.d.ts.map +1 -1
  415. package/dist/im/lark/sessions-card.js +4 -1
  416. package/dist/im/lark/sessions-card.js.map +1 -1
  417. package/dist/im/lark/substitute-command.d.ts.map +1 -1
  418. package/dist/im/lark/substitute-command.js +8 -0
  419. package/dist/im/lark/substitute-command.js.map +1 -1
  420. package/dist/im/lark/topic-root-context.d.ts +35 -0
  421. package/dist/im/lark/topic-root-context.d.ts.map +1 -0
  422. package/dist/im/lark/topic-root-context.js +37 -0
  423. package/dist/im/lark/topic-root-context.js.map +1 -0
  424. package/dist/im/lark/v3-blocked-card.d.ts.map +1 -1
  425. package/dist/im/lark/v3-blocked-card.js +2 -1
  426. package/dist/im/lark/v3-blocked-card.js.map +1 -1
  427. package/dist/im/lark/v3-gate-card.d.ts.map +1 -1
  428. package/dist/im/lark/v3-gate-card.js +2 -1
  429. package/dist/im/lark/v3-gate-card.js.map +1 -1
  430. package/dist/im/lark/v3-loop-grant-card.d.ts.map +1 -1
  431. package/dist/im/lark/v3-loop-grant-card.js +2 -1
  432. package/dist/im/lark/v3-loop-grant-card.js.map +1 -1
  433. package/dist/im/lark/v3-progress-card.d.ts.map +1 -1
  434. package/dist/im/lark/v3-progress-card.js +2 -1
  435. package/dist/im/lark/v3-progress-card.js.map +1 -1
  436. package/dist/im/lark/v3-revisit-grant-card.d.ts.map +1 -1
  437. package/dist/im/lark/v3-revisit-grant-card.js +2 -1
  438. package/dist/im/lark/v3-revisit-grant-card.js.map +1 -1
  439. package/dist/index-core-only.d.ts +3 -0
  440. package/dist/index-core-only.d.ts.map +1 -0
  441. package/dist/index-core-only.js +107 -0
  442. package/dist/index-core-only.js.map +1 -0
  443. package/dist/services/async-trigger-state.d.ts +8 -0
  444. package/dist/services/async-trigger-state.d.ts.map +1 -1
  445. package/dist/services/async-trigger-state.js +3 -2
  446. package/dist/services/async-trigger-state.js.map +1 -1
  447. package/dist/services/async-trigger-store.d.ts +10 -2
  448. package/dist/services/async-trigger-store.d.ts.map +1 -1
  449. package/dist/services/async-trigger-store.js +3 -2
  450. package/dist/services/async-trigger-store.js.map +1 -1
  451. package/dist/services/backend-availability.d.ts +3 -1
  452. package/dist/services/backend-availability.d.ts.map +1 -1
  453. package/dist/services/backend-availability.js +14 -1
  454. package/dist/services/backend-availability.js.map +1 -1
  455. package/dist/services/backend-type-store.d.ts.map +1 -1
  456. package/dist/services/backend-type-store.js +1 -1
  457. package/dist/services/backend-type-store.js.map +1 -1
  458. package/dist/services/bot-config-store.d.ts.map +1 -1
  459. package/dist/services/bot-config-store.js +2 -1
  460. package/dist/services/bot-config-store.js.map +1 -1
  461. package/dist/services/bridge-fallback-gate.d.ts +14 -1
  462. package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
  463. package/dist/services/bridge-fallback-gate.js +65 -1
  464. package/dist/services/bridge-fallback-gate.js.map +1 -1
  465. package/dist/services/card-prefs-store.d.ts +7 -2
  466. package/dist/services/card-prefs-store.d.ts.map +1 -1
  467. package/dist/services/card-prefs-store.js +30 -4
  468. package/dist/services/card-prefs-store.js.map +1 -1
  469. package/dist/services/codex-app-runner-protocol.d.ts +9 -0
  470. package/dist/services/codex-app-runner-protocol.d.ts.map +1 -1
  471. package/dist/services/codex-app-runner-protocol.js +17 -0
  472. package/dist/services/codex-app-runner-protocol.js.map +1 -1
  473. package/dist/services/codex-app-token-usage.d.ts +93 -0
  474. package/dist/services/codex-app-token-usage.d.ts.map +1 -0
  475. package/dist/services/codex-app-token-usage.js +190 -0
  476. package/dist/services/codex-app-token-usage.js.map +1 -0
  477. package/dist/services/codex-transcript.d.ts +6 -1
  478. package/dist/services/codex-transcript.d.ts.map +1 -1
  479. package/dist/services/codex-transcript.js +103 -32
  480. package/dist/services/codex-transcript.js.map +1 -1
  481. package/dist/services/federation-roster.d.ts +4 -0
  482. package/dist/services/federation-roster.d.ts.map +1 -1
  483. package/dist/services/federation-roster.js +3 -0
  484. package/dist/services/federation-roster.js.map +1 -1
  485. package/dist/services/federation-store.d.ts +6 -0
  486. package/dist/services/federation-store.d.ts.map +1 -1
  487. package/dist/services/federation-store.js.map +1 -1
  488. package/dist/services/grant-policy.d.ts +13 -0
  489. package/dist/services/grant-policy.d.ts.map +1 -0
  490. package/dist/services/grant-policy.js +40 -0
  491. package/dist/services/grant-policy.js.map +1 -0
  492. package/dist/services/grant-store.d.ts +24 -3
  493. package/dist/services/grant-store.d.ts.map +1 -1
  494. package/dist/services/grant-store.js +196 -12
  495. package/dist/services/grant-store.js.map +1 -1
  496. package/dist/services/groups-store.d.ts +36 -0
  497. package/dist/services/groups-store.d.ts.map +1 -1
  498. package/dist/services/groups-store.js +123 -1
  499. package/dist/services/groups-store.js.map +1 -1
  500. package/dist/services/jsonl-cursor.d.ts.map +1 -1
  501. package/dist/services/jsonl-cursor.js +8 -2
  502. package/dist/services/jsonl-cursor.js.map +1 -1
  503. package/dist/services/local-cli-opener.d.ts.map +1 -1
  504. package/dist/services/local-cli-opener.js +32 -0
  505. package/dist/services/local-cli-opener.js.map +1 -1
  506. package/dist/services/message-listener-run-preview-store.d.ts +43 -0
  507. package/dist/services/message-listener-run-preview-store.d.ts.map +1 -0
  508. package/dist/services/message-listener-run-preview-store.js +152 -0
  509. package/dist/services/message-listener-run-preview-store.js.map +1 -0
  510. package/dist/services/message-listener-store.d.ts +26 -0
  511. package/dist/services/message-listener-store.d.ts.map +1 -0
  512. package/dist/services/message-listener-store.js +138 -0
  513. package/dist/services/message-listener-store.js.map +1 -0
  514. package/dist/services/message-listener.d.ts +112 -0
  515. package/dist/services/message-listener.d.ts.map +1 -0
  516. package/dist/services/message-listener.js +391 -0
  517. package/dist/services/message-listener.js.map +1 -0
  518. package/dist/services/overload-alert-migration.d.ts +15 -0
  519. package/dist/services/overload-alert-migration.d.ts.map +1 -0
  520. package/dist/services/overload-alert-migration.js +115 -0
  521. package/dist/services/overload-alert-migration.js.map +1 -0
  522. package/dist/services/send-policy.d.ts +38 -0
  523. package/dist/services/send-policy.d.ts.map +1 -1
  524. package/dist/services/send-policy.js +34 -0
  525. package/dist/services/send-policy.js.map +1 -1
  526. package/dist/services/session-store.d.ts +12 -1
  527. package/dist/services/session-store.d.ts.map +1 -1
  528. package/dist/services/session-store.js +44 -2
  529. package/dist/services/session-store.js.map +1 -1
  530. package/dist/services/skill-registry-store.d.ts.map +1 -1
  531. package/dist/services/skill-registry-store.js +87 -12
  532. package/dist/services/skill-registry-store.js.map +1 -1
  533. package/dist/services/substitute-mode-normalize.d.ts +3 -0
  534. package/dist/services/substitute-mode-normalize.d.ts.map +1 -1
  535. package/dist/services/substitute-mode-normalize.js +8 -0
  536. package/dist/services/substitute-mode-normalize.js.map +1 -1
  537. package/dist/services/substitute-mode-store.d.ts.map +1 -1
  538. package/dist/services/substitute-mode-store.js +8 -1
  539. package/dist/services/substitute-mode-store.js.map +1 -1
  540. package/dist/services/transcript-resolver.d.ts +6 -1
  541. package/dist/services/transcript-resolver.d.ts.map +1 -1
  542. package/dist/services/transcript-resolver.js +145 -29
  543. package/dist/services/transcript-resolver.js.map +1 -1
  544. package/dist/services/trigger-types.d.ts +16 -0
  545. package/dist/services/trigger-types.d.ts.map +1 -1
  546. package/dist/services/trigger-types.js +6 -0
  547. package/dist/services/trigger-types.js.map +1 -1
  548. package/dist/setup/bot-config-editor.js +2 -2
  549. package/dist/setup/bot-config-editor.js.map +1 -1
  550. package/dist/setup/ensure-zmx.d.ts +31 -0
  551. package/dist/setup/ensure-zmx.d.ts.map +1 -0
  552. package/dist/setup/ensure-zmx.js +161 -0
  553. package/dist/setup/ensure-zmx.js.map +1 -0
  554. package/dist/setup/index.d.ts +1 -1
  555. package/dist/setup/index.js +1 -1
  556. package/dist/setup/open-platform-automation.d.ts +9 -0
  557. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  558. package/dist/setup/open-platform-automation.js +15 -2
  559. package/dist/setup/open-platform-automation.js.map +1 -1
  560. package/dist/setup/setup-args.d.ts +1 -1
  561. package/dist/setup/setup-args.d.ts.map +1 -1
  562. package/dist/setup/setup-args.js +1 -1
  563. package/dist/skills/definitions.d.ts.map +1 -1
  564. package/dist/skills/definitions.js +32 -0
  565. package/dist/skills/definitions.js.map +1 -1
  566. package/dist/skills/injection-mode.d.ts +6 -2
  567. package/dist/skills/injection-mode.d.ts.map +1 -1
  568. package/dist/skills/injection-mode.js +10 -5
  569. package/dist/skills/injection-mode.js.map +1 -1
  570. package/dist/types.d.ts +79 -3
  571. package/dist/types.d.ts.map +1 -1
  572. package/dist/utils/child-env.d.ts +1 -1
  573. package/dist/utils/child-env.d.ts.map +1 -1
  574. package/dist/utils/child-env.js +4 -0
  575. package/dist/utils/child-env.js.map +1 -1
  576. package/dist/utils/effort-confirm-dialog.d.ts +40 -0
  577. package/dist/utils/effort-confirm-dialog.d.ts.map +1 -0
  578. package/dist/utils/effort-confirm-dialog.js +74 -0
  579. package/dist/utils/effort-confirm-dialog.js.map +1 -0
  580. package/dist/utils/pending-input-queue.d.ts +56 -0
  581. package/dist/utils/pending-input-queue.d.ts.map +1 -1
  582. package/dist/utils/pending-input-queue.js +80 -0
  583. package/dist/utils/pending-input-queue.js.map +1 -1
  584. package/dist/utils/render-dimensions.d.ts +8 -0
  585. package/dist/utils/render-dimensions.d.ts.map +1 -1
  586. package/dist/utils/render-dimensions.js +10 -0
  587. package/dist/utils/render-dimensions.js.map +1 -1
  588. package/dist/utils/stuck-key-guard.d.ts +3 -2
  589. package/dist/utils/stuck-key-guard.d.ts.map +1 -1
  590. package/dist/utils/stuck-key-guard.js +3 -3
  591. package/dist/utils/stuck-key-guard.js.map +1 -1
  592. package/dist/utils/terminal-renderer.d.ts +6 -0
  593. package/dist/utils/terminal-renderer.d.ts.map +1 -1
  594. package/dist/utils/terminal-renderer.js +15 -0
  595. package/dist/utils/terminal-renderer.js.map +1 -1
  596. package/dist/utils/tui-input-delivery.d.ts +32 -0
  597. package/dist/utils/tui-input-delivery.d.ts.map +1 -0
  598. package/dist/utils/tui-input-delivery.js +53 -0
  599. package/dist/utils/tui-input-delivery.js.map +1 -0
  600. package/dist/utils/xml.d.ts +19 -0
  601. package/dist/utils/xml.d.ts.map +1 -0
  602. package/dist/utils/xml.js +26 -0
  603. package/dist/utils/xml.js.map +1 -0
  604. package/dist/worker.js +1852 -374
  605. package/dist/worker.js.map +1 -1
  606. package/dist/workflows/definition.d.ts +8 -8
  607. package/dist/workflows/events/payloads.d.ts +8 -8
  608. package/dist/workflows/events/schema.d.ts +160 -160
  609. package/dist/workflows/v3/session-relay-client.js +1 -1
  610. package/dist/workflows/v3/session-relay-client.js.map +1 -1
  611. package/dist/workflows/v3/worker-fence.d.ts.map +1 -1
  612. package/dist/workflows/v3/worker-fence.js +15 -10
  613. package/dist/workflows/v3/worker-fence.js.map +1 -1
  614. package/package.json +1 -1
  615. package/dist/dashboard-web/chunks/bot-defaults-page-UKA7K4JV.js +0 -10
  616. package/dist/dashboard-web/chunks/chunk-DFLNXQNT.js +0 -1
  617. package/dist/dashboard-web/chunks/connectors-page-A77YAB5Y.js +0 -1
  618. package/dist/dashboard-web/chunks/groups-page-RUZNVXQB.js +0 -8
  619. package/dist/dashboard-web/chunks/insights-page-RF7U7RTA.js +0 -22
  620. package/dist/dashboard-web/chunks/roles-page-BUMKD7Y4.js +0 -1
  621. package/dist/dashboard-web/chunks/sessions-page-6HLSEAB7.js +0 -2
  622. package/dist/dashboard-web/chunks/settings-page-MJDZHQ4X.js +0 -2
package/dist/worker.js CHANGED
@@ -15,13 +15,15 @@
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, delimiter } from 'node:path';
18
+ import { join, basename, dirname } from 'node:path';
19
+ import { resolveBotmuxWrapperBinDir, prependBotmuxBin } from './core/botmux-wrapper.js';
19
20
  import { homedir, tmpdir } from 'node:os';
20
21
  import { spawnSync } from 'node:child_process';
21
22
  import { evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, isolationPaneMarkerContent, } from './adapters/cli/read-isolation.js';
22
- import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields, resolveRedirectedAdapterAuthPaths } from './adapters/cli/fs-policy.js';
23
- import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget } from './core/persistent-backend.js';
23
+ import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields, resolveRedirectedAdapterAuthPaths, FsPolicyConfigError } from './adapters/cli/fs-policy.js';
24
+ import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, probePersistentSession, shouldRejectPersistentPostKillProbe } from './core/persistent-backend.js';
24
25
  import { readProcessStartIdentity } from './core/session-marker.js';
26
+ import { roleLibraryRoot, roleLibrarySubtree } from './core/role-library.js';
25
27
  import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff, isTranscriptRateLimitEvent, apiErrorMessageText } from './services/claude-transcript.js';
26
28
  import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
27
29
  import { shouldEmitEmptyCompletedBridgeFallback, shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
@@ -31,8 +33,9 @@ import { canStartInjectionFlush, shouldDeferUserFlush, shouldFlushInjectionsFirs
31
33
  import { decideRestartFollowup, settleDurableTurnForRestart } from './core/restart-followup-policy.js';
32
34
  import { stripAnsiForLog, tailChars } from './utils/crash-log.js';
33
35
  import { CodexUpdateDialogGuard } from './utils/codex-update-dialog.js';
36
+ import { EffortConfirmDialogGuard, isEffortLevelCommand } from './utils/effort-confirm-dialog.js';
34
37
  import { installStdioEpipeGuard, isIgnorableStreamError } from './utils/stdio-epipe-guard.js';
35
- import { mergeQueuedCliInput, pendingInputMayFlush, pendingInputAllowsTypeAhead, resolveInitialPromptDelivery, shouldDeferArgsBakedDurablePrompt, shouldDeferInitialPromptForArgLimit, shouldStopPendingBatch, terminalReleasesDurableTurn, } from './utils/pending-input-queue.js';
38
+ import { handoffQueuedDurableInputsOnBackendExit, mergeQueuedCliInput, pendingInputMayFlush, pendingInputAllowsTypeAhead, resolveInitialPromptDelivery, shouldArmSpawnArgvInitialPromptBusy, shouldTrackArgvBakedFirstPrompt, shouldDeferArgsBakedDurablePrompt, shouldDeferInitialPromptForArgLimit, shouldStopPendingBatch, terminalReleasesDurableTurn, } from './utils/pending-input-queue.js';
36
39
  import { ReadyGate, shouldArmReadyGate } from './utils/ready-gate.js';
37
40
  import { shouldRunStartupCommandsOnSpawn, shouldDeferInitialPromptForStartup } from './core/startup-commands.js';
38
41
  import { sanitizePerBotEnv } from './core/per-bot-env.js';
@@ -43,7 +46,7 @@ import { sessionMcpGatewayPathRegex, startSessionMcpGatewayHost, } from './core/
43
46
  import { MCP_GATEWAY_REQUIRED_ENV, MCP_GATEWAY_SOCKET_ENV, } from './core/plugins/mcp/environment.js';
44
47
  import { readSessionMcpRuntimeManifest, sessionMcpRuntimeHostOnlyPaths, } from './core/plugins/mcp/session-runtime.js';
45
48
  import { prepareCliPluginGeneration } from './core/plugins/cli-generation.js';
46
- import { loadBotConfigs, resolveBrandLabel } from './bot-registry.js';
49
+ import { loadBotConfigs, resolveBrandLabel, resolveUsageDisplay, } from './bot-registry.js';
47
50
  import { readGlobalConfig } from './global-config.js';
48
51
  import { deriveTerminalViewToken, deriveTerminalWriteToken, resolveTerminalAccessForRequest, safeTerminalTokenEqual, } from './core/terminal-write-auth.js';
49
52
  import { readPlatformBinding } from './platform/binding.js';
@@ -86,23 +89,29 @@ import { delay } from './utils/timing.js';
86
89
  import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds, syncClaudeResumeTargetToCwd, DEFAULT_CLAUDE_DATA_DIR } from './adapters/cli/claude-code.js';
87
90
  import { sessionReadyHookCommand } from './adapters/hook-command.js';
88
91
  import { mtrSessionIdForBotmuxSession } from './adapters/cli/mtr.js';
92
+ import { strictInputHandle } from './adapters/cli/strict-input-handle.js';
89
93
  import { HerdrBackend } from './adapters/backend/herdr-backend.js';
90
94
  import { TmuxBackend } from './adapters/backend/tmux-backend.js';
91
95
  import { TmuxPipeBackend } from './adapters/backend/tmux-pipe-backend.js';
92
96
  import { ZellijBackend, ZELLIJ_CONFIG_KDL } from './adapters/backend/zellij-backend.js';
93
97
  import { ZellijObserveBackend } from './adapters/backend/zellij-observe-backend.js';
98
+ import { ZmxBackend } from './adapters/backend/zmx-backend.js';
99
+ import { isCriticalInterruptKey, sendCriticalControlKey, } from './adapters/backend/critical-control-key.js';
100
+ import { backendCliCompatibilityError, backendSupportsWebTerminal, } from './adapters/backend/capabilities.js';
94
101
  import { zellijEnv } from './setup/ensure-zellij.js';
95
- import { isObserveBackend } from './adapters/backend/types.js';
96
- import { selectSessionBackend, decideBackendGate, backendGateUserMessage } from './adapters/backend/session-backend-selector.js';
102
+ import { AmbiguousSubmissionBlockedError, isObserveBackend, } from './adapters/backend/types.js';
103
+ import { backendGateUserMessage, backendSandboxCompatibilityError, backendSandboxCompatibilityUserMessage, decideBackendGate, retireSupersededRecordedHerdrTarget, selectSessionBackend, } from './adapters/backend/session-backend-selector.js';
97
104
  import { buildReproduceCommand, selectReproduceLaunch } from './adapters/backend/reproduce-command.js';
98
105
  import { deriveRiffReposFromDirs, deriveRiffRepoFromWorkingDir, isValidRiffBaseUrl, isValidRiffSandboxCluster, } from './adapters/backend/riff-backend.js';
99
106
  import { prepareDirectSandbox, prepareCredentialOnlySandbox, credentialOnlySandboxAvailable, probeHostCredentialIsolationMechanism, attachSandboxOutbox, startOutboxWatcher, sandboxEnabled, localSandboxApplies, } from './adapters/backend/sandbox.js';
100
107
  import { DEVICE_AUTHORITY_DIRECTORY, DEVICE_CREDENTIAL_FILE, } from './platform/device-paths.js';
101
108
  import { tmuxEnv, probeTmuxFunctionalWithRetry } from './setup/ensure-tmux.js';
109
+ import { probeZmxVersion } from './setup/ensure-zmx.js';
102
110
  import { tmuxRestartJitterMs } from './core/tmux-recovery.js';
103
111
  import { IdleDetector } from './utils/idle-detector.js';
104
112
  import { StuckDetector, matchHookReviewScreen } from './utils/stuck-detector.js';
105
113
  import { processStuckWarningTuiKeys, shouldRearmStuckDetector } from './utils/stuck-key-guard.js';
114
+ import { sendTuiKeySequence, submitTuiTextInput } from './utils/tui-input-delivery.js';
106
115
  import { captureToPng } from './utils/screenshot-renderer.js';
107
116
  import { snapshotToPng, snapshotToText, shouldCaptureScreen, isScreenSelfDriven } from './utils/transient-snapshot.js';
108
117
  import { chooseWebTerminalSeed } from './utils/web-terminal-seed.js';
@@ -151,6 +160,8 @@ scrubClaudeSessionMarkerEnv(process.env);
151
160
  // ─── State ───────────────────────────────────────────────────────────────────
152
161
  let cliAdapter = null;
153
162
  let backend = null;
163
+ let backendScreenRevision = 0;
164
+ let idleScreenSettleTask = null;
154
165
  let intentionalRestartBackend = null;
155
166
  // Hybrid codex RPC input mode (opt-in per bot, cfg.codexRpcInput). When active,
156
167
  // user input is delivered to codex via the app-server JSON-RPC channel
@@ -215,11 +226,14 @@ function persistentPaneInfo(backendType, sessionId) {
215
226
  name = HerdrBackend.sessionName(sessionId);
216
227
  else if (backendType === 'zellij')
217
228
  name = ZellijBackend.sessionName(sessionId);
229
+ else if (backendType === 'zmx')
230
+ name = ZmxBackend.sessionName(sessionId);
218
231
  if (!name)
219
232
  return null;
220
233
  const live = backendType === 'tmux' ? TmuxBackend.hasSession(name)
221
234
  : backendType === 'zellij' ? ZellijBackend.hasSession(name)
222
- : HerdrBackend.hasSession(name);
235
+ : backendType === 'zmx' ? ZmxBackend.probeManagedSession(name, sessionId).state === 'compatible'
236
+ : HerdrBackend.hasSession(name);
223
237
  return { name, live };
224
238
  }
225
239
  function persistentPaneLive(backendType, name) {
@@ -229,7 +243,26 @@ function persistentPaneLive(backendType, name) {
229
243
  return ZellijBackend.hasSession(name);
230
244
  if (backendType === 'herdr')
231
245
  return HerdrBackend.hasSession(name);
232
- return false;
246
+ return ZmxBackend.hasSession(name);
247
+ }
248
+ function probeOwnedZmxSession(name, sessionId, expectedPid) {
249
+ const managed = ZmxBackend.probeManagedSession(name, sessionId);
250
+ if (managed.state === 'missing')
251
+ return { probe: 'missing' };
252
+ if (managed.state === 'unknown')
253
+ return { probe: 'unknown', reason: managed.reason };
254
+ if (managed.state === 'incompatible') {
255
+ return {
256
+ probe: 'unknown',
257
+ reason: managed.reason === 'transport-label'
258
+ ? `ZMX 会话 ${name} 缺少 botmux 传输标签(tail 信号 + history 屏幕 + send 输入);请手动关闭旧会话后重试`
259
+ : `ZMX 会话 ${name} 属于另一个完整 botmux session`,
260
+ };
261
+ }
262
+ if (expectedPid !== undefined && managed.pid !== expectedPid) {
263
+ return { probe: 'unknown', reason: `ZMX 会话 ${name} 的 PTY root PID 已变化` };
264
+ }
265
+ return { probe: 'exists', pid: managed.pid };
233
266
  }
234
267
  /** Kill a persistent session and VERIFY it is gone (bounded retry). killSession
235
268
  * swallows exceptions and killPersistentSession returns void, so a plain
@@ -237,9 +270,9 @@ function persistentPaneLive(backendType, name) {
237
270
  * pane would then be reattached against the NEW app-server's fresh port,
238
271
  * re-triggering the exact P0 freeze. Returns true only when the session is
239
272
  * confirmed absent (Codex delta point 1). */
240
- async function killPersistentSessionVerified(backendType, name) {
273
+ async function killPersistentSessionVerified(backendType, name, sessionId) {
241
274
  return killAndVerifyPersistentPane(name, {
242
- kill: (resolvedName) => killPersistentSession(backendType, resolvedName),
275
+ kill: (resolvedName) => killPersistentSession(backendType, resolvedName, sessionId),
243
276
  isLive: (resolvedName) => persistentPaneLive(backendType, resolvedName),
244
277
  wait: delay,
245
278
  });
@@ -272,7 +305,7 @@ function codexNativeTitleEnv(cfg) {
272
305
  ...redactChildEnv(process.env),
273
306
  ...sanitizePerBotEnv(cfg.env),
274
307
  };
275
- env.PATH = `${join(homedir(), '.botmux', 'bin')}${delimiter}${env.PATH ?? ''}`;
308
+ env.PATH = prependBotmuxBin(resolveBotmuxWrapperBinDir(process.env), env.PATH);
276
309
  return env;
277
310
  }
278
311
  function registerNativeTitleForceClose(forceClose) {
@@ -415,7 +448,7 @@ async function captureCodexResumeTitleBaseline(threadId, engine) {
415
448
  ...redactChildEnv(process.env),
416
449
  ...sanitizePerBotEnv(cfg.env),
417
450
  };
418
- env.PATH = `${join(homedir(), '.botmux', 'bin')}${delimiter}${env.PATH ?? ''}`;
451
+ env.PATH = prependBotmuxBin(resolveBotmuxWrapperBinDir(process.env), env.PATH);
419
452
  const abortController = new AbortController();
420
453
  nativeSessionTitleSyncAbortControllers.add(abortController);
421
454
  try {
@@ -523,7 +556,7 @@ async function engageCodexRpc(cfg) {
523
556
  try {
524
557
  const cliBin = createCliAdapterSync(cfg.cliId, cfg.cliPathOverride).resolvedBin;
525
558
  const engineEnv = { ...redactChildEnv(process.env) };
526
- engineEnv.PATH = `${join(homedir(), '.botmux', 'bin')}:${engineEnv.PATH ?? ''}`;
559
+ engineEnv.PATH = prependBotmuxBin(resolveBotmuxWrapperBinDir(process.env), engineEnv.PATH);
527
560
  engineEnv.BOTMUX_SESSION_ID = cfg.sessionId;
528
561
  // In Codex/TraeX RPC mode the app-server, not the remote viewer TUI, runs
529
562
  // model shell tools. Give that process the same non-secret route binding as
@@ -557,7 +590,7 @@ async function engageCodexRpc(cfg) {
557
590
  Object.assign(engineEnv, sanitizePerBotEnv(cfg.env));
558
591
  engine = new CodexRpcEngine({
559
592
  cliBin, cwd: cfg.workingDir, env: engineEnv, sessionId: cfg.sessionId,
560
- model: cfg.model, log: (m) => log(m),
593
+ model: cfg.model, reasoningEffort: cfg.reasoningEffort, log: (m) => log(m),
561
594
  appServerFeatures: cfg.cliId === 'traex' ? ['default_mode_request_user_input'] : undefined,
562
595
  onRequestUserInput: cfg.cliId === 'traex'
563
596
  ? (params) => bridgeTraexUserInput(cfg, params)
@@ -981,6 +1014,19 @@ let lastSpawnEffectiveCliSessionId;
981
1014
  let lastSpawnDeferInitialPrompt = false;
982
1015
  let lastSpawnQueuedInitialPrompt;
983
1016
  let lastSpawnQueuedInitialPromptLogicalContent;
1017
+ /**
1018
+ * True only when {@link shouldArmSpawnArgvInitialPromptBusy} says so: argv-
1019
+ * baked first prompt + SessionStart ready (Grok-class). First markPromptReady
1020
+ * then reports working (not idle). Cleared on first consume. Must stay false
1021
+ * for Riff/queue-after-spawn and for quiescence-only argv adapters.
1022
+ */
1023
+ let spawnArgvInitialPromptBusy = false;
1024
+ /**
1025
+ * True when spawn baked first prompt into argv (any such adapter, incl. Pi /
1026
+ * Gemini). Card-off reactions need a working→idle edge: Grok uses the busy
1027
+ * arm above; quiescence argv adapters seed working then idle at first ready.
1028
+ */
1029
+ let spawnArgvNeedsWorkingSeed = false;
984
1030
  let idleDetector = null;
985
1031
  let isTmuxMode = false;
986
1032
  /** True once a crash diagnostic tmux shell (bmx-diag-<sid>) is live. */
@@ -999,6 +1045,15 @@ let lastCliExitSignal = null;
999
1045
  * of per-WS attach-session PTYs. Set in spawnCli's adopt branch. */
1000
1046
  let isPipeMode = false;
1001
1047
  let effectiveBackendType = 'pty';
1048
+ /**
1049
+ * Whether a screen snapshot can safely drive state changes or synthetic input.
1050
+ * ZMX exposes full history but not the authoritative current PTY geometry; a
1051
+ * local attach can resize it persistently, making a bounded render include
1052
+ * scrollback above the real viewport. Keep that evidence display-only.
1053
+ */
1054
+ function backendScreenEvidenceIsAuthoritativeForMutation() {
1055
+ return effectiveBackendType !== 'zmx';
1056
+ }
1002
1057
  /** Worker-owned statement about the confinement attached to the CURRENT CLI
1003
1058
  * generation. The daemon receives this over private IPC; child-writable PID
1004
1059
  * marker files remain diagnostics only. */
@@ -1080,6 +1135,7 @@ function ensureZellijAttachConfig() {
1080
1135
  }
1081
1136
  let sessionId = '';
1082
1137
  let lastInitConfig = null;
1138
+ let closeRequested = false;
1083
1139
  /** Dashboard「复现命令」:session 冷启时最终交给 backend.spawn 的真实调用
1084
1140
  * (bin + argv + cwd + 关键 env)。原样保留,worker `ready` 时随消息上报给 daemon
1085
1141
  * 持久化。仅有写权限的 dashboard 视图可见。 */
@@ -1107,12 +1163,11 @@ let rawInputRestartGate = false;
1107
1163
  * when the CLI restarts. Consumed inside flushPending right before the first
1108
1164
  * user prompt is drained, so the commands always precede it (see runStartupCommands). */
1109
1165
  let hasRunStartupCommands = false;
1110
- /** Per-spawn latch: set once the launch-failure detector has decided the pane
1111
- * leaf is a bare shell (the CLI never launched e.g. a user rcfile that
1112
- * `exec`-trampolines into another shell pre-empted the wrapper's `exec <cli>`).
1113
- * Once set, flushPending refuses to type prompts into the bare shell (which
1114
- * would just produce `zsh: parse error`) and the user gets one diagnostic
1115
- * instead. Reset per spawn in spawnCli. */
1166
+ /** Per-spawn latch: set when the launch-failure detector still sees a bare
1167
+ * shell after its bounded settle. While set, no input may reach the pane. A
1168
+ * later PTY prompt may release it, but only after the pane leaf has actually
1169
+ * changed to a non-shell process; otherwise a shell prompt that resembles a
1170
+ * CLI prompt could leak the queued message into zsh/bash. Reset per spawn. */
1116
1171
  let bareShellLaunchBlocked = false;
1117
1172
  /** Per-spawn one-shot: has the bare-shell launch check already run for this
1118
1173
  * spawn? Gates detectBareShellLaunch() to the FIRST flush only (the
@@ -1272,16 +1327,24 @@ async function sendRawCommandLine(be, content) {
1272
1327
  const cmd = content.trim();
1273
1328
  const typed = cmd.includes(' ') ? cmd : `${cmd} `;
1274
1329
  for (const ch of typed) {
1275
- be.sendText(ch);
1330
+ if (be.sendText(ch) === false) {
1331
+ throw new Error('backend rejected command text input');
1332
+ }
1276
1333
  await new Promise(r => setTimeout(r, COCO_SLASH_TYPE_THROTTLE_MS));
1277
1334
  }
1278
1335
  await new Promise(r => setTimeout(r, 200));
1279
- be.sendSpecialKeys('Enter');
1336
+ if (be.sendSpecialKeys('Enter') === false) {
1337
+ throw new Error('backend rejected command submit key');
1338
+ }
1280
1339
  return;
1281
1340
  }
1282
- be.sendText(content);
1341
+ if (be.sendText(content) === false) {
1342
+ throw new Error('backend rejected command text input');
1343
+ }
1283
1344
  await new Promise(r => setTimeout(r, 200));
1284
- be.sendSpecialKeys('Enter');
1345
+ if (be.sendSpecialKeys('Enter') === false) {
1346
+ throw new Error('backend rejected command submit key');
1347
+ }
1285
1348
  }
1286
1349
  else {
1287
1350
  // PtyBackend has no sendText/sendSpecialKeys, so write the keystrokes
@@ -1296,20 +1359,36 @@ async function sendRawCommandLine(be, content) {
1296
1359
  be.write('\r');
1297
1360
  }
1298
1361
  }
1299
- /** Serialize only the literal command-line write window (text -> beat -> Enter).
1300
- * raw_input deliberately keeps its legacy "send while busy" behaviour; this
1301
- * narrow mutex merely prevents concurrent IPC handlers (or native /rename)
1302
- * from splicing keystrokes into one another. */
1362
+ /** Serialize one complete raw command-line transaction (fence -> text -> Enter
1363
+ * -> commit/cancel). raw_input deliberately keeps its legacy "send while busy"
1364
+ * behaviour; this narrow mutex merely prevents concurrent IPC handlers (or
1365
+ * native /rename) from splicing keystrokes into one another. */
1303
1366
  let commandLineWriteTail = Promise.resolve();
1304
1367
  let commandLineWritesPending = 0;
1305
- async function sendRawCommandLineSerially(be, content) {
1368
+ class SubmissionWriteError extends Error {
1369
+ recoveryFailureReason;
1370
+ submissionStarted;
1371
+ constructor(message, recoveryFailureReason,
1372
+ /** False only when the logical transaction was rejected before its write
1373
+ * callback ran, so the current input is known not to have reached the PTY. */
1374
+ submissionStarted = true) {
1375
+ super(message);
1376
+ this.recoveryFailureReason = recoveryFailureReason;
1377
+ this.submissionStarted = submissionStarted;
1378
+ this.name = 'SubmissionWriteError';
1379
+ }
1380
+ }
1381
+ async function sendRawCommandLineWithRecoveryFence(be, content, beforeWrite) {
1306
1382
  const previous = commandLineWriteTail;
1307
1383
  let release;
1308
1384
  commandLineWriteTail = new Promise(resolve => { release = resolve; });
1309
1385
  commandLineWritesPending += 1;
1310
1386
  await previous;
1311
1387
  try {
1312
- await sendRawCommandLine(be, content);
1388
+ const transaction = await runAmbiguousSubmissionTransaction(be, () => sendRawCommandLine(be, content), undefined, beforeWrite);
1389
+ if (transaction.recoveryFailureReason) {
1390
+ throw new SubmissionWriteError(`backend could not commit the command submission journal; ${transaction.recoveryFailureReason}`, transaction.recoveryFailureReason);
1391
+ }
1313
1392
  }
1314
1393
  finally {
1315
1394
  commandLineWritesPending -= 1;
@@ -1359,7 +1438,7 @@ async function runStartupCommands() {
1359
1438
  if (!backend)
1360
1439
  break;
1361
1440
  try {
1362
- await sendRawCommandLineSerially(backend, cmd);
1441
+ await sendRawCommandLineWithRecoveryFence(backend, cmd);
1363
1442
  await awaitPtyQuiescence(STARTUP_CMD_QUIET_MS, STARTUP_CMD_CAP_MS);
1364
1443
  log(`Startup command sent: ${cmd}`);
1365
1444
  }
@@ -1467,34 +1546,62 @@ function armSessionRenameIdleTimeout() {
1467
1546
  * Commands received while /rename owns the TUI are deferred by the IPC handler
1468
1547
  * and come through this same function after the prompt returns. */
1469
1548
  async function deliverRawInput(msg) {
1470
- renderer?.markNewTurn();
1471
- usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
1472
- if (tmuxScrolledHalfPages > 0)
1473
- exitTmuxScrollMode();
1474
1549
  const targetBackend = backend;
1475
1550
  if (!targetBackend)
1476
1551
  return;
1477
- // A passthrough is still an accepted input boundary. Rotate (or revoke) the
1478
- // marker immediately before the literal command reaches the CLI so it can
1479
- // never inherit a previous human turn. System-generated raw commands omit
1480
- // turnId and therefore publish an explicitly unattributed marker.
1481
- currentBotmuxTurnId = msg.turnId;
1482
- currentBotmuxDispatchAttempt = undefined;
1483
- currentVcMeetingImTurnOrigin = undefined;
1484
- writeCliPidMarker();
1485
- publishSandboxRelayCapability();
1486
1552
  let sent = false;
1553
+ let recoveryFailureReason;
1487
1554
  try {
1488
- await sendRawCommandLineSerially(targetBackend, msg.content);
1555
+ await sendRawCommandLineWithRecoveryFence(targetBackend, msg.content, () => {
1556
+ renderer?.markNewTurn();
1557
+ usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
1558
+ if (tmuxScrolledHalfPages > 0)
1559
+ exitTmuxScrollMode();
1560
+ // Rotate (or revoke) attribution only after both submission locks are
1561
+ // held and immediately before the literal command reaches the CLI.
1562
+ // A queued passthrough must not steal output from the preceding turn.
1563
+ currentBotmuxTurnId = msg.turnId;
1564
+ currentBotmuxDispatchAttempt = undefined;
1565
+ currentVcMeetingImTurnOrigin = undefined;
1566
+ writeCliPidMarker();
1567
+ publishSandboxRelayCapability();
1568
+ });
1489
1569
  sent = true;
1490
1570
  isPromptReady = false;
1491
1571
  idleDetector?.reset();
1492
1572
  log(`Passthrough slash command: ${msg.content}`);
1573
+ // A mid-session `/effort <level>` opens a Yes/No "Change effort level?"
1574
+ // confirm dialog; arm the guard so onPtyData can auto-accept it. Bare
1575
+ // `/effort` opens a level picker (unknown target) and is left alone.
1576
+ if (isEffortLevelCommand(msg.content))
1577
+ armEffortConfirm();
1493
1578
  }
1494
1579
  catch (err) {
1580
+ recoveryFailureReason = err instanceof SubmissionWriteError
1581
+ ? err.recoveryFailureReason
1582
+ : undefined;
1495
1583
  // Do not send another queued command against a backend whose write failed.
1496
1584
  isPromptReady = false;
1497
1585
  log(`Passthrough slash command failed (${msg.content}): ${err?.message ?? err}`);
1586
+ const failedTurnId = msg.followUpTurnId ?? msg.turnId;
1587
+ if (failedTurnId && !recoveryFailureReason) {
1588
+ emitTurnTerminal(failedTurnId, 'ambiguous', 'raw_input_write_failed');
1589
+ }
1590
+ const failureMessageKey = msg.followUpContent
1591
+ ? (recoveryFailureReason
1592
+ ? 'worker.raw_input_failed_recovery'
1593
+ : 'worker.raw_input_failed')
1594
+ : (recoveryFailureReason
1595
+ ? 'worker.raw_input_failed_command_only_recovery'
1596
+ : 'worker.raw_input_failed_command_only');
1597
+ send({
1598
+ type: 'user_notify',
1599
+ ...(failedTurnId ? { turnId: failedTurnId } : {}),
1600
+ message: t(failureMessageKey, {
1601
+ cliName: cliName(),
1602
+ reason: recoveryFailureReason ?? '',
1603
+ }),
1604
+ });
1498
1605
  }
1499
1606
  // Follow-up rides on the same IPC and is enqueued only after this command's
1500
1607
  // Enter lands. sendToPty also observes commandLineWritesPending, so another
@@ -1822,8 +1929,15 @@ function createUsageLimitTracker() {
1822
1929
  }
1823
1930
  const usageLimitTracker = createUsageLimitTracker();
1824
1931
  function currentUsageLimitSnapshot() {
1932
+ if (!backendScreenEvidenceIsAuthoritativeForMutation())
1933
+ return '';
1825
1934
  return lastAnalyzerSnapshot || renderer?.rawSnapshot() || '';
1826
1935
  }
1936
+ function classifyScreenUsageLimit(content, status) {
1937
+ if (!backendScreenEvidenceIsAuthoritativeForMutation())
1938
+ return { status };
1939
+ return usageLimitTracker.classify(content, status);
1940
+ }
1827
1941
  // ─── Adopt-bridge state (Claude Code only) ─────────────────────────────────
1828
1942
  //
1829
1943
  // In bridge mode the daemon adopted an existing CLI session that we do NOT
@@ -2011,6 +2125,8 @@ function bridgeMarkerPath() {
2011
2125
  return join(process.env.SESSION_DATA_DIR, 'turn-sends', `${sessionId}.jsonl`);
2012
2126
  }
2013
2127
  function readSendMarkers() {
2128
+ if (closeRequested)
2129
+ return [];
2014
2130
  const path = bridgeMarkerPath();
2015
2131
  if (!path || !existsSync(path))
2016
2132
  return [];
@@ -2033,6 +2149,23 @@ function readSendMarkers() {
2033
2149
  return [];
2034
2150
  }
2035
2151
  }
2152
+ function explicitReplyMarkerForTurnWindow(turn, nextBoundaryMs, markers, adoptMode) {
2153
+ if (adoptMode || turn.isLocal || turn.markTimeMs === undefined)
2154
+ return undefined;
2155
+ const lower = turn.markTimeMs;
2156
+ const upper = nextBoundaryMs ?? Number.POSITIVE_INFINITY;
2157
+ const inWindow = markers.filter(marker => marker.sentAtMs >= lower && marker.sentAtMs < upper);
2158
+ return inWindow.at(-1);
2159
+ }
2160
+ function notifyExplicitReplyObserved(turnId, marker) {
2161
+ if (!marker)
2162
+ return;
2163
+ send({
2164
+ type: 'explicit_reply_observed',
2165
+ turnId,
2166
+ ...(marker.messageId ? { messageId: marker.messageId } : {}),
2167
+ });
2168
+ }
2036
2169
  function submitActivityEvidenceSince(sinceMs) {
2037
2170
  if (lastPtyActivityAtMs > sinceMs)
2038
2171
  return 'pty-output';
@@ -3132,9 +3265,11 @@ function emitReadyTurns(opts = {}) {
3132
3265
  if (assistantText.length === 0)
3133
3266
  continue;
3134
3267
  const lastUuid = turn.assistantUuids[turn.assistantUuids.length - 1];
3135
- if (shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: turn.isLocal, finalText: assistantText }, nextBoundaryMs, markers, adoptMode)) {
3268
+ const gateInput = { markTimeMs: turn.markTimeMs, isLocal: turn.isLocal, finalText: assistantText };
3269
+ if (shouldSuppressBridgeEmit(gateInput, nextBoundaryMs, markers, adoptMode)) {
3136
3270
  const reason = turn.isLocal ? 'local-typed' : 'model called botmux send within window';
3137
3271
  log(`Bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (${reason})`);
3272
+ notifyExplicitReplyObserved(turn.turnId, explicitReplyMarkerForTurnWindow(gateInput, nextBoundaryMs, markers, adoptMode));
3138
3273
  continue;
3139
3274
  }
3140
3275
  if (turn.isLocal) {
@@ -3876,6 +4011,7 @@ function emitReadyCodexTurns() {
3876
4011
  continue;
3877
4012
  if (shouldSuppressBridgeEmit(gateInput, nextBoundaryMs, markers, adoptMode)) {
3878
4013
  log(`Codex bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (gate)`);
4014
+ notifyExplicitReplyObserved(turn.turnId, explicitReplyMarkerForTurnWindow(gateInput, nextBoundaryMs, markers, adoptMode));
3879
4015
  continue;
3880
4016
  }
3881
4017
  if (turn.isLocal) {
@@ -4282,6 +4418,11 @@ function startStuckDetector() {
4282
4418
  stopStuckDetector();
4283
4419
  stuckDetector = new StuckDetector(sd.timeoutMs, {
4284
4420
  isActuallyStuck: () => {
4421
+ // ZMX history has no authoritative viewport geometry after a local attach
4422
+ // resize. A stale hook-review screen may sit just above the real viewport,
4423
+ // so never raise a key-driving stuck card from this observer.
4424
+ if (!backendScreenEvidenceIsAuthoritativeForMutation())
4425
+ return false;
4285
4426
  // Scope gate: this PR only handles the Codex PreToolUse hook-review
4286
4427
  // screen. Other CLIs (Claude Code, Gemini, ...) must never see the
4287
4428
  // Codex-specific t/Enter/Esc card, even if their output happens to
@@ -4344,6 +4485,7 @@ let lastShotHash = '';
4344
4485
  let larkAppIdForUpload = '';
4345
4486
  let larkAppSecretForUpload = '';
4346
4487
  let larkBrandForUpload = 'feishu';
4488
+ let apiOnlyForUpload = false;
4347
4489
  function startScreenshotLoop() {
4348
4490
  stopScreenshotLoop();
4349
4491
  screenshotTimer = setInterval(() => { void captureAndUpload(); }, SCREENSHOT_INTERVAL_MS);
@@ -4412,6 +4554,10 @@ async function captureAndUpload() {
4412
4554
  logScreenshotSkip('awaitingFirstPrompt');
4413
4555
  return;
4414
4556
  }
4557
+ if (apiOnlyForUpload) {
4558
+ logScreenshotSkip('no Feishu transport (apiOnly bot or HTTP virtual session)');
4559
+ return;
4560
+ }
4415
4561
  if (!larkAppIdForUpload || !larkAppSecretForUpload) {
4416
4562
  logScreenshotSkip('lark credentials missing');
4417
4563
  return;
@@ -4467,7 +4613,7 @@ async function captureAndUpload() {
4467
4613
  send({
4468
4614
  type: 'screenshot_uploaded',
4469
4615
  imageKey,
4470
- ...usageLimitTracker.classify(usageLimitContent, status),
4616
+ ...classifyScreenUsageLimit(usageLimitContent, status),
4471
4617
  turnId: currentBotmuxTurnId,
4472
4618
  dispatchAttempt: currentBotmuxDispatchAttempt,
4473
4619
  });
@@ -4507,7 +4653,17 @@ function exitTmuxScrollMode() {
4507
4653
  catch { /* benign */ }
4508
4654
  tmuxScrolledHalfPages = 0;
4509
4655
  }
4510
- function handleTermAction(key) {
4656
+ function sendTermActionOnce(target, key) {
4657
+ if ('sendSpecialKeys' in target && TMUX_KEY_MAP[key]) {
4658
+ return target.sendSpecialKeys(TMUX_KEY_MAP[key]);
4659
+ }
4660
+ if (PTY_SEQ_MAP[key]) {
4661
+ target.write(PTY_SEQ_MAP[key]);
4662
+ return true;
4663
+ }
4664
+ return false;
4665
+ }
4666
+ async function handleTermAction(key) {
4511
4667
  // riff:没有远端终端可驱动——把控制字符 write 进 RiffBackend 会变成一个内容为
4512
4668
  // ANSI 序列的 follow-up 任务(^C 也不会 cancel 任务),必须整体拒绝。
4513
4669
  if (effectiveBackendType === 'riff') {
@@ -4516,10 +4672,11 @@ function handleTermAction(key) {
4516
4672
  }
4517
4673
  if (!backend)
4518
4674
  return;
4675
+ const targetBackend = backend;
4519
4676
  const isHalfPage = key === 'half_page_up' || key === 'half_page_down';
4520
4677
  // Tmux copy-mode scroll (works around alternate-buffer scrollback limitation)
4521
- if (isHalfPage && 'sendCopyModeCommand' in backend) {
4522
- const tb = backend;
4678
+ if (isHalfPage && 'sendCopyModeCommand' in targetBackend) {
4679
+ const tb = targetBackend;
4523
4680
  try {
4524
4681
  if (tmuxScrolledHalfPages === 0 && key === 'half_page_up') {
4525
4682
  tb.enterCopyMode();
@@ -4547,11 +4704,49 @@ function handleTermAction(key) {
4547
4704
  // Any non-scroll key cancels active scroll first so the live view returns.
4548
4705
  if (tmuxScrolledHalfPages > 0)
4549
4706
  exitTmuxScrollMode();
4550
- if ('sendSpecialKeys' in backend && TMUX_KEY_MAP[key]) {
4551
- backend.sendSpecialKeys(TMUX_KEY_MAP[key]);
4707
+ const criticalInterrupt = isCriticalInterruptKey(key);
4708
+ let delivered = false;
4709
+ try {
4710
+ delivered = await runAfterAmbiguousSubmissionWrites(targetBackend, async () => {
4711
+ if (criticalInterrupt) {
4712
+ return sendCriticalControlKey(key, () => {
4713
+ // Never retry an old interrupt into a replacement CLI generation.
4714
+ if (backend !== targetBackend)
4715
+ return true;
4716
+ return sendTermActionOnce(targetBackend, key);
4717
+ });
4718
+ }
4719
+ return sendTermActionOnce(targetBackend, key) !== false;
4720
+ });
4552
4721
  }
4553
- else if (PTY_SEQ_MAP[key]) {
4554
- backend.write(PTY_SEQ_MAP[key]);
4722
+ catch (err) {
4723
+ if (backend === targetBackend) {
4724
+ log(`Term action ${key} failed: ${err instanceof Error ? err.message : String(err)}`);
4725
+ }
4726
+ }
4727
+ if (backend !== targetBackend)
4728
+ return;
4729
+ if (!delivered) {
4730
+ log(`Term action ${key} was not delivered${criticalInterrupt ? ' after retry' : ''}`);
4731
+ if (criticalInterrupt) {
4732
+ const recovery = t(effectiveBackendType === 'zmx'
4733
+ ? 'worker.interrupt_recovery_zmx'
4734
+ : 'worker.interrupt_recovery_web');
4735
+ send({
4736
+ type: 'user_notify',
4737
+ turnId: currentBotmuxTurnId,
4738
+ dispatchAttempt: currentBotmuxDispatchAttempt,
4739
+ message: t('worker.interrupt_unconfirmed', {
4740
+ key: TMUX_KEY_MAP[key] ?? key,
4741
+ cliName: cliName(),
4742
+ recovery,
4743
+ }),
4744
+ });
4745
+ }
4746
+ // Refresh the card from real output, but do not clear prompt blocking or
4747
+ // claim that the CLI stopped when the key never reached it.
4748
+ scheduleOneShotAfterAction();
4749
+ return;
4555
4750
  }
4556
4751
  // ESC/Ctrl-C/Enter likely ends an active TUI prompt. Un-wedge the blocking
4557
4752
  // flag here — without this, dismissing an AskUserQuestion dialog via the
@@ -4576,23 +4771,14 @@ const KEY_TO_ANSI = {
4576
4771
  * @returns true if all keys were written successfully; false if backend is gone
4577
4772
  * or a write threw (caller must NOT send tui_keys_delivered on false).
4578
4773
  */
4579
- async function handleTuiKeys(keys, isFinal) {
4580
- if (!backend || keys.length === 0)
4774
+ async function handleTuiKeysDirect(targetBackend, keys, isFinal) {
4775
+ if (keys.length === 0)
4581
4776
  return false;
4582
4777
  try {
4583
- if ('sendSpecialKeys' in backend) {
4584
- const b = backend;
4585
- // Send each key individually with 100ms delay for TUI state processing
4586
- for (const key of keys) {
4587
- b.sendSpecialKeys(key);
4588
- await new Promise(r => setTimeout(r, 100));
4589
- }
4590
- }
4591
- else {
4592
- for (const key of keys) {
4593
- backend.write(KEY_TO_ANSI[key] ?? key);
4594
- await new Promise(r => setTimeout(r, 100));
4595
- }
4778
+ const delivered = await sendTuiKeySequence(targetBackend, keys, KEY_TO_ANSI, { isCurrent: () => backend === targetBackend });
4779
+ if (!delivered) {
4780
+ logError('handleTuiKeys write rejected or backend replaced');
4781
+ return false;
4596
4782
  }
4597
4783
  }
4598
4784
  catch (e) {
@@ -4609,6 +4795,18 @@ async function handleTuiKeys(keys, isFinal) {
4609
4795
  log(`TUI keys: ${keys.join(' ')}${isFinal ? ' (final)' : ''}`);
4610
4796
  return true;
4611
4797
  }
4798
+ async function handleTuiKeys(keys, isFinal) {
4799
+ if (!backend || keys.length === 0)
4800
+ return false;
4801
+ const targetBackend = backend;
4802
+ try {
4803
+ return await runAfterAmbiguousSubmissionWrites(targetBackend, () => handleTuiKeysDirect(targetBackend, keys, isFinal));
4804
+ }
4805
+ catch (err) {
4806
+ logError(`handleTuiKeys queued write failed: ${err instanceof Error ? err.message : String(err)}`);
4807
+ return false;
4808
+ }
4809
+ }
4612
4810
  // 待注入的 TUI 命令队列。生命周期绑定当前 CLI 进程:killCli() 会清空它,
4613
4811
  // 防止 restart 后残留命令重放进新 CLI——新增清理状态时记得同步那里。
4614
4812
  // barrier 语义:barrier=true 的注入必须先于本次 pending 用户消息落地。历史上
@@ -4619,7 +4817,7 @@ async function handleTuiKeys(keys, isFinal) {
4619
4817
  // 单元——本文件只持有队列状态。
4620
4818
  const pendingInjections = [];
4621
4819
  let injectionFlushing = false;
4622
- /** 排队注入一行 TUI 命令:idle(isPromptReady)时经 sendRawCommandLineSerially 敲入。 */
4820
+ /** 排队注入一行 TUI 命令:idle(isPromptReady)时经串行恢复事务敲入。 */
4623
4821
  async function flushPendingInjections() {
4624
4822
  // 不跨 restart 边界写入(与 flushPending 同款守卫):destroySession 异步期间
4625
4823
  // backend 可能仍指向旧 CLI。
@@ -4672,7 +4870,7 @@ async function flushPendingInjections() {
4672
4870
  try {
4673
4871
  // Serially:与 startupCommands / raw_input / native-rename 共用同一条
4674
4872
  // 字面命令行写入互斥链(text → beat → Enter 窗口不被拼接)。
4675
- await sendRawCommandLineSerially(backend, cmd);
4873
+ await sendRawCommandLineWithRecoveryFence(backend, cmd);
4676
4874
  await awaitPtyQuiescence(STARTUP_CMD_QUIET_MS, STARTUP_CMD_CAP_MS);
4677
4875
  log(`Injected command: ${cmd}`);
4678
4876
  }
@@ -4701,39 +4899,53 @@ async function flushPendingInjections() {
4701
4899
  */
4702
4900
  async function handleTuiTextInput(keys, text) {
4703
4901
  if (!backend || !cliAdapter)
4704
- return;
4705
- // Strip trailing Enter from keys — we don't want to press Enter on "Type something"
4706
- const navKeys = keys[keys.length - 1] === 'Enter' ? keys.slice(0, -1) : keys;
4707
- // Step 1: navigate to "Type something" (no Enter)
4708
- if ('sendSpecialKeys' in backend) {
4709
- const b = backend;
4710
- for (const key of navKeys) {
4711
- b.sendSpecialKeys(key);
4712
- await new Promise(r => setTimeout(r, 100));
4902
+ return false;
4903
+ const targetBackend = backend;
4904
+ const targetAdapter = cliAdapter;
4905
+ const navKeyCount = keys[keys.length - 1] === 'Enter' ? keys.length - 1 : keys.length;
4906
+ log(`TUI text input: writing "${text.substring(0, 80)}" to PTY (after ${navKeyCount} nav keys)`);
4907
+ try {
4908
+ const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => submitTuiTextInput({
4909
+ target: strictInputHandle(targetBackend),
4910
+ keys,
4911
+ text,
4912
+ keyToAnsi: KEY_TO_ANSI,
4913
+ isCurrent: () => backend === targetBackend && cliAdapter === targetAdapter,
4914
+ writeInput: async (target, content) => {
4915
+ const result = await targetAdapter.writeInput(target, content);
4916
+ if (targetBackend.captureAmbiguousSubmissionFence) {
4917
+ await settleVerifiableSubmissionForJournal(result);
4918
+ }
4919
+ return result;
4920
+ },
4921
+ }), delivered => delivered);
4922
+ if (transaction.recoveryFailureReason) {
4923
+ throw new SubmissionWriteError(`backend could not commit the TUI text submission journal; ${transaction.recoveryFailureReason}`, transaction.recoveryFailureReason);
4924
+ }
4925
+ if (!transaction.result) {
4926
+ logError('TUI text input was rejected, unconfirmed, or crossed a backend replacement');
4927
+ return false;
4713
4928
  }
4714
4929
  }
4715
- else {
4716
- for (const key of navKeys) {
4717
- backend.write(KEY_TO_ANSI[key] ?? key);
4718
- await new Promise(r => setTimeout(r, 100));
4930
+ catch (err) {
4931
+ logError(`TUI text input write failed: ${err?.message ?? err}`);
4932
+ if (err instanceof SubmissionWriteError && err.recoveryFailureReason) {
4933
+ send({
4934
+ type: 'user_notify',
4935
+ turnId: currentBotmuxTurnId,
4936
+ dispatchAttempt: currentBotmuxDispatchAttempt,
4937
+ message: err.recoveryFailureReason,
4938
+ });
4719
4939
  }
4940
+ return false;
4720
4941
  }
4721
- // Step 2: clear blocking state
4942
+ // Clear blocking only after navigation and adapter submission both succeed.
4722
4943
  tuiPromptBlocking = false;
4723
4944
  if (isPromptReady) {
4724
4945
  isPromptReady = false;
4725
4946
  idleDetector?.reset();
4726
4947
  }
4727
- // Wait briefly so the cursor position is stable before pasting
4728
- await new Promise(r => setTimeout(r, 200));
4729
- // Step 3: write text via cliAdapter (auto-switches to text mode + submits with Enter)
4730
- log(`TUI text input: writing "${text.substring(0, 80)}" to PTY (after ${navKeys.length} nav keys)`);
4731
- try {
4732
- await cliAdapter.writeInput(backend, text);
4733
- }
4734
- catch (err) {
4735
- log(`TUI text input write failed: ${err.message}`);
4736
- }
4948
+ return true;
4737
4949
  }
4738
4950
  /**
4739
4951
  * Drive CoCo's native AskUserQuestion picker to enter the answer the user picked
@@ -4760,6 +4972,16 @@ async function handleTuiTextInput(keys, text) {
4760
4972
  async function driveCocoPicker(navKeys, needsReviewSubmit, comment) {
4761
4973
  if (!backend)
4762
4974
  return;
4975
+ if (!backendScreenEvidenceIsAuthoritativeForMutation()) {
4976
+ logError('coco_drive_picker: refused because ZMX screen geometry is not authoritative');
4977
+ send({
4978
+ type: 'user_notify',
4979
+ turnId: currentBotmuxTurnId,
4980
+ dispatchAttempt: currentBotmuxDispatchAttempt,
4981
+ message: t('worker.tui_submit_failed', { cliName: cliName() }),
4982
+ });
4983
+ return;
4984
+ }
4763
4985
  const snap = () => (lastAnalyzerSnapshot || renderer?.rawSnapshot() || '');
4764
4986
  const waitFor = async (re, timeoutMs) => {
4765
4987
  const deadline = Date.now() + timeoutMs;
@@ -4776,43 +4998,89 @@ async function driveCocoPicker(navKeys, needsReviewSubmit, comment) {
4776
4998
  log('coco_drive_picker: picker not detected within 30s — aborting drive');
4777
4999
  return;
4778
5000
  }
5001
+ if (!backend)
5002
+ return;
5003
+ const targetBackend = backend;
4779
5004
  tuiPromptBlocking = true;
5005
+ let failureReported = false;
5006
+ const reportFailure = (detail) => {
5007
+ if (failureReported)
5008
+ return;
5009
+ failureReported = true;
5010
+ const suffix = detail
5011
+ ? `: ${detail instanceof Error ? detail.message : String(detail)}`
5012
+ : '';
5013
+ logError(`coco_drive_picker: TUI answer delivery failed${suffix}`);
5014
+ send({
5015
+ type: 'user_notify',
5016
+ turnId: currentBotmuxTurnId,
5017
+ dispatchAttempt: currentBotmuxDispatchAttempt,
5018
+ message: t('worker.tui_submit_failed', { cliName: cliName() }),
5019
+ });
5020
+ };
4780
5021
  if (comment && comment.trim()) {
4781
5022
  // Free-text reply: navigate to the first question's "Type something" row,
4782
5023
  // type the text, then a single Enter. Single-question submits directly; for
4783
5024
  // multi-question this only fills the first question (logged limitation).
4784
5025
  log(`coco_drive_picker: free-text answer (${navKeys.length} nav keys)${needsReviewSubmit ? ' [multi-question — partial]' : ''}`);
4785
- const b = backend;
4786
- if ('sendSpecialKeys' in backend) {
4787
- for (const key of navKeys) {
4788
- b.sendSpecialKeys(key);
4789
- await new Promise(r => setTimeout(r, 100));
5026
+ try {
5027
+ const transaction = await runAmbiguousSubmissionTransaction(targetBackend, async () => {
5028
+ const navigated = await sendTuiKeySequence(targetBackend, navKeys, KEY_TO_ANSI, { isCurrent: () => backend === targetBackend });
5029
+ if (!navigated) {
5030
+ throw new Error('backend rejected free-text navigation');
5031
+ }
5032
+ await new Promise(r => setTimeout(r, 150));
5033
+ if (backend !== targetBackend) {
5034
+ throw new Error('backend changed before free-text input');
5035
+ }
5036
+ const input = strictInputHandle(targetBackend);
5037
+ if (typeof input.sendText === 'function')
5038
+ input.sendText(comment);
5039
+ else
5040
+ input.write(comment);
5041
+ await new Promise(r => setTimeout(r, 200));
5042
+ if (backend !== targetBackend) {
5043
+ throw new Error('backend changed before free-text submit');
5044
+ }
5045
+ if (!await handleTuiKeysDirect(targetBackend, ['Enter'], true)) {
5046
+ throw new Error('backend rejected free-text submit key');
5047
+ }
5048
+ });
5049
+ if (transaction.recoveryFailureReason) {
5050
+ throw new SubmissionWriteError(`backend could not commit the free-text submission journal; ${transaction.recoveryFailureReason}`, transaction.recoveryFailureReason);
4790
5051
  }
4791
5052
  }
4792
- else {
4793
- for (const key of navKeys) {
4794
- backend.write(KEY_TO_ANSI[key] ?? key);
4795
- await new Promise(r => setTimeout(r, 100));
5053
+ catch (err) {
5054
+ if (err instanceof SubmissionWriteError && err.recoveryFailureReason) {
5055
+ send({
5056
+ type: 'user_notify',
5057
+ turnId: currentBotmuxTurnId,
5058
+ dispatchAttempt: currentBotmuxDispatchAttempt,
5059
+ message: err.recoveryFailureReason,
5060
+ });
4796
5061
  }
5062
+ reportFailure(err);
4797
5063
  }
4798
- await new Promise(r => setTimeout(r, 150));
4799
- if ('sendText' in backend && b.sendText)
4800
- b.sendText(comment);
4801
- else
4802
- backend.write(comment);
4803
- await new Promise(r => setTimeout(r, 200));
4804
- await handleTuiKeys(['Enter'], true); // single Enter submits + clears blocking state
4805
5064
  return;
4806
5065
  }
4807
5066
  // Button selection. Single question: navKeys submit directly (isFinal=true).
4808
5067
  // Multi question: navKeys land on Review, then one Enter on "Submit answers".
4809
5068
  log(`coco_drive_picker: selection answer (${navKeys.length} keys, review=${needsReviewSubmit})`);
4810
- await handleTuiKeys(navKeys, !needsReviewSubmit);
5069
+ if (!await handleTuiKeys(navKeys, !needsReviewSubmit)) {
5070
+ reportFailure();
5071
+ return;
5072
+ }
4811
5073
  if (needsReviewSubmit) {
4812
5074
  const review = await waitFor(/Review your answers|Submit answers/, 8_000);
4813
5075
  if (!review)
4814
5076
  log('coco_drive_picker: Review screen not detected — submitting anyway');
4815
- await handleTuiKeys(['Enter'], true); // cursor defaults to "Submit answers"
5077
+ if (backend !== targetBackend) {
5078
+ reportFailure();
5079
+ return;
5080
+ }
5081
+ if (!await handleTuiKeys(['Enter'], true)) {
5082
+ reportFailure();
5083
+ }
4816
5084
  }
4817
5085
  }
4818
5086
  // ─── Trust Dialog Detection ──────────────────────────────────────────────────
@@ -4823,6 +5091,33 @@ async function driveCocoPicker(navKeys, needsReviewSubmit, comment) {
4823
5091
  const TRUST_DIALOG_PATTERN = /Yes, I trust this folder|Yes, continue/;
4824
5092
  let trustHandled = false;
4825
5093
  const codexUpdateDialogGuard = new CodexUpdateDialogGuard();
5094
+ // Auto-confirm Claude Code's mid-session "Change effort level?" Yes/No dialog.
5095
+ // Armed only by botmux's own `/effort <level>` passthrough (see deliverRawInput)
5096
+ // and disarmed on match, timeout, or CLI respawn — never inspects idle screens.
5097
+ const effortConfirmGuard = new EffortConfirmDialogGuard();
5098
+ let effortConfirmTimer = null;
5099
+ // The dialog appears within a second of the command landing; keep the arm
5100
+ // window short so a later, unrelated screen can never be mistaken for it.
5101
+ const EFFORT_CONFIRM_WINDOW_MS = 8_000;
5102
+ function disarmEffortConfirm() {
5103
+ if (effortConfirmTimer) {
5104
+ clearTimeout(effortConfirmTimer);
5105
+ effortConfirmTimer = null;
5106
+ }
5107
+ effortConfirmGuard.disarm();
5108
+ }
5109
+ /** Arm the effort-confirm guard for a bounded window after a `/effort <level>`
5110
+ * passthrough. Only claude-code sessions we drive show this dialog. */
5111
+ function armEffortConfirm() {
5112
+ if (lastInitConfig?.cliId !== 'claude-code' || lastInitConfig.adoptMode)
5113
+ return;
5114
+ disarmEffortConfirm();
5115
+ effortConfirmGuard.arm();
5116
+ effortConfirmTimer = setTimeout(() => {
5117
+ effortConfirmTimer = null;
5118
+ effortConfirmGuard.disarm();
5119
+ }, EFFORT_CONFIRM_WINDOW_MS);
5120
+ }
4826
5121
  /**
4827
5122
  * Aiden refuses the Codex `-c check_for_update_on_startup=false` override.
4828
5123
  * If that wrapper still exposes the startup update picker, move from its
@@ -4855,6 +5150,33 @@ function dismissAidenCodexUpdateDialog(data) {
4855
5150
  }
4856
5151
  return true;
4857
5152
  }
5153
+ /**
5154
+ * Handle startup-only interactions discovered in visible terminal text.
5155
+ * Both incremental PTY chunks and authoritative observer snapshots pass
5156
+ * through here so reconnecting a live-only backend cannot strand the CLI on a
5157
+ * dialog that appeared while its observer was offline.
5158
+ */
5159
+ function handleVisibleStartupInteraction(data) {
5160
+ // Aiden strips the Codex config override because the launcher rejects it.
5161
+ // Consume only the known startup update picker and choose its non-upgrade
5162
+ // row; never let its selection marker reach the first-prompt idle detector.
5163
+ if (dismissAidenCodexUpdateDialog(data))
5164
+ return true;
5165
+ if (trustHandled)
5166
+ return false;
5167
+ const stripped = data.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
5168
+ if (!TRUST_DIALOG_PATTERN.test(stripped))
5169
+ return false;
5170
+ trustHandled = true;
5171
+ log('Trust dialog detected, auto-accepting...');
5172
+ if (backend && 'sendSpecialKeys' in backend) {
5173
+ backend.sendSpecialKeys('Enter');
5174
+ }
5175
+ else {
5176
+ backend?.write('\r');
5177
+ }
5178
+ return true;
5179
+ }
4858
5180
  // Codex App runner sends botmux control messages as OSC sequences so they do
4859
5181
  // not pollute the visible terminal. Strip them before xterm rendering and
4860
5182
  // translate them back into worker IPC.
@@ -4935,10 +5257,18 @@ function handleCodexAppMarker(body) {
4935
5257
  + `to botmux turn ${shortCorrelationId(identity.turnId)}`);
4936
5258
  }
4937
5259
  const dispatchAttempt = currentBotmuxDispatchAttempt;
4938
- if (startedAtMs !== undefined && shouldSuppressBridgeEmit({ markTimeMs: startedAtMs, isLocal: false, finalText: marker.content }, completedAtMs + 5_001, readSendMarkers(), false)) {
4939
- log(`${cliName()} final_output suppressed (model already called botmux send)`);
4940
- emitTurnTerminal(identity.turnId, 'completed', undefined, dispatchAttempt);
4941
- return;
5260
+ if (startedAtMs !== undefined) {
5261
+ const markers = readSendMarkers();
5262
+ const gateInput = { markTimeMs: startedAtMs, isLocal: false, finalText: marker.content };
5263
+ if (shouldSuppressBridgeEmit(gateInput, completedAtMs + 5_001, markers, false)) {
5264
+ log(`${cliName()} final_output suppressed (model already called botmux send)`);
5265
+ // Symmetric with the legacy final branch: tell observers (e.g. the
5266
+ // message-listener run-preview lifecycle) that this turn's reply was
5267
+ // the model's explicit botmux send, so it stops showing "running".
5268
+ notifyExplicitReplyObserved(identity.turnId, explicitReplyMarkerForTurnWindow(gateInput, completedAtMs + 5_001, markers, false));
5269
+ emitTurnTerminal(identity.turnId, 'completed', undefined, dispatchAttempt);
5270
+ return;
5271
+ }
4942
5272
  }
4943
5273
  send({
4944
5274
  type: 'final_output',
@@ -4946,6 +5276,7 @@ function handleCodexAppMarker(body) {
4946
5276
  lastUuid: identity.lastUuid,
4947
5277
  turnId: identity.turnId,
4948
5278
  ...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
5279
+ ...(marker.usage ? { usage: marker.usage } : {}),
4949
5280
  });
4950
5281
  emitTurnTerminal(identity.turnId, 'completed', undefined, dispatchAttempt);
4951
5282
  return;
@@ -4974,9 +5305,12 @@ function handleCodexAppMarker(body) {
4974
5305
  // equality for compatibility, but can never select another attempt.
4975
5306
  const dispatchAttempt = currentBotmuxDispatchAttempt;
4976
5307
  if (startedAtMs !== undefined) {
4977
- const sentByModel = shouldSuppressBridgeEmit({ markTimeMs: startedAtMs, isLocal: false, finalText: marker.content }, completedAtMs + 5_001, readSendMarkers(), false);
5308
+ const markers = readSendMarkers();
5309
+ const gateInput = { markTimeMs: startedAtMs, isLocal: false, finalText: payload.content };
5310
+ const sentByModel = shouldSuppressBridgeEmit(gateInput, completedAtMs + 5_001, markers, false);
4978
5311
  if (sentByModel) {
4979
5312
  log(`${cliName()} final_output suppressed (model already called botmux send)`);
5313
+ notifyExplicitReplyObserved(turnId, explicitReplyMarkerForTurnWindow(gateInput, completedAtMs + 5_001, markers, false));
4980
5314
  emitTurnTerminal(turnId, 'completed', undefined, dispatchAttempt);
4981
5315
  return;
4982
5316
  }
@@ -5045,10 +5379,210 @@ function maybeReportDeferredTopicMaterialization(data) {
5045
5379
  }
5046
5380
  }
5047
5381
  // ─── Prompt Detection ────────────────────────────────────────────────────────
5382
+ function settleBackendScreenBeforeIdle(target, requestedRevision) {
5383
+ if (!target.settleCurrentScreen)
5384
+ return Promise.resolve({ proceed: true, degraded: false });
5385
+ const existing = idleScreenSettleTask?.backend === target ? idleScreenSettleTask : null;
5386
+ // A settle round is reusable only for the same (or a newer) authoritative
5387
+ // screen revision. If another idle edge arrives after more output while the
5388
+ // old barrier is still running, chain one fresh round behind it; otherwise
5389
+ // the later edge could finalize from a sample that began before its output.
5390
+ if (existing && existing.revision >= requestedRevision)
5391
+ return existing.promise;
5392
+ const runFreshSettle = async () => {
5393
+ for (let attempt = 0; attempt < 3; attempt += 1) {
5394
+ if (backend !== target)
5395
+ return { proceed: false, degraded: false };
5396
+ try {
5397
+ if (await target.settleCurrentScreen())
5398
+ return { proceed: true, degraded: false };
5399
+ }
5400
+ catch (err) {
5401
+ log(`Screen settle attempt ${attempt + 1} failed: ${err instanceof Error ? err.message : String(err)}`);
5402
+ }
5403
+ if (attempt < 2)
5404
+ await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1)));
5405
+ }
5406
+ // History is an availability enhancement around an already-successful
5407
+ // screen cache. Never leave a completed turn stuck forever when the ZMX
5408
+ // daemon is busy; use the last authoritative snapshot after bounded retry.
5409
+ return { proceed: true, degraded: true };
5410
+ };
5411
+ const promise = (async () => {
5412
+ if (existing)
5413
+ await existing.promise;
5414
+ if (backend !== target)
5415
+ return { proceed: false, degraded: false };
5416
+ return runFreshSettle();
5417
+ })();
5418
+ idleScreenSettleTask = { backend: target, revision: requestedRevision, promise };
5419
+ void promise.finally(() => {
5420
+ if (idleScreenSettleTask?.promise === promise)
5421
+ idleScreenSettleTask = null;
5422
+ });
5423
+ return promise;
5424
+ }
5425
+ /** Submission writes must surface ZMX's explicit false result, while its
5426
+ * best-effort navigation/startup keystrokes keep their non-throwing contract. */
5427
+ function adapterInputHandle(target) {
5428
+ return target instanceof ZmxBackend
5429
+ ? strictInputHandle(target)
5430
+ : target;
5431
+ }
5432
+ let ambiguousSubmissionWriteTail = Promise.resolve();
5433
+ /** A logical input that was definitely not written because this exact backend
5434
+ * already carried older composer-recovery debt. Keep it queued, but freeze all
5435
+ * further flushes until a fresh backend generation proves the composer clean. */
5436
+ let ambiguousSubmissionRecoveryHold = null;
5437
+ /** Queue an external control action behind any in-flight ZMX text transaction
5438
+ * without opening a new composer journal. This keeps card quick-actions from
5439
+ * landing between adapter text chunks and their submit key. */
5440
+ async function runAfterAmbiguousSubmissionWrites(target, action) {
5441
+ if (!target.captureAmbiguousSubmissionFence)
5442
+ return await action();
5443
+ const previous = ambiguousSubmissionWriteTail;
5444
+ let release;
5445
+ ambiguousSubmissionWriteTail = new Promise(resolve => { release = resolve; });
5446
+ await previous;
5447
+ try {
5448
+ if (backend !== target) {
5449
+ throw new Error('backend changed before queued terminal action');
5450
+ }
5451
+ return await action();
5452
+ }
5453
+ finally {
5454
+ release();
5455
+ }
5456
+ }
5457
+ /**
5458
+ * Serialize every logical submission for backends that expose the ambiguity
5459
+ * fence contract. ZMX permits only one durable pending composer transaction at
5460
+ * a time; this lock covers structured adapter writes, adopt writes and raw
5461
+ * commands alike while leaving every other backend's concurrency unchanged.
5462
+ */
5463
+ async function runAmbiguousSubmissionTransaction(target, write, submissionAccepted = () => true, beforeWrite) {
5464
+ if (!target.captureAmbiguousSubmissionFence) {
5465
+ await beforeWrite?.();
5466
+ return { result: await write() };
5467
+ }
5468
+ const previous = ambiguousSubmissionWriteTail;
5469
+ let release;
5470
+ ambiguousSubmissionWriteTail = new Promise(resolve => { release = resolve; });
5471
+ await previous;
5472
+ let submissionFence;
5473
+ let submissionStarted = false;
5474
+ try {
5475
+ if (backend !== target) {
5476
+ throw new Error('backend changed before logical submission');
5477
+ }
5478
+ submissionFence = captureAmbiguousSubmissionFence(target);
5479
+ await beforeWrite?.();
5480
+ submissionStarted = true;
5481
+ const result = await write();
5482
+ const accepted = await submissionAccepted(result);
5483
+ const recoveryFailureReason = accepted
5484
+ ? confirmAmbiguousSubmissionAfterSuccess(target, submissionFence)
5485
+ : cancelAmbiguousSubmissionAfterFailure(target, submissionFence);
5486
+ return { result, recoveryFailureReason };
5487
+ }
5488
+ catch (err) {
5489
+ const recoveryFailureReason = err instanceof AmbiguousSubmissionBlockedError
5490
+ ? ambiguousSubmissionRecoveryMessage(err.failure)
5491
+ : cancelAmbiguousSubmissionAfterFailure(target, submissionFence);
5492
+ const detail = err instanceof Error ? err.message : String(err);
5493
+ throw new SubmissionWriteError(recoveryFailureReason ? `${detail}; ${recoveryFailureReason}` : detail, recoveryFailureReason, submissionStarted);
5494
+ }
5495
+ finally {
5496
+ release();
5497
+ }
5498
+ }
5499
+ /**
5500
+ * A false adapter result can mean only that its short in-band transcript check
5501
+ * expired; slow startup hooks may append the authoritative record seconds
5502
+ * later. While the ZMX submission mutex and pending journal are still held,
5503
+ * give that recheck the same bounded settle window used by the legacy warning
5504
+ * path. Only a confirmed transcript clears the WAL; a still-missing record is
5505
+ * cancelled/poisoned before any later input can be written.
5506
+ */
5507
+ async function settleVerifiableSubmissionForJournal(result) {
5508
+ if (result?.submitted !== false)
5509
+ return true;
5510
+ if (result.failureReason || !result.recheck)
5511
+ return false;
5512
+ await new Promise(resolve => setTimeout(resolve, SUBMIT_DEFERRED_RECHECK_MS));
5513
+ try {
5514
+ const recheck = await result.recheck();
5515
+ const submitted = typeof recheck === 'boolean'
5516
+ ? recheck
5517
+ : recheck.submitted === true;
5518
+ if (!submitted)
5519
+ return false;
5520
+ result.submitted = true;
5521
+ if (typeof recheck === 'object'
5522
+ && recheck
5523
+ && typeof recheck.cliSessionId === 'string') {
5524
+ result.cliSessionId = recheck.cliSessionId;
5525
+ }
5526
+ return true;
5527
+ }
5528
+ catch (err) {
5529
+ log(`Deferred submission journal recheck failed: ${err instanceof Error ? err.message : String(err)}`);
5530
+ return false;
5531
+ }
5532
+ }
5533
+ function captureAmbiguousSubmissionFence(target) {
5534
+ try {
5535
+ return target.captureAmbiguousSubmissionFence?.();
5536
+ }
5537
+ catch (err) {
5538
+ log(`Unable to capture ambiguous-submission fence: ${err instanceof Error ? err.message : String(err)}`);
5539
+ throw err;
5540
+ }
5541
+ }
5542
+ function ambiguousSubmissionRecoveryMessage(failure) {
5543
+ const key = {
5544
+ 'recovery-pending': 'worker.zmx_recovery_pending',
5545
+ 'recovery-unconfirmed': 'worker.zmx_recovery_unconfirmed',
5546
+ };
5547
+ return t(key[failure]);
5548
+ }
5549
+ function confirmAmbiguousSubmissionAfterSuccess(target, fence) {
5550
+ if (!target || fence === undefined)
5551
+ return undefined;
5552
+ if (backend !== target) {
5553
+ return ambiguousSubmissionRecoveryMessage('recovery-unconfirmed');
5554
+ }
5555
+ try {
5556
+ const failure = target.confirmAmbiguousSubmission?.(fence);
5557
+ return failure ? ambiguousSubmissionRecoveryMessage(failure) : undefined;
5558
+ }
5559
+ catch (err) {
5560
+ log(`Unable to confirm ambiguous submission: ${err instanceof Error ? err.message : String(err)}`);
5561
+ return ambiguousSubmissionRecoveryMessage('recovery-unconfirmed');
5562
+ }
5563
+ }
5564
+ function cancelAmbiguousSubmissionAfterFailure(target, fence) {
5565
+ if (!target || fence === undefined)
5566
+ return undefined;
5567
+ if (backend !== target) {
5568
+ return ambiguousSubmissionRecoveryMessage('recovery-unconfirmed');
5569
+ }
5570
+ try {
5571
+ const failure = target.cancelAmbiguousSubmission?.(fence);
5572
+ if (!failure)
5573
+ return undefined;
5574
+ return ambiguousSubmissionRecoveryMessage(failure);
5575
+ }
5576
+ catch (err) {
5577
+ log(`Unable to cancel ambiguous submission: ${err instanceof Error ? err.message : String(err)}`);
5578
+ return ambiguousSubmissionRecoveryMessage('recovery-unconfirmed');
5579
+ }
5580
+ }
5048
5581
  function onPtyData(data) {
5049
5582
  data = splitCodexAppControl(data);
5050
5583
  if (data.length === 0)
5051
5584
  return;
5585
+ backendScreenRevision += 1;
5052
5586
  lastPtyActivityAtMs = Date.now();
5053
5587
  maybeReportDeferredTopicMaterialization(data);
5054
5588
  maybeCaptureKiroSessionId(data);
@@ -5094,38 +5628,125 @@ function onPtyData(data) {
5094
5628
  ws.send(data);
5095
5629
  }
5096
5630
  }
5097
- // Aiden strips the Codex config override because the launcher rejects it.
5098
- // Consume only the known startup update picker and choose its non-upgrade
5099
- // row; never let its selection marker reach the first-prompt idle detector.
5100
- if (dismissAidenCodexUpdateDialog(data))
5631
+ if (handleVisibleStartupInteraction(data))
5101
5632
  return;
5102
- // Trust dialog auto-accept
5103
- if (!trustHandled) {
5104
- const stripped = data.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
5105
- if (TRUST_DIALOG_PATTERN.test(stripped)) {
5106
- trustHandled = true;
5107
- log('Trust dialog detected, auto-accepting...');
5108
- if (backend && 'sendSpecialKeys' in backend) {
5109
- backend.sendSpecialKeys('Enter');
5110
- }
5111
- else {
5112
- backend?.write('\r');
5113
- }
5114
- return;
5633
+ // Effort-switch confirm auto-accept. Armed only by our own `/effort <level>`
5634
+ // passthrough, so this never fires on an ordinary screen. The dialog's
5635
+ // default row is "Yes", so Enter confirms — mirroring the trust dialog.
5636
+ if (effortConfirmGuard.isArmed() && effortConfirmGuard.inspect(data) === 'confirm') {
5637
+ disarmEffortConfirm();
5638
+ log('Effort-switch confirm dialog detected, auto-accepting...');
5639
+ if (backend && 'sendSpecialKeys' in backend) {
5640
+ backend.sendSpecialKeys('Enter');
5641
+ }
5642
+ else {
5643
+ backend?.write('\r');
5115
5644
  }
5645
+ return;
5116
5646
  }
5117
5647
  // Track last PTY output time for the ready-gate quiescence settle (see
5118
5648
  // settleThenFlush) and delegate idle detection to IdleDetector.
5119
5649
  lastPtyOutputAtMs = Date.now();
5120
5650
  idleDetector?.feed(data);
5121
5651
  }
5652
+ /**
5653
+ * Rebase state derived from a live-only observer after it reconnects. This is
5654
+ * deliberately separate from onPtyData: `snapshot` is authoritative full
5655
+ * state, not an incremental chunk, so appending it would duplicate renderer
5656
+ * and workflow history while still failing to express a reset.
5657
+ */
5658
+ async function onBackendScreenResync(snapshot) {
5659
+ const revision = ++backendScreenRevision;
5660
+ const observedScreenBackend = backend;
5661
+ const now = Date.now();
5662
+ lastPtyActivityAtMs = now;
5663
+ lastPtyOutputAtMs = now;
5664
+ maybeReportDeferredTopicMaterialization(snapshot);
5665
+ maybeCaptureKiroSessionId(snapshot);
5666
+ // Rebase synchronously before xterm's asynchronous write barrier. If newer
5667
+ // PTY bytes arrive while the snapshot is rendering they append after this
5668
+ // authoritative base rather than being overwritten by a late continuation.
5669
+ idleDetector?.reset();
5670
+ if (isWorkflowWorker() && !workflowFinalOutputSent) {
5671
+ // The append-only PTY replay log has no reset opcode. Appending a full
5672
+ // history snapshot would duplicate everything already recorded; keep its
5673
+ // live-byte semantics and rebase only the bounded final-output transcript.
5674
+ workflowTranscript = snapshot.slice(-WORKFLOW_TRANSCRIPT_MAX);
5675
+ maybeEmitWorkflowTranscriptOutput();
5676
+ }
5677
+ let nextRenderer = null;
5678
+ if (renderer) {
5679
+ const previousRenderer = renderer;
5680
+ nextRenderer = new TerminalRenderer(renderCols, renderRows);
5681
+ renderer = nextRenderer;
5682
+ previousRenderer.dispose();
5683
+ await nextRenderer.writeAndFlush(snapshot);
5684
+ // A second resync, incremental output, backend replacement, or teardown can
5685
+ // win while xterm parses the history. Never inspect or feed the stale
5686
+ // continuation after any such generation change.
5687
+ if (backendScreenRevision !== revision
5688
+ || backend !== observedScreenBackend
5689
+ || renderer !== nextRenderer)
5690
+ return;
5691
+ }
5692
+ // ZMX history includes scrollback. Keep a bounded ANSI-rendered projection
5693
+ // for cards and non-mutating diagnostics, never as proof of the authoritative
5694
+ // current viewport: a prior local attach may have changed the real geometry.
5695
+ const visibleSnapshot = nextRenderer?.rawSnapshot() ?? '';
5696
+ lastAnalyzerSnapshot = visibleSnapshot;
5697
+ // ZMX history does not carry the authoritative current PTY dimensions. A
5698
+ // local `zmx attach` can resize the session below our default 120x24 and that
5699
+ // size persists after detach, so even the rendered tail may include rows just
5700
+ // above the real viewport. Never synthesize Enter/Down from a full-history
5701
+ // resync. For the same reason, do not feed history into IdleDetector: an old
5702
+ // ready/completion marker just above the real viewport could otherwise flush
5703
+ // queued input into a CLI that is still busy. Later append-only history deltas
5704
+ // still flow through onPtyData; structured transcript completion remains
5705
+ // authoritative independently of this screen observer.
5706
+ }
5707
+ /** Fire-and-forget bridge for backend callbacks and synchronous seed callers.
5708
+ * Their surrounding try/catch cannot observe a rejected async renderer write,
5709
+ * so terminate every resync promise here instead of leaking an unhandled
5710
+ * rejection into the worker process.
5711
+ */
5712
+ function scheduleBackendScreenResync(snapshot, source) {
5713
+ void onBackendScreenResync(snapshot).catch((err) => {
5714
+ logError(`${source} screen resync failed: ${err instanceof Error ? err.message : String(err)}`);
5715
+ });
5716
+ }
5122
5717
  function releaseRawInputRestartGate() {
5123
5718
  if (!rawInputRestartGate)
5124
5719
  return;
5125
5720
  rawInputRestartGate = false;
5126
5721
  log('Replacement CLI prompt ready — releasing deferred passthrough commands');
5127
5722
  }
5128
- function markPromptReadyFromPty() {
5723
+ function readPaneLeafComm(observedBackend = backend) {
5724
+ const pid = observedBackend?.getChildPid?.();
5725
+ return pid ? readComm(pid) : undefined;
5726
+ }
5727
+ /** A slow rcfile can outlive the launch detector's settle window, then finish
5728
+ * normally. Screen readiness alone is not enough to reopen the gate because a
5729
+ * customized shell prompt can resemble a CLI prompt. Require both the PTY
5730
+ * readiness signal and a non-shell pane leaf before releasing queued input. */
5731
+ function recoverBareShellLaunchFromPty(observedBackend) {
5732
+ if (!bareShellLaunchBlocked)
5733
+ return true;
5734
+ if (backend !== observedBackend) {
5735
+ log('Ignoring PTY prompt-ready from a replaced backend generation');
5736
+ return false;
5737
+ }
5738
+ const comm = readPaneLeafComm(observedBackend);
5739
+ if (!comm || isBareShellComm(comm)) {
5740
+ log(`Ignoring PTY prompt-ready while launch pane is still a bare shell (comm=${comm ?? '?'})`);
5741
+ return false;
5742
+ }
5743
+ bareShellLaunchBlocked = false;
5744
+ log(`Late CLI launch recovered: pane leaf comm=${comm}; releasing queued input`);
5745
+ return true;
5746
+ }
5747
+ function markPromptReadyFromPty(observedBackend) {
5748
+ if (!recoverBareShellLaunchFromPty(observedBackend))
5749
+ return;
5129
5750
  postSessionStartPromptEvidenceInFlight = true;
5130
5751
  try {
5131
5752
  markPromptReady();
@@ -5134,7 +5755,40 @@ function markPromptReadyFromPty() {
5134
5755
  postSessionStartPromptEvidenceInFlight = false;
5135
5756
  }
5136
5757
  }
5758
+ /**
5759
+ * Push a coarse screen status to the daemon without waiting for the 2s sampler.
5760
+ * Used so card-off reactions see working→idle even on short turns.
5761
+ *
5762
+ * @param force - When true, send `status` as-is and skip classifyScreenUsageLimit.
5763
+ * Synthetic "working" seeds for card-off reactions MUST force: if the first
5764
+ * screen already shows a rate-limit banner, classify would rewrite working→
5765
+ * limited and the seed collapses to limited→limited (no working edge →
5766
+ * finishTurnReactions never runs). Review: PR #633 third round.
5767
+ */
5768
+ function publishScreenStatus(status, opts) {
5769
+ if (!renderer)
5770
+ return;
5771
+ const { content } = renderer.snapshot();
5772
+ const statusPayload = opts?.force
5773
+ ? { status }
5774
+ : classifyScreenUsageLimit(content, status);
5775
+ send({
5776
+ type: 'screen_update',
5777
+ content,
5778
+ ...statusPayload,
5779
+ turnId: currentBotmuxTurnId,
5780
+ dispatchAttempt: currentBotmuxDispatchAttempt,
5781
+ });
5782
+ }
5137
5783
  function markPromptReady() {
5784
+ // Only screen readiness plus a non-shell pane leaf may release a launch that
5785
+ // was previously blocked. Transcript/task callbacks can arrive while the
5786
+ // wrapper is still a shell; accepting one here would set isPromptReady and
5787
+ // make the later real PTY callback return early, stranding queued input.
5788
+ if (bareShellLaunchBlocked) {
5789
+ log('Ignoring non-PTY prompt-ready while bare-shell launch block is active');
5790
+ return;
5791
+ }
5138
5792
  if (isPromptReady)
5139
5793
  return; // guard against duplicate calls
5140
5794
  if (cliRestartInProgress && !replacementSpawnInProgress) {
@@ -5236,14 +5890,50 @@ function markPromptReady() {
5236
5890
  log('prompt-ready with no backend installed — deferring restart success receipt');
5237
5891
  }
5238
5892
  }
5239
- // Send immediate idle snapshot so Lark card reflects idle status.
5240
- // BUT: skip when messages are pending — flushPending() will immediately
5241
- // make the CLI busy, so the idle state is transient and shouldn't appear
5242
- // in the card. This avoids a false "就绪" flash on daemon restart
5243
- // (where the initial prompt is queued before the CLI becomes idle).
5244
- if (renderer && pendingMessages.length === 0 && pendingRawInputs.length === 0 && pendingSessionRename === null && !isFlushing) {
5245
- const { content } = renderer.snapshot();
5246
- send({ type: 'screen_update', content, ...usageLimitTracker.classify(content, 'idle'), turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt });
5893
+ // Send an immediate status snapshot so Lark card / card-off reactions track
5894
+ // real work. Skip pure idle when:
5895
+ // - messages are pending flushPending() will immediately make the CLI
5896
+ // busy (avoids a false "就绪" flash on daemon restart);
5897
+ // - Grok-class argv+SessionStart arming: first ready is pre-execution, so
5898
+ // park as working until assistant_final/fireIdle;
5899
+ // - quiescence argv CLIs (Pi/Gemini/MTR/OpenCode): first ready IS turn end
5900
+ // — seed working then idle so card-off gets working→idle (review P2).
5901
+ const hasPendingWork = pendingMessages.length > 0
5902
+ || pendingRawInputs.length > 0
5903
+ || pendingSessionRename !== null
5904
+ || isFlushing;
5905
+ if (renderer && !hasPendingWork) {
5906
+ if (spawnArgvInitialPromptBusy) {
5907
+ spawnArgvInitialPromptBusy = false;
5908
+ spawnArgvNeedsWorkingSeed = false;
5909
+ // Stay non-ready so the next genuine end-of-turn idle is a real edge.
5910
+ isPromptReady = false;
5911
+ idleDetector?.reset();
5912
+ // force: rate-limit banner must not rewrite synthetic working → limited
5913
+ publishScreenStatus('working', { force: true });
5914
+ log('Spawn argv initial prompt still in flight — reporting working (not idle) so turn reactions can settle later');
5915
+ }
5916
+ else if (spawnArgvNeedsWorkingSeed) {
5917
+ // First ready = true completion for quiescence argv adapters. Seed a
5918
+ // working edge before idle/limited so daemon finishTurnReactions (gated
5919
+ // on working→idle|limited) still flips card-off GoGoGo on cold-start
5920
+ // one-shots — including when the terminal already shows a rate-limit
5921
+ // banner (classify would otherwise collapse both seeds to limited).
5922
+ spawnArgvNeedsWorkingSeed = false;
5923
+ publishScreenStatus('working', { force: true });
5924
+ // Second tick may classify to limited when the banner is visible — that
5925
+ // is fine: gate allows working→limited.
5926
+ publishScreenStatus('idle');
5927
+ log('Argv-baked first prompt completed — seeded working→idle for card-off reactions');
5928
+ }
5929
+ else {
5930
+ publishScreenStatus('idle');
5931
+ }
5932
+ }
5933
+ else if (hasPendingWork) {
5934
+ // Queued path will flip busy via flushPending; drop argv seed flags.
5935
+ spawnArgvInitialPromptBusy = false;
5936
+ spawnArgvNeedsWorkingSeed = false;
5247
5937
  }
5248
5938
  // barrier 注入必须先于本次 pending 用户消息落地(现存发送方均 barrier=false,
5249
5939
  // 该分支目前不触发;机制保留见 pendingInjections 声明处注释)。跳过本次
@@ -5333,6 +6023,21 @@ function observeCursorCliSessionId(pid, label = 'spawn') {
5333
6023
  * can defer Claude's jsonl append by 5–15s; a 20s deferred recheck covers
5334
6024
  * both without being so long that a true failure goes unsurfaced. */
5335
6025
  const SUBMIT_DEFERRED_RECHECK_MS = 20_000;
6026
+ /**
6027
+ * A recovery fence failure means the prompt may already be running. Keep its
6028
+ * bridge mark and durable turn open so a real later final can still resolve it;
6029
+ * only freeze further input and tell the user how to recover. Treating this as
6030
+ * a hard non-submit would drop attribution and could replay a live turn.
6031
+ */
6032
+ function notifyAmbiguousSubmissionRecovery(recoveryReason, turnIdentity) {
6033
+ log(`Submission recovery remains ambiguous: ${recoveryReason}`);
6034
+ send({
6035
+ type: 'user_notify',
6036
+ turnId: turnIdentity?.turnId ?? currentBotmuxTurnId,
6037
+ dispatchAttempt: turnIdentity?.dispatchAttempt ?? currentBotmuxDispatchAttempt,
6038
+ message: recoveryReason,
6039
+ });
6040
+ }
5336
6041
  /** Worker-side handler for `submitted: false`. Defers the user-facing
5337
6042
  * warning and runs the adapter-supplied `recheck` closure first; if the
5338
6043
  * message has shown up in the transcript by then (slow path, hook delay),
@@ -5345,11 +6050,11 @@ const SUBMIT_DEFERRED_RECHECK_MS = 20_000;
5345
6050
  * attempt's mark — leaving it would keep `maybeSwitchBridgeJsonl` doing
5346
6051
  * full-directory scans every poll tick for a fingerprint that's permanently
5347
6052
  * dead, the 99% CPU bug this whole patch series is fixing. */
5348
- function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId, failureReason, turnSeq = usageLimitTracker.currentTurn(), turnIdentity) {
6053
+ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId, failureReason, turnSeq = usageLimitTracker.currentTurn(), turnIdentity, durableTerminalStatus = 'failed') {
5349
6054
  const preview = msg.length > 60 ? msg.slice(0, 60) + '…' : msg;
5350
- const emitDurableFailure = (errorCode) => {
6055
+ const emitDurableTerminal = (errorCode) => {
5351
6056
  if (turnIdentity?.turnId && turnIdentity.dispatchAttempt !== undefined) {
5352
- emitTurnTerminal(turnIdentity.turnId, 'failed', errorCode, turnIdentity.dispatchAttempt);
6057
+ emitTurnTerminal(turnIdentity.turnId, durableTerminalStatus, errorCode, turnIdentity.dispatchAttempt);
5353
6058
  }
5354
6059
  };
5355
6060
  const dropBridgeMark = () => {
@@ -5371,7 +6076,7 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
5371
6076
  dropBridgeMark();
5372
6077
  const reason = action.kind === 'notify-hard-failure' ? action.reason : failureReason;
5373
6078
  log(`writeInput: submit impossible — notifying user immediately. reason="${reason}" preview="${preview}"`);
5374
- emitDurableFailure(`submit_impossible:${reason}`);
6079
+ emitDurableTerminal(`submit_impossible:${reason}`);
5375
6080
  if (turnIdentity?.dispatchAttempt === undefined) {
5376
6081
  send({
5377
6082
  type: 'user_notify',
@@ -5418,7 +6123,7 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
5418
6123
  case 'suppress-usage-limit':
5419
6124
  dropBridgeMark();
5420
6125
  log(`Deferred recheck missing but usage limit was detected for this turn — suppressing submit warning. preview="${preview}"`);
5421
- emitDurableFailure('submit_usage_limit');
6126
+ emitDurableTerminal('submit_usage_limit');
5422
6127
  return;
5423
6128
  case 'suppress-active':
5424
6129
  log(`Deferred recheck missing but later ${action.evidence} shows ${cliName()} is active — suppressing submit warning. preview="${preview}"`);
@@ -5426,7 +6131,7 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
5426
6131
  // a terminal result. Keep the arbiter closed and re-check until either
5427
6132
  // the transcript confirms it or a quiet interval proves it stuck.
5428
6133
  if (turnIdentity?.dispatchAttempt !== undefined) {
5429
- scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId, undefined, turnSeq, turnIdentity);
6134
+ scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId, undefined, turnSeq, turnIdentity, durableTerminalStatus);
5430
6135
  }
5431
6136
  return;
5432
6137
  case 'notify-hard-failure':
@@ -5437,12 +6142,19 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
5437
6142
  }
5438
6143
  dropBridgeMark();
5439
6144
  log(`Deferred recheck still missing — notifying user. preview="${preview}"`);
5440
- emitDurableFailure('submit_unconfirmed');
6145
+ emitDurableTerminal('submit_unconfirmed');
5441
6146
  if (turnIdentity?.dispatchAttempt === undefined) {
5442
6147
  send({
5443
6148
  type: 'user_notify',
5444
6149
  turnId: turnIdentity?.turnId ?? currentBotmuxTurnId,
5445
- message: t('worker.submit_unconfirmed', { cliName: cliName(), secs: Math.round(SUBMIT_DEFERRED_RECHECK_MS / 1000), transcriptLabel, preview }),
6150
+ message: t(effectiveBackendType === 'zmx'
6151
+ ? 'worker.submit_unconfirmed_zmx'
6152
+ : 'worker.submit_unconfirmed', {
6153
+ cliName: cliName(),
6154
+ secs: Math.round(SUBMIT_DEFERRED_RECHECK_MS / 1000),
6155
+ transcriptLabel,
6156
+ preview,
6157
+ }),
5446
6158
  });
5447
6159
  }
5448
6160
  }, SUBMIT_DEFERRED_RECHECK_MS);
@@ -5457,15 +6169,15 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
5457
6169
  * runs, and the pane is left at a bare shell. Typing the multi-line prompt into
5458
6170
  * it just yields `zsh: parse error near '\n'` and the user is stuck (the exact
5459
6171
  * bug this guards). Instead of typing into the shell we surface ONE actionable
5460
- * diagnostic and latch the session so no further prompt is mis-typed.
6172
+ * diagnostic and hold the session so no prompt is mis-typed. A same-shell
6173
+ * verdict is provisional: slow rc startup may still exec the CLI later, in
6174
+ * which case current-generation PTY readiness can release the hold safely.
5461
6175
  *
5462
- * Why this is the right moment / low false-positive: the first prompt is held
5463
- * until the CLI signals ready OR the 15s/45s first-prompt timeout fires, so by
5464
- * the time we get here a healthy CLI has long since `exec`'d (leaf comm =
5465
- * codex/node/…) only a trampolined/failed launch is still a bare shell. We
5466
- * skip wrapperCli/adopt (their leaf is legitimately a launcher/observed pane)
5467
- * and the pty/herdr backends (which `exec` the CLI directly — getChildPid is the
5468
- * CLI itself, never a shell).
6176
+ * Why this is the right moment: it is the last guard before the first write.
6177
+ * The first-prompt timeout plus bounded settle catches ordinary wrapper jitter,
6178
+ * while the reversible hold covers slower rc startup without ever typing into
6179
+ * a shell. We skip wrapperCli/adopt (their leaf is legitimately a launcher or
6180
+ * observed pane); direct backends self-exclude because their child is the CLI.
5469
6181
  *
5470
6182
  * Returns true when a persistent bare-shell launch was detected (caller must
5471
6183
  * NOT flush). A transient wrapper shell gets a bounded chance to exec the CLI.
@@ -5484,10 +6196,7 @@ async function detectBareShellLaunch() {
5484
6196
  bareShellCheckInProgress = true;
5485
6197
  let comm;
5486
6198
  try {
5487
- comm = await settleLaunchComm(() => {
5488
- const pid = backend?.getChildPid?.();
5489
- return pid ? readComm(pid) : undefined;
5490
- });
6199
+ comm = await settleLaunchComm(readPaneLeafComm);
5491
6200
  }
5492
6201
  finally {
5493
6202
  bareShellCheckInProgress = false;
@@ -5504,15 +6213,23 @@ async function detectBareShellLaunch() {
5504
6213
  return false;
5505
6214
  if (!isBareShellComm(comm))
5506
6215
  return false; // CLI (rust/go/node) is running — healthy launch
5507
- // Bare shell is the pane leaf the CLI never launched. Tier the message on
5508
- // whether the leaf shell differs from the one botmux launched with: a
5509
- // mismatch is the unmistakable signature of an rcfile `exec`-trampoline.
6216
+ // The pane leaf is still a shell. A mismatch is the unmistakable signature
6217
+ // of an rcfile `exec`-trampoline; the same shell is ambiguous because a slow
6218
+ // rcfile may still finish and exec the CLI after this bounded check.
5510
6219
  const launchShell = (lastInitConfig?.launchShell || process.env.SHELL || '').trim();
5511
6220
  const expectedShell = launchShell ? basename(launchShell) : '';
5512
6221
  const trampolined = bareShellLaunchKind(comm, expectedShell) === 'trampoline';
5513
6222
  bareShellLaunchBlocked = true;
5514
- log(`Bare-shell launch detected: pane leaf comm=${comm}, expected launch shell=${expectedShell || '?'}, ` +
5515
- `cli=${lastInitConfig?.cliId}; suppressing first-prompt write (${trampolined ? 'rc trampoline' : 'CLI did not start'})`);
6223
+ // Injection-first flushes can enter while isPromptReady is still true. Make
6224
+ // this a real busy/blocked transition so a later PTY-ready event re-enters
6225
+ // markPromptReady instead of being swallowed by its duplicate-ready guard.
6226
+ isPromptReady = false;
6227
+ idleDetector?.reset();
6228
+ log(trampolined
6229
+ ? `Bare-shell launch detected: pane leaf comm=${comm}, expected launch shell=${expectedShell || '?'}, ` +
6230
+ `cli=${lastInitConfig?.cliId}; suppressing first-prompt write (rc trampoline)`
6231
+ : `CLI launch still pending: pane leaf comm=${comm}, cli=${lastInitConfig?.cliId}; ` +
6232
+ `holding queued input until PTY readiness confirms a non-shell process`);
5516
6233
  const cli = cliName();
5517
6234
  let message;
5518
6235
  if (trampolined) {
@@ -5525,17 +6242,16 @@ async function detectBareShellLaunch() {
5525
6242
  }
5526
6243
  else {
5527
6244
  message =
5528
- `⚠️ 会话没能启动:pane 里还停在 \`${comm}\`,${cli} 没真正跑起来——我没把消息打进去(否则会被当 shell 命令执行)。\n\n` +
5529
- `最常见原因:rc 文件里有交互式提示卡住了 shell 启动,例如:\n` +
5530
- `① Oh My Zsh 升级提示("Would you like to update Oh My Zsh? [Y/n]")——${comm} source ~/.zshrc 时弹出,等你按 Y/n,CLI 的启动命令没机会跑\n` +
5531
- `② git 凭据弹窗(GIT_TERMINAL_PROMPT)或其它需要交互输入的启动脚本\n` +
5532
- `③ ${cli} 的可执行文件不在 PATH 上(CLI 没找到)\n\n` +
5533
- `修法(任选其一,改完重启 daemon 再发一条消息):\n` +
5534
- `• 最省事:升级 botmux 到含自动注入 DISABLE_AUTO_UPDATE=true 的版本(仅 botmux 托管 shell 启动时跳过 oh-my-zsh 升级检查,不影响你自己的终端)\n` +
5535
- `• 手动修:在 ~/.zshrc 的 source $ZSH/oh-my-zsh.sh 之前加一行 DISABLE_UPDATE_PROMPT="true"(自动升级不弹提示);或加 DISABLE_AUTO_UPDATE="true"(完全跳过升级检查)\n` +
5536
- `• 在 web 终端里手动敲一下启动命令看报什么错;确认 CLI 二进制能在 PATH 上找到;或精简 rc 启动逻辑后重启 daemon 再试`;
5537
- }
5538
- send({ type: 'user_notify', turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt, message });
6245
+ `⚠️ ${cli} 启动时间较长:pane 里暂时仍是 \`${comm}\`。我没有把消息写进 shell,消息还在队列里;检测到真实输入框后会自动继续投递,无需重发。\n\n` +
6246
+ `仅凭进程仍是 \`${comm}\` 无法判断具体原因,可能只是 rc 文件或机器负载让启动变慢。若长时间没有恢复,请打开 Web 终端查看当前提示,处理后等待自动继续,或使用 \`/restart\` 重启会话。`;
6247
+ }
6248
+ const pendingTurn = pendingMessages[0];
6249
+ send({
6250
+ type: 'user_notify',
6251
+ turnId: pendingTurn?.turnId ?? currentBotmuxTurnId,
6252
+ dispatchAttempt: pendingTurn?.dispatchAttempt ?? currentBotmuxDispatchAttempt,
6253
+ message,
6254
+ });
5539
6255
  return true;
5540
6256
  }
5541
6257
  /**
@@ -5556,6 +6272,14 @@ async function flushPending() {
5556
6272
  return; // while loop in active flush will pick up new messages
5557
6273
  if (!backend || !cliAdapter)
5558
6274
  return;
6275
+ if (ambiguousSubmissionRecoveryHold
6276
+ && ambiguousSubmissionRecoveryHold.backend !== backend) {
6277
+ // A restart installed a fresh backend generation. The held item stayed at
6278
+ // the queue head and may now be delivered under its original attempt.
6279
+ ambiguousSubmissionRecoveryHold = null;
6280
+ }
6281
+ if (ambiguousSubmissionRecoveryHold?.backend === backend)
6282
+ return;
5559
6283
  if (pendingMessages.length === 0 && pendingRawInputs.length === 0 && pendingSessionRename === null)
5560
6284
  return; // nothing to flush — keep isPromptReady
5561
6285
  if (sessionRenameInFlight)
@@ -5574,7 +6298,7 @@ async function flushPending() {
5574
6298
  if (shouldDeferUserFlush(pendingInjections))
5575
6299
  return;
5576
6300
  if (bareShellLaunchBlocked)
5577
- return; // launch failed into a bare shell — don't type prompts into it
6301
+ return; // launch is held at a bare shell — don't type prompts into it
5578
6302
  // Screen-idle is not a durable receipt. A permission/AskUser prompt can look
5579
6303
  // idle while the logical delivery is unresolved, so no following IM or
5580
6304
  // meeting turn may cross this boundary until an explicit terminal releases
@@ -5647,6 +6371,11 @@ async function flushPending() {
5647
6371
  if (isPromptReady) {
5648
6372
  isPromptReady = false;
5649
6373
  idleDetector?.reset();
6374
+ // Immediate working for card-off reaction settle (PR #633 P2): the screen
6375
+ // sampler is 2s — without this a short turn can complete before any
6376
+ // working status is observed, leaving idle→idle and GoGoGo uncleared.
6377
+ // force: same rate-limit rewrite trap as argv seed (review third round).
6378
+ publishScreenStatus('working', { force: true });
5650
6379
  }
5651
6380
  try {
5652
6381
  // Launch-failure guard, run ONCE per spawn on the first flush, BEFORE startup
@@ -5699,7 +6428,7 @@ async function flushPending() {
5699
6428
  pendingSessionRename = null;
5700
6429
  sessionRenameInFlight = true;
5701
6430
  try {
5702
- await sendRawCommandLineSerially(backend, buildRename(title));
6431
+ await sendRawCommandLineWithRecoveryFence(backend, buildRename(title));
5703
6432
  armSessionRenameIdleTimeout();
5704
6433
  idleDetector?.reset();
5705
6434
  log(`Native session rename command sent (${cliName()}): ${title}`);
@@ -5722,71 +6451,66 @@ async function flushPending() {
5722
6451
  if (!item)
5723
6452
  break;
5724
6453
  const durableWrite = item.dispatchAttempt !== undefined;
5725
- if (durableWrite)
5726
- durableTurnInFlight = true;
5727
- // Track as in-flight until the CLI returns to idle (markPromptReady).
5728
- // If the CLI exits first, onExit stashes these for re-queue on respawn.
5729
- // EXCEPT in RPC mode: the app-server (not the pane) owns turn execution, so
5730
- // once turn/start is sent the turn runs regardless of the pane. Re-queuing
5731
- // it on a pane restart would RE-SEND an already-accepted turn → double
5732
- // execution (same class as #443's double-submit). So RPC turns are never
5733
- // auto-re-queued: a lost/late ack surfaces as a submit-failure the user can
5734
- // resend manually, and the stable clientUserMessageId lets codex dedupe a
5735
- // resend (Codex delta P1-1).
5736
- if (!codexRpcEngine) {
5737
- inflightInputs.onWrite(item);
5738
- stuckDetector?.arm();
5739
- }
5740
6454
  const msg = item.content;
5741
6455
  const logicalMsg = item.logicalContent ?? msg;
5742
- currentBotmuxTurnId = item.turnId;
5743
- currentBotmuxDispatchAttempt = item.dispatchAttempt;
5744
- currentVcMeetingImTurnOrigin = item.vcMeetingImTurnOrigin;
5745
- writeCliPidMarker();
5746
- publishSandboxRelayCapability();
5747
- const turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
5748
- // Bridge fallback: mark immediately before writeInput. Doing it here
5749
- // (instead of at enqueue time) means markTimeMs anchors to the
5750
- // moment the message actually starts hitting the PTY — so any
5751
- // `botmux send` whose sentAtMs lands during turn N's processing
5752
- // falls inside [markTimeMs(N), markTimeMs(N+1)). Marking earlier
5753
- // (at IPC arrival) would let a slow-finishing turn N's send leak
5754
- // into turn N+1's window and falsely suppress its emit.
6456
+ let turnSeq = usageLimitTracker.currentTurn();
5755
6457
  let bridgeTurnId;
5756
- if (claudeBridgeActive) {
5757
- try {
5758
- bridgeIngest();
6458
+ let normalWritePrepared = false;
6459
+ let submissionPreparationFailed = false;
6460
+ const prepareNormalWrite = () => {
6461
+ if (normalWritePrepared)
6462
+ return;
6463
+ normalWritePrepared = true;
6464
+ renderer?.markNewTurn();
6465
+ currentBotmuxTurnId = item.turnId;
6466
+ currentBotmuxDispatchAttempt = item.dispatchAttempt;
6467
+ currentVcMeetingImTurnOrigin = item.vcMeetingImTurnOrigin;
6468
+ // Acquire durable HOL ownership only after this turn owns the backend
6469
+ // submission mutex. If an older ZMX recovery debt rejects capture,
6470
+ // this input is known not to have started and must not leave a latch
6471
+ // whose attribution still points at the previous turn.
6472
+ if (durableWrite)
6473
+ durableTurnInFlight = true;
6474
+ // Track only after the backend accepts this logical transaction. If an
6475
+ // older ZMX recovery debt rejects capture, this item never became
6476
+ // in-flight and must not participate in restart replay. RPC mode is
6477
+ // excluded: the app-server owns an accepted turn independently of the
6478
+ // viewer pane, so replaying it after a pane restart could run it twice.
6479
+ if (!codexRpcEngine) {
6480
+ inflightInputs.onWrite(item);
6481
+ stuckDetector?.arm();
5759
6482
  }
5760
- catch { /* best-effort */ }
5761
- bridgeTurnId = bridgeMarkPendingTurn(logicalMsg, item.turnId, item.dispatchAttempt);
5762
- }
5763
- else if (codexBridgeActive) {
5764
- // Codex mark works even before the rollout path is known: the
5765
- // queue is path-agnostic, and the late-attach below will start
5766
- // ingest from offset 0 so the user_message that lands shortly
5767
- // after still fingerprint-matches this turn.
5768
- codexBridgeMarkPendingTurn(logicalMsg, item.turnId, item.dispatchAttempt);
5769
- }
5770
- if (durableWrite
5771
- && cliAdapter.reliableTurnTerminal === true
5772
- && lastInitConfig?.cliId === 'claude-code'
5773
- && (!item.turnId || !bridgeTurnId)) {
5774
- // The advertised Claude contract is transcript-backed. If we cannot
5775
- // establish the mark before touching the PTY, fail without submitting;
5776
- // otherwise this real delivery would have no attributable final edge.
5777
- if (item.turnId) {
5778
- emitTurnTerminal(item.turnId, 'failed', 'terminal_bridge_unavailable', item.dispatchAttempt);
6483
+ writeCliPidMarker();
6484
+ publishSandboxRelayCapability();
6485
+ turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
6486
+ // Anchor the bridge baseline only after this turn owns the ZMX
6487
+ // submission lock, immediately before its literal write.
6488
+ if (claudeBridgeActive) {
6489
+ try {
6490
+ bridgeIngest();
6491
+ }
6492
+ catch { /* best-effort */ }
6493
+ bridgeTurnId = bridgeMarkPendingTurn(logicalMsg, item.turnId, item.dispatchAttempt);
5779
6494
  }
5780
- log('Refused durable Claude submit: transcript terminal bridge is unavailable');
5781
- break;
5782
- }
5783
- if (lastInitConfig?.cliId === 'codex-app') {
5784
- log(`Writing Codex App input to PTY (flush): `
5785
- + `replyTurn=${shortCorrelationId(item.turnId)} chars=${msg.length}`);
5786
- }
5787
- else {
5788
- log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
5789
- }
6495
+ else if (codexBridgeActive) {
6496
+ codexBridgeMarkPendingTurn(logicalMsg, item.turnId, item.dispatchAttempt);
6497
+ }
6498
+ if (durableWrite
6499
+ && cliAdapter.reliableTurnTerminal === true
6500
+ && lastInitConfig?.cliId === 'claude-code'
6501
+ && (!item.turnId || !bridgeTurnId)) {
6502
+ submissionPreparationFailed = true;
6503
+ log('Refused durable Claude submit: transcript terminal bridge is unavailable');
6504
+ throw new Error('terminal bridge unavailable before submission');
6505
+ }
6506
+ if (lastInitConfig?.cliId === 'codex-app') {
6507
+ log(`Writing Codex App input to PTY (flush): `
6508
+ + `replyTurn=${shortCorrelationId(item.turnId)} chars=${msg.length}`);
6509
+ }
6510
+ else {
6511
+ log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
6512
+ }
6513
+ };
5790
6514
  // Defense in depth: TmuxPipeBackend's send methods no longer throw on a
5791
6515
  // dead pane (they fire onExit instead), but writeInput can still throw
5792
6516
  // for other reasons (fs errors while resolving the JSONL, a future
@@ -5794,6 +6518,8 @@ async function flushPending() {
5794
6518
  // escaping rejection would become an unhandledRejection and crash the
5795
6519
  // worker — exactly the failure mode this change is closing. Contain it.
5796
6520
  let result;
6521
+ let submissionBackend = null;
6522
+ let recoveryFailureReason;
5797
6523
  try {
5798
6524
  if (codexRpcEngine) {
5799
6525
  // RPC input mode: deliver via JSON-RPC turn/start (its ack IS the
@@ -5801,20 +6527,73 @@ async function flushPending() {
5801
6527
  // renders. No tmux paste → the history.jsonl verify/retry/recover
5802
6528
  // machinery is bypassed. A throw here falls into the catch below and
5803
6529
  // surfaces as a normal submit-failure notice.
5804
- await codexRpcEngine.sendTurn(msg, item.turnId);
6530
+ const targetBackend = backend;
6531
+ await runAfterAmbiguousSubmissionWrites(targetBackend, async () => {
6532
+ prepareNormalWrite();
6533
+ await codexRpcEngine.sendTurn(msg, item.turnId);
6534
+ });
5805
6535
  result = { submitted: true };
5806
6536
  }
5807
6537
  else if (item.codexAppInput && cliAdapter.writeStructuredInput) {
5808
- result = await cliAdapter.writeStructuredInput(backend, msg, item.codexAppInput, { turnId: item.turnId });
6538
+ const targetBackend = backend;
6539
+ const targetAdapter = cliAdapter;
6540
+ submissionBackend = targetBackend;
6541
+ const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeStructuredInput(adapterInputHandle(targetBackend), msg, item.codexAppInput, { turnId: item.turnId }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
6542
+ result = transaction.result;
6543
+ recoveryFailureReason = transaction.recoveryFailureReason;
5809
6544
  }
5810
6545
  else {
5811
- result = await cliAdapter.writeInput(backend, msg, { turnId: item.turnId });
6546
+ const targetBackend = backend;
6547
+ const targetAdapter = cliAdapter;
6548
+ submissionBackend = targetBackend;
6549
+ const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeInput(adapterInputHandle(targetBackend), msg, { turnId: item.turnId }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
6550
+ result = transaction.result;
6551
+ recoveryFailureReason = transaction.recoveryFailureReason;
6552
+ }
6553
+ if (recoveryFailureReason) {
6554
+ result = { ...result, submitted: false };
6555
+ }
6556
+ // Transcript-backed CLIs (Grok/Codex/… reliableTurnTerminal) own idle via
6557
+ // assistant_final → fireIdle. Their busyPattern is often missing for
6558
+ // several seconds after submit (or never matches the current TUI chrome),
6559
+ // so a post-submit "busy marker absent" probe falsely marks prompt ready
6560
+ // and the card-off DONE reaction lands while the turn is still running
6561
+ // (seen live on Grok: GoGoGo → +7s post-submit probe → DONE, then
6562
+ // deferred recheck still saw active PTY output).
6563
+ if (cliAdapter.reliableTurnTerminal !== true) {
6564
+ scheduleBusyPatternIdleProbe(`${cliName()} post-submit`);
5812
6565
  }
5813
- scheduleBusyPatternIdleProbe(`${cliName()} post-submit`);
5814
6566
  }
5815
6567
  catch (err) {
6568
+ recoveryFailureReason = err instanceof SubmissionWriteError
6569
+ ? err.recoveryFailureReason
6570
+ : recoveryFailureReason;
6571
+ const blockedBeforeWrite = err instanceof SubmissionWriteError
6572
+ && !!err.recoveryFailureReason
6573
+ && !err.submissionStarted;
6574
+ if (submissionPreparationFailed) {
6575
+ if (item.turnId) {
6576
+ emitTurnTerminal(item.turnId, 'failed', 'terminal_bridge_unavailable', item.dispatchAttempt);
6577
+ }
6578
+ break;
6579
+ }
5816
6580
  log(`writeInput threw: ${err?.message ?? err}`);
5817
- if (durableWrite && item.turnId) {
6581
+ if (blockedBeforeWrite && submissionBackend) {
6582
+ // This exact item is known not to have touched the PTY. Keep its
6583
+ // original durable attempt (and ordinary IM content) queued, but do
6584
+ // not hammer the same sticky journal or burn receiver retry budget.
6585
+ // A user /restart installs a fresh backend and releases the hold.
6586
+ pendingMessages.unshift(item);
6587
+ ambiguousSubmissionRecoveryHold = {
6588
+ backend: submissionBackend,
6589
+ item,
6590
+ };
6591
+ log('Held definitely-unwritten input until ZMX recovery restart');
6592
+ }
6593
+ else if (recoveryFailureReason) {
6594
+ inflightInputs.retire(item);
6595
+ }
6596
+ if (durableWrite && item.turnId && !recoveryFailureReason) {
5818
6597
  // A throwing backend cannot prove whether bytes reached the CLI.
5819
6598
  // Reconcile as ambiguous (not a definitive failure) so the receiver
5820
6599
  // can replay the same frozen delivery behind the action gate.
@@ -5824,8 +6603,14 @@ async function flushPending() {
5824
6603
  // nulled `backend` and told the user the CLI exited) — nothing more to
5825
6604
  // do. Otherwise surface it as a submit failure so the message isn't
5826
6605
  // silently lost.
5827
- if (backend)
5828
- scheduleSubmitFailureNotify(logicalMsg, undefined, '会话 JSONL', bridgeTurnId, undefined, turnSeq, item);
6606
+ if (backend) {
6607
+ if (recoveryFailureReason) {
6608
+ notifyAmbiguousSubmissionRecovery(recoveryFailureReason, item);
6609
+ }
6610
+ else {
6611
+ scheduleSubmitFailureNotify(logicalMsg, undefined, '会话 JSONL', bridgeTurnId, undefined, turnSeq, item, 'ambiguous');
6612
+ }
6613
+ }
5829
6614
  break;
5830
6615
  }
5831
6616
  if (lastInitConfig?.cliId === 'codex-app'
@@ -5856,7 +6641,16 @@ async function flushPending() {
5856
6641
  // nulled backend) the user already got a "CLI exited" notice; don't also
5857
6642
  // nag that the submit wasn't confirmed.
5858
6643
  if (result && result.submitted === false && backend) {
5859
- scheduleSubmitFailureNotify(logicalMsg, result.recheck, '会话 JSONL', bridgeTurnId, result.failureReason, turnSeq, item);
6644
+ if (recoveryFailureReason) {
6645
+ inflightInputs.retire(item);
6646
+ notifyAmbiguousSubmissionRecovery(recoveryFailureReason, item);
6647
+ // The current item may have landed, so never requeue it. Preserve
6648
+ // every later batch item untouched for an explicit fresh generation.
6649
+ break;
6650
+ }
6651
+ else {
6652
+ scheduleSubmitFailureNotify(logicalMsg, result.recheck, '会话 JSONL', bridgeTurnId, result.failureReason, turnSeq, item, 'failed');
6653
+ }
5860
6654
  }
5861
6655
  // All structured bridges now drain every pending message in one flush:
5862
6656
  // Claude's BridgeTurnQueue handles `attachment(queued_command)` events
@@ -5953,7 +6747,11 @@ function sendToPty(content, turnId, opts = {}) {
5953
6747
  else {
5954
6748
  if (!mergedQueued)
5955
6749
  log(`Queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — ${cliName()} ${awaitingFirstPrompt ? 'still booting' : 'is busy'}`);
5956
- scheduleBusyPatternIdleProbe(`${cliName()} queued-message`);
6750
+ // Same false-idle trap as post-submit (see flushPending): do not let
6751
+ // "busy marker absent" declare ready for transcript-backed CLIs.
6752
+ if (cliAdapter?.reliableTurnTerminal !== true) {
6753
+ scheduleBusyPatternIdleProbe(`${cliName()} queued-message`);
6754
+ }
5957
6755
  }
5958
6756
  return true;
5959
6757
  }
@@ -6025,7 +6823,7 @@ function startScreenUpdates() {
6025
6823
  }
6026
6824
  lastContent = content;
6027
6825
  }
6028
- const usageAware = usageLimitTracker.classify(content, status);
6826
+ const usageAware = classifyScreenUsageLimit(content, status);
6029
6827
  if (changed || usageAware.status !== lastSentStatus) {
6030
6828
  lastSentStatus = usageAware.status;
6031
6829
  send({ type: 'screen_update', content, ...usageAware, turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt });
@@ -6244,7 +7042,12 @@ function seedBackendScreen(source, be) {
6244
7042
  try {
6245
7043
  const initial = be.captureCurrentScreen?.() ?? '';
6246
7044
  if (initial.length > 0) {
6247
- onPtyData(initial);
7045
+ if (be instanceof ZmxBackend) {
7046
+ scheduleBackendScreenResync(initial, source);
7047
+ }
7048
+ else {
7049
+ onPtyData(initial);
7050
+ }
6248
7051
  if (be instanceof HerdrBackend) {
6249
7052
  relayHerdrWebSnapshot(initial);
6250
7053
  }
@@ -6263,6 +7066,10 @@ function busyProbeRegion(content) {
6263
7066
  return lines.slice(-tailLineCount).join('\n');
6264
7067
  }
6265
7068
  function probeBusyPatternIdle(source, be) {
7069
+ if (!backendScreenEvidenceIsAuthoritativeForMutation()) {
7070
+ log(`${source} idle probe skipped: backend screen geometry is not authoritative`);
7071
+ return false;
7072
+ }
6266
7073
  try {
6267
7074
  const content = captureBackendScreen(be);
6268
7075
  if (!content)
@@ -6270,8 +7077,30 @@ function probeBusyPatternIdle(source, be) {
6270
7077
  if (cliAdapter?.busyPattern) {
6271
7078
  if (cliAdapter.busyPattern.test(busyProbeRegion(content)))
6272
7079
  return false;
6273
- log(`${source} idle probe: busy marker absent, marking prompt ready`);
6274
- markPromptReady();
7080
+ if (!be.settleCurrentScreen) {
7081
+ log(`${source} idle probe: busy marker absent, marking prompt ready`);
7082
+ markPromptReady();
7083
+ return true;
7084
+ }
7085
+ // A busy-marker probe reads the backend's cached screen. ZMX refreshes
7086
+ // that cache from `history`, so absence in the current sample is not yet
7087
+ // an authoritative turn boundary. Reuse the same revision-keyed settle
7088
+ // fence as IdleDetector before publishing prompt_ready; otherwise this
7089
+ // fallback can finalize a whole turn from a pre-tail/poll snapshot.
7090
+ const revisionBeforeSettle = backendScreenRevision;
7091
+ log(`${source} idle probe: busy marker absent, settling authoritative screen before prompt ready`);
7092
+ void settleBackendScreenBeforeIdle(be, revisionBeforeSettle).then((settle) => {
7093
+ if (!settle.proceed || backend !== be || isPromptReady)
7094
+ return;
7095
+ if (backendScreenRevision !== revisionBeforeSettle) {
7096
+ log(`${source} idle probe: authoritative screen changed during settle; deferring completion`);
7097
+ return;
7098
+ }
7099
+ if (settle.degraded) {
7100
+ log(`${source} idle probe: screen settle degraded; finalizing from the last successful snapshot`);
7101
+ }
7102
+ markPromptReady();
7103
+ });
6275
7104
  return true;
6276
7105
  }
6277
7106
  }
@@ -6497,16 +7326,26 @@ async function spawnCli(cfg, opts = {}) {
6497
7326
  // backendType trust-but-verify + HARD GATE (PTY 退役): an explicit per-bot
6498
7327
  // config (or BACKEND_TYPE env override) bypasses config.ts's default, so the
6499
7328
  // worker re-probes the requested persistent backend here. A requested
6500
- // tmux/herdr/zellij backend that isn't functional NO LONGER silently
7329
+ // tmux/herdr/zellij/zmx backend that isn't functional NO LONGER silently
6501
7330
  // degrades to raw PTY — that silent fallback was the root of the "secretly
6502
7331
  // running on PTY, then hitting all of PTY's problems" bug class. Instead we
6503
7332
  // refuse to spawn and post an actionable card (user_notify) telling the user
6504
7333
  // to install the backend, or to explicitly opt into PTY with BACKEND_TYPE=pty.
6505
7334
  //
6506
- // Existing botmux sessions stay authoritative over the disposable "can we
6507
- // create a new server?" probe: a live session reattaches regardless of a
6508
- // transient probe failure (PR#249), so it's exempt from the gate.
7335
+ // Existing botmux sessions stay authoritative over a separate capability
7336
+ // probe: a live session reattaches regardless of a transient probe failure
7337
+ // (PR#249), so it is exempt from the gate. tmux/zellij use disposable
7338
+ // sessions; ZMX validates its version plus full-list control plane; Herdr
7339
+ // uses a non-destructive version check.
6509
7340
  let effectiveBackend = cfg.backendType;
7341
+ const backendCliError = backendCliCompatibilityError(effectiveBackend, cfg.cliId);
7342
+ if (backendCliError) {
7343
+ throw new Error(`⚠️ ${cfg.cliId} 当前不能使用 ZMX 后端:其完成事件依赖不可见 OSC 控制消息,` +
7344
+ `而 ZMX 的纯文本 history 不保留该通道。请将 backendType 改为 tmux / pty。\n` +
7345
+ `原因:${backendCliError}`);
7346
+ }
7347
+ let resolvedZmxSessionProbe;
7348
+ let resolvedZmxSessionPid;
6510
7349
  {
6511
7350
  let available = true;
6512
7351
  let reason = '';
@@ -6530,6 +7369,30 @@ async function spawnCli(cfg, opts = {}) {
6530
7369
  reason = 'zellij 功能性探针失败(需 zellij >= 0.44)';
6531
7370
  }
6532
7371
  }
7372
+ else if (effectiveBackend === 'zmx') {
7373
+ // The local controller version is a protocol requirement even when the
7374
+ // backing session already exists: 0.6 has the old send semantics. Only a
7375
+ // transient functional probe may be exempted for a verified live session.
7376
+ const version = probeZmxVersion();
7377
+ if (!version.ok) {
7378
+ available = false;
7379
+ reason = version.reason;
7380
+ resolvedZmxSessionProbe = 'unknown';
7381
+ }
7382
+ else {
7383
+ const resolved = probeOwnedZmxSession(ZmxBackend.sessionName(cfg.sessionId), cfg.sessionId);
7384
+ resolvedZmxSessionProbe = resolved.probe;
7385
+ resolvedZmxSessionPid = resolved.pid;
7386
+ hasExistingSession = resolved.probe === 'exists';
7387
+ if (resolved.probe === 'unknown') {
7388
+ available = false;
7389
+ reason = resolved.reason ?? 'zmx 会话所有权探针结果不确定';
7390
+ // Do not let decideBackendGate's live-session exemption override an
7391
+ // incompatible/unknown ownership result.
7392
+ hasExistingSession = false;
7393
+ }
7394
+ }
7395
+ }
6533
7396
  else if (effectiveBackend === 'herdr') {
6534
7397
  // herdr's isAvailable() is a cheap, non-destructive `herdr --version`
6535
7398
  // (not a disposable session probe), so it has no PR#249 false-negative
@@ -6573,7 +7436,14 @@ async function spawnCli(cfg, opts = {}) {
6573
7436
  BOTMUX_SESSION_ID: cfg.sessionId,
6574
7437
  BOTMUX_CHAT_ID: cfg.chatId,
6575
7438
  BOTMUX_LARK_APP_ID: cfg.larkAppId,
7439
+ BOTMUX_USAGE_DISPLAY: resolveUsageDisplay(cfg.larkAppId),
6576
7440
  };
7441
+ // Core-only capability must survive into the sandboxed CLI: riffModeSession
7442
+ // rebuilds a synthetic BotConfig from env (no bots.json), and would otherwise
7443
+ // drop apiOnly → getBotClient would not throw → `botmux send` could reach
7444
+ // Feishu. Thread the flag so the reconstructed config keeps the boundary.
7445
+ if (cfg.apiOnly)
7446
+ sessionEnv.BOTMUX_API_ONLY = '1';
6577
7447
  // Session scope for `botmux send` inside the sandbox. Thread sessions
6578
7448
  // anchor on a real om_ message (reply_in_thread); chat-scope sessions use
6579
7449
  // the chat id as anchor (sessionAnchorId), which is NOT a message id —
@@ -6613,6 +7483,19 @@ async function spawnCli(cfg, opts = {}) {
6613
7483
  // Per-bot env (bots.json `env`) takes precedence over session context;
6614
7484
  // explicit riff config.env takes precedence over both.
6615
7485
  const mergedEnv = { ...sessionEnv, ...sanitizePerBotEnv(cfg.env), ...cfg.backendConfig.env };
7486
+ // Re-freeze the no-transport capability keys AFTER the merge: a stale or
7487
+ // attacker-shaped backendConfig.env / per-bot env merges LAST and would
7488
+ // otherwise override the frozen values, restoring send capability for a
7489
+ // core-only bot or an HTTP virtual session. The host-owned session context
7490
+ // is authoritative here — these keys cannot be overridden from config.
7491
+ const noTransport = cfg.apiOnly === true
7492
+ || cfg.chatId?.startsWith('http_async_') === true
7493
+ || cfg.chatId?.startsWith('http_wait_') === true;
7494
+ if (noTransport) {
7495
+ delete mergedEnv.BOTMUX_LARK_APP_SECRET;
7496
+ mergedEnv.BOTMUX_API_ONLY = '1';
7497
+ mergedEnv.BOTMUX_CHAT_ID = cfg.chatId; // host-owned; never from config
7498
+ }
6616
7499
  riffBackendConfig = Object.assign({}, cfg.backendConfig, { env: mergedEnv, resumeParentTaskId: cfg.riffParentTaskId });
6617
7500
  // 复用本地仓库+分支:多仓只认会话上的显式 stamp(仓库选择卡多选流按用户
6618
7501
  // 顺序写入 cfg.riffRepoDirs,首仓=primary);否则仅对 workingDir 本身做单仓
@@ -6659,6 +7542,15 @@ async function spawnCli(cfg, opts = {}) {
6659
7542
  }
6660
7543
  const sandboxRequested = !riffRemoteBackend
6661
7544
  && (cfg.sandbox === true || cfg.readIsolation === true || sandboxEnabled());
7545
+ const backendIsolationGate = backendSandboxCompatibilityError({
7546
+ backendType: effectiveBackendType,
7547
+ fileSandboxRequested: sandboxRequested,
7548
+ // The unified sandbox request above already includes legacy readIsolation.
7549
+ effectiveReadIsolationRequested: false,
7550
+ });
7551
+ if (backendIsolationGate) {
7552
+ throw new Error(backendSandboxCompatibilityUserMessage(backendIsolationGate));
7553
+ }
6662
7554
  const fullIsolationCoversCredentials = sandboxRequested;
6663
7555
  let credentialMechanismAvailable = true;
6664
7556
  let credentialMechanismExecutable;
@@ -6702,17 +7594,35 @@ async function spawnCli(cfg, opts = {}) {
6702
7594
  ?? join(defaultBotmuxHome, 'data');
6703
7595
  let mcpRuntimeManifest = readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir);
6704
7596
  const hasMcpRuntimeEntries = !!cliAdapter.mcpGateway && !!mcpRuntimeManifest?.entries.length;
7597
+ const reuseRecordedHerdrTarget = !sandboxRequested && !hasMcpRuntimeEntries;
7598
+ if (effectiveBackend === 'herdr' && !reuseRecordedHerdrTarget) {
7599
+ // Isolation/MCP incarnations move historical shared-host agents to the
7600
+ // data-root-scoped managed target. Retire the exact old pane before backend
7601
+ // selection mutates the durable stamp or a replacement CLI can spawn.
7602
+ retireSupersededRecordedHerdrTarget({
7603
+ sessionId: cfg.sessionId,
7604
+ ownershipScope: isolationRuntimeDataDir,
7605
+ reuseRecordedHerdrTarget,
7606
+ persistentBackendTarget: cfg.persistentBackendTarget,
7607
+ });
7608
+ }
6705
7609
  const selectBackend = () => selectSessionBackend({
6706
7610
  sessionId: cfg.sessionId,
6707
7611
  backendType: effectiveBackend,
6708
7612
  backendConfig: riffBackendConfig,
7613
+ herdrOwnershipScope: isolationRuntimeDataDir,
6709
7614
  persistentBackendTarget: cfg.persistentBackendTarget,
6710
7615
  // Old builds could place managed agents in a user's shared Herdr session.
6711
7616
  // Preserve that recorded target for compatibility unless this incarnation
6712
7617
  // requires an isolation/MCP boundary that only a Botmux-owned session can
6713
7618
  // safely provide. Fresh tasks use distinct agents in one machine-wide host.
6714
- reuseRecordedHerdrTarget: !sandboxRequested
6715
- && !hasMcpRuntimeEntries,
7619
+ reuseRecordedHerdrTarget,
7620
+ // ZMX reattach vs fresh is frozen here from the probe taken above; the
7621
+ // backend refuses to silently turn a fresh launch into an attach.
7622
+ hasExistingSession: effectiveBackend === 'zmx'
7623
+ ? resolvedZmxSessionProbe === 'exists'
7624
+ : undefined,
7625
+ zmxRecoveryStateDir: isolationRuntimeDataDir,
6716
7626
  });
6717
7627
  let selectedBackend = selectBackend();
6718
7628
  isTmuxMode = selectedBackend.isTmuxMode;
@@ -6792,7 +7702,7 @@ async function spawnCli(cfg, opts = {}) {
6792
7702
  }
6793
7703
  }
6794
7704
  // Predict reattach vs fresh BEFORE the resume pre-flight. On a persistent
6795
- // backend (tmux/herdr/zellij) a daemon restart finds the CLI process still
7705
+ // backend (tmux/herdr/zellij/zmx) a daemon restart finds the CLI process still
6796
7706
  // alive in its pane, so the backend will `attach` to the live process and
6797
7707
  // IGNORE the bin/args — there is no spawn, and the live process still holds
6798
7708
  // the full in-memory conversation. In that case the resume-vs-fresh question
@@ -6813,9 +7723,27 @@ async function spawnCli(cfg, opts = {}) {
6813
7723
  // still the isolated process). This lets isolated bots use tmux/zellij/herdr.
6814
7724
  if (appliedIsolationCapabilities.length > 0 && persistentSessionName && effectiveBackendType !== 'pty') {
6815
7725
  const persistentTarget = selectedBackend.persistentBackendTarget;
6816
- const paneLive = persistentTarget
6817
- ? probePersistentBackendTarget(persistentTarget) === 'exists'
6818
- : false;
7726
+ // ZMX ownership is verified against the frozen PID, not just the name — a
7727
+ // same-named session may belong to the user or to a newer generation.
7728
+ const zmxOwnedProbe = effectiveBackendType === 'zmx'
7729
+ ? probeOwnedZmxSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid)
7730
+ : undefined;
7731
+ // ZMX ownership is label/PID-sensitive, so an inconclusive ZMX probe must
7732
+ // fail closed. Other persistent backends retain the upstream semantics:
7733
+ // their target probe returning unknown is not proof that a pane exists.
7734
+ const paneProbe = zmxOwnedProbe?.probe
7735
+ ?? (persistentTarget ? probePersistentBackendTarget(persistentTarget) : 'missing');
7736
+ if (effectiveBackendType === 'zmx'
7737
+ && resolvedZmxSessionProbe !== 'exists'
7738
+ && paneProbe === 'exists') {
7739
+ throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ` +
7740
+ 'ZMX session appeared after the frozen launch probe');
7741
+ }
7742
+ if (effectiveBackendType === 'zmx' && paneProbe === 'unknown') {
7743
+ throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ` +
7744
+ `could not verify existing ${effectiveBackendType} pane`);
7745
+ }
7746
+ const paneLive = paneProbe === 'exists';
6819
7747
  if (paneLive) {
6820
7748
  let marker = null;
6821
7749
  marker = readRegularHostFileNoFollow(join(isolationRuntimeDataDir, 'read-isolation', `${cfg.sessionId}.boot`));
@@ -6829,42 +7757,108 @@ async function spawnCli(cfg, opts = {}) {
6829
7757
  // Missing/legacy marker → pane predates the current policy and may retain
6830
7758
  // obsolete permissions. Kill it before publishing any new capability.
6831
7759
  log(`[read-isolation] legacy/unmarked persistent pane for ${cfg.sessionId} — killing + cold-spawning with current policy`);
7760
+ // Capture the name before re-selection: `persistentSessionName` is
7761
+ // reassigned from the new selection below and widens back to
7762
+ // `string | undefined`, but the backing name we are tearing down is
7763
+ // this one and does not change.
7764
+ const staleSessionName = persistentSessionName;
7765
+ const stalePersistentTarget = selectedBackend.persistentBackendTarget;
6832
7766
  try {
6833
- const persistentTarget = selectedBackend.persistentBackendTarget;
6834
- if (persistentTarget)
6835
- killPersistentBackendTarget(persistentTarget);
6836
- else
6837
- killPersistentSession(effectiveBackendType, persistentSessionName);
6838
- selectedBackend = selectBackend();
6839
- isTmuxMode = selectedBackend.isTmuxMode;
6840
- isPipeMode = selectedBackend.isPipeMode;
6841
- isZellijMode = selectedBackend.isZellijMode;
6842
- backend = selectedBackend.backend;
6843
- cliLifetimeNonce++;
6844
- persistentSessionName = selectedBackend.persistentSessionName;
7767
+ // ZMX keeps its own call here rather than going through the target
7768
+ // helper: only this path holds the frozen PID, which makes the
7769
+ // ownership check stricter than the name+label check.
7770
+ if (effectiveBackendType === 'zmx') {
7771
+ ZmxBackend.killManagedSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid);
7772
+ }
7773
+ else {
7774
+ if (stalePersistentTarget)
7775
+ killPersistentBackendTarget(stalePersistentTarget, cfg.sessionId);
7776
+ else
7777
+ killPersistentSession(effectiveBackendType, persistentSessionName, cfg.sessionId);
7778
+ }
6845
7779
  }
6846
7780
  catch (e) {
6847
7781
  throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: could not kill stale persistent pane (${e.message})`);
6848
7782
  }
7783
+ const postKillProbe = effectiveBackendType === 'zmx'
7784
+ ? probeOwnedZmxSession(staleSessionName, cfg.sessionId).probe
7785
+ : (stalePersistentTarget
7786
+ ? probePersistentBackendTarget(stalePersistentTarget)
7787
+ : probePersistentSession(effectiveBackendType, staleSessionName));
7788
+ if (shouldRejectPersistentPostKillProbe(effectiveBackendType, postKillProbe)) {
7789
+ throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ` +
7790
+ `could not confirm stale ${effectiveBackendType} pane termination`);
7791
+ }
7792
+ if (effectiveBackendType === 'zmx') {
7793
+ resolvedZmxSessionProbe = postKillProbe;
7794
+ resolvedZmxSessionPid = undefined;
7795
+ }
7796
+ // ZMX backend selection consumes the frozen probe. Refresh it before
7797
+ // re-selecting or the replacement keeps isReattach=true for the pane
7798
+ // that this gate just proved was removed.
7799
+ selectedBackend = selectBackend();
7800
+ isTmuxMode = selectedBackend.isTmuxMode;
7801
+ isPipeMode = selectedBackend.isPipeMode;
7802
+ isZellijMode = selectedBackend.isZellijMode;
7803
+ backend = selectedBackend.backend;
7804
+ cliLifetimeNonce++;
7805
+ persistentSessionName = selectedBackend.persistentSessionName;
6849
7806
  }
6850
7807
  }
6851
7808
  }
6852
7809
  let willReattachPersistent = selectedBackend.isReattach === true;
6853
7810
  if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length && persistentSessionName && effectiveBackendType !== 'pty') {
6854
7811
  const persistentTarget = selectedBackend.persistentBackendTarget;
6855
- const paneLive = persistentTarget
6856
- ? probePersistentBackendTarget(persistentTarget) === 'exists'
6857
- : false;
6858
- if (paneLive) {
7812
+ // ZMX ownership is label/PID-sensitive, so only its inconclusive probe
7813
+ // fails closed. Other backends keep the pre-ZMX target-probe semantics.
7814
+ const paneProbe = effectiveBackendType === 'zmx'
7815
+ ? probeOwnedZmxSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid).probe
7816
+ : (persistentTarget ? probePersistentBackendTarget(persistentTarget) : 'missing');
7817
+ if (effectiveBackendType === 'zmx'
7818
+ && resolvedZmxSessionProbe !== 'exists'
7819
+ && paneProbe === 'exists') {
7820
+ throw new Error(`[mcp-gateway] refusing to start session ${cfg.sessionId}: ` +
7821
+ 'ZMX session appeared after the frozen launch probe');
7822
+ }
7823
+ if (effectiveBackendType === 'zmx' && paneProbe === 'unknown') {
7824
+ throw new Error(`[mcp-gateway] refusing to start session ${cfg.sessionId}: ` +
7825
+ `could not verify existing ${effectiveBackendType} pane`);
7826
+ }
7827
+ if (effectiveBackendType === 'zmx') {
7828
+ resolvedZmxSessionProbe = paneProbe;
7829
+ }
7830
+ if (paneProbe === 'exists') {
6859
7831
  // The trusted Gateway host belongs to the worker and cannot survive a
6860
7832
  // worker/daemon replacement. Cold-resume the CLI so its MCP client gets a
6861
7833
  // fresh relay socket instead of reattaching to a dead connection.
6862
7834
  log(`[mcp-gateway] persistent pane ${cfg.sessionId} has plugin MCP state — cold-resuming with a fresh host`);
7835
+ const persistentBackendType = effectiveBackendType;
6863
7836
  const persistentTarget = selectedBackend.persistentBackendTarget;
6864
- if (persistentTarget)
6865
- killPersistentBackendTarget(persistentTarget);
6866
- else
6867
- killPersistentSession(effectiveBackendType, persistentSessionName);
7837
+ if (effectiveBackendType === 'zmx') {
7838
+ ZmxBackend.killManagedSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid);
7839
+ }
7840
+ else if (persistentTarget) {
7841
+ killPersistentBackendTarget(persistentTarget, cfg.sessionId);
7842
+ }
7843
+ else {
7844
+ killPersistentSession(persistentBackendType, persistentSessionName, cfg.sessionId);
7845
+ }
7846
+ // Confirm the stale pane is really gone before re-selecting. Re-selection
7847
+ // below decides reattach-vs-fresh from this probe, so an unconfirmed kill
7848
+ // would let the new backend reattach to the pane we just tried to remove.
7849
+ const postKillProbe = effectiveBackendType === 'zmx'
7850
+ ? probeOwnedZmxSession(persistentSessionName, cfg.sessionId).probe
7851
+ : (persistentTarget
7852
+ ? probePersistentBackendTarget(persistentTarget)
7853
+ : probePersistentSession(persistentBackendType, persistentSessionName));
7854
+ if (shouldRejectPersistentPostKillProbe(persistentBackendType, postKillProbe)) {
7855
+ throw new Error(`[mcp-gateway] refusing to start session ${cfg.sessionId}: ` +
7856
+ `could not confirm stale ${effectiveBackendType} pane termination`);
7857
+ }
7858
+ if (effectiveBackendType === 'zmx') {
7859
+ resolvedZmxSessionProbe = postKillProbe;
7860
+ resolvedZmxSessionPid = undefined;
7861
+ }
6868
7862
  selectedBackend = selectBackend();
6869
7863
  isTmuxMode = selectedBackend.isTmuxMode;
6870
7864
  isPipeMode = selectedBackend.isPipeMode;
@@ -7085,6 +8079,21 @@ async function spawnCli(cfg, opts = {}) {
7085
8079
  preparedInitialPrompt = initialPromptDelivery.argvPrompt;
7086
8080
  lastSpawnQueuedInitialPrompt = initialPromptDelivery.queuedContent;
7087
8081
  lastSpawnQueuedInitialPromptLogicalContent = initialPromptDelivery.logicalContent;
8082
+ // Argv-baked first prompt tracking (PR #633 P2 / second-round review):
8083
+ // - needsWorkingSeed: any argv CLI (Pi/Gemini/MTR/OpenCode/Grok) so card-off
8084
+ // reactions can form working→idle (seeded at first ready or Grok arm).
8085
+ // - busy arm: only Grok-class SessionStart (first ready ≠ turn end).
8086
+ const argvBakedOpts = {
8087
+ passesInitialPromptViaArgs: cliAdapter.passesInitialPromptViaArgs === true,
8088
+ preparedInitialPrompt,
8089
+ queuedInitialPrompt: lastSpawnQueuedInitialPrompt,
8090
+ };
8091
+ spawnArgvNeedsWorkingSeed = shouldTrackArgvBakedFirstPrompt(argvBakedOpts);
8092
+ spawnArgvInitialPromptBusy = shouldArmSpawnArgvInitialPromptBusy({
8093
+ ...argvBakedOpts,
8094
+ injectsReadyHook: cliAdapter.injectsReadyHook === true,
8095
+ reliableTurnTerminal: cliAdapter.reliableTurnTerminal === true,
8096
+ });
7088
8097
  if (deferInitialPrompt && preparedDeferredInput) {
7089
8098
  piInitialPromptAdditionalArgs = [...(preparedDeferredInput.additionalArgs ?? [])];
7090
8099
  piInitialPromptEnv = { ...(preparedDeferredInput.env ?? {}) };
@@ -7101,7 +8110,7 @@ async function spawnCli(cfg, opts = {}) {
7101
8110
  try {
7102
8111
  const credPath = sendCredFilePath(process.env.SESSION_DATA_DIR, cfg.larkAppId);
7103
8112
  mkdirSync(dirname(credPath), { recursive: true });
7104
- writeFileSync(credPath, JSON.stringify({ larkAppId: cfg.larkAppId, larkAppSecret: cfg.larkAppSecret, brand: cfg.brand }), { mode: 0o600 });
8113
+ writeFileSync(credPath, JSON.stringify({ larkAppId: cfg.larkAppId, larkAppSecret: cfg.larkAppSecret, brand: cfg.brand, apiOnly: cfg.apiOnly }), { mode: 0o600 });
7105
8114
  }
7106
8115
  catch (e) {
7107
8116
  log(`[sandbox] WARN could not write send-cred file: ${e.message}`);
@@ -7132,9 +8141,21 @@ async function spawnCli(cfg, opts = {}) {
7132
8141
  larkAppId: cfg.larkAppId,
7133
8142
  locale: cfg.locale,
7134
8143
  model: ttadkGateway ? undefined : cfg.model,
8144
+ reasoningEffort: cfg.reasoningEffort,
7135
8145
  disableCliBypass: cfg.disableCliBypass === true,
8146
+ // Codex-family hook-trust bypass: global toggle (default ON) so a headless
8147
+ // plain-TUI launch doesn't wedge on codex 0.14x's "Press t to trust" gate.
8148
+ // The adapter further ANDs this with !disableCliBypass. Read live per spawn.
8149
+ bypassHookTrust: config.bypassCodexHookTrust,
7136
8150
  skillPluginDir: cfg.skillPluginDir,
7137
8151
  readIsolation: willRedirectCliData,
8152
+ // Hybrid Codex RPC input: when engageCodexRpc (which runs BEFORE this spawn)
8153
+ // bound the pane to a botmux-owned app-server thread, these make codex.ts emit
8154
+ // `codex --remote <ws> resume <thread>` (a pure viewer — no bypass flags) instead
8155
+ // of a plain TUI. Dropped by the a0fa71010 sandbox refactor; restored here so the
8156
+ // RPC viewer branch actually triggers and never carries the bypass flags.
8157
+ remoteWsUrl,
8158
+ remoteThreadId,
7138
8159
  });
7139
8160
  // Pi's deferred long-first-prompt command is implemented by a session-scoped
7140
8161
  // extension. Keep its launch args across owned process restarts while the
@@ -7221,7 +8242,7 @@ async function spawnCli(cfg, opts = {}) {
7221
8242
  // build can't read bots.json (Seatbelt-denied) → `botmux send` fails "Bot not registered".
7222
8243
  // (The tmux backend re-prepends this in its pane script after rcfile load; this covers the
7223
8244
  // pty/direct-spawn path, whose child inherits childEnv.PATH directly.)
7224
- childEnv.PATH = `${join(homedir(), '.botmux', 'bin')}:${childEnv.PATH ?? ''}`;
8245
+ childEnv.PATH = prependBotmuxBin(resolveBotmuxWrapperBinDir(process.env), childEnv.PATH);
7225
8246
  // §5 of botmux ask v0.1.7 — `botmux ask buttons` reads these to find the
7226
8247
  // daemon socket, route the card back to this thread, and resolve the
7227
8248
  // approver allowlist against session.owner. Missing env → exit 2.
@@ -7245,6 +8266,7 @@ async function spawnCli(cfg, opts = {}) {
7245
8266
  if (typeof bl === 'string')
7246
8267
  childEnv.BOTMUX_BRAND_LABEL = bl;
7247
8268
  }
8269
+ childEnv.BOTMUX_USAGE_DISPLAY = resolveUsageDisplay(cfg.larkAppId);
7248
8270
  // NOTE: under read isolation `botmux send` gets this bot's secret from the worker-
7249
8271
  // written cred FILE in its BOT_HOME (send-cred.json, see sendCredFilePath) located
7250
8272
  // via the BOTMUX_LARK_APP_ID above — NOT from the env. The secret is deliberately kept OUT
@@ -7549,7 +8571,38 @@ async function spawnCli(cfg, opts = {}) {
7549
8571
  mandatoryReadOnlyPaths.push(canonical(sessionMcpGatewayHost.socketDir));
7550
8572
  }
7551
8573
  }
7552
- const policy = buildFsPolicy({
8574
+ // No-Lark-transport turn (apiOnly bot OR HTTP virtual chat) has no Feishu
8575
+ // sender identity and no business with the role system — it gets NO role
8576
+ // library grant, and we skip both the resolve and the diagnostic below.
8577
+ // (buildFsPolicy independently re-gates roleLibrarySubtree on larkTransport;
8578
+ // this mirror keeps the worker from resolving/diagnosing a subtree the policy
8579
+ // will discard.)
8580
+ const larkTransportEnabled = !(cfg.apiOnly === true
8581
+ || cfg.chatId?.startsWith('http_async_') === true
8582
+ || cfg.chatId?.startsWith('http_wait_') === true);
8583
+ // Own role-library subtree, plus the ONE diagnosable failure mode of keying it
8584
+ // on appId: a deployment that named the per-bot dir something else (the layout
8585
+ // pre-2026-07 runbooks used) gets no rule, and "the role system EPERMs" is
8586
+ // indistinguishable from "sandbox working as intended". Say so out loud instead
8587
+ // — the session still runs, only role switching/creation is unavailable.
8588
+ const roleLibSubtree = larkTransportEnabled
8589
+ ? (roleLibrarySubtree(cfg.larkAppId) ?? undefined)
8590
+ : undefined;
8591
+ if (larkTransportEnabled && !roleLibSubtree) {
8592
+ try {
8593
+ // 两种形态都比:配置路径表面在库内、但经中间 symlink 解析到库外时,
8594
+ // 只比 canonical 会漏报(而这恰恰也是 roleLibrarySubtree 返回空的场景)。
8595
+ const lexicalRoot = roleLibraryRoot(), rolesRoot = canonical(lexicalRoot);
8596
+ const under = (root, p) => p === root || p.startsWith(`${root}/`);
8597
+ if (under(rolesRoot, canonical(cfg.workingDir)) || under(lexicalRoot, cfg.workingDir)) {
8598
+ log(`[sandbox] role library dir mismatch: workingDir is under ${rolesRoot} but ${rolesRoot}/${cfg.larkAppId} `
8599
+ + 'is not a real directory — the role system (list/switch/create roles, post-switch knowledge writes) will '
8600
+ + 'EPERM in this sandboxed session. Rename the per-bot dir to the appId; see docs/roles/deploy-runbook.md.');
8601
+ }
8602
+ }
8603
+ catch { /* diagnostics only — never block the spawn */ }
8604
+ }
8605
+ const fsPolicyCtx = {
7553
8606
  platform: process.platform,
7554
8607
  homeDir: sandboxHome,
7555
8608
  botmuxHome: canonical(dirname(dataDir)),
@@ -7558,6 +8611,32 @@ async function spawnCli(cfg, opts = {}) {
7558
8611
  currentAppId: cfg.larkAppId,
7559
8612
  sessionId: cfg.sessionId,
7560
8613
  botHome: canonical(ownBotHome),
8614
+ // Resolved above (gated on larkTransportEnabled). roleLibrarySubtree() does
8615
+ // the existence + canonicalization + "must be a real dir, not a symlink"
8616
+ // checks itself (deliberately NOT via keepExisting: its realpath would follow
8617
+ // a planted link and hand rw to the target). A library created after spawn
8618
+ // only takes effect for the next session — bwrap cannot bind a nonexistent
8619
+ // source anyway.
8620
+ roleLibrarySubtree: roleLibSubtree,
8621
+ // No-Lark-transport credential profile: apiOnly bot OR HTTP virtual chat
8622
+ // (computed above as larkTransportEnabled). buildFsPolicy suppresses every
8623
+ // Feishu-cred grant + hard-denies bots.json/lark-cli stores so a workingDir=~
8624
+ // grant can't re-expose them, and independently withholds the role-library grant.
8625
+ larkTransportEnabled,
8626
+ // ALWAYS freeze BOTH botmux authority roots for a no-transport turn: the
8627
+ // configured one (`botmuxHome` above = dirname(dataDir)) AND the canonical
8628
+ // default `~/.botmux`. A custom SESSION_DATA_DIR moves the data dir, but the
8629
+ // default root still holds the live `.dashboard-secret` HMAC + bots.json —
8630
+ // denying only one leaves the sibling-daemon escalation open (codex P1).
8631
+ // Deduped inside buildFsPolicy when the two are equal (default layout).
8632
+ defaultBotmuxHome: canonical(defaultBotmuxHome),
8633
+ // The ACTUAL loaded bots-config path, frozen by the daemon
8634
+ // (getLoadedConfigPath), NOT guessed from BOTS_CONFIG env here. Inside a
8635
+ // frozen root → the parent mask already covers it; OUTSIDE every root →
8636
+ // buildFsPolicy THROWS (fail-closed) rather than silently masking an
8637
+ // arbitrary parent dir (`/tmp`, `/etc`, a project root) and bricking the
8638
+ // core CLI (codex P1). Canonicalized so it shares the roots' namespace.
8639
+ loadedBotsConfigPath: cfg.loadedBotsConfigPath ? canonical(cfg.loadedBotsConfigPath) : undefined,
7561
8640
  redirectedCliData: willRedirectCliData,
7562
8641
  cliDataPaths: willRedirectCliData ? undefined : keepExisting([
7563
8642
  cliAdapter.claudeDataDir,
@@ -7615,7 +8694,30 @@ async function spawnCli(cfg, opts = {}) {
7615
8694
  writeRegexes: process.platform === 'darwin' && !willRedirectCliData && claudeDataDir
7616
8695
  ? [`^${sandboxHome.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/\\.claude\\.json\\.tmp\\.[^/]+$`]
7617
8696
  : [],
7618
- });
8697
+ };
8698
+ const policy = (() => {
8699
+ try {
8700
+ return buildFsPolicy(fsPolicyCtx);
8701
+ }
8702
+ catch (err) {
8703
+ // A no-transport layout that cannot be safely confined (external
8704
+ // bots-config, or workingDir that IS a Feishu-authority root) fails the
8705
+ // spawn CLOSED with a diagnostic — never a silent parent-dir mask that
8706
+ // would hide /tmp, /etc, or a project root and brick the core CLI (codex P1).
8707
+ if (err instanceof FsPolicyConfigError) {
8708
+ const msg = `[file-sandbox] refusing to start no-transport session ${cfg.sessionId}: ${err.message}`;
8709
+ log(msg);
8710
+ throw new Error(msg);
8711
+ }
8712
+ throw err;
8713
+ }
8714
+ })();
8715
+ // A no-transport turn drops caller allow paths (extraWrite / readonlyRoots /
8716
+ // user RW+RO) that fell inside a Feishu-authority root. Log the suppression so
8717
+ // it's diagnosable rather than a silent hole (codex).
8718
+ if (policy.suppressedAuthorityPaths?.length) {
8719
+ log(`[file-sandbox] no-transport suppressed ${policy.suppressedAuthorityPaths.length} allow path(s) inside a Feishu-authority root: ${policy.suppressedAuthorityPaths.join(', ')}`);
8720
+ }
7619
8721
  // Existence-filter only the ALLOW rules (baseline entries are candidates,
7620
8722
  // not guarantees): a non-existent allow path has nothing to expose, and
7621
8723
  // bwrap cannot bind a non-existent SOURCE. DENY rules are kept regardless —
@@ -8184,7 +9286,7 @@ async function spawnCli(cfg, opts = {}) {
8184
9286
  // we hold the first prompt so a cjadk-style startup selector's ❯ can't eat it.
8185
9287
  // shouldArmReadyGate() excludes adopt (pre-existing pane, no fresh hook) AND
8186
9288
  // persistent-backend reattach (daemon restart re-attaches an already-running
8187
- // tmux/zellij/herdr Claude WITHOUT re-running its bin/args → no new
9289
+ // tmux/zellij/herdr/zmx Claude WITHOUT re-running its bin/args → no new
8188
9290
  // SessionStart hook → arming would hold the first post-recovery message until
8189
9291
  // the timeout).
8190
9292
  //
@@ -8252,6 +9354,7 @@ async function spawnCli(cfg, opts = {}) {
8252
9354
  // markPromptReady(): that call may synchronously flush a type-ahead turn and
8253
9355
  // advance bridge attribution. Both screen-idle and authoritative Herdr status
8254
9356
  // must preserve this ordering.
9357
+ const observedBackend = backend;
8255
9358
  const drainBridgesThenMarkReady = (evidenceSource) => {
8256
9359
  if (bridgeJsonlPath) {
8257
9360
  try {
@@ -8270,7 +9373,7 @@ async function spawnCli(cfg, opts = {}) {
8270
9373
  }
8271
9374
  }
8272
9375
  if (evidenceSource === 'screen')
8273
- markPromptReadyFromPty();
9376
+ markPromptReadyFromPty(observedBackend);
8274
9377
  else
8275
9378
  markPromptReady();
8276
9379
  };
@@ -8280,13 +9383,32 @@ async function spawnCli(cfg, opts = {}) {
8280
9383
  // quiescence, repeatedly triggering markPromptReady() and duplicate cards.
8281
9384
  if (effectiveBackendType !== 'riff') {
8282
9385
  idleDetector = new IdleDetector(cliAdapter);
8283
- idleDetector.onIdle((evidenceSource) => {
9386
+ idleDetector.onIdle(async (evidenceSource) => {
8284
9387
  log('Prompt detected (idle)');
9388
+ // Snapshot-only backends (ZMX) must complete one authoritative refresh
9389
+ // before a turn is declared idle, or a final burst lands after finalize.
9390
+ const idleBackend = backend;
9391
+ const revisionBeforeSettle = backendScreenRevision;
9392
+ if (idleBackend?.settleCurrentScreen) {
9393
+ const settle = await settleBackendScreenBeforeIdle(idleBackend, revisionBeforeSettle);
9394
+ if (!settle.proceed || backend !== idleBackend)
9395
+ return;
9396
+ if (backendScreenRevision !== revisionBeforeSettle) {
9397
+ log('Authoritative screen changed during idle settle; deferring completion to the re-armed detector');
9398
+ return;
9399
+ }
9400
+ if (settle.degraded) {
9401
+ log('Screen settle barrier degraded after bounded retries; finalizing from the last successful snapshot');
9402
+ }
9403
+ }
8285
9404
  drainBridgesThenMarkReady(evidenceSource);
8286
9405
  });
8287
9406
  }
8288
- backend.onData(onPtyData);
8289
- const observedBackend = backend;
9407
+ observedBackend.onData((data) => {
9408
+ if (backend !== observedBackend)
9409
+ return;
9410
+ onPtyData(data);
9411
+ });
8290
9412
  if (observedBackend instanceof HerdrBackend) {
8291
9413
  observedBackend.onAgentStatus((status) => {
8292
9414
  if (backend !== observedBackend)
@@ -8301,6 +9423,12 @@ async function spawnCli(cfg, opts = {}) {
8301
9423
  }
8302
9424
  });
8303
9425
  }
9426
+ backend.onScreenResync?.((snapshot) => {
9427
+ if (observedBackend !== backend)
9428
+ return;
9429
+ log(`${effectiveBackendType} observer recovered — rebasing screen state from history`);
9430
+ scheduleBackendScreenResync(snapshot, `${effectiveBackendType} observer recovery`);
9431
+ });
8304
9432
  backend.onAccessUrl?.((url) => {
8305
9433
  send({
8306
9434
  type: 'riff_access_url',
@@ -8347,6 +9475,19 @@ async function spawnCli(cfg, opts = {}) {
8347
9475
  log(`Ignored stale backend exit (code: ${code}, signal: ${signal})`);
8348
9476
  return;
8349
9477
  }
9478
+ const recoveryHeld = ambiguousSubmissionRecoveryHold;
9479
+ const handedOffDurable = handoffQueuedDurableInputsOnBackendExit(pendingMessages, { intentionalRestart });
9480
+ if (handedOffDurable.length > 0) {
9481
+ // onCliExit reconciles every durable receipt owned by this worker
9482
+ // generation to ambiguous, including items still queued behind an
9483
+ // ordinary turn. Drop all of their definitely-unwritten local copies so
9484
+ // a fresh backend cannot execute attempt N while the hub dispatches N+1.
9485
+ // Ordinary IM inputs have no external replay owner and remain queued.
9486
+ if (recoveryHeld && handedOffDurable.includes(recoveryHeld.item)) {
9487
+ ambiguousSubmissionRecoveryHold = null;
9488
+ }
9489
+ log(`Handed ${handedOffDurable.length} queued durable input(s) to daemon replay after CLI exit`);
9490
+ }
8350
9491
  stopSessionMcpGatewayHost();
8351
9492
  const exitedTurnId = currentBotmuxTurnId;
8352
9493
  const exitedDispatchAttempt = currentBotmuxDispatchAttempt;
@@ -8555,7 +9696,15 @@ function killCli(opts = {}) {
8555
9696
  submittedCodexAppReplyTurnIds.clear();
8556
9697
  pendingCodexAppSteerAckIds.clear();
8557
9698
  acknowledgedCodexAppSteers.clear();
8558
- if (sandboxCleanup) {
9699
+ if (opts.preserveSandbox) {
9700
+ // The daemon waits for this worker's detach ACK and exit before forking the
9701
+ // replacement. Drop this worker's cleanup reference and disarm its process-
9702
+ // exit hook so it cannot delete the same-session sandbox tree that the
9703
+ // replacement will reuse.
9704
+ sandboxCleanup = null;
9705
+ sandboxTeardownDone = true;
9706
+ }
9707
+ else if (sandboxCleanup) {
8559
9708
  try {
8560
9709
  sandboxCleanup();
8561
9710
  }
@@ -8582,6 +9731,7 @@ function killCli(opts = {}) {
8582
9731
  altBufferActive = false;
8583
9732
  trustHandled = false;
8584
9733
  codexUpdateDialogGuard.reset();
9734
+ disarmEffortConfirm();
8585
9735
  appRunnerControlDecoder.reset();
8586
9736
  }
8587
9737
  async function restartCliProcess(reason, opts = {}) {
@@ -9321,6 +10471,106 @@ function _showReadonlyToast(){
9321
10471
  }
9322
10472
  document.getElementById('terminal').addEventListener('contextmenu',function(e){e.preventDefault()});
9323
10473
 
10474
+ // ── iOS third-party IME fix (keyCode=229 + composed insertText dead-path) ──
10475
+ // Some iOS third-party keyboards (Doubao/豆包 for every char, WeChat/微信 for
10476
+ // spaces & trailing single chars) deliver text as: keydown(keyCode=229) →
10477
+ // input(inputType=insertText, composed=true) with NO composition events. xterm
10478
+ // 5.x drops these:
10479
+ // • _inputEvent bails because its guard is (!ev.composed || !_keyDownSeen) —
10480
+ // here composed=true AND a 229 keydown set _keyDownSeen, so both are false.
10481
+ // • the only fallback (CompositionHelper._handleAnyTextareaChanges) uses a
10482
+ // setTimeout + String.replace(oldValue,'') diff that miscomputes under fast
10483
+ // consecutive input, so characters are silently lost.
10484
+ //
10485
+ // Voice-input correction (Doubao 语音) exposes a SECOND bug: one physical
10486
+ // Backspace fires MANY deleteContentBackward events in the textarea (it wants to
10487
+ // delete a whole run), but xterm emits only ONE \x7f per Backspace keydown — so
10488
+ // the terminal deletes 1 char while the textarea deleted N, leaving N-1 stale
10489
+ // chars that reappear when Doubao then re-inserts the corrected sentence
10490
+ // (the "everything repeats" symptom). We must forward EACH delete event.
10491
+ //
10492
+ // Diagnosed from real-device iOS event traces. Upstream: xterm.js #5835 / #5836.
10493
+ //
10494
+ // Strategy — take over ONLY these dead/lossy paths, nothing else:
10495
+ // 1. attachCustomKeyEventHandler returns false to CLAIM a key so xterm skips
10496
+ // it (no broken 229 fallback; no single-\x7f Backspace) — this is also what
10497
+ // prevents double-emit. We claim: keydown 229 (not composing), AND
10498
+ // Backspace ONLY WHEN the textarea is non-empty (an IME edit is pending).
10499
+ // An empty textarea means a normal terminal Backspace (delete the CLI line)
10500
+ // → we DON'T claim it, so xterm sends its standard \x7f. This is critical:
10501
+ // claiming an empty-textarea Backspace would swallow it (no input event).
10502
+ // 2. our textarea 'input' listener forwards each event: insertText → the data,
10503
+ // each delete* → one \x7f, so N textarea deletes become N terminal deletes.
10504
+ // 3. composition input (WeChat Chinese) is untouched — it already works — so
10505
+ // we never interfere while _composing is true.
10506
+ // term.input(data,true) routes through onData like real typing (NOT paste — it
10507
+ // must not bracketed-paste-wrap per-char input nor clear the textarea), so the
10508
+ // readonly gating in onData still applies.
10509
+ if(hasToken && !/[?&]imefix=0\\b/.test(location.search)){(function(){
10510
+ var _claim=false; // current key is claimed (229 dead-path or IME Backspace)
10511
+ var _composing=false; // a real composition (compositionstart..end) is active
10512
+ var _ta=term.textarea;
10513
+ try{
10514
+ term.attachCustomKeyEventHandler(function(e){
10515
+ if(e.type==='keydown'){
10516
+ // Reset at the start of every keydown: iOS IME synthetic keys often fire
10517
+ // NO keyup, so _claim could otherwise stay stuck on from a prior cycle.
10518
+ // Clearing here (before deciding to claim) guarantees a fresh state each
10519
+ // physical key, closing the "stuck-open → double-emit" window.
10520
+ _claim=false;
10521
+ if(_composing) return true;
10522
+ if(e.keyCode===229){ _claim=true; return false; }
10523
+ // Backspace: claim ONLY when an IME edit is pending (textarea non-empty),
10524
+ // so one physical backspace's N delete events all reach the terminal.
10525
+ // Empty textarea → normal terminal backspace, let xterm send its \x7f.
10526
+ if(e.keyCode===8 && _ta && _ta.value.length>0){ _claim=true; return false; }
10527
+ }
10528
+ return true;
10529
+ });
10530
+ }catch(_e){}
10531
+ if(_ta){
10532
+ // compositionstart/end bound the reliable path; never take over inside it.
10533
+ _ta.addEventListener('compositionstart',function(){_composing=true;_claim=false;},{capture:true,passive:true});
10534
+ _ta.addEventListener('compositionend',function(){_composing=false;_claim=false;},{capture:true,passive:true});
10535
+ // Blur also closes the cycle — another guard against a stuck-open _claim when
10536
+ // the matching keyup never arrives (the textarea can lose focus instead).
10537
+ _ta.addEventListener('blur',function(){_claim=false;},{capture:true,passive:true});
10538
+ // A single claimed keydown can fire MULTIPLE input events, e.g. the
10539
+ // consecutive-space→。 conversion (delete THEN insertText) or a voice-input
10540
+ // correction (many deletes). So DON'T clear _claim per-input — forward every
10541
+ // input in this cycle and only close the cycle on keyup / compositionstart.
10542
+ _ta.addEventListener('input',function(e){
10543
+ if(!_claim||_composing)return; // only inside a claimed cycle
10544
+ var it=e.inputType||'';
10545
+ if(it.indexOf('delete')===0){
10546
+ // Each textarea delete → one terminal backspace, so N deletes (whole-run
10547
+ // erase) map 1:1 instead of collapsing to a single \x7f.
10548
+ try{term.input('\\x7f',true)}catch(_e){}
10549
+ return;
10550
+ }
10551
+ // Forward ONLY inputType==='insertText' — the exact dead path the target
10552
+ // Doubao/WeChat traces take (keydown 229 → insertText, composed=true, no
10553
+ // composition events). This is a strict WHITELIST, not a composed filter:
10554
+ // • e.composed is a shadow-DOM-crossing flag, NOT an "is-IME" marker —
10555
+ // EVERY trusted InputEvent (paste, replacement, drop…) is composed=true.
10556
+ // So gating on composed alone still swallows non-insertText paths.
10557
+ // • insertFromPaste: xterm's own paste handler already sent the text
10558
+ // (stopPropagation but NOT preventDefault), then the default paste
10559
+ // inserts into the textarea and fires insertFromPaste — forwarding it
10560
+ // here too would DOUBLE the paste.
10561
+ // • insertReplacementText: WebKit correction runs ㅎ→하→한 as a REPLACE
10562
+ // stream; appending each token yields "ㅎ하한" instead of "한".
10563
+ // insertText remains gated on composed too: a composed=false insertText is
10564
+ // one xterm's _inputEvent WILL emit itself (its guard passes when
10565
+ // !composed), so — should _claim ever be stuck open — forwarding it here
10566
+ // would double-emit. Whitelist + composed = mutually exclusive with xterm.
10567
+ if(e.data&&e.composed&&it==='insertText'){try{term.input(e.data,true)}catch(_e){}}
10568
+ },{capture:true,passive:true});
10569
+ // Close the cycle on keyup (the claimed keydown's matching keyup).
10570
+ _ta.addEventListener('keyup',function(){_claim=false;},{capture:true,passive:true});
10571
+ }
10572
+ })();}
10573
+
9324
10574
  // ── WebSocket ──
9325
10575
  var ws_=null,el=document.getElementById('status');
9326
10576
  term.onData(function(d){
@@ -9890,6 +11140,10 @@ function workerIpcPayload(msg) {
9890
11140
  : msg;
9891
11141
  }
9892
11142
  function send(msg) {
11143
+ if (closeRequested && msg.type === 'final_output') {
11144
+ log('Dropped final_output after close fence');
11145
+ return;
11146
+ }
9893
11147
  const payload = workerIpcPayload(msg);
9894
11148
  if (isWorkflowWorker() && payload.type === 'final_output') {
9895
11149
  workflowFinalOutputSent = true;
@@ -9929,6 +11183,27 @@ function sendAndFlush(msg) {
9929
11183
  }
9930
11184
  });
9931
11185
  }
11186
+ const TRANSFER_DETACH_ACK_FLUSH_MS = 250;
11187
+ /** Best-effort terminal ACK for transfer. Backend detach has already happened,
11188
+ * so a wedged process.send callback must not keep this old worker alive and
11189
+ * strand the daemon behind its detach fence indefinitely.
11190
+ */
11191
+ async function flushTransferDetachAck(requestId) {
11192
+ let timeout;
11193
+ try {
11194
+ await Promise.race([
11195
+ sendAndFlush({ type: 'transfer_detached', requestId }),
11196
+ new Promise((resolve) => {
11197
+ timeout = setTimeout(resolve, TRANSFER_DETACH_ACK_FLUSH_MS);
11198
+ timeout.unref?.();
11199
+ }),
11200
+ ]);
11201
+ }
11202
+ finally {
11203
+ if (timeout)
11204
+ clearTimeout(timeout);
11205
+ }
11206
+ }
9932
11207
  let fatalWorkerErrorPending = false;
9933
11208
  /** Surface a fatal (re)launch failure before terminating the worker. This is
9934
11209
  * used both during init and after a previously-ready worker tries to recover
@@ -9993,6 +11268,16 @@ process.on('message', async (raw) => {
9993
11268
  // Capture credentials for direct image upload from worker
9994
11269
  larkAppIdForUpload = msg.larkAppId;
9995
11270
  larkAppSecretForUpload = msg.larkAppSecret;
11271
+ // Core-only (apiOnly) bots have no Feishu transport — the worker uploads
11272
+ // screenshots via its OWN client (utils/lark-upload), bypassing the daemon's
11273
+ // bot-level assertLarkTransport gate, so the capability must ride the init
11274
+ // message into this process and hard-disable upload here. Also disable for
11275
+ // a NORMAL bot whose turn runs in an HTTP virtual session (chatId is
11276
+ // http_async_*/http_wait_*): it has real creds so apiOnly is false, but the
11277
+ // synthetic chat has no card to attach a screenshot to.
11278
+ apiOnlyForUpload = msg.apiOnly === true
11279
+ || msg.chatId?.startsWith('http_async_') === true
11280
+ || msg.chatId?.startsWith('http_wait_') === true;
9996
11281
  // brand 决定截图上传打哪个域(feishu / larksuite)。缺省 feishu。
9997
11282
  larkBrandForUpload = msg.brand === 'lark' ? 'lark' : 'feishu';
9998
11283
  // Resolve render dimensions BEFORE startScreenUpdates() — the
@@ -10000,7 +11285,11 @@ process.on('message', async (raw) => {
10000
11285
  // up-front. Setting them later (after the renderer was built at
10001
11286
  // 160x50) wouldn't unwrap content xterm has already buffered, so
10002
11287
  // adopt-mode wide-pane content would still come out stair-stepped.
10003
- const dims = resolveRenderDimensions(msg);
11288
+ const requestedBackendType = msg.backendType ?? config.daemon.backendType;
11289
+ const dims = resolveRenderDimensions({
11290
+ ...msg,
11291
+ backendType: requestedBackendType,
11292
+ });
10004
11293
  renderCols = dims.cols;
10005
11294
  renderRows = dims.rows;
10006
11295
  log(`Init: session=${sessionId}, cwd=${msg.workingDir}, render=${renderCols}x${renderRows}${msg.adoptMode ? ' (adopt-pane)' : ''}`);
@@ -10013,18 +11302,28 @@ process.on('message', async (raw) => {
10013
11302
  publishSandboxRelayCapability();
10014
11303
  }
10015
11304
  let port = 0;
11305
+ const webTerminalEnabled = backendSupportsWebTerminal(requestedBackendType);
10016
11306
  if (!isWorkflowWorker()) {
10017
- port = await startWebServer(config.web.workerHost, msg.webPort);
11307
+ if (webTerminalEnabled) {
11308
+ port = await startWebServer(config.web.workerHost, msg.webPort);
11309
+ }
11310
+ else {
11311
+ log(`Web terminal disabled for ${requestedBackendType} backend (no raw ANSI transport)`);
11312
+ }
10018
11313
  startScreenUpdates();
10019
11314
  startStuckDetector();
10020
11315
  }
10021
11316
  else {
10022
- // Workflow attempts still expose a read-only web terminal so the
10023
- // workflow dashboard can observe in-flight subagents. Keep the
10024
- // chat-side features disabled: no screen cards, no analyzer, no
10025
- // sessionStore writes.
10026
- port = await startWebServer(config.web.workerHost, msg.webPort);
10027
- log('Workflow worker mode: web terminal enabled; skipping screen updates and screen analyzer');
11317
+ // Workflow attempts expose a read-only web terminal only when the
11318
+ // backend has a raw terminal stream. Keep chat-side features
11319
+ // disabled: no screen cards, no analyzer, no sessionStore writes.
11320
+ if (webTerminalEnabled) {
11321
+ port = await startWebServer(config.web.workerHost, msg.webPort);
11322
+ log('Workflow worker mode: web terminal enabled; skipping screen updates and screen analyzer');
11323
+ }
11324
+ else {
11325
+ log(`Workflow worker mode: web terminal disabled for ${requestedBackendType} backend`);
11326
+ }
10028
11327
  }
10029
11328
  // Hybrid codex RPC input (opt-in): bind the pane to a botmux-owned
10030
11329
  // app-server thread; input flows via JSON-RPC instead of a drop-prone
@@ -10047,7 +11346,7 @@ process.on('message', async (raw) => {
10047
11346
  rpcPluginGenerationPrepared = true;
10048
11347
  },
10049
11348
  engage: () => engageCodexRpc(msg),
10050
- killVerify: (name) => killPersistentSessionVerified(rpcBackendType, name),
11349
+ killVerify: (name) => killPersistentSessionVerified(rpcBackendType, name, msg.sessionId),
10051
11350
  teardownEngine: () => stopCodexRpcEngine(),
10052
11351
  log: (m) => log(m),
10053
11352
  notify: (m) => send({ type: 'user_notify', message: m, turnId: msg.turnId }),
@@ -10149,9 +11448,11 @@ process.on('message', async (raw) => {
10149
11448
  break;
10150
11449
  }
10151
11450
  case 'message': {
10152
- // Mark new turn baseline so the streaming card only shows this turn's content
10153
- renderer?.markNewTurn();
10154
- const turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
11451
+ const messageAdoptMode = lastInitConfig?.adoptMode === true;
11452
+ // Adopt IPC handlers can overlap. Delay their turn baseline until the
11453
+ // submission mutex is held so a queued message cannot steal attribution
11454
+ // from the write/verification already in flight.
11455
+ let turnSeq = usageLimitTracker.currentTurn();
10155
11456
  if (msg.nativeSessionTitlePrompt
10156
11457
  && msg.nativeSessionTitle
10157
11458
  && lastInitConfig?.cliId === 'codex'
@@ -10168,7 +11469,7 @@ process.on('message', async (raw) => {
10168
11469
  void syncFreshCodexNativeSessionTitle(threadId, codexRpcEngine);
10169
11470
  }
10170
11471
  // Cancel any active tmux copy-mode scroll so user input reaches the CLI.
10171
- if (tmuxScrolledHalfPages > 0)
11472
+ if (tmuxScrolledHalfPages > 0 && !messageAdoptMode)
10172
11473
  exitTmuxScrollMode();
10173
11474
  let content = msg.content;
10174
11475
  let codexAppInput = msg.codexAppInput;
@@ -10203,51 +11504,46 @@ process.on('message', async (raw) => {
10203
11504
  }
10204
11505
  }
10205
11506
  if (lastInitConfig?.adoptMode) {
10206
- // Adopt writes immediately below, so this turn really becomes current
10207
- // now. Non-adopt messages update the marker only when flushPending
10208
- // dequeues/writes them; doing it at IPC arrival lets a queued IM turn
10209
- // steal the identity of a still-running durable delivery.
10210
- currentBotmuxTurnId = msg.turnId;
10211
- currentBotmuxDispatchAttempt = msg.dispatchAttempt;
10212
- currentVcMeetingImTurnOrigin = msg.vcMeetingImTurnOrigin;
10213
- writeCliPidMarker();
10214
- publishSandboxRelayCapability();
10215
- // Bridge mode: capture transcript baseline BEFORE writing to the pane,
10216
- // so any assistant uuids appended after this point are attributed to
10217
- // *this* Lark turn (not local user activity in the pane). Mark may
10218
- // return false (baseline not ready) — we still write to the pane;
10219
- // user just won't get a final_output for this message.
10220
- if (bridgeJsonlPath) {
10221
- try {
10222
- bridgeIngest();
10223
- }
10224
- catch { /* best effort */ }
10225
- bridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
10226
- }
10227
- else if (codexBridgeFallbackActive()) {
10228
- // Codex adopt: same idea, different bridge. ingest first so any
10229
- // in-flight events from a local-typed prior turn close before
10230
- // this Lark turn's fingerprint window opens. Mark works even
10231
- // pre-attach (queue is path-agnostic).
10232
- if (codexBridgeIsCursor()) {
10233
- // Cursor may append the current Lark/user line to its transcript
10234
- // before this IPC message is handled. Mark first so that preexisting
10235
- // current-line can still fingerprint-match instead of being marked
10236
- // seen as an unmatched event.
10237
- codexBridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
11507
+ let adoptWritePrepared = false;
11508
+ const prepareAdoptWrite = () => {
11509
+ if (adoptWritePrepared)
11510
+ return;
11511
+ adoptWritePrepared = true;
11512
+ renderer?.markNewTurn();
11513
+ turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
11514
+ if (tmuxScrolledHalfPages > 0)
11515
+ exitTmuxScrollMode();
11516
+ currentBotmuxTurnId = msg.turnId;
11517
+ currentBotmuxDispatchAttempt = msg.dispatchAttempt;
11518
+ currentVcMeetingImTurnOrigin = msg.vcMeetingImTurnOrigin;
11519
+ writeCliPidMarker();
11520
+ publishSandboxRelayCapability();
11521
+ // Capture the transcript baseline only after this turn owns the
11522
+ // submission lock and immediately before its literal write.
11523
+ if (bridgeJsonlPath) {
10238
11524
  try {
10239
- codexBridgeIngest();
11525
+ bridgeIngest();
10240
11526
  }
10241
11527
  catch { /* best effort */ }
11528
+ bridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
10242
11529
  }
10243
- else {
10244
- try {
10245
- codexBridgeIngest();
11530
+ else if (codexBridgeFallbackActive()) {
11531
+ if (codexBridgeIsCursor()) {
11532
+ codexBridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
11533
+ try {
11534
+ codexBridgeIngest();
11535
+ }
11536
+ catch { /* best effort */ }
11537
+ }
11538
+ else {
11539
+ try {
11540
+ codexBridgeIngest();
11541
+ }
11542
+ catch { /* best effort */ }
11543
+ codexBridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
10246
11544
  }
10247
- catch { /* best effort */ }
10248
- codexBridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
10249
11545
  }
10250
- }
11546
+ };
10251
11547
  // Adopt mode write:
10252
11548
  // - Structured-bridge adopt-input CLIs (codex/traex/pi/grok/mtr)
10253
11549
  // route through cliAdapter.writeInput so paste+Enter-retry +
@@ -10262,41 +11558,108 @@ process.on('message', async (raw) => {
10262
11558
  if (backend) {
10263
11559
  if (isStructuredBridgeAdoptInputCli(lastInitConfig?.cliId) && cliAdapter) {
10264
11560
  // writeInput is async but we're already inside an async
10265
- // message handler. Errors are best-effort logged; the bridge
10266
- // ingest path is unaffected because mark already happened
10267
- // above (codexBridgeMarkPendingTurn / bridgeMarkPendingTurn).
11561
+ // message handler. Errors are best-effort logged; attribution and
11562
+ // bridge baselines are prepared only after the submission lock is
11563
+ // held, immediately before the adapter write.
11564
+ const submissionBackend = backend;
11565
+ const submissionAdapter = cliAdapter;
11566
+ let recoveryFailureReason;
10268
11567
  try {
10269
- const result = await cliAdapter.writeInput(backend, content);
11568
+ const transaction = await runAmbiguousSubmissionTransaction(submissionBackend, () => submissionAdapter.writeInput(adapterInputHandle(submissionBackend), content), settleVerifiableSubmissionForJournal, prepareAdoptWrite);
11569
+ const result = transaction.result;
11570
+ recoveryFailureReason = transaction.recoveryFailureReason;
10270
11571
  if (result?.cliSessionId) {
10271
11572
  persistCliSessionId(result.cliSessionId);
10272
11573
  codexBridgeNotifyCliSessionId(result.cliSessionId);
10273
11574
  }
10274
- if (result && result.submitted === false) {
10275
- scheduleSubmitFailureNotify(content, result.recheck, 'submit history', undefined, result.failureReason, turnSeq);
11575
+ if (result?.submitted === false || recoveryFailureReason) {
11576
+ if (recoveryFailureReason) {
11577
+ notifyAmbiguousSubmissionRecovery(recoveryFailureReason, msg);
11578
+ }
11579
+ else {
11580
+ scheduleSubmitFailureNotify(content, result?.recheck, 'submit history', undefined, result?.failureReason, turnSeq, msg, 'failed');
11581
+ }
10276
11582
  }
10277
11583
  else {
10278
11584
  acknowledgeTurnInputCommitted(msg.turnId);
10279
11585
  }
10280
11586
  }
10281
11587
  catch (err) {
11588
+ recoveryFailureReason = err instanceof SubmissionWriteError
11589
+ ? err.recoveryFailureReason
11590
+ : recoveryFailureReason;
11591
+ const blockedBeforeWrite = err instanceof SubmissionWriteError
11592
+ && !!err.recoveryFailureReason
11593
+ && !err.submissionStarted;
10282
11594
  log(`Adopt writeInput error (${lastInitConfig?.cliId}): ${err.message}`);
11595
+ if (msg.turnId
11596
+ && msg.dispatchAttempt !== undefined
11597
+ && blockedBeforeWrite) {
11598
+ emitTurnTerminal(msg.turnId, 'failed', 'zmx_recovery_blocked_before_write', msg.dispatchAttempt);
11599
+ }
11600
+ else if (msg.turnId
11601
+ && msg.dispatchAttempt !== undefined
11602
+ && !recoveryFailureReason) {
11603
+ emitTurnTerminal(msg.turnId, 'ambiguous', 'write_input_threw', msg.dispatchAttempt);
11604
+ }
11605
+ if (backend) {
11606
+ if (recoveryFailureReason) {
11607
+ notifyAmbiguousSubmissionRecovery(recoveryFailureReason, msg);
11608
+ }
11609
+ else {
11610
+ scheduleSubmitFailureNotify(content, undefined, 'submit history', undefined, undefined, turnSeq, msg, 'ambiguous');
11611
+ }
11612
+ }
10283
11613
  }
10284
11614
  }
10285
11615
  else if ('sendText' in backend && 'sendSpecialKeys' in backend) {
10286
- backend.sendText(content);
10287
- // Beat between text and Enter so the adopted CLI's input layer
10288
- // has time to register the typed chars before submit. Without
10289
- // this, Ink-based TUIs (CoCo, Claude Code) flag the rapid
10290
- // input+Enter as paste continuation and treat the trailing
10291
- // Enter as a soft-newline, leaving the message stranded in the
10292
- // input box. 200ms mirrors the per-adapter writeInput delay
10293
- // that fresh-spawn mode goes through and matches the slash-
10294
- // command (raw_input) fix.
10295
- await new Promise(r => setTimeout(r, 200));
10296
- backend.sendSpecialKeys('Enter');
10297
- acknowledgeTurnInputCommitted(msg.turnId);
11616
+ const submissionBackend = backend;
11617
+ let recoveryFailureReason;
11618
+ try {
11619
+ const transaction = await runAmbiguousSubmissionTransaction(submissionBackend, async () => {
11620
+ const input = adapterInputHandle(submissionBackend);
11621
+ input.sendText(content);
11622
+ // Beat between text and Enter so the adopted CLI's input
11623
+ // layer registers the typed chars before submit.
11624
+ await new Promise(r => setTimeout(r, 200));
11625
+ input.sendSpecialKeys('Enter');
11626
+ }, undefined, prepareAdoptWrite);
11627
+ recoveryFailureReason = transaction.recoveryFailureReason;
11628
+ if (recoveryFailureReason) {
11629
+ throw new SubmissionWriteError(`backend could not commit the adopt submission journal; ${recoveryFailureReason}`, recoveryFailureReason);
11630
+ }
11631
+ acknowledgeTurnInputCommitted(msg.turnId);
11632
+ }
11633
+ catch (err) {
11634
+ recoveryFailureReason = err instanceof SubmissionWriteError
11635
+ ? err.recoveryFailureReason
11636
+ : recoveryFailureReason;
11637
+ const blockedBeforeWrite = err instanceof SubmissionWriteError
11638
+ && !!err.recoveryFailureReason
11639
+ && !err.submissionStarted;
11640
+ log(`Adopt raw input error (${lastInitConfig?.cliId}): ${err.message}`);
11641
+ if (msg.turnId
11642
+ && msg.dispatchAttempt !== undefined
11643
+ && blockedBeforeWrite) {
11644
+ emitTurnTerminal(msg.turnId, 'failed', 'zmx_recovery_blocked_before_write', msg.dispatchAttempt);
11645
+ }
11646
+ else if (msg.turnId
11647
+ && msg.dispatchAttempt !== undefined
11648
+ && !recoveryFailureReason) {
11649
+ emitTurnTerminal(msg.turnId, 'ambiguous', 'write_input_threw', msg.dispatchAttempt);
11650
+ }
11651
+ if (backend) {
11652
+ if (recoveryFailureReason) {
11653
+ notifyAmbiguousSubmissionRecovery(recoveryFailureReason, msg);
11654
+ }
11655
+ else {
11656
+ scheduleSubmitFailureNotify(content, undefined, 'submit history', undefined, undefined, turnSeq, msg, 'ambiguous');
11657
+ }
11658
+ }
11659
+ }
10298
11660
  }
10299
11661
  else {
11662
+ prepareAdoptWrite();
10300
11663
  backend.write(content + '\r');
10301
11664
  acknowledgeTurnInputCommitted(msg.turnId);
10302
11665
  }
@@ -10334,9 +11697,9 @@ process.on('message', async (raw) => {
10334
11697
  // await settleLaunchComm() 最长 2s 等 wrapper 完成 `exec <cli>`——这段
10335
11698
  // await 让出事件循环,而 IPC handler 不串行(见 raw-input-followup-
10336
11699
  // atomicity.test.ts),若此刻放行 passthrough 就会打进尚未 exec 的临时
10337
- // shell。bareShellCheckInProgress 覆盖"检查进行中"、bareShellLaunchBlocked
10338
- // 覆盖"已确认裸 shell 启动失败"两种状态,一并入队。裸 shell 确认失败后
10339
- // 这些 pending 不会再被 flush(与 pendingMessages 同款处理),符合预期。
11700
+ // shell。bareShellCheckInProgress 覆盖“检查进行中”、bareShellLaunchBlocked
11701
+ // 覆盖“仍停在裸 shell 的安全 hold”两种状态,一并入队;若该进程随后出现
11702
+ // 真实 PTY prompt leaf 已变为非 shell,markPromptReady 会恢复排空。
10340
11703
  if (cliRestartInProgress || rawInputRestartGate || sessionRenameInFlight
10341
11704
  || shouldHoldCodexRunnerInput(codexRunnerFreshness)
10342
11705
  || injectionFlushing || shouldDeferUserFlush(pendingInjections)
@@ -10383,6 +11746,16 @@ process.on('message', async (raw) => {
10383
11746
  if (msg.updateWorkingDir && lastInitConfig) {
10384
11747
  lastInitConfig.workingDir = msg.updateWorkingDir;
10385
11748
  }
11749
+ // per-bot env 热更:daemon 发 restart 时捎带 bots.json `env` 的最新值
11750
+ // (live-worker restart 不 refork,没有 init 重发这条通道),respawn 前
11751
+ // 全量覆盖 lastInitConfig.env —— spawnCli 的 sanitizePerBotEnv(cfg.env)
11752
+ // 每次 spawn 都重跑,覆盖即在重启出的 CLI 上生效。undefined=不携带(旧
11753
+ // daemon / 兜底)保持快照;null=dashboard 已清空 → 移除快照。与上面的
11754
+ // cwd merge 一样放在合并守卫之前:被合并的重复 restart 也应带走 env
11755
+ // 更新,pending 的 respawn 展开 {...lastInitConfig} 时自然拿到新值。
11756
+ if (msg.env !== undefined && lastInitConfig) {
11757
+ lastInitConfig.env = msg.env === null ? undefined : msg.env;
11758
+ }
10386
11759
  // restart 合并:已有一轮 restart 在飞(teardown 进行中,或 tmux jitter
10387
11760
  // 定时器未触发)时不叠加第二轮——叠加会 clearTimeout 吃掉首轮 teardown、
10388
11761
  // 把重启预算无故烧到 tier-2 强制 FRESH(丢上下文),非 tmux 路径还会
@@ -10471,6 +11844,21 @@ process.on('message', async (raw) => {
10471
11844
  }
10472
11845
  }
10473
11846
  if (removedPending > 0) {
11847
+ if (ambiguousSubmissionRecoveryHold?.backend === backend
11848
+ && !lastInitConfig?.adoptMode) {
11849
+ // A recovery-held item at the queue head blocked this durable
11850
+ // attempt from ever reaching the PTY. It may be this exact delivery
11851
+ // or an ordinary IM immediately ahead of it; either way, hub replay
11852
+ // must not hit the same poisoned generation. Fence it now, preserve
11853
+ // every other queued item, then admit the next attempt to the fresh
11854
+ // session.
11855
+ ambiguousSubmissionRecoveryHold = null;
11856
+ log(`Expiring recovery-held durable input turn=${msg.turnId.slice(0, 12)} `
11857
+ + `attempt=${msg.dispatchAttempt}; fencing poisoned backend`);
11858
+ await restartCliProcess('ZMX recovery hold blocked durable lease', { immediate: true, preservePending: true });
11859
+ acknowledge('cli_fenced');
11860
+ break;
11861
+ }
10474
11862
  log(`Expired ${removedPending} queued durable input(s) turn=${msg.turnId.slice(0, 12)} `
10475
11863
  + `attempt=${msg.dispatchAttempt}`);
10476
11864
  acknowledge('queued_removed');
@@ -10516,6 +11904,18 @@ process.on('message', async (raw) => {
10516
11904
  // (no stuckNonce) bypass the guard and write keys directly.
10517
11905
  let wroteKeys = false;
10518
11906
  if (msg.stuckNonce !== undefined && msg.stuckPageType) {
11907
+ if (!backendScreenEvidenceIsAuthoritativeForMutation()) {
11908
+ // Defense in depth: an old card may predate this worker or a backend
11909
+ // switch. A fresh 120x24 history render is still not authoritative
11910
+ // after local attach resize, so expire the action without writing.
11911
+ send({
11912
+ type: 'stuck_warning_expired',
11913
+ nonce: msg.stuckNonce,
11914
+ turnId: currentBotmuxTurnId,
11915
+ dispatchAttempt: currentBotmuxDispatchAttempt,
11916
+ });
11917
+ break;
11918
+ }
10519
11919
  const result = await processStuckWarningTuiKeys({
10520
11920
  stuckNonce: msg.stuckNonce,
10521
11921
  stuckPageType: msg.stuckPageType,
@@ -10534,13 +11934,40 @@ process.on('message', async (raw) => {
10534
11934
  writeKeys: handleTuiKeys,
10535
11935
  sendExpired: (nonce, turnId, dispatchAttempt) => send({ type: 'stuck_warning_expired', nonce, turnId, dispatchAttempt }),
10536
11936
  sendDelivered: (nonce, turnId, dispatchAttempt) => send({ type: 'tui_keys_delivered', nonce, turnId, dispatchAttempt }),
11937
+ sendFailed: (nonce, turnId, dispatchAttempt) => send({ type: 'tui_prompt_submit_failed', stuckNonce: nonce, turnId, dispatchAttempt }),
10537
11938
  log,
10538
11939
  });
10539
11940
  wroteKeys = result.wroteKeys;
10540
11941
  }
10541
11942
  else {
10542
- await handleTuiKeys(msg.keys, msg.isFinal);
10543
- wroteKeys = true;
11943
+ wroteKeys = await handleTuiKeys(msg.keys, msg.isFinal);
11944
+ if (msg.cardMessageId) {
11945
+ if (!wroteKeys) {
11946
+ send({
11947
+ type: 'tui_prompt_submit_failed',
11948
+ cardMessageId: msg.cardMessageId,
11949
+ turnId: currentBotmuxTurnId,
11950
+ dispatchAttempt: currentBotmuxDispatchAttempt,
11951
+ });
11952
+ }
11953
+ else if (msg.isFinal) {
11954
+ send({
11955
+ type: 'tui_prompt_resolved',
11956
+ cardMessageId: msg.cardMessageId,
11957
+ selectedText: msg.selectedText,
11958
+ turnId: currentBotmuxTurnId,
11959
+ dispatchAttempt: currentBotmuxDispatchAttempt,
11960
+ });
11961
+ }
11962
+ }
11963
+ else if (!wroteKeys) {
11964
+ send({
11965
+ type: 'user_notify',
11966
+ turnId: currentBotmuxTurnId,
11967
+ dispatchAttempt: currentBotmuxDispatchAttempt,
11968
+ message: t('worker.tui_submit_failed', { cliName: cliName() }),
11969
+ });
11970
+ }
10544
11971
  }
10545
11972
  // Re-arm the stuck detector ONLY when the card-handler explicitly flags
10546
11973
  // this as a stuck-warning card's Enter action (advances to the next
@@ -10561,7 +11988,31 @@ process.on('message', async (raw) => {
10561
11988
  break;
10562
11989
  }
10563
11990
  case 'tui_text_input': {
10564
- handleTuiTextInput(msg.keys, msg.text);
11991
+ const wroteText = await handleTuiTextInput(msg.keys, msg.text);
11992
+ if (msg.cardMessageId) {
11993
+ send(wroteText
11994
+ ? {
11995
+ type: 'tui_prompt_resolved',
11996
+ cardMessageId: msg.cardMessageId,
11997
+ selectedText: msg.text,
11998
+ turnId: currentBotmuxTurnId,
11999
+ dispatchAttempt: currentBotmuxDispatchAttempt,
12000
+ }
12001
+ : {
12002
+ type: 'tui_prompt_submit_failed',
12003
+ cardMessageId: msg.cardMessageId,
12004
+ turnId: currentBotmuxTurnId,
12005
+ dispatchAttempt: currentBotmuxDispatchAttempt,
12006
+ });
12007
+ }
12008
+ else if (!wroteText) {
12009
+ send({
12010
+ type: 'user_notify',
12011
+ turnId: currentBotmuxTurnId,
12012
+ dispatchAttempt: currentBotmuxDispatchAttempt,
12013
+ message: t('worker.tui_submit_failed', { cliName: cliName() }),
12014
+ });
12015
+ }
10565
12016
  break;
10566
12017
  }
10567
12018
  case 'coco_drive_picker': {
@@ -10622,7 +12073,7 @@ process.on('message', async (raw) => {
10622
12073
  break;
10623
12074
  }
10624
12075
  case 'term_action': {
10625
- handleTermAction(msg.key);
12076
+ await handleTermAction(msg.key);
10626
12077
  break;
10627
12078
  }
10628
12079
  case 'refresh_screen': {
@@ -10639,7 +12090,11 @@ process.on('message', async (raw) => {
10639
12090
  }
10640
12091
  case 'close': {
10641
12092
  log('Close requested');
12093
+ closeRequested = true;
12094
+ send({ type: 'session_close_ready', sessionId });
10642
12095
  stopScreenshotLoop();
12096
+ stopBridgeWatcher();
12097
+ stopCodexBridge();
10643
12098
  // destroySession kills tmux session permanently; kill() only detaches.
10644
12099
  // riff 的 destroySession 是异步远端取消——必须有界 await:紧跟着的
10645
12100
  // process.exit 会掐断未发出的 fetch,让已关闭话题的远端 agent 继续跑。
@@ -10660,6 +12115,20 @@ process.on('message', async (raw) => {
10660
12115
  cleanup();
10661
12116
  process.exit(0);
10662
12117
  }
12118
+ case 'detach_for_transfer': {
12119
+ log('Transfer detach requested');
12120
+ stopScreenshotLoop();
12121
+ // Transfer keeps the logical session alive. The daemon starts its
12122
+ // replacement on the new routing anchor only after this worker ACKs the
12123
+ // observer detach and exits. `killCli()` deliberately calls backend.kill()
12124
+ // rather than destroySession(): persistent mux/ZMX sessions and Riff
12125
+ // tasks survive for reattach, while PTY keeps its existing cold-resume
12126
+ // behavior because its kill() owns the child process.
12127
+ killCli({ preserveSandbox: true });
12128
+ cleanup();
12129
+ await flushTransferDetachAck(msg.requestId);
12130
+ process.exit(0);
12131
+ }
10663
12132
  case 'suspend': {
10664
12133
  log('Suspend requested');
10665
12134
  stopScreenshotLoop();
@@ -10670,7 +12139,7 @@ process.on('message', async (raw) => {
10670
12139
  // cold-resumes a FRESH CLI on the next message — bmx-<sid> is absent.)
10671
12140
  destroyCrashDiagnosticTerminal('suspend');
10672
12141
  // Free the CLI's memory, not just the worker's: destroySession kills the
10673
- // backing tmux/herdr/zellij session AND the CLI process inside it (kill()
12142
+ // backing tmux/herdr/zellij/zmx session AND the CLI process inside it (kill()
10674
12143
  // would only detach the pty viewer and leave the CLI running in the
10675
12144
  // background — defeating the whole point of a session cap, since the CLI
10676
12145
  // is the memory hog). On the next message the session cold-resumes via
@@ -10819,6 +12288,15 @@ function teardownSandboxBestEffort() {
10819
12288
  // the guard before any further stdout writes (log() writes to process.stdout).
10820
12289
  installStdioEpipeGuard();
10821
12290
  process.on('exit', () => {
12291
+ // `zmx tail` can remain blocked with PPID=1 after an abrupt Node exit because
12292
+ // it notices a broken stdout pipe only when new session output arrives.
12293
+ // Detach the observer synchronously; never destroy the persistent CLI here.
12294
+ if (backend instanceof ZmxBackend) {
12295
+ try {
12296
+ backend.kill();
12297
+ }
12298
+ catch { /* best-effort crash teardown */ }
12299
+ }
10822
12300
  stopNativeSessionTitleSync();
10823
12301
  teardownSandboxBestEffort();
10824
12302
  stopCodexRpcEngine();