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.
Files changed (288) hide show
  1. code_puppy/__init__.py +10 -0
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agents/__init__.py +31 -0
  4. code_puppy/agents/agent_c_reviewer.py +155 -0
  5. code_puppy/agents/agent_code_puppy.py +117 -0
  6. code_puppy/agents/agent_code_reviewer.py +90 -0
  7. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  8. code_puppy/agents/agent_creator_agent.py +638 -0
  9. code_puppy/agents/agent_golang_reviewer.py +151 -0
  10. code_puppy/agents/agent_helios.py +124 -0
  11. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  12. code_puppy/agents/agent_manager.py +742 -0
  13. code_puppy/agents/agent_pack_leader.py +385 -0
  14. code_puppy/agents/agent_planning.py +165 -0
  15. code_puppy/agents/agent_python_programmer.py +169 -0
  16. code_puppy/agents/agent_python_reviewer.py +90 -0
  17. code_puppy/agents/agent_qa_expert.py +163 -0
  18. code_puppy/agents/agent_qa_kitten.py +208 -0
  19. code_puppy/agents/agent_scheduler.py +121 -0
  20. code_puppy/agents/agent_security_auditor.py +181 -0
  21. code_puppy/agents/agent_terminal_qa.py +323 -0
  22. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  23. code_puppy/agents/base_agent.py +2156 -0
  24. code_puppy/agents/event_stream_handler.py +348 -0
  25. code_puppy/agents/json_agent.py +202 -0
  26. code_puppy/agents/pack/__init__.py +34 -0
  27. code_puppy/agents/pack/bloodhound.py +304 -0
  28. code_puppy/agents/pack/husky.py +327 -0
  29. code_puppy/agents/pack/retriever.py +393 -0
  30. code_puppy/agents/pack/shepherd.py +348 -0
  31. code_puppy/agents/pack/terrier.py +287 -0
  32. code_puppy/agents/pack/watchdog.py +367 -0
  33. code_puppy/agents/prompt_reviewer.py +145 -0
  34. code_puppy/agents/subagent_stream_handler.py +276 -0
  35. code_puppy/api/__init__.py +13 -0
  36. code_puppy/api/app.py +169 -0
  37. code_puppy/api/main.py +21 -0
  38. code_puppy/api/pty_manager.py +453 -0
  39. code_puppy/api/routers/__init__.py +12 -0
  40. code_puppy/api/routers/agents.py +36 -0
  41. code_puppy/api/routers/commands.py +217 -0
  42. code_puppy/api/routers/config.py +75 -0
  43. code_puppy/api/routers/sessions.py +234 -0
  44. code_puppy/api/templates/terminal.html +361 -0
  45. code_puppy/api/websocket.py +154 -0
  46. code_puppy/callbacks.py +692 -0
  47. code_puppy/chatgpt_codex_client.py +338 -0
  48. code_puppy/claude_cache_client.py +672 -0
  49. code_puppy/cli_runner.py +1073 -0
  50. code_puppy/command_line/__init__.py +1 -0
  51. code_puppy/command_line/add_model_menu.py +1092 -0
  52. code_puppy/command_line/agent_menu.py +662 -0
  53. code_puppy/command_line/attachments.py +395 -0
  54. code_puppy/command_line/autosave_menu.py +704 -0
  55. code_puppy/command_line/clipboard.py +527 -0
  56. code_puppy/command_line/colors_menu.py +532 -0
  57. code_puppy/command_line/command_handler.py +293 -0
  58. code_puppy/command_line/command_registry.py +150 -0
  59. code_puppy/command_line/config_commands.py +719 -0
  60. code_puppy/command_line/core_commands.py +867 -0
  61. code_puppy/command_line/diff_menu.py +865 -0
  62. code_puppy/command_line/file_path_completion.py +73 -0
  63. code_puppy/command_line/load_context_completion.py +52 -0
  64. code_puppy/command_line/mcp/__init__.py +10 -0
  65. code_puppy/command_line/mcp/base.py +32 -0
  66. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  67. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  68. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  69. code_puppy/command_line/mcp/edit_command.py +148 -0
  70. code_puppy/command_line/mcp/handler.py +138 -0
  71. code_puppy/command_line/mcp/help_command.py +147 -0
  72. code_puppy/command_line/mcp/install_command.py +214 -0
  73. code_puppy/command_line/mcp/install_menu.py +705 -0
  74. code_puppy/command_line/mcp/list_command.py +94 -0
  75. code_puppy/command_line/mcp/logs_command.py +235 -0
  76. code_puppy/command_line/mcp/remove_command.py +82 -0
  77. code_puppy/command_line/mcp/restart_command.py +100 -0
  78. code_puppy/command_line/mcp/search_command.py +123 -0
  79. code_puppy/command_line/mcp/start_all_command.py +135 -0
  80. code_puppy/command_line/mcp/start_command.py +117 -0
  81. code_puppy/command_line/mcp/status_command.py +184 -0
  82. code_puppy/command_line/mcp/stop_all_command.py +112 -0
  83. code_puppy/command_line/mcp/stop_command.py +80 -0
  84. code_puppy/command_line/mcp/test_command.py +107 -0
  85. code_puppy/command_line/mcp/utils.py +129 -0
  86. code_puppy/command_line/mcp/wizard_utils.py +334 -0
  87. code_puppy/command_line/mcp_completion.py +174 -0
  88. code_puppy/command_line/model_picker_completion.py +197 -0
  89. code_puppy/command_line/model_settings_menu.py +932 -0
  90. code_puppy/command_line/motd.py +96 -0
  91. code_puppy/command_line/onboarding_slides.py +179 -0
  92. code_puppy/command_line/onboarding_wizard.py +342 -0
  93. code_puppy/command_line/pin_command_completion.py +329 -0
  94. code_puppy/command_line/prompt_toolkit_completion.py +846 -0
  95. code_puppy/command_line/session_commands.py +302 -0
  96. code_puppy/command_line/shell_passthrough.py +145 -0
  97. code_puppy/command_line/skills_completion.py +160 -0
  98. code_puppy/command_line/uc_menu.py +893 -0
  99. code_puppy/command_line/utils.py +93 -0
  100. code_puppy/command_line/wiggum_state.py +78 -0
  101. code_puppy/config.py +1770 -0
  102. code_puppy/error_logging.py +134 -0
  103. code_puppy/gemini_code_assist.py +385 -0
  104. code_puppy/gemini_model.py +754 -0
  105. code_puppy/hook_engine/README.md +105 -0
  106. code_puppy/hook_engine/__init__.py +21 -0
  107. code_puppy/hook_engine/aliases.py +155 -0
  108. code_puppy/hook_engine/engine.py +221 -0
  109. code_puppy/hook_engine/executor.py +296 -0
  110. code_puppy/hook_engine/matcher.py +156 -0
  111. code_puppy/hook_engine/models.py +240 -0
  112. code_puppy/hook_engine/registry.py +106 -0
  113. code_puppy/hook_engine/validator.py +144 -0
  114. code_puppy/http_utils.py +361 -0
  115. code_puppy/keymap.py +128 -0
  116. code_puppy/main.py +10 -0
  117. code_puppy/mcp_/__init__.py +66 -0
  118. code_puppy/mcp_/async_lifecycle.py +286 -0
  119. code_puppy/mcp_/blocking_startup.py +469 -0
  120. code_puppy/mcp_/captured_stdio_server.py +275 -0
  121. code_puppy/mcp_/circuit_breaker.py +290 -0
  122. code_puppy/mcp_/config_wizard.py +507 -0
  123. code_puppy/mcp_/dashboard.py +308 -0
  124. code_puppy/mcp_/error_isolation.py +407 -0
  125. code_puppy/mcp_/examples/retry_example.py +226 -0
  126. code_puppy/mcp_/health_monitor.py +589 -0
  127. code_puppy/mcp_/managed_server.py +428 -0
  128. code_puppy/mcp_/manager.py +807 -0
  129. code_puppy/mcp_/mcp_logs.py +224 -0
  130. code_puppy/mcp_/registry.py +451 -0
  131. code_puppy/mcp_/retry_manager.py +337 -0
  132. code_puppy/mcp_/server_registry_catalog.py +1126 -0
  133. code_puppy/mcp_/status_tracker.py +355 -0
  134. code_puppy/mcp_/system_tools.py +209 -0
  135. code_puppy/mcp_prompts/__init__.py +1 -0
  136. code_puppy/mcp_prompts/hook_creator.py +103 -0
  137. code_puppy/messaging/__init__.py +255 -0
  138. code_puppy/messaging/bus.py +613 -0
  139. code_puppy/messaging/commands.py +167 -0
  140. code_puppy/messaging/markdown_patches.py +57 -0
  141. code_puppy/messaging/message_queue.py +361 -0
  142. code_puppy/messaging/messages.py +569 -0
  143. code_puppy/messaging/queue_console.py +271 -0
  144. code_puppy/messaging/renderers.py +311 -0
  145. code_puppy/messaging/rich_renderer.py +1158 -0
  146. code_puppy/messaging/spinner/__init__.py +83 -0
  147. code_puppy/messaging/spinner/console_spinner.py +240 -0
  148. code_puppy/messaging/spinner/spinner_base.py +95 -0
  149. code_puppy/messaging/subagent_console.py +460 -0
  150. code_puppy/model_factory.py +848 -0
  151. code_puppy/model_switching.py +63 -0
  152. code_puppy/model_utils.py +168 -0
  153. code_puppy/models.json +174 -0
  154. code_puppy/models_dev_api.json +1 -0
  155. code_puppy/models_dev_parser.py +592 -0
  156. code_puppy/plugins/__init__.py +186 -0
  157. code_puppy/plugins/agent_skills/__init__.py +22 -0
  158. code_puppy/plugins/agent_skills/config.py +175 -0
  159. code_puppy/plugins/agent_skills/discovery.py +136 -0
  160. code_puppy/plugins/agent_skills/downloader.py +392 -0
  161. code_puppy/plugins/agent_skills/installer.py +22 -0
  162. code_puppy/plugins/agent_skills/metadata.py +219 -0
  163. code_puppy/plugins/agent_skills/prompt_builder.py +60 -0
  164. code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
  165. code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
  166. code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
  167. code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
  168. code_puppy/plugins/agent_skills/skills_menu.py +781 -0
  169. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  170. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  171. code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
  172. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  173. code_puppy/plugins/antigravity_oauth/constants.py +133 -0
  174. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  175. code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
  176. code_puppy/plugins/antigravity_oauth/storage.py +288 -0
  177. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  178. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  179. code_puppy/plugins/antigravity_oauth/transport.py +863 -0
  180. code_puppy/plugins/antigravity_oauth/utils.py +168 -0
  181. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  182. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  183. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +329 -0
  184. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
  185. code_puppy/plugins/chatgpt_oauth/test_plugin.py +301 -0
  186. code_puppy/plugins/chatgpt_oauth/utils.py +523 -0
  187. code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
  188. code_puppy/plugins/claude_code_hooks/config.py +137 -0
  189. code_puppy/plugins/claude_code_hooks/register_callbacks.py +175 -0
  190. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  191. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  192. code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
  193. code_puppy/plugins/claude_code_oauth/config.py +52 -0
  194. code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
  195. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  196. code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
  197. code_puppy/plugins/claude_code_oauth/utils.py +640 -0
  198. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  199. code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
  200. code_puppy/plugins/example_custom_command/README.md +280 -0
  201. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  202. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  203. code_puppy/plugins/file_permission_handler/register_callbacks.py +470 -0
  204. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  205. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  206. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  207. code_puppy/plugins/hook_creator/__init__.py +1 -0
  208. code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
  209. code_puppy/plugins/hook_manager/__init__.py +1 -0
  210. code_puppy/plugins/hook_manager/config.py +290 -0
  211. code_puppy/plugins/hook_manager/hooks_menu.py +564 -0
  212. code_puppy/plugins/hook_manager/register_callbacks.py +227 -0
  213. code_puppy/plugins/oauth_puppy_html.py +228 -0
  214. code_puppy/plugins/scheduler/__init__.py +1 -0
  215. code_puppy/plugins/scheduler/register_callbacks.py +88 -0
  216. code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
  217. code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
  218. code_puppy/plugins/shell_safety/__init__.py +6 -0
  219. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  220. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  221. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  222. code_puppy/plugins/synthetic_status/__init__.py +1 -0
  223. code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
  224. code_puppy/plugins/synthetic_status/status_api.py +147 -0
  225. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  226. code_puppy/plugins/universal_constructor/models.py +138 -0
  227. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  228. code_puppy/plugins/universal_constructor/registry.py +302 -0
  229. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  230. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  231. code_puppy/pydantic_patches.py +356 -0
  232. code_puppy/reopenable_async_client.py +232 -0
  233. code_puppy/round_robin_model.py +150 -0
  234. code_puppy/scheduler/__init__.py +41 -0
  235. code_puppy/scheduler/__main__.py +9 -0
  236. code_puppy/scheduler/cli.py +118 -0
  237. code_puppy/scheduler/config.py +126 -0
  238. code_puppy/scheduler/daemon.py +280 -0
  239. code_puppy/scheduler/executor.py +155 -0
  240. code_puppy/scheduler/platform.py +19 -0
  241. code_puppy/scheduler/platform_unix.py +22 -0
  242. code_puppy/scheduler/platform_win.py +32 -0
  243. code_puppy/session_storage.py +338 -0
  244. code_puppy/status_display.py +257 -0
  245. code_puppy/summarization_agent.py +176 -0
  246. code_puppy/terminal_utils.py +418 -0
  247. code_puppy/tools/__init__.py +501 -0
  248. code_puppy/tools/agent_tools.py +603 -0
  249. code_puppy/tools/ask_user_question/__init__.py +26 -0
  250. code_puppy/tools/ask_user_question/constants.py +73 -0
  251. code_puppy/tools/ask_user_question/demo_tui.py +55 -0
  252. code_puppy/tools/ask_user_question/handler.py +232 -0
  253. code_puppy/tools/ask_user_question/models.py +304 -0
  254. code_puppy/tools/ask_user_question/registration.py +26 -0
  255. code_puppy/tools/ask_user_question/renderers.py +309 -0
  256. code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
  257. code_puppy/tools/ask_user_question/theme.py +155 -0
  258. code_puppy/tools/ask_user_question/tui_loop.py +423 -0
  259. code_puppy/tools/browser/__init__.py +37 -0
  260. code_puppy/tools/browser/browser_control.py +289 -0
  261. code_puppy/tools/browser/browser_interactions.py +545 -0
  262. code_puppy/tools/browser/browser_locators.py +640 -0
  263. code_puppy/tools/browser/browser_manager.py +378 -0
  264. code_puppy/tools/browser/browser_navigation.py +251 -0
  265. code_puppy/tools/browser/browser_screenshot.py +179 -0
  266. code_puppy/tools/browser/browser_scripts.py +462 -0
  267. code_puppy/tools/browser/browser_workflows.py +221 -0
  268. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  269. code_puppy/tools/browser/terminal_command_tools.py +534 -0
  270. code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
  271. code_puppy/tools/browser/terminal_tools.py +525 -0
  272. code_puppy/tools/command_runner.py +1346 -0
  273. code_puppy/tools/common.py +1409 -0
  274. code_puppy/tools/display.py +84 -0
  275. code_puppy/tools/file_modifications.py +886 -0
  276. code_puppy/tools/file_operations.py +802 -0
  277. code_puppy/tools/scheduler_tools.py +412 -0
  278. code_puppy/tools/skills_tools.py +244 -0
  279. code_puppy/tools/subagent_context.py +158 -0
  280. code_puppy/tools/tools_content.py +51 -0
  281. code_puppy/tools/universal_constructor.py +889 -0
  282. code_puppy/uvx_detection.py +242 -0
  283. code_puppy/version_checker.py +82 -0
  284. codepp-0.0.437.dist-info/METADATA +766 -0
  285. codepp-0.0.437.dist-info/RECORD +288 -0
  286. codepp-0.0.437.dist-info/WHEEL +4 -0
  287. codepp-0.0.437.dist-info/entry_points.txt +3 -0
  288. 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)