@cloudcli-ai/cloudcli 1.37.3 → 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 (295) hide show
  1. package/README.md +1 -1
  2. package/dist/assets/{abnfDiagram-N423BO3Z-CJntskwA.js → abnfDiagram-N423BO3Z-Dmkwx38H.js} +1 -1
  3. package/dist/assets/{arc-CIfH4uMN.js → arc-DLe9AYuO.js} +1 -1
  4. package/dist/assets/{architectureDiagram-T3A2C74G-_IpEkukg.js → architectureDiagram-T3A2C74G-CBbbbNUW.js} +1 -1
  5. package/dist/assets/{blockDiagram-VBNYF7ZC-BttxrpgZ.js → blockDiagram-VBNYF7ZC-CaMEaWu4.js} +1 -1
  6. package/dist/assets/{c4Diagram-5PPSVZJV-g51dSRr1.js → c4Diagram-5PPSVZJV-DhjMgNnR.js} +1 -1
  7. package/dist/assets/channel-CWrmwsir.js +1 -0
  8. package/dist/assets/{chunk-2GRJ4B5K-D_-LbQUa.js → chunk-2GRJ4B5K-yjl3-F-i.js} +1 -1
  9. package/dist/assets/{chunk-2Q5K7J3B-BtlHzwaO.js → chunk-2Q5K7J3B-DsAYaWEP.js} +1 -1
  10. package/dist/assets/{chunk-5RXB4S5H-UzkpvpAT.js → chunk-5RXB4S5H-Bt5qmqOX.js} +1 -1
  11. package/dist/assets/{chunk-5VM5RSS4-BKwx5Gag.js → chunk-5VM5RSS4-yWrWVvUt.js} +1 -1
  12. package/dist/assets/{chunk-6Q2QTUOP-D_KEBH5f.js → chunk-6Q2QTUOP-Dt66K5xb.js} +1 -1
  13. package/dist/assets/{chunk-GF5L2VYU-rK2DGyto.js → chunk-GF5L2VYU-DA0vzaj4.js} +1 -1
  14. package/dist/assets/{chunk-JWPE2WC7-CeBl_N9i.js → chunk-JWPE2WC7-CUkbx96V.js} +1 -1
  15. package/dist/assets/{chunk-KBJHAD2P-BiQLWVIr.js → chunk-KBJHAD2P-DWKxx1WH.js} +1 -1
  16. package/dist/assets/{chunk-RYQCIY6F-C1knVGV5.js → chunk-RYQCIY6F-BqluplN0.js} +1 -1
  17. package/dist/assets/{chunk-XXDRQBXY-CtKbJkvF.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-DjX8y40Z.js → cose-bilkent-JH36ORCC-DHV93-Em.js} +1 -1
  21. package/dist/assets/{cynefin-VYW2F7L2-DM6YeRba.js → cynefin-VYW2F7L2-B9HZgoGX.js} +1 -1
  22. package/dist/assets/{cynefinDiagram-MW4NZA55-wZJ-vy5B.js → cynefinDiagram-MW4NZA55-hFjjEQg8.js} +1 -1
  23. package/dist/assets/{dagre-VZM6K2ZE-BzP0ejkH.js → dagre-VZM6K2ZE-CpeVOC7V.js} +1 -1
  24. package/dist/assets/{diagram-7IWD3JNH-BTL_j23J.js → diagram-7IWD3JNH-D0H1UoRA.js} +1 -1
  25. package/dist/assets/{diagram-B4RE2ZJO-Cf1Norgu.js → diagram-B4RE2ZJO-6Y7BQUup.js} +1 -1
  26. package/dist/assets/{diagram-LBJQPF4R-DPiUEKr-.js → diagram-LBJQPF4R-C0nNOwht.js} +1 -1
  27. package/dist/assets/{diagram-Q27KOJAE-CTcTpiz5.js → diagram-Q27KOJAE-En6QlOVX.js} +1 -1
  28. package/dist/assets/{diagram-UB23O5K3-DPMSdYm0.js → diagram-UB23O5K3-CmPiNeGD.js} +1 -1
  29. package/dist/assets/{ebnfDiagram-BXEA7PRR-7yvD6uiF.js → ebnfDiagram-BXEA7PRR-Bygzp1Qt.js} +1 -1
  30. package/dist/assets/{erDiagram-JOGREHBK-CfgeDoV6.js → erDiagram-JOGREHBK-CwScRfzl.js} +1 -1
  31. package/dist/assets/{flowDiagram-UKHOOZJN-B27ng5zu.js → flowDiagram-UKHOOZJN-bKX-0xP6.js} +1 -1
  32. package/dist/assets/{ganttDiagram-PKOTCBZU-CcC6b_7t.js → ganttDiagram-PKOTCBZU-DtZPKXGd.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-DS77QQ5N-BX5kPGrO.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-CUP-SlN4.js → infoDiagram-6WML65LV-ciRQTS0h.js} +1 -1
  37. package/dist/assets/{ishikawaDiagram-WSZJBQD7-BTTDhOpX.js → ishikawaDiagram-WSZJBQD7-CgjhQZ84.js} +1 -1
  38. package/dist/assets/{journeyDiagram-NVQOT4AX-ARXcc61m.js → journeyDiagram-NVQOT4AX-BsbQSeIW.js} +1 -1
  39. package/dist/assets/{kanban-definition-27J2QSJJ-BieMpqMA.js → kanban-definition-27J2QSJJ-CrwzY9Jw.js} +1 -1
  40. package/dist/assets/{linear-61vxjRnP.js → linear-BUaCDr6l.js} +1 -1
  41. package/dist/assets/{mermaid.core-nhnwkfQx.js → mermaid.core-ClgdUU0j.js} +5 -5
  42. package/dist/assets/{mindmap-definition-FAOFIHXS-Cm6x5vT1.js → mindmap-definition-FAOFIHXS-BictljY1.js} +1 -1
  43. package/dist/assets/{pegDiagram-VL7TDLO6-CcYBq_9H.js → pegDiagram-VL7TDLO6-BXdByy_U.js} +1 -1
  44. package/dist/assets/{pieDiagram-7S7Q4E2Y-CfvIjfzo.js → pieDiagram-7S7Q4E2Y-uZsMfkRX.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-CIZ2JOQS-SgYoWPNb.js → quadrantDiagram-CIZ2JOQS-7lCv4QIj.js} +1 -1
  46. package/dist/assets/{railroadDiagram-AXF67PYL-D2Mu5E_f.js → railroadDiagram-AXF67PYL-07dxIWbi.js} +1 -1
  47. package/dist/assets/{requirementDiagram-LRYGKXZP-Azwe_k4Q.js → requirementDiagram-LRYGKXZP-Cb31Yv1_.js} +1 -1
  48. package/dist/assets/{sankeyDiagram-W5VNT64P-DN1Ju3PA.js → sankeyDiagram-W5VNT64P-X0YPSyF9.js} +1 -1
  49. package/dist/assets/{sequenceDiagram-SI44F4Z6-Bj4qC34S.js → sequenceDiagram-SI44F4Z6-BdBxSIlP.js} +1 -1
  50. package/dist/assets/{sizeCapture-X5ZJPWSS-Co4PdklG.js → sizeCapture-X5ZJPWSS-DQM5xAyD.js} +1 -1
  51. package/dist/assets/{stateDiagram-OKZ733FA-nOvL9dRC.js → stateDiagram-OKZ733FA-DJ3xkLUF.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-UEYNNEHI-DNv8ppw7.js → stateDiagram-v2-UEYNNEHI-vAaE4zNo.js} +1 -1
  53. package/dist/assets/{swimlanes-SLNWSIFB-CCK3wMid.js → swimlanes-SLNWSIFB-rjfYdW0w.js} +2 -2
  54. package/dist/assets/swimlanesDiagram-ULZ7WXOC-TV2IF4Nq.js +8 -0
  55. package/dist/assets/{timeline-definition-Z64GVDOM-BC29RNFm.js → timeline-definition-Z64GVDOM-DZMra5do.js} +1 -1
  56. package/dist/assets/{vennDiagram-T6HMQDX7-pZlZCtEo.js → vennDiagram-T6HMQDX7-BevD8uu0.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-T6FBY63Y-De8k2-zM.js → wardleyDiagram-T6FBY63Y-DsIyXB7z.js} +1 -1
  58. package/dist/assets/{xychartDiagram-ELKLHX3M-Bk8ykH0w.js → xychartDiagram-ELKLHX3M-G3PlF3ko.js} +1 -1
  59. package/dist/index.html +2 -2
  60. package/dist-server/server/index.js +4 -1
  61. package/dist-server/server/index.js.map +1 -1
  62. package/dist-server/server/modules/agent/agent.module.js +9 -2
  63. package/dist-server/server/modules/agent/agent.module.js.map +1 -1
  64. package/dist-server/server/modules/agent/agent.routes.js +175 -128
  65. package/dist-server/server/modules/agent/agent.routes.js.map +1 -1
  66. package/dist-server/server/modules/agent/tests/agent-run-registration.test.js +306 -0
  67. package/dist-server/server/modules/agent/tests/agent-run-registration.test.js.map +1 -0
  68. package/dist-server/server/modules/agent/tests/agent.routes.test.js +18 -1
  69. package/dist-server/server/modules/agent/tests/agent.routes.test.js.map +1 -1
  70. package/dist-server/server/modules/database/migrations.js +13 -0
  71. package/dist-server/server/modules/database/migrations.js.map +1 -1
  72. package/dist-server/server/modules/database/repositories/provider-models.js +60 -6
  73. package/dist-server/server/modules/database/repositories/provider-models.js.map +1 -1
  74. package/dist-server/server/modules/database/repositories/sessions.db.js +7 -0
  75. package/dist-server/server/modules/database/repositories/sessions.db.js.map +1 -1
  76. package/dist-server/server/modules/database/schema.js +7 -0
  77. package/dist-server/server/modules/database/schema.js.map +1 -1
  78. package/dist-server/server/modules/database/tests/provider-models.db.integration.test.js +99 -0
  79. package/dist-server/server/modules/database/tests/provider-models.db.integration.test.js.map +1 -1
  80. package/dist-server/server/modules/file-tree/file-tree.module.js +2 -1
  81. package/dist-server/server/modules/file-tree/file-tree.module.js.map +1 -1
  82. package/dist-server/server/modules/file-tree/file-tree.service.js +44 -7
  83. package/dist-server/server/modules/file-tree/file-tree.service.js.map +1 -1
  84. package/dist-server/server/modules/file-tree/tests/file-tree.service.test.js +126 -1
  85. package/dist-server/server/modules/file-tree/tests/file-tree.service.test.js.map +1 -1
  86. package/dist-server/server/modules/git/git-branch-diff.service.js +142 -0
  87. package/dist-server/server/modules/git/git-branch-diff.service.js.map +1 -0
  88. package/dist-server/server/modules/git/git-branch.service.js.map +1 -1
  89. package/dist-server/server/modules/git/git.routes.js +92 -5
  90. package/dist-server/server/modules/git/git.routes.js.map +1 -1
  91. package/dist-server/server/modules/git/tests/git-branch-diff.service.test.js +160 -0
  92. package/dist-server/server/modules/git/tests/git-branch-diff.service.test.js.map +1 -0
  93. package/dist-server/server/modules/projects/project-clone.routes.js +108 -0
  94. package/dist-server/server/modules/projects/project-clone.routes.js.map +1 -0
  95. package/dist-server/server/modules/projects/projects.routes.js +13 -66
  96. package/dist-server/server/modules/projects/projects.routes.js.map +1 -1
  97. package/dist-server/server/modules/projects/services/project-clone-request.service.js +36 -0
  98. package/dist-server/server/modules/projects/services/project-clone-request.service.js.map +1 -0
  99. package/dist-server/server/modules/projects/services/project-clone.service.js +86 -32
  100. package/dist-server/server/modules/projects/services/project-clone.service.js.map +1 -1
  101. package/dist-server/server/modules/projects/tests/project-clone.routes.test.js +191 -0
  102. package/dist-server/server/modules/projects/tests/project-clone.routes.test.js.map +1 -0
  103. package/dist-server/server/modules/projects/tests/project-clone.service.test.js +275 -0
  104. package/dist-server/server/modules/projects/tests/project-clone.service.test.js.map +1 -1
  105. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js +42 -8
  106. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js.map +1 -1
  107. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js +6 -16
  108. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js.map +1 -1
  109. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js +305 -16
  110. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js.map +1 -1
  111. package/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js +38 -11
  112. package/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js.map +1 -1
  113. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js +686 -77
  114. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js.map +1 -1
  115. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +31 -0
  116. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -1
  117. package/dist-server/server/modules/providers/list/codex/codex-runtime.provider.js +11 -4
  118. package/dist-server/server/modules/providers/list/codex/codex-runtime.provider.js.map +1 -1
  119. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +57 -0
  120. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -1
  121. package/dist-server/server/modules/providers/list/cursor/cursor-auth.provider.js +9 -2
  122. package/dist-server/server/modules/providers/list/cursor/cursor-auth.provider.js.map +1 -1
  123. package/dist-server/server/modules/providers/list/cursor/cursor-runtime.provider.js +8 -2
  124. package/dist-server/server/modules/providers/list/cursor/cursor-runtime.provider.js.map +1 -1
  125. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +16 -3
  126. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -1
  127. package/dist-server/server/modules/providers/list/opencode/opencode-runtime.provider.js +5 -2
  128. package/dist-server/server/modules/providers/list/opencode/opencode-runtime.provider.js.map +1 -1
  129. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +19 -2
  130. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -1
  131. package/dist-server/server/modules/providers/provider.routes.js +110 -2
  132. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  133. package/dist-server/server/modules/providers/services/claude-cli-liveness.service.js +100 -0
  134. package/dist-server/server/modules/providers/services/claude-cli-liveness.service.js.map +1 -0
  135. package/dist-server/server/modules/providers/services/claude-settings-permissions.service.js +97 -0
  136. package/dist-server/server/modules/providers/services/claude-settings-permissions.service.js.map +1 -0
  137. package/dist-server/server/modules/providers/services/provider-capabilities.service.js +9 -1
  138. package/dist-server/server/modules/providers/services/provider-capabilities.service.js.map +1 -1
  139. package/dist-server/server/modules/providers/services/provider-models.service.js +59 -5
  140. package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -1
  141. package/dist-server/server/modules/providers/services/provider-runtime.service.js +8 -0
  142. package/dist-server/server/modules/providers/services/provider-runtime.service.js.map +1 -1
  143. package/dist-server/server/modules/providers/services/session-conversations-search.service.js +2 -4
  144. package/dist-server/server/modules/providers/services/session-conversations-search.service.js.map +1 -1
  145. package/dist-server/server/modules/providers/services/session-history-cache.service.js +4 -0
  146. package/dist-server/server/modules/providers/services/session-history-cache.service.js.map +1 -1
  147. package/dist-server/server/modules/providers/services/sessions.service.js +73 -3
  148. package/dist-server/server/modules/providers/services/sessions.service.js.map +1 -1
  149. package/dist-server/server/modules/providers/tests/claude-auth.test.js +131 -0
  150. package/dist-server/server/modules/providers/tests/claude-auth.test.js.map +1 -1
  151. package/dist-server/server/modules/providers/tests/claude-background-work.test.js +172 -0
  152. package/dist-server/server/modules/providers/tests/claude-background-work.test.js.map +1 -0
  153. package/dist-server/server/modules/providers/tests/claude-compaction.test.js +84 -0
  154. package/dist-server/server/modules/providers/tests/claude-compaction.test.js.map +1 -0
  155. package/dist-server/server/modules/providers/tests/claude-message-model.test.js +123 -0
  156. package/dist-server/server/modules/providers/tests/claude-message-model.test.js.map +1 -0
  157. package/dist-server/server/modules/providers/tests/claude-runtime-hold.test.js +265 -0
  158. package/dist-server/server/modules/providers/tests/claude-runtime-hold.test.js.map +1 -0
  159. package/dist-server/server/modules/providers/tests/claude-sessions.test.js +1368 -12
  160. package/dist-server/server/modules/providers/tests/claude-sessions.test.js.map +1 -1
  161. package/dist-server/server/modules/providers/tests/claude-settings-permissions.test.js +228 -0
  162. package/dist-server/server/modules/providers/tests/claude-settings-permissions.test.js.map +1 -0
  163. package/dist-server/server/modules/providers/tests/codex-models.test.js +25 -0
  164. package/dist-server/server/modules/providers/tests/codex-models.test.js.map +1 -0
  165. package/dist-server/server/modules/providers/tests/codex-runtime.test.js +32 -0
  166. package/dist-server/server/modules/providers/tests/codex-runtime.test.js.map +1 -1
  167. package/dist-server/server/modules/providers/tests/codex-sessions.test.js +50 -0
  168. package/dist-server/server/modules/providers/tests/codex-sessions.test.js.map +1 -1
  169. package/dist-server/server/modules/providers/tests/cursor-auth.test.js +167 -0
  170. package/dist-server/server/modules/providers/tests/cursor-auth.test.js.map +1 -0
  171. package/dist-server/server/modules/providers/tests/opencode-models.test.js +79 -0
  172. package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -1
  173. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +19 -1
  174. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -1
  175. package/dist-server/server/modules/providers/tests/provider-capabilities.service.test.js +30 -0
  176. package/dist-server/server/modules/providers/tests/provider-capabilities.service.test.js.map +1 -0
  177. package/dist-server/server/modules/providers/tests/provider-models.service.test.js +79 -1
  178. package/dist-server/server/modules/providers/tests/provider-models.service.test.js.map +1 -1
  179. package/dist-server/server/modules/providers/tests/provider.routes.test.js +190 -1
  180. package/dist-server/server/modules/providers/tests/provider.routes.test.js.map +1 -1
  181. package/dist-server/server/modules/providers/tests/session-history-cache.service.test.js +19 -0
  182. package/dist-server/server/modules/providers/tests/session-history-cache.service.test.js.map +1 -1
  183. package/dist-server/server/modules/providers/tests/sessions.service.test.js +175 -1
  184. package/dist-server/server/modules/providers/tests/sessions.service.test.js.map +1 -1
  185. package/dist-server/server/modules/scheduled-messages/services/scheduled-message-dispatcher.service.js +13 -1
  186. package/dist-server/server/modules/scheduled-messages/services/scheduled-message-dispatcher.service.js.map +1 -1
  187. package/dist-server/server/modules/scheduled-messages/tests/scheduled-messages.test.js +36 -1
  188. package/dist-server/server/modules/scheduled-messages/tests/scheduled-messages.test.js.map +1 -1
  189. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js +27 -5
  190. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js.map +1 -1
  191. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +38 -1
  192. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  193. package/dist-server/server/modules/websocket/services/shell-websocket.service.js +230 -20
  194. package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
  195. package/dist-server/server/modules/websocket/tests/chat-background-subscribe.test.js +137 -0
  196. package/dist-server/server/modules/websocket/tests/chat-background-subscribe.test.js.map +1 -0
  197. package/dist-server/server/modules/websocket/tests/chat-stop-task.test.js +96 -0
  198. package/dist-server/server/modules/websocket/tests/chat-stop-task.test.js.map +1 -0
  199. package/dist-server/server/modules/websocket/tests/shell-websocket.service.test.js +420 -0
  200. package/dist-server/server/modules/websocket/tests/shell-websocket.service.test.js.map +1 -1
  201. package/dist-server/server/shared/message-unification.js +1 -1
  202. package/dist-server/server/shared/message-unification.js.map +1 -1
  203. package/dist-server/server/shared/tests/read-only-roots.test.js +124 -0
  204. package/dist-server/server/shared/tests/read-only-roots.test.js.map +1 -0
  205. package/dist-server/server/shared/tests/strip-ansi-sequences.test.js +31 -0
  206. package/dist-server/server/shared/tests/strip-ansi-sequences.test.js.map +1 -0
  207. package/dist-server/server/shared/utils.js +126 -0
  208. package/dist-server/server/shared/utils.js.map +1 -1
  209. package/electron/desktopWindow.js +6 -0
  210. package/electron/viewHost.js +25 -2
  211. package/package.json +4 -4
  212. package/server/index.ts +5 -1
  213. package/server/modules/agent/agent.module.ts +11 -1
  214. package/server/modules/agent/agent.routes.ts +198 -140
  215. package/server/modules/agent/tests/agent-run-registration.test.ts +348 -0
  216. package/server/modules/agent/tests/agent.routes.test.ts +18 -1
  217. package/server/modules/database/migrations.ts +14 -0
  218. package/server/modules/database/repositories/provider-models.ts +79 -6
  219. package/server/modules/database/repositories/sessions.db.ts +8 -0
  220. package/server/modules/database/schema.ts +7 -0
  221. package/server/modules/database/tests/provider-models.db.integration.test.ts +113 -0
  222. package/server/modules/file-tree/file-tree.module.ts +2 -1
  223. package/server/modules/file-tree/file-tree.service.ts +49 -7
  224. package/server/modules/file-tree/tests/file-tree.service.test.ts +163 -1
  225. package/server/modules/git/git-branch-diff.service.ts +204 -0
  226. package/server/modules/git/git-branch.service.ts +2 -12
  227. package/server/modules/git/git.routes.ts +100 -6
  228. package/server/modules/git/tests/git-branch-diff.service.test.ts +210 -0
  229. package/server/modules/projects/project-clone.routes.ts +142 -0
  230. package/server/modules/projects/projects.routes.ts +16 -81
  231. package/server/modules/projects/services/project-clone-request.service.ts +56 -0
  232. package/server/modules/projects/services/project-clone.service.ts +102 -34
  233. package/server/modules/projects/tests/project-clone.routes.test.ts +246 -0
  234. package/server/modules/projects/tests/project-clone.service.test.ts +376 -0
  235. package/server/modules/providers/list/claude/claude-auth.provider.ts +60 -10
  236. package/server/modules/providers/list/claude/claude-models.provider.ts +10 -22
  237. package/server/modules/providers/list/claude/claude-runtime.provider.js +322 -15
  238. package/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts +40 -14
  239. package/server/modules/providers/list/claude/claude-sessions.provider.ts +796 -88
  240. package/server/modules/providers/list/codex/codex-models.provider.ts +31 -0
  241. package/server/modules/providers/list/codex/codex-runtime.provider.ts +12 -4
  242. package/server/modules/providers/list/codex/codex-sessions.provider.ts +63 -0
  243. package/server/modules/providers/list/cursor/cursor-auth.provider.ts +10 -2
  244. package/server/modules/providers/list/cursor/cursor-runtime.provider.js +9 -2
  245. package/server/modules/providers/list/opencode/opencode-models.provider.ts +18 -3
  246. package/server/modules/providers/list/opencode/opencode-runtime.provider.js +5 -2
  247. package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +30 -2
  248. package/server/modules/providers/provider.routes.ts +131 -2
  249. package/server/modules/providers/services/claude-cli-liveness.service.ts +121 -0
  250. package/server/modules/providers/services/claude-settings-permissions.service.ts +127 -0
  251. package/server/modules/providers/services/provider-capabilities.service.ts +17 -1
  252. package/server/modules/providers/services/provider-models.service.ts +71 -4
  253. package/server/modules/providers/services/provider-runtime.service.ts +11 -0
  254. package/server/modules/providers/services/session-conversations-search.service.ts +2 -5
  255. package/server/modules/providers/services/session-history-cache.service.ts +13 -2
  256. package/server/modules/providers/services/sessions.service.ts +108 -8
  257. package/server/modules/providers/tests/claude-auth.test.ts +150 -0
  258. package/server/modules/providers/tests/claude-background-work.test.ts +213 -0
  259. package/server/modules/providers/tests/claude-compaction.test.ts +101 -0
  260. package/server/modules/providers/tests/claude-message-model.test.ts +160 -0
  261. package/server/modules/providers/tests/claude-runtime-hold.test.ts +309 -0
  262. package/server/modules/providers/tests/claude-sessions.test.ts +1630 -12
  263. package/server/modules/providers/tests/claude-settings-permissions.test.ts +271 -0
  264. package/server/modules/providers/tests/codex-models.test.ts +37 -0
  265. package/server/modules/providers/tests/codex-runtime.test.ts +36 -0
  266. package/server/modules/providers/tests/codex-sessions.test.ts +59 -0
  267. package/server/modules/providers/tests/cursor-auth.test.ts +187 -0
  268. package/server/modules/providers/tests/opencode-models.test.ts +100 -0
  269. package/server/modules/providers/tests/opencode-sessions.test.ts +40 -1
  270. package/server/modules/providers/tests/provider-capabilities.service.test.ts +37 -0
  271. package/server/modules/providers/tests/provider-models.service.test.ts +99 -1
  272. package/server/modules/providers/tests/provider.routes.test.ts +230 -1
  273. package/server/modules/providers/tests/session-history-cache.service.test.ts +22 -0
  274. package/server/modules/providers/tests/sessions.service.test.ts +237 -1
  275. package/server/modules/scheduled-messages/services/scheduled-message-dispatcher.service.ts +14 -1
  276. package/server/modules/scheduled-messages/tests/scheduled-messages.test.ts +45 -1
  277. package/server/modules/websocket/README.md +4 -2
  278. package/server/modules/websocket/services/chat-run-registry.service.ts +29 -5
  279. package/server/modules/websocket/services/chat-websocket.service.ts +58 -1
  280. package/server/modules/websocket/services/shell-websocket.service.ts +269 -22
  281. package/server/modules/websocket/tests/chat-background-subscribe.test.ts +167 -0
  282. package/server/modules/websocket/tests/chat-stop-task.test.ts +127 -0
  283. package/server/modules/websocket/tests/shell-websocket.service.test.ts +521 -0
  284. package/server/shared/interfaces.ts +29 -0
  285. package/server/shared/message-unification.ts +1 -1
  286. package/server/shared/tests/read-only-roots.test.ts +139 -0
  287. package/server/shared/tests/strip-ansi-sequences.test.ts +42 -0
  288. package/server/shared/types.ts +261 -7
  289. package/server/shared/utils.ts +136 -0
  290. package/dist/assets/channel-C5xU9jMw.js +0 -1
  291. package/dist/assets/classDiagram-JCYQIIEL-CRrL3vp7.js +0 -1
  292. package/dist/assets/classDiagram-v2-OCEON4UE-CRrL3vp7.js +0 -1
  293. package/dist/assets/index-BTDHQ6Id.css +0 -32
  294. package/dist/assets/index-sFfz8iBR.js +0 -4931
  295. package/dist/assets/swimlanesDiagram-ULZ7WXOC-DnfbNQEp.js +0 -8
@@ -1,11 +1,15 @@
1
1
  import assert from 'node:assert/strict';
2
- import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import test from 'node:test';
6
6
 
7
7
  import { closeConnection, initializeDatabase, projectsDb, sessionsDb } from '@/modules/database/index.js';
8
+ import { providerRegistry } from '@/modules/providers/provider.registry.js';
8
9
  import { sessionsService } from '@/modules/providers/services/sessions.service.js';
10
+ import { chatRunRegistry } from '@/modules/websocket/index.js';
11
+ import type { IProvider } from '@/shared/interfaces.js';
12
+ import type { BackgroundTaskSummary } from '@/shared/types.js';
9
13
 
10
14
  async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
11
15
  const previousDatabasePath = process.env.DATABASE_PATH;
@@ -191,3 +195,235 @@ test('history pages are sliced from the cached full transcript and see appended
191
195
  await rm(transcriptDirectory, { recursive: true, force: true });
192
196
  }
193
197
  });
198
+
199
+ /**
200
+ * Stands in for the provider registry with one provider per id, each with the
201
+ * runtime given — the seam through which a runtime's background work reaches
202
+ * the running-sessions list without an SDK run behind it.
203
+ */
204
+ async function withProviders(
205
+ runtimes: Partial<Record<'claude' | 'codex', IProvider['runtime']>>,
206
+ runTest: () => void | Promise<void>,
207
+ ): Promise<void> {
208
+ const realListProviders = providerRegistry.listProviders;
209
+ providerRegistry.listProviders = () =>
210
+ Object.entries(runtimes).map(([id, runtime]) => ({ id, runtime }) as IProvider);
211
+ try {
212
+ await runTest();
213
+ } finally {
214
+ providerRegistry.listProviders = realListProviders;
215
+ chatRunRegistry.clearAll();
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Runs `runTest` against a Claude CLI process registry containing exactly
221
+ * `records`, so the running-sessions tests never read the developer's real
222
+ * `~/.claude/sessions`.
223
+ */
224
+ async function withClaudeCliRegistry(
225
+ records: Array<Record<string, unknown>>,
226
+ runTest: () => void | Promise<void>,
227
+ ): Promise<void> {
228
+ const homeDirectory = await mkdtemp(path.join(os.tmpdir(), 'claude-cli-registry-'));
229
+ await mkdir(path.join(homeDirectory, '.claude', 'sessions'), { recursive: true });
230
+ for (const record of records) {
231
+ await writeFile(
232
+ path.join(homeDirectory, '.claude', 'sessions', `${record.pid}.json`),
233
+ JSON.stringify(record),
234
+ 'utf8',
235
+ );
236
+ }
237
+
238
+ const realHomedir = os.homedir;
239
+ (os as unknown as { homedir: () => string }).homedir = () => homeDirectory;
240
+ try {
241
+ await runTest();
242
+ } finally {
243
+ (os as unknown as { homedir: () => string }).homedir = realHomedir;
244
+ await rm(homeDirectory, { recursive: true, force: true });
245
+ }
246
+ }
247
+
248
+ const task = (taskId: string, startedAt: number): BackgroundTaskSummary => ({
249
+ taskId,
250
+ toolUseId: `toolu_${taskId}`,
251
+ taskType: 'local_agent',
252
+ description: `Task ${taskId}`,
253
+ startedAt,
254
+ });
255
+
256
+ test('running sessions list a session whose background work outlived its turn', { concurrency: false }, async () => {
257
+ await withIsolatedDatabase(async () => {
258
+ sessionsDb.createAppSession('held-session', 'claude', '/tmp/running-project');
259
+ // The turn ran and completed: the registry still holds the finished run.
260
+ const run = chatRunRegistry.startRun({
261
+ appSessionId: 'held-session', provider: 'claude', providerSessionId: null, connection: null, userId: null,
262
+ });
263
+ assert.ok(run);
264
+ run.writer.send({ kind: 'text', content: 'launched', sessionId: 'held-session', provider: 'claude' });
265
+ run.writer.sendComplete({ exitCode: 0 });
266
+ assert.equal(run.status, 'completed');
267
+
268
+ const tasks = [task('late', 2_000), task('early', 1_000)];
269
+ await withClaudeCliRegistry([], async () => await withProviders(
270
+ { claude: { run: async () => undefined, abort: () => false, listBackgroundWork: () => [{ sessionId: 'held-session', tasks }] } },
271
+ async () => {
272
+ assert.deepEqual(await sessionsService.listRunningSessions(), [{
273
+ sessionId: 'held-session',
274
+ provider: 'claude',
275
+ startedAt: 1_000,
276
+ lastSeq: run.lastSeq,
277
+ background: true,
278
+ canInterrupt: false,
279
+ tasks,
280
+ }]);
281
+ assert.ok(run.lastSeq > 0, 'the finished run still reports its sequence for replay');
282
+ },
283
+ ));
284
+ });
285
+ });
286
+
287
+ test('running sessions carry their tasks on a chat run that is still going', { concurrency: false }, async () => {
288
+ await withIsolatedDatabase(async () => {
289
+ sessionsDb.createAppSession('busy-session', 'claude', '/tmp/running-project');
290
+ sessionsDb.createAppSession('idle-session', 'codex', '/tmp/running-project');
291
+ const run = chatRunRegistry.startRun({
292
+ appSessionId: 'busy-session', provider: 'claude', providerSessionId: null, connection: null, userId: null,
293
+ });
294
+ assert.ok(run);
295
+
296
+ const tasks = [task('agent', 5_000)];
297
+ await withClaudeCliRegistry([], async () => await withProviders(
298
+ {
299
+ claude: { run: async () => undefined, abort: () => false, listBackgroundWork: () => [{ sessionId: 'busy-session', tasks }] },
300
+ // A runtime without background work contributes chat runs alone.
301
+ codex: { run: async () => undefined, abort: () => false },
302
+ },
303
+ async () => {
304
+ const sessions = await sessionsService.listRunningSessions();
305
+ assert.equal(sessions.length, 1, 'a session with a running chat run is listed once');
306
+ assert.deepEqual(sessions[0], {
307
+ sessionId: 'busy-session',
308
+ provider: 'claude',
309
+ startedAt: run.startedAt,
310
+ lastSeq: 0,
311
+ tasks,
312
+ });
313
+ },
314
+ ));
315
+ });
316
+ });
317
+
318
+ test('running sessions with no background work are the chat runs alone', { concurrency: false }, async () => {
319
+ await withIsolatedDatabase(async () => {
320
+ sessionsDb.createAppSession('plain-session', 'codex', '/tmp/running-project');
321
+ const run = chatRunRegistry.startRun({
322
+ appSessionId: 'plain-session', provider: 'codex', providerSessionId: null, connection: null, userId: null,
323
+ });
324
+ assert.ok(run);
325
+
326
+ await withClaudeCliRegistry([], async () => await withProviders(
327
+ { codex: { run: async () => undefined, abort: () => false } },
328
+ async () => {
329
+ assert.deepEqual(await sessionsService.listRunningSessions(), [{
330
+ sessionId: 'plain-session', provider: 'codex', startedAt: run.startedAt, lastSeq: 0,
331
+ }]);
332
+ },
333
+ ));
334
+ });
335
+ });
336
+
337
+ test('running sessions report a turn driven by the Claude CLI', { concurrency: false }, async () => {
338
+ await withIsolatedDatabase(async () => {
339
+ sessionsDb.createAppSession('cli-session', 'claude', '/tmp/running-project');
340
+ sessionsDb.assignProviderSessionId('cli-session', 'claude-native-1');
341
+
342
+ // `process.pid` is the one pid guaranteed to be alive; omitting procStart
343
+ // keeps the fixture portable, since the start-time cross-check is Linux-only.
344
+ await withClaudeCliRegistry(
345
+ [{ pid: process.pid, sessionId: 'claude-native-1', status: 'busy', entrypoint: 'cli', startedAt: 4_000 }],
346
+ async () => await withProviders(
347
+ { claude: { run: async () => undefined, abort: () => false } },
348
+ async () => {
349
+ assert.deepEqual(await sessionsService.listRunningSessions(), [{
350
+ sessionId: 'cli-session',
351
+ provider: 'claude',
352
+ startedAt: 4_000,
353
+ lastSeq: 0,
354
+ canInterrupt: false,
355
+ statusText: 'Running in the Claude CLI',
356
+ }]);
357
+ },
358
+ ),
359
+ );
360
+ });
361
+ });
362
+
363
+ test('running sessions ignore registry files a crash left behind', { concurrency: false }, async () => {
364
+ await withIsolatedDatabase(async () => {
365
+ sessionsDb.createAppSession('stale-session', 'claude', '/tmp/running-project');
366
+ sessionsDb.assignProviderSessionId('stale-session', 'claude-native-stale');
367
+
368
+ await withClaudeCliRegistry(
369
+ [
370
+ // The process is gone: signal 0 cannot find this pid.
371
+ { pid: 4_194_302, sessionId: 'claude-native-stale', status: 'busy', startedAt: 1_000 },
372
+ // Alive, but waiting for input rather than producing a response.
373
+ { pid: process.pid, sessionId: 'claude-native-idle', status: 'idle', startedAt: 2_000 },
374
+ ],
375
+ async () => await withProviders(
376
+ { claude: { run: async () => undefined, abort: () => false } },
377
+ async () => {
378
+ assert.deepEqual(await sessionsService.listRunningSessions(), []);
379
+ },
380
+ ),
381
+ );
382
+ });
383
+ });
384
+
385
+ test('a CLI turn does not duplicate a session already running a chat run', { concurrency: false }, async () => {
386
+ await withIsolatedDatabase(async () => {
387
+ sessionsDb.createAppSession('both-session', 'claude', '/tmp/running-project');
388
+ sessionsDb.assignProviderSessionId('both-session', 'claude-native-both');
389
+ const run = chatRunRegistry.startRun({
390
+ appSessionId: 'both-session', provider: 'claude', providerSessionId: 'claude-native-both', connection: null, userId: null,
391
+ });
392
+ assert.ok(run);
393
+
394
+ await withClaudeCliRegistry(
395
+ [{ pid: process.pid, sessionId: 'claude-native-both', status: 'busy', startedAt: 3_000 }],
396
+ async () => await withProviders(
397
+ { claude: { run: async () => undefined, abort: () => false } },
398
+ async () => {
399
+ const sessions = await sessionsService.listRunningSessions();
400
+ assert.equal(sessions.length, 1, 'the chat run already covers this session');
401
+ assert.equal(sessions[0].canInterrupt, undefined, 'the interruptible chat run entry wins');
402
+ },
403
+ ),
404
+ );
405
+ });
406
+ });
407
+
408
+ test('an unreadable registry never fails the running-sessions poll', { concurrency: false }, async () => {
409
+ await withIsolatedDatabase(async () => {
410
+ sessionsDb.createAppSession('safe-session', 'claude', '/tmp/running-project');
411
+ sessionsDb.assignProviderSessionId('safe-session', 'claude-native-safe');
412
+
413
+ await withClaudeCliRegistry(
414
+ [{ pid: process.pid, sessionId: 'claude-native-safe', status: 'busy', startedAt: 5_000 }],
415
+ async () => {
416
+ // A half-written file sits alongside the good one; it must be skipped
417
+ // rather than abort the whole poll.
418
+ await writeFile(path.join(os.homedir(), '.claude', 'sessions', 'broken.json'), '{"pid":', 'utf8');
419
+ await withProviders(
420
+ { claude: { run: async () => undefined, abort: () => false } },
421
+ async () => {
422
+ const sessions = await sessionsService.listRunningSessions();
423
+ assert.deepEqual(sessions.map((session) => session.sessionId), ['safe-session']);
424
+ },
425
+ );
426
+ },
427
+ );
428
+ });
429
+ });
@@ -81,13 +81,26 @@ async function sendClaimedQueuedMessage(
81
81
  sessionDraftsDb.deleteEmptyDraft(candidate.userId, candidate.sessionId);
82
82
  }
83
83
 
84
+ /**
85
+ * Whether a queued turn has to keep waiting for the session.
86
+ *
87
+ * A turn that ends with background work still going (a backgrounded command,
88
+ * agent or workflow) is marked completed, but its CLI process stays open so
89
+ * that work can report back. A new turn replaces that process and stops the
90
+ * work, which is why the composer asks before an interactive send. A queued
91
+ * turn was promised to go "when this finishes", so it waits for that work too.
92
+ */
93
+ function isSessionBusy(sessionId: string, runtime: ProviderRuntimeGateway): boolean {
94
+ return chatRunRegistry.isProcessing(sessionId) || runtime.hasBackgroundWork(sessionId);
95
+ }
96
+
84
97
  /** Sends every persisted queued turn whose session is currently idle. */
85
98
  export async function dispatchQueuedMessages(runtime: ProviderRuntimeGateway): Promise<number> {
86
99
  const candidates = sessionDraftsDb.listQueuedMessages();
87
100
  let claimed = 0;
88
101
 
89
102
  await Promise.all(candidates.map(async (candidate) => {
90
- if (chatRunRegistry.isProcessing(candidate.sessionId)) {
103
+ if (isSessionBusy(candidate.sessionId, runtime)) {
91
104
  return;
92
105
  }
93
106
  if (!sessionDraftsDb.claimQueuedMessage(candidate)) {
@@ -37,9 +37,15 @@ async function withIsolatedDatabase(runTest: (userId: number) => void | Promise<
37
37
 
38
38
  type RunCall = { provider: string; command: string; options: Record<string, unknown> };
39
39
 
40
- function createRuntime(runs: RunCall[], behaviour: 'ok' | 'throw' = 'ok', aborts: string[] = []) {
40
+ function createRuntime(
41
+ runs: RunCall[],
42
+ behaviour: 'ok' | 'throw' = 'ok',
43
+ aborts: string[] = [],
44
+ hasBackgroundWork: (sessionId: string) => boolean = () => false,
45
+ ) {
41
46
  return {
42
47
  hasRuntime: () => true,
48
+ hasBackgroundWork,
43
49
  run: async (provider: string, command: string, options: Record<string, unknown>) => {
44
50
  if (behaviour === 'throw') {
45
51
  throw new Error('provider exploded');
@@ -117,6 +123,44 @@ test('a queued message stays pending while its session is busy', async () => {
117
123
  });
118
124
  });
119
125
 
126
+ test('a queued message waits for background work its session\'s last turn left running', async () => {
127
+ await withIsolatedDatabase(async (userId) => {
128
+ sessionDraftsDb.saveDraft(userId, SESSION_ID, {
129
+ text: '',
130
+ queuedMessage: { content: 'send after the background job' },
131
+ });
132
+ // Turn A ended, so its run is completed, but it backgrounded a command
133
+ // that is still running under A's CLI process.
134
+ const runA = chatRunRegistry.startRun({
135
+ appSessionId: SESSION_ID,
136
+ provider: 'claude',
137
+ providerSessionId: null,
138
+ connection: null,
139
+ userId,
140
+ });
141
+ runA!.writer.sendComplete({ exitCode: 0 });
142
+ assert.equal(chatRunRegistry.isProcessing(SESSION_ID), false);
143
+
144
+ let backgroundWork = true;
145
+ const runs: RunCall[] = [];
146
+ const runtime = createRuntime(runs, 'ok', [], (sessionId) => sessionId === SESSION_ID && backgroundWork);
147
+
148
+ // Starting B now would replace that process and stop the command.
149
+ assert.equal(await dispatchQueuedMessages(runtime), 0);
150
+ assert.equal(runs.length, 0);
151
+ assert.deepEqual(sessionDraftsDb.getDrafts(userId)[0]?.queuedMessage, {
152
+ content: 'send after the background job',
153
+ });
154
+
155
+ // Once the work has reported back, B goes on the next pass.
156
+ backgroundWork = false;
157
+ assert.equal(await dispatchQueuedMessages(runtime), 1);
158
+ assert.equal(runs.length, 1);
159
+ assert.equal(runs[0].command, 'send after the background job');
160
+ assert.equal(sessionDraftsDb.getDrafts(userId).length, 0);
161
+ });
162
+ });
163
+
120
164
  test('a due message interrupts a run in progress instead of failing', async () => {
121
165
  await withIsolatedDatabase(async (userId) => {
122
166
  scheduledMessagesService.schedule({
@@ -33,7 +33,7 @@ Benefits:
33
33
  |---|---|
34
34
  | `services/websocket-server.service.ts` | Creates `WebSocketServer`, binds `verifyClient`, routes connection by pathname |
35
35
  | `services/websocket-auth.service.ts` | Authenticates upgrade requests and attaches `request.user` |
36
- | `services/chat-websocket.service.ts` | Handles the `/ws` chat protocol (`chat.send` / `chat.abort` / `chat.subscribe` / `chat.permission-response`) |
36
+ | `services/chat-websocket.service.ts` | Handles the `/ws` chat protocol (`chat.send` / `chat.abort` / `chat.stop-task` / `chat.subscribe` / `chat.permission-response`) |
37
37
  | `services/chat-run-registry.service.ts` | Tracks live provider runs per app session id: seq numbering, event replay buffer, provider-id mapping, completion state |
38
38
  | `services/chat-session-writer.service.ts` | Gateway writer handed to provider runtimes: remaps provider session ids to app ids, swallows `session_created`, assigns `seq` |
39
39
  | `services/shell-websocket.service.ts` | Handles `/shell` PTY lifecycle, reconnect buffering, auth URL detection |
@@ -108,7 +108,7 @@ When a chat socket connects:
108
108
 
109
109
  1. Add socket to `connectedClients`.
110
110
  2. Parse each incoming message with `parseIncomingJsonObject`.
111
- 3. Dispatch by `data.type` (four message types, none provider-specific).
111
+ 3. Dispatch by `data.type` (six message types, none provider-specific).
112
112
  4. On close, remove socket from `connectedClients`.
113
113
 
114
114
  ### Session identity model
@@ -130,7 +130,9 @@ flowchart TD
130
130
  B -->|ok| D{data.type}
131
131
 
132
132
  D -->|chat.send| E[resolve session row -> startRun -> providerRuntimeService.run]
133
+ D -->|chat.edit-send| E2[history_truncated -> rewind or resume at the anchor -> same as chat.send]
133
134
  D -->|chat.abort| F[providerRuntimeService.abort + synthetic complete]
135
+ D -->|chat.stop-task| F2[providerRuntimeService.stopBackgroundTask]
134
136
  D -->|chat.subscribe| G[chat_subscribed ack + attach socket + replay events seq > lastSeq]
135
137
  D -->|chat.permission-response| H[providerRuntimeService.resolveToolApproval]
136
138
  D -->|other| I[send kind:protocol_error]
@@ -59,12 +59,31 @@ const MAX_BUFFERED_EVENTS_PER_RUN = 5000;
59
59
  */
60
60
  const runs = new Map<string, ChatRun>();
61
61
 
62
- function evictRunLater(appSessionId: string): void {
62
+ /**
63
+ * Answers whether a completed run must stay registered a while longer. Set by
64
+ * the composition root to the provider runtimes' background-work check: a
65
+ * session whose turn ended but whose agents, workflows or commands are still
66
+ * running keeps sending live events through this run's writer, and a tab that
67
+ * subscribes meanwhile needs the run to attach to.
68
+ */
69
+ let retainCompletedRun: (appSessionId: string) => boolean = () => false;
70
+
71
+ /**
72
+ * Schedules one run's eviction. The timer is bound to the run it was armed
73
+ * for: a later run can take the session's slot while this one's retention —
74
+ * re-armed for as long as the guard holds — is still pending, and firing on
75
+ * the slot alone would evict that newer run early.
76
+ */
77
+ function evictRunLater(run: ChatRun): void {
63
78
  const timer = setTimeout(() => {
64
- const run = runs.get(appSessionId);
65
- if (run && run.status === 'completed') {
66
- runs.delete(appSessionId);
79
+ if (runs.get(run.appSessionId) !== run || run.status !== 'completed') {
80
+ return;
81
+ }
82
+ if (retainCompletedRun(run.appSessionId)) {
83
+ evictRunLater(run);
84
+ return;
67
85
  }
86
+ runs.delete(run.appSessionId);
68
87
  }, COMPLETED_RUN_RETENTION_MS);
69
88
 
70
89
  // Never keep the process alive just to evict a buffered run.
@@ -104,7 +123,7 @@ function decorateAndRecordEvent(run: ChatRun, message: NormalizedMessage): Norma
104
123
  outbound.actualSessionId = run.appSessionId;
105
124
  run.status = 'completed';
106
125
  run.completedAt = Date.now();
107
- evictRunLater(run.appSessionId);
126
+ evictRunLater(run);
108
127
  }
109
128
 
110
129
  run.events.push(outbound);
@@ -160,6 +179,11 @@ function recordProviderSessionId(run: ChatRun, providerSessionId: string): void
160
179
  * regardless of which provider runtime produced them.
161
180
  */
162
181
  export const chatRunRegistry = {
182
+ /** Installs the check that keeps a completed run registered while its session still has background work. */
183
+ setRetentionGuard(guard: (appSessionId: string) => boolean): void {
184
+ retainCompletedRun = guard;
185
+ },
186
+
163
187
  /**
164
188
  * Starts tracking a run and returns it, or `null` when a run is already in
165
189
  * progress for the session (callers must reject the duplicate send).
@@ -73,6 +73,9 @@ export type ProviderRuntimeGateway = {
73
73
  writer: ProviderRuntimeWriter,
74
74
  ): Promise<unknown>;
75
75
  abort(provider: LLMProvider, sessionId: string): Promise<boolean>;
76
+ stopBackgroundTask(provider: LLMProvider, sessionId: string, taskId: string): Promise<boolean>;
77
+ /** Whether a provider runtime still holds background work for the session after its turn ended. */
78
+ hasBackgroundWork(sessionId: string): boolean;
76
79
  resolveToolApproval(requestId: string, payload: ProviderPermissionDecision): void;
77
80
  getPendingApprovalsForSession(sessionId: string): unknown[];
78
81
  };
@@ -437,6 +440,52 @@ async function handleChatAbort(
437
440
  });
438
441
  }
439
442
 
443
+ /**
444
+ * Handles `chat.stop-task`: stops one background task of a session — an
445
+ * agent, a workflow or a backgrounded command that is still going after its
446
+ * turn ended. Unlike `chat.abort` there is no run to consult: the task lives
447
+ * in the provider's held process, which is the only thing that can stop it.
448
+ * The provider then reports the task as stopped on the session's stream, so
449
+ * nothing is echoed back here on success.
450
+ */
451
+ async function handleChatStopTask(
452
+ ws: WebSocket,
453
+ data: AnyRecord,
454
+ dependencies: ChatWebSocketDependencies
455
+ ): Promise<void> {
456
+ const sessionId = readRequiredSessionId(data);
457
+ if (!sessionId) {
458
+ sendProtocolError(ws, 'SESSION_ID_REQUIRED', 'chat.stop-task requires a sessionId.');
459
+ return;
460
+ }
461
+
462
+ const taskId = typeof data.taskId === 'string' ? data.taskId.trim() : '';
463
+ if (!taskId) {
464
+ sendProtocolError(ws, 'TASK_ID_REQUIRED', 'chat.stop-task requires a taskId.', sessionId);
465
+ return;
466
+ }
467
+
468
+ const session = sessionsDb.getSessionById(sessionId);
469
+ if (!session) {
470
+ sendProtocolError(ws, 'SESSION_NOT_FOUND', `Session "${sessionId}" was not found.`, sessionId);
471
+ return;
472
+ }
473
+
474
+ const stopped = await dependencies.runtime.stopBackgroundTask(
475
+ session.provider as LLMProvider,
476
+ sessionId,
477
+ taskId,
478
+ );
479
+ if (!stopped) {
480
+ sendProtocolError(
481
+ ws,
482
+ 'NO_SUCH_TASK',
483
+ `Session "${sessionId}" has no running background task "${taskId}".`,
484
+ sessionId,
485
+ );
486
+ }
487
+ }
488
+
440
489
  /**
441
490
  * Handles `chat.subscribe`: for each requested session, reports whether a run
442
491
  * is processing, re-attaches the live stream to this socket, replays missed
@@ -474,7 +523,11 @@ function handleChatSubscribe(
474
523
 
475
524
  // Future live events for this run should land on the socket that asked —
476
525
  // this is what makes mid-stream page refreshes work for all providers.
477
- if (isProcessing) {
526
+ // A session whose turn ended but whose background work is still going
527
+ // keeps producing events through the same writer (task progress, the
528
+ // turn the CLI pushes when a task reports), so a tab opened during that
529
+ // work attaches too; the registry keeps the run while the work lasts.
530
+ if (isProcessing || dependencies.runtime.hasBackgroundWork(sessionId)) {
478
531
  chatRunRegistry.attachConnection(sessionId, ws);
479
532
  }
480
533
 
@@ -527,6 +580,7 @@ function handlePermissionResponse(data: AnyRecord, dependencies: ChatWebSocketDe
527
580
  * Inbound protocol (client to server):
528
581
  * - `chat.send` { sessionId, content, options? }
529
582
  * - `chat.abort` { sessionId }
583
+ * - `chat.stop-task` { sessionId, taskId }
530
584
  * - `chat.subscribe` { sessions: [{ sessionId, lastSeq? }] }
531
585
  * - `chat.permission-response` { requestId, allow, updatedInput?, message?, rememberEntry? }
532
586
  *
@@ -630,6 +684,9 @@ export function handleChatConnection(
630
684
  case 'chat.abort':
631
685
  await handleChatAbort(ws, data, dependencies);
632
686
  return;
687
+ case 'chat.stop-task':
688
+ await handleChatStopTask(ws, data, dependencies);
689
+ return;
633
690
  case 'chat.subscribe':
634
691
  handleChatSubscribe(ws, data, dependencies);
635
692
  return;