11"""Terminal endpoint — WebSocket PTY for real terminal sessions."""
22
33import os
4- import pty
5- import select
6- import signal
7- import struct
8- import fcntl
9- import termios
10- import subprocess
4+ import sys
115import json
126import threading
137import time
2216bp = Blueprint ("terminal" , __name__ )
2317sock = Sock ()
2418
19+ _WINDOWS = sys .platform == "win32"
20+
21+ if _WINDOWS :
22+ from winpty import PtyProcess
23+ else :
24+ import pty
25+ import select
26+ import signal
27+ import struct
28+ import fcntl
29+ import termios
30+
2531# Store active sessions: {session_id: {pid, fd}}
2632sessions = {}
2733session_counter = 0
@@ -94,7 +100,7 @@ def _parse_session_file(filepath, max_lines=20):
94100
95101
96102def _spawn_pty (cmd , cwd ):
97- """Spawn a process in a PTY and return (pid, fd)."""
103+ """Spawn a process in a PTY and return (pid, fd). Unix only. """
98104 env = os .environ .copy ()
99105 env ["TERM" ] = "xterm-256color"
100106 env ["COLUMNS" ] = "120"
@@ -109,22 +115,34 @@ def _spawn_pty(cmd, cwd):
109115
110116
111117def _set_winsize (fd , rows , cols ):
112- """Set terminal window size."""
118+ """Set terminal window size. Unix only. """
113119 winsize = struct .pack ("HHHH" , rows , cols , 0 , 0 )
114120 fcntl .ioctl (fd , termios .TIOCSWINSZ , winsize )
115121
116122
123+ def _spawn_conpty (cmd , cwd ):
124+ """Spawn a process in a Windows ConPTY and return a PtyProcess."""
125+ env = os .environ .copy ()
126+ env ["TERM" ] = "xterm-256color"
127+ return PtyProcess .spawn (cmd , cwd = str (cwd ), env = env , dimensions = (40 , 120 ))
128+
129+
117130@bp .route ("/api/terminal/sessions" )
118131@login_required
119132def list_sessions ():
120133 """List active terminal sessions."""
121134 result = []
122135 for sid , info in sessions .items ():
123- try :
124- os .kill (info ["pid" ], 0 ) # Check if alive
125- result .append ({"id" : sid , "cmd" : info ["cmd" ], "alive" : True })
126- except OSError :
127- result .append ({"id" : sid , "cmd" : info ["cmd" ], "alive" : False })
136+ if _WINDOWS :
137+ proc = info .get ("process" )
138+ alive = proc .isalive () if proc else False
139+ else :
140+ try :
141+ os .kill (info ["pid" ], 0 )
142+ alive = True
143+ except OSError :
144+ alive = False
145+ result .append ({"id" : sid , "cmd" : info ["cmd" ], "alive" : alive })
128146 return {"sessions" : result }
129147
130148
@@ -184,15 +202,19 @@ def create_session():
184202 return {"error" : "Session not found" }, 404
185203 cmd .extend (["--resume" , resume_id ])
186204 elif cmd_type == "shell" :
187- cmd = [os .environ .get ("SHELL" , "/bin/zsh" )]
205+ cmd = ["cmd.exe" ] if _WINDOWS else [ os .environ .get ("SHELL" , "/bin/zsh" )]
188206 else :
189207 cmd = ["claude" , "--dangerously-skip-permissions" ]
190208
191209 try :
192- pid , fd = _spawn_pty (cmd , WORKSPACE )
193210 session_counter += 1
194211 sid = f"term-{ session_counter } "
195- sessions [sid ] = {"pid" : pid , "fd" : fd , "cmd" : " " .join (cmd )}
212+ if _WINDOWS :
213+ proc = _spawn_conpty (cmd , WORKSPACE )
214+ sessions [sid ] = {"process" : proc , "cmd" : " " .join (cmd )}
215+ else :
216+ pid , fd = _spawn_pty (cmd , WORKSPACE )
217+ sessions [sid ] = {"pid" : pid , "fd" : fd , "cmd" : " " .join (cmd )}
196218 return {"id" : sid , "cmd" : " " .join (cmd )}
197219 except Exception as e :
198220 return {"error" : str (e )}, 500
@@ -206,8 +228,11 @@ def kill_session(session_id):
206228 if not info :
207229 return {"error" : "Session not found" }, 404
208230 try :
209- os .kill (info ["pid" ], signal .SIGTERM )
210- os .close (info ["fd" ])
231+ if _WINDOWS :
232+ info ["process" ].terminate (force = True )
233+ else :
234+ os .kill (info ["pid" ], signal .SIGTERM )
235+ os .close (info ["fd" ])
211236 except OSError :
212237 pass
213238 del sessions [session_id ]
@@ -231,73 +256,127 @@ def terminal_ws(ws, session_id):
231256 ws .send (json .dumps ({"error" : "Session not found" }))
232257 return
233258
234- fd = info ["fd" ]
235-
236- # Set non-blocking
237- import fcntl
238- flags = fcntl .fcntl (fd , fcntl .F_GETFL )
239- fcntl .fcntl (fd , fcntl .F_SETFL , flags | os .O_NONBLOCK )
259+ if _WINDOWS :
260+ proc = info ["process" ]
240261
241- def read_output ():
242- """Read from PTY and send to WebSocket."""
243- while True :
244- try :
245- r , _ , _ = select . select ([ fd ], [], [], 0.1 )
246- if r :
247- data = os .read (fd , 4096 )
262+ def read_output ():
263+ """Read from ConPTY and send to WebSocket."""
264+ while True :
265+ try :
266+ if not proc . isalive ():
267+ break
268+ data = proc .read (4096 )
248269 if data :
249- ws .send (data .decode ("utf-8" , errors = "replace" ))
270+ ws .send (data if isinstance ( data , str ) else data .decode ("utf-8" , errors = "replace" ))
250271 else :
251- break
252- except (OSError , EOFError ):
253- break
272+ time .sleep (0.01 )
273+ except Exception :
274+ break
275+ try :
276+ ws .send ("\r \n [Process exited]\r \n " )
254277 except Exception :
255- break
278+ pass
279+
280+ reader = threading .Thread (target = read_output , daemon = True )
281+ reader .start ()
282+
256283 try :
257- ws .send ("\r \n [Process exited]\r \n " )
284+ while True :
285+ try :
286+ msg = ws .receive (timeout = 300 )
287+ except Exception :
288+ break
289+
290+ if msg is None :
291+ break
292+
293+ if isinstance (msg , str ) and msg .startswith ('{"resize":' ):
294+ try :
295+ data = json .loads (msg )
296+ proc .setwinsize (data ["resize" ]["rows" ], data ["resize" ]["cols" ])
297+ except Exception :
298+ pass
299+ continue
300+
301+ if isinstance (msg , str ) and msg == '{"ping":true}' :
302+ try :
303+ ws .send ('{"pong":true}' )
304+ except Exception :
305+ pass
306+ continue
307+
308+ try :
309+ proc .write (msg if isinstance (msg , str ) else msg .decode ("utf-8" ))
310+ except Exception :
311+ break
258312 except Exception :
259313 pass
314+ finally :
315+ reader .join (timeout = 1 )
260316
261- # Start reader thread
262- reader = threading .Thread (target = read_output , daemon = True )
263- reader .start ()
317+ else :
318+ fd = info ["fd" ]
319+
320+ # Set non-blocking
321+ import fcntl
322+ flags = fcntl .fcntl (fd , fcntl .F_GETFL )
323+ fcntl .fcntl (fd , fcntl .F_SETFL , flags | os .O_NONBLOCK )
264324
265- # Main loop: read from WebSocket, write to PTY
266- try :
267- while True :
325+ def read_output ():
326+ """Read from PTY and send to WebSocket."""
327+ while True :
328+ try :
329+ r , _ , _ = select .select ([fd ], [], [], 0.1 )
330+ if r :
331+ data = os .read (fd , 4096 )
332+ if data :
333+ ws .send (data .decode ("utf-8" , errors = "replace" ))
334+ else :
335+ break
336+ except (OSError , EOFError ):
337+ break
338+ except Exception :
339+ break
268340 try :
269- msg = ws .receive ( timeout = 300 ) # 5 min timeout
341+ ws .send ( " \r \n [Process exited] \r \n " )
270342 except Exception :
271- break
343+ pass
272344
273- if msg is None :
274- break
345+ reader = threading . Thread ( target = read_output , daemon = True )
346+ reader . start ()
275347
276- # Handle resize messages
277- if isinstance ( msg , str ) and msg . startswith ( '{"resize":' ) :
348+ try :
349+ while True :
278350 try :
279- data = json .loads (msg )
280- rows = data ["resize" ]["rows" ]
281- cols = data ["resize" ]["cols" ]
282- _set_winsize (fd , rows , cols )
351+ msg = ws .receive (timeout = 300 )
283352 except Exception :
284- pass
285- continue
353+ break
354+
355+ if msg is None :
356+ break
357+
358+ if isinstance (msg , str ) and msg .startswith ('{"resize":' ):
359+ try :
360+ data = json .loads (msg )
361+ rows = data ["resize" ]["rows" ]
362+ cols = data ["resize" ]["cols" ]
363+ _set_winsize (fd , rows , cols )
364+ except Exception :
365+ pass
366+ continue
367+
368+ if isinstance (msg , str ) and msg == '{"ping":true}' :
369+ try :
370+ ws .send ('{"pong":true}' )
371+ except Exception :
372+ pass
373+ continue
286374
287- # Handle ping/keepalive
288- if isinstance (msg , str ) and msg == '{"ping":true}' :
289375 try :
290- ws .send ('{"pong":true}' )
291- except Exception :
292- pass
293- continue
294-
295- # Write input to PTY
296- try :
297- os .write (fd , msg .encode ("utf-8" ) if isinstance (msg , str ) else msg )
298- except OSError :
299- break
300- except Exception :
301- pass
302- finally :
303- reader .join (timeout = 1 )
376+ os .write (fd , msg .encode ("utf-8" ) if isinstance (msg , str ) else msg )
377+ except OSError :
378+ break
379+ except Exception :
380+ pass
381+ finally :
382+ reader .join (timeout = 1 )
0 commit comments