botmux 3.5.2 → 3.6.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 (458) hide show
  1. package/README.en.md +56 -396
  2. package/README.md +55 -396
  3. package/dist/.runtime-build-id +1 -0
  4. package/dist/adapters/cli/claude-code.d.ts +6 -0
  5. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  6. package/dist/adapters/cli/claude-code.js +197 -11
  7. package/dist/adapters/cli/claude-code.js.map +1 -1
  8. package/dist/adapters/cli/coco.js +4 -4
  9. package/dist/adapters/cli/coco.js.map +1 -1
  10. package/dist/adapters/cli/codex-app.d.ts.map +1 -1
  11. package/dist/adapters/cli/codex-app.js +9 -6
  12. package/dist/adapters/cli/codex-app.js.map +1 -1
  13. package/dist/adapters/cli/codex.js +5 -5
  14. package/dist/adapters/cli/codex.js.map +1 -1
  15. package/dist/adapters/cli/fs-policy.d.ts.map +1 -1
  16. package/dist/adapters/cli/fs-policy.js +5 -7
  17. package/dist/adapters/cli/fs-policy.js.map +1 -1
  18. package/dist/adapters/cli/grok.d.ts +2 -1
  19. package/dist/adapters/cli/grok.d.ts.map +1 -1
  20. package/dist/adapters/cli/grok.js +2 -1
  21. package/dist/adapters/cli/grok.js.map +1 -1
  22. package/dist/adapters/cli/hermes.d.ts.map +1 -1
  23. package/dist/adapters/cli/hermes.js +8 -4
  24. package/dist/adapters/cli/hermes.js.map +1 -1
  25. package/dist/adapters/cli/mtr.d.ts.map +1 -1
  26. package/dist/adapters/cli/mtr.js +3 -2
  27. package/dist/adapters/cli/mtr.js.map +1 -1
  28. package/dist/adapters/cli/opencode.d.ts.map +1 -1
  29. package/dist/adapters/cli/opencode.js +5 -4
  30. package/dist/adapters/cli/opencode.js.map +1 -1
  31. package/dist/adapters/cli/relay.js +2 -2
  32. package/dist/adapters/cli/relay.js.map +1 -1
  33. package/dist/adapters/cli/runner-input.d.ts +2 -2
  34. package/dist/adapters/cli/runner-input.d.ts.map +1 -1
  35. package/dist/adapters/cli/runner-input.js +9 -6
  36. package/dist/adapters/cli/runner-input.js.map +1 -1
  37. package/dist/adapters/cli/seed.d.ts.map +1 -1
  38. package/dist/adapters/cli/seed.js +2 -1
  39. package/dist/adapters/cli/seed.js.map +1 -1
  40. package/dist/adapters/cli/traex.d.ts.map +1 -1
  41. package/dist/adapters/cli/traex.js +3 -2
  42. package/dist/adapters/cli/traex.js.map +1 -1
  43. package/dist/adapters/cli/types.d.ts +25 -18
  44. package/dist/adapters/cli/types.d.ts.map +1 -1
  45. package/dist/bot-registry.d.ts +28 -0
  46. package/dist/bot-registry.d.ts.map +1 -1
  47. package/dist/bot-registry.js +75 -0
  48. package/dist/bot-registry.js.map +1 -1
  49. package/dist/cli.d.ts.map +1 -1
  50. package/dist/cli.js +272 -86
  51. package/dist/cli.js.map +1 -1
  52. package/dist/codex-app-runner.js +85 -192
  53. package/dist/codex-app-runner.js.map +1 -1
  54. package/dist/config.d.ts +0 -16
  55. package/dist/config.d.ts.map +1 -1
  56. package/dist/config.js +0 -30
  57. package/dist/config.js.map +1 -1
  58. package/dist/core/ask-broker.d.ts +12 -0
  59. package/dist/core/ask-broker.d.ts.map +1 -1
  60. package/dist/core/ask-broker.js +41 -0
  61. package/dist/core/ask-broker.js.map +1 -1
  62. package/dist/core/bot-turn-mutation-gate.d.ts +24 -0
  63. package/dist/core/bot-turn-mutation-gate.d.ts.map +1 -0
  64. package/dist/core/bot-turn-mutation-gate.js +369 -0
  65. package/dist/core/bot-turn-mutation-gate.js.map +1 -0
  66. package/dist/core/command-handler.d.ts +5 -0
  67. package/dist/core/command-handler.d.ts.map +1 -1
  68. package/dist/core/command-handler.js +38 -16
  69. package/dist/core/command-handler.js.map +1 -1
  70. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  71. package/dist/core/dashboard-ipc-server.js +330 -57
  72. package/dist/core/dashboard-ipc-server.js.map +1 -1
  73. package/dist/core/dashboard-rows.d.ts +8 -0
  74. package/dist/core/dashboard-rows.d.ts.map +1 -1
  75. package/dist/core/dashboard-rows.js.map +1 -1
  76. package/dist/core/host-overload-alert.d.ts +157 -0
  77. package/dist/core/host-overload-alert.d.ts.map +1 -0
  78. package/dist/core/host-overload-alert.js +251 -0
  79. package/dist/core/host-overload-alert.js.map +1 -0
  80. package/dist/core/passthrough-commands.d.ts +8 -0
  81. package/dist/core/passthrough-commands.d.ts.map +1 -1
  82. package/dist/core/passthrough-commands.js +19 -0
  83. package/dist/core/passthrough-commands.js.map +1 -1
  84. package/dist/core/restart-coordinator.d.ts +28 -0
  85. package/dist/core/restart-coordinator.d.ts.map +1 -0
  86. package/dist/core/restart-coordinator.js +82 -0
  87. package/dist/core/restart-coordinator.js.map +1 -0
  88. package/dist/core/restart-followup-policy.d.ts +112 -0
  89. package/dist/core/restart-followup-policy.d.ts.map +1 -0
  90. package/dist/core/restart-followup-policy.js +49 -0
  91. package/dist/core/restart-followup-policy.js.map +1 -0
  92. package/dist/core/role-library.d.ts +1 -1
  93. package/dist/core/role-library.js +1 -1
  94. package/dist/core/session-cwd.d.ts.map +1 -1
  95. package/dist/core/session-cwd.js +8 -0
  96. package/dist/core/session-cwd.js.map +1 -1
  97. package/dist/core/session-discovery.d.ts +14 -0
  98. package/dist/core/session-discovery.d.ts.map +1 -1
  99. package/dist/core/session-discovery.js +25 -0
  100. package/dist/core/session-discovery.js.map +1 -1
  101. package/dist/core/session-liveness.d.ts +15 -0
  102. package/dist/core/session-liveness.d.ts.map +1 -0
  103. package/dist/core/session-liveness.js +76 -0
  104. package/dist/core/session-liveness.js.map +1 -0
  105. package/dist/core/session-row-enrichment.d.ts +15 -0
  106. package/dist/core/session-row-enrichment.d.ts.map +1 -0
  107. package/dist/core/session-row-enrichment.js +93 -0
  108. package/dist/core/session-row-enrichment.js.map +1 -0
  109. package/dist/core/slash-inject.js +1 -1
  110. package/dist/core/slash-inject.js.map +1 -1
  111. package/dist/core/trigger-final-suppression.d.ts +24 -0
  112. package/dist/core/trigger-final-suppression.d.ts.map +1 -0
  113. package/dist/core/trigger-final-suppression.js +97 -0
  114. package/dist/core/trigger-final-suppression.js.map +1 -0
  115. package/dist/core/trigger-session.d.ts.map +1 -1
  116. package/dist/core/trigger-session.js +64 -4
  117. package/dist/core/trigger-session.js.map +1 -1
  118. package/dist/core/types.d.ts +6 -0
  119. package/dist/core/types.d.ts.map +1 -1
  120. package/dist/core/types.js.map +1 -1
  121. package/dist/core/worker-pool.d.ts +20 -0
  122. package/dist/core/worker-pool.d.ts.map +1 -1
  123. package/dist/core/worker-pool.js +283 -32
  124. package/dist/core/worker-pool.js.map +1 -1
  125. package/dist/daemon.d.ts +5 -1
  126. package/dist/daemon.d.ts.map +1 -1
  127. package/dist/daemon.js +520 -35
  128. package/dist/daemon.js.map +1 -1
  129. package/dist/dashboard/aggregator.d.ts +1 -0
  130. package/dist/dashboard/aggregator.d.ts.map +1 -1
  131. package/dist/dashboard/aggregator.js +34 -6
  132. package/dist/dashboard/aggregator.js.map +1 -1
  133. package/dist/dashboard/bot-payload.d.ts +3 -0
  134. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  135. package/dist/dashboard/bot-payload.js +3 -0
  136. package/dist/dashboard/bot-payload.js.map +1 -1
  137. package/dist/dashboard/compat.d.ts +102 -2
  138. package/dist/dashboard/compat.d.ts.map +1 -1
  139. package/dist/dashboard/compat.js +78 -14
  140. package/dist/dashboard/compat.js.map +1 -1
  141. package/dist/dashboard/connector-api.d.ts.map +1 -1
  142. package/dist/dashboard/connector-api.js +1 -0
  143. package/dist/dashboard/connector-api.js.map +1 -1
  144. package/dist/dashboard/desktop-asks.d.ts +21 -0
  145. package/dist/dashboard/desktop-asks.d.ts.map +1 -0
  146. package/dist/dashboard/desktop-asks.js +49 -0
  147. package/dist/dashboard/desktop-asks.js.map +1 -0
  148. package/dist/dashboard/public-redact.d.ts +7 -0
  149. package/dist/dashboard/public-redact.d.ts.map +1 -1
  150. package/dist/dashboard/public-redact.js +35 -3
  151. package/dist/dashboard/public-redact.js.map +1 -1
  152. package/dist/dashboard/session-presentation.d.ts +16 -0
  153. package/dist/dashboard/session-presentation.d.ts.map +1 -0
  154. package/dist/dashboard/session-presentation.js +62 -0
  155. package/dist/dashboard/session-presentation.js.map +1 -0
  156. package/dist/dashboard/settings-write-applier.d.ts +2 -1
  157. package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
  158. package/dist/dashboard/settings-write-applier.js +19 -1
  159. package/dist/dashboard/settings-write-applier.js.map +1 -1
  160. package/dist/dashboard/trigger-api.d.ts.map +1 -1
  161. package/dist/dashboard/trigger-api.js +19 -1
  162. package/dist/dashboard/trigger-api.js.map +1 -1
  163. package/dist/dashboard/vc-consumer-profiles-api.d.ts +7 -2
  164. package/dist/dashboard/vc-consumer-profiles-api.d.ts.map +1 -1
  165. package/dist/dashboard/vc-consumer-profiles-api.js +13 -0
  166. package/dist/dashboard/vc-consumer-profiles-api.js.map +1 -1
  167. package/dist/dashboard/web/app.d.ts.map +1 -1
  168. package/dist/dashboard/web/app.js +18 -5
  169. package/dist/dashboard/web/app.js.map +1 -1
  170. package/dist/dashboard/web/bot-defaults-page.d.ts +1 -1
  171. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  172. package/dist/dashboard/web/bot-defaults-page.js +97 -35
  173. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  174. package/dist/dashboard/web/bot-defaults.d.ts +3 -0
  175. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  176. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  177. package/dist/dashboard/web/client-shell.d.ts +29 -0
  178. package/dist/dashboard/web/client-shell.d.ts.map +1 -0
  179. package/dist/dashboard/web/client-shell.js +89 -0
  180. package/dist/dashboard/web/client-shell.js.map +1 -0
  181. package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
  182. package/dist/dashboard/web/connectors-page.js +5 -1
  183. package/dist/dashboard/web/connectors-page.js.map +1 -1
  184. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  185. package/dist/dashboard/web/i18n.js +100 -8
  186. package/dist/dashboard/web/i18n.js.map +1 -1
  187. package/dist/dashboard/web/preferences.d.ts +7 -0
  188. package/dist/dashboard/web/preferences.d.ts.map +1 -1
  189. package/dist/dashboard/web/preferences.js +32 -0
  190. package/dist/dashboard/web/preferences.js.map +1 -1
  191. package/dist/dashboard/web/sessions-kanban.d.ts +1 -1
  192. package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
  193. package/dist/dashboard/web/sessions-kanban.js +1 -1
  194. package/dist/dashboard/web/sessions-kanban.js.map +1 -1
  195. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  196. package/dist/dashboard/web/sessions-page.js +77 -23
  197. package/dist/dashboard/web/sessions-page.js.map +1 -1
  198. package/dist/dashboard/web/sessions.d.ts +4 -0
  199. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  200. package/dist/dashboard/web/sessions.js +14 -1
  201. package/dist/dashboard/web/sessions.js.map +1 -1
  202. package/dist/dashboard/web/settings-page.d.ts +6 -0
  203. package/dist/dashboard/web/settings-page.d.ts.map +1 -1
  204. package/dist/dashboard/web/settings-page.js +55 -30
  205. package/dist/dashboard/web/settings-page.js.map +1 -1
  206. package/dist/dashboard/web/store.d.ts +1 -2
  207. package/dist/dashboard/web/store.d.ts.map +1 -1
  208. package/dist/dashboard/web/store.js +69 -20
  209. package/dist/dashboard/web/store.js.map +1 -1
  210. package/dist/dashboard/web/vc-consumer-profiles-section.d.ts.map +1 -1
  211. package/dist/dashboard/web/vc-consumer-profiles-section.js +128 -42
  212. package/dist/dashboard/web/vc-consumer-profiles-section.js.map +1 -1
  213. package/dist/dashboard/webhook-routes.d.ts.map +1 -1
  214. package/dist/dashboard/webhook-routes.js +9 -2
  215. package/dist/dashboard/webhook-routes.js.map +1 -1
  216. package/dist/dashboard-web/app.js +1 -1
  217. package/dist/dashboard-web/chunks/bot-defaults-page-VQMPUJ4Y.js +10 -0
  218. package/dist/dashboard-web/chunks/chunk-AGCPF6MT.js +1 -0
  219. package/dist/dashboard-web/chunks/{chunk-KPQAUX7V.js → chunk-JMRGZQDB.js} +1 -1
  220. package/dist/dashboard-web/chunks/{chunk-R7IAKAYX.js → chunk-NP4JUBGS.js} +1 -1
  221. package/dist/dashboard-web/chunks/{chunk-NRL6CVAS.js → chunk-P327LXOT.js} +1 -1
  222. package/dist/dashboard-web/chunks/{chunk-X2L65IOM.js → chunk-SNKATZWK.js} +15 -15
  223. package/dist/dashboard-web/chunks/connectors-page-DOY4J3ZL.js +1 -0
  224. package/dist/dashboard-web/chunks/{groups-page-6DKXBYOM.js → groups-page-74TS2DIH.js} +1 -1
  225. package/dist/dashboard-web/chunks/{insights-page-NWARTJ6J.js → insights-page-AYCO65JC.js} +1 -1
  226. package/dist/dashboard-web/chunks/{monitor-room-AGX3XRJX.js → monitor-room-YKYLTSAN.js} +1 -1
  227. package/dist/dashboard-web/chunks/{monitoring-page-GGGIWN4C.js → monitoring-page-QBIUNRBR.js} +1 -1
  228. package/dist/dashboard-web/chunks/{office-page-6SIVKSK3.js → office-page-4PH2A26P.js} +1 -1
  229. package/dist/dashboard-web/chunks/overview-page-H73D5UT4.js +1 -0
  230. package/dist/dashboard-web/chunks/{roles-page-WE2YB7KN.js → roles-page-U4BTOQ7F.js} +1 -1
  231. package/dist/dashboard-web/chunks/{schedules-page-TB3I24XN.js → schedules-page-RHRHX7R7.js} +1 -1
  232. package/dist/dashboard-web/chunks/sessions-page-VSC6AM5G.js +2 -0
  233. package/dist/dashboard-web/chunks/settings-page-LF3RKM5P.js +2 -0
  234. package/dist/dashboard-web/chunks/{skills-page-3QAT2IWD.js → skills-page-SYOIGGXH.js} +1 -1
  235. package/dist/dashboard-web/chunks/{team-federation-page-K4VVAVAK.js → team-federation-page-RMBLASTW.js} +1 -1
  236. package/dist/dashboard-web/chunks/{v3-page-V6IOJR2B.js → v3-page-XJXKKJRN.js} +1 -1
  237. package/dist/dashboard-web/chunks/{whiteboards-page-V6CGKRNX.js → whiteboards-page-K3YVSTHJ.js} +1 -1
  238. package/dist/dashboard-web/style.css +551 -0
  239. package/dist/dashboard.js +153 -9
  240. package/dist/dashboard.js.map +1 -1
  241. package/dist/global-config.d.ts +11 -0
  242. package/dist/global-config.d.ts.map +1 -1
  243. package/dist/global-config.js +23 -0
  244. package/dist/global-config.js.map +1 -1
  245. package/dist/i18n/en.d.ts.map +1 -1
  246. package/dist/i18n/en.js +5 -0
  247. package/dist/i18n/en.js.map +1 -1
  248. package/dist/i18n/zh.d.ts.map +1 -1
  249. package/dist/i18n/zh.js +5 -0
  250. package/dist/i18n/zh.js.map +1 -1
  251. package/dist/im/lark/card-builder.d.ts +1 -1
  252. package/dist/im/lark/card-builder.js +1 -1
  253. package/dist/im/lark/card-handler.d.ts +10 -0
  254. package/dist/im/lark/card-handler.d.ts.map +1 -1
  255. package/dist/im/lark/card-handler.js +163 -17
  256. package/dist/im/lark/card-handler.js.map +1 -1
  257. package/dist/im/lark/client.d.ts +11 -0
  258. package/dist/im/lark/client.d.ts.map +1 -1
  259. package/dist/im/lark/client.js +47 -18
  260. package/dist/im/lark/client.js.map +1 -1
  261. package/dist/im/lark/event-dispatcher.d.ts +13 -0
  262. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  263. package/dist/im/lark/event-dispatcher.js +20 -0
  264. package/dist/im/lark/event-dispatcher.js.map +1 -1
  265. package/dist/im/lark/identity-cache.d.ts +25 -1
  266. package/dist/im/lark/identity-cache.d.ts.map +1 -1
  267. package/dist/im/lark/identity-cache.js +47 -1
  268. package/dist/im/lark/identity-cache.js.map +1 -1
  269. package/dist/im/lark/overload-nonce.d.ts +19 -0
  270. package/dist/im/lark/overload-nonce.d.ts.map +1 -0
  271. package/dist/im/lark/overload-nonce.js +72 -0
  272. package/dist/im/lark/overload-nonce.js.map +1 -0
  273. package/dist/im/lark/v3-progress-card.d.ts.map +1 -1
  274. package/dist/im/lark/v3-progress-card.js +5 -0
  275. package/dist/im/lark/v3-progress-card.js.map +1 -1
  276. package/dist/services/async-trigger-state.d.ts +82 -0
  277. package/dist/services/async-trigger-state.d.ts.map +1 -0
  278. package/dist/services/async-trigger-state.js +95 -0
  279. package/dist/services/async-trigger-state.js.map +1 -0
  280. package/dist/services/async-trigger-store.d.ts +28 -0
  281. package/dist/services/async-trigger-store.d.ts.map +1 -0
  282. package/dist/services/async-trigger-store.js +120 -0
  283. package/dist/services/async-trigger-store.js.map +1 -0
  284. package/dist/services/bot-config-store.d.ts.map +1 -1
  285. package/dist/services/bot-config-store.js +34 -2
  286. package/dist/services/bot-config-store.js.map +1 -1
  287. package/dist/services/bridge-fallback-gate.d.ts +32 -0
  288. package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
  289. package/dist/services/bridge-fallback-gate.js +39 -0
  290. package/dist/services/bridge-fallback-gate.js.map +1 -1
  291. package/dist/services/bridge-turn-queue.d.ts.map +1 -1
  292. package/dist/services/bridge-turn-queue.js +15 -1
  293. package/dist/services/bridge-turn-queue.js.map +1 -1
  294. package/dist/services/card-prefs-store.d.ts +4 -0
  295. package/dist/services/card-prefs-store.d.ts.map +1 -1
  296. package/dist/services/card-prefs-store.js +8 -0
  297. package/dist/services/card-prefs-store.js.map +1 -1
  298. package/dist/services/claude-transcript.d.ts +23 -0
  299. package/dist/services/claude-transcript.d.ts.map +1 -1
  300. package/dist/services/claude-transcript.js +33 -0
  301. package/dist/services/claude-transcript.js.map +1 -1
  302. package/dist/services/codex-app-runner-protocol.d.ts +61 -0
  303. package/dist/services/codex-app-runner-protocol.d.ts.map +1 -0
  304. package/dist/services/codex-app-runner-protocol.js +170 -0
  305. package/dist/services/codex-app-runner-protocol.js.map +1 -0
  306. package/dist/services/codex-app-turn-controller.d.ts +72 -0
  307. package/dist/services/codex-app-turn-controller.d.ts.map +1 -0
  308. package/dist/services/codex-app-turn-controller.js +524 -0
  309. package/dist/services/codex-app-turn-controller.js.map +1 -0
  310. package/dist/services/codex-runner-freshness.d.ts +39 -0
  311. package/dist/services/codex-runner-freshness.d.ts.map +1 -0
  312. package/dist/services/codex-runner-freshness.js +78 -0
  313. package/dist/services/codex-runner-freshness.js.map +1 -0
  314. package/dist/services/connector-store.d.ts +6 -0
  315. package/dist/services/connector-store.d.ts.map +1 -1
  316. package/dist/services/connector-store.js.map +1 -1
  317. package/dist/services/grant-store.d.ts.map +1 -1
  318. package/dist/services/grant-store.js +16 -0
  319. package/dist/services/grant-store.js.map +1 -1
  320. package/dist/services/hermes-transcript.d.ts +1 -0
  321. package/dist/services/hermes-transcript.d.ts.map +1 -1
  322. package/dist/services/hermes-transcript.js +49 -0
  323. package/dist/services/hermes-transcript.js.map +1 -1
  324. package/dist/services/schedule-split-migration.d.ts +2 -0
  325. package/dist/services/schedule-split-migration.d.ts.map +1 -0
  326. package/dist/services/schedule-split-migration.js +159 -0
  327. package/dist/services/schedule-split-migration.js.map +1 -0
  328. package/dist/services/schedule-store.d.ts +28 -4
  329. package/dist/services/schedule-store.d.ts.map +1 -1
  330. package/dist/services/schedule-store.js +118 -33
  331. package/dist/services/schedule-store.js.map +1 -1
  332. package/dist/services/trigger-types.d.ts +18 -1
  333. package/dist/services/trigger-types.d.ts.map +1 -1
  334. package/dist/services/trigger-types.js +3 -0
  335. package/dist/services/trigger-types.js.map +1 -1
  336. package/dist/services/vc-meeting-consumer-profile-store.d.ts.map +1 -1
  337. package/dist/services/vc-meeting-consumer-profile-store.js +1 -0
  338. package/dist/services/vc-meeting-consumer-profile-store.js.map +1 -1
  339. package/dist/services/vc-meeting-consumer-profile-templates.d.ts +32 -0
  340. package/dist/services/vc-meeting-consumer-profile-templates.d.ts.map +1 -0
  341. package/dist/services/vc-meeting-consumer-profile-templates.js +106 -0
  342. package/dist/services/vc-meeting-consumer-profile-templates.js.map +1 -0
  343. package/dist/services/vc-meeting-delivery-feed.d.ts +1 -1
  344. package/dist/services/vc-meeting-delivery-feed.d.ts.map +1 -1
  345. package/dist/services/vc-meeting-delivery-feed.js +2 -1
  346. package/dist/services/vc-meeting-delivery-feed.js.map +1 -1
  347. package/dist/services/vc-meeting-delivery-hub-store.d.ts +3 -1
  348. package/dist/services/vc-meeting-delivery-hub-store.d.ts.map +1 -1
  349. package/dist/services/vc-meeting-delivery-hub-store.js +11 -0
  350. package/dist/services/vc-meeting-delivery-hub-store.js.map +1 -1
  351. package/dist/services/vc-meeting-delivery-protocol.d.ts +9 -2
  352. package/dist/services/vc-meeting-delivery-protocol.d.ts.map +1 -1
  353. package/dist/services/vc-meeting-delivery-protocol.js +10 -1
  354. package/dist/services/vc-meeting-delivery-protocol.js.map +1 -1
  355. package/dist/services/vc-meeting-delivery-receiver.d.ts.map +1 -1
  356. package/dist/services/vc-meeting-delivery-receiver.js +10 -1
  357. package/dist/services/vc-meeting-delivery-receiver.js.map +1 -1
  358. package/dist/services/vc-meeting-delivery-store.d.ts +12 -2
  359. package/dist/services/vc-meeting-delivery-store.d.ts.map +1 -1
  360. package/dist/services/vc-meeting-delivery-store.js +14 -2
  361. package/dist/services/vc-meeting-delivery-store.js.map +1 -1
  362. package/dist/services/vc-meeting-im-reply.d.ts +2 -1
  363. package/dist/services/vc-meeting-im-reply.d.ts.map +1 -1
  364. package/dist/services/vc-meeting-im-reply.js +19 -3
  365. package/dist/services/vc-meeting-im-reply.js.map +1 -1
  366. package/dist/services/vc-meeting-listener-output-protocol.d.ts +27 -0
  367. package/dist/services/vc-meeting-listener-output-protocol.d.ts.map +1 -0
  368. package/dist/services/vc-meeting-listener-output-protocol.js +58 -0
  369. package/dist/services/vc-meeting-listener-output-protocol.js.map +1 -0
  370. package/dist/services/vc-meeting-listener-topic-store.d.ts +31 -0
  371. package/dist/services/vc-meeting-listener-topic-store.d.ts.map +1 -0
  372. package/dist/services/vc-meeting-listener-topic-store.js +120 -0
  373. package/dist/services/vc-meeting-listener-topic-store.js.map +1 -0
  374. package/dist/services/vc-meeting-runtime-store.d.ts +2 -1
  375. package/dist/services/vc-meeting-runtime-store.d.ts.map +1 -1
  376. package/dist/services/vc-meeting-runtime-store.js +8 -0
  377. package/dist/services/vc-meeting-runtime-store.js.map +1 -1
  378. package/dist/services/vc-meeting-send-policy.d.ts +8 -1
  379. package/dist/services/vc-meeting-send-policy.d.ts.map +1 -1
  380. package/dist/services/vc-meeting-send-policy.js +8 -0
  381. package/dist/services/vc-meeting-send-policy.js.map +1 -1
  382. package/dist/types.d.ts +40 -3
  383. package/dist/types.d.ts.map +1 -1
  384. package/dist/utils/allowed-users-apply.d.ts +76 -0
  385. package/dist/utils/allowed-users-apply.d.ts.map +1 -0
  386. package/dist/utils/allowed-users-apply.js +123 -0
  387. package/dist/utils/allowed-users-apply.js.map +1 -0
  388. package/dist/utils/allowed-users-cache.d.ts +29 -0
  389. package/dist/utils/allowed-users-cache.d.ts.map +1 -0
  390. package/dist/utils/allowed-users-cache.js +82 -0
  391. package/dist/utils/allowed-users-cache.js.map +1 -0
  392. package/dist/utils/child-env.d.ts +2 -1
  393. package/dist/utils/child-env.d.ts.map +1 -1
  394. package/dist/utils/child-env.js +2 -1
  395. package/dist/utils/child-env.js.map +1 -1
  396. package/dist/utils/cli-usage-limit.d.ts +27 -1
  397. package/dist/utils/cli-usage-limit.d.ts.map +1 -1
  398. package/dist/utils/cli-usage-limit.js +92 -20
  399. package/dist/utils/cli-usage-limit.js.map +1 -1
  400. package/dist/utils/input-gate.d.ts +2 -0
  401. package/dist/utils/input-gate.d.ts.map +1 -1
  402. package/dist/utils/input-gate.js +2 -0
  403. package/dist/utils/input-gate.js.map +1 -1
  404. package/dist/utils/runtime-build-id.d.ts +21 -0
  405. package/dist/utils/runtime-build-id.d.ts.map +1 -0
  406. package/dist/utils/runtime-build-id.js +83 -0
  407. package/dist/utils/runtime-build-id.js.map +1 -0
  408. package/dist/utils/terminal-renderer.d.ts +3 -3
  409. package/dist/utils/terminal-renderer.js +3 -3
  410. package/dist/vc-agent/cards.d.ts.map +1 -1
  411. package/dist/vc-agent/cards.js +21 -1
  412. package/dist/vc-agent/cards.js.map +1 -1
  413. package/dist/worker.js +601 -179
  414. package/dist/worker.js.map +1 -1
  415. package/dist/workflows/hostExecutors/botmux-schedule.d.ts.map +1 -1
  416. package/dist/workflows/hostExecutors/botmux-schedule.js +13 -1
  417. package/dist/workflows/hostExecutors/botmux-schedule.js.map +1 -1
  418. package/dist/workflows/migration/v2-run-archive-schema.d.ts +20 -20
  419. package/dist/workflows/migration/v2-to-v3.d.ts.map +1 -1
  420. package/dist/workflows/migration/v2-to-v3.js +5 -1
  421. package/dist/workflows/migration/v2-to-v3.js.map +1 -1
  422. package/dist/workflows/v3/architect.d.ts.map +1 -1
  423. package/dist/workflows/v3/architect.js +1 -0
  424. package/dist/workflows/v3/architect.js.map +1 -1
  425. package/dist/workflows/v3/cli-run.d.ts +9 -0
  426. package/dist/workflows/v3/cli-run.d.ts.map +1 -1
  427. package/dist/workflows/v3/cli-run.js +17 -2
  428. package/dist/workflows/v3/cli-run.js.map +1 -1
  429. package/dist/workflows/v3/goal-cli.d.ts +58 -0
  430. package/dist/workflows/v3/goal-cli.d.ts.map +1 -0
  431. package/dist/workflows/v3/goal-cli.js +430 -0
  432. package/dist/workflows/v3/goal-cli.js.map +1 -0
  433. package/dist/workflows/v3/library-materialize.d.ts.map +1 -1
  434. package/dist/workflows/v3/library-materialize.js +5 -1
  435. package/dist/workflows/v3/library-materialize.js.map +1 -1
  436. package/dist/workflows/v3/library-schema.d.ts +21 -0
  437. package/dist/workflows/v3/library-schema.d.ts.map +1 -1
  438. package/dist/workflows/v3/library-schema.js +95 -1
  439. package/dist/workflows/v3/library-schema.js.map +1 -1
  440. package/dist/workflows/v3/progress-projection.d.ts +3 -0
  441. package/dist/workflows/v3/progress-projection.d.ts.map +1 -1
  442. package/dist/workflows/v3/progress-projection.js +23 -1
  443. package/dist/workflows/v3/progress-projection.js.map +1 -1
  444. package/dist/workflows/v3/run-envelope.d.ts +46 -1
  445. package/dist/workflows/v3/run-envelope.d.ts.map +1 -1
  446. package/dist/workflows/v3/run-envelope.js +32 -1
  447. package/dist/workflows/v3/run-envelope.js.map +1 -1
  448. package/package.json +2 -2
  449. package/dist/dashboard-web/chunks/bot-defaults-page-AUKV7P4L.js +0 -10
  450. package/dist/dashboard-web/chunks/chunk-SZOB6VK2.js +0 -1
  451. package/dist/dashboard-web/chunks/connectors-page-HDQFEXZI.js +0 -1
  452. package/dist/dashboard-web/chunks/overview-page-FVR3NKAF.js +0 -1
  453. package/dist/dashboard-web/chunks/sessions-page-KOOOASIL.js +0 -2
  454. package/dist/dashboard-web/chunks/settings-page-IEBR7TGN.js +0 -2
  455. package/dist/utils/screen-analyzer.d.ts +0 -75
  456. package/dist/utils/screen-analyzer.d.ts.map +0 -1
  457. package/dist/utils/screen-analyzer.js +0 -333
  458. package/dist/utils/screen-analyzer.js.map +0 -1
package/dist/worker.js CHANGED
@@ -22,11 +22,13 @@ import { evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, devic
22
22
  import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields } from './adapters/cli/fs-policy.js';
23
23
  import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget } from './core/persistent-backend.js';
24
24
  import { readProcessStartIdentity } from './core/session-marker.js';
25
- import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff } from './services/claude-transcript.js';
25
+ import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff, isTranscriptRateLimitEvent, apiErrorMessageText } from './services/claude-transcript.js';
26
26
  import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
27
- import { shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
27
+ import { shouldEmitEmptyCompletedBridgeFallback, shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
28
28
  import { decideHardTimeoutAction, decideSettleMarkReady, shouldReleaseFirstPromptTimeout, shouldWaitForPostSessionStartPromptEvidence, shouldWriteNow, } from './utils/input-gate.js';
29
+ import { decideCodexRunnerFreshness, CodexRunnerFreshnessInputQueue, shouldHoldCodexRunnerInput, } from './services/codex-runner-freshness.js';
29
30
  import { canStartInjectionFlush, shouldDeferUserFlush, shouldFlushInjectionsFirst } from './core/inject-queue-policy.js';
31
+ import { decideRestartFollowup, settleDurableTurnForRestart } from './core/restart-followup-policy.js';
30
32
  import { stripAnsiForLog, tailChars } from './utils/crash-log.js';
31
33
  import { CodexUpdateDialogGuard } from './utils/codex-update-dialog.js';
32
34
  import { installStdioEpipeGuard, isIgnorableStreamError } from './utils/stdio-epipe-guard.js';
@@ -78,7 +80,7 @@ import { DEFAULT_RENDER_COLS, DEFAULT_RENDER_ROWS, MAX_RENDER_COLS, MAX_RENDER_R
78
80
  import { createCliAdapterSync, locateOnPath } from './adapters/cli/registry.js';
79
81
  import { buildWrappedLaunch, parseWrapperCli, isTtadkWrapper } from './setup/cli-selection.js';
80
82
  import { cliUnavailableMessage } from './setup/cli-availability.js';
81
- import { findLaunchedCliPid, scheduleWrapperRealCliPid, readComm, isBareShellComm, bareShellLaunchKind } from './core/session-discovery.js';
83
+ import { findLaunchedCliPid, scheduleWrapperRealCliPid, readComm, isBareShellComm, bareShellLaunchKind, settleLaunchComm, } from './core/session-discovery.js';
82
84
  import { codexRpcEligible, paneRunsRemoteTui, orchestrateCodexRpcInit, rolloutUserTurnMatches, decideStartupDialogAction, shouldQueueInitialPrompt, shouldPreMarkFirstTurn, killAndVerifyPersistentPane } from './codex-rpc-lifecycle.js';
83
85
  import { delay } from './utils/timing.js';
84
86
  import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds, syncClaudeResumeTargetToCwd, DEFAULT_CLAUDE_DATA_DIR } from './adapters/cli/claude-code.js';
@@ -99,7 +101,6 @@ import { DEVICE_AUTHORITY_DIRECTORY, DEVICE_CREDENTIAL_FILE, } from './platform/
99
101
  import { tmuxEnv, probeTmuxFunctionalWithRetry } from './setup/ensure-tmux.js';
100
102
  import { tmuxRestartJitterMs } from './core/tmux-recovery.js';
101
103
  import { IdleDetector } from './utils/idle-detector.js';
102
- import { ScreenAnalyzer } from './utils/screen-analyzer.js';
103
104
  import { StuckDetector, matchHookReviewScreen } from './utils/stuck-detector.js';
104
105
  import { processStuckWarningTuiKeys, shouldRearmStuckDetector } from './utils/stuck-key-guard.js';
105
106
  import { captureToPng } from './utils/screenshot-renderer.js';
@@ -107,7 +108,7 @@ import { snapshotToPng, snapshotToText, shouldCaptureScreen, isScreenSelfDriven
107
108
  import { chooseWebTerminalSeed } from './utils/web-terminal-seed.js';
108
109
  import { mergeHerdrWebSnapshot, renderHerdrWebHistory, } from './utils/herdr-web-history.js';
109
110
  import { parseWorkerRequestUrl } from './utils/worker-http.js';
110
- import { detectCliUsageLimit, usageLimitStateKey } from './utils/cli-usage-limit.js';
111
+ import { detectCliUsageLimit, usageLimitStateKey, structuredRateLimitState } from './utils/cli-usage-limit.js';
111
112
  import { uploadImageBuffer } from './utils/lark-upload.js';
112
113
  import { redactChildEnv, scrubSessionCliHomeEnv } from './utils/child-env.js';
113
114
  import { decideSubmitConfirmationAction } from './services/submit-confirmation.js';
@@ -122,6 +123,7 @@ import { fetchDaemonIpc } from './core/daemon-ipc-auth.js';
122
123
  import { withCodexAppContext } from './utils/codex-app-context.js';
123
124
  import { resolveCodexAppFinalTurnIdentity } from './adapters/cli/codex-app-turn.js';
124
125
  import { RunnerControlDecoder } from './adapters/cli/runner-control-channel.js';
126
+ import { normalizeAppRunnerFinalMarker, normalizeCodexAppLifecycleEvent, projectAppRunnerFinalIds, } from './services/codex-app-runner-protocol.js';
125
127
  import { hasMatchingManagedOriginCapability, managedOriginCapabilityPath, RELAY_ORIGIN_CAPABILITY_BASENAME, replaceManagedOriginCapabilityFile, } from './core/managed-origin-capability.js';
126
128
  import { CodexRpcEngine } from './codex-rpc-engine.js';
127
129
  // A worker must never trust an INHERITED session-level CLI home pointer
@@ -692,7 +694,7 @@ function armRpcStartupDialogDismiss() {
692
694
  let cliPidMarker = null; // path to .botmux-cli-pids/<pid>
693
695
  let seatbeltProfilePath = null; // per-session Seatbelt .sb profile to rm at exit (external-wrapper read isolation)
694
696
  let sandboxStopWatcher = null; // stop fn for the sandbox outbox watcher
695
- let sandboxCleanup = null; // unmount overlays + rm the per-session sandbox tree
697
+ let sandboxCleanup = null; // reclaim deny-mask mountpoints + rm the per-session sandbox tree
696
698
  let sandboxRelayOutbox = null;
697
699
  let sandboxRelayCapability = null;
698
700
  let readIsolationOriginCapabilityFile = null;
@@ -955,6 +957,12 @@ const IDLE_PROBE_INTERVAL_MS = 3_500;
955
957
  const IDLE_PROBE_MAX_ATTEMPTS = 24;
956
958
  let busyPatternIdleProbeTimer = null;
957
959
  let reattachIdleProbeTimer = null;
960
+ let codexRunnerFreshness = 'current';
961
+ let persistCodexRunnerBuildOnReady = false;
962
+ let activeRestartAttemptId;
963
+ /** Distinguishes a replacement's synchronous ready signal (Riff) from a late
964
+ * idle callback emitted by the backend being torn down. */
965
+ let replacementSpawnInProgress = false;
958
966
  /** The effectiveResume flag used by the most recent spawnCli call. Written
959
967
  * immediately after the two-tier fallback check so late-attach timers
960
968
  * (hermes, cursor, etc.) can read THE SAME semantics the spawn used,
@@ -1106,6 +1114,17 @@ let bareShellLaunchBlocked = false;
1106
1114
  * one-shot so it also covers a reattach onto a pane that degraded to a bare
1107
1115
  * shell. Reset per spawn in spawnCli. */
1108
1116
  let bareShellChecked = false;
1117
+ /** True only while detectBareShellLaunch() is inside its async launch-settle
1118
+ * window (settleLaunchComm's bounded ≤2s poll for a wrapper's final `exec
1119
+ * <cli>`). The message/injection flush paths already hold the isFlushing /
1120
+ * injectionFlushing mutexes across that await, but raw_input (passthrough
1121
+ * slash commands: /compact, /model, /btw) deliberately bypasses those to
1122
+ * preserve busy-delivery — so it would otherwise type into a pane whose leaf
1123
+ * is still the transient shell (or a shell already about to be blocked). This
1124
+ * latch lets raw_input defer for exactly that window without borrowing the
1125
+ * general isFlushing mutex (which would wrongly also block busy-delivery when
1126
+ * no settle is in progress). Reset per spawn in spawnCli. */
1127
+ let bareShellCheckInProgress = false;
1109
1128
  /** Ready-gate (Claude-family): holds the first prompt until the SessionStart
1110
1129
  * hook proves a cjadk-style startup selector is behind us. Claude then needs
1111
1130
  * fresh post-hook prompt evidence because sibling hooks may still be running.
@@ -1344,11 +1363,42 @@ async function runStartupCommands() {
1344
1363
  isPromptReady = false;
1345
1364
  idleDetector?.reset();
1346
1365
  }
1347
- const pendingMessages = [];
1366
+ const freshnessInputQueue = new CodexRunnerFreshnessInputQueue(() => codexRunnerFreshness, state => { codexRunnerFreshness = state; });
1367
+ const pendingMessages = freshnessInputQueue.normal;
1368
+ /** Correlation ids that this worker actually wrote into the owned Codex App
1369
+ * runner. Runner lifecycle/final markers may route only to ids in this bounded
1370
+ * local set; model/user display bytes cannot add entries. */
1371
+ const submittedCodexAppReplyTurnIds = new Set();
1372
+ const pendingCodexAppSteerAckIds = new Map();
1373
+ const acknowledgedCodexAppSteers = new Set();
1374
+ const CODEX_APP_CORRELATION_LIMIT = 256;
1375
+ function rememberBounded(set, value) {
1376
+ set.delete(value);
1377
+ set.add(value);
1378
+ while (set.size > CODEX_APP_CORRELATION_LIMIT) {
1379
+ const oldest = set.values().next().value;
1380
+ if (typeof oldest !== 'string')
1381
+ break;
1382
+ set.delete(oldest);
1383
+ }
1384
+ }
1385
+ function rememberBoundedMap(map, key, value) {
1386
+ map.delete(key);
1387
+ map.set(key, value);
1388
+ while (map.size > CODEX_APP_CORRELATION_LIMIT) {
1389
+ const oldest = map.keys().next().value;
1390
+ if (typeof oldest !== 'string')
1391
+ break;
1392
+ map.delete(oldest);
1393
+ }
1394
+ }
1395
+ function shortCorrelationId(value) {
1396
+ return value?.slice(0, 12) ?? '-';
1397
+ }
1348
1398
  /** Literal commands that arrived while native /rename owned the TUI or while
1349
1399
  * an owned CLI restart was fenced. Normal raw_input commands are still
1350
1400
  * delivered immediately (including while busy). */
1351
- const pendingRawInputs = [];
1401
+ const pendingRawInputs = freshnessInputQueue.raw;
1352
1402
  /** Latest requested canonical session title. Unlike a normal prompt this is an
1353
1403
  * administrative TUI command: never type-ahead while the agent is busy, never
1354
1404
  * open a model turn, and latest-wins if several renames arrive before idle. */
@@ -1692,6 +1742,24 @@ function writeCliPidMarker() {
1692
1742
  }
1693
1743
  }
1694
1744
  let lastStructuredBridgeActivityAtMs = 0;
1745
+ /**
1746
+ * True when this CLI has an authoritative STRUCTURED rate-limit signal in its
1747
+ * transcript (Claude family — `error:"rate_limit"`, surfaced by
1748
+ * maybeEmitStructuredRateLimit). For those CLIs the screen-text `rate`
1749
+ * heuristic is not just redundant but harmful: the model's own output or a dev
1750
+ * editing rate-limit code/tests puts phrases like "429 Too Many Requests" /
1751
+ * "exceeded retry limit" on screen, which the scraper cannot distinguish from a
1752
+ * real limit. So we suppress the screen-scan `rate` verdict and let the
1753
+ * structured path be the sole authority. `usage` (quota "hit your limit …")
1754
+ * has no structured equivalent yet, so it still comes from the screen.
1755
+ *
1756
+ * reliableTurnTerminal is exactly the "transcript-backed" capability flag
1757
+ * (claude-code / seed set it); non-transcript CLIs (Codex, gemini, …) keep the
1758
+ * screen scanner as their only rate-limit signal.
1759
+ */
1760
+ function structuredRateLimitAuthoritative() {
1761
+ return cliAdapter?.reliableTurnTerminal === true;
1762
+ }
1695
1763
  // Per-turn usage-limit state machine. Owns the turn counter plus the
1696
1764
  // "did this turn hit a limit" / "suppress a stale retry-ready banner" flags, so
1697
1765
  // classify()'s state writes are explicit method calls rather than hidden
@@ -1710,7 +1778,7 @@ function createUsageLimitTracker() {
1710
1778
  beginTurn(snapshot) {
1711
1779
  turnSeq++;
1712
1780
  detectedTurn = undefined;
1713
- const current = detectCliUsageLimit(snapshot);
1781
+ const current = detectCliUsageLimit(snapshot, undefined, { suppressRateKind: structuredRateLimitAuthoritative() });
1714
1782
  suppressedRetryReadyKey = current.limited && current.retryReady
1715
1783
  ? usageLimitStateKey(current)
1716
1784
  : undefined;
@@ -1719,7 +1787,7 @@ function createUsageLimitTracker() {
1719
1787
  // Map a runtime status to a usage-limit-aware status, recording whether this
1720
1788
  // turn hit a limit (read back via detectedThisTurn).
1721
1789
  classify(content, status) {
1722
- const detected = detectCliUsageLimit(content);
1790
+ const detected = detectCliUsageLimit(content, undefined, { suppressRateKind: structuredRateLimitAuthoritative() });
1723
1791
  if (!detected.limited)
1724
1792
  return { status };
1725
1793
  const key = usageLimitStateKey(detected);
@@ -1733,6 +1801,15 @@ function createUsageLimitTracker() {
1733
1801
  detectedThisTurn(seq) {
1734
1802
  return detectedTurn === seq;
1735
1803
  },
1804
+ // Record a limit that came from a STRUCTURED signal (transcript error
1805
+ // record) rather than screen text. Mirrors classify()'s state writes so
1806
+ // the tracker stays coherent: mark this turn as having hit a limit (read
1807
+ // by detectedThisTurn for the submit-confirmation recheck) and clear any
1808
+ // stale retry-ready suppression. The actual emit is done by the caller.
1809
+ noteStructuredLimit() {
1810
+ suppressedRetryReadyKey = undefined;
1811
+ detectedTurn = turnSeq;
1812
+ },
1736
1813
  };
1737
1814
  }
1738
1815
  const usageLimitTracker = createUsageLimitTracker();
@@ -1802,6 +1879,11 @@ const bridgeSecondaryPaths = new Map(); // path → offset
1802
1879
  let bridgeOffset = 0;
1803
1880
  let bridgePendingTail = '';
1804
1881
  const bridgeQueue = new BridgeTurnQueue();
1882
+ /** uuids of Claude transcript rate-limit records we've already turned into a
1883
+ * `limited` emit. drainTranscript re-reads from offset 0 on truncation /
1884
+ * rotation and emitReadyTurns re-drains from 0, so the same rate_limit record
1885
+ * can resurface; keying on the record's stable uuid makes the emit idempotent. */
1886
+ const emittedRateLimitUuids = new Set();
1805
1887
  let bridgeWatcher = null;
1806
1888
  let bridgeFallbackTimer = null;
1807
1889
  let herdrAdoptBridgeQuietTimer = null;
@@ -1895,6 +1977,9 @@ function formatHeadlessLocalTurnContent(assistantText) {
1895
1977
  const a = truncatePreambleText(assistantText.trim(), LOCAL_TURN_ASSISTANT_MAX);
1896
1978
  return a || null;
1897
1979
  }
1980
+ function emptyCompletedBridgeFallbackContent() {
1981
+ return t('worker.empty_final_completed', { cliName: cliName() });
1982
+ }
1898
1983
  // ─── Bridge fallback marker (non-adopt) ────────────────────────────────────
1899
1984
  //
1900
1985
  // `botmux send` (cli.ts cmdSend) appends a line
@@ -2732,11 +2817,45 @@ function bridgeIngest() {
2732
2817
  if (result.events.length > 0)
2733
2818
  lastStructuredBridgeActivityAtMs = Date.now();
2734
2819
  bridgeQueue.ingest(result.events, bridgeJsonlPath);
2820
+ // Structured rate-limit: Claude Code writes an `error:"rate_limit"` record
2821
+ // at the turn's terminal boundary. This is the authoritative "limited"
2822
+ // signal — read it here (event-driven, once per record) instead of scraping
2823
+ // the TUI. The queue already skips it as an assistant reply.
2824
+ maybeEmitStructuredRateLimit(result.events);
2735
2825
  // Transcript terminal markers are authoritative and may settle a durable
2736
2826
  // turn immediately. Do not wait for the screen prompt: permission/AskUser
2737
2827
  // surfaces can resemble idle, while an explicit JSONL boundary cannot.
2738
2828
  emitReadyTurns({ explicitTerminalOnly: true });
2739
2829
  }
2830
+ /** Scan newly-drained Claude transcript events for a structured rate-limit
2831
+ * record and, on the first unseen one, emit a `limited` screen_update so the
2832
+ * session surfaces in Dashboard「需要你」with a retry countdown — identical
2833
+ * wire shape to the screen-text detector's classify() output, so the daemon /
2834
+ * card / persistence paths need no change. Claude-only (bridgeQueue is the
2835
+ * Claude bridge; Codex uses codexBridgeQueue and has no structured 429). */
2836
+ function maybeEmitStructuredRateLimit(events) {
2837
+ for (const ev of events) {
2838
+ if (!ev.uuid || emittedRateLimitUuids.has(ev.uuid))
2839
+ continue;
2840
+ if (!isTranscriptRateLimitEvent(ev))
2841
+ continue;
2842
+ emittedRateLimitUuids.add(ev.uuid);
2843
+ // Prefer a clock parsed from the record's own text ("... resets 10:40pm");
2844
+ // fall back to the shared bucketed cooldown when it carries none.
2845
+ const usageLimit = structuredRateLimitState(apiErrorMessageText(ev));
2846
+ usageLimitTracker.noteStructuredLimit();
2847
+ send({
2848
+ type: 'screen_update',
2849
+ content: currentUsageLimitSnapshot(),
2850
+ status: 'limited',
2851
+ usageLimit,
2852
+ turnId: currentBotmuxTurnId,
2853
+ dispatchAttempt: currentBotmuxDispatchAttempt,
2854
+ });
2855
+ log(`Structured rate-limit detected in Claude transcript (uuid=${ev.uuid.substring(0, 8)}, retryLabel=${usageLimit.retryLabel}) → emitted limited state.`);
2856
+ return; // one limited emit per ingest is enough; state key is stable
2857
+ }
2858
+ }
2740
2859
  function performBridgeIngestAndScheduleQuietEmit() {
2741
2860
  const beforePath = bridgeJsonlPath;
2742
2861
  const beforeOffset = bridgeOffset;
@@ -3265,6 +3384,7 @@ function hermesBridgeIngest() {
3265
3384
  // `/clear`-rotates mid-batch; the daemon accumulates them into its authorized
3266
3385
  // set, so a completed turn from an earlier source is not dropped as foreign.
3267
3386
  for (const boundSourceSessionId of filtered.newlyBoundSourceSessionIds) {
3387
+ persistCliSessionId(boundSourceSessionId);
3268
3388
  send({ type: 'bridge_source_session', bridge: 'hermes', sourceSessionId: boundSourceSessionId });
3269
3389
  log(`Hermes bridge bound sourceSessionId=${boundSourceSessionId}`);
3270
3390
  }
@@ -3677,6 +3797,37 @@ function codexBridgeDrainAndMaybeEmit(opts = {}) {
3677
3797
  }
3678
3798
  emitReadyCodexTurns();
3679
3799
  }
3800
+ /** Before we emit a fail-closed `ambiguous` terminal for a durable turn that a
3801
+ * CLI kill is about to interrupt, give an already-persisted `completed` record
3802
+ * a chance to win the worker-local terminal deduper. `reliableTurnTerminal`
3803
+ * CLIs (claude-code / codex / grok / traex) durably append their terminal line
3804
+ * to the transcript; if that line landed microseconds before the kill but the
3805
+ * fs.watch/1s poller hasn't consumed it yet, an unconditional `ambiguous` would
3806
+ * claim the deduper first and needlessly mark a turn that actually completed →
3807
+ * same durable key re-dispatchable → external side effect executed twice.
3808
+ * Draining here publishes that `completed` (claiming the deduper) so the later
3809
+ * `ambiguous` becomes a no-op. Shared by the CLI `onExit` path and the daemon
3810
+ * `restart` IPC path — both must drain identically before their ambiguous emit. */
3811
+ function drainReliableTerminalBeforeInterrupt() {
3812
+ if (cliAdapter?.reliableTurnTerminal !== true)
3813
+ return;
3814
+ if (bridgeJsonlPath) {
3815
+ try {
3816
+ bridgeDrainAndMaybeEmit();
3817
+ }
3818
+ catch (err) {
3819
+ log(`Bridge terminal drain failed: ${err.message}`);
3820
+ }
3821
+ }
3822
+ if (codexBridgeFallbackActive()) {
3823
+ try {
3824
+ codexBridgeDrainAndMaybeEmit({ signalIdle: false });
3825
+ }
3826
+ catch (err) {
3827
+ log(`Codex bridge terminal drain failed: ${err.message}`);
3828
+ }
3829
+ }
3830
+ }
3680
3831
  function emitReadyCodexTurns() {
3681
3832
  const ready = codexBridgeQueue.drainEmittable();
3682
3833
  if (ready.length === 0)
@@ -3700,11 +3851,22 @@ function emitReadyCodexTurns() {
3700
3851
  : undefined;
3701
3852
  for (let i = 0; i < ready.length; i++) {
3702
3853
  const turn = ready[i];
3703
- if (!turn.finalText)
3704
- continue;
3705
3854
  const sourceHermesSessionId = structuredBridgeIsHermes() ? turn.sourceSessionId : undefined;
3706
3855
  const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
3707
- if (shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: turn.isLocal, finalText: turn.finalText }, nextBoundaryMs, markers, adoptMode)) {
3856
+ const gateInput = {
3857
+ markTimeMs: turn.markTimeMs,
3858
+ isLocal: turn.isLocal,
3859
+ finalText: turn.finalText,
3860
+ terminalStatus: turn.terminalStatus,
3861
+ };
3862
+ const content = turn.finalText && turn.finalText.trim()
3863
+ ? turn.finalText
3864
+ : shouldEmitEmptyCompletedBridgeFallback(gateInput, nextBoundaryMs, markers, adoptMode)
3865
+ ? emptyCompletedBridgeFallbackContent()
3866
+ : '';
3867
+ if (!content)
3868
+ continue;
3869
+ if (shouldSuppressBridgeEmit(gateInput, nextBoundaryMs, markers, adoptMode)) {
3708
3870
  log(`Codex bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (gate)`);
3709
3871
  continue;
3710
3872
  }
@@ -3713,7 +3875,7 @@ function emitReadyCodexTurns() {
3713
3875
  // so the Lark thread sees a complete exchange instead of an orphan
3714
3876
  // reply. formatLocalTurnFields caps both texts to keep within
3715
3877
  // Lark's per-message limit; daemon owns the card chrome.
3716
- const fields = formatLocalTurnFields(turn.userText ?? '', turn.finalText);
3878
+ const fields = formatLocalTurnFields(turn.userText ?? '', content);
3717
3879
  if (!fields)
3718
3880
  continue;
3719
3881
  send({
@@ -3731,7 +3893,7 @@ function emitReadyCodexTurns() {
3731
3893
  send({
3732
3894
  type: 'final_output',
3733
3895
  ...(sourceHermesSessionId ? { sourceHermesSessionId } : {}),
3734
- content: turn.finalText,
3896
+ content,
3735
3897
  lastUuid: turn.turnId,
3736
3898
  turnId: turn.turnId,
3737
3899
  ...(turn.dispatchAttempt !== undefined ? { dispatchAttempt: turn.dispatchAttempt } : {}),
@@ -3853,8 +4015,9 @@ let renderRows = PTY_ROWS;
3853
4015
  // ─── Headless Terminal for Screen Capture ────────────────────────────────────
3854
4016
  let renderer = null;
3855
4017
  /** Most recent unfiltered viewport text — kept in sync by the screen_update
3856
- * timer for pipe-pane backends so ScreenAnalyzer (which is synchronous) has
3857
- * a fresh snapshot to read without needing its own tmux capture-pane call. */
4018
+ * timer for pipe-pane backends so usage-limit detection and the CoCo picker
4019
+ * have a fresh snapshot to read without needing their own tmux capture-pane
4020
+ * call. (Historically also fed the AI ScreenAnalyzer, now removed.) */
3858
4021
  let lastAnalyzerSnapshot = '';
3859
4022
  let screenUpdateTimer = null;
3860
4023
  const SCREEN_UPDATE_INTERVAL_MS = 2_000;
@@ -4028,14 +4191,14 @@ function parkCrashDiagnosticTerminal(code, signal) {
4028
4191
  // so the diagnostic shell stays visible. flushPending's retry path restarts
4029
4192
  // both when the next message respawns the CLI.
4030
4193
  stopScreenUpdates();
4031
- stopScreenAnalyzer();
4032
4194
  stopStuckDetector();
4033
4195
  log(`Crash diagnostic tmux session parked at ${TmuxBackend.diagnosticSessionName(sessionId)}`);
4034
4196
  return true;
4035
4197
  }
4036
- // ─── Screen Analyzer (AI-based TUI prompt detection) ────────────────────────
4037
- let screenAnalyzer = null;
4038
- /** When true, user messages are queued because a TUI prompt is active */
4198
+ // ─── TUI prompt blocking state ──────────────────────────────────────────────
4199
+ /** When true, user messages are queued because a TUI prompt is active. Set by
4200
+ * the ask-hook / CoCo picker paths (driveCocoPicker, handleTuiKeys); cleared
4201
+ * when the prompt resolves or a Lark/terminal input overrides it. */
4039
4202
  let tuiPromptBlocking = false;
4040
4203
  function isWorkflowWorker() {
4041
4204
  return process.env.BOTMUX_WORKFLOW === '1';
@@ -4097,54 +4260,6 @@ function maybeEmitWorkflowTranscriptOutput() {
4097
4260
  });
4098
4261
  log('Workflow PTY transcript final_output emitted');
4099
4262
  }
4100
- function startScreenAnalyzer() {
4101
- const sa = config.screenAnalyzer;
4102
- log(`ScreenAnalyzer config: enabled=${sa.enabled}, baseUrl=${sa.baseUrl ? 'set' : 'empty'}, model=${sa.model || 'empty'}, extraHeaders=${JSON.stringify(sa.extraHeaders)}`);
4103
- if (!sa.enabled || !sa.baseUrl || !sa.apiKey || !sa.model)
4104
- return;
4105
- screenAnalyzer = new ScreenAnalyzer({
4106
- baseUrl: sa.baseUrl,
4107
- apiKey: sa.apiKey,
4108
- model: sa.model,
4109
- intervalMs: sa.intervalMs,
4110
- stableCount: sa.stableCount,
4111
- snapshotMaxChars: sa.snapshotMaxChars,
4112
- extraHeaders: sa.extraHeaders,
4113
- extraBody: sa.extraBody,
4114
- }, {
4115
- getSnapshot: () => {
4116
- // ScreenAnalyzer is called every ~5s for TUI-prompt detection. We
4117
- // can't make this async without overhauling the analyzer, so cache
4118
- // the last pipe-pane text snapshot here and refresh it eagerly.
4119
- // For pipe-pane backends, the cache is repopulated by the screen
4120
- // update timer; for others, fall through to the long-lived renderer.
4121
- return lastAnalyzerSnapshot || renderer?.rawSnapshot() || '';
4122
- },
4123
- onAnalyzing: () => { },
4124
- onTuiPrompt: (description, options, multiSelect) => {
4125
- tuiPromptBlocking = true;
4126
- send({ type: 'tui_prompt', description, options, multiSelect, turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt });
4127
- },
4128
- onTuiPromptResolved: (selectedText) => {
4129
- tuiPromptBlocking = false;
4130
- send({
4131
- type: 'tui_prompt_resolved',
4132
- selectedText,
4133
- turnId: currentBotmuxTurnId,
4134
- dispatchAttempt: currentBotmuxDispatchAttempt,
4135
- });
4136
- // Flush any messages that were queued during the prompt
4137
- flushPending();
4138
- },
4139
- log,
4140
- });
4141
- screenAnalyzer.start();
4142
- }
4143
- function stopScreenAnalyzer() {
4144
- screenAnalyzer?.dispose();
4145
- screenAnalyzer = null;
4146
- tuiPromptBlocking = false;
4147
- }
4148
4263
  // ─── Stuck Detector (AI-free fallback for blocked CLI states) ───────────────
4149
4264
  let stuckDetector = null;
4150
4265
  // Monotonic counter bumped on every CLI/backend (re)start within this worker.
@@ -4341,8 +4456,6 @@ async function captureAndUpload() {
4341
4456
  return;
4342
4457
  }
4343
4458
  let status = isPromptReady ? 'idle' : 'working';
4344
- if (screenAnalyzer?.isAnalyzing)
4345
- status = 'analyzing';
4346
4459
  send({
4347
4460
  type: 'screenshot_uploaded',
4348
4461
  imageKey,
@@ -4432,14 +4545,12 @@ function handleTermAction(key) {
4432
4545
  else if (PTY_SEQ_MAP[key]) {
4433
4546
  backend.write(PTY_SEQ_MAP[key]);
4434
4547
  }
4435
- // ESC/Ctrl-C/Enter likely ends an active TUI prompt. The analyzer
4436
- // won't re-analyze while promptActive=true, so un-wedge both flags here.
4437
- // Without this, dismissing an AskUserQuestion dialog via the quick-key
4438
- // button leaves tuiPromptBlocking=true forever and silently queues every
4439
- // subsequent user message.
4548
+ // ESC/Ctrl-C/Enter likely ends an active TUI prompt. Un-wedge the blocking
4549
+ // flag here without this, dismissing an AskUserQuestion dialog via the
4550
+ // quick-key button leaves tuiPromptBlocking=true forever and silently queues
4551
+ // every subsequent user message.
4440
4552
  if (tuiPromptBlocking && (key === 'esc' || key === 'ctrlc' || key === 'enter')) {
4441
4553
  tuiPromptBlocking = false;
4442
- screenAnalyzer?.notifySelection(`term_action:${key}`);
4443
4554
  void flushPending();
4444
4555
  }
4445
4556
  log(`Term action: ${key}`);
@@ -4486,18 +4597,18 @@ async function handleTuiKeys(keys, isFinal) {
4486
4597
  isPromptReady = false;
4487
4598
  idleDetector?.reset();
4488
4599
  }
4489
- screenAnalyzer?.notifySelection('final');
4490
4600
  }
4491
4601
  log(`TUI keys: ${keys.join(' ')}${isFinal ? ' (final)' : ''}`);
4492
4602
  return true;
4493
4603
  }
4494
4604
  // 待注入的 TUI 命令队列。生命周期绑定当前 CLI 进程:killCli() 会清空它,
4495
4605
  // 防止 restart 后残留命令重放进新 CLI——新增清理状态时记得同步那里。
4496
- // barrier=true 标记该注入携带 updateWorkingDir(cwd-move,如 /cd):markPromptReady
4497
- // barrier 注入必须先于本次 pending 用户消息落地,防止用户消息在「记录已是新
4498
- // cwd、进程仍在旧 cwd」的窗口里被写进旧目录的 CLI。排队策略判定收敛到
4499
- // core/inject-queue-policy.ts(shouldDeferUserFlush / shouldFlushInjectionsFirst)
4500
- // 以获得可单测的纯函数单元——本文件只持有队列状态。
4606
+ // barrier 语义:barrier=true 的注入必须先于本次 pending 用户消息落地。历史上
4607
+ // 唯一的 barrier 来源是 cwd-move 的 /cd 注入;角色切换改为 restart respawn 后
4608
+ // 现存发送方(/slash 白名单注入)全部 barrier=false,机制保留供未来有顺序
4609
+ // 依赖的注入复用。排队策略判定收敛到 core/inject-queue-policy.ts
4610
+ // (shouldDeferUserFlush / shouldFlushInjectionsFirst)以获得可单测的纯函数
4611
+ // 单元——本文件只持有队列状态。
4501
4612
  const pendingInjections = [];
4502
4613
  let injectionFlushing = false;
4503
4614
  /** 排队注入一行 TUI 命令:idle(isPromptReady)时经 sendRawCommandLineSerially 敲入。 */
@@ -4534,9 +4645,18 @@ async function flushPendingInjections() {
4534
4645
  // path sends first runs the detection.
4535
4646
  if (!bareShellChecked) {
4536
4647
  bareShellChecked = true;
4537
- if (detectBareShellLaunch())
4648
+ if (await detectBareShellLaunch())
4538
4649
  return; // finally{} releases the mutex; queue stays
4539
4650
  }
4651
+ // The detector's settle await can span a restart's tmux jitter window
4652
+ // (cliRestartInProgress true, old backend still alive). Re-check the fence
4653
+ // before shift()/write so a queued injection never lands in a CLI already
4654
+ // being torn down. The pending injections are then handled by killCli's
4655
+ // restart policy — which drops them (barrier /cd is already durable in
4656
+ // workingDir; non-barrier injects are best-effort and not replayed
4657
+ // cross-process), unlike pendingMessages which killCli preserves.
4658
+ if (cliRestartInProgress)
4659
+ return;
4540
4660
  const item = pendingInjections.shift();
4541
4661
  const cmd = item.command;
4542
4662
  isPromptReady = false;
@@ -4596,7 +4716,6 @@ async function handleTuiTextInput(keys, text) {
4596
4716
  isPromptReady = false;
4597
4717
  idleDetector?.reset();
4598
4718
  }
4599
- screenAnalyzer?.notifySelection('text-input');
4600
4719
  // Wait briefly so the cursor position is stable before pasting
4601
4720
  await new Promise(r => setTimeout(r, 200));
4602
4721
  // Step 3: write text via cliAdapter (auto-switches to text mode + submits with Enter)
@@ -4755,9 +4874,74 @@ function handleCodexAppMarker(body) {
4755
4874
  persistCliSessionId(payload.threadId);
4756
4875
  return;
4757
4876
  }
4758
- if (kind === 'final' && typeof payload.content === 'string') {
4759
- const startedAtMs = typeof payload.startedAtMs === 'number' ? payload.startedAtMs : undefined;
4760
- const completedAtMs = typeof payload.completedAtMs === 'number' ? payload.completedAtMs : Date.now();
4877
+ if (kind === 'lifecycle') {
4878
+ const event = normalizeCodexAppLifecycleEvent(payload);
4879
+ if (!event) {
4880
+ log(`${cliName()} rejected malformed lifecycle marker`);
4881
+ return;
4882
+ }
4883
+ log(`${cliName()} lifecycle kind=${event.kind}`
4884
+ + ` appTurn=${shortCorrelationId(event.appTurnId)}`
4885
+ + ` replyTurn=${shortCorrelationId(event.replyTurnId)}`
4886
+ + `${event.queueLength !== undefined ? ` queue=${event.queueLength}` : ''}`);
4887
+ if (!event.replyTurnId || !submittedCodexAppReplyTurnIds.has(event.replyTurnId))
4888
+ return;
4889
+ if (event.kind === 'steer_attempt') {
4890
+ rememberBoundedMap(pendingCodexAppSteerAckIds, event.replyTurnId, event.appTurnId);
4891
+ return;
4892
+ }
4893
+ const steerKey = `${event.appTurnId}\0${event.replyTurnId}`;
4894
+ if (event.kind !== 'steer_accepted'
4895
+ || pendingCodexAppSteerAckIds.get(event.replyTurnId) !== event.appTurnId
4896
+ || acknowledgedCodexAppSteers.has(steerKey))
4897
+ return;
4898
+ pendingCodexAppSteerAckIds.delete(event.replyTurnId);
4899
+ rememberBounded(acknowledgedCodexAppSteers, steerKey);
4900
+ send({
4901
+ type: 'steer_accepted',
4902
+ appTurnId: event.appTurnId,
4903
+ turnId: event.replyTurnId,
4904
+ });
4905
+ return;
4906
+ }
4907
+ if (kind === 'final') {
4908
+ const marker = normalizeAppRunnerFinalMarker(payload);
4909
+ if (!marker) {
4910
+ log(`${cliName()} rejected malformed final marker`);
4911
+ return;
4912
+ }
4913
+ const startedAtMs = marker.startedAtMs;
4914
+ const completedAtMs = marker.completedAtMs ?? Date.now();
4915
+ if (marker.appTurnId) {
4916
+ const trustedReplyTurnId = marker.replyTurnId
4917
+ && submittedCodexAppReplyTurnIds.has(marker.replyTurnId)
4918
+ ? marker.replyTurnId
4919
+ : undefined;
4920
+ if (marker.replyTurnId && !trustedReplyTurnId) {
4921
+ log(`${cliName()} ignored unsubmitted final reply route `
4922
+ + `(replyTurn=${shortCorrelationId(marker.replyTurnId)})`);
4923
+ }
4924
+ const identity = projectAppRunnerFinalIds({ ...marker, replyTurnId: trustedReplyTurnId }, currentBotmuxTurnId, `${lastInitConfig?.cliId ?? 'app'}-${Date.now()}`);
4925
+ if (marker.appTurnId !== identity.turnId) {
4926
+ log(`${cliName()} app turn ${shortCorrelationId(marker.appTurnId)} mapped `
4927
+ + `to botmux turn ${shortCorrelationId(identity.turnId)}`);
4928
+ }
4929
+ const dispatchAttempt = currentBotmuxDispatchAttempt;
4930
+ if (startedAtMs !== undefined && shouldSuppressBridgeEmit({ markTimeMs: startedAtMs, isLocal: false, finalText: marker.content }, completedAtMs + 5_001, readSendMarkers(), false)) {
4931
+ log(`${cliName()} final_output suppressed (model already called botmux send)`);
4932
+ emitTurnTerminal(identity.turnId, 'completed', undefined, dispatchAttempt);
4933
+ return;
4934
+ }
4935
+ send({
4936
+ type: 'final_output',
4937
+ content: marker.content,
4938
+ lastUuid: identity.lastUuid,
4939
+ turnId: identity.turnId,
4940
+ ...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
4941
+ });
4942
+ emitTurnTerminal(identity.turnId, 'completed', undefined, dispatchAttempt);
4943
+ return;
4944
+ }
4761
4945
  // Codex App keeps the app-server-generated id separately for diagnostics.
4762
4946
  // Routing must use its stable clientUserMessageId marker; legacy envelopes
4763
4947
  // intentionally omit it and fall back to the worker's frozen botmux turn.
@@ -4782,7 +4966,7 @@ function handleCodexAppMarker(body) {
4782
4966
  // equality for compatibility, but can never select another attempt.
4783
4967
  const dispatchAttempt = currentBotmuxDispatchAttempt;
4784
4968
  if (startedAtMs !== undefined) {
4785
- const sentByModel = shouldSuppressBridgeEmit({ markTimeMs: startedAtMs, isLocal: false, finalText: payload.content }, completedAtMs + 5_001, readSendMarkers(), false);
4969
+ const sentByModel = shouldSuppressBridgeEmit({ markTimeMs: startedAtMs, isLocal: false, finalText: marker.content }, completedAtMs + 5_001, readSendMarkers(), false);
4786
4970
  if (sentByModel) {
4787
4971
  log(`${cliName()} final_output suppressed (model already called botmux send)`);
4788
4972
  emitTurnTerminal(turnId, 'completed', undefined, dispatchAttempt);
@@ -4791,7 +4975,7 @@ function handleCodexAppMarker(body) {
4791
4975
  }
4792
4976
  send({
4793
4977
  type: 'final_output',
4794
- content: payload.content,
4978
+ content: marker.content,
4795
4979
  lastUuid: turnId,
4796
4980
  turnId,
4797
4981
  ...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
@@ -4945,6 +5129,10 @@ function markPromptReadyFromPty() {
4945
5129
  function markPromptReady() {
4946
5130
  if (isPromptReady)
4947
5131
  return; // guard against duplicate calls
5132
+ if (cliRestartInProgress && !replacementSpawnInProgress) {
5133
+ log('Ignoring prompt-ready from backend generation being replaced');
5134
+ return;
5135
+ }
4948
5136
  stopBusyPatternIdleProbe();
4949
5137
  // Ready-gate: a startup selector's ❯ (cjadk et al.) falsely matches
4950
5138
  // readyPattern → the IdleDetector fires idle while the CLI is NOT actually at
@@ -4974,6 +5162,14 @@ function markPromptReady() {
4974
5162
  log('Idle detected during ready-gate settle; deferring prompt-ready until settle completes');
4975
5163
  return;
4976
5164
  }
5165
+ const freshnessAction = freshnessInputQueue.onPromptReady();
5166
+ if (freshnessAction === 'reload') {
5167
+ log('Stale Codex App runner became idle; replacing it before releasing queued input');
5168
+ void restartCliProcess('stale runner reached idle', { immediate: true, preservePending: true });
5169
+ return;
5170
+ }
5171
+ if (freshnessAction === 'ignore')
5172
+ return;
4977
5173
  isPromptReady = true;
4978
5174
  clearSessionRenameInFlight();
4979
5175
  // An old backend can still report idle while its async teardown is running.
@@ -5005,6 +5201,33 @@ function markPromptReady() {
5005
5201
  renderer?.markNewTurn(); // exclude history replay from streaming card
5006
5202
  }
5007
5203
  send({ type: 'prompt_ready' });
5204
+ if (persistCodexRunnerBuildOnReady
5205
+ && lastInitConfig?.cliId === 'codex-app'
5206
+ && lastInitConfig.runnerBuildId) {
5207
+ send({ type: 'runner_build_ready', runnerBuildId: lastInitConfig.runnerBuildId });
5208
+ persistCodexRunnerBuildOnReady = false;
5209
+ }
5210
+ if (activeRestartAttemptId) {
5211
+ // Defense in depth: only report a successful restart when a replacement
5212
+ // backend is actually installed. Every legitimate ready path assigns
5213
+ // `backend` before firing (spawnCli sets it, then idle/PTY callbacks run);
5214
+ // a stray callback that reached here with no backend (e.g. a late stale
5215
+ // generation slipping through a future gate change) must NOT claim success
5216
+ // and consume the attempt id — leave it for the real replacement or the
5217
+ // coordinator timeout.
5218
+ if (backend) {
5219
+ send({
5220
+ type: 'restart_result',
5221
+ attemptId: activeRestartAttemptId,
5222
+ status: 'succeeded',
5223
+ category: 'prompt_ready',
5224
+ });
5225
+ activeRestartAttemptId = undefined;
5226
+ }
5227
+ else {
5228
+ log('prompt-ready with no backend installed — deferring restart success receipt');
5229
+ }
5230
+ }
5008
5231
  // Send immediate idle snapshot so Lark card reflects idle status.
5009
5232
  // BUT: skip when messages are pending — flushPending() will immediately
5010
5233
  // make the CLI busy, so the idle state is transient and shouldn't appear
@@ -5014,13 +5237,11 @@ function markPromptReady() {
5014
5237
  const { content } = renderer.snapshot();
5015
5238
  send({ type: 'screen_update', content, ...usageLimitTracker.classify(content, 'idle'), turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt });
5016
5239
  }
5017
- // cwd-move 注入(barrier=true,如 /cd)必须先于本次 pending 用户消息落地:
5018
- // 用户在 bot 发完「已切换角色」确认后立刻发下一条消息是最常见路径——若
5019
- // flushPending 先跑,该消息会在 barrier 注入之前被写进旧 cwd 的 CLI(daemon
5020
- // 记录已是新目录,实际执行仍在旧目录)。跳过本次 flushPending 不会饿死用户
5021
- // 消息:flushPending 自身的 injectionFlushing 守卫会挡住并发写入,
5022
- // flushPendingInjections 的 finally 会在注入完成、CLI 重新 idle 后补踢一次
5023
- // flushPending 排空 pendingMessages。
5240
+ // barrier 注入必须先于本次 pending 用户消息落地(现存发送方均 barrier=false,
5241
+ // 该分支目前不触发;机制保留见 pendingInjections 声明处注释)。跳过本次
5242
+ // flushPending 不会饿死用户消息:flushPending 自身的 injectionFlushing 守卫
5243
+ // 会挡住并发写入,flushPendingInjections finally 会在注入完成、CLI 重新
5244
+ // idle 后补踢一次 flushPending 排空 pendingMessages。
5024
5245
  if (shouldFlushInjectionsFirst(pendingInjections)) {
5025
5246
  void flushPendingInjections();
5026
5247
  }
@@ -5238,19 +5459,41 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
5238
5459
  * and the pty/herdr backends (which `exec` the CLI directly — getChildPid is the
5239
5460
  * CLI itself, never a shell).
5240
5461
  *
5241
- * Returns true when a bare-shell launch was detected (caller must NOT flush).
5462
+ * Returns true when a persistent bare-shell launch was detected (caller must
5463
+ * NOT flush). A transient wrapper shell gets a bounded chance to exec the CLI.
5242
5464
  */
5243
- function detectBareShellLaunch() {
5465
+ async function detectBareShellLaunch() {
5244
5466
  if (bareShellLaunchBlocked)
5245
5467
  return true;
5246
5468
  if (lastInitConfig?.adoptMode)
5247
5469
  return false; // observing an existing pane, not launching
5248
5470
  if (lastInitConfig?.wrapperCli)
5249
5471
  return false; // launcher legitimately wraps the CLI (transient shell shim)
5250
- const pid = backend?.getChildPid?.();
5251
- if (!pid)
5472
+ // Hold the raw_input passthrough latch across the bounded settle poll: the
5473
+ // await below yields the event loop for up to 2s, and a concurrent raw_input
5474
+ // IPC handler (which bypasses isFlushing to preserve busy-delivery) must not
5475
+ // type into the pane while its leaf is still the unsettled shell.
5476
+ bareShellCheckInProgress = true;
5477
+ let comm;
5478
+ try {
5479
+ comm = await settleLaunchComm(() => {
5480
+ const pid = backend?.getChildPid?.();
5481
+ return pid ? readComm(pid) : undefined;
5482
+ });
5483
+ }
5484
+ finally {
5485
+ bareShellCheckInProgress = false;
5486
+ }
5487
+ // A restart can begin while we're suspended in the settle await: tmux staggers
5488
+ // teardown behind a 250–1999ms jitter, so cliRestartInProgress is already true
5489
+ // but the old backend is still alive. Do NOT classify that torn-down pane as a
5490
+ // failed launch — it would set the persistent bareShellLaunchBlocked and emit a
5491
+ // misdiagnosis card for a CLI that's about to be replaced. Return healthy; the
5492
+ // caller's own post-await restart fence keeps the queue intact for the
5493
+ // replacement generation (whose spawnCli resets bareShellChecked and re-runs
5494
+ // this check).
5495
+ if (cliRestartInProgress)
5252
5496
  return false;
5253
- const comm = readComm(pid);
5254
5497
  if (!isBareShellComm(comm))
5255
5498
  return false; // CLI (rust/go/node) is running — healthy launch
5256
5499
  // Bare shell is the pane leaf → the CLI never launched. Tier the message on
@@ -5299,6 +5542,8 @@ async function flushPending() {
5299
5542
  // backend's idle/task-done callback) write across that restart boundary.
5300
5543
  if (cliRestartInProgress)
5301
5544
  return;
5545
+ if (shouldHoldCodexRunnerInput(codexRunnerFreshness))
5546
+ return;
5302
5547
  if (isFlushing)
5303
5548
  return; // while loop in active flush will pick up new messages
5304
5549
  if (!backend || !cliAdapter)
@@ -5407,10 +5652,19 @@ async function flushPending() {
5407
5652
  // doesn't get them typed into the bare shell first.
5408
5653
  if (!bareShellChecked) {
5409
5654
  bareShellChecked = true;
5410
- if (detectBareShellLaunch()) {
5655
+ if (await detectBareShellLaunch()) {
5411
5656
  return; // finally{} releases the mutex; pendingMessages stay queued, untouched
5412
5657
  }
5413
5658
  }
5659
+ // detectBareShellLaunch() awaits a bounded settle poll; a restart can begin
5660
+ // during that yield (tmux staggers teardown behind a 250–1999ms jitter, so
5661
+ // cliRestartInProgress flips true while the old backend is still alive). Do
5662
+ // not write startup commands / rename / user prompts into a CLI already
5663
+ // promised to teardown — re-check the restart fence now, before any shift or
5664
+ // write. The queue is untouched; the replacement generation's markPromptReady
5665
+ // re-invokes flushPending.
5666
+ if (cliRestartInProgress)
5667
+ return; // finally{} releases the mutex; queue stays
5414
5668
  // One-shot per spawn: type the bot's startup commands (e.g. `/effort
5415
5669
  // ultracode`) into the CLI before the first user prompt drains. Both ready
5416
5670
  // paths funnel through flushPending — the ready-gate settle for Claude-family
@@ -5425,7 +5679,9 @@ async function flushPending() {
5425
5679
  // rename. Some passthroughs (/clear, /new) can rotate the native session;
5426
5680
  // applying the canonical title last keeps the resume-picker label aligned.
5427
5681
  if (rawInputReady && pendingRawInputs.length > 0 && backend) {
5428
- const raw = pendingRawInputs.shift();
5682
+ const raw = freshnessInputQueue.takeRaw();
5683
+ if (!raw)
5684
+ return;
5429
5685
  await deliverRawInput(raw);
5430
5686
  return;
5431
5687
  }
@@ -5454,7 +5710,9 @@ async function flushPending() {
5454
5710
  return;
5455
5711
  }
5456
5712
  while (pendingMessages.length > 0 && backend && cliAdapter) {
5457
- const item = pendingMessages.shift();
5713
+ const item = freshnessInputQueue.takeNormal();
5714
+ if (!item)
5715
+ break;
5458
5716
  const durableWrite = item.dispatchAttempt !== undefined;
5459
5717
  if (durableWrite)
5460
5718
  durableTurnInFlight = true;
@@ -5514,7 +5772,13 @@ async function flushPending() {
5514
5772
  log('Refused durable Claude submit: transcript terminal bridge is unavailable');
5515
5773
  break;
5516
5774
  }
5517
- log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
5775
+ if (lastInitConfig?.cliId === 'codex-app') {
5776
+ log(`Writing Codex App input to PTY (flush): `
5777
+ + `replyTurn=${shortCorrelationId(item.turnId)} chars=${msg.length}`);
5778
+ }
5779
+ else {
5780
+ log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
5781
+ }
5518
5782
  // Defense in depth: TmuxPipeBackend's send methods no longer throw on a
5519
5783
  // dead pane (they fire onExit instead), but writeInput can still throw
5520
5784
  // for other reasons (fs errors while resolving the JSONL, a future
@@ -5533,10 +5797,10 @@ async function flushPending() {
5533
5797
  result = { submitted: true };
5534
5798
  }
5535
5799
  else if (item.codexAppInput && cliAdapter.writeStructuredInput) {
5536
- result = await cliAdapter.writeStructuredInput(backend, msg, item.codexAppInput);
5800
+ result = await cliAdapter.writeStructuredInput(backend, msg, item.codexAppInput, { turnId: item.turnId });
5537
5801
  }
5538
5802
  else {
5539
- result = await cliAdapter.writeInput(backend, msg);
5803
+ result = await cliAdapter.writeInput(backend, msg, { turnId: item.turnId });
5540
5804
  }
5541
5805
  scheduleBusyPatternIdleProbe(`${cliName()} post-submit`);
5542
5806
  }
@@ -5556,6 +5820,11 @@ async function flushPending() {
5556
5820
  scheduleSubmitFailureNotify(logicalMsg, undefined, '会话 JSONL', bridgeTurnId, undefined, turnSeq, item);
5557
5821
  break;
5558
5822
  }
5823
+ if (lastInitConfig?.cliId === 'codex-app'
5824
+ && item.turnId
5825
+ && result?.submitted !== false) {
5826
+ rememberBounded(submittedCodexAppReplyTurnIds, item.turnId);
5827
+ }
5559
5828
  // Persist any sessionId the adapter observed via authoritative sources
5560
5829
  // (Claude's pid file, Codex's history). Done independently of submit
5561
5830
  // outcome — the rotation is real even when the current Enter didn't
@@ -5615,7 +5884,7 @@ function sendToPty(content, turnId, opts = {}) {
5615
5884
  // old early-return silently dropped it after receiver had already persisted
5616
5885
  // DISPATCHED.
5617
5886
  if (cliRestartInProgress || !backend) {
5618
- pendingMessages.push(next);
5887
+ freshnessInputQueue.enqueueNormal(next);
5619
5888
  log(`Queued message while CLI backend is restarting (${pendingMessages.length} pending)`);
5620
5889
  return true;
5621
5890
  }
@@ -5626,13 +5895,14 @@ function sendToPty(content, turnId, opts = {}) {
5626
5895
  isFlushing,
5627
5896
  supportsTypeAhead,
5628
5897
  awaitingFirstPrompt,
5898
+ holdForRunnerReload: shouldHoldCodexRunnerInput(codexRunnerFreshness),
5629
5899
  }) && cliAdapter.mergeQueuedInput === true;
5630
5900
  const mergedQueued = shouldMergeQueued && mergeQueuedCliInput(pendingMessages, next);
5631
5901
  if (mergedQueued) {
5632
5902
  log(`Merged queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — ${cliName()} ${awaitingFirstPrompt ? 'still booting' : 'is busy'}`);
5633
5903
  }
5634
5904
  else {
5635
- pendingMessages.push(next);
5905
+ freshnessInputQueue.enqueueNormal(next);
5636
5906
  }
5637
5907
  // User-override semantics: a fresh Lark message while a TUI prompt is "active"
5638
5908
  // takes precedence over the AI-detected prompt. The screen analyzer can be
@@ -5643,7 +5913,6 @@ function sendToPty(content, turnId, opts = {}) {
5643
5913
  if (tuiPromptBlocking) {
5644
5914
  log(`User override: incoming Lark message clears tuiPromptBlocking — "${content.substring(0, 80)}"`);
5645
5915
  tuiPromptBlocking = false;
5646
- screenAnalyzer?.notifySelection('lark-input');
5647
5916
  // Tear down the prompt card so the user doesn't see stale options.
5648
5917
  send({
5649
5918
  type: 'tui_prompt_resolved',
@@ -5663,7 +5932,11 @@ function sendToPty(content, turnId, opts = {}) {
5663
5932
  // delivers queued messages instead. See input-gate.ts; this fixes dispatch's
5664
5933
  // brief reaching Codex before its first idle and never landing.
5665
5934
  if (!sessionRenameInFlight && commandLineWritesPending === 0 && shouldWriteNow({
5666
- isPromptReady, isFlushing, supportsTypeAhead, awaitingFirstPrompt,
5935
+ isPromptReady,
5936
+ isFlushing,
5937
+ supportsTypeAhead,
5938
+ awaitingFirstPrompt,
5939
+ holdForRunnerReload: shouldHoldCodexRunnerInput(codexRunnerFreshness),
5667
5940
  })) {
5668
5941
  if (!mergedQueued)
5669
5942
  log(`Writing to PTY: "${content.substring(0, 80)}"`);
@@ -5699,8 +5972,6 @@ function startScreenUpdates() {
5699
5972
  if (awaitingFirstPrompt)
5700
5973
  return;
5701
5974
  let status = isPromptReady ? 'idle' : 'working';
5702
- if (screenAnalyzer?.isAnalyzing)
5703
- status = 'analyzing';
5704
5975
  void (async () => {
5705
5976
  let content = lastContent;
5706
5977
  let changed = false;
@@ -5727,8 +5998,9 @@ function startScreenUpdates() {
5727
5998
  const hash = pipeText.ansi;
5728
5999
  changed = hash !== lastTextSnapshotHash;
5729
6000
  lastTextSnapshotHash = hash;
5730
- // Refresh the unfiltered cache that ScreenAnalyzer reads from. Same
5731
- // tmux call would otherwise need to fire twice per tick.
6001
+ // Refresh the unfiltered snapshot cache (lastAnalyzerSnapshot) that
6002
+ // usage-limit detection and the CoCo picker read from. Same tmux
6003
+ // call would otherwise need to fire twice per tick.
5732
6004
  if (changed) {
5733
6005
  const rawSnap = await snapshotToText(backend, renderCols, renderRows, { filter: false });
5734
6006
  if (rawSnap)
@@ -6518,8 +6790,8 @@ async function spawnCli(cfg, opts = {}) {
6518
6790
  // the full in-memory conversation. In that case the resume-vs-fresh question
6519
6791
  // is moot: we must NOT run the pre-flight fallback (which would drop --resume
6520
6792
  // and post a misleading "started a fresh clean session — context lost" card
6521
- // on EVERY restart, e.g. for a sandboxed session whose transcript lives in
6522
- // an ephemeral overlay upper that the probe can't see). Computed here (not at
6793
+ // on EVERY restart, e.g. for a sandboxed session whose transcript lives at a
6794
+ // real host path the probe may not be able to stat). Computed here (not at
6523
6795
  // the spawn site below) so the pre-flight can short-circuit on it.
6524
6796
  let persistentSessionName = selectedBackend.persistentSessionName;
6525
6797
  // [read-isolation] Before we decide to reattach a persistent pane: a pane can
@@ -6595,6 +6867,27 @@ async function spawnCli(cfg, opts = {}) {
6595
6867
  willReattachPersistent = selectedBackend.isReattach === true;
6596
6868
  }
6597
6869
  }
6870
+ const replacementExpectedFresh = codexRunnerFreshness === 'restarting_fresh';
6871
+ const freshness = decideCodexRunnerFreshness({
6872
+ cliId: cfg.cliId,
6873
+ adoptMode: cfg.adoptMode === true,
6874
+ persistentReattach: willReattachPersistent,
6875
+ replacementExpectedFresh,
6876
+ currentBuildId: cfg.runnerBuildId,
6877
+ persistedBuildId: cfg.persistedRunnerBuildId,
6878
+ });
6879
+ codexRunnerFreshness = freshness.state;
6880
+ persistCodexRunnerBuildOnReady = freshness.persistOnReady;
6881
+ log(`Codex runner freshness=${freshness.state} reason=${freshness.reason}`);
6882
+ if (freshness.reason === 'replacement_reattached' && activeRestartAttemptId) {
6883
+ send({
6884
+ type: 'restart_result',
6885
+ attemptId: activeRestartAttemptId,
6886
+ status: 'failed',
6887
+ category: 'spawn_failed',
6888
+ });
6889
+ activeRestartAttemptId = undefined;
6890
+ }
6598
6891
  // The plugin set is stable only for the lifetime of one real CLI process.
6599
6892
  // A warm worker reattach keeps the existing Gateway and catalog untouched;
6600
6893
  // every fresh/resumed CLI spawn atomically refreshes both from current Bot config.
@@ -6618,6 +6911,7 @@ async function spawnCli(cfg, opts = {}) {
6618
6911
  // diagnostic instead of having the prompt typed into it.
6619
6912
  bareShellLaunchBlocked = false;
6620
6913
  bareShellChecked = false;
6914
+ bareShellCheckInProgress = false;
6621
6915
  // ── Resume pre-flight check + two-tier fallback ──────────────────────────
6622
6916
  // Tier 1 (adapter probe): adapter.checkResumeTargetExists returns false
6623
6917
  // → skip --resume, spawn FRESH.
@@ -6655,6 +6949,13 @@ async function spawnCli(cfg, opts = {}) {
6655
6949
  log(`WARN Claude resume transcript sync failed: ${err.message}`);
6656
6950
  }
6657
6951
  }
6952
+ // Hermes stores sessions in a SQLite state.db (not cwd-scoped JSONL). Resolve
6953
+ // the same path the bridge uses — honoring per-bot env, the forced
6954
+ // BOTMUX_SESSION_ID, and the hermes-botmux-session wrapper profile — so the
6955
+ // adapter probe below queries the exact DB Hermes will `--resume` against.
6956
+ const hermesResumeStateDbPath = cfg.cliId === 'hermes'
6957
+ ? resolveHermesStateDbPath({ ...process.env, ...sanitizePerBotEnv(cfg.env), BOTMUX_SESSION_ID: cfg.sessionId }, { botmuxSessionProfile: basename(cfg.cliPathOverride ?? '') === 'hermes-botmux-session' })
6958
+ : undefined;
6658
6959
  const tier2ForceFresh = effectiveResume && consecutiveInWorkerRestarts >= 2;
6659
6960
  let tier1ProbeFalse = false;
6660
6961
  if (effectiveResume && !tier2ForceFresh && !willReattachPersistent) {
@@ -6663,6 +6964,7 @@ async function spawnCli(cfg, opts = {}) {
6663
6964
  cliSessionId: effectiveCliSessionId,
6664
6965
  workingDir: cfg.workingDir,
6665
6966
  dataDir: claudeDataDir,
6967
+ stateDbPath: hermesResumeStateDbPath,
6666
6968
  });
6667
6969
  if (probe === false)
6668
6970
  tier1ProbeFalse = true;
@@ -7165,15 +7467,8 @@ async function spawnCli(cfg, opts = {}) {
7165
7467
  mkdirSync(join(dataDir, 'attachments', cfg.larkAppId), { recursive: true });
7166
7468
  }
7167
7469
  catch { /* */ }
7168
- // schedules.json is a shared RMW store (`botmux schedule`); pre-create as an
7169
- // empty map if absent (same content schedule-store itself writes) so a fresh
7170
- // install's first in-sandbox `schedule add` can bind+write it on bwrap too.
7171
- try {
7172
- const sf = join(dataDir, 'schedules.json');
7173
- if (!existsSync(sf))
7174
- writeFileSync(sf, '{}');
7175
- }
7176
- catch { /* */ }
7470
+ // (Schedules moved into each bot's BOT_HOME the whole dir is already
7471
+ // bound readWrite for the owner, so no per-file pre-create is needed.)
7177
7472
  const mandatoryDenyPaths = [];
7178
7473
  const mandatoryDenyRegexes = [];
7179
7474
  const mandatoryReadOnlyPaths = [];
@@ -7964,6 +8259,16 @@ async function spawnCli(cfg, opts = {}) {
7964
8259
  // prompt-ready — without this hook a follow-up arriving mid-task would sit
7965
8260
  // in pendingMessages forever once the task finishes.
7966
8261
  backend.onTaskDone?.(() => {
8262
+ // Generation fence (matches the onAgentStatus above and the onExit below):
8263
+ // a stale RiffBackend's `fetchAndEmitOutput(...).finally(taskDoneCb)` can
8264
+ // resolve AFTER destroySession()/kill() during a restart (neither clears
8265
+ // taskDoneCb nor awaits the in-flight fetch). If that late callback reached
8266
+ // markPromptReady() while a replacement is spawning, it would ride through
8267
+ // the global restart gate and emit a premature `restart_result: succeeded`,
8268
+ // swallowing the replacement's true terminal outcome. Only the current
8269
+ // backend generation may re-arm prompt-ready.
8270
+ if (backend !== observedBackend)
8271
+ return;
7967
8272
  log(`${cliName()} task finished — re-arming prompt-ready for queued follow-ups`);
7968
8273
  markPromptReady();
7969
8274
  });
@@ -8002,22 +8307,7 @@ async function spawnCli(cfg, opts = {}) {
8002
8307
  // before exiting while fs.watch/the 1s poller is still queued. Drain it
8003
8308
  // synchronously before claiming `cli_exit`; otherwise ambiguous wins the
8004
8309
  // deduper and needlessly replays a turn that actually completed.
8005
- if (bridgeJsonlPath) {
8006
- try {
8007
- bridgeDrainAndMaybeEmit();
8008
- }
8009
- catch (err) {
8010
- log(`Bridge exit drain failed: ${err.message}`);
8011
- }
8012
- }
8013
- if (codexBridgeFallbackActive()) {
8014
- try {
8015
- codexBridgeDrainAndMaybeEmit({ signalIdle: false });
8016
- }
8017
- catch (err) {
8018
- log(`Codex bridge exit drain failed: ${err.message}`);
8019
- }
8020
- }
8310
+ drainReliableTerminalBeforeInterrupt();
8021
8311
  // Race-safe with transcript final / submit-failure: the worker-local
8022
8312
  // terminal deduper lets exactly one status win for this attempt.
8023
8313
  emitTurnTerminal(exitedTurnId, 'ambiguous', 'cli_exit', exitedDispatchAttempt);
@@ -8059,6 +8349,17 @@ async function spawnCli(cfg, opts = {}) {
8059
8349
  isPromptReady = false;
8060
8350
  currentBotmuxTurnId = undefined;
8061
8351
  currentBotmuxDispatchAttempt = undefined;
8352
+ if (!intentionalRestart && activeRestartAttemptId) {
8353
+ send({
8354
+ type: 'restart_result',
8355
+ attemptId: activeRestartAttemptId,
8356
+ status: 'failed',
8357
+ category: 'runner_exited',
8358
+ });
8359
+ activeRestartAttemptId = undefined;
8360
+ }
8361
+ if (!intentionalRestart)
8362
+ freshnessInputQueue.onReplacementFailed();
8062
8363
  if (intentionalRestart) {
8063
8364
  log('Suppressed claude_exit for intentional in-worker restart');
8064
8365
  }
@@ -8161,8 +8462,8 @@ function killCli(opts = {}) {
8161
8462
  promptReadyDetectedDuringSettle = false;
8162
8463
  readyPatternSeenDuringHold = false;
8163
8464
  awaitingPostSessionStartPromptEvidence = false;
8164
- stopScreenAnalyzer();
8165
8465
  stopStuckDetector();
8466
+ tuiPromptBlocking = false;
8166
8467
  stopScreenUpdates();
8167
8468
  backend?.kill();
8168
8469
  backend = null;
@@ -8180,10 +8481,10 @@ function killCli(opts = {}) {
8180
8481
  cliPidMarker = null;
8181
8482
  }
8182
8483
  completeManagedTurnOriginRevocation(sandboxRelayCapability, currentBotmuxTurnId, currentBotmuxDispatchAttempt);
8183
- // Stop the sandbox outbox watcher, then unmount the overlays + remove the
8184
- // per-session sandbox tree. In the overlay model the upper layer (the
8185
- // changeset) must be landed BEFORE close `/land` runs while the session is
8186
- // still active, so by cleanup time anything worth keeping is already applied.
8484
+ // Stop the sandbox outbox watcher, then reclaim the deny-mask mountpoints +
8485
+ // remove the per-session sandbox tree. In the fs-policy model the CLI writes
8486
+ // the project DIRECTLY at real host paths (no upper changeset to land), so by
8487
+ // close time everything worth keeping is already on disk.
8187
8488
  if (sandboxStopWatcher) {
8188
8489
  try {
8189
8490
  sandboxStopWatcher();
@@ -8196,6 +8497,9 @@ function killCli(opts = {}) {
8196
8497
  currentBotmuxTurnId = undefined;
8197
8498
  currentBotmuxDispatchAttempt = undefined;
8198
8499
  currentVcMeetingImTurnOrigin = undefined;
8500
+ submittedCodexAppReplyTurnIds.clear();
8501
+ pendingCodexAppSteerAckIds.clear();
8502
+ acknowledgedCodexAppSteers.clear();
8199
8503
  if (sandboxCleanup) {
8200
8504
  try {
8201
8505
  sandboxCleanup();
@@ -8235,6 +8539,7 @@ async function restartCliProcess(reason, opts = {}) {
8235
8539
  // of firing idle/task-done callbacks. Inputs accepted in that interval must
8236
8540
  // remain queued until a replacement backend has been installed.
8237
8541
  cliRestartInProgress = true;
8542
+ replacementSpawnInProgress = false;
8238
8543
  rawInputRestartGate = true;
8239
8544
  // The Node worker stays alive through this restart, so the daemon will see
8240
8545
  // neither claude_exit nor worker-exit. Explicitly revoke the old turn's
@@ -8244,9 +8549,13 @@ async function restartCliProcess(reason, opts = {}) {
8244
8549
  revokeManagedTurnOriginForRestart();
8245
8550
  log(`Restart requested (${reason})`);
8246
8551
  // Tier-2 guard: 2nd consecutive in-worker restart forces FRESH. Tier-1
8247
- // adapter probing is still re-run on every spawn.
8248
- consecutiveInWorkerRestarts++;
8249
- log(`Restart count: ${consecutiveInWorkerRestarts} (>=2 forces FRESH)`);
8552
+ // adapter probing is still re-run on every spawn. skipRestartBudget(角色
8553
+ // 切换的 cwd-move respawn)不计入:那是用户主动迁移不是崩溃恢复,计进去
8554
+ // 会让「切角色 + 一次无关重启」无故触发强制 FRESH 丢上下文。
8555
+ if (!opts.skipRestartBudget) {
8556
+ consecutiveInWorkerRestarts++;
8557
+ log(`Restart count: ${consecutiveInWorkerRestarts} (>=2 forces FRESH)`);
8558
+ }
8250
8559
  const restart = async () => {
8251
8560
  try {
8252
8561
  tmuxRestartTimer = null;
@@ -8276,12 +8585,13 @@ async function restartCliProcess(reason, opts = {}) {
8276
8585
  killCli({ preservePending: opts.preservePending });
8277
8586
  awaitingFirstPrompt = true;
8278
8587
  setTimeout(async () => {
8588
+ let spawnedWorkingDir;
8279
8589
  if (lastInitConfig) {
8280
8590
  startScreenUpdates();
8281
- startScreenAnalyzer();
8282
8591
  startStuckDetector();
8283
8592
  try {
8284
8593
  const restartCfg = { ...lastInitConfig, resume: true, prompt: '', cliSessionId: rpcThreadId ?? lastInitConfig.cliSessionId };
8594
+ spawnedWorkingDir = restartCfg.workingDir;
8285
8595
  // Re-engage RPC so the new --remote pane binds to the CURRENT app-server
8286
8596
  // (a fresh port), not the dead prior one. engageCodexRpc only sets
8287
8597
  // remote* on success, else spawnCli falls back to paste.
@@ -8292,27 +8602,64 @@ async function restartCliProcess(reason, opts = {}) {
8292
8602
  rpcPluginGenerationPrepared = true;
8293
8603
  await engageCodexRpc(restartCfg);
8294
8604
  }
8605
+ replacementSpawnInProgress = true;
8295
8606
  await spawnCli(restartCfg, { pluginGenerationPrepared: rpcPluginGenerationPrepared });
8296
8607
  await prepareCodexNativeTitleGeneration(restartCfg, codexRpcEngine);
8608
+ replacementSpawnInProgress = false;
8297
8609
  if (codexRpcEngine)
8298
8610
  armRpcStartupDialogDismiss();
8299
8611
  }
8300
8612
  catch (err) {
8613
+ replacementSpawnInProgress = false;
8301
8614
  cliRestartInProgress = false;
8302
8615
  await sendFatalWorkerErrorAndExit(err);
8303
8616
  return;
8304
8617
  }
8305
8618
  }
8306
8619
  cliRestartInProgress = false;
8620
+ replacementSpawnInProgress = false;
8621
+ // Follow-up decision (pure, unit-tested in restart-followup-policy.ts):
8622
+ // - cwd-move: a role-switch restart landed after restartCfg was
8623
+ // snapshotted → CLI came up in the old cwd while daemon repinned to
8624
+ // the new one. Respawn to converge (budget skipped only if the
8625
+ // backend is still alive; a dead one keeps the crash evidence).
8626
+ // - replacement-recovery: the freshly-spawned CLI exited inside the
8627
+ // window cliRestartInProgress still covers (spawnCli +
8628
+ // prepareCodexNativeTitleGeneration). onExit nulled `backend`
8629
+ // synchronously, so `!backend` is the ground-truth recovery signal —
8630
+ // NOT a merged daemon auto-restart (a restart message carries no
8631
+ // trustworthy source, so a healthy duplicate /restart during the
8632
+ // window must NOT be misread as a crash and force a budget-burning
8633
+ // second restart). Recover now or the session strands at
8634
+ // backend=null needing a manual /restart. Genuine crash recovery →
8635
+ // COUNTS toward tier-2 FRESH.
8636
+ const followup = decideRestartFollowup({
8637
+ spawnedWorkingDir,
8638
+ currentWorkingDir: lastInitConfig?.workingDir,
8639
+ backendAlive: !!backend,
8640
+ });
8641
+ if (followup.kind !== 'none') {
8642
+ const reason = followup.kind === 'cwd-move'
8643
+ ? `cwd-move follow-up respawn → ${lastInitConfig?.workingDir}`
8644
+ : 'replacement-exit follow-up restart (replacement exited during in-flight restart)';
8645
+ log(reason);
8646
+ void restartCliProcess(reason, { preservePending: true, skipRestartBudget: followup.skipRestartBudget });
8647
+ return;
8648
+ }
8307
8649
  // Riff marks itself prompt-ready inside spawnCli(); that early flush is
8308
8650
  // intentionally held by the restart gate above. Release its raw-input
8309
8651
  // fence now; other backends keep it until their later markPromptReady().
8310
8652
  if (effectiveBackendType === 'riff' && isPromptReady)
8311
8653
  releaseRawInputRestartGate();
8312
- void flushPending();
8654
+ // A local replacement process can exist before its TUI input box does.
8655
+ // Only re-kick a prompt that became ready while the restart fence was
8656
+ // still armed; otherwise markPromptReady() owns the first flush.
8657
+ if (isPromptReady)
8658
+ void flushPending();
8313
8659
  }, 500);
8314
8660
  }
8315
8661
  catch (err) {
8662
+ replacementSpawnInProgress = false;
8316
8663
  cliRestartInProgress = false;
8317
8664
  try {
8318
8665
  await sendFatalWorkerErrorAndExit(err);
@@ -8435,6 +8782,14 @@ function startWebServer(host, preferredPort) {
8435
8782
  const startAttach = (cols, rows) => {
8436
8783
  if (cp)
8437
8784
  return;
8785
+ // Why: a prior web resize may leave the shared tmux window in manual mode.
8786
+ // `largest` restores the shared default without letting a smaller/newer
8787
+ // viewer resize every other attached client.
8788
+ spawnSync('tmux', ['set-option', '-t', tmuxTarget, 'window-size', 'largest'], {
8789
+ stdio: 'ignore',
8790
+ env: tmuxEnv(),
8791
+ timeout: 3000,
8792
+ });
8438
8793
  // Defense in depth: view-capability clients attach through tmux's
8439
8794
  // own read-only mode as well as the WebSocket input gate below.
8440
8795
  cp = pty.spawn('tmux', [
@@ -9528,6 +9883,15 @@ async function sendFatalWorkerErrorAndExit(err, turnId = currentBotmuxTurnId, di
9528
9883
  if (fatalWorkerErrorPending)
9529
9884
  return;
9530
9885
  fatalWorkerErrorPending = true;
9886
+ if (activeRestartAttemptId) {
9887
+ await sendAndFlush({
9888
+ type: 'restart_result',
9889
+ attemptId: activeRestartAttemptId,
9890
+ status: 'failed',
9891
+ category: 'spawn_failed',
9892
+ });
9893
+ activeRestartAttemptId = undefined;
9894
+ }
9531
9895
  await sendAndFlush({
9532
9896
  type: 'error',
9533
9897
  message: err instanceof Error ? err.message : String(err),
@@ -9551,6 +9915,7 @@ process.on('message', async (raw) => {
9551
9915
  if (lastInitConfig)
9552
9916
  return; // already initialized
9553
9917
  lastInitConfig = msg;
9918
+ activeRestartAttemptId = msg.restartAttemptId;
9554
9919
  sessionId = msg.sessionId;
9555
9920
  refreshTerminalViewToken();
9556
9921
  refreshTerminalWriteToken();
@@ -9596,7 +9961,6 @@ process.on('message', async (raw) => {
9596
9961
  if (!isWorkflowWorker()) {
9597
9962
  port = await startWebServer(config.web.workerHost, msg.webPort);
9598
9963
  startScreenUpdates();
9599
- startScreenAnalyzer();
9600
9964
  startStuckDetector();
9601
9965
  }
9602
9966
  else {
@@ -9764,12 +10128,11 @@ process.on('message', async (raw) => {
9764
10128
  if (!backend && crashDiagnosticStopped && lastInitConfig && !lastInitConfig.adoptMode) {
9765
10129
  log('Message received after crash-loop stop; retrying CLI start');
9766
10130
  destroyCrashDiagnosticTerminal('retry after message');
9767
- stopScreenAnalyzer();
9768
10131
  stopStuckDetector();
10132
+ tuiPromptBlocking = false;
9769
10133
  stopScreenUpdates();
9770
10134
  awaitingFirstPrompt = true;
9771
10135
  startScreenUpdates();
9772
- startScreenAnalyzer();
9773
10136
  startStuckDetector();
9774
10137
  try {
9775
10138
  const restartCfg = { ...lastInitConfig, resume: true, prompt: '' };
@@ -9912,9 +10275,18 @@ process.on('message', async (raw) => {
9912
10275
  // barrier(shouldDeferUserFlush——/cd 未落地前任何用户输入都不得写入,
9913
10276
  // 否则 passthrough 会执行在旧 cwd 的 CLI 里)时入队。注入排空后由
9914
10277
  // flushPendingInjections 的 finally 补踢 flushPending 送达。
10278
+ // 第四个例外是启动稳定窗口:detectBareShellLaunch() 采到裸 shell 时会
10279
+ // await settleLaunchComm() 最长 2s 等 wrapper 完成 `exec <cli>`——这段
10280
+ // await 让出事件循环,而 IPC handler 不串行(见 raw-input-followup-
10281
+ // atomicity.test.ts),若此刻放行 passthrough 就会打进尚未 exec 的临时
10282
+ // shell。bareShellCheckInProgress 覆盖"检查进行中"、bareShellLaunchBlocked
10283
+ // 覆盖"已确认裸 shell 启动失败"两种状态,一并入队。裸 shell 确认失败后
10284
+ // 这些 pending 不会再被 flush(与 pendingMessages 同款处理),符合预期。
9915
10285
  if (cliRestartInProgress || rawInputRestartGate || sessionRenameInFlight
9916
- || injectionFlushing || shouldDeferUserFlush(pendingInjections)) {
9917
- pendingRawInputs.push(msg);
10286
+ || shouldHoldCodexRunnerInput(codexRunnerFreshness)
10287
+ || injectionFlushing || shouldDeferUserFlush(pendingInjections)
10288
+ || bareShellCheckInProgress || bareShellLaunchBlocked) {
10289
+ freshnessInputQueue.enqueueRaw(msg);
9918
10290
  log(`Deferred passthrough slash command until CLI input gate settles: ${msg.content}`);
9919
10291
  }
9920
10292
  else {
@@ -9949,7 +10321,58 @@ process.on('message', async (raw) => {
9949
10321
  break;
9950
10322
  }
9951
10323
  case 'restart': {
9952
- await restartCliProcess('daemon request', { preservePending: true });
10324
+ // 角色切换的 cwd-move respawn:respawn 用 {...lastInitConfig, resume:true}
10325
+ // 先收敛 workingDir 才能让 CLI 在新目录重启(新 cwd 的 CLAUDE.md/记忆索引
10326
+ // 开场注入)。旧桶 transcript 由 resume 预检的 syncClaudeResumeTargetToCwd
10327
+ // (COPY 最新 <sid>.jsonl 进新 cwd 桶,已在 master)接住,上下文不丢。
10328
+ if (msg.updateWorkingDir && lastInitConfig) {
10329
+ lastInitConfig.workingDir = msg.updateWorkingDir;
10330
+ }
10331
+ // restart 合并:已有一轮 restart 在飞(teardown 进行中,或 tmux jitter
10332
+ // 定时器未触发)时不叠加第二轮——叠加会 clearTimeout 吃掉首轮 teardown、
10333
+ // 把重启预算无故烧到 tier-2 强制 FRESH(丢上下文),非 tmux 路径还会
10334
+ // 双 spawn。workingDir 已收敛进 lastInitConfig,pending 的 spawn 展开
10335
+ // {...lastInitConfig} 时自然拿到新目录。
10336
+ //
10337
+ // 这里**只 break、不记任何 flag**:restart 消息不带可信来源,无法区分
10338
+ // 「replacement 崩溃触发的 auto-restart」与「用户重复点了一次 restart」。
10339
+ // replacement 真退出时 onExit 已同步把 backend 置 null,续体用 !backend 即可
10340
+ // 补 recovery(见 decideRestartFollowup);健康的重复 restart 就该被合并掉,
10341
+ // 记 flag 反而会逼健康进程再重启一轮、烧预算丢 --resume(正是合并要防的)。
10342
+ if (cliRestartInProgress || tmuxRestartTimer) {
10343
+ log(`Restart request merged into in-flight restart${msg.updateWorkingDir ? ` (workingDir → ${msg.updateWorkingDir})` : ''}`);
10344
+ break;
10345
+ }
10346
+ activeRestartAttemptId = msg.attemptId;
10347
+ codexRunnerFreshness = 'restarting_fresh';
10348
+ // restart 杀死 CLI,在飞的 durable turn 随之死亡。对被杀的那次投递,主动发一个
10349
+ // 'ambiguous' 终端回执:CLI 被中途杀掉,副作用到底发没发是**真的无法证明**
10350
+ // (故不能报 'cancelled'),交由 daemon 的重试策略即时对账。不发的话 receipt 会
10351
+ // 悬着,等 daemon 租约到期走 expire_durable_turn 的「无法证明 → 扣 ACK → 超时
10352
+ // fencing teardown」慢路径(还会把刚 respawn 的新 CLI 二次 teardown)。
10353
+ // emitTurnTerminal 自带释放(复位 durableTurnInFlight、退休 inflight input、
10354
+ // 撤销 turn-origin relay、丢弃 bridge 尝试)并对后续 CLI-exit 终端去重;它排的
10355
+ // flushPending 微任务会因下面 restartCliProcess 同步置位 cliRestartInProgress 而空跑。
10356
+ // 结算被 restart 打断的在飞 durable turn(编排见 settleDurableTurnForRestart,
10357
+ // 已单测):drain 已落盘可靠终态让「刚好在 kill 前完成、watcher 未消费」的 turn
10358
+ // 以 completed 抢占 deduper → 复检仍在飞才补发 ambiguous(否则无条件 ambiguous
10359
+ // 会误标已完成投递 → 同 key 可重派 → 副作用两次)→ 兜底释放 latch 免卡 respawn。
10360
+ // emitTurnTerminal 自带释放(复位 durableTurnInFlight、退休 inflight input、撤销
10361
+ // turn-origin relay、丢弃 bridge 尝试);它排的 flushPending 微任务会因下面
10362
+ // restartCliProcess 同步置位 cliRestartInProgress 而空跑。
10363
+ settleDurableTurnForRestart({
10364
+ hasInFlightTurn: durableTurnInFlight,
10365
+ hasCurrentTurnId: !!currentBotmuxTurnId,
10366
+ drain: () => drainReliableTerminalBeforeInterrupt(),
10367
+ isStillInFlight: () => durableTurnInFlight,
10368
+ emitAmbiguous: () => emitTurnTerminal(currentBotmuxTurnId, 'ambiguous', undefined, currentBotmuxDispatchAttempt),
10369
+ release: () => { durableTurnInFlight = false; inflightInputs.onTurnComplete(); },
10370
+ });
10371
+ await restartCliProcess(msg.updateWorkingDir ? `cwd-move respawn → ${msg.updateWorkingDir}` : 'daemon request',
10372
+ // cwd-move 是用户主动的目录迁移、不是崩溃恢复,不计入 tier-2 强制
10373
+ // FRESH 的重启预算;respawn 真失败仍有 claude_exit → daemon
10374
+ // auto-restart 那条裸 restart 的计数兜底。
10375
+ { preservePending: true, skipRestartBudget: !!msg.updateWorkingDir });
9953
10376
  break;
9954
10377
  }
9955
10378
  case 'expire_durable_turn': {
@@ -10075,12 +10498,10 @@ process.on('message', async (raw) => {
10075
10498
  break;
10076
10499
  }
10077
10500
  case 'inject_command': {
10078
- // 会话内 /cd 移动后,worker 内部 respawn(claude_exit 自动重启 / IM /restart /
10079
- // dashboard restart)必须收敛到新目录,而不是陈旧的 lastInitConfig.workingDir。
10080
- if (msg.updateWorkingDir && lastInitConfig) {
10081
- lastInitConfig.workingDir = msg.updateWorkingDir;
10082
- }
10083
- pendingInjections.push({ command: msg.command, barrier: !!msg.updateWorkingDir });
10501
+ // 唯一发送方是 /slash 路由的白名单 TUI 命令注入。cwd 移动不再走注入
10502
+ // (角色切换已改为 restart+updateWorkingDir 的 respawn),这里不接受
10503
+ // 任何 workingDir 改写——那会绕过 cd 路由的角色库硬校验。
10504
+ pendingInjections.push({ command: msg.command, barrier: false });
10084
10505
  void flushPendingInjections();
10085
10506
  break;
10086
10507
  }
@@ -10213,10 +10634,11 @@ process.on('message', async (raw) => {
10213
10634
  catch { /* best-effort */ }
10214
10635
  backend = null;
10215
10636
  isPromptReady = false;
10216
- // Suspend INTENDS to resume later: preserve the sandbox overlay mount + the
10217
- // upper changeset across the suspension (on resume, prepareSandbox re-mounts
10218
- // over the SAME upper). So we stop the outbox watcher (no live CLI to serve)
10219
- // but DO NOT run sandboxCleanup (which would unmount + rm the changeset). We
10637
+ // Suspend INTENDS to resume later: keep the per-session sandbox tree (the
10638
+ // outbox + pre-created deny-mask mountpoints) intact across the suspension
10639
+ // (on resume, prepareDirectSandbox re-binds over the SAME tree). So we stop
10640
+ // the outbox watcher (no live CLI to serve) but DO NOT run sandboxCleanup
10641
+ // (which would reclaim the mask mountpoints + rm the tree). We
10220
10642
  // also disarm the exit-time teardown so process.exit(0) below can't reclaim
10221
10643
  // it. (Crash/SIGKILL of a suspended-but-active session is still backstopped
10222
10644
  // by the daemon's periodic sandbox reconciler.)
@@ -10227,7 +10649,7 @@ process.on('message', async (raw) => {
10227
10649
  catch { /* */ }
10228
10650
  sandboxStopWatcher = null;
10229
10651
  }
10230
- sandboxCleanup = null; // drop the ref WITHOUT calling it (keep the mount)
10652
+ sandboxCleanup = null; // drop the ref WITHOUT calling it (keep the tree)
10231
10653
  sandboxTeardownDone = true; // make the process.on('exit') hook a no-op
10232
10654
  cleanup();
10233
10655
  process.exit(0);
@@ -10303,15 +10725,15 @@ setInterval(() => {
10303
10725
  }
10304
10726
  }, 30_000).unref();
10305
10727
  // ─── Sandbox crash-time teardown ─────────────────────────────────────────────
10306
- // killCli() (which unmounts the overlays + rm's the per-session tree) only runs
10307
- // from the SIGTERM/SIGINT/disconnect/watchdog handlers and the close/suspend IPC
10308
- // cases. An UNCAUGHT exception or unhandled rejection kills the process WITHOUT
10309
- // any of those firing, so without this hook a crashed sandboxed worker would
10310
- // leak BOTH overlay mounts (mount-table growth) + its upper/work dirs (disk leak)
10311
- // per crash. We run a minimal, synchronous, best-effort sandbox teardown here so
10312
- // the overlay/dir residue is reclaimed even on an abnormal exit. (SIGKILL still
10313
- // can't be trapped — the daemon-side sweep + the periodic reconciler below are
10314
- // the backstop for that.)
10728
+ // killCli() (which reclaims the deny-mask mountpoints + rm's the per-session
10729
+ // tree) only runs from the SIGTERM/SIGINT/disconnect/watchdog handlers and the
10730
+ // close/suspend IPC cases. An UNCAUGHT exception or unhandled rejection kills
10731
+ // the process WITHOUT any of those firing, so without this hook a crashed
10732
+ // sandboxed worker would leak its pre-created host mask mountpoints (empty dirs/
10733
+ // files) + the per-session tree (disk leak) per crash. We run a minimal,
10734
+ // synchronous, best-effort sandbox teardown here so that residue is reclaimed
10735
+ // even on an abnormal exit. (SIGKILL still can't be trapped — the daemon-side
10736
+ // sweep + the periodic reconciler below are the backstop for that.)
10315
10737
  function teardownSandboxBestEffort() {
10316
10738
  if (sandboxTeardownDone)
10317
10739
  return;