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.
Files changed (87) hide show
  1. code_puppy/agents/base_agent.py +343 -35
  2. code_puppy/chatgpt_codex_client.py +283 -0
  3. code_puppy/cli_runner.py +898 -0
  4. code_puppy/command_line/add_model_menu.py +23 -1
  5. code_puppy/command_line/autosave_menu.py +271 -35
  6. code_puppy/command_line/colors_menu.py +520 -0
  7. code_puppy/command_line/command_handler.py +8 -2
  8. code_puppy/command_line/config_commands.py +82 -10
  9. code_puppy/command_line/core_commands.py +70 -7
  10. code_puppy/command_line/diff_menu.py +5 -0
  11. code_puppy/command_line/mcp/custom_server_form.py +4 -0
  12. code_puppy/command_line/mcp/edit_command.py +3 -1
  13. code_puppy/command_line/mcp/handler.py +7 -2
  14. code_puppy/command_line/mcp/install_command.py +8 -3
  15. code_puppy/command_line/mcp/install_menu.py +5 -1
  16. code_puppy/command_line/mcp/logs_command.py +173 -64
  17. code_puppy/command_line/mcp/restart_command.py +7 -2
  18. code_puppy/command_line/mcp/search_command.py +10 -4
  19. code_puppy/command_line/mcp/start_all_command.py +16 -6
  20. code_puppy/command_line/mcp/start_command.py +3 -1
  21. code_puppy/command_line/mcp/status_command.py +2 -1
  22. code_puppy/command_line/mcp/stop_all_command.py +5 -1
  23. code_puppy/command_line/mcp/stop_command.py +3 -1
  24. code_puppy/command_line/mcp/wizard_utils.py +10 -4
  25. code_puppy/command_line/model_settings_menu.py +58 -7
  26. code_puppy/command_line/motd.py +13 -7
  27. code_puppy/command_line/onboarding_slides.py +180 -0
  28. code_puppy/command_line/onboarding_wizard.py +340 -0
  29. code_puppy/command_line/prompt_toolkit_completion.py +16 -2
  30. code_puppy/command_line/session_commands.py +11 -4
  31. code_puppy/config.py +106 -17
  32. code_puppy/http_utils.py +155 -196
  33. code_puppy/keymap.py +8 -0
  34. code_puppy/main.py +5 -828
  35. code_puppy/mcp_/__init__.py +17 -0
  36. code_puppy/mcp_/blocking_startup.py +61 -32
  37. code_puppy/mcp_/config_wizard.py +5 -1
  38. code_puppy/mcp_/managed_server.py +23 -3
  39. code_puppy/mcp_/manager.py +65 -0
  40. code_puppy/mcp_/mcp_logs.py +224 -0
  41. code_puppy/messaging/__init__.py +20 -4
  42. code_puppy/messaging/bus.py +64 -0
  43. code_puppy/messaging/markdown_patches.py +57 -0
  44. code_puppy/messaging/messages.py +16 -0
  45. code_puppy/messaging/renderers.py +21 -9
  46. code_puppy/messaging/rich_renderer.py +113 -67
  47. code_puppy/messaging/spinner/console_spinner.py +34 -0
  48. code_puppy/model_factory.py +271 -45
  49. code_puppy/model_utils.py +57 -48
  50. code_puppy/models.json +21 -7
  51. code_puppy/plugins/__init__.py +12 -0
  52. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  53. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  54. code_puppy/plugins/antigravity_oauth/antigravity_model.py +612 -0
  55. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  56. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  57. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  58. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  59. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  60. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  61. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  62. code_puppy/plugins/antigravity_oauth/transport.py +595 -0
  63. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  64. code_puppy/plugins/chatgpt_oauth/config.py +5 -1
  65. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
  66. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +5 -3
  67. code_puppy/plugins/chatgpt_oauth/test_plugin.py +26 -11
  68. code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
  69. code_puppy/plugins/claude_code_oauth/register_callbacks.py +30 -0
  70. code_puppy/plugins/claude_code_oauth/utils.py +1 -0
  71. code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -118
  72. code_puppy/plugins/shell_safety/register_callbacks.py +44 -3
  73. code_puppy/prompts/codex_system_prompt.md +310 -0
  74. code_puppy/pydantic_patches.py +131 -0
  75. code_puppy/reopenable_async_client.py +8 -8
  76. code_puppy/terminal_utils.py +291 -0
  77. code_puppy/tools/agent_tools.py +34 -9
  78. code_puppy/tools/command_runner.py +344 -27
  79. code_puppy/tools/file_operations.py +33 -45
  80. code_puppy/uvx_detection.py +242 -0
  81. {code_puppy-0.0.302.data → code_puppy-0.0.335.data}/data/code_puppy/models.json +21 -7
  82. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/METADATA +30 -1
  83. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/RECORD +87 -64
  84. {code_puppy-0.0.302.data → code_puppy-0.0.335.data}/data/code_puppy/models_dev_api.json +0 -0
  85. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/WHEEL +0 -0
  86. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/entry_points.txt +0 -0
  87. {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
- 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,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
- 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()
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
- 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()
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, command: str, cwd: str = None, timeout: int = 60
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=next_steps if next_steps and next_steps.strip() else None,
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, command: str = "", cwd: str = None, timeout: int = 60
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, reasoning: str = "", next_steps: str | None = None
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 None if no specific next steps
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
- 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):