@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.
- package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
- package/.github/dependabot.yml +33 -0
- package/.github/workflows/ci.yml +65 -0
- package/.github/workflows/deploy.yml +65 -0
- package/.github/workflows/publish.yml +312 -0
- package/.github/workflows/release-please.yml +21 -0
- package/.gitmodules +3 -0
- package/.nvmrc +1 -0
- package/.release-please-manifest.json +3 -0
- package/CLAUDE.md +104 -0
- package/Dockerfile +23 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/apps/ios/Config/signing.xcconfig +4 -0
- package/apps/ios/Package.swift +26 -0
- package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
- package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
- package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
- package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
- package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
- package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
- package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
- package/apps/ios/Sources/Remux/RootView.swift +130 -0
- package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
- package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
- package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
- package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
- package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
- package/apps/macos/Package.swift +37 -0
- package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
- package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
- package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
- package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
- package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
- package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
- package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
- package/apps/macos/Resources/terminfo/67/ghostty +0 -0
- package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
- package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
- package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
- package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
- package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
- package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
- package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
- package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
- package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
- package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
- package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
- package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
- package/apps/macos/Sources/Remux/SocketController.swift +258 -0
- package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
- package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
- package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
- package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
- package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
- package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
- package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
- package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
- package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
- package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
- package/build.mjs +33 -0
- package/native/android/DecodeGoldenPayloads.kt +487 -0
- package/native/android/ProtocolModels.kt +188 -0
- package/native/ios/DecodeGoldenPayloads.swift +711 -0
- package/native/ios/ProtocolModels.swift +200 -0
- package/package.json +45 -0
- package/packages/RemuxKit/Package.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
- package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
- package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
- package/playwright.config.ts +17 -0
- package/pnpm-lock.yaml +1588 -0
- package/pty-daemon.js +303 -0
- package/release-please-config.json +14 -0
- package/scripts/auto-deploy.sh +46 -0
- package/scripts/build-dmg.sh +121 -0
- package/scripts/build-ghostty-kit.sh +43 -0
- package/scripts/check-active-terminology.mjs +132 -0
- package/scripts/setup-ci-secrets.sh +80 -0
- package/scripts/sync-ghostty-web.sh +28 -0
- package/scripts/upload-testflight.sh +100 -0
- package/server.js +7074 -0
- package/src/adapters/agent-events.ts +246 -0
- package/src/adapters/claude-code.ts +158 -0
- package/src/adapters/codex.ts +210 -0
- package/src/adapters/generic-shell.ts +58 -0
- package/src/adapters/index.ts +15 -0
- package/src/adapters/registry.ts +99 -0
- package/src/adapters/types.ts +41 -0
- package/src/auth.ts +174 -0
- package/src/e2ee.ts +236 -0
- package/src/git-service.ts +168 -0
- package/src/message-buffer.ts +137 -0
- package/src/pty-daemon.ts +357 -0
- package/src/push.ts +127 -0
- package/src/renderers.ts +455 -0
- package/src/server.ts +2407 -0
- package/src/service.ts +226 -0
- package/src/session.ts +978 -0
- package/src/store.ts +1422 -0
- package/src/team.ts +123 -0
- package/src/tunnel.ts +126 -0
- package/src/types.d.ts +50 -0
- package/src/vt-tracker.ts +188 -0
- package/src/workspace-head.ts +144 -0
- package/src/workspace.ts +153 -0
- package/src/ws-handler.ts +1526 -0
- package/start.ps1 +83 -0
- package/tests/adapters.test.js +171 -0
- package/tests/auth.test.js +243 -0
- package/tests/codex-adapter.test.js +535 -0
- package/tests/durable-stream.test.js +153 -0
- package/tests/e2e/app.spec.js +530 -0
- package/tests/e2ee.test.js +325 -0
- package/tests/message-buffer.test.js +245 -0
- package/tests/message-routing.test.js +305 -0
- package/tests/pty-daemon.test.js +346 -0
- package/tests/push.test.js +281 -0
- package/tests/renderers.test.js +391 -0
- package/tests/search-shell.test.js +499 -0
- package/tests/server.test.js +882 -0
- package/tests/service.test.js +267 -0
- package/tests/store.test.js +369 -0
- package/tests/tunnel.test.js +67 -0
- package/tests/workspace-head.test.js +116 -0
- package/tests/workspace.test.js +417 -0
- package/tsconfig.backend.json +11 -0
- package/tsconfig.json +15 -0
- package/tui/client/client_test.go +125 -0
- package/tui/client/connection.go +342 -0
- package/tui/client/host_manager.go +141 -0
- package/tui/config/cache.go +81 -0
- package/tui/config/config.go +53 -0
- package/tui/config/config_test.go +89 -0
- package/tui/go.mod +32 -0
- package/tui/go.sum +50 -0
- package/tui/main.go +261 -0
- package/tui/tests/integration_test.go +283 -0
- package/tui/ui/model.go +310 -0
- 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
|
+
}
|