@wangyaoshen/remux 0.3.8-dev.a8ceb0c

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,1526 @@
1
+ /**
2
+ * WebSocket handler for Remux.
3
+ * All WebSocket message routing: auth gate, control messages, terminal I/O.
4
+ *
5
+ * Protocol envelope (v1): all server-sent JSON messages are wrapped in
6
+ * { v: 1, type: string, payload: T }. Incoming messages accept both
7
+ * enveloped (v:1) and legacy bare formats for backward compatibility.
8
+ *
9
+ * Client connection state: each WebSocket gets a clientId and role
10
+ * (active/observer). First client on a tab is active; subsequent are
11
+ * observers whose terminal input is silently dropped.
12
+ */
13
+
14
+ import crypto from "crypto";
15
+ import type http from "http";
16
+ import { WebSocketServer } from "ws";
17
+ import { E2EESession } from "./e2ee.js";
18
+ import type WebSocket from "ws";
19
+ import { BufferRegistry } from "./message-buffer.js";
20
+ import {
21
+ type RemuxWebSocket,
22
+ controlClients,
23
+ sessionMap,
24
+ createSession,
25
+ createTab,
26
+ deleteSession,
27
+ getState,
28
+ getFirstSessionName,
29
+ findTab,
30
+ attachToTab,
31
+ detachFromTab,
32
+ recalcTabSize,
33
+ broadcastState,
34
+ setBroadcastHooks,
35
+ setBufferHooks,
36
+ reviveTab,
37
+ } from "./session.js";
38
+ import {
39
+ getHead,
40
+ updateHead,
41
+ } from "./workspace-head.js";
42
+ import {
43
+ encodeFrame,
44
+ TAG_CLIENT_INPUT,
45
+ } from "./pty-daemon.js";
46
+ import { validateToken, registerDevice } from "./auth.js";
47
+ import {
48
+ listDevices,
49
+ updateDeviceTrust,
50
+ renameDevice,
51
+ deleteDevice,
52
+ findDeviceById,
53
+ createPairCode,
54
+ consumePairCode,
55
+ touchDevice,
56
+ savePushSubscription,
57
+ removePushSubscription,
58
+ getPushSubscription,
59
+ type Device,
60
+ } from "./store.js";
61
+ import {
62
+ getVapidPublicKey,
63
+ sendPushNotification,
64
+ broadcastPush,
65
+ } from "./push.js";
66
+ import {
67
+ createTopic,
68
+ updateTopic,
69
+ listTopics,
70
+ deleteTopic,
71
+ createRun,
72
+ updateRun,
73
+ listRuns,
74
+ listArtifacts,
75
+ createApproval,
76
+ listApprovals,
77
+ resolveApproval,
78
+ searchEntities,
79
+ createNote,
80
+ listNotes,
81
+ updateNote,
82
+ deleteNote,
83
+ togglePinNote,
84
+ listCommands,
85
+ removeStaleTab,
86
+ removeSession as removeSessionFromDb,
87
+ } from "./store.js";
88
+ import {
89
+ captureSnapshot,
90
+ createCommandCard,
91
+ getTopicSummary,
92
+ generateHandoffBundle,
93
+ } from "./workspace.js";
94
+ import {
95
+ getChunksSince,
96
+ getDeviceCursor,
97
+ updateDeviceCursor,
98
+ getLatestSnapshot,
99
+ saveStreamChunk,
100
+ saveTabSnapshot,
101
+ } from "./store.js";
102
+ import {
103
+ detectContentType,
104
+ renderDiff,
105
+ renderMarkdown,
106
+ renderAnsi,
107
+ } from "./renderers.js";
108
+
109
+ // ── Offline Message Buffer ──────────────────────────────────────
110
+
111
+ /**
112
+ * Global buffer registry: stores messages for recently-disconnected devices
113
+ * so they can be replayed on reconnect. Keyed by deviceId.
114
+ */
115
+ export const bufferRegistry = new BufferRegistry();
116
+
117
+ /**
118
+ * Set of deviceIds that are currently "recently disconnected" — their buffer
119
+ * is active and collecting messages. Cleared when the device reconnects
120
+ * and sends a `resume`, or when the buffer expires.
121
+ */
122
+ export const disconnectedDevices = new Set<string>();
123
+
124
+ /**
125
+ * Map deviceId -> tabId the device was watching when it disconnected.
126
+ * Used to buffer PTY output for the correct tab.
127
+ */
128
+ export const disconnectedDeviceTab = new Map<string, number>();
129
+
130
+ /**
131
+ * Buffer an enveloped message for a disconnected device.
132
+ * Only buffers if the device is in the recently-disconnected set.
133
+ */
134
+ export function bufferForDevice(deviceId: string, type: string, payload: any): void {
135
+ if (!disconnectedDevices.has(deviceId)) return;
136
+ const buf = bufferRegistry.getOrCreate(deviceId);
137
+ buf.push(JSON.stringify({ v: 1, type, payload }));
138
+ }
139
+
140
+ /**
141
+ * Buffer raw terminal data for a disconnected device.
142
+ * Only buffers if the device is in the recently-disconnected set.
143
+ */
144
+ export function bufferRawForDevice(deviceId: string, data: string): void {
145
+ if (!disconnectedDevices.has(deviceId)) return;
146
+ const buf = bufferRegistry.getOrCreate(deviceId);
147
+ buf.push(data);
148
+ }
149
+
150
+ /**
151
+ * Buffer raw terminal data for all disconnected devices watching a given tab.
152
+ */
153
+ export function bufferTabOutput(tabId: number, data: string): void {
154
+ for (const [deviceId, watchingTabId] of disconnectedDeviceTab) {
155
+ if (watchingTabId === tabId) {
156
+ bufferRawForDevice(deviceId, data);
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Buffer a state broadcast for all disconnected devices.
163
+ */
164
+ export function bufferStateForDisconnected(): void {
165
+ if (disconnectedDevices.size === 0) return;
166
+ // Lazy import to avoid calling getState before session module is ready
167
+ const state = getState();
168
+ const clients = getClientList();
169
+ for (const deviceId of disconnectedDevices) {
170
+ bufferForDevice(deviceId, "state", { sessions: state, clients });
171
+ }
172
+ }
173
+
174
+ // ── Protocol Envelope ───────────────────────────────────────────
175
+
176
+ /**
177
+ * Send an enveloped JSON message: { v: 1, type, payload }.
178
+ */
179
+ export function sendEnvelope<T>(
180
+ ws: WebSocket | RemuxWebSocket,
181
+ type: string,
182
+ payload: T,
183
+ ): void {
184
+ if (ws.readyState === ws.OPEN) {
185
+ ws.send(JSON.stringify({ v: 1, type, payload }));
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Unwrap an incoming message. If it has `v: 1`, extract type + payload.
191
+ * Otherwise treat as legacy bare message (return as-is).
192
+ */
193
+ function unwrapMessage(parsed: any): { type: string; [key: string]: any } {
194
+ if (parsed && parsed.v === 1 && typeof parsed.type === "string") {
195
+ return { type: parsed.type, ...(parsed.payload || {}) };
196
+ }
197
+ return parsed;
198
+ }
199
+
200
+ // ── Client Connection State ─────────────────────────────────────
201
+
202
+ export interface ClientState {
203
+ clientId: string;
204
+ role: "active" | "observer";
205
+ connectedAt: number;
206
+ lastActiveAt: number; // last time this client sent input or took control
207
+ currentSession: string | null;
208
+ currentTabId: number | null;
209
+ }
210
+
211
+ // If active client hasn't sent input for this long, new clients can claim active
212
+ const ACTIVE_IDLE_TIMEOUT_MS = 120_000; // 2 minutes
213
+
214
+ /** Map from WebSocket to client tracking state. */
215
+ export const clientStates = new Map<RemuxWebSocket, ClientState>();
216
+
217
+ /** Map from WebSocket to E2EE session (only present when client initiated E2EE). */
218
+ export const e2eeSessions = new Map<RemuxWebSocket, E2EESession>();
219
+
220
+ /**
221
+ * Send data to a WebSocket, encrypting if E2EE is established.
222
+ * For raw terminal output that bypasses sendEnvelope.
223
+ */
224
+ export function e2eeSend(ws: WebSocket | RemuxWebSocket, data: string): void {
225
+ if (ws.readyState !== ws.OPEN) return;
226
+ const session = e2eeSessions.get(ws as RemuxWebSocket);
227
+ if (session && session.isEstablished()) {
228
+ // Wrap encrypted data in e2ee_msg envelope
229
+ const encrypted = session.encryptMessage(data);
230
+ ws.send(JSON.stringify({ v: 1, type: "e2ee_msg", payload: { data: encrypted } }));
231
+ } else {
232
+ ws.send(data);
233
+ }
234
+ }
235
+
236
+ function generateClientId(): string {
237
+ return crypto.randomBytes(4).toString("hex");
238
+ }
239
+
240
+ /**
241
+ * Determine the active client for a given tab.
242
+ * Returns the first client found with role 'active' on that tab.
243
+ */
244
+ function getActiveClientForTab(tabId: number): RemuxWebSocket | null {
245
+ for (const [ws, state] of clientStates) {
246
+ if (state.currentTabId === tabId && state.role === "active") return ws;
247
+ }
248
+ return null;
249
+ }
250
+
251
+ /**
252
+ * Assign roles after a client attaches to a tab.
253
+ * If no active client exists on the tab, the client becomes active.
254
+ * If the existing active client has been idle beyond ACTIVE_IDLE_TIMEOUT_MS,
255
+ * the new client steals active and the idle client is demoted.
256
+ * Otherwise the new client becomes an observer.
257
+ */
258
+ function assignRole(ws: RemuxWebSocket, tabId: number): void {
259
+ const state = clientStates.get(ws);
260
+ if (!state) return;
261
+
262
+ const existingActive = getActiveClientForTab(tabId);
263
+ if (!existingActive || existingActive === ws) {
264
+ state.role = "active";
265
+ state.lastActiveAt = Date.now();
266
+ } else {
267
+ // Check if existing active client is idle — if so, steal active
268
+ const activeState = clientStates.get(existingActive);
269
+ const isIdle = activeState && (Date.now() - activeState.lastActiveAt > ACTIVE_IDLE_TIMEOUT_MS);
270
+ if (isIdle) {
271
+ activeState!.role = "observer";
272
+ sendEnvelope(existingActive, "role_changed", {
273
+ clientId: activeState!.clientId,
274
+ role: "observer",
275
+ });
276
+ state.role = "active";
277
+ state.lastActiveAt = Date.now();
278
+ } else {
279
+ state.role = "observer";
280
+ }
281
+ }
282
+ state.currentTabId = tabId;
283
+ }
284
+
285
+ /**
286
+ * When a client detaches or disconnects, reassign roles.
287
+ * If the disconnecting client was active, promote the first observer.
288
+ */
289
+ function reassignRolesAfterDetach(
290
+ tabId: number,
291
+ wasActive: boolean,
292
+ ): void {
293
+ if (!wasActive) return;
294
+
295
+ // Find first observer on the same tab and promote
296
+ for (const [ws, state] of clientStates) {
297
+ if (
298
+ state.currentTabId === tabId &&
299
+ state.role === "observer" &&
300
+ ws.readyState === ws.OPEN
301
+ ) {
302
+ state.role = "active";
303
+ sendEnvelope(ws, "role_changed", {
304
+ clientId: state.clientId,
305
+ role: "active",
306
+ });
307
+ break;
308
+ }
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Get client list for state broadcasts.
314
+ */
315
+ export function getClientList(): Array<{
316
+ clientId: string;
317
+ role: "active" | "observer";
318
+ session: string | null;
319
+ tabId: number | null;
320
+ }> {
321
+ const list: Array<{
322
+ clientId: string;
323
+ role: "active" | "observer";
324
+ session: string | null;
325
+ tabId: number | null;
326
+ }> = [];
327
+ for (const [ws, state] of clientStates) {
328
+ if (ws.readyState === ws.OPEN) {
329
+ list.push({
330
+ clientId: state.clientId,
331
+ role: state.role,
332
+ session: state.currentSession,
333
+ tabId: state.currentTabId,
334
+ });
335
+ }
336
+ }
337
+ return list;
338
+ }
339
+
340
+ // ── Workspace Head Broadcast ────────────────────────────────────
341
+
342
+ /**
343
+ * Broadcast the current workspace head to all authenticated clients.
344
+ */
345
+ function broadcastHead(): void {
346
+ const head = getHead();
347
+ if (!head) return;
348
+ for (const ws of controlClients) {
349
+ if (ws.readyState === ws.OPEN) {
350
+ sendEnvelope(ws, "workspace_head", head);
351
+ }
352
+ }
353
+ }
354
+
355
+ // ── Setup ────────────────────────────────────────────────────────
356
+
357
+ /**
358
+ * Create a WebSocketServer, wire up upgrade handling and message routing.
359
+ */
360
+ /** Map deviceId -> Set of connected WebSockets (for force-disconnect on revoke). */
361
+ const deviceSockets = new Map<string, Set<RemuxWebSocket>>();
362
+
363
+ export function setupWebSocket(
364
+ httpServer: http.Server,
365
+ TOKEN: string | null,
366
+ PASSWORD: string | null,
367
+ ): WebSocketServer {
368
+ // Wire broadcast hooks to break the circular session <-> ws-handler dependency
369
+ setBroadcastHooks(sendEnvelope, getClientList, e2eeSend);
370
+ // Wire buffer hooks for offline message queuing
371
+ setBufferHooks(bufferTabOutput, bufferStateForDisconnected);
372
+
373
+ const wss = new WebSocketServer({ noServer: true });
374
+
375
+ httpServer.on("upgrade", (req, socket, head) => {
376
+ // Disable Nagle algorithm to minimize input latency (see #80)
377
+ if ("setNoDelay" in socket) (socket as import("net").Socket).setNoDelay(true);
378
+ const url = new URL(req.url!, `http://${req.headers.host}`);
379
+ if (url.pathname === "/ws") {
380
+ wss.handleUpgrade(req, socket, head, (ws) =>
381
+ wss.emit("connection", ws, req),
382
+ );
383
+ } else {
384
+ socket.destroy();
385
+ }
386
+ });
387
+
388
+ // ── Heartbeat: send data-level ping to authenticated clients every 30s ──
389
+ // Browser onmessage does NOT fire for protocol-level ws.ping() frames,
390
+ // so we send a JSON envelope that the client can use to reset its timeout.
391
+ const HEARTBEAT_INTERVAL = 30_000;
392
+ setInterval(() => {
393
+ for (const ws of controlClients) {
394
+ if (ws.readyState === ws.OPEN) sendEnvelope(ws, "ping", {});
395
+ }
396
+ }, HEARTBEAT_INTERVAL);
397
+
398
+ wss.on("connection", (rawWs: WebSocket, req: http.IncomingMessage) => {
399
+ const ws = rawWs as RemuxWebSocket;
400
+ ws._remuxTabId = null;
401
+ ws._remuxCols = 80;
402
+ ws._remuxRows = 24;
403
+ ws._remuxDeviceId = null;
404
+
405
+ // Auth: no auth needed only if neither token nor password is configured
406
+ const requiresAuth = !!(TOKEN || PASSWORD);
407
+ ws._remuxAuthed = !requiresAuth;
408
+
409
+ // Initialize client state
410
+ const clientId = generateClientId();
411
+ const clientState: ClientState = {
412
+ clientId,
413
+ role: "observer",
414
+ connectedAt: Date.now(),
415
+ lastActiveAt: Date.now(),
416
+ currentSession: null,
417
+ currentTabId: null,
418
+ };
419
+ clientStates.set(ws, clientState);
420
+
421
+ // Register device from request headers
422
+ let deviceInfo: Device | null = null;
423
+ try {
424
+ const { device } = registerDevice(req);
425
+ deviceInfo = device;
426
+ ws._remuxDeviceId = device.id;
427
+
428
+ // Track socket for device disconnect
429
+ if (!deviceSockets.has(device.id)) {
430
+ deviceSockets.set(device.id, new Set());
431
+ }
432
+ deviceSockets.get(device.id)!.add(ws);
433
+
434
+ // Block connections from blocked devices
435
+ if (device.trust === "blocked") {
436
+ sendEnvelope(ws, "auth_error", { reason: "device blocked" });
437
+ ws.close(4003, "device blocked");
438
+ return;
439
+ }
440
+ } catch {
441
+ // Device registration failure is non-fatal
442
+ }
443
+
444
+ if (!requiresAuth) controlClients.add(ws);
445
+
446
+ ws.on("message", (raw) => {
447
+ const msg = raw.toString("utf8");
448
+
449
+ // ── Auth gate ──
450
+ if (!ws._remuxAuthed) {
451
+ try {
452
+ const rawParsed = JSON.parse(msg);
453
+ const parsed = unwrapMessage(rawParsed);
454
+ if (parsed.type === "auth") {
455
+ if (validateToken(parsed.token, TOKEN)) {
456
+ ws._remuxAuthed = true;
457
+ controlClients.add(ws);
458
+ // If client provides a persistent device ID, re-register with it
459
+ // (replaces the initial header-fingerprint registration)
460
+ if (parsed.deviceId) {
461
+ try {
462
+ // Clean up old fingerprint-based device socket tracking
463
+ if (ws._remuxDeviceId && ws._remuxDeviceId !== parsed.deviceId) {
464
+ const oldSockets = deviceSockets.get(ws._remuxDeviceId);
465
+ if (oldSockets) { oldSockets.delete(ws); if (oldSockets.size === 0) deviceSockets.delete(ws._remuxDeviceId); }
466
+ }
467
+ const { device } = registerDevice(req, parsed.deviceId);
468
+ // Re-check trust after re-registration: block if device was blocked
469
+ if (device.trust === "blocked") {
470
+ sendEnvelope(ws, "auth_error", { reason: "device blocked" });
471
+ ws.close(4003, "device blocked");
472
+ return;
473
+ }
474
+ deviceInfo = device;
475
+ ws._remuxDeviceId = device.id;
476
+ if (!deviceSockets.has(device.id)) deviceSockets.set(device.id, new Set());
477
+ deviceSockets.get(device.id)!.add(ws);
478
+ } catch {}
479
+ }
480
+ sendEnvelope(ws, "auth_ok", {
481
+ deviceId: deviceInfo?.id ?? null,
482
+ trust: deviceInfo?.trust ?? null,
483
+ });
484
+ // Send bootstrap message with workspace head + state
485
+ const head = getHead();
486
+ sendEnvelope(ws, "bootstrap", {
487
+ head,
488
+ sessions: getState(),
489
+ clients: getClientList(),
490
+ });
491
+ // Also send legacy state for backward compatibility
492
+ sendEnvelope(ws, "state", {
493
+ sessions: getState(),
494
+ clients: getClientList(),
495
+ });
496
+ return;
497
+ }
498
+ }
499
+ } catch {}
500
+ sendEnvelope(ws, "auth_error", { reason: "invalid token" });
501
+ ws.close(4001, "unauthorized");
502
+ return;
503
+ }
504
+
505
+ // ── JSON control messages ──
506
+ if (msg.startsWith("{")) {
507
+ try {
508
+ const rawParsed = JSON.parse(msg);
509
+ const p = unwrapMessage(rawParsed);
510
+
511
+ // ── E2EE handshake (opt-in, backward compatible) ──
512
+
513
+ if (p.type === "e2ee_init") {
514
+ // Client sends its X25519 public key to initiate E2EE
515
+ if (typeof p.publicKey === "string") {
516
+ const session = new E2EESession();
517
+ session.completeHandshake(p.publicKey);
518
+ e2eeSessions.set(ws, session);
519
+ sendEnvelope(ws, "e2ee_init", {
520
+ publicKey: session.getPublicKey(),
521
+ });
522
+ sendEnvelope(ws, "e2ee_ready", { established: true });
523
+ }
524
+ return;
525
+ }
526
+
527
+ if (p.type === "e2ee_msg") {
528
+ // Decrypt incoming encrypted message and re-process as control message
529
+ const e2ee = e2eeSessions.get(ws);
530
+ if (!e2ee || !e2ee.isEstablished()) {
531
+ sendEnvelope(ws, "error", { reason: "E2EE not established" });
532
+ return;
533
+ }
534
+ try {
535
+ const decrypted = e2ee.decryptMessage(p.data);
536
+ // Re-parse the decrypted plaintext as a control message
537
+ if (decrypted.startsWith("{")) {
538
+ const innerParsed = JSON.parse(decrypted);
539
+ const inner = unwrapMessage(innerParsed);
540
+
541
+ // Terminal input wrapped in E2EE
542
+ if (inner.type === "input") {
543
+ if (clientState.role === "active") {
544
+ clientState.lastActiveAt = Date.now();
545
+ const found = findTab(ws._remuxTabId);
546
+ if (found && !found.tab.ended) {
547
+ if (found.tab.daemonClient) {
548
+ found.tab.daemonClient.write(encodeFrame(TAG_CLIENT_INPUT, inner.data));
549
+ } else if (found.tab.pty) {
550
+ found.tab.pty.write(inner.data);
551
+ }
552
+ }
553
+ }
554
+ return;
555
+ }
556
+
557
+ // For other control messages, re-emit as if received normally.
558
+ ws.emit("message", Buffer.from(decrypted, "utf8"));
559
+ } else {
560
+ // Raw terminal input wrapped in E2EE
561
+ if (clientState.role === "active") {
562
+ clientState.lastActiveAt = Date.now();
563
+ const found = findTab(ws._remuxTabId);
564
+ if (found && !found.tab.ended) {
565
+ if (found.tab.daemonClient) {
566
+ found.tab.daemonClient.write(encodeFrame(TAG_CLIENT_INPUT, decrypted));
567
+ } else if (found.tab.pty) {
568
+ found.tab.pty.write(decrypted);
569
+ }
570
+ }
571
+ }
572
+ }
573
+ } catch (err) {
574
+ sendEnvelope(ws, "error", {
575
+ reason: "E2EE decrypt failed",
576
+ });
577
+ }
578
+ return;
579
+ }
580
+
581
+ // ── Session recovery: replay buffered messages ──
582
+ if (p.type === "resume") {
583
+ const resumeDeviceId = p.deviceId || ws._remuxDeviceId;
584
+ let totalReplayed = 0;
585
+
586
+ // Step 4: try durable stream resume first (if tabId and seq provided)
587
+ if (resumeDeviceId && typeof p.tabId === "number") {
588
+ const cursor = getDeviceCursor(resumeDeviceId, p.tabId);
589
+ const sinceSeq = p.seq ?? cursor?.lastAckedSeq ?? 0;
590
+ if (sinceSeq > 0) {
591
+ const chunks = getChunksSince(p.tabId, sinceSeq);
592
+ for (const chunk of chunks) {
593
+ if (ws.readyState === ws.OPEN) {
594
+ ws.send(chunk.data);
595
+ }
596
+ }
597
+ totalReplayed += chunks.length;
598
+ }
599
+ }
600
+
601
+ // Legacy buffer-based resume
602
+ if (resumeDeviceId && disconnectedDevices.has(resumeDeviceId)) {
603
+ const buf = bufferRegistry.getOrCreate(resumeDeviceId);
604
+ const messages = buf.drain(p.lastTimestamp || undefined);
605
+
606
+ // Replay buffered messages in order
607
+ for (const m of messages) {
608
+ if (ws.readyState === ws.OPEN) {
609
+ ws.send(m.data);
610
+ }
611
+ }
612
+ totalReplayed += messages.length;
613
+
614
+ // Clean up: device is no longer disconnected
615
+ disconnectedDevices.delete(resumeDeviceId);
616
+ disconnectedDeviceTab.delete(resumeDeviceId);
617
+ bufferRegistry.remove(resumeDeviceId);
618
+ }
619
+
620
+ sendEnvelope(ws, "resume_complete", {
621
+ replayed: totalReplayed,
622
+ });
623
+ return;
624
+ }
625
+
626
+ // ── Step 2: workspace head sync messages ──
627
+
628
+ if (p.type === "switch_tab") {
629
+ if (typeof p.tabId === "number") {
630
+ const head = updateHead({ tabId: p.tabId }, ws._remuxDeviceId || undefined);
631
+ broadcastHead();
632
+ }
633
+ return;
634
+ }
635
+
636
+ if (p.type === "switch_session") {
637
+ if (typeof p.name === "string") {
638
+ const session = sessionMap.get(p.name);
639
+ const firstTab = session?.tabs.find((t) => !t.ended);
640
+ const head = updateHead(
641
+ { sessionName: p.name, tabId: firstTab?.id ?? 0 },
642
+ ws._remuxDeviceId || undefined,
643
+ );
644
+ broadcastHead();
645
+ }
646
+ return;
647
+ }
648
+
649
+ if (p.type === "switch_view") {
650
+ if (typeof p.view === "string") {
651
+ const head = updateHead({ view: p.view }, ws._remuxDeviceId || undefined);
652
+ broadcastHead();
653
+ }
654
+ return;
655
+ }
656
+
657
+ // ── Step 4: durable stream ack ──
658
+
659
+ if (p.type === "ack") {
660
+ const deviceId = ws._remuxDeviceId;
661
+ if (deviceId && typeof p.tabId === "number" && typeof p.seq === "number") {
662
+ updateDeviceCursor(deviceId, p.tabId, p.seq);
663
+ }
664
+ return;
665
+ }
666
+
667
+ // Attach to first tab of a session (or create one).
668
+ // If no session specified, pick the first existing one or create "default".
669
+ if (p.type === "attach_first") {
670
+ const name = p.session || getFirstSessionName() || "default";
671
+ const session = createSession(name);
672
+ let tab = session.tabs.find((t) => !t.ended);
673
+ if (!tab)
674
+ tab = createTab(
675
+ session,
676
+ p.cols || ws._remuxCols,
677
+ p.rows || ws._remuxRows,
678
+ );
679
+ attachToTab(
680
+ tab,
681
+ ws,
682
+ p.cols || ws._remuxCols,
683
+ p.rows || ws._remuxRows,
684
+ );
685
+ clientState.currentSession = name;
686
+ assignRole(ws, tab.id);
687
+ // Send state BEFORE attached so client has session/tab data when processing attached
688
+ broadcastState();
689
+ sendEnvelope(ws, "attached", {
690
+ tabId: tab.id,
691
+ session: name,
692
+ clientId: clientState.clientId,
693
+ role: clientState.role,
694
+ });
695
+ return;
696
+ }
697
+
698
+ // Attach to an existing tab by id
699
+ if (p.type === "attach_tab") {
700
+ const found = findTab(p.tabId);
701
+ if (found) {
702
+ attachToTab(
703
+ found.tab,
704
+ ws,
705
+ p.cols || ws._remuxCols,
706
+ p.rows || ws._remuxRows,
707
+ );
708
+ clientState.currentSession = found.session.name;
709
+ assignRole(ws, found.tab.id);
710
+ broadcastState();
711
+ sendEnvelope(ws, "attached", {
712
+ tabId: found.tab.id,
713
+ session: found.session.name,
714
+ clientId: clientState.clientId,
715
+ role: clientState.role,
716
+ });
717
+ }
718
+ return;
719
+ }
720
+
721
+ // Create a new tab in a session (creates session if needed)
722
+ if (p.type === "new_tab") {
723
+ const session = createSession(p.session || "default");
724
+ const tab = createTab(
725
+ session,
726
+ p.cols || ws._remuxCols,
727
+ p.rows || ws._remuxRows,
728
+ );
729
+ attachToTab(
730
+ tab,
731
+ ws,
732
+ p.cols || ws._remuxCols,
733
+ p.rows || ws._remuxRows,
734
+ );
735
+ clientState.currentSession = session.name;
736
+ assignRole(ws, tab.id);
737
+ broadcastState();
738
+ sendEnvelope(ws, "attached", {
739
+ tabId: tab.id,
740
+ session: session.name,
741
+ clientId: clientState.clientId,
742
+ role: clientState.role,
743
+ });
744
+ return;
745
+ }
746
+
747
+ // Close a tab (kill its PTY or shutdown daemon)
748
+ if (p.type === "close_tab") {
749
+ const found = findTab(p.tabId);
750
+ if (found) {
751
+ if (!found.tab.ended) {
752
+ if (found.tab.daemonClient) {
753
+ try {
754
+ found.tab.daemonClient.write(encodeFrame(0xff, Buffer.alloc(0)));
755
+ } catch { /* ignore */ }
756
+ } else if (found.tab.pty) {
757
+ found.tab.pty.kill();
758
+ }
759
+ }
760
+ found.session.tabs = found.session.tabs.filter(
761
+ (t) => t.id !== p.tabId,
762
+ );
763
+ removeStaleTab(p.tabId);
764
+ // If session has no tabs left, remove it
765
+ if (found.session.tabs.length === 0) {
766
+ removeSessionFromDb(found.session.name);
767
+ sessionMap.delete(found.session.name);
768
+ }
769
+ }
770
+ broadcastState();
771
+ return;
772
+ }
773
+
774
+ // Create a new session (with one default tab)
775
+ if (p.type === "new_session") {
776
+ const name = p.name || "session-" + Date.now();
777
+ const session = createSession(name);
778
+ const tab = createTab(
779
+ session,
780
+ p.cols || ws._remuxCols,
781
+ p.rows || ws._remuxRows,
782
+ );
783
+ attachToTab(
784
+ tab,
785
+ ws,
786
+ p.cols || ws._remuxCols,
787
+ p.rows || ws._remuxRows,
788
+ );
789
+ clientState.currentSession = name;
790
+ assignRole(ws, tab.id);
791
+ broadcastState();
792
+ sendEnvelope(ws, "attached", {
793
+ tabId: tab.id,
794
+ session: name,
795
+ clientId: clientState.clientId,
796
+ role: clientState.role,
797
+ });
798
+ return;
799
+ }
800
+
801
+ // Delete entire session
802
+ if (p.type === "delete_session") {
803
+ if (p.name) {
804
+ deleteSession(p.name);
805
+ broadcastState();
806
+ }
807
+ return;
808
+ }
809
+
810
+ // Inspect: capture current tab's terminal content as text
811
+ if (p.type === "inspect") {
812
+ const found = findTab(ws._remuxTabId);
813
+ if (found && found.tab.vt && !found.tab.ended) {
814
+ const { text, cols, rows } = found.tab.vt.textSnapshot();
815
+ sendEnvelope(ws, "inspect_result", {
816
+ text,
817
+ meta: {
818
+ session: found.session.name,
819
+ tabId: found.tab.id,
820
+ tabTitle: found.tab.title,
821
+ cols,
822
+ rows,
823
+ timestamp: Date.now(),
824
+ },
825
+ });
826
+ } else {
827
+ // Fallback: raw scrollback as text
828
+ const found2 = findTab(ws._remuxTabId);
829
+ const rawText = found2
830
+ ? found2.tab.scrollback.read().toString("utf8")
831
+ : "";
832
+ // Strip ANSI escape sequences comprehensively:
833
+ // CSI sequences, OSC sequences (BEL or ST terminated), DCS/PM/APC,
834
+ // simple escapes, and remaining control chars except newline/tab
835
+ const text = rawText
836
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "") // CSI sequences
837
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") // OSC sequences
838
+ .replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, "") // DCS/PM/APC sequences
839
+ .replace(/\x1b[()][A-Z0-9]/g, "") // charset selection
840
+ .replace(/\x1b[A-Z=><78]/gi, "") // simple escape sequences
841
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "") // remaining ctrl chars
842
+ .replace(/\x7f/g, ""); // DEL char
843
+ // Include tab metadata so Inspect header shows identity and size
844
+ const session = found2 ? found2.session : null;
845
+ const tab = found2 ? found2.tab : null;
846
+ sendEnvelope(ws, "inspect_result", {
847
+ text,
848
+ meta: {
849
+ session: session?.name || "",
850
+ tabId: tab?.id || null,
851
+ tabTitle: tab?.title || "",
852
+ cols: ws._remuxCols || 80,
853
+ rows: ws._remuxRows || 24,
854
+ timestamp: Date.now(),
855
+ },
856
+ });
857
+ }
858
+ return;
859
+ }
860
+
861
+ // Rename a tab
862
+ if (p.type === "rename_tab") {
863
+ const found = findTab(p.tabId);
864
+ if (found && typeof p.title === "string" && p.title.trim()) {
865
+ found.tab.title = p.title.trim().slice(0, 32);
866
+ broadcastState();
867
+ }
868
+ return;
869
+ }
870
+
871
+ // Resize current tab
872
+ if (p.type === "resize") {
873
+ ws._remuxCols = p.cols;
874
+ ws._remuxRows = p.rows;
875
+ const found = findTab(ws._remuxTabId);
876
+ if (found) recalcTabSize(found.tab);
877
+ return;
878
+ }
879
+
880
+ // ── Control handoff ──
881
+
882
+ // Request control: observer requests to become active
883
+ if (p.type === "request_control") {
884
+ const tabId = ws._remuxTabId;
885
+ if (tabId == null) return;
886
+
887
+ const currentActive = getActiveClientForTab(tabId);
888
+ if (currentActive && currentActive !== ws) {
889
+ // Demote current active to observer
890
+ const activeState = clientStates.get(currentActive);
891
+ if (activeState) {
892
+ activeState.role = "observer";
893
+ sendEnvelope(currentActive, "role_changed", {
894
+ clientId: activeState.clientId,
895
+ role: "observer",
896
+ });
897
+ }
898
+ }
899
+
900
+ // Promote requester to active
901
+ clientState.role = "active";
902
+ clientState.lastActiveAt = Date.now();
903
+ sendEnvelope(ws, "role_changed", {
904
+ clientId: clientState.clientId,
905
+ role: "active",
906
+ });
907
+ broadcastState();
908
+ return;
909
+ }
910
+
911
+ // Release control: active voluntarily becomes observer
912
+ if (p.type === "release_control") {
913
+ const tabId = ws._remuxTabId;
914
+ if (tabId == null) return;
915
+
916
+ if (clientState.role === "active") {
917
+ clientState.role = "observer";
918
+ sendEnvelope(ws, "role_changed", {
919
+ clientId: clientState.clientId,
920
+ role: "observer",
921
+ });
922
+
923
+ // Promote first waiting observer
924
+ for (const [otherWs, otherState] of clientStates) {
925
+ if (
926
+ otherWs !== ws &&
927
+ otherState.currentTabId === tabId &&
928
+ otherState.role === "observer" &&
929
+ otherWs.readyState === otherWs.OPEN
930
+ ) {
931
+ otherState.role = "active";
932
+ sendEnvelope(otherWs, "role_changed", {
933
+ clientId: otherState.clientId,
934
+ role: "active",
935
+ });
936
+ break;
937
+ }
938
+ }
939
+ broadcastState();
940
+ }
941
+ return;
942
+ }
943
+
944
+ // ── Device management messages ──
945
+
946
+ if (p.type === "list_devices") {
947
+ const devices = listDevices();
948
+ sendEnvelope(ws, "device_list", { devices });
949
+ return;
950
+ }
951
+
952
+ if (p.type === "trust_device") {
953
+ // Only trusted devices can trust others
954
+ const sender = ws._remuxDeviceId
955
+ ? findDeviceById(ws._remuxDeviceId)
956
+ : null;
957
+ if (!sender || sender.trust !== "trusted") {
958
+ sendEnvelope(ws, "error", {
959
+ reason: "only trusted devices can trust others",
960
+ });
961
+ return;
962
+ }
963
+ if (p.deviceId) {
964
+ updateDeviceTrust(p.deviceId, "trusted");
965
+ sendEnvelope(ws, "device_list", { devices: listDevices() });
966
+ broadcastDeviceList();
967
+ }
968
+ return;
969
+ }
970
+
971
+ if (p.type === "block_device") {
972
+ // Only trusted devices can block others
973
+ const sender = ws._remuxDeviceId
974
+ ? findDeviceById(ws._remuxDeviceId)
975
+ : null;
976
+ if (!sender || sender.trust !== "trusted") {
977
+ sendEnvelope(ws, "error", {
978
+ reason: "only trusted devices can block others",
979
+ });
980
+ return;
981
+ }
982
+ if (p.deviceId) {
983
+ updateDeviceTrust(p.deviceId, "blocked");
984
+ // Force disconnect blocked device
985
+ forceDisconnectDevice(p.deviceId);
986
+ sendEnvelope(ws, "device_list", { devices: listDevices() });
987
+ broadcastDeviceList();
988
+ }
989
+ return;
990
+ }
991
+
992
+ if (p.type === "rename_device") {
993
+ if (p.deviceId && typeof p.name === "string" && p.name.trim()) {
994
+ renameDevice(p.deviceId, p.name.trim().slice(0, 32));
995
+ sendEnvelope(ws, "device_list", { devices: listDevices() });
996
+ broadcastDeviceList();
997
+ }
998
+ return;
999
+ }
1000
+
1001
+ if (p.type === "revoke_device") {
1002
+ // Only trusted devices can revoke others
1003
+ const sender = ws._remuxDeviceId
1004
+ ? findDeviceById(ws._remuxDeviceId)
1005
+ : null;
1006
+ if (!sender || sender.trust !== "trusted") {
1007
+ sendEnvelope(ws, "error", {
1008
+ reason: "only trusted devices can revoke others",
1009
+ });
1010
+ return;
1011
+ }
1012
+ if (p.deviceId) {
1013
+ forceDisconnectDevice(p.deviceId);
1014
+ deleteDevice(p.deviceId);
1015
+ sendEnvelope(ws, "device_list", { devices: listDevices() });
1016
+ broadcastDeviceList();
1017
+ }
1018
+ return;
1019
+ }
1020
+
1021
+ if (p.type === "generate_pair_code") {
1022
+ // Only trusted devices can generate pair codes
1023
+ const sender = ws._remuxDeviceId
1024
+ ? findDeviceById(ws._remuxDeviceId)
1025
+ : null;
1026
+ if (!sender || sender.trust !== "trusted") {
1027
+ sendEnvelope(ws, "error", {
1028
+ reason: "only trusted devices can generate pair codes",
1029
+ });
1030
+ return;
1031
+ }
1032
+ const pairCode = createPairCode(sender.id);
1033
+ sendEnvelope(ws, "pair_code", {
1034
+ code: pairCode.code,
1035
+ expiresAt: pairCode.expiresAt,
1036
+ });
1037
+ return;
1038
+ }
1039
+
1040
+ if (p.type === "pair") {
1041
+ if (typeof p.code === "string") {
1042
+ const createdBy = consumePairCode(p.code);
1043
+ if (createdBy && ws._remuxDeviceId) {
1044
+ updateDeviceTrust(ws._remuxDeviceId, "trusted");
1045
+ // Refresh device info
1046
+ deviceInfo = findDeviceById(ws._remuxDeviceId);
1047
+ sendEnvelope(ws, "pair_result", {
1048
+ success: true,
1049
+ deviceId: ws._remuxDeviceId,
1050
+ });
1051
+ broadcastDeviceList();
1052
+ } else {
1053
+ sendEnvelope(ws, "pair_result", {
1054
+ success: false,
1055
+ reason: "invalid or expired code",
1056
+ });
1057
+ }
1058
+ }
1059
+ return;
1060
+ }
1061
+
1062
+ // ── Push notification messages ──
1063
+
1064
+ if (p.type === "get_vapid_key") {
1065
+ const publicKey = getVapidPublicKey();
1066
+ sendEnvelope(ws, "vapid_key", { publicKey });
1067
+ return;
1068
+ }
1069
+
1070
+ if (p.type === "subscribe_push") {
1071
+ if (
1072
+ ws._remuxDeviceId &&
1073
+ p.subscription &&
1074
+ typeof p.subscription.endpoint === "string" &&
1075
+ p.subscription.keys?.p256dh &&
1076
+ p.subscription.keys?.auth
1077
+ ) {
1078
+ savePushSubscription(
1079
+ ws._remuxDeviceId,
1080
+ p.subscription.endpoint,
1081
+ p.subscription.keys.p256dh,
1082
+ p.subscription.keys.auth,
1083
+ );
1084
+ sendEnvelope(ws, "push_subscribed", { success: true });
1085
+ } else {
1086
+ sendEnvelope(ws, "push_subscribed", {
1087
+ success: false,
1088
+ reason: "invalid subscription or no device ID",
1089
+ });
1090
+ }
1091
+ return;
1092
+ }
1093
+
1094
+ if (p.type === "unsubscribe_push") {
1095
+ if (ws._remuxDeviceId) {
1096
+ removePushSubscription(ws._remuxDeviceId);
1097
+ sendEnvelope(ws, "push_unsubscribed", { success: true });
1098
+ }
1099
+ return;
1100
+ }
1101
+
1102
+ if (p.type === "test_push") {
1103
+ if (ws._remuxDeviceId) {
1104
+ sendPushNotification(
1105
+ ws._remuxDeviceId,
1106
+ "Remux Test",
1107
+ "Push notifications are working!",
1108
+ ).then((sent) => {
1109
+ sendEnvelope(ws, "push_test_result", { sent });
1110
+ });
1111
+ } else {
1112
+ sendEnvelope(ws, "push_test_result", { sent: false });
1113
+ }
1114
+ return;
1115
+ }
1116
+
1117
+ if (p.type === "get_push_status") {
1118
+ const hasSub = ws._remuxDeviceId
1119
+ ? !!getPushSubscription(ws._remuxDeviceId)
1120
+ : false;
1121
+ sendEnvelope(ws, "push_status", { subscribed: hasSub });
1122
+ return;
1123
+ }
1124
+
1125
+ // ── Workspace: Topics ──
1126
+
1127
+ if (p.type === "create_topic") {
1128
+ if (typeof p.title === "string" && p.title.trim()) {
1129
+ const topic = createTopic(
1130
+ p.sessionName || clientState.currentSession || "default",
1131
+ p.title.trim(),
1132
+ );
1133
+ sendEnvelope(ws, "topic_created", topic);
1134
+ }
1135
+ return;
1136
+ }
1137
+
1138
+ if (p.type === "list_topics") {
1139
+ const topics = listTopics(p.sessionName || undefined);
1140
+ sendEnvelope(ws, "topic_list", { topics });
1141
+ return;
1142
+ }
1143
+
1144
+ if (p.type === "delete_topic") {
1145
+ if (p.topicId) {
1146
+ const ok = deleteTopic(p.topicId);
1147
+ sendEnvelope(ws, "topic_deleted", {
1148
+ topicId: p.topicId,
1149
+ success: ok,
1150
+ });
1151
+ }
1152
+ return;
1153
+ }
1154
+
1155
+ // ── Workspace: Runs ──
1156
+
1157
+ if (p.type === "create_run") {
1158
+ const run = createRun({
1159
+ topicId: p.topicId || undefined,
1160
+ sessionName: p.sessionName || clientState.currentSession || "default",
1161
+ tabId: p.tabId,
1162
+ command: p.command,
1163
+ });
1164
+ sendEnvelope(ws, "run_created", run);
1165
+ return;
1166
+ }
1167
+
1168
+ if (p.type === "update_run") {
1169
+ if (p.runId) {
1170
+ const ok = updateRun(p.runId, {
1171
+ exitCode: p.exitCode,
1172
+ status: p.status,
1173
+ });
1174
+ sendEnvelope(ws, "run_updated", {
1175
+ runId: p.runId,
1176
+ success: ok,
1177
+ });
1178
+ }
1179
+ return;
1180
+ }
1181
+
1182
+ if (p.type === "list_runs") {
1183
+ const runs = listRuns(p.topicId || undefined, p.sessionName || undefined);
1184
+ sendEnvelope(ws, "run_list", { runs });
1185
+ return;
1186
+ }
1187
+
1188
+ // ── Workspace: Artifacts ──
1189
+
1190
+ if (p.type === "capture_snapshot") {
1191
+ const tabId = ws._remuxTabId;
1192
+ if (tabId != null) {
1193
+ const result = captureSnapshot(
1194
+ clientState.currentSession || "default",
1195
+ tabId,
1196
+ p.topicId || undefined,
1197
+ );
1198
+ if (result) {
1199
+ const a = result.artifact;
1200
+ const contentType = a.content ? detectContentType(a.content) : "plain";
1201
+ let renderedHtml: string | undefined;
1202
+ if (a.content) {
1203
+ if (contentType === "diff") renderedHtml = renderDiff(a.content);
1204
+ else if (contentType === "markdown") renderedHtml = renderMarkdown(a.content);
1205
+ else if (contentType === "ansi") renderedHtml = '<pre style="margin:0;font-size:11px;line-height:1.5">' + renderAnsi(a.content) + "</pre>";
1206
+ }
1207
+ sendEnvelope(ws, "snapshot_captured", { ...a, contentType, renderedHtml });
1208
+ } else {
1209
+ sendEnvelope(ws, "error", {
1210
+ reason: "no tab attached for snapshot",
1211
+ });
1212
+ }
1213
+ }
1214
+ return;
1215
+ }
1216
+
1217
+ if (p.type === "list_artifacts") {
1218
+ const artifacts = listArtifacts({
1219
+ topicId: p.topicId || undefined,
1220
+ runId: p.runId || undefined,
1221
+ sessionName: p.sessionName || clientState.currentSession || undefined,
1222
+ });
1223
+ // Enrich artifacts with server-side rendered HTML
1224
+ const enriched = artifacts.map((a) => {
1225
+ if (!a.content) return a;
1226
+ const contentType = detectContentType(a.content);
1227
+ let renderedHtml: string | undefined;
1228
+ if (contentType === "diff") renderedHtml = renderDiff(a.content);
1229
+ else if (contentType === "markdown") renderedHtml = renderMarkdown(a.content);
1230
+ else if (contentType === "ansi") renderedHtml = '<pre style="margin:0;font-size:11px;line-height:1.5">' + renderAnsi(a.content) + "</pre>";
1231
+ return { ...a, contentType, renderedHtml };
1232
+ });
1233
+ sendEnvelope(ws, "artifact_list", { artifacts: enriched });
1234
+ return;
1235
+ }
1236
+
1237
+ // ── Workspace: Approvals ──
1238
+
1239
+ if (p.type === "create_approval") {
1240
+ if (typeof p.title === "string" && p.title.trim()) {
1241
+ const approval = createApproval({
1242
+ runId: p.runId || undefined,
1243
+ topicId: p.topicId || undefined,
1244
+ title: p.title.trim(),
1245
+ description: p.description,
1246
+ });
1247
+ sendEnvelope(ws, "approval_created", approval);
1248
+ // Broadcast to all control clients so they see the new approval
1249
+ for (const client of controlClients) {
1250
+ if (client !== ws && client.readyState === client.OPEN) {
1251
+ sendEnvelope(client, "approval_created", approval);
1252
+ }
1253
+ }
1254
+ }
1255
+ return;
1256
+ }
1257
+
1258
+ if (p.type === "list_approvals") {
1259
+ const approvals = listApprovals(p.status || undefined);
1260
+ sendEnvelope(ws, "approval_list", { approvals });
1261
+ return;
1262
+ }
1263
+
1264
+ if (p.type === "resolve_approval") {
1265
+ if (
1266
+ p.approvalId &&
1267
+ (p.status === "approved" || p.status === "rejected")
1268
+ ) {
1269
+ const ok = resolveApproval(p.approvalId, p.status);
1270
+ sendEnvelope(ws, "approval_resolved", {
1271
+ approvalId: p.approvalId,
1272
+ status: p.status,
1273
+ success: ok,
1274
+ });
1275
+ // Broadcast resolution to all control clients
1276
+ for (const client of controlClients) {
1277
+ if (client !== ws && client.readyState === client.OPEN) {
1278
+ sendEnvelope(client, "approval_resolved", {
1279
+ approvalId: p.approvalId,
1280
+ status: p.status,
1281
+ success: ok,
1282
+ });
1283
+ }
1284
+ }
1285
+ }
1286
+ return;
1287
+ }
1288
+
1289
+ // ── Search ──
1290
+
1291
+ if (p.type === "search") {
1292
+ if (typeof p.query === "string") {
1293
+ const results = searchEntities(p.query, p.limit || 20);
1294
+ sendEnvelope(ws, "search_results", { query: p.query, results });
1295
+ }
1296
+ return;
1297
+ }
1298
+
1299
+ // ── Handoff Bundle ──
1300
+
1301
+ if (p.type === "get_handoff") {
1302
+ const bundle = generateHandoffBundle();
1303
+ sendEnvelope(ws, "handoff_bundle", bundle);
1304
+ return;
1305
+ }
1306
+
1307
+ // ── Memory Notes ──
1308
+
1309
+ if (p.type === "create_note") {
1310
+ if (typeof p.content === "string" && p.content.trim()) {
1311
+ const note = createNote(p.content.trim(), p.sessionName || undefined);
1312
+ sendEnvelope(ws, "note_created", note);
1313
+ }
1314
+ return;
1315
+ }
1316
+
1317
+ if (p.type === "list_notes") {
1318
+ const notes = listNotes(p.sessionName || undefined);
1319
+ sendEnvelope(ws, "note_list", { notes });
1320
+ return;
1321
+ }
1322
+
1323
+ if (p.type === "update_note") {
1324
+ if (p.noteId && typeof p.content === "string" && p.content.trim()) {
1325
+ const ok = updateNote(p.noteId, p.content.trim());
1326
+ sendEnvelope(ws, "note_updated", { noteId: p.noteId, success: ok });
1327
+ }
1328
+ return;
1329
+ }
1330
+
1331
+ if (p.type === "delete_note") {
1332
+ if (p.noteId) {
1333
+ const ok = deleteNote(p.noteId);
1334
+ sendEnvelope(ws, "note_deleted", { noteId: p.noteId, success: ok });
1335
+ }
1336
+ return;
1337
+ }
1338
+
1339
+ if (p.type === "pin_note") {
1340
+ if (p.noteId) {
1341
+ const ok = togglePinNote(p.noteId);
1342
+ sendEnvelope(ws, "note_pinned", { noteId: p.noteId, success: ok });
1343
+ }
1344
+ return;
1345
+ }
1346
+
1347
+ // ── Shell Integration: Commands ──
1348
+
1349
+ if (p.type === "list_commands") {
1350
+ const tabId = p.tabId ?? ws._remuxTabId;
1351
+ if (tabId != null) {
1352
+ const commands = listCommands(tabId, p.limit || 50);
1353
+ sendEnvelope(ws, "command_list", { tabId, commands });
1354
+ }
1355
+ return;
1356
+ }
1357
+
1358
+ // ── E11: Git / Review ──
1359
+
1360
+ if (p.type === "git_status") {
1361
+ const { getGitStatus } = require("./git-service.js");
1362
+ getGitStatus().then((status: any) => sendEnvelope(ws, "git_status_result", status)).catch(() => {});
1363
+ return;
1364
+ }
1365
+
1366
+ if (p.type === "git_diff") {
1367
+ const { getGitDiff } = require("./git-service.js");
1368
+ getGitDiff(p.base).then((diff: any) => sendEnvelope(ws, "git_diff_result", diff)).catch(() => {});
1369
+ return;
1370
+ }
1371
+
1372
+ if (p.type === "git_worktrees") {
1373
+ const { getWorktrees } = require("./git-service.js");
1374
+ getWorktrees().then((worktrees: any) => sendEnvelope(ws, "git_worktrees_result", { worktrees })).catch(() => {});
1375
+ return;
1376
+ }
1377
+
1378
+ if (p.type === "git_compare") {
1379
+ const { compareBranches } = require("./git-service.js");
1380
+ compareBranches(p.base || "main", p.head || "HEAD").then((result: any) => sendEnvelope(ws, "git_compare_result", result)).catch(() => {});
1381
+ return;
1382
+ }
1383
+
1384
+ // ── E10: Adapter Platform ──
1385
+
1386
+ if (p.type === "request_adapter_state") {
1387
+ // E10-007: return all adapter states
1388
+ // Lazy import to avoid circular dependency with server.ts
1389
+ const { adapterRegistry } = require("./server.js");
1390
+ const states = adapterRegistry?.getAllStates() ?? [];
1391
+ sendEnvelope(ws, "adapter_state", { adapters: states });
1392
+ return;
1393
+ }
1394
+
1395
+ if (p.type === "request_agent_summary") {
1396
+ // E10-009: build AgentSessionSummary for each adapter
1397
+ const { adapterRegistry } = require("./server.js");
1398
+ const { AgentSessionSummary } = require("./adapters/agent-events.js");
1399
+ const states = adapterRegistry?.getAllStates() ?? [];
1400
+ const summaries = states.map((s: any) => ({
1401
+ agentId: s.adapterId,
1402
+ agentName: s.name,
1403
+ state: s.currentState,
1404
+ currentTurn: undefined,
1405
+ recentToolCalls: [],
1406
+ pendingApprovals: [],
1407
+ }));
1408
+ sendEnvelope(ws, "agent_summary", { agents: summaries });
1409
+ return;
1410
+ }
1411
+
1412
+ return;
1413
+ } catch (err) {
1414
+ // Log JSON parse errors for debugging (e.g. createNote DB failures)
1415
+ if (msg.startsWith("{")) {
1416
+ console.error("[remux] JSON handler error:", err);
1417
+ return; // Don't fall through to PTY for malformed JSON
1418
+ }
1419
+ }
1420
+ }
1421
+
1422
+ // ── Raw terminal input -> current tab's PTY ──
1423
+ // Only active clients can write to PTY; observer input is silently dropped
1424
+ if (clientState.role !== "active") return;
1425
+
1426
+ // Track activity for idle timeout
1427
+ clientState.lastActiveAt = Date.now();
1428
+
1429
+ // Defense-in-depth: never write JSON control messages to PTY
1430
+ if (msg.startsWith("{") && msg.includes('"type"')) return;
1431
+
1432
+ const found = findTab(ws._remuxTabId);
1433
+
1434
+ // Handle restored tab revival: when user presses Enter on a restored tab
1435
+ if (found && found.tab.restored && !found.tab.ended) {
1436
+ // Check if input contains Enter (CR or LF)
1437
+ if (msg.includes("\r") || msg.includes("\n")) {
1438
+ reviveTab(found.tab, found.session).then((ok) => {
1439
+ if (ok) {
1440
+ sendEnvelope(ws, "tab_revived", { tabId: found.tab.id });
1441
+ broadcastState();
1442
+ }
1443
+ }).catch(() => {});
1444
+ }
1445
+ return; // Swallow all input until tab is revived
1446
+ }
1447
+ if (found && !found.tab.ended) {
1448
+ if (found.tab.daemonClient) {
1449
+ // Daemon mode: send input via TLV frame
1450
+ found.tab.daemonClient.write(encodeFrame(TAG_CLIENT_INPUT, msg));
1451
+ } else if (found.tab.pty) {
1452
+ // Direct PTY mode
1453
+ found.tab.pty.write(msg);
1454
+ }
1455
+ }
1456
+ });
1457
+
1458
+ ws.on("close", () => {
1459
+ const tabId = ws._remuxTabId;
1460
+ const wasActive = clientState.role === "active";
1461
+
1462
+ // Clean up device socket tracking
1463
+ if (ws._remuxDeviceId) {
1464
+ const sockets = deviceSockets.get(ws._remuxDeviceId);
1465
+ if (sockets) {
1466
+ sockets.delete(ws);
1467
+ // If this was the device's last connection, start buffering
1468
+ // so messages can be replayed on reconnect
1469
+ if (sockets.size === 0) {
1470
+ deviceSockets.delete(ws._remuxDeviceId);
1471
+ if (ws._remuxAuthed) {
1472
+ disconnectedDevices.add(ws._remuxDeviceId);
1473
+ bufferRegistry.getOrCreate(ws._remuxDeviceId); // ensure buffer exists
1474
+ // Track which tab the device was watching for PTY output buffering
1475
+ if (tabId != null) {
1476
+ disconnectedDeviceTab.set(ws._remuxDeviceId, tabId);
1477
+ }
1478
+ }
1479
+ }
1480
+ }
1481
+ }
1482
+
1483
+ detachFromTab(ws);
1484
+ controlClients.delete(ws);
1485
+ clientStates.delete(ws);
1486
+ e2eeSessions.delete(ws);
1487
+
1488
+ // Reassign roles if the disconnecting client was active
1489
+ if (tabId != null) {
1490
+ reassignRolesAfterDetach(tabId, wasActive);
1491
+ broadcastState();
1492
+ }
1493
+ });
1494
+
1495
+ ws.on("error", (err) => {
1496
+ console.error("[ws] error:", err.message);
1497
+ });
1498
+ });
1499
+
1500
+ /**
1501
+ * Force disconnect all sockets belonging to a device.
1502
+ */
1503
+ function forceDisconnectDevice(deviceId: string): void {
1504
+ const sockets = deviceSockets.get(deviceId);
1505
+ if (!sockets) return;
1506
+ for (const sock of sockets) {
1507
+ sendEnvelope(sock, "auth_error", { reason: "device revoked" });
1508
+ sock.close(4003, "device revoked");
1509
+ }
1510
+ deviceSockets.delete(deviceId);
1511
+ }
1512
+
1513
+ /**
1514
+ * Broadcast device list to all authenticated control clients.
1515
+ */
1516
+ function broadcastDeviceList(): void {
1517
+ const devices = listDevices();
1518
+ for (const client of controlClients) {
1519
+ if (client.readyState === client.OPEN) {
1520
+ sendEnvelope(client, "device_list", { devices });
1521
+ }
1522
+ }
1523
+ }
1524
+
1525
+ return wss;
1526
+ }