@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
@@ -6,6 +6,8 @@ import test from 'node:test';
6
6
 
7
7
  import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
8
8
  import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js';
9
+ import { ClaudeSessionSynchronizer } from '@/modules/providers/list/claude/claude-session-synchronizer.provider.js';
10
+ import { buildLookupMap } from '@/shared/utils.js';
9
11
 
10
12
  async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
11
13
  const previousDatabasePath = process.env.DATABASE_PATH;
@@ -28,6 +30,43 @@ async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promis
28
30
  }
29
31
  }
30
32
 
33
+ const patchHomeDir = (nextHomeDir: string) => {
34
+ const original = os.homedir;
35
+ (os as any).homedir = () => nextHomeDir;
36
+ return () => {
37
+ (os as any).homedir = original;
38
+ };
39
+ };
40
+
41
+ /**
42
+ * Writes a minimal valid Claude JSONL session file with enough fields for
43
+ * `extractFirstValidJsonlData` to parse `sessionId` and `cwd`.
44
+ */
45
+ async function writeSessionJsonl(
46
+ dirPath: string,
47
+ fileName: string,
48
+ lines: string[],
49
+ ): Promise<string> {
50
+ const filePath = path.join(dirPath, fileName);
51
+ const head = [
52
+ JSON.stringify({ type: 'mode', mode: 'normal', sessionId: 'test-session-1' }),
53
+ JSON.stringify({ type: 'permission-mode', permissionMode: 'default', sessionId: 'test-session-1' }),
54
+ JSON.stringify({
55
+ parentUuid: null,
56
+ isSidechain: false,
57
+ type: 'user',
58
+ message: { role: 'user', content: 'first prompt' },
59
+ uuid: 'msg-1',
60
+ timestamp: '2026-07-10T00:00:00.000Z',
61
+ cwd: '/workspace/demo',
62
+ sessionId: 'test-session-1',
63
+ }),
64
+ ];
65
+ const content = [...head, ...lines, ''].join('\n');
66
+ await writeFile(filePath, content, 'utf8');
67
+ return filePath;
68
+ }
69
+
31
70
  const SESSION_ID = 'claude-session-1';
32
71
  const AGENT_ID = 'a1b2c3d4e5f60718';
33
72
  const AGENT_TOOL_USE_ID = 'toolu_agent_1';
@@ -209,6 +248,161 @@ test('Claude history folds an agent task notification into the call that spawned
209
248
  }
210
249
  });
211
250
 
251
+ /**
252
+ * Rewrites the notification turn into the shape the harness writes when it
253
+ * consumes the report inline, while the parent turn is still running: a
254
+ * top-level `queue-operation` record with the payload in `content`, no
255
+ * `message`, no `uuid`, and no user-role turn anywhere in the file.
256
+ */
257
+ async function useQueueOperationNotification(
258
+ parentPath: string,
259
+ operation: 'remove' | 'enqueue',
260
+ ): Promise<void> {
261
+ const lines = (await readFile(parentPath, 'utf8')).split('\n').filter(Boolean);
262
+ const rewritten = lines.map((line) => {
263
+ const row = JSON.parse(line) as Record<string, any>;
264
+ if (!line.includes('task-notification')) {
265
+ return line;
266
+ }
267
+
268
+ return JSON.stringify({
269
+ type: 'queue-operation',
270
+ operation,
271
+ timestamp: row.timestamp,
272
+ sessionId: row.sessionId,
273
+ content: row.message.content[0].text,
274
+ });
275
+ });
276
+ await writeFile(parentPath, `${rewritten.join('\n')}\n`, 'utf8');
277
+ }
278
+
279
+ for (const operation of ['remove', 'enqueue'] as const) {
280
+ test(`Claude history folds a ${operation} queue-operation notification onto the agent card`, { concurrency: false }, async () => {
281
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-queued-notification-'));
282
+
283
+ try {
284
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
285
+ await useQueueOperationNotification(parentPath, operation);
286
+
287
+ await withIsolatedDatabase(async () => {
288
+ const now = new Date().toISOString();
289
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
290
+
291
+ const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
292
+ providerSessionId: SESSION_ID,
293
+ });
294
+ const agentRow = history.messages.find(
295
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
296
+ );
297
+
298
+ // The report is in the file, just in the shape the collector used to
299
+ // skip — so the card has to read exactly as it does for a user-role
300
+ // notification: the agent's answer on the card, and a settled status.
301
+ assert.equal(agentRow?.toolResult?.content, 'The repo has two packages.');
302
+ assert.equal(agentRow?.subagent?.status, 'completed');
303
+
304
+ const strayNotification = history.messages.find(
305
+ (message) => typeof message.content === 'string' && message.content.includes('<task-notification>'),
306
+ );
307
+ assert.equal(strayNotification, undefined, 'a folded notification must never also render on its own');
308
+ });
309
+ } finally {
310
+ await rm(tempRoot, { recursive: true, force: true });
311
+ }
312
+ });
313
+ }
314
+
315
+ test('Claude history keeps rows that have no uuid when a queue-operation notification is folded', { concurrency: false }, async () => {
316
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-uuidless-row-'));
317
+
318
+ try {
319
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
320
+ await useQueueOperationNotification(parentPath, 'remove');
321
+ // A queue-operation record carries no `uuid`, so folding it must not put an
322
+ // empty string in the drop set — that would match every other uuid-less row
323
+ // and delete it from the transcript.
324
+ await writeFile(
325
+ parentPath,
326
+ `${(await readFile(parentPath, 'utf8')).trim()}\n${JSON.stringify({
327
+ type: 'assistant',
328
+ sessionId: SESSION_ID,
329
+ timestamp: '2026-08-21T10:06:00.000Z',
330
+ message: { role: 'assistant', content: [{ type: 'text', text: 'A row with no uuid.' }] },
331
+ })}\n`,
332
+ 'utf8',
333
+ );
334
+
335
+ await withIsolatedDatabase(async () => {
336
+ const now = new Date().toISOString();
337
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
338
+
339
+ const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
340
+ providerSessionId: SESSION_ID,
341
+ });
342
+
343
+ assert.ok(
344
+ history.messages.some((message) => message.content === 'A row with no uuid.'),
345
+ 'a row without a uuid must survive the fold',
346
+ );
347
+ });
348
+ } finally {
349
+ await rm(tempRoot, { recursive: true, force: true });
350
+ }
351
+ });
352
+
353
+ test('Claude history drops every notification row of a task that reported more than once', { concurrency: false }, async () => {
354
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-renotified-agent-'));
355
+
356
+ try {
357
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
358
+ // A resumed session first reports the task as stopped ("no completion
359
+ // record was found"), then the task finishes and reports again. The last
360
+ // word is what the card shows; the earlier row is superseded bookkeeping
361
+ // and must not linger as a bubble of its own.
362
+ const lines = (await readFile(parentPath, 'utf8')).split('\n').filter(Boolean);
363
+ const [notificationLine] = lines.filter((line) => line.includes('task-notification'));
364
+ const completed = JSON.parse(notificationLine) as Record<string, any>;
365
+ const stopped = {
366
+ ...completed,
367
+ uuid: 'notification-0',
368
+ timestamp: '2026-08-21T10:04:00.000Z',
369
+ message: {
370
+ role: 'user',
371
+ content: [{
372
+ type: 'text',
373
+ text: completed.message.content[0].text
374
+ .replace('<status>completed</status>', '<status>stopped</status>')
375
+ .replace(/<result>[\s\S]*<\/result>/, '')
376
+ .replace(/<summary>[^<]*<\/summary>/, '<summary>No completion record was found for this agent</summary>'),
377
+ }],
378
+ },
379
+ };
380
+ const rewritten = lines.flatMap((line) => (line === notificationLine ? [JSON.stringify(stopped), line] : [line]));
381
+ await writeFile(parentPath, `${rewritten.join('\n')}\n`, 'utf8');
382
+
383
+ await withIsolatedDatabase(async () => {
384
+ const now = new Date().toISOString();
385
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
386
+
387
+ const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
388
+ providerSessionId: SESSION_ID,
389
+ });
390
+ const agentRow = history.messages.find(
391
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
392
+ );
393
+
394
+ assert.equal(agentRow?.subagent?.status, 'completed');
395
+ assert.equal(agentRow?.toolResult?.content, 'The repo has two packages.');
396
+ assert.ok(
397
+ !history.messages.some((message) => (message.content ?? '').includes('<task-notification>')),
398
+ 'no notification row may render on its own once the task is folded',
399
+ );
400
+ });
401
+ } finally {
402
+ await rm(tempRoot, { recursive: true, force: true });
403
+ }
404
+ });
405
+
212
406
  /** Strips the `<task-notification>` turn so the agent has no reported outcome. */
213
407
  async function dropTaskNotification(parentPath: string): Promise<void> {
214
408
  const raw = await readFile(parentPath, 'utf8');
@@ -219,8 +413,8 @@ async function dropTaskNotification(parentPath: string): Promise<void> {
219
413
  );
220
414
  }
221
415
 
222
- test('Claude history reads a missing notification off the agent\'s own transcript', { concurrency: false }, async () => {
223
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-finished-agent-'));
416
+ test('Claude history keeps a background agent running until its outcome is reported', { concurrency: false }, async () => {
417
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-live-agent-'));
224
418
 
225
419
  try {
226
420
  const parentPath = await writeClaudeSubagentSession(tempRoot);
@@ -230,17 +424,52 @@ test('Claude history reads a missing notification off the agent\'s own transcrip
230
424
  const now = new Date().toISOString();
231
425
  sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
232
426
 
233
- const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
427
+ // The run that launched the agent is still up, so it can still report.
428
+ const liveRunStartedAt = Date.parse('2026-08-21T09:59:00.000Z');
429
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => liveRunStartedAt }).fetchHistory(SESSION_ID, {
234
430
  providerSessionId: SESSION_ID,
235
431
  });
236
432
  const agentRow = history.messages.find(
237
433
  (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
238
434
  );
239
435
 
240
- // The notification can be compacted out of a long session. The agent's
241
- // transcript ends on a resolved tool call, so it finished — reporting it
242
- // as still running would leave a spinner on the card forever.
243
- assert.equal(agentRow?.subagent?.status, 'completed');
436
+ // This agent's transcript ends on a resolved tool call, which used to be
437
+ // read as proof it had finished. A background agent routinely stops there
438
+ // while its work is outstanding — it says it is waiting on something and
439
+ // ends its turn — so the transcript proves nothing and only the
440
+ // notification does. Reporting `completed` here dropped the spinner and
441
+ // collapsed the card while the agent was still working.
442
+ assert.equal(agentRow?.subagent?.status, 'running');
443
+ assert.equal(agentRow?.toolResult?.content, '', 'the launch acknowledgement must never show as a result');
444
+ });
445
+ } finally {
446
+ await rm(tempRoot, { recursive: true, force: true });
447
+ }
448
+ });
449
+
450
+ test('Claude history reports a background agent stopped once its session process is gone', { concurrency: false }, async () => {
451
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-stopped-agent-'));
452
+
453
+ try {
454
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
455
+ await dropTaskNotification(parentPath);
456
+
457
+ await withIsolatedDatabase(async () => {
458
+ const now = new Date().toISOString();
459
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
460
+
461
+ // A background agent runs inside the session's CLI process. With that
462
+ // process gone — the run was stopped, or crashed — an agent that never
463
+ // reported never will, and "still running" would pin a spinner on the
464
+ // card forever.
465
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(SESSION_ID, {
466
+ providerSessionId: SESSION_ID,
467
+ });
468
+ const agentRow = history.messages.find(
469
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
470
+ );
471
+
472
+ assert.equal(agentRow?.subagent?.status, 'stopped');
244
473
  assert.equal(agentRow?.toolResult?.content, '', 'the launch acknowledgement must never show as a result');
245
474
  });
246
475
  } finally {
@@ -248,15 +477,902 @@ test('Claude history reads a missing notification off the agent\'s own transcrip
248
477
  }
249
478
  });
250
479
 
251
- test('Claude history keeps an agent running when its transcript stops mid tool call', { concurrency: false }, async () => {
252
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-running-agent-'));
480
+ test('Claude history reports a background agent stopped when a later run of the session is live', { concurrency: false }, async () => {
481
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-orphaned-agent-'));
482
+
483
+ try {
484
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
485
+ await dropTaskNotification(parentPath);
486
+
487
+ await withIsolatedDatabase(async () => {
488
+ const now = new Date().toISOString();
489
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
490
+
491
+ // Each turn of a session is its own CLI process. The agent was launched
492
+ // at 10:00:01 by a process that has since exited; the run up now started
493
+ // an hour later and cannot deliver that agent's report. Reading it as
494
+ // `running` made an orphaned card flicker back to a spinner on every
495
+ // later turn.
496
+ const laterRunStartedAt = Date.parse('2026-08-21T11:00:00.000Z');
497
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => laterRunStartedAt }).fetchHistory(SESSION_ID, {
498
+ providerSessionId: SESSION_ID,
499
+ });
500
+ const agentRow = history.messages.find(
501
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
502
+ );
503
+
504
+ assert.equal(agentRow?.subagent?.status, 'stopped');
505
+ });
506
+ } finally {
507
+ await rm(tempRoot, { recursive: true, force: true });
508
+ }
509
+ });
510
+
511
+ test('Claude history asks after the session process by both the app and the provider session id', { concurrency: false }, async () => {
512
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-liveness-keys-'));
513
+ const appSessionId = 'app-session-1';
514
+
515
+ try {
516
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
517
+ await dropTaskNotification(parentPath);
518
+
519
+ await withIsolatedDatabase(async () => {
520
+ const now = new Date().toISOString();
521
+ // The DB row carries the app-facing id; the transcript rows carry the
522
+ // provider-native one. The runtime keys its process map by whichever
523
+ // id started the run — the app id from the chat gateway, the provider
524
+ // id from the agent API — so a probe that knows only one of them must
525
+ // still find the run.
526
+ sessionsDb.createSession(appSessionId, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
527
+
528
+ for (const liveId of [appSessionId, SESSION_ID]) {
529
+ const history = await new ClaudeSessionsProvider({
530
+ getLiveRunStartTime: (sessionId) => (sessionId === liveId ? Date.parse('2026-08-21T09:59:00.000Z') : null),
531
+ }).fetchHistory(appSessionId, { providerSessionId: SESSION_ID });
532
+ const agentRow = history.messages.find(
533
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
534
+ );
535
+
536
+ assert.equal(agentRow?.subagent?.status, 'running', `a run keyed by ${liveId} must read as live`);
537
+ }
538
+ });
539
+ } finally {
540
+ await rm(tempRoot, { recursive: true, force: true });
541
+ }
542
+ });
543
+
544
+ /** Removes the agent's own transcript and sidecar, leaving only the parent's rows. */
545
+ async function dropSubagentTranscript(projectDirectory: string): Promise<void> {
546
+ await rm(path.join(projectDirectory, SESSION_ID, 'subagents'), { recursive: true, force: true });
547
+ }
548
+
549
+ for (const reported of ['completed', 'failed'] as const) {
550
+ test(`Claude history settles a ${reported} background agent from its notification when its transcript is missing`, { concurrency: false }, async () => {
551
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-transcriptless-agent-'));
552
+
553
+ try {
554
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
555
+ await useQueueOperationNotification(parentPath, 'remove');
556
+ await writeFile(
557
+ parentPath,
558
+ (await readFile(parentPath, 'utf8')).replace('<status>completed</status>', `<status>${reported}</status>`),
559
+ 'utf8',
560
+ );
561
+ await dropSubagentTranscript(tempRoot);
562
+
563
+ await withIsolatedDatabase(async () => {
564
+ const now = new Date().toISOString();
565
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
566
+
567
+ // The notification is the only record of the outcome, and it is
568
+ // enough: the agent's own transcript names its timeline, not whether
569
+ // it finished. Folding the answer onto the card while leaving the
570
+ // status unset read as `running` on a card whose result said "done".
571
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(SESSION_ID, {
572
+ providerSessionId: SESSION_ID,
573
+ });
574
+ const agentRow = history.messages.find(
575
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
576
+ );
577
+
578
+ assert.equal(agentRow?.toolResult?.content, 'The repo has two packages.');
579
+ assert.equal(agentRow?.subagent?.status, reported);
580
+ assert.equal(agentRow?.subagent?.id, AGENT_ID);
581
+ assert.equal(agentRow?.subagent?.description, 'Survey the repo');
582
+ assert.equal(agentRow?.subagent?.model, 'claude-opus-5');
583
+ assert.equal(agentRow?.subagentTools, undefined, 'there is no timeline to show without a transcript');
584
+ });
585
+ } finally {
586
+ await rm(tempRoot, { recursive: true, force: true });
587
+ }
588
+ });
589
+ }
590
+
591
+ test('Claude history reports a forked background agent stopped: no transcript, no notification, no process', { concurrency: false }, async () => {
592
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-forked-agent-'));
253
593
 
254
594
  try {
595
+ // A fork copies the parent's `.jsonl` rows and nothing else — no
596
+ // `subagents/` directory and no queue-operation records — and the new
597
+ // session has no process of its own yet.
255
598
  const parentPath = await writeClaudeSubagentSession(tempRoot);
256
599
  await dropTaskNotification(parentPath);
600
+ await dropSubagentTranscript(tempRoot);
601
+
602
+ await withIsolatedDatabase(async () => {
603
+ const now = new Date().toISOString();
604
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
605
+
606
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(SESSION_ID, {
607
+ providerSessionId: SESSION_ID,
608
+ });
609
+ const agentRow = history.messages.find(
610
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
611
+ );
612
+
613
+ // Without `subagent` the card falls back to "async launch, therefore
614
+ // running", which is the spinner that never went away.
615
+ assert.equal(agentRow?.subagent?.status, 'stopped');
616
+ });
617
+ } finally {
618
+ await rm(tempRoot, { recursive: true, force: true });
619
+ }
620
+ });
621
+
622
+ test('a live SDK tool result keeps its launch metadata under the stream key', () => {
623
+ // The transcript spells it `toolUseResult`; the SDK stream spells it
624
+ // `tool_use_result`. Reading only the former sent every live agent launch to
625
+ // the client without `isAsync`, so the card could not tell the launch
626
+ // acknowledgement apart from an answer and settled the agent at launch.
627
+ const [normalized] = new ClaudeSessionsProvider().normalizeMessage({
628
+ type: 'user',
629
+ session_id: SESSION_ID,
630
+ parent_tool_use_id: null,
631
+ uuid: 'launch-ack-live',
632
+ message: {
633
+ role: 'user',
634
+ content: [{
635
+ type: 'tool_result',
636
+ tool_use_id: AGENT_TOOL_USE_ID,
637
+ content: 'Async agent launched successfully. agentId: internal bookkeeping',
638
+ }],
639
+ },
640
+ tool_use_result: { isAsync: true, status: 'async_launched', agentId: AGENT_ID },
641
+ }, SESSION_ID);
642
+
643
+ assert.equal(normalized?.kind, 'tool_result');
644
+ assert.equal((normalized?.toolUseResult as { isAsync?: boolean } | undefined)?.isAsync, true);
645
+ });
646
+
647
+ test('a live SDK tool result caps the strings inside its structured output', () => {
648
+ // The stream's `tool_use_result` is the tool's whole structured output — an
649
+ // Edit's carries the entire file it edited — and it is sent to every client
650
+ // and kept in the run's replay buffer. The transcript path caps each nested
651
+ // string at the same limit before it leaves the server; the live path must
652
+ // not be the one place that forwards it whole.
653
+ const [normalized] = new ClaudeSessionsProvider().normalizeMessage({
654
+ type: 'user',
655
+ session_id: SESSION_ID,
656
+ parent_tool_use_id: null,
657
+ uuid: 'edit-result-live',
658
+ message: {
659
+ role: 'user',
660
+ content: [{ type: 'tool_result', tool_use_id: 'toolu_edit', content: 'The file has been updated.' }],
661
+ },
662
+ tool_use_result: { filePath: '/repo/big.ts', originalFile: 'x'.repeat(50_000) },
663
+ }, SESSION_ID);
664
+
665
+ const forwarded = normalized?.toolUseResult as { filePath?: string; originalFile?: string } | undefined;
666
+ assert.equal(forwarded?.filePath, '/repo/big.ts');
667
+ assert.ok(forwarded?.originalFile && forwarded.originalFile.length < 50_000, 'the file body must be capped');
668
+ assert.match(forwarded?.originalFile ?? '', /… 10000 more characters$/);
669
+ });
670
+
671
+ const WORKFLOW_SESSION_ID = 'claude-workflow-session';
672
+ const WORKFLOW_TOOL_USE_ID = 'toolu_workflow_1';
673
+ const WORKFLOW_RUN_ID = 'wf_16fbf852-274';
674
+ const WORKFLOW_TASK_ID = 'wxkj4kcvd';
675
+ const WORKFLOW_SCRIPT = [
676
+ 'export const meta = {',
677
+ " name: 'frontend-architecture-audit',",
678
+ " description: 'Evidence-based audit of the frontend',",
679
+ " phases: [{ title: 'Audit', detail: 'parallel deep-dives per module cluster' }],",
680
+ '}',
681
+ ].join('\n');
682
+
683
+ /**
684
+ * The `<task-notification>` a finished workflow reports through, as the
685
+ * harness writes it — the real one in session 4820c6b8 carries a JSON
686
+ * `<result>` and names the tool call that launched the run.
687
+ */
688
+ const workflowNotificationText = (status: string) => [
689
+ '<task-notification>',
690
+ `<task-id>${WORKFLOW_TASK_ID}</task-id>`,
691
+ `<tool-use-id>${WORKFLOW_TOOL_USE_ID}</tool-use-id>`,
692
+ `<output-file>/tmp/claude-1000/tasks/${WORKFLOW_TASK_ID}.output</output-file>`,
693
+ `<status>${status}</status>`,
694
+ '<summary>Dynamic workflow "Evidence-based audit of the frontend" completed</summary>',
695
+ '<result>{"audits":[{"area":"src/modules/chat","summary":"three large hooks"}]}</result>',
696
+ '</task-notification>',
697
+ ].join('\n');
698
+
699
+ type WorkflowSessionOptions = {
700
+ /** How the completion report is recorded, or `none` for a run still out. */
701
+ notification: 'user' | 'remove' | 'enqueue' | 'none';
702
+ /** Whether the run left a journal behind; a fork copies none. */
703
+ journal: boolean;
704
+ };
705
+
706
+ /**
707
+ * Writes the rows a `Workflow` launch leaves in a session transcript, copied
708
+ * from session 4820c6b8-b23b-468f-b462-74162ecf0f24 (lines 107, 108 and 294),
709
+ * plus the run's journal under `<session>/subagents/workflows/<runId>/`.
710
+ *
711
+ * The journal records three agents: one finished, one failed, one still
712
+ * going — the shape a run has partway through its second phase.
713
+ */
714
+ async function writeClaudeWorkflowSession(
715
+ projectDirectory: string,
716
+ { notification, journal }: WorkflowSessionOptions,
717
+ ): Promise<string> {
718
+ const parentPath = path.join(projectDirectory, `${WORKFLOW_SESSION_ID}.jsonl`);
719
+ const transcriptDir = path.join(projectDirectory, WORKFLOW_SESSION_ID, 'subagents', 'workflows', WORKFLOW_RUN_ID);
720
+ const scriptPath = path.join(projectDirectory, WORKFLOW_SESSION_ID, 'workflows', 'scripts', `frontend-architecture-audit-${WORKFLOW_RUN_ID}.js`);
721
+
722
+ const parentLines: Record<string, unknown>[] = [
723
+ {
724
+ type: 'assistant',
725
+ uuid: 'assistant-wf-1',
726
+ sessionId: WORKFLOW_SESSION_ID,
727
+ timestamp: '2026-08-21T10:32:10.000Z',
728
+ message: {
729
+ role: 'assistant',
730
+ content: [{
731
+ type: 'tool_use',
732
+ id: WORKFLOW_TOOL_USE_ID,
733
+ name: 'Workflow',
734
+ input: { script: WORKFLOW_SCRIPT, description: 'Parallel frontend architecture audit' },
735
+ }],
736
+ },
737
+ },
738
+ {
739
+ type: 'user',
740
+ uuid: 'workflow-ack-1',
741
+ sessionId: WORKFLOW_SESSION_ID,
742
+ timestamp: '2026-08-21T10:32:15.657Z',
743
+ message: {
744
+ role: 'user',
745
+ content: [{
746
+ type: 'tool_result',
747
+ tool_use_id: WORKFLOW_TOOL_USE_ID,
748
+ content: `Workflow launched in background. Task ID: ${WORKFLOW_TASK_ID}\nSummary: Evidence-based audit of the frontend\nTranscript dir: ${transcriptDir}\nScript file: ${scriptPath}`,
749
+ }],
750
+ },
751
+ toolUseResult: {
752
+ status: 'async_launched',
753
+ taskId: WORKFLOW_TASK_ID,
754
+ taskType: 'local_workflow',
755
+ workflowName: 'frontend-architecture-audit',
756
+ runId: WORKFLOW_RUN_ID,
757
+ summary: 'Evidence-based audit of the frontend',
758
+ transcriptDir,
759
+ scriptPath,
760
+ },
761
+ },
762
+ ];
763
+
764
+ if (notification === 'user') {
765
+ parentLines.push({
766
+ type: 'user',
767
+ uuid: 'workflow-notification-1',
768
+ sessionId: WORKFLOW_SESSION_ID,
769
+ timestamp: '2026-08-21T11:27:00.000Z',
770
+ message: { role: 'user', content: [{ type: 'text', text: workflowNotificationText('completed') }] },
771
+ });
772
+ } else if (notification !== 'none') {
773
+ parentLines.push({
774
+ type: 'queue-operation',
775
+ operation: notification,
776
+ timestamp: '2026-08-21T11:27:00.000Z',
777
+ sessionId: WORKFLOW_SESSION_ID,
778
+ content: workflowNotificationText('completed'),
779
+ });
780
+ }
781
+
782
+ await writeFile(parentPath, `${parentLines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf8');
783
+
784
+ if (journal) {
785
+ await mkdir(transcriptDir, { recursive: true });
786
+ const journalLines = [
787
+ { type: 'launched' },
788
+ { type: 'started', key: 'v2:one', agentId: 'aa1e064cf8bd159d6', label: 'audit:chat', phase: 'Audit' },
789
+ { type: 'started', key: 'v2:two', agentId: 'a9cfe29aa8f2afcbf', label: 'audit:sidebar', phase: 'Audit' },
790
+ { type: 'result', key: 'v2:one', agentId: 'aa1e064cf8bd159d6', result: { area: 'chat' } },
791
+ { type: 'failed', key: 'v2:two', agentId: 'a9cfe29aa8f2afcbf' },
792
+ { type: 'started', key: 'v2:three', agentId: 'ab89f2cde612a51b1', label: 'synthesize', phase: 'Synthesize' },
793
+ ];
794
+ await writeFile(
795
+ path.join(transcriptDir, 'journal.jsonl'),
796
+ `${journalLines.map((line) => JSON.stringify(line)).join('\n')}\n`,
797
+ 'utf8',
798
+ );
799
+ }
800
+
801
+ return parentPath;
802
+ }
803
+
804
+ for (const notification of ['user', 'remove', 'enqueue'] as const) {
805
+ test(`Claude history folds a workflow's ${notification} notification onto the call that launched it`, { concurrency: false }, async () => {
806
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-notification-'));
807
+
808
+ try {
809
+ const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification, journal: true });
810
+
811
+ await withIsolatedDatabase(async () => {
812
+ const now = new Date().toISOString();
813
+ sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
814
+
815
+ // The run is over, so no process is up — and that must not matter:
816
+ // the notification is the outcome.
817
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(WORKFLOW_SESSION_ID, {
818
+ providerSessionId: WORKFLOW_SESSION_ID,
819
+ });
820
+ const workflowRow = history.messages.find(
821
+ (message) => message.kind === 'tool_use' && message.toolId === WORKFLOW_TOOL_USE_ID,
822
+ );
823
+
824
+ // A Workflow launch has no `agentId`, so the fold used to skip it: the
825
+ // card kept "Workflow launched in background. Task ID: …" as its result
826
+ // for good while the report rendered as a raw user bubble — or, for a
827
+ // queue-operation record, nowhere.
828
+ assert.equal(
829
+ workflowRow?.toolResult?.content,
830
+ '{"audits":[{"area":"src/modules/chat","summary":"three large hooks"}]}',
831
+ );
832
+ assert.equal(workflowRow?.workflow?.status, 'completed');
833
+ assert.equal(workflowRow?.workflow?.name, 'frontend-architecture-audit');
834
+ assert.equal(workflowRow?.workflow?.runId, WORKFLOW_RUN_ID);
835
+ assert.equal(workflowRow?.workflow?.description, 'Evidence-based audit of the frontend');
836
+ assert.match(workflowRow?.workflow?.scriptPath ?? '', /frontend-architecture-audit-wf_16fbf852-274\.js$/);
837
+
838
+ const strayNotification = history.messages.find(
839
+ (message) => typeof message.content === 'string' && message.content.includes('<task-notification>'),
840
+ );
841
+ assert.equal(strayNotification, undefined, 'the folded notification must not also render on its own');
842
+ });
843
+ } finally {
844
+ await rm(tempRoot, { recursive: true, force: true });
845
+ }
846
+ });
847
+ }
848
+
849
+ test('Claude history reads a workflow\'s agents and their progress from its journal', { concurrency: false }, async () => {
850
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-journal-'));
851
+
852
+ try {
853
+ const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification: 'none', journal: true });
854
+
855
+ await withIsolatedDatabase(async () => {
856
+ const now = new Date().toISOString();
857
+ sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
858
+
859
+ // The run that launched the workflow is still up, so it is still going.
860
+ const liveRunStartedAt = Date.parse('2026-08-21T10:30:00.000Z');
861
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => liveRunStartedAt }).fetchHistory(WORKFLOW_SESSION_ID, {
862
+ providerSessionId: WORKFLOW_SESSION_ID,
863
+ });
864
+ const workflowRow = history.messages.find(
865
+ (message) => message.kind === 'tool_use' && message.toolId === WORKFLOW_TOOL_USE_ID,
866
+ );
867
+
868
+ assert.equal(workflowRow?.workflow?.status, 'running');
869
+ assert.equal(workflowRow?.toolResult?.content, '', 'the launch acknowledgement must never show as a result');
870
+ // An agent with a `started` record and no `result` or `failed` is the
871
+ // one still working; the journal is the only place that says so.
872
+ assert.deepEqual(workflowRow?.workflow?.agents, [
873
+ { id: 'aa1e064cf8bd159d6', label: 'audit:chat', phase: 'Audit', status: 'completed' },
874
+ { id: 'a9cfe29aa8f2afcbf', label: 'audit:sidebar', phase: 'Audit', status: 'failed' },
875
+ { id: 'ab89f2cde612a51b1', label: 'synthesize', phase: 'Synthesize', status: 'running' },
876
+ ]);
877
+ assert.deepEqual(workflowRow?.workflow?.agentCounts, { total: 3, completed: 1, failed: 1, running: 1, stopped: 0 });
878
+ });
879
+ } finally {
880
+ await rm(tempRoot, { recursive: true, force: true });
881
+ }
882
+ });
883
+
884
+ test('Claude history does not keep a journal agent running once the workflow itself has settled', { concurrency: false }, async () => {
885
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-orphan-agent-'));
886
+
887
+ try {
888
+ // The journal has a `started` with no `result` or `failed` for the
889
+ // synthesize step, but the run has reported completed — a resume re-ran
890
+ // that step under a new id, or the run was stopped under it. Either way
891
+ // nothing is still going, and a pulsing dot on a finished card said
892
+ // otherwise.
893
+ const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification: 'user', journal: true });
894
+
895
+ await withIsolatedDatabase(async () => {
896
+ const now = new Date().toISOString();
897
+ sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
898
+
899
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(WORKFLOW_SESSION_ID, {
900
+ providerSessionId: WORKFLOW_SESSION_ID,
901
+ });
902
+ const workflowRow = history.messages.find(
903
+ (message) => message.kind === 'tool_use' && message.toolId === WORKFLOW_TOOL_USE_ID,
904
+ );
905
+
906
+ assert.equal(workflowRow?.workflow?.status, 'completed');
907
+ assert.deepEqual(
908
+ workflowRow?.workflow?.agents.map((agent) => agent.status),
909
+ ['completed', 'failed', 'stopped'],
910
+ );
911
+ assert.deepEqual(workflowRow?.workflow?.agentCounts, { total: 3, completed: 1, failed: 1, running: 0, stopped: 1 });
912
+ });
913
+ } finally {
914
+ await rm(tempRoot, { recursive: true, force: true });
915
+ }
916
+ });
917
+
918
+ test('Claude history reports a workflow stopped, with no agents, when its run and journal are both gone', { concurrency: false }, async () => {
919
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-forked-'));
920
+
921
+ try {
922
+ // A fork copies the parent's `.jsonl` rows and nothing else: no
923
+ // `subagents/workflows/` directory, no queue-operation records, and no
924
+ // process of its own.
925
+ const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification: 'none', journal: false });
926
+
927
+ await withIsolatedDatabase(async () => {
928
+ const now = new Date().toISOString();
929
+ sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
930
+
931
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(WORKFLOW_SESSION_ID, {
932
+ providerSessionId: WORKFLOW_SESSION_ID,
933
+ });
934
+ const workflowRow = history.messages.find(
935
+ (message) => message.kind === 'tool_use' && message.toolId === WORKFLOW_TOOL_USE_ID,
936
+ );
937
+
938
+ assert.equal(workflowRow?.workflow?.status, 'stopped');
939
+ assert.deepEqual(workflowRow?.workflow?.agents, []);
940
+ assert.deepEqual(workflowRow?.workflow?.agentCounts, { total: 0, completed: 0, failed: 0, running: 0, stopped: 0 });
941
+ });
942
+ } finally {
943
+ await rm(tempRoot, { recursive: true, force: true });
944
+ }
945
+ });
946
+
947
+ test('Claude history folds a backgrounded Bash command\'s notification onto its call', { concurrency: false }, async () => {
948
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-background-bash-'));
949
+ const sessionId = 'claude-bash-session';
950
+ const toolUseId = 'toolu_bash_bg_1';
951
+
952
+ try {
953
+ // Copied from session 30ee5a44 (lines 167 and 234): the shell's own
954
+ // acknowledgement, and the report the harness later queues for it. A
955
+ // shell report carries no `<result>`, only its summary.
956
+ const rows = [
957
+ {
958
+ type: 'assistant',
959
+ uuid: 'assistant-bash-1',
960
+ sessionId,
961
+ timestamp: '2026-08-21T10:00:00.000Z',
962
+ message: {
963
+ role: 'assistant',
964
+ content: [{ type: 'tool_use', id: toolUseId, name: 'Bash', input: { command: 'npm run dev', run_in_background: true } }],
965
+ },
966
+ },
967
+ {
968
+ type: 'user',
969
+ uuid: 'bash-ack-1',
970
+ sessionId,
971
+ timestamp: '2026-08-21T10:00:01.000Z',
972
+ message: {
973
+ role: 'user',
974
+ content: [{
975
+ type: 'tool_result',
976
+ tool_use_id: toolUseId,
977
+ content: 'Command running in background with ID: b5xsbzu5k. Output is being written to: /tmp/tasks/b5xsbzu5k.output. You will be notified when it completes.',
978
+ }],
979
+ },
980
+ toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false, noOutputExpected: false, backgroundTaskId: 'b5xsbzu5k' },
981
+ },
982
+ {
983
+ type: 'queue-operation',
984
+ operation: 'enqueue',
985
+ timestamp: '2026-08-21T10:05:00.000Z',
986
+ sessionId,
987
+ content: [
988
+ '<task-notification>',
989
+ '<task-id>b5xsbzu5k</task-id>',
990
+ `<tool-use-id>${toolUseId}</tool-use-id>`,
991
+ '<output-file>/tmp/tasks/b5xsbzu5k.output</output-file>',
992
+ '<status>completed</status>',
993
+ '<summary>Background command "Start the dev server" completed (exit code 0)</summary>',
994
+ '</task-notification>',
995
+ ].join('\n'),
996
+ },
997
+ ];
998
+ const transcriptPath = path.join(tempRoot, `${sessionId}.jsonl`);
999
+ await writeFile(transcriptPath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
1000
+
1001
+ await withIsolatedDatabase(async () => {
1002
+ const now = new Date().toISOString();
1003
+ sessionsDb.createSession(sessionId, 'claude', tempRoot, 'Bash session', now, now, transcriptPath);
1004
+
1005
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(sessionId, {
1006
+ providerSessionId: sessionId,
1007
+ });
1008
+ const bashRow = history.messages.find(
1009
+ (message) => message.kind === 'tool_use' && message.toolId === toolUseId,
1010
+ );
1011
+
1012
+ assert.equal(bashRow?.toolResult?.content, 'Background command "Start the dev server" completed (exit code 0)');
1013
+
1014
+ // Without the report, the shell's acknowledgement stays: unlike an
1015
+ // agent's or a workflow's, it names the output file, which is the only
1016
+ // handle on the command's output until it reports.
1017
+ await writeFile(transcriptPath, `${rows.slice(0, 2).map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
1018
+ const unreported = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(sessionId, {
1019
+ providerSessionId: sessionId,
1020
+ });
1021
+ const unreportedRow = unreported.messages.find(
1022
+ (message) => message.kind === 'tool_use' && message.toolId === toolUseId,
1023
+ );
1024
+ assert.match(unreportedRow?.toolResult?.content ?? '', /Output is being written to: \/tmp\/tasks\/b5xsbzu5k\.output/);
1025
+ });
1026
+ } finally {
1027
+ await rm(tempRoot, { recursive: true, force: true });
1028
+ }
1029
+ });
1030
+
1031
+ test('a long workflow agent timeline keeps its newest steps within the transport cap', { concurrency: false }, async () => {
1032
+ // The timeline is polled while the agent runs for what it is doing now; a
1033
+ // cap that kept the first 200 steps would freeze it there, and the card's
1034
+ // "N earlier steps are not included" would be the wrong way round.
1035
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-agent-long-'));
1036
+
1037
+ try {
1038
+ const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification: 'none', journal: true });
1039
+ const runDir = path.join(tempRoot, WORKFLOW_SESSION_ID, 'subagents', 'workflows', WORKFLOW_RUN_ID);
1040
+ const agentId = 'ab89f2cde612a51b1';
1041
+ const rows = [
1042
+ { isSidechain: true, agentId, type: 'user', uuid: 'wa-u1', timestamp: '2026-08-21T10:40:00.000Z', message: { role: 'user', content: 'Synthesize the audits.' } },
1043
+ ...Array.from({ length: 205 }, (_, index) => ({
1044
+ isSidechain: true,
1045
+ agentId,
1046
+ type: 'assistant',
1047
+ uuid: `wa-a${index + 1}`,
1048
+ timestamp: new Date(Date.parse('2026-08-21T10:40:05.000Z') + index * 1_000).toISOString(),
1049
+ message: { role: 'assistant', model: 'claude-opus-4-1', content: [{ type: 'thinking', thinking: `step ${index + 1}` }] },
1050
+ })),
1051
+ ];
1052
+ await writeFile(path.join(runDir, `agent-${agentId}.jsonl`), `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
1053
+
1054
+ await withIsolatedDatabase(async () => {
1055
+ const now = new Date().toISOString();
1056
+ sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
1057
+
1058
+ const live = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => Date.parse('2026-08-21T10:30:00.000Z') })
1059
+ .readWorkflowAgentActivity(WORKFLOW_SESSION_ID, WORKFLOW_RUN_ID, agentId);
1060
+ assert.equal(live?.activityCount, 205);
1061
+ assert.equal(live?.activity.length, 200);
1062
+ assert.equal(live?.activity[0]?.content, 'step 6');
1063
+ assert.equal(live?.activity.at(-1)?.content, 'step 205');
1064
+ });
1065
+ } finally {
1066
+ await rm(tempRoot, { recursive: true, force: true });
1067
+ }
1068
+ });
1069
+
1070
+ test('a workflow agent the journal has not settled is running only while the run that spawned it is', { concurrency: false }, async () => {
1071
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-agent-activity-'));
1072
+
1073
+ try {
1074
+ const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification: 'none', journal: true });
1075
+ // The synthesize step's transcript: the journal says `started` and nothing
1076
+ // more, so only the process tells whether it is still working.
1077
+ const runDir = path.join(tempRoot, WORKFLOW_SESSION_ID, 'subagents', 'workflows', WORKFLOW_RUN_ID);
1078
+ const agentId = 'ab89f2cde612a51b1';
1079
+ const rows = [
1080
+ { isSidechain: true, agentId, type: 'user', uuid: 'wa-u1', timestamp: '2026-08-21T10:40:00.000Z', message: { role: 'user', content: 'Synthesize the audits.' } },
1081
+ { isSidechain: true, agentId, type: 'assistant', uuid: 'wa-a1', timestamp: '2026-08-21T10:40:05.000Z', message: { role: 'assistant', model: 'claude-opus-4-1', content: [{ type: 'thinking', thinking: 'Two audits to merge.' }, { type: 'tool_use', id: 'toolu_wa_1', name: 'Read', input: { file_path: '/repo/audits.json' } }] } },
1082
+ ];
1083
+ await writeFile(path.join(runDir, `agent-${agentId}.jsonl`), `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
1084
+
1085
+ await withIsolatedDatabase(async () => {
1086
+ const now = new Date().toISOString();
1087
+ sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
1088
+
1089
+ // The run that spawned the agent is still up: it started before the
1090
+ // agent's first row was written.
1091
+ const live = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => Date.parse('2026-08-21T10:30:00.000Z') })
1092
+ .readWorkflowAgentActivity(WORKFLOW_SESSION_ID, WORKFLOW_RUN_ID, agentId);
1093
+ assert.deepEqual(live?.agent, { id: agentId, label: 'synthesize', model: 'claude-opus-4-1', status: 'running' });
1094
+ assert.equal(live?.activityCount, 2);
1095
+ assert.deepEqual(live?.activity.map((entry) => entry.kind), ['thinking', 'tool']);
1096
+ assert.equal(live?.activity[0]?.content, 'Two audits to merge.');
1097
+ assert.equal(live?.activity[1]?.toolName, 'Read');
1098
+
1099
+ // A later run of the session is a new process; the agent's own is gone.
1100
+ const orphaned = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => Date.parse('2026-08-21T11:00:00.000Z') })
1101
+ .readWorkflowAgentActivity(WORKFLOW_SESSION_ID, WORKFLOW_RUN_ID, agentId);
1102
+ assert.equal(orphaned?.agent.status, 'stopped');
1103
+
1104
+ // No transcript for this agent: nothing to show, which the route turns
1105
+ // into a 404 rather than an empty timeline.
1106
+ const missing = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null })
1107
+ .readWorkflowAgentActivity(WORKFLOW_SESSION_ID, WORKFLOW_RUN_ID, 'aa1e064cf8bd159d6');
1108
+ assert.equal(missing, null);
1109
+ });
1110
+ } finally {
1111
+ await rm(tempRoot, { recursive: true, force: true });
1112
+ }
1113
+ });
1114
+
1115
+ test('the live stream\'s background-task events normalize to task_status', () => {
1116
+ // Verified against a real query: the SDK reports on a launched workflow,
1117
+ // agent or shell command through four `system` subtypes the transcript
1118
+ // never records. They used to fall through every branch of the normalizer
1119
+ // and vanish, so a running workflow's card could not move off its launch
1120
+ // acknowledgement until the whole run had finished and history reloaded.
1121
+ const provider = new ClaudeSessionsProvider();
1122
+ const normalize = (raw: Record<string, unknown>) =>
1123
+ provider.normalizeMessage({ type: 'system', session_id: WORKFLOW_SESSION_ID, uuid: `evt-${String(raw.subtype)}`, ...raw }, WORKFLOW_SESSION_ID);
1124
+
1125
+ const [started] = normalize({
1126
+ subtype: 'task_started',
1127
+ task_id: WORKFLOW_TASK_ID,
1128
+ tool_use_id: WORKFLOW_TOOL_USE_ID,
1129
+ description: 'Parallel frontend architecture audit',
1130
+ task_type: 'local_workflow',
1131
+ workflow_name: 'frontend-architecture-audit',
1132
+ });
1133
+ assert.equal(started?.kind, 'task_status');
1134
+ assert.equal(started?.event, 'started');
1135
+ assert.equal(started?.taskId, WORKFLOW_TASK_ID);
1136
+ assert.equal(started?.toolUseId, WORKFLOW_TOOL_USE_ID);
1137
+ assert.equal(started?.taskType, 'local_workflow');
1138
+ assert.equal(started?.workflowName, 'frontend-architecture-audit');
1139
+ assert.equal(started?.description, 'Parallel frontend architecture audit');
1140
+
1141
+ const [progress] = normalize({
1142
+ subtype: 'task_progress',
1143
+ task_id: WORKFLOW_TASK_ID,
1144
+ tool_use_id: WORKFLOW_TOOL_USE_ID,
1145
+ description: 'Parallel frontend architecture audit',
1146
+ usage: { total_tokens: 12_345, tool_uses: 12, duration_ms: 65_000 },
1147
+ summary: 'Verify 3/6',
1148
+ });
1149
+ assert.equal(progress?.kind, 'task_status');
1150
+ assert.equal(progress?.event, 'progress');
1151
+ assert.deepEqual(progress?.usage, { totalTokens: 12_345, toolUses: 12, durationMs: 65_000 });
1152
+ assert.equal(progress?.summary, 'Verify 3/6');
1153
+
1154
+ // `task_updated` names no tool call — the client has to remember the task
1155
+ // id from the start event — and spells a stop `killed`.
1156
+ const [updated] = normalize({
1157
+ subtype: 'task_updated',
1158
+ task_id: WORKFLOW_TASK_ID,
1159
+ patch: { status: 'killed', end_time: 1_700_000_000_000 },
1160
+ });
1161
+ assert.equal(updated?.kind, 'task_status');
1162
+ assert.equal(updated?.event, 'updated');
1163
+ assert.equal(updated?.taskId, WORKFLOW_TASK_ID);
1164
+ assert.equal(updated?.toolUseId, undefined);
1165
+ assert.equal(updated?.status, 'stopped');
1166
+
1167
+ const [notification] = normalize({
1168
+ subtype: 'task_notification',
1169
+ task_id: WORKFLOW_TASK_ID,
1170
+ tool_use_id: WORKFLOW_TOOL_USE_ID,
1171
+ status: 'completed',
1172
+ summary: 'Dynamic workflow "Evidence-based audit of the frontend" completed',
1173
+ output_file: `/tmp/claude-1000/tasks/${WORKFLOW_TASK_ID}.output`,
1174
+ });
1175
+ assert.equal(notification?.kind, 'task_status');
1176
+ assert.equal(notification?.event, 'notification');
1177
+ assert.equal(notification?.status, 'completed');
1178
+ assert.equal(notification?.summary, 'Dynamic workflow "Evidence-based audit of the frontend" completed');
1179
+ assert.equal(notification?.outputFile, `/tmp/claude-1000/tasks/${WORKFLOW_TASK_ID}.output`);
1180
+
1181
+ // Every other `system` subtype still normalizes to nothing.
1182
+ assert.deepEqual(normalize({ subtype: 'hook_started', task_id: 'irrelevant' }), []);
1183
+ assert.deepEqual(normalize({ subtype: 'init' }), []);
1184
+ });
1185
+
1186
+ test('a workflow\'s progress event reports where each of its agents stands', () => {
1187
+ // Verified against a real query (SDK 0.3.165 / CLI 2.1.274): a workflow's
1188
+ // `task_progress` carries an undocumented `workflow_progress` with one entry
1189
+ // per agent the script spawned, and its task-level `last_tool_name` is the
1190
+ // current agent's label, not a tool — on every batch, with or without the
1191
+ // agent list. Read as a tool, the card's usage line said
1192
+ // "32 tool uses · 3m 30s · You are the analysis step of…".
1193
+ const provider = new ClaudeSessionsProvider();
1194
+ const [progress] = provider.normalizeMessage({
1195
+ type: 'system',
1196
+ subtype: 'task_progress',
1197
+ session_id: WORKFLOW_SESSION_ID,
1198
+ uuid: 'evt-workflow-progress',
1199
+ task_id: WORKFLOW_TASK_ID,
1200
+ tool_use_id: WORKFLOW_TOOL_USE_ID,
1201
+ description: 'audit:sidebar',
1202
+ summary: 'Evidence-based audit of the frontend',
1203
+ usage: { total_tokens: 12_345, tool_uses: 32, duration_ms: 210_000 },
1204
+ last_tool_name: 'audit:sidebar',
1205
+ workflow_progress: [
1206
+ {
1207
+ type: 'workflow_agent',
1208
+ index: 0,
1209
+ label: 'audit:chat',
1210
+ phaseIndex: 0,
1211
+ phaseTitle: 'Audit',
1212
+ agentId: 'aa1e064cf8bd159d6',
1213
+ model: 'claude-opus-4-1',
1214
+ state: 'done',
1215
+ startedAt: 1_700_000_000_000,
1216
+ tokens: 8_000,
1217
+ toolCalls: 20,
1218
+ durationMs: 120_000,
1219
+ resultPreview: 'Three large hooks carry most of the module.',
1220
+ },
1221
+ {
1222
+ type: 'workflow_agent',
1223
+ index: 1,
1224
+ label: 'audit:sidebar',
1225
+ phaseIndex: 0,
1226
+ phaseTitle: 'Audit',
1227
+ agentId: 'a9cfe29aa8f2afcbf',
1228
+ state: 'progress',
1229
+ startedAt: 1_700_000_120_000,
1230
+ lastToolName: 'Grep',
1231
+ lastToolSummary: 'useSidebar',
1232
+ promptPreview: 'You are the analysis step of a code-quality audit',
1233
+ lastProgressAt: 1_700_000_200_000,
1234
+ tokens: 4_000,
1235
+ toolCalls: 12,
1236
+ },
1237
+ // Started but not yet spawned: no transcript, so no id.
1238
+ { type: 'workflow_agent', index: 2, label: 'synthesize', state: 'start', queuedAt: 1_700_000_200_000 },
1239
+ { type: 'workflow_agent', index: 3, label: 'audit:files', agentId: 'ab89f2cde612a51b1', state: 'start', startedAt: 1_700_000_201_000 },
1240
+ { type: 'workflow_agent', index: 4, label: 'audit:terminal', agentId: 'ac89f2cde612a51b2', state: 'error', attempt: 2 },
1241
+ ],
1242
+ }, WORKFLOW_SESSION_ID);
1243
+
1244
+ assert.equal(progress?.kind, 'task_status');
1245
+ assert.equal(progress?.event, 'progress');
1246
+ assert.deepEqual(progress?.usage, { totalTokens: 12_345, toolUses: 32, durationMs: 210_000 });
1247
+ assert.equal('lastToolName' in (progress ?? {}), false, 'the current agent\'s label must not pass as a tool name');
1248
+ assert.deepEqual(progress?.agents, [
1249
+ {
1250
+ index: 0,
1251
+ label: 'audit:chat',
1252
+ phase: 'Audit',
1253
+ agentId: 'aa1e064cf8bd159d6',
1254
+ model: 'claude-opus-4-1',
1255
+ state: 'done',
1256
+ startedAt: 1_700_000_000_000,
1257
+ lastToolName: undefined,
1258
+ lastToolSummary: undefined,
1259
+ promptPreview: undefined,
1260
+ tokens: 8_000,
1261
+ toolCalls: 20,
1262
+ durationMs: 120_000,
1263
+ resultPreview: 'Three large hooks carry most of the module.',
1264
+ },
1265
+ {
1266
+ index: 1,
1267
+ label: 'audit:sidebar',
1268
+ phase: 'Audit',
1269
+ agentId: 'a9cfe29aa8f2afcbf',
1270
+ model: undefined,
1271
+ state: 'running',
1272
+ startedAt: 1_700_000_120_000,
1273
+ lastToolName: 'Grep',
1274
+ lastToolSummary: 'useSidebar',
1275
+ promptPreview: 'You are the analysis step of a code-quality audit',
1276
+ tokens: 4_000,
1277
+ toolCalls: 12,
1278
+ durationMs: undefined,
1279
+ resultPreview: undefined,
1280
+ },
1281
+ {
1282
+ index: 2,
1283
+ label: 'synthesize',
1284
+ phase: undefined,
1285
+ agentId: undefined,
1286
+ model: undefined,
1287
+ state: 'queued',
1288
+ startedAt: undefined,
1289
+ lastToolName: undefined,
1290
+ lastToolSummary: undefined,
1291
+ promptPreview: undefined,
1292
+ tokens: undefined,
1293
+ toolCalls: undefined,
1294
+ durationMs: undefined,
1295
+ resultPreview: undefined,
1296
+ },
1297
+ {
1298
+ index: 3,
1299
+ label: 'audit:files',
1300
+ phase: undefined,
1301
+ agentId: 'ab89f2cde612a51b1',
1302
+ model: undefined,
1303
+ state: 'running',
1304
+ startedAt: 1_700_000_201_000,
1305
+ lastToolName: undefined,
1306
+ lastToolSummary: undefined,
1307
+ promptPreview: undefined,
1308
+ tokens: undefined,
1309
+ toolCalls: undefined,
1310
+ durationMs: undefined,
1311
+ resultPreview: undefined,
1312
+ },
1313
+ {
1314
+ index: 4,
1315
+ label: 'audit:terminal',
1316
+ phase: undefined,
1317
+ agentId: 'ac89f2cde612a51b2',
1318
+ model: undefined,
1319
+ state: 'failed',
1320
+ startedAt: undefined,
1321
+ lastToolName: undefined,
1322
+ lastToolSummary: undefined,
1323
+ promptPreview: undefined,
1324
+ tokens: undefined,
1325
+ toolCalls: undefined,
1326
+ durationMs: undefined,
1327
+ resultPreview: undefined,
1328
+ },
1329
+ ]);
1330
+
1331
+ // A throttled batch — agents mid-step only — comes without the agent list
1332
+ // but still names the current agent as the tool.
1333
+ const [throttled] = provider.normalizeMessage({
1334
+ type: 'system',
1335
+ subtype: 'task_progress',
1336
+ session_id: WORKFLOW_SESSION_ID,
1337
+ uuid: 'evt-workflow-progress-throttled',
1338
+ task_id: WORKFLOW_TASK_ID,
1339
+ tool_use_id: WORKFLOW_TOOL_USE_ID,
1340
+ description: 'Audit: audit:sidebar',
1341
+ usage: { total_tokens: 12_400, tool_uses: 33, duration_ms: 212_000 },
1342
+ last_tool_name: 'audit:sidebar',
1343
+ }, WORKFLOW_SESSION_ID);
1344
+ assert.equal('lastToolName' in (throttled ?? {}), false);
1345
+ assert.equal(throttled?.agents, undefined);
1346
+
1347
+ // An agent's own progress carries no agent list.
1348
+ const [agentProgress] = provider.normalizeMessage({
1349
+ type: 'system',
1350
+ subtype: 'task_progress',
1351
+ session_id: WORKFLOW_SESSION_ID,
1352
+ uuid: 'evt-agent-progress',
1353
+ task_id: 'agent-task-1',
1354
+ tool_use_id: 'toolu_agent_1',
1355
+ description: 'Survey the repo',
1356
+ usage: { total_tokens: 1, tool_uses: 3, duration_ms: 1 },
1357
+ last_tool_name: 'Read',
1358
+ }, WORKFLOW_SESSION_ID);
1359
+ assert.equal(agentProgress?.agents, undefined);
1360
+ });
1361
+
1362
+ test('Claude history still settles a synchronous agent that stops mid tool call', { concurrency: false }, async () => {
1363
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-agent-'));
1364
+
1365
+ try {
1366
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
1367
+ await dropTaskNotification(parentPath);
1368
+
1369
+ // A synchronous agent hands its answer back on its own tool result and
1370
+ // never sends a notification, so "no notification" must not read as
1371
+ // "still running" for one — not even with a dangling tool call, the
1372
+ // strongest possible hint that it stopped mid-flight.
1373
+ const parentRaw = await readFile(parentPath, 'utf8');
1374
+ await writeFile(parentPath, parentRaw.replace('"isAsync":true', '"isAsync":false'), 'utf8');
257
1375
 
258
- // Drop the child tool result: the agent is mid-call, which is the only
259
- // in-file evidence that it is still working.
260
1376
  const agentPath = path.join(tempRoot, SESSION_ID, 'subagents', `agent-${AGENT_ID}.jsonl`);
261
1377
  const agentRaw = await readFile(agentPath, 'utf8');
262
1378
  await writeFile(
@@ -276,7 +1392,7 @@ test('Claude history keeps an agent running when its transcript stops mid tool c
276
1392
  (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
277
1393
  );
278
1394
 
279
- assert.equal(agentRow?.subagent?.status, 'running');
1395
+ assert.equal(agentRow?.subagent?.status, 'completed');
280
1396
  });
281
1397
  } finally {
282
1398
  await rm(tempRoot, { recursive: true, force: true });
@@ -403,6 +1519,137 @@ test('an edited prompt replaces the one it superseded instead of stacking on it'
403
1519
  }
404
1520
  });
405
1521
 
1522
+ const CONCURRENT_SESSION_ID = 'claude-concurrent-session';
1523
+
1524
+ /**
1525
+ * Rows two live writers leave behind: Chat and the native `claude` CLI both
1526
+ * branch off the assistant row each of them saw last. `last-prompt` is the row
1527
+ * every CLI process appends when it exits, naming the leaf it was on.
1528
+ */
1529
+ const concurrentRow = {
1530
+ sharedPrompt: {
1531
+ type: 'user', uuid: 'u1', parentUuid: null, sessionId: CONCURRENT_SESSION_ID,
1532
+ timestamp: '2026-08-23T10:00:00.000Z',
1533
+ message: { role: 'user', content: [{ type: 'text', text: 'shared prompt' }] },
1534
+ },
1535
+ sharedAnswer: {
1536
+ type: 'assistant', uuid: 'a1', parentUuid: 'u1', sessionId: CONCURRENT_SESSION_ID,
1537
+ timestamp: '2026-08-23T10:00:01.000Z',
1538
+ message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'shared answer' }] },
1539
+ },
1540
+ chatPrompt: {
1541
+ type: 'user', uuid: 'u2', parentUuid: 'a1', sessionId: CONCURRENT_SESSION_ID,
1542
+ timestamp: '2026-08-23T10:00:02.000Z',
1543
+ message: { role: 'user', content: [{ type: 'text', text: 'chat prompt' }] },
1544
+ },
1545
+ chatAnswer: {
1546
+ type: 'assistant', uuid: 'a2', parentUuid: 'u2', sessionId: CONCURRENT_SESSION_ID,
1547
+ timestamp: '2026-08-23T10:00:03.000Z',
1548
+ message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'chat answer' }] },
1549
+ },
1550
+ // The native CLI had loaded the session before `u2` existed, so its prompt
1551
+ // parents onto `a1` too.
1552
+ terminalPrompt: {
1553
+ type: 'user', uuid: 't1', parentUuid: 'a1', sessionId: CONCURRENT_SESSION_ID,
1554
+ timestamp: '2026-08-23T10:00:04.000Z',
1555
+ message: { role: 'user', content: [{ type: 'text', text: 'terminal prompt' }] },
1556
+ },
1557
+ terminalAnswer: {
1558
+ type: 'assistant', uuid: 't2', parentUuid: 't1', sessionId: CONCURRENT_SESSION_ID,
1559
+ timestamp: '2026-08-23T10:00:05.000Z',
1560
+ message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'terminal answer' }] },
1561
+ },
1562
+ chatFollowUp: {
1563
+ type: 'user', uuid: 'u3', parentUuid: 'a2', sessionId: CONCURRENT_SESSION_ID,
1564
+ timestamp: '2026-08-23T10:00:06.000Z',
1565
+ message: { role: 'user', content: [{ type: 'text', text: 'chat follow-up' }] },
1566
+ },
1567
+ chatFollowUpAnswer: {
1568
+ type: 'assistant', uuid: 'a3', parentUuid: 'u3', sessionId: CONCURRENT_SESSION_ID,
1569
+ timestamp: '2026-08-23T10:00:07.000Z',
1570
+ message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'chat follow-up answer' }] },
1571
+ },
1572
+ };
1573
+
1574
+ const exitStamp = (leafUuid: string) => ({
1575
+ type: 'last-prompt', lastPrompt: 'redacted', leafUuid, sessionId: CONCURRENT_SESSION_ID,
1576
+ });
1577
+
1578
+ async function readConcurrentlyForkedHistory(rows: object[]): Promise<unknown[]> {
1579
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-concurrent-history-'));
1580
+
1581
+ try {
1582
+ const transcriptPath = path.join(tempRoot, `${CONCURRENT_SESSION_ID}.jsonl`);
1583
+ await writeFile(transcriptPath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
1584
+
1585
+ let texts: unknown[] = [];
1586
+ await withIsolatedDatabase(async () => {
1587
+ const now = new Date().toISOString();
1588
+ sessionsDb.createSession(
1589
+ CONCURRENT_SESSION_ID, 'claude', tempRoot, 'Concurrently forked session', now, now, transcriptPath,
1590
+ );
1591
+
1592
+ const history = await new ClaudeSessionsProvider().fetchHistory(CONCURRENT_SESSION_ID, {
1593
+ providerSessionId: CONCURRENT_SESSION_ID,
1594
+ });
1595
+ texts = history.messages.map((message) => message.content);
1596
+ });
1597
+ return texts;
1598
+ } finally {
1599
+ await rm(tempRoot, { recursive: true, force: true });
1600
+ }
1601
+ }
1602
+
1603
+ const SHARED_TURN = ['shared prompt', 'shared answer'];
1604
+ const TERMINAL_TURN = ['terminal prompt', 'terminal answer'];
1605
+
1606
+ test('a fork with no exit stamp shows the branch ending at the newest row', { concurrency: false }, async () => {
1607
+ const r = concurrentRow;
1608
+ // With no `last-prompt` in the file, `--resume` falls back to the newest row:
1609
+ // Chat forked first but kept the session going.
1610
+ const texts = await readConcurrentlyForkedHistory([
1611
+ r.sharedPrompt, r.sharedAnswer, r.chatPrompt, r.chatAnswer,
1612
+ r.terminalPrompt, r.terminalAnswer, r.chatFollowUp, r.chatFollowUpAnswer,
1613
+ ]);
1614
+
1615
+ assert.deepEqual(texts, [...SHARED_TURN, 'chat prompt', 'chat answer', 'chat follow-up', 'chat follow-up answer']);
1616
+ });
1617
+
1618
+ test('a fork shows the branch Chat stamped when Chat exited last', { concurrency: false }, async () => {
1619
+ const r = concurrentRow;
1620
+ const texts = await readConcurrentlyForkedHistory([
1621
+ r.sharedPrompt, r.sharedAnswer, r.chatPrompt, r.chatAnswer, exitStamp('a2'),
1622
+ r.terminalPrompt, r.terminalAnswer, r.chatFollowUp, r.chatFollowUpAnswer, exitStamp('a3'),
1623
+ ]);
1624
+
1625
+ assert.deepEqual(texts, [...SHARED_TURN, 'chat prompt', 'chat answer', 'chat follow-up', 'chat follow-up answer']);
1626
+ });
1627
+
1628
+ test('a fork shows the terminal branch once the terminal exits after Chat', { concurrency: false }, async () => {
1629
+ const r = concurrentRow;
1630
+ // Chat wrote the newest rows, but closing the terminal stamps its own leaf
1631
+ // last, and that stamp is what the next resume continues from.
1632
+ const texts = await readConcurrentlyForkedHistory([
1633
+ r.sharedPrompt, r.sharedAnswer, r.chatPrompt, r.chatAnswer, exitStamp('a2'),
1634
+ r.terminalPrompt, r.terminalAnswer, r.chatFollowUp, r.chatFollowUpAnswer, exitStamp('a3'),
1635
+ exitStamp('t2'),
1636
+ ]);
1637
+
1638
+ assert.deepEqual(texts, [...SHARED_TURN, ...TERMINAL_TURN]);
1639
+ });
1640
+
1641
+ test('a fork keeps the stamped branch while the other writer is still open', { concurrency: false }, async () => {
1642
+ const r = concurrentRow;
1643
+ // Chat exited after its turn; the terminal then typed a prompt and is still
1644
+ // open. Its rows are the newest, but they do not descend from Chat's stamp.
1645
+ const texts = await readConcurrentlyForkedHistory([
1646
+ r.sharedPrompt, r.sharedAnswer, r.chatPrompt, r.chatAnswer, exitStamp('a2'),
1647
+ r.terminalPrompt, r.terminalAnswer,
1648
+ ]);
1649
+
1650
+ assert.deepEqual(texts, [...SHARED_TURN, 'chat prompt', 'chat answer']);
1651
+ });
1652
+
406
1653
  test('parallel tool calls are not mistaken for an edit', { concurrency: false }, async () => {
407
1654
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-parallel-tools-'));
408
1655
  const sessionId = 'claude-parallel-session';
@@ -571,3 +1818,374 @@ test('resolving an edit anchor skips rows that are not conversation turns', { co
571
1818
  await rm(tempRoot, { recursive: true, force: true });
572
1819
  }
573
1820
  });
1821
+
1822
+ // ---------------------------------------------------------------------------
1823
+ // buildLookupMap
1824
+ // ---------------------------------------------------------------------------
1825
+
1826
+ test('buildLookupMap returns first-seen value when key appears multiple times', async () => {
1827
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-lookup-'));
1828
+ const filePath = path.join(tmp, 'history.jsonl');
1829
+ try {
1830
+ await writeFile(
1831
+ filePath,
1832
+ [
1833
+ JSON.stringify({ sessionId: 's1', display: 'first-message' }),
1834
+ JSON.stringify({ sessionId: 's1', display: 'second-message' }),
1835
+ JSON.stringify({ sessionId: 's2', display: 'only-message' }),
1836
+ ].join('\n'),
1837
+ 'utf8',
1838
+ );
1839
+
1840
+ const map = await buildLookupMap(filePath, 'sessionId', 'display');
1841
+
1842
+ assert.equal(map.size, 2);
1843
+ assert.equal(map.get('s1'), 'first-message');
1844
+ assert.equal(map.get('s2'), 'only-message');
1845
+ } finally {
1846
+ await rm(tmp, { recursive: true, force: true });
1847
+ }
1848
+ });
1849
+
1850
+ test('buildLookupMap returns empty map for missing file', async () => {
1851
+ const map = await buildLookupMap(path.join(os.tmpdir(), 'does-not-exist.jsonl'), 'k', 'v');
1852
+ assert.equal(map.size, 0);
1853
+ });
1854
+
1855
+ test('buildLookupMap returns empty map for empty file', async () => {
1856
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-lookup-'));
1857
+ const filePath = path.join(tmp, 'empty.jsonl');
1858
+ try {
1859
+ await writeFile(filePath, '', 'utf8');
1860
+ const map = await buildLookupMap(filePath, 'k', 'v');
1861
+ assert.equal(map.size, 0);
1862
+ } finally {
1863
+ await rm(tmp, { recursive: true, force: true });
1864
+ }
1865
+ });
1866
+
1867
+ test('buildLookupMap skips rows with non-string key or value', async () => {
1868
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-lookup-'));
1869
+ const filePath = path.join(tmp, 'history.jsonl');
1870
+ try {
1871
+ await writeFile(
1872
+ filePath,
1873
+ [
1874
+ JSON.stringify({ sessionId: 123, display: 'not-a-string-key' }),
1875
+ JSON.stringify({ sessionId: 's1', display: 456 }),
1876
+ JSON.stringify({ sessionId: 's1', display: 'valid-entry' }),
1877
+ ].join('\n'),
1878
+ 'utf8',
1879
+ );
1880
+
1881
+ const map = await buildLookupMap(filePath, 'sessionId', 'display');
1882
+ assert.equal(map.size, 1);
1883
+ assert.equal(map.get('s1'), 'valid-entry');
1884
+ } finally {
1885
+ await rm(tmp, { recursive: true, force: true });
1886
+ }
1887
+ });
1888
+
1889
+ // ---------------------------------------------------------------------------
1890
+ // extractSessionTitle — tested via synchronizeFile
1891
+ // ---------------------------------------------------------------------------
1892
+
1893
+ test('synchronizeFile uses ai-title from JSONL when no DB custom_name exists', { concurrency: false }, async () => {
1894
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-aititle-'));
1895
+ const workspacePath = path.join(tmp, 'workspace');
1896
+ await mkdir(workspacePath, { recursive: true });
1897
+ const restoreHomeDir = patchHomeDir(tmp);
1898
+
1899
+ try {
1900
+ // Create ~/.claude/history.jsonl with a competing display name.
1901
+ const claudeHome = path.join(tmp, '.claude');
1902
+ await mkdir(claudeHome, { recursive: true });
1903
+ await writeFile(
1904
+ path.join(claudeHome, 'history.jsonl'),
1905
+ JSON.stringify({ sessionId: 'test-session-1', display: 'user-first-prompt-from-history' }) + '\n',
1906
+ 'utf8',
1907
+ );
1908
+
1909
+ // Write session JSONL with ai-title before last-prompt.
1910
+ await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
1911
+ JSON.stringify({ type: 'ai-title', aiTitle: 'AI generated title', sessionId: 'test-session-1' }),
1912
+ JSON.stringify({
1913
+ parentUuid: 'msg-1',
1914
+ isSidechain: false,
1915
+ message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }] },
1916
+ type: 'assistant',
1917
+ uuid: 'msg-2',
1918
+ }),
1919
+ JSON.stringify({ type: 'last-prompt', lastPrompt: 'first prompt', sessionId: 'test-session-1' }),
1920
+ ]);
1921
+
1922
+ await withIsolatedDatabase(async () => {
1923
+ const synchronizer = new ClaudeSessionSynchronizer();
1924
+ const result = await synchronizer.synchronizeFile(
1925
+ path.join(workspacePath, 'test-session-1.jsonl'),
1926
+ );
1927
+
1928
+ assert.ok(result, 'synchronizeFile should return a session id');
1929
+ const session = sessionsDb.getSessionById(result!);
1930
+ assert.equal(session?.custom_name, 'AI generated title');
1931
+ });
1932
+ } finally {
1933
+ restoreHomeDir();
1934
+ await rm(tmp, { recursive: true, force: true });
1935
+ }
1936
+ });
1937
+
1938
+ test('synchronizeFile uses custom-title from JSONL when no DB custom_name and no ai-title', { concurrency: false }, async () => {
1939
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-customtitle-'));
1940
+ const workspacePath = path.join(tmp, 'workspace');
1941
+ await mkdir(workspacePath, { recursive: true });
1942
+ const restoreHomeDir = patchHomeDir(tmp);
1943
+
1944
+ try {
1945
+ const claudeHome = path.join(tmp, '.claude');
1946
+ await mkdir(claudeHome, { recursive: true });
1947
+ await writeFile(path.join(claudeHome, 'history.jsonl'), '', 'utf8');
1948
+
1949
+ await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
1950
+ JSON.stringify({
1951
+ parentUuid: 'msg-1',
1952
+ isSidechain: false,
1953
+ message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
1954
+ type: 'assistant',
1955
+ uuid: 'msg-2',
1956
+ }),
1957
+ JSON.stringify({ type: 'custom-title', customTitle: 'Renamed via cli', sessionId: 'test-session-1' }),
1958
+ JSON.stringify({ type: 'last-prompt', lastPrompt: 'first prompt', sessionId: 'test-session-1' }),
1959
+ ]);
1960
+
1961
+ await withIsolatedDatabase(async () => {
1962
+ const synchronizer = new ClaudeSessionSynchronizer();
1963
+ const result = await synchronizer.synchronizeFile(
1964
+ path.join(workspacePath, 'test-session-1.jsonl'),
1965
+ );
1966
+
1967
+ assert.ok(result);
1968
+ const session = sessionsDb.getSessionById(result!);
1969
+ assert.equal(session?.custom_name, 'Renamed via cli');
1970
+ });
1971
+ } finally {
1972
+ restoreHomeDir();
1973
+ await rm(tmp, { recursive: true, force: true });
1974
+ }
1975
+ });
1976
+
1977
+ test('synchronizeFile falls back to history.jsonl display when JSONL has no title events', { concurrency: false }, async () => {
1978
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-fallback-'));
1979
+ const workspacePath = path.join(tmp, 'workspace');
1980
+ await mkdir(workspacePath, { recursive: true });
1981
+ const restoreHomeDir = patchHomeDir(tmp);
1982
+
1983
+ try {
1984
+ const claudeHome = path.join(tmp, '.claude');
1985
+ await mkdir(claudeHome, { recursive: true });
1986
+ await writeFile(
1987
+ path.join(claudeHome, 'history.jsonl'),
1988
+ JSON.stringify({ sessionId: 'test-session-1', display: 'fallback display name' }) + '\n',
1989
+ 'utf8',
1990
+ );
1991
+
1992
+ // Session JSONL with NO ai-title, custom-title, or last-prompt.
1993
+ await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
1994
+ JSON.stringify({
1995
+ parentUuid: 'msg-1',
1996
+ isSidechain: false,
1997
+ message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
1998
+ type: 'assistant',
1999
+ uuid: 'msg-2',
2000
+ }),
2001
+ ]);
2002
+
2003
+ await withIsolatedDatabase(async () => {
2004
+ const synchronizer = new ClaudeSessionSynchronizer();
2005
+ const result = await synchronizer.synchronizeFile(
2006
+ path.join(workspacePath, 'test-session-1.jsonl'),
2007
+ );
2008
+
2009
+ assert.ok(result);
2010
+ const session = sessionsDb.getSessionById(result!);
2011
+ assert.equal(session?.custom_name, 'fallback display name');
2012
+ });
2013
+ } finally {
2014
+ restoreHomeDir();
2015
+ await rm(tmp, { recursive: true, force: true });
2016
+ }
2017
+ });
2018
+
2019
+ test('synchronizeFile falls back to Untitled Claude Session when all sources are empty', { concurrency: false }, async () => {
2020
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-untitled-'));
2021
+ const workspacePath = path.join(tmp, 'workspace');
2022
+ await mkdir(workspacePath, { recursive: true });
2023
+ const restoreHomeDir = patchHomeDir(tmp);
2024
+
2025
+ try {
2026
+ const claudeHome = path.join(tmp, '.claude');
2027
+ await mkdir(claudeHome, { recursive: true });
2028
+ await writeFile(path.join(claudeHome, 'history.jsonl'), '', 'utf8');
2029
+
2030
+ // Session JSONL with NO title events at all.
2031
+ await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
2032
+ JSON.stringify({
2033
+ parentUuid: 'msg-1',
2034
+ isSidechain: false,
2035
+ message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
2036
+ type: 'assistant',
2037
+ uuid: 'msg-2',
2038
+ }),
2039
+ ]);
2040
+
2041
+ await withIsolatedDatabase(async () => {
2042
+ const synchronizer = new ClaudeSessionSynchronizer();
2043
+ const result = await synchronizer.synchronizeFile(
2044
+ path.join(workspacePath, 'test-session-1.jsonl'),
2045
+ );
2046
+
2047
+ assert.ok(result);
2048
+ const session = sessionsDb.getSessionById(result!);
2049
+ assert.equal(session?.custom_name, 'Untitled Claude Session');
2050
+ });
2051
+ } finally {
2052
+ restoreHomeDir();
2053
+ await rm(tmp, { recursive: true, force: true });
2054
+ }
2055
+ });
2056
+
2057
+ // ---------------------------------------------------------------------------
2058
+ // Priority: DB custom_name > JSONL title > history.jsonl
2059
+ // ---------------------------------------------------------------------------
2060
+
2061
+ test('synchronizeFile preserves existing DB custom_name regardless of JSONL and history.jsonl', { concurrency: false }, async () => {
2062
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-dbwins-'));
2063
+ const workspacePath = path.join(tmp, 'workspace');
2064
+ await mkdir(workspacePath, { recursive: true });
2065
+ const restoreHomeDir = patchHomeDir(tmp);
2066
+
2067
+ try {
2068
+ const claudeHome = path.join(tmp, '.claude');
2069
+ await mkdir(claudeHome, { recursive: true });
2070
+ await writeFile(
2071
+ path.join(claudeHome, 'history.jsonl'),
2072
+ JSON.stringify({ sessionId: 'test-session-1', display: 'history-display-name' }) + '\n',
2073
+ 'utf8',
2074
+ );
2075
+
2076
+ // Write session JSONL with competing ai-title.
2077
+ await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
2078
+ JSON.stringify({ type: 'ai-title', aiTitle: 'JSONL ai title', sessionId: 'test-session-1' }),
2079
+ JSON.stringify({ type: 'last-prompt', lastPrompt: 'first prompt', sessionId: 'test-session-1' }),
2080
+ ]);
2081
+
2082
+ await withIsolatedDatabase(async () => {
2083
+ // Pre-seed the DB with a custom_name set via CloudCLI sidebar rename.
2084
+ sessionsDb.createSession(
2085
+ 'test-session-1',
2086
+ 'claude',
2087
+ workspacePath,
2088
+ 'Sidebar custom name',
2089
+ );
2090
+
2091
+ const synchronizer = new ClaudeSessionSynchronizer();
2092
+ const result = await synchronizer.synchronizeFile(
2093
+ path.join(workspacePath, 'test-session-1.jsonl'),
2094
+ );
2095
+
2096
+ assert.ok(result);
2097
+ const session = sessionsDb.getSessionById(result!);
2098
+ // DB custom_name must win over JSONL ai-title AND history.jsonl display.
2099
+ assert.equal(session?.custom_name, 'Sidebar custom name');
2100
+ });
2101
+ } finally {
2102
+ restoreHomeDir();
2103
+ await rm(tmp, { recursive: true, force: true });
2104
+ }
2105
+ });
2106
+
2107
+ test('synchronizeFile does NOT treat "Untitled Claude Session" in DB as a real custom_name', { concurrency: false }, async () => {
2108
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-untitled-db-'));
2109
+ const workspacePath = path.join(tmp, 'workspace');
2110
+ await mkdir(workspacePath, { recursive: true });
2111
+ const restoreHomeDir = patchHomeDir(tmp);
2112
+
2113
+ try {
2114
+ const claudeHome = path.join(tmp, '.claude');
2115
+ await mkdir(claudeHome, { recursive: true });
2116
+ await writeFile(path.join(claudeHome, 'history.jsonl'), '', 'utf8');
2117
+
2118
+ // Session JSONL with an ai-title that should win over the DB default.
2119
+ await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
2120
+ JSON.stringify({ type: 'ai-title', aiTitle: 'Real AI title from JSONL', sessionId: 'test-session-1' }),
2121
+ JSON.stringify({ type: 'last-prompt', lastPrompt: 'first prompt', sessionId: 'test-session-1' }),
2122
+ ]);
2123
+
2124
+ await withIsolatedDatabase(async () => {
2125
+ // Seed with the default fallback name — should be ignored.
2126
+ sessionsDb.createSession(
2127
+ 'test-session-1',
2128
+ 'claude',
2129
+ workspacePath,
2130
+ 'Untitled Claude Session',
2131
+ );
2132
+
2133
+ const synchronizer = new ClaudeSessionSynchronizer();
2134
+ const result = await synchronizer.synchronizeFile(
2135
+ path.join(workspacePath, 'test-session-1.jsonl'),
2136
+ );
2137
+
2138
+ assert.ok(result);
2139
+ const session = sessionsDb.getSessionById(result!);
2140
+ assert.equal(session?.custom_name, 'Real AI title from JSONL');
2141
+ });
2142
+ } finally {
2143
+ restoreHomeDir();
2144
+ await rm(tmp, { recursive: true, force: true });
2145
+ }
2146
+ });
2147
+
2148
+ // ---------------------------------------------------------------------------
2149
+ // Edge cases
2150
+ // ---------------------------------------------------------------------------
2151
+
2152
+ test('synchronizeFile skips subagent transcripts', { concurrency: false }, async () => {
2153
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-subagent-'));
2154
+ const workspacePath = path.join(tmp, 'workspace');
2155
+ await mkdir(workspacePath, { recursive: true });
2156
+ const restoreHomeDir = patchHomeDir(tmp);
2157
+
2158
+ try {
2159
+ const claudeHome = path.join(tmp, '.claude');
2160
+ await mkdir(claudeHome, { recursive: true });
2161
+ await writeFile(path.join(claudeHome, 'history.jsonl'), '', 'utf8');
2162
+
2163
+ // Create a file whose path contains "subagents".
2164
+ const subagentsDir = path.join(workspacePath, 'test-session-1', 'subagents');
2165
+ await mkdir(subagentsDir, { recursive: true });
2166
+ await writeSessionJsonl(subagentsDir, 'agent-1.jsonl', [
2167
+ JSON.stringify({ type: 'ai-title', aiTitle: 'Subagent title', sessionId: 'test-session-1' }),
2168
+ ]);
2169
+
2170
+ await withIsolatedDatabase(async () => {
2171
+ const synchronizer = new ClaudeSessionSynchronizer();
2172
+ const result = await synchronizer.synchronizeFile(
2173
+ path.join(subagentsDir, 'agent-1.jsonl'),
2174
+ );
2175
+
2176
+ // Subagent transcripts should be silently skipped (return null).
2177
+ assert.equal(result, null);
2178
+ });
2179
+ } finally {
2180
+ restoreHomeDir();
2181
+ await rm(tmp, { recursive: true, force: true });
2182
+ }
2183
+ });
2184
+
2185
+ test('synchronizeFile skips non-jsonl files', { concurrency: false }, async () => {
2186
+ await withIsolatedDatabase(async () => {
2187
+ const synchronizer = new ClaudeSessionSynchronizer();
2188
+ const result = await synchronizer.synchronizeFile('/tmp/not-a-jsonl.txt');
2189
+ assert.equal(result, null);
2190
+ });
2191
+ });