code-puppy 0.0.214__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 +2 -0
- code_puppy/agents/agent_c_reviewer.py +59 -6
- code_puppy/agents/agent_code_puppy.py +7 -1
- code_puppy/agents/agent_code_reviewer.py +12 -2
- code_puppy/agents/agent_cpp_reviewer.py +73 -6
- code_puppy/agents/agent_creator_agent.py +45 -4
- code_puppy/agents/agent_golang_reviewer.py +92 -3
- code_puppy/agents/agent_javascript_reviewer.py +101 -8
- code_puppy/agents/agent_manager.py +81 -4
- 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 +28 -6
- code_puppy/agents/agent_qa_expert.py +98 -6
- code_puppy/agents/agent_qa_kitten.py +12 -7
- code_puppy/agents/agent_security_auditor.py +113 -3
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +106 -7
- code_puppy/agents/base_agent.py +802 -176
- code_puppy/agents/event_stream_handler.py +350 -0
- 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 +142 -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 +10 -5
- 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 +176 -738
- 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 +0 -3
- 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 +15 -26
- code_puppy/command_line/mcp/install_menu.py +685 -0
- code_puppy/command_line/mcp/list_command.py +2 -2
- 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 +16 -10
- code_puppy/command_line/mcp/start_all_command.py +18 -6
- code_puppy/command_line/mcp/start_command.py +47 -25
- code_puppy/command_line/mcp/status_command.py +4 -5
- code_puppy/command_line/mcp/stop_all_command.py +7 -1
- code_puppy/command_line/mcp/stop_command.py +8 -4
- code_puppy/command_line/mcp/test_command.py +2 -2
- code_puppy/command_line/mcp/wizard_utils.py +20 -16
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +75 -25
- 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 +463 -63
- code_puppy/command_line/session_commands.py +296 -0
- code_puppy/command_line/utils.py +54 -0
- code_puppy/config.py +898 -112
- 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 +210 -148
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +5 -698
- code_puppy/mcp_/__init__.py +17 -0
- code_puppy/mcp_/async_lifecycle.py +35 -4
- code_puppy/mcp_/blocking_startup.py +70 -43
- code_puppy/mcp_/captured_stdio_server.py +2 -2
- code_puppy/mcp_/config_wizard.py +4 -4
- code_puppy/mcp_/dashboard.py +15 -6
- code_puppy/mcp_/managed_server.py +65 -38
- code_puppy/mcp_/manager.py +146 -52
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/mcp_/registry.py +6 -6
- code_puppy/mcp_/server_registry_catalog.py +24 -5
- 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 +21 -5
- code_puppy/messaging/spinner/console_spinner.py +86 -51
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_factory.py +634 -83
- code_puppy/model_utils.py +167 -0
- code_puppy/models.json +66 -68
- 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 +2 -2
- 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 +9 -12
- code_puppy/session_storage.py +2 -1
- code_puppy/status_display.py +21 -4
- code_puppy/summarization_agent.py +41 -13
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +37 -1
- code_puppy/tools/agent_tools.py +536 -52
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +19 -23
- code_puppy/tools/browser/browser_interactions.py +41 -48
- code_puppy/tools/browser/browser_locators.py +36 -38
- code_puppy/tools/browser/browser_manager.py +316 -0
- code_puppy/tools/browser/browser_navigation.py +16 -16
- code_puppy/tools/browser/browser_screenshot.py +79 -143
- code_puppy/tools/browser/browser_scripts.py +32 -42
- code_puppy/tools/browser/browser_workflows.py +44 -27
- 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 +930 -147
- code_puppy/tools/common.py +1113 -5
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +288 -89
- code_puppy/tools/file_operations.py +226 -154
- 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.214.dist-info → code_puppy-0.0.366.dist-info}/METADATA +149 -75
- code_puppy-0.0.366.dist-info/RECORD +217 -0
- {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
- code_puppy/command_line/mcp/add_command.py +0 -183
- code_puppy/messaging/spinner/textual_spinner.py +0 -106
- code_puppy/tools/browser/camoufox_manager.py +0 -216
- code_puppy/tools/browser/vqa_agent.py +0 -70
- code_puppy/tui/__init__.py +0 -10
- code_puppy/tui/app.py +0 -1105
- code_puppy/tui/components/__init__.py +0 -21
- code_puppy/tui/components/chat_view.py +0 -551
- 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 -185
- 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 -17
- code_puppy/tui/screens/autosave_picker.py +0 -175
- 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 -306
- code_puppy/tui/screens/tools.py +0 -74
- code_puppy/tui_state.py +0 -55
- code_puppy-0.0.214.data/data/code_puppy/models.json +0 -112
- code_puppy-0.0.214.dist-info/RECORD +0 -131
- {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.214.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.tools.common import generate_group_id
|
|
23
|
-
from code_puppy.
|
|
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
|
|
@@ -34,6 +43,60 @@ def _truncate_line(line: str) -> str:
|
|
|
34
43
|
return line
|
|
35
44
|
|
|
36
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
|
+
|
|
37
100
|
_AWAITING_USER_INPUT = False
|
|
38
101
|
|
|
39
102
|
_CONFIRMATION_LOCK = threading.Lock()
|
|
@@ -43,6 +106,22 @@ _RUNNING_PROCESSES: Set[subprocess.Popen] = set()
|
|
|
43
106
|
_RUNNING_PROCESSES_LOCK = threading.Lock()
|
|
44
107
|
_USER_KILLED_PROCESSES = set()
|
|
45
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
|
+
|
|
46
125
|
|
|
47
126
|
def _register_process(proc: subprocess.Popen) -> None:
|
|
48
127
|
with _RUNNING_PROCESSES_LOCK:
|
|
@@ -57,25 +136,32 @@ def _unregister_process(proc: subprocess.Popen) -> None:
|
|
|
57
136
|
def _kill_process_group(proc: subprocess.Popen) -> None:
|
|
58
137
|
"""Attempt to aggressively terminate a process and its group.
|
|
59
138
|
|
|
60
|
-
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.
|
|
61
140
|
"""
|
|
62
141
|
try:
|
|
63
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
|
|
64
145
|
try:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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)
|
|
68
156
|
except Exception:
|
|
157
|
+
# Fallback to Python's built-in methods
|
|
69
158
|
pass
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
proc.terminate()
|
|
73
|
-
time.sleep(0.8)
|
|
74
|
-
except Exception:
|
|
75
|
-
pass
|
|
159
|
+
|
|
160
|
+
# Double-check it's dead, if not use proc.kill()
|
|
76
161
|
if proc.poll() is None:
|
|
77
162
|
try:
|
|
78
163
|
proc.kill()
|
|
164
|
+
time.sleep(0.3)
|
|
79
165
|
except Exception:
|
|
80
166
|
pass
|
|
81
167
|
return
|
|
@@ -115,16 +201,33 @@ def _kill_process_group(proc: subprocess.Popen) -> None:
|
|
|
115
201
|
|
|
116
202
|
|
|
117
203
|
def kill_all_running_shell_processes() -> int:
|
|
118
|
-
"""Kill all currently tracked running shell processes.
|
|
204
|
+
"""Kill all currently tracked running shell processes and stop reader threads.
|
|
119
205
|
|
|
120
206
|
Returns the number of processes signaled.
|
|
121
207
|
"""
|
|
208
|
+
global _READER_STOP_EVENT
|
|
209
|
+
|
|
210
|
+
# Signal reader threads to stop
|
|
211
|
+
if _READER_STOP_EVENT:
|
|
212
|
+
_READER_STOP_EVENT.set()
|
|
213
|
+
|
|
122
214
|
procs: list[subprocess.Popen]
|
|
123
215
|
with _RUNNING_PROCESSES_LOCK:
|
|
124
216
|
procs = list(_RUNNING_PROCESSES)
|
|
125
217
|
count = 0
|
|
126
218
|
for p in procs:
|
|
127
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
|
+
|
|
128
231
|
if p.poll() is None:
|
|
129
232
|
_kill_process_group(p)
|
|
130
233
|
count += 1
|
|
@@ -134,6 +237,21 @@ def kill_all_running_shell_processes() -> int:
|
|
|
134
237
|
return count
|
|
135
238
|
|
|
136
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
|
+
|
|
137
255
|
# Function to check if user input is awaited
|
|
138
256
|
def is_awaiting_user_input():
|
|
139
257
|
"""Check if command_runner is waiting for user input."""
|
|
@@ -176,6 +294,314 @@ class ShellCommandOutput(BaseModel):
|
|
|
176
294
|
execution_time: float | None
|
|
177
295
|
timeout: bool | None = False
|
|
178
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()
|
|
179
605
|
|
|
180
606
|
|
|
181
607
|
def run_shell_command_streaming(
|
|
@@ -183,7 +609,11 @@ def run_shell_command_streaming(
|
|
|
183
609
|
timeout: int = 60,
|
|
184
610
|
command: str = "",
|
|
185
611
|
group_id: str = None,
|
|
612
|
+
silent: bool = False,
|
|
186
613
|
):
|
|
614
|
+
global _READER_STOP_EVENT
|
|
615
|
+
_READER_STOP_EVENT = threading.Event()
|
|
616
|
+
|
|
187
617
|
start_time = time.time()
|
|
188
618
|
last_output_time = [start_time]
|
|
189
619
|
|
|
@@ -197,27 +627,138 @@ def run_shell_command_streaming(
|
|
|
197
627
|
|
|
198
628
|
def read_stdout():
|
|
199
629
|
try:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
208
695
|
except Exception:
|
|
209
696
|
pass
|
|
210
697
|
|
|
211
698
|
def read_stderr():
|
|
212
699
|
try:
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
221
762
|
except Exception:
|
|
222
763
|
pass
|
|
223
764
|
|
|
@@ -228,6 +769,10 @@ def run_shell_command_streaming(
|
|
|
228
769
|
_kill_process_group(proc)
|
|
229
770
|
|
|
230
771
|
try:
|
|
772
|
+
# Signal reader threads to stop first
|
|
773
|
+
if _READER_STOP_EVENT:
|
|
774
|
+
_READER_STOP_EVENT.set()
|
|
775
|
+
|
|
231
776
|
if process.poll() is None:
|
|
232
777
|
nuclear_kill(process)
|
|
233
778
|
|
|
@@ -246,7 +791,7 @@ def run_shell_command_streaming(
|
|
|
246
791
|
|
|
247
792
|
if stdout_thread and stdout_thread.is_alive():
|
|
248
793
|
stdout_thread.join(timeout=3)
|
|
249
|
-
if stdout_thread.is_alive():
|
|
794
|
+
if stdout_thread.is_alive() and not silent:
|
|
250
795
|
emit_warning(
|
|
251
796
|
f"stdout reader thread failed to terminate after {timeout_type} timeout",
|
|
252
797
|
message_group=group_id,
|
|
@@ -254,14 +799,17 @@ def run_shell_command_streaming(
|
|
|
254
799
|
|
|
255
800
|
if stderr_thread and stderr_thread.is_alive():
|
|
256
801
|
stderr_thread.join(timeout=3)
|
|
257
|
-
if stderr_thread.is_alive():
|
|
802
|
+
if stderr_thread.is_alive() and not silent:
|
|
258
803
|
emit_warning(
|
|
259
804
|
f"stderr reader thread failed to terminate after {timeout_type} timeout",
|
|
260
805
|
message_group=group_id,
|
|
261
806
|
)
|
|
262
807
|
|
|
263
808
|
except Exception as e:
|
|
264
|
-
|
|
809
|
+
if not silent:
|
|
810
|
+
emit_warning(
|
|
811
|
+
f"Error during process cleanup: {e}", message_group=group_id
|
|
812
|
+
)
|
|
265
813
|
|
|
266
814
|
execution_time = time.time() - start_time
|
|
267
815
|
return ShellCommandOutput(
|
|
@@ -288,19 +836,19 @@ def run_shell_command_streaming(
|
|
|
288
836
|
current_time = time.time()
|
|
289
837
|
|
|
290
838
|
if current_time - start_time > ABSOLUTE_TIMEOUT_SECONDS:
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
839
|
+
if not silent:
|
|
840
|
+
emit_error(
|
|
841
|
+
"Process killed: absolute timeout reached",
|
|
842
|
+
message_group=group_id,
|
|
843
|
+
)
|
|
296
844
|
return cleanup_process_and_threads("absolute")
|
|
297
845
|
|
|
298
846
|
if current_time - last_output_time[0] > timeout:
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
847
|
+
if not silent:
|
|
848
|
+
emit_error(
|
|
849
|
+
"Process killed: inactivity timeout reached",
|
|
850
|
+
message_group=group_id,
|
|
851
|
+
)
|
|
304
852
|
return cleanup_process_and_threads("inactivity")
|
|
305
853
|
|
|
306
854
|
time.sleep(0.1)
|
|
@@ -325,16 +873,26 @@ def run_shell_command_streaming(
|
|
|
325
873
|
|
|
326
874
|
_unregister_process(process)
|
|
327
875
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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,
|
|
331
888
|
)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
truncated_stderr = [_truncate_line(line) for line in stderr_lines[-256:]]
|
|
889
|
+
get_message_bus().emit(shell_output_msg)
|
|
890
|
+
|
|
891
|
+
# Reset the stop event now that we're done
|
|
892
|
+
_READER_STOP_EVENT = None
|
|
337
893
|
|
|
894
|
+
if exit_code != 0:
|
|
895
|
+
time.sleep(1)
|
|
338
896
|
return ShellCommandOutput(
|
|
339
897
|
success=False,
|
|
340
898
|
command=command,
|
|
@@ -347,12 +905,9 @@ def run_shell_command_streaming(
|
|
|
347
905
|
timeout=False,
|
|
348
906
|
user_interrupted=process.pid in _USER_KILLED_PROCESSES,
|
|
349
907
|
)
|
|
350
|
-
# Apply line length limits to stdout/stderr before returning
|
|
351
|
-
truncated_stdout = [_truncate_line(line) for line in stdout_lines[-256:]]
|
|
352
|
-
truncated_stderr = [_truncate_line(line) for line in stderr_lines[-256:]]
|
|
353
908
|
|
|
354
909
|
return ShellCommandOutput(
|
|
355
|
-
success=
|
|
910
|
+
success=True,
|
|
356
911
|
command=command,
|
|
357
912
|
stdout="\n".join(truncated_stdout),
|
|
358
913
|
stderr="\n".join(truncated_stderr),
|
|
@@ -362,6 +917,8 @@ def run_shell_command_streaming(
|
|
|
362
917
|
)
|
|
363
918
|
|
|
364
919
|
except Exception as e:
|
|
920
|
+
# Reset the stop event on exception too
|
|
921
|
+
_READER_STOP_EVENT = None
|
|
365
922
|
return ShellCommandOutput(
|
|
366
923
|
success=False,
|
|
367
924
|
command=command,
|
|
@@ -373,33 +930,139 @@ def run_shell_command_streaming(
|
|
|
373
930
|
)
|
|
374
931
|
|
|
375
932
|
|
|
376
|
-
def run_shell_command(
|
|
377
|
-
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,
|
|
378
939
|
) -> ShellCommandOutput:
|
|
379
|
-
|
|
940
|
+
time.time()
|
|
380
941
|
|
|
381
942
|
# Generate unique group_id for this command execution
|
|
382
943
|
group_id = generate_group_id("shell_command", command)
|
|
383
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...
|
|
384
1048
|
if not command or not command.strip():
|
|
385
1049
|
emit_error("Command cannot be empty", message_group=group_id)
|
|
386
1050
|
return ShellCommandOutput(
|
|
387
1051
|
**{"success": False, "error": "Command cannot be empty"}
|
|
388
1052
|
)
|
|
389
1053
|
|
|
390
|
-
emit_info(
|
|
391
|
-
f"\n[bold white on blue] SHELL COMMAND [/bold white on blue] 📂 [bold green]$ {command}[/bold green]",
|
|
392
|
-
message_group=group_id,
|
|
393
|
-
)
|
|
394
|
-
|
|
395
1054
|
from code_puppy.config import get_yolo_mode
|
|
396
1055
|
|
|
397
1056
|
yolo_mode = get_yolo_mode()
|
|
398
1057
|
|
|
1058
|
+
# Check if we're running as a sub-agent (skip confirmation and run silently)
|
|
1059
|
+
running_as_subagent = is_subagent()
|
|
1060
|
+
|
|
399
1061
|
confirmation_lock_acquired = False
|
|
400
1062
|
|
|
401
|
-
# Only ask for confirmation if we're in an interactive TTY
|
|
402
|
-
|
|
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():
|
|
403
1066
|
confirmation_lock_acquired = _CONFIRMATION_LOCK.acquire(blocking=False)
|
|
404
1067
|
if not confirmation_lock_acquired:
|
|
405
1068
|
return ShellCommandOutput(
|
|
@@ -408,71 +1071,168 @@ def run_shell_command(
|
|
|
408
1071
|
error="Another command is currently awaiting confirmation",
|
|
409
1072
|
)
|
|
410
1073
|
|
|
411
|
-
|
|
1074
|
+
# Get puppy name for personalized messages
|
|
1075
|
+
from code_puppy.config import get_puppy_name
|
|
412
1076
|
|
|
413
|
-
|
|
414
|
-
emit_info(f"[dim] Working directory: {cwd} [/dim]", message_group=group_id)
|
|
1077
|
+
puppy_name = get_puppy_name().title()
|
|
415
1078
|
|
|
416
|
-
#
|
|
417
|
-
|
|
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")
|
|
418
1084
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
+
)
|
|
422
1098
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
except (KeyboardInterrupt, EOFError):
|
|
427
|
-
emit_warning("\n Cancelled by user")
|
|
428
|
-
confirmed = False
|
|
429
|
-
finally:
|
|
430
|
-
# Clear the flag regardless of the outcome
|
|
431
|
-
set_awaiting_user_input(False)
|
|
432
|
-
if confirmation_lock_acquired:
|
|
433
|
-
_CONFIRMATION_LOCK.release()
|
|
1099
|
+
# Release lock after approval
|
|
1100
|
+
if confirmation_lock_acquired:
|
|
1101
|
+
_CONFIRMATION_LOCK.release()
|
|
434
1102
|
|
|
435
1103
|
if not confirmed:
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
+
)
|
|
439
1125
|
return result
|
|
440
1126
|
else:
|
|
441
|
-
|
|
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
|
+
)
|
|
442
1137
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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,
|
|
460
1163
|
cwd=cwd,
|
|
461
|
-
|
|
462
|
-
universal_newlines=True,
|
|
463
|
-
preexec_fn=preexec_fn,
|
|
464
|
-
creationflags=creationflags,
|
|
1164
|
+
timeout=timeout,
|
|
465
1165
|
)
|
|
466
|
-
|
|
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"):
|
|
467
1188
|
try:
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
+
)
|
|
474
1233
|
except Exception as e:
|
|
475
|
-
|
|
1234
|
+
if not silent:
|
|
1235
|
+
emit_error(traceback.format_exc(), message_group=group_id)
|
|
476
1236
|
if "stdout" not in locals():
|
|
477
1237
|
stdout = None
|
|
478
1238
|
if "stderr" not in locals():
|
|
@@ -509,36 +1269,37 @@ class ReasoningOutput(BaseModel):
|
|
|
509
1269
|
|
|
510
1270
|
|
|
511
1271
|
def share_your_reasoning(
|
|
512
|
-
context: RunContext, reasoning: str, next_steps: str | None = None
|
|
1272
|
+
context: RunContext, reasoning: str, next_steps: str | List[str] | None = None
|
|
513
1273
|
) -> ReasoningOutput:
|
|
514
|
-
#
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
if not is_tui_mode():
|
|
520
|
-
emit_divider(message_group=group_id)
|
|
521
|
-
emit_info(
|
|
522
|
-
"\n[bold white on purple] AGENT REASONING [/bold white on purple]",
|
|
523
|
-
message_group=group_id,
|
|
524
|
-
)
|
|
525
|
-
emit_info("[bold cyan]Current reasoning:[/bold cyan]", message_group=group_id)
|
|
526
|
-
emit_system_message(Markdown(reasoning), message_group=group_id)
|
|
527
|
-
if next_steps is not None and next_steps.strip():
|
|
528
|
-
emit_info(
|
|
529
|
-
"\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)]
|
|
530
1279
|
)
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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)
|
|
534
1291
|
|
|
535
1292
|
|
|
536
1293
|
def register_agent_run_shell_command(agent):
|
|
537
1294
|
"""Register only the agent_run_shell_command tool."""
|
|
538
1295
|
|
|
539
1296
|
@agent.tool
|
|
540
|
-
def agent_run_shell_command(
|
|
541
|
-
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,
|
|
542
1303
|
) -> ShellCommandOutput:
|
|
543
1304
|
"""Execute a shell command with comprehensive monitoring and safety features.
|
|
544
1305
|
|
|
@@ -554,6 +1315,14 @@ def register_agent_run_shell_command(agent):
|
|
|
554
1315
|
timeout: Inactivity timeout in seconds. If no output is
|
|
555
1316
|
produced for this duration, the process will be terminated.
|
|
556
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.
|
|
557
1326
|
|
|
558
1327
|
Returns:
|
|
559
1328
|
ShellCommandOutput: A structured response containing:
|
|
@@ -566,6 +1335,9 @@ def register_agent_run_shell_command(agent):
|
|
|
566
1335
|
- execution_time (float | None): Total execution time in seconds
|
|
567
1336
|
- timeout (bool | None): True if command was terminated due to timeout
|
|
568
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
|
|
569
1341
|
|
|
570
1342
|
Examples:
|
|
571
1343
|
>>> # Basic command execution
|
|
@@ -582,11 +1354,16 @@ def register_agent_run_shell_command(agent):
|
|
|
582
1354
|
>>> if result.timeout:
|
|
583
1355
|
... print("Command timed out")
|
|
584
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
|
+
|
|
585
1362
|
Warning:
|
|
586
1363
|
This tool can execute arbitrary shell commands. Exercise caution when
|
|
587
1364
|
running untrusted commands, especially those that modify system state.
|
|
588
1365
|
"""
|
|
589
|
-
return run_shell_command(context, command, cwd, timeout)
|
|
1366
|
+
return await run_shell_command(context, command, cwd, timeout, background)
|
|
590
1367
|
|
|
591
1368
|
|
|
592
1369
|
def register_agent_share_your_reasoning(agent):
|
|
@@ -594,7 +1371,9 @@ def register_agent_share_your_reasoning(agent):
|
|
|
594
1371
|
|
|
595
1372
|
@agent.tool
|
|
596
1373
|
def agent_share_your_reasoning(
|
|
597
|
-
context: RunContext,
|
|
1374
|
+
context: RunContext,
|
|
1375
|
+
reasoning: str = "",
|
|
1376
|
+
next_steps: str | List[str] | None = None,
|
|
598
1377
|
) -> ReasoningOutput:
|
|
599
1378
|
"""Share the agent's current reasoning and planned next steps with the user.
|
|
600
1379
|
|
|
@@ -608,8 +1387,8 @@ def register_agent_share_your_reasoning(agent):
|
|
|
608
1387
|
reasoning for the current situation. This should be clear,
|
|
609
1388
|
comprehensive, and explain the 'why' behind decisions.
|
|
610
1389
|
next_steps: Planned upcoming actions or steps
|
|
611
|
-
the agent intends to take. Can be
|
|
612
|
-
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.
|
|
613
1392
|
|
|
614
1393
|
Returns:
|
|
615
1394
|
ReasoningOutput: A simple response object containing:
|
|
@@ -620,6 +1399,10 @@ def register_agent_share_your_reasoning(agent):
|
|
|
620
1399
|
>>> next_steps = "First, I'll list the directory contents, then read key files"
|
|
621
1400
|
>>> result = agent_share_your_reasoning(ctx, reasoning, next_steps)
|
|
622
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
|
+
|
|
623
1406
|
Best Practice:
|
|
624
1407
|
Use this tool frequently to maintain transparency. Call it:
|
|
625
1408
|
- Before starting complex operations
|