botmux 3.10.0 → 3.11.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 (539) hide show
  1. package/dist/.runtime-build-id +1 -1
  2. package/dist/adapters/backend/herdr-backend.d.ts +4 -3
  3. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  4. package/dist/adapters/backend/herdr-backend.js +11 -8
  5. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  6. package/dist/adapters/backend/pty-backend.d.ts +1 -1
  7. package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
  8. package/dist/adapters/backend/pty-backend.js +4 -1
  9. package/dist/adapters/backend/pty-backend.js.map +1 -1
  10. package/dist/adapters/backend/riff-backend.d.ts +29 -5
  11. package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
  12. package/dist/adapters/backend/riff-backend.js +302 -31
  13. package/dist/adapters/backend/riff-backend.js.map +1 -1
  14. package/dist/adapters/backend/sandbox.d.ts +6 -0
  15. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  16. package/dist/adapters/backend/sandbox.js +7 -0
  17. package/dist/adapters/backend/sandbox.js.map +1 -1
  18. package/dist/adapters/backend/tmux-backend.d.ts +1 -1
  19. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  20. package/dist/adapters/backend/tmux-backend.js +4 -1
  21. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  22. package/dist/adapters/backend/tmux-pipe-backend.d.ts +1 -1
  23. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  24. package/dist/adapters/backend/tmux-pipe-backend.js +1 -1
  25. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  26. package/dist/adapters/backend/types.d.ts +37 -3
  27. package/dist/adapters/backend/types.d.ts.map +1 -1
  28. package/dist/adapters/backend/types.js.map +1 -1
  29. package/dist/adapters/backend/zellij-backend.d.ts +3 -3
  30. package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
  31. package/dist/adapters/backend/zellij-backend.js +9 -3
  32. package/dist/adapters/backend/zellij-backend.js.map +1 -1
  33. package/dist/adapters/backend/zellij-observe-backend.d.ts +3 -3
  34. package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -1
  35. package/dist/adapters/backend/zellij-observe-backend.js +12 -7
  36. package/dist/adapters/backend/zellij-observe-backend.js.map +1 -1
  37. package/dist/adapters/cli/codex-app.d.ts.map +1 -1
  38. package/dist/adapters/cli/codex-app.js +11 -2
  39. package/dist/adapters/cli/codex-app.js.map +1 -1
  40. package/dist/adapters/cli/read-isolation.d.ts +35 -2
  41. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  42. package/dist/adapters/cli/read-isolation.js +88 -7
  43. package/dist/adapters/cli/read-isolation.js.map +1 -1
  44. package/dist/adapters/cli/runner-input.d.ts +15 -14
  45. package/dist/adapters/cli/runner-input.d.ts.map +1 -1
  46. package/dist/adapters/cli/runner-input.js +64 -26
  47. package/dist/adapters/cli/runner-input.js.map +1 -1
  48. package/dist/adapters/cli/types.d.ts +20 -5
  49. package/dist/adapters/cli/types.d.ts.map +1 -1
  50. package/dist/bot-registry.d.ts +24 -0
  51. package/dist/bot-registry.d.ts.map +1 -1
  52. package/dist/bot-registry.js +110 -5
  53. package/dist/bot-registry.js.map +1 -1
  54. package/dist/cli/fleet-shutdown.d.ts +47 -0
  55. package/dist/cli/fleet-shutdown.d.ts.map +1 -0
  56. package/dist/cli/fleet-shutdown.js +387 -0
  57. package/dist/cli/fleet-shutdown.js.map +1 -0
  58. package/dist/cli/pm2-descriptor-guard.d.ts +19 -0
  59. package/dist/cli/pm2-descriptor-guard.d.ts.map +1 -0
  60. package/dist/cli/pm2-descriptor-guard.js +122 -0
  61. package/dist/cli/pm2-descriptor-guard.js.map +1 -0
  62. package/dist/cli/pm2-exact-start.d.ts +16 -0
  63. package/dist/cli/pm2-exact-start.d.ts.map +1 -0
  64. package/dist/cli/pm2-exact-start.js +63 -0
  65. package/dist/cli/pm2-exact-start.js.map +1 -0
  66. package/dist/cli/pm2-god-admission.d.ts +8 -0
  67. package/dist/cli/pm2-god-admission.d.ts.map +1 -0
  68. package/dist/cli/pm2-god-admission.js +25 -0
  69. package/dist/cli/pm2-god-admission.js.map +1 -0
  70. package/dist/cli/pm2-jlist.d.ts +13 -0
  71. package/dist/cli/pm2-jlist.d.ts.map +1 -0
  72. package/dist/cli/pm2-jlist.js +108 -0
  73. package/dist/cli/pm2-jlist.js.map +1 -0
  74. package/dist/cli/pm2-preflight.d.ts +10 -0
  75. package/dist/cli/pm2-preflight.d.ts.map +1 -0
  76. package/dist/cli/pm2-preflight.js +23 -0
  77. package/dist/cli/pm2-preflight.js.map +1 -0
  78. package/dist/cli/pm2-shutdown-capability.d.ts +27 -0
  79. package/dist/cli/pm2-shutdown-capability.d.ts.map +1 -0
  80. package/dist/cli/pm2-shutdown-capability.js +161 -0
  81. package/dist/cli/pm2-shutdown-capability.js.map +1 -0
  82. package/dist/cli/pm2-start-transaction.d.ts +55 -0
  83. package/dist/cli/pm2-start-transaction.d.ts.map +1 -0
  84. package/dist/cli/pm2-start-transaction.js +220 -0
  85. package/dist/cli/pm2-start-transaction.js.map +1 -0
  86. package/dist/cli/send-dispatch.d.ts +12 -0
  87. package/dist/cli/send-dispatch.d.ts.map +1 -1
  88. package/dist/cli/send-dispatch.js +31 -4
  89. package/dist/cli/send-dispatch.js.map +1 -1
  90. package/dist/cli/session-list-liveness.d.ts +1 -0
  91. package/dist/cli/session-list-liveness.d.ts.map +1 -1
  92. package/dist/cli/session-list-liveness.js +6 -0
  93. package/dist/cli/session-list-liveness.js.map +1 -1
  94. package/dist/cli/session-picker-layout.d.ts +26 -0
  95. package/dist/cli/session-picker-layout.d.ts.map +1 -0
  96. package/dist/cli/session-picker-layout.js +123 -0
  97. package/dist/cli/session-picker-layout.js.map +1 -0
  98. package/dist/cli/session-picker-viewport.d.ts +49 -0
  99. package/dist/cli/session-picker-viewport.d.ts.map +1 -0
  100. package/dist/cli/session-picker-viewport.js +46 -0
  101. package/dist/cli/session-picker-viewport.js.map +1 -0
  102. package/dist/cli/supervisor-shutdown-client.d.ts +31 -0
  103. package/dist/cli/supervisor-shutdown-client.d.ts.map +1 -0
  104. package/dist/cli/supervisor-shutdown-client.js +146 -0
  105. package/dist/cli/supervisor-shutdown-client.js.map +1 -0
  106. package/dist/cli/terminal-width.d.ts +48 -0
  107. package/dist/cli/terminal-width.d.ts.map +1 -0
  108. package/dist/cli/terminal-width.js +119 -0
  109. package/dist/cli/terminal-width.js.map +1 -0
  110. package/dist/cli/vc-agent.d.ts.map +1 -1
  111. package/dist/cli/vc-agent.js +1 -1
  112. package/dist/cli/vc-agent.js.map +1 -1
  113. package/dist/cli.d.ts.map +1 -1
  114. package/dist/cli.js +2269 -716
  115. package/dist/cli.js.map +1 -1
  116. package/dist/codex-app-runner.js +1630 -125
  117. package/dist/codex-app-runner.js.map +1 -1
  118. package/dist/core/ask-api.d.ts +8 -1
  119. package/dist/core/ask-api.d.ts.map +1 -1
  120. package/dist/core/ask-api.js +18 -0
  121. package/dist/core/ask-api.js.map +1 -1
  122. package/dist/core/ask-broker.d.ts +20 -0
  123. package/dist/core/ask-broker.d.ts.map +1 -1
  124. package/dist/core/ask-broker.js +467 -47
  125. package/dist/core/ask-broker.js.map +1 -1
  126. package/dist/core/ask-persist-store.d.ts +126 -0
  127. package/dist/core/ask-persist-store.d.ts.map +1 -0
  128. package/dist/core/ask-persist-store.js +188 -0
  129. package/dist/core/ask-persist-store.js.map +1 -0
  130. package/dist/core/ask-types.d.ts +80 -0
  131. package/dist/core/ask-types.d.ts.map +1 -1
  132. package/dist/core/ask-types.js +62 -0
  133. package/dist/core/ask-types.js.map +1 -1
  134. package/dist/core/cli-session-id-publisher.d.ts +20 -0
  135. package/dist/core/cli-session-id-publisher.d.ts.map +1 -0
  136. package/dist/core/cli-session-id-publisher.js +21 -0
  137. package/dist/core/cli-session-id-publisher.js.map +1 -0
  138. package/dist/core/command-handler.d.ts.map +1 -1
  139. package/dist/core/command-handler.js +427 -255
  140. package/dist/core/command-handler.js.map +1 -1
  141. package/dist/core/cost-calculator.d.ts +13 -0
  142. package/dist/core/cost-calculator.d.ts.map +1 -1
  143. package/dist/core/cost-calculator.js +385 -26
  144. package/dist/core/cost-calculator.js.map +1 -1
  145. package/dist/core/dashboard-ipc-server.d.ts +9 -0
  146. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  147. package/dist/core/dashboard-ipc-server.js +842 -288
  148. package/dist/core/dashboard-ipc-server.js.map +1 -1
  149. package/dist/core/explicit-session-backing-cleanup.d.ts +49 -0
  150. package/dist/core/explicit-session-backing-cleanup.d.ts.map +1 -0
  151. package/dist/core/explicit-session-backing-cleanup.js +76 -0
  152. package/dist/core/explicit-session-backing-cleanup.js.map +1 -0
  153. package/dist/core/idle-worker-sweeper.d.ts +20 -0
  154. package/dist/core/idle-worker-sweeper.d.ts.map +1 -1
  155. package/dist/core/idle-worker-sweeper.js +18 -0
  156. package/dist/core/idle-worker-sweeper.js.map +1 -1
  157. package/dist/core/inflight-input-tracker.d.ts +10 -0
  158. package/dist/core/inflight-input-tracker.d.ts.map +1 -1
  159. package/dist/core/inflight-input-tracker.js +7 -0
  160. package/dist/core/inflight-input-tracker.js.map +1 -1
  161. package/dist/core/managed-origin-attestation.d.ts +51 -0
  162. package/dist/core/managed-origin-attestation.d.ts.map +1 -0
  163. package/dist/core/managed-origin-attestation.js +208 -0
  164. package/dist/core/managed-origin-attestation.js.map +1 -0
  165. package/dist/core/managed-origin-capability.d.ts +62 -3
  166. package/dist/core/managed-origin-capability.d.ts.map +1 -1
  167. package/dist/core/managed-origin-capability.js +419 -9
  168. package/dist/core/managed-origin-capability.js.map +1 -1
  169. package/dist/core/pending-repo-journal.d.ts +6 -0
  170. package/dist/core/pending-repo-journal.d.ts.map +1 -0
  171. package/dist/core/pending-repo-journal.js +85 -0
  172. package/dist/core/pending-repo-journal.js.map +1 -0
  173. package/dist/core/persistent-backend.d.ts +6 -1
  174. package/dist/core/persistent-backend.d.ts.map +1 -1
  175. package/dist/core/persistent-backend.js +12 -6
  176. package/dist/core/persistent-backend.js.map +1 -1
  177. package/dist/core/process-start-identity.d.ts +3 -0
  178. package/dist/core/process-start-identity.d.ts.map +1 -0
  179. package/dist/core/process-start-identity.js +62 -0
  180. package/dist/core/process-start-identity.js.map +1 -0
  181. package/dist/core/raw-command-writer.d.ts +34 -0
  182. package/dist/core/raw-command-writer.d.ts.map +1 -0
  183. package/dist/core/raw-command-writer.js +48 -0
  184. package/dist/core/raw-command-writer.js.map +1 -0
  185. package/dist/core/reply-target.d.ts +85 -22
  186. package/dist/core/reply-target.d.ts.map +1 -1
  187. package/dist/core/reply-target.js +267 -32
  188. package/dist/core/reply-target.js.map +1 -1
  189. package/dist/core/resource-monitor/runtime.d.ts.map +1 -1
  190. package/dist/core/resource-monitor/runtime.js +4 -1
  191. package/dist/core/resource-monitor/runtime.js.map +1 -1
  192. package/dist/core/restart-report.d.ts +6 -1
  193. package/dist/core/restart-report.d.ts.map +1 -1
  194. package/dist/core/restart-report.js +22 -4
  195. package/dist/core/restart-report.js.map +1 -1
  196. package/dist/core/riff-shutdown-detach.d.ts +164 -0
  197. package/dist/core/riff-shutdown-detach.d.ts.map +1 -0
  198. package/dist/core/riff-shutdown-detach.js +787 -0
  199. package/dist/core/riff-shutdown-detach.js.map +1 -0
  200. package/dist/core/riff-worker-shutdown-readiness.d.ts +17 -0
  201. package/dist/core/riff-worker-shutdown-readiness.d.ts.map +1 -0
  202. package/dist/core/riff-worker-shutdown-readiness.js +21 -0
  203. package/dist/core/riff-worker-shutdown-readiness.js.map +1 -0
  204. package/dist/core/session-manager.d.ts +6 -6
  205. package/dist/core/session-manager.d.ts.map +1 -1
  206. package/dist/core/session-manager.js +1227 -594
  207. package/dist/core/session-manager.js.map +1 -1
  208. package/dist/core/session-marker.d.ts +1 -1
  209. package/dist/core/session-marker.d.ts.map +1 -1
  210. package/dist/core/session-marker.js +2 -2
  211. package/dist/core/session-marker.js.map +1 -1
  212. package/dist/core/session-mutation-guard.d.ts +17 -0
  213. package/dist/core/session-mutation-guard.d.ts.map +1 -0
  214. package/dist/core/session-mutation-guard.js +33 -0
  215. package/dist/core/session-mutation-guard.js.map +1 -0
  216. package/dist/core/shutdown-budgets.d.ts +27 -0
  217. package/dist/core/shutdown-budgets.d.ts.map +1 -0
  218. package/dist/core/shutdown-budgets.js +47 -0
  219. package/dist/core/shutdown-budgets.js.map +1 -0
  220. package/dist/core/supervisor-shutdown-ipc.d.ts +10 -0
  221. package/dist/core/supervisor-shutdown-ipc.d.ts.map +1 -0
  222. package/dist/core/supervisor-shutdown-ipc.js +10 -0
  223. package/dist/core/supervisor-shutdown-ipc.js.map +1 -0
  224. package/dist/core/supervisor-shutdown-protocol.d.ts +16 -0
  225. package/dist/core/supervisor-shutdown-protocol.d.ts.map +1 -0
  226. package/dist/core/supervisor-shutdown-protocol.js +16 -0
  227. package/dist/core/supervisor-shutdown-protocol.js.map +1 -0
  228. package/dist/core/trigger-final-suppression.d.ts +4 -0
  229. package/dist/core/trigger-final-suppression.d.ts.map +1 -1
  230. package/dist/core/trigger-final-suppression.js +6 -11
  231. package/dist/core/trigger-final-suppression.js.map +1 -1
  232. package/dist/core/trigger-session.d.ts +70 -0
  233. package/dist/core/trigger-session.d.ts.map +1 -1
  234. package/dist/core/trigger-session.js +923 -145
  235. package/dist/core/trigger-session.js.map +1 -1
  236. package/dist/core/types.d.ts +94 -1
  237. package/dist/core/types.d.ts.map +1 -1
  238. package/dist/core/types.js +9 -0
  239. package/dist/core/types.js.map +1 -1
  240. package/dist/core/worker-pool.d.ts +194 -13
  241. package/dist/core/worker-pool.d.ts.map +1 -1
  242. package/dist/core/worker-pool.js +2597 -547
  243. package/dist/core/worker-pool.js.map +1 -1
  244. package/dist/daemon.d.ts +76 -2
  245. package/dist/daemon.d.ts.map +1 -1
  246. package/dist/daemon.js +2255 -773
  247. package/dist/daemon.js.map +1 -1
  248. package/dist/dashboard/bot-payload.d.ts +20 -0
  249. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  250. package/dist/dashboard/bot-payload.js +24 -0
  251. package/dist/dashboard/bot-payload.js.map +1 -1
  252. package/dist/dashboard/feed-groups.d.ts +17 -0
  253. package/dist/dashboard/feed-groups.d.ts.map +1 -0
  254. package/dist/dashboard/feed-groups.js +75 -0
  255. package/dist/dashboard/feed-groups.js.map +1 -0
  256. package/dist/dashboard/session-card-model.d.ts +1 -1
  257. package/dist/dashboard/session-card-model.d.ts.map +1 -1
  258. package/dist/dashboard/session-card-model.js +8 -5
  259. package/dist/dashboard/session-card-model.js.map +1 -1
  260. package/dist/dashboard/web/bot-defaults-page.d.ts +4 -0
  261. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  262. package/dist/dashboard/web/bot-defaults-page.js +139 -46
  263. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  264. package/dist/dashboard/web/bot-defaults.d.ts +4 -0
  265. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  266. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  267. package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
  268. package/dist/dashboard/web/connectors-page.js +40 -3
  269. package/dist/dashboard/web/connectors-page.js.map +1 -1
  270. package/dist/dashboard/web/groups-page.d.ts.map +1 -1
  271. package/dist/dashboard/web/groups-page.js +95 -2
  272. package/dist/dashboard/web/groups-page.js.map +1 -1
  273. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  274. package/dist/dashboard/web/i18n.js +86 -40
  275. package/dist/dashboard/web/i18n.js.map +1 -1
  276. package/dist/dashboard/web/kanban-model.js +1 -1
  277. package/dist/dashboard/web/kanban-model.js.map +1 -1
  278. package/dist/dashboard/web/overview-page.d.ts.map +1 -1
  279. package/dist/dashboard/web/overview-page.js +3 -2
  280. package/dist/dashboard/web/overview-page.js.map +1 -1
  281. package/dist/dashboard/web/overview.d.ts +3 -0
  282. package/dist/dashboard/web/overview.d.ts.map +1 -1
  283. package/dist/dashboard/web/overview.js +2 -0
  284. package/dist/dashboard/web/overview.js.map +1 -1
  285. package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
  286. package/dist/dashboard/web/sessions-kanban.js +2 -0
  287. package/dist/dashboard/web/sessions-kanban.js.map +1 -1
  288. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  289. package/dist/dashboard/web/sessions-page.js +103 -2
  290. package/dist/dashboard/web/sessions-page.js.map +1 -1
  291. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  292. package/dist/dashboard/web/sessions.js +3 -2
  293. package/dist/dashboard/web/sessions.js.map +1 -1
  294. package/dist/dashboard/web/ui.d.ts +8 -0
  295. package/dist/dashboard/web/ui.d.ts.map +1 -1
  296. package/dist/dashboard/web/ui.js +15 -0
  297. package/dist/dashboard/web/ui.js.map +1 -1
  298. package/dist/dashboard-web/app.js +1 -1
  299. package/dist/dashboard-web/chunks/bot-defaults-page-GED4FEMS.js +10 -0
  300. package/dist/dashboard-web/chunks/chunk-6J5LS5AU.js +51 -0
  301. package/dist/dashboard-web/chunks/{chunk-ZU3UUKAR.js → chunk-HK7NIBJG.js} +1 -1
  302. package/dist/dashboard-web/chunks/{chunk-3UE7S77S.js → chunk-KRHWZEXU.js} +1 -1
  303. package/dist/dashboard-web/chunks/chunk-VBXJOJFH.js +1 -0
  304. package/dist/dashboard-web/chunks/connectors-page-EELM66G3.js +1 -0
  305. package/dist/dashboard-web/chunks/groups-page-WKUV6E42.js +8 -0
  306. package/dist/dashboard-web/chunks/{insights-page-T2ZX2RWL.js → insights-page-54UBPPOC.js} +1 -1
  307. package/dist/dashboard-web/chunks/{monitor-room-YNVBAGR6.js → monitor-room-PORHVKRH.js} +1 -1
  308. package/dist/dashboard-web/chunks/{monitoring-page-LHM55YWN.js → monitoring-page-XGA7AXNL.js} +1 -1
  309. package/dist/dashboard-web/chunks/{office-page-ATXVP7PW.js → office-page-AO3AFG45.js} +1 -1
  310. package/dist/dashboard-web/chunks/overview-page-F5BUTYR6.js +1 -0
  311. package/dist/dashboard-web/chunks/{roles-page-56VWM6CD.js → roles-page-M5GIASJU.js} +1 -1
  312. package/dist/dashboard-web/chunks/{schedules-page-S5K6U4EO.js → schedules-page-O232WQRR.js} +1 -1
  313. package/dist/dashboard-web/chunks/sessions-page-YFRPSTIL.js +6 -0
  314. package/dist/dashboard-web/chunks/{settings-page-QUZDQARR.js → settings-page-OJAZLZQT.js} +1 -1
  315. package/dist/dashboard-web/chunks/{skills-page-IFY7AEND.js → skills-page-EFZQPWV4.js} +1 -1
  316. package/dist/dashboard-web/chunks/{team-federation-page-C7FJQPFZ.js → team-federation-page-ZJVAP7HW.js} +1 -1
  317. package/dist/dashboard-web/chunks/{v3-page-2LMF3WWR.js → v3-page-GYWTRGO2.js} +1 -1
  318. package/dist/dashboard-web/chunks/{whiteboards-page-3XC6QRMY.js → whiteboards-page-MSDAG6P7.js} +1 -1
  319. package/dist/dashboard-web/style.css +255 -0
  320. package/dist/dashboard.js +172 -7
  321. package/dist/dashboard.js.map +1 -1
  322. package/dist/desktop/renderer/app.js +21 -0
  323. package/dist/desktop/renderer/app.js.map +1 -1
  324. package/dist/i18n/en.d.ts.map +1 -1
  325. package/dist/i18n/en.js +35 -9
  326. package/dist/i18n/en.js.map +1 -1
  327. package/dist/i18n/zh.d.ts.map +1 -1
  328. package/dist/i18n/zh.js +35 -9
  329. package/dist/i18n/zh.js.map +1 -1
  330. package/dist/im/lark/ask-card.d.ts +23 -0
  331. package/dist/im/lark/ask-card.d.ts.map +1 -1
  332. package/dist/im/lark/ask-card.js +94 -4
  333. package/dist/im/lark/ask-card.js.map +1 -1
  334. package/dist/im/lark/card-builder.d.ts +6 -1
  335. package/dist/im/lark/card-builder.d.ts.map +1 -1
  336. package/dist/im/lark/card-builder.js +112 -15
  337. package/dist/im/lark/card-builder.js.map +1 -1
  338. package/dist/im/lark/card-handler.d.ts +1 -1
  339. package/dist/im/lark/card-handler.d.ts.map +1 -1
  340. package/dist/im/lark/card-handler.js +496 -310
  341. package/dist/im/lark/card-handler.js.map +1 -1
  342. package/dist/im/lark/client.d.ts +7 -0
  343. package/dist/im/lark/client.d.ts.map +1 -1
  344. package/dist/im/lark/client.js +39 -26
  345. package/dist/im/lark/client.js.map +1 -1
  346. package/dist/im/lark/doc-comment.d.ts +7 -4
  347. package/dist/im/lark/doc-comment.d.ts.map +1 -1
  348. package/dist/im/lark/doc-comment.js +19 -6
  349. package/dist/im/lark/doc-comment.js.map +1 -1
  350. package/dist/im/lark/event-dispatcher.d.ts +16 -0
  351. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  352. package/dist/im/lark/event-dispatcher.js +88 -23
  353. package/dist/im/lark/event-dispatcher.js.map +1 -1
  354. package/dist/im/lark/forward-followup-content.d.ts.map +1 -1
  355. package/dist/im/lark/forward-followup-content.js +9 -1
  356. package/dist/im/lark/forward-followup-content.js.map +1 -1
  357. package/dist/im/lark/grant-command.d.ts.map +1 -1
  358. package/dist/im/lark/grant-command.js +7 -4
  359. package/dist/im/lark/grant-command.js.map +1 -1
  360. package/dist/im/lark/md-card.d.ts +11 -0
  361. package/dist/im/lark/md-card.d.ts.map +1 -1
  362. package/dist/im/lark/md-card.js +46 -0
  363. package/dist/im/lark/md-card.js.map +1 -1
  364. package/dist/im/lark/message-parser.d.ts +23 -2
  365. package/dist/im/lark/message-parser.d.ts.map +1 -1
  366. package/dist/im/lark/message-parser.js +58 -3
  367. package/dist/im/lark/message-parser.js.map +1 -1
  368. package/dist/im/lark/overview-card.d.ts.map +1 -1
  369. package/dist/im/lark/overview-card.js +2 -1
  370. package/dist/im/lark/overview-card.js.map +1 -1
  371. package/dist/services/async-trigger-state.d.ts +6 -1
  372. package/dist/services/async-trigger-state.d.ts.map +1 -1
  373. package/dist/services/async-trigger-state.js +21 -0
  374. package/dist/services/async-trigger-state.js.map +1 -1
  375. package/dist/services/async-trigger-store.d.ts +23 -1
  376. package/dist/services/async-trigger-store.d.ts.map +1 -1
  377. package/dist/services/async-trigger-store.js +96 -13
  378. package/dist/services/async-trigger-store.js.map +1 -1
  379. package/dist/services/bridge-fallback-gate.d.ts +40 -0
  380. package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
  381. package/dist/services/bridge-fallback-gate.js +124 -24
  382. package/dist/services/bridge-fallback-gate.js.map +1 -1
  383. package/dist/services/codex-app-runner-protocol.d.ts +8 -0
  384. package/dist/services/codex-app-runner-protocol.d.ts.map +1 -1
  385. package/dist/services/codex-app-runner-protocol.js +9 -2
  386. package/dist/services/codex-app-runner-protocol.js.map +1 -1
  387. package/dist/services/codex-bridge-queue.d.ts +1 -0
  388. package/dist/services/codex-bridge-queue.d.ts.map +1 -1
  389. package/dist/services/codex-bridge-queue.js +1 -0
  390. package/dist/services/codex-bridge-queue.js.map +1 -1
  391. package/dist/services/codex-service-tier.d.ts +2 -0
  392. package/dist/services/codex-service-tier.d.ts.map +1 -1
  393. package/dist/services/codex-service-tier.js +1 -0
  394. package/dist/services/codex-service-tier.js.map +1 -1
  395. package/dist/services/codex-transcript.d.ts +27 -0
  396. package/dist/services/codex-transcript.d.ts.map +1 -1
  397. package/dist/services/codex-transcript.js +340 -2
  398. package/dist/services/codex-transcript.js.map +1 -1
  399. package/dist/services/grant-policy.d.ts +3 -0
  400. package/dist/services/grant-policy.d.ts.map +1 -1
  401. package/dist/services/grant-policy.js +5 -0
  402. package/dist/services/grant-policy.js.map +1 -1
  403. package/dist/services/grant-prefs-store.d.ts +6 -3
  404. package/dist/services/grant-prefs-store.d.ts.map +1 -1
  405. package/dist/services/grant-prefs-store.js +42 -12
  406. package/dist/services/grant-prefs-store.js.map +1 -1
  407. package/dist/services/hook-runner.d.ts +20 -1
  408. package/dist/services/hook-runner.d.ts.map +1 -1
  409. package/dist/services/hook-runner.js +17 -13
  410. package/dist/services/hook-runner.js.map +1 -1
  411. package/dist/services/idempotency-store.d.ts +134 -0
  412. package/dist/services/idempotency-store.d.ts.map +1 -0
  413. package/dist/services/idempotency-store.js +299 -0
  414. package/dist/services/idempotency-store.js.map +1 -0
  415. package/dist/services/jsonl-cursor.d.ts +7 -3
  416. package/dist/services/jsonl-cursor.d.ts.map +1 -1
  417. package/dist/services/jsonl-cursor.js +55 -31
  418. package/dist/services/jsonl-cursor.js.map +1 -1
  419. package/dist/services/open-platform-rename.d.ts.map +1 -1
  420. package/dist/services/open-platform-rename.js +1 -108
  421. package/dist/services/open-platform-rename.js.map +1 -1
  422. package/dist/services/restart-intent-store.d.ts +22 -2
  423. package/dist/services/restart-intent-store.d.ts.map +1 -1
  424. package/dist/services/restart-intent-store.js +160 -18
  425. package/dist/services/restart-intent-store.js.map +1 -1
  426. package/dist/services/sandbox-store.d.ts +22 -0
  427. package/dist/services/sandbox-store.d.ts.map +1 -1
  428. package/dist/services/sandbox-store.js +37 -8
  429. package/dist/services/sandbox-store.js.map +1 -1
  430. package/dist/services/send-policy.d.ts +41 -33
  431. package/dist/services/send-policy.d.ts.map +1 -1
  432. package/dist/services/send-policy.js +39 -40
  433. package/dist/services/send-policy.js.map +1 -1
  434. package/dist/services/session-store.d.ts +66 -0
  435. package/dist/services/session-store.d.ts.map +1 -1
  436. package/dist/services/session-store.js +403 -67
  437. package/dist/services/session-store.js.map +1 -1
  438. package/dist/services/submit-notification.d.ts +3 -0
  439. package/dist/services/submit-notification.d.ts.map +1 -0
  440. package/dist/services/submit-notification.js +15 -0
  441. package/dist/services/submit-notification.js.map +1 -0
  442. package/dist/services/traex-transcript.d.ts +25 -2
  443. package/dist/services/traex-transcript.d.ts.map +1 -1
  444. package/dist/services/traex-transcript.js +133 -1
  445. package/dist/services/traex-transcript.js.map +1 -1
  446. package/dist/services/trigger-types.d.ts +13 -1
  447. package/dist/services/trigger-types.d.ts.map +1 -1
  448. package/dist/services/trigger-types.js +39 -0
  449. package/dist/services/trigger-types.js.map +1 -1
  450. package/dist/services/voice/index.d.ts +3 -2
  451. package/dist/services/voice/index.d.ts.map +1 -1
  452. package/dist/services/voice/index.js +5 -5
  453. package/dist/services/voice/index.js.map +1 -1
  454. package/dist/services/voice/openai.d.ts +2 -1
  455. package/dist/services/voice/openai.d.ts.map +1 -1
  456. package/dist/services/voice/openai.js +2 -1
  457. package/dist/services/voice/openai.js.map +1 -1
  458. package/dist/services/voice/sami.d.ts +5 -2
  459. package/dist/services/voice/sami.d.ts.map +1 -1
  460. package/dist/services/voice/sami.js +19 -4
  461. package/dist/services/voice/sami.js.map +1 -1
  462. package/dist/setup/bots-store.d.ts.map +1 -1
  463. package/dist/setup/bots-store.js +10 -4
  464. package/dist/setup/bots-store.js.map +1 -1
  465. package/dist/setup/lark-scopes.json +2 -0
  466. package/dist/setup/open-platform-automation.d.ts +9 -1
  467. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  468. package/dist/setup/open-platform-automation.js +44 -15
  469. package/dist/setup/open-platform-automation.js.map +1 -1
  470. package/dist/setup/open-platform-visibility.d.ts +42 -0
  471. package/dist/setup/open-platform-visibility.d.ts.map +1 -0
  472. package/dist/setup/open-platform-visibility.js +133 -0
  473. package/dist/setup/open-platform-visibility.js.map +1 -0
  474. package/dist/setup/verify-permissions.d.ts.map +1 -1
  475. package/dist/setup/verify-permissions.js +6 -0
  476. package/dist/setup/verify-permissions.js.map +1 -1
  477. package/dist/skills/definitions.js +2 -2
  478. package/dist/types.d.ts +290 -13
  479. package/dist/types.d.ts.map +1 -1
  480. package/dist/utils/bot-routing.d.ts +1 -0
  481. package/dist/utils/bot-routing.d.ts.map +1 -1
  482. package/dist/utils/bot-routing.js +1 -1
  483. package/dist/utils/bot-routing.js.map +1 -1
  484. package/dist/utils/child-env.d.ts +1 -1
  485. package/dist/utils/child-env.d.ts.map +1 -1
  486. package/dist/utils/child-env.js +9 -0
  487. package/dist/utils/child-env.js.map +1 -1
  488. package/dist/utils/codex-app-control.d.ts +438 -0
  489. package/dist/utils/codex-app-control.d.ts.map +1 -0
  490. package/dist/utils/codex-app-control.js +2336 -0
  491. package/dist/utils/codex-app-control.js.map +1 -0
  492. package/dist/utils/codex-app-dispatch-ledger.d.ts +78 -0
  493. package/dist/utils/codex-app-dispatch-ledger.d.ts.map +1 -0
  494. package/dist/utils/codex-app-dispatch-ledger.js +149 -0
  495. package/dist/utils/codex-app-dispatch-ledger.js.map +1 -0
  496. package/dist/utils/codex-app-turn-dispatch.d.ts +77 -0
  497. package/dist/utils/codex-app-turn-dispatch.d.ts.map +1 -0
  498. package/dist/utils/codex-app-turn-dispatch.js +141 -0
  499. package/dist/utils/codex-app-turn-dispatch.js.map +1 -0
  500. package/dist/utils/codex-app-turn-liveness.d.ts +125 -0
  501. package/dist/utils/codex-app-turn-liveness.d.ts.map +1 -0
  502. package/dist/utils/codex-app-turn-liveness.js +301 -0
  503. package/dist/utils/codex-app-turn-liveness.js.map +1 -0
  504. package/dist/utils/idle-detector.d.ts +12 -0
  505. package/dist/utils/idle-detector.d.ts.map +1 -1
  506. package/dist/utils/idle-detector.js +19 -0
  507. package/dist/utils/idle-detector.js.map +1 -1
  508. package/dist/utils/input-gate.d.ts +87 -0
  509. package/dist/utils/input-gate.d.ts.map +1 -1
  510. package/dist/utils/input-gate.js +86 -0
  511. package/dist/utils/input-gate.js.map +1 -1
  512. package/dist/utils/lark-upload.d.ts.map +1 -1
  513. package/dist/utils/lark-upload.js +52 -1
  514. package/dist/utils/lark-upload.js.map +1 -1
  515. package/dist/utils/pending-input-queue.d.ts +15 -0
  516. package/dist/utils/pending-input-queue.d.ts.map +1 -1
  517. package/dist/utils/pending-input-queue.js +5 -1
  518. package/dist/utils/pending-input-queue.js.map +1 -1
  519. package/dist/utils/user-token.d.ts.map +1 -1
  520. package/dist/utils/user-token.js +53 -2
  521. package/dist/utils/user-token.js.map +1 -1
  522. package/dist/worker-ipc-preload.d.ts +12 -0
  523. package/dist/worker-ipc-preload.d.ts.map +1 -0
  524. package/dist/worker-ipc-preload.js +44 -0
  525. package/dist/worker-ipc-preload.js.map +1 -0
  526. package/dist/worker.js +2604 -325
  527. package/dist/worker.js.map +1 -1
  528. package/dist/workflows/v3/session-relay-client.d.ts +1 -0
  529. package/dist/workflows/v3/session-relay-client.d.ts.map +1 -1
  530. package/dist/workflows/v3/session-relay-client.js +12 -3
  531. package/dist/workflows/v3/session-relay-client.js.map +1 -1
  532. package/package.json +1 -1
  533. package/dist/dashboard-web/chunks/bot-defaults-page-LTVOUDIS.js +0 -10
  534. package/dist/dashboard-web/chunks/chunk-DCS35UMD.js +0 -1
  535. package/dist/dashboard-web/chunks/chunk-MFQPCVHY.js +0 -51
  536. package/dist/dashboard-web/chunks/connectors-page-5KWDIMKC.js +0 -1
  537. package/dist/dashboard-web/chunks/groups-page-FHOKYXMW.js +0 -8
  538. package/dist/dashboard-web/chunks/overview-page-QYI3KBDD.js +0 -1
  539. package/dist/dashboard-web/chunks/sessions-page-WBP4N7D2.js +0 -6
package/dist/worker.js CHANGED
@@ -12,22 +12,25 @@
12
12
  * 6. On 'close', kills CLI and exits
13
13
  * 7. On 'restart', kills CLI and re-spawns with --resume
14
14
  */
15
- import { randomBytes } from 'node:crypto';
16
- import { mkdirSync, writeFileSync, unlinkSync, rmdirSync, existsSync, statSync, lstatSync, readdirSync, readlinkSync, readFileSync, realpathSync, copyFileSync, watch as fsWatch, createWriteStream, openSync, closeSync, fstatSync, constants as fsConstants } from 'node:fs';
15
+ import { createHash, randomBytes } from 'node:crypto';
16
+ import { chmodSync, mkdirSync, writeFileSync, unlinkSync, rmdirSync, existsSync, statSync, lstatSync, readdirSync, readlinkSync, readFileSync, realpathSync, copyFileSync, watch as fsWatch, createWriteStream } from 'node:fs';
17
17
  import { atomicWriteFileSync } from './utils/atomic-write.js';
18
18
  import { join, basename, dirname } from 'node:path';
19
19
  import { resolveBotmuxWrapperBinDir, prependBotmuxBin } from './core/botmux-wrapper.js';
20
- import { homedir, tmpdir } from 'node:os';
20
+ import { homedir, tmpdir, userInfo } from 'node:os';
21
21
  import { spawnSync } from 'node:child_process';
22
- import { evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, isolationPaneMarkerContent, } from './adapters/cli/read-isolation.js';
22
+ import { evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneOriginChannel, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, isolationPaneMarkerContent, isolationPanePolicyDigest, } from './adapters/cli/read-isolation.js';
23
23
  import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields, resolveRedirectedAdapterAuthPaths, FsPolicyConfigError } from './adapters/cli/fs-policy.js';
24
24
  import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, probePersistentSession, shouldRejectPersistentPostKillProbe } from './core/persistent-backend.js';
25
+ import { finalizeRawCommandDelivery, writeRawCommandLine } from './core/raw-command-writer.js';
26
+ import { publishCliSessionIdToDaemon } from './core/cli-session-id-publisher.js';
25
27
  import { readProcessStartIdentity } from './core/session-marker.js';
26
28
  import { roleLibraryRoot, roleLibrarySubtree } from './core/role-library.js';
27
29
  import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff, isTranscriptRateLimitEvent, apiErrorMessageText } from './services/claude-transcript.js';
28
30
  import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
29
- import { shouldEmitEmptyCompletedBridgeFallback, shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
30
- import { decideHardTimeoutAction, decideSettleMarkReady, shouldReleaseFirstPromptTimeout, shouldWaitForPostSessionStartPromptEvidence, shouldWriteNow, } from './utils/input-gate.js';
31
+ import { bridgePostText, shouldEmitEmptyCompletedBridgeFallback, shouldEmitFailedBridgeFallback, shouldSuppressBridgeEmit, stripTrailingBridgeSentinelLine } from './services/bridge-fallback-gate.js';
32
+ import { buildSubmitMessagePreview } from './services/submit-notification.js';
33
+ import { decideHardTimeoutAction, decidePostHookPromptEvidence, decideSettleMarkReady, shouldArmPostHookPromptEvidenceFallback, shouldReleaseFirstPromptTimeout, shouldWaitForPostSessionStartPromptEvidence, shouldWriteNow, POST_HOOK_EVIDENCE_FALLBACK_MS, POST_HOOK_EVIDENCE_RETRY_MS, } from './utils/input-gate.js';
31
34
  import { decideCodexRunnerFreshness, CodexRunnerFreshnessInputQueue, shouldHoldCodexRunnerInput, } from './services/codex-runner-freshness.js';
32
35
  import { canStartInjectionFlush, shouldDeferUserFlush, shouldFlushInjectionsFirst } from './core/inject-queue-policy.js';
33
36
  import { decideRestartFollowup, settleDurableTurnForRestart } from './core/restart-followup-policy.js';
@@ -36,6 +39,7 @@ import { CodexUpdateDialogGuard } from './utils/codex-update-dialog.js';
36
39
  import { EffortConfirmDialogGuard, isEffortLevelCommand } from './utils/effort-confirm-dialog.js';
37
40
  import { installStdioEpipeGuard, isIgnorableStreamError } from './utils/stdio-epipe-guard.js';
38
41
  import { handoffQueuedDurableInputsOnBackendExit, mergeQueuedCliInput, pendingInputMayFlush, pendingInputAllowsTypeAhead, resolveInitialPromptDelivery, shouldArmSpawnArgvInitialPromptBusy, shouldTrackArgvBakedFirstPrompt, shouldDeferArgsBakedDurablePrompt, shouldDeferInitialPromptForArgLimit, shouldStopPendingBatch, terminalReleasesDurableTurn, } from './utils/pending-input-queue.js';
42
+ import { riffWorkerShutdownInputBlocker } from './core/riff-worker-shutdown-readiness.js';
39
43
  import { ReadyGate, shouldArmReadyGate } from './utils/ready-gate.js';
40
44
  import { shouldRunStartupCommandsOnSpawn, shouldDeferInitialPromptForStartup } from './core/startup-commands.js';
41
45
  import { sanitizePerBotEnv } from './core/per-bot-env.js';
@@ -58,9 +62,10 @@ import { CodexBridgeQueue } from './services/codex-bridge-queue.js';
58
62
  import { detectCodexComposerState } from './services/codex-composer-state.js';
59
63
  import { generateCodexAppThreadTitle, readCodexAppThreadMetadata, setCodexAppThreadName, } from './services/codex-app-threads.js';
60
64
  import { buildBotmuxLarkNativeSessionTitle } from './core/session-title.js';
61
- import { drainCodexRollout, findCodexRolloutBySessionId, findCodexRolloutByPid, findCodexRolloutSetByPid, codexHistorySidIsOwned, splitCodexEventsByCutoff, extractLastCodexTurn, codexSessionIdFromRolloutPath, scanCodexThreadSettings } from './services/codex-transcript.js';
65
+ import { CODEX_AUTH_ERROR_CODE, CODEX_CONNECTION_ERROR_CODE, CODEX_INVALID_REQUEST_ERROR_CODE, CODEX_RATE_LIMIT_ERROR_CODE, drainCodexRollout, findCodexRolloutBySessionId, findCodexRolloutByPid, findCodexRolloutSetByPid, codexHistorySidIsOwned, splitCodexEventsByCutoff, extractLastCodexTurn, codexSessionIdFromRolloutPath, isCodexRateLimitEvent, scanCodexThreadSettings, readLatestCodexRuntime } from './services/codex-transcript.js';
62
66
  import { CodexServiceTierTracker, resolveCodexServiceTierSnapshot } from './services/codex-service-tier.js';
63
- import { drainTraexRollout, findTraexRolloutBySessionId, findTraexRolloutByPid, findTraexRolloutSetByPid, traexHistorySidIsOwned } from './services/traex-transcript.js';
67
+ import { WORKER_IPC_HANDLER_READY_EVENT } from './worker-ipc-preload.js';
68
+ import { drainTraexRollout, findTraexRolloutBySessionId, findTraexRolloutByPid, findTraexRolloutSetByPid, readLatestTraexRuntime, traexHistorySidIsOwned } from './services/traex-transcript.js';
64
69
  import { parseTraexUserInputQuestions } from './services/traex-user-input.js';
65
70
  import { cocoEventsPathForSession, drainCocoEvents, findCocoSessionByPid } from './services/coco-transcript.js';
66
71
  import { currentHermesStateOffset, drainHermesStateDb, resolveHermesStateDbPath } from './services/hermes-transcript.js';
@@ -76,6 +81,7 @@ import { extractKiroSessionIdFromOutput } from './services/kiro-session.js';
76
81
  import { baselineJsonlCursor } from './services/jsonl-cursor.js';
77
82
  import { fileURLToPath } from 'node:url';
78
83
  import { createServer as createHttpServer } from 'node:http';
84
+ import { createServer as createNetServer } from 'node:net';
79
85
  import { WebSocketServer, WebSocket } from 'ws';
80
86
  import { listenWebTerminalWithFallback } from './utils/web-terminal-listen.js';
81
87
  import { HerdrWebTerminalBinding } from './utils/herdr-web-terminal-binding.js';
@@ -127,7 +133,6 @@ import { decideSubmitConfirmationAction } from './services/submit-confirmation.j
127
133
  import { config, resolveChatBotDiscoveryConfig } from './config.js';
128
134
  import * as sessionStore from './services/session-store.js';
129
135
  import * as pty from 'node-pty';
130
- import { createHash } from 'node:crypto';
131
136
  import { hasInstalledSessionReadyHook, installHook, } from './adapters/hook-installer.js';
132
137
  import { hookCommandFor } from './adapters/hook-command.js';
133
138
  import { findOnlineDaemon, parseDaemonIpcPort } from './utils/daemon-discovery.js';
@@ -135,8 +140,8 @@ import { fetchDaemonIpc } from './core/daemon-ipc-auth.js';
135
140
  import { withCodexAppContext } from './utils/codex-app-context.js';
136
141
  import { resolveCodexAppFinalTurnIdentity } from './adapters/cli/codex-app-turn.js';
137
142
  import { RunnerControlDecoder } from './adapters/cli/runner-control-channel.js';
138
- import { normalizeAppRunnerFinalMarker, normalizeCodexAppLifecycleEvent, projectAppRunnerFinalIds, } from './services/codex-app-runner-protocol.js';
139
- import { hasMatchingManagedOriginCapability, managedOriginCapabilityPath, RELAY_ORIGIN_CAPABILITY_BASENAME, replaceManagedOriginCapabilityFile, } from './core/managed-origin-capability.js';
143
+ import { normalizeCodexAppLifecycleEvent, normalizeFinalUsage, } from './services/codex-app-runner-protocol.js';
144
+ import { hasMatchingManagedOriginCapability, ensureManagedOriginAttestationDirectory, ensureManagedOriginCapabilityLeafSafe, ensureManagedOriginDataRootProbe, ensureManagedOriginIsolationSentinel, ensureManagedOriginRootLocator, managedOriginLegacyIsolationProbeAccess, managedOriginDataRootProbeAccess, managedOriginIsolationSentinelAccess, managedOriginAttestationDirectory, managedOriginCapabilityPath, managedOriginRootLocatorPath, readManagedOriginAuthorityFile, RELAY_ORIGIN_CAPABILITY_BASENAME, replaceManagedOriginCapabilityFile, sweepManagedOriginAttestationProofs, } from './core/managed-origin-capability.js';
140
145
  import { CodexRpcEngine } from './codex-rpc-engine.js';
141
146
  // A worker must never trust an INHERITED session-level CLI home pointer
142
147
  // (CLAUDE_CONFIG_DIR / CODEX_HOME): a stale pm2 dump can resurrect the daemon
@@ -153,6 +158,12 @@ import { CodexRpcEngine } from './codex-rpc-engine.js';
153
158
  // dirs. See SESSION_CLI_HOME_ENV_KEYS for why deleting beats pinning a
154
159
  // default (~/.claude relocates Claude's state file → onboarding rerun) and
155
160
  // why GROK_HOME is exempt.
161
+ import { applyTrustedCodexAppActivityMarker, applyTrustedCodexAppStateMarker, CODEX_APP_NO_PROGRESS_TIMEOUT_MS, CodexAppFlushPromptReplay, CodexAppReadyAuthority, CodexAppTurnLiveness, } from './utils/codex-app-turn-liveness.js';
162
+ import { CodexAppTurnDispatchQueue } from './utils/codex-app-turn-dispatch.js';
163
+ import { committedCodexAppSequence, validateCodexAppManagedSendOrigin, } from './utils/codex-app-dispatch-ledger.js';
164
+ import { CODEX_APP_CONTROL_BOOTSTRAP_ENV, CODEX_APP_CONTROL_STARTUP_TIMEOUT_MS, CodexAppControlFinalAssembler, CodexAppControlLineDecoder, CodexAppControlProofDeadline, CodexAppControlRecordApplicationGate, CodexAppControlReplayWindow, CodexAppControlSequenceFence, activateCodexAppControlIdentity, acquireCodexAppControlOwnerLease, acquireCodexAppPosixOwnerLease, armCodexAppControlStartupTimeout, authenticateCodexAppControlCandidate, bindThenPublishCodexAppControlLocator, cleanupStaleCodexAppControlBootstraps, codexAppSignedStateReadiness, codexAppControlLocatorPath, codexAppPosixControlRoot, codexAppControlStatePath, codexAppWindowsOwnerPipeEndpoint, codexAppWindowsControlRoot, createCodexAppControlBootstrap, encodeCodexAppControlAck, encodeCodexAppControlAccepted, encodeCodexAppControlChallenge, ensureCodexAppControlDirectory, generateCodexAppControlEpoch, generateCodexAppControlChallenge, generateCodexAppPosixSocketEndpoint, generateCodexAppWindowsPipeEndpoint, hardenWindowsCodexAppControlFile, mergeCodexAppControlCandidate, parseCodexAppControlWireRecord, projectCodexAppControlReadinessStatus, readCodexAppControlState, shouldColdStartCodexAppReattach, shouldFailCodexAppControlChannel, verifyCodexAppSignedControlMarker, writeCodexAppControlLocator, writeCodexAppControlState, } from './utils/codex-app-control.js';
165
+ // Never inherit a session-level CLI home from the daemon's launch environment.
166
+ // Each worker re-pins the current bot's home after this scrub.
156
167
  scrubSessionCliHomeEnv(process.env);
157
168
  // Claude session-identity markers ride the same restart-from-a-session vector
158
169
  // and this process's env seeds childEnv AND the tmux client env — a marker
@@ -649,6 +660,16 @@ async function engageCodexRpc(cfg) {
649
660
  // terminal are the authoritative recovery surface (Codex P1).
650
661
  if (shouldPreMarkFirstTurn(first))
651
662
  codexBridgeMarkPendingTurn(cfg.prompt, cfg.turnId);
663
+ if (first === 'accepted' && cfg.queuedActivationToken) {
664
+ // Fresh RPC input bypasses pendingMessages/flushPending entirely. The
665
+ // app-server's accepted turn/start (or positive rollout proof) is the
666
+ // durable submission boundary for the daemon's queued-opening journal.
667
+ send({
668
+ type: 'queued_activation_submitted',
669
+ sessionId: cfg.sessionId,
670
+ activationToken: cfg.queuedActivationToken,
671
+ });
672
+ }
652
673
  outcome = first; // 'accepted' | 'ambiguous' — both stay engaged, prompt never re-queued
653
674
  }
654
675
  codexRpcEngine = engine;
@@ -733,6 +754,13 @@ function armRpcStartupDialogDismiss() {
733
754
  };
734
755
  rpcDialogDismissTimer = setTimeout(tick, 2500);
735
756
  }
757
+ let cliSpawnGeneration = 0;
758
+ class CliSpawnSupersededError extends Error {
759
+ constructor() {
760
+ super('CLI spawn was superseded by a newer lifecycle operation');
761
+ this.name = 'CliSpawnSupersededError';
762
+ }
763
+ }
736
764
  let cliPidMarker = null; // path to .botmux-cli-pids/<pid>
737
765
  let seatbeltProfilePath = null; // per-session Seatbelt .sb profile to rm at exit (external-wrapper read isolation)
738
766
  let sandboxStopWatcher = null; // stop fn for the sandbox outbox watcher
@@ -740,6 +768,7 @@ let sandboxCleanup = null; // reclaim deny-mask mountpoints + rm the per-session
740
768
  let sandboxRelayOutbox = null;
741
769
  let sandboxRelayCapability = null;
742
770
  let readIsolationOriginCapabilityFile = null;
771
+ let readIsolationOriginChannelId = null;
743
772
  let sandboxTeardownDone = false; // guards the exit-time best-effort teardown from double-running / running on suspend-for-resume
744
773
  let sessionMcpGatewayHost = null;
745
774
  /** Counts consecutive in-worker restart cycles (see case 'restart'). Used by
@@ -1077,6 +1106,13 @@ function backendScreenEvidenceIsAuthoritativeForMutation() {
1077
1106
  * generation. The daemon receives this over private IPC; child-writable PID
1078
1107
  * marker files remain diagnostics only. */
1079
1108
  let currentCliCredentialIsolated = false;
1109
+ /** Successful Riff close prepare awaiting durable daemon commit. */
1110
+ let preparedCloseRequestId = null;
1111
+ let closeRequestInFlightId = null;
1112
+ let lastAbortedCloseRequestId = null;
1113
+ /** Graceful daemon-shutdown detach stays alive until lineage commit. */
1114
+ let shutdownDetachRequestId = null;
1115
+ let shutdownDetachPhase = null;
1080
1116
  /** pty-under-zellij backend (BACKEND_TYPE=zellij). Behaves like the non-tmux
1081
1117
  * pty path for the worker (renderer screenshots, relay web terminal) but owns
1082
1118
  * a persistent zellij session that survives daemon restart. */
@@ -1234,7 +1270,7 @@ const READY_SIGNAL_TIMEOUT_MS = 45_000;
1234
1270
  const FIRST_PROMPT_TIMEOUT_MS = 15_000;
1235
1271
  /** Hard cap for startup screens that outlive the soft fallback. Prevents a
1236
1272
  * changed/missing readyPattern from trapping the first queued input forever. */
1237
- const FIRST_PROMPT_HARD_TIMEOUT_MS = 90_000;
1273
+ const FIRST_PROMPT_HARD_TIMEOUT_MS = CODEX_APP_CONTROL_STARTUP_TIMEOUT_MS;
1238
1274
  /** Epoch ms of the most recent PTY output — used to settle for quiescence
1239
1275
  * before the first flush (see settleThenFlush). */
1240
1276
  let lastPtyOutputAtMs = 0;
@@ -1339,53 +1375,19 @@ const COCO_SLASH_TYPE_THROTTLE_MS = 40;
1339
1375
  * single write + CR. Shared by the `raw_input` IPC handler and runStartupCommands
1340
1376
  * so both stay in lockstep. */
1341
1377
  async function sendRawCommandLine(be, content) {
1342
- if ('sendText' in be && 'sendSpecialKeys' in be) {
1343
- if (lastInitConfig?.cliId === 'coco') {
1344
- // CoCo (Trae CLI, Ink TUI) detects "several bytes in one PTY read = paste",
1345
- // so a one-shot sendText('/model') lands as PASTED text: command mode + the
1346
- // slash picker never activate and the trailing Enter submits `/model` to the
1347
- // model (the "/model 多一个换行" bug). Fix: type char-by-char (throttled) so
1348
- // each char is a distinct keystroke that opens command mode, and append ONE
1349
- // trailing space to a bare command so the suggestion popup is dismissed.
1350
- // Without that dismissal the popup captures the first Enter (CoCo then needs
1351
- // two), and for an interactive command like /model — which opens a model
1352
- // selector — a stray second Enter would confirm whatever model is highlighted.
1353
- // Popup gone ⇒ exactly one Enter executes (/model just opens the selector and
1354
- // waits). trim() first so a trailing newline carried from the Lark message
1355
- // isn't typed as a literal newline that re-breaks command detection.
1356
- const cmd = content.trim();
1357
- const typed = cmd.includes(' ') ? cmd : `${cmd} `;
1358
- for (const ch of typed) {
1359
- if (be.sendText(ch) === false) {
1360
- throw new Error('backend rejected command text input');
1361
- }
1362
- await new Promise(r => setTimeout(r, COCO_SLASH_TYPE_THROTTLE_MS));
1363
- }
1364
- await new Promise(r => setTimeout(r, 200));
1365
- if (be.sendSpecialKeys('Enter') === false) {
1366
- throw new Error('backend rejected command submit key');
1367
- }
1368
- return;
1369
- }
1370
- if (be.sendText(content) === false) {
1371
- throw new Error('backend rejected command text input');
1372
- }
1373
- await new Promise(r => setTimeout(r, 200));
1374
- if (be.sendSpecialKeys('Enter') === false) {
1375
- throw new Error('backend rejected command submit key');
1376
- }
1377
- }
1378
- else {
1379
- // PtyBackend has no sendText/sendSpecialKeys, so write the keystrokes
1380
- // directly — but still beat between the text and the Enter. Writing
1381
- // `content + '\r'` in one chunk submits before the CLI's slash-command
1382
- // parser has registered the `/cmd` match, so the command is left
1383
- // unsent in the input box (observed with `/goal <text>` on a pty
1384
- // workflow worker: typed but never executed). Mirror the tmux path's
1385
- // 200ms beat.
1386
- be.write(content);
1387
- await new Promise(r => setTimeout(r, 200));
1388
- be.write('\r');
1378
+ // PR #597 extracted the CoCo/pty keystroke choreography into the shared
1379
+ // writeRawCommandLine helper (unit-tested in raw-command-writer.ts). It
1380
+ // reports a rejected text/Enter write as `false` instead of throwing; master's
1381
+ // recovery-fence transaction detects a failed submission by a thrown error, so
1382
+ // bridge the two: turn an explicit `false` into the same throw the inline
1383
+ // implementation used, letting runAmbiguousSubmissionTransaction cancel the WAL.
1384
+ const accepted = await writeRawCommandLine(be, content, {
1385
+ coco: lastInitConfig?.cliId === 'coco',
1386
+ cocoThrottleMs: COCO_SLASH_TYPE_THROTTLE_MS,
1387
+ submitBeatMs: 200,
1388
+ });
1389
+ if (accepted === false) {
1390
+ throw new Error('backend rejected command text or submit key input');
1389
1391
  }
1390
1392
  }
1391
1393
  /** Serialize one complete raw command-line transaction (fence -> text -> Enter
@@ -1481,6 +1483,9 @@ async function runStartupCommands() {
1481
1483
  }
1482
1484
  const freshnessInputQueue = new CodexRunnerFreshnessInputQueue(() => codexRunnerFreshness, state => { codexRunnerFreshness = state; });
1483
1485
  const pendingMessages = freshnessInputQueue.normal;
1486
+ /** Async init must materialize its opening input before follow-ups may flush
1487
+ * or shutdown may fence the worker generation. */
1488
+ let initPromptMaterialized = false;
1484
1489
  /** Ordinary Lark IM turns may be retransmitted by the daemon when the exact
1485
1490
  * receipt ACK times out. Fence those retries before any renderer / adapter side
1486
1491
  * effects so one Lark message can enter the CLI input queue at most once per
@@ -1603,9 +1608,9 @@ async function deliverRawInput(msg) {
1603
1608
  publishSandboxRelayCapability();
1604
1609
  });
1605
1610
  sent = true;
1606
- isPromptReady = false;
1607
- idleDetector?.reset();
1608
- log(`Passthrough slash command: ${msg.content}`);
1611
+ // The accepted side effects (isPromptReady/idle reset/log) are owned by
1612
+ // finalizeRawCommandDelivery.onAccepted below so the durable-activation
1613
+ // ACK and follow-up enqueue stay in one ordered dispatcher.
1609
1614
  // A mid-session `/effort <level>` opens a Yes/No "Change effort level?"
1610
1615
  // confirm dialog; arm the guard so onPtyData can auto-accept it. Bare
1611
1616
  // `/effort` opens a level picker (unknown target) and is left alone.
@@ -1639,14 +1644,39 @@ async function deliverRawInput(msg) {
1639
1644
  }),
1640
1645
  });
1641
1646
  }
1642
- // Follow-up rides on the same IPC and is enqueued only after this command's
1643
- // Enter lands. sendToPty also observes commandLineWritesPending, so another
1644
- // raw command's text -> Enter window cannot be interrupted by the follow-up.
1645
- if (sent && msg.followUpContent) {
1646
- sendToPty(msg.followUpContent, msg.followUpTurnId, {
1647
- codexAppInput: msg.followUpCodexAppInput,
1648
- });
1649
- log(`Enqueued follow-up after raw input (${msg.followUpContent.length} chars)`);
1647
+ finalizeRawCommandDelivery({
1648
+ accepted: sent,
1649
+ durableActivation: !!msg.queuedActivationToken,
1650
+ acknowledgeActivation: !!msg.queuedActivationToken,
1651
+ hasFollowUp: !!msg.followUpContent,
1652
+ onAccepted: () => {
1653
+ isPromptReady = false;
1654
+ idleDetector?.reset();
1655
+ log(`Passthrough slash command: ${msg.content}`);
1656
+ },
1657
+ // Follow-up rides on the same IPC and is enqueued only after this command's
1658
+ // Enter lands. sendToPty also observes commandLineWritesPending, so another
1659
+ // raw command's text -> Enter window cannot be interrupted by the follow-up.
1660
+ onFollowUp: () => {
1661
+ sendToPty(msg.followUpContent, msg.followUpTurnId, {
1662
+ codexAppInput: msg.followUpCodexAppInput,
1663
+ });
1664
+ log(`Enqueued follow-up after raw input (${msg.followUpContent.length} chars)`);
1665
+ },
1666
+ // Pending-repo raw openings are durable too. ACK only after text + Enter.
1667
+ onActivationAck: () => send({
1668
+ type: 'queued_activation_submitted',
1669
+ sessionId,
1670
+ activationToken: msg.queuedActivationToken,
1671
+ }),
1672
+ onDurableFailure: () => {
1673
+ isPromptReady = false;
1674
+ log(`Durable raw activation write rejected; retiring worker generation`);
1675
+ void sendFatalWorkerErrorAndExit(new Error('durable raw activation was not accepted by the backend'), msg.turnId);
1676
+ },
1677
+ });
1678
+ if (!sent && !msg.queuedActivationToken) {
1679
+ log(`Passthrough slash command was not accepted by the backend: ${msg.content}`);
1650
1680
  }
1651
1681
  // A pending /rename may have been held by the command-write mutex. It still
1652
1682
  // waits for a genuine prompt because isPromptReady was cleared above.
@@ -1677,19 +1707,6 @@ function publishSandboxRelayCapability(opts = {}) {
1677
1707
  body: JSON.stringify({ token: capability.token }),
1678
1708
  }]
1679
1709
  : []),
1680
- ...(readIsolationOriginCapabilityFile && sessionId
1681
- ? [{
1682
- path: readIsolationOriginCapabilityFile,
1683
- body: JSON.stringify({
1684
- sessionId,
1685
- capability: capability.token,
1686
- ...(capability.turnId ? { turnId: capability.turnId } : {}),
1687
- ...(capability.dispatchAttempt !== undefined
1688
- ? { dispatchAttempt: capability.dispatchAttempt }
1689
- : {}),
1690
- }),
1691
- }]
1692
- : []),
1693
1710
  ];
1694
1711
  let publishError;
1695
1712
  for (const file of files) {
@@ -1719,6 +1736,9 @@ function publishSandboxRelayCapability(opts = {}) {
1719
1736
  type: 'managed_turn_origin',
1720
1737
  sessionId,
1721
1738
  capability: capability.token,
1739
+ ...(readIsolationOriginChannelId
1740
+ ? { originChannelId: readIsolationOriginChannelId }
1741
+ : {}),
1722
1742
  ...(capability.turnId ? { turnId: capability.turnId } : {}),
1723
1743
  ...(capability.dispatchAttempt !== undefined
1724
1744
  ? { dispatchAttempt: capability.dispatchAttempt }
@@ -1728,11 +1748,15 @@ function publishSandboxRelayCapability(opts = {}) {
1728
1748
  return true;
1729
1749
  }
1730
1750
  function unlinkManagedOriginCapabilityFiles() {
1751
+ // The Linux outbox belongs to this worker generation and can be removed.
1752
+ // The macOS capability belongs to the persistent pane channel. A warm
1753
+ // Node-worker reattach reuses that path, so stale worker teardown must never
1754
+ // unlink the successor worker's freshly published token. Leaving old bytes
1755
+ // is fail-closed against the daemon's exact live origin tuple.
1731
1756
  const files = [
1732
1757
  sandboxRelayOutbox
1733
1758
  ? join(sandboxRelayOutbox, RELAY_ORIGIN_CAPABILITY_BASENAME)
1734
1759
  : undefined,
1735
- readIsolationOriginCapabilityFile ?? undefined,
1736
1760
  ];
1737
1761
  for (const file of new Set(files.filter((p) => !!p))) {
1738
1762
  try {
@@ -1741,28 +1765,6 @@ function unlinkManagedOriginCapabilityFiles() {
1741
1765
  catch { /* absent or teardown racing */ }
1742
1766
  }
1743
1767
  }
1744
- /** Read a host-owned isolation marker without following a child-planted
1745
- * symlink between lookup and open. */
1746
- function readRegularHostFileNoFollow(filePath) {
1747
- let fd;
1748
- try {
1749
- fd = openSync(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
1750
- if (!fstatSync(fd).isFile())
1751
- return null;
1752
- return readFileSync(fd, 'utf8');
1753
- }
1754
- catch {
1755
- return null;
1756
- }
1757
- finally {
1758
- if (fd !== undefined) {
1759
- try {
1760
- closeSync(fd);
1761
- }
1762
- catch { /* best-effort */ }
1763
- }
1764
- }
1765
- }
1766
1768
  function completeManagedTurnOriginRevocation(revoked, turnId, dispatchAttempt) {
1767
1769
  // Clear local authority before queuing daemon IPC. A forked/delayed process
1768
1770
  // can otherwise win the small window between terminal publication and
@@ -1774,6 +1776,9 @@ function completeManagedTurnOriginRevocation(revoked, turnId, dispatchAttempt) {
1774
1776
  type: 'managed_turn_origin_revoked',
1775
1777
  sessionId,
1776
1778
  ...(revoked ? { capability: revoked.token } : {}),
1779
+ ...(readIsolationOriginChannelId
1780
+ ? { originChannelId: readIsolationOriginChannelId }
1781
+ : {}),
1777
1782
  ...(turnId ? { turnId } : {}),
1778
1783
  ...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
1779
1784
  });
@@ -1818,7 +1823,18 @@ function authorizeManagedSend(claim) {
1818
1823
  if (!capability || !claim.capability || claim.capability !== capability.token) {
1819
1824
  return { ok: false, error: 'origin_mismatch: relay capability is stale or missing' };
1820
1825
  }
1821
- const session = sessionStore.getSession(sessionId);
1826
+ // The daemon owns and rotates this ledger in another process. Never consult
1827
+ // SessionStore's worker-local cache here: it was loaded at init and would
1828
+ // stale-authorize turn N or reject turn N+1 after daemon settlement.
1829
+ const session = sessionStore.getSessionFresh(sessionId);
1830
+ const ledger = session?.codexAppDispatchLedger ?? [];
1831
+ const codexAppManagedOrigin = lastInitConfig?.cliId === 'codex-app'
1832
+ || session?.cliId === 'codex-app';
1833
+ const codexLedgerDecision = validateCodexAppManagedSendOrigin(ledger, capability, codexAppManagedOrigin);
1834
+ if (!codexLedgerDecision.ok) {
1835
+ return { ok: false, error: `origin_mismatch: ${codexLedgerDecision.error}` };
1836
+ }
1837
+ const requiresCodexAppLedger = codexLedgerDecision.requiresLedger;
1822
1838
  const currentImOrigin = currentVcMeetingImTurnOrigin;
1823
1839
  const imOrigin = currentImOrigin?.larkMessageId === capability.turnId
1824
1840
  && currentImOrigin?.receiverSessionId === sessionId
@@ -1839,6 +1855,7 @@ function authorizeManagedSend(claim) {
1839
1855
  ...(capability.dispatchAttempt !== undefined
1840
1856
  ? { dispatchAttempt: capability.dispatchAttempt }
1841
1857
  : {}),
1858
+ ...(requiresCodexAppLedger ? { requiresCodexAppLedger: true } : {}),
1842
1859
  },
1843
1860
  }
1844
1861
  : { ok: false, error: `${decision.errorCode}: ${decision.error}` };
@@ -1893,6 +1910,889 @@ function writeCliPidMarker() {
1893
1910
  }
1894
1911
  }
1895
1912
  let lastStructuredBridgeActivityAtMs = 0;
1913
+ const codexAppTurnLiveness = new CodexAppTurnLiveness();
1914
+ const codexAppReadyAuthority = new CodexAppReadyAuthority();
1915
+ const codexAppTurnDispatchQueue = new CodexAppTurnDispatchQueue();
1916
+ let codexAppFallbackTurnSequence = 0;
1917
+ let codexAppRecoveredDispatches = [];
1918
+ let codexAppGenerationCommits = [];
1919
+ const codexAppPendingDaemonAcks = new Map();
1920
+ let codexAppCompletionAwaitingFinal = false;
1921
+ let codexAppControlBootstrapPathForSpawn;
1922
+ let codexAppControlStateValue;
1923
+ let codexAppControlProven = false;
1924
+ /** Authentication proves identity, not app-server readiness. These become true
1925
+ * only after the active generation publishes a valid signed state marker. */
1926
+ let codexAppSignedStateObserved = false;
1927
+ let codexAppInputReady = false;
1928
+ let codexAppControlFatal = false;
1929
+ let codexAppControlPersistentGeneration = false;
1930
+ let codexAppControlDirectoryForSpawn;
1931
+ let codexAppControlSocketPathValue;
1932
+ let codexAppControlSocketDirectory;
1933
+ let codexAppControlLocatorPathValue;
1934
+ let codexAppControlEndpointEpoch;
1935
+ let codexAppControlServer;
1936
+ let codexAppWindowsOwnerLeaseServer;
1937
+ let codexAppWindowsOwnerLeaseSessionId;
1938
+ let codexAppWindowsOwnerLeasePromise;
1939
+ let codexAppPosixOwnerLease;
1940
+ let codexAppPosixOwnerLeaseSessionId;
1941
+ let codexAppPosixOwnerLeasePromise;
1942
+ let codexAppControlActiveSocket;
1943
+ let codexAppControlActiveIdentity;
1944
+ let codexAppFreshCandidateGeneration;
1945
+ let codexAppUnprovenPromptDeferred = false;
1946
+ let codexAppRejectedControlLogged = false;
1947
+ let codexAppMalformedControlLogged = false;
1948
+ let codexAppBootstrapCleanupTimer;
1949
+ const codexAppProofDeadline = new CodexAppControlProofDeadline();
1950
+ let codexAppControlStopping = false;
1951
+ let codexAppControlChannelId = 0;
1952
+ let codexAppControlRotation;
1953
+ const CODEX_APP_CONTROL_ENDPOINT_RETRY_MS = 5_000;
1954
+ const codexAppControlConnections = new Map();
1955
+ const codexAppControlReplayWindow = new CodexAppControlReplayWindow();
1956
+ const codexAppControlRecordApplicationGate = new CodexAppControlRecordApplicationGate();
1957
+ function cleanupCodexAppControlBootstrap() {
1958
+ if (codexAppBootstrapCleanupTimer) {
1959
+ clearTimeout(codexAppBootstrapCleanupTimer);
1960
+ codexAppBootstrapCleanupTimer = undefined;
1961
+ }
1962
+ const path = codexAppControlBootstrapPathForSpawn;
1963
+ codexAppControlBootstrapPathForSpawn = undefined;
1964
+ if (!path)
1965
+ return;
1966
+ try {
1967
+ unlinkSync(path);
1968
+ }
1969
+ catch { /* runner normally unlinks it first */ }
1970
+ }
1971
+ function readPersistedCodexAppControlState(cfg) {
1972
+ const dataDir = process.env.SESSION_DATA_DIR;
1973
+ if (!dataDir && process.platform !== 'win32')
1974
+ return undefined;
1975
+ const statePath = codexAppControlStatePath(dataDir ?? '', cfg.sessionId);
1976
+ if (process.platform === 'win32') {
1977
+ ensureCodexAppControlDirectory(codexAppWindowsControlRoot());
1978
+ ensureCodexAppControlDirectory(dirname(statePath));
1979
+ if (existsSync(statePath))
1980
+ hardenWindowsCodexAppControlFile(statePath);
1981
+ }
1982
+ return readCodexAppControlState(statePath);
1983
+ }
1984
+ function persistCodexAppControlState(cfg, state) {
1985
+ const dataDir = process.env.SESSION_DATA_DIR;
1986
+ if (!dataDir && process.platform !== 'win32') {
1987
+ throw new Error('SESSION_DATA_DIR is required for persistent Codex App control state');
1988
+ }
1989
+ const statePath = codexAppControlStatePath(dataDir ?? '', cfg.sessionId);
1990
+ if (process.platform === 'win32') {
1991
+ ensureCodexAppControlDirectory(codexAppWindowsControlRoot());
1992
+ ensureCodexAppControlDirectory(dirname(statePath));
1993
+ }
1994
+ writeCodexAppControlState(statePath, state);
1995
+ }
1996
+ function stopCodexAppControlChannel(opts = {}) {
1997
+ codexAppControlStopping = true;
1998
+ // Attribution belongs to exactly one worker/control generation. A fresh or
1999
+ // replacement worker may recover only the daemon-frozen active identity
2000
+ // after the old runner proves a warm reattach; stale queued entries must not
2001
+ // cross a stop/restart boundary.
2002
+ if (!opts.preserveDispatchRecovery) {
2003
+ codexAppTurnDispatchQueue.clear();
2004
+ codexAppRecoveredDispatches = [];
2005
+ }
2006
+ for (const pending of codexAppPendingDaemonAcks.values()) {
2007
+ clearTimeout(pending.timer);
2008
+ pending.resolve(false);
2009
+ }
2010
+ codexAppPendingDaemonAcks.clear();
2011
+ codexAppControlChannelId++;
2012
+ codexAppControlRotation = undefined;
2013
+ codexAppProofDeadline.clear();
2014
+ codexAppSignedStateObserved = false;
2015
+ codexAppInputReady = false;
2016
+ for (const connection of codexAppControlConnections.values()) {
2017
+ clearTimeout(connection.authTimer);
2018
+ connection.socket.destroy();
2019
+ }
2020
+ codexAppControlConnections.clear();
2021
+ codexAppControlActiveSocket = undefined;
2022
+ codexAppControlActiveIdentity = undefined;
2023
+ const server = codexAppControlServer;
2024
+ codexAppControlServer = undefined;
2025
+ try {
2026
+ server?.close();
2027
+ }
2028
+ catch { /* worker exit/restart */ }
2029
+ const socketPath = codexAppControlSocketPathValue;
2030
+ // Named pipes are kernel objects, not filesystem entries. Never lstat,
2031
+ // chmod, or unlink them on Windows; closing the server retires the endpoint.
2032
+ if (socketPath && process.platform !== 'win32') {
2033
+ try {
2034
+ const stat = lstatSync(socketPath);
2035
+ const uid = process.geteuid?.() ?? process.getuid?.();
2036
+ if (stat.isSocket() && !stat.isSymbolicLink() && (uid === undefined || stat.uid === uid)) {
2037
+ unlinkSync(socketPath);
2038
+ }
2039
+ }
2040
+ catch { /* absent or already removed */ }
2041
+ }
2042
+ // Do not read-then-unlink the fixed locator here. That is not an
2043
+ // atomic compare-and-delete: a replacement process could publish a new epoch
2044
+ // between those operations. A stale locator is harmless (the random pipe is
2045
+ // closed and the runner validates its independent epoch) and the next owner
2046
+ // overwrites it atomically after binding a fresh endpoint.
2047
+ codexAppControlEndpointEpoch = undefined;
2048
+ }
2049
+ function failCodexAppControlGeneration(reason) {
2050
+ if (codexAppControlFatal)
2051
+ return;
2052
+ codexAppControlFatal = true;
2053
+ log(`Codex App control generation failed closed: ${reason}`);
2054
+ codexAppControlProven = false;
2055
+ codexAppSignedStateObserved = false;
2056
+ codexAppInputReady = false;
2057
+ codexAppTurnLiveness.clear();
2058
+ codexAppReadyAuthority.reset();
2059
+ cleanupCodexAppControlBootstrap();
2060
+ stopCodexAppControlChannel();
2061
+ const cfg = lastInitConfig;
2062
+ if (cfg && effectiveBackendType !== 'pty') {
2063
+ const name = effectiveBackendType === 'tmux'
2064
+ ? TmuxBackend.sessionName(cfg.sessionId)
2065
+ : effectiveBackendType === 'zellij'
2066
+ ? ZellijBackend.sessionName(cfg.sessionId)
2067
+ : effectiveBackendType === 'herdr'
2068
+ ? HerdrBackend.sessionName(cfg.sessionId)
2069
+ : undefined;
2070
+ if (name) {
2071
+ try {
2072
+ killPersistentSession(effectiveBackendType, name);
2073
+ }
2074
+ catch (err) {
2075
+ log(`Failed to kill rejected Codex App generation: ${err?.message ?? err}`);
2076
+ }
2077
+ }
2078
+ }
2079
+ try {
2080
+ backend?.kill();
2081
+ }
2082
+ catch { /* process exit is the final fail-close */ }
2083
+ backend = null;
2084
+ queueMicrotask(() => {
2085
+ void sendFatalWorkerErrorAndExit(new Error(reason), undefined, undefined, { hardExit: true });
2086
+ });
2087
+ }
2088
+ function activateCodexAppControlConnection(connection, identity) {
2089
+ const cfg = lastInitConfig;
2090
+ const state = codexAppControlStateValue;
2091
+ if (!cfg || !state) {
2092
+ connection.socket.destroy();
2093
+ return;
2094
+ }
2095
+ const proofKind = identity.generation === codexAppFreshCandidateGeneration
2096
+ ? 'fresh runner'
2097
+ : 'warm reattach';
2098
+ if (proofKind === 'fresh runner'
2099
+ && codexAppRecoveredDispatches.some(entry => entry.state === 'prepared')) {
2100
+ // A fresh process cannot prove whether the prior generation buffered a
2101
+ // prepared frame. Fail before persisting/announcing this generation or
2102
+ // ACKing authentication; otherwise the daemon may trim recovery state and
2103
+ // observers can briefly treat an unusable runner as active.
2104
+ connection.socket.destroy();
2105
+ failCodexAppControlGeneration('Fresh Codex App runner cannot adopt a recovered prepared dispatch');
2106
+ return;
2107
+ }
2108
+ let active;
2109
+ try {
2110
+ active = activateCodexAppControlIdentity(state, identity.generation);
2111
+ if (codexAppControlPersistentGeneration)
2112
+ persistCodexAppControlState(cfg, active);
2113
+ }
2114
+ catch (err) {
2115
+ failCodexAppControlGeneration(`Could not persist authenticated Codex App generation: ${err?.message ?? err}`);
2116
+ return;
2117
+ }
2118
+ codexAppControlStateValue = active;
2119
+ codexAppControlProven = true;
2120
+ codexAppSignedStateObserved = false;
2121
+ codexAppInputReady = false;
2122
+ if (!codexAppProofDeadline.armed) {
2123
+ codexAppProofDeadline.arm(() => {
2124
+ failCodexAppControlGeneration(`Authenticated Codex App runner did not publish signed state within ${CODEX_APP_CONTROL_STARTUP_TIMEOUT_MS / 1000} seconds`);
2125
+ });
2126
+ }
2127
+ codexAppControlActiveIdentity = identity;
2128
+ codexAppControlReplayWindow.retainOnly(identity.generation);
2129
+ connection.authenticated = true;
2130
+ connection.identity = identity;
2131
+ clearTimeout(connection.authTimer);
2132
+ const previous = codexAppControlActiveSocket;
2133
+ codexAppControlActiveSocket = connection.socket;
2134
+ if (previous && previous !== connection.socket)
2135
+ previous.destroy();
2136
+ cleanupCodexAppControlBootstrap();
2137
+ connection.socket.write(`${encodeCodexAppControlAccepted(cfg.sessionId, identity.generation, connection.challenge, connection.epoch)}\n`);
2138
+ log(`Authenticated Codex App ${proofKind} by Ed25519 challenge proof (generation=${identity.generation.slice(0, 8)})`);
2139
+ send({
2140
+ type: 'codex_app_generation_active',
2141
+ sessionId: cfg.sessionId,
2142
+ generation: identity.generation,
2143
+ fresh: proofKind === 'fresh runner',
2144
+ });
2145
+ if (proofKind === 'fresh runner') {
2146
+ codexAppGenerationCommits = codexAppGenerationCommits.filter(commit => commit.generation === identity.generation);
2147
+ }
2148
+ if (proofKind === 'warm reattach') {
2149
+ codexAppTurnLiveness.beginReattachObservation();
2150
+ }
2151
+ // Authentication is not an input-ready barrier. A warm runner may still
2152
+ // replay old final/state records immediately after auth. Only the later
2153
+ // signed idle record, after recovered-prefix reconciliation, may flush.
2154
+ }
2155
+ async function handleCodexAppControlLine(connection, line) {
2156
+ const record = parseCodexAppControlWireRecord(line);
2157
+ const cfg = lastInitConfig;
2158
+ if (!record || !cfg || record.sessionId !== cfg.sessionId
2159
+ || connection.channelId !== codexAppControlChannelId
2160
+ || connection.epoch !== codexAppControlEndpointEpoch) {
2161
+ rejectCodexAppControlMarker('malformed socket');
2162
+ connection.socket.destroy();
2163
+ return;
2164
+ }
2165
+ if (!connection.authenticated) {
2166
+ if (record.type !== 'auth' || record.challenge !== connection.challenge) {
2167
+ rejectCodexAppControlMarker(record.type);
2168
+ connection.socket.destroy();
2169
+ return;
2170
+ }
2171
+ const identity = authenticateCodexAppControlCandidate({
2172
+ state: codexAppControlStateValue,
2173
+ auth: record,
2174
+ sessionId: cfg.sessionId,
2175
+ challenge: connection.challenge,
2176
+ });
2177
+ if (!identity) {
2178
+ rejectCodexAppControlMarker('challenge response');
2179
+ connection.socket.destroy();
2180
+ return;
2181
+ }
2182
+ activateCodexAppControlConnection(connection, identity);
2183
+ return;
2184
+ }
2185
+ const identity = connection.identity;
2186
+ if (record.type !== 'marker' || !identity
2187
+ || connection.socket !== codexAppControlActiveSocket
2188
+ || record.generation !== identity.generation
2189
+ || record.challenge !== connection.challenge
2190
+ || !verifyCodexAppSignedControlMarker(record, identity.publicKey)) {
2191
+ rejectCodexAppControlMarker(record.type === 'marker' ? record.kind : record.type);
2192
+ connection.socket.destroy();
2193
+ return;
2194
+ }
2195
+ if (!connection.sequenceFence.accept(record.seq)) {
2196
+ rejectCodexAppControlMarker('non-contiguous signed sequence');
2197
+ connection.socket.destroy();
2198
+ return;
2199
+ }
2200
+ // A replacement worker may see the runner replay a complete final whose
2201
+ // daemon-side delivery and FIFO pop were committed just before the old
2202
+ // worker died. The per-generation high-water mark is a durable cumulative
2203
+ // ACK boundary: acknowledge each replayed prefix record without assembling
2204
+ // or re-emitting the final.
2205
+ if (committedCodexAppSequence(codexAppGenerationCommits, identity.generation, record.seq)) {
2206
+ connection.socket.write(`${encodeCodexAppControlAck(cfg.sessionId, identity.generation, connection.challenge, record.seq)}\n`);
2207
+ return;
2208
+ }
2209
+ // ACK loss is expected around endpoint replacement. The runner re-signs its
2210
+ // unacknowledged record against the fresh challenge; acknowledge that retry
2211
+ // without applying completed/final side effects twice.
2212
+ if (codexAppControlReplayWindow.hasSeen(identity.generation, record.seq)) {
2213
+ connection.socket.write(`${encodeCodexAppControlAck(cfg.sessionId, identity.generation, connection.challenge, record.seq)}\n`);
2214
+ return;
2215
+ }
2216
+ const finalResult = connection.finalAssembler.accept(record.kind, record.payload);
2217
+ if (finalResult.status === 'reject') {
2218
+ rejectCodexAppControlMarker(finalResult.reason);
2219
+ connection.socket.destroy();
2220
+ return;
2221
+ }
2222
+ // start/chunk records intentionally remain uncommitted so a replacement
2223
+ // connection can rebuild the complete final. Every record that does apply
2224
+ // worker effects is single-flight by signed generation/sequence until the
2225
+ // replay window below is committed.
2226
+ if (finalResult.status === 'accepted')
2227
+ return;
2228
+ const application = codexAppControlRecordApplicationGate.run(identity.generation, record.seq, () => finalResult.status === 'not-final'
2229
+ ? handleTrustedCodexAppMarker(record.kind, record.payload, { generation: identity.generation, seq: record.seq })
2230
+ : handleTrustedCodexAppMarker('final', finalResult.payload, { generation: identity.generation, seq: record.seq }));
2231
+ let applied;
2232
+ try {
2233
+ applied = await application;
2234
+ }
2235
+ catch (err) {
2236
+ codexAppControlRecordApplicationGate.release(identity.generation, record.seq, application);
2237
+ throw err;
2238
+ }
2239
+ if (!applied) {
2240
+ codexAppControlRecordApplicationGate.release(identity.generation, record.seq, application);
2241
+ // Do not commit or cumulatively ACK a semantically rejected signed marker.
2242
+ // In particular, a final for the wrong FIFO head must remain replayable
2243
+ // rather than becoming a permanently lost answer.
2244
+ connection.socket.destroy();
2245
+ return;
2246
+ }
2247
+ codexAppControlReplayWindow.commit(identity.generation, record.seq);
2248
+ codexAppControlRecordApplicationGate.release(identity.generation, record.seq, application);
2249
+ if (!connection.socket.destroyed) {
2250
+ connection.socket.write(`${encodeCodexAppControlAck(cfg.sessionId, identity.generation, connection.challenge, record.seq)}\n`);
2251
+ }
2252
+ }
2253
+ const CODEX_APP_CONTROL_PENDING_LINE_LIMIT = 4_096;
2254
+ async function drainCodexAppControlLines(connection) {
2255
+ if (connection.processingLines)
2256
+ return;
2257
+ connection.processingLines = true;
2258
+ connection.socket.pause();
2259
+ try {
2260
+ while (!connection.socket.destroyed && connection.pendingLines.length > 0) {
2261
+ const line = connection.pendingLines.shift();
2262
+ await handleCodexAppControlLine(connection, line);
2263
+ }
2264
+ }
2265
+ catch (err) {
2266
+ log(`Codex App control record processing failed: ${err?.message ?? err}`);
2267
+ connection.socket.destroy();
2268
+ }
2269
+ finally {
2270
+ connection.processingLines = false;
2271
+ if (!connection.socket.destroyed)
2272
+ connection.socket.resume();
2273
+ }
2274
+ }
2275
+ function acceptCodexAppControlSocket(socket, endpoint, epoch, channelId) {
2276
+ if (codexAppControlFatal || codexAppControlStopping || !lastInitConfig
2277
+ || channelId !== codexAppControlChannelId) {
2278
+ socket.destroy();
2279
+ return;
2280
+ }
2281
+ // Never let a stalled unauthenticated client monopolize the endpoint. Every
2282
+ // connection gets its own challenge and timeout; new accepts continue while
2283
+ // bad same-UID clients are rejected independently.
2284
+ const unauthenticated = [...codexAppControlConnections.values()]
2285
+ .filter(connection => !connection.authenticated);
2286
+ if (unauthenticated.length >= 16) {
2287
+ socket.destroy();
2288
+ return;
2289
+ }
2290
+ const challenge = generateCodexAppControlChallenge();
2291
+ const connection = {
2292
+ socket,
2293
+ endpoint,
2294
+ epoch,
2295
+ channelId,
2296
+ challenge,
2297
+ decoder: new CodexAppControlLineDecoder(),
2298
+ sequenceFence: new CodexAppControlSequenceFence(),
2299
+ finalAssembler: new CodexAppControlFinalAssembler(),
2300
+ authenticated: false,
2301
+ pendingLines: [],
2302
+ processingLines: false,
2303
+ authTimer: setTimeout(() => socket.destroy(), CODEX_APP_CONTROL_ENDPOINT_RETRY_MS),
2304
+ };
2305
+ connection.authTimer.unref?.();
2306
+ codexAppControlConnections.set(socket, connection);
2307
+ socket.setNoDelay(true);
2308
+ socket.on('data', chunk => {
2309
+ const decoded = connection.decoder.push(chunk);
2310
+ if (decoded.droppedMalformed) {
2311
+ if (!codexAppMalformedControlLogged) {
2312
+ codexAppMalformedControlLogged = true;
2313
+ log('Dropped malformed/oversized Codex App socket control line');
2314
+ }
2315
+ socket.destroy();
2316
+ return;
2317
+ }
2318
+ if (connection.pendingLines.length + decoded.lines.length > CODEX_APP_CONTROL_PENDING_LINE_LIMIT) {
2319
+ log('Dropped Codex App control connection whose pending line queue exceeded the bound');
2320
+ socket.destroy();
2321
+ return;
2322
+ }
2323
+ connection.pendingLines.push(...decoded.lines);
2324
+ void drainCodexAppControlLines(connection);
2325
+ });
2326
+ socket.on('error', () => { });
2327
+ socket.on('close', () => {
2328
+ clearTimeout(connection.authTimer);
2329
+ codexAppControlConnections.delete(socket);
2330
+ connection.pendingLines.length = 0;
2331
+ const wasActive = codexAppControlActiveSocket === socket;
2332
+ if (wasActive) {
2333
+ codexAppControlActiveSocket = undefined;
2334
+ codexAppControlActiveIdentity = undefined;
2335
+ codexAppControlProven = false;
2336
+ codexAppSignedStateObserved = false;
2337
+ codexAppInputReady = false;
2338
+ codexAppReadyAuthority.beginWork();
2339
+ codexAppTurnLiveness.beginReattachObservation();
2340
+ if (!codexAppControlStopping
2341
+ && !codexAppControlFatal
2342
+ && connection.channelId === codexAppControlChannelId) {
2343
+ codexAppProofDeadline.arm(() => {
2344
+ failCodexAppControlGeneration(`Codex App runner did not re-authenticate within ${CODEX_APP_CONTROL_STARTUP_TIMEOUT_MS / 1000} seconds after its control socket closed`);
2345
+ });
2346
+ }
2347
+ log('Codex App authenticated control socket closed; waiting for a fresh challenge proof');
2348
+ }
2349
+ // Rotate only after the authenticated runner closes. An unauthenticated
2350
+ // close cannot prove that the legitimate runner has no pending connect to
2351
+ // this name; retiring it could let another local process rebind that name
2352
+ // under the pending attempt. Pre-auth failures therefore remain bound until
2353
+ // the shared 90-second fail-close deadline (availability-only boundary).
2354
+ if (wasActive
2355
+ && !codexAppControlStopping
2356
+ && !codexAppControlFatal
2357
+ && connection.channelId === codexAppControlChannelId
2358
+ && connection.epoch === codexAppControlEndpointEpoch) {
2359
+ void rotateCodexAppControlEndpoint('active socket closed');
2360
+ }
2361
+ });
2362
+ socket.write(`${encodeCodexAppControlChallenge(lastInitConfig.sessionId, challenge)}\n`);
2363
+ }
2364
+ function removeStaleCodexAppSocket(path) {
2365
+ if (process.platform === 'win32')
2366
+ return;
2367
+ try {
2368
+ const stat = lstatSync(path);
2369
+ const uid = process.geteuid?.() ?? process.getuid?.();
2370
+ if (!stat.isSocket() || stat.isSymbolicLink() || (uid !== undefined && stat.uid !== uid)) {
2371
+ throw new Error('existing Codex App control socket path is not an owned socket');
2372
+ }
2373
+ unlinkSync(path);
2374
+ }
2375
+ catch (err) {
2376
+ if (err?.code !== 'ENOENT')
2377
+ throw err;
2378
+ }
2379
+ }
2380
+ function listenCodexAppControlServer(server, endpoint) {
2381
+ return new Promise((resolve, reject) => {
2382
+ const onError = (err) => {
2383
+ server.off('listening', onListening);
2384
+ reject(err);
2385
+ };
2386
+ const onListening = () => {
2387
+ server.off('error', onError);
2388
+ resolve();
2389
+ };
2390
+ server.once('error', onError);
2391
+ server.once('listening', onListening);
2392
+ server.listen(endpoint);
2393
+ });
2394
+ }
2395
+ async function ensureCodexAppWindowsOwnerLease(sessionId) {
2396
+ if (process.platform !== 'win32')
2397
+ return;
2398
+ if (codexAppWindowsOwnerLeaseServer) {
2399
+ if (codexAppWindowsOwnerLeaseSessionId !== sessionId) {
2400
+ throw new Error('Windows Codex App owner lease belongs to another session');
2401
+ }
2402
+ return;
2403
+ }
2404
+ if (codexAppWindowsOwnerLeasePromise)
2405
+ return codexAppWindowsOwnerLeasePromise;
2406
+ const endpoint = codexAppWindowsOwnerPipeEndpoint(sessionId);
2407
+ let acquisition;
2408
+ acquisition = (async () => {
2409
+ const server = await acquireCodexAppControlOwnerLease({
2410
+ bind: async () => {
2411
+ const candidate = createNetServer(socket => socket.destroy());
2412
+ try {
2413
+ await listenCodexAppControlServer(candidate, endpoint);
2414
+ return candidate;
2415
+ }
2416
+ catch (err) {
2417
+ try {
2418
+ candidate.close();
2419
+ }
2420
+ catch { /* bind never completed */ }
2421
+ throw err;
2422
+ }
2423
+ },
2424
+ });
2425
+ if (codexAppWindowsOwnerLeaseServer) {
2426
+ try {
2427
+ server.close();
2428
+ }
2429
+ catch { /* another local acquire won */ }
2430
+ if (codexAppWindowsOwnerLeaseSessionId !== sessionId) {
2431
+ throw new Error('Windows Codex App owner lease changed session during acquisition');
2432
+ }
2433
+ return;
2434
+ }
2435
+ codexAppWindowsOwnerLeaseServer = server;
2436
+ codexAppWindowsOwnerLeaseSessionId = sessionId;
2437
+ server.on('error', err => {
2438
+ if (codexAppWindowsOwnerLeaseServer === server && !codexAppControlStopping) {
2439
+ failCodexAppControlGeneration(`Windows Codex App owner lease failed: ${err.message}`);
2440
+ }
2441
+ });
2442
+ })().finally(() => {
2443
+ if (codexAppWindowsOwnerLeasePromise === acquisition) {
2444
+ codexAppWindowsOwnerLeasePromise = undefined;
2445
+ }
2446
+ });
2447
+ codexAppWindowsOwnerLeasePromise = acquisition;
2448
+ return acquisition;
2449
+ }
2450
+ async function ensureCodexAppPosixOwnerLease(controlRoot, ownerSessionId) {
2451
+ if (process.platform === 'win32')
2452
+ return;
2453
+ if (codexAppPosixOwnerLease) {
2454
+ if (codexAppPosixOwnerLeaseSessionId !== ownerSessionId || !codexAppPosixOwnerLease.isOwned()) {
2455
+ throw new Error('POSIX Codex App owner lease is not owned by this worker/session');
2456
+ }
2457
+ return;
2458
+ }
2459
+ if (codexAppPosixOwnerLeasePromise)
2460
+ return codexAppPosixOwnerLeasePromise;
2461
+ let acquisition;
2462
+ acquisition = (async () => {
2463
+ const lease = await acquireCodexAppPosixOwnerLease({
2464
+ controlRoot,
2465
+ sessionId: ownerSessionId,
2466
+ });
2467
+ if (codexAppPosixOwnerLease) {
2468
+ lease.release();
2469
+ if (codexAppPosixOwnerLeaseSessionId !== ownerSessionId) {
2470
+ throw new Error('POSIX Codex App owner lease changed session during acquisition');
2471
+ }
2472
+ return;
2473
+ }
2474
+ codexAppPosixOwnerLease = lease;
2475
+ codexAppPosixOwnerLeaseSessionId = ownerSessionId;
2476
+ })().finally(() => {
2477
+ if (codexAppPosixOwnerLeasePromise === acquisition) {
2478
+ codexAppPosixOwnerLeasePromise = undefined;
2479
+ }
2480
+ });
2481
+ codexAppPosixOwnerLeasePromise = acquisition;
2482
+ return acquisition;
2483
+ }
2484
+ function releaseCodexAppPosixOwnerLease() {
2485
+ codexAppPosixOwnerLease?.release();
2486
+ codexAppPosixOwnerLease = undefined;
2487
+ codexAppPosixOwnerLeaseSessionId = undefined;
2488
+ }
2489
+ async function startCodexAppControlEndpoint(cfg, channelId) {
2490
+ const epoch = generateCodexAppControlEpoch();
2491
+ const endpoint = process.platform === 'win32'
2492
+ ? generateCodexAppWindowsPipeEndpoint()
2493
+ : codexAppControlSocketDirectory
2494
+ ? generateCodexAppPosixSocketEndpoint(codexAppControlSocketDirectory)
2495
+ : undefined;
2496
+ if (!endpoint)
2497
+ throw new Error('Codex App control endpoint path was not prepared');
2498
+ const locatorPath = codexAppControlLocatorPathValue;
2499
+ if (!locatorPath)
2500
+ throw new Error('Codex App control locator path was not prepared');
2501
+ const server = createNetServer(socket => {
2502
+ acceptCodexAppControlSocket(socket, endpoint, epoch, channelId);
2503
+ });
2504
+ const priorServer = codexAppControlServer;
2505
+ const priorEndpoint = codexAppControlSocketPathValue;
2506
+ const priorEpoch = codexAppControlEndpointEpoch;
2507
+ try {
2508
+ const publisherLeaseOwned = () => process.platform === 'win32'
2509
+ ? !!codexAppWindowsOwnerLeaseServer
2510
+ : codexAppPosixOwnerLease?.isOwned() === true;
2511
+ const published = await bindThenPublishCodexAppControlLocator({
2512
+ sessionId: cfg.sessionId,
2513
+ epoch,
2514
+ endpoint,
2515
+ platform: process.platform,
2516
+ locatorPath,
2517
+ expectedControlRoot: process.platform === 'win32'
2518
+ ? undefined
2519
+ : codexAppPosixControlRoot(),
2520
+ listen: async () => {
2521
+ await listenCodexAppControlServer(server, endpoint);
2522
+ if (process.platform !== 'win32') {
2523
+ chmodSync(endpoint, 0o600);
2524
+ const stat = lstatSync(endpoint);
2525
+ const uid = process.geteuid?.() ?? process.getuid?.();
2526
+ if (!stat.isSocket() || stat.isSymbolicLink() || (uid !== undefined && stat.uid !== uid)) {
2527
+ throw new Error('listening path is not the owned Codex App socket');
2528
+ }
2529
+ }
2530
+ },
2531
+ isCurrent: () => !codexAppControlStopping
2532
+ && !codexAppControlFatal
2533
+ && channelId === codexAppControlChannelId
2534
+ && publisherLeaseOwned(),
2535
+ retire: () => { try {
2536
+ server.close();
2537
+ }
2538
+ catch { /* already retired */ } },
2539
+ publish: locator => {
2540
+ if (!publisherLeaseOwned())
2541
+ throw new Error('Codex App locator publisher lease was lost');
2542
+ // Install the bound epoch before making the locator visible. Any
2543
+ // immediate connection is therefore checked against this exact epoch.
2544
+ codexAppControlServer = server;
2545
+ codexAppControlSocketPathValue = endpoint;
2546
+ codexAppControlEndpointEpoch = epoch;
2547
+ writeCodexAppControlLocator(locatorPath, locator);
2548
+ },
2549
+ });
2550
+ if (!published)
2551
+ throw new Error('Codex App control endpoint was retired before locator publication');
2552
+ }
2553
+ catch (err) {
2554
+ if (codexAppControlServer === server) {
2555
+ codexAppControlServer = priorServer;
2556
+ codexAppControlSocketPathValue = priorEndpoint;
2557
+ codexAppControlEndpointEpoch = priorEpoch;
2558
+ }
2559
+ try {
2560
+ server.close();
2561
+ }
2562
+ catch { /* bind may not have completed */ }
2563
+ if (process.platform !== 'win32') {
2564
+ try {
2565
+ removeStaleCodexAppSocket(endpoint);
2566
+ }
2567
+ catch { /* preserve original error */ }
2568
+ }
2569
+ throw err;
2570
+ }
2571
+ server.on('error', err => {
2572
+ if (codexAppControlServer === server
2573
+ && channelId === codexAppControlChannelId
2574
+ && !codexAppControlStopping) {
2575
+ failCodexAppControlGeneration(`Codex App control endpoint failed: ${err.message}`);
2576
+ }
2577
+ });
2578
+ return { server, endpoint, epoch };
2579
+ }
2580
+ function installCodexAppControlEndpoint(started) {
2581
+ const previous = codexAppControlServer;
2582
+ codexAppControlServer = started.server;
2583
+ codexAppControlSocketPathValue = started.endpoint;
2584
+ codexAppControlEndpointEpoch = started.epoch;
2585
+ return previous;
2586
+ }
2587
+ function closeRetiredCodexAppControlEndpoint(server, endpoint) {
2588
+ try {
2589
+ server?.close();
2590
+ }
2591
+ catch { /* already closed */ }
2592
+ if (endpoint && process.platform !== 'win32') {
2593
+ try {
2594
+ removeStaleCodexAppSocket(endpoint);
2595
+ }
2596
+ catch { /* next bind will verify */ }
2597
+ }
2598
+ }
2599
+ function rotateCodexAppControlEndpoint(reason) {
2600
+ if (codexAppControlStopping || codexAppControlFatal) {
2601
+ return Promise.resolve();
2602
+ }
2603
+ if (codexAppControlRotation)
2604
+ return codexAppControlRotation;
2605
+ const cfg = lastInitConfig;
2606
+ const channelId = codexAppControlChannelId;
2607
+ if (!cfg)
2608
+ return Promise.resolve();
2609
+ const oldServer = codexAppControlServer;
2610
+ const oldEndpoint = codexAppControlSocketPathValue;
2611
+ let rotation;
2612
+ rotation = (async () => {
2613
+ try {
2614
+ const started = await startCodexAppControlEndpoint(cfg, channelId);
2615
+ if (codexAppControlStopping || codexAppControlFatal
2616
+ || channelId !== codexAppControlChannelId) {
2617
+ closeRetiredCodexAppControlEndpoint(started.server, started.endpoint);
2618
+ return;
2619
+ }
2620
+ installCodexAppControlEndpoint(started);
2621
+ // Bind + publish the fresh random endpoint before retiring the old one.
2622
+ closeRetiredCodexAppControlEndpoint(oldServer, oldEndpoint);
2623
+ for (const connection of [...codexAppControlConnections.values()]) {
2624
+ if (connection.epoch !== started.epoch)
2625
+ connection.socket.destroy();
2626
+ }
2627
+ log(`Rotated Codex App control endpoint (${reason}, epoch=${started.epoch.slice(0, 8)})`);
2628
+ }
2629
+ catch (err) {
2630
+ if (shouldFailCodexAppControlChannel({
2631
+ channelId,
2632
+ currentChannelId: codexAppControlChannelId,
2633
+ stopping: codexAppControlStopping,
2634
+ })) {
2635
+ failCodexAppControlGeneration(`Could not rotate Codex App control endpoint: ${err?.message ?? err}`);
2636
+ }
2637
+ }
2638
+ finally {
2639
+ if (codexAppControlRotation === rotation)
2640
+ codexAppControlRotation = undefined;
2641
+ }
2642
+ })();
2643
+ codexAppControlRotation = rotation;
2644
+ return rotation;
2645
+ }
2646
+ async function prepareCodexAppControlGeneration(cfg, _willReattachPersistent, persistentGeneration) {
2647
+ cleanupCodexAppControlBootstrap();
2648
+ // init already restored the daemon-owned FIFO before spawn. Retiring a
2649
+ // previous/empty socket endpoint must not erase that recovery snapshot just
2650
+ // before a warm runner replays its unacked final.
2651
+ stopCodexAppControlChannel({ preserveDispatchRecovery: true });
2652
+ codexAppControlStopping = false;
2653
+ const channelId = codexAppControlChannelId;
2654
+ codexAppControlStateValue = undefined;
2655
+ codexAppControlProven = false;
2656
+ codexAppSignedStateObserved = false;
2657
+ codexAppInputReady = false;
2658
+ codexAppReadyAuthority.reset();
2659
+ codexAppControlFatal = false;
2660
+ codexAppControlPersistentGeneration = false;
2661
+ codexAppControlDirectoryForSpawn = undefined;
2662
+ codexAppControlSocketPathValue = undefined;
2663
+ codexAppControlSocketDirectory = undefined;
2664
+ codexAppControlLocatorPathValue = undefined;
2665
+ codexAppControlEndpointEpoch = undefined;
2666
+ codexAppFreshCandidateGeneration = undefined;
2667
+ codexAppUnprovenPromptDeferred = false;
2668
+ codexAppRejectedControlLogged = false;
2669
+ codexAppMalformedControlLogged = false;
2670
+ if (cfg.cliId !== 'codex-app')
2671
+ return;
2672
+ const dataDir = process.env.SESSION_DATA_DIR;
2673
+ const windowsRoot = process.platform === 'win32' ? codexAppWindowsControlRoot() : undefined;
2674
+ const controlRoot = windowsRoot ?? codexAppPosixControlRoot();
2675
+ // The daemon's kill-then-fork replacement path can briefly overlap worker
2676
+ // processes. Hold a process-lifetime per-session publisher lease before
2677
+ // touching the fixed locator so an old process cannot publish after its
2678
+ // replacement. The lease is never exposed as a runner control endpoint.
2679
+ if (windowsRoot)
2680
+ await ensureCodexAppWindowsOwnerLease(cfg.sessionId);
2681
+ else
2682
+ await ensureCodexAppPosixOwnerLease(controlRoot, cfg.sessionId);
2683
+ const controlDirectory = windowsRoot
2684
+ ? join(windowsRoot, 'bootstraps')
2685
+ : dataDir
2686
+ ? join(botHomePath(dirname(dataDir), cfg.larkAppId), 'codex-app-control-bootstrap')
2687
+ : join(tmpdir(), `botmux-codex-app-control-${process.getuid?.() ?? 'unknown'}`);
2688
+ // AF_UNIX paths are capped at roughly 104 bytes on macOS. Keep random POSIX
2689
+ // endpoints in the short private control root; both platforms publish them
2690
+ // through a fixed protected locator while the process-lifetime owner lease
2691
+ // serializes replacement workers.
2692
+ const socketDirectory = join(controlRoot, 'sockets');
2693
+ ensureCodexAppControlDirectory(socketDirectory);
2694
+ ensureCodexAppControlDirectory(controlDirectory);
2695
+ cleanupStaleCodexAppControlBootstraps(controlDirectory, cfg.sessionId);
2696
+ codexAppControlDirectoryForSpawn = controlDirectory;
2697
+ codexAppControlSocketDirectory = socketDirectory;
2698
+ codexAppControlLocatorPathValue = codexAppControlLocatorPath(controlRoot, cfg.sessionId);
2699
+ ensureCodexAppControlDirectory(dirname(codexAppControlLocatorPathValue));
2700
+ // A crashed worker may leave a stale locator. Keep it until the new random
2701
+ // endpoint has bound, then atomically overwrite it. Deleting first would
2702
+ // introduce a cross-process read/unlink race and is unnecessary because
2703
+ // accepted carries the protected, independently random locator epoch.
2704
+ // Preserve old public identities for every persistent spawn attempt, even
2705
+ // when the existence probe predicted fresh. The backend may race between
2706
+ // probe and spawn; the socket proof, not that prediction, selects old reuse
2707
+ // versus the fresh candidate.
2708
+ if (persistentGeneration)
2709
+ codexAppControlStateValue = readPersistedCodexAppControlState(cfg);
2710
+ const started = await startCodexAppControlEndpoint(cfg, channelId);
2711
+ if (codexAppControlStopping || channelId !== codexAppControlChannelId) {
2712
+ closeRetiredCodexAppControlEndpoint(started.server, started.endpoint);
2713
+ throw new Error('Codex App control endpoint was retired during startup');
2714
+ }
2715
+ installCodexAppControlEndpoint(started);
2716
+ }
2717
+ /** Late-create the only secret-bearing file immediately before backend.spawn. */
2718
+ function prepareFreshCodexAppControlBootstrap(cfg, persistentGeneration) {
2719
+ if (cfg.cliId !== 'codex-app')
2720
+ return;
2721
+ if (!codexAppControlDirectoryForSpawn || !codexAppControlLocatorPathValue) {
2722
+ throw new Error('Codex App control channel was not prepared');
2723
+ }
2724
+ const bootstrap = createCodexAppControlBootstrap(codexAppControlDirectoryForSpawn, cfg.sessionId, { kind: 'locator', locatorPath: codexAppControlLocatorPathValue });
2725
+ codexAppControlBootstrapPathForSpawn = bootstrap.path;
2726
+ codexAppFreshCandidateGeneration = bootstrap.identity.generation;
2727
+ codexAppControlStateValue = mergeCodexAppControlCandidate(codexAppControlStateValue, bootstrap.identity);
2728
+ codexAppControlPersistentGeneration = persistentGeneration;
2729
+ if (persistentGeneration)
2730
+ persistCodexAppControlState(cfg, codexAppControlStateValue);
2731
+ codexAppBootstrapCleanupTimer = armCodexAppControlStartupTimeout(cleanupCodexAppControlBootstrap);
2732
+ codexAppBootstrapCleanupTimer.unref?.();
2733
+ }
2734
+ function finalizeCodexAppControlGeneration(cfg, _actuallyReattached, persistentGeneration) {
2735
+ if (cfg.cliId !== 'codex-app')
2736
+ return;
2737
+ codexAppControlPersistentGeneration = persistentGeneration;
2738
+ // A live runner can answer while synchronous backend.spawn is still
2739
+ // returning. Authentication already persisted the active public identity;
2740
+ // do not mistake that success for a missing pending bootstrap.
2741
+ if (codexAppControlProven && codexAppControlStateValue?.status === 'active') {
2742
+ cleanupCodexAppControlBootstrap();
2743
+ if (!codexAppSignedStateObserved && !codexAppProofDeadline.armed) {
2744
+ codexAppProofDeadline.arm(() => {
2745
+ failCodexAppControlGeneration(`Authenticated Codex App runner did not publish signed state within ${CODEX_APP_CONTROL_STARTUP_TIMEOUT_MS / 1000} seconds`);
2746
+ });
2747
+ }
2748
+ return;
2749
+ }
2750
+ if (codexAppControlStateValue?.status !== 'pending'
2751
+ || !codexAppControlBootstrapPathForSpawn) {
2752
+ cleanupCodexAppControlBootstrap();
2753
+ throw new Error('Codex App pending asymmetric control generation was not prepared');
2754
+ }
2755
+ codexAppProofDeadline.arm(() => {
2756
+ failCodexAppControlGeneration(`Codex App runner did not authenticate and publish signed state within ${CODEX_APP_CONTROL_STARTUP_TIMEOUT_MS / 1000} seconds`);
2757
+ });
2758
+ }
2759
+ function rejectCodexAppControlMarker(kind) {
2760
+ if (codexAppRejectedControlLogged)
2761
+ return;
2762
+ codexAppRejectedControlLogged = true;
2763
+ log(`Ignored unauthenticated Codex App ${kind} control record`);
2764
+ }
2765
+ /**
2766
+ * Project an explicit Codex App no-progress state above the screen heuristic.
2767
+ * The warning is once-per-turn and intentionally does not restart or replay
2768
+ * anything: both actions could duplicate model/tool side effects.
2769
+ */
2770
+ function codexAppLivenessStatus(base, nowMs = Date.now()) {
2771
+ if (lastInitConfig?.cliId !== 'codex-app')
2772
+ return base;
2773
+ base = projectCodexAppControlReadinessStatus(base, {
2774
+ controlProven: codexAppControlProven,
2775
+ signedStateObserved: codexAppSignedStateObserved,
2776
+ inputReady: codexAppInputReady,
2777
+ });
2778
+ const liveness = codexAppTurnLiveness.poll(nowMs);
2779
+ if (liveness.shouldNotify) {
2780
+ send({
2781
+ type: 'user_notify',
2782
+ turnId: liveness.turnId ?? currentBotmuxTurnId,
2783
+ ...(liveness.turnId === currentBotmuxTurnId
2784
+ && currentBotmuxDispatchAttempt !== undefined
2785
+ ? { dispatchAttempt: currentBotmuxDispatchAttempt }
2786
+ : {}),
2787
+ message: t('worker.codex_app.no_progress', {
2788
+ seconds: Math.round(CODEX_APP_NO_PROGRESS_TIMEOUT_MS / 1000),
2789
+ }),
2790
+ });
2791
+ }
2792
+ if (liveness.stalled)
2793
+ return 'stalled';
2794
+ return liveness.active && base === 'idle' ? 'working' : base;
2795
+ }
1896
2796
  /**
1897
2797
  * True when this CLI has an authoritative STRUCTURED rate-limit signal that is
1898
2798
  * actually PUBLISHED as a `limited` screen_update — i.e. the Claude family,
@@ -2045,10 +2945,9 @@ const bridgeSecondaryPaths = new Map(); // path → offset
2045
2945
  let bridgeOffset = 0;
2046
2946
  let bridgePendingTail = '';
2047
2947
  const bridgeQueue = new BridgeTurnQueue();
2048
- /** uuids of Claude transcript rate-limit records we've already turned into a
2049
- * `limited` emit. drainTranscript re-reads from offset 0 on truncation /
2050
- * rotation and emitReadyTurns re-drains from 0, so the same rate_limit record
2051
- * can resurface; keying on the record's stable uuid makes the emit idempotent. */
2948
+ /** uuids of structured transcript rate-limit records already turned into a
2949
+ * `limited` emit. Readers may re-read from offset 0 after rotation, so the
2950
+ * stable record uuid keeps the notification idempotent. */
2052
2951
  const emittedRateLimitUuids = new Set();
2053
2952
  let bridgeWatcher = null;
2054
2953
  let bridgeFallbackTimer = null;
@@ -2074,6 +2973,8 @@ let codexBridgeRolloutPath;
2074
2973
  let codexBridgeOffset = 0;
2075
2974
  let codexBridgePendingTail = '';
2076
2975
  let codexBridgeBaselineDone = false;
2976
+ let publishedActiveRuntime = {};
2977
+ let activeRuntimePublished = false;
2077
2978
  const codexBridgeQueue = new CodexBridgeQueue();
2078
2979
  let codexBridgeWatcher = null;
2079
2980
  let codexBridgeTimer = null;
@@ -2081,6 +2982,26 @@ let codexBridgeTimer = null;
2081
2982
  * The tracker owns rollout-generation clear/update semantics and publishes a
2082
2983
  * dedicated IPC event, independent of PTY redraw frequency. */
2083
2984
  const codexServiceTierTracker = new CodexServiceTierTracker(resolveCodexServiceTierSnapshot, snapshot => send({ type: 'codex_service_tier', snapshot }));
2985
+ function publishActiveRuntime(runtime) {
2986
+ const normalized = {
2987
+ ...(runtime.model?.trim() ? { model: runtime.model.trim() } : {}),
2988
+ ...(runtime.reasoningEffort?.trim()
2989
+ ? { reasoningEffort: runtime.reasoningEffort.trim() }
2990
+ : {}),
2991
+ };
2992
+ if (activeRuntimePublished
2993
+ && normalized.model === publishedActiveRuntime.model
2994
+ && normalized.reasoningEffort === publishedActiveRuntime.reasoningEffort) {
2995
+ return;
2996
+ }
2997
+ activeRuntimePublished = true;
2998
+ publishedActiveRuntime = normalized;
2999
+ send({
3000
+ type: 'active_runtime',
3001
+ model: normalized.model ?? null,
3002
+ reasoningEffort: normalized.reasoningEffort ?? null,
3003
+ });
3004
+ }
2084
3005
  let hermesBridgeOffset = 0;
2085
3006
  let hermesBridgeBaselineDone = false;
2086
3007
  let hermesBridgeDbPath;
@@ -2150,6 +3071,18 @@ function formatHeadlessLocalTurnContent(assistantText) {
2150
3071
  function emptyCompletedBridgeFallbackContent() {
2151
3072
  return t('worker.empty_final_completed', { cliName: cliName() });
2152
3073
  }
3074
+ function failedBridgeFallbackContent(errorCode, summary, partialText) {
3075
+ const reason = summary || t('worker.failed_reason_unavailable');
3076
+ const key = errorCode === CODEX_INVALID_REQUEST_ERROR_CODE
3077
+ ? 'worker.empty_final_failed_invalid_request'
3078
+ : errorCode === CODEX_AUTH_ERROR_CODE
3079
+ ? 'worker.empty_final_failed_auth'
3080
+ : errorCode === CODEX_CONNECTION_ERROR_CODE
3081
+ ? 'worker.empty_final_failed_connection'
3082
+ : 'worker.empty_final_failed';
3083
+ const failure = t(key, { cliName: cliName(), reason });
3084
+ return partialText?.trim() ? `${partialText.trim()}\n\n${failure}` : failure;
3085
+ }
2153
3086
  // ─── Bridge fallback marker (non-adopt) ────────────────────────────────────
2154
3087
  //
2155
3088
  // `botmux send` (cli.ts cmdSend) appends a line
@@ -3320,6 +4253,19 @@ function emitReadyTurns(opts = {}) {
3320
4253
  notifyExplicitReplyObserved(turn.turnId, explicitReplyMarkerForTurnWindow(gateInput, nextBoundaryMs, markers, adoptMode));
3321
4254
  continue;
3322
4255
  }
4256
+ // Gate let this through, so the final is a real answer. NON-ADOPT only: if
4257
+ // the model appended a trailing sentinel line (the "did work, forgot to
4258
+ // send, ended with the sentinel" shape), strip it so the literal token never
4259
+ // reaches Lark — the prose before it is what the user should see; a
4260
+ // pure-sentinel final was already suppressed by isBridgeNothingToSendFinal,
4261
+ // so what remains is non-empty (guard and skip if not). ADOPT must NEVER
4262
+ // touch the text: the adopted CLI is botmux-unaware, transcript drain is its
4263
+ // only channel, and it may legitimately output that literal string as
4264
+ // content — shouldSuppressBridgeEmit(adoptMode) already refuses to interpret
4265
+ // the sentinel, so stripping here would break that contract.
4266
+ const postText = bridgePostText(assistantText, adoptMode);
4267
+ if (!adoptMode && postText.trim().length === 0)
4268
+ continue;
3323
4269
  if (turn.isLocal) {
3324
4270
  if (turn.userUuid) {
3325
4271
  // Local turn (adopt mode only): also surface the user prompt so the
@@ -3331,7 +4277,7 @@ function emitReadyTurns(opts = {}) {
3331
4277
  // a normally-typed pane prompt.
3332
4278
  const userEv = drained.events.find(e => e.uuid === turn.userUuid);
3333
4279
  const rawUserText = userEv ? extractTurnStartText(userEv) : '';
3334
- const fields = formatLocalTurnFields(rawUserText, assistantText);
4280
+ const fields = formatLocalTurnFields(rawUserText, postText);
3335
4281
  if (!fields)
3336
4282
  continue;
3337
4283
  send({
@@ -3346,7 +4292,7 @@ function emitReadyTurns(opts = {}) {
3346
4292
  continue;
3347
4293
  }
3348
4294
  // Headless local turn — see formatHeadlessLocalTurnContent for context.
3349
- const headlessContent = formatHeadlessLocalTurnContent(assistantText);
4295
+ const headlessContent = formatHeadlessLocalTurnContent(postText);
3350
4296
  if (!headlessContent)
3351
4297
  continue;
3352
4298
  send({
@@ -3361,7 +4307,7 @@ function emitReadyTurns(opts = {}) {
3361
4307
  }
3362
4308
  send({
3363
4309
  type: 'final_output',
3364
- content: assistantText,
4310
+ content: postText,
3365
4311
  lastUuid,
3366
4312
  turnId: turn.turnId,
3367
4313
  ...(turn.dispatchAttempt !== undefined ? { dispatchAttempt: turn.dispatchAttempt } : {}),
@@ -3677,7 +4623,23 @@ function codexBridgeAttach(rolloutPath, mode) {
3677
4623
  codexBridgePendingTail = result.pendingTail;
3678
4624
  codexBridgeBaselineDone = true;
3679
4625
  if (structuredBridgeIsCodex()) {
3680
- codexServiceTierTracker.observe(rolloutPath, result.latestThreadSettings);
4626
+ const codex = result;
4627
+ codexServiceTierTracker.observe(rolloutPath, codex.latestThreadSettings);
4628
+ // Reuse this drain's turn_context observation — split-live already read
4629
+ // the whole rollout above, so no second full-file scan is needed.
4630
+ publishActiveRuntime({
4631
+ model: codex.latestModel,
4632
+ reasoningEffort: codex.latestReasoningEffort,
4633
+ });
4634
+ }
4635
+ // Reuse this drain's runtime observation instead of a second full-file
4636
+ // scan — split-live already read the whole rollout above.
4637
+ if (structuredBridgeIsTraex()) {
4638
+ const traex = result;
4639
+ publishActiveRuntime({
4640
+ model: traex.latestModel,
4641
+ reasoningEffort: traex.latestReasoningEffort,
4642
+ });
3681
4643
  }
3682
4644
  log(`Codex bridge split-live: ${rolloutPath} (history=${history.length}, live=${live.length}, cutoff=${cutoff}, offset=${codexBridgeOffset})`);
3683
4645
  maybeEmitCodexAdoptPreamble(history);
@@ -3722,6 +4684,20 @@ function codexBridgeAttach(rolloutPath, mode) {
3722
4684
  && mode !== 'fresh-empty'
3723
4685
  && mode !== 'split-live') {
3724
4686
  codexServiceTierTracker.observe(rolloutPath, scanCodexThreadSettings(rolloutPath));
4687
+ // Codex baseline modes cursor to the tail without draining, so seed the
4688
+ // active runtime (model/effort from turn_context) via a bounded backward
4689
+ // read — same as TRAE below. split-live already published from its own
4690
+ // drain; fresh-empty has no history and picks it up on first live ingest.
4691
+ publishActiveRuntime(readLatestCodexRuntime(rolloutPath));
4692
+ }
4693
+ // TRAE baseline modes only cursor to the tail without draining, so seed the
4694
+ // active runtime from a bounded backward read. split-live already published
4695
+ // from its own drain; fresh-empty has no history and picks it up on first
4696
+ // live ingest.
4697
+ if (structuredBridgeIsTraex()
4698
+ && mode !== 'fresh-empty'
4699
+ && mode !== 'split-live') {
4700
+ publishActiveRuntime(readLatestTraexRuntime(rolloutPath));
3725
4701
  }
3726
4702
  try {
3727
4703
  codexBridgeWatcher = fsWatch(rolloutPath, { persistent: false }, () => {
@@ -4144,8 +5120,21 @@ function codexBridgeIngest(opts = {}) {
4144
5120
  const result = structuredBridgeIngestPath(codexBridgeRolloutPath, codexBridgeOffset);
4145
5121
  codexBridgeOffset = result.newOffset;
4146
5122
  codexBridgePendingTail = result.pendingTail;
5123
+ if (structuredBridgeIsTraex()) {
5124
+ const traex = result;
5125
+ publishActiveRuntime({
5126
+ model: traex.latestModel ?? publishedActiveRuntime.model,
5127
+ reasoningEffort: traex.latestReasoningEffort ?? publishedActiveRuntime.reasoningEffort,
5128
+ });
5129
+ }
4147
5130
  if (structuredBridgeIsCodex()) {
4148
- codexServiceTierTracker.observe(codexBridgeRolloutPath, result.latestThreadSettings);
5131
+ const codex = result;
5132
+ codexServiceTierTracker.observe(codexBridgeRolloutPath, codex.latestThreadSettings);
5133
+ publishActiveRuntime({
5134
+ model: codex.latestModel ?? publishedActiveRuntime.model,
5135
+ reasoningEffort: codex.latestReasoningEffort ?? publishedActiveRuntime.reasoningEffort,
5136
+ });
5137
+ maybeEmitCodexStructuredRateLimit(result.events);
4149
5138
  }
4150
5139
  if (result.events.length > 0)
4151
5140
  lastStructuredBridgeActivityAtMs = Date.now();
@@ -4160,6 +5149,26 @@ function codexBridgeIngest(opts = {}) {
4160
5149
  idleDetector?.fireIdle();
4161
5150
  }
4162
5151
  }
5152
+ /** 将 Codex 的结构化 429 终态同步为既有的限流状态。 */
5153
+ function maybeEmitCodexStructuredRateLimit(events) {
5154
+ for (const ev of events) {
5155
+ if (!isCodexRateLimitEvent(ev) || emittedRateLimitUuids.has(ev.uuid))
5156
+ continue;
5157
+ emittedRateLimitUuids.add(ev.uuid);
5158
+ const usageLimit = structuredRateLimitState(ev.terminalErrorSummary ?? '429 Too Many Requests');
5159
+ usageLimitTracker.noteStructuredLimit();
5160
+ send({
5161
+ type: 'screen_update',
5162
+ content: currentUsageLimitSnapshot(),
5163
+ status: 'limited',
5164
+ usageLimit,
5165
+ turnId: currentBotmuxTurnId,
5166
+ dispatchAttempt: currentBotmuxDispatchAttempt,
5167
+ });
5168
+ log(`Structured rate-limit detected in Codex transcript (uuid=${ev.uuid.substring(0, 8)}, retryLabel=${usageLimit.retryLabel}) → emitted limited state.`);
5169
+ return;
5170
+ }
5171
+ }
4163
5172
  /** Mark a pending Lark turn for Codex. Crucially this works even before a
4164
5173
  * rollout path is known — the queue is path-agnostic, and ingest after
4165
5174
  * late-attach picks up the user_message and matches the fingerprint. */
@@ -4245,11 +5254,14 @@ function emitReadyCodexTurns() {
4245
5254
  finalText: turn.finalText,
4246
5255
  terminalStatus: turn.terminalStatus,
4247
5256
  };
4248
- const content = turn.finalText && turn.finalText.trim()
4249
- ? turn.finalText
4250
- : shouldEmitEmptyCompletedBridgeFallback(gateInput, nextBoundaryMs, markers, adoptMode)
4251
- ? emptyCompletedBridgeFallbackContent()
4252
- : '';
5257
+ const content = turn.terminalErrorCode !== CODEX_RATE_LIMIT_ERROR_CODE
5258
+ && shouldEmitFailedBridgeFallback(gateInput, nextBoundaryMs, markers, adoptMode)
5259
+ ? failedBridgeFallbackContent(turn.terminalErrorCode, turn.terminalErrorSummary, turn.finalText)
5260
+ : turn.finalText && turn.finalText.trim()
5261
+ ? turn.finalText
5262
+ : shouldEmitEmptyCompletedBridgeFallback(gateInput, nextBoundaryMs, markers, adoptMode)
5263
+ ? emptyCompletedBridgeFallbackContent()
5264
+ : '';
4253
5265
  if (!content)
4254
5266
  continue;
4255
5267
  if (shouldSuppressBridgeEmit(gateInput, nextBoundaryMs, markers, adoptMode)) {
@@ -4257,12 +5269,22 @@ function emitReadyCodexTurns() {
4257
5269
  notifyExplicitReplyObserved(turn.turnId, explicitReplyMarkerForTurnWindow(gateInput, nextBoundaryMs, markers, adoptMode));
4258
5270
  continue;
4259
5271
  }
5272
+ // NON-ADOPT only: strip a trailing sentinel line so the literal token never
5273
+ // reaches Lark (prose+sentinel = "did work, forgot to send" — post the
5274
+ // prose). A pure-sentinel final was suppressed above, and the fallback
5275
+ // string carries no sentinel, so `postContent` is normally non-empty (guard
5276
+ // and skip if not). ADOPT must NEVER touch the text — the adopted CLI is
5277
+ // botmux-unaware and may output that literal string as content; the gate
5278
+ // already refuses to interpret the sentinel under adoptMode.
5279
+ const postContent = bridgePostText(content, adoptMode);
5280
+ if (!adoptMode && postContent.trim().length === 0)
5281
+ continue;
4260
5282
  if (turn.isLocal) {
4261
5283
  // Local turn (adopt only): user typed in iTerm. Surface both sides
4262
5284
  // so the Lark thread sees a complete exchange instead of an orphan
4263
5285
  // reply. formatLocalTurnFields caps both texts to keep within
4264
5286
  // Lark's per-message limit; daemon owns the card chrome.
4265
- const fields = formatLocalTurnFields(turn.userText ?? '', content);
5287
+ const fields = formatLocalTurnFields(turn.userText ?? '', postContent);
4266
5288
  if (!fields)
4267
5289
  continue;
4268
5290
  send({
@@ -4280,7 +5302,7 @@ function emitReadyCodexTurns() {
4280
5302
  send({
4281
5303
  type: 'final_output',
4282
5304
  ...(sourceHermesSessionId ? { sourceHermesSessionId } : {}),
4283
- content,
5305
+ content: postContent,
4284
5306
  lastUuid: turn.turnId,
4285
5307
  turnId: turn.turnId,
4286
5308
  ...(turn.dispatchAttempt !== undefined ? { dispatchAttempt: turn.dispatchAttempt } : {}),
@@ -4303,6 +5325,8 @@ function stopCodexBridge() {
4303
5325
  codexBridgeTimer = null;
4304
5326
  }
4305
5327
  codexServiceTierTracker.detach();
5328
+ activeRuntimePublished = false;
5329
+ publishedActiveRuntime = {};
4306
5330
  codexBridgeRolloutPath = undefined;
4307
5331
  codexBridgeOffset = 0;
4308
5332
  codexBridgePendingTail = '';
@@ -4498,6 +5522,68 @@ function recentTerminalLogTail() {
4498
5522
  return undefined;
4499
5523
  return tailChars(plain, CRASH_LOG_TAIL_MAX);
4500
5524
  }
5525
+ /**
5526
+ * 兜底:SessionStart 边界之后接受「屏幕上已有的」提示符。
5527
+ * 判据本身是 decidePostHookPromptEvidence()(input-gate.ts),这里只负责取数据
5528
+ * 和排定时器。
5529
+ *
5530
+ * 挡住启动选择器假提示符的**不是**静默窗口 —— 选择器停在那儿等按键时屏幕是静
5531
+ * 的(这正是它当初骗过 readyPattern 的原因)。真正的保障是 arm 的时机:只在
5532
+ * 收到 SessionStart ready 信号之后才 arm,而该 hook 在选择器还没过去时不会
5533
+ * 触发。窗口之外本兜底根本不运行。adopt / reattach 同理拿不到信号,不会 arm,
5534
+ * 与 shouldArmReadyGate() 的 !adoptMode && !willReattachPersistent 天然一致。
5535
+ *
5536
+ * 提示符必须从**渲染后的画面**读(renderer.rawSnapshot()),不能用
5537
+ * recentTerminalLogTail() —— 后者是 PTY 追加日志,stripAnsi 之后擦除语义全丢,
5538
+ * 一个早已被 TUI 抹掉的 ❯ 照样匹配得上,会把首条消息灌进还没就绪的界面。
5539
+ * 另外只有 rawSnapshot() 才含 ❯ 行,snapshot() 会把它过滤掉。
5540
+ */
5541
+ let postHookEvidenceFallbackTimer = null;
5542
+ function clearPostHookEvidenceFallback() {
5543
+ if (postHookEvidenceFallbackTimer) {
5544
+ clearTimeout(postHookEvidenceFallbackTimer);
5545
+ postHookEvidenceFallbackTimer = null;
5546
+ }
5547
+ }
5548
+ /** 当前渲染画面是否有提示符(renderer 尚未就绪时按「没有」处理,等下一轮)。 */
5549
+ function screenShowsReadyPattern() {
5550
+ const pattern = cliAdapter?.readyPattern;
5551
+ if (!pattern)
5552
+ return false;
5553
+ let screen = '';
5554
+ try {
5555
+ screen = renderer?.rawSnapshot() ?? '';
5556
+ }
5557
+ catch {
5558
+ return false;
5559
+ }
5560
+ return !!screen && pattern.test(screen);
5561
+ }
5562
+ function armPostHookPromptEvidenceFallback(startedAt = Date.now(), delayMs = POST_HOOK_EVIDENCE_FALLBACK_MS) {
5563
+ clearPostHookEvidenceFallback();
5564
+ postHookEvidenceFallbackTimer = setTimeout(() => {
5565
+ postHookEvidenceFallbackTimer = null;
5566
+ const quietMs = Date.now() - lastPtyOutputAtMs;
5567
+ const decision = decidePostHookPromptEvidence({
5568
+ stillWaiting: awaitingPostSessionStartPromptEvidence,
5569
+ elapsedMs: Date.now() - startedAt,
5570
+ quietMs,
5571
+ screenHasReadyPattern: screenShowsReadyPattern(),
5572
+ });
5573
+ if (decision.action === 'stop')
5574
+ return;
5575
+ if (decision.action === 'retry') {
5576
+ armPostHookPromptEvidenceFallback(startedAt, decision.retryInMs ?? POST_HOOK_EVIDENCE_RETRY_MS);
5577
+ return;
5578
+ }
5579
+ // seedReadyEvidence() 只补 readySeen,仍走完整静默判定;随后由既有的
5580
+ // markIdle('screen') → markPromptReadyFromPty() 链路清除等待标记。
5581
+ if (idleDetector?.seedReadyEvidence()) {
5582
+ log(`Post-SessionStart evidence fallback: screen quiet ${quietMs}ms with readyPattern on screen; accepting existing prompt`);
5583
+ }
5584
+ }, delayMs);
5585
+ postHookEvidenceFallbackTimer.unref?.();
5586
+ }
4501
5587
  function crashDiagnosticPath() {
4502
5588
  const dataDir = process.env.SESSION_DATA_DIR;
4503
5589
  if (!dataDir || !sessionId)
@@ -4854,6 +5940,9 @@ async function captureAndUpload() {
4854
5940
  return;
4855
5941
  }
4856
5942
  let status = isPromptReady ? 'idle' : 'working';
5943
+ // master removed the AI ScreenAnalyzer, so the `analyzing` projection is gone;
5944
+ // PR #597's Codex App no-progress liveness projection still layers on top.
5945
+ status = codexAppLivenessStatus(status);
4857
5946
  send({
4858
5947
  type: 'screenshot_uploaded',
4859
5948
  imageKey,
@@ -5421,10 +6510,11 @@ function handleVisibleStartupInteraction(data) {
5421
6510
  }
5422
6511
  return true;
5423
6512
  }
5424
- // Codex App runner sends botmux control messages as OSC sequences so they do
5425
- // not pollute the visible terminal. Strip them before xterm rendering and
5426
- // translate them back into worker IPC.
5427
- const APP_RUNNER_OSC_CLI_IDS = new Set(['codex-app', 'mira', 'mir']);
6513
+ // Mira/Mir still send terminal OSC control messages. Codex App deliberately
6514
+ // does not (PR #597): its signed Unix-socket channel is independent of the
6515
+ // terminal/backend rendering (including Herdr and Zellij), so it is no longer
6516
+ // in the terminal-OSC decode set.
6517
+ const APP_RUNNER_OSC_CLI_IDS = new Set(['mira', 'mir']);
5428
6518
  const appRunnerControlDecoder = new RunnerControlDecoder();
5429
6519
  let kiroSessionIdCaptureArmed = false;
5430
6520
  let kiroSessionIdCaptureBuffer = '';
@@ -5436,39 +6526,130 @@ function decodeCodexAppPayload(payload) {
5436
6526
  return undefined;
5437
6527
  }
5438
6528
  }
5439
- function handleCodexAppMarker(body) {
5440
- const sep = body.indexOf(':');
5441
- if (sep < 0)
5442
- return;
5443
- const kind = body.slice(0, sep);
5444
- const payload = decodeCodexAppPayload(body.slice(sep + 1));
5445
- if (!payload || typeof payload !== 'object')
5446
- return;
6529
+ const CODEX_APP_DAEMON_PERSIST_TIMEOUT_MS = 30_000;
6530
+ function waitForCodexAppDaemonPersistence(requestId, publish) {
6531
+ return new Promise(resolve => {
6532
+ const timer = setTimeout(() => {
6533
+ const pending = codexAppPendingDaemonAcks.get(requestId);
6534
+ if (!pending)
6535
+ return;
6536
+ codexAppPendingDaemonAcks.delete(requestId);
6537
+ resolve(false);
6538
+ }, CODEX_APP_DAEMON_PERSIST_TIMEOUT_MS);
6539
+ timer.unref?.();
6540
+ codexAppPendingDaemonAcks.set(requestId, { resolve, timer });
6541
+ try {
6542
+ publish();
6543
+ }
6544
+ catch {
6545
+ clearTimeout(timer);
6546
+ codexAppPendingDaemonAcks.delete(requestId);
6547
+ resolve(false);
6548
+ }
6549
+ });
6550
+ }
6551
+ function requestCodexAppDispatchTransition(operation, entries) {
6552
+ if (!sessionId || entries.length === 0)
6553
+ return Promise.resolve(entries.length === 0);
6554
+ const requestId = randomBytes(16).toString('hex');
6555
+ return waitForCodexAppDaemonPersistence(requestId, () => send({
6556
+ type: 'codex_app_dispatch_transition',
6557
+ sessionId,
6558
+ requestId,
6559
+ operation,
6560
+ entries,
6561
+ }));
6562
+ }
6563
+ /** Retire one exact durable dispatch before its receipt/lease owner is allowed
6564
+ * to replay it. The daemon ledger is the authority; local queue removal is
6565
+ * deliberately after the durable ACK so a failed session-file write cannot
6566
+ * create a replay window while the old worker still owns the turn. */
6567
+ async function retireCodexAppDispatchForDurableReplay(turnId, dispatchAttempt) {
6568
+ if (lastInitConfig?.cliId !== 'codex-app') {
6569
+ for (let index = pendingMessages.length - 1; index >= 0; index--) {
6570
+ const item = pendingMessages[index];
6571
+ if (item.turnId === turnId && item.dispatchAttempt === dispatchAttempt) {
6572
+ pendingMessages.splice(index, 1);
6573
+ }
6574
+ }
6575
+ return true;
6576
+ }
6577
+ const reservation = codexAppTurnDispatchQueue.findExact(turnId, dispatchAttempt);
6578
+ const pending = pendingMessages.find(item => item.turnId === turnId
6579
+ && item.dispatchAttempt === dispatchAttempt);
6580
+ const recovered = codexAppRecoveredDispatches.find(entry => entry.turnId === turnId
6581
+ && entry.dispatchAttempt === dispatchAttempt);
6582
+ const dispatchIds = new Set([
6583
+ reservation?.dispatchId,
6584
+ pending?.codexAppDispatchId,
6585
+ recovered?.dispatchId,
6586
+ ].filter((value) => !!value));
6587
+ if (dispatchIds.size !== 1) {
6588
+ log(`Cannot retire Codex App durable dispatch turn=${turnId.slice(0, 12)} `
6589
+ + `attempt=${dispatchAttempt}: exact dispatch id is ${dispatchIds.size === 0 ? 'missing' : 'ambiguous'}`);
6590
+ return false;
6591
+ }
6592
+ const dispatchId = [...dispatchIds][0];
6593
+ if (!await requestCodexAppDispatchTransition('cancel', [{
6594
+ dispatchId,
6595
+ turnId,
6596
+ dispatchAttempt,
6597
+ }]))
6598
+ return false;
6599
+ if (reservation && !codexAppTurnDispatchQueue.cancelExact(reservation.handle))
6600
+ return false;
6601
+ for (let index = pendingMessages.length - 1; index >= 0; index--) {
6602
+ const item = pendingMessages[index];
6603
+ if (item.turnId === turnId && item.dispatchAttempt === dispatchAttempt) {
6604
+ pendingMessages.splice(index, 1);
6605
+ }
6606
+ }
6607
+ codexAppRecoveredDispatches = codexAppRecoveredDispatches.filter(entry => entry.dispatchId !== dispatchId);
6608
+ return true;
6609
+ }
6610
+ async function handleTrustedCodexAppMarker(kind, payload, control) {
5447
6611
  if (kind === 'thread' && typeof payload.threadId === 'string') {
5448
6612
  persistCliSessionId(payload.threadId);
5449
- return;
6613
+ return true;
5450
6614
  }
6615
+ // master added lifecycle/steer markers. In the merged world codex-app reaches
6616
+ // this handler over the signed socket (PR #597 moved it off terminal OSC), so
6617
+ // the branch lives here rather than in handleAppRunnerOscMarker. These events
6618
+ // are informational (steer-ack correlation); a malformed or unmatched event is
6619
+ // logged/ignored but still counts as successfully applied so the signed record
6620
+ // is ACKed rather than destroying the control socket.
5451
6621
  if (kind === 'lifecycle') {
5452
6622
  const event = normalizeCodexAppLifecycleEvent(payload);
5453
6623
  if (!event) {
5454
6624
  log(`${cliName()} rejected malformed lifecycle marker`);
5455
- return;
6625
+ return true;
5456
6626
  }
5457
6627
  log(`${cliName()} lifecycle kind=${event.kind}`
5458
6628
  + ` appTurn=${shortCorrelationId(event.appTurnId)}`
5459
6629
  + ` replyTurn=${shortCorrelationId(event.replyTurnId)}`
5460
6630
  + `${event.queueLength !== undefined ? ` queue=${event.queueLength}` : ''}`);
6631
+ // An authenticated `fatal` lifecycle is a control-plane kill, NOT an
6632
+ // informational steer-correlation event (B2): the runner fenced its
6633
+ // generation on an unknown turn/start|turn/steer outcome and will emit no
6634
+ // final for the in-doubt turn. Tear the generation down so no recovered
6635
+ // prepared frame is replayed and no ready is published. This MUST run before
6636
+ // the replyTurnId gate below — a fatal carries no replyTurnId, so gating on
6637
+ // it would (wrongly) swallow the kill as informational.
6638
+ if (event.kind === 'fatal') {
6639
+ failCodexAppControlGeneration(`Codex App runner fenced its generation (${event.operation}/${event.category})`);
6640
+ return false;
6641
+ }
5461
6642
  if (!event.replyTurnId || !submittedCodexAppReplyTurnIds.has(event.replyTurnId))
5462
- return;
6643
+ return true;
5463
6644
  if (event.kind === 'steer_attempt') {
5464
6645
  rememberBoundedMap(pendingCodexAppSteerAckIds, event.replyTurnId, event.appTurnId);
5465
- return;
6646
+ return true;
5466
6647
  }
5467
6648
  const steerKey = `${event.appTurnId}\0${event.replyTurnId}`;
5468
6649
  if (event.kind !== 'steer_accepted'
5469
6650
  || pendingCodexAppSteerAckIds.get(event.replyTurnId) !== event.appTurnId
5470
6651
  || acknowledgedCodexAppSteers.has(steerKey))
5471
- return;
6652
+ return true;
5472
6653
  pendingCodexAppSteerAckIds.delete(event.replyTurnId);
5473
6654
  rememberBounded(acknowledgedCodexAppSteers, steerKey);
5474
6655
  send({
@@ -5476,98 +6657,359 @@ function handleCodexAppMarker(body) {
5476
6657
  appTurnId: event.appTurnId,
5477
6658
  turnId: event.replyTurnId,
5478
6659
  });
5479
- return;
6660
+ return true;
5480
6661
  }
5481
- if (kind === 'final') {
5482
- const marker = normalizeAppRunnerFinalMarker(payload);
5483
- if (!marker) {
5484
- log(`${cliName()} rejected malformed final marker`);
5485
- return;
6662
+ if (kind === 'state' && lastInitConfig?.cliId === 'codex-app') {
6663
+ const readiness = codexAppSignedStateReadiness(payload);
6664
+ if (readiness === 'invalid') {
6665
+ rejectCodexAppControlMarker('signed state missing boolean acceptingInput');
6666
+ failCodexAppControlGeneration('Codex App runner published signed state without boolean acceptingInput');
6667
+ return false;
5486
6668
  }
5487
- const startedAtMs = marker.startedAtMs;
5488
- const completedAtMs = marker.completedAtMs ?? Date.now();
5489
- if (marker.appTurnId) {
5490
- const trustedReplyTurnId = marker.replyTurnId
5491
- && submittedCodexAppReplyTurnIds.has(marker.replyTurnId)
5492
- ? marker.replyTurnId
5493
- : undefined;
5494
- if (marker.replyTurnId && !trustedReplyTurnId) {
5495
- log(`${cliName()} ignored unsubmitted final reply route `
5496
- + `(replyTurn=${shortCorrelationId(marker.replyTurnId)})`);
5497
- }
5498
- const identity = projectAppRunnerFinalIds({ ...marker, replyTurnId: trustedReplyTurnId }, currentBotmuxTurnId, `${lastInitConfig?.cliId ?? 'app'}-${Date.now()}`);
5499
- if (marker.appTurnId !== identity.turnId) {
5500
- log(`${cliName()} app turn ${shortCorrelationId(marker.appTurnId)} mapped `
5501
- + `to botmux turn ${shortCorrelationId(identity.turnId)}`);
5502
- }
5503
- const dispatchAttempt = currentBotmuxDispatchAttempt;
5504
- if (startedAtMs !== undefined) {
5505
- const markers = readSendMarkers();
5506
- const gateInput = { markTimeMs: startedAtMs, isLocal: false, finalText: marker.content };
5507
- if (shouldSuppressBridgeEmit(gateInput, completedAtMs + 5_001, markers, false)) {
5508
- log(`${cliName()} final_output suppressed (model already called botmux send)`);
5509
- // Symmetric with the legacy final branch: tell observers (e.g. the
5510
- // message-listener run-preview lifecycle) that this turn's reply was
5511
- // the model's explicit botmux send, so it stops showing "running".
5512
- notifyExplicitReplyObserved(identity.turnId, explicitReplyMarkerForTurnWindow(gateInput, completedAtMs + 5_001, markers, false));
5513
- emitTurnTerminal(identity.turnId, 'completed', undefined, dispatchAttempt);
5514
- return;
5515
- }
6669
+ if (readiness === 'waiting') {
6670
+ if (typeof payload.busy !== 'boolean') {
6671
+ rejectCodexAppControlMarker('invalid signed state');
6672
+ return false;
5516
6673
  }
5517
- send({
5518
- type: 'final_output',
5519
- content: marker.content,
5520
- lastUuid: identity.lastUuid,
5521
- turnId: identity.turnId,
5522
- ...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
5523
- ...(marker.usage ? { usage: marker.usage } : {}),
5524
- });
5525
- emitTurnTerminal(identity.turnId, 'completed', undefined, dispatchAttempt);
5526
- return;
6674
+ codexAppSignedStateObserved = false;
6675
+ codexAppInputReady = false;
6676
+ codexAppReadyAuthority.beginWork();
6677
+ isPromptReady = false;
6678
+ idleDetector?.reset();
6679
+ if (!codexAppProofDeadline.armed) {
6680
+ codexAppProofDeadline.arm(() => {
6681
+ failCodexAppControlGeneration(`Codex App runner did not become input-ready within ${CODEX_APP_CONTROL_STARTUP_TIMEOUT_MS / 1000} seconds`);
6682
+ });
6683
+ }
6684
+ log('Authenticated Codex App runner is not yet accepting input; readiness deadline remains armed');
6685
+ return true;
5527
6686
  }
5528
- // Codex App keeps the app-server-generated id separately for diagnostics.
5529
- // Routing must use its stable clientUserMessageId marker; legacy envelopes
5530
- // intentionally omit it and fall back to the worker's frozen botmux turn.
5531
- const identity = resolveCodexAppFinalTurnIdentity(payload, currentBotmuxTurnId, `${lastInitConfig?.cliId ?? 'app'}-${Date.now()}`);
5532
- if (!identity.ok) {
5533
- log(`${cliName()} rejected final marker with mismatched turn `
5534
- + `(marker=${identity.markerTurnId.substring(0, 12)}, `
5535
- + `current=${identity.currentBotmuxTurnId?.substring(0, 12) ?? '-'})`);
5536
- return;
6687
+ const state = applyTrustedCodexAppStateMarker(codexAppTurnLiveness, codexAppReadyAuthority, payload);
6688
+ if (!state.accepted) {
6689
+ rejectCodexAppControlMarker('invalid signed state');
6690
+ return false;
5537
6691
  }
5538
- const { turnId, nativeTurnId } = identity;
5539
- if (nativeTurnId && nativeTurnId !== turnId) {
5540
- log(`${cliName()} native turn ${nativeTurnId.substring(0, 12)} mapped to botmux turn ${turnId.substring(0, 12)}`);
6692
+ // This is the first actual readiness proof. Authentication alone cannot
6693
+ // clear startup/reconnect failure detection or release queued input.
6694
+ codexAppSignedStateObserved = true;
6695
+ codexAppInputReady = true;
6696
+ codexAppProofDeadline.clear();
6697
+ cleanupCodexAppControlBootstrap();
6698
+ if (codexAppInputReady && awaitingFirstPrompt) {
6699
+ awaitingFirstPrompt = false;
6700
+ renderer?.markNewTurn();
5541
6701
  }
5542
- if (payload.dispatchAttempt !== undefined
5543
- && payload.dispatchAttempt !== currentBotmuxDispatchAttempt) {
5544
- log(`${cliName()} rejected final marker with mismatched dispatch attempt `
5545
- + `(marker=${String(payload.dispatchAttempt)}, current=${currentBotmuxDispatchAttempt ?? '-'})`);
5546
- return;
6702
+ if (state.busy) {
6703
+ isPromptReady = false;
6704
+ idleDetector?.reset();
6705
+ if (codexAppInputReady)
6706
+ queueMicrotask(() => { void flushPending(); });
5547
6707
  }
5548
- // Attempt authority is worker-owned. A runner marker may redundantly assert
5549
- // equality for compatibility, but can never select another attempt.
5550
- const dispatchAttempt = currentBotmuxDispatchAttempt;
5551
- if (startedAtMs !== undefined) {
5552
- const markers = readSendMarkers();
5553
- const gateInput = { markTimeMs: startedAtMs, isLocal: false, finalText: payload.content };
5554
- const sentByModel = shouldSuppressBridgeEmit(gateInput, completedAtMs + 5_001, markers, false);
5555
- if (sentByModel) {
5556
- log(`${cliName()} final_output suppressed (model already called botmux send)`);
5557
- notifyExplicitReplyObserved(turnId, explicitReplyMarkerForTurnWindow(gateInput, completedAtMs + 5_001, markers, false));
5558
- emitTurnTerminal(turnId, 'completed', undefined, dispatchAttempt);
5559
- return;
6708
+ else {
6709
+ if (codexAppCompletionAwaitingFinal) {
6710
+ // Every runner generation that can authenticate this new signed
6711
+ // channel emits an explicit final transaction, including zero chunks
6712
+ // for an empty answer. Guessing an empty final here would let a
6713
+ // mismatched/rejected final advance the FIFO and be ACKed as success.
6714
+ failCodexAppControlGeneration('Codex App runner published idle before the required final transaction');
6715
+ return false;
5560
6716
  }
6717
+ // Signed idle proves only that the runner has no queued/active turn. It
6718
+ // does NOT prove its raw stdin inputBuffer is empty: the old worker can
6719
+ // die after writing the complete frame but before Enter, then this warm
6720
+ // runner reconnects and reports idle. Resetting prepared→accepted would
6721
+ // let the replacement pre-flush submit that old frame and then replay it
6722
+ // a second time. A recovered prepared entry therefore advances only by
6723
+ // replaying its final/high-water ACK; otherwise fail closed for explicit
6724
+ // operator abandon.
6725
+ if (codexAppTurnDispatchQueue.recoveredPrefix().length > 0) {
6726
+ failCodexAppControlGeneration('Codex App signed idle cannot prove the recovered prepared frame was never buffered');
6727
+ return false;
6728
+ }
6729
+ if (state.shouldPublishReady) {
6730
+ codexAppUnprovenPromptDeferred = false;
6731
+ queueMicrotask(() => markPromptReady());
6732
+ }
6733
+ }
6734
+ return true;
6735
+ }
6736
+ if (kind === 'diagnostic' && lastInitConfig?.cliId === 'codex-app') {
6737
+ if (payload.code !== 'native_turn_identity_conflict'
6738
+ || typeof payload.message !== 'string') {
6739
+ rejectCodexAppControlMarker('invalid signed diagnostic');
6740
+ return false;
5561
6741
  }
6742
+ log(`Codex App fail-closed diagnostic: ${payload.message}`);
5562
6743
  send({
5563
- type: 'final_output',
5564
- content: marker.content,
5565
- lastUuid: turnId,
5566
- turnId,
5567
- ...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
6744
+ type: 'user_notify',
6745
+ message: payload.message,
6746
+ ...(typeof payload.turnId === 'string' ? { turnId: payload.turnId } : {}),
5568
6747
  });
6748
+ return true;
6749
+ }
6750
+ if (kind === 'activity' && lastInitConfig?.cliId === 'codex-app') {
6751
+ const activity = applyTrustedCodexAppActivityMarker(codexAppTurnLiveness, payload);
6752
+ if (!activity.accepted) {
6753
+ rejectCodexAppControlMarker('invalid signed activity');
6754
+ return false;
6755
+ }
6756
+ cleanupCodexAppControlBootstrap();
6757
+ if (activity.phase === 'completed') {
6758
+ codexAppCompletionAwaitingFinal = true;
6759
+ }
6760
+ else {
6761
+ if (activity.phase === 'submitted' && codexAppCompletionAwaitingFinal) {
6762
+ failCodexAppControlGeneration('Codex App runner submitted the next turn before the required final transaction');
6763
+ return false;
6764
+ }
6765
+ codexAppReadyAuthority.beginWork();
6766
+ isPromptReady = false;
6767
+ idleDetector?.reset();
6768
+ }
6769
+ return true;
6770
+ }
6771
+ if (kind === 'final' && typeof payload.content === 'string') {
6772
+ const finalContent = payload.content;
6773
+ // Blocking 1 N-final expansion: a `steer_superseded` disposition marks one of
6774
+ // the first N−1 members of an ordered steered group. It advances the worker
6775
+ // FIFO (durable settlement) but is NEVER delivered, carries no usage, and does
6776
+ // NOT clear completion-awaiting or publish ready — only the LAST (real) final
6777
+ // of the group does that. Validate the contract strictly: any other
6778
+ // disposition value, or a superseded final with content/usage or without an
6779
+ // awaited completion, is a malformed control record → reject (fail closed).
6780
+ const disposition = payload.disposition;
6781
+ const isSuperseded = disposition === 'steer_superseded';
6782
+ if (disposition !== undefined && !isSuperseded) {
6783
+ rejectCodexAppControlMarker(`unknown final disposition ${String(disposition)}`);
6784
+ return false;
6785
+ }
6786
+ if (isSuperseded
6787
+ && (lastInitConfig?.cliId !== 'codex-app'
6788
+ || finalContent !== ''
6789
+ || payload.usage !== undefined
6790
+ || !codexAppCompletionAwaitingFinal)) {
6791
+ rejectCodexAppControlMarker('invalid steer_superseded final (content/usage/awaiting contract)');
6792
+ return false;
6793
+ }
6794
+ // Forward the runner's four-bucket token usage on the final_output IPC so
6795
+ // the daemon's async-trigger sink (worker-pool recordCompleted) can persist
6796
+ // it. normalizeFinalUsage validates non-negative integers and drops a
6797
+ // malformed/partial packet — never let a compromised runner poison usage.
6798
+ // A superseded final never carries usage (validated above).
6799
+ const finalUsage = normalizeFinalUsage(payload.usage);
6800
+ const startedAtMs = typeof payload.startedAtMs === 'number' && Number.isFinite(payload.startedAtMs)
6801
+ ? payload.startedAtMs
6802
+ : undefined;
6803
+ const receivedAtMs = Date.now();
6804
+ const completedAtMs = typeof payload.completedAtMs === 'number' && Number.isFinite(payload.completedAtMs)
6805
+ ? Math.min(payload.completedAtMs, receivedAtMs)
6806
+ : receivedAtMs;
6807
+ let turnId;
6808
+ let nativeTurnId;
6809
+ let replyTurnId;
6810
+ let dispatchAttempt;
6811
+ let codexAppDispatchId;
6812
+ let codexAppDispatchHandle;
6813
+ if (lastInitConfig?.cliId === 'codex-app') {
6814
+ // flushPending may finish writing N+1 before final N arrives. Attribute
6815
+ // only from the immutable worker-owned FIFO head; currentBotmuxTurnId is
6816
+ // intentionally not consulted here.
6817
+ const settlement = codexAppTurnDispatchQueue.settleFinal(payload, false);
6818
+ if (!settlement.ok) {
6819
+ log(`${cliName()} rejected final marker (${settlement.reason}; `
6820
+ + `marker=${settlement.markerTurnId?.substring(0, 12) ?? '-'}, `
6821
+ + `expected=${settlement.expectedTurnId?.substring(0, 12) ?? '-'})`);
6822
+ return false;
6823
+ }
6824
+ ({
6825
+ turnId,
6826
+ replyTurnId,
6827
+ nativeTurnId,
6828
+ dispatchAttempt,
6829
+ dispatchId: codexAppDispatchId,
6830
+ handle: codexAppDispatchHandle,
6831
+ } = settlement);
6832
+ // R4-B4 worker-side defense-in-depth: a superseded settlement is only valid
6833
+ // for a steerable exact head that STILL has a successor reservation
6834
+ // (remaining > 0). A single forged superseded on the only head would
6835
+ // otherwise silently commit it, and the real final would then find no
6836
+ // pending turn. Reject before any commit — the reservation, logical slot,
6837
+ // and awaiting-final all stay untouched (settleFinal used consume:false).
6838
+ if (isSuperseded
6839
+ && (settlement.codexAppSteerable !== true || settlement.remaining <= 0)) {
6840
+ rejectCodexAppControlMarker(`steer_superseded head not steerable / has no successor `
6841
+ + `(steerable=${settlement.codexAppSteerable === true}, remaining=${settlement.remaining})`);
6842
+ return false;
6843
+ }
6844
+ // Liveness slot retirement + completion-awaiting clearing happen AFTER the
6845
+ // durable commit below (a persist/commit failure must not retire a slot or
6846
+ // clear awaiting), and are disposition-aware: a superseded member retires
6847
+ // exactly one extra logical slot but keeps awaiting=true / never publishes
6848
+ // ready; only the real final clears awaiting. See the post-commit block.
6849
+ }
6850
+ else {
6851
+ // Mira/Mir retain their terminal OSC control path and do not use the
6852
+ // Codex App serial dispatch FIFO.
6853
+ const identity = resolveCodexAppFinalTurnIdentity(payload, currentBotmuxTurnId, `${lastInitConfig?.cliId ?? 'app'}-${Date.now()}`);
6854
+ if (!identity.ok) {
6855
+ log(`${cliName()} rejected final marker with mismatched turn `
6856
+ + `(marker=${identity.markerTurnId.substring(0, 12)}, `
6857
+ + `current=${identity.currentBotmuxTurnId?.substring(0, 12) ?? '-'})`);
6858
+ return false;
6859
+ }
6860
+ ({ turnId, nativeTurnId } = identity);
6861
+ if (payload.dispatchAttempt !== undefined
6862
+ && payload.dispatchAttempt !== currentBotmuxDispatchAttempt) {
6863
+ log(`${cliName()} rejected final marker with mismatched dispatch attempt `
6864
+ + `(marker=${String(payload.dispatchAttempt)}, current=${currentBotmuxDispatchAttempt ?? '-'})`);
6865
+ return false;
6866
+ }
6867
+ dispatchAttempt = currentBotmuxDispatchAttempt;
6868
+ }
6869
+ if (nativeTurnId && nativeTurnId !== turnId) {
6870
+ log(`${cliName()} native turn ${nativeTurnId.substring(0, 12)} mapped to botmux turn ${turnId.substring(0, 12)}`);
6871
+ }
6872
+ let suppressDelivery = false;
6873
+ // What actually reaches Lark: strip a trailing sentinel line so the literal
6874
+ // token never posts. `finalContent` stays RAW above (the steer_superseded
6875
+ // validator asserts finalContent==='' and must see the unstripped payload).
6876
+ // If nothing remains after stripping, this was a pure-silence final → treat
6877
+ // as suppressed so the daemon persists the FIFO advance without delivering.
6878
+ const deliverableContent = stripTrailingBridgeSentinelLine(finalContent);
6879
+ if (deliverableContent.trim().length === 0 && finalContent.trim().length > 0) {
6880
+ suppressDelivery = true;
6881
+ }
6882
+ if (deliverableContent && startedAtMs !== undefined) {
6883
+ const suppressMarkers = readSendMarkers();
6884
+ const gateInput = { markTimeMs: startedAtMs, isLocal: false, finalText: deliverableContent };
6885
+ suppressDelivery = suppressDelivery || shouldSuppressBridgeEmit(gateInput, completedAtMs + 5_001, suppressMarkers, false);
6886
+ if (suppressDelivery) {
6887
+ log(`${cliName()} final_output suppressed (model already called botmux send)`);
6888
+ // Symmetric with the legacy/master appTurnId final branch: tell
6889
+ // observers (e.g. the message-listener run-preview lifecycle) that this
6890
+ // turn's reply was the model's explicit botmux send, so it stops showing
6891
+ // "running". The signed suppress path forwards final_output with
6892
+ // suppressDelivery:true, which the daemon short-circuits WITHOUT calling
6893
+ // deliverFinalOutput — the only site that otherwise marks run-preview
6894
+ // replied — so without this the preview shows "running" forever (F3).
6895
+ notifyExplicitReplyObserved(turnId, explicitReplyMarkerForTurnWindow(gateInput, completedAtMs + 5_001, suppressMarkers, false));
6896
+ }
6897
+ }
6898
+ if (codexAppDispatchId) {
6899
+ if (!control || codexAppDispatchHandle === undefined)
6900
+ return false;
6901
+ const requestId = randomBytes(16).toString('hex');
6902
+ // A superseded member is durably settled but NEVER delivered: force empty
6903
+ // content + suppressDelivery so the daemon persists the FIFO advance without
6904
+ // deliverFinalOutput, and tag the disposition so the sink is explicit.
6905
+ const persisted = await waitForCodexAppDaemonPersistence(requestId, () => send({
6906
+ type: 'final_output',
6907
+ content: (suppressDelivery || isSuperseded) ? '' : deliverableContent,
6908
+ lastUuid: turnId,
6909
+ turnId,
6910
+ ...(replyTurnId ? { replyTurnId } : {}),
6911
+ ...(finalUsage ? { usage: finalUsage } : {}),
6912
+ ...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
6913
+ ...((suppressDelivery || isSuperseded) ? { suppressDelivery: true } : {}),
6914
+ ...(isSuperseded ? { disposition: 'steer_superseded' } : {}),
6915
+ codexAppSettlement: {
6916
+ requestId,
6917
+ generation: control.generation,
6918
+ seq: control.seq,
6919
+ dispatchId: codexAppDispatchId,
6920
+ },
6921
+ }));
6922
+ if (!persisted || !codexAppTurnDispatchQueue.commitExactHead(codexAppDispatchHandle)) {
6923
+ // Persist/commit failed: do NOT retire a liveness slot or clear awaiting
6924
+ // (codex step 5) — the generation may replay this exact transaction.
6925
+ return false;
6926
+ }
6927
+ codexAppGenerationCommits = [
6928
+ ...codexAppGenerationCommits.filter(commit => commit.generation !== control.generation),
6929
+ {
6930
+ generation: control.generation,
6931
+ committedThrough: Math.max(control.seq, codexAppGenerationCommits.find(commit => commit.generation === control.generation)?.committedThrough ?? 0),
6932
+ },
6933
+ ];
6934
+ codexAppRecoveredDispatches = codexAppRecoveredDispatches.filter(entry => entry.dispatchId !== codexAppDispatchId);
6935
+ }
6936
+ else {
6937
+ if (lastInitConfig?.cliId === 'codex-app'
6938
+ && codexAppDispatchHandle !== undefined
6939
+ && !codexAppTurnDispatchQueue.commitExactHead(codexAppDispatchHandle)) {
6940
+ return false;
6941
+ }
6942
+ if (finalContent && !suppressDelivery) {
6943
+ send({
6944
+ type: 'final_output',
6945
+ content: deliverableContent,
6946
+ lastUuid: turnId,
6947
+ turnId,
6948
+ ...(finalUsage ? { usage: finalUsage } : {}),
6949
+ ...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
6950
+ });
6951
+ }
6952
+ else if (isSuperseded) {
6953
+ // A superseded member still emits a final_output so the daemon (and any
6954
+ // async-trigger/observer sink) records the FIFO advance — but with empty
6955
+ // content + suppressDelivery + the disposition tag so it is persisted and
6956
+ // NEVER delivered. Without this the member would be invisible downstream.
6957
+ send({
6958
+ type: 'final_output',
6959
+ content: '',
6960
+ lastUuid: turnId,
6961
+ turnId,
6962
+ ...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
6963
+ suppressDelivery: true,
6964
+ disposition: 'steer_superseded',
6965
+ });
6966
+ }
6967
+ else if (!finalContent) {
6968
+ log(`${cliName()} empty final settled for botmux turn ${turnId.substring(0, 12)}`);
6969
+ }
6970
+ }
6971
+ // Post-commit liveness (codex-app only; runs only after a successful durable
6972
+ // or fallback commit — a failed commit returned above without touching state):
6973
+ if (lastInitConfig?.cliId === 'codex-app') {
6974
+ if (isSuperseded) {
6975
+ // A superseded member retires exactly ONE extra logical liveness slot
6976
+ // (worker flush created N slots for the group; the single native
6977
+ // activity:completed already retired the first). Keep awaiting-final TRUE
6978
+ // and never publish ready — the group's real final is still to come. This
6979
+ // is "retire a logical slot", NOT "close the native completion gate".
6980
+ codexAppTurnLiveness.completeCurrent(completedAtMs);
6981
+ // codexAppCompletionAwaitingFinal intentionally left true.
6982
+ }
6983
+ else {
6984
+ if (!codexAppCompletionAwaitingFinal) {
6985
+ // turn/start failures emit a final transaction without an app-server
6986
+ // completed activity edge. Close exactly one liveness slot here.
6987
+ codexAppTurnLiveness.completeCurrent(completedAtMs);
6988
+ }
6989
+ // The real final of the group clears awaiting; the runner sequences the
6990
+ // signed state{busy:false} after this complete final transaction, so
6991
+ // ready publishes there (never here).
6992
+ codexAppCompletionAwaitingFinal = false;
6993
+ }
6994
+ }
5569
6995
  emitTurnTerminal(turnId, 'completed', undefined, dispatchAttempt);
6996
+ return true;
5570
6997
  }
6998
+ rejectCodexAppControlMarker(`unsupported signed ${kind}`);
6999
+ return false;
7000
+ }
7001
+ function handleAppRunnerOscMarker(body) {
7002
+ const sep = body.indexOf(':');
7003
+ if (sep < 0)
7004
+ return;
7005
+ const kind = body.slice(0, sep);
7006
+ const payload = decodeCodexAppPayload(body.slice(sep + 1));
7007
+ if (!payload || typeof payload !== 'object')
7008
+ return;
7009
+ // PR #597 routes every runner marker (thread/state/diagnostic/activity/final
7010
+ // and master's lifecycle/steer) through the single trusted handler; codex-app
7011
+ // reaches it over the signed socket, mira/mir over this terminal-OSC path.
7012
+ void handleTrustedCodexAppMarker(kind, payload);
5571
7013
  }
5572
7014
  function maybeCaptureKiroSessionId(data) {
5573
7015
  if (!kiroSessionIdCaptureArmed || lastInitConfig?.cliId !== 'kiro-cli')
@@ -5581,7 +7023,7 @@ function maybeCaptureKiroSessionId(data) {
5581
7023
  kiroSessionIdCaptureBuffer = '';
5582
7024
  }
5583
7025
  function splitCodexAppControl(data) {
5584
- return appRunnerControlDecoder.push(data, APP_RUNNER_OSC_CLI_IDS.has(lastInitConfig?.cliId ?? ''), handleCodexAppMarker);
7026
+ return appRunnerControlDecoder.push(data, APP_RUNNER_OSC_CLI_IDS.has(lastInitConfig?.cliId ?? ''), handleAppRunnerOscMarker);
5585
7027
  }
5586
7028
  /** Riff runs `botmux send` in a remote filesystem, so its local binding
5587
7029
  * sidecar is invisible to the host daemon. The CLI includes the claimed root
@@ -6043,6 +7485,13 @@ function markPromptReady() {
6043
7485
  }
6044
7486
  if (isPromptReady)
6045
7487
  return; // guard against duplicate calls
7488
+ if (lastInitConfig?.cliId === 'codex-app' && !codexAppControlProven) {
7489
+ if (!codexAppUnprovenPromptDeferred) {
7490
+ log('Ignoring Codex App prompt until this worker verifies a fresh Ed25519 challenge response');
7491
+ }
7492
+ codexAppUnprovenPromptDeferred = true;
7493
+ return;
7494
+ }
6046
7495
  if (cliRestartInProgress && !replacementSpawnInProgress) {
6047
7496
  log('Ignoring prompt-ready from backend generation being replaced');
6048
7497
  return;
@@ -6069,6 +7518,7 @@ function markPromptReady() {
6069
7518
  return;
6070
7519
  }
6071
7520
  awaitingPostSessionStartPromptEvidence = false;
7521
+ clearPostHookEvidenceFallback();
6072
7522
  log('Fresh prompt evidence observed after SessionStart hooks');
6073
7523
  }
6074
7524
  if (isSettlingFirstFlush) {
@@ -6076,6 +7526,22 @@ function markPromptReady() {
6076
7526
  log('Idle detected during ready-gate settle; deferring prompt-ready until settle completes');
6077
7527
  return;
6078
7528
  }
7529
+ // Authenticated runners advance their explicit control queue on signed
7530
+ // completed/final records.
7531
+ // A prompt is authoritative only for the synthetic observation installed
7532
+ // after a verified warm reattach; clearing normal queued turns here could
7533
+ // lose follow-up inputs written by one flushPending() call.
7534
+ if (lastInitConfig?.cliId === 'codex-app' && !codexAppTurnLiveness.notePrompt()) {
7535
+ log('Ignoring transient Codex App prompt while an explicit runner turn remains queued');
7536
+ return;
7537
+ }
7538
+ if (lastInitConfig?.cliId === 'codex-app' && !codexAppReadyAuthority.canPublishPromptReady()) {
7539
+ if (!codexAppReadyAuthority.consumeLatePromptRecovery(!codexAppTurnLiveness.hasActiveTurn())) {
7540
+ log('Deferring Codex App terminal prompt until signed runner state confirms busy=false');
7541
+ return;
7542
+ }
7543
+ log('Accepted one late Codex App prompt after exact local submit cancellation');
7544
+ }
6079
7545
  const freshnessAction = freshnessInputQueue.onPromptReady();
6080
7546
  if (freshnessAction === 'reload') {
6081
7547
  log('Stale Codex App runner became idle; replacing it before releasing queued input');
@@ -6112,6 +7578,7 @@ function markPromptReady() {
6112
7578
  if (awaitingFirstPrompt) {
6113
7579
  awaitingFirstPrompt = false;
6114
7580
  awaitingPostSessionStartPromptEvidence = false;
7581
+ clearPostHookEvidenceFallback();
6115
7582
  renderer?.markNewTurn(); // exclude history replay from streaming card
6116
7583
  }
6117
7584
  send({ type: 'prompt_ready' });
@@ -6202,40 +7669,16 @@ function markPromptReady() {
6202
7669
  }
6203
7670
  }
6204
7671
  function persistCliSessionId(cliSessionId) {
6205
- if (!cliSessionId || !sessionId)
6206
- return;
6207
- if (lastInitConfig)
6208
- lastInitConfig.cliSessionId = cliSessionId;
6209
- send({
6210
- type: 'cli_session_id',
7672
+ const published = publishCliSessionIdToDaemon({
6211
7673
  cliSessionId,
7674
+ sessionId,
7675
+ initConfig: lastInitConfig,
6212
7676
  turnId: currentBotmuxTurnId,
6213
7677
  dispatchAttempt: currentBotmuxDispatchAttempt,
7678
+ send,
6214
7679
  });
6215
- try {
6216
- const session = sessionStore.getSession(sessionId);
6217
- if (!session)
6218
- return;
6219
- // One-shot native fork completed: the child now has its own CLI-native id
6220
- // (Claude/Codex minted it during --fork-session / codex fork). Clear the
6221
- // pending-fork marker so a later refork resumes THIS transcript instead of
6222
- // re-forking the parent's again. Done HERE (worker process, same write that
6223
- // sets cliSessionId) rather than only in the daemon's cli_session_id
6224
- // handler — the worker writes the sessions file directly, and if it reloaded
6225
- // the row from disk (pendingForkSession still true) its write would race and
6226
- // clobber the daemon-side clear.
6227
- const forkMarkerNeedsClear = session.pendingForkSession === true;
6228
- if (session.cliSessionId === cliSessionId && !forkMarkerNeedsClear)
6229
- return;
6230
- session.cliSessionId = cliSessionId;
6231
- if (forkMarkerNeedsClear)
6232
- session.pendingForkSession = undefined;
6233
- sessionStore.updateSession(session);
6234
- log(`Persisted CLI session id: ${cliSessionId}${forkMarkerNeedsClear ? ' (cleared pending-fork marker)' : ''}`);
6235
- }
6236
- catch (err) {
6237
- log(`Failed to persist CLI session id: ${err.message}`);
6238
- }
7680
+ if (published)
7681
+ log(`Published CLI session id for daemon persistence: ${cliSessionId}`);
6239
7682
  }
6240
7683
  function observeCursorCliSessionId(pid, label = 'spawn') {
6241
7684
  if (!Number.isInteger(pid) || pid <= 0)
@@ -6316,7 +7759,7 @@ function notifyAmbiguousSubmissionRecovery(recoveryReason, turnIdentity) {
6316
7759
  * full-directory scans every poll tick for a fingerprint that's permanently
6317
7760
  * dead, the 99% CPU bug this whole patch series is fixing. */
6318
7761
  function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId, failureReason, turnSeq = usageLimitTracker.currentTurn(), turnIdentity, durableTerminalStatus = 'failed') {
6319
- const preview = msg.length > 60 ? msg.slice(0, 60) + '…' : msg;
7762
+ const preview = buildSubmitMessagePreview(msg);
6320
7763
  const emitDurableTerminal = (errorCode) => {
6321
7764
  if (turnIdentity?.turnId && turnIdentity.dispatchAttempt !== undefined) {
6322
7765
  emitTurnTerminal(turnIdentity.turnId, durableTerminalStatus, errorCode, turnIdentity.dispatchAttempt);
@@ -6525,6 +7968,25 @@ async function detectBareShellLaunch() {
6525
7968
  * sends the next message (type-ahead) without waiting for idle detection.
6526
7969
  * Messages pushed during a flush are picked up by the while loop.
6527
7970
  */
7971
+ function requeueUnsubmittedQueuedActivation(item) {
7972
+ if (!item.queuedActivationToken)
7973
+ return;
7974
+ // backend.onExit already moved the same object into carryOver; spawnCli will
7975
+ // restore it after the fenced restart, so do not create a second owner here.
7976
+ if (!backend)
7977
+ return;
7978
+ inflightInputs.forget(item);
7979
+ if (!pendingMessages.some(candidate => candidate.queuedActivationToken === item.queuedActivationToken)) {
7980
+ pendingMessages.unshift(item);
7981
+ }
7982
+ log(`Retained queued activation ${item.queuedActivationToken.substring(0, 8)} for retry`);
7983
+ }
7984
+ function codexAppRuntimeTypeAheadReady() {
7985
+ return lastInitConfig?.cliId === 'codex-app'
7986
+ && codexAppControlProven
7987
+ && codexAppSignedStateObserved
7988
+ && codexAppInputReady;
7989
+ }
6528
7990
  async function flushPending() {
6529
7991
  // destroySession() may be asynchronous while `backend` still references the
6530
7992
  // old CLI. Never let a new flush (including one triggered by the old
@@ -6616,7 +8078,7 @@ async function flushPending() {
6616
8078
  // Codex on codex-cli 0.134.0.
6617
8079
  const claudeBridgeActive = !!bridgeJsonlPath && !lastInitConfig?.adoptMode;
6618
8080
  const codexBridgeActive = codexBridgeFallbackActive();
6619
- const typeAheadAllowed = pendingInputAllowsTypeAhead(cliAdapter.supportsTypeAhead === true, durableTurnInFlight, pendingMessages[0]);
8081
+ const typeAheadAllowed = pendingInputAllowsTypeAhead(cliAdapter.supportsTypeAhead === true || codexAppRuntimeTypeAheadReady(), durableTurnInFlight, pendingMessages[0]);
6620
8082
  // Native /rename is an administrative command, not a steer/queued model
6621
8083
  // message. It must wait for a real prompt even on type-ahead CLIs. Normal
6622
8084
  // pending messages can still drain while busy; the rename stays queued.
@@ -6635,6 +8097,7 @@ async function flushPending() {
6635
8097
  if (!isPromptReady && !typeAheadAllowed)
6636
8098
  return;
6637
8099
  isFlushing = true;
8100
+ const codexAppPromptReplay = new CodexAppFlushPromptReplay();
6638
8101
  if (isPromptReady) {
6639
8102
  isPromptReady = false;
6640
8103
  idleDetector?.reset();
@@ -6778,6 +8241,49 @@ async function flushPending() {
6778
8241
  log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
6779
8242
  }
6780
8243
  };
8244
+ // Unlike the transcript bridge, Codex App has an explicit app-server
8245
+ // runner. Start its liveness clock before the control line is written so
8246
+ // an accepted-but-never-dequeued input is diagnosed too; authenticated
8247
+ // activity records can arrive while writeInput awaits chunk delivery.
8248
+ // This bookkeeping runs before master's runAmbiguousSubmissionTransaction;
8249
+ // prepareNormalWrite (its beforeWrite) still rotates attribution atomically
8250
+ // with the fenced write, and it logs the "Writing …" line for both paths.
8251
+ const tracksCodexAppLiveness = lastInitConfig?.cliId === 'codex-app';
8252
+ const codexAppFrozenTurnId = tracksCodexAppLiveness
8253
+ ? item.turnId
8254
+ || item.codexAppInput?.clientUserMessageId
8255
+ || `codex-app-${sessionId || 'unknown'}-${Date.now().toString(36)}-${++codexAppFallbackTurnSequence}`
8256
+ : undefined;
8257
+ // Reserve attribution before the first chunk is written. The runner can
8258
+ // dequeue and finish a small turn while writeRunnerInput is still
8259
+ // awaiting later chunk throttles, and this flush may then write N+1.
8260
+ const codexAppDispatchReservation = codexAppFrozenTurnId
8261
+ ? codexAppTurnDispatchQueue.reserve(codexAppFrozenTurnId, item.dispatchAttempt, item.codexAppDispatchId, false, item.replyTurnId,
8262
+ // R4-B4: COPY the frozen steer authorization onto the reservation so a
8263
+ // superseded settlement can verify the exact head is a steerable turn.
8264
+ item.codexAppSteerable ? true : undefined)
8265
+ : undefined;
8266
+ if (tracksCodexAppLiveness && item.codexAppDispatchId && codexAppFrozenTurnId) {
8267
+ const prepared = await requestCodexAppDispatchTransition('submit', [{
8268
+ dispatchId: item.codexAppDispatchId,
8269
+ turnId: codexAppFrozenTurnId,
8270
+ ...(item.dispatchAttempt !== undefined
8271
+ ? { dispatchAttempt: item.dispatchAttempt }
8272
+ : {}),
8273
+ }]);
8274
+ if (!prepared) {
8275
+ if (codexAppDispatchReservation) {
8276
+ codexAppTurnDispatchQueue.cancelExact(codexAppDispatchReservation.handle);
8277
+ }
8278
+ failCodexAppControlGeneration('Daemon could not persist the Codex App prepared dispatch before runner write');
8279
+ break;
8280
+ }
8281
+ }
8282
+ const codexAppLivenessHandle = tracksCodexAppLiveness
8283
+ ? codexAppTurnLiveness.begin(codexAppFrozenTurnId)
8284
+ : undefined;
8285
+ if (tracksCodexAppLiveness)
8286
+ codexAppReadyAuthority.beginWork();
6781
8287
  // Defense in depth: TmuxPipeBackend's send methods no longer throw on a
6782
8288
  // dead pane (they fire onExit instead), but writeInput can still throw
6783
8289
  // for other reasons (fs errors while resolving the JSONL, a future
@@ -6805,7 +8311,7 @@ async function flushPending() {
6805
8311
  const targetBackend = backend;
6806
8312
  const targetAdapter = cliAdapter;
6807
8313
  submissionBackend = targetBackend;
6808
- const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeStructuredInput(adapterInputHandle(targetBackend), msg, item.codexAppInput, { turnId: item.turnId }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
8314
+ const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeStructuredInput(adapterInputHandle(targetBackend), msg, item.codexAppInput, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
6809
8315
  result = transaction.result;
6810
8316
  recoveryFailureReason = transaction.recoveryFailureReason;
6811
8317
  }
@@ -6813,7 +8319,7 @@ async function flushPending() {
6813
8319
  const targetBackend = backend;
6814
8320
  const targetAdapter = cliAdapter;
6815
8321
  submissionBackend = targetBackend;
6816
- const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeInput(adapterInputHandle(targetBackend), msg, { turnId: item.turnId }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
8322
+ const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeInput(adapterInputHandle(targetBackend), msg, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
6817
8323
  result = transaction.result;
6818
8324
  recoveryFailureReason = transaction.recoveryFailureReason;
6819
8325
  }
@@ -6838,12 +8344,51 @@ async function flushPending() {
6838
8344
  const blockedBeforeWrite = err instanceof SubmissionWriteError
6839
8345
  && !!err.recoveryFailureReason
6840
8346
  && !err.submissionStarted;
8347
+ // Roll back this turn's Codex App liveness slot regardless of the failure
8348
+ // shape; the slot was opened before the fenced write.
8349
+ codexAppPromptReplay.cancelSubmission(codexAppTurnLiveness, codexAppReadyAuthority, codexAppLivenessHandle);
6841
8350
  if (submissionPreparationFailed) {
6842
8351
  if (item.turnId) {
6843
8352
  emitTurnTerminal(item.turnId, 'failed', 'terminal_bridge_unavailable', item.dispatchAttempt);
6844
8353
  }
6845
8354
  break;
6846
8355
  }
8356
+ if (tracksCodexAppLiveness) {
8357
+ // A throwing framed write has unknown side effects: the runner may
8358
+ // hold a complete valid line without its newline. Never cancel and
8359
+ // continue in this generation. Ordinary IM ownership stays in the
8360
+ // daemon's prepared ledger for explicit crash recovery. Durable
8361
+ // ownership also stays intact: the worker-exit path atomically marks
8362
+ // its receipt ambiguous and arms the runtime fence before replay.
8363
+ // This supersedes master's blockedBeforeWrite/held-path for codex-app:
8364
+ // the dispatch ledger owns recovery, so any write failure (including a
8365
+ // provable pre-write block) fences the whole generation rather than
8366
+ // holding the item with a dangling reservation/prepared transition.
8367
+ log(`writeInput threw with unknown Codex App runner state: ${err?.message ?? err}`);
8368
+ failCodexAppControlGeneration('Codex App input write became ambiguous; fenced the runner before any successor could submit');
8369
+ break;
8370
+ }
8371
+ // Legacy/non-control adapters keep their existing submit-failure path.
8372
+ // A durable receiver attempt transfers replay ownership to the
8373
+ // receipt/lease reconciler on the ambiguous terminal below, so remove
8374
+ // any exact local reservation first to avoid two independent replayers.
8375
+ let dispatchStillPending = true;
8376
+ if (codexAppDispatchReservation) {
8377
+ if (item.codexAppDispatchId && durableWrite && codexAppFrozenTurnId) {
8378
+ const cancelled = codexAppTurnDispatchQueue.cancelExact(codexAppDispatchReservation.handle) && await requestCodexAppDispatchTransition('cancel', [{
8379
+ dispatchId: item.codexAppDispatchId,
8380
+ turnId: codexAppFrozenTurnId,
8381
+ dispatchAttempt: item.dispatchAttempt,
8382
+ }]);
8383
+ if (!cancelled) {
8384
+ failCodexAppControlGeneration('Could not transfer an ambiguous Codex App delivery to durable recovery');
8385
+ break;
8386
+ }
8387
+ }
8388
+ else if (!item.codexAppDispatchId) {
8389
+ dispatchStillPending = codexAppTurnDispatchQueue.cancelExact(codexAppDispatchReservation.handle);
8390
+ }
8391
+ }
6847
8392
  log(`writeInput threw: ${err?.message ?? err}`);
6848
8393
  if (blockedBeforeWrite && submissionBackend) {
6849
8394
  // This exact item is known not to have touched the PTY. Keep its
@@ -6857,10 +8402,12 @@ async function flushPending() {
6857
8402
  };
6858
8403
  log('Held definitely-unwritten input until ZMX recovery restart');
6859
8404
  }
6860
- else if (recoveryFailureReason) {
6861
- inflightInputs.retire(item);
8405
+ else {
8406
+ requeueUnsubmittedQueuedActivation(item);
8407
+ if (recoveryFailureReason)
8408
+ inflightInputs.retire(item);
6862
8409
  }
6863
- if (durableWrite && item.turnId && !recoveryFailureReason) {
8410
+ if (dispatchStillPending && durableWrite && item.turnId && !recoveryFailureReason) {
6864
8411
  // A throwing backend cannot prove whether bytes reached the CLI.
6865
8412
  // Reconcile as ambiguous (not a definitive failure) so the receiver
6866
8413
  // can replay the same frozen delivery behind the action gate.
@@ -6870,7 +8417,7 @@ async function flushPending() {
6870
8417
  // nulled `backend` and told the user the CLI exited) — nothing more to
6871
8418
  // do. Otherwise surface it as a submit failure so the message isn't
6872
8419
  // silently lost.
6873
- if (backend) {
8420
+ if (backend && dispatchStillPending) {
6874
8421
  if (recoveryFailureReason) {
6875
8422
  notifyAmbiguousSubmissionRecovery(recoveryFailureReason, item);
6876
8423
  }
@@ -6907,17 +8454,67 @@ async function flushPending() {
6907
8454
  // `&& backend`: if the CLI exited during this write (pane gone → onExit
6908
8455
  // nulled backend) the user already got a "CLI exited" notice; don't also
6909
8456
  // nag that the submit wasn't confirmed.
6910
- if (result && result.submitted === false && backend) {
6911
- if (recoveryFailureReason) {
6912
- inflightInputs.retire(item);
6913
- notifyAmbiguousSubmissionRecovery(recoveryFailureReason, item);
6914
- // The current item may have landed, so never requeue it. Preserve
6915
- // every later batch item untouched for an explicit fresh generation.
8457
+ if (result && result.submitted === false) {
8458
+ codexAppPromptReplay.cancelSubmission(codexAppTurnLiveness, codexAppReadyAuthority, codexAppLivenessHandle);
8459
+ const codexAppSafeNonSubmission = result.submissionDisposition === 'untouched'
8460
+ || result.submissionDisposition === 'flushed_invalid';
8461
+ if (tracksCodexAppLiveness && !codexAppSafeNonSubmission) {
8462
+ // final-Enter exhaustion, cleanup failure, and thrown/unknown writes
8463
+ // can leave a complete valid frame buffered. Retain an ordinary
8464
+ // prepared ledger entry and fence the entire generation. For a
8465
+ // durable attempt, worker exit owns the ambiguous receipt + runtime
8466
+ // fence transition; publishing an earlier terminal would open a
8467
+ // replay window before the old runner teardown is proven.
8468
+ failCodexAppControlGeneration('Codex App runner input buffer is not provably clean; fenced before any successor could submit');
6916
8469
  break;
6917
8470
  }
6918
- else {
8471
+ const dispatchStillPending = codexAppDispatchReservation
8472
+ ? codexAppTurnDispatchQueue.cancelExact(codexAppDispatchReservation.handle)
8473
+ : true;
8474
+ if (dispatchStillPending && item.codexAppDispatchId && codexAppFrozenTurnId) {
8475
+ const retryQueuedActivation = tracksCodexAppLiveness
8476
+ && !!item.queuedActivationToken
8477
+ && codexAppSafeNonSubmission;
8478
+ const transitioned = await requestCodexAppDispatchTransition(retryQueuedActivation ? 'retry' : 'cancel', [{
8479
+ dispatchId: item.codexAppDispatchId,
8480
+ turnId: codexAppFrozenTurnId,
8481
+ ...(item.dispatchAttempt !== undefined
8482
+ ? { dispatchAttempt: item.dispatchAttempt }
8483
+ : {}),
8484
+ }]);
8485
+ if (!transitioned) {
8486
+ failCodexAppControlGeneration(retryQueuedActivation
8487
+ ? 'Daemon could not return a safely untouched queued activation to accepted'
8488
+ : 'Daemon could not cancel a Codex App dispatch rejected before submission');
8489
+ break;
8490
+ }
8491
+ if (!retryQueuedActivation) {
8492
+ codexAppRecoveredDispatches = codexAppRecoveredDispatches.filter(entry => entry.dispatchId !== item.codexAppDispatchId);
8493
+ }
8494
+ }
8495
+ if (backend && dispatchStillPending) {
8496
+ // master's ZMX recovery: a sticky submission-journal failure means the
8497
+ // current item may actually have landed. Retire it and stop the batch
8498
+ // rather than requeue (which could double-submit).
8499
+ if (recoveryFailureReason) {
8500
+ inflightInputs.retire(item);
8501
+ notifyAmbiguousSubmissionRecovery(recoveryFailureReason, item);
8502
+ break;
8503
+ }
6919
8504
  scheduleSubmitFailureNotify(logicalMsg, result.recheck, '会话 JSONL', bridgeTurnId, result.failureReason, turnSeq, item, 'failed');
6920
8505
  }
8506
+ if (!recoveryFailureReason)
8507
+ requeueUnsubmittedQueuedActivation(item);
8508
+ }
8509
+ else if (item.queuedActivationToken) {
8510
+ // The daemon keeps the exact journal and route reservation until this
8511
+ // adapter-level boundary. IPC loss may replay at-least-once; an early
8512
+ // worker/daemon crash can never silently consume the opening turn.
8513
+ send({
8514
+ type: 'queued_activation_submitted',
8515
+ sessionId,
8516
+ activationToken: item.queuedActivationToken,
8517
+ });
6921
8518
  }
6922
8519
  // All structured bridges now drain every pending message in one flush:
6923
8520
  // Claude's BridgeTurnQueue handles `attachment(queued_command)` events
@@ -6934,18 +8531,35 @@ async function flushPending() {
6934
8531
  }
6935
8532
  finally {
6936
8533
  isFlushing = false;
8534
+ // A prompt can arrive after turn N completes but before turn N+1's chunked
8535
+ // control line finishes. We reject it while N+1 has a liveness slot. If that
8536
+ // write then fails, the prompt is still the runner's real ready boundary;
8537
+ // replay it after releasing the flush mutex so queued peers can drain.
8538
+ if (codexAppPromptReplay.consumeAfterFlush(codexAppTurnLiveness)) {
8539
+ log('Replaying deferred Codex App prompt after a queued submission was cancelled');
8540
+ markPromptReady();
8541
+ }
6937
8542
  }
6938
8543
  }
6939
8544
  function sendToPty(content, turnId, opts = {}) {
6940
8545
  const next = {
6941
8546
  content,
6942
8547
  turnId,
8548
+ ...(opts.replyTurnId ? { replyTurnId: opts.replyTurnId } : {}),
8549
+ ...(opts.codexAppDispatchId ? { codexAppDispatchId: opts.codexAppDispatchId } : {}),
8550
+ ...(opts.codexAppSteerable ? { codexAppSteerable: true } : {}),
8551
+ ...(opts.queuedActivationToken ? { queuedActivationToken: opts.queuedActivationToken } : {}),
6943
8552
  ...(opts.codexAppInput ? { codexAppInput: opts.codexAppInput } : {}),
6944
8553
  ...(opts.dispatchAttempt !== undefined ? { dispatchAttempt: opts.dispatchAttempt } : {}),
6945
8554
  ...(opts.vcMeetingImTurnOrigin
6946
8555
  ? { vcMeetingImTurnOrigin: opts.vcMeetingImTurnOrigin }
6947
8556
  : {}),
6948
8557
  };
8558
+ if (!initPromptMaterialized) {
8559
+ pendingMessages.push(next);
8560
+ log(`Queued message behind async init materialization (${pendingMessages.length} pending)`);
8561
+ return true;
8562
+ }
6949
8563
  // During an exact lease-fenced CLI restart the worker stays alive while the
6950
8564
  // backend is rebuilt. Preserve incoming attempt N+1 in the worker queue; the
6951
8565
  // old early-return silently dropped it after receiver had already persisted
@@ -6957,7 +8571,7 @@ function sendToPty(content, turnId, opts = {}) {
6957
8571
  }
6958
8572
  if (!cliAdapter)
6959
8573
  return false;
6960
- const supportsTypeAhead = pendingInputAllowsTypeAhead(cliAdapter.supportsTypeAhead === true, durableTurnInFlight, next);
8574
+ const supportsTypeAhead = pendingInputAllowsTypeAhead(cliAdapter.supportsTypeAhead === true || codexAppRuntimeTypeAheadReady(), durableTurnInFlight, next);
6961
8575
  const shouldMergeQueued = opts.dispatchAttempt === undefined && !durableTurnInFlight
6962
8576
  && !isFlushing && !shouldWriteNow({
6963
8577
  isPromptReady,
@@ -7045,6 +8659,9 @@ function startScreenUpdates() {
7045
8659
  if (awaitingFirstPrompt)
7046
8660
  return;
7047
8661
  let status = isPromptReady ? 'idle' : 'working';
8662
+ // master removed the AI ScreenAnalyzer; PR #597's Codex App no-progress
8663
+ // liveness projection still layers on top of the base idle/working verdict.
8664
+ status = codexAppLivenessStatus(status);
7048
8665
  void (async () => {
7049
8666
  let content = lastContent;
7050
8667
  let changed = false;
@@ -7464,6 +9081,7 @@ function scheduleBusyPatternIdleProbe(source) {
7464
9081
  busyPatternIdleProbeTimer.unref?.();
7465
9082
  }
7466
9083
  async function spawnCli(cfg, opts = {}) {
9084
+ const spawnGeneration = ++cliSpawnGeneration;
7467
9085
  clearSessionRenameInFlight();
7468
9086
  currentCliCredentialIsolated = false;
7469
9087
  // Enrollment writes the fixed marker before any device credential appears.
@@ -7902,6 +9520,45 @@ async function spawnCli(cfg, opts = {}) {
7902
9520
  currentCliCredentialIsolated = appliedIsolationCapabilities.includes('credential');
7903
9521
  const isolationRuntimeDataDir = process.env.SESSION_DATA_DIR
7904
9522
  ?? join(defaultBotmuxHome, 'data');
9523
+ // The unified Darwin sandbox enforces both read and write isolation. Keep
9524
+ // the legacy marker fields because a live persistent pane carries the
9525
+ // compiled Seatbelt policy in-process and may only be reattached when that
9526
+ // exact policy and authority channel still match.
9527
+ const willReadIsolate = process.platform === 'darwin' && sandboxRequested;
9528
+ const willWriteSandbox = process.platform === 'darwin' && sandboxRequested;
9529
+ const canonicalPolicyPath = (path) => {
9530
+ if (!path)
9531
+ return '';
9532
+ try {
9533
+ return realpathSync(path);
9534
+ }
9535
+ catch {
9536
+ return path;
9537
+ }
9538
+ };
9539
+ const darwinIsolationPolicyDigest = process.platform === 'darwin'
9540
+ ? isolationPanePolicyDigest({
9541
+ readIsolation: willReadIsolate,
9542
+ writeSandbox: willWriteSandbox,
9543
+ readDenyExtraPaths: [
9544
+ ...(cfg.readDenyExtraPaths ?? []),
9545
+ ...(cfg.sandboxPaths?.deny ?? []),
9546
+ ].map(canonicalPolicyPath),
9547
+ readOnlyExtraPaths: (cfg.sandboxPaths?.readOnly ?? []).map(canonicalPolicyPath),
9548
+ readWriteExtraPaths: (cfg.sandboxPaths?.readWrite ?? []).map(canonicalPolicyPath),
9549
+ writeAllowExtraPaths: process.env.TMPDIR
9550
+ ? [canonicalPolicyPath(process.env.TMPDIR)]
9551
+ : [],
9552
+ workingDir: canonicalPolicyPath(cfg.workingDir),
9553
+ homeDir: canonicalPolicyPath(homedir()),
9554
+ osUserHomeDir: canonicalPolicyPath(userInfo().homedir),
9555
+ botmuxHome: canonicalPolicyPath(dirname(isolationRuntimeDataDir)),
9556
+ sessionDataDir: canonicalPolicyPath(isolationRuntimeDataDir),
9557
+ currentAppId: cfg.larkAppId,
9558
+ cliId: cfg.cliId,
9559
+ resolvedBin: canonicalPolicyPath(cliAdapter.resolvedBin),
9560
+ })
9561
+ : undefined;
7905
9562
  let mcpRuntimeManifest = readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir);
7906
9563
  const hasMcpRuntimeEntries = !!cliAdapter.mcpGateway && !!mcpRuntimeManifest?.entries.length;
7907
9564
  const reuseRecordedHerdrTarget = !sandboxRequested && !hasMcpRuntimeEntries;
@@ -8031,6 +9688,7 @@ async function spawnCli(cfg, opts = {}) {
8031
9688
  // so the probe below sees no pane and we cold-spawn fresh isolated. A pane from
8032
9689
  // this lifetime (suspend→resume) keeps its marker → reattaches normally (it is
8033
9690
  // still the isolated process). This lets isolated bots use tmux/zellij/herdr.
9691
+ let persistentPaneOriginChannelId;
8034
9692
  if (appliedIsolationCapabilities.length > 0 && persistentSessionName && effectiveBackendType !== 'pty') {
8035
9693
  const persistentTarget = selectedBackend.persistentBackendTarget;
8036
9694
  // ZMX ownership is verified against the frozen PID, not just the name — a
@@ -8055,9 +9713,30 @@ async function spawnCli(cfg, opts = {}) {
8055
9713
  }
8056
9714
  const paneLive = paneProbe === 'exists';
8057
9715
  if (paneLive) {
8058
- let marker = null;
8059
- marker = readRegularHostFileNoFollow(join(isolationRuntimeDataDir, 'read-isolation', `${cfg.sessionId}.boot`));
8060
- if (isolatedPaneReattachSafe(marker, appliedIsolationCapabilities)) {
9716
+ const markerPath = join(isolationRuntimeDataDir, 'read-isolation', `${cfg.sessionId}.boot`);
9717
+ const marker = readManagedOriginAuthorityFile(markerPath);
9718
+ const darwinPolicyExpected = process.platform === 'darwin'
9719
+ && (willReadIsolate || willWriteSandbox);
9720
+ // A stamped pane must match even when the new policy is OFF. Otherwise a
9721
+ // disable followed by restart could reattach the still-confined process
9722
+ // without rebuilding its authority/profile. An unsafe planted marker
9723
+ // leaf is treated as stamped/unknown by the no-follow existence check.
9724
+ const policyMatches = appliedIsolationCapabilities.length > 0
9725
+ ? isolatedPaneReattachSafe(marker, {
9726
+ requiredCapabilities: appliedIsolationCapabilities,
9727
+ exactCapabilities: true,
9728
+ ...(darwinPolicyExpected ? {
9729
+ readIsolation: willReadIsolate,
9730
+ writeSandbox: willWriteSandbox,
9731
+ requireOriginChannel: true,
9732
+ policyDigest: darwinIsolationPolicyDigest,
9733
+ } : {}),
9734
+ })
9735
+ : marker === null && !hostEntryExistsNoFollow(markerPath);
9736
+ if (policyMatches) {
9737
+ if (darwinPolicyExpected) {
9738
+ persistentPaneOriginChannelId = isolatedPaneOriginChannel(marker);
9739
+ }
8061
9740
  // Pane was spawned under the current isolation policy → still confined
8062
9741
  // on the running process across daemon restarts; warm reattach preserves
8063
9742
  // resume/context + tmux idle-suspend.
@@ -8116,6 +9795,12 @@ async function spawnCli(cfg, opts = {}) {
8116
9795
  }
8117
9796
  }
8118
9797
  }
9798
+ readIsolationOriginChannelId = willReadIsolate
9799
+ ? (persistentPaneOriginChannelId ?? randomBytes(32).toString('hex'))
9800
+ : null;
9801
+ if (readIsolationOriginChannelId) {
9802
+ persistentPaneOriginChannelId = readIsolationOriginChannelId;
9803
+ }
8119
9804
  let willReattachPersistent = selectedBackend.isReattach === true;
8120
9805
  if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length && persistentSessionName && effectiveBackendType !== 'pty') {
8121
9806
  const persistentTarget = selectedBackend.persistentBackendTarget;
@@ -8179,6 +9864,40 @@ async function spawnCli(cfg, opts = {}) {
8179
9864
  willReattachPersistent = selectedBackend.isReattach === true;
8180
9865
  }
8181
9866
  }
9867
+ // A pane created before asymmetric control framing has no persisted public
9868
+ // identity capable of answering this worker's fresh challenge. Never fall
9869
+ // back to terminal OSC trust: terminate it and cold-spawn a signed runner.
9870
+ if (persistentSessionName && shouldColdStartCodexAppReattach({
9871
+ cliId: cfg.cliId,
9872
+ backendType: effectiveBackendType,
9873
+ isReattach: willReattachPersistent,
9874
+ persistedState: readPersistedCodexAppControlState(cfg),
9875
+ })) {
9876
+ log(`Codex App persistent pane ${persistentSessionName} has no valid public control identity — killing + cold-spawning authenticated runner`);
9877
+ try {
9878
+ killPersistentSession(effectiveBackendType, persistentSessionName);
9879
+ }
9880
+ catch (err) {
9881
+ throw new Error(`Refusing unauthenticated Codex App reattach: could not kill stale pane (${err?.message ?? err})`);
9882
+ }
9883
+ willReattachPersistent = false;
9884
+ }
9885
+ // The worker establishes trust before any runner output can be parsed. A
9886
+ // fresh runner gets a new capability; a persistent reattach reloads the
9887
+ // capability created by that same runner generation.
9888
+ // The control endpoint is an authenticated lifecycle prerequisite. Await
9889
+ // bind + protected locator publication before backend.spawn so
9890
+ // a runner can never observe a not-yet-owned or stale endpoint.
9891
+ try {
9892
+ await prepareCodexAppControlGeneration(cfg, willReattachPersistent, !!persistentSessionName);
9893
+ }
9894
+ catch (err) {
9895
+ if (spawnGeneration !== cliSpawnGeneration)
9896
+ throw new CliSpawnSupersededError();
9897
+ throw err;
9898
+ }
9899
+ if (spawnGeneration !== cliSpawnGeneration)
9900
+ throw new CliSpawnSupersededError();
8182
9901
  const replacementExpectedFresh = codexRunnerFreshness === 'restarting_fresh';
8183
9902
  const freshness = decideCodexRunnerFreshness({
8184
9903
  cliId: cfg.cliId,
@@ -8208,6 +9927,8 @@ async function spawnCli(cfg, opts = {}) {
8208
9927
  ? readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir)
8209
9928
  : await prepareCliPluginGenerationAndGateway(cfg, cliAdapter);
8210
9929
  }
9930
+ if (spawnGeneration !== cliSpawnGeneration)
9931
+ throw new CliSpawnSupersededError();
8211
9932
  // Re-arm the startup-commands one-shot ONLY for a genuinely fresh CLI process.
8212
9933
  // A reattach to a LIVE persistent pane (daemon-restart recovery) is the SAME
8213
9934
  // CLI with /effort etc. already applied — replaying would re-type them (and
@@ -8374,6 +10095,7 @@ async function spawnCli(cfg, opts = {}) {
8374
10095
  passesInitialPromptViaArgs: cliAdapter.passesInitialPromptViaArgs === true,
8375
10096
  adoptMode: cfg.adoptMode === true,
8376
10097
  dispatchAttempt: cfg.dispatchAttempt,
10098
+ queuedActivationToken: cfg.queuedActivationToken,
8377
10099
  }) || (effectiveResume && cliAdapter.initialPromptArgsIgnoredOnResume === true)
8378
10100
  || (!promptArgPreparationChanged && shouldDeferInitialPromptForArgLimit({
8379
10101
  passesInitialPromptViaArgs: cliAdapter.passesInitialPromptViaArgs === true,
@@ -8552,6 +10274,9 @@ async function spawnCli(cfg, opts = {}) {
8552
10274
  delete childEnv[MCP_GATEWAY_SOCKET_ENV];
8553
10275
  delete childEnv[MCP_GATEWAY_REQUIRED_ENV];
8554
10276
  }
10277
+ // Never inherit an ambient/stale bootstrap path from the daemon's own launch
10278
+ // environment. The raw capability is never placed in any environment.
10279
+ delete childEnv[CODEX_APP_CONTROL_BOOTSTRAP_ENV];
8555
10280
  // Put the daemon-written wrapper dir (~/.botmux/bin/botmux = THIS build) ahead of any
8556
10281
  // stale npm-global botmux in PATH, so the agent's `botmux` is always this build. Matters
8557
10282
  // most under read isolation: only this build has the send-cred reader — a shadowing stale
@@ -8674,6 +10399,13 @@ async function spawnCli(cfg, opts = {}) {
8674
10399
  else
8675
10400
  childEnv.CODEX_HOME = canonicalizeForSandbox(isolatedCodexHome);
8676
10401
  }
10402
+ if (willReadIsolate) {
10403
+ if (!readIsolationOriginChannelId) {
10404
+ throw new Error('[read-isolation] origin channel is unavailable');
10405
+ }
10406
+ childEnv.BOTMUX_READ_ISOLATED = '1';
10407
+ childEnv.BOTMUX_ORIGIN_CHANNEL_ID = readIsolationOriginChannelId;
10408
+ }
8677
10409
  // Per-bot env (bots.json `env`): extra vars for THIS bot's CLI only — e.g.
8678
10410
  // ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN to run a bot on GLM/a 3rd-party
8679
10411
  // provider, an HTTPS_PROXY, or a CLI feature flag. Passed as injectEnv (NOT
@@ -8872,15 +10604,39 @@ async function spawnCli(cfg, opts = {}) {
8872
10604
  if (process.platform === 'linux') {
8873
10605
  mandatoryDenyPaths.push(join(canonical(dataDir), 'sandboxes', cfg.sessionId));
8874
10606
  }
10607
+ if (process.platform === 'darwin') {
10608
+ const osUserHomeDir = userInfo().homedir;
10609
+ if (!osUserHomeDir) {
10610
+ throw new Error('[read-isolation] OS account home is unavailable; refusing to create sentinel');
10611
+ }
10612
+ ensureManagedOriginIsolationSentinel(osUserHomeDir);
10613
+ ensureManagedOriginRootLocator(osUserHomeDir, cfg.sessionId, dataDir);
10614
+ const canonicalSessionDataDir = realpathSync(dataDir);
10615
+ ensureManagedOriginDataRootProbe(canonicalSessionDataDir, cfg.sessionId);
10616
+ const legacyProbe = managedOriginLegacyIsolationProbeAccess(osUserHomeDir);
10617
+ const fixedProbe = managedOriginIsolationSentinelAccess(osUserHomeDir);
10618
+ const dataRootProbe = managedOriginDataRootProbeAccess(canonicalSessionDataDir, cfg.sessionId);
10619
+ if (legacyProbe !== 'host_accessible' && fixedProbe !== 'host_accessible') {
10620
+ throw new Error('[read-isolation] kernel isolation probes are unavailable or unsafe');
10621
+ }
10622
+ if (dataRootProbe !== 'host_accessible') {
10623
+ throw new Error('[read-isolation] locator-selected data-root probe is unavailable or unsafe');
10624
+ }
10625
+ ensureManagedOriginAttestationDirectory(dataDir, cfg.sessionId, readIsolationOriginChannelId);
10626
+ sweepManagedOriginAttestationProofs(dataDir, cfg.sessionId, readIsolationOriginChannelId);
10627
+ }
8875
10628
  readIsolationOriginCapabilityFile = process.platform === 'darwin'
8876
- ? managedOriginCapabilityPath(dataDir, cfg.sessionId)
10629
+ ? managedOriginCapabilityPath(dataDir, cfg.sessionId, readIsolationOriginChannelId)
8877
10630
  : null;
8878
10631
  // The macOS child reads the per-session rotating capability directly.
8879
10632
  // Materialize it before the policy's existence filter, and make the exact
8880
10633
  // file a mandatory read-only carve-out that user rules cannot shadow.
8881
10634
  if (readIsolationOriginCapabilityFile) {
10635
+ ensureManagedOriginCapabilityLeafSafe(readIsolationOriginCapabilityFile);
8882
10636
  publishSandboxRelayCapability({ failClosed: true });
8883
10637
  mandatoryReadOnlyPaths.push(readIsolationOriginCapabilityFile);
10638
+ mandatoryReadOnlyPaths.push(managedOriginAttestationDirectory(dataDir, cfg.sessionId, readIsolationOriginChannelId));
10639
+ mandatoryReadOnlyPaths.push(managedOriginRootLocatorPath(userInfo().homedir, cfg.sessionId));
8884
10640
  }
8885
10641
  if (credentialBoundaryActive) {
8886
10642
  const credentialRules = buildCredentialIsolationRules({
@@ -9164,7 +10920,14 @@ async function spawnCli(cfg, opts = {}) {
9164
10920
  try {
9165
10921
  const markerDir = join(isolationRuntimeDataDir, 'read-isolation');
9166
10922
  mkdirSync(markerDir, { recursive: true });
9167
- writeFileSync(join(markerDir, `${cfg.sessionId}.boot`), isolationPaneMarkerContent(cfg.daemonBootId ?? '', appliedIsolationCapabilities), { mode: 0o600 });
10923
+ replaceManagedOriginCapabilityFile(join(markerDir, `${cfg.sessionId}.boot`), isolationPaneMarkerContent(cfg.daemonBootId ?? '', appliedIsolationCapabilities, willReadIsolate || willWriteSandbox
10924
+ ? {
10925
+ originChannelId: persistentPaneOriginChannelId,
10926
+ readIsolation: willReadIsolate,
10927
+ writeSandbox: willWriteSandbox,
10928
+ policyDigest: darwinIsolationPolicyDigest,
10929
+ }
10930
+ : undefined));
9168
10931
  }
9169
10932
  catch { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ }
9170
10933
  }
@@ -9391,14 +11154,55 @@ async function spawnCli(cfg, opts = {}) {
9391
11154
  capturedSpawnCommand = null;
9392
11155
  log(`Failed to capture reproduce command: ${err?.message ?? err}`);
9393
11156
  }
9394
- backend.spawn(spawnBin, spawnArgs, {
9395
- cwd: spawnCwd,
9396
- cols: PTY_COLS,
9397
- rows: PTY_ROWS,
9398
- env: childEnv,
9399
- injectEnv: perBotInjectKeys.length ? perBotInjectEnv : undefined,
9400
- launchShell: lastInitConfig?.launchShell,
9401
- });
11157
+ // Create the asymmetric candidate as late as possible. Persistent backends
11158
+ // receive it even on a predicted reattach: an actual reuse ignores the
11159
+ // launch env and proves the old key, while an actual fresh start consumes
11160
+ // the candidate and proves the new key. The worker trusts cryptographic
11161
+ // proof, not a backend's prediction flag.
11162
+ try {
11163
+ if (spawnGeneration !== cliSpawnGeneration)
11164
+ throw new CliSpawnSupersededError();
11165
+ prepareFreshCodexAppControlBootstrap(cfg, !!persistentSessionName);
11166
+ if (codexAppControlBootstrapPathForSpawn) {
11167
+ childEnv[CODEX_APP_CONTROL_BOOTSTRAP_ENV] = codexAppControlBootstrapPathForSpawn;
11168
+ }
11169
+ backend.spawn(spawnBin, spawnArgs, {
11170
+ cwd: spawnCwd,
11171
+ cols: PTY_COLS,
11172
+ rows: PTY_ROWS,
11173
+ env: childEnv,
11174
+ injectEnv: perBotInjectKeys.length ? perBotInjectEnv : undefined,
11175
+ launchShell: lastInitConfig?.launchShell,
11176
+ });
11177
+ }
11178
+ catch (err) {
11179
+ cleanupCodexAppControlBootstrap();
11180
+ throw err;
11181
+ }
11182
+ finally {
11183
+ delete childEnv[CODEX_APP_CONTROL_BOOTSTRAP_ENV];
11184
+ }
11185
+ const actuallyReattachedPersistent = 'isReattach' in backend
11186
+ && backend.isReattach === true;
11187
+ try {
11188
+ finalizeCodexAppControlGeneration(cfg, actuallyReattachedPersistent, !!persistentSessionName);
11189
+ }
11190
+ catch (err) {
11191
+ cleanupCodexAppControlBootstrap();
11192
+ // A runner whose generation cannot be authenticated durably must not keep
11193
+ // executing behind an apparently failed worker. This covers both a
11194
+ // fresh-persist failure or a generation that cannot complete challenge
11195
+ // setup.
11196
+ if (persistentSessionName) {
11197
+ try {
11198
+ killPersistentSession(effectiveBackendType, persistentSessionName);
11199
+ }
11200
+ catch (killErr) {
11201
+ log(`Failed to kill unauthenticated Codex App persistent generation: ${killErr?.message ?? killErr}`);
11202
+ }
11203
+ }
11204
+ throw err;
11205
+ }
9402
11206
  if (selectedBackend.createdHerdrSessionName) {
9403
11207
  send({
9404
11208
  type: 'user_notify',
@@ -9487,6 +11291,7 @@ async function spawnCli(cfg, opts = {}) {
9487
11291
  log(`TRAE sandbox: resolved real traex leaf pid ${realPid} under bwrap supervisor ${launcherPid}; rewiring ownership pid`);
9488
11292
  backend.cliPid = realPid;
9489
11293
  backend.cliCwd = cfg.workingDir;
11294
+ codexAdoptPendingPid = realPid;
9490
11295
  publishLocalProcessAttestation(realPid);
9491
11296
  },
9492
11297
  schedule: (fn, ms) => { setTimeout(fn, ms); },
@@ -9517,6 +11322,8 @@ async function spawnCli(cfg, opts = {}) {
9517
11322
  const wiredPid = cfg.cliId === 'traex' ? resolveTraexOwnershipPid(cliPid, outerBwrapActive) : cliPid;
9518
11323
  backend.cliPid = wiredPid;
9519
11324
  backend.cliCwd = cfg.workingDir;
11325
+ if (cfg.cliId === 'traex')
11326
+ codexAdoptPendingPid = wiredPid;
9520
11327
  if (cfg.cliId === 'traex' && outerBwrapActive)
9521
11328
  startTraexSandboxPidResolve(cliPid);
9522
11329
  }
@@ -9551,6 +11358,8 @@ async function spawnCli(cfg, opts = {}) {
9551
11358
  const wiredPid = cfg.cliId === 'traex' ? resolveTraexOwnershipPid(pid, outerBwrapActive) : pid;
9552
11359
  backend.cliPid = wiredPid;
9553
11360
  backend.cliCwd = cfg.workingDir;
11361
+ if (cfg.cliId === 'traex')
11362
+ codexAdoptPendingPid = wiredPid;
9554
11363
  if (cfg.cliId === 'traex' && outerBwrapActive)
9555
11364
  startTraexSandboxPidResolve(pid);
9556
11365
  }
@@ -9706,6 +11515,7 @@ async function spawnCli(cfg, opts = {}) {
9706
11515
  promptReadyDetectedDuringSettle = false;
9707
11516
  readyPatternSeenDuringHold = false;
9708
11517
  awaitingPostSessionStartPromptEvidence = false;
11518
+ clearPostHookEvidenceFallback();
9709
11519
  // Reset quiescence baseline so the settle measures silence from THIS spawn.
9710
11520
  lastPtyOutputAtMs = Date.now();
9711
11521
  const readyHookAvailable = effectiveReadyHookInstall
@@ -9719,7 +11529,7 @@ async function spawnCli(cfg, opts = {}) {
9719
11529
  const readyPortAvailable = !isolatedReadyTransportRequired
9720
11530
  || parseDaemonIpcPort(childEnv.BOTMUX_DAEMON_IPC_PORT) !== undefined;
9721
11531
  const readyCapabilityAvailable = !isolatedReadyTransportRequired
9722
- || hasMatchingManagedOriginCapability(process.env.SESSION_DATA_DIR ?? '', cfg.sessionId, sandboxRelayCapability?.token, sandboxRelayOutbox ?? undefined);
11532
+ || hasMatchingManagedOriginCapability(process.env.SESSION_DATA_DIR ?? '', cfg.sessionId, sandboxRelayCapability?.token, sandboxRelayOutbox ?? undefined, readIsolationOriginChannelId ?? undefined);
9723
11533
  const readySignalAvailable = readyHookAvailable && readyPortAvailable && readyCapabilityAvailable;
9724
11534
  const freshReadyGateCandidate = cliAdapter.injectsReadyHook === true
9725
11535
  && cfg.adoptMode !== true
@@ -9842,6 +11652,8 @@ async function spawnCli(cfg, opts = {}) {
9842
11652
  // prompt-ready — without this hook a follow-up arriving mid-task would sit
9843
11653
  // in pendingMessages forever once the task finishes.
9844
11654
  backend.onTaskDone?.(() => {
11655
+ if (fatalWorkerErrorPending)
11656
+ return;
9845
11657
  // Generation fence (matches the onAgentStatus above and the onExit below):
9846
11658
  // a stale RiffBackend's `fetchAndEmitOutput(...).finally(taskDoneCb)` can
9847
11659
  // resolve AFTER destroySession()/kill() during a restart (neither clears
@@ -9896,6 +11708,30 @@ async function spawnCli(cfg, opts = {}) {
9896
11708
  // token or explicit IM origin must not remain usable in that interval.
9897
11709
  completeManagedTurnOriginRevocation(sandboxRelayCapability, exitedTurnId, exitedDispatchAttempt);
9898
11710
  log(`${cliName()} exited (code: ${code}, signal: ${signal})`);
11711
+ if (lastInitConfig?.cliId === 'codex-app' && codexAppControlFatal) {
11712
+ // An earlier control-path failure already made this whole worker
11713
+ // generation terminal and scheduled its hard OS exit. That failure may
11714
+ // synchronously kill the backend, invoking this callback after
11715
+ // stopCodexAppControlChannel() cleared the local FIFO. Never reinterpret
11716
+ // that cleared snapshot as safe ownership: publishing ambiguous or
11717
+ // claude_exit here would let the daemon admit N+1 before the Node-worker
11718
+ // exit arms the durable recovery fence.
11719
+ log('Suppressed Codex App backend-exit signals for a fatal worker generation');
11720
+ return;
11721
+ }
11722
+ const codexAppPreparedAtExit = !intentionalRestart
11723
+ && lastInitConfig?.cliId === 'codex-app'
11724
+ && (codexAppTurnDispatchQueue.size() > 0
11725
+ || codexAppRecoveredDispatches.some(entry => entry.state === 'prepared'));
11726
+ if (codexAppPreparedAtExit) {
11727
+ // Do not publish a standalone ambiguous terminal or claude_exit first.
11728
+ // Either signal can let the durable receiver admit N+1 before the later
11729
+ // Node-worker exit arms its persistent-pane fence. Exit this worker
11730
+ // generation directly; daemon onWorkerExit performs the ambiguous
11731
+ // transition and recovery-arm as one ordering boundary.
11732
+ failCodexAppControlGeneration('Codex App runner exited with a prepared dispatch; worker replacement requires exact recovery');
11733
+ return;
11734
+ }
9899
11735
  if (cliAdapter?.reliableTurnTerminal === true
9900
11736
  && exitedTurnId
9901
11737
  && exitedDispatchAttempt !== undefined) {
@@ -9919,6 +11755,22 @@ async function spawnCli(cfg, opts = {}) {
9919
11755
  rpcDialogDismissTimer = null;
9920
11756
  }
9921
11757
  }
11758
+ cleanupCodexAppControlBootstrap();
11759
+ // A natural Codex App runner exit is only the CLI-generation terminal,
11760
+ // not proof that any prepared frame was unconsumed. Keep the exact local
11761
+ // FIFO/recovery snapshot until the daemon answers claude_exit with its
11762
+ // cli_crash decision. The restart handler then sees prepared ownership and
11763
+ // exits this Node worker too, which is what drives the daemon's
11764
+ // onWorkerExit durable-receipt fence. Intentional in-worker teardown has
11765
+ // already established its own replay/cancel boundary and may clear here.
11766
+ stopCodexAppControlChannel({
11767
+ preserveDispatchRecovery: lastInitConfig?.cliId === 'codex-app' && !intentionalRestart,
11768
+ });
11769
+ codexAppControlStateValue = undefined;
11770
+ codexAppControlProven = false;
11771
+ codexAppTurnLiveness.clear();
11772
+ codexAppReadyAuthority.reset();
11773
+ codexAppCompletionAwaitingFinal = false;
9922
11774
  const logTail = recentTerminalLogTail();
9923
11775
  // Don't park a diagnostic shell here: most exits are immediately
9924
11776
  // auto-restarted by the daemon, so an inline park would just be torn down
@@ -9937,10 +11789,30 @@ async function spawnCli(cfg, opts = {}) {
9937
11789
  // onCliExit tells the daemon to mark that receipt ambiguous; replaying the
9938
11790
  // same attempt locally as ordinary carry-over would race hub attempt N+1
9939
11791
  // and execute the prompt twice.
9940
- const stashed = inflightInputs.onCliExit(item => item.dispatchAttempt === undefined);
11792
+ // At-most-once (idempotency lease) inputs are ALSO excluded — but PER ITEM,
11793
+ // never per session. The daemon terminalizes the keyed turn to
11794
+ // dispatch_unknown on this exit, so replaying THAT input on the auto-restarted
11795
+ // CLI would run a turn the caller already saw failed (codex #776 round-7
11796
+ // finding #1). It must NOT, however, drop a later PLAIN (no-key) turn folded
11797
+ // into the same http_async_ session via target.sessionId — the API allows that
11798
+ // resume, so a whole-session flag would strand a legitimate follow-up input
11799
+ // (codex #776 round-8). The keyed input is tagged item.noReplay at enqueue;
11800
+ // only it is excluded, from BOTH the inflight carry-over and the still-queued
11801
+ // pendingMessages.
11802
+ const stashed = inflightInputs.onCliExit(item => item.dispatchAttempt === undefined && !item.noReplay);
9941
11803
  if (stashed > 0) {
9942
11804
  log(`CLI exited with ${stashed} in-flight message(s); will re-queue after restart`);
9943
11805
  }
11806
+ const droppedPending = pendingMessages.filter(m => m.noReplay).length;
11807
+ if (droppedPending > 0) {
11808
+ // Remove ONLY the no-replay items still queued (never written); keep any
11809
+ // ordinary follow-up input for the restart's normal flush.
11810
+ for (let i = pendingMessages.length - 1; i >= 0; i--) {
11811
+ if (pendingMessages[i].noReplay)
11812
+ pendingMessages.splice(i, 1);
11813
+ }
11814
+ log(`Dropped ${droppedPending} at-most-once pending message(s) on CLI exit (no replay)`);
11815
+ }
9944
11816
  backend = null;
9945
11817
  isPromptReady = false;
9946
11818
  currentBotmuxTurnId = undefined;
@@ -9963,8 +11835,15 @@ async function spawnCli(cfg, opts = {}) {
9963
11835
  send({ type: 'claude_exit', code, signal, logTail, canParkDiagnostic, turnId: exitedTurnId, dispatchAttempt: exitedDispatchAttempt });
9964
11836
  }
9965
11837
  });
9966
- if (isPipeMode && backend && 'isReattach' in backend && backend.isReattach) {
11838
+ const isPersistentBackendReattach = actuallyReattachedPersistent;
11839
+ // Codex App warm observation is armed only after the old generation proves
11840
+ // its private key over this worker's fresh socket challenge. Backend flags
11841
+ // remain useful for screen seeding but are not authentication evidence.
11842
+ if (isPipeMode && backend && isPersistentBackendReattach) {
9967
11843
  log(`Re-attached to existing ${effectiveBackendType} session via pipe backend: ${persistentSessionName}`);
11844
+ // Pipe backends expose an authoritative snapshot + busy-pattern probe.
11845
+ // Keep those operations pipe-only; the liveness slot above is backend-
11846
+ // independent and Zellij receives its screen through normal PTY output.
9968
11847
  seedBackendScreen(`${effectiveBackendType} reattach`, backend);
9969
11848
  scheduleReattachIdleProbe(`${effectiveBackendType} reattach`, backend);
9970
11849
  }
@@ -9993,6 +11872,7 @@ async function spawnCli(cfg, opts = {}) {
9993
11872
  }
9994
11873
  awaitingFirstPrompt = false;
9995
11874
  awaitingPostSessionStartPromptEvidence = false;
11875
+ clearPostHookEvidenceFallback();
9996
11876
  renderer?.markNewTurn();
9997
11877
  log(forced
9998
11878
  ? `WARN First prompt hard timeout — ${cliName()} readyPattern did not arrive; forcing queued message flush`
@@ -10036,10 +11916,21 @@ async function spawnCli(cfg, opts = {}) {
10036
11916
  }
10037
11917
  }
10038
11918
  function killCli(opts = {}) {
11919
+ // Invalidate an in-flight async spawn before any other teardown hook runs. The
11920
+ // remaining cleanup is synchronous today, but generation ownership must not
11921
+ // depend on that implementation detail.
11922
+ cliSpawnGeneration++;
10039
11923
  currentCliCredentialIsolated = false;
10040
11924
  stopNativeSessionTitleSync();
10041
11925
  stopSessionMcpGatewayHost();
10042
11926
  stopCodexRpcEngine();
11927
+ cleanupCodexAppControlBootstrap();
11928
+ stopCodexAppControlChannel();
11929
+ codexAppControlStateValue = undefined;
11930
+ codexAppControlProven = false;
11931
+ codexAppTurnLiveness.clear();
11932
+ codexAppReadyAuthority.reset();
11933
+ codexAppCompletionAwaitingFinal = false;
10043
11934
  if (!opts.preservePending)
10044
11935
  cleanupPiInitialPromptFiles();
10045
11936
  destroyCrashDiagnosticTerminal('killCli');
@@ -10060,6 +11951,7 @@ function killCli(opts = {}) {
10060
11951
  promptReadyDetectedDuringSettle = false;
10061
11952
  readyPatternSeenDuringHold = false;
10062
11953
  awaitingPostSessionStartPromptEvidence = false;
11954
+ clearPostHookEvidenceFallback();
10063
11955
  stopStuckDetector();
10064
11956
  tuiPromptBlocking = false;
10065
11957
  stopScreenUpdates();
@@ -10092,6 +11984,7 @@ function killCli(opts = {}) {
10092
11984
  }
10093
11985
  sandboxRelayOutbox = null;
10094
11986
  readIsolationOriginCapabilityFile = null;
11987
+ readIsolationOriginChannelId = null;
10095
11988
  currentBotmuxTurnId = undefined;
10096
11989
  currentBotmuxDispatchAttempt = undefined;
10097
11990
  currentVcMeetingImTurnOrigin = undefined;
@@ -10173,12 +12066,6 @@ async function restartCliProcess(reason, opts = {}) {
10173
12066
  const restartingBackend = backend;
10174
12067
  if (restartingBackend)
10175
12068
  intentionalRestartBackend = restartingBackend;
10176
- // Riff teardown cancels a remote task asynchronously. Wait for the cancel
10177
- // (bounded) before respawning, otherwise the old and replacement tasks can
10178
- // overlap and both emit into the same Lark thread. Local backends normally
10179
- // return void here and continue synchronously. A synchronous throw is
10180
- // handled by the same fatal restart path below, so it cannot leave the
10181
- // input gate permanently armed.
10182
12069
  const teardown = restartingBackend?.destroySession?.();
10183
12070
  if (teardown && typeof teardown.then === 'function') {
10184
12071
  try {
@@ -10219,6 +12106,8 @@ async function restartCliProcess(reason, opts = {}) {
10219
12106
  catch (err) {
10220
12107
  replacementSpawnInProgress = false;
10221
12108
  cliRestartInProgress = false;
12109
+ if (err instanceof CliSpawnSupersededError)
12110
+ return;
10222
12111
  await sendFatalWorkerErrorAndExit(err);
10223
12112
  return;
10224
12113
  }
@@ -11592,11 +13481,23 @@ function sendAndFlush(msg) {
11592
13481
  resolve();
11593
13482
  return;
11594
13483
  }
13484
+ let settled = false;
13485
+ const finish = () => {
13486
+ if (settled)
13487
+ return;
13488
+ settled = true;
13489
+ clearTimeout(timer);
13490
+ resolve();
13491
+ };
13492
+ // IPC callbacks are best-effort transport evidence, not an exit gate. A
13493
+ // parent that has stopped draining (or a runtime callback edge) must not
13494
+ // keep a fail-closed worker and its stale ownership alive indefinitely.
13495
+ const timer = setTimeout(finish, 1_000);
11595
13496
  try {
11596
- process.send(payload, () => resolve());
13497
+ process.send(payload, finish);
11597
13498
  }
11598
13499
  catch {
11599
- resolve();
13500
+ finish();
11600
13501
  }
11601
13502
  });
11602
13503
  }
@@ -11626,7 +13527,7 @@ let fatalWorkerErrorPending = false;
11626
13527
  * used both during init and after a previously-ready worker tries to recover
11627
13528
  * from a stopped/crashed CLI, so those later paths cannot regress to a silent
11628
13529
  * unhandled rejection/exception. */
11629
- async function sendFatalWorkerErrorAndExit(err, turnId = currentBotmuxTurnId, dispatchAttempt = currentBotmuxDispatchAttempt) {
13530
+ async function sendFatalWorkerErrorAndExit(err, turnId = currentBotmuxTurnId, dispatchAttempt = currentBotmuxDispatchAttempt, opts = {}) {
11630
13531
  if (fatalWorkerErrorPending)
11631
13532
  return;
11632
13533
  fatalWorkerErrorPending = true;
@@ -11647,6 +13548,28 @@ async function sendFatalWorkerErrorAndExit(err, turnId = currentBotmuxTurnId, di
11647
13548
  // failure to the receipt/lease recovery path instead of replying out-of-band.
11648
13549
  ...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
11649
13550
  });
13551
+ log('Fatal worker error delivered; exiting process');
13552
+ if (opts.hardExit) {
13553
+ // A fail-closed Codex App generation must produce a real OS-level worker
13554
+ // exit so the daemon can arm its worker-generation receipt fence. On some
13555
+ // runtimes process.exit() can hang joining a native/libuv fs worker (the
13556
+ // control/terminal stack uses long-lived reads). Perform the synchronous
13557
+ // cleanup we control, then use an uncatchable self-signal for this one
13558
+ // replacement path. Persistent-session and SIGKILL residue both have
13559
+ // daemon/next-worker reconcilers as additional backstops.
13560
+ try {
13561
+ teardownSandboxBestEffort();
13562
+ }
13563
+ catch { /* best effort */ }
13564
+ try {
13565
+ cleanup();
13566
+ }
13567
+ catch { /* best effort */ }
13568
+ try {
13569
+ process.kill(process.pid, 'SIGKILL');
13570
+ }
13571
+ catch { /* fall through */ }
13572
+ }
11650
13573
  process.exit(1);
11651
13574
  }
11652
13575
  function log(msg) {
@@ -11717,6 +13640,27 @@ process.on('message', async (raw) => {
11717
13640
  if (msg.larkAppId && process.env.BOTMUX_WORKFLOW !== '1') {
11718
13641
  sessionStore.init(msg.larkAppId);
11719
13642
  }
13643
+ if (msg.cliId === 'codex-app') {
13644
+ codexAppRecoveredDispatches = (msg.codexAppRecoveredDispatches ?? []).map(entry => ({
13645
+ ...entry,
13646
+ codexAppInput: entry.codexAppInput ? { ...entry.codexAppInput } : undefined,
13647
+ }));
13648
+ codexAppGenerationCommits = [...(msg.codexAppGenerationCommits ?? [])];
13649
+ codexAppTurnDispatchQueue.restore(codexAppRecoveredDispatches
13650
+ .filter(entry => entry.state === 'prepared')
13651
+ .map(entry => ({
13652
+ dispatchId: entry.dispatchId,
13653
+ turnId: entry.turnId,
13654
+ replyTurnId: entry.replyTurnId,
13655
+ dispatchAttempt: entry.dispatchAttempt,
13656
+ // R5-B4-1: COPY the frozen steer authorization onto the restored
13657
+ // prepared reservation. Without it a replacement worker's exact-head
13658
+ // reservation is steerable=false while the daemon ledger head is
13659
+ // true, so a legitimate superseded settlement would be rejected and
13660
+ // the crash-replay chain wedges.
13661
+ ...(entry.codexAppSteerable ? { codexAppSteerable: true } : {}),
13662
+ })));
13663
+ }
11720
13664
  // Capture credentials for direct image upload from worker
11721
13665
  larkAppIdForUpload = msg.larkAppId;
11722
13666
  larkAppSecretForUpload = msg.larkAppSecret;
@@ -11843,6 +13787,25 @@ process.on('message', async (raw) => {
11843
13787
  // execution — exactly-once, Codex P1-1). Ambiguous never reaches here.
11844
13788
  // - RPC RESUME: queuePrompt=true → queue for post-ready flush (bridge
11845
13789
  // marked at flush time, P0-1).
13790
+ const recoveredAcceptedEntries = codexAppRecoveredDispatches
13791
+ .filter(entry => entry.state === 'accepted');
13792
+ const recoveredAcceptedInputs = recoveredAcceptedEntries
13793
+ .map(entry => ({
13794
+ content: entry.content,
13795
+ turnId: entry.turnId,
13796
+ replyTurnId: entry.replyTurnId,
13797
+ dispatchAttempt: entry.dispatchAttempt,
13798
+ codexAppDispatchId: entry.dispatchId,
13799
+ // COPY the frozen steer authorization from the ledger entry; never
13800
+ // recompute it on recovery (codex decision A).
13801
+ ...(entry.codexAppSteerable ? { codexAppSteerable: true } : {}),
13802
+ queuedActivationToken: entry.queuedActivationToken
13803
+ ?? (recoveredAcceptedEntries.length === 1
13804
+ ? msg.queuedActivationToken
13805
+ : undefined),
13806
+ codexAppInput: entry.codexAppInput,
13807
+ vcMeetingImTurnOrigin: entry.vcMeetingImTurnOrigin,
13808
+ }));
11846
13809
  let initialInputCommitted = false;
11847
13810
  if (shouldQueueInitialPrompt({
11848
13811
  hasPrompt: !!msg.prompt,
@@ -11854,18 +13817,38 @@ process.on('message', async (raw) => {
11854
13817
  // Follow-up IPC handlers can run while this async init is awaiting
11855
13818
  // backend startup. They queue safely behind !backend above, but the
11856
13819
  // original init prompt must remain first in logical turn order.
11857
- pendingMessages.unshift({
13820
+ // #597: accepted entries that never crossed the old worker's
13821
+ // prepared/write boundary are replayable payload, not runner
13822
+ // attribution — queue them BEFORE this fork's new prompt while prepared
13823
+ // predecessors stay preloaded in the exact final FIFO above.
13824
+ pendingMessages.unshift(...recoveredAcceptedInputs, {
11858
13825
  content: lastSpawnQueuedInitialPrompt ?? msg.prompt,
11859
13826
  ...(lastSpawnQueuedInitialPromptLogicalContent
11860
13827
  ? { logicalContent: lastSpawnQueuedInitialPromptLogicalContent }
11861
13828
  : {}),
11862
13829
  turnId: msg.turnId,
13830
+ replyTurnId: msg.replyTurnId,
11863
13831
  dispatchAttempt: msg.dispatchAttempt,
13832
+ codexAppDispatchId: msg.codexAppDispatchId,
13833
+ queuedActivationToken: msg.queuedActivationToken,
11864
13834
  vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
11865
13835
  codexAppInput: msg.promptCodexAppInput,
13836
+ // Thread the root's steer authorization so ordered pre-final steer can
13837
+ // fold follow-ups into THIS turn (the runner's canSteer requires the
13838
+ // group root — accepted[0] — to be steerable). Without this the first
13839
+ // turn of a codex-app session could never absorb a follow-up steer.
13840
+ ...(msg.codexAppSteerable ? { codexAppSteerable: true } : {}),
13841
+ // At-most-once (idempotency lease): tag the KEYED init prompt so a CLI
13842
+ // exit never replays it onto the auto-restarted CLI — while leaving a
13843
+ // later plain follow-up turn on the same http_async_ session intact
13844
+ // (codex #776 round-8: per-item, not per-session).
13845
+ ...(msg.atMostOnce ? { noReplay: true } : {}),
11866
13846
  });
11867
13847
  initialInputCommitted = true;
11868
13848
  }
13849
+ else if (msg.cliId === 'codex-app') {
13850
+ pendingMessages.unshift(...recoveredAcceptedInputs);
13851
+ }
11869
13852
  else if (msg.prompt) {
11870
13853
  // A successful spawn with a non-queued prompt means the adapter baked
11871
13854
  // it into argv or the RPC engine already accepted it.
@@ -11877,6 +13860,7 @@ process.on('message', async (raw) => {
11877
13860
  initialInputOwnershipPending = false;
11878
13861
  if (initialInputCommitted)
11879
13862
  acknowledgeTurnInputCommitted(msg.turnId);
13863
+ initPromptMaterialized = true;
11880
13864
  // A backend may become prompt-ready before spawnCli() returns. The
11881
13865
  // initial prompt is queued only afterwards, so the earlier
11882
13866
  // markPromptReady() necessarily flushed an empty queue. This is normal
@@ -11901,12 +13885,26 @@ process.on('message', async (raw) => {
11901
13885
  });
11902
13886
  }
11903
13887
  catch (err) {
13888
+ if (err instanceof CliSpawnSupersededError)
13889
+ return;
11904
13890
  initialInputOwnershipPending = false;
11905
13891
  await sendFatalWorkerErrorAndExit(err);
11906
13892
  return;
11907
13893
  }
11908
13894
  break;
11909
13895
  }
13896
+ case 'codex_app_dispatch_persisted': {
13897
+ const pending = codexAppPendingDaemonAcks.get(msg.requestId);
13898
+ if (!pending)
13899
+ break;
13900
+ codexAppPendingDaemonAcks.delete(msg.requestId);
13901
+ clearTimeout(pending.timer);
13902
+ pending.resolve(msg.ok);
13903
+ if (!msg.ok) {
13904
+ log(`Daemon rejected Codex App dispatch persistence: ${msg.error ?? 'unknown error'}`);
13905
+ }
13906
+ break;
13907
+ }
11910
13908
  case 'message': {
11911
13909
  const messageAdoptMode = lastInitConfig?.adoptMode === true;
11912
13910
  const ordinaryImTurnId = !messageAdoptMode
@@ -11980,6 +13978,8 @@ process.on('message', async (raw) => {
11980
13978
  // Pass the message's own attempt (not the stale currentBotmux* from a
11981
13979
  // prior IM turn) so a durable delivery relaunch failure carries the
11982
13980
  // right attribution for the daemon's receipt/lease gate.
13981
+ if (err instanceof CliSpawnSupersededError)
13982
+ return;
11983
13983
  await sendFatalWorkerErrorAndExit(err, msg.turnId, msg.dispatchAttempt);
11984
13984
  return;
11985
13985
  }
@@ -12163,6 +14163,10 @@ process.on('message', async (raw) => {
12163
14163
  const inputCommitted = sendToPty(content, msg.turnId, {
12164
14164
  codexAppInput,
12165
14165
  dispatchAttempt: msg.dispatchAttempt,
14166
+ codexAppDispatchId: msg.codexAppDispatchId,
14167
+ ...(msg.codexAppSteerable ? { codexAppSteerable: true } : {}),
14168
+ queuedActivationToken: msg.queuedActivationToken,
14169
+ replyTurnId: msg.replyTurnId,
12166
14170
  vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
12167
14171
  });
12168
14172
  if (inputCommitted)
@@ -12228,6 +14232,10 @@ process.on('message', async (raw) => {
12228
14232
  break;
12229
14233
  }
12230
14234
  case 'restart': {
14235
+ if (effectiveBackendType === 'riff') {
14236
+ log('Refused Riff generation restart; the existing lineage-owning worker is retained');
14237
+ break;
14238
+ }
12231
14239
  // 角色切换的 cwd-move respawn:respawn 用 {...lastInitConfig, resume:true},
12232
14240
  // 先收敛 workingDir 才能让 CLI 在新目录重启(新 cwd 的 CLAUDE.md/记忆索引
12233
14241
  // 开场注入)。旧桶 transcript 由 resume 预检的 syncClaudeResumeTargetToCwd
@@ -12308,6 +14316,10 @@ process.on('message', async (raw) => {
12308
14316
  log('Refused durable expiry ACK for adopt-mode session');
12309
14317
  break;
12310
14318
  }
14319
+ if (!await retireCodexAppDispatchForDurableReplay(msg.turnId, msg.dispatchAttempt)) {
14320
+ log('Withholding durable expiry ACK because the daemon dispatch ledger could not retire exactly');
14321
+ break;
14322
+ }
12311
14323
  log(`Expiring active durable turn=${msg.turnId.slice(0, 12)} attempt=${msg.dispatchAttempt}; `
12312
14324
  + 'restarting CLI with queued follow-ups preserved');
12313
14325
  durableTurnInFlight = false;
@@ -12324,30 +14336,34 @@ process.on('message', async (raw) => {
12324
14336
  // ordinary IM turn. Remove that exact queued generation and ACK without
12325
14337
  // restarting the unrelated active turn; otherwise attempt N survives in
12326
14338
  // the queue and executes after the hub has already replayed N+1.
12327
- let removedPending = 0;
12328
- for (let i = pendingMessages.length - 1; i >= 0; i--) {
12329
- const item = pendingMessages[i];
12330
- if (item.turnId === msg.turnId && item.dispatchAttempt === msg.dispatchAttempt) {
12331
- pendingMessages.splice(i, 1);
12332
- removedPending++;
14339
+ const removedPending = pendingMessages.filter(item => item.turnId === msg.turnId
14340
+ && item.dispatchAttempt === msg.dispatchAttempt).length;
14341
+ if (removedPending > 0
14342
+ && ambiguousSubmissionRecoveryHold?.backend === backend
14343
+ && !lastInitConfig?.adoptMode) {
14344
+ // A recovery-held item at the queue head blocked this durable
14345
+ // attempt from ever reaching the PTY. It may be this exact delivery
14346
+ // or an ordinary IM immediately ahead of it; either way, hub replay
14347
+ // must not hit the same poisoned generation. Fence it now, preserve
14348
+ // every other queued item, then admit the next attempt to the fresh
14349
+ // session. (Recovery-hold is only ever set for non-codex-app backends,
14350
+ // so there is no dispatch-ledger entry to retire here — remove the
14351
+ // expired items directly before the preservePending restart.)
14352
+ for (let i = pendingMessages.length - 1; i >= 0; i--) {
14353
+ const item = pendingMessages[i];
14354
+ if (item.turnId === msg.turnId && item.dispatchAttempt === msg.dispatchAttempt) {
14355
+ pendingMessages.splice(i, 1);
14356
+ }
12333
14357
  }
14358
+ ambiguousSubmissionRecoveryHold = null;
14359
+ log(`Expiring recovery-held durable input turn=${msg.turnId.slice(0, 12)} `
14360
+ + `attempt=${msg.dispatchAttempt}; fencing poisoned backend`);
14361
+ await restartCliProcess('ZMX recovery hold blocked durable lease', { immediate: true, preservePending: true });
14362
+ acknowledge('cli_fenced');
14363
+ break;
12334
14364
  }
12335
- if (removedPending > 0) {
12336
- if (ambiguousSubmissionRecoveryHold?.backend === backend
12337
- && !lastInitConfig?.adoptMode) {
12338
- // A recovery-held item at the queue head blocked this durable
12339
- // attempt from ever reaching the PTY. It may be this exact delivery
12340
- // or an ordinary IM immediately ahead of it; either way, hub replay
12341
- // must not hit the same poisoned generation. Fence it now, preserve
12342
- // every other queued item, then admit the next attempt to the fresh
12343
- // session.
12344
- ambiguousSubmissionRecoveryHold = null;
12345
- log(`Expiring recovery-held durable input turn=${msg.turnId.slice(0, 12)} `
12346
- + `attempt=${msg.dispatchAttempt}; fencing poisoned backend`);
12347
- await restartCliProcess('ZMX recovery hold blocked durable lease', { immediate: true, preservePending: true });
12348
- acknowledge('cli_fenced');
12349
- break;
12350
- }
14365
+ if (removedPending > 0
14366
+ && await retireCodexAppDispatchForDurableReplay(msg.turnId, msg.dispatchAttempt)) {
12351
14367
  log(`Expired ${removedPending} queued durable input(s) turn=${msg.turnId.slice(0, 12)} `
12352
14368
  + `attempt=${msg.dispatchAttempt}`);
12353
14369
  acknowledge('queued_removed');
@@ -12372,6 +14388,10 @@ process.on('message', async (raw) => {
12372
14388
  }
12373
14389
  log(`Boot recovery fencing ambiguous receiver turn=${msg.turnId.slice(0, 12)} `
12374
14390
  + `attempt=${msg.dispatchAttempt}`);
14391
+ if (!await retireCodexAppDispatchForDurableReplay(msg.turnId, msg.dispatchAttempt)) {
14392
+ log('Withholding receiver reset ACK because the daemon dispatch ledger could not retire exactly');
14393
+ break;
14394
+ }
12375
14395
  durableTurnInFlight = false;
12376
14396
  inflightInputs.onTurnComplete();
12377
14397
  await restartCliProcess('ambiguous receiver boot recovery', { immediate: true, preservePending: true });
@@ -12522,6 +14542,16 @@ process.on('message', async (raw) => {
12522
14542
  isPromptReady,
12523
14543
  alreadyWaiting: awaitingPostSessionStartPromptEvidence,
12524
14544
  });
14545
+ // 「接受屏幕已有提示符」兜底只对全新会话(source=startup)arm:新建会话在
14546
+ // hook 跑完前就画好提示符、之后不再重绘,fence 必须靠兜底才解。resume/clear/
14547
+ // compact 会在边界后自行重绘一个新 ❯(transcript 重放/重印),fence 靠真证据
14548
+ // ~2s 自解;给它们 arm 反而会让回放中残留的历史 ❯ 满足静默门控、提前接受边界
14549
+ // 前的旧提示符,破坏 resume 本就依赖的 fresh-evidence fence。未识别的 source
14550
+ // 按非 startup 处理(fail-safe 不 arm,退回既有 15s 首提示符超时,无新回归)。
14551
+ const armPostHookFallback = shouldArmPostHookPromptEvidenceFallback({
14552
+ waitingForPostHookPrompt: waitForPostHookPrompt,
14553
+ source: msg.source,
14554
+ });
12525
14555
  if (waitForPostHookPrompt) {
12526
14556
  awaitingPostSessionStartPromptEvidence = true;
12527
14557
  promptReadyDetectedDuringSettle = false;
@@ -12529,6 +14559,8 @@ process.on('message', async (raw) => {
12529
14559
  idleDetector?.resetReadyEvidence();
12530
14560
  lastPtyOutputAtMs = Date.now();
12531
14561
  log('SessionStart boundary recorded — waiting for fresh post-hook prompt evidence');
14562
+ if (armPostHookFallback)
14563
+ armPostHookPromptEvidenceFallback();
12532
14564
  }
12533
14565
  // 先记下 gate 是否已被 45s fallback 释放:ReadyGate.receive() 是一次性
12534
14566
  // 语义,fallback 抢先后 releaseReadyGate 会整块跳过迟到的真信号。
@@ -12579,21 +14611,82 @@ process.on('message', async (raw) => {
12579
14611
  }
12580
14612
  case 'close': {
12581
14613
  log('Close requested');
14614
+ // destroySession kills tmux session permanently; kill() only detaches.
14615
+ // riff 的 destroySession 是异步远端取消——必须有界 await:紧跟着的
14616
+ // process.exit 会掐断未发出的 fetch,让已关闭话题的远端 agent 继续跑。
14617
+ if (effectiveBackendType === 'riff') {
14618
+ if (!msg.requestId) {
14619
+ log('Refused unsafe request-less Riff close; explicit close requires prepare/commit');
14620
+ break;
14621
+ }
14622
+ let result;
14623
+ let attemptedPrepare = false;
14624
+ if (shutdownDetachRequestId) {
14625
+ result = {
14626
+ ok: false,
14627
+ error: `shutdown detach already ${shutdownDetachPhase ?? 'active'} as ${shutdownDetachRequestId}`,
14628
+ };
14629
+ }
14630
+ else if (preparedCloseRequestId) {
14631
+ result = {
14632
+ ok: false,
14633
+ error: `close already prepared as ${preparedCloseRequestId}`,
14634
+ };
14635
+ }
14636
+ else if (closeRequestInFlightId) {
14637
+ result = {
14638
+ ok: false,
14639
+ error: `close prepare already in flight as ${closeRequestInFlightId}`,
14640
+ };
14641
+ }
14642
+ else {
14643
+ attemptedPrepare = true;
14644
+ lastAbortedCloseRequestId = null;
14645
+ closeRequestInFlightId = msg.requestId;
14646
+ try {
14647
+ const raw = await backend?.destroySession?.();
14648
+ result = raw && typeof raw === 'object' && 'ok' in raw
14649
+ ? raw
14650
+ : { ok: true };
14651
+ }
14652
+ catch (err) {
14653
+ result = { ok: false, error: err instanceof Error ? err.message : String(err) };
14654
+ }
14655
+ }
14656
+ if (result.ok) {
14657
+ preparedCloseRequestId = msg.requestId;
14658
+ }
14659
+ else if (attemptedPrepare) {
14660
+ await backend?.abortDestroySession?.();
14661
+ lastAbortedCloseRequestId = msg.requestId;
14662
+ }
14663
+ if (closeRequestInFlightId === msg.requestId)
14664
+ closeRequestInFlightId = null;
14665
+ send({
14666
+ type: 'close_result',
14667
+ requestId: msg.requestId,
14668
+ ok: result.ok,
14669
+ ...(result.taskId ? { taskId: result.taskId } : {}),
14670
+ ...(result.error ? { error: result.error } : {}),
14671
+ });
14672
+ if (!result.ok) {
14673
+ log(`Riff close prepare failed (${result.error ?? 'cancel failed'}); session stays active for retry`);
14674
+ }
14675
+ break;
14676
+ }
12582
14677
  closeRequested = true;
12583
14678
  send({ type: 'session_close_ready', sessionId });
12584
14679
  stopScreenshotLoop();
12585
14680
  stopBridgeWatcher();
12586
14681
  stopCodexBridge();
12587
- // destroySession kills tmux session permanently; kill() only detaches.
12588
- // riff destroySession 是异步远端取消——必须有界 await:紧跟着的
12589
- // process.exit 会掐断未发出的 fetch,让已关闭话题的远端 agent 继续跑。
14682
+ // Local close: destroySession kills persistent owned sessions. Riff has
14683
+ // already been handled above and cannot enter this request-less path.
12590
14684
  const closeTeardown = backend?.destroySession?.();
12591
14685
  if (closeTeardown && typeof closeTeardown.then === 'function') {
12592
14686
  try {
12593
- // 预算层级见 RiffBackend.destroySession(总 deadline 20s)——这里 22s 只作兜底。
12594
14687
  await Promise.race([closeTeardown, new Promise((r) => setTimeout(r, 22_000))]);
12595
14688
  }
12596
- catch { /* logged inside destroySession */ }
14689
+ catch { /* logged by backend */ }
12597
14690
  }
12598
14691
  killCli();
12599
14692
  // Bridge marker file outlives a single CLI process (we keep it across
@@ -12625,7 +14718,192 @@ process.on('message', async (raw) => {
12625
14718
  // We still request a clean exit; the daemon SIGKILL is the hard backstop.
12626
14719
  process.exit(0);
12627
14720
  }
14721
+ case 'riff_shutdown_prepare': {
14722
+ if (effectiveBackendType !== 'riff') {
14723
+ send({
14724
+ type: 'riff_shutdown_result',
14725
+ requestId: msg.requestId,
14726
+ phase: 'prepare',
14727
+ ok: false,
14728
+ taskId: null,
14729
+ error: 'not_riff_backend',
14730
+ });
14731
+ break;
14732
+ }
14733
+ let result;
14734
+ const inputBlocker = riffWorkerShutdownInputBlocker({
14735
+ initPromptMaterialized,
14736
+ isFlushing,
14737
+ pendingMessages: pendingMessages.length,
14738
+ pendingRawInputs: pendingRawInputs.length,
14739
+ pendingSessionRename: pendingSessionRename !== null,
14740
+ sessionRenameInFlight,
14741
+ commandLineWritesPending,
14742
+ });
14743
+ if (preparedCloseRequestId || closeRequestInFlightId) {
14744
+ result = { ok: false, taskId: null, error: 'explicit_close_in_progress' };
14745
+ }
14746
+ else if (shutdownDetachRequestId) {
14747
+ result = {
14748
+ ok: false,
14749
+ taskId: null,
14750
+ error: `shutdown detach already ${shutdownDetachPhase ?? 'active'} as ${shutdownDetachRequestId}`,
14751
+ };
14752
+ }
14753
+ else if (inputBlocker) {
14754
+ result = {
14755
+ ok: false,
14756
+ taskId: null,
14757
+ error: `worker_inputs_not_drained:${inputBlocker}`,
14758
+ };
14759
+ }
14760
+ else {
14761
+ shutdownDetachRequestId = msg.requestId;
14762
+ shutdownDetachPhase = 'preparing';
14763
+ try {
14764
+ result = await backend?.prepareShutdownDetach?.()
14765
+ ?? { ok: false, taskId: null, error: 'shutdown_detach_unsupported' };
14766
+ }
14767
+ catch (err) {
14768
+ result = {
14769
+ ok: false,
14770
+ taskId: null,
14771
+ error: err instanceof Error ? err.message : String(err),
14772
+ };
14773
+ }
14774
+ if (shutdownDetachRequestId === msg.requestId && result.ok) {
14775
+ shutdownDetachPhase = 'prepared';
14776
+ }
14777
+ }
14778
+ send({
14779
+ type: 'riff_shutdown_result',
14780
+ requestId: msg.requestId,
14781
+ phase: 'prepare',
14782
+ ok: result.ok,
14783
+ taskId: result.taskId,
14784
+ ...(result.error ? { error: result.error } : {}),
14785
+ });
14786
+ break;
14787
+ }
14788
+ case 'riff_shutdown_commit': {
14789
+ if (effectiveBackendType !== 'riff'
14790
+ || shutdownDetachRequestId !== msg.requestId
14791
+ || shutdownDetachPhase !== 'prepared') {
14792
+ log(`Ignoring stale Riff shutdown commit ${msg.requestId}`);
14793
+ break;
14794
+ }
14795
+ log(`Riff shutdown detach committed (${msg.requestId})`);
14796
+ shutdownDetachRequestId = null;
14797
+ shutdownDetachPhase = null;
14798
+ backend?.commitShutdownDetach?.();
14799
+ intentionalRestartBackend = backend;
14800
+ stopScreenshotLoop();
14801
+ killCli();
14802
+ cleanup();
14803
+ process.exit(0);
14804
+ }
14805
+ case 'riff_shutdown_abort': {
14806
+ if (shutdownDetachRequestId !== msg.requestId) {
14807
+ log(`Ignoring stale Riff shutdown abort ${msg.requestId}`);
14808
+ send({
14809
+ type: 'riff_shutdown_result',
14810
+ requestId: msg.requestId,
14811
+ phase: 'abort',
14812
+ ok: false,
14813
+ taskId: null,
14814
+ error: 'shutdown_detach_not_active',
14815
+ });
14816
+ break;
14817
+ }
14818
+ log(`Riff shutdown detach aborted (${msg.requestId})`);
14819
+ let result;
14820
+ try {
14821
+ result = await backend?.abortShutdownDetach?.()
14822
+ ?? { ok: false, taskId: null, error: 'shutdown_abort_unsupported' };
14823
+ }
14824
+ catch (err) {
14825
+ result = {
14826
+ ok: false,
14827
+ taskId: null,
14828
+ error: err instanceof Error ? err.message : String(err),
14829
+ };
14830
+ }
14831
+ if (result.ok && shutdownDetachRequestId === msg.requestId) {
14832
+ shutdownDetachRequestId = null;
14833
+ shutdownDetachPhase = null;
14834
+ }
14835
+ send({
14836
+ type: 'riff_shutdown_result',
14837
+ requestId: msg.requestId,
14838
+ phase: 'abort',
14839
+ ok: result.ok,
14840
+ taskId: result.taskId,
14841
+ ...(result.error ? { error: result.error } : {}),
14842
+ });
14843
+ break;
14844
+ }
14845
+ case 'close_commit': {
14846
+ if (!preparedCloseRequestId || preparedCloseRequestId !== msg.requestId) {
14847
+ log(`Ignoring stale close_commit ${msg.requestId}`);
14848
+ break;
14849
+ }
14850
+ log(`Close committed (${msg.requestId})`);
14851
+ preparedCloseRequestId = null;
14852
+ closeRequestInFlightId = null;
14853
+ lastAbortedCloseRequestId = null;
14854
+ backend?.commitDestroySession?.();
14855
+ closeRequested = true;
14856
+ send({ type: 'session_close_ready', sessionId });
14857
+ stopScreenshotLoop();
14858
+ stopBridgeWatcher();
14859
+ stopCodexBridge();
14860
+ killCli();
14861
+ clearSendMarkers();
14862
+ cleanup();
14863
+ process.exit(0);
14864
+ }
14865
+ case 'close_abort': {
14866
+ const alreadyRestored = lastAbortedCloseRequestId === msg.requestId;
14867
+ if (!alreadyRestored
14868
+ && preparedCloseRequestId !== msg.requestId
14869
+ && closeRequestInFlightId !== msg.requestId) {
14870
+ log(`Ignoring stale close_abort ${msg.requestId}`);
14871
+ send({
14872
+ type: 'close_abort_result',
14873
+ requestId: msg.requestId,
14874
+ ok: false,
14875
+ error: 'close_abort_not_active',
14876
+ });
14877
+ break;
14878
+ }
14879
+ log(`Close aborted (${msg.requestId}); Riff admission restored`);
14880
+ let abortError = null;
14881
+ if (!alreadyRestored) {
14882
+ try {
14883
+ await backend?.abortDestroySession?.();
14884
+ }
14885
+ catch (err) {
14886
+ abortError = err instanceof Error ? err.message : String(err);
14887
+ }
14888
+ }
14889
+ if (!abortError) {
14890
+ preparedCloseRequestId = null;
14891
+ closeRequestInFlightId = null;
14892
+ lastAbortedCloseRequestId = null;
14893
+ }
14894
+ send({
14895
+ type: 'close_abort_result',
14896
+ requestId: msg.requestId,
14897
+ ok: abortError === null,
14898
+ ...(abortError ? { error: abortError } : {}),
14899
+ });
14900
+ break;
14901
+ }
12628
14902
  case 'suspend': {
14903
+ if (effectiveBackendType === 'riff') {
14904
+ log('Refused unsafe Riff suspend; explicit close requires prepare/commit');
14905
+ break;
14906
+ }
12629
14907
  log('Suspend requested');
12630
14908
  stopScreenshotLoop();
12631
14909
  stopBridgeWatcher();
@@ -12644,12 +14922,7 @@ process.on('message', async (raw) => {
12644
14922
  // uses to recover sessions after a reboot kills the tmux server).
12645
14923
  revokeManagedTurnOriginForRestart();
12646
14924
  try {
12647
- // riff:suspend 语义是「休眠待续」——绝不能 cancel 远端任务(血缘已持久化,
12648
- // 恢复时 follow-up 续上);只断流 detach。
12649
- if (effectiveBackendType === 'riff')
12650
- backend?.kill();
12651
- else
12652
- (backend?.destroySession ?? backend?.kill)?.call(backend);
14925
+ (backend?.destroySession ?? backend?.kill)?.call(backend);
12653
14926
  }
12654
14927
  catch { /* best-effort */ }
12655
14928
  backend = null;
@@ -12676,6 +14949,8 @@ process.on('message', async (raw) => {
12676
14949
  }
12677
14950
  }
12678
14951
  });
14952
+ // 预加载层会在正式处理器注册前暂存冷启动消息;现在按到达顺序交还。
14953
+ process.emit(WORKER_IPC_HANDLER_READY_EVENT);
12679
14954
  // ─── Cleanup ─────────────────────────────────────────────────────────────────
12680
14955
  function cleanup() {
12681
14956
  stopNativeSessionTitleSync();
@@ -12711,6 +14986,10 @@ function cleanup() {
12711
14986
  catch { /* already closed */ }
12712
14987
  workflowPtyLogStream = undefined;
12713
14988
  }
14989
+ // Publisher ownership spans every CLI generation in this worker. Releasing
14990
+ // from killCli/restart would reopen a stale-publish window; only process-level
14991
+ // cleanup retires the lease. SIGKILL residue is reclaimed by the next worker.
14992
+ releaseCodexAppPosixOwnerLease();
12714
14993
  }
12715
14994
  process.on('SIGTERM', () => { stopScreenshotLoop(); killCli(); cleanup(); process.exit(0); });
12716
14995
  process.on('SIGINT', () => { stopScreenshotLoop(); killCli(); cleanup(); process.exit(0); });