gitspace 0.2.0-rc.2 → 0.2.0-rc.21

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 (316) hide show
  1. package/README.md +68 -38
  2. package/package.json +36 -25
  3. package/.claude/settings.local.json +0 -21
  4. package/.gitspace/bundle.json +0 -50
  5. package/.gitspace/select/01-status.sh +0 -40
  6. package/.gitspace/setup/01-install-deps.sh +0 -12
  7. package/.gitspace/setup/02-typecheck.sh +0 -16
  8. package/AGENTS.md +0 -439
  9. package/CLAUDE.md +0 -1
  10. package/bun.lock +0 -647
  11. package/docs/CONNECTION.md +0 -623
  12. package/docs/GATEWAY-WORKER.md +0 -319
  13. package/docs/GETTING-STARTED.md +0 -448
  14. package/docs/GITSPACE-PLATFORM.md +0 -1819
  15. package/docs/INFRASTRUCTURE.md +0 -1347
  16. package/docs/PROTOCOL.md +0 -619
  17. package/docs/QUICKSTART.md +0 -174
  18. package/docs/RELAY.md +0 -327
  19. package/docs/REMOTE-DESIGN.md +0 -549
  20. package/docs/ROADMAP.md +0 -564
  21. package/docs/SITE_DOCS_FIGMA_MAKE.md +0 -1167
  22. package/docs/STACK-DESIGN.md +0 -588
  23. package/docs/UNIFIED_ARCHITECTURE.md +0 -292
  24. package/experiments/pty-benchmark.ts +0 -148
  25. package/experiments/pty-latency.ts +0 -100
  26. package/experiments/router/client.ts +0 -199
  27. package/experiments/router/protocol.ts +0 -74
  28. package/experiments/router/router.ts +0 -217
  29. package/experiments/router/session.ts +0 -180
  30. package/experiments/router/test.ts +0 -133
  31. package/experiments/socket-bandwidth.ts +0 -77
  32. package/homebrew/gitspace.rb +0 -45
  33. package/landing-page/ATTRIBUTIONS.md +0 -3
  34. package/landing-page/README.md +0 -11
  35. package/landing-page/bun.lock +0 -801
  36. package/landing-page/guidelines/Guidelines.md +0 -61
  37. package/landing-page/index.html +0 -37
  38. package/landing-page/package.json +0 -90
  39. package/landing-page/postcss.config.mjs +0 -15
  40. package/landing-page/public/_redirects +0 -1
  41. package/landing-page/public/favicon.png +0 -0
  42. package/landing-page/src/app/App.tsx +0 -53
  43. package/landing-page/src/app/components/figma/ImageWithFallback.tsx +0 -27
  44. package/landing-page/src/app/components/ui/accordion.tsx +0 -66
  45. package/landing-page/src/app/components/ui/alert-dialog.tsx +0 -157
  46. package/landing-page/src/app/components/ui/alert.tsx +0 -66
  47. package/landing-page/src/app/components/ui/aspect-ratio.tsx +0 -11
  48. package/landing-page/src/app/components/ui/avatar.tsx +0 -53
  49. package/landing-page/src/app/components/ui/badge.tsx +0 -46
  50. package/landing-page/src/app/components/ui/breadcrumb.tsx +0 -109
  51. package/landing-page/src/app/components/ui/button.tsx +0 -57
  52. package/landing-page/src/app/components/ui/calendar.tsx +0 -75
  53. package/landing-page/src/app/components/ui/card.tsx +0 -92
  54. package/landing-page/src/app/components/ui/carousel.tsx +0 -241
  55. package/landing-page/src/app/components/ui/chart.tsx +0 -353
  56. package/landing-page/src/app/components/ui/checkbox.tsx +0 -32
  57. package/landing-page/src/app/components/ui/collapsible.tsx +0 -33
  58. package/landing-page/src/app/components/ui/command.tsx +0 -177
  59. package/landing-page/src/app/components/ui/context-menu.tsx +0 -252
  60. package/landing-page/src/app/components/ui/dialog.tsx +0 -135
  61. package/landing-page/src/app/components/ui/drawer.tsx +0 -132
  62. package/landing-page/src/app/components/ui/dropdown-menu.tsx +0 -257
  63. package/landing-page/src/app/components/ui/form.tsx +0 -168
  64. package/landing-page/src/app/components/ui/hover-card.tsx +0 -44
  65. package/landing-page/src/app/components/ui/input-otp.tsx +0 -77
  66. package/landing-page/src/app/components/ui/input.tsx +0 -21
  67. package/landing-page/src/app/components/ui/label.tsx +0 -24
  68. package/landing-page/src/app/components/ui/menubar.tsx +0 -276
  69. package/landing-page/src/app/components/ui/navigation-menu.tsx +0 -168
  70. package/landing-page/src/app/components/ui/pagination.tsx +0 -127
  71. package/landing-page/src/app/components/ui/popover.tsx +0 -48
  72. package/landing-page/src/app/components/ui/progress.tsx +0 -31
  73. package/landing-page/src/app/components/ui/radio-group.tsx +0 -45
  74. package/landing-page/src/app/components/ui/resizable.tsx +0 -56
  75. package/landing-page/src/app/components/ui/scroll-area.tsx +0 -58
  76. package/landing-page/src/app/components/ui/select.tsx +0 -189
  77. package/landing-page/src/app/components/ui/separator.tsx +0 -28
  78. package/landing-page/src/app/components/ui/sheet.tsx +0 -139
  79. package/landing-page/src/app/components/ui/sidebar.tsx +0 -726
  80. package/landing-page/src/app/components/ui/skeleton.tsx +0 -13
  81. package/landing-page/src/app/components/ui/slider.tsx +0 -63
  82. package/landing-page/src/app/components/ui/sonner.tsx +0 -25
  83. package/landing-page/src/app/components/ui/switch.tsx +0 -31
  84. package/landing-page/src/app/components/ui/table.tsx +0 -116
  85. package/landing-page/src/app/components/ui/tabs.tsx +0 -66
  86. package/landing-page/src/app/components/ui/textarea.tsx +0 -18
  87. package/landing-page/src/app/components/ui/toggle-group.tsx +0 -73
  88. package/landing-page/src/app/components/ui/toggle.tsx +0 -47
  89. package/landing-page/src/app/components/ui/tooltip.tsx +0 -61
  90. package/landing-page/src/app/components/ui/use-mobile.ts +0 -21
  91. package/landing-page/src/app/components/ui/utils.ts +0 -6
  92. package/landing-page/src/components/docs/DocsContent.tsx +0 -718
  93. package/landing-page/src/components/docs/DocsSidebar.tsx +0 -84
  94. package/landing-page/src/components/landing/CTA.tsx +0 -59
  95. package/landing-page/src/components/landing/Comparison.tsx +0 -84
  96. package/landing-page/src/components/landing/FaultyTerminal.tsx +0 -424
  97. package/landing-page/src/components/landing/Features.tsx +0 -201
  98. package/landing-page/src/components/landing/Hero.tsx +0 -142
  99. package/landing-page/src/components/landing/Pricing.tsx +0 -140
  100. package/landing-page/src/components/landing/Roadmap.tsx +0 -86
  101. package/landing-page/src/components/landing/Security.tsx +0 -81
  102. package/landing-page/src/components/landing/TerminalWindow.tsx +0 -27
  103. package/landing-page/src/components/landing/UseCases.tsx +0 -55
  104. package/landing-page/src/components/landing/Workflow.tsx +0 -101
  105. package/landing-page/src/components/layout/DashboardNavbar.tsx +0 -37
  106. package/landing-page/src/components/layout/Footer.tsx +0 -55
  107. package/landing-page/src/components/layout/LandingNavbar.tsx +0 -82
  108. package/landing-page/src/components/ui/badge.tsx +0 -39
  109. package/landing-page/src/components/ui/breadcrumb.tsx +0 -115
  110. package/landing-page/src/components/ui/button.tsx +0 -57
  111. package/landing-page/src/components/ui/card.tsx +0 -79
  112. package/landing-page/src/components/ui/mock-terminal.tsx +0 -68
  113. package/landing-page/src/components/ui/separator.tsx +0 -28
  114. package/landing-page/src/lib/utils.ts +0 -6
  115. package/landing-page/src/main.tsx +0 -10
  116. package/landing-page/src/pages/Dashboard.tsx +0 -133
  117. package/landing-page/src/pages/DocsPage.tsx +0 -79
  118. package/landing-page/src/pages/LandingPage.tsx +0 -31
  119. package/landing-page/src/pages/TerminalView.tsx +0 -106
  120. package/landing-page/src/styles/fonts.css +0 -0
  121. package/landing-page/src/styles/index.css +0 -3
  122. package/landing-page/src/styles/tailwind.css +0 -4
  123. package/landing-page/src/styles/theme.css +0 -181
  124. package/landing-page/vite.config.ts +0 -19
  125. package/npm/darwin-arm64/bin/gssh +0 -0
  126. package/npm/darwin-arm64/package.json +0 -20
  127. package/scripts/build.ts +0 -298
  128. package/scripts/release.ts +0 -140
  129. package/src/__tests__/test-utils.ts +0 -298
  130. package/src/commands/__tests__/serve-messages.test.ts +0 -190
  131. package/src/commands/access.ts +0 -298
  132. package/src/commands/add.ts +0 -452
  133. package/src/commands/auth.ts +0 -364
  134. package/src/commands/connect.ts +0 -287
  135. package/src/commands/directory.ts +0 -16
  136. package/src/commands/host.ts +0 -396
  137. package/src/commands/identity.ts +0 -184
  138. package/src/commands/list.ts +0 -200
  139. package/src/commands/relay.ts +0 -315
  140. package/src/commands/remove.ts +0 -241
  141. package/src/commands/serve.ts +0 -1493
  142. package/src/commands/share.ts +0 -456
  143. package/src/commands/status.ts +0 -125
  144. package/src/commands/switch.ts +0 -353
  145. package/src/commands/tmux.ts +0 -317
  146. package/src/core/__tests__/access.test.ts +0 -240
  147. package/src/core/access.ts +0 -277
  148. package/src/core/bundle.ts +0 -342
  149. package/src/core/config.ts +0 -510
  150. package/src/core/git.ts +0 -317
  151. package/src/core/github.ts +0 -151
  152. package/src/core/identity.ts +0 -631
  153. package/src/core/linear.ts +0 -225
  154. package/src/core/shell.ts +0 -161
  155. package/src/core/trusted-relays.ts +0 -315
  156. package/src/index.ts +0 -810
  157. package/src/lib/remote-session/index.ts +0 -7
  158. package/src/lib/remote-session/protocol.ts +0 -267
  159. package/src/lib/remote-session/session-handler.ts +0 -581
  160. package/src/lib/remote-session/workspace-scanner.ts +0 -167
  161. package/src/lib/tmux-lite/README.md +0 -81
  162. package/src/lib/tmux-lite/cli.ts +0 -796
  163. package/src/lib/tmux-lite/crypto/__tests__/helpers/handshake-runner.ts +0 -349
  164. package/src/lib/tmux-lite/crypto/__tests__/helpers/mock-relay.ts +0 -291
  165. package/src/lib/tmux-lite/crypto/__tests__/helpers/test-identities.ts +0 -142
  166. package/src/lib/tmux-lite/crypto/__tests__/integration/authorization.integration.test.ts +0 -339
  167. package/src/lib/tmux-lite/crypto/__tests__/integration/e2e-communication.integration.test.ts +0 -477
  168. package/src/lib/tmux-lite/crypto/__tests__/integration/error-handling.integration.test.ts +0 -499
  169. package/src/lib/tmux-lite/crypto/__tests__/integration/handshake.integration.test.ts +0 -371
  170. package/src/lib/tmux-lite/crypto/__tests__/integration/security.integration.test.ts +0 -573
  171. package/src/lib/tmux-lite/crypto/access-control.test.ts +0 -512
  172. package/src/lib/tmux-lite/crypto/access-control.ts +0 -320
  173. package/src/lib/tmux-lite/crypto/frames.test.ts +0 -262
  174. package/src/lib/tmux-lite/crypto/frames.ts +0 -141
  175. package/src/lib/tmux-lite/crypto/handshake.ts +0 -894
  176. package/src/lib/tmux-lite/crypto/identity.test.ts +0 -220
  177. package/src/lib/tmux-lite/crypto/identity.ts +0 -286
  178. package/src/lib/tmux-lite/crypto/index.ts +0 -51
  179. package/src/lib/tmux-lite/crypto/invites.test.ts +0 -381
  180. package/src/lib/tmux-lite/crypto/invites.ts +0 -215
  181. package/src/lib/tmux-lite/crypto/keyexchange.ts +0 -435
  182. package/src/lib/tmux-lite/crypto/keys.test.ts +0 -58
  183. package/src/lib/tmux-lite/crypto/keys.ts +0 -47
  184. package/src/lib/tmux-lite/crypto/secretbox.test.ts +0 -169
  185. package/src/lib/tmux-lite/crypto/secretbox.ts +0 -124
  186. package/src/lib/tmux-lite/handshake-handler.ts +0 -451
  187. package/src/lib/tmux-lite/protocol.test.ts +0 -307
  188. package/src/lib/tmux-lite/protocol.ts +0 -266
  189. package/src/lib/tmux-lite/relay-client.ts +0 -506
  190. package/src/lib/tmux-lite/server.ts +0 -1250
  191. package/src/lib/tmux-lite/shell-integration.sh +0 -37
  192. package/src/lib/tmux-lite/terminal-queries.test.ts +0 -54
  193. package/src/lib/tmux-lite/terminal-queries.ts +0 -49
  194. package/src/relay/__tests__/e2e-flow.test.ts +0 -1284
  195. package/src/relay/__tests__/helpers/auth.ts +0 -354
  196. package/src/relay/__tests__/helpers/ports.ts +0 -51
  197. package/src/relay/__tests__/protocol-validation.test.ts +0 -265
  198. package/src/relay/authorization.ts +0 -303
  199. package/src/relay/embedded-assets.generated.d.ts +0 -15
  200. package/src/relay/identity.ts +0 -352
  201. package/src/relay/index.ts +0 -57
  202. package/src/relay/pipes.test.ts +0 -427
  203. package/src/relay/pipes.ts +0 -195
  204. package/src/relay/protocol.ts +0 -804
  205. package/src/relay/registries.test.ts +0 -437
  206. package/src/relay/registries.ts +0 -593
  207. package/src/relay/server.test.ts +0 -1323
  208. package/src/relay/server.ts +0 -1092
  209. package/src/relay/signing.ts +0 -238
  210. package/src/relay/types.ts +0 -69
  211. package/src/serve/client-session-manager.ts +0 -622
  212. package/src/serve/daemon.ts +0 -497
  213. package/src/serve/pty-session.ts +0 -236
  214. package/src/serve/types.ts +0 -169
  215. package/src/shared/components/Flow.tsx +0 -453
  216. package/src/shared/components/Flow.tui.tsx +0 -343
  217. package/src/shared/components/Flow.web.tsx +0 -442
  218. package/src/shared/components/Inbox.tsx +0 -446
  219. package/src/shared/components/Inbox.tui.tsx +0 -262
  220. package/src/shared/components/Inbox.web.tsx +0 -329
  221. package/src/shared/components/MachineList.tsx +0 -187
  222. package/src/shared/components/MachineList.tui.tsx +0 -161
  223. package/src/shared/components/MachineList.web.tsx +0 -210
  224. package/src/shared/components/ProjectList.tsx +0 -176
  225. package/src/shared/components/ProjectList.tui.tsx +0 -109
  226. package/src/shared/components/ProjectList.web.tsx +0 -143
  227. package/src/shared/components/SpacesBrowser.tsx +0 -332
  228. package/src/shared/components/SpacesBrowser.tui.tsx +0 -163
  229. package/src/shared/components/SpacesBrowser.web.tsx +0 -221
  230. package/src/shared/components/index.ts +0 -103
  231. package/src/shared/hooks/index.ts +0 -16
  232. package/src/shared/hooks/useNavigation.ts +0 -226
  233. package/src/shared/index.ts +0 -122
  234. package/src/shared/providers/LocalMachineProvider.ts +0 -425
  235. package/src/shared/providers/MachineProvider.ts +0 -165
  236. package/src/shared/providers/RemoteMachineProvider.ts +0 -444
  237. package/src/shared/providers/index.ts +0 -26
  238. package/src/shared/types.ts +0 -145
  239. package/src/tui/adapters.ts +0 -120
  240. package/src/tui/app.tsx +0 -1816
  241. package/src/tui/components/Terminal.tsx +0 -580
  242. package/src/tui/hooks/index.ts +0 -35
  243. package/src/tui/hooks/useAppState.ts +0 -314
  244. package/src/tui/hooks/useDaemonStatus.ts +0 -174
  245. package/src/tui/hooks/useInboxTUI.ts +0 -113
  246. package/src/tui/hooks/useRemoteMachines.ts +0 -209
  247. package/src/tui/index.ts +0 -24
  248. package/src/tui/state.ts +0 -299
  249. package/src/tui/terminal-bracketed-paste.test.ts +0 -45
  250. package/src/tui/terminal-bracketed-paste.ts +0 -47
  251. package/src/types/bundle.ts +0 -112
  252. package/src/types/config.ts +0 -89
  253. package/src/types/errors.ts +0 -206
  254. package/src/types/identity.ts +0 -284
  255. package/src/types/workspace-fuzzy.ts +0 -49
  256. package/src/types/workspace.ts +0 -151
  257. package/src/utils/bun-socket-writer.ts +0 -80
  258. package/src/utils/deps.ts +0 -127
  259. package/src/utils/fuzzy-match.ts +0 -125
  260. package/src/utils/logger.ts +0 -127
  261. package/src/utils/markdown.ts +0 -254
  262. package/src/utils/onboarding.ts +0 -229
  263. package/src/utils/prompts.ts +0 -114
  264. package/src/utils/run-commands.ts +0 -112
  265. package/src/utils/run-scripts.ts +0 -142
  266. package/src/utils/sanitize.ts +0 -98
  267. package/src/utils/secrets.ts +0 -122
  268. package/src/utils/shell-escape.ts +0 -40
  269. package/src/utils/utf8.ts +0 -79
  270. package/src/utils/workspace-state.ts +0 -47
  271. package/src/web/README.md +0 -73
  272. package/src/web/bun.lock +0 -575
  273. package/src/web/eslint.config.js +0 -23
  274. package/src/web/index.html +0 -16
  275. package/src/web/package.json +0 -37
  276. package/src/web/public/vite.svg +0 -1
  277. package/src/web/src/App.tsx +0 -604
  278. package/src/web/src/assets/react.svg +0 -1
  279. package/src/web/src/components/Terminal.tsx +0 -207
  280. package/src/web/src/hooks/useRelayConnection.ts +0 -224
  281. package/src/web/src/hooks/useTerminal.ts +0 -699
  282. package/src/web/src/index.css +0 -55
  283. package/src/web/src/lib/crypto/__tests__/web-terminal.test.ts +0 -1158
  284. package/src/web/src/lib/crypto/frames.ts +0 -205
  285. package/src/web/src/lib/crypto/handshake.ts +0 -396
  286. package/src/web/src/lib/crypto/identity.ts +0 -128
  287. package/src/web/src/lib/crypto/keyexchange.ts +0 -246
  288. package/src/web/src/lib/crypto/relay-signing.ts +0 -53
  289. package/src/web/src/lib/invite.ts +0 -58
  290. package/src/web/src/lib/storage/identity-store.ts +0 -94
  291. package/src/web/src/main.tsx +0 -10
  292. package/src/web/src/types/identity.ts +0 -45
  293. package/src/web/tsconfig.app.json +0 -28
  294. package/src/web/tsconfig.json +0 -7
  295. package/src/web/tsconfig.node.json +0 -26
  296. package/src/web/vite.config.ts +0 -31
  297. package/todo-security.md +0 -92
  298. package/tsconfig.json +0 -23
  299. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite +0 -0
  300. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-shm +0 -0
  301. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-wal +0 -0
  302. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite +0 -0
  303. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-shm +0 -0
  304. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-wal +0 -0
  305. package/worker/bun.lock +0 -237
  306. package/worker/package.json +0 -22
  307. package/worker/schema.sql +0 -96
  308. package/worker/src/handlers/auth.ts +0 -451
  309. package/worker/src/handlers/subdomains.ts +0 -376
  310. package/worker/src/handlers/user.ts +0 -98
  311. package/worker/src/index.ts +0 -70
  312. package/worker/src/middleware/auth.ts +0 -152
  313. package/worker/src/services/cloudflare.ts +0 -609
  314. package/worker/src/types.ts +0 -96
  315. package/worker/tsconfig.json +0 -15
  316. package/worker/wrangler.toml +0 -26
@@ -1,1250 +0,0 @@
1
- #!/usr/bin/env bun
2
- // @ts-nocheck - Uses Bun-specific APIs (Bun.Terminal, etc.)
3
- /**
4
- * tmux-lite server - manages all sessions in a single process
5
- * Uses xterm-headless for proper terminal state tracking
6
- */
7
-
8
- import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "fs";
9
- import { dirname } from "path";
10
- import { Terminal as XTerminal } from "@xterm/headless";
11
- import { SerializeAddon } from "@xterm/addon-serialize";
12
- import { createBufferedSocketWriter } from "../../utils/bun-socket-writer";
13
- import { installDsrCprResponder } from "./terminal-queries";
14
- import {
15
- getRouterSocket,
16
- getSessionSocketPath,
17
- getPidFile,
18
- PROTOCOL_VERSION,
19
- PACKAGE_VERSION,
20
- type Command,
21
- type Response,
22
- type Session,
23
- type SessionCtrl,
24
- type InboxItem,
25
- encodeRouterMessage,
26
- decodeRouterMessages,
27
- encodePTY,
28
- encodeControl,
29
- parseFrames,
30
- decodeControl,
31
- FrameType,
32
- MAX_FRAME_SIZE,
33
- } from "./protocol";
34
-
35
- // Chunk size for large PTY data (leave room for frame header overhead)
36
- // Using 512KB to be well under the 1MB limit
37
- const PTY_CHUNK_SIZE = 512 * 1024;
38
-
39
- // Max scrollback lines to include in serialized state during attach
40
- // This is a limit - if less scrollback exists, we'll send what's available
41
- const SERIALIZE_SCROLLBACK_LINES = 1_000;
42
-
43
- const rawArgs = process.argv.slice(2);
44
- if (rawArgs.includes("--test")) {
45
- process.env.TMUX_LITE_SOCKET = "/tmp/tmux-lite-test.sock";
46
- process.env.TMUX_LITE_SESSION_DIR = "/tmp/tmux-lite-test";
47
- process.env.TMUX_LITE_PID_FILE = "/tmp/tmux-lite-test.pid";
48
- }
49
-
50
- const ROUTER_SOCKET = getRouterSocket();
51
- const PID_FILE = getPidFile();
52
- const SERVER_START_TIME = Date.now();
53
-
54
- // Clean up old socket
55
- try { unlinkSync(ROUTER_SOCKET); } catch {}
56
-
57
- // Write PID file
58
- writeFileSync(PID_FILE, String(process.pid));
59
-
60
- interface SessionData {
61
- info: Session;
62
- ptyTerminal: Bun.Terminal;
63
- xterm: XTerminal;
64
- serialize: SerializeAddon;
65
- proc: Bun.Subprocess;
66
- client: any;
67
- clientWriter: any;
68
- ctrlBuffer: Buffer;
69
- pendingWrites: number; // Track pending xterm writes
70
- attaching: boolean;
71
- attachBuffer: Buffer[];
72
- attachPending: boolean;
73
- attachTimer: any;
74
- processTitle: string; // Title set by running process (via OSC 0)
75
- lastInteraction: number; // Timestamp of last user input
76
- lastDetached: number; // Timestamp of last detach (for grace period)
77
- lastAttached: number; // Timestamp of last attach (for grace period)
78
- }
79
-
80
- const sessions = new Map<string, SessionData>();
81
- const inbox: InboxItem[] = [];
82
-
83
- function writeToClient(session: SessionData, data: Buffer): void {
84
- if (!session.client) return;
85
- if (session.clientWriter) {
86
- session.clientWriter.write(data);
87
- return;
88
- }
89
- session.client.write(data);
90
- }
91
-
92
- function flushClient(session: SessionData): void {
93
- if (session.clientWriter) session.clientWriter.flush();
94
- }
95
-
96
- // ============================================================================
97
- // Socket State Management
98
- // ============================================================================
99
-
100
- /**
101
- * Type-safe socket state manager using WeakMap.
102
- * This avoids mutating socket objects with `as any` casts.
103
- */
104
- interface RouterSocketState {
105
- buffer: Buffer;
106
- writer: any;
107
- }
108
-
109
- const routerSocketStates = new WeakMap<object, RouterSocketState>();
110
-
111
- function getRouterSocketState(socket: object): RouterSocketState {
112
- let state = routerSocketStates.get(socket);
113
- if (!state) {
114
- state = { buffer: Buffer.alloc(0), writer: null };
115
- routerSocketStates.set(socket, state);
116
- }
117
- return state;
118
- }
119
-
120
- function clearRouterSocketState(socket: object): void {
121
- routerSocketStates.delete(socket);
122
- }
123
-
124
- // How long after last interaction before we consider the user "inactive"
125
- const INTERACTION_TIMEOUT_MS = 30000; // 30 seconds
126
- // Grace period after attach/detach - don't notify immediately
127
- const ATTACH_GRACE_MS = 5000; // 5 seconds after attach
128
- const DETACH_GRACE_MS = 5000; // 5 seconds after detach
129
-
130
- // ============================================================================
131
- // OSC Pattern Registry
132
- // ============================================================================
133
-
134
- /**
135
- * Registry of OSC (Operating System Command) patterns for terminal notifications.
136
- * Each pattern matches specific escape sequences and extracts relevant data.
137
- */
138
- interface OscPattern {
139
- name: string;
140
- pattern: RegExp;
141
- /** Extract notification data from a match. Returns null to skip notification. */
142
- extract: (match: RegExpMatchArray, context: OscMatchContext) => OscNotificationData | null;
143
- }
144
-
145
- interface OscMatchContext {
146
- sessionId: string;
147
- sessionName: string;
148
- processTitle: string;
149
- xterm: XTerminal;
150
- now: number;
151
- }
152
-
153
- interface OscNotificationData {
154
- type: InboxItem['type'];
155
- context: string;
156
- exitCode?: number;
157
- }
158
-
159
- const OSC_PATTERNS: OscPattern[] = [
160
- {
161
- // Custom exit code: ESC ] 777 ; exit : <code> BEL
162
- name: 'exit',
163
- pattern: /\x1b\]777;exit:(-?\d+)\x07/g,
164
- extract: (match, ctx) => ({
165
- type: 'exit',
166
- exitCode: parseInt(match[1], 10),
167
- context: getCurrentLine(ctx.xterm) || `Exit code: ${match[1]}`,
168
- }),
169
- },
170
- {
171
- // iTerm2/Growl notification: ESC ] 9 ; message BEL
172
- name: 'osc9',
173
- pattern: /\x1b\]9;([^\x07]*)\x07/g,
174
- extract: (match) => match[1] ? { type: 'bell', context: match[1] } : null,
175
- },
176
- {
177
- // Kitty notification: ESC ] 99 ; i=id:d=0; body BEL (simplified)
178
- name: 'osc99',
179
- pattern: /\x1b\]99;[^;]*;([^\x07]*)\x07/g,
180
- extract: (match) => match[1] ? { type: 'bell', context: match[1] } : null,
181
- },
182
- {
183
- // rxvt notification: ESC ] 777 ; notify ; title ; body BEL
184
- name: 'osc777notify',
185
- pattern: /\x1b\]777;notify;([^;]*);([^\x07]*)\x07/g,
186
- extract: (match) => ({
187
- type: 'bell',
188
- context: match[2] || match[1] || 'Notification',
189
- }),
190
- },
191
- ];
192
-
193
- // Semantic shell integration patterns (OSC 133) - handled separately due to state tracking
194
- const OSC_133_DONE_PATTERN = /\x1b\]133;D(?:;(\d+))?\x07/g;
195
- const OSC_133_CMD_START = /\x1b\]133;C\x07/g;
196
-
197
- /**
198
- * Process OSC patterns in terminal output and create inbox notifications.
199
- */
200
- function processOscPatterns(
201
- str: string,
202
- ctx: OscMatchContext,
203
- addNotification: (data: OscNotificationData) => void
204
- ): void {
205
- for (const { name, pattern, extract } of OSC_PATTERNS) {
206
- // Reset lastIndex for global patterns
207
- pattern.lastIndex = 0;
208
- const matches = [...str.matchAll(pattern)];
209
- for (const match of matches) {
210
- const data = extract(match, ctx);
211
- if (data) {
212
- addNotification(data);
213
- console.log(`[${ctx.sessionName}] ${name} notification: ${data.context.substring(0, 50)}`);
214
- }
215
- }
216
- }
217
- }
218
-
219
- // ============================================================================
220
- // Inbox Notification Helpers
221
- // ============================================================================
222
-
223
- /**
224
- * Creates an inbox item with common fields populated.
225
- */
226
- function createInboxNotification(
227
- sessionId: string,
228
- sessionName: string,
229
- type: InboxItem['type'],
230
- context: string,
231
- processTitle?: string,
232
- exitCode?: number
233
- ): Omit<InboxItem, 'id' | 'read'> {
234
- return {
235
- sessionId,
236
- sessionName,
237
- type,
238
- timestamp: Date.now(),
239
- context,
240
- processTitle,
241
- exitCode,
242
- };
243
- }
244
-
245
- // Check if user is actively using the session or recently attached/detached
246
- // Returns true if we should SUPPRESS notifications
247
- function isActivelyUsing(session: SessionData | undefined): boolean {
248
- if (!session) return false;
249
-
250
- const now = Date.now();
251
-
252
- // If recently detached, still suppress notifications (grace period)
253
- if (session.lastDetached > 0) {
254
- const timeSinceDetach = now - session.lastDetached;
255
- if (timeSinceDetach < DETACH_GRACE_MS) {
256
- return true; // Suppress - just detached
257
- }
258
- }
259
-
260
- // If not attached, don't suppress (unless in grace period above)
261
- if (!session.info.attached) return false;
262
-
263
- // If recently attached, suppress notifications (startup grace period)
264
- if (session.lastAttached > 0) {
265
- const timeSinceAttach = now - session.lastAttached;
266
- if (timeSinceAttach < ATTACH_GRACE_MS) {
267
- return true; // Suppress - just attached
268
- }
269
- }
270
-
271
- // If attached but never interacted AND past the attach grace period, don't suppress
272
- if (session.lastInteraction === 0) return false;
273
-
274
- // If attached and recently interacted, suppress
275
- const timeSinceInteraction = now - session.lastInteraction;
276
- return timeSinceInteraction < INTERACTION_TIMEOUT_MS;
277
- }
278
-
279
- let sessionCounter = 0;
280
- let inboxCounter = 0;
281
-
282
- function genId(): string {
283
- return String(sessionCounter++);
284
- }
285
-
286
- function genInboxId(): string {
287
- return String(inboxCounter++);
288
- }
289
-
290
- function addInboxItem(item: Omit<InboxItem, 'id' | 'read'>): void {
291
- inbox.push({
292
- ...item,
293
- id: genInboxId(),
294
- read: false,
295
- });
296
- console.log(`[inbox] ${item.type}: ${item.sessionName} - ${item.context.substring(0, 50)}`);
297
-
298
- // Update titles for all attached sessions to show new inbox count
299
- broadcastTitleUpdate();
300
- }
301
-
302
- function getLastLines(xterm: XTerminal, count: number): string {
303
- const buffer = xterm.buffer.active;
304
- const lines: string[] = [];
305
- const startRow = Math.max(0, buffer.cursorY - count + 1);
306
-
307
- for (let i = startRow; i <= buffer.cursorY; i++) {
308
- const line = buffer.getLine(i)?.translateToString(true);
309
- if (line) lines.push(line);
310
- }
311
-
312
- return lines.join('\n').trim();
313
- }
314
-
315
- function getCurrentLine(xterm: XTerminal): string {
316
- const buffer = xterm.buffer.active;
317
- return buffer.getLine(buffer.cursorY)?.translateToString(true)?.trim() || '';
318
- }
319
-
320
- function getUnreadInboxCount(): number {
321
- return inbox.filter(i => !i.read).length;
322
- }
323
-
324
- function buildTitle(sessionName: string, processTitle?: string): string {
325
- const unread = getUnreadInboxCount();
326
- let title = `tl: ${sessionName}`;
327
-
328
- if (processTitle) {
329
- title += ` | ${processTitle}`;
330
- }
331
-
332
- if (unread > 0) {
333
- title += ` (${unread} 🔔)`;
334
- }
335
-
336
- return title;
337
- }
338
-
339
- function sendTitle(session: SessionData, sessionName: string, processTitle?: string): void {
340
- if (!session.client) return;
341
- const title = buildTitle(sessionName, processTitle);
342
- // OSC 0 sets both icon and window title - must be framed!
343
- writeToClient(session, encodePTY(Buffer.from(`\x1b]0;${title}\x07`)));
344
- }
345
-
346
- function broadcastTitleUpdate(): void {
347
- // Update title for all attached sessions
348
- for (const [id, session] of sessions) {
349
- if (session.client) {
350
- sendTitle(session, session.info.name, session.processTitle);
351
- }
352
- }
353
- }
354
-
355
- // RIS (Reset to Initial State) - the nuclear option that resets everything
356
- const TERM_RESET = Buffer.from("\x1bc");
357
-
358
- // ============================================================================
359
- // Session Helper Functions
360
- // ============================================================================
361
-
362
- /**
363
- * Configuration for idle detection in a session.
364
- */
365
- interface IdleDetectionState {
366
- lastOutputTime: number;
367
- outputSinceIdle: number;
368
- idleTimer: ReturnType<typeof setTimeout> | null;
369
- }
370
-
371
- const IDLE_THRESHOLD_MS = 10000; // 10 seconds of quiet after output
372
- const MIN_OUTPUT_FOR_IDLE = 500; // Need at least 500 bytes of output to consider "activity"
373
-
374
- /**
375
- * Creates the idle detection check function for a session.
376
- */
377
- function createIdleChecker(
378
- id: string,
379
- sessionName: string,
380
- xterm: XTerminal,
381
- idleState: IdleDetectionState,
382
- getProcessTitle: () => string
383
- ): () => void {
384
- return () => {
385
- const session = sessions.get(id);
386
- // Only notify if: not actively using, had significant output, and now idle
387
- if (!isActivelyUsing(session) && idleState.outputSinceIdle >= MIN_OUTPUT_FOR_IDLE) {
388
- const context = getLastLines(xterm, 3) || '(idle)';
389
- addInboxItem(createInboxNotification(
390
- id,
391
- sessionName,
392
- 'idle',
393
- context,
394
- session?.processTitle || getProcessTitle()
395
- ));
396
- console.log(`[${sessionName}] idle notification after ${idleState.outputSinceIdle} bytes output`);
397
- }
398
- idleState.outputSinceIdle = 0;
399
- };
400
- }
401
-
402
- /**
403
- * Sets up xterm event handlers for bell and title change notifications.
404
- */
405
- function setupXtermEventHandlers(
406
- id: string,
407
- sessionName: string,
408
- xterm: XTerminal
409
- ): { getProcessTitle: () => string; setProcessTitle: (title: string) => void } {
410
- let processTitle = '';
411
- let lastBellTime = 0;
412
- let lastTitleNotification = 0;
413
-
414
- // Track bells for inbox notifications (with debounce)
415
- xterm.onBell(() => {
416
- const session = sessions.get(id);
417
- // Don't notify if user is actively using the session
418
- if (isActivelyUsing(session)) return;
419
-
420
- const now = Date.now();
421
- // Debounce: ignore bells within 500ms of each other
422
- if (now - lastBellTime < 500) return;
423
- lastBellTime = now;
424
-
425
- // Get last few lines for context (not just current line)
426
- const context = getLastLines(xterm, 3) || getCurrentLine(xterm) || '(bell)';
427
- addInboxItem(createInboxNotification(
428
- id,
429
- sessionName,
430
- 'bell',
431
- context,
432
- session?.processTitle
433
- ));
434
- });
435
-
436
- // Track title changes from running processes
437
- xterm.onTitleChange((title) => {
438
- console.log(`[${sessionName}] title changed: "${title}"`);
439
- const previousTitle = processTitle;
440
- processTitle = title;
441
- const session = sessions.get(id);
442
- if (session) {
443
- session.processTitle = title;
444
- // Update client's terminal title if attached
445
- if (session.client) {
446
- sendTitle(session.client, sessionName, title);
447
- }
448
-
449
- // Create inbox notification for ANY title change when not actively using
450
- // This helps track when background processes change state
451
- const now = Date.now();
452
- if (!isActivelyUsing(session) && title && title !== previousTitle) {
453
- // Debounce: don't notify more than once per 3 seconds
454
- if (now - lastTitleNotification > 3000) {
455
- lastTitleNotification = now;
456
- addInboxItem(createInboxNotification(
457
- id,
458
- sessionName,
459
- 'title',
460
- title,
461
- title
462
- ));
463
- console.log(`[${sessionName}] title change: ${previousTitle} -> ${title}`);
464
- }
465
- }
466
- }
467
- });
468
-
469
- return {
470
- getProcessTitle: () => processTitle,
471
- setProcessTitle: (title: string) => { processTitle = title; },
472
- };
473
- }
474
-
475
- /**
476
- * State for tracking OSC 133 shell integration commands.
477
- */
478
- interface Osc133State {
479
- commandRunning: boolean;
480
- }
481
-
482
- /**
483
- * Creates the PTY data handler that processes terminal output.
484
- */
485
- function createPtyDataHandler(
486
- id: string,
487
- sessionName: string,
488
- xterm: XTerminal,
489
- idleState: IdleDetectionState,
490
- osc133State: Osc133State,
491
- checkIdle: () => void,
492
- getProcessTitle: () => string
493
- ): (term: Bun.Terminal, data: Buffer) => void {
494
- return (term, data) => {
495
- // Track output for idle detection
496
- idleState.lastOutputTime = Date.now();
497
- idleState.outputSinceIdle += data.length;
498
-
499
- // Reset idle timer
500
- if (idleState.idleTimer) clearTimeout(idleState.idleTimer);
501
- idleState.idleTimer = setTimeout(checkIdle, IDLE_THRESHOLD_MS);
502
-
503
- const session = sessions.get(id);
504
- if (!session) return;
505
-
506
- const str = data.toString();
507
- const now = Date.now();
508
-
509
- // Only create inbox notifications if user is not actively using the session
510
- const activelyUsing = session.attaching || isActivelyUsing(session);
511
- const currentProcessTitle = session.processTitle || getProcessTitle();
512
-
513
- // Process OSC patterns for notifications (only if not actively using)
514
- if (!activelyUsing) {
515
- const oscContext: OscMatchContext = {
516
- sessionId: id,
517
- sessionName,
518
- processTitle: currentProcessTitle,
519
- xterm,
520
- now,
521
- };
522
-
523
- processOscPatterns(str, oscContext, (notifData) => {
524
- addInboxItem(createInboxNotification(
525
- id,
526
- sessionName,
527
- notifData.type,
528
- notifData.context,
529
- currentProcessTitle,
530
- notifData.exitCode
531
- ));
532
- });
533
- }
534
-
535
- // Check for semantic shell integration (OSC 133)
536
- // Command start
537
- if (OSC_133_CMD_START.test(str)) {
538
- osc133State.commandRunning = true;
539
- OSC_133_CMD_START.lastIndex = 0; // Reset regex state
540
- }
541
-
542
- // Command done - only notify if not actively using and command was running
543
- OSC_133_DONE_PATTERN.lastIndex = 0;
544
- const osc133DoneMatches = [...str.matchAll(OSC_133_DONE_PATTERN)];
545
- for (const match of osc133DoneMatches) {
546
- const exitCode = match[1] ? parseInt(match[1], 10) : 0;
547
- // Only notify for background sessions with non-zero exit or if command was tracked
548
- if (!activelyUsing && (exitCode !== 0 || osc133State.commandRunning)) {
549
- const context = getLastLines(xterm, 2) || `Command finished (exit ${exitCode})`;
550
- addInboxItem(createInboxNotification(
551
- id,
552
- sessionName,
553
- exitCode !== 0 ? 'exit' : 'idle',
554
- context,
555
- currentProcessTitle,
556
- exitCode !== 0 ? exitCode : undefined
557
- ));
558
- console.log(`[${sessionName}] OSC 133 command done: exit ${exitCode}`);
559
- }
560
- osc133State.commandRunning = false;
561
- }
562
-
563
- // Pass original data through unchanged to preserve all escape sequences
564
- // Our custom OSC 777 exit sequences are harmless - terminals ignore unknown OSC
565
- // Converting to string and back was corrupting cursor movement/screen control sequences
566
-
567
- if (session.attaching) {
568
- session.attachBuffer.push(Buffer.from(data));
569
- return;
570
- }
571
-
572
- // Feed data to xterm-headless for state tracking
573
- session.pendingWrites++;
574
- xterm.write(data, () => {
575
- session.pendingWrites--;
576
- });
577
-
578
- // Send to client (buffered - avoid framed protocol desync on backpressure)
579
- writeToClient(session, encodePTY(data));
580
- };
581
- }
582
-
583
- /**
584
- * Handles process exit and cleanup for a session.
585
- */
586
- function handleProcessExit(
587
- id: string,
588
- sessionName: string,
589
- xterm: XTerminal,
590
- socketPath: string,
591
- disposeDsr: () => void,
592
- getProcessTitle: () => string
593
- ): (code: number) => void {
594
- return (code) => {
595
- const session = sessions.get(id);
596
-
597
- // Clean up parser hooks
598
- try { disposeDsr(); } catch {}
599
-
600
- // Capture last lines for inbox before disposing xterm
601
- const context = getLastLines(xterm, 3);
602
- addInboxItem(createInboxNotification(
603
- id,
604
- sessionName,
605
- 'exit',
606
- context || `Session ended (exit ${code})`,
607
- session?.processTitle || getProcessTitle(),
608
- code
609
- ));
610
-
611
- // Update session info with exit code
612
- if (session) {
613
- session.info.exitCode = code;
614
- }
615
-
616
- if (session?.client) {
617
- writeToClient(session, encodeControl({ type: "exited", code }));
618
- session.client.end();
619
- }
620
-
621
- xterm.dispose();
622
- try { unlinkSync(socketPath); } catch {}
623
- sessions.delete(id);
624
- console.log(`[${sessionName}] exited (${code})`);
625
- };
626
- }
627
-
628
- /**
629
- * Sends cursor visibility and style state to the client.
630
- */
631
- function sendCursorState(session: SessionData): void {
632
- // Access xterm internal API for cursor hidden state
633
- // Note: _core is not part of the public API but is stable
634
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
635
- const xtermInternal = session.xterm as { _core?: { coreService?: { isCursorHidden?: boolean } } };
636
- const isCursorHidden = xtermInternal._core?.coreService?.isCursorHidden;
637
- if (typeof isCursorHidden === "boolean") {
638
- writeToClient(session, encodePTY(Buffer.from(isCursorHidden ? "\x1b[?25l" : "\x1b[?25h")));
639
- }
640
-
641
- const cursorStyle = session.xterm.options.cursorStyle;
642
- const cursorBlink = session.xterm.options.cursorBlink;
643
- let cursorStyleParam: number | null = null;
644
- if (cursorStyle === "block") {
645
- cursorStyleParam = cursorBlink ? 2 : 1;
646
- } else if (cursorStyle === "underline") {
647
- cursorStyleParam = cursorBlink ? 4 : 3;
648
- } else if (cursorStyle === "bar") {
649
- cursorStyleParam = cursorBlink ? 6 : 5;
650
- }
651
- if (cursorStyleParam !== null) {
652
- writeToClient(session, encodePTY(Buffer.from(`\x1b[${cursorStyleParam} q`)));
653
- }
654
- }
655
-
656
- /**
657
- * Clears the attach timer for a session.
658
- */
659
- function clearAttachTimer(session: SessionData): void {
660
- if (session.attachTimer) {
661
- clearTimeout(session.attachTimer);
662
- session.attachTimer = null;
663
- }
664
- }
665
-
666
- /**
667
- * Sends serialized terminal state to client during attach.
668
- */
669
- function sendSerializedState(session: SessionData, sessionName: string): void {
670
- // Debug mode: skip xterm serialization to test if it's the issue
671
- const skipSerialize = process.env.TMUX_LITE_SKIP_SERIALIZE === '1';
672
-
673
- try {
674
- // Send reset first to clear any bad modes
675
- console.log(`[${sessionName}] sending TERM_RESET`);
676
- writeToClient(session, encodePTY(TERM_RESET));
677
- writeToClient(session, encodePTY(Buffer.from("\x1b[2J\x1b[H"))); // clear + home
678
-
679
- if (!skipSerialize) {
680
- // Get serialized terminal state (including modes) for consistent redraws
681
- // Limit scrollback to prevent oversized payloads
682
- const serialized = session.serialize.serialize({
683
- scrollback: SERIALIZE_SCROLLBACK_LINES,
684
- });
685
- const serializedBytes = Buffer.from(serialized);
686
-
687
- // Log size for debugging
688
- const sizeKB = Math.round(serializedBytes.length / 1024);
689
- if (serializedBytes.length > PTY_CHUNK_SIZE) {
690
- console.log(`[${sessionName}] serialized ${serializedBytes.length} bytes (${sizeKB}KB) - will send in chunks`);
691
- } else {
692
- console.log(`[${sessionName}] serialized ${serializedBytes.length} bytes (${sizeKB}KB)`);
693
- }
694
-
695
- // Send in chunks if too large for a single frame
696
- if (serializedBytes.length > PTY_CHUNK_SIZE) {
697
- let offset = 0;
698
- let chunkNum = 0;
699
- while (offset < serializedBytes.length) {
700
- const chunkEnd = Math.min(offset + PTY_CHUNK_SIZE, serializedBytes.length);
701
- const chunk = serializedBytes.subarray(offset, chunkEnd);
702
- writeToClient(session, encodePTY(chunk));
703
- chunkNum++;
704
- offset = chunkEnd;
705
- }
706
- console.log(`[${sessionName}] attached (restored ${serializedBytes.length} bytes in ${chunkNum} chunks)`);
707
- } else {
708
- writeToClient(session, encodePTY(serializedBytes));
709
- console.log(`[${sessionName}] attached (restored ${serializedBytes.length} bytes)`);
710
- }
711
- } else {
712
- console.log(`[${sessionName}] attached (serialization skipped for debugging)`);
713
- }
714
- } catch (e) {
715
- console.log(`[${sessionName}] serialize error:`, e);
716
- // Fallback: just send a reset
717
- writeToClient(session, encodePTY(TERM_RESET));
718
- writeToClient(session, encodePTY(Buffer.from("\x1b[2J\x1b[H")));
719
- }
720
- }
721
-
722
- /**
723
- * Creates the startAttach function that handles the attach process.
724
- */
725
- function createStartAttach(sessionName: string): (session: SessionData) => void {
726
- return (session: SessionData) => {
727
- if (!session.attachPending || !session.client) return;
728
- session.attachPending = false;
729
- clearAttachTimer(session);
730
-
731
- // Wait for any pending xterm writes to complete
732
- const sendState = () => {
733
- if (session.pendingWrites > 0) {
734
- setTimeout(sendState, 10);
735
- return;
736
- }
737
-
738
- sendSerializedState(session, sessionName);
739
- sendCursorState(session);
740
-
741
- writeToClient(session, encodeControl({ type: "attach-ready", cols: session.xterm.cols, rows: session.xterm.rows }));
742
-
743
- const drainAttachBuffer = () => {
744
- const buffered = session.attachBuffer;
745
- session.attachBuffer = [];
746
- for (const chunk of buffered) {
747
- session.pendingWrites++;
748
- session.xterm.write(chunk, () => {
749
- session.pendingWrites--;
750
- });
751
- writeToClient(session, encodePTY(chunk));
752
- }
753
- };
754
-
755
- const attachStart = Date.now();
756
- const finalizeAttach = () => {
757
- if (session.attachBuffer.length > 0) {
758
- drainAttachBuffer();
759
- }
760
-
761
- if ((session.pendingWrites > 0 || session.attachBuffer.length > 0) &&
762
- Date.now() - attachStart < 200) {
763
- setTimeout(finalizeAttach, 10);
764
- return;
765
- }
766
-
767
- session.attaching = false;
768
-
769
- writeToClient(session, encodeControl({ type: "attached" }));
770
-
771
- // Set terminal title
772
- sendTitle(session, sessionName, session.processTitle);
773
- };
774
-
775
- finalizeAttach();
776
- };
777
-
778
- sendState();
779
- };
780
- }
781
-
782
- /**
783
- * Creates socket handlers for a session.
784
- */
785
- function createSessionSocketHandlers(
786
- id: string,
787
- sessionName: string,
788
- proc: Bun.Subprocess,
789
- startAttach: (session: SessionData) => void
790
- ): {
791
- open: (socket: any) => void;
792
- data: (socket: any, data: Buffer) => void;
793
- drain: (socket: any) => void;
794
- close: (socket: any) => void;
795
- } {
796
- return {
797
- open(socket) {
798
- const session = sessions.get(id);
799
- if (!session) return socket.end();
800
-
801
- // Kick existing client
802
- if (session.client) {
803
- writeToClient(session, encodeControl({ type: "kicked" }));
804
- session.client.end();
805
- }
806
-
807
- session.attaching = true;
808
- session.attachPending = true;
809
- session.attachBuffer = [];
810
- session.client = socket;
811
- session.clientWriter = createBufferedSocketWriter(socket);
812
- session.info.attached = true;
813
- session.lastAttached = Date.now(); // Record attach time for grace period
814
- session.ctrlBuffer = Buffer.alloc(0);
815
- clearAttachTimer(session);
816
- // Fallback timeout - client should send attach-init immediately, but just in case
817
- session.attachTimer = setTimeout(() => {
818
- if (session.attachPending) {
819
- console.log(`[${sessionName}] WARN: attach-init not received after 5s, starting attach anyway`);
820
- startAttach(session);
821
- }
822
- }, 5000);
823
- },
824
-
825
- data(socket, data) {
826
- const session = sessions.get(id);
827
- if (!session) return;
828
-
829
- const applyResize = (cols: number, rows: number) => {
830
- try {
831
- session.ptyTerminal.resize(cols, rows);
832
- session.xterm.resize(cols, rows);
833
- // Send SIGWINCH to process group so children (vim, etc.) get it
834
- try {
835
- process.kill(-proc.pid, "SIGWINCH");
836
- } catch {
837
- try {
838
- process.kill(proc.pid, "SIGWINCH");
839
- } catch {}
840
- }
841
- } catch {}
842
- };
843
-
844
- let buf = Buffer.from(data);
845
-
846
- // Prepend any buffered data
847
- if (session.ctrlBuffer.length > 0) {
848
- buf = Buffer.concat([session.ctrlBuffer, buf]);
849
- }
850
-
851
- // Parse frames using the new framed protocol
852
- let frames;
853
- let remaining;
854
- try {
855
- const result = parseFrames(buf);
856
- frames = result.frames;
857
- remaining = result.remaining;
858
- } catch (err) {
859
- // Protocol error - likely desync or corrupted data
860
- const msg = err instanceof Error ? err.message : 'Frame parse error';
861
- console.error(`[${sessionName}] Frame parse error: ${msg}`);
862
- // Close the client connection on protocol error
863
- socket.end();
864
- return;
865
- }
866
- // Copy remaining bytes - subarray references can become invalid when Bun reuses buffers
867
- session.ctrlBuffer = Buffer.from(remaining);
868
-
869
- for (const frame of frames) {
870
- if (frame.type === FrameType.CONTROL) {
871
- const ctrl = decodeControl(frame.payload) as SessionCtrl;
872
- if (ctrl.type === "resize" || ctrl.type === "attach-init") {
873
- applyResize(ctrl.cols, ctrl.rows);
874
- if (session.attaching && session.attachPending) {
875
- startAttach(session);
876
- }
877
- } else if (ctrl.type === "detach") {
878
- // Send reset before detaching to clean up client terminal
879
- writeToClient(session, encodePTY(TERM_RESET));
880
- session.client = null;
881
- session.clientWriter = null;
882
- session.info.attached = false;
883
- session.attaching = false;
884
- session.attachPending = false;
885
- clearAttachTimer(session);
886
- session.attachBuffer = [];
887
- session.lastDetached = Date.now(); // Record detach time for grace period
888
- socket.end();
889
- console.log(`[${sessionName}] detached`);
890
- }
891
- } else if (frame.type === FrameType.PTY) {
892
- // Raw PTY input - write to terminal
893
- session.ptyTerminal.write(frame.payload);
894
- // Track last interaction time
895
- session.lastInteraction = Date.now();
896
- }
897
- }
898
- },
899
-
900
- drain(socket) {
901
- const session = sessions.get(id);
902
- if (session && session.client === socket) {
903
- flushClient(session);
904
- }
905
- },
906
-
907
- close(socket) {
908
- const session = sessions.get(id);
909
- if (session && session.client === socket) {
910
- session.client = null;
911
- session.clientWriter = null;
912
- session.info.attached = false;
913
- session.attaching = false;
914
- session.attachPending = false;
915
- clearAttachTimer(session);
916
- session.attachBuffer = [];
917
- console.log(`[${sessionName}] disconnected`);
918
- }
919
- }
920
- };
921
- }
922
-
923
- /**
924
- * Builds the shell environment with integration hooks.
925
- */
926
- function buildShellEnvironment(id: string, shell: string): Record<string, string> {
927
- // Shell integration: report non-zero exit codes via OSC 777
928
- // This creates inbox notifications for failed commands
929
- const exitReporter = '__tl_report() { local e=$?; [[ $e -ne 0 ]] && printf "\\033]777;exit:%d\\007" "$e"; return $e; }';
930
-
931
- const shellEnv: Record<string, string> = {
932
- ...process.env as Record<string, string>,
933
- TMUX_LITE: id,
934
- };
935
-
936
- // Add PROMPT_COMMAND for bash
937
- if (shell.endsWith('/bash') || shell.endsWith('/sh')) {
938
- const existingPrompt = process.env.PROMPT_COMMAND || '';
939
- shellEnv.PROMPT_COMMAND = `${exitReporter}; __tl_report${existingPrompt ? '; ' + existingPrompt : ''}`;
940
- }
941
-
942
- return shellEnv;
943
- }
944
-
945
- // ============================================================================
946
- // Main Session Creation
947
- // ============================================================================
948
-
949
- function createSession(name: string | undefined, cwd: string): Session {
950
- const id = genId();
951
- const sessionName = name || `session-${id}`;
952
- const socketPath = getSessionSocketPath(id);
953
- const socketDir = dirname(socketPath);
954
- if (!existsSync(socketDir)) {
955
- mkdirSync(socketDir, { recursive: true });
956
- }
957
-
958
- const cols = process.stdout.columns || 80;
959
- const rows = process.stdout.rows || 24;
960
-
961
- // Create xterm-headless for proper terminal state tracking
962
- const xterm = new XTerminal({
963
- cols,
964
- rows,
965
- // Keep stored scrollback bounded to avoid slow attach+render on large sessions.
966
- // Note: attach serialization is additionally capped by SERIALIZE_SCROLLBACK_LINES.
967
- scrollback: 2_000,
968
- allowProposedApi: true,
969
- });
970
-
971
- const serialize = new SerializeAddon();
972
- xterm.loadAddon(serialize);
973
-
974
- // Set up xterm event handlers for notifications (bell, title changes)
975
- const { getProcessTitle } = setupXtermEventHandlers(id, sessionName, xterm);
976
-
977
- // Initialize idle detection state
978
- const idleState: IdleDetectionState = {
979
- lastOutputTime: 0,
980
- outputSinceIdle: 0,
981
- idleTimer: null,
982
- };
983
-
984
- // Initialize OSC 133 state for shell integration
985
- const osc133State: Osc133State = {
986
- commandRunning: false,
987
- };
988
-
989
- // Create the idle checker function
990
- const checkIdle = createIdleChecker(id, sessionName, xterm, idleState, getProcessTitle);
991
-
992
- // Create PTY terminal with data handler
993
- const ptyDataHandler = createPtyDataHandler(
994
- id,
995
- sessionName,
996
- xterm,
997
- idleState,
998
- osc133State,
999
- checkIdle,
1000
- getProcessTitle
1001
- );
1002
-
1003
- const ptyTerminal = new Bun.Terminal({
1004
- cols,
1005
- rows,
1006
- data: ptyDataHandler,
1007
- });
1008
-
1009
- // Terminal query support (server-side): respond to DSR (CSI 6 n) with CPR.
1010
- const disposeDsr = installDsrCprResponder(xterm, (data) => {
1011
- try { ptyTerminal.write(data); } catch {}
1012
- });
1013
-
1014
- // Spawn shell process
1015
- const shell = process.env.SHELL || "/bin/bash";
1016
- const shellEnv = buildShellEnvironment(id, shell);
1017
-
1018
- const proc = Bun.spawn([shell], {
1019
- terminal: ptyTerminal,
1020
- cwd,
1021
- env: shellEnv,
1022
- });
1023
-
1024
- // Handle process exit
1025
- proc.exited.then(handleProcessExit(id, sessionName, xterm, socketPath, disposeDsr, getProcessTitle));
1026
-
1027
- // Create session info
1028
- const info: Session = {
1029
- id,
1030
- name: sessionName,
1031
- socketPath,
1032
- pid: proc.pid,
1033
- attached: false,
1034
- cwd,
1035
- createdAt: Date.now(),
1036
- };
1037
-
1038
- // Create attach handler
1039
- const startAttach = createStartAttach(sessionName);
1040
-
1041
- // Create and bind socket handlers
1042
- const socketHandlers = createSessionSocketHandlers(id, sessionName, proc, startAttach);
1043
-
1044
- // Create session socket
1045
- Bun.listen({
1046
- unix: socketPath,
1047
- socket: socketHandlers,
1048
- });
1049
-
1050
- // Store session data
1051
- sessions.set(id, {
1052
- info,
1053
- ptyTerminal,
1054
- xterm,
1055
- serialize,
1056
- proc,
1057
- client: null,
1058
- clientWriter: null,
1059
- ctrlBuffer: Buffer.alloc(0),
1060
- pendingWrites: 0,
1061
- attaching: false,
1062
- attachBuffer: [],
1063
- attachPending: false,
1064
- attachTimer: null,
1065
- processTitle: '',
1066
- lastInteraction: 0, // No interaction yet
1067
- lastDetached: 0, // Never detached yet
1068
- lastAttached: 0, // Never attached yet (will be set on first attach)
1069
- });
1070
-
1071
- console.log(`[${sessionName}] created (pid ${proc.pid})`);
1072
- return info;
1073
- }
1074
-
1075
- // Router server
1076
- Bun.listen({
1077
- unix: ROUTER_SOCKET,
1078
- socket: {
1079
- open(socket) {
1080
- const socketState = getRouterSocketState(socket);
1081
- socketState.writer = createBufferedSocketWriter(socket as any);
1082
- },
1083
- data(socket, data) {
1084
- const socketState = getRouterSocketState(socket);
1085
- const combined = Buffer.concat([socketState.buffer, Buffer.from(data)]);
1086
- let decoded;
1087
-
1088
- try {
1089
- decoded = decodeRouterMessages(combined);
1090
- } catch (err) {
1091
- const message = err instanceof Error ? err.message : "Invalid request";
1092
- if (socketState.writer) socketState.writer.write(encodeRouterMessage({ type: "error", message }));
1093
- else socket.write(encodeRouterMessage({ type: "error", message }));
1094
- socketState.buffer = Buffer.alloc(0);
1095
- return;
1096
- }
1097
-
1098
- socketState.buffer = decoded.remaining;
1099
-
1100
- for (const message of decoded.messages) {
1101
- const cmd = message as Command;
1102
- let res: Response;
1103
-
1104
- // Helper to get session info with current processTitle
1105
- const getSessionInfo = (s: SessionData): Session => ({
1106
- ...s.info,
1107
- processTitle: s.processTitle || undefined,
1108
- });
1109
-
1110
- switch (cmd.type) {
1111
- case "list":
1112
- res = {
1113
- type: "sessions",
1114
- sessions: Array.from(sessions.values()).map(getSessionInfo)
1115
- };
1116
- break;
1117
-
1118
- case "new":
1119
- try {
1120
- const session = createSession(cmd.name, cmd.cwd);
1121
- res = { type: "session", session };
1122
- } catch (e) {
1123
- const errMsg = e instanceof Error ? e.message : String(e);
1124
- console.error(`[server] createSession failed: ${errMsg}`);
1125
- res = { type: "error", message: `Failed to create session: ${errMsg}` };
1126
- }
1127
- break;
1128
-
1129
- case "attach": {
1130
- const s = sessions.get(cmd.id);
1131
- if (!s) {
1132
- res = { type: "error", message: `Session ${cmd.id} not found` };
1133
- } else if (s.info.attached && !cmd.force) {
1134
- res = { type: "already-attached", session: getSessionInfo(s) };
1135
- } else {
1136
- res = { type: "session", session: getSessionInfo(s) };
1137
- }
1138
- break;
1139
- }
1140
-
1141
- case "kill": {
1142
- const s = sessions.get(cmd.id);
1143
- if (!s) {
1144
- res = { type: "error", message: `Session ${cmd.id} not found` };
1145
- } else {
1146
- // Use SIGKILL to forcefully terminate - SIGTERM is often ignored by shells
1147
- s.proc.kill(9);
1148
- res = { type: "ok" };
1149
- }
1150
- break;
1151
- }
1152
-
1153
- case "kill-server":
1154
- console.log("Shutting down...");
1155
- for (const [id, s] of sessions) {
1156
- s.xterm.dispose();
1157
- s.proc.kill(9); // Use SIGKILL - shells ignore SIGTERM
1158
- }
1159
- // Clean up PID file
1160
- try { unlinkSync(PID_FILE); } catch {}
1161
- res = { type: "ok" };
1162
- if (socketState.writer) socketState.writer.write(encodeRouterMessage(res));
1163
- else socket.write(encodeRouterMessage(res));
1164
- setTimeout(() => process.exit(0), 100);
1165
- return;
1166
-
1167
- case "inbox":
1168
- res = { type: "inbox", items: [...inbox] };
1169
- break;
1170
-
1171
- case "inbox-clear":
1172
- if (cmd.id) {
1173
- const idx = inbox.findIndex(i => i.id === cmd.id);
1174
- if (idx !== -1) inbox.splice(idx, 1);
1175
- } else {
1176
- inbox.length = 0;
1177
- }
1178
- broadcastTitleUpdate();
1179
- res = { type: "ok" };
1180
- break;
1181
-
1182
- case "inbox-read": {
1183
- const item = inbox.find(i => i.id === cmd.id);
1184
- if (item) item.read = true;
1185
- broadcastTitleUpdate();
1186
- res = { type: "ok" };
1187
- break;
1188
- }
1189
-
1190
- case "version":
1191
- res = {
1192
- type: "version",
1193
- version: PACKAGE_VERSION,
1194
- protocol: PROTOCOL_VERSION,
1195
- };
1196
- break;
1197
-
1198
- case "status": {
1199
- const sessionList = Array.from(sessions.values());
1200
- const attachedCount = sessionList.filter(s => s.info.attached).length;
1201
- res = {
1202
- type: "status",
1203
- version: PACKAGE_VERSION,
1204
- protocol: PROTOCOL_VERSION,
1205
- pid: process.pid,
1206
- uptime: Math.floor((Date.now() - SERVER_START_TIME) / 1000),
1207
- sessions: sessionList.length,
1208
- attached: attachedCount,
1209
- };
1210
- break;
1211
- }
1212
-
1213
- default:
1214
- res = { type: "error", message: "Unknown command" };
1215
- }
1216
-
1217
- if (socketState.writer) socketState.writer.write(encodeRouterMessage(res));
1218
- else socket.write(encodeRouterMessage(res));
1219
- }
1220
- },
1221
- drain(socket) {
1222
- const socketState = getRouterSocketState(socket);
1223
- socketState.writer?.flush?.();
1224
- },
1225
- close(socket) {
1226
- clearRouterSocketState(socket);
1227
- }
1228
- }
1229
- });
1230
-
1231
- // Handle unexpected termination - kill all session processes
1232
- function cleanupAndExit(signal: string) {
1233
- console.log(`\nReceived ${signal}, cleaning up sessions...`);
1234
- for (const [id, s] of sessions) {
1235
- try {
1236
- s.xterm.dispose();
1237
- s.proc.kill(9);
1238
- } catch {}
1239
- }
1240
- // Clean up PID file
1241
- try { unlinkSync(PID_FILE); } catch {}
1242
- process.exit(0);
1243
- }
1244
-
1245
- process.on('SIGTERM', () => cleanupAndExit('SIGTERM'));
1246
- process.on('SIGINT', () => cleanupAndExit('SIGINT'));
1247
- process.on('SIGHUP', () => cleanupAndExit('SIGHUP'));
1248
-
1249
- console.log("tmux-lite server running (xterm-headless)");
1250
- console.log(`Socket: ${ROUTER_SOCKET}\n`);