@wangyaoshen/remux 0.3.8-dev.29e114b
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E11: Git service — provides git status, diff, worktree, branch operations.
|
|
3
|
+
* Uses simple-git (npm). All operations are read-only by default.
|
|
4
|
+
* Write operations (commit, push) require explicit confirmation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import simpleGit, { SimpleGit, StatusResult, DiffResult } from "simple-git";
|
|
8
|
+
|
|
9
|
+
/** Validate git ref names to prevent flag injection via user-supplied arguments. */
|
|
10
|
+
const SAFE_REF_RE = /^[a-zA-Z0-9._\/@{}\[\]:^~-]+$/;
|
|
11
|
+
function assertSafeRef(ref: string): void {
|
|
12
|
+
if (!ref || ref.startsWith("-") || !SAFE_REF_RE.test(ref)) {
|
|
13
|
+
throw new Error(`Invalid git ref: ${ref}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let git: SimpleGit | null = null;
|
|
18
|
+
|
|
19
|
+
export function initGitService(cwd?: string): void {
|
|
20
|
+
git = simpleGit(cwd || process.cwd());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getGit(): SimpleGit {
|
|
24
|
+
if (!git) initGitService();
|
|
25
|
+
return git!;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// E11-001: git status query
|
|
29
|
+
export async function getGitStatus(): Promise<{
|
|
30
|
+
branch: string;
|
|
31
|
+
status: StatusResult;
|
|
32
|
+
recentCommits: Array<{ hash: string; message: string; date: string; author: string }>;
|
|
33
|
+
}> {
|
|
34
|
+
const g = getGit();
|
|
35
|
+
const status = await g.status();
|
|
36
|
+
const log = await g.log({ maxCount: 10 });
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
branch: status.current || "unknown",
|
|
40
|
+
status,
|
|
41
|
+
recentCommits: log.all.map((c) => ({
|
|
42
|
+
hash: c.hash.substring(0, 7),
|
|
43
|
+
message: c.message,
|
|
44
|
+
date: c.date,
|
|
45
|
+
author: c.author_name,
|
|
46
|
+
})),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// E11-002: git diff query
|
|
51
|
+
export async function getGitDiff(base?: string): Promise<{
|
|
52
|
+
diff: string;
|
|
53
|
+
files: Array<{ file: string; insertions: number; deletions: number }>;
|
|
54
|
+
}> {
|
|
55
|
+
const g = getGit();
|
|
56
|
+
const diffBase = base || "HEAD";
|
|
57
|
+
assertSafeRef(diffBase);
|
|
58
|
+
|
|
59
|
+
const diffText = await g.diff([diffBase]);
|
|
60
|
+
const diffStat = await g.diffSummary([diffBase]);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
diff: diffText.substring(0, 1024 * 1024), // 1MB limit
|
|
64
|
+
files: diffStat.files.map((f) => ({
|
|
65
|
+
file: f.file,
|
|
66
|
+
insertions: "binary" in f && f.binary ? 0 : (f as { insertions: number }).insertions,
|
|
67
|
+
deletions: "binary" in f && f.binary ? 0 : (f as { deletions: number }).deletions,
|
|
68
|
+
})),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// E11-003: worktree operations
|
|
73
|
+
export async function getWorktrees(): Promise<
|
|
74
|
+
Array<{ path: string; branch: string; head: string }>
|
|
75
|
+
> {
|
|
76
|
+
const g = getGit();
|
|
77
|
+
const result = await g.raw(["worktree", "list", "--porcelain"]);
|
|
78
|
+
const worktrees: Array<{ path: string; branch: string; head: string }> = [];
|
|
79
|
+
let current: Record<string, string> = {};
|
|
80
|
+
|
|
81
|
+
for (const line of result.split("\n")) {
|
|
82
|
+
if (line.startsWith("worktree ")) {
|
|
83
|
+
if (current.path) worktrees.push({
|
|
84
|
+
path: current.path,
|
|
85
|
+
branch: current.branch || "",
|
|
86
|
+
head: current.head || "",
|
|
87
|
+
});
|
|
88
|
+
current = { path: line.replace("worktree ", "") };
|
|
89
|
+
} else if (line.startsWith("HEAD ")) {
|
|
90
|
+
current.head = line.replace("HEAD ", "").substring(0, 7);
|
|
91
|
+
} else if (line.startsWith("branch ")) {
|
|
92
|
+
current.branch = line.replace("branch refs/heads/", "");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (current.path) worktrees.push({
|
|
96
|
+
path: current.path,
|
|
97
|
+
branch: current.branch || "",
|
|
98
|
+
head: current.head || "",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return worktrees;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function createWorktree(
|
|
105
|
+
branch: string,
|
|
106
|
+
worktreePath: string,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
assertSafeRef(branch);
|
|
109
|
+
// Prevent path traversal: worktree path must be relative and within project
|
|
110
|
+
if (worktreePath.includes("..") || worktreePath.startsWith("/")) {
|
|
111
|
+
throw new Error(`Invalid worktree path: ${worktreePath}`);
|
|
112
|
+
}
|
|
113
|
+
const g = getGit();
|
|
114
|
+
await g.raw(["worktree", "add", "-b", branch, worktreePath, "dev"]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// E11-013: branch comparison
|
|
118
|
+
export async function compareBranches(
|
|
119
|
+
base: string,
|
|
120
|
+
head: string,
|
|
121
|
+
): Promise<{
|
|
122
|
+
ahead: number;
|
|
123
|
+
behind: number;
|
|
124
|
+
files: Array<{ file: string; insertions: number; deletions: number }>;
|
|
125
|
+
}> {
|
|
126
|
+
assertSafeRef(base);
|
|
127
|
+
assertSafeRef(head);
|
|
128
|
+
const g = getGit();
|
|
129
|
+
const diffStat = await g.diffSummary([`${base}...${head}`]);
|
|
130
|
+
|
|
131
|
+
// Count commits
|
|
132
|
+
const aheadLog = await g.log({ from: base, to: head });
|
|
133
|
+
const behindLog = await g.log({ from: head, to: base });
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
ahead: aheadLog.total,
|
|
137
|
+
behind: behindLog.total,
|
|
138
|
+
files: diffStat.files.map((f) => ({
|
|
139
|
+
file: f.file,
|
|
140
|
+
insertions: "binary" in f && f.binary ? 0 : (f as { insertions: number }).insertions,
|
|
141
|
+
deletions: "binary" in f && f.binary ? 0 : (f as { deletions: number }).deletions,
|
|
142
|
+
})),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// E11-012: git webhook — watch .git for changes
|
|
147
|
+
export function watchGitChanges(
|
|
148
|
+
onChange: () => void,
|
|
149
|
+
): () => void {
|
|
150
|
+
const fs = require("fs");
|
|
151
|
+
const path = require("path");
|
|
152
|
+
const gitDir = path.join(process.cwd(), ".git");
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const watcher = fs.watch(
|
|
156
|
+
gitDir,
|
|
157
|
+
{ recursive: false },
|
|
158
|
+
(eventType: string, filename: string) => {
|
|
159
|
+
if (filename === "HEAD" || filename?.startsWith("refs")) {
|
|
160
|
+
onChange();
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
return () => watcher.close();
|
|
165
|
+
} catch {
|
|
166
|
+
return () => {};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-device message ring buffer for offline message queuing.
|
|
3
|
+
* When a client disconnects, messages are buffered here so they can
|
|
4
|
+
* be replayed on reconnect (session recovery).
|
|
5
|
+
*
|
|
6
|
+
* Design influenced by Mosh's approach to state synchronization and
|
|
7
|
+
* tmux's client-server buffering model.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface BufferedMessage {
|
|
11
|
+
timestamp: number;
|
|
12
|
+
data: string; // serialized JSON message
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Ring buffer that holds messages for a single device.
|
|
17
|
+
* Oldest messages are evicted when maxSize is exceeded.
|
|
18
|
+
* Messages older than maxAgeMs are filtered out on drain/prune.
|
|
19
|
+
*/
|
|
20
|
+
export class MessageBuffer {
|
|
21
|
+
private buffer: BufferedMessage[] = [];
|
|
22
|
+
private maxSize: number;
|
|
23
|
+
private maxAgeMs: number;
|
|
24
|
+
|
|
25
|
+
constructor(maxSize = 1000, maxAgeMs = 10 * 60 * 1000) {
|
|
26
|
+
this.maxSize = maxSize;
|
|
27
|
+
this.maxAgeMs = maxAgeMs;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Add a message to the buffer.
|
|
32
|
+
* Evicts the oldest message if buffer is at capacity.
|
|
33
|
+
*/
|
|
34
|
+
push(data: string): void {
|
|
35
|
+
if (this.buffer.length >= this.maxSize) {
|
|
36
|
+
this.buffer.shift(); // evict oldest
|
|
37
|
+
}
|
|
38
|
+
this.buffer.push({ timestamp: Date.now(), data });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Return all messages (optionally since a given timestamp).
|
|
43
|
+
* Filters out expired messages (older than maxAgeMs).
|
|
44
|
+
* Clears returned messages from the buffer.
|
|
45
|
+
*/
|
|
46
|
+
drain(since?: number): BufferedMessage[] {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
const cutoff = now - this.maxAgeMs;
|
|
49
|
+
|
|
50
|
+
let result = this.buffer.filter((m) => m.timestamp > cutoff);
|
|
51
|
+
if (since !== undefined) {
|
|
52
|
+
result = result.filter((m) => m.timestamp > since);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.buffer = [];
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Clear all messages from the buffer.
|
|
61
|
+
*/
|
|
62
|
+
clear(): void {
|
|
63
|
+
this.buffer = [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Number of messages currently in the buffer.
|
|
68
|
+
*/
|
|
69
|
+
get size(): number {
|
|
70
|
+
return this.buffer.length;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Remove messages older than maxAgeMs.
|
|
75
|
+
*/
|
|
76
|
+
pruneExpired(): void {
|
|
77
|
+
const cutoff = Date.now() - this.maxAgeMs;
|
|
78
|
+
this.buffer = this.buffer.filter((m) => m.timestamp > cutoff);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Global registry of message buffers, keyed by deviceId.
|
|
84
|
+
* Runs periodic cleanup to evict stale/empty buffers.
|
|
85
|
+
*/
|
|
86
|
+
export class BufferRegistry {
|
|
87
|
+
private buffers = new Map<string, MessageBuffer>();
|
|
88
|
+
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
|
89
|
+
|
|
90
|
+
constructor() {
|
|
91
|
+
// Periodic cleanup every 60s
|
|
92
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get or create a MessageBuffer for a device.
|
|
97
|
+
*/
|
|
98
|
+
getOrCreate(deviceId: string): MessageBuffer {
|
|
99
|
+
let buf = this.buffers.get(deviceId);
|
|
100
|
+
if (!buf) {
|
|
101
|
+
buf = new MessageBuffer();
|
|
102
|
+
this.buffers.set(deviceId, buf);
|
|
103
|
+
}
|
|
104
|
+
return buf;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Remove the buffer for a device.
|
|
109
|
+
*/
|
|
110
|
+
remove(deviceId: string): void {
|
|
111
|
+
this.buffers.delete(deviceId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Remove empty buffers that have no messages (stale).
|
|
116
|
+
* Prune expired messages in all remaining buffers.
|
|
117
|
+
*/
|
|
118
|
+
private cleanup(): void {
|
|
119
|
+
for (const [deviceId, buf] of this.buffers) {
|
|
120
|
+
buf.pruneExpired();
|
|
121
|
+
if (buf.size === 0) {
|
|
122
|
+
this.buffers.delete(deviceId);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Tear down: clear the cleanup interval and all buffers.
|
|
129
|
+
*/
|
|
130
|
+
destroy(): void {
|
|
131
|
+
if (this.cleanupInterval) {
|
|
132
|
+
clearInterval(this.cleanupInterval);
|
|
133
|
+
this.cleanupInterval = null;
|
|
134
|
+
}
|
|
135
|
+
this.buffers.clear();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY Daemon — independent Node.js process that holds a PTY alive.
|
|
3
|
+
* Each tab spawns one daemon. The daemon survives server restarts.
|
|
4
|
+
* Communication is via a Unix domain socket using a TLV frame protocol.
|
|
5
|
+
*
|
|
6
|
+
* Adapted from tmux client-server model and tsm's VT tracking pattern.
|
|
7
|
+
*
|
|
8
|
+
* Usage: node pty-daemon.js --socket <path> --shell <shell> --cols <n> --rows <n> --cwd <dir> --tab-id <id>
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import net from "net";
|
|
12
|
+
import pty from "node-pty";
|
|
13
|
+
import { parseArgs } from "util";
|
|
14
|
+
import type { IPty } from "node-pty";
|
|
15
|
+
|
|
16
|
+
// ── TLV Frame Tags ──────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export const TAG_PTY_OUTPUT = 0x01;
|
|
19
|
+
export const TAG_CLIENT_INPUT = 0x02;
|
|
20
|
+
export const TAG_RESIZE = 0x03;
|
|
21
|
+
export const TAG_STATUS_REQ = 0x04;
|
|
22
|
+
export const TAG_STATUS_RES = 0x05;
|
|
23
|
+
export const TAG_SNAPSHOT_REQ = 0x06;
|
|
24
|
+
export const TAG_SNAPSHOT_RES = 0x07;
|
|
25
|
+
export const TAG_SCROLLBACK_REQ = 0x08;
|
|
26
|
+
export const TAG_SCROLLBACK_RES = 0x09;
|
|
27
|
+
export const TAG_SHUTDOWN = 0xff;
|
|
28
|
+
|
|
29
|
+
// ── TLV Frame Codec ─────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Encode a TLV frame: [1 byte tag][4 bytes length (big-endian)][payload].
|
|
33
|
+
*/
|
|
34
|
+
export function encodeFrame(tag: number, payload: Buffer | string): Buffer {
|
|
35
|
+
const data = typeof payload === "string" ? Buffer.from(payload, "utf8") : payload;
|
|
36
|
+
const frame = Buffer.alloc(5 + data.length);
|
|
37
|
+
frame[0] = tag;
|
|
38
|
+
frame.writeUInt32BE(data.length, 1);
|
|
39
|
+
data.copy(frame, 5);
|
|
40
|
+
return frame;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* TLV frame parser — accumulates data and emits complete frames.
|
|
45
|
+
*/
|
|
46
|
+
export class FrameParser {
|
|
47
|
+
private buffer = Buffer.alloc(0);
|
|
48
|
+
private onFrame: (tag: number, payload: Buffer) => void;
|
|
49
|
+
|
|
50
|
+
constructor(onFrame: (tag: number, payload: Buffer) => void) {
|
|
51
|
+
this.onFrame = onFrame;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
feed(data: Buffer): void {
|
|
55
|
+
this.buffer = Buffer.concat([this.buffer, data]);
|
|
56
|
+
while (this.buffer.length >= 5) {
|
|
57
|
+
const tag = this.buffer[0];
|
|
58
|
+
const length = this.buffer.readUInt32BE(1);
|
|
59
|
+
if (this.buffer.length < 5 + length) break; // incomplete frame
|
|
60
|
+
const payload = this.buffer.subarray(5, 5 + length);
|
|
61
|
+
this.buffer = this.buffer.subarray(5 + length);
|
|
62
|
+
this.onFrame(tag, payload);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── RingBuffer (scrollback) ─────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
class RingBuffer {
|
|
70
|
+
private buf: Buffer;
|
|
71
|
+
private maxBytes: number;
|
|
72
|
+
private writePos: number;
|
|
73
|
+
private length: number;
|
|
74
|
+
|
|
75
|
+
constructor(maxBytes = 10 * 1024 * 1024) {
|
|
76
|
+
this.buf = Buffer.alloc(maxBytes);
|
|
77
|
+
this.maxBytes = maxBytes;
|
|
78
|
+
this.writePos = 0;
|
|
79
|
+
this.length = 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
write(data: string | Buffer): void {
|
|
83
|
+
const bytes = typeof data === "string" ? Buffer.from(data) : data;
|
|
84
|
+
if (bytes.length >= this.maxBytes) {
|
|
85
|
+
bytes.copy(this.buf, 0, bytes.length - this.maxBytes);
|
|
86
|
+
this.writePos = 0;
|
|
87
|
+
this.length = this.maxBytes;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const space = this.maxBytes - this.writePos;
|
|
91
|
+
if (bytes.length <= space) {
|
|
92
|
+
bytes.copy(this.buf, this.writePos);
|
|
93
|
+
} else {
|
|
94
|
+
bytes.copy(this.buf, this.writePos, 0, space);
|
|
95
|
+
bytes.copy(this.buf, 0, space);
|
|
96
|
+
}
|
|
97
|
+
this.writePos = (this.writePos + bytes.length) % this.maxBytes;
|
|
98
|
+
this.length = Math.min(this.length + bytes.length, this.maxBytes);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
read(): Buffer {
|
|
102
|
+
if (this.length === 0) return Buffer.alloc(0);
|
|
103
|
+
if (this.length < this.maxBytes) {
|
|
104
|
+
return Buffer.from(this.buf.subarray(this.writePos - this.length, this.writePos));
|
|
105
|
+
}
|
|
106
|
+
return Buffer.concat([
|
|
107
|
+
this.buf.subarray(this.writePos),
|
|
108
|
+
this.buf.subarray(0, this.writePos),
|
|
109
|
+
]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── CLI argument parsing ────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
function parseCliArgs(): {
|
|
116
|
+
socket: string;
|
|
117
|
+
shell: string;
|
|
118
|
+
cols: number;
|
|
119
|
+
rows: number;
|
|
120
|
+
cwd: string;
|
|
121
|
+
tabId: string;
|
|
122
|
+
} {
|
|
123
|
+
const { values } = parseArgs({
|
|
124
|
+
options: {
|
|
125
|
+
socket: { type: "string" },
|
|
126
|
+
shell: { type: "string" },
|
|
127
|
+
cols: { type: "string" },
|
|
128
|
+
rows: { type: "string" },
|
|
129
|
+
cwd: { type: "string" },
|
|
130
|
+
"tab-id": { type: "string" },
|
|
131
|
+
},
|
|
132
|
+
strict: true,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (!values.socket || !values.shell) {
|
|
136
|
+
console.error("Usage: pty-daemon --socket <path> --shell <shell> [--cols N] [--rows N] [--cwd dir] [--tab-id id]");
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
socket: values.socket,
|
|
142
|
+
shell: values.shell,
|
|
143
|
+
cols: parseInt(values.cols || "80", 10),
|
|
144
|
+
rows: parseInt(values.rows || "24", 10),
|
|
145
|
+
cwd: values.cwd || process.env.HOME || "/",
|
|
146
|
+
tabId: values["tab-id"] || "0",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Daemon main ─────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
function main(): void {
|
|
153
|
+
const args = parseCliArgs();
|
|
154
|
+
|
|
155
|
+
// Sequence counter for durable stream (Step 4)
|
|
156
|
+
let seq = 0;
|
|
157
|
+
|
|
158
|
+
// Detach from parent process if possible (survive server restarts)
|
|
159
|
+
if (typeof process.disconnect === "function") {
|
|
160
|
+
try { process.disconnect(); } catch { /* already disconnected */ }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Create PTY
|
|
164
|
+
const ptyProcess: IPty = pty.spawn(args.shell, [], {
|
|
165
|
+
name: "xterm-256color",
|
|
166
|
+
cols: args.cols,
|
|
167
|
+
rows: args.rows,
|
|
168
|
+
cwd: args.cwd,
|
|
169
|
+
env: {
|
|
170
|
+
...process.env,
|
|
171
|
+
TERM: "xterm-256color",
|
|
172
|
+
COLORTERM: "truecolor",
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const scrollback = new RingBuffer();
|
|
177
|
+
const clients = new Set<net.Socket>();
|
|
178
|
+
let alive = true;
|
|
179
|
+
|
|
180
|
+
console.log(`[pty-daemon] started: pid=${ptyProcess.pid} socket=${args.socket} tab-id=${args.tabId}`);
|
|
181
|
+
|
|
182
|
+
// ── PTY output → broadcast to all clients + scrollback ──
|
|
183
|
+
|
|
184
|
+
ptyProcess.onData((data: string) => {
|
|
185
|
+
seq++;
|
|
186
|
+
scrollback.write(data);
|
|
187
|
+
const frame = encodeFrame(TAG_PTY_OUTPUT, data);
|
|
188
|
+
for (const client of clients) {
|
|
189
|
+
try {
|
|
190
|
+
client.write(frame);
|
|
191
|
+
} catch {
|
|
192
|
+
// Client write error — will be cleaned up on close
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ── PTY exit ──
|
|
198
|
+
|
|
199
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
200
|
+
alive = false;
|
|
201
|
+
console.log(`[pty-daemon] PTY exited: code=${exitCode} tab-id=${args.tabId}`);
|
|
202
|
+
|
|
203
|
+
// Notify all clients
|
|
204
|
+
const exitMsg = `\r\n\x1b[33mShell exited (code: ${exitCode})\x1b[0m\r\n`;
|
|
205
|
+
const frame = encodeFrame(TAG_PTY_OUTPUT, exitMsg);
|
|
206
|
+
for (const client of clients) {
|
|
207
|
+
try { client.write(frame); } catch { /* ignore */ }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Wait briefly for clients to disconnect, then clean up
|
|
211
|
+
setTimeout(() => {
|
|
212
|
+
for (const client of clients) {
|
|
213
|
+
try { client.end(); } catch { /* ignore */ }
|
|
214
|
+
}
|
|
215
|
+
cleanup();
|
|
216
|
+
}, 2000);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ── Unix socket server ──
|
|
220
|
+
|
|
221
|
+
const server = net.createServer((socket) => {
|
|
222
|
+
clients.add(socket);
|
|
223
|
+
console.log(`[pty-daemon] client connected (total: ${clients.size})`);
|
|
224
|
+
|
|
225
|
+
const parser = new FrameParser((tag, payload) => {
|
|
226
|
+
switch (tag) {
|
|
227
|
+
case TAG_CLIENT_INPUT:
|
|
228
|
+
if (alive) {
|
|
229
|
+
ptyProcess.write(payload.toString("utf8"));
|
|
230
|
+
}
|
|
231
|
+
break;
|
|
232
|
+
|
|
233
|
+
case TAG_RESIZE: {
|
|
234
|
+
try {
|
|
235
|
+
const { cols, rows } = JSON.parse(payload.toString("utf8"));
|
|
236
|
+
if (alive && cols > 0 && rows > 0) {
|
|
237
|
+
ptyProcess.resize(
|
|
238
|
+
Math.max(1, Math.min(cols, 500)),
|
|
239
|
+
Math.max(1, Math.min(rows, 200)),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
} catch { /* invalid resize payload */ }
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
case TAG_STATUS_REQ: {
|
|
247
|
+
const status = JSON.stringify({
|
|
248
|
+
pid: ptyProcess.pid,
|
|
249
|
+
cols: args.cols,
|
|
250
|
+
rows: args.rows,
|
|
251
|
+
alive,
|
|
252
|
+
cwd: args.cwd,
|
|
253
|
+
tabId: args.tabId,
|
|
254
|
+
seq,
|
|
255
|
+
});
|
|
256
|
+
socket.write(encodeFrame(TAG_STATUS_RES, status));
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
case TAG_SNAPSHOT_REQ: {
|
|
261
|
+
const data = scrollback.read();
|
|
262
|
+
socket.write(encodeFrame(TAG_SNAPSHOT_RES, data));
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
case TAG_SCROLLBACK_REQ: {
|
|
267
|
+
// Return full scrollback (future: support since-seq filtering)
|
|
268
|
+
const data = scrollback.read();
|
|
269
|
+
socket.write(encodeFrame(TAG_SCROLLBACK_RES, data));
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
case TAG_SHUTDOWN:
|
|
274
|
+
console.log(`[pty-daemon] shutdown requested`);
|
|
275
|
+
if (alive) {
|
|
276
|
+
try { ptyProcess.kill(); } catch { /* already dead */ }
|
|
277
|
+
}
|
|
278
|
+
// Close all clients and clean up
|
|
279
|
+
for (const c of clients) {
|
|
280
|
+
try { c.end(); } catch { /* ignore */ }
|
|
281
|
+
}
|
|
282
|
+
cleanup();
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
socket.on("data", (data) => {
|
|
288
|
+
parser.feed(Buffer.isBuffer(data) ? data : Buffer.from(data));
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
socket.on("close", () => {
|
|
292
|
+
clients.delete(socket);
|
|
293
|
+
console.log(`[pty-daemon] client disconnected (total: ${clients.size})`);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
socket.on("error", (err) => {
|
|
297
|
+
console.error(`[pty-daemon] client socket error:`, err.message);
|
|
298
|
+
clients.delete(socket);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Clean up stale socket file before listening
|
|
303
|
+
try {
|
|
304
|
+
const fs = require("fs");
|
|
305
|
+
if (fs.existsSync(args.socket)) {
|
|
306
|
+
fs.unlinkSync(args.socket);
|
|
307
|
+
}
|
|
308
|
+
} catch { /* ignore */ }
|
|
309
|
+
|
|
310
|
+
server.listen(args.socket, () => {
|
|
311
|
+
console.log(`[pty-daemon] listening on ${args.socket}`);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
server.on("error", (err) => {
|
|
315
|
+
console.error(`[pty-daemon] server error:`, err.message);
|
|
316
|
+
cleanup();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ── Cleanup ──
|
|
320
|
+
|
|
321
|
+
function cleanup(): void {
|
|
322
|
+
try {
|
|
323
|
+
const fs = require("fs");
|
|
324
|
+
if (fs.existsSync(args.socket)) {
|
|
325
|
+
fs.unlinkSync(args.socket);
|
|
326
|
+
}
|
|
327
|
+
} catch { /* ignore */ }
|
|
328
|
+
server.close();
|
|
329
|
+
process.exit(0);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── Signal handling ──
|
|
333
|
+
|
|
334
|
+
process.on("SIGTERM", () => {
|
|
335
|
+
console.log(`[pty-daemon] SIGTERM received`);
|
|
336
|
+
if (alive) {
|
|
337
|
+
try { ptyProcess.kill(); } catch { /* already dead */ }
|
|
338
|
+
}
|
|
339
|
+
cleanup();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
process.on("SIGINT", () => {
|
|
343
|
+
// Ignore SIGINT in daemon — only respond to SIGTERM or explicit shutdown
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Run only when executed as the main entry point (not when imported).
|
|
348
|
+
// When esbuild bundles this file as pty-daemon.js, it becomes the main module.
|
|
349
|
+
// When imported by session.ts (for encodeFrame/FrameParser), we skip main().
|
|
350
|
+
//
|
|
351
|
+
// Detection: if process.argv[1] contains "pty-daemon" or if CLI args include --socket,
|
|
352
|
+
// this is being run as the daemon entry point.
|
|
353
|
+
const isMainEntry = process.argv[1]?.includes("pty-daemon") ||
|
|
354
|
+
process.argv.includes("--socket");
|
|
355
|
+
if (isMainEntry) {
|
|
356
|
+
main();
|
|
357
|
+
}
|