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
@@ -1,152 +1,36 @@
1
- import { createOpencodeClient } from '@opencode-ai/sdk';
2
- import { execSync } from 'child_process';
3
- import path from 'path';
4
- import {
5
- discoverOpencodePortsWithMeta,
6
- discoverOpencodeProcessCwdsWithoutPortWithMeta,
7
- } from '@/lib/opencodeDiscovery';
8
1
  import { readConfig } from '@/lib/opencodeConfig';
2
+ import { claudeCodeLocalSessionProvider } from '@/lib/session-providers/claudeCode';
3
+ import {
4
+ applyStickyBusyStatus,
5
+ applyStickyStatusStabilization,
6
+ getLocalSessionsResult,
7
+ shouldSkipSessionStatusStabilization,
8
+ } from '@/lib/session-providers/localAggregator';
9
+ import { opencodeLocalSessionProvider } from '@/lib/session-providers/opencodeProvider';
10
+ import type {
11
+ ChildEntry,
12
+ EnrichedSession,
13
+ HostAwareFields,
14
+ ProcessHint,
15
+ SessionHostStatus,
16
+ SessionsRouteResult,
17
+ SessionsSuccessPayload,
18
+ SessionSource,
19
+ SourceResultMeta,
20
+ } from '@/lib/session-providers/types';
9
21
  import {
10
22
  clearSessionForceUnarchived,
11
- markSessionForceUnarchived,
12
- pruneSessionStickyStatusBlocked,
13
- pruneSessionForceUnarchived,
14
- shouldForceSessionUnarchived,
15
- takeSessionStickyStatusBlocked,
16
23
  } from '@/lib/sessionArchiveOverrides';
17
24
  import { composeSourceKey, parseSourceKey } from '@/lib/hostIdentity';
18
25
  import { createNodeRequestHeaders, NODE_PROTOCOL_VERSION } from '@/lib/nodeProtocol';
26
+ import { composeProviderSourceKey, detectProviderFromRawId, extractProviderRawId } from '@/lib/session-providers/providerIds';
19
27
  import { listNodeRecords, type StoredNodeRecord } from '@/lib/nodeRegistry';
20
28
  import { RUNTIME_ROLE_ENV_VAR } from '@/lib/runtimeMode';
21
- import type { BuiltInHostSource, RemoteHostConfig } from '@/types';
22
-
23
- type SessionLike = {
24
- id: string;
25
- slug?: string;
26
- title?: string;
27
- directory: string;
28
- debugReason?: string;
29
- parentID?: string;
30
- time?: {
31
- created: number;
32
- updated: number;
33
- archived?: number;
34
- };
35
- };
29
+ import type { BuiltInHostSource, RemoteHostConfig, SessionCapabilities, SessionProvider } from '@/types';
36
30
 
37
- const CHILD_ACTIVE_WINDOW_MS = 30 * 60 * 1000;
38
- const CHILD_UNKNOWN_STATE_BUSY_WINDOW_MS = 2 * 60 * 1000;
39
- const CHILD_STATUS_MESSAGE_CHECK_LIMIT = 50;
40
- const STALL_DETECTION_WINDOW_MS = 30 * 1000;
41
- const STATUS_STICKY_RETENTION_MS = 24 * 60 * 60 * 1000;
42
- const STATUS_STICKY_ABSENT_RETENTION_MS = 30 * 60 * 1000;
43
- const DEFAULT_STATUS_STICKY_MAX_ENTRIES = 5000;
44
- const GIT_COMMAND_TIMEOUT_MS = 1200;
45
- const sessionListTimeoutMs = readPositiveTimeoutEnv('OPENCODE_SESSIONS_LIST_TIMEOUT_MS', 6000);
46
- const sessionStatusTimeoutMs = readPositiveTimeoutEnv('OPENCODE_SESSIONS_STATUS_TIMEOUT_MS', 4000);
47
- const sessionMessagesTimeoutMs = readPositiveTimeoutEnv('OPENCODE_SESSIONS_MESSAGES_TIMEOUT_MS', 2500);
48
31
  const nodeSessionsTimeoutMs = readPositiveTimeoutEnv('VIBEPULSE_NODE_SESSIONS_TIMEOUT_MS', 6000);
49
-
50
- type StableRealtimeStatus = 'idle' | 'busy' | 'retry';
51
-
52
- type StatusStickyState = {
53
- lastBusyAt: number;
54
- lastSeenAt: number;
55
- };
56
-
57
- const statusStickyState = new Map<string, StatusStickyState>();
58
-
59
- function clearStickyStatusState(sessionId: string): void {
60
- statusStickyState.delete(sessionId);
61
- statusStickyState.delete(`child:${sessionId}`);
62
- }
63
-
64
- type ChildEntry = HostAwareFields & {
65
- id: string;
66
- slug?: string;
67
- title?: string;
68
- directory?: string;
69
- debugReason?: string;
70
- parentID?: string;
71
- time?: { created: number; updated: number; archived?: number };
72
- realTimeStatus: string;
73
- waitingForUser: boolean;
74
- };
75
-
76
- type EnrichedSession = SessionLike & HostAwareFields & {
77
- projectName: string;
78
- branch: string | null;
79
- realTimeStatus: 'idle' | 'busy' | 'retry';
80
- waitingForUser: boolean;
81
- children: ChildEntry[];
82
- };
83
-
84
- type SessionStatusStabilizationTarget = {
85
- id: string;
86
- time?: {
87
- archived?: number;
88
- };
89
- realTimeStatus: string;
90
- waitingForUser: boolean;
91
- children: Array<{
92
- id: string;
93
- time?: {
94
- archived?: number;
95
- };
96
- realTimeStatus: string;
97
- waitingForUser: boolean;
98
- }>;
99
- };
100
-
101
- type ProcessHint = {
102
- pid: number;
103
- directory: string;
104
- projectName: string;
105
- reason: 'process_without_api_port';
106
- };
107
-
108
- type SessionSource = BuiltInHostSource | (RemoteHostConfig & { hostKind: 'remote' });
109
-
110
- type HostAwareFields = {
111
- hostId?: string;
112
- hostLabel?: string;
113
- hostKind?: SessionSource['hostKind'];
114
- hostBaseUrl?: string;
115
- rawSessionId?: string;
116
- sourceSessionKey?: string;
117
- readOnly?: boolean;
118
- };
119
-
120
- type SessionHostStatus = {
121
- hostId: string;
122
- hostLabel: string;
123
- hostKind: SessionSource['hostKind'];
124
- online: boolean;
125
- degraded?: boolean;
126
- reason?: string;
127
- baseUrl?: string;
128
- };
129
-
130
- type SourceResultMeta = {
131
- online: boolean;
132
- degraded?: boolean;
133
- reason?: string;
134
- };
135
-
136
- type SessionsSuccessPayload = {
137
- sessions: EnrichedSession[];
138
- processHints: ProcessHint[];
139
- failedPorts?: Array<{ port: number; reason: string }>;
140
- degraded?: boolean;
141
- hosts?: SessionHostStatus[];
142
- hostStatuses?: SessionHostStatus[];
143
- };
144
-
145
- type SessionsRouteResult = {
146
- payload: SessionsSuccessPayload | Record<string, unknown>;
147
- status?: number;
148
- sourceMeta?: SourceResultMeta;
149
- };
32
+ const CLAUDE_INFERRED_PARENT_MAX_CREATED_GAP_MS = 60_000;
33
+ const CLAUDE_INFERRED_PARENT_AMBIGUITY_GAP_MS = 5_000;
150
34
 
151
35
  const LOCAL_SOURCE: BuiltInHostSource = {
152
36
  hostId: 'local',
@@ -154,8 +38,16 @@ const LOCAL_SOURCE: BuiltInHostSource = {
154
38
  hostKind: 'local',
155
39
  };
156
40
 
41
+ const LOCAL_POLLING_PROVIDERS = [opencodeLocalSessionProvider, claudeCodeLocalSessionProvider] as const;
42
+
157
43
  export const dynamic = 'force-dynamic';
158
44
 
45
+ export {
46
+ applyStickyBusyStatus,
47
+ applyStickyStatusStabilization,
48
+ shouldSkipSessionStatusStabilization,
49
+ };
50
+
159
51
  export async function GET() {
160
52
  return handleGet();
161
53
  }
@@ -164,14 +56,6 @@ export async function POST(request: Request) {
164
56
  return handlePost(request);
165
57
  }
166
58
 
167
- type MessageStateStatus = string;
168
-
169
- type MessagePart = {
170
- state?: {
171
- status?: unknown;
172
- };
173
- };
174
-
175
59
  function readPositiveTimeoutEnv(name: string, fallback: number): number {
176
60
  const raw = process.env[name];
177
61
  const parsed = Number(raw);
@@ -181,300 +65,6 @@ function readPositiveTimeoutEnv(name: string, fallback: number): number {
181
65
  return fallback;
182
66
  }
183
67
 
184
- function withTimeout<T>(operation: (signal: AbortSignal) => Promise<T>, timeoutMs: number, label: string): Promise<T> {
185
- const timeoutError = new Error(`${label} timed out after ${timeoutMs}ms`);
186
- const timeoutController = new AbortController();
187
- let timeoutHandle: NodeJS.Timeout | undefined;
188
- const timeoutPromise = new Promise<never>((_, reject) => {
189
- timeoutHandle = setTimeout(() => {
190
- timeoutController.abort();
191
- reject(timeoutError);
192
- }, timeoutMs);
193
- });
194
-
195
- const operationPromise = operation(timeoutController.signal).catch((error) => {
196
- if (timeoutController.signal.aborted) {
197
- throw timeoutError;
198
- }
199
-
200
- throw error;
201
- });
202
-
203
- return Promise.race([operationPromise, timeoutPromise]).finally(() => {
204
- if (timeoutHandle) {
205
- clearTimeout(timeoutHandle);
206
- }
207
- });
208
- }
209
-
210
- const WAITING_PART_STATUSES = new Set<string>([
211
- 'awaiting-input',
212
- 'awaiting_input',
213
- 'input-required',
214
- 'input_required',
215
- 'requires-input',
216
- 'requires_input',
217
- 'blocked',
218
- 'paused',
219
- ]);
220
-
221
- function normalizePartStatus(status: string): string {
222
- return status.trim().toLowerCase();
223
- }
224
-
225
- function isWaitingPartStatus(status: string): boolean {
226
- return WAITING_PART_STATUSES.has(normalizePartStatus(status));
227
- }
228
-
229
- function collectPartStatuses(messages: Array<{ parts?: MessagePart[] }>): MessageStateStatus[] {
230
- const partStatuses: MessageStateStatus[] = [];
231
-
232
- for (const message of messages) {
233
- for (const part of message.parts || []) {
234
- const status = part?.state?.status;
235
- if (typeof status === 'string') {
236
- const normalized = normalizePartStatus(status);
237
- if (normalized) {
238
- partStatuses.push(normalized);
239
- }
240
- }
241
- }
242
- }
243
-
244
- return partStatuses;
245
- }
246
-
247
- async function fetchPartStatuses(
248
- client: ReturnType<typeof createOpencodeClient>,
249
- sessionId: string,
250
- timeoutMs: number
251
- ): Promise<MessageStateStatus[]> {
252
- const messagesResult = await withTimeout(
253
- (signal) =>
254
- client.session.messages({
255
- path: { id: sessionId },
256
- query: { limit: 8 },
257
- signal,
258
- }),
259
- timeoutMs,
260
- `session.messages(${sessionId})`
261
- );
262
- const messages = (messagesResult.data || []) as Array<{ parts?: MessagePart[] }>;
263
- return collectPartStatuses(messages);
264
- }
265
-
266
- function getUpdatedAt(session: { time?: { updated?: number; created?: number } }): number {
267
- return session.time?.updated || session.time?.created || 0;
268
- }
269
-
270
- function normalizeRealtimeStatus(value: string | undefined): StableRealtimeStatus {
271
- if (value === 'busy' || value === 'retry') return value;
272
- return 'idle';
273
- }
274
-
275
- export function applyStickyBusyStatus(id: string, status: StableRealtimeStatus, now: number, stickyBusyWindowMs: number): StableRealtimeStatus {
276
- const existing = statusStickyState.get(id) ?? { lastBusyAt: 0, lastSeenAt: now };
277
-
278
- if (status === 'busy') {
279
- existing.lastBusyAt = now;
280
- existing.lastSeenAt = now;
281
- statusStickyState.set(id, existing);
282
- return status;
283
- }
284
-
285
- if (status === 'retry') {
286
- existing.lastSeenAt = now;
287
- statusStickyState.set(id, existing);
288
- return status;
289
- }
290
-
291
- const shouldKeepBusy = existing.lastBusyAt > 0 && now - existing.lastBusyAt <= stickyBusyWindowMs;
292
- existing.lastSeenAt = now;
293
- statusStickyState.set(id, existing);
294
- return shouldKeepBusy ? 'busy' : 'idle';
295
- }
296
-
297
- function getStickyStateMaxEntries(): number {
298
- const raw = Number(process.env.OPENCODE_STATUS_STICKY_MAX_ENTRIES);
299
- if (Number.isFinite(raw) && raw > 0) {
300
- return Math.floor(raw);
301
- }
302
- return DEFAULT_STATUS_STICKY_MAX_ENTRIES;
303
- }
304
-
305
- function pruneStickyState(now: number, activeIds: Set<string>): void {
306
- for (const [id, state] of statusStickyState) {
307
- const ageMs = now - state.lastSeenAt;
308
- const isActive = activeIds.has(id);
309
- if (ageMs > STATUS_STICKY_RETENTION_MS || (!isActive && ageMs > STATUS_STICKY_ABSENT_RETENTION_MS)) {
310
- statusStickyState.delete(id);
311
- }
312
- }
313
-
314
- const maxEntries = getStickyStateMaxEntries();
315
- if (statusStickyState.size <= maxEntries) {
316
- return;
317
- }
318
-
319
- const overflow = statusStickyState.size - maxEntries;
320
- const sortedByLastSeen = Array.from(statusStickyState.entries()).sort((a, b) => a[1].lastSeenAt - b[1].lastSeenAt);
321
-
322
- let removed = 0;
323
- for (const [id] of sortedByLastSeen) {
324
- if (removed >= overflow) break;
325
- if (activeIds.has(id)) continue;
326
- statusStickyState.delete(id);
327
- removed++;
328
- }
329
-
330
- if (removed >= overflow) {
331
- return;
332
- }
333
-
334
- for (const [id] of sortedByLastSeen) {
335
- if (removed >= overflow) break;
336
- if (!statusStickyState.has(id)) continue;
337
- statusStickyState.delete(id);
338
- removed++;
339
- }
340
- }
341
-
342
- function hasRecentActivity(session: { time?: { updated?: number } }, now: number): boolean {
343
- const updatedAt = session.time?.updated;
344
- if (!updatedAt) return false;
345
- return now - updatedAt <= STALL_DETECTION_WINDOW_MS;
346
- }
347
-
348
- function toChildEntry(
349
- child: SessionLike,
350
- status: 'idle' | 'busy' | 'retry',
351
- waitingForUser = false
352
- ): ChildEntry {
353
- return {
354
- id: child.id,
355
- slug: child.slug,
356
- title: child.title,
357
- directory: child.directory,
358
- debugReason: child.debugReason,
359
- parentID: child.parentID,
360
- time: child.time,
361
- realTimeStatus: status,
362
- waitingForUser,
363
- };
364
- }
365
-
366
- function clearSessionStabilizationState(session: SessionStatusStabilizationTarget): void {
367
- clearStickyStatusState(session.id);
368
- clearSessionForceUnarchived(session.id);
369
- for (const child of session.children) {
370
- clearStickyStatusState(`child:${child.id}`);
371
- clearSessionForceUnarchived(child.id);
372
- }
373
- }
374
-
375
- export function shouldSkipSessionStatusStabilization(
376
- session: SessionStatusStabilizationTarget,
377
- now: number
378
- ): boolean {
379
- if (takeSessionStickyStatusBlocked(session.id, now)) {
380
- clearSessionStabilizationState(session);
381
- return true;
382
- }
383
-
384
- if (session.time?.archived) {
385
- clearSessionStabilizationState(session);
386
- return true;
387
- }
388
-
389
- return false;
390
- }
391
-
392
- export function applyStickyStatusStabilization(
393
- session: SessionStatusStabilizationTarget,
394
- stickyNow: number,
395
- stickyBusyDelayMs: number
396
- ): void {
397
- for (const child of session.children) {
398
- if (child.time?.archived) {
399
- clearStickyStatusState(`child:${child.id}`);
400
- clearSessionForceUnarchived(child.id);
401
- continue;
402
- }
403
-
404
- const normalizedChildStatus = normalizeRealtimeStatus(child.realTimeStatus);
405
- const childStatusForStabilization =
406
- child.waitingForUser && normalizedChildStatus === 'idle' ? 'retry' : normalizedChildStatus;
407
- child.realTimeStatus = applyStickyBusyStatus(
408
- `child:${child.id}`,
409
- childStatusForStabilization,
410
- stickyNow,
411
- stickyBusyDelayMs
412
- );
413
-
414
- if (child.realTimeStatus === 'busy' || child.realTimeStatus === 'retry' || child.waitingForUser) {
415
- markSessionForceUnarchived(child.id, stickyNow);
416
- }
417
- }
418
-
419
- const normalizedSessionStatus = normalizeRealtimeStatus(session.realTimeStatus);
420
- const sessionStatusForStabilization =
421
- session.waitingForUser && normalizedSessionStatus === 'idle' ? 'retry' : normalizedSessionStatus;
422
- session.realTimeStatus = applyStickyBusyStatus(
423
- session.id,
424
- sessionStatusForStabilization,
425
- stickyNow,
426
- stickyBusyDelayMs
427
- );
428
-
429
- const hasActiveChildren = session.children.some(
430
- (child) => child.realTimeStatus === 'busy' || child.realTimeStatus === 'retry' || child.waitingForUser
431
- );
432
- const shouldAutoUnarchive =
433
- session.realTimeStatus === 'busy' ||
434
- session.realTimeStatus === 'retry' ||
435
- session.waitingForUser ||
436
- hasActiveChildren;
437
-
438
- if (shouldAutoUnarchive) {
439
- markSessionForceUnarchived(session.id, stickyNow);
440
- }
441
- }
442
- // Get project name from directory path
443
- function getProjectName(directory: string): string {
444
- return path.basename(directory);
445
- }
446
-
447
- // Check if directory is a git repository
448
- function isGitRepo(directory: string): boolean {
449
- try {
450
- const result = execSync('git rev-parse --is-inside-work-tree', {
451
- cwd: directory,
452
- encoding: 'utf-8',
453
- stdio: ['ignore', 'pipe', 'ignore'],
454
- timeout: GIT_COMMAND_TIMEOUT_MS,
455
- });
456
- return result.trim() === 'true';
457
- } catch {
458
- return false;
459
- }
460
- }
461
-
462
- // Get git branch name
463
- function getGitBranch(directory: string): string | null {
464
- if (!isGitRepo(directory)) return null;
465
- try {
466
- const branch = execSync('git branch --show-current', {
467
- cwd: directory,
468
- encoding: 'utf-8',
469
- stdio: ['ignore', 'pipe', 'ignore'],
470
- timeout: GIT_COMMAND_TIMEOUT_MS,
471
- });
472
- return branch.trim() || null;
473
- } catch {
474
- return null;
475
- }
476
- }
477
-
478
68
  async function readStickyBusyDelayMs(): Promise<number> {
479
69
  let stickyBusyDelayMs = 1000; // default 1s
480
70
  try {
@@ -502,6 +92,23 @@ function isRemoteSource(source: SessionSource): source is RemoteHostConfig & { h
502
92
  return source.hostKind === 'remote';
503
93
  }
504
94
 
95
+ function getRemoteClaudeCapabilities(
96
+ capabilities: SessionCapabilities | undefined,
97
+ provider: SessionProvider,
98
+ source: SessionSource
99
+ ): SessionCapabilities | undefined {
100
+ if (!isRemoteSource(source) || provider !== 'claude-code') {
101
+ return capabilities;
102
+ }
103
+
104
+ return {
105
+ openProject: capabilities?.openProject ?? true,
106
+ openEditor: false,
107
+ archive: false,
108
+ delete: false,
109
+ };
110
+ }
111
+
505
112
  function normalizeNodeBaseUrl(baseUrl: string): string | null {
506
113
  const trimmed = baseUrl.trim();
507
114
  if (!trimmed) {
@@ -551,10 +158,26 @@ function isTimeValue(value: unknown): boolean {
551
158
  return (
552
159
  typeof created === 'number' &&
553
160
  typeof updated === 'number' &&
554
- (archived === undefined || typeof archived === 'number')
161
+ (archived === undefined || archived === null || typeof archived === 'number')
555
162
  );
556
163
  }
557
164
 
165
+ function normalizeSessionTimeValue<T extends { created: number; updated: number; archived?: number } | undefined>(time: T): T {
166
+ if (!time) {
167
+ return time;
168
+ }
169
+
170
+ const archived = (time as { archived?: number | null }).archived;
171
+ if (archived === null) {
172
+ return {
173
+ created: time.created,
174
+ updated: time.updated,
175
+ } as T;
176
+ }
177
+
178
+ return time;
179
+ }
180
+
558
181
  function isChildEntryValue(value: unknown): value is ChildEntry {
559
182
  if (!isRecord(value)) {
560
183
  return false;
@@ -784,6 +407,10 @@ function toRawSessionId(value: string): string {
784
407
  }
785
408
  }
786
409
 
410
+ function toProviderRawSessionId(value: string): string {
411
+ return extractProviderRawId(toRawSessionId(value));
412
+ }
413
+
787
414
  function composeSourceKeySafely(hostId: string, sessionId: string): string | undefined {
788
415
  try {
789
416
  return composeSourceKey(hostId, sessionId);
@@ -792,17 +419,58 @@ function composeSourceKeySafely(hostId: string, sessionId: string): string | und
792
419
  }
793
420
  }
794
421
 
795
- function addHostMetadataToChildEntry(child: ChildEntry, source: SessionSource): ChildEntry | null {
796
- const rawSessionId = child.rawSessionId ?? toRawSessionId(child.id);
797
- const rawParentId = child.parentID ? toRawSessionId(child.parentID) : child.parentID;
798
- const sourceSessionKey = composeSourceKeySafely(source.hostId, rawSessionId);
422
+ function composeProviderSourceKeySafely(
423
+ hostId: string,
424
+ rawId: string,
425
+ readOnly?: boolean,
426
+ provider?: SessionProvider
427
+ ): string | undefined {
428
+ try {
429
+ return composeProviderSourceKey(hostId, rawId, {
430
+ readOnly,
431
+ ...(provider ? { provider } : {}),
432
+ }).sourceKey;
433
+ } catch {
434
+ return undefined;
435
+ }
436
+ }
437
+
438
+ function addHostMetadataToChildEntry(
439
+ child: ChildEntry,
440
+ source: SessionSource,
441
+ parentSourceSessionKey?: string
442
+ ): ChildEntry | null {
443
+ const rawSessionId = child.rawSessionId ?? toProviderRawSessionId(child.id);
444
+ const rawParentId = child.parentID ? toProviderRawSessionId(child.parentID) : child.parentID;
445
+ const inferredProvider = detectProviderFromRawId(child.id);
446
+ const parentProvider = parentSourceSessionKey ? detectProviderFromRawId(parentSourceSessionKey) : undefined;
447
+ const childProvider = inferredProvider === 'claude-code' ? inferredProvider : (parentProvider ?? inferredProvider);
448
+ const childCapabilities = getRemoteClaudeCapabilities(child.capabilities, childProvider, source);
449
+ const sourceSessionKey = composeProviderSourceKeySafely(source.hostId, rawSessionId, child.readOnly, childProvider);
799
450
  if (!sourceSessionKey) {
800
451
  return null;
801
452
  }
802
453
 
454
+ const parentSourceRawId = parentSourceSessionKey
455
+ ? toProviderRawSessionId(parentSourceSessionKey)
456
+ : undefined;
457
+ const shouldReuseParentSourceKey =
458
+ typeof rawParentId === 'string'
459
+ && typeof parentSourceSessionKey === 'string'
460
+ && parentSourceRawId === rawParentId;
461
+
803
462
  const sourceParentKey = rawParentId
804
- ? (composeSourceKeySafely(source.hostId, rawParentId) ?? undefined)
463
+ ? (
464
+ shouldReuseParentSourceKey
465
+ ? parentSourceSessionKey
466
+ : composeProviderSourceKeySafely(source.hostId, rawParentId, undefined, childProvider) ?? undefined
467
+ )
805
468
  : undefined;
469
+ const normalizedChildProvider = child.provider ?? (childProvider === 'claude-code' ? childProvider : undefined);
470
+ const normalizedChildProviderRawId =
471
+ normalizedChildProvider === 'claude-code'
472
+ ? (child.providerRawId ?? rawSessionId)
473
+ : child.providerRawId;
806
474
 
807
475
  return {
808
476
  ...child,
@@ -812,26 +480,37 @@ function addHostMetadataToChildEntry(child: ChildEntry, source: SessionSource):
812
480
  hostLabel: source.hostLabel,
813
481
  hostKind: source.hostKind,
814
482
  ...(isRemoteSource(source) ? { hostBaseUrl: source.baseUrl } : {}),
483
+ time: normalizeSessionTimeValue(child.time),
815
484
  rawSessionId,
816
485
  sourceSessionKey,
817
- readOnly: false,
486
+ readOnly: child.readOnly ?? false,
487
+ capabilities: childCapabilities,
488
+ ...(normalizedChildProvider ? { provider: normalizedChildProvider } : {}),
489
+ ...(normalizedChildProviderRawId ? { providerRawId: normalizedChildProviderRawId } : {}),
818
490
  };
819
491
  }
820
492
 
821
493
  function addHostMetadataToSession(session: EnrichedSession, source: SessionSource): EnrichedSession | null {
822
- const rawSessionId = session.rawSessionId ?? toRawSessionId(session.id);
823
- const rawParentId = session.parentID ? toRawSessionId(session.parentID) : session.parentID;
824
- const sourceSessionKey = composeSourceKeySafely(source.hostId, rawSessionId);
494
+ const rawSessionId = session.rawSessionId ?? toProviderRawSessionId(session.id);
495
+ const rawParentId = session.parentID ? toProviderRawSessionId(session.parentID) : session.parentID;
496
+ const sessionProvider = detectProviderFromRawId(session.id);
497
+ const sessionCapabilities = getRemoteClaudeCapabilities(session.capabilities, sessionProvider, source);
498
+ const sourceSessionKey = composeProviderSourceKeySafely(source.hostId, rawSessionId, session.readOnly, sessionProvider);
825
499
  if (!sourceSessionKey) {
826
500
  return null;
827
501
  }
828
502
 
829
503
  const sourceParentKey = rawParentId
830
- ? (composeSourceKeySafely(source.hostId, rawParentId) ?? undefined)
504
+ ? (composeProviderSourceKeySafely(source.hostId, rawParentId, undefined, sessionProvider) ?? undefined)
831
505
  : undefined;
506
+ const normalizedSessionProvider = session.provider ?? (sessionProvider === 'claude-code' ? sessionProvider : undefined);
507
+ const normalizedSessionProviderRawId =
508
+ normalizedSessionProvider === 'claude-code'
509
+ ? (session.providerRawId ?? rawSessionId)
510
+ : session.providerRawId;
832
511
  const children: ChildEntry[] = [];
833
512
  for (const child of session.children) {
834
- const enrichedChild = addHostMetadataToChildEntry(child, source);
513
+ const enrichedChild = addHostMetadataToChildEntry(child, source, sourceSessionKey);
835
514
  if (enrichedChild) {
836
515
  children.push(enrichedChild);
837
516
  }
@@ -845,9 +524,13 @@ function addHostMetadataToSession(session: EnrichedSession, source: SessionSourc
845
524
  hostLabel: source.hostLabel,
846
525
  hostKind: source.hostKind,
847
526
  ...(isRemoteSource(source) ? { hostBaseUrl: source.baseUrl } : {}),
527
+ time: normalizeSessionTimeValue(session.time),
848
528
  rawSessionId,
849
529
  sourceSessionKey,
850
- readOnly: false,
530
+ readOnly: session.readOnly ?? false,
531
+ capabilities: sessionCapabilities,
532
+ ...(normalizedSessionProvider ? { provider: normalizedSessionProvider } : {}),
533
+ ...(normalizedSessionProviderRawId ? { providerRawId: normalizedSessionProviderRawId } : {}),
851
534
  children,
852
535
  };
853
536
  }
@@ -873,11 +556,234 @@ function addHostMetadataToPayload(payload: Record<string, unknown>, source: Sess
873
556
 
874
557
  return {
875
558
  ...payload,
876
- sessions,
559
+ sessions: rebuildAggregateClaudeTopology(sessions),
877
560
  ...(payloadDegraded || droppedSessions > 0 ? { degraded: true } : {}),
878
561
  };
879
562
  }
880
563
 
564
+ function toAggregateClaudeChildEntry(session: EnrichedSession): ChildEntry {
565
+ const { children: _children, ...childEntry } = session;
566
+ return childEntry as ChildEntry;
567
+ }
568
+
569
+ function hasClaudeProvider(entry: HostAwareFields): boolean {
570
+ return entry.provider === 'claude-code';
571
+ }
572
+
573
+ function toFiniteTimestamp(value: unknown): number | undefined {
574
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
575
+ }
576
+
577
+ function getCreatedAt(session: EnrichedSession): number | undefined {
578
+ return toFiniteTimestamp(session.time?.created);
579
+ }
580
+
581
+ function getUpdatedAt(session: EnrichedSession): number | undefined {
582
+ return toFiniteTimestamp(session.time?.updated);
583
+ }
584
+
585
+ function hasSameHostIdentity(a: HostAwareFields, b: HostAwareFields): boolean {
586
+ return a.hostId === b.hostId && a.hostKind === b.hostKind;
587
+ }
588
+
589
+ type InferredClaudeParentCandidate = {
590
+ parent: EnrichedSession;
591
+ createdGapMs: number;
592
+ updatedGapMs: number;
593
+ };
594
+
595
+ function inferClaudeParentSession(
596
+ child: EnrichedSession,
597
+ sessions: EnrichedSession[],
598
+ absorbedChildIds: Set<string>
599
+ ): EnrichedSession | undefined {
600
+ if (child.topology?.childSessions === 'authoritative') {
601
+ return undefined;
602
+ }
603
+
604
+ const childCreatedAt = getCreatedAt(child);
605
+ if (childCreatedAt === undefined) {
606
+ return undefined;
607
+ }
608
+
609
+ const childUpdatedAt = getUpdatedAt(child) ?? childCreatedAt;
610
+ const candidates: InferredClaudeParentCandidate[] = [];
611
+
612
+ for (const parent of sessions) {
613
+ if (parent.id === child.id || absorbedChildIds.has(parent.id)) {
614
+ continue;
615
+ }
616
+
617
+ if (!hasClaudeProvider(parent) || !hasSameHostIdentity(parent, child)) {
618
+ continue;
619
+ }
620
+
621
+ if (typeof parent.parentID === 'string') {
622
+ continue;
623
+ }
624
+
625
+ if (parent.directory !== child.directory || parent.projectName !== child.projectName) {
626
+ continue;
627
+ }
628
+
629
+ const parentCreatedAt = getCreatedAt(parent);
630
+ if (parentCreatedAt === undefined || parentCreatedAt > childCreatedAt) {
631
+ continue;
632
+ }
633
+
634
+ const createdGapMs = childCreatedAt - parentCreatedAt;
635
+ if (createdGapMs > CLAUDE_INFERRED_PARENT_MAX_CREATED_GAP_MS) {
636
+ continue;
637
+ }
638
+
639
+ const parentLooksActive =
640
+ parent.realTimeStatus === 'busy' ||
641
+ parent.realTimeStatus === 'retry' ||
642
+ parent.waitingForUser;
643
+ if (!parentLooksActive) {
644
+ continue;
645
+ }
646
+
647
+ const parentUpdatedAt = getUpdatedAt(parent) ?? parentCreatedAt;
648
+ const updatedGapMs = Math.abs(childUpdatedAt - parentUpdatedAt);
649
+
650
+ candidates.push({
651
+ parent,
652
+ createdGapMs,
653
+ updatedGapMs,
654
+ });
655
+ }
656
+
657
+ if (candidates.length === 0) {
658
+ return undefined;
659
+ }
660
+
661
+ candidates.sort((a, b) => {
662
+ if (a.createdGapMs !== b.createdGapMs) {
663
+ return a.createdGapMs - b.createdGapMs;
664
+ }
665
+ return a.updatedGapMs - b.updatedGapMs;
666
+ });
667
+
668
+ const bestCandidate = candidates[0];
669
+ const secondCandidate = candidates[1];
670
+
671
+ if (
672
+ secondCandidate
673
+ && Math.abs(secondCandidate.createdGapMs - bestCandidate.createdGapMs) <= CLAUDE_INFERRED_PARENT_AMBIGUITY_GAP_MS
674
+ ) {
675
+ return undefined;
676
+ }
677
+
678
+ return bestCandidate.parent;
679
+ }
680
+
681
+ function rebuildAggregateClaudeTopology(sessions: EnrichedSession[]): EnrichedSession[] {
682
+ const sessionsById = new Map(sessions.map((session) => [session.id, session]));
683
+ const absorbedChildIds = new Set<string>();
684
+ const resolveVisibleClaudeParent = (
685
+ childSession: EnrichedSession,
686
+ parentSession: EnrichedSession
687
+ ): EnrichedSession | undefined => {
688
+ let cursor: EnrichedSession | undefined = parentSession;
689
+ const visited = new Set<string>([childSession.id]);
690
+
691
+ while (cursor) {
692
+ if (visited.has(cursor.id)) {
693
+ return undefined;
694
+ }
695
+
696
+ visited.add(cursor.id);
697
+
698
+ if (!absorbedChildIds.has(cursor.id)) {
699
+ return cursor;
700
+ }
701
+
702
+ if (typeof cursor.parentID !== 'string') {
703
+ return undefined;
704
+ }
705
+
706
+ cursor = sessionsById.get(cursor.parentID);
707
+ }
708
+
709
+ return undefined;
710
+ };
711
+
712
+ const orderedSessions = [...sessions].sort((a, b) => {
713
+ const createdDiff = (getCreatedAt(b) ?? 0) - (getCreatedAt(a) ?? 0);
714
+ if (createdDiff !== 0) {
715
+ return createdDiff;
716
+ }
717
+
718
+ return (getUpdatedAt(b) ?? 0) - (getUpdatedAt(a) ?? 0);
719
+ });
720
+
721
+ for (const session of orderedSessions) {
722
+ if (!hasClaudeProvider(session) || absorbedChildIds.has(session.id)) {
723
+ continue;
724
+ }
725
+
726
+ let parentSession: EnrichedSession | undefined;
727
+
728
+ if (typeof session.parentID === 'string') {
729
+ const explicitParentSession = sessionsById.get(session.parentID);
730
+ if (explicitParentSession) {
731
+ if (
732
+ explicitParentSession === session
733
+ || !hasClaudeProvider(explicitParentSession)
734
+ || !hasSameHostIdentity(explicitParentSession, session)
735
+ ) {
736
+ continue;
737
+ }
738
+
739
+ const visibleParentSession = resolveVisibleClaudeParent(session, explicitParentSession);
740
+ if (!visibleParentSession) {
741
+ if (absorbedChildIds.has(explicitParentSession.id)) {
742
+ session.parentID = undefined;
743
+ }
744
+ continue;
745
+ }
746
+
747
+ parentSession = visibleParentSession;
748
+ if (session.parentID !== visibleParentSession.id) {
749
+ session.parentID = visibleParentSession.id;
750
+ }
751
+ }
752
+ }
753
+
754
+ if (!parentSession) {
755
+ parentSession = inferClaudeParentSession(session, sessions, absorbedChildIds);
756
+ if (parentSession) {
757
+ session.parentID = parentSession.id;
758
+ }
759
+ }
760
+
761
+ if (!parentSession) {
762
+ continue;
763
+ }
764
+
765
+ if (session.children.length > 0) {
766
+ continue;
767
+ }
768
+
769
+ const childEntry = toAggregateClaudeChildEntry(session);
770
+ const existingChildIndex = parentSession.children.findIndex((child) => child.id === childEntry.id);
771
+ if (existingChildIndex >= 0) {
772
+ parentSession.children[existingChildIndex] = {
773
+ ...parentSession.children[existingChildIndex],
774
+ ...childEntry,
775
+ };
776
+ } else {
777
+ parentSession.children.push(childEntry);
778
+ }
779
+
780
+ sortChildEntries(parentSession.children);
781
+ absorbedChildIds.add(session.id);
782
+ }
783
+
784
+ return sessions.filter((session) => !absorbedChildIds.has(session.id));
785
+ }
786
+
881
787
  function sortChildEntries(children: ChildEntry[]): void {
882
788
  children.sort((a, b) => {
883
789
  const aActive = a.realTimeStatus === 'busy' || a.realTimeStatus === 'retry';
@@ -1014,531 +920,9 @@ async function getRemoteNodeSessionsResult(
1014
920
  }
1015
921
  }
1016
922
 
1017
- async function getLocalSessionsResult(stickyBusyDelayMs: number): Promise<SessionsRouteResult> {
1018
-
1019
- const { processes: rawProcessHints, timedOut: processDiscoveryTimedOut } =
1020
- discoverOpencodeProcessCwdsWithoutPortWithMeta();
1021
- const processHintsByDirectory = new Map<string, ProcessHint>();
1022
- for (const process of rawProcessHints) {
1023
- if (!process.cwd || process.cwd.startsWith('/private/tmp/opencode')) {
1024
- continue;
1025
- }
1026
- if (processHintsByDirectory.has(process.cwd)) {
1027
- continue;
1028
- }
1029
- processHintsByDirectory.set(process.cwd, {
1030
- pid: process.pid,
1031
- directory: process.cwd,
1032
- projectName: getProjectName(process.cwd),
1033
- reason: 'process_without_api_port',
1034
- });
1035
- }
1036
-
1037
- const { ports, timedOut: portDiscoveryTimedOut } = discoverOpencodePortsWithMeta();
1038
-
1039
- if (!ports.length) {
1040
- const processHints = Array.from(processHintsByDirectory.values());
1041
-
1042
- if (portDiscoveryTimedOut || processDiscoveryTimedOut) {
1043
- return {
1044
- payload: {
1045
- error: 'OpenCode discovery timed out',
1046
- hint: 'Host process discovery exceeded timeout. Retry shortly, or increase OPENCODE_DISCOVERY_TIMEOUT_MS.',
1047
- ...(processHints.length > 0 ? { processHints } : {}),
1048
- },
1049
- status: 503,
1050
- sourceMeta: {
1051
- online: false,
1052
- degraded: true,
1053
- reason: 'OpenCode discovery timed out',
1054
- },
1055
- };
1056
- }
1057
-
1058
- if (processHints.length > 0) {
1059
- return {
1060
- payload: { sessions: [], processHints },
1061
- sourceMeta: {
1062
- online: false,
1063
- reason: 'OpenCode server not found',
1064
- },
1065
- };
1066
- }
1067
-
1068
- return {
1069
- payload: {
1070
- error: 'OpenCode server not found',
1071
- hint: 'Make sure OpenCode is running with an exposed API port. Example: opencode --port <PORT> (VibePulse auto-detects active ports).'
1072
- },
1073
- status: 503,
1074
- sourceMeta: {
1075
- online: false,
1076
- reason: 'OpenCode server not found',
1077
- },
1078
- };
1079
- }
1080
-
1081
- try {
1082
- const results = await Promise.allSettled(ports.map(async (port) => {
1083
- const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` });
1084
- const sessionsResult = await withTimeout(
1085
- (signal) => client.session.list({ signal }),
1086
- sessionListTimeoutMs,
1087
- `session.list(${port})`
1088
- );
1089
- const statusResult = await withTimeout(
1090
- (signal) => client.session.status({ signal }),
1091
- sessionStatusTimeoutMs,
1092
- `session.status(${port})`
1093
- ).catch(() => ({ data: {} }));
1094
- return { port, client, sessions: sessionsResult.data || [], status: statusResult.data || {} };
1095
- }));
1096
-
1097
- const allSessions: SessionLike[] = [];
1098
- const statusMap: Record<string, { type: 'idle' | 'busy' | 'retry' }> = {};
1099
- const clientByPort: Record<number, ReturnType<typeof createOpencodeClient>> = {};
1100
- const sessionPortMap: Record<string, number> = {};
1101
- const failedPorts: Array<{ port: number; reason: string }> = [];
1102
-
1103
- for (let i = 0; i < results.length; i++) {
1104
- const r = results[i];
1105
- const port = ports[i];
1106
- if (r.status !== 'fulfilled') {
1107
- failedPorts.push({
1108
- port,
1109
- reason: r.reason instanceof Error ? r.reason.message : String(r.reason),
1110
- });
1111
- continue;
1112
- }
1113
- allSessions.push(...r.value.sessions);
1114
- Object.assign(statusMap, r.value.status);
1115
- clientByPort[r.value.port] = r.value.client;
1116
- for (const session of r.value.sessions as SessionLike[]) {
1117
- if (!(session.id in sessionPortMap)) {
1118
- sessionPortMap[session.id] = r.value.port;
1119
- }
1120
- }
1121
- }
1122
-
1123
- // Deduplicate by session.id
1124
- const seen = new Set<string>();
1125
- const sessions = allSessions.filter((session) => {
1126
- if (seen.has(session.id)) return false;
1127
- seen.add(session.id);
1128
- return true;
1129
- });
1130
-
1131
- const parentSessions = sessions.filter((s) => !s.parentID);
1132
- const childSessions = sessions.filter((s) => !!s.parentID);
1133
-
1134
- const lifecycleNow = Date.now();
1135
- pruneSessionForceUnarchived(lifecycleNow);
1136
- pruneSessionStickyStatusBlocked(lifecycleNow);
1137
-
1138
- for (const session of parentSessions) {
1139
- if (session.time?.archived !== undefined && shouldForceSessionUnarchived(session.id, lifecycleNow)) {
1140
- session.time = {
1141
- ...session.time,
1142
- archived: undefined,
1143
- };
1144
- }
1145
- }
1146
-
1147
- for (const child of childSessions) {
1148
- if (child.time?.archived !== undefined && shouldForceSessionUnarchived(child.id, lifecycleNow)) {
1149
- child.time = {
1150
- ...child.time,
1151
- archived: undefined,
1152
- };
1153
- }
1154
- }
1155
-
1156
- if (results.length > 0 && failedPorts.length === results.length) {
1157
- pruneStickyState(Date.now(), new Set<string>());
1158
- return {
1159
- payload: {
1160
- error: 'Failed to fetch sessions from OpenCode ports',
1161
- hint: 'All discovered OpenCode API ports timed out or failed. Retry shortly or increase OPENCODE_SESSIONS_LIST_TIMEOUT_MS.',
1162
- failedPorts,
1163
- },
1164
- status: 503,
1165
- sourceMeta: {
1166
- online: false,
1167
- degraded: true,
1168
- reason: 'Failed to fetch sessions from OpenCode ports',
1169
- },
1170
- };
1171
- }
1172
-
1173
- if (failedPorts.length > 0 && parentSessions.length === 0 && childSessions.length === 0) {
1174
- pruneStickyState(Date.now(), new Set<string>());
1175
- const processHints = Array.from(processHintsByDirectory.values());
1176
- return {
1177
- payload: {
1178
- sessions: [],
1179
- processHints,
1180
- failedPorts,
1181
- degraded: true,
1182
- },
1183
- sourceMeta: {
1184
- online: true,
1185
- degraded: true,
1186
- },
1187
- };
1188
- }
1189
-
1190
- // Enrich parent sessions
1191
- const enrichedSessions: EnrichedSession[] = parentSessions.map((session) => {
1192
- const projectName = getProjectName(session.directory);
1193
- const branch = getGitBranch(session.directory);
1194
- return {
1195
- ...session,
1196
- projectName,
1197
- branch,
1198
- realTimeStatus: statusMap[session.id]?.type || 'idle',
1199
- waitingForUser: false,
1200
- children: [],
1201
- };
1202
- });
1203
-
1204
- const parentById = new Map(enrichedSessions.map((session) => [session.id, session]));
1205
-
1206
- const now = Date.now();
1207
- const unresolvedChildren: Array<{ parentId: string; child: SessionLike; childUpdatedAt: number }> = [];
1208
-
1209
- // Enrich and nest child sessions under parents
1210
- for (const child of childSessions) {
1211
- // Find parent by parentID
1212
- let parent = child.parentID
1213
- ? enrichedSessions.find((session) => session.id === child.parentID)
1214
- : null;
1215
-
1216
- if (!parent) {
1217
- const candidates = enrichedSessions
1218
- .filter((session) => session.directory === child.directory)
1219
- .sort((a, b) => getUpdatedAt(b) - getUpdatedAt(a));
1220
-
1221
- parent =
1222
- candidates.find((session) => session.realTimeStatus === 'busy' || session.realTimeStatus === 'retry') ||
1223
- candidates[0];
1224
- }
1225
-
1226
- if (!parent) {
1227
- continue;
1228
- }
1229
-
1230
- const statusFromMap = statusMap[child.id]?.type;
1231
- const childUpdatedAt = getUpdatedAt(child);
1232
- const isRecent = childUpdatedAt > 0 && now - childUpdatedAt <= CHILD_ACTIVE_WINDOW_MS;
1233
- const shouldSkipArchivedChild = !!child.time?.archived && !statusFromMap && !isRecent;
1234
-
1235
- if (shouldSkipArchivedChild) {
1236
- continue;
1237
- }
1238
-
1239
- if (statusFromMap && statusFromMap !== 'idle') {
1240
- parent.children.push(toChildEntry(child, statusFromMap));
1241
- } else if (isRecent) {
1242
- if (unresolvedChildren.length < CHILD_STATUS_MESSAGE_CHECK_LIMIT) {
1243
- unresolvedChildren.push({ parentId: parent.id, child, childUpdatedAt });
1244
- }
1245
- } else {
1246
- continue;
1247
- }
1248
- }
1249
-
1250
- if (unresolvedChildren.length > 0) {
1251
- const unresolvedChecks = await Promise.allSettled(
1252
- unresolvedChildren.map(async ({ parentId, child, childUpdatedAt }) => {
1253
- const port = sessionPortMap[child.id] ?? sessionPortMap[parentId];
1254
- const client = port ? clientByPort[port] : undefined;
1255
- const assumeBusyForUnknown =
1256
- childUpdatedAt > 0 && now - childUpdatedAt <= CHILD_UNKNOWN_STATE_BUSY_WINDOW_MS;
1257
- if (!client) {
1258
- return {
1259
- parentId,
1260
- child,
1261
- childStatus: assumeBusyForUnknown ? 'busy' as const : 'idle' as const,
1262
- };
1263
- }
1264
-
1265
- try {
1266
- const partStatuses = await fetchPartStatuses(client, child.id, sessionMessagesTimeoutMs);
1267
- const hasRunningState = partStatuses.some((status) => status === 'running');
1268
- const hasWaitingState = !hasRunningState && partStatuses.some(isWaitingPartStatus);
1269
- const hasActiveState = hasWaitingState || hasRunningState;
1270
- const recentlyActive = childUpdatedAt > 0 && now - childUpdatedAt <= 5 * 60 * 1000;
1271
-
1272
- return {
1273
- parentId,
1274
- child,
1275
- childWaitingForUser: hasWaitingState,
1276
- childStatus: hasActiveState
1277
- ? 'busy' as const
1278
- : recentlyActive || assumeBusyForUnknown
1279
- ? 'busy' as const
1280
- : 'idle' as const,
1281
- };
1282
- } catch {
1283
- return {
1284
- parentId,
1285
- child,
1286
- childWaitingForUser: false,
1287
- childStatus: assumeBusyForUnknown ? 'busy' as const : 'idle' as const,
1288
- };
1289
- }
1290
- })
1291
- );
1292
-
1293
- for (const check of unresolvedChecks) {
1294
- if (check.status !== 'fulfilled') continue;
1295
- if (check.value.childStatus === 'idle') continue;
1296
- const parent = parentById.get(check.value.parentId);
1297
- if (!parent) continue;
1298
- parent.children.push(toChildEntry(check.value.child, check.value.childStatus, check.value.childWaitingForUser));
1299
- }
1300
- }
1301
-
1302
- const parentStatusFallbackCandidates = enrichedSessions
1303
- .filter((session) => {
1304
- if (session.realTimeStatus !== 'idle') return false;
1305
- const updatedAt = getUpdatedAt(session);
1306
- if (updatedAt > 0 && now - updatedAt <= CHILD_ACTIVE_WINDOW_MS) return true;
1307
- return !!session.time?.archived;
1308
- })
1309
- .sort((a, b) => getUpdatedAt(b) - getUpdatedAt(a))
1310
- .slice(0, CHILD_STATUS_MESSAGE_CHECK_LIMIT);
1311
-
1312
- if (parentStatusFallbackCandidates.length > 0) {
1313
- const parentFallbackChecks = await Promise.allSettled(
1314
- parentStatusFallbackCandidates.map(async (session) => {
1315
- const updatedAt = getUpdatedAt(session);
1316
- const assumeBusyForUnknown =
1317
- updatedAt > 0 && now - updatedAt <= CHILD_UNKNOWN_STATE_BUSY_WINDOW_MS;
1318
- const port = sessionPortMap[session.id];
1319
- const client = port ? clientByPort[port] : undefined;
1320
-
1321
- if (!client) {
1322
- return {
1323
- sessionId: session.id,
1324
- status: assumeBusyForUnknown ? 'busy' as const : 'idle' as const,
1325
- waitingForUser: false,
1326
- };
1327
- }
1328
-
1329
- try {
1330
- const partStatuses = await fetchPartStatuses(client, session.id, sessionMessagesTimeoutMs);
1331
- const hasRunningState = partStatuses.some((status) => status === 'running');
1332
- const hasWaitingState = !hasRunningState && partStatuses.some(isWaitingPartStatus);
1333
- const hasCompletedState =
1334
- partStatuses.length > 0 && partStatuses.every((status) => status === 'completed');
1335
- const recentlyActive = hasRecentActivity(session, now);
1336
-
1337
- return {
1338
- sessionId: session.id,
1339
- status: hasRunningState || hasWaitingState
1340
- ? 'busy' as const
1341
- : hasCompletedState && !recentlyActive
1342
- ? 'idle' as const
1343
- : assumeBusyForUnknown || recentlyActive
1344
- ? 'busy' as const
1345
- : 'idle' as const,
1346
- waitingForUser: hasWaitingState,
1347
- };
1348
- } catch {
1349
- return {
1350
- sessionId: session.id,
1351
- status: assumeBusyForUnknown ? 'busy' as const : 'idle' as const,
1352
- waitingForUser: false,
1353
- };
1354
- }
1355
- })
1356
- );
1357
-
1358
- for (const check of parentFallbackChecks) {
1359
- if (check.status !== 'fulfilled') continue;
1360
- if (check.value.status === 'idle') continue;
1361
- const session = parentById.get(check.value.sessionId);
1362
- if (!session) continue;
1363
- session.realTimeStatus = check.value.status;
1364
- if (check.value.waitingForUser) {
1365
- session.waitingForUser = true;
1366
- }
1367
- }
1368
- }
1369
-
1370
- // Sort children for each parent: active first, then by updated time
1371
- for (const session of enrichedSessions) {
1372
- if (session.children.length > 0) {
1373
- sortChildEntries(session.children);
1374
- }
1375
- }
1376
-
1377
- const sessionsForInteractionChecks = enrichedSessions.filter(
1378
- (session) =>
1379
- session.realTimeStatus === 'busy' ||
1380
- !!session.time?.archived ||
1381
- session.children.some((child) => child.realTimeStatus === 'busy' || child.realTimeStatus === 'retry')
1382
- );
1383
- if (sessionsForInteractionChecks.length > 0) {
1384
- const pendingChecks = await Promise.allSettled(
1385
- sessionsForInteractionChecks.map(async (session) => {
1386
- const port = sessionPortMap[session.id];
1387
- const client = port ? clientByPort[port] : undefined;
1388
- if (!client) {
1389
- return {
1390
- sessionId: session.id,
1391
- parentWaiting: false,
1392
- waiting: false,
1393
- running: false,
1394
- waitingChildIds: new Set<string>(),
1395
- };
1396
- }
1397
-
1398
- try {
1399
- const partStatuses = await fetchPartStatuses(client, session.id, sessionMessagesTimeoutMs);
1400
- const hasRunning = partStatuses.some((status) => status === 'running');
1401
- const hasInteractionWait = !hasRunning && partStatuses.some(isWaitingPartStatus);
1402
-
1403
- const childStateChecks = await Promise.allSettled(
1404
- session.children
1405
- .filter((child) => child.realTimeStatus === 'busy' || child.realTimeStatus === 'retry')
1406
- .map(async (child) => {
1407
- const childPort = sessionPortMap[child.id] ?? sessionPortMap[session.id];
1408
- const childClient = childPort ? clientByPort[childPort] : undefined;
1409
- if (!childClient) {
1410
- return { childId: child.id, waiting: false };
1411
- }
1412
- try {
1413
- const childStatuses = await fetchPartStatuses(childClient, child.id, sessionMessagesTimeoutMs);
1414
- const childHasRunning = childStatuses.some((status) => status === 'running');
1415
- return {
1416
- childId: child.id,
1417
- waiting: !childHasRunning && childStatuses.some(isWaitingPartStatus),
1418
- };
1419
- } catch {
1420
- return { childId: child.id, waiting: false };
1421
- }
1422
- })
1423
- );
1424
-
1425
- const waitingChildIds = new Set(
1426
- childStateChecks
1427
- .filter((result): result is PromiseFulfilledResult<{ childId: string; waiting: boolean }> => result.status === 'fulfilled')
1428
- .filter((result) => result.value.waiting)
1429
- .map((result) => result.value.childId)
1430
- );
1431
-
1432
- const hasWaitingChildren =
1433
- waitingChildIds.size > 0 ||
1434
- session.children.some((child) => child.waitingForUser || child.realTimeStatus === 'retry');
1435
-
1436
- return {
1437
- sessionId: session.id,
1438
- parentWaiting: hasInteractionWait,
1439
- waiting: hasInteractionWait || hasWaitingChildren,
1440
- running: hasRunning,
1441
- waitingChildIds,
1442
- };
1443
- } catch {
1444
- return {
1445
- sessionId: session.id,
1446
- parentWaiting: false,
1447
- waiting: false,
1448
- running: false,
1449
- waitingChildIds: new Set<string>(),
1450
- };
1451
- }
1452
- })
1453
- );
1454
-
1455
- for (const result of pendingChecks) {
1456
- if (result.status === 'fulfilled') {
1457
- const session = enrichedSessions.find((candidate) => candidate.id === result.value.sessionId);
1458
- if (!session) continue;
1459
- for (const child of session.children) {
1460
- if (result.value.waitingChildIds.has(child.id)) {
1461
- child.waitingForUser = true;
1462
- }
1463
- }
1464
- if (result.value.running) {
1465
- session.realTimeStatus = 'busy';
1466
- }
1467
- if (result.value.parentWaiting) {
1468
- session.waitingForUser = true;
1469
- }
1470
- }
1471
- }
1472
- }
1473
-
1474
- const stickyNow = Date.now();
1475
- const activeStickyIds = new Set<string>();
1476
-
1477
- for (const session of enrichedSessions) {
1478
- activeStickyIds.add(session.id);
1479
- for (const child of session.children) {
1480
- activeStickyIds.add(`child:${child.id}`);
1481
- }
1482
- }
1483
-
1484
- for (const session of enrichedSessions) {
1485
- if (shouldSkipSessionStatusStabilization(session, stickyNow)) {
1486
- continue;
1487
- }
1488
-
1489
- applyStickyStatusStabilization(session, stickyNow, stickyBusyDelayMs);
1490
- }
1491
- pruneStickyState(stickyNow, activeStickyIds);
1492
-
1493
- const knownDirectories = new Set<string>();
1494
- for (const session of sessions) {
1495
- if (session.directory) {
1496
- knownDirectories.add(session.directory);
1497
- }
1498
- }
1499
-
1500
- const processHints = Array.from(processHintsByDirectory.values()).filter(
1501
- (hint) => !knownDirectories.has(hint.directory)
1502
- );
1503
-
1504
- const payload: SessionsSuccessPayload = {
1505
- sessions: enrichedSessions,
1506
- processHints,
1507
- };
1508
-
1509
- if (failedPorts.length > 0) {
1510
- payload.failedPorts = failedPorts;
1511
- payload.degraded = true;
1512
- }
1513
-
1514
- return {
1515
- payload,
1516
- sourceMeta: {
1517
- online: true,
1518
- ...(failedPorts.length > 0 ? { degraded: true } : {}),
1519
- },
1520
- };
1521
- } catch (error) {
1522
- console.error('Error fetching sessions:', error);
1523
- return {
1524
- payload: {
1525
- error: 'Failed to fetch sessions',
1526
- details: error instanceof Error ? error.message : String(error),
1527
- hint: 'Make sure OpenCode is running with an exposed API port. Example: opencode --port <PORT> (VibePulse auto-detects active ports).'
1528
- },
1529
- status: 500,
1530
- sourceMeta: {
1531
- online: false,
1532
- degraded: true,
1533
- reason: 'Failed to fetch sessions',
1534
- },
1535
- };
1536
- }
1537
- }
1538
-
1539
923
  async function handleGet() {
1540
924
  const stickyBusyDelayMs = await readStickyBusyDelayMs();
1541
- return toRouteResponse(await getLocalSessionsResult(stickyBusyDelayMs));
925
+ return toRouteResponse(await getLocalSessionsResult({ stickyBusyDelayMs, providers: [...LOCAL_POLLING_PROVIDERS] }));
1542
926
  }
1543
927
 
1544
928
  async function handlePost(request: Request) {
@@ -1568,7 +952,7 @@ async function handlePost(request: Request) {
1568
952
 
1569
953
  if (enabledSources.length === 1 && enabledSources[0].hostKind === 'local') {
1570
954
  const stickyBusyDelayMs = await readStickyBusyDelayMs();
1571
- const localResult = await getLocalSessionsResult(stickyBusyDelayMs);
955
+ const localResult = await getLocalSessionsResult({ stickyBusyDelayMs, providers: [...LOCAL_POLLING_PROVIDERS] });
1572
956
  const rawLocalMeta = localResult.sourceMeta ?? {
1573
957
  online: !localResult.status,
1574
958
  ...(localResult.status ? { degraded: true } : {}),
@@ -1634,7 +1018,7 @@ async function handlePost(request: Request) {
1634
1018
  resolvedSources.map(async (source) => ({
1635
1019
  source,
1636
1020
  result: source.hostKind === 'local'
1637
- ? await getLocalSessionsResult(stickyBusyDelayMs)
1021
+ ? await getLocalSessionsResult({ stickyBusyDelayMs, providers: [...LOCAL_POLLING_PROVIDERS] })
1638
1022
  : await getRemoteNodeSessionsResult(source, nodeRecordsById.get(source.hostId)),
1639
1023
  }))
1640
1024
  );
@@ -1686,7 +1070,7 @@ async function handlePost(request: Request) {
1686
1070
  }
1687
1071
 
1688
1072
  return Response.json({
1689
- sessions: aggregateSessions,
1073
+ sessions: rebuildAggregateClaudeTopology(aggregateSessions),
1690
1074
  processHints: aggregateProcessHints,
1691
1075
  hosts: hostStatuses,
1692
1076
  hostStatuses,