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
@@ -0,0 +1,131 @@
1
+ """Monkey patches for pydantic-ai.
2
+
3
+ This module contains all monkey patches needed to customize pydantic-ai behavior.
4
+ These patches MUST be applied before any other pydantic-ai imports to work correctly.
5
+
6
+ Usage:
7
+ from code_puppy.pydantic_patches import apply_all_patches
8
+ apply_all_patches()
9
+ """
10
+
11
+ import importlib.metadata
12
+
13
+
14
+ def _get_code_puppy_version() -> str:
15
+ """Get the current code-puppy version."""
16
+ try:
17
+ return importlib.metadata.version("code-puppy")
18
+ except Exception:
19
+ return "0.0.0-dev"
20
+
21
+
22
+ def patch_user_agent() -> None:
23
+ """Patch pydantic-ai's User-Agent to use Code-Puppy's version.
24
+
25
+ pydantic-ai sets its own User-Agent ('pydantic-ai/x.x.x') via a @cache-decorated
26
+ function. We replace it with a dynamic function that returns:
27
+ - 'KimiCLI/0.63' for Kimi models
28
+ - 'Code-Puppy/{version}' for all other models
29
+
30
+ This MUST be called before any pydantic-ai models are created.
31
+ """
32
+ try:
33
+ import pydantic_ai.models as pydantic_models
34
+
35
+ version = _get_code_puppy_version()
36
+
37
+ # Clear cache if already called
38
+ if hasattr(pydantic_models.get_user_agent, "cache_clear"):
39
+ pydantic_models.get_user_agent.cache_clear()
40
+
41
+ def _get_dynamic_user_agent() -> str:
42
+ """Return User-Agent based on current model selection."""
43
+ try:
44
+ from code_puppy.config import get_global_model_name
45
+
46
+ model_name = get_global_model_name()
47
+ if model_name and "kimi" in model_name.lower():
48
+ return "KimiCLI/0.63"
49
+ except Exception:
50
+ pass
51
+ return f"Code-Puppy/{version}"
52
+
53
+ pydantic_models.get_user_agent = _get_dynamic_user_agent
54
+ except Exception:
55
+ pass # Don't crash on patch failure
56
+
57
+
58
+ def patch_message_history_cleaning() -> None:
59
+ """Disable overly strict message history cleaning in pydantic-ai."""
60
+ try:
61
+ from pydantic_ai import _agent_graph
62
+
63
+ _agent_graph._clean_message_history = lambda messages: messages
64
+ except Exception:
65
+ pass
66
+
67
+
68
+ def patch_process_message_history() -> None:
69
+ """Patch _process_message_history to skip strict ModelRequest validation.
70
+
71
+ Pydantic AI added a validation that history must end with ModelRequest,
72
+ but this breaks valid conversation flows. We patch it to skip that validation.
73
+ """
74
+ try:
75
+ from pydantic_ai import _agent_graph
76
+
77
+ async def _patched_process_message_history(messages, processors, run_context):
78
+ """Patched version that doesn't enforce ModelRequest at end."""
79
+ from pydantic_ai._agent_graph import (
80
+ _HistoryProcessorAsync,
81
+ _HistoryProcessorSync,
82
+ _HistoryProcessorSyncWithCtx,
83
+ cast,
84
+ exceptions,
85
+ is_async_callable,
86
+ is_takes_ctx,
87
+ run_in_executor,
88
+ )
89
+
90
+ for processor in processors:
91
+ takes_ctx = is_takes_ctx(processor)
92
+
93
+ if is_async_callable(processor):
94
+ if takes_ctx:
95
+ messages = await processor(run_context, messages)
96
+ else:
97
+ async_processor = cast(_HistoryProcessorAsync, processor)
98
+ messages = await async_processor(messages)
99
+ else:
100
+ if takes_ctx:
101
+ sync_processor_with_ctx = cast(
102
+ _HistoryProcessorSyncWithCtx, processor
103
+ )
104
+ messages = await run_in_executor(
105
+ sync_processor_with_ctx, run_context, messages
106
+ )
107
+ else:
108
+ sync_processor = cast(_HistoryProcessorSync, processor)
109
+ messages = await run_in_executor(sync_processor, messages)
110
+
111
+ if len(messages) == 0:
112
+ raise exceptions.UserError("Processed history cannot be empty.")
113
+
114
+ # NOTE: We intentionally skip the "must end with ModelRequest" validation
115
+ # that was added in newer Pydantic AI versions.
116
+
117
+ return messages
118
+
119
+ _agent_graph._process_message_history = _patched_process_message_history
120
+ except Exception:
121
+ pass
122
+
123
+
124
+ def apply_all_patches() -> None:
125
+ """Apply all pydantic-ai monkey patches.
126
+
127
+ Call this at the very top of main.py, before any other imports.
128
+ """
129
+ patch_user_agent()
130
+ patch_message_history_cleaning()
131
+ patch_process_message_history()
@@ -146,6 +146,7 @@ async def restore_autosave_interactively(base_dir: Path) -> None:
146
146
 
147
147
  # Import locally to avoid pulling the messaging layer into storage modules
148
148
  from datetime import datetime
149
+
149
150
  from prompt_toolkit.formatted_text import FormattedText
150
151
 
151
152
  from code_puppy.agents.agent_manager import get_current_agent
@@ -186,7 +187,7 @@ async def restore_autosave_interactively(base_dir: Path) -> None:
186
187
  start = page * PAGE_SIZE
187
188
  end = min(start + PAGE_SIZE, total)
188
189
  page_entries = entries[start:end]
189
- emit_system_message("[bold magenta]Autosave Sessions Available:[/bold magenta]")
190
+ emit_system_message("Autosave Sessions Available:")
190
191
  for idx, (name, timestamp, message_count) in enumerate(page_entries, start=1):
191
192
  timestamp_display = timestamp or "unknown time"
192
193
  message_display = (
@@ -7,6 +7,8 @@ from rich.panel import Panel
7
7
  from rich.spinner import Spinner
8
8
  from rich.text import Text
9
9
 
10
+ from code_puppy.messaging import emit_info
11
+
10
12
  # Global variable to track current token per second rate
11
13
  CURRENT_TOKEN_RATE = 0.0
12
14
 
@@ -185,7 +187,7 @@ class StatusDisplay:
185
187
  async def _update_display(self) -> None:
186
188
  """Update the display continuously while active using Rich Live display"""
187
189
  # Add a newline to ensure we're below the blue bar
188
- self.console.print("\n")
190
+ emit_info("")
189
191
 
190
192
  # Create a Live display that will update in-place
191
193
  with Live(
@@ -221,8 +223,8 @@ class StatusDisplay:
221
223
  # Print final stats
222
224
  elapsed = time.time() - self.start_time if self.start_time else 0
223
225
  avg_rate = self.token_count / elapsed if elapsed > 0 else 0
224
- self.console.print(
225
- f"[dim]Completed: {self.token_count} tokens in {elapsed:.1f}s ({avg_rate:.1f} t/s avg)[/dim]"
226
+ emit_info(
227
+ f"Completed: {self.token_count} tokens in {elapsed:.1f}s ({avg_rate:.1f} t/s avg)"
226
228
  )
227
229
 
228
230
  # Reset state
@@ -240,6 +242,6 @@ class StatusDisplay:
240
242
  # This is for testing purposes
241
243
  elapsed = time.time() - self.start_time if self.start_time else 0
242
244
  avg_rate = self.token_count / elapsed if elapsed > 0 else 0
243
- self.console.print(
244
- f"[dim]Completed: {self.token_count} tokens in {elapsed:.1f}s ({avg_rate:.1f} t/s avg)[/dim]"
245
+ emit_info(
246
+ f"Completed: {self.token_count} tokens in {elapsed:.1f}s ({avg_rate:.1f} t/s avg)"
245
247
  )
@@ -0,0 +1,126 @@
1
+ """Terminal utilities for cross-platform terminal state management.
2
+
3
+ Handles Windows console mode resets and Unix terminal sanity restoration.
4
+ """
5
+
6
+ import platform
7
+ import subprocess
8
+ import sys
9
+
10
+
11
+ def reset_windows_terminal_ansi() -> None:
12
+ """Reset ANSI formatting on Windows stdout/stderr.
13
+
14
+ This is a lightweight reset that just clears ANSI escape sequences.
15
+ Use this for quick resets after output operations.
16
+ """
17
+ if platform.system() != "Windows":
18
+ return
19
+
20
+ try:
21
+ sys.stdout.write("\x1b[0m") # Reset ANSI formatting
22
+ sys.stdout.flush()
23
+ sys.stderr.write("\x1b[0m")
24
+ sys.stderr.flush()
25
+ except Exception:
26
+ pass # Silently ignore errors - best effort reset
27
+
28
+
29
+ def reset_windows_console_mode() -> None:
30
+ """Full Windows console mode reset using ctypes.
31
+
32
+ This resets both stdout and stdin console modes to restore proper
33
+ terminal behavior after interrupts (Ctrl+C, Ctrl+D). Without this,
34
+ the terminal can become unresponsive (can't type characters).
35
+ """
36
+ if platform.system() != "Windows":
37
+ return
38
+
39
+ try:
40
+ import ctypes
41
+
42
+ kernel32 = ctypes.windll.kernel32
43
+
44
+ # Reset stdout
45
+ STD_OUTPUT_HANDLE = -11
46
+ handle = kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
47
+
48
+ # Enable virtual terminal processing and line input
49
+ mode = ctypes.c_ulong()
50
+ kernel32.GetConsoleMode(handle, ctypes.byref(mode))
51
+
52
+ # Console mode flags for stdout
53
+ ENABLE_PROCESSED_OUTPUT = 0x0001
54
+ ENABLE_WRAP_AT_EOL_OUTPUT = 0x0002
55
+ ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
56
+
57
+ new_mode = (
58
+ mode.value
59
+ | ENABLE_PROCESSED_OUTPUT
60
+ | ENABLE_WRAP_AT_EOL_OUTPUT
61
+ | ENABLE_VIRTUAL_TERMINAL_PROCESSING
62
+ )
63
+ kernel32.SetConsoleMode(handle, new_mode)
64
+
65
+ # Reset stdin
66
+ STD_INPUT_HANDLE = -10
67
+ stdin_handle = kernel32.GetStdHandle(STD_INPUT_HANDLE)
68
+
69
+ # Console mode flags for stdin
70
+ ENABLE_LINE_INPUT = 0x0002
71
+ ENABLE_ECHO_INPUT = 0x0004
72
+ ENABLE_PROCESSED_INPUT = 0x0001
73
+
74
+ stdin_mode = ctypes.c_ulong()
75
+ kernel32.GetConsoleMode(stdin_handle, ctypes.byref(stdin_mode))
76
+
77
+ new_stdin_mode = (
78
+ stdin_mode.value
79
+ | ENABLE_LINE_INPUT
80
+ | ENABLE_ECHO_INPUT
81
+ | ENABLE_PROCESSED_INPUT
82
+ )
83
+ kernel32.SetConsoleMode(stdin_handle, new_stdin_mode)
84
+
85
+ except Exception:
86
+ pass # Silently ignore errors - best effort reset
87
+
88
+
89
+ def reset_windows_terminal_full() -> None:
90
+ """Perform a full Windows terminal reset (ANSI + console mode).
91
+
92
+ Combines both ANSI reset and console mode reset for complete
93
+ terminal state restoration after interrupts.
94
+ """
95
+ if platform.system() != "Windows":
96
+ return
97
+
98
+ reset_windows_terminal_ansi()
99
+ reset_windows_console_mode()
100
+
101
+
102
+ def reset_unix_terminal() -> None:
103
+ """Reset Unix/Linux/macOS terminal to sane state.
104
+
105
+ Uses the `reset` command to restore terminal sanity.
106
+ Silently fails if the command isn't available.
107
+ """
108
+ if platform.system() == "Windows":
109
+ return
110
+
111
+ try:
112
+ subprocess.run(["reset"], check=True, capture_output=True)
113
+ except (subprocess.CalledProcessError, FileNotFoundError):
114
+ pass # Silently fail if reset command isn't available
115
+
116
+
117
+ def reset_terminal() -> None:
118
+ """Cross-platform terminal reset.
119
+
120
+ Automatically detects the platform and performs the appropriate
121
+ terminal reset operation.
122
+ """
123
+ if platform.system() == "Windows":
124
+ reset_windows_terminal_full()
125
+ else:
126
+ reset_unix_terminal()
@@ -1,5 +1,6 @@
1
1
  # agent_tools.py
2
2
  import asyncio
3
+ import hashlib
3
4
  import itertools
4
5
  import json
5
6
  import pickle
@@ -17,19 +18,22 @@ from pydantic_ai import Agent, RunContext, UsageLimits
17
18
  from pydantic_ai.messages import ModelMessage
18
19
 
19
20
  from code_puppy.config import (
21
+ DATA_DIR,
20
22
  get_message_limit,
21
23
  get_use_dbos,
22
24
  )
23
25
  from code_puppy.messaging import (
24
- emit_divider,
26
+ SubAgentInvocationMessage,
27
+ SubAgentResponseMessage,
25
28
  emit_error,
26
29
  emit_info,
27
- emit_system_message,
30
+ get_message_bus,
31
+ get_session_context,
32
+ set_session_context,
28
33
  )
29
34
  from code_puppy.model_factory import ModelFactory, make_model_settings
30
35
  from code_puppy.tools.common import generate_group_id
31
36
 
32
- _temp_agent_count = 0
33
37
  # Set to track active subagent invocation tasks
34
38
  _active_subagent_tasks: Set[asyncio.Task] = set()
35
39
 
@@ -55,6 +59,16 @@ def _generate_dbos_workflow_id(base_id: str) -> str:
55
59
  return f"{base_id}-wf-{counter}"
56
60
 
57
61
 
62
+ def _generate_session_hash_suffix() -> str:
63
+ """Generate a short SHA1 hash suffix based on current timestamp for uniqueness.
64
+
65
+ Returns:
66
+ A 6-character hex string, e.g., "a3f2b1"
67
+ """
68
+ timestamp = str(datetime.now().timestamp())
69
+ return hashlib.sha1(timestamp.encode()).hexdigest()[:6]
70
+
71
+
58
72
  # Regex pattern for kebab-case session IDs
59
73
  SESSION_ID_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
60
74
  SESSION_ID_MAX_LENGTH = 128
@@ -100,10 +114,10 @@ def _get_subagent_sessions_dir() -> Path:
100
114
  """Get the directory for storing subagent session data.
101
115
 
102
116
  Returns:
103
- Path to ~/.code_puppy/subagent_sessions/
117
+ Path to XDG data directory/subagent_sessions/
104
118
  """
105
- sessions_dir = Path.home() / ".code_puppy" / "subagent_sessions"
106
- sessions_dir.mkdir(parents=True, exist_ok=True)
119
+ sessions_dir = Path(DATA_DIR) / "subagent_sessions"
120
+ sessions_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
107
121
  return sessions_dir
108
122
 
109
123
 
@@ -194,6 +208,7 @@ class AgentInfo(BaseModel):
194
208
 
195
209
  name: str
196
210
  display_name: str
211
+ description: str
197
212
 
198
213
 
199
214
  class ListAgentsOutput(BaseModel):
@@ -208,6 +223,7 @@ class AgentInvokeOutput(BaseModel):
208
223
 
209
224
  response: str | None
210
225
  agent_name: str
226
+ session_id: str | None = None
211
227
  error: str | None = None
212
228
 
213
229
 
@@ -228,38 +244,50 @@ def register_list_agents(agent):
228
244
  # Generate a group ID for this tool execution
229
245
  group_id = generate_group_id("list_agents")
230
246
 
247
+ from rich.text import Text
248
+
249
+ from code_puppy.config import get_banner_color
250
+
251
+ list_agents_color = get_banner_color("list_agents")
231
252
  emit_info(
232
- "\n[bold white on blue] LIST AGENTS [/bold white on blue]",
253
+ Text.from_markup(
254
+ f"\n[bold white on {list_agents_color}] LIST AGENTS [/bold white on {list_agents_color}]"
255
+ ),
233
256
  message_group=group_id,
234
257
  )
235
- emit_divider(message_group=group_id)
236
258
 
237
259
  try:
238
- from code_puppy.agents import get_available_agents
260
+ from code_puppy.agents import get_agent_descriptions, get_available_agents
239
261
 
240
- # Get available agents from the agent manager
262
+ # Get available agents and their descriptions from the agent manager
241
263
  agents_dict = get_available_agents()
264
+ descriptions_dict = get_agent_descriptions()
242
265
 
243
266
  # Convert to list of AgentInfo objects
244
267
  agents = [
245
- AgentInfo(name=name, display_name=display_name)
268
+ AgentInfo(
269
+ name=name,
270
+ display_name=display_name,
271
+ description=descriptions_dict.get(name, "No description available"),
272
+ )
246
273
  for name, display_name in agents_dict.items()
247
274
  ]
248
275
 
249
- # Display the agents in the console
276
+ # Accumulate output into a single string and emit once
277
+ # Use Text.from_markup() to pass a Rich object that won't be escaped
278
+ lines = []
250
279
  for agent_item in agents:
251
- emit_system_message(
252
- f"- [bold]{agent_item.name}[/bold]: {agent_item.display_name}",
253
- message_group=group_id,
280
+ lines.append(
281
+ f"- [bold]{agent_item.name}[/bold]: {agent_item.display_name}\n"
282
+ f" [dim]{agent_item.description}[/dim]"
254
283
  )
284
+ emit_info(Text.from_markup("\n".join(lines)), message_group=group_id)
255
285
 
256
- emit_divider(message_group=group_id)
257
286
  return ListAgentsOutput(agents=agents)
258
287
 
259
288
  except Exception as e:
260
289
  error_msg = f"Error listing agents: {str(e)}"
261
290
  emit_error(error_msg, message_group=group_id)
262
- emit_divider(message_group=group_id)
263
291
  return ListAgentsOutput(agents=[], error=error_msg)
264
292
 
265
293
  return list_agents
@@ -285,21 +313,25 @@ def register_invoke_agent(agent):
285
313
 
286
314
  **Session ID Format:**
287
315
  - Must be kebab-case (lowercase letters, numbers, hyphens only)
288
- - Should be human-readable with random suffix: e.g., "implement-oauth-abc123", "review-auth-x7k9"
289
- - Add 3-6 random characters/numbers at the end to prevent namespace collisions
316
+ - Should be human-readable: e.g., "implement-oauth", "review-auth"
317
+ - For NEW sessions, a SHA1 hash suffix is automatically appended for uniqueness
318
+ - To CONTINUE a session, use the full session_id (with hash) from the previous invocation
290
319
  - If None (default), auto-generates like "agent-name-session-1"
291
320
 
292
321
  **When to use session_id:**
293
- - **REUSE** the same session_id ONLY when you need the sub-agent to remember
294
- previous conversation context (e.g., multi-turn discussions, iterative reviews)
295
- - **DO NOT REUSE** for independent, one-off tasks - let it auto-generate or use
296
- unique IDs for each invocation
322
+ - **NEW SESSION**: Provide a base name like "review-auth" - we'll append a unique hash
323
+ - **CONTINUE SESSION**: Use the full session_id from output (e.g., "review-auth-a3f2b1")
324
+ - **ONE-OFF TASKS**: Leave as None (auto-generate)
297
325
 
298
326
  **Most common pattern:** Leave session_id as None (auto-generate) unless you
299
327
  specifically need conversational memory.
300
328
 
301
329
  Returns:
302
- AgentInvokeOutput: The agent's response to the prompt
330
+ AgentInvokeOutput: Contains:
331
+ - response (str | None): The agent's response to the prompt
332
+ - agent_name (str): Name of the invoked agent
333
+ - session_id (str | None): The full session ID (with hash suffix) - USE THIS to continue the conversation!
334
+ - error (str | None): Error message if invocation failed
303
335
 
304
336
  Examples:
305
337
  # COMMON CASE: One-off invocation, no memory needed (auto-generate session)
@@ -307,46 +339,43 @@ def register_invoke_agent(agent):
307
339
  "qa-expert",
308
340
  "Review this function: def add(a, b): return a + b"
309
341
  )
342
+ # result.session_id will be something like "qa-expert-session-a3f2b1"
310
343
 
311
- # MULTI-TURN: Start a conversation with explicit session ID (note random suffix)
344
+ # MULTI-TURN: Start a NEW conversation with a base session ID
345
+ # A hash suffix is auto-appended: "review-add-function" -> "review-add-function-a3f2b1"
312
346
  result1 = invoke_agent(
313
347
  "qa-expert",
314
348
  "Review this function: def add(a, b): return a + b",
315
- session_id="review-add-function-x7k9" # Random suffix prevents collisions
349
+ session_id="review-add-function"
316
350
  )
351
+ # result1.session_id contains the full ID like "review-add-function-a3f2b1"
317
352
 
318
- # Continue the SAME conversation (reuse session_id to maintain memory)
353
+ # Continue the SAME conversation using session_id from the previous result
319
354
  result2 = invoke_agent(
320
355
  "qa-expert",
321
356
  "Can you suggest edge cases for that function?",
322
- session_id="review-add-function-x7k9" # SAME session_id = conversation memory
357
+ session_id=result1.session_id # Use the session_id from previous output!
323
358
  )
324
359
 
325
- # Multiple INDEPENDENT reviews (unique session IDs with random suffixes)
360
+ # Multiple INDEPENDENT reviews (each gets unique hash suffix)
326
361
  auth_review = invoke_agent(
327
362
  "code-reviewer",
328
363
  "Review my authentication code",
329
- session_id="auth-review-abc123" # Random suffix for uniqueness
364
+ session_id="auth-review" # -> "auth-review-<hash1>"
330
365
  )
366
+ # auth_review.session_id contains the full ID to continue this review
331
367
 
332
368
  payment_review = invoke_agent(
333
369
  "code-reviewer",
334
370
  "Review my payment processing code",
335
- session_id="payment-review-def456" # Different session = no shared context
371
+ session_id="payment-review" # -> "payment-review-<hash2>"
336
372
  )
373
+ # payment_review.session_id contains a different full ID
337
374
  """
338
- global _temp_agent_count
339
-
340
375
  from code_puppy.agents.agent_manager import load_agent
341
376
 
342
- # Generate or use provided session_id (kebab-case format)
343
- if session_id is None:
344
- # Create a new session ID in kebab-case format
345
- # Example: "qa-expert-session-1", "code-reviewer-session-2"
346
- _temp_agent_count += 1
347
- session_id = f"{agent_name}-session-{_temp_agent_count}"
348
- else:
349
- # Validate user-provided session_id
377
+ # Validate user-provided session_id if given
378
+ if session_id is not None:
350
379
  try:
351
380
  _validate_session_id(session_id)
352
381
  except ValueError as e:
@@ -360,28 +389,44 @@ def register_invoke_agent(agent):
360
389
  # Generate a group ID for this tool execution
361
390
  group_id = generate_group_id("invoke_agent", agent_name)
362
391
 
363
- emit_info(
364
- f"\n[bold white on blue] INVOKE AGENT [/bold white on blue] {agent_name} (session: {session_id})",
365
- message_group=group_id,
366
- )
367
- emit_divider(message_group=group_id)
368
- emit_system_message(f"Prompt: {prompt}", message_group=group_id)
369
-
370
- # Retrieve existing message history from filesystem for this session, if any
371
- message_history = _load_session_history(session_id)
372
- is_new_session = len(message_history) == 0
373
-
374
- if message_history:
375
- emit_system_message(
376
- f"Continuing conversation from session {session_id} ({len(message_history)} messages)",
377
- message_group=group_id,
378
- )
392
+ # Check if this is an existing session or a new one
393
+ # For user-provided session_id, check if it exists
394
+ # For None, we'll generate a new one below
395
+ if session_id is not None:
396
+ message_history = _load_session_history(session_id)
397
+ is_new_session = len(message_history) == 0
379
398
  else:
380
- emit_system_message(
381
- f"Starting new session {session_id}",
382
- message_group=group_id,
399
+ message_history = []
400
+ is_new_session = True
401
+
402
+ # Generate or finalize session_id
403
+ if session_id is None:
404
+ # Auto-generate a session ID with hash suffix for uniqueness
405
+ # Example: "qa-expert-session-a3f2b1"
406
+ hash_suffix = _generate_session_hash_suffix()
407
+ session_id = f"{agent_name}-session-{hash_suffix}"
408
+ elif is_new_session:
409
+ # User provided a base name for a NEW session - append hash suffix
410
+ # Example: "review-auth" -> "review-auth-a3f2b1"
411
+ hash_suffix = _generate_session_hash_suffix()
412
+ session_id = f"{session_id}-{hash_suffix}"
413
+ # else: continuing existing session, use session_id as-is
414
+
415
+ # Emit structured invocation message via MessageBus
416
+ bus = get_message_bus()
417
+ bus.emit(
418
+ SubAgentInvocationMessage(
419
+ agent_name=agent_name,
420
+ session_id=session_id,
421
+ prompt=prompt,
422
+ is_new_session=is_new_session,
423
+ message_count=len(message_history),
383
424
  )
384
- emit_divider(message_group=group_id)
425
+ )
426
+
427
+ # Save current session context and set the new one for this sub-agent
428
+ previous_session_id = get_session_context()
429
+ set_session_context(session_id)
385
430
 
386
431
  try:
387
432
  # Load the specified agent config
@@ -400,6 +445,11 @@ def register_invoke_agent(agent):
400
445
  # Create a temporary agent instance to avoid interfering with current agent state
401
446
  instructions = agent_config.get_system_prompt()
402
447
 
448
+ # Add AGENTS.md content to subagents
449
+ puppy_rules = agent_config.load_puppy_rules()
450
+ if puppy_rules:
451
+ instructions += f"\n\n{puppy_rules}"
452
+
403
453
  # Apply prompt additions (like file permission handling) to temporary agents
404
454
  from code_puppy import callbacks
405
455
  from code_puppy.model_utils import prepare_prompt_for_model
@@ -418,7 +468,7 @@ def register_invoke_agent(agent):
418
468
  instructions = prepared.instructions
419
469
  prompt = prepared.user_prompt
420
470
 
421
- subagent_name = f"temp-invoke-agent-{_temp_agent_count}"
471
+ subagent_name = f"temp-invoke-agent-{session_id}"
422
472
  model_settings = make_model_settings(model_name)
423
473
 
424
474
  temp_agent = Agent(
@@ -490,21 +540,32 @@ def register_invoke_agent(agent):
490
540
  initial_prompt=prompt if is_new_session else None,
491
541
  )
492
542
 
493
- emit_system_message(f"Response: {response}", message_group=group_id)
494
- emit_system_message(
495
- f"Session {session_id} saved to disk ({len(updated_history)} messages)",
496
- message_group=group_id,
543
+ # Emit structured response message via MessageBus
544
+ bus.emit(
545
+ SubAgentResponseMessage(
546
+ agent_name=agent_name,
547
+ session_id=session_id,
548
+ response=response,
549
+ message_count=len(updated_history),
550
+ )
497
551
  )
498
- emit_divider(message_group=group_id)
499
552
 
500
- return AgentInvokeOutput(response=response, agent_name=agent_name)
553
+ return AgentInvokeOutput(
554
+ response=response, agent_name=agent_name, session_id=session_id
555
+ )
501
556
 
502
557
  except Exception:
503
558
  error_msg = f"Error invoking agent '{agent_name}': {traceback.format_exc()}"
504
559
  emit_error(error_msg, message_group=group_id)
505
- emit_divider(message_group=group_id)
506
560
  return AgentInvokeOutput(
507
- response=None, agent_name=agent_name, error=error_msg
561
+ response=None,
562
+ agent_name=agent_name,
563
+ session_id=session_id,
564
+ error=error_msg,
508
565
  )
509
566
 
567
+ finally:
568
+ # Restore the previous session context
569
+ set_session_context(previous_session_id)
570
+
510
571
  return invoke_agent