indent 0.1.22__tar.gz → 0.1.23__tar.gz

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.

Files changed (54) hide show
  1. {indent-0.1.22 → indent-0.1.23}/PKG-INFO +1 -1
  2. {indent-0.1.22 → indent-0.1.23}/exponent/__init__.py +2 -2
  3. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/cli_rpc_types.py +117 -0
  4. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/client.py +219 -32
  5. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/code_execution.py +26 -7
  6. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/languages/shell_streaming.py +2 -4
  7. indent-0.1.23/exponent/core/remote_execution/terminal_session.py +429 -0
  8. indent-0.1.23/exponent/core/remote_execution/terminal_types.py +29 -0
  9. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/tool_execution.py +4 -4
  10. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/truncation.py +1 -1
  11. {indent-0.1.22 → indent-0.1.23}/.gitignore +0 -0
  12. {indent-0.1.22 → indent-0.1.23}/exponent/cli.py +0 -0
  13. {indent-0.1.22 → indent-0.1.23}/exponent/commands/cloud_commands.py +0 -0
  14. {indent-0.1.22 → indent-0.1.23}/exponent/commands/common.py +0 -0
  15. {indent-0.1.22 → indent-0.1.23}/exponent/commands/config_commands.py +0 -0
  16. {indent-0.1.22 → indent-0.1.23}/exponent/commands/run_commands.py +0 -0
  17. {indent-0.1.22 → indent-0.1.23}/exponent/commands/settings.py +0 -0
  18. {indent-0.1.22 → indent-0.1.23}/exponent/commands/types.py +0 -0
  19. {indent-0.1.22 → indent-0.1.23}/exponent/commands/upgrade.py +0 -0
  20. {indent-0.1.22 → indent-0.1.23}/exponent/commands/utils.py +0 -0
  21. {indent-0.1.22 → indent-0.1.23}/exponent/core/config.py +0 -0
  22. {indent-0.1.22 → indent-0.1.23}/exponent/core/graphql/__init__.py +0 -0
  23. {indent-0.1.22 → indent-0.1.23}/exponent/core/graphql/client.py +0 -0
  24. {indent-0.1.22 → indent-0.1.23}/exponent/core/graphql/get_chats_query.py +0 -0
  25. {indent-0.1.22 → indent-0.1.23}/exponent/core/graphql/mutations.py +0 -0
  26. {indent-0.1.22 → indent-0.1.23}/exponent/core/graphql/queries.py +0 -0
  27. {indent-0.1.22 → indent-0.1.23}/exponent/core/graphql/subscriptions.py +0 -0
  28. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/checkpoints.py +0 -0
  29. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/default_env.py +0 -0
  30. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/error_info.py +0 -0
  31. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/exceptions.py +0 -0
  32. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/file_write.py +0 -0
  33. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/files.py +0 -0
  34. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/git.py +0 -0
  35. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/http_fetch.py +0 -0
  36. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/languages/python_execution.py +0 -0
  37. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/languages/types.py +0 -0
  38. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/port_utils.py +0 -0
  39. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/session.py +0 -0
  40. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/system_context.py +0 -0
  41. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/tool_type_utils.py +0 -0
  42. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/types.py +0 -0
  43. {indent-0.1.22 → indent-0.1.23}/exponent/core/remote_execution/utils.py +0 -0
  44. {indent-0.1.22 → indent-0.1.23}/exponent/core/types/__init__.py +0 -0
  45. {indent-0.1.22 → indent-0.1.23}/exponent/core/types/command_data.py +0 -0
  46. {indent-0.1.22 → indent-0.1.23}/exponent/core/types/event_types.py +0 -0
  47. {indent-0.1.22 → indent-0.1.23}/exponent/core/types/generated/__init__.py +0 -0
  48. {indent-0.1.22 → indent-0.1.23}/exponent/core/types/generated/strategy_info.py +0 -0
  49. {indent-0.1.22 → indent-0.1.23}/exponent/migration-docs/login.md +0 -0
  50. {indent-0.1.22 → indent-0.1.23}/exponent/py.typed +0 -0
  51. {indent-0.1.22 → indent-0.1.23}/exponent/utils/__init__.py +0 -0
  52. {indent-0.1.22 → indent-0.1.23}/exponent/utils/colors.py +0 -0
  53. {indent-0.1.22 → indent-0.1.23}/exponent/utils/version.py +0 -0
  54. {indent-0.1.22 → indent-0.1.23}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: indent
3
- Version: 0.1.22
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
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.22'
32
- __version_tuple__ = version_tuple = (0, 1, 22)
31
+ __version__ = version = '0.1.23'
32
+ __version_tuple__ = version_tuple = (0, 1, 23)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -304,6 +304,77 @@ class GenerateUploadUrlResponse(msgspec.Struct, tag="generate_upload_url"):
304
304
  s3_uri: str
305
305
 
306
306
 
307
+ # Terminal session management
308
+ class StartTerminalRequest(msgspec.Struct, tag="start_terminal"):
309
+ """Start a new terminal session with PTY"""
310
+
311
+ session_id: str
312
+ command: list[str] | None = None # None = default shell, or specific command
313
+ cols: int = 80
314
+ rows: int = 24
315
+ env: dict[str, str] | None = None # Additional environment variables
316
+
317
+
318
+ class StartTerminalResponse(msgspec.Struct, tag="start_terminal"):
319
+ """Response after starting terminal"""
320
+
321
+ session_id: str
322
+ success: bool
323
+ error_message: str | None = None
324
+
325
+
326
+ # Terminal input (user typing)
327
+ class TerminalInputRequest(msgspec.Struct, tag="terminal_input"):
328
+ """Send user input to terminal"""
329
+
330
+ session_id: str
331
+ data: str # Raw input data from user
332
+
333
+
334
+ class TerminalInputResponse(msgspec.Struct, tag="terminal_input"):
335
+ """Acknowledge input received"""
336
+
337
+ session_id: str
338
+ success: bool
339
+
340
+
341
+ # Terminal resize
342
+ class TerminalResizeRequest(msgspec.Struct, tag="terminal_resize"):
343
+ """Resize terminal dimensions"""
344
+
345
+ session_id: str
346
+ cols: int
347
+ rows: int
348
+
349
+
350
+ class TerminalResizeResponse(msgspec.Struct, tag="terminal_resize"):
351
+ """Acknowledge resize"""
352
+
353
+ session_id: str
354
+ success: bool
355
+
356
+
357
+ # Terminal stop
358
+ class StopTerminalRequest(msgspec.Struct, tag="stop_terminal"):
359
+ """Stop a terminal session"""
360
+
361
+ session_id: str
362
+
363
+
364
+ class StopTerminalResponse(msgspec.Struct, tag="stop_terminal"):
365
+ """Acknowledge terminal stopped"""
366
+
367
+ session_id: str
368
+ success: bool
369
+
370
+
371
+ class StreamingCodeExecutionRequest(msgspec.Struct, tag="streaming_code_execution"):
372
+ correlation_id: str
373
+ language: str # "python" or "shell"
374
+ content: str
375
+ timeout: int
376
+
377
+
307
378
  class CliRpcRequest(msgspec.Struct):
308
379
  request_id: str
309
380
  request: (
@@ -315,6 +386,11 @@ class CliRpcRequest(msgspec.Struct):
315
386
  | SwitchCLIChatRequest
316
387
  | KeepAliveCliChatRequest
317
388
  | GenerateUploadUrlRequest
389
+ | StartTerminalRequest
390
+ | TerminalInputRequest
391
+ | TerminalResizeRequest
392
+ | StopTerminalRequest
393
+ | StreamingCodeExecutionRequest
318
394
  )
319
395
 
320
396
 
@@ -326,6 +402,40 @@ class ErrorResponse(msgspec.Struct, tag="error"):
326
402
  error_message: str
327
403
 
328
404
 
405
+ class StreamingCodeExecutionResponseChunk(
406
+ msgspec.Struct, tag="streaming_code_execution_chunk"
407
+ ):
408
+ correlation_id: str
409
+ content: str
410
+ truncated: bool = False
411
+
412
+ def add(
413
+ self, new_chunk: "StreamingCodeExecutionResponseChunk"
414
+ ) -> "StreamingCodeExecutionResponseChunk":
415
+ """Aggregates content of this and a new chunk."""
416
+ assert self.correlation_id == new_chunk.correlation_id
417
+ return StreamingCodeExecutionResponseChunk(
418
+ correlation_id=self.correlation_id,
419
+ content=self.content + new_chunk.content,
420
+ truncated=self.truncated or new_chunk.truncated,
421
+ )
422
+
423
+
424
+ class StreamingCodeExecutionResponse(msgspec.Struct, tag="streaming_code_execution"):
425
+ correlation_id: str
426
+ content: str
427
+ truncated: bool = False
428
+ # Only present for shell code execution
429
+ cancelled_for_timeout: bool = False
430
+ exit_code: int | None = None
431
+ halted: bool = False
432
+
433
+
434
+ class StreamingErrorResponse(msgspec.Struct, tag="streaming_error"):
435
+ correlation_id: str
436
+ error_message: str
437
+
438
+
329
439
  class CliRpcResponse(msgspec.Struct):
330
440
  request_id: str
331
441
  response: (
@@ -339,4 +449,11 @@ class CliRpcResponse(msgspec.Struct):
339
449
  | SwitchCLIChatResponse
340
450
  | KeepAliveCliChatResponse
341
451
  | GenerateUploadUrlResponse
452
+ | StartTerminalResponse
453
+ | TerminalInputResponse
454
+ | TerminalResizeResponse
455
+ | StopTerminalResponse
456
+ | StreamingCodeExecutionResponseChunk
457
+ | StreamingCodeExecutionResponse
458
+ | StreamingErrorResponse
342
459
  )
@@ -38,8 +38,16 @@ from exponent.core.remote_execution.cli_rpc_types import (
38
38
  HttpRequest,
39
39
  KeepAliveCliChatRequest,
40
40
  KeepAliveCliChatResponse,
41
+ StartTerminalRequest,
42
+ StartTerminalResponse,
43
+ StopTerminalRequest,
44
+ StopTerminalResponse,
41
45
  SwitchCLIChatRequest,
42
46
  SwitchCLIChatResponse,
47
+ TerminalInputRequest,
48
+ TerminalInputResponse,
49
+ TerminalResizeRequest,
50
+ TerminalResizeResponse,
43
51
  TerminateRequest,
44
52
  TerminateResponse,
45
53
  ToolExecutionRequest,
@@ -56,6 +64,8 @@ from exponent.core.remote_execution.session import (
56
64
  get_session,
57
65
  send_exception_log,
58
66
  )
67
+ from exponent.core.remote_execution.terminal_session import TerminalSessionManager
68
+ from exponent.core.remote_execution.terminal_types import TerminalMessage
59
69
  from exponent.core.remote_execution.tool_execution import (
60
70
  execute_bash_tool,
61
71
  execute_tool,
@@ -66,9 +76,7 @@ from exponent.core.remote_execution.types import (
66
76
  CLIConnectedState,
67
77
  CreateChatResponse,
68
78
  HeartbeatInfo,
69
- RemoteExecutionResponseType,
70
79
  RunWorkflowRequest,
71
- StreamingCodeExecutionRequest,
72
80
  WorkflowInput,
73
81
  WorkflowTriggerRequest,
74
82
  WorkflowTriggerResponse,
@@ -183,11 +191,12 @@ class RemoteExecutionClient:
183
191
  # Handle cancellation gracefully
184
192
  return None
185
193
 
186
- async def _handle_websocket_message(
194
+ async def _handle_websocket_message( # noqa: PLR0911, PLR0915
187
195
  self,
188
196
  msg: str,
189
197
  websocket: ClientConnection,
190
198
  requests: asyncio.Queue[CliRpcRequest],
199
+ terminal_session_manager: TerminalSessionManager,
191
200
  ) -> REMOTE_EXECUTION_CLIENT_EXIT_INFO | None:
192
201
  """Handle an incoming websocket message.
193
202
  Returns None to continue processing, or a REMOTE_EXECUTION_CLIENT_EXIT_INFO to exit."""
@@ -215,7 +224,7 @@ class RemoteExecutionClient:
215
224
  data = json.dumps(msg_data["data"])
216
225
  try:
217
226
  request = msgspec.json.decode(data, type=CliRpcRequest)
218
- except msgspec.DecodeError as e:
227
+ except (msgspec.DecodeError, msgspec.ValidationError) as e:
219
228
  # Try and decode to get request_id if possible
220
229
  request = msgspec.json.decode(data)
221
230
  if isinstance(request, dict) and "request_id" in request:
@@ -317,6 +326,101 @@ class RemoteExecutionClient:
317
326
  )
318
327
  )
319
328
  return None
329
+ elif isinstance(request.request, StartTerminalRequest):
330
+ # Start a new terminal session
331
+ session_id = await terminal_session_manager.start_session(
332
+ websocket=websocket,
333
+ session_id=request.request.session_id,
334
+ command=request.request.command,
335
+ cols=request.request.cols,
336
+ rows=request.request.rows,
337
+ env=request.request.env,
338
+ )
339
+ await websocket.send(
340
+ json.dumps(
341
+ {
342
+ "type": "result",
343
+ "data": msgspec.to_builtins(
344
+ CliRpcResponse(
345
+ request_id=request.request_id,
346
+ response=StartTerminalResponse(
347
+ session_id=session_id, success=True
348
+ ),
349
+ )
350
+ ),
351
+ }
352
+ )
353
+ )
354
+ return None
355
+ elif isinstance(request.request, TerminalInputRequest):
356
+ # Send input to terminal session
357
+ success = await terminal_session_manager.send_input(
358
+ session_id=request.request.session_id,
359
+ data=request.request.data,
360
+ )
361
+ await websocket.send(
362
+ json.dumps(
363
+ {
364
+ "type": "result",
365
+ "data": msgspec.to_builtins(
366
+ CliRpcResponse(
367
+ request_id=request.request_id,
368
+ response=TerminalInputResponse(
369
+ session_id=request.request.session_id,
370
+ success=success,
371
+ ),
372
+ )
373
+ ),
374
+ }
375
+ )
376
+ )
377
+ return None
378
+ elif isinstance(request.request, TerminalResizeRequest):
379
+ # Resize terminal session
380
+ success = await terminal_session_manager.resize_terminal(
381
+ session_id=request.request.session_id,
382
+ rows=request.request.rows,
383
+ cols=request.request.cols,
384
+ )
385
+ await websocket.send(
386
+ json.dumps(
387
+ {
388
+ "type": "result",
389
+ "data": msgspec.to_builtins(
390
+ CliRpcResponse(
391
+ request_id=request.request_id,
392
+ response=TerminalResizeResponse(
393
+ session_id=request.request.session_id,
394
+ success=success,
395
+ ),
396
+ )
397
+ ),
398
+ }
399
+ )
400
+ )
401
+ return None
402
+ elif isinstance(request.request, StopTerminalRequest):
403
+ # Stop terminal session
404
+ success = await terminal_session_manager.stop_session(
405
+ session_id=request.request.session_id
406
+ )
407
+ await websocket.send(
408
+ json.dumps(
409
+ {
410
+ "type": "result",
411
+ "data": msgspec.to_builtins(
412
+ CliRpcResponse(
413
+ request_id=request.request_id,
414
+ response=StopTerminalResponse(
415
+ session_id=request.request.session_id,
416
+ success=success,
417
+ ),
418
+ )
419
+ ),
420
+ }
421
+ )
422
+ )
423
+ return None
320
424
  else:
321
425
  if isinstance(request.request, ToolExecutionRequest) and isinstance(
322
426
  request.request.tool_input, BashToolInput
@@ -363,21 +467,31 @@ class RemoteExecutionClient:
363
467
  request = await requests.get()
364
468
 
365
469
  try:
366
- # if isinstance(request, StreamingCodeExecutionRequest):
367
- # async for streaming_response in self.handle_streaming_request(
368
- # request
369
- # ):
370
- # async with results_lock:
371
- # await results.put(streaming_response)
372
- # else:
373
- # Note that we don't want to hold the lock here
374
- # because we want other executors to be able to
375
- # grab requests while we're handling a request.
376
- logger.info(f"Handling request {request}")
377
- response = await self.handle_request(request)
378
- async with results_lock:
379
- logger.info(f"Putting response {response}")
380
- await results.put(response)
470
+ # Check if this is a streaming request
471
+ from exponent.core.remote_execution.cli_rpc_types import (
472
+ StreamingCodeExecutionRequest,
473
+ )
474
+
475
+ if isinstance(request.request, StreamingCodeExecutionRequest):
476
+ async for streaming_response in self.handle_streaming_request(
477
+ request.request
478
+ ):
479
+ async with results_lock:
480
+ await results.put(
481
+ CliRpcResponse(
482
+ request_id=request.request_id,
483
+ response=streaming_response,
484
+ )
485
+ )
486
+ else:
487
+ # Note that we don't want to hold the lock here
488
+ # because we want other executors to be able to
489
+ # grab requests while we're handling a request.
490
+ logger.info(f"Handling request {request}")
491
+ response = await self.handle_request(request)
492
+ async with results_lock:
493
+ logger.info(f"Putting response {response}")
494
+ await results.put(response)
381
495
  except Exception as e:
382
496
  logger.info(f"Error handling request {request}:\n\n{e}")
383
497
  try:
@@ -385,15 +499,32 @@ class RemoteExecutionClient:
385
499
  except Exception:
386
500
  pass
387
501
  async with results_lock:
388
- await results.put(
389
- CliRpcResponse(
390
- request_id=request.request_id,
391
- response=ErrorResponse(
392
- error_message=str(e),
393
- ),
394
- )
502
+ from exponent.core.remote_execution.cli_rpc_types import (
503
+ StreamingCodeExecutionRequest,
504
+ StreamingErrorResponse,
395
505
  )
396
506
 
507
+ if isinstance(request.request, StreamingCodeExecutionRequest):
508
+ # For streaming requests, send a streaming error response
509
+ await results.put(
510
+ CliRpcResponse(
511
+ request_id=request.request_id,
512
+ response=StreamingErrorResponse(
513
+ correlation_id=request.request.correlation_id,
514
+ error_message=str(e),
515
+ ),
516
+ )
517
+ )
518
+ else:
519
+ await results.put(
520
+ CliRpcResponse(
521
+ request_id=request.request_id,
522
+ response=ErrorResponse(
523
+ error_message=str(e),
524
+ ),
525
+ )
526
+ )
527
+
397
528
  beat_task = asyncio.create_task(beat())
398
529
  # Three parallel executors to handle requests
399
530
 
@@ -411,6 +542,8 @@ class RemoteExecutionClient:
411
542
  beats: asyncio.Queue[HeartbeatInfo],
412
543
  requests: asyncio.Queue[CliRpcRequest],
413
544
  results: asyncio.Queue[CliRpcResponse],
545
+ terminal_output_queue: asyncio.Queue[TerminalMessage],
546
+ terminal_session_manager: TerminalSessionManager,
414
547
  ) -> REMOTE_EXECUTION_CLIENT_EXIT_INFO:
415
548
  """Process messages from the websocket connection."""
416
549
  pending: set[asyncio.Task[object]] = set()
@@ -418,7 +551,8 @@ class RemoteExecutionClient:
418
551
  recv = asyncio.create_task(websocket.recv())
419
552
  get_beat = asyncio.create_task(beats.get())
420
553
  get_result = asyncio.create_task(results.get())
421
- pending = {recv, get_beat, get_result}
554
+ get_terminal_output = asyncio.create_task(terminal_output_queue.get())
555
+ pending = {recv, get_beat, get_result, get_terminal_output}
422
556
 
423
557
  while True:
424
558
  done, pending = await asyncio.wait(
@@ -428,7 +562,7 @@ class RemoteExecutionClient:
428
562
  if recv in done:
429
563
  msg = str(recv.result())
430
564
  exit_info = await self._handle_websocket_message(
431
- msg, websocket, requests
565
+ msg, websocket, requests, terminal_session_manager
432
566
  )
433
567
  if exit_info is not None:
434
568
  return exit_info
@@ -447,12 +581,24 @@ class RemoteExecutionClient:
447
581
 
448
582
  if get_result in done:
449
583
  response = get_result.result()
584
+ # All responses are now CliRpcResponse with msgspec
450
585
  data = msgspec.to_builtins(response)
451
586
  msg = json.dumps({"type": "result", "data": data})
452
587
  await websocket.send(msg)
453
588
 
454
589
  get_result = asyncio.create_task(results.get())
455
590
  pending.add(get_result)
591
+
592
+ if get_terminal_output in done:
593
+ terminal_message = get_terminal_output.result()
594
+ data = msgspec.to_builtins(terminal_message)
595
+ msg = json.dumps({"type": "terminal_message", "data": data})
596
+ await websocket.send(msg)
597
+
598
+ get_terminal_output = asyncio.create_task(
599
+ terminal_output_queue.get()
600
+ )
601
+ pending.add(get_terminal_output)
456
602
  finally:
457
603
  for task in pending:
458
604
  task.cancel()
@@ -466,6 +612,8 @@ class RemoteExecutionClient:
466
612
  beats: asyncio.Queue[HeartbeatInfo],
467
613
  requests: asyncio.Queue[CliRpcRequest],
468
614
  results: asyncio.Queue[CliRpcResponse],
615
+ terminal_output_queue: asyncio.Queue[TerminalMessage],
616
+ terminal_session_manager: TerminalSessionManager,
469
617
  ) -> REMOTE_EXECUTION_CLIENT_EXIT_INFO | None:
470
618
  """Handle a single websocket connection.
471
619
  Returns None to continue with reconnection attempts, or an exit info to terminate."""
@@ -476,7 +624,12 @@ class RemoteExecutionClient:
476
624
 
477
625
  try:
478
626
  return await self._process_websocket_messages(
479
- websocket, beats, requests, results
627
+ websocket,
628
+ beats,
629
+ requests,
630
+ results,
631
+ terminal_output_queue,
632
+ terminal_session_manager,
480
633
  )
481
634
  except websockets.exceptions.ConnectionClosed as e:
482
635
  if e.rcvd is not None:
@@ -517,6 +670,10 @@ class RemoteExecutionClient:
517
670
  beats: asyncio.Queue[HeartbeatInfo] = asyncio.Queue()
518
671
  requests: asyncio.Queue[CliRpcRequest] = asyncio.Queue()
519
672
  results: asyncio.Queue[CliRpcResponse] = asyncio.Queue()
673
+ terminal_output_queue: asyncio.Queue[TerminalMessage] = asyncio.Queue()
674
+
675
+ # Create terminal session manager ONCE - persist across reconnections
676
+ terminal_session_manager = TerminalSessionManager(terminal_output_queue)
520
677
 
521
678
  # Create tasks ONCE - persist across reconnections
522
679
  executors = await self._setup_tasks(beats, requests, results)
@@ -529,7 +686,13 @@ class RemoteExecutionClient:
529
686
  [
530
687
  asyncio.create_task(
531
688
  self._handle_websocket_connection(
532
- websocket, connection_tracker, beats, requests, results
689
+ websocket,
690
+ connection_tracker,
691
+ beats,
692
+ requests,
693
+ results,
694
+ terminal_output_queue,
695
+ terminal_session_manager,
533
696
  )
534
697
  ),
535
698
  asyncio.create_task(self._timeout_monitor(timeout_seconds)),
@@ -554,6 +717,9 @@ class RemoteExecutionClient:
554
717
  error_message="Could not establish websocket connection"
555
718
  )
556
719
  finally:
720
+ # Stop all terminal sessions to clean up PTY processes
721
+ await terminal_session_manager.stop_all_sessions()
722
+
557
723
  # Cancel all background tasks when exiting
558
724
  for task in executors:
559
725
  task.cancel()
@@ -733,6 +899,22 @@ class RemoteExecutionClient:
733
899
  raise ValueError(
734
900
  "KeepAliveCliChatRequest should not be handled by handle_request"
735
901
  )
902
+ elif isinstance(request.request, StartTerminalRequest):
903
+ raise ValueError(
904
+ "StartTerminalRequest should not be handled by handle_request"
905
+ )
906
+ elif isinstance(request.request, TerminalInputRequest):
907
+ raise ValueError(
908
+ "TerminalInputRequest should not be handled by handle_request"
909
+ )
910
+ elif isinstance(request.request, TerminalResizeRequest):
911
+ raise ValueError(
912
+ "TerminalResizeRequest should not be handled by handle_request"
913
+ )
914
+ elif isinstance(request.request, StopTerminalRequest):
915
+ raise ValueError(
916
+ "StopTerminalRequest should not be handled by handle_request"
917
+ )
736
918
 
737
919
  raise ValueError(f"Unhandled request type: {type(request)}")
738
920
 
@@ -754,8 +936,13 @@ class RemoteExecutionClient:
754
936
  await self.clear_halt_state(request.request_id)
755
937
 
756
938
  async def handle_streaming_request(
757
- self, request: StreamingCodeExecutionRequest
758
- ) -> AsyncGenerator[RemoteExecutionResponseType, None]:
939
+ self,
940
+ request: Any,
941
+ ) -> AsyncGenerator[Any, None]:
942
+ from exponent.core.remote_execution.cli_rpc_types import (
943
+ StreamingCodeExecutionRequest,
944
+ )
945
+
759
946
  if not isinstance(request, StreamingCodeExecutionRequest):
760
947
  assert False, f"{type(request)} should be sent to handle_streaming_request"
761
948
  async for output in execute_code_streaming(
@@ -1,5 +1,10 @@
1
1
  from collections.abc import AsyncGenerator, Callable
2
2
 
3
+ from exponent.core.remote_execution.cli_rpc_types import (
4
+ StreamingCodeExecutionRequest,
5
+ StreamingCodeExecutionResponse,
6
+ StreamingCodeExecutionResponseChunk,
7
+ )
3
8
  from exponent.core.remote_execution.languages.python_execution import (
4
9
  execute_python_streaming,
5
10
  )
@@ -8,13 +13,9 @@ from exponent.core.remote_execution.languages.shell_streaming import (
8
13
  )
9
14
  from exponent.core.remote_execution.languages.types import StreamedOutputPiece
10
15
  from exponent.core.remote_execution.session import RemoteExecutionClientSession
11
- from exponent.core.remote_execution.types import (
12
- StreamingCodeExecutionRequest,
13
- StreamingCodeExecutionResponse,
14
- StreamingCodeExecutionResponseChunk,
15
- )
16
16
 
17
17
  EMPTY_OUTPUT_STRING = "(No output)"
18
+ MAX_OUTPUT_LENGTH = 50000 # Maximum characters to keep in final output
18
19
 
19
20
 
20
21
  async def execute_code_streaming(
@@ -34,9 +35,18 @@ async def execute_code_streaming(
34
35
  content=output.content, correlation_id=request.correlation_id
35
36
  )
36
37
  else:
38
+ final_output = output.output or EMPTY_OUTPUT_STRING
39
+ truncated = len(final_output) > MAX_OUTPUT_LENGTH
40
+ if truncated:
41
+ final_output = final_output[-MAX_OUTPUT_LENGTH:]
42
+ final_output = (
43
+ f"[Truncated to last {MAX_OUTPUT_LENGTH} characters]\n\n"
44
+ + final_output
45
+ )
37
46
  yield StreamingCodeExecutionResponse(
38
47
  correlation_id=request.correlation_id,
39
- content=output.output or EMPTY_OUTPUT_STRING,
48
+ content=final_output,
49
+ truncated=truncated,
40
50
  halted=output.halted,
41
51
  )
42
52
 
@@ -49,9 +59,18 @@ async def execute_code_streaming(
49
59
  content=shell_output.content, correlation_id=request.correlation_id
50
60
  )
51
61
  else:
62
+ final_output = shell_output.output or EMPTY_OUTPUT_STRING
63
+ truncated = len(final_output) > MAX_OUTPUT_LENGTH
64
+ if truncated:
65
+ final_output = final_output[-MAX_OUTPUT_LENGTH:]
66
+ final_output = (
67
+ f"[Truncated to last {MAX_OUTPUT_LENGTH} characters]\n\n"
68
+ + final_output
69
+ )
52
70
  yield StreamingCodeExecutionResponse(
53
71
  correlation_id=request.correlation_id,
54
- content=shell_output.output or EMPTY_OUTPUT_STRING,
72
+ content=final_output,
73
+ truncated=truncated,
55
74
  halted=shell_output.halted,
56
75
  exit_code=shell_output.exit_code,
57
76
  cancelled_for_timeout=shell_output.cancelled_for_timeout,
@@ -50,7 +50,7 @@ async def read_stream(
50
50
 
51
51
  while True:
52
52
  try:
53
- data = await stream.read(4096)
53
+ data = await stream.read(50_000)
54
54
  if not data:
55
55
  break
56
56
  chunk = data.decode(encoding=encoding, errors="replace")
@@ -182,10 +182,8 @@ async def execute_shell_streaming( # noqa: PLR0915
182
182
  piece = await task
183
183
  yield piece
184
184
 
185
- if process.returncode is not None:
186
- break
187
-
188
185
  # Schedule next read from the same stream
186
+ # Don't check process.returncode here - we need to drain all buffered output
189
187
  if task is stdout_task and not process.stdout.at_eof():
190
188
  stdout_task = asyncio.create_task(stdout_gen.__anext__())
191
189
  pending.add(stdout_task)
@@ -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 = 90_000
20
+ DEFAULT_CHARACTER_LIMIT = 50_000
21
21
  DEFAULT_LIST_ITEM_LIMIT = 1000
22
22
  DEFAULT_LIST_PREVIEW_ITEMS = 10
23
23
 
File without changes
File without changes
File without changes
File without changes
File without changes