code-puppy 0.0.302__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/agents/base_agent.py +373 -46
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/cli_runner.py +795 -0
- code_puppy/command_line/add_model_menu.py +8 -1
- code_puppy/command_line/autosave_menu.py +266 -35
- code_puppy/command_line/colors_menu.py +515 -0
- code_puppy/command_line/command_handler.py +8 -2
- code_puppy/command_line/config_commands.py +59 -10
- code_puppy/command_line/core_commands.py +19 -7
- code_puppy/command_line/mcp/edit_command.py +3 -1
- code_puppy/command_line/mcp/handler.py +7 -2
- code_puppy/command_line/mcp/install_command.py +8 -3
- code_puppy/command_line/mcp/logs_command.py +173 -64
- code_puppy/command_line/mcp/restart_command.py +7 -2
- code_puppy/command_line/mcp/search_command.py +10 -4
- code_puppy/command_line/mcp/start_all_command.py +16 -6
- code_puppy/command_line/mcp/start_command.py +3 -1
- code_puppy/command_line/mcp/status_command.py +2 -1
- code_puppy/command_line/mcp/stop_all_command.py +5 -1
- code_puppy/command_line/mcp/stop_command.py +3 -1
- code_puppy/command_line/mcp/wizard_utils.py +10 -4
- code_puppy/command_line/model_settings_menu.py +53 -7
- code_puppy/command_line/prompt_toolkit_completion.py +16 -2
- code_puppy/command_line/session_commands.py +11 -4
- code_puppy/config.py +103 -15
- code_puppy/keymap.py +8 -2
- code_puppy/main.py +5 -828
- code_puppy/mcp_/__init__.py +17 -0
- code_puppy/mcp_/blocking_startup.py +61 -32
- code_puppy/mcp_/config_wizard.py +5 -1
- code_puppy/mcp_/managed_server.py +23 -3
- code_puppy/mcp_/manager.py +65 -0
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/messaging/__init__.py +20 -4
- code_puppy/messaging/bus.py +64 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/messages.py +16 -0
- code_puppy/messaging/renderers.py +21 -9
- code_puppy/messaging/rich_renderer.py +113 -67
- code_puppy/messaging/spinner/console_spinner.py +34 -0
- code_puppy/model_factory.py +185 -30
- code_puppy/model_utils.py +57 -48
- code_puppy/models.json +19 -5
- code_puppy/plugins/chatgpt_oauth/config.py +5 -1
- 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 +26 -11
- code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +28 -0
- code_puppy/plugins/claude_code_oauth/utils.py +1 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -118
- code_puppy/plugins/shell_safety/register_callbacks.py +44 -3
- code_puppy/prompts/codex_system_prompt.md +310 -0
- code_puppy/pydantic_patches.py +131 -0
- code_puppy/terminal_utils.py +126 -0
- code_puppy/tools/agent_tools.py +34 -9
- code_puppy/tools/command_runner.py +361 -32
- code_puppy/tools/file_operations.py +33 -45
- {code_puppy-0.0.302.data → code_puppy-0.0.323.data}/data/code_puppy/models.json +19 -5
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/METADATA +1 -1
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/RECORD +65 -57
- {code_puppy-0.0.302.data → code_puppy-0.0.323.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.302.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
|
|
@@ -17,7 +20,8 @@ from code_puppy.messaging import ( # Structured messaging types
|
|
|
17
20
|
ShellOutputMessage,
|
|
18
21
|
ShellStartMessage,
|
|
19
22
|
emit_error,
|
|
20
|
-
|
|
23
|
+
emit_info,
|
|
24
|
+
emit_shell_line,
|
|
21
25
|
emit_warning,
|
|
22
26
|
get_message_bus,
|
|
23
27
|
)
|
|
@@ -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):
|
|
@@ -414,6 +522,9 @@ def run_shell_command_streaming(
|
|
|
414
522
|
command: str = "",
|
|
415
523
|
group_id: str = None,
|
|
416
524
|
):
|
|
525
|
+
global _READER_STOP_EVENT
|
|
526
|
+
_READER_STOP_EVENT = threading.Event()
|
|
527
|
+
|
|
417
528
|
start_time = time.time()
|
|
418
529
|
last_output_time = [start_time]
|
|
419
530
|
|
|
@@ -427,27 +538,132 @@ def run_shell_command_streaming(
|
|
|
427
538
|
|
|
428
539
|
def read_stdout():
|
|
429
540
|
try:
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
438
603
|
except Exception:
|
|
439
604
|
pass
|
|
440
605
|
|
|
441
606
|
def read_stderr():
|
|
442
607
|
try:
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|
451
667
|
except Exception:
|
|
452
668
|
pass
|
|
453
669
|
|
|
@@ -458,6 +674,10 @@ def run_shell_command_streaming(
|
|
|
458
674
|
_kill_process_group(proc)
|
|
459
675
|
|
|
460
676
|
try:
|
|
677
|
+
# Signal reader threads to stop first
|
|
678
|
+
if _READER_STOP_EVENT:
|
|
679
|
+
_READER_STOP_EVENT.set()
|
|
680
|
+
|
|
461
681
|
if process.poll() is None:
|
|
462
682
|
nuclear_kill(process)
|
|
463
683
|
|
|
@@ -567,6 +787,9 @@ def run_shell_command_streaming(
|
|
|
567
787
|
)
|
|
568
788
|
get_message_bus().emit(shell_output_msg)
|
|
569
789
|
|
|
790
|
+
# Reset the stop event now that we're done
|
|
791
|
+
_READER_STOP_EVENT = None
|
|
792
|
+
|
|
570
793
|
if exit_code != 0:
|
|
571
794
|
time.sleep(1)
|
|
572
795
|
return ShellCommandOutput(
|
|
@@ -593,6 +816,8 @@ def run_shell_command_streaming(
|
|
|
593
816
|
)
|
|
594
817
|
|
|
595
818
|
except Exception as e:
|
|
819
|
+
# Reset the stop event on exception too
|
|
820
|
+
_READER_STOP_EVENT = None
|
|
596
821
|
return ShellCommandOutput(
|
|
597
822
|
success=False,
|
|
598
823
|
command=command,
|
|
@@ -605,7 +830,11 @@ def run_shell_command_streaming(
|
|
|
605
830
|
|
|
606
831
|
|
|
607
832
|
async def run_shell_command(
|
|
608
|
-
context: RunContext,
|
|
833
|
+
context: RunContext,
|
|
834
|
+
command: str,
|
|
835
|
+
cwd: str = None,
|
|
836
|
+
timeout: int = 60,
|
|
837
|
+
background: bool = False,
|
|
609
838
|
) -> ShellCommandOutput:
|
|
610
839
|
command_displayed = False
|
|
611
840
|
start_time = time.time()
|
|
@@ -634,6 +863,86 @@ async def run_shell_command(
|
|
|
634
863
|
execution_time=None,
|
|
635
864
|
)
|
|
636
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
|
+
|
|
637
946
|
# Rest of the existing function continues...
|
|
638
947
|
if not command or not command.strip():
|
|
639
948
|
emit_error("Command cannot be empty", message_group=group_id)
|
|
@@ -812,7 +1121,11 @@ def register_agent_run_shell_command(agent):
|
|
|
812
1121
|
|
|
813
1122
|
@agent.tool
|
|
814
1123
|
async def agent_run_shell_command(
|
|
815
|
-
context: RunContext,
|
|
1124
|
+
context: RunContext,
|
|
1125
|
+
command: str = "",
|
|
1126
|
+
cwd: str = None,
|
|
1127
|
+
timeout: int = 60,
|
|
1128
|
+
background: bool = False,
|
|
816
1129
|
) -> ShellCommandOutput:
|
|
817
1130
|
"""Execute a shell command with comprehensive monitoring and safety features.
|
|
818
1131
|
|
|
@@ -828,6 +1141,14 @@ def register_agent_run_shell_command(agent):
|
|
|
828
1141
|
timeout: Inactivity timeout in seconds. If no output is
|
|
829
1142
|
produced for this duration, the process will be terminated.
|
|
830
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.
|
|
831
1152
|
|
|
832
1153
|
Returns:
|
|
833
1154
|
ShellCommandOutput: A structured response containing:
|
|
@@ -840,6 +1161,9 @@ def register_agent_run_shell_command(agent):
|
|
|
840
1161
|
- execution_time (float | None): Total execution time in seconds
|
|
841
1162
|
- timeout (bool | None): True if command was terminated due to timeout
|
|
842
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
|
|
843
1167
|
|
|
844
1168
|
Examples:
|
|
845
1169
|
>>> # Basic command execution
|
|
@@ -856,11 +1180,16 @@ def register_agent_run_shell_command(agent):
|
|
|
856
1180
|
>>> if result.timeout:
|
|
857
1181
|
... print("Command timed out")
|
|
858
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
|
+
|
|
859
1188
|
Warning:
|
|
860
1189
|
This tool can execute arbitrary shell commands. Exercise caution when
|
|
861
1190
|
running untrusted commands, especially those that modify system state.
|
|
862
1191
|
"""
|
|
863
|
-
return await run_shell_command(context, command, cwd, timeout)
|
|
1192
|
+
return await run_shell_command(context, command, cwd, timeout, background)
|
|
864
1193
|
|
|
865
1194
|
|
|
866
1195
|
def register_agent_share_your_reasoning(agent):
|
|
@@ -18,11 +18,8 @@ from code_puppy.messaging import ( # New structured messaging types
|
|
|
18
18
|
FileListingMessage,
|
|
19
19
|
GrepMatch,
|
|
20
20
|
GrepResultMessage,
|
|
21
|
-
emit_error,
|
|
22
|
-
emit_warning,
|
|
23
21
|
get_message_bus,
|
|
24
22
|
)
|
|
25
|
-
from code_puppy.tools.common import generate_group_id
|
|
26
23
|
|
|
27
24
|
|
|
28
25
|
# Pydantic models for tool return types
|
|
@@ -53,6 +50,7 @@ class MatchInfo(BaseModel):
|
|
|
53
50
|
|
|
54
51
|
class GrepOutput(BaseModel):
|
|
55
52
|
matches: List[MatchInfo]
|
|
53
|
+
error: str | None = None
|
|
56
54
|
|
|
57
55
|
|
|
58
56
|
def is_likely_home_directory(directory):
|
|
@@ -582,9 +580,7 @@ def _grep(context: RunContext, search_string: str, directory: str = ".") -> Grep
|
|
|
582
580
|
|
|
583
581
|
directory = os.path.abspath(os.path.expanduser(directory))
|
|
584
582
|
matches: List[MatchInfo] = []
|
|
585
|
-
|
|
586
|
-
# Generate group_id for this tool execution
|
|
587
|
-
group_id = generate_group_id("grep", f"{directory}_{search_string}")
|
|
583
|
+
error_message: str | None = None
|
|
588
584
|
|
|
589
585
|
# Create a temporary ignore file with our ignore patterns
|
|
590
586
|
ignore_file = None
|
|
@@ -616,11 +612,10 @@ def _grep(context: RunContext, search_string: str, directory: str = ".") -> Grep
|
|
|
616
612
|
break
|
|
617
613
|
|
|
618
614
|
if not rg_path:
|
|
619
|
-
|
|
620
|
-
"ripgrep (rg) not found. Please install ripgrep to use this tool."
|
|
621
|
-
message_group=group_id,
|
|
615
|
+
error_message = (
|
|
616
|
+
"ripgrep (rg) not found. Please install ripgrep to use this tool."
|
|
622
617
|
)
|
|
623
|
-
return GrepOutput(matches=[])
|
|
618
|
+
return GrepOutput(matches=[], error=error_message)
|
|
624
619
|
|
|
625
620
|
cmd = [
|
|
626
621
|
rg_path,
|
|
@@ -688,50 +683,43 @@ def _grep(context: RunContext, search_string: str, directory: str = ".") -> Grep
|
|
|
688
683
|
# Skip lines that aren't valid JSON
|
|
689
684
|
continue
|
|
690
685
|
|
|
691
|
-
# Build structured GrepMatch objects for the UI
|
|
692
|
-
grep_matches = [
|
|
693
|
-
GrepMatch(
|
|
694
|
-
file_path=m.file_path or "",
|
|
695
|
-
line_number=m.line_number or 1,
|
|
696
|
-
line_content=m.line_content or "",
|
|
697
|
-
)
|
|
698
|
-
for m in matches
|
|
699
|
-
]
|
|
700
|
-
|
|
701
|
-
# Count unique files searched (approximation based on matches)
|
|
702
|
-
unique_files = len(set(m.file_path for m in matches)) if matches else 0
|
|
703
|
-
|
|
704
|
-
# Emit structured message for the UI
|
|
705
|
-
grep_result_msg = GrepResultMessage(
|
|
706
|
-
search_term=search_string,
|
|
707
|
-
directory=directory,
|
|
708
|
-
matches=grep_matches,
|
|
709
|
-
total_matches=len(matches),
|
|
710
|
-
files_searched=unique_files,
|
|
711
|
-
)
|
|
712
|
-
get_message_bus().emit(grep_result_msg)
|
|
713
|
-
|
|
714
|
-
if not matches:
|
|
715
|
-
emit_warning(
|
|
716
|
-
f"No matches found for '{search_string}' in {directory}",
|
|
717
|
-
message_group=group_id,
|
|
718
|
-
)
|
|
719
|
-
|
|
720
686
|
except subprocess.TimeoutExpired:
|
|
721
|
-
|
|
687
|
+
error_message = "Grep command timed out after 30 seconds"
|
|
722
688
|
except FileNotFoundError:
|
|
723
|
-
|
|
724
|
-
"ripgrep (rg) not found. Please install ripgrep to use this tool."
|
|
725
|
-
message_group=group_id,
|
|
689
|
+
error_message = (
|
|
690
|
+
"ripgrep (rg) not found. Please install ripgrep to use this tool."
|
|
726
691
|
)
|
|
727
692
|
except Exception as e:
|
|
728
|
-
|
|
693
|
+
error_message = f"Error during grep operation: {e}"
|
|
729
694
|
finally:
|
|
730
695
|
# Clean up the temporary ignore file
|
|
731
696
|
if ignore_file and os.path.exists(ignore_file):
|
|
732
697
|
os.unlink(ignore_file)
|
|
733
698
|
|
|
734
|
-
|
|
699
|
+
# Build structured GrepMatch objects for the UI
|
|
700
|
+
grep_matches = [
|
|
701
|
+
GrepMatch(
|
|
702
|
+
file_path=m.file_path or "",
|
|
703
|
+
line_number=m.line_number or 1,
|
|
704
|
+
line_content=m.line_content or "",
|
|
705
|
+
)
|
|
706
|
+
for m in matches
|
|
707
|
+
]
|
|
708
|
+
|
|
709
|
+
# Count unique files searched (approximation based on matches)
|
|
710
|
+
unique_files = len(set(m.file_path for m in matches)) if matches else 0
|
|
711
|
+
|
|
712
|
+
# Emit structured message for the UI (only once, at the end)
|
|
713
|
+
grep_result_msg = GrepResultMessage(
|
|
714
|
+
search_term=search_string,
|
|
715
|
+
directory=directory,
|
|
716
|
+
matches=grep_matches,
|
|
717
|
+
total_matches=len(matches),
|
|
718
|
+
files_searched=unique_files,
|
|
719
|
+
)
|
|
720
|
+
get_message_bus().emit(grep_result_msg)
|
|
721
|
+
|
|
722
|
+
return GrepOutput(matches=matches, error=error_message)
|
|
735
723
|
|
|
736
724
|
|
|
737
725
|
def register_list_files(agent):
|