@cloudcli-ai/cloudcli 1.37.2 → 1.37.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. package/README.md +269 -0
  2. package/dist/assets/{abnfDiagram-N423BO3Z-C4UAoHJb.js → abnfDiagram-N423BO3Z-CJntskwA.js} +1 -1
  3. package/dist/assets/{arc-B8jF-ja9.js → arc-CIfH4uMN.js} +1 -1
  4. package/dist/assets/{architectureDiagram-T3A2C74G-C_Wur_CU.js → architectureDiagram-T3A2C74G-_IpEkukg.js} +1 -1
  5. package/dist/assets/{blockDiagram-VBNYF7ZC-Cq4P1i8t.js → blockDiagram-VBNYF7ZC-BttxrpgZ.js} +1 -1
  6. package/dist/assets/{c4Diagram-5PPSVZJV-C1wG4eUj.js → c4Diagram-5PPSVZJV-g51dSRr1.js} +1 -1
  7. package/dist/assets/channel-C5xU9jMw.js +1 -0
  8. package/dist/assets/{chunk-2GRJ4B5K-B8fnBG6E.js → chunk-2GRJ4B5K-D_-LbQUa.js} +1 -1
  9. package/dist/assets/{chunk-2Q5K7J3B-WdfPooPJ.js → chunk-2Q5K7J3B-BtlHzwaO.js} +1 -1
  10. package/dist/assets/{chunk-5RXB4S5H-DhZJuG2E.js → chunk-5RXB4S5H-UzkpvpAT.js} +1 -1
  11. package/dist/assets/{chunk-5VM5RSS4-B0rjslal.js → chunk-5VM5RSS4-BKwx5Gag.js} +1 -1
  12. package/dist/assets/{chunk-6Q2QTUOP-CG2Oj4jL.js → chunk-6Q2QTUOP-D_KEBH5f.js} +1 -1
  13. package/dist/assets/{chunk-GF5L2VYU-rrl2lRxY.js → chunk-GF5L2VYU-rK2DGyto.js} +1 -1
  14. package/dist/assets/{chunk-JWPE2WC7-CjVbrVMA.js → chunk-JWPE2WC7-CeBl_N9i.js} +1 -1
  15. package/dist/assets/{chunk-KBJHAD2P-CRzKgU8S.js → chunk-KBJHAD2P-BiQLWVIr.js} +1 -1
  16. package/dist/assets/{chunk-RYQCIY6F-DBz4GTZF.js → chunk-RYQCIY6F-C1knVGV5.js} +1 -1
  17. package/dist/assets/{chunk-XXDRQBXY-wHxalsXk.js → chunk-XXDRQBXY-CtKbJkvF.js} +1 -1
  18. package/dist/assets/classDiagram-JCYQIIEL-CRrL3vp7.js +1 -0
  19. package/dist/assets/classDiagram-v2-OCEON4UE-CRrL3vp7.js +1 -0
  20. package/dist/assets/{cose-bilkent-JH36ORCC-s1hygY8e.js → cose-bilkent-JH36ORCC-DjX8y40Z.js} +1 -1
  21. package/dist/assets/{cynefin-VYW2F7L2-C4LGV15i.js → cynefin-VYW2F7L2-DM6YeRba.js} +1 -1
  22. package/dist/assets/{cynefinDiagram-MW4NZA55-C5uhu7B3.js → cynefinDiagram-MW4NZA55-wZJ-vy5B.js} +1 -1
  23. package/dist/assets/{dagre-VZM6K2ZE-B-OBTewm.js → dagre-VZM6K2ZE-BzP0ejkH.js} +1 -1
  24. package/dist/assets/{diagram-7IWD3JNH-BjGKQxih.js → diagram-7IWD3JNH-BTL_j23J.js} +1 -1
  25. package/dist/assets/{diagram-B4RE2ZJO-BMKz1X2L.js → diagram-B4RE2ZJO-Cf1Norgu.js} +1 -1
  26. package/dist/assets/{diagram-LBJQPF4R-DNh-GhKC.js → diagram-LBJQPF4R-DPiUEKr-.js} +1 -1
  27. package/dist/assets/{diagram-Q27KOJAE-BaTG9jPT.js → diagram-Q27KOJAE-CTcTpiz5.js} +1 -1
  28. package/dist/assets/{diagram-UB23O5K3-BUwo2OvJ.js → diagram-UB23O5K3-DPMSdYm0.js} +1 -1
  29. package/dist/assets/{ebnfDiagram-BXEA7PRR-DAZgfzX2.js → ebnfDiagram-BXEA7PRR-7yvD6uiF.js} +1 -1
  30. package/dist/assets/{erDiagram-JOGREHBK-1w1CcGEb.js → erDiagram-JOGREHBK-CfgeDoV6.js} +1 -1
  31. package/dist/assets/{flowDiagram-UKHOOZJN-D4EII8Jj.js → flowDiagram-UKHOOZJN-B27ng5zu.js} +1 -1
  32. package/dist/assets/{ganttDiagram-PKOTCBZU-ChvJZAgB.js → ganttDiagram-PKOTCBZU-CcC6b_7t.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-DS77QQ5N-DtOn41S8.js → gitGraphDiagram-DS77QQ5N-BX5kPGrO.js} +1 -1
  34. package/dist/assets/index-BTDHQ6Id.css +32 -0
  35. package/dist/assets/index-sFfz8iBR.js +4931 -0
  36. package/dist/assets/{infoDiagram-6WML65LV-DN_PVEJZ.js → infoDiagram-6WML65LV-CUP-SlN4.js} +1 -1
  37. package/dist/assets/{ishikawaDiagram-WSZJBQD7-DbkF1kXb.js → ishikawaDiagram-WSZJBQD7-BTTDhOpX.js} +1 -1
  38. package/dist/assets/{journeyDiagram-NVQOT4AX-pJwmhddE.js → journeyDiagram-NVQOT4AX-ARXcc61m.js} +1 -1
  39. package/dist/assets/{kanban-definition-27J2QSJJ-PPAPgNp5.js → kanban-definition-27J2QSJJ-BieMpqMA.js} +1 -1
  40. package/dist/assets/{linear-iPR-J9f7.js → linear-61vxjRnP.js} +1 -1
  41. package/dist/assets/{mermaid.core-BEUL86Z6.js → mermaid.core-nhnwkfQx.js} +5 -5
  42. package/dist/assets/{mindmap-definition-FAOFIHXS-BvZoBib7.js → mindmap-definition-FAOFIHXS-Cm6x5vT1.js} +1 -1
  43. package/dist/assets/{pegDiagram-VL7TDLO6-DhYreaUd.js → pegDiagram-VL7TDLO6-CcYBq_9H.js} +1 -1
  44. package/dist/assets/{pieDiagram-7S7Q4E2Y-CrOHlyD0.js → pieDiagram-7S7Q4E2Y-CfvIjfzo.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-CIZ2JOQS-D4g5yH3t.js → quadrantDiagram-CIZ2JOQS-SgYoWPNb.js} +1 -1
  46. package/dist/assets/{railroadDiagram-AXF67PYL-Dj39lHlf.js → railroadDiagram-AXF67PYL-D2Mu5E_f.js} +1 -1
  47. package/dist/assets/{requirementDiagram-LRYGKXZP-1AS4Zgm_.js → requirementDiagram-LRYGKXZP-Azwe_k4Q.js} +1 -1
  48. package/dist/assets/{sankeyDiagram-W5VNT64P-CKlMn19P.js → sankeyDiagram-W5VNT64P-DN1Ju3PA.js} +1 -1
  49. package/dist/assets/{sequenceDiagram-SI44F4Z6-B_j3qyvo.js → sequenceDiagram-SI44F4Z6-Bj4qC34S.js} +1 -1
  50. package/dist/assets/server.browser-CtX3Hdl5.js +27 -0
  51. package/dist/assets/{sizeCapture-X5ZJPWSS-Bp2Op16v.js → sizeCapture-X5ZJPWSS-Co4PdklG.js} +1 -1
  52. package/dist/assets/{stateDiagram-OKZ733FA-C0cN6DoY.js → stateDiagram-OKZ733FA-nOvL9dRC.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-UEYNNEHI-DNv8ppw7.js +1 -0
  54. package/dist/assets/{swimlanes-SLNWSIFB-BEvXOV9b.js → swimlanes-SLNWSIFB-CCK3wMid.js} +2 -2
  55. package/dist/assets/swimlanesDiagram-ULZ7WXOC-DnfbNQEp.js +8 -0
  56. package/dist/assets/{timeline-definition-Z64GVDOM-zdJQinvZ.js → timeline-definition-Z64GVDOM-BC29RNFm.js} +1 -1
  57. package/dist/assets/{vendor-codemirror-OwyKSvPE.js → vendor-codemirror-CYrSt7-U.js} +18 -18
  58. package/dist/assets/{vennDiagram-T6HMQDX7-DuF1uZMY.js → vennDiagram-T6HMQDX7-pZlZCtEo.js} +1 -1
  59. package/dist/assets/{wardleyDiagram-T6FBY63Y-B9AlLQup.js → wardleyDiagram-T6FBY63Y-De8k2-zM.js} +1 -1
  60. package/dist/assets/{xychartDiagram-ELKLHX3M-DTJX48Qg.js → xychartDiagram-ELKLHX3M-Bk8ykH0w.js} +1 -1
  61. package/dist/index.html +3 -3
  62. package/dist-server/server/index.js +7 -3
  63. package/dist-server/server/index.js.map +1 -1
  64. package/dist-server/server/modules/agent/agent.routes.js +2 -2
  65. package/dist-server/server/modules/agent/agent.routes.js.map +1 -1
  66. package/dist-server/server/modules/agent/tests/agent.routes.test.js +30 -1
  67. package/dist-server/server/modules/agent/tests/agent.routes.test.js.map +1 -1
  68. package/dist-server/server/modules/browser-use/browser-use.routes.js +39 -33
  69. package/dist-server/server/modules/browser-use/browser-use.routes.js.map +1 -1
  70. package/dist-server/server/modules/database/connection.js +1 -1
  71. package/dist-server/server/modules/database/connection.js.map +1 -1
  72. package/dist-server/server/modules/database/index.js +5 -0
  73. package/dist-server/server/modules/database/index.js.map +1 -1
  74. package/dist-server/server/modules/database/migrations.js +32 -1
  75. package/dist-server/server/modules/database/migrations.js.map +1 -1
  76. package/dist-server/server/modules/database/repositories/scheduled-messages.db.js +71 -0
  77. package/dist-server/server/modules/database/repositories/scheduled-messages.db.js.map +1 -0
  78. package/dist-server/server/modules/database/repositories/session-drafts.db.js +105 -0
  79. package/dist-server/server/modules/database/repositories/session-drafts.db.js.map +1 -0
  80. package/dist-server/server/modules/database/repositories/sessions.db.js +137 -5
  81. package/dist-server/server/modules/database/repositories/sessions.db.js.map +1 -1
  82. package/dist-server/server/modules/database/repositories/user-preferences.db.js +65 -0
  83. package/dist-server/server/modules/database/repositories/user-preferences.db.js.map +1 -0
  84. package/dist-server/server/modules/database/schema.js +92 -0
  85. package/dist-server/server/modules/database/schema.js.map +1 -1
  86. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js +39 -0
  87. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js.map +1 -1
  88. package/dist-server/server/modules/database/tests/user-preferences.db.integration.test.js +105 -0
  89. package/dist-server/server/modules/database/tests/user-preferences.db.integration.test.js.map +1 -0
  90. package/dist-server/server/modules/projects/services/project-delete.service.js +12 -1
  91. package/dist-server/server/modules/projects/services/project-delete.service.js.map +1 -1
  92. package/dist-server/server/modules/projects/services/project-star.service.js +2 -2
  93. package/dist-server/server/modules/projects/services/project-star.service.js.map +1 -1
  94. package/dist-server/server/modules/providers/index.js +3 -0
  95. package/dist-server/server/modules/providers/index.js.map +1 -1
  96. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js +20 -1
  97. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js.map +1 -1
  98. package/dist-server/server/modules/providers/list/claude/claude-fork.provider.js +46 -0
  99. package/dist-server/server/modules/providers/list/claude/claude-fork.provider.js.map +1 -0
  100. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js +35 -6
  101. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js.map +1 -1
  102. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js +168 -38
  103. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js.map +1 -1
  104. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js +388 -50
  105. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js.map +1 -1
  106. package/dist-server/server/modules/providers/list/claude/claude-skills.provider.js +4 -1
  107. package/dist-server/server/modules/providers/list/claude/claude-skills.provider.js.map +1 -1
  108. package/dist-server/server/modules/providers/list/claude/claude.provider.js +2 -0
  109. package/dist-server/server/modules/providers/list/claude/claude.provider.js.map +1 -1
  110. package/dist-server/server/modules/providers/list/codex/codex-app-server.client.js +204 -0
  111. package/dist-server/server/modules/providers/list/codex/codex-app-server.client.js.map +1 -0
  112. package/dist-server/server/modules/providers/list/codex/codex-fork.provider.js +32 -0
  113. package/dist-server/server/modules/providers/list/codex/codex-fork.provider.js.map +1 -0
  114. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +16 -0
  115. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -1
  116. package/dist-server/server/modules/providers/list/codex/codex-runtime.provider.js +60 -49
  117. package/dist-server/server/modules/providers/list/codex/codex-runtime.provider.js.map +1 -1
  118. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js +8 -0
  119. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js.map +1 -1
  120. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +1565 -432
  121. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -1
  122. package/dist-server/server/modules/providers/list/codex/codex.provider.js +2 -0
  123. package/dist-server/server/modules/providers/list/codex/codex.provider.js.map +1 -1
  124. package/dist-server/server/modules/providers/list/cursor/cursor-runtime.provider.js +8 -6
  125. package/dist-server/server/modules/providers/list/cursor/cursor-runtime.provider.js.map +1 -1
  126. package/dist-server/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.js.map +1 -1
  127. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +258 -6
  128. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -1
  129. package/dist-server/server/modules/providers/provider.routes.js +24 -0
  130. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  131. package/dist-server/server/modules/providers/services/provider-capabilities.service.js +14 -0
  132. package/dist-server/server/modules/providers/services/provider-capabilities.service.js.map +1 -1
  133. package/dist-server/server/modules/providers/services/provider-token-usage.service.js +139 -35
  134. package/dist-server/server/modules/providers/services/provider-token-usage.service.js.map +1 -1
  135. package/dist-server/server/modules/providers/services/session-history-cache.service.js +87 -0
  136. package/dist-server/server/modules/providers/services/session-history-cache.service.js.map +1 -0
  137. package/dist-server/server/modules/providers/services/session-synchronizer.service.js +105 -32
  138. package/dist-server/server/modules/providers/services/session-synchronizer.service.js.map +1 -1
  139. package/dist-server/server/modules/providers/services/sessions-watcher.service.js +3 -60
  140. package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
  141. package/dist-server/server/modules/providers/services/sessions.service.js +179 -9
  142. package/dist-server/server/modules/providers/services/sessions.service.js.map +1 -1
  143. package/dist-server/server/modules/providers/tests/claude-auth.test.js +52 -0
  144. package/dist-server/server/modules/providers/tests/claude-auth.test.js.map +1 -1
  145. package/dist-server/server/modules/providers/tests/claude-models.test.js +39 -0
  146. package/dist-server/server/modules/providers/tests/claude-models.test.js.map +1 -0
  147. package/dist-server/server/modules/providers/tests/claude-sessions.test.js +465 -39
  148. package/dist-server/server/modules/providers/tests/claude-sessions.test.js.map +1 -1
  149. package/dist-server/server/modules/providers/tests/claude-subagent-echo.test.js +16 -0
  150. package/dist-server/server/modules/providers/tests/claude-subagent-echo.test.js.map +1 -0
  151. package/dist-server/server/modules/providers/tests/claude-token-budget.test.js +85 -0
  152. package/dist-server/server/modules/providers/tests/claude-token-budget.test.js.map +1 -0
  153. package/dist-server/server/modules/providers/tests/codex-message-editing.test.js +338 -0
  154. package/dist-server/server/modules/providers/tests/codex-message-editing.test.js.map +1 -0
  155. package/dist-server/server/modules/providers/tests/codex-runtime.test.js +51 -0
  156. package/dist-server/server/modules/providers/tests/codex-runtime.test.js.map +1 -0
  157. package/dist-server/server/modules/providers/tests/codex-session-fork.test.js +54 -0
  158. package/dist-server/server/modules/providers/tests/codex-session-fork.test.js.map +1 -0
  159. package/dist-server/server/modules/providers/tests/codex-sessions.test.js +304 -15
  160. package/dist-server/server/modules/providers/tests/codex-sessions.test.js.map +1 -1
  161. package/dist-server/server/modules/providers/tests/opencode-models.test.js +117 -4
  162. package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -1
  163. package/dist-server/server/modules/providers/tests/provider-token-usage.service.test.js +164 -1
  164. package/dist-server/server/modules/providers/tests/provider-token-usage.service.test.js.map +1 -1
  165. package/dist-server/server/modules/providers/tests/session-fork.test.js +113 -0
  166. package/dist-server/server/modules/providers/tests/session-fork.test.js.map +1 -0
  167. package/dist-server/server/modules/providers/tests/session-history-cache.service.test.js +120 -0
  168. package/dist-server/server/modules/providers/tests/session-history-cache.service.test.js.map +1 -0
  169. package/dist-server/server/modules/providers/tests/session-orphan-prune.test.js +90 -0
  170. package/dist-server/server/modules/providers/tests/session-orphan-prune.test.js.map +1 -0
  171. package/dist-server/server/modules/providers/tests/session-synchronizer-coalescing.test.js +122 -0
  172. package/dist-server/server/modules/providers/tests/session-synchronizer-coalescing.test.js.map +1 -0
  173. package/dist-server/server/modules/providers/tests/sessions.service.test.js +45 -1
  174. package/dist-server/server/modules/providers/tests/sessions.service.test.js.map +1 -1
  175. package/dist-server/server/modules/providers/tests/skills.test.js +15 -3
  176. package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -1
  177. package/dist-server/server/modules/scheduled-messages/index.js +5 -0
  178. package/dist-server/server/modules/scheduled-messages/index.js.map +1 -0
  179. package/dist-server/server/modules/scheduled-messages/scheduled-messages.routes.js +47 -0
  180. package/dist-server/server/modules/scheduled-messages/scheduled-messages.routes.js.map +1 -0
  181. package/dist-server/server/modules/scheduled-messages/services/scheduled-message-dispatcher.service.js +163 -0
  182. package/dist-server/server/modules/scheduled-messages/services/scheduled-message-dispatcher.service.js.map +1 -0
  183. package/dist-server/server/modules/scheduled-messages/services/scheduled-messages.service.js +95 -0
  184. package/dist-server/server/modules/scheduled-messages/services/scheduled-messages.service.js.map +1 -0
  185. package/dist-server/server/modules/scheduled-messages/tests/scheduled-messages.test.js +255 -0
  186. package/dist-server/server/modules/scheduled-messages/tests/scheduled-messages.test.js.map +1 -0
  187. package/dist-server/server/modules/taskmaster/taskmaster.routes.js +23 -23
  188. package/dist-server/server/modules/taskmaster/taskmaster.routes.js.map +1 -1
  189. package/dist-server/server/modules/taskmaster/tests/taskmaster.routes.test.js +48 -0
  190. package/dist-server/server/modules/taskmaster/tests/taskmaster.routes.test.js.map +1 -1
  191. package/dist-server/server/modules/user/tests/user.service.test.js +70 -0
  192. package/dist-server/server/modules/user/tests/user.service.test.js.map +1 -1
  193. package/dist-server/server/modules/user/user.module.js +10 -1
  194. package/dist-server/server/modules/user/user.module.js.map +1 -1
  195. package/dist-server/server/modules/user/user.routes.js +44 -0
  196. package/dist-server/server/modules/user/user.routes.js.map +1 -1
  197. package/dist-server/server/modules/user/user.service.js +75 -0
  198. package/dist-server/server/modules/user/user.service.js.map +1 -1
  199. package/dist-server/server/modules/voice/tests/voice.service.test.js +3 -2
  200. package/dist-server/server/modules/voice/tests/voice.service.test.js.map +1 -1
  201. package/dist-server/server/modules/websocket/index.js +7 -0
  202. package/dist-server/server/modules/websocket/index.js.map +1 -1
  203. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js +10 -44
  204. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js.map +1 -1
  205. package/dist-server/server/modules/websocket/services/chat-session-writer.service.js +35 -5
  206. package/dist-server/server/modules/websocket/services/chat-session-writer.service.js.map +1 -1
  207. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +166 -9
  208. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  209. package/dist-server/server/modules/websocket/services/session-upsert-broadcast.service.js +94 -0
  210. package/dist-server/server/modules/websocket/services/session-upsert-broadcast.service.js.map +1 -0
  211. package/dist-server/server/modules/websocket/services/shell-websocket.service.js +9 -3
  212. package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
  213. package/dist-server/server/modules/websocket/tests/chat-edit-send.test.js +263 -0
  214. package/dist-server/server/modules/websocket/tests/chat-edit-send.test.js.map +1 -0
  215. package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js +29 -3
  216. package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js.map +1 -1
  217. package/dist-server/server/modules/websocket/tests/session-upsert-broadcast.test.js +106 -0
  218. package/dist-server/server/modules/websocket/tests/session-upsert-broadcast.test.js.map +1 -0
  219. package/dist-server/server/modules/websocket/tests/shell-websocket.service.test.js +80 -0
  220. package/dist-server/server/modules/websocket/tests/shell-websocket.service.test.js.map +1 -1
  221. package/dist-server/server/shared/claude-cli-path.js +22 -5
  222. package/dist-server/server/shared/claude-cli-path.js.map +1 -1
  223. package/dist-server/server/shared/index.js +3 -0
  224. package/dist-server/server/shared/index.js.map +1 -0
  225. package/dist-server/server/shared/message-unification.js +376 -0
  226. package/dist-server/server/shared/message-unification.js.map +1 -0
  227. package/dist-server/server/shared/tests/claude-cli-path.test.js +45 -1
  228. package/dist-server/server/shared/tests/claude-cli-path.test.js.map +1 -1
  229. package/dist-server/server/shared/tests/message-unification.test.js +174 -0
  230. package/dist-server/server/shared/tests/message-unification.test.js.map +1 -0
  231. package/dist-server/server/shared/utils.js +34 -0
  232. package/dist-server/server/shared/utils.js.map +1 -1
  233. package/package.json +19 -16
  234. package/server/index.ts +11 -4
  235. package/server/modules/agent/agent.routes.ts +2 -2
  236. package/server/modules/agent/tests/agent.routes.test.ts +33 -1
  237. package/server/modules/browser-use/browser-use.routes.ts +96 -33
  238. package/server/modules/database/connection.ts +2 -1
  239. package/server/modules/database/index.ts +10 -0
  240. package/server/modules/database/migrations.ts +37 -0
  241. package/server/modules/database/repositories/scheduled-messages.db.ts +127 -0
  242. package/server/modules/database/repositories/session-drafts.db.ts +171 -0
  243. package/server/modules/database/repositories/sessions.db.ts +196 -3
  244. package/server/modules/database/repositories/user-preferences.db.ts +80 -0
  245. package/server/modules/database/schema.ts +96 -0
  246. package/server/modules/database/tests/sessions.db.integration.test.ts +52 -0
  247. package/server/modules/database/tests/user-preferences.db.integration.test.ts +135 -0
  248. package/server/modules/projects/services/project-delete.service.ts +13 -1
  249. package/server/modules/projects/services/project-star.service.ts +2 -2
  250. package/server/modules/providers/index.ts +4 -0
  251. package/server/modules/providers/list/claude/claude-auth.provider.ts +21 -1
  252. package/server/modules/providers/list/claude/claude-fork.provider.ts +57 -0
  253. package/server/modules/providers/list/claude/claude-models.provider.ts +38 -6
  254. package/server/modules/providers/list/claude/claude-runtime.provider.js +188 -39
  255. package/server/modules/providers/list/claude/claude-sessions.provider.ts +478 -54
  256. package/server/modules/providers/list/claude/claude-skills.provider.ts +4 -1
  257. package/server/modules/providers/list/claude/claude.provider.ts +3 -0
  258. package/server/modules/providers/list/codex/codex-app-server.client.ts +243 -0
  259. package/server/modules/providers/list/codex/codex-fork.provider.ts +40 -0
  260. package/server/modules/providers/list/codex/codex-models.provider.ts +16 -0
  261. package/server/modules/providers/list/codex/{codex-runtime.provider.js → codex-runtime.provider.ts} +93 -66
  262. package/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +9 -0
  263. package/server/modules/providers/list/codex/codex-sessions.provider.ts +1806 -477
  264. package/server/modules/providers/list/codex/codex.provider.ts +3 -0
  265. package/server/modules/providers/list/cursor/cursor-runtime.provider.js +8 -6
  266. package/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts +0 -1
  267. package/server/modules/providers/list/opencode/opencode-models.provider.ts +279 -6
  268. package/server/modules/providers/provider.routes.ts +31 -0
  269. package/server/modules/providers/services/provider-capabilities.service.ts +23 -0
  270. package/server/modules/providers/services/provider-token-usage.service.ts +166 -39
  271. package/server/modules/providers/services/session-history-cache.service.ts +139 -0
  272. package/server/modules/providers/services/session-synchronizer.service.ts +125 -38
  273. package/server/modules/providers/services/sessions-watcher.service.ts +3 -64
  274. package/server/modules/providers/services/sessions.service.ts +201 -9
  275. package/server/modules/providers/tests/claude-auth.test.ts +58 -0
  276. package/server/modules/providers/tests/claude-models.test.ts +87 -0
  277. package/server/modules/providers/tests/claude-sessions.test.ts +552 -40
  278. package/server/modules/providers/tests/claude-subagent-echo.test.ts +25 -0
  279. package/server/modules/providers/tests/claude-token-budget.test.ts +104 -0
  280. package/server/modules/providers/tests/codex-message-editing.test.ts +419 -0
  281. package/server/modules/providers/tests/codex-runtime.test.ts +58 -0
  282. package/server/modules/providers/tests/codex-session-fork.test.ts +59 -0
  283. package/server/modules/providers/tests/codex-sessions.test.ts +337 -15
  284. package/server/modules/providers/tests/opencode-models.test.ts +166 -8
  285. package/server/modules/providers/tests/provider-token-usage.service.test.ts +188 -1
  286. package/server/modules/providers/tests/session-fork.test.ts +173 -0
  287. package/server/modules/providers/tests/session-history-cache.service.test.ts +143 -0
  288. package/server/modules/providers/tests/session-orphan-prune.test.ts +107 -0
  289. package/server/modules/providers/tests/session-synchronizer-coalescing.test.ts +136 -0
  290. package/server/modules/providers/tests/sessions.service.test.ts +67 -1
  291. package/server/modules/providers/tests/skills.test.ts +21 -5
  292. package/server/modules/scheduled-messages/index.ts +8 -0
  293. package/server/modules/scheduled-messages/scheduled-messages.routes.ts +68 -0
  294. package/server/modules/scheduled-messages/services/scheduled-message-dispatcher.service.ts +203 -0
  295. package/server/modules/scheduled-messages/services/scheduled-messages.service.ts +121 -0
  296. package/server/modules/scheduled-messages/tests/scheduled-messages.test.ts +310 -0
  297. package/server/modules/taskmaster/taskmaster.routes.ts +23 -33
  298. package/server/modules/taskmaster/tests/taskmaster.routes.test.ts +57 -0
  299. package/server/modules/user/tests/user.service.test.ts +90 -0
  300. package/server/modules/user/user.module.ts +10 -1
  301. package/server/modules/user/user.routes.ts +44 -0
  302. package/server/modules/user/user.service.ts +105 -0
  303. package/server/modules/voice/tests/voice.service.test.ts +3 -2
  304. package/server/modules/websocket/index.ts +8 -0
  305. package/server/modules/websocket/services/chat-run-registry.service.ts +17 -50
  306. package/server/modules/websocket/services/chat-session-writer.service.ts +36 -6
  307. package/server/modules/websocket/services/chat-websocket.service.ts +244 -15
  308. package/server/modules/websocket/services/session-upsert-broadcast.service.ts +108 -0
  309. package/server/modules/websocket/services/shell-websocket.service.ts +10 -3
  310. package/server/modules/websocket/tests/chat-edit-send.test.ts +309 -0
  311. package/server/modules/websocket/tests/chat-run-registry.test.ts +34 -3
  312. package/server/modules/websocket/tests/session-upsert-broadcast.test.ts +138 -0
  313. package/server/modules/websocket/tests/shell-websocket.service.test.ts +108 -0
  314. package/server/shared/claude-cli-path.ts +24 -6
  315. package/server/shared/index.ts +3 -0
  316. package/server/shared/interfaces.ts +62 -0
  317. package/server/shared/message-unification.ts +449 -0
  318. package/server/shared/tests/claude-cli-path.test.ts +58 -1
  319. package/server/shared/tests/message-unification.test.ts +218 -0
  320. package/server/shared/types.ts +123 -2
  321. package/server/shared/utils.ts +40 -0
  322. package/dist/assets/channel-_falYbU1.js +0 -1
  323. package/dist/assets/classDiagram-JCYQIIEL-Dn-3CfpR.js +0 -1
  324. package/dist/assets/classDiagram-v2-OCEON4UE-Dn-3CfpR.js +0 -1
  325. package/dist/assets/index-CzPLBvXT.css +0 -32
  326. package/dist/assets/index-DFkBBuSA.js +0 -1549
  327. package/dist/assets/stateDiagram-v2-UEYNNEHI-BvBxUUrZ.js +0 -1
  328. package/dist/assets/swimlanesDiagram-ULZ7WXOC-CsE-x4YK.js +0 -8
@@ -11,6 +11,12 @@ import { AppError, getOpenCodeDatabasePath } from '@/shared/utils.js';
11
11
 
12
12
  type SessionRow = NonNullable<ReturnType<typeof sessionsDb.getSessionById>>;
13
13
 
14
+ type FileTail = {
15
+ content: string;
16
+ /** True when `content` is the whole file, not just its trailing bytes. */
17
+ isComplete: boolean;
18
+ };
19
+
14
20
  type ProviderTokenUsageServiceDependencies = {
15
21
  getSessionById: (sessionId: string) => SessionRow | null | undefined;
16
22
  getHomeDirectory: () => string;
@@ -18,7 +24,9 @@ type ProviderTokenUsageServiceDependencies = {
18
24
  fileExists: (filePath: string) => boolean;
19
25
  readDirectory: (directoryPath: string) => Promise<Dirent[]>;
20
26
  readTextFile: (filePath: string) => Promise<string>;
27
+ readTextFileTail: (filePath: string, maxBytes: number) => Promise<FileTail>;
21
28
  getClaudeContextWindow: () => string | undefined;
29
+ isProviderSessionSuperseded: (providerSessionId: string, provider: string) => boolean;
22
30
  };
23
31
 
24
32
  type TokenUsageResult = {
@@ -45,6 +53,14 @@ type OpenCodeTokenRow = {
45
53
  cacheWriteTokens: number | null;
46
54
  };
47
55
 
56
+ /**
57
+ * Both JSONL usage readers below only need the newest usage row, which sits at
58
+ * or near the end of the transcript. Reading just this much of the tail keeps
59
+ * a session-open usage lookup O(1) in the transcript's size; the rare tail
60
+ * with no usage row at all falls back to the full read.
61
+ */
62
+ const TOKEN_USAGE_TAIL_BYTES = 4 * 1024 * 1024;
63
+
48
64
  const defaultDependencies: ProviderTokenUsageServiceDependencies = {
49
65
  getSessionById: (sessionId) => sessionsDb.getSessionById(sessionId),
50
66
  getHomeDirectory: () => os.homedir(),
@@ -52,7 +68,32 @@ const defaultDependencies: ProviderTokenUsageServiceDependencies = {
52
68
  fileExists: (filePath) => fsSync.existsSync(filePath),
53
69
  readDirectory: (directoryPath) => fsp.readdir(directoryPath, { withFileTypes: true }),
54
70
  readTextFile: (filePath) => fsp.readFile(filePath, 'utf8'),
71
+ readTextFileTail: async (filePath, maxBytes) => {
72
+ const handle = await fsp.open(filePath, 'r');
73
+ try {
74
+ const { size } = await handle.stat();
75
+ if (size <= maxBytes) {
76
+ return { content: await handle.readFile({ encoding: 'utf8' }), isComplete: true };
77
+ }
78
+
79
+ const buffer = Buffer.alloc(maxBytes);
80
+ await handle.read(buffer, 0, maxBytes, size - maxBytes);
81
+ const content = buffer.toString('utf8');
82
+ // The window almost never starts on a row boundary; dropping everything
83
+ // up to the first newline discards the partial row (and any split
84
+ // multi-byte character with it).
85
+ const firstNewline = content.indexOf('\n');
86
+ return {
87
+ content: firstNewline === -1 ? '' : content.slice(firstNewline + 1),
88
+ isComplete: false,
89
+ };
90
+ } finally {
91
+ await handle.close();
92
+ }
93
+ },
55
94
  getClaudeContextWindow: () => process.env.CONTEXT_WINDOW,
95
+ isProviderSessionSuperseded: (providerSessionId, provider) =>
96
+ sessionsDb.isProviderSessionSuperseded(providerSessionId, provider),
56
97
  };
57
98
 
58
99
  function readUsageNumber(value: unknown): number {
@@ -92,11 +133,8 @@ async function findCodexSessionFile(
92
133
  return null;
93
134
  }
94
135
 
95
- function readCodexTokenUsage(fileContent: string): TokenUsageResult {
96
- let inputTokens = 0;
97
- let outputTokens = 0;
98
- let totalTokens = 0;
99
- let contextWindow = 200_000;
136
+ /** Newest `token_count` snapshot in the given JSONL text, or null when it has none. */
137
+ function findCodexTokenUsage(fileContent: string): TokenUsageResult | null {
100
138
  const lines = fileContent.trim().split('\n');
101
139
 
102
140
  for (let index = lines.length - 1; index >= 0; index -= 1) {
@@ -109,58 +147,101 @@ function readCodexTokenUsage(fileContent: string): TokenUsageResult {
109
147
  continue;
110
148
  }
111
149
 
150
+ let inputTokens = 0;
151
+ let outputTokens = 0;
152
+ let totalTokens = 0;
112
153
  if (tokenInfo.total_token_usage) {
113
154
  inputTokens = readUsageNumber(tokenInfo.total_token_usage.input_tokens);
114
155
  outputTokens = readUsageNumber(tokenInfo.total_token_usage.output_tokens);
115
156
  totalTokens = readUsageNumber(tokenInfo.total_token_usage.total_tokens)
116
157
  || inputTokens + outputTokens;
117
158
  }
118
- contextWindow = readUsageNumber(tokenInfo.model_context_window) || contextWindow;
119
- break;
159
+ return {
160
+ used: totalTokens,
161
+ total: readUsageNumber(tokenInfo.model_context_window) || 200_000,
162
+ inputTokens,
163
+ outputTokens,
164
+ breakdown: { input: inputTokens, output: outputTokens },
165
+ };
120
166
  } catch {
121
167
  // A provider may be writing the last JSONL line while this read happens.
122
168
  }
123
169
  }
124
170
 
171
+ return null;
172
+ }
173
+
174
+ function emptyCodexTokenUsage(): TokenUsageResult {
125
175
  return {
126
- used: totalTokens,
127
- total: contextWindow,
128
- inputTokens,
129
- outputTokens,
130
- breakdown: { input: inputTokens, output: outputTokens },
176
+ used: 0,
177
+ total: 200_000,
178
+ inputTokens: 0,
179
+ outputTokens: 0,
180
+ breakdown: { input: 0, output: 0 },
131
181
  };
132
182
  }
133
183
 
134
- function readClaudeTokenUsage(fileContent: string, configuredContextWindow: string | undefined): TokenUsageResult {
184
+ /**
185
+ * Latest context-window usage from a Claude transcript's already-parsed rows.
186
+ *
187
+ * Exported because the session-messages reader hands the same usage back on
188
+ * every history page, the way the Codex and OpenCode readers do. Without that,
189
+ * a Claude session's counter only moved when the session was reselected, and
190
+ * the store's "this provider reports no usage" path overwrote it with zero.
191
+ *
192
+ * Reads the newest assistant turn only: `input_tokens + cache_read +
193
+ * cache_creation` is that one request's whole prompt, i.e. what the context
194
+ * window currently holds. Summing turns would count the same cached prefix
195
+ * once per turn.
196
+ */
197
+ export function summarizeClaudeTokenUsage(
198
+ entries: AnyRecord[],
199
+ configuredContextWindow: string | undefined = process.env.CONTEXT_WINDOW,
200
+ ): TokenUsageResult {
135
201
  let inputTokens = 0;
136
202
  let outputTokens = 0;
137
203
  let cacheReadTokens = 0;
138
204
  let cacheCreationTokens = 0;
139
- const lines = fileContent.trim().split('\n');
140
205
 
141
- for (let index = lines.length - 1; index >= 0; index -= 1) {
142
- try {
143
- const entry = JSON.parse(lines[index]) as AnyRecord;
144
- const usage = entry.type === 'assistant' ? entry.message?.usage : null;
145
- if (!usage) {
146
- continue;
147
- }
206
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
207
+ const entry = entries[index];
208
+ // A subagent's turns report the subagent's context window, not this
209
+ // conversation's; reading one makes the counter drop to the subagent's
210
+ // number and bounce back on the next main-thread turn.
211
+ if (entry?.isSidechain === true) {
212
+ continue;
213
+ }
148
214
 
149
- const directInputTokens = readUsageNumber(usage.input_tokens ?? usage.inputTokens);
150
- cacheReadTokens = readUsageNumber(
151
- usage.cache_read_input_tokens ?? usage.cacheReadInputTokens ?? usage.cacheReadTokens,
152
- );
153
- cacheCreationTokens = readUsageNumber(
154
- usage.cache_creation_input_tokens
155
- ?? usage.cacheCreationInputTokens
156
- ?? usage.cacheCreationTokens,
157
- );
158
- inputTokens = directInputTokens + cacheReadTokens + cacheCreationTokens;
159
- outputTokens = readUsageNumber(usage.output_tokens ?? usage.outputTokens);
160
- break;
161
- } catch {
162
- // Skip malformed lines without discarding usage from earlier messages.
215
+ const usage = entry?.type === 'assistant' ? entry.message?.usage : null;
216
+ if (!usage) {
217
+ continue;
218
+ }
219
+
220
+ const directInputTokens = readUsageNumber(usage.input_tokens ?? usage.inputTokens);
221
+ const rowCacheReadTokens = readUsageNumber(
222
+ usage.cache_read_input_tokens ?? usage.cacheReadInputTokens ?? usage.cacheReadTokens,
223
+ );
224
+ const rowCacheCreationTokens = readUsageNumber(
225
+ usage.cache_creation_input_tokens
226
+ ?? usage.cacheCreationInputTokens
227
+ ?? usage.cacheCreationTokens,
228
+ );
229
+ const rowInputTokens = directInputTokens + rowCacheReadTokens + rowCacheCreationTokens;
230
+ const rowOutputTokens = readUsageNumber(usage.output_tokens ?? usage.outputTokens);
231
+
232
+ // `<synthetic>` rows — interrupts, API errors, "No response requested" —
233
+ // are written with an all-zero usage block rather than none at all. They
234
+ // never carried a prompt, so treating one as the newest turn zeroed a
235
+ // counter that a live event had just set correctly.
236
+ if (rowInputTokens === 0 && rowOutputTokens === 0) {
237
+ continue;
163
238
  }
239
+
240
+ cacheReadTokens = rowCacheReadTokens;
241
+ cacheCreationTokens = rowCacheCreationTokens;
242
+ inputTokens = rowInputTokens;
243
+ outputTokens = rowOutputTokens;
244
+ break;
164
245
  }
165
246
 
166
247
  const parsedContextWindow = Number.parseInt(configuredContextWindow ?? '', 10);
@@ -179,6 +260,22 @@ function readClaudeTokenUsage(fileContent: string, configuredContextWindow: stri
179
260
  };
180
261
  }
181
262
 
263
+ function parseClaudeUsageEntries(fileContent: string): AnyRecord[] {
264
+ const entries: AnyRecord[] = [];
265
+ for (const line of fileContent.trim().split('\n')) {
266
+ try {
267
+ entries.push(JSON.parse(line) as AnyRecord);
268
+ } catch {
269
+ // Skip malformed lines without discarding usage from earlier messages.
270
+ }
271
+ }
272
+ return entries;
273
+ }
274
+
275
+ function claudeEntriesHaveUsage(entries: AnyRecord[]): boolean {
276
+ return entries.some((entry) => entry?.type === 'assistant' && entry.message?.usage);
277
+ }
278
+
182
279
  function readOpenCodeTokenUsage(databasePath: string, providerSessionId: string): TokenUsageResult {
183
280
  const database = new Database(databasePath, { readonly: true, fileMustExist: true });
184
281
  try {
@@ -264,6 +361,24 @@ export function createProviderTokenUsageService(
264
361
  });
265
362
  }
266
363
 
364
+ // The fallback covers rows whose provider id was never recorded, and for
365
+ // a session discovered from disk the app id *is* its provider id. That
366
+ // stops being true the moment an edit rewinds the conversation off a
367
+ // thread: until the replacement run announces its own id, the fallback
368
+ // would resolve the retired transcript and report the discarded
369
+ // conversation's usage against an empty one.
370
+ if (
371
+ !session.provider_session_id
372
+ && dependencies.isProviderSessionSuperseded(sessionId, session.provider)
373
+ ) {
374
+ return {
375
+ used: 0,
376
+ inputTokens: 0,
377
+ outputTokens: 0,
378
+ breakdown: { input: 0, output: 0 },
379
+ };
380
+ }
381
+
267
382
  const providerSessionId = session.provider_session_id || sessionId;
268
383
 
269
384
  if (session.provider === 'cursor') {
@@ -307,8 +422,16 @@ export function createProviderTokenUsageService(
307
422
  });
308
423
  }
309
424
 
310
- const fileContent = await dependencies.readTextFile(sessionFilePath);
311
- return readCodexTokenUsage(fileContent);
425
+ const tail = await dependencies.readTextFileTail(sessionFilePath, TOKEN_USAGE_TAIL_BYTES);
426
+ const tailUsage = findCodexTokenUsage(tail.content);
427
+ if (tailUsage || tail.isComplete) {
428
+ return tailUsage ?? emptyCodexTokenUsage();
429
+ }
430
+
431
+ // A tail this large with no token_count row is pathological, but the
432
+ // whole file is still authoritative when it happens.
433
+ return findCodexTokenUsage(await dependencies.readTextFile(sessionFilePath))
434
+ ?? emptyCodexTokenUsage();
312
435
  }
313
436
 
314
437
  let sessionFilePath = session.jsonl_path;
@@ -345,8 +468,12 @@ export function createProviderTokenUsageService(
345
468
  });
346
469
  }
347
470
 
348
- const fileContent = await dependencies.readTextFile(sessionFilePath);
349
- return readClaudeTokenUsage(fileContent, dependencies.getClaudeContextWindow());
471
+ const tail = await dependencies.readTextFileTail(sessionFilePath, TOKEN_USAGE_TAIL_BYTES);
472
+ let entries = parseClaudeUsageEntries(tail.content);
473
+ if (!claudeEntriesHaveUsage(entries) && !tail.isComplete) {
474
+ entries = parseClaudeUsageEntries(await dependencies.readTextFile(sessionFilePath));
475
+ }
476
+ return summarizeClaudeTokenUsage(entries, dependencies.getClaudeContextWindow());
350
477
  },
351
478
  };
352
479
  }
@@ -0,0 +1,139 @@
1
+ import fsp from 'node:fs/promises';
2
+
3
+ import type { FetchHistoryResult } from '@/shared/types.js';
4
+
5
+ /**
6
+ * Full-transcript cache for session history reads.
7
+ *
8
+ * Every provider history reader materializes the complete normalized
9
+ * transcript and then slices out the requested page, so serving a 20-row page
10
+ * of a large session re-read and re-parsed the whole transcript file on every
11
+ * request — opening a session, each older page while scrolling up, and every
12
+ * post-turn refresh. For a multi-megabyte JSONL that is most of a second of
13
+ * CPU per request.
14
+ *
15
+ * Entries are keyed by app session id and validated with one `stat` per
16
+ * request against the transcript file's identity (path + mtime + size), so
17
+ * only the first read after the file changes pays the parse. Anything that
18
+ * rewrites history (a new turn, an edit, a rewind, a fork) touches the file
19
+ * and invalidates naturally; no explicit invalidation hooks exist or are
20
+ * needed.
21
+ *
22
+ * Only history readers that read `jsonl_path` itself may use this cache —
23
+ * callers pass `transcriptPath: null` for providers whose messages live
24
+ * elsewhere (Cursor's store.db, OpenCode's shared SQLite), which bypasses
25
+ * caching entirely.
26
+ */
27
+
28
+ type CacheEntry = {
29
+ transcriptPath: string;
30
+ mtimeMs: number;
31
+ /** File size in bytes; doubles as the entry's cost against the byte budget. */
32
+ size: number;
33
+ full: FetchHistoryResult;
34
+ };
35
+
36
+ type GetFullHistoryArgs = {
37
+ sessionId: string;
38
+ /** Path of the file the provider's history reader actually parses, or null to bypass. */
39
+ transcriptPath: string | null | undefined;
40
+ /** Loads the complete transcript (`limit: null, offset: 0`) from the provider. */
41
+ loadFull: () => Promise<FetchHistoryResult>;
42
+ };
43
+
44
+ /**
45
+ * A transcript entry's heap cost is roughly the file it was parsed from, so
46
+ * the budget is expressed in file bytes. The newest entry is always retained
47
+ * even when it alone exceeds the budget — evicting it would just re-parse the
48
+ * same file on the next request.
49
+ */
50
+ const MAX_CACHED_TRANSCRIPT_FILE_BYTES = 256 * 1024 * 1024;
51
+ const MAX_CACHE_ENTRIES = 8;
52
+
53
+ export function createSessionHistoryCache(
54
+ maxTotalFileBytes = MAX_CACHED_TRANSCRIPT_FILE_BYTES,
55
+ maxEntries = MAX_CACHE_ENTRIES,
56
+ ) {
57
+ const entries = new Map<string, CacheEntry>();
58
+ const pendingLoads = new Map<string, Promise<FetchHistoryResult>>();
59
+
60
+ function evictOverBudget(): void {
61
+ let totalBytes = 0;
62
+ for (const entry of entries.values()) {
63
+ totalBytes += entry.size;
64
+ }
65
+ for (const key of entries.keys()) {
66
+ if (entries.size <= 1 || (totalBytes <= maxTotalFileBytes && entries.size <= maxEntries)) {
67
+ break;
68
+ }
69
+ totalBytes -= entries.get(key)!.size;
70
+ entries.delete(key);
71
+ }
72
+ }
73
+
74
+ return {
75
+ /**
76
+ * Returns the session's full transcript through the cache, or null when
77
+ * the session is not cacheable (no transcript path, or the file cannot be
78
+ * stat'ed) — the caller then falls back to a plain provider read.
79
+ */
80
+ async getFullHistory({ sessionId, transcriptPath, loadFull }: GetFullHistoryArgs): Promise<FetchHistoryResult | null> {
81
+ if (!transcriptPath) {
82
+ return null;
83
+ }
84
+
85
+ let stat;
86
+ try {
87
+ stat = await fsp.stat(transcriptPath);
88
+ } catch {
89
+ entries.delete(sessionId);
90
+ return null;
91
+ }
92
+ if (!stat.isFile()) {
93
+ entries.delete(sessionId);
94
+ return null;
95
+ }
96
+
97
+ const cached = entries.get(sessionId);
98
+ if (
99
+ cached
100
+ && cached.transcriptPath === transcriptPath
101
+ && cached.mtimeMs === stat.mtimeMs
102
+ && cached.size === stat.size
103
+ ) {
104
+ // Re-insert to mark as most recently used.
105
+ entries.delete(sessionId);
106
+ entries.set(sessionId, cached);
107
+ return cached.full;
108
+ }
109
+
110
+ // Concurrent requests for the same session share one parse. The file may
111
+ // gain rows while the load runs; the pre-load stat is what the entry is
112
+ // keyed by, so the next request would see a changed stat and re-read.
113
+ const pending = pendingLoads.get(sessionId);
114
+ if (pending) {
115
+ return pending;
116
+ }
117
+
118
+ const load = loadFull().then((full) => {
119
+ entries.delete(sessionId);
120
+ entries.set(sessionId, {
121
+ transcriptPath,
122
+ mtimeMs: stat.mtimeMs,
123
+ size: stat.size,
124
+ full,
125
+ });
126
+ evictOverBudget();
127
+ return full;
128
+ });
129
+ pendingLoads.set(sessionId, load);
130
+ try {
131
+ return await load;
132
+ } finally {
133
+ pendingLoads.delete(sessionId);
134
+ }
135
+ },
136
+ };
137
+ }
138
+
139
+ export const sessionHistoryCache = createSessionHistoryCache();
@@ -1,59 +1,146 @@
1
- import { scanStateDb } from '@/modules/database/index.js';
1
+ import path from 'node:path';
2
+ import { access } from 'node:fs/promises';
3
+
4
+ import { scanStateDb, sessionsDb } from '@/modules/database/index.js';
2
5
  import { providerRegistry } from '@/modules/providers/provider.registry.js';
3
6
  import type { LLMProvider } from '@/shared/types.js';
4
7
 
5
8
  type SessionSynchronizeResult = {
6
9
  processedByProvider: Record<LLMProvider, number>;
10
+ /** Indexed sessions dropped because their transcript file no longer exists. */
11
+ prunedOrphans: number;
7
12
  failures: string[];
8
13
  };
9
14
 
15
+ const pathExists = async (target: string): Promise<boolean> => {
16
+ try {
17
+ await access(target);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ };
23
+
24
+ /**
25
+ * Removes indexed sessions whose transcript file has disappeared from disk.
26
+ *
27
+ * Nothing else deletes these rows: the synchronizers only ever upsert, and the
28
+ * watcher reacts to `add`/`change` but not `unlink`. A transcript removed by
29
+ * hand — or written by a test run that pointed at the real `~/.claude` — left a
30
+ * permanent sidebar entry that opened an empty "Untitled" session.
31
+ *
32
+ * A row is only dropped when its *containing directory* still exists. That
33
+ * keeps an unmounted or not-yet-created home from being read as "every
34
+ * transcript was deleted" and wiping the whole index.
35
+ */
36
+ const pruneOrphanedSessions = async (): Promise<number> => {
37
+ const knownDirectoryExists = new Map<string, boolean>();
38
+ let pruned = 0;
39
+
40
+ for (const { session_id: sessionId, jsonl_path: jsonlPath } of sessionsDb.getSessionsWithTranscriptPath()) {
41
+ if (await pathExists(jsonlPath)) {
42
+ continue;
43
+ }
44
+
45
+ const directory = path.dirname(jsonlPath);
46
+ let directoryExists = knownDirectoryExists.get(directory);
47
+ if (directoryExists === undefined) {
48
+ directoryExists = await pathExists(directory);
49
+ knownDirectoryExists.set(directory, directoryExists);
50
+ }
51
+
52
+ if (!directoryExists) {
53
+ continue;
54
+ }
55
+
56
+ if (sessionsDb.deleteSessionById(sessionId)) {
57
+ pruned += 1;
58
+ }
59
+ }
60
+
61
+ return pruned;
62
+ };
63
+
64
+ /**
65
+ * The scan that every `synchronizeSessions()` caller shares while it runs.
66
+ *
67
+ * Opening the UI fires `/api/projects` and `/api/projects/archived` at once,
68
+ * and each used to start its own full provider scan from the same
69
+ * `last_scanned_at` cursor: identical work over identical transcripts,
70
+ * contending for the same synchronous SQLite writes and doubling how long the
71
+ * sidebar sits on its loading screen. Callers that arrive while a scan is
72
+ * already running now await that scan instead of starting another.
73
+ */
74
+ let inFlightSynchronization: Promise<SessionSynchronizeResult> | null = null;
75
+
76
+ /**
77
+ * Runs all provider synchronizers and updates scan_state.last_scanned_at.
78
+ */
79
+ async function runSessionSynchronization(): Promise<SessionSynchronizeResult> {
80
+ const lastScanAt = scanStateDb.getLastScannedAt();
81
+ const scanBoundary = new Date();
82
+ const processedByProvider: Record<LLMProvider, number> = {
83
+ claude: 0,
84
+ codex: 0,
85
+ cursor: 0,
86
+ opencode: 0,
87
+ };
88
+ const failures: string[] = [];
89
+
90
+ const results = await Promise.allSettled(
91
+ providerRegistry.listProviders().map(async (provider) => ({
92
+ provider: provider.id,
93
+ processed: await provider.sessionSynchronizer.synchronize(lastScanAt ?? undefined),
94
+ }))
95
+ );
96
+
97
+ for (const result of results) {
98
+ if (result.status === 'fulfilled') {
99
+ processedByProvider[result.value.provider] = result.value.processed;
100
+ continue;
101
+ }
102
+
103
+ const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
104
+ failures.push(reason);
105
+ }
106
+
107
+ // Pruning is skipped after a partial sync: a provider that just failed may
108
+ // not have re-indexed transcripts it would otherwise have re-created.
109
+ const prunedOrphans = failures.length === 0 ? await pruneOrphanedSessions() : 0;
110
+
111
+ if (failures.length === 0) {
112
+ scanStateDb.updateLastScannedAt(scanBoundary);
113
+ } else {
114
+ console.warn(
115
+ `[Sessions] Skipping scan_state cursor advance because ${failures.length} provider sync(s) failed.`,
116
+ );
117
+ }
118
+
119
+ return {
120
+ processedByProvider,
121
+ prunedOrphans,
122
+ failures,
123
+ };
124
+ }
125
+
10
126
  /**
11
127
  * Orchestrates provider-specific session indexers and indexed-session lifecycle operations.
12
128
  */
13
129
  export const sessionSynchronizerService = {
14
130
  /**
15
- * Runs all provider synchronizers and updates scan_state.last_scanned_at.
131
+ * Scans every provider for new or changed sessions, coalescing concurrent
132
+ * callers onto a single scan.
16
133
  */
17
134
  async synchronizeSessions(): Promise<SessionSynchronizeResult> {
18
- const lastScanAt = scanStateDb.getLastScannedAt();
19
- const scanBoundary = new Date();
20
- const processedByProvider: Record<LLMProvider, number> = {
21
- claude: 0,
22
- codex: 0,
23
- cursor: 0,
24
- opencode: 0,
25
- };
26
- const failures: string[] = [];
27
-
28
- const results = await Promise.allSettled(
29
- providerRegistry.listProviders().map(async (provider) => ({
30
- provider: provider.id,
31
- processed: await provider.sessionSynchronizer.synchronize(lastScanAt ?? undefined),
32
- }))
33
- );
34
-
35
- for (const result of results) {
36
- if (result.status === 'fulfilled') {
37
- processedByProvider[result.value.provider] = result.value.processed;
38
- continue;
39
- }
40
-
41
- const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
42
- failures.push(reason);
135
+ if (inFlightSynchronization) {
136
+ return inFlightSynchronization;
43
137
  }
44
138
 
45
- if (failures.length === 0) {
46
- scanStateDb.updateLastScannedAt(scanBoundary);
47
- } else {
48
- console.warn(
49
- `[Sessions] Skipping scan_state cursor advance because ${failures.length} provider sync(s) failed.`,
50
- );
51
- }
139
+ inFlightSynchronization = runSessionSynchronization().finally(() => {
140
+ inFlightSynchronization = null;
141
+ });
52
142
 
53
- return {
54
- processedByProvider,
55
- failures,
56
- };
143
+ return inFlightSynchronization;
57
144
  },
58
145
 
59
146
  /**