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,699 @@
1
+ /**
2
+ * Hook for terminal connection to relay with X3DH handshake and E2E encryption
3
+ *
4
+ * Supports two modes after handshake:
5
+ * - "browsing": List workspaces and sessions
6
+ * - "attached": Connected to a PTY session
7
+ */
8
+
9
+ import { useState, useCallback, useRef, useEffect } from "react";
10
+ import {
11
+ createClientHello,
12
+ processServerHello,
13
+ createClientAuth,
14
+ processServerAuth,
15
+ isX3DHResponseMessage,
16
+ isX3DHResultMessage,
17
+ type X3DHClientState,
18
+ } from "../lib/crypto/handshake";
19
+ import { createFrame, openFrame, MASTER_STREAM_ID } from "../lib/crypto/frames";
20
+ import { signRelayMessage } from "../lib/crypto/relay-signing";
21
+ import type { Identity, SessionKeys } from "../types/identity";
22
+ import type { InboxItem } from "../../../lib/remote-session/protocol";
23
+ import { findUtf8Boundary } from "../../../utils/utf8";
24
+
25
+ /** Stream ID for control messages (resize, detach, etc.) */
26
+ const CONTROL_STREAM_ID = 1;
27
+
28
+ export type ConnectionStatus =
29
+ | "disconnected"
30
+ | "connecting"
31
+ | "connected"
32
+ | "handshaking"
33
+ | "established"
34
+ | "error";
35
+
36
+ /** Mode after handshake is established */
37
+ export type SessionMode = "browsing" | "attached";
38
+
39
+ /** Workspace information from machine */
40
+ export interface WorkspaceInfo {
41
+ id: string;
42
+ name: string;
43
+ path: string;
44
+ projectName: string;
45
+ branch?: string;
46
+ sessionCount: number;
47
+ isStale?: boolean;
48
+ }
49
+
50
+ /** Session information from machine */
51
+ export interface SessionInfo {
52
+ id: string;
53
+ name: string;
54
+ workspaceId: string;
55
+ attached: boolean;
56
+ createdAt: number;
57
+ processTitle?: string;
58
+ exitCode?: number;
59
+ }
60
+
61
+ /** Project information from machine */
62
+ export interface ProjectInfo {
63
+ name: string;
64
+ repository: string;
65
+ workspaceCount: number;
66
+ isCurrent: boolean;
67
+ }
68
+
69
+ interface ConnectionParams {
70
+ ws: WebSocket; // Existing WebSocket from relay connection
71
+ identity: Identity; // Client identity
72
+ machineId: string;
73
+ inviteId?: string; // Short hash for relay lookup (connect_with_invite)
74
+ inviteToken?: string; // Full invite token for X3DH authorization
75
+ }
76
+
77
+ export function useTerminal() {
78
+ const [status, setStatus] = useState<ConnectionStatus>("disconnected");
79
+ const [mode, setMode] = useState<SessionMode>("browsing");
80
+ const [projects, setProjects] = useState<ProjectInfo[]>([]);
81
+ const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>([]);
82
+ const [sessions, setSessions] = useState<SessionInfo[]>([]);
83
+ const [attachedSessionId, setAttachedSessionId] = useState<string | null>(null);
84
+ const [attachedSessionName, setAttachedSessionName] = useState<string | null>(null);
85
+ const [selectedProjectName, setSelectedProjectName] = useState<string | null>(null);
86
+ const [inbox, setInbox] = useState<InboxItem[]>([]);
87
+ const [inboxUnreadCount, setInboxUnreadCount] = useState(0);
88
+
89
+ const wsRef = useRef<WebSocket | null>(null);
90
+ const identityRef = useRef<Identity | null>(null);
91
+ const sessionKeysRef = useRef<SessionKeys | null>(null);
92
+ const handshakeStateRef = useRef<X3DHClientState | null>(null);
93
+ const writeCallbackRef = useRef<((data: Uint8Array) => void) | null>(null);
94
+ const connectionParamsRef = useRef<ConnectionParams | null>(null);
95
+ const modeRef = useRef<SessionMode>("browsing"); // For use in callbacks
96
+ const utf8BufferRef = useRef<Uint8Array>(new Uint8Array(0)); // Buffer for incomplete UTF-8 sequences
97
+ const handleDataMessageRef = useRef<((data: string) => void) | null>(null); // For use in handleMessage
98
+
99
+ const connect = useCallback(async (params: ConnectionParams) => {
100
+ try {
101
+ setStatus("connecting");
102
+ connectionParamsRef.current = params;
103
+
104
+ // Use the passed WebSocket and identity (from relay connection)
105
+ const { ws, identity } = params;
106
+ wsRef.current = ws;
107
+ identityRef.current = identity;
108
+
109
+ // Take over message handling for this WebSocket
110
+ ws.onmessage = (event) => {
111
+ handleMessage(event.data);
112
+ };
113
+
114
+ ws.onclose = () => {
115
+ setStatus("disconnected");
116
+ wsRef.current = null;
117
+ sessionKeysRef.current = null;
118
+ handshakeStateRef.current = null;
119
+ };
120
+
121
+ ws.onerror = () => {
122
+ setStatus("error");
123
+ };
124
+
125
+ setStatus("connected");
126
+
127
+ // Send connect request on existing WebSocket
128
+ const connectMsg = params.inviteId
129
+ ? {
130
+ type: "connect_with_invite",
131
+ inviteId: params.inviteId,
132
+ clientIdentityId: identity.id,
133
+ }
134
+ : {
135
+ type: "connect_to_machine",
136
+ machineId: params.machineId,
137
+ clientIdentityId: identity.id,
138
+ };
139
+
140
+ const signed = signRelayMessage(connectMsg, identity);
141
+ ws.send(JSON.stringify(signed));
142
+ } catch (e) {
143
+ console.error("Connection failed:", e);
144
+ setStatus("error");
145
+ }
146
+ }, []);
147
+
148
+ const handleMessage = useCallback((raw: string) => {
149
+ try {
150
+ const msg = JSON.parse(raw);
151
+
152
+ switch (msg.type) {
153
+ case "connection_established":
154
+ console.log("Connection established, starting X3DH handshake...");
155
+ setStatus("handshaking");
156
+ startHandshake();
157
+ break;
158
+
159
+ case "data":
160
+ // All data messages (handshake and encrypted frames) come through here
161
+ // handleDataMessage will parse and route appropriately
162
+ // Use ref to avoid stale closure (handleMessage has [] deps)
163
+ handleDataMessageRef.current?.(msg.data);
164
+ break;
165
+
166
+ case "error":
167
+ console.error("Relay error:", msg.message);
168
+ setStatus("error");
169
+ break;
170
+
171
+ case "pong":
172
+ // Keepalive response - connection is alive (from relay ping)
173
+ break;
174
+
175
+ default:
176
+ console.log("Unknown message type:", msg.type);
177
+ }
178
+ } catch (e) {
179
+ console.error("Failed to parse message:", e);
180
+ }
181
+ }, []);
182
+
183
+ const startHandshake = useCallback(() => {
184
+ if (!wsRef.current || !identityRef.current) return;
185
+
186
+ const machineId = connectionParamsRef.current?.machineId;
187
+ const { state, message } = createClientHello(machineId);
188
+ handshakeStateRef.current = state;
189
+
190
+ // Send ClientHello wrapped in handshake message
191
+ // Format must match HandshakeMessageEnvelope: { type, phase, data }
192
+ wsRef.current.send(JSON.stringify({
193
+ type: "handshake",
194
+ phase: "client_hello",
195
+ data: message,
196
+ }));
197
+ }, []);
198
+
199
+ const handleHandshakeMessage = useCallback((msg: { phase: string; data: Record<string, unknown> }) => {
200
+ const ws = wsRef.current;
201
+ const identity = identityRef.current;
202
+ const state = handshakeStateRef.current;
203
+
204
+ if (!ws || !identity || !state) {
205
+ console.error("Missing handshake prerequisites");
206
+ setStatus("error");
207
+ return;
208
+ }
209
+
210
+ switch (msg.phase) {
211
+ case "server_hello": {
212
+ if (!isX3DHResponseMessage(msg.data)) {
213
+ console.error("Invalid ServerHello message structure");
214
+ setStatus("error");
215
+ return;
216
+ }
217
+ const response = msg.data;
218
+ const newState = processServerHello(state, response);
219
+
220
+ if (!newState) {
221
+ console.error("Failed to process ServerHello");
222
+ setStatus("error");
223
+ return;
224
+ }
225
+
226
+ handshakeStateRef.current = newState;
227
+
228
+ // Create ClientAuth
229
+ // Use inviteToken (full invite token) for X3DH authorization
230
+ // inviteId is only used for relay's connect_with_invite message
231
+ const inviteToken = connectionParamsRef.current?.inviteToken;
232
+ const authorization = inviteToken
233
+ ? { type: "invite" as const, inviteToken }
234
+ : { type: "access_list" as const };
235
+
236
+ const { state: authState, message, sessionKeys } = createClientAuth(
237
+ newState,
238
+ identity,
239
+ authorization
240
+ );
241
+
242
+ handshakeStateRef.current = authState;
243
+ sessionKeysRef.current = sessionKeys;
244
+
245
+ ws.send(JSON.stringify({
246
+ type: "handshake",
247
+ phase: "client_auth",
248
+ data: message,
249
+ }));
250
+ break;
251
+ }
252
+
253
+ case "server_auth": {
254
+ if (!isX3DHResultMessage(msg.data)) {
255
+ console.error("Invalid ServerAuth message structure");
256
+ setStatus("error");
257
+ return;
258
+ }
259
+ const response = msg.data;
260
+ const sessionKeys = sessionKeysRef.current;
261
+
262
+ console.log("ServerAuth response:", response);
263
+ console.log("Current state:", {
264
+ peerIdentityKey: state.peerIdentityKey ? "present" : "missing",
265
+ serverNonce: state.serverNonce ? "present" : "missing",
266
+ clientNonce: state.clientNonce ? "present" : "missing",
267
+ });
268
+
269
+ if (!sessionKeys) {
270
+ console.error("Missing session keys");
271
+ setStatus("error");
272
+ return;
273
+ }
274
+
275
+ const result = processServerAuth(state, response, sessionKeys);
276
+
277
+ if (!result) {
278
+ console.error("Failed to process ServerAuth - response:", response);
279
+ if (response.result?.type === "rejected") {
280
+ console.error("Handshake rejected:", response.result);
281
+ }
282
+ setStatus("error");
283
+ return;
284
+ }
285
+
286
+ console.log("X3DH handshake complete! Peer:", result.peerIdentityId);
287
+ setStatus("established");
288
+ setMode("browsing");
289
+ modeRef.current = "browsing";
290
+
291
+ // Request workspace list now that handshake is complete
292
+ requestWorkspaces();
293
+ break;
294
+ }
295
+
296
+ default:
297
+ console.log("Unknown handshake phase:", msg.phase);
298
+ }
299
+ }, []);
300
+
301
+ /**
302
+ * Send an encrypted JSON command to the machine
303
+ * Uses CONTROL stream ID for proper routing on server side
304
+ */
305
+ const sendCommand = useCallback(async (command: Record<string, unknown>) => {
306
+ const ws = wsRef.current;
307
+ const sessionKeys = sessionKeysRef.current;
308
+
309
+ console.log("[useTerminal] sendCommand called:", command.type, "ws:", !!ws, "wsState:", ws?.readyState, "keys:", !!sessionKeys);
310
+
311
+ if (!ws || ws.readyState !== WebSocket.OPEN || !sessionKeys) {
312
+ console.warn("Cannot send command: not connected");
313
+ return;
314
+ }
315
+
316
+ try {
317
+ const json = JSON.stringify(command);
318
+ const data = new TextEncoder().encode(json);
319
+ const frame = await createFrame(CONTROL_STREAM_ID, data, sessionKeys.sendKey);
320
+ const base64 = btoa(String.fromCharCode(...frame));
321
+ ws.send(JSON.stringify({ type: "data", data: base64 }));
322
+ console.log("[useTerminal] Command sent successfully:", command.type);
323
+ } catch (e) {
324
+ console.error("Failed to send command:", e);
325
+ }
326
+ }, []);
327
+
328
+ /**
329
+ * Request workspace list from machine
330
+ */
331
+ const requestWorkspaces = useCallback(() => {
332
+ sendCommand({ type: "list_workspaces" });
333
+ }, [sendCommand]);
334
+
335
+ /**
336
+ * Request session list from machine
337
+ */
338
+ const requestSessions = useCallback((workspaceId?: string) => {
339
+ sendCommand({ type: "list_sessions", workspaceId });
340
+ }, [sendCommand]);
341
+
342
+ /**
343
+ * Attach to a session (existing or new in workspace)
344
+ */
345
+ const attachSession = useCallback((params: {
346
+ sessionId?: string;
347
+ workspaceId?: string;
348
+ sessionName?: string;
349
+ cols?: number;
350
+ rows?: number;
351
+ }) => {
352
+ console.log("[useTerminal] attachSession:", params);
353
+ sendCommand({ type: "attach_session", ...params });
354
+ }, [sendCommand]);
355
+
356
+ /**
357
+ * Detach from current session (return to browsing)
358
+ */
359
+ const detachSession = useCallback(() => {
360
+ sendCommand({ type: "detach" });
361
+ }, [sendCommand]);
362
+
363
+ /**
364
+ * Request project list from machine
365
+ */
366
+ const requestProjects = useCallback(() => {
367
+ sendCommand({ type: "list_projects" });
368
+ }, [sendCommand]);
369
+
370
+ /**
371
+ * Kill a session
372
+ */
373
+ const killSession = useCallback((sessionId: string) => {
374
+ sendCommand({ type: "kill_session", sessionId });
375
+ }, [sendCommand]);
376
+
377
+ /**
378
+ * Delete a workspace
379
+ */
380
+ const deleteWorkspace = useCallback((projectName: string, workspaceId: string) => {
381
+ sendCommand({ type: "delete_workspace", projectName, workspaceId });
382
+ }, [sendCommand]);
383
+
384
+ /**
385
+ * Resize terminal
386
+ */
387
+ const resize = useCallback((cols: number, rows: number) => {
388
+ sendCommand({ type: "resize", cols, rows });
389
+ }, [sendCommand]);
390
+
391
+ /**
392
+ * Request inbox items from machine
393
+ */
394
+ const requestInbox = useCallback(() => {
395
+ sendCommand({ type: "get_inbox" });
396
+ }, [sendCommand]);
397
+
398
+ /**
399
+ * Clear inbox item(s)
400
+ */
401
+ const clearInboxItem = useCallback((id?: string) => {
402
+ sendCommand({ type: "clear_inbox", id });
403
+ }, [sendCommand]);
404
+
405
+ /**
406
+ * Mark inbox item as read
407
+ */
408
+ const markInboxItemRead = useCallback((id: string) => {
409
+ sendCommand({ type: "mark_inbox_read", id });
410
+ }, [sendCommand]);
411
+
412
+ /**
413
+ * Select a project (for filtering workspaces)
414
+ */
415
+ const selectProject = useCallback((projectName: string | null) => {
416
+ setSelectedProjectName(projectName);
417
+ if (projectName) {
418
+ // Request workspaces for the selected project
419
+ requestWorkspaces();
420
+ }
421
+ }, [requestWorkspaces]);
422
+
423
+ /**
424
+ * Handle a decrypted browse response (workspace_list, session_list, etc.)
425
+ */
426
+ const handleBrowseResponse = useCallback((msg: Record<string, unknown>) => {
427
+ switch (msg.type) {
428
+ case "project_list":
429
+ console.log("[useTerminal] Received project_list:", (msg.projects as ProjectInfo[]).length, "projects");
430
+ setProjects(msg.projects as ProjectInfo[]);
431
+ break;
432
+
433
+ case "workspace_list":
434
+ console.log("[useTerminal] Received workspace_list:", (msg.workspaces as WorkspaceInfo[]).length, "workspaces");
435
+ setWorkspaces(msg.workspaces as WorkspaceInfo[]);
436
+ break;
437
+
438
+ case "session_list":
439
+ setSessions(msg.sessions as SessionInfo[]);
440
+ break;
441
+
442
+ case "session_killed":
443
+ console.log("Session killed:", msg.sessionId, "in workspace:", msg.workspaceId);
444
+ // Refresh workspace list to update session counts
445
+ requestWorkspaces();
446
+ // Also refresh the sessions for that workspace so the killed session disappears
447
+ if (msg.workspaceId && msg.workspaceId !== "unknown") {
448
+ requestSessions(msg.workspaceId as string);
449
+ }
450
+ break;
451
+
452
+ case "workspace_deleted":
453
+ console.log("Workspace deleted:", msg.workspaceId);
454
+ // Refresh workspace list
455
+ requestWorkspaces();
456
+ break;
457
+
458
+ case "attached":
459
+ console.log("Attached to session:", msg.sessionId, msg.sessionName);
460
+ setMode("attached");
461
+ modeRef.current = "attached";
462
+ setAttachedSessionId(msg.sessionId as string);
463
+ setAttachedSessionName(msg.sessionName as string || null);
464
+ break;
465
+
466
+ case "detached":
467
+ console.log("Detached from session");
468
+ setMode("browsing");
469
+ modeRef.current = "browsing";
470
+ setAttachedSessionId(null);
471
+ setAttachedSessionName(null);
472
+ // Refresh workspace list
473
+ requestWorkspaces();
474
+ break;
475
+
476
+ case "session_exited":
477
+ console.log("Session exited:", msg.sessionId, "code:", msg.exitCode);
478
+ setMode("browsing");
479
+ modeRef.current = "browsing";
480
+ setAttachedSessionId(null);
481
+ setAttachedSessionName(null);
482
+ requestWorkspaces();
483
+ break;
484
+
485
+ case "inbox_list":
486
+ setInbox(msg.items as InboxItem[]);
487
+ setInboxUnreadCount(msg.unreadCount as number);
488
+ break;
489
+
490
+ case "inbox_cleared":
491
+ // Refresh inbox after clearing
492
+ requestInbox();
493
+ break;
494
+
495
+ case "inbox_marked_read":
496
+ // Refresh inbox after marking read
497
+ requestInbox();
498
+ break;
499
+
500
+ case "error":
501
+ console.error("Machine error:", msg.code, msg.message);
502
+ break;
503
+
504
+ default:
505
+ console.log("Unknown browse response:", msg.type);
506
+ }
507
+ }, [requestWorkspaces, requestSessions, requestInbox]);
508
+
509
+ /**
510
+ * Write PTY data to terminal with UTF-8 boundary handling
511
+ * Buffers incomplete UTF-8 sequences to prevent garbled output
512
+ */
513
+ const writePtyData = useCallback((data: Uint8Array) => {
514
+ if (!writeCallbackRef.current) return;
515
+
516
+ // Combine with any buffered incomplete UTF-8 bytes
517
+ let combined: Uint8Array;
518
+ if (utf8BufferRef.current.length > 0) {
519
+ combined = new Uint8Array(utf8BufferRef.current.length + data.length);
520
+ combined.set(utf8BufferRef.current, 0);
521
+ combined.set(data, utf8BufferRef.current.length);
522
+ utf8BufferRef.current = new Uint8Array(0);
523
+ } else {
524
+ combined = data;
525
+ }
526
+
527
+ // Find UTF-8 boundary
528
+ const boundary = findUtf8Boundary(combined);
529
+ if (boundary < combined.length) {
530
+ // Buffer incomplete sequence for next time
531
+ utf8BufferRef.current = combined.slice(boundary);
532
+ combined = combined.slice(0, boundary);
533
+ }
534
+
535
+ if (combined.length > 0) {
536
+ writeCallbackRef.current(combined);
537
+ }
538
+ }, []);
539
+
540
+ const handleDataMessage = useCallback(async (base64Data: string) => {
541
+ const sessionKeys = sessionKeysRef.current;
542
+
543
+ try {
544
+ // Decode base64 to bytes
545
+ const bytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
546
+
547
+ // Try to parse as JSON first - could be a handshake message
548
+ // This is important because session keys are set before server_auth arrives
549
+ try {
550
+ const jsonStr = new TextDecoder().decode(bytes);
551
+ const envelope = JSON.parse(jsonStr);
552
+
553
+ if (envelope.type === "handshake") {
554
+ handleHandshakeMessage(envelope);
555
+ return;
556
+ }
557
+ } catch {
558
+ // Not JSON, must be encrypted data - continue to decryption
559
+ }
560
+
561
+ // If no session keys, we can't decrypt
562
+ if (!sessionKeys) {
563
+ console.warn("Received encrypted data before session established");
564
+ return;
565
+ }
566
+
567
+ // Session established - decrypt as encrypted frame
568
+ const result = await openFrame(bytes, sessionKeys.receiveKey);
569
+ if (!result) {
570
+ console.error("Failed to decrypt frame");
571
+ return;
572
+ }
573
+
574
+ // Try to parse as JSON - could be a browse response or PTY output message
575
+ try {
576
+ const jsonStr = new TextDecoder().decode(result.data);
577
+ const msg = JSON.parse(jsonStr);
578
+
579
+ // Check if it's a browse response or pty_output
580
+ if (msg.type === "pty_output") {
581
+ // Decode base64 PTY data and forward to terminal with UTF-8 handling
582
+ const ptyData = Uint8Array.from(atob(msg.data), c => c.charCodeAt(0));
583
+ writePtyData(ptyData);
584
+ return;
585
+ }
586
+
587
+ // Handle as browse response
588
+ handleBrowseResponse(msg);
589
+ return;
590
+ } catch {
591
+ // Not JSON - in attached mode, this is raw PTY data
592
+ if (modeRef.current === "attached") {
593
+ writePtyData(result.data);
594
+ }
595
+ }
596
+ } catch (e) {
597
+ console.error("Failed to handle data message:", e);
598
+ }
599
+ }, [handleBrowseResponse, writePtyData]);
600
+
601
+ // Keep ref updated to avoid stale closure in handleMessage
602
+ useEffect(() => {
603
+ handleDataMessageRef.current = handleDataMessage;
604
+ }, [handleDataMessage]);
605
+
606
+ const send = useCallback(async (data: Uint8Array) => {
607
+ const ws = wsRef.current;
608
+ const sessionKeys = sessionKeysRef.current;
609
+
610
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
611
+ console.warn("WebSocket not connected");
612
+ return;
613
+ }
614
+
615
+ if (!sessionKeys) {
616
+ console.warn("Session keys not established");
617
+ return;
618
+ }
619
+
620
+ try {
621
+ // Encrypt data into a frame
622
+ const frame = await createFrame(MASTER_STREAM_ID, data, sessionKeys.sendKey);
623
+
624
+ // Encode as base64 and send
625
+ const base64 = btoa(String.fromCharCode(...frame));
626
+ ws.send(JSON.stringify({
627
+ type: "data",
628
+ data: base64,
629
+ }));
630
+ } catch (e) {
631
+ console.error("Failed to send data:", e);
632
+ }
633
+ }, []);
634
+
635
+ const setWriteCallback = useCallback((fn: (data: Uint8Array) => void) => {
636
+ writeCallbackRef.current = fn;
637
+ }, []);
638
+
639
+ const disconnect = useCallback(() => {
640
+ wsRef.current?.close();
641
+ wsRef.current = null;
642
+ sessionKeysRef.current = null;
643
+ handshakeStateRef.current = null;
644
+ utf8BufferRef.current = new Uint8Array(0); // Clear UTF-8 buffer
645
+ setStatus("disconnected");
646
+ setMode("browsing");
647
+ modeRef.current = "browsing";
648
+ setProjects([]);
649
+ setWorkspaces([]);
650
+ setSessions([]);
651
+ setAttachedSessionId(null);
652
+ setAttachedSessionName(null);
653
+ setSelectedProjectName(null);
654
+ setInbox([]);
655
+ setInboxUnreadCount(0);
656
+ }, []);
657
+
658
+ return {
659
+ // Connection state
660
+ status,
661
+ mode,
662
+
663
+ // Browse data
664
+ projects,
665
+ workspaces,
666
+ sessions,
667
+ attachedSessionId,
668
+ attachedSessionName,
669
+ selectedProjectName,
670
+
671
+ // Connection actions
672
+ connect,
673
+ disconnect,
674
+
675
+ // Browse actions
676
+ requestProjects,
677
+ requestWorkspaces,
678
+ requestSessions,
679
+ attachSession,
680
+ detachSession,
681
+ selectProject,
682
+
683
+ // Session/workspace management
684
+ killSession,
685
+ deleteWorkspace,
686
+
687
+ // Terminal I/O (for attached mode)
688
+ send,
689
+ resize,
690
+ setWriteCallback,
691
+
692
+ // Inbox
693
+ inbox,
694
+ inboxUnreadCount,
695
+ requestInbox,
696
+ clearInboxItem,
697
+ markInboxItemRead,
698
+ };
699
+ }