@cloudcli-ai/cloudcli 1.37.2 → 1.37.4

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