indent 0.1.26__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 +34 -0
- exponent/cli.py +110 -0
- exponent/commands/cloud_commands.py +585 -0
- exponent/commands/common.py +411 -0
- exponent/commands/config_commands.py +334 -0
- exponent/commands/run_commands.py +222 -0
- exponent/commands/settings.py +56 -0
- exponent/commands/types.py +111 -0
- exponent/commands/upgrade.py +29 -0
- exponent/commands/utils.py +146 -0
- exponent/core/config.py +180 -0
- exponent/core/graphql/__init__.py +0 -0
- exponent/core/graphql/client.py +61 -0
- exponent/core/graphql/get_chats_query.py +47 -0
- exponent/core/graphql/mutations.py +160 -0
- exponent/core/graphql/queries.py +146 -0
- exponent/core/graphql/subscriptions.py +16 -0
- exponent/core/remote_execution/checkpoints.py +212 -0
- exponent/core/remote_execution/cli_rpc_types.py +499 -0
- exponent/core/remote_execution/client.py +999 -0
- exponent/core/remote_execution/code_execution.py +77 -0
- exponent/core/remote_execution/default_env.py +31 -0
- exponent/core/remote_execution/error_info.py +45 -0
- exponent/core/remote_execution/exceptions.py +10 -0
- exponent/core/remote_execution/file_write.py +35 -0
- exponent/core/remote_execution/files.py +330 -0
- exponent/core/remote_execution/git.py +268 -0
- exponent/core/remote_execution/http_fetch.py +94 -0
- exponent/core/remote_execution/languages/python_execution.py +239 -0
- exponent/core/remote_execution/languages/shell_streaming.py +226 -0
- exponent/core/remote_execution/languages/types.py +20 -0
- exponent/core/remote_execution/port_utils.py +73 -0
- exponent/core/remote_execution/session.py +128 -0
- exponent/core/remote_execution/system_context.py +26 -0
- exponent/core/remote_execution/terminal_session.py +375 -0
- exponent/core/remote_execution/terminal_types.py +29 -0
- exponent/core/remote_execution/tool_execution.py +595 -0
- exponent/core/remote_execution/tool_type_utils.py +39 -0
- exponent/core/remote_execution/truncation.py +296 -0
- exponent/core/remote_execution/types.py +635 -0
- exponent/core/remote_execution/utils.py +477 -0
- exponent/core/types/__init__.py +0 -0
- exponent/core/types/command_data.py +206 -0
- exponent/core/types/event_types.py +89 -0
- exponent/core/types/generated/__init__.py +0 -0
- exponent/core/types/generated/strategy_info.py +213 -0
- exponent/migration-docs/login.md +112 -0
- exponent/py.typed +4 -0
- exponent/utils/__init__.py +0 -0
- exponent/utils/colors.py +92 -0
- exponent/utils/version.py +289 -0
- indent-0.1.26.dist-info/METADATA +38 -0
- indent-0.1.26.dist-info/RECORD +55 -0
- indent-0.1.26.dist-info/WHEEL +4 -0
- indent-0.1.26.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import fcntl
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import pty
|
|
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
|
+
async def start(
|
|
46
|
+
self,
|
|
47
|
+
command: list[str] | None = None,
|
|
48
|
+
env: dict[str, str] | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Start the terminal session with PTY"""
|
|
51
|
+
if self._running:
|
|
52
|
+
raise RuntimeError(f"Terminal session {self.session_id} already running")
|
|
53
|
+
|
|
54
|
+
# Default to bash if no command specified
|
|
55
|
+
if command is None:
|
|
56
|
+
command = ["/bin/bash"]
|
|
57
|
+
|
|
58
|
+
# Spawn process with PTY
|
|
59
|
+
try:
|
|
60
|
+
self.pid, self.master_fd = pty.fork()
|
|
61
|
+
except OSError as e:
|
|
62
|
+
logger.error(
|
|
63
|
+
"Failed to fork PTY",
|
|
64
|
+
)
|
|
65
|
+
raise RuntimeError(f"Failed to fork PTY: {e}") from e
|
|
66
|
+
|
|
67
|
+
if self.pid == 0:
|
|
68
|
+
# Child process - execute command
|
|
69
|
+
try:
|
|
70
|
+
# Set up environment
|
|
71
|
+
if env:
|
|
72
|
+
for key, value in env.items():
|
|
73
|
+
os.environ[key] = value
|
|
74
|
+
|
|
75
|
+
# Set terminal environment
|
|
76
|
+
os.environ["TERM"] = "xterm-256color"
|
|
77
|
+
os.environ["COLORTERM"] = "truecolor"
|
|
78
|
+
|
|
79
|
+
# Execute command
|
|
80
|
+
os.execvp(command[0], command)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
# If exec fails, log and exit child process
|
|
83
|
+
traceback.print_exc()
|
|
84
|
+
sys.stderr.write(f"Failed to execute command {command}: {e}\n")
|
|
85
|
+
sys.stderr.flush()
|
|
86
|
+
os._exit(1)
|
|
87
|
+
else:
|
|
88
|
+
# Parent process - set up non-blocking I/O
|
|
89
|
+
flags = fcntl.fcntl(self.master_fd, fcntl.F_GETFL)
|
|
90
|
+
fcntl.fcntl(self.master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
91
|
+
|
|
92
|
+
# Set initial size
|
|
93
|
+
self.resize(self.cols, self.rows)
|
|
94
|
+
|
|
95
|
+
# Start reading from PTY
|
|
96
|
+
self._running = True
|
|
97
|
+
self._read_task = asyncio.create_task(self._read_from_pty())
|
|
98
|
+
|
|
99
|
+
async def _read_from_pty(self) -> None:
|
|
100
|
+
"""Continuously read from PTY using event loop's add_reader (non-blocking)"""
|
|
101
|
+
if self.master_fd is None:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
loop = asyncio.get_event_loop()
|
|
105
|
+
read_queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
|
106
|
+
|
|
107
|
+
def read_callback() -> None:
|
|
108
|
+
"""Called by event loop when data is available on the FD"""
|
|
109
|
+
if self.master_fd is None:
|
|
110
|
+
return
|
|
111
|
+
try:
|
|
112
|
+
data = os.read(self.master_fd, 4096)
|
|
113
|
+
if data:
|
|
114
|
+
# Put data in queue to be processed by async task
|
|
115
|
+
read_queue.put_nowait(data)
|
|
116
|
+
else:
|
|
117
|
+
# EOF - PTY closed
|
|
118
|
+
read_queue.put_nowait(None)
|
|
119
|
+
except OSError as e:
|
|
120
|
+
if e.errno == 11: # EAGAIN - shouldn't happen with add_reader
|
|
121
|
+
pass
|
|
122
|
+
else:
|
|
123
|
+
read_queue.put_nowait(None)
|
|
124
|
+
except Exception:
|
|
125
|
+
logger.error(
|
|
126
|
+
"Unexpected error in PTY read callback",
|
|
127
|
+
)
|
|
128
|
+
read_queue.put_nowait(None)
|
|
129
|
+
|
|
130
|
+
# Register the FD with the event loop
|
|
131
|
+
loop.add_reader(self.master_fd, read_callback)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
while self._running:
|
|
135
|
+
# Wait for data from the queue (non-blocking for event loop)
|
|
136
|
+
data = await read_queue.get()
|
|
137
|
+
|
|
138
|
+
if data is None:
|
|
139
|
+
# EOF or error
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
# Process the data
|
|
143
|
+
decoded = data.decode("utf-8", errors="replace")
|
|
144
|
+
self.output_callback(decoded)
|
|
145
|
+
finally:
|
|
146
|
+
# Unregister the FD from the event loop
|
|
147
|
+
loop.remove_reader(self.master_fd)
|
|
148
|
+
|
|
149
|
+
async def write_input(self, data: str) -> None:
|
|
150
|
+
"""Write user input to PTY"""
|
|
151
|
+
if not self._running or self.master_fd is None:
|
|
152
|
+
raise RuntimeError(f"Terminal session {self.session_id} not running")
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
os.write(self.master_fd, data.encode("utf-8"))
|
|
156
|
+
except OSError:
|
|
157
|
+
logger.error(
|
|
158
|
+
"Error writing to PTY",
|
|
159
|
+
)
|
|
160
|
+
raise
|
|
161
|
+
|
|
162
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
163
|
+
"""Resize the PTY to match terminal dimensions"""
|
|
164
|
+
if self.master_fd is None:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
self.cols = cols
|
|
168
|
+
self.rows = rows
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
size = struct.pack("HHHH", rows, cols, 0, 0)
|
|
172
|
+
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, size)
|
|
173
|
+
except Exception:
|
|
174
|
+
logger.error(
|
|
175
|
+
"Error resizing PTY",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
async def stop(self) -> tuple[bool, int | None]:
|
|
179
|
+
"""
|
|
180
|
+
Stop the terminal session and clean up resources.
|
|
181
|
+
Returns (success, exit_code)
|
|
182
|
+
"""
|
|
183
|
+
if not self._running:
|
|
184
|
+
return True, None
|
|
185
|
+
|
|
186
|
+
self._running = False
|
|
187
|
+
|
|
188
|
+
# Cancel read task
|
|
189
|
+
if self._read_task and not self._read_task.done():
|
|
190
|
+
self._read_task.cancel()
|
|
191
|
+
try:
|
|
192
|
+
await self._read_task
|
|
193
|
+
except asyncio.CancelledError:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
exit_code = None
|
|
197
|
+
|
|
198
|
+
# Close file descriptor
|
|
199
|
+
if self.master_fd is not None:
|
|
200
|
+
try:
|
|
201
|
+
os.close(self.master_fd)
|
|
202
|
+
except Exception:
|
|
203
|
+
logger.error(
|
|
204
|
+
"Error closing PTY fd",
|
|
205
|
+
)
|
|
206
|
+
self.master_fd = None
|
|
207
|
+
|
|
208
|
+
# Kill child process
|
|
209
|
+
if self.pid is not None:
|
|
210
|
+
try:
|
|
211
|
+
os.kill(self.pid, signal.SIGTERM)
|
|
212
|
+
# Wait for process to terminate (with timeout)
|
|
213
|
+
for _ in range(10): # Wait up to 1 second
|
|
214
|
+
try:
|
|
215
|
+
pid, status = os.waitpid(self.pid, os.WNOHANG)
|
|
216
|
+
if pid != 0:
|
|
217
|
+
exit_code = os.WEXITSTATUS(status)
|
|
218
|
+
break
|
|
219
|
+
except ChildProcessError:
|
|
220
|
+
break
|
|
221
|
+
await asyncio.sleep(0.1)
|
|
222
|
+
else:
|
|
223
|
+
# Force kill if still running
|
|
224
|
+
try:
|
|
225
|
+
os.kill(self.pid, signal.SIGKILL)
|
|
226
|
+
os.waitpid(self.pid, 0)
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
except Exception:
|
|
230
|
+
logger.error(
|
|
231
|
+
"Error killing PTY process",
|
|
232
|
+
)
|
|
233
|
+
self.pid = None
|
|
234
|
+
|
|
235
|
+
logger.info(
|
|
236
|
+
"Terminal session stopped",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return True, exit_code
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def is_running(self) -> bool:
|
|
243
|
+
"""Check if terminal session is running"""
|
|
244
|
+
return self._running and self.master_fd is not None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class TerminalSessionManager:
|
|
248
|
+
"""Manages multiple terminal sessions"""
|
|
249
|
+
|
|
250
|
+
def __init__(self, output_queue: asyncio.Queue[TerminalMessage]) -> None:
|
|
251
|
+
self._sessions: dict[str, TerminalSession] = {}
|
|
252
|
+
self._lock = asyncio.Lock()
|
|
253
|
+
self._websocket: object | None = None
|
|
254
|
+
self._output_queue = output_queue
|
|
255
|
+
|
|
256
|
+
# Send reset message immediately to clear stale sessions
|
|
257
|
+
try:
|
|
258
|
+
reset_message = TerminalResetSessions()
|
|
259
|
+
self._output_queue.put_nowait(reset_message)
|
|
260
|
+
logger.info("Sent TerminalResetSessions message")
|
|
261
|
+
except asyncio.QueueFull:
|
|
262
|
+
logger.error("Failed to queue terminal reset message - queue full")
|
|
263
|
+
|
|
264
|
+
def set_websocket(self, websocket: object) -> None:
|
|
265
|
+
"""Set the websocket for sending output"""
|
|
266
|
+
self._websocket = websocket
|
|
267
|
+
|
|
268
|
+
async def start_session(
|
|
269
|
+
self,
|
|
270
|
+
websocket: object,
|
|
271
|
+
session_id: str,
|
|
272
|
+
command: list[str] | None = None,
|
|
273
|
+
cols: int = 80,
|
|
274
|
+
rows: int = 24,
|
|
275
|
+
env: dict[str, str] | None = None,
|
|
276
|
+
) -> str:
|
|
277
|
+
"""Start a new terminal session"""
|
|
278
|
+
async with self._lock:
|
|
279
|
+
if session_id in self._sessions:
|
|
280
|
+
raise RuntimeError(f"Terminal session {session_id} already exists")
|
|
281
|
+
|
|
282
|
+
# Store websocket reference
|
|
283
|
+
self._websocket = websocket
|
|
284
|
+
|
|
285
|
+
# Create output callback that queues data to be sent
|
|
286
|
+
def output_callback(data: str) -> None:
|
|
287
|
+
# Queue the output to be sent asynchronously
|
|
288
|
+
try:
|
|
289
|
+
terminal_output = TerminalOutput(
|
|
290
|
+
session_id=session_id,
|
|
291
|
+
data=data,
|
|
292
|
+
timestamp=time.time(),
|
|
293
|
+
)
|
|
294
|
+
self._output_queue.put_nowait(terminal_output)
|
|
295
|
+
except asyncio.QueueFull:
|
|
296
|
+
logger.error(
|
|
297
|
+
"Terminal output queue full",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
session = TerminalSession(
|
|
301
|
+
session_id=session_id,
|
|
302
|
+
output_callback=output_callback,
|
|
303
|
+
cols=cols,
|
|
304
|
+
rows=rows,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
await session.start(command=command, env=env)
|
|
308
|
+
self._sessions[session_id] = session
|
|
309
|
+
|
|
310
|
+
return session_id
|
|
311
|
+
|
|
312
|
+
async def send_input(self, session_id: str, data: str) -> bool:
|
|
313
|
+
"""Send input to a terminal session"""
|
|
314
|
+
async with self._lock:
|
|
315
|
+
session = self._sessions.get(session_id)
|
|
316
|
+
if session is None:
|
|
317
|
+
return False
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
await session.write_input(data)
|
|
321
|
+
return True
|
|
322
|
+
except Exception:
|
|
323
|
+
logger.error(
|
|
324
|
+
"Failed to send input to terminal",
|
|
325
|
+
)
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
async def resize_terminal(self, session_id: str, rows: int, cols: int) -> bool:
|
|
329
|
+
"""Resize a terminal session"""
|
|
330
|
+
async with self._lock:
|
|
331
|
+
session = self._sessions.get(session_id)
|
|
332
|
+
if session is None:
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
session.resize(cols, rows)
|
|
337
|
+
return True
|
|
338
|
+
except Exception:
|
|
339
|
+
logger.error(
|
|
340
|
+
"Failed to resize terminal",
|
|
341
|
+
)
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
async def stop_session(self, session_id: str) -> bool:
|
|
345
|
+
"""Stop a terminal session"""
|
|
346
|
+
async with self._lock:
|
|
347
|
+
session = self._sessions.pop(session_id, None)
|
|
348
|
+
if session is None:
|
|
349
|
+
return True # Already stopped
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
await session.stop()
|
|
353
|
+
return True
|
|
354
|
+
except Exception:
|
|
355
|
+
logger.error(
|
|
356
|
+
"Failed to stop terminal",
|
|
357
|
+
)
|
|
358
|
+
return False
|
|
359
|
+
|
|
360
|
+
async def stop_all_sessions(self) -> None:
|
|
361
|
+
"""Stop all terminal sessions (cleanup on disconnect)"""
|
|
362
|
+
async with self._lock:
|
|
363
|
+
session_ids = list(self._sessions.keys())
|
|
364
|
+
for session_id in session_ids:
|
|
365
|
+
session = self._sessions.pop(session_id, None)
|
|
366
|
+
if session:
|
|
367
|
+
try:
|
|
368
|
+
await session.stop()
|
|
369
|
+
logger.info(
|
|
370
|
+
"Stopped terminal session on cleanup",
|
|
371
|
+
)
|
|
372
|
+
except Exception:
|
|
373
|
+
logger.error(
|
|
374
|
+
"Error stopping terminal session on cleanup",
|
|
375
|
+
)
|
|
@@ -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
|