code-puppy 0.0.287__py3-none-any.whl → 0.0.323__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_puppy/__init__.py +3 -1
- code_puppy/agents/agent_code_puppy.py +5 -4
- code_puppy/agents/agent_creator_agent.py +22 -18
- code_puppy/agents/agent_manager.py +2 -2
- code_puppy/agents/base_agent.py +496 -102
- code_puppy/callbacks.py +8 -0
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/cli_runner.py +795 -0
- code_puppy/command_line/add_model_menu.py +19 -16
- code_puppy/command_line/attachments.py +10 -5
- code_puppy/command_line/autosave_menu.py +269 -41
- code_puppy/command_line/colors_menu.py +515 -0
- code_puppy/command_line/command_handler.py +10 -24
- code_puppy/command_line/config_commands.py +106 -25
- code_puppy/command_line/core_commands.py +32 -20
- code_puppy/command_line/mcp/add_command.py +3 -16
- code_puppy/command_line/mcp/base.py +0 -3
- code_puppy/command_line/mcp/catalog_server_installer.py +15 -15
- code_puppy/command_line/mcp/custom_server_form.py +66 -5
- code_puppy/command_line/mcp/custom_server_installer.py +17 -17
- code_puppy/command_line/mcp/edit_command.py +15 -22
- code_puppy/command_line/mcp/handler.py +7 -2
- code_puppy/command_line/mcp/help_command.py +2 -2
- code_puppy/command_line/mcp/install_command.py +10 -14
- code_puppy/command_line/mcp/install_menu.py +2 -6
- code_puppy/command_line/mcp/list_command.py +2 -2
- code_puppy/command_line/mcp/logs_command.py +174 -65
- code_puppy/command_line/mcp/remove_command.py +2 -2
- code_puppy/command_line/mcp/restart_command.py +7 -2
- code_puppy/command_line/mcp/search_command.py +16 -10
- code_puppy/command_line/mcp/start_all_command.py +16 -6
- code_puppy/command_line/mcp/start_command.py +12 -10
- code_puppy/command_line/mcp/status_command.py +4 -5
- code_puppy/command_line/mcp/stop_all_command.py +5 -1
- code_puppy/command_line/mcp/stop_command.py +6 -4
- code_puppy/command_line/mcp/test_command.py +2 -2
- code_puppy/command_line/mcp/wizard_utils.py +20 -16
- code_puppy/command_line/model_settings_menu.py +53 -7
- code_puppy/command_line/motd.py +1 -1
- code_puppy/command_line/pin_command_completion.py +82 -7
- code_puppy/command_line/prompt_toolkit_completion.py +32 -9
- code_puppy/command_line/session_commands.py +11 -4
- code_puppy/config.py +217 -53
- code_puppy/error_logging.py +118 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/keymap.py +126 -0
- code_puppy/main.py +5 -745
- code_puppy/mcp_/__init__.py +17 -0
- code_puppy/mcp_/blocking_startup.py +63 -36
- code_puppy/mcp_/captured_stdio_server.py +1 -1
- code_puppy/mcp_/config_wizard.py +4 -4
- code_puppy/mcp_/dashboard.py +15 -6
- code_puppy/mcp_/managed_server.py +25 -5
- code_puppy/mcp_/manager.py +65 -0
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/mcp_/registry.py +6 -6
- code_puppy/messaging/__init__.py +184 -2
- code_puppy/messaging/bus.py +610 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +3 -3
- code_puppy/messaging/messages.py +470 -0
- code_puppy/messaging/renderers.py +43 -141
- code_puppy/messaging/rich_renderer.py +900 -0
- code_puppy/messaging/spinner/console_spinner.py +39 -2
- code_puppy/model_factory.py +292 -53
- code_puppy/model_utils.py +57 -48
- code_puppy/models.json +19 -5
- code_puppy/plugins/__init__.py +152 -10
- code_puppy/plugins/chatgpt_oauth/config.py +20 -12
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +3 -3
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +30 -13
- code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
- code_puppy/plugins/claude_code_oauth/config.py +15 -11
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +28 -0
- code_puppy/plugins/claude_code_oauth/utils.py +6 -1
- code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
- code_puppy/plugins/oauth_puppy_html.py +3 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -134
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +77 -3
- code_puppy/prompts/codex_system_prompt.md +310 -0
- code_puppy/pydantic_patches.py +131 -0
- code_puppy/session_storage.py +2 -1
- code_puppy/status_display.py +7 -5
- code_puppy/terminal_utils.py +126 -0
- code_puppy/tools/agent_tools.py +131 -70
- code_puppy/tools/browser/browser_control.py +10 -14
- code_puppy/tools/browser/browser_interactions.py +20 -28
- code_puppy/tools/browser/browser_locators.py +27 -29
- code_puppy/tools/browser/browser_navigation.py +9 -9
- code_puppy/tools/browser/browser_screenshot.py +12 -14
- code_puppy/tools/browser/browser_scripts.py +17 -29
- code_puppy/tools/browser/browser_workflows.py +24 -25
- code_puppy/tools/browser/camoufox_manager.py +22 -26
- code_puppy/tools/command_runner.py +410 -88
- code_puppy/tools/common.py +51 -38
- code_puppy/tools/file_modifications.py +98 -24
- code_puppy/tools/file_operations.py +113 -202
- code_puppy/version_checker.py +28 -13
- {code_puppy-0.0.287.data → code_puppy-0.0.323.data}/data/code_puppy/models.json +19 -5
- {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/METADATA +3 -8
- code_puppy-0.0.323.dist-info/RECORD +168 -0
- code_puppy/tui_state.py +0 -55
- code_puppy-0.0.287.dist-info/RECORD +0 -153
- {code_puppy-0.0.287.data → code_puppy-0.0.323.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import ctypes
|
|
1
2
|
import os
|
|
3
|
+
import select
|
|
2
4
|
import signal
|
|
3
5
|
import subprocess
|
|
4
6
|
import sys
|
|
7
|
+
import tempfile
|
|
5
8
|
import threading
|
|
6
9
|
import time
|
|
7
10
|
import traceback
|
|
@@ -10,18 +13,19 @@ from typing import Callable, Literal, Optional, Set
|
|
|
10
13
|
|
|
11
14
|
from pydantic import BaseModel
|
|
12
15
|
from pydantic_ai import RunContext
|
|
13
|
-
from rich.markdown import Markdown
|
|
14
16
|
from rich.text import Text
|
|
15
17
|
|
|
16
|
-
from code_puppy.messaging import (
|
|
17
|
-
|
|
18
|
+
from code_puppy.messaging import ( # Structured messaging types
|
|
19
|
+
AgentReasoningMessage,
|
|
20
|
+
ShellOutputMessage,
|
|
21
|
+
ShellStartMessage,
|
|
18
22
|
emit_error,
|
|
19
23
|
emit_info,
|
|
20
|
-
|
|
24
|
+
emit_shell_line,
|
|
21
25
|
emit_warning,
|
|
26
|
+
get_message_bus,
|
|
22
27
|
)
|
|
23
28
|
from code_puppy.tools.common import generate_group_id, get_user_approval_async
|
|
24
|
-
from code_puppy.tui_state import is_tui_mode
|
|
25
29
|
|
|
26
30
|
# Maximum line length for shell command output to prevent massive token usage
|
|
27
31
|
# This helps avoid exceeding model context limits when commands produce very long lines
|
|
@@ -35,6 +39,60 @@ def _truncate_line(line: str) -> str:
|
|
|
35
39
|
return line
|
|
36
40
|
|
|
37
41
|
|
|
42
|
+
# Windows-specific: Check if pipe has data available without blocking
|
|
43
|
+
# This is needed because select() doesn't work on pipes on Windows
|
|
44
|
+
if sys.platform.startswith("win"):
|
|
45
|
+
import msvcrt
|
|
46
|
+
|
|
47
|
+
# Load kernel32 for PeekNamedPipe
|
|
48
|
+
_kernel32 = ctypes.windll.kernel32
|
|
49
|
+
|
|
50
|
+
def _win32_pipe_has_data(pipe) -> bool:
|
|
51
|
+
"""Check if a Windows pipe has data available without blocking.
|
|
52
|
+
|
|
53
|
+
Uses PeekNamedPipe from kernel32.dll to check if there's data
|
|
54
|
+
in the pipe buffer without actually reading it.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
pipe: A file object with a fileno() method (e.g., process.stdout)
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
True if data is available, False otherwise (including on error)
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
# Get the Windows handle from the file descriptor
|
|
64
|
+
handle = msvcrt.get_osfhandle(pipe.fileno())
|
|
65
|
+
|
|
66
|
+
# PeekNamedPipe parameters:
|
|
67
|
+
# - hNamedPipe: handle to the pipe
|
|
68
|
+
# - lpBuffer: buffer to receive data (NULL = don't read)
|
|
69
|
+
# - nBufferSize: size of buffer (0 = don't read)
|
|
70
|
+
# - lpBytesRead: receives bytes read (NULL)
|
|
71
|
+
# - lpTotalBytesAvail: receives total bytes available
|
|
72
|
+
# - lpBytesLeftThisMessage: receives bytes left (NULL)
|
|
73
|
+
bytes_available = ctypes.c_ulong(0)
|
|
74
|
+
|
|
75
|
+
result = _kernel32.PeekNamedPipe(
|
|
76
|
+
handle,
|
|
77
|
+
None, # Don't read data
|
|
78
|
+
0, # Buffer size 0
|
|
79
|
+
None, # Don't care about bytes read
|
|
80
|
+
ctypes.byref(bytes_available), # Get bytes available
|
|
81
|
+
None, # Don't care about bytes left in message
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if result:
|
|
85
|
+
return bytes_available.value > 0
|
|
86
|
+
return False
|
|
87
|
+
except (ValueError, OSError, ctypes.ArgumentError):
|
|
88
|
+
# Handle closed, invalid, or other errors
|
|
89
|
+
return False
|
|
90
|
+
else:
|
|
91
|
+
# POSIX stub - not used, but keeps the code clean
|
|
92
|
+
def _win32_pipe_has_data(pipe) -> bool:
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
38
96
|
_AWAITING_USER_INPUT = False
|
|
39
97
|
|
|
40
98
|
_CONFIRMATION_LOCK = threading.Lock()
|
|
@@ -49,6 +107,9 @@ _SHELL_CTRL_X_STOP_EVENT: Optional[threading.Event] = None
|
|
|
49
107
|
_SHELL_CTRL_X_THREAD: Optional[threading.Thread] = None
|
|
50
108
|
_ORIGINAL_SIGINT_HANDLER = None
|
|
51
109
|
|
|
110
|
+
# Stop event to signal reader threads to terminate
|
|
111
|
+
_READER_STOP_EVENT: Optional[threading.Event] = None
|
|
112
|
+
|
|
52
113
|
|
|
53
114
|
def _register_process(proc: subprocess.Popen) -> None:
|
|
54
115
|
with _RUNNING_PROCESSES_LOCK:
|
|
@@ -128,23 +189,67 @@ def _kill_process_group(proc: subprocess.Popen) -> None:
|
|
|
128
189
|
|
|
129
190
|
|
|
130
191
|
def kill_all_running_shell_processes() -> int:
|
|
131
|
-
"""Kill all currently tracked running shell processes.
|
|
192
|
+
"""Kill all currently tracked running shell processes and stop reader threads.
|
|
132
193
|
|
|
133
194
|
Returns the number of processes signaled.
|
|
195
|
+
|
|
196
|
+
Implementation notes:
|
|
197
|
+
- Atomically snapshot and clear the registry to prevent race conditions
|
|
198
|
+
- Deduplicate by PID to ensure each process is killed at most once
|
|
199
|
+
- Let exceptions from _kill_process_group propagate (tests expect this)
|
|
134
200
|
"""
|
|
135
|
-
|
|
201
|
+
global _READER_STOP_EVENT
|
|
202
|
+
|
|
203
|
+
# Signal reader threads to stop
|
|
204
|
+
if _READER_STOP_EVENT:
|
|
205
|
+
_READER_STOP_EVENT.set()
|
|
206
|
+
|
|
207
|
+
# Atomically take snapshot and clear registry
|
|
208
|
+
# This prevents other threads from seeing/processing the same processes
|
|
136
209
|
with _RUNNING_PROCESSES_LOCK:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
210
|
+
procs_snapshot = list(_RUNNING_PROCESSES)
|
|
211
|
+
_RUNNING_PROCESSES.clear()
|
|
212
|
+
|
|
213
|
+
# Deduplicate by pid to ensure at-most-one kill per process
|
|
214
|
+
seen_pids: set = set()
|
|
215
|
+
killed_count = 0
|
|
216
|
+
|
|
217
|
+
for proc in procs_snapshot:
|
|
218
|
+
if proc is None:
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
pid = getattr(proc, "pid", None)
|
|
222
|
+
key = pid if pid is not None else id(proc)
|
|
223
|
+
|
|
224
|
+
if key in seen_pids:
|
|
225
|
+
continue
|
|
226
|
+
seen_pids.add(key)
|
|
227
|
+
|
|
228
|
+
# Close pipes first to unblock readline()
|
|
140
229
|
try:
|
|
141
|
-
if
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
230
|
+
if proc.stdout and not proc.stdout.closed:
|
|
231
|
+
proc.stdout.close()
|
|
232
|
+
if proc.stderr and not proc.stderr.closed:
|
|
233
|
+
proc.stderr.close()
|
|
234
|
+
if proc.stdin and not proc.stdin.closed:
|
|
235
|
+
proc.stdin.close()
|
|
236
|
+
except (OSError, ValueError):
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
# Only attempt to kill processes that are still running
|
|
240
|
+
if proc.poll() is None:
|
|
241
|
+
# Let exceptions bubble up (tests expect this behavior)
|
|
242
|
+
_kill_process_group(proc)
|
|
243
|
+
killed_count += 1
|
|
244
|
+
|
|
245
|
+
# Track user-killed PIDs
|
|
246
|
+
if pid is not None:
|
|
247
|
+
try:
|
|
248
|
+
_USER_KILLED_PROCESSES.add(pid)
|
|
249
|
+
except Exception:
|
|
250
|
+
pass # Non-fatal bookkeeping
|
|
251
|
+
|
|
252
|
+
return killed_count
|
|
148
253
|
|
|
149
254
|
|
|
150
255
|
def get_running_shell_process_count() -> int:
|
|
@@ -205,6 +310,9 @@ class ShellCommandOutput(BaseModel):
|
|
|
205
310
|
timeout: bool | None = False
|
|
206
311
|
user_interrupted: bool | None = False
|
|
207
312
|
user_feedback: str | None = None # User feedback when command is rejected
|
|
313
|
+
background: bool = False # True if command was run in background mode
|
|
314
|
+
log_file: str | None = None # Path to temp log file for background commands
|
|
315
|
+
pid: int | None = None # Process ID for background commands
|
|
208
316
|
|
|
209
317
|
|
|
210
318
|
class ShellSafetyAssessment(BaseModel):
|
|
@@ -214,15 +322,18 @@ class ShellSafetyAssessment(BaseModel):
|
|
|
214
322
|
It provides a risk level classification and reasoning for that assessment.
|
|
215
323
|
|
|
216
324
|
Attributes:
|
|
217
|
-
risk: Risk level classification. Can be
|
|
325
|
+
risk: Risk level classification. Can be one of:
|
|
218
326
|
'none' (completely safe), 'low' (minimal risk), 'medium' (moderate risk),
|
|
219
327
|
'high' (significant risk), 'critical' (severe/destructive risk).
|
|
220
328
|
reasoning: Brief explanation (max 1-2 sentences) of why this risk level
|
|
221
329
|
was assigned. Should be concise and actionable.
|
|
330
|
+
is_fallback: Whether this assessment is a fallback due to parsing failure.
|
|
331
|
+
Fallback assessments are not cached to allow retry with fresh LLM responses.
|
|
222
332
|
"""
|
|
223
333
|
|
|
224
|
-
risk: Literal["none", "low", "medium", "high", "critical"]
|
|
334
|
+
risk: Literal["none", "low", "medium", "high", "critical"]
|
|
225
335
|
reasoning: str
|
|
336
|
+
is_fallback: bool = False
|
|
226
337
|
|
|
227
338
|
|
|
228
339
|
def _listen_for_ctrl_x_windows(
|
|
@@ -354,11 +465,6 @@ def _shell_command_keyboard_context():
|
|
|
354
465
|
"""
|
|
355
466
|
global _SHELL_CTRL_X_STOP_EVENT, _SHELL_CTRL_X_THREAD, _ORIGINAL_SIGINT_HANDLER
|
|
356
467
|
|
|
357
|
-
# Skip all this in TUI mode
|
|
358
|
-
if is_tui_mode():
|
|
359
|
-
yield
|
|
360
|
-
return
|
|
361
|
-
|
|
362
468
|
# Handler for Ctrl-X: kill all running shell processes
|
|
363
469
|
def handle_ctrl_x_press() -> None:
|
|
364
470
|
emit_warning("\n🛑 Ctrl-X detected! Interrupting shell command...")
|
|
@@ -416,6 +522,9 @@ def run_shell_command_streaming(
|
|
|
416
522
|
command: str = "",
|
|
417
523
|
group_id: str = None,
|
|
418
524
|
):
|
|
525
|
+
global _READER_STOP_EVENT
|
|
526
|
+
_READER_STOP_EVENT = threading.Event()
|
|
527
|
+
|
|
419
528
|
start_time = time.time()
|
|
420
529
|
last_output_time = [start_time]
|
|
421
530
|
|
|
@@ -429,27 +538,132 @@ def run_shell_command_streaming(
|
|
|
429
538
|
|
|
430
539
|
def read_stdout():
|
|
431
540
|
try:
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
541
|
+
fd = process.stdout.fileno()
|
|
542
|
+
except (ValueError, OSError):
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
try:
|
|
546
|
+
while True:
|
|
547
|
+
# Check stop event first
|
|
548
|
+
if _READER_STOP_EVENT and _READER_STOP_EVENT.is_set():
|
|
549
|
+
break
|
|
550
|
+
|
|
551
|
+
# Use select to check if data is available (with timeout)
|
|
552
|
+
if sys.platform.startswith("win"):
|
|
553
|
+
# Windows doesn't support select on pipes
|
|
554
|
+
# Use PeekNamedPipe via _win32_pipe_has_data() to check
|
|
555
|
+
# if data is available without blocking
|
|
556
|
+
try:
|
|
557
|
+
if _win32_pipe_has_data(process.stdout):
|
|
558
|
+
line = process.stdout.readline()
|
|
559
|
+
if not line: # EOF
|
|
560
|
+
break
|
|
561
|
+
line = line.rstrip("\n\r")
|
|
562
|
+
line = _truncate_line(line)
|
|
563
|
+
stdout_lines.append(line)
|
|
564
|
+
emit_shell_line(line, stream="stdout")
|
|
565
|
+
last_output_time[0] = time.time()
|
|
566
|
+
else:
|
|
567
|
+
# No data available, check if process has exited
|
|
568
|
+
if process.poll() is not None:
|
|
569
|
+
# Process exited, do one final drain
|
|
570
|
+
try:
|
|
571
|
+
remaining = process.stdout.read()
|
|
572
|
+
if remaining:
|
|
573
|
+
for line in remaining.splitlines():
|
|
574
|
+
line = _truncate_line(line)
|
|
575
|
+
stdout_lines.append(line)
|
|
576
|
+
emit_shell_line(line, stream="stdout")
|
|
577
|
+
except (ValueError, OSError):
|
|
578
|
+
pass
|
|
579
|
+
break
|
|
580
|
+
# Sleep briefly to avoid busy-waiting (100ms like POSIX)
|
|
581
|
+
time.sleep(0.1)
|
|
582
|
+
except (ValueError, OSError):
|
|
583
|
+
break
|
|
584
|
+
else:
|
|
585
|
+
# POSIX: use select with timeout
|
|
586
|
+
try:
|
|
587
|
+
ready, _, _ = select.select([fd], [], [], 0.1) # 100ms timeout
|
|
588
|
+
except (ValueError, OSError, select.error):
|
|
589
|
+
break
|
|
590
|
+
|
|
591
|
+
if ready:
|
|
592
|
+
line = process.stdout.readline()
|
|
593
|
+
if not line: # EOF
|
|
594
|
+
break
|
|
595
|
+
line = line.rstrip("\n\r")
|
|
596
|
+
line = _truncate_line(line)
|
|
597
|
+
stdout_lines.append(line)
|
|
598
|
+
emit_shell_line(line, stream="stdout")
|
|
599
|
+
last_output_time[0] = time.time()
|
|
600
|
+
# If not ready, loop continues and checks stop event again
|
|
601
|
+
except (ValueError, OSError):
|
|
602
|
+
pass
|
|
440
603
|
except Exception:
|
|
441
604
|
pass
|
|
442
605
|
|
|
443
606
|
def read_stderr():
|
|
444
607
|
try:
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
608
|
+
fd = process.stderr.fileno()
|
|
609
|
+
except (ValueError, OSError):
|
|
610
|
+
return
|
|
611
|
+
|
|
612
|
+
try:
|
|
613
|
+
while True:
|
|
614
|
+
# Check stop event first
|
|
615
|
+
if _READER_STOP_EVENT and _READER_STOP_EVENT.is_set():
|
|
616
|
+
break
|
|
617
|
+
|
|
618
|
+
if sys.platform.startswith("win"):
|
|
619
|
+
# Windows doesn't support select on pipes
|
|
620
|
+
# Use PeekNamedPipe via _win32_pipe_has_data() to check
|
|
621
|
+
# if data is available without blocking
|
|
622
|
+
try:
|
|
623
|
+
if _win32_pipe_has_data(process.stderr):
|
|
624
|
+
line = process.stderr.readline()
|
|
625
|
+
if not line: # EOF
|
|
626
|
+
break
|
|
627
|
+
line = line.rstrip("\n\r")
|
|
628
|
+
line = _truncate_line(line)
|
|
629
|
+
stderr_lines.append(line)
|
|
630
|
+
emit_shell_line(line, stream="stderr")
|
|
631
|
+
last_output_time[0] = time.time()
|
|
632
|
+
else:
|
|
633
|
+
# No data available, check if process has exited
|
|
634
|
+
if process.poll() is not None:
|
|
635
|
+
# Process exited, do one final drain
|
|
636
|
+
try:
|
|
637
|
+
remaining = process.stderr.read()
|
|
638
|
+
if remaining:
|
|
639
|
+
for line in remaining.splitlines():
|
|
640
|
+
line = _truncate_line(line)
|
|
641
|
+
stderr_lines.append(line)
|
|
642
|
+
emit_shell_line(line, stream="stderr")
|
|
643
|
+
except (ValueError, OSError):
|
|
644
|
+
pass
|
|
645
|
+
break
|
|
646
|
+
# Sleep briefly to avoid busy-waiting (100ms like POSIX)
|
|
647
|
+
time.sleep(0.1)
|
|
648
|
+
except (ValueError, OSError):
|
|
649
|
+
break
|
|
650
|
+
else:
|
|
651
|
+
try:
|
|
652
|
+
ready, _, _ = select.select([fd], [], [], 0.1)
|
|
653
|
+
except (ValueError, OSError, select.error):
|
|
654
|
+
break
|
|
655
|
+
|
|
656
|
+
if ready:
|
|
657
|
+
line = process.stderr.readline()
|
|
658
|
+
if not line: # EOF
|
|
659
|
+
break
|
|
660
|
+
line = line.rstrip("\n\r")
|
|
661
|
+
line = _truncate_line(line)
|
|
662
|
+
stderr_lines.append(line)
|
|
663
|
+
emit_shell_line(line, stream="stderr")
|
|
664
|
+
last_output_time[0] = time.time()
|
|
665
|
+
except (ValueError, OSError):
|
|
666
|
+
pass
|
|
453
667
|
except Exception:
|
|
454
668
|
pass
|
|
455
669
|
|
|
@@ -460,6 +674,10 @@ def run_shell_command_streaming(
|
|
|
460
674
|
_kill_process_group(proc)
|
|
461
675
|
|
|
462
676
|
try:
|
|
677
|
+
# Signal reader threads to stop first
|
|
678
|
+
if _READER_STOP_EVENT:
|
|
679
|
+
_READER_STOP_EVENT.set()
|
|
680
|
+
|
|
463
681
|
if process.poll() is None:
|
|
464
682
|
nuclear_kill(process)
|
|
465
683
|
|
|
@@ -520,19 +738,17 @@ def run_shell_command_streaming(
|
|
|
520
738
|
current_time = time.time()
|
|
521
739
|
|
|
522
740
|
if current_time - start_time > ABSOLUTE_TIMEOUT_SECONDS:
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
741
|
+
emit_error(
|
|
742
|
+
"Process killed: absolute timeout reached",
|
|
743
|
+
message_group=group_id,
|
|
526
744
|
)
|
|
527
|
-
emit_error(error_msg, message_group=group_id)
|
|
528
745
|
return cleanup_process_and_threads("absolute")
|
|
529
746
|
|
|
530
747
|
if current_time - last_output_time[0] > timeout:
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
748
|
+
emit_error(
|
|
749
|
+
"Process killed: inactivity timeout reached",
|
|
750
|
+
message_group=group_id,
|
|
534
751
|
)
|
|
535
|
-
emit_error(error_msg, message_group=group_id)
|
|
536
752
|
return cleanup_process_and_threads("inactivity")
|
|
537
753
|
|
|
538
754
|
time.sleep(0.1)
|
|
@@ -557,16 +773,25 @@ def run_shell_command_streaming(
|
|
|
557
773
|
|
|
558
774
|
_unregister_process(process)
|
|
559
775
|
|
|
776
|
+
# Apply line length limits to stdout/stderr before returning
|
|
777
|
+
truncated_stdout = [_truncate_line(line) for line in stdout_lines[-256:]]
|
|
778
|
+
truncated_stderr = [_truncate_line(line) for line in stderr_lines[-256:]]
|
|
779
|
+
|
|
780
|
+
# Emit structured ShellOutputMessage for the UI
|
|
781
|
+
shell_output_msg = ShellOutputMessage(
|
|
782
|
+
command=command,
|
|
783
|
+
stdout="\n".join(truncated_stdout),
|
|
784
|
+
stderr="\n".join(truncated_stderr),
|
|
785
|
+
exit_code=exit_code,
|
|
786
|
+
duration_seconds=execution_time,
|
|
787
|
+
)
|
|
788
|
+
get_message_bus().emit(shell_output_msg)
|
|
789
|
+
|
|
790
|
+
# Reset the stop event now that we're done
|
|
791
|
+
_READER_STOP_EVENT = None
|
|
792
|
+
|
|
560
793
|
if exit_code != 0:
|
|
561
|
-
emit_error(
|
|
562
|
-
f"Command failed with exit code {exit_code}", message_group=group_id
|
|
563
|
-
)
|
|
564
|
-
emit_info(f"Took {execution_time:.2f}s", message_group=group_id)
|
|
565
794
|
time.sleep(1)
|
|
566
|
-
# Apply line length limits to stdout/stderr before returning
|
|
567
|
-
truncated_stdout = [_truncate_line(line) for line in stdout_lines[-256:]]
|
|
568
|
-
truncated_stderr = [_truncate_line(line) for line in stderr_lines[-256:]]
|
|
569
|
-
|
|
570
795
|
return ShellCommandOutput(
|
|
571
796
|
success=False,
|
|
572
797
|
command=command,
|
|
@@ -579,12 +804,9 @@ def run_shell_command_streaming(
|
|
|
579
804
|
timeout=False,
|
|
580
805
|
user_interrupted=process.pid in _USER_KILLED_PROCESSES,
|
|
581
806
|
)
|
|
582
|
-
# Apply line length limits to stdout/stderr before returning
|
|
583
|
-
truncated_stdout = [_truncate_line(line) for line in stdout_lines[-256:]]
|
|
584
|
-
truncated_stderr = [_truncate_line(line) for line in stderr_lines[-256:]]
|
|
585
807
|
|
|
586
808
|
return ShellCommandOutput(
|
|
587
|
-
success=
|
|
809
|
+
success=True,
|
|
588
810
|
command=command,
|
|
589
811
|
stdout="\n".join(truncated_stdout),
|
|
590
812
|
stderr="\n".join(truncated_stderr),
|
|
@@ -594,6 +816,8 @@ def run_shell_command_streaming(
|
|
|
594
816
|
)
|
|
595
817
|
|
|
596
818
|
except Exception as e:
|
|
819
|
+
# Reset the stop event on exception too
|
|
820
|
+
_READER_STOP_EVENT = None
|
|
597
821
|
return ShellCommandOutput(
|
|
598
822
|
success=False,
|
|
599
823
|
command=command,
|
|
@@ -606,18 +830,18 @@ def run_shell_command_streaming(
|
|
|
606
830
|
|
|
607
831
|
|
|
608
832
|
async def run_shell_command(
|
|
609
|
-
context: RunContext,
|
|
833
|
+
context: RunContext,
|
|
834
|
+
command: str,
|
|
835
|
+
cwd: str = None,
|
|
836
|
+
timeout: int = 60,
|
|
837
|
+
background: bool = False,
|
|
610
838
|
) -> ShellCommandOutput:
|
|
611
839
|
command_displayed = False
|
|
840
|
+
start_time = time.time()
|
|
612
841
|
|
|
613
842
|
# Generate unique group_id for this command execution
|
|
614
843
|
group_id = generate_group_id("shell_command", command)
|
|
615
844
|
|
|
616
|
-
emit_info(
|
|
617
|
-
f"\n[bold white on blue] SHELL COMMAND [/bold white on blue] 📂 [bold green]$ {command}[/bold green]",
|
|
618
|
-
message_group=group_id,
|
|
619
|
-
)
|
|
620
|
-
|
|
621
845
|
# Invoke safety check callbacks (only active in yolo_mode)
|
|
622
846
|
# This allows plugins to intercept and assess commands before execution
|
|
623
847
|
from code_puppy.callbacks import on_run_shell_command
|
|
@@ -639,6 +863,86 @@ async def run_shell_command(
|
|
|
639
863
|
execution_time=None,
|
|
640
864
|
)
|
|
641
865
|
|
|
866
|
+
# Handle background execution - runs command detached and returns immediately
|
|
867
|
+
# This happens BEFORE user confirmation since we don't wait for the command
|
|
868
|
+
if background:
|
|
869
|
+
# Create temp log file for output
|
|
870
|
+
log_file = tempfile.NamedTemporaryFile(
|
|
871
|
+
mode="w",
|
|
872
|
+
prefix="shell_bg_",
|
|
873
|
+
suffix=".log",
|
|
874
|
+
delete=False, # Keep file so agent can read it later
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
try:
|
|
878
|
+
# Platform-specific process detachment
|
|
879
|
+
if sys.platform.startswith("win"):
|
|
880
|
+
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
881
|
+
process = subprocess.Popen(
|
|
882
|
+
command,
|
|
883
|
+
shell=True,
|
|
884
|
+
stdout=log_file,
|
|
885
|
+
stderr=subprocess.STDOUT,
|
|
886
|
+
stdin=subprocess.DEVNULL,
|
|
887
|
+
cwd=cwd,
|
|
888
|
+
creationflags=creationflags,
|
|
889
|
+
)
|
|
890
|
+
else:
|
|
891
|
+
process = subprocess.Popen(
|
|
892
|
+
command,
|
|
893
|
+
shell=True,
|
|
894
|
+
stdout=log_file,
|
|
895
|
+
stderr=subprocess.STDOUT,
|
|
896
|
+
stdin=subprocess.DEVNULL,
|
|
897
|
+
cwd=cwd,
|
|
898
|
+
start_new_session=True, # Fully detach on POSIX
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
log_file.close() # Close our handle, process keeps writing
|
|
902
|
+
|
|
903
|
+
# Emit UI messages so user sees what happened
|
|
904
|
+
bus = get_message_bus()
|
|
905
|
+
bus.emit(
|
|
906
|
+
ShellStartMessage(
|
|
907
|
+
command=command,
|
|
908
|
+
cwd=cwd,
|
|
909
|
+
timeout=0, # No timeout for background processes
|
|
910
|
+
)
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
# Emit info about background execution
|
|
914
|
+
emit_info(
|
|
915
|
+
f"🚀 Background process started (PID: {process.pid}) - no timeout, runs until complete"
|
|
916
|
+
)
|
|
917
|
+
emit_info(f"📄 Output logging to: {log_file.name}")
|
|
918
|
+
|
|
919
|
+
# Return immediately - don't wait, don't block
|
|
920
|
+
return ShellCommandOutput(
|
|
921
|
+
success=True,
|
|
922
|
+
command=command,
|
|
923
|
+
stdout=None,
|
|
924
|
+
stderr=None,
|
|
925
|
+
exit_code=None,
|
|
926
|
+
execution_time=0.0,
|
|
927
|
+
background=True,
|
|
928
|
+
log_file=log_file.name,
|
|
929
|
+
pid=process.pid,
|
|
930
|
+
)
|
|
931
|
+
except Exception as e:
|
|
932
|
+
log_file.close()
|
|
933
|
+
# Emit error message so user sees what happened
|
|
934
|
+
emit_error(f"❌ Failed to start background process: {e}")
|
|
935
|
+
return ShellCommandOutput(
|
|
936
|
+
success=False,
|
|
937
|
+
command=command,
|
|
938
|
+
error=f"Failed to start background process: {e}",
|
|
939
|
+
stdout=None,
|
|
940
|
+
stderr=None,
|
|
941
|
+
exit_code=None,
|
|
942
|
+
execution_time=None,
|
|
943
|
+
background=True,
|
|
944
|
+
)
|
|
945
|
+
|
|
642
946
|
# Rest of the existing function continues...
|
|
643
947
|
if not command or not command.strip():
|
|
644
948
|
emit_error("Command cannot be empty", message_group=group_id)
|
|
@@ -721,6 +1025,16 @@ async def run_shell_command(
|
|
|
721
1025
|
|
|
722
1026
|
# Now that approval is done, activate the Ctrl-X listener and disable agent Ctrl-C
|
|
723
1027
|
with _shell_command_keyboard_context():
|
|
1028
|
+
# Emit structured ShellStartMessage for the UI
|
|
1029
|
+
bus = get_message_bus()
|
|
1030
|
+
bus.emit(
|
|
1031
|
+
ShellStartMessage(
|
|
1032
|
+
command=command,
|
|
1033
|
+
cwd=cwd,
|
|
1034
|
+
timeout=timeout,
|
|
1035
|
+
)
|
|
1036
|
+
)
|
|
1037
|
+
|
|
724
1038
|
try:
|
|
725
1039
|
creationflags = 0
|
|
726
1040
|
preexec_fn = None
|
|
@@ -792,26 +1106,14 @@ class ReasoningOutput(BaseModel):
|
|
|
792
1106
|
def share_your_reasoning(
|
|
793
1107
|
context: RunContext, reasoning: str, next_steps: str | None = None
|
|
794
1108
|
) -> ReasoningOutput:
|
|
795
|
-
#
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
"\n[bold white on purple] AGENT REASONING [/bold white on purple]",
|
|
804
|
-
message_group=group_id,
|
|
805
|
-
)
|
|
806
|
-
emit_info("[bold cyan]Current reasoning:[/bold cyan]", message_group=group_id)
|
|
807
|
-
emit_system_message(Markdown(reasoning), message_group=group_id)
|
|
808
|
-
if next_steps is not None and next_steps.strip():
|
|
809
|
-
emit_info(
|
|
810
|
-
"\n[bold cyan]Planned next steps:[/bold cyan]", message_group=group_id
|
|
811
|
-
)
|
|
812
|
-
emit_system_message(Markdown(next_steps), message_group=group_id)
|
|
813
|
-
emit_info("[dim]" + "-" * 60 + "[/dim]\n", message_group=group_id)
|
|
814
|
-
return ReasoningOutput(**{"success": True})
|
|
1109
|
+
# Emit structured AgentReasoningMessage for the UI
|
|
1110
|
+
reasoning_msg = AgentReasoningMessage(
|
|
1111
|
+
reasoning=reasoning,
|
|
1112
|
+
next_steps=next_steps if next_steps and next_steps.strip() else None,
|
|
1113
|
+
)
|
|
1114
|
+
get_message_bus().emit(reasoning_msg)
|
|
1115
|
+
|
|
1116
|
+
return ReasoningOutput(success=True)
|
|
815
1117
|
|
|
816
1118
|
|
|
817
1119
|
def register_agent_run_shell_command(agent):
|
|
@@ -819,7 +1121,11 @@ def register_agent_run_shell_command(agent):
|
|
|
819
1121
|
|
|
820
1122
|
@agent.tool
|
|
821
1123
|
async def agent_run_shell_command(
|
|
822
|
-
context: RunContext,
|
|
1124
|
+
context: RunContext,
|
|
1125
|
+
command: str = "",
|
|
1126
|
+
cwd: str = None,
|
|
1127
|
+
timeout: int = 60,
|
|
1128
|
+
background: bool = False,
|
|
823
1129
|
) -> ShellCommandOutput:
|
|
824
1130
|
"""Execute a shell command with comprehensive monitoring and safety features.
|
|
825
1131
|
|
|
@@ -835,6 +1141,14 @@ def register_agent_run_shell_command(agent):
|
|
|
835
1141
|
timeout: Inactivity timeout in seconds. If no output is
|
|
836
1142
|
produced for this duration, the process will be terminated.
|
|
837
1143
|
Defaults to 60 seconds.
|
|
1144
|
+
background: If True, run the command in the background and return immediately.
|
|
1145
|
+
The command output will be written to a temporary log file.
|
|
1146
|
+
Use this for long-running processes like servers (npm run dev, python -m http.server),
|
|
1147
|
+
or any command you don't need to wait for.
|
|
1148
|
+
When background=True, the response includes:
|
|
1149
|
+
- log_file: Path to temp file containing stdout/stderr (read with read_file tool)
|
|
1150
|
+
- pid: Process ID of the background process
|
|
1151
|
+
Defaults to False.
|
|
838
1152
|
|
|
839
1153
|
Returns:
|
|
840
1154
|
ShellCommandOutput: A structured response containing:
|
|
@@ -847,6 +1161,9 @@ def register_agent_run_shell_command(agent):
|
|
|
847
1161
|
- execution_time (float | None): Total execution time in seconds
|
|
848
1162
|
- timeout (bool | None): True if command was terminated due to timeout
|
|
849
1163
|
- user_interrupted (bool | None): True if user killed the process
|
|
1164
|
+
- background (bool): True if command was run in background mode
|
|
1165
|
+
- log_file (str | None): Path to temp log file for background commands
|
|
1166
|
+
- pid (int | None): Process ID for background commands
|
|
850
1167
|
|
|
851
1168
|
Examples:
|
|
852
1169
|
>>> # Basic command execution
|
|
@@ -863,11 +1180,16 @@ def register_agent_run_shell_command(agent):
|
|
|
863
1180
|
>>> if result.timeout:
|
|
864
1181
|
... print("Command timed out")
|
|
865
1182
|
|
|
1183
|
+
>>> # Background command for long-running server
|
|
1184
|
+
>>> result = agent_run_shell_command(ctx, "npm run dev", background=True)
|
|
1185
|
+
>>> print(f"Server started with PID {result.pid}")
|
|
1186
|
+
>>> print(f"Logs available at: {result.log_file}")
|
|
1187
|
+
|
|
866
1188
|
Warning:
|
|
867
1189
|
This tool can execute arbitrary shell commands. Exercise caution when
|
|
868
1190
|
running untrusted commands, especially those that modify system state.
|
|
869
1191
|
"""
|
|
870
|
-
return await run_shell_command(context, command, cwd, timeout)
|
|
1192
|
+
return await run_shell_command(context, command, cwd, timeout, background)
|
|
871
1193
|
|
|
872
1194
|
|
|
873
1195
|
def register_agent_share_your_reasoning(agent):
|