code-puppy 0.0.169__py3-none-any.whl → 0.0.366__py3-none-any.whl

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