@seawork/server 1.0.22-rc.3 → 2.0.2-rc.6

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 (349) hide show
  1. package/dist/scripts/supervisor-entrypoint.js +48 -8
  2. package/dist/scripts/supervisor-entrypoint.js.map +1 -1
  3. package/dist/scripts/supervisor-native-classifier.js +77 -5
  4. package/dist/scripts/supervisor-native-classifier.js.map +1 -1
  5. package/dist/scripts/supervisor-stdio-tail.js +27 -0
  6. package/dist/scripts/supervisor-stdio-tail.js.map +1 -0
  7. package/dist/scripts/supervisor.js +12 -0
  8. package/dist/scripts/supervisor.js.map +1 -1
  9. package/dist/server/client/daemon-client.d.ts +142 -2
  10. package/dist/server/client/daemon-client.d.ts.map +1 -1
  11. package/dist/server/client/daemon-client.js +384 -3
  12. package/dist/server/client/daemon-client.js.map +1 -1
  13. package/dist/server/server/agent/agent-manager.d.ts +55 -3
  14. package/dist/server/server/agent/agent-manager.d.ts.map +1 -1
  15. package/dist/server/server/agent/agent-manager.js +324 -45
  16. package/dist/server/server/agent/agent-manager.js.map +1 -1
  17. package/dist/server/server/agent/agent-metadata-generator.d.ts +1 -0
  18. package/dist/server/server/agent/agent-metadata-generator.d.ts.map +1 -1
  19. package/dist/server/server/agent/agent-metadata-generator.js +8 -0
  20. package/dist/server/server/agent/agent-metadata-generator.js.map +1 -1
  21. package/dist/server/server/agent/agent-projections.js +7 -2
  22. package/dist/server/server/agent/agent-projections.js.map +1 -1
  23. package/dist/server/server/agent/agent-response-loop.d.ts +3 -1
  24. package/dist/server/server/agent/agent-response-loop.d.ts.map +1 -1
  25. package/dist/server/server/agent/agent-response-loop.js +33 -6
  26. package/dist/server/server/agent/agent-response-loop.js.map +1 -1
  27. package/dist/server/server/agent/agent-sdk-types.d.ts +43 -1
  28. package/dist/server/server/agent/agent-sdk-types.d.ts.map +1 -1
  29. package/dist/server/server/agent/claude-memory.d.ts +4 -0
  30. package/dist/server/server/agent/claude-memory.d.ts.map +1 -0
  31. package/dist/server/server/agent/claude-memory.js +97 -0
  32. package/dist/server/server/agent/claude-memory.js.map +1 -0
  33. package/dist/server/server/agent/mcp-server.d.ts.map +1 -1
  34. package/dist/server/server/agent/mcp-server.js +247 -0
  35. package/dist/server/server/agent/mcp-server.js.map +1 -1
  36. package/dist/server/server/agent/mcp-shared.d.ts +2 -0
  37. package/dist/server/server/agent/mcp-shared.d.ts.map +1 -1
  38. package/dist/server/server/agent/provider-launch-config.d.ts +6 -139
  39. package/dist/server/server/agent/provider-launch-config.d.ts.map +1 -1
  40. package/dist/server/server/agent/provider-launch-config.js +65 -33
  41. package/dist/server/server/agent/provider-launch-config.js.map +1 -1
  42. package/dist/server/server/agent/provider-manifest.d.ts +1 -0
  43. package/dist/server/server/agent/provider-manifest.d.ts.map +1 -1
  44. package/dist/server/server/agent/provider-manifest.js +36 -0
  45. package/dist/server/server/agent/provider-manifest.js.map +1 -1
  46. package/dist/server/server/agent/provider-registry.d.ts.map +1 -1
  47. package/dist/server/server/agent/provider-registry.js +4 -0
  48. package/dist/server/server/agent/provider-registry.js.map +1 -1
  49. package/dist/server/server/agent/provider-snapshot-manager.d.ts +3 -1
  50. package/dist/server/server/agent/provider-snapshot-manager.d.ts.map +1 -1
  51. package/dist/server/server/agent/provider-snapshot-manager.js +13 -0
  52. package/dist/server/server/agent/provider-snapshot-manager.js.map +1 -1
  53. package/dist/server/server/agent/providers/claude-agent.d.ts.map +1 -1
  54. package/dist/server/server/agent/providers/claude-agent.js +141 -27
  55. package/dist/server/server/agent/providers/claude-agent.js.map +1 -1
  56. package/dist/server/server/agent/providers/codex/tool-call-mapper.d.ts.map +1 -1
  57. package/dist/server/server/agent/providers/codex/tool-call-mapper.js +14 -1
  58. package/dist/server/server/agent/providers/codex/tool-call-mapper.js.map +1 -1
  59. package/dist/server/server/agent/providers/codex-app-server-agent.d.ts +132 -4
  60. package/dist/server/server/agent/providers/codex-app-server-agent.d.ts.map +1 -1
  61. package/dist/server/server/agent/providers/codex-app-server-agent.js +2233 -163
  62. package/dist/server/server/agent/providers/codex-app-server-agent.js.map +1 -1
  63. package/dist/server/server/agent/providers/codex-binary-resolver.d.ts +9 -0
  64. package/dist/server/server/agent/providers/codex-binary-resolver.d.ts.map +1 -1
  65. package/dist/server/server/agent/providers/codex-binary-resolver.js +35 -14
  66. package/dist/server/server/agent/providers/codex-binary-resolver.js.map +1 -1
  67. package/dist/server/server/agent/providers/codex-health-probe.js +1 -1
  68. package/dist/server/server/agent/providers/codex-health-probe.js.map +1 -1
  69. package/dist/server/server/agent/providers/deepseek/constants.d.ts +4 -0
  70. package/dist/server/server/agent/providers/deepseek/constants.d.ts.map +1 -0
  71. package/dist/server/server/agent/providers/deepseek/constants.js +11 -0
  72. package/dist/server/server/agent/providers/deepseek/constants.js.map +1 -0
  73. package/dist/server/server/agent/providers/deepseek/event-mapper.d.ts +21 -0
  74. package/dist/server/server/agent/providers/deepseek/event-mapper.d.ts.map +1 -0
  75. package/dist/server/server/agent/providers/deepseek/event-mapper.js +286 -0
  76. package/dist/server/server/agent/providers/deepseek/event-mapper.js.map +1 -0
  77. package/dist/server/server/agent/providers/deepseek/serve-client.d.ts +94 -0
  78. package/dist/server/server/agent/providers/deepseek/serve-client.d.ts.map +1 -0
  79. package/dist/server/server/agent/providers/deepseek/serve-client.js +142 -0
  80. package/dist/server/server/agent/providers/deepseek/serve-client.js.map +1 -0
  81. package/dist/server/server/agent/providers/deepseek/serve-process.d.ts +18 -0
  82. package/dist/server/server/agent/providers/deepseek/serve-process.d.ts.map +1 -0
  83. package/dist/server/server/agent/providers/deepseek/serve-process.js +93 -0
  84. package/dist/server/server/agent/providers/deepseek/serve-process.js.map +1 -0
  85. package/dist/server/server/agent/providers/deepseek-agent.d.ts +94 -0
  86. package/dist/server/server/agent/providers/deepseek-agent.d.ts.map +1 -0
  87. package/dist/server/server/agent/providers/deepseek-agent.js +811 -0
  88. package/dist/server/server/agent/providers/deepseek-agent.js.map +1 -0
  89. package/dist/server/server/agent/providers/gateway-telemetry.d.ts +9 -0
  90. package/dist/server/server/agent/providers/gateway-telemetry.d.ts.map +1 -0
  91. package/dist/server/server/agent/providers/gateway-telemetry.js +36 -0
  92. package/dist/server/server/agent/providers/gateway-telemetry.js.map +1 -0
  93. package/dist/server/server/agent/providers/seaagent/constants.d.ts +3 -0
  94. package/dist/server/server/agent/providers/seaagent/constants.d.ts.map +1 -0
  95. package/dist/server/server/agent/providers/seaagent/constants.js +3 -0
  96. package/dist/server/server/agent/providers/seaagent/constants.js.map +1 -0
  97. package/dist/server/server/agent/providers/seaagent/event-mapper.d.ts +3 -0
  98. package/dist/server/server/agent/providers/seaagent/event-mapper.d.ts.map +1 -0
  99. package/dist/server/server/agent/providers/seaagent/event-mapper.js +69 -0
  100. package/dist/server/server/agent/providers/seaagent/event-mapper.js.map +1 -0
  101. package/dist/server/server/agent/providers/seaagent/rpc-client.d.ts +23 -0
  102. package/dist/server/server/agent/providers/seaagent/rpc-client.d.ts.map +1 -0
  103. package/dist/server/server/agent/providers/seaagent/rpc-client.js +139 -0
  104. package/dist/server/server/agent/providers/seaagent/rpc-client.js.map +1 -0
  105. package/dist/server/server/agent/providers/seaagent/tool-call-mapper.d.ts +3 -0
  106. package/dist/server/server/agent/providers/seaagent/tool-call-mapper.d.ts.map +1 -0
  107. package/dist/server/server/agent/providers/seaagent/tool-call-mapper.js +38 -0
  108. package/dist/server/server/agent/providers/seaagent/tool-call-mapper.js.map +1 -0
  109. package/dist/server/server/agent/providers/seaagent-agent.d.ts +81 -0
  110. package/dist/server/server/agent/providers/seaagent-agent.d.ts.map +1 -0
  111. package/dist/server/server/agent/providers/seaagent-agent.js +502 -0
  112. package/dist/server/server/agent/providers/seaagent-agent.js.map +1 -0
  113. package/dist/server/server/agent/providers/seaagent-binary-resolver.d.ts +18 -0
  114. package/dist/server/server/agent/providers/seaagent-binary-resolver.d.ts.map +1 -0
  115. package/dist/server/server/agent/providers/seaagent-binary-resolver.js +46 -0
  116. package/dist/server/server/agent/providers/seaagent-binary-resolver.js.map +1 -0
  117. package/dist/server/server/agent/providers/seaagent-health-probe.d.ts +11 -0
  118. package/dist/server/server/agent/providers/seaagent-health-probe.d.ts.map +1 -0
  119. package/dist/server/server/agent/providers/seaagent-health-probe.js +49 -0
  120. package/dist/server/server/agent/providers/seaagent-health-probe.js.map +1 -0
  121. package/dist/server/server/agent/providers/seawork-models.d.ts +8 -0
  122. package/dist/server/server/agent/providers/seawork-models.d.ts.map +1 -1
  123. package/dist/server/server/agent/providers/seawork-models.js +118 -74
  124. package/dist/server/server/agent/providers/seawork-models.js.map +1 -1
  125. package/dist/server/server/agent/providers/tool-call-detail-primitives.d.ts +2 -2
  126. package/dist/server/server/agent/timeline-projection.d.ts +5 -1
  127. package/dist/server/server/agent/timeline-projection.d.ts.map +1 -1
  128. package/dist/server/server/agent/timeline-projection.js +20 -4
  129. package/dist/server/server/agent/timeline-projection.js.map +1 -1
  130. package/dist/server/server/agent-attention-policy.d.ts +1 -0
  131. package/dist/server/server/agent-attention-policy.d.ts.map +1 -1
  132. package/dist/server/server/agent-attention-policy.js +6 -0
  133. package/dist/server/server/agent-attention-policy.js.map +1 -1
  134. package/dist/server/server/allowed-hosts.d.ts +13 -0
  135. package/dist/server/server/allowed-hosts.d.ts.map +1 -1
  136. package/dist/server/server/allowed-hosts.js +33 -0
  137. package/dist/server/server/allowed-hosts.js.map +1 -1
  138. package/dist/server/server/bootstrap.d.ts +2 -0
  139. package/dist/server/server/bootstrap.d.ts.map +1 -1
  140. package/dist/server/server/bootstrap.js +200 -14
  141. package/dist/server/server/bootstrap.js.map +1 -1
  142. package/dist/server/server/browser-extension-token.d.ts +23 -0
  143. package/dist/server/server/browser-extension-token.d.ts.map +1 -0
  144. package/dist/server/server/browser-extension-token.js +114 -0
  145. package/dist/server/server/browser-extension-token.js.map +1 -0
  146. package/dist/server/server/bug-report-handler.d.ts +7 -1
  147. package/dist/server/server/bug-report-handler.d.ts.map +1 -1
  148. package/dist/server/server/bug-report-handler.js +73 -5
  149. package/dist/server/server/bug-report-handler.js.map +1 -1
  150. package/dist/server/server/bug-report-redact.d.ts +25 -1
  151. package/dist/server/server/bug-report-redact.d.ts.map +1 -1
  152. package/dist/server/server/bug-report-redact.js +42 -5
  153. package/dist/server/server/bug-report-redact.js.map +1 -1
  154. package/dist/server/server/config.d.ts +1 -0
  155. package/dist/server/server/config.d.ts.map +1 -1
  156. package/dist/server/server/config.js +51 -1
  157. package/dist/server/server/config.js.map +1 -1
  158. package/dist/server/server/crash-report.d.ts.map +1 -1
  159. package/dist/server/server/crash-report.js +18 -0
  160. package/dist/server/server/crash-report.js.map +1 -1
  161. package/dist/server/server/daemon-config-store.d.ts.map +1 -1
  162. package/dist/server/server/daemon-config-store.js +94 -3
  163. package/dist/server/server/daemon-config-store.js.map +1 -1
  164. package/dist/server/server/disk-full.d.ts +4 -0
  165. package/dist/server/server/disk-full.d.ts.map +1 -0
  166. package/dist/server/server/disk-full.js +46 -0
  167. package/dist/server/server/disk-full.js.map +1 -0
  168. package/dist/server/server/exports.d.ts +3 -2
  169. package/dist/server/server/exports.d.ts.map +1 -1
  170. package/dist/server/server/exports.js +2 -1
  171. package/dist/server/server/exports.js.map +1 -1
  172. package/dist/server/server/git-forge/github-client.d.ts +18 -0
  173. package/dist/server/server/git-forge/github-client.d.ts.map +1 -1
  174. package/dist/server/server/git-forge/github-client.js +88 -0
  175. package/dist/server/server/git-forge/github-client.js.map +1 -1
  176. package/dist/server/server/git-forge/parse-remote.d.ts +2 -0
  177. package/dist/server/server/git-forge/parse-remote.d.ts.map +1 -1
  178. package/dist/server/server/git-forge/parse-remote.js +71 -6
  179. package/dist/server/server/git-forge/parse-remote.js.map +1 -1
  180. package/dist/server/server/git-forge/service.d.ts +87 -0
  181. package/dist/server/server/git-forge/service.d.ts.map +1 -1
  182. package/dist/server/server/git-forge/service.js +198 -4
  183. package/dist/server/server/git-forge/service.js.map +1 -1
  184. package/dist/server/server/index.js +72 -0
  185. package/dist/server/server/index.js.map +1 -1
  186. package/dist/server/server/integrations/wecom-openclaw/bridge.d.ts +88 -0
  187. package/dist/server/server/integrations/wecom-openclaw/bridge.d.ts.map +1 -0
  188. package/dist/server/server/integrations/wecom-openclaw/bridge.js +1229 -0
  189. package/dist/server/server/integrations/wecom-openclaw/bridge.js.map +1 -0
  190. package/dist/server/server/integrations/wecom-openclaw/qr.d.ts +38 -0
  191. package/dist/server/server/integrations/wecom-openclaw/qr.d.ts.map +1 -0
  192. package/dist/server/server/integrations/wecom-openclaw/qr.js +101 -0
  193. package/dist/server/server/integrations/wecom-openclaw/qr.js.map +1 -0
  194. package/dist/server/server/integrations/wecom-openclaw/workspace.d.ts +5 -0
  195. package/dist/server/server/integrations/wecom-openclaw/workspace.d.ts.map +1 -0
  196. package/dist/server/server/integrations/wecom-openclaw/workspace.js +40 -0
  197. package/dist/server/server/integrations/wecom-openclaw/workspace.js.map +1 -0
  198. package/dist/server/server/latency-proxy.d.ts.map +1 -1
  199. package/dist/server/server/latency-proxy.js +45 -5
  200. package/dist/server/server/latency-proxy.js.map +1 -1
  201. package/dist/server/server/library/codex-skill-discovery.d.ts +9 -0
  202. package/dist/server/server/library/codex-skill-discovery.d.ts.map +1 -0
  203. package/dist/server/server/library/codex-skill-discovery.js +49 -0
  204. package/dist/server/server/library/codex-skill-discovery.js.map +1 -0
  205. package/dist/server/server/library/hub-install.d.ts +79 -0
  206. package/dist/server/server/library/hub-install.d.ts.map +1 -0
  207. package/dist/server/server/library/hub-install.js +263 -0
  208. package/dist/server/server/library/hub-install.js.map +1 -0
  209. package/dist/server/server/library/hub-test-run.d.ts +81 -0
  210. package/dist/server/server/library/hub-test-run.d.ts.map +1 -0
  211. package/dist/server/server/library/hub-test-run.js +237 -0
  212. package/dist/server/server/library/hub-test-run.js.map +1 -0
  213. package/dist/server/server/library/library-import.d.ts +27 -0
  214. package/dist/server/server/library/library-import.d.ts.map +1 -0
  215. package/dist/server/server/library/library-import.js +227 -0
  216. package/dist/server/server/library/library-import.js.map +1 -0
  217. package/dist/server/server/library/library-injection.d.ts +16 -0
  218. package/dist/server/server/library/library-injection.d.ts.map +1 -0
  219. package/dist/server/server/library/library-injection.js +49 -0
  220. package/dist/server/server/library/library-injection.js.map +1 -0
  221. package/dist/server/server/library/library-rpc.d.ts +73 -0
  222. package/dist/server/server/library/library-rpc.d.ts.map +1 -0
  223. package/dist/server/server/library/library-rpc.js +239 -0
  224. package/dist/server/server/library/library-rpc.js.map +1 -0
  225. package/dist/server/server/library/library-store.d.ts +35 -0
  226. package/dist/server/server/library/library-store.d.ts.map +1 -0
  227. package/dist/server/server/library/library-store.js +169 -0
  228. package/dist/server/server/library/library-store.js.map +1 -0
  229. package/dist/server/server/library/library-sync.d.ts +46 -0
  230. package/dist/server/server/library/library-sync.d.ts.map +1 -0
  231. package/dist/server/server/library/library-sync.js +235 -0
  232. package/dist/server/server/library/library-sync.js.map +1 -0
  233. package/dist/server/server/library/library-types.d.ts +756 -0
  234. package/dist/server/server/library/library-types.d.ts.map +1 -0
  235. package/dist/server/server/library/library-types.js +99 -0
  236. package/dist/server/server/library/library-types.js.map +1 -0
  237. package/dist/server/server/library/worktree-dev.d.ts +14 -0
  238. package/dist/server/server/library/worktree-dev.d.ts.map +1 -0
  239. package/dist/server/server/library/worktree-dev.js +24 -0
  240. package/dist/server/server/library/worktree-dev.js.map +1 -0
  241. package/dist/server/server/log-stream-error.d.ts +2 -0
  242. package/dist/server/server/log-stream-error.d.ts.map +1 -0
  243. package/dist/server/server/log-stream-error.js +33 -0
  244. package/dist/server/server/log-stream-error.js.map +1 -0
  245. package/dist/server/server/logger.d.ts +1 -0
  246. package/dist/server/server/logger.d.ts.map +1 -1
  247. package/dist/server/server/logger.js +32 -0
  248. package/dist/server/server/logger.js.map +1 -1
  249. package/dist/server/server/loop/rpc-schemas.d.ts +96 -96
  250. package/dist/server/server/loop-service.d.ts +18 -18
  251. package/dist/server/server/messages.d.ts +4 -1
  252. package/dist/server/server/messages.d.ts.map +1 -1
  253. package/dist/server/server/messages.js +40 -2
  254. package/dist/server/server/messages.js.map +1 -1
  255. package/dist/server/server/node-pty-error.d.ts +2 -0
  256. package/dist/server/server/node-pty-error.d.ts.map +1 -0
  257. package/dist/server/server/node-pty-error.js +19 -0
  258. package/dist/server/server/node-pty-error.js.map +1 -0
  259. package/dist/server/server/persisted-config.d.ts +219 -135
  260. package/dist/server/server/persisted-config.d.ts.map +1 -1
  261. package/dist/server/server/persisted-config.js +35 -1
  262. package/dist/server/server/persisted-config.js.map +1 -1
  263. package/dist/server/server/port-in-use.d.ts +4 -0
  264. package/dist/server/server/port-in-use.d.ts.map +1 -0
  265. package/dist/server/server/port-in-use.js +35 -0
  266. package/dist/server/server/port-in-use.js.map +1 -0
  267. package/dist/server/server/provider-runtime-settings-mask.d.ts +7 -0
  268. package/dist/server/server/provider-runtime-settings-mask.d.ts.map +1 -0
  269. package/dist/server/server/provider-runtime-settings-mask.js +65 -0
  270. package/dist/server/server/provider-runtime-settings-mask.js.map +1 -0
  271. package/dist/server/server/sac/auth.d.ts +12 -0
  272. package/dist/server/server/sac/auth.d.ts.map +1 -1
  273. package/dist/server/server/sac/auth.js +19 -1
  274. package/dist/server/server/sac/auth.js.map +1 -1
  275. package/dist/server/server/sac/index.d.ts +2 -2
  276. package/dist/server/server/sac/index.d.ts.map +1 -1
  277. package/dist/server/server/sac/index.js +2 -2
  278. package/dist/server/server/sac/index.js.map +1 -1
  279. package/dist/server/server/sac/poll.d.ts +2 -0
  280. package/dist/server/server/sac/poll.d.ts.map +1 -1
  281. package/dist/server/server/sac/poll.js +7 -2
  282. package/dist/server/server/sac/poll.js.map +1 -1
  283. package/dist/server/server/schedule/cron.d.ts.map +1 -1
  284. package/dist/server/server/schedule/cron.js +6 -6
  285. package/dist/server/server/schedule/cron.js.map +1 -1
  286. package/dist/server/server/schedule/rpc-schemas.d.ts +895 -0
  287. package/dist/server/server/schedule/rpc-schemas.d.ts.map +1 -1
  288. package/dist/server/server/schedule/rpc-schemas.js +34 -0
  289. package/dist/server/server/schedule/rpc-schemas.js.map +1 -1
  290. package/dist/server/server/schedule/service.d.ts +5 -1
  291. package/dist/server/server/schedule/service.d.ts.map +1 -1
  292. package/dist/server/server/schedule/service.js +97 -14
  293. package/dist/server/server/schedule/service.js.map +1 -1
  294. package/dist/server/server/schedule/types.d.ts +19 -0
  295. package/dist/server/server/schedule/types.d.ts.map +1 -1
  296. package/dist/server/server/schedule/types.js +1 -0
  297. package/dist/server/server/schedule/types.js.map +1 -1
  298. package/dist/server/server/session.d.ts +83 -2
  299. package/dist/server/server/session.d.ts.map +1 -1
  300. package/dist/server/server/session.js +895 -82
  301. package/dist/server/server/session.js.map +1 -1
  302. package/dist/server/server/speech/native-runtime-guard.d.ts +1 -0
  303. package/dist/server/server/speech/native-runtime-guard.d.ts.map +1 -1
  304. package/dist/server/server/speech/native-runtime-guard.js +10 -4
  305. package/dist/server/server/speech/native-runtime-guard.js.map +1 -1
  306. package/dist/server/server/websocket-server.d.ts +6 -1
  307. package/dist/server/server/websocket-server.d.ts.map +1 -1
  308. package/dist/server/server/websocket-server.js +79 -7
  309. package/dist/server/server/websocket-server.js.map +1 -1
  310. package/dist/server/server/workspace-git-service.d.ts +2 -1
  311. package/dist/server/server/workspace-git-service.d.ts.map +1 -1
  312. package/dist/server/server/workspace-git-service.js +7 -3
  313. package/dist/server/server/workspace-git-service.js.map +1 -1
  314. package/dist/server/server/workspace-registry-model.d.ts +1 -0
  315. package/dist/server/server/workspace-registry-model.d.ts.map +1 -1
  316. package/dist/server/server/workspace-registry-model.js +18 -0
  317. package/dist/server/server/workspace-registry-model.js.map +1 -1
  318. package/dist/server/server/worktree-session.d.ts +3 -3
  319. package/dist/server/server/worktree-session.d.ts.map +1 -1
  320. package/dist/server/server/worktree-session.js +1 -3
  321. package/dist/server/server/worktree-session.js.map +1 -1
  322. package/dist/server/shared/messages.d.ts +59658 -21927
  323. package/dist/server/shared/messages.d.ts.map +1 -1
  324. package/dist/server/shared/messages.js +531 -3
  325. package/dist/server/shared/messages.js.map +1 -1
  326. package/dist/server/shared/provider-runtime-settings.d.ts +87 -0
  327. package/dist/server/shared/provider-runtime-settings.d.ts.map +1 -0
  328. package/dist/server/shared/provider-runtime-settings.js +33 -0
  329. package/dist/server/shared/provider-runtime-settings.js.map +1 -0
  330. package/dist/server/terminal/terminal.d.ts +9 -0
  331. package/dist/server/terminal/terminal.d.ts.map +1 -1
  332. package/dist/server/terminal/terminal.js +100 -3
  333. package/dist/server/terminal/terminal.js.map +1 -1
  334. package/dist/server/utils/checkout-git.d.ts +23 -1
  335. package/dist/server/utils/checkout-git.d.ts.map +1 -1
  336. package/dist/server/utils/checkout-git.js +182 -21
  337. package/dist/server/utils/checkout-git.js.map +1 -1
  338. package/dist/server/utils/directory-suggestions.d.ts.map +1 -1
  339. package/dist/server/utils/directory-suggestions.js +57 -9
  340. package/dist/server/utils/directory-suggestions.js.map +1 -1
  341. package/dist/src/server/bug-report-redact.js +42 -5
  342. package/dist/src/server/bug-report-redact.js.map +1 -1
  343. package/dist/src/server/crash-report.js +18 -0
  344. package/dist/src/server/crash-report.js.map +1 -1
  345. package/dist/src/server/speech/native-runtime-guard.js +177 -0
  346. package/dist/src/server/speech/native-runtime-guard.js.map +1 -0
  347. package/dist/src/server/speech/speech-types.js +8 -0
  348. package/dist/src/server/speech/speech-types.js.map +1 -0
  349. package/package.json +16 -4
@@ -1,24 +1,55 @@
1
- import { execSync } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
3
2
  import { realpathSync } from "node:fs";
4
3
  import fs from "node:fs/promises";
5
4
  import os from "node:os";
6
5
  import path from "node:path";
7
6
  import readline from "node:readline";
8
7
  import { z } from "zod";
9
- import { loadCodexPersistedTimeline } from "./codex-rollout-timeline.js";
10
- import { mapCodexRolloutToolCall, mapCodexToolCallFromThreadItem, } from "./codex/tool-call-mapper.js";
11
- import { applyProviderEnv, resolveProviderCommandPrefix, } from "../provider-launch-config.js";
12
8
  import { resolveElectronNodeRuntime } from "../../../utils/electron-helper.js";
9
+ import { spawnProcess } from "../../../utils/spawn.js";
13
10
  import { getLatencyProxyUrlSync } from "../../latency-proxy.js";
11
+ import { resolveCodexSkillDiscoveryDirs } from "../../library/codex-skill-discovery.js";
12
+ import { applyProviderEnv, clearInheritedProxyEnv, mergeLocalhostProxyBypass, resolveProviderCommandPrefix, } from "../provider-launch-config.js";
13
+ import { mapCodexRolloutToolCall, mapCodexToolCallFromThreadItem, } from "./codex/tool-call-mapper.js";
14
14
  import { selectCodexBinary, selectEffectiveCodexBinary, verifyCommandAvailable, } from "./codex-binary-resolver.js";
15
- import { getSeaworkModels } from "./seawork-models.js";
16
- import { spawnProcess } from "../../../utils/spawn.js";
17
- import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
18
15
  import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
16
+ import { loadCodexPersistedTimeline } from "./codex-rollout-timeline.js";
19
17
  import { collectStdout, formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, resolveBinaryVersion, toDiagnosticErrorMessage, } from "./diagnostic-utils.js";
18
+ import { buildSeaworkGatewayHeaders, SEAWORK_AGENT_PROVIDER_HEADER_NAME, SEAWORK_SOURCE_HEADER_NAME, SEAWORK_SOURCE_HEADER_VALUE, } from "./gateway-telemetry.js";
19
+ import { getSeaworkModels } from "./seawork-models.js";
20
+ import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
20
21
  const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
21
22
  const TURN_START_TIMEOUT_MS = 90 * 1000;
23
+ // issue #505: if a turn produces no notifications for this long, treat it as
24
+ // stalled and force-complete it. Codex normally streams item/reasoning/output
25
+ // events continuously, so a healthy long-running turn re-arms the watchdog far
26
+ // more often than this; only a turn whose `turn/completed` is lost goes silent.
27
+ const TURN_WATCHDOG_IDLE_MS = 5 * 60 * 1000;
28
+ // issue #1427: when the watchdog decides a turn is stalled, do NOT force-fail
29
+ // immediately. A successful turn's `turn/completed` can arrive tens of ms after
30
+ // the watchdog fires — codex emits it only after a serial flush_rollout +
31
+ // on_task_finished (token/metric/proxy work) + a second persist, a structural,
32
+ // load/disk-dependent gap measured at ~55-71ms in #1416. Wait this grace window
33
+ // for that completion before committing the failure: if it arrives the turn
34
+ // finalizes normally and we never emit a spurious turn_failed. Far larger than
35
+ // the observed gap, far smaller than the 5min idle window, so it cannot mask a
36
+ // genuine stall.
37
+ const WATCHDOG_LATE_COMPLETION_GRACE_MS = 2500;
38
+ // issue #505: a tool execution still in flight (slow build, network fetch) can
39
+ // be legitimately output-silent, so an in-flight tool grants extra idle cycles
40
+ // before the turn is force-failed. Bounded so a *lost tool completion* still
41
+ // recovers instead of re-arming forever. The watchdog fails on the cycle AFTER
42
+ // the count reaches this max, so total silence tolerated with a tool in flight
43
+ // is the initial idle cycle plus this many deferred cycles:
44
+ // (1 + 3) * 5min = 20min.
45
+ const TURN_WATCHDOG_MAX_INFLIGHT_CYCLES = 3;
46
+ // issue #999: auto-compaction packs the whole (~240K-token) context and makes
47
+ // an extra LLM summarization call, so it can stay turn-event-silent for well
48
+ // over the idle window — a legitimate long operation, not a lost turn. Grant
49
+ // it more deferred cycles than a tool (compaction is slower) but stay bounded
50
+ // so a lost compaction-completion still recovers: (1 + 6) * 5min = 35min.
51
+ const TURN_WATCHDOG_MAX_COMPACTION_CYCLES = 6;
52
+ const COMPACTION_ITEM_WATCHDOG_MS = TURN_WATCHDOG_IDLE_MS * (1 + TURN_WATCHDOG_MAX_COMPACTION_CYCLES);
22
53
  // issue #259: window after turn/interrupt during which the next turn/start
23
54
  // gets a sentinel reminder prepended to its input.
24
55
  const CANCEL_REMINDER_WINDOW_MS = 60 * 1000;
@@ -26,10 +57,103 @@ const CANCEL_REMINDER_TEXT = "[seawork system note] The user canceled the previo
26
57
  "Ignore any prior user message that did not receive a complete answer. " +
27
58
  "Only respond to the message below.";
28
59
  const CODEX_EXTERNAL_MIGRATION_MIN_VERSION = "0.128.0";
60
+ // Cap for the per-agent terminal-session maps. They are keyed by codex
61
+ // terminal processId (and, for the dedup set, by stdin payload), which can
62
+ // legitimately persist across turns — so we cannot sweep them per-turn and
63
+ // only clear them on close(). To stop them from growing unbounded over a
64
+ // long-lived agent (they would otherwise retain raw stdin for the whole
65
+ // session — same OOM family as #1058), bound them: evicting the oldest entry
66
+ // past the cap only risks a stale command label / a re-emitted interaction
67
+ // for a terminal untouched across thousands of newer ones, which is benign.
68
+ const TERMINAL_SESSION_MAP_MAX = 512;
69
+ // Insert into an insertion-ordered Map/Set, evicting oldest entries first
70
+ // once the size cap is exceeded. (Map and Set both iterate in insertion
71
+ // order, so the first key is the oldest.)
72
+ function setBounded(map, key, value, max) {
73
+ map.set(key, value);
74
+ while (map.size > max) {
75
+ const oldest = map.keys().next().value;
76
+ if (oldest === undefined)
77
+ break;
78
+ map.delete(oldest);
79
+ }
80
+ }
81
+ function addBounded(set, value, max) {
82
+ set.add(value);
83
+ while (set.size > max) {
84
+ const oldest = set.values().next().value;
85
+ if (oldest === undefined)
86
+ break;
87
+ set.delete(oldest);
88
+ }
89
+ }
90
+ // issue #1012: manual context compaction. `thread/compact/start` is a dedicated
91
+ // RPC (not a turn), present from this codex version on. Mirrors claude's native
92
+ // `/compact`. Gated so old codex doesn't surface a command its app-server lacks.
93
+ const COMPACT_COMMAND_NAME = "compact";
94
+ const COMPACT_COMMAND = {
95
+ name: COMPACT_COMMAND_NAME,
96
+ description: "压缩当前会话上下文",
97
+ argumentHint: "",
98
+ };
99
+ const CODEX_MANUAL_COMPACT_MIN_VERSION = "0.137.0";
100
+ // issue #973: codex's own auto-compaction threshold is derived from the
101
+ // gateway-reported context_window (limit = context_window * 90%). Some gateway
102
+ // models report an inflated window (e.g. gpt-5.4-ops reports 950K), so the
103
+ // derived threshold (855K) is never reached and a thread keeps growing until it
104
+ // slams into max_output_tokens — the model's real budget is exhausted long
105
+ // before codex's window-relative limit. Pin a conservative absolute limit well
106
+ // below the smallest real context window so compaction fires on token count,
107
+ // not on the (untrustworthy) reported window. Users can override via extra.codex.
108
+ const CODEX_AUTO_COMPACT_TOKEN_LIMIT = 180000;
29
109
  const CODEX_PROVIDER = "codex";
30
110
  const CODEX_SEAWORK_PROVIDER_ID = "seawork";
111
+ // MCP server key for the daemon-owned builtin (see agent-manager.applySeaworkMcp).
112
+ const SEAWORK_BUILTIN_MCP_NAME = "seawork";
113
+ // codex MCP tools/call timeout for the seawork builtin (default is 120s, too
114
+ // short for SeaArt video/3d generation that generation_task waits on).
115
+ const SEAWORK_MCP_TOOL_TIMEOUT_SEC = 660;
116
+ // Only these seawork builtin tools are safe to auto-approve (no side effects
117
+ // beyond a paid SeaArt generation the user already asked for). The builtin also
118
+ // exposes high-impact agent/terminal/schedule/permission tools that MUST keep
119
+ // their normal approval gate — so we set approval per-tool, never server-wide.
120
+ const SEAWORK_AUTO_APPROVE_TOOLS = [
121
+ "generate_image",
122
+ "generate_video",
123
+ "generate_audio",
124
+ "generate_3d",
125
+ "generation_models",
126
+ "generation_task",
127
+ ];
31
128
  const CODEX_IMAGE_ATTACHMENT_DIR = "seawork-attachments";
32
129
  const CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX = "The user approved the plan. Implement it now. Do not restate or revise the plan unless blocked.";
130
+ // codex's experimental `goals` feature ships in 0.128.0+. Older binaries reject
131
+ // `--enable goals`, so we version-gate both the launch flag and the /goal
132
+ // command. /goal is out-of-band (no turn) so it works mid-turn against the same
133
+ // thread without canceling a running turn.
134
+ const CODEX_GOALS_MIN_VERSION = "0.128.0";
135
+ const GOAL_COMMAND_NAME = "goal";
136
+ const GOAL_COMMAND = {
137
+ name: GOAL_COMMAND_NAME,
138
+ description: "设置、暂停、恢复或清除当前会话目标",
139
+ argumentHint: "[<objective>|pause|resume|clear]",
140
+ };
141
+ function parseGoalSubcommand(args) {
142
+ const trimmed = (args ?? "").trim();
143
+ if (!trimmed)
144
+ return { kind: "usage" };
145
+ const lower = trimmed.toLowerCase();
146
+ if (lower === "pause")
147
+ return { kind: "pause" };
148
+ if (lower === "resume")
149
+ return { kind: "resume" };
150
+ if (lower === "clear")
151
+ return { kind: "clear" };
152
+ return { kind: "set", objective: trimmed };
153
+ }
154
+ function formatOutOfBandStatusMessage(text) {
155
+ return `${text.replace(/\n+$/u, "")}\n\n`;
156
+ }
33
157
  const CODEX_APP_SERVER_CAPABILITIES = {
34
158
  supportsStreaming: true,
35
159
  supportsSessionPersistence: true,
@@ -66,6 +190,15 @@ const MODE_PRESETS = {
66
190
  networkAccess: true,
67
191
  },
68
192
  };
193
+ // plan 模式语义=只产出计划、不写盘:物理收紧 sandbox 到 read-only,approval 不得为 never
194
+ function applyPlanModeConstraints(approvalPolicy, sandboxPolicyType, planModeEnabled) {
195
+ if (!planModeEnabled)
196
+ return { approvalPolicy, sandboxPolicyType };
197
+ return {
198
+ approvalPolicy: approvalPolicy === "never" ? "on-request" : approvalPolicy,
199
+ sandboxPolicyType: "read-only",
200
+ };
201
+ }
69
202
  function validateCodexMode(modeId) {
70
203
  if (!(modeId in MODE_PRESETS)) {
71
204
  const validModes = Object.keys(MODE_PRESETS).join(", ");
@@ -184,6 +317,13 @@ function isVersionAtLeast(raw, minimum) {
184
317
  }
185
318
  return true;
186
319
  }
320
+ // issue #1012: the app-server `initialize` response carries a required
321
+ // `userAgent` like "codex/0.137.0 (Mac OS ...)". parseVersionTriplet picks the
322
+ // first x.y.z, so returning the raw userAgent is enough for version gating.
323
+ function parseCodexUserAgentVersion(initializeResponse) {
324
+ const parsed = z.object({ userAgent: z.string() }).safeParse(initializeResponse);
325
+ return parsed.success ? parsed.data.userAgent : null;
326
+ }
187
327
  function readStringMetadata(value) {
188
328
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
189
329
  }
@@ -339,40 +479,14 @@ async function listCodexCustomPrompts() {
339
479
  }
340
480
  return commands.sort((a, b) => a.name.localeCompare(b.name));
341
481
  }
342
- async function listCodexSkillEntries(cwd) {
343
- const candidates = [];
344
- candidates.push(path.join(cwd, ".codex", "skills"));
345
- candidates.push(path.join(cwd, ".agents", "skills"));
346
- candidates.push(path.join(cwd, ".claude", "skills"));
347
- candidates.push(path.join(cwd, "skills"));
348
- const repoRoot = (() => {
349
- try {
350
- const output = execSync("git rev-parse --show-toplevel", {
351
- cwd,
352
- encoding: "utf8",
353
- stdio: ["ignore", "pipe", "ignore"],
354
- });
355
- const trimmed = output.trim();
356
- return trimmed ? trimmed : null;
357
- }
358
- catch {
359
- return null;
360
- }
361
- })();
362
- if (repoRoot) {
363
- candidates.push(path.join(path.dirname(cwd), ".codex", "skills"));
364
- candidates.push(path.join(path.dirname(cwd), ".agents", "skills"));
365
- candidates.push(path.join(path.dirname(cwd), ".claude", "skills"));
366
- candidates.push(path.join(path.dirname(cwd), "skills"));
367
- candidates.push(path.join(repoRoot, ".codex", "skills"));
368
- candidates.push(path.join(repoRoot, ".agents", "skills"));
369
- candidates.push(path.join(repoRoot, ".claude", "skills"));
370
- candidates.push(path.join(repoRoot, "skills"));
371
- }
372
- candidates.push(path.join(resolveCodexHomeDir(), "skills"));
373
- candidates.push(path.join(os.homedir(), ".agents", "skills"));
482
+ async function listCodexSkillEntries(cwd, logger) {
483
+ const candidates = resolveCodexSkillDiscoveryDirs({
484
+ cwd,
485
+ codexHomeDir: resolveCodexHomeDir(),
486
+ includeSeaworkCache: true,
487
+ });
374
488
  const skillsByName = new Map();
375
- for (const dir of Array.from(new Set(candidates))) {
489
+ for (const dir of candidates) {
376
490
  let entries;
377
491
  try {
378
492
  entries = await fs.readdir(dir, { withFileTypes: true });
@@ -397,6 +511,19 @@ async function listCodexSkillEntries(cwd) {
397
511
  const name = frontMatter["name"];
398
512
  const description = frontMatter["description"];
399
513
  if (!name || !description) {
514
+ if (logger) {
515
+ const reasons = [];
516
+ if (Object.keys(frontMatter).length === 0) {
517
+ reasons.push("missing YAML frontmatter delimited by ---");
518
+ }
519
+ else {
520
+ if (!name)
521
+ reasons.push("missing 'name' field in frontmatter");
522
+ if (!description)
523
+ reasons.push("missing 'description' field in frontmatter");
524
+ }
525
+ logger.warn({ path: skillPath, reasons }, "Codex skill skipped: invalid SKILL.md");
526
+ }
400
527
  continue;
401
528
  }
402
529
  if (!skillsByName.has(name)) {
@@ -466,6 +593,33 @@ function toCodexMcpConfig(config) {
466
593
  };
467
594
  }
468
595
  }
596
+ function summarizeJsonRpcParamsForLog(params) {
597
+ if (params && typeof params === "object" && !Array.isArray(params)) {
598
+ return { paramKeys: Object.keys(params).sort() };
599
+ }
600
+ if (params === null) {
601
+ return { paramType: "null" };
602
+ }
603
+ if (Array.isArray(params)) {
604
+ return { paramType: "array" };
605
+ }
606
+ return { paramType: typeof params };
607
+ }
608
+ // Pull the gateway `x-request-id` out of codex's `codex_client::default_client:
609
+ // Request completed ... url=.../responses ... headers={... "x-request-id": "..." ...}`
610
+ // debug line. Only `/responses` (model turn) requests are matched so a `/models`
611
+ // request id never masquerades as the turn's. Exported for regression coverage
612
+ // because this scrapes codex's log format, which can drift across versions.
613
+ export function extractResponsesRequestId(logLine) {
614
+ // codex colorizes app-server tracing with ANSI escapes even when stderr is a
615
+ // pipe, which would otherwise break the substring/regex match below.
616
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI SGR codes
617
+ const line = logLine.replace(/\u001b\[[0-9;]*m/g, "");
618
+ if (!line.includes("Request completed") || !line.includes("/responses")) {
619
+ return null;
620
+ }
621
+ return line.match(/"x-request-id":\s*"([0-9a-fA-F-]+)"/)?.[1] ?? null;
622
+ }
469
623
  class CodexAppServerClient {
470
624
  constructor(child, logger) {
471
625
  this.child = child;
@@ -473,9 +627,13 @@ class CodexAppServerClient {
473
627
  this.pending = new Map();
474
628
  this.requestHandlers = new Map();
475
629
  this.notificationHandler = null;
630
+ this.exitHandler = null;
631
+ this.upstreamLivenessHandler = null;
476
632
  this.nextId = 1;
477
633
  this.disposed = false;
478
634
  this.stderrBuffer = "";
635
+ this.stderrLineBuf = "";
636
+ this.lastResponsesRequestId = null;
479
637
  this.resolveExitPromise = null;
480
638
  this.rl = readline.createInterface({ input: child.stdout });
481
639
  this.exitPromise = new Promise((resolve) => {
@@ -483,10 +641,12 @@ class CodexAppServerClient {
483
641
  });
484
642
  this.rl.on("line", (line) => this.handleLine(line));
485
643
  child.stderr.on("data", (chunk) => {
486
- this.stderrBuffer += chunk.toString();
644
+ const text = chunk.toString();
645
+ this.stderrBuffer += text;
487
646
  if (this.stderrBuffer.length > 8192) {
488
647
  this.stderrBuffer = this.stderrBuffer.slice(-8192);
489
648
  }
649
+ this.captureResponsesRequestId(text);
490
650
  });
491
651
  child.on("error", (err) => {
492
652
  this.logger.error({ err }, "Codex app-server child process error");
@@ -498,6 +658,7 @@ class CodexAppServerClient {
498
658
  this.disposed = true;
499
659
  this.resolveExitPromise?.();
500
660
  this.resolveExitPromise = null;
661
+ this.exitHandler?.(err instanceof Error ? err.message : String(err));
501
662
  });
502
663
  child.on("exit", (code, signal) => {
503
664
  const message = code === 0 && !signal
@@ -512,11 +673,56 @@ class CodexAppServerClient {
512
673
  this.disposed = true;
513
674
  this.resolveExitPromise?.();
514
675
  this.resolveExitPromise = null;
676
+ this.exitHandler?.(message);
515
677
  });
516
678
  }
679
+ // Scrape codex's `codex_client::default_client: Request completed ... url=.../responses
680
+ // ... "x-request-id": "..."` debug line so a turn failure can be tagged with the
681
+ // gateway request id (codex omits it from the error message for stream
682
+ // disconnects). Newline-buffered so an id split across stderr chunks still
683
+ // parses; only `/responses` requests are tracked, latest wins.
684
+ captureResponsesRequestId(text) {
685
+ this.stderrLineBuf += text;
686
+ let nl = this.stderrLineBuf.indexOf("\n");
687
+ while (nl >= 0) {
688
+ const line = this.stderrLineBuf.slice(0, nl);
689
+ this.stderrLineBuf = this.stderrLineBuf.slice(nl + 1);
690
+ const requestId = extractResponsesRequestId(line);
691
+ if (requestId) {
692
+ this.lastResponsesRequestId = requestId;
693
+ this.upstreamLivenessHandler?.();
694
+ }
695
+ nl = this.stderrLineBuf.indexOf("\n");
696
+ }
697
+ if (this.stderrLineBuf.length > 16384) {
698
+ this.stderrLineBuf = this.stderrLineBuf.slice(-16384);
699
+ }
700
+ }
701
+ getLastResponsesRequestId() {
702
+ return this.lastResponsesRequestId;
703
+ }
704
+ resetLastResponsesRequestId() {
705
+ this.lastResponsesRequestId = null;
706
+ }
707
+ resetRecentStderrTail() {
708
+ this.stderrBuffer = "";
709
+ this.stderrLineBuf = "";
710
+ }
517
711
  setNotificationHandler(handler) {
518
712
  this.notificationHandler = handler;
519
713
  }
714
+ // Invoked once when the child process exits or errors. The session uses this
715
+ // to fail any foreground turn whose `turn/completed` notification will never
716
+ // arrive (codex process crashed mid-turn).
717
+ setExitHandler(handler) {
718
+ this.exitHandler = handler;
719
+ }
720
+ // Invoked whenever a `/responses` request completes (HTTP round-trip to the
721
+ // gateway), proving codex is alive and talking upstream even when the turn
722
+ // emits no protocol notification (e.g. a multi-minute large-payload upload).
723
+ setUpstreamLivenessHandler(handler) {
724
+ this.upstreamLivenessHandler = handler;
725
+ }
520
726
  setRequestHandler(method, handler) {
521
727
  this.requestHandlers.set(method, handler);
522
728
  }
@@ -602,6 +808,9 @@ class CodexAppServerClient {
602
808
  if (typeof msg.method === "string") {
603
809
  const request = msg;
604
810
  const handler = this.requestHandlers.get(request.method);
811
+ if (!handler) {
812
+ this.logger.warn({ method: request.method, ...summarizeJsonRpcParamsForLog(request.params) }, "Unhandled Codex app-server request method");
813
+ }
605
814
  try {
606
815
  const result = handler ? await handler(request.params) : {};
607
816
  this.writeJsonRpcResponse({ id: request.id, result });
@@ -685,8 +894,10 @@ function extractUserText(content) {
685
894
  }
686
895
  return parts.length > 0 ? parts.join("\n") : null;
687
896
  }
897
+ const MAX_PLAN_TEXT_LENGTH = 50000;
688
898
  function normalizePlanMarkdown(text) {
689
899
  return text
900
+ .slice(0, MAX_PLAN_TEXT_LENGTH)
690
901
  .split("\n")
691
902
  .map((line) => line.replace(/\s+$/, ""))
692
903
  .join("\n")
@@ -948,6 +1159,8 @@ function normalizeCodexThreadItemType(rawType) {
948
1159
  return "webSearch";
949
1160
  case "ImageGeneration":
950
1161
  return "imageGeneration";
1162
+ case "ContextCompaction":
1163
+ return "contextCompaction";
951
1164
  default:
952
1165
  return rawType;
953
1166
  }
@@ -958,7 +1171,9 @@ function normalizeCodexCommandValue(value) {
958
1171
  if (!trimmed.length) {
959
1172
  return null;
960
1173
  }
961
- const wrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
1174
+ const wrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/) ||
1175
+ trimmed.match(/^(?:[^\s]*[/\\])?(?:powershell(?:\.exe)?|pwsh(?:\.exe)?)\s+-Command\s+([\s\S]+)$/i) ||
1176
+ trimmed.match(/^(?:[^\s]*[/\\])?cmd(?:\.exe)?\s+\/(?:c|k)\s+([\s\S]+)$/i);
962
1177
  if (!wrapperMatch) {
963
1178
  return trimmed;
964
1179
  }
@@ -985,8 +1200,38 @@ function normalizeCodexCommandValue(value) {
985
1200
  if (parts.length >= 3 && (parts[1] === "-lc" || parts[1] === "-c")) {
986
1201
  return parts[2] ?? parts;
987
1202
  }
1203
+ if (parts.length >= 3) {
1204
+ const exe = parts[0]
1205
+ .replace(/^.*[/\\]/, "")
1206
+ .replace(/\.exe$/i, "")
1207
+ .toLowerCase();
1208
+ const flag = parts[1].toLowerCase();
1209
+ if (((exe === "powershell" || exe === "pwsh") && flag === "-command") ||
1210
+ (exe === "cmd" && (flag === "/c" || flag === "/k"))) {
1211
+ return parts.slice(2).join(" ").trim() || parts;
1212
+ }
1213
+ }
988
1214
  return parts;
989
1215
  }
1216
+ function buildExecFallbackDetail(command, cwd) {
1217
+ const normalized = normalizeCodexCommandValue(command);
1218
+ const display = Array.isArray(normalized) ? normalized.join(" ") : (normalized ?? "");
1219
+ if (display.trim().length > 0) {
1220
+ return {
1221
+ type: "shell",
1222
+ command: display,
1223
+ ...(cwd ? { cwd } : {}),
1224
+ };
1225
+ }
1226
+ return {
1227
+ type: "unknown",
1228
+ input: {
1229
+ command: Array.isArray(command) ? command : (command ?? null),
1230
+ cwd: cwd ?? null,
1231
+ },
1232
+ output: null,
1233
+ };
1234
+ }
990
1235
  function parseCodexPatchChanges(changes) {
991
1236
  const resolvePathFromRecord = (record) => {
992
1237
  const directPath = (typeof record.path === "string" && record.path.trim().length > 0
@@ -1302,6 +1547,10 @@ function threadItemToTimeline(item, options) {
1302
1547
  return mapCodexToolCallFromThreadItem(normalizedItem, { cwd });
1303
1548
  case "imageGeneration":
1304
1549
  return mapCodexImageGenerationToTimeline(normalizedItem);
1550
+ case "contextCompaction":
1551
+ // issue #973: surface codex's auto-compaction as a timeline marker so the
1552
+ // user sees why the context shrank. The thread item carries no preTokens.
1553
+ return { type: "compaction", status: "completed", trigger: "auto" };
1305
1554
  default:
1306
1555
  return null;
1307
1556
  }
@@ -1388,17 +1637,20 @@ const TurnDiffUpdatedNotificationSchema = z
1388
1637
  .passthrough();
1389
1638
  const ThreadTokenUsageUpdatedNotificationSchema = z
1390
1639
  .object({
1640
+ turnId: z.string().optional(),
1391
1641
  tokenUsage: z.unknown(),
1392
1642
  })
1393
1643
  .passthrough();
1394
1644
  const ItemTextDeltaNotificationSchema = z
1395
1645
  .object({
1646
+ turnId: z.string().optional(),
1396
1647
  itemId: z.string(),
1397
1648
  delta: z.string(),
1398
1649
  })
1399
1650
  .passthrough();
1400
1651
  const ItemLifecycleNotificationSchema = z
1401
1652
  .object({
1653
+ turnId: z.string().optional(),
1402
1654
  item: z
1403
1655
  .object({
1404
1656
  id: z.string().optional(),
@@ -1409,6 +1661,7 @@ const ItemLifecycleNotificationSchema = z
1409
1661
  .passthrough();
1410
1662
  const RawResponseItemCompletedNotificationSchema = z
1411
1663
  .object({
1664
+ turnId: z.string().optional(),
1412
1665
  item: z
1413
1666
  .object({
1414
1667
  id: z.string().optional(),
@@ -1510,6 +1763,7 @@ const CodexEventTerminalInteractionNotificationSchema = z
1510
1763
  .passthrough();
1511
1764
  const ItemCommandExecutionTerminalInteractionNotificationSchema = z
1512
1765
  .object({
1766
+ turnId: z.string().optional(),
1513
1767
  itemId: z.string().optional(),
1514
1768
  processId: z.union([z.string(), z.number()]).optional(),
1515
1769
  stdin: z.string().optional(),
@@ -1542,11 +1796,19 @@ const CodexEventPatchApplyEndNotificationSchema = z
1542
1796
  .passthrough();
1543
1797
  const ItemFileChangeOutputDeltaNotificationSchema = z
1544
1798
  .object({
1799
+ turnId: z.string().optional(),
1545
1800
  itemId: z.string(),
1546
1801
  delta: z.string().optional(),
1547
1802
  chunk: z.string().optional(),
1548
1803
  })
1549
1804
  .passthrough();
1805
+ const ItemCommandExecutionOutputDeltaNotificationSchema = z
1806
+ .object({
1807
+ turnId: z.string().optional(),
1808
+ itemId: z.string(),
1809
+ delta: z.string().optional(),
1810
+ })
1811
+ .passthrough();
1550
1812
  const CodexEventTurnDiffNotificationSchema = z
1551
1813
  .object({
1552
1814
  msg: z
@@ -1619,6 +1881,7 @@ const CodexNotificationSchema = z.union([
1619
1881
  })
1620
1882
  .transform(({ params }) => ({
1621
1883
  kind: "token_usage_updated",
1884
+ turnId: params.turnId ?? null,
1622
1885
  tokenUsage: params.tokenUsage,
1623
1886
  })),
1624
1887
  z.object({ method: z.literal("thread/tokenUsage/updated"), params: z.unknown() }).transform(({ method, params }) => ({
@@ -1626,6 +1889,50 @@ const CodexNotificationSchema = z.union([
1626
1889
  method,
1627
1890
  params,
1628
1891
  })),
1892
+ // codex announces an in-flight response-stream reconnect via `warning`
1893
+ // (message "Reconnecting... n/5") and a paired `error(willRetry:true)`. Both
1894
+ // are non-terminal — surface them as a reconnect notice so a slow turn does
1895
+ // not look frozen. `warning` carries the human-readable attempt counter.
1896
+ z
1897
+ .object({
1898
+ method: z.literal("warning"),
1899
+ params: z.object({ threadId: z.string().optional(), message: z.string() }),
1900
+ })
1901
+ .transform(({ params }) => {
1902
+ const counts = parseReconnectAttemptCounts(params.message);
1903
+ return {
1904
+ kind: "stream_retrying",
1905
+ turnId: null,
1906
+ message: params.message,
1907
+ attempt: counts.attempt,
1908
+ maxAttempts: counts.maxAttempts,
1909
+ };
1910
+ }),
1911
+ z
1912
+ .object({
1913
+ method: z.literal("error"),
1914
+ params: z.object({
1915
+ willRetry: z.boolean().optional(),
1916
+ turnId: z.string().optional(),
1917
+ error: z.object({ message: z.string() }).partial().optional(),
1918
+ }),
1919
+ })
1920
+ .transform(({ method, params }) => {
1921
+ // Only a will_retry error is a non-terminal reconnect. A terminal error
1922
+ // (willRetry false/absent) is delivered to clients via turn/completed
1923
+ // (status Failed); let it fall through as an unknown method (logged, no
1924
+ // UI) rather than misrender it as a reconnect.
1925
+ if (params.willRetry !== true) {
1926
+ return { kind: "unknown_method", method, params };
1927
+ }
1928
+ return {
1929
+ kind: "stream_retrying",
1930
+ turnId: params.turnId ?? null,
1931
+ message: params.error?.message ?? null,
1932
+ attempt: null,
1933
+ maxAttempts: null,
1934
+ };
1935
+ }),
1629
1936
  z
1630
1937
  .object({
1631
1938
  method: z.literal("item/agentMessage/delta"),
@@ -1633,6 +1940,7 @@ const CodexNotificationSchema = z.union([
1633
1940
  })
1634
1941
  .transform(({ params }) => ({
1635
1942
  kind: "agent_message_delta",
1943
+ turnId: params.turnId ?? null,
1636
1944
  itemId: params.itemId,
1637
1945
  delta: params.delta,
1638
1946
  })),
@@ -1648,6 +1956,7 @@ const CodexNotificationSchema = z.union([
1648
1956
  })
1649
1957
  .transform(({ params }) => ({
1650
1958
  kind: "reasoning_delta",
1959
+ turnId: params.turnId ?? null,
1651
1960
  itemId: params.itemId,
1652
1961
  delta: params.delta,
1653
1962
  })),
@@ -1661,6 +1970,7 @@ const CodexNotificationSchema = z.union([
1661
1970
  .transform(({ params }) => ({
1662
1971
  kind: "item_completed",
1663
1972
  source: "item",
1973
+ turnId: params.turnId ?? null,
1664
1974
  item: params.item,
1665
1975
  })),
1666
1976
  z.object({ method: z.literal("item/completed"), params: z.unknown() }).transform(({ method, params }) => ({
@@ -1675,6 +1985,7 @@ const CodexNotificationSchema = z.union([
1675
1985
  })
1676
1986
  .transform(({ params }) => ({
1677
1987
  kind: "raw_response_item_completed",
1988
+ turnId: params.turnId ?? null,
1678
1989
  item: params.item,
1679
1990
  })),
1680
1991
  z.object({ method: z.literal("rawResponseItem/completed"), params: z.unknown() }).transform(({ method, params }) => ({
@@ -1687,6 +1998,7 @@ const CodexNotificationSchema = z.union([
1687
1998
  .transform(({ params }) => ({
1688
1999
  kind: "item_started",
1689
2000
  source: "item",
2001
+ turnId: params.turnId ?? null,
1690
2002
  item: params.item,
1691
2003
  })),
1692
2004
  z.object({ method: z.literal("item/started"), params: z.unknown() }).transform(({ method, params }) => ({
@@ -1702,6 +2014,7 @@ const CodexNotificationSchema = z.union([
1702
2014
  .transform(({ params }) => ({
1703
2015
  kind: "item_started",
1704
2016
  source: "codex_event",
2017
+ turnId: null,
1705
2018
  item: params.msg.item,
1706
2019
  })),
1707
2020
  z.object({ method: z.literal("codex/event/item_started"), params: z.unknown() }).transform(({ method, params }) => ({
@@ -1717,6 +2030,7 @@ const CodexNotificationSchema = z.union([
1717
2030
  .transform(({ params }) => ({
1718
2031
  kind: "item_completed",
1719
2032
  source: "codex_event",
2033
+ turnId: null,
1720
2034
  item: params.msg.item,
1721
2035
  })),
1722
2036
  z.object({ method: z.literal("codex/event/item_completed"), params: z.unknown() }).transform(({ method, params }) => ({
@@ -1771,6 +2085,7 @@ const CodexNotificationSchema = z.union([
1771
2085
  })
1772
2086
  .transform(({ params }) => ({
1773
2087
  kind: "exec_command_output_delta",
2088
+ turnId: null,
1774
2089
  callId: params.msg.call_id ?? null,
1775
2090
  stream: params.msg.stream ?? null,
1776
2091
  chunk: params.msg.chunk ?? params.msg.delta ?? null,
@@ -1785,6 +2100,27 @@ const CodexNotificationSchema = z.union([
1785
2100
  method,
1786
2101
  params,
1787
2102
  })),
2103
+ z
2104
+ .object({
2105
+ method: z.literal("item/commandExecution/outputDelta"),
2106
+ params: ItemCommandExecutionOutputDeltaNotificationSchema,
2107
+ })
2108
+ .transform(({ params }) => ({
2109
+ kind: "command_execution_output_delta",
2110
+ turnId: params.turnId ?? null,
2111
+ itemId: params.itemId,
2112
+ delta: params.delta ?? null,
2113
+ })),
2114
+ z
2115
+ .object({
2116
+ method: z.literal("item/commandExecution/outputDelta"),
2117
+ params: z.unknown(),
2118
+ })
2119
+ .transform(({ method, params }) => ({
2120
+ kind: "invalid_payload",
2121
+ method,
2122
+ params,
2123
+ })),
1788
2124
  z
1789
2125
  .object({
1790
2126
  method: z.literal("codex/event/terminal_interaction"),
@@ -1793,6 +2129,7 @@ const CodexNotificationSchema = z.union([
1793
2129
  .transform(({ params }) => ({
1794
2130
  kind: "terminal_interaction",
1795
2131
  source: "codex_event",
2132
+ turnId: null,
1796
2133
  callId: params.msg.call_id ?? null,
1797
2134
  processId: typeof params.msg.process_id === "number"
1798
2135
  ? String(params.msg.process_id)
@@ -1814,6 +2151,7 @@ const CodexNotificationSchema = z.union([
1814
2151
  .transform(({ params }) => ({
1815
2152
  kind: "terminal_interaction",
1816
2153
  source: "item",
2154
+ turnId: params.turnId ?? null,
1817
2155
  callId: params.itemId ?? null,
1818
2156
  processId: typeof params.processId === "number"
1819
2157
  ? String(params.processId)
@@ -1870,6 +2208,7 @@ const CodexNotificationSchema = z.union([
1870
2208
  })
1871
2209
  .transform(({ params }) => ({
1872
2210
  kind: "file_change_output_delta",
2211
+ turnId: params.turnId ?? null,
1873
2212
  itemId: params.itemId,
1874
2213
  delta: params.delta ?? params.chunk ?? null,
1875
2214
  })),
@@ -1926,6 +2265,15 @@ const CodexNotificationSchema = z.union([
1926
2265
  .object({ method: z.string(), params: z.unknown() })
1927
2266
  .transform(({ method, params }) => ({ kind: "unknown_method", method, params })),
1928
2267
  ]);
2268
+ // Pull "n/5" out of codex's "Reconnecting... 2/5" warning message. Best-effort:
2269
+ // returns nulls when the message has no counter (e.g. a transport-fallback warning).
2270
+ function parseReconnectAttemptCounts(message) {
2271
+ const match = message.match(/(\d+)\s*\/\s*(\d+)/);
2272
+ if (!match) {
2273
+ return { attempt: null, maxAttempts: null };
2274
+ }
2275
+ return { attempt: Number(match[1]), maxAttempts: Number(match[2]) };
2276
+ }
1929
2277
  async function writeImageAttachment(mimeType, data) {
1930
2278
  const attachmentsDir = path.join(os.tmpdir(), CODEX_IMAGE_ATTACHMENT_DIR);
1931
2279
  await fs.mkdir(attachmentsDir, { recursive: true });
@@ -2013,10 +2361,7 @@ function buildCodexAppServerEnv(runtimeSettings, launchEnv) {
2013
2361
  env.HTTPS_PROXY = latencyProxyUrl;
2014
2362
  }
2015
2363
  else {
2016
- env.http_proxy = "";
2017
- env.https_proxy = "";
2018
- env.HTTP_PROXY = "";
2019
- env.HTTPS_PROXY = "";
2364
+ clearInheritedProxyEnv(env, runtimeSettings);
2020
2365
  }
2021
2366
  const merged = launchEnv
2022
2367
  ? {
@@ -2024,8 +2369,25 @@ function buildCodexAppServerEnv(runtimeSettings, launchEnv) {
2024
2369
  ...launchEnv,
2025
2370
  }
2026
2371
  : env;
2027
- if (readStringMetadata(merged.OPENAI_BASE_URL)) {
2028
- merged.SEAWORK_API_KEY = merged.OPENAI_API_KEY ?? "";
2372
+ // Merge the localhost bypass AFTER launchEnv is spread: a launch context that
2373
+ // carries its own NO_PROXY would otherwise overwrite the bypass and send local
2374
+ // daemon MCP traffic back through the CONNECT-only latency proxy.
2375
+ if (latencyProxyUrl) {
2376
+ mergeLocalhostProxyBypass(merged);
2377
+ }
2378
+ // LLM endpoint comes ONLY from SEAWORK_LLM_BASE_URL (auth.json base_url).
2379
+ // OPENAI_BASE_URL is the desktop's image/SAC gateway, not the LLM gateway.
2380
+ const llmBaseUrl = readStringMetadata(merged.SEAWORK_LLM_BASE_URL);
2381
+ if (llmBaseUrl) {
2382
+ merged.SEAWORK_API_KEY = merged.OPENAI_API_KEY ?? merged.SEAWORK_API_KEY ?? "";
2383
+ }
2384
+ // Enable codex's HTTP request-completion debug line (one per request, which
2385
+ // carries the gateway `x-request-id` response header) so turn failures can be
2386
+ // tagged with a request id — including stream disconnects, where codex omits
2387
+ // the id from the error message. Scoped to a single module plus a global
2388
+ // `error` level so stderr stays lean. A user-set RUST_LOG always wins.
2389
+ if (!readStringMetadata(merged.RUST_LOG)) {
2390
+ merged.RUST_LOG = "error,codex_client::default_client=debug";
2029
2391
  }
2030
2392
  return merged;
2031
2393
  }
@@ -2034,7 +2396,8 @@ function codexProviderBaseUrl(baseUrl) {
2034
2396
  return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
2035
2397
  }
2036
2398
  function buildCodexSeaworkProviderConfig(env) {
2037
- const baseUrl = readStringMetadata(env.OPENAI_BASE_URL);
2399
+ // LLM endpoint comes ONLY from SEAWORK_LLM_BASE_URL (auth.json base_url).
2400
+ const baseUrl = readStringMetadata(env.SEAWORK_LLM_BASE_URL);
2038
2401
  if (!baseUrl) {
2039
2402
  return null;
2040
2403
  }
@@ -2045,6 +2408,7 @@ function buildCodexSeaworkProviderConfig(env) {
2045
2408
  name: "Seawork",
2046
2409
  base_url: codexProviderBaseUrl(baseUrl),
2047
2410
  env_key: "SEAWORK_API_KEY",
2411
+ http_headers: buildSeaworkGatewayHeaders(CODEX_PROVIDER),
2048
2412
  },
2049
2413
  },
2050
2414
  };
@@ -2064,6 +2428,322 @@ function mergeCodexConfig(base, override) {
2064
2428
  },
2065
2429
  };
2066
2430
  }
2431
+ function formatCodexTomlString(value) {
2432
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
2433
+ }
2434
+ function buildManagedCodexConfigToml(config, logger) {
2435
+ if (!config) {
2436
+ return null;
2437
+ }
2438
+ const providerId = readStringMetadata(config.model_provider);
2439
+ const providerConfigs = readStringRecord(config.model_providers);
2440
+ const provider = providerId ? readStringRecord(providerConfigs[providerId]) : {};
2441
+ const providerName = readStringMetadata(provider.name);
2442
+ const providerBaseUrl = readStringMetadata(provider.base_url);
2443
+ const providerEnvKey = readStringMetadata(provider.env_key);
2444
+ const providerHeaders = readStringRecord(provider.http_headers);
2445
+ if (!providerId || !providerName || !providerBaseUrl || !providerEnvKey) {
2446
+ logger?.warn({
2447
+ providerId,
2448
+ hasProviderName: Boolean(providerName),
2449
+ hasProviderBaseUrl: Boolean(providerBaseUrl),
2450
+ hasProviderEnvKey: Boolean(providerEnvKey),
2451
+ }, "Skipping managed Codex config overlay because provider config is incomplete");
2452
+ return null;
2453
+ }
2454
+ const lines = [
2455
+ `model_provider = ${formatCodexTomlString(providerId)}`,
2456
+ "",
2457
+ `[model_providers.${providerId}]`,
2458
+ `name = ${formatCodexTomlString(providerName)}`,
2459
+ `base_url = ${formatCodexTomlString(providerBaseUrl)}`,
2460
+ `env_key = ${formatCodexTomlString(providerEnvKey)}`,
2461
+ ];
2462
+ const headerEntries = Object.entries(providerHeaders)
2463
+ .filter((entry) => typeof entry[1] === "string")
2464
+ .sort(([a], [b]) => a.localeCompare(b));
2465
+ if (headerEntries.length > 0) {
2466
+ lines.push("", `[model_providers.${providerId}.http_headers]`);
2467
+ for (const [name, value] of headerEntries) {
2468
+ lines.push(`${formatCodexTomlString(name)} = ${formatCodexTomlString(value)}`);
2469
+ }
2470
+ }
2471
+ return `${lines.join("\n")}\n`;
2472
+ }
2473
+ function managedCodexHomeRoot() {
2474
+ return path.join(os.tmpdir(), "seawork-managed-codex-home");
2475
+ }
2476
+ function managedCodexHomePath(originalHome, configToml) {
2477
+ const digest = createHash("sha1")
2478
+ .update(originalHome)
2479
+ .update("\0")
2480
+ .update(configToml)
2481
+ .digest("hex");
2482
+ return path.join(managedCodexHomeRoot(), digest);
2483
+ }
2484
+ const MANAGED_CODEX_HOME_PRESERVED_ROOT_NAMES = ["AGENTS.md", "plugins"];
2485
+ const MANAGED_CODEX_ROOT_ENTRY_LEDGER = ".seawork-managed-root-entries.json";
2486
+ const MANAGED_CODEX_HOME_PRESERVED_ROOT_NAME_SET = new Set(MANAGED_CODEX_HOME_PRESERVED_ROOT_NAMES);
2487
+ const MANAGED_CODEX_HOME_BUSY_REMOVE_CODES = new Set(["EBUSY", "EPERM", "EACCES"]);
2488
+ async function ensureManagedCodexLink(source, dest) {
2489
+ let sourceLstat;
2490
+ try {
2491
+ sourceLstat = await fs.lstat(source);
2492
+ }
2493
+ catch {
2494
+ return;
2495
+ }
2496
+ try {
2497
+ const existing = await fs.lstat(dest);
2498
+ if (existing.isSymbolicLink()) {
2499
+ const existingTarget = await fs.readlink(dest);
2500
+ const resolvedExisting = path.resolve(path.dirname(dest), existingTarget);
2501
+ if (resolvedExisting === source) {
2502
+ return;
2503
+ }
2504
+ }
2505
+ await fs.rm(dest, { recursive: true, force: true });
2506
+ }
2507
+ catch {
2508
+ // Destination does not exist.
2509
+ }
2510
+ let symlinkType;
2511
+ if (process.platform === "win32") {
2512
+ if (sourceLstat.isSymbolicLink()) {
2513
+ try {
2514
+ const sourceStat = await fs.stat(source);
2515
+ symlinkType = sourceStat.isDirectory() ? "junction" : "file";
2516
+ }
2517
+ catch {
2518
+ return;
2519
+ }
2520
+ }
2521
+ else {
2522
+ symlinkType = sourceLstat.isDirectory() ? "junction" : "file";
2523
+ }
2524
+ }
2525
+ await fs.symlink(source, dest, symlinkType);
2526
+ }
2527
+ function areSameFilesystemEntry(sourceStat, destStat) {
2528
+ return sourceStat.dev === destStat.dev && sourceStat.ino === destStat.ino;
2529
+ }
2530
+ async function ensureManagedCodexFileAlias(source, dest, logger) {
2531
+ const sourceStat = await fs.stat(source);
2532
+ let existing;
2533
+ try {
2534
+ existing = await fs.lstat(dest);
2535
+ }
2536
+ catch {
2537
+ // Destination does not exist.
2538
+ }
2539
+ if (existing) {
2540
+ let shouldPreserveDestFile = false;
2541
+ if (existing.isSymbolicLink()) {
2542
+ const existingTarget = await fs.readlink(dest);
2543
+ const resolvedExisting = path.resolve(path.dirname(dest), existingTarget);
2544
+ if (resolvedExisting === source) {
2545
+ return;
2546
+ }
2547
+ }
2548
+ else if (existing.isFile()) {
2549
+ const destStat = await fs.stat(dest);
2550
+ if (areSameFilesystemEntry(sourceStat, destStat)) {
2551
+ return;
2552
+ }
2553
+ shouldPreserveDestFile =
2554
+ process.platform === "win32" && destStat.mtimeMs > sourceStat.mtimeMs;
2555
+ }
2556
+ if (shouldPreserveDestFile) {
2557
+ await fs.copyFile(dest, source);
2558
+ }
2559
+ await fs.rm(dest, { recursive: true, force: true });
2560
+ }
2561
+ if (process.platform !== "win32") {
2562
+ await fs.symlink(source, dest);
2563
+ return;
2564
+ }
2565
+ try {
2566
+ await fs.link(source, dest);
2567
+ return;
2568
+ }
2569
+ catch (error) {
2570
+ logger.warn({ error, source, dest }, "Failed to hard-link managed Codex file alias; falling back to copy");
2571
+ }
2572
+ await fs.copyFile(source, dest);
2573
+ }
2574
+ async function ensureManagedCodexPathAlias(params) {
2575
+ if (params.kind === "directory") {
2576
+ await fs.mkdir(params.source, { recursive: true });
2577
+ await ensureManagedCodexLink(params.source, params.dest);
2578
+ return;
2579
+ }
2580
+ await fs.mkdir(path.dirname(params.source), { recursive: true });
2581
+ try {
2582
+ await fs.access(params.source);
2583
+ }
2584
+ catch {
2585
+ try {
2586
+ await fs.copyFile(params.dest, params.source);
2587
+ }
2588
+ catch {
2589
+ await fs.writeFile(params.source, "", "utf8");
2590
+ }
2591
+ }
2592
+ await ensureManagedCodexFileAlias(params.source, params.dest, params.logger);
2593
+ }
2594
+ async function resolveManagedCodexRootEntryKind(sourcePath, entry) {
2595
+ if (entry.isSymbolicLink()) {
2596
+ try {
2597
+ const stat = await fs.stat(sourcePath);
2598
+ if (stat.isDirectory())
2599
+ return "directory";
2600
+ if (stat.isFile())
2601
+ return "file";
2602
+ return null;
2603
+ }
2604
+ catch {
2605
+ return null;
2606
+ }
2607
+ }
2608
+ if (entry.isDirectory())
2609
+ return "directory";
2610
+ if (entry.isFile())
2611
+ return "file";
2612
+ return null;
2613
+ }
2614
+ async function isManagedCodexRootAlias(params) {
2615
+ const destPath = path.join(params.managedHome, params.entryName);
2616
+ try {
2617
+ const existing = await fs.lstat(destPath);
2618
+ if (existing.isSymbolicLink()) {
2619
+ const existingTarget = await fs.readlink(destPath);
2620
+ const resolvedExisting = path.resolve(path.dirname(destPath), existingTarget);
2621
+ return resolvedExisting === path.join(params.originalHome, params.entryName);
2622
+ }
2623
+ if (process.platform === "win32" && params.entryName === "AGENTS.md" && existing.isFile()) {
2624
+ return true;
2625
+ }
2626
+ return false;
2627
+ }
2628
+ catch {
2629
+ return false;
2630
+ }
2631
+ }
2632
+ async function removeManagedCodexHomeEntry(targetPath, logger) {
2633
+ try {
2634
+ await fs.rm(targetPath, { recursive: true, force: true });
2635
+ }
2636
+ catch (error) {
2637
+ const code = error.code;
2638
+ if (process.platform === "win32" && code && MANAGED_CODEX_HOME_BUSY_REMOVE_CODES.has(code)) {
2639
+ logger.warn({ error, path: targetPath }, "Skipping locked managed Codex home entry during cleanup");
2640
+ return;
2641
+ }
2642
+ throw error;
2643
+ }
2644
+ }
2645
+ function isManagedCodexHomePreservedRootName(value) {
2646
+ return (typeof value === "string" &&
2647
+ MANAGED_CODEX_HOME_PRESERVED_ROOT_NAME_SET.has(value));
2648
+ }
2649
+ async function readManagedCodexRootEntryLedger(managedHome) {
2650
+ try {
2651
+ const raw = await fs.readFile(path.join(managedHome, MANAGED_CODEX_ROOT_ENTRY_LEDGER), "utf8");
2652
+ const parsed = JSON.parse(raw);
2653
+ if (!Array.isArray(parsed)) {
2654
+ return new Set();
2655
+ }
2656
+ return new Set(parsed.filter(isManagedCodexHomePreservedRootName));
2657
+ }
2658
+ catch {
2659
+ return new Set();
2660
+ }
2661
+ }
2662
+ async function preserveManagedCodexHomeRootEntries(params) {
2663
+ let entries;
2664
+ try {
2665
+ entries = await fs.readdir(params.originalHome, { withFileTypes: true });
2666
+ }
2667
+ catch {
2668
+ return;
2669
+ }
2670
+ const entriesByName = new Map(entries.map((entry) => [entry.name, entry]));
2671
+ const previouslyManagedNames = await readManagedCodexRootEntryLedger(params.managedHome);
2672
+ const nextManagedNames = new Set();
2673
+ for (const entryName of MANAGED_CODEX_HOME_PRESERVED_ROOT_NAMES) {
2674
+ const entry = entriesByName.get(entryName);
2675
+ const sourcePath = path.join(params.originalHome, entryName);
2676
+ const destPath = path.join(params.managedHome, entryName);
2677
+ if (!entry) {
2678
+ if (previouslyManagedNames.has(entryName) ||
2679
+ (previouslyManagedNames.size === 0 &&
2680
+ (await isManagedCodexRootAlias({
2681
+ managedHome: params.managedHome,
2682
+ originalHome: params.originalHome,
2683
+ entryName,
2684
+ })))) {
2685
+ await removeManagedCodexHomeEntry(destPath, params.logger);
2686
+ }
2687
+ continue;
2688
+ }
2689
+ const kind = await resolveManagedCodexRootEntryKind(sourcePath, entry);
2690
+ if (!kind) {
2691
+ if (previouslyManagedNames.has(entryName)) {
2692
+ await removeManagedCodexHomeEntry(destPath, params.logger);
2693
+ }
2694
+ continue;
2695
+ }
2696
+ await ensureManagedCodexPathAlias({
2697
+ source: sourcePath,
2698
+ dest: destPath,
2699
+ kind,
2700
+ logger: params.logger,
2701
+ });
2702
+ nextManagedNames.add(entryName);
2703
+ }
2704
+ await fs.writeFile(path.join(params.managedHome, MANAGED_CODEX_ROOT_ENTRY_LEDGER), `${JSON.stringify([...nextManagedNames].sort())}\n`, "utf8");
2705
+ }
2706
+ async function prepareManagedCodexHome(params) {
2707
+ const managedConfig = buildCodexSeaworkProviderConfig(params.env);
2708
+ const configToml = buildManagedCodexConfigToml(managedConfig, params.logger);
2709
+ if (!configToml) {
2710
+ return null;
2711
+ }
2712
+ const originalHome = readStringMetadata(params.env.CODEX_HOME) ?? path.join(os.homedir(), ".codex");
2713
+ const managedHome = managedCodexHomePath(originalHome, configToml);
2714
+ await fs.mkdir(managedHome, { recursive: true });
2715
+ await fs.writeFile(path.join(managedHome, "config.toml"), configToml, "utf8");
2716
+ await ensureManagedCodexPathAlias({
2717
+ source: path.join(originalHome, "sessions"),
2718
+ dest: path.join(managedHome, "sessions"),
2719
+ kind: "directory",
2720
+ logger: params.logger,
2721
+ });
2722
+ await ensureManagedCodexPathAlias({
2723
+ source: path.join(originalHome, "prompts"),
2724
+ dest: path.join(managedHome, "prompts"),
2725
+ kind: "directory",
2726
+ logger: params.logger,
2727
+ });
2728
+ await ensureManagedCodexPathAlias({
2729
+ source: path.join(originalHome, "skills"),
2730
+ dest: path.join(managedHome, "skills"),
2731
+ kind: "directory",
2732
+ logger: params.logger,
2733
+ });
2734
+ await ensureManagedCodexPathAlias({
2735
+ source: path.join(originalHome, "external_agent_session_imports.json"),
2736
+ dest: path.join(managedHome, "external_agent_session_imports.json"),
2737
+ kind: "file",
2738
+ logger: params.logger,
2739
+ });
2740
+ await preserveManagedCodexHomeRootEntries({
2741
+ originalHome,
2742
+ managedHome,
2743
+ logger: params.logger,
2744
+ });
2745
+ return managedHome;
2746
+ }
2067
2747
  function withManagedCodexConfig(config, runtimeSettings, launchEnv) {
2068
2748
  const env = buildCodexAppServerEnv(runtimeSettings, launchEnv);
2069
2749
  const managedConfig = buildCodexSeaworkProviderConfig(env);
@@ -2091,9 +2771,10 @@ function buildCodexAppServerInitializeParams() {
2091
2771
  };
2092
2772
  }
2093
2773
  class CodexAppServerAgentSession {
2094
- constructor(config, resumeHandle, logger, spawnAppServer) {
2774
+ constructor(config, resumeHandle, logger, spawnAppServer, goalsEnabled = false) {
2095
2775
  this.resumeHandle = resumeHandle;
2096
2776
  this.spawnAppServer = spawnAppServer;
2777
+ this.goalsEnabled = goalsEnabled;
2097
2778
  this.provider = CODEX_PROVIDER;
2098
2779
  this.capabilities = CODEX_APP_SERVER_CAPABILITIES;
2099
2780
  this.currentThreadId = null;
@@ -2106,6 +2787,62 @@ class CodexAppServerAgentSession {
2106
2787
  this.subscribers = new Set();
2107
2788
  this.nextTurnOrdinal = 0;
2108
2789
  this.activeForegroundTurnId = null;
2790
+ // issue #505: fires if the active turn goes silent (lost `turn/completed`).
2791
+ this.turnWatchdogTimer = null;
2792
+ // issue #1427: grace timer armed when the watchdog decides a turn is stalled.
2793
+ // Commits forceFailActiveTurn only if no turn/completed arrives within
2794
+ // WATCHDOG_LATE_COMPLETION_GRACE_MS, so a late-but-successful completion is
2795
+ // not mis-killed. Cleared by any notification (the watchdog re-arm path) and
2796
+ // by turn teardown.
2797
+ this.pendingForceFailTimer = null;
2798
+ // issue #505: consecutive watchdog cycles that have elapsed in silence while
2799
+ // a tool was in flight. Reset by any real notification; bounded so a lost
2800
+ // tool completion still recovers instead of deferring the watchdog forever.
2801
+ this.turnWatchdogInflightCycles = 0;
2802
+ // issue #505: set when a turn is force-failed (watchdog / process exit /
2803
+ // interrupt). Late notifications for that fenced turn are dropped until the
2804
+ // next `turn/started` arrives, so a stale `turn/completed` or terminal item
2805
+ // cannot resurface a turn the UI already saw fail.
2806
+ this.fencedAfterForcedFailure = false;
2807
+ // issue #505: codex's `turn/start` RPC response carries `turn.id`, which the
2808
+ // subsequent `turn/started` notification echoes verbatim. Saving it here lets
2809
+ // the fence reject a late `turn/started` belonging to a force-failed turn
2810
+ // even after a retry has set a fresh `activeForegroundTurnId` — we accept
2811
+ // only the `turn/started` whose id matches the most recent turn/start ack.
2812
+ this.expectedCodexTurnId = null;
2813
+ // issue #505: between the moment startTurn() sets activeForegroundTurnId and
2814
+ // the moment turn/start RPC ack returns and we know codex's turn.id, we
2815
+ // cannot distinguish a dead turn's late turn/started from the new turn's
2816
+ // own. This flag marks that "in-flight ack" window — while true the fence
2817
+ // unconditionally drops every turn/started, so a stale notification cannot
2818
+ // race the ack and pollute the new turn.
2819
+ this.turnStartAckPending = false;
2820
+ // issue #664 review #3 (gpt-5.5): codex can emit a new turn's
2821
+ // `turn/started` BEFORE the `turn/start` RPC ack returns. The ack-pending
2822
+ // fence drops it (since we don't yet know codex's turn.id to verify
2823
+ // ownership). Cache the most recent fence-dropped turn/started here so
2824
+ // the ack handler can replay it after `expectedCodexTurnId` is set —
2825
+ // otherwise the fence stays closed and the new turn looks silent until
2826
+ // the watchdog fires. Cleared on startTurn() entry, startTurn() failure,
2827
+ // and after a successful replay.
2828
+ this.pendingFencedTurnStarted = null;
2829
+ // issue #1235 review #3 (geli-bot): keep the most recently force-failed
2830
+ // codex turn id separate from `currentTurnId`. Older codex can omit
2831
+ // `turn.id` from turn/start ack, leaving item.turnId as the only ownership
2832
+ // signal for the retry turn. This lets the fallback fence reject the dead
2833
+ // turn specifically without hard-locking the retry.
2834
+ this.fencedTurnId = null;
2835
+ // issue #1836: the local foreground turn id of the most recent force-fail.
2836
+ // The fence self-heal reuses it when resuming the old codex turn so the
2837
+ // recovered turn's start/completed accounting (pushTurnEvent durationMs,
2838
+ // bug-report ring, latency) keys off the original id instead of a fresh one.
2839
+ this.lastForcedFailForegroundTurnId = null;
2840
+ // issue #1836: whether the current fence came from a watchdog force-fail (the
2841
+ // only case self-heal should resurrect). `resetForegroundTurnState` is also
2842
+ // called by the user interrupt/cancel paths, which fence the turn too —
2843
+ // self-heal must NOT revive a turn the user deliberately canceled, so it gates
2844
+ // on this flag rather than the fence merely existing.
2845
+ this.fencedByForceFail = false;
2109
2846
  this.cachedRuntimeInfo = null;
2110
2847
  this.serviceTier = null;
2111
2848
  this.planModeEnabled = false;
@@ -2115,6 +2852,17 @@ class CodexAppServerAgentSession {
2115
2852
  this.pendingPermissionHandlers = new Map();
2116
2853
  this.resolvedPermissionRequests = new Set();
2117
2854
  this.pendingAgentMessages = new Map();
2855
+ /**
2856
+ * Per-itemId count of assistant-message characters already emitted as
2857
+ * streaming `assistant_message` timeline deltas. `item_completed` uses this
2858
+ * to emit only the un-streamed tail (and skip entirely if all streamed),
2859
+ * avoiding a duplicate full-text emit at the end of a streamed message.
2860
+ */
2861
+ this.emittedAgentMessageLength = new Map();
2862
+ /** Pending throttle timer for streaming agent-message deltas. */
2863
+ this.agentMessageFlushTimer = null;
2864
+ /** itemId awaiting a throttled streaming flush. */
2865
+ this.agentMessageFlushPendingItemId = null;
2118
2866
  this.pendingReasoning = new Map();
2119
2867
  this.pendingCommandOutputDeltas = new Map();
2120
2868
  this.pendingFileChangeOutputDeltas = new Map();
@@ -2125,6 +2873,35 @@ class CodexAppServerAgentSession {
2125
2873
  this.emittedExecCommandCompletedCallIds = new Set();
2126
2874
  this.emittedItemStartedIds = new Set();
2127
2875
  this.emittedItemCompletedIds = new Set();
2876
+ // issue #505: callIds of tool executions that have started but not completed.
2877
+ // A long, output-silent command (build, network fetch) keeps this non-empty,
2878
+ // so the stall watchdog can tell "genuinely working" from "lost turn".
2879
+ this.inFlightToolCalls = new Set();
2880
+ // issue #999: itemIds of context-compaction items currently in flight. Like
2881
+ // inFlightToolCalls, it tells the watchdog "genuinely working" (packing the
2882
+ // context + summarizing) from "lost turn", so a long compaction is deferred
2883
+ // instead of force-failed.
2884
+ this.compactionInFlight = new Set();
2885
+ this.compactionItemTimers = new Map();
2886
+ // issue #999: consecutive watchdog cycles elapsed in silence while a
2887
+ // compaction was in flight. Reset by any real notification; bounded so a lost
2888
+ // compaction completion still recovers.
2889
+ this.turnWatchdogCompactionCycles = 0;
2890
+ // issue #1181: wall-clock time of the most recent codex notification accepted
2891
+ // by the active turn. The watchdog compares this to Date.now() so event-loop
2892
+ // starvation from other agents cannot cause a false stall.
2893
+ this.lastTurnNotificationTime = null;
2894
+ // issue #1427: last reconnect-attempt key for which a "reconnecting" loading
2895
+ // marker was emitted, so the paired warning+error(willRetry) for one attempt
2896
+ // collapse into a single marker. Reset to null when a marker is resolved
2897
+ // (real turn progress) or on turn teardown so the next stall re-announces.
2898
+ this.lastReconnectMarkerKey = null;
2899
+ // issue #1012: codex version parsed from the app-server `initialize`
2900
+ // response's `userAgent` (e.g. "codex/0.137.0 (...)"). Gates the manual
2901
+ // `/compact` command — `thread/compact/start` only exists on newer codex.
2902
+ this.codexVersion = null;
2903
+ this.manualCompactTurnId = null;
2904
+ this.manualCompactCanceledTurnIds = new Set();
2128
2905
  this.warnedUnknownNotificationMethods = new Set();
2129
2906
  this.warnedInvalidNotificationPayloads = new Set();
2130
2907
  this.warnedIncompleteEditToolCallIds = new Set();
@@ -2150,6 +2927,12 @@ class CodexAppServerAgentSession {
2150
2927
  if (this.resumeHandle?.sessionId) {
2151
2928
  this.currentThreadId = this.resumeHandle.sessionId;
2152
2929
  this.historyPending = true;
2930
+ // issue #277: restore the cancel sentinel persisted by describePersistence
2931
+ // so a turn/interrupt issued before resume is honored on the next turn.
2932
+ const persistedCanceledAt = this.resumeHandle.metadata?.lastCanceledAt;
2933
+ if (typeof persistedCanceledAt === "number" && Number.isFinite(persistedCanceledAt)) {
2934
+ this.lastCanceledAt = persistedCanceledAt;
2935
+ }
2153
2936
  }
2154
2937
  }
2155
2938
  get id() {
@@ -2169,17 +2952,41 @@ class CodexAppServerAgentSession {
2169
2952
  const child = await this.spawnAppServer();
2170
2953
  this.client = new CodexAppServerClient(child, this.logger);
2171
2954
  this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
2955
+ // issue #505: a mid-turn process crash never produces a `turn/completed`
2956
+ // notification, so fail the foreground turn explicitly instead of leaving
2957
+ // the UI stuck "thinking".
2958
+ this.client.setExitHandler((reason) => {
2959
+ this.handleClientExit(reason);
2960
+ });
2961
+ // issue #1824: a `/responses` round-trip completing proves the turn is alive
2962
+ // even with no protocol notification (multi-minute large-payload uploads),
2963
+ // so feed it into the stall watchdog as a liveness signal.
2964
+ this.client.setUpstreamLivenessHandler(() => this.markUpstreamLiveness());
2172
2965
  this.registerRequestHandlers();
2173
- await this.client.request("initialize", buildCodexAppServerInitializeParams());
2966
+ const initializeResponse = await this.client.request("initialize", buildCodexAppServerInitializeParams());
2967
+ this.codexVersion = parseCodexUserAgentVersion(initializeResponse);
2174
2968
  this.client.notify("initialized", {});
2175
2969
  await this.loadCollaborationModes();
2176
2970
  await this.loadSkills();
2177
2971
  if (this.currentThreadId) {
2178
- await this.loadPersistedHistory();
2179
- await this.ensureThreadLoaded();
2972
+ await this.restorePersistedHistoryForResume();
2180
2973
  }
2181
2974
  this.connected = true;
2182
2975
  }
2976
+ async restorePersistedHistoryForResume() {
2977
+ if (!this.currentThreadId) {
2978
+ return;
2979
+ }
2980
+ const resumedThreadId = this.currentThreadId;
2981
+ const restored = await this.loadPersistedHistory();
2982
+ await this.ensureThreadLoaded();
2983
+ // Some Codex builds only allow thread/read after thread/resume has loaded
2984
+ // the thread into the app-server process. Retry once so reconnect/login
2985
+ // flows don't lose visible history just because the first read raced.
2986
+ if (!restored && this.currentThreadId === resumedThreadId) {
2987
+ await this.loadPersistedHistory();
2988
+ }
2989
+ }
2183
2990
  async loadCollaborationModes() {
2184
2991
  if (!this.client)
2185
2992
  return;
@@ -2202,7 +3009,7 @@ class CodexAppServerAgentSession {
2202
3009
  }
2203
3010
  async loadSkills() {
2204
3011
  const skillsByName = new Map();
2205
- const projectSkills = await listCodexSkillEntries(this.config.cwd);
3012
+ const projectSkills = await listCodexSkillEntries(this.config.cwd, this.logger);
2206
3013
  for (const skill of projectSkills) {
2207
3014
  skillsByName.set(skill.name, skill);
2208
3015
  }
@@ -2231,7 +3038,10 @@ class CodexAppServerAgentSession {
2231
3038
  catch (error) {
2232
3039
  this.logger.trace({ error }, "Failed to load skills list");
2233
3040
  }
2234
- this.cachedSkills = Array.from(skillsByName.values()).sort((a, b) => a.name.localeCompare(b.name));
3041
+ const denied = new Set(this.config.librarySkillDenylist ?? []);
3042
+ this.cachedSkills = Array.from(skillsByName.values())
3043
+ .filter((skill) => !denied.has(skill.name))
3044
+ .sort((a, b) => a.name.localeCompare(b.name));
2235
3045
  }
2236
3046
  findCollaborationMode(target) {
2237
3047
  if (this.collaborationModes.length === 0)
@@ -2343,10 +3153,66 @@ class CodexAppServerAgentSession {
2343
3153
  this.pendingPermissionHandlers.set(requestId, {
2344
3154
  resolve: () => undefined,
2345
3155
  kind: "plan",
3156
+ foregroundTurnId: this.activeForegroundTurnId,
2346
3157
  planText,
3158
+ // Capture the plan timeline item's callId now (while latestPlanResult is
3159
+ // populated) so an edited-plan approval can re-emit/replace that exact card
3160
+ // even after a daemon restart cleared latestPlanResult.
3161
+ planCallId: this.latestPlanResult?.callId,
2347
3162
  });
2348
3163
  this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
2349
3164
  }
3165
+ foregroundTurnIdForLegacyPermission() {
3166
+ if (this.turnStartAckPending) {
3167
+ return null;
3168
+ }
3169
+ return this.activeForegroundTurnId;
3170
+ }
3171
+ isActiveForegroundCodexTurnId(codexTurnId) {
3172
+ if (!codexTurnId || this.activeForegroundTurnId === null || this.turnStartAckPending) {
3173
+ return false;
3174
+ }
3175
+ if (this.expectedCodexTurnId) {
3176
+ return codexTurnId === this.expectedCodexTurnId;
3177
+ }
3178
+ if (this.currentTurnId) {
3179
+ return codexTurnId === this.currentTurnId;
3180
+ }
3181
+ if (this.fencedAfterForcedFailure) {
3182
+ return this.fencedTurnId === null || codexTurnId !== this.fencedTurnId;
3183
+ }
3184
+ return false;
3185
+ }
3186
+ foregroundTurnIdForCodexPermission(codexTurnId) {
3187
+ const foregroundTurnId = this.activeForegroundTurnId;
3188
+ if (!foregroundTurnId) {
3189
+ return null;
3190
+ }
3191
+ return this.isActiveForegroundCodexTurnId(codexTurnId) ? foregroundTurnId : null;
3192
+ }
3193
+ parsedNotificationTurnId(parsed) {
3194
+ switch (parsed.kind) {
3195
+ case "turn_started":
3196
+ return parsed.turnId;
3197
+ case "token_usage_updated":
3198
+ case "agent_message_delta":
3199
+ case "reasoning_delta":
3200
+ case "raw_response_item_completed":
3201
+ case "command_execution_output_delta":
3202
+ case "file_change_output_delta":
3203
+ return parsed.turnId;
3204
+ case "item_started":
3205
+ case "item_completed":
3206
+ case "terminal_interaction":
3207
+ return parsed.turnId;
3208
+ default:
3209
+ return null;
3210
+ }
3211
+ }
3212
+ isCurrentRetryTurnNotification(parsed) {
3213
+ const turnId = this.parsedNotificationTurnId(parsed);
3214
+ return this.isActiveForegroundCodexTurnId(turnId);
3215
+ }
2350
3216
  /**
2351
3217
  * Prepare the session for plan implementation by disabling plan mode (so the
2352
3218
  * next turn runs in `code` collaboration mode instead of generating another
@@ -2365,27 +3231,29 @@ class CodexAppServerAgentSession {
2365
3231
  return;
2366
3232
  this.client.setRequestHandler("item/commandExecution/requestApproval", (params) => this.handleCommandApprovalRequest(params));
2367
3233
  this.client.setRequestHandler("item/fileChange/requestApproval", (params) => this.handleFileChangeApprovalRequest(params));
3234
+ this.client.setRequestHandler("execCommandApproval", (params) => this.handleLegacyExecCommandApprovalRequest(params));
3235
+ this.client.setRequestHandler("applyPatchApproval", (params) => this.handleLegacyApplyPatchApprovalRequest(params));
2368
3236
  this.client.setRequestHandler("item/tool/requestUserInput", (params) => this.handleToolApprovalRequest(params));
2369
3237
  // Keep the legacy method name for older Codex builds.
2370
3238
  this.client.setRequestHandler("tool/requestUserInput", (params) => this.handleToolApprovalRequest(params));
2371
3239
  }
2372
3240
  async loadPersistedHistory() {
2373
3241
  if (!this.client || !this.currentThreadId)
2374
- return;
3242
+ return false;
3243
+ let rolloutTimeline = [];
3244
+ try {
3245
+ rolloutTimeline = await loadCodexPersistedTimeline(this.currentThreadId, undefined, this.logger);
3246
+ }
3247
+ catch {
3248
+ rolloutTimeline = [];
3249
+ }
3250
+ let threadTimeline = [];
2375
3251
  try {
2376
- let rolloutTimeline = [];
2377
- try {
2378
- rolloutTimeline = await loadCodexPersistedTimeline(this.currentThreadId, undefined, this.logger);
2379
- }
2380
- catch {
2381
- rolloutTimeline = [];
2382
- }
2383
3252
  const response = (await this.client.request("thread/read", {
2384
3253
  threadId: this.currentThreadId,
2385
3254
  includeTurns: true,
2386
3255
  }));
2387
3256
  const thread = response?.thread;
2388
- const threadTimeline = [];
2389
3257
  if (thread && Array.isArray(thread.turns)) {
2390
3258
  for (const turn of thread.turns) {
2391
3259
  const items = Array.isArray(turn.items) ? turn.items : [];
@@ -2402,17 +3270,19 @@ class CodexAppServerAgentSession {
2402
3270
  }
2403
3271
  }
2404
3272
  }
2405
- const timeline = rolloutTimeline.length > 0 ? rolloutTimeline : threadTimeline;
2406
- if (timeline.length > 0) {
2407
- this.persistedHistory = timeline;
2408
- this.historyPending = true;
2409
- }
2410
3273
  }
2411
3274
  catch (error) {
2412
- this.logger.warn({ error }, "Failed to load Codex thread history");
3275
+ this.logger.warn({ error }, "Failed to read Codex thread history");
2413
3276
  }
2414
- }
2415
- async ensureThreadLoaded() {
3277
+ const timeline = rolloutTimeline.length > 0 ? rolloutTimeline : threadTimeline;
3278
+ if (timeline.length === 0) {
3279
+ return false;
3280
+ }
3281
+ this.persistedHistory = timeline;
3282
+ this.historyPending = true;
3283
+ return true;
3284
+ }
3285
+ async ensureThreadLoaded() {
2416
3286
  if (!this.client || !this.currentThreadId)
2417
3287
  return;
2418
3288
  try {
@@ -2501,6 +3371,9 @@ class CodexAppServerAgentSession {
2501
3371
  async run(prompt, options) {
2502
3372
  const timeline = [];
2503
3373
  let finalText = "";
3374
+ // Whether the previous timeline item was an assistant_message, so
3375
+ // consecutive streaming deltas accumulate into finalText.
3376
+ let prevTimelineWasAssistant = false;
2504
3377
  let usage;
2505
3378
  let turnId = null;
2506
3379
  const bufferedEvents = [];
@@ -2517,11 +3390,26 @@ class CodexAppServerAgentSession {
2517
3390
  }
2518
3391
  if (event.type === "timeline") {
2519
3392
  timeline.push(event.item);
3393
+ // assistant_message is now emitted as a stream of deltas (one per
3394
+ // ~60ms throttle window), not a single full-text item. Mirror the
3395
+ // UI's `appendAssistantMessage` coalescing: consecutive
3396
+ // assistant_message items concatenate; a non-assistant item resets
3397
+ // the run, so the last assistant message wins as finalText.
2520
3398
  if (event.item.type === "assistant_message") {
2521
- finalText = event.item.text;
3399
+ if (prevTimelineWasAssistant) {
3400
+ finalText += event.item.text;
3401
+ }
3402
+ else {
3403
+ finalText = event.item.text;
3404
+ prevTimelineWasAssistant = true;
3405
+ }
2522
3406
  }
2523
3407
  else if (event.item.type === "tool_call" && event.item.detail.type === "plan") {
2524
3408
  finalText = event.item.detail.text;
3409
+ prevTimelineWasAssistant = false;
3410
+ }
3411
+ else {
3412
+ prevTimelineWasAssistant = false;
2525
3413
  }
2526
3414
  return;
2527
3415
  }
@@ -2581,6 +3469,17 @@ class CodexAppServerAgentSession {
2581
3469
  if (!this.client) {
2582
3470
  throw new Error("Codex client not initialized");
2583
3471
  }
3472
+ // issue #1012: intercept `/compact` and route to the dedicated
3473
+ // `thread/compact/start` RPC instead of starting a turn. Mirrors claude's
3474
+ // rewind interception — fire-and-forget so we return the turnId immediately
3475
+ // and the synthesized turn events finalize the foreground turn.
3476
+ const compactInvocation = typeof prompt === "string" ? this.parseSlashCommandInput(prompt) : null;
3477
+ if (compactInvocation?.commandName === COMPACT_COMMAND_NAME) {
3478
+ const turnId = this.createTurnId();
3479
+ this.activeForegroundTurnId = turnId;
3480
+ void this.executeManualCompact(turnId);
3481
+ return { turnId };
3482
+ }
2584
3483
  const slashCommand = await this.resolveSlashCommandInvocation(prompt);
2585
3484
  const effectivePrompt = slashCommand
2586
3485
  ? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
@@ -2593,8 +3492,9 @@ class CodexAppServerAgentSession {
2593
3492
  }
2594
3493
  const input = this.maybePrependCancelReminder(await this.buildUserInput(effectivePrompt));
2595
3494
  const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
2596
- const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
2597
- const sandboxPolicyType = this.config.sandboxMode ?? preset.sandbox;
3495
+ let approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
3496
+ let sandboxPolicyType = this.config.sandboxMode ?? preset.sandbox;
3497
+ ({ approvalPolicy, sandboxPolicyType } = applyPlanModeConstraints(approvalPolicy, sandboxPolicyType, this.planModeEnabled));
2598
3498
  const params = {
2599
3499
  threadId: this.currentThreadId,
2600
3500
  input,
@@ -2634,22 +3534,72 @@ class CodexAppServerAgentSession {
2634
3534
  }
2635
3535
  const turnId = this.createTurnId();
2636
3536
  this.activeForegroundTurnId = turnId;
3537
+ // issue #664 review #3 (gpt-5.5): start with a clean cache. A previous
3538
+ // turn's leftover entry must not be replayed against this new turn.
3539
+ this.pendingFencedTurnStarted = null;
3540
+ // NOTE: the fence is intentionally NOT lifted here. `turn/start` is only a
3541
+ // request; the new turn has not begun until codex starts producing
3542
+ // notifications that we can safely attribute to it. Lifting the fence now
3543
+ // would let a dead turn's late notification, arriving in the gap before
3544
+ // the new turn is identified, be processed against the retry.
3545
+ this.armTurnWatchdog();
2637
3546
  const turnStartT0 = Date.now();
2638
3547
  this.logger.info({ turnId }, "[timing] codex turn/start request sending");
3548
+ // issue #505: from this point until the ack returns, we don't know codex's
3549
+ // turn.id for the new turn, so the fence must drop every turn/started
3550
+ // unconditionally — otherwise a dead turn's late turn/started could race
3551
+ // the ack and be treated as the new turn.
3552
+ this.turnStartAckPending = true;
3553
+ let turnStartResponse;
2639
3554
  try {
2640
- await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS);
3555
+ turnStartResponse = await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS);
2641
3556
  }
2642
3557
  catch (error) {
2643
3558
  this.logger.info({ turnId, elapsedMs: Date.now() - turnStartT0 }, "[timing] codex turn/start FAILED");
2644
3559
  this.activeForegroundTurnId = null;
3560
+ this.clearTurnWatchdog();
3561
+ this.turnStartAckPending = false;
3562
+ // issue #664 review #3 (gpt-5.5): the RPC threw, so no ack will
3563
+ // arrive to replay a cached turn/started. Drop the cache so a later
3564
+ // unrelated startTurn() can't pick it up.
3565
+ this.pendingFencedTurnStarted = null;
2645
3566
  throw error;
2646
3567
  }
2647
3568
  this.logger.info({ turnId, elapsedMs: Date.now() - turnStartT0 }, "[timing] codex turn/start acknowledged");
3569
+ // issue #505: capture codex's turn.id from the start ack so the fence can
3570
+ // reject a late `turn/started` belonging to a force-failed turn. If the
3571
+ // ack omits `turn.id` (older codex or future schema change), leave this
3572
+ // as null and the fence falls back to the weaker "any turn/started after
3573
+ // a retry lifts the fence" rule — no event flow is hard-locked.
3574
+ const parsedStartId = z
3575
+ .object({ turn: z.object({ id: z.string() }).passthrough() })
3576
+ .passthrough()
3577
+ .safeParse(turnStartResponse);
3578
+ this.expectedCodexTurnId = parsedStartId.success ? parsedStartId.data.turn.id : null;
3579
+ this.turnStartAckPending = false;
2648
3580
  // issue #259: do NOT consume `lastCanceledAt` here. The sentinel is only
2649
3581
  // cleared once we see turn/completed with status "completed" — a
2650
3582
  // turn/start ack can still race with turn/completed: failed, in which
2651
3583
  // case the orphaned prompt is still in the thread and the next retry
2652
3584
  // must carry the reminder (PR #273 review #3).
3585
+ // issue #664 review #3 (gpt-5.5): if codex emitted this turn's
3586
+ // `turn/started` while the ack was still pending, the fence dropped it
3587
+ // and cached it. Now that we know the expected codex turn.id, replay
3588
+ // it through handleNotification — the fence will see
3589
+ // `!turnStartAckPending` and a matching id, lift, and process the
3590
+ // turn_started normally. Without this replay, the new turn would have
3591
+ // no visible turn_started and stall until watchdog. Match by codex
3592
+ // turn.id when we have one; fall back to "any cached turn/started"
3593
+ // when the ack omitted turn.id (matches the looser fence rule above).
3594
+ const cached = this.pendingFencedTurnStarted;
3595
+ this.pendingFencedTurnStarted = null;
3596
+ if (cached) {
3597
+ const idMatches = this.expectedCodexTurnId === null || cached.codexTurnId === this.expectedCodexTurnId;
3598
+ if (idMatches) {
3599
+ this.logger.info({ turnId, codexTurnId: cached.codexTurnId }, "[timing] replaying turn/started that arrived during ack-pending window");
3600
+ this.handleNotification("turn/started", cached.rawParams);
3601
+ }
3602
+ }
2653
3603
  return { turnId };
2654
3604
  }
2655
3605
  subscribe(callback) {
@@ -2732,14 +3682,42 @@ class CodexAppServerAgentSession {
2732
3682
  async respondToPermission(requestId, response) {
2733
3683
  const pending = this.pendingPermissionHandlers.get(requestId);
2734
3684
  if (!pending) {
3685
+ if (this.resolvedPermissionRequests.has(requestId)) {
3686
+ this.emitEvent({
3687
+ type: "permission_resolved",
3688
+ provider: CODEX_PROVIDER,
3689
+ requestId,
3690
+ resolution: response,
3691
+ });
3692
+ return;
3693
+ }
2735
3694
  throw new Error(`No pending Codex app-server permission request with id '${requestId}'`);
2736
3695
  }
2737
3696
  const pendingRequest = this.pendingPermissions.get(requestId) ?? null;
2738
3697
  if (pending.kind === "plan") {
2739
3698
  let followUpPrompt;
2740
3699
  if (response.behavior === "allow") {
3700
+ const rawEditedPlan = response.updatedInput?.["plan"];
3701
+ const editedPlan = typeof rawEditedPlan === "string" ? rawEditedPlan : undefined;
3702
+ const originalPlan = pending.planText ?? pendingRequest?.metadata?.planText;
3703
+ // When the user edited the plan, re-emit the plan timeline item with the
3704
+ // same callId so the conversation reflects the approved (edited) version
3705
+ // instead of the original proposal. The callId is captured on the pending
3706
+ // handler at request time so this survives a daemon restart (which clears
3707
+ // the in-memory latestPlanResult).
3708
+ const planCallId = pending.planCallId ?? this.latestPlanResult?.callId;
3709
+ if (editedPlan != null && editedPlan !== originalPlan && planCallId) {
3710
+ const updatedItem = mapCodexPlanToToolCall({
3711
+ callId: planCallId,
3712
+ text: editedPlan,
3713
+ });
3714
+ if (updatedItem) {
3715
+ this.rememberPlanResult(updatedItem);
3716
+ this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: updatedItem });
3717
+ }
3718
+ }
2741
3719
  followUpPrompt = this.preparePlanImplementation({
2742
- planText: pending.planText ?? pendingRequest?.metadata?.planText,
3720
+ planText: editedPlan ?? originalPlan,
2743
3721
  });
2744
3722
  }
2745
3723
  this.pendingPermissionHandlers.delete(requestId);
@@ -2793,12 +3771,32 @@ class CodexAppServerAgentSession {
2793
3771
  resolution: response,
2794
3772
  });
2795
3773
  if (pending.kind === "command") {
2796
- const decision = response.behavior === "allow" ? "accept" : response.interrupt ? "cancel" : "decline";
3774
+ const decision = pending.protocol === "legacy-review"
3775
+ ? response.behavior === "allow"
3776
+ ? "approved"
3777
+ : response.interrupt
3778
+ ? "abort"
3779
+ : "denied"
3780
+ : response.behavior === "allow"
3781
+ ? "accept"
3782
+ : response.interrupt
3783
+ ? "cancel"
3784
+ : "decline";
2797
3785
  pending.resolve({ decision });
2798
3786
  return;
2799
3787
  }
2800
3788
  if (pending.kind === "file") {
2801
- const decision = response.behavior === "allow" ? "accept" : response.interrupt ? "cancel" : "decline";
3789
+ const decision = pending.protocol === "legacy-review"
3790
+ ? response.behavior === "allow"
3791
+ ? "approved"
3792
+ : response.interrupt
3793
+ ? "abort"
3794
+ : "denied"
3795
+ : response.behavior === "allow"
3796
+ ? "accept"
3797
+ : response.interrupt
3798
+ ? "cancel"
3799
+ : "decline";
2802
3800
  pending.resolve({ decision });
2803
3801
  return;
2804
3802
  }
@@ -2860,44 +3858,156 @@ class CodexAppServerAgentSession {
2860
3858
  model: this.config.model ?? null,
2861
3859
  thinkingOptionId,
2862
3860
  extra: this.config.extra,
2863
- systemPrompt: this.config.systemPrompt,
3861
+ // Persist the pre-injection base so resume re-injects once, not twice.
3862
+ // `systemPromptBase` may be "" (no user base) — coalesce to undefined so we
3863
+ // never persist the daemon-injected value back as the base.
3864
+ systemPrompt: ("systemPromptBase" in this.config
3865
+ ? this.config.systemPromptBase || undefined
3866
+ : this.config.systemPrompt) ?? null,
2864
3867
  mcpServers: this.config.mcpServers,
3868
+ // issue #277: persist the cancel sentinel so a turn/interrupt issued
3869
+ // before resume is still honored after — resume re-reads this into
3870
+ // `lastCanceledAt` so the next turn/start prepends the ignore reminder.
3871
+ // Only emitted when set, keeping the metadata backward-compatible.
3872
+ ...(this.lastCanceledAt !== null ? { lastCanceledAt: this.lastCanceledAt } : {}),
2865
3873
  },
2866
3874
  };
2867
3875
  }
2868
3876
  async interrupt() {
2869
- if (!this.client || !this.currentThreadId || !this.currentTurnId)
3877
+ if (this.client && this.manualCompactTurnId) {
3878
+ const foregroundTurnId = this.activeForegroundTurnId;
3879
+ if (foregroundTurnId === this.manualCompactTurnId) {
3880
+ this.manualCompactCanceledTurnIds.add(foregroundTurnId);
3881
+ this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
3882
+ this.activeForegroundTurnId = null;
3883
+ this.manualCompactTurnId = null;
3884
+ this.clearTurnWatchdog();
3885
+ }
2870
3886
  return;
2871
- try {
2872
- await this.client.request("turn/interrupt", {
2873
- threadId: this.currentThreadId,
2874
- turnId: this.currentTurnId,
2875
- });
2876
- // issue #259: codex protocol has no thread-truncate RPC, so a canceled
2877
- // user prompt stays in the thread. The sentinel timestamp is set on the
2878
- // turn/completed `interrupted` event below (not here), because a
2879
- // successful turn/interrupt RPC can race with normal completion — if
2880
- // the model already finished, the prompt has a paired assistant reply
2881
- // and the next turn must NOT prepend the sentinel.
2882
3887
  }
2883
- catch (error) {
2884
- this.logger.warn({ error }, "Failed to interrupt Codex turn");
3888
+ // issue #1418: turn/start ack succeeded but turn/started not yet received
3889
+ // (currentTurnId === null). No codex turnId → skip turn/interrupt RPC and
3890
+ // tear down locally so activeForegroundTurnId is cleared; otherwise it
3891
+ // never clears and every later startTurn() throws "already active".
3892
+ // `!turnStartAckPending` excludes the in-flight ack window: during that
3893
+ // window currentTurnId is also null, but tearing down here would orphan a
3894
+ // real codex turn (the awaited turn/start RPC still resolves and codex runs
3895
+ // it to completion silently). The guard at line below then makes interrupt()
3896
+ // a no-op while the ack is pending, matching isActiveForegroundCodexTurnId
3897
+ // and the fence-lift semantics.
3898
+ if (this.activeForegroundTurnId && !this.currentTurnId && !this.turnStartAckPending) {
3899
+ this.lastCanceledAt = Date.now();
3900
+ this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
3901
+ this.activeForegroundTurnId = null;
3902
+ this.resetForegroundTurnState();
3903
+ return;
3904
+ }
3905
+ if (!this.client || !this.currentThreadId || !this.currentTurnId)
3906
+ return;
3907
+ const threadId = this.currentThreadId;
3908
+ const codexTurnId = this.currentTurnId;
3909
+ const foregroundTurnId = this.activeForegroundTurnId;
3910
+ // issue #664 review (gpt-5.5): send `turn/interrupt` BEFORE tearing
3911
+ // down local state. If the RPC fails or is rejected (transport drop,
3912
+ // codex rejects the cancel, no client connected), codex's old turn is
3913
+ // still running — we must NOT optimistically clear
3914
+ // `activeForegroundTurnId`, open the fence, or set the sentinel,
3915
+ // because doing so would let a new turn start while the old one keeps
3916
+ // streaming and would also fence-drop the old turn's eventual terminal
3917
+ // notification, leaving UI + provider state desynced from codex's
3918
+ // real state. The throw propagates to agent-manager, whose existing
3919
+ // 2s wait + synthetic `turn_canceled` + watchdog handle the failure
3920
+ // path, matching pre-#664 behavior for the RPC-failed case.
3921
+ await this.client.request("turn/interrupt", {
3922
+ threadId,
3923
+ turnId: codexTurnId,
3924
+ });
3925
+ // issue #664: RPC accepted. Now clear provider-side turn state
3926
+ // synchronously instead of waiting for codex to send
3927
+ // `turn/completed (interrupted)` — codex can fail to send it (upstream
3928
+ // cancel-token guard race in codex-rs `tasks/mod.rs:430`), and that's
3929
+ // the specific case this PR is fixing. agent-manager's 2s
3930
+ // force-dispatch synthetic `turn_canceled` only clears agent-manager's
3931
+ // own state, leaving provider's `activeForegroundTurnId` set, so the
3932
+ // next `startTurn()` would throw "A foreground turn is already
3933
+ // active". Aligns with claude provider's `interrupt()`, which also
3934
+ // drops state synchronously (via `requestCancel`) once the cancel
3935
+ // request has been issued.
3936
+ //
3937
+ // Guard against the RPC await racing a real `turn/completed` (the
3938
+ // happy path where codex DID emit a terminal notification): if
3939
+ // `handleNotification` already cleared `activeForegroundTurnId` or
3940
+ // moved on to a new turn while we awaited, don't double-teardown.
3941
+ if (foregroundTurnId && this.activeForegroundTurnId === foregroundTurnId) {
3942
+ // issue #259 + #664: optimistically set the sentinel. PR #664's
3943
+ // whole point is that codex can fail to send `turn/completed
3944
+ // (interrupted)`. If we waited for that notification, the
3945
+ // lost-notification path would leave the orphaned user prompt in
3946
+ // the thread without the next turn warning the model to ignore it.
3947
+ // The race-aware override stays in `handleNotification`: if codex
3948
+ // actually won the race and reports `turn/completed
3949
+ // status=completed`, the fenced branch clears this back to null so
3950
+ // the next turn does NOT carry a stale sentinel.
3951
+ //
3952
+ // issue #277: set this BEFORE the synchronous `emitEvent` below so the
3953
+ // `turn_canceled`-driven `finalizeForegroundTurn` snapshot persists the
3954
+ // sentinel; otherwise describePersistence would capture the stale null.
3955
+ this.lastCanceledAt = Date.now();
3956
+ // Emit `turn_canceled` BEFORE clearing `activeForegroundTurnId`:
3957
+ // `notifySubscribers` reads `activeForegroundTurnId` to tag the event
3958
+ // with `turnId`, so consumers can correlate the cancel back to the
3959
+ // foreground turn it terminates. Mirrors `forceFailActiveTurn`'s
3960
+ // ordering for the same reason.
3961
+ this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
3962
+ this.activeForegroundTurnId = null;
3963
+ // Fence drops any late `turn/completed` / trailing items for this
3964
+ // turn so we don't emit a second terminal event (which would be
3965
+ // tagged with the *new* foreground turn's id and incorrectly
3966
+ // finalize it). Lifted by the next `turn/started`. The fence still
3967
+ // allows the issue #259 sentinel bookkeeping inside
3968
+ // `handleNotification`, so codex's turn/completed status
3969
+ // (interrupted vs completed — the race winner) can still override
3970
+ // the optimistic sentinel set above if codex won the race.
3971
+ this.resetForegroundTurnState();
2885
3972
  }
2886
3973
  }
2887
3974
  async close() {
2888
3975
  for (const pending of this.pendingPermissionHandlers.values()) {
2889
- pending.resolve({ decision: "cancel" });
3976
+ pending.resolve({ decision: pending.protocol === "legacy-review" ? "abort" : "cancel" });
2890
3977
  }
2891
3978
  this.pendingPermissionHandlers.clear();
2892
3979
  this.pendingPermissions.clear();
2893
3980
  this.resolvedPermissionRequests.clear();
2894
3981
  this.subscribers.clear();
2895
3982
  this.activeForegroundTurnId = null;
3983
+ this.clearTurnWatchdog();
3984
+ this.turnWatchdogInflightCycles = 0;
3985
+ this.turnWatchdogCompactionCycles = 0;
3986
+ this.lastTurnNotificationTime = null;
3987
+ this.lastReconnectMarkerKey = null;
3988
+ this.fencedAfterForcedFailure = false;
3989
+ this.fencedTurnId = null;
3990
+ this.expectedCodexTurnId = null;
3991
+ this.turnStartAckPending = false;
3992
+ this.pendingFencedTurnStarted = null;
3993
+ this.inFlightToolCalls.clear();
3994
+ this.compactionInFlight.clear();
3995
+ this.clearCompactionItemWatchdogs();
3996
+ this.cancelAgentMessageFlush();
3997
+ this.pendingAgentMessages.clear();
3998
+ this.emittedAgentMessageLength.clear();
3999
+ this.pendingReasoning.clear();
4000
+ this.terminalCommandByProcessId.clear();
4001
+ this.emittedTerminalInteractionKeys.clear();
4002
+ this.pendingUnlabeledTerminalInteractions.clear();
2896
4003
  if (this.client) {
2897
4004
  await this.client.dispose();
2898
4005
  }
2899
4006
  this.client = null;
2900
4007
  this.connected = false;
4008
+ this.codexVersion = null;
4009
+ this.manualCompactTurnId = null;
4010
+ this.manualCompactCanceledTurnIds.clear();
2901
4011
  this.currentThreadId = null;
2902
4012
  this.currentTurnId = null;
2903
4013
  }
@@ -2914,7 +4024,128 @@ class CodexAppServerAgentSession {
2914
4024
  description: skill.description,
2915
4025
  argumentHint: "",
2916
4026
  }));
2917
- return [...appServerSkills, ...prompts].sort((a, b) => a.name.localeCompare(b.name));
4027
+ const builtins = [
4028
+ ...(this.supportsManualCompact() ? [COMPACT_COMMAND] : []),
4029
+ ...(this.goalsEnabled ? [GOAL_COMMAND] : []),
4030
+ ];
4031
+ return [...builtins, ...appServerSkills, ...prompts].sort((a, b) => a.name.localeCompare(b.name));
4032
+ }
4033
+ // issue #1012: whether codex's `thread/compact/start` RPC is available, based
4034
+ // on the version captured from the `initialize` handshake. Unknown version
4035
+ // (older codex / handshake without userAgent) → don't expose `/compact`.
4036
+ supportsManualCompact() {
4037
+ return (this.codexVersion !== null &&
4038
+ isVersionAtLeast(this.codexVersion, CODEX_MANUAL_COMPACT_MIN_VERSION));
4039
+ }
4040
+ async tryHandleOutOfBand(prompt) {
4041
+ if (!this.goalsEnabled || typeof prompt !== "string") {
4042
+ return null;
4043
+ }
4044
+ const parsed = this.parseSlashCommandInput(prompt);
4045
+ if (!parsed || parsed.commandName !== GOAL_COMMAND_NAME) {
4046
+ return null;
4047
+ }
4048
+ const subcommand = parseGoalSubcommand(parsed.args);
4049
+ const text = formatOutOfBandStatusMessage(await this.executeGoalSubcommand(subcommand));
4050
+ return { handled: true, response: text };
4051
+ }
4052
+ async executeGoalSubcommand(subcommand) {
4053
+ if (subcommand.kind === "usage") {
4054
+ return "用法:/goal <objective>|pause|resume|clear";
4055
+ }
4056
+ try {
4057
+ await this.connect();
4058
+ if (this.currentThreadId) {
4059
+ await this.ensureThreadLoaded();
4060
+ }
4061
+ else if (subcommand.kind === "set") {
4062
+ await this.ensureThread();
4063
+ }
4064
+ else {
4065
+ return "没有活动目标可操作。";
4066
+ }
4067
+ if (!this.client || !this.currentThreadId) {
4068
+ throw new Error("Codex 会话不可用");
4069
+ }
4070
+ switch (subcommand.kind) {
4071
+ case "set":
4072
+ await this.client.request("thread/goal/set", {
4073
+ threadId: this.currentThreadId,
4074
+ objective: subcommand.objective,
4075
+ status: "active",
4076
+ });
4077
+ return `已设置目标:${subcommand.objective}`;
4078
+ case "pause":
4079
+ await this.client.request("thread/goal/set", {
4080
+ threadId: this.currentThreadId,
4081
+ status: "paused",
4082
+ });
4083
+ return "目标已暂停。";
4084
+ case "resume":
4085
+ await this.client.request("thread/goal/set", {
4086
+ threadId: this.currentThreadId,
4087
+ status: "active",
4088
+ });
4089
+ return "目标已恢复。";
4090
+ case "clear":
4091
+ await this.client.request("thread/goal/clear", {
4092
+ threadId: this.currentThreadId,
4093
+ });
4094
+ return "目标已清除。";
4095
+ }
4096
+ }
4097
+ catch (error) {
4098
+ const message = error instanceof Error ? error.message : "未知错误";
4099
+ return `更新目标失败:${message}`;
4100
+ }
4101
+ }
4102
+ // issue #1012: run a manual context compaction. `thread/compact/start` is a
4103
+ // standalone RPC, not a turn, so codex emits no `turn/started` / `turn/completed`
4104
+ // for it — we synthesize those here (mirrors claude's `executeRewindTurn`) so the
4105
+ // UI leaves the "thinking" state. The `contextCompaction` thread item streams in
4106
+ // independently and renders the compaction marker (#999 watchdog defer applies).
4107
+ async executeManualCompact(turnId) {
4108
+ this.manualCompactTurnId = turnId;
4109
+ this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
4110
+ try {
4111
+ if (!this.client) {
4112
+ throw new Error("Codex client not initialized");
4113
+ }
4114
+ if (this.currentThreadId) {
4115
+ await this.ensureThreadLoaded();
4116
+ }
4117
+ else {
4118
+ await this.ensureThread();
4119
+ }
4120
+ if (!this.currentThreadId) {
4121
+ throw new Error("没有可压缩的会话上下文");
4122
+ }
4123
+ await this.client.request("thread/compact/start", { threadId: this.currentThreadId });
4124
+ if (this.manualCompactCanceledTurnIds.has(turnId)) {
4125
+ return;
4126
+ }
4127
+ this.emitEvent({ type: "turn_completed", provider: CODEX_PROVIDER });
4128
+ }
4129
+ catch (error) {
4130
+ if (this.manualCompactCanceledTurnIds.has(turnId)) {
4131
+ return;
4132
+ }
4133
+ this.emitEvent({
4134
+ type: "turn_failed",
4135
+ provider: CODEX_PROVIDER,
4136
+ error: error instanceof Error ? error.message : "手动压缩失败",
4137
+ });
4138
+ }
4139
+ finally {
4140
+ this.manualCompactCanceledTurnIds.delete(turnId);
4141
+ if (this.manualCompactTurnId === turnId) {
4142
+ this.manualCompactTurnId = null;
4143
+ }
4144
+ if (this.activeForegroundTurnId === turnId) {
4145
+ this.activeForegroundTurnId = null;
4146
+ this.clearTurnWatchdog();
4147
+ }
4148
+ }
2918
4149
  }
2919
4150
  async ensureThread() {
2920
4151
  if (!this.client)
@@ -2952,8 +4183,9 @@ class CodexAppServerAgentSession {
2952
4183
  this.config.model = model;
2953
4184
  this.config.thinkingOptionId = thinkingOptionId;
2954
4185
  const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
2955
- const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
2956
- const sandbox = this.config.sandboxMode ?? preset.sandbox;
4186
+ let approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
4187
+ let sandbox = this.config.sandboxMode ?? preset.sandbox;
4188
+ ({ approvalPolicy, sandboxPolicyType: sandbox } = applyPlanModeConstraints(approvalPolicy, sandbox, this.planModeEnabled));
2957
4189
  const innerConfig = this.buildCodexInnerConfig();
2958
4190
  const response = (await this.client.request("thread/start", {
2959
4191
  model,
@@ -2973,16 +4205,66 @@ class CodexAppServerAgentSession {
2973
4205
  }
2974
4206
  buildCodexInnerConfig() {
2975
4207
  const innerConfig = {};
4208
+ // The daemon-owned seawork builtin (if present) is auto-approved + given a
4209
+ // long tool timeout. We build it separately and re-assert it AFTER the
4210
+ // extra.codex merge below, so a raw `extra.codex.mcp_servers.seawork`
4211
+ // override can never replace the trusted builtin with an auto-approved
4212
+ // attacker-controlled MCP. The `seawork` key itself is already reserved for
4213
+ // the builtin upstream (agent-manager.applySeaworkMcp).
4214
+ let seaworkBuiltin;
2976
4215
  if (this.config.mcpServers) {
2977
4216
  const mcpServers = {};
2978
4217
  for (const [name, serverConfig] of Object.entries(this.config.mcpServers)) {
2979
- mcpServers[name] = toCodexMcpConfig(serverConfig);
4218
+ const mapped = toCodexMcpConfig(serverConfig);
4219
+ if (name === SEAWORK_BUILTIN_MCP_NAME) {
4220
+ // generation_task blocks while a video/3d task renders; codex aborts
4221
+ // MCP tools/call after DEFAULT_TOOL_TIMEOUT (120s), so raise the limit.
4222
+ mapped.tool_timeout_sec = SEAWORK_MCP_TOOL_TIMEOUT_SEC;
4223
+ // Auto-approve ONLY the safe SeaArt generation tools (per-tool, never
4224
+ // server-wide). Under the default "auto" mode + "on-request" policy
4225
+ // codex routes each call to an approval prompt with no interactive
4226
+ // approver, which resolves to "user rejected MCP tool call". The same
4227
+ // builtin server also exposes high-impact agent/terminal/schedule/
4228
+ // permission tools that MUST keep their approval gate, so a blanket
4229
+ // default_tools_approval_mode would be an over-broad bypass.
4230
+ mapped.tools = Object.fromEntries(SEAWORK_AUTO_APPROVE_TOOLS.map((tool) => [tool, { approval_mode: "approve" }]));
4231
+ seaworkBuiltin = mapped;
4232
+ }
4233
+ mcpServers[name] = mapped;
2980
4234
  }
2981
4235
  innerConfig.mcp_servers = mcpServers;
2982
4236
  }
4237
+ // issue #973: tighten codex's auto-compaction threshold. Scope MUST stay
4238
+ // "body_after_prefix" (codex's default) — that is the only branch that reads
4239
+ // our injected `model_auto_compact_token_limit`. The "total" scope ignores
4240
+ // the config value entirely and only consults the per-model window-derived
4241
+ // limit (codex-rs/core/src/session/turn.rs:664-684), so setting it would
4242
+ // silently disable this fix. Set the defaults before the extra.codex merge so
4243
+ // an explicit override still wins.
4244
+ innerConfig.model_auto_compact_token_limit = CODEX_AUTO_COMPACT_TOKEN_LIMIT;
4245
+ innerConfig.model_auto_compact_token_limit_scope = "body_after_prefix";
2983
4246
  if (this.config.extra?.codex) {
2984
4247
  Object.assign(innerConfig, this.config.extra.codex);
2985
4248
  }
4249
+ // Enforce the reserved-name invariant after the extra.codex merge (a shallow
4250
+ // Object.assign that could replace the whole mcp_servers map). The `seawork`
4251
+ // key must ALWAYS be the daemon-owned builtin or absent — never an
4252
+ // extra.codex-supplied entry, which could carry default_tools_approval_mode
4253
+ // "approve". When a builtin exists, force it back; when none exists
4254
+ // (no mcpBaseUrl / injection disabled), drop any `seawork` extra.codex set.
4255
+ if (seaworkBuiltin) {
4256
+ // Builtin exists → always restore it, even if extra.codex set
4257
+ // mcp_servers to null/garbage (which would otherwise drop it).
4258
+ const mergedMcp = readStringRecord(innerConfig.mcp_servers);
4259
+ mergedMcp[SEAWORK_BUILTIN_MCP_NAME] = seaworkBuiltin;
4260
+ innerConfig.mcp_servers = mergedMcp;
4261
+ }
4262
+ else if (innerConfig.mcp_servers) {
4263
+ // No builtin → strip any `seawork` an extra.codex override smuggled in.
4264
+ const mergedMcp = readStringRecord(innerConfig.mcp_servers);
4265
+ delete mergedMcp[SEAWORK_BUILTIN_MCP_NAME];
4266
+ innerConfig.mcp_servers = mergedMcp;
4267
+ }
2986
4268
  return Object.keys(innerConfig).length > 0 ? innerConfig : null;
2987
4269
  }
2988
4270
  async buildUserInput(prompt) {
@@ -3013,13 +4295,358 @@ class CodexAppServerAgentSession {
3013
4295
  }
3014
4296
  return [{ type: "text", text: CANCEL_REMINDER_TEXT }, ...input];
3015
4297
  }
4298
+ // Tag a turn failure with the gateway request id captured from codex's
4299
+ // stderr when the message doesn't already carry one (status errors include
4300
+ // `request id:` themselves; stream disconnects do not). The app's
4301
+ // system-error parser reads this `request id: <id>` token.
4302
+ //
4303
+ // `lastResponsesRequestId` is scraped from codex's `Request completed` debug
4304
+ // line, which codex only logs once a response HEADER has arrived. A
4305
+ // mid-stream disconnect therefore has the failing request's own id (correct
4306
+ // to append). But an `error sending request` failure happens in the SEND
4307
+ // phase — no response header, no `Request completed` line — so the scraped id
4308
+ // still points at the PREVIOUS, successful request. Appending it there
4309
+ // mislabels the error with an unrelated id that looks failed but resolves to
4310
+ // a 200 in gateway logs (issues #914/#862: one successful id `de0a34fc…`
4311
+ // surfaced in unrelated disconnect reports). Don't append in that case.
4312
+ appendRequestId(error) {
4313
+ if (/request id:/i.test(error))
4314
+ return error;
4315
+ if (/error sending request/i.test(error))
4316
+ return error;
4317
+ const requestId = typeof this.client?.getLastResponsesRequestId === "function"
4318
+ ? this.client.getLastResponsesRequestId()
4319
+ : null;
4320
+ return requestId ? `${error} (request id: ${requestId})` : error;
4321
+ }
4322
+ buildTurnFailedDiagnostic(error) {
4323
+ const stderrTail = typeof this.client?.getRecentStderrTail === "function"
4324
+ ? this.client.getRecentStderrTail()?.trim()
4325
+ : undefined;
4326
+ if (!stderrTail) {
4327
+ return undefined;
4328
+ }
4329
+ const normalizedError = error
4330
+ .trim()
4331
+ .replace(/^\d{4}-\d{2}-\d{2}T\S+\s+/gm, "")
4332
+ .replace(/^[A-Z]+\s+/gm, "")
4333
+ .trim();
4334
+ const normalizedStderr = stderrTail
4335
+ .replace(/^\d{4}-\d{2}-\d{2}T\S+\s+/gm, "")
4336
+ .replace(/^[A-Z]+\s+/gm, "")
4337
+ .trim();
4338
+ if (normalizedStderr === normalizedError || normalizedStderr.endsWith(normalizedError)) {
4339
+ return undefined;
4340
+ }
4341
+ return stderrTail;
4342
+ }
3016
4343
  emitEvent(event) {
3017
- if (event.type === "timeline") {
3018
- if (event.item.type === "assistant_message") {
3019
- this.pendingAgentMessages.clear();
4344
+ // Note: assistant-message buffers (`pendingAgentMessages`) are NOT cleared
4345
+ // here. Streaming now emits multiple `assistant_message` timeline items
4346
+ // per message, so an emit-driven clear would wipe the buffer mid-stream.
4347
+ // Buffers are instead cleared per-itemId at `item_completed` and fully on
4348
+ // turn teardown.
4349
+ this.notifySubscribers(event);
4350
+ }
4351
+ clearCompactionItemWatchdogs() {
4352
+ for (const timer of this.compactionItemTimers.values()) {
4353
+ clearTimeout(timer);
4354
+ }
4355
+ this.compactionItemTimers.clear();
4356
+ }
4357
+ clearCompactionItemWatchdog(itemId) {
4358
+ const timer = this.compactionItemTimers.get(itemId);
4359
+ if (timer) {
4360
+ clearTimeout(timer);
4361
+ this.compactionItemTimers.delete(itemId);
4362
+ }
4363
+ }
4364
+ armCompactionItemWatchdog(itemId) {
4365
+ this.clearCompactionItemWatchdog(itemId);
4366
+ const timer = setTimeout(() => {
4367
+ this.compactionItemTimers.delete(itemId);
4368
+ if (!this.compactionInFlight.delete(itemId)) {
4369
+ return;
4370
+ }
4371
+ this.logger.warn({ itemId, timeoutMs: COMPACTION_ITEM_WATCHDOG_MS }, "Codex context compaction completion missing; closing timeline marker");
4372
+ this.emitEvent({
4373
+ type: "timeline",
4374
+ provider: CODEX_PROVIDER,
4375
+ item: { type: "compaction", status: "completed", trigger: "auto" },
4376
+ });
4377
+ }, COMPACTION_ITEM_WATCHDOG_MS);
4378
+ timer.unref?.();
4379
+ this.compactionItemTimers.set(itemId, timer);
4380
+ }
4381
+ // issue #1824: a completed `/responses` HTTP round-trip is upstream activity
4382
+ // that emits no protocol notification, so the watchdog would otherwise force-
4383
+ // fail a turn that is genuinely uploading/streaming. Treat it exactly like a
4384
+ // notification: re-arm and cancel any pending late-completion force-fail.
4385
+ markUpstreamLiveness() {
4386
+ if (!this.activeForegroundTurnId)
4387
+ return;
4388
+ this.clearPendingForceFail();
4389
+ this.lastTurnNotificationTime = Date.now();
4390
+ this.armTurnWatchdog();
4391
+ }
4392
+ // issue #505: the turn lifecycle relies entirely on codex sending a
4393
+ // `turn/completed` notification. If that notification is lost, the turn
4394
+ // never finalizes and the UI stays "thinking" forever. The watchdog is
4395
+ // re-armed on every notification for the active turn, so a healthy turn
4396
+ // never trips it; a turn that goes fully silent is force-completed.
4397
+ //
4398
+ // `deferred` distinguishes a re-arm by the watchdog itself (silence
4399
+ // continuing) from a reset by a genuine notification — only the latter
4400
+ // clears the in-flight grace counter.
4401
+ armTurnWatchdog(deferred = false) {
4402
+ this.clearTurnWatchdog();
4403
+ if (!deferred) {
4404
+ this.turnWatchdogInflightCycles = 0;
4405
+ this.turnWatchdogCompactionCycles = 0;
4406
+ }
4407
+ this.turnWatchdogTimer = setTimeout(() => {
4408
+ this.turnWatchdogTimer = null;
4409
+ const pendingPermissionsForActiveTurn = this.countPendingPermissionsForActiveTurn();
4410
+ if (pendingPermissionsForActiveTurn > 0) {
4411
+ this.logger.debug({
4412
+ pendingPermissions: pendingPermissionsForActiveTurn,
4413
+ totalPendingPermissions: this.pendingPermissionHandlers.size,
4414
+ }, "Codex turn watchdog deferred: waiting for permission response");
4415
+ this.armTurnWatchdog(true);
4416
+ return;
4417
+ }
4418
+ // A long, output-silent tool (build, slow network call) is NOT a lost
4419
+ // turn — codex will still send its completion. Grant it a bounded number
4420
+ // of extra idle cycles. The bound matters: if the tool's *completion*
4421
+ // notification is itself lost, an unbounded re-arm would recreate the
4422
+ // exact "stuck thinking forever" bug, just keyed on a lost tool event.
4423
+ if (this.inFlightToolCalls.size > 0 &&
4424
+ this.turnWatchdogInflightCycles < TURN_WATCHDOG_MAX_INFLIGHT_CYCLES) {
4425
+ this.turnWatchdogInflightCycles += 1;
4426
+ this.logger.debug({ inFlight: this.inFlightToolCalls.size, cycle: this.turnWatchdogInflightCycles }, "Codex turn watchdog deferred: tool execution still in flight");
4427
+ this.armTurnWatchdog(true);
4428
+ return;
4429
+ }
4430
+ // issue #999: auto-compaction (pack ~240K-token context + an LLM
4431
+ // summarization call) is a legitimate long, turn-event-silent operation,
4432
+ // not a lost turn. Defer like an in-flight tool, with a wider but still
4433
+ // bounded budget so a lost compaction completion still recovers.
4434
+ if (this.compactionInFlight.size > 0 &&
4435
+ this.turnWatchdogCompactionCycles < TURN_WATCHDOG_MAX_COMPACTION_CYCLES) {
4436
+ this.turnWatchdogCompactionCycles += 1;
4437
+ this.logger.debug({
4438
+ compactionInFlight: this.compactionInFlight.size,
4439
+ cycle: this.turnWatchdogCompactionCycles,
4440
+ }, "Codex turn watchdog deferred: context compaction in flight");
4441
+ this.armTurnWatchdog(true);
4442
+ return;
3020
4443
  }
4444
+ // issue #1181: the timer may fire long after the last notification arrived
4445
+ // because other agents starved the event loop. Re-check the real idle gap;
4446
+ // if a notification arrived < IDLE_MS ago, re-arm instead of false-failing.
4447
+ if (this.lastTurnNotificationTime !== null) {
4448
+ const idleSinceMs = Date.now() - this.lastTurnNotificationTime;
4449
+ if (idleSinceMs < TURN_WATCHDOG_IDLE_MS) {
4450
+ this.armTurnWatchdog(true);
4451
+ return;
4452
+ }
4453
+ }
4454
+ const stallError = "Codex turn stalled (no events received); recovered by watchdog";
4455
+ const context = [
4456
+ `pendingPermissions=${pendingPermissionsForActiveTurn}`,
4457
+ `watchdogCycles inflight=${this.turnWatchdogInflightCycles} compaction=${this.turnWatchdogCompactionCycles}`,
4458
+ `sinceLastNotification=${this.lastTurnNotificationTime !== null ? Date.now() - this.lastTurnNotificationTime : "unknown"}ms`,
4459
+ ].join("\n");
4460
+ // issue #1427: do not force-fail yet. Give a late-but-successful
4461
+ // turn/completed a short grace window to land — committing now is what
4462
+ // mis-kills it. armPendingForceFail commits only if the turn is still the
4463
+ // active foreground turn (i.e. no completion arrived) when the window
4464
+ // elapses; any notification in between clears the pending timer.
4465
+ this.armPendingForceFail(stallError, context);
4466
+ }, TURN_WATCHDOG_IDLE_MS);
4467
+ this.turnWatchdogTimer.unref?.();
4468
+ }
4469
+ // issue #1427: arm the grace timer that decides whether a stalled turn is
4470
+ // really dead or just slow to deliver its completion. Snapshots the turn id at
4471
+ // schedule time; on fire, only commits the force-fail if that same turn is
4472
+ // still active (a completion that arrived during the window finalizes the turn
4473
+ // and clears activeForegroundTurnId, so the snapshot no longer matches).
4474
+ armPendingForceFail(stallError, context) {
4475
+ this.clearPendingForceFail();
4476
+ const turnIdAtSchedule = this.activeForegroundTurnId;
4477
+ if (!turnIdAtSchedule) {
4478
+ return;
3021
4479
  }
3022
- this.notifySubscribers(event);
4480
+ this.pendingForceFailTimer = setTimeout(() => {
4481
+ this.pendingForceFailTimer = null;
4482
+ if (this.activeForegroundTurnId !== turnIdAtSchedule) {
4483
+ // A turn/completed (or interrupt/new turn) landed during the grace
4484
+ // window and already finalized this turn — the watchdog mis-fired. Do
4485
+ // not emit turn_failed.
4486
+ this.logger.debug({ turnId: turnIdAtSchedule, graceMs: WATCHDOG_LATE_COMPLETION_GRACE_MS }, "Codex watchdog stand-down: turn completed within late-completion grace window");
4487
+ return;
4488
+ }
4489
+ const stderrTail = this.buildTurnFailedDiagnostic(stallError);
4490
+ const graceNote = `lateCompletionGraceMs=${WATCHDOG_LATE_COMPLETION_GRACE_MS}`;
4491
+ const diagnostic = `${context}\n${graceNote}`;
4492
+ this.forceFailActiveTurn(stallError, stderrTail ? `${diagnostic}\n${stderrTail}` : diagnostic);
4493
+ }, WATCHDOG_LATE_COMPLETION_GRACE_MS);
4494
+ this.pendingForceFailTimer.unref?.();
4495
+ }
4496
+ clearPendingForceFail() {
4497
+ if (this.pendingForceFailTimer) {
4498
+ clearTimeout(this.pendingForceFailTimer);
4499
+ this.pendingForceFailTimer = null;
4500
+ }
4501
+ }
4502
+ // Stops the timers only. The in-flight grace counter is reset by a
4503
+ // non-deferred armTurnWatchdog() (a real notification) and by turn teardown,
4504
+ // so a deferred re-arm — which calls this first — keeps its accumulated count.
4505
+ // issue #1427: also drops a pending late-completion force-fail. Safe across a
4506
+ // deferred re-arm: those paths return before arming a pending force-fail, and
4507
+ // the force-fail-committing path arms its grace timer *after* the last
4508
+ // clearTurnWatchdog() in the same tick, so this never kills the timer it set.
4509
+ clearTurnWatchdog() {
4510
+ if (this.turnWatchdogTimer) {
4511
+ clearTimeout(this.turnWatchdogTimer);
4512
+ this.turnWatchdogTimer = null;
4513
+ }
4514
+ this.clearPendingForceFail();
4515
+ }
4516
+ countPendingPermissionsForActiveTurn() {
4517
+ const foregroundTurnId = this.activeForegroundTurnId;
4518
+ if (!foregroundTurnId) {
4519
+ return 0;
4520
+ }
4521
+ let count = 0;
4522
+ for (const pending of this.pendingPermissionHandlers.values()) {
4523
+ if (pending.foregroundTurnId !== foregroundTurnId) {
4524
+ continue;
4525
+ }
4526
+ if (pending.codexTurnId && !this.isActiveForegroundCodexTurnId(pending.codexTurnId)) {
4527
+ continue;
4528
+ }
4529
+ count += 1;
4530
+ }
4531
+ return count;
4532
+ }
4533
+ // Tear down provider-side foreground turn state after a cancel/fail. Fences
4534
+ // any late notification for the (now cleared) turn. Does not emit, set
4535
+ // `lastCanceledAt`, clear pending permissions, or touch
4536
+ // `activeForegroundTurnId` — callers own those.
4537
+ resetForegroundTurnState() {
4538
+ this.fencedAfterForcedFailure = true;
4539
+ // issue #1836: headline case — the turn/start ack returned codex's turn.id
4540
+ // (expectedCodexTurnId) but turn/started never arrived, so currentTurnId is
4541
+ // still null. Fall back to expectedCodexTurnId so the fenced id is non-null
4542
+ // and the self-heal guard (fencedTurnId !== null) can fire.
4543
+ this.fencedTurnId = this.currentTurnId ?? this.expectedCodexTurnId;
4544
+ this.currentTurnId = null;
4545
+ this.expectedCodexTurnId = null;
4546
+ this.turnStartAckPending = false;
4547
+ this.pendingFencedTurnStarted = null;
4548
+ this.clearTurnWatchdog();
4549
+ this.turnWatchdogInflightCycles = 0;
4550
+ this.turnWatchdogCompactionCycles = 0;
4551
+ this.lastTurnNotificationTime = null;
4552
+ // issue #1836: default the fence to "not from force-fail" so the interrupt
4553
+ // callers leave self-heal disabled; `forceFailActiveTurn` re-sets these two
4554
+ // right after calling this. Clearing the stored id here also stops a prior
4555
+ // force-fail's id leaking into a later interrupt fence.
4556
+ this.fencedByForceFail = false;
4557
+ this.lastForcedFailForegroundTurnId = null;
4558
+ // issue #1427: drop the reconnect-marker dedup key on turn teardown so the
4559
+ // next stall re-announces.
4560
+ this.lastReconnectMarkerKey = null;
4561
+ this.inFlightToolCalls.clear();
4562
+ this.compactionInFlight.clear();
4563
+ this.clearCompactionItemWatchdogs();
4564
+ this.cancelAgentMessageFlush();
4565
+ this.pendingAgentMessages.clear();
4566
+ this.emittedAgentMessageLength.clear();
4567
+ }
4568
+ // issue #505: force-fail the active foreground turn (watchdog stall or
4569
+ // process exit). Emits `turn_failed`, then fences the turn so any late
4570
+ // notification — a delayed `turn/completed`, a trailing terminal item — is
4571
+ // dropped instead of resurfacing a turn the UI already saw fail. The fence
4572
+ // is lifted once we see a safely attributable retry-turn notification.
4573
+ forceFailActiveTurn(error, diagnostic) {
4574
+ if (!this.activeForegroundTurnId)
4575
+ return;
4576
+ const turnId = this.activeForegroundTurnId;
4577
+ this.logger.warn({ turnId, error }, "Codex force-failing turn");
4578
+ this.clearTurnWatchdog();
4579
+ // emitEvent tags the event with activeForegroundTurnId, so emit before clearing.
4580
+ this.emitEvent({ type: "turn_failed", provider: CODEX_PROVIDER, error, diagnostic });
4581
+ this.clearPendingPermissionsForTurn(turnId);
4582
+ this.activeForegroundTurnId = null;
4583
+ this.resetForegroundTurnState();
4584
+ // issue #1836: mark this fence as force-fail (so self-heal may resurrect it)
4585
+ // and remember the local turn id so a self-heal resumes under the same id
4586
+ // (preserving turn-event accounting). Set AFTER resetForegroundTurnState,
4587
+ // which clears both for the interrupt callers that share it.
4588
+ this.fencedByForceFail = true;
4589
+ this.lastForcedFailForegroundTurnId = turnId;
4590
+ }
4591
+ handleClientExit(reason) {
4592
+ this.client = null;
4593
+ this.connected = false;
4594
+ this.clearTurnWatchdog();
4595
+ this.forceFailActiveTurn(`Codex process exited: ${reason}`);
4596
+ this.currentTurnId = null;
4597
+ }
4598
+ clearPendingPermissionsForTurn(turnId) {
4599
+ for (const [requestId, pending] of this.pendingPermissionHandlers.entries()) {
4600
+ if (pending.foregroundTurnId !== turnId) {
4601
+ continue;
4602
+ }
4603
+ this.pendingPermissionHandlers.delete(requestId);
4604
+ this.pendingPermissions.delete(requestId);
4605
+ this.resolvedPermissionRequests.add(requestId);
4606
+ if (pending.kind === "question") {
4607
+ pending.resolve({ answers: {} });
4608
+ continue;
4609
+ }
4610
+ pending.resolve({ decision: pending.protocol === "legacy-review" ? "abort" : "cancel" });
4611
+ }
4612
+ }
4613
+ /**
4614
+ * Emit any not-yet-streamed tail of an in-flight agent message as an
4615
+ * `assistant_message` timeline delta. Throttled (~60ms) so a fast token
4616
+ * stream does not produce one timeline event — and one client setState —
4617
+ * per token. The UI coalesces consecutive `assistant_message` items into a
4618
+ * single growing bubble (see app `appendAssistantMessage`).
4619
+ */
4620
+ flushAgentMessageDelta(itemId) {
4621
+ this.agentMessageFlushPendingItemId = itemId;
4622
+ if (this.agentMessageFlushTimer)
4623
+ return;
4624
+ this.agentMessageFlushTimer = setTimeout(() => {
4625
+ this.agentMessageFlushTimer = null;
4626
+ const id = this.agentMessageFlushPendingItemId;
4627
+ this.agentMessageFlushPendingItemId = null;
4628
+ if (!id)
4629
+ return;
4630
+ const full = this.pendingAgentMessages.get(id) ?? "";
4631
+ const emitted = this.emittedAgentMessageLength.get(id) ?? 0;
4632
+ const chunk = full.slice(emitted);
4633
+ if (chunk.length === 0)
4634
+ return;
4635
+ this.emittedAgentMessageLength.set(id, full.length);
4636
+ this.emitEvent({
4637
+ type: "timeline",
4638
+ provider: CODEX_PROVIDER,
4639
+ item: { type: "assistant_message", text: chunk },
4640
+ });
4641
+ }, 60);
4642
+ }
4643
+ /** Cancel a pending streaming flush (turn teardown / cancel / close). */
4644
+ cancelAgentMessageFlush() {
4645
+ if (this.agentMessageFlushTimer) {
4646
+ clearTimeout(this.agentMessageFlushTimer);
4647
+ this.agentMessageFlushTimer = null;
4648
+ }
4649
+ this.agentMessageFlushPendingItemId = null;
3023
4650
  }
3024
4651
  notifySubscribers(event) {
3025
4652
  const turnId = this.activeForegroundTurnId;
@@ -3038,6 +4665,130 @@ class CodexAppServerAgentSession {
3038
4665
  }
3039
4666
  handleNotification(method, params) {
3040
4667
  const parsed = CodexNotificationSchema.parse({ method, params });
4668
+ // issue #505: after a turn was force-failed, drop every late notification
4669
+ // for that turn — a delayed `turn/completed`, a trailing terminal item —
4670
+ // so it cannot resurface a turn the UI already saw fail.
4671
+ //
4672
+ // `turn/started` lifts the fence only when it belongs to the post-retry
4673
+ // turn. Three states for the new turn's `turn/start` ack:
4674
+ // * pending (`turnStartAckPending=true`): activeForegroundTurnId is set
4675
+ // but the ack has not returned, so we do NOT yet know codex's turn.id
4676
+ // for the new turn. Drop unconditionally — a turn/started arriving in
4677
+ // this window belongs to a dead turn racing the ack.
4678
+ // * returned with `turn.id` captured: require the incoming id to match
4679
+ // `expectedCodexTurnId`. Mismatched ids stay fenced.
4680
+ // * returned without `turn.id` (older codex / schema drift): fall back
4681
+ // to the weaker "any turn/started after retry lifts the fence" rule.
4682
+ // Strictly worse than the matched-id path, but does NOT hard-lock the
4683
+ // retry forever.
4684
+ if (this.fencedAfterForcedFailure) {
4685
+ const isFreshTurnStarted = parsed.kind === "turn_started" &&
4686
+ this.activeForegroundTurnId !== null &&
4687
+ !this.turnStartAckPending &&
4688
+ (this.expectedCodexTurnId === null || parsed.turnId === this.expectedCodexTurnId);
4689
+ const isFreshTurnContinuation = this.isCurrentRetryTurnNotification(parsed);
4690
+ if (isFreshTurnStarted || isFreshTurnContinuation) {
4691
+ this.fencedAfterForcedFailure = false;
4692
+ this.fencedTurnId = null;
4693
+ }
4694
+ else {
4695
+ // issue #259 + #664: even though we drop the notification (no emit,
4696
+ // no state mutation), codex's `turn/completed` for the fenced turn
4697
+ // still carries the authoritative interrupted-vs-completed race
4698
+ // outcome. Update only `lastCanceledAt` so the next `turn/start`
4699
+ // either prepends the ignore-sentinel (codex confirmed the cancel)
4700
+ // or starts clean (codex won the race and naturally completed).
4701
+ if (parsed.kind === "turn_completed") {
4702
+ const before = this.lastCanceledAt;
4703
+ if (parsed.status === "interrupted") {
4704
+ this.lastCanceledAt = Date.now();
4705
+ }
4706
+ else if (parsed.status === "completed") {
4707
+ this.lastCanceledAt = null;
4708
+ }
4709
+ // issue #277 (gpt-5.5 review): this fenced path emits no terminal
4710
+ // event, so `finalizeForegroundTurn` never re-snapshots persistence.
4711
+ // Without this, a race the codex side won (sentinel cleared to null)
4712
+ // would leave the optimistic non-null `lastCanceledAt` in the
4713
+ // persisted handle, and a resume within CANCEL_REMINDER_WINDOW_MS
4714
+ // would wrongly prepend the ignore reminder for a turn that actually
4715
+ // completed. Re-emit `thread_started` (same threadId, no UI side
4716
+ // effect — manager only re-reads describePersistence) to persist the
4717
+ // authoritative sentinel state.
4718
+ if (this.lastCanceledAt !== before && this.currentThreadId) {
4719
+ this.emitEvent({
4720
+ type: "thread_started",
4721
+ provider: CODEX_PROVIDER,
4722
+ sessionId: this.currentThreadId,
4723
+ });
4724
+ }
4725
+ }
4726
+ // issue #664 review #3 (gpt-5.5): if this is a `turn/started`
4727
+ // arriving inside the `turn/start` ack-pending window for a
4728
+ // post-interrupt retry, cache it. We don't yet know the new turn's
4729
+ // codex turn.id (the ack hasn't returned), so we can't safely lift
4730
+ // the fence here. startTurn()'s ack handler will replay this
4731
+ // notification once `expectedCodexTurnId` is set, so the new turn
4732
+ // doesn't lose its only `turn/started` and stall until watchdog.
4733
+ if (parsed.kind === "turn_started" &&
4734
+ this.activeForegroundTurnId !== null &&
4735
+ this.turnStartAckPending) {
4736
+ this.pendingFencedTurnStarted = {
4737
+ codexTurnId: parsed.turnId,
4738
+ rawParams: params,
4739
+ };
4740
+ }
4741
+ // issue #1836: a force-fail is a purely local action — codex was not
4742
+ // told to abort, so it keeps emitting for the old turn. If that turn is
4743
+ // demonstrably still making progress (an item started or assistant text
4744
+ // streaming) and no retry has begun, the force-fail was a misfire (e.g.
4745
+ // watchdog mis-killed a live turn). Self-heal by un-fencing and resuming
4746
+ // the old turn instead of dropping its events forever, which would leave
4747
+ // the UI permanently blank. `fencedByForceFail` gates this to watchdog
4748
+ // force-fails only — a user interrupt/cancel also fences and must never
4749
+ // be resurrected.
4750
+ const isProgress = parsed.kind === "item_started" || parsed.kind === "agent_message_delta";
4751
+ const notifTurnId = this.parsedNotificationTurnId(parsed);
4752
+ if (this.fencedByForceFail &&
4753
+ isProgress &&
4754
+ this.activeForegroundTurnId === null &&
4755
+ !this.turnStartAckPending &&
4756
+ this.fencedTurnId !== null &&
4757
+ notifTurnId === this.fencedTurnId) {
4758
+ this.logger.warn({ fencedTurnId: this.fencedTurnId }, "Codex fence self-heal: progress after force-fail; resuming turn");
4759
+ this.activeForegroundTurnId = this.lastForcedFailForegroundTurnId ?? this.createTurnId();
4760
+ this.lastForcedFailForegroundTurnId = null;
4761
+ this.fencedByForceFail = false;
4762
+ this.currentTurnId = this.fencedTurnId;
4763
+ this.fencedAfterForcedFailure = false;
4764
+ this.fencedTurnId = null;
4765
+ this.lastTurnNotificationTime = Date.now();
4766
+ this.armTurnWatchdog();
4767
+ this.handleNotification(method, params);
4768
+ return;
4769
+ }
4770
+ this.logger.debug({ method }, "Dropping late Codex notification for force-failed turn");
4771
+ return;
4772
+ }
4773
+ }
4774
+ // issue #505: any notification proves the turn is still alive; re-arm the
4775
+ // stall watchdog so only a genuinely silent turn ever trips it.
4776
+ // issue #1427: it also cancels a pending late-completion force-fail — the
4777
+ // turn is demonstrably alive, so the grace window's verdict is moot. A
4778
+ // turn/completed during the window finalizes the turn below (clearing
4779
+ // activeForegroundTurnId); any other notification just proves liveness and
4780
+ // re-arms the normal 5min watchdog.
4781
+ if (this.activeForegroundTurnId) {
4782
+ this.clearPendingForceFail();
4783
+ this.lastTurnNotificationTime = Date.now();
4784
+ this.armTurnWatchdog();
4785
+ }
4786
+ // issue #1427: a real (non-reconnect) notification after a reconnect marker
4787
+ // means the stream is flowing again — close the loading marker so the UI
4788
+ // stops showing "Reconnecting…".
4789
+ if (parsed.kind !== "stream_retrying") {
4790
+ this.resolveReconnectMarker();
4791
+ }
3041
4792
  if (parsed.kind === "thread_started") {
3042
4793
  this.currentThreadId = parsed.threadId;
3043
4794
  this.emitEvent({
@@ -3048,24 +4799,49 @@ class CodexAppServerAgentSession {
3048
4799
  return;
3049
4800
  }
3050
4801
  if (parsed.kind === "turn_started") {
4802
+ this.fencedTurnId = null;
3051
4803
  this.currentTurnId = parsed.turnId;
4804
+ if (typeof this.client?.resetLastResponsesRequestId === "function") {
4805
+ this.client.resetLastResponsesRequestId();
4806
+ }
4807
+ if (typeof this.client?.resetRecentStderrTail === "function") {
4808
+ this.client.resetRecentStderrTail();
4809
+ }
3052
4810
  this.latestPlanResult = null;
3053
4811
  this.emittedItemStartedIds.clear();
3054
4812
  this.emittedItemCompletedIds.clear();
3055
4813
  this.emittedExecCommandStartedCallIds.clear();
3056
4814
  this.emittedExecCommandCompletedCallIds.clear();
4815
+ this.inFlightToolCalls.clear();
4816
+ this.compactionInFlight.clear();
4817
+ this.clearCompactionItemWatchdogs();
3057
4818
  this.pendingCommandOutputDeltas.clear();
3058
4819
  this.pendingFileChangeOutputDeltas.clear();
3059
4820
  this.warnedIncompleteEditToolCallIds.clear();
4821
+ this.lastTurnNotificationTime = null;
4822
+ this.cancelAgentMessageFlush();
4823
+ this.pendingAgentMessages.clear();
4824
+ this.emittedAgentMessageLength.clear();
4825
+ // Reasoning buffers are per-itemId and already consumed at
4826
+ // item_completed, so a per-turn sweep is safe and catches items that
4827
+ // never reach item_completed (interrupted/failed turns). We do NOT
4828
+ // clear terminalCommandByProcessId / emittedTerminalInteractionKeys
4829
+ // here: codex terminal sessions (and their stdin-dedup keys) can span
4830
+ // turns, so those are only cleared on close() to avoid losing
4831
+ // cross-turn command labels / re-emitting deduped interactions.
4832
+ this.pendingReasoning.clear();
3060
4833
  this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
3061
4834
  return;
3062
4835
  }
3063
4836
  if (parsed.kind === "turn_completed") {
4837
+ this.fencedTurnId = null;
3064
4838
  if (parsed.status === "failed") {
4839
+ const error = this.appendRequestId(parsed.errorMessage ?? "Codex turn failed");
3065
4840
  this.emitEvent({
3066
4841
  type: "turn_failed",
3067
4842
  provider: CODEX_PROVIDER,
3068
- error: parsed.errorMessage ?? "Codex turn failed",
4843
+ error,
4844
+ diagnostic: this.buildTurnFailedDiagnostic(error),
3069
4845
  });
3070
4846
  }
3071
4847
  else if (parsed.status === "interrupted") {
@@ -3095,14 +4871,24 @@ class CodexAppServerAgentSession {
3095
4871
  });
3096
4872
  }
3097
4873
  this.activeForegroundTurnId = null;
4874
+ this.clearTurnWatchdog();
3098
4875
  this.latestPlanResult = null;
3099
4876
  this.emittedItemStartedIds.clear();
3100
4877
  this.emittedItemCompletedIds.clear();
3101
4878
  this.emittedExecCommandStartedCallIds.clear();
3102
4879
  this.emittedExecCommandCompletedCallIds.clear();
4880
+ this.inFlightToolCalls.clear();
4881
+ this.compactionInFlight.clear();
4882
+ this.clearCompactionItemWatchdogs();
3103
4883
  this.pendingCommandOutputDeltas.clear();
3104
4884
  this.pendingFileChangeOutputDeltas.clear();
3105
4885
  this.warnedIncompleteEditToolCallIds.clear();
4886
+ this.cancelAgentMessageFlush();
4887
+ this.pendingAgentMessages.clear();
4888
+ this.emittedAgentMessageLength.clear();
4889
+ // See turn_started: only the per-item reasoning buffers are safe to
4890
+ // sweep per-turn; terminal session maps persist until close().
4891
+ this.pendingReasoning.clear();
3106
4892
  return;
3107
4893
  }
3108
4894
  if (parsed.kind === "plan_updated") {
@@ -3141,9 +4927,32 @@ class CodexAppServerAgentSession {
3141
4927
  }
3142
4928
  return;
3143
4929
  }
4930
+ if (parsed.kind === "stream_retrying") {
4931
+ // Non-terminal: codex is auto-retrying a dropped response stream. The
4932
+ // shared "any notification re-arms the watchdog" path above already kept
4933
+ // the turn alive; here we only surface a UI notice so a slow reconnect
4934
+ // does not look frozen. Dedupe to one marker per (attempt) so the paired
4935
+ // warning + error(willRetry) for the same attempt collapse into one.
4936
+ const attemptKey = `${parsed.attempt ?? ""}/${parsed.maxAttempts ?? ""}`;
4937
+ if (this.lastReconnectMarkerKey !== attemptKey) {
4938
+ this.lastReconnectMarkerKey = attemptKey;
4939
+ this.emitEvent({
4940
+ type: "timeline",
4941
+ provider: CODEX_PROVIDER,
4942
+ item: {
4943
+ type: "reconnecting",
4944
+ status: "loading",
4945
+ attempt: parsed.attempt ?? undefined,
4946
+ maxAttempts: parsed.maxAttempts ?? undefined,
4947
+ },
4948
+ });
4949
+ }
4950
+ return;
4951
+ }
3144
4952
  if (parsed.kind === "agent_message_delta") {
3145
4953
  const prev = this.pendingAgentMessages.get(parsed.itemId) ?? "";
3146
4954
  this.pendingAgentMessages.set(parsed.itemId, prev + parsed.delta);
4955
+ this.flushAgentMessageDelta(parsed.itemId);
3147
4956
  return;
3148
4957
  }
3149
4958
  if (parsed.kind === "reasoning_delta") {
@@ -3158,6 +4967,10 @@ class CodexAppServerAgentSession {
3158
4967
  });
3159
4968
  return;
3160
4969
  }
4970
+ if (parsed.kind === "command_execution_output_delta") {
4971
+ this.appendOutputDeltaChunk(this.pendingCommandOutputDeltas, parsed.itemId, parsed.delta);
4972
+ return;
4973
+ }
3161
4974
  if (parsed.kind === "file_change_output_delta") {
3162
4975
  this.appendOutputDeltaChunk(this.pendingFileChangeOutputDeltas, parsed.itemId, parsed.delta);
3163
4976
  return;
@@ -3165,6 +4978,7 @@ class CodexAppServerAgentSession {
3165
4978
  if (parsed.kind === "exec_command_started") {
3166
4979
  if (parsed.callId) {
3167
4980
  this.emittedExecCommandStartedCallIds.add(parsed.callId);
4981
+ this.inFlightToolCalls.add(parsed.callId);
3168
4982
  this.pendingCommandOutputDeltas.delete(parsed.callId);
3169
4983
  }
3170
4984
  const timelineItem = mapCodexExecNotificationToToolCall({
@@ -3179,6 +4993,9 @@ class CodexAppServerAgentSession {
3179
4993
  return;
3180
4994
  }
3181
4995
  if (parsed.kind === "exec_command_completed") {
4996
+ if (parsed.callId) {
4997
+ this.inFlightToolCalls.delete(parsed.callId);
4998
+ }
3182
4999
  const bufferedOutput = this.consumeOutputDelta(this.pendingCommandOutputDeltas, parsed.callId);
3183
5000
  const resolvedOutput = parsed.output ?? bufferedOutput;
3184
5001
  this.rememberTerminalProcessForCommand(parsed.command, resolvedOutput);
@@ -3206,7 +5023,7 @@ class CodexAppServerAgentSession {
3206
5023
  const command = (parsed.processId ? this.terminalCommandByProcessId.get(parsed.processId) : undefined) ??
3207
5024
  null;
3208
5025
  if (!command && parsed.processId) {
3209
- this.pendingUnlabeledTerminalInteractions.add(parsed.processId);
5026
+ addBounded(this.pendingUnlabeledTerminalInteractions, parsed.processId, TERMINAL_SESSION_MAP_MAX);
3210
5027
  }
3211
5028
  const timelineItem = mapCodexTerminalInteractionToToolCall({
3212
5029
  processId: parsed.processId,
@@ -3218,6 +5035,7 @@ class CodexAppServerAgentSession {
3218
5035
  }
3219
5036
  if (parsed.kind === "patch_apply_started") {
3220
5037
  if (parsed.callId) {
5038
+ this.inFlightToolCalls.add(parsed.callId);
3221
5039
  this.pendingFileChangeOutputDeltas.delete(parsed.callId);
3222
5040
  }
3223
5041
  const timelineItem = mapCodexPatchNotificationToToolCall({
@@ -3236,6 +5054,9 @@ class CodexAppServerAgentSession {
3236
5054
  return;
3237
5055
  }
3238
5056
  if (parsed.kind === "patch_apply_completed") {
5057
+ if (parsed.callId) {
5058
+ this.inFlightToolCalls.delete(parsed.callId);
5059
+ }
3239
5060
  const bufferedOutput = this.consumeOutputDelta(this.pendingFileChangeOutputDeltas, parsed.callId);
3240
5061
  const timelineItem = mapCodexPatchNotificationToToolCall({
3241
5062
  callId: parsed.callId,
@@ -3273,6 +5094,10 @@ class CodexAppServerAgentSession {
3273
5094
  this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
3274
5095
  if (itemId) {
3275
5096
  this.emittedItemCompletedIds.add(itemId);
5097
+ if (timelineItem.type === "compaction") {
5098
+ this.compactionInFlight.delete(itemId);
5099
+ this.clearCompactionItemWatchdog(itemId);
5100
+ }
3276
5101
  }
3277
5102
  }
3278
5103
  return;
@@ -3298,14 +5123,38 @@ class CodexAppServerAgentSession {
3298
5123
  if (callId && this.emittedExecCommandCompletedCallIds.has(callId)) {
3299
5124
  return;
3300
5125
  }
5126
+ if (callId) {
5127
+ const bufferedOutput = this.consumeOutputDelta(this.pendingCommandOutputDeltas, callId);
5128
+ if (bufferedOutput &&
5129
+ timelineItem.detail.type === "shell" &&
5130
+ (timelineItem.detail.output == null ||
5131
+ bufferedOutput.length > timelineItem.detail.output.length)) {
5132
+ timelineItem.detail.output = bufferedOutput;
5133
+ }
5134
+ }
3301
5135
  }
3302
5136
  if (itemId && this.emittedItemCompletedIds.has(itemId)) {
3303
5137
  return;
3304
5138
  }
5139
+ // True once a streamed message has been fully emitted via deltas, so
5140
+ // the final `item_completed` emit would only duplicate it.
5141
+ let assistantFullyStreamed = false;
3305
5142
  if (timelineItem.type === "assistant_message" && itemId) {
3306
- const buffered = this.pendingAgentMessages.get(itemId);
3307
- if (buffered && buffered.length > 0) {
3308
- timelineItem.text = buffered;
5143
+ const buffered = this.pendingAgentMessages.get(itemId) ?? "";
5144
+ // `item_completed`'s text is codex's authoritative final text.
5145
+ // Take whichever of (final text, accumulated deltas) is longer:
5146
+ // they should match, but a dropped delta leaves `buffered` short,
5147
+ // and the final item then backfills the missing tail.
5148
+ const fullText = timelineItem.text.length >= buffered.length ? timelineItem.text : buffered;
5149
+ const emitted = this.emittedAgentMessageLength.get(itemId) ?? 0;
5150
+ if (emitted > 0) {
5151
+ // Streamed already: emit only the un-streamed tail.
5152
+ timelineItem.text = fullText.slice(emitted);
5153
+ assistantFullyStreamed = timelineItem.text.length === 0;
5154
+ }
5155
+ else {
5156
+ // Never streamed (no deltas seen): emit the full text.
5157
+ timelineItem.text = fullText;
3309
5158
  }
3310
5159
  }
3311
5160
  if (timelineItem.type === "reasoning" && itemId) {
@@ -3320,12 +5169,33 @@ class CodexAppServerAgentSession {
3320
5169
  }
3321
5170
  this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
3322
5171
  }
3323
- this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
5172
+ // Skip the emit when the assistant message was fully delivered via
5173
+ // streaming deltas (avoids a duplicate / empty bubble), but still run
5174
+ // the bookkeeping below so completion state stays consistent.
5175
+ if (!assistantFullyStreamed) {
5176
+ this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
5177
+ }
3324
5178
  if (itemId) {
3325
5179
  this.emittedItemCompletedIds.add(itemId);
3326
5180
  this.emittedItemStartedIds.delete(itemId);
5181
+ this.inFlightToolCalls.delete(itemId);
5182
+ // issue #999: compaction finished — let the watchdog resume normal
5183
+ // silence monitoring.
5184
+ this.compactionInFlight.delete(itemId);
5185
+ this.clearCompactionItemWatchdog(itemId);
3327
5186
  this.pendingCommandOutputDeltas.delete(itemId);
3328
5187
  this.pendingFileChangeOutputDeltas.delete(itemId);
5188
+ // Per-itemId buffer cleanup — replaces the old emit-driven full
5189
+ // `pendingAgentMessages.clear()` that streaming made unsafe.
5190
+ this.pendingAgentMessages.delete(itemId);
5191
+ this.emittedAgentMessageLength.delete(itemId);
5192
+ // The buffered reasoning deltas were already consumed at line ~5289
5193
+ // above. Drop them here too, mirroring the sibling per-item buffers:
5194
+ // gpt-5.x reasoning is voluminous, and without this the array of
5195
+ // every reasoning delta leaks for the whole agent lifetime, slowly
5196
+ // driving the worker to a V8 heap OOM on long multi-agent sessions
5197
+ // (#1058, #1298, #1188, #1124, #1082).
5198
+ this.pendingReasoning.delete(itemId);
3329
5199
  }
3330
5200
  }
3331
5201
  return;
@@ -3354,10 +5224,31 @@ class CodexAppServerAgentSession {
3354
5224
  this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
3355
5225
  if (itemId) {
3356
5226
  this.emittedItemStartedIds.add(itemId);
5227
+ this.inFlightToolCalls.add(itemId);
3357
5228
  this.pendingCommandOutputDeltas.delete(itemId);
3358
5229
  this.pendingFileChangeOutputDeltas.delete(itemId);
3359
5230
  }
3360
5231
  }
5232
+ else if (timelineItem && timelineItem.type === "compaction") {
5233
+ // issue #999: codex's auto-compaction starts a turn-event-silent
5234
+ // operation. Track it so the watchdog defers instead of force-failing,
5235
+ // and surface a "loading" marker (the app reducer pairs it with the
5236
+ // later `item_completed` "completed", so it stays a single row).
5237
+ const itemId = parsed.item.id;
5238
+ if (itemId && this.emittedItemStartedIds.has(itemId)) {
5239
+ return;
5240
+ }
5241
+ this.compactionInFlight.add(itemId ?? "<no-id>");
5242
+ this.armCompactionItemWatchdog(itemId ?? "<no-id>");
5243
+ this.emitEvent({
5244
+ type: "timeline",
5245
+ provider: CODEX_PROVIDER,
5246
+ item: { ...timelineItem, status: "loading" },
5247
+ });
5248
+ if (itemId) {
5249
+ this.emittedItemStartedIds.add(itemId);
5250
+ }
5251
+ }
3361
5252
  return;
3362
5253
  }
3363
5254
  if (parsed.kind === "invalid_payload") {
@@ -3366,6 +5257,20 @@ class CodexAppServerAgentSession {
3366
5257
  }
3367
5258
  this.warnUnknownNotificationMethod(parsed.method, parsed.params);
3368
5259
  }
5260
+ // issue #1427: close an open "reconnecting" loading marker by emitting its
5261
+ // completed counterpart, then clear the dedup key. No-op when no marker is
5262
+ // open. Used when real turn progress resumes after a reconnect.
5263
+ resolveReconnectMarker() {
5264
+ if (this.lastReconnectMarkerKey === null) {
5265
+ return;
5266
+ }
5267
+ this.lastReconnectMarkerKey = null;
5268
+ this.emitEvent({
5269
+ type: "timeline",
5270
+ provider: CODEX_PROVIDER,
5271
+ item: { type: "reconnecting", status: "completed" },
5272
+ });
5273
+ }
3369
5274
  warnUnknownNotificationMethod(method, params) {
3370
5275
  if (this.warnedUnknownNotificationMethods.has(method)) {
3371
5276
  return;
@@ -3419,7 +5324,7 @@ class CodexAppServerAgentSession {
3419
5324
  if (!processId) {
3420
5325
  return;
3421
5326
  }
3422
- this.terminalCommandByProcessId.set(processId, displayCommand);
5327
+ setBounded(this.terminalCommandByProcessId, processId, displayCommand, TERMINAL_SESSION_MAP_MAX);
3423
5328
  if (!this.pendingUnlabeledTerminalInteractions.has(processId)) {
3424
5329
  return;
3425
5330
  }
@@ -3437,7 +5342,7 @@ class CodexAppServerAgentSession {
3437
5342
  if (this.emittedTerminalInteractionKeys.has(key)) {
3438
5343
  return false;
3439
5344
  }
3440
- this.emittedTerminalInteractionKeys.add(key);
5345
+ addBounded(this.emittedTerminalInteractionKeys, key, TERMINAL_SESSION_MAP_MAX);
3441
5346
  return true;
3442
5347
  }
3443
5348
  warnOnIncompleteEditToolCall(item, source, payload) {
@@ -3460,7 +5365,7 @@ class CodexAppServerAgentSession {
3460
5365
  }
3461
5366
  shouldAutoAcceptToolApprovals() {
3462
5367
  const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
3463
- const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
5368
+ const { approvalPolicy } = applyPlanModeConstraints(this.config.approvalPolicy ?? preset.approvalPolicy, this.config.sandboxMode ?? preset.sandbox, this.planModeEnabled);
3464
5369
  return approvalPolicy === "never";
3465
5370
  }
3466
5371
  handleCommandApprovalRequest(params) {
@@ -3487,14 +5392,8 @@ class CodexAppServerAgentSession {
3487
5392
  command: parsed.command ?? undefined,
3488
5393
  cwd: parsed.cwd ?? undefined,
3489
5394
  },
3490
- detail: commandPreview?.detail ?? {
3491
- type: "unknown",
3492
- input: {
3493
- command: parsed.command ?? null,
3494
- cwd: parsed.cwd ?? null,
3495
- },
3496
- output: null,
3497
- },
5395
+ detail: commandPreview?.detail ??
5396
+ buildExecFallbackDetail(parsed.command ?? null, parsed.cwd ?? this.config.cwd ?? null),
3498
5397
  metadata: {
3499
5398
  itemId: parsed.itemId,
3500
5399
  threadId: parsed.threadId,
@@ -3504,7 +5403,65 @@ class CodexAppServerAgentSession {
3504
5403
  this.pendingPermissions.set(requestId, request);
3505
5404
  this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
3506
5405
  return new Promise((resolve) => {
3507
- this.pendingPermissionHandlers.set(requestId, { resolve, kind: "command" });
5406
+ this.pendingPermissionHandlers.set(requestId, {
5407
+ resolve,
5408
+ kind: "command",
5409
+ foregroundTurnId: this.foregroundTurnIdForCodexPermission(parsed.turnId),
5410
+ codexTurnId: parsed.turnId,
5411
+ });
5412
+ });
5413
+ }
5414
+ handleLegacyExecCommandApprovalRequest(params) {
5415
+ const parsed = params;
5416
+ if (this.shouldAutoAcceptToolApprovals()) {
5417
+ return Promise.resolve({ decision: "approved" });
5418
+ }
5419
+ const requestKey = parsed.approvalId ?? parsed.callId;
5420
+ const requestId = `permission-${requestKey}`;
5421
+ const commandValue = Array.isArray(parsed.command) && parsed.command.length > 0
5422
+ ? parsed.command
5423
+ : parsed.command == null
5424
+ ? null
5425
+ : String(parsed.command);
5426
+ const commandPreview = mapCodexExecNotificationToToolCall({
5427
+ callId: parsed.callId,
5428
+ command: commandValue,
5429
+ cwd: parsed.cwd ?? this.config.cwd ?? null,
5430
+ running: true,
5431
+ });
5432
+ const commandText = Array.isArray(commandValue)
5433
+ ? commandValue.join(" ")
5434
+ : typeof commandValue === "string"
5435
+ ? commandValue
5436
+ : null;
5437
+ const request = {
5438
+ id: requestId,
5439
+ provider: CODEX_PROVIDER,
5440
+ name: "CodexBash",
5441
+ kind: "tool",
5442
+ title: commandText ? `Run command: ${commandText}` : "Run command",
5443
+ description: parsed.reason ?? undefined,
5444
+ input: {
5445
+ command: commandValue ?? undefined,
5446
+ cwd: parsed.cwd ?? undefined,
5447
+ },
5448
+ detail: commandPreview?.detail ??
5449
+ buildExecFallbackDetail(commandValue ?? null, parsed.cwd ?? this.config.cwd ?? null),
5450
+ metadata: {
5451
+ itemId: parsed.callId,
5452
+ approvalId: parsed.approvalId ?? null,
5453
+ threadId: parsed.conversationId ?? null,
5454
+ },
5455
+ };
5456
+ this.pendingPermissions.set(requestId, request);
5457
+ this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
5458
+ return new Promise((resolve) => {
5459
+ this.pendingPermissionHandlers.set(requestId, {
5460
+ resolve,
5461
+ kind: "command",
5462
+ foregroundTurnId: this.foregroundTurnIdForLegacyPermission(),
5463
+ protocol: "legacy-review",
5464
+ });
3508
5465
  });
3509
5466
  }
3510
5467
  handleFileChangeApprovalRequest(params) {
@@ -3536,7 +5493,59 @@ class CodexAppServerAgentSession {
3536
5493
  this.pendingPermissions.set(requestId, request);
3537
5494
  this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
3538
5495
  return new Promise((resolve) => {
3539
- this.pendingPermissionHandlers.set(requestId, { resolve, kind: "file" });
5496
+ this.pendingPermissionHandlers.set(requestId, {
5497
+ resolve,
5498
+ kind: "file",
5499
+ foregroundTurnId: this.foregroundTurnIdForCodexPermission(parsed.turnId),
5500
+ codexTurnId: parsed.turnId,
5501
+ });
5502
+ });
5503
+ }
5504
+ handleLegacyApplyPatchApprovalRequest(params) {
5505
+ const parsed = params;
5506
+ if (this.shouldAutoAcceptToolApprovals()) {
5507
+ return Promise.resolve({ decision: "approved" });
5508
+ }
5509
+ const requestId = `permission-${parsed.callId}`;
5510
+ const patchPreview = mapCodexPatchNotificationToToolCall({
5511
+ callId: parsed.callId,
5512
+ changes: parsed.fileChanges,
5513
+ cwd: this.config.cwd ?? null,
5514
+ running: true,
5515
+ });
5516
+ const request = {
5517
+ id: requestId,
5518
+ provider: CODEX_PROVIDER,
5519
+ name: "CodexFileChange",
5520
+ kind: "tool",
5521
+ title: "Apply file changes",
5522
+ description: parsed.reason ?? undefined,
5523
+ input: {
5524
+ fileChanges: parsed.fileChanges ?? undefined,
5525
+ grantRoot: parsed.grantRoot ?? undefined,
5526
+ },
5527
+ detail: patchPreview?.detail ?? {
5528
+ type: "unknown",
5529
+ input: {
5530
+ fileChanges: parsed.fileChanges ?? null,
5531
+ grantRoot: parsed.grantRoot ?? null,
5532
+ },
5533
+ output: null,
5534
+ },
5535
+ metadata: {
5536
+ itemId: parsed.callId,
5537
+ threadId: parsed.conversationId ?? null,
5538
+ },
5539
+ };
5540
+ this.pendingPermissions.set(requestId, request);
5541
+ this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
5542
+ return new Promise((resolve) => {
5543
+ this.pendingPermissionHandlers.set(requestId, {
5544
+ resolve,
5545
+ kind: "file",
5546
+ foregroundTurnId: this.foregroundTurnIdForLegacyPermission(),
5547
+ protocol: "legacy-review",
5548
+ });
3540
5549
  });
3541
5550
  }
3542
5551
  handleToolApprovalRequest(params) {
@@ -3578,6 +5587,8 @@ class CodexAppServerAgentSession {
3578
5587
  this.pendingPermissionHandlers.set(requestId, {
3579
5588
  resolve,
3580
5589
  kind: "question",
5590
+ foregroundTurnId: this.foregroundTurnIdForCodexPermission(parsed.turnId),
5591
+ codexTurnId: parsed.turnId,
3581
5592
  questions,
3582
5593
  });
3583
5594
  });
@@ -3592,6 +5603,24 @@ export class CodexAppServerAgentClient {
3592
5603
  this.runtimeSettings = runtimeSettings;
3593
5604
  this.provider = CODEX_PROVIDER;
3594
5605
  this.capabilities = CODEX_APP_SERVER_CAPABILITIES;
5606
+ this.goalsEnabledPromise = null;
5607
+ }
5608
+ // codex `goals` feature gate. Determined once per client (version probe is a
5609
+ // separate `--version` spawn) and cached, so create/resume don't each probe.
5610
+ //
5611
+ // The cache is keyed at the client level (one `CodexAppServerAgentClient`
5612
+ // per provider, constructed once from static `runtimeSettings`). The binary
5613
+ // that gets probed here is resolved from `runtimeSettings.command` /
5614
+ // `runtimeSettings.env.PATH` — the same path that `spawnAppServer` uses at
5615
+ // launch time. `launchContext.env` (injected per-session by agent-manager)
5616
+ // only carries `SEAWORK_AGENT_ID`, which is a runtime identifier and has no
5617
+ // effect on binary resolution, so the cached result stays consistent with
5618
+ // the actual spawn.
5619
+ resolveGoalsEnabled() {
5620
+ if (!this.goalsEnabledPromise) {
5621
+ this.goalsEnabledPromise = this.resolveCodexVersion().then(({ version }) => isVersionAtLeast(version, CODEX_GOALS_MIN_VERSION), () => false);
5622
+ }
5623
+ return this.goalsEnabledPromise;
3595
5624
  }
3596
5625
  async spawnAppServer(options) {
3597
5626
  const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings, this.logger);
@@ -3600,10 +5629,18 @@ export class CodexAppServerAgentClient {
3600
5629
  }, "Spawning Codex app server");
3601
5630
  const launchEnv = options?.launchEnv;
3602
5631
  const codexEnv = buildCodexAppServerEnv(this.runtimeSettings, launchEnv);
5632
+ const managedCodexHome = await prepareManagedCodexHome({
5633
+ env: codexEnv,
5634
+ logger: this.logger,
5635
+ });
5636
+ if (managedCodexHome) {
5637
+ codexEnv.CODEX_HOME = managedCodexHome;
5638
+ }
3603
5639
  this.logger.info({
3604
5640
  hasOpenaiKey: !!codexEnv.OPENAI_API_KEY,
3605
5641
  openaiKeyPrefix: codexEnv.OPENAI_API_KEY?.slice(0, 10) ?? null,
3606
5642
  openaiBaseUrl: codexEnv.OPENAI_BASE_URL ?? null,
5643
+ managedCodexHome,
3607
5644
  }, "Codex app-server env check");
3608
5645
  // If a Seawork-managed base URL is available, inject a "seawork" provider
3609
5646
  // via -c flags to override any user config.
@@ -3612,13 +5649,14 @@ export class CodexAppServerAgentClient {
3612
5649
  const seaworkProvider = readStringRecord(readStringRecord(seaworkConfig?.model_providers)[CODEX_SEAWORK_PROVIDER_ID]);
3613
5650
  const seaworkBaseUrl = readStringMetadata(seaworkProvider.base_url);
3614
5651
  if (seaworkConfig && seaworkBaseUrl) {
3615
- extraArgs.push("-c", `model_provider="${CODEX_SEAWORK_PROVIDER_ID}"`, "-c", `model_providers.${CODEX_SEAWORK_PROVIDER_ID}.name="Seawork"`, "-c", `model_providers.${CODEX_SEAWORK_PROVIDER_ID}.base_url="${seaworkBaseUrl}"`, "-c", `model_providers.${CODEX_SEAWORK_PROVIDER_ID}.env_key="SEAWORK_API_KEY"`, "--disable", "js_repl");
5652
+ extraArgs.push("-c", `model_provider="${CODEX_SEAWORK_PROVIDER_ID}"`, "-c", `model_providers.${CODEX_SEAWORK_PROVIDER_ID}.name="Seawork"`, "-c", `model_providers.${CODEX_SEAWORK_PROVIDER_ID}.base_url="${seaworkBaseUrl}"`, "-c", `model_providers.${CODEX_SEAWORK_PROVIDER_ID}.env_key="SEAWORK_API_KEY"`, "-c", `model_providers.${CODEX_SEAWORK_PROVIDER_ID}.http_headers."${SEAWORK_AGENT_PROVIDER_HEADER_NAME}"="${CODEX_PROVIDER}"`, "-c", `model_providers.${CODEX_SEAWORK_PROVIDER_ID}.http_headers."${SEAWORK_SOURCE_HEADER_NAME}"="${SEAWORK_SOURCE_HEADER_VALUE}"`, "--disable", "js_repl");
3616
5653
  }
3617
5654
  let command = launchPrefix.command;
3618
5655
  let fullArgs = [
3619
5656
  ...launchPrefix.args,
3620
5657
  "app-server",
3621
5658
  ...(options?.enableExternalMigration ? ["--enable", "external_migration"] : []),
5659
+ ...(options?.enableGoals ? ["--enable", "goals"] : []),
3622
5660
  ...extraArgs,
3623
5661
  ];
3624
5662
  let runtimeKind = "default";
@@ -3739,13 +5777,20 @@ export class CodexAppServerAgentClient {
3739
5777
  // Falling back to process.env.CODEX_HOME would silently break setups that
3740
5778
  // override CODEX_HOME via provider runtimeSettings.env.
3741
5779
  const spawnEnv = buildCodexAppServerEnv(this.runtimeSettings, input.launchContext?.env);
3742
- const codexHome = readStringMetadata(spawnEnv.CODEX_HOME) ?? path.join(os.homedir(), ".codex");
5780
+ const managedCodexHome = await prepareManagedCodexHome({
5781
+ env: spawnEnv,
5782
+ logger: this.logger,
5783
+ });
5784
+ const codexHome = managedCodexHome ??
5785
+ readStringMetadata(spawnEnv.CODEX_HOME) ??
5786
+ path.join(os.homedir(), ".codex");
3743
5787
  this.logger.info({
3744
5788
  sourceProvider: input.source.provider,
3745
5789
  sourceSessionId: input.source.sessionId,
3746
5790
  sourcePath,
3747
5791
  sourceCwd,
3748
5792
  codexHome,
5793
+ managedCodexHome,
3749
5794
  }, "Codex external migration: starting import");
3750
5795
  // Short-circuit: codex's `externalAgentConfig/detect` deliberately omits
3751
5796
  // sessions it has already imported (dedup against the ledger). If we
@@ -3923,7 +5968,8 @@ export class CodexAppServerAgentClient {
3923
5968
  }
3924
5969
  async createSession(config, launchContext) {
3925
5970
  const sessionConfig = withManagedCodexConfig({ ...config, provider: CODEX_PROVIDER }, this.runtimeSettings, launchContext?.env);
3926
- const session = new CodexAppServerAgentSession(sessionConfig, null, this.logger, () => this.spawnAppServer({ launchEnv: launchContext?.env }));
5971
+ const goalsEnabled = await this.resolveGoalsEnabled();
5972
+ const session = new CodexAppServerAgentSession(sessionConfig, null, this.logger, () => this.spawnAppServer({ launchEnv: launchContext?.env, enableGoals: goalsEnabled }), goalsEnabled);
3927
5973
  await session.connect();
3928
5974
  return session;
3929
5975
  }
@@ -3937,7 +5983,8 @@ export class CodexAppServerAgentClient {
3937
5983
  modeId: overrides?.modeId ?? storedConfig.modeId ?? "auto",
3938
5984
  };
3939
5985
  const sessionConfig = withManagedCodexConfig(merged, this.runtimeSettings, launchContext?.env);
3940
- const session = new CodexAppServerAgentSession(sessionConfig, handle, this.logger, () => this.spawnAppServer({ launchEnv: launchContext?.env }));
5986
+ const goalsEnabled = await this.resolveGoalsEnabled();
5987
+ const session = new CodexAppServerAgentSession(sessionConfig, handle, this.logger, () => this.spawnAppServer({ launchEnv: launchContext?.env, enableGoals: goalsEnabled }), goalsEnabled);
3941
5988
  await session.connect();
3942
5989
  return session;
3943
5990
  }
@@ -3955,26 +6002,32 @@ export class CodexAppServerAgentClient {
3955
6002
  const threadId = thread.id;
3956
6003
  const cwd = thread.cwd ?? process.cwd();
3957
6004
  const title = thread.preview ?? null;
6005
+ // Loading a timeline costs a `thread/read` round-trip plus a rollout
6006
+ // file parse per thread. The daemon discards it unless the caller
6007
+ // passed `includeTimeline`, so skip the work entirely otherwise —
6008
+ // listing 20 threads would otherwise blow past the client timeout.
3958
6009
  let timeline = [];
3959
- try {
3960
- const rolloutTimeline = await loadCodexPersistedTimeline(threadId, undefined, this.logger);
3961
- const read = (await client.request("thread/read", {
3962
- threadId,
3963
- includeTurns: true,
3964
- }));
3965
- const turns = read.thread?.turns ?? [];
3966
- const itemsFromThreadRead = [];
3967
- for (const turn of turns) {
3968
- for (const item of turn.items ?? []) {
3969
- const timelineItem = threadItemToTimeline(item, { cwd });
3970
- if (timelineItem)
3971
- itemsFromThreadRead.push(timelineItem);
6010
+ if (options?.includeTimeline) {
6011
+ try {
6012
+ const rolloutTimeline = await loadCodexPersistedTimeline(threadId, undefined, this.logger);
6013
+ const read = (await client.request("thread/read", {
6014
+ threadId,
6015
+ includeTurns: true,
6016
+ }));
6017
+ const turns = read.thread?.turns ?? [];
6018
+ const itemsFromThreadRead = [];
6019
+ for (const turn of turns) {
6020
+ for (const item of turn.items ?? []) {
6021
+ const timelineItem = threadItemToTimeline(item, { cwd });
6022
+ if (timelineItem)
6023
+ itemsFromThreadRead.push(timelineItem);
6024
+ }
3972
6025
  }
6026
+ timeline = rolloutTimeline.length > 0 ? rolloutTimeline : itemsFromThreadRead;
6027
+ }
6028
+ catch {
6029
+ timeline = [];
3973
6030
  }
3974
- timeline = rolloutTimeline.length > 0 ? rolloutTimeline : itemsFromThreadRead;
3975
- }
3976
- catch {
3977
- timeline = [];
3978
6031
  }
3979
6032
  descriptors.push({
3980
6033
  provider: CODEX_PROVIDER,
@@ -4002,33 +6055,41 @@ export class CodexAppServerAgentClient {
4002
6055
  await client.dispose();
4003
6056
  }
4004
6057
  }
6058
+ async loadPersistedTimeline(handle) {
6059
+ const metadata = handle.metadata ?? {};
6060
+ const threadId = readStringMetadata(metadata.threadId) ??
6061
+ readStringMetadata(handle.nativeHandle) ??
6062
+ handle.sessionId;
6063
+ const sessionRoot = readStringMetadata(metadata.sessionRoot);
6064
+ const rolloutPath = readStringMetadata(metadata.rolloutPath);
6065
+ return loadCodexPersistedTimeline(threadId, {
6066
+ ...(sessionRoot ? { sessionRoot } : {}),
6067
+ ...(rolloutPath ? { rolloutPath } : {}),
6068
+ }, this.logger);
6069
+ }
4005
6070
  async listModels(_options) {
4006
6071
  return getSeaworkModels("codex");
4007
6072
  }
4008
6073
  async isAvailable() {
4009
6074
  try {
4010
- // Same selector daemon spawn uses, so UI availability matches actual
4011
- // launch behavior. We always run `verifyCommandAvailable` against
4012
- // the resolved binary without that check `install-state-stale`
4013
- // (recorded path that's now missing/broken) and unhealthy PATH
4014
- // candidates would still report Available even though daemon spawn
4015
- // would fail immediately. The bare-name semantics of
4016
- // `verifyCommandAvailable` cover `command-override` cases like
4017
- // `argv=["docker", ...]` while still rejecting paths that don't
4018
- // exist or aren't executable.
6075
+ // Mirror the daemon spawn path exactly: resolve the binary with the
6076
+ // same selector, then check it is launchable. `verifyCommandAvailable`
6077
+ // covers `command-override` bare names (`argv=["docker", ...]`) and
6078
+ // rejects paths that don't exist or aren't executable — the only
6079
+ // gates daemon spawn itself would care about.
4019
6080
  //
4020
- // `installStateRejected` is set by the resolver when an unhealthy
4021
- // recorded install OR an unhealthy PATH candidate was selected as
4022
- // a last-resort. Trust the probe verdict and report unavailable
4023
- // rather than letting `verifyCommandAvailable` (file-existence
4024
- // only) accept a fake/stale shim that just happens to be
4025
- // launchable. Diagnostic still surfaces the path + reason for
4026
- // troubleshooting.
6081
+ // We deliberately do NOT short-circuit on `selection.installStateRejected`.
6082
+ // That field is set when the `codex --help` health probe rejects the
6083
+ // candidate (stale install-state, fake/echo shim, OR transient probe
6084
+ // timeout on a slow Windows host / AV-scanned binary). Daemon spawn
6085
+ // doesn't gate on that probe, so neither should UI availability —
6086
+ // otherwise a transient probe timeout silently hides every codex
6087
+ // model from the selector while daemon could still spawn codex fine
6088
+ // (issue #479). For genuine fake-shim cases, the real spawn surfaces
6089
+ // the error directly; `getDiagnostic` still reports the probe
6090
+ // verdict for troubleshooting.
4027
6091
  const spawnEnv = buildCodexAppServerEnv(this.runtimeSettings);
4028
6092
  const selection = await selectEffectiveCodexBinary(this.runtimeSettings, { spawnEnv });
4029
- if (selection.installStateRejected) {
4030
- return false;
4031
- }
4032
6093
  return await verifyCommandAvailable(selection.binary, { spawnEnv });
4033
6094
  }
4034
6095
  catch {
@@ -4121,9 +6182,11 @@ export class CodexAppServerAgentClient {
4121
6182
  }
4122
6183
  }
4123
6184
  export const __codexAppServerInternals = {
6185
+ buildManagedCodexConfigToml,
4124
6186
  buildCodexAppServerEnv,
4125
6187
  buildCodexSeaworkProviderConfig,
4126
6188
  codexModelSupportsFastMode,
6189
+ CodexAppServerClient,
4127
6190
  CodexAppServerAgentSession,
4128
6191
  listCodexSkillEntries,
4129
6192
  formatCodexQuestionPrompts,
@@ -4132,10 +6195,17 @@ export const __codexAppServerInternals = {
4132
6195
  mapCodexQuestionRequestToToolCall,
4133
6196
  mapCodexPatchNotificationToToolCall,
4134
6197
  planStepsToMarkdown,
6198
+ prepareManagedCodexHome,
4135
6199
  mapCodexPlanToToolCall,
4136
6200
  normalizeCodexOutputSchema,
4137
6201
  normalizeCodexQuestionPrompts,
6202
+ summarizeJsonRpcParamsForLog,
4138
6203
  toAgentUsage,
4139
6204
  threadItemToTimeline,
6205
+ TURN_WATCHDOG_IDLE_MS,
6206
+ WATCHDOG_LATE_COMPLETION_GRACE_MS,
6207
+ TURN_WATCHDOG_MAX_INFLIGHT_CYCLES,
6208
+ TURN_WATCHDOG_MAX_COMPACTION_CYCLES,
6209
+ COMPACTION_ITEM_WATCHDOG_MS,
4140
6210
  };
4141
6211
  //# sourceMappingURL=codex-app-server-agent.js.map