@wangyaoshen/remux 0.3.8-dev.a8ceb0c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
  4. package/.github/dependabot.yml +33 -0
  5. package/.github/workflows/ci.yml +65 -0
  6. package/.github/workflows/deploy.yml +65 -0
  7. package/.github/workflows/publish.yml +312 -0
  8. package/.github/workflows/release-please.yml +21 -0
  9. package/.gitmodules +3 -0
  10. package/.nvmrc +1 -0
  11. package/.release-please-manifest.json +3 -0
  12. package/CLAUDE.md +104 -0
  13. package/Dockerfile +23 -0
  14. package/LICENSE +21 -0
  15. package/README.md +120 -0
  16. package/apps/ios/Config/signing.xcconfig +4 -0
  17. package/apps/ios/Package.swift +26 -0
  18. package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
  19. package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  20. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
  21. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
  22. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
  23. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
  24. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
  25. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
  26. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
  27. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
  28. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
  29. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
  30. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
  31. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
  32. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
  33. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
  34. package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
  35. package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
  36. package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
  37. package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
  38. package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
  39. package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
  40. package/apps/ios/Sources/Remux/RootView.swift +130 -0
  41. package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
  42. package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
  43. package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
  44. package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
  45. package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
  46. package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
  47. package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
  48. package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
  49. package/apps/macos/Package.swift +37 -0
  50. package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
  51. package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
  52. package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
  53. package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
  54. package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
  55. package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
  56. package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
  57. package/apps/macos/Resources/terminfo/67/ghostty +0 -0
  58. package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
  59. package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
  60. package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
  61. package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
  62. package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
  63. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
  64. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
  65. package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
  66. package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
  67. package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
  68. package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
  69. package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
  70. package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
  71. package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
  72. package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
  73. package/apps/macos/Sources/Remux/SocketController.swift +258 -0
  74. package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
  75. package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
  76. package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
  77. package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
  78. package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
  79. package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
  80. package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
  81. package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
  82. package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
  83. package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
  84. package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
  85. package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
  86. package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
  87. package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
  88. package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
  89. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
  90. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
  91. package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
  92. package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
  93. package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
  94. package/build.mjs +33 -0
  95. package/native/android/DecodeGoldenPayloads.kt +487 -0
  96. package/native/android/ProtocolModels.kt +188 -0
  97. package/native/ios/DecodeGoldenPayloads.swift +711 -0
  98. package/native/ios/ProtocolModels.swift +200 -0
  99. package/package.json +45 -0
  100. package/packages/RemuxKit/Package.swift +27 -0
  101. package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
  102. package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
  103. package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
  104. package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
  105. package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
  106. package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
  107. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
  108. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
  109. package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
  110. package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
  111. package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
  112. package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
  113. package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
  114. package/playwright.config.ts +17 -0
  115. package/pnpm-lock.yaml +1588 -0
  116. package/pty-daemon.js +303 -0
  117. package/release-please-config.json +14 -0
  118. package/scripts/auto-deploy.sh +46 -0
  119. package/scripts/build-dmg.sh +121 -0
  120. package/scripts/build-ghostty-kit.sh +43 -0
  121. package/scripts/check-active-terminology.mjs +132 -0
  122. package/scripts/setup-ci-secrets.sh +80 -0
  123. package/scripts/sync-ghostty-web.sh +28 -0
  124. package/scripts/upload-testflight.sh +100 -0
  125. package/server.js +7074 -0
  126. package/src/adapters/agent-events.ts +246 -0
  127. package/src/adapters/claude-code.ts +158 -0
  128. package/src/adapters/codex.ts +210 -0
  129. package/src/adapters/generic-shell.ts +58 -0
  130. package/src/adapters/index.ts +15 -0
  131. package/src/adapters/registry.ts +99 -0
  132. package/src/adapters/types.ts +41 -0
  133. package/src/auth.ts +174 -0
  134. package/src/e2ee.ts +236 -0
  135. package/src/git-service.ts +168 -0
  136. package/src/message-buffer.ts +137 -0
  137. package/src/pty-daemon.ts +357 -0
  138. package/src/push.ts +127 -0
  139. package/src/renderers.ts +455 -0
  140. package/src/server.ts +2407 -0
  141. package/src/service.ts +226 -0
  142. package/src/session.ts +978 -0
  143. package/src/store.ts +1422 -0
  144. package/src/team.ts +123 -0
  145. package/src/tunnel.ts +126 -0
  146. package/src/types.d.ts +50 -0
  147. package/src/vt-tracker.ts +188 -0
  148. package/src/workspace-head.ts +144 -0
  149. package/src/workspace.ts +153 -0
  150. package/src/ws-handler.ts +1526 -0
  151. package/start.ps1 +83 -0
  152. package/tests/adapters.test.js +171 -0
  153. package/tests/auth.test.js +243 -0
  154. package/tests/codex-adapter.test.js +535 -0
  155. package/tests/durable-stream.test.js +153 -0
  156. package/tests/e2e/app.spec.js +530 -0
  157. package/tests/e2ee.test.js +325 -0
  158. package/tests/message-buffer.test.js +245 -0
  159. package/tests/message-routing.test.js +305 -0
  160. package/tests/pty-daemon.test.js +346 -0
  161. package/tests/push.test.js +281 -0
  162. package/tests/renderers.test.js +391 -0
  163. package/tests/search-shell.test.js +499 -0
  164. package/tests/server.test.js +882 -0
  165. package/tests/service.test.js +267 -0
  166. package/tests/store.test.js +369 -0
  167. package/tests/tunnel.test.js +67 -0
  168. package/tests/workspace-head.test.js +116 -0
  169. package/tests/workspace.test.js +417 -0
  170. package/tsconfig.backend.json +11 -0
  171. package/tsconfig.json +15 -0
  172. package/tui/client/client_test.go +125 -0
  173. package/tui/client/connection.go +342 -0
  174. package/tui/client/host_manager.go +141 -0
  175. package/tui/config/cache.go +81 -0
  176. package/tui/config/config.go +53 -0
  177. package/tui/config/config_test.go +89 -0
  178. package/tui/go.mod +32 -0
  179. package/tui/go.sum +50 -0
  180. package/tui/main.go +261 -0
  181. package/tui/tests/integration_test.go +283 -0
  182. package/tui/ui/model.go +310 -0
  183. package/vitest.config.js +10 -0
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
+ }