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,1250 @@
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`);