@cloudcli-ai/cloudcli 1.37.2 → 1.37.3

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 (328) hide show
  1. package/README.md +269 -0
  2. package/dist/assets/{abnfDiagram-N423BO3Z-C4UAoHJb.js → abnfDiagram-N423BO3Z-CJntskwA.js} +1 -1
  3. package/dist/assets/{arc-B8jF-ja9.js → arc-CIfH4uMN.js} +1 -1
  4. package/dist/assets/{architectureDiagram-T3A2C74G-C_Wur_CU.js → architectureDiagram-T3A2C74G-_IpEkukg.js} +1 -1
  5. package/dist/assets/{blockDiagram-VBNYF7ZC-Cq4P1i8t.js → blockDiagram-VBNYF7ZC-BttxrpgZ.js} +1 -1
  6. package/dist/assets/{c4Diagram-5PPSVZJV-C1wG4eUj.js → c4Diagram-5PPSVZJV-g51dSRr1.js} +1 -1
  7. package/dist/assets/channel-C5xU9jMw.js +1 -0
  8. package/dist/assets/{chunk-2GRJ4B5K-B8fnBG6E.js → chunk-2GRJ4B5K-D_-LbQUa.js} +1 -1
  9. package/dist/assets/{chunk-2Q5K7J3B-WdfPooPJ.js → chunk-2Q5K7J3B-BtlHzwaO.js} +1 -1
  10. package/dist/assets/{chunk-5RXB4S5H-DhZJuG2E.js → chunk-5RXB4S5H-UzkpvpAT.js} +1 -1
  11. package/dist/assets/{chunk-5VM5RSS4-B0rjslal.js → chunk-5VM5RSS4-BKwx5Gag.js} +1 -1
  12. package/dist/assets/{chunk-6Q2QTUOP-CG2Oj4jL.js → chunk-6Q2QTUOP-D_KEBH5f.js} +1 -1
  13. package/dist/assets/{chunk-GF5L2VYU-rrl2lRxY.js → chunk-GF5L2VYU-rK2DGyto.js} +1 -1
  14. package/dist/assets/{chunk-JWPE2WC7-CjVbrVMA.js → chunk-JWPE2WC7-CeBl_N9i.js} +1 -1
  15. package/dist/assets/{chunk-KBJHAD2P-CRzKgU8S.js → chunk-KBJHAD2P-BiQLWVIr.js} +1 -1
  16. package/dist/assets/{chunk-RYQCIY6F-DBz4GTZF.js → chunk-RYQCIY6F-C1knVGV5.js} +1 -1
  17. package/dist/assets/{chunk-XXDRQBXY-wHxalsXk.js → chunk-XXDRQBXY-CtKbJkvF.js} +1 -1
  18. package/dist/assets/classDiagram-JCYQIIEL-CRrL3vp7.js +1 -0
  19. package/dist/assets/classDiagram-v2-OCEON4UE-CRrL3vp7.js +1 -0
  20. package/dist/assets/{cose-bilkent-JH36ORCC-s1hygY8e.js → cose-bilkent-JH36ORCC-DjX8y40Z.js} +1 -1
  21. package/dist/assets/{cynefin-VYW2F7L2-C4LGV15i.js → cynefin-VYW2F7L2-DM6YeRba.js} +1 -1
  22. package/dist/assets/{cynefinDiagram-MW4NZA55-C5uhu7B3.js → cynefinDiagram-MW4NZA55-wZJ-vy5B.js} +1 -1
  23. package/dist/assets/{dagre-VZM6K2ZE-B-OBTewm.js → dagre-VZM6K2ZE-BzP0ejkH.js} +1 -1
  24. package/dist/assets/{diagram-7IWD3JNH-BjGKQxih.js → diagram-7IWD3JNH-BTL_j23J.js} +1 -1
  25. package/dist/assets/{diagram-B4RE2ZJO-BMKz1X2L.js → diagram-B4RE2ZJO-Cf1Norgu.js} +1 -1
  26. package/dist/assets/{diagram-LBJQPF4R-DNh-GhKC.js → diagram-LBJQPF4R-DPiUEKr-.js} +1 -1
  27. package/dist/assets/{diagram-Q27KOJAE-BaTG9jPT.js → diagram-Q27KOJAE-CTcTpiz5.js} +1 -1
  28. package/dist/assets/{diagram-UB23O5K3-BUwo2OvJ.js → diagram-UB23O5K3-DPMSdYm0.js} +1 -1
  29. package/dist/assets/{ebnfDiagram-BXEA7PRR-DAZgfzX2.js → ebnfDiagram-BXEA7PRR-7yvD6uiF.js} +1 -1
  30. package/dist/assets/{erDiagram-JOGREHBK-1w1CcGEb.js → erDiagram-JOGREHBK-CfgeDoV6.js} +1 -1
  31. package/dist/assets/{flowDiagram-UKHOOZJN-D4EII8Jj.js → flowDiagram-UKHOOZJN-B27ng5zu.js} +1 -1
  32. package/dist/assets/{ganttDiagram-PKOTCBZU-ChvJZAgB.js → ganttDiagram-PKOTCBZU-CcC6b_7t.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-DS77QQ5N-DtOn41S8.js → gitGraphDiagram-DS77QQ5N-BX5kPGrO.js} +1 -1
  34. package/dist/assets/index-BTDHQ6Id.css +32 -0
  35. package/dist/assets/index-sFfz8iBR.js +4931 -0
  36. package/dist/assets/{infoDiagram-6WML65LV-DN_PVEJZ.js → infoDiagram-6WML65LV-CUP-SlN4.js} +1 -1
  37. package/dist/assets/{ishikawaDiagram-WSZJBQD7-DbkF1kXb.js → ishikawaDiagram-WSZJBQD7-BTTDhOpX.js} +1 -1
  38. package/dist/assets/{journeyDiagram-NVQOT4AX-pJwmhddE.js → journeyDiagram-NVQOT4AX-ARXcc61m.js} +1 -1
  39. package/dist/assets/{kanban-definition-27J2QSJJ-PPAPgNp5.js → kanban-definition-27J2QSJJ-BieMpqMA.js} +1 -1
  40. package/dist/assets/{linear-iPR-J9f7.js → linear-61vxjRnP.js} +1 -1
  41. package/dist/assets/{mermaid.core-BEUL86Z6.js → mermaid.core-nhnwkfQx.js} +5 -5
  42. package/dist/assets/{mindmap-definition-FAOFIHXS-BvZoBib7.js → mindmap-definition-FAOFIHXS-Cm6x5vT1.js} +1 -1
  43. package/dist/assets/{pegDiagram-VL7TDLO6-DhYreaUd.js → pegDiagram-VL7TDLO6-CcYBq_9H.js} +1 -1
  44. package/dist/assets/{pieDiagram-7S7Q4E2Y-CrOHlyD0.js → pieDiagram-7S7Q4E2Y-CfvIjfzo.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-CIZ2JOQS-D4g5yH3t.js → quadrantDiagram-CIZ2JOQS-SgYoWPNb.js} +1 -1
  46. package/dist/assets/{railroadDiagram-AXF67PYL-Dj39lHlf.js → railroadDiagram-AXF67PYL-D2Mu5E_f.js} +1 -1
  47. package/dist/assets/{requirementDiagram-LRYGKXZP-1AS4Zgm_.js → requirementDiagram-LRYGKXZP-Azwe_k4Q.js} +1 -1
  48. package/dist/assets/{sankeyDiagram-W5VNT64P-CKlMn19P.js → sankeyDiagram-W5VNT64P-DN1Ju3PA.js} +1 -1
  49. package/dist/assets/{sequenceDiagram-SI44F4Z6-B_j3qyvo.js → sequenceDiagram-SI44F4Z6-Bj4qC34S.js} +1 -1
  50. package/dist/assets/server.browser-CtX3Hdl5.js +27 -0
  51. package/dist/assets/{sizeCapture-X5ZJPWSS-Bp2Op16v.js → sizeCapture-X5ZJPWSS-Co4PdklG.js} +1 -1
  52. package/dist/assets/{stateDiagram-OKZ733FA-C0cN6DoY.js → stateDiagram-OKZ733FA-nOvL9dRC.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-UEYNNEHI-DNv8ppw7.js +1 -0
  54. package/dist/assets/{swimlanes-SLNWSIFB-BEvXOV9b.js → swimlanes-SLNWSIFB-CCK3wMid.js} +2 -2
  55. package/dist/assets/swimlanesDiagram-ULZ7WXOC-DnfbNQEp.js +8 -0
  56. package/dist/assets/{timeline-definition-Z64GVDOM-zdJQinvZ.js → timeline-definition-Z64GVDOM-BC29RNFm.js} +1 -1
  57. package/dist/assets/{vendor-codemirror-OwyKSvPE.js → vendor-codemirror-CYrSt7-U.js} +18 -18
  58. package/dist/assets/{vennDiagram-T6HMQDX7-DuF1uZMY.js → vennDiagram-T6HMQDX7-pZlZCtEo.js} +1 -1
  59. package/dist/assets/{wardleyDiagram-T6FBY63Y-B9AlLQup.js → wardleyDiagram-T6FBY63Y-De8k2-zM.js} +1 -1
  60. package/dist/assets/{xychartDiagram-ELKLHX3M-DTJX48Qg.js → xychartDiagram-ELKLHX3M-Bk8ykH0w.js} +1 -1
  61. package/dist/index.html +3 -3
  62. package/dist-server/server/index.js +7 -3
  63. package/dist-server/server/index.js.map +1 -1
  64. package/dist-server/server/modules/agent/agent.routes.js +2 -2
  65. package/dist-server/server/modules/agent/agent.routes.js.map +1 -1
  66. package/dist-server/server/modules/agent/tests/agent.routes.test.js +30 -1
  67. package/dist-server/server/modules/agent/tests/agent.routes.test.js.map +1 -1
  68. package/dist-server/server/modules/browser-use/browser-use.routes.js +39 -33
  69. package/dist-server/server/modules/browser-use/browser-use.routes.js.map +1 -1
  70. package/dist-server/server/modules/database/connection.js +1 -1
  71. package/dist-server/server/modules/database/connection.js.map +1 -1
  72. package/dist-server/server/modules/database/index.js +5 -0
  73. package/dist-server/server/modules/database/index.js.map +1 -1
  74. package/dist-server/server/modules/database/migrations.js +32 -1
  75. package/dist-server/server/modules/database/migrations.js.map +1 -1
  76. package/dist-server/server/modules/database/repositories/scheduled-messages.db.js +71 -0
  77. package/dist-server/server/modules/database/repositories/scheduled-messages.db.js.map +1 -0
  78. package/dist-server/server/modules/database/repositories/session-drafts.db.js +105 -0
  79. package/dist-server/server/modules/database/repositories/session-drafts.db.js.map +1 -0
  80. package/dist-server/server/modules/database/repositories/sessions.db.js +137 -5
  81. package/dist-server/server/modules/database/repositories/sessions.db.js.map +1 -1
  82. package/dist-server/server/modules/database/repositories/user-preferences.db.js +65 -0
  83. package/dist-server/server/modules/database/repositories/user-preferences.db.js.map +1 -0
  84. package/dist-server/server/modules/database/schema.js +92 -0
  85. package/dist-server/server/modules/database/schema.js.map +1 -1
  86. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js +39 -0
  87. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js.map +1 -1
  88. package/dist-server/server/modules/database/tests/user-preferences.db.integration.test.js +105 -0
  89. package/dist-server/server/modules/database/tests/user-preferences.db.integration.test.js.map +1 -0
  90. package/dist-server/server/modules/projects/services/project-delete.service.js +12 -1
  91. package/dist-server/server/modules/projects/services/project-delete.service.js.map +1 -1
  92. package/dist-server/server/modules/projects/services/project-star.service.js +2 -2
  93. package/dist-server/server/modules/projects/services/project-star.service.js.map +1 -1
  94. package/dist-server/server/modules/providers/index.js +3 -0
  95. package/dist-server/server/modules/providers/index.js.map +1 -1
  96. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js +20 -1
  97. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js.map +1 -1
  98. package/dist-server/server/modules/providers/list/claude/claude-fork.provider.js +46 -0
  99. package/dist-server/server/modules/providers/list/claude/claude-fork.provider.js.map +1 -0
  100. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js +35 -6
  101. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js.map +1 -1
  102. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js +168 -38
  103. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js.map +1 -1
  104. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js +388 -50
  105. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js.map +1 -1
  106. package/dist-server/server/modules/providers/list/claude/claude-skills.provider.js +4 -1
  107. package/dist-server/server/modules/providers/list/claude/claude-skills.provider.js.map +1 -1
  108. package/dist-server/server/modules/providers/list/claude/claude.provider.js +2 -0
  109. package/dist-server/server/modules/providers/list/claude/claude.provider.js.map +1 -1
  110. package/dist-server/server/modules/providers/list/codex/codex-app-server.client.js +204 -0
  111. package/dist-server/server/modules/providers/list/codex/codex-app-server.client.js.map +1 -0
  112. package/dist-server/server/modules/providers/list/codex/codex-fork.provider.js +32 -0
  113. package/dist-server/server/modules/providers/list/codex/codex-fork.provider.js.map +1 -0
  114. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +16 -0
  115. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -1
  116. package/dist-server/server/modules/providers/list/codex/codex-runtime.provider.js +60 -49
  117. package/dist-server/server/modules/providers/list/codex/codex-runtime.provider.js.map +1 -1
  118. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js +8 -0
  119. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js.map +1 -1
  120. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +1565 -432
  121. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -1
  122. package/dist-server/server/modules/providers/list/codex/codex.provider.js +2 -0
  123. package/dist-server/server/modules/providers/list/codex/codex.provider.js.map +1 -1
  124. package/dist-server/server/modules/providers/list/cursor/cursor-runtime.provider.js +8 -6
  125. package/dist-server/server/modules/providers/list/cursor/cursor-runtime.provider.js.map +1 -1
  126. package/dist-server/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.js.map +1 -1
  127. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +258 -6
  128. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -1
  129. package/dist-server/server/modules/providers/provider.routes.js +24 -0
  130. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  131. package/dist-server/server/modules/providers/services/provider-capabilities.service.js +14 -0
  132. package/dist-server/server/modules/providers/services/provider-capabilities.service.js.map +1 -1
  133. package/dist-server/server/modules/providers/services/provider-token-usage.service.js +139 -35
  134. package/dist-server/server/modules/providers/services/provider-token-usage.service.js.map +1 -1
  135. package/dist-server/server/modules/providers/services/session-history-cache.service.js +87 -0
  136. package/dist-server/server/modules/providers/services/session-history-cache.service.js.map +1 -0
  137. package/dist-server/server/modules/providers/services/session-synchronizer.service.js +105 -32
  138. package/dist-server/server/modules/providers/services/session-synchronizer.service.js.map +1 -1
  139. package/dist-server/server/modules/providers/services/sessions-watcher.service.js +3 -60
  140. package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
  141. package/dist-server/server/modules/providers/services/sessions.service.js +179 -9
  142. package/dist-server/server/modules/providers/services/sessions.service.js.map +1 -1
  143. package/dist-server/server/modules/providers/tests/claude-auth.test.js +52 -0
  144. package/dist-server/server/modules/providers/tests/claude-auth.test.js.map +1 -1
  145. package/dist-server/server/modules/providers/tests/claude-models.test.js +39 -0
  146. package/dist-server/server/modules/providers/tests/claude-models.test.js.map +1 -0
  147. package/dist-server/server/modules/providers/tests/claude-sessions.test.js +465 -39
  148. package/dist-server/server/modules/providers/tests/claude-sessions.test.js.map +1 -1
  149. package/dist-server/server/modules/providers/tests/claude-subagent-echo.test.js +16 -0
  150. package/dist-server/server/modules/providers/tests/claude-subagent-echo.test.js.map +1 -0
  151. package/dist-server/server/modules/providers/tests/claude-token-budget.test.js +85 -0
  152. package/dist-server/server/modules/providers/tests/claude-token-budget.test.js.map +1 -0
  153. package/dist-server/server/modules/providers/tests/codex-message-editing.test.js +338 -0
  154. package/dist-server/server/modules/providers/tests/codex-message-editing.test.js.map +1 -0
  155. package/dist-server/server/modules/providers/tests/codex-runtime.test.js +51 -0
  156. package/dist-server/server/modules/providers/tests/codex-runtime.test.js.map +1 -0
  157. package/dist-server/server/modules/providers/tests/codex-session-fork.test.js +54 -0
  158. package/dist-server/server/modules/providers/tests/codex-session-fork.test.js.map +1 -0
  159. package/dist-server/server/modules/providers/tests/codex-sessions.test.js +304 -15
  160. package/dist-server/server/modules/providers/tests/codex-sessions.test.js.map +1 -1
  161. package/dist-server/server/modules/providers/tests/opencode-models.test.js +117 -4
  162. package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -1
  163. package/dist-server/server/modules/providers/tests/provider-token-usage.service.test.js +164 -1
  164. package/dist-server/server/modules/providers/tests/provider-token-usage.service.test.js.map +1 -1
  165. package/dist-server/server/modules/providers/tests/session-fork.test.js +113 -0
  166. package/dist-server/server/modules/providers/tests/session-fork.test.js.map +1 -0
  167. package/dist-server/server/modules/providers/tests/session-history-cache.service.test.js +120 -0
  168. package/dist-server/server/modules/providers/tests/session-history-cache.service.test.js.map +1 -0
  169. package/dist-server/server/modules/providers/tests/session-orphan-prune.test.js +90 -0
  170. package/dist-server/server/modules/providers/tests/session-orphan-prune.test.js.map +1 -0
  171. package/dist-server/server/modules/providers/tests/session-synchronizer-coalescing.test.js +122 -0
  172. package/dist-server/server/modules/providers/tests/session-synchronizer-coalescing.test.js.map +1 -0
  173. package/dist-server/server/modules/providers/tests/sessions.service.test.js +45 -1
  174. package/dist-server/server/modules/providers/tests/sessions.service.test.js.map +1 -1
  175. package/dist-server/server/modules/providers/tests/skills.test.js +15 -3
  176. package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -1
  177. package/dist-server/server/modules/scheduled-messages/index.js +5 -0
  178. package/dist-server/server/modules/scheduled-messages/index.js.map +1 -0
  179. package/dist-server/server/modules/scheduled-messages/scheduled-messages.routes.js +47 -0
  180. package/dist-server/server/modules/scheduled-messages/scheduled-messages.routes.js.map +1 -0
  181. package/dist-server/server/modules/scheduled-messages/services/scheduled-message-dispatcher.service.js +163 -0
  182. package/dist-server/server/modules/scheduled-messages/services/scheduled-message-dispatcher.service.js.map +1 -0
  183. package/dist-server/server/modules/scheduled-messages/services/scheduled-messages.service.js +95 -0
  184. package/dist-server/server/modules/scheduled-messages/services/scheduled-messages.service.js.map +1 -0
  185. package/dist-server/server/modules/scheduled-messages/tests/scheduled-messages.test.js +255 -0
  186. package/dist-server/server/modules/scheduled-messages/tests/scheduled-messages.test.js.map +1 -0
  187. package/dist-server/server/modules/taskmaster/taskmaster.routes.js +23 -23
  188. package/dist-server/server/modules/taskmaster/taskmaster.routes.js.map +1 -1
  189. package/dist-server/server/modules/taskmaster/tests/taskmaster.routes.test.js +48 -0
  190. package/dist-server/server/modules/taskmaster/tests/taskmaster.routes.test.js.map +1 -1
  191. package/dist-server/server/modules/user/tests/user.service.test.js +70 -0
  192. package/dist-server/server/modules/user/tests/user.service.test.js.map +1 -1
  193. package/dist-server/server/modules/user/user.module.js +10 -1
  194. package/dist-server/server/modules/user/user.module.js.map +1 -1
  195. package/dist-server/server/modules/user/user.routes.js +44 -0
  196. package/dist-server/server/modules/user/user.routes.js.map +1 -1
  197. package/dist-server/server/modules/user/user.service.js +75 -0
  198. package/dist-server/server/modules/user/user.service.js.map +1 -1
  199. package/dist-server/server/modules/voice/tests/voice.service.test.js +3 -2
  200. package/dist-server/server/modules/voice/tests/voice.service.test.js.map +1 -1
  201. package/dist-server/server/modules/websocket/index.js +7 -0
  202. package/dist-server/server/modules/websocket/index.js.map +1 -1
  203. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js +10 -44
  204. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js.map +1 -1
  205. package/dist-server/server/modules/websocket/services/chat-session-writer.service.js +35 -5
  206. package/dist-server/server/modules/websocket/services/chat-session-writer.service.js.map +1 -1
  207. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +166 -9
  208. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  209. package/dist-server/server/modules/websocket/services/session-upsert-broadcast.service.js +94 -0
  210. package/dist-server/server/modules/websocket/services/session-upsert-broadcast.service.js.map +1 -0
  211. package/dist-server/server/modules/websocket/services/shell-websocket.service.js +9 -3
  212. package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
  213. package/dist-server/server/modules/websocket/tests/chat-edit-send.test.js +263 -0
  214. package/dist-server/server/modules/websocket/tests/chat-edit-send.test.js.map +1 -0
  215. package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js +29 -3
  216. package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js.map +1 -1
  217. package/dist-server/server/modules/websocket/tests/session-upsert-broadcast.test.js +106 -0
  218. package/dist-server/server/modules/websocket/tests/session-upsert-broadcast.test.js.map +1 -0
  219. package/dist-server/server/modules/websocket/tests/shell-websocket.service.test.js +80 -0
  220. package/dist-server/server/modules/websocket/tests/shell-websocket.service.test.js.map +1 -1
  221. package/dist-server/server/shared/claude-cli-path.js +22 -5
  222. package/dist-server/server/shared/claude-cli-path.js.map +1 -1
  223. package/dist-server/server/shared/index.js +3 -0
  224. package/dist-server/server/shared/index.js.map +1 -0
  225. package/dist-server/server/shared/message-unification.js +376 -0
  226. package/dist-server/server/shared/message-unification.js.map +1 -0
  227. package/dist-server/server/shared/tests/claude-cli-path.test.js +45 -1
  228. package/dist-server/server/shared/tests/claude-cli-path.test.js.map +1 -1
  229. package/dist-server/server/shared/tests/message-unification.test.js +174 -0
  230. package/dist-server/server/shared/tests/message-unification.test.js.map +1 -0
  231. package/dist-server/server/shared/utils.js +34 -0
  232. package/dist-server/server/shared/utils.js.map +1 -1
  233. package/package.json +19 -16
  234. package/server/index.ts +11 -4
  235. package/server/modules/agent/agent.routes.ts +2 -2
  236. package/server/modules/agent/tests/agent.routes.test.ts +33 -1
  237. package/server/modules/browser-use/browser-use.routes.ts +96 -33
  238. package/server/modules/database/connection.ts +2 -1
  239. package/server/modules/database/index.ts +10 -0
  240. package/server/modules/database/migrations.ts +37 -0
  241. package/server/modules/database/repositories/scheduled-messages.db.ts +127 -0
  242. package/server/modules/database/repositories/session-drafts.db.ts +171 -0
  243. package/server/modules/database/repositories/sessions.db.ts +196 -3
  244. package/server/modules/database/repositories/user-preferences.db.ts +80 -0
  245. package/server/modules/database/schema.ts +96 -0
  246. package/server/modules/database/tests/sessions.db.integration.test.ts +52 -0
  247. package/server/modules/database/tests/user-preferences.db.integration.test.ts +135 -0
  248. package/server/modules/projects/services/project-delete.service.ts +13 -1
  249. package/server/modules/projects/services/project-star.service.ts +2 -2
  250. package/server/modules/providers/index.ts +4 -0
  251. package/server/modules/providers/list/claude/claude-auth.provider.ts +21 -1
  252. package/server/modules/providers/list/claude/claude-fork.provider.ts +57 -0
  253. package/server/modules/providers/list/claude/claude-models.provider.ts +38 -6
  254. package/server/modules/providers/list/claude/claude-runtime.provider.js +188 -39
  255. package/server/modules/providers/list/claude/claude-sessions.provider.ts +478 -54
  256. package/server/modules/providers/list/claude/claude-skills.provider.ts +4 -1
  257. package/server/modules/providers/list/claude/claude.provider.ts +3 -0
  258. package/server/modules/providers/list/codex/codex-app-server.client.ts +243 -0
  259. package/server/modules/providers/list/codex/codex-fork.provider.ts +40 -0
  260. package/server/modules/providers/list/codex/codex-models.provider.ts +16 -0
  261. package/server/modules/providers/list/codex/{codex-runtime.provider.js → codex-runtime.provider.ts} +93 -66
  262. package/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +9 -0
  263. package/server/modules/providers/list/codex/codex-sessions.provider.ts +1806 -477
  264. package/server/modules/providers/list/codex/codex.provider.ts +3 -0
  265. package/server/modules/providers/list/cursor/cursor-runtime.provider.js +8 -6
  266. package/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts +0 -1
  267. package/server/modules/providers/list/opencode/opencode-models.provider.ts +279 -6
  268. package/server/modules/providers/provider.routes.ts +31 -0
  269. package/server/modules/providers/services/provider-capabilities.service.ts +23 -0
  270. package/server/modules/providers/services/provider-token-usage.service.ts +166 -39
  271. package/server/modules/providers/services/session-history-cache.service.ts +139 -0
  272. package/server/modules/providers/services/session-synchronizer.service.ts +125 -38
  273. package/server/modules/providers/services/sessions-watcher.service.ts +3 -64
  274. package/server/modules/providers/services/sessions.service.ts +201 -9
  275. package/server/modules/providers/tests/claude-auth.test.ts +58 -0
  276. package/server/modules/providers/tests/claude-models.test.ts +87 -0
  277. package/server/modules/providers/tests/claude-sessions.test.ts +552 -40
  278. package/server/modules/providers/tests/claude-subagent-echo.test.ts +25 -0
  279. package/server/modules/providers/tests/claude-token-budget.test.ts +104 -0
  280. package/server/modules/providers/tests/codex-message-editing.test.ts +419 -0
  281. package/server/modules/providers/tests/codex-runtime.test.ts +58 -0
  282. package/server/modules/providers/tests/codex-session-fork.test.ts +59 -0
  283. package/server/modules/providers/tests/codex-sessions.test.ts +337 -15
  284. package/server/modules/providers/tests/opencode-models.test.ts +166 -8
  285. package/server/modules/providers/tests/provider-token-usage.service.test.ts +188 -1
  286. package/server/modules/providers/tests/session-fork.test.ts +173 -0
  287. package/server/modules/providers/tests/session-history-cache.service.test.ts +143 -0
  288. package/server/modules/providers/tests/session-orphan-prune.test.ts +107 -0
  289. package/server/modules/providers/tests/session-synchronizer-coalescing.test.ts +136 -0
  290. package/server/modules/providers/tests/sessions.service.test.ts +67 -1
  291. package/server/modules/providers/tests/skills.test.ts +21 -5
  292. package/server/modules/scheduled-messages/index.ts +8 -0
  293. package/server/modules/scheduled-messages/scheduled-messages.routes.ts +68 -0
  294. package/server/modules/scheduled-messages/services/scheduled-message-dispatcher.service.ts +203 -0
  295. package/server/modules/scheduled-messages/services/scheduled-messages.service.ts +121 -0
  296. package/server/modules/scheduled-messages/tests/scheduled-messages.test.ts +310 -0
  297. package/server/modules/taskmaster/taskmaster.routes.ts +23 -33
  298. package/server/modules/taskmaster/tests/taskmaster.routes.test.ts +57 -0
  299. package/server/modules/user/tests/user.service.test.ts +90 -0
  300. package/server/modules/user/user.module.ts +10 -1
  301. package/server/modules/user/user.routes.ts +44 -0
  302. package/server/modules/user/user.service.ts +105 -0
  303. package/server/modules/voice/tests/voice.service.test.ts +3 -2
  304. package/server/modules/websocket/index.ts +8 -0
  305. package/server/modules/websocket/services/chat-run-registry.service.ts +17 -50
  306. package/server/modules/websocket/services/chat-session-writer.service.ts +36 -6
  307. package/server/modules/websocket/services/chat-websocket.service.ts +244 -15
  308. package/server/modules/websocket/services/session-upsert-broadcast.service.ts +108 -0
  309. package/server/modules/websocket/services/shell-websocket.service.ts +10 -3
  310. package/server/modules/websocket/tests/chat-edit-send.test.ts +309 -0
  311. package/server/modules/websocket/tests/chat-run-registry.test.ts +34 -3
  312. package/server/modules/websocket/tests/session-upsert-broadcast.test.ts +138 -0
  313. package/server/modules/websocket/tests/shell-websocket.service.test.ts +108 -0
  314. package/server/shared/claude-cli-path.ts +24 -6
  315. package/server/shared/index.ts +3 -0
  316. package/server/shared/interfaces.ts +62 -0
  317. package/server/shared/message-unification.ts +449 -0
  318. package/server/shared/tests/claude-cli-path.test.ts +58 -1
  319. package/server/shared/tests/message-unification.test.ts +218 -0
  320. package/server/shared/types.ts +123 -2
  321. package/server/shared/utils.ts +40 -0
  322. package/dist/assets/channel-_falYbU1.js +0 -1
  323. package/dist/assets/classDiagram-JCYQIIEL-Dn-3CfpR.js +0 -1
  324. package/dist/assets/classDiagram-v2-OCEON4UE-Dn-3CfpR.js +0 -1
  325. package/dist/assets/index-CzPLBvXT.css +0 -32
  326. package/dist/assets/index-DFkBBuSA.js +0 -1549
  327. package/dist/assets/stateDiagram-v2-UEYNNEHI-BvBxUUrZ.js +0 -1
  328. package/dist/assets/swimlanesDiagram-ULZ7WXOC-CsE-x4YK.js +0 -8
@@ -1,24 +1,61 @@
1
1
  import fsSync from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
2
4
  import readline from 'node:readline';
3
5
 
4
6
  import { sessionsDb } from '@/modules/database/index.js';
7
+ import { codexAppServer } from '@/modules/providers/list/codex/codex-app-server.client.js';
5
8
  import { parseFilesInputTag, toImageAttachments } from '@/shared/image-attachments.js';
6
9
  import type { IProviderSessions } from '@/shared/interfaces.js';
7
- import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
8
- import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
10
+ import { prepareTranscriptMessages } from '@/shared/message-unification.js';
11
+ import type {
12
+ AnyRecord,
13
+ FetchHistoryOptions,
14
+ FetchHistoryResult,
15
+ MemoryCitation,
16
+ NormalizedMessage,
17
+ SubagentActivity,
18
+ SubagentInfo,
19
+ } from '@/shared/types.js';
20
+ import {
21
+ AppError,
22
+ createNormalizedMessage,
23
+ generateMessageId,
24
+ readObjectRecord,
25
+ sliceTailPage,
26
+ truncateSubagentActivity,
27
+ } from '@/shared/utils.js';
9
28
 
10
29
  const PROVIDER = 'codex';
11
30
 
12
- type CodexHistoryResult =
13
- | AnyRecord[]
14
- | {
15
- messages?: AnyRecord[];
16
- total?: number;
17
- hasMore?: boolean;
18
- offset?: number;
19
- limit?: number | null;
20
- tokenUsage?: unknown;
21
- };
31
+ /**
32
+ * How far up the `~/.codex/sessions/<year>/<month>/<day>` tree a subagent
33
+ * rollout is searched for when it is not next to its parent. Spawned agents
34
+ * are written seconds after the parent, so one directory level up (the month)
35
+ * already covers a run that crosses midnight.
36
+ */
37
+ const SUBAGENT_LOOKUP_PARENT_LEVELS = 2;
38
+
39
+ /**
40
+ * Upper bound on how much of a subagent's timeline is sent to the client. A
41
+ * long-running agent can record hundreds of tool calls, and the transcript only
42
+ * ever shows them behind a collapsed header, so shipping the whole history on
43
+ * every load costs far more than it shows.
44
+ */
45
+ const MAX_TRANSMITTED_SUBAGENT_ACTIVITIES = 200;
46
+
47
+ type CodexHistoryResult = {
48
+ messages: AnyRecord[];
49
+ total?: number;
50
+ hasMore?: boolean;
51
+ offset?: number;
52
+ limit?: number | null;
53
+ tokenUsage?: unknown;
54
+ };
55
+
56
+ function readNonEmptyString(value: unknown): string | undefined {
57
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
58
+ }
22
59
 
23
60
  function isVisibleCodexUserMessage(payload: AnyRecord | null | undefined): boolean {
24
61
  if (!payload || payload.type !== 'user_message') {
@@ -32,6 +69,96 @@ function isVisibleCodexUserMessage(payload: AnyRecord | null | undefined): boole
32
69
  return typeof payload.message === 'string' && payload.message.trim().length > 0;
33
70
  }
34
71
 
72
+ /**
73
+ * Follows which turn a Codex rollout is inside as its rows stream past.
74
+ *
75
+ * Codex writes no per-row id — every line is `{timestamp, type, payload}` and
76
+ * the `user_message` event that carries a prompt has no identity of its own —
77
+ * so a turn id is the only durable address a user message has. Turns are
78
+ * bracketed by rows that do carry `turn_id`: `task_started` opens one,
79
+ * `turn_context` restates it, `task_complete` closes it, and the prompt is
80
+ * written between them. That id is also exactly what `thread/fork` cuts at,
81
+ * which is what makes "edit this message" and "fork from here" addressable
82
+ * for Codex at all.
83
+ *
84
+ * `thread_rolled_back` retires the last N turns in place: the rows stay in the
85
+ * file, but the turns are no longer part of the thread and the fork endpoint
86
+ * refuses to cut at one. They are tracked here so a retired turn is never
87
+ * offered as an anchor, rather than discovered when the fork fails.
88
+ */
89
+ function createCodexTurnTracker() {
90
+ /** Turn ids still part of the conversation, oldest first. */
91
+ const liveTurnIds: string[] = [];
92
+ const seenTurnIds = new Set<string>();
93
+ const rolledBackTurnIds = new Set<string>();
94
+ let currentTurnId: string | undefined;
95
+
96
+ return {
97
+ /** Feeds one rollout row in. */
98
+ observe(entryType: unknown, payload: AnyRecord): void {
99
+ if (entryType === 'event_msg' && payload.type === 'thread_rolled_back') {
100
+ const retiredTurns = Number(payload.num_turns);
101
+ for (let retired = 0; retired < retiredTurns && liveTurnIds.length > 0; retired++) {
102
+ rolledBackTurnIds.add(liveTurnIds.pop() as string);
103
+ }
104
+ currentTurnId = undefined;
105
+ return;
106
+ }
107
+
108
+ const turnId = readNonEmptyString(payload.turn_id);
109
+ if (!turnId) {
110
+ return;
111
+ }
112
+ currentTurnId = turnId;
113
+ if (!seenTurnIds.has(turnId)) {
114
+ seenTurnIds.add(turnId);
115
+ liveTurnIds.push(turnId);
116
+ }
117
+ },
118
+ /** The turn the rows being read belong to, or undefined outside one. */
119
+ getCurrentTurnId(): string | undefined {
120
+ return currentTurnId;
121
+ },
122
+ getLiveTurnIds(): string[] {
123
+ return liveTurnIds;
124
+ },
125
+ isRolledBack(turnId: string): boolean {
126
+ return rolledBackTurnIds.has(turnId);
127
+ },
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Reads the turns a Codex rollout still contains, oldest first.
133
+ *
134
+ * A separate pass rather than a by-product of the transcript reader: this runs
135
+ * once when a message is edited, while the reader runs on every history fetch,
136
+ * and both share the one rule for what a live turn is.
137
+ */
138
+ async function readCodexLiveTurnIds(filePath: string): Promise<string[]> {
139
+ const turns = createCodexTurnTracker();
140
+ const stream = fsSync.createReadStream(filePath);
141
+ const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
142
+
143
+ for await (const line of lines) {
144
+ if (!line.trim()) {
145
+ continue;
146
+ }
147
+ let entry: AnyRecord;
148
+ try {
149
+ entry = JSON.parse(line) as AnyRecord;
150
+ } catch {
151
+ continue;
152
+ }
153
+ const payload = readObjectRecord(entry.payload);
154
+ if (payload) {
155
+ turns.observe(entry.type, payload);
156
+ }
157
+ }
158
+
159
+ return turns.getLiveTurnIds();
160
+ }
161
+
35
162
  /**
36
163
  * Reads the image attachments Codex records on `user_message` events.
37
164
  * Turns sent with `local_image` input items land in `local_images` as file
@@ -93,6 +220,78 @@ function extractCodexTextContent(content: unknown): string {
93
220
  .join('\n');
94
221
  }
95
222
 
223
+ /**
224
+ * Reads the markdown out of Codex's `<proposed_plan>` envelope.
225
+ *
226
+ * Codex delivers a plan as a wrapped assistant message while Claude delivers
227
+ * one as an `ExitPlanMode` call. Recognizing the envelope here lets both end up
228
+ * on the same plan card instead of one provider showing a card and the other
229
+ * showing prose with a stray tag in it. The closing tag is optional because a
230
+ * streamed plan exposes the opening tag first.
231
+ *
232
+ * Exported for tests.
233
+ */
234
+ export function readCodexProposedPlan(text: string): string | null {
235
+ const openingTag = /^\s*<proposed_plan>[ \t]*(?:\r?\n)?/i;
236
+ if (!openingTag.test(text)) {
237
+ return null;
238
+ }
239
+
240
+ const body = text
241
+ .replace(openingTag, '')
242
+ .replace(/(?:\r?\n)?[ \t]*<\/proposed_plan>\s*$/i, '')
243
+ .trim();
244
+
245
+ return body || null;
246
+ }
247
+
248
+ /**
249
+ * The block Codex appends to a reply that leaned on its memory files. It is
250
+ * always the last thing in the message and is meant for programmatic parsing,
251
+ * so it reads as raw XML when left in the prose.
252
+ */
253
+ const CODEX_MEMORY_CITATION_BLOCK = /<oai-mem-citation>([\s\S]*?)<\/oai-mem-citation>\s*$/i;
254
+
255
+ /**
256
+ * Lifts Codex's memory citations out of an assistant reply.
257
+ *
258
+ * The block names the memory files and line ranges the answer drew on, which is
259
+ * worth showing — but as a footnote under the reply, not as markup inside it.
260
+ * The trailing `<rollout_ids>` list is dropped: those are handles to earlier
261
+ * rollouts that the transcript view has no way to open.
262
+ *
263
+ * Exported for tests.
264
+ */
265
+ export function readCodexMemoryCitations(text: string): { text: string; memoryCitations?: MemoryCitation[] } {
266
+ const block = CODEX_MEMORY_CITATION_BLOCK.exec(text);
267
+ if (!block) {
268
+ return { text };
269
+ }
270
+
271
+ const entries = /<citation_entries>([\s\S]*?)<\/citation_entries>/i.exec(block[1])?.[1] ?? '';
272
+ const memoryCitations: MemoryCitation[] = [];
273
+ for (const line of entries.split('\n')) {
274
+ const entry = line.trim();
275
+ if (!entry) {
276
+ continue;
277
+ }
278
+
279
+ const noteMarker = entry.indexOf('|note=');
280
+ const source = (noteMarker === -1 ? entry : entry.slice(0, noteMarker)).trim();
281
+ if (!source) {
282
+ continue;
283
+ }
284
+
285
+ const note = noteMarker === -1
286
+ ? ''
287
+ : entry.slice(noteMarker + '|note='.length).trim().replace(/^\[/, '').replace(/\]$/, '').trim();
288
+ memoryCitations.push(note ? { source, note } : { source });
289
+ }
290
+
291
+ const prose = text.slice(0, block.index).trimEnd();
292
+ return memoryCitations.length > 0 ? { text: prose, memoryCitations } : { text: prose };
293
+ }
294
+
96
295
  function extractCodexToolOutput(output: unknown): string {
97
296
  if (typeof output === 'string') {
98
297
  return output;
@@ -111,19 +310,30 @@ function extractCodexToolOutput(output: unknown): string {
111
310
  .join('');
112
311
  }
113
312
 
114
- function readRunningExecOutput(output: string): { cellId: string; content: string } | null {
115
- const runningCell = /Script running with cell ID\s+(\S+)/i.exec(output);
116
- if (!runningCell) {
117
- return null;
118
- }
313
+ // ─── Codex "code mode" exec scripts ─────────────────────────────────────────
314
+ // Newer Codex builds (and every Sol-family model) stop calling shell/patch
315
+ // tools directly. Instead they emit one `exec` custom tool call whose input is
316
+ // a small JavaScript program that awaits `tools.<name>({...})`. Persisting that
317
+ // program verbatim is what makes the transcript read as raw machinery, so it is
318
+ // parsed back into the operations it performs.
119
319
 
120
- const outputMarker = /\r?\nOutput:\r?\n/i.exec(output);
121
- return {
122
- cellId: runningCell[1],
123
- content: outputMarker ? output.slice((outputMarker.index || 0) + outputMarker[0].length) : '',
124
- };
125
- }
320
+ type CodexExecOperation =
321
+ | { kind: 'shell'; command: string; justification?: string }
322
+ | { kind: 'stdin'; chars: string }
323
+ | { kind: 'patch'; patch: string }
324
+ | { kind: 'search'; queries: string[] }
325
+ | { kind: 'plan'; todos: CodexPlanTodo[] };
326
+
327
+ /** One step of a checklist, in the shape the shared todo renderer consumes. */
328
+ type CodexPlanTodo = { content: string; status: string };
126
329
 
330
+ /**
331
+ * Decodes one JavaScript string literal (single, double or backtick quoted).
332
+ *
333
+ * Template literals with `${}` interpolation are returned with the expression
334
+ * text intact — the transcript has no bindings to evaluate, and showing the
335
+ * raw template is still far more readable than the surrounding program.
336
+ */
127
337
  function decodeJavaScriptStringLiteral(literal: string): string {
128
338
  if (literal.startsWith('"')) {
129
339
  try {
@@ -141,49 +351,789 @@ function decodeJavaScriptStringLiteral(literal: string): string {
141
351
  .replace(/\\([\\'`])/g, '$1');
142
352
  }
143
353
 
144
- function extractNestedCodexCommands(source: string): string[] {
354
+ /**
355
+ * Returns the source of the argument list of the call that opens at
356
+ * `openParenIndex`, skipping over parens that appear inside string literals.
357
+ *
358
+ * A plain regex cannot be used here: shell commands routinely embed `(`/`)`
359
+ * and quotes, so the argument object has to be found by balancing.
360
+ */
361
+ function readCallArguments(source: string, openParenIndex: number): string | null {
362
+ let depth = 0;
363
+ let quote: string | null = null;
364
+
365
+ for (let index = openParenIndex; index < source.length; index += 1) {
366
+ const character = source[index];
367
+
368
+ if (quote) {
369
+ if (character === '\\') {
370
+ index += 1;
371
+ continue;
372
+ }
373
+ if (character === quote) {
374
+ quote = null;
375
+ }
376
+ continue;
377
+ }
378
+
379
+ if (character === '"' || character === "'" || character === '`') {
380
+ quote = character;
381
+ continue;
382
+ }
383
+
384
+ if (character === '(') {
385
+ depth += 1;
386
+ continue;
387
+ }
388
+
389
+ if (character === ')') {
390
+ depth -= 1;
391
+ if (depth === 0) {
392
+ return source.slice(openParenIndex + 1, index);
393
+ }
394
+ }
395
+ }
396
+
397
+ return null;
398
+ }
399
+
400
+ /** Reads `key: "value"` / `"key": "value"` out of an object-literal source. */
401
+ function readStringProperty(objectSource: string, key: string): string | undefined {
402
+ const pattern = new RegExp(
403
+ `(?:["']${key}["']|\\b${key})\\s*:\\s*("(?:\\\\.|[^"\\\\])*"|'(?:\\\\.|[^'\\\\])*'|\`(?:\\\\.|[^\`\\\\])*\`)`,
404
+ 's',
405
+ );
406
+ const match = pattern.exec(objectSource);
407
+ return match ? decodeJavaScriptStringLiteral(match[1]) : undefined;
408
+ }
409
+
410
+ /** Reads every `q: "..."` entry out of a `web__run` search-query array. */
411
+ function readSearchQueries(objectSource: string): string[] {
412
+ const pattern = /(?:["']q["']|\bq)\s*:\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)/gs;
413
+ return Array.from(objectSource.matchAll(pattern), (match) => decodeJavaScriptStringLiteral(match[1]));
414
+ }
415
+
416
+ /**
417
+ * Finds the patch body an `apply_patch` call is given.
418
+ *
419
+ * The program almost always assigns it to a local first
420
+ * (`const patch = "*** Begin Patch..."; tools.apply_patch(patch)`), so the
421
+ * argument itself is usually an identifier. Scanning the whole script for the
422
+ * literal that carries the patch header covers both forms.
423
+ */
424
+ function readPatchLiteral(source: string): string | undefined {
425
+ const pattern = /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)/gs;
426
+ for (const match of source.matchAll(pattern)) {
427
+ const decoded = decodeJavaScriptStringLiteral(match[1]);
428
+ if (decoded.includes('*** Begin Patch')) {
429
+ return decoded;
430
+ }
431
+ }
432
+ return undefined;
433
+ }
434
+
435
+ /**
436
+ * Reads the steps of an `update_plan` call made from an exec script.
437
+ *
438
+ * The script writes its argument as a JavaScript object literal with unquoted
439
+ * keys, so it is not JSON — each `{step, status}` entry is matched directly.
440
+ */
441
+ function readPlanSteps(objectSource: string): CodexPlanTodo[] {
442
+ const todos: CodexPlanTodo[] = [];
443
+ for (const entry of objectSource.matchAll(/\{([^{}]*)\}/g)) {
444
+ const content = readStringProperty(entry[1], 'step') ?? readStringProperty(entry[1], 'content');
445
+ if (content) {
446
+ todos.push({ content, status: readStringProperty(entry[1], 'status') ?? 'pending' });
447
+ }
448
+ }
449
+
450
+ return todos;
451
+ }
452
+
453
+ /**
454
+ * Recovers commands a script keeps in a local array and maps over, which is how
455
+ * Codex batches several shell calls:
456
+ * `const cmds = ["a", "b"]; await Promise.all(cmds.map(command => tools.shell_command({ command })))`
457
+ * The call site then passes `{ command }` shorthand, so there is no literal to
458
+ * read at the call itself.
459
+ */
460
+ function readMappedCommandCandidates(source: string): string[] {
145
461
  const commands: string[] = [];
146
- const commandPattern = /(?:["']command["']|\bcommand)\s*:\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)/gs;
147
- for (const match of source.matchAll(commandPattern)) {
148
- commands.push(decodeJavaScriptStringLiteral(match[1]));
462
+ const arrayPattern = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*\[([\s\S]*?)\]\s*;/g;
463
+ const stringPattern = /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)/gs;
464
+
465
+ for (const arrayMatch of source.matchAll(arrayPattern)) {
466
+ if (!new RegExp(`\\b${arrayMatch[1]}\\.map\\s*\\(`).test(source)) {
467
+ continue;
468
+ }
469
+ for (const stringMatch of arrayMatch[2].matchAll(stringPattern)) {
470
+ commands.push(decodeJavaScriptStringLiteral(stringMatch[1]));
471
+ }
472
+ }
473
+
474
+ return commands;
475
+ }
476
+
477
+ /**
478
+ * Turns one Codex `exec` script into the operations it performs, in source
479
+ * order. An empty result means the script did nothing recognizable and the
480
+ * caller should fall back to showing it as code.
481
+ *
482
+ * Exported for tests.
483
+ */
484
+ export function parseCodexExecScript(input: unknown): CodexExecOperation[] {
485
+ const source = typeof input === 'string' ? input : String(input ?? '');
486
+ if (!source.includes('tools.')) {
487
+ return [];
488
+ }
489
+
490
+ const operations: CodexExecOperation[] = [];
491
+ const callPattern = /tools\.([A-Za-z_0-9]+)\s*\(/g;
492
+ /** Filled in lazily, and only once, for shorthand-argument shell calls. */
493
+ let mappedCommands: string[] | null = null;
494
+
495
+ for (const match of source.matchAll(callPattern)) {
496
+ const toolName = match[1];
497
+ const openParenIndex = match.index + match[0].length - 1;
498
+ const argumentSource = readCallArguments(source, openParenIndex) ?? '';
499
+
500
+ switch (toolName) {
501
+ case 'shell_command':
502
+ case 'exec_command': {
503
+ const command = readStringProperty(argumentSource, 'command')
504
+ ?? readStringProperty(argumentSource, 'cmd');
505
+ if (command) {
506
+ operations.push({
507
+ kind: 'shell',
508
+ command,
509
+ justification: readStringProperty(argumentSource, 'justification'),
510
+ });
511
+ break;
512
+ }
513
+
514
+ if (mappedCommands === null) {
515
+ mappedCommands = readMappedCommandCandidates(source);
516
+ for (const mappedCommand of mappedCommands) {
517
+ operations.push({ kind: 'shell', command: mappedCommand });
518
+ }
519
+ }
520
+ break;
521
+ }
522
+ case 'write_stdin': {
523
+ operations.push({ kind: 'stdin', chars: readStringProperty(argumentSource, 'chars') ?? '' });
524
+ break;
525
+ }
526
+ case 'apply_patch': {
527
+ const patch = readPatchLiteral(argumentSource) ?? readPatchLiteral(source);
528
+ if (patch) {
529
+ operations.push({ kind: 'patch', patch });
530
+ }
531
+ break;
532
+ }
533
+ case 'web__run':
534
+ case 'web_search': {
535
+ const queries = readSearchQueries(argumentSource);
536
+ if (queries.length > 0) {
537
+ operations.push({ kind: 'search', queries });
538
+ }
539
+ break;
540
+ }
541
+ case 'update_plan': {
542
+ const todos = readPlanSteps(argumentSource);
543
+ if (todos.length > 0) {
544
+ operations.push({ kind: 'plan', todos });
545
+ }
546
+ break;
547
+ }
548
+ default:
549
+ break;
550
+ }
149
551
  }
150
552
 
151
- if (commands.length === 0) {
152
- const arrayPattern = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*\[([\s\S]*?)\]\s*;/g;
153
- const stringPattern = /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)/g;
553
+ return operations;
554
+ }
555
+
556
+ // ─── Codex shell output ─────────────────────────────────────────────────────
557
+
558
+ type CodexShellOutput = {
559
+ output: string;
560
+ /** Set when the command has exited; `undefined` while it is still running. */
561
+ exitCode?: number;
562
+ /** Present while the command runs in the background under this cell id. */
563
+ runningCellId?: string;
564
+ truncated: boolean;
565
+ };
566
+
567
+ /**
568
+ * Strips the two nested envelopes Codex wraps shell output in.
569
+ *
570
+ * The outer one reports on the sandbox *script* (`Script completed`, its wall
571
+ * time, and `Output:`); the inner one reports on the *command* (`Exit code`,
572
+ * its own wall time, an optional line count). Neither is useful in a chat
573
+ * transcript, but the exit code decides whether the row is shown as failed, so
574
+ * it is lifted out before the text is discarded.
575
+ *
576
+ * Exported for tests.
577
+ */
578
+ export function cleanCodexShellOutput(rawOutput: string): CodexShellOutput {
579
+ let output = rawOutput;
580
+ let exitCode: number | undefined;
581
+ let runningCellId: string | undefined;
582
+ let truncated = false;
583
+
584
+ const runningMatch = /^Script running with cell ID\s+(\S+)[^\n]*\n?/i.exec(output);
585
+ if (runningMatch) {
586
+ runningCellId = runningMatch[1];
587
+ output = output.slice(runningMatch[0].length);
588
+ } else {
589
+ output = output.replace(/^Script (?:completed|failed)[^\n]*\n?/i, '');
590
+ }
591
+
592
+ output = output.replace(/^Wall time[^\n]*\n?/i, '');
593
+ output = output.replace(/^Output:\s*\n?/i, '');
594
+ output = output.replace(/^Script error:\s*\n?/i, '');
595
+
596
+ const truncationMatch = /^Warning: truncated output[^\n]*\n(?:Total output lines:[^\n]*\n)?\n?/i.exec(output);
597
+ if (truncationMatch) {
598
+ truncated = true;
599
+ output = output.slice(truncationMatch[0].length);
600
+ output = output.replace(/^Script error:\s*\n?/i, '');
601
+ }
602
+
603
+ const exitCodeMatch = /^Exit code:\s*(-?\d+)[^\n]*\n?/i.exec(output);
604
+ if (exitCodeMatch) {
605
+ exitCode = Number.parseInt(exitCodeMatch[1], 10);
606
+ output = output.slice(exitCodeMatch[0].length);
607
+ output = output.replace(/^Wall time:[^\n]*\n?/i, '');
608
+ output = output.replace(/^Total output lines:[^\n]*\n?/i, '');
609
+ output = output.replace(/^Output:\s*\n?/i, '');
610
+ }
611
+
612
+ return { output, exitCode, runningCellId, truncated };
613
+ }
614
+
615
+ /**
616
+ * `write_stdin` answers with a JSON envelope rather than plain text. Only its
617
+ * `output` and `exit_code` matter to a reader.
618
+ */
619
+ function readCodexStdinOutput(rawOutput: string): CodexShellOutput | null {
620
+ const trimmed = rawOutput.trim();
621
+ const jsonStart = trimmed.indexOf('{');
622
+ if (jsonStart === -1) {
623
+ return null;
624
+ }
625
+
626
+ try {
627
+ const parsed = JSON.parse(trimmed.slice(jsonStart)) as AnyRecord;
628
+ if (typeof parsed.output !== 'string') {
629
+ return null;
630
+ }
631
+ return {
632
+ output: parsed.output,
633
+ exitCode: typeof parsed.exit_code === 'number' ? parsed.exit_code : undefined,
634
+ truncated: false,
635
+ };
636
+ } catch {
637
+ return null;
638
+ }
639
+ }
640
+
641
+ // ─── Unified patches ────────────────────────────────────────────────────────
642
+
643
+ type CodexFileChange = {
644
+ filePath: string;
645
+ /** `add` and `delete` are rendered as a whole-file write/removal. */
646
+ changeType: 'add' | 'update' | 'delete';
647
+ oldText: string;
648
+ newText: string;
649
+ };
154
650
 
155
- for (const arrayMatch of source.matchAll(arrayPattern)) {
156
- const arrayName = arrayMatch[1];
157
- if (!new RegExp(`\\b${arrayName}\\.map\\s*\\(`).test(source)) {
651
+ /**
652
+ * Rebuilds the before/after text of one unified diff.
653
+ *
654
+ * Only the hunks are available — never the whole file — so the reconstruction
655
+ * keeps context lines in both sides and drops hunk headers. Feeding that pair
656
+ * to the frontend's line differ reproduces exactly the additions, removals and
657
+ * surrounding context the patch described, which is what Claude's `Edit` rows
658
+ * already show.
659
+ *
660
+ * Exported for tests.
661
+ */
662
+ export function unifiedDiffToTexts(unifiedDiff: string): { oldText: string; newText: string } {
663
+ const oldLines: string[] = [];
664
+ const newLines: string[] = [];
665
+
666
+ for (const line of unifiedDiff.split('\n')) {
667
+ if (line.startsWith('@@') || line.startsWith('\\ No newline')) {
668
+ continue;
669
+ }
670
+ if (line.startsWith('+')) {
671
+ newLines.push(line.slice(1));
672
+ continue;
673
+ }
674
+ if (line.startsWith('-')) {
675
+ oldLines.push(line.slice(1));
676
+ continue;
677
+ }
678
+ const contextLine = line.startsWith(' ') ? line.slice(1) : line;
679
+ oldLines.push(contextLine);
680
+ newLines.push(contextLine);
681
+ }
682
+
683
+ // A unified diff ends with a newline, which would otherwise reconstruct as a
684
+ // phantom trailing blank line on both sides.
685
+ if (oldLines[oldLines.length - 1] === '' && newLines[newLines.length - 1] === '') {
686
+ oldLines.pop();
687
+ newLines.pop();
688
+ }
689
+
690
+ return { oldText: oldLines.join('\n'), newText: newLines.join('\n') };
691
+ }
692
+
693
+ /**
694
+ * Splits a `*** Begin Patch` body into one change per file.
695
+ *
696
+ * Exported for tests.
697
+ */
698
+ export function parseCodexPatch(patch: string): CodexFileChange[] {
699
+ const changes: CodexFileChange[] = [];
700
+ const lines = patch.split('\n');
701
+ let current: { filePath: string; changeType: CodexFileChange['changeType']; body: string[] } | null = null;
702
+
703
+ const flush = () => {
704
+ if (!current) {
705
+ return;
706
+ }
707
+ const { oldText, newText } = unifiedDiffToTexts(current.body.join('\n'));
708
+ changes.push({
709
+ filePath: current.filePath,
710
+ changeType: current.changeType,
711
+ oldText: current.changeType === 'add' ? '' : oldText,
712
+ newText: current.changeType === 'delete' ? '' : newText,
713
+ });
714
+ current = null;
715
+ };
716
+
717
+ for (const line of lines) {
718
+ const fileHeader = /^\*\*\* (Add|Update|Delete) File:\s*(.+)$/.exec(line);
719
+ if (fileHeader) {
720
+ flush();
721
+ const changeType = fileHeader[1].toLowerCase() as CodexFileChange['changeType'];
722
+ current = { filePath: fileHeader[2].trim(), changeType, body: [] };
723
+ continue;
724
+ }
725
+
726
+ if (line.startsWith('*** ')) {
727
+ // `*** Begin Patch`, `*** End Patch`, `*** Move to:` — never diff content.
728
+ continue;
729
+ }
730
+
731
+ current?.body.push(line);
732
+ }
733
+
734
+ flush();
735
+ return changes;
736
+ }
737
+
738
+ /** Converts a `patch_apply_end` change map into the same per-file shape. */
739
+ function readPatchApplyChanges(changes: unknown): CodexFileChange[] {
740
+ const record = readObjectRecord(changes);
741
+ if (!record) {
742
+ return [];
743
+ }
744
+
745
+ const parsed: CodexFileChange[] = [];
746
+ for (const [filePath, rawChange] of Object.entries(record)) {
747
+ const change = readObjectRecord(rawChange);
748
+ if (!change) {
749
+ continue;
750
+ }
751
+ const changeType = change.type === 'add' || change.type === 'delete' ? change.type : 'update';
752
+ const { oldText, newText } = unifiedDiffToTexts(
753
+ typeof change.unified_diff === 'string' ? change.unified_diff : '',
754
+ );
755
+ parsed.push({
756
+ filePath,
757
+ changeType,
758
+ oldText: changeType === 'add' ? '' : oldText,
759
+ newText: changeType === 'delete' ? '' : newText,
760
+ });
761
+ }
762
+
763
+ return parsed;
764
+ }
765
+
766
+ /** Maps a file change onto the tool the frontend already renders diffs for. */
767
+ function fileChangeToToolMessage(change: CodexFileChange, timestamp: string, toolCallId: string): AnyRecord {
768
+ const toolName = change.changeType === 'add' ? 'Write' : 'Edit';
769
+ return {
770
+ type: 'tool_use',
771
+ timestamp,
772
+ toolName,
773
+ toolInput: JSON.stringify({
774
+ file_path: change.filePath,
775
+ old_string: change.oldText,
776
+ new_string: change.newText,
777
+ ...(change.changeType === 'delete' ? { deleted: true } : {}),
778
+ }),
779
+ toolCallId,
780
+ };
781
+ }
782
+
783
+ /** Reads the `file_path` back out of a rendered file-change row. */
784
+ function readRowFilePath(row: AnyRecord): string {
785
+ try {
786
+ return String((JSON.parse(String(row.toolInput ?? '{}')) as AnyRecord).file_path ?? '');
787
+ } catch {
788
+ return '';
789
+ }
790
+ }
791
+
792
+ /**
793
+ * Rewrites the rows reconstructed from a patch call's input with the
794
+ * authoritative diffs Codex reports once the patch has been applied.
795
+ *
796
+ * Rows are matched by path, never by position: `patch_apply_end` reports the
797
+ * touched files in map order, which does not follow the order they appeared in
798
+ * the patch body. Rows whose file the report does not mention are dropped
799
+ * rather than left behind claiming an edit that never happened.
800
+ */
801
+ function rewritePatchRows(
802
+ rows: AnyRecord[],
803
+ changes: CodexFileChange[],
804
+ callId: string,
805
+ messages: AnyRecord[],
806
+ ): void {
807
+ const rowsByPath = new Map<string, AnyRecord>();
808
+ for (const row of rows) {
809
+ rowsByPath.set(readRowFilePath(row), row);
810
+ }
811
+
812
+ const keptRows: AnyRecord[] = [];
813
+ for (const [index, change] of changes.entries()) {
814
+ const replacement = fileChangeToToolMessage(change, String(rows[0]?.timestamp ?? ''), `${callId}#${index}`);
815
+ const existing = rowsByPath.get(change.filePath);
816
+ if (existing) {
817
+ existing.toolName = replacement.toolName;
818
+ existing.toolInput = replacement.toolInput;
819
+ keptRows.push(existing);
820
+ rowsByPath.delete(change.filePath);
821
+ continue;
822
+ }
823
+
824
+ replacement.timestamp = rows[0]?.timestamp ?? replacement.timestamp;
825
+ keptRows.push(replacement);
826
+ messages.push(replacement);
827
+ }
828
+
829
+ for (const surplus of rowsByPath.values()) {
830
+ const position = messages.indexOf(surplus);
831
+ if (position !== -1) {
832
+ messages.splice(position, 1);
833
+ }
834
+ }
835
+
836
+ rows.splice(0, rows.length, ...keptRows);
837
+ }
838
+
839
+ // ─── Subagents ──────────────────────────────────────────────────────────────
840
+
841
+ type CodexSubagentRecord = {
842
+ toolCallId: string;
843
+ message: AnyRecord;
844
+ agentPath?: string;
845
+ agentThreadId?: string;
846
+ isComplete: boolean;
847
+ };
848
+
849
+ function parseCodexSubagentMessage(payload: AnyRecord): {
850
+ author: string;
851
+ messageType: string;
852
+ result: string;
853
+ } | null {
854
+ const text = extractCodexTextContent(payload.content);
855
+ const header = /Message Type:\s*([^\r\n]+)[\s\S]*?Sender:\s*([^\r\n]+)[\s\S]*?Payload:\s*\r?\n([\s\S]*)/i.exec(text);
856
+ const author = readNonEmptyString(payload.author) || header?.[2]?.trim();
857
+ if (!author) {
858
+ return null;
859
+ }
860
+
861
+ return {
862
+ author,
863
+ messageType: header?.[1]?.trim().toUpperCase() || 'MESSAGE',
864
+ result: header?.[3]?.trim() || '',
865
+ };
866
+ }
867
+
868
+ /**
869
+ * Codex names a spawned agent's rollout file `rollout-<timestamp>-<threadId>`
870
+ * and writes it next to the parent's. Look there first, then widen to the
871
+ * enclosing day/month directories so a run that crosses midnight still
872
+ * resolves, and give up rather than walking the whole archive.
873
+ */
874
+ async function findCodexSubagentRollout(
875
+ parentFilePath: string,
876
+ agentThreadId: string,
877
+ ): Promise<string | null> {
878
+ const suffix = `-${agentThreadId}.jsonl`;
879
+ let directory = path.dirname(parentFilePath);
880
+
881
+ for (let level = 0; level <= SUBAGENT_LOOKUP_PARENT_LEVELS; level += 1) {
882
+ const match = await findFileWithSuffix(directory, suffix, level === 0 ? 0 : level);
883
+ if (match) {
884
+ return match;
885
+ }
886
+ const parent = path.dirname(directory);
887
+ if (parent === directory) {
888
+ break;
889
+ }
890
+ directory = parent;
891
+ }
892
+
893
+ return null;
894
+ }
895
+
896
+ /** Depth-limited search for a file whose name ends with `suffix`. */
897
+ async function findFileWithSuffix(directory: string, suffix: string, depth: number): Promise<string | null> {
898
+ let entries;
899
+ try {
900
+ entries = await fsp.readdir(directory, { withFileTypes: true });
901
+ } catch {
902
+ return null;
903
+ }
904
+
905
+ const subdirectories: string[] = [];
906
+ for (const entry of entries) {
907
+ if (entry.isDirectory()) {
908
+ subdirectories.push(path.join(directory, entry.name));
909
+ continue;
910
+ }
911
+ if (entry.name.endsWith(suffix)) {
912
+ return path.join(directory, entry.name);
913
+ }
914
+ }
915
+
916
+ if (depth <= 0) {
917
+ return null;
918
+ }
919
+
920
+ for (const subdirectory of subdirectories) {
921
+ const match = await findFileWithSuffix(subdirectory, suffix, depth - 1);
922
+ if (match) {
923
+ return match;
924
+ }
925
+ }
926
+
927
+ return null;
928
+ }
929
+
930
+ type CodexSubagentTranscript = {
931
+ activity: SubagentActivity[];
932
+ nickname?: string;
933
+ agentPath?: string;
934
+ model?: string;
935
+ };
936
+
937
+ /**
938
+ * Flattens a spawned agent's own rollout into the shared activity timeline.
939
+ *
940
+ * The subagent's transcript is the same format as the parent's, so its shell
941
+ * and patch calls go through the same translation and end up rendered by the
942
+ * same components as the main thread's.
943
+ */
944
+ async function readCodexSubagentTranscript(filePath: string): Promise<CodexSubagentTranscript> {
945
+ const activity: SubagentActivity[] = [];
946
+ const transcript: CodexSubagentTranscript = { activity };
947
+ const pendingResults = new Map<string, SubagentActivity>();
948
+
949
+ let fileStream;
950
+ try {
951
+ fileStream = fsSync.createReadStream(filePath);
952
+ } catch {
953
+ return transcript;
954
+ }
955
+
956
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
957
+
958
+ for await (const line of rl) {
959
+ if (!line.trim()) {
960
+ continue;
961
+ }
962
+
963
+ let entry: AnyRecord;
964
+ try {
965
+ entry = JSON.parse(line) as AnyRecord;
966
+ } catch {
967
+ continue;
968
+ }
969
+
970
+ const payload = readObjectRecord(entry.payload);
971
+ if (!payload) {
972
+ continue;
973
+ }
974
+
975
+ if (entry.type === 'session_meta') {
976
+ // A resumed rollout writes a second, sparser session_meta; assigning
977
+ // unconditionally would erase the identity the first one carried.
978
+ transcript.nickname = readNonEmptyString(payload.agent_nickname) ?? transcript.nickname;
979
+ transcript.agentPath = readNonEmptyString(payload.agent_path) ?? transcript.agentPath;
980
+ continue;
981
+ }
982
+
983
+ if (entry.type === 'turn_context') {
984
+ transcript.model = readNonEmptyString(payload.model) ?? transcript.model;
985
+ continue;
986
+ }
987
+
988
+ if (entry.type !== 'response_item') {
989
+ continue;
990
+ }
991
+
992
+ const timestamp = typeof entry.timestamp === 'string' ? entry.timestamp : undefined;
993
+
994
+ if (payload.type === 'message' && payload.role === 'assistant') {
995
+ const content = extractCodexTextContent(payload.content).trim();
996
+ if (content) {
997
+ activity.push({ kind: 'text', content, timestamp });
998
+ }
999
+ continue;
1000
+ }
1001
+
1002
+ if (payload.type === 'reasoning') {
1003
+ const summary = Array.isArray(payload.summary)
1004
+ ? payload.summary.map((item: AnyRecord) => item?.text).filter(Boolean).join('\n')
1005
+ : '';
1006
+ if (summary.trim()) {
1007
+ activity.push({ kind: 'thinking', content: summary, timestamp });
1008
+ }
1009
+ continue;
1010
+ }
1011
+
1012
+ if (payload.type === 'function_call' || payload.type === 'custom_tool_call') {
1013
+ const callId = readNonEmptyString(payload.call_id) ?? generateMessageId('codex-subagent-call');
1014
+ for (const child of translateCodexToolCall(payload, callId)) {
1015
+ const record: SubagentActivity = { ...child, timestamp };
1016
+ activity.push(record);
1017
+ pendingResults.set(callId, record);
1018
+ }
1019
+ continue;
1020
+ }
1021
+
1022
+ if (payload.type === 'function_call_output' || payload.type === 'custom_tool_call_output') {
1023
+ const callId = readNonEmptyString(payload.call_id);
1024
+ const target = callId ? pendingResults.get(callId) : undefined;
1025
+ if (!target) {
158
1026
  continue;
159
1027
  }
160
- for (const stringMatch of arrayMatch[2].matchAll(stringPattern)) {
161
- commands.push(decodeJavaScriptStringLiteral(stringMatch[1]));
1028
+ const cleaned = cleanCodexShellOutput(extractCodexToolOutput(payload.output));
1029
+ target.toolResult = {
1030
+ content: cleaned.output,
1031
+ isError: cleaned.exitCode !== undefined && cleaned.exitCode !== 0,
1032
+ };
1033
+ }
1034
+ }
1035
+
1036
+ return transcript;
1037
+ }
1038
+
1039
+ /**
1040
+ * Translates one persisted Codex tool call into the canonical tool rows the
1041
+ * transcript renders. Most calls produce exactly one row; a patch produces one
1042
+ * per file it touched.
1043
+ *
1044
+ * Shared by the main thread and by subagent transcripts so a subagent's shell
1045
+ * command looks identical to the parent's.
1046
+ */
1047
+ function translateCodexToolCall(payload: AnyRecord, callId: string): SubagentActivity[] {
1048
+ const name = readNonEmptyString(payload.name) ?? 'tool';
1049
+ const rawInput = payload.type === 'custom_tool_call' ? payload.input : payload.arguments;
1050
+
1051
+ if (name === 'exec') {
1052
+ const operations = parseCodexExecScript(rawInput);
1053
+ const rows: SubagentActivity[] = [];
1054
+ for (const operation of operations) {
1055
+ if (operation.kind === 'shell') {
1056
+ rows.push({
1057
+ kind: 'tool',
1058
+ toolId: callId,
1059
+ toolName: 'Bash',
1060
+ toolInput: { command: operation.command, description: operation.justification },
1061
+ });
1062
+ } else if (operation.kind === 'patch') {
1063
+ for (const change of parseCodexPatch(operation.patch)) {
1064
+ rows.push({
1065
+ kind: 'tool',
1066
+ toolId: callId,
1067
+ toolName: change.changeType === 'add' ? 'Write' : 'Edit',
1068
+ toolInput: {
1069
+ file_path: change.filePath,
1070
+ old_string: change.oldText,
1071
+ new_string: change.newText,
1072
+ },
1073
+ });
1074
+ }
1075
+ } else if (operation.kind === 'search') {
1076
+ rows.push({
1077
+ kind: 'tool',
1078
+ toolId: callId,
1079
+ toolName: 'WebSearch',
1080
+ toolInput: { query: operation.queries.join(' | ') },
1081
+ });
162
1082
  }
163
1083
  }
1084
+ if (rows.length > 0) {
1085
+ return rows;
1086
+ }
1087
+ }
1088
+
1089
+ if (name === 'shell_command' || name === 'exec_command') {
1090
+ let command = '';
1091
+ try {
1092
+ const args = JSON.parse(String(rawInput || '{}')) as AnyRecord;
1093
+ command = String(args.command ?? args.cmd ?? '');
1094
+ } catch {
1095
+ command = String(rawInput ?? '');
1096
+ }
1097
+ return [{ kind: 'tool', toolId: callId, toolName: 'Bash', toolInput: { command } }];
1098
+ }
1099
+
1100
+ if (name === 'apply_patch') {
1101
+ return parseCodexPatch(String(rawInput ?? '')).map((change) => ({
1102
+ kind: 'tool' as const,
1103
+ toolId: callId,
1104
+ toolName: change.changeType === 'add' ? 'Write' : 'Edit',
1105
+ toolInput: {
1106
+ file_path: change.filePath,
1107
+ old_string: change.oldText,
1108
+ new_string: change.newText,
1109
+ },
1110
+ }));
164
1111
  }
165
1112
 
166
- return commands;
1113
+ if (name === 'update_plan') {
1114
+ return [{ kind: 'tool', toolId: callId, toolName: 'TodoWrite', toolInput: readCodexPlanInput(rawInput) }];
1115
+ }
1116
+
1117
+ return [{ kind: 'tool', toolId: callId, toolName: humanizeCodexToolName(name), toolInput: rawInput }];
167
1118
  }
168
1119
 
169
1120
  /**
170
- * Newer Codex rollouts persist the orchestration wrapper (`exec`) instead of
171
- * the nested tool name. Recover the useful UI-level operation so history does
172
- * not degrade into rows labelled only "exec / Parameters".
1121
+ * Converts Codex's `update_plan` steps into the todo shape Claude's
1122
+ * `TodoWrite` already uses, so one renderer covers both providers.
173
1123
  */
174
- function translateCodexExecInput(input: unknown): { toolName: string; toolInput: string } | null {
175
- const source = typeof input === 'string' ? input : String(input || '');
176
- if (/\btools\.(?:shell_command|exec_command)\s*\(/.test(source)) {
177
- const commands = extractNestedCodexCommands(source);
178
- if (commands.length > 0) {
179
- return {
180
- toolName: 'Bash',
181
- toolInput: JSON.stringify({ command: commands.join('\n') }),
182
- };
183
- }
1124
+ function readCodexPlanInput(rawArguments: unknown): AnyRecord {
1125
+ try {
1126
+ const parsed = JSON.parse(String(rawArguments || '{}')) as AnyRecord;
1127
+ const steps = Array.isArray(parsed.plan) ? parsed.plan : [];
1128
+ return {
1129
+ todos: steps.map((step: AnyRecord) => ({
1130
+ content: typeof step?.step === 'string' ? step.step : String(step?.content ?? ''),
1131
+ status: typeof step?.status === 'string' ? step.status : 'pending',
1132
+ })),
1133
+ };
1134
+ } catch {
1135
+ return { todos: [] };
184
1136
  }
185
-
186
- return null;
187
1137
  }
188
1138
 
189
1139
  function humanizeCodexToolName(toolName: string): string {
@@ -193,32 +1143,6 @@ function humanizeCodexToolName(toolName: string): string {
193
1143
  .replace(/\b\w/g, (character) => character.toUpperCase());
194
1144
  }
195
1145
 
196
- type CodexSubagentRecord = {
197
- toolCallId: string;
198
- message: AnyRecord;
199
- agentPath?: string;
200
- isComplete: boolean;
201
- };
202
-
203
- function parseCodexSubagentMessage(payload: AnyRecord): {
204
- author: string;
205
- messageType: string;
206
- result: string;
207
- } | null {
208
- const text = extractCodexTextContent(payload.content);
209
- const header = /Message Type:\s*([^\r\n]+)[\s\S]*?Sender:\s*([^\r\n]+)[\s\S]*?Payload:\s*\r?\n([\s\S]*)/i.exec(text);
210
- const author = readNonEmptyString(payload.author) || header?.[2]?.trim();
211
- if (!author) {
212
- return null;
213
- }
214
-
215
- return {
216
- author,
217
- messageType: header?.[1]?.trim().toUpperCase() || 'MESSAGE',
218
- result: header?.[3]?.trim() || '',
219
- };
220
- }
221
-
222
1146
  const CODEX_COLLABORATION_CONTROL_TOOLS = new Set([
223
1147
  'followup_task',
224
1148
  'interrupt_agent',
@@ -227,412 +1151,751 @@ const CODEX_COLLABORATION_CONTROL_TOOLS = new Set([
227
1151
  'wait_agent',
228
1152
  ]);
229
1153
 
230
- function readNonEmptyString(value: unknown): string | undefined {
231
- return typeof value === 'string' && value.length > 0 ? value : undefined;
232
- }
1154
+ // ─── Transcript reader ──────────────────────────────────────────────────────
233
1155
 
234
- async function getCodexSessionMessages(
235
- sessionId: string,
236
- limit: number | null = null,
237
- offset = 0,
238
- ): Promise<CodexHistoryResult> {
239
- try {
240
- const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
241
-
242
- if (!sessionFilePath) {
243
- console.warn(`Codex session file not found for session ${sessionId}`);
244
- return { messages: [], total: 0, hasMore: false };
245
- }
246
-
247
- const messages: AnyRecord[] = [];
248
- let tokenUsage: AnyRecord | null = null;
249
- const ignoredToolCallIds = new Set<string>();
250
- const execToolCallIds = new Set<string>();
251
- const execCallByCellId = new Map<string, string>();
252
- const waitCallToExecCall = new Map<string, string>();
253
- const pendingExecOutput = new Map<string, string>();
254
- const completedExecCalls = new Set<string>();
255
- const subagentsByCallId = new Map<string, CodexSubagentRecord>();
256
- const subagentsByPath = new Map<string, CodexSubagentRecord>();
257
- const fileStream = fsSync.createReadStream(sessionFilePath);
258
- const rl = readline.createInterface({
259
- input: fileStream,
260
- crlfDelay: Infinity,
261
- });
1156
+ /**
1157
+ * Reads one Codex rollout file and produces the compact per-message records
1158
+ * `normalizeHistoryEntry` turns into `NormalizedMessage`s.
1159
+ */
1160
+ async function getCodexSessionMessages(sessionId: string): Promise<CodexHistoryResult> {
1161
+ const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
262
1162
 
263
- for await (const line of rl) {
264
- if (!line.trim()) {
265
- continue;
266
- }
1163
+ if (!sessionFilePath) {
1164
+ console.warn(`Codex session file not found for session ${sessionId}`);
1165
+ return { messages: [], total: 0, hasMore: false };
1166
+ }
267
1167
 
268
- try {
269
- const entry = JSON.parse(line) as AnyRecord;
270
- if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
271
- const info = entry.payload.info as AnyRecord;
272
- if (info.total_token_usage) {
273
- const usage = info.total_token_usage as AnyRecord;
274
- tokenUsage = {
275
- used: usage.total_tokens || 0,
276
- total: info.model_context_window || 200000,
277
- };
278
- }
1168
+ const messages: AnyRecord[] = [];
1169
+ let tokenUsage: AnyRecord | null = null;
1170
+
1171
+ /** Call ids whose orchestration wrapper must never reach the transcript. */
1172
+ const ignoredToolCallIds = new Set<string>();
1173
+ /** Exec calls that own a shell row, so their output can be routed back. */
1174
+ const shellCallMessages = new Map<string, AnyRecord>();
1175
+ /**
1176
+ * File rows produced by each patch call, so the authoritative
1177
+ * `patch_apply_end` diffs can replace what was reconstructed from the call
1178
+ * input, and so every row gets exactly one result.
1179
+ */
1180
+ const patchGroups = new Map<string, {
1181
+ callId: string;
1182
+ rows: AnyRecord[];
1183
+ timestamp: string;
1184
+ success?: boolean;
1185
+ failure?: string;
1186
+ }>();
1187
+ /** Patch call awaiting its out-of-band `patch_apply_end` report. */
1188
+ let pendingPatchCallId: string | null = null;
1189
+ const execCallByCellId = new Map<string, string>();
1190
+ const waitCallToExecCall = new Map<string, string>();
1191
+ const pendingExecOutput = new Map<string, string>();
1192
+ const completedExecCalls = new Set<string>();
1193
+ /** Newest still-running exec call, for `write_stdin` polls that carry no cell id. */
1194
+ let runningExecCallId: string | null = null;
1195
+
1196
+ const subagentsByCallId = new Map<string, CodexSubagentRecord>();
1197
+ const subagentsByPath = new Map<string, CodexSubagentRecord>();
1198
+ const turns = createCodexTurnTracker();
1199
+ /** Turns whose prompt already carries the anchor, so only the first does. */
1200
+ const anchoredTurnIds = new Set<string>();
1201
+
1202
+ const fileStream = fsSync.createReadStream(sessionFilePath);
1203
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
1204
+
1205
+ /** Emits a tool_result row unless the call already produced one. */
1206
+ const pushToolResult = (callId: string, timestamp: string, output: string, isError: boolean) => {
1207
+ if (completedExecCalls.has(callId)) {
1208
+ return;
1209
+ }
1210
+ completedExecCalls.add(callId);
1211
+ messages.push({ type: 'tool_result', timestamp, toolCallId: callId, output, isError });
1212
+ };
1213
+
1214
+ for await (const line of rl) {
1215
+ if (!line.trim()) {
1216
+ continue;
1217
+ }
1218
+
1219
+ let entry: AnyRecord;
1220
+ try {
1221
+ entry = JSON.parse(line) as AnyRecord;
1222
+ } catch {
1223
+ continue;
1224
+ }
1225
+
1226
+ const payload = readObjectRecord(entry.payload);
1227
+ if (!payload) {
1228
+ continue;
1229
+ }
1230
+ const timestamp = typeof entry.timestamp === 'string' ? entry.timestamp : new Date().toISOString();
1231
+ // Before the type-specific branches: a turn is opened and closed by rows
1232
+ // that produce no transcript entry of their own, and each of those
1233
+ // branches ends in a `continue`.
1234
+ turns.observe(entry.type, payload);
1235
+
1236
+ // ── event_msg ──────────────────────────────────────────────────────────
1237
+ if (entry.type === 'event_msg') {
1238
+ if (payload.type === 'token_count' && payload.info) {
1239
+ const info = payload.info as AnyRecord;
1240
+ if (info.total_token_usage) {
1241
+ const usage = info.total_token_usage as AnyRecord;
1242
+ tokenUsage = { used: usage.total_tokens || 0, total: info.model_context_window || 200000 };
279
1243
  }
1244
+ continue;
1245
+ }
280
1246
 
281
- if (
282
- entry.type === 'event_msg'
283
- && entry.payload?.type === 'sub_agent_activity'
284
- && entry.payload.kind === 'started'
285
- ) {
286
- const eventId = readNonEmptyString(entry.payload.event_id);
287
- const agentPath = readNonEmptyString(entry.payload.agent_path);
288
- const subagent = eventId ? subagentsByCallId.get(eventId) : undefined;
289
- if (subagent && agentPath) {
1247
+ if (payload.type === 'sub_agent_activity' && payload.kind === 'started') {
1248
+ const eventId = readNonEmptyString(payload.event_id);
1249
+ const agentPath = readNonEmptyString(payload.agent_path);
1250
+ const agentThreadId = readNonEmptyString(payload.agent_thread_id);
1251
+ const subagent = eventId ? subagentsByCallId.get(eventId) : undefined;
1252
+ if (subagent) {
1253
+ subagent.agentThreadId = agentThreadId ?? subagent.agentThreadId;
1254
+ if (agentPath) {
290
1255
  subagent.agentPath = agentPath;
291
1256
  subagentsByPath.set(agentPath, subagent);
292
1257
  }
293
1258
  }
1259
+ continue;
1260
+ }
294
1261
 
295
- if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload as AnyRecord)) {
296
- messages.push({
297
- type: 'user',
298
- timestamp: entry.timestamp,
299
- message: {
300
- role: 'user',
301
- content: entry.payload.message,
302
- },
303
- images: extractCodexUserImages(entry.payload as AnyRecord),
1262
+ // Codex reports patch results out of band. They carry the real unified
1263
+ // diffs, so they replace whatever was reconstructed from the call input.
1264
+ // Codex reports the applied patch out of band, and it is the only place
1265
+ // the real unified diffs appear. It always arrives before the call's own
1266
+ // output, so the rows reconstructed from the call input are rewritten
1267
+ // here rather than duplicated.
1268
+ if (payload.type === 'patch_apply_end') {
1269
+ const callId = readNonEmptyString(payload.call_id);
1270
+ if (!callId) {
1271
+ continue;
1272
+ }
1273
+ const changes = readPatchApplyChanges(payload.changes);
1274
+ // A patch applied from inside an `exec` script is reported under a
1275
+ // synthetic id that does not match the script's call id, so fall back
1276
+ // to the patch call that is still in flight.
1277
+ const group = patchGroups.get(callId)
1278
+ ?? (pendingPatchCallId ? patchGroups.get(pendingPatchCallId) : undefined);
1279
+ if (group) {
1280
+ rewritePatchRows(group.rows, changes, group.callId, messages);
1281
+ group.success = payload.success !== false;
1282
+ group.failure = String(payload.stderr || payload.stdout || '');
1283
+ pendingPatchCallId = null;
1284
+ } else if (changes.length > 0) {
1285
+ // A resumed thread can replay the result of a patch whose call is
1286
+ // not in this file; the rows still belong in the transcript.
1287
+ const rows = changes.map((change, index) =>
1288
+ fileChangeToToolMessage(change, timestamp, `${callId}#${index}`));
1289
+ rows.forEach((row) => messages.push(row));
1290
+ patchGroups.set(callId, {
1291
+ callId,
1292
+ rows,
1293
+ timestamp,
1294
+ success: payload.success !== false,
1295
+ failure: String(payload.stderr || payload.stdout || ''),
304
1296
  });
305
1297
  }
1298
+ continue;
1299
+ }
306
1300
 
307
- if (
308
- entry.type === 'response_item' &&
309
- entry.payload?.type === 'message' &&
310
- entry.payload.role === 'assistant'
311
- ) {
312
- const textContent = extractCodexTextContent(entry.payload.content);
313
- if (textContent.trim()) {
314
- messages.push({
315
- type: 'assistant',
316
- timestamp: entry.timestamp,
317
- message: {
318
- role: 'assistant',
319
- content: textContent,
320
- },
321
- });
322
- }
1301
+ if (payload.type === 'turn_aborted') {
1302
+ messages.push({
1303
+ type: 'status_note',
1304
+ timestamp,
1305
+ content: payload.reason === 'interrupted' ? 'Turn interrupted' : `Turn aborted (${String(payload.reason ?? 'unknown')})`,
1306
+ });
1307
+ continue;
1308
+ }
1309
+
1310
+ if (payload.type === 'context_compacted') {
1311
+ messages.push({ type: 'status_note', timestamp, content: 'Context compacted' });
1312
+ continue;
1313
+ }
1314
+
1315
+ if (isVisibleCodexUserMessage(payload)) {
1316
+ // Only the first prompt of a turn is anchored. A turn can hold more
1317
+ // than one — a follow-up queued while the turn was running is written
1318
+ // into it — and the cut is per turn, so anchoring the second would
1319
+ // quietly take the first with it when the user edited only the second.
1320
+ const turnId = turns.getCurrentTurnId();
1321
+ const isFirstPromptOfTurn = Boolean(turnId) && !anchoredTurnIds.has(turnId as string);
1322
+ if (isFirstPromptOfTurn) {
1323
+ anchoredTurnIds.add(turnId as string);
323
1324
  }
1325
+ messages.push({
1326
+ type: 'user',
1327
+ timestamp,
1328
+ message: { role: 'user', content: payload.message },
1329
+ images: extractCodexUserImages(payload),
1330
+ ...(isFirstPromptOfTurn ? { turnId } : {}),
1331
+ });
1332
+ }
1333
+ continue;
1334
+ }
324
1335
 
325
- if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') {
326
- const summaryText = Array.isArray(entry.payload.summary)
327
- ? entry.payload.summary
328
- .map((item: AnyRecord) => item?.text)
329
- .filter(Boolean)
330
- .join('\n')
331
- : '';
332
-
333
- if (summaryText.trim()) {
334
- messages.push({
335
- type: 'thinking',
336
- timestamp: entry.timestamp,
337
- message: {
338
- role: 'assistant',
339
- content: summaryText,
340
- },
341
- });
342
- }
1336
+ if (entry.type !== 'response_item') {
1337
+ continue;
1338
+ }
1339
+
1340
+ // ── response_item ──────────────────────────────────────────────────────
1341
+ if (payload.type === 'message' && payload.role === 'assistant') {
1342
+ const cited = readCodexMemoryCitations(extractCodexTextContent(payload.content));
1343
+ const textContent = cited.text;
1344
+ if (!textContent.trim()) {
1345
+ continue;
1346
+ }
1347
+
1348
+ const proposedPlan = readCodexProposedPlan(textContent);
1349
+ if (proposedPlan) {
1350
+ const planCallId = readNonEmptyString(payload.id) ?? generateMessageId('codex-plan');
1351
+ messages.push({
1352
+ type: 'tool_use',
1353
+ timestamp,
1354
+ toolName: 'ExitPlanMode',
1355
+ toolInput: JSON.stringify({ plan: proposedPlan }),
1356
+ toolCallId: planCallId,
1357
+ memoryCitations: cited.memoryCitations,
1358
+ });
1359
+ messages.push({ type: 'tool_result', timestamp, toolCallId: planCallId, output: '' });
1360
+ continue;
1361
+ }
1362
+
1363
+ messages.push({
1364
+ type: 'assistant',
1365
+ timestamp,
1366
+ message: { role: 'assistant', content: textContent },
1367
+ memoryCitations: cited.memoryCitations,
1368
+ });
1369
+ continue;
1370
+ }
1371
+
1372
+ if (payload.type === 'reasoning') {
1373
+ const summaryText = Array.isArray(payload.summary)
1374
+ ? payload.summary.map((item: AnyRecord) => item?.text).filter(Boolean).join('\n')
1375
+ : '';
1376
+ if (summaryText.trim()) {
1377
+ messages.push({
1378
+ type: 'thinking',
1379
+ timestamp,
1380
+ message: { role: 'assistant', content: summaryText },
1381
+ });
1382
+ }
1383
+ continue;
1384
+ }
1385
+
1386
+ if (payload.type === 'web_search_call') {
1387
+ const action = readObjectRecord(payload.action);
1388
+ const query = readNonEmptyString(action?.query as string | undefined);
1389
+ if (query) {
1390
+ const callId = readNonEmptyString(payload.id) ?? generateMessageId('codex-search');
1391
+ messages.push({
1392
+ type: 'tool_use',
1393
+ timestamp,
1394
+ toolName: 'WebSearch',
1395
+ toolInput: JSON.stringify({ query }),
1396
+ toolCallId: callId,
1397
+ });
1398
+ }
1399
+ continue;
1400
+ }
1401
+
1402
+ if (payload.type === 'agent_message') {
1403
+ const agentMessage = parseCodexSubagentMessage(payload);
1404
+ if (!agentMessage || agentMessage.messageType !== 'FINAL_ANSWER' || !agentMessage.result) {
1405
+ continue;
1406
+ }
1407
+
1408
+ let subagent = subagentsByPath.get(agentMessage.author);
1409
+ if (!subagent) {
1410
+ // A resumed thread can replay an answer whose spawn call is not in this
1411
+ // file. Synthesize the container so the answer still has a home.
1412
+ const fallbackCallId = readNonEmptyString(payload.id) ?? generateMessageId('codex-subagent');
1413
+ const taskName = agentMessage.author.split('/').filter(Boolean).pop() || 'agent';
1414
+ const taskMessage: AnyRecord = {
1415
+ uuid: fallbackCallId,
1416
+ type: 'tool_use',
1417
+ timestamp,
1418
+ toolName: 'Task',
1419
+ toolInput: JSON.stringify({ description: humanizeCodexToolName(taskName) }),
1420
+ toolCallId: fallbackCallId,
1421
+ };
1422
+ messages.push(taskMessage);
1423
+ subagent = { toolCallId: fallbackCallId, message: taskMessage, agentPath: agentMessage.author, isComplete: false };
1424
+ subagentsByCallId.set(fallbackCallId, subagent);
1425
+ subagentsByPath.set(agentMessage.author, subagent);
1426
+ }
1427
+
1428
+ if (!subagent.isComplete) {
1429
+ messages.push({
1430
+ type: 'tool_result',
1431
+ timestamp,
1432
+ toolCallId: subagent.toolCallId,
1433
+ output: agentMessage.result,
1434
+ });
1435
+ subagent.isComplete = true;
1436
+ }
1437
+ continue;
1438
+ }
1439
+
1440
+ if (payload.type === 'function_call' || payload.type === 'custom_tool_call') {
1441
+ const callId = readNonEmptyString(payload.call_id) ?? generateMessageId('codex-call');
1442
+ const name = readNonEmptyString(payload.name) ?? 'tool';
1443
+ const rawInput = payload.type === 'custom_tool_call' ? payload.input : payload.arguments;
1444
+
1445
+ if (name === 'spawn_agent') {
1446
+ let taskName = 'agent';
1447
+ let prompt: string | undefined;
1448
+ try {
1449
+ const args = JSON.parse(String(rawInput || '{}')) as AnyRecord;
1450
+ taskName = readNonEmptyString(args.task_name) || taskName;
1451
+ // `message` is an encrypted transport blob on collaboration spawns —
1452
+ // only surface it when it is readable prompt text.
1453
+ const message = readNonEmptyString(args.message);
1454
+ prompt = message && !/^gAAAAA/.test(message) ? message : undefined;
1455
+ } catch {
1456
+ // The activity event still supplies the canonical agent path.
343
1457
  }
344
1458
 
345
- if (entry.type === 'response_item' && entry.payload?.type === 'agent_message') {
346
- const agentMessage = parseCodexSubagentMessage(entry.payload as AnyRecord);
347
- if (agentMessage && agentMessage.messageType === 'FINAL_ANSWER' && agentMessage.result) {
348
- let subagent = subagentsByPath.get(agentMessage.author);
349
- if (!subagent) {
350
- const fallbackCallId = entry.payload.id || generateMessageId('codex-subagent');
351
- const taskName = agentMessage.author.split('/').filter(Boolean).pop() || 'agent';
352
- const taskMessage: AnyRecord = {
353
- uuid: fallbackCallId,
354
- type: 'tool_use',
355
- timestamp: entry.timestamp,
356
- toolName: 'Task',
357
- toolInput: JSON.stringify({
358
- subagent_type: 'Codex',
359
- description: humanizeCodexToolName(taskName),
360
- }),
361
- toolCallId: fallbackCallId,
362
- };
363
- messages.push(taskMessage);
364
- subagent = {
365
- toolCallId: fallbackCallId,
366
- message: taskMessage,
367
- agentPath: agentMessage.author,
368
- isComplete: false,
369
- };
370
- subagentsByCallId.set(fallbackCallId, subagent);
371
- subagentsByPath.set(agentMessage.author, subagent);
372
- }
373
-
374
- if (!subagent.isComplete) {
375
- messages.push({
376
- type: 'tool_result',
377
- timestamp: entry.timestamp,
378
- toolCallId: subagent.toolCallId,
379
- output: agentMessage.result,
380
- });
381
- subagent.isComplete = true;
382
- }
1459
+ const taskMessage: AnyRecord = {
1460
+ uuid: callId,
1461
+ type: 'tool_use',
1462
+ timestamp,
1463
+ toolName: 'Task',
1464
+ // No `subagent_type`: Codex has no agent presets, and naming the
1465
+ // provider there would render as "Codex / Codex" in the transcript.
1466
+ toolInput: JSON.stringify({
1467
+ description: humanizeCodexToolName(taskName),
1468
+ ...(prompt ? { prompt } : {}),
1469
+ }),
1470
+ toolCallId: callId,
1471
+ };
1472
+ messages.push(taskMessage);
1473
+ subagentsByCallId.set(callId, { toolCallId: callId, message: taskMessage, isComplete: false });
1474
+ ignoredToolCallIds.add(callId);
1475
+ continue;
1476
+ }
1477
+
1478
+ if (name === 'wait') {
1479
+ try {
1480
+ const args = JSON.parse(String(rawInput || '{}')) as AnyRecord;
1481
+ const execCallId = execCallByCellId.get(String(args.cell_id || ''));
1482
+ if (execCallId) {
1483
+ waitCallToExecCall.set(callId, execCallId);
383
1484
  }
1485
+ } catch {
1486
+ // Suppress the orchestration wait even when its payload is malformed.
384
1487
  }
1488
+ ignoredToolCallIds.add(callId);
1489
+ continue;
1490
+ }
385
1491
 
386
- if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
387
- let toolName = entry.payload.name;
388
- let toolInput = entry.payload.arguments;
389
-
390
- if (toolName === 'spawn_agent') {
391
- let taskName = 'agent';
392
- try {
393
- const args = JSON.parse(String(entry.payload.arguments || '{}')) as AnyRecord;
394
- taskName = readNonEmptyString(args.task_name) || taskName;
395
- } catch {
396
- // The activity event can still provide the canonical agent path.
397
- }
398
-
399
- const taskMessage: AnyRecord = {
400
- uuid: entry.payload.call_id,
401
- type: 'tool_use',
402
- timestamp: entry.timestamp,
403
- toolName: 'Task',
404
- toolInput: JSON.stringify({
405
- subagent_type: 'Codex',
406
- description: humanizeCodexToolName(taskName),
407
- }),
408
- toolCallId: entry.payload.call_id,
409
- };
410
- messages.push(taskMessage);
411
- subagentsByCallId.set(entry.payload.call_id, {
412
- toolCallId: entry.payload.call_id,
413
- message: taskMessage,
414
- isComplete: false,
415
- });
416
- ignoredToolCallIds.add(entry.payload.call_id);
417
- continue;
418
- }
1492
+ if (CODEX_COLLABORATION_CONTROL_TOOLS.has(name)) {
1493
+ ignoredToolCallIds.add(callId);
1494
+ continue;
1495
+ }
419
1496
 
420
- if (toolName === 'wait') {
421
- try {
422
- const args = JSON.parse(String(entry.payload.arguments || '{}')) as AnyRecord;
423
- const cellId = String(args.cell_id || '');
424
- const execCallId = execCallByCellId.get(cellId);
425
- if (execCallId) {
426
- waitCallToExecCall.set(entry.payload.call_id, execCallId);
427
- }
428
- } catch {
429
- // Suppress the orchestration wait even when its payload is malformed.
430
- }
431
- ignoredToolCallIds.add(entry.payload.call_id);
432
- continue;
433
- }
1497
+ if (name === 'update_plan') {
1498
+ messages.push({
1499
+ type: 'tool_use',
1500
+ timestamp,
1501
+ toolName: 'TodoWrite',
1502
+ toolInput: JSON.stringify(readCodexPlanInput(rawInput)),
1503
+ toolCallId: callId,
1504
+ });
1505
+ continue;
1506
+ }
434
1507
 
435
- if (CODEX_COLLABORATION_CONTROL_TOOLS.has(toolName)) {
436
- ignoredToolCallIds.add(entry.payload.call_id);
437
- continue;
1508
+ if (name === 'exec') {
1509
+ const operations = parseCodexExecScript(rawInput);
1510
+ const shellCommands = operations.filter((operation) => operation.kind === 'shell');
1511
+ const stdinWrites = operations.filter((operation) => operation.kind === 'stdin');
1512
+ const patches = operations.filter((operation) => operation.kind === 'patch');
1513
+ const searches = operations.filter((operation) => operation.kind === 'search');
1514
+ const plans = operations.filter((operation) => operation.kind === 'plan');
1515
+
1516
+ // A bare stdin poll continues whatever command is still running; it is
1517
+ // a continuation, not an action of its own.
1518
+ if (operations.length > 0 && shellCommands.length === 0 && stdinWrites.length > 0) {
1519
+ ignoredToolCallIds.add(callId);
1520
+ if (runningExecCallId) {
1521
+ waitCallToExecCall.set(callId, runningExecCallId);
438
1522
  }
1523
+ continue;
1524
+ }
1525
+
1526
+ if (operations.length === 0) {
1527
+ // An unrecognized wrapper is shown as-is rather than mislabelled as a
1528
+ // shell command, so a new Codex tool degrades to a readable code
1529
+ // block instead of a wrong one.
1530
+ const fallbackMessage: AnyRecord = {
1531
+ type: 'tool_use',
1532
+ timestamp,
1533
+ toolName: 'exec',
1534
+ toolInput: rawInput,
1535
+ toolCallId: callId,
1536
+ };
1537
+ messages.push(fallbackMessage);
1538
+ continue;
1539
+ }
439
1540
 
440
- if (toolName === 'shell_command') {
441
- toolName = 'Bash';
442
- try {
443
- const args = JSON.parse(entry.payload.arguments) as AnyRecord;
444
- toolInput = JSON.stringify({ command: args.command });
445
- } catch {
446
- // Keep original arguments when parsing fails.
447
- }
1541
+ // The call has exactly one output, so whichever row represents the work
1542
+ // it did keeps the call id and receives that output. Extra rows get
1543
+ // suffixed ids and are resolved by their own bookkeeping.
1544
+ let outputOwnerAssigned = false;
1545
+ const nextRowId = () => {
1546
+ if (!outputOwnerAssigned) {
1547
+ outputOwnerAssigned = true;
1548
+ return callId;
448
1549
  }
1550
+ return `${callId}~${messages.length}`;
1551
+ };
1552
+
1553
+ if (shellCommands.length > 0) {
1554
+ const shellMessage: AnyRecord = {
1555
+ type: 'tool_use',
1556
+ timestamp,
1557
+ toolName: 'Bash',
1558
+ toolInput: JSON.stringify({
1559
+ command: shellCommands.map((operation) => operation.command).join('\n'),
1560
+ description: shellCommands.find((operation) => operation.justification)?.justification,
1561
+ }),
1562
+ toolCallId: nextRowId(),
1563
+ };
1564
+ messages.push(shellMessage);
1565
+ shellCallMessages.set(callId, shellMessage);
1566
+ }
449
1567
 
1568
+ for (const search of searches) {
450
1569
  messages.push({
451
1570
  type: 'tool_use',
452
- timestamp: entry.timestamp,
453
- toolName,
454
- toolInput,
455
- toolCallId: entry.payload.call_id,
1571
+ timestamp,
1572
+ toolName: 'WebSearch',
1573
+ toolInput: JSON.stringify({ query: search.queries.join(' | ') }),
1574
+ toolCallId: nextRowId(),
456
1575
  });
457
1576
  }
458
1577
 
459
- if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') {
460
- const waitExecCallId = waitCallToExecCall.get(entry.payload.call_id);
461
- if (waitExecCallId) {
462
- const output = extractCodexToolOutput(entry.payload.output);
463
- const runningOutput = readRunningExecOutput(output);
464
- const accumulatedOutput = `${pendingExecOutput.get(waitExecCallId) || ''}${runningOutput?.content ?? output}`;
465
- pendingExecOutput.set(waitExecCallId, accumulatedOutput);
466
-
467
- if (!runningOutput && !completedExecCalls.has(waitExecCallId)) {
468
- messages.push({
469
- type: 'tool_result',
470
- timestamp: entry.timestamp,
471
- toolCallId: waitExecCallId,
472
- output: accumulatedOutput,
473
- });
474
- completedExecCalls.add(waitExecCallId);
475
- }
476
- continue;
477
- }
1578
+ for (const plan of plans) {
1579
+ // The script's own result covers whatever else it did, so the plan
1580
+ // row is closed here — without one it would read as still running.
1581
+ const planCallId = nextRowId();
1582
+ messages.push({
1583
+ type: 'tool_use',
1584
+ timestamp,
1585
+ toolName: 'TodoWrite',
1586
+ toolInput: JSON.stringify({ todos: plan.todos }),
1587
+ toolCallId: planCallId,
1588
+ });
1589
+ messages.push({ type: 'tool_result', timestamp, toolCallId: planCallId, output: '' });
1590
+ }
478
1591
 
479
- const subagent = subagentsByCallId.get(entry.payload.call_id);
480
- if (subagent) {
481
- const output = extractCodexToolOutput(entry.payload.output);
482
- try {
483
- const taskPath = readNonEmptyString((JSON.parse(output) as AnyRecord).task_name);
484
- if (taskPath) {
485
- subagent.agentPath = taskPath;
486
- subagentsByPath.set(taskPath, subagent);
487
- }
488
- } catch {
489
- // The sub_agent_activity event normally supplies the path.
490
- }
491
- continue;
492
- }
1592
+ if (patches.length > 0) {
1593
+ const changes = patches.flatMap((patch) => parseCodexPatch(patch.patch));
1594
+ const rows = changes.map((change, index) =>
1595
+ fileChangeToToolMessage(change, timestamp, index === 0 ? nextRowId() : `${callId}#${index}`));
1596
+ rows.forEach((row) => messages.push(row));
1597
+ patchGroups.set(callId, { callId, rows, timestamp });
1598
+ pendingPatchCallId = callId;
1599
+ }
493
1600
 
494
- if (ignoredToolCallIds.has(entry.payload.call_id)) {
495
- continue;
496
- }
1601
+ if (!outputOwnerAssigned) {
1602
+ // Every recognized operation produced no row (an empty patch, say).
1603
+ // Nothing owns the output, so drop it rather than leaving it loose.
1604
+ ignoredToolCallIds.add(callId);
1605
+ }
1606
+ continue;
1607
+ }
497
1608
 
498
- messages.push({
499
- type: 'tool_result',
500
- timestamp: entry.timestamp,
501
- toolCallId: entry.payload.call_id,
502
- output: extractCodexToolOutput(entry.payload.output),
503
- });
1609
+ if (name === 'apply_patch') {
1610
+ const rows = parseCodexPatch(String(rawInput ?? ''))
1611
+ .map((change, index) => fileChangeToToolMessage(change, timestamp, `${callId}#${index}`));
1612
+ rows.forEach((row) => messages.push(row));
1613
+ patchGroups.set(callId, { callId, rows, timestamp });
1614
+ pendingPatchCallId = callId;
1615
+ // The call's own output is a bare `{}`; the per-file rows carry the result.
1616
+ ignoredToolCallIds.add(callId);
1617
+ continue;
1618
+ }
1619
+
1620
+ if (name === 'shell_command' || name === 'exec_command') {
1621
+ let command = '';
1622
+ try {
1623
+ const args = JSON.parse(String(rawInput || '{}')) as AnyRecord;
1624
+ command = String(args.command ?? args.cmd ?? '');
1625
+ } catch {
1626
+ command = String(rawInput ?? '');
504
1627
  }
1628
+ const shellMessage: AnyRecord = {
1629
+ type: 'tool_use',
1630
+ timestamp,
1631
+ toolName: 'Bash',
1632
+ toolInput: JSON.stringify({ command }),
1633
+ toolCallId: callId,
1634
+ };
1635
+ messages.push(shellMessage);
1636
+ shellCallMessages.set(callId, shellMessage);
1637
+ continue;
1638
+ }
505
1639
 
506
- if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') {
507
- let toolName = entry.payload.name || 'custom_tool';
508
- const input = entry.payload.input || '';
509
- let toolInput = input;
510
-
511
- if (toolName === 'exec') {
512
- const translated = translateCodexExecInput(input);
513
- if (translated) {
514
- toolName = translated.toolName;
515
- toolInput = translated.toolInput;
516
- }
517
- messages.push({
518
- type: 'tool_use',
519
- timestamp: entry.timestamp,
520
- toolName,
521
- toolInput,
522
- toolCallId: entry.payload.call_id,
523
- });
524
- execToolCallIds.add(entry.payload.call_id);
525
- continue;
526
- }
1640
+ messages.push({
1641
+ type: 'tool_use',
1642
+ timestamp,
1643
+ toolName: name,
1644
+ toolInput: rawInput,
1645
+ toolCallId: callId,
1646
+ });
1647
+ continue;
1648
+ }
527
1649
 
528
- if (toolName === 'apply_patch') {
529
- const fileMatch = String(input).match(/\*\*\* Update File: (.+)/);
530
- const filePath = fileMatch ? fileMatch[1].trim() : 'unknown';
531
- const lines = String(input).split('\n');
532
- const oldLines: string[] = [];
533
- const newLines: string[] = [];
534
-
535
- for (const lineContent of lines) {
536
- if (lineContent.startsWith('-') && !lineContent.startsWith('---')) {
537
- oldLines.push(lineContent.slice(1));
538
- } else if (lineContent.startsWith('+') && !lineContent.startsWith('+++')) {
539
- newLines.push(lineContent.slice(1));
540
- }
541
- }
542
-
543
- messages.push({
544
- type: 'tool_use',
545
- timestamp: entry.timestamp,
546
- toolName: 'Edit',
547
- toolInput: JSON.stringify({
548
- file_path: filePath,
549
- old_string: oldLines.join('\n'),
550
- new_string: newLines.join('\n'),
551
- }),
552
- toolCallId: entry.payload.call_id,
553
- });
554
- } else {
555
- messages.push({
556
- type: 'tool_use',
557
- timestamp: entry.timestamp,
558
- toolName,
559
- toolInput: input,
560
- toolCallId: entry.payload.call_id,
561
- });
1650
+ if (payload.type === 'function_call_output' || payload.type === 'custom_tool_call_output') {
1651
+ const callId = readNonEmptyString(payload.call_id) ?? '';
1652
+ const rawOutput = extractCodexToolOutput(payload.output);
1653
+
1654
+ // A wait / stdin poll delivers the tail of an earlier command's output.
1655
+ const continuedCallId = waitCallToExecCall.get(callId);
1656
+ if (continuedCallId) {
1657
+ const cleaned = readCodexStdinOutput(rawOutput) ?? cleanCodexShellOutput(rawOutput);
1658
+ const accumulated = `${pendingExecOutput.get(continuedCallId) || ''}${cleaned.output}`;
1659
+ pendingExecOutput.set(continuedCallId, accumulated);
1660
+ if (!cleaned.runningCellId) {
1661
+ if (runningExecCallId === continuedCallId) {
1662
+ runningExecCallId = null;
562
1663
  }
1664
+ pushToolResult(
1665
+ continuedCallId,
1666
+ timestamp,
1667
+ accumulated,
1668
+ cleaned.exitCode !== undefined && cleaned.exitCode !== 0,
1669
+ );
563
1670
  }
1671
+ continue;
1672
+ }
564
1673
 
565
- if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') {
566
- if (ignoredToolCallIds.has(entry.payload.call_id)) {
567
- continue;
1674
+ const subagent = subagentsByCallId.get(callId);
1675
+ if (subagent) {
1676
+ try {
1677
+ const taskPath = readNonEmptyString((JSON.parse(rawOutput) as AnyRecord).task_name);
1678
+ if (taskPath) {
1679
+ subagent.agentPath = taskPath;
1680
+ subagentsByPath.set(taskPath, subagent);
568
1681
  }
1682
+ } catch {
1683
+ // The sub_agent_activity event normally supplies the path.
1684
+ }
1685
+ continue;
1686
+ }
569
1687
 
570
- const output = extractCodexToolOutput(entry.payload.output);
571
- if (execToolCallIds.has(entry.payload.call_id)) {
572
- const runningOutput = readRunningExecOutput(output);
573
- if (runningOutput) {
574
- execCallByCellId.set(runningOutput.cellId, entry.payload.call_id);
575
- pendingExecOutput.set(entry.payload.call_id, runningOutput.content);
576
- continue;
577
- }
578
- completedExecCalls.add(entry.payload.call_id);
579
- }
1688
+ if (ignoredToolCallIds.has(callId)) {
1689
+ continue;
1690
+ }
580
1691
 
581
- messages.push({
582
- type: 'tool_result',
583
- timestamp: entry.timestamp,
584
- toolCallId: entry.payload.call_id,
585
- output,
586
- });
1692
+ if (shellCallMessages.has(callId)) {
1693
+ const cleaned = cleanCodexShellOutput(rawOutput);
1694
+ if (cleaned.runningCellId) {
1695
+ execCallByCellId.set(cleaned.runningCellId, callId);
1696
+ pendingExecOutput.set(callId, cleaned.output);
1697
+ runningExecCallId = callId;
1698
+ continue;
587
1699
  }
588
- } catch {
589
- // Skip malformed lines.
1700
+ const accumulated = `${pendingExecOutput.get(callId) || ''}${cleaned.output}`;
1701
+ pushToolResult(
1702
+ callId,
1703
+ timestamp,
1704
+ accumulated,
1705
+ cleaned.exitCode !== undefined && cleaned.exitCode !== 0,
1706
+ );
1707
+ continue;
590
1708
  }
1709
+
1710
+ pushToolResult(callId, timestamp, rawOutput, false);
591
1711
  }
1712
+ }
592
1713
 
593
- messages.sort(
594
- (a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(),
595
- );
596
- const total = messages.length;
597
-
598
- if (limit !== null) {
599
- const startIndex = Math.max(0, total - offset - limit);
600
- const endIndex = total - offset;
601
- const paginatedMessages = messages.slice(startIndex, endIndex);
602
- const hasMore = startIndex > 0;
603
-
604
- return {
605
- messages: paginatedMessages,
606
- total,
607
- hasMore,
608
- offset,
609
- limit,
610
- tokenUsage,
611
- };
1714
+ // Every file row needs a result or the UI shows it as still running. The
1715
+ // patch outcome is known only after `patch_apply_end` (which may never
1716
+ // arrive), so results are emitted once the whole file has been read.
1717
+ for (const group of patchGroups.values()) {
1718
+ const failed = group.success === false;
1719
+ for (const row of group.rows) {
1720
+ messages.push({
1721
+ type: 'tool_result',
1722
+ timestamp: group.timestamp,
1723
+ toolCallId: row.toolCallId,
1724
+ output: failed ? (group.failure || 'Patch failed') : '',
1725
+ isError: failed,
1726
+ });
612
1727
  }
1728
+ }
613
1729
 
614
- return { messages, tokenUsage };
615
- } catch (error) {
616
- console.error(`Error reading Codex session messages for ${sessionId}:`, error);
617
- return { messages: [], total: 0, hasMore: false };
1730
+ await attachCodexSubagentTranscripts(sessionFilePath, subagentsByCallId);
1731
+
1732
+ // A rollback is recorded after the turns it retires, so a prompt can be
1733
+ // anchored and then retired later in the same file. Its rows still render —
1734
+ // they are what the conversation looked like — but the turn is gone from the
1735
+ // thread, so the anchor goes with it and the message loses its pencil.
1736
+ for (const message of messages) {
1737
+ if (typeof message.turnId === 'string' && turns.isRolledBack(message.turnId)) {
1738
+ delete message.turnId;
1739
+ }
1740
+ }
1741
+
1742
+ messages.sort((a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime());
1743
+ return { messages, tokenUsage: tokenUsage ?? undefined };
1744
+ }
1745
+
1746
+ /**
1747
+ * Loads each spawned agent's own rollout and hangs its timeline off the
1748
+ * `Task` row that started it.
1749
+ */
1750
+ async function attachCodexSubagentTranscripts(
1751
+ parentFilePath: string,
1752
+ subagentsByCallId: Map<string, CodexSubagentRecord>,
1753
+ ): Promise<void> {
1754
+ for (const record of subagentsByCallId.values()) {
1755
+ let parsedInput: AnyRecord = {};
1756
+ try {
1757
+ parsedInput = JSON.parse(String(record.message.toolInput || '{}')) as AnyRecord;
1758
+ } catch {
1759
+ parsedInput = {};
1760
+ }
1761
+
1762
+ const agentName = record.agentPath?.split('/').filter(Boolean).pop();
1763
+ // Codex has no agent-type concept the way Claude does, so `type` is left
1764
+ // unset rather than filled with the provider's own name; the panel falls
1765
+ // back to a neutral label.
1766
+ const subagent: SubagentInfo = {
1767
+ id: record.agentThreadId ?? record.toolCallId,
1768
+ description: readNonEmptyString(parsedInput.description as string | undefined) ?? agentName,
1769
+ status: record.isComplete ? 'completed' : 'running',
1770
+ };
1771
+
1772
+ if (record.agentThreadId) {
1773
+ const rolloutPath = await findCodexSubagentRollout(parentFilePath, record.agentThreadId);
1774
+ if (rolloutPath) {
1775
+ const transcript = await readCodexSubagentTranscript(rolloutPath);
1776
+ if (transcript.activity.length > 0) {
1777
+ record.message.subagentTools = transcript.activity
1778
+ .slice(0, MAX_TRANSMITTED_SUBAGENT_ACTIVITIES)
1779
+ .map(truncateSubagentActivity);
1780
+ subagent.activityCount = transcript.activity.length;
1781
+ }
1782
+ subagent.name = transcript.nickname ?? agentName;
1783
+ subagent.model = transcript.model;
1784
+ }
1785
+ }
1786
+
1787
+ subagent.name = subagent.name ?? agentName;
1788
+ record.message.subagent = subagent;
618
1789
  }
619
1790
  }
620
1791
 
621
1792
  export class CodexSessionsProvider implements IProviderSessions {
1793
+ /**
1794
+ * Resolves the last turn to keep when the turn `anchorId` names is replaced.
1795
+ *
1796
+ * `thread/fork`'s `lastTurnId` is inclusive of the turn it names, which is
1797
+ * the same thing this contract's `resumeThroughId` means, so the answer is
1798
+ * simply the turn before the edited one. Editing the first prompt leaves
1799
+ * nothing to keep, and `null` is how that is reported.
1800
+ */
1801
+ async resolveEditAnchor(
1802
+ sessionId: string,
1803
+ anchorId: string,
1804
+ ): Promise<{ found: boolean; resumeThroughId: string | null }> {
1805
+ const session = sessionsDb.getSessionById(sessionId);
1806
+ const jsonlPath = session?.jsonl_path;
1807
+ if (!jsonlPath || !session?.provider_session_id) {
1808
+ return { found: false, resumeThroughId: null };
1809
+ }
1810
+
1811
+ const liveTurnIds = await readCodexLiveTurnIds(jsonlPath);
1812
+ const anchorIndex = liveTurnIds.indexOf(anchorId);
1813
+ if (anchorIndex < 0) {
1814
+ return { found: false, resumeThroughId: null };
1815
+ }
1816
+
1817
+ return {
1818
+ found: true,
1819
+ resumeThroughId: anchorIndex === 0 ? null : liveTurnIds[anchorIndex - 1],
1820
+ };
1821
+ }
1822
+
1823
+ /**
1824
+ * Branches the conversation at `keepThroughId` and moves the session onto
1825
+ * the branch.
1826
+ *
1827
+ * Codex threads are append-only — the SDK resumes one at its tip and nothing
1828
+ * shortens it — so rewinding means copying the part being kept into a new
1829
+ * thread. The pre-edit thread stays on disk in full and is recorded as
1830
+ * superseded so the session indexer does not hand it back later.
1831
+ */
1832
+ async rewindSession(sessionId: string, keepThroughId: string | null): Promise<void> {
1833
+ const session = sessionsDb.getSessionById(sessionId);
1834
+ const supersededThreadId = session?.provider_session_id;
1835
+ if (!session || !supersededThreadId) {
1836
+ throw new AppError('This session has not produced a transcript yet.', {
1837
+ code: 'EDIT_SOURCE_NOT_READY',
1838
+ statusCode: 409,
1839
+ });
1840
+ }
1841
+
1842
+ // Nothing of the conversation survives an edit to its first prompt, and
1843
+ // there is no such thing as a fork of no turns, so the session is simply
1844
+ // detached and the next run opens a new thread.
1845
+ if (keepThroughId === null) {
1846
+ sessionsDb.markProviderSessionSuperseded({
1847
+ providerSessionId: supersededThreadId,
1848
+ provider: PROVIDER,
1849
+ sessionId,
1850
+ jsonlPath: session.jsonl_path ?? null,
1851
+ });
1852
+ sessionsDb.detachProviderSession(sessionId);
1853
+ return;
1854
+ }
1855
+
1856
+ const fork = await codexAppServer.forkThread({
1857
+ threadId: supersededThreadId,
1858
+ lastTurnId: keepThroughId,
1859
+ cwd: session.project_path ?? '',
1860
+ });
1861
+
1862
+ sessionsDb.markProviderSessionSuperseded({
1863
+ providerSessionId: supersededThreadId,
1864
+ provider: PROVIDER,
1865
+ sessionId,
1866
+ jsonlPath: session.jsonl_path ?? null,
1867
+ });
1868
+ sessionsDb.repointSessionToProviderSession(sessionId, {
1869
+ providerSessionId: fork.threadId,
1870
+ jsonlPath: fork.path,
1871
+ });
1872
+ }
1873
+
622
1874
  /**
623
1875
  * Normalizes a persisted Codex JSONL entry.
624
1876
  *
625
1877
  * Live Codex SDK events are transformed before they reach normalizeMessage(),
626
- * while history entries already use a compact message/tool shape from projects.js.
1878
+ * while history entries already use the compact message/tool shape produced
1879
+ * by getCodexSessionMessages().
627
1880
  */
628
1881
  private normalizeHistoryEntry(raw: AnyRecord, sessionId: string | null): NormalizedMessage[] {
629
1882
  const ts = raw.timestamp || new Date().toISOString();
630
1883
  const baseId = raw.uuid || generateMessageId('codex');
631
1884
 
1885
+ if (raw.type === 'status_note' && typeof raw.content === 'string') {
1886
+ return [createNormalizedMessage({
1887
+ id: baseId,
1888
+ sessionId,
1889
+ timestamp: ts,
1890
+ provider: PROVIDER,
1891
+ kind: 'task_notification',
1892
+ summary: raw.content,
1893
+ status: 'info',
1894
+ })];
1895
+ }
1896
+
632
1897
  if (raw.type === 'thinking' || raw.isReasoning) {
633
- const thinkingContent = typeof raw.message?.content === 'string'
634
- ? raw.message.content
635
- : '';
1898
+ const thinkingContent = typeof raw.message?.content === 'string' ? raw.message.content : '';
636
1899
  if (!thinkingContent.trim()) {
637
1900
  return [];
638
1901
  }
@@ -651,9 +1914,9 @@ export class CodexSessionsProvider implements IProviderSessions {
651
1914
  ? raw.message.content
652
1915
  : Array.isArray(raw.message.content)
653
1916
  ? raw.message.content
654
- .map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '')
655
- .filter(Boolean)
656
- .join('\n')
1917
+ .map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '')
1918
+ .filter(Boolean)
1919
+ .join('\n')
657
1920
  : String(raw.message.content || '');
658
1921
  const parsedFiles = parseFilesInputTag(content);
659
1922
  const rawImages = Array.isArray(raw.images) && raw.images.length > 0 ? raw.images : undefined;
@@ -671,6 +1934,10 @@ export class CodexSessionsProvider implements IProviderSessions {
671
1934
  content: parsedFiles.text,
672
1935
  images: rawImages,
673
1936
  files,
1937
+ // The enclosing turn, when the reader could name one. `baseId` is not
1938
+ // usable for this: Codex rows carry no id, so it is synthesized per
1939
+ // read and would address a different message every time.
1940
+ transcriptAnchorId: readNonEmptyString(raw.turnId),
674
1941
  })];
675
1942
  }
676
1943
 
@@ -679,9 +1946,9 @@ export class CodexSessionsProvider implements IProviderSessions {
679
1946
  ? raw.message.content
680
1947
  : Array.isArray(raw.message.content)
681
1948
  ? raw.message.content
682
- .map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '')
683
- .filter(Boolean)
684
- .join('\n')
1949
+ .map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '')
1950
+ .filter(Boolean)
1951
+ .join('\n')
685
1952
  : '';
686
1953
  if (!content.trim()) {
687
1954
  return [];
@@ -694,6 +1961,7 @@ export class CodexSessionsProvider implements IProviderSessions {
694
1961
  kind: 'text',
695
1962
  role: 'assistant',
696
1963
  content,
1964
+ memoryCitations: raw.memoryCitations,
697
1965
  })];
698
1966
  }
699
1967
 
@@ -707,6 +1975,9 @@ export class CodexSessionsProvider implements IProviderSessions {
707
1975
  toolName: raw.toolName || 'Unknown',
708
1976
  toolInput: raw.toolInput,
709
1977
  toolId: raw.toolCallId || baseId,
1978
+ subagentTools: raw.subagentTools,
1979
+ subagent: raw.subagent,
1980
+ memoryCitations: raw.memoryCitations,
710
1981
  })];
711
1982
  }
712
1983
 
@@ -743,92 +2014,157 @@ export class CodexSessionsProvider implements IProviderSessions {
743
2014
  const baseId = raw.uuid || generateMessageId('codex');
744
2015
 
745
2016
  if (raw.type === 'item') {
2017
+ // Live items carry a stable SDK id, so an in-progress row and its later
2018
+ // completion collapse onto the same transcript entry instead of stacking.
2019
+ const itemId = readNonEmptyString(raw.itemId as string | undefined) ?? baseId;
2020
+
746
2021
  switch (raw.itemType) {
747
- case 'agent_message':
2022
+ case 'agent_message': {
2023
+ const cited = readCodexMemoryCitations(String(raw.message?.content || ''));
2024
+ const text = cited.text;
2025
+ const proposedPlan = readCodexProposedPlan(text);
2026
+ if (proposedPlan) {
2027
+ // Same unification as history: a proposed plan is a plan card, not
2028
+ // an assistant paragraph that happens to start with a tag.
2029
+ return [createNormalizedMessage({
2030
+ id: itemId,
2031
+ sessionId,
2032
+ timestamp: ts,
2033
+ provider: PROVIDER,
2034
+ kind: 'tool_use',
2035
+ toolName: 'ExitPlanMode',
2036
+ toolInput: { plan: proposedPlan },
2037
+ toolId: itemId,
2038
+ memoryCitations: cited.memoryCitations,
2039
+ })];
2040
+ }
748
2041
  return [createNormalizedMessage({
749
- id: baseId,
2042
+ id: itemId,
750
2043
  sessionId,
751
2044
  timestamp: ts,
752
2045
  provider: PROVIDER,
753
2046
  kind: 'text',
754
2047
  role: 'assistant',
755
- content: raw.message?.content || '',
2048
+ content: text,
2049
+ memoryCitations: cited.memoryCitations,
756
2050
  })];
2051
+ }
757
2052
  case 'reasoning':
758
2053
  return [createNormalizedMessage({
759
- id: baseId,
2054
+ id: itemId,
760
2055
  sessionId,
761
2056
  timestamp: ts,
762
2057
  provider: PROVIDER,
763
2058
  kind: 'thinking',
764
2059
  content: raw.message?.content || '',
765
2060
  })];
766
- case 'command_execution':
767
- return [createNormalizedMessage({
768
- id: baseId,
2061
+ case 'command_execution': {
2062
+ const toolUse = createNormalizedMessage({
2063
+ id: itemId,
769
2064
  sessionId,
770
2065
  timestamp: ts,
771
2066
  provider: PROVIDER,
772
2067
  kind: 'tool_use',
773
2068
  toolName: 'Bash',
774
2069
  toolInput: { command: raw.command },
775
- toolId: baseId,
776
- output: raw.output,
777
- exitCode: raw.exitCode,
2070
+ toolId: itemId,
778
2071
  status: raw.status,
2072
+ });
2073
+ const partialOutput = typeof raw.output === 'string' ? raw.output : '';
2074
+ if (raw.status === 'in_progress' && !partialOutput) {
2075
+ return [toolUse];
2076
+ }
2077
+ // The SDK reports a command's output on the item itself; the UI reads
2078
+ // tool output from a paired result, so one is emitted here. Emitting
2079
+ // it while the command is still running is what lets its output
2080
+ // stream into the row instead of appearing only at the end.
2081
+ return [toolUse, createNormalizedMessage({
2082
+ id: `${itemId}_result`,
2083
+ sessionId,
2084
+ timestamp: ts,
2085
+ provider: PROVIDER,
2086
+ kind: 'tool_result',
2087
+ toolId: itemId,
2088
+ content: partialOutput,
2089
+ isError: raw.status === 'failed' || (typeof raw.exitCode === 'number' && raw.exitCode !== 0),
779
2090
  })];
780
- case 'file_change':
781
- return [createNormalizedMessage({
782
- id: baseId,
2091
+ }
2092
+ case 'file_change': {
2093
+ // One row per file so each change gets the same diff view as an Edit.
2094
+ const changes = Array.isArray(raw.changes) ? raw.changes : [];
2095
+ return changes.map((change: AnyRecord, index: number) => createNormalizedMessage({
2096
+ id: `${itemId}_${index}`,
783
2097
  sessionId,
784
2098
  timestamp: ts,
785
2099
  provider: PROVIDER,
786
2100
  kind: 'tool_use',
787
- toolName: 'FileChanges',
788
- toolInput: raw.changes,
789
- toolId: baseId,
2101
+ toolName: change?.kind === 'add' ? 'Write' : 'Edit',
2102
+ toolInput: { file_path: change?.path, old_string: '', new_string: '' },
2103
+ toolId: `${itemId}_${index}`,
790
2104
  status: raw.status,
791
- })];
792
- case 'mcp_tool_call':
793
- return [createNormalizedMessage({
794
- id: baseId,
2105
+ }));
2106
+ }
2107
+ case 'mcp_tool_call': {
2108
+ const toolName = raw.server ? `mcp__${String(raw.server)}__${String(raw.tool ?? 'tool')}` : String(raw.tool || 'MCP');
2109
+ const toolUse = createNormalizedMessage({
2110
+ id: itemId,
795
2111
  sessionId,
796
2112
  timestamp: ts,
797
2113
  provider: PROVIDER,
798
2114
  kind: 'tool_use',
799
- toolName: raw.tool || 'MCP',
2115
+ toolName,
800
2116
  toolInput: raw.arguments,
801
- toolId: baseId,
802
- server: raw.server,
803
- result: raw.result,
804
- error: raw.error,
2117
+ toolId: itemId,
805
2118
  status: raw.status,
2119
+ });
2120
+ if (raw.status === 'in_progress') {
2121
+ return [toolUse];
2122
+ }
2123
+ return [toolUse, createNormalizedMessage({
2124
+ id: `${itemId}_result`,
2125
+ sessionId,
2126
+ timestamp: ts,
2127
+ provider: PROVIDER,
2128
+ kind: 'tool_result',
2129
+ toolId: itemId,
2130
+ content: raw.error
2131
+ ? String((raw.error as AnyRecord)?.message ?? raw.error)
2132
+ : JSON.stringify(raw.result ?? ''),
2133
+ isError: Boolean(raw.error) || raw.status === 'failed',
806
2134
  })];
2135
+ }
807
2136
  case 'web_search':
808
2137
  return [createNormalizedMessage({
809
- id: baseId,
2138
+ id: itemId,
810
2139
  sessionId,
811
2140
  timestamp: ts,
812
2141
  provider: PROVIDER,
813
2142
  kind: 'tool_use',
814
2143
  toolName: 'WebSearch',
815
2144
  toolInput: { query: raw.query },
816
- toolId: baseId,
2145
+ toolId: itemId,
817
2146
  })];
818
2147
  case 'todo_list':
2148
+ // Codex's live plan uses `{text, completed}`; Claude's TodoWrite uses
2149
+ // `{content, status}`. Normalizing here means one renderer, not two.
819
2150
  return [createNormalizedMessage({
820
- id: baseId,
2151
+ id: itemId,
821
2152
  sessionId,
822
2153
  timestamp: ts,
823
2154
  provider: PROVIDER,
824
2155
  kind: 'tool_use',
825
- toolName: 'TodoList',
826
- toolInput: { items: raw.items },
827
- toolId: baseId,
2156
+ toolName: 'TodoWrite',
2157
+ toolInput: {
2158
+ todos: (Array.isArray(raw.items) ? raw.items : []).map((item: AnyRecord) => ({
2159
+ content: String(item?.text ?? ''),
2160
+ status: item?.completed ? 'completed' : 'pending',
2161
+ })),
2162
+ },
2163
+ toolId: itemId,
828
2164
  })];
829
2165
  case 'error':
830
2166
  return [createNormalizedMessage({
831
- id: baseId,
2167
+ id: itemId,
832
2168
  sessionId,
833
2169
  timestamp: ts,
834
2170
  provider: PROVIDER,
@@ -837,14 +2173,14 @@ export class CodexSessionsProvider implements IProviderSessions {
837
2173
  })];
838
2174
  default:
839
2175
  return [createNormalizedMessage({
840
- id: baseId,
2176
+ id: itemId,
841
2177
  sessionId,
842
2178
  timestamp: ts,
843
2179
  provider: PROVIDER,
844
2180
  kind: 'tool_use',
845
2181
  toolName: raw.itemType || 'Unknown',
846
2182
  toolInput: raw.item || raw,
847
- toolId: baseId,
2183
+ toolId: itemId,
848
2184
  })];
849
2185
  }
850
2186
  }
@@ -873,8 +2209,8 @@ export class CodexSessionsProvider implements IProviderSessions {
873
2209
  }
874
2210
 
875
2211
  /**
876
- * Loads Codex JSONL history and keeps token usage metadata when projects.js
877
- * provides it.
2212
+ * Loads Codex JSONL history and keeps token usage metadata when the
2213
+ * transcript reported it.
878
2214
  */
879
2215
  async fetchHistory(
880
2216
  sessionId: string,
@@ -884,20 +2220,15 @@ export class CodexSessionsProvider implements IProviderSessions {
884
2220
 
885
2221
  let result: CodexHistoryResult;
886
2222
  try {
887
- // Load full history first so `total` reflects frontend-normalized messages,
888
- // not raw JSONL records.
889
- result = await getCodexSessionMessages(sessionId, null, 0);
2223
+ result = await getCodexSessionMessages(sessionId);
890
2224
  } catch (error) {
891
2225
  const message = error instanceof Error ? error.message : String(error);
892
2226
  console.warn(`[CodexProvider] Failed to load session ${sessionId}:`, message);
893
2227
  return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
894
2228
  }
895
2229
 
896
- const rawMessages = Array.isArray(result) ? result : (result.messages || []);
897
- const tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage;
898
-
899
2230
  const normalized: NormalizedMessage[] = [];
900
- for (const raw of rawMessages) {
2231
+ for (const raw of result.messages) {
901
2232
  normalized.push(...this.normalizeHistoryEntry(raw, sessionId));
902
2233
  }
903
2234
 
@@ -916,15 +2247,13 @@ export class CodexSessionsProvider implements IProviderSessions {
916
2247
  }
917
2248
  }
918
2249
 
919
- let total = 0;
920
- for (const msg of normalized) {
921
- if (msg.kind !== 'tool_result') {
922
- total += 1;
923
- }
924
- }
2250
+ // Everything the transcript draws, and nothing else — so a page of N rows
2251
+ // is N rows the user sees, and `total` counts the same thing.
2252
+ const transcript = prepareTranscriptMessages(normalized);
2253
+ const total = transcript.length;
925
2254
  const normalizedOffset = Math.max(0, offset);
926
2255
  const normalizedLimit = limit === null ? null : Math.max(0, limit);
927
- const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
2256
+ const { page, hasMore } = sliceTailPage(transcript, normalizedLimit, normalizedOffset);
928
2257
 
929
2258
  return {
930
2259
  messages: page,
@@ -932,7 +2261,7 @@ export class CodexSessionsProvider implements IProviderSessions {
932
2261
  hasMore,
933
2262
  offset: normalizedOffset,
934
2263
  limit: normalizedLimit,
935
- tokenUsage,
2264
+ tokenUsage: result.tokenUsage,
936
2265
  };
937
2266
  }
938
2267
  }