code-puppy 0.0.287__py3-none-any.whl → 0.0.323__py3-none-any.whl

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