code-puppy 0.0.341__py3-none-any.whl → 0.0.361__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.
Files changed (86) hide show
  1. code_puppy/agents/__init__.py +2 -0
  2. code_puppy/agents/agent_manager.py +49 -0
  3. code_puppy/agents/agent_pack_leader.py +383 -0
  4. code_puppy/agents/agent_qa_kitten.py +12 -7
  5. code_puppy/agents/agent_terminal_qa.py +323 -0
  6. code_puppy/agents/base_agent.py +34 -252
  7. code_puppy/agents/event_stream_handler.py +350 -0
  8. code_puppy/agents/pack/__init__.py +34 -0
  9. code_puppy/agents/pack/bloodhound.py +304 -0
  10. code_puppy/agents/pack/husky.py +321 -0
  11. code_puppy/agents/pack/retriever.py +393 -0
  12. code_puppy/agents/pack/shepherd.py +348 -0
  13. code_puppy/agents/pack/terrier.py +287 -0
  14. code_puppy/agents/pack/watchdog.py +367 -0
  15. code_puppy/agents/subagent_stream_handler.py +276 -0
  16. code_puppy/api/__init__.py +13 -0
  17. code_puppy/api/app.py +169 -0
  18. code_puppy/api/main.py +21 -0
  19. code_puppy/api/pty_manager.py +446 -0
  20. code_puppy/api/routers/__init__.py +12 -0
  21. code_puppy/api/routers/agents.py +36 -0
  22. code_puppy/api/routers/commands.py +217 -0
  23. code_puppy/api/routers/config.py +74 -0
  24. code_puppy/api/routers/sessions.py +232 -0
  25. code_puppy/api/templates/terminal.html +361 -0
  26. code_puppy/api/websocket.py +154 -0
  27. code_puppy/callbacks.py +73 -0
  28. code_puppy/claude_cache_client.py +249 -34
  29. code_puppy/cli_runner.py +4 -3
  30. code_puppy/command_line/add_model_menu.py +8 -9
  31. code_puppy/command_line/core_commands.py +85 -0
  32. code_puppy/command_line/mcp/catalog_server_installer.py +5 -6
  33. code_puppy/command_line/mcp/custom_server_form.py +54 -19
  34. code_puppy/command_line/mcp/custom_server_installer.py +8 -9
  35. code_puppy/command_line/mcp/handler.py +0 -2
  36. code_puppy/command_line/mcp/help_command.py +1 -5
  37. code_puppy/command_line/mcp/start_command.py +36 -18
  38. code_puppy/command_line/onboarding_slides.py +0 -1
  39. code_puppy/command_line/prompt_toolkit_completion.py +16 -10
  40. code_puppy/command_line/utils.py +54 -0
  41. code_puppy/config.py +66 -62
  42. code_puppy/mcp_/async_lifecycle.py +35 -4
  43. code_puppy/mcp_/managed_server.py +49 -20
  44. code_puppy/mcp_/manager.py +81 -52
  45. code_puppy/messaging/__init__.py +15 -0
  46. code_puppy/messaging/message_queue.py +11 -23
  47. code_puppy/messaging/messages.py +27 -0
  48. code_puppy/messaging/queue_console.py +1 -1
  49. code_puppy/messaging/rich_renderer.py +36 -1
  50. code_puppy/messaging/spinner/__init__.py +20 -2
  51. code_puppy/messaging/subagent_console.py +461 -0
  52. code_puppy/model_utils.py +54 -0
  53. code_puppy/plugins/antigravity_oauth/antigravity_model.py +90 -19
  54. code_puppy/plugins/antigravity_oauth/transport.py +1 -0
  55. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  56. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  57. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  58. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  59. code_puppy/status_display.py +6 -2
  60. code_puppy/tools/__init__.py +37 -1
  61. code_puppy/tools/agent_tools.py +139 -36
  62. code_puppy/tools/browser/__init__.py +37 -0
  63. code_puppy/tools/browser/browser_control.py +6 -6
  64. code_puppy/tools/browser/browser_interactions.py +21 -20
  65. code_puppy/tools/browser/browser_locators.py +9 -9
  66. code_puppy/tools/browser/browser_navigation.py +7 -7
  67. code_puppy/tools/browser/browser_screenshot.py +78 -140
  68. code_puppy/tools/browser/browser_scripts.py +15 -13
  69. code_puppy/tools/browser/camoufox_manager.py +226 -64
  70. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  71. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  72. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  73. code_puppy/tools/browser/terminal_tools.py +525 -0
  74. code_puppy/tools/command_runner.py +292 -101
  75. code_puppy/tools/common.py +176 -1
  76. code_puppy/tools/display.py +84 -0
  77. code_puppy/tools/subagent_context.py +158 -0
  78. {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/METADATA +13 -11
  79. {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/RECORD +84 -53
  80. code_puppy/command_line/mcp/add_command.py +0 -170
  81. code_puppy/tools/browser/vqa_agent.py +0 -90
  82. {code_puppy-0.0.341.data → code_puppy-0.0.361.data}/data/code_puppy/models.json +0 -0
  83. {code_puppy-0.0.341.data → code_puppy-0.0.361.data}/data/code_puppy/models_dev_api.json +0 -0
  84. {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/WHEEL +0 -0
  85. {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/entry_points.txt +0 -0
  86. {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/licenses/LICENSE +0 -0
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import ctypes
2
3
  import os
3
4
  import select
@@ -8,7 +9,9 @@ import tempfile
8
9
  import threading
9
10
  import time
10
11
  import traceback
12
+ from concurrent.futures import ThreadPoolExecutor
11
13
  from contextlib import contextmanager
14
+ from functools import partial
12
15
  from typing import Callable, List, Literal, Optional, Set
13
16
 
14
17
  from pydantic import BaseModel
@@ -26,6 +29,7 @@ from code_puppy.messaging import ( # Structured messaging types
26
29
  get_message_bus,
27
30
  )
28
31
  from code_puppy.tools.common import generate_group_id, get_user_approval_async
32
+ from code_puppy.tools.subagent_context import is_subagent
29
33
 
30
34
  # Maximum line length for shell command output to prevent massive token usage
31
35
  # This helps avoid exceeding model context limits when commands produce very long lines
@@ -107,9 +111,17 @@ _SHELL_CTRL_X_STOP_EVENT: Optional[threading.Event] = None
107
111
  _SHELL_CTRL_X_THREAD: Optional[threading.Thread] = None
108
112
  _ORIGINAL_SIGINT_HANDLER = None
109
113
 
114
+ # Reference-counted keyboard context - stays active while ANY command is running
115
+ _KEYBOARD_CONTEXT_REFCOUNT = 0
116
+ _KEYBOARD_CONTEXT_LOCK = threading.Lock()
117
+
110
118
  # Stop event to signal reader threads to terminate
111
119
  _READER_STOP_EVENT: Optional[threading.Event] = None
112
120
 
121
+ # Thread pool for running blocking shell commands without blocking the event loop
122
+ # This allows multiple sub-agents to run shell commands in parallel
123
+ _SHELL_EXECUTOR = ThreadPoolExecutor(max_workers=16, thread_name_prefix="shell_cmd_")
124
+
113
125
 
114
126
  def _register_process(proc: subprocess.Popen) -> None:
115
127
  with _RUNNING_PROCESSES_LOCK:
@@ -489,11 +501,115 @@ def _shell_command_keyboard_context():
489
501
  _ORIGINAL_SIGINT_HANDLER = None
490
502
 
491
503
 
504
+ def _handle_ctrl_x_press() -> None:
505
+ """Handler for Ctrl-X: kill all running shell processes."""
506
+ emit_warning("\n🛑 Ctrl-X detected! Interrupting all shell commands...")
507
+ kill_all_running_shell_processes()
508
+
509
+
510
+ def _shell_sigint_handler(_sig, _frame):
511
+ """During shell execution, Ctrl-C kills all shells but doesn't cancel agent."""
512
+ emit_warning("\n🛑 Ctrl-C detected! Interrupting all shell commands...")
513
+ kill_all_running_shell_processes()
514
+
515
+
516
+ def _start_keyboard_listener() -> None:
517
+ """Start the Ctrl-X listener and install SIGINT handler.
518
+
519
+ Called when the first shell command starts.
520
+ """
521
+ global _SHELL_CTRL_X_STOP_EVENT, _SHELL_CTRL_X_THREAD, _ORIGINAL_SIGINT_HANDLER
522
+
523
+ # Set up Ctrl-X listener
524
+ _SHELL_CTRL_X_STOP_EVENT = threading.Event()
525
+ _SHELL_CTRL_X_THREAD = _spawn_ctrl_x_key_listener(
526
+ _SHELL_CTRL_X_STOP_EVENT,
527
+ _handle_ctrl_x_press,
528
+ )
529
+
530
+ # Replace SIGINT handler temporarily
531
+ try:
532
+ _ORIGINAL_SIGINT_HANDLER = signal.signal(signal.SIGINT, _shell_sigint_handler)
533
+ except (ValueError, OSError):
534
+ # Can't set signal handler (maybe not main thread?)
535
+ _ORIGINAL_SIGINT_HANDLER = None
536
+
537
+
538
+ def _stop_keyboard_listener() -> None:
539
+ """Stop the Ctrl-X listener and restore SIGINT handler.
540
+
541
+ Called when the last shell command finishes.
542
+ """
543
+ global _SHELL_CTRL_X_STOP_EVENT, _SHELL_CTRL_X_THREAD, _ORIGINAL_SIGINT_HANDLER
544
+
545
+ # Clean up: stop Ctrl-X listener
546
+ if _SHELL_CTRL_X_STOP_EVENT:
547
+ _SHELL_CTRL_X_STOP_EVENT.set()
548
+
549
+ if _SHELL_CTRL_X_THREAD and _SHELL_CTRL_X_THREAD.is_alive():
550
+ try:
551
+ _SHELL_CTRL_X_THREAD.join(timeout=0.2)
552
+ except Exception:
553
+ pass
554
+
555
+ # Restore original SIGINT handler
556
+ if _ORIGINAL_SIGINT_HANDLER is not None:
557
+ try:
558
+ signal.signal(signal.SIGINT, _ORIGINAL_SIGINT_HANDLER)
559
+ except (ValueError, OSError):
560
+ pass
561
+
562
+ # Clean up global state
563
+ _SHELL_CTRL_X_STOP_EVENT = None
564
+ _SHELL_CTRL_X_THREAD = None
565
+ _ORIGINAL_SIGINT_HANDLER = None
566
+
567
+
568
+ def _acquire_keyboard_context() -> None:
569
+ """Acquire the shared keyboard context (reference counted).
570
+
571
+ Starts the Ctrl-X listener when the first command starts.
572
+ Safe to call from any thread.
573
+ """
574
+ global _KEYBOARD_CONTEXT_REFCOUNT
575
+
576
+ should_start = False
577
+ with _KEYBOARD_CONTEXT_LOCK:
578
+ _KEYBOARD_CONTEXT_REFCOUNT += 1
579
+ if _KEYBOARD_CONTEXT_REFCOUNT == 1:
580
+ should_start = True
581
+
582
+ # Start listener OUTSIDE the lock to avoid blocking other commands
583
+ if should_start:
584
+ _start_keyboard_listener()
585
+
586
+
587
+ def _release_keyboard_context() -> None:
588
+ """Release the shared keyboard context (reference counted).
589
+
590
+ Stops the Ctrl-X listener when the last command finishes.
591
+ Safe to call from any thread.
592
+ """
593
+ global _KEYBOARD_CONTEXT_REFCOUNT
594
+
595
+ should_stop = False
596
+ with _KEYBOARD_CONTEXT_LOCK:
597
+ _KEYBOARD_CONTEXT_REFCOUNT -= 1
598
+ if _KEYBOARD_CONTEXT_REFCOUNT <= 0:
599
+ _KEYBOARD_CONTEXT_REFCOUNT = 0 # Safety clamp
600
+ should_stop = True
601
+
602
+ # Stop listener OUTSIDE the lock to avoid blocking other commands
603
+ if should_stop:
604
+ _stop_keyboard_listener()
605
+
606
+
492
607
  def run_shell_command_streaming(
493
608
  process: subprocess.Popen,
494
609
  timeout: int = 60,
495
610
  command: str = "",
496
611
  group_id: str = None,
612
+ silent: bool = False,
497
613
  ):
498
614
  global _READER_STOP_EVENT
499
615
  _READER_STOP_EVENT = threading.Event()
@@ -534,7 +650,8 @@ def run_shell_command_streaming(
534
650
  line = line.rstrip("\n\r")
535
651
  line = _truncate_line(line)
536
652
  stdout_lines.append(line)
537
- emit_shell_line(line, stream="stdout")
653
+ if not silent:
654
+ emit_shell_line(line, stream="stdout")
538
655
  last_output_time[0] = time.time()
539
656
  else:
540
657
  # No data available, check if process has exited
@@ -546,7 +663,8 @@ def run_shell_command_streaming(
546
663
  for line in remaining.splitlines():
547
664
  line = _truncate_line(line)
548
665
  stdout_lines.append(line)
549
- emit_shell_line(line, stream="stdout")
666
+ if not silent:
667
+ emit_shell_line(line, stream="stdout")
550
668
  except (ValueError, OSError):
551
669
  pass
552
670
  break
@@ -568,7 +686,8 @@ def run_shell_command_streaming(
568
686
  line = line.rstrip("\n\r")
569
687
  line = _truncate_line(line)
570
688
  stdout_lines.append(line)
571
- emit_shell_line(line, stream="stdout")
689
+ if not silent:
690
+ emit_shell_line(line, stream="stdout")
572
691
  last_output_time[0] = time.time()
573
692
  # If not ready, loop continues and checks stop event again
574
693
  except (ValueError, OSError):
@@ -600,7 +719,8 @@ def run_shell_command_streaming(
600
719
  line = line.rstrip("\n\r")
601
720
  line = _truncate_line(line)
602
721
  stderr_lines.append(line)
603
- emit_shell_line(line, stream="stderr")
722
+ if not silent:
723
+ emit_shell_line(line, stream="stderr")
604
724
  last_output_time[0] = time.time()
605
725
  else:
606
726
  # No data available, check if process has exited
@@ -612,7 +732,8 @@ def run_shell_command_streaming(
612
732
  for line in remaining.splitlines():
613
733
  line = _truncate_line(line)
614
734
  stderr_lines.append(line)
615
- emit_shell_line(line, stream="stderr")
735
+ if not silent:
736
+ emit_shell_line(line, stream="stderr")
616
737
  except (ValueError, OSError):
617
738
  pass
618
739
  break
@@ -633,7 +754,8 @@ def run_shell_command_streaming(
633
754
  line = line.rstrip("\n\r")
634
755
  line = _truncate_line(line)
635
756
  stderr_lines.append(line)
636
- emit_shell_line(line, stream="stderr")
757
+ if not silent:
758
+ emit_shell_line(line, stream="stderr")
637
759
  last_output_time[0] = time.time()
638
760
  except (ValueError, OSError):
639
761
  pass
@@ -669,7 +791,7 @@ def run_shell_command_streaming(
669
791
 
670
792
  if stdout_thread and stdout_thread.is_alive():
671
793
  stdout_thread.join(timeout=3)
672
- if stdout_thread.is_alive():
794
+ if stdout_thread.is_alive() and not silent:
673
795
  emit_warning(
674
796
  f"stdout reader thread failed to terminate after {timeout_type} timeout",
675
797
  message_group=group_id,
@@ -677,14 +799,17 @@ def run_shell_command_streaming(
677
799
 
678
800
  if stderr_thread and stderr_thread.is_alive():
679
801
  stderr_thread.join(timeout=3)
680
- if stderr_thread.is_alive():
802
+ if stderr_thread.is_alive() and not silent:
681
803
  emit_warning(
682
804
  f"stderr reader thread failed to terminate after {timeout_type} timeout",
683
805
  message_group=group_id,
684
806
  )
685
807
 
686
808
  except Exception as e:
687
- emit_warning(f"Error during process cleanup: {e}", message_group=group_id)
809
+ if not silent:
810
+ emit_warning(
811
+ f"Error during process cleanup: {e}", message_group=group_id
812
+ )
688
813
 
689
814
  execution_time = time.time() - start_time
690
815
  return ShellCommandOutput(
@@ -711,17 +836,19 @@ def run_shell_command_streaming(
711
836
  current_time = time.time()
712
837
 
713
838
  if current_time - start_time > ABSOLUTE_TIMEOUT_SECONDS:
714
- emit_error(
715
- "Process killed: absolute timeout reached",
716
- message_group=group_id,
717
- )
839
+ if not silent:
840
+ emit_error(
841
+ "Process killed: absolute timeout reached",
842
+ message_group=group_id,
843
+ )
718
844
  return cleanup_process_and_threads("absolute")
719
845
 
720
846
  if current_time - last_output_time[0] > timeout:
721
- emit_error(
722
- "Process killed: inactivity timeout reached",
723
- message_group=group_id,
724
- )
847
+ if not silent:
848
+ emit_error(
849
+ "Process killed: inactivity timeout reached",
850
+ message_group=group_id,
851
+ )
725
852
  return cleanup_process_and_threads("inactivity")
726
853
 
727
854
  time.sleep(0.1)
@@ -750,15 +877,16 @@ def run_shell_command_streaming(
750
877
  truncated_stdout = [_truncate_line(line) for line in stdout_lines[-256:]]
751
878
  truncated_stderr = [_truncate_line(line) for line in stderr_lines[-256:]]
752
879
 
753
- # Emit structured ShellOutputMessage for the UI
754
- shell_output_msg = ShellOutputMessage(
755
- command=command,
756
- stdout="\n".join(truncated_stdout),
757
- stderr="\n".join(truncated_stderr),
758
- exit_code=exit_code,
759
- duration_seconds=execution_time,
760
- )
761
- get_message_bus().emit(shell_output_msg)
880
+ # Emit structured ShellOutputMessage for the UI (skip for silent sub-agents)
881
+ if not silent:
882
+ shell_output_msg = ShellOutputMessage(
883
+ command=command,
884
+ stdout="\n".join(truncated_stdout),
885
+ stderr="\n".join(truncated_stderr),
886
+ exit_code=exit_code,
887
+ duration_seconds=execution_time,
888
+ )
889
+ get_message_bus().emit(shell_output_msg)
762
890
 
763
891
  # Reset the stop event now that we're done
764
892
  _READER_STOP_EVENT = None
@@ -809,8 +937,7 @@ async def run_shell_command(
809
937
  timeout: int = 60,
810
938
  background: bool = False,
811
939
  ) -> ShellCommandOutput:
812
- command_displayed = False
813
- start_time = time.time()
940
+ time.time()
814
941
 
815
942
  # Generate unique group_id for this command execution
816
943
  group_id = generate_group_id("shell_command", command)
@@ -928,10 +1055,14 @@ async def run_shell_command(
928
1055
 
929
1056
  yolo_mode = get_yolo_mode()
930
1057
 
1058
+ # Check if we're running as a sub-agent (skip confirmation and run silently)
1059
+ running_as_subagent = is_subagent()
1060
+
931
1061
  confirmation_lock_acquired = False
932
1062
 
933
- # Only ask for confirmation if we're in an interactive TTY and not in yolo mode.
934
- if not yolo_mode and sys.stdin.isatty():
1063
+ # Only ask for confirmation if we're in an interactive TTY, not in yolo mode,
1064
+ # and NOT running as a sub-agent (sub-agents run without user interaction)
1065
+ if not yolo_mode and not running_as_subagent and sys.stdin.isatty():
935
1066
  confirmation_lock_acquired = _CONFIRMATION_LOCK.acquire(blocking=False)
936
1067
  if not confirmation_lock_acquired:
937
1068
  return ShellCommandOutput(
@@ -940,8 +1071,6 @@ async def run_shell_command(
940
1071
  error="Another command is currently awaiting confirmation",
941
1072
  )
942
1073
 
943
- command_displayed = True
944
-
945
1074
  # Get puppy name for personalized messages
946
1075
  from code_puppy.config import get_puppy_name
947
1076
 
@@ -995,83 +1124,145 @@ async def run_shell_command(
995
1124
  )
996
1125
  return result
997
1126
  else:
998
- start_time = time.time()
999
-
1000
- # Now that approval is done, activate the Ctrl-X listener and disable agent Ctrl-C
1001
- with _shell_command_keyboard_context():
1002
- # Emit structured ShellStartMessage for the UI
1003
- bus = get_message_bus()
1004
- bus.emit(
1005
- ShellStartMessage(
1006
- command=command,
1007
- cwd=cwd,
1008
- timeout=timeout,
1009
- )
1127
+ time.time()
1128
+
1129
+ # Execute the command - sub-agents run silently without keyboard context
1130
+ return await _execute_shell_command(
1131
+ command=command,
1132
+ cwd=cwd,
1133
+ timeout=timeout,
1134
+ group_id=group_id,
1135
+ silent=running_as_subagent,
1136
+ )
1137
+
1138
+
1139
+ async def _execute_shell_command(
1140
+ command: str,
1141
+ cwd: str | None,
1142
+ timeout: int,
1143
+ group_id: str,
1144
+ silent: bool = False,
1145
+ ) -> ShellCommandOutput:
1146
+ """Internal helper to execute a shell command.
1147
+
1148
+ Args:
1149
+ command: The shell command to execute
1150
+ cwd: Working directory for command execution
1151
+ timeout: Inactivity timeout in seconds
1152
+ group_id: Unique group ID for message grouping
1153
+ silent: If True, suppress streaming output (for sub-agents)
1154
+
1155
+ Returns:
1156
+ ShellCommandOutput with execution results
1157
+ """
1158
+ # Always emit the ShellStartMessage banner (even for sub-agents)
1159
+ bus = get_message_bus()
1160
+ bus.emit(
1161
+ ShellStartMessage(
1162
+ command=command,
1163
+ cwd=cwd,
1164
+ timeout=timeout,
1010
1165
  )
1166
+ )
1167
+
1168
+ # Acquire shared keyboard context - Ctrl-X/Ctrl-C will kill ALL running commands
1169
+ # This is reference-counted: listener starts on first command, stops on last
1170
+ _acquire_keyboard_context()
1171
+ try:
1172
+ return await _run_command_inner(command, cwd, timeout, group_id, silent=silent)
1173
+ finally:
1174
+ _release_keyboard_context()
1011
1175
 
1176
+
1177
+ def _run_command_sync(
1178
+ command: str,
1179
+ cwd: str | None,
1180
+ timeout: int,
1181
+ group_id: str,
1182
+ silent: bool = False,
1183
+ ) -> ShellCommandOutput:
1184
+ """Synchronous command execution - runs in thread pool."""
1185
+ creationflags = 0
1186
+ preexec_fn = None
1187
+ if sys.platform.startswith("win"):
1012
1188
  try:
1189
+ creationflags = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
1190
+ except Exception:
1013
1191
  creationflags = 0
1014
- preexec_fn = None
1015
- if sys.platform.startswith("win"):
1016
- try:
1017
- creationflags = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
1018
- except Exception:
1019
- creationflags = 0
1020
- else:
1021
- preexec_fn = os.setsid if hasattr(os, "setsid") else None
1022
-
1023
- process = subprocess.Popen(
1024
- command,
1025
- shell=True,
1026
- stdout=subprocess.PIPE,
1027
- stderr=subprocess.PIPE,
1028
- text=True,
1029
- cwd=cwd,
1030
- bufsize=1,
1031
- universal_newlines=True,
1032
- preexec_fn=preexec_fn,
1033
- creationflags=creationflags,
1034
- )
1035
- _register_process(process)
1036
- try:
1037
- return run_shell_command_streaming(
1038
- process, timeout=timeout, command=command, group_id=group_id
1039
- )
1040
- finally:
1041
- # Ensure unregistration in case streaming returned early or raised
1042
- _unregister_process(process)
1043
- except Exception as e:
1044
- emit_error(traceback.format_exc(), message_group=group_id)
1045
- if "stdout" not in locals():
1046
- stdout = None
1047
- if "stderr" not in locals():
1048
- stderr = None
1049
-
1050
- # Apply line length limits to stdout/stderr if they exist
1051
- truncated_stdout = None
1052
- if stdout:
1053
- stdout_lines = stdout.split("\n")
1054
- truncated_stdout = "\n".join(
1055
- [_truncate_line(line) for line in stdout_lines[-256:]]
1056
- )
1192
+ else:
1193
+ preexec_fn = os.setsid if hasattr(os, "setsid") else None
1194
+
1195
+ process = subprocess.Popen(
1196
+ command,
1197
+ shell=True,
1198
+ stdout=subprocess.PIPE,
1199
+ stderr=subprocess.PIPE,
1200
+ text=True,
1201
+ cwd=cwd,
1202
+ bufsize=1,
1203
+ universal_newlines=True,
1204
+ preexec_fn=preexec_fn,
1205
+ creationflags=creationflags,
1206
+ )
1207
+ _register_process(process)
1208
+ try:
1209
+ return run_shell_command_streaming(
1210
+ process, timeout=timeout, command=command, group_id=group_id, silent=silent
1211
+ )
1212
+ finally:
1213
+ # Ensure unregistration in case streaming returned early or raised
1214
+ _unregister_process(process)
1057
1215
 
1058
- truncated_stderr = None
1059
- if stderr:
1060
- stderr_lines = stderr.split("\n")
1061
- truncated_stderr = "\n".join(
1062
- [_truncate_line(line) for line in stderr_lines[-256:]]
1063
- )
1064
1216
 
1065
- return ShellCommandOutput(
1066
- success=False,
1067
- command=command,
1068
- error=f"Error executing command {str(e)}",
1069
- stdout=truncated_stdout,
1070
- stderr=truncated_stderr,
1071
- exit_code=-1,
1072
- timeout=False,
1217
+ async def _run_command_inner(
1218
+ command: str,
1219
+ cwd: str | None,
1220
+ timeout: int,
1221
+ group_id: str,
1222
+ silent: bool = False,
1223
+ ) -> ShellCommandOutput:
1224
+ """Inner command execution logic - runs blocking code in thread pool."""
1225
+ loop = asyncio.get_running_loop()
1226
+ try:
1227
+ # Run the blocking shell command in a thread pool to avoid blocking the event loop
1228
+ # This allows multiple sub-agents to run shell commands in parallel
1229
+ return await loop.run_in_executor(
1230
+ _SHELL_EXECUTOR,
1231
+ partial(_run_command_sync, command, cwd, timeout, group_id, silent),
1232
+ )
1233
+ except Exception as e:
1234
+ if not silent:
1235
+ emit_error(traceback.format_exc(), message_group=group_id)
1236
+ if "stdout" not in locals():
1237
+ stdout = None
1238
+ if "stderr" not in locals():
1239
+ stderr = None
1240
+
1241
+ # Apply line length limits to stdout/stderr if they exist
1242
+ truncated_stdout = None
1243
+ if stdout:
1244
+ stdout_lines = stdout.split("\n")
1245
+ truncated_stdout = "\n".join(
1246
+ [_truncate_line(line) for line in stdout_lines[-256:]]
1247
+ )
1248
+
1249
+ truncated_stderr = None
1250
+ if stderr:
1251
+ stderr_lines = stderr.split("\n")
1252
+ truncated_stderr = "\n".join(
1253
+ [_truncate_line(line) for line in stderr_lines[-256:]]
1073
1254
  )
1074
1255
 
1256
+ return ShellCommandOutput(
1257
+ success=False,
1258
+ command=command,
1259
+ error=f"Error executing command {str(e)}",
1260
+ stdout=truncated_stdout,
1261
+ stderr=truncated_stderr,
1262
+ exit_code=-1,
1263
+ timeout=False,
1264
+ )
1265
+
1075
1266
 
1076
1267
  class ReasoningOutput(BaseModel):
1077
1268
  success: bool = True