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

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