indent 0.1.21__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.21 → indent-0.1.23}/PKG-INFO +2 -1
  2. {indent-0.1.21 → indent-0.1.23}/exponent/__init__.py +2 -2
  3. {indent-0.1.21 → indent-0.1.23}/exponent/commands/cloud_commands.py +2 -0
  4. {indent-0.1.21 → indent-0.1.23}/exponent/core/graphql/mutations.py +2 -2
  5. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/cli_rpc_types.py +117 -0
  6. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/client.py +219 -32
  7. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/code_execution.py +26 -7
  8. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/languages/shell_streaming.py +2 -4
  9. indent-0.1.23/exponent/core/remote_execution/port_utils.py +73 -0
  10. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/system_context.py +2 -0
  11. indent-0.1.23/exponent/core/remote_execution/terminal_session.py +429 -0
  12. indent-0.1.23/exponent/core/remote_execution/terminal_types.py +29 -0
  13. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/tool_execution.py +4 -4
  14. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/truncation.py +1 -1
  15. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/types.py +9 -0
  16. {indent-0.1.21 → indent-0.1.23}/pyproject.toml +1 -0
  17. {indent-0.1.21 → indent-0.1.23}/.gitignore +0 -0
  18. {indent-0.1.21 → indent-0.1.23}/exponent/cli.py +0 -0
  19. {indent-0.1.21 → indent-0.1.23}/exponent/commands/common.py +0 -0
  20. {indent-0.1.21 → indent-0.1.23}/exponent/commands/config_commands.py +0 -0
  21. {indent-0.1.21 → indent-0.1.23}/exponent/commands/run_commands.py +0 -0
  22. {indent-0.1.21 → indent-0.1.23}/exponent/commands/settings.py +0 -0
  23. {indent-0.1.21 → indent-0.1.23}/exponent/commands/types.py +0 -0
  24. {indent-0.1.21 → indent-0.1.23}/exponent/commands/upgrade.py +0 -0
  25. {indent-0.1.21 → indent-0.1.23}/exponent/commands/utils.py +0 -0
  26. {indent-0.1.21 → indent-0.1.23}/exponent/core/config.py +0 -0
  27. {indent-0.1.21 → indent-0.1.23}/exponent/core/graphql/__init__.py +0 -0
  28. {indent-0.1.21 → indent-0.1.23}/exponent/core/graphql/client.py +0 -0
  29. {indent-0.1.21 → indent-0.1.23}/exponent/core/graphql/get_chats_query.py +0 -0
  30. {indent-0.1.21 → indent-0.1.23}/exponent/core/graphql/queries.py +0 -0
  31. {indent-0.1.21 → indent-0.1.23}/exponent/core/graphql/subscriptions.py +0 -0
  32. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/checkpoints.py +0 -0
  33. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/default_env.py +0 -0
  34. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/error_info.py +0 -0
  35. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/exceptions.py +0 -0
  36. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/file_write.py +0 -0
  37. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/files.py +0 -0
  38. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/git.py +0 -0
  39. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/http_fetch.py +0 -0
  40. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/languages/python_execution.py +0 -0
  41. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/languages/types.py +0 -0
  42. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/session.py +0 -0
  43. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/tool_type_utils.py +0 -0
  44. {indent-0.1.21 → indent-0.1.23}/exponent/core/remote_execution/utils.py +0 -0
  45. {indent-0.1.21 → indent-0.1.23}/exponent/core/types/__init__.py +0 -0
  46. {indent-0.1.21 → indent-0.1.23}/exponent/core/types/command_data.py +0 -0
  47. {indent-0.1.21 → indent-0.1.23}/exponent/core/types/event_types.py +0 -0
  48. {indent-0.1.21 → indent-0.1.23}/exponent/core/types/generated/__init__.py +0 -0
  49. {indent-0.1.21 → indent-0.1.23}/exponent/core/types/generated/strategy_info.py +0 -0
  50. {indent-0.1.21 → indent-0.1.23}/exponent/migration-docs/login.md +0 -0
  51. {indent-0.1.21 → indent-0.1.23}/exponent/py.typed +0 -0
  52. {indent-0.1.21 → indent-0.1.23}/exponent/utils/__init__.py +0 -0
  53. {indent-0.1.21 → indent-0.1.23}/exponent/utils/colors.py +0 -0
  54. {indent-0.1.21 → indent-0.1.23}/exponent/utils/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: indent
3
- Version: 0.1.21
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
@@ -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.21'
32
- __version_tuple__ = version_tuple = (0, 1, 21)
31
+ __version__ = version = '0.1.23'
32
+ __version_tuple__ = version_tuple = (0, 1, 23)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -111,6 +111,7 @@ async def create_cloud_chat_from_repository(
111
111
  base_api_url: str,
112
112
  base_ws_url: str,
113
113
  repository_id: str,
114
+ provider: str | None = None,
114
115
  ) -> dict[str, Any]:
115
116
  graphql_client = GraphQLClient(
116
117
  api_key=api_key, base_api_url=base_api_url, base_ws_url=base_ws_url
@@ -118,6 +119,7 @@ async def create_cloud_chat_from_repository(
118
119
 
119
120
  variables = {
120
121
  "repositoryId": repository_id,
122
+ "provider": provider,
121
123
  }
122
124
 
123
125
  result = await graphql_client.execute(
@@ -76,8 +76,8 @@ mutation CreateCloudChat($configId: String!) {
76
76
 
77
77
 
78
78
  CREATE_CLOUD_CHAT_FROM_REPOSITORY_MUTATION = """
79
- mutation CreateCloudChatFromRepository($repositoryId: String!) {
80
- createCloudChat(repositoryId: $repositoryId) {
79
+ mutation CreateCloudChatFromRepository($repositoryId: String!, $provider: String) {
80
+ createCloudChat(repositoryId: $repositoryId, provider: $provider) {
81
81
  __typename
82
82
  ...on Chat {
83
83
  chatUuid
@@ -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)