indent 0.1.20__py3-none-any.whl → 0.1.28__py3-none-any.whl
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.
- exponent/__init__.py +2 -2
- exponent/commands/cloud_commands.py +2 -87
- exponent/commands/common.py +3 -0
- exponent/core/config.py +1 -1
- exponent/core/container_build/__init__.py +0 -0
- exponent/core/container_build/types.py +25 -0
- exponent/core/graphql/mutations.py +2 -31
- exponent/core/graphql/queries.py +0 -3
- exponent/core/remote_execution/cli_rpc_types.py +183 -13
- exponent/core/remote_execution/client.py +265 -64
- exponent/core/remote_execution/code_execution.py +26 -7
- exponent/core/remote_execution/languages/shell_streaming.py +3 -5
- exponent/core/remote_execution/port_utils.py +73 -0
- exponent/core/remote_execution/system_context.py +2 -0
- exponent/core/remote_execution/terminal_session.py +517 -0
- exponent/core/remote_execution/terminal_types.py +29 -0
- exponent/core/remote_execution/tool_execution.py +151 -11
- exponent/core/remote_execution/truncation.py +1 -3
- exponent/core/remote_execution/types.py +32 -1
- exponent/utils/version.py +1 -0
- {indent-0.1.20.dist-info → indent-0.1.28.dist-info}/METADATA +4 -2
- {indent-0.1.20.dist-info → indent-0.1.28.dist-info}/RECORD +24 -19
- {indent-0.1.20.dist-info → indent-0.1.28.dist-info}/WHEEL +0 -0
- {indent-0.1.20.dist-info → indent-0.1.28.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import codecs
|
|
3
|
+
import fcntl
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import struct
|
|
8
|
+
import sys
|
|
9
|
+
import termios
|
|
10
|
+
import time
|
|
11
|
+
import traceback
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
|
|
14
|
+
from exponent.core.remote_execution.terminal_types import (
|
|
15
|
+
TerminalMessage,
|
|
16
|
+
TerminalOutput,
|
|
17
|
+
TerminalResetSessions,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TerminalSession:
|
|
24
|
+
"""
|
|
25
|
+
Manages a PTY session for terminal emulation.
|
|
26
|
+
Runs on the CLI machine and streams output back to server.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
session_id: str,
|
|
32
|
+
output_callback: Callable[[str], None],
|
|
33
|
+
cols: int = 80,
|
|
34
|
+
rows: int = 24,
|
|
35
|
+
):
|
|
36
|
+
self.session_id = session_id
|
|
37
|
+
self.output_callback = output_callback # Called with terminal output
|
|
38
|
+
self.cols = cols
|
|
39
|
+
self.rows = rows
|
|
40
|
+
self.master_fd: int | None = None
|
|
41
|
+
self.pid: int | None = None
|
|
42
|
+
self._running = False
|
|
43
|
+
self._read_task: asyncio.Task[None] | None = None
|
|
44
|
+
|
|
45
|
+
def _cleanup_fds(self, master_fd: int | None, slave_fd: int | None) -> None:
|
|
46
|
+
"""Clean up file descriptors on error."""
|
|
47
|
+
if master_fd is not None:
|
|
48
|
+
try:
|
|
49
|
+
os.close(master_fd)
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
|
52
|
+
if slave_fd is not None:
|
|
53
|
+
try:
|
|
54
|
+
os.close(slave_fd)
|
|
55
|
+
except OSError:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
def _setup_child_process(
|
|
59
|
+
self, slave_fd: int, command: list[str], env: dict[str, str] | None
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Set up child process with PTY slave as controlling terminal."""
|
|
62
|
+
# Create new session and set controlling terminal
|
|
63
|
+
os.setsid()
|
|
64
|
+
|
|
65
|
+
# Redirect stdin/stdout/stderr to slave PTY
|
|
66
|
+
os.dup2(slave_fd, 0)
|
|
67
|
+
os.dup2(slave_fd, 1)
|
|
68
|
+
os.dup2(slave_fd, 2)
|
|
69
|
+
|
|
70
|
+
# Close the slave fd after duplication
|
|
71
|
+
if slave_fd > 2:
|
|
72
|
+
os.close(slave_fd)
|
|
73
|
+
|
|
74
|
+
# Close master fd in child
|
|
75
|
+
if self.master_fd is not None:
|
|
76
|
+
os.close(self.master_fd)
|
|
77
|
+
|
|
78
|
+
# Set up environment
|
|
79
|
+
if env:
|
|
80
|
+
for key, value in env.items():
|
|
81
|
+
os.environ[key] = value
|
|
82
|
+
|
|
83
|
+
# Set terminal environment
|
|
84
|
+
os.environ["TERM"] = "xterm-256color"
|
|
85
|
+
os.environ["COLORTERM"] = "truecolor"
|
|
86
|
+
os.environ["TERM_PROGRAM"] = "indent"
|
|
87
|
+
# Remove TERM_PROGRAM_VERSION if it exists
|
|
88
|
+
os.environ.pop("TERM_PROGRAM_VERSION", None)
|
|
89
|
+
|
|
90
|
+
# Execute command
|
|
91
|
+
os.execvp(command[0], command)
|
|
92
|
+
|
|
93
|
+
async def start( # noqa: PLR0915
|
|
94
|
+
self,
|
|
95
|
+
command: list[str] | None = None,
|
|
96
|
+
env: dict[str, str] | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Start the terminal session with PTY"""
|
|
99
|
+
if self._running:
|
|
100
|
+
raise RuntimeError(f"Terminal session {self.session_id} already running")
|
|
101
|
+
|
|
102
|
+
# Default to user's shell if no command specified
|
|
103
|
+
if command is None:
|
|
104
|
+
# Get user's default shell from environment, fallback to /bin/bash
|
|
105
|
+
default_shell = os.environ.get("SHELL", "/bin/bash")
|
|
106
|
+
command = [default_shell]
|
|
107
|
+
|
|
108
|
+
# Create PTY with window size set BEFORE fork to eliminate race condition
|
|
109
|
+
master_fd = None
|
|
110
|
+
slave_fd = None
|
|
111
|
+
try:
|
|
112
|
+
# Open PTY pair
|
|
113
|
+
master_fd, slave_fd = os.openpty()
|
|
114
|
+
|
|
115
|
+
# Set window size on PTY before forking
|
|
116
|
+
# This ensures the child process sees correct dimensions from the start
|
|
117
|
+
size = struct.pack("HHHH", self.rows, self.cols, 0, 0)
|
|
118
|
+
fcntl.ioctl(master_fd, termios.TIOCSWINSZ, size)
|
|
119
|
+
|
|
120
|
+
# Fork process
|
|
121
|
+
self.pid = os.fork()
|
|
122
|
+
self.master_fd = master_fd
|
|
123
|
+
except OSError as e:
|
|
124
|
+
# Clean up FDs if anything failed
|
|
125
|
+
self._cleanup_fds(master_fd, slave_fd)
|
|
126
|
+
logger.error(
|
|
127
|
+
"Failed to create PTY or fork",
|
|
128
|
+
)
|
|
129
|
+
raise RuntimeError(f"Failed to create PTY or fork: {e}") from e
|
|
130
|
+
|
|
131
|
+
if self.pid == 0:
|
|
132
|
+
# Child process - set up PTY slave as controlling terminal
|
|
133
|
+
try:
|
|
134
|
+
self._setup_child_process(slave_fd, command, env)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
# If exec fails, log and exit child process
|
|
137
|
+
traceback.print_exc()
|
|
138
|
+
sys.stderr.write(f"Failed to execute command {command}: {e}\n")
|
|
139
|
+
sys.stderr.flush()
|
|
140
|
+
os._exit(1)
|
|
141
|
+
else:
|
|
142
|
+
# Parent process - close slave fd (only child needs it)
|
|
143
|
+
os.close(slave_fd)
|
|
144
|
+
|
|
145
|
+
# Set up non-blocking I/O on master
|
|
146
|
+
flags = fcntl.fcntl(self.master_fd, fcntl.F_GETFL)
|
|
147
|
+
fcntl.fcntl(self.master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
148
|
+
|
|
149
|
+
# Configure terminal attributes
|
|
150
|
+
try:
|
|
151
|
+
attrs = termios.tcgetattr(self.master_fd)
|
|
152
|
+
|
|
153
|
+
# Local flags (lflags)
|
|
154
|
+
# Enable: icanon isig iexten echo echoe echoke echoctl pendin
|
|
155
|
+
attrs[3] |= (
|
|
156
|
+
termios.ICANON
|
|
157
|
+
| termios.ISIG
|
|
158
|
+
| termios.IEXTEN
|
|
159
|
+
| termios.ECHO
|
|
160
|
+
| termios.ECHOE
|
|
161
|
+
| termios.ECHOKE
|
|
162
|
+
| termios.ECHOCTL
|
|
163
|
+
)
|
|
164
|
+
# Disable: echok echonl echoprt noflsh tostop
|
|
165
|
+
attrs[3] &= ~(
|
|
166
|
+
termios.ECHOK
|
|
167
|
+
| termios.ECHONL
|
|
168
|
+
| termios.ECHOPRT
|
|
169
|
+
| termios.NOFLSH
|
|
170
|
+
| termios.TOSTOP
|
|
171
|
+
)
|
|
172
|
+
if hasattr(termios, "PENDIN"):
|
|
173
|
+
attrs[3] |= termios.PENDIN
|
|
174
|
+
|
|
175
|
+
# Input flags (iflags)
|
|
176
|
+
# Enable: icrnl ixon ixany imaxbel brkint
|
|
177
|
+
attrs[0] |= (
|
|
178
|
+
termios.ICRNL
|
|
179
|
+
| termios.IXON
|
|
180
|
+
| termios.IXANY
|
|
181
|
+
| termios.IMAXBEL
|
|
182
|
+
| termios.BRKINT
|
|
183
|
+
)
|
|
184
|
+
# Disable: istrip inlcr igncr ixoff ignbrk inpck ignpar parmrk
|
|
185
|
+
attrs[0] &= ~(
|
|
186
|
+
termios.ISTRIP
|
|
187
|
+
| termios.INLCR
|
|
188
|
+
| termios.IGNCR
|
|
189
|
+
| termios.IXOFF
|
|
190
|
+
| termios.IGNBRK
|
|
191
|
+
| termios.INPCK
|
|
192
|
+
| termios.IGNPAR
|
|
193
|
+
| termios.PARMRK
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Output flags (oflags)
|
|
197
|
+
# Enable: opost onlcr
|
|
198
|
+
attrs[1] |= termios.OPOST | termios.ONLCR
|
|
199
|
+
# Disable: onocr onlret
|
|
200
|
+
attrs[1] &= ~(termios.ONOCR | termios.ONLRET)
|
|
201
|
+
|
|
202
|
+
# Control flags (cflags)
|
|
203
|
+
# Enable: cread cs8 hupcl
|
|
204
|
+
attrs[2] |= termios.CREAD | termios.CS8 | termios.HUPCL
|
|
205
|
+
# Disable: parenb parodd clocal cstopb crtscts
|
|
206
|
+
attrs[2] &= ~(
|
|
207
|
+
termios.PARENB | termios.PARODD | termios.CLOCAL | termios.CSTOPB
|
|
208
|
+
)
|
|
209
|
+
if hasattr(termios, "CRTSCTS"):
|
|
210
|
+
attrs[2] &= ~termios.CRTSCTS
|
|
211
|
+
|
|
212
|
+
# Control characters (cchars)
|
|
213
|
+
attrs[6][termios.VEOF] = ord("\x04") # ^D
|
|
214
|
+
attrs[6][termios.VERASE] = 0x7F # ^? (DEL)
|
|
215
|
+
attrs[6][termios.VINTR] = ord("\x03") # ^C
|
|
216
|
+
attrs[6][termios.VKILL] = ord("\x15") # ^U
|
|
217
|
+
attrs[6][termios.VQUIT] = ord("\x1c") # ^\
|
|
218
|
+
attrs[6][termios.VSUSP] = ord("\x1a") # ^Z
|
|
219
|
+
attrs[6][termios.VSTART] = ord("\x11") # ^Q
|
|
220
|
+
attrs[6][termios.VSTOP] = ord("\x13") # ^S
|
|
221
|
+
attrs[6][termios.VMIN] = 1
|
|
222
|
+
attrs[6][termios.VTIME] = 0
|
|
223
|
+
|
|
224
|
+
termios.tcsetattr(self.master_fd, termios.TCSANOW, attrs)
|
|
225
|
+
except Exception as e:
|
|
226
|
+
logger.warning(f"Failed to set terminal attributes: {e}")
|
|
227
|
+
|
|
228
|
+
# Start reading from PTY
|
|
229
|
+
self._running = True
|
|
230
|
+
self._read_task = asyncio.create_task(self._read_from_pty())
|
|
231
|
+
|
|
232
|
+
async def _read_from_pty(self) -> None:
|
|
233
|
+
"""Continuously read from PTY using event loop's add_reader (non-blocking)"""
|
|
234
|
+
if self.master_fd is None:
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
loop = asyncio.get_event_loop()
|
|
238
|
+
read_queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
|
239
|
+
|
|
240
|
+
def read_callback() -> None:
|
|
241
|
+
"""Called by event loop when data is available on the FD"""
|
|
242
|
+
if self.master_fd is None:
|
|
243
|
+
return
|
|
244
|
+
try:
|
|
245
|
+
data = os.read(self.master_fd, 4096)
|
|
246
|
+
if data:
|
|
247
|
+
# Put data in queue to be processed by async task
|
|
248
|
+
read_queue.put_nowait(data)
|
|
249
|
+
else:
|
|
250
|
+
# EOF - PTY closed
|
|
251
|
+
read_queue.put_nowait(None)
|
|
252
|
+
except OSError as e:
|
|
253
|
+
if e.errno == 11: # EAGAIN - shouldn't happen with add_reader
|
|
254
|
+
pass
|
|
255
|
+
else:
|
|
256
|
+
read_queue.put_nowait(None)
|
|
257
|
+
except Exception:
|
|
258
|
+
logger.error(
|
|
259
|
+
"Unexpected error in PTY read callback",
|
|
260
|
+
)
|
|
261
|
+
read_queue.put_nowait(None)
|
|
262
|
+
|
|
263
|
+
# Register the FD with the event loop
|
|
264
|
+
loop.add_reader(self.master_fd, read_callback)
|
|
265
|
+
|
|
266
|
+
# Use incremental decoder to handle partial UTF-8 sequences
|
|
267
|
+
|
|
268
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
while self._running:
|
|
272
|
+
# Wait for data from the queue (non-blocking for event loop)
|
|
273
|
+
data = await read_queue.get()
|
|
274
|
+
|
|
275
|
+
if data is None:
|
|
276
|
+
# EOF or error - flush any remaining bytes in decoder
|
|
277
|
+
final = decoder.decode(b"", final=True)
|
|
278
|
+
if final:
|
|
279
|
+
self.output_callback(final)
|
|
280
|
+
break
|
|
281
|
+
|
|
282
|
+
# Process the data with incremental decoder
|
|
283
|
+
# This handles partial UTF-8 sequences correctly
|
|
284
|
+
decoded = decoder.decode(data, final=False)
|
|
285
|
+
if decoded:
|
|
286
|
+
self.output_callback(decoded)
|
|
287
|
+
finally:
|
|
288
|
+
# Unregister the FD from the event loop
|
|
289
|
+
loop.remove_reader(self.master_fd)
|
|
290
|
+
|
|
291
|
+
async def write_input(self, data: str) -> None:
|
|
292
|
+
"""Write user input to PTY"""
|
|
293
|
+
if not self._running or self.master_fd is None:
|
|
294
|
+
raise RuntimeError(f"Terminal session {self.session_id} not running")
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
os.write(self.master_fd, data.encode("utf-8"))
|
|
298
|
+
except OSError:
|
|
299
|
+
logger.error(
|
|
300
|
+
"Error writing to PTY",
|
|
301
|
+
)
|
|
302
|
+
raise
|
|
303
|
+
|
|
304
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
305
|
+
"""Resize the PTY to match terminal dimensions"""
|
|
306
|
+
if self.master_fd is None:
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
self.cols = cols
|
|
310
|
+
self.rows = rows
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
size = struct.pack("HHHH", rows, cols, 0, 0)
|
|
314
|
+
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, size)
|
|
315
|
+
except Exception:
|
|
316
|
+
logger.error(
|
|
317
|
+
"Error resizing PTY",
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
async def stop(self) -> tuple[bool, int | None]:
|
|
321
|
+
"""
|
|
322
|
+
Stop the terminal session and clean up resources.
|
|
323
|
+
Returns (success, exit_code)
|
|
324
|
+
"""
|
|
325
|
+
if not self._running:
|
|
326
|
+
return True, None
|
|
327
|
+
|
|
328
|
+
self._running = False
|
|
329
|
+
|
|
330
|
+
# Cancel read task
|
|
331
|
+
if self._read_task and not self._read_task.done():
|
|
332
|
+
self._read_task.cancel()
|
|
333
|
+
try:
|
|
334
|
+
await self._read_task
|
|
335
|
+
except asyncio.CancelledError:
|
|
336
|
+
pass
|
|
337
|
+
|
|
338
|
+
exit_code = None
|
|
339
|
+
|
|
340
|
+
# Close file descriptor
|
|
341
|
+
if self.master_fd is not None:
|
|
342
|
+
try:
|
|
343
|
+
os.close(self.master_fd)
|
|
344
|
+
except Exception:
|
|
345
|
+
logger.error(
|
|
346
|
+
"Error closing PTY fd",
|
|
347
|
+
)
|
|
348
|
+
self.master_fd = None
|
|
349
|
+
|
|
350
|
+
# Kill child process
|
|
351
|
+
if self.pid is not None:
|
|
352
|
+
try:
|
|
353
|
+
os.kill(self.pid, signal.SIGTERM)
|
|
354
|
+
# Wait for process to terminate (with timeout)
|
|
355
|
+
for _ in range(10): # Wait up to 1 second
|
|
356
|
+
try:
|
|
357
|
+
pid, status = os.waitpid(self.pid, os.WNOHANG)
|
|
358
|
+
if pid != 0:
|
|
359
|
+
exit_code = os.WEXITSTATUS(status)
|
|
360
|
+
break
|
|
361
|
+
except ChildProcessError:
|
|
362
|
+
break
|
|
363
|
+
await asyncio.sleep(0.1)
|
|
364
|
+
else:
|
|
365
|
+
# Force kill if still running
|
|
366
|
+
try:
|
|
367
|
+
os.kill(self.pid, signal.SIGKILL)
|
|
368
|
+
os.waitpid(self.pid, 0)
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
except Exception:
|
|
372
|
+
logger.error(
|
|
373
|
+
"Error killing PTY process",
|
|
374
|
+
)
|
|
375
|
+
self.pid = None
|
|
376
|
+
|
|
377
|
+
logger.info(
|
|
378
|
+
"Terminal session stopped",
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
return True, exit_code
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def is_running(self) -> bool:
|
|
385
|
+
"""Check if terminal session is running"""
|
|
386
|
+
return self._running and self.master_fd is not None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class TerminalSessionManager:
|
|
390
|
+
"""Manages multiple terminal sessions"""
|
|
391
|
+
|
|
392
|
+
def __init__(self, output_queue: asyncio.Queue[TerminalMessage]) -> None:
|
|
393
|
+
self._sessions: dict[str, TerminalSession] = {}
|
|
394
|
+
self._lock = asyncio.Lock()
|
|
395
|
+
self._websocket: object | None = None
|
|
396
|
+
self._output_queue = output_queue
|
|
397
|
+
|
|
398
|
+
# Send reset message immediately to clear stale sessions
|
|
399
|
+
try:
|
|
400
|
+
reset_message = TerminalResetSessions()
|
|
401
|
+
self._output_queue.put_nowait(reset_message)
|
|
402
|
+
logger.info("Sent TerminalResetSessions message")
|
|
403
|
+
except asyncio.QueueFull:
|
|
404
|
+
logger.error("Failed to queue terminal reset message - queue full")
|
|
405
|
+
|
|
406
|
+
def set_websocket(self, websocket: object) -> None:
|
|
407
|
+
"""Set the websocket for sending output"""
|
|
408
|
+
self._websocket = websocket
|
|
409
|
+
|
|
410
|
+
async def start_session(
|
|
411
|
+
self,
|
|
412
|
+
websocket: object,
|
|
413
|
+
session_id: str,
|
|
414
|
+
command: list[str] | None = None,
|
|
415
|
+
cols: int = 80,
|
|
416
|
+
rows: int = 24,
|
|
417
|
+
env: dict[str, str] | None = None,
|
|
418
|
+
) -> str:
|
|
419
|
+
"""Start a new terminal session"""
|
|
420
|
+
async with self._lock:
|
|
421
|
+
if session_id in self._sessions:
|
|
422
|
+
raise RuntimeError(f"Terminal session {session_id} already exists")
|
|
423
|
+
|
|
424
|
+
# Store websocket reference
|
|
425
|
+
self._websocket = websocket
|
|
426
|
+
|
|
427
|
+
# Create output callback that queues data to be sent
|
|
428
|
+
def output_callback(data: str) -> None:
|
|
429
|
+
# Queue the output to be sent asynchronously
|
|
430
|
+
try:
|
|
431
|
+
terminal_output = TerminalOutput(
|
|
432
|
+
session_id=session_id,
|
|
433
|
+
data=data,
|
|
434
|
+
timestamp=time.time(),
|
|
435
|
+
)
|
|
436
|
+
self._output_queue.put_nowait(terminal_output)
|
|
437
|
+
except asyncio.QueueFull:
|
|
438
|
+
logger.error(
|
|
439
|
+
"Terminal output queue full",
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
session = TerminalSession(
|
|
443
|
+
session_id=session_id,
|
|
444
|
+
output_callback=output_callback,
|
|
445
|
+
cols=cols,
|
|
446
|
+
rows=rows,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
await session.start(command=command, env=env)
|
|
450
|
+
self._sessions[session_id] = session
|
|
451
|
+
|
|
452
|
+
return session_id
|
|
453
|
+
|
|
454
|
+
async def send_input(self, session_id: str, data: str) -> bool:
|
|
455
|
+
"""Send input to a terminal session"""
|
|
456
|
+
async with self._lock:
|
|
457
|
+
session = self._sessions.get(session_id)
|
|
458
|
+
if session is None:
|
|
459
|
+
return False
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
await session.write_input(data)
|
|
463
|
+
return True
|
|
464
|
+
except Exception:
|
|
465
|
+
logger.error(
|
|
466
|
+
"Failed to send input to terminal",
|
|
467
|
+
)
|
|
468
|
+
return False
|
|
469
|
+
|
|
470
|
+
async def resize_terminal(self, session_id: str, rows: int, cols: int) -> bool:
|
|
471
|
+
"""Resize a terminal session"""
|
|
472
|
+
async with self._lock:
|
|
473
|
+
session = self._sessions.get(session_id)
|
|
474
|
+
if session is None:
|
|
475
|
+
return False
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
session.resize(cols, rows)
|
|
479
|
+
return True
|
|
480
|
+
except Exception:
|
|
481
|
+
logger.error(
|
|
482
|
+
"Failed to resize terminal",
|
|
483
|
+
)
|
|
484
|
+
return False
|
|
485
|
+
|
|
486
|
+
async def stop_session(self, session_id: str) -> bool:
|
|
487
|
+
"""Stop a terminal session"""
|
|
488
|
+
async with self._lock:
|
|
489
|
+
session = self._sessions.pop(session_id, None)
|
|
490
|
+
if session is None:
|
|
491
|
+
return True # Already stopped
|
|
492
|
+
|
|
493
|
+
try:
|
|
494
|
+
await session.stop()
|
|
495
|
+
return True
|
|
496
|
+
except Exception:
|
|
497
|
+
logger.error(
|
|
498
|
+
"Failed to stop terminal",
|
|
499
|
+
)
|
|
500
|
+
return False
|
|
501
|
+
|
|
502
|
+
async def stop_all_sessions(self) -> None:
|
|
503
|
+
"""Stop all terminal sessions (cleanup on disconnect)"""
|
|
504
|
+
async with self._lock:
|
|
505
|
+
session_ids = list(self._sessions.keys())
|
|
506
|
+
for session_id in session_ids:
|
|
507
|
+
session = self._sessions.pop(session_id, None)
|
|
508
|
+
if session:
|
|
509
|
+
try:
|
|
510
|
+
await session.stop()
|
|
511
|
+
logger.info(
|
|
512
|
+
"Stopped terminal session on cleanup",
|
|
513
|
+
)
|
|
514
|
+
except Exception:
|
|
515
|
+
logger.error(
|
|
516
|
+
"Error stopping terminal session on cleanup",
|
|
517
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Type definitions for terminal output streaming."""
|
|
2
|
+
|
|
3
|
+
import msgspec
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TerminalOutput(msgspec.Struct, tag="terminal_output"):
|
|
7
|
+
"""Terminal output data from CLI to web client."""
|
|
8
|
+
|
|
9
|
+
session_id: str
|
|
10
|
+
data: str
|
|
11
|
+
timestamp: float
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TerminalStatus(msgspec.Struct, tag="terminal_status"):
|
|
15
|
+
"""Terminal status update from CLI to web client."""
|
|
16
|
+
|
|
17
|
+
session_id: str
|
|
18
|
+
status: str
|
|
19
|
+
message: str
|
|
20
|
+
exit_code: int | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TerminalResetSessions(msgspec.Struct, tag="terminal_reset_sessions"):
|
|
24
|
+
"""Sent from CLI when terminal session manager starts to clear stale sessions."""
|
|
25
|
+
|
|
26
|
+
# No fields needed - just a signal
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
TerminalMessage = TerminalOutput | TerminalStatus | TerminalResetSessions
|