code-puppy 0.0.354__py3-none-any.whl → 0.0.355__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 (37) hide show
  1. code_puppy/agents/__init__.py +2 -0
  2. code_puppy/agents/event_stream_handler.py +74 -1
  3. code_puppy/agents/subagent_stream_handler.py +276 -0
  4. code_puppy/api/__init__.py +13 -0
  5. code_puppy/api/app.py +92 -0
  6. code_puppy/api/main.py +21 -0
  7. code_puppy/api/pty_manager.py +446 -0
  8. code_puppy/api/routers/__init__.py +12 -0
  9. code_puppy/api/routers/agents.py +36 -0
  10. code_puppy/api/routers/commands.py +198 -0
  11. code_puppy/api/routers/config.py +74 -0
  12. code_puppy/api/routers/sessions.py +191 -0
  13. code_puppy/api/templates/terminal.html +361 -0
  14. code_puppy/api/websocket.py +154 -0
  15. code_puppy/callbacks.py +73 -0
  16. code_puppy/command_line/core_commands.py +85 -0
  17. code_puppy/config.py +63 -0
  18. code_puppy/messaging/__init__.py +15 -0
  19. code_puppy/messaging/messages.py +27 -0
  20. code_puppy/messaging/rich_renderer.py +34 -0
  21. code_puppy/messaging/spinner/__init__.py +20 -2
  22. code_puppy/messaging/subagent_console.py +461 -0
  23. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  24. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  25. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  26. code_puppy/status_display.py +6 -2
  27. code_puppy/tools/agent_tools.py +53 -49
  28. code_puppy/tools/common.py +176 -1
  29. code_puppy/tools/display.py +6 -1
  30. code_puppy/tools/subagent_context.py +158 -0
  31. {code_puppy-0.0.354.dist-info → code_puppy-0.0.355.dist-info}/METADATA +4 -3
  32. {code_puppy-0.0.354.dist-info → code_puppy-0.0.355.dist-info}/RECORD +37 -20
  33. {code_puppy-0.0.354.data → code_puppy-0.0.355.data}/data/code_puppy/models.json +0 -0
  34. {code_puppy-0.0.354.data → code_puppy-0.0.355.data}/data/code_puppy/models_dev_api.json +0 -0
  35. {code_puppy-0.0.354.dist-info → code_puppy-0.0.355.dist-info}/WHEEL +0 -0
  36. {code_puppy-0.0.354.dist-info → code_puppy-0.0.355.dist-info}/entry_points.txt +0 -0
  37. {code_puppy-0.0.354.dist-info → code_puppy-0.0.355.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,154 @@
1
+ """WebSocket endpoints for Code Puppy API.
2
+
3
+ Provides real-time communication channels:
4
+ - /ws/events - Server-sent events stream
5
+ - /ws/terminal - Interactive PTY terminal sessions
6
+ - /ws/health - Simple health check endpoint
7
+ """
8
+
9
+ import asyncio
10
+ import base64
11
+ import logging
12
+ import uuid
13
+
14
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def setup_websocket(app: FastAPI) -> None:
20
+ """Setup WebSocket endpoints for the application."""
21
+
22
+ @app.websocket("/ws/events")
23
+ async def websocket_events(websocket: WebSocket) -> None:
24
+ """Stream real-time events to connected clients."""
25
+ await websocket.accept()
26
+ logger.info("Events WebSocket client connected")
27
+
28
+ from code_puppy.plugins.frontend_emitter.emitter import (
29
+ get_recent_events,
30
+ subscribe,
31
+ unsubscribe,
32
+ )
33
+
34
+ event_queue = subscribe()
35
+
36
+ try:
37
+ recent_events = get_recent_events()
38
+ for event in recent_events:
39
+ await websocket.send_json(event)
40
+
41
+ while True:
42
+ try:
43
+ event = await asyncio.wait_for(event_queue.get(), timeout=30.0)
44
+ await websocket.send_json(event)
45
+ except asyncio.TimeoutError:
46
+ try:
47
+ await websocket.send_json({"type": "ping"})
48
+ except Exception:
49
+ break
50
+ except WebSocketDisconnect:
51
+ logger.info("Events WebSocket client disconnected")
52
+ except Exception as e:
53
+ logger.error(f"Events WebSocket error: {e}")
54
+ finally:
55
+ unsubscribe(event_queue)
56
+
57
+ @app.websocket("/ws/terminal")
58
+ async def websocket_terminal(websocket: WebSocket) -> None:
59
+ """Interactive terminal WebSocket endpoint."""
60
+ await websocket.accept()
61
+ logger.info("Terminal WebSocket client connected")
62
+
63
+ from code_puppy.api.pty_manager import get_pty_manager
64
+
65
+ manager = get_pty_manager()
66
+ session_id = str(uuid.uuid4())[:8]
67
+ session = None
68
+
69
+ # Get the current event loop for thread-safe scheduling
70
+ loop = asyncio.get_running_loop()
71
+
72
+ # Queue to receive PTY output in a thread-safe way
73
+ output_queue: asyncio.Queue[bytes] = asyncio.Queue()
74
+
75
+ # Output callback - called from thread pool, puts data in queue
76
+ def on_output(data: bytes) -> None:
77
+ try:
78
+ loop.call_soon_threadsafe(output_queue.put_nowait, data)
79
+ except Exception as e:
80
+ logger.error(f"on_output error: {e}")
81
+
82
+ async def output_sender() -> None:
83
+ """Coroutine that sends queued output to WebSocket."""
84
+ try:
85
+ while True:
86
+ data = await output_queue.get()
87
+ await websocket.send_json(
88
+ {
89
+ "type": "output",
90
+ "data": base64.b64encode(data).decode("ascii"),
91
+ }
92
+ )
93
+ except asyncio.CancelledError:
94
+ pass
95
+ except Exception as e:
96
+ logger.error(f"output_sender error: {e}")
97
+
98
+ sender_task = None
99
+
100
+ try:
101
+ # Create PTY session
102
+ session = await manager.create_session(
103
+ session_id=session_id,
104
+ on_output=on_output,
105
+ )
106
+
107
+ # Send session info
108
+ await websocket.send_json({"type": "session", "id": session_id})
109
+
110
+ # Start output sender task
111
+ sender_task = asyncio.create_task(output_sender())
112
+
113
+ # Handle incoming messages
114
+ while True:
115
+ try:
116
+ msg = await websocket.receive_json()
117
+
118
+ if msg.get("type") == "input":
119
+ data = msg.get("data", "")
120
+ if isinstance(data, str):
121
+ data = data.encode("utf-8")
122
+ await manager.write(session_id, data)
123
+ elif msg.get("type") == "resize":
124
+ cols = msg.get("cols", 80)
125
+ rows = msg.get("rows", 24)
126
+ await manager.resize(session_id, cols, rows)
127
+ except WebSocketDisconnect:
128
+ break
129
+ except Exception as e:
130
+ logger.error(f"Terminal WebSocket error: {e}")
131
+ break
132
+ except Exception as e:
133
+ logger.error(f"Terminal session error: {e}")
134
+ finally:
135
+ if sender_task:
136
+ sender_task.cancel()
137
+ try:
138
+ await sender_task
139
+ except asyncio.CancelledError:
140
+ pass
141
+ if session:
142
+ await manager.close_session(session_id)
143
+ logger.info("Terminal WebSocket disconnected")
144
+
145
+ @app.websocket("/ws/health")
146
+ async def websocket_health(websocket: WebSocket) -> None:
147
+ """Simple WebSocket health check - echoes messages back."""
148
+ await websocket.accept()
149
+ try:
150
+ while True:
151
+ data = await websocket.receive_text()
152
+ await websocket.send_text(f"echo: {data}")
153
+ except WebSocketDisconnect:
154
+ pass
code_puppy/callbacks.py CHANGED
@@ -18,6 +18,9 @@ PhaseType = Literal[
18
18
  "custom_command",
19
19
  "custom_command_help",
20
20
  "file_permission",
21
+ "pre_tool_call",
22
+ "post_tool_call",
23
+ "stream_event",
21
24
  ]
22
25
  CallbackFunc = Callable[..., Any]
23
26
 
@@ -36,6 +39,9 @@ _callbacks: Dict[PhaseType, List[CallbackFunc]] = {
36
39
  "custom_command": [],
37
40
  "custom_command_help": [],
38
41
  "file_permission": [],
42
+ "pre_tool_call": [],
43
+ "post_tool_call": [],
44
+ "stream_event": [],
39
45
  }
40
46
 
41
47
  logger = logging.getLogger(__name__)
@@ -271,3 +277,70 @@ def on_file_permission(
271
277
  message_group,
272
278
  operation_data,
273
279
  )
280
+
281
+
282
+ async def on_pre_tool_call(
283
+ tool_name: str, tool_args: dict, context: Any = None
284
+ ) -> List[Any]:
285
+ """Trigger callbacks before a tool is called.
286
+
287
+ This allows plugins to inspect, modify, or log tool calls before
288
+ they are executed.
289
+
290
+ Args:
291
+ tool_name: Name of the tool being called
292
+ tool_args: Arguments being passed to the tool
293
+ context: Optional context data for the tool call
294
+
295
+ Returns:
296
+ List of results from registered callbacks.
297
+ """
298
+ return await _trigger_callbacks("pre_tool_call", tool_name, tool_args, context)
299
+
300
+
301
+ async def on_post_tool_call(
302
+ tool_name: str,
303
+ tool_args: dict,
304
+ result: Any,
305
+ duration_ms: float,
306
+ context: Any = None,
307
+ ) -> List[Any]:
308
+ """Trigger callbacks after a tool completes.
309
+
310
+ This allows plugins to inspect tool results, log execution times,
311
+ or perform post-processing.
312
+
313
+ Args:
314
+ tool_name: Name of the tool that was called
315
+ tool_args: Arguments that were passed to the tool
316
+ result: The result returned by the tool
317
+ duration_ms: Execution time in milliseconds
318
+ context: Optional context data for the tool call
319
+
320
+ Returns:
321
+ List of results from registered callbacks.
322
+ """
323
+ return await _trigger_callbacks(
324
+ "post_tool_call", tool_name, tool_args, result, duration_ms, context
325
+ )
326
+
327
+
328
+ async def on_stream_event(
329
+ event_type: str, event_data: Any, agent_session_id: str | None = None
330
+ ) -> List[Any]:
331
+ """Trigger callbacks for streaming events.
332
+
333
+ This allows plugins to react to streaming events in real-time,
334
+ such as tokens being generated, tool calls starting, etc.
335
+
336
+ Args:
337
+ event_type: Type of the streaming event
338
+ event_data: Data associated with the event
339
+ agent_session_id: Optional session ID of the agent emitting the event
340
+
341
+ Returns:
342
+ List of results from registered callbacks.
343
+ """
344
+ return await _trigger_callbacks(
345
+ "stream_event", event_type, event_data, agent_session_id
346
+ )
@@ -772,6 +772,91 @@ def handle_mcp_command(command: str) -> bool:
772
772
  return handler.handle_mcp_command(command)
773
773
 
774
774
 
775
+ @register_command(
776
+ name="api",
777
+ description="Manage the Code Puppy API server",
778
+ usage="/api [start|stop|status]",
779
+ category="core",
780
+ detailed_help="Start, stop, or check status of the local FastAPI server for GUI integration.",
781
+ )
782
+ def handle_api_command(command: str) -> bool:
783
+ """Handle the /api command."""
784
+ import os
785
+ import signal
786
+ import subprocess
787
+ import sys
788
+ from pathlib import Path
789
+
790
+ from code_puppy.config import STATE_DIR
791
+ from code_puppy.messaging import emit_error, emit_info, emit_success
792
+
793
+ parts = command.split()
794
+ subcommand = parts[1] if len(parts) > 1 else "status"
795
+
796
+ pid_file = Path(STATE_DIR) / "api_server.pid"
797
+
798
+ if subcommand == "start":
799
+ # Check if already running
800
+ if pid_file.exists():
801
+ try:
802
+ pid = int(pid_file.read_text().strip())
803
+ os.kill(pid, 0) # Check if process exists
804
+ emit_info(f"API server already running (PID {pid})")
805
+ return True
806
+ except (OSError, ValueError):
807
+ pid_file.unlink(missing_ok=True) # Stale PID file
808
+
809
+ # Start the server in background
810
+ emit_info("Starting API server on http://127.0.0.1:8765 ...")
811
+ proc = subprocess.Popen(
812
+ [sys.executable, "-m", "code_puppy.api.main"],
813
+ stdout=subprocess.DEVNULL,
814
+ stderr=subprocess.DEVNULL,
815
+ start_new_session=True,
816
+ )
817
+ pid_file.parent.mkdir(parents=True, exist_ok=True)
818
+ pid_file.write_text(str(proc.pid))
819
+ emit_success(f"API server started (PID {proc.pid})")
820
+ emit_info("Docs available at http://127.0.0.1:8765/docs")
821
+ return True
822
+
823
+ elif subcommand == "stop":
824
+ if not pid_file.exists():
825
+ emit_info("API server is not running")
826
+ return True
827
+
828
+ try:
829
+ pid = int(pid_file.read_text().strip())
830
+ os.kill(pid, signal.SIGTERM)
831
+ pid_file.unlink()
832
+ emit_success(f"API server stopped (PID {pid})")
833
+ except (OSError, ValueError) as e:
834
+ pid_file.unlink(missing_ok=True)
835
+ emit_error(f"Error stopping server: {e}")
836
+ return True
837
+
838
+ elif subcommand == "status":
839
+ if not pid_file.exists():
840
+ emit_info("API server is not running")
841
+ return True
842
+
843
+ try:
844
+ pid = int(pid_file.read_text().strip())
845
+ os.kill(pid, 0) # Check if process exists
846
+ emit_success(f"API server is running (PID {pid})")
847
+ emit_info("URL: http://127.0.0.1:8765")
848
+ emit_info("Docs: http://127.0.0.1:8765/docs")
849
+ except (OSError, ValueError):
850
+ pid_file.unlink(missing_ok=True)
851
+ emit_info("API server is not running (stale PID file removed)")
852
+ return True
853
+
854
+ else:
855
+ emit_error(f"Unknown subcommand: {subcommand}")
856
+ emit_info("Usage: /api [start|stop|status]")
857
+ return True
858
+
859
+
775
860
  @register_command(
776
861
  name="generate-pr-description",
777
862
  description="Generate comprehensive PR description",
code_puppy/config.py CHANGED
@@ -75,6 +75,19 @@ def get_use_dbos() -> bool:
75
75
  return str(cfg_val).strip().lower() in {"1", "true", "yes", "on"}
76
76
 
77
77
 
78
+ def get_subagent_verbose() -> bool:
79
+ """Return True if sub-agent verbose output is enabled (default False).
80
+
81
+ When False (default), sub-agents produce quiet, sparse output suitable
82
+ for parallel execution. When True, sub-agents produce full verbose output
83
+ like the main agent (useful for debugging).
84
+ """
85
+ cfg_val = get_value("subagent_verbose")
86
+ if cfg_val is None:
87
+ return False
88
+ return str(cfg_val).strip().lower() in {"1", "true", "yes", "on"}
89
+
90
+
78
91
  DEFAULT_SECTION = "puppy"
79
92
  REQUIRED_KEYS = ["puppy_name", "owner_name"]
80
93
 
@@ -208,6 +221,9 @@ def get_config_keys():
208
221
  "diff_context_lines",
209
222
  "default_agent",
210
223
  "temperature",
224
+ "frontend_emitter_enabled",
225
+ "frontend_emitter_max_recent_events",
226
+ "frontend_emitter_queue_size",
211
227
  ]
212
228
  # Add DBOS control key
213
229
  default_keys.append("enable_dbos")
@@ -237,6 +253,22 @@ def set_config_value(key: str, value: str):
237
253
  config.write(f)
238
254
 
239
255
 
256
+ # Alias for API compatibility
257
+ def set_value(key: str, value: str) -> None:
258
+ """Set a config value. Alias for set_config_value."""
259
+ set_config_value(key, value)
260
+
261
+
262
+ def reset_value(key: str) -> None:
263
+ """Remove a key from the config file, resetting it to default."""
264
+ config = configparser.ConfigParser()
265
+ config.read(CONFIG_FILE)
266
+ if DEFAULT_SECTION in config and key in config[DEFAULT_SECTION]:
267
+ del config[DEFAULT_SECTION][key]
268
+ with open(CONFIG_FILE, "w") as f:
269
+ config.write(f)
270
+
271
+
240
272
  # --- MODEL STICKY EXTENSION STARTS HERE ---
241
273
  def load_mcp_server_configs():
242
274
  """
@@ -1584,3 +1616,34 @@ def set_default_agent(agent_name: str) -> None:
1584
1616
  agent_name: The name of the agent to set as default.
1585
1617
  """
1586
1618
  set_config_value("default_agent", agent_name)
1619
+
1620
+
1621
+ # --- FRONTEND EMITTER CONFIGURATION ---
1622
+ def get_frontend_emitter_enabled() -> bool:
1623
+ """Check if frontend emitter is enabled."""
1624
+ val = get_value("frontend_emitter_enabled")
1625
+ if val is None:
1626
+ return True # Enabled by default
1627
+ return str(val).lower() in ("1", "true", "yes", "on")
1628
+
1629
+
1630
+ def get_frontend_emitter_max_recent_events() -> int:
1631
+ """Get max number of recent events to buffer."""
1632
+ val = get_value("frontend_emitter_max_recent_events")
1633
+ if val is None:
1634
+ return 100
1635
+ try:
1636
+ return int(val)
1637
+ except ValueError:
1638
+ return 100
1639
+
1640
+
1641
+ def get_frontend_emitter_queue_size() -> int:
1642
+ """Get max subscriber queue size."""
1643
+ val = get_value("frontend_emitter_queue_size")
1644
+ if val is None:
1645
+ return 100
1646
+ try:
1647
+ return int(val)
1648
+ except ValueError:
1649
+ return 100
@@ -113,6 +113,7 @@ from .messages import ( # Enums, Base, Text, File ops, Diff, Shell, Agent, etc.
113
113
  StatusPanelMessage,
114
114
  SubAgentInvocationMessage,
115
115
  SubAgentResponseMessage,
116
+ SubAgentStatusMessage,
116
117
  TextMessage,
117
118
  UserInputRequest,
118
119
  VersionCheckMessage,
@@ -120,6 +121,14 @@ from .messages import ( # Enums, Base, Text, File ops, Diff, Shell, Agent, etc.
120
121
  from .queue_console import QueueConsole, get_queue_console
121
122
  from .renderers import InteractiveRenderer, SynchronousInteractiveRenderer
122
123
 
124
+ # Sub-agent console manager
125
+ from .subagent_console import (
126
+ AgentState,
127
+ SubAgentConsoleManager,
128
+ get_subagent_console_manager,
129
+ STATUS_STYLES as SUBAGENT_STATUS_STYLES,
130
+ )
131
+
123
132
  # Renderer
124
133
  from .rich_renderer import (
125
134
  DEFAULT_STYLES,
@@ -193,6 +202,7 @@ __all__ = [
193
202
  "AgentResponseMessage",
194
203
  "SubAgentInvocationMessage",
195
204
  "SubAgentResponseMessage",
205
+ "SubAgentStatusMessage",
196
206
  "UserInputRequest",
197
207
  "ConfirmationRequest",
198
208
  "SelectionRequest",
@@ -229,4 +239,9 @@ __all__ = [
229
239
  "DIFF_STYLES",
230
240
  # Markdown patches
231
241
  "patch_markdown_headings",
242
+ # Sub-agent console manager
243
+ "AgentState",
244
+ "SubAgentConsoleManager",
245
+ "get_subagent_console_manager",
246
+ "SUBAGENT_STATUS_STYLES",
232
247
  ]
@@ -292,6 +292,31 @@ class SubAgentResponseMessage(BaseMessage):
292
292
  )
293
293
 
294
294
 
295
+ class SubAgentStatusMessage(BaseMessage):
296
+ """Real-time status update for a running sub-agent."""
297
+
298
+ category: MessageCategory = MessageCategory.AGENT
299
+ session_id: str = Field(description="Unique session ID of the sub-agent")
300
+ agent_name: str = Field(description="Name of the agent (e.g., 'code-puppy')")
301
+ model_name: str = Field(description="Model being used by this agent")
302
+ status: Literal[
303
+ "starting", "running", "thinking", "tool_calling", "completed", "error"
304
+ ] = Field(description="Current status of the agent")
305
+ tool_call_count: int = Field(
306
+ default=0, ge=0, description="Number of tools called so far"
307
+ )
308
+ token_count: int = Field(default=0, ge=0, description="Estimated tokens in context")
309
+ current_tool: Optional[str] = Field(
310
+ default=None, description="Name of tool currently being called"
311
+ )
312
+ elapsed_seconds: float = Field(
313
+ default=0.0, ge=0, description="Time since agent started"
314
+ )
315
+ error_message: Optional[str] = Field(
316
+ default=None, description="Error message if status is 'error'"
317
+ )
318
+
319
+
295
320
  # =============================================================================
296
321
  # User Interaction Messages (Agent → User)
297
322
  # =============================================================================
@@ -417,6 +442,7 @@ AnyMessage = Union[
417
442
  AgentResponseMessage,
418
443
  SubAgentInvocationMessage,
419
444
  SubAgentResponseMessage,
445
+ SubAgentStatusMessage,
420
446
  UserInputRequest,
421
447
  ConfirmationRequest,
422
448
  SelectionRequest,
@@ -458,6 +484,7 @@ __all__ = [
458
484
  "AgentResponseMessage",
459
485
  "SubAgentInvocationMessage",
460
486
  "SubAgentResponseMessage",
487
+ "SubAgentStatusMessage",
461
488
  # User interaction
462
489
  "UserInputRequest",
463
490
  "ConfirmationRequest",
@@ -18,7 +18,9 @@ from rich.rule import Rule
18
18
  # Note: Syntax import removed - file content not displayed, only header
19
19
  from rich.table import Table
20
20
 
21
+ from code_puppy.config import get_subagent_verbose
21
22
  from code_puppy.tools.common import format_diff_with_colors
23
+ from code_puppy.tools.subagent_context import is_subagent
22
24
 
23
25
  from .bus import MessageBus
24
26
  from .commands import (
@@ -159,6 +161,14 @@ class RichConsoleRenderer:
159
161
  color = self._get_banner_color(banner_name)
160
162
  return f"[bold white on {color}] {text} [/bold white on {color}]"
161
163
 
164
+ def _should_suppress_subagent_output(self) -> bool:
165
+ """Check if sub-agent output should be suppressed.
166
+
167
+ Returns:
168
+ True if we're in a sub-agent context and verbose mode is disabled
169
+ """
170
+ return is_subagent() and not get_subagent_verbose()
171
+
162
172
  # =========================================================================
163
173
  # Lifecycle (Synchronous - for compatibility with main.py)
164
174
  # =========================================================================
@@ -357,6 +367,10 @@ class RichConsoleRenderer:
357
367
  - Total size
358
368
  - Number of subdirectories
359
369
  """
370
+ # Skip for sub-agents unless verbose mode
371
+ if self._should_suppress_subagent_output():
372
+ return
373
+
360
374
  import os
361
375
  from collections import defaultdict
362
376
 
@@ -479,6 +493,10 @@ class RichConsoleRenderer:
479
493
 
480
494
  The file content is for the LLM only, not for display in the UI.
481
495
  """
496
+ # Skip for sub-agents unless verbose mode
497
+ if self._should_suppress_subagent_output():
498
+ return
499
+
482
500
  # Build line info
483
501
  line_info = ""
484
502
  if msg.start_line is not None and msg.num_lines is not None:
@@ -493,6 +511,10 @@ class RichConsoleRenderer:
493
511
 
494
512
  def _render_grep_result(self, msg: GrepResultMessage) -> None:
495
513
  """Render grep results grouped by file matching old format."""
514
+ # Skip for sub-agents unless verbose mode
515
+ if self._should_suppress_subagent_output():
516
+ return
517
+
496
518
  import re
497
519
 
498
520
  # Header
@@ -573,6 +595,10 @@ class RichConsoleRenderer:
573
595
 
574
596
  def _render_diff(self, msg: DiffMessage) -> None:
575
597
  """Render a diff with beautiful syntax highlighting."""
598
+ # Skip for sub-agents unless verbose mode
599
+ if self._should_suppress_subagent_output():
600
+ return
601
+
576
602
  # Operation-specific styling
577
603
  op_icons = {"create": "✨", "modify": "✏️", "delete": "🗑️"}
578
604
  op_colors = {"create": "green", "modify": "yellow", "delete": "red"}
@@ -617,6 +643,10 @@ class RichConsoleRenderer:
617
643
 
618
644
  def _render_shell_start(self, msg: ShellStartMessage) -> None:
619
645
  """Render shell command start notification."""
646
+ # Skip for sub-agents unless verbose mode
647
+ if self._should_suppress_subagent_output():
648
+ return
649
+
620
650
  # Escape command to prevent Rich markup injection
621
651
  safe_command = escape_rich_markup(msg.command)
622
652
  # Header showing command is starting
@@ -701,6 +731,10 @@ class RichConsoleRenderer:
701
731
 
702
732
  def _render_subagent_invocation(self, msg: SubAgentInvocationMessage) -> None:
703
733
  """Render sub-agent invocation header with nice formatting."""
734
+ # Skip for sub-agents unless verbose mode (avoid nested invocation banners)
735
+ if self._should_suppress_subagent_output():
736
+ return
737
+
704
738
  # Header with agent name and session
705
739
  session_type = (
706
740
  "New session"
@@ -24,7 +24,16 @@ def unregister_spinner(spinner):
24
24
 
25
25
 
26
26
  def pause_all_spinners():
27
- """Pause all active spinners."""
27
+ """Pause all active spinners.
28
+
29
+ No-op when called from a sub-agent context to prevent
30
+ parallel sub-agents from interfering with the main spinner.
31
+ """
32
+ # Lazy import to avoid circular dependency
33
+ from code_puppy.tools.subagent_context import is_subagent
34
+
35
+ if is_subagent():
36
+ return # Sub-agents don't control the main spinner
28
37
  for spinner in _active_spinners:
29
38
  try:
30
39
  spinner.pause()
@@ -34,7 +43,16 @@ def pause_all_spinners():
34
43
 
35
44
 
36
45
  def resume_all_spinners():
37
- """Resume all active spinners."""
46
+ """Resume all active spinners.
47
+
48
+ No-op when called from a sub-agent context to prevent
49
+ parallel sub-agents from interfering with the main spinner.
50
+ """
51
+ # Lazy import to avoid circular dependency
52
+ from code_puppy.tools.subagent_context import is_subagent
53
+
54
+ if is_subagent():
55
+ return # Sub-agents don't control the main spinner
38
56
  for spinner in _active_spinners:
39
57
  try:
40
58
  spinner.resume()