codepp 0.0.437__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 +10 -0
- code_puppy/__main__.py +10 -0
- code_puppy/agents/__init__.py +31 -0
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +117 -0
- 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 +638 -0
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_helios.py +124 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +742 -0
- code_puppy/agents/agent_pack_leader.py +385 -0
- code_puppy/agents/agent_planning.py +165 -0
- code_puppy/agents/agent_python_programmer.py +169 -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_scheduler.py +121 -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 +2156 -0
- code_puppy/agents/event_stream_handler.py +348 -0
- code_puppy/agents/json_agent.py +202 -0
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +327 -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 +453 -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 +75 -0
- code_puppy/api/routers/sessions.py +234 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +692 -0
- code_puppy/chatgpt_codex_client.py +338 -0
- code_puppy/claude_cache_client.py +672 -0
- code_puppy/cli_runner.py +1073 -0
- code_puppy/command_line/__init__.py +1 -0
- code_puppy/command_line/add_model_menu.py +1092 -0
- code_puppy/command_line/agent_menu.py +662 -0
- code_puppy/command_line/attachments.py +395 -0
- code_puppy/command_line/autosave_menu.py +704 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +532 -0
- code_puppy/command_line/command_handler.py +293 -0
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +719 -0
- code_puppy/command_line/core_commands.py +867 -0
- code_puppy/command_line/diff_menu.py +865 -0
- code_puppy/command_line/file_path_completion.py +73 -0
- code_puppy/command_line/load_context_completion.py +52 -0
- code_puppy/command_line/mcp/__init__.py +10 -0
- code_puppy/command_line/mcp/base.py +32 -0
- 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 +138 -0
- code_puppy/command_line/mcp/help_command.py +147 -0
- code_puppy/command_line/mcp/install_command.py +214 -0
- code_puppy/command_line/mcp/install_menu.py +705 -0
- code_puppy/command_line/mcp/list_command.py +94 -0
- code_puppy/command_line/mcp/logs_command.py +235 -0
- code_puppy/command_line/mcp/remove_command.py +82 -0
- code_puppy/command_line/mcp/restart_command.py +100 -0
- code_puppy/command_line/mcp/search_command.py +123 -0
- code_puppy/command_line/mcp/start_all_command.py +135 -0
- code_puppy/command_line/mcp/start_command.py +117 -0
- code_puppy/command_line/mcp/status_command.py +184 -0
- code_puppy/command_line/mcp/stop_all_command.py +112 -0
- code_puppy/command_line/mcp/stop_command.py +80 -0
- code_puppy/command_line/mcp/test_command.py +107 -0
- code_puppy/command_line/mcp/utils.py +129 -0
- code_puppy/command_line/mcp/wizard_utils.py +334 -0
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +197 -0
- code_puppy/command_line/model_settings_menu.py +932 -0
- code_puppy/command_line/motd.py +96 -0
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +342 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +846 -0
- code_puppy/command_line/session_commands.py +302 -0
- code_puppy/command_line/shell_passthrough.py +145 -0
- code_puppy/command_line/skills_completion.py +160 -0
- code_puppy/command_line/uc_menu.py +893 -0
- code_puppy/command_line/utils.py +93 -0
- code_puppy/command_line/wiggum_state.py +78 -0
- code_puppy/config.py +1770 -0
- code_puppy/error_logging.py +134 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +754 -0
- code_puppy/hook_engine/README.md +105 -0
- code_puppy/hook_engine/__init__.py +21 -0
- code_puppy/hook_engine/aliases.py +155 -0
- code_puppy/hook_engine/engine.py +221 -0
- code_puppy/hook_engine/executor.py +296 -0
- code_puppy/hook_engine/matcher.py +156 -0
- code_puppy/hook_engine/models.py +240 -0
- code_puppy/hook_engine/registry.py +106 -0
- code_puppy/hook_engine/validator.py +144 -0
- code_puppy/http_utils.py +361 -0
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +10 -0
- code_puppy/mcp_/__init__.py +66 -0
- code_puppy/mcp_/async_lifecycle.py +286 -0
- code_puppy/mcp_/blocking_startup.py +469 -0
- code_puppy/mcp_/captured_stdio_server.py +275 -0
- code_puppy/mcp_/circuit_breaker.py +290 -0
- code_puppy/mcp_/config_wizard.py +507 -0
- code_puppy/mcp_/dashboard.py +308 -0
- code_puppy/mcp_/error_isolation.py +407 -0
- code_puppy/mcp_/examples/retry_example.py +226 -0
- code_puppy/mcp_/health_monitor.py +589 -0
- code_puppy/mcp_/managed_server.py +428 -0
- code_puppy/mcp_/manager.py +807 -0
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/mcp_/registry.py +451 -0
- code_puppy/mcp_/retry_manager.py +337 -0
- code_puppy/mcp_/server_registry_catalog.py +1126 -0
- code_puppy/mcp_/status_tracker.py +355 -0
- code_puppy/mcp_/system_tools.py +209 -0
- code_puppy/mcp_prompts/__init__.py +1 -0
- code_puppy/mcp_prompts/hook_creator.py +103 -0
- code_puppy/messaging/__init__.py +255 -0
- code_puppy/messaging/bus.py +613 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +361 -0
- code_puppy/messaging/messages.py +569 -0
- code_puppy/messaging/queue_console.py +271 -0
- code_puppy/messaging/renderers.py +311 -0
- code_puppy/messaging/rich_renderer.py +1158 -0
- code_puppy/messaging/spinner/__init__.py +83 -0
- code_puppy/messaging/spinner/console_spinner.py +240 -0
- code_puppy/messaging/spinner/spinner_base.py +95 -0
- code_puppy/messaging/subagent_console.py +460 -0
- code_puppy/model_factory.py +848 -0
- code_puppy/model_switching.py +63 -0
- code_puppy/model_utils.py +168 -0
- code_puppy/models.json +174 -0
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +186 -0
- code_puppy/plugins/agent_skills/__init__.py +22 -0
- code_puppy/plugins/agent_skills/config.py +175 -0
- code_puppy/plugins/agent_skills/discovery.py +136 -0
- code_puppy/plugins/agent_skills/downloader.py +392 -0
- code_puppy/plugins/agent_skills/installer.py +22 -0
- code_puppy/plugins/agent_skills/metadata.py +219 -0
- code_puppy/plugins/agent_skills/prompt_builder.py +60 -0
- code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
- code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
- code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
- code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
- code_puppy/plugins/agent_skills/skills_menu.py +781 -0
- 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 +706 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +133 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
- code_puppy/plugins/antigravity_oauth/storage.py +288 -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 +863 -0
- code_puppy/plugins/antigravity_oauth/utils.py +168 -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 +329 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +301 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +523 -0
- code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
- code_puppy/plugins/claude_code_hooks/config.py +137 -0
- code_puppy/plugins/claude_code_hooks/register_callbacks.py +175 -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 +25 -0
- code_puppy/plugins/claude_code_oauth/config.py +52 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
- code_puppy/plugins/claude_code_oauth/utils.py +640 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +152 -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 +470 -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/hook_creator/__init__.py +1 -0
- code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
- code_puppy/plugins/hook_manager/__init__.py +1 -0
- code_puppy/plugins/hook_manager/config.py +290 -0
- code_puppy/plugins/hook_manager/hooks_menu.py +564 -0
- code_puppy/plugins/hook_manager/register_callbacks.py +227 -0
- code_puppy/plugins/oauth_puppy_html.py +228 -0
- code_puppy/plugins/scheduler/__init__.py +1 -0
- code_puppy/plugins/scheduler/register_callbacks.py +88 -0
- code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
- code_puppy/plugins/scheduler/scheduler_wizard.py +341 -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/plugins/synthetic_status/__init__.py +1 -0
- code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
- code_puppy/plugins/synthetic_status/status_api.py +147 -0
- code_puppy/plugins/universal_constructor/__init__.py +13 -0
- code_puppy/plugins/universal_constructor/models.py +138 -0
- code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
- code_puppy/plugins/universal_constructor/registry.py +302 -0
- code_puppy/plugins/universal_constructor/sandbox.py +584 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/pydantic_patches.py +356 -0
- code_puppy/reopenable_async_client.py +232 -0
- code_puppy/round_robin_model.py +150 -0
- code_puppy/scheduler/__init__.py +41 -0
- code_puppy/scheduler/__main__.py +9 -0
- code_puppy/scheduler/cli.py +118 -0
- code_puppy/scheduler/config.py +126 -0
- code_puppy/scheduler/daemon.py +280 -0
- code_puppy/scheduler/executor.py +155 -0
- code_puppy/scheduler/platform.py +19 -0
- code_puppy/scheduler/platform_unix.py +22 -0
- code_puppy/scheduler/platform_win.py +32 -0
- code_puppy/session_storage.py +338 -0
- code_puppy/status_display.py +257 -0
- code_puppy/summarization_agent.py +176 -0
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +501 -0
- code_puppy/tools/agent_tools.py +603 -0
- code_puppy/tools/ask_user_question/__init__.py +26 -0
- code_puppy/tools/ask_user_question/constants.py +73 -0
- code_puppy/tools/ask_user_question/demo_tui.py +55 -0
- code_puppy/tools/ask_user_question/handler.py +232 -0
- code_puppy/tools/ask_user_question/models.py +304 -0
- code_puppy/tools/ask_user_question/registration.py +26 -0
- code_puppy/tools/ask_user_question/renderers.py +309 -0
- code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
- code_puppy/tools/ask_user_question/theme.py +155 -0
- code_puppy/tools/ask_user_question/tui_loop.py +423 -0
- 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 +378 -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 +534 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +1346 -0
- code_puppy/tools/common.py +1409 -0
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +886 -0
- code_puppy/tools/file_operations.py +802 -0
- code_puppy/tools/scheduler_tools.py +412 -0
- code_puppy/tools/skills_tools.py +244 -0
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/tools/tools_content.py +51 -0
- code_puppy/tools/universal_constructor.py +889 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +82 -0
- codepp-0.0.437.dist-info/METADATA +766 -0
- codepp-0.0.437.dist-info/RECORD +288 -0
- codepp-0.0.437.dist-info/WHEEL +4 -0
- codepp-0.0.437.dist-info/entry_points.txt +3 -0
- codepp-0.0.437.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1346 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import ctypes
|
|
3
|
+
import os
|
|
4
|
+
import select
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import traceback
|
|
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
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
from pydantic_ai import RunContext
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
|
|
21
|
+
from code_puppy.messaging import ( # Structured messaging types
|
|
22
|
+
AgentReasoningMessage,
|
|
23
|
+
ShellOutputMessage,
|
|
24
|
+
ShellStartMessage,
|
|
25
|
+
emit_error,
|
|
26
|
+
emit_info,
|
|
27
|
+
emit_shell_line,
|
|
28
|
+
emit_warning,
|
|
29
|
+
get_message_bus,
|
|
30
|
+
)
|
|
31
|
+
from code_puppy.tools.common import generate_group_id, get_user_approval_async
|
|
32
|
+
from code_puppy.tools.subagent_context import is_subagent
|
|
33
|
+
|
|
34
|
+
# Maximum line length for shell command output to prevent massive token usage
|
|
35
|
+
# This helps avoid exceeding model context limits when commands produce very long lines
|
|
36
|
+
MAX_LINE_LENGTH = 256
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _truncate_line(line: str) -> str:
|
|
40
|
+
"""Truncate a line to MAX_LINE_LENGTH if it exceeds the limit."""
|
|
41
|
+
if len(line) > MAX_LINE_LENGTH:
|
|
42
|
+
return line[:MAX_LINE_LENGTH] + "... [truncated]"
|
|
43
|
+
return line
|
|
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
|
+
|
|
100
|
+
_AWAITING_USER_INPUT = threading.Event()
|
|
101
|
+
|
|
102
|
+
_CONFIRMATION_LOCK = threading.Lock()
|
|
103
|
+
|
|
104
|
+
# Track running shell processes so we can kill them on Ctrl-C from the UI
|
|
105
|
+
_RUNNING_PROCESSES: Set[subprocess.Popen] = set()
|
|
106
|
+
_RUNNING_PROCESSES_LOCK = threading.Lock()
|
|
107
|
+
_USER_KILLED_PROCESSES = set()
|
|
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
|
+
# Thread-safe registry of active stop events for concurrent shell commands
|
|
119
|
+
_ACTIVE_STOP_EVENTS: Set[threading.Event] = set()
|
|
120
|
+
_ACTIVE_STOP_EVENTS_LOCK = threading.Lock()
|
|
121
|
+
|
|
122
|
+
# Thread pool for running blocking shell commands without blocking the event loop
|
|
123
|
+
# This allows multiple sub-agents to run shell commands in parallel
|
|
124
|
+
_SHELL_EXECUTOR = ThreadPoolExecutor(max_workers=16, thread_name_prefix="shell_cmd_")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _register_process(proc: subprocess.Popen) -> None:
|
|
128
|
+
with _RUNNING_PROCESSES_LOCK:
|
|
129
|
+
_RUNNING_PROCESSES.add(proc)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _unregister_process(proc: subprocess.Popen) -> None:
|
|
133
|
+
with _RUNNING_PROCESSES_LOCK:
|
|
134
|
+
_RUNNING_PROCESSES.discard(proc)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _kill_process_group(proc: subprocess.Popen) -> None:
|
|
138
|
+
"""Attempt to aggressively terminate a process and its group.
|
|
139
|
+
|
|
140
|
+
Cross-platform best-effort. On POSIX, uses process groups. On Windows, tries taskkill with /T flag for tree kill.
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
if sys.platform.startswith("win"):
|
|
144
|
+
# On Windows, use taskkill to kill the process tree
|
|
145
|
+
# /F = force, /T = kill tree (children), /PID = process ID
|
|
146
|
+
try:
|
|
147
|
+
import subprocess as sp
|
|
148
|
+
|
|
149
|
+
# Try taskkill first - more reliable on Windows
|
|
150
|
+
sp.run(
|
|
151
|
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
152
|
+
capture_output=True,
|
|
153
|
+
timeout=2,
|
|
154
|
+
check=False,
|
|
155
|
+
)
|
|
156
|
+
time.sleep(0.3)
|
|
157
|
+
except Exception:
|
|
158
|
+
# Fallback to Python's built-in methods
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
# Double-check it's dead, if not use proc.kill()
|
|
162
|
+
if proc.poll() is None:
|
|
163
|
+
try:
|
|
164
|
+
proc.kill()
|
|
165
|
+
time.sleep(0.3)
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
# POSIX
|
|
171
|
+
pid = proc.pid
|
|
172
|
+
try:
|
|
173
|
+
pgid = os.getpgid(pid)
|
|
174
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
175
|
+
time.sleep(1.0)
|
|
176
|
+
if proc.poll() is None:
|
|
177
|
+
os.killpg(pgid, signal.SIGINT)
|
|
178
|
+
time.sleep(0.6)
|
|
179
|
+
if proc.poll() is None:
|
|
180
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
181
|
+
time.sleep(0.5)
|
|
182
|
+
except (OSError, ProcessLookupError):
|
|
183
|
+
# Fall back to direct kill of the process
|
|
184
|
+
try:
|
|
185
|
+
if proc.poll() is None:
|
|
186
|
+
proc.kill()
|
|
187
|
+
except (OSError, ProcessLookupError):
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
if proc.poll() is None:
|
|
191
|
+
# Last ditch attempt; may be unkillable zombie
|
|
192
|
+
try:
|
|
193
|
+
for _ in range(3):
|
|
194
|
+
os.kill(proc.pid, signal.SIGKILL)
|
|
195
|
+
time.sleep(0.2)
|
|
196
|
+
if proc.poll() is not None:
|
|
197
|
+
break
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
except Exception as e:
|
|
201
|
+
emit_error(f"Kill process error: {e}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def kill_all_running_shell_processes() -> int:
|
|
205
|
+
"""Kill all currently tracked running shell processes and stop reader threads.
|
|
206
|
+
|
|
207
|
+
Returns the number of processes signaled.
|
|
208
|
+
"""
|
|
209
|
+
# Signal all active reader threads to stop
|
|
210
|
+
with _ACTIVE_STOP_EVENTS_LOCK:
|
|
211
|
+
for evt in _ACTIVE_STOP_EVENTS:
|
|
212
|
+
evt.set()
|
|
213
|
+
|
|
214
|
+
procs: list[subprocess.Popen]
|
|
215
|
+
with _RUNNING_PROCESSES_LOCK:
|
|
216
|
+
procs = list(_RUNNING_PROCESSES)
|
|
217
|
+
count = 0
|
|
218
|
+
for p in procs:
|
|
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
|
+
|
|
231
|
+
if p.poll() is None:
|
|
232
|
+
_kill_process_group(p)
|
|
233
|
+
count += 1
|
|
234
|
+
_USER_KILLED_PROCESSES.add(p.pid)
|
|
235
|
+
finally:
|
|
236
|
+
_unregister_process(p)
|
|
237
|
+
return count
|
|
238
|
+
|
|
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
|
+
|
|
255
|
+
# Function to check if user input is awaited
|
|
256
|
+
def is_awaiting_user_input():
|
|
257
|
+
"""Check if command_runner is waiting for user input."""
|
|
258
|
+
return _AWAITING_USER_INPUT.is_set()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# Function to set user input flag
|
|
262
|
+
def set_awaiting_user_input(awaiting=True):
|
|
263
|
+
"""Set the flag indicating if user input is awaited."""
|
|
264
|
+
if awaiting:
|
|
265
|
+
_AWAITING_USER_INPUT.set()
|
|
266
|
+
else:
|
|
267
|
+
_AWAITING_USER_INPUT.clear()
|
|
268
|
+
|
|
269
|
+
# When we're setting this flag, also pause/resume all active spinners
|
|
270
|
+
if awaiting:
|
|
271
|
+
# Pause all active spinners (imported here to avoid circular imports)
|
|
272
|
+
try:
|
|
273
|
+
from code_puppy.messaging.spinner import pause_all_spinners
|
|
274
|
+
|
|
275
|
+
pause_all_spinners()
|
|
276
|
+
except ImportError:
|
|
277
|
+
pass # Spinner functionality not available
|
|
278
|
+
else:
|
|
279
|
+
# Resume all active spinners
|
|
280
|
+
try:
|
|
281
|
+
from code_puppy.messaging.spinner import resume_all_spinners
|
|
282
|
+
|
|
283
|
+
resume_all_spinners()
|
|
284
|
+
except ImportError:
|
|
285
|
+
pass # Spinner functionality not available
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class ShellCommandOutput(BaseModel):
|
|
289
|
+
success: bool
|
|
290
|
+
command: str | None
|
|
291
|
+
error: str | None = ""
|
|
292
|
+
stdout: str | None
|
|
293
|
+
stderr: str | None
|
|
294
|
+
exit_code: int | None
|
|
295
|
+
execution_time: float | None
|
|
296
|
+
timeout: bool | None = False
|
|
297
|
+
user_interrupted: bool | None = False
|
|
298
|
+
user_feedback: str | None = None # User feedback when command is rejected
|
|
299
|
+
background: bool = False # True if command was run in background mode
|
|
300
|
+
log_file: str | None = None # Path to temp log file for background commands
|
|
301
|
+
pid: int | None = None # Process ID for background commands
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class ShellSafetyAssessment(BaseModel):
|
|
305
|
+
"""Assessment of shell command safety risks.
|
|
306
|
+
|
|
307
|
+
This model represents the structured output from the shell safety checker agent.
|
|
308
|
+
It provides a risk level classification and reasoning for that assessment.
|
|
309
|
+
|
|
310
|
+
Attributes:
|
|
311
|
+
risk: Risk level classification. Can be one of:
|
|
312
|
+
'none' (completely safe), 'low' (minimal risk), 'medium' (moderate risk),
|
|
313
|
+
'high' (significant risk), 'critical' (severe/destructive risk).
|
|
314
|
+
reasoning: Brief explanation (max 1-2 sentences) of why this risk level
|
|
315
|
+
was assigned. Should be concise and actionable.
|
|
316
|
+
is_fallback: Whether this assessment is a fallback due to parsing failure.
|
|
317
|
+
Fallback assessments are not cached to allow retry with fresh LLM responses.
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
risk: Literal["none", "low", "medium", "high", "critical"]
|
|
321
|
+
reasoning: str
|
|
322
|
+
is_fallback: bool = False
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _listen_for_ctrl_x_windows(
|
|
326
|
+
stop_event: threading.Event,
|
|
327
|
+
on_escape: Callable[[], None],
|
|
328
|
+
) -> None:
|
|
329
|
+
"""Windows-specific Ctrl-X listener."""
|
|
330
|
+
import msvcrt
|
|
331
|
+
import time
|
|
332
|
+
|
|
333
|
+
while not stop_event.is_set():
|
|
334
|
+
try:
|
|
335
|
+
if msvcrt.kbhit():
|
|
336
|
+
try:
|
|
337
|
+
# Try to read a character
|
|
338
|
+
# Note: msvcrt.getwch() returns unicode string on Windows
|
|
339
|
+
key = msvcrt.getwch()
|
|
340
|
+
|
|
341
|
+
# Check for Ctrl+X (\x18) or other interrupt keys
|
|
342
|
+
# Some terminals might not send \x18, so also check for 'x' with modifier
|
|
343
|
+
if key == "\x18": # Standard Ctrl+X
|
|
344
|
+
try:
|
|
345
|
+
on_escape()
|
|
346
|
+
except Exception:
|
|
347
|
+
emit_warning(
|
|
348
|
+
"Ctrl+X handler raised unexpectedly; Ctrl+C still works."
|
|
349
|
+
)
|
|
350
|
+
# Note: In some Windows terminals, Ctrl+X might not be captured
|
|
351
|
+
# Users can use Ctrl+C as alternative, which is handled by signal handler
|
|
352
|
+
except (OSError, ValueError):
|
|
353
|
+
# kbhit/getwch can fail on Windows in certain terminal states
|
|
354
|
+
# Just continue, user can use Ctrl+C
|
|
355
|
+
pass
|
|
356
|
+
except Exception:
|
|
357
|
+
# Be silent about Windows listener errors - they're common
|
|
358
|
+
# User can use Ctrl+C as fallback
|
|
359
|
+
pass
|
|
360
|
+
time.sleep(0.05)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _listen_for_ctrl_x_posix(
|
|
364
|
+
stop_event: threading.Event,
|
|
365
|
+
on_escape: Callable[[], None],
|
|
366
|
+
) -> None:
|
|
367
|
+
"""POSIX-specific Ctrl-X listener."""
|
|
368
|
+
import select
|
|
369
|
+
import sys
|
|
370
|
+
import termios
|
|
371
|
+
import tty
|
|
372
|
+
|
|
373
|
+
stdin = sys.stdin
|
|
374
|
+
try:
|
|
375
|
+
fd = stdin.fileno()
|
|
376
|
+
except (AttributeError, ValueError, OSError):
|
|
377
|
+
return
|
|
378
|
+
try:
|
|
379
|
+
original_attrs = termios.tcgetattr(fd)
|
|
380
|
+
except Exception:
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
tty.setcbreak(fd)
|
|
385
|
+
while not stop_event.is_set():
|
|
386
|
+
try:
|
|
387
|
+
read_ready, _, _ = select.select([stdin], [], [], 0.05)
|
|
388
|
+
except Exception:
|
|
389
|
+
break
|
|
390
|
+
if not read_ready:
|
|
391
|
+
continue
|
|
392
|
+
data = stdin.read(1)
|
|
393
|
+
if not data:
|
|
394
|
+
break
|
|
395
|
+
if data == "\x18": # Ctrl+X
|
|
396
|
+
try:
|
|
397
|
+
on_escape()
|
|
398
|
+
except Exception:
|
|
399
|
+
emit_warning(
|
|
400
|
+
"Ctrl+X handler raised unexpectedly; Ctrl+C still works."
|
|
401
|
+
)
|
|
402
|
+
finally:
|
|
403
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, original_attrs)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _spawn_ctrl_x_key_listener(
|
|
407
|
+
stop_event: threading.Event,
|
|
408
|
+
on_escape: Callable[[], None],
|
|
409
|
+
) -> Optional[threading.Thread]:
|
|
410
|
+
"""Start a Ctrl+X key listener thread for CLI sessions."""
|
|
411
|
+
try:
|
|
412
|
+
import sys
|
|
413
|
+
except ImportError:
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
stdin = getattr(sys, "stdin", None)
|
|
417
|
+
if stdin is None or not hasattr(stdin, "isatty"):
|
|
418
|
+
return None
|
|
419
|
+
try:
|
|
420
|
+
if not stdin.isatty():
|
|
421
|
+
return None
|
|
422
|
+
except Exception:
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
def listener() -> None:
|
|
426
|
+
try:
|
|
427
|
+
if sys.platform.startswith("win"):
|
|
428
|
+
_listen_for_ctrl_x_windows(stop_event, on_escape)
|
|
429
|
+
else:
|
|
430
|
+
_listen_for_ctrl_x_posix(stop_event, on_escape)
|
|
431
|
+
except Exception:
|
|
432
|
+
emit_warning(
|
|
433
|
+
"Ctrl+X key listener stopped unexpectedly; press Ctrl+C to cancel."
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
thread = threading.Thread(
|
|
437
|
+
target=listener, name="shell-command-ctrl-x-listener", daemon=True
|
|
438
|
+
)
|
|
439
|
+
thread.start()
|
|
440
|
+
return thread
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@contextmanager
|
|
444
|
+
def _shell_command_keyboard_context():
|
|
445
|
+
"""Context manager to handle keyboard interrupts during shell command execution.
|
|
446
|
+
|
|
447
|
+
This context manager:
|
|
448
|
+
1. Disables the agent's Ctrl-C handler (so it doesn't cancel the agent)
|
|
449
|
+
2. Enables a Ctrl-X listener to kill the running shell process
|
|
450
|
+
3. Restores the original Ctrl-C handler when done
|
|
451
|
+
"""
|
|
452
|
+
global _SHELL_CTRL_X_STOP_EVENT, _SHELL_CTRL_X_THREAD, _ORIGINAL_SIGINT_HANDLER
|
|
453
|
+
|
|
454
|
+
# Handler for Ctrl-X: kill all running shell processes
|
|
455
|
+
def handle_ctrl_x_press() -> None:
|
|
456
|
+
emit_warning("\n🛑 Ctrl-X detected! Interrupting shell command...")
|
|
457
|
+
kill_all_running_shell_processes()
|
|
458
|
+
|
|
459
|
+
# Handler for Ctrl-C during shell execution: just kill the shell process, don't cancel agent
|
|
460
|
+
def shell_sigint_handler(_sig, _frame):
|
|
461
|
+
"""During shell execution, Ctrl-C kills the shell but doesn't cancel the agent."""
|
|
462
|
+
emit_warning("\n🛑 Ctrl-C detected! Interrupting shell command...")
|
|
463
|
+
kill_all_running_shell_processes()
|
|
464
|
+
|
|
465
|
+
# Set up Ctrl-X listener
|
|
466
|
+
_SHELL_CTRL_X_STOP_EVENT = threading.Event()
|
|
467
|
+
_SHELL_CTRL_X_THREAD = _spawn_ctrl_x_key_listener(
|
|
468
|
+
_SHELL_CTRL_X_STOP_EVENT,
|
|
469
|
+
handle_ctrl_x_press,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# Replace SIGINT handler temporarily
|
|
473
|
+
try:
|
|
474
|
+
_ORIGINAL_SIGINT_HANDLER = signal.signal(signal.SIGINT, shell_sigint_handler)
|
|
475
|
+
except (ValueError, OSError):
|
|
476
|
+
# Can't set signal handler (maybe not main thread?)
|
|
477
|
+
_ORIGINAL_SIGINT_HANDLER = None
|
|
478
|
+
|
|
479
|
+
try:
|
|
480
|
+
yield
|
|
481
|
+
finally:
|
|
482
|
+
# Clean up: stop Ctrl-X listener
|
|
483
|
+
if _SHELL_CTRL_X_STOP_EVENT:
|
|
484
|
+
_SHELL_CTRL_X_STOP_EVENT.set()
|
|
485
|
+
|
|
486
|
+
if _SHELL_CTRL_X_THREAD and _SHELL_CTRL_X_THREAD.is_alive():
|
|
487
|
+
try:
|
|
488
|
+
_SHELL_CTRL_X_THREAD.join(timeout=0.2)
|
|
489
|
+
except Exception:
|
|
490
|
+
pass
|
|
491
|
+
|
|
492
|
+
# Restore original SIGINT handler
|
|
493
|
+
if _ORIGINAL_SIGINT_HANDLER is not None:
|
|
494
|
+
try:
|
|
495
|
+
signal.signal(signal.SIGINT, _ORIGINAL_SIGINT_HANDLER)
|
|
496
|
+
except (ValueError, OSError):
|
|
497
|
+
pass
|
|
498
|
+
|
|
499
|
+
# Clean up global state
|
|
500
|
+
_SHELL_CTRL_X_STOP_EVENT = None
|
|
501
|
+
_SHELL_CTRL_X_THREAD = None
|
|
502
|
+
_ORIGINAL_SIGINT_HANDLER = None
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _handle_ctrl_x_press() -> None:
|
|
506
|
+
"""Handler for Ctrl-X: kill all running shell processes."""
|
|
507
|
+
emit_warning("\n🛑 Ctrl-X detected! Interrupting all shell commands...")
|
|
508
|
+
kill_all_running_shell_processes()
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _shell_sigint_handler(_sig, _frame):
|
|
512
|
+
"""During shell execution, Ctrl-C kills all shells but doesn't cancel agent."""
|
|
513
|
+
emit_warning("\n🛑 Ctrl-C detected! Interrupting all shell commands...")
|
|
514
|
+
kill_all_running_shell_processes()
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _start_keyboard_listener() -> None:
|
|
518
|
+
"""Start the Ctrl-X listener and install SIGINT handler.
|
|
519
|
+
|
|
520
|
+
Called when the first shell command starts.
|
|
521
|
+
"""
|
|
522
|
+
global _SHELL_CTRL_X_STOP_EVENT, _SHELL_CTRL_X_THREAD, _ORIGINAL_SIGINT_HANDLER
|
|
523
|
+
|
|
524
|
+
# Set up Ctrl-X listener
|
|
525
|
+
_SHELL_CTRL_X_STOP_EVENT = threading.Event()
|
|
526
|
+
_SHELL_CTRL_X_THREAD = _spawn_ctrl_x_key_listener(
|
|
527
|
+
_SHELL_CTRL_X_STOP_EVENT,
|
|
528
|
+
_handle_ctrl_x_press,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# Replace SIGINT handler temporarily
|
|
532
|
+
try:
|
|
533
|
+
_ORIGINAL_SIGINT_HANDLER = signal.signal(signal.SIGINT, _shell_sigint_handler)
|
|
534
|
+
except (ValueError, OSError):
|
|
535
|
+
# Can't set signal handler (maybe not main thread?)
|
|
536
|
+
_ORIGINAL_SIGINT_HANDLER = None
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _stop_keyboard_listener() -> None:
|
|
540
|
+
"""Stop the Ctrl-X listener and restore SIGINT handler.
|
|
541
|
+
|
|
542
|
+
Called when the last shell command finishes.
|
|
543
|
+
"""
|
|
544
|
+
global _SHELL_CTRL_X_STOP_EVENT, _SHELL_CTRL_X_THREAD, _ORIGINAL_SIGINT_HANDLER
|
|
545
|
+
|
|
546
|
+
# Clean up: stop Ctrl-X listener
|
|
547
|
+
if _SHELL_CTRL_X_STOP_EVENT:
|
|
548
|
+
_SHELL_CTRL_X_STOP_EVENT.set()
|
|
549
|
+
|
|
550
|
+
if _SHELL_CTRL_X_THREAD and _SHELL_CTRL_X_THREAD.is_alive():
|
|
551
|
+
try:
|
|
552
|
+
_SHELL_CTRL_X_THREAD.join(timeout=0.2)
|
|
553
|
+
except Exception:
|
|
554
|
+
pass
|
|
555
|
+
|
|
556
|
+
# Restore original SIGINT handler
|
|
557
|
+
if _ORIGINAL_SIGINT_HANDLER is not None:
|
|
558
|
+
try:
|
|
559
|
+
signal.signal(signal.SIGINT, _ORIGINAL_SIGINT_HANDLER)
|
|
560
|
+
except (ValueError, OSError):
|
|
561
|
+
pass
|
|
562
|
+
|
|
563
|
+
# Clean up global state
|
|
564
|
+
_SHELL_CTRL_X_STOP_EVENT = None
|
|
565
|
+
_SHELL_CTRL_X_THREAD = None
|
|
566
|
+
_ORIGINAL_SIGINT_HANDLER = None
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _acquire_keyboard_context() -> None:
|
|
570
|
+
"""Acquire the shared keyboard context (reference counted).
|
|
571
|
+
|
|
572
|
+
Starts the Ctrl-X listener when the first command starts.
|
|
573
|
+
Safe to call from any thread.
|
|
574
|
+
"""
|
|
575
|
+
global _KEYBOARD_CONTEXT_REFCOUNT
|
|
576
|
+
|
|
577
|
+
should_start = False
|
|
578
|
+
with _KEYBOARD_CONTEXT_LOCK:
|
|
579
|
+
_KEYBOARD_CONTEXT_REFCOUNT += 1
|
|
580
|
+
if _KEYBOARD_CONTEXT_REFCOUNT == 1:
|
|
581
|
+
should_start = True
|
|
582
|
+
|
|
583
|
+
# Start listener OUTSIDE the lock to avoid blocking other commands
|
|
584
|
+
if should_start:
|
|
585
|
+
_start_keyboard_listener()
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _release_keyboard_context() -> None:
|
|
589
|
+
"""Release the shared keyboard context (reference counted).
|
|
590
|
+
|
|
591
|
+
Stops the Ctrl-X listener when the last command finishes.
|
|
592
|
+
Safe to call from any thread.
|
|
593
|
+
"""
|
|
594
|
+
global _KEYBOARD_CONTEXT_REFCOUNT
|
|
595
|
+
|
|
596
|
+
should_stop = False
|
|
597
|
+
with _KEYBOARD_CONTEXT_LOCK:
|
|
598
|
+
_KEYBOARD_CONTEXT_REFCOUNT -= 1
|
|
599
|
+
if _KEYBOARD_CONTEXT_REFCOUNT <= 0:
|
|
600
|
+
_KEYBOARD_CONTEXT_REFCOUNT = 0 # Safety clamp
|
|
601
|
+
should_stop = True
|
|
602
|
+
|
|
603
|
+
# Stop listener OUTSIDE the lock to avoid blocking other commands
|
|
604
|
+
if should_stop:
|
|
605
|
+
_stop_keyboard_listener()
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def run_shell_command_streaming(
|
|
609
|
+
process: subprocess.Popen,
|
|
610
|
+
timeout: int = 60,
|
|
611
|
+
command: str = "",
|
|
612
|
+
group_id: str = None,
|
|
613
|
+
silent: bool = False,
|
|
614
|
+
):
|
|
615
|
+
stop_event = threading.Event()
|
|
616
|
+
with _ACTIVE_STOP_EVENTS_LOCK:
|
|
617
|
+
_ACTIVE_STOP_EVENTS.add(stop_event)
|
|
618
|
+
|
|
619
|
+
start_time = time.time()
|
|
620
|
+
last_output_time = [start_time]
|
|
621
|
+
|
|
622
|
+
ABSOLUTE_TIMEOUT_SECONDS = 270
|
|
623
|
+
|
|
624
|
+
stdout_lines = []
|
|
625
|
+
stderr_lines = []
|
|
626
|
+
|
|
627
|
+
stdout_thread = None
|
|
628
|
+
stderr_thread = None
|
|
629
|
+
|
|
630
|
+
def read_stdout():
|
|
631
|
+
try:
|
|
632
|
+
fd = process.stdout.fileno()
|
|
633
|
+
except (ValueError, OSError):
|
|
634
|
+
return
|
|
635
|
+
|
|
636
|
+
try:
|
|
637
|
+
while True:
|
|
638
|
+
# Check stop event first
|
|
639
|
+
if stop_event.is_set():
|
|
640
|
+
break
|
|
641
|
+
|
|
642
|
+
# Use select to check if data is available (with timeout)
|
|
643
|
+
if sys.platform.startswith("win"):
|
|
644
|
+
# Windows doesn't support select on pipes
|
|
645
|
+
# Use PeekNamedPipe via _win32_pipe_has_data() to check
|
|
646
|
+
# if data is available without blocking
|
|
647
|
+
try:
|
|
648
|
+
if _win32_pipe_has_data(process.stdout):
|
|
649
|
+
line = process.stdout.readline()
|
|
650
|
+
if not line: # EOF
|
|
651
|
+
break
|
|
652
|
+
line = line.rstrip("\n")
|
|
653
|
+
line = _truncate_line(line)
|
|
654
|
+
stdout_lines.append(line)
|
|
655
|
+
if not silent:
|
|
656
|
+
emit_shell_line(line, stream="stdout")
|
|
657
|
+
last_output_time[0] = time.time()
|
|
658
|
+
else:
|
|
659
|
+
# No data available, check if process has exited
|
|
660
|
+
if process.poll() is not None:
|
|
661
|
+
# Process exited, do one final drain
|
|
662
|
+
try:
|
|
663
|
+
remaining = process.stdout.read()
|
|
664
|
+
if remaining:
|
|
665
|
+
for line in remaining.split("\n"):
|
|
666
|
+
line = _truncate_line(line)
|
|
667
|
+
stdout_lines.append(line)
|
|
668
|
+
if not silent:
|
|
669
|
+
emit_shell_line(line, stream="stdout")
|
|
670
|
+
except (ValueError, OSError):
|
|
671
|
+
pass
|
|
672
|
+
break
|
|
673
|
+
# Sleep briefly to avoid busy-waiting (100ms like POSIX)
|
|
674
|
+
time.sleep(0.1)
|
|
675
|
+
except (ValueError, OSError):
|
|
676
|
+
break
|
|
677
|
+
else:
|
|
678
|
+
# POSIX: use select with timeout
|
|
679
|
+
try:
|
|
680
|
+
ready, _, _ = select.select([fd], [], [], 0.1) # 100ms timeout
|
|
681
|
+
except (ValueError, OSError, select.error):
|
|
682
|
+
break
|
|
683
|
+
|
|
684
|
+
if ready:
|
|
685
|
+
line = process.stdout.readline()
|
|
686
|
+
if not line: # EOF
|
|
687
|
+
break
|
|
688
|
+
line = line.rstrip("\n")
|
|
689
|
+
line = _truncate_line(line)
|
|
690
|
+
stdout_lines.append(line)
|
|
691
|
+
if not silent:
|
|
692
|
+
emit_shell_line(line, stream="stdout")
|
|
693
|
+
last_output_time[0] = time.time()
|
|
694
|
+
# If not ready, loop continues and checks stop event again
|
|
695
|
+
except (ValueError, OSError):
|
|
696
|
+
pass
|
|
697
|
+
except Exception:
|
|
698
|
+
pass
|
|
699
|
+
|
|
700
|
+
def read_stderr():
|
|
701
|
+
try:
|
|
702
|
+
fd = process.stderr.fileno()
|
|
703
|
+
except (ValueError, OSError):
|
|
704
|
+
return
|
|
705
|
+
|
|
706
|
+
try:
|
|
707
|
+
while True:
|
|
708
|
+
# Check stop event first
|
|
709
|
+
if stop_event.is_set():
|
|
710
|
+
break
|
|
711
|
+
|
|
712
|
+
if sys.platform.startswith("win"):
|
|
713
|
+
# Windows doesn't support select on pipes
|
|
714
|
+
# Use PeekNamedPipe via _win32_pipe_has_data() to check
|
|
715
|
+
# if data is available without blocking
|
|
716
|
+
try:
|
|
717
|
+
if _win32_pipe_has_data(process.stderr):
|
|
718
|
+
line = process.stderr.readline()
|
|
719
|
+
if not line: # EOF
|
|
720
|
+
break
|
|
721
|
+
line = line.rstrip("\n")
|
|
722
|
+
line = _truncate_line(line)
|
|
723
|
+
stderr_lines.append(line)
|
|
724
|
+
if not silent:
|
|
725
|
+
emit_shell_line(line, stream="stderr")
|
|
726
|
+
last_output_time[0] = time.time()
|
|
727
|
+
else:
|
|
728
|
+
# No data available, check if process has exited
|
|
729
|
+
if process.poll() is not None:
|
|
730
|
+
# Process exited, do one final drain
|
|
731
|
+
try:
|
|
732
|
+
remaining = process.stderr.read()
|
|
733
|
+
if remaining:
|
|
734
|
+
for line in remaining.split("\n"):
|
|
735
|
+
line = _truncate_line(line)
|
|
736
|
+
stderr_lines.append(line)
|
|
737
|
+
if not silent:
|
|
738
|
+
emit_shell_line(line, stream="stderr")
|
|
739
|
+
except (ValueError, OSError):
|
|
740
|
+
pass
|
|
741
|
+
break
|
|
742
|
+
# Sleep briefly to avoid busy-waiting (100ms like POSIX)
|
|
743
|
+
time.sleep(0.1)
|
|
744
|
+
except (ValueError, OSError):
|
|
745
|
+
break
|
|
746
|
+
else:
|
|
747
|
+
try:
|
|
748
|
+
ready, _, _ = select.select([fd], [], [], 0.1)
|
|
749
|
+
except (ValueError, OSError, select.error):
|
|
750
|
+
break
|
|
751
|
+
|
|
752
|
+
if ready:
|
|
753
|
+
line = process.stderr.readline()
|
|
754
|
+
if not line: # EOF
|
|
755
|
+
break
|
|
756
|
+
line = line.rstrip("\n")
|
|
757
|
+
line = _truncate_line(line)
|
|
758
|
+
stderr_lines.append(line)
|
|
759
|
+
if not silent:
|
|
760
|
+
emit_shell_line(line, stream="stderr")
|
|
761
|
+
last_output_time[0] = time.time()
|
|
762
|
+
except (ValueError, OSError):
|
|
763
|
+
pass
|
|
764
|
+
except Exception:
|
|
765
|
+
pass
|
|
766
|
+
|
|
767
|
+
def cleanup_process_and_threads(timeout_type: str = "unknown"):
|
|
768
|
+
nonlocal stdout_thread, stderr_thread
|
|
769
|
+
|
|
770
|
+
def nuclear_kill(proc):
|
|
771
|
+
_kill_process_group(proc)
|
|
772
|
+
|
|
773
|
+
try:
|
|
774
|
+
# Signal reader threads to stop first
|
|
775
|
+
stop_event.set()
|
|
776
|
+
|
|
777
|
+
if process.poll() is None:
|
|
778
|
+
nuclear_kill(process)
|
|
779
|
+
|
|
780
|
+
try:
|
|
781
|
+
if process.stdout and not process.stdout.closed:
|
|
782
|
+
process.stdout.close()
|
|
783
|
+
if process.stderr and not process.stderr.closed:
|
|
784
|
+
process.stderr.close()
|
|
785
|
+
if process.stdin and not process.stdin.closed:
|
|
786
|
+
process.stdin.close()
|
|
787
|
+
except (OSError, ValueError):
|
|
788
|
+
pass
|
|
789
|
+
|
|
790
|
+
# Unregister once we're done cleaning up
|
|
791
|
+
_unregister_process(process)
|
|
792
|
+
|
|
793
|
+
if stdout_thread and stdout_thread.is_alive():
|
|
794
|
+
stdout_thread.join(timeout=3)
|
|
795
|
+
if stdout_thread.is_alive() and not silent:
|
|
796
|
+
emit_warning(
|
|
797
|
+
f"stdout reader thread failed to terminate after {timeout_type} timeout",
|
|
798
|
+
message_group=group_id,
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
if stderr_thread and stderr_thread.is_alive():
|
|
802
|
+
stderr_thread.join(timeout=3)
|
|
803
|
+
if stderr_thread.is_alive() and not silent:
|
|
804
|
+
emit_warning(
|
|
805
|
+
f"stderr reader thread failed to terminate after {timeout_type} timeout",
|
|
806
|
+
message_group=group_id,
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
except Exception as e:
|
|
810
|
+
if not silent:
|
|
811
|
+
emit_warning(
|
|
812
|
+
f"Error during process cleanup: {e}", message_group=group_id
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
execution_time = time.time() - start_time
|
|
816
|
+
return ShellCommandOutput(
|
|
817
|
+
**{
|
|
818
|
+
"success": False,
|
|
819
|
+
"command": command,
|
|
820
|
+
"stdout": "\n".join(stdout_lines[-256:]),
|
|
821
|
+
"stderr": "\n".join(stderr_lines[-256:]),
|
|
822
|
+
"exit_code": -9,
|
|
823
|
+
"execution_time": execution_time,
|
|
824
|
+
"timeout": True,
|
|
825
|
+
"error": f"Command timed out after {timeout} seconds",
|
|
826
|
+
}
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
try:
|
|
830
|
+
stdout_thread = threading.Thread(target=read_stdout, daemon=True)
|
|
831
|
+
stderr_thread = threading.Thread(target=read_stderr, daemon=True)
|
|
832
|
+
|
|
833
|
+
stdout_thread.start()
|
|
834
|
+
stderr_thread.start()
|
|
835
|
+
|
|
836
|
+
while process.poll() is None:
|
|
837
|
+
current_time = time.time()
|
|
838
|
+
|
|
839
|
+
if current_time - start_time > ABSOLUTE_TIMEOUT_SECONDS:
|
|
840
|
+
if not silent:
|
|
841
|
+
emit_error(
|
|
842
|
+
"Process killed: absolute timeout reached",
|
|
843
|
+
message_group=group_id,
|
|
844
|
+
)
|
|
845
|
+
return cleanup_process_and_threads("absolute")
|
|
846
|
+
|
|
847
|
+
if current_time - last_output_time[0] > timeout:
|
|
848
|
+
if not silent:
|
|
849
|
+
emit_error(
|
|
850
|
+
"Process killed: inactivity timeout reached",
|
|
851
|
+
message_group=group_id,
|
|
852
|
+
)
|
|
853
|
+
return cleanup_process_and_threads("inactivity")
|
|
854
|
+
|
|
855
|
+
time.sleep(0.1)
|
|
856
|
+
|
|
857
|
+
if stdout_thread:
|
|
858
|
+
stdout_thread.join(timeout=5)
|
|
859
|
+
if stderr_thread:
|
|
860
|
+
stderr_thread.join(timeout=5)
|
|
861
|
+
|
|
862
|
+
exit_code = process.returncode
|
|
863
|
+
execution_time = time.time() - start_time
|
|
864
|
+
|
|
865
|
+
try:
|
|
866
|
+
if process.stdout and not process.stdout.closed:
|
|
867
|
+
process.stdout.close()
|
|
868
|
+
if process.stderr and not process.stderr.closed:
|
|
869
|
+
process.stderr.close()
|
|
870
|
+
if process.stdin and not process.stdin.closed:
|
|
871
|
+
process.stdin.close()
|
|
872
|
+
except (OSError, ValueError):
|
|
873
|
+
pass
|
|
874
|
+
|
|
875
|
+
_unregister_process(process)
|
|
876
|
+
|
|
877
|
+
# Apply line length limits to stdout/stderr before returning
|
|
878
|
+
truncated_stdout = stdout_lines[-256:]
|
|
879
|
+
truncated_stderr = stderr_lines[-256:]
|
|
880
|
+
|
|
881
|
+
# Emit structured ShellOutputMessage for the UI (skip for silent sub-agents)
|
|
882
|
+
if not silent:
|
|
883
|
+
shell_output_msg = ShellOutputMessage(
|
|
884
|
+
command=command,
|
|
885
|
+
stdout="\n".join(truncated_stdout),
|
|
886
|
+
stderr="\n".join(truncated_stderr),
|
|
887
|
+
exit_code=exit_code,
|
|
888
|
+
duration_seconds=execution_time,
|
|
889
|
+
)
|
|
890
|
+
get_message_bus().emit(shell_output_msg)
|
|
891
|
+
|
|
892
|
+
with _ACTIVE_STOP_EVENTS_LOCK:
|
|
893
|
+
_ACTIVE_STOP_EVENTS.discard(stop_event)
|
|
894
|
+
|
|
895
|
+
if exit_code != 0:
|
|
896
|
+
time.sleep(1)
|
|
897
|
+
return ShellCommandOutput(
|
|
898
|
+
success=False,
|
|
899
|
+
command=command,
|
|
900
|
+
error="""The process didn't exit cleanly! If the user_interrupted flag is true,
|
|
901
|
+
please stop all execution and ask the user for clarification!""",
|
|
902
|
+
stdout="\n".join(truncated_stdout),
|
|
903
|
+
stderr="\n".join(truncated_stderr),
|
|
904
|
+
exit_code=exit_code,
|
|
905
|
+
execution_time=execution_time,
|
|
906
|
+
timeout=False,
|
|
907
|
+
user_interrupted=process.pid in _USER_KILLED_PROCESSES,
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
return ShellCommandOutput(
|
|
911
|
+
success=True,
|
|
912
|
+
command=command,
|
|
913
|
+
stdout="\n".join(truncated_stdout),
|
|
914
|
+
stderr="\n".join(truncated_stderr),
|
|
915
|
+
exit_code=exit_code,
|
|
916
|
+
execution_time=execution_time,
|
|
917
|
+
timeout=False,
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
except Exception as e:
|
|
921
|
+
with _ACTIVE_STOP_EVENTS_LOCK:
|
|
922
|
+
_ACTIVE_STOP_EVENTS.discard(stop_event)
|
|
923
|
+
return ShellCommandOutput(
|
|
924
|
+
success=False,
|
|
925
|
+
command=command,
|
|
926
|
+
error=f"Error during streaming execution: {str(e)}",
|
|
927
|
+
stdout="\n".join(stdout_lines[-256:]),
|
|
928
|
+
stderr="\n".join(stderr_lines[-256:]),
|
|
929
|
+
exit_code=-1,
|
|
930
|
+
timeout=False,
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
async def run_shell_command(
|
|
935
|
+
context: RunContext,
|
|
936
|
+
command: str,
|
|
937
|
+
cwd: str = None,
|
|
938
|
+
timeout: int = 60,
|
|
939
|
+
background: bool = False,
|
|
940
|
+
) -> ShellCommandOutput:
|
|
941
|
+
# Generate unique group_id for this command execution
|
|
942
|
+
group_id = generate_group_id("shell_command", command)
|
|
943
|
+
|
|
944
|
+
# Invoke safety check callbacks (only active in yolo_mode)
|
|
945
|
+
# This allows plugins to intercept and assess commands before execution
|
|
946
|
+
from code_puppy.callbacks import on_run_shell_command
|
|
947
|
+
|
|
948
|
+
callback_results = await on_run_shell_command(context, command, cwd, timeout)
|
|
949
|
+
|
|
950
|
+
# Check if any callback blocked the command
|
|
951
|
+
# Callbacks can return None (allow) or a dict with blocked=True (reject)
|
|
952
|
+
for result in callback_results:
|
|
953
|
+
if result and isinstance(result, dict) and result.get("blocked"):
|
|
954
|
+
return ShellCommandOutput(
|
|
955
|
+
success=False,
|
|
956
|
+
command=command,
|
|
957
|
+
error=result.get("error_message", "Command blocked by safety check"),
|
|
958
|
+
user_feedback=result.get("reasoning", ""),
|
|
959
|
+
stdout=None,
|
|
960
|
+
stderr=None,
|
|
961
|
+
exit_code=None,
|
|
962
|
+
execution_time=None,
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
# Handle background execution - runs command detached and returns immediately
|
|
966
|
+
# This happens BEFORE user confirmation since we don't wait for the command
|
|
967
|
+
if background:
|
|
968
|
+
# Create temp log file for output
|
|
969
|
+
log_file = tempfile.NamedTemporaryFile(
|
|
970
|
+
mode="w",
|
|
971
|
+
prefix="shell_bg_",
|
|
972
|
+
suffix=".log",
|
|
973
|
+
delete=False, # Keep file so agent can read it later
|
|
974
|
+
)
|
|
975
|
+
log_file_path = log_file.name
|
|
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
|
+
try:
|
|
1034
|
+
log_file.close()
|
|
1035
|
+
except Exception:
|
|
1036
|
+
pass
|
|
1037
|
+
# Clean up the temp file on error since no process will write to it
|
|
1038
|
+
try:
|
|
1039
|
+
os.unlink(log_file_path)
|
|
1040
|
+
except OSError:
|
|
1041
|
+
pass
|
|
1042
|
+
# Emit error message so user sees what happened
|
|
1043
|
+
emit_error(f"❌ Failed to start background process: {e}")
|
|
1044
|
+
return ShellCommandOutput(
|
|
1045
|
+
success=False,
|
|
1046
|
+
command=command,
|
|
1047
|
+
error=f"Failed to start background process: {e}",
|
|
1048
|
+
stdout=None,
|
|
1049
|
+
stderr=None,
|
|
1050
|
+
exit_code=None,
|
|
1051
|
+
execution_time=None,
|
|
1052
|
+
background=True,
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
# Rest of the existing function continues...
|
|
1056
|
+
if not command or not command.strip():
|
|
1057
|
+
emit_error("Command cannot be empty", message_group=group_id)
|
|
1058
|
+
return ShellCommandOutput(
|
|
1059
|
+
**{"success": False, "error": "Command cannot be empty"}
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
from code_puppy.config import get_yolo_mode
|
|
1063
|
+
|
|
1064
|
+
yolo_mode = get_yolo_mode()
|
|
1065
|
+
|
|
1066
|
+
# Check if we're running as a sub-agent (skip confirmation and run silently)
|
|
1067
|
+
running_as_subagent = is_subagent()
|
|
1068
|
+
|
|
1069
|
+
confirmation_lock_acquired = False
|
|
1070
|
+
|
|
1071
|
+
# Only ask for confirmation if we're in an interactive TTY, not in yolo mode,
|
|
1072
|
+
# and NOT running as a sub-agent (sub-agents run without user interaction)
|
|
1073
|
+
if not yolo_mode and not running_as_subagent and sys.stdin.isatty():
|
|
1074
|
+
confirmation_lock_acquired = _CONFIRMATION_LOCK.acquire(blocking=False)
|
|
1075
|
+
if not confirmation_lock_acquired:
|
|
1076
|
+
return ShellCommandOutput(
|
|
1077
|
+
success=False,
|
|
1078
|
+
command=command,
|
|
1079
|
+
error="Another command is currently awaiting confirmation",
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
# Get puppy name for personalized messages
|
|
1083
|
+
from code_puppy.config import get_puppy_name
|
|
1084
|
+
|
|
1085
|
+
puppy_name = get_puppy_name().title()
|
|
1086
|
+
|
|
1087
|
+
# Build panel content
|
|
1088
|
+
panel_content = Text()
|
|
1089
|
+
panel_content.append("⚡ Requesting permission to run:\n", style="bold yellow")
|
|
1090
|
+
panel_content.append("$ ", style="bold green")
|
|
1091
|
+
panel_content.append(command, style="bold white")
|
|
1092
|
+
|
|
1093
|
+
if cwd:
|
|
1094
|
+
panel_content.append("\n\n", style="")
|
|
1095
|
+
panel_content.append("📂 Working directory: ", style="dim")
|
|
1096
|
+
panel_content.append(cwd, style="dim cyan")
|
|
1097
|
+
|
|
1098
|
+
# Use the common approval function (async version)
|
|
1099
|
+
confirmed, user_feedback = await get_user_approval_async(
|
|
1100
|
+
title="Shell Command",
|
|
1101
|
+
content=panel_content,
|
|
1102
|
+
preview=None,
|
|
1103
|
+
border_style="dim white",
|
|
1104
|
+
puppy_name=puppy_name,
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
# Release lock after approval
|
|
1108
|
+
if confirmation_lock_acquired:
|
|
1109
|
+
_CONFIRMATION_LOCK.release()
|
|
1110
|
+
|
|
1111
|
+
if not confirmed:
|
|
1112
|
+
if user_feedback:
|
|
1113
|
+
result = ShellCommandOutput(
|
|
1114
|
+
success=False,
|
|
1115
|
+
command=command,
|
|
1116
|
+
error=f"USER REJECTED: {user_feedback}",
|
|
1117
|
+
user_feedback=user_feedback,
|
|
1118
|
+
stdout=None,
|
|
1119
|
+
stderr=None,
|
|
1120
|
+
exit_code=None,
|
|
1121
|
+
execution_time=None,
|
|
1122
|
+
)
|
|
1123
|
+
else:
|
|
1124
|
+
result = ShellCommandOutput(
|
|
1125
|
+
success=False,
|
|
1126
|
+
command=command,
|
|
1127
|
+
error="User rejected the command!",
|
|
1128
|
+
stdout=None,
|
|
1129
|
+
stderr=None,
|
|
1130
|
+
exit_code=None,
|
|
1131
|
+
execution_time=None,
|
|
1132
|
+
)
|
|
1133
|
+
return result
|
|
1134
|
+
else:
|
|
1135
|
+
time.time()
|
|
1136
|
+
|
|
1137
|
+
# Execute the command - sub-agents run silently without keyboard context
|
|
1138
|
+
return await _execute_shell_command(
|
|
1139
|
+
command=command,
|
|
1140
|
+
cwd=cwd,
|
|
1141
|
+
timeout=timeout,
|
|
1142
|
+
group_id=group_id,
|
|
1143
|
+
silent=running_as_subagent,
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
async def _execute_shell_command(
|
|
1148
|
+
command: str,
|
|
1149
|
+
cwd: str | None,
|
|
1150
|
+
timeout: int,
|
|
1151
|
+
group_id: str,
|
|
1152
|
+
silent: bool = False,
|
|
1153
|
+
) -> ShellCommandOutput:
|
|
1154
|
+
"""Internal helper to execute a shell command.
|
|
1155
|
+
|
|
1156
|
+
Args:
|
|
1157
|
+
command: The shell command to execute
|
|
1158
|
+
cwd: Working directory for command execution
|
|
1159
|
+
timeout: Inactivity timeout in seconds
|
|
1160
|
+
group_id: Unique group ID for message grouping
|
|
1161
|
+
silent: If True, suppress streaming output (for sub-agents)
|
|
1162
|
+
|
|
1163
|
+
Returns:
|
|
1164
|
+
ShellCommandOutput with execution results
|
|
1165
|
+
"""
|
|
1166
|
+
# Always emit the ShellStartMessage banner (even for sub-agents)
|
|
1167
|
+
bus = get_message_bus()
|
|
1168
|
+
bus.emit(
|
|
1169
|
+
ShellStartMessage(
|
|
1170
|
+
command=command,
|
|
1171
|
+
cwd=cwd,
|
|
1172
|
+
timeout=timeout,
|
|
1173
|
+
)
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
# Pause spinner during shell command so \r output can work properly
|
|
1177
|
+
from code_puppy.messaging.spinner import pause_all_spinners, resume_all_spinners
|
|
1178
|
+
|
|
1179
|
+
pause_all_spinners()
|
|
1180
|
+
|
|
1181
|
+
# Acquire shared keyboard context - Ctrl-X/Ctrl-C will kill ALL running commands
|
|
1182
|
+
# This is reference-counted: listener starts on first command, stops on last
|
|
1183
|
+
_acquire_keyboard_context()
|
|
1184
|
+
try:
|
|
1185
|
+
return await _run_command_inner(command, cwd, timeout, group_id, silent=silent)
|
|
1186
|
+
finally:
|
|
1187
|
+
_release_keyboard_context()
|
|
1188
|
+
resume_all_spinners()
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _run_command_sync(
|
|
1192
|
+
command: str,
|
|
1193
|
+
cwd: str | None,
|
|
1194
|
+
timeout: int,
|
|
1195
|
+
group_id: str,
|
|
1196
|
+
silent: bool = False,
|
|
1197
|
+
) -> ShellCommandOutput:
|
|
1198
|
+
"""Synchronous command execution - runs in thread pool."""
|
|
1199
|
+
creationflags = 0
|
|
1200
|
+
preexec_fn = None
|
|
1201
|
+
if sys.platform.startswith("win"):
|
|
1202
|
+
try:
|
|
1203
|
+
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
|
|
1204
|
+
except Exception:
|
|
1205
|
+
creationflags = 0
|
|
1206
|
+
else:
|
|
1207
|
+
preexec_fn = os.setsid if hasattr(os, "setsid") else None
|
|
1208
|
+
|
|
1209
|
+
import io
|
|
1210
|
+
|
|
1211
|
+
process = subprocess.Popen(
|
|
1212
|
+
command,
|
|
1213
|
+
shell=True,
|
|
1214
|
+
stdout=subprocess.PIPE,
|
|
1215
|
+
stderr=subprocess.PIPE,
|
|
1216
|
+
cwd=cwd,
|
|
1217
|
+
bufsize=0, # Unbuffered for real-time output
|
|
1218
|
+
preexec_fn=preexec_fn,
|
|
1219
|
+
creationflags=creationflags,
|
|
1220
|
+
)
|
|
1221
|
+
|
|
1222
|
+
# Wrap pipes with TextIOWrapper that preserves \r (newline='' disables translation)
|
|
1223
|
+
process.stdout = io.TextIOWrapper(
|
|
1224
|
+
process.stdout, newline="", encoding="utf-8", errors="replace"
|
|
1225
|
+
)
|
|
1226
|
+
process.stderr = io.TextIOWrapper(
|
|
1227
|
+
process.stderr, newline="", encoding="utf-8", errors="replace"
|
|
1228
|
+
)
|
|
1229
|
+
_register_process(process)
|
|
1230
|
+
try:
|
|
1231
|
+
return run_shell_command_streaming(
|
|
1232
|
+
process, timeout=timeout, command=command, group_id=group_id, silent=silent
|
|
1233
|
+
)
|
|
1234
|
+
finally:
|
|
1235
|
+
# Ensure unregistration in case streaming returned early or raised
|
|
1236
|
+
_unregister_process(process)
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
async def _run_command_inner(
|
|
1240
|
+
command: str,
|
|
1241
|
+
cwd: str | None,
|
|
1242
|
+
timeout: int,
|
|
1243
|
+
group_id: str,
|
|
1244
|
+
silent: bool = False,
|
|
1245
|
+
) -> ShellCommandOutput:
|
|
1246
|
+
"""Inner command execution logic - runs blocking code in thread pool."""
|
|
1247
|
+
loop = asyncio.get_running_loop()
|
|
1248
|
+
try:
|
|
1249
|
+
# Run the blocking shell command in a thread pool to avoid blocking the event loop
|
|
1250
|
+
# This allows multiple sub-agents to run shell commands in parallel
|
|
1251
|
+
return await loop.run_in_executor(
|
|
1252
|
+
_SHELL_EXECUTOR,
|
|
1253
|
+
partial(_run_command_sync, command, cwd, timeout, group_id, silent),
|
|
1254
|
+
)
|
|
1255
|
+
except Exception as e:
|
|
1256
|
+
if not silent:
|
|
1257
|
+
emit_error(traceback.format_exc(), message_group=group_id)
|
|
1258
|
+
if "stdout" not in locals():
|
|
1259
|
+
stdout = None
|
|
1260
|
+
if "stderr" not in locals():
|
|
1261
|
+
stderr = None
|
|
1262
|
+
|
|
1263
|
+
# Apply line length limits to stdout/stderr if they exist
|
|
1264
|
+
truncated_stdout = None
|
|
1265
|
+
if stdout:
|
|
1266
|
+
stdout_lines = stdout.split("\n")
|
|
1267
|
+
truncated_stdout = "\n".join(
|
|
1268
|
+
[_truncate_line(line) for line in stdout_lines[-256:]]
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
truncated_stderr = None
|
|
1272
|
+
if stderr:
|
|
1273
|
+
stderr_lines = stderr.split("\n")
|
|
1274
|
+
truncated_stderr = "\n".join(
|
|
1275
|
+
[_truncate_line(line) for line in stderr_lines[-256:]]
|
|
1276
|
+
)
|
|
1277
|
+
|
|
1278
|
+
return ShellCommandOutput(
|
|
1279
|
+
success=False,
|
|
1280
|
+
command=command,
|
|
1281
|
+
error=f"Error executing command {str(e)}",
|
|
1282
|
+
stdout=truncated_stdout,
|
|
1283
|
+
stderr=truncated_stderr,
|
|
1284
|
+
exit_code=-1,
|
|
1285
|
+
timeout=False,
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
class ReasoningOutput(BaseModel):
|
|
1290
|
+
success: bool = True
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def share_your_reasoning(
|
|
1294
|
+
context: RunContext, reasoning: str, next_steps: str | List[str] | None = None
|
|
1295
|
+
) -> ReasoningOutput:
|
|
1296
|
+
# Handle list of next steps by formatting them
|
|
1297
|
+
formatted_next_steps = next_steps
|
|
1298
|
+
if isinstance(next_steps, list):
|
|
1299
|
+
formatted_next_steps = "\n".join(
|
|
1300
|
+
[f"{i + 1}. {step}" for i, step in enumerate(next_steps)]
|
|
1301
|
+
)
|
|
1302
|
+
|
|
1303
|
+
# Emit structured AgentReasoningMessage for the UI
|
|
1304
|
+
reasoning_msg = AgentReasoningMessage(
|
|
1305
|
+
reasoning=reasoning,
|
|
1306
|
+
next_steps=formatted_next_steps
|
|
1307
|
+
if formatted_next_steps and formatted_next_steps.strip()
|
|
1308
|
+
else None,
|
|
1309
|
+
)
|
|
1310
|
+
get_message_bus().emit(reasoning_msg)
|
|
1311
|
+
|
|
1312
|
+
return ReasoningOutput(success=True)
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def register_agent_run_shell_command(agent):
|
|
1316
|
+
"""Register only the agent_run_shell_command tool."""
|
|
1317
|
+
|
|
1318
|
+
@agent.tool
|
|
1319
|
+
async def agent_run_shell_command(
|
|
1320
|
+
context: RunContext,
|
|
1321
|
+
command: str = "",
|
|
1322
|
+
cwd: str = None,
|
|
1323
|
+
timeout: int = 60,
|
|
1324
|
+
background: bool = False,
|
|
1325
|
+
) -> ShellCommandOutput:
|
|
1326
|
+
"""Execute a shell command with comprehensive monitoring and safety features.
|
|
1327
|
+
|
|
1328
|
+
Supports streaming output, timeout handling, and background execution.
|
|
1329
|
+
"""
|
|
1330
|
+
return await run_shell_command(context, command, cwd, timeout, background)
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def register_agent_share_your_reasoning(agent):
|
|
1334
|
+
"""Register only the agent_share_your_reasoning tool."""
|
|
1335
|
+
|
|
1336
|
+
@agent.tool
|
|
1337
|
+
def agent_share_your_reasoning(
|
|
1338
|
+
context: RunContext,
|
|
1339
|
+
reasoning: str = "",
|
|
1340
|
+
next_steps: str | List[str] | None = None,
|
|
1341
|
+
) -> ReasoningOutput:
|
|
1342
|
+
"""Share the agent's current reasoning and planned next steps with the user.
|
|
1343
|
+
|
|
1344
|
+
Displays reasoning and upcoming actions in a formatted panel for transparency.
|
|
1345
|
+
"""
|
|
1346
|
+
return share_your_reasoning(context, reasoning, next_steps)
|