66 */
77
88import { log } from "@/services/log" ;
9- import type { Runtime , ExecStream } from "@/runtime/Runtime" ;
9+ import type { Runtime } from "@/runtime/Runtime" ;
1010import type { TerminalSession , TerminalCreateParams , TerminalResizeParams } from "@/types/terminal" ;
1111import type { IPty } from "node-pty" ;
12- import { SSHRuntime } from "@/runtime/SSHRuntime" ;
12+ import { SSHRuntime , type SSHRuntimeConfig } from "@/runtime/SSHRuntime" ;
1313import { LocalRuntime } from "@/runtime/LocalRuntime" ;
1414import { access } from "fs/promises" ;
1515import { constants } from "fs" ;
16+ import { getControlPath } from "@/runtime/sshConnectionPool" ;
17+ import { expandTildeForSSH } from "@/runtime/tildeExpansion" ;
1618
1719interface SessionData {
18- pty ?: IPty ; // For local sessions
19- stream ?: ExecStream ; // For SSH sessions
20- stdinWriter ?: WritableStreamDefaultWriter < Uint8Array > ; // Persistent writer for SSH stdin
20+ pty : IPty ; // Used for both local and SSH sessions
2121 workspaceId : string ;
2222 workspacePath : string ;
2323 runtime : Runtime ;
2424 onData : ( data : string ) => void ;
2525 onExit : ( exitCode : number ) => void ;
2626}
2727
28+ /**
29+ * Build SSH command arguments from config
30+ * Preserves ControlMaster connection pooling and respects ~/.ssh/config
31+ */
32+ function buildSSHArgs ( config : SSHRuntimeConfig , remotePath : string ) : string [ ] {
33+ const args : string [ ] = [ ] ;
34+
35+ // Add port if specified (overrides ~/.ssh/config)
36+ if ( config . port ) {
37+ args . push ( "-p" , String ( config . port ) ) ;
38+ }
39+
40+ // Add identity file if specified (overrides ~/.ssh/config)
41+ if ( config . identityFile ) {
42+ args . push ( "-i" , config . identityFile ) ;
43+ args . push ( "-o" , "StrictHostKeyChecking=no" ) ;
44+ args . push ( "-o" , "UserKnownHostsFile=/dev/null" ) ;
45+ args . push ( "-o" , "LogLevel=ERROR" ) ;
46+ }
47+
48+ // Add connection multiplexing (reuse SSHRuntime's controlPath logic)
49+ const controlPath = getControlPath ( config ) ;
50+ args . push ( "-o" , "ControlMaster=auto" ) ;
51+ args . push ( "-o" , `ControlPath=${ controlPath } ` ) ;
52+ args . push ( "-o" , "ControlPersist=60" ) ;
53+
54+ // Add connection timeout
55+ args . push ( "-o" , "ConnectTimeout=15" ) ;
56+ args . push ( "-o" , "ServerAliveInterval=5" ) ;
57+ args . push ( "-o" , "ServerAliveCountMax=2" ) ;
58+
59+ // Force PTY allocation
60+ args . push ( "-t" ) ;
61+
62+ // Host (can be alias from ~/.ssh/config)
63+ args . push ( config . host ) ;
64+
65+ // Remote command: cd to workspace and start shell
66+ // expandTildeForSSH already handles quoting, so use it directly
67+ const expandedPath = expandTildeForSSH ( remotePath ) ;
68+ args . push ( `cd ${ expandedPath } && exec $SHELL -i` ) ;
69+
70+ return args ;
71+ }
72+
2873/**
2974 * PTYService - Manages terminal PTY sessions for workspaces
3075 *
@@ -152,107 +197,89 @@ export class PTYService {
152197 onExit,
153198 } ) ;
154199 } else if ( runtime instanceof SSHRuntime ) {
155- // SSH: Use runtime.exec with PTY allocation
156- // Use 'script' to force a proper PTY session with the shell
157- // Set LINES and COLUMNS before starting script so the shell knows the terminal size
158- // -q = quiet (no start/done messages)
159- // -c = command to run
160- // /dev/null = don't save output to a file
161- const command = `export LINES=${ params . rows } COLUMNS=${ params . cols } ; script -qfc "$SHELL -i" /dev/null` ;
162-
163- log . info ( `[PTY] SSH command for ${ sessionId } : ${ command } ` ) ;
200+ // SSH: Use node-pty to spawn SSH with local PTY (enables resize support)
201+ const sshConfig = runtime . getConfig ( ) ;
202+ const sshArgs = buildSSHArgs ( sshConfig , workspacePath ) ;
203+
204+ log . info ( `[PTY] SSH terminal for ${ sessionId } : ssh ${ sshArgs . join ( " " ) } ` ) ;
164205 log . info ( `[PTY] SSH terminal size: ${ params . cols } x${ params . rows } ` ) ;
165- log . info ( `[PTY] SSH working directory: ${ workspacePath } ` ) ;
166206
167- let stream : ExecStream ;
207+ // Load node-pty dynamically
208+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
209+ let pty : typeof import ( "node-pty" ) ;
168210 try {
169- log . info ( `[PTY] Calling runtime.exec for ${ sessionId } ...` ) ;
170- // Execute shell with PTY allocation
171- // Use a very long timeout (24 hours) instead of Infinity
172- stream = await runtime . exec ( command , {
173- cwd : workspacePath ,
174- timeout : 86400 , // 24 hours in seconds
211+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
212+ pty = require ( "node-pty" ) ;
213+ } catch ( err ) {
214+ log . error ( "node-pty not available - SSH terminals will not work:" , err ) ;
215+ throw new Error (
216+ "SSH terminals are not available. node-pty failed to load (likely due to Electron ABI version mismatch)."
217+ ) ;
218+ }
219+
220+ let ptyProcess : IPty ;
221+ try {
222+ // Spawn SSH with PTY (same as local terminals)
223+ ptyProcess = pty . spawn ( "ssh" , sshArgs , {
224+ name : "xterm-256color" ,
225+ cols : params . cols ,
226+ rows : params . rows ,
227+ cwd : process . cwd ( ) ,
175228 env : {
229+ ...process . env ,
176230 TERM : "xterm-256color" ,
231+ PATH : process . env . PATH ?? "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" ,
177232 } ,
178- forcePTY : true ,
179233 } ) ;
180- log . info ( `[PTY] runtime.exec returned successfully for ${ sessionId } ` ) ;
181234 } catch ( err ) {
182- log . error ( `[PTY] Failed to create SSH stream for ${ sessionId } :` , err ) ;
183- throw err ;
235+ log . error ( `[PTY] Failed to spawn SSH terminal ${ sessionId } :` , err ) ;
236+ throw new Error (
237+ `Failed to spawn SSH terminal: ${ err instanceof Error ? err . message : String ( err ) } `
238+ ) ;
184239 }
185240
186- log . info (
187- `[PTY] SSH stream created for ${ sessionId } , stdin writable: ${ stream . stdin . locked === false } `
188- ) ;
241+ // Handle data (same as local - buffer incomplete escape sequences)
242+ let buffer = "" ;
243+ ptyProcess . onData ( ( data ) => {
244+ buffer += data ;
245+ let sendUpTo = buffer . length ;
189246
190- // Get a persistent writer for stdin to avoid locking issues
191- const stdinWriter = stream . stdin . getWriter ( ) ;
247+ // Hold back incomplete escape sequences
248+ if ( buffer . endsWith ( "\x1b" ) ) {
249+ sendUpTo = buffer . length - 1 ;
250+ } else if ( buffer . endsWith ( "\x1b[" ) ) {
251+ sendUpTo = buffer . length - 2 ;
252+ } else {
253+ // eslint-disable-next-line no-control-regex, @typescript-eslint/prefer-regexp-exec
254+ const match = buffer . match ( / \x1b \[ [ 0 - 9 ; ] * $ / ) ;
255+ if ( match ) {
256+ sendUpTo = buffer . length - match [ 0 ] . length ;
257+ }
258+ }
259+
260+ if ( sendUpTo > 0 ) {
261+ const toSend = buffer . substring ( 0 , sendUpTo ) ;
262+ onData ( toSend ) ;
263+ buffer = buffer . substring ( sendUpTo ) ;
264+ }
265+ } ) ;
192266
267+ // Handle exit (same as local)
268+ ptyProcess . onExit ( ( { exitCode } ) => {
269+ log . info ( `SSH terminal session ${ sessionId } exited with code ${ exitCode } ` ) ;
270+ this . sessions . delete ( sessionId ) ;
271+ onExit ( exitCode ) ;
272+ } ) ;
273+
274+ // Store PTY (same interface as local)
193275 this . sessions . set ( sessionId , {
194- stream,
195- stdinWriter,
276+ pty : ptyProcess ,
196277 workspaceId : params . workspaceId ,
197278 workspacePath,
198279 runtime,
199280 onData,
200281 onExit,
201282 } ) ;
202-
203- // Pipe stdout via callback
204- const reader = stream . stdout . getReader ( ) ;
205- const decoder = new TextDecoder ( ) ;
206-
207- ( async ( ) => {
208- try {
209- let bytesRead = 0 ;
210- while ( true ) {
211- const { done, value } = await reader . read ( ) ;
212- if ( done ) {
213- log . info ( `[PTY] SSH stdout closed for ${ sessionId } after ${ bytesRead } bytes` ) ;
214- break ;
215- }
216- bytesRead += value . length ;
217- const text = decoder . decode ( value , { stream : true } ) ;
218- onData ( text ) ;
219- }
220- } catch ( err ) {
221- log . error ( `[PTY] Error reading from SSH terminal ${ sessionId } :` , err ) ;
222- }
223- } ) ( ) ;
224-
225- // Pipe stderr to terminal AND logs (zsh sends prompt to stderr)
226- const stderrReader = stream . stderr . getReader ( ) ;
227- ( async ( ) => {
228- try {
229- while ( true ) {
230- const { done, value } = await stderrReader . read ( ) ;
231- if ( done ) break ;
232- const text = decoder . decode ( value , { stream : true } ) ;
233- // Send stderr to terminal (shells often write prompts to stderr)
234- onData ( text ) ;
235- }
236- } catch ( err ) {
237- log . error ( `[PTY] Error reading stderr for ${ sessionId } :` , err ) ;
238- }
239- } ) ( ) ;
240-
241- // Handle exit
242- stream . exitCode
243- . then ( ( exitCode : number ) => {
244- log . info ( `[PTY] SSH terminal session ${ sessionId } exited with code ${ exitCode } ` ) ;
245- log . info (
246- `[PTY] Session was alive for ${ ( ( Date . now ( ) - parseInt ( sessionId . split ( "-" ) [ 1 ] ) ) / 1000 ) . toFixed ( 1 ) } s`
247- ) ;
248- this . sessions . delete ( sessionId ) ;
249- onExit ( exitCode ) ;
250- } )
251- . catch ( ( err : unknown ) => {
252- log . error ( `[PTY] SSH terminal session ${ sessionId } error:` , err ) ;
253- this . sessions . delete ( sessionId ) ;
254- onExit ( 1 ) ;
255- } ) ;
256283 } else {
257284 throw new Error ( `Unsupported runtime type: ${ runtime . constructor . name } ` ) ;
258285 }
@@ -268,53 +295,38 @@ export class PTYService {
268295 /**
269296 * Send input to a terminal session
270297 */
271- async sendInput ( sessionId : string , data : string ) : Promise < void > {
298+ sendInput ( sessionId : string , data : string ) : void {
272299 const session = this . sessions . get ( sessionId ) ;
273- if ( ! session ) {
274- throw new Error ( `Terminal session ${ sessionId } not found` ) ;
300+ if ( ! session ?. pty ) {
301+ log . info ( `Cannot send input to session ${ sessionId } : not found or no PTY` ) ;
302+ return ;
275303 }
276304
277- if ( session . pty ) {
278- // Local: Write to PTY
279- session . pty . write ( data ) ;
280- } else if ( session . stdinWriter ) {
281- // SSH: Write to stdin using persistent writer
282- try {
283- await session . stdinWriter . write ( new TextEncoder ( ) . encode ( data ) ) ;
284- } catch ( err ) {
285- log . error ( `[PTY] Error writing to ${ sessionId } :` , err ) ;
286- throw err ;
287- }
288- }
305+ // Works for both local and SSH now
306+ session . pty . write ( data ) ;
289307 }
290308
291309 /**
292310 * Resize a terminal session
293311 */
294312 resize ( params : TerminalResizeParams ) : void {
295313 const session = this . sessions . get ( params . sessionId ) ;
296- if ( ! session ) {
297- log . info ( `Cannot resize terminal session ${ params . sessionId } : not found` ) ;
314+ if ( ! session ?. pty ) {
315+ log . info ( `Cannot resize terminal session ${ params . sessionId } : not found or no PTY ` ) ;
298316 return ;
299317 }
300318
301- if ( session . pty ) {
302- // Local: Resize PTY
303- session . pty . resize ( params . cols , params . rows ) ;
304- log . debug ( `Resized local terminal ${ params . sessionId } to ${ params . cols } x${ params . rows } ` ) ;
305- } else {
306- // SSH: Dynamic resize not supported for SSH sessions
307- // The terminal size is set at session creation time via LINES/COLUMNS env vars
308- log . debug (
309- `SSH terminal ${ params . sessionId } resize requested to ${ params . cols } x${ params . rows } (not supported)`
310- ) ;
311- }
319+ // Now works for both local AND SSH! 🎉
320+ session . pty . resize ( params . cols , params . rows ) ;
321+ log . debug (
322+ `Resized terminal ${ params . sessionId } (${ session . runtime instanceof SSHRuntime ? "SSH" : "local" } ) to ${ params . cols } x${ params . rows } `
323+ ) ;
312324 }
313325
314326 /**
315327 * Close a terminal session
316328 */
317- async closeSession ( sessionId : string ) : Promise < void > {
329+ closeSession ( sessionId : string ) : void {
318330 const session = this . sessions . get ( sessionId ) ;
319331 if ( ! session ) {
320332 log . info ( `Cannot close terminal session ${ sessionId } : not found` ) ;
@@ -324,15 +336,8 @@ export class PTYService {
324336 log . info ( `Closing terminal session ${ sessionId } ` ) ;
325337
326338 if ( session . pty ) {
327- // Local: Kill PTY process
339+ // Works for both local and SSH
328340 session . pty . kill ( ) ;
329- } else if ( session . stdinWriter ) {
330- // SSH: Close stdin writer to signal EOF
331- try {
332- await session . stdinWriter . close ( ) ;
333- } catch ( err ) {
334- log . error ( `Error closing SSH terminal ${ sessionId } :` , err ) ;
335- }
336341 }
337342
338343 this . sessions . delete ( sessionId ) ;
@@ -341,14 +346,14 @@ export class PTYService {
341346 /**
342347 * Close all terminal sessions for a workspace
343348 */
344- async closeWorkspaceSessions ( workspaceId : string ) : Promise < void > {
349+ closeWorkspaceSessions ( workspaceId : string ) : void {
345350 const sessionIds = Array . from ( this . sessions . entries ( ) )
346351 . filter ( ( [ , session ] ) => session . workspaceId === workspaceId )
347352 . map ( ( [ id ] ) => id ) ;
348353
349354 log . info ( `Closing ${ sessionIds . length } terminal session(s) for workspace ${ workspaceId } ` ) ;
350355
351- await Promise . all ( sessionIds . map ( ( id ) => this . closeSession ( id ) ) ) ;
356+ sessionIds . forEach ( ( id ) => this . closeSession ( id ) ) ;
352357 }
353358
354359 /**
0 commit comments