code-puppy 0.0.302__py3-none-any.whl → 0.0.335__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 +343 -35
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/cli_runner.py +898 -0
- code_puppy/command_line/add_model_menu.py +23 -1
- code_puppy/command_line/autosave_menu.py +271 -35
- code_puppy/command_line/colors_menu.py +520 -0
- code_puppy/command_line/command_handler.py +8 -2
- code_puppy/command_line/config_commands.py +82 -10
- code_puppy/command_line/core_commands.py +70 -7
- code_puppy/command_line/diff_menu.py +5 -0
- code_puppy/command_line/mcp/custom_server_form.py +4 -0
- 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/install_menu.py +5 -1
- 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 +58 -7
- code_puppy/command_line/motd.py +13 -7
- code_puppy/command_line/onboarding_slides.py +180 -0
- code_puppy/command_line/onboarding_wizard.py +340 -0
- code_puppy/command_line/prompt_toolkit_completion.py +16 -2
- code_puppy/command_line/session_commands.py +11 -4
- code_puppy/config.py +106 -17
- code_puppy/http_utils.py +155 -196
- code_puppy/keymap.py +8 -0
- 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 +271 -45
- code_puppy/model_utils.py +57 -48
- code_puppy/models.json +21 -7
- code_puppy/plugins/__init__.py +12 -0
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +612 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +136 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
- code_puppy/plugins/antigravity_oauth/storage.py +271 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +595 -0
- code_puppy/plugins/antigravity_oauth/utils.py +169 -0
- 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 +5 -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 +30 -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/reopenable_async_client.py +8 -8
- code_puppy/terminal_utils.py +291 -0
- code_puppy/tools/agent_tools.py +34 -9
- code_puppy/tools/command_runner.py +344 -27
- code_puppy/tools/file_operations.py +33 -45
- code_puppy/uvx_detection.py +242 -0
- {code_puppy-0.0.302.data → code_puppy-0.0.335.data}/data/code_puppy/models.json +21 -7
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/METADATA +30 -1
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/RECORD +87 -64
- {code_puppy-0.0.302.data → code_puppy-0.0.335.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,12 +1,15 @@
|
|
|
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
|
|
8
11
|
from contextlib import contextmanager
|
|
9
|
-
from typing import Callable, Literal, Optional, Set
|
|
12
|
+
from typing import Callable, List, Literal, Optional, Set
|
|
10
13
|
|
|
11
14
|
from pydantic import BaseModel
|
|
12
15
|
from pydantic_ai import RunContext
|
|
@@ -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,16 +189,33 @@ 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.
|
|
134
195
|
"""
|
|
196
|
+
global _READER_STOP_EVENT
|
|
197
|
+
|
|
198
|
+
# Signal reader threads to stop
|
|
199
|
+
if _READER_STOP_EVENT:
|
|
200
|
+
_READER_STOP_EVENT.set()
|
|
201
|
+
|
|
135
202
|
procs: list[subprocess.Popen]
|
|
136
203
|
with _RUNNING_PROCESSES_LOCK:
|
|
137
204
|
procs = list(_RUNNING_PROCESSES)
|
|
138
205
|
count = 0
|
|
139
206
|
for p in procs:
|
|
140
207
|
try:
|
|
208
|
+
# Close pipes first to unblock readline()
|
|
209
|
+
try:
|
|
210
|
+
if p.stdout and not p.stdout.closed:
|
|
211
|
+
p.stdout.close()
|
|
212
|
+
if p.stderr and not p.stderr.closed:
|
|
213
|
+
p.stderr.close()
|
|
214
|
+
if p.stdin and not p.stdin.closed:
|
|
215
|
+
p.stdin.close()
|
|
216
|
+
except (OSError, ValueError):
|
|
217
|
+
pass
|
|
218
|
+
|
|
141
219
|
if p.poll() is None:
|
|
142
220
|
_kill_process_group(p)
|
|
143
221
|
count += 1
|
|
@@ -205,6 +283,9 @@ class ShellCommandOutput(BaseModel):
|
|
|
205
283
|
timeout: bool | None = False
|
|
206
284
|
user_interrupted: bool | None = False
|
|
207
285
|
user_feedback: str | None = None # User feedback when command is rejected
|
|
286
|
+
background: bool = False # True if command was run in background mode
|
|
287
|
+
log_file: str | None = None # Path to temp log file for background commands
|
|
288
|
+
pid: int | None = None # Process ID for background commands
|
|
208
289
|
|
|
209
290
|
|
|
210
291
|
class ShellSafetyAssessment(BaseModel):
|
|
@@ -414,6 +495,9 @@ def run_shell_command_streaming(
|
|
|
414
495
|
command: str = "",
|
|
415
496
|
group_id: str = None,
|
|
416
497
|
):
|
|
498
|
+
global _READER_STOP_EVENT
|
|
499
|
+
_READER_STOP_EVENT = threading.Event()
|
|
500
|
+
|
|
417
501
|
start_time = time.time()
|
|
418
502
|
last_output_time = [start_time]
|
|
419
503
|
|
|
@@ -427,27 +511,132 @@ def run_shell_command_streaming(
|
|
|
427
511
|
|
|
428
512
|
def read_stdout():
|
|
429
513
|
try:
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
514
|
+
fd = process.stdout.fileno()
|
|
515
|
+
except (ValueError, OSError):
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
while True:
|
|
520
|
+
# Check stop event first
|
|
521
|
+
if _READER_STOP_EVENT and _READER_STOP_EVENT.is_set():
|
|
522
|
+
break
|
|
523
|
+
|
|
524
|
+
# Use select to check if data is available (with timeout)
|
|
525
|
+
if sys.platform.startswith("win"):
|
|
526
|
+
# Windows doesn't support select on pipes
|
|
527
|
+
# Use PeekNamedPipe via _win32_pipe_has_data() to check
|
|
528
|
+
# if data is available without blocking
|
|
529
|
+
try:
|
|
530
|
+
if _win32_pipe_has_data(process.stdout):
|
|
531
|
+
line = process.stdout.readline()
|
|
532
|
+
if not line: # EOF
|
|
533
|
+
break
|
|
534
|
+
line = line.rstrip("\n\r")
|
|
535
|
+
line = _truncate_line(line)
|
|
536
|
+
stdout_lines.append(line)
|
|
537
|
+
emit_shell_line(line, stream="stdout")
|
|
538
|
+
last_output_time[0] = time.time()
|
|
539
|
+
else:
|
|
540
|
+
# No data available, check if process has exited
|
|
541
|
+
if process.poll() is not None:
|
|
542
|
+
# Process exited, do one final drain
|
|
543
|
+
try:
|
|
544
|
+
remaining = process.stdout.read()
|
|
545
|
+
if remaining:
|
|
546
|
+
for line in remaining.splitlines():
|
|
547
|
+
line = _truncate_line(line)
|
|
548
|
+
stdout_lines.append(line)
|
|
549
|
+
emit_shell_line(line, stream="stdout")
|
|
550
|
+
except (ValueError, OSError):
|
|
551
|
+
pass
|
|
552
|
+
break
|
|
553
|
+
# Sleep briefly to avoid busy-waiting (100ms like POSIX)
|
|
554
|
+
time.sleep(0.1)
|
|
555
|
+
except (ValueError, OSError):
|
|
556
|
+
break
|
|
557
|
+
else:
|
|
558
|
+
# POSIX: use select with timeout
|
|
559
|
+
try:
|
|
560
|
+
ready, _, _ = select.select([fd], [], [], 0.1) # 100ms timeout
|
|
561
|
+
except (ValueError, OSError, select.error):
|
|
562
|
+
break
|
|
563
|
+
|
|
564
|
+
if ready:
|
|
565
|
+
line = process.stdout.readline()
|
|
566
|
+
if not line: # EOF
|
|
567
|
+
break
|
|
568
|
+
line = line.rstrip("\n\r")
|
|
569
|
+
line = _truncate_line(line)
|
|
570
|
+
stdout_lines.append(line)
|
|
571
|
+
emit_shell_line(line, stream="stdout")
|
|
572
|
+
last_output_time[0] = time.time()
|
|
573
|
+
# If not ready, loop continues and checks stop event again
|
|
574
|
+
except (ValueError, OSError):
|
|
575
|
+
pass
|
|
438
576
|
except Exception:
|
|
439
577
|
pass
|
|
440
578
|
|
|
441
579
|
def read_stderr():
|
|
442
580
|
try:
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
581
|
+
fd = process.stderr.fileno()
|
|
582
|
+
except (ValueError, OSError):
|
|
583
|
+
return
|
|
584
|
+
|
|
585
|
+
try:
|
|
586
|
+
while True:
|
|
587
|
+
# Check stop event first
|
|
588
|
+
if _READER_STOP_EVENT and _READER_STOP_EVENT.is_set():
|
|
589
|
+
break
|
|
590
|
+
|
|
591
|
+
if sys.platform.startswith("win"):
|
|
592
|
+
# Windows doesn't support select on pipes
|
|
593
|
+
# Use PeekNamedPipe via _win32_pipe_has_data() to check
|
|
594
|
+
# if data is available without blocking
|
|
595
|
+
try:
|
|
596
|
+
if _win32_pipe_has_data(process.stderr):
|
|
597
|
+
line = process.stderr.readline()
|
|
598
|
+
if not line: # EOF
|
|
599
|
+
break
|
|
600
|
+
line = line.rstrip("\n\r")
|
|
601
|
+
line = _truncate_line(line)
|
|
602
|
+
stderr_lines.append(line)
|
|
603
|
+
emit_shell_line(line, stream="stderr")
|
|
604
|
+
last_output_time[0] = time.time()
|
|
605
|
+
else:
|
|
606
|
+
# No data available, check if process has exited
|
|
607
|
+
if process.poll() is not None:
|
|
608
|
+
# Process exited, do one final drain
|
|
609
|
+
try:
|
|
610
|
+
remaining = process.stderr.read()
|
|
611
|
+
if remaining:
|
|
612
|
+
for line in remaining.splitlines():
|
|
613
|
+
line = _truncate_line(line)
|
|
614
|
+
stderr_lines.append(line)
|
|
615
|
+
emit_shell_line(line, stream="stderr")
|
|
616
|
+
except (ValueError, OSError):
|
|
617
|
+
pass
|
|
618
|
+
break
|
|
619
|
+
# Sleep briefly to avoid busy-waiting (100ms like POSIX)
|
|
620
|
+
time.sleep(0.1)
|
|
621
|
+
except (ValueError, OSError):
|
|
622
|
+
break
|
|
623
|
+
else:
|
|
624
|
+
try:
|
|
625
|
+
ready, _, _ = select.select([fd], [], [], 0.1)
|
|
626
|
+
except (ValueError, OSError, select.error):
|
|
627
|
+
break
|
|
628
|
+
|
|
629
|
+
if ready:
|
|
630
|
+
line = process.stderr.readline()
|
|
631
|
+
if not line: # EOF
|
|
632
|
+
break
|
|
633
|
+
line = line.rstrip("\n\r")
|
|
634
|
+
line = _truncate_line(line)
|
|
635
|
+
stderr_lines.append(line)
|
|
636
|
+
emit_shell_line(line, stream="stderr")
|
|
637
|
+
last_output_time[0] = time.time()
|
|
638
|
+
except (ValueError, OSError):
|
|
639
|
+
pass
|
|
451
640
|
except Exception:
|
|
452
641
|
pass
|
|
453
642
|
|
|
@@ -458,6 +647,10 @@ def run_shell_command_streaming(
|
|
|
458
647
|
_kill_process_group(proc)
|
|
459
648
|
|
|
460
649
|
try:
|
|
650
|
+
# Signal reader threads to stop first
|
|
651
|
+
if _READER_STOP_EVENT:
|
|
652
|
+
_READER_STOP_EVENT.set()
|
|
653
|
+
|
|
461
654
|
if process.poll() is None:
|
|
462
655
|
nuclear_kill(process)
|
|
463
656
|
|
|
@@ -567,6 +760,9 @@ def run_shell_command_streaming(
|
|
|
567
760
|
)
|
|
568
761
|
get_message_bus().emit(shell_output_msg)
|
|
569
762
|
|
|
763
|
+
# Reset the stop event now that we're done
|
|
764
|
+
_READER_STOP_EVENT = None
|
|
765
|
+
|
|
570
766
|
if exit_code != 0:
|
|
571
767
|
time.sleep(1)
|
|
572
768
|
return ShellCommandOutput(
|
|
@@ -593,6 +789,8 @@ def run_shell_command_streaming(
|
|
|
593
789
|
)
|
|
594
790
|
|
|
595
791
|
except Exception as e:
|
|
792
|
+
# Reset the stop event on exception too
|
|
793
|
+
_READER_STOP_EVENT = None
|
|
596
794
|
return ShellCommandOutput(
|
|
597
795
|
success=False,
|
|
598
796
|
command=command,
|
|
@@ -605,7 +803,11 @@ def run_shell_command_streaming(
|
|
|
605
803
|
|
|
606
804
|
|
|
607
805
|
async def run_shell_command(
|
|
608
|
-
context: RunContext,
|
|
806
|
+
context: RunContext,
|
|
807
|
+
command: str,
|
|
808
|
+
cwd: str = None,
|
|
809
|
+
timeout: int = 60,
|
|
810
|
+
background: bool = False,
|
|
609
811
|
) -> ShellCommandOutput:
|
|
610
812
|
command_displayed = False
|
|
611
813
|
start_time = time.time()
|
|
@@ -634,6 +836,86 @@ async def run_shell_command(
|
|
|
634
836
|
execution_time=None,
|
|
635
837
|
)
|
|
636
838
|
|
|
839
|
+
# Handle background execution - runs command detached and returns immediately
|
|
840
|
+
# This happens BEFORE user confirmation since we don't wait for the command
|
|
841
|
+
if background:
|
|
842
|
+
# Create temp log file for output
|
|
843
|
+
log_file = tempfile.NamedTemporaryFile(
|
|
844
|
+
mode="w",
|
|
845
|
+
prefix="shell_bg_",
|
|
846
|
+
suffix=".log",
|
|
847
|
+
delete=False, # Keep file so agent can read it later
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
try:
|
|
851
|
+
# Platform-specific process detachment
|
|
852
|
+
if sys.platform.startswith("win"):
|
|
853
|
+
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
854
|
+
process = subprocess.Popen(
|
|
855
|
+
command,
|
|
856
|
+
shell=True,
|
|
857
|
+
stdout=log_file,
|
|
858
|
+
stderr=subprocess.STDOUT,
|
|
859
|
+
stdin=subprocess.DEVNULL,
|
|
860
|
+
cwd=cwd,
|
|
861
|
+
creationflags=creationflags,
|
|
862
|
+
)
|
|
863
|
+
else:
|
|
864
|
+
process = subprocess.Popen(
|
|
865
|
+
command,
|
|
866
|
+
shell=True,
|
|
867
|
+
stdout=log_file,
|
|
868
|
+
stderr=subprocess.STDOUT,
|
|
869
|
+
stdin=subprocess.DEVNULL,
|
|
870
|
+
cwd=cwd,
|
|
871
|
+
start_new_session=True, # Fully detach on POSIX
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
log_file.close() # Close our handle, process keeps writing
|
|
875
|
+
|
|
876
|
+
# Emit UI messages so user sees what happened
|
|
877
|
+
bus = get_message_bus()
|
|
878
|
+
bus.emit(
|
|
879
|
+
ShellStartMessage(
|
|
880
|
+
command=command,
|
|
881
|
+
cwd=cwd,
|
|
882
|
+
timeout=0, # No timeout for background processes
|
|
883
|
+
)
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
# Emit info about background execution
|
|
887
|
+
emit_info(
|
|
888
|
+
f"🚀 Background process started (PID: {process.pid}) - no timeout, runs until complete"
|
|
889
|
+
)
|
|
890
|
+
emit_info(f"📄 Output logging to: {log_file.name}")
|
|
891
|
+
|
|
892
|
+
# Return immediately - don't wait, don't block
|
|
893
|
+
return ShellCommandOutput(
|
|
894
|
+
success=True,
|
|
895
|
+
command=command,
|
|
896
|
+
stdout=None,
|
|
897
|
+
stderr=None,
|
|
898
|
+
exit_code=None,
|
|
899
|
+
execution_time=0.0,
|
|
900
|
+
background=True,
|
|
901
|
+
log_file=log_file.name,
|
|
902
|
+
pid=process.pid,
|
|
903
|
+
)
|
|
904
|
+
except Exception as e:
|
|
905
|
+
log_file.close()
|
|
906
|
+
# Emit error message so user sees what happened
|
|
907
|
+
emit_error(f"❌ Failed to start background process: {e}")
|
|
908
|
+
return ShellCommandOutput(
|
|
909
|
+
success=False,
|
|
910
|
+
command=command,
|
|
911
|
+
error=f"Failed to start background process: {e}",
|
|
912
|
+
stdout=None,
|
|
913
|
+
stderr=None,
|
|
914
|
+
exit_code=None,
|
|
915
|
+
execution_time=None,
|
|
916
|
+
background=True,
|
|
917
|
+
)
|
|
918
|
+
|
|
637
919
|
# Rest of the existing function continues...
|
|
638
920
|
if not command or not command.strip():
|
|
639
921
|
emit_error("Command cannot be empty", message_group=group_id)
|
|
@@ -795,12 +1077,21 @@ class ReasoningOutput(BaseModel):
|
|
|
795
1077
|
|
|
796
1078
|
|
|
797
1079
|
def share_your_reasoning(
|
|
798
|
-
context: RunContext, reasoning: str, next_steps: str | None = None
|
|
1080
|
+
context: RunContext, reasoning: str, next_steps: str | List[str] | None = None
|
|
799
1081
|
) -> ReasoningOutput:
|
|
1082
|
+
# Handle list of next steps by formatting them
|
|
1083
|
+
formatted_next_steps = next_steps
|
|
1084
|
+
if isinstance(next_steps, list):
|
|
1085
|
+
formatted_next_steps = "\n".join(
|
|
1086
|
+
[f"{i + 1}. {step}" for i, step in enumerate(next_steps)]
|
|
1087
|
+
)
|
|
1088
|
+
|
|
800
1089
|
# Emit structured AgentReasoningMessage for the UI
|
|
801
1090
|
reasoning_msg = AgentReasoningMessage(
|
|
802
1091
|
reasoning=reasoning,
|
|
803
|
-
next_steps=
|
|
1092
|
+
next_steps=formatted_next_steps
|
|
1093
|
+
if formatted_next_steps and formatted_next_steps.strip()
|
|
1094
|
+
else None,
|
|
804
1095
|
)
|
|
805
1096
|
get_message_bus().emit(reasoning_msg)
|
|
806
1097
|
|
|
@@ -812,7 +1103,11 @@ def register_agent_run_shell_command(agent):
|
|
|
812
1103
|
|
|
813
1104
|
@agent.tool
|
|
814
1105
|
async def agent_run_shell_command(
|
|
815
|
-
context: RunContext,
|
|
1106
|
+
context: RunContext,
|
|
1107
|
+
command: str = "",
|
|
1108
|
+
cwd: str = None,
|
|
1109
|
+
timeout: int = 60,
|
|
1110
|
+
background: bool = False,
|
|
816
1111
|
) -> ShellCommandOutput:
|
|
817
1112
|
"""Execute a shell command with comprehensive monitoring and safety features.
|
|
818
1113
|
|
|
@@ -828,6 +1123,14 @@ def register_agent_run_shell_command(agent):
|
|
|
828
1123
|
timeout: Inactivity timeout in seconds. If no output is
|
|
829
1124
|
produced for this duration, the process will be terminated.
|
|
830
1125
|
Defaults to 60 seconds.
|
|
1126
|
+
background: If True, run the command in the background and return immediately.
|
|
1127
|
+
The command output will be written to a temporary log file.
|
|
1128
|
+
Use this for long-running processes like servers (npm run dev, python -m http.server),
|
|
1129
|
+
or any command you don't need to wait for.
|
|
1130
|
+
When background=True, the response includes:
|
|
1131
|
+
- log_file: Path to temp file containing stdout/stderr (read with read_file tool)
|
|
1132
|
+
- pid: Process ID of the background process
|
|
1133
|
+
Defaults to False.
|
|
831
1134
|
|
|
832
1135
|
Returns:
|
|
833
1136
|
ShellCommandOutput: A structured response containing:
|
|
@@ -840,6 +1143,9 @@ def register_agent_run_shell_command(agent):
|
|
|
840
1143
|
- execution_time (float | None): Total execution time in seconds
|
|
841
1144
|
- timeout (bool | None): True if command was terminated due to timeout
|
|
842
1145
|
- user_interrupted (bool | None): True if user killed the process
|
|
1146
|
+
- background (bool): True if command was run in background mode
|
|
1147
|
+
- log_file (str | None): Path to temp log file for background commands
|
|
1148
|
+
- pid (int | None): Process ID for background commands
|
|
843
1149
|
|
|
844
1150
|
Examples:
|
|
845
1151
|
>>> # Basic command execution
|
|
@@ -856,11 +1162,16 @@ def register_agent_run_shell_command(agent):
|
|
|
856
1162
|
>>> if result.timeout:
|
|
857
1163
|
... print("Command timed out")
|
|
858
1164
|
|
|
1165
|
+
>>> # Background command for long-running server
|
|
1166
|
+
>>> result = agent_run_shell_command(ctx, "npm run dev", background=True)
|
|
1167
|
+
>>> print(f"Server started with PID {result.pid}")
|
|
1168
|
+
>>> print(f"Logs available at: {result.log_file}")
|
|
1169
|
+
|
|
859
1170
|
Warning:
|
|
860
1171
|
This tool can execute arbitrary shell commands. Exercise caution when
|
|
861
1172
|
running untrusted commands, especially those that modify system state.
|
|
862
1173
|
"""
|
|
863
|
-
return await run_shell_command(context, command, cwd, timeout)
|
|
1174
|
+
return await run_shell_command(context, command, cwd, timeout, background)
|
|
864
1175
|
|
|
865
1176
|
|
|
866
1177
|
def register_agent_share_your_reasoning(agent):
|
|
@@ -868,7 +1179,9 @@ def register_agent_share_your_reasoning(agent):
|
|
|
868
1179
|
|
|
869
1180
|
@agent.tool
|
|
870
1181
|
def agent_share_your_reasoning(
|
|
871
|
-
context: RunContext,
|
|
1182
|
+
context: RunContext,
|
|
1183
|
+
reasoning: str = "",
|
|
1184
|
+
next_steps: str | List[str] | None = None,
|
|
872
1185
|
) -> ReasoningOutput:
|
|
873
1186
|
"""Share the agent's current reasoning and planned next steps with the user.
|
|
874
1187
|
|
|
@@ -882,8 +1195,8 @@ def register_agent_share_your_reasoning(agent):
|
|
|
882
1195
|
reasoning for the current situation. This should be clear,
|
|
883
1196
|
comprehensive, and explain the 'why' behind decisions.
|
|
884
1197
|
next_steps: Planned upcoming actions or steps
|
|
885
|
-
the agent intends to take. Can be
|
|
886
|
-
are determined. Defaults to None.
|
|
1198
|
+
the agent intends to take. Can be a string or a list of strings.
|
|
1199
|
+
Can be None if no specific next steps are determined. Defaults to None.
|
|
887
1200
|
|
|
888
1201
|
Returns:
|
|
889
1202
|
ReasoningOutput: A simple response object containing:
|
|
@@ -894,6 +1207,10 @@ def register_agent_share_your_reasoning(agent):
|
|
|
894
1207
|
>>> next_steps = "First, I'll list the directory contents, then read key files"
|
|
895
1208
|
>>> result = agent_share_your_reasoning(ctx, reasoning, next_steps)
|
|
896
1209
|
|
|
1210
|
+
>>> # Using a list for next steps
|
|
1211
|
+
>>> next_steps_list = ["List files", "Read README.md", "Run tests"]
|
|
1212
|
+
>>> result = agent_share_your_reasoning(ctx, reasoning, next_steps_list)
|
|
1213
|
+
|
|
897
1214
|
Best Practice:
|
|
898
1215
|
Use this tool frequently to maintain transparency. Call it:
|
|
899
1216
|
- Before starting complex operations
|
|
@@ -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):
|