@wangyaoshen/remux 0.3.8-dev.29e114b

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 (183) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
  4. package/.github/dependabot.yml +33 -0
  5. package/.github/workflows/ci.yml +65 -0
  6. package/.github/workflows/deploy.yml +65 -0
  7. package/.github/workflows/publish.yml +312 -0
  8. package/.github/workflows/release-please.yml +21 -0
  9. package/.gitmodules +3 -0
  10. package/.nvmrc +1 -0
  11. package/.release-please-manifest.json +3 -0
  12. package/CLAUDE.md +104 -0
  13. package/Dockerfile +23 -0
  14. package/LICENSE +21 -0
  15. package/README.md +120 -0
  16. package/apps/ios/Config/signing.xcconfig +4 -0
  17. package/apps/ios/Package.swift +26 -0
  18. package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
  19. package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  20. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
  21. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
  22. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
  23. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
  24. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
  25. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
  26. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
  27. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
  28. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
  29. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
  30. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
  31. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
  32. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
  33. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
  34. package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
  35. package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
  36. package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
  37. package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
  38. package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
  39. package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
  40. package/apps/ios/Sources/Remux/RootView.swift +130 -0
  41. package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
  42. package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
  43. package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
  44. package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
  45. package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
  46. package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
  47. package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
  48. package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
  49. package/apps/macos/Package.swift +37 -0
  50. package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
  51. package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
  52. package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
  53. package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
  54. package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
  55. package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
  56. package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
  57. package/apps/macos/Resources/terminfo/67/ghostty +0 -0
  58. package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
  59. package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
  60. package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
  61. package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
  62. package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
  63. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
  64. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
  65. package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
  66. package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
  67. package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
  68. package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
  69. package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
  70. package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
  71. package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
  72. package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
  73. package/apps/macos/Sources/Remux/SocketController.swift +258 -0
  74. package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
  75. package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
  76. package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
  77. package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
  78. package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
  79. package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
  80. package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
  81. package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
  82. package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
  83. package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
  84. package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
  85. package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
  86. package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
  87. package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
  88. package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
  89. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
  90. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
  91. package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
  92. package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
  93. package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
  94. package/build.mjs +33 -0
  95. package/native/android/DecodeGoldenPayloads.kt +487 -0
  96. package/native/android/ProtocolModels.kt +188 -0
  97. package/native/ios/DecodeGoldenPayloads.swift +711 -0
  98. package/native/ios/ProtocolModels.swift +200 -0
  99. package/package.json +45 -0
  100. package/packages/RemuxKit/Package.swift +27 -0
  101. package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
  102. package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
  103. package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
  104. package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
  105. package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
  106. package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
  107. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
  108. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
  109. package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
  110. package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
  111. package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
  112. package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
  113. package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
  114. package/playwright.config.ts +17 -0
  115. package/pnpm-lock.yaml +1588 -0
  116. package/pty-daemon.js +303 -0
  117. package/release-please-config.json +14 -0
  118. package/scripts/auto-deploy.sh +46 -0
  119. package/scripts/build-dmg.sh +121 -0
  120. package/scripts/build-ghostty-kit.sh +43 -0
  121. package/scripts/check-active-terminology.mjs +132 -0
  122. package/scripts/setup-ci-secrets.sh +80 -0
  123. package/scripts/sync-ghostty-web.sh +28 -0
  124. package/scripts/upload-testflight.sh +100 -0
  125. package/server.js +7074 -0
  126. package/src/adapters/agent-events.ts +246 -0
  127. package/src/adapters/claude-code.ts +158 -0
  128. package/src/adapters/codex.ts +210 -0
  129. package/src/adapters/generic-shell.ts +58 -0
  130. package/src/adapters/index.ts +15 -0
  131. package/src/adapters/registry.ts +99 -0
  132. package/src/adapters/types.ts +41 -0
  133. package/src/auth.ts +174 -0
  134. package/src/e2ee.ts +236 -0
  135. package/src/git-service.ts +168 -0
  136. package/src/message-buffer.ts +137 -0
  137. package/src/pty-daemon.ts +357 -0
  138. package/src/push.ts +127 -0
  139. package/src/renderers.ts +455 -0
  140. package/src/server.ts +2407 -0
  141. package/src/service.ts +226 -0
  142. package/src/session.ts +978 -0
  143. package/src/store.ts +1422 -0
  144. package/src/team.ts +123 -0
  145. package/src/tunnel.ts +126 -0
  146. package/src/types.d.ts +50 -0
  147. package/src/vt-tracker.ts +188 -0
  148. package/src/workspace-head.ts +144 -0
  149. package/src/workspace.ts +153 -0
  150. package/src/ws-handler.ts +1526 -0
  151. package/start.ps1 +83 -0
  152. package/tests/adapters.test.js +171 -0
  153. package/tests/auth.test.js +243 -0
  154. package/tests/codex-adapter.test.js +535 -0
  155. package/tests/durable-stream.test.js +153 -0
  156. package/tests/e2e/app.spec.js +530 -0
  157. package/tests/e2ee.test.js +325 -0
  158. package/tests/message-buffer.test.js +245 -0
  159. package/tests/message-routing.test.js +305 -0
  160. package/tests/pty-daemon.test.js +346 -0
  161. package/tests/push.test.js +281 -0
  162. package/tests/renderers.test.js +391 -0
  163. package/tests/search-shell.test.js +499 -0
  164. package/tests/server.test.js +882 -0
  165. package/tests/service.test.js +267 -0
  166. package/tests/store.test.js +369 -0
  167. package/tests/tunnel.test.js +67 -0
  168. package/tests/workspace-head.test.js +116 -0
  169. package/tests/workspace.test.js +417 -0
  170. package/tsconfig.backend.json +11 -0
  171. package/tsconfig.json +15 -0
  172. package/tui/client/client_test.go +125 -0
  173. package/tui/client/connection.go +342 -0
  174. package/tui/client/host_manager.go +141 -0
  175. package/tui/config/cache.go +81 -0
  176. package/tui/config/config.go +53 -0
  177. package/tui/config/config_test.go +89 -0
  178. package/tui/go.mod +32 -0
  179. package/tui/go.sum +50 -0
  180. package/tui/main.go +261 -0
  181. package/tui/tests/integration_test.go +283 -0
  182. package/tui/ui/model.go +310 -0
  183. package/vitest.config.js +10 -0
@@ -0,0 +1,168 @@
1
+ /**
2
+ * E11: Git service — provides git status, diff, worktree, branch operations.
3
+ * Uses simple-git (npm). All operations are read-only by default.
4
+ * Write operations (commit, push) require explicit confirmation.
5
+ */
6
+
7
+ import simpleGit, { SimpleGit, StatusResult, DiffResult } from "simple-git";
8
+
9
+ /** Validate git ref names to prevent flag injection via user-supplied arguments. */
10
+ const SAFE_REF_RE = /^[a-zA-Z0-9._\/@{}\[\]:^~-]+$/;
11
+ function assertSafeRef(ref: string): void {
12
+ if (!ref || ref.startsWith("-") || !SAFE_REF_RE.test(ref)) {
13
+ throw new Error(`Invalid git ref: ${ref}`);
14
+ }
15
+ }
16
+
17
+ let git: SimpleGit | null = null;
18
+
19
+ export function initGitService(cwd?: string): void {
20
+ git = simpleGit(cwd || process.cwd());
21
+ }
22
+
23
+ function getGit(): SimpleGit {
24
+ if (!git) initGitService();
25
+ return git!;
26
+ }
27
+
28
+ // E11-001: git status query
29
+ export async function getGitStatus(): Promise<{
30
+ branch: string;
31
+ status: StatusResult;
32
+ recentCommits: Array<{ hash: string; message: string; date: string; author: string }>;
33
+ }> {
34
+ const g = getGit();
35
+ const status = await g.status();
36
+ const log = await g.log({ maxCount: 10 });
37
+
38
+ return {
39
+ branch: status.current || "unknown",
40
+ status,
41
+ recentCommits: log.all.map((c) => ({
42
+ hash: c.hash.substring(0, 7),
43
+ message: c.message,
44
+ date: c.date,
45
+ author: c.author_name,
46
+ })),
47
+ };
48
+ }
49
+
50
+ // E11-002: git diff query
51
+ export async function getGitDiff(base?: string): Promise<{
52
+ diff: string;
53
+ files: Array<{ file: string; insertions: number; deletions: number }>;
54
+ }> {
55
+ const g = getGit();
56
+ const diffBase = base || "HEAD";
57
+ assertSafeRef(diffBase);
58
+
59
+ const diffText = await g.diff([diffBase]);
60
+ const diffStat = await g.diffSummary([diffBase]);
61
+
62
+ return {
63
+ diff: diffText.substring(0, 1024 * 1024), // 1MB limit
64
+ files: diffStat.files.map((f) => ({
65
+ file: f.file,
66
+ insertions: "binary" in f && f.binary ? 0 : (f as { insertions: number }).insertions,
67
+ deletions: "binary" in f && f.binary ? 0 : (f as { deletions: number }).deletions,
68
+ })),
69
+ };
70
+ }
71
+
72
+ // E11-003: worktree operations
73
+ export async function getWorktrees(): Promise<
74
+ Array<{ path: string; branch: string; head: string }>
75
+ > {
76
+ const g = getGit();
77
+ const result = await g.raw(["worktree", "list", "--porcelain"]);
78
+ const worktrees: Array<{ path: string; branch: string; head: string }> = [];
79
+ let current: Record<string, string> = {};
80
+
81
+ for (const line of result.split("\n")) {
82
+ if (line.startsWith("worktree ")) {
83
+ if (current.path) worktrees.push({
84
+ path: current.path,
85
+ branch: current.branch || "",
86
+ head: current.head || "",
87
+ });
88
+ current = { path: line.replace("worktree ", "") };
89
+ } else if (line.startsWith("HEAD ")) {
90
+ current.head = line.replace("HEAD ", "").substring(0, 7);
91
+ } else if (line.startsWith("branch ")) {
92
+ current.branch = line.replace("branch refs/heads/", "");
93
+ }
94
+ }
95
+ if (current.path) worktrees.push({
96
+ path: current.path,
97
+ branch: current.branch || "",
98
+ head: current.head || "",
99
+ });
100
+
101
+ return worktrees;
102
+ }
103
+
104
+ export async function createWorktree(
105
+ branch: string,
106
+ worktreePath: string,
107
+ ): Promise<void> {
108
+ assertSafeRef(branch);
109
+ // Prevent path traversal: worktree path must be relative and within project
110
+ if (worktreePath.includes("..") || worktreePath.startsWith("/")) {
111
+ throw new Error(`Invalid worktree path: ${worktreePath}`);
112
+ }
113
+ const g = getGit();
114
+ await g.raw(["worktree", "add", "-b", branch, worktreePath, "dev"]);
115
+ }
116
+
117
+ // E11-013: branch comparison
118
+ export async function compareBranches(
119
+ base: string,
120
+ head: string,
121
+ ): Promise<{
122
+ ahead: number;
123
+ behind: number;
124
+ files: Array<{ file: string; insertions: number; deletions: number }>;
125
+ }> {
126
+ assertSafeRef(base);
127
+ assertSafeRef(head);
128
+ const g = getGit();
129
+ const diffStat = await g.diffSummary([`${base}...${head}`]);
130
+
131
+ // Count commits
132
+ const aheadLog = await g.log({ from: base, to: head });
133
+ const behindLog = await g.log({ from: head, to: base });
134
+
135
+ return {
136
+ ahead: aheadLog.total,
137
+ behind: behindLog.total,
138
+ files: diffStat.files.map((f) => ({
139
+ file: f.file,
140
+ insertions: "binary" in f && f.binary ? 0 : (f as { insertions: number }).insertions,
141
+ deletions: "binary" in f && f.binary ? 0 : (f as { deletions: number }).deletions,
142
+ })),
143
+ };
144
+ }
145
+
146
+ // E11-012: git webhook — watch .git for changes
147
+ export function watchGitChanges(
148
+ onChange: () => void,
149
+ ): () => void {
150
+ const fs = require("fs");
151
+ const path = require("path");
152
+ const gitDir = path.join(process.cwd(), ".git");
153
+
154
+ try {
155
+ const watcher = fs.watch(
156
+ gitDir,
157
+ { recursive: false },
158
+ (eventType: string, filename: string) => {
159
+ if (filename === "HEAD" || filename?.startsWith("refs")) {
160
+ onChange();
161
+ }
162
+ },
163
+ );
164
+ return () => watcher.close();
165
+ } catch {
166
+ return () => {};
167
+ }
168
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Per-device message ring buffer for offline message queuing.
3
+ * When a client disconnects, messages are buffered here so they can
4
+ * be replayed on reconnect (session recovery).
5
+ *
6
+ * Design influenced by Mosh's approach to state synchronization and
7
+ * tmux's client-server buffering model.
8
+ */
9
+
10
+ export interface BufferedMessage {
11
+ timestamp: number;
12
+ data: string; // serialized JSON message
13
+ }
14
+
15
+ /**
16
+ * Ring buffer that holds messages for a single device.
17
+ * Oldest messages are evicted when maxSize is exceeded.
18
+ * Messages older than maxAgeMs are filtered out on drain/prune.
19
+ */
20
+ export class MessageBuffer {
21
+ private buffer: BufferedMessage[] = [];
22
+ private maxSize: number;
23
+ private maxAgeMs: number;
24
+
25
+ constructor(maxSize = 1000, maxAgeMs = 10 * 60 * 1000) {
26
+ this.maxSize = maxSize;
27
+ this.maxAgeMs = maxAgeMs;
28
+ }
29
+
30
+ /**
31
+ * Add a message to the buffer.
32
+ * Evicts the oldest message if buffer is at capacity.
33
+ */
34
+ push(data: string): void {
35
+ if (this.buffer.length >= this.maxSize) {
36
+ this.buffer.shift(); // evict oldest
37
+ }
38
+ this.buffer.push({ timestamp: Date.now(), data });
39
+ }
40
+
41
+ /**
42
+ * Return all messages (optionally since a given timestamp).
43
+ * Filters out expired messages (older than maxAgeMs).
44
+ * Clears returned messages from the buffer.
45
+ */
46
+ drain(since?: number): BufferedMessage[] {
47
+ const now = Date.now();
48
+ const cutoff = now - this.maxAgeMs;
49
+
50
+ let result = this.buffer.filter((m) => m.timestamp > cutoff);
51
+ if (since !== undefined) {
52
+ result = result.filter((m) => m.timestamp > since);
53
+ }
54
+
55
+ this.buffer = [];
56
+ return result;
57
+ }
58
+
59
+ /**
60
+ * Clear all messages from the buffer.
61
+ */
62
+ clear(): void {
63
+ this.buffer = [];
64
+ }
65
+
66
+ /**
67
+ * Number of messages currently in the buffer.
68
+ */
69
+ get size(): number {
70
+ return this.buffer.length;
71
+ }
72
+
73
+ /**
74
+ * Remove messages older than maxAgeMs.
75
+ */
76
+ pruneExpired(): void {
77
+ const cutoff = Date.now() - this.maxAgeMs;
78
+ this.buffer = this.buffer.filter((m) => m.timestamp > cutoff);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Global registry of message buffers, keyed by deviceId.
84
+ * Runs periodic cleanup to evict stale/empty buffers.
85
+ */
86
+ export class BufferRegistry {
87
+ private buffers = new Map<string, MessageBuffer>();
88
+ private cleanupInterval: ReturnType<typeof setInterval> | null = null;
89
+
90
+ constructor() {
91
+ // Periodic cleanup every 60s
92
+ this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
93
+ }
94
+
95
+ /**
96
+ * Get or create a MessageBuffer for a device.
97
+ */
98
+ getOrCreate(deviceId: string): MessageBuffer {
99
+ let buf = this.buffers.get(deviceId);
100
+ if (!buf) {
101
+ buf = new MessageBuffer();
102
+ this.buffers.set(deviceId, buf);
103
+ }
104
+ return buf;
105
+ }
106
+
107
+ /**
108
+ * Remove the buffer for a device.
109
+ */
110
+ remove(deviceId: string): void {
111
+ this.buffers.delete(deviceId);
112
+ }
113
+
114
+ /**
115
+ * Remove empty buffers that have no messages (stale).
116
+ * Prune expired messages in all remaining buffers.
117
+ */
118
+ private cleanup(): void {
119
+ for (const [deviceId, buf] of this.buffers) {
120
+ buf.pruneExpired();
121
+ if (buf.size === 0) {
122
+ this.buffers.delete(deviceId);
123
+ }
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Tear down: clear the cleanup interval and all buffers.
129
+ */
130
+ destroy(): void {
131
+ if (this.cleanupInterval) {
132
+ clearInterval(this.cleanupInterval);
133
+ this.cleanupInterval = null;
134
+ }
135
+ this.buffers.clear();
136
+ }
137
+ }
@@ -0,0 +1,357 @@
1
+ /**
2
+ * PTY Daemon — independent Node.js process that holds a PTY alive.
3
+ * Each tab spawns one daemon. The daemon survives server restarts.
4
+ * Communication is via a Unix domain socket using a TLV frame protocol.
5
+ *
6
+ * Adapted from tmux client-server model and tsm's VT tracking pattern.
7
+ *
8
+ * Usage: node pty-daemon.js --socket <path> --shell <shell> --cols <n> --rows <n> --cwd <dir> --tab-id <id>
9
+ */
10
+
11
+ import net from "net";
12
+ import pty from "node-pty";
13
+ import { parseArgs } from "util";
14
+ import type { IPty } from "node-pty";
15
+
16
+ // ── TLV Frame Tags ──────────────────────────────────────────────
17
+
18
+ export const TAG_PTY_OUTPUT = 0x01;
19
+ export const TAG_CLIENT_INPUT = 0x02;
20
+ export const TAG_RESIZE = 0x03;
21
+ export const TAG_STATUS_REQ = 0x04;
22
+ export const TAG_STATUS_RES = 0x05;
23
+ export const TAG_SNAPSHOT_REQ = 0x06;
24
+ export const TAG_SNAPSHOT_RES = 0x07;
25
+ export const TAG_SCROLLBACK_REQ = 0x08;
26
+ export const TAG_SCROLLBACK_RES = 0x09;
27
+ export const TAG_SHUTDOWN = 0xff;
28
+
29
+ // ── TLV Frame Codec ─────────────────────────────────────────────
30
+
31
+ /**
32
+ * Encode a TLV frame: [1 byte tag][4 bytes length (big-endian)][payload].
33
+ */
34
+ export function encodeFrame(tag: number, payload: Buffer | string): Buffer {
35
+ const data = typeof payload === "string" ? Buffer.from(payload, "utf8") : payload;
36
+ const frame = Buffer.alloc(5 + data.length);
37
+ frame[0] = tag;
38
+ frame.writeUInt32BE(data.length, 1);
39
+ data.copy(frame, 5);
40
+ return frame;
41
+ }
42
+
43
+ /**
44
+ * TLV frame parser — accumulates data and emits complete frames.
45
+ */
46
+ export class FrameParser {
47
+ private buffer = Buffer.alloc(0);
48
+ private onFrame: (tag: number, payload: Buffer) => void;
49
+
50
+ constructor(onFrame: (tag: number, payload: Buffer) => void) {
51
+ this.onFrame = onFrame;
52
+ }
53
+
54
+ feed(data: Buffer): void {
55
+ this.buffer = Buffer.concat([this.buffer, data]);
56
+ while (this.buffer.length >= 5) {
57
+ const tag = this.buffer[0];
58
+ const length = this.buffer.readUInt32BE(1);
59
+ if (this.buffer.length < 5 + length) break; // incomplete frame
60
+ const payload = this.buffer.subarray(5, 5 + length);
61
+ this.buffer = this.buffer.subarray(5 + length);
62
+ this.onFrame(tag, payload);
63
+ }
64
+ }
65
+ }
66
+
67
+ // ── RingBuffer (scrollback) ─────────────────────────────────────
68
+
69
+ class RingBuffer {
70
+ private buf: Buffer;
71
+ private maxBytes: number;
72
+ private writePos: number;
73
+ private length: number;
74
+
75
+ constructor(maxBytes = 10 * 1024 * 1024) {
76
+ this.buf = Buffer.alloc(maxBytes);
77
+ this.maxBytes = maxBytes;
78
+ this.writePos = 0;
79
+ this.length = 0;
80
+ }
81
+
82
+ write(data: string | Buffer): void {
83
+ const bytes = typeof data === "string" ? Buffer.from(data) : data;
84
+ if (bytes.length >= this.maxBytes) {
85
+ bytes.copy(this.buf, 0, bytes.length - this.maxBytes);
86
+ this.writePos = 0;
87
+ this.length = this.maxBytes;
88
+ return;
89
+ }
90
+ const space = this.maxBytes - this.writePos;
91
+ if (bytes.length <= space) {
92
+ bytes.copy(this.buf, this.writePos);
93
+ } else {
94
+ bytes.copy(this.buf, this.writePos, 0, space);
95
+ bytes.copy(this.buf, 0, space);
96
+ }
97
+ this.writePos = (this.writePos + bytes.length) % this.maxBytes;
98
+ this.length = Math.min(this.length + bytes.length, this.maxBytes);
99
+ }
100
+
101
+ read(): Buffer {
102
+ if (this.length === 0) return Buffer.alloc(0);
103
+ if (this.length < this.maxBytes) {
104
+ return Buffer.from(this.buf.subarray(this.writePos - this.length, this.writePos));
105
+ }
106
+ return Buffer.concat([
107
+ this.buf.subarray(this.writePos),
108
+ this.buf.subarray(0, this.writePos),
109
+ ]);
110
+ }
111
+ }
112
+
113
+ // ── CLI argument parsing ────────────────────────────────────────
114
+
115
+ function parseCliArgs(): {
116
+ socket: string;
117
+ shell: string;
118
+ cols: number;
119
+ rows: number;
120
+ cwd: string;
121
+ tabId: string;
122
+ } {
123
+ const { values } = parseArgs({
124
+ options: {
125
+ socket: { type: "string" },
126
+ shell: { type: "string" },
127
+ cols: { type: "string" },
128
+ rows: { type: "string" },
129
+ cwd: { type: "string" },
130
+ "tab-id": { type: "string" },
131
+ },
132
+ strict: true,
133
+ });
134
+
135
+ if (!values.socket || !values.shell) {
136
+ console.error("Usage: pty-daemon --socket <path> --shell <shell> [--cols N] [--rows N] [--cwd dir] [--tab-id id]");
137
+ process.exit(1);
138
+ }
139
+
140
+ return {
141
+ socket: values.socket,
142
+ shell: values.shell,
143
+ cols: parseInt(values.cols || "80", 10),
144
+ rows: parseInt(values.rows || "24", 10),
145
+ cwd: values.cwd || process.env.HOME || "/",
146
+ tabId: values["tab-id"] || "0",
147
+ };
148
+ }
149
+
150
+ // ── Daemon main ─────────────────────────────────────────────────
151
+
152
+ function main(): void {
153
+ const args = parseCliArgs();
154
+
155
+ // Sequence counter for durable stream (Step 4)
156
+ let seq = 0;
157
+
158
+ // Detach from parent process if possible (survive server restarts)
159
+ if (typeof process.disconnect === "function") {
160
+ try { process.disconnect(); } catch { /* already disconnected */ }
161
+ }
162
+
163
+ // Create PTY
164
+ const ptyProcess: IPty = pty.spawn(args.shell, [], {
165
+ name: "xterm-256color",
166
+ cols: args.cols,
167
+ rows: args.rows,
168
+ cwd: args.cwd,
169
+ env: {
170
+ ...process.env,
171
+ TERM: "xterm-256color",
172
+ COLORTERM: "truecolor",
173
+ },
174
+ });
175
+
176
+ const scrollback = new RingBuffer();
177
+ const clients = new Set<net.Socket>();
178
+ let alive = true;
179
+
180
+ console.log(`[pty-daemon] started: pid=${ptyProcess.pid} socket=${args.socket} tab-id=${args.tabId}`);
181
+
182
+ // ── PTY output → broadcast to all clients + scrollback ──
183
+
184
+ ptyProcess.onData((data: string) => {
185
+ seq++;
186
+ scrollback.write(data);
187
+ const frame = encodeFrame(TAG_PTY_OUTPUT, data);
188
+ for (const client of clients) {
189
+ try {
190
+ client.write(frame);
191
+ } catch {
192
+ // Client write error — will be cleaned up on close
193
+ }
194
+ }
195
+ });
196
+
197
+ // ── PTY exit ──
198
+
199
+ ptyProcess.onExit(({ exitCode }) => {
200
+ alive = false;
201
+ console.log(`[pty-daemon] PTY exited: code=${exitCode} tab-id=${args.tabId}`);
202
+
203
+ // Notify all clients
204
+ const exitMsg = `\r\n\x1b[33mShell exited (code: ${exitCode})\x1b[0m\r\n`;
205
+ const frame = encodeFrame(TAG_PTY_OUTPUT, exitMsg);
206
+ for (const client of clients) {
207
+ try { client.write(frame); } catch { /* ignore */ }
208
+ }
209
+
210
+ // Wait briefly for clients to disconnect, then clean up
211
+ setTimeout(() => {
212
+ for (const client of clients) {
213
+ try { client.end(); } catch { /* ignore */ }
214
+ }
215
+ cleanup();
216
+ }, 2000);
217
+ });
218
+
219
+ // ── Unix socket server ──
220
+
221
+ const server = net.createServer((socket) => {
222
+ clients.add(socket);
223
+ console.log(`[pty-daemon] client connected (total: ${clients.size})`);
224
+
225
+ const parser = new FrameParser((tag, payload) => {
226
+ switch (tag) {
227
+ case TAG_CLIENT_INPUT:
228
+ if (alive) {
229
+ ptyProcess.write(payload.toString("utf8"));
230
+ }
231
+ break;
232
+
233
+ case TAG_RESIZE: {
234
+ try {
235
+ const { cols, rows } = JSON.parse(payload.toString("utf8"));
236
+ if (alive && cols > 0 && rows > 0) {
237
+ ptyProcess.resize(
238
+ Math.max(1, Math.min(cols, 500)),
239
+ Math.max(1, Math.min(rows, 200)),
240
+ );
241
+ }
242
+ } catch { /* invalid resize payload */ }
243
+ break;
244
+ }
245
+
246
+ case TAG_STATUS_REQ: {
247
+ const status = JSON.stringify({
248
+ pid: ptyProcess.pid,
249
+ cols: args.cols,
250
+ rows: args.rows,
251
+ alive,
252
+ cwd: args.cwd,
253
+ tabId: args.tabId,
254
+ seq,
255
+ });
256
+ socket.write(encodeFrame(TAG_STATUS_RES, status));
257
+ break;
258
+ }
259
+
260
+ case TAG_SNAPSHOT_REQ: {
261
+ const data = scrollback.read();
262
+ socket.write(encodeFrame(TAG_SNAPSHOT_RES, data));
263
+ break;
264
+ }
265
+
266
+ case TAG_SCROLLBACK_REQ: {
267
+ // Return full scrollback (future: support since-seq filtering)
268
+ const data = scrollback.read();
269
+ socket.write(encodeFrame(TAG_SCROLLBACK_RES, data));
270
+ break;
271
+ }
272
+
273
+ case TAG_SHUTDOWN:
274
+ console.log(`[pty-daemon] shutdown requested`);
275
+ if (alive) {
276
+ try { ptyProcess.kill(); } catch { /* already dead */ }
277
+ }
278
+ // Close all clients and clean up
279
+ for (const c of clients) {
280
+ try { c.end(); } catch { /* ignore */ }
281
+ }
282
+ cleanup();
283
+ break;
284
+ }
285
+ });
286
+
287
+ socket.on("data", (data) => {
288
+ parser.feed(Buffer.isBuffer(data) ? data : Buffer.from(data));
289
+ });
290
+
291
+ socket.on("close", () => {
292
+ clients.delete(socket);
293
+ console.log(`[pty-daemon] client disconnected (total: ${clients.size})`);
294
+ });
295
+
296
+ socket.on("error", (err) => {
297
+ console.error(`[pty-daemon] client socket error:`, err.message);
298
+ clients.delete(socket);
299
+ });
300
+ });
301
+
302
+ // Clean up stale socket file before listening
303
+ try {
304
+ const fs = require("fs");
305
+ if (fs.existsSync(args.socket)) {
306
+ fs.unlinkSync(args.socket);
307
+ }
308
+ } catch { /* ignore */ }
309
+
310
+ server.listen(args.socket, () => {
311
+ console.log(`[pty-daemon] listening on ${args.socket}`);
312
+ });
313
+
314
+ server.on("error", (err) => {
315
+ console.error(`[pty-daemon] server error:`, err.message);
316
+ cleanup();
317
+ });
318
+
319
+ // ── Cleanup ──
320
+
321
+ function cleanup(): void {
322
+ try {
323
+ const fs = require("fs");
324
+ if (fs.existsSync(args.socket)) {
325
+ fs.unlinkSync(args.socket);
326
+ }
327
+ } catch { /* ignore */ }
328
+ server.close();
329
+ process.exit(0);
330
+ }
331
+
332
+ // ── Signal handling ──
333
+
334
+ process.on("SIGTERM", () => {
335
+ console.log(`[pty-daemon] SIGTERM received`);
336
+ if (alive) {
337
+ try { ptyProcess.kill(); } catch { /* already dead */ }
338
+ }
339
+ cleanup();
340
+ });
341
+
342
+ process.on("SIGINT", () => {
343
+ // Ignore SIGINT in daemon — only respond to SIGTERM or explicit shutdown
344
+ });
345
+ }
346
+
347
+ // Run only when executed as the main entry point (not when imported).
348
+ // When esbuild bundles this file as pty-daemon.js, it becomes the main module.
349
+ // When imported by session.ts (for encodeFrame/FrameParser), we skip main().
350
+ //
351
+ // Detection: if process.argv[1] contains "pty-daemon" or if CLI args include --socket,
352
+ // this is being run as the daemon entry point.
353
+ const isMainEntry = process.argv[1]?.includes("pty-daemon") ||
354
+ process.argv.includes("--socket");
355
+ if (isMainEntry) {
356
+ main();
357
+ }