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.
Files changed (65) hide show
  1. code_puppy/agents/base_agent.py +373 -46
  2. code_puppy/chatgpt_codex_client.py +283 -0
  3. code_puppy/cli_runner.py +795 -0
  4. code_puppy/command_line/add_model_menu.py +8 -1
  5. code_puppy/command_line/autosave_menu.py +266 -35
  6. code_puppy/command_line/colors_menu.py +515 -0
  7. code_puppy/command_line/command_handler.py +8 -2
  8. code_puppy/command_line/config_commands.py +59 -10
  9. code_puppy/command_line/core_commands.py +19 -7
  10. code_puppy/command_line/mcp/edit_command.py +3 -1
  11. code_puppy/command_line/mcp/handler.py +7 -2
  12. code_puppy/command_line/mcp/install_command.py +8 -3
  13. code_puppy/command_line/mcp/logs_command.py +173 -64
  14. code_puppy/command_line/mcp/restart_command.py +7 -2
  15. code_puppy/command_line/mcp/search_command.py +10 -4
  16. code_puppy/command_line/mcp/start_all_command.py +16 -6
  17. code_puppy/command_line/mcp/start_command.py +3 -1
  18. code_puppy/command_line/mcp/status_command.py +2 -1
  19. code_puppy/command_line/mcp/stop_all_command.py +5 -1
  20. code_puppy/command_line/mcp/stop_command.py +3 -1
  21. code_puppy/command_line/mcp/wizard_utils.py +10 -4
  22. code_puppy/command_line/model_settings_menu.py +53 -7
  23. code_puppy/command_line/prompt_toolkit_completion.py +16 -2
  24. code_puppy/command_line/session_commands.py +11 -4
  25. code_puppy/config.py +103 -15
  26. code_puppy/keymap.py +8 -2
  27. code_puppy/main.py +5 -828
  28. code_puppy/mcp_/__init__.py +17 -0
  29. code_puppy/mcp_/blocking_startup.py +61 -32
  30. code_puppy/mcp_/config_wizard.py +5 -1
  31. code_puppy/mcp_/managed_server.py +23 -3
  32. code_puppy/mcp_/manager.py +65 -0
  33. code_puppy/mcp_/mcp_logs.py +224 -0
  34. code_puppy/messaging/__init__.py +20 -4
  35. code_puppy/messaging/bus.py +64 -0
  36. code_puppy/messaging/markdown_patches.py +57 -0
  37. code_puppy/messaging/messages.py +16 -0
  38. code_puppy/messaging/renderers.py +21 -9
  39. code_puppy/messaging/rich_renderer.py +113 -67
  40. code_puppy/messaging/spinner/console_spinner.py +34 -0
  41. code_puppy/model_factory.py +185 -30
  42. code_puppy/model_utils.py +57 -48
  43. code_puppy/models.json +19 -5
  44. code_puppy/plugins/chatgpt_oauth/config.py +5 -1
  45. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
  46. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +3 -3
  47. code_puppy/plugins/chatgpt_oauth/test_plugin.py +26 -11
  48. code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
  49. code_puppy/plugins/claude_code_oauth/register_callbacks.py +28 -0
  50. code_puppy/plugins/claude_code_oauth/utils.py +1 -0
  51. code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -118
  52. code_puppy/plugins/shell_safety/register_callbacks.py +44 -3
  53. code_puppy/prompts/codex_system_prompt.md +310 -0
  54. code_puppy/pydantic_patches.py +131 -0
  55. code_puppy/terminal_utils.py +126 -0
  56. code_puppy/tools/agent_tools.py +34 -9
  57. code_puppy/tools/command_runner.py +361 -32
  58. code_puppy/tools/file_operations.py +33 -45
  59. {code_puppy-0.0.302.data → code_puppy-0.0.323.data}/data/code_puppy/models.json +19 -5
  60. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/METADATA +1 -1
  61. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/RECORD +65 -57
  62. {code_puppy-0.0.302.data → code_puppy-0.0.323.data}/data/code_puppy/models_dev_api.json +0 -0
  63. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/WHEEL +0 -0
  64. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/entry_points.txt +0 -0
  65. {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
- emit_system_message,
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
- procs: list[subprocess.Popen]
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
- procs = list(_RUNNING_PROCESSES)
138
- count = 0
139
- for p in procs:
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 p.poll() is None:
142
- _kill_process_group(p)
143
- count += 1
144
- _USER_KILLED_PROCESSES.add(p.pid)
145
- finally:
146
- _unregister_process(p)
147
- return count
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
- for line in iter(process.stdout.readline, ""):
431
- if line:
432
- line = line.rstrip("\n\r")
433
- # Limit line length to prevent massive token usage
434
- line = _truncate_line(line)
435
- stdout_lines.append(line)
436
- emit_system_message(line, message_group=group_id)
437
- last_output_time[0] = time.time()
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
- for line in iter(process.stderr.readline, ""):
444
- if line:
445
- line = line.rstrip("\n\r")
446
- # Limit line length to prevent massive token usage
447
- line = _truncate_line(line)
448
- stderr_lines.append(line)
449
- emit_system_message(line, message_group=group_id)
450
- last_output_time[0] = time.time()
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, command: str, cwd: str = None, timeout: int = 60
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, command: str = "", cwd: str = None, timeout: int = 60
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
- emit_error(
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
- emit_error("Grep command timed out after 30 seconds", message_group=group_id)
687
+ error_message = "Grep command timed out after 30 seconds"
722
688
  except FileNotFoundError:
723
- emit_error(
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
- emit_error(f"Error during grep operation: {e}", message_group=group_id)
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
- return GrepOutput(matches=matches)
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):