gitspace 0.2.0-rc.1

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 (318) hide show
  1. package/.claude/settings.local.json +21 -0
  2. package/.gitspace/bundle.json +50 -0
  3. package/.gitspace/select/01-status.sh +40 -0
  4. package/.gitspace/setup/01-install-deps.sh +12 -0
  5. package/.gitspace/setup/02-typecheck.sh +16 -0
  6. package/AGENTS.md +439 -0
  7. package/CLAUDE.md +1 -0
  8. package/LICENSE +25 -0
  9. package/README.md +607 -0
  10. package/bin/gssh +62 -0
  11. package/bun.lock +647 -0
  12. package/docs/CONNECTION.md +623 -0
  13. package/docs/GATEWAY-WORKER.md +319 -0
  14. package/docs/GETTING-STARTED.md +448 -0
  15. package/docs/GITSPACE-PLATFORM.md +1819 -0
  16. package/docs/INFRASTRUCTURE.md +1347 -0
  17. package/docs/PROTOCOL.md +619 -0
  18. package/docs/QUICKSTART.md +174 -0
  19. package/docs/RELAY.md +327 -0
  20. package/docs/REMOTE-DESIGN.md +549 -0
  21. package/docs/ROADMAP.md +564 -0
  22. package/docs/SITE_DOCS_FIGMA_MAKE.md +1167 -0
  23. package/docs/STACK-DESIGN.md +588 -0
  24. package/docs/UNIFIED_ARCHITECTURE.md +292 -0
  25. package/experiments/pty-benchmark.ts +148 -0
  26. package/experiments/pty-latency.ts +100 -0
  27. package/experiments/router/client.ts +199 -0
  28. package/experiments/router/protocol.ts +74 -0
  29. package/experiments/router/router.ts +217 -0
  30. package/experiments/router/session.ts +180 -0
  31. package/experiments/router/test.ts +133 -0
  32. package/experiments/socket-bandwidth.ts +77 -0
  33. package/homebrew/gitspace.rb +45 -0
  34. package/landing-page/ATTRIBUTIONS.md +3 -0
  35. package/landing-page/README.md +11 -0
  36. package/landing-page/bun.lock +801 -0
  37. package/landing-page/guidelines/Guidelines.md +61 -0
  38. package/landing-page/index.html +37 -0
  39. package/landing-page/package.json +90 -0
  40. package/landing-page/postcss.config.mjs +15 -0
  41. package/landing-page/public/_redirects +1 -0
  42. package/landing-page/public/favicon.png +0 -0
  43. package/landing-page/src/app/App.tsx +53 -0
  44. package/landing-page/src/app/components/figma/ImageWithFallback.tsx +27 -0
  45. package/landing-page/src/app/components/ui/accordion.tsx +66 -0
  46. package/landing-page/src/app/components/ui/alert-dialog.tsx +157 -0
  47. package/landing-page/src/app/components/ui/alert.tsx +66 -0
  48. package/landing-page/src/app/components/ui/aspect-ratio.tsx +11 -0
  49. package/landing-page/src/app/components/ui/avatar.tsx +53 -0
  50. package/landing-page/src/app/components/ui/badge.tsx +46 -0
  51. package/landing-page/src/app/components/ui/breadcrumb.tsx +109 -0
  52. package/landing-page/src/app/components/ui/button.tsx +57 -0
  53. package/landing-page/src/app/components/ui/calendar.tsx +75 -0
  54. package/landing-page/src/app/components/ui/card.tsx +92 -0
  55. package/landing-page/src/app/components/ui/carousel.tsx +241 -0
  56. package/landing-page/src/app/components/ui/chart.tsx +353 -0
  57. package/landing-page/src/app/components/ui/checkbox.tsx +32 -0
  58. package/landing-page/src/app/components/ui/collapsible.tsx +33 -0
  59. package/landing-page/src/app/components/ui/command.tsx +177 -0
  60. package/landing-page/src/app/components/ui/context-menu.tsx +252 -0
  61. package/landing-page/src/app/components/ui/dialog.tsx +135 -0
  62. package/landing-page/src/app/components/ui/drawer.tsx +132 -0
  63. package/landing-page/src/app/components/ui/dropdown-menu.tsx +257 -0
  64. package/landing-page/src/app/components/ui/form.tsx +168 -0
  65. package/landing-page/src/app/components/ui/hover-card.tsx +44 -0
  66. package/landing-page/src/app/components/ui/input-otp.tsx +77 -0
  67. package/landing-page/src/app/components/ui/input.tsx +21 -0
  68. package/landing-page/src/app/components/ui/label.tsx +24 -0
  69. package/landing-page/src/app/components/ui/menubar.tsx +276 -0
  70. package/landing-page/src/app/components/ui/navigation-menu.tsx +168 -0
  71. package/landing-page/src/app/components/ui/pagination.tsx +127 -0
  72. package/landing-page/src/app/components/ui/popover.tsx +48 -0
  73. package/landing-page/src/app/components/ui/progress.tsx +31 -0
  74. package/landing-page/src/app/components/ui/radio-group.tsx +45 -0
  75. package/landing-page/src/app/components/ui/resizable.tsx +56 -0
  76. package/landing-page/src/app/components/ui/scroll-area.tsx +58 -0
  77. package/landing-page/src/app/components/ui/select.tsx +189 -0
  78. package/landing-page/src/app/components/ui/separator.tsx +28 -0
  79. package/landing-page/src/app/components/ui/sheet.tsx +139 -0
  80. package/landing-page/src/app/components/ui/sidebar.tsx +726 -0
  81. package/landing-page/src/app/components/ui/skeleton.tsx +13 -0
  82. package/landing-page/src/app/components/ui/slider.tsx +63 -0
  83. package/landing-page/src/app/components/ui/sonner.tsx +25 -0
  84. package/landing-page/src/app/components/ui/switch.tsx +31 -0
  85. package/landing-page/src/app/components/ui/table.tsx +116 -0
  86. package/landing-page/src/app/components/ui/tabs.tsx +66 -0
  87. package/landing-page/src/app/components/ui/textarea.tsx +18 -0
  88. package/landing-page/src/app/components/ui/toggle-group.tsx +73 -0
  89. package/landing-page/src/app/components/ui/toggle.tsx +47 -0
  90. package/landing-page/src/app/components/ui/tooltip.tsx +61 -0
  91. package/landing-page/src/app/components/ui/use-mobile.ts +21 -0
  92. package/landing-page/src/app/components/ui/utils.ts +6 -0
  93. package/landing-page/src/components/docs/DocsContent.tsx +718 -0
  94. package/landing-page/src/components/docs/DocsSidebar.tsx +84 -0
  95. package/landing-page/src/components/landing/CTA.tsx +59 -0
  96. package/landing-page/src/components/landing/Comparison.tsx +84 -0
  97. package/landing-page/src/components/landing/FaultyTerminal.tsx +424 -0
  98. package/landing-page/src/components/landing/Features.tsx +201 -0
  99. package/landing-page/src/components/landing/Hero.tsx +142 -0
  100. package/landing-page/src/components/landing/Pricing.tsx +140 -0
  101. package/landing-page/src/components/landing/Roadmap.tsx +86 -0
  102. package/landing-page/src/components/landing/Security.tsx +81 -0
  103. package/landing-page/src/components/landing/TerminalWindow.tsx +27 -0
  104. package/landing-page/src/components/landing/UseCases.tsx +55 -0
  105. package/landing-page/src/components/landing/Workflow.tsx +101 -0
  106. package/landing-page/src/components/layout/DashboardNavbar.tsx +37 -0
  107. package/landing-page/src/components/layout/Footer.tsx +55 -0
  108. package/landing-page/src/components/layout/LandingNavbar.tsx +82 -0
  109. package/landing-page/src/components/ui/badge.tsx +39 -0
  110. package/landing-page/src/components/ui/breadcrumb.tsx +115 -0
  111. package/landing-page/src/components/ui/button.tsx +57 -0
  112. package/landing-page/src/components/ui/card.tsx +79 -0
  113. package/landing-page/src/components/ui/mock-terminal.tsx +68 -0
  114. package/landing-page/src/components/ui/separator.tsx +28 -0
  115. package/landing-page/src/lib/utils.ts +6 -0
  116. package/landing-page/src/main.tsx +10 -0
  117. package/landing-page/src/pages/Dashboard.tsx +133 -0
  118. package/landing-page/src/pages/DocsPage.tsx +79 -0
  119. package/landing-page/src/pages/LandingPage.tsx +31 -0
  120. package/landing-page/src/pages/TerminalView.tsx +106 -0
  121. package/landing-page/src/styles/fonts.css +0 -0
  122. package/landing-page/src/styles/index.css +3 -0
  123. package/landing-page/src/styles/tailwind.css +4 -0
  124. package/landing-page/src/styles/theme.css +181 -0
  125. package/landing-page/vite.config.ts +19 -0
  126. package/npm/darwin-arm64/bin/gssh +0 -0
  127. package/npm/darwin-arm64/package.json +20 -0
  128. package/package.json +74 -0
  129. package/scripts/build.ts +284 -0
  130. package/scripts/release.ts +140 -0
  131. package/src/__tests__/test-utils.ts +298 -0
  132. package/src/commands/__tests__/serve-messages.test.ts +190 -0
  133. package/src/commands/access.ts +298 -0
  134. package/src/commands/add.ts +452 -0
  135. package/src/commands/auth.ts +364 -0
  136. package/src/commands/connect.ts +287 -0
  137. package/src/commands/directory.ts +16 -0
  138. package/src/commands/host.ts +396 -0
  139. package/src/commands/identity.ts +184 -0
  140. package/src/commands/list.ts +200 -0
  141. package/src/commands/relay.ts +315 -0
  142. package/src/commands/remove.ts +241 -0
  143. package/src/commands/serve.ts +1493 -0
  144. package/src/commands/share.ts +456 -0
  145. package/src/commands/status.ts +125 -0
  146. package/src/commands/switch.ts +353 -0
  147. package/src/commands/tmux.ts +317 -0
  148. package/src/core/__tests__/access.test.ts +240 -0
  149. package/src/core/access.ts +277 -0
  150. package/src/core/bundle.ts +342 -0
  151. package/src/core/config.ts +510 -0
  152. package/src/core/git.ts +317 -0
  153. package/src/core/github.ts +151 -0
  154. package/src/core/identity.ts +631 -0
  155. package/src/core/linear.ts +225 -0
  156. package/src/core/shell.ts +161 -0
  157. package/src/core/trusted-relays.ts +315 -0
  158. package/src/index.ts +821 -0
  159. package/src/lib/remote-session/index.ts +7 -0
  160. package/src/lib/remote-session/protocol.ts +267 -0
  161. package/src/lib/remote-session/session-handler.ts +581 -0
  162. package/src/lib/remote-session/workspace-scanner.ts +167 -0
  163. package/src/lib/tmux-lite/README.md +81 -0
  164. package/src/lib/tmux-lite/cli.ts +796 -0
  165. package/src/lib/tmux-lite/crypto/__tests__/helpers/handshake-runner.ts +349 -0
  166. package/src/lib/tmux-lite/crypto/__tests__/helpers/mock-relay.ts +291 -0
  167. package/src/lib/tmux-lite/crypto/__tests__/helpers/test-identities.ts +142 -0
  168. package/src/lib/tmux-lite/crypto/__tests__/integration/authorization.integration.test.ts +339 -0
  169. package/src/lib/tmux-lite/crypto/__tests__/integration/e2e-communication.integration.test.ts +477 -0
  170. package/src/lib/tmux-lite/crypto/__tests__/integration/error-handling.integration.test.ts +499 -0
  171. package/src/lib/tmux-lite/crypto/__tests__/integration/handshake.integration.test.ts +371 -0
  172. package/src/lib/tmux-lite/crypto/__tests__/integration/security.integration.test.ts +573 -0
  173. package/src/lib/tmux-lite/crypto/access-control.test.ts +512 -0
  174. package/src/lib/tmux-lite/crypto/access-control.ts +320 -0
  175. package/src/lib/tmux-lite/crypto/frames.test.ts +262 -0
  176. package/src/lib/tmux-lite/crypto/frames.ts +141 -0
  177. package/src/lib/tmux-lite/crypto/handshake.ts +894 -0
  178. package/src/lib/tmux-lite/crypto/identity.test.ts +220 -0
  179. package/src/lib/tmux-lite/crypto/identity.ts +286 -0
  180. package/src/lib/tmux-lite/crypto/index.ts +51 -0
  181. package/src/lib/tmux-lite/crypto/invites.test.ts +381 -0
  182. package/src/lib/tmux-lite/crypto/invites.ts +215 -0
  183. package/src/lib/tmux-lite/crypto/keyexchange.ts +435 -0
  184. package/src/lib/tmux-lite/crypto/keys.test.ts +58 -0
  185. package/src/lib/tmux-lite/crypto/keys.ts +47 -0
  186. package/src/lib/tmux-lite/crypto/secretbox.test.ts +169 -0
  187. package/src/lib/tmux-lite/crypto/secretbox.ts +124 -0
  188. package/src/lib/tmux-lite/handshake-handler.ts +451 -0
  189. package/src/lib/tmux-lite/protocol.test.ts +307 -0
  190. package/src/lib/tmux-lite/protocol.ts +266 -0
  191. package/src/lib/tmux-lite/relay-client.ts +506 -0
  192. package/src/lib/tmux-lite/server.ts +1250 -0
  193. package/src/lib/tmux-lite/shell-integration.sh +37 -0
  194. package/src/lib/tmux-lite/terminal-queries.test.ts +54 -0
  195. package/src/lib/tmux-lite/terminal-queries.ts +49 -0
  196. package/src/relay/__tests__/e2e-flow.test.ts +1284 -0
  197. package/src/relay/__tests__/helpers/auth.ts +354 -0
  198. package/src/relay/__tests__/helpers/ports.ts +51 -0
  199. package/src/relay/__tests__/protocol-validation.test.ts +265 -0
  200. package/src/relay/authorization.ts +303 -0
  201. package/src/relay/embedded-assets.generated.d.ts +15 -0
  202. package/src/relay/identity.ts +352 -0
  203. package/src/relay/index.ts +57 -0
  204. package/src/relay/pipes.test.ts +427 -0
  205. package/src/relay/pipes.ts +195 -0
  206. package/src/relay/protocol.ts +804 -0
  207. package/src/relay/registries.test.ts +437 -0
  208. package/src/relay/registries.ts +593 -0
  209. package/src/relay/server.test.ts +1323 -0
  210. package/src/relay/server.ts +1092 -0
  211. package/src/relay/signing.ts +238 -0
  212. package/src/relay/types.ts +69 -0
  213. package/src/serve/client-session-manager.ts +622 -0
  214. package/src/serve/daemon.ts +497 -0
  215. package/src/serve/pty-session.ts +236 -0
  216. package/src/serve/types.ts +169 -0
  217. package/src/shared/components/Flow.tsx +453 -0
  218. package/src/shared/components/Flow.tui.tsx +343 -0
  219. package/src/shared/components/Flow.web.tsx +442 -0
  220. package/src/shared/components/Inbox.tsx +446 -0
  221. package/src/shared/components/Inbox.tui.tsx +262 -0
  222. package/src/shared/components/Inbox.web.tsx +329 -0
  223. package/src/shared/components/MachineList.tsx +187 -0
  224. package/src/shared/components/MachineList.tui.tsx +161 -0
  225. package/src/shared/components/MachineList.web.tsx +210 -0
  226. package/src/shared/components/ProjectList.tsx +176 -0
  227. package/src/shared/components/ProjectList.tui.tsx +109 -0
  228. package/src/shared/components/ProjectList.web.tsx +143 -0
  229. package/src/shared/components/SpacesBrowser.tsx +332 -0
  230. package/src/shared/components/SpacesBrowser.tui.tsx +163 -0
  231. package/src/shared/components/SpacesBrowser.web.tsx +221 -0
  232. package/src/shared/components/index.ts +103 -0
  233. package/src/shared/hooks/index.ts +16 -0
  234. package/src/shared/hooks/useNavigation.ts +226 -0
  235. package/src/shared/index.ts +122 -0
  236. package/src/shared/providers/LocalMachineProvider.ts +425 -0
  237. package/src/shared/providers/MachineProvider.ts +165 -0
  238. package/src/shared/providers/RemoteMachineProvider.ts +444 -0
  239. package/src/shared/providers/index.ts +26 -0
  240. package/src/shared/types.ts +145 -0
  241. package/src/tui/adapters.ts +120 -0
  242. package/src/tui/app.tsx +1816 -0
  243. package/src/tui/components/Terminal.tsx +580 -0
  244. package/src/tui/hooks/index.ts +35 -0
  245. package/src/tui/hooks/useAppState.ts +314 -0
  246. package/src/tui/hooks/useDaemonStatus.ts +174 -0
  247. package/src/tui/hooks/useInboxTUI.ts +113 -0
  248. package/src/tui/hooks/useRemoteMachines.ts +209 -0
  249. package/src/tui/index.ts +24 -0
  250. package/src/tui/state.ts +299 -0
  251. package/src/tui/terminal-bracketed-paste.test.ts +45 -0
  252. package/src/tui/terminal-bracketed-paste.ts +47 -0
  253. package/src/types/bundle.ts +112 -0
  254. package/src/types/config.ts +89 -0
  255. package/src/types/errors.ts +206 -0
  256. package/src/types/identity.ts +284 -0
  257. package/src/types/workspace-fuzzy.ts +49 -0
  258. package/src/types/workspace.ts +151 -0
  259. package/src/utils/bun-socket-writer.ts +80 -0
  260. package/src/utils/deps.ts +127 -0
  261. package/src/utils/fuzzy-match.ts +125 -0
  262. package/src/utils/logger.ts +127 -0
  263. package/src/utils/markdown.ts +254 -0
  264. package/src/utils/onboarding.ts +229 -0
  265. package/src/utils/prompts.ts +114 -0
  266. package/src/utils/run-commands.ts +112 -0
  267. package/src/utils/run-scripts.ts +142 -0
  268. package/src/utils/sanitize.ts +98 -0
  269. package/src/utils/secrets.ts +122 -0
  270. package/src/utils/shell-escape.ts +40 -0
  271. package/src/utils/utf8.ts +79 -0
  272. package/src/utils/workspace-state.ts +47 -0
  273. package/src/web/README.md +73 -0
  274. package/src/web/bun.lock +575 -0
  275. package/src/web/eslint.config.js +23 -0
  276. package/src/web/index.html +16 -0
  277. package/src/web/package.json +37 -0
  278. package/src/web/public/vite.svg +1 -0
  279. package/src/web/src/App.tsx +604 -0
  280. package/src/web/src/assets/react.svg +1 -0
  281. package/src/web/src/components/Terminal.tsx +207 -0
  282. package/src/web/src/hooks/useRelayConnection.ts +224 -0
  283. package/src/web/src/hooks/useTerminal.ts +699 -0
  284. package/src/web/src/index.css +55 -0
  285. package/src/web/src/lib/crypto/__tests__/web-terminal.test.ts +1158 -0
  286. package/src/web/src/lib/crypto/frames.ts +205 -0
  287. package/src/web/src/lib/crypto/handshake.ts +396 -0
  288. package/src/web/src/lib/crypto/identity.ts +128 -0
  289. package/src/web/src/lib/crypto/keyexchange.ts +246 -0
  290. package/src/web/src/lib/crypto/relay-signing.ts +53 -0
  291. package/src/web/src/lib/invite.ts +58 -0
  292. package/src/web/src/lib/storage/identity-store.ts +94 -0
  293. package/src/web/src/main.tsx +10 -0
  294. package/src/web/src/types/identity.ts +45 -0
  295. package/src/web/tsconfig.app.json +28 -0
  296. package/src/web/tsconfig.json +7 -0
  297. package/src/web/tsconfig.node.json +26 -0
  298. package/src/web/vite.config.ts +31 -0
  299. package/todo-security.md +92 -0
  300. package/tsconfig.json +23 -0
  301. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite +0 -0
  302. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-shm +0 -0
  303. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-wal +0 -0
  304. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite +0 -0
  305. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-shm +0 -0
  306. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-wal +0 -0
  307. package/worker/bun.lock +237 -0
  308. package/worker/package.json +22 -0
  309. package/worker/schema.sql +96 -0
  310. package/worker/src/handlers/auth.ts +451 -0
  311. package/worker/src/handlers/subdomains.ts +376 -0
  312. package/worker/src/handlers/user.ts +98 -0
  313. package/worker/src/index.ts +70 -0
  314. package/worker/src/middleware/auth.ts +152 -0
  315. package/worker/src/services/cloudflare.ts +609 -0
  316. package/worker/src/types.ts +96 -0
  317. package/worker/tsconfig.json +15 -0
  318. package/worker/wrangler.toml +26 -0
@@ -0,0 +1,622 @@
1
+ /**
2
+ * Client session manager for the serve daemon
3
+ *
4
+ * Manages multiple concurrent client connections:
5
+ * - Routes handshake messages to HandshakeHandler
6
+ * - After handshake, enters "browsing" mode for workspace/session listing
7
+ * - Spawns PTY sessions when client attaches to a session
8
+ * - Routes encrypted frames between clients and PTY sessions
9
+ * - Handles disconnect cleanup
10
+ */
11
+
12
+ import { HandshakeHandler, type HandshakeMessage, type EstablishedSession } from "../lib/tmux-lite/handshake-handler.js";
13
+ import { PTYSession } from "./pty-session.js";
14
+ import { createFrame, openFrame, MASTER_STREAM_ID } from "../lib/tmux-lite/crypto/frames.js";
15
+ import { encodeControl, encodePTY, parseFrames, decodeControl, FrameType, type SessionEvent } from "../lib/tmux-lite/protocol.js";
16
+ import { RemoteSessionHandler, type RemoteClientSession } from "../lib/remote-session/index.js";
17
+ import { STREAM_ID, canWrite, type ServeOptions, type ClientSession, type ServeEventHandler, type HandshakeMessageEnvelope } from "./types.js";
18
+ import { createBufferedSocketWriter } from "../utils/bun-socket-writer.js";
19
+
20
+ // ============================================================================
21
+ // ClientSessionManager Class
22
+ // ============================================================================
23
+
24
+ /**
25
+ * Manages client sessions for the serve daemon
26
+ *
27
+ * @example
28
+ * ```typescript
29
+ * const manager = new ClientSessionManager({
30
+ * relay: "wss://relay.example.com",
31
+ * identity: machineIdentity,
32
+ * accessList: acl,
33
+ * });
34
+ *
35
+ * manager.onEvent((event) => {
36
+ * if (event.type === "client_authenticated") {
37
+ * console.log(`Client ${event.identityId} connected`);
38
+ * }
39
+ * });
40
+ *
41
+ * // Handle incoming message
42
+ * const response = await manager.handleMessage(connectionId, data);
43
+ * if (response) {
44
+ * relay.send(connectionId, response);
45
+ * }
46
+ * ```
47
+ */
48
+ export class ClientSessionManager {
49
+ private sessions: Map<string, ClientSession> = new Map();
50
+ private handshakeHandler: HandshakeHandler;
51
+ private remoteSessionHandler: RemoteSessionHandler;
52
+ private options: ServeOptions;
53
+ private eventHandler: ServeEventHandler | null = null;
54
+
55
+ constructor(options: ServeOptions) {
56
+ this.options = options;
57
+ this.handshakeHandler = new HandshakeHandler({
58
+ identity: options.identity,
59
+ accessList: options.accessList,
60
+ handshakeTimeoutMs: options.handshakeTimeoutMs,
61
+ });
62
+ this.remoteSessionHandler = new RemoteSessionHandler();
63
+ }
64
+
65
+ private writeToTmuxSocket(session: ClientSession, frame: Buffer): void {
66
+ if (session.tmuxSocketWriter) {
67
+ session.tmuxSocketWriter.write(frame);
68
+ return;
69
+ }
70
+ session.tmuxSocket?.write(frame);
71
+ }
72
+
73
+ /**
74
+ * Initialize async resources (like tmux-lite connection)
75
+ */
76
+ async initialize(): Promise<void> {
77
+ await this.remoteSessionHandler.initialize();
78
+ }
79
+
80
+ /**
81
+ * Set event handler for session events
82
+ */
83
+ onEvent(handler: ServeEventHandler): void {
84
+ this.eventHandler = handler;
85
+ }
86
+
87
+ /**
88
+ * Emit an event
89
+ */
90
+ private emit(event: Parameters<ServeEventHandler>[0]): void {
91
+ this.eventHandler?.(event);
92
+ }
93
+
94
+ /**
95
+ * Get number of active sessions
96
+ */
97
+ get activeSessionCount(): number {
98
+ return this.sessions.size;
99
+ }
100
+
101
+ /**
102
+ * Get number of established sessions (post-handshake: browsing or attached)
103
+ */
104
+ get establishedSessionCount(): number {
105
+ let count = 0;
106
+ for (const session of this.sessions.values()) {
107
+ if (session.state === "browsing" || session.state === "attached") count++;
108
+ }
109
+ return count;
110
+ }
111
+
112
+ /**
113
+ * Get session by connection ID
114
+ */
115
+ getSession(connectionId: string): ClientSession | undefined {
116
+ return this.sessions.get(connectionId);
117
+ }
118
+
119
+ /**
120
+ * Get all sessions
121
+ */
122
+ getAllSessions(): ClientSession[] {
123
+ return Array.from(this.sessions.values());
124
+ }
125
+
126
+ /**
127
+ * Handle a new client connection
128
+ */
129
+ handleConnect(connectionId: string): void {
130
+ // Create new session in handshaking state
131
+ const session: ClientSession = {
132
+ connectionId,
133
+ state: "handshaking",
134
+ handshakeStartedAt: Date.now(),
135
+ };
136
+ this.sessions.set(connectionId, session);
137
+
138
+ this.emit({ type: "client_connected", connectionId });
139
+ }
140
+
141
+ /**
142
+ * Handle incoming message from a client
143
+ *
144
+ * Routes to handshake handler or PTY session based on state.
145
+ *
146
+ * @param connectionId - Client connection ID
147
+ * @param data - Raw message data
148
+ * @returns Response to send back (if any)
149
+ */
150
+ async handleMessage(
151
+ connectionId: string,
152
+ data: Uint8Array
153
+ ): Promise<Uint8Array | null> {
154
+ let session = this.sessions.get(connectionId);
155
+
156
+ // New connection - create session
157
+ if (!session) {
158
+ this.handleConnect(connectionId);
159
+ session = this.sessions.get(connectionId)!;
160
+ }
161
+
162
+ // Handle based on session state
163
+ if (session.state === "handshaking") {
164
+ return this.handleHandshakeMessage(connectionId, session, data);
165
+ }
166
+
167
+ if (session.state === "browsing") {
168
+ // Handle browse commands (list_workspaces, list_sessions, attach_session, etc.)
169
+ return this.handleBrowseMessage(connectionId, session, data);
170
+ }
171
+
172
+ if (session.state === "attached" && session.tmuxSocket) {
173
+ // Decrypt and route to tmux-lite session based on stream ID
174
+ return this.handleAttachedMessage(connectionId, session, data);
175
+ }
176
+
177
+ if (session.state === "attached" && session.ptySession) {
178
+ // Legacy: Forward encrypted data to PTY
179
+ session.ptySession.write(Buffer.from(data));
180
+ return null;
181
+ }
182
+
183
+ // Invalid state
184
+ console.warn(`[session-manager] Message in invalid state: ${session.state}`);
185
+ return null;
186
+ }
187
+
188
+ /**
189
+ * Handle message in attached state - route to tmux-lite session based on stream ID
190
+ */
191
+ private async handleAttachedMessage(
192
+ connectionId: string,
193
+ session: ClientSession,
194
+ data: Uint8Array
195
+ ): Promise<Uint8Array | null> {
196
+ if (!session.sessionKeys || !session.tmuxSocket) {
197
+ console.error("[session-manager] handleAttachedMessage: missing sessionKeys or tmuxSocket");
198
+ return null;
199
+ }
200
+
201
+ try {
202
+ // Decrypt the frame
203
+ const result = openFrame(data, session.sessionKeys.receiveKey);
204
+ if (!result) {
205
+ console.error("[session-manager] Failed to decrypt attached frame");
206
+ return null;
207
+ }
208
+
209
+ // Debug: console.log(`[session-manager] Attached message: streamId=${result.streamId}, dataLen=${result.data.length}`);
210
+
211
+ if (result.streamId === STREAM_ID.CONTROL) {
212
+ // Control message (resize, detach) - parse and encode for tmux-lite protocol
213
+ const msg = JSON.parse(new TextDecoder().decode(result.data));
214
+ console.log(`[session-manager] Control message: ${msg.type}`);
215
+
216
+ if (msg.type === "detach") {
217
+ // Handle detach specially - close tmux socket and send response to client
218
+ // Store socket reference and clear it BEFORE ending to prevent close callback
219
+ // from triggering handleDisconnect
220
+ const socket = session.tmuxSocket;
221
+ const writer = session.tmuxSocketWriter;
222
+ session.tmuxSocket = undefined;
223
+ session.tmuxSocketWriter = undefined;
224
+ session.state = "browsing";
225
+ session.attachedSessionId = undefined;
226
+ session.sessionSocketPath = undefined;
227
+ session.waitingForResize = undefined;
228
+ session.frameBuffer = undefined;
229
+
230
+ // Now send detach and close the socket (using framed protocol)
231
+ {
232
+ const frame = encodeControl(msg);
233
+ if (writer) writer.write(frame);
234
+ else socket.write(frame);
235
+ }
236
+ socket.end();
237
+
238
+ // Send detached response to client
239
+ const detachedMsg = JSON.stringify({ type: "detached" });
240
+ const detachedData = new TextEncoder().encode(detachedMsg);
241
+ const frame = createFrame(STREAM_ID.DATA, detachedData, session.sessionKeys.sendKey);
242
+ console.log("[session-manager] Sent detached response, returning to browsing mode");
243
+ return frame;
244
+ }
245
+
246
+ if (msg.type === "resize" && session.waitingForResize) {
247
+ // First resize - send attach-init with actual dimensions
248
+ console.log(`[session-manager] First resize: ${msg.cols}x${msg.rows} - sending attach-init`);
249
+ session.waitingForResize = false;
250
+ this.writeToTmuxSocket(session, encodeControl({ type: "attach-init", cols: msg.cols, rows: msg.rows, clientType: "web" }));
251
+ return null; // attach-init handles the resize
252
+ }
253
+
254
+ // Other control messages (resize after init) - encode for tmux-lite and send
255
+ this.writeToTmuxSocket(session, encodeControl(msg));
256
+ } else {
257
+ // Raw PTY input (STREAM_ID.DATA) - send directly to socket
258
+ // Security: Check write permission before forwarding input
259
+ if (!canWrite(session.accessType)) {
260
+ console.warn(`[session-manager] Read-only client ${connectionId} attempted PTY write - denied`);
261
+ return null; // Silently drop input from read-only clients
262
+ }
263
+
264
+ // Only forward if we've sent attach-init (waitingForResize is false)
265
+ if (!session.waitingForResize) {
266
+ // Wrap PTY data in a frame for the framed protocol
267
+ this.writeToTmuxSocket(session, encodePTY(result.data));
268
+ } else {
269
+ console.warn("[session-manager] Ignoring PTY data before attach-init");
270
+ }
271
+ }
272
+
273
+ return null;
274
+ } catch (e) {
275
+ console.error("[session-manager] Error handling attached message:", e);
276
+ return null;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Handle browse message (encrypted command in browsing state)
282
+ */
283
+ private async handleBrowseMessage(
284
+ connectionId: string,
285
+ session: ClientSession,
286
+ data: Uint8Array
287
+ ): Promise<Uint8Array | null> {
288
+ if (!session.sessionKeys) {
289
+ console.error("[session-manager] No session keys for browse message");
290
+ return null;
291
+ }
292
+
293
+ // Create RemoteClientSession adapter for the handler
294
+ const remoteSession: RemoteClientSession = {
295
+ connectionId,
296
+ state: "browsing",
297
+ sessionKeys: session.sessionKeys,
298
+ accessType: session.accessType,
299
+ grantedSessionId: session.sessionId,
300
+ };
301
+
302
+ // Create send callback that captures the raw encrypted response
303
+ // Don't wrap in JSON here - serve.ts handles the relay envelope
304
+ let responseData: Uint8Array | null = null;
305
+ const sendResponse = (encryptedFrame: Uint8Array) => {
306
+ responseData = encryptedFrame;
307
+ };
308
+
309
+ // Handle the message through RemoteSessionHandler
310
+ await this.remoteSessionHandler.handleMessage(remoteSession, data, sendResponse);
311
+
312
+ // Check if we're now attached (after attach_session command)
313
+ if (remoteSession.state === "attached" && remoteSession.attachedSessionId) {
314
+ session.state = "attached";
315
+ session.attachedSessionId = remoteSession.attachedSessionId;
316
+ session.sessionSocketPath = remoteSession.sessionSocketPath;
317
+
318
+ // Connect to tmux-lite session socket for PTY I/O
319
+ await this.attachToTmuxLiteSession(connectionId, session);
320
+ }
321
+
322
+ return responseData;
323
+ }
324
+
325
+ /**
326
+ * Handle handshake message
327
+ */
328
+ private async handleHandshakeMessage(
329
+ connectionId: string,
330
+ session: ClientSession,
331
+ data: Uint8Array
332
+ ): Promise<Uint8Array | null> {
333
+ try {
334
+ // Parse as JSON handshake message
335
+ const jsonStr = new TextDecoder().decode(data);
336
+ const envelope = JSON.parse(jsonStr) as HandshakeMessageEnvelope;
337
+
338
+ if (envelope.type !== "handshake") {
339
+ console.warn(`[session-manager] Expected handshake, got: ${envelope.type}`);
340
+ return null;
341
+ }
342
+
343
+ // Process through HandshakeHandler
344
+ const result = await this.handshakeHandler.processMessage(connectionId, envelope as HandshakeMessage);
345
+
346
+ switch (result.type) {
347
+ case "reply": {
348
+ // Send reply back to client
349
+ return new TextEncoder().encode(JSON.stringify(result.message));
350
+ }
351
+
352
+ case "established": {
353
+ // Handshake complete - spawn PTY and send ServerAuth
354
+ return this.handleHandshakeEstablished(connectionId, session, result.session, result.message);
355
+ }
356
+
357
+ case "error": {
358
+ console.error(`[session-manager] Handshake error: ${result.reason}`);
359
+ this.emit({ type: "error", connectionId, error: new Error(result.reason) });
360
+
361
+ if (result.close) {
362
+ this.handleDisconnect(connectionId, result.reason);
363
+ }
364
+ return null;
365
+ }
366
+ }
367
+ } catch (e) {
368
+ console.error("[session-manager] Handshake message parse error:", e);
369
+ this.emit({
370
+ type: "error",
371
+ connectionId,
372
+ error: new Error(`Invalid handshake message: ${e instanceof Error ? e.message : String(e)}`),
373
+ });
374
+ return null;
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Handle successful handshake - enter browsing mode
380
+ */
381
+ private handleHandshakeEstablished(
382
+ connectionId: string,
383
+ session: ClientSession,
384
+ established: EstablishedSession,
385
+ serverAuthMessage: HandshakeMessage
386
+ ): Uint8Array | null {
387
+ // Update session state - enter browsing mode (not spawning PTY yet)
388
+ session.state = "browsing";
389
+ session.sessionKeys = established.sessionKeys;
390
+ session.accessType = established.accessType;
391
+ session.sessionId = established.sessionId;
392
+ session.peerIdentityId = established.peerIdentityId;
393
+
394
+ // Emit event
395
+ this.emit({
396
+ type: "client_authenticated",
397
+ connectionId,
398
+ identityId: established.peerIdentityId,
399
+ accessType: established.accessType,
400
+ sessionId: established.sessionId,
401
+ });
402
+
403
+ // Client can now send list_workspaces, list_sessions, attach_session commands
404
+ // PTY will be spawned when attach_session is received
405
+
406
+ // Return ServerAuth message from HandshakeHandler
407
+ return new TextEncoder().encode(JSON.stringify(serverAuthMessage));
408
+ }
409
+
410
+ /**
411
+ * Spawn PTY session for an established connection
412
+ */
413
+ private spawnPTYSession(connectionId: string, session: ClientSession): void {
414
+ if (!session.sessionKeys) {
415
+ console.error("[session-manager] Cannot spawn PTY: no session keys");
416
+ return;
417
+ }
418
+
419
+ // Callback to send encrypted data to client
420
+ const sendToClient = this.createSendCallback(connectionId);
421
+
422
+ session.ptySession = new PTYSession({
423
+ shell: this.options.shell,
424
+ env: {
425
+ ...this.options.env,
426
+ SPACES_PEER_ID: session.peerIdentityId ?? "",
427
+ },
428
+ sessionKeys: session.sessionKeys,
429
+ onData: (encrypted) => {
430
+ sendToClient(encrypted);
431
+ },
432
+ onClose: (exitCode) => {
433
+ console.log(`[session-manager] PTY exited: ${exitCode}`);
434
+ this.handleDisconnect(connectionId, `PTY exited with code ${exitCode}`);
435
+ },
436
+ });
437
+
438
+ console.log(`[session-manager] PTY spawned for ${connectionId} (pid: ${session.ptySession.pid})`);
439
+ }
440
+
441
+ /**
442
+ * Attach to a tmux-lite session socket for PTY I/O
443
+ * This is the proper way to connect - through the existing tmux-lite session
444
+ */
445
+ private async attachToTmuxLiteSession(connectionId: string, session: ClientSession): Promise<void> {
446
+ if (!session.sessionKeys || !session.sessionSocketPath) {
447
+ console.error("[session-manager] Cannot attach: missing session keys or socket path");
448
+ return;
449
+ }
450
+
451
+ const sendToClient = this.createSendCallback(connectionId);
452
+
453
+ try {
454
+ // Connect to tmux-lite session socket
455
+ const socket = await Bun.connect({
456
+ unix: session.sessionSocketPath,
457
+ socket: {
458
+ drain: () => {
459
+ session.tmuxSocketWriter?.flush();
460
+ },
461
+ data: (sock, data) => {
462
+ if (!session.sessionKeys) return;
463
+
464
+ // Accumulate in frame buffer (for handling partial frames)
465
+ const prev = session.frameBuffer || Buffer.alloc(0);
466
+ const buf = Buffer.concat([prev, Buffer.from(data)]);
467
+
468
+ // Parse frames from the accumulated buffer
469
+ let frames;
470
+ let remaining;
471
+ try {
472
+ const result = parseFrames(buf);
473
+ frames = result.frames;
474
+ remaining = result.remaining;
475
+ } catch (err) {
476
+ // Protocol error - likely desync or corrupted data
477
+ const msg = err instanceof Error ? err.message : 'Frame parse error';
478
+ console.error(`[session-manager] Frame parse error: ${msg}`);
479
+ this.handleDisconnect(connectionId, `Frame parse error: ${msg}`);
480
+ return;
481
+ }
482
+ // Copy remaining bytes - subarray references can become invalid when Bun reuses buffers
483
+ session.frameBuffer = Buffer.from(remaining);
484
+
485
+ for (const frame of frames) {
486
+ if (frame.type === FrameType.CONTROL) {
487
+ // Decode and handle control events
488
+ const event = decodeControl(frame.payload) as SessionEvent;
489
+
490
+ if (event.type === "exited") {
491
+ console.log(`[session-manager] Session exited: ${event.code}`);
492
+ // Send exit notification to client
493
+ const exitMsg = JSON.stringify({ type: "session_exited", sessionId: session.attachedSessionId, exitCode: event.code });
494
+ const exitData = new TextEncoder().encode(exitMsg);
495
+ const encFrame = createFrame(STREAM_ID.DATA, exitData, session.sessionKeys.sendKey);
496
+ sendToClient(Buffer.from(encFrame));
497
+ this.handleDisconnect(connectionId, `Session exited with code ${event.code}`);
498
+ return;
499
+ } else if (event.type === "kicked") {
500
+ console.log("[session-manager] Session kicked");
501
+ this.handleDisconnect(connectionId, "Session kicked");
502
+ return;
503
+ }
504
+ // Ignore attach-ready and attached - handled by client
505
+ } else if (frame.type === FrameType.PTY) {
506
+ // Forward PTY data to web client
507
+ const encFrame = createFrame(STREAM_ID.DATA, frame.payload, session.sessionKeys.sendKey);
508
+ sendToClient(Buffer.from(encFrame));
509
+ }
510
+ }
511
+ },
512
+
513
+ close: () => {
514
+ // Check if this was a voluntary detach (tmuxSocket already cleared)
515
+ // vs an unexpected close
516
+ if (session.tmuxSocket) {
517
+ console.log("[session-manager] tmux-lite socket closed unexpectedly");
518
+ this.handleDisconnect(connectionId, "Session closed");
519
+ } else {
520
+ console.log("[session-manager] tmux-lite socket closed (detached)");
521
+ }
522
+ },
523
+
524
+ error: (_, e) => {
525
+ console.error("[session-manager] tmux-lite socket error:", e);
526
+ this.handleDisconnect(connectionId, e.message);
527
+ },
528
+ }
529
+ });
530
+
531
+ // Store socket reference
532
+ session.tmuxSocket = socket;
533
+ session.tmuxSocketWriter = createBufferedSocketWriter(socket);
534
+
535
+ // Don't send attach-init yet - wait for the first resize from client
536
+ // This ensures tmux-lite receives the actual terminal dimensions
537
+ session.waitingForResize = true;
538
+
539
+ console.log(`[session-manager] Connected to tmux-lite session: ${session.sessionSocketPath} (waiting for resize)`);
540
+ } catch (e) {
541
+ console.error("[session-manager] Failed to connect to tmux-lite session:", e);
542
+ this.handleDisconnect(connectionId, "Failed to connect to session");
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Create a callback to send data to a specific client
548
+ *
549
+ * This is set by the serve command to route through the relay.
550
+ */
551
+ private sendCallbacks: Map<string, (data: Buffer) => void> = new Map();
552
+
553
+ /**
554
+ * Register a send callback for a connection
555
+ */
556
+ setSendCallback(connectionId: string, callback: (data: Buffer) => void): void {
557
+ this.sendCallbacks.set(connectionId, callback);
558
+ }
559
+
560
+ /**
561
+ * Create send callback for a connection
562
+ */
563
+ private createSendCallback(connectionId: string): (data: Buffer) => void {
564
+ return (data: Buffer) => {
565
+ const callback = this.sendCallbacks.get(connectionId);
566
+ if (callback) {
567
+ callback(data);
568
+ } else {
569
+ console.warn(`[session-manager] No send callback for ${connectionId}`);
570
+ }
571
+ };
572
+ }
573
+
574
+ /**
575
+ * Handle client disconnect
576
+ */
577
+ handleDisconnect(connectionId: string, reason: string = "disconnected"): void {
578
+ const session = this.sessions.get(connectionId);
579
+ if (!session) return;
580
+
581
+ // Close tmux-lite socket if active
582
+ if (session.tmuxSocket) {
583
+ try {
584
+ // Send detach message before closing (using framed protocol)
585
+ this.writeToTmuxSocket(session, encodeControl({ type: "detach" }));
586
+ session.tmuxSocket.end();
587
+ } catch {
588
+ // Socket may already be closed
589
+ }
590
+ session.tmuxSocket = undefined;
591
+ session.tmuxSocketWriter = undefined;
592
+ session.frameBuffer = undefined;
593
+ }
594
+
595
+ // Close PTY if active (legacy)
596
+ if (session.ptySession && !session.ptySession.isClosed) {
597
+ session.ptySession.close();
598
+ }
599
+
600
+ // Cleanup handshake state
601
+ this.handshakeHandler.cleanup(connectionId);
602
+
603
+ // Remove send callback
604
+ this.sendCallbacks.delete(connectionId);
605
+
606
+ // Remove session
607
+ session.state = "closed";
608
+ this.sessions.delete(connectionId);
609
+
610
+ this.emit({ type: "client_disconnected", connectionId, reason });
611
+ }
612
+
613
+ /**
614
+ * Clean up all sessions
615
+ */
616
+ async cleanup(): Promise<void> {
617
+ for (const [connectionId] of this.sessions) {
618
+ this.handleDisconnect(connectionId, "server shutdown");
619
+ }
620
+ await this.remoteSessionHandler.cleanup();
621
+ }
622
+ }