@jlongo78/agent-spaces 0.7.7 → 0.8.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 (797) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +6 -0
  3. package/.next/standalone/.next/build-manifest.json +2 -2
  4. package/.next/standalone/.next/prerender-manifest.json +3 -3
  5. package/.next/standalone/.next/required-server-files.json +19 -19
  6. package/.next/standalone/.next/routes-manifest.json +38 -0
  7. package/.next/standalone/.next/server/app/(desktop)/admin/analytics/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/(desktop)/admin/users/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/(desktop)/analytics/page_client-reference-manifest.js +1 -1
  10. package/.next/standalone/.next/server/app/(desktop)/cortex/page/react-loadable-manifest.json +1 -1
  11. package/.next/standalone/.next/server/app/(desktop)/cortex/page.js.nft.json +1 -1
  12. package/.next/standalone/.next/server/app/(desktop)/cortex/page_client-reference-manifest.js +1 -1
  13. package/.next/standalone/.next/server/app/(desktop)/network/page_client-reference-manifest.js +1 -1
  14. package/.next/standalone/.next/server/app/(desktop)/page_client-reference-manifest.js +1 -1
  15. package/.next/standalone/.next/server/app/(desktop)/projects/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/.next/server/app/(desktop)/sessions/[id]/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/(desktop)/sessions/[id]/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/(desktop)/sessions/page_client-reference-manifest.js +1 -1
  19. package/.next/standalone/.next/server/app/(desktop)/settings/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/(desktop)/terminal/page.js.nft.json +1 -1
  21. package/.next/standalone/.next/server/app/(desktop)/terminal/page_client-reference-manifest.js +1 -1
  22. package/.next/standalone/.next/server/app/(desktop)/terminal/pane/[id]/page.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/(desktop)/terminal/pane/[id]/page_client-reference-manifest.js +1 -1
  24. package/.next/standalone/.next/server/app/(desktop)/terminal/remote/[nodeId]/[workspaceId]/page.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/(desktop)/terminal/remote/[nodeId]/[workspaceId]/page_client-reference-manifest.js +1 -1
  26. package/.next/standalone/.next/server/app/(desktop)/workspaces/page_client-reference-manifest.js +1 -1
  27. package/.next/standalone/.next/server/app/_global-error.html +2 -2
  28. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  29. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  30. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  31. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  32. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  33. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  34. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  35. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  36. package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
  37. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  38. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  39. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  40. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  41. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  42. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  43. package/.next/standalone/.next/server/app/admin/analytics.html +1 -1
  44. package/.next/standalone/.next/server/app/admin/analytics.rsc +6 -7
  45. package/.next/standalone/.next/server/app/admin/analytics.segments/!KGRlc2t0b3Ap/admin/analytics/__PAGE__.segment.rsc +2 -2
  46. package/.next/standalone/.next/server/app/admin/analytics.segments/!KGRlc2t0b3Ap/admin/analytics.segment.rsc +1 -1
  47. package/.next/standalone/.next/server/app/admin/analytics.segments/!KGRlc2t0b3Ap/admin.segment.rsc +1 -1
  48. package/.next/standalone/.next/server/app/admin/analytics.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  49. package/.next/standalone/.next/server/app/admin/analytics.segments/_full.segment.rsc +6 -7
  50. package/.next/standalone/.next/server/app/admin/analytics.segments/_head.segment.rsc +1 -1
  51. package/.next/standalone/.next/server/app/admin/analytics.segments/_index.segment.rsc +2 -2
  52. package/.next/standalone/.next/server/app/admin/analytics.segments/_tree.segment.rsc +2 -2
  53. package/.next/standalone/.next/server/app/admin/users.html +1 -1
  54. package/.next/standalone/.next/server/app/admin/users.rsc +2 -2
  55. package/.next/standalone/.next/server/app/admin/users.segments/!KGRlc2t0b3Ap/admin/users/__PAGE__.segment.rsc +1 -1
  56. package/.next/standalone/.next/server/app/admin/users.segments/!KGRlc2t0b3Ap/admin/users.segment.rsc +1 -1
  57. package/.next/standalone/.next/server/app/admin/users.segments/!KGRlc2t0b3Ap/admin.segment.rsc +1 -1
  58. package/.next/standalone/.next/server/app/admin/users.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  59. package/.next/standalone/.next/server/app/admin/users.segments/_full.segment.rsc +2 -2
  60. package/.next/standalone/.next/server/app/admin/users.segments/_head.segment.rsc +1 -1
  61. package/.next/standalone/.next/server/app/admin/users.segments/_index.segment.rsc +2 -2
  62. package/.next/standalone/.next/server/app/admin/users.segments/_tree.segment.rsc +2 -2
  63. package/.next/standalone/.next/server/app/analytics.html +1 -1
  64. package/.next/standalone/.next/server/app/analytics.rsc +3 -3
  65. package/.next/standalone/.next/server/app/analytics.segments/!KGRlc2t0b3Ap/analytics/__PAGE__.segment.rsc +2 -2
  66. package/.next/standalone/.next/server/app/analytics.segments/!KGRlc2t0b3Ap/analytics.segment.rsc +1 -1
  67. package/.next/standalone/.next/server/app/analytics.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  68. package/.next/standalone/.next/server/app/analytics.segments/_full.segment.rsc +3 -3
  69. package/.next/standalone/.next/server/app/analytics.segments/_head.segment.rsc +1 -1
  70. package/.next/standalone/.next/server/app/analytics.segments/_index.segment.rsc +2 -2
  71. package/.next/standalone/.next/server/app/analytics.segments/_tree.segment.rsc +2 -2
  72. package/.next/standalone/.next/server/app/api/analytics/overview/route.js.nft.json +1 -1
  73. package/.next/standalone/.next/server/app/api/benchmark/lobes/route/app-paths-manifest.json +3 -0
  74. package/.next/standalone/.next/server/app/api/benchmark/lobes/route/build-manifest.json +11 -0
  75. package/.next/standalone/.next/server/app/api/benchmark/lobes/route/server-reference-manifest.json +4 -0
  76. package/.next/standalone/.next/server/app/api/benchmark/lobes/route.js +7 -0
  77. package/.next/standalone/.next/server/app/api/benchmark/lobes/route.js.map +5 -0
  78. package/.next/standalone/.next/server/app/api/benchmark/lobes/route.js.nft.json +1 -0
  79. package/.next/standalone/.next/server/app/api/benchmark/lobes/route_client-reference-manifest.js +2 -0
  80. package/.next/standalone/.next/server/app/api/benchmark/run/route/app-paths-manifest.json +3 -0
  81. package/.next/standalone/.next/server/app/api/benchmark/run/route/build-manifest.json +11 -0
  82. package/.next/standalone/.next/server/app/api/benchmark/run/route/server-reference-manifest.json +4 -0
  83. package/.next/standalone/.next/server/app/api/benchmark/run/route.js +7 -0
  84. package/.next/standalone/.next/server/app/api/benchmark/run/route.js.map +5 -0
  85. package/.next/standalone/.next/server/app/api/benchmark/run/route.js.nft.json +1 -0
  86. package/.next/standalone/.next/server/app/api/benchmark/run/route_client-reference-manifest.js +2 -0
  87. package/.next/standalone/.next/server/app/api/benchmark/runs/[id]/route/app-paths-manifest.json +3 -0
  88. package/.next/standalone/.next/server/app/api/benchmark/runs/[id]/route/build-manifest.json +11 -0
  89. package/.next/standalone/.next/server/app/api/benchmark/runs/[id]/route/server-reference-manifest.json +4 -0
  90. package/.next/standalone/.next/server/app/api/benchmark/runs/[id]/route.js +7 -0
  91. package/.next/standalone/.next/server/app/api/benchmark/runs/[id]/route.js.map +5 -0
  92. package/.next/standalone/.next/server/app/api/benchmark/runs/[id]/route.js.nft.json +1 -0
  93. package/.next/standalone/.next/server/app/api/benchmark/runs/[id]/route_client-reference-manifest.js +2 -0
  94. package/.next/standalone/.next/server/app/api/benchmark/runs/route/app-paths-manifest.json +3 -0
  95. package/.next/standalone/.next/server/app/api/benchmark/runs/route/build-manifest.json +11 -0
  96. package/.next/standalone/.next/server/app/api/benchmark/runs/route/server-reference-manifest.json +4 -0
  97. package/.next/standalone/.next/server/app/api/benchmark/runs/route.js +7 -0
  98. package/.next/standalone/.next/server/app/api/benchmark/runs/route.js.map +5 -0
  99. package/.next/standalone/.next/server/app/api/benchmark/runs/route.js.nft.json +1 -0
  100. package/.next/standalone/.next/server/app/api/benchmark/runs/route_client-reference-manifest.js +2 -0
  101. package/.next/standalone/.next/server/app/api/benchmark/status/route/app-paths-manifest.json +3 -0
  102. package/.next/standalone/.next/server/app/api/benchmark/status/route/build-manifest.json +11 -0
  103. package/.next/standalone/.next/server/app/api/benchmark/status/route/server-reference-manifest.json +4 -0
  104. package/.next/standalone/.next/server/app/api/benchmark/status/route.js +7 -0
  105. package/.next/standalone/.next/server/app/api/benchmark/status/route.js.map +5 -0
  106. package/.next/standalone/.next/server/app/api/benchmark/status/route.js.nft.json +1 -0
  107. package/.next/standalone/.next/server/app/api/benchmark/status/route_client-reference-manifest.js +2 -0
  108. package/.next/standalone/.next/server/app/api/bulk/route.js.nft.json +1 -1
  109. package/.next/standalone/.next/server/app/api/config/route.js.nft.json +1 -1
  110. package/.next/standalone/.next/server/app/api/cortex/context/route.js.nft.json +1 -1
  111. package/.next/standalone/.next/server/app/api/cortex/curation/assess/route.js.nft.json +1 -1
  112. package/.next/standalone/.next/server/app/api/cortex/curation/publish/route.js.nft.json +1 -1
  113. package/.next/standalone/.next/server/app/api/cortex/curation/refine/route.js.nft.json +1 -1
  114. package/.next/standalone/.next/server/app/api/cortex/curation/review/route.js.nft.json +1 -1
  115. package/.next/standalone/.next/server/app/api/cortex/curation/seed/route.js.nft.json +1 -1
  116. package/.next/standalone/.next/server/app/api/cortex/export/route.js.nft.json +1 -1
  117. package/.next/standalone/.next/server/app/api/cortex/federation/pending/route.js.nft.json +1 -1
  118. package/.next/standalone/.next/server/app/api/cortex/federation/resolve/route.js.nft.json +1 -1
  119. package/.next/standalone/.next/server/app/api/cortex/federation/search/route.js.nft.json +1 -1
  120. package/.next/standalone/.next/server/app/api/cortex/federation/teach/route.js.nft.json +1 -1
  121. package/.next/standalone/.next/server/app/api/cortex/graph/edges/route.js.nft.json +1 -1
  122. package/.next/standalone/.next/server/app/api/cortex/graph/entities/[id]/route.js.nft.json +1 -1
  123. package/.next/standalone/.next/server/app/api/cortex/graph/entities/route.js.nft.json +1 -1
  124. package/.next/standalone/.next/server/app/api/cortex/graph/populate/route.js.nft.json +1 -1
  125. package/.next/standalone/.next/server/app/api/cortex/import/route.js.nft.json +1 -1
  126. package/.next/standalone/.next/server/app/api/cortex/import/status/route.js.nft.json +1 -1
  127. package/.next/standalone/.next/server/app/api/cortex/ingest/bootstrap/route.js.nft.json +1 -1
  128. package/.next/standalone/.next/server/app/api/cortex/ingest/status/route.js.nft.json +1 -1
  129. package/.next/standalone/.next/server/app/api/cortex/knowledge/[id]/route.js.nft.json +1 -1
  130. package/.next/standalone/.next/server/app/api/cortex/knowledge/route.js.nft.json +1 -1
  131. package/.next/standalone/.next/server/app/api/cortex/lobes/[id]/route.js.nft.json +1 -1
  132. package/.next/standalone/.next/server/app/api/cortex/lobes/route.js.nft.json +1 -1
  133. package/.next/standalone/.next/server/app/api/cortex/lobes/share/route.js.nft.json +1 -1
  134. package/.next/standalone/.next/server/app/api/cortex/marketplace/browse/route.js.nft.json +1 -1
  135. package/.next/standalone/.next/server/app/api/cortex/marketplace/preview/route.js.nft.json +1 -1
  136. package/.next/standalone/.next/server/app/api/cortex/mcp/call/route.js.nft.json +1 -1
  137. package/.next/standalone/.next/server/app/api/cortex/mcp/tools/route.js.nft.json +1 -1
  138. package/.next/standalone/.next/server/app/api/cortex/search/route.js.nft.json +1 -1
  139. package/.next/standalone/.next/server/app/api/cortex/settings/route.js.nft.json +1 -1
  140. package/.next/standalone/.next/server/app/api/cortex/status/route.js.nft.json +1 -1
  141. package/.next/standalone/.next/server/app/api/cortex/timeline/route.js.nft.json +1 -1
  142. package/.next/standalone/.next/server/app/api/cortex/usage/route.js.nft.json +1 -1
  143. package/.next/standalone/.next/server/app/api/cortex/workspace/[id]/context/route.js.nft.json +1 -1
  144. package/.next/standalone/.next/server/app/api/events/route.js.nft.json +1 -1
  145. package/.next/standalone/.next/server/app/api/files/route.js +1 -1
  146. package/.next/standalone/.next/server/app/api/files/route.js.nft.json +1 -1
  147. package/.next/standalone/.next/server/app/api/folders/route.js.nft.json +1 -1
  148. package/.next/standalone/.next/server/app/api/network/handshake/route.js.nft.json +1 -1
  149. package/.next/standalone/.next/server/app/api/network/projects/route.js.nft.json +1 -1
  150. package/.next/standalone/.next/server/app/api/network/search/route.js.nft.json +1 -1
  151. package/.next/standalone/.next/server/app/api/network/sessions/[id]/messages/route.js.nft.json +1 -1
  152. package/.next/standalone/.next/server/app/api/network/sessions/[id]/route.js.nft.json +1 -1
  153. package/.next/standalone/.next/server/app/api/network/sessions/route.js.nft.json +1 -1
  154. package/.next/standalone/.next/server/app/api/network/workspaces/[id]/route.js.nft.json +1 -1
  155. package/.next/standalone/.next/server/app/api/network/workspaces/route.js.nft.json +1 -1
  156. package/.next/standalone/.next/server/app/api/panes/[id]/route.js.nft.json +1 -1
  157. package/.next/standalone/.next/server/app/api/panes/route.js.nft.json +1 -1
  158. package/.next/standalone/.next/server/app/api/projects/route.js.nft.json +1 -1
  159. package/.next/standalone/.next/server/app/api/search/route.js.nft.json +1 -1
  160. package/.next/standalone/.next/server/app/api/sessions/[id]/chat/route.js.nft.json +1 -1
  161. package/.next/standalone/.next/server/app/api/sessions/[id]/messages/route.js.nft.json +1 -1
  162. package/.next/standalone/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  163. package/.next/standalone/.next/server/app/api/sessions/route.js.nft.json +1 -1
  164. package/.next/standalone/.next/server/app/api/sync/route.js.nft.json +1 -1
  165. package/.next/standalone/.next/server/app/api/tags/route.js.nft.json +1 -1
  166. package/.next/standalone/.next/server/app/api/tier/route.js.nft.json +1 -1
  167. package/.next/standalone/.next/server/app/api/whisper/route/app-paths-manifest.json +3 -0
  168. package/.next/standalone/.next/server/app/api/whisper/route/build-manifest.json +11 -0
  169. package/.next/standalone/.next/server/app/api/whisper/route/server-reference-manifest.json +4 -0
  170. package/.next/standalone/.next/server/app/api/whisper/route.js +7 -0
  171. package/.next/standalone/.next/server/app/api/whisper/route.js.map +5 -0
  172. package/.next/standalone/.next/server/app/api/whisper/route.js.nft.json +1 -0
  173. package/.next/standalone/.next/server/app/api/whisper/route_client-reference-manifest.js +2 -0
  174. package/.next/standalone/.next/server/app/api/workspaces/[id]/context/[key]/route.js.nft.json +1 -1
  175. package/.next/standalone/.next/server/app/api/workspaces/[id]/context/route.js.nft.json +1 -1
  176. package/.next/standalone/.next/server/app/api/workspaces/[id]/messages/[msgId]/route.js.nft.json +1 -1
  177. package/.next/standalone/.next/server/app/api/workspaces/[id]/messages/route.js.nft.json +1 -1
  178. package/.next/standalone/.next/server/app/api/workspaces/[id]/route.js.nft.json +1 -1
  179. package/.next/standalone/.next/server/app/api/workspaces/[id]/sessions/route.js.nft.json +1 -1
  180. package/.next/standalone/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  181. package/.next/standalone/.next/server/app/cortex.html +1 -1
  182. package/.next/standalone/.next/server/app/cortex.rsc +3 -3
  183. package/.next/standalone/.next/server/app/cortex.segments/!KGRlc2t0b3Ap/cortex/__PAGE__.segment.rsc +2 -2
  184. package/.next/standalone/.next/server/app/cortex.segments/!KGRlc2t0b3Ap/cortex.segment.rsc +1 -1
  185. package/.next/standalone/.next/server/app/cortex.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  186. package/.next/standalone/.next/server/app/cortex.segments/_full.segment.rsc +3 -3
  187. package/.next/standalone/.next/server/app/cortex.segments/_head.segment.rsc +1 -1
  188. package/.next/standalone/.next/server/app/cortex.segments/_index.segment.rsc +2 -2
  189. package/.next/standalone/.next/server/app/cortex.segments/_tree.segment.rsc +2 -2
  190. package/.next/standalone/.next/server/app/login/page_client-reference-manifest.js +1 -1
  191. package/.next/standalone/.next/server/app/login.html +1 -1
  192. package/.next/standalone/.next/server/app/login.rsc +2 -2
  193. package/.next/standalone/.next/server/app/login.segments/_full.segment.rsc +2 -2
  194. package/.next/standalone/.next/server/app/login.segments/_head.segment.rsc +1 -1
  195. package/.next/standalone/.next/server/app/login.segments/_index.segment.rsc +2 -2
  196. package/.next/standalone/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  197. package/.next/standalone/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
  198. package/.next/standalone/.next/server/app/login.segments/login.segment.rsc +1 -1
  199. package/.next/standalone/.next/server/app/m/page_client-reference-manifest.js +1 -1
  200. package/.next/standalone/.next/server/app/m/projects/page_client-reference-manifest.js +1 -1
  201. package/.next/standalone/.next/server/app/m/projects.html +1 -1
  202. package/.next/standalone/.next/server/app/m/projects.rsc +2 -2
  203. package/.next/standalone/.next/server/app/m/projects.segments/_full.segment.rsc +2 -2
  204. package/.next/standalone/.next/server/app/m/projects.segments/_head.segment.rsc +1 -1
  205. package/.next/standalone/.next/server/app/m/projects.segments/_index.segment.rsc +2 -2
  206. package/.next/standalone/.next/server/app/m/projects.segments/_tree.segment.rsc +2 -2
  207. package/.next/standalone/.next/server/app/m/projects.segments/m/projects/__PAGE__.segment.rsc +1 -1
  208. package/.next/standalone/.next/server/app/m/projects.segments/m/projects.segment.rsc +1 -1
  209. package/.next/standalone/.next/server/app/m/projects.segments/m.segment.rsc +1 -1
  210. package/.next/standalone/.next/server/app/m/sessions/[id]/page.js.nft.json +1 -1
  211. package/.next/standalone/.next/server/app/m/sessions/[id]/page_client-reference-manifest.js +1 -1
  212. package/.next/standalone/.next/server/app/m/sessions/page_client-reference-manifest.js +1 -1
  213. package/.next/standalone/.next/server/app/m/sessions.html +1 -1
  214. package/.next/standalone/.next/server/app/m/sessions.rsc +2 -2
  215. package/.next/standalone/.next/server/app/m/sessions.segments/_full.segment.rsc +2 -2
  216. package/.next/standalone/.next/server/app/m/sessions.segments/_head.segment.rsc +1 -1
  217. package/.next/standalone/.next/server/app/m/sessions.segments/_index.segment.rsc +2 -2
  218. package/.next/standalone/.next/server/app/m/sessions.segments/_tree.segment.rsc +2 -2
  219. package/.next/standalone/.next/server/app/m/sessions.segments/m/sessions/__PAGE__.segment.rsc +1 -1
  220. package/.next/standalone/.next/server/app/m/sessions.segments/m/sessions.segment.rsc +1 -1
  221. package/.next/standalone/.next/server/app/m/sessions.segments/m.segment.rsc +1 -1
  222. package/.next/standalone/.next/server/app/m/settings/page_client-reference-manifest.js +1 -1
  223. package/.next/standalone/.next/server/app/m/settings.html +1 -1
  224. package/.next/standalone/.next/server/app/m/settings.rsc +2 -2
  225. package/.next/standalone/.next/server/app/m/settings.segments/_full.segment.rsc +2 -2
  226. package/.next/standalone/.next/server/app/m/settings.segments/_head.segment.rsc +1 -1
  227. package/.next/standalone/.next/server/app/m/settings.segments/_index.segment.rsc +2 -2
  228. package/.next/standalone/.next/server/app/m/settings.segments/_tree.segment.rsc +2 -2
  229. package/.next/standalone/.next/server/app/m/settings.segments/m/settings/__PAGE__.segment.rsc +1 -1
  230. package/.next/standalone/.next/server/app/m/settings.segments/m/settings.segment.rsc +1 -1
  231. package/.next/standalone/.next/server/app/m/settings.segments/m.segment.rsc +1 -1
  232. package/.next/standalone/.next/server/app/m/terminal/page.js.nft.json +1 -1
  233. package/.next/standalone/.next/server/app/m/terminal/page_client-reference-manifest.js +1 -1
  234. package/.next/standalone/.next/server/app/m/terminal.html +1 -1
  235. package/.next/standalone/.next/server/app/m/terminal.rsc +3 -3
  236. package/.next/standalone/.next/server/app/m/terminal.segments/_full.segment.rsc +3 -3
  237. package/.next/standalone/.next/server/app/m/terminal.segments/_head.segment.rsc +1 -1
  238. package/.next/standalone/.next/server/app/m/terminal.segments/_index.segment.rsc +2 -2
  239. package/.next/standalone/.next/server/app/m/terminal.segments/_tree.segment.rsc +2 -2
  240. package/.next/standalone/.next/server/app/m/terminal.segments/m/terminal/__PAGE__.segment.rsc +2 -2
  241. package/.next/standalone/.next/server/app/m/terminal.segments/m/terminal.segment.rsc +1 -1
  242. package/.next/standalone/.next/server/app/m/terminal.segments/m.segment.rsc +1 -1
  243. package/.next/standalone/.next/server/app/m.html +1 -1
  244. package/.next/standalone/.next/server/app/m.rsc +2 -2
  245. package/.next/standalone/.next/server/app/m.segments/_full.segment.rsc +2 -2
  246. package/.next/standalone/.next/server/app/m.segments/_head.segment.rsc +1 -1
  247. package/.next/standalone/.next/server/app/m.segments/_index.segment.rsc +2 -2
  248. package/.next/standalone/.next/server/app/m.segments/_tree.segment.rsc +2 -2
  249. package/.next/standalone/.next/server/app/m.segments/m/__PAGE__.segment.rsc +1 -1
  250. package/.next/standalone/.next/server/app/m.segments/m.segment.rsc +1 -1
  251. package/.next/standalone/.next/server/app/network.html +1 -1
  252. package/.next/standalone/.next/server/app/network.rsc +2 -2
  253. package/.next/standalone/.next/server/app/network.segments/!KGRlc2t0b3Ap/network/__PAGE__.segment.rsc +1 -1
  254. package/.next/standalone/.next/server/app/network.segments/!KGRlc2t0b3Ap/network.segment.rsc +1 -1
  255. package/.next/standalone/.next/server/app/network.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  256. package/.next/standalone/.next/server/app/network.segments/_full.segment.rsc +2 -2
  257. package/.next/standalone/.next/server/app/network.segments/_head.segment.rsc +1 -1
  258. package/.next/standalone/.next/server/app/network.segments/_index.segment.rsc +2 -2
  259. package/.next/standalone/.next/server/app/network.segments/_tree.segment.rsc +2 -2
  260. package/.next/standalone/.next/server/app/projects.html +1 -1
  261. package/.next/standalone/.next/server/app/projects.rsc +2 -2
  262. package/.next/standalone/.next/server/app/projects.segments/!KGRlc2t0b3Ap/projects/__PAGE__.segment.rsc +1 -1
  263. package/.next/standalone/.next/server/app/projects.segments/!KGRlc2t0b3Ap/projects.segment.rsc +1 -1
  264. package/.next/standalone/.next/server/app/projects.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  265. package/.next/standalone/.next/server/app/projects.segments/_full.segment.rsc +2 -2
  266. package/.next/standalone/.next/server/app/projects.segments/_head.segment.rsc +1 -1
  267. package/.next/standalone/.next/server/app/projects.segments/_index.segment.rsc +2 -2
  268. package/.next/standalone/.next/server/app/projects.segments/_tree.segment.rsc +2 -2
  269. package/.next/standalone/.next/server/app/sessions.html +1 -1
  270. package/.next/standalone/.next/server/app/sessions.rsc +2 -2
  271. package/.next/standalone/.next/server/app/sessions.segments/!KGRlc2t0b3Ap/sessions/__PAGE__.segment.rsc +1 -1
  272. package/.next/standalone/.next/server/app/sessions.segments/!KGRlc2t0b3Ap/sessions.segment.rsc +1 -1
  273. package/.next/standalone/.next/server/app/sessions.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  274. package/.next/standalone/.next/server/app/sessions.segments/_full.segment.rsc +2 -2
  275. package/.next/standalone/.next/server/app/sessions.segments/_head.segment.rsc +1 -1
  276. package/.next/standalone/.next/server/app/sessions.segments/_index.segment.rsc +2 -2
  277. package/.next/standalone/.next/server/app/sessions.segments/_tree.segment.rsc +2 -2
  278. package/.next/standalone/.next/server/app/settings.html +1 -1
  279. package/.next/standalone/.next/server/app/settings.rsc +2 -2
  280. package/.next/standalone/.next/server/app/settings.segments/!KGRlc2t0b3Ap/settings/__PAGE__.segment.rsc +1 -1
  281. package/.next/standalone/.next/server/app/settings.segments/!KGRlc2t0b3Ap/settings.segment.rsc +1 -1
  282. package/.next/standalone/.next/server/app/settings.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  283. package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +2 -2
  284. package/.next/standalone/.next/server/app/settings.segments/_head.segment.rsc +1 -1
  285. package/.next/standalone/.next/server/app/settings.segments/_index.segment.rsc +2 -2
  286. package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  287. package/.next/standalone/.next/server/app/terminal.html +1 -1
  288. package/.next/standalone/.next/server/app/terminal.rsc +3 -3
  289. package/.next/standalone/.next/server/app/terminal.segments/!KGRlc2t0b3Ap/terminal/__PAGE__.segment.rsc +2 -2
  290. package/.next/standalone/.next/server/app/terminal.segments/!KGRlc2t0b3Ap/terminal.segment.rsc +1 -1
  291. package/.next/standalone/.next/server/app/terminal.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  292. package/.next/standalone/.next/server/app/terminal.segments/_full.segment.rsc +3 -3
  293. package/.next/standalone/.next/server/app/terminal.segments/_head.segment.rsc +1 -1
  294. package/.next/standalone/.next/server/app/terminal.segments/_index.segment.rsc +2 -2
  295. package/.next/standalone/.next/server/app/terminal.segments/_tree.segment.rsc +2 -2
  296. package/.next/standalone/.next/server/app/vr/page/react-loadable-manifest.json +1 -1
  297. package/.next/standalone/.next/server/app/vr/page_client-reference-manifest.js +1 -1
  298. package/.next/standalone/.next/server/app/vr.html +1 -1
  299. package/.next/standalone/.next/server/app/vr.rsc +3 -3
  300. package/.next/standalone/.next/server/app/vr.segments/_full.segment.rsc +3 -3
  301. package/.next/standalone/.next/server/app/vr.segments/_head.segment.rsc +1 -1
  302. package/.next/standalone/.next/server/app/vr.segments/_index.segment.rsc +2 -2
  303. package/.next/standalone/.next/server/app/vr.segments/_tree.segment.rsc +2 -2
  304. package/.next/standalone/.next/server/app/vr.segments/vr/__PAGE__.segment.rsc +2 -2
  305. package/.next/standalone/.next/server/app/vr.segments/vr.segment.rsc +1 -1
  306. package/.next/standalone/.next/server/app/workspaces.html +1 -1
  307. package/.next/standalone/.next/server/app/workspaces.rsc +2 -2
  308. package/.next/standalone/.next/server/app/workspaces.segments/!KGRlc2t0b3Ap/workspaces/__PAGE__.segment.rsc +1 -1
  309. package/.next/standalone/.next/server/app/workspaces.segments/!KGRlc2t0b3Ap/workspaces.segment.rsc +1 -1
  310. package/.next/standalone/.next/server/app/workspaces.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  311. package/.next/standalone/.next/server/app/workspaces.segments/_full.segment.rsc +2 -2
  312. package/.next/standalone/.next/server/app/workspaces.segments/_head.segment.rsc +1 -1
  313. package/.next/standalone/.next/server/app/workspaces.segments/_index.segment.rsc +2 -2
  314. package/.next/standalone/.next/server/app/workspaces.segments/_tree.segment.rsc +2 -2
  315. package/.next/standalone/.next/server/app-paths-manifest.json +6 -0
  316. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0041efe4._.js +2 -2
  317. package/.next/standalone/.next/server/chunks/[root-of-the-server]__00bf0ace._.js +2 -2
  318. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0a837dd9._.js +98 -0
  319. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0e71d908._.js +3 -3
  320. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0e9142f3._.js +2 -2
  321. package/.next/standalone/.next/server/chunks/[root-of-the-server]__10e47926._.js +1 -1
  322. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1665dc78._.js +2 -2
  323. package/.next/standalone/.next/server/chunks/[root-of-the-server]__175cbabf._.js +2 -2
  324. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1adae357._.js +2 -2
  325. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1d359752._.js +2 -2
  326. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1e8fabeb._.js +2 -2
  327. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1f8deca0._.js +8 -8
  328. package/.next/standalone/.next/server/chunks/[root-of-the-server]__253fdda1._.js +2 -2
  329. package/.next/standalone/.next/server/chunks/[root-of-the-server]__28e6434f._.js +2 -2
  330. package/.next/standalone/.next/server/chunks/[root-of-the-server]__2a386564._.js +3 -3
  331. package/.next/standalone/.next/server/chunks/[root-of-the-server]__2c20fb38._.js +2 -2
  332. package/.next/standalone/.next/server/chunks/[root-of-the-server]__309132cd._.js +1 -1
  333. package/.next/standalone/.next/server/chunks/[root-of-the-server]__33fec964._.js +3 -3
  334. package/.next/standalone/.next/server/chunks/[root-of-the-server]__3786d8ae._.js +1 -1
  335. package/.next/standalone/.next/server/chunks/[root-of-the-server]__3ae92407._.js +2 -2
  336. package/.next/standalone/.next/server/chunks/[root-of-the-server]__3beda9fe._.js +2 -2
  337. package/.next/standalone/.next/server/chunks/[root-of-the-server]__4619e9bd._.js +1 -1
  338. package/.next/standalone/.next/server/chunks/[root-of-the-server]__4a051043._.js +1 -1
  339. package/.next/standalone/.next/server/chunks/[root-of-the-server]__508002e4._.js +2 -2
  340. package/.next/standalone/.next/server/chunks/[root-of-the-server]__5086c373._.js +2 -2
  341. package/.next/standalone/.next/server/chunks/[root-of-the-server]__5913e097._.js +2 -2
  342. package/.next/standalone/.next/server/chunks/[root-of-the-server]__5b5f68d2._.js +2 -2
  343. package/.next/standalone/.next/server/chunks/[root-of-the-server]__5c1f2459._.js +2 -2
  344. package/.next/standalone/.next/server/chunks/[root-of-the-server]__5ec8c977._.js +2 -2
  345. package/.next/standalone/.next/server/chunks/[root-of-the-server]__63cebc6c._.js +2 -2
  346. package/.next/standalone/.next/server/chunks/[root-of-the-server]__64d30d4d._.js +2 -2
  347. package/.next/standalone/.next/server/chunks/[root-of-the-server]__6c54fc2e._.js +2 -2
  348. package/.next/standalone/.next/server/chunks/[root-of-the-server]__6dc1fb7e._.js +1 -1
  349. package/.next/standalone/.next/server/chunks/[root-of-the-server]__6e568102._.js +2 -2
  350. package/.next/standalone/.next/server/chunks/[root-of-the-server]__6faa04c0._.js +2 -2
  351. package/.next/standalone/.next/server/chunks/[root-of-the-server]__74a34dc3._.js +2 -2
  352. package/.next/standalone/.next/server/chunks/[root-of-the-server]__7819e4cf._.js +98 -0
  353. package/.next/standalone/.next/server/chunks/[root-of-the-server]__7e7250a4._.js +2 -2
  354. package/.next/standalone/.next/server/chunks/[root-of-the-server]__8309e0a4._.js +2 -2
  355. package/.next/standalone/.next/server/chunks/[root-of-the-server]__86cc0e2b._.js +6 -6
  356. package/.next/standalone/.next/server/chunks/[root-of-the-server]__89c2565a._.js +2 -2
  357. package/.next/standalone/.next/server/chunks/[root-of-the-server]__8d178ad9._.js +2 -2
  358. package/.next/standalone/.next/server/chunks/[root-of-the-server]__93ee06f3._.js +2 -2
  359. package/.next/standalone/.next/server/chunks/[root-of-the-server]__9e1f0137._.js +98 -0
  360. package/.next/standalone/.next/server/chunks/[root-of-the-server]__9e4c154a._.js +2 -2
  361. package/.next/standalone/.next/server/chunks/[root-of-the-server]__a9d2e1d3._.js +2 -2
  362. package/.next/standalone/.next/server/chunks/[root-of-the-server]__ae53d343._.js +2 -2
  363. package/.next/standalone/.next/server/chunks/[root-of-the-server]__b3a04cef._.js +2 -2
  364. package/.next/standalone/.next/server/chunks/[root-of-the-server]__b4270b77._.js +1 -1
  365. package/.next/standalone/.next/server/chunks/[root-of-the-server]__b6b6ce60._.js +3 -3
  366. package/.next/standalone/.next/server/chunks/[root-of-the-server]__c0757773._.js +98 -0
  367. package/.next/standalone/.next/server/chunks/[root-of-the-server]__c24cfa91._.js +3 -0
  368. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__eb8acb65._.js → [root-of-the-server]__c7c47529._.js} +2 -2
  369. package/.next/standalone/.next/server/chunks/[root-of-the-server]__c88b63f7._.js +2 -2
  370. package/.next/standalone/.next/server/chunks/[root-of-the-server]__cba5f007._.js +1 -1
  371. package/.next/standalone/.next/server/chunks/[root-of-the-server]__cbf4ceb0._.js +2 -2
  372. package/.next/standalone/.next/server/chunks/[root-of-the-server]__cefdba2f._.js +2 -2
  373. package/.next/standalone/.next/server/chunks/[root-of-the-server]__cf9e82bb._.js +2 -2
  374. package/.next/standalone/.next/server/chunks/[root-of-the-server]__d2897392._.js +2 -2
  375. package/.next/standalone/.next/server/chunks/[root-of-the-server]__d3b2d856._.js +2 -2
  376. package/.next/standalone/.next/server/chunks/[root-of-the-server]__d73273ca._.js +2 -2
  377. package/.next/standalone/.next/server/chunks/[root-of-the-server]__d8417eb6._.js +2 -2
  378. package/.next/standalone/.next/server/chunks/[root-of-the-server]__dc2a55de._.js +2 -2
  379. package/.next/standalone/.next/server/chunks/[root-of-the-server]__e0d4690b._.js +2 -2
  380. package/.next/standalone/.next/server/chunks/[root-of-the-server]__e3ea8547._.js +98 -0
  381. package/.next/standalone/.next/server/chunks/[root-of-the-server]__e678dd53._.js +1 -1
  382. package/.next/standalone/.next/server/chunks/[root-of-the-server]__e9223f55._.js +2 -2
  383. package/.next/standalone/.next/server/chunks/[root-of-the-server]__ea630076._.js +2 -2
  384. package/.next/standalone/.next/server/chunks/[root-of-the-server]__f26ca49d._.js +1 -1
  385. package/.next/standalone/.next/server/chunks/[root-of-the-server]__f33e1101._.js +1 -1
  386. package/.next/standalone/.next/server/chunks/[root-of-the-server]__f515f865._.js +2 -2
  387. package/.next/standalone/.next/server/chunks/[root-of-the-server]__fceb5d60._.js +2 -2
  388. package/.next/standalone/.next/server/chunks/[root-of-the-server]__fed41403._.js +2 -2
  389. package/.next/standalone/.next/server/chunks/[root-of-the-server]__ff2e98c2._.js +2 -2
  390. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_benchmark_lobes_route_actions_ea7beadb.js +3 -0
  391. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_benchmark_run_route_actions_9ed0ba41.js +3 -0
  392. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_benchmark_runs_[id]_route_actions_39f90307.js +3 -0
  393. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_benchmark_runs_route_actions_37cf958b.js +3 -0
  394. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_benchmark_status_route_actions_009e2cba.js +3 -0
  395. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_whisper_route_actions_be9a633d.js +3 -0
  396. package/.next/standalone/.next/server/chunks/ssr/{_c1cfdd09._.js → [root-of-the-server]__16621ac5._.js} +2 -2
  397. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__66aca5d4._.js +1 -1
  398. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__9c81bd86._.js +3 -0
  399. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__e3bf6054._.js +5 -0
  400. package/.next/standalone/.next/server/chunks/ssr/{_81abf587._.js → _31ada310._.js} +2 -2
  401. package/.next/standalone/.next/server/chunks/ssr/_648a8a2d._.js +3 -0
  402. package/.next/standalone/.next/server/chunks/ssr/_999dae61._.js +3 -0
  403. package/.next/standalone/.next/server/chunks/ssr/_c48c91e5._.js +3 -0
  404. package/.next/standalone/.next/server/chunks/ssr/_f8959434._.js +3 -0
  405. package/.next/standalone/.next/server/chunks/ssr/src_app_(desktop)_cortex_page_tsx_0f33d8b3._.js +1 -1
  406. package/.next/standalone/.next/server/chunks/ssr/src_components_terminal_terminal-pane_tsx_803c5e2c._.js +9 -0
  407. package/.next/standalone/.next/server/edge/chunks/[root-of-the-server]__32a0045c._.js +1 -1
  408. package/.next/standalone/.next/server/edge/chunks/_d73df637._.js +1 -1
  409. package/.next/standalone/.next/server/middleware-manifest.json +5 -5
  410. package/.next/standalone/.next/server/pages/404.html +1 -1
  411. package/.next/standalone/.next/server/pages/500.html +2 -2
  412. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  413. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  414. package/.next/standalone/.next/static/chunks/157826a3253f8ccf.js +7 -0
  415. package/.next/standalone/.next/static/chunks/{69606d281c39f9b2.js → 2f9f09924ff9d447.js} +1 -1
  416. package/.next/standalone/.next/static/chunks/{84fe8d44deeeb74f.js → 4ced66ef12d91b38.js} +35 -12
  417. package/.next/standalone/.next/static/chunks/57d04d161b8a01b3.js +7 -0
  418. package/.next/standalone/.next/static/chunks/709c4608e5b935e8.js +1 -0
  419. package/.next/standalone/.next/static/chunks/{9cfa0291d55d8d2a.js → 7b14d4e609b55b9b.js} +1 -1
  420. package/.next/standalone/.next/static/chunks/7be37f4a56b8f575.js +1 -0
  421. package/.next/standalone/.next/static/chunks/8021d5a2269ff113.js +1 -0
  422. package/.next/standalone/.next/static/chunks/a3fd93a9dde3cacc.js +1 -0
  423. package/.next/standalone/.next/static/chunks/{412140a02893327a.js → afee31c7399daf2a.js} +1 -1
  424. package/.next/standalone/.next/static/chunks/b92fdbf858aeb0b3.js +1 -0
  425. package/.next/standalone/.next/static/chunks/c17274c2f95d4ba2.js +5 -0
  426. package/.next/standalone/.next/static/chunks/e82f4414650587cf.js +7 -0
  427. package/.next/standalone/.next/static/chunks/f6464729e7aa0da0.css +3 -0
  428. package/.next/standalone/LICENSE +661 -0
  429. package/.next/standalone/NOTICE +5 -0
  430. package/.next/standalone/README.md +131 -0
  431. package/.next/standalone/bin/cortex-hook.sh +62 -62
  432. package/.next/standalone/bin/cortex-mcp.js +60 -60
  433. package/.next/standalone/bin/fix-standalone-externals.js +79 -0
  434. package/.next/standalone/bin/lib/auto-setup.js +110 -0
  435. package/.next/standalone/bin/mdns-service.js +171 -0
  436. package/.next/standalone/bin/postinstall.js +35 -0
  437. package/.next/standalone/bin/setup-admin.js +195 -0
  438. package/.next/standalone/bin/spaces-dev.js +247 -0
  439. package/.next/standalone/bin/spaces-install.js +638 -0
  440. package/.next/standalone/bin/spaces-reset-totp.js +50 -0
  441. package/.next/standalone/bin/spaces-service.js +1020 -0
  442. package/.next/standalone/bin/spaces-setup.js +253 -0
  443. package/.next/standalone/bin/spaces.js +788 -0
  444. package/.next/standalone/bin/ssh-auth-keys.sh +68 -0
  445. package/.next/standalone/bin/terminal-server.js +1807 -0
  446. package/.next/standalone/docker-compose.yml +28 -0
  447. package/.next/standalone/docs/architecture.md +387 -0
  448. package/.next/standalone/docs/cortex.md +293 -0
  449. package/.next/standalone/docs/getting-started.md +96 -0
  450. package/.next/standalone/docs/plans/2026-02-24-multi-agent-sessions-design.md +133 -0
  451. package/.next/standalone/docs/plans/2026-02-24-multi-agent-sessions-plan.md +959 -0
  452. package/.next/standalone/docs/plans/2026-03-07-service-command-design.md +146 -0
  453. package/.next/standalone/docs/plans/2026-03-07-service-command-plan.md +254 -0
  454. package/.next/standalone/docs/server-install.md +564 -0
  455. package/.next/standalone/docs/social-card.html +150 -0
  456. package/.next/standalone/docs/superpowers/plans/2026-03-12-spaces-cortex.md +5270 -0
  457. package/.next/standalone/docs/superpowers/plans/2026-03-13-cortex-wiring.md +1387 -1387
  458. package/.next/standalone/docs/superpowers/plans/2026-03-14-cortex-v2-entity-graph.md +1923 -1923
  459. package/.next/standalone/docs/superpowers/plans/2026-03-14-cortex-v2-knowledge-evolution.md +1113 -1113
  460. package/.next/standalone/docs/superpowers/plans/2026-03-15-cortex-v2-boundary-engine.md +853 -853
  461. package/.next/standalone/docs/superpowers/plans/2026-03-15-cortex-v2-context-engine.md +1274 -1274
  462. package/.next/standalone/docs/superpowers/plans/2026-03-15-cortex-v2-signal-ingestion.md +933 -933
  463. package/.next/standalone/docs/superpowers/plans/2026-03-16-cortex-lobes.md +1080 -1080
  464. package/.next/standalone/docs/superpowers/plans/2026-03-16-cortex-v2-gravity-system.md +768 -768
  465. package/.next/standalone/docs/superpowers/plans/2026-03-16-cortex-v2-ui.md +1108 -1108
  466. package/.next/standalone/docs/superpowers/plans/2026-03-18-cortex-ui-integration.md +1846 -1846
  467. package/.next/standalone/docs/superpowers/plans/2026-03-19-vr-phase1-shell.md +1639 -0
  468. package/.next/standalone/docs/superpowers/specs/2026-03-11-universe-view-design.md +320 -0
  469. package/.next/standalone/docs/superpowers/specs/2026-03-12-spaces-brain-design.md +720 -0
  470. package/.next/standalone/docs/superpowers/specs/2026-03-13-cortex-wiring-design.md +268 -268
  471. package/.next/standalone/docs/superpowers/specs/2026-03-14-cortex-v2-design.md +623 -623
  472. package/.next/standalone/docs/superpowers/specs/2026-03-16-cortex-lobes-design.md +263 -263
  473. package/.next/standalone/docs/superpowers/specs/2026-03-16-cortex-v2-ui-design.md +240 -240
  474. package/.next/standalone/docs/superpowers/specs/2026-03-16-pane-ux-design.md +77 -0
  475. package/.next/standalone/docs/superpowers/specs/2026-03-18-cortex-ui-integration-design.md +341 -341
  476. package/.next/standalone/docs/superpowers/specs/2026-03-19-vr-phase1-shell-design.md +288 -0
  477. package/.next/standalone/docs/tiers.md +104 -0
  478. package/.next/standalone/eslint.config.mjs +18 -0
  479. package/.next/standalone/next.config.ts +20 -0
  480. package/.next/standalone/nginx.conf +53 -0
  481. package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/README.md +46 -0
  482. package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/lib/glib-2.0/include/glibconfig.h +221 -0
  483. package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/lib/index.js +1 -0
  484. package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.8.17.3 +0 -0
  485. package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/package.json +42 -0
  486. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/README.md +46 -0
  487. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/lib/glib-2.0/include/glibconfig.h +221 -0
  488. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/lib/index.js +1 -0
  489. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/lib/libvips-cpp.so.8.17.3 +0 -0
  490. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/package.json +42 -0
  491. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/versions.json +30 -0
  492. package/.next/standalone/node_modules/@img/sharp-linux-x64/lib/sharp-linux-x64.node +0 -0
  493. package/.next/standalone/node_modules/@img/{sharp-win32-x64 → sharp-linux-x64}/package.json +46 -39
  494. package/.next/standalone/node_modules/@img/sharp-linuxmusl-x64/lib/sharp-linuxmusl-x64.node +0 -0
  495. package/.next/standalone/node_modules/@img/sharp-linuxmusl-x64/package.json +46 -0
  496. package/.next/standalone/package-lock.json +14154 -0
  497. package/.next/standalone/package.json +104 -104
  498. package/.next/standalone/postcss.config.mjs +7 -0
  499. package/.next/standalone/scripts/rebuild.cmd +65 -0
  500. package/.next/standalone/scripts/rebuild.sh +59 -0
  501. package/.next/standalone/server.js +1 -1
  502. package/.next/standalone/src/app/(desktop)/admin/analytics/page.tsx +266 -0
  503. package/.next/standalone/src/app/(desktop)/admin/users/page.tsx +399 -0
  504. package/.next/standalone/src/app/(desktop)/analytics/page.tsx +166 -0
  505. package/.next/standalone/src/app/(desktop)/cortex/page.tsx +81 -78
  506. package/.next/standalone/src/app/(desktop)/dashboard-client.tsx +56 -0
  507. package/.next/standalone/src/app/(desktop)/layout.tsx +18 -0
  508. package/.next/standalone/src/app/(desktop)/network/page.tsx +137 -0
  509. package/.next/standalone/src/app/(desktop)/page.tsx +17 -0
  510. package/.next/standalone/src/app/(desktop)/projects/page.tsx +68 -0
  511. package/.next/standalone/src/app/(desktop)/sessions/[id]/page.tsx +519 -0
  512. package/.next/standalone/src/app/(desktop)/sessions/page.tsx +145 -0
  513. package/.next/standalone/src/app/(desktop)/settings/page.tsx +446 -0
  514. package/.next/standalone/src/app/(desktop)/terminal/layout.tsx +7 -0
  515. package/.next/standalone/src/app/(desktop)/terminal/page.tsx +1151 -0
  516. package/.next/standalone/src/app/(desktop)/terminal/pane/[id]/page.tsx +211 -0
  517. package/.next/standalone/src/app/(desktop)/terminal/remote/[nodeId]/[workspaceId]/page.tsx +192 -0
  518. package/.next/standalone/src/app/(desktop)/workspaces/page.tsx +12 -0
  519. package/.next/standalone/src/app/api/admin/analytics/route.ts +10 -0
  520. package/.next/standalone/src/app/api/admin/users/[id]/route.ts +20 -0
  521. package/.next/standalone/src/app/api/admin/users/route.ts +15 -0
  522. package/.next/standalone/src/app/api/analytics/overview/route.ts +80 -0
  523. package/.next/standalone/src/app/api/auth/login/route.ts +10 -0
  524. package/.next/standalone/src/app/api/auth/logout/route.ts +9 -0
  525. package/.next/standalone/src/app/api/auth/me/route.ts +22 -0
  526. package/.next/standalone/src/app/api/auth/totp/setup/route.ts +10 -0
  527. package/.next/standalone/src/app/api/auth/totp/status/route.ts +10 -0
  528. package/.next/standalone/src/app/api/auth/totp/verify/route.ts +10 -0
  529. package/.next/standalone/src/app/api/benchmark/lobes/route.ts +16 -0
  530. package/.next/standalone/src/app/api/benchmark/run/route.ts +92 -0
  531. package/.next/standalone/src/app/api/benchmark/runs/[id]/route.ts +26 -0
  532. package/.next/standalone/src/app/api/benchmark/runs/route.ts +16 -0
  533. package/.next/standalone/src/app/api/benchmark/status/route.ts +35 -0
  534. package/.next/standalone/src/app/api/bulk/route.ts +34 -0
  535. package/.next/standalone/src/app/api/chat/route.ts +85 -0
  536. package/.next/standalone/src/app/api/config/route.ts +30 -0
  537. package/.next/standalone/src/app/api/cortex/context/route.ts +78 -78
  538. package/.next/standalone/src/app/api/cortex/curation/assess/route.ts +27 -27
  539. package/.next/standalone/src/app/api/cortex/curation/publish/route.ts +23 -23
  540. package/.next/standalone/src/app/api/cortex/curation/refine/route.ts +23 -23
  541. package/.next/standalone/src/app/api/cortex/curation/review/route.ts +29 -29
  542. package/.next/standalone/src/app/api/cortex/curation/seed/route.ts +23 -23
  543. package/.next/standalone/src/app/api/cortex/export/route.ts +40 -40
  544. package/.next/standalone/src/app/api/cortex/federation/pending/route.ts +20 -20
  545. package/.next/standalone/src/app/api/cortex/federation/resolve/route.ts +43 -43
  546. package/.next/standalone/src/app/api/cortex/federation/search/route.ts +35 -35
  547. package/.next/standalone/src/app/api/cortex/federation/teach/route.ts +76 -76
  548. package/.next/standalone/src/app/api/cortex/graph/edges/route.ts +112 -112
  549. package/.next/standalone/src/app/api/cortex/graph/entities/[id]/route.ts +73 -73
  550. package/.next/standalone/src/app/api/cortex/graph/entities/route.ts +75 -75
  551. package/.next/standalone/src/app/api/cortex/graph/populate/route.ts +203 -203
  552. package/.next/standalone/src/app/api/cortex/import/route.ts +75 -75
  553. package/.next/standalone/src/app/api/cortex/import/status/route.ts +15 -15
  554. package/.next/standalone/src/app/api/cortex/ingest/bootstrap/route.ts +29 -29
  555. package/.next/standalone/src/app/api/cortex/ingest/status/route.ts +15 -15
  556. package/.next/standalone/src/app/api/cortex/knowledge/[id]/route.ts +91 -91
  557. package/.next/standalone/src/app/api/cortex/knowledge/route.ts +93 -93
  558. package/.next/standalone/src/app/api/cortex/lobes/[id]/route.ts +67 -67
  559. package/.next/standalone/src/app/api/cortex/lobes/route.ts +22 -22
  560. package/.next/standalone/src/app/api/cortex/lobes/share/route.ts +80 -80
  561. package/.next/standalone/src/app/api/cortex/marketplace/browse/route.ts +43 -43
  562. package/.next/standalone/src/app/api/cortex/marketplace/preview/route.ts +46 -46
  563. package/.next/standalone/src/app/api/cortex/mcp/call/route.ts +11 -11
  564. package/.next/standalone/src/app/api/cortex/mcp/tools/route.ts +6 -6
  565. package/.next/standalone/src/app/api/cortex/search/route.ts +43 -43
  566. package/.next/standalone/src/app/api/cortex/settings/route.ts +33 -33
  567. package/.next/standalone/src/app/api/cortex/status/route.ts +169 -169
  568. package/.next/standalone/src/app/api/cortex/timeline/route.ts +42 -42
  569. package/.next/standalone/src/app/api/cortex/usage/route.ts +31 -31
  570. package/.next/standalone/src/app/api/cortex/workspace/[id]/context/route.ts +41 -41
  571. package/.next/standalone/src/app/api/events/route.ts +40 -0
  572. package/.next/standalone/src/app/api/files/route.ts +187 -0
  573. package/.next/standalone/src/app/api/folders/route.ts +97 -0
  574. package/.next/standalone/src/app/api/network/connect-callback/route.ts +11 -0
  575. package/.next/standalone/src/app/api/network/connect-request/[id]/route.ts +11 -0
  576. package/.next/standalone/src/app/api/network/connect-request/route.ts +17 -0
  577. package/.next/standalone/src/app/api/network/discovered/route.ts +9 -0
  578. package/.next/standalone/src/app/api/network/handshake/route.ts +25 -0
  579. package/.next/standalone/src/app/api/network/health/route.ts +10 -0
  580. package/.next/standalone/src/app/api/network/identity/route.ts +15 -0
  581. package/.next/standalone/src/app/api/network/keys/[id]/route.ts +10 -0
  582. package/.next/standalone/src/app/api/network/keys/route.ts +15 -0
  583. package/.next/standalone/src/app/api/network/nodes/[id]/route.ts +15 -0
  584. package/.next/standalone/src/app/api/network/nodes/check/route.ts +9 -0
  585. package/.next/standalone/src/app/api/network/nodes/route.ts +15 -0
  586. package/.next/standalone/src/app/api/network/projects/route.ts +25 -0
  587. package/.next/standalone/src/app/api/network/proxy/[nodeId]/[...path]/route.ts +15 -0
  588. package/.next/standalone/src/app/api/network/search/route.ts +38 -0
  589. package/.next/standalone/src/app/api/network/sessions/[id]/messages/route.ts +36 -0
  590. package/.next/standalone/src/app/api/network/sessions/[id]/route.ts +34 -0
  591. package/.next/standalone/src/app/api/network/sessions/route.ts +43 -0
  592. package/.next/standalone/src/app/api/network/terminal/token/route.ts +10 -0
  593. package/.next/standalone/src/app/api/network/workspaces/[id]/route.ts +34 -0
  594. package/.next/standalone/src/app/api/network/workspaces/route.ts +61 -0
  595. package/.next/standalone/src/app/api/panes/[id]/route.ts +60 -0
  596. package/.next/standalone/src/app/api/panes/route.ts +39 -0
  597. package/.next/standalone/src/app/api/projects/route.ts +13 -0
  598. package/.next/standalone/src/app/api/search/route.ts +47 -0
  599. package/.next/standalone/src/app/api/sessions/[id]/chat/route.ts +120 -0
  600. package/.next/standalone/src/app/api/sessions/[id]/messages/route.ts +28 -0
  601. package/.next/standalone/src/app/api/sessions/[id]/route.ts +73 -0
  602. package/.next/standalone/src/app/api/sessions/route.ts +64 -0
  603. package/.next/standalone/src/app/api/sync/route.ts +24 -0
  604. package/.next/standalone/src/app/api/tags/route.ts +35 -0
  605. package/.next/standalone/src/app/api/tier/route.ts +16 -0
  606. package/.next/standalone/src/app/api/updates/route.ts +53 -0
  607. package/.next/standalone/src/app/api/whisper/route.ts +90 -0
  608. package/.next/standalone/src/app/api/workspaces/[id]/context/[key]/route.ts +39 -0
  609. package/.next/standalone/src/app/api/workspaces/[id]/context/route.ts +28 -0
  610. package/.next/standalone/src/app/api/workspaces/[id]/messages/[msgId]/route.ts +17 -0
  611. package/.next/standalone/src/app/api/workspaces/[id]/messages/route.ts +39 -0
  612. package/.next/standalone/src/app/api/workspaces/[id]/route.ts +47 -0
  613. package/.next/standalone/src/app/api/workspaces/[id]/sessions/route.ts +62 -0
  614. package/.next/standalone/src/app/api/workspaces/route.ts +79 -0
  615. package/.next/standalone/src/app/globals.css +85 -0
  616. package/.next/standalone/src/app/icon.png +0 -0
  617. package/.next/standalone/src/app/layout.tsx +33 -0
  618. package/.next/standalone/src/app/login/layout.tsx +7 -0
  619. package/.next/standalone/src/app/login/page.tsx +315 -0
  620. package/.next/standalone/src/app/m/layout.tsx +16 -0
  621. package/.next/standalone/src/app/m/page.tsx +118 -0
  622. package/.next/standalone/src/app/m/projects/page.tsx +64 -0
  623. package/.next/standalone/src/app/m/sessions/[id]/page.tsx +168 -0
  624. package/.next/standalone/src/app/m/sessions/page.tsx +177 -0
  625. package/.next/standalone/src/app/m/settings/page.tsx +230 -0
  626. package/.next/standalone/src/app/m/terminal/page.tsx +413 -0
  627. package/.next/standalone/src/app/vr/page.tsx +21 -0
  628. package/.next/standalone/src/app/vr/vr-app.tsx +163 -0
  629. package/.next/standalone/src/app/vr/vr-controls.tsx +139 -0
  630. package/.next/standalone/src/app/vr/vr-door.tsx +82 -0
  631. package/.next/standalone/src/app/vr/vr-environment.tsx +71 -0
  632. package/.next/standalone/src/app/vr/vr-gaze.tsx +89 -0
  633. package/.next/standalone/src/app/vr/vr-layout.ts +49 -0
  634. package/.next/standalone/src/app/vr/vr-lobby.tsx +97 -0
  635. package/.next/standalone/src/app/vr/vr-pane.tsx +195 -0
  636. package/.next/standalone/src/app/vr/vr-room.tsx +79 -0
  637. package/.next/standalone/src/app/vr/vr-terminal.tsx +303 -0
  638. package/.next/standalone/src/components/auth/totp-gate.tsx +183 -0
  639. package/.next/standalone/src/components/bus/activity-panel.tsx +261 -0
  640. package/.next/standalone/src/components/common/color-picker.tsx +35 -0
  641. package/.next/standalone/src/components/common/dev-directory-picker.tsx +339 -0
  642. package/.next/standalone/src/components/common/folder-picker.tsx +200 -0
  643. package/.next/standalone/src/components/common/tag-picker.tsx +190 -0
  644. package/.next/standalone/src/components/common/workspace-picker.tsx +113 -0
  645. package/.next/standalone/src/components/cortex/benchmark-tab.tsx +880 -0
  646. package/.next/standalone/src/components/cortex/constants.ts +29 -29
  647. package/.next/standalone/src/components/cortex/cortex-dashboard.tsx +304 -304
  648. package/.next/standalone/src/components/cortex/cortex-indicator.tsx +44 -44
  649. package/.next/standalone/src/components/cortex/cortex-panel.tsx +140 -140
  650. package/.next/standalone/src/components/cortex/cortex-settings.tsx +221 -221
  651. package/.next/standalone/src/components/cortex/curation-tab.tsx +810 -810
  652. package/.next/standalone/src/components/cortex/entity-detail.tsx +101 -101
  653. package/.next/standalone/src/components/cortex/entity-graph.tsx +382 -382
  654. package/.next/standalone/src/components/cortex/import-dialog.tsx +212 -212
  655. package/.next/standalone/src/components/cortex/injection-badge.tsx +72 -72
  656. package/.next/standalone/src/components/cortex/knowledge-card.tsx +109 -109
  657. package/.next/standalone/src/components/cortex/knowledge-tab.tsx +158 -158
  658. package/.next/standalone/src/components/cortex/lobe-settings.tsx +215 -215
  659. package/.next/standalone/src/components/cortex/marketplace-card.tsx +126 -126
  660. package/.next/standalone/src/components/cortex/marketplace-tab.tsx +113 -113
  661. package/.next/standalone/src/components/dashboard/activity-chart.tsx +41 -0
  662. package/.next/standalone/src/components/dashboard/model-usage-chart.tsx +61 -0
  663. package/.next/standalone/src/components/dashboard/recent-sessions.tsx +68 -0
  664. package/.next/standalone/src/components/dashboard/stats-cards.tsx +36 -0
  665. package/.next/standalone/src/components/files/file-explorer.tsx +703 -0
  666. package/.next/standalone/src/components/layout/providers.tsx +38 -0
  667. package/.next/standalone/src/components/layout/sidebar.tsx +170 -0
  668. package/.next/standalone/src/components/layout/tier-provider.tsx +53 -0
  669. package/.next/standalone/src/components/layout/update-banner.tsx +92 -0
  670. package/.next/standalone/src/components/mobile/bottom-nav.tsx +46 -0
  671. package/.next/standalone/src/components/mobile/mobile-chat-input.tsx +244 -0
  672. package/.next/standalone/src/components/mobile/mobile-header.tsx +44 -0
  673. package/.next/standalone/src/components/mobile/mobile-session-card.tsx +56 -0
  674. package/.next/standalone/src/components/mobile/mobile-terminal-input.tsx +71 -0
  675. package/.next/standalone/src/components/mobile/mobile-terminal-pane.tsx +300 -0
  676. package/.next/standalone/src/components/mobile/mobile-terminal-toolbar.tsx +53 -0
  677. package/.next/standalone/src/components/mobile/pull-to-refresh.tsx +82 -0
  678. package/.next/standalone/src/components/mobile/voice-input.tsx +53 -0
  679. package/.next/standalone/src/components/network/api-key-list.tsx +190 -0
  680. package/.next/standalone/src/components/network/connection-requests.tsx +94 -0
  681. package/.next/standalone/src/components/network/node-add-dialog.tsx +131 -0
  682. package/.next/standalone/src/components/network/node-badge.tsx +26 -0
  683. package/.next/standalone/src/components/network/node-list.tsx +207 -0
  684. package/.next/standalone/src/components/network/node-selector.tsx +49 -0
  685. package/.next/standalone/src/components/sessions/session-filters.tsx +116 -0
  686. package/.next/standalone/src/components/sessions/session-list.tsx +485 -0
  687. package/.next/standalone/src/components/terminal/terminal-pane.tsx +874 -0
  688. package/.next/standalone/src/components/viewer/chat-input.tsx +275 -0
  689. package/.next/standalone/src/components/viewer/message-renderer.tsx +551 -0
  690. package/.next/standalone/src/components/workspace/universe-cluster.tsx +131 -0
  691. package/.next/standalone/src/components/workspace/universe-orb.tsx +128 -0
  692. package/.next/standalone/src/components/workspace/universe-types.ts +22 -0
  693. package/.next/standalone/src/components/workspace/universe-utils.ts +11 -0
  694. package/.next/standalone/src/components/workspace/universe-view.tsx +397 -0
  695. package/.next/standalone/src/components/workspace/workspace-chooser.tsx +616 -0
  696. package/.next/standalone/src/hooks/use-benchmark.ts +71 -0
  697. package/.next/standalone/src/hooks/use-bus.ts +147 -0
  698. package/.next/standalone/src/hooks/use-idle-detection.ts +79 -0
  699. package/.next/standalone/src/hooks/use-network.ts +229 -0
  700. package/.next/standalone/src/hooks/use-sessions.ts +437 -0
  701. package/.next/standalone/src/hooks/use-speech-recognition.ts +113 -0
  702. package/.next/standalone/src/hooks/use-sse.ts +35 -0
  703. package/.next/standalone/src/hooks/use-tier.ts +39 -0
  704. package/.next/standalone/src/lib/agents.ts +70 -0
  705. package/.next/standalone/src/lib/aider/parser.ts +111 -0
  706. package/.next/standalone/src/lib/api.ts +19 -0
  707. package/.next/standalone/src/lib/auth.ts +47 -0
  708. package/.next/standalone/src/lib/claude/parser.ts +212 -0
  709. package/.next/standalone/src/lib/claude/stats.ts +204 -0
  710. package/.next/standalone/src/lib/codex/parser.ts +265 -0
  711. package/.next/standalone/src/lib/config.ts +115 -0
  712. package/.next/standalone/src/lib/cortex/benchmark.ts +67 -0
  713. package/.next/standalone/src/lib/cortex/config.ts +40 -40
  714. package/.next/standalone/src/lib/cortex/debug.ts +10 -10
  715. package/.next/standalone/src/lib/cortex/distillation/usage-store.ts +18 -18
  716. package/.next/standalone/src/lib/cortex/graph/resolver.ts +10 -10
  717. package/.next/standalone/src/lib/cortex/graph/types.ts +22 -22
  718. package/.next/standalone/src/lib/cortex/index.ts +56 -56
  719. package/.next/standalone/src/lib/cortex/ingestion/bootstrap.ts +14 -14
  720. package/.next/standalone/src/lib/cortex/knowledge/compat.ts +14 -14
  721. package/.next/standalone/src/lib/cortex/knowledge/contradiction.ts +10 -10
  722. package/.next/standalone/src/lib/cortex/knowledge/types.ts +67 -67
  723. package/.next/standalone/src/lib/cortex/lobes/config.ts +16 -16
  724. package/.next/standalone/src/lib/cortex/lobes/resolver.ts +8 -8
  725. package/.next/standalone/src/lib/cortex/lobes/shares.ts +14 -14
  726. package/.next/standalone/src/lib/cortex/mcp/server.ts +8 -8
  727. package/.next/standalone/src/lib/cortex/portability/exporter.ts +6 -6
  728. package/.next/standalone/src/lib/cortex/portability/importer.ts +10 -10
  729. package/.next/standalone/src/lib/cortex/retrieval/context-engine.ts +10 -10
  730. package/.next/standalone/src/lib/cortex/types.ts +39 -39
  731. package/.next/standalone/src/lib/cost-calculator.ts +48 -0
  732. package/.next/standalone/src/lib/db/init.ts +71 -0
  733. package/.next/standalone/src/lib/db/queries.ts +718 -0
  734. package/.next/standalone/src/lib/db/schema.ts +202 -0
  735. package/.next/standalone/src/lib/events/sse.ts +36 -0
  736. package/.next/standalone/src/lib/gemini/parser.ts +216 -0
  737. package/.next/standalone/src/lib/license.ts +56 -0
  738. package/.next/standalone/src/lib/pro.ts +31 -0
  739. package/.next/standalone/src/lib/sync/indexer.ts +429 -0
  740. package/.next/standalone/src/lib/sync/watcher.ts +64 -0
  741. package/.next/standalone/src/lib/teams.ts +31 -0
  742. package/.next/standalone/src/lib/telemetry.ts +75 -0
  743. package/.next/standalone/src/lib/terminal/server.ts +128 -0
  744. package/.next/standalone/src/lib/tier.ts +38 -0
  745. package/.next/standalone/src/lib/utils.ts +72 -0
  746. package/.next/standalone/src/middleware.ts +133 -0
  747. package/.next/standalone/src/types/claude.ts +208 -0
  748. package/.next/standalone/src/types/network.ts +61 -0
  749. package/.next/standalone/tests/setup.ts +8 -0
  750. package/.next/standalone/tsconfig.json +34 -34
  751. package/.next/standalone/vitest.config.ts +24 -0
  752. package/LICENSE +661 -661
  753. package/README.md +131 -131
  754. package/bin/cortex-hook.sh +62 -62
  755. package/bin/cortex-mcp.js +60 -60
  756. package/bin/fix-standalone-externals.js +79 -79
  757. package/bin/lib/auto-setup.js +110 -110
  758. package/bin/mdns-service.js +171 -171
  759. package/bin/postinstall.js +35 -35
  760. package/bin/setup-admin.js +195 -195
  761. package/bin/spaces-dev.js +247 -208
  762. package/bin/spaces-install.js +638 -599
  763. package/bin/spaces-reset-totp.js +50 -50
  764. package/bin/spaces-service.js +1020 -1020
  765. package/bin/spaces-setup.js +253 -253
  766. package/bin/spaces.js +788 -776
  767. package/bin/ssh-auth-keys.sh +68 -68
  768. package/bin/terminal-server.js +1807 -1683
  769. package/package.json +104 -104
  770. package/.next/standalone/.claude/settings.local.json +0 -55
  771. package/.next/standalone/.claude/spaces-env.json +0 -1
  772. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__e921fdfc._.js +0 -5
  773. package/.next/standalone/.next/server/chunks/ssr/_2230ad2d._.js +0 -3
  774. package/.next/standalone/.next/server/chunks/ssr/_5cf334fd._.js +0 -3
  775. package/.next/standalone/.next/server/chunks/ssr/_db0abd0a._.js +0 -3
  776. package/.next/standalone/.next/server/chunks/ssr/_db2fec84._.js +0 -3
  777. package/.next/standalone/.next/server/chunks/ssr/_f4e57187._.js +0 -3
  778. package/.next/standalone/.next/server/chunks/ssr/src_40fa36ce._.js +0 -7
  779. package/.next/standalone/.next/static/chunks/003e7aa1adfe577d.js +0 -1
  780. package/.next/standalone/.next/static/chunks/158b52b84e647ac1.js +0 -5
  781. package/.next/standalone/.next/static/chunks/232d8aae4fefab70.js +0 -1
  782. package/.next/standalone/.next/static/chunks/5325351ef49cb65f.js +0 -1
  783. package/.next/standalone/.next/static/chunks/559735e598ca3cbb.js +0 -1
  784. package/.next/standalone/.next/static/chunks/5d5d7b0095dd52ae.js +0 -1
  785. package/.next/standalone/.next/static/chunks/898f380eba90427a.js +0 -1
  786. package/.next/standalone/.next/static/chunks/95339e55722bb4ca.js +0 -5
  787. package/.next/standalone/.next/static/chunks/9cd594813c539df9.js +0 -1
  788. package/.next/standalone/.next/static/chunks/c1a95aebf6725f64.css +0 -3
  789. package/.next/standalone/.next/static/chunks/c515eb77d9410aa0.js +0 -5
  790. package/.next/standalone/.next/static/chunks/d9ae203a7f123546.js +0 -5
  791. package/.next/standalone/.next/static/chunks/f9f2628207848ac2.js +0 -1
  792. package/.next/standalone/.spaces/cortex-context.md +0 -70
  793. package/.next/standalone/node_modules/@img/sharp-win32-x64/lib/sharp-win32-x64.node +0 -0
  794. /package/.next/standalone/.next/static/{BEY-sql3lQLouidpurSQf → PcpzUspSK8QDdwzAJz8br}/_buildManifest.js +0 -0
  795. /package/.next/standalone/.next/static/{BEY-sql3lQLouidpurSQf → PcpzUspSK8QDdwzAJz8br}/_clientMiddlewareManifest.json +0 -0
  796. /package/.next/standalone/.next/static/{BEY-sql3lQLouidpurSQf → PcpzUspSK8QDdwzAJz8br}/_ssgManifest.js +0 -0
  797. /package/.next/standalone/node_modules/@img/{sharp-win32-x64 → sharp-libvips-linux-x64}/versions.json +0 -0
@@ -1,1683 +1,1807 @@
1
- #!/usr/bin/env node
2
-
3
- const { WebSocketServer } = require('ws');
4
- const pty = require('node-pty');
5
- const http = require('http');
6
- const fs = require('fs');
7
- const path = require('path');
8
- const os = require('os');
9
- const crypto = require('crypto');
10
-
11
- const PORT = parseInt(process.env.SPACES_WS_PORT || '3458', 10);
12
- const SPACES_TIER = process.env.SPACES_TIER || 'community';
13
- // API_PORT is the port where Next.js API routes are reachable.
14
- // In attached mode, createTerminalServer() updates this to the parent server's port.
15
- let API_PORT = parseInt(process.env.SPACES_PORT || '3457', 10);
16
-
17
- // Track whether the Next.js API is ready — avoids timeout spam during startup
18
- let _apiReady = false;
19
- function setApiReady() { _apiReady = true; }
20
- function isApiReady() { return _apiReady; }
21
- // Poll until the API responds, then mark ready
22
- function waitForApi() {
23
- const check = () => {
24
- const req = http.get(`http://localhost:${API_PORT}/api/tier`, { timeout: 1000 }, (res) => {
25
- res.resume(); // consume body to free socket
26
- if (res.statusCode < 500) { setApiReady(); return; }
27
- setTimeout(check, 2000);
28
- });
29
- req.on('error', () => setTimeout(check, 2000));
30
- req.on('timeout', () => { req.destroy(); setTimeout(check, 2000); });
31
- };
32
- setTimeout(check, 1000);
33
- }
34
-
35
- // ─── Terminal token verification ──────────────────────────
36
-
37
- const SECRET_PATH = path.join(os.homedir(), '.spaces', 'terminal_secret');
38
-
39
- function getTerminalSecret() {
40
- if (fs.existsSync(SECRET_PATH)) {
41
- return Buffer.from(fs.readFileSync(SECRET_PATH, 'utf-8').trim(), 'hex');
42
- }
43
- const secret = crypto.randomBytes(32);
44
- const dir = path.dirname(SECRET_PATH);
45
- if (!fs.existsSync(dir)) {
46
- fs.mkdirSync(dir, { recursive: true });
47
- }
48
- fs.writeFileSync(SECRET_PATH, secret.toString('hex'), { mode: 0o600 });
49
- return secret;
50
- }
51
-
52
- let _terminalSecret = null;
53
- function terminalSecret() {
54
- if (!_terminalSecret) {
55
- _terminalSecret = getTerminalSecret();
56
- }
57
- return _terminalSecret;
58
- }
59
-
60
- function verifyTerminalToken(token) {
61
- if (!token) return null;
62
- const parts = token.split('.');
63
- if (parts.length !== 2) return null;
64
-
65
- const [payloadB64, sig] = parts;
66
- const expectedSig = crypto.createHmac('sha256', terminalSecret())
67
- .update(payloadB64)
68
- .digest('base64url');
69
-
70
- try {
71
- if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
72
- return null;
73
- }
74
- } catch {
75
- return null;
76
- }
77
-
78
- try {
79
- const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
80
- if (payload.exp < Math.floor(Date.now() / 1000)) {
81
- return null;
82
- }
83
- return payload.sub || null;
84
- } catch {
85
- return null;
86
- }
87
- }
88
-
89
- // ─── Session token verification (for self-contained auth) ──
90
-
91
- const SESSION_SECRET_PATH = path.join(os.homedir(), '.spaces', 'session_secret');
92
-
93
- function getSessionSecret() {
94
- if (fs.existsSync(SESSION_SECRET_PATH)) {
95
- return Buffer.from(fs.readFileSync(SESSION_SECRET_PATH, 'utf-8').trim(), 'hex');
96
- }
97
- return null;
98
- }
99
-
100
- let _sessionSecret = null;
101
- function sessionSecret() {
102
- if (!_sessionSecret) {
103
- _sessionSecret = getSessionSecret();
104
- }
105
- return _sessionSecret;
106
- }
107
-
108
- function verifySessionToken(token) {
109
- const secret = sessionSecret();
110
- if (!token || !secret) return null;
111
- const parts = token.split('.');
112
- if (parts.length !== 2) return null;
113
-
114
- const [payloadB64, sig] = parts;
115
- const expectedSig = crypto.createHmac('sha256', secret)
116
- .update(payloadB64)
117
- .digest('base64url');
118
-
119
- try {
120
- if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
121
- return null;
122
- }
123
- } catch {
124
- return null;
125
- }
126
-
127
- try {
128
- const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
129
- if (payload.exp < Math.floor(Date.now() / 1000)) {
130
- return null;
131
- }
132
- return { sub: payload.sub, role: payload.role || 'user' };
133
- } catch {
134
- return null;
135
- }
136
- }
137
-
138
- // ─── Admin DB for shell user lookup ─────────────────────────
139
-
140
- const ADMIN_DB_PATH = path.join(os.homedir(), '.spaces', 'admin.db');
141
- let _adminDb = null;
142
-
143
- function getAdminDb() {
144
- if (_adminDb) return _adminDb;
145
- if (!fs.existsSync(ADMIN_DB_PATH)) return null;
146
- try {
147
- const Database = require('better-sqlite3');
148
- _adminDb = new Database(ADMIN_DB_PATH, { readonly: true });
149
- return _adminDb;
150
- } catch {
151
- return null;
152
- }
153
- }
154
-
155
- function lookupShellUser(appUsername) {
156
- const db = getAdminDb();
157
- if (!db) return appUsername;
158
- try {
159
- const row = db.prepare('SELECT shell_user FROM users WHERE username = ?').get(appUsername);
160
- return row ? row.shell_user : appUsername;
161
- } catch {
162
- return appUsername;
163
- }
164
- }
165
-
166
- // ─── Network DB for federation ───────────────────────────────
167
-
168
- const NETWORK_DB_PATH = path.join(os.homedir(), '.spaces', 'network.db');
169
- let _networkDb = null;
170
-
171
- function getNetworkDb() {
172
- if (_networkDb) return _networkDb;
173
- if (!fs.existsSync(NETWORK_DB_PATH)) return null;
174
- try {
175
- const Database = require('better-sqlite3');
176
- _networkDb = new Database(NETWORK_DB_PATH, { readonly: true });
177
- return _networkDb;
178
- } catch {
179
- return null;
180
- }
181
- }
182
-
183
- function validateNetworkApiKey(rawKey) {
184
- const db = getNetworkDb();
185
- if (!db || !rawKey || !rawKey.startsWith('spk_')) return null;
186
- try {
187
- const keys = db.prepare('SELECT * FROM api_keys').all();
188
- for (const key of keys) {
189
- if (key.expires && new Date(key.expires) < new Date()) continue;
190
- const [salt, hash] = key.key_hash.split(':');
191
- if (!salt || !hash) continue;
192
- const derived = crypto.scryptSync(rawKey, salt, 64).toString('hex');
193
- try {
194
- if (crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(derived, 'hex'))) {
195
- return key;
196
- }
197
- } catch { continue; }
198
- }
199
- } catch { /* ignore */ }
200
- return null;
201
- }
202
-
203
- function getNodeInfo(nodeId) {
204
- const db = getNetworkDb();
205
- if (!db) return null;
206
- try {
207
- return db.prepare('SELECT * FROM nodes WHERE id = ?').get(nodeId);
208
- } catch {
209
- return null;
210
- }
211
- }
212
-
213
- function decryptNodeApiKey(encrypted) {
214
- try {
215
- const key = Buffer.from(fs.readFileSync(SECRET_PATH, 'utf-8').trim(), 'hex');
216
- const [ivB64, tagB64, dataB64] = encrypted.split(':');
217
- if (!ivB64 || !tagB64 || !dataB64) return null;
218
- const iv = Buffer.from(ivB64, 'base64');
219
- const tag = Buffer.from(tagB64, 'base64');
220
- const data = Buffer.from(dataB64, 'base64');
221
- const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
222
- decipher.setAuthTag(tag);
223
- return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf-8');
224
- } catch {
225
- return null;
226
- }
227
- }
228
-
229
- // ─── Writable Admin DB for analytics ─────────────────────────
230
-
231
- let _adminDbRW = null;
232
-
233
- function getAdminDbRW() {
234
- if (_adminDbRW) return _adminDbRW;
235
- try {
236
- const Database = require('better-sqlite3');
237
- const dir = path.dirname(ADMIN_DB_PATH);
238
- if (!fs.existsSync(dir)) {
239
- fs.mkdirSync(dir, { recursive: true });
240
- }
241
- const db = new Database(ADMIN_DB_PATH);
242
- db.pragma('journal_mode = WAL');
243
- db.pragma('busy_timeout = 5000');
244
- db.exec(`
245
- CREATE TABLE IF NOT EXISTS login_events (
246
- id INTEGER PRIMARY KEY AUTOINCREMENT,
247
- username TEXT NOT NULL,
248
- timestamp TEXT NOT NULL DEFAULT (datetime('now')),
249
- ip_address TEXT,
250
- user_agent TEXT
251
- );
252
- CREATE TABLE IF NOT EXISTS terminal_sessions (
253
- id TEXT PRIMARY KEY,
254
- username TEXT NOT NULL,
255
- agent_type TEXT NOT NULL DEFAULT 'shell',
256
- started_at TEXT NOT NULL DEFAULT (datetime('now')),
257
- ended_at TEXT,
258
- duration_seconds INTEGER
259
- );
260
- CREATE INDEX IF NOT EXISTS idx_login_events_username ON login_events(username);
261
- CREATE INDEX IF NOT EXISTS idx_login_events_timestamp ON login_events(timestamp);
262
- CREATE INDEX IF NOT EXISTS idx_terminal_sessions_username ON terminal_sessions(username);
263
- CREATE INDEX IF NOT EXISTS idx_terminal_sessions_started_at ON terminal_sessions(started_at);
264
- `);
265
- // Clean up stale sessions from previous crashes
266
- db.prepare(`
267
- UPDATE terminal_sessions
268
- SET ended_at = datetime('now'),
269
- duration_seconds = CAST((julianday('now') - julianday(started_at)) * 86400 AS INTEGER)
270
- WHERE ended_at IS NULL
271
- `).run();
272
- _adminDbRW = db;
273
- console.log('[Analytics] Writable admin DB connected, stale sessions cleaned up');
274
- return db;
275
- } catch (err) {
276
- console.error('[Analytics] Failed to open writable admin DB:', err.message);
277
- return null;
278
- }
279
- }
280
-
281
- function analyticsRecordSessionStart(paneId, username, agentType) {
282
- try {
283
- const db = getAdminDbRW();
284
- if (!db) return;
285
- db.prepare(
286
- 'INSERT OR REPLACE INTO terminal_sessions (id, username, agent_type) VALUES (?, ?, ?)'
287
- ).run(paneId, username, agentType);
288
- } catch (err) {
289
- console.error('[Analytics] recordSessionStart error:', err.message);
290
- }
291
- }
292
-
293
- function analyticsRecordSessionEnd(paneId) {
294
- try {
295
- const db = getAdminDbRW();
296
- if (!db) return;
297
- db.prepare(`
298
- UPDATE terminal_sessions
299
- SET ended_at = datetime('now'),
300
- duration_seconds = CAST((julianday('now') - julianday(started_at)) * 86400 AS INTEGER)
301
- WHERE id = ? AND ended_at IS NULL
302
- `).run(paneId);
303
- } catch (err) {
304
- console.error('[Analytics] recordSessionEnd error:', err.message);
305
- }
306
- }
307
-
308
- // ─── Cookie parser ──────────────────────────────────────────
309
-
310
- function parseCookies(cookieHeader) {
311
- const cookies = {};
312
- if (!cookieHeader) return cookies;
313
- cookieHeader.split(';').forEach(part => {
314
- const [key, ...rest] = part.trim().split('=');
315
- if (key) cookies[key] = rest.join('=');
316
- });
317
- return cookies;
318
- }
319
-
320
- // ─── SSH service key path (used to spawn shells as other OS users) ──
321
-
322
- const SERVICE_KEY = path.join(os.homedir(), '.spaces', 'service_key');
323
-
324
- // Ensure the SSH service key exists, has correct permissions, and is authorized.
325
- function ensureServiceKeyAtRuntime() {
326
- const { spawnSync } = require('child_process');
327
- const currentUser = os.userInfo().username;
328
- const isWindows = process.platform === 'win32';
329
-
330
- // Generate key if missing (all platforms)
331
- if (!fs.existsSync(SERVICE_KEY)) {
332
- const dir = path.dirname(SERVICE_KEY);
333
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
334
- const result = spawnSync('ssh-keygen', [
335
- '-t', 'ed25519', '-f', SERVICE_KEY, '-N', '', '-C', 'spaces-service-key',
336
- ], { stdio: 'pipe', timeout: 10000 });
337
- if (result.status !== 0) {
338
- console.error('[SSH] Failed to generate service key');
339
- return;
340
- }
341
- if (isWindows) {
342
- // Lock down permissions: only the process owner + SYSTEM
343
- spawnSync('icacls', [SERVICE_KEY, '/inheritance:r',
344
- '/remove', 'BUILTIN\\Administrators', '/remove', 'BUILTIN\\Users', '/remove', 'Everyone',
345
- '/grant:r', currentUser + ':(F)',
346
- '/grant', 'NT AUTHORITY\\SYSTEM:(F)'], { stdio: 'pipe', timeout: 5000 });
347
- } else {
348
- spawnSync('chmod', ['600', SERVICE_KEY], { stdio: 'pipe', timeout: 5000 });
349
- }
350
- console.log('[SSH] Generated service key as ' + currentUser);
351
- }
352
-
353
- // Always ensure the public key is authorized
354
- if (!fs.existsSync(SERVICE_KEY + '.pub')) return;
355
- const pubKey = fs.readFileSync(SERVICE_KEY + '.pub', 'utf-8').trim();
356
-
357
- if (isWindows) {
358
- // Authorize in administrators_authorized_keys (for admin shell users)
359
- try {
360
- const adminAuthKeys = path.join(process.env.ProgramData || 'C:\\ProgramData', 'ssh', 'administrators_authorized_keys');
361
- const authDir = path.dirname(adminAuthKeys);
362
- if (!fs.existsSync(authDir)) fs.mkdirSync(authDir, { recursive: true });
363
- spawnSync('icacls', [adminAuthKeys, '/inheritance:r',
364
- '/grant:r', 'SYSTEM:(F)', '/grant', 'Administrators:(R)'], { stdio: 'pipe', timeout: 5000 });
365
- let existing = '';
366
- try { existing = fs.readFileSync(adminAuthKeys, 'utf-8'); } catch {}
367
- if (!existing.includes(pubKey)) {
368
- fs.appendFileSync(adminAuthKeys, pubKey + String.fromCharCode(10));
369
- console.log('[SSH] Authorized service key in administrators_authorized_keys');
370
- }
371
- } catch (e) {
372
- console.error('[SSH] Could not authorize admin key (non-fatal):', e.message);
373
- }
374
-
375
- // Authorize in each shell user's ~/.ssh/authorized_keys (for non-admin users)
376
- try {
377
- const usersDir = path.dirname(os.homedir());
378
- const skip = new Set(['Public', 'Default', 'Default User', 'All Users']);
379
- const profiles = fs.readdirSync(usersDir)
380
- .filter(name => !skip.has(name) && !name.startsWith('.'))
381
- .filter(name => fs.existsSync(path.join(usersDir, name, '.claude')));
382
- for (const username of profiles) {
383
- try {
384
- const sshDir = path.join(usersDir, username, '.ssh');
385
- if (!fs.existsSync(sshDir)) fs.mkdirSync(sshDir, { recursive: true });
386
- const authKeysPath = path.join(sshDir, 'authorized_keys');
387
- let existing = '';
388
- try { existing = fs.readFileSync(authKeysPath, 'utf-8'); } catch {}
389
- if (!existing.includes(pubKey)) {
390
- fs.appendFileSync(authKeysPath, pubKey + String.fromCharCode(10));
391
- spawnSync('icacls', [authKeysPath, '/inheritance:r',
392
- '/grant:r', username + ':(F)',
393
- '/grant', 'NT AUTHORITY\\SYSTEM:(F)'], { stdio: 'pipe', timeout: 5000 });
394
- console.log('[SSH] Authorized service key for user ' + username);
395
- }
396
- } catch (e) {
397
- console.error('[SSH] Could not authorize key for ' + username + ' (non-fatal):', e.message);
398
- }
399
- }
400
- } catch (e) {
401
- console.error('[SSH] Could not scan user profiles (non-fatal):', e.message);
402
- }
403
- } else {
404
- // Linux/macOS: authorize all shell users from admin DB as a fallback
405
- // (AuthorizedKeysCommand is the primary mechanism, this is belt-and-suspenders)
406
- const db = getAdminDb();
407
- if (db) {
408
- try {
409
- const users = db.prepare('SELECT DISTINCT shell_user FROM users').all();
410
- for (const row of users) {
411
- const shellUser = row.shell_user;
412
- try {
413
- authorizeShellUser(shellUser, pubKey);
414
- } catch (e) {
415
- console.error('[SSH] Could not authorize key for ' + shellUser + ' (non-fatal):', e.message);
416
- }
417
- }
418
- } catch (e) {
419
- console.error('[SSH] Could not query admin DB for shell users (non-fatal):', e.message);
420
- }
421
- }
422
- }
423
- }
424
-
425
- // Authorize the service key for a Linux/macOS shell user
426
- function authorizeShellUser(shellUser, pubKey) {
427
- const { spawnSync } = require('child_process');
428
-
429
- // Resolve home directory
430
- let userHome;
431
- try {
432
- const result = spawnSync('getent', ['passwd', shellUser], { encoding: 'utf-8', timeout: 5000 });
433
- const fields = (result.stdout || '').split(':');
434
- userHome = fields[5];
435
- } catch {}
436
- if (!userHome) {
437
- userHome = process.platform === 'darwin' ? `/Users/${shellUser}` : `/home/${shellUser}`;
438
- }
439
- if (!fs.existsSync(userHome)) return;
440
-
441
- const sshDir = path.join(userHome, '.ssh');
442
- const authKeysPath = path.join(sshDir, 'authorized_keys');
443
-
444
- // Check if already authorized
445
- let existing = '';
446
- try { existing = fs.readFileSync(authKeysPath, 'utf-8'); } catch {}
447
- if (existing.includes(pubKey)) return;
448
-
449
- // Create .ssh dir with correct ownership
450
- if (!fs.existsSync(sshDir)) {
451
- spawnSync('sudo', ['mkdir', '-p', sshDir], { stdio: 'pipe', timeout: 5000 });
452
- spawnSync('sudo', ['chmod', '700', sshDir], { stdio: 'pipe', timeout: 5000 });
453
- spawnSync('sudo', ['chown', `${shellUser}:${shellUser}`, sshDir], { stdio: 'pipe', timeout: 5000 });
454
- }
455
-
456
- // Append key and fix permissions
457
- const tmpFile = `/tmp/spaces-authkey-${shellUser}-${Date.now()}`;
458
- fs.writeFileSync(tmpFile, existing + pubKey + '\n');
459
- spawnSync('sudo', ['cp', tmpFile, authKeysPath], { stdio: 'pipe', timeout: 5000 });
460
- spawnSync('sudo', ['chmod', '600', authKeysPath], { stdio: 'pipe', timeout: 5000 });
461
- spawnSync('sudo', ['chown', `${shellUser}:${shellUser}`, authKeysPath], { stdio: 'pipe', timeout: 5000 });
462
- try { fs.unlinkSync(tmpFile); } catch {}
463
-
464
- console.log('[SSH] Authorized service key for user ' + shellUser);
465
- }
466
- try { ensureServiceKeyAtRuntime(); } catch (e) { console.error('[SSH] Key setup failed (non-fatal):', e.message); }
467
-
468
- // Session store: keeps ptys alive across WebSocket reconnections
469
- // Key: paneId, Value: { pty, ws (current WebSocket or null), buffer (rolling output), username }
470
- const sessions = new Map();
471
-
472
- const MAX_BUFFER_LINES = 500;
473
-
474
- // ─── Agent definitions (mirrors src/lib/agents.ts) ────────
475
- const AGENTS = {
476
- shell: { command: '', resumeFlag: '', resumeStyle: '' },
477
- claude: { command: 'claude', resumeFlag: '--resume', resumeStyle: 'flag' },
478
- codex: { command: 'codex', resumeFlag: 'resume', resumeStyle: 'subcommand' },
479
- gemini: { command: 'gemini', resumeFlag: '--resume', resumeStyle: 'flag' },
480
- aider: { command: 'aider', resumeFlag: '', resumeStyle: '' },
481
- custom: { command: '', resumeFlag: '', resumeStyle: '' },
482
- };
483
-
484
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
485
-
486
- // ─── Remove Cortex hooks from Claude Code config ─────────
487
- function removeCortexHookConfig(cwd) {
488
- try {
489
- const settingsPath = path.join(cwd, '.claude', 'settings.local.json');
490
- if (!fs.existsSync(settingsPath)) return;
491
- const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
492
- let changed = false;
493
-
494
- // Remove cortex hooks from UserPromptSubmit and Stop
495
- if (settings.hooks?.UserPromptSubmit) {
496
- settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
497
- (g) => !g.hooks?.some((h) => h.command?.includes('cortex-hook'))
498
- );
499
- if (settings.hooks.UserPromptSubmit.length === 0) delete settings.hooks.UserPromptSubmit;
500
- changed = true;
501
- }
502
- if (settings.hooks?.Stop) {
503
- settings.hooks.Stop = settings.hooks.Stop.filter(
504
- (g) => !g.hooks?.some((h) => h.command?.includes('cortex-learn-hook'))
505
- );
506
- if (settings.hooks.Stop.length === 0) delete settings.hooks.Stop;
507
- changed = true;
508
- }
509
-
510
- // Remove cortex MCP server
511
- if (settings.mcpServers?.cortex) {
512
- delete settings.mcpServers.cortex;
513
- changed = true;
514
- }
515
-
516
- if (changed) {
517
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
518
- console.log(`[Cortex] Removed hooks and MCP server from ${settingsPath}`);
519
- }
520
-
521
- // Remove spaces-env.json
522
- const envFile = path.join(cwd, '.claude', 'spaces-env.json');
523
- if (fs.existsSync(envFile)) fs.unlinkSync(envFile);
524
- } catch (err) {
525
- console.error(`[Cortex] Failed to remove hook config:`, err.message);
526
- }
527
- }
528
-
529
- // ─── Cortex Claude Code hook config ──────────────────────
530
- // Write a UserPromptSubmit hook into .claude/settings.local.json
531
- // so every prompt gets a RAG search before Claude sees it.
532
- function writeCortexHookConfig(cwd, paneId) {
533
- try {
534
- const claudeDir = path.join(cwd, '.claude');
535
- if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true });
536
-
537
- const settingsPath = path.join(claudeDir, 'settings.local.json');
538
- let settings = {};
539
- if (fs.existsSync(settingsPath)) {
540
- try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); } catch {}
541
- }
542
-
543
- // Resolve hook paths from @spaces/cortex addon or legacy bin/
544
- let ragHook, learnHook;
545
- try {
546
- const cortexDir = path.dirname(require.resolve('@spaces/cortex'));
547
- ragHook = path.join(cortexDir, 'hooks', 'cortex-hook.js');
548
- learnHook = path.join(cortexDir, 'hooks', 'cortex-learn-hook.js');
549
- } catch {
550
- ragHook = path.resolve(__dirname, 'cortex-hook.js');
551
- learnHook = path.resolve(__dirname, 'cortex-learn-hook.js');
552
- }
553
-
554
- // Merge — don't clobber existing hooks for other events
555
- if (!settings.hooks) settings.hooks = {};
556
-
557
- // Bake env vars into hook commands so they're always available
558
- // (Claude Code hook subprocesses may not inherit the PTY env)
559
- const hookEnv = `SPACES_PORT=${API_PORT} SPACES_SESSION_SECRET="${process.env.SPACES_SESSION_SECRET || ''}"`;
560
-
561
- // RAG search: runs on every prompt, injects relevant context
562
- settings.hooks.UserPromptSubmit = [
563
- {
564
- hooks: [
565
- {
566
- type: 'command',
567
- command: `${hookEnv} node "${ragHook}"`,
568
- timeout: 5,
569
- },
570
- ],
571
- },
572
- ];
573
-
574
- // Learn: runs after Claude finishes, ingests the exchange back into Cortex
575
- settings.hooks.Stop = [
576
- {
577
- hooks: [
578
- {
579
- type: 'command',
580
- command: `${hookEnv} node "${learnHook}"`,
581
- timeout: 10,
582
- },
583
- ],
584
- },
585
- ];
586
-
587
- // Refresh SessionStart hook with current pane ID (prevents stale ID errors)
588
- if (paneId) {
589
- try {
590
- const teamsHook = path.join(os.homedir(), '.spaces', 'packages', 'teams', 'bin', 'spaces-hook.js');
591
- if (fs.existsSync(teamsHook)) {
592
- settings.hooks.SessionStart = [
593
- {
594
- hooks: [
595
- {
596
- type: 'command',
597
- command: `node "${teamsHook}" ${paneId}`,
598
- timeout: 10000,
599
- },
600
- ],
601
- },
602
- ];
603
- }
604
- } catch { /* teams not installed */ }
605
- }
606
-
607
- // Register Cortex MCP server
608
- const mcpServer = path.resolve(__dirname, 'cortex-mcp.js');
609
- if (!settings.mcpServers) settings.mcpServers = {};
610
- settings.mcpServers.cortex = {
611
- command: 'node',
612
- args: [mcpServer],
613
- env: {
614
- SPACES_URL: `http://localhost:${API_PORT}`,
615
- SPACES_INTERNAL_TOKEN: (process.env.SPACES_SESSION_SECRET || '').slice(0, 16),
616
- },
617
- };
618
-
619
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
620
- console.log(`[Cortex] Wrote Claude Code hooks (RAG + Learn) + MCP server to ${settingsPath}`);
621
- } catch (err) {
622
- console.error(`[Cortex] Failed to write hook config:`, err.message);
623
- }
624
- }
625
-
626
- // ─── Cortex context injection ────────────────────────────
627
- // Fetch relevant knowledge from Cortex API and write a context file
628
- // in the workspace before the agent launches.
629
- async function injectCortexContext(cwd, workspaceId, ws) {
630
- if (!isApiReady()) return 0;
631
- if (SPACES_TIER !== 'team' && SPACES_TIER !== 'federation') return 0;
632
- // Check if Cortex is actually enabled in user config
633
- try {
634
- const configPath = path.join(os.homedir(), '.spaces', 'config.json');
635
- if (fs.existsSync(configPath)) {
636
- const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
637
- if (!cfg.cortex?.enabled) return 0;
638
- } else {
639
- return 0;
640
- }
641
- } catch { return 0; }
642
- try {
643
- const projectName = path.basename(cwd);
644
- const query = encodeURIComponent(`${projectName} workspace context`);
645
- const params = `q=${query}&limit=10${workspaceId ? `&workspace_id=${workspaceId}` : ''}`;
646
- const url = `http://localhost:${API_PORT}/api/cortex/search?${params}`;
647
-
648
- // Use internal auth bypass (x-spaces-internal header) to skip session middleware
649
- const internalToken = (process.env.SPACES_SESSION_SECRET || '').slice(0, 16);
650
- const options = {
651
- timeout: 5000,
652
- headers: {
653
- 'x-spaces-internal': internalToken,
654
- },
655
- };
656
-
657
- const body = await new Promise((resolve, reject) => {
658
- const req = http.get(url, options, (res) => {
659
- // Follow redirects (Next.js trailing-slash redirects)
660
- if (res.statusCode === 308 || res.statusCode === 307 || res.statusCode === 301 || res.statusCode === 302) {
661
- const redirectUrl = res.headers.location;
662
- if (redirectUrl) {
663
- const fullUrl = redirectUrl.startsWith('http') ? redirectUrl : `http://localhost:${API_PORT}${redirectUrl}`;
664
- const req2 = http.get(fullUrl, options, (res2) => {
665
- let data = '';
666
- res2.on('data', (chunk) => { data += chunk; });
667
- res2.on('end', () => {
668
- if (res2.statusCode !== 200) {
669
- reject(new Error(`Cortex API returned ${res2.statusCode}: ${data.slice(0, 200)}`));
670
- } else {
671
- resolve(data);
672
- }
673
- });
674
- });
675
- req2.on('error', reject);
676
- return;
677
- }
678
- }
679
- let data = '';
680
- res.on('data', (chunk) => { data += chunk; });
681
- res.on('end', () => {
682
- if (res.statusCode !== 200) {
683
- reject(new Error(`Cortex API returned ${res.statusCode}: ${data.slice(0, 200)}`));
684
- } else {
685
- resolve(data);
686
- }
687
- });
688
- });
689
- req.on('error', reject);
690
- req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
691
- });
692
-
693
- const parsed = JSON.parse(body);
694
- const results = parsed.results;
695
- if (!results || results.length === 0) {
696
- console.log(`[Cortex] No knowledge found for "${projectName}"`);
697
- return 0;
698
- }
699
-
700
- // Format context (mirrors src/lib/cortex/retrieval/injection.ts)
701
- const TYPE_LABELS = {
702
- decision: 'Decision', pattern: 'Pattern', preference: 'Preference',
703
- error_fix: 'Error Fix', context: 'Context', code_pattern: 'Code',
704
- command: 'Command', conversation: 'Conversation', summary: 'Summary',
705
- };
706
- const lines = ['<cortex-context>', 'Relevant context from your workspace history:', ''];
707
- let tokens = 20;
708
- const included = [];
709
- for (const unit of results) {
710
- const label = TYPE_LABELS[unit.type] || unit.type;
711
- const date = (unit.source_timestamp || '').slice(0, 10);
712
- const confidence = (unit.confidence * 100).toFixed(0);
713
- let entry = `[${label}]`;
714
- if (date) entry += ` ${date}:`;
715
- entry += ` ${unit.text}`;
716
- if (unit.session_id) entry += `\nSource: session ${unit.session_id}, confidence: ${confidence}%`;
717
- const entryTokens = Math.ceil(entry.length / 4);
718
- if (tokens + entryTokens > 2000) break;
719
- lines.push(entry, '');
720
- tokens += entryTokens;
721
- included.push({ type: unit.type, text: unit.text.slice(0, 80) });
722
- }
723
- lines.push('</cortex-context>');
724
-
725
- // Write context file (readable artifact for any agent)
726
- const spacesDir = path.join(cwd, '.spaces');
727
- if (!fs.existsSync(spacesDir)) fs.mkdirSync(spacesDir, { recursive: true });
728
- fs.writeFileSync(path.join(spacesDir, 'cortex-context.md'), lines.join('\n'), 'utf-8');
729
- console.log(`[Cortex] Injected ${included.length} knowledge units for ${path.basename(cwd)}`);
730
-
731
- // Notify client for injection badge
732
- if (ws && ws.readyState === 1) {
733
- ws.send(JSON.stringify({ type: 'cortex-injection', count: included.length, items: included }));
734
- }
735
-
736
- return included.length;
737
- } catch (err) {
738
- console.error(`[Cortex] Injection failed:`, err.message);
739
- return 0;
740
- }
741
- }
742
-
743
- // ─── Git Bash detection (Windows) ────────────────────────
744
- function findGitBash() {
745
- const custom = process.env.CLAUDE_CODE_GIT_BASH_PATH;
746
- if (custom && fs.existsSync(custom)) return custom;
747
- const localAppData = process.env.LOCALAPPDATA || '';
748
- const candidates = [
749
- 'C:\\Program Files\\Git\\bin\\bash.exe',
750
- 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
751
- path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
752
- ];
753
- for (const p of candidates) {
754
- if (p && fs.existsSync(p)) return p;
755
- }
756
- // Last resort: check if bash is on PATH via where command
757
- try {
758
- const result = require('child_process').execSync('where bash.exe 2>nul', { encoding: 'utf-8', timeout: 3000 });
759
- const first = result.trim().split('\n')[0].trim();
760
- if (first && first.toLowerCase().includes('git') && fs.existsSync(first)) return first;
761
- } catch { /* not found */ }
762
- return null;
763
- }
764
-
765
- // ─── SSH binary detection (Windows) ──────────────────────
766
- function findSshBinary() {
767
- if (process.platform !== 'win32') return '/usr/bin/ssh';
768
- // Windows OpenSSH ships in System32
769
- const sysSSH = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe');
770
- if (fs.existsSync(sysSSH)) return sysSSH;
771
- // Git for Windows also bundles ssh
772
- const gitSSH = 'C:\\Program Files\\Git\\usr\\bin\\ssh.exe';
773
- if (fs.existsSync(gitSSH)) return gitSSH;
774
- try {
775
- const result = require('child_process').execSync('where ssh.exe 2>nul', { encoding: 'utf-8', timeout: 3000 });
776
- const first = result.trim().split(String.fromCharCode(10))[0].trim();
777
- if (first && fs.existsSync(first)) return first;
778
- } catch {}
779
- return null;
780
- }
781
-
782
- // ─── Origin validation ───────────────────────────────────
783
- function isAllowedOrigin(origin, req) {
784
- if (!origin) return false;
785
- try {
786
- const url = new URL(origin);
787
- // Allow localhost/127.0.0.1 (any port)
788
- if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') return true;
789
- // Allow if origin matches the server's own Host header (same-origin requests)
790
- const host = req && req.headers && req.headers.host;
791
- if (host && url.host === host) return true;
792
- // Allow configured hostname from env (e.g., spaces.example.com)
793
- const allowed = process.env.SPACES_ALLOWED_ORIGINS;
794
- if (allowed) {
795
- return allowed.split(',').some(h => url.hostname === h.trim());
796
- }
797
- // In non-community modes, require explicit allowed origins
798
- if (SPACES_TIER !== 'community') return false;
799
- // Desktop/community: allow any origin
800
- return true;
801
- } catch {
802
- return false;
803
- }
804
- }
805
-
806
- // ─── Live collab toggle handler ─────────────────────────
807
- function handleCollabToggle(paneId, session) {
808
- try {
809
- const teams = require('@spaces/teams');
810
- const config = teams.terminal.getCollabConfig(paneId, session.username);
811
-
812
- if (config) {
813
- // Enabling collaboration
814
- session.isCollaborating = true;
815
- session.workspaceId = config.workspaceId;
816
- session.paneName = config.paneName;
817
-
818
- const env = {
819
- SPACES_PANE_ID: paneId,
820
- SPACES_WORKSPACE_ID: config.workspaceId,
821
- SPACES_PANE_NAME: config.paneName,
822
- SPACES_USERNAME: session.username,
823
- SPACES_API_URL: `http://localhost:${API_PORT}`,
824
- SPACES_COLLABORATING: '1',
825
- };
826
- teams.terminal.writeAgentConfig(session.agentType, session.cwd, env);
827
-
828
- // Nudge the agent so it knows collaboration is available
829
- if (session.pty && !session.exited) {
830
- const nudge = 'Workspace collaboration has been enabled. Hooks are active — you will receive messages on the next prompt. MCP tools (post_message, read_messages) require reconnecting the MCP server (use /mcp).';
831
- session.pty.write(nudge);
832
- setTimeout(() => { if (!session.exited) session.pty.write('\r'); }, 100);
833
- }
834
-
835
- console.log(`[CollabToggle] Enabled for pane ${paneId.slice(0, 8)} (workspace ${config.workspaceId.slice(0, 8)})`);
836
- } else {
837
- // Disabling collaboration
838
- teams.terminal.removeAgentConfig(session.agentType, session.cwd);
839
- session.isCollaborating = false;
840
- session.workspaceId = null;
841
-
842
- console.log(`[CollabToggle] Disabled for pane ${paneId.slice(0, 8)}`);
843
- }
844
-
845
- // Confirm to browser
846
- if (session.ws && session.ws.readyState === 1) {
847
- session.ws.send(JSON.stringify({ type: 'collab-updated', isCollaborating: !!config }));
848
- }
849
- } catch (e) {
850
- console.error(`[CollabToggle] Error for pane ${paneId.slice(0, 8)}:`, e.message);
851
- }
852
- }
853
-
854
- // ─── Shared connection handler ──────────────────────────
855
- function handleConnection(wss, ws, req) {
856
- ws.isAlive = true;
857
- ws.on('pong', () => { ws.isAlive = true; });
858
-
859
- const url = new URL(req.url || '/', 'http://localhost');
860
- const paneId = url.searchParams.get('paneId') || require('crypto').randomUUID();
861
- const cwd = url.searchParams.get('cwd') || process.env.HOME || process.env.USERPROFILE || 'C:\\';
862
- const agentType = url.searchParams.get('agentType') || 'shell';
863
- const rawAgentSession = url.searchParams.get('agentSession') || '';
864
- const agentSession = (rawAgentSession === 'new' || UUID_RE.test(rawAgentSession)) ? rawAgentSession : '';
865
- const rawCustomCommand = url.searchParams.get('customCommand') || '';
866
- // Sanitize: reject shell metacharacters that enable injection (;, |, &, $, `, etc.)
867
- const customCommand = /[;&|`$(){}]/.test(rawCustomCommand) ? '' : rawCustomCommand;
868
- const cols = parseInt(url.searchParams.get('cols') || '120', 10);
869
- const rows = parseInt(url.searchParams.get('rows') || '30', 10);
870
-
871
- // Authenticate: try session cookie first (self-contained auth), then terminal token + SSO header
872
- let username = null;
873
- const cookies = parseCookies(req.headers.cookie);
874
- const sessionToken = cookies['spaces-session'];
875
- const sessionPayload = sessionToken ? verifySessionToken(sessionToken) : null;
876
-
877
- console.log(`[Auth] pane=${paneId.slice(0, 8)} cookie=${sessionToken ? 'present' : 'MISSING'} sessionValid=${!!sessionPayload} terminalToken=${(url.searchParams.get('terminalToken') || '').slice(0, 12) || 'NONE'} nodeId=${url.searchParams.get('nodeId') || 'NONE'} apiKey=${url.searchParams.get('apiKey') ? 'present' : 'NONE'}`);
878
-
879
- if (sessionPayload) {
880
- // Self-contained auth: session cookie is valid
881
- username = sessionPayload.sub;
882
- console.log(`[Auth] Authenticated via session cookie: ${username}`);
883
- } else {
884
- const terminalToken = url.searchParams.get('terminalToken') || '';
885
-
886
- // Accept magic tokens from desktop/community tier, or from trusted local proxies (Docker/localhost)
887
- const remoteIp = req.socket.remoteAddress || '';
888
- const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1' || remoteIp.startsWith('172.') || remoteIp.startsWith('::ffff:172.');
889
- if ((terminalToken === 'desktop-local' || terminalToken === 'session-auth') && (SPACES_TIER === 'desktop' || SPACES_TIER === 'community' || isLocal)) {
890
- username = os.userInfo().username;
891
- // When running as SYSTEM, resolve to the first real user from admin DB
892
- if (process.platform === "win32" && username.toUpperCase() === "SYSTEM") {
893
- const db = getAdminDb();
894
- if (db) {
895
- try {
896
- const row = db.prepare("SELECT username FROM users LIMIT 1").get();
897
- if (row) username = row.username;
898
- } catch {}
899
- }
900
- }
901
- console.log(`[Auth] Authenticated via desktop token: ${username}`);
902
- } else {
903
- // Verify terminal token — if signed by this server's secret, trust it
904
- const tokenUser = verifyTerminalToken(terminalToken);
905
- if (tokenUser) {
906
- // Use the user from the signed token — do NOT trust x-auth-user header
907
- // as it can be spoofed by clients
908
- username = tokenUser;
909
- console.log(`[Auth] Authenticated via terminal token: ${username}`);
910
- } else if (terminalToken) {
911
- console.log(`[Auth] Terminal token FAILED: invalid or expired`);
912
- }
913
- }
914
- }
915
-
916
- // Internal token auth (for VR client and other trusted local processes)
917
- if (!username) {
918
- const internalToken = url.searchParams.get('internal') || '';
919
- const expectedToken = (process.env.SPACES_SESSION_SECRET || '').slice(0, 16);
920
- if (internalToken && expectedToken && internalToken === expectedToken) {
921
- const remoteIp = req.socket.remoteAddress || '';
922
- const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1' || remoteIp.includes('192.168.') || remoteIp.includes('::ffff:192.168.');
923
- if (isLocal) {
924
- username = os.userInfo().username;
925
- console.log(`[Auth] Authenticated via internal token (VR): ${username}`);
926
- }
927
- }
928
- }
929
-
930
- // Network API key auth (for proxied connections from remote nodes)
931
- if (!username) {
932
- const apiKey = url.searchParams.get('apiKey');
933
- if (apiKey) {
934
- console.log(`[Auth] API key provided: ${apiKey.slice(0, 4)}*** (length=${apiKey.length})`);
935
- const keyRecord = validateNetworkApiKey(apiKey);
936
- if (keyRecord) {
937
- console.log(`[Auth] API key validated: permissions=${keyRecord.permissions}, username=${keyRecord.username}`);
938
- if (keyRecord.permissions === 'terminal' || keyRecord.permissions === 'admin') {
939
- username = keyRecord.username || os.userInfo().username;
940
- } else {
941
- console.log(`[Auth] API key rejected: permissions="${keyRecord.permissions}" not terminal/admin`);
942
- }
943
- } else {
944
- console.log(`[Auth] API key validation FAILED (no matching key in DB)`);
945
- }
946
- } else {
947
- console.log(`[Auth] No apiKey param in WebSocket URL`);
948
- }
949
- }
950
-
951
- if (!username) {
952
- console.log(`[Auth] REJECTED connection for pane ${paneId} — no auth method succeeded`);
953
- ws.send(JSON.stringify({ type: 'error', data: 'Authentication required' }));
954
- ws.close();
955
- return;
956
- }
957
-
958
- // Proxy to remote node (federation tier only)
959
- const nodeId = url.searchParams.get('nodeId');
960
- if (nodeId) {
961
- if (SPACES_TIER !== 'federation') {
962
- ws.send(JSON.stringify({ type: 'error', data: 'Remote workspaces require the Federation tier' }));
963
- ws.close();
964
- return;
965
- }
966
- handleProxyConnection(ws, nodeId, { paneId, cwd, agentType, agentSession, customCommand, cols, rows });
967
- return;
968
- }
969
-
970
- // Check for existing session to reattach
971
- const existing = sessions.get(paneId);
972
- if (existing && existing.pty && !existing.exited) {
973
- console.log(`[WS] Reattach pane=${paneId.slice(0,8)} buffer=${existing.buffer.length} chunks`);
974
- existing.ws = ws;
975
-
976
- // Replay buffered output so user sees context
977
- for (const chunk of existing.buffer) {
978
- ws.send(JSON.stringify({ type: 'data', data: chunk }));
979
- }
980
-
981
- try { existing.pty.resize(cols, rows); } catch { /* ignore */ }
982
-
983
- ws.send(JSON.stringify({ type: 'ready', paneId, reattached: true }));
984
-
985
- // Skip Cortex injection on reattach — context was already injected at spawn.
986
- // The badge polls /api/cortex/status independently.
987
-
988
- ws.on('message', (raw) => {
989
- try {
990
- const msg = JSON.parse(raw.toString());
991
- if (msg.type === 'data') {
992
- existing.pty.write(msg.data);
993
- } else if (msg.type === 'resize') {
994
- try { existing.pty.resize(msg.cols, msg.rows); } catch { /* ignore */ }
995
- } else if (msg.type === 'collab-toggle') {
996
- handleCollabToggle(paneId, existing);
997
- }
998
- } catch {
999
- existing.pty.write(raw.toString());
1000
- }
1001
- });
1002
-
1003
- ws.on('close', (code, reason) => {
1004
- console.log(`[WS] Close pane=${paneId.slice(0,8)} code=${code} reason=${reason || 'none'}`);
1005
- if (existing.ws === ws) existing.ws = null;
1006
- });
1007
-
1008
- ws.on('error', (err) => {
1009
- console.log(`[WS] Error pane=${paneId.slice(0,8)} err=${err.message}`);
1010
- });
1011
-
1012
- return;
1013
- }
1014
-
1015
- // Create new pty session
1016
- const isWindows = process.platform === 'win32';
1017
-
1018
- // Resolve the OS shell user for this app user
1019
- const shellUser = lookupShellUser(username);
1020
- const processUser = os.userInfo().username;
1021
- let shell, args;
1022
- const isSSH = shellUser !== processUser;
1023
- if (isSSH) {
1024
- // SSH to localhost as the mapped shell user using the service key
1025
- const sshBin = findSshBinary();
1026
- if (!sshBin) {
1027
- console.error(`[Spawn] SSH binary not found — cannot spawn as ${shellUser}`);
1028
- ws.send(JSON.stringify({ type: 'error', data: 'SSH not available. Install OpenSSH to enable multi-user terminals.' }));
1029
- ws.close();
1030
- return;
1031
- }
1032
- shell = sshBin;
1033
- args = [
1034
- '-o', 'StrictHostKeyChecking=accept-new',
1035
- '-o', `UserKnownHostsFile=${path.join(os.homedir(), '.spaces', 'known_hosts')}`,
1036
- '-i', SERVICE_KEY,
1037
- '-t',
1038
- `${shellUser}@localhost`,
1039
- ];
1040
- // Force IPv4 — localhost may resolve to ::1 (IPv6) which sshd can reject
1041
- args.unshift('-4');
1042
-
1043
- // On-demand SSH provisioning: ensure the shell user's authorized_keys is set up
1044
- // before attempting the connection. This handles users added after service install.
1045
- if (process.platform !== 'win32' && fs.existsSync(SERVICE_KEY + '.pub')) {
1046
- try {
1047
- const pubKey = fs.readFileSync(SERVICE_KEY + '.pub', 'utf-8').trim();
1048
- authorizeShellUser(shellUser, pubKey);
1049
- } catch (e) {
1050
- console.error(`[SSH] On-demand provisioning for ${shellUser} failed (non-fatal):`, e.message);
1051
- }
1052
- }
1053
- } else if (isWindows && agentType !== 'shell') {
1054
- // Agents like Claude Code require bash on Windows find git-bash
1055
- shell = findGitBash();
1056
- args = [];
1057
- if (!shell) {
1058
- shell = 'cmd.exe';
1059
- }
1060
- } else {
1061
- shell = isWindows ? 'cmd.exe' : (process.env.SHELL || '/bin/bash');
1062
- args = [];
1063
- }
1064
-
1065
- const env = { ...process.env };
1066
- delete env.CLAUDECODE;
1067
- // Enable prompt suggestions in spawned Claude Code sessions
1068
- env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION = 'true';
1069
- // Tell Claude Code where git-bash is so it doesn't fail the bash detection
1070
- if (isWindows && shell && shell.endsWith('bash.exe') && !env.CLAUDE_CODE_GIT_BASH_PATH) {
1071
- env.CLAUDE_CODE_GIT_BASH_PATH = shell;
1072
- }
1073
-
1074
- // Fall back to HOME if cwd doesn't exist (e.g. remote path from another node)
1075
- let safeCwd = cwd;
1076
- if (!fs.existsSync(safeCwd)) {
1077
- safeCwd = os.homedir();
1078
- console.log(`[Spawn] cwd "${cwd}" does not exist, falling back to "${safeCwd}"`);
1079
- }
1080
-
1081
- // Inject Spaces bus environment for agent communication
1082
- env.SPACES_PANE_ID = paneId;
1083
- env.SPACES_API_URL = `http://localhost:${API_PORT}`;
1084
-
1085
- // Look up workspace collaboration config from @spaces/teams
1086
- let isCollaborating = false;
1087
- try {
1088
- const teams = require('@spaces/teams');
1089
- const config = teams.terminal.getCollabConfig(paneId, username);
1090
- if (config) {
1091
- env.SPACES_WORKSPACE_ID = config.workspaceId;
1092
- env.SPACES_PANE_NAME = config.paneName;
1093
- env.SPACES_USERNAME = username;
1094
- isCollaborating = true;
1095
- env.SPACES_COLLABORATING = '1';
1096
- console.log(`[Collab] Enabled for pane ${paneId.slice(0, 8)} — workspace ${config.workspaceId}, name "${config.paneName}"`);
1097
- }
1098
- } catch (e) {
1099
- console.error(`[Collab] Failed to check collaboration config for pane ${paneId.slice(0, 8)}:`, e.message);
1100
- }
1101
-
1102
- console.log(`[Spawn] user=${username} shell=${shell} args=${JSON.stringify(args)} cwd=${safeCwd} agentType=${agentType}`);
1103
-
1104
- // Write Cortex RAG hook for Claude Code before spawning (only if Cortex is enabled)
1105
- if (agentType === 'claude' && (SPACES_TIER === 'team' || SPACES_TIER === 'federation')) {
1106
- try {
1107
- const userHome = getUserHome(username);
1108
- const configPath = path.join(userHome, '.spaces', 'config.json');
1109
- let cortexEnabled = false;
1110
- if (fs.existsSync(configPath)) {
1111
- const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1112
- cortexEnabled = cfg.cortex?.enabled === true;
1113
- }
1114
- if (!cortexEnabled) {
1115
- removeCortexHookConfig(safeCwd);
1116
- } else {
1117
- writeCortexHookConfig(safeCwd, paneId);
1118
- // Resolve workspace ID: from collab config, or look up from pane DB
1119
- let wsId = env.SPACES_WORKSPACE_ID || null;
1120
- if (!wsId) {
1121
- try {
1122
- const Database = require('better-sqlite3');
1123
- const spacesDb = new Database(path.join(getUserHome(username), '.spaces', 'spaces.db'), { readonly: true });
1124
- const row = spacesDb.prepare('SELECT workspace_id FROM panes WHERE id = ?').get(paneId);
1125
- if (row && row.workspace_id) wsId = String(row.workspace_id);
1126
- spacesDb.close();
1127
- } catch { /* non-fatal */ }
1128
- }
1129
- if (wsId) env.SPACES_WORKSPACE_ID = wsId;
1130
- // Write workspace ID for hooks to read (they can't inherit PTY env)
1131
- try {
1132
- const envFile = path.join(safeCwd, '.claude', 'spaces-env.json');
1133
- fs.writeFileSync(envFile, JSON.stringify({
1134
- workspaceId: wsId,
1135
- port: API_PORT,
1136
- }), 'utf-8');
1137
- } catch { /* non-fatal */ }
1138
- }
1139
- } catch (e) {
1140
- console.error('[Cortex] Config check failed (non-fatal):', e.message);
1141
- }
1142
- }
1143
-
1144
- let term;
1145
- try {
1146
- term = pty.spawn(shell, args, {
1147
- name: 'xterm-256color',
1148
- cols,
1149
- rows,
1150
- cwd: safeCwd,
1151
- env,
1152
- });
1153
- } catch (err) {
1154
- console.error(`[Spawn Error] ${err.message} (cwd=${cwd}, shell=${shell})`);
1155
- ws.send(JSON.stringify({ type: 'error', data: 'Failed to spawn terminal session' }));
1156
- ws.close();
1157
- return;
1158
- }
1159
-
1160
- const session = {
1161
- pty: term, ws, buffer: [], exited: false, username,
1162
- agentType,
1163
- cwd: safeCwd,
1164
- paneName: env.SPACES_PANE_NAME || paneId,
1165
- lastOutputTime: Date.now(),
1166
- lastNudgeTime: 0,
1167
- startedAt: Date.now(),
1168
- workspaceId: env.SPACES_WORKSPACE_ID || null,
1169
- isCollaborating,
1170
- };
1171
- sessions.set(paneId, session);
1172
- analyticsRecordSessionStart(paneId, username, agentType);
1173
-
1174
- // ─── Cortex context injection (async, non-blocking) ─────
1175
- if (agentType !== 'shell') {
1176
- injectCortexContext(safeCwd, env.SPACES_WORKSPACE_ID || null, ws).catch(() => {});
1177
- }
1178
-
1179
- // ─── Inject cd for SSH sessions, then agent command ─────
1180
- const agent = AGENTS[agentType] || AGENTS.shell;
1181
-
1182
- // SSH sessions start in the remote user's home dir — cd to target cwd first
1183
- if (isSSH) {
1184
- setTimeout(() => {
1185
- if (!session.exited) {
1186
- if (isWindows) {
1187
- // Windows cmd.exe uses double quotes
1188
- const escapedCwd = safeCwd.replace(/"/g, '""');
1189
- term.write(`cd /d "${escapedCwd}"\r`);
1190
- } else {
1191
- // Unix shells use single quotes
1192
- const escapedCwd = safeCwd.replace(/'/g, "'\\''");
1193
- term.write(`cd '${escapedCwd}'\r`);
1194
- }
1195
- }
1196
- }, 300);
1197
- }
1198
-
1199
- // Write collaboration config for agent panes via @spaces/teams
1200
- if (isCollaborating && agentType !== 'shell') {
1201
- try {
1202
- const teams = require('@spaces/teams');
1203
- teams.terminal.writeAgentConfig(agentType, safeCwd, env);
1204
- console.log(`[Collab] Wrote agent config for pane ${paneId.slice(0, 8)} (${agentType}) in ${safeCwd}`);
1205
- } catch (e) {
1206
- console.error(`[Collab] Failed to write agent config for pane ${paneId.slice(0, 8)}:`, e.message);
1207
- }
1208
- }
1209
-
1210
- if (agentType !== 'shell') {
1211
- const command = agentType === 'custom' ? customCommand : agent.command;
1212
-
1213
- if (command) {
1214
- const delay = isSSH ? 800 : 300;
1215
-
1216
- if (agentSession && agentSession !== 'new' && agent.resumeFlag) {
1217
- // Resume an existing session
1218
- if (agentType === 'claude') {
1219
- // Claude needs to be run from the correct project CWD
1220
- const sessionCwd = findSessionCwd(agentSession, username);
1221
- setTimeout(() => {
1222
- if (session.exited) return;
1223
- if (sessionCwd && sessionCwd !== safeCwd) {
1224
- const cdCmd = isWindows ? `cd /d "${sessionCwd}"` : `cd "${sessionCwd}"`;
1225
- term.write(cdCmd + '\r');
1226
- setTimeout(() => {
1227
- if (!session.exited) {
1228
- term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
1229
- }
1230
- }, 300);
1231
- } else {
1232
- term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
1233
- }
1234
- }, delay);
1235
- } else {
1236
- // Generic resume: works for both subcommand (codex resume <id>) and flag (gemini --resume <id>)
1237
- setTimeout(() => {
1238
- if (!session.exited) {
1239
- term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
1240
- }
1241
- }, delay);
1242
- }
1243
- } else {
1244
- // Start new session
1245
- setTimeout(() => {
1246
- if (!session.exited) {
1247
- term.write(`${command}\r`);
1248
- }
1249
- }, delay);
1250
- }
1251
- }
1252
- }
1253
-
1254
- // pty -> ws (and buffer)
1255
- term.onData((data) => {
1256
- session.lastOutputTime = Date.now();
1257
- session.buffer.push(data);
1258
- if (session.buffer.length > MAX_BUFFER_LINES) {
1259
- session.buffer.shift();
1260
- }
1261
-
1262
- if (session.ws && session.ws.readyState === 1) {
1263
- session.ws.send(JSON.stringify({ type: 'data', data }));
1264
- }
1265
- });
1266
-
1267
- term.onExit(({ exitCode }) => {
1268
- session.exited = true;
1269
- analyticsRecordSessionEnd(paneId);
1270
- // Clean up hook state file
1271
- try {
1272
- const hookStateFile = path.join(os.homedir(), '.spaces', 'hook-state', `${paneId}.json`);
1273
- if (fs.existsSync(hookStateFile)) fs.unlinkSync(hookStateFile);
1274
- } catch { /* ignore */ }
1275
- if (session.ws && session.ws.readyState === 1) {
1276
- session.ws.send(JSON.stringify({ type: 'exit', exitCode }));
1277
- }
1278
- setTimeout(() => {
1279
- if (sessions.get(paneId) === session) {
1280
- sessions.delete(paneId);
1281
- }
1282
- }, 120000);
1283
- });
1284
-
1285
- // ws -> pty
1286
- ws.on('message', (raw) => {
1287
- try {
1288
- const msg = JSON.parse(raw.toString());
1289
- if (msg.type === 'data') {
1290
- term.write(msg.data);
1291
- } else if (msg.type === 'resize') {
1292
- try { term.resize(msg.cols, msg.rows); } catch { /* ignore */ }
1293
- } else if (msg.type === 'collab-toggle') {
1294
- handleCollabToggle(paneId, session);
1295
- }
1296
- } catch {
1297
- term.write(raw.toString());
1298
- }
1299
- });
1300
-
1301
- ws.on('close', (code, reason) => {
1302
- console.log(`[WS] Close pane=${paneId.slice(0,8)} code=${code} reason=${reason || 'none'}`);
1303
- if (session.ws === ws) session.ws = null;
1304
- });
1305
-
1306
- ws.on('error', (err) => {
1307
- console.log(`[WS] Error pane=${paneId.slice(0,8)} err=${err.message}`);
1308
- });
1309
-
1310
- ws.send(JSON.stringify({ type: 'ready', paneId }));
1311
-
1312
- // Confirm actual collaboration state so browser syncs with backend
1313
- ws.send(JSON.stringify({ type: 'collab-updated', isCollaborating }));
1314
-
1315
- // ─── Session ID detection for Claude sessions ────────────
1316
- // Always run detection for Claude panes — even when resuming, because
1317
- // the resume may fail (stale/expired session) and Claude will start fresh,
1318
- // creating a new session ID that we need to capture.
1319
- if (agentType === 'claude') {
1320
- detectNewClaudeSession(paneId, cwd, ws, session, username);
1321
- }
1322
- }
1323
-
1324
- // ─── Claude-specific helpers ──────────────────────────────
1325
-
1326
- function getUserHome(username) {
1327
- const shellUser = lookupShellUser(username);
1328
- if (shellUser === os.userInfo().username) return os.homedir();
1329
- if (process.platform === 'win32') {
1330
- // On Windows, user profiles live under the Users directory
1331
- const usersDir = path.dirname(os.homedir());
1332
- const userHome = path.join(usersDir, shellUser);
1333
- if (fs.existsSync(userHome)) return userHome;
1334
- return os.homedir();
1335
- }
1336
- return `/home/${shellUser}`;
1337
- }
1338
-
1339
- const UUID_JSONL_RE = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
1340
-
1341
- function findSessionCwd(sessionId, username) {
1342
- const claudeProjectsDir = path.join(getUserHome(username), '.claude', 'projects');
1343
- try {
1344
- if (!fs.existsSync(claudeProjectsDir)) return null;
1345
- const fileName = `${sessionId}.jsonl`;
1346
-
1347
- for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
1348
- if (!projDir.isDirectory()) continue;
1349
- const filePath = path.join(claudeProjectsDir, projDir.name, fileName);
1350
- if (fs.existsSync(filePath)) {
1351
- // Try to find cwd in the jsonl first few lines
1352
- const fd = fs.openSync(filePath, 'r');
1353
- const buf = Buffer.alloc(4096);
1354
- const bytesRead = fs.readSync(fd, buf, 0, 4096, 0);
1355
- fs.closeSync(fd);
1356
-
1357
- const chunk = buf.toString('utf-8', 0, bytesRead);
1358
- const lines = chunk.split('\n');
1359
- for (const line of lines) {
1360
- if (!line.trim()) continue;
1361
- try {
1362
- const entry = JSON.parse(line);
1363
- if (entry.cwd) {
1364
- console.log(`[Session CWD] ${sessionId.slice(0, 8)}: ${entry.cwd}`);
1365
- return entry.cwd;
1366
- }
1367
- } catch { /* skip */ }
1368
- }
1369
-
1370
- // Fallback: derive CWD from the project directory name
1371
- // Claude encodes paths as e.g. "-home-user-projects-myapp"
1372
- const derivedPath = '/' + projDir.name.replace(/^-/, '').replace(/-/g, '/');
1373
- if (fs.existsSync(derivedPath)) {
1374
- console.log(`[Session CWD] ${sessionId.slice(0, 8)}: ${derivedPath} (derived from dir name)`);
1375
- return derivedPath;
1376
- }
1377
- }
1378
- }
1379
- } catch (err) {
1380
- console.error(`[Session CWD] Error looking up ${sessionId}:`, err.message);
1381
- }
1382
- return null;
1383
- }
1384
-
1385
- function detectNewClaudeSession(paneId, cwd, ws, session, username) {
1386
- const claudeProjectsDir = path.join(getUserHome(username), '.claude', 'projects');
1387
-
1388
- const knownSessionIds = new Set();
1389
- try {
1390
- if (!fs.existsSync(claudeProjectsDir)) { /* will be created */ }
1391
- else {
1392
- for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
1393
- if (!projDir.isDirectory()) continue;
1394
- const projPath = path.join(claudeProjectsDir, projDir.name);
1395
- try {
1396
- for (const item of fs.readdirSync(projPath)) {
1397
- const m = item.match(UUID_JSONL_RE);
1398
- if (m) knownSessionIds.add(m[1]);
1399
- }
1400
- const indexPath = path.join(projPath, 'sessions-index.json');
1401
- if (fs.existsSync(indexPath)) {
1402
- try {
1403
- const data = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
1404
- if (data.entries) {
1405
- for (const entry of data.entries) knownSessionIds.add(entry.sessionId);
1406
- }
1407
- } catch { /* ignore */ }
1408
- }
1409
- } catch { /* ignore */ }
1410
- }
1411
- }
1412
- } catch { /* ignore */ }
1413
-
1414
- console.log(`[Session Detect] Pane ${paneId.slice(0, 8)} (${username}): snapshot ${knownSessionIds.size} existing sessions`);
1415
-
1416
- let attempts = 0;
1417
- const maxAttempts = 45;
1418
- const interval = setInterval(() => {
1419
- attempts++;
1420
- if (attempts > maxAttempts || session.exited) {
1421
- clearInterval(interval);
1422
- return;
1423
- }
1424
-
1425
- try {
1426
- if (!fs.existsSync(claudeProjectsDir)) return;
1427
-
1428
- for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
1429
- if (!projDir.isDirectory()) continue;
1430
- const projPath = path.join(claudeProjectsDir, projDir.name);
1431
- try {
1432
- for (const item of fs.readdirSync(projPath)) {
1433
- const m = item.match(UUID_JSONL_RE);
1434
- if (m && !knownSessionIds.has(m[1])) {
1435
- const newSessionId = m[1];
1436
- clearInterval(interval);
1437
- console.log(`[Session Detect] Pane ${paneId.slice(0, 8)} (${username}): detected session ${newSessionId}`);
1438
- if (session.ws && session.ws.readyState === 1) {
1439
- session.ws.send(JSON.stringify({
1440
- type: 'session-detected',
1441
- sessionId: newSessionId,
1442
- paneId,
1443
- }));
1444
- }
1445
- return;
1446
- }
1447
- }
1448
- } catch { /* ignore */ }
1449
- }
1450
- } catch { /* ignore */ }
1451
- }, 2000);
1452
- }
1453
-
1454
- // ─── Proxy: forward connection to remote node ──────────
1455
-
1456
- async function handleProxyConnection(clientWs, nodeId, opts) {
1457
- const { paneId, cwd, agentType, agentSession, customCommand, cols, rows } = opts;
1458
-
1459
- const node = getNodeInfo(nodeId);
1460
- if (!node) {
1461
- clientWs.send(JSON.stringify({ type: 'error', data: `Node ${nodeId} not found` }));
1462
- clientWs.close();
1463
- return;
1464
- }
1465
-
1466
- const apiKey = decryptNodeApiKey(node.api_key_encrypted);
1467
- if (!apiKey) {
1468
- clientWs.send(JSON.stringify({ type: 'error', data: 'Cannot decrypt API key for remote node' }));
1469
- clientWs.close();
1470
- return;
1471
- }
1472
-
1473
- // Get the remote WebSocket URL via the terminal token endpoint
1474
- // Only skip TLS verification if explicitly opted in (e.g., self-signed certs)
1475
- const skipTls = process.env.SPACES_SKIP_TLS_VERIFY === '1';
1476
- const prevTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
1477
- if (skipTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
1478
- let remoteWsUrl;
1479
- try {
1480
- const tokenUrl = `${node.url}/api/network/terminal/token/`;
1481
- const res = await fetch(tokenUrl, {
1482
- method: 'POST',
1483
- headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
1484
- signal: AbortSignal.timeout(10000),
1485
- });
1486
-
1487
- if (!res.ok) {
1488
- let detail = '';
1489
- try { const body = await res.json(); detail = body.error || JSON.stringify(body); } catch { detail = await res.text().catch(() => `HTTP ${res.status}`); }
1490
- clientWs.send(JSON.stringify({ type: 'error', data: `Remote terminal auth failed: ${detail}` }));
1491
- clientWs.close();
1492
- return;
1493
- }
1494
-
1495
- const data = await res.json();
1496
- remoteWsUrl = data.wsUrl;
1497
- } catch (err) {
1498
- clientWs.send(JSON.stringify({ type: 'error', data: `Cannot reach remote node: ${err.message}` }));
1499
- clientWs.close();
1500
- return;
1501
- } finally {
1502
- if (prevTls === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
1503
- else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTls;
1504
- }
1505
-
1506
- // Connect to remote terminal server using the API key directly.
1507
- // The terminal token approach fails because the proxied WebSocket has no
1508
- // x-auth-user header, so the remote server can't match the token's username
1509
- // to the request. API key auth on the WebSocket is the reliable path.
1510
- const WebSocket = require('ws');
1511
- const remoteParams = new URLSearchParams({
1512
- paneId,
1513
- cwd,
1514
- agentType,
1515
- cols: String(cols),
1516
- rows: String(rows),
1517
- apiKey: apiKey,
1518
- });
1519
- if (agentSession) remoteParams.set('agentSession', agentSession);
1520
- // Never forward customCommand to remote nodes — too dangerous
1521
-
1522
- // Upgrade ws:// to wss:// if the node uses https
1523
- let wsUrl = remoteWsUrl;
1524
- if (node.url.startsWith('https://') && wsUrl.startsWith('ws://')) {
1525
- wsUrl = 'wss://' + wsUrl.slice(5);
1526
- }
1527
- const remoteUrl = `${wsUrl}?${remoteParams}`;
1528
- console.log(`[Proxy] Connecting to remote node ${nodeId.slice(0, 8)}`);
1529
-
1530
- const remoteWs = new WebSocket(remoteUrl, { rejectUnauthorized: !skipTls ? undefined : false });
1531
-
1532
- remoteWs.on('open', () => {
1533
- console.log(`[Proxy] Connected to remote node ${nodeId.slice(0, 8)} for pane ${paneId.slice(0, 8)}`);
1534
- });
1535
-
1536
- // Pipe data bidirectionally
1537
- let firstMsg = true;
1538
- remoteWs.on('message', (data) => {
1539
- const str = data.toString();
1540
- if (firstMsg) {
1541
- console.log(`[Proxy] First message from remote for pane ${paneId.slice(0, 8)}: ${str.slice(0, 200)}`);
1542
- firstMsg = false;
1543
- }
1544
- if (clientWs.readyState === 1) {
1545
- clientWs.send(str);
1546
- }
1547
- });
1548
-
1549
- clientWs.on('message', (data) => {
1550
- if (remoteWs.readyState === 1) {
1551
- remoteWs.send(data.toString());
1552
- }
1553
- });
1554
-
1555
- // Handle closes
1556
- remoteWs.on('close', () => {
1557
- console.log(`[Proxy] Remote connection closed for pane ${paneId.slice(0, 8)}`);
1558
- if (clientWs.readyState === 1) {
1559
- clientWs.send(JSON.stringify({ type: 'exit', exitCode: -1, reason: 'Remote connection closed' }));
1560
- }
1561
- });
1562
-
1563
- remoteWs.on('error', (err) => {
1564
- console.error(`[Proxy] Remote error for pane ${paneId.slice(0, 8)}:`, err.message);
1565
- if (clientWs.readyState === 1) {
1566
- clientWs.send(JSON.stringify({ type: 'error', data: `Remote error: ${err.message}` }));
1567
- }
1568
- });
1569
-
1570
- clientWs.on('close', () => {
1571
- console.log(`[Proxy] Client disconnected for pane ${paneId.slice(0, 8)}`);
1572
- if (remoteWs.readyState === 1) {
1573
- remoteWs.close();
1574
- }
1575
- });
1576
- }
1577
-
1578
- // ─── Shared WSS setup (attach handlers to any WebSocketServer) ──
1579
-
1580
- function setupWss(wss) {
1581
- const pingInterval = setInterval(() => {
1582
- wss.clients.forEach((ws) => {
1583
- if (ws.isAlive === false) return ws.terminate();
1584
- ws.isAlive = false;
1585
- ws.ping();
1586
- });
1587
- }, 30000);
1588
-
1589
- wss.on('connection', (ws, req) => handleConnection(wss, ws, req));
1590
-
1591
- wss.on('error', (err) => {
1592
- console.error('[Terminal Server] Error:', err.message);
1593
- });
1594
-
1595
- // Initialize analytics DB
1596
- getAdminDbRW();
1597
-
1598
- return pingInterval;
1599
- }
1600
-
1601
- function startMdnsIfNeeded(httpPort) {
1602
- if (SPACES_TIER === 'federation') {
1603
- try {
1604
- const { startMdns } = require('./mdns-service');
1605
- startMdns(httpPort || PORT);
1606
- } catch (err) {
1607
- console.log('[mDNS] Discovery not available:', err.message);
1608
- }
1609
- } else {
1610
- console.log(`[mDNS] Skipped (tier=${SPACES_TIER}, requires federation)`);
1611
- }
1612
- }
1613
-
1614
- // ─── Poll-based idle nudge for agent collaboration ───────
1615
-
1616
- function startMessageWatcher(apiPort) {
1617
- try {
1618
- const teams = require('@spaces/teams');
1619
- teams.terminal.startMessageWatcher(apiPort, sessions);
1620
- } catch { /* @spaces/teams not installed — no message watcher */ }
1621
- }
1622
-
1623
- // ─── Attached mode: mount on an existing HTTP server ─────
1624
-
1625
- function createTerminalServer(httpServer) {
1626
- // In attached mode, the API is served by the parent HTTP server, not on PORT (3458).
1627
- if (httpServer.listening) {
1628
- API_PORT = httpServer.address().port;
1629
- waitForApi();
1630
- } else {
1631
- httpServer.on('listening', () => { API_PORT = httpServer.address().port; waitForApi(); });
1632
- }
1633
-
1634
- const wss = new WebSocketServer({ noServer: true });
1635
- setupWss(wss);
1636
-
1637
- httpServer.on('upgrade', (req, socket, head) => {
1638
- const url = new URL(req.url, 'http://localhost');
1639
- if (url.pathname === '/ws' || url.pathname.endsWith('/ws')) {
1640
- // Verify origin for browser clients
1641
- const origin = req.headers.origin;
1642
- if (origin && !isAllowedOrigin(origin, req)) {
1643
- socket.destroy();
1644
- return;
1645
- }
1646
- wss.handleUpgrade(req, socket, head, (ws) => {
1647
- wss.emit('connection', ws, req);
1648
- });
1649
- }
1650
- // Non-/ws upgrades are left for other listeners (e.g. HMR proxy)
1651
- });
1652
-
1653
- // Start mDNS and message watcher once we know the HTTP port
1654
- if (httpServer.listening) {
1655
- startMdnsIfNeeded(httpServer.address().port);
1656
- startMessageWatcher(httpServer.address().port);
1657
- } else {
1658
- httpServer.on('listening', () => {
1659
- startMdnsIfNeeded(httpServer.address().port);
1660
- startMessageWatcher(httpServer.address().port);
1661
- });
1662
- }
1663
- return wss;
1664
- }
1665
-
1666
- // ─── Standalone mode (run directly) ──────────────────────
1667
-
1668
- if (require.main === module) {
1669
- const wss = new WebSocketServer({
1670
- port: PORT,
1671
- verifyClient: ({ req }) => {
1672
- const origin = req.headers.origin;
1673
- if (!origin) return true;
1674
- return isAllowedOrigin(origin, req);
1675
- },
1676
- });
1677
- setupWss(wss);
1678
- startMdnsIfNeeded();
1679
- startMessageWatcher(PORT);
1680
- console.log(`Terminal WebSocket server running on ws://localhost:${PORT}`);
1681
- }
1682
-
1683
- module.exports = { createTerminalServer };
1
+ #!/usr/bin/env node
2
+
3
+ const { WebSocketServer } = require('ws');
4
+ const pty = require('node-pty');
5
+ const http = require('http');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+ const crypto = require('crypto');
10
+
11
+ const PORT = parseInt(process.env.SPACES_WS_PORT || '3458', 10);
12
+ const SPACES_TIER = process.env.SPACES_TIER || 'community';
13
+ // API_PORT is the port where Next.js API routes are reachable.
14
+ // In attached mode, createTerminalServer() updates this to the parent server's port.
15
+ let API_PORT = parseInt(process.env.SPACES_PORT || '3457', 10);
16
+
17
+ // Track whether the Next.js API is ready — avoids timeout spam during startup
18
+ let _apiReady = false;
19
+ function setApiReady() { _apiReady = true; }
20
+ function isApiReady() { return _apiReady; }
21
+ // Poll until the API responds, then mark ready
22
+ function waitForApi() {
23
+ const check = () => {
24
+ const req = http.get(`http://localhost:${API_PORT}/api/tier`, { timeout: 1000 }, (res) => {
25
+ res.resume(); // consume body to free socket
26
+ if (res.statusCode < 500) { setApiReady(); return; }
27
+ setTimeout(check, 2000);
28
+ });
29
+ req.on('error', () => setTimeout(check, 2000));
30
+ req.on('timeout', () => { req.destroy(); setTimeout(check, 2000); });
31
+ };
32
+ setTimeout(check, 1000);
33
+ }
34
+
35
+ // ─── Terminal token verification ──────────────────────────
36
+
37
+ const SECRET_PATH = path.join(os.homedir(), '.spaces', 'terminal_secret');
38
+
39
+ function getTerminalSecret() {
40
+ if (fs.existsSync(SECRET_PATH)) {
41
+ return Buffer.from(fs.readFileSync(SECRET_PATH, 'utf-8').trim(), 'hex');
42
+ }
43
+ const secret = crypto.randomBytes(32);
44
+ const dir = path.dirname(SECRET_PATH);
45
+ if (!fs.existsSync(dir)) {
46
+ fs.mkdirSync(dir, { recursive: true });
47
+ }
48
+ fs.writeFileSync(SECRET_PATH, secret.toString('hex'), { mode: 0o600 });
49
+ return secret;
50
+ }
51
+
52
+ let _terminalSecret = null;
53
+ function terminalSecret() {
54
+ if (!_terminalSecret) {
55
+ _terminalSecret = getTerminalSecret();
56
+ }
57
+ return _terminalSecret;
58
+ }
59
+
60
+ function verifyTerminalToken(token) {
61
+ if (!token) return null;
62
+ const parts = token.split('.');
63
+ if (parts.length !== 2) return null;
64
+
65
+ const [payloadB64, sig] = parts;
66
+ const expectedSig = crypto.createHmac('sha256', terminalSecret())
67
+ .update(payloadB64)
68
+ .digest('base64url');
69
+
70
+ try {
71
+ if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
72
+ return null;
73
+ }
74
+ } catch {
75
+ return null;
76
+ }
77
+
78
+ try {
79
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
80
+ if (payload.exp < Math.floor(Date.now() / 1000)) {
81
+ return null;
82
+ }
83
+ return payload.sub || null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ // ─── Session token verification (for self-contained auth) ──
90
+
91
+ const SESSION_SECRET_PATH = path.join(os.homedir(), '.spaces', 'session_secret');
92
+
93
+ function getSessionSecret() {
94
+ if (fs.existsSync(SESSION_SECRET_PATH)) {
95
+ return Buffer.from(fs.readFileSync(SESSION_SECRET_PATH, 'utf-8').trim(), 'hex');
96
+ }
97
+ return null;
98
+ }
99
+
100
+ let _sessionSecret = null;
101
+ function sessionSecret() {
102
+ if (!_sessionSecret) {
103
+ _sessionSecret = getSessionSecret();
104
+ }
105
+ return _sessionSecret;
106
+ }
107
+
108
+ function verifySessionToken(token) {
109
+ const secret = sessionSecret();
110
+ if (!token || !secret) return null;
111
+ const parts = token.split('.');
112
+ if (parts.length !== 2) return null;
113
+
114
+ const [payloadB64, sig] = parts;
115
+ const expectedSig = crypto.createHmac('sha256', secret)
116
+ .update(payloadB64)
117
+ .digest('base64url');
118
+
119
+ try {
120
+ if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
121
+ return null;
122
+ }
123
+ } catch {
124
+ return null;
125
+ }
126
+
127
+ try {
128
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
129
+ if (payload.exp < Math.floor(Date.now() / 1000)) {
130
+ return null;
131
+ }
132
+ return { sub: payload.sub, role: payload.role || 'user' };
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ // ─── Admin DB for shell user lookup ─────────────────────────
139
+
140
+ const ADMIN_DB_PATH = path.join(os.homedir(), '.spaces', 'admin.db');
141
+ let _adminDb = null;
142
+
143
+ function getAdminDb() {
144
+ if (_adminDb) return _adminDb;
145
+ if (!fs.existsSync(ADMIN_DB_PATH)) return null;
146
+ try {
147
+ const Database = require('better-sqlite3');
148
+ _adminDb = new Database(ADMIN_DB_PATH, { readonly: true });
149
+ return _adminDb;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ function lookupShellUser(appUsername) {
156
+ const db = getAdminDb();
157
+ if (!db) return appUsername;
158
+ try {
159
+ const row = db.prepare('SELECT shell_user FROM users WHERE username = ?').get(appUsername);
160
+ return row ? row.shell_user : appUsername;
161
+ } catch {
162
+ return appUsername;
163
+ }
164
+ }
165
+
166
+ // ─── Network DB for federation ───────────────────────────────
167
+
168
+ const NETWORK_DB_PATH = path.join(os.homedir(), '.spaces', 'network.db');
169
+ let _networkDb = null;
170
+
171
+ function getNetworkDb() {
172
+ if (_networkDb) return _networkDb;
173
+ if (!fs.existsSync(NETWORK_DB_PATH)) return null;
174
+ try {
175
+ const Database = require('better-sqlite3');
176
+ _networkDb = new Database(NETWORK_DB_PATH, { readonly: true });
177
+ return _networkDb;
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ function validateNetworkApiKey(rawKey) {
184
+ const db = getNetworkDb();
185
+ if (!db || !rawKey || !rawKey.startsWith('spk_')) return null;
186
+ try {
187
+ const keys = db.prepare('SELECT * FROM api_keys').all();
188
+ for (const key of keys) {
189
+ if (key.expires && new Date(key.expires) < new Date()) continue;
190
+ const [salt, hash] = key.key_hash.split(':');
191
+ if (!salt || !hash) continue;
192
+ const derived = crypto.scryptSync(rawKey, salt, 64).toString('hex');
193
+ try {
194
+ if (crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(derived, 'hex'))) {
195
+ return key;
196
+ }
197
+ } catch { continue; }
198
+ }
199
+ } catch { /* ignore */ }
200
+ return null;
201
+ }
202
+
203
+ function getNodeInfo(nodeId) {
204
+ const db = getNetworkDb();
205
+ if (!db) return null;
206
+ try {
207
+ return db.prepare('SELECT * FROM nodes WHERE id = ?').get(nodeId);
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
212
+
213
+ function decryptNodeApiKey(encrypted) {
214
+ try {
215
+ const key = Buffer.from(fs.readFileSync(SECRET_PATH, 'utf-8').trim(), 'hex');
216
+ const [ivB64, tagB64, dataB64] = encrypted.split(':');
217
+ if (!ivB64 || !tagB64 || !dataB64) return null;
218
+ const iv = Buffer.from(ivB64, 'base64');
219
+ const tag = Buffer.from(tagB64, 'base64');
220
+ const data = Buffer.from(dataB64, 'base64');
221
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
222
+ decipher.setAuthTag(tag);
223
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf-8');
224
+ } catch {
225
+ return null;
226
+ }
227
+ }
228
+
229
+ // ─── Writable Admin DB for analytics ─────────────────────────
230
+
231
+ let _adminDbRW = null;
232
+
233
+ function getAdminDbRW() {
234
+ if (_adminDbRW) return _adminDbRW;
235
+ try {
236
+ const Database = require('better-sqlite3');
237
+ const dir = path.dirname(ADMIN_DB_PATH);
238
+ if (!fs.existsSync(dir)) {
239
+ fs.mkdirSync(dir, { recursive: true });
240
+ }
241
+ const db = new Database(ADMIN_DB_PATH);
242
+ db.pragma('journal_mode = WAL');
243
+ db.pragma('busy_timeout = 5000');
244
+ db.exec(`
245
+ CREATE TABLE IF NOT EXISTS login_events (
246
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
247
+ username TEXT NOT NULL,
248
+ timestamp TEXT NOT NULL DEFAULT (datetime('now')),
249
+ ip_address TEXT,
250
+ user_agent TEXT
251
+ );
252
+ CREATE TABLE IF NOT EXISTS terminal_sessions (
253
+ id TEXT PRIMARY KEY,
254
+ username TEXT NOT NULL,
255
+ agent_type TEXT NOT NULL DEFAULT 'shell',
256
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
257
+ ended_at TEXT,
258
+ duration_seconds INTEGER
259
+ );
260
+ CREATE INDEX IF NOT EXISTS idx_login_events_username ON login_events(username);
261
+ CREATE INDEX IF NOT EXISTS idx_login_events_timestamp ON login_events(timestamp);
262
+ CREATE INDEX IF NOT EXISTS idx_terminal_sessions_username ON terminal_sessions(username);
263
+ CREATE INDEX IF NOT EXISTS idx_terminal_sessions_started_at ON terminal_sessions(started_at);
264
+ `);
265
+ // Clean up stale sessions from previous crashes
266
+ db.prepare(`
267
+ UPDATE terminal_sessions
268
+ SET ended_at = datetime('now'),
269
+ duration_seconds = CAST((julianday('now') - julianday(started_at)) * 86400 AS INTEGER)
270
+ WHERE ended_at IS NULL
271
+ `).run();
272
+ _adminDbRW = db;
273
+ console.log('[Analytics] Writable admin DB connected, stale sessions cleaned up');
274
+ return db;
275
+ } catch (err) {
276
+ console.error('[Analytics] Failed to open writable admin DB:', err.message);
277
+ return null;
278
+ }
279
+ }
280
+
281
+ function analyticsRecordSessionStart(paneId, username, agentType) {
282
+ try {
283
+ const db = getAdminDbRW();
284
+ if (!db) return;
285
+ db.prepare(
286
+ 'INSERT OR REPLACE INTO terminal_sessions (id, username, agent_type) VALUES (?, ?, ?)'
287
+ ).run(paneId, username, agentType);
288
+ } catch (err) {
289
+ console.error('[Analytics] recordSessionStart error:', err.message);
290
+ }
291
+ }
292
+
293
+ function analyticsRecordSessionEnd(paneId) {
294
+ try {
295
+ const db = getAdminDbRW();
296
+ if (!db) return;
297
+ db.prepare(`
298
+ UPDATE terminal_sessions
299
+ SET ended_at = datetime('now'),
300
+ duration_seconds = CAST((julianday('now') - julianday(started_at)) * 86400 AS INTEGER)
301
+ WHERE id = ? AND ended_at IS NULL
302
+ `).run(paneId);
303
+ } catch (err) {
304
+ console.error('[Analytics] recordSessionEnd error:', err.message);
305
+ }
306
+ }
307
+
308
+ // ─── Cookie parser ──────────────────────────────────────────
309
+
310
+ function parseCookies(cookieHeader) {
311
+ const cookies = {};
312
+ if (!cookieHeader) return cookies;
313
+ cookieHeader.split(';').forEach(part => {
314
+ const [key, ...rest] = part.trim().split('=');
315
+ if (key) cookies[key] = rest.join('=');
316
+ });
317
+ return cookies;
318
+ }
319
+
320
+ // ─── SSH service key path (used to spawn shells as other OS users) ──
321
+
322
+ const SERVICE_KEY = path.join(os.homedir(), '.spaces', 'service_key');
323
+
324
+ // Ensure the SSH service key exists, has correct permissions, and is authorized.
325
+ function ensureServiceKeyAtRuntime() {
326
+ const { spawnSync } = require('child_process');
327
+ const currentUser = os.userInfo().username;
328
+ const isWindows = process.platform === 'win32';
329
+
330
+ // Generate key if missing (all platforms)
331
+ if (!fs.existsSync(SERVICE_KEY)) {
332
+ const dir = path.dirname(SERVICE_KEY);
333
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
334
+ const result = spawnSync('ssh-keygen', [
335
+ '-t', 'ed25519', '-f', SERVICE_KEY, '-N', '', '-C', 'spaces-service-key',
336
+ ], { stdio: 'pipe', timeout: 10000 });
337
+ if (result.status !== 0) {
338
+ console.error('[SSH] Failed to generate service key');
339
+ return;
340
+ }
341
+ if (isWindows) {
342
+ // Lock down permissions: only the process owner + SYSTEM
343
+ spawnSync('icacls', [SERVICE_KEY, '/inheritance:r',
344
+ '/remove', 'BUILTIN\\Administrators', '/remove', 'BUILTIN\\Users', '/remove', 'Everyone',
345
+ '/grant:r', currentUser + ':(F)',
346
+ '/grant', 'NT AUTHORITY\\SYSTEM:(F)'], { stdio: 'pipe', timeout: 5000 });
347
+ } else {
348
+ spawnSync('chmod', ['600', SERVICE_KEY], { stdio: 'pipe', timeout: 5000 });
349
+ }
350
+ console.log('[SSH] Generated service key as ' + currentUser);
351
+ }
352
+
353
+ // Always ensure the public key is authorized
354
+ if (!fs.existsSync(SERVICE_KEY + '.pub')) return;
355
+ const pubKey = fs.readFileSync(SERVICE_KEY + '.pub', 'utf-8').trim();
356
+
357
+ if (isWindows) {
358
+ // Authorize in administrators_authorized_keys (for admin shell users)
359
+ try {
360
+ const adminAuthKeys = path.join(process.env.ProgramData || 'C:\\ProgramData', 'ssh', 'administrators_authorized_keys');
361
+ const authDir = path.dirname(adminAuthKeys);
362
+ if (!fs.existsSync(authDir)) fs.mkdirSync(authDir, { recursive: true });
363
+ spawnSync('icacls', [adminAuthKeys, '/inheritance:r',
364
+ '/grant:r', 'SYSTEM:(F)', '/grant', 'Administrators:(R)'], { stdio: 'pipe', timeout: 5000 });
365
+ let existing = '';
366
+ try { existing = fs.readFileSync(adminAuthKeys, 'utf-8'); } catch {}
367
+ if (!existing.includes(pubKey)) {
368
+ fs.appendFileSync(adminAuthKeys, pubKey + String.fromCharCode(10));
369
+ console.log('[SSH] Authorized service key in administrators_authorized_keys');
370
+ }
371
+ } catch (e) {
372
+ console.error('[SSH] Could not authorize admin key (non-fatal):', e.message);
373
+ }
374
+
375
+ // Authorize in each shell user's ~/.ssh/authorized_keys (for non-admin users)
376
+ try {
377
+ const usersDir = path.dirname(os.homedir());
378
+ const skip = new Set(['Public', 'Default', 'Default User', 'All Users']);
379
+ const profiles = fs.readdirSync(usersDir)
380
+ .filter(name => !skip.has(name) && !name.startsWith('.'))
381
+ .filter(name => fs.existsSync(path.join(usersDir, name, '.claude')));
382
+ for (const username of profiles) {
383
+ try {
384
+ const sshDir = path.join(usersDir, username, '.ssh');
385
+ if (!fs.existsSync(sshDir)) fs.mkdirSync(sshDir, { recursive: true });
386
+ const authKeysPath = path.join(sshDir, 'authorized_keys');
387
+ let existing = '';
388
+ try { existing = fs.readFileSync(authKeysPath, 'utf-8'); } catch {}
389
+ if (!existing.includes(pubKey)) {
390
+ fs.appendFileSync(authKeysPath, pubKey + String.fromCharCode(10));
391
+ spawnSync('icacls', [authKeysPath, '/inheritance:r',
392
+ '/grant:r', username + ':(F)',
393
+ '/grant', 'NT AUTHORITY\\SYSTEM:(F)'], { stdio: 'pipe', timeout: 5000 });
394
+ console.log('[SSH] Authorized service key for user ' + username);
395
+ }
396
+ } catch (e) {
397
+ console.error('[SSH] Could not authorize key for ' + username + ' (non-fatal):', e.message);
398
+ }
399
+ }
400
+ } catch (e) {
401
+ console.error('[SSH] Could not scan user profiles (non-fatal):', e.message);
402
+ }
403
+ } else {
404
+ // Linux/macOS: authorize all shell users from admin DB as a fallback
405
+ // (AuthorizedKeysCommand is the primary mechanism, this is belt-and-suspenders)
406
+ const db = getAdminDb();
407
+ if (db) {
408
+ try {
409
+ const users = db.prepare('SELECT DISTINCT shell_user FROM users').all();
410
+ for (const row of users) {
411
+ const shellUser = row.shell_user;
412
+ try {
413
+ authorizeShellUser(shellUser, pubKey);
414
+ } catch (e) {
415
+ console.error('[SSH] Could not authorize key for ' + shellUser + ' (non-fatal):', e.message);
416
+ }
417
+ }
418
+ } catch (e) {
419
+ console.error('[SSH] Could not query admin DB for shell users (non-fatal):', e.message);
420
+ }
421
+ }
422
+ }
423
+ }
424
+
425
+ // Authorize the service key for a Linux/macOS shell user
426
+ function authorizeShellUser(shellUser, pubKey) {
427
+ const { spawnSync } = require('child_process');
428
+
429
+ // Resolve home directory
430
+ let userHome;
431
+ try {
432
+ const result = spawnSync('getent', ['passwd', shellUser], { encoding: 'utf-8', timeout: 5000 });
433
+ const fields = (result.stdout || '').split(':');
434
+ userHome = fields[5];
435
+ } catch {}
436
+ if (!userHome) {
437
+ userHome = process.platform === 'darwin' ? `/Users/${shellUser}` : `/home/${shellUser}`;
438
+ }
439
+ if (!fs.existsSync(userHome)) return;
440
+
441
+ const sshDir = path.join(userHome, '.ssh');
442
+ const authKeysPath = path.join(sshDir, 'authorized_keys');
443
+
444
+ // Check if already authorized
445
+ let existing = '';
446
+ try { existing = fs.readFileSync(authKeysPath, 'utf-8'); } catch {}
447
+ if (existing.includes(pubKey)) return;
448
+
449
+ // Create .ssh dir with correct ownership
450
+ if (!fs.existsSync(sshDir)) {
451
+ spawnSync('sudo', ['mkdir', '-p', sshDir], { stdio: 'pipe', timeout: 5000 });
452
+ spawnSync('sudo', ['chmod', '700', sshDir], { stdio: 'pipe', timeout: 5000 });
453
+ spawnSync('sudo', ['chown', `${shellUser}:${shellUser}`, sshDir], { stdio: 'pipe', timeout: 5000 });
454
+ }
455
+
456
+ // Append key and fix permissions
457
+ const tmpFile = `/tmp/spaces-authkey-${shellUser}-${Date.now()}`;
458
+ fs.writeFileSync(tmpFile, existing + pubKey + '\n');
459
+ spawnSync('sudo', ['cp', tmpFile, authKeysPath], { stdio: 'pipe', timeout: 5000 });
460
+ spawnSync('sudo', ['chmod', '600', authKeysPath], { stdio: 'pipe', timeout: 5000 });
461
+ spawnSync('sudo', ['chown', `${shellUser}:${shellUser}`, authKeysPath], { stdio: 'pipe', timeout: 5000 });
462
+ try { fs.unlinkSync(tmpFile); } catch {}
463
+
464
+ console.log('[SSH] Authorized service key for user ' + shellUser);
465
+ }
466
+ try { ensureServiceKeyAtRuntime(); } catch (e) { console.error('[SSH] Key setup failed (non-fatal):', e.message); }
467
+
468
+ // Session store: keeps ptys alive across WebSocket reconnections
469
+ // Key: paneId, Value: { pty, ws (current WebSocket or null), buffer (rolling output), username }
470
+ const sessions = new Map();
471
+
472
+ const MAX_BUFFER_LINES = 500;
473
+
474
+ // ─── Agent definitions (mirrors src/lib/agents.ts) ────────
475
+ const AGENTS = {
476
+ shell: { command: '', resumeFlag: '', resumeStyle: '' },
477
+ claude: { command: 'claude', resumeFlag: '--resume', resumeStyle: 'flag' },
478
+ codex: { command: 'codex', resumeFlag: 'resume', resumeStyle: 'subcommand' },
479
+ gemini: { command: 'gemini', resumeFlag: '--resume', resumeStyle: 'flag' },
480
+ aider: { command: 'aider', resumeFlag: '', resumeStyle: '' },
481
+ custom: { command: '', resumeFlag: '', resumeStyle: '' },
482
+ };
483
+
484
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
485
+
486
+ // ─── Remove Cortex hooks from Claude Code config ─────────
487
+ function removeCortexHookConfig(cwd) {
488
+ try {
489
+ const settingsPath = path.join(cwd, '.claude', 'settings.local.json');
490
+ if (!fs.existsSync(settingsPath)) return;
491
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
492
+ let changed = false;
493
+
494
+ // Remove cortex hooks from UserPromptSubmit and Stop
495
+ if (settings.hooks?.UserPromptSubmit) {
496
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
497
+ (g) => !g.hooks?.some((h) => h.command?.includes('cortex-hook'))
498
+ );
499
+ if (settings.hooks.UserPromptSubmit.length === 0) delete settings.hooks.UserPromptSubmit;
500
+ changed = true;
501
+ }
502
+ if (settings.hooks?.Stop) {
503
+ settings.hooks.Stop = settings.hooks.Stop.filter(
504
+ (g) => !g.hooks?.some((h) => h.command?.includes('cortex-learn-hook'))
505
+ );
506
+ if (settings.hooks.Stop.length === 0) delete settings.hooks.Stop;
507
+ changed = true;
508
+ }
509
+
510
+ // Remove cortex MCP server
511
+ if (settings.mcpServers?.cortex) {
512
+ delete settings.mcpServers.cortex;
513
+ changed = true;
514
+ }
515
+
516
+ if (changed) {
517
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
518
+ console.log(`[Cortex] Removed hooks and MCP server from ${settingsPath}`);
519
+ }
520
+
521
+ // Remove spaces-env.json
522
+ const envFile = path.join(cwd, '.claude', 'spaces-env.json');
523
+ if (fs.existsSync(envFile)) fs.unlinkSync(envFile);
524
+ } catch (err) {
525
+ console.error(`[Cortex] Failed to remove hook config:`, err.message);
526
+ }
527
+ }
528
+
529
+ // ─── Cortex Claude Code hook config ──────────────────────
530
+ // Write a UserPromptSubmit hook into .claude/settings.local.json
531
+ // so every prompt gets a RAG search before Claude sees it.
532
+ function writeCortexHookConfig(cwd, paneId) {
533
+ try {
534
+ const claudeDir = path.join(cwd, '.claude');
535
+ if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true });
536
+
537
+ const settingsPath = path.join(claudeDir, 'settings.local.json');
538
+ let settings = {};
539
+ if (fs.existsSync(settingsPath)) {
540
+ try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); } catch {}
541
+ }
542
+
543
+ // Resolve hook paths from @spaces/cortex addon or legacy bin/
544
+ let ragHook, learnHook;
545
+ try {
546
+ const cortexDir = path.dirname(require.resolve('@spaces/cortex'));
547
+ ragHook = path.join(cortexDir, 'hooks', 'cortex-hook.js');
548
+ learnHook = path.join(cortexDir, 'hooks', 'cortex-learn-hook.js');
549
+ } catch {
550
+ ragHook = path.resolve(__dirname, 'cortex-hook.js');
551
+ learnHook = path.resolve(__dirname, 'cortex-learn-hook.js');
552
+ }
553
+
554
+ // Merge — don't clobber existing hooks for other events
555
+ if (!settings.hooks) settings.hooks = {};
556
+
557
+ // Bake env vars into hook commands so they're always available
558
+ // (Claude Code hook subprocesses may not inherit the PTY env)
559
+ const hookEnv = `SPACES_PORT=${API_PORT} SPACES_SESSION_SECRET="${process.env.SPACES_SESSION_SECRET || ''}"`;
560
+
561
+ // RAG search: runs on every prompt, injects relevant context
562
+ settings.hooks.UserPromptSubmit = [
563
+ {
564
+ hooks: [
565
+ {
566
+ type: 'command',
567
+ command: `${hookEnv} node "${ragHook}"`,
568
+ timeout: 5,
569
+ },
570
+ ],
571
+ },
572
+ ];
573
+
574
+ // Learn: runs after Claude finishes, ingests the exchange back into Cortex
575
+ settings.hooks.Stop = [
576
+ {
577
+ hooks: [
578
+ {
579
+ type: 'command',
580
+ command: `${hookEnv} node "${learnHook}"`,
581
+ timeout: 10,
582
+ },
583
+ ],
584
+ },
585
+ ];
586
+
587
+ // Refresh SessionStart hook with current pane ID (prevents stale ID errors)
588
+ if (paneId) {
589
+ try {
590
+ const teamsHook = path.join(os.homedir(), '.spaces', 'packages', 'teams', 'bin', 'spaces-hook.js');
591
+ if (fs.existsSync(teamsHook)) {
592
+ settings.hooks.SessionStart = [
593
+ {
594
+ hooks: [
595
+ {
596
+ type: 'command',
597
+ command: `node "${teamsHook}" ${paneId}`,
598
+ timeout: 10000,
599
+ },
600
+ ],
601
+ },
602
+ ];
603
+ }
604
+ } catch { /* teams not installed */ }
605
+ }
606
+
607
+ // Register Cortex MCP server
608
+ const mcpServer = path.resolve(__dirname, 'cortex-mcp.js');
609
+ if (!settings.mcpServers) settings.mcpServers = {};
610
+ settings.mcpServers.cortex = {
611
+ command: 'node',
612
+ args: [mcpServer],
613
+ env: {
614
+ SPACES_URL: `http://localhost:${API_PORT}`,
615
+ SPACES_INTERNAL_TOKEN: (process.env.SPACES_SESSION_SECRET || '').slice(0, 16),
616
+ },
617
+ };
618
+
619
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
620
+ console.log(`[Cortex] Wrote Claude Code hooks (RAG + Learn) + MCP server to ${settingsPath}`);
621
+ } catch (err) {
622
+ console.error(`[Cortex] Failed to write hook config:`, err.message);
623
+ }
624
+ }
625
+
626
+ // ─── Cortex context injection ────────────────────────────
627
+ // Fetch relevant knowledge from Cortex API and write a context file
628
+ // in the workspace before the agent launches.
629
+ async function injectCortexContext(cwd, workspaceId, ws) {
630
+ if (!isApiReady()) return 0;
631
+ if (SPACES_TIER !== 'team' && SPACES_TIER !== 'federation') return 0;
632
+ // Check if Cortex is actually enabled in user config
633
+ try {
634
+ const configPath = path.join(os.homedir(), '.spaces', 'config.json');
635
+ if (fs.existsSync(configPath)) {
636
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
637
+ if (!cfg.cortex?.enabled) return 0;
638
+ } else {
639
+ return 0;
640
+ }
641
+ } catch { return 0; }
642
+ try {
643
+ const projectName = path.basename(cwd);
644
+ const query = encodeURIComponent(`${projectName} workspace context`);
645
+ const params = `q=${query}&limit=10${workspaceId ? `&workspace_id=${workspaceId}` : ''}`;
646
+ const url = `http://localhost:${API_PORT}/api/cortex/search?${params}`;
647
+
648
+ // Use internal auth bypass (x-spaces-internal header) to skip session middleware
649
+ const internalToken = (process.env.SPACES_SESSION_SECRET || '').slice(0, 16);
650
+ const options = {
651
+ timeout: 5000,
652
+ headers: {
653
+ 'x-spaces-internal': internalToken,
654
+ },
655
+ };
656
+
657
+ const body = await new Promise((resolve, reject) => {
658
+ const req = http.get(url, options, (res) => {
659
+ // Follow redirects (Next.js trailing-slash redirects)
660
+ if (res.statusCode === 308 || res.statusCode === 307 || res.statusCode === 301 || res.statusCode === 302) {
661
+ const redirectUrl = res.headers.location;
662
+ if (redirectUrl) {
663
+ const fullUrl = redirectUrl.startsWith('http') ? redirectUrl : `http://localhost:${API_PORT}${redirectUrl}`;
664
+ const req2 = http.get(fullUrl, options, (res2) => {
665
+ let data = '';
666
+ res2.on('data', (chunk) => { data += chunk; });
667
+ res2.on('end', () => {
668
+ if (res2.statusCode !== 200) {
669
+ reject(new Error(`Cortex API returned ${res2.statusCode}: ${data.slice(0, 200)}`));
670
+ } else {
671
+ resolve(data);
672
+ }
673
+ });
674
+ });
675
+ req2.on('error', reject);
676
+ return;
677
+ }
678
+ }
679
+ let data = '';
680
+ res.on('data', (chunk) => { data += chunk; });
681
+ res.on('end', () => {
682
+ if (res.statusCode !== 200) {
683
+ reject(new Error(`Cortex API returned ${res.statusCode}: ${data.slice(0, 200)}`));
684
+ } else {
685
+ resolve(data);
686
+ }
687
+ });
688
+ });
689
+ req.on('error', reject);
690
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
691
+ });
692
+
693
+ const parsed = JSON.parse(body);
694
+ const results = parsed.results;
695
+ if (!results || results.length === 0) {
696
+ console.log(`[Cortex] No knowledge found for "${projectName}"`);
697
+ return 0;
698
+ }
699
+
700
+ // Format context (mirrors src/lib/cortex/retrieval/injection.ts)
701
+ const TYPE_LABELS = {
702
+ decision: 'Decision', pattern: 'Pattern', preference: 'Preference',
703
+ error_fix: 'Error Fix', context: 'Context', code_pattern: 'Code',
704
+ command: 'Command', conversation: 'Conversation', summary: 'Summary',
705
+ };
706
+ const lines = ['<cortex-context>', 'Relevant context from your workspace history:', ''];
707
+ let tokens = 20;
708
+ const included = [];
709
+ for (const unit of results) {
710
+ const label = TYPE_LABELS[unit.type] || unit.type;
711
+ const date = (unit.source_timestamp || '').slice(0, 10);
712
+ const confidence = (unit.confidence * 100).toFixed(0);
713
+ let entry = `[${label}]`;
714
+ if (date) entry += ` ${date}:`;
715
+ entry += ` ${unit.text}`;
716
+ if (unit.session_id) entry += `\nSource: session ${unit.session_id}, confidence: ${confidence}%`;
717
+ const entryTokens = Math.ceil(entry.length / 4);
718
+ if (tokens + entryTokens > 2000) break;
719
+ lines.push(entry, '');
720
+ tokens += entryTokens;
721
+ included.push({ type: unit.type, text: unit.text.slice(0, 80) });
722
+ }
723
+ lines.push('</cortex-context>');
724
+
725
+ // Write context file (readable artifact for any agent)
726
+ const spacesDir = path.join(cwd, '.spaces');
727
+ if (!fs.existsSync(spacesDir)) fs.mkdirSync(spacesDir, { recursive: true });
728
+ fs.writeFileSync(path.join(spacesDir, 'cortex-context.md'), lines.join('\n'), 'utf-8');
729
+ console.log(`[Cortex] Injected ${included.length} knowledge units for ${path.basename(cwd)}`);
730
+
731
+ // Notify client for injection badge
732
+ if (ws && ws.readyState === 1) {
733
+ ws.send(JSON.stringify({ type: 'cortex-injection', count: included.length, items: included }));
734
+ }
735
+
736
+ return included.length;
737
+ } catch (err) {
738
+ console.error(`[Cortex] Injection failed:`, err.message);
739
+ return 0;
740
+ }
741
+ }
742
+
743
+ // ─── Git Bash detection (Windows) ────────────────────────
744
+ function findGitBash() {
745
+ const custom = process.env.CLAUDE_CODE_GIT_BASH_PATH;
746
+ if (custom && fs.existsSync(custom)) return custom;
747
+ const localAppData = process.env.LOCALAPPDATA || '';
748
+ const candidates = [
749
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
750
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
751
+ path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
752
+ ];
753
+ for (const p of candidates) {
754
+ if (p && fs.existsSync(p)) return p;
755
+ }
756
+ // Last resort: check if bash is on PATH via where command
757
+ try {
758
+ const result = require('child_process').execSync('where bash.exe 2>nul', { encoding: 'utf-8', timeout: 3000 });
759
+ const first = result.trim().split('\n')[0].trim();
760
+ if (first && first.toLowerCase().includes('git') && fs.existsSync(first)) return first;
761
+ } catch { /* not found */ }
762
+ return null;
763
+ }
764
+
765
+ // ─── SSH binary detection (Windows) ──────────────────────
766
+ function findSshBinary() {
767
+ if (process.platform !== 'win32') return '/usr/bin/ssh';
768
+ // Windows OpenSSH ships in System32
769
+ const sysSSH = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe');
770
+ if (fs.existsSync(sysSSH)) return sysSSH;
771
+ // Git for Windows also bundles ssh
772
+ const gitSSH = 'C:\\Program Files\\Git\\usr\\bin\\ssh.exe';
773
+ if (fs.existsSync(gitSSH)) return gitSSH;
774
+ try {
775
+ const result = require('child_process').execSync('where ssh.exe 2>nul', { encoding: 'utf-8', timeout: 3000 });
776
+ const first = result.trim().split(String.fromCharCode(10))[0].trim();
777
+ if (first && fs.existsSync(first)) return first;
778
+ } catch {}
779
+ return null;
780
+ }
781
+
782
+ // ─── Origin validation ───────────────────────────────────
783
+ function isAllowedOrigin(origin, req) {
784
+ if (!origin) return false;
785
+ try {
786
+ const url = new URL(origin);
787
+ // Allow localhost/127.0.0.1 (any port)
788
+ if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') return true;
789
+ // Allow if origin matches the server's own Host header (same-origin requests)
790
+ const host = req && req.headers && req.headers.host;
791
+ if (host && url.host === host) return true;
792
+ // Allow configured hostname from env (e.g., spaces.example.com)
793
+ const allowed = process.env.SPACES_ALLOWED_ORIGINS;
794
+ if (allowed) {
795
+ return allowed.split(',').some(h => url.hostname === h.trim());
796
+ }
797
+ // In non-community modes, require explicit allowed origins
798
+ if (SPACES_TIER !== 'community') return false;
799
+ // Desktop/community: allow any origin
800
+ return true;
801
+ } catch {
802
+ return false;
803
+ }
804
+ }
805
+
806
+ // ─── Live collab toggle handler ─────────────────────────
807
+ function handleCollabToggle(paneId, session) {
808
+ try {
809
+ const teams = require('@spaces/teams');
810
+ const config = teams.terminal.getCollabConfig(paneId, session.username);
811
+
812
+ if (config) {
813
+ // Enabling collaboration
814
+ session.isCollaborating = true;
815
+ session.workspaceId = config.workspaceId;
816
+ session.paneName = config.paneName;
817
+
818
+ const env = {
819
+ SPACES_PANE_ID: paneId,
820
+ SPACES_WORKSPACE_ID: config.workspaceId,
821
+ SPACES_PANE_NAME: config.paneName,
822
+ SPACES_USERNAME: session.username,
823
+ SPACES_API_URL: `http://localhost:${API_PORT}`,
824
+ SPACES_COLLABORATING: '1',
825
+ };
826
+ teams.terminal.writeAgentConfig(session.agentType, session.cwd, env);
827
+
828
+ // Nudge the agent so it knows collaboration is available
829
+ if (session.pty && !session.exited) {
830
+ const nudge = 'Workspace collaboration has been enabled. Hooks are active — you will receive messages on the next prompt. MCP tools (post_message, read_messages) require reconnecting the MCP server (use /mcp).';
831
+ session.pty.write(nudge);
832
+ setTimeout(() => { if (!session.exited) session.pty.write('\r'); }, 100);
833
+ }
834
+
835
+ console.log(`[CollabToggle] Enabled for pane ${paneId.slice(0, 8)} (workspace ${config.workspaceId.slice(0, 8)})`);
836
+ } else {
837
+ // Disabling collaboration
838
+ teams.terminal.removeAgentConfig(session.agentType, session.cwd);
839
+ session.isCollaborating = false;
840
+ session.workspaceId = null;
841
+
842
+ console.log(`[CollabToggle] Disabled for pane ${paneId.slice(0, 8)}`);
843
+ }
844
+
845
+ // Confirm to browser
846
+ if (session.ws && session.ws.readyState === 1) {
847
+ session.ws.send(JSON.stringify({ type: 'collab-updated', isCollaborating: !!config }));
848
+ }
849
+ } catch (e) {
850
+ console.error(`[CollabToggle] Error for pane ${paneId.slice(0, 8)}:`, e.message);
851
+ }
852
+ }
853
+
854
+ // ─── Shared connection handler ──────────────────────────
855
+ function handleConnection(wss, ws, req) {
856
+ ws.isAlive = true;
857
+ ws.on('pong', () => { ws.isAlive = true; });
858
+
859
+ const url = new URL(req.url || '/', 'http://localhost');
860
+ const paneId = url.searchParams.get('paneId') || require('crypto').randomUUID();
861
+ const cwd = url.searchParams.get('cwd') || process.env.HOME || process.env.USERPROFILE || 'C:\\';
862
+ const agentType = url.searchParams.get('agentType') || 'shell';
863
+ const rawAgentSession = url.searchParams.get('agentSession') || '';
864
+ const agentSession = (rawAgentSession === 'new' || UUID_RE.test(rawAgentSession)) ? rawAgentSession : '';
865
+ const rawCustomCommand = url.searchParams.get('customCommand') || '';
866
+ // Sanitize: reject shell metacharacters that enable injection (;, |, &, $, `, etc.)
867
+ const customCommand = /[;&|`$(){}]/.test(rawCustomCommand) ? '' : rawCustomCommand;
868
+ const cols = parseInt(url.searchParams.get('cols') || '120', 10);
869
+ const rows = parseInt(url.searchParams.get('rows') || '30', 10);
870
+
871
+ // Authenticate: try session cookie first (self-contained auth), then terminal token + SSO header
872
+ let username = null;
873
+ const cookies = parseCookies(req.headers.cookie);
874
+ const sessionToken = cookies['spaces-session'];
875
+ const sessionPayload = sessionToken ? verifySessionToken(sessionToken) : null;
876
+
877
+ console.log(`[Auth] pane=${paneId.slice(0, 8)} cookie=${sessionToken ? 'present' : 'MISSING'} sessionValid=${!!sessionPayload} terminalToken=${(url.searchParams.get('terminalToken') || '').slice(0, 12) || 'NONE'} nodeId=${url.searchParams.get('nodeId') || 'NONE'} apiKey=${url.searchParams.get('apiKey') ? 'present' : 'NONE'}`);
878
+
879
+ if (sessionPayload) {
880
+ // Self-contained auth: session cookie is valid
881
+ username = sessionPayload.sub;
882
+ console.log(`[Auth] Authenticated via session cookie: ${username}`);
883
+ } else {
884
+ const terminalToken = url.searchParams.get('terminalToken') || '';
885
+
886
+ // Accept magic tokens from desktop/community tier, or from trusted local proxies (Docker/localhost)
887
+ const remoteIp = req.socket.remoteAddress || '';
888
+ const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1' || remoteIp.startsWith('172.') || remoteIp.startsWith('::ffff:172.');
889
+ if ((terminalToken === 'desktop-local' || terminalToken === 'session-auth') && (SPACES_TIER === 'desktop' || SPACES_TIER === 'community' || isLocal)) {
890
+ username = os.userInfo().username;
891
+ // When running as SYSTEM, resolve to the first real user from admin DB
892
+ if (process.platform === "win32" && username.toUpperCase() === "SYSTEM") {
893
+ const db = getAdminDb();
894
+ if (db) {
895
+ try {
896
+ const row = db.prepare("SELECT username FROM users LIMIT 1").get();
897
+ if (row) username = row.username;
898
+ } catch {}
899
+ }
900
+ }
901
+ console.log(`[Auth] Authenticated via desktop token: ${username}`);
902
+ } else {
903
+ // Verify terminal token — if signed by this server's secret, trust it
904
+ const tokenUser = verifyTerminalToken(terminalToken);
905
+ if (tokenUser) {
906
+ // Use the user from the signed token — do NOT trust x-auth-user header
907
+ // as it can be spoofed by clients
908
+ username = tokenUser;
909
+ console.log(`[Auth] Authenticated via terminal token: ${username}`);
910
+ } else if (terminalToken) {
911
+ console.log(`[Auth] Terminal token FAILED: invalid or expired`);
912
+ }
913
+ }
914
+ }
915
+
916
+ // Internal token auth (for VR client and other trusted local processes)
917
+ if (!username) {
918
+ const internalToken = url.searchParams.get('internal') || '';
919
+ const expectedToken = (process.env.SPACES_SESSION_SECRET || '').slice(0, 16);
920
+ if (internalToken && expectedToken && internalToken === expectedToken) {
921
+ const remoteIp = req.socket.remoteAddress || '';
922
+ const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1' || remoteIp.includes('192.168.') || remoteIp.includes('::ffff:192.168.');
923
+ if (isLocal) {
924
+ username = os.userInfo().username;
925
+ console.log(`[Auth] Authenticated via internal token (VR): ${username}`);
926
+ }
927
+ }
928
+ }
929
+
930
+ // Network API key auth (for proxied connections from remote nodes)
931
+ if (!username) {
932
+ const apiKey = url.searchParams.get('apiKey');
933
+ if (apiKey) {
934
+ console.log(`[Auth] API key provided: ${apiKey.slice(0, 4)}*** (length=${apiKey.length})`);
935
+ const keyRecord = validateNetworkApiKey(apiKey);
936
+ if (keyRecord) {
937
+ console.log(`[Auth] API key validated: permissions=${keyRecord.permissions}, username=${keyRecord.username}`);
938
+ if (keyRecord.permissions === 'terminal' || keyRecord.permissions === 'admin') {
939
+ username = keyRecord.username || os.userInfo().username;
940
+ } else {
941
+ console.log(`[Auth] API key rejected: permissions="${keyRecord.permissions}" not terminal/admin`);
942
+ }
943
+ } else {
944
+ console.log(`[Auth] API key validation FAILED (no matching key in DB)`);
945
+ }
946
+ } else {
947
+ console.log(`[Auth] No apiKey param in WebSocket URL`);
948
+ }
949
+ }
950
+
951
+ if (!username) {
952
+ console.log(`[Auth] REJECTED connection for pane ${paneId} — no auth method succeeded`);
953
+ ws.send(JSON.stringify({ type: 'error', data: 'Authentication required' }));
954
+ ws.close();
955
+ return;
956
+ }
957
+
958
+ // Proxy to remote node (federation tier only)
959
+ const nodeId = url.searchParams.get('nodeId');
960
+ if (nodeId) {
961
+ if (SPACES_TIER !== 'federation') {
962
+ ws.send(JSON.stringify({ type: 'error', data: 'Remote workspaces require the Federation tier' }));
963
+ ws.close();
964
+ return;
965
+ }
966
+ handleProxyConnection(ws, nodeId, { paneId, cwd, agentType, agentSession, customCommand, cols, rows });
967
+ return;
968
+ }
969
+
970
+ // Check for existing session to reattach
971
+ const existing = sessions.get(paneId);
972
+ if (existing && existing.pty && !existing.exited) {
973
+ console.log(`[WS] Reattach pane=${paneId.slice(0,8)} buffer=${existing.buffer.length} chunks`);
974
+ existing.ws = ws;
975
+
976
+ // Replay buffered output so user sees context
977
+ for (const chunk of existing.buffer) {
978
+ ws.send(JSON.stringify({ type: 'data', data: chunk }));
979
+ }
980
+
981
+ try { existing.pty.resize(cols, rows); } catch { /* ignore */ }
982
+
983
+ ws.send(JSON.stringify({ type: 'ready', paneId, reattached: true }));
984
+
985
+ // Re-send detected session ID on reattach — the original WS message may
986
+ // have been lost if the connection dropped during detection
987
+ if (existing.detectedSessionId) {
988
+ ws.send(JSON.stringify({
989
+ type: 'session-detected',
990
+ sessionId: existing.detectedSessionId,
991
+ paneId,
992
+ }));
993
+ }
994
+
995
+ // Skip Cortex injection on reattach — context was already injected at spawn.
996
+ // The badge polls /api/cortex/status independently.
997
+
998
+ ws.on('message', (raw) => {
999
+ try {
1000
+ const msg = JSON.parse(raw.toString());
1001
+ if (msg.type === 'data') {
1002
+ existing.pty.write(msg.data);
1003
+ } else if (msg.type === 'resize') {
1004
+ try { existing.pty.resize(msg.cols, msg.rows); } catch { /* ignore */ }
1005
+ } else if (msg.type === 'collab-toggle') {
1006
+ handleCollabToggle(paneId, existing);
1007
+ }
1008
+ } catch {
1009
+ existing.pty.write(raw.toString());
1010
+ }
1011
+ });
1012
+
1013
+ ws.on('close', (code, reason) => {
1014
+ console.log(`[WS] Close pane=${paneId.slice(0,8)} code=${code} reason=${reason || 'none'}`);
1015
+ if (existing.ws === ws) existing.ws = null;
1016
+ });
1017
+
1018
+ ws.on('error', (err) => {
1019
+ console.log(`[WS] Error pane=${paneId.slice(0,8)} err=${err.message}`);
1020
+ });
1021
+
1022
+ return;
1023
+ }
1024
+
1025
+ // Create new pty session
1026
+ const isWindows = process.platform === 'win32';
1027
+
1028
+ // Resolve the OS shell user for this app user
1029
+ const shellUser = lookupShellUser(username);
1030
+ const processUser = os.userInfo().username;
1031
+ let shell, args;
1032
+ const isSSH = shellUser !== processUser;
1033
+ if (isSSH) {
1034
+ // SSH to localhost as the mapped shell user using the service key
1035
+ const sshBin = findSshBinary();
1036
+ if (!sshBin) {
1037
+ console.error(`[Spawn] SSH binary not found — cannot spawn as ${shellUser}`);
1038
+ ws.send(JSON.stringify({ type: 'error', data: 'SSH not available. Install OpenSSH to enable multi-user terminals.' }));
1039
+ ws.close();
1040
+ return;
1041
+ }
1042
+ shell = sshBin;
1043
+ args = [
1044
+ '-o', 'StrictHostKeyChecking=accept-new',
1045
+ '-o', `UserKnownHostsFile=${path.join(os.homedir(), '.spaces', 'known_hosts')}`,
1046
+ '-i', SERVICE_KEY,
1047
+ '-t',
1048
+ `${shellUser}@localhost`,
1049
+ ];
1050
+ // Force IPv4 localhost may resolve to ::1 (IPv6) which sshd can reject
1051
+ args.unshift('-4');
1052
+
1053
+ // On-demand SSH provisioning: ensure the shell user's authorized_keys is set up
1054
+ // before attempting the connection. This handles users added after service install.
1055
+ if (process.platform !== 'win32' && fs.existsSync(SERVICE_KEY + '.pub')) {
1056
+ try {
1057
+ const pubKey = fs.readFileSync(SERVICE_KEY + '.pub', 'utf-8').trim();
1058
+ authorizeShellUser(shellUser, pubKey);
1059
+ } catch (e) {
1060
+ console.error(`[SSH] On-demand provisioning for ${shellUser} failed (non-fatal):`, e.message);
1061
+ }
1062
+ }
1063
+ } else if (isWindows && agentType !== 'shell') {
1064
+ // Agents like Claude Code require bash on Windows — find git-bash
1065
+ shell = findGitBash();
1066
+ args = [];
1067
+ if (!shell) {
1068
+ shell = 'cmd.exe';
1069
+ }
1070
+ } else {
1071
+ shell = isWindows ? 'cmd.exe' : (process.env.SHELL || '/bin/bash');
1072
+ args = [];
1073
+ }
1074
+
1075
+ // Detect bash-on-Windows so cd commands use bash syntax, not cmd.exe `cd /d`
1076
+ const isBashOnWindows = isWindows && shell && (shell.endsWith('bash.exe') || shell.endsWith('bash'));
1077
+
1078
+ const env = { ...process.env };
1079
+ delete env.CLAUDECODE;
1080
+ // Enable prompt suggestions in spawned Claude Code sessions
1081
+ env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION = 'true';
1082
+ // Tell Claude Code where git-bash is so it doesn't fail the bash detection
1083
+ if (isWindows && shell && shell.endsWith('bash.exe') && !env.CLAUDE_CODE_GIT_BASH_PATH) {
1084
+ env.CLAUDE_CODE_GIT_BASH_PATH = shell;
1085
+ }
1086
+
1087
+ // Fall back to HOME if cwd doesn't exist (e.g. remote path from another node)
1088
+ let safeCwd = cwd;
1089
+ if (!fs.existsSync(safeCwd)) {
1090
+ safeCwd = os.homedir();
1091
+ console.log(`[Spawn] cwd "${cwd}" does not exist, falling back to "${safeCwd}"`);
1092
+ }
1093
+
1094
+ // Inject Spaces bus environment for agent communication
1095
+ env.SPACES_PANE_ID = paneId;
1096
+ env.SPACES_API_URL = `http://localhost:${API_PORT}`;
1097
+
1098
+ // Look up workspace collaboration config from @spaces/teams
1099
+ let isCollaborating = false;
1100
+ try {
1101
+ const teams = require('@spaces/teams');
1102
+ const config = teams.terminal.getCollabConfig(paneId, username);
1103
+ if (config) {
1104
+ env.SPACES_WORKSPACE_ID = config.workspaceId;
1105
+ env.SPACES_PANE_NAME = config.paneName;
1106
+ env.SPACES_USERNAME = username;
1107
+ isCollaborating = true;
1108
+ env.SPACES_COLLABORATING = '1';
1109
+ console.log(`[Collab] Enabled for pane ${paneId.slice(0, 8)} — workspace ${config.workspaceId}, name "${config.paneName}"`);
1110
+ }
1111
+ } catch (e) {
1112
+ console.error(`[Collab] Failed to check collaboration config for pane ${paneId.slice(0, 8)}:`, e.message);
1113
+ }
1114
+
1115
+ console.log(`[Spawn] user=${username} shell=${shell} args=${JSON.stringify(args)} cwd=${safeCwd} agentType=${agentType}`);
1116
+
1117
+ // Write Cortex RAG hook for Claude Code before spawning (only if Cortex is enabled)
1118
+ if (agentType === 'claude' && (SPACES_TIER === 'team' || SPACES_TIER === 'federation')) {
1119
+ try {
1120
+ const userHome = getUserHome(username);
1121
+ const configPath = path.join(userHome, '.spaces', 'config.json');
1122
+ let cortexEnabled = false;
1123
+ if (fs.existsSync(configPath)) {
1124
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1125
+ cortexEnabled = cfg.cortex?.enabled === true;
1126
+ }
1127
+ if (!cortexEnabled) {
1128
+ removeCortexHookConfig(safeCwd);
1129
+ } else {
1130
+ writeCortexHookConfig(safeCwd, paneId);
1131
+ // Resolve workspace ID: from collab config, or look up from pane DB
1132
+ let wsId = env.SPACES_WORKSPACE_ID || null;
1133
+ if (!wsId) {
1134
+ try {
1135
+ const Database = require('better-sqlite3');
1136
+ const spacesDb = new Database(path.join(getUserHome(username), '.spaces', 'spaces.db'), { readonly: true });
1137
+ const row = spacesDb.prepare('SELECT workspace_id FROM panes WHERE id = ?').get(paneId);
1138
+ if (row && row.workspace_id) wsId = String(row.workspace_id);
1139
+ spacesDb.close();
1140
+ } catch { /* non-fatal */ }
1141
+ }
1142
+ if (wsId) env.SPACES_WORKSPACE_ID = wsId;
1143
+ // Write workspace ID for hooks to read (they can't inherit PTY env)
1144
+ try {
1145
+ const envFile = path.join(safeCwd, '.claude', 'spaces-env.json');
1146
+ fs.writeFileSync(envFile, JSON.stringify({
1147
+ workspaceId: wsId,
1148
+ port: API_PORT,
1149
+ }), 'utf-8');
1150
+ } catch { /* non-fatal */ }
1151
+ }
1152
+ } catch (e) {
1153
+ console.error('[Cortex] Config check failed (non-fatal):', e.message);
1154
+ }
1155
+ }
1156
+
1157
+ let term;
1158
+ try {
1159
+ term = pty.spawn(shell, args, {
1160
+ name: 'xterm-256color',
1161
+ cols,
1162
+ rows,
1163
+ cwd: safeCwd,
1164
+ env,
1165
+ });
1166
+ } catch (err) {
1167
+ console.error(`[Spawn Error] ${err.message} (cwd=${cwd}, shell=${shell})`);
1168
+ ws.send(JSON.stringify({ type: 'error', data: 'Failed to spawn terminal session' }));
1169
+ ws.close();
1170
+ return;
1171
+ }
1172
+
1173
+ const session = {
1174
+ pty: term, ws, buffer: [], exited: false, username,
1175
+ agentType,
1176
+ cwd: safeCwd,
1177
+ paneName: env.SPACES_PANE_NAME || paneId,
1178
+ lastOutputTime: Date.now(),
1179
+ lastNudgeTime: 0,
1180
+ startedAt: Date.now(),
1181
+ workspaceId: env.SPACES_WORKSPACE_ID || null,
1182
+ isCollaborating,
1183
+ detectedSessionId: null, // Populated when Claude session is detected
1184
+ };
1185
+ sessions.set(paneId, session);
1186
+ analyticsRecordSessionStart(paneId, username, agentType);
1187
+
1188
+ // ─── Cortex context injection (async, non-blocking) ─────
1189
+ if (agentType !== 'shell') {
1190
+ injectCortexContext(safeCwd, env.SPACES_WORKSPACE_ID || null, ws).catch(() => {});
1191
+ }
1192
+
1193
+ // ─── Inject cd for SSH sessions, then agent command ─────
1194
+ const agent = AGENTS[agentType] || AGENTS.shell;
1195
+
1196
+ // SSH sessions start in the remote user's home dir — cd to target cwd first
1197
+ if (isSSH) {
1198
+ setTimeout(() => {
1199
+ if (!session.exited) {
1200
+ if (isWindows && !isBashOnWindows) {
1201
+ // Windows cmd.exe uses double quotes and /d to change drive
1202
+ const escapedCwd = safeCwd.replace(/"/g, '""');
1203
+ term.write(`cd /d "${escapedCwd}"\r`);
1204
+ } else {
1205
+ // Unix shells (including git-bash on Windows) use single quotes
1206
+ const escapedCwd = safeCwd.replace(/'/g, "'\\''");
1207
+ term.write(`cd '${escapedCwd}'\r`);
1208
+ }
1209
+ }
1210
+ }, 300);
1211
+ }
1212
+
1213
+ // Write collaboration config for agent panes via @spaces/teams
1214
+ if (isCollaborating && agentType !== 'shell') {
1215
+ try {
1216
+ const teams = require('@spaces/teams');
1217
+ teams.terminal.writeAgentConfig(agentType, safeCwd, env);
1218
+ console.log(`[Collab] Wrote agent config for pane ${paneId.slice(0, 8)} (${agentType}) in ${safeCwd}`);
1219
+ } catch (e) {
1220
+ console.error(`[Collab] Failed to write agent config for pane ${paneId.slice(0, 8)}:`, e.message);
1221
+ }
1222
+ }
1223
+
1224
+ if (agentType !== 'shell') {
1225
+ const command = agentType === 'custom' ? customCommand : agent.command;
1226
+
1227
+ if (command) {
1228
+ const delay = isSSH ? 800 : 300;
1229
+
1230
+ if (agentSession && agentSession !== 'new' && agent.resumeFlag) {
1231
+ // Resume an existing session
1232
+ if (agentType === 'claude') {
1233
+ // Claude needs to be run from the correct project CWD
1234
+ const sessionCwd = findSessionCwd(agentSession, username);
1235
+
1236
+ // Verify the session actually exists on disk before attempting resume.
1237
+ // If the .jsonl file is gone (server restart, cleanup, etc.), fall back
1238
+ // to starting fresh so the user doesn't see "No conversation found".
1239
+ const sessionExists = sessionCwd !== null || findSessionFile(agentSession, username);
1240
+
1241
+ if (!sessionExists) {
1242
+ console.log(`[Resume] Session ${agentSession.slice(0, 8)} not found on disk — starting fresh for pane ${paneId.slice(0, 8)}`);
1243
+ // Clear the stale session ID from the DB
1244
+ persistSessionToDb(paneId, 'new');
1245
+ setTimeout(() => {
1246
+ if (!session.exited) {
1247
+ term.write(`${command}\r`);
1248
+ }
1249
+ }, delay);
1250
+ } else {
1251
+ setTimeout(() => {
1252
+ if (session.exited) return;
1253
+ if (sessionCwd && sessionCwd !== safeCwd) {
1254
+ // Use bash-compatible cd on Windows when shell is git-bash
1255
+ const cdCmd = (isWindows && !isBashOnWindows) ? `cd /d "${sessionCwd}"` : `cd "${sessionCwd}"`;
1256
+ term.write(cdCmd + '\r');
1257
+ setTimeout(() => {
1258
+ if (!session.exited) {
1259
+ term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
1260
+ }
1261
+ }, 300);
1262
+ } else {
1263
+ term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
1264
+ }
1265
+ }, delay);
1266
+ }
1267
+ } else {
1268
+ // Generic resume: works for both subcommand (codex resume <id>) and flag (gemini --resume <id>)
1269
+ setTimeout(() => {
1270
+ if (!session.exited) {
1271
+ term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
1272
+ }
1273
+ }, delay);
1274
+ }
1275
+ } else {
1276
+ // Start new session
1277
+ setTimeout(() => {
1278
+ if (!session.exited) {
1279
+ term.write(`${command}\r`);
1280
+ }
1281
+ }, delay);
1282
+ }
1283
+ }
1284
+ }
1285
+
1286
+ // pty -> ws (and buffer)
1287
+ term.onData((data) => {
1288
+ session.lastOutputTime = Date.now();
1289
+ session.buffer.push(data);
1290
+ if (session.buffer.length > MAX_BUFFER_LINES) {
1291
+ session.buffer.shift();
1292
+ }
1293
+
1294
+ if (session.ws && session.ws.readyState === 1) {
1295
+ session.ws.send(JSON.stringify({ type: 'data', data }));
1296
+ }
1297
+ });
1298
+
1299
+ term.onExit(({ exitCode }) => {
1300
+ session.exited = true;
1301
+ analyticsRecordSessionEnd(paneId);
1302
+ // Clean up hook state file
1303
+ try {
1304
+ const hookStateFile = path.join(os.homedir(), '.spaces', 'hook-state', `${paneId}.json`);
1305
+ if (fs.existsSync(hookStateFile)) fs.unlinkSync(hookStateFile);
1306
+ } catch { /* ignore */ }
1307
+ if (session.ws && session.ws.readyState === 1) {
1308
+ session.ws.send(JSON.stringify({ type: 'exit', exitCode }));
1309
+ }
1310
+ setTimeout(() => {
1311
+ if (sessions.get(paneId) === session) {
1312
+ sessions.delete(paneId);
1313
+ }
1314
+ }, 120000);
1315
+ });
1316
+
1317
+ // ws -> pty
1318
+ ws.on('message', (raw) => {
1319
+ try {
1320
+ const msg = JSON.parse(raw.toString());
1321
+ if (msg.type === 'data') {
1322
+ term.write(msg.data);
1323
+ } else if (msg.type === 'resize') {
1324
+ try { term.resize(msg.cols, msg.rows); } catch { /* ignore */ }
1325
+ } else if (msg.type === 'collab-toggle') {
1326
+ handleCollabToggle(paneId, session);
1327
+ }
1328
+ } catch {
1329
+ term.write(raw.toString());
1330
+ }
1331
+ });
1332
+
1333
+ ws.on('close', (code, reason) => {
1334
+ console.log(`[WS] Close pane=${paneId.slice(0,8)} code=${code} reason=${reason || 'none'}`);
1335
+ if (session.ws === ws) session.ws = null;
1336
+ });
1337
+
1338
+ ws.on('error', (err) => {
1339
+ console.log(`[WS] Error pane=${paneId.slice(0,8)} err=${err.message}`);
1340
+ });
1341
+
1342
+ ws.send(JSON.stringify({ type: 'ready', paneId }));
1343
+
1344
+ // Confirm actual collaboration state so browser syncs with backend
1345
+ ws.send(JSON.stringify({ type: 'collab-updated', isCollaborating }));
1346
+
1347
+ // ─── Session ID detection for Claude sessions ────────────
1348
+ // Always run detection for Claude panes — even when resuming, because
1349
+ // the resume may fail (stale/expired session) and Claude will start fresh,
1350
+ // creating a new session ID that we need to capture.
1351
+ if (agentType === 'claude') {
1352
+ detectNewClaudeSession(paneId, cwd, ws, session, username);
1353
+ }
1354
+ }
1355
+
1356
+ // ─── Claude-specific helpers ──────────────────────────────
1357
+
1358
+ function getUserHome(username) {
1359
+ const shellUser = lookupShellUser(username);
1360
+ if (shellUser === os.userInfo().username) return os.homedir();
1361
+ if (process.platform === 'win32') {
1362
+ // On Windows, user profiles live under the Users directory
1363
+ const usersDir = path.dirname(os.homedir());
1364
+ const userHome = path.join(usersDir, shellUser);
1365
+ if (fs.existsSync(userHome)) return userHome;
1366
+ return os.homedir();
1367
+ }
1368
+ return `/home/${shellUser}`;
1369
+ }
1370
+
1371
+ const UUID_JSONL_RE = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
1372
+
1373
+ /**
1374
+ * Convert a CWD path to the project directory key used by Claude Code.
1375
+ * Claude encodes paths by replacing colons, slashes, backslashes, and spaces with dashes.
1376
+ * e.g. "C:\projects\spaces-cortex" → "C--projects-spaces-cortex"
1377
+ * e.g. "/home/user/projects" → "-home-user-projects"
1378
+ */
1379
+ function cwdToProjectKey(cwd) {
1380
+ return cwd.replace(/[:\\/\s]/g, '-').replace(/-$/, '');
1381
+ }
1382
+
1383
+ /**
1384
+ * Check if a Claude session's .jsonl file exists on disk (without parsing CWD).
1385
+ * Used to verify a session is resumable before attempting `claude --resume`.
1386
+ */
1387
+ function findSessionFile(sessionId, username) {
1388
+ const claudeProjectsDir = path.join(getUserHome(username), '.claude', 'projects');
1389
+ try {
1390
+ if (!fs.existsSync(claudeProjectsDir)) return false;
1391
+ const fileName = `${sessionId}.jsonl`;
1392
+ for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
1393
+ if (!projDir.isDirectory()) continue;
1394
+ if (fs.existsSync(path.join(claudeProjectsDir, projDir.name, fileName))) return true;
1395
+ }
1396
+ } catch { /* ignore */ }
1397
+ return false;
1398
+ }
1399
+
1400
+ function findSessionCwd(sessionId, username) {
1401
+ const claudeProjectsDir = path.join(getUserHome(username), '.claude', 'projects');
1402
+ try {
1403
+ if (!fs.existsSync(claudeProjectsDir)) return null;
1404
+ const fileName = `${sessionId}.jsonl`;
1405
+
1406
+ for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
1407
+ if (!projDir.isDirectory()) continue;
1408
+ const filePath = path.join(claudeProjectsDir, projDir.name, fileName);
1409
+ if (fs.existsSync(filePath)) {
1410
+ // Try to find cwd in the jsonl first few lines
1411
+ const fd = fs.openSync(filePath, 'r');
1412
+ const buf = Buffer.alloc(4096);
1413
+ const bytesRead = fs.readSync(fd, buf, 0, 4096, 0);
1414
+ fs.closeSync(fd);
1415
+
1416
+ const chunk = buf.toString('utf-8', 0, bytesRead);
1417
+ const lines = chunk.split('\n');
1418
+ for (const line of lines) {
1419
+ if (!line.trim()) continue;
1420
+ try {
1421
+ const entry = JSON.parse(line);
1422
+ if (entry.cwd) {
1423
+ console.log(`[Session CWD] ${sessionId.slice(0, 8)}: ${entry.cwd}`);
1424
+ return entry.cwd;
1425
+ }
1426
+ } catch { /* skip */ }
1427
+ }
1428
+
1429
+ // Fallback: derive CWD from the project directory name.
1430
+ // NOTE: this is inherently ambiguous — hyphens in directory names
1431
+ // (e.g. "spaces-cortex") are indistinguishable from path separators
1432
+ // in the encoded form. Only use this if the .jsonl had no cwd field.
1433
+ let derivedPath;
1434
+ const winDriveMatch = projDir.name.match(/^([A-Za-z])--(.*)/);
1435
+ if (winDriveMatch) {
1436
+ // Windows: "C--projects-spaces-cortex" → try "C:\projects\spaces-cortex" etc.
1437
+ // We can't perfectly reverse the encoding due to hyphen ambiguity,
1438
+ // but the drive letter prefix (X--) is unambiguous.
1439
+ derivedPath = winDriveMatch[1] + ':\\' + winDriveMatch[2].replace(/-/g, '\\');
1440
+ } else {
1441
+ // Unix: "-home-user-projects" → "/home/user/projects"
1442
+ derivedPath = '/' + projDir.name.replace(/^-/, '').replace(/-/g, '/');
1443
+ }
1444
+ if (derivedPath && fs.existsSync(derivedPath)) {
1445
+ console.log(`[Session CWD] ${sessionId.slice(0, 8)}: ${derivedPath} (derived from dir name)`);
1446
+ return derivedPath;
1447
+ }
1448
+ }
1449
+ }
1450
+ } catch (err) {
1451
+ console.error(`[Session CWD] Error looking up ${sessionId}:`, err.message);
1452
+ }
1453
+ return null;
1454
+ }
1455
+
1456
+ /**
1457
+ * Persist a detected Claude session ID to the database via the Next.js API.
1458
+ * This is the critical reliability fix: even if the WebSocket message to the
1459
+ * frontend is lost (tab backgrounded, network glitch, etc.), the DB is updated
1460
+ * so that workspace-load and page-refresh will use `claude --resume <id>`.
1461
+ */
1462
+ function persistSessionToDb(paneId, sessionId, _retries) {
1463
+ const retries = _retries || 0;
1464
+ if (!isApiReady()) {
1465
+ console.log(`[Session Persist] API not ready, retrying in 2s for pane ${paneId.slice(0, 8)}`);
1466
+ if (retries < 5) setTimeout(() => persistSessionToDb(paneId, sessionId, retries + 1), 2000);
1467
+ return;
1468
+ }
1469
+ const internalToken = (process.env.SPACES_SESSION_SECRET || '').slice(0, 16);
1470
+ const payload = JSON.stringify({ claudeSessionId: sessionId });
1471
+ const req = http.request({
1472
+ hostname: 'localhost',
1473
+ port: API_PORT,
1474
+ path: `/api/panes/${paneId}`,
1475
+ method: 'PUT',
1476
+ headers: {
1477
+ 'Content-Type': 'application/json',
1478
+ 'Content-Length': Buffer.byteLength(payload),
1479
+ 'x-spaces-internal': internalToken,
1480
+ },
1481
+ timeout: 5000,
1482
+ }, (res) => {
1483
+ res.resume(); // consume body
1484
+ if (res.statusCode < 300) {
1485
+ console.log(`[Session Persist] Saved session ${sessionId.slice(0, 8)} to DB for pane ${paneId.slice(0, 8)}`);
1486
+ } else {
1487
+ console.error(`[Session Persist] DB update failed: HTTP ${res.statusCode} for pane ${paneId.slice(0, 8)}`);
1488
+ if (retries < 3) setTimeout(() => persistSessionToDb(paneId, sessionId, retries + 1), 3000);
1489
+ }
1490
+ });
1491
+ req.on('error', (err) => {
1492
+ console.error(`[Session Persist] HTTP error for pane ${paneId.slice(0, 8)}: ${err.message}`);
1493
+ if (retries < 3) setTimeout(() => persistSessionToDb(paneId, sessionId, retries + 1), 3000);
1494
+ });
1495
+ req.on('timeout', () => { req.destroy(); });
1496
+ req.write(payload);
1497
+ req.end();
1498
+ }
1499
+
1500
+ function detectNewClaudeSession(paneId, cwd, ws, session, username) {
1501
+ const claudeProjectsDir = path.join(getUserHome(username), '.claude', 'projects');
1502
+
1503
+ // CRITICAL FIX: scope detection to the project directory matching this pane's CWD.
1504
+ // Previously, this scanned ALL project directories for any new .jsonl file.
1505
+ // When multiple panes started Claude simultaneously (e.g. workspace load),
1506
+ // panes could steal each other's session IDs Pane A would detect Pane B's
1507
+ // new session file first and claim it, leaving Pane B with no session.
1508
+ const expectedProjectKey = cwdToProjectKey(cwd);
1509
+ const expectedProjPath = path.join(claudeProjectsDir, expectedProjectKey);
1510
+
1511
+ const knownSessionIds = new Set();
1512
+ try {
1513
+ if (fs.existsSync(expectedProjPath)) {
1514
+ for (const item of fs.readdirSync(expectedProjPath)) {
1515
+ const m = item.match(UUID_JSONL_RE);
1516
+ if (m) knownSessionIds.add(m[1]);
1517
+ }
1518
+ const indexPath = path.join(expectedProjPath, 'sessions-index.json');
1519
+ if (fs.existsSync(indexPath)) {
1520
+ try {
1521
+ const data = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
1522
+ if (data.entries) {
1523
+ for (const entry of data.entries) knownSessionIds.add(entry.sessionId);
1524
+ }
1525
+ } catch { /* ignore */ }
1526
+ }
1527
+ }
1528
+ } catch { /* ignore */ }
1529
+
1530
+ console.log(`[Session Detect] Pane ${paneId.slice(0, 8)} (${username}): scanning ${expectedProjectKey} snapshot ${knownSessionIds.size} existing sessions`);
1531
+
1532
+ let attempts = 0;
1533
+ const maxAttempts = 45;
1534
+ const interval = setInterval(() => {
1535
+ attempts++;
1536
+ if (attempts > maxAttempts || session.exited) {
1537
+ clearInterval(interval);
1538
+ if (attempts > maxAttempts) {
1539
+ console.log(`[Session Detect] Pane ${paneId.slice(0, 8)}: timed out after ${maxAttempts * 2}s waiting for session in ${expectedProjectKey}`);
1540
+ }
1541
+ return;
1542
+ }
1543
+
1544
+ try {
1545
+ // Project directory may not exist yet if Claude hasn't started writing
1546
+ if (!fs.existsSync(expectedProjPath)) return;
1547
+
1548
+ for (const item of fs.readdirSync(expectedProjPath)) {
1549
+ const m = item.match(UUID_JSONL_RE);
1550
+ if (m && !knownSessionIds.has(m[1])) {
1551
+ const newSessionId = m[1];
1552
+ clearInterval(interval);
1553
+ console.log(`[Session Detect] Pane ${paneId.slice(0, 8)} (${username}): detected session ${newSessionId} in ${expectedProjectKey}`);
1554
+
1555
+ // Cache in memory so reattaching WebSocket gets the session ID
1556
+ session.detectedSessionId = newSessionId;
1557
+
1558
+ // Persist to DB server-side — this is the reliability backstop.
1559
+ // Even if the WebSocket message below never reaches the frontend,
1560
+ // the DB will have the correct sessionId for future loads.
1561
+ persistSessionToDb(paneId, newSessionId);
1562
+
1563
+ // Also notify the frontend via WebSocket (for immediate UI update)
1564
+ if (session.ws && session.ws.readyState === 1) {
1565
+ session.ws.send(JSON.stringify({
1566
+ type: 'session-detected',
1567
+ sessionId: newSessionId,
1568
+ paneId,
1569
+ }));
1570
+ }
1571
+ return;
1572
+ }
1573
+ }
1574
+ } catch { /* ignore */ }
1575
+ }, 2000);
1576
+ }
1577
+
1578
+ // ─── Proxy: forward connection to remote node ──────────
1579
+
1580
+ async function handleProxyConnection(clientWs, nodeId, opts) {
1581
+ const { paneId, cwd, agentType, agentSession, customCommand, cols, rows } = opts;
1582
+
1583
+ const node = getNodeInfo(nodeId);
1584
+ if (!node) {
1585
+ clientWs.send(JSON.stringify({ type: 'error', data: `Node ${nodeId} not found` }));
1586
+ clientWs.close();
1587
+ return;
1588
+ }
1589
+
1590
+ const apiKey = decryptNodeApiKey(node.api_key_encrypted);
1591
+ if (!apiKey) {
1592
+ clientWs.send(JSON.stringify({ type: 'error', data: 'Cannot decrypt API key for remote node' }));
1593
+ clientWs.close();
1594
+ return;
1595
+ }
1596
+
1597
+ // Get the remote WebSocket URL via the terminal token endpoint
1598
+ // Only skip TLS verification if explicitly opted in (e.g., self-signed certs)
1599
+ const skipTls = process.env.SPACES_SKIP_TLS_VERIFY === '1';
1600
+ const prevTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
1601
+ if (skipTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
1602
+ let remoteWsUrl;
1603
+ try {
1604
+ const tokenUrl = `${node.url}/api/network/terminal/token/`;
1605
+ const res = await fetch(tokenUrl, {
1606
+ method: 'POST',
1607
+ headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
1608
+ signal: AbortSignal.timeout(10000),
1609
+ });
1610
+
1611
+ if (!res.ok) {
1612
+ let detail = '';
1613
+ try { const body = await res.json(); detail = body.error || JSON.stringify(body); } catch { detail = await res.text().catch(() => `HTTP ${res.status}`); }
1614
+ clientWs.send(JSON.stringify({ type: 'error', data: `Remote terminal auth failed: ${detail}` }));
1615
+ clientWs.close();
1616
+ return;
1617
+ }
1618
+
1619
+ const data = await res.json();
1620
+ remoteWsUrl = data.wsUrl;
1621
+ } catch (err) {
1622
+ clientWs.send(JSON.stringify({ type: 'error', data: `Cannot reach remote node: ${err.message}` }));
1623
+ clientWs.close();
1624
+ return;
1625
+ } finally {
1626
+ if (prevTls === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
1627
+ else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTls;
1628
+ }
1629
+
1630
+ // Connect to remote terminal server using the API key directly.
1631
+ // The terminal token approach fails because the proxied WebSocket has no
1632
+ // x-auth-user header, so the remote server can't match the token's username
1633
+ // to the request. API key auth on the WebSocket is the reliable path.
1634
+ const WebSocket = require('ws');
1635
+ const remoteParams = new URLSearchParams({
1636
+ paneId,
1637
+ cwd,
1638
+ agentType,
1639
+ cols: String(cols),
1640
+ rows: String(rows),
1641
+ apiKey: apiKey,
1642
+ });
1643
+ if (agentSession) remoteParams.set('agentSession', agentSession);
1644
+ // Never forward customCommand to remote nodes — too dangerous
1645
+
1646
+ // Upgrade ws:// to wss:// if the node uses https
1647
+ let wsUrl = remoteWsUrl;
1648
+ if (node.url.startsWith('https://') && wsUrl.startsWith('ws://')) {
1649
+ wsUrl = 'wss://' + wsUrl.slice(5);
1650
+ }
1651
+ const remoteUrl = `${wsUrl}?${remoteParams}`;
1652
+ console.log(`[Proxy] Connecting to remote node ${nodeId.slice(0, 8)}`);
1653
+
1654
+ const remoteWs = new WebSocket(remoteUrl, { rejectUnauthorized: !skipTls ? undefined : false });
1655
+
1656
+ remoteWs.on('open', () => {
1657
+ console.log(`[Proxy] Connected to remote node ${nodeId.slice(0, 8)} for pane ${paneId.slice(0, 8)}`);
1658
+ });
1659
+
1660
+ // Pipe data bidirectionally
1661
+ let firstMsg = true;
1662
+ remoteWs.on('message', (data) => {
1663
+ const str = data.toString();
1664
+ if (firstMsg) {
1665
+ console.log(`[Proxy] First message from remote for pane ${paneId.slice(0, 8)}: ${str.slice(0, 200)}`);
1666
+ firstMsg = false;
1667
+ }
1668
+ if (clientWs.readyState === 1) {
1669
+ clientWs.send(str);
1670
+ }
1671
+ });
1672
+
1673
+ clientWs.on('message', (data) => {
1674
+ if (remoteWs.readyState === 1) {
1675
+ remoteWs.send(data.toString());
1676
+ }
1677
+ });
1678
+
1679
+ // Handle closes
1680
+ remoteWs.on('close', () => {
1681
+ console.log(`[Proxy] Remote connection closed for pane ${paneId.slice(0, 8)}`);
1682
+ if (clientWs.readyState === 1) {
1683
+ clientWs.send(JSON.stringify({ type: 'exit', exitCode: -1, reason: 'Remote connection closed' }));
1684
+ }
1685
+ });
1686
+
1687
+ remoteWs.on('error', (err) => {
1688
+ console.error(`[Proxy] Remote error for pane ${paneId.slice(0, 8)}:`, err.message);
1689
+ if (clientWs.readyState === 1) {
1690
+ clientWs.send(JSON.stringify({ type: 'error', data: `Remote error: ${err.message}` }));
1691
+ }
1692
+ });
1693
+
1694
+ clientWs.on('close', () => {
1695
+ console.log(`[Proxy] Client disconnected for pane ${paneId.slice(0, 8)}`);
1696
+ if (remoteWs.readyState === 1) {
1697
+ remoteWs.close();
1698
+ }
1699
+ });
1700
+ }
1701
+
1702
+ // ─── Shared WSS setup (attach handlers to any WebSocketServer) ──
1703
+
1704
+ function setupWss(wss) {
1705
+ const pingInterval = setInterval(() => {
1706
+ wss.clients.forEach((ws) => {
1707
+ if (ws.isAlive === false) return ws.terminate();
1708
+ ws.isAlive = false;
1709
+ ws.ping();
1710
+ });
1711
+ }, 30000);
1712
+
1713
+ wss.on('connection', (ws, req) => handleConnection(wss, ws, req));
1714
+
1715
+ wss.on('error', (err) => {
1716
+ console.error('[Terminal Server] Error:', err.message);
1717
+ });
1718
+
1719
+ // Initialize analytics DB
1720
+ getAdminDbRW();
1721
+
1722
+ return pingInterval;
1723
+ }
1724
+
1725
+ function startMdnsIfNeeded(httpPort) {
1726
+ if (SPACES_TIER === 'federation') {
1727
+ try {
1728
+ const { startMdns } = require('./mdns-service');
1729
+ startMdns(httpPort || PORT);
1730
+ } catch (err) {
1731
+ console.log('[mDNS] Discovery not available:', err.message);
1732
+ }
1733
+ } else {
1734
+ console.log(`[mDNS] Skipped (tier=${SPACES_TIER}, requires federation)`);
1735
+ }
1736
+ }
1737
+
1738
+ // ─── Poll-based idle nudge for agent collaboration ───────
1739
+
1740
+ function startMessageWatcher(apiPort) {
1741
+ try {
1742
+ const teams = require('@spaces/teams');
1743
+ teams.terminal.startMessageWatcher(apiPort, sessions);
1744
+ } catch { /* @spaces/teams not installed — no message watcher */ }
1745
+ }
1746
+
1747
+ // ─── Attached mode: mount on an existing HTTP server ─────
1748
+
1749
+ function createTerminalServer(httpServer) {
1750
+ // In attached mode, the API is served by the parent HTTP server, not on PORT (3458).
1751
+ if (httpServer.listening) {
1752
+ API_PORT = httpServer.address().port;
1753
+ waitForApi();
1754
+ } else {
1755
+ httpServer.on('listening', () => { API_PORT = httpServer.address().port; waitForApi(); });
1756
+ }
1757
+
1758
+ const wss = new WebSocketServer({ noServer: true });
1759
+ setupWss(wss);
1760
+
1761
+ httpServer.on('upgrade', (req, socket, head) => {
1762
+ const url = new URL(req.url, 'http://localhost');
1763
+ if (url.pathname === '/ws' || url.pathname.endsWith('/ws')) {
1764
+ // Verify origin for browser clients
1765
+ const origin = req.headers.origin;
1766
+ if (origin && !isAllowedOrigin(origin, req)) {
1767
+ socket.destroy();
1768
+ return;
1769
+ }
1770
+ wss.handleUpgrade(req, socket, head, (ws) => {
1771
+ wss.emit('connection', ws, req);
1772
+ });
1773
+ }
1774
+ // Non-/ws upgrades are left for other listeners (e.g. HMR proxy)
1775
+ });
1776
+
1777
+ // Start mDNS and message watcher once we know the HTTP port
1778
+ if (httpServer.listening) {
1779
+ startMdnsIfNeeded(httpServer.address().port);
1780
+ startMessageWatcher(httpServer.address().port);
1781
+ } else {
1782
+ httpServer.on('listening', () => {
1783
+ startMdnsIfNeeded(httpServer.address().port);
1784
+ startMessageWatcher(httpServer.address().port);
1785
+ });
1786
+ }
1787
+ return wss;
1788
+ }
1789
+
1790
+ // ─── Standalone mode (run directly) ──────────────────────
1791
+
1792
+ if (require.main === module) {
1793
+ const wss = new WebSocketServer({
1794
+ port: PORT,
1795
+ verifyClient: ({ req }) => {
1796
+ const origin = req.headers.origin;
1797
+ if (!origin) return true;
1798
+ return isAllowedOrigin(origin, req);
1799
+ },
1800
+ });
1801
+ setupWss(wss);
1802
+ startMdnsIfNeeded();
1803
+ startMessageWatcher(PORT);
1804
+ console.log(`Terminal WebSocket server running on ws://localhost:${PORT}`);
1805
+ }
1806
+
1807
+ module.exports = { createTerminalServer };