code-puppy 0.0.348__py3-none-any.whl → 0.0.372__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.
- code_puppy/agents/__init__.py +8 -0
- code_puppy/agents/agent_manager.py +272 -1
- code_puppy/agents/agent_pack_leader.py +383 -0
- code_puppy/agents/agent_qa_kitten.py +12 -7
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/base_agent.py +11 -8
- code_puppy/agents/event_stream_handler.py +101 -8
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +321 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +446 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +74 -0
- code_puppy/api/routers/sessions.py +232 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +73 -0
- code_puppy/chatgpt_codex_client.py +53 -0
- code_puppy/claude_cache_client.py +294 -41
- code_puppy/command_line/add_model_menu.py +13 -4
- code_puppy/command_line/agent_menu.py +662 -0
- code_puppy/command_line/core_commands.py +89 -112
- code_puppy/command_line/model_picker_completion.py +3 -20
- code_puppy/command_line/model_settings_menu.py +21 -3
- code_puppy/config.py +145 -70
- code_puppy/gemini_model.py +706 -0
- code_puppy/http_utils.py +6 -3
- code_puppy/messaging/__init__.py +15 -0
- code_puppy/messaging/messages.py +27 -0
- code_puppy/messaging/queue_console.py +1 -1
- code_puppy/messaging/rich_renderer.py +36 -1
- code_puppy/messaging/spinner/__init__.py +20 -2
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_factory.py +50 -16
- code_puppy/model_switching.py +63 -0
- code_puppy/model_utils.py +27 -24
- code_puppy/models.json +12 -12
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +206 -172
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +15 -8
- code_puppy/plugins/antigravity_oauth/transport.py +236 -45
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -2
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -30
- code_puppy/plugins/claude_code_oauth/utils.py +4 -1
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/pydantic_patches.py +52 -0
- code_puppy/status_display.py +6 -2
- code_puppy/tools/__init__.py +37 -1
- code_puppy/tools/agent_tools.py +83 -33
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +6 -6
- code_puppy/tools/browser/browser_interactions.py +21 -20
- code_puppy/tools/browser/browser_locators.py +9 -9
- code_puppy/tools/browser/browser_manager.py +316 -0
- code_puppy/tools/browser/browser_navigation.py +7 -7
- code_puppy/tools/browser/browser_screenshot.py +78 -140
- code_puppy/tools/browser/browser_scripts.py +15 -13
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +521 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +292 -101
- code_puppy/tools/common.py +176 -1
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/subagent_context.py +158 -0
- {code_puppy-0.0.348.data → code_puppy-0.0.372.data}/data/code_puppy/models.json +12 -12
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/METADATA +17 -16
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/RECORD +84 -51
- code_puppy/prompts/codex_system_prompt.md +0 -310
- code_puppy/tools/browser/camoufox_manager.py +0 -235
- code_puppy/tools/browser/vqa_agent.py +0 -90
- {code_puppy-0.0.348.data → code_puppy-0.0.372.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
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
|
|
934
|
-
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
#
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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
|