vibepulse 0.2.2 → 0.3.0-beta.0

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 (253) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +1 -0
  3. package/.next/build-manifest.json +2 -2
  4. package/.next/cache/.previewinfo +1 -1
  5. package/.next/cache/.rscinfo +1 -1
  6. package/.next/cache/.tsbuildinfo +1 -1
  7. package/.next/cache/config.json +3 -3
  8. package/.next/fallback-build-manifest.json +2 -2
  9. package/.next/prerender-manifest.json +3 -3
  10. package/.next/routes-manifest.json +8 -0
  11. package/.next/server/app/_global-error/page.js +1 -1
  12. package/.next/server/app/_global-error/page.js.nft.json +1 -1
  13. package/.next/server/app/_global-error.html +2 -2
  14. package/.next/server/app/_global-error.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  16. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  17. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  18. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  19. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  20. package/.next/server/app/_not-found/page.js +1 -1
  21. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  22. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  23. package/.next/server/app/_not-found.html +1 -1
  24. package/.next/server/app/_not-found.rsc +2 -2
  25. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  26. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  27. package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  28. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  29. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  30. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  31. package/.next/server/app/api/node/sessions/[id]/open-editor/route.js.nft.json +1 -1
  32. package/.next/server/app/api/node/sessions/route.js +5 -3
  33. package/.next/server/app/api/node/sessions/route.js.nft.json +1 -1
  34. package/.next/server/app/api/opencode-config/route.js.nft.json +1 -1
  35. package/.next/server/app/api/opencode-config/status/route.js.nft.json +1 -1
  36. package/.next/server/app/api/profiles/[id]/apply/route.js.nft.json +1 -1
  37. package/.next/server/app/api/profiles/[id]/export/route.js.nft.json +1 -1
  38. package/.next/server/app/api/profiles/[id]/route.js.nft.json +1 -1
  39. package/.next/server/app/api/profiles/import/route.js.nft.json +1 -1
  40. package/.next/server/app/api/profiles/route.js.nft.json +1 -1
  41. package/.next/server/app/api/sessions/[id]/archive/route.js +3 -2
  42. package/.next/server/app/api/sessions/[id]/archive/route.js.nft.json +1 -1
  43. package/.next/server/app/api/sessions/[id]/delete/route.js +3 -2
  44. package/.next/server/app/api/sessions/[id]/delete/route.js.nft.json +1 -1
  45. package/.next/server/app/api/sessions/[id]/open-editor/route.js +1 -1
  46. package/.next/server/app/api/sessions/[id]/open-editor/route.js.nft.json +1 -1
  47. package/.next/server/app/api/sessions/[id]/restore/route/app-paths-manifest.json +3 -0
  48. package/.next/server/app/api/sessions/[id]/restore/route/build-manifest.json +11 -0
  49. package/.next/server/app/api/sessions/[id]/restore/route/server-reference-manifest.json +4 -0
  50. package/.next/server/app/api/sessions/[id]/restore/route.js +8 -0
  51. package/.next/server/app/api/sessions/[id]/restore/route.js.map +5 -0
  52. package/.next/server/app/api/sessions/[id]/restore/route.js.nft.json +1 -0
  53. package/.next/server/app/api/sessions/[id]/restore/route_client-reference-manifest.js +2 -0
  54. package/.next/server/app/api/sessions/route.js +4 -2
  55. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  56. package/.next/server/app/index.html +1 -1
  57. package/.next/server/app/index.rsc +3 -3
  58. package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  59. package/.next/server/app/index.segments/_full.segment.rsc +3 -3
  60. package/.next/server/app/index.segments/_head.segment.rsc +1 -1
  61. package/.next/server/app/index.segments/_index.segment.rsc +2 -2
  62. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  63. package/.next/server/app/page_client-reference-manifest.js +1 -1
  64. package/.next/server/app-paths-manifest.json +1 -0
  65. package/.next/server/chunks/[root-of-the-server]__31d19c5c._.js +3 -0
  66. package/.next/server/chunks/[root-of-the-server]__31d19c5c._.js.map +1 -0
  67. package/.next/server/chunks/[root-of-the-server]__56f5f249._.js +1 -1
  68. package/.next/server/chunks/[root-of-the-server]__56f5f249._.js.map +1 -1
  69. package/.next/server/chunks/[root-of-the-server]__5e0a0e38._.js +3 -0
  70. package/.next/server/chunks/[root-of-the-server]__5e0a0e38._.js.map +1 -0
  71. package/.next/server/chunks/[root-of-the-server]__98073dd6._.js +3 -0
  72. package/.next/server/chunks/[root-of-the-server]__98073dd6._.js.map +1 -0
  73. package/.next/server/chunks/[root-of-the-server]__b7b717eb._.js +3 -0
  74. package/.next/server/chunks/[root-of-the-server]__b7b717eb._.js.map +1 -0
  75. package/.next/server/chunks/[root-of-the-server]__d8e61048._.js +1 -1
  76. package/.next/server/chunks/[root-of-the-server]__d8e61048._.js.map +1 -1
  77. package/.next/server/chunks/[root-of-the-server]__f441109e._.js +3 -0
  78. package/.next/server/chunks/[root-of-the-server]__f441109e._.js.map +1 -0
  79. package/.next/server/chunks/_next-internal_server_app_api_sessions_[id]_restore_route_actions_af7d6b6c.js +3 -0
  80. package/.next/server/chunks/_next-internal_server_app_api_sessions_[id]_restore_route_actions_af7d6b6c.js.map +1 -0
  81. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_2edc9589.js +3 -0
  82. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_2edc9589.js.map +1 -0
  83. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_7f178d4a.js +3 -0
  84. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_7f178d4a.js.map +1 -0
  85. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_aca45402.js +1 -1
  86. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_aca45402.js.map +1 -1
  87. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_b054aff3.js +1 -1
  88. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_b054aff3.js.map +1 -1
  89. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_d0c0f338.js +3 -0
  90. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_d0c0f338.js.map +1 -0
  91. package/.next/server/chunks/src_lib_session-providers_claudeCode_ts_0f9590ed._.js +3 -0
  92. package/.next/server/chunks/src_lib_session-providers_claudeCode_ts_0f9590ed._.js.map +1 -0
  93. package/.next/server/chunks/ssr/{[root-of-the-server]__631e12d0._.js → [root-of-the-server]__c91a8380._.js} +2 -2
  94. package/.next/server/chunks/ssr/{[root-of-the-server]__631e12d0._.js.map → [root-of-the-server]__c91a8380._.js.map} +1 -1
  95. package/.next/server/chunks/ssr/src_app_page_tsx_a7111f3e._.js +3 -3
  96. package/.next/server/chunks/ssr/src_app_page_tsx_a7111f3e._.js.map +1 -1
  97. package/.next/server/pages/404.html +1 -1
  98. package/.next/server/pages/500.html +2 -2
  99. package/.next/server/server-reference-manifest.js +1 -1
  100. package/.next/server/server-reference-manifest.json +1 -1
  101. package/.next/standalone/.next/BUILD_ID +1 -1
  102. package/.next/standalone/.next/app-path-routes-manifest.json +1 -0
  103. package/.next/standalone/.next/build-manifest.json +2 -2
  104. package/.next/standalone/.next/prerender-manifest.json +3 -3
  105. package/.next/standalone/.next/routes-manifest.json +8 -0
  106. package/.next/standalone/.next/server/app/_global-error/page.js +1 -1
  107. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  108. package/.next/standalone/.next/server/app/_global-error.html +2 -2
  109. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  110. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  111. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  112. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  113. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  114. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  115. package/.next/standalone/.next/server/app/_not-found/page.js +1 -1
  116. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  117. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  118. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  119. package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
  120. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  121. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  122. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  123. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  124. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  125. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  126. package/.next/standalone/.next/server/app/api/node/sessions/[id]/open-editor/route.js.nft.json +1 -1
  127. package/.next/standalone/.next/server/app/api/node/sessions/route.js +5 -3
  128. package/.next/standalone/.next/server/app/api/node/sessions/route.js.nft.json +1 -1
  129. package/.next/standalone/.next/server/app/api/opencode-config/route.js.nft.json +1 -1
  130. package/.next/standalone/.next/server/app/api/opencode-config/status/route.js.nft.json +1 -1
  131. package/.next/standalone/.next/server/app/api/profiles/[id]/apply/route.js.nft.json +1 -1
  132. package/.next/standalone/.next/server/app/api/profiles/[id]/export/route.js.nft.json +1 -1
  133. package/.next/standalone/.next/server/app/api/profiles/[id]/route.js.nft.json +1 -1
  134. package/.next/standalone/.next/server/app/api/profiles/import/route.js.nft.json +1 -1
  135. package/.next/standalone/.next/server/app/api/profiles/route.js.nft.json +1 -1
  136. package/.next/standalone/.next/server/app/api/sessions/[id]/archive/route.js +3 -2
  137. package/.next/standalone/.next/server/app/api/sessions/[id]/archive/route.js.nft.json +1 -1
  138. package/.next/standalone/.next/server/app/api/sessions/[id]/delete/route.js +3 -2
  139. package/.next/standalone/.next/server/app/api/sessions/[id]/delete/route.js.nft.json +1 -1
  140. package/.next/standalone/.next/server/app/api/sessions/[id]/open-editor/route.js +1 -1
  141. package/.next/standalone/.next/server/app/api/sessions/[id]/open-editor/route.js.nft.json +1 -1
  142. package/.next/standalone/.next/server/app/api/sessions/[id]/restore/route/app-paths-manifest.json +3 -0
  143. package/.next/standalone/.next/server/app/api/sessions/[id]/restore/route/build-manifest.json +11 -0
  144. package/.next/standalone/.next/server/app/api/sessions/[id]/restore/route/server-reference-manifest.json +4 -0
  145. package/.next/standalone/.next/server/app/api/sessions/[id]/restore/route.js +8 -0
  146. package/.next/standalone/.next/server/app/api/sessions/[id]/restore/route.js.map +5 -0
  147. package/.next/standalone/.next/server/app/api/sessions/[id]/restore/route.js.nft.json +1 -0
  148. package/.next/standalone/.next/server/app/api/sessions/[id]/restore/route_client-reference-manifest.js +2 -0
  149. package/.next/standalone/.next/server/app/api/sessions/route.js +4 -2
  150. package/.next/standalone/.next/server/app/api/sessions/route.js.nft.json +1 -1
  151. package/.next/standalone/.next/server/app/index.html +1 -1
  152. package/.next/standalone/.next/server/app/index.rsc +3 -3
  153. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  154. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  155. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  156. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +2 -2
  157. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  158. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  159. package/.next/standalone/.next/server/app-paths-manifest.json +1 -0
  160. package/.next/standalone/.next/server/chunks/[root-of-the-server]__31d19c5c._.js +3 -0
  161. package/.next/standalone/.next/server/chunks/[root-of-the-server]__56f5f249._.js +1 -1
  162. package/.next/standalone/.next/server/chunks/[root-of-the-server]__5e0a0e38._.js +3 -0
  163. package/.next/standalone/.next/server/chunks/[root-of-the-server]__98073dd6._.js +3 -0
  164. package/.next/standalone/.next/server/chunks/[root-of-the-server]__b7b717eb._.js +3 -0
  165. package/.next/standalone/.next/server/chunks/[root-of-the-server]__d8e61048._.js +1 -1
  166. package/.next/standalone/.next/server/chunks/[root-of-the-server]__f441109e._.js +3 -0
  167. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_sessions_[id]_restore_route_actions_af7d6b6c.js +3 -0
  168. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_2edc9589.js +3 -0
  169. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_7f178d4a.js +3 -0
  170. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_aca45402.js +1 -1
  171. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_b054aff3.js +1 -1
  172. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_d0c0f338.js +3 -0
  173. package/.next/standalone/.next/server/chunks/src_lib_session-providers_claudeCode_ts_0f9590ed._.js +3 -0
  174. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__631e12d0._.js → [root-of-the-server]__c91a8380._.js} +2 -2
  175. package/.next/standalone/.next/server/chunks/ssr/src_app_page_tsx_a7111f3e._.js +3 -3
  176. package/.next/standalone/.next/server/pages/404.html +1 -1
  177. package/.next/standalone/.next/server/pages/500.html +2 -2
  178. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  179. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  180. package/.next/standalone/.next/static/chunks/b3bc362202331708.css +3 -0
  181. package/.next/standalone/.next/static/chunks/{65d5354ba0add961.js → c1294e057d8d4681.js} +3 -3
  182. package/.next/standalone/README.md +29 -5
  183. package/.next/standalone/docs/session-status-detection.md +36 -0
  184. package/.next/standalone/docs/superpowers/specs/2026-04-09-claude-capability-alignment-design.md +39 -0
  185. package/.next/standalone/package-lock.json +2 -2
  186. package/.next/standalone/package.json +1 -1
  187. package/.next/standalone/src/app/api/node/sessions/[id]/archive/route.test.ts +60 -1
  188. package/.next/standalone/src/app/api/node/sessions/[id]/archive/route.ts +77 -22
  189. package/.next/standalone/src/app/api/node/sessions/route.test.ts +282 -0
  190. package/.next/standalone/src/app/api/node/sessions/route.ts +141 -17
  191. package/.next/standalone/src/app/api/opencode-events/route.test.ts +3 -1
  192. package/.next/standalone/src/app/api/sessions/[id]/archive/route.test.ts +101 -0
  193. package/.next/standalone/src/app/api/sessions/[id]/archive/route.ts +47 -12
  194. package/.next/standalone/src/app/api/sessions/[id]/delete/route.test.ts +92 -0
  195. package/.next/standalone/src/app/api/sessions/[id]/delete/route.ts +45 -10
  196. package/.next/standalone/src/app/api/sessions/[id]/open-editor/route.test.ts +74 -0
  197. package/.next/standalone/src/app/api/sessions/[id]/open-editor/route.ts +22 -2
  198. package/.next/standalone/src/app/api/sessions/[id]/restore/route.test.ts +186 -0
  199. package/.next/standalone/src/app/api/sessions/[id]/restore/route.ts +184 -0
  200. package/.next/standalone/src/app/api/sessions/route.test.ts +1889 -107
  201. package/.next/standalone/src/app/api/sessions/route.ts +365 -981
  202. package/.next/standalone/src/components/KanbanBoard.test.tsx +307 -1
  203. package/.next/standalone/src/components/KanbanBoard.tsx +105 -18
  204. package/.next/standalone/src/components/ProjectCard.test.tsx +416 -2
  205. package/.next/standalone/src/components/ProjectCard.tsx +238 -86
  206. package/.next/standalone/src/components/SessionCard.test.tsx +253 -2
  207. package/.next/standalone/src/components/SessionCard.tsx +182 -76
  208. package/.next/standalone/src/hooks/useOpencodeSync.test.ts +321 -1
  209. package/.next/standalone/src/hooks/useOpencodeSync.ts +16 -12
  210. package/.next/standalone/src/lib/claudeSessionOverrides.test.ts +75 -0
  211. package/.next/standalone/src/lib/claudeSessionOverrides.ts +169 -0
  212. package/.next/standalone/src/lib/session-providers/claudeCode.test.ts +2288 -0
  213. package/.next/standalone/src/lib/session-providers/claudeCode.ts +1083 -0
  214. package/.next/standalone/src/lib/session-providers/localAggregator.test.ts +322 -0
  215. package/.next/standalone/src/lib/session-providers/localAggregator.ts +302 -0
  216. package/.next/standalone/src/lib/session-providers/opencodeProvider.ts +723 -0
  217. package/.next/standalone/src/lib/session-providers/providerIds.test.ts +337 -0
  218. package/.next/standalone/src/lib/session-providers/providerIds.ts +176 -0
  219. package/.next/standalone/src/lib/session-providers/types.ts +131 -0
  220. package/.next/standalone/src/lib/transform.test.ts +253 -0
  221. package/.next/standalone/src/lib/transform.ts +96 -37
  222. package/.next/standalone/src/types/index.ts +23 -17
  223. package/.next/static/chunks/b3bc362202331708.css +3 -0
  224. package/.next/static/chunks/{65d5354ba0add961.js → c1294e057d8d4681.js} +3 -3
  225. package/.next/trace +1 -1
  226. package/.next/trace-build +1 -1
  227. package/.next/types/routes.d.ts +2 -1
  228. package/.next/types/validator.ts +9 -0
  229. package/README.md +29 -5
  230. package/package.json +1 -1
  231. package/.next/server/chunks/[root-of-the-server]__2f981540._.js +0 -3
  232. package/.next/server/chunks/[root-of-the-server]__2f981540._.js.map +0 -1
  233. package/.next/server/chunks/[root-of-the-server]__3745b314._.js +0 -3
  234. package/.next/server/chunks/[root-of-the-server]__3745b314._.js.map +0 -1
  235. package/.next/server/chunks/[root-of-the-server]__6c428a24._.js +0 -3
  236. package/.next/server/chunks/[root-of-the-server]__6c428a24._.js.map +0 -1
  237. package/.next/server/chunks/[root-of-the-server]__73a00b88._.js +0 -3
  238. package/.next/server/chunks/[root-of-the-server]__73a00b88._.js.map +0 -1
  239. package/.next/server/chunks/[root-of-the-server]__db285678._.js +0 -3
  240. package/.next/server/chunks/[root-of-the-server]__db285678._.js.map +0 -1
  241. package/.next/standalone/.next/server/chunks/[root-of-the-server]__2f981540._.js +0 -3
  242. package/.next/standalone/.next/server/chunks/[root-of-the-server]__3745b314._.js +0 -3
  243. package/.next/standalone/.next/server/chunks/[root-of-the-server]__6c428a24._.js +0 -3
  244. package/.next/standalone/.next/server/chunks/[root-of-the-server]__73a00b88._.js +0 -3
  245. package/.next/standalone/.next/server/chunks/[root-of-the-server]__db285678._.js +0 -3
  246. package/.next/standalone/.next/static/chunks/f42202943f6742e5.css +0 -3
  247. package/.next/static/chunks/f42202943f6742e5.css +0 -3
  248. /package/.next/standalone/.next/static/{5kq9DtuBFVxu4jsgmL5Q- → bsWNvgDS7Zp38Yt9q0DUg}/_buildManifest.js +0 -0
  249. /package/.next/standalone/.next/static/{5kq9DtuBFVxu4jsgmL5Q- → bsWNvgDS7Zp38Yt9q0DUg}/_clientMiddlewareManifest.json +0 -0
  250. /package/.next/standalone/.next/static/{5kq9DtuBFVxu4jsgmL5Q- → bsWNvgDS7Zp38Yt9q0DUg}/_ssgManifest.js +0 -0
  251. /package/.next/static/{5kq9DtuBFVxu4jsgmL5Q- → bsWNvgDS7Zp38Yt9q0DUg}/_buildManifest.js +0 -0
  252. /package/.next/static/{5kq9DtuBFVxu4jsgmL5Q- → bsWNvgDS7Zp38Yt9q0DUg}/_clientMiddlewareManifest.json +0 -0
  253. /package/.next/static/{5kq9DtuBFVxu4jsgmL5Q- → bsWNvgDS7Zp38Yt9q0DUg}/_ssgManifest.js +0 -0
@@ -13,6 +13,16 @@ vi.mock('@/lib/opencodeConfig', () => ({
13
13
  readConfig: vi.fn(),
14
14
  }));
15
15
 
16
+ vi.mock('@/lib/session-providers/claudeCode', () => ({
17
+ claudeCodeLocalSessionProvider: {
18
+ id: 'claude-code',
19
+ getSessionsResult: vi.fn(async () => ({
20
+ payload: { sessions: [], processHints: [] },
21
+ sourceMeta: { online: false },
22
+ })),
23
+ },
24
+ }));
25
+
16
26
  vi.mock('@/lib/nodeRegistry', () => ({
17
27
  listNodeRecords: vi.fn(),
18
28
  }));
@@ -43,6 +53,7 @@ import {
43
53
  discoverOpencodeProcessCwdsWithoutPortWithMeta,
44
54
  } from '@/lib/opencodeDiscovery';
45
55
  import { readConfig } from '@/lib/opencodeConfig';
56
+ import { claudeCodeLocalSessionProvider } from '@/lib/session-providers/claudeCode';
46
57
  import { listNodeRecords } from '@/lib/nodeRegistry';
47
58
  import { NODE_PROTOCOL_VERSION } from '@/lib/nodeProtocol';
48
59
 
@@ -61,6 +72,7 @@ const mockCreateOpencodeClient: any = createOpencodeClient;
61
72
  const mockDiscoverPortsWithMeta: any = discoverOpencodePortsWithMeta;
62
73
  const mockDiscoverProcessCwdsWithoutPortWithMeta: any = discoverOpencodeProcessCwdsWithoutPortWithMeta;
63
74
  const mockReadConfig: any = readConfig;
75
+ const mockClaudeLocalProviderGetSessionsResult: any = claudeCodeLocalSessionProvider.getSessionsResult;
64
76
  const mockListNodeRecords: any = listNodeRecords;
65
77
  const mockExecSync: any = execSync;
66
78
 
@@ -113,6 +125,10 @@ type TestSession = {
113
125
 
114
126
  function setupLocalSessionsMocks(): void {
115
127
  resetDefaultClientMock();
128
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
129
+ payload: { sessions: [], processHints: [] },
130
+ sourceMeta: { online: false },
131
+ });
116
132
 
117
133
  mockReadConfig.mockResolvedValue({
118
134
  vibepulse: {
@@ -261,37 +277,1777 @@ describe('/api/sessions status stabilization ordering', () => {
261
277
 
262
278
  applyStickyStatusStabilization(session, now, stickyBusyDelayMs);
263
279
 
264
- expect(session.children[0].realTimeStatus).toBe('idle');
280
+ expect(session.children[0].realTimeStatus).toBe('idle');
281
+ });
282
+ });
283
+
284
+ describe('/api/sessions route source handling', () => {
285
+ const originalRuntimeRole = process.env.VIBEPULSE_RUNTIME_ROLE;
286
+
287
+ beforeEach(() => {
288
+ process.env.VIBEPULSE_RUNTIME_ROLE = 'hub';
289
+ });
290
+
291
+ afterEach(() => {
292
+ process.env.VIBEPULSE_RUNTIME_ROLE = originalRuntimeRole;
293
+ });
294
+
295
+ it('enforces local-only aggregation in node mode even when remote sources are requested', async () => {
296
+ process.env.VIBEPULSE_RUNTIME_ROLE = 'node';
297
+ setupLocalSessionsMocks();
298
+ mockListNodeRecords.mockResolvedValue([
299
+ {
300
+ nodeId: 'remote-a',
301
+ nodeLabel: 'Remote A',
302
+ baseUrl: 'https://remote-a.test',
303
+ enabled: true,
304
+ token: 'token-a',
305
+ createdAt: new Date().toISOString(),
306
+ updatedAt: new Date().toISOString(),
307
+ },
308
+ ]);
309
+
310
+ const mockFetch: any = vi.fn();
311
+ vi.stubGlobal('fetch', mockFetch);
312
+
313
+ const response = await POST(
314
+ new Request('http://localhost/api/sessions', {
315
+ method: 'POST',
316
+ headers: { 'content-type': 'application/json' },
317
+ body: JSON.stringify({
318
+ sources: [
319
+ { hostId: 'local', hostLabel: 'Local', hostKind: 'local' },
320
+ {
321
+ hostId: 'remote-a',
322
+ hostLabel: 'Remote A',
323
+ hostKind: 'remote',
324
+ baseUrl: 'https://remote-a.test',
325
+ enabled: true,
326
+ },
327
+ ],
328
+ }),
329
+ })
330
+ );
331
+ const data = await response.json();
332
+
333
+ expect(response.status).toBe(200);
334
+ expect(data.hostStatuses).toEqual([
335
+ { hostId: 'local', hostLabel: 'Local', hostKind: 'local', online: true },
336
+ ]);
337
+ expect(data.hosts).toEqual(data.hostStatuses);
338
+ expect(data.sessions.every((session: any) => session.hostId === 'local')).toBe(true);
339
+ expect(data.sessions.every((session: any) => !session.baseUrl)).toBe(true);
340
+ expect(data.sessions.every((session: any) =>
341
+ session.children.every((child: any) => child.hostId === 'local' && !child.baseUrl)
342
+ )).toBe(true);
343
+ expect(mockFetch.mock.calls).toHaveLength(0);
344
+ expect(mockListNodeRecords.mock.calls).toHaveLength(0);
345
+ });
346
+
347
+ it('keeps GET local aggregation behavior working without request host config', async () => {
348
+ setupLocalSessionsMocks();
349
+
350
+ const response = await GET();
351
+ const data = await response.json();
352
+
353
+ expect(response.status).toBe(200);
354
+ expect(data.processHints).toEqual([
355
+ {
356
+ pid: 321,
357
+ directory: '/repo/orphan-project',
358
+ projectName: 'orphan-project',
359
+ reason: 'process_without_api_port',
360
+ },
361
+ ]);
362
+ expect(data.sessions).toHaveLength(1);
363
+
364
+ const session = data.sessions[0];
365
+ expect(session.id).toBe('parent-1');
366
+ expect(session.slug).toBe('parent-1');
367
+ expect(session.title).toBe('Parent Session');
368
+ expect(session.directory).toBe('/repo/project-one');
369
+ expect(session.time.created).toBe(1_000);
370
+ expect(typeof session.time.updated).toBe('number');
371
+ expect(session.projectName).toBe('project-one');
372
+ expect(session.branch).toBe('main');
373
+ expect(session.realTimeStatus).toBe('busy');
374
+ expect(session.waitingForUser).toBe(false);
375
+ expect(session.children).toHaveLength(1);
376
+
377
+ const child = session.children[0];
378
+ expect(child.id).toBe('child-1');
379
+ expect(child.title).toBe('Child Session');
380
+ expect(child.directory).toBe('/repo/project-one');
381
+ expect(child.parentID).toBe('parent-1');
382
+ expect(child.time?.created).toBe(1_100);
383
+ expect(typeof child.time?.updated).toBe('number');
384
+ expect(session.hostId).toBeUndefined();
385
+ expect(session.rawSessionId).toBeUndefined();
386
+ expect(session.sourceSessionKey).toBeUndefined();
387
+ expect(session.readOnly).toBeUndefined();
388
+ expect(child.hostId).toBeUndefined();
389
+ expect(child.rawSessionId).toBeUndefined();
390
+ expect(child.sourceSessionKey).toBeUndefined();
391
+ expect(child.readOnly).toBeUndefined();
392
+ });
393
+
394
+ it('returns host-aware Local identities for POST when only the Local source is requested', async () => {
395
+ setupLocalSessionsMocks();
396
+
397
+ const getResponse = await GET();
398
+ const getData = await getResponse.json();
399
+
400
+ const postResponse = await POST(
401
+ new Request('http://localhost/api/sessions', {
402
+ method: 'POST',
403
+ headers: { 'content-type': 'application/json' },
404
+ body: JSON.stringify({
405
+ sources: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local' }],
406
+ }),
407
+ })
408
+ );
409
+ const postData = await postResponse.json();
410
+
411
+ expect(getResponse.status).toBe(200);
412
+ expect(postResponse.status).toBe(200);
413
+ expect(postData.sessions).toHaveLength(1);
414
+ expect(postData.processHints).toEqual(getData.processHints);
415
+ expect(postData.hostStatuses).toEqual([
416
+ { hostId: 'local', hostLabel: 'Local', hostKind: 'local', online: true },
417
+ ]);
418
+ expect(postData.hosts).toEqual(postData.hostStatuses);
419
+
420
+ expect(postData.sessions[0]).toMatchObject({
421
+ id: 'local:parent-1',
422
+ slug: getData.sessions[0].slug,
423
+ title: getData.sessions[0].title,
424
+ directory: getData.sessions[0].directory,
425
+ time: getData.sessions[0].time,
426
+ projectName: getData.sessions[0].projectName,
427
+ branch: getData.sessions[0].branch,
428
+ realTimeStatus: getData.sessions[0].realTimeStatus,
429
+ waitingForUser: getData.sessions[0].waitingForUser,
430
+ rawSessionId: 'parent-1',
431
+ sourceSessionKey: 'local:parent-1',
432
+ hostId: 'local',
433
+ hostLabel: 'Local',
434
+ hostKind: 'local',
435
+ readOnly: false,
436
+ });
437
+ expect(postData.sessions[0].children).toHaveLength(1);
438
+ expect(postData.sessions[0].children[0]).toMatchObject({
439
+ ...getData.sessions[0].children[0],
440
+ id: 'local:child-1',
441
+ parentID: 'local:parent-1',
442
+ rawSessionId: 'child-1',
443
+ sourceSessionKey: 'local:child-1',
444
+ hostId: 'local',
445
+ hostLabel: 'Local',
446
+ hostKind: 'local',
447
+ readOnly: false,
448
+ });
449
+
450
+ expect(postData.sessions.every((session: any) => session.hostId === 'local')).toBe(true);
451
+ expect(postData.sessions.every((session: any) => !session.baseUrl)).toBe(true);
452
+ expect(postData.sessions.every((session: any) =>
453
+ session.children.every((child: any) => child.hostId === 'local' && !child.baseUrl)
454
+ )).toBe(true);
455
+ });
456
+
457
+ it('returns mixed OpenCode and Claude sessions for local polling while preserving Local host metadata rules', async () => {
458
+ setupLocalSessionsMocks();
459
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
460
+ payload: {
461
+ sessions: [
462
+ {
463
+ id: 'claude~550e8400-e29b-41d4-a716-446655440000',
464
+ slug: 'claude~550e8400-e29b-41d4-a716-446655440000',
465
+ title: 'Claude Session',
466
+ directory: '/repo/project-one',
467
+ projectName: 'project-one',
468
+ branch: 'main',
469
+ provider: 'claude-code',
470
+ providerRawId: '550e8400-e29b-41d4-a716-446655440000',
471
+ rawSessionId: '550e8400-e29b-41d4-a716-446655440000',
472
+ realTimeStatus: 'busy',
473
+ waitingForUser: false,
474
+ readOnly: true,
475
+ children: [],
476
+ },
477
+ ],
478
+ processHints: [],
479
+ },
480
+ sourceMeta: { online: true },
481
+ });
482
+
483
+ const getResponse = await GET();
484
+ const getData = await getResponse.json();
485
+
486
+ const postResponse = await POST(
487
+ new Request('http://localhost/api/sessions', {
488
+ method: 'POST',
489
+ headers: { 'content-type': 'application/json' },
490
+ body: JSON.stringify({
491
+ sources: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local' }],
492
+ }),
493
+ })
494
+ );
495
+ const postData = await postResponse.json();
496
+
497
+ expect(getResponse.status).toBe(200);
498
+ expect(getData.sessions).toHaveLength(2);
499
+ expect(getData.sessions).toEqual(
500
+ expect.arrayContaining([
501
+ expect.objectContaining({ id: 'parent-1' }),
502
+ expect.objectContaining({
503
+ id: 'claude~550e8400-e29b-41d4-a716-446655440000',
504
+ provider: 'claude-code',
505
+ providerRawId: '550e8400-e29b-41d4-a716-446655440000',
506
+ rawSessionId: '550e8400-e29b-41d4-a716-446655440000',
507
+ readOnly: true,
508
+ children: [],
509
+ }),
510
+ ])
511
+ );
512
+ const getClaudeSession = getData.sessions.find(
513
+ (session: any) => session.id === 'claude~550e8400-e29b-41d4-a716-446655440000'
514
+ );
515
+ expect(getClaudeSession).not.toHaveProperty('hostId');
516
+
517
+ expect(postResponse.status).toBe(200);
518
+ expect(postData.hostStatuses).toEqual([
519
+ { hostId: 'local', hostLabel: 'Local', hostKind: 'local', online: true },
520
+ ]);
521
+ expect(postData.sessions).toHaveLength(2);
522
+ expect(postData.sessions).toEqual(
523
+ expect.arrayContaining([
524
+ expect.objectContaining({
525
+ id: 'local:parent-1',
526
+ hostId: 'local',
527
+ hostLabel: 'Local',
528
+ hostKind: 'local',
529
+ readOnly: false,
530
+ }),
531
+ expect.objectContaining({
532
+ id: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
533
+ rawSessionId: '550e8400-e29b-41d4-a716-446655440000',
534
+ sourceSessionKey: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
535
+ provider: 'claude-code',
536
+ providerRawId: '550e8400-e29b-41d4-a716-446655440000',
537
+ hostId: 'local',
538
+ hostLabel: 'Local',
539
+ hostKind: 'local',
540
+ readOnly: true,
541
+ children: [],
542
+ }),
543
+ ])
544
+ );
545
+ });
546
+
547
+ it('persists inferred Claude provider fields after local host rebinding when upstream rows omit provider metadata', async () => {
548
+ setupLocalSessionsMocks();
549
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
550
+ payload: {
551
+ sessions: [
552
+ {
553
+ id: 'claude~550e8400-e29b-41d4-a716-446655440000',
554
+ slug: 'claude~550e8400-e29b-41d4-a716-446655440000',
555
+ title: 'Claude Session Inferred Provider',
556
+ directory: '/repo/project-one',
557
+ projectName: 'project-one',
558
+ branch: 'main',
559
+ rawSessionId: '550e8400-e29b-41d4-a716-446655440000',
560
+ realTimeStatus: 'busy',
561
+ waitingForUser: false,
562
+ readOnly: true,
563
+ children: [
564
+ {
565
+ id: 'claude~660e8400-e29b-41d4-a716-446655440000',
566
+ title: 'Claude Child Inferred Provider',
567
+ directory: '/repo/project-one',
568
+ parentID: 'claude~550e8400-e29b-41d4-a716-446655440000',
569
+ rawSessionId: '660e8400-e29b-41d4-a716-446655440000',
570
+ realTimeStatus: 'idle',
571
+ waitingForUser: true,
572
+ readOnly: true,
573
+ },
574
+ ],
575
+ },
576
+ ],
577
+ processHints: [],
578
+ },
579
+ sourceMeta: { online: true },
580
+ });
581
+
582
+ const response = await POST(
583
+ new Request('http://localhost/api/sessions', {
584
+ method: 'POST',
585
+ headers: { 'content-type': 'application/json' },
586
+ body: JSON.stringify({
587
+ sources: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local' }],
588
+ }),
589
+ })
590
+ );
591
+ const data = await response.json();
592
+
593
+ expect(response.status).toBe(200);
594
+ const parent = data.sessions.find(
595
+ (session: any) => session.id === 'local:claude~550e8400-e29b-41d4-a716-446655440000'
596
+ );
597
+ expect(parent).toBeDefined();
598
+ expect(parent).toMatchObject({
599
+ provider: 'claude-code',
600
+ providerRawId: '550e8400-e29b-41d4-a716-446655440000',
601
+ sourceSessionKey: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
602
+ });
603
+ expect(parent.children).toHaveLength(1);
604
+ expect(parent.children[0]).toMatchObject({
605
+ id: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
606
+ parentID: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
607
+ provider: 'claude-code',
608
+ providerRawId: '660e8400-e29b-41d4-a716-446655440000',
609
+ sourceSessionKey: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
610
+ });
611
+ });
612
+
613
+ it('surfaces merged Claude sessions from both current and external local projects without changing read-only provider semantics', async () => {
614
+ setupLocalSessionsMocks();
615
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
616
+ payload: {
617
+ sessions: [
618
+ {
619
+ id: 'claude~550e8400-e29b-41d4-a716-446655440000',
620
+ slug: '550e8400-e29b-41d4-a716-446655440000',
621
+ title: 'Claude Session',
622
+ directory: '/repo/project-one',
623
+ projectName: 'project-one',
624
+ branch: 'main',
625
+ provider: 'claude-code',
626
+ providerRawId: '550e8400-e29b-41d4-a716-446655440000',
627
+ rawSessionId: '550e8400-e29b-41d4-a716-446655440000',
628
+ realTimeStatus: 'busy',
629
+ waitingForUser: false,
630
+ readOnly: true,
631
+ children: [],
632
+ },
633
+ {
634
+ id: 'claude~660e8400-e29b-41d4-a716-446655440000',
635
+ slug: '660e8400-e29b-41d4-a716-446655440000',
636
+ title: 'Claude Session',
637
+ directory: '/projects/apps-guide',
638
+ projectName: 'apps-guide',
639
+ branch: 'docs',
640
+ provider: 'claude-code',
641
+ providerRawId: '660e8400-e29b-41d4-a716-446655440000',
642
+ rawSessionId: '660e8400-e29b-41d4-a716-446655440000',
643
+ realTimeStatus: 'idle',
644
+ waitingForUser: false,
645
+ readOnly: true,
646
+ children: [],
647
+ },
648
+ ],
649
+ processHints: [],
650
+ },
651
+ sourceMeta: { online: true },
652
+ });
653
+
654
+ const getResponse = await GET();
655
+ const getData = await getResponse.json();
656
+
657
+ const postResponse = await POST(
658
+ new Request('http://localhost/api/sessions', {
659
+ method: 'POST',
660
+ headers: { 'content-type': 'application/json' },
661
+ body: JSON.stringify({
662
+ sources: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local' }],
663
+ }),
664
+ })
665
+ );
666
+ const postData = await postResponse.json();
667
+
668
+ expect(getResponse.status).toBe(200);
669
+ expect(getData.sessions).toEqual(
670
+ expect.arrayContaining([
671
+ expect.objectContaining({
672
+ id: 'claude~550e8400-e29b-41d4-a716-446655440000',
673
+ directory: '/repo/project-one',
674
+ provider: 'claude-code',
675
+ readOnly: true,
676
+ }),
677
+ expect.objectContaining({
678
+ id: 'claude~660e8400-e29b-41d4-a716-446655440000',
679
+ directory: '/projects/apps-guide',
680
+ projectName: 'apps-guide',
681
+ provider: 'claude-code',
682
+ providerRawId: '660e8400-e29b-41d4-a716-446655440000',
683
+ rawSessionId: '660e8400-e29b-41d4-a716-446655440000',
684
+ readOnly: true,
685
+ }),
686
+ ])
687
+ );
688
+
689
+ expect(postResponse.status).toBe(200);
690
+ expect(postData.sessions).toEqual(
691
+ expect.arrayContaining([
692
+ expect.objectContaining({
693
+ id: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
694
+ rawSessionId: '550e8400-e29b-41d4-a716-446655440000',
695
+ sourceSessionKey: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
696
+ hostId: 'local',
697
+ provider: 'claude-code',
698
+ readOnly: true,
699
+ }),
700
+ expect.objectContaining({
701
+ id: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
702
+ directory: '/projects/apps-guide',
703
+ rawSessionId: '660e8400-e29b-41d4-a716-446655440000',
704
+ sourceSessionKey: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
705
+ hostId: 'local',
706
+ hostLabel: 'Local',
707
+ hostKind: 'local',
708
+ provider: 'claude-code',
709
+ readOnly: true,
710
+ children: [],
711
+ }),
712
+ ])
713
+ );
714
+ });
715
+
716
+ it('rebinds provider-specific child ids with provider-aware host metadata', async () => {
717
+ setupLocalSessionsMocks();
718
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
719
+ payload: {
720
+ sessions: [
721
+ {
722
+ id: 'claude~550e8400-e29b-41d4-a716-446655440000',
723
+ slug: '550e8400-e29b-41d4-a716-446655440000',
724
+ title: 'Claude Session',
725
+ directory: '/repo/project-one',
726
+ projectName: 'project-one',
727
+ provider: 'claude-code',
728
+ providerRawId: '550e8400-e29b-41d4-a716-446655440000',
729
+ rawSessionId: '550e8400-e29b-41d4-a716-446655440000',
730
+ realTimeStatus: 'busy',
731
+ waitingForUser: false,
732
+ readOnly: true,
733
+ children: [
734
+ {
735
+ id: '660e8400-e29b-41d4-a716-446655440000',
736
+ parentID: '550e8400-e29b-41d4-a716-446655440000',
737
+ rawSessionId: '660e8400-e29b-41d4-a716-446655440000',
738
+ title: 'Claude Child',
739
+ directory: '/repo/project-one',
740
+ realTimeStatus: 'busy',
741
+ waitingForUser: false,
742
+ readOnly: true,
743
+ time: { created: 2_100, updated: Date.now() - 900 },
744
+ },
745
+ {
746
+ id: '770e8400-e29b-41d4-a716-446655440000',
747
+ parentID: '660e8400-e29b-41d4-a716-446655440000',
748
+ rawSessionId: '770e8400-e29b-41d4-a716-446655440000',
749
+ title: 'Claude Grandchild',
750
+ directory: '/repo/project-one',
751
+ realTimeStatus: 'busy',
752
+ waitingForUser: false,
753
+ readOnly: true,
754
+ time: { created: 2_200, updated: Date.now() - 800 },
755
+ },
756
+ ],
757
+ time: { created: 2_000, updated: Date.now() - 1_000 },
758
+ },
759
+ ],
760
+ processHints: [],
761
+ },
762
+ sourceMeta: { online: true },
763
+ });
764
+
765
+ const response = await POST(
766
+ new Request('http://localhost/api/sessions', {
767
+ method: 'POST',
768
+ headers: { 'content-type': 'application/json' },
769
+ body: JSON.stringify({
770
+ sources: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local' }],
771
+ }),
772
+ })
773
+ );
774
+ const data = await response.json();
775
+
776
+ expect(response.status).toBe(200);
777
+ expect(data.sessions).toEqual(
778
+ expect.arrayContaining([
779
+ expect.objectContaining({
780
+ id: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
781
+ children: expect.arrayContaining([
782
+ expect.objectContaining({
783
+ id: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
784
+ rawSessionId: '660e8400-e29b-41d4-a716-446655440000',
785
+ sourceSessionKey: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
786
+ parentID: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
787
+ hostId: 'local',
788
+ hostLabel: 'Local',
789
+ hostKind: 'local',
790
+ readOnly: true,
791
+ }),
792
+ expect.objectContaining({
793
+ id: 'local:claude~770e8400-e29b-41d4-a716-446655440000',
794
+ rawSessionId: '770e8400-e29b-41d4-a716-446655440000',
795
+ sourceSessionKey: 'local:claude~770e8400-e29b-41d4-a716-446655440000',
796
+ parentID: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
797
+ hostId: 'local',
798
+ hostLabel: 'Local',
799
+ hostKind: 'local',
800
+ readOnly: true,
801
+ }),
802
+ ]),
803
+ }),
804
+ ])
805
+ );
806
+ });
807
+
808
+ it('rebuilds local Claude child topology from flat provider sessions after host rebinding', async () => {
809
+ setupLocalSessionsMocks();
810
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
811
+ payload: {
812
+ sessions: [
813
+ {
814
+ id: 'claude~550e8400-e29b-41d4-a716-446655440000',
815
+ slug: '550e8400-e29b-41d4-a716-446655440000',
816
+ title: 'Claude Parent',
817
+ directory: '/repo/project-one',
818
+ projectName: 'project-one',
819
+ branch: 'main',
820
+ provider: 'claude-code',
821
+ providerRawId: '550e8400-e29b-41d4-a716-446655440000',
822
+ rawSessionId: '550e8400-e29b-41d4-a716-446655440000',
823
+ realTimeStatus: 'busy',
824
+ waitingForUser: false,
825
+ readOnly: true,
826
+ topology: { childSessions: 'authoritative' },
827
+ children: [],
828
+ time: { created: 2_000, updated: Date.now() - 1_000 },
829
+ },
830
+ {
831
+ id: 'claude~660e8400-e29b-41d4-a716-446655440000',
832
+ slug: '660e8400-e29b-41d4-a716-446655440000',
833
+ title: 'Claude Child',
834
+ directory: '/repo/project-one',
835
+ projectName: 'project-one',
836
+ branch: 'main',
837
+ parentID: 'claude~550e8400-e29b-41d4-a716-446655440000',
838
+ provider: 'claude-code',
839
+ providerRawId: '660e8400-e29b-41d4-a716-446655440000',
840
+ rawSessionId: '660e8400-e29b-41d4-a716-446655440000',
841
+ realTimeStatus: 'busy',
842
+ waitingForUser: false,
843
+ readOnly: true,
844
+ topology: { childSessions: 'authoritative' },
845
+ children: [],
846
+ time: { created: 2_100, updated: Date.now() - 900 },
847
+ },
848
+ ],
849
+ processHints: [],
850
+ },
851
+ sourceMeta: { online: true },
852
+ });
853
+
854
+ const response = await POST(
855
+ new Request('http://localhost/api/sessions', {
856
+ method: 'POST',
857
+ headers: { 'content-type': 'application/json' },
858
+ body: JSON.stringify({
859
+ sources: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local' }],
860
+ }),
861
+ })
862
+ );
863
+ const data = await response.json();
864
+
865
+ expect(response.status).toBe(200);
866
+ const claudeParent = data.sessions.find(
867
+ (session: any) => session.id === 'local:claude~550e8400-e29b-41d4-a716-446655440000'
868
+ );
869
+ expect(claudeParent).toMatchObject({
870
+ id: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
871
+ rawSessionId: '550e8400-e29b-41d4-a716-446655440000',
872
+ sourceSessionKey: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
873
+ provider: 'claude-code',
874
+ providerRawId: '550e8400-e29b-41d4-a716-446655440000',
875
+ readOnly: true,
876
+ topology: { childSessions: 'authoritative' },
877
+ });
878
+ expect(claudeParent.children).toEqual([
879
+ expect.objectContaining({
880
+ id: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
881
+ parentID: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
882
+ rawSessionId: '660e8400-e29b-41d4-a716-446655440000',
883
+ sourceSessionKey: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
884
+ provider: 'claude-code',
885
+ providerRawId: '660e8400-e29b-41d4-a716-446655440000',
886
+ readOnly: true,
887
+ topology: { childSessions: 'authoritative' },
888
+ }),
889
+ ]);
890
+ expect(
891
+ data.sessions.find((session: any) => session.id === 'local:claude~660e8400-e29b-41d4-a716-446655440000')
892
+ ).toBeUndefined();
893
+ });
894
+
895
+ it('preserves deep local authoritative Claude descendants without dropping grandchildren', async () => {
896
+ setupLocalSessionsMocks();
897
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
898
+ payload: {
899
+ sessions: [
900
+ {
901
+ id: 'claude~550e8400-e29b-41d4-a716-446655440000',
902
+ slug: '550e8400-e29b-41d4-a716-446655440000',
903
+ title: 'Claude Root Parent',
904
+ directory: '/repo/project-one',
905
+ projectName: 'project-one',
906
+ branch: 'main',
907
+ provider: 'claude-code',
908
+ providerRawId: '550e8400-e29b-41d4-a716-446655440000',
909
+ rawSessionId: '550e8400-e29b-41d4-a716-446655440000',
910
+ realTimeStatus: 'busy',
911
+ waitingForUser: false,
912
+ readOnly: true,
913
+ topology: { childSessions: 'authoritative' },
914
+ children: [],
915
+ time: { created: 2_000, updated: Date.now() - 1_000 },
916
+ },
917
+ {
918
+ id: 'claude~660e8400-e29b-41d4-a716-446655440000',
919
+ slug: '660e8400-e29b-41d4-a716-446655440000',
920
+ title: 'Claude Intermediate Child',
921
+ directory: '/repo/project-one',
922
+ projectName: 'project-one',
923
+ branch: 'main',
924
+ parentID: 'claude~550e8400-e29b-41d4-a716-446655440000',
925
+ provider: 'claude-code',
926
+ providerRawId: '660e8400-e29b-41d4-a716-446655440000',
927
+ rawSessionId: '660e8400-e29b-41d4-a716-446655440000',
928
+ realTimeStatus: 'busy',
929
+ waitingForUser: false,
930
+ readOnly: true,
931
+ topology: { childSessions: 'authoritative' },
932
+ children: [],
933
+ time: { created: 2_100, updated: Date.now() - 900 },
934
+ },
935
+ {
936
+ id: 'claude~770e8400-e29b-41d4-a716-446655440000',
937
+ slug: '770e8400-e29b-41d4-a716-446655440000',
938
+ title: 'Claude Grandchild',
939
+ directory: '/repo/project-one',
940
+ projectName: 'project-one',
941
+ branch: 'main',
942
+ parentID: 'claude~660e8400-e29b-41d4-a716-446655440000',
943
+ provider: 'claude-code',
944
+ providerRawId: '770e8400-e29b-41d4-a716-446655440000',
945
+ rawSessionId: '770e8400-e29b-41d4-a716-446655440000',
946
+ realTimeStatus: 'idle',
947
+ waitingForUser: true,
948
+ readOnly: true,
949
+ topology: { childSessions: 'authoritative' },
950
+ children: [],
951
+ time: { created: 2_200, updated: Date.now() - 800 },
952
+ },
953
+ ],
954
+ processHints: [],
955
+ },
956
+ sourceMeta: { online: true },
957
+ });
958
+
959
+ const response = await POST(
960
+ new Request('http://localhost/api/sessions', {
961
+ method: 'POST',
962
+ headers: { 'content-type': 'application/json' },
963
+ body: JSON.stringify({
964
+ sources: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local' }],
965
+ }),
966
+ })
967
+ );
968
+ const data = await response.json();
969
+
970
+ expect(response.status).toBe(200);
971
+ const rootParent = data.sessions.find(
972
+ (session: any) => session.id === 'local:claude~550e8400-e29b-41d4-a716-446655440000'
973
+ );
974
+ const intermediateChild = data.sessions.find(
975
+ (session: any) => session.id === 'local:claude~660e8400-e29b-41d4-a716-446655440000'
976
+ );
977
+
978
+ expect(rootParent).toBeTruthy();
979
+ expect(intermediateChild).toMatchObject({
980
+ id: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
981
+ parentID: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
982
+ topology: { childSessions: 'authoritative' },
983
+ children: [
984
+ expect.objectContaining({
985
+ id: 'local:claude~770e8400-e29b-41d4-a716-446655440000',
986
+ parentID: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
987
+ provider: 'claude-code',
988
+ topology: { childSessions: 'authoritative' },
989
+ }),
990
+ ],
991
+ });
992
+
993
+ expect(
994
+ data.sessions.find((session: any) => session.id === 'local:claude~770e8400-e29b-41d4-a716-446655440000')
995
+ ).toBeUndefined();
996
+ });
997
+
998
+ it('rebinds descendants to a visible ancestor when explicit parent was already absorbed', async () => {
999
+ setupLocalSessionsMocks();
1000
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
1001
+ payload: {
1002
+ sessions: [
1003
+ {
1004
+ id: 'claude~550e8400-e29b-41d4-a716-446655440000',
1005
+ slug: '550e8400-e29b-41d4-a716-446655440000',
1006
+ title: 'Claude Root Parent',
1007
+ directory: '/repo/project-one',
1008
+ projectName: 'project-one',
1009
+ branch: 'main',
1010
+ provider: 'claude-code',
1011
+ providerRawId: '550e8400-e29b-41d4-a716-446655440000',
1012
+ rawSessionId: '550e8400-e29b-41d4-a716-446655440000',
1013
+ realTimeStatus: 'busy',
1014
+ waitingForUser: false,
1015
+ readOnly: true,
1016
+ topology: { childSessions: 'authoritative' },
1017
+ children: [],
1018
+ time: { created: 2_000, updated: Date.now() - 1_000 },
1019
+ },
1020
+ {
1021
+ id: 'claude~660e8400-e29b-41d4-a716-446655440000',
1022
+ slug: '660e8400-e29b-41d4-a716-446655440000',
1023
+ title: 'Claude Intermediate Child',
1024
+ directory: '/repo/project-one',
1025
+ projectName: 'project-one',
1026
+ branch: 'main',
1027
+ parentID: 'claude~550e8400-e29b-41d4-a716-446655440000',
1028
+ provider: 'claude-code',
1029
+ providerRawId: '660e8400-e29b-41d4-a716-446655440000',
1030
+ rawSessionId: '660e8400-e29b-41d4-a716-446655440000',
1031
+ realTimeStatus: 'busy',
1032
+ waitingForUser: false,
1033
+ readOnly: true,
1034
+ topology: { childSessions: 'authoritative' },
1035
+ children: [],
1036
+ time: { created: 3_000, updated: Date.now() - 500 },
1037
+ },
1038
+ {
1039
+ id: 'claude~770e8400-e29b-41d4-a716-446655440000',
1040
+ slug: '770e8400-e29b-41d4-a716-446655440000',
1041
+ title: 'Claude Descendant with Older Timestamp',
1042
+ directory: '/repo/project-one',
1043
+ projectName: 'project-one',
1044
+ branch: 'main',
1045
+ parentID: 'claude~660e8400-e29b-41d4-a716-446655440000',
1046
+ provider: 'claude-code',
1047
+ providerRawId: '770e8400-e29b-41d4-a716-446655440000',
1048
+ rawSessionId: '770e8400-e29b-41d4-a716-446655440000',
1049
+ realTimeStatus: 'idle',
1050
+ waitingForUser: true,
1051
+ readOnly: true,
1052
+ topology: { childSessions: 'authoritative' },
1053
+ children: [],
1054
+ time: { created: 1_000, updated: Date.now() - 2_000 },
1055
+ },
1056
+ ],
1057
+ processHints: [],
1058
+ },
1059
+ sourceMeta: { online: true },
1060
+ });
1061
+
1062
+ const response = await POST(
1063
+ new Request('http://localhost/api/sessions', {
1064
+ method: 'POST',
1065
+ headers: { 'content-type': 'application/json' },
1066
+ body: JSON.stringify({
1067
+ sources: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local' }],
1068
+ }),
1069
+ })
1070
+ );
1071
+ const data = await response.json();
1072
+
1073
+ expect(response.status).toBe(200);
1074
+ const rootParent = data.sessions.find(
1075
+ (session: any) => session.id === 'local:claude~550e8400-e29b-41d4-a716-446655440000'
1076
+ );
1077
+
1078
+ expect(rootParent).toMatchObject({
1079
+ id: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
1080
+ children: expect.arrayContaining([
1081
+ expect.objectContaining({
1082
+ id: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
1083
+ parentID: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
1084
+ }),
1085
+ expect.objectContaining({
1086
+ id: 'local:claude~770e8400-e29b-41d4-a716-446655440000',
1087
+ parentID: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
1088
+ }),
1089
+ ]),
1090
+ });
1091
+
1092
+ expect(
1093
+ data.sessions.find((session: any) => session.id === 'local:claude~660e8400-e29b-41d4-a716-446655440000')
1094
+ ).toBeUndefined();
1095
+ expect(
1096
+ data.sessions.find((session: any) => session.id === 'local:claude~770e8400-e29b-41d4-a716-446655440000')
1097
+ ).toBeUndefined();
1098
+ });
1099
+
1100
+ it('rebuilds remote Claude child topology without linking unrelated local or cross-provider sessions', async () => {
1101
+ setupLocalSessionsMocks();
1102
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
1103
+ payload: {
1104
+ sessions: [
1105
+ {
1106
+ id: 'claude~550e8400-e29b-41d4-a716-446655440000',
1107
+ slug: '550e8400-e29b-41d4-a716-446655440000',
1108
+ title: 'Local Claude Parent',
1109
+ directory: '/repo/project-one',
1110
+ projectName: 'project-one',
1111
+ branch: 'main',
1112
+ provider: 'claude-code',
1113
+ providerRawId: '550e8400-e29b-41d4-a716-446655440000',
1114
+ rawSessionId: '550e8400-e29b-41d4-a716-446655440000',
1115
+ realTimeStatus: 'busy',
1116
+ waitingForUser: false,
1117
+ readOnly: true,
1118
+ topology: { childSessions: 'authoritative' },
1119
+ children: [],
1120
+ },
1121
+ ],
1122
+ processHints: [],
1123
+ },
1124
+ sourceMeta: { online: true },
1125
+ });
1126
+ mockListNodeRecords.mockResolvedValue([
1127
+ {
1128
+ nodeId: 'remote-claude',
1129
+ nodeLabel: 'Remote Claude',
1130
+ baseUrl: 'https://remote-claude.test',
1131
+ enabled: true,
1132
+ token: 'remote-claude-token',
1133
+ createdAt: new Date().toISOString(),
1134
+ updatedAt: new Date().toISOString(),
1135
+ },
1136
+ ]);
1137
+
1138
+ const mockFetch = vi.fn(async (input: RequestInfo | URL) => {
1139
+ const url = typeof input === 'string' ? input : input.toString();
1140
+ if (url === 'https://remote-claude.test/api/node/sessions') {
1141
+ return new Response(
1142
+ JSON.stringify({
1143
+ ok: true,
1144
+ role: 'node',
1145
+ protocolVersion: NODE_PROTOCOL_VERSION,
1146
+ source: { hostId: 'local', hostLabel: 'Local', hostKind: 'local' },
1147
+ upstream: { kind: 'opencode', reachable: true },
1148
+ sessions: [
1149
+ {
1150
+ id: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
1151
+ rawSessionId: '550e8400-e29b-41d4-a716-446655440000',
1152
+ sourceSessionKey: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
1153
+ title: 'Remote Claude Parent',
1154
+ directory: '/remote/project-one',
1155
+ projectName: 'project-one',
1156
+ branch: null,
1157
+ provider: 'claude-code',
1158
+ providerRawId: '550e8400-e29b-41d4-a716-446655440000',
1159
+ realTimeStatus: 'busy',
1160
+ waitingForUser: false,
1161
+ readOnly: true,
1162
+ topology: { childSessions: 'authoritative' },
1163
+ children: [],
1164
+ time: { created: 2_000, updated: Date.now() - 1_000 },
1165
+ },
1166
+ {
1167
+ id: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
1168
+ rawSessionId: '660e8400-e29b-41d4-a716-446655440000',
1169
+ sourceSessionKey: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
1170
+ parentID: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
1171
+ title: 'Remote Claude Child',
1172
+ directory: '/remote/project-one',
1173
+ projectName: 'project-one',
1174
+ branch: null,
1175
+ provider: 'claude-code',
1176
+ providerRawId: '660e8400-e29b-41d4-a716-446655440000',
1177
+ realTimeStatus: 'busy',
1178
+ waitingForUser: false,
1179
+ readOnly: true,
1180
+ topology: { childSessions: 'authoritative' },
1181
+ children: [],
1182
+ time: { created: 2_100, updated: Date.now() - 900 },
1183
+ },
1184
+ {
1185
+ id: 'local:claude~770e8400-e29b-41d4-a716-446655440000',
1186
+ rawSessionId: '770e8400-e29b-41d4-a716-446655440000',
1187
+ sourceSessionKey: 'local:claude~770e8400-e29b-41d4-a716-446655440000',
1188
+ parentID: 'local:claude~999e8400-e29b-41d4-a716-446655440000',
1189
+ title: 'Remote Claude Orphan',
1190
+ directory: '/remote/project-two',
1191
+ projectName: 'project-two',
1192
+ branch: null,
1193
+ provider: 'claude-code',
1194
+ providerRawId: '770e8400-e29b-41d4-a716-446655440000',
1195
+ realTimeStatus: 'idle',
1196
+ waitingForUser: true,
1197
+ readOnly: true,
1198
+ topology: { childSessions: 'authoritative' },
1199
+ children: [],
1200
+ time: { created: 2_200, updated: Date.now() - 800 },
1201
+ },
1202
+ ],
1203
+ processHints: [],
1204
+ hosts: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local', online: true }],
1205
+ hostStatuses: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local', online: true }],
1206
+ }),
1207
+ { status: 200, headers: { 'content-type': 'application/json' } }
1208
+ );
1209
+ }
1210
+
1211
+ throw new Error(`Unexpected node sessions URL: ${url}`);
1212
+ });
1213
+ vi.stubGlobal('fetch', mockFetch);
1214
+
1215
+ const response = await POST(
1216
+ new Request('http://localhost/api/sessions', {
1217
+ method: 'POST',
1218
+ headers: { 'content-type': 'application/json' },
1219
+ body: JSON.stringify({
1220
+ sources: [
1221
+ { hostId: 'local', hostLabel: 'Local', hostKind: 'local' },
1222
+ {
1223
+ hostId: 'remote-claude',
1224
+ hostLabel: 'Remote Claude',
1225
+ hostKind: 'remote',
1226
+ baseUrl: 'https://remote-claude.test',
1227
+ enabled: true,
1228
+ },
1229
+ ],
1230
+ }),
1231
+ })
1232
+ );
1233
+ const data = await response.json();
1234
+
1235
+ expect(response.status).toBe(200);
1236
+ const remoteParent = data.sessions.find(
1237
+ (session: any) => session.id === 'remote-claude:claude~550e8400-e29b-41d4-a716-446655440000'
1238
+ );
1239
+ expect(remoteParent).toMatchObject({
1240
+ id: 'remote-claude:claude~550e8400-e29b-41d4-a716-446655440000',
1241
+ rawSessionId: '550e8400-e29b-41d4-a716-446655440000',
1242
+ sourceSessionKey: 'remote-claude:claude~550e8400-e29b-41d4-a716-446655440000',
1243
+ hostId: 'remote-claude',
1244
+ hostLabel: 'Remote Claude',
1245
+ hostKind: 'remote',
1246
+ provider: 'claude-code',
1247
+ providerRawId: '550e8400-e29b-41d4-a716-446655440000',
1248
+ readOnly: true,
1249
+ capabilities: {
1250
+ openProject: true,
1251
+ openEditor: false,
1252
+ archive: false,
1253
+ delete: false,
1254
+ },
1255
+ topology: { childSessions: 'authoritative' },
1256
+ });
1257
+ expect(remoteParent.children).toEqual([
1258
+ expect.objectContaining({
1259
+ id: 'remote-claude:claude~660e8400-e29b-41d4-a716-446655440000',
1260
+ parentID: 'remote-claude:claude~550e8400-e29b-41d4-a716-446655440000',
1261
+ rawSessionId: '660e8400-e29b-41d4-a716-446655440000',
1262
+ sourceSessionKey: 'remote-claude:claude~660e8400-e29b-41d4-a716-446655440000',
1263
+ hostId: 'remote-claude',
1264
+ hostLabel: 'Remote Claude',
1265
+ hostKind: 'remote',
1266
+ provider: 'claude-code',
1267
+ providerRawId: '660e8400-e29b-41d4-a716-446655440000',
1268
+ readOnly: true,
1269
+ capabilities: {
1270
+ openProject: true,
1271
+ openEditor: false,
1272
+ archive: false,
1273
+ delete: false,
1274
+ },
1275
+ topology: { childSessions: 'authoritative' },
1276
+ }),
1277
+ ]);
1278
+ const localClaudeParent = data.sessions.find(
1279
+ (session: any) => session.id === 'local:claude~550e8400-e29b-41d4-a716-446655440000'
1280
+ );
1281
+ expect(localClaudeParent.children).toEqual([]);
1282
+ expect(
1283
+ data.sessions.find((session: any) => session.id === 'remote-claude:claude~660e8400-e29b-41d4-a716-446655440000')
1284
+ ).toBeUndefined();
1285
+ expect(data.sessions).toEqual(
1286
+ expect.arrayContaining([
1287
+ expect.objectContaining({
1288
+ id: 'remote-claude:claude~770e8400-e29b-41d4-a716-446655440000',
1289
+ parentID: 'remote-claude:claude~999e8400-e29b-41d4-a716-446655440000',
1290
+ provider: 'claude-code',
1291
+ providerRawId: '770e8400-e29b-41d4-a716-446655440000',
1292
+ rawSessionId: '770e8400-e29b-41d4-a716-446655440000',
1293
+ sourceSessionKey: 'remote-claude:claude~770e8400-e29b-41d4-a716-446655440000',
1294
+ hostId: 'remote-claude',
1295
+ hostLabel: 'Remote Claude',
1296
+ hostKind: 'remote',
1297
+ readOnly: true,
1298
+ capabilities: {
1299
+ openProject: true,
1300
+ openEditor: false,
1301
+ archive: false,
1302
+ delete: false,
1303
+ },
1304
+ topology: { childSessions: 'authoritative' },
1305
+ children: [],
1306
+ }),
1307
+ ])
1308
+ );
1309
+ });
1310
+
1311
+ it('absorbs remote flat Claude rows under matching remote Claude parents', async () => {
1312
+ setupLocalSessionsMocks();
1313
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
1314
+ payload: { sessions: [], processHints: [] },
1315
+ sourceMeta: { online: false },
1316
+ });
1317
+ mockListNodeRecords.mockResolvedValue([
1318
+ {
1319
+ nodeId: 'remote-claude',
1320
+ nodeLabel: 'Remote Claude',
1321
+ baseUrl: 'https://remote-claude.test',
1322
+ enabled: true,
1323
+ token: 'remote-claude-token',
1324
+ createdAt: new Date().toISOString(),
1325
+ updatedAt: new Date().toISOString(),
1326
+ },
1327
+ ]);
1328
+
1329
+ const mockFetch = vi.fn(async (input: RequestInfo | URL) => {
1330
+ const url = typeof input === 'string' ? input : input.toString();
1331
+ if (url === 'https://remote-claude.test/api/node/sessions') {
1332
+ return new Response(
1333
+ JSON.stringify({
1334
+ ok: true,
1335
+ role: 'node',
1336
+ protocolVersion: NODE_PROTOCOL_VERSION,
1337
+ source: { hostId: 'local', hostLabel: 'Local', hostKind: 'local' },
1338
+ upstream: { kind: 'opencode', reachable: true },
1339
+ sessions: [
1340
+ {
1341
+ id: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
1342
+ rawSessionId: '550e8400-e29b-41d4-a716-446655440000',
1343
+ sourceSessionKey: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
1344
+ title: 'Remote Flat Claude Parent',
1345
+ directory: '/remote/project-one',
1346
+ projectName: 'project-one',
1347
+ branch: null,
1348
+ provider: 'claude-code',
1349
+ providerRawId: '550e8400-e29b-41d4-a716-446655440000',
1350
+ realTimeStatus: 'busy',
1351
+ waitingForUser: false,
1352
+ readOnly: true,
1353
+ topology: { childSessions: 'flat' },
1354
+ children: [],
1355
+ time: { created: 2_000, updated: Date.now() - 1_000 },
1356
+ },
1357
+ {
1358
+ id: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
1359
+ rawSessionId: '660e8400-e29b-41d4-a716-446655440000',
1360
+ sourceSessionKey: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
1361
+ parentID: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
1362
+ title: 'Remote Flat Claude Child',
1363
+ directory: '/remote/project-one',
1364
+ projectName: 'project-one',
1365
+ branch: null,
1366
+ provider: 'claude-code',
1367
+ providerRawId: '660e8400-e29b-41d4-a716-446655440000',
1368
+ realTimeStatus: 'busy',
1369
+ waitingForUser: false,
1370
+ readOnly: true,
1371
+ topology: { childSessions: 'flat' },
1372
+ children: [],
1373
+ time: { created: 2_100, updated: Date.now() - 900 },
1374
+ },
1375
+ ],
1376
+ processHints: [],
1377
+ hosts: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local', online: true }],
1378
+ hostStatuses: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local', online: true }],
1379
+ }),
1380
+ { status: 200, headers: { 'content-type': 'application/json' } }
1381
+ );
1382
+ }
1383
+
1384
+ throw new Error(`Unexpected node sessions URL: ${url}`);
1385
+ });
1386
+ vi.stubGlobal('fetch', mockFetch);
1387
+
1388
+ const response = await POST(
1389
+ new Request('http://localhost/api/sessions', {
1390
+ method: 'POST',
1391
+ headers: { 'content-type': 'application/json' },
1392
+ body: JSON.stringify({
1393
+ sources: [
1394
+ { hostId: 'local', hostLabel: 'Local', hostKind: 'local' },
1395
+ {
1396
+ hostId: 'remote-claude',
1397
+ hostLabel: 'Remote Claude',
1398
+ hostKind: 'remote',
1399
+ baseUrl: 'https://remote-claude.test',
1400
+ enabled: true,
1401
+ },
1402
+ ],
1403
+ }),
1404
+ })
1405
+ );
1406
+ const data = await response.json();
1407
+
1408
+ expect(response.status).toBe(200);
1409
+ const remoteParent = data.sessions.find(
1410
+ (session: any) => session.id === 'remote-claude:claude~550e8400-e29b-41d4-a716-446655440000'
1411
+ );
1412
+ expect(remoteParent).toMatchObject({
1413
+ id: 'remote-claude:claude~550e8400-e29b-41d4-a716-446655440000',
1414
+ provider: 'claude-code',
1415
+ capabilities: {
1416
+ openProject: true,
1417
+ openEditor: false,
1418
+ archive: false,
1419
+ delete: false,
1420
+ },
1421
+ topology: { childSessions: 'flat' },
1422
+ hostId: 'remote-claude',
1423
+ hostKind: 'remote',
1424
+ });
1425
+ expect(remoteParent.children).toEqual(
1426
+ expect.arrayContaining([
1427
+ expect.objectContaining({
1428
+ id: 'remote-claude:claude~660e8400-e29b-41d4-a716-446655440000',
1429
+ parentID: 'remote-claude:claude~550e8400-e29b-41d4-a716-446655440000',
1430
+ provider: 'claude-code',
1431
+ capabilities: {
1432
+ openProject: true,
1433
+ openEditor: false,
1434
+ archive: false,
1435
+ delete: false,
1436
+ },
1437
+ topology: { childSessions: 'flat' },
1438
+ hostId: 'remote-claude',
1439
+ hostKind: 'remote',
1440
+ }),
1441
+ ])
1442
+ );
1443
+ expect(
1444
+ data.sessions.find((session: any) => session.id === 'remote-claude:claude~660e8400-e29b-41d4-a716-446655440000')
1445
+ ).toBeUndefined();
1446
+ });
1447
+
1448
+ it('keeps orphan Claude children flat instead of linking them to OpenCode parents with the same raw id', async () => {
1449
+ setupLocalSessionsMocks();
1450
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
1451
+ payload: {
1452
+ sessions: [
1453
+ {
1454
+ id: 'claude~660e8400-e29b-41d4-a716-446655440000',
1455
+ slug: '660e8400-e29b-41d4-a716-446655440000',
1456
+ title: 'Claude Orphan',
1457
+ directory: '/repo/project-one',
1458
+ projectName: 'project-one',
1459
+ branch: 'main',
1460
+ parentID: 'parent-1',
1461
+ provider: 'claude-code',
1462
+ providerRawId: '660e8400-e29b-41d4-a716-446655440000',
1463
+ rawSessionId: '660e8400-e29b-41d4-a716-446655440000',
1464
+ realTimeStatus: 'idle',
1465
+ waitingForUser: true,
1466
+ readOnly: true,
1467
+ topology: { childSessions: 'authoritative' },
1468
+ children: [],
1469
+ },
1470
+ ],
1471
+ processHints: [],
1472
+ },
1473
+ sourceMeta: { online: true },
1474
+ });
1475
+
1476
+ const response = await POST(
1477
+ new Request('http://localhost/api/sessions', {
1478
+ method: 'POST',
1479
+ headers: { 'content-type': 'application/json' },
1480
+ body: JSON.stringify({
1481
+ sources: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local' }],
1482
+ }),
1483
+ })
1484
+ );
1485
+ const data = await response.json();
1486
+
1487
+ expect(response.status).toBe(200);
1488
+ const openCodeParent = data.sessions.find((session: any) => session.id === 'local:parent-1');
1489
+ expect(openCodeParent.children).toEqual([
1490
+ expect.objectContaining({ id: 'local:child-1', parentID: 'local:parent-1' }),
1491
+ ]);
1492
+ expect(openCodeParent.children).not.toEqual(
1493
+ expect.arrayContaining([
1494
+ expect.objectContaining({ id: 'local:claude~660e8400-e29b-41d4-a716-446655440000' }),
1495
+ ])
1496
+ );
1497
+ expect(data.sessions).toEqual(
1498
+ expect.arrayContaining([
1499
+ expect.objectContaining({
1500
+ id: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
1501
+ parentID: 'local:parent-1',
1502
+ provider: 'claude-code',
1503
+ providerRawId: '660e8400-e29b-41d4-a716-446655440000',
1504
+ rawSessionId: '660e8400-e29b-41d4-a716-446655440000',
1505
+ sourceSessionKey: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
1506
+ readOnly: true,
1507
+ topology: { childSessions: 'authoritative' },
1508
+ children: [],
1509
+ }),
1510
+ ])
1511
+ );
1512
+ });
1513
+
1514
+ it('absorbs flat Claude rows with parent ids under matching Claude parents', async () => {
1515
+ setupLocalSessionsMocks();
1516
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
1517
+ payload: {
1518
+ sessions: [
1519
+ {
1520
+ id: 'claude~550e8400-e29b-41d4-a716-446655440000',
1521
+ slug: '550e8400-e29b-41d4-a716-446655440000',
1522
+ title: 'Flat Claude Parent',
1523
+ directory: '/repo/project-one',
1524
+ projectName: 'project-one',
1525
+ branch: 'main',
1526
+ provider: 'claude-code',
1527
+ providerRawId: '550e8400-e29b-41d4-a716-446655440000',
1528
+ rawSessionId: '550e8400-e29b-41d4-a716-446655440000',
1529
+ realTimeStatus: 'busy',
1530
+ waitingForUser: false,
1531
+ readOnly: true,
1532
+ topology: { childSessions: 'flat' },
1533
+ children: [],
1534
+ time: { created: 2_000, updated: Date.now() - 1_000 },
1535
+ },
1536
+ {
1537
+ id: 'claude~660e8400-e29b-41d4-a716-446655440000',
1538
+ slug: '660e8400-e29b-41d4-a716-446655440000',
1539
+ title: 'Flat Claude Child',
1540
+ directory: '/repo/project-one',
1541
+ projectName: 'project-one',
1542
+ branch: 'main',
1543
+ parentID: 'claude~550e8400-e29b-41d4-a716-446655440000',
1544
+ provider: 'claude-code',
1545
+ providerRawId: '660e8400-e29b-41d4-a716-446655440000',
1546
+ rawSessionId: '660e8400-e29b-41d4-a716-446655440000',
1547
+ realTimeStatus: 'busy',
1548
+ waitingForUser: false,
1549
+ readOnly: true,
1550
+ topology: { childSessions: 'flat' },
1551
+ children: [],
1552
+ time: { created: 2_100, updated: Date.now() - 900 },
1553
+ },
1554
+ ],
1555
+ processHints: [],
1556
+ },
1557
+ sourceMeta: { online: true },
1558
+ });
1559
+
1560
+ const response = await POST(
1561
+ new Request('http://localhost/api/sessions', {
1562
+ method: 'POST',
1563
+ headers: { 'content-type': 'application/json' },
1564
+ body: JSON.stringify({
1565
+ sources: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local' }],
1566
+ }),
1567
+ })
1568
+ );
1569
+ const data = await response.json();
1570
+
1571
+ expect(response.status).toBe(200);
1572
+ expect(data.sessions).toEqual(
1573
+ expect.arrayContaining([
1574
+ expect.objectContaining({
1575
+ id: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
1576
+ topology: { childSessions: 'flat' },
1577
+ }),
1578
+ ])
1579
+ );
1580
+ const flatParent = data.sessions.find(
1581
+ (session: any) => session.id === 'local:claude~550e8400-e29b-41d4-a716-446655440000'
1582
+ );
1583
+ expect(flatParent.children).toEqual(
1584
+ expect.arrayContaining([
1585
+ expect.objectContaining({
1586
+ id: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
1587
+ parentID: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
1588
+ topology: { childSessions: 'flat' },
1589
+ }),
1590
+ ])
1591
+ );
1592
+ expect(data.sessions).not.toEqual(
1593
+ expect.arrayContaining([
1594
+ expect.objectContaining({
1595
+ id: 'local:claude~660e8400-e29b-41d4-a716-446655440000',
1596
+ parentID: 'local:claude~550e8400-e29b-41d4-a716-446655440000',
1597
+ }),
1598
+ ])
1599
+ );
1600
+ });
1601
+
1602
+ it('infers flat Claude child ownership without parent ids using directory and recent creation window', async () => {
1603
+ setupLocalSessionsMocks();
1604
+ const now = Date.now();
1605
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
1606
+ payload: {
1607
+ sessions: [
1608
+ {
1609
+ id: 'claude~111e8400-e29b-41d4-a716-446655440000',
1610
+ slug: '111e8400-e29b-41d4-a716-446655440000',
1611
+ title: 'Flat Claude Parent (Inferred)',
1612
+ directory: '/repo/project-two',
1613
+ projectName: 'project-two',
1614
+ branch: 'main',
1615
+ provider: 'claude-code',
1616
+ providerRawId: '111e8400-e29b-41d4-a716-446655440000',
1617
+ rawSessionId: '111e8400-e29b-41d4-a716-446655440000',
1618
+ realTimeStatus: 'busy',
1619
+ waitingForUser: false,
1620
+ readOnly: true,
1621
+ topology: { childSessions: 'flat' },
1622
+ children: [],
1623
+ time: { created: now - 5_000, updated: now - 500 },
1624
+ },
1625
+ {
1626
+ id: 'claude~222e8400-e29b-41d4-a716-446655440000',
1627
+ slug: '222e8400-e29b-41d4-a716-446655440000',
1628
+ title: 'Flat Claude Child (Inferred)',
1629
+ directory: '/repo/project-two',
1630
+ projectName: 'project-two',
1631
+ branch: 'main',
1632
+ provider: 'claude-code',
1633
+ providerRawId: '222e8400-e29b-41d4-a716-446655440000',
1634
+ rawSessionId: '222e8400-e29b-41d4-a716-446655440000',
1635
+ realTimeStatus: 'busy',
1636
+ waitingForUser: false,
1637
+ readOnly: true,
1638
+ topology: { childSessions: 'flat' },
1639
+ children: [],
1640
+ time: { created: now - 4_970, updated: now - 300 },
1641
+ },
1642
+ ],
1643
+ processHints: [],
1644
+ },
1645
+ sourceMeta: { online: true },
1646
+ });
1647
+
1648
+ const response = await POST(
1649
+ new Request('http://localhost/api/sessions', {
1650
+ method: 'POST',
1651
+ headers: { 'content-type': 'application/json' },
1652
+ body: JSON.stringify({
1653
+ sources: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local' }],
1654
+ }),
1655
+ })
1656
+ );
1657
+ const data = await response.json();
1658
+
1659
+ expect(response.status).toBe(200);
1660
+ const inferredParent = data.sessions.find(
1661
+ (session: any) => session.id === 'local:claude~111e8400-e29b-41d4-a716-446655440000'
1662
+ );
1663
+ expect(inferredParent.children).toEqual(
1664
+ expect.arrayContaining([
1665
+ expect.objectContaining({
1666
+ id: 'local:claude~222e8400-e29b-41d4-a716-446655440000',
1667
+ parentID: 'local:claude~111e8400-e29b-41d4-a716-446655440000',
1668
+ topology: { childSessions: 'flat' },
1669
+ }),
1670
+ ])
1671
+ );
1672
+ expect(
1673
+ data.sessions.find((session: any) => session.id === 'local:claude~222e8400-e29b-41d4-a716-446655440000')
1674
+ ).toBeUndefined();
1675
+ });
1676
+
1677
+ it('keeps flat Claude rows top-level when candidate parent is outside inference window', async () => {
1678
+ setupLocalSessionsMocks();
1679
+ const now = Date.now();
1680
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
1681
+ payload: {
1682
+ sessions: [
1683
+ {
1684
+ id: 'claude~333e8400-e29b-41d4-a716-446655440000',
1685
+ slug: '333e8400-e29b-41d4-a716-446655440000',
1686
+ title: 'Flat Claude Parent (Too Old)',
1687
+ directory: '/repo/project-three',
1688
+ projectName: 'project-three',
1689
+ branch: 'main',
1690
+ provider: 'claude-code',
1691
+ providerRawId: '333e8400-e29b-41d4-a716-446655440000',
1692
+ rawSessionId: '333e8400-e29b-41d4-a716-446655440000',
1693
+ realTimeStatus: 'busy',
1694
+ waitingForUser: false,
1695
+ readOnly: true,
1696
+ topology: { childSessions: 'flat' },
1697
+ children: [],
1698
+ time: { created: now - 10 * 60_000, updated: now - 30_000 },
1699
+ },
1700
+ {
1701
+ id: 'claude~444e8400-e29b-41d4-a716-446655440000',
1702
+ slug: '444e8400-e29b-41d4-a716-446655440000',
1703
+ title: 'Flat Claude Child (Too New)',
1704
+ directory: '/repo/project-three',
1705
+ projectName: 'project-three',
1706
+ branch: 'main',
1707
+ provider: 'claude-code',
1708
+ providerRawId: '444e8400-e29b-41d4-a716-446655440000',
1709
+ rawSessionId: '444e8400-e29b-41d4-a716-446655440000',
1710
+ realTimeStatus: 'busy',
1711
+ waitingForUser: false,
1712
+ readOnly: true,
1713
+ topology: { childSessions: 'flat' },
1714
+ children: [],
1715
+ time: { created: now - 20_000, updated: now - 1_000 },
1716
+ },
1717
+ ],
1718
+ processHints: [],
1719
+ },
1720
+ sourceMeta: { online: true },
1721
+ });
1722
+
1723
+ const response = await POST(
1724
+ new Request('http://localhost/api/sessions', {
1725
+ method: 'POST',
1726
+ headers: { 'content-type': 'application/json' },
1727
+ body: JSON.stringify({
1728
+ sources: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local' }],
1729
+ }),
1730
+ })
1731
+ );
1732
+ const data = await response.json();
1733
+
1734
+ expect(response.status).toBe(200);
1735
+ const oldParent = data.sessions.find(
1736
+ (session: any) => session.id === 'local:claude~333e8400-e29b-41d4-a716-446655440000'
1737
+ );
1738
+ expect(oldParent.children).toEqual([]);
1739
+ expect(data.sessions).toEqual(
1740
+ expect.arrayContaining([
1741
+ expect.objectContaining({ id: 'local:claude~333e8400-e29b-41d4-a716-446655440000' }),
1742
+ expect.objectContaining({ id: 'local:claude~444e8400-e29b-41d4-a716-446655440000' }),
1743
+ ])
1744
+ );
1745
+ });
1746
+
1747
+ it('keeps inferred Claude child top-level when multiple parent candidates are ambiguous', async () => {
1748
+ setupLocalSessionsMocks();
1749
+ const now = Date.now();
1750
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
1751
+ payload: {
1752
+ sessions: [
1753
+ {
1754
+ id: 'claude~555e8400-e29b-41d4-a716-446655440000',
1755
+ slug: '555e8400-e29b-41d4-a716-446655440000',
1756
+ title: 'Flat Claude Parent Candidate A',
1757
+ directory: '/repo/project-ambiguity',
1758
+ projectName: 'project-ambiguity',
1759
+ branch: 'main',
1760
+ provider: 'claude-code',
1761
+ providerRawId: '555e8400-e29b-41d4-a716-446655440000',
1762
+ rawSessionId: '555e8400-e29b-41d4-a716-446655440000',
1763
+ realTimeStatus: 'busy',
1764
+ waitingForUser: false,
1765
+ readOnly: true,
1766
+ topology: { childSessions: 'flat' },
1767
+ children: [],
1768
+ time: { created: now - 5_000, updated: now - 500 },
1769
+ },
1770
+ {
1771
+ id: 'claude~666e8400-e29b-41d4-a716-446655440000',
1772
+ slug: '666e8400-e29b-41d4-a716-446655440000',
1773
+ title: 'Flat Claude Parent Candidate B',
1774
+ directory: '/repo/project-ambiguity',
1775
+ projectName: 'project-ambiguity',
1776
+ branch: 'main',
1777
+ provider: 'claude-code',
1778
+ providerRawId: '666e8400-e29b-41d4-a716-446655440000',
1779
+ rawSessionId: '666e8400-e29b-41d4-a716-446655440000',
1780
+ realTimeStatus: 'busy',
1781
+ waitingForUser: false,
1782
+ readOnly: true,
1783
+ topology: { childSessions: 'flat' },
1784
+ children: [],
1785
+ time: { created: now - 4_998, updated: now - 480 },
1786
+ },
1787
+ {
1788
+ id: 'claude~777e8400-e29b-41d4-a716-446655440000',
1789
+ slug: '777e8400-e29b-41d4-a716-446655440000',
1790
+ title: 'Flat Claude Child Ambiguous',
1791
+ directory: '/repo/project-ambiguity',
1792
+ projectName: 'project-ambiguity',
1793
+ branch: 'main',
1794
+ provider: 'claude-code',
1795
+ providerRawId: '777e8400-e29b-41d4-a716-446655440000',
1796
+ rawSessionId: '777e8400-e29b-41d4-a716-446655440000',
1797
+ realTimeStatus: 'busy',
1798
+ waitingForUser: false,
1799
+ readOnly: true,
1800
+ topology: { childSessions: 'flat' },
1801
+ children: [],
1802
+ time: { created: now - 4_970, updated: now - 300 },
1803
+ },
1804
+ ],
1805
+ processHints: [],
1806
+ },
1807
+ sourceMeta: { online: true },
1808
+ });
1809
+
1810
+ const response = await POST(
1811
+ new Request('http://localhost/api/sessions', {
1812
+ method: 'POST',
1813
+ headers: { 'content-type': 'application/json' },
1814
+ body: JSON.stringify({
1815
+ sources: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local' }],
1816
+ }),
1817
+ })
1818
+ );
1819
+ const data = await response.json();
1820
+
1821
+ expect(response.status).toBe(200);
1822
+ const candidateA = data.sessions.find(
1823
+ (session: any) => session.id === 'local:claude~555e8400-e29b-41d4-a716-446655440000'
1824
+ );
1825
+ const candidateAChildIds = (candidateA.children ?? []).map((child: any) => child.id);
1826
+ expect(candidateAChildIds).not.toContain('local:claude~777e8400-e29b-41d4-a716-446655440000');
1827
+ expect(data.sessions).toEqual(
1828
+ expect.arrayContaining([
1829
+ expect.objectContaining({ id: 'local:claude~777e8400-e29b-41d4-a716-446655440000' }),
1830
+ ])
1831
+ );
265
1832
  });
266
- });
267
1833
 
268
- describe('/api/sessions route source handling', () => {
269
- const originalRuntimeRole = process.env.VIBEPULSE_RUNTIME_ROLE;
1834
+ it('keeps inferred Claude child top-level when directory does not match', async () => {
1835
+ setupLocalSessionsMocks();
1836
+ const now = Date.now();
1837
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
1838
+ payload: {
1839
+ sessions: [
1840
+ {
1841
+ id: 'claude~888e8400-e29b-41d4-a716-446655440000',
1842
+ slug: '888e8400-e29b-41d4-a716-446655440000',
1843
+ title: 'Flat Claude Parent (Dir A)',
1844
+ directory: '/repo/project-dir-a',
1845
+ projectName: 'project-dir',
1846
+ branch: 'main',
1847
+ provider: 'claude-code',
1848
+ providerRawId: '888e8400-e29b-41d4-a716-446655440000',
1849
+ rawSessionId: '888e8400-e29b-41d4-a716-446655440000',
1850
+ realTimeStatus: 'busy',
1851
+ waitingForUser: false,
1852
+ readOnly: true,
1853
+ topology: { childSessions: 'flat' },
1854
+ children: [],
1855
+ time: { created: now - 5_000, updated: now - 500 },
1856
+ },
1857
+ {
1858
+ id: 'claude~999e8400-e29b-41d4-a716-446655440000',
1859
+ slug: '999e8400-e29b-41d4-a716-446655440000',
1860
+ title: 'Flat Claude Child (Dir B)',
1861
+ directory: '/repo/project-dir-b',
1862
+ projectName: 'project-dir',
1863
+ branch: 'main',
1864
+ provider: 'claude-code',
1865
+ providerRawId: '999e8400-e29b-41d4-a716-446655440000',
1866
+ rawSessionId: '999e8400-e29b-41d4-a716-446655440000',
1867
+ realTimeStatus: 'busy',
1868
+ waitingForUser: false,
1869
+ readOnly: true,
1870
+ topology: { childSessions: 'flat' },
1871
+ children: [],
1872
+ time: { created: now - 4_970, updated: now - 300 },
1873
+ },
1874
+ ],
1875
+ processHints: [],
1876
+ },
1877
+ sourceMeta: { online: true },
1878
+ });
270
1879
 
271
- beforeEach(() => {
272
- process.env.VIBEPULSE_RUNTIME_ROLE = 'hub';
1880
+ const response = await POST(
1881
+ new Request('http://localhost/api/sessions', {
1882
+ method: 'POST',
1883
+ headers: { 'content-type': 'application/json' },
1884
+ body: JSON.stringify({
1885
+ sources: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local' }],
1886
+ }),
1887
+ })
1888
+ );
1889
+ const data = await response.json();
1890
+
1891
+ expect(response.status).toBe(200);
1892
+ const parent = data.sessions.find(
1893
+ (session: any) => session.id === 'local:claude~888e8400-e29b-41d4-a716-446655440000'
1894
+ );
1895
+ expect(parent.children).toEqual([]);
1896
+ expect(data.sessions).toEqual(
1897
+ expect.arrayContaining([
1898
+ expect.objectContaining({ id: 'local:claude~999e8400-e29b-41d4-a716-446655440000' }),
1899
+ ])
1900
+ );
273
1901
  });
274
1902
 
275
- afterEach(() => {
276
- process.env.VIBEPULSE_RUNTIME_ROLE = originalRuntimeRole;
1903
+ it('keeps inferred Claude child top-level when project name does not match', async () => {
1904
+ setupLocalSessionsMocks();
1905
+ const now = Date.now();
1906
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
1907
+ payload: {
1908
+ sessions: [
1909
+ {
1910
+ id: 'claude~aaa08400-e29b-41d4-a716-446655440000',
1911
+ slug: 'aaa08400-e29b-41d4-a716-446655440000',
1912
+ title: 'Flat Claude Parent (Project A)',
1913
+ directory: '/repo/project-name-shared',
1914
+ projectName: 'project-name-a',
1915
+ branch: 'main',
1916
+ provider: 'claude-code',
1917
+ providerRawId: 'aaa08400-e29b-41d4-a716-446655440000',
1918
+ rawSessionId: 'aaa08400-e29b-41d4-a716-446655440000',
1919
+ realTimeStatus: 'busy',
1920
+ waitingForUser: false,
1921
+ readOnly: true,
1922
+ topology: { childSessions: 'flat' },
1923
+ children: [],
1924
+ time: { created: now - 5_000, updated: now - 500 },
1925
+ },
1926
+ {
1927
+ id: 'claude~bbb08400-e29b-41d4-a716-446655440000',
1928
+ slug: 'bbb08400-e29b-41d4-a716-446655440000',
1929
+ title: 'Flat Claude Child (Project B)',
1930
+ directory: '/repo/project-name-shared',
1931
+ projectName: 'project-name-b',
1932
+ branch: 'main',
1933
+ provider: 'claude-code',
1934
+ providerRawId: 'bbb08400-e29b-41d4-a716-446655440000',
1935
+ rawSessionId: 'bbb08400-e29b-41d4-a716-446655440000',
1936
+ realTimeStatus: 'busy',
1937
+ waitingForUser: false,
1938
+ readOnly: true,
1939
+ topology: { childSessions: 'flat' },
1940
+ children: [],
1941
+ time: { created: now - 4_970, updated: now - 300 },
1942
+ },
1943
+ ],
1944
+ processHints: [],
1945
+ },
1946
+ sourceMeta: { online: true },
1947
+ });
1948
+
1949
+ const response = await POST(
1950
+ new Request('http://localhost/api/sessions', {
1951
+ method: 'POST',
1952
+ headers: { 'content-type': 'application/json' },
1953
+ body: JSON.stringify({
1954
+ sources: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local' }],
1955
+ }),
1956
+ })
1957
+ );
1958
+ const data = await response.json();
1959
+
1960
+ expect(response.status).toBe(200);
1961
+ const parent = data.sessions.find(
1962
+ (session: any) => session.id === 'local:claude~aaa08400-e29b-41d4-a716-446655440000'
1963
+ );
1964
+ expect(parent.children).toEqual([]);
1965
+ expect(data.sessions).toEqual(
1966
+ expect.arrayContaining([
1967
+ expect.objectContaining({ id: 'local:claude~bbb08400-e29b-41d4-a716-446655440000' }),
1968
+ ])
1969
+ );
277
1970
  });
278
1971
 
279
- it('enforces local-only aggregation in node mode even when remote sources are requested', async () => {
280
- process.env.VIBEPULSE_RUNTIME_ROLE = 'node';
1972
+ it('does not infer Claude ownership across hosts when only remote parent candidate exists', async () => {
281
1973
  setupLocalSessionsMocks();
1974
+ const now = Date.now();
1975
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
1976
+ payload: {
1977
+ sessions: [
1978
+ {
1979
+ id: 'claude~ccc08400-e29b-41d4-a716-446655440000',
1980
+ slug: 'ccc08400-e29b-41d4-a716-446655440000',
1981
+ title: 'Local Flat Claude Child (No Local Parent)',
1982
+ directory: '/repo/shared-host-guard',
1983
+ projectName: 'shared-host-guard',
1984
+ branch: 'main',
1985
+ provider: 'claude-code',
1986
+ providerRawId: 'ccc08400-e29b-41d4-a716-446655440000',
1987
+ rawSessionId: 'ccc08400-e29b-41d4-a716-446655440000',
1988
+ realTimeStatus: 'busy',
1989
+ waitingForUser: false,
1990
+ readOnly: true,
1991
+ topology: { childSessions: 'flat' },
1992
+ children: [],
1993
+ time: { created: now - 4_970, updated: now - 300 },
1994
+ },
1995
+ ],
1996
+ processHints: [],
1997
+ },
1998
+ sourceMeta: { online: true },
1999
+ });
282
2000
  mockListNodeRecords.mockResolvedValue([
283
2001
  {
284
- nodeId: 'remote-a',
285
- nodeLabel: 'Remote A',
286
- baseUrl: 'https://remote-a.test',
2002
+ nodeId: 'remote-claude',
2003
+ nodeLabel: 'Remote Claude',
2004
+ baseUrl: 'https://remote-claude.test',
287
2005
  enabled: true,
288
- token: 'token-a',
2006
+ token: 'remote-claude-token',
289
2007
  createdAt: new Date().toISOString(),
290
2008
  updatedAt: new Date().toISOString(),
291
2009
  },
292
2010
  ]);
293
2011
 
294
- const mockFetch: any = vi.fn();
2012
+ const mockFetch = vi.fn(async (input: RequestInfo | URL) => {
2013
+ const url = typeof input === 'string' ? input : input.toString();
2014
+ if (url === 'https://remote-claude.test/api/node/sessions') {
2015
+ return new Response(
2016
+ JSON.stringify({
2017
+ ok: true,
2018
+ role: 'node',
2019
+ protocolVersion: NODE_PROTOCOL_VERSION,
2020
+ source: { hostId: 'local', hostLabel: 'Local', hostKind: 'local' },
2021
+ upstream: { kind: 'opencode', reachable: true },
2022
+ sessions: [
2023
+ {
2024
+ id: 'local:claude~ddd08400-e29b-41d4-a716-446655440000',
2025
+ rawSessionId: 'ddd08400-e29b-41d4-a716-446655440000',
2026
+ sourceSessionKey: 'local:claude~ddd08400-e29b-41d4-a716-446655440000',
2027
+ title: 'Remote Flat Claude Parent Candidate',
2028
+ directory: '/repo/shared-host-guard',
2029
+ projectName: 'shared-host-guard',
2030
+ branch: null,
2031
+ provider: 'claude-code',
2032
+ providerRawId: 'ddd08400-e29b-41d4-a716-446655440000',
2033
+ realTimeStatus: 'busy',
2034
+ waitingForUser: false,
2035
+ readOnly: true,
2036
+ topology: { childSessions: 'flat' },
2037
+ children: [],
2038
+ time: { created: now - 5_000, updated: now - 500 },
2039
+ },
2040
+ ],
2041
+ processHints: [],
2042
+ hosts: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local', online: true }],
2043
+ hostStatuses: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local', online: true }],
2044
+ }),
2045
+ { status: 200, headers: { 'content-type': 'application/json' } }
2046
+ );
2047
+ }
2048
+
2049
+ throw new Error(`Unexpected node sessions URL: ${url}`);
2050
+ });
295
2051
  vi.stubGlobal('fetch', mockFetch);
296
2052
 
297
2053
  const response = await POST(
@@ -302,10 +2058,10 @@ describe('/api/sessions route source handling', () => {
302
2058
  sources: [
303
2059
  { hostId: 'local', hostLabel: 'Local', hostKind: 'local' },
304
2060
  {
305
- hostId: 'remote-a',
306
- hostLabel: 'Remote A',
2061
+ hostId: 'remote-claude',
2062
+ hostLabel: 'Remote Claude',
307
2063
  hostKind: 'remote',
308
- baseUrl: 'https://remote-a.test',
2064
+ baseUrl: 'https://remote-claude.test',
309
2065
  enabled: true,
310
2066
  },
311
2067
  ],
@@ -315,22 +2071,40 @@ describe('/api/sessions route source handling', () => {
315
2071
  const data = await response.json();
316
2072
 
317
2073
  expect(response.status).toBe(200);
318
- expect(data.hostStatuses).toEqual([
319
- { hostId: 'local', hostLabel: 'Local', hostKind: 'local', online: true },
320
- ]);
321
- expect(data.hosts).toEqual(data.hostStatuses);
322
- expect(data.sessions.every((session: any) => session.hostId === 'local')).toBe(true);
323
- expect(mockFetch.mock.calls).toHaveLength(0);
324
- expect(mockListNodeRecords.mock.calls).toHaveLength(0);
2074
+ const remoteParent = data.sessions.find(
2075
+ (session: any) => session.id === 'remote-claude:claude~ddd08400-e29b-41d4-a716-446655440000'
2076
+ );
2077
+ const localChild = data.sessions.find(
2078
+ (session: any) => session.id === 'local:claude~ccc08400-e29b-41d4-a716-446655440000'
2079
+ );
2080
+
2081
+ expect(remoteParent.children).toEqual([]);
2082
+ expect(localChild).toBeDefined();
2083
+ expect(localChild.parentID).toBeUndefined();
325
2084
  });
326
2085
 
327
- it('keeps GET local aggregation behavior working without request host config', async () => {
2086
+ it('keeps OpenCode-only local polling behavior when Claude artifacts are missing or empty', async () => {
328
2087
  setupLocalSessionsMocks();
2088
+ mockClaudeLocalProviderGetSessionsResult.mockResolvedValue({
2089
+ payload: { sessions: [], processHints: [] },
2090
+ sourceMeta: { online: false },
2091
+ });
329
2092
 
330
2093
  const response = await GET();
331
2094
  const data = await response.json();
332
2095
 
333
2096
  expect(response.status).toBe(200);
2097
+ expect(data.sessions).toHaveLength(1);
2098
+ expect(data.sessions[0]).toMatchObject({
2099
+ id: 'parent-1',
2100
+ children: [
2101
+ {
2102
+ id: 'child-1',
2103
+ },
2104
+ ],
2105
+ });
2106
+ expect(data.sessions[0]).not.toHaveProperty('provider');
2107
+ expect(data.sessions[0]).not.toHaveProperty('readOnly');
334
2108
  expect(data.processHints).toEqual([
335
2109
  {
336
2110
  pid: 321,
@@ -339,87 +2113,6 @@ describe('/api/sessions route source handling', () => {
339
2113
  reason: 'process_without_api_port',
340
2114
  },
341
2115
  ]);
342
- expect(data.sessions).toHaveLength(1);
343
-
344
- const session = data.sessions[0];
345
- expect(session.id).toBe('parent-1');
346
- expect(session.slug).toBe('parent-1');
347
- expect(session.title).toBe('Parent Session');
348
- expect(session.directory).toBe('/repo/project-one');
349
- expect(session.time.created).toBe(1_000);
350
- expect(typeof session.time.updated).toBe('number');
351
- expect(session.projectName).toBe('project-one');
352
- expect(session.branch).toBe('main');
353
- expect(session.realTimeStatus).toBe('busy');
354
- expect(session.waitingForUser).toBe(false);
355
- expect(session.children).toHaveLength(1);
356
-
357
- const child = session.children[0];
358
- expect(child.id).toBe('child-1');
359
- expect(child.title).toBe('Child Session');
360
- expect(child.directory).toBe('/repo/project-one');
361
- expect(child.parentID).toBe('parent-1');
362
- expect(child.time?.created).toBe(1_100);
363
- expect(typeof child.time?.updated).toBe('number');
364
- expect(child.realTimeStatus).toBe('busy');
365
- expect(child.waitingForUser).toBe(true);
366
- });
367
-
368
- it('returns host-aware Local identities for POST when only the Local source is requested', async () => {
369
- setupLocalSessionsMocks();
370
-
371
- const getResponse = await GET();
372
- const getData = await getResponse.json();
373
-
374
- const postResponse = await POST(
375
- new Request('http://localhost/api/sessions', {
376
- method: 'POST',
377
- headers: { 'content-type': 'application/json' },
378
- body: JSON.stringify({
379
- sources: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local' }],
380
- }),
381
- })
382
- );
383
- const postData = await postResponse.json();
384
-
385
- expect(getResponse.status).toBe(200);
386
- expect(postResponse.status).toBe(200);
387
- expect(postData.sessions).toHaveLength(1);
388
- expect(postData.processHints).toEqual(getData.processHints);
389
- expect(postData.hostStatuses).toEqual([
390
- { hostId: 'local', hostLabel: 'Local', hostKind: 'local', online: true },
391
- ]);
392
- expect(postData.hosts).toEqual(postData.hostStatuses);
393
-
394
- expect(postData.sessions[0]).toMatchObject({
395
- id: 'local:parent-1',
396
- slug: getData.sessions[0].slug,
397
- title: getData.sessions[0].title,
398
- directory: getData.sessions[0].directory,
399
- time: getData.sessions[0].time,
400
- projectName: getData.sessions[0].projectName,
401
- branch: getData.sessions[0].branch,
402
- realTimeStatus: getData.sessions[0].realTimeStatus,
403
- waitingForUser: getData.sessions[0].waitingForUser,
404
- rawSessionId: 'parent-1',
405
- sourceSessionKey: 'local:parent-1',
406
- hostId: 'local',
407
- hostLabel: 'Local',
408
- hostKind: 'local',
409
- readOnly: false,
410
- });
411
- expect(postData.sessions[0].children).toHaveLength(1);
412
- expect(postData.sessions[0].children[0]).toMatchObject({
413
- ...getData.sessions[0].children[0],
414
- id: 'local:child-1',
415
- parentID: 'local:parent-1',
416
- rawSessionId: 'child-1',
417
- sourceSessionKey: 'local:child-1',
418
- hostId: 'local',
419
- hostLabel: 'Local',
420
- hostKind: 'local',
421
- readOnly: false,
422
- });
423
2116
  });
424
2117
 
425
2118
  it('keeps GET offline behavior but returns a degraded error payload for local-only POST when Local is offline', async () => {
@@ -970,6 +2663,95 @@ describe('/api/sessions route source handling', () => {
970
2663
  });
971
2664
  });
972
2665
 
2666
+ it('accepts remote node payloads with archived:null and normalizes archived away', async () => {
2667
+ setupLocalSessionsMocks();
2668
+ mockListNodeRecords.mockResolvedValue([
2669
+ {
2670
+ nodeId: 'remote-null-archived',
2671
+ nodeLabel: 'Remote Null Archived',
2672
+ baseUrl: 'https://remote-null-archived.test',
2673
+ enabled: true,
2674
+ token: 'null-archived-token',
2675
+ createdAt: new Date().toISOString(),
2676
+ updatedAt: new Date().toISOString(),
2677
+ },
2678
+ ]);
2679
+
2680
+ const mockFetch = vi.fn(async (input: RequestInfo | URL) => {
2681
+ const url = typeof input === 'string' ? input : input.toString();
2682
+ if (url === 'https://remote-null-archived.test/api/node/sessions') {
2683
+ return new Response(
2684
+ JSON.stringify({
2685
+ ok: true,
2686
+ role: 'node',
2687
+ protocolVersion: NODE_PROTOCOL_VERSION,
2688
+ source: { hostId: 'local', hostLabel: 'Local', hostKind: 'local' },
2689
+ upstream: { kind: 'opencode', reachable: true },
2690
+ sessions: [
2691
+ {
2692
+ id: 'ses_remote_1',
2693
+ rawSessionId: 'ses_remote_1',
2694
+ title: 'Remote Session',
2695
+ directory: '/remote/project',
2696
+ projectName: 'remote-project',
2697
+ branch: 'main',
2698
+ realTimeStatus: 'idle',
2699
+ waitingForUser: false,
2700
+ time: { created: 2_000, updated: 2_500, archived: null },
2701
+ children: [
2702
+ {
2703
+ id: 'child_remote_1',
2704
+ rawSessionId: 'child_remote_1',
2705
+ parentID: 'ses_remote_1',
2706
+ title: 'Child Session',
2707
+ directory: '/remote/project',
2708
+ realTimeStatus: 'idle',
2709
+ waitingForUser: false,
2710
+ time: { created: 2_100, updated: 2_400, archived: null },
2711
+ },
2712
+ ],
2713
+ },
2714
+ ],
2715
+ processHints: [],
2716
+ hosts: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local', online: true }],
2717
+ hostStatuses: [{ hostId: 'local', hostLabel: 'Local', hostKind: 'local', online: true }],
2718
+ }),
2719
+ { status: 200, headers: { 'content-type': 'application/json' } }
2720
+ );
2721
+ }
2722
+
2723
+ throw new Error(`Unexpected node sessions URL: ${url}`);
2724
+ });
2725
+ vi.stubGlobal('fetch', mockFetch);
2726
+
2727
+ const response = await POST(
2728
+ new Request('http://localhost/api/sessions', {
2729
+ method: 'POST',
2730
+ headers: { 'content-type': 'application/json' },
2731
+ body: JSON.stringify({
2732
+ sources: [
2733
+ {
2734
+ hostId: 'remote-null-archived',
2735
+ hostLabel: 'Remote Null Archived',
2736
+ hostKind: 'remote',
2737
+ baseUrl: 'https://remote-null-archived.test',
2738
+ enabled: true,
2739
+ },
2740
+ ],
2741
+ }),
2742
+ })
2743
+ );
2744
+ const data = await response.json();
2745
+
2746
+ expect(response.status).toBe(200);
2747
+ const session = data.sessions.find((entry: any) => entry.id === 'remote-null-archived:ses_remote_1');
2748
+ expect(session).toBeTruthy();
2749
+ expect(session.time).toEqual({ created: 2_000, updated: 2_500 });
2750
+ expect(session.time).not.toHaveProperty('archived');
2751
+ expect(session.children[0].time).toEqual({ created: 2_100, updated: 2_400 });
2752
+ expect(session.children[0].time).not.toHaveProperty('archived');
2753
+ });
2754
+
973
2755
  it('returns 400 for invalid remote source entries', async () => {
974
2756
  const invalidRemoteUrlResponse = await POST(
975
2757
  new Request('http://localhost/api/sessions', {