code-puppy 0.0.169__py3-none-any.whl → 0.0.366__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/__init__.py +7 -1
- code_puppy/agents/__init__.py +8 -8
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +9 -2
- code_puppy/agents/agent_code_reviewer.py +90 -0
- code_puppy/agents/agent_cpp_reviewer.py +132 -0
- code_puppy/agents/agent_creator_agent.py +48 -9
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +146 -199
- code_puppy/agents/agent_pack_leader.py +383 -0
- code_puppy/agents/agent_planning.py +163 -0
- code_puppy/agents/agent_python_programmer.py +165 -0
- code_puppy/agents/agent_python_reviewer.py +90 -0
- code_puppy/agents/agent_qa_expert.py +163 -0
- code_puppy/agents/agent_qa_kitten.py +208 -0
- code_puppy/agents/agent_security_auditor.py +181 -0
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +166 -0
- code_puppy/agents/base_agent.py +1713 -1
- code_puppy/agents/event_stream_handler.py +350 -0
- code_puppy/agents/json_agent.py +12 -1
- 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/prompt_reviewer.py +145 -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 +174 -4
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/claude_cache_client.py +586 -0
- code_puppy/cli_runner.py +916 -0
- code_puppy/command_line/add_model_menu.py +1079 -0
- code_puppy/command_line/agent_menu.py +395 -0
- code_puppy/command_line/attachments.py +395 -0
- code_puppy/command_line/autosave_menu.py +605 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +520 -0
- code_puppy/command_line/command_handler.py +233 -627
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +715 -0
- code_puppy/command_line/core_commands.py +792 -0
- code_puppy/command_line/diff_menu.py +863 -0
- code_puppy/command_line/load_context_completion.py +15 -22
- code_puppy/command_line/mcp/base.py +1 -4
- code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
- code_puppy/command_line/mcp/custom_server_form.py +688 -0
- code_puppy/command_line/mcp/custom_server_installer.py +195 -0
- code_puppy/command_line/mcp/edit_command.py +148 -0
- code_puppy/command_line/mcp/handler.py +9 -4
- code_puppy/command_line/mcp/help_command.py +6 -5
- code_puppy/command_line/mcp/install_command.py +16 -27
- code_puppy/command_line/mcp/install_menu.py +685 -0
- code_puppy/command_line/mcp/list_command.py +3 -3
- code_puppy/command_line/mcp/logs_command.py +174 -65
- code_puppy/command_line/mcp/remove_command.py +2 -2
- code_puppy/command_line/mcp/restart_command.py +12 -4
- code_puppy/command_line/mcp/search_command.py +17 -11
- code_puppy/command_line/mcp/start_all_command.py +22 -13
- code_puppy/command_line/mcp/start_command.py +50 -31
- code_puppy/command_line/mcp/status_command.py +6 -7
- code_puppy/command_line/mcp/stop_all_command.py +11 -8
- code_puppy/command_line/mcp/stop_command.py +11 -10
- code_puppy/command_line/mcp/test_command.py +2 -2
- code_puppy/command_line/mcp/utils.py +1 -1
- code_puppy/command_line/mcp/wizard_utils.py +22 -18
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +89 -30
- code_puppy/command_line/model_settings_menu.py +884 -0
- code_puppy/command_line/motd.py +14 -8
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +340 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +626 -75
- code_puppy/command_line/session_commands.py +296 -0
- code_puppy/command_line/utils.py +54 -0
- code_puppy/config.py +1181 -51
- code_puppy/error_logging.py +118 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +602 -0
- code_puppy/http_utils.py +220 -104
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +5 -594
- code_puppy/{mcp → mcp_}/__init__.py +17 -0
- code_puppy/{mcp → mcp_}/async_lifecycle.py +35 -4
- code_puppy/{mcp → mcp_}/blocking_startup.py +70 -43
- code_puppy/{mcp → mcp_}/captured_stdio_server.py +2 -2
- code_puppy/{mcp → mcp_}/config_wizard.py +5 -5
- code_puppy/{mcp → mcp_}/dashboard.py +15 -6
- code_puppy/{mcp → mcp_}/examples/retry_example.py +4 -1
- code_puppy/{mcp → mcp_}/managed_server.py +66 -39
- code_puppy/{mcp → mcp_}/manager.py +146 -52
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/{mcp → mcp_}/registry.py +6 -6
- code_puppy/{mcp → mcp_}/server_registry_catalog.py +25 -8
- code_puppy/messaging/__init__.py +199 -2
- code_puppy/messaging/bus.py +610 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +17 -48
- code_puppy/messaging/messages.py +500 -0
- code_puppy/messaging/queue_console.py +1 -24
- code_puppy/messaging/renderers.py +43 -146
- code_puppy/messaging/rich_renderer.py +1027 -0
- code_puppy/messaging/spinner/__init__.py +33 -5
- code_puppy/messaging/spinner/console_spinner.py +92 -52
- code_puppy/messaging/spinner/spinner_base.py +29 -0
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_factory.py +686 -80
- code_puppy/model_utils.py +167 -0
- code_puppy/models.json +86 -104
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +164 -10
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +136 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
- code_puppy/plugins/antigravity_oauth/storage.py +271 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +767 -0
- code_puppy/plugins/antigravity_oauth/utils.py +169 -0
- code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
- code_puppy/plugins/chatgpt_oauth/config.py +52 -0
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
- code_puppy/plugins/claude_code_oauth/README.md +167 -0
- code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
- code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
- code_puppy/plugins/claude_code_oauth/config.py +50 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/utils.py +518 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
- code_puppy/plugins/example_custom_command/README.md +280 -0
- code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
- 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/plugins/oauth_puppy_html.py +228 -0
- code_puppy/plugins/shell_safety/__init__.py +6 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/prompts/codex_system_prompt.md +310 -0
- code_puppy/pydantic_patches.py +131 -0
- code_puppy/reopenable_async_client.py +8 -8
- code_puppy/round_robin_model.py +10 -15
- code_puppy/session_storage.py +294 -0
- code_puppy/status_display.py +21 -4
- code_puppy/summarization_agent.py +52 -14
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +139 -6
- code_puppy/tools/agent_tools.py +548 -49
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +289 -0
- code_puppy/tools/browser/browser_interactions.py +545 -0
- code_puppy/tools/browser/browser_locators.py +640 -0
- code_puppy/tools/browser/browser_manager.py +316 -0
- code_puppy/tools/browser/browser_navigation.py +251 -0
- code_puppy/tools/browser/browser_screenshot.py +179 -0
- code_puppy/tools/browser/browser_scripts.py +462 -0
- code_puppy/tools/browser/browser_workflows.py +221 -0
- 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 +941 -153
- code_puppy/tools/common.py +1146 -6
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +288 -89
- code_puppy/tools/file_operations.py +352 -266
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +30 -11
- code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
- code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/METADATA +184 -67
- code_puppy-0.0.366.dist-info/RECORD +217 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +1 -0
- code_puppy/agent.py +0 -231
- code_puppy/agents/agent_orchestrator.json +0 -26
- code_puppy/agents/runtime_manager.py +0 -272
- code_puppy/command_line/mcp/add_command.py +0 -183
- code_puppy/command_line/meta_command_handler.py +0 -153
- code_puppy/message_history_processor.py +0 -490
- code_puppy/messaging/spinner/textual_spinner.py +0 -101
- code_puppy/state_management.py +0 -200
- code_puppy/tui/__init__.py +0 -10
- code_puppy/tui/app.py +0 -986
- code_puppy/tui/components/__init__.py +0 -21
- code_puppy/tui/components/chat_view.py +0 -550
- code_puppy/tui/components/command_history_modal.py +0 -218
- code_puppy/tui/components/copy_button.py +0 -139
- code_puppy/tui/components/custom_widgets.py +0 -63
- code_puppy/tui/components/human_input_modal.py +0 -175
- code_puppy/tui/components/input_area.py +0 -167
- code_puppy/tui/components/sidebar.py +0 -309
- code_puppy/tui/components/status_bar.py +0 -182
- code_puppy/tui/messages.py +0 -27
- code_puppy/tui/models/__init__.py +0 -8
- code_puppy/tui/models/chat_message.py +0 -25
- code_puppy/tui/models/command_history.py +0 -89
- code_puppy/tui/models/enums.py +0 -24
- code_puppy/tui/screens/__init__.py +0 -15
- code_puppy/tui/screens/help.py +0 -130
- code_puppy/tui/screens/mcp_install_wizard.py +0 -803
- code_puppy/tui/screens/settings.py +0 -290
- code_puppy/tui/screens/tools.py +0 -74
- code_puppy-0.0.169.data/data/code_puppy/models.json +0 -128
- code_puppy-0.0.169.dist-info/RECORD +0 -112
- /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
- /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
- /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
- /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
- /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
- /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,26 +1,35 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import ctypes
|
|
1
3
|
import os
|
|
4
|
+
import select
|
|
2
5
|
import signal
|
|
3
6
|
import subprocess
|
|
4
7
|
import sys
|
|
8
|
+
import tempfile
|
|
5
9
|
import threading
|
|
6
10
|
import time
|
|
7
11
|
import traceback
|
|
8
|
-
from
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from functools import partial
|
|
15
|
+
from typing import Callable, List, Literal, Optional, Set
|
|
9
16
|
|
|
10
17
|
from pydantic import BaseModel
|
|
11
18
|
from pydantic_ai import RunContext
|
|
12
|
-
from rich.markdown import Markdown
|
|
13
19
|
from rich.text import Text
|
|
14
20
|
|
|
15
|
-
from code_puppy.messaging import (
|
|
16
|
-
|
|
21
|
+
from code_puppy.messaging import ( # Structured messaging types
|
|
22
|
+
AgentReasoningMessage,
|
|
23
|
+
ShellOutputMessage,
|
|
24
|
+
ShellStartMessage,
|
|
17
25
|
emit_error,
|
|
18
26
|
emit_info,
|
|
19
|
-
|
|
27
|
+
emit_shell_line,
|
|
20
28
|
emit_warning,
|
|
29
|
+
get_message_bus,
|
|
21
30
|
)
|
|
22
|
-
from code_puppy.
|
|
23
|
-
from code_puppy.tools.
|
|
31
|
+
from code_puppy.tools.common import generate_group_id, get_user_approval_async
|
|
32
|
+
from code_puppy.tools.subagent_context import is_subagent
|
|
24
33
|
|
|
25
34
|
# Maximum line length for shell command output to prevent massive token usage
|
|
26
35
|
# This helps avoid exceeding model context limits when commands produce very long lines
|
|
@@ -33,6 +42,61 @@ def _truncate_line(line: str) -> str:
|
|
|
33
42
|
return line[:MAX_LINE_LENGTH] + "... [truncated]"
|
|
34
43
|
return line
|
|
35
44
|
|
|
45
|
+
|
|
46
|
+
# Windows-specific: Check if pipe has data available without blocking
|
|
47
|
+
# This is needed because select() doesn't work on pipes on Windows
|
|
48
|
+
if sys.platform.startswith("win"):
|
|
49
|
+
import msvcrt
|
|
50
|
+
|
|
51
|
+
# Load kernel32 for PeekNamedPipe
|
|
52
|
+
_kernel32 = ctypes.windll.kernel32
|
|
53
|
+
|
|
54
|
+
def _win32_pipe_has_data(pipe) -> bool:
|
|
55
|
+
"""Check if a Windows pipe has data available without blocking.
|
|
56
|
+
|
|
57
|
+
Uses PeekNamedPipe from kernel32.dll to check if there's data
|
|
58
|
+
in the pipe buffer without actually reading it.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
pipe: A file object with a fileno() method (e.g., process.stdout)
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
True if data is available, False otherwise (including on error)
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
# Get the Windows handle from the file descriptor
|
|
68
|
+
handle = msvcrt.get_osfhandle(pipe.fileno())
|
|
69
|
+
|
|
70
|
+
# PeekNamedPipe parameters:
|
|
71
|
+
# - hNamedPipe: handle to the pipe
|
|
72
|
+
# - lpBuffer: buffer to receive data (NULL = don't read)
|
|
73
|
+
# - nBufferSize: size of buffer (0 = don't read)
|
|
74
|
+
# - lpBytesRead: receives bytes read (NULL)
|
|
75
|
+
# - lpTotalBytesAvail: receives total bytes available
|
|
76
|
+
# - lpBytesLeftThisMessage: receives bytes left (NULL)
|
|
77
|
+
bytes_available = ctypes.c_ulong(0)
|
|
78
|
+
|
|
79
|
+
result = _kernel32.PeekNamedPipe(
|
|
80
|
+
handle,
|
|
81
|
+
None, # Don't read data
|
|
82
|
+
0, # Buffer size 0
|
|
83
|
+
None, # Don't care about bytes read
|
|
84
|
+
ctypes.byref(bytes_available), # Get bytes available
|
|
85
|
+
None, # Don't care about bytes left in message
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if result:
|
|
89
|
+
return bytes_available.value > 0
|
|
90
|
+
return False
|
|
91
|
+
except (ValueError, OSError, ctypes.ArgumentError):
|
|
92
|
+
# Handle closed, invalid, or other errors
|
|
93
|
+
return False
|
|
94
|
+
else:
|
|
95
|
+
# POSIX stub - not used, but keeps the code clean
|
|
96
|
+
def _win32_pipe_has_data(pipe) -> bool:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
36
100
|
_AWAITING_USER_INPUT = False
|
|
37
101
|
|
|
38
102
|
_CONFIRMATION_LOCK = threading.Lock()
|
|
@@ -42,6 +106,22 @@ _RUNNING_PROCESSES: Set[subprocess.Popen] = set()
|
|
|
42
106
|
_RUNNING_PROCESSES_LOCK = threading.Lock()
|
|
43
107
|
_USER_KILLED_PROCESSES = set()
|
|
44
108
|
|
|
109
|
+
# Global state for shell command keyboard handling
|
|
110
|
+
_SHELL_CTRL_X_STOP_EVENT: Optional[threading.Event] = None
|
|
111
|
+
_SHELL_CTRL_X_THREAD: Optional[threading.Thread] = None
|
|
112
|
+
_ORIGINAL_SIGINT_HANDLER = None
|
|
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
|
+
|
|
118
|
+
# Stop event to signal reader threads to terminate
|
|
119
|
+
_READER_STOP_EVENT: Optional[threading.Event] = None
|
|
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
|
+
|
|
45
125
|
|
|
46
126
|
def _register_process(proc: subprocess.Popen) -> None:
|
|
47
127
|
with _RUNNING_PROCESSES_LOCK:
|
|
@@ -56,25 +136,32 @@ def _unregister_process(proc: subprocess.Popen) -> None:
|
|
|
56
136
|
def _kill_process_group(proc: subprocess.Popen) -> None:
|
|
57
137
|
"""Attempt to aggressively terminate a process and its group.
|
|
58
138
|
|
|
59
|
-
Cross-platform best-effort. On POSIX, uses process groups. On Windows, tries
|
|
139
|
+
Cross-platform best-effort. On POSIX, uses process groups. On Windows, tries taskkill with /T flag for tree kill.
|
|
60
140
|
"""
|
|
61
141
|
try:
|
|
62
142
|
if sys.platform.startswith("win"):
|
|
143
|
+
# On Windows, use taskkill to kill the process tree
|
|
144
|
+
# /F = force, /T = kill tree (children), /PID = process ID
|
|
63
145
|
try:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
146
|
+
import subprocess as sp
|
|
147
|
+
|
|
148
|
+
# Try taskkill first - more reliable on Windows
|
|
149
|
+
sp.run(
|
|
150
|
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
151
|
+
capture_output=True,
|
|
152
|
+
timeout=2,
|
|
153
|
+
check=False,
|
|
154
|
+
)
|
|
155
|
+
time.sleep(0.3)
|
|
67
156
|
except Exception:
|
|
157
|
+
# Fallback to Python's built-in methods
|
|
68
158
|
pass
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
proc.terminate()
|
|
72
|
-
time.sleep(0.8)
|
|
73
|
-
except Exception:
|
|
74
|
-
pass
|
|
159
|
+
|
|
160
|
+
# Double-check it's dead, if not use proc.kill()
|
|
75
161
|
if proc.poll() is None:
|
|
76
162
|
try:
|
|
77
163
|
proc.kill()
|
|
164
|
+
time.sleep(0.3)
|
|
78
165
|
except Exception:
|
|
79
166
|
pass
|
|
80
167
|
return
|
|
@@ -114,16 +201,33 @@ def _kill_process_group(proc: subprocess.Popen) -> None:
|
|
|
114
201
|
|
|
115
202
|
|
|
116
203
|
def kill_all_running_shell_processes() -> int:
|
|
117
|
-
"""Kill all currently tracked running shell processes.
|
|
204
|
+
"""Kill all currently tracked running shell processes and stop reader threads.
|
|
118
205
|
|
|
119
206
|
Returns the number of processes signaled.
|
|
120
207
|
"""
|
|
208
|
+
global _READER_STOP_EVENT
|
|
209
|
+
|
|
210
|
+
# Signal reader threads to stop
|
|
211
|
+
if _READER_STOP_EVENT:
|
|
212
|
+
_READER_STOP_EVENT.set()
|
|
213
|
+
|
|
121
214
|
procs: list[subprocess.Popen]
|
|
122
215
|
with _RUNNING_PROCESSES_LOCK:
|
|
123
216
|
procs = list(_RUNNING_PROCESSES)
|
|
124
217
|
count = 0
|
|
125
218
|
for p in procs:
|
|
126
219
|
try:
|
|
220
|
+
# Close pipes first to unblock readline()
|
|
221
|
+
try:
|
|
222
|
+
if p.stdout and not p.stdout.closed:
|
|
223
|
+
p.stdout.close()
|
|
224
|
+
if p.stderr and not p.stderr.closed:
|
|
225
|
+
p.stderr.close()
|
|
226
|
+
if p.stdin and not p.stdin.closed:
|
|
227
|
+
p.stdin.close()
|
|
228
|
+
except (OSError, ValueError):
|
|
229
|
+
pass
|
|
230
|
+
|
|
127
231
|
if p.poll() is None:
|
|
128
232
|
_kill_process_group(p)
|
|
129
233
|
count += 1
|
|
@@ -133,6 +237,21 @@ def kill_all_running_shell_processes() -> int:
|
|
|
133
237
|
return count
|
|
134
238
|
|
|
135
239
|
|
|
240
|
+
def get_running_shell_process_count() -> int:
|
|
241
|
+
"""Return the number of currently-active shell processes being tracked."""
|
|
242
|
+
with _RUNNING_PROCESSES_LOCK:
|
|
243
|
+
alive = 0
|
|
244
|
+
stale: Set[subprocess.Popen] = set()
|
|
245
|
+
for proc in _RUNNING_PROCESSES:
|
|
246
|
+
if proc.poll() is None:
|
|
247
|
+
alive += 1
|
|
248
|
+
else:
|
|
249
|
+
stale.add(proc)
|
|
250
|
+
for proc in stale:
|
|
251
|
+
_RUNNING_PROCESSES.discard(proc)
|
|
252
|
+
return alive
|
|
253
|
+
|
|
254
|
+
|
|
136
255
|
# Function to check if user input is awaited
|
|
137
256
|
def is_awaiting_user_input():
|
|
138
257
|
"""Check if command_runner is waiting for user input."""
|
|
@@ -175,6 +294,314 @@ class ShellCommandOutput(BaseModel):
|
|
|
175
294
|
execution_time: float | None
|
|
176
295
|
timeout: bool | None = False
|
|
177
296
|
user_interrupted: bool | None = False
|
|
297
|
+
user_feedback: str | None = None # User feedback when command is rejected
|
|
298
|
+
background: bool = False # True if command was run in background mode
|
|
299
|
+
log_file: str | None = None # Path to temp log file for background commands
|
|
300
|
+
pid: int | None = None # Process ID for background commands
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class ShellSafetyAssessment(BaseModel):
|
|
304
|
+
"""Assessment of shell command safety risks.
|
|
305
|
+
|
|
306
|
+
This model represents the structured output from the shell safety checker agent.
|
|
307
|
+
It provides a risk level classification and reasoning for that assessment.
|
|
308
|
+
|
|
309
|
+
Attributes:
|
|
310
|
+
risk: Risk level classification. Can be one of:
|
|
311
|
+
'none' (completely safe), 'low' (minimal risk), 'medium' (moderate risk),
|
|
312
|
+
'high' (significant risk), 'critical' (severe/destructive risk).
|
|
313
|
+
reasoning: Brief explanation (max 1-2 sentences) of why this risk level
|
|
314
|
+
was assigned. Should be concise and actionable.
|
|
315
|
+
is_fallback: Whether this assessment is a fallback due to parsing failure.
|
|
316
|
+
Fallback assessments are not cached to allow retry with fresh LLM responses.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
risk: Literal["none", "low", "medium", "high", "critical"]
|
|
320
|
+
reasoning: str
|
|
321
|
+
is_fallback: bool = False
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _listen_for_ctrl_x_windows(
|
|
325
|
+
stop_event: threading.Event,
|
|
326
|
+
on_escape: Callable[[], None],
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Windows-specific Ctrl-X listener."""
|
|
329
|
+
import msvcrt
|
|
330
|
+
import time
|
|
331
|
+
|
|
332
|
+
while not stop_event.is_set():
|
|
333
|
+
try:
|
|
334
|
+
if msvcrt.kbhit():
|
|
335
|
+
try:
|
|
336
|
+
# Try to read a character
|
|
337
|
+
# Note: msvcrt.getwch() returns unicode string on Windows
|
|
338
|
+
key = msvcrt.getwch()
|
|
339
|
+
|
|
340
|
+
# Check for Ctrl+X (\x18) or other interrupt keys
|
|
341
|
+
# Some terminals might not send \x18, so also check for 'x' with modifier
|
|
342
|
+
if key == "\x18": # Standard Ctrl+X
|
|
343
|
+
try:
|
|
344
|
+
on_escape()
|
|
345
|
+
except Exception:
|
|
346
|
+
emit_warning(
|
|
347
|
+
"Ctrl+X handler raised unexpectedly; Ctrl+C still works."
|
|
348
|
+
)
|
|
349
|
+
# Note: In some Windows terminals, Ctrl+X might not be captured
|
|
350
|
+
# Users can use Ctrl+C as alternative, which is handled by signal handler
|
|
351
|
+
except (OSError, ValueError):
|
|
352
|
+
# kbhit/getwch can fail on Windows in certain terminal states
|
|
353
|
+
# Just continue, user can use Ctrl+C
|
|
354
|
+
pass
|
|
355
|
+
except Exception:
|
|
356
|
+
# Be silent about Windows listener errors - they're common
|
|
357
|
+
# User can use Ctrl+C as fallback
|
|
358
|
+
pass
|
|
359
|
+
time.sleep(0.05)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _listen_for_ctrl_x_posix(
|
|
363
|
+
stop_event: threading.Event,
|
|
364
|
+
on_escape: Callable[[], None],
|
|
365
|
+
) -> None:
|
|
366
|
+
"""POSIX-specific Ctrl-X listener."""
|
|
367
|
+
import select
|
|
368
|
+
import sys
|
|
369
|
+
import termios
|
|
370
|
+
import tty
|
|
371
|
+
|
|
372
|
+
stdin = sys.stdin
|
|
373
|
+
try:
|
|
374
|
+
fd = stdin.fileno()
|
|
375
|
+
except (AttributeError, ValueError, OSError):
|
|
376
|
+
return
|
|
377
|
+
try:
|
|
378
|
+
original_attrs = termios.tcgetattr(fd)
|
|
379
|
+
except Exception:
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
tty.setcbreak(fd)
|
|
384
|
+
while not stop_event.is_set():
|
|
385
|
+
try:
|
|
386
|
+
read_ready, _, _ = select.select([stdin], [], [], 0.05)
|
|
387
|
+
except Exception:
|
|
388
|
+
break
|
|
389
|
+
if not read_ready:
|
|
390
|
+
continue
|
|
391
|
+
data = stdin.read(1)
|
|
392
|
+
if not data:
|
|
393
|
+
break
|
|
394
|
+
if data == "\x18": # Ctrl+X
|
|
395
|
+
try:
|
|
396
|
+
on_escape()
|
|
397
|
+
except Exception:
|
|
398
|
+
emit_warning(
|
|
399
|
+
"Ctrl+X handler raised unexpectedly; Ctrl+C still works."
|
|
400
|
+
)
|
|
401
|
+
finally:
|
|
402
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, original_attrs)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _spawn_ctrl_x_key_listener(
|
|
406
|
+
stop_event: threading.Event,
|
|
407
|
+
on_escape: Callable[[], None],
|
|
408
|
+
) -> Optional[threading.Thread]:
|
|
409
|
+
"""Start a Ctrl+X key listener thread for CLI sessions."""
|
|
410
|
+
try:
|
|
411
|
+
import sys
|
|
412
|
+
except ImportError:
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
stdin = getattr(sys, "stdin", None)
|
|
416
|
+
if stdin is None or not hasattr(stdin, "isatty"):
|
|
417
|
+
return None
|
|
418
|
+
try:
|
|
419
|
+
if not stdin.isatty():
|
|
420
|
+
return None
|
|
421
|
+
except Exception:
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
def listener() -> None:
|
|
425
|
+
try:
|
|
426
|
+
if sys.platform.startswith("win"):
|
|
427
|
+
_listen_for_ctrl_x_windows(stop_event, on_escape)
|
|
428
|
+
else:
|
|
429
|
+
_listen_for_ctrl_x_posix(stop_event, on_escape)
|
|
430
|
+
except Exception:
|
|
431
|
+
emit_warning(
|
|
432
|
+
"Ctrl+X key listener stopped unexpectedly; press Ctrl+C to cancel."
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
thread = threading.Thread(
|
|
436
|
+
target=listener, name="shell-command-ctrl-x-listener", daemon=True
|
|
437
|
+
)
|
|
438
|
+
thread.start()
|
|
439
|
+
return thread
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@contextmanager
|
|
443
|
+
def _shell_command_keyboard_context():
|
|
444
|
+
"""Context manager to handle keyboard interrupts during shell command execution.
|
|
445
|
+
|
|
446
|
+
This context manager:
|
|
447
|
+
1. Disables the agent's Ctrl-C handler (so it doesn't cancel the agent)
|
|
448
|
+
2. Enables a Ctrl-X listener to kill the running shell process
|
|
449
|
+
3. Restores the original Ctrl-C handler when done
|
|
450
|
+
"""
|
|
451
|
+
global _SHELL_CTRL_X_STOP_EVENT, _SHELL_CTRL_X_THREAD, _ORIGINAL_SIGINT_HANDLER
|
|
452
|
+
|
|
453
|
+
# Handler for Ctrl-X: kill all running shell processes
|
|
454
|
+
def handle_ctrl_x_press() -> None:
|
|
455
|
+
emit_warning("\n🛑 Ctrl-X detected! Interrupting shell command...")
|
|
456
|
+
kill_all_running_shell_processes()
|
|
457
|
+
|
|
458
|
+
# Handler for Ctrl-C during shell execution: just kill the shell process, don't cancel agent
|
|
459
|
+
def shell_sigint_handler(_sig, _frame):
|
|
460
|
+
"""During shell execution, Ctrl-C kills the shell but doesn't cancel the agent."""
|
|
461
|
+
emit_warning("\n🛑 Ctrl-C detected! Interrupting shell command...")
|
|
462
|
+
kill_all_running_shell_processes()
|
|
463
|
+
|
|
464
|
+
# Set up Ctrl-X listener
|
|
465
|
+
_SHELL_CTRL_X_STOP_EVENT = threading.Event()
|
|
466
|
+
_SHELL_CTRL_X_THREAD = _spawn_ctrl_x_key_listener(
|
|
467
|
+
_SHELL_CTRL_X_STOP_EVENT,
|
|
468
|
+
handle_ctrl_x_press,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# Replace SIGINT handler temporarily
|
|
472
|
+
try:
|
|
473
|
+
_ORIGINAL_SIGINT_HANDLER = signal.signal(signal.SIGINT, shell_sigint_handler)
|
|
474
|
+
except (ValueError, OSError):
|
|
475
|
+
# Can't set signal handler (maybe not main thread?)
|
|
476
|
+
_ORIGINAL_SIGINT_HANDLER = None
|
|
477
|
+
|
|
478
|
+
try:
|
|
479
|
+
yield
|
|
480
|
+
finally:
|
|
481
|
+
# Clean up: stop Ctrl-X listener
|
|
482
|
+
if _SHELL_CTRL_X_STOP_EVENT:
|
|
483
|
+
_SHELL_CTRL_X_STOP_EVENT.set()
|
|
484
|
+
|
|
485
|
+
if _SHELL_CTRL_X_THREAD and _SHELL_CTRL_X_THREAD.is_alive():
|
|
486
|
+
try:
|
|
487
|
+
_SHELL_CTRL_X_THREAD.join(timeout=0.2)
|
|
488
|
+
except Exception:
|
|
489
|
+
pass
|
|
490
|
+
|
|
491
|
+
# Restore original SIGINT handler
|
|
492
|
+
if _ORIGINAL_SIGINT_HANDLER is not None:
|
|
493
|
+
try:
|
|
494
|
+
signal.signal(signal.SIGINT, _ORIGINAL_SIGINT_HANDLER)
|
|
495
|
+
except (ValueError, OSError):
|
|
496
|
+
pass
|
|
497
|
+
|
|
498
|
+
# Clean up global state
|
|
499
|
+
_SHELL_CTRL_X_STOP_EVENT = None
|
|
500
|
+
_SHELL_CTRL_X_THREAD = None
|
|
501
|
+
_ORIGINAL_SIGINT_HANDLER = None
|
|
502
|
+
|
|
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()
|
|
178
605
|
|
|
179
606
|
|
|
180
607
|
def run_shell_command_streaming(
|
|
@@ -182,7 +609,11 @@ def run_shell_command_streaming(
|
|
|
182
609
|
timeout: int = 60,
|
|
183
610
|
command: str = "",
|
|
184
611
|
group_id: str = None,
|
|
612
|
+
silent: bool = False,
|
|
185
613
|
):
|
|
614
|
+
global _READER_STOP_EVENT
|
|
615
|
+
_READER_STOP_EVENT = threading.Event()
|
|
616
|
+
|
|
186
617
|
start_time = time.time()
|
|
187
618
|
last_output_time = [start_time]
|
|
188
619
|
|
|
@@ -196,27 +627,138 @@ def run_shell_command_streaming(
|
|
|
196
627
|
|
|
197
628
|
def read_stdout():
|
|
198
629
|
try:
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
630
|
+
fd = process.stdout.fileno()
|
|
631
|
+
except (ValueError, OSError):
|
|
632
|
+
return
|
|
633
|
+
|
|
634
|
+
try:
|
|
635
|
+
while True:
|
|
636
|
+
# Check stop event first
|
|
637
|
+
if _READER_STOP_EVENT and _READER_STOP_EVENT.is_set():
|
|
638
|
+
break
|
|
639
|
+
|
|
640
|
+
# Use select to check if data is available (with timeout)
|
|
641
|
+
if sys.platform.startswith("win"):
|
|
642
|
+
# Windows doesn't support select on pipes
|
|
643
|
+
# Use PeekNamedPipe via _win32_pipe_has_data() to check
|
|
644
|
+
# if data is available without blocking
|
|
645
|
+
try:
|
|
646
|
+
if _win32_pipe_has_data(process.stdout):
|
|
647
|
+
line = process.stdout.readline()
|
|
648
|
+
if not line: # EOF
|
|
649
|
+
break
|
|
650
|
+
line = line.rstrip("\n\r")
|
|
651
|
+
line = _truncate_line(line)
|
|
652
|
+
stdout_lines.append(line)
|
|
653
|
+
if not silent:
|
|
654
|
+
emit_shell_line(line, stream="stdout")
|
|
655
|
+
last_output_time[0] = time.time()
|
|
656
|
+
else:
|
|
657
|
+
# No data available, check if process has exited
|
|
658
|
+
if process.poll() is not None:
|
|
659
|
+
# Process exited, do one final drain
|
|
660
|
+
try:
|
|
661
|
+
remaining = process.stdout.read()
|
|
662
|
+
if remaining:
|
|
663
|
+
for line in remaining.splitlines():
|
|
664
|
+
line = _truncate_line(line)
|
|
665
|
+
stdout_lines.append(line)
|
|
666
|
+
if not silent:
|
|
667
|
+
emit_shell_line(line, stream="stdout")
|
|
668
|
+
except (ValueError, OSError):
|
|
669
|
+
pass
|
|
670
|
+
break
|
|
671
|
+
# Sleep briefly to avoid busy-waiting (100ms like POSIX)
|
|
672
|
+
time.sleep(0.1)
|
|
673
|
+
except (ValueError, OSError):
|
|
674
|
+
break
|
|
675
|
+
else:
|
|
676
|
+
# POSIX: use select with timeout
|
|
677
|
+
try:
|
|
678
|
+
ready, _, _ = select.select([fd], [], [], 0.1) # 100ms timeout
|
|
679
|
+
except (ValueError, OSError, select.error):
|
|
680
|
+
break
|
|
681
|
+
|
|
682
|
+
if ready:
|
|
683
|
+
line = process.stdout.readline()
|
|
684
|
+
if not line: # EOF
|
|
685
|
+
break
|
|
686
|
+
line = line.rstrip("\n\r")
|
|
687
|
+
line = _truncate_line(line)
|
|
688
|
+
stdout_lines.append(line)
|
|
689
|
+
if not silent:
|
|
690
|
+
emit_shell_line(line, stream="stdout")
|
|
691
|
+
last_output_time[0] = time.time()
|
|
692
|
+
# If not ready, loop continues and checks stop event again
|
|
693
|
+
except (ValueError, OSError):
|
|
694
|
+
pass
|
|
207
695
|
except Exception:
|
|
208
696
|
pass
|
|
209
697
|
|
|
210
698
|
def read_stderr():
|
|
211
699
|
try:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
700
|
+
fd = process.stderr.fileno()
|
|
701
|
+
except (ValueError, OSError):
|
|
702
|
+
return
|
|
703
|
+
|
|
704
|
+
try:
|
|
705
|
+
while True:
|
|
706
|
+
# Check stop event first
|
|
707
|
+
if _READER_STOP_EVENT and _READER_STOP_EVENT.is_set():
|
|
708
|
+
break
|
|
709
|
+
|
|
710
|
+
if sys.platform.startswith("win"):
|
|
711
|
+
# Windows doesn't support select on pipes
|
|
712
|
+
# Use PeekNamedPipe via _win32_pipe_has_data() to check
|
|
713
|
+
# if data is available without blocking
|
|
714
|
+
try:
|
|
715
|
+
if _win32_pipe_has_data(process.stderr):
|
|
716
|
+
line = process.stderr.readline()
|
|
717
|
+
if not line: # EOF
|
|
718
|
+
break
|
|
719
|
+
line = line.rstrip("\n\r")
|
|
720
|
+
line = _truncate_line(line)
|
|
721
|
+
stderr_lines.append(line)
|
|
722
|
+
if not silent:
|
|
723
|
+
emit_shell_line(line, stream="stderr")
|
|
724
|
+
last_output_time[0] = time.time()
|
|
725
|
+
else:
|
|
726
|
+
# No data available, check if process has exited
|
|
727
|
+
if process.poll() is not None:
|
|
728
|
+
# Process exited, do one final drain
|
|
729
|
+
try:
|
|
730
|
+
remaining = process.stderr.read()
|
|
731
|
+
if remaining:
|
|
732
|
+
for line in remaining.splitlines():
|
|
733
|
+
line = _truncate_line(line)
|
|
734
|
+
stderr_lines.append(line)
|
|
735
|
+
if not silent:
|
|
736
|
+
emit_shell_line(line, stream="stderr")
|
|
737
|
+
except (ValueError, OSError):
|
|
738
|
+
pass
|
|
739
|
+
break
|
|
740
|
+
# Sleep briefly to avoid busy-waiting (100ms like POSIX)
|
|
741
|
+
time.sleep(0.1)
|
|
742
|
+
except (ValueError, OSError):
|
|
743
|
+
break
|
|
744
|
+
else:
|
|
745
|
+
try:
|
|
746
|
+
ready, _, _ = select.select([fd], [], [], 0.1)
|
|
747
|
+
except (ValueError, OSError, select.error):
|
|
748
|
+
break
|
|
749
|
+
|
|
750
|
+
if ready:
|
|
751
|
+
line = process.stderr.readline()
|
|
752
|
+
if not line: # EOF
|
|
753
|
+
break
|
|
754
|
+
line = line.rstrip("\n\r")
|
|
755
|
+
line = _truncate_line(line)
|
|
756
|
+
stderr_lines.append(line)
|
|
757
|
+
if not silent:
|
|
758
|
+
emit_shell_line(line, stream="stderr")
|
|
759
|
+
last_output_time[0] = time.time()
|
|
760
|
+
except (ValueError, OSError):
|
|
761
|
+
pass
|
|
220
762
|
except Exception:
|
|
221
763
|
pass
|
|
222
764
|
|
|
@@ -227,6 +769,10 @@ def run_shell_command_streaming(
|
|
|
227
769
|
_kill_process_group(proc)
|
|
228
770
|
|
|
229
771
|
try:
|
|
772
|
+
# Signal reader threads to stop first
|
|
773
|
+
if _READER_STOP_EVENT:
|
|
774
|
+
_READER_STOP_EVENT.set()
|
|
775
|
+
|
|
230
776
|
if process.poll() is None:
|
|
231
777
|
nuclear_kill(process)
|
|
232
778
|
|
|
@@ -245,7 +791,7 @@ def run_shell_command_streaming(
|
|
|
245
791
|
|
|
246
792
|
if stdout_thread and stdout_thread.is_alive():
|
|
247
793
|
stdout_thread.join(timeout=3)
|
|
248
|
-
if stdout_thread.is_alive():
|
|
794
|
+
if stdout_thread.is_alive() and not silent:
|
|
249
795
|
emit_warning(
|
|
250
796
|
f"stdout reader thread failed to terminate after {timeout_type} timeout",
|
|
251
797
|
message_group=group_id,
|
|
@@ -253,14 +799,17 @@ def run_shell_command_streaming(
|
|
|
253
799
|
|
|
254
800
|
if stderr_thread and stderr_thread.is_alive():
|
|
255
801
|
stderr_thread.join(timeout=3)
|
|
256
|
-
if stderr_thread.is_alive():
|
|
802
|
+
if stderr_thread.is_alive() and not silent:
|
|
257
803
|
emit_warning(
|
|
258
804
|
f"stderr reader thread failed to terminate after {timeout_type} timeout",
|
|
259
805
|
message_group=group_id,
|
|
260
806
|
)
|
|
261
807
|
|
|
262
808
|
except Exception as e:
|
|
263
|
-
|
|
809
|
+
if not silent:
|
|
810
|
+
emit_warning(
|
|
811
|
+
f"Error during process cleanup: {e}", message_group=group_id
|
|
812
|
+
)
|
|
264
813
|
|
|
265
814
|
execution_time = time.time() - start_time
|
|
266
815
|
return ShellCommandOutput(
|
|
@@ -287,19 +836,19 @@ def run_shell_command_streaming(
|
|
|
287
836
|
current_time = time.time()
|
|
288
837
|
|
|
289
838
|
if current_time - start_time > ABSOLUTE_TIMEOUT_SECONDS:
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
839
|
+
if not silent:
|
|
840
|
+
emit_error(
|
|
841
|
+
"Process killed: absolute timeout reached",
|
|
842
|
+
message_group=group_id,
|
|
843
|
+
)
|
|
295
844
|
return cleanup_process_and_threads("absolute")
|
|
296
845
|
|
|
297
846
|
if current_time - last_output_time[0] > timeout:
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
847
|
+
if not silent:
|
|
848
|
+
emit_error(
|
|
849
|
+
"Process killed: inactivity timeout reached",
|
|
850
|
+
message_group=group_id,
|
|
851
|
+
)
|
|
303
852
|
return cleanup_process_and_threads("inactivity")
|
|
304
853
|
|
|
305
854
|
time.sleep(0.1)
|
|
@@ -324,16 +873,26 @@ def run_shell_command_streaming(
|
|
|
324
873
|
|
|
325
874
|
_unregister_process(process)
|
|
326
875
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
876
|
+
# Apply line length limits to stdout/stderr before returning
|
|
877
|
+
truncated_stdout = [_truncate_line(line) for line in stdout_lines[-256:]]
|
|
878
|
+
truncated_stderr = [_truncate_line(line) for line in stderr_lines[-256:]]
|
|
879
|
+
|
|
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,
|
|
330
888
|
)
|
|
331
|
-
|
|
889
|
+
get_message_bus().emit(shell_output_msg)
|
|
890
|
+
|
|
891
|
+
# Reset the stop event now that we're done
|
|
892
|
+
_READER_STOP_EVENT = None
|
|
893
|
+
|
|
894
|
+
if exit_code != 0:
|
|
332
895
|
time.sleep(1)
|
|
333
|
-
# Apply line length limits to stdout/stderr before returning
|
|
334
|
-
truncated_stdout = [_truncate_line(line) for line in stdout_lines[-256:]]
|
|
335
|
-
truncated_stderr = [_truncate_line(line) for line in stderr_lines[-256:]]
|
|
336
|
-
|
|
337
896
|
return ShellCommandOutput(
|
|
338
897
|
success=False,
|
|
339
898
|
command=command,
|
|
@@ -346,12 +905,9 @@ def run_shell_command_streaming(
|
|
|
346
905
|
timeout=False,
|
|
347
906
|
user_interrupted=process.pid in _USER_KILLED_PROCESSES,
|
|
348
907
|
)
|
|
349
|
-
|
|
350
|
-
truncated_stdout = [_truncate_line(line) for line in stdout_lines[-256:]]
|
|
351
|
-
truncated_stderr = [_truncate_line(line) for line in stderr_lines[-256:]]
|
|
352
|
-
|
|
908
|
+
|
|
353
909
|
return ShellCommandOutput(
|
|
354
|
-
success=
|
|
910
|
+
success=True,
|
|
355
911
|
command=command,
|
|
356
912
|
stdout="\n".join(truncated_stdout),
|
|
357
913
|
stderr="\n".join(truncated_stderr),
|
|
@@ -361,6 +917,8 @@ def run_shell_command_streaming(
|
|
|
361
917
|
)
|
|
362
918
|
|
|
363
919
|
except Exception as e:
|
|
920
|
+
# Reset the stop event on exception too
|
|
921
|
+
_READER_STOP_EVENT = None
|
|
364
922
|
return ShellCommandOutput(
|
|
365
923
|
success=False,
|
|
366
924
|
command=command,
|
|
@@ -372,33 +930,139 @@ def run_shell_command_streaming(
|
|
|
372
930
|
)
|
|
373
931
|
|
|
374
932
|
|
|
375
|
-
def run_shell_command(
|
|
376
|
-
context: RunContext,
|
|
933
|
+
async def run_shell_command(
|
|
934
|
+
context: RunContext,
|
|
935
|
+
command: str,
|
|
936
|
+
cwd: str = None,
|
|
937
|
+
timeout: int = 60,
|
|
938
|
+
background: bool = False,
|
|
377
939
|
) -> ShellCommandOutput:
|
|
378
|
-
|
|
940
|
+
time.time()
|
|
379
941
|
|
|
380
942
|
# Generate unique group_id for this command execution
|
|
381
943
|
group_id = generate_group_id("shell_command", command)
|
|
382
944
|
|
|
945
|
+
# Invoke safety check callbacks (only active in yolo_mode)
|
|
946
|
+
# This allows plugins to intercept and assess commands before execution
|
|
947
|
+
from code_puppy.callbacks import on_run_shell_command
|
|
948
|
+
|
|
949
|
+
callback_results = await on_run_shell_command(context, command, cwd, timeout)
|
|
950
|
+
|
|
951
|
+
# Check if any callback blocked the command
|
|
952
|
+
# Callbacks can return None (allow) or a dict with blocked=True (reject)
|
|
953
|
+
for result in callback_results:
|
|
954
|
+
if result and isinstance(result, dict) and result.get("blocked"):
|
|
955
|
+
return ShellCommandOutput(
|
|
956
|
+
success=False,
|
|
957
|
+
command=command,
|
|
958
|
+
error=result.get("error_message", "Command blocked by safety check"),
|
|
959
|
+
user_feedback=result.get("reasoning", ""),
|
|
960
|
+
stdout=None,
|
|
961
|
+
stderr=None,
|
|
962
|
+
exit_code=None,
|
|
963
|
+
execution_time=None,
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
# Handle background execution - runs command detached and returns immediately
|
|
967
|
+
# This happens BEFORE user confirmation since we don't wait for the command
|
|
968
|
+
if background:
|
|
969
|
+
# Create temp log file for output
|
|
970
|
+
log_file = tempfile.NamedTemporaryFile(
|
|
971
|
+
mode="w",
|
|
972
|
+
prefix="shell_bg_",
|
|
973
|
+
suffix=".log",
|
|
974
|
+
delete=False, # Keep file so agent can read it later
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
try:
|
|
978
|
+
# Platform-specific process detachment
|
|
979
|
+
if sys.platform.startswith("win"):
|
|
980
|
+
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
981
|
+
process = subprocess.Popen(
|
|
982
|
+
command,
|
|
983
|
+
shell=True,
|
|
984
|
+
stdout=log_file,
|
|
985
|
+
stderr=subprocess.STDOUT,
|
|
986
|
+
stdin=subprocess.DEVNULL,
|
|
987
|
+
cwd=cwd,
|
|
988
|
+
creationflags=creationflags,
|
|
989
|
+
)
|
|
990
|
+
else:
|
|
991
|
+
process = subprocess.Popen(
|
|
992
|
+
command,
|
|
993
|
+
shell=True,
|
|
994
|
+
stdout=log_file,
|
|
995
|
+
stderr=subprocess.STDOUT,
|
|
996
|
+
stdin=subprocess.DEVNULL,
|
|
997
|
+
cwd=cwd,
|
|
998
|
+
start_new_session=True, # Fully detach on POSIX
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
log_file.close() # Close our handle, process keeps writing
|
|
1002
|
+
|
|
1003
|
+
# Emit UI messages so user sees what happened
|
|
1004
|
+
bus = get_message_bus()
|
|
1005
|
+
bus.emit(
|
|
1006
|
+
ShellStartMessage(
|
|
1007
|
+
command=command,
|
|
1008
|
+
cwd=cwd,
|
|
1009
|
+
timeout=0, # No timeout for background processes
|
|
1010
|
+
background=True,
|
|
1011
|
+
)
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
# Emit info about background execution
|
|
1015
|
+
emit_info(
|
|
1016
|
+
f"🚀 Background process started (PID: {process.pid}) - no timeout, runs until complete"
|
|
1017
|
+
)
|
|
1018
|
+
emit_info(f"📄 Output logging to: {log_file.name}")
|
|
1019
|
+
|
|
1020
|
+
# Return immediately - don't wait, don't block
|
|
1021
|
+
return ShellCommandOutput(
|
|
1022
|
+
success=True,
|
|
1023
|
+
command=command,
|
|
1024
|
+
stdout=None,
|
|
1025
|
+
stderr=None,
|
|
1026
|
+
exit_code=None,
|
|
1027
|
+
execution_time=0.0,
|
|
1028
|
+
background=True,
|
|
1029
|
+
log_file=log_file.name,
|
|
1030
|
+
pid=process.pid,
|
|
1031
|
+
)
|
|
1032
|
+
except Exception as e:
|
|
1033
|
+
log_file.close()
|
|
1034
|
+
# Emit error message so user sees what happened
|
|
1035
|
+
emit_error(f"❌ Failed to start background process: {e}")
|
|
1036
|
+
return ShellCommandOutput(
|
|
1037
|
+
success=False,
|
|
1038
|
+
command=command,
|
|
1039
|
+
error=f"Failed to start background process: {e}",
|
|
1040
|
+
stdout=None,
|
|
1041
|
+
stderr=None,
|
|
1042
|
+
exit_code=None,
|
|
1043
|
+
execution_time=None,
|
|
1044
|
+
background=True,
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
# Rest of the existing function continues...
|
|
383
1048
|
if not command or not command.strip():
|
|
384
1049
|
emit_error("Command cannot be empty", message_group=group_id)
|
|
385
1050
|
return ShellCommandOutput(
|
|
386
1051
|
**{"success": False, "error": "Command cannot be empty"}
|
|
387
1052
|
)
|
|
388
1053
|
|
|
389
|
-
emit_info(
|
|
390
|
-
f"\n[bold white on blue] SHELL COMMAND [/bold white on blue] 📂 [bold green]$ {command}[/bold green]",
|
|
391
|
-
message_group=group_id,
|
|
392
|
-
)
|
|
393
|
-
|
|
394
1054
|
from code_puppy.config import get_yolo_mode
|
|
395
1055
|
|
|
396
1056
|
yolo_mode = get_yolo_mode()
|
|
397
1057
|
|
|
1058
|
+
# Check if we're running as a sub-agent (skip confirmation and run silently)
|
|
1059
|
+
running_as_subagent = is_subagent()
|
|
1060
|
+
|
|
398
1061
|
confirmation_lock_acquired = False
|
|
399
1062
|
|
|
400
|
-
# Only ask for confirmation if we're in an interactive TTY
|
|
401
|
-
|
|
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():
|
|
402
1066
|
confirmation_lock_acquired = _CONFIRMATION_LOCK.acquire(blocking=False)
|
|
403
1067
|
if not confirmation_lock_acquired:
|
|
404
1068
|
return ShellCommandOutput(
|
|
@@ -407,87 +1071,188 @@ def run_shell_command(
|
|
|
407
1071
|
error="Another command is currently awaiting confirmation",
|
|
408
1072
|
)
|
|
409
1073
|
|
|
410
|
-
|
|
1074
|
+
# Get puppy name for personalized messages
|
|
1075
|
+
from code_puppy.config import get_puppy_name
|
|
411
1076
|
|
|
412
|
-
|
|
413
|
-
emit_info(f"[dim] Working directory: {cwd} [/dim]", message_group=group_id)
|
|
1077
|
+
puppy_name = get_puppy_name().title()
|
|
414
1078
|
|
|
415
|
-
#
|
|
416
|
-
|
|
1079
|
+
# Build panel content
|
|
1080
|
+
panel_content = Text()
|
|
1081
|
+
panel_content.append("⚡ Requesting permission to run:\n", style="bold yellow")
|
|
1082
|
+
panel_content.append("$ ", style="bold green")
|
|
1083
|
+
panel_content.append(command, style="bold white")
|
|
417
1084
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
1085
|
+
if cwd:
|
|
1086
|
+
panel_content.append("\n\n", style="")
|
|
1087
|
+
panel_content.append("📂 Working directory: ", style="dim")
|
|
1088
|
+
panel_content.append(cwd, style="dim cyan")
|
|
1089
|
+
|
|
1090
|
+
# Use the common approval function (async version)
|
|
1091
|
+
confirmed, user_feedback = await get_user_approval_async(
|
|
1092
|
+
title="Shell Command",
|
|
1093
|
+
content=panel_content,
|
|
1094
|
+
preview=None,
|
|
1095
|
+
border_style="dim white",
|
|
1096
|
+
puppy_name=puppy_name,
|
|
1097
|
+
)
|
|
421
1098
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
except (KeyboardInterrupt, EOFError):
|
|
426
|
-
emit_warning("\n Cancelled by user")
|
|
427
|
-
confirmed = False
|
|
428
|
-
finally:
|
|
429
|
-
# Clear the flag regardless of the outcome
|
|
430
|
-
set_awaiting_user_input(False)
|
|
431
|
-
if confirmation_lock_acquired:
|
|
432
|
-
_CONFIRMATION_LOCK.release()
|
|
1099
|
+
# Release lock after approval
|
|
1100
|
+
if confirmation_lock_acquired:
|
|
1101
|
+
_CONFIRMATION_LOCK.release()
|
|
433
1102
|
|
|
434
1103
|
if not confirmed:
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
1104
|
+
if user_feedback:
|
|
1105
|
+
result = ShellCommandOutput(
|
|
1106
|
+
success=False,
|
|
1107
|
+
command=command,
|
|
1108
|
+
error=f"USER REJECTED: {user_feedback}",
|
|
1109
|
+
user_feedback=user_feedback,
|
|
1110
|
+
stdout=None,
|
|
1111
|
+
stderr=None,
|
|
1112
|
+
exit_code=None,
|
|
1113
|
+
execution_time=None,
|
|
1114
|
+
)
|
|
1115
|
+
else:
|
|
1116
|
+
result = ShellCommandOutput(
|
|
1117
|
+
success=False,
|
|
1118
|
+
command=command,
|
|
1119
|
+
error="User rejected the command!",
|
|
1120
|
+
stdout=None,
|
|
1121
|
+
stderr=None,
|
|
1122
|
+
exit_code=None,
|
|
1123
|
+
execution_time=None,
|
|
1124
|
+
)
|
|
438
1125
|
return result
|
|
439
1126
|
else:
|
|
440
|
-
|
|
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
|
+
)
|
|
441
1137
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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,
|
|
459
1163
|
cwd=cwd,
|
|
460
|
-
|
|
461
|
-
universal_newlines=True,
|
|
462
|
-
preexec_fn=preexec_fn,
|
|
463
|
-
creationflags=creationflags,
|
|
1164
|
+
timeout=timeout,
|
|
464
1165
|
)
|
|
465
|
-
|
|
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()
|
|
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"):
|
|
466
1188
|
try:
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
1189
|
+
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
|
|
1190
|
+
except Exception:
|
|
1191
|
+
creationflags = 0
|
|
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)
|
|
1215
|
+
|
|
1216
|
+
|
|
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
|
+
)
|
|
473
1233
|
except Exception as e:
|
|
474
|
-
|
|
1234
|
+
if not silent:
|
|
1235
|
+
emit_error(traceback.format_exc(), message_group=group_id)
|
|
475
1236
|
if "stdout" not in locals():
|
|
476
1237
|
stdout = None
|
|
477
1238
|
if "stderr" not in locals():
|
|
478
1239
|
stderr = None
|
|
479
|
-
|
|
1240
|
+
|
|
480
1241
|
# Apply line length limits to stdout/stderr if they exist
|
|
481
1242
|
truncated_stdout = None
|
|
482
1243
|
if stdout:
|
|
483
1244
|
stdout_lines = stdout.split("\n")
|
|
484
|
-
truncated_stdout = "\n".join(
|
|
485
|
-
|
|
1245
|
+
truncated_stdout = "\n".join(
|
|
1246
|
+
[_truncate_line(line) for line in stdout_lines[-256:]]
|
|
1247
|
+
)
|
|
1248
|
+
|
|
486
1249
|
truncated_stderr = None
|
|
487
1250
|
if stderr:
|
|
488
1251
|
stderr_lines = stderr.split("\n")
|
|
489
|
-
truncated_stderr = "\n".join(
|
|
490
|
-
|
|
1252
|
+
truncated_stderr = "\n".join(
|
|
1253
|
+
[_truncate_line(line) for line in stderr_lines[-256:]]
|
|
1254
|
+
)
|
|
1255
|
+
|
|
491
1256
|
return ShellCommandOutput(
|
|
492
1257
|
success=False,
|
|
493
1258
|
command=command,
|
|
@@ -504,36 +1269,37 @@ class ReasoningOutput(BaseModel):
|
|
|
504
1269
|
|
|
505
1270
|
|
|
506
1271
|
def share_your_reasoning(
|
|
507
|
-
context: RunContext, reasoning: str, next_steps: str | None = None
|
|
1272
|
+
context: RunContext, reasoning: str, next_steps: str | List[str] | None = None
|
|
508
1273
|
) -> ReasoningOutput:
|
|
509
|
-
#
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
if not is_tui_mode():
|
|
515
|
-
emit_divider(message_group=group_id)
|
|
516
|
-
emit_info(
|
|
517
|
-
"\n[bold white on purple] AGENT REASONING [/bold white on purple]",
|
|
518
|
-
message_group=group_id,
|
|
519
|
-
)
|
|
520
|
-
emit_info("[bold cyan]Current reasoning:[/bold cyan]", message_group=group_id)
|
|
521
|
-
emit_system_message(Markdown(reasoning), message_group=group_id)
|
|
522
|
-
if next_steps is not None and next_steps.strip():
|
|
523
|
-
emit_info(
|
|
524
|
-
"\n[bold cyan]Planned next steps:[/bold cyan]", message_group=group_id
|
|
1274
|
+
# Handle list of next steps by formatting them
|
|
1275
|
+
formatted_next_steps = next_steps
|
|
1276
|
+
if isinstance(next_steps, list):
|
|
1277
|
+
formatted_next_steps = "\n".join(
|
|
1278
|
+
[f"{i + 1}. {step}" for i, step in enumerate(next_steps)]
|
|
525
1279
|
)
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
1280
|
+
|
|
1281
|
+
# Emit structured AgentReasoningMessage for the UI
|
|
1282
|
+
reasoning_msg = AgentReasoningMessage(
|
|
1283
|
+
reasoning=reasoning,
|
|
1284
|
+
next_steps=formatted_next_steps
|
|
1285
|
+
if formatted_next_steps and formatted_next_steps.strip()
|
|
1286
|
+
else None,
|
|
1287
|
+
)
|
|
1288
|
+
get_message_bus().emit(reasoning_msg)
|
|
1289
|
+
|
|
1290
|
+
return ReasoningOutput(success=True)
|
|
529
1291
|
|
|
530
1292
|
|
|
531
1293
|
def register_agent_run_shell_command(agent):
|
|
532
1294
|
"""Register only the agent_run_shell_command tool."""
|
|
533
1295
|
|
|
534
1296
|
@agent.tool
|
|
535
|
-
def agent_run_shell_command(
|
|
536
|
-
context: RunContext,
|
|
1297
|
+
async def agent_run_shell_command(
|
|
1298
|
+
context: RunContext,
|
|
1299
|
+
command: str = "",
|
|
1300
|
+
cwd: str = None,
|
|
1301
|
+
timeout: int = 60,
|
|
1302
|
+
background: bool = False,
|
|
537
1303
|
) -> ShellCommandOutput:
|
|
538
1304
|
"""Execute a shell command with comprehensive monitoring and safety features.
|
|
539
1305
|
|
|
@@ -549,6 +1315,14 @@ def register_agent_run_shell_command(agent):
|
|
|
549
1315
|
timeout: Inactivity timeout in seconds. If no output is
|
|
550
1316
|
produced for this duration, the process will be terminated.
|
|
551
1317
|
Defaults to 60 seconds.
|
|
1318
|
+
background: If True, run the command in the background and return immediately.
|
|
1319
|
+
The command output will be written to a temporary log file.
|
|
1320
|
+
Use this for long-running processes like servers (npm run dev, python -m http.server),
|
|
1321
|
+
or any command you don't need to wait for.
|
|
1322
|
+
When background=True, the response includes:
|
|
1323
|
+
- log_file: Path to temp file containing stdout/stderr (read with read_file tool)
|
|
1324
|
+
- pid: Process ID of the background process
|
|
1325
|
+
Defaults to False.
|
|
552
1326
|
|
|
553
1327
|
Returns:
|
|
554
1328
|
ShellCommandOutput: A structured response containing:
|
|
@@ -561,6 +1335,9 @@ def register_agent_run_shell_command(agent):
|
|
|
561
1335
|
- execution_time (float | None): Total execution time in seconds
|
|
562
1336
|
- timeout (bool | None): True if command was terminated due to timeout
|
|
563
1337
|
- user_interrupted (bool | None): True if user killed the process
|
|
1338
|
+
- background (bool): True if command was run in background mode
|
|
1339
|
+
- log_file (str | None): Path to temp log file for background commands
|
|
1340
|
+
- pid (int | None): Process ID for background commands
|
|
564
1341
|
|
|
565
1342
|
Examples:
|
|
566
1343
|
>>> # Basic command execution
|
|
@@ -577,11 +1354,16 @@ def register_agent_run_shell_command(agent):
|
|
|
577
1354
|
>>> if result.timeout:
|
|
578
1355
|
... print("Command timed out")
|
|
579
1356
|
|
|
1357
|
+
>>> # Background command for long-running server
|
|
1358
|
+
>>> result = agent_run_shell_command(ctx, "npm run dev", background=True)
|
|
1359
|
+
>>> print(f"Server started with PID {result.pid}")
|
|
1360
|
+
>>> print(f"Logs available at: {result.log_file}")
|
|
1361
|
+
|
|
580
1362
|
Warning:
|
|
581
1363
|
This tool can execute arbitrary shell commands. Exercise caution when
|
|
582
1364
|
running untrusted commands, especially those that modify system state.
|
|
583
1365
|
"""
|
|
584
|
-
return run_shell_command(context, command, cwd, timeout)
|
|
1366
|
+
return await run_shell_command(context, command, cwd, timeout, background)
|
|
585
1367
|
|
|
586
1368
|
|
|
587
1369
|
def register_agent_share_your_reasoning(agent):
|
|
@@ -589,7 +1371,9 @@ def register_agent_share_your_reasoning(agent):
|
|
|
589
1371
|
|
|
590
1372
|
@agent.tool
|
|
591
1373
|
def agent_share_your_reasoning(
|
|
592
|
-
context: RunContext,
|
|
1374
|
+
context: RunContext,
|
|
1375
|
+
reasoning: str = "",
|
|
1376
|
+
next_steps: str | List[str] | None = None,
|
|
593
1377
|
) -> ReasoningOutput:
|
|
594
1378
|
"""Share the agent's current reasoning and planned next steps with the user.
|
|
595
1379
|
|
|
@@ -603,8 +1387,8 @@ def register_agent_share_your_reasoning(agent):
|
|
|
603
1387
|
reasoning for the current situation. This should be clear,
|
|
604
1388
|
comprehensive, and explain the 'why' behind decisions.
|
|
605
1389
|
next_steps: Planned upcoming actions or steps
|
|
606
|
-
the agent intends to take. Can be
|
|
607
|
-
are determined. Defaults to None.
|
|
1390
|
+
the agent intends to take. Can be a string or a list of strings.
|
|
1391
|
+
Can be None if no specific next steps are determined. Defaults to None.
|
|
608
1392
|
|
|
609
1393
|
Returns:
|
|
610
1394
|
ReasoningOutput: A simple response object containing:
|
|
@@ -615,6 +1399,10 @@ def register_agent_share_your_reasoning(agent):
|
|
|
615
1399
|
>>> next_steps = "First, I'll list the directory contents, then read key files"
|
|
616
1400
|
>>> result = agent_share_your_reasoning(ctx, reasoning, next_steps)
|
|
617
1401
|
|
|
1402
|
+
>>> # Using a list for next steps
|
|
1403
|
+
>>> next_steps_list = ["List files", "Read README.md", "Run tests"]
|
|
1404
|
+
>>> result = agent_share_your_reasoning(ctx, reasoning, next_steps_list)
|
|
1405
|
+
|
|
618
1406
|
Best Practice:
|
|
619
1407
|
Use this tool frequently to maintain transparency. Call it:
|
|
620
1408
|
- Before starting complex operations
|