@ebowwa/terminal 0.2.0

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.
@@ -0,0 +1,861 @@
1
+ /**
2
+ * Terminal Session Management
3
+ * Handles SSH PTY session lifecycle, persistence, and querying
4
+ * Uses tmux for persistent sessions that survive disconnections
5
+ */
6
+
7
+ import path from "node:path";
8
+ import { createOrAttachTmuxSession, killTmuxSession } from "./tmux.js";
9
+ // metadata import for updating bootstrapStatus on terminal connect
10
+ // import { getMetadata, setMetadata } from "cheapspaces/workspace/src/lib/metadata";
11
+ import type { WebSocket, ServerWebSocket, Subprocess } from "bun";
12
+ import { WebSocketCloseCode } from "@ebowwa/codespaces-types/compile";
13
+
14
+ // Lazy-load metadata functions (Hetzner-specific, optional)
15
+ let metadataModule: {
16
+ getMetadata: (id: string) => any;
17
+ setMetadata: (metadata: any) => void;
18
+ } | null = null;
19
+
20
+ function loadMetadata() {
21
+ if (metadataModule === null) {
22
+ try {
23
+ // Try to import from Hetzner workspace
24
+ metadataModule = require("@ebowwa/hetzner-workspace-metadata");
25
+ } catch {
26
+ // Metadata module not available - bootstrap status tracking will be disabled
27
+ metadataModule = { getMetadata: null as any, setMetadata: null as any };
28
+ }
29
+ }
30
+ return metadataModule;
31
+ }
32
+
33
+ /**
34
+ * Resolve SSH key path to absolute path
35
+ * Handles legacy relative paths from metadata
36
+ */
37
+ function resolveKeyPath(keyPath: string | undefined): string | undefined {
38
+ if (!keyPath) return undefined;
39
+ if (path.isAbsolute(keyPath)) return keyPath;
40
+
41
+ // Legacy relative paths like "../.ssh-keys/..." - resolve from project root
42
+ const projectRoot = path.resolve(import.meta.dir, "../../com.hetzner.codespaces");
43
+ if (keyPath.includes(".ssh-keys")) {
44
+ const keyName = path.basename(keyPath);
45
+ return path.join(projectRoot, ".ssh-keys", keyName);
46
+ }
47
+ return path.resolve(keyPath);
48
+ }
49
+
50
+ /**
51
+ * Terminal session interface
52
+ * Represents an active SSH PTY session
53
+ */
54
+ export interface TerminalSession {
55
+ sessionId: string;
56
+ proc: Subprocess<"pipe", "pipe", "pipe">;
57
+ stdin: Subprocess<"pipe", "pipe", "pipe">["stdin"];
58
+ stdout: ReadableStream<Uint8Array>;
59
+ stderr: ReadableStream<Uint8Array>;
60
+ host: string;
61
+ user: string;
62
+ ws: ServerWebSocket<unknown> | null;
63
+ createdAt: number;
64
+ lastUsed: number;
65
+ reader: ReadableStreamDefaultReader<Uint8Array> | null;
66
+ stderrReader: ReadableStreamDefaultReader<Uint8Array> | null;
67
+ writer: WritableStreamDefaultWriter<Uint8Array> | null;
68
+ closed: boolean;
69
+ tmuxSessionName?: string; // tmux session name if using tmux
70
+ bootstrapLogStreamer?: Subprocess; // Cloud-init log tail process
71
+ }
72
+
73
+ /**
74
+ * Session info for API responses (safe to expose)
75
+ */
76
+ export interface SessionInfo {
77
+ sessionId: string;
78
+ host: string;
79
+ user: string;
80
+ createdAt: number;
81
+ lastUsed: number;
82
+ hasActiveWebSocket: boolean;
83
+ closed: boolean;
84
+ uptime: number;
85
+ idleTime: number;
86
+ }
87
+
88
+ // Active sessions storage
89
+ const terminalSessions = new Map<string, TerminalSession>();
90
+
91
+ /**
92
+ * Clean up old sessions (older than specified milliseconds)
93
+ * Called automatically by interval timer
94
+ */
95
+ export function cleanupStaleSessions(maxAge: number = 30 * 60 * 1000): string[] {
96
+ const now = Date.now();
97
+ const cleaned: string[] = [];
98
+
99
+ for (const [id, session] of terminalSessions.entries()) {
100
+ // Only clean up sessions that have no active WebSocket and are stale
101
+ if (now - session.lastUsed > maxAge && !session.ws) {
102
+ console.log(
103
+ `[Terminal] Cleaning up stale session ${id} (${session.host})`,
104
+ );
105
+ closeSession(id);
106
+ cleaned.push(id);
107
+ }
108
+ }
109
+
110
+ return cleaned;
111
+ }
112
+
113
+ /**
114
+ * Close a specific session
115
+ */
116
+ export async function closeSession(sessionId: string): Promise<boolean> {
117
+ const session = terminalSessions.get(sessionId);
118
+ if (!session) return false;
119
+
120
+ session.closed = true;
121
+
122
+ // Close stdout reader if exists
123
+ if (session.reader) {
124
+ try {
125
+ session.reader.releaseLock();
126
+ } catch {
127
+ // Already released
128
+ }
129
+ }
130
+
131
+ // Close stderr reader if exists
132
+ if (session.stderrReader) {
133
+ try {
134
+ session.stderrReader.releaseLock();
135
+ } catch {
136
+ // Already released
137
+ }
138
+ }
139
+
140
+ // Close WebSocket if still connected
141
+ try {
142
+ session.ws?.close(WebSocketCloseCode.NORMAL_CLOSURE, "Session closed");
143
+ } catch {
144
+ // Already closed
145
+ }
146
+
147
+ // Kill bootstrap log streamer if still running
148
+ if (session.bootstrapLogStreamer) {
149
+ try {
150
+ session.bootstrapLogStreamer.kill();
151
+ console.log(`[Terminal] Killed bootstrap log streamer for ${sessionId}`);
152
+ } catch {
153
+ // Already killed
154
+ }
155
+ }
156
+
157
+ // Kill tmux session if it exists (keep session alive on server)
158
+ if (session.tmuxSessionName) {
159
+ console.log(`[Terminal] Leaving tmux session "${session.tmuxSessionName}" alive on server`);
160
+ // Don't kill tmux session - it persists for reconnection
161
+ }
162
+
163
+ // Kill the SSH process (detaches from tmux but keeps it running)
164
+ try {
165
+ session.proc.kill();
166
+ } catch {
167
+ // Already terminated
168
+ }
169
+
170
+ terminalSessions.delete(sessionId);
171
+ console.log(`[Terminal] Session ${sessionId} closed`);
172
+
173
+ return true;
174
+ }
175
+
176
+ /**
177
+ * Stream cloud-init bootstrap logs from remote server
178
+ * Spawns a background SSH subprocess that tails the cloud-init output log
179
+ * @param host - Target server hostname or IP
180
+ * @param user - SSH user (default: root)
181
+ * @param keyPath - Optional path to SSH private key
182
+ * @param onOutput - Callback for each line of log output
183
+ * @param stopSignal - Object with `stopped: boolean` to signal when to stop streaming
184
+ * @returns Promise that resolves when streaming stops
185
+ */
186
+ async function streamCloudInitLogs(
187
+ host: string,
188
+ user: string,
189
+ keyPath: string | undefined,
190
+ onOutput: (data: string) => void,
191
+ stopSignal: { stopped: boolean },
192
+ ): Promise<Subprocess | null> {
193
+ try {
194
+ // Build SSH command for tailing cloud-init logs
195
+ const sshCmd: string[] = [
196
+ "ssh",
197
+ "-F", "/dev/null", // Skip user SSH config
198
+ "-o", "StrictHostKeyChecking=no",
199
+ "-o", "UserKnownHostsFile=/dev/null",
200
+ "-o", "ServerAliveInterval=30",
201
+ "-o", "ServerAliveCountMax=3",
202
+ ];
203
+
204
+ if (keyPath) {
205
+ sshCmd.push("-i", String(keyPath));
206
+ }
207
+
208
+ // Tail cloud-init logs, with fallback to seed-setup log
209
+ // Using -n +0 to output all existing content, then follow for new lines
210
+ sshCmd.push(
211
+ "-p", "22",
212
+ `${user}@${host}`,
213
+ "tail -n +0 -f /var/log/cloud-init-output.log 2>/dev/null || tail -n +0 -f /var/log/seed-setup.log 2>/dev/null || echo 'Waiting for bootstrap logs to become available...'"
214
+ );
215
+
216
+ console.log("[Bootstrap] Starting log streamer for", host);
217
+
218
+ // Spawn subprocess to tail logs
219
+ const proc = Bun.spawn(sshCmd, {
220
+ stdout: "pipe",
221
+ stderr: "pipe",
222
+ env: {
223
+ ...process.env,
224
+ // Suppress SSH warnings
225
+ LANG: "en_US.UTF-8",
226
+ },
227
+ });
228
+
229
+ // Read stdout and send to callback
230
+ const reader = proc.stdout.getReader();
231
+ const decoder = new TextDecoder();
232
+ let buffer = "";
233
+
234
+ // Start reading in background
235
+ (async () => {
236
+ try {
237
+ while (!stopSignal.stopped) {
238
+ const { done, value } = await reader.read();
239
+ if (done) {
240
+ console.log("[Bootstrap] Log streamer EOF reached");
241
+ break;
242
+ }
243
+
244
+ buffer += decoder.decode(value, { stream: true });
245
+ const lines = buffer.split("\n");
246
+ buffer = lines.pop() || ""; // Keep partial line in buffer
247
+
248
+ for (const line of lines) {
249
+ if (line) {
250
+ onOutput(line + "\n");
251
+ }
252
+ }
253
+ }
254
+ } catch (err) {
255
+ if (!stopSignal.stopped) {
256
+ console.log("[Bootstrap] Log streamer error:", err);
257
+ }
258
+ } finally {
259
+ reader.releaseLock();
260
+ }
261
+ })();
262
+
263
+ return proc;
264
+ } catch (err) {
265
+ console.error("[Bootstrap] Failed to start log streamer:", err);
266
+ return null;
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Find or create a session for a host
272
+ * If sessionId is provided, try to reuse that specific session
273
+ * If sessionId is null/undefined, always create a new session (for multiple terminals)
274
+ * @param onProgress - Optional callback to send progress updates to WebSocket
275
+ * @param onBootstrapOutput - Optional callback to stream bootstrap log output to WebSocket
276
+ */
277
+ export async function getOrCreateSession(
278
+ host: string,
279
+ user: string = "root",
280
+ sessionId: string | null = null,
281
+ keyPath?: string,
282
+ onProgress?: (message: string, status: "info" | "success" | "error") => void,
283
+ environmentId?: string,
284
+ onBootstrapOutput?: (data: string) => void,
285
+ ): Promise<TerminalSession> {
286
+ // Resolve relative key paths to absolute
287
+ keyPath = resolveKeyPath(keyPath);
288
+
289
+ // If specific sessionId requested, try to find and reuse it
290
+ if (sessionId) {
291
+ const existing = terminalSessions.get(sessionId);
292
+ if (existing && !existing.closed && existing.host === host && existing.user === user) {
293
+ console.log(
294
+ `[Terminal] Reusing requested session ${sessionId} for ${user}@${host}`,
295
+ );
296
+ existing.ws = null; // Will be set by caller
297
+ existing.lastUsed = Date.now();
298
+ return existing;
299
+ }
300
+ // Requested session not found or invalid - will create new below
301
+ console.log(
302
+ `[Terminal] Requested session ${sessionId} not found, creating new one`,
303
+ );
304
+ }
305
+
306
+ // Create new session (always creates when sessionId is null or not found)
307
+ const newSessionId = `term-${host.replace(/[^a-zA-Z0-9]/g, "_")}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
308
+ console.log(
309
+ `[Terminal] Creating new session ${newSessionId} for ${user}@${host}`,
310
+ );
311
+
312
+ const progress = (msg: string, status: "info" | "success" | "error" = "info") => {
313
+ console.log(`[Terminal] ${msg}`);
314
+ onProgress?.(msg, status);
315
+ };
316
+
317
+ // First, verify SSH is reachable before attempting seed installation
318
+ progress(`Waiting for SSH connection to ${host}...`);
319
+ let sshReachable = false;
320
+ let lastError: string | null = null;
321
+ const timeout = 5;
322
+ const maxAttempts = 5; // Increased attempts for faster retries
323
+ const retryDelayMs = 500; // Reduced from 1s to 500ms for faster recovery
324
+
325
+ // Resolve and validate SSH key path before connection attempts
326
+ let resolvedKeyPath: string | null = null;
327
+ let keyFileExists = false;
328
+ let keyFilePermissions = null;
329
+ let keyFileStats = null;
330
+
331
+ if (keyPath) {
332
+ try {
333
+ resolvedKeyPath = require('path').resolve(keyPath);
334
+ const keyStats = await import('fs').then(fs => resolvedKeyPath ? fs.promises.stat(resolvedKeyPath).catch(() => null) : null);
335
+ keyFileExists = keyStats !== null;
336
+ if (keyStats) {
337
+ keyFileStats = {
338
+ size: keyStats.size,
339
+ mode: keyStats.mode.toString(8),
340
+ isFile: keyStats.isFile(),
341
+ };
342
+ // Extract Unix permissions (last 3 octal digits)
343
+ keyFilePermissions = (keyStats.mode & parseInt('777', 8)).toString(8);
344
+ }
345
+ console.log(`[Terminal] SSH key validation: keyPath=${keyPath}, resolvedPath=${resolvedKeyPath}, exists=${keyFileExists}, permissions=${keyFilePermissions ?? 'N/A'}, stats=${keyFileStats ? JSON.stringify(keyFileStats) : 'N/A'}`);
346
+ } catch (pathErr) {
347
+ console.log(`[Terminal] SSH key path resolution failed: keyPath=${keyPath}, error=${pathErr instanceof Error ? pathErr.message : String(pathErr)}`);
348
+ }
349
+ } else {
350
+ console.log(`[Terminal] SSH key validation: no keyPath provided (will attempt ssh-agent)`);
351
+ }
352
+
353
+ console.log(`[Terminal] SSH connection parameters: target=${user}@${host}, timeout=${timeout}s, maxAttempts=${maxAttempts}, retryDelay=${retryDelayMs}ms, keyPath=${keyPath ?? 'none'}, resolvedKeyPath=${resolvedKeyPath ?? 'none'}, keyFileExists=${keyFileExists}, keyFilePermissions=${keyFilePermissions ?? 'N/A'}`);
354
+
355
+ // Try up to maxAttempts times with short timeout to check if SSH is reachable
356
+ for (let i = 0; i < maxAttempts; i++) {
357
+ const attempt = i + 1;
358
+ const attemptStart = Date.now();
359
+ try {
360
+ console.log(`[Terminal] SSH connection attempt ${attempt}/${maxAttempts} starting (attemptIdx=${i}, sshReachable=${sshReachable}, lastError=${lastError ?? 'null'})`);
361
+ const { execSSH } = await import("./client.js");
362
+ // Quick connection test with configurable timeout
363
+ await execSSH("echo ok", {
364
+ host,
365
+ user,
366
+ keyPath,
367
+ timeout,
368
+ });
369
+ const elapsed = Date.now() - attemptStart;
370
+ sshReachable = true;
371
+ console.log(`[Terminal] SSH connection successful on attempt ${attempt}/${maxAttempts} (elapsedMs=${elapsed}, sshReachable=true)`);
372
+ break;
373
+ } catch (err) {
374
+ const elapsed = Date.now() - attemptStart;
375
+ const errType = err instanceof Error ? err.constructor.name : typeof err;
376
+ const errorMsg = err instanceof Error ? err.message : String(err);
377
+ const errName = err instanceof Error ? err.name : 'UnknownError';
378
+ // Extract cause from SSHError if available
379
+ const cause = (err as { cause?: unknown }).cause;
380
+ const causeType = cause instanceof Error ? cause.constructor.name : typeof cause;
381
+ const causeMsg = cause instanceof Error ? ` (cause: ${cause.message})` : cause ? ` (cause: ${String(cause)})` : "";
382
+ lastError = `${errorMsg}${causeMsg}`;
383
+ console.log(`[Terminal] SSH connection attempt ${attempt}/${maxAttempts} failed (elapsedMs=${elapsed}, errType=${errType}, errName=${errName}, causeType=${causeType}, sshReachable=${sshReachable}, lastError=${lastError})`);
384
+ // If it's a connection error, server might still be booting
385
+ if (i < maxAttempts - 1) {
386
+ // Wait a bit before retrying
387
+ console.log(`[Terminal] Waiting ${retryDelayMs}ms before retry ${attempt + 1}/${maxAttempts}...`);
388
+ await new Promise(resolve => setTimeout(resolve, retryDelayMs));
389
+ }
390
+ }
391
+ }
392
+
393
+ if (sshReachable) {
394
+ // Check if metadata already says ready — skip polling entirely
395
+ if (environmentId) {
396
+ const metadata = loadMetadata();
397
+ const existing = metadata.getMetadata?.(environmentId);
398
+ if (existing?.bootstrapStatus === "ready") {
399
+ progress(`SSH connected. Environment ready.`, "success");
400
+ // Skip the bootstrap gate entirely
401
+ } else {
402
+ // Fall through to polling below
403
+ }
404
+ }
405
+
406
+ // Only poll if we don't already know the status
407
+ const metadata = loadMetadata();
408
+ const knownReady = environmentId && metadata.getMetadata
409
+ ? metadata.getMetadata(environmentId)?.bootstrapStatus === "ready"
410
+ : false;
411
+
412
+ const bootstrapMaxAttempts = 60; // Poll up to 60 times
413
+ const bootstrapPollIntervalMs = 5000; // Every 5 seconds = 5 min max
414
+ let bootstrapComplete = knownReady;
415
+
416
+ // Start streaming bootstrap logs if callback provided
417
+ const stopLogStreaming = { stopped: false };
418
+ let logStreamer: Subprocess | null = null;
419
+
420
+ if (!knownReady) {
421
+ progress(`SSH connected. Checking bootstrap status...`);
422
+
423
+ // Start streaming logs if callback is provided
424
+ if (onBootstrapOutput) {
425
+ logStreamer = await streamCloudInitLogs(
426
+ host,
427
+ user,
428
+ keyPath,
429
+ onBootstrapOutput,
430
+ stopLogStreaming,
431
+ );
432
+ }
433
+ }
434
+
435
+ for (let attempt = 0; !bootstrapComplete && attempt < bootstrapMaxAttempts; attempt++) {
436
+ try {
437
+ const { execSSH } = await import("./client.js");
438
+ const result = await execSSH(
439
+ "cat /root/.bootstrap-status 2>/dev/null || echo 'status=missing'",
440
+ { host, user, keyPath, timeout: 10 }
441
+ );
442
+
443
+ const output = (typeof result === "string" ? result : String(result)).trim();
444
+ // bootstrap-status is appended to — check if the final status= line says complete
445
+ const statusLines = output.split("\n").filter((l: string) => l.startsWith("status="));
446
+ const lastStatus = statusLines.length > 0 ? statusLines[statusLines.length - 1] : "";
447
+
448
+ if (lastStatus === "status=complete") {
449
+ bootstrapComplete = true;
450
+ progress(`Bootstrap complete.`, "success");
451
+ // Stop log streaming
452
+ stopLogStreaming.stopped = true;
453
+ if (logStreamer) {
454
+ try {
455
+ logStreamer.kill();
456
+ } catch {
457
+ // Already killed
458
+ }
459
+ }
460
+ // Update metadata so the frontend knows this env is ready
461
+ if (environmentId) {
462
+ try {
463
+ const metadata = loadMetadata();
464
+ const existing = metadata.getMetadata?.(environmentId);
465
+ if (existing && existing.bootstrapStatus !== "ready" && metadata.setMetadata) {
466
+ metadata.setMetadata({ ...existing, bootstrapStatus: "ready" });
467
+ }
468
+ } catch { /* non-fatal */ }
469
+ }
470
+ break;
471
+ }
472
+
473
+ if (lastStatus === "status=missing") {
474
+ // No bootstrap-status file — server may not use cloud-init, allow through
475
+ console.log(`[Terminal] No /root/.bootstrap-status found, skipping gate`);
476
+ bootstrapComplete = true;
477
+ progress(`No bootstrap status file found — skipping readiness gate.`);
478
+ // Stop log streaming
479
+ stopLogStreaming.stopped = true;
480
+ if (logStreamer) {
481
+ try {
482
+ logStreamer.kill();
483
+ } catch {
484
+ // Already killed
485
+ }
486
+ }
487
+ if (environmentId) {
488
+ try {
489
+ const metadata = loadMetadata();
490
+ const existing = metadata.getMetadata?.(environmentId);
491
+ if (existing && existing.bootstrapStatus !== "ready" && metadata.setMetadata) {
492
+ metadata.setMetadata({ ...existing, bootstrapStatus: "ready" });
493
+ }
494
+ } catch { /* non-fatal */ }
495
+ }
496
+ break;
497
+ }
498
+
499
+ // Still in progress
500
+ if (attempt === 0) {
501
+ progress(`Server is still bootstrapping. Waiting for cloud-init to finish...`);
502
+ } else if (attempt % 6 === 0) {
503
+ // Log progress every ~30 seconds
504
+ progress(`Still bootstrapping... (${attempt * 5}s elapsed)`);
505
+ }
506
+ } catch (pollErr) {
507
+ console.log(`[Terminal] Bootstrap status poll attempt ${attempt + 1} failed: ${pollErr instanceof Error ? pollErr.message : String(pollErr)}`);
508
+ // SSH might have dropped during heavy bootstrap — keep trying
509
+ }
510
+
511
+ await new Promise(resolve => setTimeout(resolve, bootstrapPollIntervalMs));
512
+ }
513
+
514
+ // Clean up log streamer if still running
515
+ if (!bootstrapComplete) {
516
+ stopLogStreaming.stopped = true;
517
+ if (logStreamer) {
518
+ try {
519
+ logStreamer.kill();
520
+ } catch {
521
+ // Already killed
522
+ }
523
+ }
524
+ progress(`Bootstrap did not complete within 5 minutes. Connecting anyway...`, "error");
525
+ }
526
+ } else {
527
+ // SSH not reachable - server is likely still booting
528
+ progress(`Server not ready yet (${lastError}) - tried 3 attempts with ${timeout}s timeout each`, "info");
529
+ console.log(`[Terminal] All SSH connection attempts exhausted, continuing anyway - frontend will retry`);
530
+ // Continue to create SSH connection anyway - frontend has retry logic
531
+ }
532
+
533
+ // Use tmux for persistent sessions
534
+ // This will install tmux if not present, then create/attach to session
535
+ let sshCmd: string[];
536
+ let tmuxSessionName: string | undefined;
537
+
538
+ try {
539
+ const tmuxResult = await createOrAttachTmuxSession(host, user, keyPath);
540
+ sshCmd = tmuxResult.sshArgs;
541
+ tmuxSessionName = tmuxResult.sessionName;
542
+ console.log(`[Terminal] Using tmux session: ${tmuxSessionName}${tmuxResult.newlyCreated ? ' (newly created)' : ' (reattached)'}`);
543
+ } catch (tmuxError) {
544
+ console.error(`[Terminal] tmux setup failed: ${tmuxError}`);
545
+ // Fallback to plain SSH without tmux
546
+ sshCmd = [
547
+ "ssh",
548
+ "-F", "/dev/null", // Skip user SSH config to avoid conflicts
549
+ "-o", "StrictHostKeyChecking=no",
550
+ "-o", "UserKnownHostsFile=/dev/null",
551
+ "-o", "ServerAliveInterval=30",
552
+ "-o", "ServerAliveCountMax=3",
553
+ "-p", "22",
554
+ ];
555
+ if (keyPath) {
556
+ // Ensure keyPath is properly escaped for SSH
557
+ // Bun.spawn() passes args directly without shell, but SSH -i needs proper path handling
558
+ // When keyPath contains spaces, we need to ensure it's a single argument
559
+ sshCmd.push("-i", String(keyPath));
560
+ }
561
+ sshCmd.push("-t", "-t", `${user}@${host}`);
562
+ }
563
+
564
+ console.log("[Terminal] Spawning SSH:", sshCmd.join(" "));
565
+
566
+ const proc = Bun.spawn(sshCmd, {
567
+ stdin: "pipe",
568
+ stdout: "pipe",
569
+ stderr: "pipe",
570
+ env: {
571
+ ...process.env,
572
+ TERM: "xterm-256color",
573
+ },
574
+ });
575
+
576
+ const stdin = proc.stdin;
577
+ const stdout = proc.stdout as ReadableStream<Uint8Array>;
578
+ const stderr = proc.stderr as ReadableStream<Uint8Array>;
579
+
580
+ const session: TerminalSession = {
581
+ sessionId: newSessionId,
582
+ proc,
583
+ stdin,
584
+ stdout,
585
+ stderr,
586
+ host,
587
+ user,
588
+ ws: null,
589
+ createdAt: Date.now(),
590
+ lastUsed: Date.now(),
591
+ reader: null,
592
+ stderrReader: null,
593
+ writer: null,
594
+ closed: false,
595
+ tmuxSessionName,
596
+ };
597
+
598
+ terminalSessions.set(newSessionId, session);
599
+
600
+ // Handle process exit
601
+ proc.exited
602
+ .then((exitCode) => {
603
+ console.log(
604
+ `[Terminal] Process for ${newSessionId} exited with code ${exitCode}`,
605
+ );
606
+ session.closed = true;
607
+ try {
608
+ session.ws?.close(
609
+ WebSocketCloseCode.NORMAL_CLOSURE,
610
+ `SSH process exited (code: ${exitCode})`
611
+ );
612
+ } catch {
613
+ // Already closed
614
+ }
615
+ terminalSessions.delete(newSessionId);
616
+ })
617
+ .catch((err) => {
618
+ console.log(`[Terminal] Process exit error for ${newSessionId}:`, err);
619
+ session.closed = true;
620
+ try {
621
+ session.ws?.close(
622
+ WebSocketCloseCode.INTERNAL_ERROR,
623
+ "SSH process error"
624
+ );
625
+ } catch {
626
+ // Already closed
627
+ }
628
+ });
629
+
630
+ return session;
631
+ }
632
+
633
+ /**
634
+ * Get a session by ID
635
+ */
636
+ export function getSession(sessionId: string): TerminalSession | undefined {
637
+ return terminalSessions.get(sessionId);
638
+ }
639
+
640
+ /**
641
+ * Get all active sessions
642
+ */
643
+ export function getAllSessions(): TerminalSession[] {
644
+ return Array.from(terminalSessions.values());
645
+ }
646
+
647
+ /**
648
+ * Get session info for all sessions (safe for API responses)
649
+ */
650
+ export function getAllSessionInfo(): SessionInfo[] {
651
+ const now = Date.now();
652
+ return Array.from(terminalSessions.values()).map((session) => ({
653
+ sessionId: session.sessionId,
654
+ host: session.host,
655
+ user: session.user,
656
+ createdAt: session.createdAt,
657
+ lastUsed: session.lastUsed,
658
+ hasActiveWebSocket: !!session.ws,
659
+ closed: session.closed,
660
+ uptime: now - session.createdAt,
661
+ idleTime: now - session.lastUsed,
662
+ }));
663
+ }
664
+
665
+ /**
666
+ * Get session info for a specific session
667
+ */
668
+ export function getSessionInfo(sessionId: string): SessionInfo | undefined {
669
+ const session = terminalSessions.get(sessionId);
670
+ if (!session) return undefined;
671
+
672
+ const now = Date.now();
673
+ return {
674
+ sessionId: session.sessionId,
675
+ host: session.host,
676
+ user: session.user,
677
+ createdAt: session.createdAt,
678
+ lastUsed: session.lastUsed,
679
+ hasActiveWebSocket: !!session.ws,
680
+ closed: session.closed,
681
+ uptime: now - session.createdAt,
682
+ idleTime: now - session.lastUsed,
683
+ };
684
+ }
685
+
686
+ /**
687
+ * Get the total number of active sessions
688
+ */
689
+ export function getSessionCount(): number {
690
+ return terminalSessions.size;
691
+ }
692
+
693
+ /**
694
+ * Find sessions by host
695
+ */
696
+ export function getSessionsByHost(host: string): TerminalSession[] {
697
+ return Array.from(terminalSessions.values()).filter(
698
+ (s) => s.host === host && !s.closed
699
+ );
700
+ }
701
+
702
+ /**
703
+ * Attach a WebSocket to a session
704
+ * Sets up stdin/stdout/stderr streaming
705
+ */
706
+ export function attachWebSocket(
707
+ session: TerminalSession,
708
+ ws: ServerWebSocket<unknown>,
709
+ wasReused: boolean = false
710
+ ): void {
711
+ session.ws = ws;
712
+ session.lastUsed = Date.now();
713
+
714
+ // Send session info to client
715
+ ws.send(
716
+ JSON.stringify({
717
+ type: "session",
718
+ sessionId: session.sessionId,
719
+ existing: wasReused,
720
+ host: session.host,
721
+ user: session.user,
722
+ }),
723
+ );
724
+
725
+ // Start or resume reading stderr
726
+ if (!session.stderrReader) {
727
+ const readStderr = async () => {
728
+ const reader = session.stderr.getReader();
729
+ session.stderrReader = reader;
730
+ try {
731
+ while (!session.closed) {
732
+ const { value, done } = await reader.read();
733
+ if (done || session.closed) break;
734
+ if (value && session.ws) {
735
+ const text = new TextDecoder().decode(value);
736
+ console.log(`[Terminal] stderr: ${text.trim()}`);
737
+ try {
738
+ session.ws.send(text);
739
+ } catch {
740
+ // WebSocket closed, stop reading temporarily
741
+ session.ws = null;
742
+ }
743
+ }
744
+ }
745
+ } catch (err) {
746
+ if (!session.closed) {
747
+ console.log(`[Terminal] stderr read error:`, err);
748
+ }
749
+ } finally {
750
+ if (session.closed) {
751
+ session.stderrReader = null;
752
+ reader.releaseLock();
753
+ }
754
+ // Keep reader open for potential reconnection
755
+ }
756
+ };
757
+ readStderr();
758
+ }
759
+
760
+ // Start or resume reading stdout
761
+ if (!session.reader) {
762
+ const readOutput = async () => {
763
+ const reader = session.stdout.getReader();
764
+ session.reader = reader;
765
+
766
+ try {
767
+ while (!session.closed) {
768
+ const { value, done } = await reader.read();
769
+ if (done || session.closed) {
770
+ console.log(`[Terminal] stdout closed for ${session.sessionId}`);
771
+ break;
772
+ }
773
+
774
+ if (value) {
775
+ const text = new TextDecoder().decode(value);
776
+ if (session.ws) {
777
+ try {
778
+ session.ws.send(text);
779
+ } catch {
780
+ // WebSocket closed, stop reading temporarily
781
+ session.ws = null;
782
+ }
783
+ }
784
+ }
785
+ }
786
+ } catch (err) {
787
+ if (!session.closed) {
788
+ console.log(`[Terminal] stdout read error:`, err);
789
+ }
790
+ } finally {
791
+ if (session.closed) {
792
+ reader.releaseLock();
793
+ }
794
+ // Keep reader open for potential reconnection
795
+ }
796
+ };
797
+ readOutput();
798
+ }
799
+ }
800
+
801
+ /**
802
+ * Write data to a session's stdin
803
+ */
804
+ export async function writeToSession(
805
+ sessionId: string,
806
+ data: string
807
+ ): Promise<boolean> {
808
+ const session = terminalSessions.get(sessionId);
809
+ if (!session) {
810
+ console.log(`[Terminal] Write failed: session ${sessionId} not found`);
811
+ return false;
812
+ }
813
+ if (session.closed) {
814
+ console.log(`[Terminal] Write failed: session ${sessionId} is closed`);
815
+ return false;
816
+ }
817
+
818
+ try {
819
+ await session.stdin.write(data);
820
+ session.lastUsed = Date.now();
821
+ return true;
822
+ } catch (err) {
823
+ console.log(`[Terminal] Write error for ${sessionId}:`, err);
824
+ return false;
825
+ }
826
+ }
827
+
828
+ /**
829
+ * Resize a session's PTY
830
+ */
831
+ export async function resizeSession(
832
+ sessionId: string,
833
+ rows: number,
834
+ cols: number
835
+ ): Promise<boolean> {
836
+ const session = terminalSessions.get(sessionId);
837
+ if (!session || session.closed) return false;
838
+
839
+ try {
840
+ // Send SIGWINCH via escape sequence
841
+ await session.stdin.write(`\u001B[8;${rows};${cols}t`);
842
+ session.lastUsed = Date.now();
843
+ return true;
844
+ } catch {
845
+ return false;
846
+ }
847
+ }
848
+
849
+ /**
850
+ * Detach WebSocket from session (without closing session)
851
+ */
852
+ export function detachWebSocket(sessionId: string, ws: ServerWebSocket): boolean {
853
+ const session = terminalSessions.get(sessionId);
854
+ if (!session || session.ws !== ws) return false;
855
+
856
+ session.ws = null;
857
+ console.log(
858
+ `[Terminal] Detached WebSocket from session ${sessionId}, keeping SSH alive`,
859
+ );
860
+ return true;
861
+ }