@jlongo78/agent-spaces 0.7.4 → 0.7.5

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 (515) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +2 -2
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +19 -19
  5. package/.next/standalone/.next/server/app/(desktop)/admin/analytics/page.js.nft.json +1 -1
  6. package/.next/standalone/.next/server/app/(desktop)/admin/analytics/page_client-reference-manifest.js +1 -1
  7. package/.next/standalone/.next/server/app/(desktop)/admin/users/page.js.nft.json +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.js.nft.json +1 -1
  10. package/.next/standalone/.next/server/app/(desktop)/analytics/page_client-reference-manifest.js +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.js.nft.json +1 -1
  14. package/.next/standalone/.next/server/app/(desktop)/network/page_client-reference-manifest.js +1 -1
  15. package/.next/standalone/.next/server/app/(desktop)/page.js.nft.json +1 -1
  16. package/.next/standalone/.next/server/app/(desktop)/page_client-reference-manifest.js +1 -1
  17. package/.next/standalone/.next/server/app/(desktop)/projects/page.js.nft.json +1 -1
  18. package/.next/standalone/.next/server/app/(desktop)/projects/page_client-reference-manifest.js +1 -1
  19. package/.next/standalone/.next/server/app/(desktop)/sessions/[id]/page.js.nft.json +1 -1
  20. package/.next/standalone/.next/server/app/(desktop)/sessions/[id]/page_client-reference-manifest.js +1 -1
  21. package/.next/standalone/.next/server/app/(desktop)/sessions/page.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/(desktop)/sessions/page_client-reference-manifest.js +1 -1
  23. package/.next/standalone/.next/server/app/(desktop)/settings/page.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/(desktop)/settings/page_client-reference-manifest.js +1 -1
  25. package/.next/standalone/.next/server/app/(desktop)/terminal/page.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/(desktop)/terminal/page_client-reference-manifest.js +1 -1
  27. package/.next/standalone/.next/server/app/(desktop)/terminal/pane/[id]/page.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/(desktop)/terminal/pane/[id]/page_client-reference-manifest.js +1 -1
  29. package/.next/standalone/.next/server/app/(desktop)/terminal/remote/[nodeId]/[workspaceId]/page.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/(desktop)/terminal/remote/[nodeId]/[workspaceId]/page_client-reference-manifest.js +1 -1
  31. package/.next/standalone/.next/server/app/(desktop)/workspaces/page.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/(desktop)/workspaces/page_client-reference-manifest.js +1 -1
  33. package/.next/standalone/.next/server/app/_global-error.html +2 -2
  34. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  35. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  36. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  37. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  38. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  39. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  40. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  42. package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
  43. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  44. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  45. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  46. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  47. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  48. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  49. package/.next/standalone/.next/server/app/admin/analytics.html +1 -1
  50. package/.next/standalone/.next/server/app/admin/analytics.rsc +5 -5
  51. package/.next/standalone/.next/server/app/admin/analytics.segments/!KGRlc2t0b3Ap/admin/analytics/__PAGE__.segment.rsc +2 -2
  52. package/.next/standalone/.next/server/app/admin/analytics.segments/!KGRlc2t0b3Ap/admin/analytics.segment.rsc +1 -1
  53. package/.next/standalone/.next/server/app/admin/analytics.segments/!KGRlc2t0b3Ap/admin.segment.rsc +1 -1
  54. package/.next/standalone/.next/server/app/admin/analytics.segments/!KGRlc2t0b3Ap.segment.rsc +3 -3
  55. package/.next/standalone/.next/server/app/admin/analytics.segments/_full.segment.rsc +5 -5
  56. package/.next/standalone/.next/server/app/admin/analytics.segments/_head.segment.rsc +1 -1
  57. package/.next/standalone/.next/server/app/admin/analytics.segments/_index.segment.rsc +2 -2
  58. package/.next/standalone/.next/server/app/admin/analytics.segments/_tree.segment.rsc +2 -2
  59. package/.next/standalone/.next/server/app/admin/users.html +1 -1
  60. package/.next/standalone/.next/server/app/admin/users.rsc +5 -5
  61. package/.next/standalone/.next/server/app/admin/users.segments/!KGRlc2t0b3Ap/admin/users/__PAGE__.segment.rsc +2 -2
  62. package/.next/standalone/.next/server/app/admin/users.segments/!KGRlc2t0b3Ap/admin/users.segment.rsc +1 -1
  63. package/.next/standalone/.next/server/app/admin/users.segments/!KGRlc2t0b3Ap/admin.segment.rsc +1 -1
  64. package/.next/standalone/.next/server/app/admin/users.segments/!KGRlc2t0b3Ap.segment.rsc +3 -3
  65. package/.next/standalone/.next/server/app/admin/users.segments/_full.segment.rsc +5 -5
  66. package/.next/standalone/.next/server/app/admin/users.segments/_head.segment.rsc +1 -1
  67. package/.next/standalone/.next/server/app/admin/users.segments/_index.segment.rsc +2 -2
  68. package/.next/standalone/.next/server/app/admin/users.segments/_tree.segment.rsc +2 -2
  69. package/.next/standalone/.next/server/app/analytics.html +1 -1
  70. package/.next/standalone/.next/server/app/analytics.rsc +5 -5
  71. package/.next/standalone/.next/server/app/analytics.segments/!KGRlc2t0b3Ap/analytics/__PAGE__.segment.rsc +2 -2
  72. package/.next/standalone/.next/server/app/analytics.segments/!KGRlc2t0b3Ap/analytics.segment.rsc +1 -1
  73. package/.next/standalone/.next/server/app/analytics.segments/!KGRlc2t0b3Ap.segment.rsc +3 -3
  74. package/.next/standalone/.next/server/app/analytics.segments/_full.segment.rsc +5 -5
  75. package/.next/standalone/.next/server/app/analytics.segments/_head.segment.rsc +1 -1
  76. package/.next/standalone/.next/server/app/analytics.segments/_index.segment.rsc +2 -2
  77. package/.next/standalone/.next/server/app/analytics.segments/_tree.segment.rsc +2 -2
  78. package/.next/standalone/.next/server/app/api/analytics/overview/route.js.nft.json +1 -1
  79. package/.next/standalone/.next/server/app/api/bulk/route.js.nft.json +1 -1
  80. package/.next/standalone/.next/server/app/api/chat/route.js +1 -1
  81. package/.next/standalone/.next/server/app/api/chat/route.js.nft.json +1 -1
  82. package/.next/standalone/.next/server/app/api/config/route.js.nft.json +1 -1
  83. package/.next/standalone/.next/server/app/api/cortex/context/route.js.nft.json +1 -1
  84. package/.next/standalone/.next/server/app/api/cortex/curation/assess/route.js.nft.json +1 -1
  85. package/.next/standalone/.next/server/app/api/cortex/curation/publish/route.js.nft.json +1 -1
  86. package/.next/standalone/.next/server/app/api/cortex/curation/refine/route.js.nft.json +1 -1
  87. package/.next/standalone/.next/server/app/api/cortex/curation/review/route.js.nft.json +1 -1
  88. package/.next/standalone/.next/server/app/api/cortex/curation/seed/route.js.nft.json +1 -1
  89. package/.next/standalone/.next/server/app/api/cortex/export/route.js.nft.json +1 -1
  90. package/.next/standalone/.next/server/app/api/cortex/federation/pending/route.js.nft.json +1 -1
  91. package/.next/standalone/.next/server/app/api/cortex/federation/resolve/route.js.nft.json +1 -1
  92. package/.next/standalone/.next/server/app/api/cortex/federation/search/route.js.nft.json +1 -1
  93. package/.next/standalone/.next/server/app/api/cortex/federation/teach/route.js.nft.json +1 -1
  94. package/.next/standalone/.next/server/app/api/cortex/graph/edges/route.js.nft.json +1 -1
  95. package/.next/standalone/.next/server/app/api/cortex/graph/entities/[id]/route.js.nft.json +1 -1
  96. package/.next/standalone/.next/server/app/api/cortex/graph/entities/route.js.nft.json +1 -1
  97. package/.next/standalone/.next/server/app/api/cortex/graph/populate/route.js.nft.json +1 -1
  98. package/.next/standalone/.next/server/app/api/cortex/import/route.js.nft.json +1 -1
  99. package/.next/standalone/.next/server/app/api/cortex/import/status/route.js.nft.json +1 -1
  100. package/.next/standalone/.next/server/app/api/cortex/ingest/bootstrap/route.js.nft.json +1 -1
  101. package/.next/standalone/.next/server/app/api/cortex/ingest/status/route.js.nft.json +1 -1
  102. package/.next/standalone/.next/server/app/api/cortex/knowledge/[id]/route.js.nft.json +1 -1
  103. package/.next/standalone/.next/server/app/api/cortex/knowledge/route.js.nft.json +1 -1
  104. package/.next/standalone/.next/server/app/api/cortex/lobes/[id]/route.js.nft.json +1 -1
  105. package/.next/standalone/.next/server/app/api/cortex/lobes/route.js.nft.json +1 -1
  106. package/.next/standalone/.next/server/app/api/cortex/lobes/share/route.js.nft.json +1 -1
  107. package/.next/standalone/.next/server/app/api/cortex/marketplace/browse/route.js.nft.json +1 -1
  108. package/.next/standalone/.next/server/app/api/cortex/marketplace/preview/route.js.nft.json +1 -1
  109. package/.next/standalone/.next/server/app/api/cortex/mcp/call/route.js.nft.json +1 -1
  110. package/.next/standalone/.next/server/app/api/cortex/mcp/tools/route.js.nft.json +1 -1
  111. package/.next/standalone/.next/server/app/api/cortex/search/route.js.nft.json +1 -1
  112. package/.next/standalone/.next/server/app/api/cortex/settings/route.js.nft.json +1 -1
  113. package/.next/standalone/.next/server/app/api/cortex/status/route.js.nft.json +1 -1
  114. package/.next/standalone/.next/server/app/api/cortex/timeline/route.js.nft.json +1 -1
  115. package/.next/standalone/.next/server/app/api/cortex/usage/route.js.nft.json +1 -1
  116. package/.next/standalone/.next/server/app/api/cortex/workspace/[id]/context/route.js.nft.json +1 -1
  117. package/.next/standalone/.next/server/app/api/events/route.js.nft.json +1 -1
  118. package/.next/standalone/.next/server/app/api/folders/route.js.nft.json +1 -1
  119. package/.next/standalone/.next/server/app/api/network/handshake/route.js.nft.json +1 -1
  120. package/.next/standalone/.next/server/app/api/network/projects/route.js.nft.json +1 -1
  121. package/.next/standalone/.next/server/app/api/network/search/route.js.nft.json +1 -1
  122. package/.next/standalone/.next/server/app/api/network/sessions/[id]/messages/route.js.nft.json +1 -1
  123. package/.next/standalone/.next/server/app/api/network/sessions/[id]/route.js.nft.json +1 -1
  124. package/.next/standalone/.next/server/app/api/network/sessions/route.js.nft.json +1 -1
  125. package/.next/standalone/.next/server/app/api/network/workspaces/[id]/route.js.nft.json +1 -1
  126. package/.next/standalone/.next/server/app/api/network/workspaces/route.js +1 -1
  127. package/.next/standalone/.next/server/app/api/network/workspaces/route.js.nft.json +1 -1
  128. package/.next/standalone/.next/server/app/api/panes/[id]/route.js.nft.json +1 -1
  129. package/.next/standalone/.next/server/app/api/panes/route.js.nft.json +1 -1
  130. package/.next/standalone/.next/server/app/api/projects/route.js.nft.json +1 -1
  131. package/.next/standalone/.next/server/app/api/search/route.js.nft.json +1 -1
  132. package/.next/standalone/.next/server/app/api/sessions/[id]/chat/route.js.nft.json +1 -1
  133. package/.next/standalone/.next/server/app/api/sessions/[id]/messages/route.js.nft.json +1 -1
  134. package/.next/standalone/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  135. package/.next/standalone/.next/server/app/api/sessions/route.js.nft.json +1 -1
  136. package/.next/standalone/.next/server/app/api/sync/route.js.nft.json +1 -1
  137. package/.next/standalone/.next/server/app/api/tags/route.js.nft.json +1 -1
  138. package/.next/standalone/.next/server/app/api/tier/route.js.nft.json +1 -1
  139. package/.next/standalone/.next/server/app/api/workspaces/[id]/context/[key]/route.js.nft.json +1 -1
  140. package/.next/standalone/.next/server/app/api/workspaces/[id]/context/route.js.nft.json +1 -1
  141. package/.next/standalone/.next/server/app/api/workspaces/[id]/messages/[msgId]/route.js.nft.json +1 -1
  142. package/.next/standalone/.next/server/app/api/workspaces/[id]/messages/route.js.nft.json +1 -1
  143. package/.next/standalone/.next/server/app/api/workspaces/[id]/route.js.nft.json +1 -1
  144. package/.next/standalone/.next/server/app/api/workspaces/[id]/sessions/route.js.nft.json +1 -1
  145. package/.next/standalone/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  146. package/.next/standalone/.next/server/app/cortex.html +1 -1
  147. package/.next/standalone/.next/server/app/cortex.rsc +5 -5
  148. package/.next/standalone/.next/server/app/cortex.segments/!KGRlc2t0b3Ap/cortex/__PAGE__.segment.rsc +2 -2
  149. package/.next/standalone/.next/server/app/cortex.segments/!KGRlc2t0b3Ap/cortex.segment.rsc +1 -1
  150. package/.next/standalone/.next/server/app/cortex.segments/!KGRlc2t0b3Ap.segment.rsc +3 -3
  151. package/.next/standalone/.next/server/app/cortex.segments/_full.segment.rsc +5 -5
  152. package/.next/standalone/.next/server/app/cortex.segments/_head.segment.rsc +1 -1
  153. package/.next/standalone/.next/server/app/cortex.segments/_index.segment.rsc +2 -2
  154. package/.next/standalone/.next/server/app/cortex.segments/_tree.segment.rsc +2 -2
  155. package/.next/standalone/.next/server/app/login/page_client-reference-manifest.js +1 -1
  156. package/.next/standalone/.next/server/app/login.html +1 -1
  157. package/.next/standalone/.next/server/app/login.rsc +2 -2
  158. package/.next/standalone/.next/server/app/login.segments/_full.segment.rsc +2 -2
  159. package/.next/standalone/.next/server/app/login.segments/_head.segment.rsc +1 -1
  160. package/.next/standalone/.next/server/app/login.segments/_index.segment.rsc +2 -2
  161. package/.next/standalone/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  162. package/.next/standalone/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
  163. package/.next/standalone/.next/server/app/login.segments/login.segment.rsc +1 -1
  164. package/.next/standalone/.next/server/app/m/page_client-reference-manifest.js +1 -1
  165. package/.next/standalone/.next/server/app/m/projects/page_client-reference-manifest.js +1 -1
  166. package/.next/standalone/.next/server/app/m/projects.html +1 -1
  167. package/.next/standalone/.next/server/app/m/projects.rsc +2 -2
  168. package/.next/standalone/.next/server/app/m/projects.segments/_full.segment.rsc +2 -2
  169. package/.next/standalone/.next/server/app/m/projects.segments/_head.segment.rsc +1 -1
  170. package/.next/standalone/.next/server/app/m/projects.segments/_index.segment.rsc +2 -2
  171. package/.next/standalone/.next/server/app/m/projects.segments/_tree.segment.rsc +2 -2
  172. package/.next/standalone/.next/server/app/m/projects.segments/m/projects/__PAGE__.segment.rsc +1 -1
  173. package/.next/standalone/.next/server/app/m/projects.segments/m/projects.segment.rsc +1 -1
  174. package/.next/standalone/.next/server/app/m/projects.segments/m.segment.rsc +1 -1
  175. package/.next/standalone/.next/server/app/m/sessions/[id]/page.js.nft.json +1 -1
  176. package/.next/standalone/.next/server/app/m/sessions/[id]/page_client-reference-manifest.js +1 -1
  177. package/.next/standalone/.next/server/app/m/sessions/page_client-reference-manifest.js +1 -1
  178. package/.next/standalone/.next/server/app/m/sessions.html +1 -1
  179. package/.next/standalone/.next/server/app/m/sessions.rsc +2 -2
  180. package/.next/standalone/.next/server/app/m/sessions.segments/_full.segment.rsc +2 -2
  181. package/.next/standalone/.next/server/app/m/sessions.segments/_head.segment.rsc +1 -1
  182. package/.next/standalone/.next/server/app/m/sessions.segments/_index.segment.rsc +2 -2
  183. package/.next/standalone/.next/server/app/m/sessions.segments/_tree.segment.rsc +2 -2
  184. package/.next/standalone/.next/server/app/m/sessions.segments/m/sessions/__PAGE__.segment.rsc +1 -1
  185. package/.next/standalone/.next/server/app/m/sessions.segments/m/sessions.segment.rsc +1 -1
  186. package/.next/standalone/.next/server/app/m/sessions.segments/m.segment.rsc +1 -1
  187. package/.next/standalone/.next/server/app/m/settings/page_client-reference-manifest.js +1 -1
  188. package/.next/standalone/.next/server/app/m/settings.html +1 -1
  189. package/.next/standalone/.next/server/app/m/settings.rsc +2 -2
  190. package/.next/standalone/.next/server/app/m/settings.segments/_full.segment.rsc +2 -2
  191. package/.next/standalone/.next/server/app/m/settings.segments/_head.segment.rsc +1 -1
  192. package/.next/standalone/.next/server/app/m/settings.segments/_index.segment.rsc +2 -2
  193. package/.next/standalone/.next/server/app/m/settings.segments/_tree.segment.rsc +2 -2
  194. package/.next/standalone/.next/server/app/m/settings.segments/m/settings/__PAGE__.segment.rsc +1 -1
  195. package/.next/standalone/.next/server/app/m/settings.segments/m/settings.segment.rsc +1 -1
  196. package/.next/standalone/.next/server/app/m/settings.segments/m.segment.rsc +1 -1
  197. package/.next/standalone/.next/server/app/m/terminal/page.js.nft.json +1 -1
  198. package/.next/standalone/.next/server/app/m/terminal/page_client-reference-manifest.js +1 -1
  199. package/.next/standalone/.next/server/app/m/terminal.html +1 -1
  200. package/.next/standalone/.next/server/app/m/terminal.rsc +2 -2
  201. package/.next/standalone/.next/server/app/m/terminal.segments/_full.segment.rsc +2 -2
  202. package/.next/standalone/.next/server/app/m/terminal.segments/_head.segment.rsc +1 -1
  203. package/.next/standalone/.next/server/app/m/terminal.segments/_index.segment.rsc +2 -2
  204. package/.next/standalone/.next/server/app/m/terminal.segments/_tree.segment.rsc +2 -2
  205. package/.next/standalone/.next/server/app/m/terminal.segments/m/terminal/__PAGE__.segment.rsc +1 -1
  206. package/.next/standalone/.next/server/app/m/terminal.segments/m/terminal.segment.rsc +1 -1
  207. package/.next/standalone/.next/server/app/m/terminal.segments/m.segment.rsc +1 -1
  208. package/.next/standalone/.next/server/app/m.html +1 -1
  209. package/.next/standalone/.next/server/app/m.rsc +2 -2
  210. package/.next/standalone/.next/server/app/m.segments/_full.segment.rsc +2 -2
  211. package/.next/standalone/.next/server/app/m.segments/_head.segment.rsc +1 -1
  212. package/.next/standalone/.next/server/app/m.segments/_index.segment.rsc +2 -2
  213. package/.next/standalone/.next/server/app/m.segments/_tree.segment.rsc +2 -2
  214. package/.next/standalone/.next/server/app/m.segments/m/__PAGE__.segment.rsc +1 -1
  215. package/.next/standalone/.next/server/app/m.segments/m.segment.rsc +1 -1
  216. package/.next/standalone/.next/server/app/network.html +1 -1
  217. package/.next/standalone/.next/server/app/network.rsc +5 -5
  218. package/.next/standalone/.next/server/app/network.segments/!KGRlc2t0b3Ap/network/__PAGE__.segment.rsc +2 -2
  219. package/.next/standalone/.next/server/app/network.segments/!KGRlc2t0b3Ap/network.segment.rsc +1 -1
  220. package/.next/standalone/.next/server/app/network.segments/!KGRlc2t0b3Ap.segment.rsc +3 -3
  221. package/.next/standalone/.next/server/app/network.segments/_full.segment.rsc +5 -5
  222. package/.next/standalone/.next/server/app/network.segments/_head.segment.rsc +1 -1
  223. package/.next/standalone/.next/server/app/network.segments/_index.segment.rsc +2 -2
  224. package/.next/standalone/.next/server/app/network.segments/_tree.segment.rsc +2 -2
  225. package/.next/standalone/.next/server/app/projects.html +1 -1
  226. package/.next/standalone/.next/server/app/projects.rsc +5 -5
  227. package/.next/standalone/.next/server/app/projects.segments/!KGRlc2t0b3Ap/projects/__PAGE__.segment.rsc +2 -2
  228. package/.next/standalone/.next/server/app/projects.segments/!KGRlc2t0b3Ap/projects.segment.rsc +1 -1
  229. package/.next/standalone/.next/server/app/projects.segments/!KGRlc2t0b3Ap.segment.rsc +3 -3
  230. package/.next/standalone/.next/server/app/projects.segments/_full.segment.rsc +5 -5
  231. package/.next/standalone/.next/server/app/projects.segments/_head.segment.rsc +1 -1
  232. package/.next/standalone/.next/server/app/projects.segments/_index.segment.rsc +2 -2
  233. package/.next/standalone/.next/server/app/projects.segments/_tree.segment.rsc +2 -2
  234. package/.next/standalone/.next/server/app/sessions.html +1 -1
  235. package/.next/standalone/.next/server/app/sessions.rsc +5 -5
  236. package/.next/standalone/.next/server/app/sessions.segments/!KGRlc2t0b3Ap/sessions/__PAGE__.segment.rsc +2 -2
  237. package/.next/standalone/.next/server/app/sessions.segments/!KGRlc2t0b3Ap/sessions.segment.rsc +1 -1
  238. package/.next/standalone/.next/server/app/sessions.segments/!KGRlc2t0b3Ap.segment.rsc +3 -3
  239. package/.next/standalone/.next/server/app/sessions.segments/_full.segment.rsc +5 -5
  240. package/.next/standalone/.next/server/app/sessions.segments/_head.segment.rsc +1 -1
  241. package/.next/standalone/.next/server/app/sessions.segments/_index.segment.rsc +2 -2
  242. package/.next/standalone/.next/server/app/sessions.segments/_tree.segment.rsc +2 -2
  243. package/.next/standalone/.next/server/app/settings.html +1 -1
  244. package/.next/standalone/.next/server/app/settings.rsc +5 -5
  245. package/.next/standalone/.next/server/app/settings.segments/!KGRlc2t0b3Ap/settings/__PAGE__.segment.rsc +2 -2
  246. package/.next/standalone/.next/server/app/settings.segments/!KGRlc2t0b3Ap/settings.segment.rsc +1 -1
  247. package/.next/standalone/.next/server/app/settings.segments/!KGRlc2t0b3Ap.segment.rsc +3 -3
  248. package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +5 -5
  249. package/.next/standalone/.next/server/app/settings.segments/_head.segment.rsc +1 -1
  250. package/.next/standalone/.next/server/app/settings.segments/_index.segment.rsc +2 -2
  251. package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  252. package/.next/standalone/.next/server/app/terminal.html +1 -1
  253. package/.next/standalone/.next/server/app/terminal.rsc +5 -5
  254. package/.next/standalone/.next/server/app/terminal.segments/!KGRlc2t0b3Ap/terminal/__PAGE__.segment.rsc +2 -2
  255. package/.next/standalone/.next/server/app/terminal.segments/!KGRlc2t0b3Ap/terminal.segment.rsc +1 -1
  256. package/.next/standalone/.next/server/app/terminal.segments/!KGRlc2t0b3Ap.segment.rsc +3 -3
  257. package/.next/standalone/.next/server/app/terminal.segments/_full.segment.rsc +5 -5
  258. package/.next/standalone/.next/server/app/terminal.segments/_head.segment.rsc +1 -1
  259. package/.next/standalone/.next/server/app/terminal.segments/_index.segment.rsc +2 -2
  260. package/.next/standalone/.next/server/app/terminal.segments/_tree.segment.rsc +2 -2
  261. package/.next/standalone/.next/server/app/workspaces.html +1 -1
  262. package/.next/standalone/.next/server/app/workspaces.rsc +5 -5
  263. package/.next/standalone/.next/server/app/workspaces.segments/!KGRlc2t0b3Ap/workspaces/__PAGE__.segment.rsc +2 -2
  264. package/.next/standalone/.next/server/app/workspaces.segments/!KGRlc2t0b3Ap/workspaces.segment.rsc +1 -1
  265. package/.next/standalone/.next/server/app/workspaces.segments/!KGRlc2t0b3Ap.segment.rsc +3 -3
  266. package/.next/standalone/.next/server/app/workspaces.segments/_full.segment.rsc +5 -5
  267. package/.next/standalone/.next/server/app/workspaces.segments/_head.segment.rsc +1 -1
  268. package/.next/standalone/.next/server/app/workspaces.segments/_index.segment.rsc +2 -2
  269. package/.next/standalone/.next/server/app/workspaces.segments/_tree.segment.rsc +2 -2
  270. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0041efe4._.js +2 -2
  271. package/.next/standalone/.next/server/chunks/[root-of-the-server]__00bf0ace._.js +2 -2
  272. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0e71d908._.js +2 -2
  273. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0e9142f3._.js +2 -2
  274. package/.next/standalone/.next/server/chunks/[root-of-the-server]__10e47926._.js +1 -1
  275. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1665dc78._.js +2 -2
  276. package/.next/standalone/.next/server/chunks/[root-of-the-server]__175cbabf._.js +2 -2
  277. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1adae357._.js +2 -2
  278. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1d359752._.js +1 -1
  279. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1e8fabeb._.js +3 -3
  280. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1f8deca0._.js +8 -8
  281. package/.next/standalone/.next/server/chunks/[root-of-the-server]__253fdda1._.js +2 -2
  282. package/.next/standalone/.next/server/chunks/[root-of-the-server]__28e6434f._.js +2 -2
  283. package/.next/standalone/.next/server/chunks/[root-of-the-server]__2a386564._.js +1 -1
  284. package/.next/standalone/.next/server/chunks/[root-of-the-server]__2c20fb38._.js +2 -2
  285. package/.next/standalone/.next/server/chunks/[root-of-the-server]__309132cd._.js +1 -1
  286. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__cf3c60c2._.js → [root-of-the-server]__33fec964._.js} +4 -4
  287. package/.next/standalone/.next/server/chunks/[root-of-the-server]__3786d8ae._.js +1 -1
  288. package/.next/standalone/.next/server/chunks/[root-of-the-server]__3ae92407._.js +2 -2
  289. package/.next/standalone/.next/server/chunks/[root-of-the-server]__3beda9fe._.js +2 -2
  290. package/.next/standalone/.next/server/chunks/[root-of-the-server]__4619e9bd._.js +1 -1
  291. package/.next/standalone/.next/server/chunks/[root-of-the-server]__4a051043._.js +1 -1
  292. package/.next/standalone/.next/server/chunks/[root-of-the-server]__508002e4._.js +2 -2
  293. package/.next/standalone/.next/server/chunks/[root-of-the-server]__5086c373._.js +2 -2
  294. package/.next/standalone/.next/server/chunks/[root-of-the-server]__5913e097._.js +2 -2
  295. package/.next/standalone/.next/server/chunks/[root-of-the-server]__5b5f68d2._.js +2 -2
  296. package/.next/standalone/.next/server/chunks/[root-of-the-server]__5c1f2459._.js +2 -2
  297. package/.next/standalone/.next/server/chunks/[root-of-the-server]__5ec8c977._.js +2 -2
  298. package/.next/standalone/.next/server/chunks/[root-of-the-server]__63cebc6c._.js +2 -2
  299. package/.next/standalone/.next/server/chunks/[root-of-the-server]__64d30d4d._.js +2 -2
  300. package/.next/standalone/.next/server/chunks/[root-of-the-server]__6c54fc2e._.js +2 -2
  301. package/.next/standalone/.next/server/chunks/[root-of-the-server]__6dc1fb7e._.js +1 -1
  302. package/.next/standalone/.next/server/chunks/[root-of-the-server]__6e568102._.js +1 -1
  303. package/.next/standalone/.next/server/chunks/[root-of-the-server]__6faa04c0._.js +2 -2
  304. package/.next/standalone/.next/server/chunks/[root-of-the-server]__74a34dc3._.js +2 -2
  305. package/.next/standalone/.next/server/chunks/[root-of-the-server]__7e7250a4._.js +3 -3
  306. package/.next/standalone/.next/server/chunks/[root-of-the-server]__8309e0a4._.js +2 -2
  307. package/.next/standalone/.next/server/chunks/[root-of-the-server]__86cc0e2b._.js +6 -6
  308. package/.next/standalone/.next/server/chunks/[root-of-the-server]__89c2565a._.js +2 -2
  309. package/.next/standalone/.next/server/chunks/[root-of-the-server]__8d178ad9._.js +2 -2
  310. package/.next/standalone/.next/server/chunks/[root-of-the-server]__93ee06f3._.js +3 -3
  311. package/.next/standalone/.next/server/chunks/[root-of-the-server]__9e4c154a._.js +2 -2
  312. package/.next/standalone/.next/server/chunks/[root-of-the-server]__a9d2e1d3._.js +2 -2
  313. package/.next/standalone/.next/server/chunks/[root-of-the-server]__ae53d343._.js +2 -2
  314. package/.next/standalone/.next/server/chunks/[root-of-the-server]__b3a04cef._.js +2 -2
  315. package/.next/standalone/.next/server/chunks/[root-of-the-server]__b4270b77._.js +1 -1
  316. package/.next/standalone/.next/server/chunks/[root-of-the-server]__b6b6ce60._.js +1 -1
  317. package/.next/standalone/.next/server/chunks/[root-of-the-server]__c88b63f7._.js +2 -2
  318. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__eee4c5e8._.js → [root-of-the-server]__cba5f007._.js} +2 -2
  319. package/.next/standalone/.next/server/chunks/[root-of-the-server]__cbf4ceb0._.js +2 -2
  320. package/.next/standalone/.next/server/chunks/[root-of-the-server]__cefdba2f._.js +2 -2
  321. package/.next/standalone/.next/server/chunks/[root-of-the-server]__cf9e82bb._.js +2 -2
  322. package/.next/standalone/.next/server/chunks/[root-of-the-server]__d2897392._.js +2 -2
  323. package/.next/standalone/.next/server/chunks/[root-of-the-server]__d3b2d856._.js +2 -2
  324. package/.next/standalone/.next/server/chunks/[root-of-the-server]__d73273ca._.js +1 -1
  325. package/.next/standalone/.next/server/chunks/[root-of-the-server]__d8417eb6._.js +2 -2
  326. package/.next/standalone/.next/server/chunks/[root-of-the-server]__dc2a55de._.js +2 -2
  327. package/.next/standalone/.next/server/chunks/[root-of-the-server]__e0d4690b._.js +3 -3
  328. package/.next/standalone/.next/server/chunks/[root-of-the-server]__e678dd53._.js +1 -1
  329. package/.next/standalone/.next/server/chunks/[root-of-the-server]__e9223f55._.js +2 -2
  330. package/.next/standalone/.next/server/chunks/[root-of-the-server]__ea630076._.js +3 -3
  331. package/.next/standalone/.next/server/chunks/[root-of-the-server]__eb8acb65._.js +1 -1
  332. package/.next/standalone/.next/server/chunks/[root-of-the-server]__f26ca49d._.js +1 -1
  333. package/.next/standalone/.next/server/chunks/[root-of-the-server]__f33e1101._.js +1 -1
  334. package/.next/standalone/.next/server/chunks/[root-of-the-server]__f515f865._.js +2 -2
  335. package/.next/standalone/.next/server/chunks/[root-of-the-server]__fceb5d60._.js +2 -2
  336. package/.next/standalone/.next/server/chunks/[root-of-the-server]__fed41403._.js +2 -2
  337. package/.next/standalone/.next/server/chunks/[root-of-the-server]__ff2e98c2._.js +2 -2
  338. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__19afc53d._.js +3 -0
  339. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__66aca5d4._.js +1 -1
  340. package/.next/standalone/.next/server/chunks/ssr/{_036cbaa2._.js → _078dd64d._.js} +1 -1
  341. package/.next/standalone/.next/server/chunks/ssr/{_a4eeff0d._.js → _2230ad2d._.js} +2 -2
  342. package/.next/standalone/.next/server/chunks/ssr/{_43455582._.js → _701606d5._.js} +1 -1
  343. package/.next/standalone/.next/server/chunks/ssr/_72b1de37._.js +3 -0
  344. package/.next/standalone/.next/server/chunks/ssr/{_d39bcfda._.js → _93ef0f79._.js} +2 -2
  345. package/.next/standalone/.next/server/chunks/ssr/_950142a4._.js +1 -1
  346. package/.next/standalone/.next/server/chunks/ssr/_a22b5eb0._.js +3 -0
  347. package/.next/standalone/.next/server/chunks/ssr/_aeeff784._.js +3 -0
  348. package/.next/standalone/.next/server/chunks/ssr/_c1cfdd09._.js +3 -0
  349. package/.next/standalone/.next/server/chunks/ssr/_c2d3f6de._.js +3 -0
  350. package/.next/standalone/.next/server/chunks/ssr/{_8167090e._.js → _db2fec84._.js} +2 -2
  351. package/.next/standalone/.next/server/chunks/ssr/src_components_terminal_terminal-pane_tsx_803c5e2c._.js +2 -2
  352. package/.next/standalone/.next/server/edge/chunks/_d73df637._.js +1 -1
  353. package/.next/standalone/.next/server/middleware-manifest.json +5 -5
  354. package/.next/standalone/.next/server/pages/404.html +1 -1
  355. package/.next/standalone/.next/server/pages/500.html +2 -2
  356. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  357. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  358. package/.next/standalone/.next/static/chunks/25b7a243a404a1a7.js +1 -0
  359. package/.next/standalone/.next/static/chunks/2b997e211a5d547b.js +1 -0
  360. package/.next/standalone/.next/static/chunks/5e08abb00653754a.js +1 -0
  361. package/.next/standalone/.next/static/chunks/61f2ed39b75b1efc.js +1 -0
  362. package/.next/standalone/.next/static/chunks/6c78a1dfa7ec2959.css +3 -0
  363. package/.next/standalone/.next/static/chunks/7246f1ee445f7024.js +1 -0
  364. package/.next/standalone/.next/static/chunks/7424664c6ffa94bd.js +1 -0
  365. package/.next/standalone/.next/static/chunks/7e0091ab6c5ee8bd.js +1 -0
  366. package/.next/standalone/.next/static/chunks/8b3f4572fec83caa.js +5 -0
  367. package/.next/standalone/.next/static/chunks/{224aab5107987011.js → 9899cf4c2bdbe61d.js} +2 -2
  368. package/.next/standalone/.next/static/chunks/ac339e970df82fa5.js +5 -0
  369. package/.next/standalone/.next/static/chunks/f9f2628207848ac2.js +1 -0
  370. package/.next/standalone/bin/cortex-hook.sh +62 -62
  371. package/.next/standalone/bin/cortex-mcp.js +60 -60
  372. package/.next/standalone/docs/superpowers/plans/2026-03-13-cortex-wiring.md +1387 -1387
  373. package/.next/standalone/docs/superpowers/plans/2026-03-14-cortex-v2-entity-graph.md +1923 -1923
  374. package/.next/standalone/docs/superpowers/plans/2026-03-14-cortex-v2-knowledge-evolution.md +1113 -1113
  375. package/.next/standalone/docs/superpowers/plans/2026-03-15-cortex-v2-boundary-engine.md +853 -853
  376. package/.next/standalone/docs/superpowers/plans/2026-03-15-cortex-v2-context-engine.md +1274 -1274
  377. package/.next/standalone/docs/superpowers/plans/2026-03-15-cortex-v2-signal-ingestion.md +933 -933
  378. package/.next/standalone/docs/superpowers/plans/2026-03-16-cortex-lobes.md +1080 -1080
  379. package/.next/standalone/docs/superpowers/plans/2026-03-16-cortex-v2-gravity-system.md +768 -768
  380. package/.next/standalone/docs/superpowers/plans/2026-03-16-cortex-v2-ui.md +1108 -1108
  381. package/.next/standalone/docs/superpowers/plans/2026-03-18-cortex-ui-integration.md +1846 -1846
  382. package/.next/standalone/docs/superpowers/specs/2026-03-13-cortex-wiring-design.md +268 -268
  383. package/.next/standalone/docs/superpowers/specs/2026-03-14-cortex-v2-design.md +623 -623
  384. package/.next/standalone/docs/superpowers/specs/2026-03-16-cortex-lobes-design.md +263 -263
  385. package/.next/standalone/docs/superpowers/specs/2026-03-16-cortex-v2-ui-design.md +240 -240
  386. package/.next/standalone/docs/superpowers/specs/2026-03-18-cortex-ui-integration-design.md +341 -341
  387. package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/README.md +46 -0
  388. package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/lib/glib-2.0/include/glibconfig.h +221 -0
  389. package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/lib/index.js +1 -0
  390. package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.8.17.3 +0 -0
  391. package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/package.json +42 -0
  392. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/README.md +46 -0
  393. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/lib/glib-2.0/include/glibconfig.h +221 -0
  394. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/lib/index.js +1 -0
  395. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/lib/libvips-cpp.so.8.17.3 +0 -0
  396. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/package.json +42 -0
  397. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/versions.json +30 -0
  398. package/.next/standalone/node_modules/@img/sharp-linux-x64/lib/sharp-linux-x64.node +0 -0
  399. package/.next/standalone/node_modules/@img/{sharp-win32-x64 → sharp-linux-x64}/package.json +46 -39
  400. package/.next/standalone/node_modules/@img/sharp-linuxmusl-x64/lib/sharp-linuxmusl-x64.node +0 -0
  401. package/.next/standalone/node_modules/@img/sharp-linuxmusl-x64/package.json +46 -0
  402. package/.next/standalone/package.json +102 -102
  403. package/.next/standalone/server.js +1 -1
  404. package/.next/standalone/src/app/(desktop)/cortex/page.tsx +78 -78
  405. package/.next/standalone/src/app/api/cortex/context/route.ts +78 -78
  406. package/.next/standalone/src/app/api/cortex/curation/assess/route.ts +27 -27
  407. package/.next/standalone/src/app/api/cortex/curation/publish/route.ts +23 -23
  408. package/.next/standalone/src/app/api/cortex/curation/refine/route.ts +23 -23
  409. package/.next/standalone/src/app/api/cortex/curation/review/route.ts +29 -29
  410. package/.next/standalone/src/app/api/cortex/curation/seed/route.ts +23 -23
  411. package/.next/standalone/src/app/api/cortex/export/route.ts +40 -40
  412. package/.next/standalone/src/app/api/cortex/federation/pending/route.ts +20 -20
  413. package/.next/standalone/src/app/api/cortex/federation/resolve/route.ts +43 -43
  414. package/.next/standalone/src/app/api/cortex/federation/search/route.ts +35 -35
  415. package/.next/standalone/src/app/api/cortex/federation/teach/route.ts +76 -76
  416. package/.next/standalone/src/app/api/cortex/graph/edges/route.ts +112 -112
  417. package/.next/standalone/src/app/api/cortex/graph/entities/[id]/route.ts +73 -73
  418. package/.next/standalone/src/app/api/cortex/graph/entities/route.ts +75 -75
  419. package/.next/standalone/src/app/api/cortex/graph/populate/route.ts +203 -203
  420. package/.next/standalone/src/app/api/cortex/import/route.ts +75 -75
  421. package/.next/standalone/src/app/api/cortex/import/status/route.ts +15 -15
  422. package/.next/standalone/src/app/api/cortex/ingest/bootstrap/route.ts +29 -29
  423. package/.next/standalone/src/app/api/cortex/ingest/status/route.ts +15 -15
  424. package/.next/standalone/src/app/api/cortex/knowledge/[id]/route.ts +91 -91
  425. package/.next/standalone/src/app/api/cortex/knowledge/route.ts +93 -93
  426. package/.next/standalone/src/app/api/cortex/lobes/[id]/route.ts +67 -67
  427. package/.next/standalone/src/app/api/cortex/lobes/route.ts +22 -22
  428. package/.next/standalone/src/app/api/cortex/lobes/share/route.ts +80 -80
  429. package/.next/standalone/src/app/api/cortex/marketplace/browse/route.ts +43 -43
  430. package/.next/standalone/src/app/api/cortex/marketplace/preview/route.ts +46 -46
  431. package/.next/standalone/src/app/api/cortex/mcp/call/route.ts +11 -11
  432. package/.next/standalone/src/app/api/cortex/mcp/tools/route.ts +6 -6
  433. package/.next/standalone/src/app/api/cortex/search/route.ts +43 -43
  434. package/.next/standalone/src/app/api/cortex/settings/route.ts +33 -33
  435. package/.next/standalone/src/app/api/cortex/status/route.ts +169 -169
  436. package/.next/standalone/src/app/api/cortex/timeline/route.ts +42 -42
  437. package/.next/standalone/src/app/api/cortex/usage/route.ts +31 -31
  438. package/.next/standalone/src/app/api/cortex/workspace/[id]/context/route.ts +41 -41
  439. package/.next/standalone/src/components/cortex/constants.ts +29 -29
  440. package/.next/standalone/src/components/cortex/cortex-dashboard.tsx +304 -304
  441. package/.next/standalone/src/components/cortex/cortex-indicator.tsx +44 -44
  442. package/.next/standalone/src/components/cortex/cortex-panel.tsx +140 -140
  443. package/.next/standalone/src/components/cortex/cortex-settings.tsx +221 -221
  444. package/.next/standalone/src/components/cortex/curation-tab.tsx +810 -810
  445. package/.next/standalone/src/components/cortex/entity-detail.tsx +101 -101
  446. package/.next/standalone/src/components/cortex/entity-graph.tsx +382 -382
  447. package/.next/standalone/src/components/cortex/import-dialog.tsx +212 -212
  448. package/.next/standalone/src/components/cortex/injection-badge.tsx +72 -72
  449. package/.next/standalone/src/components/cortex/knowledge-card.tsx +109 -109
  450. package/.next/standalone/src/components/cortex/knowledge-tab.tsx +158 -158
  451. package/.next/standalone/src/components/cortex/lobe-settings.tsx +215 -215
  452. package/.next/standalone/src/components/cortex/marketplace-card.tsx +126 -126
  453. package/.next/standalone/src/components/cortex/marketplace-tab.tsx +113 -113
  454. package/.next/standalone/src/lib/cortex/config.ts +40 -40
  455. package/.next/standalone/src/lib/cortex/debug.ts +10 -10
  456. package/.next/standalone/src/lib/cortex/distillation/usage-store.ts +18 -18
  457. package/.next/standalone/src/lib/cortex/graph/resolver.ts +10 -10
  458. package/.next/standalone/src/lib/cortex/graph/types.ts +22 -22
  459. package/.next/standalone/src/lib/cortex/index.ts +56 -56
  460. package/.next/standalone/src/lib/cortex/ingestion/bootstrap.ts +14 -14
  461. package/.next/standalone/src/lib/cortex/knowledge/compat.ts +14 -14
  462. package/.next/standalone/src/lib/cortex/knowledge/contradiction.ts +10 -10
  463. package/.next/standalone/src/lib/cortex/knowledge/types.ts +67 -67
  464. package/.next/standalone/src/lib/cortex/lobes/config.ts +16 -16
  465. package/.next/standalone/src/lib/cortex/lobes/resolver.ts +8 -8
  466. package/.next/standalone/src/lib/cortex/lobes/shares.ts +14 -14
  467. package/.next/standalone/src/lib/cortex/mcp/server.ts +8 -8
  468. package/.next/standalone/src/lib/cortex/portability/exporter.ts +6 -6
  469. package/.next/standalone/src/lib/cortex/portability/importer.ts +10 -10
  470. package/.next/standalone/src/lib/cortex/retrieval/context-engine.ts +10 -10
  471. package/.next/standalone/src/lib/cortex/types.ts +39 -39
  472. package/.next/standalone/tsconfig.json +34 -34
  473. package/LICENSE +661 -661
  474. package/README.md +131 -131
  475. package/bin/cortex-hook.sh +62 -62
  476. package/bin/cortex-mcp.js +60 -60
  477. package/bin/fix-standalone-externals.js +79 -79
  478. package/bin/lib/auto-setup.js +110 -110
  479. package/bin/mdns-service.js +171 -171
  480. package/bin/postinstall.js +35 -35
  481. package/bin/setup-admin.js +195 -195
  482. package/bin/spaces-dev.js +208 -208
  483. package/bin/spaces-install.js +599 -599
  484. package/bin/spaces-reset-totp.js +50 -50
  485. package/bin/spaces-service.js +1020 -1020
  486. package/bin/spaces-setup.js +253 -253
  487. package/bin/spaces.js +776 -717
  488. package/bin/ssh-auth-keys.sh +68 -68
  489. package/bin/terminal-server.js +1649 -1646
  490. package/package.json +102 -102
  491. package/.next/standalone/.claude/settings.local.json +0 -55
  492. package/.next/standalone/.claude/spaces-env.json +0 -1
  493. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__23f374a2._.js +0 -3
  494. package/.next/standalone/.next/server/chunks/ssr/_01842037._.js +0 -3
  495. package/.next/standalone/.next/server/chunks/ssr/_10c0d382._.js +0 -3
  496. package/.next/standalone/.next/server/chunks/ssr/_67887e33._.js +0 -3
  497. package/.next/standalone/.next/server/chunks/ssr/_a012c43b._.js +0 -3
  498. package/.next/standalone/.next/server/chunks/ssr/_ed6c285b._.js +0 -3
  499. package/.next/standalone/.next/static/chunks/17d164c01fa1aaa9.js +0 -5
  500. package/.next/standalone/.next/static/chunks/19da759dde107f02.js +0 -1
  501. package/.next/standalone/.next/static/chunks/415827af44b8c374.js +0 -1
  502. package/.next/standalone/.next/static/chunks/596bd56e0ad09173.js +0 -5
  503. package/.next/standalone/.next/static/chunks/79aacab676df80c4.js +0 -1
  504. package/.next/standalone/.next/static/chunks/846d6ef408f69390.js +0 -1
  505. package/.next/standalone/.next/static/chunks/8a7f58872c123a53.css +0 -3
  506. package/.next/standalone/.next/static/chunks/8de5e432a2fc563a.js +0 -1
  507. package/.next/standalone/.next/static/chunks/b0484608571d975a.js +0 -1
  508. package/.next/standalone/.next/static/chunks/b42a38df3e418ff0.js +0 -1
  509. package/.next/standalone/.next/static/chunks/d6474f65a17c483e.js +0 -1
  510. package/.next/standalone/.spaces/cortex-context.md +0 -70
  511. package/.next/standalone/node_modules/@img/sharp-win32-x64/lib/sharp-win32-x64.node +0 -0
  512. /package/.next/standalone/.next/static/{Ku7_sP1T1UuqtRhUTiJZw → 77VYbwIoyxFNr5xevTrCu}/_buildManifest.js +0 -0
  513. /package/.next/standalone/.next/static/{Ku7_sP1T1UuqtRhUTiJZw → 77VYbwIoyxFNr5xevTrCu}/_clientMiddlewareManifest.json +0 -0
  514. /package/.next/standalone/.next/static/{Ku7_sP1T1UuqtRhUTiJZw → 77VYbwIoyxFNr5xevTrCu}/_ssgManifest.js +0 -0
  515. /package/.next/standalone/node_modules/@img/{sharp-win32-x64 → sharp-libvips-linux-x64}/versions.json +0 -0
@@ -1,1646 +1,1649 @@
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) {
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
- // Register Cortex MCP server
588
- const mcpServer = path.resolve(__dirname, 'cortex-mcp.js');
589
- if (!settings.mcpServers) settings.mcpServers = {};
590
- settings.mcpServers.cortex = {
591
- command: 'node',
592
- args: [mcpServer],
593
- env: {
594
- SPACES_URL: `http://localhost:${API_PORT}`,
595
- SPACES_INTERNAL_TOKEN: (process.env.SPACES_SESSION_SECRET || '').slice(0, 16),
596
- },
597
- };
598
-
599
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
600
- console.log(`[Cortex] Wrote Claude Code hooks (RAG + Learn) + MCP server to ${settingsPath}`);
601
- } catch (err) {
602
- console.error(`[Cortex] Failed to write hook config:`, err.message);
603
- }
604
- }
605
-
606
- // ─── Cortex context injection ────────────────────────────
607
- // Fetch relevant knowledge from Cortex API and write a context file
608
- // in the workspace before the agent launches.
609
- async function injectCortexContext(cwd, workspaceId, ws) {
610
- if (!isApiReady()) return 0;
611
- if (SPACES_TIER !== 'team' && SPACES_TIER !== 'federation') return 0;
612
- // Check if Cortex is actually enabled in user config
613
- try {
614
- const configPath = path.join(os.homedir(), '.spaces', 'config.json');
615
- if (fs.existsSync(configPath)) {
616
- const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
617
- if (!cfg.cortex?.enabled) return 0;
618
- } else {
619
- return 0;
620
- }
621
- } catch { return 0; }
622
- try {
623
- const projectName = path.basename(cwd);
624
- const query = encodeURIComponent(`${projectName} workspace context`);
625
- const params = `q=${query}&limit=10${workspaceId ? `&workspace_id=${workspaceId}` : ''}`;
626
- const url = `http://localhost:${API_PORT}/api/cortex/search?${params}`;
627
-
628
- // Use internal auth bypass (x-spaces-internal header) to skip session middleware
629
- const internalToken = (process.env.SPACES_SESSION_SECRET || '').slice(0, 16);
630
- const options = {
631
- timeout: 5000,
632
- headers: {
633
- 'x-spaces-internal': internalToken,
634
- },
635
- };
636
-
637
- const body = await new Promise((resolve, reject) => {
638
- const req = http.get(url, options, (res) => {
639
- // Follow redirects (Next.js trailing-slash redirects)
640
- if (res.statusCode === 308 || res.statusCode === 307 || res.statusCode === 301 || res.statusCode === 302) {
641
- const redirectUrl = res.headers.location;
642
- if (redirectUrl) {
643
- const fullUrl = redirectUrl.startsWith('http') ? redirectUrl : `http://localhost:${API_PORT}${redirectUrl}`;
644
- const req2 = http.get(fullUrl, options, (res2) => {
645
- let data = '';
646
- res2.on('data', (chunk) => { data += chunk; });
647
- res2.on('end', () => {
648
- if (res2.statusCode !== 200) {
649
- reject(new Error(`Cortex API returned ${res2.statusCode}: ${data.slice(0, 200)}`));
650
- } else {
651
- resolve(data);
652
- }
653
- });
654
- });
655
- req2.on('error', reject);
656
- return;
657
- }
658
- }
659
- let data = '';
660
- res.on('data', (chunk) => { data += chunk; });
661
- res.on('end', () => {
662
- if (res.statusCode !== 200) {
663
- reject(new Error(`Cortex API returned ${res.statusCode}: ${data.slice(0, 200)}`));
664
- } else {
665
- resolve(data);
666
- }
667
- });
668
- });
669
- req.on('error', reject);
670
- req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
671
- });
672
-
673
- const parsed = JSON.parse(body);
674
- const results = parsed.results;
675
- if (!results || results.length === 0) {
676
- console.log(`[Cortex] No knowledge found for "${projectName}"`);
677
- return 0;
678
- }
679
-
680
- // Format context (mirrors src/lib/cortex/retrieval/injection.ts)
681
- const TYPE_LABELS = {
682
- decision: 'Decision', pattern: 'Pattern', preference: 'Preference',
683
- error_fix: 'Error Fix', context: 'Context', code_pattern: 'Code',
684
- command: 'Command', conversation: 'Conversation', summary: 'Summary',
685
- };
686
- const lines = ['<cortex-context>', 'Relevant context from your workspace history:', ''];
687
- let tokens = 20;
688
- const included = [];
689
- for (const unit of results) {
690
- const label = TYPE_LABELS[unit.type] || unit.type;
691
- const date = (unit.source_timestamp || '').slice(0, 10);
692
- const confidence = (unit.confidence * 100).toFixed(0);
693
- let entry = `[${label}]`;
694
- if (date) entry += ` ${date}:`;
695
- entry += ` ${unit.text}`;
696
- if (unit.session_id) entry += `\nSource: session ${unit.session_id}, confidence: ${confidence}%`;
697
- const entryTokens = Math.ceil(entry.length / 4);
698
- if (tokens + entryTokens > 2000) break;
699
- lines.push(entry, '');
700
- tokens += entryTokens;
701
- included.push({ type: unit.type, text: unit.text.slice(0, 80) });
702
- }
703
- lines.push('</cortex-context>');
704
-
705
- // Write context file (readable artifact for any agent)
706
- const spacesDir = path.join(cwd, '.spaces');
707
- if (!fs.existsSync(spacesDir)) fs.mkdirSync(spacesDir, { recursive: true });
708
- fs.writeFileSync(path.join(spacesDir, 'cortex-context.md'), lines.join('\n'), 'utf-8');
709
- console.log(`[Cortex] Injected ${included.length} knowledge units for ${path.basename(cwd)}`);
710
-
711
- // Notify client for injection badge
712
- if (ws && ws.readyState === 1) {
713
- ws.send(JSON.stringify({ type: 'cortex-injection', count: included.length, items: included }));
714
- }
715
-
716
- return included.length;
717
- } catch (err) {
718
- console.error(`[Cortex] Injection failed:`, err.message);
719
- return 0;
720
- }
721
- }
722
-
723
- // ─── Git Bash detection (Windows) ────────────────────────
724
- function findGitBash() {
725
- const custom = process.env.CLAUDE_CODE_GIT_BASH_PATH;
726
- if (custom && fs.existsSync(custom)) return custom;
727
- const localAppData = process.env.LOCALAPPDATA || '';
728
- const candidates = [
729
- 'C:\\Program Files\\Git\\bin\\bash.exe',
730
- 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
731
- path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
732
- ];
733
- for (const p of candidates) {
734
- if (p && fs.existsSync(p)) return p;
735
- }
736
- // Last resort: check if bash is on PATH via where command
737
- try {
738
- const result = require('child_process').execSync('where bash.exe 2>nul', { encoding: 'utf-8', timeout: 3000 });
739
- const first = result.trim().split('\n')[0].trim();
740
- if (first && first.toLowerCase().includes('git') && fs.existsSync(first)) return first;
741
- } catch { /* not found */ }
742
- return null;
743
- }
744
-
745
- // ─── SSH binary detection (Windows) ──────────────────────
746
- function findSshBinary() {
747
- if (process.platform !== 'win32') return '/usr/bin/ssh';
748
- // Windows OpenSSH ships in System32
749
- const sysSSH = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe');
750
- if (fs.existsSync(sysSSH)) return sysSSH;
751
- // Git for Windows also bundles ssh
752
- const gitSSH = 'C:\\Program Files\\Git\\usr\\bin\\ssh.exe';
753
- if (fs.existsSync(gitSSH)) return gitSSH;
754
- try {
755
- const result = require('child_process').execSync('where ssh.exe 2>nul', { encoding: 'utf-8', timeout: 3000 });
756
- const first = result.trim().split(String.fromCharCode(10))[0].trim();
757
- if (first && fs.existsSync(first)) return first;
758
- } catch {}
759
- return null;
760
- }
761
-
762
- // ─── Origin validation ───────────────────────────────────
763
- function isAllowedOrigin(origin, req) {
764
- if (!origin) return false;
765
- try {
766
- const url = new URL(origin);
767
- // Allow localhost/127.0.0.1 (any port)
768
- if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') return true;
769
- // Allow if origin matches the server's own Host header (same-origin requests)
770
- const host = req && req.headers && req.headers.host;
771
- if (host && url.host === host) return true;
772
- // Allow configured hostname from env (e.g., spaces.example.com)
773
- const allowed = process.env.SPACES_ALLOWED_ORIGINS;
774
- if (allowed) {
775
- return allowed.split(',').some(h => url.hostname === h.trim());
776
- }
777
- // In non-community modes, require explicit allowed origins
778
- if (SPACES_TIER !== 'community') return false;
779
- // Desktop/community: allow any origin
780
- return true;
781
- } catch {
782
- return false;
783
- }
784
- }
785
-
786
- // ─── Live collab toggle handler ─────────────────────────
787
- function handleCollabToggle(paneId, session) {
788
- try {
789
- const teams = require('@spaces/teams');
790
- const config = teams.terminal.getCollabConfig(paneId, session.username);
791
-
792
- if (config) {
793
- // Enabling collaboration
794
- session.isCollaborating = true;
795
- session.workspaceId = config.workspaceId;
796
- session.paneName = config.paneName;
797
-
798
- const env = {
799
- SPACES_PANE_ID: paneId,
800
- SPACES_WORKSPACE_ID: config.workspaceId,
801
- SPACES_PANE_NAME: config.paneName,
802
- SPACES_USERNAME: session.username,
803
- SPACES_API_URL: `http://localhost:${API_PORT}`,
804
- SPACES_COLLABORATING: '1',
805
- };
806
- teams.terminal.writeAgentConfig(session.agentType, session.cwd, env);
807
-
808
- // Nudge the agent so it knows collaboration is available
809
- if (session.pty && !session.exited) {
810
- 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).';
811
- session.pty.write(nudge);
812
- setTimeout(() => { if (!session.exited) session.pty.write('\r'); }, 100);
813
- }
814
-
815
- console.log(`[CollabToggle] Enabled for pane ${paneId.slice(0, 8)} (workspace ${config.workspaceId.slice(0, 8)})`);
816
- } else {
817
- // Disabling collaboration
818
- teams.terminal.removeAgentConfig(session.agentType, session.cwd);
819
- session.isCollaborating = false;
820
- session.workspaceId = null;
821
-
822
- console.log(`[CollabToggle] Disabled for pane ${paneId.slice(0, 8)}`);
823
- }
824
-
825
- // Confirm to browser
826
- if (session.ws && session.ws.readyState === 1) {
827
- session.ws.send(JSON.stringify({ type: 'collab-updated', isCollaborating: !!config }));
828
- }
829
- } catch (e) {
830
- console.error(`[CollabToggle] Error for pane ${paneId.slice(0, 8)}:`, e.message);
831
- }
832
- }
833
-
834
- // ─── Shared connection handler ──────────────────────────
835
- function handleConnection(wss, ws, req) {
836
- ws.isAlive = true;
837
- ws.on('pong', () => { ws.isAlive = true; });
838
-
839
- const url = new URL(req.url || '/', 'http://localhost');
840
- const paneId = url.searchParams.get('paneId') || require('crypto').randomUUID();
841
- const cwd = url.searchParams.get('cwd') || process.env.HOME || process.env.USERPROFILE || 'C:\\';
842
- const agentType = url.searchParams.get('agentType') || 'shell';
843
- const rawAgentSession = url.searchParams.get('agentSession') || '';
844
- const agentSession = (rawAgentSession === 'new' || UUID_RE.test(rawAgentSession)) ? rawAgentSession : '';
845
- const rawCustomCommand = url.searchParams.get('customCommand') || '';
846
- // Sanitize: reject shell metacharacters that enable injection (;, |, &, $, `, etc.)
847
- const customCommand = /[;&|`$(){}]/.test(rawCustomCommand) ? '' : rawCustomCommand;
848
- const cols = parseInt(url.searchParams.get('cols') || '120', 10);
849
- const rows = parseInt(url.searchParams.get('rows') || '30', 10);
850
-
851
- // Authenticate: try session cookie first (self-contained auth), then terminal token + SSO header
852
- let username = null;
853
- const cookies = parseCookies(req.headers.cookie);
854
- const sessionToken = cookies['spaces-session'];
855
- const sessionPayload = sessionToken ? verifySessionToken(sessionToken) : null;
856
-
857
- 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'}`);
858
-
859
- if (sessionPayload) {
860
- // Self-contained auth: session cookie is valid
861
- username = sessionPayload.sub;
862
- console.log(`[Auth] Authenticated via session cookie: ${username}`);
863
- } else {
864
- const terminalToken = url.searchParams.get('terminalToken') || '';
865
-
866
- // Accept magic tokens from desktop/community tier, or from trusted local proxies (Docker/localhost)
867
- const remoteIp = req.socket.remoteAddress || '';
868
- const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1' || remoteIp.startsWith('172.') || remoteIp.startsWith('::ffff:172.');
869
- if ((terminalToken === 'desktop-local' || terminalToken === 'session-auth') && (SPACES_TIER === 'desktop' || SPACES_TIER === 'community' || isLocal)) {
870
- username = os.userInfo().username;
871
- // When running as SYSTEM, resolve to the first real user from admin DB
872
- if (process.platform === "win32" && username.toUpperCase() === "SYSTEM") {
873
- const db = getAdminDb();
874
- if (db) {
875
- try {
876
- const row = db.prepare("SELECT username FROM users LIMIT 1").get();
877
- if (row) username = row.username;
878
- } catch {}
879
- }
880
- }
881
- console.log(`[Auth] Authenticated via desktop token: ${username}`);
882
- } else {
883
- // Verify terminal token — if signed by this server's secret, trust it
884
- const tokenUser = verifyTerminalToken(terminalToken);
885
- if (tokenUser) {
886
- // Use the user from the signed token — do NOT trust x-auth-user header
887
- // as it can be spoofed by clients
888
- username = tokenUser;
889
- console.log(`[Auth] Authenticated via terminal token: ${username}`);
890
- } else if (terminalToken) {
891
- console.log(`[Auth] Terminal token FAILED: invalid or expired`);
892
- }
893
- }
894
- }
895
-
896
- // Network API key auth (for proxied connections from remote nodes)
897
- if (!username) {
898
- const apiKey = url.searchParams.get('apiKey');
899
- if (apiKey) {
900
- console.log(`[Auth] API key provided: ${apiKey.slice(0, 4)}*** (length=${apiKey.length})`);
901
- const keyRecord = validateNetworkApiKey(apiKey);
902
- if (keyRecord) {
903
- console.log(`[Auth] API key validated: permissions=${keyRecord.permissions}, username=${keyRecord.username}`);
904
- if (keyRecord.permissions === 'terminal' || keyRecord.permissions === 'admin') {
905
- username = keyRecord.username || os.userInfo().username;
906
- } else {
907
- console.log(`[Auth] API key rejected: permissions="${keyRecord.permissions}" not terminal/admin`);
908
- }
909
- } else {
910
- console.log(`[Auth] API key validation FAILED (no matching key in DB)`);
911
- }
912
- } else {
913
- console.log(`[Auth] No apiKey param in WebSocket URL`);
914
- }
915
- }
916
-
917
- if (!username) {
918
- console.log(`[Auth] REJECTED connection for pane ${paneId} — no auth method succeeded`);
919
- ws.send(JSON.stringify({ type: 'error', data: 'Authentication required' }));
920
- ws.close();
921
- return;
922
- }
923
-
924
- // Proxy to remote node (federation tier only)
925
- const nodeId = url.searchParams.get('nodeId');
926
- if (nodeId) {
927
- if (SPACES_TIER !== 'federation') {
928
- ws.send(JSON.stringify({ type: 'error', data: 'Remote workspaces require the Federation tier' }));
929
- ws.close();
930
- return;
931
- }
932
- handleProxyConnection(ws, nodeId, { paneId, cwd, agentType, agentSession, customCommand, cols, rows });
933
- return;
934
- }
935
-
936
- // Check for existing session to reattach
937
- const existing = sessions.get(paneId);
938
- if (existing && existing.pty && !existing.exited) {
939
- console.log(`[WS] Reattach pane=${paneId.slice(0,8)} buffer=${existing.buffer.length} chunks`);
940
- existing.ws = ws;
941
-
942
- // Replay buffered output so user sees context
943
- for (const chunk of existing.buffer) {
944
- ws.send(JSON.stringify({ type: 'data', data: chunk }));
945
- }
946
-
947
- try { existing.pty.resize(cols, rows); } catch { /* ignore */ }
948
-
949
- ws.send(JSON.stringify({ type: 'ready', paneId, reattached: true }));
950
-
951
- // Skip Cortex injection on reattach — context was already injected at spawn.
952
- // The badge polls /api/cortex/status independently.
953
-
954
- ws.on('message', (raw) => {
955
- try {
956
- const msg = JSON.parse(raw.toString());
957
- if (msg.type === 'data') {
958
- existing.pty.write(msg.data);
959
- } else if (msg.type === 'resize') {
960
- try { existing.pty.resize(msg.cols, msg.rows); } catch { /* ignore */ }
961
- } else if (msg.type === 'collab-toggle') {
962
- handleCollabToggle(paneId, existing);
963
- }
964
- } catch {
965
- existing.pty.write(raw.toString());
966
- }
967
- });
968
-
969
- ws.on('close', (code, reason) => {
970
- console.log(`[WS] Close pane=${paneId.slice(0,8)} code=${code} reason=${reason || 'none'}`);
971
- if (existing.ws === ws) existing.ws = null;
972
- });
973
-
974
- ws.on('error', (err) => {
975
- console.log(`[WS] Error pane=${paneId.slice(0,8)} err=${err.message}`);
976
- });
977
-
978
- return;
979
- }
980
-
981
- // Create new pty session
982
- const isWindows = process.platform === 'win32';
983
-
984
- // Resolve the OS shell user for this app user
985
- const shellUser = lookupShellUser(username);
986
- const processUser = os.userInfo().username;
987
- let shell, args;
988
- const isSSH = shellUser !== processUser;
989
- if (isSSH) {
990
- // SSH to localhost as the mapped shell user using the service key
991
- const sshBin = findSshBinary();
992
- if (!sshBin) {
993
- console.error(`[Spawn] SSH binary not found — cannot spawn as ${shellUser}`);
994
- ws.send(JSON.stringify({ type: 'error', data: 'SSH not available. Install OpenSSH to enable multi-user terminals.' }));
995
- ws.close();
996
- return;
997
- }
998
- shell = sshBin;
999
- args = [
1000
- '-o', 'StrictHostKeyChecking=accept-new',
1001
- '-o', `UserKnownHostsFile=${path.join(os.homedir(), '.spaces', 'known_hosts')}`,
1002
- '-i', SERVICE_KEY,
1003
- '-t',
1004
- `${shellUser}@localhost`,
1005
- ];
1006
- // Force IPv4 — localhost may resolve to ::1 (IPv6) which sshd can reject
1007
- args.unshift('-4');
1008
-
1009
- // On-demand SSH provisioning: ensure the shell user's authorized_keys is set up
1010
- // before attempting the connection. This handles users added after service install.
1011
- if (process.platform !== 'win32' && fs.existsSync(SERVICE_KEY + '.pub')) {
1012
- try {
1013
- const pubKey = fs.readFileSync(SERVICE_KEY + '.pub', 'utf-8').trim();
1014
- authorizeShellUser(shellUser, pubKey);
1015
- } catch (e) {
1016
- console.error(`[SSH] On-demand provisioning for ${shellUser} failed (non-fatal):`, e.message);
1017
- }
1018
- }
1019
- } else if (isWindows && agentType !== 'shell') {
1020
- // Agents like Claude Code require bash on Windows — find git-bash
1021
- shell = findGitBash();
1022
- args = [];
1023
- if (!shell) {
1024
- shell = 'cmd.exe';
1025
- }
1026
- } else {
1027
- shell = isWindows ? 'cmd.exe' : (process.env.SHELL || '/bin/bash');
1028
- args = [];
1029
- }
1030
-
1031
- const env = { ...process.env };
1032
- delete env.CLAUDECODE;
1033
- // Enable prompt suggestions in spawned Claude Code sessions
1034
- env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION = 'true';
1035
- // Tell Claude Code where git-bash is so it doesn't fail the bash detection
1036
- if (isWindows && shell && shell.endsWith('bash.exe') && !env.CLAUDE_CODE_GIT_BASH_PATH) {
1037
- env.CLAUDE_CODE_GIT_BASH_PATH = shell;
1038
- }
1039
-
1040
- // Fall back to HOME if cwd doesn't exist (e.g. remote path from another node)
1041
- let safeCwd = cwd;
1042
- if (!fs.existsSync(safeCwd)) {
1043
- safeCwd = os.homedir();
1044
- console.log(`[Spawn] cwd "${cwd}" does not exist, falling back to "${safeCwd}"`);
1045
- }
1046
-
1047
- // Inject Spaces bus environment for agent communication
1048
- env.SPACES_PANE_ID = paneId;
1049
- env.SPACES_API_URL = `http://localhost:${API_PORT}`;
1050
-
1051
- // Look up workspace collaboration config from @spaces/teams
1052
- let isCollaborating = false;
1053
- try {
1054
- const teams = require('@spaces/teams');
1055
- const config = teams.terminal.getCollabConfig(paneId, username);
1056
- if (config) {
1057
- env.SPACES_WORKSPACE_ID = config.workspaceId;
1058
- env.SPACES_PANE_NAME = config.paneName;
1059
- env.SPACES_USERNAME = username;
1060
- isCollaborating = true;
1061
- env.SPACES_COLLABORATING = '1';
1062
- console.log(`[Collab] Enabled for pane ${paneId.slice(0, 8)} — workspace ${config.workspaceId}, name "${config.paneName}"`);
1063
- }
1064
- } catch (e) {
1065
- console.error(`[Collab] Failed to check collaboration config for pane ${paneId.slice(0, 8)}:`, e.message);
1066
- }
1067
-
1068
- console.log(`[Spawn] user=${username} shell=${shell} args=${JSON.stringify(args)} cwd=${safeCwd} agentType=${agentType}`);
1069
-
1070
- // Write Cortex RAG hook for Claude Code before spawning (only if Cortex is enabled)
1071
- if (agentType === 'claude' && (SPACES_TIER === 'team' || SPACES_TIER === 'federation')) {
1072
- try {
1073
- const userHome = getUserHome(username);
1074
- const configPath = path.join(userHome, '.spaces', 'config.json');
1075
- let cortexEnabled = false;
1076
- if (fs.existsSync(configPath)) {
1077
- const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1078
- cortexEnabled = cfg.cortex?.enabled === true;
1079
- }
1080
- if (!cortexEnabled) {
1081
- removeCortexHookConfig(safeCwd);
1082
- } else {
1083
- writeCortexHookConfig(safeCwd);
1084
- // Resolve workspace ID: from collab config, or look up from pane DB
1085
- let wsId = env.SPACES_WORKSPACE_ID || null;
1086
- if (!wsId) {
1087
- try {
1088
- const Database = require('better-sqlite3');
1089
- const spacesDb = new Database(path.join(getUserHome(username), '.spaces', 'spaces.db'), { readonly: true });
1090
- const row = spacesDb.prepare('SELECT workspace_id FROM panes WHERE id = ?').get(paneId);
1091
- if (row && row.workspace_id) wsId = String(row.workspace_id);
1092
- spacesDb.close();
1093
- } catch { /* non-fatal */ }
1094
- }
1095
- if (wsId) env.SPACES_WORKSPACE_ID = wsId;
1096
- // Write workspace ID for hooks to read (they can't inherit PTY env)
1097
- try {
1098
- const envFile = path.join(safeCwd, '.claude', 'spaces-env.json');
1099
- fs.writeFileSync(envFile, JSON.stringify({
1100
- workspaceId: wsId,
1101
- port: API_PORT,
1102
- }), 'utf-8');
1103
- } catch { /* non-fatal */ }
1104
- }
1105
- } catch (e) {
1106
- console.error('[Cortex] Config check failed (non-fatal):', e.message);
1107
- }
1108
- }
1109
-
1110
- let term;
1111
- try {
1112
- term = pty.spawn(shell, args, {
1113
- name: 'xterm-256color',
1114
- cols,
1115
- rows,
1116
- cwd: safeCwd,
1117
- env,
1118
- });
1119
- } catch (err) {
1120
- console.error(`[Spawn Error] ${err.message} (cwd=${cwd}, shell=${shell})`);
1121
- ws.send(JSON.stringify({ type: 'error', data: 'Failed to spawn terminal session' }));
1122
- ws.close();
1123
- return;
1124
- }
1125
-
1126
- const session = {
1127
- pty: term, ws, buffer: [], exited: false, username,
1128
- agentType,
1129
- cwd: safeCwd,
1130
- paneName: env.SPACES_PANE_NAME || paneId,
1131
- lastOutputTime: Date.now(),
1132
- lastNudgeTime: 0,
1133
- startedAt: Date.now(),
1134
- workspaceId: env.SPACES_WORKSPACE_ID || null,
1135
- isCollaborating,
1136
- };
1137
- sessions.set(paneId, session);
1138
- analyticsRecordSessionStart(paneId, username, agentType);
1139
-
1140
- // ─── Cortex context injection (async, non-blocking) ─────
1141
- if (agentType !== 'shell') {
1142
- injectCortexContext(safeCwd, env.SPACES_WORKSPACE_ID || null, ws).catch(() => {});
1143
- }
1144
-
1145
- // ─── Inject cd for SSH sessions, then agent command ─────
1146
- const agent = AGENTS[agentType] || AGENTS.shell;
1147
-
1148
- // SSH sessions start in the remote user's home dir — cd to target cwd first
1149
- if (isSSH) {
1150
- setTimeout(() => {
1151
- if (!session.exited) {
1152
- if (isWindows) {
1153
- // Windows cmd.exe uses double quotes
1154
- const escapedCwd = safeCwd.replace(/"/g, '""');
1155
- term.write(`cd /d "${escapedCwd}"\r`);
1156
- } else {
1157
- // Unix shells use single quotes
1158
- const escapedCwd = safeCwd.replace(/'/g, "'\\''");
1159
- term.write(`cd '${escapedCwd}'\r`);
1160
- }
1161
- }
1162
- }, 300);
1163
- }
1164
-
1165
- // Write collaboration config for agent panes via @spaces/teams
1166
- if (isCollaborating && agentType !== 'shell') {
1167
- try {
1168
- const teams = require('@spaces/teams');
1169
- teams.terminal.writeAgentConfig(agentType, safeCwd, env);
1170
- console.log(`[Collab] Wrote agent config for pane ${paneId.slice(0, 8)} (${agentType}) in ${safeCwd}`);
1171
- } catch (e) {
1172
- console.error(`[Collab] Failed to write agent config for pane ${paneId.slice(0, 8)}:`, e.message);
1173
- }
1174
- }
1175
-
1176
- if (agentType !== 'shell') {
1177
- const command = agentType === 'custom' ? customCommand : agent.command;
1178
-
1179
- if (command) {
1180
- const delay = isSSH ? 800 : 300;
1181
-
1182
- if (agentSession && agentSession !== 'new' && agent.resumeFlag) {
1183
- // Resume an existing session
1184
- if (agentType === 'claude') {
1185
- // Claude needs to be run from the correct project CWD
1186
- const sessionCwd = findSessionCwd(agentSession, username);
1187
- setTimeout(() => {
1188
- if (session.exited) return;
1189
- if (sessionCwd && sessionCwd !== safeCwd) {
1190
- const cdCmd = isWindows ? `cd /d "${sessionCwd}"` : `cd "${sessionCwd}"`;
1191
- term.write(cdCmd + '\r');
1192
- setTimeout(() => {
1193
- if (!session.exited) {
1194
- term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
1195
- }
1196
- }, 300);
1197
- } else {
1198
- term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
1199
- }
1200
- }, delay);
1201
- } else {
1202
- // Generic resume: works for both subcommand (codex resume <id>) and flag (gemini --resume <id>)
1203
- setTimeout(() => {
1204
- if (!session.exited) {
1205
- term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
1206
- }
1207
- }, delay);
1208
- }
1209
- } else {
1210
- // Start new session
1211
- setTimeout(() => {
1212
- if (!session.exited) {
1213
- term.write(`${command}\r`);
1214
- }
1215
- }, delay);
1216
- }
1217
- }
1218
- }
1219
-
1220
- // pty -> ws (and buffer)
1221
- term.onData((data) => {
1222
- session.lastOutputTime = Date.now();
1223
- session.buffer.push(data);
1224
- if (session.buffer.length > MAX_BUFFER_LINES) {
1225
- session.buffer.shift();
1226
- }
1227
-
1228
- if (session.ws && session.ws.readyState === 1) {
1229
- session.ws.send(JSON.stringify({ type: 'data', data }));
1230
- }
1231
- });
1232
-
1233
- term.onExit(({ exitCode }) => {
1234
- session.exited = true;
1235
- analyticsRecordSessionEnd(paneId);
1236
- // Clean up hook state file
1237
- try {
1238
- const hookStateFile = path.join(os.homedir(), '.spaces', 'hook-state', `${paneId}.json`);
1239
- if (fs.existsSync(hookStateFile)) fs.unlinkSync(hookStateFile);
1240
- } catch { /* ignore */ }
1241
- if (session.ws && session.ws.readyState === 1) {
1242
- session.ws.send(JSON.stringify({ type: 'exit', exitCode }));
1243
- }
1244
- setTimeout(() => {
1245
- if (sessions.get(paneId) === session) {
1246
- sessions.delete(paneId);
1247
- }
1248
- }, 120000);
1249
- });
1250
-
1251
- // ws -> pty
1252
- ws.on('message', (raw) => {
1253
- try {
1254
- const msg = JSON.parse(raw.toString());
1255
- if (msg.type === 'data') {
1256
- term.write(msg.data);
1257
- } else if (msg.type === 'resize') {
1258
- try { term.resize(msg.cols, msg.rows); } catch { /* ignore */ }
1259
- } else if (msg.type === 'collab-toggle') {
1260
- handleCollabToggle(paneId, session);
1261
- }
1262
- } catch {
1263
- term.write(raw.toString());
1264
- }
1265
- });
1266
-
1267
- ws.on('close', (code, reason) => {
1268
- console.log(`[WS] Close pane=${paneId.slice(0,8)} code=${code} reason=${reason || 'none'}`);
1269
- if (session.ws === ws) session.ws = null;
1270
- });
1271
-
1272
- ws.on('error', (err) => {
1273
- console.log(`[WS] Error pane=${paneId.slice(0,8)} err=${err.message}`);
1274
- });
1275
-
1276
- ws.send(JSON.stringify({ type: 'ready', paneId }));
1277
-
1278
- // Confirm actual collaboration state so browser syncs with backend
1279
- ws.send(JSON.stringify({ type: 'collab-updated', isCollaborating }));
1280
-
1281
- // ─── Session ID detection for new Claude sessions ────────
1282
- if (agentType === 'claude' && (!agentSession || agentSession === 'new')) {
1283
- detectNewClaudeSession(paneId, cwd, ws, session, username);
1284
- }
1285
- }
1286
-
1287
- // ─── Claude-specific helpers ──────────────────────────────
1288
-
1289
- function getUserHome(username) {
1290
- const shellUser = lookupShellUser(username);
1291
- if (shellUser === os.userInfo().username) return os.homedir();
1292
- if (process.platform === 'win32') {
1293
- // On Windows, user profiles live under the Users directory
1294
- const usersDir = path.dirname(os.homedir());
1295
- const userHome = path.join(usersDir, shellUser);
1296
- if (fs.existsSync(userHome)) return userHome;
1297
- return os.homedir();
1298
- }
1299
- return `/home/${shellUser}`;
1300
- }
1301
-
1302
- 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$/;
1303
-
1304
- function findSessionCwd(sessionId, username) {
1305
- const claudeProjectsDir = path.join(getUserHome(username), '.claude', 'projects');
1306
- try {
1307
- if (!fs.existsSync(claudeProjectsDir)) return null;
1308
- const fileName = `${sessionId}.jsonl`;
1309
-
1310
- for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
1311
- if (!projDir.isDirectory()) continue;
1312
- const filePath = path.join(claudeProjectsDir, projDir.name, fileName);
1313
- if (fs.existsSync(filePath)) {
1314
- // Try to find cwd in the jsonl first few lines
1315
- const fd = fs.openSync(filePath, 'r');
1316
- const buf = Buffer.alloc(4096);
1317
- const bytesRead = fs.readSync(fd, buf, 0, 4096, 0);
1318
- fs.closeSync(fd);
1319
-
1320
- const chunk = buf.toString('utf-8', 0, bytesRead);
1321
- const lines = chunk.split('\n');
1322
- for (const line of lines) {
1323
- if (!line.trim()) continue;
1324
- try {
1325
- const entry = JSON.parse(line);
1326
- if (entry.cwd) {
1327
- console.log(`[Session CWD] ${sessionId.slice(0, 8)}: ${entry.cwd}`);
1328
- return entry.cwd;
1329
- }
1330
- } catch { /* skip */ }
1331
- }
1332
-
1333
- // Fallback: derive CWD from the project directory name
1334
- // Claude encodes paths as e.g. "-home-user-projects-myapp"
1335
- const derivedPath = '/' + projDir.name.replace(/^-/, '').replace(/-/g, '/');
1336
- if (fs.existsSync(derivedPath)) {
1337
- console.log(`[Session CWD] ${sessionId.slice(0, 8)}: ${derivedPath} (derived from dir name)`);
1338
- return derivedPath;
1339
- }
1340
- }
1341
- }
1342
- } catch (err) {
1343
- console.error(`[Session CWD] Error looking up ${sessionId}:`, err.message);
1344
- }
1345
- return null;
1346
- }
1347
-
1348
- function detectNewClaudeSession(paneId, cwd, ws, session, username) {
1349
- const claudeProjectsDir = path.join(getUserHome(username), '.claude', 'projects');
1350
-
1351
- const knownSessionIds = new Set();
1352
- try {
1353
- if (!fs.existsSync(claudeProjectsDir)) { /* will be created */ }
1354
- else {
1355
- for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
1356
- if (!projDir.isDirectory()) continue;
1357
- const projPath = path.join(claudeProjectsDir, projDir.name);
1358
- try {
1359
- for (const item of fs.readdirSync(projPath)) {
1360
- const m = item.match(UUID_JSONL_RE);
1361
- if (m) knownSessionIds.add(m[1]);
1362
- }
1363
- const indexPath = path.join(projPath, 'sessions-index.json');
1364
- if (fs.existsSync(indexPath)) {
1365
- try {
1366
- const data = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
1367
- if (data.entries) {
1368
- for (const entry of data.entries) knownSessionIds.add(entry.sessionId);
1369
- }
1370
- } catch { /* ignore */ }
1371
- }
1372
- } catch { /* ignore */ }
1373
- }
1374
- }
1375
- } catch { /* ignore */ }
1376
-
1377
- console.log(`[Session Detect] Pane ${paneId.slice(0, 8)} (${username}): snapshot ${knownSessionIds.size} existing sessions`);
1378
-
1379
- let attempts = 0;
1380
- const maxAttempts = 45;
1381
- const interval = setInterval(() => {
1382
- attempts++;
1383
- if (attempts > maxAttempts || session.exited) {
1384
- clearInterval(interval);
1385
- return;
1386
- }
1387
-
1388
- try {
1389
- if (!fs.existsSync(claudeProjectsDir)) return;
1390
-
1391
- for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
1392
- if (!projDir.isDirectory()) continue;
1393
- const projPath = path.join(claudeProjectsDir, projDir.name);
1394
- try {
1395
- for (const item of fs.readdirSync(projPath)) {
1396
- const m = item.match(UUID_JSONL_RE);
1397
- if (m && !knownSessionIds.has(m[1])) {
1398
- const newSessionId = m[1];
1399
- clearInterval(interval);
1400
- console.log(`[Session Detect] Pane ${paneId.slice(0, 8)} (${username}): detected session ${newSessionId}`);
1401
- if (session.ws && session.ws.readyState === 1) {
1402
- session.ws.send(JSON.stringify({
1403
- type: 'session-detected',
1404
- sessionId: newSessionId,
1405
- paneId,
1406
- }));
1407
- }
1408
- return;
1409
- }
1410
- }
1411
- } catch { /* ignore */ }
1412
- }
1413
- } catch { /* ignore */ }
1414
- }, 2000);
1415
- }
1416
-
1417
- // ─── Proxy: forward connection to remote node ──────────
1418
-
1419
- async function handleProxyConnection(clientWs, nodeId, opts) {
1420
- const { paneId, cwd, agentType, agentSession, customCommand, cols, rows } = opts;
1421
-
1422
- const node = getNodeInfo(nodeId);
1423
- if (!node) {
1424
- clientWs.send(JSON.stringify({ type: 'error', data: `Node ${nodeId} not found` }));
1425
- clientWs.close();
1426
- return;
1427
- }
1428
-
1429
- const apiKey = decryptNodeApiKey(node.api_key_encrypted);
1430
- if (!apiKey) {
1431
- clientWs.send(JSON.stringify({ type: 'error', data: 'Cannot decrypt API key for remote node' }));
1432
- clientWs.close();
1433
- return;
1434
- }
1435
-
1436
- // Get the remote WebSocket URL via the terminal token endpoint
1437
- // Only skip TLS verification if explicitly opted in (e.g., self-signed certs)
1438
- const skipTls = process.env.SPACES_SKIP_TLS_VERIFY === '1';
1439
- const prevTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
1440
- if (skipTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
1441
- let remoteWsUrl;
1442
- try {
1443
- const tokenUrl = `${node.url}/api/network/terminal/token/`;
1444
- const res = await fetch(tokenUrl, {
1445
- method: 'POST',
1446
- headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
1447
- signal: AbortSignal.timeout(10000),
1448
- });
1449
-
1450
- if (!res.ok) {
1451
- let detail = '';
1452
- try { const body = await res.json(); detail = body.error || JSON.stringify(body); } catch { detail = await res.text().catch(() => `HTTP ${res.status}`); }
1453
- clientWs.send(JSON.stringify({ type: 'error', data: `Remote terminal auth failed: ${detail}` }));
1454
- clientWs.close();
1455
- return;
1456
- }
1457
-
1458
- const data = await res.json();
1459
- remoteWsUrl = data.wsUrl;
1460
- } catch (err) {
1461
- clientWs.send(JSON.stringify({ type: 'error', data: `Cannot reach remote node: ${err.message}` }));
1462
- clientWs.close();
1463
- return;
1464
- } finally {
1465
- if (prevTls === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
1466
- else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTls;
1467
- }
1468
-
1469
- // Connect to remote terminal server using the API key directly.
1470
- // The terminal token approach fails because the proxied WebSocket has no
1471
- // x-auth-user header, so the remote server can't match the token's username
1472
- // to the request. API key auth on the WebSocket is the reliable path.
1473
- const WebSocket = require('ws');
1474
- const remoteParams = new URLSearchParams({
1475
- paneId,
1476
- cwd,
1477
- agentType,
1478
- cols: String(cols),
1479
- rows: String(rows),
1480
- apiKey: apiKey,
1481
- });
1482
- if (agentSession) remoteParams.set('agentSession', agentSession);
1483
- // Never forward customCommand to remote nodes — too dangerous
1484
-
1485
- // Upgrade ws:// to wss:// if the node uses https
1486
- let wsUrl = remoteWsUrl;
1487
- if (node.url.startsWith('https://') && wsUrl.startsWith('ws://')) {
1488
- wsUrl = 'wss://' + wsUrl.slice(5);
1489
- }
1490
- const remoteUrl = `${wsUrl}?${remoteParams}`;
1491
- console.log(`[Proxy] Connecting to remote node ${nodeId.slice(0, 8)}`);
1492
-
1493
- const remoteWs = new WebSocket(remoteUrl, { rejectUnauthorized: !skipTls ? undefined : false });
1494
-
1495
- remoteWs.on('open', () => {
1496
- console.log(`[Proxy] Connected to remote node ${nodeId.slice(0, 8)} for pane ${paneId.slice(0, 8)}`);
1497
- });
1498
-
1499
- // Pipe data bidirectionally
1500
- let firstMsg = true;
1501
- remoteWs.on('message', (data) => {
1502
- const str = data.toString();
1503
- if (firstMsg) {
1504
- console.log(`[Proxy] First message from remote for pane ${paneId.slice(0, 8)}: ${str.slice(0, 200)}`);
1505
- firstMsg = false;
1506
- }
1507
- if (clientWs.readyState === 1) {
1508
- clientWs.send(str);
1509
- }
1510
- });
1511
-
1512
- clientWs.on('message', (data) => {
1513
- if (remoteWs.readyState === 1) {
1514
- remoteWs.send(data.toString());
1515
- }
1516
- });
1517
-
1518
- // Handle closes
1519
- remoteWs.on('close', () => {
1520
- console.log(`[Proxy] Remote connection closed for pane ${paneId.slice(0, 8)}`);
1521
- if (clientWs.readyState === 1) {
1522
- clientWs.send(JSON.stringify({ type: 'exit', exitCode: -1, reason: 'Remote connection closed' }));
1523
- }
1524
- });
1525
-
1526
- remoteWs.on('error', (err) => {
1527
- console.error(`[Proxy] Remote error for pane ${paneId.slice(0, 8)}:`, err.message);
1528
- if (clientWs.readyState === 1) {
1529
- clientWs.send(JSON.stringify({ type: 'error', data: `Remote error: ${err.message}` }));
1530
- }
1531
- });
1532
-
1533
- clientWs.on('close', () => {
1534
- console.log(`[Proxy] Client disconnected for pane ${paneId.slice(0, 8)}`);
1535
- if (remoteWs.readyState === 1) {
1536
- remoteWs.close();
1537
- }
1538
- });
1539
- }
1540
-
1541
- // ─── Shared WSS setup (attach handlers to any WebSocketServer) ──
1542
-
1543
- function setupWss(wss) {
1544
- const pingInterval = setInterval(() => {
1545
- wss.clients.forEach((ws) => {
1546
- if (ws.isAlive === false) return ws.terminate();
1547
- ws.isAlive = false;
1548
- ws.ping();
1549
- });
1550
- }, 30000);
1551
-
1552
- wss.on('connection', (ws, req) => handleConnection(wss, ws, req));
1553
-
1554
- wss.on('error', (err) => {
1555
- console.error('[Terminal Server] Error:', err.message);
1556
- });
1557
-
1558
- // Initialize analytics DB
1559
- getAdminDbRW();
1560
-
1561
- return pingInterval;
1562
- }
1563
-
1564
- function startMdnsIfNeeded(httpPort) {
1565
- if (SPACES_TIER === 'federation') {
1566
- try {
1567
- const { startMdns } = require('./mdns-service');
1568
- startMdns(httpPort || PORT);
1569
- } catch (err) {
1570
- console.log('[mDNS] Discovery not available:', err.message);
1571
- }
1572
- } else {
1573
- console.log(`[mDNS] Skipped (tier=${SPACES_TIER}, requires federation)`);
1574
- }
1575
- }
1576
-
1577
- // ─── Poll-based idle nudge for agent collaboration ───────
1578
-
1579
- function startMessageWatcher(apiPort) {
1580
- try {
1581
- const teams = require('@spaces/teams');
1582
- teams.terminal.startMessageWatcher(apiPort, sessions);
1583
- } catch { /* @spaces/teams not installed — no message watcher */ }
1584
- }
1585
-
1586
- // ─── Attached mode: mount on an existing HTTP server ─────
1587
-
1588
- function createTerminalServer(httpServer) {
1589
- // In attached mode, the API is served by the parent HTTP server, not on PORT (3458).
1590
- if (httpServer.listening) {
1591
- API_PORT = httpServer.address().port;
1592
- waitForApi();
1593
- } else {
1594
- httpServer.on('listening', () => { API_PORT = httpServer.address().port; waitForApi(); });
1595
- }
1596
-
1597
- const wss = new WebSocketServer({ noServer: true });
1598
- setupWss(wss);
1599
-
1600
- httpServer.on('upgrade', (req, socket, head) => {
1601
- const url = new URL(req.url, 'http://localhost');
1602
- if (url.pathname === '/ws' || url.pathname.endsWith('/ws')) {
1603
- // Verify origin for browser clients
1604
- const origin = req.headers.origin;
1605
- if (origin && !isAllowedOrigin(origin, req)) {
1606
- socket.destroy();
1607
- return;
1608
- }
1609
- wss.handleUpgrade(req, socket, head, (ws) => {
1610
- wss.emit('connection', ws, req);
1611
- });
1612
- }
1613
- // Non-/ws upgrades are left for other listeners (e.g. HMR proxy)
1614
- });
1615
-
1616
- // Start mDNS and message watcher once we know the HTTP port
1617
- if (httpServer.listening) {
1618
- startMdnsIfNeeded(httpServer.address().port);
1619
- startMessageWatcher(httpServer.address().port);
1620
- } else {
1621
- httpServer.on('listening', () => {
1622
- startMdnsIfNeeded(httpServer.address().port);
1623
- startMessageWatcher(httpServer.address().port);
1624
- });
1625
- }
1626
- return wss;
1627
- }
1628
-
1629
- // ─── Standalone mode (run directly) ──────────────────────
1630
-
1631
- if (require.main === module) {
1632
- const wss = new WebSocketServer({
1633
- port: PORT,
1634
- verifyClient: ({ req }) => {
1635
- const origin = req.headers.origin;
1636
- if (!origin) return true;
1637
- return isAllowedOrigin(origin, req);
1638
- },
1639
- });
1640
- setupWss(wss);
1641
- startMdnsIfNeeded();
1642
- startMessageWatcher(PORT);
1643
- console.log(`Terminal WebSocket server running on ws://localhost:${PORT}`);
1644
- }
1645
-
1646
- 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) {
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
+ // Register Cortex MCP server
588
+ const mcpServer = path.resolve(__dirname, 'cortex-mcp.js');
589
+ if (!settings.mcpServers) settings.mcpServers = {};
590
+ settings.mcpServers.cortex = {
591
+ command: 'node',
592
+ args: [mcpServer],
593
+ env: {
594
+ SPACES_URL: `http://localhost:${API_PORT}`,
595
+ SPACES_INTERNAL_TOKEN: (process.env.SPACES_SESSION_SECRET || '').slice(0, 16),
596
+ },
597
+ };
598
+
599
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
600
+ console.log(`[Cortex] Wrote Claude Code hooks (RAG + Learn) + MCP server to ${settingsPath}`);
601
+ } catch (err) {
602
+ console.error(`[Cortex] Failed to write hook config:`, err.message);
603
+ }
604
+ }
605
+
606
+ // ─── Cortex context injection ────────────────────────────
607
+ // Fetch relevant knowledge from Cortex API and write a context file
608
+ // in the workspace before the agent launches.
609
+ async function injectCortexContext(cwd, workspaceId, ws) {
610
+ if (!isApiReady()) return 0;
611
+ if (SPACES_TIER !== 'team' && SPACES_TIER !== 'federation') return 0;
612
+ // Check if Cortex is actually enabled in user config
613
+ try {
614
+ const configPath = path.join(os.homedir(), '.spaces', 'config.json');
615
+ if (fs.existsSync(configPath)) {
616
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
617
+ if (!cfg.cortex?.enabled) return 0;
618
+ } else {
619
+ return 0;
620
+ }
621
+ } catch { return 0; }
622
+ try {
623
+ const projectName = path.basename(cwd);
624
+ const query = encodeURIComponent(`${projectName} workspace context`);
625
+ const params = `q=${query}&limit=10${workspaceId ? `&workspace_id=${workspaceId}` : ''}`;
626
+ const url = `http://localhost:${API_PORT}/api/cortex/search?${params}`;
627
+
628
+ // Use internal auth bypass (x-spaces-internal header) to skip session middleware
629
+ const internalToken = (process.env.SPACES_SESSION_SECRET || '').slice(0, 16);
630
+ const options = {
631
+ timeout: 5000,
632
+ headers: {
633
+ 'x-spaces-internal': internalToken,
634
+ },
635
+ };
636
+
637
+ const body = await new Promise((resolve, reject) => {
638
+ const req = http.get(url, options, (res) => {
639
+ // Follow redirects (Next.js trailing-slash redirects)
640
+ if (res.statusCode === 308 || res.statusCode === 307 || res.statusCode === 301 || res.statusCode === 302) {
641
+ const redirectUrl = res.headers.location;
642
+ if (redirectUrl) {
643
+ const fullUrl = redirectUrl.startsWith('http') ? redirectUrl : `http://localhost:${API_PORT}${redirectUrl}`;
644
+ const req2 = http.get(fullUrl, options, (res2) => {
645
+ let data = '';
646
+ res2.on('data', (chunk) => { data += chunk; });
647
+ res2.on('end', () => {
648
+ if (res2.statusCode !== 200) {
649
+ reject(new Error(`Cortex API returned ${res2.statusCode}: ${data.slice(0, 200)}`));
650
+ } else {
651
+ resolve(data);
652
+ }
653
+ });
654
+ });
655
+ req2.on('error', reject);
656
+ return;
657
+ }
658
+ }
659
+ let data = '';
660
+ res.on('data', (chunk) => { data += chunk; });
661
+ res.on('end', () => {
662
+ if (res.statusCode !== 200) {
663
+ reject(new Error(`Cortex API returned ${res.statusCode}: ${data.slice(0, 200)}`));
664
+ } else {
665
+ resolve(data);
666
+ }
667
+ });
668
+ });
669
+ req.on('error', reject);
670
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
671
+ });
672
+
673
+ const parsed = JSON.parse(body);
674
+ const results = parsed.results;
675
+ if (!results || results.length === 0) {
676
+ console.log(`[Cortex] No knowledge found for "${projectName}"`);
677
+ return 0;
678
+ }
679
+
680
+ // Format context (mirrors src/lib/cortex/retrieval/injection.ts)
681
+ const TYPE_LABELS = {
682
+ decision: 'Decision', pattern: 'Pattern', preference: 'Preference',
683
+ error_fix: 'Error Fix', context: 'Context', code_pattern: 'Code',
684
+ command: 'Command', conversation: 'Conversation', summary: 'Summary',
685
+ };
686
+ const lines = ['<cortex-context>', 'Relevant context from your workspace history:', ''];
687
+ let tokens = 20;
688
+ const included = [];
689
+ for (const unit of results) {
690
+ const label = TYPE_LABELS[unit.type] || unit.type;
691
+ const date = (unit.source_timestamp || '').slice(0, 10);
692
+ const confidence = (unit.confidence * 100).toFixed(0);
693
+ let entry = `[${label}]`;
694
+ if (date) entry += ` ${date}:`;
695
+ entry += ` ${unit.text}`;
696
+ if (unit.session_id) entry += `\nSource: session ${unit.session_id}, confidence: ${confidence}%`;
697
+ const entryTokens = Math.ceil(entry.length / 4);
698
+ if (tokens + entryTokens > 2000) break;
699
+ lines.push(entry, '');
700
+ tokens += entryTokens;
701
+ included.push({ type: unit.type, text: unit.text.slice(0, 80) });
702
+ }
703
+ lines.push('</cortex-context>');
704
+
705
+ // Write context file (readable artifact for any agent)
706
+ const spacesDir = path.join(cwd, '.spaces');
707
+ if (!fs.existsSync(spacesDir)) fs.mkdirSync(spacesDir, { recursive: true });
708
+ fs.writeFileSync(path.join(spacesDir, 'cortex-context.md'), lines.join('\n'), 'utf-8');
709
+ console.log(`[Cortex] Injected ${included.length} knowledge units for ${path.basename(cwd)}`);
710
+
711
+ // Notify client for injection badge
712
+ if (ws && ws.readyState === 1) {
713
+ ws.send(JSON.stringify({ type: 'cortex-injection', count: included.length, items: included }));
714
+ }
715
+
716
+ return included.length;
717
+ } catch (err) {
718
+ console.error(`[Cortex] Injection failed:`, err.message);
719
+ return 0;
720
+ }
721
+ }
722
+
723
+ // ─── Git Bash detection (Windows) ────────────────────────
724
+ function findGitBash() {
725
+ const custom = process.env.CLAUDE_CODE_GIT_BASH_PATH;
726
+ if (custom && fs.existsSync(custom)) return custom;
727
+ const localAppData = process.env.LOCALAPPDATA || '';
728
+ const candidates = [
729
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
730
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
731
+ path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
732
+ ];
733
+ for (const p of candidates) {
734
+ if (p && fs.existsSync(p)) return p;
735
+ }
736
+ // Last resort: check if bash is on PATH via where command
737
+ try {
738
+ const result = require('child_process').execSync('where bash.exe 2>nul', { encoding: 'utf-8', timeout: 3000 });
739
+ const first = result.trim().split('\n')[0].trim();
740
+ if (first && first.toLowerCase().includes('git') && fs.existsSync(first)) return first;
741
+ } catch { /* not found */ }
742
+ return null;
743
+ }
744
+
745
+ // ─── SSH binary detection (Windows) ──────────────────────
746
+ function findSshBinary() {
747
+ if (process.platform !== 'win32') return '/usr/bin/ssh';
748
+ // Windows OpenSSH ships in System32
749
+ const sysSSH = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe');
750
+ if (fs.existsSync(sysSSH)) return sysSSH;
751
+ // Git for Windows also bundles ssh
752
+ const gitSSH = 'C:\\Program Files\\Git\\usr\\bin\\ssh.exe';
753
+ if (fs.existsSync(gitSSH)) return gitSSH;
754
+ try {
755
+ const result = require('child_process').execSync('where ssh.exe 2>nul', { encoding: 'utf-8', timeout: 3000 });
756
+ const first = result.trim().split(String.fromCharCode(10))[0].trim();
757
+ if (first && fs.existsSync(first)) return first;
758
+ } catch {}
759
+ return null;
760
+ }
761
+
762
+ // ─── Origin validation ───────────────────────────────────
763
+ function isAllowedOrigin(origin, req) {
764
+ if (!origin) return false;
765
+ try {
766
+ const url = new URL(origin);
767
+ // Allow localhost/127.0.0.1 (any port)
768
+ if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') return true;
769
+ // Allow if origin matches the server's own Host header (same-origin requests)
770
+ const host = req && req.headers && req.headers.host;
771
+ if (host && url.host === host) return true;
772
+ // Allow configured hostname from env (e.g., spaces.example.com)
773
+ const allowed = process.env.SPACES_ALLOWED_ORIGINS;
774
+ if (allowed) {
775
+ return allowed.split(',').some(h => url.hostname === h.trim());
776
+ }
777
+ // In non-community modes, require explicit allowed origins
778
+ if (SPACES_TIER !== 'community') return false;
779
+ // Desktop/community: allow any origin
780
+ return true;
781
+ } catch {
782
+ return false;
783
+ }
784
+ }
785
+
786
+ // ─── Live collab toggle handler ─────────────────────────
787
+ function handleCollabToggle(paneId, session) {
788
+ try {
789
+ const teams = require('@spaces/teams');
790
+ const config = teams.terminal.getCollabConfig(paneId, session.username);
791
+
792
+ if (config) {
793
+ // Enabling collaboration
794
+ session.isCollaborating = true;
795
+ session.workspaceId = config.workspaceId;
796
+ session.paneName = config.paneName;
797
+
798
+ const env = {
799
+ SPACES_PANE_ID: paneId,
800
+ SPACES_WORKSPACE_ID: config.workspaceId,
801
+ SPACES_PANE_NAME: config.paneName,
802
+ SPACES_USERNAME: session.username,
803
+ SPACES_API_URL: `http://localhost:${API_PORT}`,
804
+ SPACES_COLLABORATING: '1',
805
+ };
806
+ teams.terminal.writeAgentConfig(session.agentType, session.cwd, env);
807
+
808
+ // Nudge the agent so it knows collaboration is available
809
+ if (session.pty && !session.exited) {
810
+ 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).';
811
+ session.pty.write(nudge);
812
+ setTimeout(() => { if (!session.exited) session.pty.write('\r'); }, 100);
813
+ }
814
+
815
+ console.log(`[CollabToggle] Enabled for pane ${paneId.slice(0, 8)} (workspace ${config.workspaceId.slice(0, 8)})`);
816
+ } else {
817
+ // Disabling collaboration
818
+ teams.terminal.removeAgentConfig(session.agentType, session.cwd);
819
+ session.isCollaborating = false;
820
+ session.workspaceId = null;
821
+
822
+ console.log(`[CollabToggle] Disabled for pane ${paneId.slice(0, 8)}`);
823
+ }
824
+
825
+ // Confirm to browser
826
+ if (session.ws && session.ws.readyState === 1) {
827
+ session.ws.send(JSON.stringify({ type: 'collab-updated', isCollaborating: !!config }));
828
+ }
829
+ } catch (e) {
830
+ console.error(`[CollabToggle] Error for pane ${paneId.slice(0, 8)}:`, e.message);
831
+ }
832
+ }
833
+
834
+ // ─── Shared connection handler ──────────────────────────
835
+ function handleConnection(wss, ws, req) {
836
+ ws.isAlive = true;
837
+ ws.on('pong', () => { ws.isAlive = true; });
838
+
839
+ const url = new URL(req.url || '/', 'http://localhost');
840
+ const paneId = url.searchParams.get('paneId') || require('crypto').randomUUID();
841
+ const cwd = url.searchParams.get('cwd') || process.env.HOME || process.env.USERPROFILE || 'C:\\';
842
+ const agentType = url.searchParams.get('agentType') || 'shell';
843
+ const rawAgentSession = url.searchParams.get('agentSession') || '';
844
+ const agentSession = (rawAgentSession === 'new' || UUID_RE.test(rawAgentSession)) ? rawAgentSession : '';
845
+ const rawCustomCommand = url.searchParams.get('customCommand') || '';
846
+ // Sanitize: reject shell metacharacters that enable injection (;, |, &, $, `, etc.)
847
+ const customCommand = /[;&|`$(){}]/.test(rawCustomCommand) ? '' : rawCustomCommand;
848
+ const cols = parseInt(url.searchParams.get('cols') || '120', 10);
849
+ const rows = parseInt(url.searchParams.get('rows') || '30', 10);
850
+
851
+ // Authenticate: try session cookie first (self-contained auth), then terminal token + SSO header
852
+ let username = null;
853
+ const cookies = parseCookies(req.headers.cookie);
854
+ const sessionToken = cookies['spaces-session'];
855
+ const sessionPayload = sessionToken ? verifySessionToken(sessionToken) : null;
856
+
857
+ 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'}`);
858
+
859
+ if (sessionPayload) {
860
+ // Self-contained auth: session cookie is valid
861
+ username = sessionPayload.sub;
862
+ console.log(`[Auth] Authenticated via session cookie: ${username}`);
863
+ } else {
864
+ const terminalToken = url.searchParams.get('terminalToken') || '';
865
+
866
+ // Accept magic tokens from desktop/community tier, or from trusted local proxies (Docker/localhost)
867
+ const remoteIp = req.socket.remoteAddress || '';
868
+ const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1' || remoteIp.startsWith('172.') || remoteIp.startsWith('::ffff:172.');
869
+ if ((terminalToken === 'desktop-local' || terminalToken === 'session-auth') && (SPACES_TIER === 'desktop' || SPACES_TIER === 'community' || isLocal)) {
870
+ username = os.userInfo().username;
871
+ // When running as SYSTEM, resolve to the first real user from admin DB
872
+ if (process.platform === "win32" && username.toUpperCase() === "SYSTEM") {
873
+ const db = getAdminDb();
874
+ if (db) {
875
+ try {
876
+ const row = db.prepare("SELECT username FROM users LIMIT 1").get();
877
+ if (row) username = row.username;
878
+ } catch {}
879
+ }
880
+ }
881
+ console.log(`[Auth] Authenticated via desktop token: ${username}`);
882
+ } else {
883
+ // Verify terminal token — if signed by this server's secret, trust it
884
+ const tokenUser = verifyTerminalToken(terminalToken);
885
+ if (tokenUser) {
886
+ // Use the user from the signed token — do NOT trust x-auth-user header
887
+ // as it can be spoofed by clients
888
+ username = tokenUser;
889
+ console.log(`[Auth] Authenticated via terminal token: ${username}`);
890
+ } else if (terminalToken) {
891
+ console.log(`[Auth] Terminal token FAILED: invalid or expired`);
892
+ }
893
+ }
894
+ }
895
+
896
+ // Network API key auth (for proxied connections from remote nodes)
897
+ if (!username) {
898
+ const apiKey = url.searchParams.get('apiKey');
899
+ if (apiKey) {
900
+ console.log(`[Auth] API key provided: ${apiKey.slice(0, 4)}*** (length=${apiKey.length})`);
901
+ const keyRecord = validateNetworkApiKey(apiKey);
902
+ if (keyRecord) {
903
+ console.log(`[Auth] API key validated: permissions=${keyRecord.permissions}, username=${keyRecord.username}`);
904
+ if (keyRecord.permissions === 'terminal' || keyRecord.permissions === 'admin') {
905
+ username = keyRecord.username || os.userInfo().username;
906
+ } else {
907
+ console.log(`[Auth] API key rejected: permissions="${keyRecord.permissions}" not terminal/admin`);
908
+ }
909
+ } else {
910
+ console.log(`[Auth] API key validation FAILED (no matching key in DB)`);
911
+ }
912
+ } else {
913
+ console.log(`[Auth] No apiKey param in WebSocket URL`);
914
+ }
915
+ }
916
+
917
+ if (!username) {
918
+ console.log(`[Auth] REJECTED connection for pane ${paneId} — no auth method succeeded`);
919
+ ws.send(JSON.stringify({ type: 'error', data: 'Authentication required' }));
920
+ ws.close();
921
+ return;
922
+ }
923
+
924
+ // Proxy to remote node (federation tier only)
925
+ const nodeId = url.searchParams.get('nodeId');
926
+ if (nodeId) {
927
+ if (SPACES_TIER !== 'federation') {
928
+ ws.send(JSON.stringify({ type: 'error', data: 'Remote workspaces require the Federation tier' }));
929
+ ws.close();
930
+ return;
931
+ }
932
+ handleProxyConnection(ws, nodeId, { paneId, cwd, agentType, agentSession, customCommand, cols, rows });
933
+ return;
934
+ }
935
+
936
+ // Check for existing session to reattach
937
+ const existing = sessions.get(paneId);
938
+ if (existing && existing.pty && !existing.exited) {
939
+ console.log(`[WS] Reattach pane=${paneId.slice(0,8)} buffer=${existing.buffer.length} chunks`);
940
+ existing.ws = ws;
941
+
942
+ // Replay buffered output so user sees context
943
+ for (const chunk of existing.buffer) {
944
+ ws.send(JSON.stringify({ type: 'data', data: chunk }));
945
+ }
946
+
947
+ try { existing.pty.resize(cols, rows); } catch { /* ignore */ }
948
+
949
+ ws.send(JSON.stringify({ type: 'ready', paneId, reattached: true }));
950
+
951
+ // Skip Cortex injection on reattach — context was already injected at spawn.
952
+ // The badge polls /api/cortex/status independently.
953
+
954
+ ws.on('message', (raw) => {
955
+ try {
956
+ const msg = JSON.parse(raw.toString());
957
+ if (msg.type === 'data') {
958
+ existing.pty.write(msg.data);
959
+ } else if (msg.type === 'resize') {
960
+ try { existing.pty.resize(msg.cols, msg.rows); } catch { /* ignore */ }
961
+ } else if (msg.type === 'collab-toggle') {
962
+ handleCollabToggle(paneId, existing);
963
+ }
964
+ } catch {
965
+ existing.pty.write(raw.toString());
966
+ }
967
+ });
968
+
969
+ ws.on('close', (code, reason) => {
970
+ console.log(`[WS] Close pane=${paneId.slice(0,8)} code=${code} reason=${reason || 'none'}`);
971
+ if (existing.ws === ws) existing.ws = null;
972
+ });
973
+
974
+ ws.on('error', (err) => {
975
+ console.log(`[WS] Error pane=${paneId.slice(0,8)} err=${err.message}`);
976
+ });
977
+
978
+ return;
979
+ }
980
+
981
+ // Create new pty session
982
+ const isWindows = process.platform === 'win32';
983
+
984
+ // Resolve the OS shell user for this app user
985
+ const shellUser = lookupShellUser(username);
986
+ const processUser = os.userInfo().username;
987
+ let shell, args;
988
+ const isSSH = shellUser !== processUser;
989
+ if (isSSH) {
990
+ // SSH to localhost as the mapped shell user using the service key
991
+ const sshBin = findSshBinary();
992
+ if (!sshBin) {
993
+ console.error(`[Spawn] SSH binary not found — cannot spawn as ${shellUser}`);
994
+ ws.send(JSON.stringify({ type: 'error', data: 'SSH not available. Install OpenSSH to enable multi-user terminals.' }));
995
+ ws.close();
996
+ return;
997
+ }
998
+ shell = sshBin;
999
+ args = [
1000
+ '-o', 'StrictHostKeyChecking=accept-new',
1001
+ '-o', `UserKnownHostsFile=${path.join(os.homedir(), '.spaces', 'known_hosts')}`,
1002
+ '-i', SERVICE_KEY,
1003
+ '-t',
1004
+ `${shellUser}@localhost`,
1005
+ ];
1006
+ // Force IPv4 — localhost may resolve to ::1 (IPv6) which sshd can reject
1007
+ args.unshift('-4');
1008
+
1009
+ // On-demand SSH provisioning: ensure the shell user's authorized_keys is set up
1010
+ // before attempting the connection. This handles users added after service install.
1011
+ if (process.platform !== 'win32' && fs.existsSync(SERVICE_KEY + '.pub')) {
1012
+ try {
1013
+ const pubKey = fs.readFileSync(SERVICE_KEY + '.pub', 'utf-8').trim();
1014
+ authorizeShellUser(shellUser, pubKey);
1015
+ } catch (e) {
1016
+ console.error(`[SSH] On-demand provisioning for ${shellUser} failed (non-fatal):`, e.message);
1017
+ }
1018
+ }
1019
+ } else if (isWindows && agentType !== 'shell') {
1020
+ // Agents like Claude Code require bash on Windows — find git-bash
1021
+ shell = findGitBash();
1022
+ args = [];
1023
+ if (!shell) {
1024
+ shell = 'cmd.exe';
1025
+ }
1026
+ } else {
1027
+ shell = isWindows ? 'cmd.exe' : (process.env.SHELL || '/bin/bash');
1028
+ args = [];
1029
+ }
1030
+
1031
+ const env = { ...process.env };
1032
+ delete env.CLAUDECODE;
1033
+ // Enable prompt suggestions in spawned Claude Code sessions
1034
+ env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION = 'true';
1035
+ // Tell Claude Code where git-bash is so it doesn't fail the bash detection
1036
+ if (isWindows && shell && shell.endsWith('bash.exe') && !env.CLAUDE_CODE_GIT_BASH_PATH) {
1037
+ env.CLAUDE_CODE_GIT_BASH_PATH = shell;
1038
+ }
1039
+
1040
+ // Fall back to HOME if cwd doesn't exist (e.g. remote path from another node)
1041
+ let safeCwd = cwd;
1042
+ if (!fs.existsSync(safeCwd)) {
1043
+ safeCwd = os.homedir();
1044
+ console.log(`[Spawn] cwd "${cwd}" does not exist, falling back to "${safeCwd}"`);
1045
+ }
1046
+
1047
+ // Inject Spaces bus environment for agent communication
1048
+ env.SPACES_PANE_ID = paneId;
1049
+ env.SPACES_API_URL = `http://localhost:${API_PORT}`;
1050
+
1051
+ // Look up workspace collaboration config from @spaces/teams
1052
+ let isCollaborating = false;
1053
+ try {
1054
+ const teams = require('@spaces/teams');
1055
+ const config = teams.terminal.getCollabConfig(paneId, username);
1056
+ if (config) {
1057
+ env.SPACES_WORKSPACE_ID = config.workspaceId;
1058
+ env.SPACES_PANE_NAME = config.paneName;
1059
+ env.SPACES_USERNAME = username;
1060
+ isCollaborating = true;
1061
+ env.SPACES_COLLABORATING = '1';
1062
+ console.log(`[Collab] Enabled for pane ${paneId.slice(0, 8)} — workspace ${config.workspaceId}, name "${config.paneName}"`);
1063
+ }
1064
+ } catch (e) {
1065
+ console.error(`[Collab] Failed to check collaboration config for pane ${paneId.slice(0, 8)}:`, e.message);
1066
+ }
1067
+
1068
+ console.log(`[Spawn] user=${username} shell=${shell} args=${JSON.stringify(args)} cwd=${safeCwd} agentType=${agentType}`);
1069
+
1070
+ // Write Cortex RAG hook for Claude Code before spawning (only if Cortex is enabled)
1071
+ if (agentType === 'claude' && (SPACES_TIER === 'team' || SPACES_TIER === 'federation')) {
1072
+ try {
1073
+ const userHome = getUserHome(username);
1074
+ const configPath = path.join(userHome, '.spaces', 'config.json');
1075
+ let cortexEnabled = false;
1076
+ if (fs.existsSync(configPath)) {
1077
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1078
+ cortexEnabled = cfg.cortex?.enabled === true;
1079
+ }
1080
+ if (!cortexEnabled) {
1081
+ removeCortexHookConfig(safeCwd);
1082
+ } else {
1083
+ writeCortexHookConfig(safeCwd);
1084
+ // Resolve workspace ID: from collab config, or look up from pane DB
1085
+ let wsId = env.SPACES_WORKSPACE_ID || null;
1086
+ if (!wsId) {
1087
+ try {
1088
+ const Database = require('better-sqlite3');
1089
+ const spacesDb = new Database(path.join(getUserHome(username), '.spaces', 'spaces.db'), { readonly: true });
1090
+ const row = spacesDb.prepare('SELECT workspace_id FROM panes WHERE id = ?').get(paneId);
1091
+ if (row && row.workspace_id) wsId = String(row.workspace_id);
1092
+ spacesDb.close();
1093
+ } catch { /* non-fatal */ }
1094
+ }
1095
+ if (wsId) env.SPACES_WORKSPACE_ID = wsId;
1096
+ // Write workspace ID for hooks to read (they can't inherit PTY env)
1097
+ try {
1098
+ const envFile = path.join(safeCwd, '.claude', 'spaces-env.json');
1099
+ fs.writeFileSync(envFile, JSON.stringify({
1100
+ workspaceId: wsId,
1101
+ port: API_PORT,
1102
+ }), 'utf-8');
1103
+ } catch { /* non-fatal */ }
1104
+ }
1105
+ } catch (e) {
1106
+ console.error('[Cortex] Config check failed (non-fatal):', e.message);
1107
+ }
1108
+ }
1109
+
1110
+ let term;
1111
+ try {
1112
+ term = pty.spawn(shell, args, {
1113
+ name: 'xterm-256color',
1114
+ cols,
1115
+ rows,
1116
+ cwd: safeCwd,
1117
+ env,
1118
+ });
1119
+ } catch (err) {
1120
+ console.error(`[Spawn Error] ${err.message} (cwd=${cwd}, shell=${shell})`);
1121
+ ws.send(JSON.stringify({ type: 'error', data: 'Failed to spawn terminal session' }));
1122
+ ws.close();
1123
+ return;
1124
+ }
1125
+
1126
+ const session = {
1127
+ pty: term, ws, buffer: [], exited: false, username,
1128
+ agentType,
1129
+ cwd: safeCwd,
1130
+ paneName: env.SPACES_PANE_NAME || paneId,
1131
+ lastOutputTime: Date.now(),
1132
+ lastNudgeTime: 0,
1133
+ startedAt: Date.now(),
1134
+ workspaceId: env.SPACES_WORKSPACE_ID || null,
1135
+ isCollaborating,
1136
+ };
1137
+ sessions.set(paneId, session);
1138
+ analyticsRecordSessionStart(paneId, username, agentType);
1139
+
1140
+ // ─── Cortex context injection (async, non-blocking) ─────
1141
+ if (agentType !== 'shell') {
1142
+ injectCortexContext(safeCwd, env.SPACES_WORKSPACE_ID || null, ws).catch(() => {});
1143
+ }
1144
+
1145
+ // ─── Inject cd for SSH sessions, then agent command ─────
1146
+ const agent = AGENTS[agentType] || AGENTS.shell;
1147
+
1148
+ // SSH sessions start in the remote user's home dir — cd to target cwd first
1149
+ if (isSSH) {
1150
+ setTimeout(() => {
1151
+ if (!session.exited) {
1152
+ if (isWindows) {
1153
+ // Windows cmd.exe uses double quotes
1154
+ const escapedCwd = safeCwd.replace(/"/g, '""');
1155
+ term.write(`cd /d "${escapedCwd}"\r`);
1156
+ } else {
1157
+ // Unix shells use single quotes
1158
+ const escapedCwd = safeCwd.replace(/'/g, "'\\''");
1159
+ term.write(`cd '${escapedCwd}'\r`);
1160
+ }
1161
+ }
1162
+ }, 300);
1163
+ }
1164
+
1165
+ // Write collaboration config for agent panes via @spaces/teams
1166
+ if (isCollaborating && agentType !== 'shell') {
1167
+ try {
1168
+ const teams = require('@spaces/teams');
1169
+ teams.terminal.writeAgentConfig(agentType, safeCwd, env);
1170
+ console.log(`[Collab] Wrote agent config for pane ${paneId.slice(0, 8)} (${agentType}) in ${safeCwd}`);
1171
+ } catch (e) {
1172
+ console.error(`[Collab] Failed to write agent config for pane ${paneId.slice(0, 8)}:`, e.message);
1173
+ }
1174
+ }
1175
+
1176
+ if (agentType !== 'shell') {
1177
+ const command = agentType === 'custom' ? customCommand : agent.command;
1178
+
1179
+ if (command) {
1180
+ const delay = isSSH ? 800 : 300;
1181
+
1182
+ if (agentSession && agentSession !== 'new' && agent.resumeFlag) {
1183
+ // Resume an existing session
1184
+ if (agentType === 'claude') {
1185
+ // Claude needs to be run from the correct project CWD
1186
+ const sessionCwd = findSessionCwd(agentSession, username);
1187
+ setTimeout(() => {
1188
+ if (session.exited) return;
1189
+ if (sessionCwd && sessionCwd !== safeCwd) {
1190
+ const cdCmd = isWindows ? `cd /d "${sessionCwd}"` : `cd "${sessionCwd}"`;
1191
+ term.write(cdCmd + '\r');
1192
+ setTimeout(() => {
1193
+ if (!session.exited) {
1194
+ term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
1195
+ }
1196
+ }, 300);
1197
+ } else {
1198
+ term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
1199
+ }
1200
+ }, delay);
1201
+ } else {
1202
+ // Generic resume: works for both subcommand (codex resume <id>) and flag (gemini --resume <id>)
1203
+ setTimeout(() => {
1204
+ if (!session.exited) {
1205
+ term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
1206
+ }
1207
+ }, delay);
1208
+ }
1209
+ } else {
1210
+ // Start new session
1211
+ setTimeout(() => {
1212
+ if (!session.exited) {
1213
+ term.write(`${command}\r`);
1214
+ }
1215
+ }, delay);
1216
+ }
1217
+ }
1218
+ }
1219
+
1220
+ // pty -> ws (and buffer)
1221
+ term.onData((data) => {
1222
+ session.lastOutputTime = Date.now();
1223
+ session.buffer.push(data);
1224
+ if (session.buffer.length > MAX_BUFFER_LINES) {
1225
+ session.buffer.shift();
1226
+ }
1227
+
1228
+ if (session.ws && session.ws.readyState === 1) {
1229
+ session.ws.send(JSON.stringify({ type: 'data', data }));
1230
+ }
1231
+ });
1232
+
1233
+ term.onExit(({ exitCode }) => {
1234
+ session.exited = true;
1235
+ analyticsRecordSessionEnd(paneId);
1236
+ // Clean up hook state file
1237
+ try {
1238
+ const hookStateFile = path.join(os.homedir(), '.spaces', 'hook-state', `${paneId}.json`);
1239
+ if (fs.existsSync(hookStateFile)) fs.unlinkSync(hookStateFile);
1240
+ } catch { /* ignore */ }
1241
+ if (session.ws && session.ws.readyState === 1) {
1242
+ session.ws.send(JSON.stringify({ type: 'exit', exitCode }));
1243
+ }
1244
+ setTimeout(() => {
1245
+ if (sessions.get(paneId) === session) {
1246
+ sessions.delete(paneId);
1247
+ }
1248
+ }, 120000);
1249
+ });
1250
+
1251
+ // ws -> pty
1252
+ ws.on('message', (raw) => {
1253
+ try {
1254
+ const msg = JSON.parse(raw.toString());
1255
+ if (msg.type === 'data') {
1256
+ term.write(msg.data);
1257
+ } else if (msg.type === 'resize') {
1258
+ try { term.resize(msg.cols, msg.rows); } catch { /* ignore */ }
1259
+ } else if (msg.type === 'collab-toggle') {
1260
+ handleCollabToggle(paneId, session);
1261
+ }
1262
+ } catch {
1263
+ term.write(raw.toString());
1264
+ }
1265
+ });
1266
+
1267
+ ws.on('close', (code, reason) => {
1268
+ console.log(`[WS] Close pane=${paneId.slice(0,8)} code=${code} reason=${reason || 'none'}`);
1269
+ if (session.ws === ws) session.ws = null;
1270
+ });
1271
+
1272
+ ws.on('error', (err) => {
1273
+ console.log(`[WS] Error pane=${paneId.slice(0,8)} err=${err.message}`);
1274
+ });
1275
+
1276
+ ws.send(JSON.stringify({ type: 'ready', paneId }));
1277
+
1278
+ // Confirm actual collaboration state so browser syncs with backend
1279
+ ws.send(JSON.stringify({ type: 'collab-updated', isCollaborating }));
1280
+
1281
+ // ─── Session ID detection for Claude sessions ────────────
1282
+ // Always run detection for Claude panes even when resuming, because
1283
+ // the resume may fail (stale/expired session) and Claude will start fresh,
1284
+ // creating a new session ID that we need to capture.
1285
+ if (agentType === 'claude') {
1286
+ detectNewClaudeSession(paneId, cwd, ws, session, username);
1287
+ }
1288
+ }
1289
+
1290
+ // ─── Claude-specific helpers ──────────────────────────────
1291
+
1292
+ function getUserHome(username) {
1293
+ const shellUser = lookupShellUser(username);
1294
+ if (shellUser === os.userInfo().username) return os.homedir();
1295
+ if (process.platform === 'win32') {
1296
+ // On Windows, user profiles live under the Users directory
1297
+ const usersDir = path.dirname(os.homedir());
1298
+ const userHome = path.join(usersDir, shellUser);
1299
+ if (fs.existsSync(userHome)) return userHome;
1300
+ return os.homedir();
1301
+ }
1302
+ return `/home/${shellUser}`;
1303
+ }
1304
+
1305
+ 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$/;
1306
+
1307
+ function findSessionCwd(sessionId, username) {
1308
+ const claudeProjectsDir = path.join(getUserHome(username), '.claude', 'projects');
1309
+ try {
1310
+ if (!fs.existsSync(claudeProjectsDir)) return null;
1311
+ const fileName = `${sessionId}.jsonl`;
1312
+
1313
+ for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
1314
+ if (!projDir.isDirectory()) continue;
1315
+ const filePath = path.join(claudeProjectsDir, projDir.name, fileName);
1316
+ if (fs.existsSync(filePath)) {
1317
+ // Try to find cwd in the jsonl first few lines
1318
+ const fd = fs.openSync(filePath, 'r');
1319
+ const buf = Buffer.alloc(4096);
1320
+ const bytesRead = fs.readSync(fd, buf, 0, 4096, 0);
1321
+ fs.closeSync(fd);
1322
+
1323
+ const chunk = buf.toString('utf-8', 0, bytesRead);
1324
+ const lines = chunk.split('\n');
1325
+ for (const line of lines) {
1326
+ if (!line.trim()) continue;
1327
+ try {
1328
+ const entry = JSON.parse(line);
1329
+ if (entry.cwd) {
1330
+ console.log(`[Session CWD] ${sessionId.slice(0, 8)}: ${entry.cwd}`);
1331
+ return entry.cwd;
1332
+ }
1333
+ } catch { /* skip */ }
1334
+ }
1335
+
1336
+ // Fallback: derive CWD from the project directory name
1337
+ // Claude encodes paths as e.g. "-home-user-projects-myapp"
1338
+ const derivedPath = '/' + projDir.name.replace(/^-/, '').replace(/-/g, '/');
1339
+ if (fs.existsSync(derivedPath)) {
1340
+ console.log(`[Session CWD] ${sessionId.slice(0, 8)}: ${derivedPath} (derived from dir name)`);
1341
+ return derivedPath;
1342
+ }
1343
+ }
1344
+ }
1345
+ } catch (err) {
1346
+ console.error(`[Session CWD] Error looking up ${sessionId}:`, err.message);
1347
+ }
1348
+ return null;
1349
+ }
1350
+
1351
+ function detectNewClaudeSession(paneId, cwd, ws, session, username) {
1352
+ const claudeProjectsDir = path.join(getUserHome(username), '.claude', 'projects');
1353
+
1354
+ const knownSessionIds = new Set();
1355
+ try {
1356
+ if (!fs.existsSync(claudeProjectsDir)) { /* will be created */ }
1357
+ else {
1358
+ for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
1359
+ if (!projDir.isDirectory()) continue;
1360
+ const projPath = path.join(claudeProjectsDir, projDir.name);
1361
+ try {
1362
+ for (const item of fs.readdirSync(projPath)) {
1363
+ const m = item.match(UUID_JSONL_RE);
1364
+ if (m) knownSessionIds.add(m[1]);
1365
+ }
1366
+ const indexPath = path.join(projPath, 'sessions-index.json');
1367
+ if (fs.existsSync(indexPath)) {
1368
+ try {
1369
+ const data = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
1370
+ if (data.entries) {
1371
+ for (const entry of data.entries) knownSessionIds.add(entry.sessionId);
1372
+ }
1373
+ } catch { /* ignore */ }
1374
+ }
1375
+ } catch { /* ignore */ }
1376
+ }
1377
+ }
1378
+ } catch { /* ignore */ }
1379
+
1380
+ console.log(`[Session Detect] Pane ${paneId.slice(0, 8)} (${username}): snapshot ${knownSessionIds.size} existing sessions`);
1381
+
1382
+ let attempts = 0;
1383
+ const maxAttempts = 45;
1384
+ const interval = setInterval(() => {
1385
+ attempts++;
1386
+ if (attempts > maxAttempts || session.exited) {
1387
+ clearInterval(interval);
1388
+ return;
1389
+ }
1390
+
1391
+ try {
1392
+ if (!fs.existsSync(claudeProjectsDir)) return;
1393
+
1394
+ for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
1395
+ if (!projDir.isDirectory()) continue;
1396
+ const projPath = path.join(claudeProjectsDir, projDir.name);
1397
+ try {
1398
+ for (const item of fs.readdirSync(projPath)) {
1399
+ const m = item.match(UUID_JSONL_RE);
1400
+ if (m && !knownSessionIds.has(m[1])) {
1401
+ const newSessionId = m[1];
1402
+ clearInterval(interval);
1403
+ console.log(`[Session Detect] Pane ${paneId.slice(0, 8)} (${username}): detected session ${newSessionId}`);
1404
+ if (session.ws && session.ws.readyState === 1) {
1405
+ session.ws.send(JSON.stringify({
1406
+ type: 'session-detected',
1407
+ sessionId: newSessionId,
1408
+ paneId,
1409
+ }));
1410
+ }
1411
+ return;
1412
+ }
1413
+ }
1414
+ } catch { /* ignore */ }
1415
+ }
1416
+ } catch { /* ignore */ }
1417
+ }, 2000);
1418
+ }
1419
+
1420
+ // ─── Proxy: forward connection to remote node ──────────
1421
+
1422
+ async function handleProxyConnection(clientWs, nodeId, opts) {
1423
+ const { paneId, cwd, agentType, agentSession, customCommand, cols, rows } = opts;
1424
+
1425
+ const node = getNodeInfo(nodeId);
1426
+ if (!node) {
1427
+ clientWs.send(JSON.stringify({ type: 'error', data: `Node ${nodeId} not found` }));
1428
+ clientWs.close();
1429
+ return;
1430
+ }
1431
+
1432
+ const apiKey = decryptNodeApiKey(node.api_key_encrypted);
1433
+ if (!apiKey) {
1434
+ clientWs.send(JSON.stringify({ type: 'error', data: 'Cannot decrypt API key for remote node' }));
1435
+ clientWs.close();
1436
+ return;
1437
+ }
1438
+
1439
+ // Get the remote WebSocket URL via the terminal token endpoint
1440
+ // Only skip TLS verification if explicitly opted in (e.g., self-signed certs)
1441
+ const skipTls = process.env.SPACES_SKIP_TLS_VERIFY === '1';
1442
+ const prevTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
1443
+ if (skipTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
1444
+ let remoteWsUrl;
1445
+ try {
1446
+ const tokenUrl = `${node.url}/api/network/terminal/token/`;
1447
+ const res = await fetch(tokenUrl, {
1448
+ method: 'POST',
1449
+ headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
1450
+ signal: AbortSignal.timeout(10000),
1451
+ });
1452
+
1453
+ if (!res.ok) {
1454
+ let detail = '';
1455
+ try { const body = await res.json(); detail = body.error || JSON.stringify(body); } catch { detail = await res.text().catch(() => `HTTP ${res.status}`); }
1456
+ clientWs.send(JSON.stringify({ type: 'error', data: `Remote terminal auth failed: ${detail}` }));
1457
+ clientWs.close();
1458
+ return;
1459
+ }
1460
+
1461
+ const data = await res.json();
1462
+ remoteWsUrl = data.wsUrl;
1463
+ } catch (err) {
1464
+ clientWs.send(JSON.stringify({ type: 'error', data: `Cannot reach remote node: ${err.message}` }));
1465
+ clientWs.close();
1466
+ return;
1467
+ } finally {
1468
+ if (prevTls === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
1469
+ else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTls;
1470
+ }
1471
+
1472
+ // Connect to remote terminal server using the API key directly.
1473
+ // The terminal token approach fails because the proxied WebSocket has no
1474
+ // x-auth-user header, so the remote server can't match the token's username
1475
+ // to the request. API key auth on the WebSocket is the reliable path.
1476
+ const WebSocket = require('ws');
1477
+ const remoteParams = new URLSearchParams({
1478
+ paneId,
1479
+ cwd,
1480
+ agentType,
1481
+ cols: String(cols),
1482
+ rows: String(rows),
1483
+ apiKey: apiKey,
1484
+ });
1485
+ if (agentSession) remoteParams.set('agentSession', agentSession);
1486
+ // Never forward customCommand to remote nodes — too dangerous
1487
+
1488
+ // Upgrade ws:// to wss:// if the node uses https
1489
+ let wsUrl = remoteWsUrl;
1490
+ if (node.url.startsWith('https://') && wsUrl.startsWith('ws://')) {
1491
+ wsUrl = 'wss://' + wsUrl.slice(5);
1492
+ }
1493
+ const remoteUrl = `${wsUrl}?${remoteParams}`;
1494
+ console.log(`[Proxy] Connecting to remote node ${nodeId.slice(0, 8)}`);
1495
+
1496
+ const remoteWs = new WebSocket(remoteUrl, { rejectUnauthorized: !skipTls ? undefined : false });
1497
+
1498
+ remoteWs.on('open', () => {
1499
+ console.log(`[Proxy] Connected to remote node ${nodeId.slice(0, 8)} for pane ${paneId.slice(0, 8)}`);
1500
+ });
1501
+
1502
+ // Pipe data bidirectionally
1503
+ let firstMsg = true;
1504
+ remoteWs.on('message', (data) => {
1505
+ const str = data.toString();
1506
+ if (firstMsg) {
1507
+ console.log(`[Proxy] First message from remote for pane ${paneId.slice(0, 8)}: ${str.slice(0, 200)}`);
1508
+ firstMsg = false;
1509
+ }
1510
+ if (clientWs.readyState === 1) {
1511
+ clientWs.send(str);
1512
+ }
1513
+ });
1514
+
1515
+ clientWs.on('message', (data) => {
1516
+ if (remoteWs.readyState === 1) {
1517
+ remoteWs.send(data.toString());
1518
+ }
1519
+ });
1520
+
1521
+ // Handle closes
1522
+ remoteWs.on('close', () => {
1523
+ console.log(`[Proxy] Remote connection closed for pane ${paneId.slice(0, 8)}`);
1524
+ if (clientWs.readyState === 1) {
1525
+ clientWs.send(JSON.stringify({ type: 'exit', exitCode: -1, reason: 'Remote connection closed' }));
1526
+ }
1527
+ });
1528
+
1529
+ remoteWs.on('error', (err) => {
1530
+ console.error(`[Proxy] Remote error for pane ${paneId.slice(0, 8)}:`, err.message);
1531
+ if (clientWs.readyState === 1) {
1532
+ clientWs.send(JSON.stringify({ type: 'error', data: `Remote error: ${err.message}` }));
1533
+ }
1534
+ });
1535
+
1536
+ clientWs.on('close', () => {
1537
+ console.log(`[Proxy] Client disconnected for pane ${paneId.slice(0, 8)}`);
1538
+ if (remoteWs.readyState === 1) {
1539
+ remoteWs.close();
1540
+ }
1541
+ });
1542
+ }
1543
+
1544
+ // ─── Shared WSS setup (attach handlers to any WebSocketServer) ──
1545
+
1546
+ function setupWss(wss) {
1547
+ const pingInterval = setInterval(() => {
1548
+ wss.clients.forEach((ws) => {
1549
+ if (ws.isAlive === false) return ws.terminate();
1550
+ ws.isAlive = false;
1551
+ ws.ping();
1552
+ });
1553
+ }, 30000);
1554
+
1555
+ wss.on('connection', (ws, req) => handleConnection(wss, ws, req));
1556
+
1557
+ wss.on('error', (err) => {
1558
+ console.error('[Terminal Server] Error:', err.message);
1559
+ });
1560
+
1561
+ // Initialize analytics DB
1562
+ getAdminDbRW();
1563
+
1564
+ return pingInterval;
1565
+ }
1566
+
1567
+ function startMdnsIfNeeded(httpPort) {
1568
+ if (SPACES_TIER === 'federation') {
1569
+ try {
1570
+ const { startMdns } = require('./mdns-service');
1571
+ startMdns(httpPort || PORT);
1572
+ } catch (err) {
1573
+ console.log('[mDNS] Discovery not available:', err.message);
1574
+ }
1575
+ } else {
1576
+ console.log(`[mDNS] Skipped (tier=${SPACES_TIER}, requires federation)`);
1577
+ }
1578
+ }
1579
+
1580
+ // ─── Poll-based idle nudge for agent collaboration ───────
1581
+
1582
+ function startMessageWatcher(apiPort) {
1583
+ try {
1584
+ const teams = require('@spaces/teams');
1585
+ teams.terminal.startMessageWatcher(apiPort, sessions);
1586
+ } catch { /* @spaces/teams not installed no message watcher */ }
1587
+ }
1588
+
1589
+ // ─── Attached mode: mount on an existing HTTP server ─────
1590
+
1591
+ function createTerminalServer(httpServer) {
1592
+ // In attached mode, the API is served by the parent HTTP server, not on PORT (3458).
1593
+ if (httpServer.listening) {
1594
+ API_PORT = httpServer.address().port;
1595
+ waitForApi();
1596
+ } else {
1597
+ httpServer.on('listening', () => { API_PORT = httpServer.address().port; waitForApi(); });
1598
+ }
1599
+
1600
+ const wss = new WebSocketServer({ noServer: true });
1601
+ setupWss(wss);
1602
+
1603
+ httpServer.on('upgrade', (req, socket, head) => {
1604
+ const url = new URL(req.url, 'http://localhost');
1605
+ if (url.pathname === '/ws' || url.pathname.endsWith('/ws')) {
1606
+ // Verify origin for browser clients
1607
+ const origin = req.headers.origin;
1608
+ if (origin && !isAllowedOrigin(origin, req)) {
1609
+ socket.destroy();
1610
+ return;
1611
+ }
1612
+ wss.handleUpgrade(req, socket, head, (ws) => {
1613
+ wss.emit('connection', ws, req);
1614
+ });
1615
+ }
1616
+ // Non-/ws upgrades are left for other listeners (e.g. HMR proxy)
1617
+ });
1618
+
1619
+ // Start mDNS and message watcher once we know the HTTP port
1620
+ if (httpServer.listening) {
1621
+ startMdnsIfNeeded(httpServer.address().port);
1622
+ startMessageWatcher(httpServer.address().port);
1623
+ } else {
1624
+ httpServer.on('listening', () => {
1625
+ startMdnsIfNeeded(httpServer.address().port);
1626
+ startMessageWatcher(httpServer.address().port);
1627
+ });
1628
+ }
1629
+ return wss;
1630
+ }
1631
+
1632
+ // ─── Standalone mode (run directly) ──────────────────────
1633
+
1634
+ if (require.main === module) {
1635
+ const wss = new WebSocketServer({
1636
+ port: PORT,
1637
+ verifyClient: ({ req }) => {
1638
+ const origin = req.headers.origin;
1639
+ if (!origin) return true;
1640
+ return isAllowedOrigin(origin, req);
1641
+ },
1642
+ });
1643
+ setupWss(wss);
1644
+ startMdnsIfNeeded();
1645
+ startMessageWatcher(PORT);
1646
+ console.log(`Terminal WebSocket server running on ws://localhost:${PORT}`);
1647
+ }
1648
+
1649
+ module.exports = { createTerminalServer };