@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
package/src/session.ts
ADDED
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session and tab management for Remux.
|
|
3
|
+
* Data model, PTY lifecycle, scrollback, VT tracking, persistence.
|
|
4
|
+
* Supports both direct PTY and daemon-backed PTY modes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import net from "net";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import { spawn } from "child_process";
|
|
12
|
+
import { fileURLToPath } from "url";
|
|
13
|
+
import pty from "node-pty";
|
|
14
|
+
import {
|
|
15
|
+
upsertSession,
|
|
16
|
+
upsertTab,
|
|
17
|
+
loadSessions as loadSessionsFromDb,
|
|
18
|
+
removeSession as removeSessionFromDb,
|
|
19
|
+
removeStaleTab,
|
|
20
|
+
createCommand,
|
|
21
|
+
completeCommand,
|
|
22
|
+
type CommandRecord,
|
|
23
|
+
} from "./store.js";
|
|
24
|
+
import { broadcastPush } from "./push.js";
|
|
25
|
+
import {
|
|
26
|
+
encodeFrame,
|
|
27
|
+
FrameParser,
|
|
28
|
+
TAG_PTY_OUTPUT,
|
|
29
|
+
TAG_CLIENT_INPUT,
|
|
30
|
+
TAG_RESIZE,
|
|
31
|
+
TAG_STATUS_REQ,
|
|
32
|
+
TAG_SNAPSHOT_REQ,
|
|
33
|
+
TAG_SHUTDOWN,
|
|
34
|
+
} from "./pty-daemon.js";
|
|
35
|
+
import type { IPty } from "node-pty";
|
|
36
|
+
import type WebSocket from "ws";
|
|
37
|
+
import { createVtTerminal, type VtTerminal } from "./vt-tracker.js";
|
|
38
|
+
|
|
39
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
40
|
+
const __dirname = path.dirname(__filename);
|
|
41
|
+
|
|
42
|
+
// ── Idle activity tracking (for push on resume) ─────────────────
|
|
43
|
+
|
|
44
|
+
const IDLE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
45
|
+
let lastOutputTimestamp = Date.now();
|
|
46
|
+
let isIdle = false;
|
|
47
|
+
|
|
48
|
+
// ── RingBuffer ───────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
export class RingBuffer {
|
|
51
|
+
buf: Buffer;
|
|
52
|
+
maxBytes: number;
|
|
53
|
+
writePos: number;
|
|
54
|
+
length: number;
|
|
55
|
+
|
|
56
|
+
constructor(maxBytes = 10 * 1024 * 1024) {
|
|
57
|
+
this.buf = Buffer.alloc(maxBytes);
|
|
58
|
+
this.maxBytes = maxBytes;
|
|
59
|
+
this.writePos = 0;
|
|
60
|
+
this.length = 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
write(data: string | Buffer): void {
|
|
64
|
+
const bytes = typeof data === "string" ? Buffer.from(data) : data;
|
|
65
|
+
if (bytes.length >= this.maxBytes) {
|
|
66
|
+
bytes.copy(this.buf, 0, bytes.length - this.maxBytes);
|
|
67
|
+
this.writePos = 0;
|
|
68
|
+
this.length = this.maxBytes;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const space = this.maxBytes - this.writePos;
|
|
72
|
+
if (bytes.length <= space) {
|
|
73
|
+
bytes.copy(this.buf, this.writePos);
|
|
74
|
+
} else {
|
|
75
|
+
bytes.copy(this.buf, this.writePos, 0, space);
|
|
76
|
+
bytes.copy(this.buf, 0, space);
|
|
77
|
+
}
|
|
78
|
+
this.writePos = (this.writePos + bytes.length) % this.maxBytes;
|
|
79
|
+
this.length = Math.min(this.length + bytes.length, this.maxBytes);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
read(): Buffer {
|
|
83
|
+
if (this.length === 0) return Buffer.alloc(0);
|
|
84
|
+
if (this.length < this.maxBytes) {
|
|
85
|
+
// Return a copy to prevent data corruption if buffer is written to before consumer finishes
|
|
86
|
+
return Buffer.from(this.buf.subarray(this.writePos - this.length, this.writePos));
|
|
87
|
+
}
|
|
88
|
+
return Buffer.concat([
|
|
89
|
+
this.buf.subarray(this.writePos),
|
|
90
|
+
this.buf.subarray(0, this.writePos),
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Types ────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export interface RemuxWebSocket extends WebSocket {
|
|
98
|
+
_remuxTabId: number | null;
|
|
99
|
+
_remuxCols: number;
|
|
100
|
+
_remuxRows: number;
|
|
101
|
+
_remuxAuthed: boolean;
|
|
102
|
+
_remuxDeviceId: string | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Shell integration state tracking for OSC 133 sequences. */
|
|
106
|
+
export interface ShellIntegration {
|
|
107
|
+
/** Current phase: 'idle' | 'prompt' | 'command' | 'output' */
|
|
108
|
+
phase: "idle" | "prompt" | "command" | "output";
|
|
109
|
+
/** Buffer to capture command text between 133;B and 133;C */
|
|
110
|
+
commandBuffer: string;
|
|
111
|
+
/** Current working directory from OSC 7 */
|
|
112
|
+
cwd: string | null;
|
|
113
|
+
/** Active command record ID (from DB) */
|
|
114
|
+
activeCommandId: string | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface Tab {
|
|
118
|
+
id: number;
|
|
119
|
+
pty: IPty | null;
|
|
120
|
+
scrollback: RingBuffer;
|
|
121
|
+
vt: VtTerminal | null;
|
|
122
|
+
clients: Set<RemuxWebSocket>;
|
|
123
|
+
cols: number;
|
|
124
|
+
rows: number;
|
|
125
|
+
ended: boolean;
|
|
126
|
+
title: string;
|
|
127
|
+
/** Shell integration state for OSC 133 command tracking */
|
|
128
|
+
shellIntegration: ShellIntegration;
|
|
129
|
+
/** Path to daemon Unix socket (null = direct PTY mode) */
|
|
130
|
+
daemonSocket: string | null;
|
|
131
|
+
/** Connected client socket to daemon (null = not connected) */
|
|
132
|
+
daemonClient: net.Socket | null;
|
|
133
|
+
/** True when tab was restored from DB with dead daemon (readonly until user presses Enter) */
|
|
134
|
+
restored: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface Session {
|
|
138
|
+
name: string;
|
|
139
|
+
tabs: Tab[];
|
|
140
|
+
createdAt: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Data Model ───────────────────────────────────────────────────
|
|
144
|
+
//
|
|
145
|
+
// Session "work" <- sidebar (left)
|
|
146
|
+
// +-- Tab 0 (PTY: zsh) <- tab bar (right)
|
|
147
|
+
// +-- Tab 1 (PTY: vim)
|
|
148
|
+
// +-- Tab 2 (PTY: htop)
|
|
149
|
+
//
|
|
150
|
+
// Session "logs"
|
|
151
|
+
// +-- Tab 0 (PTY: tail)
|
|
152
|
+
|
|
153
|
+
function getShell(): string {
|
|
154
|
+
if (process.platform === "win32") return process.env.COMSPEC || "cmd.exe";
|
|
155
|
+
if (process.env.SHELL) return process.env.SHELL;
|
|
156
|
+
try {
|
|
157
|
+
fs.accessSync("/bin/zsh", fs.constants.X_OK);
|
|
158
|
+
return "/bin/zsh";
|
|
159
|
+
} catch {}
|
|
160
|
+
return "/bin/bash";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── State ────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
let tabIdCounter = 0;
|
|
166
|
+
export const sessionMap = new Map<string, Session>();
|
|
167
|
+
export const controlClients = new Set<RemuxWebSocket>();
|
|
168
|
+
|
|
169
|
+
// ── Daemon helpers ──────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get the path to the compiled pty-daemon.js script.
|
|
173
|
+
* It's compiled alongside server.js in the same directory.
|
|
174
|
+
*/
|
|
175
|
+
function getDaemonScriptPath(): string {
|
|
176
|
+
return path.join(__dirname, "pty-daemon.js");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build a unique socket path for a tab's daemon.
|
|
181
|
+
* Includes process PID to avoid collisions between server instances.
|
|
182
|
+
*/
|
|
183
|
+
function buildSocketPath(tabId: number): string {
|
|
184
|
+
return `/tmp/remux-pty-${tabId}-${process.pid}.sock`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Spawn a PTY daemon as a detached child process.
|
|
189
|
+
* Returns the socket path where the daemon listens.
|
|
190
|
+
*/
|
|
191
|
+
function spawnDaemon(
|
|
192
|
+
tabId: number,
|
|
193
|
+
shell: string,
|
|
194
|
+
cols: number,
|
|
195
|
+
rows: number,
|
|
196
|
+
cwd: string,
|
|
197
|
+
): string {
|
|
198
|
+
const socketPath = buildSocketPath(tabId);
|
|
199
|
+
const daemonScript = getDaemonScriptPath();
|
|
200
|
+
|
|
201
|
+
const child = spawn(process.execPath, [
|
|
202
|
+
daemonScript,
|
|
203
|
+
"--socket", socketPath,
|
|
204
|
+
"--shell", shell,
|
|
205
|
+
"--cols", String(cols),
|
|
206
|
+
"--rows", String(rows),
|
|
207
|
+
"--cwd", cwd,
|
|
208
|
+
"--tab-id", String(tabId),
|
|
209
|
+
], {
|
|
210
|
+
detached: true,
|
|
211
|
+
stdio: "ignore",
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
child.unref();
|
|
215
|
+
console.log(`[session] spawned daemon for tab ${tabId}: pid=${child.pid} socket=${socketPath}`);
|
|
216
|
+
return socketPath;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Connect to a daemon's Unix socket. Returns a Promise that resolves
|
|
221
|
+
* with the connected socket, or rejects on timeout/error.
|
|
222
|
+
*/
|
|
223
|
+
function connectToDaemon(
|
|
224
|
+
socketPath: string,
|
|
225
|
+
retries = 20,
|
|
226
|
+
delayMs = 100,
|
|
227
|
+
): Promise<net.Socket> {
|
|
228
|
+
return new Promise((resolve, reject) => {
|
|
229
|
+
let attempt = 0;
|
|
230
|
+
|
|
231
|
+
function tryConnect() {
|
|
232
|
+
attempt++;
|
|
233
|
+
const socket = net.createConnection({ path: socketPath }, () => {
|
|
234
|
+
resolve(socket);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
socket.on("error", (err) => {
|
|
238
|
+
socket.destroy();
|
|
239
|
+
if (attempt < retries) {
|
|
240
|
+
setTimeout(tryConnect, delayMs);
|
|
241
|
+
} else {
|
|
242
|
+
reject(new Error(`Failed to connect to daemon at ${socketPath} after ${retries} attempts: ${err.message}`));
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
tryConnect();
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Wire up daemon socket data to tab clients and shell integration.
|
|
253
|
+
* The daemon sends TLV frames; we parse them and broadcast PTY output.
|
|
254
|
+
*/
|
|
255
|
+
function wireDaemonToTab(
|
|
256
|
+
tab: Tab,
|
|
257
|
+
daemonSocket: net.Socket,
|
|
258
|
+
sessionName: string,
|
|
259
|
+
): void {
|
|
260
|
+
const parser = new FrameParser((tag, payload) => {
|
|
261
|
+
if (tag === TAG_PTY_OUTPUT) {
|
|
262
|
+
const data = payload.toString("utf8");
|
|
263
|
+
tab.scrollback.write(data);
|
|
264
|
+
if (tab.vt) tab.vt.consume(data);
|
|
265
|
+
for (const ws of tab.clients) {
|
|
266
|
+
sendData(ws, data);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Buffer PTY output for recently-disconnected devices watching this tab
|
|
270
|
+
if (_bufferTabOutputFn) _bufferTabOutputFn(tab.id, data);
|
|
271
|
+
|
|
272
|
+
// Shell integration: parse OSC 133 / OSC 7 sequences
|
|
273
|
+
processShellIntegration(data, tab, sessionName);
|
|
274
|
+
|
|
275
|
+
// E10: dispatch terminal data to adapters
|
|
276
|
+
try {
|
|
277
|
+
const { adapterRegistry } = require("./server.js");
|
|
278
|
+
adapterRegistry?.dispatchTerminalData(sessionName, data);
|
|
279
|
+
} catch { /* adapter not initialized yet */ }
|
|
280
|
+
|
|
281
|
+
// Idle activity tracking: notify on resume after >5 min silence
|
|
282
|
+
const now = Date.now();
|
|
283
|
+
const wasIdle = isIdle || (now - lastOutputTimestamp > IDLE_THRESHOLD_MS);
|
|
284
|
+
if (now - lastOutputTimestamp > IDLE_THRESHOLD_MS) {
|
|
285
|
+
isIdle = true;
|
|
286
|
+
}
|
|
287
|
+
if (wasIdle && isIdle) {
|
|
288
|
+
isIdle = false;
|
|
289
|
+
const connectedDeviceIds: string[] = [];
|
|
290
|
+
for (const ws of controlClients) {
|
|
291
|
+
if (ws._remuxDeviceId) connectedDeviceIds.push(ws._remuxDeviceId);
|
|
292
|
+
}
|
|
293
|
+
broadcastPush(
|
|
294
|
+
"Terminal Activity",
|
|
295
|
+
`New output in "${sessionName}" after idle`,
|
|
296
|
+
connectedDeviceIds,
|
|
297
|
+
).catch(() => {});
|
|
298
|
+
}
|
|
299
|
+
lastOutputTimestamp = now;
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
daemonSocket.on("data", (data: Buffer) => {
|
|
304
|
+
parser.feed(data);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
daemonSocket.on("close", () => {
|
|
308
|
+
console.log(`[session] daemon connection closed for tab ${tab.id}`);
|
|
309
|
+
tab.daemonClient = null;
|
|
310
|
+
// If the daemon connection closes unexpectedly and tab isn't ended, mark as ended
|
|
311
|
+
if (!tab.ended) {
|
|
312
|
+
tab.ended = true;
|
|
313
|
+
if (tab.vt) {
|
|
314
|
+
tab.vt.dispose();
|
|
315
|
+
tab.vt = null;
|
|
316
|
+
}
|
|
317
|
+
const msg = `\r\n\x1b[33mDaemon connection lost\x1b[0m\r\n`;
|
|
318
|
+
for (const ws of tab.clients) {
|
|
319
|
+
sendData(ws, msg);
|
|
320
|
+
}
|
|
321
|
+
broadcastState();
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
daemonSocket.on("error", (err) => {
|
|
326
|
+
console.error(`[session] daemon socket error for tab ${tab.id}:`, err.message);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Spawn a new daemon for a restored tab (user pressed Enter to revive).
|
|
332
|
+
*/
|
|
333
|
+
export async function reviveTab(tab: Tab, session: Session): Promise<boolean> {
|
|
334
|
+
if (!tab.restored) return false;
|
|
335
|
+
|
|
336
|
+
const shell = getShell();
|
|
337
|
+
const socketPath = spawnDaemon(tab.id, shell, tab.cols, tab.rows, homedir());
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const client = await connectToDaemon(socketPath);
|
|
341
|
+
tab.daemonSocket = socketPath;
|
|
342
|
+
tab.daemonClient = client;
|
|
343
|
+
tab.restored = false;
|
|
344
|
+
tab.ended = false;
|
|
345
|
+
|
|
346
|
+
// Create a new VT terminal for the revived tab
|
|
347
|
+
tab.vt = createVtTerminal(tab.cols, tab.rows);
|
|
348
|
+
|
|
349
|
+
wireDaemonToTab(tab, client, session.name);
|
|
350
|
+
|
|
351
|
+
console.log(`[session] revived tab ${tab.id} in session "${session.name}"`);
|
|
352
|
+
broadcastState();
|
|
353
|
+
return true;
|
|
354
|
+
} catch (err: any) {
|
|
355
|
+
console.error(`[session] failed to revive tab ${tab.id}:`, err.message);
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── Shell Integration: OSC 133 + OSC 7 parsing ─────────────────
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Parse PTY output for shell integration OSC sequences:
|
|
364
|
+
* - OSC 133;A -- prompt start
|
|
365
|
+
* - OSC 133;B -- command start (user pressed Enter)
|
|
366
|
+
* - OSC 133;C -- command output start
|
|
367
|
+
* - OSC 133;D;exitcode -- command end with exit code
|
|
368
|
+
* - OSC 7;file://host/path -- CWD update
|
|
369
|
+
*
|
|
370
|
+
* Adapted from Warp terminal / VS Code shell integration patterns.
|
|
371
|
+
*/
|
|
372
|
+
export function processShellIntegration(
|
|
373
|
+
data: string,
|
|
374
|
+
tab: Tab,
|
|
375
|
+
sessionName: string,
|
|
376
|
+
): void {
|
|
377
|
+
const si = tab.shellIntegration;
|
|
378
|
+
|
|
379
|
+
// OSC 7: CWD tracking -- \x1b]7;file://host/path\x07 or \x1b]7;file://host/path\x1b\\
|
|
380
|
+
const osc7Re = /\x1b\]7;file:\/\/[^/]*([^\x07\x1b]*?)(?:\x07|\x1b\\)/g;
|
|
381
|
+
let m7;
|
|
382
|
+
while ((m7 = osc7Re.exec(data)) !== null) {
|
|
383
|
+
const cwdPath = decodeURIComponent(m7[1]);
|
|
384
|
+
if (cwdPath) si.cwd = cwdPath;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// OSC 133: command boundary markers
|
|
388
|
+
// Match \x1b]133;X(;params)?\x07 or \x1b]133;X(;params)?\x1b\\
|
|
389
|
+
const osc133Re = /\x1b\]133;([ABCD])(?:;([^\x07\x1b]*?))?(?:\x07|\x1b\\)/g;
|
|
390
|
+
let m;
|
|
391
|
+
while ((m = osc133Re.exec(data)) !== null) {
|
|
392
|
+
const marker = m[1];
|
|
393
|
+
const params = m[2] || "";
|
|
394
|
+
|
|
395
|
+
switch (marker) {
|
|
396
|
+
case "A": // Prompt start
|
|
397
|
+
si.phase = "prompt";
|
|
398
|
+
si.commandBuffer = "";
|
|
399
|
+
break;
|
|
400
|
+
|
|
401
|
+
case "B": // Command start (after Enter)
|
|
402
|
+
si.phase = "command";
|
|
403
|
+
si.commandBuffer = "";
|
|
404
|
+
break;
|
|
405
|
+
|
|
406
|
+
case "C": { // Command output start -- capture command text between B and C
|
|
407
|
+
// The text between B and C markers contains the command
|
|
408
|
+
// We extract it from data between the B marker end and C marker start
|
|
409
|
+
const bIdx = data.indexOf("\x1b]133;B");
|
|
410
|
+
const cIdx = data.indexOf("\x1b]133;C");
|
|
411
|
+
if (bIdx >= 0 && cIdx > bIdx) {
|
|
412
|
+
// Find end of B marker
|
|
413
|
+
const bEnd = data.indexOf("\x07", bIdx);
|
|
414
|
+
const bEnd2 = data.indexOf("\x1b\\", bIdx);
|
|
415
|
+
const bEndPos = bEnd >= 0 && bEnd < cIdx
|
|
416
|
+
? bEnd + 1
|
|
417
|
+
: bEnd2 >= 0 && bEnd2 < cIdx
|
|
418
|
+
? bEnd2 + 2
|
|
419
|
+
: bIdx + 9; // fallback: skip \x1b]133;B\x07
|
|
420
|
+
const cmdText = data.slice(bEndPos, cIdx).trim();
|
|
421
|
+
if (cmdText) si.commandBuffer = cmdText;
|
|
422
|
+
}
|
|
423
|
+
si.phase = "output";
|
|
424
|
+
// Create command record in DB
|
|
425
|
+
const cmd = createCommand({
|
|
426
|
+
sessionName,
|
|
427
|
+
tabId: tab.id,
|
|
428
|
+
command: si.commandBuffer || undefined,
|
|
429
|
+
cwd: si.cwd || undefined,
|
|
430
|
+
});
|
|
431
|
+
si.activeCommandId = cmd.id;
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
case "D": { // Command end with exit code
|
|
436
|
+
const exitCode = params ? parseInt(params, 10) : 0;
|
|
437
|
+
if (si.activeCommandId) {
|
|
438
|
+
completeCommand(
|
|
439
|
+
si.activeCommandId,
|
|
440
|
+
isNaN(exitCode) ? 0 : exitCode,
|
|
441
|
+
);
|
|
442
|
+
si.activeCommandId = null;
|
|
443
|
+
}
|
|
444
|
+
si.phase = "idle";
|
|
445
|
+
si.commandBuffer = "";
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ── Tab lifecycle ────────────────────────────────────────────────
|
|
453
|
+
|
|
454
|
+
export function createTab(
|
|
455
|
+
session: Session,
|
|
456
|
+
cols = 80,
|
|
457
|
+
rows = 24,
|
|
458
|
+
): Tab {
|
|
459
|
+
const id = tabIdCounter++;
|
|
460
|
+
const shell = getShell();
|
|
461
|
+
|
|
462
|
+
// Check if daemon script exists — if so, use daemon mode
|
|
463
|
+
const daemonScript = getDaemonScriptPath();
|
|
464
|
+
const useDaemon = fs.existsSync(daemonScript);
|
|
465
|
+
|
|
466
|
+
let ptyProcess: IPty | null = null;
|
|
467
|
+
let socketPath: string | null = null;
|
|
468
|
+
|
|
469
|
+
const vtTerminal = createVtTerminal(cols, rows);
|
|
470
|
+
|
|
471
|
+
const tab: Tab = {
|
|
472
|
+
id,
|
|
473
|
+
pty: null,
|
|
474
|
+
scrollback: new RingBuffer(),
|
|
475
|
+
vt: vtTerminal,
|
|
476
|
+
clients: new Set(),
|
|
477
|
+
cols,
|
|
478
|
+
rows,
|
|
479
|
+
ended: false,
|
|
480
|
+
title: `Tab ${session.tabs.length + 1}`,
|
|
481
|
+
shellIntegration: {
|
|
482
|
+
phase: "idle",
|
|
483
|
+
commandBuffer: "",
|
|
484
|
+
cwd: null,
|
|
485
|
+
activeCommandId: null,
|
|
486
|
+
},
|
|
487
|
+
daemonSocket: null,
|
|
488
|
+
daemonClient: null,
|
|
489
|
+
restored: false,
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
if (useDaemon) {
|
|
493
|
+
// Daemon mode: spawn detached pty-daemon process
|
|
494
|
+
socketPath = spawnDaemon(id, shell, cols, rows, homedir());
|
|
495
|
+
tab.daemonSocket = socketPath;
|
|
496
|
+
|
|
497
|
+
// Connect to daemon asynchronously
|
|
498
|
+
connectToDaemon(socketPath).then((client) => {
|
|
499
|
+
tab.daemonClient = client;
|
|
500
|
+
wireDaemonToTab(tab, client, session.name);
|
|
501
|
+
console.log(`[tab] daemon connected for id=${id} in session "${session.name}"`);
|
|
502
|
+
}).catch((err) => {
|
|
503
|
+
console.error(`[tab] failed to connect to daemon for id=${id}:`, err.message);
|
|
504
|
+
// Fallback: spawn direct PTY
|
|
505
|
+
spawnDirectPty(tab, session, shell, cols, rows);
|
|
506
|
+
});
|
|
507
|
+
} else {
|
|
508
|
+
// Direct PTY mode (fallback when daemon script not available)
|
|
509
|
+
spawnDirectPty(tab, session, shell, cols, rows);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
session.tabs.push(tab);
|
|
513
|
+
console.log(
|
|
514
|
+
`[tab] created id=${id} in session "${session.name}" (mode=${useDaemon ? "daemon" : "direct"})`,
|
|
515
|
+
);
|
|
516
|
+
return tab;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Spawn a direct PTY (non-daemon fallback mode).
|
|
521
|
+
* Used when pty-daemon.js is not available or daemon connection fails.
|
|
522
|
+
*/
|
|
523
|
+
function spawnDirectPty(
|
|
524
|
+
tab: Tab,
|
|
525
|
+
session: Session,
|
|
526
|
+
shell: string,
|
|
527
|
+
cols: number,
|
|
528
|
+
rows: number,
|
|
529
|
+
): void {
|
|
530
|
+
const ptyProcess = pty.spawn(shell, [], {
|
|
531
|
+
name: "xterm-256color",
|
|
532
|
+
cols,
|
|
533
|
+
rows,
|
|
534
|
+
cwd: homedir(),
|
|
535
|
+
env: {
|
|
536
|
+
...process.env,
|
|
537
|
+
TERM: "xterm-256color",
|
|
538
|
+
COLORTERM: "truecolor",
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
tab.pty = ptyProcess;
|
|
543
|
+
|
|
544
|
+
ptyProcess.onData((data: string) => {
|
|
545
|
+
tab.scrollback.write(data);
|
|
546
|
+
if (tab.vt) tab.vt.consume(data);
|
|
547
|
+
for (const ws of tab.clients) {
|
|
548
|
+
sendData(ws, data);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Buffer PTY output for recently-disconnected devices watching this tab
|
|
552
|
+
if (_bufferTabOutputFn) _bufferTabOutputFn(tab.id, data);
|
|
553
|
+
|
|
554
|
+
// Shell integration: parse OSC 133 / OSC 7 sequences
|
|
555
|
+
processShellIntegration(data, tab, session.name);
|
|
556
|
+
|
|
557
|
+
// E10: dispatch terminal data to adapters
|
|
558
|
+
try {
|
|
559
|
+
const { adapterRegistry } = require("./server.js");
|
|
560
|
+
adapterRegistry?.dispatchTerminalData(session.name, data);
|
|
561
|
+
} catch { /* adapter not initialized yet */ }
|
|
562
|
+
|
|
563
|
+
// Idle activity tracking: notify on resume after >5 min silence
|
|
564
|
+
const now = Date.now();
|
|
565
|
+
const wasIdle = isIdle || (now - lastOutputTimestamp > IDLE_THRESHOLD_MS);
|
|
566
|
+
if (now - lastOutputTimestamp > IDLE_THRESHOLD_MS) {
|
|
567
|
+
isIdle = true;
|
|
568
|
+
}
|
|
569
|
+
if (wasIdle && isIdle) {
|
|
570
|
+
isIdle = false;
|
|
571
|
+
// Collect deviceIds of currently connected clients to exclude
|
|
572
|
+
const connectedDeviceIds: string[] = [];
|
|
573
|
+
for (const ws of controlClients) {
|
|
574
|
+
if (ws._remuxDeviceId) connectedDeviceIds.push(ws._remuxDeviceId);
|
|
575
|
+
}
|
|
576
|
+
broadcastPush(
|
|
577
|
+
"Terminal Activity",
|
|
578
|
+
`New output in "${session.name}" after idle`,
|
|
579
|
+
connectedDeviceIds,
|
|
580
|
+
).catch(() => {});
|
|
581
|
+
}
|
|
582
|
+
lastOutputTimestamp = now;
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
586
|
+
tab.ended = true;
|
|
587
|
+
if (tab.vt) {
|
|
588
|
+
tab.vt.dispose();
|
|
589
|
+
tab.vt = null;
|
|
590
|
+
}
|
|
591
|
+
const msg = `\r\n\x1b[33mShell exited (code: ${exitCode})\x1b[0m\r\n`;
|
|
592
|
+
for (const ws of tab.clients) {
|
|
593
|
+
sendData(ws, msg);
|
|
594
|
+
}
|
|
595
|
+
broadcastState();
|
|
596
|
+
|
|
597
|
+
// Push notification: shell exit
|
|
598
|
+
broadcastPush(
|
|
599
|
+
"Shell Exited",
|
|
600
|
+
`"${session.name}" tab "${tab.title}" exited (code: ${exitCode})`,
|
|
601
|
+
).catch(() => {});
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ── Session lifecycle ────────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
export function createSession(name: string): Session {
|
|
608
|
+
if (sessionMap.has(name)) return sessionMap.get(name)!;
|
|
609
|
+
const session: Session = { name, tabs: [], createdAt: Date.now() };
|
|
610
|
+
sessionMap.set(name, session);
|
|
611
|
+
console.log(`[session] created "${name}"`);
|
|
612
|
+
return session;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export function deleteSession(name: string): void {
|
|
616
|
+
const session = sessionMap.get(name);
|
|
617
|
+
if (!session) return;
|
|
618
|
+
for (const tab of session.tabs) {
|
|
619
|
+
if (!tab.ended) {
|
|
620
|
+
if (tab.daemonClient) {
|
|
621
|
+
// Send shutdown command to daemon
|
|
622
|
+
try {
|
|
623
|
+
tab.daemonClient.write(encodeFrame(TAG_SHUTDOWN, Buffer.alloc(0)));
|
|
624
|
+
} catch { /* ignore */ }
|
|
625
|
+
} else if (tab.pty) {
|
|
626
|
+
tab.pty.kill();
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
sessionMap.delete(name);
|
|
631
|
+
removeSessionFromDb(name);
|
|
632
|
+
console.log(`[session] deleted "${name}"`);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ── State queries ────────────────────────────────────────────────
|
|
636
|
+
|
|
637
|
+
/** Return the name of the first existing session, or null if none. */
|
|
638
|
+
export function getFirstSessionName(): string | null {
|
|
639
|
+
const first = sessionMap.values().next();
|
|
640
|
+
return first.done ? null : first.value.name;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function getState(): Array<{
|
|
644
|
+
name: string;
|
|
645
|
+
tabs: Array<{
|
|
646
|
+
id: number;
|
|
647
|
+
title: string;
|
|
648
|
+
ended: boolean;
|
|
649
|
+
clients: number;
|
|
650
|
+
restored: boolean;
|
|
651
|
+
}>;
|
|
652
|
+
createdAt: number;
|
|
653
|
+
}> {
|
|
654
|
+
return [...sessionMap.values()].map((s) => ({
|
|
655
|
+
name: s.name,
|
|
656
|
+
tabs: s.tabs.map((t) => ({
|
|
657
|
+
id: t.id,
|
|
658
|
+
title: t.title,
|
|
659
|
+
ended: t.ended,
|
|
660
|
+
clients: t.clients.size,
|
|
661
|
+
restored: t.restored,
|
|
662
|
+
})),
|
|
663
|
+
createdAt: s.createdAt,
|
|
664
|
+
}));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export function findTab(
|
|
668
|
+
tabId: number | null,
|
|
669
|
+
): { session: Session; tab: Tab } | null {
|
|
670
|
+
if (tabId == null) return null;
|
|
671
|
+
for (const session of sessionMap.values()) {
|
|
672
|
+
const tab = session.tabs.find((t) => t.id === tabId);
|
|
673
|
+
if (tab) return { session, tab };
|
|
674
|
+
}
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ── Attach / Detach ──────────────────────────────────────────────
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Attach ws to a specific tab -- detach from previous first.
|
|
682
|
+
* Uses tsm-style snapshot: if VT tracking available, send viewport
|
|
683
|
+
* snapshot; otherwise fall back to raw scrollback.
|
|
684
|
+
* After snapshot, resize PTY + send Ctrl+L to trigger app redraw.
|
|
685
|
+
*/
|
|
686
|
+
export function attachToTab(
|
|
687
|
+
tab: Tab,
|
|
688
|
+
ws: RemuxWebSocket,
|
|
689
|
+
cols: number,
|
|
690
|
+
rows: number,
|
|
691
|
+
): void {
|
|
692
|
+
detachFromTab(ws);
|
|
693
|
+
|
|
694
|
+
if (ws.readyState === ws.OPEN) {
|
|
695
|
+
if (tab.vt && !tab.ended) {
|
|
696
|
+
// tsm pattern: send VT snapshot (screen content with colors + cursor)
|
|
697
|
+
const snapshot = tab.vt.snapshot();
|
|
698
|
+
if (snapshot) sendData(ws, snapshot);
|
|
699
|
+
} else {
|
|
700
|
+
// fallback: raw scrollback
|
|
701
|
+
const history = tab.scrollback.read();
|
|
702
|
+
if (history.length > 0) sendData(ws, history.toString("utf8"));
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
tab.clients.add(ws);
|
|
707
|
+
ws._remuxTabId = tab.id;
|
|
708
|
+
ws._remuxCols = cols;
|
|
709
|
+
ws._remuxRows = rows;
|
|
710
|
+
recalcTabSize(tab);
|
|
711
|
+
|
|
712
|
+
// Send restored banner if tab is in restored-readonly mode
|
|
713
|
+
if (tab.restored) {
|
|
714
|
+
const banner = `\r\n\x1b[33m[Session restored — shell has exited. Press Enter to start a new shell.]\x1b[0m\r\n`;
|
|
715
|
+
sendData(ws, banner);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Only send Ctrl+L redraw when a VT snapshot exists (app is actively
|
|
719
|
+
// rendering). Idle shells don't need it and it pollutes scrollback
|
|
720
|
+
// with ^L artifacts visible in Inspect.
|
|
721
|
+
if (!tab.ended && !tab.restored && tab.vt) {
|
|
722
|
+
const { text } = tab.vt.textSnapshot();
|
|
723
|
+
// Only redraw if there's real content beyond an empty prompt
|
|
724
|
+
if (text && text.trim().length > 0) {
|
|
725
|
+
setTimeout(() => {
|
|
726
|
+
if (tab.daemonClient) {
|
|
727
|
+
tab.daemonClient.write(encodeFrame(TAG_CLIENT_INPUT, "\x0c"));
|
|
728
|
+
} else if (tab.pty) {
|
|
729
|
+
tab.pty.write("\x0c");
|
|
730
|
+
}
|
|
731
|
+
}, 50);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export function detachFromTab(ws: RemuxWebSocket): void {
|
|
737
|
+
const prevId = ws._remuxTabId;
|
|
738
|
+
if (prevId == null) return;
|
|
739
|
+
// find tab across all sessions
|
|
740
|
+
for (const session of sessionMap.values()) {
|
|
741
|
+
const tab = session.tabs.find((t) => t.id === prevId);
|
|
742
|
+
if (tab) {
|
|
743
|
+
tab.clients.delete(ws);
|
|
744
|
+
if (tab.clients.size > 0) recalcTabSize(tab);
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
ws._remuxTabId = null;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export function recalcTabSize(tab: Tab): void {
|
|
752
|
+
let minCols = Infinity;
|
|
753
|
+
let minRows = Infinity;
|
|
754
|
+
for (const ws of tab.clients) {
|
|
755
|
+
if (ws._remuxCols) minCols = Math.min(minCols, ws._remuxCols);
|
|
756
|
+
if (ws._remuxRows) minRows = Math.min(minRows, ws._remuxRows);
|
|
757
|
+
}
|
|
758
|
+
if (minCols < Infinity && minRows < Infinity && !tab.ended) {
|
|
759
|
+
// Clamp to sane ranges to prevent crashes or extreme memory allocation
|
|
760
|
+
minCols = Math.max(1, Math.min(minCols, 500));
|
|
761
|
+
minRows = Math.max(1, Math.min(minRows, 200));
|
|
762
|
+
tab.cols = minCols;
|
|
763
|
+
tab.rows = minRows;
|
|
764
|
+
if (tab.daemonClient) {
|
|
765
|
+
// Send resize to daemon
|
|
766
|
+
const resizePayload = JSON.stringify({ cols: minCols, rows: minRows });
|
|
767
|
+
tab.daemonClient.write(encodeFrame(TAG_RESIZE, resizePayload));
|
|
768
|
+
} else if (tab.pty) {
|
|
769
|
+
tab.pty.resize(minCols, minRows);
|
|
770
|
+
}
|
|
771
|
+
if (tab.vt) tab.vt.resize(minCols, minRows);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ── Broadcast ────────────────────────────────────────────────────
|
|
776
|
+
|
|
777
|
+
// Broadcast hooks -- injected by ws-handler to avoid circular imports.
|
|
778
|
+
// sendEnvelopeFn and getClientListFn are set at startup.
|
|
779
|
+
let _sendEnvelopeFn: ((ws: any, type: string, payload: any) => void) | null =
|
|
780
|
+
null;
|
|
781
|
+
let _getClientListFn: (() => any[]) | null = null;
|
|
782
|
+
// E2EE-aware data send hook: encrypts raw terminal data when E2EE is active
|
|
783
|
+
let _sendDataFn: ((ws: any, data: string) => void) | null = null;
|
|
784
|
+
|
|
785
|
+
// Buffer hooks -- injected by ws-handler for offline message queuing.
|
|
786
|
+
let _bufferTabOutputFn: ((tabId: number, data: string) => void) | null = null;
|
|
787
|
+
let _bufferStateForDisconnectedFn: (() => void) | null = null;
|
|
788
|
+
|
|
789
|
+
export function setBroadcastHooks(
|
|
790
|
+
sendFn: (ws: any, type: string, payload: any) => void,
|
|
791
|
+
clientListFn: () => any[],
|
|
792
|
+
sendDataFn?: (ws: any, data: string) => void,
|
|
793
|
+
): void {
|
|
794
|
+
_sendEnvelopeFn = sendFn;
|
|
795
|
+
_getClientListFn = clientListFn;
|
|
796
|
+
if (sendDataFn) _sendDataFn = sendDataFn;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** Send raw data to a client, using E2EE if available. */
|
|
800
|
+
function sendData(ws: RemuxWebSocket, data: string): void {
|
|
801
|
+
if (_sendDataFn) {
|
|
802
|
+
_sendDataFn(ws, data);
|
|
803
|
+
} else if (ws.readyState === ws.OPEN) {
|
|
804
|
+
ws.send(data);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Set buffer hooks for offline message queuing.
|
|
810
|
+
* Called by ws-handler after initialization to avoid circular imports.
|
|
811
|
+
*/
|
|
812
|
+
export function setBufferHooks(
|
|
813
|
+
bufferTabOutputFn: (tabId: number, data: string) => void,
|
|
814
|
+
bufferStateFn: () => void,
|
|
815
|
+
): void {
|
|
816
|
+
_bufferTabOutputFn = bufferTabOutputFn;
|
|
817
|
+
_bufferStateForDisconnectedFn = bufferStateFn;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export function broadcastState(): void {
|
|
821
|
+
const state = getState();
|
|
822
|
+
const clients = _getClientListFn ? _getClientListFn() : [];
|
|
823
|
+
for (const ws of controlClients) {
|
|
824
|
+
if (_sendEnvelopeFn) {
|
|
825
|
+
_sendEnvelopeFn(ws, "state", { sessions: state, clients });
|
|
826
|
+
} else {
|
|
827
|
+
// Fallback: legacy format (before hooks are wired)
|
|
828
|
+
if (ws.readyState === ws.OPEN) {
|
|
829
|
+
ws.send(JSON.stringify({ v: 1, type: "state", payload: { sessions: state, clients } }));
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
// Also buffer state for recently-disconnected devices
|
|
834
|
+
if (_bufferStateForDisconnectedFn) _bufferStateForDisconnectedFn();
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ── Persistence (SQLite via store.ts) ────────────────────────────
|
|
838
|
+
|
|
839
|
+
export const PERSIST_INTERVAL_MS = 8000;
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Persist all live sessions and tabs to SQLite (scrollback as BLOB).
|
|
843
|
+
*/
|
|
844
|
+
export function persistSessions(): void {
|
|
845
|
+
try {
|
|
846
|
+
for (const session of sessionMap.values()) {
|
|
847
|
+
upsertSession(session.name, session.createdAt);
|
|
848
|
+
for (const tab of session.tabs) {
|
|
849
|
+
upsertTab({
|
|
850
|
+
id: tab.id,
|
|
851
|
+
sessionName: session.name,
|
|
852
|
+
title: tab.title,
|
|
853
|
+
scrollback: tab.ended ? null : tab.scrollback.read(),
|
|
854
|
+
ended: tab.ended,
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
} catch (e: any) {
|
|
859
|
+
console.error("[persist] save failed:", e.message);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Restore sessions from SQLite. Returns saved data or false.
|
|
865
|
+
*/
|
|
866
|
+
export function restoreSessions(): {
|
|
867
|
+
sessions: Array<{
|
|
868
|
+
name: string;
|
|
869
|
+
createdAt: number;
|
|
870
|
+
tabs: Array<{
|
|
871
|
+
id: number;
|
|
872
|
+
title: string;
|
|
873
|
+
ended: boolean;
|
|
874
|
+
scrollback: Buffer | null;
|
|
875
|
+
}>;
|
|
876
|
+
}>;
|
|
877
|
+
} | false {
|
|
878
|
+
try {
|
|
879
|
+
const sessions = loadSessionsFromDb();
|
|
880
|
+
if (sessions.length === 0) return false;
|
|
881
|
+
console.log(`[persist] restoring ${sessions.length} session(s) from SQLite`);
|
|
882
|
+
return { sessions };
|
|
883
|
+
} catch (e: any) {
|
|
884
|
+
console.error("[persist] restore failed:", e.message);
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Check if a daemon socket file exists and is connectable.
|
|
891
|
+
* Used during session restore to detect alive daemons.
|
|
892
|
+
*/
|
|
893
|
+
export function findAliveDaemonSocket(tabId: number): string | null {
|
|
894
|
+
// Look for any socket file matching the tab ID pattern
|
|
895
|
+
const tmpDir = "/tmp";
|
|
896
|
+
try {
|
|
897
|
+
const files = fs.readdirSync(tmpDir);
|
|
898
|
+
const pattern = `remux-pty-${tabId}-`;
|
|
899
|
+
for (const f of files) {
|
|
900
|
+
if (f.startsWith(pattern) && f.endsWith(".sock")) {
|
|
901
|
+
return path.join(tmpDir, f);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
} catch { /* ignore */ }
|
|
905
|
+
return null;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Create a restored tab (from persisted data, without a live PTY).
|
|
910
|
+
* The tab is in restored-readonly mode until the user activates it.
|
|
911
|
+
*/
|
|
912
|
+
export function createRestoredTab(
|
|
913
|
+
session: Session,
|
|
914
|
+
savedTab: { id: number; title: string; scrollback: Buffer | null; ended: boolean },
|
|
915
|
+
): Tab {
|
|
916
|
+
// Ensure tabIdCounter stays ahead of restored IDs
|
|
917
|
+
if (savedTab.id >= tabIdCounter) {
|
|
918
|
+
tabIdCounter = savedTab.id + 1;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const vtTerminal = createVtTerminal(80, 24);
|
|
922
|
+
|
|
923
|
+
const tab: Tab = {
|
|
924
|
+
id: savedTab.id,
|
|
925
|
+
pty: null,
|
|
926
|
+
scrollback: new RingBuffer(),
|
|
927
|
+
vt: vtTerminal,
|
|
928
|
+
clients: new Set(),
|
|
929
|
+
cols: 80,
|
|
930
|
+
rows: 24,
|
|
931
|
+
ended: savedTab.ended,
|
|
932
|
+
title: savedTab.title || `Tab ${session.tabs.length + 1}`,
|
|
933
|
+
shellIntegration: {
|
|
934
|
+
phase: "idle",
|
|
935
|
+
commandBuffer: "",
|
|
936
|
+
cwd: null,
|
|
937
|
+
activeCommandId: null,
|
|
938
|
+
},
|
|
939
|
+
daemonSocket: null,
|
|
940
|
+
daemonClient: null,
|
|
941
|
+
restored: !savedTab.ended, // only "restored" if not already ended
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
// Pre-fill scrollback
|
|
945
|
+
if (savedTab.scrollback) {
|
|
946
|
+
tab.scrollback.write(savedTab.scrollback);
|
|
947
|
+
if (tab.vt) tab.vt.consume(savedTab.scrollback);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
session.tabs.push(tab);
|
|
951
|
+
return tab;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Attempt to reattach to an alive daemon for a restored tab.
|
|
956
|
+
* Returns true if reattach succeeded, false otherwise.
|
|
957
|
+
*/
|
|
958
|
+
export async function reattachToDaemon(
|
|
959
|
+
tab: Tab,
|
|
960
|
+
session: Session,
|
|
961
|
+
socketPath: string,
|
|
962
|
+
): Promise<boolean> {
|
|
963
|
+
try {
|
|
964
|
+
const client = await connectToDaemon(socketPath, 3, 50);
|
|
965
|
+
tab.daemonSocket = socketPath;
|
|
966
|
+
tab.daemonClient = client;
|
|
967
|
+
tab.restored = false;
|
|
968
|
+
tab.ended = false;
|
|
969
|
+
|
|
970
|
+
wireDaemonToTab(tab, client, session.name);
|
|
971
|
+
|
|
972
|
+
console.log(`[session] reattached to daemon for tab ${tab.id} at ${socketPath}`);
|
|
973
|
+
return true;
|
|
974
|
+
} catch (err: any) {
|
|
975
|
+
console.log(`[session] daemon at ${socketPath} not reachable: ${err.message}`);
|
|
976
|
+
return false;
|
|
977
|
+
}
|
|
978
|
+
}
|