indent 0.1.21__py3-none-any.whl → 0.1.23__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.
Potentially problematic release.
This version of indent might be problematic. Click here for more details.
- exponent/__init__.py +2 -2
- exponent/commands/cloud_commands.py +2 -0
- exponent/core/graphql/mutations.py +2 -2
- exponent/core/remote_execution/cli_rpc_types.py +117 -0
- exponent/core/remote_execution/client.py +219 -32
- exponent/core/remote_execution/code_execution.py +26 -7
- exponent/core/remote_execution/languages/shell_streaming.py +2 -4
- 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 +429 -0
- exponent/core/remote_execution/terminal_types.py +29 -0
- exponent/core/remote_execution/tool_execution.py +4 -4
- exponent/core/remote_execution/truncation.py +1 -1
- exponent/core/remote_execution/types.py +9 -0
- {indent-0.1.21.dist-info → indent-0.1.23.dist-info}/METADATA +2 -1
- {indent-0.1.21.dist-info → indent-0.1.23.dist-info}/RECORD +18 -15
- {indent-0.1.21.dist-info → indent-0.1.23.dist-info}/WHEEL +0 -0
- {indent-0.1.21.dist-info → indent-0.1.23.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import psutil
|
|
4
|
+
|
|
5
|
+
from exponent.core.remote_execution.types import PortInfo
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_port_usage() -> list[PortInfo] | None:
|
|
9
|
+
"""
|
|
10
|
+
Get information about all listening ports on the system.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
List of PortInfo objects containing process name, port, protocol, pid, and uptime.
|
|
14
|
+
Returns None if there's a permission error.
|
|
15
|
+
Returns empty list if no listening ports are found.
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
connections = psutil.net_connections(kind="tcp")
|
|
19
|
+
except (psutil.AccessDenied, PermissionError):
|
|
20
|
+
# If we don't have permission to see connections, return None
|
|
21
|
+
return None
|
|
22
|
+
except Exception:
|
|
23
|
+
# For any other unexpected errors, return None
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
port_info_list: list[PortInfo] = []
|
|
27
|
+
current_time = time.time()
|
|
28
|
+
|
|
29
|
+
for conn in connections:
|
|
30
|
+
# Only include TCP ports in LISTEN state
|
|
31
|
+
if conn.status != "LISTEN":
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
# Skip if no local address (shouldn't happen for LISTEN, but be safe)
|
|
35
|
+
if not conn.laddr:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
port = conn.laddr.port
|
|
39
|
+
pid = conn.pid
|
|
40
|
+
|
|
41
|
+
# Try to get process information
|
|
42
|
+
process_name = "unknown"
|
|
43
|
+
uptime_seconds = None
|
|
44
|
+
|
|
45
|
+
if pid:
|
|
46
|
+
try:
|
|
47
|
+
process = psutil.Process(pid)
|
|
48
|
+
process_name = process.name()
|
|
49
|
+
|
|
50
|
+
# Calculate uptime
|
|
51
|
+
create_time = process.create_time()
|
|
52
|
+
uptime_seconds = current_time - create_time
|
|
53
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
54
|
+
# Process disappeared or we don't have permission
|
|
55
|
+
pass
|
|
56
|
+
except Exception:
|
|
57
|
+
# Any other unexpected error, just skip process info
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
port_info = PortInfo(
|
|
61
|
+
process_name=process_name,
|
|
62
|
+
port=port,
|
|
63
|
+
protocol="TCP",
|
|
64
|
+
pid=pid,
|
|
65
|
+
uptime_seconds=uptime_seconds,
|
|
66
|
+
)
|
|
67
|
+
port_info_list.append(port_info)
|
|
68
|
+
|
|
69
|
+
# Limit to 50 ports to avoid bloating the heartbeat payload
|
|
70
|
+
if len(port_info_list) >= 50:
|
|
71
|
+
break
|
|
72
|
+
|
|
73
|
+
return port_info_list
|
|
@@ -4,6 +4,7 @@ import platform
|
|
|
4
4
|
|
|
5
5
|
from exponent.core.remote_execution.git import get_git_info
|
|
6
6
|
from exponent.core.remote_execution.languages import python_execution
|
|
7
|
+
from exponent.core.remote_execution.port_utils import get_port_usage
|
|
7
8
|
from exponent.core.remote_execution.types import (
|
|
8
9
|
SystemInfo,
|
|
9
10
|
)
|
|
@@ -17,6 +18,7 @@ async def get_system_info(working_directory: str) -> SystemInfo:
|
|
|
17
18
|
shell=_get_user_shell(),
|
|
18
19
|
git=await get_git_info(working_directory),
|
|
19
20
|
python_env=python_execution.get_python_env_info(),
|
|
21
|
+
port_usage=get_port_usage(),
|
|
20
22
|
)
|
|
21
23
|
|
|
22
24
|
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import fcntl
|
|
3
|
+
import os
|
|
4
|
+
import pty
|
|
5
|
+
import signal
|
|
6
|
+
import struct
|
|
7
|
+
import sys
|
|
8
|
+
import termios
|
|
9
|
+
import time
|
|
10
|
+
import traceback
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
|
|
15
|
+
from exponent.core.remote_execution.terminal_types import (
|
|
16
|
+
TerminalMessage,
|
|
17
|
+
TerminalOutput,
|
|
18
|
+
TerminalResetSessions,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = structlog.get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TerminalSession:
|
|
25
|
+
"""
|
|
26
|
+
Manages a PTY session for terminal emulation.
|
|
27
|
+
Runs on the CLI machine and streams output back to server.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
session_id: str,
|
|
33
|
+
output_callback: Callable[[str], None],
|
|
34
|
+
cols: int = 80,
|
|
35
|
+
rows: int = 24,
|
|
36
|
+
):
|
|
37
|
+
self.session_id = session_id
|
|
38
|
+
self.output_callback = output_callback # Called with terminal output
|
|
39
|
+
self.cols = cols
|
|
40
|
+
self.rows = rows
|
|
41
|
+
self.master_fd: int | None = None
|
|
42
|
+
self.pid: int | None = None
|
|
43
|
+
self._running = False
|
|
44
|
+
self._read_task: asyncio.Task[None] | None = None
|
|
45
|
+
|
|
46
|
+
async def start(
|
|
47
|
+
self,
|
|
48
|
+
command: list[str] | None = None,
|
|
49
|
+
env: dict[str, str] | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Start the terminal session with PTY"""
|
|
52
|
+
if self._running:
|
|
53
|
+
raise RuntimeError(f"Terminal session {self.session_id} already running")
|
|
54
|
+
|
|
55
|
+
# Default to bash if no command specified
|
|
56
|
+
if command is None:
|
|
57
|
+
command = ["/bin/bash"]
|
|
58
|
+
|
|
59
|
+
# Spawn process with PTY
|
|
60
|
+
try:
|
|
61
|
+
self.pid, self.master_fd = pty.fork()
|
|
62
|
+
except OSError as e:
|
|
63
|
+
logger.error(
|
|
64
|
+
"Failed to fork PTY",
|
|
65
|
+
session_id=self.session_id,
|
|
66
|
+
error=str(e),
|
|
67
|
+
)
|
|
68
|
+
raise RuntimeError(f"Failed to fork PTY: {e}") from e
|
|
69
|
+
|
|
70
|
+
if self.pid == 0:
|
|
71
|
+
# Child process - execute command
|
|
72
|
+
try:
|
|
73
|
+
# Set up environment
|
|
74
|
+
if env:
|
|
75
|
+
for key, value in env.items():
|
|
76
|
+
os.environ[key] = value
|
|
77
|
+
|
|
78
|
+
# Set terminal environment
|
|
79
|
+
os.environ["TERM"] = "xterm-256color"
|
|
80
|
+
os.environ["COLORTERM"] = "truecolor"
|
|
81
|
+
|
|
82
|
+
# Execute command
|
|
83
|
+
os.execvp(command[0], command)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
# If exec fails, log and exit child process
|
|
86
|
+
traceback.print_exc()
|
|
87
|
+
sys.stderr.write(f"Failed to execute command {command}: {e}\n")
|
|
88
|
+
sys.stderr.flush()
|
|
89
|
+
os._exit(1)
|
|
90
|
+
else:
|
|
91
|
+
# Parent process - set up non-blocking I/O
|
|
92
|
+
flags = fcntl.fcntl(self.master_fd, fcntl.F_GETFL)
|
|
93
|
+
fcntl.fcntl(self.master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
94
|
+
|
|
95
|
+
# Set initial size
|
|
96
|
+
self.resize(self.cols, self.rows)
|
|
97
|
+
|
|
98
|
+
# Start reading from PTY
|
|
99
|
+
self._running = True
|
|
100
|
+
self._read_task = asyncio.create_task(self._read_from_pty())
|
|
101
|
+
|
|
102
|
+
logger.info(
|
|
103
|
+
"Terminal session started",
|
|
104
|
+
session_id=self.session_id,
|
|
105
|
+
pid=self.pid,
|
|
106
|
+
command=command,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
async def _read_from_pty(self) -> None:
|
|
110
|
+
"""Continuously read from PTY using event loop's add_reader (non-blocking)"""
|
|
111
|
+
if self.master_fd is None:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
loop = asyncio.get_event_loop()
|
|
115
|
+
read_queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
|
116
|
+
|
|
117
|
+
def read_callback() -> None:
|
|
118
|
+
"""Called by event loop when data is available on the FD"""
|
|
119
|
+
if self.master_fd is None:
|
|
120
|
+
return
|
|
121
|
+
try:
|
|
122
|
+
data = os.read(self.master_fd, 4096)
|
|
123
|
+
if data:
|
|
124
|
+
# Put data in queue to be processed by async task
|
|
125
|
+
read_queue.put_nowait(data)
|
|
126
|
+
else:
|
|
127
|
+
# EOF - PTY closed
|
|
128
|
+
read_queue.put_nowait(None)
|
|
129
|
+
except OSError as e:
|
|
130
|
+
if e.errno == 11: # EAGAIN - shouldn't happen with add_reader
|
|
131
|
+
pass
|
|
132
|
+
else:
|
|
133
|
+
# PTY closed or error
|
|
134
|
+
logger.info(
|
|
135
|
+
"PTY read error in callback",
|
|
136
|
+
session_id=self.session_id,
|
|
137
|
+
error=str(e),
|
|
138
|
+
errno=e.errno,
|
|
139
|
+
)
|
|
140
|
+
read_queue.put_nowait(None)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.error(
|
|
143
|
+
"Unexpected error in PTY read callback",
|
|
144
|
+
session_id=self.session_id,
|
|
145
|
+
error=str(e),
|
|
146
|
+
)
|
|
147
|
+
read_queue.put_nowait(None)
|
|
148
|
+
|
|
149
|
+
# Register the FD with the event loop
|
|
150
|
+
loop.add_reader(self.master_fd, read_callback)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
while self._running:
|
|
154
|
+
# Wait for data from the queue (non-blocking for event loop)
|
|
155
|
+
data = await read_queue.get()
|
|
156
|
+
|
|
157
|
+
if data is None:
|
|
158
|
+
# EOF or error
|
|
159
|
+
logger.info(
|
|
160
|
+
"PTY closed (EOF)",
|
|
161
|
+
session_id=self.session_id,
|
|
162
|
+
)
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
# Process the data
|
|
166
|
+
decoded = data.decode("utf-8", errors="replace")
|
|
167
|
+
self.output_callback(decoded)
|
|
168
|
+
finally:
|
|
169
|
+
# Unregister the FD from the event loop
|
|
170
|
+
loop.remove_reader(self.master_fd)
|
|
171
|
+
logger.info(
|
|
172
|
+
"PTY read loop exited",
|
|
173
|
+
session_id=self.session_id,
|
|
174
|
+
running=self._running,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
async def write_input(self, data: str) -> None:
|
|
178
|
+
"""Write user input to PTY"""
|
|
179
|
+
if not self._running or self.master_fd is None:
|
|
180
|
+
raise RuntimeError(f"Terminal session {self.session_id} not running")
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
os.write(self.master_fd, data.encode("utf-8"))
|
|
184
|
+
except OSError as e:
|
|
185
|
+
logger.error(
|
|
186
|
+
"Error writing to PTY",
|
|
187
|
+
session_id=self.session_id,
|
|
188
|
+
error=str(e),
|
|
189
|
+
)
|
|
190
|
+
raise
|
|
191
|
+
|
|
192
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
193
|
+
"""Resize the PTY to match terminal dimensions"""
|
|
194
|
+
if self.master_fd is None:
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
self.cols = cols
|
|
198
|
+
self.rows = rows
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
size = struct.pack("HHHH", rows, cols, 0, 0)
|
|
202
|
+
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, size)
|
|
203
|
+
logger.debug(
|
|
204
|
+
"Terminal resized",
|
|
205
|
+
session_id=self.session_id,
|
|
206
|
+
cols=cols,
|
|
207
|
+
rows=rows,
|
|
208
|
+
)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
logger.error(
|
|
211
|
+
"Error resizing PTY",
|
|
212
|
+
session_id=self.session_id,
|
|
213
|
+
error=str(e),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
async def stop(self) -> tuple[bool, int | None]:
|
|
217
|
+
"""
|
|
218
|
+
Stop the terminal session and clean up resources.
|
|
219
|
+
Returns (success, exit_code)
|
|
220
|
+
"""
|
|
221
|
+
if not self._running:
|
|
222
|
+
return True, None
|
|
223
|
+
|
|
224
|
+
self._running = False
|
|
225
|
+
|
|
226
|
+
# Cancel read task
|
|
227
|
+
if self._read_task and not self._read_task.done():
|
|
228
|
+
self._read_task.cancel()
|
|
229
|
+
try:
|
|
230
|
+
await self._read_task
|
|
231
|
+
except asyncio.CancelledError:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
exit_code = None
|
|
235
|
+
|
|
236
|
+
# Close file descriptor
|
|
237
|
+
if self.master_fd is not None:
|
|
238
|
+
try:
|
|
239
|
+
os.close(self.master_fd)
|
|
240
|
+
except Exception as e:
|
|
241
|
+
logger.error(
|
|
242
|
+
"Error closing PTY fd",
|
|
243
|
+
session_id=self.session_id,
|
|
244
|
+
error=str(e),
|
|
245
|
+
)
|
|
246
|
+
self.master_fd = None
|
|
247
|
+
|
|
248
|
+
# Kill child process
|
|
249
|
+
if self.pid is not None:
|
|
250
|
+
try:
|
|
251
|
+
os.kill(self.pid, signal.SIGTERM)
|
|
252
|
+
# Wait for process to terminate (with timeout)
|
|
253
|
+
for _ in range(10): # Wait up to 1 second
|
|
254
|
+
try:
|
|
255
|
+
pid, status = os.waitpid(self.pid, os.WNOHANG)
|
|
256
|
+
if pid != 0:
|
|
257
|
+
exit_code = os.WEXITSTATUS(status)
|
|
258
|
+
break
|
|
259
|
+
except ChildProcessError:
|
|
260
|
+
break
|
|
261
|
+
await asyncio.sleep(0.1)
|
|
262
|
+
else:
|
|
263
|
+
# Force kill if still running
|
|
264
|
+
try:
|
|
265
|
+
os.kill(self.pid, signal.SIGKILL)
|
|
266
|
+
os.waitpid(self.pid, 0)
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
except Exception as e:
|
|
270
|
+
logger.error(
|
|
271
|
+
"Error killing PTY process",
|
|
272
|
+
session_id=self.session_id,
|
|
273
|
+
error=str(e),
|
|
274
|
+
)
|
|
275
|
+
self.pid = None
|
|
276
|
+
|
|
277
|
+
logger.info(
|
|
278
|
+
"Terminal session stopped",
|
|
279
|
+
session_id=self.session_id,
|
|
280
|
+
exit_code=exit_code,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return True, exit_code
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def is_running(self) -> bool:
|
|
287
|
+
"""Check if terminal session is running"""
|
|
288
|
+
return self._running and self.master_fd is not None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class TerminalSessionManager:
|
|
292
|
+
"""Manages multiple terminal sessions"""
|
|
293
|
+
|
|
294
|
+
def __init__(self, output_queue: asyncio.Queue[TerminalMessage]) -> None:
|
|
295
|
+
self._sessions: dict[str, TerminalSession] = {}
|
|
296
|
+
self._lock = asyncio.Lock()
|
|
297
|
+
self._websocket: object | None = None
|
|
298
|
+
self._output_queue = output_queue
|
|
299
|
+
|
|
300
|
+
# Send reset message immediately to clear stale sessions
|
|
301
|
+
try:
|
|
302
|
+
reset_message = TerminalResetSessions()
|
|
303
|
+
self._output_queue.put_nowait(reset_message)
|
|
304
|
+
logger.info("Sent TerminalResetSessions message")
|
|
305
|
+
except asyncio.QueueFull:
|
|
306
|
+
logger.error("Failed to queue terminal reset message - queue full")
|
|
307
|
+
|
|
308
|
+
def set_websocket(self, websocket: object) -> None:
|
|
309
|
+
"""Set the websocket for sending output"""
|
|
310
|
+
self._websocket = websocket
|
|
311
|
+
|
|
312
|
+
async def start_session(
|
|
313
|
+
self,
|
|
314
|
+
websocket: object,
|
|
315
|
+
session_id: str,
|
|
316
|
+
command: list[str] | None = None,
|
|
317
|
+
cols: int = 80,
|
|
318
|
+
rows: int = 24,
|
|
319
|
+
env: dict[str, str] | None = None,
|
|
320
|
+
) -> str:
|
|
321
|
+
"""Start a new terminal session"""
|
|
322
|
+
async with self._lock:
|
|
323
|
+
if session_id in self._sessions:
|
|
324
|
+
raise RuntimeError(f"Terminal session {session_id} already exists")
|
|
325
|
+
|
|
326
|
+
# Store websocket reference
|
|
327
|
+
self._websocket = websocket
|
|
328
|
+
|
|
329
|
+
# Create output callback that queues data to be sent
|
|
330
|
+
def output_callback(data: str) -> None:
|
|
331
|
+
# Queue the output to be sent asynchronously
|
|
332
|
+
try:
|
|
333
|
+
terminal_output = TerminalOutput(
|
|
334
|
+
session_id=session_id,
|
|
335
|
+
data=data,
|
|
336
|
+
timestamp=time.time(),
|
|
337
|
+
)
|
|
338
|
+
self._output_queue.put_nowait(terminal_output)
|
|
339
|
+
except asyncio.QueueFull:
|
|
340
|
+
logger.error(
|
|
341
|
+
"Terminal output queue full",
|
|
342
|
+
session_id=session_id,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
session = TerminalSession(
|
|
346
|
+
session_id=session_id,
|
|
347
|
+
output_callback=output_callback,
|
|
348
|
+
cols=cols,
|
|
349
|
+
rows=rows,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
await session.start(command=command, env=env)
|
|
353
|
+
self._sessions[session_id] = session
|
|
354
|
+
|
|
355
|
+
return session_id
|
|
356
|
+
|
|
357
|
+
async def send_input(self, session_id: str, data: str) -> bool:
|
|
358
|
+
"""Send input to a terminal session"""
|
|
359
|
+
async with self._lock:
|
|
360
|
+
session = self._sessions.get(session_id)
|
|
361
|
+
if session is None:
|
|
362
|
+
return False
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
await session.write_input(data)
|
|
366
|
+
return True
|
|
367
|
+
except Exception as e:
|
|
368
|
+
logger.error(
|
|
369
|
+
"Failed to send input to terminal",
|
|
370
|
+
session_id=session_id,
|
|
371
|
+
error=str(e),
|
|
372
|
+
)
|
|
373
|
+
return False
|
|
374
|
+
|
|
375
|
+
async def resize_terminal(self, session_id: str, rows: int, cols: int) -> bool:
|
|
376
|
+
"""Resize a terminal session"""
|
|
377
|
+
async with self._lock:
|
|
378
|
+
session = self._sessions.get(session_id)
|
|
379
|
+
if session is None:
|
|
380
|
+
return False
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
session.resize(cols, rows)
|
|
384
|
+
return True
|
|
385
|
+
except Exception as e:
|
|
386
|
+
logger.error(
|
|
387
|
+
"Failed to resize terminal",
|
|
388
|
+
session_id=session_id,
|
|
389
|
+
error=str(e),
|
|
390
|
+
)
|
|
391
|
+
return False
|
|
392
|
+
|
|
393
|
+
async def stop_session(self, session_id: str) -> bool:
|
|
394
|
+
"""Stop a terminal session"""
|
|
395
|
+
async with self._lock:
|
|
396
|
+
session = self._sessions.pop(session_id, None)
|
|
397
|
+
if session is None:
|
|
398
|
+
return True # Already stopped
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
await session.stop()
|
|
402
|
+
return True
|
|
403
|
+
except Exception as e:
|
|
404
|
+
logger.error(
|
|
405
|
+
"Failed to stop terminal",
|
|
406
|
+
session_id=session_id,
|
|
407
|
+
error=str(e),
|
|
408
|
+
)
|
|
409
|
+
return False
|
|
410
|
+
|
|
411
|
+
async def stop_all_sessions(self) -> None:
|
|
412
|
+
"""Stop all terminal sessions (cleanup on disconnect)"""
|
|
413
|
+
async with self._lock:
|
|
414
|
+
session_ids = list(self._sessions.keys())
|
|
415
|
+
for session_id in session_ids:
|
|
416
|
+
session = self._sessions.pop(session_id, None)
|
|
417
|
+
if session:
|
|
418
|
+
try:
|
|
419
|
+
await session.stop()
|
|
420
|
+
logger.info(
|
|
421
|
+
"Stopped terminal session on cleanup",
|
|
422
|
+
session_id=session_id,
|
|
423
|
+
)
|
|
424
|
+
except Exception as e:
|
|
425
|
+
logger.error(
|
|
426
|
+
"Error stopping terminal session on cleanup",
|
|
427
|
+
session_id=session_id,
|
|
428
|
+
error=str(e),
|
|
429
|
+
)
|
|
@@ -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
|
|
@@ -32,15 +32,15 @@ from exponent.core.remote_execution.cli_rpc_types import (
|
|
|
32
32
|
|
|
33
33
|
if TYPE_CHECKING:
|
|
34
34
|
from exponent.core.remote_execution.client import RemoteExecutionClient
|
|
35
|
+
from exponent.core.remote_execution.cli_rpc_types import (
|
|
36
|
+
StreamingCodeExecutionRequest,
|
|
37
|
+
StreamingCodeExecutionResponse,
|
|
38
|
+
)
|
|
35
39
|
from exponent.core.remote_execution.code_execution import (
|
|
36
40
|
execute_code_streaming,
|
|
37
41
|
)
|
|
38
42
|
from exponent.core.remote_execution.file_write import execute_full_file_rewrite
|
|
39
43
|
from exponent.core.remote_execution.truncation import truncate_tool_result
|
|
40
|
-
from exponent.core.remote_execution.types import (
|
|
41
|
-
StreamingCodeExecutionRequest,
|
|
42
|
-
StreamingCodeExecutionResponse,
|
|
43
|
-
)
|
|
44
44
|
from exponent.core.remote_execution.utils import (
|
|
45
45
|
assert_unreachable,
|
|
46
46
|
safe_get_file_metadata,
|
|
@@ -17,7 +17,7 @@ from exponent.core.remote_execution.cli_rpc_types import (
|
|
|
17
17
|
)
|
|
18
18
|
from exponent.core.remote_execution.utils import truncate_output
|
|
19
19
|
|
|
20
|
-
DEFAULT_CHARACTER_LIMIT =
|
|
20
|
+
DEFAULT_CHARACTER_LIMIT = 50_000
|
|
21
21
|
DEFAULT_LIST_ITEM_LIMIT = 1000
|
|
22
22
|
DEFAULT_LIST_PREVIEW_ITEMS = 10
|
|
23
23
|
|
|
@@ -120,6 +120,14 @@ class PythonEnvInfo(BaseModel):
|
|
|
120
120
|
provider: Literal["venv", "pyenv", "pipenv", "conda"] | None = "pyenv"
|
|
121
121
|
|
|
122
122
|
|
|
123
|
+
class PortInfo(BaseModel):
|
|
124
|
+
process_name: str
|
|
125
|
+
port: int
|
|
126
|
+
protocol: str
|
|
127
|
+
pid: int | None
|
|
128
|
+
uptime_seconds: float | None
|
|
129
|
+
|
|
130
|
+
|
|
123
131
|
class SystemInfo(BaseModel):
|
|
124
132
|
name: str
|
|
125
133
|
cwd: str
|
|
@@ -127,6 +135,7 @@ class SystemInfo(BaseModel):
|
|
|
127
135
|
shell: str
|
|
128
136
|
git: GitInfo | None
|
|
129
137
|
python_env: PythonEnvInfo | None
|
|
138
|
+
port_usage: list[PortInfo] | None = None
|
|
130
139
|
|
|
131
140
|
|
|
132
141
|
class HeartbeatInfo(BaseModel):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: indent
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.23
|
|
4
4
|
Summary: Indent is an AI Pair Programmer
|
|
5
5
|
Author-email: Sashank Thupukari <sashank@exponent.run>
|
|
6
6
|
Requires-Python: <3.13,>=3.10
|
|
@@ -22,6 +22,7 @@ Requires-Dist: msgspec>=0.19.0
|
|
|
22
22
|
Requires-Dist: packaging~=24.1
|
|
23
23
|
Requires-Dist: pip<26,>=25.0.1
|
|
24
24
|
Requires-Dist: prompt-toolkit<4,>=3.0.36
|
|
25
|
+
Requires-Dist: psutil<7,>=5.9.0
|
|
25
26
|
Requires-Dist: pydantic-ai==0.0.30
|
|
26
27
|
Requires-Dist: pydantic-settings<3,>=2.2.1
|
|
27
28
|
Requires-Dist: pydantic[email]<3,>=2.6.4
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
exponent/__init__.py,sha256=
|
|
1
|
+
exponent/__init__.py,sha256=eZj6tqY-zTtH5r_8y6a4Vovz6LQ_hDHSesTIiwuyahQ,706
|
|
2
2
|
exponent/cli.py,sha256=QnIeDTgWaQJrRs5WESCkQpVEQiJiAO4qWgB0rYlkd78,3344
|
|
3
3
|
exponent/py.typed,sha256=9XZl5avs8yHp89XP_1Fjtbeg_2rjYorCC9I0k_j-h2c,334
|
|
4
|
-
exponent/commands/cloud_commands.py,sha256=
|
|
4
|
+
exponent/commands/cloud_commands.py,sha256=yd0d7l8AaFZIgrFPRYspsKlmcyFqkK_ovSQKhK-YpVU,16773
|
|
5
5
|
exponent/commands/common.py,sha256=M2KI9yKjB8fecPoDBphMa123c35-iNeaE9q4DxhkaFU,12817
|
|
6
6
|
exponent/commands/config_commands.py,sha256=mmQYuyRosODgawoHWsn9xnWnV37GiQaxJjMv-_xreAU,8902
|
|
7
7
|
exponent/commands/run_commands.py,sha256=xn0SJX0PPrmHu8Nh-kG-lJBhGC4qFCc04aLIzdVyFho,6389
|
|
@@ -13,13 +13,13 @@ exponent/core/config.py,sha256=TNFLUgLnfSocRMVSav_7E4VcaNHXZ_3Mg5Lp1smP46U,5731
|
|
|
13
13
|
exponent/core/graphql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
14
|
exponent/core/graphql/client.py,sha256=SRagD3YPyoYZSO1RtfO-OXD7b5dm1NvgoL6CTbN380o,2009
|
|
15
15
|
exponent/core/graphql/get_chats_query.py,sha256=9-2N1VfapXUZB3IFIKw5U_gKdmfyviJp5JSUntB_Yyk,1177
|
|
16
|
-
exponent/core/graphql/mutations.py,sha256=
|
|
16
|
+
exponent/core/graphql/mutations.py,sha256=Szs8wS_5EpVuZdt09QbstIm_8i-_-EGT4Z17pou5714,2971
|
|
17
17
|
exponent/core/graphql/queries.py,sha256=RYsk8bub0esspqgakilhzX07yJf2652Ey9tBZK1l_lY,3297
|
|
18
18
|
exponent/core/graphql/subscriptions.py,sha256=SQngv_nYVNJjiZ_P2k0UcLIu1pzc4vi7q7lhH89NCZM,393
|
|
19
19
|
exponent/core/remote_execution/checkpoints.py,sha256=3QGYMLa8vT7XmxMYTRcGrW8kNGHwRC0AkUfULribJWg,6354
|
|
20
|
-
exponent/core/remote_execution/cli_rpc_types.py,sha256=
|
|
21
|
-
exponent/core/remote_execution/client.py,sha256=
|
|
22
|
-
exponent/core/remote_execution/code_execution.py,sha256=
|
|
20
|
+
exponent/core/remote_execution/cli_rpc_types.py,sha256=RPlvZYb7KLYlb65I8wcVAlkv3cSB7n2okZqMeWz_V4c,11791
|
|
21
|
+
exponent/core/remote_execution/client.py,sha256=bUl2w28c_okWndW9jEreVyYKeFNyL8h8OMZNBb4lf3g,40307
|
|
22
|
+
exponent/core/remote_execution/code_execution.py,sha256=QL78v2yHMrIcbNWIczAICWRceziXgG4pzw7gvhUOLxs,3328
|
|
23
23
|
exponent/core/remote_execution/default_env.py,sha256=s44A1Cz9EgYuhF17WO3ESVNSLQw57EoOLyi9k6qliIo,911
|
|
24
24
|
exponent/core/remote_execution/error_info.py,sha256=Rd7OA3ps06qYejPVcOaMBB9AtftP3wqQoOfiILFASnc,1378
|
|
25
25
|
exponent/core/remote_execution/exceptions.py,sha256=eT57lBnBhvh-KJ5lsKWcfgGA5-WisAxhjZx-Z6OupZY,135
|
|
@@ -27,15 +27,18 @@ exponent/core/remote_execution/file_write.py,sha256=8Sa70ANIDHGxIAq4_Uy2Qoo55K7-
|
|
|
27
27
|
exponent/core/remote_execution/files.py,sha256=mIVjhStaEKETW6y3pCVeV8eJKNaPtroWGP_kBK1x8uA,8776
|
|
28
28
|
exponent/core/remote_execution/git.py,sha256=dGjBpeoKJZsYgRwctSq29GmbsNIN9tbSA3VwBnRD0IQ,7810
|
|
29
29
|
exponent/core/remote_execution/http_fetch.py,sha256=aFEyXd0S-MRfisSMuIFiEyc1AEAj9nUZ9Rj_P_YRows,2827
|
|
30
|
+
exponent/core/remote_execution/port_utils.py,sha256=kWje8ikCzBXMeS7qr6NZZOzQOMoMuacgPUDYyloYgwM,2183
|
|
30
31
|
exponent/core/remote_execution/session.py,sha256=jlQIdeUj0f7uOk3BgzlJtBJ_GyTIjCchBp5ApQuF2-I,3847
|
|
31
|
-
exponent/core/remote_execution/system_context.py,sha256=
|
|
32
|
-
exponent/core/remote_execution/
|
|
32
|
+
exponent/core/remote_execution/system_context.py,sha256=I4RNuM60isS-529EuRrrEPPwJssNFC2TZ_7MhBTWEd0,754
|
|
33
|
+
exponent/core/remote_execution/terminal_session.py,sha256=s0ANsr_AGIfCI5u6PSvioOlnbk7ON43YAc30la5k3TM,14219
|
|
34
|
+
exponent/core/remote_execution/terminal_types.py,sha256=t4snBiTtidAEJZTvy789x-5HFqjkV9rlonjDO30PfAY,731
|
|
35
|
+
exponent/core/remote_execution/tool_execution.py,sha256=tDVW1c4ZysfTZqbR-wd7et41Mfv1lFQJCBvQShWnWq4,15791
|
|
33
36
|
exponent/core/remote_execution/tool_type_utils.py,sha256=7qi6Qd8fvHts019ZSLPbtiy17BUqgqBg3P_gdfvFf7w,1301
|
|
34
|
-
exponent/core/remote_execution/truncation.py,sha256=
|
|
35
|
-
exponent/core/remote_execution/types.py,sha256=
|
|
37
|
+
exponent/core/remote_execution/truncation.py,sha256=0zFnmqXES2vtQCSpfXIQn5hgg6bZK4Sad_Cfh27xTZU,9849
|
|
38
|
+
exponent/core/remote_execution/types.py,sha256=2tp73g6WLhL3x-5FyP9jhadcRHIswt4wfJJlEvNwlvk,15782
|
|
36
39
|
exponent/core/remote_execution/utils.py,sha256=6PlBqYJ3OQwZ0dgXiIu3br04a-d-glDeDZpD0XGGPAE,14793
|
|
37
40
|
exponent/core/remote_execution/languages/python_execution.py,sha256=nsX_LsXcUcHhiEHpSTjOTVNd7CxM146al0kw_iQX5OU,7724
|
|
38
|
-
exponent/core/remote_execution/languages/shell_streaming.py,sha256=
|
|
41
|
+
exponent/core/remote_execution/languages/shell_streaming.py,sha256=MpE1XQiu18xWUGp1wD_Hb1nuPCQE-i5-_XO6FnkcNvo,7675
|
|
39
42
|
exponent/core/remote_execution/languages/types.py,sha256=f7FjSRNRSga-ZaE3LddDhxCirUVjlSYMEdoskG6Pta4,314
|
|
40
43
|
exponent/core/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
41
44
|
exponent/core/types/command_data.py,sha256=_HqQsnamRZeVoVaTpeO3ecVUzNBdG62WXlFy6Q7rtUM,5294
|
|
@@ -46,7 +49,7 @@ exponent/migration-docs/login.md,sha256=KIeXy3m2nzSUgw-4PW1XzXfHael1D4Zu93CplLMb
|
|
|
46
49
|
exponent/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
47
50
|
exponent/utils/colors.py,sha256=HBkqe_ZmhJ9YiL2Fpulqek4KvLS5mwBTY4LQSM5N8SM,2762
|
|
48
51
|
exponent/utils/version.py,sha256=GHZ9ET1kMyDubJZU3w2sah5Pw8XpiEakS5IOlt3wUnQ,8888
|
|
49
|
-
indent-0.1.
|
|
50
|
-
indent-0.1.
|
|
51
|
-
indent-0.1.
|
|
52
|
-
indent-0.1.
|
|
52
|
+
indent-0.1.23.dist-info/METADATA,sha256=WOFHXH8yRn6F_tajQ4B1H9eIxo5RxFcUHVFKhOps2nM,1340
|
|
53
|
+
indent-0.1.23.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
54
|
+
indent-0.1.23.dist-info/entry_points.txt,sha256=q8q1t1sbl4NULGOR0OV5RmSG4KEjkpEQRU_RUXEGzcs,44
|
|
55
|
+
indent-0.1.23.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|