botmux 3.11.0 → 3.12.1

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 (343) hide show
  1. package/dist/.runtime-build-id +1 -1
  2. package/dist/adapters/backend/riff-backend.d.ts +18 -0
  3. package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
  4. package/dist/adapters/backend/riff-backend.js +154 -2
  5. package/dist/adapters/backend/riff-backend.js.map +1 -1
  6. package/dist/adapters/cli/coco.js +3 -3
  7. package/dist/adapters/cli/coco.js.map +1 -1
  8. package/dist/adapters/cli/codex.js +2 -2
  9. package/dist/adapters/cli/codex.js.map +1 -1
  10. package/dist/adapters/cli/kimi.d.ts.map +1 -1
  11. package/dist/adapters/cli/kimi.js +22 -8
  12. package/dist/adapters/cli/kimi.js.map +1 -1
  13. package/dist/adapters/cli/pi.d.ts.map +1 -1
  14. package/dist/adapters/cli/pi.js +3 -1
  15. package/dist/adapters/cli/pi.js.map +1 -1
  16. package/dist/bot-registry.d.ts.map +1 -1
  17. package/dist/bot-registry.js +4 -1
  18. package/dist/bot-registry.js.map +1 -1
  19. package/dist/cli/dashboard-command.d.ts +23 -0
  20. package/dist/cli/dashboard-command.d.ts.map +1 -0
  21. package/dist/cli/dashboard-command.js +61 -0
  22. package/dist/cli/dashboard-command.js.map +1 -0
  23. package/dist/cli/dashboard-endpoint.d.ts +3 -3
  24. package/dist/cli/dashboard-endpoint.d.ts.map +1 -1
  25. package/dist/cli/dashboard-endpoint.js +1 -1
  26. package/dist/cli.d.ts.map +1 -1
  27. package/dist/cli.js +101 -49
  28. package/dist/cli.js.map +1 -1
  29. package/dist/codex-rpc-engine.d.ts +33 -3
  30. package/dist/codex-rpc-engine.d.ts.map +1 -1
  31. package/dist/codex-rpc-engine.js +182 -20
  32. package/dist/codex-rpc-engine.js.map +1 -1
  33. package/dist/codex-rpc-lifecycle.d.ts +42 -7
  34. package/dist/codex-rpc-lifecycle.d.ts.map +1 -1
  35. package/dist/codex-rpc-lifecycle.js +73 -7
  36. package/dist/codex-rpc-lifecycle.js.map +1 -1
  37. package/dist/config.d.ts +1 -1
  38. package/dist/config.js +1 -1
  39. package/dist/core/command-handler.d.ts +3 -2
  40. package/dist/core/command-handler.d.ts.map +1 -1
  41. package/dist/core/command-handler.js +19 -4
  42. package/dist/core/command-handler.js.map +1 -1
  43. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  44. package/dist/core/dashboard-ipc-server.js +16 -11
  45. package/dist/core/dashboard-ipc-server.js.map +1 -1
  46. package/dist/core/dashboard-rows.d.ts +17 -1
  47. package/dist/core/dashboard-rows.d.ts.map +1 -1
  48. package/dist/core/dashboard-rows.js +13 -1
  49. package/dist/core/dashboard-rows.js.map +1 -1
  50. package/dist/core/dispatch.d.ts +9 -10
  51. package/dist/core/dispatch.d.ts.map +1 -1
  52. package/dist/core/dispatch.js +17 -10
  53. package/dist/core/dispatch.js.map +1 -1
  54. package/dist/core/maintenance.d.ts.map +1 -1
  55. package/dist/core/maintenance.js +5 -0
  56. package/dist/core/maintenance.js.map +1 -1
  57. package/dist/core/role-resolver.d.ts +5 -2
  58. package/dist/core/role-resolver.d.ts.map +1 -1
  59. package/dist/core/role-resolver.js +55 -23
  60. package/dist/core/role-resolver.js.map +1 -1
  61. package/dist/core/session-manager.d.ts +2 -0
  62. package/dist/core/session-manager.d.ts.map +1 -1
  63. package/dist/core/session-manager.js +20 -18
  64. package/dist/core/session-manager.js.map +1 -1
  65. package/dist/core/skills/cli-admin-command.d.ts.map +1 -1
  66. package/dist/core/skills/cli-admin-command.js +142 -3
  67. package/dist/core/skills/cli-admin-command.js.map +1 -1
  68. package/dist/core/skills/frontmatter.d.ts.map +1 -1
  69. package/dist/core/skills/frontmatter.js +44 -7
  70. package/dist/core/skills/frontmatter.js.map +1 -1
  71. package/dist/core/skills/im-command.d.ts +6 -0
  72. package/dist/core/skills/im-command.d.ts.map +1 -1
  73. package/dist/core/skills/im-command.js +34 -7
  74. package/dist/core/skills/im-command.js.map +1 -1
  75. package/dist/core/skills/install-command.d.ts +4 -0
  76. package/dist/core/skills/install-command.d.ts.map +1 -0
  77. package/dist/core/skills/install-command.js +89 -0
  78. package/dist/core/skills/install-command.js.map +1 -0
  79. package/dist/core/skills/package.d.ts.map +1 -1
  80. package/dist/core/skills/package.js +10 -3
  81. package/dist/core/skills/package.js.map +1 -1
  82. package/dist/core/skills/policy.d.ts +5 -1
  83. package/dist/core/skills/policy.d.ts.map +1 -1
  84. package/dist/core/skills/policy.js +66 -3
  85. package/dist/core/skills/policy.js.map +1 -1
  86. package/dist/core/skills/references.d.ts +9 -1
  87. package/dist/core/skills/references.d.ts.map +1 -1
  88. package/dist/core/skills/references.js +35 -2
  89. package/dist/core/skills/references.js.map +1 -1
  90. package/dist/core/skills/registry-paths.d.ts +1 -0
  91. package/dist/core/skills/registry-paths.d.ts.map +1 -1
  92. package/dist/core/skills/registry-paths.js +3 -0
  93. package/dist/core/skills/registry-paths.js.map +1 -1
  94. package/dist/core/skills/session-resolver.d.ts +2 -1
  95. package/dist/core/skills/session-resolver.d.ts.map +1 -1
  96. package/dist/core/skills/session-resolver.js +1 -0
  97. package/dist/core/skills/session-resolver.js.map +1 -1
  98. package/dist/core/skills/session-runtime.d.ts.map +1 -1
  99. package/dist/core/skills/session-runtime.js +2 -0
  100. package/dist/core/skills/session-runtime.js.map +1 -1
  101. package/dist/core/skills/sources.d.ts +23 -0
  102. package/dist/core/skills/sources.d.ts.map +1 -1
  103. package/dist/core/skills/sources.js +128 -12
  104. package/dist/core/skills/sources.js.map +1 -1
  105. package/dist/core/skills/types.d.ts +22 -1
  106. package/dist/core/skills/types.d.ts.map +1 -1
  107. package/dist/core/terminal-write-auth.d.ts +4 -4
  108. package/dist/core/terminal-write-auth.d.ts.map +1 -1
  109. package/dist/core/terminal-write-auth.js +18 -6
  110. package/dist/core/terminal-write-auth.js.map +1 -1
  111. package/dist/core/types.d.ts +2 -0
  112. package/dist/core/types.d.ts.map +1 -1
  113. package/dist/core/types.js.map +1 -1
  114. package/dist/core/worker-pool.d.ts.map +1 -1
  115. package/dist/core/worker-pool.js +52 -1
  116. package/dist/core/worker-pool.js.map +1 -1
  117. package/dist/daemon.d.ts +1 -0
  118. package/dist/daemon.d.ts.map +1 -1
  119. package/dist/daemon.js +101 -12
  120. package/dist/daemon.js.map +1 -1
  121. package/dist/dashboard/auth.d.ts +21 -11
  122. package/dist/dashboard/auth.d.ts.map +1 -1
  123. package/dist/dashboard/auth.js +39 -25
  124. package/dist/dashboard/auth.js.map +1 -1
  125. package/dist/dashboard/bot-onboarding.d.ts.map +1 -1
  126. package/dist/dashboard/bot-onboarding.js +2 -114
  127. package/dist/dashboard/bot-onboarding.js.map +1 -1
  128. package/dist/dashboard/bot-payload.d.ts +0 -1
  129. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  130. package/dist/dashboard/bot-payload.js +0 -1
  131. package/dist/dashboard/bot-payload.js.map +1 -1
  132. package/dist/dashboard/connector-api.d.ts.map +1 -1
  133. package/dist/dashboard/connector-api.js +66 -6
  134. package/dist/dashboard/connector-api.js.map +1 -1
  135. package/dist/dashboard/dashboard-summary-endpoint.d.ts +39 -0
  136. package/dist/dashboard/dashboard-summary-endpoint.d.ts.map +1 -0
  137. package/dist/dashboard/dashboard-summary-endpoint.js +50 -0
  138. package/dist/dashboard/dashboard-summary-endpoint.js.map +1 -0
  139. package/dist/dashboard/dashboard-summary.d.ts +62 -0
  140. package/dist/dashboard/dashboard-summary.d.ts.map +1 -0
  141. package/dist/dashboard/dashboard-summary.js +106 -0
  142. package/dist/dashboard/dashboard-summary.js.map +1 -0
  143. package/dist/dashboard/public-redact.d.ts.map +1 -1
  144. package/dist/dashboard/public-redact.js +3 -0
  145. package/dist/dashboard/public-redact.js.map +1 -1
  146. package/dist/dashboard/skill-install-request.js +10 -11
  147. package/dist/dashboard/skill-install-request.js.map +1 -1
  148. package/dist/dashboard/skill-pack-response.d.ts +15 -0
  149. package/dist/dashboard/skill-pack-response.d.ts.map +1 -0
  150. package/dist/dashboard/skill-pack-response.js +32 -0
  151. package/dist/dashboard/skill-pack-response.js.map +1 -0
  152. package/dist/dashboard/web/connectors-page.d.ts +69 -0
  153. package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
  154. package/dist/dashboard/web/connectors-page.js +47 -7
  155. package/dist/dashboard/web/connectors-page.js.map +1 -1
  156. package/dist/dashboard/web/feed-group-picker.d.ts +13 -0
  157. package/dist/dashboard/web/feed-group-picker.d.ts.map +1 -0
  158. package/dist/dashboard/web/feed-group-picker.js +45 -0
  159. package/dist/dashboard/web/feed-group-picker.js.map +1 -0
  160. package/dist/dashboard/web/groups-page.d.ts.map +1 -1
  161. package/dist/dashboard/web/groups-page.js +2 -5
  162. package/dist/dashboard/web/groups-page.js.map +1 -1
  163. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  164. package/dist/dashboard/web/i18n.js +309 -40
  165. package/dist/dashboard/web/i18n.js.map +1 -1
  166. package/dist/dashboard/web/preferences.d.ts +4 -2
  167. package/dist/dashboard/web/preferences.d.ts.map +1 -1
  168. package/dist/dashboard/web/preferences.js +14 -6
  169. package/dist/dashboard/web/preferences.js.map +1 -1
  170. package/dist/dashboard/web/roles-page.d.ts.map +1 -1
  171. package/dist/dashboard/web/roles-page.js +36 -4
  172. package/dist/dashboard/web/roles-page.js.map +1 -1
  173. package/dist/dashboard/web/roles.d.ts +3 -1
  174. package/dist/dashboard/web/roles.d.ts.map +1 -1
  175. package/dist/dashboard/web/roles.js +10 -2
  176. package/dist/dashboard/web/roles.js.map +1 -1
  177. package/dist/dashboard/web/schedules-page.d.ts.map +1 -1
  178. package/dist/dashboard/web/schedules-page.js +8 -2
  179. package/dist/dashboard/web/schedules-page.js.map +1 -1
  180. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  181. package/dist/dashboard/web/sessions-page.js +101 -5
  182. package/dist/dashboard/web/sessions-page.js.map +1 -1
  183. package/dist/dashboard/web/sessions.d.ts +3 -1
  184. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  185. package/dist/dashboard/web/sessions.js +13 -6
  186. package/dist/dashboard/web/sessions.js.map +1 -1
  187. package/dist/dashboard/web/skills/bot-assignments-tab.d.ts +27 -0
  188. package/dist/dashboard/web/skills/bot-assignments-tab.d.ts.map +1 -0
  189. package/dist/dashboard/web/skills/bot-assignments-tab.js +257 -0
  190. package/dist/dashboard/web/skills/bot-assignments-tab.js.map +1 -0
  191. package/dist/dashboard/web/skills/delivery-settings-tab.d.ts +25 -0
  192. package/dist/dashboard/web/skills/delivery-settings-tab.d.ts.map +1 -0
  193. package/dist/dashboard/web/skills/delivery-settings-tab.js +20 -0
  194. package/dist/dashboard/web/skills/delivery-settings-tab.js.map +1 -0
  195. package/dist/dashboard/web/skills/shared.d.ts +86 -0
  196. package/dist/dashboard/web/skills/shared.d.ts.map +1 -0
  197. package/dist/dashboard/web/skills/shared.js +178 -0
  198. package/dist/dashboard/web/skills/shared.js.map +1 -0
  199. package/dist/dashboard/web/skills/skill-library-tab.d.ts +75 -0
  200. package/dist/dashboard/web/skills/skill-library-tab.d.ts.map +1 -0
  201. package/dist/dashboard/web/skills/skill-library-tab.js +108 -0
  202. package/dist/dashboard/web/skills/skill-library-tab.js.map +1 -0
  203. package/dist/dashboard/web/skills/skill-packs-tab.d.ts +21 -0
  204. package/dist/dashboard/web/skills/skill-packs-tab.d.ts.map +1 -0
  205. package/dist/dashboard/web/skills/skill-packs-tab.js +185 -0
  206. package/dist/dashboard/web/skills/skill-packs-tab.js.map +1 -0
  207. package/dist/dashboard/web/skills/types.d.ts +95 -0
  208. package/dist/dashboard/web/skills/types.d.ts.map +1 -0
  209. package/dist/dashboard/web/skills/types.js +2 -0
  210. package/dist/dashboard/web/skills/types.js.map +1 -0
  211. package/dist/dashboard/web/skills/use-skills-data.d.ts +31 -0
  212. package/dist/dashboard/web/skills/use-skills-data.d.ts.map +1 -0
  213. package/dist/dashboard/web/skills/use-skills-data.js +86 -0
  214. package/dist/dashboard/web/skills/use-skills-data.js.map +1 -0
  215. package/dist/dashboard/web/skills-page.d.ts +34 -31
  216. package/dist/dashboard/web/skills-page.d.ts.map +1 -1
  217. package/dist/dashboard/web/skills-page.js +321 -176
  218. package/dist/dashboard/web/skills-page.js.map +1 -1
  219. package/dist/dashboard/webhook-routes.d.ts +18 -0
  220. package/dist/dashboard/webhook-routes.d.ts.map +1 -1
  221. package/dist/dashboard/webhook-routes.js +35 -1
  222. package/dist/dashboard/webhook-routes.js.map +1 -1
  223. package/dist/dashboard-web/app.js +1 -1
  224. package/dist/dashboard-web/chunks/{bot-defaults-page-GED4FEMS.js → bot-defaults-page-HL655CNR.js} +1 -1
  225. package/dist/dashboard-web/chunks/{chunk-HK7NIBJG.js → chunk-5PAYQ3KT.js} +1 -1
  226. package/dist/dashboard-web/chunks/{chunk-KRHWZEXU.js → chunk-5ZL7NLR5.js} +1 -1
  227. package/dist/dashboard-web/chunks/chunk-6ALR252E.js +1 -0
  228. package/dist/dashboard-web/chunks/{chunk-VBXJOJFH.js → chunk-BFUTTYVK.js} +1 -1
  229. package/dist/dashboard-web/chunks/chunk-ELL53D62.js +55 -0
  230. package/dist/dashboard-web/chunks/connectors-page-3QQACF23.js +1 -0
  231. package/dist/dashboard-web/chunks/groups-page-SDZMJ7UV.js +8 -0
  232. package/dist/dashboard-web/chunks/{insights-page-54UBPPOC.js → insights-page-ZZ3JEJ6L.js} +1 -1
  233. package/dist/dashboard-web/chunks/{monitor-room-PORHVKRH.js → monitor-room-ROJ5GZ4N.js} +1 -1
  234. package/dist/dashboard-web/chunks/{monitoring-page-XGA7AXNL.js → monitoring-page-IATV7KJF.js} +1 -1
  235. package/dist/dashboard-web/chunks/{office-page-AO3AFG45.js → office-page-TYO5OIIT.js} +1 -1
  236. package/dist/dashboard-web/chunks/{overview-page-F5BUTYR6.js → overview-page-VJUULZF2.js} +1 -1
  237. package/dist/dashboard-web/chunks/roles-page-7RJJKZGK.js +1 -0
  238. package/dist/dashboard-web/chunks/schedules-page-JTEJ5IXW.js +1 -0
  239. package/dist/dashboard-web/chunks/sessions-page-KD7HDT3U.js +8 -0
  240. package/dist/dashboard-web/chunks/{settings-page-OJAZLZQT.js → settings-page-7CTTFBRS.js} +1 -1
  241. package/dist/dashboard-web/chunks/skills-page-57NZECJ3.js +4 -0
  242. package/dist/dashboard-web/chunks/{team-federation-page-ZJVAP7HW.js → team-federation-page-6YYIMJG3.js} +1 -1
  243. package/dist/dashboard-web/chunks/{v3-page-GYWTRGO2.js → v3-page-ZGKBSBK3.js} +1 -1
  244. package/dist/dashboard-web/chunks/{whiteboards-page-MSDAG6P7.js → whiteboards-page-AACJF6DB.js} +1 -1
  245. package/dist/dashboard-web/style.css +1952 -377
  246. package/dist/dashboard.js +316 -47
  247. package/dist/dashboard.js.map +1 -1
  248. package/dist/desktop/main/ipc.js +1 -1
  249. package/dist/desktop/main/runtime-service.js +2 -2
  250. package/dist/desktop/main/runtime-service.js.map +1 -1
  251. package/dist/desktop/shared/types.d.ts +1 -1
  252. package/dist/i18n/en.d.ts.map +1 -1
  253. package/dist/i18n/en.js +5 -1
  254. package/dist/i18n/en.js.map +1 -1
  255. package/dist/i18n/zh.d.ts.map +1 -1
  256. package/dist/i18n/zh.js +5 -1
  257. package/dist/i18n/zh.js.map +1 -1
  258. package/dist/index-daemon.js +6 -1
  259. package/dist/index-daemon.js.map +1 -1
  260. package/dist/services/adopt-input-sequence.d.ts +52 -0
  261. package/dist/services/adopt-input-sequence.d.ts.map +1 -0
  262. package/dist/services/adopt-input-sequence.js +50 -0
  263. package/dist/services/adopt-input-sequence.js.map +1 -0
  264. package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
  265. package/dist/services/bridge-fallback-gate.js +34 -11
  266. package/dist/services/bridge-fallback-gate.js.map +1 -1
  267. package/dist/services/codex-bridge-queue.d.ts +103 -7
  268. package/dist/services/codex-bridge-queue.d.ts.map +1 -1
  269. package/dist/services/codex-bridge-queue.js +291 -17
  270. package/dist/services/codex-bridge-queue.js.map +1 -1
  271. package/dist/services/codex-transcript.d.ts +4 -3
  272. package/dist/services/codex-transcript.d.ts.map +1 -1
  273. package/dist/services/codex-transcript.js.map +1 -1
  274. package/dist/services/connector-store.d.ts +14 -1
  275. package/dist/services/connector-store.d.ts.map +1 -1
  276. package/dist/services/connector-store.js.map +1 -1
  277. package/dist/services/connector-topic-template.d.ts +7 -0
  278. package/dist/services/connector-topic-template.d.ts.map +1 -0
  279. package/dist/services/connector-topic-template.js +210 -0
  280. package/dist/services/connector-topic-template.js.map +1 -0
  281. package/dist/services/project-scanner.d.ts +33 -0
  282. package/dist/services/project-scanner.d.ts.map +1 -1
  283. package/dist/services/project-scanner.js +40 -1
  284. package/dist/services/project-scanner.js.map +1 -1
  285. package/dist/services/skill-pack-store.d.ts +50 -0
  286. package/dist/services/skill-pack-store.d.ts.map +1 -0
  287. package/dist/services/skill-pack-store.js +270 -0
  288. package/dist/services/skill-pack-store.js.map +1 -0
  289. package/dist/services/skill-registry-store.d.ts +8 -0
  290. package/dist/services/skill-registry-store.d.ts.map +1 -1
  291. package/dist/services/skill-registry-store.js +100 -68
  292. package/dist/services/skill-registry-store.js.map +1 -1
  293. package/dist/services/structured-bridge-clis.d.ts +8 -1
  294. package/dist/services/structured-bridge-clis.d.ts.map +1 -1
  295. package/dist/services/structured-bridge-clis.js +13 -0
  296. package/dist/services/structured-bridge-clis.js.map +1 -1
  297. package/dist/services/submit-confirmation.d.ts +50 -0
  298. package/dist/services/submit-confirmation.d.ts.map +1 -1
  299. package/dist/services/submit-confirmation.js +82 -0
  300. package/dist/services/submit-confirmation.js.map +1 -1
  301. package/dist/services/todo-state.d.ts +74 -0
  302. package/dist/services/todo-state.d.ts.map +1 -0
  303. package/dist/services/todo-state.js +351 -0
  304. package/dist/services/todo-state.js.map +1 -0
  305. package/dist/setup/agent-preset.d.ts.map +1 -1
  306. package/dist/setup/agent-preset.js +2 -0
  307. package/dist/setup/agent-preset.js.map +1 -1
  308. package/dist/setup/owner-identity.d.ts +47 -0
  309. package/dist/setup/owner-identity.d.ts.map +1 -0
  310. package/dist/setup/owner-identity.js +196 -0
  311. package/dist/setup/owner-identity.js.map +1 -0
  312. package/dist/setup/register-app.d.ts +4 -3
  313. package/dist/setup/register-app.d.ts.map +1 -1
  314. package/dist/setup/register-app.js.map +1 -1
  315. package/dist/setup/setup-args.d.ts +1 -1
  316. package/dist/setup/setup-args.d.ts.map +1 -1
  317. package/dist/setup/setup-args.js +7 -3
  318. package/dist/setup/setup-args.js.map +1 -1
  319. package/dist/types.d.ts +11 -0
  320. package/dist/types.d.ts.map +1 -1
  321. package/dist/utils/async-serial-queue.d.ts +9 -0
  322. package/dist/utils/async-serial-queue.d.ts.map +1 -0
  323. package/dist/utils/async-serial-queue.js +13 -0
  324. package/dist/utils/async-serial-queue.js.map +1 -0
  325. package/dist/utils/child-env.d.ts +25 -0
  326. package/dist/utils/child-env.d.ts.map +1 -1
  327. package/dist/utils/child-env.js +47 -0
  328. package/dist/utils/child-env.js.map +1 -1
  329. package/dist/utils/runtime-screen-status.d.ts +52 -0
  330. package/dist/utils/runtime-screen-status.d.ts.map +1 -0
  331. package/dist/utils/runtime-screen-status.js +60 -0
  332. package/dist/utils/runtime-screen-status.js.map +1 -0
  333. package/dist/worker.js +1697 -468
  334. package/dist/worker.js.map +1 -1
  335. package/dist/workflows/definition.d.ts +44 -44
  336. package/package.json +1 -1
  337. package/dist/dashboard-web/chunks/chunk-6J5LS5AU.js +0 -51
  338. package/dist/dashboard-web/chunks/connectors-page-EELM66G3.js +0 -1
  339. package/dist/dashboard-web/chunks/groups-page-WKUV6E42.js +0 -8
  340. package/dist/dashboard-web/chunks/roles-page-M5GIASJU.js +0 -1
  341. package/dist/dashboard-web/chunks/schedules-page-O232WQRR.js +0 -1
  342. package/dist/dashboard-web/chunks/sessions-page-YFRPSTIL.js +0 -6
  343. package/dist/dashboard-web/chunks/skills-page-EFZQPWV4.js +0 -4
package/dist/worker.js CHANGED
@@ -28,7 +28,7 @@ import { readProcessStartIdentity } from './core/session-marker.js';
28
28
  import { roleLibraryRoot, roleLibrarySubtree } from './core/role-library.js';
29
29
  import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff, isTranscriptRateLimitEvent, apiErrorMessageText } from './services/claude-transcript.js';
30
30
  import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
31
- import { bridgePostText, shouldEmitEmptyCompletedBridgeFallback, shouldEmitFailedBridgeFallback, shouldSuppressBridgeEmit, stripTrailingBridgeSentinelLine } from './services/bridge-fallback-gate.js';
31
+ import { bridgePostText, isBridgeNothingToSendFinal, shouldEmitEmptyCompletedBridgeFallback, shouldEmitFailedBridgeFallback, shouldSuppressBridgeEmit, stripTrailingBridgeSentinelLine } from './services/bridge-fallback-gate.js';
32
32
  import { buildSubmitMessagePreview } from './services/submit-notification.js';
33
33
  import { decideHardTimeoutAction, decidePostHookPromptEvidence, decideSettleMarkReady, shouldArmPostHookPromptEvidenceFallback, shouldReleaseFirstPromptTimeout, shouldWaitForPostSessionStartPromptEvidence, shouldWriteNow, POST_HOOK_EVIDENCE_FALLBACK_MS, POST_HOOK_EVIDENCE_RETRY_MS, } from './utils/input-gate.js';
34
34
  import { decideCodexRunnerFreshness, CodexRunnerFreshnessInputQueue, shouldHoldCodexRunnerInput, } from './services/codex-runner-freshness.js';
@@ -36,7 +36,7 @@ import { canStartInjectionFlush, shouldDeferUserFlush, shouldFlushInjectionsFirs
36
36
  import { decideRestartFollowup, settleDurableTurnForRestart } from './core/restart-followup-policy.js';
37
37
  import { stripAnsiForLog, tailChars } from './utils/crash-log.js';
38
38
  import { CodexUpdateDialogGuard } from './utils/codex-update-dialog.js';
39
- import { EffortConfirmDialogGuard, isEffortLevelCommand } from './utils/effort-confirm-dialog.js';
39
+ import { EffortConfirmDialogGuard } from './utils/effort-confirm-dialog.js';
40
40
  import { installStdioEpipeGuard, isIgnorableStreamError } from './utils/stdio-epipe-guard.js';
41
41
  import { handoffQueuedDurableInputsOnBackendExit, mergeQueuedCliInput, pendingInputMayFlush, pendingInputAllowsTypeAhead, resolveInitialPromptDelivery, shouldArmSpawnArgvInitialPromptBusy, shouldTrackArgvBakedFirstPrompt, shouldDeferArgsBakedDurablePrompt, shouldDeferInitialPromptForArgLimit, shouldStopPendingBatch, terminalReleasesDurableTurn, } from './utils/pending-input-queue.js';
42
42
  import { riffWorkerShutdownInputBlocker } from './core/riff-worker-shutdown-readiness.js';
@@ -58,7 +58,7 @@ import { loadDashboardSecret, loadPersistedToken } from './dashboard/auth.js';
58
58
  import { InflightInputTracker } from './core/inflight-input-tracker.js';
59
59
  import { InputTurnDeduper } from './core/input-turn-dedupe.js';
60
60
  import { shouldRunQuietRotation, evaluatePidResolverPullback, decideFingerprintSwitch, shouldHealAbsentBaseline, sessionIdFromJsonlPath, SESSION_ID_FILENAME_RE, } from './services/bridge-rotation-policy.js';
61
- import { CodexBridgeQueue } from './services/codex-bridge-queue.js';
61
+ import { CodexBridgeQueue, pruneExpiredPreStartHeadsAndEmit } from './services/codex-bridge-queue.js';
62
62
  import { detectCodexComposerState } from './services/codex-composer-state.js';
63
63
  import { generateCodexAppThreadTitle, readCodexAppThreadMetadata, setCodexAppThreadName, } from './services/codex-app-threads.js';
64
64
  import { buildBotmuxLarkNativeSessionTitle } from './core/session-title.js';
@@ -74,7 +74,7 @@ import { currentMtrSessionOffset, drainMtrSession, findLatestMtrSessionByDirecto
74
74
  import { drainPiTranscript } from './services/pi-transcript.js';
75
75
  import { drainGrokUpdates, findGrokSessionByPid, grokSessionIdFromPath, } from './services/grok-transcript.js';
76
76
  import { resolveFileBridgePath } from './services/file-bridge-path.js';
77
- import { isStructuredBridgeAdoptIdleCli, isStructuredBridgeAdoptInputCli, isStructuredBridgeFallbackActive, } from './services/structured-bridge-clis.js';
77
+ import { isStructuredBridgeAdoptIdleCli, isStructuredBridgeAdoptInputCli, isStructuredBridgeFallbackActive, isStructuredBridgeLifecycleBlockingCli, } from './services/structured-bridge-clis.js';
78
78
  import { drainCursorTranscript, findCursorChatIdByPid, findCursorTranscriptByChatId, findCursorTranscriptByPid } from './services/cursor-transcript.js';
79
79
  import { shouldObserveCursorChatId, shouldPersistObservedCursorChatId } from './services/cursor-resume-policy.js';
80
80
  import { extractKiroSessionIdFromOutput } from './services/kiro-session.js';
@@ -93,7 +93,7 @@ import { createCliAdapterSync, locateOnPath } from './adapters/cli/registry.js';
93
93
  import { buildWrappedLaunch, parseWrapperCli, isTtadkWrapper } from './setup/cli-selection.js';
94
94
  import { cliUnavailableMessage } from './setup/cli-availability.js';
95
95
  import { findLaunchedCliPid, scheduleWrapperRealCliPid, readComm, isBareShellComm, bareShellLaunchKind, settleLaunchComm, } from './core/session-discovery.js';
96
- import { codexRpcEligible, paneRunsRemoteTui, orchestrateCodexRpcInit, rolloutUserTurnMatches, decideStartupDialogAction, shouldQueueInitialPrompt, shouldPreMarkFirstTurn, killAndVerifyPersistentPane } from './codex-rpc-lifecycle.js';
96
+ import { CODEX_RPC_TERMINAL_HYDRATION_DELAYS_MS, RpcEngagementFence, codexRpcEligible, paneRunsRemoteTui, orchestrateCodexRpcInit, rolloutUserTurnMatches, decideStartupDialogAction, shouldQueueInitialPrompt, shouldPreMarkFirstTurn, killAndVerifyPersistentPane, rpcTranscriptIngestBlockedByAwaitingActivation } from './codex-rpc-lifecycle.js';
97
97
  import { delay } from './utils/timing.js';
98
98
  import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds, syncClaudeResumeTargetToCwd, DEFAULT_CLAUDE_DATA_DIR } from './adapters/cli/claude-code.js';
99
99
  import { sessionReadyHookCommand } from './adapters/hook-command.js';
@@ -128,8 +128,9 @@ import { mergeHerdrWebSnapshot, renderHerdrWebHistory, } from './utils/herdr-web
128
128
  import { parseWorkerRequestUrl } from './utils/worker-http.js';
129
129
  import { detectCliUsageLimit, usageLimitStateKey, structuredRateLimitState, isStructuredRateLimitAuthoritative } from './utils/cli-usage-limit.js';
130
130
  import { uploadImageBuffer } from './utils/lark-upload.js';
131
- import { redactChildEnv, scrubClaudeSessionMarkerEnv, scrubSessionCliHomeEnv } from './utils/child-env.js';
132
- import { decideSubmitConfirmationAction } from './services/submit-confirmation.js';
131
+ import { applySessionOwnerEnv, redactChildEnv, scrubClaudeSessionMarkerEnv, scrubSessionCliHomeEnv } from './utils/child-env.js';
132
+ import { decideSubmitConfirmationAction, settleDeferredSubmitConfirmation, settleStaleWriteContinuation, } from './services/submit-confirmation.js';
133
+ import { runAdoptQueuedWriteSequence, runAdoptRawInputSequence, runAdoptSessionRenameSequence, } from './services/adopt-input-sequence.js';
133
134
  import { config, resolveChatBotDiscoveryConfig } from './config.js';
134
135
  import * as sessionStore from './services/session-store.js';
135
136
  import * as pty from 'node-pty';
@@ -142,7 +143,9 @@ import { resolveCodexAppFinalTurnIdentity } from './adapters/cli/codex-app-turn.
142
143
  import { RunnerControlDecoder } from './adapters/cli/runner-control-channel.js';
143
144
  import { normalizeCodexAppLifecycleEvent, normalizeFinalUsage, } from './services/codex-app-runner-protocol.js';
144
145
  import { hasMatchingManagedOriginCapability, ensureManagedOriginAttestationDirectory, ensureManagedOriginCapabilityLeafSafe, ensureManagedOriginDataRootProbe, ensureManagedOriginIsolationSentinel, ensureManagedOriginRootLocator, managedOriginLegacyIsolationProbeAccess, managedOriginDataRootProbeAccess, managedOriginIsolationSentinelAccess, managedOriginAttestationDirectory, managedOriginCapabilityPath, managedOriginRootLocatorPath, readManagedOriginAuthorityFile, RELAY_ORIGIN_CAPABILITY_BASENAME, replaceManagedOriginCapabilityFile, sweepManagedOriginAttestationProofs, } from './core/managed-origin-capability.js';
145
- import { CodexRpcEngine } from './codex-rpc-engine.js';
146
+ import { CodexRpcEngine, } from './codex-rpc-engine.js';
147
+ import { beginRuntimeWriteCycle, isCliBackendGenerationCurrent, PtyOutputGeneration, projectRuntimeScreenStatus, } from './utils/runtime-screen-status.js';
148
+ import { AsyncSerialQueue } from './utils/async-serial-queue.js';
146
149
  // A worker must never trust an INHERITED session-level CLI home pointer
147
150
  // (CLAUDE_CONFIG_DIR / CODEX_HOME): a stale pm2 dump can resurrect the daemon
148
151
  // with a sibling bot's home baked in (see the index-daemon.ts boot scrub —
@@ -216,16 +219,184 @@ function cleanupPiInitialPromptFiles() {
216
219
  piInitialPromptAdditionalArgs = [];
217
220
  piInitialPromptEnv = {};
218
221
  }
222
+ const rpcEngagementFence = new RpcEngagementFence();
223
+ const pendingRpcTurnTerminals = new Map();
224
+ const rpcTurnsAwaitingActivation = new Map();
225
+ const rpcTurnsAwaitingActivationIdentities = new Map();
226
+ /** Local send-start anchor for each exact awaiting owner. A predecessor's
227
+ * terminal hydration may ingest this successor's transcript before turn/start
228
+ * ACK returns. Reusing the send-start time when the bridge mark is installed
229
+ * keeps that already-buffered user/final pair inside its original bounded
230
+ * attribution window without widening replay for unrelated turns. */
231
+ const rpcTurnsAwaitingActivationReplayAnchors = new Map();
232
+ /** A bridge mark/activation failure after an accepted RPC submit must never
233
+ * degrade to ready. Keep a separate fail-closed gate until that exact native
234
+ * turn reaches terminal or the engine is torn down. */
235
+ const rpcLifecycleFailClosedOwners = new Map();
236
+ const rpcLifecycleFailClosedIdentities = new Map();
237
+ const rpcActiveOwners = new Map();
238
+ const rpcTerminalHydrationOwners = new Map();
239
+ const rpcTerminalHydrationTimers = new Map();
240
+ const rpcTerminalHydrationTerminals = new Map();
241
+ const settlingRpcTerminalOwners = new Map();
242
+ let deferredFreshRpcTurn;
243
+ function rpcTurnOwnerKey(identity) {
244
+ return `${identity.turnId}\0${identity.dispatchAttempt ?? ''}`;
245
+ }
246
+ function sameRpcGeneration(left, right) {
247
+ return left?.engine === right.engine
248
+ && left.cliGeneration === right.cliGeneration;
249
+ }
250
+ function installRpcLifecycleFailClosedOwner(identity, generation) {
251
+ const ownerKey = rpcTurnOwnerKey(identity);
252
+ rpcLifecycleFailClosedOwners.set(ownerKey, generation);
253
+ rpcLifecycleFailClosedIdentities.set(ownerKey, identity);
254
+ }
255
+ function installAwaitingRpcActivation(identity, generation) {
256
+ const ownerKey = rpcTurnOwnerKey(identity);
257
+ rpcTurnsAwaitingActivation.set(ownerKey, generation);
258
+ rpcTurnsAwaitingActivationIdentities.set(ownerKey, identity);
259
+ rpcTurnsAwaitingActivationReplayAnchors.set(ownerKey, Date.now());
260
+ }
261
+ function awaitingRpcActivationReplayAnchorMs(identity, generation) {
262
+ const ownerKey = rpcTurnOwnerKey(identity);
263
+ if (!sameRpcGeneration(rpcTurnsAwaitingActivation.get(ownerKey), generation)) {
264
+ return undefined;
265
+ }
266
+ return rpcTurnsAwaitingActivationReplayAnchors.get(ownerKey);
267
+ }
268
+ function clearRpcLifecycleFailClosedOwner(identity, generation) {
269
+ const ownerKey = rpcTurnOwnerKey(identity);
270
+ if (!sameRpcGeneration(rpcLifecycleFailClosedOwners.get(ownerKey), generation)) {
271
+ return false;
272
+ }
273
+ rpcLifecycleFailClosedOwners.delete(ownerKey);
274
+ rpcLifecycleFailClosedIdentities.delete(ownerKey);
275
+ return true;
276
+ }
277
+ function clearAwaitingRpcActivation(identity, generation) {
278
+ const ownerKey = rpcTurnOwnerKey(identity);
279
+ if (sameRpcGeneration(rpcTurnsAwaitingActivation.get(ownerKey), generation)) {
280
+ rpcTurnsAwaitingActivation.delete(ownerKey);
281
+ rpcTurnsAwaitingActivationIdentities.delete(ownerKey);
282
+ rpcTurnsAwaitingActivationReplayAnchors.delete(ownerKey);
283
+ }
284
+ const pending = pendingRpcTurnTerminals.get(ownerKey);
285
+ if (pending && sameRpcGeneration(pending.generation, generation)) {
286
+ pendingRpcTurnTerminals.delete(ownerKey);
287
+ }
288
+ }
289
+ function notifyRpcTeardownBeforeActivation(identity, terminalStatus) {
290
+ const completedBeforeActivation = terminalStatus === 'completed';
291
+ send({
292
+ type: 'user_notify',
293
+ message: completedBeforeActivation
294
+ ? 'Codex RPC 消息已执行完成,但会话在本地完成生命周期登记前重启,兜底输出可能未被捕获;原消息未自动重发,如未看到回复请先查看终端结果再人工跟进。'
295
+ : 'Codex RPC 消息已写出,但会话在取得 turn/start 归属前重启;为避免重复执行未自动重发,请按需人工确认结果。',
296
+ turnId: identity.turnId,
297
+ ...(identity.dispatchAttempt !== undefined
298
+ ? { dispatchAttempt: identity.dispatchAttempt }
299
+ : {}),
300
+ });
301
+ }
219
302
  function stopCodexRpcEngine() {
303
+ // Invalidate even when the engine has not yet been published: engageCodexRpc
304
+ // may be awaiting a local engine/thread/probe while another IPC handler starts
305
+ // a restart. That stale continuation must never republish the stopped engine.
306
+ rpcEngagementFence.invalidate();
220
307
  const engine = codexRpcEngine;
221
- codexRpcEngine = undefined;
222
- remoteWsUrl = undefined;
223
- remoteThreadId = undefined;
224
- clearRpcEnginePidMarker();
308
+ const ownedRpcTurns = new Set([
309
+ ...rpcTurnsAwaitingActivation.keys(),
310
+ ...rpcLifecycleFailClosedOwners.keys(),
311
+ ...rpcActiveOwners.keys(),
312
+ ...settlingRpcTerminalOwners.keys(),
313
+ ]);
314
+ // stop() emits exact 'stopped' terminals for every engine-owned native turn.
315
+ // Keep the engine identity installed until those synchronous callbacks have
316
+ // retired their matching rpcActive entries.
225
317
  try {
226
318
  engine?.stop();
227
319
  }
228
320
  catch { /* best effort */ }
321
+ // A native terminal can arrive synchronously from engine.stop() while its
322
+ // turn/start continuation is still awaiting activation. handleRpcTurnTerminal
323
+ // buffers that exact terminal; settle it now, before teardown clears the
324
+ // awaiting/pending maps, so an accepted RPC delivery never loses its only
325
+ // terminal at an operator or in-worker restart boundary.
326
+ for (const [ownerKey, pending] of [...pendingRpcTurnTerminals]) {
327
+ const awaiting = rpcTurnsAwaitingActivation.get(ownerKey);
328
+ if (!awaiting || !sameRpcGeneration(awaiting, pending.generation))
329
+ continue;
330
+ const identity = rpcTurnsAwaitingActivationIdentities.get(ownerKey);
331
+ const shouldNotify = !rpcLifecycleFailClosedOwners.has(ownerKey);
332
+ settleRpcTurnTerminal(pending.terminal, pending.generation);
333
+ if (identity && shouldNotify) {
334
+ notifyRpcTeardownBeforeActivation(identity, pending.terminal.status);
335
+ }
336
+ }
337
+ // A follow-up can be accepted by the socket but lose its turn/start response
338
+ // while an operator or liveness restart tears down the engine. RPC submissions
339
+ // intentionally bypass InflightInputTracker (replay would risk duplicate
340
+ // execution), so close every still-unbound logical owner explicitly instead
341
+ // of letting its stale continuation assume an ordinary carryover exists.
342
+ for (const [ownerKey, identity] of [...rpcTurnsAwaitingActivationIdentities]) {
343
+ const awaiting = rpcTurnsAwaitingActivation.get(ownerKey);
344
+ if (!awaiting)
345
+ continue;
346
+ clearAwaitingRpcActivation(identity, awaiting);
347
+ emitTurnTerminal(identity.turnId, 'ambiguous', 'rpc_engine_teardown_before_turn_start_ack', identity.dispatchAttempt);
348
+ if (!rpcLifecycleFailClosedOwners.has(ownerKey)) {
349
+ notifyRpcTeardownBeforeActivation(identity);
350
+ }
351
+ }
352
+ for (const pending of [...rpcTerminalHydrationTerminals.values()]) {
353
+ finalizeRpcTurnTerminal(pending.terminal, pending.generation, true);
354
+ }
355
+ // A dispatched delivery for which the app-server never yielded a native turn
356
+ // id cannot receive engine.stop()'s native terminal callback. Publish one
357
+ // exact logical terminal before clearing the owner so the daemon's durable
358
+ // ledger never waits forever on a teardown-only attempt.
359
+ for (const [ownerKey, identity] of rpcLifecycleFailClosedIdentities) {
360
+ if (!rpcLifecycleFailClosedOwners.has(ownerKey))
361
+ continue;
362
+ emitTurnTerminal(identity.turnId, 'ambiguous', 'rpc_engine_teardown_without_native_terminal', identity.dispatchAttempt);
363
+ }
364
+ codexRpcEngine = undefined;
365
+ remoteWsUrl = undefined;
366
+ remoteThreadId = undefined;
367
+ clearRpcEnginePidMarker();
368
+ // Defensive cleanup for a protocol/transport failure that never yielded a
369
+ // native id. No RPC turn may survive an engine teardown holding the lifecycle
370
+ // gate open.
371
+ for (const turn of codexBridgeQueue.peek()) {
372
+ if (turn.finalText === undefined
373
+ && (turn.rpcActive
374
+ || ownedRpcTurns.has(rpcTurnOwnerKey({
375
+ turnId: turn.turnId,
376
+ ...(turn.dispatchAttempt !== undefined
377
+ ? { dispatchAttempt: turn.dispatchAttempt }
378
+ : {}),
379
+ })))) {
380
+ codexBridgeQueue.stopRpcActive(turn.turnId, turn.dispatchAttempt);
381
+ codexBridgeQueue.dropPendingTurn(turn.turnId, turn.dispatchAttempt, true);
382
+ }
383
+ }
384
+ pendingRpcTurnTerminals.clear();
385
+ rpcTurnsAwaitingActivation.clear();
386
+ rpcTurnsAwaitingActivationIdentities.clear();
387
+ rpcTurnsAwaitingActivationReplayAnchors.clear();
388
+ rpcLifecycleFailClosedOwners.clear();
389
+ rpcLifecycleFailClosedIdentities.clear();
390
+ rpcActiveOwners.clear();
391
+ for (const timer of rpcTerminalHydrationTimers.values())
392
+ clearTimeout(timer);
393
+ rpcTerminalHydrationTimers.clear();
394
+ rpcTerminalHydrationTerminals.clear();
395
+ rpcTerminalHydrationOwners.clear();
396
+ settlingRpcTerminalOwners.clear();
397
+ deferredFreshRpcTurn = undefined;
398
+ stopStructuredStartGraceRecheck();
399
+ structuredRejectedReadyEvidenceGeneration = undefined;
229
400
  }
230
401
  /** Resolve the persistent pane name + liveness for a backend type, mirroring
231
402
  * spawnCli's willReattachPersistent probe (worker.ts) but callable from the init
@@ -566,8 +737,24 @@ async function engageCodexRpc(cfg) {
566
737
  return 'not-engaged';
567
738
  const wantResume = cfg.resume === true && !!cfg.cliSessionId;
568
739
  stopCodexRpcEngine();
740
+ const engagementLease = rpcEngagementFence.begin();
569
741
  let engine;
742
+ let enginePidMarker = null;
743
+ let freshDeliveryOwned = false;
744
+ const assertRpcEngagementCurrent = () => {
745
+ if (!rpcEngagementFence.isCurrent(engagementLease)) {
746
+ throw new CliSpawnSupersededError();
747
+ }
748
+ if (!rpcEngagementFence.isLive(engagementLease)) {
749
+ throw new CodexRpcEngineDiedDuringEngagementError();
750
+ }
751
+ };
570
752
  try {
753
+ // engage runs immediately before spawnCli, which advances the viewer
754
+ // generation once. Bind every callback from this app-server to that target
755
+ // generation so a late terminal from an old server can never retire a
756
+ // same-(turnId,attempt) entry installed by its replacement.
757
+ const engineCliGeneration = cliSpawnGeneration + 1;
571
758
  const cliBin = createCliAdapterSync(cfg.cliId, cfg.cliPathOverride).resolvedBin;
572
759
  const engineEnv = { ...redactChildEnv(process.env) };
573
760
  engineEnv.PATH = prependBotmuxBin(resolveBotmuxWrapperBinDir(process.env), engineEnv.PATH);
@@ -583,10 +770,6 @@ async function engageCodexRpc(cfg) {
583
770
  engineEnv.BOTMUX_LARK_APP_ID = cfg.larkAppId;
584
771
  engineEnv.BOTMUX_ROOT_MESSAGE_ID = cfg.rootMessageId;
585
772
  engineEnv.BOTMUX_SESSION_SCOPE = cfg.rootMessageId?.startsWith('om_') ? 'thread' : 'chat';
586
- if (cfg.ownerOpenId)
587
- engineEnv.BOTMUX_OWNER_OPEN_ID = cfg.ownerOpenId;
588
- else
589
- delete engineEnv.BOTMUX_OWNER_OPEN_ID;
590
773
  // The app-server owns model execution in RPC mode. Its MCP gateway child
591
774
  // must inherit the trusted host socket just like a native CLI process does.
592
775
  if (sessionMcpGatewayHost) {
@@ -602,6 +785,9 @@ async function engageCodexRpc(cfg) {
602
785
  // injects into the TUI — else a 3rd-party-provider bot's app-server silently
603
786
  // falls back to the default provider. Re-sanitized (crossed IPC).
604
787
  Object.assign(engineEnv, sanitizePerBotEnv(cfg.env));
788
+ // Session identity is host-owned. Pin it after the config-controlled merge,
789
+ // matching every other backend and preventing stale owner resurrection.
790
+ applySessionOwnerEnv(engineEnv, cfg.ownerOpenId);
605
791
  engine = new CodexRpcEngine({
606
792
  cliBin, cwd: cfg.workingDir, env: engineEnv, sessionId: cfg.sessionId,
607
793
  model: cfg.model, reasoningEffort: cfg.reasoningEffort, log: (m) => log(m),
@@ -609,7 +795,21 @@ async function engageCodexRpc(cfg) {
609
795
  onRequestUserInput: cfg.cliId === 'traex'
610
796
  ? (params) => bridgeTraexUserInput(cfg, params)
611
797
  : undefined,
798
+ onTurnTerminal: (terminal) => {
799
+ if (!engine)
800
+ return;
801
+ handleRpcTurnTerminal(terminal, {
802
+ engine,
803
+ cliGeneration: engineCliGeneration,
804
+ });
805
+ },
612
806
  onDead: () => {
807
+ // Death can race after the final awaited response but before the engine
808
+ // is published below. Record it against this exact engagement even when
809
+ // codexRpcEngine is still undefined; otherwise the continuation can
810
+ // publish a permanently dead/deadNotified engine whose later failures
811
+ // can no longer trigger onDead recovery.
812
+ rpcEngagementFence.markDead(engagementLease);
613
813
  if (codexRpcEngine === engine) {
614
814
  log('Codex RPC app-server died; replacing the tmux session and re-engaging the thread');
615
815
  // failAll() rejects the active sendTurn promise immediately after this
@@ -627,18 +827,37 @@ async function engageCodexRpc(cfg) {
627
827
  },
628
828
  });
629
829
  await engine.start();
830
+ assertRpcEngagementCurrent();
630
831
  // Shell tools run under the app-server process tree, not the viewer TUI.
631
832
  // Mark its pid before the first turn so `botmux send` resolves the current
632
833
  // per-turn identity instead of falling back to a stale/session-only env.
633
- registerRpcEnginePidMarker(engine.appServerPid);
834
+ enginePidMarker = registerRpcEnginePidMarker(engine.appServerPid);
634
835
  const threadId = wantResume ? await engine.resumeThread(cfg.cliSessionId) : await engine.startThread();
836
+ assertRpcEngagementCurrent();
635
837
  let outcome = wantResume ? 'resumed' : 'accepted';
636
838
  if (!wantResume && cfg.prompt) {
839
+ const firstIdentity = {
840
+ turnId: cfg.turnId ?? `codex-rpc-${randomBytes(8).toString('hex')}`,
841
+ ...(cfg.dispatchAttempt !== undefined
842
+ ? { dispatchAttempt: cfg.dispatchAttempt }
843
+ : {}),
844
+ };
845
+ const firstGeneration = {
846
+ engine,
847
+ cliGeneration: engineCliGeneration,
848
+ };
849
+ installAwaitingRpcActivation(firstIdentity, firstGeneration);
637
850
  // Three-state delivery (P1-1, exactly-once priority): 'accepted' (ack or
638
851
  // rollout evidence), 'not-sent' (frame never dispatched → safe paste), or
639
852
  // 'ambiguous' (dispatched, unconfirmed → engaged but NEVER resend).
640
- const first = await engine.sendFirstTurn(cfg.prompt, cfg.turnId, (tid) => codexRolloutProbe(cfg.cliId, tid, cfg.prompt, 12_000));
641
- if (first === 'not-sent') {
853
+ const first = await engine.sendFirstTurn(cfg.prompt, firstIdentity, (tid) => codexRolloutProbe(cfg.cliId, tid, cfg.prompt, 12_000));
854
+ freshDeliveryOwned = first.outcome !== 'not-sent';
855
+ // restart/close may have settled this exact attempt as ambiguous while the
856
+ // rollout probe was pending. Fence before ANY durable/bridge/global engine
857
+ // mutation; a superseded delivery is never pasted or re-queued.
858
+ assertRpcEngagementCurrent();
859
+ if (first.outcome === 'not-sent') {
860
+ clearAwaitingRpcActivation(firstIdentity, firstGeneration);
642
861
  // The turn/start frame never left → the turn cannot have run → tear the
643
862
  // engine down and fall back to paste. flushPending marks the bridge once
644
863
  // on the paste path — we must NOT pre-mark here or that would double-mark
@@ -651,16 +870,69 @@ async function engageCodexRpc(cfg) {
651
870
  catch { /* best effort */ }
652
871
  return 'not-engaged';
653
872
  }
873
+ // Fresh RPC delivery bypasses flushPending(), which is the normal owner
874
+ // of this durable head-of-line gate. Claim it here only after the frame is
875
+ // known dispatched (accepted or ambiguous); the not-sent branch above
876
+ // safely falls back to paste and lets flushPending claim it once.
877
+ if (firstIdentity.dispatchAttempt !== undefined) {
878
+ durableTurnInFlight = true;
879
+ }
654
880
  // Bridge mark ONLY for a confirmed-accepted turn — so the structured
655
881
  // fallback can attribute the reply even if the model skips `botmux send`.
656
882
  // Marked here (after the outcome, before persistCliSessionId/attach — late
657
- // attach from offset 0 still matches, timing-safe). 'ambiguous' does NOT
658
- // mark: there is no positive evidence the turn ran, and an unstarted head
659
- // would permanently block every later turn's drain — the notify + Web
660
- // terminal are the authoritative recovery surface (Codex P1).
661
- if (shouldPreMarkFirstTurn(first))
662
- codexBridgeMarkPendingTurn(cfg.prompt, cfg.turnId);
663
- if (first === 'accepted' && cfg.queuedActivationToken) {
883
+ // attach from offset 0 still matches, timing-safe). An ambiguous outcome
884
+ // gets an attribution-only mark plus a separate fail-closed owner below:
885
+ // it cannot publish false idle or head-of-line a successor because no
886
+ // successor is admitted until terminal/teardown retires that owner.
887
+ if (shouldPreMarkFirstTurn(first.outcome)) {
888
+ if (!first.nativeTurnId) {
889
+ // Positive rollout evidence proves delivery, but without a native id
890
+ // no terminal can be associated precisely. Preserve exactly-once
891
+ // (never paste/requeue) and hold the explicit fail-closed lifecycle
892
+ // gate until a structured terminal or engine teardown instead of
893
+ // guessing a latest turn.
894
+ codexBridgeMarkPendingTurn(cfg.prompt, firstIdentity.turnId, firstIdentity.dispatchAttempt, awaitingRpcActivationReplayAnchorMs(firstIdentity, firstGeneration));
895
+ installRpcLifecycleFailClosedOwner(firstIdentity, firstGeneration);
896
+ deferredFreshRpcTurn = {
897
+ identity: firstIdentity,
898
+ generation: firstGeneration,
899
+ };
900
+ log(`Codex RPC fresh accepted turn has no native id; lifecycle failed closed for ${firstIdentity.turnId}`);
901
+ send({
902
+ type: 'user_notify',
903
+ message: 'Codex RPC 首条消息已确认落盘,但无法取得原生 turn id;为避免重复执行未重发,并保持忙碌直到检测到终态或会话重启。',
904
+ turnId: firstIdentity.turnId,
905
+ ...(firstIdentity.dispatchAttempt !== undefined
906
+ ? { dispatchAttempt: firstIdentity.dispatchAttempt }
907
+ : {}),
908
+ });
909
+ }
910
+ else {
911
+ // Keep terminal delivery deferred until spawnCli has attached and
912
+ // baselined the rollout; otherwise an ultra-fast first completion can
913
+ // retire the bridge mark before fallback output is harvested.
914
+ activateRpcTurnLifecycle(firstIdentity, cfg.prompt, false, firstGeneration, true);
915
+ deferredFreshRpcTurn = {
916
+ identity: firstIdentity,
917
+ generation: firstGeneration,
918
+ };
919
+ }
920
+ }
921
+ else {
922
+ // A dispatched-but-unconfirmed first turn is never re-sent. Keep an
923
+ // exact attribution mark plus an explicit fail-closed gate even without
924
+ // a native id. A structured transcript terminal can retire it; a later
925
+ // native terminal without an owner is intentionally ignored instead of
926
+ // guessed. If no structured terminal becomes visible, exact engine
927
+ // teardown is the expected fallback and drops this attempt's mark.
928
+ codexBridgeMarkPendingTurn(cfg.prompt, firstIdentity.turnId, firstIdentity.dispatchAttempt, awaitingRpcActivationReplayAnchorMs(firstIdentity, firstGeneration));
929
+ installRpcLifecycleFailClosedOwner(firstIdentity, firstGeneration);
930
+ deferredFreshRpcTurn = {
931
+ identity: firstIdentity,
932
+ generation: firstGeneration,
933
+ };
934
+ }
935
+ if (first.outcome === 'accepted' && cfg.queuedActivationToken) {
664
936
  // Fresh RPC input bypasses pendingMessages/flushPending entirely. The
665
937
  // app-server's accepted turn/start (or positive rollout proof) is the
666
938
  // durable submission boundary for the daemon's queued-opening journal.
@@ -670,7 +942,7 @@ async function engageCodexRpc(cfg) {
670
942
  activationToken: cfg.queuedActivationToken,
671
943
  });
672
944
  }
673
- outcome = first; // 'accepted' | 'ambiguous' — both stay engaged, prompt never re-queued
945
+ outcome = first.outcome; // accepted | ambiguous — both stay engaged, prompt never re-queued
674
946
  }
675
947
  codexRpcEngine = engine;
676
948
  remoteWsUrl = engine.wsUrl;
@@ -683,15 +955,43 @@ async function engageCodexRpc(cfg) {
683
955
  return outcome;
684
956
  }
685
957
  catch (err) {
958
+ if (err instanceof CliSpawnSupersededError
959
+ || !rpcEngagementFence.isCurrent(engagementLease)) {
960
+ log('Codex RPC engagement was superseded; stopping only its local app-server and preserving the replacement generation');
961
+ try {
962
+ engine?.stop();
963
+ }
964
+ catch { /* best effort */ }
965
+ if (enginePidMarker)
966
+ clearRpcEnginePidMarker(enginePidMarker);
967
+ throw err instanceof CliSpawnSupersededError
968
+ ? err
969
+ : new CliSpawnSupersededError();
970
+ }
971
+ if (err instanceof CodexRpcEngineDiedDuringEngagementError && freshDeliveryOwned) {
972
+ // The first frame may already have executed. Never turn this into
973
+ // `not-engaged`, because the init path would paste the same prompt into a
974
+ // native viewer and duplicate side effects. Tear down exact local state
975
+ // and fail the worker generation so durable recovery owns the next step.
976
+ log('Codex RPC app-server died after the fresh delivery boundary; aborting init without paste fallback');
977
+ clearRpcEnginePidMarker();
978
+ try {
979
+ engine?.stop();
980
+ }
981
+ catch { /* best effort */ }
982
+ stopCodexRpcEngine();
983
+ throw err;
984
+ }
686
985
  log(`Codex RPC input failed to start (${err?.message ?? err}); falling back to paste mode`);
687
986
  clearRpcEnginePidMarker();
688
987
  try {
689
988
  engine?.stop();
690
989
  }
691
990
  catch { /* best effort */ } // P1-3a: stop the LOCAL ref (codexRpcEngine may be unassigned)
692
- codexRpcEngine = undefined;
693
- remoteWsUrl = undefined;
694
- remoteThreadId = undefined;
991
+ // The local engine may not yet have been published into codexRpcEngine.
992
+ // Still clear any exact lifecycle state its callbacks installed before the
993
+ // failure; otherwise paste fallback could inherit a stale fail-closed mark.
994
+ stopCodexRpcEngine();
695
995
  return 'not-engaged';
696
996
  }
697
997
  }
@@ -754,6 +1054,9 @@ function armRpcStartupDialogDismiss() {
754
1054
  };
755
1055
  rpcDialogDismissTimer = setTimeout(tick, 2500);
756
1056
  }
1057
+ /** Monotonic identity for the owned/adopted CLI backend generation. Deferred
1058
+ * callbacks capture it and must re-check after every await before touching
1059
+ * transcript lifecycle or user-visible state. */
757
1060
  let cliSpawnGeneration = 0;
758
1061
  class CliSpawnSupersededError extends Error {
759
1062
  constructor() {
@@ -761,6 +1064,12 @@ class CliSpawnSupersededError extends Error {
761
1064
  this.name = 'CliSpawnSupersededError';
762
1065
  }
763
1066
  }
1067
+ class CodexRpcEngineDiedDuringEngagementError extends Error {
1068
+ constructor() {
1069
+ super('Codex RPC app-server died before engagement publication');
1070
+ this.name = 'CodexRpcEngineDiedDuringEngagementError';
1071
+ }
1072
+ }
764
1073
  let cliPidMarker = null; // path to .botmux-cli-pids/<pid>
765
1074
  let seatbeltProfilePath = null; // per-session Seatbelt .sb profile to rm at exit (external-wrapper read isolation)
766
1075
  let sandboxStopWatcher = null; // stop fn for the sandbox outbox watcher
@@ -1045,6 +1354,10 @@ let activeRestartAttemptId;
1045
1354
  /** Distinguishes a replacement's synchronous ready signal (Riff) from a late
1046
1355
  * idle callback emitted by the backend being torn down. */
1047
1356
  let replacementSpawnInProgress = false;
1357
+ let structuredStartGraceRecheckTimer = null;
1358
+ let structuredRejectedReadyEvidenceGeneration;
1359
+ const ptyOutputGeneration = new PtyOutputGeneration();
1360
+ const adoptWriteQueue = new AsyncSerialQueue();
1048
1361
  /** The effectiveResume flag used by the most recent spawnCli call. Written
1049
1362
  * immediately after the two-tier fallback check so late-attach timers
1050
1363
  * (hermes, cursor, etc.) can read THE SAME semantics the spawn used,
@@ -1527,6 +1840,25 @@ function shortCorrelationId(value) {
1527
1840
  * an owned CLI restart was fenced. Normal raw_input commands are still
1528
1841
  * delivered immediately (including while busy). */
1529
1842
  const pendingRawInputs = freshnessInputQueue.raw;
1843
+ /** Adopt messages accepted while native /rename owns the TUI. They cannot use
1844
+ * pendingMessages because adopt writes need writeAdoptMessage's transcript
1845
+ * baseline + complete adapter lifecycle. flushPending drains them only after
1846
+ * deferred raw commands and the latest native rename have settled. */
1847
+ const pendingAdoptMessages = [];
1848
+ function captureAdoptWriteFence() {
1849
+ if (!backend || cliRestartInProgress || rawInputRestartGate)
1850
+ return undefined;
1851
+ return { generation: cliSpawnGeneration, backend };
1852
+ }
1853
+ /** A queued adopt task may outlive its CLI. Never let process-lifetime queue
1854
+ * work run against backend=null or a replacement backend generation. Tasks
1855
+ * that fail this check have not written anything and are safe to requeue. */
1856
+ function adoptWriteFenceIsCurrent(fence) {
1857
+ return cliSpawnGeneration === fence.generation
1858
+ && backend === fence.backend
1859
+ && !cliRestartInProgress
1860
+ && !rawInputRestartGate;
1861
+ }
1530
1862
  /** Latest requested canonical session title. Unlike a normal prompt this is an
1531
1863
  * administrative TUI command: never type-ahead while the agent is busy, never
1532
1864
  * open a model turn, and latest-wins if several renames arrive before idle. */
@@ -1546,32 +1878,45 @@ function stopNativeSessionTitleSync() {
1546
1878
  forceClose();
1547
1879
  nativeSessionTitleSyncForceClosers.clear();
1548
1880
  }
1549
- /** True after the rename command's Enter lands and until the TUI returns to its
1550
- * prompt. Blocks type-ahead user messages from racing into the command UI. */
1551
- let sessionRenameInFlight = false;
1881
+ let sessionRenamePhase = 'idle';
1552
1882
  const SESSION_RENAME_IDLE_TIMEOUT_MS = 5_000;
1553
1883
  let sessionRenameIdleTimer = null;
1554
- function clearSessionRenameInFlight() {
1884
+ function sessionRenameInFlight() {
1885
+ return sessionRenamePhase !== 'idle';
1886
+ }
1887
+ function forceClearSessionRenameInFlight() {
1555
1888
  if (sessionRenameIdleTimer) {
1556
1889
  clearTimeout(sessionRenameIdleTimer);
1557
1890
  sessionRenameIdleTimer = null;
1558
1891
  }
1559
- sessionRenameInFlight = false;
1892
+ sessionRenamePhase = 'idle';
1893
+ }
1894
+ /** A prompt can release rename only after the command Enter was actually sent.
1895
+ * A fast final from an older adopt write may arrive while rename is merely
1896
+ * reserved behind the queue; keep that reservation and let the queued helper
1897
+ * consume the newly-ready prompt. */
1898
+ function settleSessionRenameOnPrompt() {
1899
+ if (sessionRenamePhase === 'sent')
1900
+ forceClearSessionRenameInFlight();
1901
+ }
1902
+ function settleFailedSessionRenameWrite() {
1903
+ if (sessionRenamePhase === 'writing')
1904
+ sessionRenamePhase = 'sent';
1560
1905
  }
1561
1906
  /** Fail open if a CLI executes /rename without emitting enough redraw output
1562
1907
  * for IdleDetector to rediscover the prompt. The verified Codex/Claude paths
1563
1908
  * normally settle immediately; this cap prevents one administrative command
1564
1909
  * from wedging all later Lark messages forever. */
1565
1910
  function armSessionRenameIdleTimeout() {
1566
- if (!sessionRenameInFlight)
1911
+ if (sessionRenamePhase !== 'sent')
1567
1912
  return;
1568
1913
  if (sessionRenameIdleTimer)
1569
1914
  clearTimeout(sessionRenameIdleTimer);
1570
1915
  sessionRenameIdleTimer = setTimeout(() => {
1571
1916
  sessionRenameIdleTimer = null;
1572
- if (!sessionRenameInFlight)
1917
+ if (sessionRenamePhase !== 'sent')
1573
1918
  return;
1574
- sessionRenameInFlight = false;
1919
+ sessionRenamePhase = 'idle';
1575
1920
  // The timeout specifically means prompt redraw detection failed. Fail open
1576
1921
  // by restoring the gate explicitly; otherwise a deferred raw_input is the
1577
1922
  // only queued work and flushPending would keep waiting for the very signal
@@ -1587,96 +1932,147 @@ function armSessionRenameIdleTimeout() {
1587
1932
  * Commands received while /rename owns the TUI are deferred by the IPC handler
1588
1933
  * and come through this same function after the prompt returns. */
1589
1934
  async function deliverRawInput(msg) {
1590
- const targetBackend = backend;
1591
- if (!targetBackend)
1592
- return;
1593
- let sent = false;
1594
- let recoveryFailureReason;
1595
- try {
1596
- await sendRawCommandLineWithRecoveryFence(targetBackend, msg.content, () => {
1597
- renderer?.markNewTurn();
1598
- usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
1599
- if (tmuxScrolledHalfPages > 0)
1600
- exitTmuxScrollMode();
1601
- // Rotate (or revoke) attribution only after both submission locks are
1602
- // held and immediately before the literal command reaches the CLI.
1603
- // A queued passthrough must not steal output from the preceding turn.
1604
- currentBotmuxTurnId = msg.turnId;
1605
- currentBotmuxDispatchAttempt = undefined;
1606
- currentVcMeetingImTurnOrigin = undefined;
1607
- writeCliPidMarker();
1608
- publishSandboxRelayCapability();
1609
- });
1610
- sent = true;
1611
- // The accepted side effects (isPromptReady/idle reset/log) are owned by
1612
- // finalizeRawCommandDelivery.onAccepted below so the durable-activation
1613
- // ACK and follow-up enqueue stay in one ordered dispatcher.
1614
- // A mid-session `/effort <level>` opens a Yes/No "Change effort level?"
1615
- // confirm dialog; arm the guard so onPtyData can auto-accept it. Bare
1616
- // `/effort` opens a level picker (unknown target) and is left alone.
1617
- if (isEffortLevelCommand(msg.content))
1618
- armEffortConfirm();
1619
- }
1620
- catch (err) {
1621
- recoveryFailureReason = err instanceof SubmissionWriteError
1622
- ? err.recoveryFailureReason
1623
- : undefined;
1624
- // Do not send another queued command against a backend whose write failed.
1625
- isPromptReady = false;
1626
- log(`Passthrough slash command failed (${msg.content}): ${err?.message ?? err}`);
1627
- const failedTurnId = msg.followUpTurnId ?? msg.turnId;
1628
- if (failedTurnId && !recoveryFailureReason) {
1629
- emitTurnTerminal(failedTurnId, 'ambiguous', 'raw_input_write_failed');
1630
- }
1631
- const failureMessageKey = msg.followUpContent
1632
- ? (recoveryFailureReason
1633
- ? 'worker.raw_input_failed_recovery'
1634
- : 'worker.raw_input_failed')
1635
- : (recoveryFailureReason
1636
- ? 'worker.raw_input_failed_command_only_recovery'
1637
- : 'worker.raw_input_failed_command_only');
1638
- send({
1639
- type: 'user_notify',
1640
- ...(failedTurnId ? { turnId: failedTurnId } : {}),
1641
- message: t(failureMessageKey, {
1642
- cliName: cliName(),
1643
- reason: recoveryFailureReason ?? '',
1935
+ const writeRawInput = async (targetBackend, fence, onFollowUp) => {
1936
+ let sent = false;
1937
+ let recoveryFailureReason;
1938
+ try {
1939
+ await sendRawCommandLineWithRecoveryFence(targetBackend, msg.content, () => {
1940
+ renderer?.markNewTurn();
1941
+ usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
1942
+ if (tmuxScrolledHalfPages > 0)
1943
+ exitTmuxScrollMode();
1944
+ currentBotmuxTurnId = msg.turnId;
1945
+ currentBotmuxDispatchAttempt = undefined;
1946
+ currentVcMeetingImTurnOrigin = undefined;
1947
+ writeCliPidMarker();
1948
+ publishSandboxRelayCapability();
1949
+ });
1950
+ sent = true;
1951
+ if (fence && !adoptWriteFenceIsCurrent(fence)) {
1952
+ send({
1953
+ type: 'user_notify',
1954
+ message: `Passthrough command ${msg.content} became ambiguous because the adopted CLI backend changed while it was being written. Its dependent follow-up was withheld; please verify the terminal and retry the bundle explicitly.`,
1955
+ });
1956
+ sent = false;
1957
+ }
1958
+ }
1959
+ catch (err) {
1960
+ recoveryFailureReason = err instanceof SubmissionWriteError
1961
+ ? err.recoveryFailureReason
1962
+ : undefined;
1963
+ // Do not send a bundled follow-up or another queued command against a
1964
+ // backend whose literal command write failed.
1965
+ log(`Passthrough slash command failed (${msg.content}): ${err?.message ?? err}`);
1966
+ const failedTurnId = msg.followUpTurnId ?? msg.turnId;
1967
+ if (failedTurnId && !recoveryFailureReason) {
1968
+ emitTurnTerminal(failedTurnId, 'ambiguous', 'raw_input_write_failed');
1969
+ }
1970
+ send({
1971
+ type: 'user_notify',
1972
+ ...(failedTurnId ? { turnId: failedTurnId } : {}),
1973
+ message: t(msg.followUpContent
1974
+ ? (recoveryFailureReason ? 'worker.raw_input_failed_recovery' : 'worker.raw_input_failed')
1975
+ : (recoveryFailureReason ? 'worker.raw_input_failed_command_only_recovery' : 'worker.raw_input_failed_command_only'), {
1976
+ cliName: cliName(),
1977
+ reason: recoveryFailureReason ?? '',
1978
+ }),
1979
+ });
1980
+ }
1981
+ const accepted = finalizeRawCommandDelivery({
1982
+ accepted: sent,
1983
+ durableActivation: !!msg.queuedActivationToken,
1984
+ acknowledgeActivation: !!msg.queuedActivationToken,
1985
+ hasFollowUp: !!onFollowUp,
1986
+ onAccepted: () => {
1987
+ isPromptReady = false;
1988
+ idleDetector?.reset();
1989
+ log(`Passthrough slash command: ${msg.content}`);
1990
+ },
1991
+ // Non-adopt follows the normal busy queue. Adopt keeps the complete
1992
+ // adapter lifecycle in runAdoptRawInputSequence below.
1993
+ onFollowUp: () => onFollowUp?.(),
1994
+ // Pending-repo raw openings are durable too. ACK only after text + Enter.
1995
+ onActivationAck: () => send({
1996
+ type: 'queued_activation_submitted',
1997
+ sessionId,
1998
+ activationToken: msg.queuedActivationToken,
1644
1999
  }),
2000
+ onDurableFailure: () => {
2001
+ isPromptReady = false;
2002
+ log('Durable raw activation write rejected; retiring worker generation');
2003
+ void sendFatalWorkerErrorAndExit(new Error('durable raw activation was not accepted by the backend'), msg.turnId);
2004
+ },
2005
+ });
2006
+ if (!accepted && !msg.queuedActivationToken) {
2007
+ log(`Passthrough slash command was not accepted by the backend: ${msg.content}`);
2008
+ }
2009
+ return accepted;
2010
+ };
2011
+ if (lastInitConfig?.adoptMode) {
2012
+ const fence = captureAdoptWriteFence();
2013
+ if (!fence) {
2014
+ pendingRawInputs.push(msg);
2015
+ log(`Deferred adopt passthrough until a stable CLI generation is ready: ${msg.content}`);
2016
+ return;
2017
+ }
2018
+ const followUpContent = msg.followUpContent;
2019
+ // Adopt raw commands, their bundled follow-up and ordinary adopt messages
2020
+ // all share one queue. Keep the queue until writeAdoptMessage has completed
2021
+ // transcript marking, adapter/history verification and lifecycle settling.
2022
+ await runAdoptRawInputSequence({
2023
+ queue: adoptWriteQueue,
2024
+ isCurrent: () => adoptWriteFenceIsCurrent(fence),
2025
+ onStaleBeforeWrite: () => {
2026
+ pendingRawInputs.push(msg);
2027
+ log(`Re-queued stale adopt passthrough for the replacement CLI generation: ${msg.content}`);
2028
+ },
2029
+ onStaleBeforeFollowUp: () => {
2030
+ if (!followUpContent)
2031
+ return;
2032
+ // The raw command already landed on the previous backend. Replaying
2033
+ // only its dependent prompt into the replacement could run in the
2034
+ // wrong repo/session (for example after `/cd ...`). Hold the bundle
2035
+ // for an explicit user retry instead of splitting its atomic meaning.
2036
+ send({
2037
+ type: 'user_notify',
2038
+ message: `Passthrough command ${msg.content} completed on the previous CLI, but the backend changed before its dependent follow-up. The follow-up was withheld; verify the terminal and retry the bundle explicitly.`,
2039
+ });
2040
+ log(`Withheld bundled adopt follow-up after CLI generation changed (${followUpContent.length} chars)`);
2041
+ },
2042
+ writeRawInput: () => writeRawInput(fence.backend, fence),
2043
+ ...(followUpContent
2044
+ ? {
2045
+ writeFollowUp: async () => {
2046
+ const result = await writeAdoptMessage(followUpContent, msg.followUpTurnId, undefined, undefined, fence);
2047
+ if (result === 'stale-before-write') {
2048
+ send({
2049
+ type: 'user_notify',
2050
+ message: `The CLI backend changed after passthrough command ${msg.content} but before its dependent follow-up. The follow-up was withheld; verify the terminal and retry the bundle explicitly.`,
2051
+ });
2052
+ log(`Withheld stale bundled adopt follow-up after raw command (${followUpContent.length} chars)`);
2053
+ }
2054
+ else if (result === 'completed') {
2055
+ log(`Completed adopt follow-up after raw input (${followUpContent.length} chars)`);
2056
+ }
2057
+ },
2058
+ }
2059
+ : {}),
1645
2060
  });
1646
2061
  }
1647
- finalizeRawCommandDelivery({
1648
- accepted: sent,
1649
- durableActivation: !!msg.queuedActivationToken,
1650
- acknowledgeActivation: !!msg.queuedActivationToken,
1651
- hasFollowUp: !!msg.followUpContent,
1652
- onAccepted: () => {
1653
- isPromptReady = false;
1654
- idleDetector?.reset();
1655
- log(`Passthrough slash command: ${msg.content}`);
1656
- },
1657
- // Follow-up rides on the same IPC and is enqueued only after this command's
1658
- // Enter lands. sendToPty also observes commandLineWritesPending, so another
1659
- // raw command's text -> Enter window cannot be interrupted by the follow-up.
1660
- onFollowUp: () => {
1661
- sendToPty(msg.followUpContent, msg.followUpTurnId, {
1662
- codexAppInput: msg.followUpCodexAppInput,
1663
- });
1664
- log(`Enqueued follow-up after raw input (${msg.followUpContent.length} chars)`);
1665
- },
1666
- // Pending-repo raw openings are durable too. ACK only after text + Enter.
1667
- onActivationAck: () => send({
1668
- type: 'queued_activation_submitted',
1669
- sessionId,
1670
- activationToken: msg.queuedActivationToken,
1671
- }),
1672
- onDurableFailure: () => {
1673
- isPromptReady = false;
1674
- log(`Durable raw activation write rejected; retiring worker generation`);
1675
- void sendFatalWorkerErrorAndExit(new Error('durable raw activation was not accepted by the backend'), msg.turnId);
1676
- },
1677
- });
1678
- if (!sent && !msg.queuedActivationToken) {
1679
- log(`Passthrough slash command was not accepted by the backend: ${msg.content}`);
2062
+ else {
2063
+ const targetBackend = backend;
2064
+ if (!targetBackend) {
2065
+ pendingRawInputs.push(msg);
2066
+ return;
2067
+ }
2068
+ await writeRawInput(targetBackend, undefined, msg.followUpContent
2069
+ ? () => {
2070
+ sendToPty(msg.followUpContent, msg.followUpTurnId, {
2071
+ codexAppInput: msg.followUpCodexAppInput,
2072
+ });
2073
+ log(`Enqueued follow-up after raw input (${msg.followUpContent.length} chars)`);
2074
+ }
2075
+ : undefined);
1680
2076
  }
1681
2077
  // A pending /rename may have been held by the command-write mutex. It still
1682
2078
  // waits for a genuine prompt because isPromptReady was cleared above.
@@ -1860,7 +2256,9 @@ function authorizeManagedSend(claim) {
1860
2256
  }
1861
2257
  : { ok: false, error: `${decision.errorCode}: ${decision.error}` };
1862
2258
  }
1863
- function clearRpcEnginePidMarker() {
2259
+ function clearRpcEnginePidMarker(expectedMarker) {
2260
+ if (expectedMarker !== undefined && rpcEnginePidMarker !== expectedMarker)
2261
+ return;
1864
2262
  if (!rpcEnginePidMarker)
1865
2263
  return;
1866
2264
  try {
@@ -1872,17 +2270,19 @@ function clearRpcEnginePidMarker() {
1872
2270
  function registerRpcEnginePidMarker(pid) {
1873
2271
  clearRpcEnginePidMarker();
1874
2272
  if (!pid || !process.env.SESSION_DATA_DIR)
1875
- return;
2273
+ return null;
1876
2274
  try {
1877
2275
  const markersDir = join(process.env.SESSION_DATA_DIR, '.botmux-cli-pids');
1878
2276
  mkdirSync(markersDir, { recursive: true });
1879
2277
  rpcEnginePidMarker = join(markersDir, String(pid));
1880
2278
  writeCliPidMarker();
1881
2279
  log(`Codex RPC app-server PID marker written: ${pid}`);
2280
+ return rpcEnginePidMarker;
1882
2281
  }
1883
2282
  catch (err) {
1884
2283
  rpcEnginePidMarker = null;
1885
2284
  log(`Failed to write Codex RPC app-server PID marker: ${err?.message ?? err}`);
2285
+ return null;
1886
2286
  }
1887
2287
  }
1888
2288
  function writeCliPidMarker() {
@@ -4218,6 +4618,10 @@ function emitReadyTurns(opts = {}) {
4218
4618
  const remainingPending = bridgeQueue.peek();
4219
4619
  const nextPendingMarkTimeMs = remainingPending.length > 0 ? remainingPending[0].markTimeMs : undefined;
4220
4620
  const cache = new Map();
4621
+ // Turns suppressed as GENUINE SILENCE — see emitReadyCodexTurns for the full
4622
+ // rationale. Tracked by object identity across this function's two loops so
4623
+ // the terminal can carry positive silence evidence for a durable/async caller.
4624
+ const nothingToSendTurns = new Set();
4221
4625
  for (let i = 0; i < ready.length; i++) {
4222
4626
  const turn = ready[i];
4223
4627
  const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
@@ -4250,6 +4654,11 @@ function emitReadyTurns(opts = {}) {
4250
4654
  if (shouldSuppressBridgeEmit(gateInput, nextBoundaryMs, markers, adoptMode)) {
4251
4655
  const reason = turn.isLocal ? 'local-typed' : 'model called botmux send within window';
4252
4656
  log(`Bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (${reason})`);
4657
+ // Positive silence evidence for the terminal — only a bare nothing-to-send
4658
+ // sentinel (no prose, no send), never "already sent" / local-typed.
4659
+ if (!adoptMode && isBridgeNothingToSendFinal(assistantText)) {
4660
+ nothingToSendTurns.add(turn);
4661
+ }
4253
4662
  notifyExplicitReplyObserved(turn.turnId, explicitReplyMarkerForTurnWindow(gateInput, nextBoundaryMs, markers, adoptMode));
4254
4663
  continue;
4255
4664
  }
@@ -4318,7 +4727,7 @@ function emitReadyTurns(opts = {}) {
4318
4727
  // only after all corresponding final_output IPC messages have been queued so
4319
4728
  // the daemon observes a stable per-turn ordering.
4320
4729
  for (const turn of ready)
4321
- emitTurnTerminal(turn.turnId, 'completed', undefined, turn.dispatchAttempt);
4730
+ emitTurnTerminal(turn.turnId, 'completed', undefined, turn.dispatchAttempt, nothingToSendTurns.has(turn) ? 'nothing_to_send' : undefined);
4322
4731
  }
4323
4732
  /** Drain `path` from `fromOffset` and feed the events to the bridge queue
4324
4733
  * with that path as the source stamp. Pure side-effects on bridgeQueue +
@@ -4346,6 +4755,23 @@ function codexBridgeFallbackActive() {
4346
4755
  // services/structured-bridge-clis.ts (single source of the allowlist).
4347
4756
  return isStructuredBridgeFallbackActive(lastInitConfig?.cliId, lastInitConfig?.adoptMode === true);
4348
4757
  }
4758
+ /** Only drivers with a complete normal-final + interrupted-terminal contract
4759
+ * may let a transcript-started turn override the screen-ready heuristic. */
4760
+ function hasStructuredLifecycleBlock() {
4761
+ if (rpcTurnsAwaitingActivation.size > 0)
4762
+ return true;
4763
+ if (rpcLifecycleFailClosedOwners.size > 0)
4764
+ return true;
4765
+ if (rpcTerminalHydrationOwners.size > 0)
4766
+ return true;
4767
+ // rpcActive is an explicit native app-server ownership proof and is valid for
4768
+ // every RPC-capable Codex-family adapter (currently codex + traex), including
4769
+ // drivers intentionally excluded from transcript-only lifecycle blocking.
4770
+ if (codexBridgeQueue.peek().some(turn => turn.rpcActive === true && turn.finalText === undefined))
4771
+ return true;
4772
+ return isStructuredBridgeLifecycleBlockingCli(lastInitConfig?.cliId)
4773
+ && codexBridgeQueue.hasBlockingTurn();
4774
+ }
4349
4775
  function structuredBridgeIsCodex() {
4350
4776
  return lastInitConfig?.cliId === 'codex';
4351
4777
  }
@@ -4400,7 +4826,10 @@ function codexBridgeStartTimer() {
4400
4826
  // up. Emitting here when isPromptReady=true closes that window.
4401
4827
  // Codex's queue only releases turns on `assistant_final` (the model's
4402
4828
  // declared end-of-turn), so a tick-driven emit can't accidentally
4403
- // publish a half-streamed response.
4829
+ // publish a half-streamed response. The finally-path also expires stale
4830
+ // attribution heads after every tick even when no transcript exists yet or
4831
+ // its offset produced no events; otherwise an adapter returning undefined
4832
+ // before late-attach could leave a bare fingerprint at the head forever.
4404
4833
  codexBridgeTimer = setInterval(() => {
4405
4834
  try {
4406
4835
  if (structuredBridgeIsHermes()) {
@@ -4512,6 +4941,18 @@ function codexBridgeStartTimer() {
4512
4941
  catch (err) {
4513
4942
  log(`Codex bridge tick error: ${err.message}`);
4514
4943
  }
4944
+ finally {
4945
+ // All branch returns above still pass through here. Ingest always gets
4946
+ // first chance to claim a boundary-time user event; only afterwards do
4947
+ // we expire an unstarted head, replay buffered successors, and emit any
4948
+ // completion created by that replay in the same call stack.
4949
+ try {
4950
+ pruneExpiredStructuredHeadsAndEmit('structured bridge tick');
4951
+ }
4952
+ catch (err) {
4953
+ log(`Codex bridge tick expiry error: ${err.message}`);
4954
+ }
4955
+ }
4515
4956
  }, 1000);
4516
4957
  }
4517
4958
  function hermesBridgeAttach(mode) {
@@ -4547,7 +4988,8 @@ function hermesBridgeIngest() {
4547
4988
  if (filtered.events.length > 0)
4548
4989
  lastStructuredBridgeActivityAtMs = Date.now();
4549
4990
  codexBridgeQueue.ingest(filtered.events);
4550
- if (filtered.events.some(e => e.kind === 'assistant_final')) {
4991
+ pruneExpiredStructuredHeadsAndEmit('Hermes ingest');
4992
+ if (filtered.events.some(event => event.kind === 'assistant_final')) {
4551
4993
  idleDetector?.fireIdle();
4552
4994
  }
4553
4995
  }
@@ -4559,6 +5001,7 @@ function mtrBridgeAttach(source, mode) {
4559
5001
  const { history, live } = splitCodexEventsByCutoff(result.events, cutoff);
4560
5002
  codexBridgeQueue.absorb(history);
4561
5003
  codexBridgeQueue.ingest(live);
5004
+ pruneExpiredStructuredHeadsAndEmit('MTR split-live attach');
4562
5005
  mtrBridgeOffset = result.newOffset;
4563
5006
  mtrBridgeBaselineDone = true;
4564
5007
  log(`MTR bridge split-live: ${source.dbPath}#${source.sessionId} (history=${history.length}, live=${live.length}, cutoff=${cutoff}, offset=${mtrBridgeOffset})`);
@@ -4587,7 +5030,8 @@ function mtrBridgeIngest() {
4587
5030
  if (result.events.length > 0)
4588
5031
  lastStructuredBridgeActivityAtMs = Date.now();
4589
5032
  codexBridgeQueue.ingest(result.events);
4590
- if (result.events.some(e => e.kind === 'assistant_final')) {
5033
+ pruneExpiredStructuredHeadsAndEmit('MTR ingest');
5034
+ if (result.events.some(event => event.kind === 'assistant_final')) {
4591
5035
  idleDetector?.fireIdle();
4592
5036
  }
4593
5037
  }
@@ -4619,6 +5063,14 @@ function codexBridgeAttach(rolloutPath, mode) {
4619
5063
  const { history, live } = splitCodexEventsByCutoff(result.events, cutoff);
4620
5064
  codexBridgeQueue.absorb(history);
4621
5065
  codexBridgeQueue.ingest(live);
5066
+ pruneExpiredStructuredHeadsAndEmit('structured split-live attach');
5067
+ // Late attach can discover an already-completed live turn in the same
5068
+ // drain. Re-drive prompt readiness from that terminal event immediately;
5069
+ // otherwise a ready edge rejected by the lifecycle gate waits for the
5070
+ // 20-30s lease timer even though the transcript has already ended.
5071
+ if (live.some(event => event.kind === 'assistant_final')) {
5072
+ idleDetector?.fireIdle();
5073
+ }
4622
5074
  codexBridgeOffset = result.newOffset;
4623
5075
  codexBridgePendingTail = result.pendingTail;
4624
5076
  codexBridgeBaselineDone = true;
@@ -5107,6 +5559,13 @@ function maybeFollowTraexSessionRotationViaPid() {
5107
5559
  codexBridgeNotifyCliSessionId(observed.cliSessionId);
5108
5560
  }
5109
5561
  function codexBridgeIngest(opts = {}) {
5562
+ // Follow-up RPC turns install their exact bridge mark only after the
5563
+ // turn/start ACK passes the generation fence. Ordinary ingest must not
5564
+ // advance the rollout cursor in that window. Terminal hydration for an
5565
+ // older owner may continue: successor events reached by the same drain stay
5566
+ // buffered until the exact successor mark is installed.
5567
+ if (rpcTranscriptIngestBlockedByAwaitingActivation(rpcTurnsAwaitingActivation.keys(), opts.hydrationOwnerKey))
5568
+ return;
5110
5569
  if (structuredBridgeIsHermes()) {
5111
5570
  hermesBridgeIngest();
5112
5571
  return;
@@ -5139,13 +5598,14 @@ function codexBridgeIngest(opts = {}) {
5139
5598
  if (result.events.length > 0)
5140
5599
  lastStructuredBridgeActivityAtMs = Date.now();
5141
5600
  codexBridgeQueue.ingest(result.events);
5142
- // Transcript-driven idle: an `assistant_final` event is the CLI declaring
5143
- // end-of-turn, far more reliable than the screen-pattern heuristic
5601
+ pruneExpiredStructuredHeadsAndEmit('structured ingest');
5602
+ // Transcript-driven idle: a normal `assistant_final` or no-output
5603
+ // `turn_aborted` is Codex declaring end-of-turn, far more reliable than the screen-pattern heuristic
5144
5604
  // (CoCo's status bar varies by --yolo flag, version, theme; codex has
5145
5605
  // its own moving targets). Pushing idle here lets the bridge emit
5146
5606
  // immediately instead of waiting for readyPattern + quiescence to
5147
5607
  // converge. Idempotent — IdleDetector.fireIdle no-ops while already idle.
5148
- if (opts.signalIdle !== false && result.events.some(e => e.kind === 'assistant_final')) {
5608
+ if (opts.signalIdle !== false && result.events.some(event => event.kind === 'assistant_final')) {
5149
5609
  idleDetector?.fireIdle();
5150
5610
  }
5151
5611
  }
@@ -5172,11 +5632,309 @@ function maybeEmitCodexStructuredRateLimit(events) {
5172
5632
  /** Mark a pending Lark turn for Codex. Crucially this works even before a
5173
5633
  * rollout path is known — the queue is path-agnostic, and ingest after
5174
5634
  * late-attach picks up the user_message and matches the fingerprint. */
5175
- function codexBridgeMarkPendingTurn(messageText, preferredTurnId, dispatchAttempt) {
5635
+ function codexBridgeMarkPendingTurn(messageText, preferredTurnId, dispatchAttempt, markTimeMs = Date.now()) {
5176
5636
  if (!codexBridgeFallbackActive())
5177
- return false;
5637
+ return undefined;
5178
5638
  const turnId = preferredTurnId ?? `codex-${randomBytes(8).toString('hex')}`;
5179
- codexBridgeQueue.mark(turnId, messageText, Date.now(), dispatchAttempt);
5639
+ codexBridgeQueue.mark(turnId, messageText, markTimeMs, dispatchAttempt);
5640
+ return turnId;
5641
+ }
5642
+ function finalizeRpcTurnTerminal(terminal, generation, dropPending) {
5643
+ const { identity } = terminal;
5644
+ const ownerKey = rpcTurnOwnerKey(identity);
5645
+ if (!sameRpcGeneration(settlingRpcTerminalOwners.get(ownerKey), generation))
5646
+ return;
5647
+ const timer = rpcTerminalHydrationTimers.get(ownerKey);
5648
+ if (timer)
5649
+ clearTimeout(timer);
5650
+ rpcTerminalHydrationTimers.delete(ownerKey);
5651
+ if (sameRpcGeneration(rpcTerminalHydrationOwners.get(ownerKey), generation)) {
5652
+ rpcTerminalHydrationOwners.delete(ownerKey);
5653
+ rpcTerminalHydrationTerminals.delete(ownerKey);
5654
+ }
5655
+ settlingRpcTerminalOwners.delete(ownerKey);
5656
+ clearAwaitingRpcActivation(identity, generation);
5657
+ clearRpcLifecycleFailClosedOwner(identity, generation);
5658
+ if (sameRpcGeneration(rpcActiveOwners.get(ownerKey), generation)) {
5659
+ rpcActiveOwners.delete(ownerKey);
5660
+ }
5661
+ codexBridgeQueue.stopRpcActive(identity.turnId, identity.dispatchAttempt);
5662
+ if (dropPending) {
5663
+ codexBridgeQueue.dropPendingTurn(identity.turnId, identity.dispatchAttempt, true);
5664
+ }
5665
+ // RPC `aborted` means the turn had already started executing before it was
5666
+ // interrupted, so its side effects may have already run. Map it to
5667
+ // `ambiguous` (fail-closed, no auto-retry) to match the transcript path
5668
+ // (codex-transcript.ts turn_aborted -> ambiguous). Only a genuine app-server
5669
+ // `failed` is retryable. Otherwise durable delivery would auto-redispatch and
5670
+ // re-run already-executed side effects — exactly what this PR guards against.
5671
+ const status = terminal.status === 'completed' ? 'completed'
5672
+ : terminal.status === 'failed' ? 'failed'
5673
+ : 'ambiguous';
5674
+ const errorCode = terminal.errorCode
5675
+ ?? (terminal.status === 'engine-dead' ? 'rpc_engine_dead'
5676
+ : terminal.status === 'stopped' ? 'rpc_engine_stopped'
5677
+ : terminal.status === 'aborted' ? 'rpc_turn_aborted'
5678
+ : terminal.status === 'failed' ? 'rpc_turn_failed'
5679
+ : undefined);
5680
+ emitTurnTerminal(identity.turnId, status, errorCode, identity.dispatchAttempt);
5681
+ log(`Codex RPC terminal ${terminal.status}: native=${terminal.nativeTurnId.slice(0, 12)} `
5682
+ + `turn=${identity.turnId.slice(0, 12)} attempt=${identity.dispatchAttempt ?? '-'}`);
5683
+ redriveRejectedStructuredReady();
5684
+ idleDetector?.fireIdle();
5685
+ }
5686
+ /** turn/completed can race ahead of rollout fs visibility even though it is an
5687
+ * authoritative execution terminal. Release rpcActive immediately, but retain
5688
+ * a short non-ready hydration gate while polling the structured transcript.
5689
+ * This preserves fallback final output without allowing the stale fingerprint
5690
+ * to head-of-line block a new RPC turn indefinitely. */
5691
+ function hydrateCompletedRpcTurn(terminal, generation, attempt = 0) {
5692
+ const { identity } = terminal;
5693
+ const ownerKey = rpcTurnOwnerKey(identity);
5694
+ if (!sameRpcGeneration(settlingRpcTerminalOwners.get(ownerKey), generation))
5695
+ return;
5696
+ // restartCliProcess advances the generation before its asynchronous teardown.
5697
+ // Do not let an old hydration timer touch a bridge queue that may already be
5698
+ // owned by the replacement. stopCodexRpcEngine will synchronously retire the
5699
+ // old generation when teardown reaches the engine.
5700
+ if (cliSpawnGeneration !== generation.cliGeneration
5701
+ || codexRpcEngine !== generation.engine)
5702
+ return;
5703
+ try {
5704
+ codexBridgeDrainAndMaybeEmit({
5705
+ signalIdle: false,
5706
+ hydrationOwnerKey: ownerKey,
5707
+ });
5708
+ }
5709
+ catch { /* retry */ }
5710
+ if (!codexBridgeQueue.hasPendingTurn(identity.turnId, identity.dispatchAttempt)) {
5711
+ // Structured ingest already emitted final_output + terminal in the canonical
5712
+ // order. The explicit native terminal below is idempotently suppressed.
5713
+ finalizeRpcTurnTerminal(terminal, generation, false);
5714
+ return;
5715
+ }
5716
+ // The native terminal can beat rollout persistence by seconds on a cold or
5717
+ // busy filesystem. Keep this bounded (~11.6s), aligned with the fresh-turn
5718
+ // 12s positive-evidence probe, rather than dropping fallback output after the
5719
+ // previous 1.55s window.
5720
+ const delays = CODEX_RPC_TERMINAL_HYDRATION_DELAYS_MS;
5721
+ if (attempt >= delays.length) {
5722
+ finalizeRpcTurnTerminal(terminal, generation, true);
5723
+ return;
5724
+ }
5725
+ const timer = setTimeout(() => {
5726
+ rpcTerminalHydrationTimers.delete(ownerKey);
5727
+ hydrateCompletedRpcTurn(terminal, generation, attempt + 1);
5728
+ }, delays[attempt]);
5729
+ timer.unref?.();
5730
+ rpcTerminalHydrationTimers.set(ownerKey, timer);
5731
+ }
5732
+ function settleRpcTurnTerminal(terminal, generation) {
5733
+ const ownerKey = rpcTurnOwnerKey(terminal.identity);
5734
+ const existingSettlement = settlingRpcTerminalOwners.get(ownerKey);
5735
+ if (existingSettlement) {
5736
+ if (!sameRpcGeneration(existingSettlement, generation)) {
5737
+ log(`Ignored stale Codex RPC terminal settlement for ${terminal.identity.turnId}`);
5738
+ }
5739
+ return;
5740
+ }
5741
+ settlingRpcTerminalOwners.set(ownerKey, generation);
5742
+ clearAwaitingRpcActivation(terminal.identity, generation);
5743
+ clearRpcLifecycleFailClosedOwner(terminal.identity, generation);
5744
+ if (sameRpcGeneration(rpcActiveOwners.get(ownerKey), generation)) {
5745
+ rpcActiveOwners.delete(ownerKey);
5746
+ }
5747
+ codexBridgeQueue.stopRpcActive(terminal.identity.turnId, terminal.identity.dispatchAttempt);
5748
+ if (terminal.status === 'completed'
5749
+ && codexBridgeQueue.hasPendingTurn(terminal.identity.turnId, terminal.identity.dispatchAttempt)) {
5750
+ rpcTerminalHydrationOwners.set(ownerKey, generation);
5751
+ rpcTerminalHydrationTerminals.set(ownerKey, { terminal, generation });
5752
+ hydrateCompletedRpcTurn(terminal, generation);
5753
+ return;
5754
+ }
5755
+ finalizeRpcTurnTerminal(terminal, generation, true);
5756
+ }
5757
+ function handleRpcTurnTerminal(terminal, generation) {
5758
+ const ownerKey = rpcTurnOwnerKey(terminal.identity);
5759
+ const awaiting = rpcTurnsAwaitingActivation.get(ownerKey);
5760
+ if (awaiting && sameRpcGeneration(awaiting, generation)) {
5761
+ // Response + turn/completed can be decoded before the await continuation
5762
+ // installs rpcActive. The exact terminal is replayed immediately after that
5763
+ // activation, never against a guessed/latest queue entry.
5764
+ pendingRpcTurnTerminals.set(ownerKey, { terminal, generation });
5765
+ return;
5766
+ }
5767
+ if (awaiting && !sameRpcGeneration(awaiting, generation)) {
5768
+ log(`Ignored stale Codex RPC terminal from replaced engine for ${terminal.identity.turnId}`);
5769
+ return;
5770
+ }
5771
+ const activeGeneration = rpcActiveOwners.get(ownerKey);
5772
+ const failedClosedGeneration = rpcLifecycleFailClosedOwners.get(ownerKey);
5773
+ if (!sameRpcGeneration(activeGeneration, generation)
5774
+ && !sameRpcGeneration(failedClosedGeneration, generation)) {
5775
+ // The structured bridge may already have consumed the same turn and emitted
5776
+ // its terminal, or a replacement generation may own the same logical
5777
+ // delivery key. In either case this callback has no queue state to mutate.
5778
+ log(`Codex RPC terminal already retired: native=${terminal.nativeTurnId.slice(0, 12)} `
5779
+ + `turn=${terminal.identity.turnId.slice(0, 12)}`);
5780
+ return;
5781
+ }
5782
+ settleRpcTurnTerminal(terminal, generation);
5783
+ }
5784
+ /** Install the exact bridge mark + rpcActive hand-off after turn/start is known
5785
+ * accepted. Failure is fail-closed: a separate lifecycle gate remains asserted
5786
+ * for this owner until its native terminal/engine teardown, so a bookkeeping
5787
+ * bug cannot publish false idle. */
5788
+ function activateRpcTurnLifecycle(identity, messageText, alreadyMarked, generation, deferTerminal = false) {
5789
+ const ownerKey = rpcTurnOwnerKey(identity);
5790
+ if (!sameRpcGeneration(rpcTurnsAwaitingActivation.get(ownerKey), generation)) {
5791
+ log(`Refused stale Codex RPC lifecycle activation for ${identity.turnId}`);
5792
+ return false;
5793
+ }
5794
+ const replayAnchorMs = awaitingRpcActivationReplayAnchorMs(identity, generation);
5795
+ let marked = alreadyMarked
5796
+ && (codexBridgeQueue.hasPendingTurn(identity.turnId, identity.dispatchAttempt)
5797
+ || codexBridgeQueue.hasTerminalTurn(identity.turnId, identity.dispatchAttempt));
5798
+ if (!alreadyMarked) {
5799
+ const bridgeTurnId = codexBridgeMarkPendingTurn(messageText, identity.turnId, identity.dispatchAttempt, replayAnchorMs);
5800
+ marked = bridgeTurnId === identity.turnId;
5801
+ }
5802
+ // An older turn's terminal hydration may have drained and buffered this
5803
+ // successor's complete user/final pair while turn/start ACK was still
5804
+ // pending. mark() replays that pair synchronously. Treat the exact transcript
5805
+ // terminal as an already-retired activation instead of failing closed merely
5806
+ // because markRpcActive correctly refuses a completed queue entry.
5807
+ const transcriptTerminalObserved = marked
5808
+ && codexBridgeQueue.hasTerminalTurn(identity.turnId, identity.dispatchAttempt);
5809
+ const activated = transcriptTerminalObserved
5810
+ || (marked && codexBridgeQueue.markRpcActive(identity.turnId, identity.dispatchAttempt));
5811
+ if (activated && !transcriptTerminalObserved) {
5812
+ rpcActiveOwners.set(ownerKey, generation);
5813
+ }
5814
+ if (transcriptTerminalObserved)
5815
+ emitReadyCodexTurns();
5816
+ if (!deferTerminal
5817
+ && sameRpcGeneration(rpcTurnsAwaitingActivation.get(ownerKey), generation)) {
5818
+ rpcTurnsAwaitingActivation.delete(ownerKey);
5819
+ rpcTurnsAwaitingActivationIdentities.delete(ownerKey);
5820
+ rpcTurnsAwaitingActivationReplayAnchors.delete(ownerKey);
5821
+ }
5822
+ if (!activated) {
5823
+ if (marked)
5824
+ codexBridgeQueue.dropPendingTurn(identity.turnId, identity.dispatchAttempt, true);
5825
+ installRpcLifecycleFailClosedOwner(identity, generation);
5826
+ log(`Codex RPC lifecycle failed closed: mark=${marked} active=${activated} `
5827
+ + `turn=${identity.turnId.slice(0, 12)} attempt=${identity.dispatchAttempt ?? '-'}`);
5828
+ send({
5829
+ type: 'user_notify',
5830
+ message: 'Codex RPC 已接收消息,但本地生命周期登记失败;在原生终态到达前保持忙碌,未自动重发。',
5831
+ turnId: identity.turnId,
5832
+ ...(identity.dispatchAttempt !== undefined
5833
+ ? { dispatchAttempt: identity.dispatchAttempt }
5834
+ : {}),
5835
+ });
5836
+ }
5837
+ const pendingTerminal = pendingRpcTurnTerminals.get(ownerKey);
5838
+ if (pendingTerminal
5839
+ && sameRpcGeneration(pendingTerminal.generation, generation)
5840
+ && !deferTerminal) {
5841
+ settleRpcTurnTerminal(pendingTerminal.terminal, generation);
5842
+ }
5843
+ return activated;
5844
+ }
5845
+ function releaseRpcTurnTerminalDeferral(identity, generation) {
5846
+ const ownerKey = rpcTurnOwnerKey(identity);
5847
+ if (sameRpcGeneration(rpcTurnsAwaitingActivation.get(ownerKey), generation)) {
5848
+ rpcTurnsAwaitingActivation.delete(ownerKey);
5849
+ rpcTurnsAwaitingActivationIdentities.delete(ownerKey);
5850
+ rpcTurnsAwaitingActivationReplayAnchors.delete(ownerKey);
5851
+ }
5852
+ const pendingTerminal = pendingRpcTurnTerminals.get(ownerKey);
5853
+ if (pendingTerminal && sameRpcGeneration(pendingTerminal.generation, generation)) {
5854
+ settleRpcTurnTerminal(pendingTerminal.terminal, generation);
5855
+ }
5856
+ }
5857
+ function retireRpcLifecycleFromStructuredTerminal(identity) {
5858
+ const ownerKey = rpcTurnOwnerKey(identity);
5859
+ // Native completion hydration owns its own finalization after this canonical
5860
+ // drain returns. Do not tear down that generation's settlement underneath it.
5861
+ if (settlingRpcTerminalOwners.has(ownerKey))
5862
+ return;
5863
+ const generation = rpcActiveOwners.get(ownerKey)
5864
+ ?? rpcLifecycleFailClosedOwners.get(ownerKey)
5865
+ ?? rpcTurnsAwaitingActivation.get(ownerKey);
5866
+ if (!generation)
5867
+ return;
5868
+ clearAwaitingRpcActivation(identity, generation);
5869
+ if (sameRpcGeneration(rpcActiveOwners.get(ownerKey), generation)) {
5870
+ rpcActiveOwners.delete(ownerKey);
5871
+ }
5872
+ clearRpcLifecycleFailClosedOwner(identity, generation);
5873
+ codexBridgeQueue.stopRpcActive(identity.turnId, identity.dispatchAttempt);
5874
+ }
5875
+ /** Expire confirmed or attribution-only unstarted queue heads at an explicit worker
5876
+ * lifecycle boundary. Pruning can replay a buffered successor user+final and
5877
+ * thereby produce an immediately emittable completion, so query/projection
5878
+ * methods must never do this mutation invisibly: every removal funnels
5879
+ * through this helper and drains ready output in the same call stack. */
5880
+ function pruneExpiredStructuredHeadsAndEmit(source) {
5881
+ let fenceCurrentRpcEngine = false;
5882
+ const dropped = pruneExpiredPreStartHeadsAndEmit(codexBridgeQueue, emitReadyCodexTurns, undefined, turns => {
5883
+ // A bounded pre-start lease is also the terminal boundary for a durable
5884
+ // delivery attempt that the CLI accepted but never wrote to transcript.
5885
+ // Settle N before the prune replay drains successor N+1; ordinary IM
5886
+ // turns have no dispatchAttempt and remain log-only as before.
5887
+ for (const turn of turns) {
5888
+ const identity = {
5889
+ turnId: turn.turnId,
5890
+ ...(turn.dispatchAttempt !== undefined
5891
+ ? { dispatchAttempt: turn.dispatchAttempt }
5892
+ : {}),
5893
+ };
5894
+ const ownerKey = rpcTurnOwnerKey(identity);
5895
+ const failedClosedGeneration = rpcLifecycleFailClosedOwners.get(ownerKey);
5896
+ const retiredRpcOwner = failedClosedGeneration
5897
+ ? clearRpcLifecycleFailClosedOwner(identity, failedClosedGeneration)
5898
+ : false;
5899
+ let shouldFenceRpcEngine = false;
5900
+ if (retiredRpcOwner && failedClosedGeneration) {
5901
+ clearAwaitingRpcActivation(identity, failedClosedGeneration);
5902
+ if (deferredFreshRpcTurn
5903
+ && rpcTurnOwnerKey(deferredFreshRpcTurn.identity) === ownerKey
5904
+ && sameRpcGeneration(deferredFreshRpcTurn.generation, failedClosedGeneration)) {
5905
+ deferredFreshRpcTurn = undefined;
5906
+ }
5907
+ if (codexRpcEngine === failedClosedGeneration.engine
5908
+ && cliSpawnGeneration === failedClosedGeneration.cliGeneration
5909
+ && !cliRestartInProgress) {
5910
+ fenceCurrentRpcEngine = true;
5911
+ shouldFenceRpcEngine = true;
5912
+ }
5913
+ }
5914
+ if (retiredRpcOwner || turn.dispatchAttempt !== undefined) {
5915
+ emitTurnTerminal(turn.turnId, 'ambiguous', retiredRpcOwner
5916
+ ? 'rpc_delivery_ambiguous_timeout'
5917
+ : 'structured_start_timeout', turn.dispatchAttempt);
5918
+ }
5919
+ // Publish/retire N before a synchronous immediate restart can clear the
5920
+ // current exact identity in killCli(). The callback still installs the
5921
+ // restart fence before it returns, so the helper cannot emit buffered
5922
+ // successor N+1 against this ambiguous engine generation.
5923
+ if (shouldFenceRpcEngine) {
5924
+ void restartCliProcess('Codex RPC ambiguous delivery exceeded its bounded attribution lease', { immediate: true, preservePending: true });
5925
+ }
5926
+ }
5927
+ });
5928
+ if (dropped.length === 0)
5929
+ return false;
5930
+ log(`${source}: expired ${dropped.length} structured head(s) without transcript start (${dropped.map(turn => turn.turnId).join(', ')})`);
5931
+ if (fenceCurrentRpcEngine)
5932
+ log('Fenced ambiguous Codex RPC generation before structured successor replay');
5933
+ // A rejected prompt-ready signal may be waiting on this exact pre-start
5934
+ // lease. Re-evaluate it after both the queue mark and its separate RPC
5935
+ // fail-closed owner have retired; otherwise the projector can stay busy
5936
+ // forever even though there is no remaining terminal source.
5937
+ redriveRejectedStructuredReady();
5180
5938
  return true;
5181
5939
  }
5182
5940
  function codexBridgeDrainAndMaybeEmit(opts = {}) {
@@ -5227,6 +5985,14 @@ function emitReadyCodexTurns() {
5227
5985
  const ready = codexBridgeQueue.drainEmittable();
5228
5986
  if (ready.length === 0)
5229
5987
  return;
5988
+ // Turns suppressed as GENUINE SILENCE (model terminated with a bare
5989
+ // nothing-to-send sentinel, no `botmux send`). Tracked by object identity —
5990
+ // both the emit loop and the terminal loop below iterate this same `ready`
5991
+ // array — so the terminal for such a turn can carry positive silence evidence
5992
+ // (outputDisposition: 'nothing_to_send'). A durable/async caller uses ONLY
5993
+ // that flag to settle completed-with-empty; a bare `completed` terminal (e.g.
5994
+ // the RPC-hydration timeout path) must never be read as silence.
5995
+ const nothingToSendTurns = new Set();
5230
5996
  const adoptMode = lastInitConfig?.adoptMode === true;
5231
5997
  // Adopt mode: model is the user's external Codex, no botmux send to
5232
5998
  // gate against — every assistant turn (Lark-driven OR locally typed)
@@ -5266,6 +6032,16 @@ function emitReadyCodexTurns() {
5266
6032
  continue;
5267
6033
  if (shouldSuppressBridgeEmit(gateInput, nextBoundaryMs, markers, adoptMode)) {
5268
6034
  log(`Codex bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (gate)`);
6035
+ // Distinguish DELIBERATE SILENCE (bare nothing-to-send sentinel, no prose,
6036
+ // no send) from other suppression reasons (already `botmux send`-ed this
6037
+ // turn, local-typed). Only the former is positive evidence that the model
6038
+ // chose to produce no output — the terminal below carries it so a durable/
6039
+ // async caller can settle completed-with-empty. "Already sent" is NOT
6040
+ // silence: its content went out via final_output/botmux send and the async
6041
+ // result is captured there, so it must not be stamped.
6042
+ if (!adoptMode && isBridgeNothingToSendFinal(turn.finalText)) {
6043
+ nothingToSendTurns.add(turn);
6044
+ }
5269
6045
  notifyExplicitReplyObserved(turn.turnId, explicitReplyMarkerForTurnWindow(gateInput, nextBoundaryMs, markers, adoptMode));
5270
6046
  continue;
5271
6047
  }
@@ -5309,7 +6085,13 @@ function emitReadyCodexTurns() {
5309
6085
  });
5310
6086
  }
5311
6087
  for (const turn of ready) {
5312
- emitTurnTerminal(turn.turnId, turn.terminalStatus ?? 'completed', turn.terminalErrorCode, turn.dispatchAttempt);
6088
+ retireRpcLifecycleFromStructuredTerminal({
6089
+ turnId: turn.turnId,
6090
+ ...(turn.dispatchAttempt !== undefined
6091
+ ? { dispatchAttempt: turn.dispatchAttempt }
6092
+ : {}),
6093
+ });
6094
+ emitTurnTerminal(turn.turnId, turn.terminalStatus ?? 'completed', turn.terminalErrorCode, turn.dispatchAttempt, nothingToSendTurns.has(turn) ? 'nothing_to_send' : undefined);
5313
6095
  }
5314
6096
  }
5315
6097
  function stopCodexBridge() {
@@ -5674,6 +6456,264 @@ function parkCrashDiagnosticTerminal(code, signal) {
5674
6456
  * the ask-hook / CoCo picker paths (driveCocoPicker, handleTuiKeys); cleared
5675
6457
  * when the prompt resolves or a Lark/terminal input overrides it. */
5676
6458
  let tuiPromptBlocking = false;
6459
+ /** One composed status projection shared by the immediate screen path,
6460
+ * periodic text updates, and screenshot uploads. */
6461
+ function projectedRuntimeScreenStatus() {
6462
+ return codexAppLivenessStatus(projectRuntimeScreenStatus({
6463
+ promptReady: isPromptReady,
6464
+ analyzing: false,
6465
+ structuredTurnBlocking: hasStructuredLifecycleBlock(),
6466
+ }));
6467
+ }
6468
+ /** Worker-local widening of the status snapshot helper. Codex App composes a
6469
+ * `stalled` state on top of the base screen/structured projection, while the
6470
+ * shared utility intentionally exposes only the base three-state union. */
6471
+ async function snapshotWithLatestRuntimeStatus(capture, projectStatus) {
6472
+ const snapshot = await capture();
6473
+ return { snapshot, status: projectStatus() };
6474
+ }
6475
+ /** Re-arm readiness before every individual CLI write. A whole flush can span
6476
+ * several type-ahead items, and adopt writeInput can await history polling;
6477
+ * either path may observe an assistant_final before the await returns. Reset
6478
+ * here (never after the await) so that final remains a usable ready edge. */
6479
+ function beginCliWriteCycle() {
6480
+ beginRuntimeWriteCycle({
6481
+ setPromptReady: ready => { isPromptReady = ready; },
6482
+ resetIdleDetector: () => { idleDetector?.reset(); },
6483
+ });
6484
+ }
6485
+ async function writeAdoptMessage(content, turnId, dispatchAttempt, vcMeetingImTurnOrigin, fence) {
6486
+ const executionFence = fence ?? captureAdoptWriteFence();
6487
+ if (!executionFence || !adoptWriteFenceIsCurrent(executionFence)) {
6488
+ return 'stale-before-write';
6489
+ }
6490
+ const adoptBackend = executionFence.backend;
6491
+ renderer?.markNewTurn();
6492
+ const turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
6493
+ currentBotmuxTurnId = turnId;
6494
+ currentBotmuxDispatchAttempt = dispatchAttempt;
6495
+ currentVcMeetingImTurnOrigin = vcMeetingImTurnOrigin;
6496
+ if (dispatchAttempt !== undefined)
6497
+ durableTurnInFlight = true;
6498
+ writeCliPidMarker();
6499
+ publishSandboxRelayCapability();
6500
+ let adoptStructuredBridgeTurnId;
6501
+ // Capture the transcript baseline immediately before the literal write, inside
6502
+ // the submission transaction (as its beforeWrite hook), so a ZMX composer-
6503
+ // recovery hold that refuses the write never leaves a bare, unwritten bridge
6504
+ // mark. prepareAdoptWrite is idempotent and also re-arms readiness before the
6505
+ // adapter can yield (an assistant_final may arrive while writeInput polls
6506
+ // history), so the re-arm stays BEFORE the write, never after.
6507
+ let adoptWritePrepared = false;
6508
+ const prepareAdoptWrite = () => {
6509
+ if (adoptWritePrepared)
6510
+ return;
6511
+ adoptWritePrepared = true;
6512
+ beginCliWriteCycle();
6513
+ if (bridgeJsonlPath) {
6514
+ try {
6515
+ bridgeIngest();
6516
+ }
6517
+ catch { /* best effort */ }
6518
+ bridgeMarkPendingTurn(content, turnId, dispatchAttempt);
6519
+ }
6520
+ else if (codexBridgeFallbackActive()) {
6521
+ if (codexBridgeIsCursor()) {
6522
+ // Cursor may append the current line before IPC handling; mark first so
6523
+ // the pre-existing line can fingerprint-match instead of becoming seen.
6524
+ adoptStructuredBridgeTurnId = codexBridgeMarkPendingTurn(content, turnId, dispatchAttempt);
6525
+ try {
6526
+ codexBridgeIngest();
6527
+ }
6528
+ catch { /* best effort */ }
6529
+ }
6530
+ else {
6531
+ try {
6532
+ codexBridgeIngest();
6533
+ }
6534
+ catch { /* best effort */ }
6535
+ adoptStructuredBridgeTurnId = codexBridgeMarkPendingTurn(content, turnId, dispatchAttempt);
6536
+ }
6537
+ if (adoptStructuredBridgeTurnId) {
6538
+ codexBridgeQueue.beginSubmitVerification(adoptStructuredBridgeTurnId, undefined, dispatchAttempt);
6539
+ }
6540
+ }
6541
+ };
6542
+ const settleStaleAfterWrite = (errorCode) => {
6543
+ if (adoptStructuredBridgeTurnId) {
6544
+ codexBridgeQueue.finishSubmitVerification(adoptStructuredBridgeTurnId, undefined, dispatchAttempt);
6545
+ }
6546
+ dropFailedBridgeMark(adoptStructuredBridgeTurnId, dispatchAttempt);
6547
+ if (turnId && dispatchAttempt !== undefined) {
6548
+ emitTurnTerminal(turnId, 'ambiguous', errorCode, dispatchAttempt);
6549
+ }
6550
+ else {
6551
+ send({
6552
+ type: 'user_notify',
6553
+ turnId,
6554
+ message: 'Adopt input could not be reconciled because the CLI backend changed while it was being written. Please verify the terminal before retrying.',
6555
+ });
6556
+ }
6557
+ return 'stale-after-write';
6558
+ };
6559
+ // Adopt mode write:
6560
+ // - Structured-bridge adopt-input CLIs (codex/traex/pi/grok/mtr) route
6561
+ // through cliAdapter.writeInput. The composer-conflict guard refuses input
6562
+ // when the adopted pane already holds an unsubmitted human draft, and the
6563
+ // shared submission transaction gives ZMX its capture→write→confirm/cancel
6564
+ // journal (a transparent pass-through on every non-ZMX backend).
6565
+ // - raw sendText+Enter runs inside the same transaction for identical
6566
+ // commit-journal atomicity.
6567
+ if (isStructuredBridgeAdoptInputCli(lastInitConfig?.cliId) && cliAdapter) {
6568
+ const submissionBackend = adoptBackend;
6569
+ let recoveryFailureReason;
6570
+ // Refuse adopt input while the local composer still holds an unsubmitted
6571
+ // human draft, BEFORE any bridge attribution or terminal write — otherwise
6572
+ // the Lark message would be appended onto the human's half-typed line.
6573
+ const composerConflict = codexAdoptComposerConflict(submissionBackend);
6574
+ if (composerConflict) {
6575
+ log('Refused Codex adopt input because the local composer contains an unsubmitted draft');
6576
+ scheduleSubmitFailureNotify(content, undefined, 'submit history', undefined, composerConflict, turnSeq, { turnId, dispatchAttempt }, 'failed');
6577
+ return 'completed';
6578
+ }
6579
+ try {
6580
+ const transaction = await runAmbiguousSubmissionTransaction(submissionBackend,
6581
+ // adapterInputHandle wraps a ZmxBackend in strictInputHandle, which turns
6582
+ // a `sendText/sendSpecialKeys === false` (ZMX write refusal) into a throw
6583
+ // so the transaction can cancel/poison the WAL. Passing the bare backend
6584
+ // would let a refused write look like success and clear the journal —
6585
+ // silent input loss (the exact failure the transaction guards against).
6586
+ () => cliAdapter.writeInput(adapterInputHandle(submissionBackend), content), settleVerifiableSubmissionForJournal, prepareAdoptWrite);
6587
+ const result = transaction.result;
6588
+ recoveryFailureReason = transaction.recoveryFailureReason;
6589
+ if (!adoptWriteFenceIsCurrent(executionFence)) {
6590
+ return settleStaleAfterWrite('adopt_generation_changed');
6591
+ }
6592
+ if (result?.cliSessionId) {
6593
+ persistCliSessionId(result.cliSessionId);
6594
+ codexBridgeNotifyCliSessionId(result.cliSessionId);
6595
+ }
6596
+ if (result?.submitted === true && adoptStructuredBridgeTurnId) {
6597
+ codexBridgeQueue.confirmPendingTurn(adoptStructuredBridgeTurnId, undefined, dispatchAttempt);
6598
+ }
6599
+ else if (adoptStructuredBridgeTurnId && !(result?.submitted === false && result.recheck && !result.failureReason)) {
6600
+ codexBridgeQueue.finishSubmitVerification(adoptStructuredBridgeTurnId, undefined, dispatchAttempt);
6601
+ }
6602
+ redriveRejectedStructuredReady();
6603
+ if (result?.submitted === false || recoveryFailureReason) {
6604
+ if (recoveryFailureReason) {
6605
+ notifyAmbiguousSubmissionRecovery(recoveryFailureReason, { turnId, dispatchAttempt });
6606
+ }
6607
+ else {
6608
+ scheduleSubmitFailureNotify(content, result?.recheck, 'submit history', adoptStructuredBridgeTurnId, result?.failureReason, turnSeq, { turnId, dispatchAttempt }, 'failed', true);
6609
+ }
6610
+ }
6611
+ }
6612
+ catch (err) {
6613
+ recoveryFailureReason = err instanceof SubmissionWriteError
6614
+ ? err.recoveryFailureReason
6615
+ : recoveryFailureReason;
6616
+ const blockedBeforeWrite = err instanceof SubmissionWriteError
6617
+ && !!err.recoveryFailureReason
6618
+ && !err.submissionStarted;
6619
+ log(`Adopt writeInput error (${lastInitConfig?.cliId}): ${err.message}`);
6620
+ if (!adoptWriteFenceIsCurrent(executionFence)) {
6621
+ return settleStaleAfterWrite('adopt_generation_changed');
6622
+ }
6623
+ if (adoptStructuredBridgeTurnId) {
6624
+ codexBridgeQueue.finishSubmitVerification(adoptStructuredBridgeTurnId, undefined, dispatchAttempt);
6625
+ }
6626
+ dropFailedBridgeMark(adoptStructuredBridgeTurnId, dispatchAttempt);
6627
+ if (turnId && dispatchAttempt !== undefined && blockedBeforeWrite) {
6628
+ // The ZMX recovery hold refused the write, so the input definitely did
6629
+ // NOT execute — a genuine retryable failure, not an ambiguous one.
6630
+ emitTurnTerminal(turnId, 'failed', 'zmx_recovery_blocked_before_write', dispatchAttempt);
6631
+ }
6632
+ else if (turnId && dispatchAttempt !== undefined && !recoveryFailureReason) {
6633
+ emitTurnTerminal(turnId, 'ambiguous', 'adopt_write_input_threw', dispatchAttempt);
6634
+ }
6635
+ if (recoveryFailureReason) {
6636
+ notifyAmbiguousSubmissionRecovery(recoveryFailureReason, { turnId, dispatchAttempt });
6637
+ }
6638
+ redriveRejectedStructuredReady();
6639
+ }
6640
+ }
6641
+ else if ('sendText' in adoptBackend && 'sendSpecialKeys' in adoptBackend) {
6642
+ const submissionBackend = adoptBackend;
6643
+ let recoveryFailureReason;
6644
+ try {
6645
+ const transaction = await runAmbiguousSubmissionTransaction(submissionBackend, async () => {
6646
+ // Use adapterInputHandle so a ZMX write refusal (sendText/sendSpecialKeys
6647
+ // returning false) throws instead of silently succeeding — the raw path
6648
+ // needs the same strict-handle guard as the structured writeInput path.
6649
+ const input = adapterInputHandle(submissionBackend);
6650
+ input.sendText(content);
6651
+ // Beat between text and Enter so Ink-based TUIs register pasted text
6652
+ // before submit. The serial queue holds across this await.
6653
+ await new Promise(r => setTimeout(r, 200));
6654
+ if (!adoptWriteFenceIsCurrent(executionFence)) {
6655
+ throw new Error('backend changed before adopt raw Enter');
6656
+ }
6657
+ input.sendSpecialKeys('Enter');
6658
+ }, undefined, prepareAdoptWrite);
6659
+ recoveryFailureReason = transaction.recoveryFailureReason;
6660
+ if (!adoptWriteFenceIsCurrent(executionFence)) {
6661
+ return settleStaleAfterWrite('adopt_generation_changed_before_enter');
6662
+ }
6663
+ if (recoveryFailureReason) {
6664
+ throw new SubmissionWriteError(`backend could not commit the adopt submission journal; ${recoveryFailureReason}`, recoveryFailureReason);
6665
+ }
6666
+ }
6667
+ catch (err) {
6668
+ recoveryFailureReason = err instanceof SubmissionWriteError
6669
+ ? err.recoveryFailureReason
6670
+ : recoveryFailureReason;
6671
+ const blockedBeforeWrite = err instanceof SubmissionWriteError
6672
+ && !!err.recoveryFailureReason
6673
+ && !err.submissionStarted;
6674
+ log(`Adopt raw input error (${lastInitConfig?.cliId}): ${err.message}`);
6675
+ if (turnId && dispatchAttempt !== undefined && blockedBeforeWrite) {
6676
+ emitTurnTerminal(turnId, 'failed', 'zmx_recovery_blocked_before_write', dispatchAttempt);
6677
+ }
6678
+ else if (turnId && dispatchAttempt !== undefined && !recoveryFailureReason) {
6679
+ emitTurnTerminal(turnId, 'ambiguous', 'write_input_threw', dispatchAttempt);
6680
+ }
6681
+ if (recoveryFailureReason) {
6682
+ notifyAmbiguousSubmissionRecovery(recoveryFailureReason, { turnId, dispatchAttempt });
6683
+ }
6684
+ }
6685
+ }
6686
+ else {
6687
+ prepareAdoptWrite();
6688
+ adoptBackend.write(content + '\r');
6689
+ }
6690
+ return 'completed';
6691
+ }
6692
+ async function runAdoptMessageForCapturedGeneration(item, requeue) {
6693
+ const fence = captureAdoptWriteFence();
6694
+ if (!fence) {
6695
+ requeue();
6696
+ return 'stale-before-write';
6697
+ }
6698
+ let requeued = false;
6699
+ const requeueOnce = () => {
6700
+ if (requeued)
6701
+ return;
6702
+ requeued = true;
6703
+ requeue();
6704
+ };
6705
+ const queued = await runAdoptQueuedWriteSequence({
6706
+ queue: adoptWriteQueue,
6707
+ isCurrent: () => adoptWriteFenceIsCurrent(fence),
6708
+ onStale: requeueOnce,
6709
+ write: () => writeAdoptMessage(item.content, item.turnId, item.dispatchAttempt, item.vcMeetingImTurnOrigin, fence),
6710
+ });
6711
+ if (queued.status === 'stale-before-write')
6712
+ return 'stale-before-write';
6713
+ if (queued.value === 'stale-before-write')
6714
+ requeueOnce();
6715
+ return queued.value;
6716
+ }
5677
6717
  function isWorkflowWorker() {
5678
6718
  return process.env.BOTMUX_WORKFLOW === '1';
5679
6719
  }
@@ -5939,10 +6979,7 @@ async function captureAndUpload() {
5939
6979
  logError(`Screenshot upload failed: ${err?.message ?? err}`);
5940
6980
  return;
5941
6981
  }
5942
- let status = isPromptReady ? 'idle' : 'working';
5943
- // master removed the AI ScreenAnalyzer, so the `analyzing` projection is gone;
5944
- // PR #597's Codex App no-progress liveness projection still layers on top.
5945
- status = codexAppLivenessStatus(status);
6982
+ const status = projectedRuntimeScreenStatus();
5946
6983
  send({
5947
6984
  type: 'screenshot_uploaded',
5948
6985
  imageKey,
@@ -6168,7 +7205,7 @@ async function flushPendingInjections() {
6168
7205
  if (!canStartInjectionFlush({
6169
7206
  injectionFlushing,
6170
7207
  userFlushing: isFlushing,
6171
- sessionRenameInFlight,
7208
+ sessionRenameInFlight: sessionRenameInFlight(),
6172
7209
  commandLineWritesPending,
6173
7210
  bareShellLaunchBlocked,
6174
7211
  }))
@@ -6176,7 +7213,7 @@ async function flushPendingInjections() {
6176
7213
  injectionFlushing = true;
6177
7214
  try {
6178
7215
  while (pendingInjections.length > 0 && backend && isPromptReady && !bareShellLaunchBlocked
6179
- && !sessionRenameInFlight) {
7216
+ && !sessionRenameInFlight()) {
6180
7217
  // Mirror flushPending's one-shot launch-failure guard: when an injection is
6181
7218
  // the FIRST writer after a (re)spawn, flushPending may never have run
6182
7219
  // (pendingMessages empty ⇒ early return) so the bare-shell check must also
@@ -6881,7 +7918,15 @@ async function handleTrustedCodexAppMarker(kind, payload, control) {
6881
7918
  }
6882
7919
  if (deliverableContent && startedAtMs !== undefined) {
6883
7920
  const suppressMarkers = readSendMarkers();
6884
- const gateInput = { markTimeMs: startedAtMs, isLocal: false, finalText: deliverableContent };
7921
+ // Pass the RAW finalContent (not the pre-stripped deliverableContent) as
7922
+ // finalText: shouldSuppressBridgeEmit needs to SEE the trailing sentinel to
7923
+ // apply the "already sent this turn + sentinel terminator → suppress
7924
+ // narration" branch. It strips internally for the length comparison, so a
7925
+ // prose+sentinel final is still length-matched on the stripped prose. If we
7926
+ // handed it the already-stripped text, the trailing-sentinel signal would
7927
+ // be invisible and a longer-than-send narration would leak (same class as
7928
+ // the transcript-path bug this fixes).
7929
+ const gateInput = { markTimeMs: startedAtMs, isLocal: false, finalText: finalContent };
6885
7930
  suppressDelivery = suppressDelivery || shouldSuppressBridgeEmit(gateInput, completedAtMs + 5_001, suppressMarkers, false);
6886
7931
  if (suppressDelivery) {
6887
7932
  log(`${cliName()} final_output suppressed (model already called botmux send)`);
@@ -7339,8 +8384,11 @@ function onPtyData(data) {
7339
8384
  return;
7340
8385
  }
7341
8386
  // Track last PTY output time for the ready-gate quiescence settle (see
7342
- // settleThenFlush) and delegate idle detection to IdleDetector.
8387
+ // settleThenFlush) and a monotonic generation for rejected-ready evidence.
8388
+ // Generation (not timestamp equality) distinguishes two redraw chunks that
8389
+ // happen inside the same millisecond.
7343
8390
  lastPtyOutputAtMs = Date.now();
8391
+ ptyOutputGeneration.observe();
7344
8392
  idleDetector?.feed(data);
7345
8393
  }
7346
8394
  /**
@@ -7449,23 +8497,12 @@ function markPromptReadyFromPty(observedBackend) {
7449
8497
  postSessionStartPromptEvidenceInFlight = false;
7450
8498
  }
7451
8499
  }
7452
- /**
7453
- * Push a coarse screen status to the daemon without waiting for the 2s sampler.
7454
- * Used so card-off reactions see working→idle even on short turns.
7455
- *
7456
- * @param force - When true, send `status` as-is and skip classifyScreenUsageLimit.
7457
- * Synthetic "working" seeds for card-off reactions MUST force: if the first
7458
- * screen already shows a rate-limit banner, classify would rewrite working→
7459
- * limited and the seed collapses to limited→limited (no working edge →
7460
- * finishTurnReactions never runs). Review: PR #633 third round.
7461
- */
8500
+ /** Push a coarse screen status without waiting for the periodic sampler. */
7462
8501
  function publishScreenStatus(status, opts) {
7463
8502
  if (!renderer)
7464
8503
  return;
7465
8504
  const { content } = renderer.snapshot();
7466
- const statusPayload = opts?.force
7467
- ? { status }
7468
- : classifyScreenUsageLimit(content, status);
8505
+ const statusPayload = opts?.force ? { status } : classifyScreenUsageLimit(content, status);
7469
8506
  send({
7470
8507
  type: 'screen_update',
7471
8508
  content,
@@ -7474,17 +8511,73 @@ function publishScreenStatus(status, opts) {
7474
8511
  dispatchAttempt: currentBotmuxDispatchAttempt,
7475
8512
  });
7476
8513
  }
8514
+ function stopStructuredStartGraceRecheck() {
8515
+ if (!structuredStartGraceRecheckTimer)
8516
+ return;
8517
+ clearTimeout(structuredStartGraceRecheckTimer);
8518
+ structuredStartGraceRecheckTimer = null;
8519
+ }
8520
+ function redriveRejectedStructuredReady() {
8521
+ const readyEvidenceGeneration = structuredRejectedReadyEvidenceGeneration;
8522
+ if (readyEvidenceGeneration === undefined)
8523
+ return;
8524
+ stopStructuredStartGraceRecheck();
8525
+ pruneExpiredStructuredHeadsAndEmit('structured pre-start gate');
8526
+ if (!backend || isPromptReady) {
8527
+ structuredRejectedReadyEvidenceGeneration = undefined;
8528
+ return;
8529
+ }
8530
+ if (hasStructuredLifecycleBlock()) {
8531
+ const nextRemainingMs = codexBridgeQueue.preStartLeaseRemainingMs();
8532
+ if (nextRemainingMs !== undefined)
8533
+ scheduleStructuredStartGraceRecheck(nextRemainingMs);
8534
+ return;
8535
+ }
8536
+ structuredRejectedReadyEvidenceGeneration = undefined;
8537
+ if (ptyOutputGeneration.isCurrent(readyEvidenceGeneration)) {
8538
+ log('Structured pre-start gate settled with no newer PTY output — re-driving prior prompt-ready signal');
8539
+ idleDetector?.fireIdle();
8540
+ return;
8541
+ }
8542
+ if (cliAdapter?.busyPattern && (backend.captureCurrentScreen || backend.captureViewport)) {
8543
+ probeBusyPatternIdle('structured pre-start gate', backend);
8544
+ return;
8545
+ }
8546
+ try {
8547
+ const currentScreen = captureBackendScreen(backend);
8548
+ if (currentScreen)
8549
+ idleDetector?.feed(currentScreen);
8550
+ }
8551
+ catch (err) {
8552
+ log(`Structured pre-start screen recheck failed: ${err.message}`);
8553
+ }
8554
+ }
8555
+ /** Re-drive a prompt signal rejected only because an explicitly confirmed
8556
+ * submit or its in-flight verification had not reached the structured
8557
+ * transcript yet. The bounded lease prevents permanent false-busy. */
8558
+ function scheduleStructuredStartGraceRecheck(remainingMs) {
8559
+ stopStructuredStartGraceRecheck();
8560
+ const backendAtSchedule = backend;
8561
+ const cliGenerationAtSchedule = cliSpawnGeneration;
8562
+ structuredStartGraceRecheckTimer = setTimeout(() => {
8563
+ structuredStartGraceRecheckTimer = null;
8564
+ if (!isCliBackendGenerationCurrent({ generation: cliGenerationAtSchedule, backend: backendAtSchedule }, { generation: cliSpawnGeneration, backend, restartInProgress: cliRestartInProgress })) {
8565
+ return;
8566
+ }
8567
+ redriveRejectedStructuredReady();
8568
+ }, Math.max(1, remainingMs + 1));
8569
+ structuredStartGraceRecheckTimer.unref?.();
8570
+ }
7477
8571
  function markPromptReady() {
7478
- // Only screen readiness plus a non-shell pane leaf may release a launch that
7479
- // was previously blocked. Transcript/task callbacks can arrive while the
7480
- // wrapper is still a shell; accepting one here would set isPromptReady and
7481
- // make the later real PTY callback return early, stranding queued input.
7482
8572
  if (bareShellLaunchBlocked) {
7483
8573
  log('Ignoring non-PTY prompt-ready while bare-shell launch block is active');
7484
8574
  return;
7485
8575
  }
7486
- if (isPromptReady)
8576
+ if (isPromptReady) {
8577
+ stopStructuredStartGraceRecheck();
7487
8578
  return; // guard against duplicate calls
8579
+ }
8580
+ stopStructuredStartGraceRecheck();
7488
8581
  if (lastInitConfig?.cliId === 'codex-app' && !codexAppControlProven) {
7489
8582
  if (!codexAppUnprovenPromptDeferred) {
7490
8583
  log('Ignoring Codex App prompt until this worker verifies a fresh Ed25519 challenge response');
@@ -7550,8 +8643,27 @@ function markPromptReady() {
7550
8643
  }
7551
8644
  if (freshnessAction === 'ignore')
7552
8645
  return;
8646
+ // Screen prompt/quiescence is only a UI heuristic. Structured transcript
8647
+ // bridges have the stronger lifecycle signal: a transcript-started turn
8648
+ // without assistant_final is still running even if the TUI redraw exposes a
8649
+ // prompt. An adapter-confirmed type-ahead submit also blocks during a bounded
8650
+ // hand-off lease while the CLI waits to write its transcript user event. A
8651
+ // bare mark is never authoritative, and the lease is explicitly re-driven,
8652
+ // so a dropped Enter cannot create permanent false-busy.
8653
+ // Reject the heuristic and re-arm IdleDetector so the later transcript-final
8654
+ // fireIdle() can drive the real ready edge.
8655
+ if (hasStructuredLifecycleBlock()) {
8656
+ const remainingMs = codexBridgeQueue.preStartLeaseRemainingMs();
8657
+ structuredRejectedReadyEvidenceGeneration = ptyOutputGeneration.snapshot();
8658
+ log('Ignoring prompt-ready heuristic while a structured turn is unfinished or submit verification/start is pending');
8659
+ idleDetector?.reset();
8660
+ if (remainingMs !== undefined)
8661
+ scheduleStructuredStartGraceRecheck(remainingMs);
8662
+ return;
8663
+ }
8664
+ structuredRejectedReadyEvidenceGeneration = undefined;
7553
8665
  isPromptReady = true;
7554
- clearSessionRenameInFlight();
8666
+ settleSessionRenameOnPrompt();
7555
8667
  // An old backend can still report idle while its async teardown is running.
7556
8668
  // Only a prompt observed after the general restart fence drops may release
7557
8669
  // slash commands to the replacement generation.
@@ -7588,6 +8700,21 @@ function markPromptReady() {
7588
8700
  send({ type: 'runner_build_ready', runnerBuildId: lastInitConfig.runnerBuildId });
7589
8701
  persistCodexRunnerBuildOnReady = false;
7590
8702
  }
8703
+ // Send immediate idle snapshot so Lark card reflects idle status.
8704
+ // BUT: skip when messages are pending — flushPending() will immediately
8705
+ // make the CLI busy, so the idle state is transient and shouldn't appear
8706
+ // in the card. This avoids a false "就绪" flash on daemon restart
8707
+ // (where the initial prompt is queued before the CLI becomes idle).
8708
+ if (renderer && pendingMessages.length === 0 && pendingAdoptMessages.length === 0 && pendingRawInputs.length === 0 && pendingSessionRename === null && !isFlushing) {
8709
+ const { content } = renderer.snapshot();
8710
+ send({
8711
+ type: 'screen_update',
8712
+ content,
8713
+ ...usageLimitTracker.classify(content, projectedRuntimeScreenStatus()),
8714
+ turnId: currentBotmuxTurnId,
8715
+ dispatchAttempt: currentBotmuxDispatchAttempt,
8716
+ });
8717
+ }
7591
8718
  if (activeRestartAttemptId) {
7592
8719
  // Defense in depth: only report a successful restart when a replacement
7593
8720
  // backend is actually installed. Every legitimate ready path assigns
@@ -7746,42 +8873,56 @@ function notifyAmbiguousSubmissionRecovery(recoveryReason, turnIdentity) {
7746
8873
  message: recoveryReason,
7747
8874
  });
7748
8875
  }
8876
+ /** Remove a transcript mark after the submit path has conclusively failed.
8877
+ * Both bridge implementations mark before writing so they cannot miss a
8878
+ * fast transcript append; a failed write must undo only an UNSTARTED mark.
8879
+ * Cleanup itself does not synthesize ready: an unverified mark never blocks,
8880
+ * and any verified pre-start lease is bounded and owns its expiry re-drive. */
8881
+ function dropFailedBridgeMark(bridgeTurnId, dispatchAttempt) {
8882
+ if (!bridgeTurnId)
8883
+ return;
8884
+ const dropped = bridgeQueue.dropPendingTurn(bridgeTurnId, dispatchAttempt);
8885
+ const droppedStructured = codexBridgeQueue.dropPendingTurn(bridgeTurnId, dispatchAttempt);
8886
+ if (dropped) {
8887
+ if (dropped.contentFingerprint)
8888
+ bridgeFingerprintScanLastMs.delete(dropped.contentFingerprint);
8889
+ log(`Bridge mark dropped after submit failure (turnId=${bridgeTurnId}) — rotation-fallback scan will stop spinning on this fingerprint.`);
8890
+ }
8891
+ if (droppedStructured) {
8892
+ log(`Structured bridge mark dropped after submit failure (turnId=${bridgeTurnId}) — later buffered turns were rechecked.`);
8893
+ // dropPendingTurn can replay a buffered successor user+final pair. Drain
8894
+ // it in the same explicit mutation path; leaving completion production to
8895
+ // a later transcript event can strand fallback output indefinitely.
8896
+ emitReadyCodexTurns();
8897
+ }
8898
+ }
7749
8899
  /** Worker-side handler for `submitted: false`. Defers the user-facing
7750
8900
  * warning and runs the adapter-supplied `recheck` closure first; if the
7751
8901
  * message has shown up in the transcript by then (slow path, hook delay),
7752
8902
  * suppresses the warning entirely. Adapters without a recheck still fall
7753
8903
  * through to the warning after the same delay so the UX is uniform.
7754
8904
  *
7755
- * `bridgeTurnId` is the BridgeTurnQueue mark created right before the
8905
+ * `bridgeTurnId` is the transcript queue mark created right before the
7756
8906
  * failing writeInput. When the deferred recheck conclusively fails (= no
7757
- * jsonl line will ever match this fingerprint), we drop that exact dispatch
7758
- * attempt's mark — leaving it would keep `maybeSwitchBridgeJsonl` doing
7759
- * full-directory scans every poll tick for a fingerprint that's permanently
7760
- * dead, the 99% CPU bug this whole patch series is fixing. */
7761
- function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId, failureReason, turnSeq = usageLimitTracker.currentTurn(), turnIdentity, durableTerminalStatus = 'failed') {
8907
+ * transcript event will ever match this fingerprint), we drop that exact
8908
+ * dispatch attempt's mark. Leaving a Claude mark would keep rotation-fallback
8909
+ * scans spinning; leaving a structured mark would block later fingerprint
8910
+ * attribution even after its bounded status lease expires. */
8911
+ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId, failureReason, turnSeq = usageLimitTracker.currentTurn(), turnIdentity, durableTerminalStatus = 'failed', structuredTarget = false) {
7762
8912
  const preview = buildSubmitMessagePreview(msg);
7763
8913
  const emitDurableTerminal = (errorCode) => {
7764
8914
  if (turnIdentity?.turnId && turnIdentity.dispatchAttempt !== undefined) {
7765
8915
  emitTurnTerminal(turnIdentity.turnId, durableTerminalStatus, errorCode, turnIdentity.dispatchAttempt);
7766
8916
  }
7767
8917
  };
7768
- const dropBridgeMark = () => {
7769
- if (!bridgeTurnId)
7770
- return;
7771
- const dropped = bridgeQueue.dropPendingTurn(bridgeTurnId, turnIdentity?.dispatchAttempt);
7772
- if (dropped) {
7773
- if (dropped.contentFingerprint)
7774
- bridgeFingerprintScanLastMs.delete(dropped.contentFingerprint);
7775
- log(`Bridge mark dropped after submit failure (turnId=${bridgeTurnId}, attempt=${turnIdentity?.dispatchAttempt ?? '-'}) — rotation-fallback scan will stop spinning on this fingerprint.`);
7776
- }
7777
- };
7778
8918
  if (failureReason) {
7779
8919
  const action = decideSubmitConfirmationAction({
7780
8920
  failureReason,
7781
8921
  recheckSubmitted: false,
7782
8922
  usageLimitDetected: false,
7783
8923
  });
7784
- dropBridgeMark();
8924
+ dropFailedBridgeMark(bridgeTurnId, turnIdentity?.dispatchAttempt);
8925
+ redriveRejectedStructuredReady();
7785
8926
  const reason = action.kind === 'notify-hard-failure' ? action.reason : failureReason;
7786
8927
  log(`writeInput: submit impossible — notifying user immediately. reason="${reason}" preview="${preview}"`);
7787
8928
  emitDurableTerminal(`submit_impossible:${reason}`);
@@ -7795,29 +8936,36 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
7795
8936
  return;
7796
8937
  }
7797
8938
  const activityBaselineMs = Date.now();
8939
+ const cliGenerationAtSchedule = cliSpawnGeneration;
8940
+ const backendAtSchedule = backend;
8941
+ const deferredAttemptIsCurrent = () => (cliSpawnGeneration === cliGenerationAtSchedule
8942
+ && backendAtSchedule !== null
8943
+ && backend === backendAtSchedule
8944
+ && !cliRestartInProgress);
7798
8945
  log(`writeInput: submit not confirmed after retries — deferred ${SUBMIT_DEFERRED_RECHECK_MS}ms recheck queued. preview="${preview}"`);
7799
8946
  setTimeout(async () => {
7800
- let recheckSubmitted = false;
7801
- let cliSessionId;
7802
- if (recheck) {
7803
- try {
7804
- const recheckResult = await recheck();
7805
- recheckSubmitted = typeof recheckResult === 'boolean'
7806
- ? recheckResult
7807
- : recheckResult.submitted === true;
7808
- cliSessionId = typeof recheckResult === 'object' && recheckResult && typeof recheckResult.cliSessionId === 'string'
7809
- ? recheckResult.cliSessionId
7810
- : undefined;
7811
- }
7812
- catch (err) {
7813
- log(`Deferred recheck threw (${err?.message ?? err}); falling through to warning.`);
7814
- }
7815
- }
7816
- const action = decideSubmitConfirmationAction({
7817
- recheckSubmitted,
7818
- usageLimitDetected: usageLimitTracker.detectedThisTurn(turnSeq),
7819
- activityEvidence: submitActivityEvidenceSince(activityBaselineMs),
8947
+ const settlement = await settleDeferredSubmitConfirmation(codexBridgeQueue, {
8948
+ turnId: bridgeTurnId,
8949
+ dispatchAttempt: turnIdentity?.dispatchAttempt,
8950
+ structuredTarget,
8951
+ recheck,
8952
+ usageLimitDetected: () => usageLimitTracker.detectedThisTurn(turnSeq),
8953
+ activityEvidence: () => submitActivityEvidenceSince(activityBaselineMs),
8954
+ isCurrent: deferredAttemptIsCurrent,
7820
8955
  });
8956
+ // Restart/exit or exact-attempt expiry can happen during either the 20s
8957
+ // delay or the awaited adapter recheck. A stale callback must perform no
8958
+ // side effects at all: no old cliSessionId persistence, ready redrive,
8959
+ // recursive timer, terminal, warning, or mutation of replay attempt N+1.
8960
+ if (settlement.stale) {
8961
+ log(`Discarded stale deferred submit recheck (${settlement.staleReason}) turn=${bridgeTurnId ?? '-'} attempt=${turnIdentity?.dispatchAttempt ?? '-'}`);
8962
+ return;
8963
+ }
8964
+ if (settlement.recheckError !== undefined) {
8965
+ const err = settlement.recheckError;
8966
+ log(`Deferred recheck threw (${err?.message ?? err}); falling through to warning.`);
8967
+ }
8968
+ const { action, cliSessionId } = settlement;
7821
8969
  switch (action.kind) {
7822
8970
  case 'suppress-confirmed':
7823
8971
  if (cliSessionId) {
@@ -7827,13 +8975,16 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
7827
8975
  void syncFreshCodexNativeSessionTitle(cliSessionId, codexRpcEngine);
7828
8976
  }
7829
8977
  log(`Deferred recheck found submit in ${transcriptLabel} — suppressing warning. preview="${preview}"`);
8978
+ redriveRejectedStructuredReady();
7830
8979
  return;
7831
8980
  case 'suppress-usage-limit':
7832
- dropBridgeMark();
8981
+ dropFailedBridgeMark(bridgeTurnId, turnIdentity?.dispatchAttempt);
8982
+ redriveRejectedStructuredReady();
7833
8983
  log(`Deferred recheck missing but usage limit was detected for this turn — suppressing submit warning. preview="${preview}"`);
7834
8984
  emitDurableTerminal('submit_usage_limit');
7835
8985
  return;
7836
8986
  case 'suppress-active':
8987
+ redriveRejectedStructuredReady();
7837
8988
  log(`Deferred recheck missing but later ${action.evidence} shows ${cliName()} is active — suppressing submit warning. preview="${preview}"`);
7838
8989
  // For a durable turn, activity is evidence of possible submission, not
7839
8990
  // a terminal result. Keep the arbiter closed and re-check until either
@@ -7848,7 +8999,8 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
7848
8999
  case 'notify-stuck':
7849
9000
  break;
7850
9001
  }
7851
- dropBridgeMark();
9002
+ dropFailedBridgeMark(bridgeTurnId, turnIdentity?.dispatchAttempt);
9003
+ redriveRejectedStructuredReady();
7852
9004
  log(`Deferred recheck still missing — notifying user. preview="${preview}"`);
7853
9005
  emitDurableTerminal('submit_unconfirmed');
7854
9006
  if (turnIdentity?.dispatchAttempt === undefined) {
@@ -8009,9 +9161,9 @@ async function flushPending() {
8009
9161
  }
8010
9162
  if (ambiguousSubmissionRecoveryHold?.backend === backend)
8011
9163
  return;
8012
- if (pendingMessages.length === 0 && pendingRawInputs.length === 0 && pendingSessionRename === null)
9164
+ if (pendingMessages.length === 0 && pendingAdoptMessages.length === 0 && pendingRawInputs.length === 0 && pendingSessionRename === null)
8013
9165
  return; // nothing to flush — keep isPromptReady
8014
- if (sessionRenameInFlight)
9166
+ if (sessionRenameInFlight())
8015
9167
  return; // wait for /rename to finish before any user input
8016
9168
  if (commandLineWritesPending > 0)
8017
9169
  return; // do not splice into text -> Enter
@@ -8034,6 +9186,16 @@ async function flushPending() {
8034
9186
  // the active attempt.
8035
9187
  if (!pendingInputMayFlush(durableTurnInFlight))
8036
9188
  return;
9189
+ // A no-native or locally-unregistered RPC delivery is deliberately
9190
+ // fail-closed: its exact execution state is unknown. Codex normally permits
9191
+ // type-ahead/steer, but admitting a successor here could merge it into the
9192
+ // unknown turn or overlap a durable replay. Keep every input class queued
9193
+ // until structured evidence, exact terminal, bounded generation restart, or
9194
+ // teardown retires the owner.
9195
+ if (rpcLifecycleFailClosedOwners.size > 0) {
9196
+ log(`Holding pending input behind ${rpcLifecycleFailClosedOwners.size} unresolved Codex RPC delivery owner(s)`);
9197
+ return;
9198
+ }
8037
9199
  // Ready-gate: hold the FIRST prompt until the SessionStart hook fires a true-
8038
9200
  // ready signal. A cjadk-style startup selector's ❯ falsely matches readyPattern
8039
9201
  // and would otherwise eat this message. releaseReadyGate() re-invokes us once
@@ -8084,12 +9246,13 @@ async function flushPending() {
8084
9246
  // pending messages can still drain while busy; the rename stays queued.
8085
9247
  const sessionRenameReady = isPromptReady && pendingSessionRename !== null;
8086
9248
  const rawInputReady = isPromptReady && pendingRawInputs.length > 0;
9249
+ const adoptInputReady = isPromptReady && lastInitConfig?.adoptMode === true && pendingAdoptMessages.length > 0;
8087
9250
  let supportedSessionRenameReady = sessionRenameReady;
8088
9251
  if (sessionRenameReady && (!cliAdapter.buildSessionRenameCommand || effectiveBackendType === 'riff')) {
8089
9252
  pendingSessionRename = null;
8090
9253
  supportedSessionRenameReady = false;
8091
9254
  log(`Ignoring native session rename — unsupported by ${cliName()}${effectiveBackendType === 'riff' ? ' on riff backend' : ''}`);
8092
- if (pendingMessages.length === 0 && pendingRawInputs.length === 0)
9255
+ if (pendingMessages.length === 0 && pendingAdoptMessages.length === 0 && pendingRawInputs.length === 0)
8093
9256
  return;
8094
9257
  }
8095
9258
  if (!isPromptReady && pendingMessages.length === 0)
@@ -8098,14 +9261,12 @@ async function flushPending() {
8098
9261
  return;
8099
9262
  isFlushing = true;
8100
9263
  const codexAppPromptReplay = new CodexAppFlushPromptReplay();
8101
- if (isPromptReady) {
8102
- isPromptReady = false;
8103
- idleDetector?.reset();
8104
- // Immediate working for card-off reaction settle (PR #633 P2): the screen
8105
- // sampler is 2s — without this a short turn can complete before any
8106
- // working status is observed, leaving idle→idle and GoGoGo uncleared.
8107
- // force: same rate-limit rewrite trap as argv seed (review third round).
8108
- publishScreenStatus('working', { force: true });
9264
+ // Raw input and native rename own their explicit command-line/session gates;
9265
+ // pending adopt writes re-arm inside writeAdoptMessage. Clearing readiness
9266
+ // here would make an adopt rename's in-queue readiness recheck always see a
9267
+ // synthetic false. Normal prompt/startup flushes keep the original re-arm.
9268
+ if (!rawInputReady && !supportedSessionRenameReady && !adoptInputReady) {
9269
+ beginCliWriteCycle();
8109
9270
  }
8110
9271
  try {
8111
9272
  // Launch-failure guard, run ONCE per spawn on the first flush, BEFORE startup
@@ -8155,10 +9316,49 @@ async function flushPending() {
8155
9316
  if (supportedSessionRenameReady && pendingSessionRename !== null && backend && cliAdapter) {
8156
9317
  const title = pendingSessionRename;
8157
9318
  const buildRename = cliAdapter.buildSessionRenameCommand;
9319
+ const renameBackend = backend;
9320
+ const renameGeneration = cliSpawnGeneration;
8158
9321
  pendingSessionRename = null;
8159
- sessionRenameInFlight = true;
9322
+ sessionRenamePhase = 'reserved';
8160
9323
  try {
8161
- await sendRawCommandLineWithRecoveryFence(backend, buildRename(title));
9324
+ const writeRename = async () => {
9325
+ // Transition to busy inside the adopt queue, after the readiness
9326
+ // recheck. Keep `writing` through text→beat→Enter: a terminal from
9327
+ // the preceding turn in that window must not release the rename gate.
9328
+ beginCliWriteCycle();
9329
+ sessionRenamePhase = 'writing';
9330
+ await sendRawCommandLineWithRecoveryFence(renameBackend, buildRename(title));
9331
+ if (cliSpawnGeneration !== renameGeneration || backend !== renameBackend || cliRestartInProgress) {
9332
+ throw new Error('rename backend generation changed before Enter settlement');
9333
+ }
9334
+ sessionRenamePhase = 'sent';
9335
+ // A previous turn's terminal may have raised ready while the rename
9336
+ // was still writing. Re-arm after Enter so only rename's own prompt
9337
+ // can settle `sent` (with the bounded timeout as fail-open fallback).
9338
+ beginCliWriteCycle();
9339
+ };
9340
+ const sent = lastInitConfig?.adoptMode
9341
+ ? await runAdoptSessionRenameSequence({
9342
+ queue: adoptWriteQueue,
9343
+ // An adopt write queued immediately before rename can make the
9344
+ // outer readiness decision stale while this task waits.
9345
+ isPromptReady: () => isPromptReady
9346
+ && backend === renameBackend
9347
+ && cliSpawnGeneration === renameGeneration
9348
+ && !cliRestartInProgress
9349
+ && !rawInputRestartGate,
9350
+ writeRename,
9351
+ })
9352
+ : (await writeRename(), true);
9353
+ if (!sent) {
9354
+ // Keep a newer title if one arrived while this rename waited. Retry
9355
+ // this title only when it is still the latest requested canonical title.
9356
+ if (pendingSessionRename === null)
9357
+ pendingSessionRename = title;
9358
+ forceClearSessionRenameInFlight();
9359
+ log(`Deferred native session rename until the next prompt (${cliName()}): ${title}`);
9360
+ return;
9361
+ }
8162
9362
  armSessionRenameIdleTimeout();
8163
9363
  idleDetector?.reset();
8164
9364
  log(`Native session rename command sent (${cliName()}): ${title}`);
@@ -8169,17 +9369,43 @@ async function flushPending() {
8169
9369
  // Keep the rename gate for the same bounded fail-open window: the write
8170
9370
  // may have stopped after typing only part of the command, so immediately
8171
9371
  // appending a deferred raw_input could corrupt both commands.
8172
- armSessionRenameIdleTimeout();
9372
+ if (cliSpawnGeneration === renameGeneration && backend === renameBackend && !cliRestartInProgress) {
9373
+ settleFailedSessionRenameWrite();
9374
+ armSessionRenameIdleTimeout();
9375
+ }
9376
+ else {
9377
+ forceClearSessionRenameInFlight();
9378
+ }
8173
9379
  log(`Native session rename command failed (${cliName()}): ${err?.message ?? err}; waiting for prompt or fail-open timeout`);
8174
9380
  }
8175
9381
  // Wait for the command to finish and the TUI to become idle again before
8176
9382
  // sending queued user prompts; otherwise they can land in its picker.
8177
9383
  return;
8178
9384
  }
9385
+ if (adoptInputReady && pendingAdoptMessages.length > 0) {
9386
+ const item = pendingAdoptMessages.shift();
9387
+ await runAdoptMessageForCapturedGeneration(item, () => {
9388
+ pendingAdoptMessages.unshift(item);
9389
+ log(`Re-queued adopt message at queue head for the replacement CLI generation (${item.content.length} chars)`);
9390
+ });
9391
+ return;
9392
+ }
8179
9393
  while (pendingMessages.length > 0 && backend && cliAdapter) {
8180
9394
  const item = freshnessInputQueue.takeNormal();
8181
9395
  if (!item)
8182
9396
  break;
9397
+ // Type-ahead can drain several items in one flush. Each logical submit
9398
+ // starts its own readiness generation before transcript marking/writing.
9399
+ beginCliWriteCycle();
9400
+ const writeGeneration = cliSpawnGeneration;
9401
+ const writeBackend = backend;
9402
+ const writeAdapter = cliAdapter;
9403
+ const writeRpcEngine = codexRpcEngine;
9404
+ const writeContinuationIsCurrent = () => (cliSpawnGeneration === writeGeneration
9405
+ && backend === writeBackend
9406
+ && cliAdapter === writeAdapter
9407
+ && codexRpcEngine === writeRpcEngine
9408
+ && !cliRestartInProgress);
8183
9409
  const durableWrite = item.dispatchAttempt !== undefined;
8184
9410
  const msg = item.content;
8185
9411
  const logicalMsg = item.logicalContent ?? msg;
@@ -8206,7 +9432,7 @@ async function flushPending() {
8206
9432
  // in-flight and must not participate in restart replay. RPC mode is
8207
9433
  // excluded: the app-server owns an accepted turn independently of the
8208
9434
  // viewer pane, so replaying it after a pane restart could run it twice.
8209
- if (!codexRpcEngine) {
9435
+ if (!writeRpcEngine) {
8210
9436
  inflightInputs.onWrite(item);
8211
9437
  stuckDetector?.arm();
8212
9438
  }
@@ -8222,8 +9448,11 @@ async function flushPending() {
8222
9448
  catch { /* best-effort */ }
8223
9449
  bridgeTurnId = bridgeMarkPendingTurn(logicalMsg, item.turnId, item.dispatchAttempt);
8224
9450
  }
8225
- else if (codexBridgeActive) {
8226
- codexBridgeMarkPendingTurn(logicalMsg, item.turnId, item.dispatchAttempt);
9451
+ else if (codexBridgeActive && !writeRpcEngine) {
9452
+ bridgeTurnId = codexBridgeMarkPendingTurn(logicalMsg, item.turnId, item.dispatchAttempt);
9453
+ if (bridgeTurnId) {
9454
+ codexBridgeQueue.beginSubmitVerification(bridgeTurnId, undefined, item.dispatchAttempt);
9455
+ }
8227
9456
  }
8228
9457
  if (durableWrite
8229
9458
  && cliAdapter.reliableTurnTerminal === true
@@ -8290,42 +9519,89 @@ async function flushPending() {
8290
9519
  // backend regression). flushPending is invoked fire-and-forget, so an
8291
9520
  // escaping rejection would become an unhandledRejection and crash the
8292
9521
  // worker — exactly the failure mode this change is closing. Contain it.
8293
- let result;
8294
9522
  let submissionBackend = null;
8295
9523
  let recoveryFailureReason;
9524
+ const handleStaleWriteContinuation = (errorCode) => {
9525
+ const disposition = settleStaleWriteContinuation(item, errorCode, (turnId, code, dispatchAttempt) => {
9526
+ emitTurnTerminal(turnId, 'ambiguous', code, dispatchAttempt);
9527
+ });
9528
+ // Generation change has already handed ordinary input to
9529
+ // InflightInputTracker carryover. Do not touch global bridge queues:
9530
+ // replacement may already have marked the same turnId/undefined key.
9531
+ log(`Discarded stale writeInput continuation turn=${item.turnId ?? '-'} attempt=${item.dispatchAttempt ?? '-'} generation=${writeGeneration} disposition=${disposition}`);
9532
+ };
9533
+ let result;
9534
+ let rpcTurnIdentity;
9535
+ let rpcTurnGeneration;
8296
9536
  try {
8297
- if (codexRpcEngine) {
9537
+ if (writeRpcEngine) {
9538
+ rpcTurnIdentity = {
9539
+ turnId: item.turnId ?? `codex-rpc-${randomBytes(8).toString('hex')}`,
9540
+ ...(item.dispatchAttempt !== undefined
9541
+ ? { dispatchAttempt: item.dispatchAttempt }
9542
+ : {}),
9543
+ };
9544
+ rpcTurnGeneration = {
9545
+ engine: writeRpcEngine,
9546
+ cliGeneration: writeGeneration,
9547
+ };
9548
+ installAwaitingRpcActivation(rpcTurnIdentity, rpcTurnGeneration);
8298
9549
  // RPC input mode: deliver via JSON-RPC turn/start (its ack IS the
8299
9550
  // submit confirmation), which the attached `codex --remote` TUI
8300
9551
  // renders. No tmux paste → the history.jsonl verify/retry/recover
8301
9552
  // machinery is bypassed. A throw here falls into the catch below and
8302
9553
  // surfaces as a normal submit-failure notice.
8303
- const targetBackend = backend;
8304
- await runAfterAmbiguousSubmissionWrites(targetBackend, async () => {
9554
+ await runAfterAmbiguousSubmissionWrites(writeBackend, async () => {
8305
9555
  prepareNormalWrite();
8306
- await codexRpcEngine.sendTurn(msg, item.turnId);
9556
+ await writeRpcEngine.sendTurn(msg, rpcTurnIdentity);
8307
9557
  });
9558
+ // The await may overlap an engine/pane replacement. Fence the captured
9559
+ // generation BEFORE touching the global bridge queue; a stale ack must
9560
+ // never activate a mark owned by the replacement generation.
9561
+ if (!writeContinuationIsCurrent()) {
9562
+ // RPC writes bypass InflightInputTracker because replay could
9563
+ // duplicate an already-accepted server turn. Keep this exact owner
9564
+ // registered until stopCodexRpcEngine settles a native terminal or
9565
+ // publishes one ambiguous teardown terminal + notice.
9566
+ log(`Deferred stale Codex RPC continuation to engine teardown `
9567
+ + `turn=${rpcTurnIdentity.turnId} generation=${writeGeneration}`);
9568
+ break;
9569
+ }
8308
9570
  result = { submitted: true };
9571
+ // Only the ACKed, still-current generation may create bridge state.
9572
+ // While turn/start was pending, codexBridgeIngest left its cursor
9573
+ // untouched; marking now therefore still precedes replay of any
9574
+ // already-persisted user/final events.
9575
+ bridgeTurnId = rpcTurnIdentity.turnId;
9576
+ // The app-server ack confirms execution has begun, but no local
9577
+ // transcript event will follow to flip started. Mark the turn active
9578
+ // so the lifecycle gate stays asserted for the full server-side run
9579
+ // instead of relying on the bounded 20s confirmation lease (which
9580
+ // would expire mid-turn on a long-running or approval-pending turn).
9581
+ const activated = activateRpcTurnLifecycle(rpcTurnIdentity, msg, false, rpcTurnGeneration);
9582
+ if (activated) {
9583
+ codexBridgeDrainAndMaybeEmit({ signalIdle: false });
9584
+ }
8309
9585
  }
8310
- else if (item.codexAppInput && cliAdapter.writeStructuredInput) {
8311
- const targetBackend = backend;
8312
- const targetAdapter = cliAdapter;
8313
- submissionBackend = targetBackend;
8314
- const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeStructuredInput(adapterInputHandle(targetBackend), msg, item.codexAppInput, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
9586
+ else if (item.codexAppInput && writeAdapter.writeStructuredInput) {
9587
+ submissionBackend = writeBackend;
9588
+ const transaction = await runAmbiguousSubmissionTransaction(writeBackend, () => writeAdapter.writeStructuredInput(adapterInputHandle(writeBackend), msg, item.codexAppInput, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
8315
9589
  result = transaction.result;
8316
9590
  recoveryFailureReason = transaction.recoveryFailureReason;
8317
9591
  }
8318
9592
  else {
8319
- const targetBackend = backend;
8320
- const targetAdapter = cliAdapter;
8321
- submissionBackend = targetBackend;
8322
- const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeInput(adapterInputHandle(targetBackend), msg, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
9593
+ submissionBackend = writeBackend;
9594
+ const transaction = await runAmbiguousSubmissionTransaction(writeBackend, () => writeAdapter.writeInput(adapterInputHandle(writeBackend), msg, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
8323
9595
  result = transaction.result;
8324
9596
  recoveryFailureReason = transaction.recoveryFailureReason;
8325
9597
  }
8326
9598
  if (recoveryFailureReason) {
8327
9599
  result = { ...result, submitted: false };
8328
9600
  }
9601
+ if (!writeContinuationIsCurrent()) {
9602
+ handleStaleWriteContinuation('write_generation_changed');
9603
+ break;
9604
+ }
8329
9605
  // Transcript-backed CLIs (Grok/Codex/… reliableTurnTerminal) own idle via
8330
9606
  // assistant_final → fireIdle. Their busyPattern is often missing for
8331
9607
  // several seconds after submit (or never matches the current TUI chrome),
@@ -8346,6 +9622,42 @@ async function flushPending() {
8346
9622
  && !err.submissionStarted;
8347
9623
  // Roll back this turn's Codex App liveness slot regardless of the failure
8348
9624
  // shape; the slot was opened before the fenced write.
9625
+ // A replacement generation may already have installed an equal
9626
+ // turn/attempt key. Fence before touching any process-global queue or
9627
+ // liveness state owned by that successor.
9628
+ if (!writeContinuationIsCurrent()) {
9629
+ if (rpcTurnIdentity && rpcTurnGeneration) {
9630
+ // The frame may have crossed the socket before the error. Never hand
9631
+ // RPC input to ordinary carryover/replay; exact teardown owns the
9632
+ // terminal and user notice for this still-awaiting identity.
9633
+ log(`Deferred stale failed Codex RPC continuation to engine teardown `
9634
+ + `turn=${rpcTurnIdentity.turnId} generation=${writeGeneration}`);
9635
+ }
9636
+ else {
9637
+ handleStaleWriteContinuation('write_generation_changed_after_error');
9638
+ }
9639
+ break;
9640
+ }
9641
+ if (rpcTurnIdentity && rpcTurnGeneration && writeRpcEngine) {
9642
+ const replayAnchorMs = awaitingRpcActivationReplayAnchorMs(rpcTurnIdentity, rpcTurnGeneration);
9643
+ clearAwaitingRpcActivation(rpcTurnIdentity, rpcTurnGeneration);
9644
+ bridgeTurnId = codexBridgeMarkPendingTurn(msg, rpcTurnIdentity.turnId, rpcTurnIdentity.dispatchAttempt, replayAnchorMs);
9645
+ installRpcLifecycleFailClosedOwner(rpcTurnIdentity, rpcTurnGeneration);
9646
+ log(`Codex RPC turn/start became ambiguous (${err?.message ?? err}); `
9647
+ + `held exact owner ${rpcTurnIdentity.turnId} and blocked successors`);
9648
+ send({
9649
+ type: 'user_notify',
9650
+ message: 'Codex RPC 消息已发出但未取得可验证的 turn/start 响应;为避免重复执行,未自动重发,并暂时阻塞后续消息直到结构化终态、边界重启或会话重启。',
9651
+ turnId: rpcTurnIdentity.turnId,
9652
+ ...(rpcTurnIdentity.dispatchAttempt !== undefined
9653
+ ? { dispatchAttempt: rpcTurnIdentity.dispatchAttempt }
9654
+ : {}),
9655
+ });
9656
+ if (bridgeTurnId !== rpcTurnIdentity.turnId && !cliRestartInProgress) {
9657
+ void restartCliProcess('Codex RPC ambiguous delivery could not establish an attribution lease', { immediate: true, preservePending: true });
9658
+ }
9659
+ break;
9660
+ }
8349
9661
  codexAppPromptReplay.cancelSubmission(codexAppTurnLiveness, codexAppReadyAuthority, codexAppLivenessHandle);
8350
9662
  if (submissionPreparationFailed) {
8351
9663
  if (item.turnId) {
@@ -8389,6 +9701,10 @@ async function flushPending() {
8389
9701
  dispatchStillPending = codexAppTurnDispatchQueue.cancelExact(codexAppDispatchReservation.handle);
8390
9702
  }
8391
9703
  }
9704
+ if (bridgeTurnId) {
9705
+ codexBridgeQueue.stopRpcActive(bridgeTurnId, item.dispatchAttempt);
9706
+ codexBridgeQueue.finishSubmitVerification(bridgeTurnId, undefined, item.dispatchAttempt);
9707
+ }
8392
9708
  log(`writeInput threw: ${err?.message ?? err}`);
8393
9709
  if (blockedBeforeWrite && submissionBackend) {
8394
9710
  // This exact item is known not to have touched the PTY. Keep its
@@ -8451,6 +9767,15 @@ async function flushPending() {
8451
9767
  if (threadId)
8452
9768
  void syncFreshCodexNativeSessionTitle(threadId, codexRpcEngine);
8453
9769
  }
9770
+ if (result?.submitted === true && bridgeTurnId) {
9771
+ codexBridgeQueue.confirmPendingTurn(bridgeTurnId, undefined, item.dispatchAttempt);
9772
+ }
9773
+ else if (bridgeTurnId && !(result?.submitted === false && result.recheck && !result.failureReason)) {
9774
+ // Keep verification pending only while an adapter-supplied deferred
9775
+ // recheck can still produce authoritative submit evidence.
9776
+ codexBridgeQueue.finishSubmitVerification(bridgeTurnId, undefined, item.dispatchAttempt);
9777
+ }
9778
+ redriveRejectedStructuredReady();
8454
9779
  // `&& backend`: if the CLI exited during this write (pane gone → onExit
8455
9780
  // nulled backend) the user already got a "CLI exited" notice; don't also
8456
9781
  // nag that the submit wasn't confirmed.
@@ -8525,6 +9850,8 @@ async function flushPending() {
8525
9850
  // that correctly. Durable receiver attempts are the exception: they and
8526
9851
  // adjacent IM turns wait for separate idle edges so neither can be
8527
9852
  // HOL-dropped or steered into the other.
9853
+ if (rpcLifecycleFailClosedOwners.size > 0)
9854
+ break;
8528
9855
  if (shouldStopPendingBatch(item, pendingMessages[0]))
8529
9856
  break;
8530
9857
  }
@@ -8614,7 +9941,7 @@ function sendToPty(content, turnId, opts = {}) {
8614
9941
  // type-ahead write is dropped (no input box yet) — markPromptReady()'s flush
8615
9942
  // delivers queued messages instead. See input-gate.ts; this fixes dispatch's
8616
9943
  // brief reaching Codex before its first idle and never landing.
8617
- if (!sessionRenameInFlight && commandLineWritesPending === 0 && shouldWriteNow({
9944
+ if (!sessionRenameInFlight() && commandLineWritesPending === 0 && shouldWriteNow({
8618
9945
  isPromptReady,
8619
9946
  isFlushing,
8620
9947
  supportsTypeAhead,
@@ -8658,59 +9985,65 @@ function startScreenUpdates() {
8658
9985
  screenUpdateTimer = setInterval(() => {
8659
9986
  if (awaitingFirstPrompt)
8660
9987
  return;
8661
- let status = isPromptReady ? 'idle' : 'working';
8662
- // master removed the AI ScreenAnalyzer; PR #597's Codex App no-progress
8663
- // liveness projection still layers on top of the base idle/working verdict.
8664
- status = codexAppLivenessStatus(status);
8665
9988
  void (async () => {
8666
- let content = lastContent;
8667
- let changed = false;
8668
- // Capture only when the pane has emitted output since our last snapshot.
8669
- // During idle (the steady state for a parked session) this skips a tmux
8670
- // capture-pane + a throwaway xterm-headless instantiation every tick —
8671
- // the dominant per-session background cost — while the status-transition
8672
- // send below still fires off the cached content. The exception is a
8673
- // self-driven screen (observe backend with a live web-attach): the
8674
- // watermark can't be trusted there, so capture every tick.
8675
- const ptyActivity = lastPtyActivityAtMs;
8676
- if (shouldCaptureScreen({
8677
- ptyActivity,
8678
- lastCapturedPtyActivity: lastSnapshotPtyActivity,
8679
- screenSelfDriven: isScreenSelfDriven(backend),
8680
- })) {
8681
- lastSnapshotPtyActivity = ptyActivity;
8682
- // Preferred path: pipe-pane backends pull a fresh viewport snapshot
8683
- // from tmux every tick. This eliminates the accumulated-buffer drift
8684
- // that produced duplicated/staircase text in 'text' display mode.
8685
- const pipeText = await snapshotToText(backend, renderCols, renderRows, { filter: true });
8686
- if (pipeText) {
8687
- content = pipeText.content;
8688
- const hash = pipeText.ansi;
8689
- changed = hash !== lastTextSnapshotHash;
8690
- lastTextSnapshotHash = hash;
8691
- // Refresh the unfiltered snapshot cache (lastAnalyzerSnapshot) that
8692
- // usage-limit detection and the CoCo picker read from. Same tmux
8693
- // call would otherwise need to fire twice per tick.
8694
- if (changed) {
8695
- const rawSnap = await snapshotToText(backend, renderCols, renderRows, { filter: false });
8696
- if (rawSnap)
8697
- lastAnalyzerSnapshot = rawSnap.content;
9989
+ const { snapshot, status } = await snapshotWithLatestRuntimeStatus(async () => {
9990
+ let content = lastContent;
9991
+ let changed = false;
9992
+ // Capture only when the pane has emitted output since our last snapshot.
9993
+ // During idle (the steady state for a parked session) this skips a tmux
9994
+ // capture-pane + a throwaway xterm-headless instantiation every tick —
9995
+ // the dominant per-session background cost — while the status-transition
9996
+ // send below still fires off the cached content. The exception is a
9997
+ // self-driven screen (observe backend with a live web-attach): the
9998
+ // watermark can't be trusted there, so capture every tick.
9999
+ const ptyActivity = lastPtyActivityAtMs;
10000
+ if (shouldCaptureScreen({
10001
+ ptyActivity,
10002
+ lastCapturedPtyActivity: lastSnapshotPtyActivity,
10003
+ screenSelfDriven: isScreenSelfDriven(backend),
10004
+ })) {
10005
+ lastSnapshotPtyActivity = ptyActivity;
10006
+ // Preferred path: pipe-pane backends pull a fresh viewport snapshot
10007
+ // from tmux every tick. This eliminates the accumulated-buffer drift
10008
+ // that produced duplicated/staircase text in 'text' display mode.
10009
+ const pipeText = await snapshotToText(backend, renderCols, renderRows, { filter: true });
10010
+ if (pipeText) {
10011
+ content = pipeText.content;
10012
+ const hash = pipeText.ansi;
10013
+ changed = hash !== lastTextSnapshotHash;
10014
+ lastTextSnapshotHash = hash;
10015
+ // Refresh the unfiltered cache that ScreenAnalyzer reads from. Same
10016
+ // tmux call would otherwise need to fire twice per tick.
10017
+ if (changed) {
10018
+ const rawSnap = await snapshotToText(backend, renderCols, renderRows, { filter: false });
10019
+ if (rawSnap)
10020
+ lastAnalyzerSnapshot = rawSnap.content;
10021
+ }
8698
10022
  }
10023
+ else if (renderer) {
10024
+ const snap = renderer.snapshot();
10025
+ content = snap.content;
10026
+ changed = snap.changed;
10027
+ }
10028
+ else {
10029
+ return null;
10030
+ }
10031
+ lastContent = content;
8699
10032
  }
8700
- else if (renderer) {
8701
- const snap = renderer.snapshot();
8702
- content = snap.content;
8703
- changed = snap.changed;
8704
- }
8705
- else {
8706
- return;
8707
- }
8708
- lastContent = content;
8709
- }
8710
- const usageAware = classifyScreenUsageLimit(content, status);
8711
- if (changed || usageAware.status !== lastSentStatus) {
10033
+ return { content, changed };
10034
+ }, projectedRuntimeScreenStatus);
10035
+ if (!snapshot)
10036
+ return;
10037
+ const usageAware = usageLimitTracker.classify(snapshot.content, status);
10038
+ if (snapshot.changed || usageAware.status !== lastSentStatus) {
8712
10039
  lastSentStatus = usageAware.status;
8713
- send({ type: 'screen_update', content, ...usageAware, turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt });
10040
+ send({
10041
+ type: 'screen_update',
10042
+ content: snapshot.content,
10043
+ ...usageAware,
10044
+ turnId: currentBotmuxTurnId,
10045
+ dispatchAttempt: currentBotmuxDispatchAttempt,
10046
+ });
8714
10047
  }
8715
10048
  })();
8716
10049
  }, SCREEN_UPDATE_INTERVAL_MS);
@@ -9082,7 +10415,8 @@ function scheduleBusyPatternIdleProbe(source) {
9082
10415
  }
9083
10416
  async function spawnCli(cfg, opts = {}) {
9084
10417
  const spawnGeneration = ++cliSpawnGeneration;
9085
- clearSessionRenameInFlight();
10418
+ // Prefer force-clear so a half-finished rename cannot block the new generation.
10419
+ forceClearSessionRenameInFlight();
9086
10420
  currentCliCredentialIsolated = false;
9087
10421
  // Enrollment writes the fixed marker before any device credential appears.
9088
10422
  // From that instant onward every NEW local CLI must carry a credential
@@ -9392,9 +10726,6 @@ async function spawnCli(cfg, opts = {}) {
9392
10726
  sessionEnv.BOTMUX_DEFERRED_SCHEDULE_TOPIC_TITLE = cfg.deferredScheduleRun.topicTitle;
9393
10727
  }
9394
10728
  }
9395
- // Turn sender so `--mention-back` can resolve an @ target in the sandbox.
9396
- if (cfg.ownerOpenId)
9397
- sessionEnv.BOTMUX_OWNER_OPEN_ID = cfg.ownerOpenId;
9398
10729
  // Lark credentials so `botmux send` works inside the riff sandbox without a
9399
10730
  // local daemon or bots.json. The sandbox has no session data / bot config,
9400
10731
  // so cmdSend falls back to these env vars to call the Lark API directly.
@@ -9424,6 +10755,10 @@ async function spawnCli(cfg, opts = {}) {
9424
10755
  mergedEnv.BOTMUX_API_ONLY = '1';
9425
10756
  mergedEnv.BOTMUX_CHAT_ID = cfg.chatId; // host-owned; never from config
9426
10757
  }
10758
+ // Session identity is host-owned. Re-freeze it AFTER per-bot/riff config
10759
+ // merge so a stale or user-supplied backend env cannot impersonate another
10760
+ // owner (or resurrect an owner on an ownerless session).
10761
+ applySessionOwnerEnv(mergedEnv, cfg.ownerOpenId);
9427
10762
  riffBackendConfig = Object.assign({}, cfg.backendConfig, { env: mergedEnv, resumeParentTaskId: cfg.riffParentTaskId });
9428
10763
  // 复用本地仓库+分支:多仓只认会话上的显式 stamp(仓库选择卡多选流按用户
9429
10764
  // 顺序写入 cfg.riffRepoDirs,首仓=primary);否则仅对 workingDir 本身做单仓
@@ -10321,6 +11656,7 @@ async function spawnCli(cfg, opts = {}) {
10321
11656
  else
10322
11657
  delete childEnv.BOTMUX_API_ONLY;
10323
11658
  childEnv.BOTMUX_ROOT_MESSAGE_ID = cfg.rootMessageId;
11659
+ applySessionOwnerEnv(childEnv, cfg.ownerOpenId);
10324
11660
  // This bot's resolved brandLabel template, injected so a SANDBOXED `botmux
10325
11661
  // send` renders the role-name footer without reading bots.json (deny-by-
10326
11662
  // default → EPERM → role footer would silently fall back to the default
@@ -11420,7 +12756,7 @@ async function spawnCli(cfg, opts = {}) {
11420
12756
  if (effectiveCliSessionId) {
11421
12757
  const rolloutPath = findCodexRolloutBySessionId(effectiveCliSessionId);
11422
12758
  if (rolloutPath) {
11423
- codexBridgeAttach(rolloutPath, 'baseline-existing');
12759
+ codexBridgeAttach(rolloutPath, effectiveResume ? 'baseline-existing' : 'fresh-empty');
11424
12760
  }
11425
12761
  else {
11426
12762
  codexBridgePendingSessionId = effectiveCliSessionId;
@@ -11439,7 +12775,7 @@ async function spawnCli(cfg, opts = {}) {
11439
12775
  if (effectiveCliSessionId) {
11440
12776
  const rolloutPath = findTraexRolloutBySessionId(effectiveCliSessionId);
11441
12777
  if (rolloutPath) {
11442
- codexBridgeAttach(rolloutPath, 'baseline-existing');
12778
+ codexBridgeAttach(rolloutPath, effectiveResume ? 'baseline-existing' : 'fresh-empty');
11443
12779
  }
11444
12780
  else {
11445
12781
  codexBridgePendingSessionId = effectiveCliSessionId;
@@ -11542,6 +12878,7 @@ async function spawnCli(cfg, opts = {}) {
11542
12878
  ];
11543
12879
  log(`Ready gate skipped — preflight failed: ${reasons.join('; ')}`);
11544
12880
  }
12881
+ ptyOutputGeneration.reset();
11545
12882
  if (shouldArmReadyGate({
11546
12883
  injectsReadyHook: cliAdapter.injectsReadyHook === true,
11547
12884
  readySignalAvailable,
@@ -11608,8 +12945,19 @@ async function spawnCli(cfg, opts = {}) {
11608
12945
  log('Screen settle barrier degraded after bounded retries; finalizing from the last successful snapshot');
11609
12946
  }
11610
12947
  }
11611
- if (evidenceSource === 'screen' && idleBackend
11612
- && deferPromptReadyWhileBusy(`${cliName()} screen-idle`, idleBackend))
12948
+ // Pi's transcript final (assistant_final) is persisted asynchronously
12949
+ // from the TUI clearing its `Working...` busy marker — either order
12950
+ // occurs. An external idle landing while the authoritative viewport
12951
+ // still shows busy must defer exactly like a screen idle, or the card
12952
+ // flips to 等待输入 mid-turn. Scoped to Pi: Grok/Codex own their idle
12953
+ // via reliableTurnTerminal / lifecycle blocking, and their busy markers
12954
+ // legitimately lag behind the transcript final. deferPromptReadyWhileBusy
12955
+ // itself fail-opens on non-authoritative screens (ZMX), so a stale
12956
+ // `Working...` in ZMX history can never block a structured terminal.
12957
+ const busyGuardedIdle = evidenceSource === 'screen'
12958
+ || (evidenceSource === 'external' && structuredBridgeIsPi());
12959
+ if (busyGuardedIdle && idleBackend
12960
+ && deferPromptReadyWhileBusy(`${cliName()} ${evidenceSource}-idle`, idleBackend))
11613
12961
  return;
11614
12962
  drainBridgesThenMarkReady(evidenceSource);
11615
12963
  });
@@ -11938,6 +13286,9 @@ function killCli(opts = {}) {
11938
13286
  idleDetector = null;
11939
13287
  stopReattachIdleProbe();
11940
13288
  stopBusyPatternIdleProbe();
13289
+ stopStructuredStartGraceRecheck();
13290
+ structuredRejectedReadyEvidenceGeneration = undefined;
13291
+ ptyOutputGeneration.reset();
11941
13292
  // Cancel any pending ready-gate fallback / settle timers; spawnCli re-arms on respawn.
11942
13293
  if (readySignalTimer) {
11943
13294
  clearTimeout(readySignalTimer);
@@ -12007,9 +13358,11 @@ function killCli(opts = {}) {
12007
13358
  sandboxCleanup = null;
12008
13359
  }
12009
13360
  isPromptReady = false;
12010
- clearSessionRenameInFlight();
12011
- if (!opts.preservePending)
13361
+ forceClearSessionRenameInFlight();
13362
+ if (!opts.preservePending) {
12012
13363
  pendingMessages.length = 0;
13364
+ pendingAdoptMessages.length = 0;
13365
+ }
12013
13366
  // pendingRawInputs contains only commands that were accepted but never typed
12014
13367
  // because /rename owned the TUI or an owned CLI restart was already fenced.
12015
13368
  // Preserve them across restart; unlike an in-flight raw command, replaying
@@ -12034,6 +13387,10 @@ async function restartCliProcess(reason, opts = {}) {
12034
13387
  log(`Restart ignored in adopt mode (${reason})`);
12035
13388
  return;
12036
13389
  }
13390
+ // Invalidate queued/deferred callbacks before async teardown begins. Waiting
13391
+ // until killCli/spawnCli would leave a window where an old timer can mutate
13392
+ // the next durable attempt while destroySession is still awaiting.
13393
+ cliSpawnGeneration += 1;
12037
13394
  // Set before touching destroySession(): remote teardown can await for many
12038
13395
  // seconds while the old backend object is still non-null and still capable
12039
13396
  // of firing idle/task-done callbacks. Inputs accepted in that interval must
@@ -13391,13 +14748,13 @@ const emittedTurnTerminals = new TurnTerminalDeduper();
13391
14748
  /** Report CLI processing completion independently from user-visible output.
13392
14749
  * Keep a bounded worker-local dedup set because transcript watchers and app
13393
14750
  * runners may surface the same final boundary more than once. */
13394
- function emitTurnTerminal(turnId, status, errorCode, dispatchAttempt) {
14751
+ function emitTurnTerminal(turnId, status, errorCode, dispatchAttempt, outputDisposition) {
13395
14752
  if (!sessionId || !turnId)
13396
14753
  return;
13397
14754
  if (!emittedTurnTerminals.claim(sessionId, turnId, dispatchAttempt))
13398
14755
  return;
13399
14756
  if (status !== 'completed') {
13400
- const dropped = codexBridgeQueue.dropPendingTurn(turnId, dispatchAttempt);
14757
+ const dropped = codexBridgeQueue.dropPendingTurn(turnId, dispatchAttempt, true);
13401
14758
  if (dropped) {
13402
14759
  log(`Structured bridge retired terminal attempt turn=${turnId.slice(0, 12)} attempt=${dispatchAttempt ?? '-'} status=${status}`);
13403
14760
  }
@@ -13412,6 +14769,7 @@ function emitTurnTerminal(turnId, status, errorCode, dispatchAttempt) {
13412
14769
  status,
13413
14770
  ...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
13414
14771
  ...(errorCode ? { errorCode } : {}),
14772
+ ...(outputDisposition ? { outputDisposition } : {}),
13415
14773
  });
13416
14774
  if (terminalReleasesDurableTurn({ turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt }, { turnId, dispatchAttempt })) {
13417
14775
  // Durable replay ownership moves back to the receiver/hub at terminal.
@@ -13624,8 +14982,7 @@ process.on('message', async (raw) => {
13624
14982
  sessionId = msg.sessionId;
13625
14983
  refreshTerminalViewToken();
13626
14984
  refreshTerminalWriteToken();
13627
- if (msg.ownerOpenId)
13628
- process.env.__OWNER_OPEN_ID = msg.ownerOpenId;
14985
+ applySessionOwnerEnv(process.env, msg.ownerOpenId);
13629
14986
  // Pin this worker's i18n locale early so every t() call below resolves
13630
14987
  // against the bot's chosen language without each callsite needing to
13631
14988
  // re-thread it.
@@ -13758,6 +15115,11 @@ process.on('message', async (raw) => {
13758
15115
  await prepareCodexNativeTitleGeneration(msg, codexRpcEngine);
13759
15116
  if (codexRpcEngine)
13760
15117
  armRpcStartupDialogDismiss(); // boundary #4: keep the --remote pane from freezing on a startup dialog
15118
+ if (deferredFreshRpcTurn) {
15119
+ const deferred = deferredFreshRpcTurn;
15120
+ deferredFreshRpcTurn = undefined;
15121
+ releaseRpcTurnTerminalDeferral(deferred.identity, deferred.generation);
15122
+ }
13761
15123
  // Queue the initial prompt — flushed when CLI shows idle.
13762
15124
  // Adapters with passesInitialPromptViaArgs (e.g. Gemini -i) bake the
13763
15125
  // prompt into CLI args, so we normally skip queuing to avoid double-send.
@@ -13778,7 +15140,9 @@ process.on('message', async (raw) => {
13778
15140
  // Mark it here before the CLI starts processing; late-attach is fine
13779
15141
  // because CodexBridgeQueue is path-agnostic until ingest discovers the
13780
15142
  // transcript file.
13781
- codexBridgeMarkPendingTurn(msg.prompt, msg.turnId, msg.dispatchAttempt);
15143
+ const bridgeTurnId = codexBridgeMarkPendingTurn(msg.prompt, msg.turnId, msg.dispatchAttempt);
15144
+ if (bridgeTurnId)
15145
+ codexBridgeQueue.confirmPendingTurn(bridgeTurnId, undefined, msg.dispatchAttempt);
13782
15146
  }
13783
15147
  // Queue the initial prompt for flushPending (pure decision, unit-tested):
13784
15148
  // - paste (no engine): normal queue.
@@ -13930,6 +15294,13 @@ process.on('message', async (raw) => {
13930
15294
  // submission mutex is held so a queued message cannot steal attribution
13931
15295
  // from the write/verification already in flight.
13932
15296
  let turnSeq = usageLimitTracker.currentTurn();
15297
+ // Adopt handlers are async and serialized below, so their renderer/usage
15298
+ // turn begins inside writeAdoptMessage. Non-adopt keeps the immediate
15299
+ // baseline while the message waits for normal flush scheduling.
15300
+ if (!lastInitConfig?.adoptMode) {
15301
+ renderer?.markNewTurn();
15302
+ usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
15303
+ }
13933
15304
  if (msg.nativeSessionTitlePrompt
13934
15305
  && msg.nativeSessionTitle
13935
15306
  && lastInitConfig?.cliId === 'codex'
@@ -13985,173 +15356,28 @@ process.on('message', async (raw) => {
13985
15356
  }
13986
15357
  }
13987
15358
  if (lastInitConfig?.adoptMode) {
13988
- let adoptWritePrepared = false;
13989
- const prepareAdoptWrite = () => {
13990
- if (adoptWritePrepared)
13991
- return;
13992
- adoptWritePrepared = true;
13993
- renderer?.markNewTurn();
13994
- turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
13995
- if (tmuxScrolledHalfPages > 0)
13996
- exitTmuxScrollMode();
13997
- currentBotmuxTurnId = msg.turnId;
13998
- currentBotmuxDispatchAttempt = msg.dispatchAttempt;
13999
- currentVcMeetingImTurnOrigin = msg.vcMeetingImTurnOrigin;
14000
- writeCliPidMarker();
14001
- publishSandboxRelayCapability();
14002
- // Capture the transcript baseline only after this turn owns the
14003
- // submission lock and immediately before its literal write.
14004
- if (bridgeJsonlPath) {
14005
- try {
14006
- bridgeIngest();
14007
- }
14008
- catch { /* best effort */ }
14009
- bridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
14010
- }
14011
- else if (codexBridgeFallbackActive()) {
14012
- if (codexBridgeIsCursor()) {
14013
- codexBridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
14014
- try {
14015
- codexBridgeIngest();
14016
- }
14017
- catch { /* best effort */ }
14018
- }
14019
- else {
14020
- try {
14021
- codexBridgeIngest();
14022
- }
14023
- catch { /* best effort */ }
14024
- codexBridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
14025
- }
14026
- }
15359
+ const item = {
15360
+ content,
15361
+ turnId: msg.turnId,
15362
+ replyTurnId: msg.replyTurnId,
15363
+ dispatchAttempt: msg.dispatchAttempt,
15364
+ codexAppDispatchId: msg.codexAppDispatchId,
15365
+ queuedActivationToken: msg.queuedActivationToken,
15366
+ vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
15367
+ codexAppInput,
14027
15368
  };
14028
- // Adopt mode write:
14029
- // - Structured-bridge adopt-input CLIs (codex/traex/pi/grok/mtr)
14030
- // route through cliAdapter.writeInput so paste+Enter-retry +
14031
- // transcript/history verify (and grok's prompt_history →
14032
- // cliSessionId re-attach after /new) all run. Hardcoding only
14033
- // codex/traex left grok on raw sendText, so /new rotation never
14034
- // re-attached the bridge.
14035
- // - everything else keeps the simple raw sendText+Enter — the
14036
- // claude-code adopt bridge has its own dual-write recovery
14037
- // path, and the other CLIs' adopt flows haven't surfaced
14038
- // this submit-detection issue.
14039
- if (backend) {
14040
- if (isStructuredBridgeAdoptInputCli(lastInitConfig?.cliId) && cliAdapter) {
14041
- // writeInput is async but we're already inside an async
14042
- // message handler. Errors are best-effort logged; attribution and
14043
- // bridge baselines are prepared only after the submission lock is
14044
- // held, immediately before the adapter write.
14045
- const submissionBackend = backend;
14046
- const submissionAdapter = cliAdapter;
14047
- let recoveryFailureReason;
14048
- const composerConflict = codexAdoptComposerConflict(submissionBackend);
14049
- if (composerConflict) {
14050
- log('Refused Codex adopt input because the local composer contains an unsubmitted draft');
14051
- scheduleSubmitFailureNotify(content, undefined, 'submit history', undefined, composerConflict, turnSeq, msg, 'failed');
14052
- return;
14053
- }
14054
- try {
14055
- const transaction = await runAmbiguousSubmissionTransaction(submissionBackend, () => submissionAdapter.writeInput(adapterInputHandle(submissionBackend), content), settleVerifiableSubmissionForJournal, prepareAdoptWrite);
14056
- const result = transaction.result;
14057
- recoveryFailureReason = transaction.recoveryFailureReason;
14058
- if (result?.cliSessionId) {
14059
- persistCliSessionId(result.cliSessionId);
14060
- codexBridgeNotifyCliSessionId(result.cliSessionId);
14061
- }
14062
- if (result?.submitted === false || recoveryFailureReason) {
14063
- if (recoveryFailureReason) {
14064
- notifyAmbiguousSubmissionRecovery(recoveryFailureReason, msg);
14065
- }
14066
- else {
14067
- scheduleSubmitFailureNotify(content, result?.recheck, 'submit history', undefined, result?.failureReason, turnSeq, msg, 'failed');
14068
- }
14069
- }
14070
- else {
14071
- acknowledgeTurnInputCommitted(msg.turnId);
14072
- }
14073
- }
14074
- catch (err) {
14075
- recoveryFailureReason = err instanceof SubmissionWriteError
14076
- ? err.recoveryFailureReason
14077
- : recoveryFailureReason;
14078
- const blockedBeforeWrite = err instanceof SubmissionWriteError
14079
- && !!err.recoveryFailureReason
14080
- && !err.submissionStarted;
14081
- log(`Adopt writeInput error (${lastInitConfig?.cliId}): ${err.message}`);
14082
- if (msg.turnId
14083
- && msg.dispatchAttempt !== undefined
14084
- && blockedBeforeWrite) {
14085
- emitTurnTerminal(msg.turnId, 'failed', 'zmx_recovery_blocked_before_write', msg.dispatchAttempt);
14086
- }
14087
- else if (msg.turnId
14088
- && msg.dispatchAttempt !== undefined
14089
- && !recoveryFailureReason) {
14090
- emitTurnTerminal(msg.turnId, 'ambiguous', 'write_input_threw', msg.dispatchAttempt);
14091
- }
14092
- if (backend) {
14093
- if (recoveryFailureReason) {
14094
- notifyAmbiguousSubmissionRecovery(recoveryFailureReason, msg);
14095
- }
14096
- else {
14097
- scheduleSubmitFailureNotify(content, undefined, 'submit history', undefined, undefined, turnSeq, msg, 'ambiguous');
14098
- }
14099
- }
14100
- }
14101
- }
14102
- else if ('sendText' in backend && 'sendSpecialKeys' in backend) {
14103
- const submissionBackend = backend;
14104
- let recoveryFailureReason;
14105
- try {
14106
- const transaction = await runAmbiguousSubmissionTransaction(submissionBackend, async () => {
14107
- const input = adapterInputHandle(submissionBackend);
14108
- input.sendText(content);
14109
- // Beat between text and Enter so the adopted CLI's input
14110
- // layer registers the typed chars before submit.
14111
- await new Promise(r => setTimeout(r, 200));
14112
- input.sendSpecialKeys('Enter');
14113
- }, undefined, prepareAdoptWrite);
14114
- recoveryFailureReason = transaction.recoveryFailureReason;
14115
- if (recoveryFailureReason) {
14116
- throw new SubmissionWriteError(`backend could not commit the adopt submission journal; ${recoveryFailureReason}`, recoveryFailureReason);
14117
- }
14118
- acknowledgeTurnInputCommitted(msg.turnId);
14119
- }
14120
- catch (err) {
14121
- recoveryFailureReason = err instanceof SubmissionWriteError
14122
- ? err.recoveryFailureReason
14123
- : recoveryFailureReason;
14124
- const blockedBeforeWrite = err instanceof SubmissionWriteError
14125
- && !!err.recoveryFailureReason
14126
- && !err.submissionStarted;
14127
- log(`Adopt raw input error (${lastInitConfig?.cliId}): ${err.message}`);
14128
- if (msg.turnId
14129
- && msg.dispatchAttempt !== undefined
14130
- && blockedBeforeWrite) {
14131
- emitTurnTerminal(msg.turnId, 'failed', 'zmx_recovery_blocked_before_write', msg.dispatchAttempt);
14132
- }
14133
- else if (msg.turnId
14134
- && msg.dispatchAttempt !== undefined
14135
- && !recoveryFailureReason) {
14136
- emitTurnTerminal(msg.turnId, 'ambiguous', 'write_input_threw', msg.dispatchAttempt);
14137
- }
14138
- if (backend) {
14139
- if (recoveryFailureReason) {
14140
- notifyAmbiguousSubmissionRecovery(recoveryFailureReason, msg);
14141
- }
14142
- else {
14143
- scheduleSubmitFailureNotify(content, undefined, 'submit history', undefined, undefined, turnSeq, msg, 'ambiguous');
14144
- }
14145
- }
14146
- }
14147
- }
14148
- else {
14149
- prepareAdoptWrite();
14150
- backend.write(content + '\r');
14151
- acknowledgeTurnInputCommitted(msg.turnId);
14152
- }
14153
- isPromptReady = false;
14154
- idleDetector?.reset();
15369
+ // process.on('message') does not serialize async listeners. Hold the
15370
+ // per-worker queue across transcript mark + complete adapter write so
15371
+ // two CoCo/Codex paste→wait→Enter/history cycles cannot overlap.
15372
+ if (cliRestartInProgress || rawInputRestartGate || !backend || sessionRenameInFlight()) {
15373
+ pendingAdoptMessages.push(item);
15374
+ log(`Deferred adopt message until the CLI generation/input gate settles (${content.length} chars)`);
15375
+ }
15376
+ else {
15377
+ await runAdoptMessageForCapturedGeneration(item, () => {
15378
+ pendingAdoptMessages.push(item);
15379
+ log(`Re-queued stale adopt message for the replacement CLI generation (${content.length} chars)`);
15380
+ });
14155
15381
  }
14156
15382
  }
14157
15383
  else {
@@ -14193,7 +15419,7 @@ process.on('message', async (raw) => {
14193
15419
  // shell。bareShellCheckInProgress 覆盖“检查进行中”、bareShellLaunchBlocked
14194
15420
  // 覆盖“仍停在裸 shell 的安全 hold”两种状态,一并入队;若该进程随后出现
14195
15421
  // 真实 PTY prompt 且 leaf 已变为非 shell,markPromptReady 会恢复排空。
14196
- if (cliRestartInProgress || rawInputRestartGate || sessionRenameInFlight
15422
+ if (cliRestartInProgress || rawInputRestartGate || sessionRenameInFlight()
14197
15423
  || shouldHoldCodexRunnerInput(codexRunnerFreshness)
14198
15424
  || injectionFlushing || shouldDeferUserFlush(pendingInjections)
14199
15425
  || bareShellCheckInProgress || bareShellLaunchBlocked) {
@@ -14737,7 +15963,10 @@ process.on('message', async (raw) => {
14737
15963
  pendingMessages: pendingMessages.length,
14738
15964
  pendingRawInputs: pendingRawInputs.length,
14739
15965
  pendingSessionRename: pendingSessionRename !== null,
14740
- sessionRenameInFlight,
15966
+ // sessionRenameInFlight became a function in this branch's rename
15967
+ // lifecycle rework; riffWorkerShutdownInputBlocker (added on master by
15968
+ // the RIFF two-phase shutdown) still consumes it as a boolean field.
15969
+ sessionRenameInFlight: sessionRenameInFlight(),
14741
15970
  commandLineWritesPending,
14742
15971
  });
14743
15972
  if (preparedCloseRequestId || closeRequestInFlightId) {