code-puppy 0.0.341__py3-none-any.whl → 0.0.361__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 (86) hide show
  1. code_puppy/agents/__init__.py +2 -0
  2. code_puppy/agents/agent_manager.py +49 -0
  3. code_puppy/agents/agent_pack_leader.py +383 -0
  4. code_puppy/agents/agent_qa_kitten.py +12 -7
  5. code_puppy/agents/agent_terminal_qa.py +323 -0
  6. code_puppy/agents/base_agent.py +34 -252
  7. code_puppy/agents/event_stream_handler.py +350 -0
  8. code_puppy/agents/pack/__init__.py +34 -0
  9. code_puppy/agents/pack/bloodhound.py +304 -0
  10. code_puppy/agents/pack/husky.py +321 -0
  11. code_puppy/agents/pack/retriever.py +393 -0
  12. code_puppy/agents/pack/shepherd.py +348 -0
  13. code_puppy/agents/pack/terrier.py +287 -0
  14. code_puppy/agents/pack/watchdog.py +367 -0
  15. code_puppy/agents/subagent_stream_handler.py +276 -0
  16. code_puppy/api/__init__.py +13 -0
  17. code_puppy/api/app.py +169 -0
  18. code_puppy/api/main.py +21 -0
  19. code_puppy/api/pty_manager.py +446 -0
  20. code_puppy/api/routers/__init__.py +12 -0
  21. code_puppy/api/routers/agents.py +36 -0
  22. code_puppy/api/routers/commands.py +217 -0
  23. code_puppy/api/routers/config.py +74 -0
  24. code_puppy/api/routers/sessions.py +232 -0
  25. code_puppy/api/templates/terminal.html +361 -0
  26. code_puppy/api/websocket.py +154 -0
  27. code_puppy/callbacks.py +73 -0
  28. code_puppy/claude_cache_client.py +249 -34
  29. code_puppy/cli_runner.py +4 -3
  30. code_puppy/command_line/add_model_menu.py +8 -9
  31. code_puppy/command_line/core_commands.py +85 -0
  32. code_puppy/command_line/mcp/catalog_server_installer.py +5 -6
  33. code_puppy/command_line/mcp/custom_server_form.py +54 -19
  34. code_puppy/command_line/mcp/custom_server_installer.py +8 -9
  35. code_puppy/command_line/mcp/handler.py +0 -2
  36. code_puppy/command_line/mcp/help_command.py +1 -5
  37. code_puppy/command_line/mcp/start_command.py +36 -18
  38. code_puppy/command_line/onboarding_slides.py +0 -1
  39. code_puppy/command_line/prompt_toolkit_completion.py +16 -10
  40. code_puppy/command_line/utils.py +54 -0
  41. code_puppy/config.py +66 -62
  42. code_puppy/mcp_/async_lifecycle.py +35 -4
  43. code_puppy/mcp_/managed_server.py +49 -20
  44. code_puppy/mcp_/manager.py +81 -52
  45. code_puppy/messaging/__init__.py +15 -0
  46. code_puppy/messaging/message_queue.py +11 -23
  47. code_puppy/messaging/messages.py +27 -0
  48. code_puppy/messaging/queue_console.py +1 -1
  49. code_puppy/messaging/rich_renderer.py +36 -1
  50. code_puppy/messaging/spinner/__init__.py +20 -2
  51. code_puppy/messaging/subagent_console.py +461 -0
  52. code_puppy/model_utils.py +54 -0
  53. code_puppy/plugins/antigravity_oauth/antigravity_model.py +90 -19
  54. code_puppy/plugins/antigravity_oauth/transport.py +1 -0
  55. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  56. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  57. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  58. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  59. code_puppy/status_display.py +6 -2
  60. code_puppy/tools/__init__.py +37 -1
  61. code_puppy/tools/agent_tools.py +139 -36
  62. code_puppy/tools/browser/__init__.py +37 -0
  63. code_puppy/tools/browser/browser_control.py +6 -6
  64. code_puppy/tools/browser/browser_interactions.py +21 -20
  65. code_puppy/tools/browser/browser_locators.py +9 -9
  66. code_puppy/tools/browser/browser_navigation.py +7 -7
  67. code_puppy/tools/browser/browser_screenshot.py +78 -140
  68. code_puppy/tools/browser/browser_scripts.py +15 -13
  69. code_puppy/tools/browser/camoufox_manager.py +226 -64
  70. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  71. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  72. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  73. code_puppy/tools/browser/terminal_tools.py +525 -0
  74. code_puppy/tools/command_runner.py +292 -101
  75. code_puppy/tools/common.py +176 -1
  76. code_puppy/tools/display.py +84 -0
  77. code_puppy/tools/subagent_context.py +158 -0
  78. {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/METADATA +13 -11
  79. {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/RECORD +84 -53
  80. code_puppy/command_line/mcp/add_command.py +0 -170
  81. code_puppy/tools/browser/vqa_agent.py +0 -90
  82. {code_puppy-0.0.341.data → code_puppy-0.0.361.data}/data/code_puppy/models.json +0 -0
  83. {code_puppy-0.0.341.data → code_puppy-0.0.361.data}/data/code_puppy/models_dev_api.json +0 -0
  84. {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/WHEEL +0 -0
  85. {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/entry_points.txt +0 -0
  86. {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/licenses/LICENSE +0 -0
@@ -28,6 +28,31 @@ from code_puppy.mcp_.blocking_startup import BlockingMCPServerStdio
28
28
  from code_puppy.messaging import emit_info
29
29
 
30
30
 
31
+ def _expand_env_vars(value: Any) -> Any:
32
+ """
33
+ Recursively expand environment variables in config values.
34
+
35
+ Supports $VAR and ${VAR} syntax. Works with:
36
+ - Strings: expands env vars
37
+ - Dicts: recursively expands all string values
38
+ - Lists: recursively expands all string elements
39
+ - Other types: returned as-is
40
+
41
+ Args:
42
+ value: The value to expand env vars in
43
+
44
+ Returns:
45
+ The value with env vars expanded
46
+ """
47
+ if isinstance(value, str):
48
+ return os.path.expandvars(value)
49
+ elif isinstance(value, dict):
50
+ return {k: _expand_env_vars(v) for k, v in value.items()}
51
+ elif isinstance(value, list):
52
+ return [_expand_env_vars(item) for item in value]
53
+ return value
54
+
55
+
31
56
  class ServerState(Enum):
32
57
  """Enumeration of possible server states."""
33
58
 
@@ -153,9 +178,9 @@ class ManagedMCPServer:
153
178
  if "url" not in config:
154
179
  raise ValueError("SSE server requires 'url' in config")
155
180
 
156
- # Prepare arguments for MCPServerSSE
181
+ # Prepare arguments for MCPServerSSE (expand env vars in URL)
157
182
  sse_kwargs = {
158
- "url": config["url"],
183
+ "url": _expand_env_vars(config["url"]),
159
184
  }
160
185
 
161
186
  # Add optional parameters if provided
@@ -177,23 +202,26 @@ class ManagedMCPServer:
177
202
  if "command" not in config:
178
203
  raise ValueError("Stdio server requires 'command' in config")
179
204
 
180
- # Handle command and arguments
181
- command = config["command"]
205
+ # Handle command and arguments (expand env vars)
206
+ command = _expand_env_vars(config["command"])
182
207
  args = config.get("args", [])
183
208
  if isinstance(args, str):
184
- # If args is a string, split it
185
- args = args.split()
209
+ # If args is a string, split it then expand
210
+ args = [_expand_env_vars(a) for a in args.split()]
211
+ else:
212
+ args = _expand_env_vars(args)
186
213
 
187
214
  # Prepare arguments for MCPServerStdio
188
215
  stdio_kwargs = {"command": command, "args": list(args) if args else []}
189
216
 
190
- # Add optional parameters if provided
217
+ # Add optional parameters if provided (expand env vars in env and cwd)
191
218
  if "env" in config:
192
- stdio_kwargs["env"] = config["env"]
219
+ stdio_kwargs["env"] = _expand_env_vars(config["env"])
193
220
  if "cwd" in config:
194
- stdio_kwargs["cwd"] = config["cwd"]
195
- if "timeout" in config:
196
- stdio_kwargs["timeout"] = config["timeout"]
221
+ stdio_kwargs["cwd"] = _expand_env_vars(config["cwd"])
222
+ # Default timeout of 60s for stdio servers - some servers like Serena take a while to start
223
+ # Users can override this in their config
224
+ stdio_kwargs["timeout"] = config.get("timeout", 60)
197
225
  if "read_timeout" in config:
198
226
  stdio_kwargs["read_timeout"] = config["read_timeout"]
199
227
 
@@ -212,9 +240,9 @@ class ManagedMCPServer:
212
240
  if "url" not in config:
213
241
  raise ValueError("HTTP server requires 'url' in config")
214
242
 
215
- # Prepare arguments for MCPServerStreamableHTTP
243
+ # Prepare arguments for MCPServerStreamableHTTP (expand env vars in URL)
216
244
  http_kwargs = {
217
- "url": config["url"],
245
+ "url": _expand_env_vars(config["url"]),
218
246
  }
219
247
 
220
248
  # Add optional parameters if provided
@@ -223,13 +251,14 @@ class ManagedMCPServer:
223
251
  if "read_timeout" in config:
224
252
  http_kwargs["read_timeout"] = config["read_timeout"]
225
253
 
226
- # Handle http_client vs headers (mutually exclusive)
227
- if "http_client" in config:
228
- # Use provided http_client
229
- http_kwargs["http_client"] = config["http_client"]
230
- elif config.get("headers"):
231
- # Create HTTP client if headers are provided but no client specified
232
- http_kwargs["http_client"] = self._get_http_client()
254
+ # Pass headers directly instead of creating http_client
255
+ # Note: There's a bug in MCP 1.25.0 where passing http_client
256
+ # causes "'_AsyncGeneratorContextManager' object has no attribute 'stream'"
257
+ # The workaround is to pass headers directly and let pydantic-ai
258
+ # create the http_client internally.
259
+ if config.get("headers"):
260
+ # Expand environment variables in headers
261
+ http_kwargs["headers"] = _expand_env_vars(config["headers"])
233
262
 
234
263
  self._pydantic_server = MCPServerStreamableHTTP(
235
264
  **http_kwargs, process_tool_call=process_tool_call
@@ -469,41 +469,57 @@ class MCPManager:
469
469
  def start_server_sync(self, server_id: str) -> bool:
470
470
  """
471
471
  Synchronous wrapper for start_server.
472
+
473
+ IMPORTANT: This schedules the server start as a background task.
474
+ The server subprocess will start asynchronously - it may not be
475
+ immediately ready when this function returns.
472
476
  """
473
477
  try:
474
- asyncio.get_running_loop()
475
- # We're in an async context, but we need to wait for completion
476
- # Create a future and schedule the coroutine
478
+ loop = asyncio.get_running_loop()
479
+ # We're in an async context - schedule the server start as a background task
480
+ # DO NOT use blocking time.sleep() here as it freezes the event loop!
481
+
482
+ # First, enable the server immediately so it's recognized as "starting"
483
+ managed_server = self._managed_servers.get(server_id)
484
+ if managed_server:
485
+ managed_server.enable()
486
+ self.status_tracker.set_status(server_id, ServerState.STARTING)
487
+ self.status_tracker.record_start_time(server_id)
477
488
 
478
- # Use run_in_executor to run the async function synchronously
479
- async def run_async():
480
- return await self.start_server(server_id)
489
+ # Schedule the async start_server to run in the background
490
+ # This will properly start the subprocess and lifecycle task
491
+ async def start_server_background():
492
+ try:
493
+ result = await self.start_server(server_id)
494
+ if result:
495
+ logger.info(f"Background server start completed: {server_id}")
496
+ else:
497
+ logger.warning(f"Background server start failed: {server_id}")
498
+ return result
499
+ except Exception as e:
500
+ logger.error(f"Background server start error for {server_id}: {e}")
501
+ self.status_tracker.set_status(server_id, ServerState.ERROR)
502
+ return False
481
503
 
482
- # Schedule the task and wait briefly for it to complete
483
- task = asyncio.create_task(run_async())
504
+ # Create the task - it will run when the event loop gets control
505
+ task = loop.create_task(
506
+ start_server_background(), name=f"start_server_{server_id}"
507
+ )
484
508
 
485
- # Give it a moment to complete - this fixes the race condition
486
- import time
509
+ # Store task reference to prevent garbage collection
510
+ if not hasattr(self, "_pending_start_tasks"):
511
+ self._pending_start_tasks = {}
512
+ self._pending_start_tasks[server_id] = task
487
513
 
488
- time.sleep(0.1) # Small delay to let async tasks progress
514
+ # Add callback to clean up task reference when done
515
+ def cleanup_task(t):
516
+ if hasattr(self, "_pending_start_tasks"):
517
+ self._pending_start_tasks.pop(server_id, None)
489
518
 
490
- # Check if task completed, if not, fall back to sync enable
491
- if task.done():
492
- try:
493
- result = task.result()
494
- return result
495
- except Exception:
496
- pass
519
+ task.add_done_callback(cleanup_task)
497
520
 
498
- # If async didn't complete, enable synchronously
499
- managed_server = self._managed_servers.get(server_id)
500
- if managed_server:
501
- managed_server.enable()
502
- self.status_tracker.set_status(server_id, ServerState.RUNNING)
503
- self.status_tracker.record_start_time(server_id)
504
- logger.info(f"Enabled server synchronously: {server_id}")
505
- return True
506
- return False
521
+ logger.info(f"Scheduled background start for server: {server_id}")
522
+ return True # Return immediately - server will start in background
507
523
 
508
524
  except RuntimeError:
509
525
  # No async loop, just enable the server
@@ -582,39 +598,52 @@ class MCPManager:
582
598
  def stop_server_sync(self, server_id: str) -> bool:
583
599
  """
584
600
  Synchronous wrapper for stop_server.
601
+
602
+ IMPORTANT: This schedules the server stop as a background task.
603
+ The server subprocess will stop asynchronously.
585
604
  """
586
605
  try:
587
- asyncio.get_running_loop()
606
+ loop = asyncio.get_running_loop()
607
+ # We're in an async context - schedule the server stop as a background task
608
+ # DO NOT use blocking time.sleep() here as it freezes the event loop!
588
609
 
589
- # We're in an async context, but we need to wait for completion
590
- async def run_async():
591
- return await self.stop_server(server_id)
610
+ # First, disable the server immediately
611
+ managed_server = self._managed_servers.get(server_id)
612
+ if managed_server:
613
+ managed_server.disable()
614
+ self.status_tracker.set_status(server_id, ServerState.STOPPING)
615
+ self.status_tracker.record_stop_time(server_id)
592
616
 
593
- # Schedule the task and wait briefly for it to complete
594
- task = asyncio.create_task(run_async())
617
+ # Schedule the async stop_server to run in the background
618
+ async def stop_server_background():
619
+ try:
620
+ result = await self.stop_server(server_id)
621
+ if result:
622
+ logger.info(f"Background server stop completed: {server_id}")
623
+ return result
624
+ except Exception as e:
625
+ logger.error(f"Background server stop error for {server_id}: {e}")
626
+ return False
595
627
 
596
- # Give it a moment to complete - this fixes the race condition
597
- import time
628
+ # Create the task - it will run when the event loop gets control
629
+ task = loop.create_task(
630
+ stop_server_background(), name=f"stop_server_{server_id}"
631
+ )
598
632
 
599
- time.sleep(0.1) # Small delay to let async tasks progress
633
+ # Store task reference to prevent garbage collection
634
+ if not hasattr(self, "_pending_stop_tasks"):
635
+ self._pending_stop_tasks = {}
636
+ self._pending_stop_tasks[server_id] = task
600
637
 
601
- # Check if task completed, if not, fall back to sync disable
602
- if task.done():
603
- try:
604
- result = task.result()
605
- return result
606
- except Exception:
607
- pass
638
+ # Add callback to clean up task reference when done
639
+ def cleanup_task(t):
640
+ if hasattr(self, "_pending_stop_tasks"):
641
+ self._pending_stop_tasks.pop(server_id, None)
608
642
 
609
- # If async didn't complete, disable synchronously
610
- managed_server = self._managed_servers.get(server_id)
611
- if managed_server:
612
- managed_server.disable()
613
- self.status_tracker.set_status(server_id, ServerState.STOPPED)
614
- self.status_tracker.record_stop_time(server_id)
615
- logger.info(f"Disabled server synchronously: {server_id}")
616
- return True
617
- return False
643
+ task.add_done_callback(cleanup_task)
644
+
645
+ logger.info(f"Scheduled background stop for server: {server_id}")
646
+ return True # Return immediately - server will stop in background
618
647
 
619
648
  except RuntimeError:
620
649
  # No async loop, just disable the server
@@ -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
  ]
@@ -329,31 +329,19 @@ def emit_divider(content: str = "─" * 100 + "\n", **metadata):
329
329
 
330
330
 
331
331
  def emit_prompt(prompt_text: str, timeout: float = None) -> str:
332
- """Emit a human input request and wait for response."""
333
- # TUI mode has been removed, always use interactive mode input
334
- if True:
335
- # Emit the prompt as a message for display
336
- from code_puppy.messaging import emit_info
332
+ """Emit a human input request and wait for response.
337
333
 
338
- emit_info(prompt_text)
334
+ Uses safe_input for cross-platform compatibility, especially on Windows
335
+ where raw input() can fail after prompt_toolkit Applications.
336
+ """
337
+ from code_puppy.command_line.utils import safe_input
338
+ from code_puppy.messaging import emit_info
339
339
 
340
- # Get input directly
341
- try:
342
- # Try to use rich console for better formatting
343
- from rich.console import Console
344
-
345
- console = Console()
346
- response = console.input("[cyan]>>> [/cyan]")
347
- return response
348
- except Exception:
349
- # Fallback to basic input
350
- response = input(">>> ")
351
- return response
352
-
353
- # In TUI mode, use the queue system
354
- queue = get_global_queue()
355
- prompt_id = queue.create_prompt_request(prompt_text)
356
- return queue.wait_for_prompt_response(prompt_id, timeout)
340
+ emit_info(prompt_text)
341
+
342
+ # Use safe_input which resets Windows console state before reading
343
+ response = safe_input(">>> ")
344
+ return response
357
345
 
358
346
 
359
347
  def provide_prompt_response(prompt_id: str, response: str):
@@ -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",
@@ -239,7 +239,7 @@ class QueueConsole:
239
239
  # Show the user's response in the chat as well
240
240
  if user_response:
241
241
  self.queue.emit_simple(
242
- MessageType.USER, f"User response: {user_response}"
242
+ MessageType.INFO, f"User response: {user_response}"
243
243
  )
244
244
 
245
245
  return user_response
@@ -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
  # =========================================================================
@@ -275,7 +285,8 @@ class RichConsoleRenderer:
275
285
  elif isinstance(message, SubAgentInvocationMessage):
276
286
  self._render_subagent_invocation(message)
277
287
  elif isinstance(message, SubAgentResponseMessage):
278
- self._render_subagent_response(message)
288
+ # Skip rendering - we now display sub-agent responses via display_non_streamed_result
289
+ pass
279
290
  elif isinstance(message, UserInputRequest):
280
291
  # Can't handle async user input in sync context - skip
281
292
  self._console.print("[dim]User input requested (requires async)[/dim]")
@@ -356,6 +367,10 @@ class RichConsoleRenderer:
356
367
  - Total size
357
368
  - Number of subdirectories
358
369
  """
370
+ # Skip for sub-agents unless verbose mode
371
+ if self._should_suppress_subagent_output():
372
+ return
373
+
359
374
  import os
360
375
  from collections import defaultdict
361
376
 
@@ -478,6 +493,10 @@ class RichConsoleRenderer:
478
493
 
479
494
  The file content is for the LLM only, not for display in the UI.
480
495
  """
496
+ # Skip for sub-agents unless verbose mode
497
+ if self._should_suppress_subagent_output():
498
+ return
499
+
481
500
  # Build line info
482
501
  line_info = ""
483
502
  if msg.start_line is not None and msg.num_lines is not None:
@@ -492,6 +511,10 @@ class RichConsoleRenderer:
492
511
 
493
512
  def _render_grep_result(self, msg: GrepResultMessage) -> None:
494
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
+
495
518
  import re
496
519
 
497
520
  # Header
@@ -572,6 +595,10 @@ class RichConsoleRenderer:
572
595
 
573
596
  def _render_diff(self, msg: DiffMessage) -> None:
574
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
+
575
602
  # Operation-specific styling
576
603
  op_icons = {"create": "✨", "modify": "✏️", "delete": "🗑️"}
577
604
  op_colors = {"create": "green", "modify": "yellow", "delete": "red"}
@@ -616,6 +643,10 @@ class RichConsoleRenderer:
616
643
 
617
644
  def _render_shell_start(self, msg: ShellStartMessage) -> None:
618
645
  """Render shell command start notification."""
646
+ # Skip for sub-agents unless verbose mode
647
+ if self._should_suppress_subagent_output():
648
+ return
649
+
619
650
  # Escape command to prevent Rich markup injection
620
651
  safe_command = escape_rich_markup(msg.command)
621
652
  # Header showing command is starting
@@ -700,6 +731,10 @@ class RichConsoleRenderer:
700
731
 
701
732
  def _render_subagent_invocation(self, msg: SubAgentInvocationMessage) -> None:
702
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
+
703
738
  # Header with agent name and session
704
739
  session_type = (
705
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()