@@ -27,6 +27,10 @@ interface SessionData {
2727 runtime : Runtime ;
2828 onData : ( data : string ) => void ;
2929 onExit : ( exitCode : number ) => void ;
30+ /** Buffer for output received before client subscribes */
31+ outputBuffer : string [ ] ;
32+ /** Whether the client has subscribed to receive output */
33+ clientSubscribed : boolean ;
3034}
3135
3236/**
@@ -161,37 +165,55 @@ export class PTYService {
161165 ) ;
162166 }
163167
168+ // Create session data with buffer for pre-subscription output
169+ const sessionData : SessionData = {
170+ pty : ptyProcess ,
171+ workspaceId : params . workspaceId ,
172+ workspacePath,
173+ runtime,
174+ onData,
175+ onExit,
176+ outputBuffer : [ ] ,
177+ clientSubscribed : false ,
178+ } ;
179+ this . sessions . set ( sessionId , sessionData ) ;
180+
164181 // Forward PTY data via callback
165182 // Buffer to handle escape sequences split across chunks
166- let buffer = "" ;
183+ let escapeBuffer = "" ;
167184
168185 ptyProcess . onData ( ( data ) => {
169- // Append new data to buffer
170- buffer += data ;
186+ // Append new data to escape sequence buffer
187+ escapeBuffer += data ;
171188
172189 // Check if buffer ends with an incomplete escape sequence
173190 // Look for ESC at the end without its complete sequence
174- let sendUpTo = buffer . length ;
191+ let sendUpTo = escapeBuffer . length ;
175192
176193 // If buffer ends with ESC or ESC[, hold it back for next chunk
177- if ( buffer . endsWith ( "\x1b" ) ) {
178- sendUpTo = buffer . length - 1 ;
179- } else if ( buffer . endsWith ( "\x1b[" ) ) {
180- sendUpTo = buffer . length - 2 ;
194+ if ( escapeBuffer . endsWith ( "\x1b" ) ) {
195+ sendUpTo = escapeBuffer . length - 1 ;
196+ } else if ( escapeBuffer . endsWith ( "\x1b[" ) ) {
197+ sendUpTo = escapeBuffer . length - 2 ;
181198 } else {
182199 // Check if it ends with ESC[ followed by incomplete CSI sequence
183200 // eslint-disable-next-line no-control-regex, @typescript-eslint/prefer-regexp-exec
184- const match = buffer . match ( / \x1b \[ [ 0 - 9 ; ] * $ / ) ;
201+ const match = escapeBuffer . match ( / \x1b \[ [ 0 - 9 ; ] * $ / ) ;
185202 if ( match ) {
186- sendUpTo = buffer . length - match [ 0 ] . length ;
203+ sendUpTo = escapeBuffer . length - match [ 0 ] . length ;
187204 }
188205 }
189206
190207 // Send complete data
191208 if ( sendUpTo > 0 ) {
192- const toSend = buffer . substring ( 0 , sendUpTo ) ;
193- onData ( toSend ) ;
194- buffer = buffer . substring ( sendUpTo ) ;
209+ const toSend = escapeBuffer . substring ( 0 , sendUpTo ) ;
210+ // Buffer output until client subscribes (fixes race in browser mode)
211+ if ( sessionData . clientSubscribed ) {
212+ onData ( toSend ) ;
213+ } else {
214+ sessionData . outputBuffer . push ( toSend ) ;
215+ }
216+ escapeBuffer = escapeBuffer . substring ( sendUpTo ) ;
195217 }
196218 } ) ;
197219
@@ -201,15 +223,6 @@ export class PTYService {
201223 this . sessions . delete ( sessionId ) ;
202224 onExit ( exitCode ) ;
203225 } ) ;
204-
205- this . sessions . set ( sessionId , {
206- pty : ptyProcess ,
207- workspaceId : params . workspaceId ,
208- workspacePath,
209- runtime,
210- onData,
211- onExit,
212- } ) ;
213226 } else if ( runtime instanceof SSHRuntime ) {
214227 // SSH: Use node-pty to spawn SSH with local PTY (enables resize support)
215228 const sshConfig = runtime . getConfig ( ) ;
@@ -263,29 +276,47 @@ export class PTYService {
263276 ) ;
264277 }
265278
279+ // Create session data with buffer for pre-subscription output
280+ const sessionData : SessionData = {
281+ pty : ptyProcess ,
282+ workspaceId : params . workspaceId ,
283+ workspacePath,
284+ runtime,
285+ onData,
286+ onExit,
287+ outputBuffer : [ ] ,
288+ clientSubscribed : false ,
289+ } ;
290+ this . sessions . set ( sessionId , sessionData ) ;
291+
266292 // Handle data (same as local - buffer incomplete escape sequences)
267- let buffer = "" ;
293+ let escapeBuffer = "" ;
268294 ptyProcess . onData ( ( data ) => {
269- buffer += data ;
270- let sendUpTo = buffer . length ;
295+ escapeBuffer += data ;
296+ let sendUpTo = escapeBuffer . length ;
271297
272298 // Hold back incomplete escape sequences
273- if ( buffer . endsWith ( "\x1b" ) ) {
274- sendUpTo = buffer . length - 1 ;
275- } else if ( buffer . endsWith ( "\x1b[" ) ) {
276- sendUpTo = buffer . length - 2 ;
299+ if ( escapeBuffer . endsWith ( "\x1b" ) ) {
300+ sendUpTo = escapeBuffer . length - 1 ;
301+ } else if ( escapeBuffer . endsWith ( "\x1b[" ) ) {
302+ sendUpTo = escapeBuffer . length - 2 ;
277303 } else {
278304 // eslint-disable-next-line no-control-regex, @typescript-eslint/prefer-regexp-exec
279- const match = buffer . match ( / \x1b \[ [ 0 - 9 ; ] * $ / ) ;
305+ const match = escapeBuffer . match ( / \x1b \[ [ 0 - 9 ; ] * $ / ) ;
280306 if ( match ) {
281- sendUpTo = buffer . length - match [ 0 ] . length ;
307+ sendUpTo = escapeBuffer . length - match [ 0 ] . length ;
282308 }
283309 }
284310
285311 if ( sendUpTo > 0 ) {
286- const toSend = buffer . substring ( 0 , sendUpTo ) ;
287- onData ( toSend ) ;
288- buffer = buffer . substring ( sendUpTo ) ;
312+ const toSend = escapeBuffer . substring ( 0 , sendUpTo ) ;
313+ // Buffer output until client subscribes (fixes race in browser mode)
314+ if ( sessionData . clientSubscribed ) {
315+ onData ( toSend ) ;
316+ } else {
317+ sessionData . outputBuffer . push ( toSend ) ;
318+ }
319+ escapeBuffer = escapeBuffer . substring ( sendUpTo ) ;
289320 }
290321 } ) ;
291322
@@ -295,16 +326,6 @@ export class PTYService {
295326 this . sessions . delete ( sessionId ) ;
296327 onExit ( exitCode ) ;
297328 } ) ;
298-
299- // Store PTY (same interface as local)
300- this . sessions . set ( sessionId , {
301- pty : ptyProcess ,
302- workspaceId : params . workspaceId ,
303- workspacePath,
304- runtime,
305- onData,
306- onExit,
307- } ) ;
308329 } else {
309330 throw new Error ( `Unsupported runtime type: ${ runtime . constructor . name } ` ) ;
310331 }
@@ -317,6 +338,34 @@ export class PTYService {
317338 } ;
318339 }
319340
341+ /**
342+ * Subscribe to terminal output (flushes buffered output and starts streaming).
343+ * In browser mode, output is buffered until the client subscribes to avoid
344+ * losing initial output (like the shell prompt) during the HTTP round-trip.
345+ */
346+ subscribeOutput ( sessionId : string ) : void {
347+ const session = this . sessions . get ( sessionId ) ;
348+ if ( ! session ) {
349+ log . info ( `Cannot subscribe to session ${ sessionId } : not found` ) ;
350+ return ;
351+ }
352+
353+ if ( session . clientSubscribed ) {
354+ log . debug ( `Session ${ sessionId } already subscribed` ) ;
355+ return ;
356+ }
357+
358+ // Mark as subscribed and flush buffered output
359+ session . clientSubscribed = true ;
360+ if ( session . outputBuffer . length > 0 ) {
361+ log . debug ( `Flushing ${ session . outputBuffer . length } buffered chunks for ${ sessionId } ` ) ;
362+ for ( const chunk of session . outputBuffer ) {
363+ session . onData ( chunk ) ;
364+ }
365+ session . outputBuffer = [ ] ;
366+ }
367+ }
368+
320369 /**
321370 * Send input to a terminal session
322371 */
0 commit comments