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
@@ -1,16 +1,53 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from 'node:child_process';
3
- import { buildCodexAppTurnStartParams, isCleanInputCapabilityError, parseCodexVersion, supportsClientUserMessageId, } from './adapters/cli/codex-app-turn.js';
3
+ import { Buffer } from 'node:buffer';
4
+ import { createConnection } from 'node:net';
5
+ import { buildCodexAppTurnStartParams, isCleanInputCapabilityError, isCodexAppTurnInput, parseCodexVersion, supportsClientUserMessageId, } from './adapters/cli/codex-app-turn.js';
4
6
  import { RunnerControlWriter } from './adapters/cli/runner-control-channel.js';
5
- import { CodexAppRpcResponseError, CodexAppTransportError, CodexAppTurnController, } from './services/codex-app-turn-controller.js';
6
- import { CODEX_APP_INPUT_PREFIX, decodeCodexAppRunnerInput, } from './services/codex-app-runner-protocol.js';
7
+ import { CODEX_APP_CONTROL_BOOTSTRAP_ENV, CODEX_APP_CONTROL_FINAL_CHUNK_BYTES, CODEX_APP_CONTROL_FINAL_MAX_BYTES, CodexAppControlEndpointTracker, CodexAppControlLineDecoder, CodexAppControlRunnerHandshake, armCodexAppControlHandshakeTimeout, armCodexAppControlStartupTimeout, consumeCodexAppControlBootstrap, encodeCodexAppControlAuth, encodeCodexAppSignedControlMarker, parseCodexAppControlWireRecord, takeCodexAppControlLocatorEndpoint, } from './utils/codex-app-control.js';
7
8
  import { TurnTokenUsageAccumulator, parseTokenUsagePair, } from './services/codex-app-token-usage.js';
8
9
  const output = new RunnerControlWriter();
10
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
11
+ const RECONCILIATION_TIMEOUT_MS = 5_000;
12
+ const RECONCILIATION_PAGE_LIMIT = 3;
13
+ const RECONCILIATION_PAGE_SIZE = 50;
14
+ class AppServerRpcError extends Error {
15
+ method;
16
+ code;
17
+ data;
18
+ constructor(method, code, data, message) {
19
+ super(`${method}: ${message}`);
20
+ this.method = method;
21
+ this.code = code;
22
+ this.data = data;
23
+ this.name = 'AppServerRpcError';
24
+ }
25
+ }
26
+ class AppServerRequestTimeoutError extends Error {
27
+ method;
28
+ timeoutMs;
29
+ constructor(method, timeoutMs) {
30
+ super(`${method}: timed out after ${timeoutMs}ms; request acceptance is unknown`);
31
+ this.method = method;
32
+ this.timeoutMs = timeoutMs;
33
+ this.name = 'AppServerRequestTimeoutError';
34
+ }
35
+ }
36
+ function asError(value) {
37
+ return value instanceof Error ? value : new Error(String(value));
38
+ }
9
39
  function parseArgs(argv) {
40
+ const controlBootstrapPath = process.env[CODEX_APP_CONTROL_BOOTSTRAP_ENV];
41
+ // app-server and every model-launched tool inherit process.env. Remove even
42
+ // the non-secret bootstrap path before either can start; private key material
43
+ // was never present in env/argv/layout.
44
+ delete process.env[CODEX_APP_CONTROL_BOOTSTRAP_ENV];
10
45
  const out = {
11
46
  sessionId: '',
12
47
  codexBin: 'codex',
13
48
  cwd: process.cwd(),
49
+ controlGeneration: '',
50
+ controlPrivateKey: undefined,
14
51
  };
15
52
  for (let i = 0; i < argv.length; i++) {
16
53
  const key = argv[i];
@@ -54,11 +91,15 @@ function parseArgs(argv) {
54
91
  }
55
92
  if (!out.sessionId)
56
93
  throw new Error('--session-id is required');
94
+ if (!controlBootstrapPath)
95
+ throw new Error(`${CODEX_APP_CONTROL_BOOTSTRAP_ENV} is required`);
96
+ const control = consumeCodexAppControlBootstrap(controlBootstrapPath, out.sessionId);
97
+ out.controlGeneration = control.generation;
98
+ out.controlPrivateKey = control.privateKey;
99
+ out.controlSocketPath = control.socketPath;
100
+ out.controlLocatorPath = control.locatorPath;
57
101
  return out;
58
102
  }
59
- function emitMarker(kind, payload) {
60
- output.marker(kind, payload);
61
- }
62
103
  function writeLine(text = '') {
63
104
  output.line(text);
64
105
  }
@@ -98,7 +139,6 @@ class AppServerClient {
98
139
  pending = new Map();
99
140
  notificationHandlers = [];
100
141
  requestHandlers = [];
101
- fatalHandlers = [];
102
142
  lastStderr = '';
103
143
  fatalError;
104
144
  constructor(codexBin, cwd) {
@@ -110,7 +150,7 @@ class AppServerClient {
110
150
  stdio: ['pipe', 'pipe', 'pipe'],
111
151
  });
112
152
  this.child.stdout.on('data', chunk => this.onStdout(chunk.toString('utf8')));
113
- this.child.stdin.on('error', err => this.failAll(new CodexAppTransportError(`Codex app-server stdin error: ${err.message}`)));
153
+ this.child.stdin.on('error', err => this.failAll(new Error(`Codex app-server stdin error: ${err.message}`)));
114
154
  this.child.stderr.on('data', chunk => {
115
155
  const text = chunk.toString('utf8');
116
156
  this.lastStderr = (this.lastStderr + text).slice(-8000);
@@ -121,10 +161,10 @@ class AppServerClient {
121
161
  const hint = err.code === 'ENOENT'
122
162
  ? '\nHint: install the Codex CLI, or set cliPathOverride to the Codex App bundled binary, for example /Applications/Codex.app/Contents/Resources/codex.'
123
163
  : '';
124
- this.failAll(new CodexAppTransportError(`Failed to start Codex app-server with "${codexBin}": ${err.message}${hint}`));
164
+ this.failAll(new Error(`Failed to start Codex app-server with "${codexBin}": ${err.message}${hint}`));
125
165
  });
126
166
  this.child.on('exit', (code, signal) => {
127
- const err = this.fatalError ?? new CodexAppTransportError(`Codex app-server exited (code=${code}, signal=${signal})${this.lastStderr ? `\n${this.lastStderr}` : ''}`);
167
+ const err = this.fatalError ?? new Error(`Codex app-server exited (code=${code}, signal=${signal})${this.lastStderr ? `\n${this.lastStderr}` : ''}`);
128
168
  this.failAll(err);
129
169
  });
130
170
  }
@@ -134,28 +174,39 @@ class AppServerClient {
134
174
  onRequest(handler) {
135
175
  this.requestHandlers.push(handler);
136
176
  }
137
- onFatal(handler) {
138
- this.fatalHandlers.push(handler);
139
- if (this.fatalError)
140
- handler(this.fatalError);
141
- }
142
- async initialize() {
177
+ async initialize(timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
143
178
  await this.request('initialize', {
144
179
  clientInfo: { name: 'botmux-codex-app', version: '0.0.0' },
145
180
  capabilities: { experimentalApi: true },
146
- });
181
+ }, { timeoutMs });
147
182
  this.notify('initialized');
148
183
  }
149
- request(method, params) {
184
+ request(method, params, options = {}) {
150
185
  const id = this.nextId++;
151
186
  return new Promise((resolve, reject) => {
152
- this.pending.set(id, { resolve, reject, method });
187
+ const timeoutMs = options.timeoutMs;
188
+ const pending = { resolve, reject, method };
189
+ if (timeoutMs !== undefined) {
190
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
191
+ reject(new AppServerRequestTimeoutError(method, Math.max(0, timeoutMs)));
192
+ return;
193
+ }
194
+ pending.timer = setTimeout(() => {
195
+ if (!this.pending.delete(id))
196
+ return;
197
+ reject(new AppServerRequestTimeoutError(method, timeoutMs));
198
+ }, timeoutMs);
199
+ pending.timer.unref?.();
200
+ }
201
+ this.pending.set(id, pending);
153
202
  try {
154
203
  this.write({ jsonrpc: '2.0', id, method, params });
155
204
  }
156
205
  catch (err) {
157
- const message = err instanceof Error ? err.message : String(err);
158
- this.failAll(new CodexAppTransportError(`Codex app-server write failed: ${message}`));
206
+ this.pending.delete(id);
207
+ if (pending.timer)
208
+ clearTimeout(pending.timer);
209
+ reject(asError(err));
159
210
  }
160
211
  });
161
212
  }
@@ -180,18 +231,14 @@ class AppServerClient {
180
231
  this.child.stdin.write(JSON.stringify(msg) + '\n');
181
232
  }
182
233
  failAll(err) {
183
- const firstFailure = this.fatalError === undefined;
184
- this.fatalError = this.fatalError ?? (err instanceof CodexAppTransportError
185
- ? err
186
- : new CodexAppTransportError(err.message));
234
+ this.fatalError = this.fatalError ?? err;
187
235
  const fatal = this.fatalError;
188
- for (const pending of this.pending.values())
236
+ for (const pending of this.pending.values()) {
237
+ if (pending.timer)
238
+ clearTimeout(pending.timer);
189
239
  pending.reject(fatal);
190
- this.pending.clear();
191
- if (firstFailure) {
192
- for (const handler of this.fatalHandlers)
193
- handler(fatal);
194
240
  }
241
+ this.pending.clear();
195
242
  }
196
243
  onStdout(data) {
197
244
  this.stdoutBuffer += data;
@@ -219,8 +266,11 @@ class AppServerClient {
219
266
  if (!pending)
220
267
  return;
221
268
  this.pending.delete(msg.id);
222
- if (msg.error)
223
- pending.reject(new CodexAppRpcResponseError(pending.method, msg.error));
269
+ if (pending.timer)
270
+ clearTimeout(pending.timer);
271
+ if (msg.error) {
272
+ pending.reject(new AppServerRpcError(pending.method, typeof msg.error.code === 'number' ? msg.error.code : undefined, msg.error.data, typeof msg.error.message === 'string' ? msg.error.message : JSON.stringify(msg.error)));
273
+ }
224
274
  else
225
275
  pending.resolve(msg.result);
226
276
  return;
@@ -247,15 +297,242 @@ catch (err) {
247
297
  output.error(`${err?.message ?? err}\n`);
248
298
  process.exit(2);
249
299
  }
250
- const client = new AppServerClient(args.codexBin, args.cwd);
300
+ let controlSeq = 0;
301
+ let controlAckedSeq = 0;
302
+ let controlSentThrough = 0;
303
+ const controlQueue = [];
304
+ let controlFatal = false;
305
+ let controlSocket;
306
+ let controlChallenge;
307
+ let controlAccepted = false;
308
+ let controlAcceptanceCount = 0;
309
+ let controlReconnectTimer;
310
+ let resolveControlReady;
311
+ const controlReady = new Promise(resolve => { resolveControlReady = resolve; });
312
+ const CONTROL_QUEUE_MAX_RECORDS = 2_048;
313
+ const controlEndpoints = new CodexAppControlEndpointTracker();
314
+ function scheduleControlReconnect() {
315
+ if (controlFatal || controlReconnectTimer)
316
+ return;
317
+ controlReconnectTimer = setTimeout(() => {
318
+ controlReconnectTimer = undefined;
319
+ connectControlSocket();
320
+ }, 250);
321
+ }
322
+ function flushControlQueue() {
323
+ const socket = controlSocket;
324
+ const challenge = controlChallenge;
325
+ if (controlFatal || !socket || socket.destroyed || !controlAccepted || !challenge)
326
+ return;
327
+ try {
328
+ for (const marker of controlQueue) {
329
+ if (marker.seq <= controlSentThrough)
330
+ continue;
331
+ socket.write(`${encodeCodexAppSignedControlMarker(args.controlPrivateKey, args.sessionId, args.controlGeneration, challenge, marker.seq, marker.kind, marker.payload)}\n`);
332
+ controlSentThrough = marker.seq;
333
+ }
334
+ }
335
+ catch (err) {
336
+ controlFatal = true;
337
+ console.error(`Codex App control channel failed closed: ${err?.message ?? err}`);
338
+ process.exit(2);
339
+ }
340
+ }
341
+ function nextControlEndpoint() {
342
+ if (args.controlSocketPath)
343
+ return { endpoint: args.controlSocketPath };
344
+ if (!args.controlLocatorPath)
345
+ return undefined;
346
+ return takeCodexAppControlLocatorEndpoint({
347
+ locatorPath: args.controlLocatorPath,
348
+ sessionId: args.sessionId,
349
+ tracker: controlEndpoints,
350
+ });
351
+ }
352
+ function connectControlSocket() {
353
+ if (controlFatal || (controlSocket && !controlSocket.destroyed))
354
+ return;
355
+ const target = nextControlEndpoint();
356
+ if (!target) {
357
+ scheduleControlReconnect();
358
+ return;
359
+ }
360
+ // A never-accepted locator endpoint may retry with the existing 250ms
361
+ // reconnect backoff; the protected 256-bit locator epoch is still required
362
+ // before acceptance. Once accepted, its endpoint is permanently burned and
363
+ // only a newly published locator can be used.
364
+ const socket = createConnection(target.endpoint);
365
+ const handshakeTimer = armCodexAppControlHandshakeTimeout(() => {
366
+ socket.destroy(new Error('Codex App control endpoint handshake timed out'));
367
+ });
368
+ handshakeTimer.unref?.();
369
+ const decoder = new CodexAppControlLineDecoder();
370
+ const handshake = new CodexAppControlRunnerHandshake(args.sessionId, args.controlGeneration, target.epoch);
371
+ controlSocket = socket;
372
+ controlChallenge = undefined;
373
+ controlAccepted = false;
374
+ controlSentThrough = controlAckedSeq;
375
+ socket.setNoDelay(true);
376
+ socket.on('data', chunk => {
377
+ const decoded = decoder.push(chunk);
378
+ if (decoded.droppedMalformed) {
379
+ socket.destroy(new Error('oversized Codex App control response'));
380
+ return;
381
+ }
382
+ for (const line of decoded.lines) {
383
+ if (controlSocket !== socket) {
384
+ socket.destroy(new Error('unexpected Codex App control response'));
385
+ return;
386
+ }
387
+ const action = handshake.handle(parseCodexAppControlWireRecord(line), controlSentThrough);
388
+ if (action.type === 'authenticate') {
389
+ controlChallenge = action.challenge;
390
+ socket.write(`${encodeCodexAppControlAuth(args.controlPrivateKey, args.sessionId, args.controlGeneration, action.challenge)}\n`);
391
+ }
392
+ else if (action.type === 'accepted') {
393
+ // `accepted` is intentionally unsigned. Its authority is the protected
394
+ // locator's independent epoch plus the already-bound random endpoint.
395
+ // Ed25519 still authenticates every runner marker to the worker.
396
+ controlAccepted = true;
397
+ clearTimeout(handshakeTimer);
398
+ if (args.controlLocatorPath)
399
+ controlEndpoints.noteAccepted(target.endpoint);
400
+ controlAcceptanceCount++;
401
+ resolveControlReady();
402
+ // The first acceptance happens before app-server initialization; it is
403
+ // not a ready boundary. Re-authentication can publish the live state
404
+ // only after initialization has completed.
405
+ if (controlAcceptanceCount > 1 && runnerReady)
406
+ emitRunnerState();
407
+ flushControlQueue();
408
+ }
409
+ else if (action.type === 'ack' && controlAccepted) {
410
+ if (action.seq > controlAckedSeq)
411
+ controlAckedSeq = action.seq;
412
+ while (controlQueue[0] && controlQueue[0].seq <= controlAckedSeq)
413
+ controlQueue.shift();
414
+ }
415
+ else {
416
+ socket.destroy(new Error('out-of-order Codex App control response'));
417
+ return;
418
+ }
419
+ }
420
+ });
421
+ socket.on('error', () => { });
422
+ socket.on('close', () => {
423
+ clearTimeout(handshakeTimer);
424
+ if (controlSocket === socket) {
425
+ controlSocket = undefined;
426
+ controlChallenge = undefined;
427
+ controlAccepted = false;
428
+ }
429
+ scheduleControlReconnect();
430
+ });
431
+ }
432
+ function emitMarker(kind, payload) {
433
+ if (controlQueue.length >= CONTROL_QUEUE_MAX_RECORDS) {
434
+ controlFatal = true;
435
+ console.error('Codex App control queue exceeded its fail-closed bound');
436
+ process.exit(2);
437
+ return;
438
+ }
439
+ controlQueue.push({ seq: ++controlSeq, kind, payload });
440
+ flushControlQueue();
441
+ }
442
+ function emitFinalMarker(payload, opts = {}) {
443
+ // Usage draining is caller-controlled so an N-final expansion of one native
444
+ // completion does not let the FIRST (superseded) transaction steal the usage:
445
+ // only the LAST real final drains the accumulator. Default (no opts) drains,
446
+ // preserving every existing single-final caller. A `steer_superseded`
447
+ // transaction never carries usage and never drains.
448
+ const superseded = opts.disposition === 'steer_superseded';
449
+ const drainUsage = superseded ? false : (opts.drainUsage ?? true);
450
+ // Attach this turn's token usage (if the accumulator saw coherent totals) and
451
+ // drain its accumulator. Keyed by the codex native turn id; omitted when no
452
+ // usage was observed / a protocol anomaly was detected — never zeros.
453
+ const usageKey = typeof payload.nativeTurnId === 'string' && payload.nativeTurnId.length > 0
454
+ ? payload.nativeTurnId
455
+ : undefined;
456
+ const acc = usageKey && drainUsage ? usageAccumulators.get(usageKey) : undefined;
457
+ const usage = acc?.result() ?? undefined;
458
+ if (acc?.warning && !usage) {
459
+ // Surface a protocol anomaly rather than silently omitting usage — a
460
+ // regression/negative-baseline should be visible in the runner log.
461
+ writeLine(`[codex-app] token usage dropped for turn ${usageKey ?? '?'}: ${acc.warning}`);
462
+ }
463
+ if (usageKey && drainUsage)
464
+ usageAccumulators.delete(usageKey);
465
+ // Dual-name the turn identity so both protocol vocabularies ride the same
466
+ // signed final transaction:
467
+ // - PR #597: `turnId` (botmux/Lark id) + `nativeTurnId` (codex app-server id)
468
+ // — consumed by the worker's serial dispatch FIFO (settleFinal).
469
+ // - master: `replyTurnId` (botmux/Lark id) + `appTurnId` (codex id) — the
470
+ // names the runner-protocol normalizer + steer-ack correlation expect.
471
+ // `appTurnId` is always present: a turn that never bound a native id (e.g. a
472
+ // turn/start rejection) gets a synthetic codex-app-error id, matching master's
473
+ // controller failure-id contract so the worker can still key the completion.
474
+ const replyTurnId = typeof payload.turnId === 'string' && payload.turnId.length > 0
475
+ ? payload.turnId
476
+ : undefined;
477
+ const appTurnId = usageKey ?? `codex-app-error-${Date.now()}-${++finalFailureSequence}`;
478
+ const original = Buffer.from(String(payload.content ?? ''), 'utf8');
479
+ const truncated = original.length > CODEX_APP_CONTROL_FINAL_MAX_BYTES;
480
+ const content = truncated
481
+ ? Buffer.concat([
482
+ original.subarray(0, CODEX_APP_CONTROL_FINAL_MAX_BYTES - 64),
483
+ Buffer.from('\n\n[botmux: final output truncated at control limit]', 'utf8'),
484
+ ])
485
+ : original;
486
+ const id = `${String(payload.turnId ?? 'turn')}:${String(payload.completedAtMs ?? Date.now())}`;
487
+ const total = Math.ceil(content.length / CODEX_APP_CONTROL_FINAL_CHUNK_BYTES);
488
+ const { content: _content, ...metadata } = payload;
489
+ emitMarker('final-start', {
490
+ id,
491
+ total,
492
+ truncated,
493
+ ...metadata,
494
+ appTurnId,
495
+ ...(replyTurnId ? { replyTurnId } : {}),
496
+ ...(usage ? { usage } : {}),
497
+ ...(superseded ? { disposition: 'steer_superseded' } : {}),
498
+ });
499
+ for (let index = 0; index < total; index++) {
500
+ const start = index * CODEX_APP_CONTROL_FINAL_CHUNK_BYTES;
501
+ emitMarker('final-chunk', {
502
+ id,
503
+ index,
504
+ data: content.subarray(start, start + CODEX_APP_CONTROL_FINAL_CHUNK_BYTES).toString('base64'),
505
+ });
506
+ }
507
+ emitMarker('final-end', { id, total });
508
+ }
509
+ connectControlSocket();
510
+ let client;
251
511
  let threadId = args.threadId;
252
512
  let threadReady = false;
513
+ let activeTurn = null;
514
+ let activeTurnEpoch = 0;
515
+ /** App-server may start a Goal continuation without a Botmux input. Keep that
516
+ * native lifecycle separate from `activeTurn`; otherwise the next Lark input
517
+ * is incorrectly sent with turn/start and its completion can be discarded as
518
+ * belonging to an "unexpected" native turn. */
519
+ let nativeActiveTurnId;
520
+ const queue = [];
253
521
  let inputBuffer = '';
522
+ let processing = false;
523
+ /** At most one pre-final Lark `turn/steer` admission runs at a time (checkpoint:
524
+ * startResponsePending ≠ steerInFlight — this guards ONLY the steer RPC). */
525
+ let steerAdmitting = false;
526
+ /** An unknown turn/start|turn/steer outcome fenced the generation. Stops all
527
+ * dequeue / steer / final / idle-state emission until the process is torn down
528
+ * (the worker's authenticated `fatal` lifecycle fails the control generation). */
529
+ let generationFenced = false;
530
+ let runnerReady = false;
531
+ let cleanInputUnsupported = false;
254
532
  let codexVersionChecked = false;
255
533
  let codexVersion;
256
534
  let cleanVersionWarningShown = false;
257
- let controller;
258
- /** Per-turn token accumulators keyed by codex appTurnId. Fed by
535
+ /** Per-turn token accumulators keyed by codex native turn id. Fed by
259
536
  * thread/tokenUsage/updated notifications; drained (and deleted) when the
260
537
  * matching turn's final marker is emitted. Bounded by turn lifetime — a turn
261
538
  * that never finalizes leaves at most one stale entry, cleared on next final. */
@@ -263,6 +540,10 @@ const usageAccumulators = new Map();
263
540
  /** Only one turn is active at a time; a small cap bounds leakage from turns
264
541
  * that never emit a final marker. */
265
542
  const MAX_USAGE_ACCUMULATORS = 8;
543
+ /** Monotonic counter for synthetic appTurnId values on finals that never bound a
544
+ * codex native turn id (e.g. turn/start rejections). Mirrors master's controller
545
+ * failure-id contract (`codex-app-error-<now>-<seq>`). */
546
+ let finalFailureSequence = 0;
266
547
  /** Get (or create, with bounded pruning) the usage accumulator for a turn. */
267
548
  function getOrCreateUsageAccumulator(turnId) {
268
549
  let acc = usageAccumulators.get(turnId);
@@ -279,6 +560,17 @@ function getOrCreateUsageAccumulator(turnId) {
279
560
  }
280
561
  return acc;
281
562
  }
563
+ function emitRunnerState(busy = processing || queue.length > 0 || nativeActiveTurnId !== undefined, tracksTurn = activeTurn !== null) {
564
+ emitMarker('state', {
565
+ busy,
566
+ atMs: Date.now(),
567
+ // Input is accepted only after the runner has initialized and emitted this
568
+ // signed state. The worker uses this field as a runtime type-ahead gate;
569
+ // authentication alone never releases a prompt.
570
+ acceptingInput: runnerReady,
571
+ ...(busy && !tracksTurn ? { tracksTurn: false } : {}),
572
+ });
573
+ }
282
574
  function detectedCodexVersion() {
283
575
  if (codexVersionChecked)
284
576
  return codexVersion;
@@ -297,6 +589,47 @@ function detectedCodexVersion() {
297
589
  }
298
590
  return codexVersion;
299
591
  }
592
+ function makeTurn(clientUserMessageId, requestKind) {
593
+ let resolveDone;
594
+ const done = new Promise(resolve => { resolveDone = resolve; });
595
+ return {
596
+ ...(clientUserMessageId ? { clientUserMessageId } : {}),
597
+ epoch: ++activeTurnEpoch,
598
+ identityConflictReported: false,
599
+ completed: false,
600
+ requestKind,
601
+ requestAccepted: false,
602
+ pendingCompletions: [],
603
+ pendingNotifications: [],
604
+ startedAtMs: Date.now(),
605
+ lastActivityMarkerAtMs: 0,
606
+ serverStarted: false,
607
+ finalText: '',
608
+ allAgentText: '',
609
+ itemText: new Map(),
610
+ phase: 'starting',
611
+ done,
612
+ resolveDone,
613
+ };
614
+ }
615
+ const TURN_ACTIVITY_MARKER_MIN_INTERVAL_MS = 5_000;
616
+ /**
617
+ * Expose app-server lifecycle activity to the parent worker without polluting
618
+ * the visible terminal. Progress markers are throttled because token-delta
619
+ * notifications can arrive many times per second; submitted/completed edges
620
+ * are always emitted.
621
+ */
622
+ function emitTurnActivity(turn, phase, force = false) {
623
+ const atMs = Date.now();
624
+ if (!force && atMs - turn.lastActivityMarkerAtMs < TURN_ACTIVITY_MARKER_MIN_INTERVAL_MS)
625
+ return;
626
+ turn.lastActivityMarkerAtMs = atMs;
627
+ emitMarker('activity', {
628
+ phase,
629
+ atMs,
630
+ ...(turn.nativeTurnId ? { turnId: turn.nativeTurnId } : {}),
631
+ });
632
+ }
300
633
  function handleServerRequest(msg) {
301
634
  const method = msg.method;
302
635
  if (method === 'item/commandExecution/requestApproval') {
@@ -329,17 +662,150 @@ function handleServerRequest(msg) {
329
662
  }
330
663
  return false;
331
664
  }
332
- function handleNotification(msg) {
665
+ function exactClientItemIndexes(turn, clientUserMessageId) {
666
+ if (turn?.itemsView !== 'full' || !Array.isArray(turn?.items))
667
+ return [];
668
+ const indexes = [];
669
+ for (let index = 0; index < turn.items.length; index++) {
670
+ const item = turn.items[index];
671
+ if (item?.type === 'userMessage' && item.clientId === clientUserMessageId)
672
+ indexes.push(index);
673
+ }
674
+ return indexes;
675
+ }
676
+ function isTerminalNativeTurn(turn) {
677
+ return turn?.status === undefined
678
+ || turn.status === 'completed'
679
+ || turn.status === 'failed'
680
+ || turn.status === 'interrupted';
681
+ }
682
+ /** Rebuild only from content causally after the exact user item. Never reuse
683
+ * streamed text from a different native turn during identity reconciliation. */
684
+ function rebuildReconciledFinal(turn, userItemIndex) {
685
+ const following = Array.isArray(turn.items) ? turn.items.slice(userItemIndex + 1) : [];
686
+ const finalAnswers = following.filter((item) => item?.type === 'agentMessage' && item.phase === 'final_answer');
687
+ if (finalAnswers.length > 0)
688
+ return String(finalAnswers.at(-1)?.text ?? '');
689
+ if (turn?.error?.message)
690
+ return `Codex App turn failed: ${String(turn.error.message)}`;
691
+ return '';
692
+ }
693
+ function completeActiveTurnFromNative(turn, nativeTurn, exactIndex) {
694
+ if (activeTurn !== turn || turn.completed || !isTerminalNativeTurn(nativeTurn))
695
+ return;
696
+ if (exactIndex !== undefined) {
697
+ turn.finalText = rebuildReconciledFinal(nativeTurn, exactIndex);
698
+ turn.allAgentText = '';
699
+ }
700
+ else if (nativeTurn?.error?.message && !turn.finalText) {
701
+ turn.finalText = `Codex App turn failed: ${String(nativeTurn.error.message)}`;
702
+ }
703
+ if (typeof nativeTurn?.id === 'string') {
704
+ turn.nativeTurnId = nativeTurn.id;
705
+ if (nativeActiveTurnId === nativeTurn.id)
706
+ nativeActiveTurnId = undefined;
707
+ }
708
+ turn.completed = true;
709
+ emitTurnActivity(turn, 'completed', true);
710
+ turn.resolveDone();
711
+ }
712
+ function reportIdentityConflict(turn, observedNativeTurnId, reason = 'no exact client id match') {
713
+ if (activeTurn !== turn || turn.identityConflictReported)
714
+ return;
715
+ turn.identityConflictReported = true;
716
+ const stableTurnId = turn.clientUserMessageId;
717
+ const message = `Codex App native turn identity conflict (${reason}); refusing to attribute a completion without an exact clientUserMessageId match`;
718
+ writeLine(`[codex-app] ${message}`);
719
+ emitMarker('diagnostic', {
720
+ code: 'native_turn_identity_conflict',
721
+ message,
722
+ ...(stableTurnId ? { turnId: stableTurnId } : {}),
723
+ ...(turn.nativeTurnId ? { expectedNativeTurnId: turn.nativeTurnId } : {}),
724
+ ...(observedNativeTurnId ? { observedNativeTurnId } : {}),
725
+ atMs: Date.now(),
726
+ });
727
+ // Attribution failed closed, but the logical Botmux turn must still settle.
728
+ // Leaving turn.done unresolved permanently poisons the runner FIFO and keeps
729
+ // the session "busy" forever. Emit one explicit error final under the stable
730
+ // client turn id; never reuse untrusted native text.
731
+ turn.finalText = `Codex App turn failed: ${message}`;
732
+ turn.allAgentText = '';
733
+ if (turn.nativeTurnId && nativeActiveTurnId === turn.nativeTurnId) {
734
+ nativeActiveTurnId = undefined;
735
+ }
736
+ turn.completed = true;
737
+ emitTurnActivity(turn, 'completed', true);
738
+ turn.resolveDone();
739
+ }
740
+ async function reconcileCompletedTurn(turn, observedNativeTurnId) {
741
+ if (turn.reconciliation || turn.completed)
742
+ return turn.reconciliation;
743
+ const clientUserMessageId = turn.clientUserMessageId;
744
+ if (!clientUserMessageId || !threadId) {
745
+ reportIdentityConflict(turn, observedNativeTurnId, 'legacy input has no clientUserMessageId');
746
+ return;
747
+ }
748
+ const epoch = turn.epoch;
749
+ const deadlineAtMs = Date.now() + RECONCILIATION_TIMEOUT_MS;
750
+ turn.reconciliation = (async () => {
751
+ const matches = [];
752
+ let cursor;
753
+ for (let page = 0; page < RECONCILIATION_PAGE_LIMIT; page++) {
754
+ const remaining = deadlineAtMs - Date.now();
755
+ if (remaining <= 0)
756
+ break;
757
+ const result = await client.request('thread/turns/list', {
758
+ threadId,
759
+ ...(cursor ? { cursor } : {}),
760
+ limit: RECONCILIATION_PAGE_SIZE,
761
+ sortDirection: 'desc',
762
+ itemsView: 'full',
763
+ }, { timeoutMs: remaining });
764
+ for (const candidate of Array.isArray(result?.data) ? result.data : []) {
765
+ if (!isTerminalNativeTurn(candidate))
766
+ continue;
767
+ const indexes = exactClientItemIndexes(candidate, clientUserMessageId);
768
+ if (indexes.length === 1)
769
+ matches.push({ turn: candidate, itemIndex: indexes[0] });
770
+ else if (indexes.length > 1) {
771
+ reportIdentityConflict(turn, observedNativeTurnId, 'client id appears more than once in one turn');
772
+ return;
773
+ }
774
+ }
775
+ cursor = typeof result?.nextCursor === 'string' ? result.nextCursor : null;
776
+ if (!cursor)
777
+ break;
778
+ }
779
+ if (activeTurn !== turn || turn.epoch !== epoch || turn.completed)
780
+ return;
781
+ if (matches.length === 1) {
782
+ completeActiveTurnFromNative(turn, matches[0].turn, matches[0].itemIndex);
783
+ return;
784
+ }
785
+ reportIdentityConflict(turn, observedNativeTurnId, matches.length === 0 ? 'bounded history lookup found no match' : 'bounded history lookup found multiple matches');
786
+ })().catch(err => {
787
+ if (activeTurn === turn && !turn.completed) {
788
+ reportIdentityConflict(turn, observedNativeTurnId, `bounded history lookup failed: ${asError(err).message}`);
789
+ }
790
+ });
791
+ await turn.reconciliation;
792
+ }
793
+ function handleNotification(msg, replayedAfterResponse = false) {
794
+ const params = msg.params ?? {};
795
+ if (params.threadId !== threadId)
796
+ return;
797
+ const notificationTurnId = params.turnId ?? params.turn?.id;
333
798
  // Per-turn token usage rides on thread/tokenUsage/updated (NOT turn/completed).
334
- // Feed the accumulator for the matching appTurnId; the controller ignores this
335
- // method, so we handle it here and still delegate for everything else.
799
+ // Feed the accumulator for the matching native turn id; it is drained onto the
800
+ // final marker under the same id when the turn's final transaction is emitted.
336
801
  if (msg.method === 'thread/tokenUsage/updated') {
337
- const params = (msg.params ?? {});
338
- const turnId = typeof params.turnId === 'string' ? params.turnId : undefined;
339
- if (turnId) {
802
+ const usageTurnId = typeof notificationTurnId === 'string' && notificationTurnId.length > 0
803
+ ? notificationTurnId
804
+ : undefined;
805
+ if (usageTurnId) {
340
806
  const usage = (params.tokenUsage ?? {});
341
807
  const parsed = parseTokenUsagePair(usage.total, usage.last);
342
- const acc = getOrCreateUsageAccumulator(turnId);
808
+ const acc = getOrCreateUsageAccumulator(usageTurnId);
343
809
  if (parsed) {
344
810
  acc.update(parsed.total, parsed.last);
345
811
  }
@@ -357,10 +823,290 @@ function handleNotification(msg) {
357
823
  // protocol warning rather than dropping it entirely silently.
358
824
  writeLine('[codex-app] tokenUsage notification without turnId (ignored)');
359
825
  }
826
+ return;
827
+ }
828
+ if (msg.method === 'turn/started') {
829
+ const startedId = typeof notificationTurnId === 'string' ? notificationTurnId : undefined;
830
+ if (startedId && (!replayedAfterResponse
831
+ || nativeActiveTurnId === undefined
832
+ || nativeActiveTurnId === startedId))
833
+ nativeActiveTurnId = startedId;
834
+ const turn = activeTurn;
835
+ if (turn && startedId) {
836
+ const exact = turn.clientUserMessageId
837
+ ? exactClientItemIndexes(params.turn, turn.clientUserMessageId)
838
+ : [];
839
+ // B1/R4-B2: an exact-client `turn/started` proves the canonical native id
840
+ // even before the turn/start response returns. First-proof-wins: bind only
841
+ // if canonical is unset OR already this id; a different already-proven
842
+ // canonical fences (contradiction). Same id opens the group + kicks.
843
+ if (exact.length === 1) {
844
+ if (!proveCanonicalExact(turn, startedId, 'exact_started'))
845
+ return;
846
+ if (!turn.nativeTurnId)
847
+ turn.nativeTurnId = startedId;
848
+ if (turn.phase === undefined || turn.phase === 'starting')
849
+ turn.phase = 'open';
850
+ }
851
+ if (turn.nativeTurnId === startedId) {
852
+ turn.serverStarted = true;
853
+ emitTurnActivity(turn, 'progress', true);
854
+ // Kick only when the canonical id is proven exact (not a bare id echo)
855
+ // so a follow-up steers into the proven turn during a pending response.
856
+ if ((turn.identityProof === 'exact_started' || turn.identityProof === 'exact_completed')
857
+ && !turn.completed)
858
+ void tryAdmitSteer();
859
+ return;
860
+ }
861
+ // app-server is allowed to publish turn/started before replying to
862
+ // turn/start. Without the response we do not yet know whether this is
863
+ // our native turn, but dropping it also loses the first (and sometimes
864
+ // only) progress edge. Replay it after the RPC binds nativeTurnId.
865
+ if (!turn.requestAccepted && turn.requestKind === 'start') {
866
+ const alreadyBufferedStart = turn.pendingNotifications.some(notification => notification.method === 'turn/started'
867
+ && (notification.params?.turnId ?? notification.params?.turn?.id) === startedId);
868
+ if (!alreadyBufferedStart)
869
+ turn.pendingNotifications.push(msg);
870
+ return;
871
+ }
872
+ }
873
+ // A Goal continuation is native work, not a Botmux turn. Keep the worker
874
+ // busy while explicitly advertising that the initialized runner can accept
875
+ // a Lark follow-up through turn/steer.
876
+ if (runnerReady)
877
+ emitRunnerState(true, false);
878
+ return;
879
+ }
880
+ if (msg.method === 'turn/completed') {
881
+ const nativeTurn = params.turn ?? {};
882
+ const completedId = typeof notificationTurnId === 'string' ? notificationTurnId : undefined;
883
+ if (completedId && nativeActiveTurnId === completedId)
884
+ nativeActiveTurnId = undefined;
885
+ const turn = activeTurn;
886
+ if (!turn) {
887
+ // An autonomous Goal turn finished with no tracked Botmux turn. If a
888
+ // false-flag head was parked behind it (B3 gate), nativeActiveTurnId is
889
+ // now cleared (line above) so re-kick the drain to start it as its own
890
+ // turn — otherwise it would sleep forever. drainQueue no-ops when idle.
891
+ if (runnerReady)
892
+ emitRunnerState();
893
+ if (queue.length > 0 && nativeActiveTurnId === undefined)
894
+ void drainQueue();
895
+ return;
896
+ }
897
+ // Group mode (R3): a steerable root that actually began ordered-steer work
898
+ // routes EVERY completion through group-aware settlement — never the
899
+ // single-turn completeActiveTurnFromNative / single-root reconcile, which
900
+ // finalizeAcceptedGroup would then wrongly expand into N finals from a turn
901
+ // that does not contain the whole group.
902
+ if (inGroupMode(turn)) {
903
+ turn.completionSeen = true;
904
+ turn.steeringClosed = true;
905
+ // A steer RPC racing this completion, or a still-pending root start
906
+ // response, is a barrier: buffer and let that continuation settle the group
907
+ // once it appends its member / binds canonical. (canonical-only barrier.)
908
+ const isCanonical = turn.canonicalNativeTurnId !== undefined
909
+ && completedId === turn.canonicalNativeTurnId;
910
+ if (isCanonical) {
911
+ turn.terminalCompletion = nativeTurn;
912
+ if (turn.steerInFlight || turn.startResponsePending) {
913
+ emitLifecycle({ kind: 'completion_race', appTurnId: completedId, category: 'steer_in_flight' });
914
+ return;
915
+ }
916
+ settleSteeredCompletion(turn, nativeTurn);
917
+ return;
918
+ }
919
+ // A NON-canonical completion cannot itself close the group. If a steer is
920
+ // still racing / the start response is pending, wait for that continuation
921
+ // (it may prove the canonical id). Otherwise scan bounded history for the
922
+ // one terminal turn that contains the full ordered group; fail closed
923
+ // (identity error, no foreign text) on 0 / multiple / partial matches.
924
+ if (turn.steerInFlight || turn.startResponsePending) {
925
+ emitLifecycle({ kind: 'completion_race', appTurnId: completedId ?? '', category: 'steer_in_flight' });
926
+ return;
927
+ }
928
+ void reconcileSteeredGroupFromHistory(turn, completedId);
929
+ return;
930
+ }
931
+ if (turn.nativeTurnId && completedId === turn.nativeTurnId) {
932
+ const exact = turn.clientUserMessageId
933
+ ? exactClientItemIndexes(nativeTurn, turn.clientUserMessageId)
934
+ : [];
935
+ // R3-B2: an exact-client completion arriving BEFORE the start response is
936
+ // upgraded to canonical identity proof — atomically bind the canonical id,
937
+ // set an independent proof, and require a late start response to match it.
938
+ // Until that atomic upgrade, the completion is only a candidate (buffered);
939
+ // it must never single-turn-settle here in a way a late response could then
940
+ // silently overwrite. Only a steerable root can upgrade (Goal steer / plain
941
+ // legacy keep the original single-turn behavior below).
942
+ if (exact.length === 1
943
+ && !turn.requestAccepted
944
+ && turn.startResponsePending
945
+ && turn.accepted?.[0]?.input.codexAppSteerable === true
946
+ && typeof completedId === 'string') {
947
+ // R4-B2 first-proof-wins: a prior exact proof for a DIFFERENT id fences;
948
+ // same id is idempotent. Never overwrite an already-proven canonical.
949
+ if (!proveCanonicalExact(turn, completedId, 'exact_completed'))
950
+ return;
951
+ turn.completionSeen = true;
952
+ turn.steeringClosed = true;
953
+ turn.terminalCompletion = nativeTurn;
954
+ emitLifecycle({ kind: 'completion_race', appTurnId: completedId, category: 'steer_in_flight' });
955
+ return;
956
+ }
957
+ if (exact.length === 1) {
958
+ completeActiveTurnFromNative(turn, nativeTurn, exact[0]);
959
+ return;
960
+ }
961
+ if (!turn.requestAccepted) {
962
+ turn.pendingCompletions.push(nativeTurn);
963
+ return;
964
+ }
965
+ if (turn.requestKind === 'steer') {
966
+ void reconcileCompletedTurn(turn, completedId);
967
+ return;
968
+ }
969
+ completeActiveTurnFromNative(turn, nativeTurn);
970
+ return;
971
+ }
972
+ const exact = turn.clientUserMessageId
973
+ ? exactClientItemIndexes(nativeTurn, turn.clientUserMessageId)
974
+ : [];
975
+ // R3-B2: an exact-client full-items completion arriving BEFORE the start
976
+ // response binds nativeTurnId is upgraded to canonical identity proof for a
977
+ // steerable root (atomic: bind canonical id + independent proof + buffer as
978
+ // candidate + require the late response to match). It must NOT single-turn
979
+ // settle here — a late response with a different id would otherwise be unable
980
+ // to correct an already-settled wrong attribution. The response path fences
981
+ // on id mismatch and settles the buffered candidate on match.
982
+ if (exact.length === 1
983
+ && !turn.requestAccepted
984
+ && turn.startResponsePending
985
+ && turn.accepted?.[0]?.input.codexAppSteerable === true
986
+ && typeof completedId === 'string') {
987
+ // R4-B2 first-proof-wins: a prior exact proof for a DIFFERENT id fences;
988
+ // same id is idempotent. Never overwrite an already-proven canonical.
989
+ if (!proveCanonicalExact(turn, completedId, 'exact_completed'))
990
+ return;
991
+ turn.completionSeen = true;
992
+ turn.steeringClosed = true;
993
+ turn.terminalCompletion = nativeTurn;
994
+ emitLifecycle({ kind: 'completion_race', appTurnId: completedId, category: 'steer_in_flight' });
995
+ return;
996
+ }
997
+ if (exact.length === 1) {
998
+ completeActiveTurnFromNative(turn, nativeTurn, exact[0]);
999
+ return;
1000
+ }
1001
+ if (!turn.requestAccepted) {
1002
+ turn.pendingCompletions.push(nativeTurn);
1003
+ return;
1004
+ }
1005
+ void reconcileCompletedTurn(turn, completedId);
1006
+ return;
1007
+ }
1008
+ const turn = activeTurn;
1009
+ if (!turn)
1010
+ return;
1011
+ if (!turn.requestAccepted) {
1012
+ // turn/start notifications may beat their response. Buffer only that
1013
+ // request's candidate native events and replay after the response chooses
1014
+ // the authoritative id. For turn/steer, pre-response events can be old
1015
+ // autonomous output and are deliberately never promoted into the final.
1016
+ if (turn.requestKind === 'start' && typeof notificationTurnId === 'string') {
1017
+ turn.pendingNotifications.push(msg);
1018
+ }
1019
+ return;
1020
+ }
1021
+ if (turn.nativeTurnId && notificationTurnId && notificationTurnId !== turn.nativeTurnId)
1022
+ return;
1023
+ // Every notification for the active app-server turn is evidence of forward
1024
+ // progress, including reasoning/status events that do not render text.
1025
+ emitTurnActivity(turn, 'progress');
1026
+ if (msg.method === 'item/started') {
1027
+ const item = params.item;
1028
+ if (item?.type === 'commandExecution') {
1029
+ writeLine(`\n$ ${item.command}`);
1030
+ }
1031
+ else if (item?.type === 'fileChange') {
1032
+ writeLine('\n[files changed]');
1033
+ }
1034
+ return;
1035
+ }
1036
+ if (msg.method === 'item/agentMessage/delta') {
1037
+ const delta = String(params.delta ?? '');
1038
+ const itemId = String(params.itemId ?? '');
1039
+ turn.itemText.set(itemId, (turn.itemText.get(itemId) ?? '') + delta);
1040
+ turn.allAgentText += delta;
1041
+ output.display(delta);
1042
+ return;
1043
+ }
1044
+ if (msg.method === 'item/commandExecution/outputDelta' || msg.method === 'item/fileChange/outputDelta') {
1045
+ output.display(String(params.delta ?? ''));
1046
+ return;
1047
+ }
1048
+ if (msg.method === 'item/completed') {
1049
+ const item = params.item;
1050
+ if (item?.type === 'agentMessage') {
1051
+ if (item.phase === 'final_answer')
1052
+ turn.finalText = String(item.text ?? '');
1053
+ else if (!turn.itemText.has(item.id) && item.text) {
1054
+ turn.allAgentText += String(item.text);
1055
+ }
1056
+ }
1057
+ return;
1058
+ }
1059
+ }
1060
+ function startupRequestTimeout(deadlineAtMs, method) {
1061
+ if (deadlineAtMs === undefined)
1062
+ return DEFAULT_REQUEST_TIMEOUT_MS;
1063
+ const remaining = deadlineAtMs - Date.now();
1064
+ if (remaining <= 0)
1065
+ throw new AppServerRequestTimeoutError(method, 0);
1066
+ return remaining;
1067
+ }
1068
+ function isExplicitMissingThread(error) {
1069
+ if (!(error instanceof AppServerRpcError))
1070
+ return false;
1071
+ return /(thread|rollout|conversation).*(not found|does not exist|missing|unknown)|not found.*(thread|rollout|conversation)/i
1072
+ .test(error.message);
1073
+ }
1074
+ function isExplicitExpectedTurnInactive(error) {
1075
+ return error instanceof AppServerRpcError
1076
+ && /(expected|active).*(turn).*(not active|no longer active|mismatch|does not match)|(turn).*(not active|no longer active).*(expected)/i
1077
+ .test(error.message);
1078
+ }
1079
+ /**
1080
+ * Whether an RPC error definitively proves the operation was NOT accepted — safe
1081
+ * to treat as a clean rejection (no fence, no in-doubt final). Mirrors the
1082
+ * controller's isDefiniteSteerRejection (nit): JSON-RPC parse/method/params
1083
+ * errors (-32600/-32601/-32602) prove non-acceptance, plus the app-server's
1084
+ * explicit "not steerable / expected turn" rejection phrases. Anything else
1085
+ * (transport, timeout, generic -32000, protocol anomaly) is an UNKNOWN outcome
1086
+ * that must fence rather than guess a disposition.
1087
+ */
1088
+ function isDefiniteRpcRejection(error) {
1089
+ if (!(error instanceof AppServerRpcError))
1090
+ return false;
1091
+ if (error.code === -32600 || error.code === -32601 || error.code === -32602)
1092
+ return true;
1093
+ let dataText = '';
1094
+ try {
1095
+ dataText = JSON.stringify(error.data ?? '').toLowerCase();
360
1096
  }
361
- controller?.handleNotification(msg);
1097
+ catch { /* untrusted data */ }
1098
+ const detail = `${error.message} ${dataText}`.toLowerCase();
1099
+ return detail.includes('no active turn to steer')
1100
+ || detail.includes('activeturnnotsteerable')
1101
+ || detail.includes('active turn not steerable')
1102
+ || detail.includes('expectedturnid')
1103
+ || detail.includes('expected turn id')
1104
+ || detail.includes('cannot steer a review turn')
1105
+ || detail.includes('cannot steer a compact turn')
1106
+ || detail.includes('input must not be empty')
1107
+ || isExplicitExpectedTurnInactive(error);
362
1108
  }
363
- async function ensureThread() {
1109
+ async function ensureThread(startupDeadlineAtMs) {
364
1110
  if (threadReady && threadId)
365
1111
  return threadId;
366
1112
  if (threadId) {
@@ -382,7 +1128,7 @@ async function ensureThread() {
382
1128
  // Keep Codex App's rich history in sync with turns created by this
383
1129
  // external runner so the desktop UI can render follow-up messages.
384
1130
  persistExtendedHistory: true,
385
- });
1131
+ }, { timeoutMs: startupRequestTimeout(startupDeadlineAtMs, 'thread/resume') });
386
1132
  const resumedThreadId = String(resumed.thread.id);
387
1133
  threadId = resumedThreadId;
388
1134
  threadReady = true;
@@ -390,6 +1136,11 @@ async function ensureThread() {
390
1136
  return resumedThreadId;
391
1137
  }
392
1138
  catch (err) {
1139
+ // A transport error or timeout is an ambiguous acceptance boundary. It
1140
+ // must never fork history by silently creating a fresh thread. Only an
1141
+ // explicit app-server "missing thread" rejection permits fallback.
1142
+ if (!isExplicitMissingThread(err))
1143
+ throw err;
393
1144
  writeLine(`[codex-app] resume failed, starting a fresh thread: ${err?.message ?? err}`);
394
1145
  threadId = undefined;
395
1146
  threadReady = false;
@@ -417,102 +1168,831 @@ async function ensureThread() {
417
1168
  // Keep Codex App's rich history in sync with turns created by this
418
1169
  // external runner so the desktop UI can render follow-up messages.
419
1170
  persistExtendedHistory: true,
420
- });
1171
+ }, { timeoutMs: startupRequestTimeout(startupDeadlineAtMs, 'thread/start') });
421
1172
  const startedThreadId = String(started.thread.id);
422
1173
  threadId = startedThreadId;
423
1174
  threadReady = true;
424
1175
  emitMarker('thread', { threadId: startedThreadId });
1176
+ void client.request('thread/name/set', {
1177
+ threadId: startedThreadId,
1178
+ name: `botmux ${args.sessionId.slice(0, 8)}`,
1179
+ }, { timeoutMs: 2_000 }).catch(() => { });
1180
+ return startedThreadId;
1181
+ }
1182
+ /** Emit a signed lifecycle marker (steer_attempt / steer_accepted / completion_race
1183
+ * / unknown_outcome / fatal ...). The worker relays steer_accepted back to the
1184
+ * daemon as a "收到,引导成功" edge and treats fatal as a control-generation kill. */
1185
+ function emitLifecycle(event) {
1186
+ emitMarker('lifecycle', { atMs: Date.now(), ...event });
1187
+ }
1188
+ /**
1189
+ * Fence the runner generation on an UNKNOWN turn/start|turn/steer outcome
1190
+ * (transport / rpc / protocol — never a definite rejection). This is a
1191
+ * control-plane action, not a log line: it emits a signed unknown_outcome + a
1192
+ * signed fatal so the worker calls failCodexAppControlGeneration, and it stops
1193
+ * every subsequent dequeue / steer / final / idle emission. It NEVER emits a
1194
+ * failure final — guessing a final would advance the worker FIFO past a turn
1195
+ * whose true disposition is unknown.
1196
+ */
1197
+ function fenceUnknown(operation, category, turn) {
1198
+ if (generationFenced)
1199
+ return;
1200
+ generationFenced = true;
1201
+ if (turn)
1202
+ turn.phase = 'fenced';
1203
+ emitLifecycle({ kind: 'unknown_outcome', operation, category });
1204
+ emitLifecycle({ kind: 'fatal', operation, category });
1205
+ writeLine(`[codex-app] fenced generation on unknown ${operation} outcome (${category})`);
1206
+ }
1207
+ /**
1208
+ * First-proof-wins canonical binding for an exact-client proof (R4-B2). The
1209
+ * FIRST exact `turn/started` or exact `turn/completed` to prove the canonical
1210
+ * native id wins; any LATER exact proof carrying a DIFFERENT id is a protocol
1211
+ * anomaly (the app-server contradicted itself) → fence. The same id is
1212
+ * idempotent. Returns true if the caller may proceed (proof accepted / already
1213
+ * bound to same id), false if it fenced (caller must stop).
1214
+ */
1215
+ function proveCanonicalExact(turn, id, proof) {
1216
+ if (turn.canonicalNativeTurnId === undefined) {
1217
+ turn.canonicalNativeTurnId = id;
1218
+ turn.identityProof = proof;
1219
+ return true;
1220
+ }
1221
+ if (turn.canonicalNativeTurnId === id)
1222
+ return true; // idempotent, same proof id
1223
+ // A different exact id after canonical was already proven: contradiction.
1224
+ fenceUnknown('turn/start', 'protocol', turn);
1225
+ if (!turn.completed) {
1226
+ turn.completed = true;
1227
+ turn.resolveDone();
1228
+ }
1229
+ return false;
1230
+ }
1231
+ /**
1232
+ * Whether this turn is in "group mode": a steerable root that has actually begun
1233
+ * ordered-steer work (grew past its root, has a steer RPC in flight, or already
1234
+ * closed steering with an accepted member). In group mode EVERY completion and
1235
+ * failure path must be group-aware — a single-turn settle (completeActiveTurnFromNative
1236
+ * / single-root reconcile) followed by finalizeAcceptedGroup's N-final expansion
1237
+ * would mis-map a foreign/partial turn's content onto the last follower. A bare
1238
+ * steerable root that never steered is NOT in group mode (stays single-turn).
1239
+ */
1240
+ function inGroupMode(turn) {
1241
+ return turn.accepted?.[0]?.input.codexAppSteerable === true
1242
+ && (turn.steerInFlight !== undefined || (turn.accepted?.length ?? 0) > 1);
1243
+ }
1244
+ /**
1245
+ * Whether the runner may steer `head` into the active native turn right now.
1246
+ * Mirrors codex's canSteer contract (Blocking 1): the group must be open, its
1247
+ * canonical native id proven, no steer in flight, and BOTH the root and the
1248
+ * follow-up head explicitly authorized by the daemon admission gate. A Goal
1249
+ * root additionally requires its own matching steer to have been accepted.
1250
+ */
1251
+ function canSteer(turn, head) {
1252
+ return !generationFenced
1253
+ && !turn.completed
1254
+ && turn.phase !== 'fenced'
1255
+ && turn.phase !== 'closing'
1256
+ && !turn.steeringClosed
1257
+ && !turn.steerInFlight
1258
+ && !steerAdmitting
1259
+ && turn.canonicalNativeTurnId !== undefined
1260
+ && turn.accepted !== undefined
1261
+ && turn.accepted.length > 0
1262
+ && turn.accepted[0].input.codexAppSteerable === true
1263
+ && head.codexAppSteerable === true;
1264
+ }
1265
+ /**
1266
+ * Verify a candidate terminal turn contains the full ordered steered-group
1267
+ * subsequence: EVERY member that actually sent a clientUserMessageId appears
1268
+ * exactly once, and those user-item indexes are strictly increasing in accepted
1269
+ * order. Returns the LAST member's item index on success (to rebuild the real
1270
+ * answer from it), or a reason string on failure. When no member sent a
1271
+ * clientId, or the turn has no full items, returns { lastIndex: -1 } (caller
1272
+ * trusts streamed text — matches the single-turn contract).
1273
+ */
1274
+ function verifyGroupSubsequence(turn, nativeTurn) {
1275
+ const sentIdMembers = (turn.accepted ?? []).filter(m => m.clientUserMessageId);
1276
+ if (nativeTurn?.itemsView !== 'full' || sentIdMembers.length === 0)
1277
+ return { lastIndex: -1 };
1278
+ let previousIndex = -1;
1279
+ for (const member of sentIdMembers) {
1280
+ const indexes = exactClientItemIndexes(nativeTurn, member.clientUserMessageId);
1281
+ if (indexes.length !== 1 || indexes[0] <= previousIndex) {
1282
+ return {
1283
+ reason: indexes.length === 0
1284
+ ? 'steered group member missing from terminal turn items'
1285
+ : indexes.length > 1
1286
+ ? 'steered group member appears more than once in terminal turn'
1287
+ : 'steered group members are out of order in terminal turn',
1288
+ };
1289
+ }
1290
+ previousIndex = indexes[0];
1291
+ }
1292
+ return { lastIndex: previousIndex };
1293
+ }
1294
+ /**
1295
+ * Settle a steered group against a terminal turn already proven to be its
1296
+ * canonical completion (completedId === canonicalNativeTurnId). Resolves
1297
+ * `turn.done` exactly once; runTurn then expands the group into N finals.
1298
+ *
1299
+ * B5 group-aware identity defense: EVERY member that sent a clientUserMessageId
1300
+ * must appear exactly once in strictly-increasing order (verifyGroupSubsequence);
1301
+ * the real answer is rebuilt from the LAST member's user item so a prior
1302
+ * member's agent text is never mistaken for the final reply. Any violation fails
1303
+ * closed via reportIdentityConflict — the settled final then carries an explicit
1304
+ * identity error, never foreign model text.
1305
+ */
1306
+ function settleSteeredCompletion(turn, nativeTurn) {
1307
+ if (activeTurn !== turn || turn.completed)
1308
+ return;
1309
+ const verdict = verifyGroupSubsequence(turn, nativeTurn);
1310
+ if ('reason' in verdict) {
1311
+ reportIdentityConflict(turn, typeof nativeTurn?.id === 'string' ? nativeTurn.id : undefined, verdict.reason);
1312
+ return;
1313
+ }
1314
+ if (verdict.lastIndex >= 0) {
1315
+ // Rebuild the real answer from the LAST accepted member's user item.
1316
+ turn.finalText = rebuildReconciledFinal(nativeTurn, verdict.lastIndex);
1317
+ turn.allAgentText = '';
1318
+ }
1319
+ turn.terminalCompletion = nativeTurn;
1320
+ if (typeof nativeTurn?.id === 'string' && nativeActiveTurnId === nativeTurn.id) {
1321
+ nativeActiveTurnId = undefined;
1322
+ }
1323
+ turn.completed = true;
1324
+ turn.phase = 'closing';
1325
+ emitTurnActivity(turn, 'completed', true);
1326
+ turn.resolveDone();
1327
+ }
1328
+ /**
1329
+ * Group-aware bounded-history reconcile (codex R3 boundary): a group-mode turn
1330
+ * received a completion whose native id is NOT its canonical id. A non-canonical
1331
+ * completion cannot itself close the group — instead scan bounded history for the
1332
+ * ONE terminal turn that contains the full ordered group subsequence. Only a
1333
+ * unique complete match settles (rebuilding the real answer from its last
1334
+ * member). Zero / multiple / partial (root-only) matches fail closed with an
1335
+ * explicit identity error that carries NO foreign model text — never expand a
1336
+ * follower final from a turn that does not contain the whole group.
1337
+ */
1338
+ async function reconcileSteeredGroupFromHistory(turn, observedNativeTurnId) {
1339
+ if (turn.reconciliation || turn.completed)
1340
+ return turn.reconciliation;
1341
+ if (!threadId) {
1342
+ reportIdentityConflict(turn, observedNativeTurnId, 'steered group reconcile has no thread');
1343
+ return;
1344
+ }
1345
+ const epoch = turn.epoch;
1346
+ const deadlineAtMs = Date.now() + RECONCILIATION_TIMEOUT_MS;
1347
+ turn.reconciliation = (async () => {
1348
+ const matches = [];
1349
+ let cursor;
1350
+ for (let page = 0; page < RECONCILIATION_PAGE_LIMIT; page++) {
1351
+ const remaining = deadlineAtMs - Date.now();
1352
+ if (remaining <= 0)
1353
+ break;
1354
+ const result = await client.request('thread/turns/list', {
1355
+ threadId,
1356
+ ...(cursor ? { cursor } : {}),
1357
+ limit: RECONCILIATION_PAGE_SIZE,
1358
+ sortDirection: 'desc',
1359
+ itemsView: 'full',
1360
+ }, { timeoutMs: remaining });
1361
+ for (const candidate of Array.isArray(result?.data) ? result.data : []) {
1362
+ if (!isTerminalNativeTurn(candidate))
1363
+ continue;
1364
+ const verdict = verifyGroupSubsequence(turn, candidate);
1365
+ // Only a turn containing the FULL ordered group counts. A root-only turn
1366
+ // yields a 'reason' (later members missing) and is skipped, never matched.
1367
+ if ('lastIndex' in verdict && verdict.lastIndex >= 0) {
1368
+ matches.push({ turn: candidate, lastIndex: verdict.lastIndex });
1369
+ }
1370
+ }
1371
+ cursor = typeof result?.nextCursor === 'string' ? result.nextCursor : null;
1372
+ if (!cursor)
1373
+ break;
1374
+ }
1375
+ if (activeTurn !== turn || turn.epoch !== epoch || turn.completed)
1376
+ return;
1377
+ if (matches.length === 1) {
1378
+ const { turn: matched, lastIndex } = matches[0];
1379
+ turn.finalText = rebuildReconciledFinal(matched, lastIndex);
1380
+ turn.allAgentText = '';
1381
+ turn.terminalCompletion = matched;
1382
+ // R4-B3: the matched turn is now the authoritative native identity for this
1383
+ // group — the final marker, native id, and usage drain must all key off
1384
+ // matched.id, not the stale pre-history canonical. Compare-and-clear the OLD
1385
+ // canonical from the global native-busy slot ONLY if it is still the head
1386
+ // there; if a newer autonomous Goal C has since claimed nativeActiveTurnId,
1387
+ // leave C untouched (never clear/overwrite a newer lifecycle).
1388
+ if (typeof matched?.id === 'string') {
1389
+ const priorCanonical = turn.canonicalNativeTurnId;
1390
+ if (priorCanonical !== undefined && nativeActiveTurnId === priorCanonical) {
1391
+ nativeActiveTurnId = undefined;
1392
+ }
1393
+ turn.nativeTurnId = matched.id;
1394
+ turn.canonicalNativeTurnId = matched.id;
1395
+ if (nativeActiveTurnId === matched.id)
1396
+ nativeActiveTurnId = undefined;
1397
+ }
1398
+ turn.completed = true;
1399
+ turn.phase = 'closing';
1400
+ emitTurnActivity(turn, 'completed', true);
1401
+ turn.resolveDone();
1402
+ return;
1403
+ }
1404
+ reportIdentityConflict(turn, observedNativeTurnId, matches.length === 0
1405
+ ? 'steered group: no terminal turn contains the full ordered group'
1406
+ : 'steered group: multiple terminal turns match the full group');
1407
+ })().catch(err => {
1408
+ if (activeTurn === turn && !turn.completed) {
1409
+ reportIdentityConflict(turn, observedNativeTurnId, `steered group history lookup failed: ${asError(err).message}`);
1410
+ }
1411
+ });
1412
+ await turn.reconciliation;
1413
+ }
1414
+ /**
1415
+ * Opportunistically admit the queue head as a pre-final `turn/steer` into the
1416
+ * active steerable group (Blocking 1). Kicked from enqueueLine, from runTurn
1417
+ * once the root's canonical id is proven, and re-kicked after each success to
1418
+ * chain successive follow-ups. At most one steer RPC runs at a time
1419
+ * (steerAdmitting + steerInFlight). The queue head is shifted ONLY after the
1420
+ * steer is accepted; a definite rejection closes steering; an unknown outcome
1421
+ * fences the generation (never falls back to a fresh start that would reorder).
1422
+ */
1423
+ async function tryAdmitSteer() {
1424
+ const turn = activeTurn;
1425
+ if (!turn)
1426
+ return;
1427
+ const head = queue[0];
1428
+ if (!head || !canSteer(turn, head))
1429
+ return;
1430
+ steerAdmitting = true;
1431
+ const expectedTurnId = turn.canonicalNativeTurnId;
1432
+ const replyTurnId = head.replyTurnId;
1433
+ const version = head.codexAppInput ? detectedCodexVersion() : undefined;
1434
+ const requestClientId = head.codexAppInput
1435
+ && !cleanInputUnsupported
1436
+ && supportsClientUserMessageId(version)
1437
+ && replyTurnId
1438
+ ? replyTurnId
1439
+ : head.codexAppInput?.clientUserMessageId;
1440
+ const dispatch = {
1441
+ input: head,
1442
+ ...(replyTurnId ? { replyTurnId } : {}),
1443
+ ...(requestClientId ? { clientUserMessageId: requestClientId } : {}),
1444
+ receivedAtMs: head.receivedAtMs ?? Date.now(),
1445
+ };
1446
+ turn.steerInFlight = { dispatch, expectedTurnId };
1447
+ const built = buildCodexAppTurnStartParams({
1448
+ threadId: threadId,
1449
+ cwd: args.cwd,
1450
+ legacyContent: head.content,
1451
+ codexAppInput: head.codexAppInput,
1452
+ codexVersion: version,
1453
+ structuredDisabled: cleanInputUnsupported,
1454
+ });
1455
+ if (built.structured && requestClientId)
1456
+ built.params.clientUserMessageId = requestClientId;
1457
+ // steer_attempt/steer_accepted share appTurnId (the canonical native id) so the
1458
+ // worker can correlate them; replyTurnId selects the follow-up's reply routing.
1459
+ emitLifecycle({
1460
+ kind: 'steer_attempt',
1461
+ appTurnId: expectedTurnId,
1462
+ ...(replyTurnId ? { replyTurnId } : {}),
1463
+ });
1464
+ let result;
425
1465
  try {
426
- await client.request('thread/name/set', {
427
- threadId: startedThreadId,
428
- name: `botmux ${args.sessionId.slice(0, 8)}`,
1466
+ const { input, clientUserMessageId, additionalContext } = built.params;
1467
+ result = await client.request('turn/steer', {
1468
+ threadId: threadId,
1469
+ input,
1470
+ expectedTurnId,
1471
+ ...(clientUserMessageId ? { clientUserMessageId } : {}),
1472
+ ...(additionalContext ? { additionalContext } : {}),
429
1473
  });
430
1474
  }
431
- catch { /* naming is cosmetic */ }
432
- return startedThreadId;
1475
+ catch (err) {
1476
+ turn.steerInFlight = undefined;
1477
+ steerAdmitting = false;
1478
+ if (isDefiniteRpcRejection(err)) {
1479
+ // Definite rejection (nit: full controller rejection vocabulary, incl.
1480
+ // -32600/-32601/-32602 and the explicit "not steerable / expected turn"
1481
+ // phrases): the head did NOT land. Do not shift/append. Close steering;
1482
+ // once this native turn completes the head starts its own turn.
1483
+ turn.steeringClosed = true;
1484
+ emitLifecycle({ kind: 'steer_rejected_fallback', appTurnId: expectedTurnId, category: 'definite_rejection' });
1485
+ if (turn.completionSeen && turn.terminalCompletion)
1486
+ settleSteeredCompletion(turn, turn.terminalCompletion);
1487
+ return;
1488
+ }
1489
+ // Unknown outcome (transport/timeout/generic rpc/protocol): fence — never
1490
+ // guess a final for a turn whose acceptance is in doubt.
1491
+ const category = err instanceof AppServerRpcError ? 'rpc' : 'transport';
1492
+ fenceUnknown('turn/steer', category, turn);
1493
+ if (!turn.completed) {
1494
+ turn.completed = true;
1495
+ turn.resolveDone();
1496
+ }
1497
+ return;
1498
+ }
1499
+ // Only `result.turnId === expectedTurnId` proves acceptance into this group.
1500
+ if (result?.turnId !== expectedTurnId) {
1501
+ turn.steerInFlight = undefined;
1502
+ steerAdmitting = false;
1503
+ fenceUnknown('turn/steer', 'protocol', turn);
1504
+ if (!turn.completed) {
1505
+ turn.completed = true;
1506
+ turn.resolveDone();
1507
+ }
1508
+ return;
1509
+ }
1510
+ // The accepted steer must correspond to the exact queue head we sent (nit): a
1511
+ // divergence means an out-of-band mutation reordered the FIFO under us, so the
1512
+ // append below would attribute this acceptance to the wrong input. Fence
1513
+ // rather than silently append a mismatched member.
1514
+ if (queue[0] !== head) {
1515
+ turn.steerInFlight = undefined;
1516
+ steerAdmitting = false;
1517
+ fenceUnknown('turn/steer', 'protocol', turn);
1518
+ if (!turn.completed) {
1519
+ turn.completed = true;
1520
+ turn.resolveDone();
1521
+ }
1522
+ return;
1523
+ }
1524
+ queue.shift();
1525
+ turn.accepted.push(dispatch);
1526
+ turn.steerInFlight = undefined;
1527
+ steerAdmitting = false;
1528
+ emitLifecycle({
1529
+ kind: 'steer_accepted',
1530
+ appTurnId: expectedTurnId,
1531
+ ...(replyTurnId ? { replyTurnId } : {}),
1532
+ });
1533
+ // A completion may have arrived while this steer was in flight (barrier): settle
1534
+ // now that the group is final. Otherwise chain the next queued follow-up.
1535
+ if (turn.completionSeen && turn.terminalCompletion) {
1536
+ settleSteeredCompletion(turn, turn.terminalCompletion);
1537
+ return;
1538
+ }
1539
+ void tryAdmitSteer();
433
1540
  }
434
- function prepareControllerInput(message, structuredDisabled) {
435
- const version = message.codexAppInput || message.replyTurnId
436
- ? detectedCodexVersion()
437
- : undefined;
438
- const built = buildCodexAppTurnStartParams({
439
- threadId: threadId ?? '',
1541
+ /**
1542
+ * Emit the final transaction(s) for a completed native turn, expanding the
1543
+ * ordered `accepted` group into N signed finals (Blocking 1, decision A/B/C).
1544
+ *
1545
+ * - N === 1 (every existing path: single start, Goal-steer root, legacy): emits
1546
+ * exactly one final, byte-identical to the pre-driver single-final contract.
1547
+ * - N > 1 (a plain-Lark root that absorbed pre-final `turn/steer` follow-ups):
1548
+ * the FIRST N−1 members get an empty `steer_superseded` final (no content, no
1549
+ * usage, not delivered — only advances the worker FIFO); the LAST member owns
1550
+ * the real model answer + usage. The real final's startedAtMs is the last
1551
+ * accepted dispatch's receivedAtMs so an early botmux-send marker from before
1552
+ * the follow-up arrived cannot suppress the true answer.
1553
+ *
1554
+ * A fenced turn (unknown outcome) emits ZERO finals — the signed fatal lifecycle
1555
+ * already tore the generation down; guessing a final would advance the FIFO.
1556
+ */
1557
+ function finalizeAcceptedGroup(turn) {
1558
+ if (turn.phase === 'fenced')
1559
+ return;
1560
+ const group = turn.accepted && turn.accepted.length > 0
1561
+ ? turn.accepted
1562
+ : [{ input: { content: '' }, receivedAtMs: turn.startedAtMs }];
1563
+ const finalText = (turn.finalText || turn.allAgentText).trim();
1564
+ const completedAtMs = Date.now();
1565
+ const lastIndex = group.length - 1;
1566
+ for (let index = 0; index < group.length; index++) {
1567
+ const dispatch = group[index];
1568
+ const isReal = index === lastIndex;
1569
+ const replyTurnId = dispatch.replyTurnId;
1570
+ emitFinalMarker({
1571
+ ...(replyTurnId ? { turnId: replyTurnId } : {}),
1572
+ ...(turn.nativeTurnId ? { nativeTurnId: turn.nativeTurnId } : {}),
1573
+ content: isReal ? finalText : '',
1574
+ // The real answer's clock starts at the LAST accepted dispatch so a
1575
+ // botmux-send marker emitted before the follow-up landed cannot race
1576
+ // ahead and suppress it. A single-member group keeps the turn's own
1577
+ // startedAtMs (byte-identical to the pre-driver contract).
1578
+ startedAtMs: isReal
1579
+ ? (group.length > 1 ? dispatch.receivedAtMs : turn.startedAtMs)
1580
+ : turn.startedAtMs,
1581
+ completedAtMs,
1582
+ }, isReal ? {} : { disposition: 'steer_superseded' });
1583
+ }
1584
+ }
1585
+ async function runTurn(message) {
1586
+ const tid = await ensureThread();
1587
+ // The botmux/Lark reply identity is the top-level replyTurnId (which already
1588
+ // fell back to the sidecar clientUserMessageId at decode time). This is the id
1589
+ // exposed on the final marker for reply routing; the app-server's native turn
1590
+ // id is tracked separately as nativeTurnId.
1591
+ const replyTurnId = message.replyTurnId;
1592
+ const version = message.codexAppInput ? detectedCodexVersion() : undefined;
1593
+ // When structured input is supported, thread the reply id through as the
1594
+ // request's clientUserMessageId so app-server echoes it as item.clientId
1595
+ // (used for exact native-turn attribution). Mirrors master's prepareControllerInput.
1596
+ const requestClientId = message.codexAppInput
1597
+ && !cleanInputUnsupported
1598
+ && supportsClientUserMessageId(version)
1599
+ && replyTurnId
1600
+ ? replyTurnId
1601
+ : message.codexAppInput?.clientUserMessageId;
1602
+ let expectedSteerTurnId = nativeActiveTurnId;
1603
+ const turn = makeTurn(requestClientId, expectedSteerTurnId ? 'steer' : 'start');
1604
+ // The root dispatch is accepted[0]. Follow-up Lark inputs that win a pre-final
1605
+ // turn/steer are appended by the steer-admission path; at native completion
1606
+ // finalizeAcceptedGroup expands the group into N signed finals.
1607
+ turn.accepted = [{
1608
+ input: message,
1609
+ ...(replyTurnId ? { replyTurnId } : {}),
1610
+ ...(requestClientId ? { clientUserMessageId: requestClientId } : {}),
1611
+ receivedAtMs: message.receivedAtMs ?? turn.startedAtMs,
1612
+ }];
1613
+ if (expectedSteerTurnId) {
1614
+ turn.nativeTurnId = expectedSteerTurnId;
1615
+ turn.serverStarted = true;
1616
+ }
1617
+ else {
1618
+ // B1: a plain start request is outstanding. startResponsePending is SEPARATE
1619
+ // from steerInFlight — an exact `turn/started` can prove the canonical id and
1620
+ // open pre-final steering while this start response is still pending, and a
1621
+ // late start response must still bind/verify the same canonical id.
1622
+ turn.startResponsePending = true;
1623
+ }
1624
+ activeTurn = turn;
1625
+ // This edge proves the runner decoded and dequeued Botmux's control line,
1626
+ // even if app-server stalls before acknowledging turn/start.
1627
+ emitTurnActivity(turn, 'submitted', true);
1628
+ let built = buildCodexAppTurnStartParams({
1629
+ threadId: tid,
440
1630
  cwd: args.cwd,
441
1631
  legacyContent: message.content,
442
1632
  codexAppInput: message.codexAppInput,
443
1633
  codexVersion: version,
444
- structuredDisabled,
1634
+ structuredDisabled: cleanInputUnsupported,
445
1635
  });
446
- if (message.codexAppInput
447
- && !built.structured
448
- && !structuredDisabled
449
- && !cleanVersionWarningShown) {
1636
+ // Override the built clientUserMessageId with the reply id when the sidecar
1637
+ // omitted one (e.g. a top-level replyTurnId with no clientUserMessageId).
1638
+ if (built.structured && requestClientId) {
1639
+ built.params.clientUserMessageId = requestClientId;
1640
+ }
1641
+ if (message.codexAppInput && !built.structured && !cleanInputUnsupported && !cleanVersionWarningShown) {
450
1642
  cleanVersionWarningShown = true;
451
1643
  const found = version ? `${version.major}.${version.minor}.${version.patch}` : 'unknown';
452
1644
  writeLine(`[codex-app] clean input requires codex >= 0.135.0 (found ${found}); using legacy prompt`);
453
1645
  }
454
- const clientUserMessageId = !structuredDisabled
455
- && message.replyTurnId
456
- && version
457
- && supportsClientUserMessageId(version)
458
- ? message.replyTurnId
459
- : built.params.clientUserMessageId;
460
- return {
461
- input: built.params.input,
462
- ...(built.params.additionalContext
463
- ? { additionalContext: built.params.additionalContext }
464
- : {}),
465
- ...(clientUserMessageId ? { clientUserMessageId } : {}),
466
- visibleText: message.codexAppInput?.text ?? message.content,
467
- structured: built.structured,
468
- skippedImages: built.skippedImages,
1646
+ for (const path of built.skippedImages) {
1647
+ writeLine(`[codex-app] skipped unreadable local image: ${path}`);
1648
+ }
1649
+ writeLine();
1650
+ writeLine('[user]');
1651
+ writeLine(built.structured && message.codexAppInput ? message.codexAppInput.text : message.content);
1652
+ writeLine();
1653
+ const requestBuiltTurn = (candidate) => {
1654
+ if (!expectedSteerTurnId)
1655
+ return client.request('turn/start', candidate.params);
1656
+ const { threadId, input, clientUserMessageId, additionalContext } = candidate.params;
1657
+ return client.request('turn/steer', {
1658
+ threadId,
1659
+ input,
1660
+ expectedTurnId: expectedSteerTurnId,
1661
+ ...(clientUserMessageId ? { clientUserMessageId } : {}),
1662
+ ...(additionalContext ? { additionalContext } : {}),
1663
+ });
469
1664
  };
1665
+ let result;
1666
+ let capabilityRetried = false;
1667
+ let inactiveSteerFallback = false;
1668
+ for (;;) {
1669
+ try {
1670
+ result = await requestBuiltTurn(built);
1671
+ break;
1672
+ }
1673
+ catch (err) {
1674
+ if (expectedSteerTurnId
1675
+ && !inactiveSteerFallback
1676
+ && nativeActiveTurnId === undefined
1677
+ && isExplicitExpectedTurnInactive(err)) {
1678
+ // turn/steer was explicitly rejected before acceptance and the signed
1679
+ // native completion already proved the captured Goal turn is gone.
1680
+ // Starting the same client-id input is safe; timeout/transport/generic
1681
+ // errors never enter this branch.
1682
+ inactiveSteerFallback = true;
1683
+ expectedSteerTurnId = undefined;
1684
+ turn.requestKind = 'start';
1685
+ turn.nativeTurnId = undefined;
1686
+ turn.serverStarted = false;
1687
+ turn.pendingCompletions.length = 0;
1688
+ turn.pendingNotifications.length = 0;
1689
+ turn.finalText = '';
1690
+ turn.allAgentText = '';
1691
+ turn.itemText.clear();
1692
+ writeLine('[codex-app] captured native turn completed before steer acceptance; starting the same client-id input as a new turn');
1693
+ continue;
1694
+ }
1695
+ if (capabilityRetried
1696
+ || !built.structured
1697
+ || (!expectedSteerTurnId && turn.serverStarted)
1698
+ || !isCleanInputCapabilityError(err)) {
1699
+ // Nit: the fenced RPC's operation reflects what we actually sent — a Goal
1700
+ // root steers (expectedSteerTurnId set), a plain root starts — so the
1701
+ // signed unknown_outcome/fatal diagnostic is accurate.
1702
+ const fenceOperation = expectedSteerTurnId ? 'turn/steer' : 'turn/start';
1703
+ // R3-B1 fail-closed on positive evidence: once ANY evidence exists that
1704
+ // this native turn may have started or grown — an exact `turn/started`
1705
+ // proof, a server-started edge, or an already-accepted follow-up (group
1706
+ // grew past its root) — a start-RPC failure of ANY kind (even an explicit
1707
+ // RPC error) must FENCE, never synthesize a failure final. Synthesizing a
1708
+ // single root failure would (a) misreport an actually-started turn and
1709
+ // (b) if a follow-up was already steer-accepted and shifted, settle only
1710
+ // the root and strand the follow-up in the worker FIFO (poison). The
1711
+ // durable recovery contract has no authoritative response record proving
1712
+ // this generation can safely continue, so we tear it down.
1713
+ const positiveEvidence = turn.serverStarted
1714
+ || turn.identityProof !== undefined
1715
+ || turn.canonicalNativeTurnId !== undefined
1716
+ || (turn.accepted?.length ?? 0) > 1;
1717
+ if (positiveEvidence) {
1718
+ fenceUnknown(fenceOperation, err instanceof AppServerRpcError ? 'rpc' : 'transport', turn);
1719
+ if (!turn.completed) {
1720
+ turn.completed = true;
1721
+ turn.resolveDone();
1722
+ }
1723
+ return;
1724
+ }
1725
+ // No positive evidence yet. A non-RPC failure (timeout / transport drop)
1726
+ // leaves acceptance genuinely UNKNOWN → fence. An explicit server error
1727
+ // response (any AppServerRpcError, e.g. -32000 "model overloaded")
1728
+ // definitively proves the turn did NOT run → stays on the
1729
+ // throw→failure-final path (a clean, safely-attributed failure).
1730
+ if (!(err instanceof AppServerRpcError)) {
1731
+ fenceUnknown(fenceOperation, 'transport', turn);
1732
+ if (!turn.completed) {
1733
+ turn.completed = true;
1734
+ turn.resolveDone();
1735
+ }
1736
+ return;
1737
+ }
1738
+ throw err;
1739
+ }
1740
+ // The app-server explicitly rejected the experimental field before a turn
1741
+ // started. Disable structured input for this runner lifetime and retry the
1742
+ // preserved legacy prompt exactly once.
1743
+ capabilityRetried = true;
1744
+ cleanInputUnsupported = true;
1745
+ writeLine('[codex-app] clean input unsupported by app-server; retrying this turn with the legacy prompt');
1746
+ built = buildCodexAppTurnStartParams({
1747
+ threadId: tid,
1748
+ cwd: args.cwd,
1749
+ legacyContent: message.content,
1750
+ codexAppInput: message.codexAppInput,
1751
+ codexVersion: version,
1752
+ structuredDisabled: true,
1753
+ });
1754
+ }
1755
+ }
1756
+ const responseNativeId = result.turn?.id ?? result.turnId ?? turn.nativeTurnId;
1757
+ // B1/R4-B2 late start response: if an exact proof (turn/started OR completed)
1758
+ // already bound a canonical id, the start response MUST match it — a divergent
1759
+ // id is a protocol anomaly whose true turn is unknown → fence, never bind a
1760
+ // second id. Otherwise the start response is what proves canonical.
1761
+ if ((turn.identityProof === 'exact_started' || turn.identityProof === 'exact_completed')
1762
+ && turn.canonicalNativeTurnId
1763
+ && responseNativeId
1764
+ && responseNativeId !== turn.canonicalNativeTurnId) {
1765
+ turn.startResponsePending = false;
1766
+ fenceUnknown('turn/start', 'protocol', turn);
1767
+ if (!turn.completed) {
1768
+ turn.completed = true;
1769
+ turn.resolveDone();
1770
+ }
1771
+ return;
1772
+ }
1773
+ turn.nativeTurnId = responseNativeId;
1774
+ turn.requestAccepted = true;
1775
+ turn.startResponsePending = false;
1776
+ // The root request is accepted → its native id is now canonical for the group,
1777
+ // so pre-final follow-up steers may bind against it (canSteer). A plain start
1778
+ // is proven by its start-response; a Goal-continuation root is proven by the
1779
+ // accepted matching steer (identityProof left as the Goal snapshot). An exact
1780
+ // `turn/started` may already have upgraded identityProof to 'exact_started'.
1781
+ if (!turn.completed && turn.nativeTurnId) {
1782
+ turn.canonicalNativeTurnId = turn.nativeTurnId;
1783
+ if (!turn.identityProof) {
1784
+ turn.identityProof = turn.requestKind === 'steer' ? 'goal_snapshot' : 'start_response';
1785
+ }
1786
+ if (turn.phase === undefined || turn.phase === 'starting')
1787
+ turn.phase = 'open';
1788
+ }
1789
+ // A response may arrive after its turn completed or after a Goal
1790
+ // continuation B already started. Never resurrect the completed lifecycle,
1791
+ // and never let late response A overwrite the newer global lifecycle. If A
1792
+ // is still pending and no newer turn exists, temporarily restoring A is safe
1793
+ // because the buffered A completion below clears it immediately.
1794
+ if (!turn.completed
1795
+ && turn.nativeTurnId
1796
+ && (nativeActiveTurnId === undefined || nativeActiveTurnId === turn.nativeTurnId)) {
1797
+ nativeActiveTurnId = turn.nativeTurnId;
1798
+ }
1799
+ // B4: replay buffered notifications/completions BEFORE any steer kick. A
1800
+ // completion buffered during the RPC (completion-before-response) must set
1801
+ // completionSeen / close steering here so the kick below sees a closed group
1802
+ // and refuses — otherwise we would steer into an already-completed turn.
1803
+ // B1+B4 / R3-B2: if a completion was buffered as a barrier while this start
1804
+ // response was pending — either a grown steered group (completion_race), or a
1805
+ // single steerable root whose exact-completion was upgraded to canonical proof
1806
+ // before the response — settle it now that the response resolved and matched
1807
+ // the canonical id. settleSteeredCompletion verifies the ordered group
1808
+ // subsequence (1-member is a valid group) and fails closed on any mismatch, so
1809
+ // a foreign/partial buffered completion can never expand a wrong final.
1810
+ // R4-B2 defense-in-depth: the buffered terminal's id MUST equal the proven
1811
+ // canonical id before we settle from it — a first-proof-wins violation upstream
1812
+ // would otherwise let terminal A's content ship under a different native id.
1813
+ const bufferedTerminalMatchesCanonical = turn.terminalCompletion !== undefined
1814
+ && (typeof turn.terminalCompletion.id !== 'string'
1815
+ || turn.terminalCompletion.id === turn.canonicalNativeTurnId);
1816
+ const settleBufferedCanonical = !turn.completed
1817
+ && turn.completionSeen
1818
+ && turn.terminalCompletion
1819
+ && !turn.steerInFlight
1820
+ && bufferedTerminalMatchesCanonical
1821
+ && turn.accepted?.[0]?.input.codexAppSteerable === true
1822
+ && ((turn.accepted?.length ?? 0) > 1
1823
+ || turn.identityProof === 'exact_started'
1824
+ || turn.identityProof === 'exact_completed');
1825
+ if (settleBufferedCanonical) {
1826
+ settleSteeredCompletion(turn, turn.terminalCompletion);
1827
+ }
1828
+ const pendingNotifications = turn.pendingNotifications.splice(0);
1829
+ for (const notification of pendingNotifications) {
1830
+ const notificationTurnId = notification.params?.turnId ?? notification.params?.turn?.id;
1831
+ if (notificationTurnId === turn.nativeTurnId)
1832
+ handleNotification(notification, true);
1833
+ }
1834
+ const pendingCompletions = turn.pendingCompletions.splice(0);
1835
+ if (pendingCompletions.length > 0 && !turn.completed) {
1836
+ const attributionClientId = turn.clientUserMessageId;
1837
+ const exactMatches = attributionClientId
1838
+ ? pendingCompletions.flatMap(completion => {
1839
+ const indexes = exactClientItemIndexes(completion, attributionClientId);
1840
+ return indexes.length === 1 ? [{ completion, itemIndex: indexes[0] }] : [];
1841
+ })
1842
+ : [];
1843
+ const nativeMatches = pendingCompletions.filter(completion => completion?.id === turn.nativeTurnId);
1844
+ if (exactMatches.length === 1) {
1845
+ completeActiveTurnFromNative(turn, exactMatches[0].completion, exactMatches[0].itemIndex);
1846
+ }
1847
+ else if (exactMatches.length > 1 || nativeMatches.length > 1) {
1848
+ reportIdentityConflict(turn, turn.nativeTurnId, 'multiple pre-response completions matched one request');
1849
+ }
1850
+ else if (turn.requestKind === 'steer' && nativeMatches.length === 1) {
1851
+ void reconcileCompletedTurn(turn, turn.nativeTurnId);
1852
+ }
1853
+ else if (turn.requestKind === 'start' && nativeMatches.length === 1) {
1854
+ completeActiveTurnFromNative(turn, nativeMatches[0]);
1855
+ }
1856
+ }
1857
+ // B4: only NOW, after buffered completions were replayed, admit a follow-up.
1858
+ // A follow-up that arrived during the RPC (before canonical was proven) steers
1859
+ // here; if the group already closed (completion-before-response), canSteer
1860
+ // refuses and it stays serial. Never kick before the replay above.
1861
+ if (!turn.completed)
1862
+ void tryAdmitSteer();
1863
+ await turn.done;
1864
+ // Expand the ordered accepted group into N signed finals (N===1 for every
1865
+ // pre-driver path — single start, Goal-steer root, legacy — byte-identical to
1866
+ // the old single-final contract). clientUserMessageId is the daemon-frozen
1867
+ // botmux/Lark turn identity; the app-server generates a different native id
1868
+ // for the same logical turn, exposing which as `turnId` would break daemon
1869
+ // wait maps / VC suppression / reply routing. When no structured sidecar
1870
+ // exists, turnId is omitted so the worker resolves it from its own FIFO head.
1871
+ finalizeAcceptedGroup(turn);
1872
+ writeLine();
1873
+ activeTurn = null;
1874
+ }
1875
+ async function drainQueue() {
1876
+ if (processing)
1877
+ return;
1878
+ processing = true;
1879
+ try {
1880
+ while (queue.length > 0) {
1881
+ // A fenced generation must not consume any more input: an unknown
1882
+ // turn/start|turn/steer outcome left a head whose disposition is unknown,
1883
+ // and starting the next queued input would submit against a poisoned FIFO.
1884
+ // The signed fatal already tore the generation down; stop draining.
1885
+ if (generationFenced)
1886
+ break;
1887
+ // Goal gate (B3): while an autonomous Goal continuation is native-busy,
1888
+ // only a daemon-authorized steerable head may enter it (as a Goal-steer
1889
+ // root). A missing/false-flag head must stay serial — leave it in the
1890
+ // queue and wait for the Goal to complete, at which point turn/completed's
1891
+ // no-active-turn branch re-kicks drainQueue so the head starts its own
1892
+ // turn. Without this a special-sink / non-interactive input would be
1893
+ // silently merged into the Goal turn and its output mis-delivered.
1894
+ if (nativeActiveTurnId !== undefined && queue[0].codexAppSteerable !== true) {
1895
+ break;
1896
+ }
1897
+ const next = queue.shift();
1898
+ try {
1899
+ await runTurn(next);
1900
+ }
1901
+ catch (err) {
1902
+ // A fenced generation already emitted the signed unknown_outcome+fatal;
1903
+ // never synthesize a failure final on top (it would advance the worker
1904
+ // FIFO past a turn whose true disposition is unknown).
1905
+ if (generationFenced) {
1906
+ activeTurn = null;
1907
+ break;
1908
+ }
1909
+ const message = `Codex App runner error: ${err?.message ?? err}`;
1910
+ const completedAtMs = Date.now();
1911
+ const replyTurnId = next.replyTurnId;
1912
+ const nativeTurnId = activeTurn?.nativeTurnId;
1913
+ writeLine(message);
1914
+ emitFinalMarker({
1915
+ ...(replyTurnId ? { turnId: replyTurnId } : {}),
1916
+ ...(nativeTurnId ? { nativeTurnId } : {}),
1917
+ content: message,
1918
+ startedAtMs: activeTurn?.startedAtMs ?? completedAtMs,
1919
+ completedAtMs,
1920
+ });
1921
+ activeTurn = null;
1922
+ }
1923
+ // Do not publish a transient idle boundary between inputs already queued
1924
+ // in the serial runner. Once the queue is truly empty, append signed
1925
+ // busy:false only AFTER completed + every final fragment. The worker can
1926
+ // therefore become ready even if the terminal prompt is lost, while its
1927
+ // IPC order remains final_output before prompt_ready.
1928
+ //
1929
+ // A queue that is non-empty only because its head is a false-flag input
1930
+ // parked behind an active Goal (the B3 gate above) is NOT drainable now:
1931
+ // treat it like an empty queue so the runner still advertises native-busy
1932
+ // and never wedges without an idle/ready edge.
1933
+ const parkedBehindGoal = queue.length > 0
1934
+ && nativeActiveTurnId !== undefined
1935
+ && queue[0].codexAppSteerable !== true;
1936
+ if (queue.length === 0 || parkedBehindGoal) {
1937
+ const nativeBusy = nativeActiveTurnId !== undefined;
1938
+ emitRunnerState(nativeBusy, !nativeBusy);
1939
+ if (!nativeBusy)
1940
+ prompt();
1941
+ }
1942
+ }
1943
+ }
1944
+ finally {
1945
+ processing = false;
1946
+ }
470
1947
  }
471
- controller = new CodexAppTurnController({
472
- cwd: args.cwd,
473
- ensureThread,
474
- request: (method, params) => client.request(method, params),
475
- prepareInput: prepareControllerInput,
476
- isStartCapabilityError: isCleanInputCapabilityError,
477
- onTurnInput(_input, prepared) {
478
- writeLine();
479
- writeLine('[user]');
480
- writeLine(prepared.visibleText);
481
- writeLine();
482
- },
483
- onOutput: text => output.display(text),
484
- onDiagnostic: writeLine,
485
- onLifecycle: event => emitMarker('lifecycle', event),
486
- onFinal: marker => {
487
- // Attach this turn's token usage (if the accumulator saw coherent totals)
488
- // and drain its accumulator. Omitted when no usage was observed — never zeros.
489
- const acc = marker.appTurnId ? usageAccumulators.get(marker.appTurnId) : undefined;
490
- const usage = acc?.result() ?? undefined;
491
- // Surface a protocol anomaly rather than silently omitting usage — a
492
- // regression/negative-baseline should be visible in the runner log.
493
- if (acc?.warning && !usage) {
494
- writeLine(`[codex-app] token usage dropped for turn ${marker.appTurnId ?? '?'}: ${acc.warning}`);
495
- }
496
- if (marker.appTurnId)
497
- usageAccumulators.delete(marker.appTurnId);
498
- emitMarker('final', usage ? { ...marker, usage } : marker);
499
- writeLine();
500
- },
501
- onPrompt: prompt,
502
- });
503
1948
  function enqueueLine(line) {
504
1949
  const trimmed = line.trim();
505
1950
  if (!trimmed)
506
1951
  return;
507
- if (trimmed.startsWith(CODEX_APP_INPUT_PREFIX)) {
508
- const decoded = decodeCodexAppRunnerInput(trimmed);
509
- if (decoded)
510
- controller.enqueue(decoded);
511
- else
512
- writeLine('[codex-app] bad botmux input');
1952
+ if (trimmed.startsWith('::botmux-codex-app:')) {
1953
+ const encoded = trimmed.slice('::botmux-codex-app:'.length);
1954
+ try {
1955
+ const decoded = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'));
1956
+ if (decoded?.type === 'message' && typeof decoded.content === 'string') {
1957
+ const codexAppInput = isCodexAppTurnInput(decoded.codexAppInput)
1958
+ ? decoded.codexAppInput
1959
+ : undefined;
1960
+ if (decoded.codexAppInput !== undefined && !codexAppInput) {
1961
+ writeLine('[codex-app] ignored invalid structured input sidecar');
1962
+ }
1963
+ // Reply routing identity: prefer the top-level replyTurnId, else fall
1964
+ // back to the structured sidecar's clientUserMessageId (mirrors the
1965
+ // shared decodeCodexAppRunnerInput contract).
1966
+ const replyTurnId = typeof decoded.replyTurnId === 'string' && decoded.replyTurnId.length > 0
1967
+ ? decoded.replyTurnId
1968
+ : codexAppInput?.clientUserMessageId;
1969
+ // Explicit positive only: the daemon admission gate (decision A) sets
1970
+ // `true` solely for a plain-human-interactive turn. Any other value is
1971
+ // treated as absent → forced serial, never a silent steer authorization.
1972
+ const codexAppSteerable = decoded.codexAppSteerable === true;
1973
+ queue.push({
1974
+ content: decoded.content,
1975
+ codexAppInput,
1976
+ ...(replyTurnId ? { replyTurnId } : {}),
1977
+ ...(codexAppSteerable ? { codexAppSteerable: true } : {}),
1978
+ receivedAtMs: Date.now(),
1979
+ });
1980
+ // If a steerable group is active, opportunistically steer this head into
1981
+ // it (pre-final). Otherwise fall through to the serial drain, which
1982
+ // starts it as its own turn once the runner is idle. drainQueue's
1983
+ // `await runTurn(root)` keeps the root active until its group settles, so
1984
+ // a head consumed by tryAdmitSteer is never double-processed by drain.
1985
+ void tryAdmitSteer();
1986
+ void drainQueue();
1987
+ }
1988
+ }
1989
+ catch (err) {
1990
+ writeLine(`[codex-app] bad botmux input: ${err?.message ?? err}`);
1991
+ }
513
1992
  return;
514
1993
  }
515
- controller.enqueue({ type: 'message', content: line });
1994
+ queue.push({ content: line });
1995
+ void drainQueue();
516
1996
  }
517
1997
  function handleInput(data) {
518
1998
  const text = data.toString('utf8');
@@ -534,16 +2014,31 @@ function handleInput(data) {
534
2014
  }
535
2015
  }
536
2016
  async function main() {
2017
+ const testTimeout = process.env.NODE_ENV === 'test'
2018
+ ? Number(process.env.BOTMUX_TEST_CODEX_APP_STARTUP_TIMEOUT_MS)
2019
+ : Number.NaN;
2020
+ const startupTimeoutMs = Number.isFinite(testTimeout) && testTimeout > 0
2021
+ ? testTimeout
2022
+ : 90_000;
2023
+ const startupDeadlineAtMs = Date.now() + startupTimeoutMs;
2024
+ const authTimeout = armCodexAppControlStartupTimeout(() => {
2025
+ console.error('Codex App startup timed out before the first signed runner state');
2026
+ process.exit(2);
2027
+ }, startupTimeoutMs);
2028
+ await controlReady;
2029
+ client = new AppServerClient(args.codexBin, args.cwd);
537
2030
  client.onRequest(handleServerRequest);
538
2031
  client.onNotification(handleNotification);
539
- client.onFatal(error => {
540
- controller.handleFatal(error);
541
- process.exitCode = 1;
542
- process.stdout.write('', () => process.exit(1));
543
- });
544
- await client.initialize();
545
- await ensureThread();
2032
+ await client.initialize(startupRequestTimeout(startupDeadlineAtMs, 'initialize'));
2033
+ await ensureThread(startupDeadlineAtMs);
546
2034
  writeLine('Codex App connected.');
2035
+ runnerReady = true;
2036
+ // Initial readiness is signed too; never rely on terminal rendering as the
2037
+ // only path that releases the worker's first-prompt gate.
2038
+ emitRunnerState(false);
2039
+ // Authentication is deliberately insufficient. Keep the absolute startup
2040
+ // timer armed through initialize + resume/start and the first signed state.
2041
+ clearTimeout(authTimeout);
547
2042
  if (process.stdin.isTTY)
548
2043
  process.stdin.setRawMode(true);
549
2044
  process.stdin.resume();
@@ -551,14 +2046,24 @@ async function main() {
551
2046
  prompt();
552
2047
  }
553
2048
  process.on('SIGTERM', () => {
554
- client.close();
2049
+ if (controlReconnectTimer)
2050
+ clearTimeout(controlReconnectTimer);
2051
+ controlSocket?.destroy();
2052
+ client?.close();
555
2053
  process.exit(0);
556
2054
  });
557
2055
  process.on('SIGINT', () => {
558
- client.close();
2056
+ if (controlReconnectTimer)
2057
+ clearTimeout(controlReconnectTimer);
2058
+ controlSocket?.destroy();
2059
+ client?.close();
559
2060
  process.exit(130);
560
2061
  });
561
2062
  main().catch(err => {
2063
+ if (!runnerReady && err instanceof AppServerRequestTimeoutError) {
2064
+ output.error('Codex App startup timed out before the first signed runner state\n');
2065
+ process.exit(2);
2066
+ }
562
2067
  output.error(`${err?.stack ?? err?.message ?? err}\n`);
563
2068
  process.exit(1);
564
2069
  });