code-puppy 0.0.302__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 (65) hide show
  1. code_puppy/agents/base_agent.py +373 -46
  2. code_puppy/chatgpt_codex_client.py +283 -0
  3. code_puppy/cli_runner.py +795 -0
  4. code_puppy/command_line/add_model_menu.py +8 -1
  5. code_puppy/command_line/autosave_menu.py +266 -35
  6. code_puppy/command_line/colors_menu.py +515 -0
  7. code_puppy/command_line/command_handler.py +8 -2
  8. code_puppy/command_line/config_commands.py +59 -10
  9. code_puppy/command_line/core_commands.py +19 -7
  10. code_puppy/command_line/mcp/edit_command.py +3 -1
  11. code_puppy/command_line/mcp/handler.py +7 -2
  12. code_puppy/command_line/mcp/install_command.py +8 -3
  13. code_puppy/command_line/mcp/logs_command.py +173 -64
  14. code_puppy/command_line/mcp/restart_command.py +7 -2
  15. code_puppy/command_line/mcp/search_command.py +10 -4
  16. code_puppy/command_line/mcp/start_all_command.py +16 -6
  17. code_puppy/command_line/mcp/start_command.py +3 -1
  18. code_puppy/command_line/mcp/status_command.py +2 -1
  19. code_puppy/command_line/mcp/stop_all_command.py +5 -1
  20. code_puppy/command_line/mcp/stop_command.py +3 -1
  21. code_puppy/command_line/mcp/wizard_utils.py +10 -4
  22. code_puppy/command_line/model_settings_menu.py +53 -7
  23. code_puppy/command_line/prompt_toolkit_completion.py +16 -2
  24. code_puppy/command_line/session_commands.py +11 -4
  25. code_puppy/config.py +103 -15
  26. code_puppy/keymap.py +8 -2
  27. code_puppy/main.py +5 -828
  28. code_puppy/mcp_/__init__.py +17 -0
  29. code_puppy/mcp_/blocking_startup.py +61 -32
  30. code_puppy/mcp_/config_wizard.py +5 -1
  31. code_puppy/mcp_/managed_server.py +23 -3
  32. code_puppy/mcp_/manager.py +65 -0
  33. code_puppy/mcp_/mcp_logs.py +224 -0
  34. code_puppy/messaging/__init__.py +20 -4
  35. code_puppy/messaging/bus.py +64 -0
  36. code_puppy/messaging/markdown_patches.py +57 -0
  37. code_puppy/messaging/messages.py +16 -0
  38. code_puppy/messaging/renderers.py +21 -9
  39. code_puppy/messaging/rich_renderer.py +113 -67
  40. code_puppy/messaging/spinner/console_spinner.py +34 -0
  41. code_puppy/model_factory.py +185 -30
  42. code_puppy/model_utils.py +57 -48
  43. code_puppy/models.json +19 -5
  44. code_puppy/plugins/chatgpt_oauth/config.py +5 -1
  45. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
  46. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +3 -3
  47. code_puppy/plugins/chatgpt_oauth/test_plugin.py +26 -11
  48. code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
  49. code_puppy/plugins/claude_code_oauth/register_callbacks.py +28 -0
  50. code_puppy/plugins/claude_code_oauth/utils.py +1 -0
  51. code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -118
  52. code_puppy/plugins/shell_safety/register_callbacks.py +44 -3
  53. code_puppy/prompts/codex_system_prompt.md +310 -0
  54. code_puppy/pydantic_patches.py +131 -0
  55. code_puppy/terminal_utils.py +126 -0
  56. code_puppy/tools/agent_tools.py +34 -9
  57. code_puppy/tools/command_runner.py +361 -32
  58. code_puppy/tools/file_operations.py +33 -45
  59. {code_puppy-0.0.302.data → code_puppy-0.0.323.data}/data/code_puppy/models.json +19 -5
  60. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/METADATA +1 -1
  61. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/RECORD +65 -57
  62. {code_puppy-0.0.302.data → code_puppy-0.0.323.data}/data/code_puppy/models_dev_api.json +0 -0
  63. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/WHEEL +0 -0
  64. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/entry_points.txt +0 -0
  65. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/licenses/LICENSE +0 -0
@@ -17,6 +17,15 @@ from .error_isolation import (
17
17
  )
18
18
  from .managed_server import ManagedMCPServer, ServerConfig, ServerState
19
19
  from .manager import MCPManager, ServerInfo, get_mcp_manager
20
+ from .mcp_logs import (
21
+ clear_logs,
22
+ get_log_file_path,
23
+ get_log_stats,
24
+ get_mcp_logs_dir,
25
+ list_servers_with_logs,
26
+ read_logs,
27
+ write_log,
28
+ )
20
29
  from .registry import ServerRegistry
21
30
  from .retry_manager import RetryManager, RetryStats, get_retry_manager, retry_mcp_call
22
31
  from .status_tracker import Event, ServerStatusTracker
@@ -46,4 +55,12 @@ __all__ = [
46
55
  "MCPDashboard",
47
56
  "MCPConfigWizard",
48
57
  "run_add_wizard",
58
+ # Log management
59
+ "get_mcp_logs_dir",
60
+ "get_log_file_path",
61
+ "read_logs",
62
+ "write_log",
63
+ "clear_logs",
64
+ "list_servers_with_logs",
65
+ "get_log_stats",
49
66
  ]
@@ -2,14 +2,13 @@
2
2
  MCP Server with blocking startup capability and stderr capture.
3
3
 
4
4
  This module provides MCP servers that:
5
- 1. Capture stderr output from stdio servers
5
+ 1. Capture stderr output from stdio servers to persistent log files
6
6
  2. Block until fully initialized before allowing operations
7
- 3. Emit stderr to users via emit_info with message groups
7
+ 3. Optionally emit stderr to users (disabled by default to reduce console noise)
8
8
  """
9
9
 
10
10
  import asyncio
11
11
  import os
12
- import tempfile
13
12
  import threading
14
13
  import uuid
15
14
  from contextlib import asynccontextmanager
@@ -18,56 +17,73 @@ from typing import List, Optional
18
17
  from mcp.client.stdio import StdioServerParameters, stdio_client
19
18
  from pydantic_ai.mcp import MCPServerStdio
20
19
 
20
+ from code_puppy.mcp_.mcp_logs import get_log_file_path, rotate_log_if_needed, write_log
21
21
  from code_puppy.messaging import emit_info
22
22
 
23
23
 
24
24
  class StderrFileCapture:
25
- """Captures stderr to a file and monitors it in a background thread."""
25
+ """
26
+ Captures stderr to a persistent log file and optionally monitors it.
27
+
28
+ Logs are written to ~/.code_puppy/mcp_logs/<server_name>.log
29
+ """
26
30
 
27
31
  def __init__(
28
32
  self,
29
33
  server_name: str,
30
- emit_to_user: bool = True,
34
+ emit_to_user: bool = False, # Disabled by default to reduce console noise
31
35
  message_group: Optional[uuid.UUID] = None,
32
36
  ):
33
37
  self.server_name = server_name
34
38
  self.emit_to_user = emit_to_user
35
39
  self.message_group = message_group or uuid.uuid4()
36
- self.temp_file = None
37
- self.temp_path = None
40
+ self.log_file = None
41
+ self.log_path = None
38
42
  self.monitor_thread = None
39
43
  self.stop_monitoring = threading.Event()
40
44
  self.captured_lines = []
45
+ self._last_read_pos = 0
41
46
 
42
47
  def start(self):
43
- """Start capture by creating temp file and monitor thread."""
44
- # Create temp file
45
- self.temp_file = tempfile.NamedTemporaryFile(
46
- mode="w+", delete=False, suffix=".err"
47
- )
48
- self.temp_path = self.temp_file.name
48
+ """Start capture by opening persistent log file and monitor thread."""
49
+ # Rotate log if needed
50
+ rotate_log_if_needed(self.server_name)
51
+
52
+ # Get persistent log path
53
+ self.log_path = get_log_file_path(self.server_name)
54
+
55
+ # Write startup marker
56
+ write_log(self.server_name, "--- Server starting ---", "INFO")
57
+
58
+ # Open log file for appending stderr
59
+ self.log_file = open(self.log_path, "a", encoding="utf-8")
49
60
 
50
- # Start monitoring thread
61
+ # Start monitoring thread only if we need to emit to user or capture lines
51
62
  self.stop_monitoring.clear()
52
63
  self.monitor_thread = threading.Thread(target=self._monitor_file)
53
64
  self.monitor_thread.daemon = True
54
65
  self.monitor_thread.start()
55
66
 
56
- return self.temp_file
67
+ return self.log_file
57
68
 
58
69
  def _monitor_file(self):
59
- """Monitor the temp file for new content."""
60
- if not self.temp_path:
70
+ """Monitor the log file for new content."""
71
+ if not self.log_path:
61
72
  return
62
73
 
63
- last_pos = 0
74
+ # Start reading from current position (end of file before we started)
75
+ try:
76
+ self._last_read_pos = os.path.getsize(self.log_path)
77
+ except OSError:
78
+ self._last_read_pos = 0
79
+
64
80
  while not self.stop_monitoring.is_set():
65
81
  try:
66
- with open(self.temp_path, "r") as f:
67
- f.seek(last_pos)
82
+ with open(self.log_path, "r", encoding="utf-8", errors="replace") as f:
83
+ f.seek(self._last_read_pos)
68
84
  new_content = f.read()
69
85
  if new_content:
70
- last_pos = f.tell()
86
+ self._last_read_pos = f.tell()
71
87
  # Process new lines
72
88
  for line in new_content.splitlines():
73
89
  if line.strip():
@@ -89,16 +105,21 @@ class StderrFileCapture:
89
105
  if self.monitor_thread:
90
106
  self.monitor_thread.join(timeout=1)
91
107
 
92
- if self.temp_file:
108
+ if self.log_file:
93
109
  try:
94
- self.temp_file.close()
110
+ self.log_file.flush()
111
+ self.log_file.close()
95
112
  except Exception:
96
113
  pass
97
114
 
98
- if self.temp_path and os.path.exists(self.temp_path):
115
+ # Write shutdown marker
116
+ write_log(self.server_name, "--- Server stopped ---", "INFO")
117
+
118
+ # Read any remaining content for in-memory capture
119
+ if self.log_path and os.path.exists(self.log_path):
99
120
  try:
100
- # Read any remaining content
101
- with open(self.temp_path, "r") as f:
121
+ with open(self.log_path, "r", encoding="utf-8", errors="replace") as f:
122
+ f.seek(self._last_read_pos)
102
123
  content = f.read()
103
124
  for line in content.splitlines():
104
125
  if line.strip() and line not in self.captured_lines:
@@ -108,13 +129,13 @@ class StderrFileCapture:
108
129
  f"MCP {self.server_name}: {line}",
109
130
  message_group=self.message_group,
110
131
  )
111
-
112
- os.unlink(self.temp_path)
113
132
  except Exception:
114
133
  pass
115
134
 
135
+ # Note: We do NOT delete the log file - it's persistent now!
136
+
116
137
  def get_captured_lines(self) -> List[str]:
117
- """Get all captured lines."""
138
+ """Get all captured lines from this session."""
118
139
  return self.captured_lines.copy()
119
140
 
120
141
 
@@ -201,15 +222,23 @@ class BlockingMCPServerStdio(SimpleCapturedMCPServerStdio):
201
222
 
202
223
  return result
203
224
 
204
- except Exception as e:
225
+ except BaseException as e:
205
226
  # Store error and mark as initialized (with error)
206
- self._init_error = e
227
+ # Unwrap ExceptionGroup if present (Python 3.11+)
228
+ if type(e).__name__ == "ExceptionGroup" and hasattr(e, "exceptions"):
229
+ # Use the first exception as the primary cause
230
+ self._init_error = e.exceptions[0]
231
+ error_details = f"{e.exceptions[0]}"
232
+ else:
233
+ self._init_error = e
234
+ error_details = str(e)
235
+
207
236
  self._initialized.set()
208
237
 
209
238
  # Emit error message
210
239
  server_name = getattr(self, "tool_prefix", self.command)
211
240
  emit_info(
212
- f"❌ MCP Server '{server_name}' failed to initialize: {e}",
241
+ f"❌ MCP Server '{server_name}' failed to initialize: {error_details}",
213
242
  style="red",
214
243
  message_group=self.message_group,
215
244
  )
@@ -9,6 +9,8 @@ import re
9
9
  from typing import Dict, Optional
10
10
  from urllib.parse import urlparse
11
11
 
12
+ from rich.text import Text
13
+
12
14
  from code_puppy.mcp_.manager import ServerConfig, get_mcp_manager
13
15
  from code_puppy.messaging import (
14
16
  emit_error,
@@ -487,7 +489,9 @@ def run_add_wizard(group_id: str = None) -> bool:
487
489
  json.dump(data, f, indent=2)
488
490
 
489
491
  emit_info(
490
- f"[dim]Configuration saved to {MCP_SERVERS_FILE}[/dim]",
492
+ Text.from_markup(
493
+ f"[dim]Configuration saved to {MCP_SERVERS_FILE}[/dim]"
494
+ ),
491
495
  message_group=group_id,
492
496
  )
493
497
  return True
@@ -6,6 +6,7 @@ that adds management capabilities while maintaining 100% compatibility.
6
6
  """
7
7
 
8
8
  import json
9
+ import os
9
10
  import uuid
10
11
  from dataclasses import dataclass, field
11
12
  from datetime import datetime, timedelta
@@ -203,7 +204,7 @@ class ManagedMCPServer:
203
204
  **stdio_kwargs,
204
205
  process_tool_call=process_tool_call,
205
206
  tool_prefix=self.config.name,
206
- emit_stderr=True, # Always emit stderr for now
207
+ emit_stderr=False, # Logs go to file, not console (use /mcp logs to view)
207
208
  message_group=message_group,
208
209
  )
209
210
 
@@ -222,7 +223,16 @@ class ManagedMCPServer:
222
223
  if "read_timeout" in config:
223
224
  http_kwargs["read_timeout"] = config["read_timeout"]
224
225
  if "headers" in config:
225
- http_kwargs["headers"] = config.get("headers")
226
+ # Expand environment variables in headers
227
+ headers = config.get("headers")
228
+ resolved_headers = {}
229
+ if isinstance(headers, dict):
230
+ for k, v in headers.items():
231
+ if isinstance(v, str):
232
+ resolved_headers[k] = os.path.expandvars(v)
233
+ else:
234
+ resolved_headers[k] = v
235
+ http_kwargs["headers"] = resolved_headers
226
236
  # Create HTTP client if headers are provided but no client specified
227
237
 
228
238
  self._pydantic_server = MCPServerStreamableHTTP(
@@ -243,8 +253,18 @@ class ManagedMCPServer:
243
253
  Configured async HTTP client with custom headers
244
254
  """
245
255
  headers = self.config.config.get("headers", {})
256
+
257
+ # Expand environment variables in headers
258
+ resolved_headers = {}
259
+ if isinstance(headers, dict):
260
+ for k, v in headers.items():
261
+ if isinstance(v, str):
262
+ resolved_headers[k] = os.path.expandvars(v)
263
+ else:
264
+ resolved_headers[k] = v
265
+
246
266
  timeout = self.config.config.get("timeout", 30)
247
- client = create_async_client(headers=headers, timeout=timeout)
267
+ client = create_async_client(headers=resolved_headers, timeout=timeout)
248
268
  return client
249
269
 
250
270
  def enable(self) -> None:
@@ -78,11 +78,76 @@ class MCPManager:
78
78
  # Active managed servers (server_id -> ManagedMCPServer)
79
79
  self._managed_servers: Dict[str, ManagedMCPServer] = {}
80
80
 
81
+ # Sync servers from mcp_servers.json into registry
82
+ self.sync_from_config()
83
+
81
84
  # Load existing servers from registry
82
85
  self._initialize_servers()
83
86
 
84
87
  logger.info("MCPManager initialized with core components")
85
88
 
89
+ def sync_from_config(self) -> None:
90
+ """Sync servers from mcp_servers.json into the registry.
91
+
92
+ This public method ensures that servers defined in the user's
93
+ configuration file are automatically registered with the manager.
94
+ It can be called during initialization or manually to reload
95
+ server configurations.
96
+
97
+ This is the single source of truth for syncing mcp_servers.json
98
+ into the registry, avoiding duplication with base_agent.py.
99
+ """
100
+ try:
101
+ from code_puppy.config import load_mcp_server_configs
102
+
103
+ configs = load_mcp_server_configs()
104
+ if not configs:
105
+ logger.debug("No servers found in mcp_servers.json")
106
+ return
107
+
108
+ synced_count = 0
109
+ updated_count = 0
110
+
111
+ for name, conf in configs.items():
112
+ try:
113
+ # Create ServerConfig from the loaded configuration
114
+ server_config = ServerConfig(
115
+ id=conf.get("id", ""), # Empty ID will be auto-generated
116
+ name=name,
117
+ type=conf.get("type", "sse"),
118
+ enabled=conf.get("enabled", True),
119
+ config=conf,
120
+ )
121
+
122
+ # Check if server already exists by name
123
+ existing = self.registry.get_by_name(name)
124
+
125
+ if not existing:
126
+ # Register new server
127
+ self.registry.register(server_config)
128
+ synced_count += 1
129
+ logger.debug(f"Synced new server from config: {name}")
130
+ else:
131
+ # Update existing server if config has changed
132
+ if existing.config != server_config.config:
133
+ server_config.id = existing.id # Keep existing ID
134
+ self.registry.update(existing.id, server_config)
135
+ updated_count += 1
136
+ logger.debug(f"Updated server from config: {name}")
137
+
138
+ except Exception as e:
139
+ logger.warning(f"Failed to sync server '{name}' from config: {e}")
140
+ continue
141
+
142
+ if synced_count > 0 or updated_count > 0:
143
+ logger.info(
144
+ f"Synced {synced_count} new and updated {updated_count} servers from mcp_servers.json"
145
+ )
146
+
147
+ except Exception as e:
148
+ logger.error(f"Failed to sync from mcp_servers.json: {e}")
149
+ # Don't fail initialization if sync fails
150
+
86
151
  def _initialize_servers(self) -> None:
87
152
  """Initialize managed servers from registry configurations."""
88
153
  configs = self.registry.list_all()
@@ -0,0 +1,224 @@
1
+ """
2
+ MCP Server Log Management.
3
+
4
+ This module provides persistent log file management for MCP servers.
5
+ Logs are stored in STATE_DIR/mcp_logs/<server_name>.log
6
+ """
7
+
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import List, Optional
11
+
12
+ from code_puppy.config import STATE_DIR
13
+
14
+ # Maximum log file size in bytes (5MB)
15
+ MAX_LOG_SIZE = 5 * 1024 * 1024
16
+
17
+ # Number of rotated logs to keep
18
+ MAX_ROTATED_LOGS = 3
19
+
20
+
21
+ def get_mcp_logs_dir() -> Path:
22
+ """
23
+ Get the directory for MCP server logs.
24
+
25
+ Creates the directory if it doesn't exist.
26
+
27
+ Returns:
28
+ Path to the MCP logs directory
29
+ """
30
+ logs_dir = Path(STATE_DIR) / "mcp_logs"
31
+ logs_dir.mkdir(parents=True, exist_ok=True)
32
+ return logs_dir
33
+
34
+
35
+ def get_log_file_path(server_name: str) -> Path:
36
+ """
37
+ Get the log file path for a specific server.
38
+
39
+ Args:
40
+ server_name: Name of the MCP server
41
+
42
+ Returns:
43
+ Path to the server's log file
44
+ """
45
+ # Sanitize server name for filesystem
46
+ safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in server_name)
47
+ return get_mcp_logs_dir() / f"{safe_name}.log"
48
+
49
+
50
+ def rotate_log_if_needed(server_name: str) -> None:
51
+ """
52
+ Rotate log file if it exceeds MAX_LOG_SIZE.
53
+
54
+ Args:
55
+ server_name: Name of the MCP server
56
+ """
57
+ log_path = get_log_file_path(server_name)
58
+
59
+ if not log_path.exists():
60
+ return
61
+
62
+ # Check if rotation is needed
63
+ if log_path.stat().st_size < MAX_LOG_SIZE:
64
+ return
65
+
66
+ logs_dir = get_mcp_logs_dir()
67
+ safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in server_name)
68
+
69
+ # Remove oldest rotated log if we're at the limit
70
+ oldest = logs_dir / f"{safe_name}.log.{MAX_ROTATED_LOGS}"
71
+ if oldest.exists():
72
+ oldest.unlink()
73
+
74
+ # Shift existing rotated logs
75
+ for i in range(MAX_ROTATED_LOGS - 1, 0, -1):
76
+ old_path = logs_dir / f"{safe_name}.log.{i}"
77
+ new_path = logs_dir / f"{safe_name}.log.{i + 1}"
78
+ if old_path.exists():
79
+ old_path.rename(new_path)
80
+
81
+ # Rotate current log
82
+ rotated_path = logs_dir / f"{safe_name}.log.1"
83
+ log_path.rename(rotated_path)
84
+
85
+
86
+ def write_log(server_name: str, message: str, level: str = "INFO") -> None:
87
+ """
88
+ Write a log message for a server.
89
+
90
+ Args:
91
+ server_name: Name of the MCP server
92
+ message: Log message to write
93
+ level: Log level (INFO, ERROR, WARN, DEBUG)
94
+ """
95
+ rotate_log_if_needed(server_name)
96
+
97
+ log_path = get_log_file_path(server_name)
98
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
99
+
100
+ with open(log_path, "a", encoding="utf-8") as f:
101
+ f.write(f"[{timestamp}] [{level}] {message}\n")
102
+
103
+
104
+ def read_logs(
105
+ server_name: str, lines: Optional[int] = None, include_rotated: bool = False
106
+ ) -> List[str]:
107
+ """
108
+ Read log lines for a server.
109
+
110
+ Args:
111
+ server_name: Name of the MCP server
112
+ lines: Number of lines to return (from end). None means all lines.
113
+ include_rotated: Whether to include rotated log files
114
+
115
+ Returns:
116
+ List of log lines (most recent last)
117
+ """
118
+ all_lines = []
119
+
120
+ # Read rotated logs first (oldest to newest)
121
+ if include_rotated:
122
+ logs_dir = get_mcp_logs_dir()
123
+ safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in server_name)
124
+
125
+ for i in range(MAX_ROTATED_LOGS, 0, -1):
126
+ rotated_path = logs_dir / f"{safe_name}.log.{i}"
127
+ if rotated_path.exists():
128
+ with open(rotated_path, "r", encoding="utf-8", errors="replace") as f:
129
+ all_lines.extend(f.read().splitlines())
130
+
131
+ # Read current log
132
+ log_path = get_log_file_path(server_name)
133
+ if log_path.exists():
134
+ with open(log_path, "r", encoding="utf-8", errors="replace") as f:
135
+ all_lines.extend(f.read().splitlines())
136
+
137
+ # Return requested number of lines
138
+ if lines is not None and lines > 0:
139
+ return all_lines[-lines:]
140
+
141
+ return all_lines
142
+
143
+
144
+ def clear_logs(server_name: str, include_rotated: bool = True) -> None:
145
+ """
146
+ Clear logs for a server.
147
+
148
+ Args:
149
+ server_name: Name of the MCP server
150
+ include_rotated: Whether to also clear rotated log files
151
+ """
152
+ log_path = get_log_file_path(server_name)
153
+
154
+ if log_path.exists():
155
+ log_path.unlink()
156
+
157
+ if include_rotated:
158
+ logs_dir = get_mcp_logs_dir()
159
+ safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in server_name)
160
+
161
+ for i in range(1, MAX_ROTATED_LOGS + 1):
162
+ rotated_path = logs_dir / f"{safe_name}.log.{i}"
163
+ if rotated_path.exists():
164
+ rotated_path.unlink()
165
+
166
+
167
+ def list_servers_with_logs() -> List[str]:
168
+ """
169
+ List all servers that have log files.
170
+
171
+ Returns:
172
+ List of server names with log files
173
+ """
174
+ logs_dir = get_mcp_logs_dir()
175
+ servers = set()
176
+
177
+ for path in logs_dir.glob("*.log*"):
178
+ # Extract server name from filename
179
+ name = path.stem
180
+ # Remove .log suffix and rotation numbers
181
+ name = name.replace(".log", "").rstrip(".0123456789")
182
+ if name:
183
+ servers.add(name)
184
+
185
+ return sorted(servers)
186
+
187
+
188
+ def get_log_stats(server_name: str) -> dict:
189
+ """
190
+ Get statistics about a server's logs.
191
+
192
+ Args:
193
+ server_name: Name of the MCP server
194
+
195
+ Returns:
196
+ Dictionary with log statistics
197
+ """
198
+ log_path = get_log_file_path(server_name)
199
+
200
+ stats = {
201
+ "exists": log_path.exists(),
202
+ "size_bytes": 0,
203
+ "line_count": 0,
204
+ "rotated_count": 0,
205
+ "total_size_bytes": 0,
206
+ }
207
+
208
+ if log_path.exists():
209
+ stats["size_bytes"] = log_path.stat().st_size
210
+ stats["total_size_bytes"] = stats["size_bytes"]
211
+ with open(log_path, "r", encoding="utf-8", errors="replace") as f:
212
+ stats["line_count"] = sum(1 for _ in f)
213
+
214
+ # Count rotated logs
215
+ logs_dir = get_mcp_logs_dir()
216
+ safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in server_name)
217
+
218
+ for i in range(1, MAX_ROTATED_LOGS + 1):
219
+ rotated_path = logs_dir / f"{safe_name}.log.{i}"
220
+ if rotated_path.exists():
221
+ stats["rotated_count"] += 1
222
+ stats["total_size_bytes"] += rotated_path.stat().st_size
223
+
224
+ return stats
@@ -29,6 +29,13 @@ Example (new):
29
29
  >>> bus.emit(TextMessage(level=MessageLevel.INFO, text="Hello"))
30
30
  """
31
31
 
32
+ # =============================================================================
33
+ # Apply Rich Markdown patches (left-justified headers)
34
+ # =============================================================================
35
+ from .markdown_patches import patch_markdown_headings
36
+
37
+ patch_markdown_headings()
38
+
32
39
  # =============================================================================
33
40
  # Legacy API (backward compatible)
34
41
  # =============================================================================
@@ -36,6 +43,11 @@ Example (new):
36
43
  # Message bus
37
44
  from .bus import (
38
45
  MessageBus,
46
+ emit_shell_line,
47
+ get_message_bus,
48
+ get_session_context,
49
+ reset_message_bus,
50
+ set_session_context,
39
51
  )
40
52
  from .bus import emit as bus_emit # Convenience functions (new API versions)
41
53
  from .bus import emit_debug as bus_emit_debug
@@ -43,10 +55,6 @@ from .bus import emit_error as bus_emit_error
43
55
  from .bus import emit_info as bus_emit_info
44
56
  from .bus import emit_success as bus_emit_success
45
57
  from .bus import emit_warning as bus_emit_warning
46
- from .bus import (
47
- get_message_bus,
48
- reset_message_bus,
49
- )
50
58
 
51
59
  # Command types (UI -> Agent)
52
60
  from .commands import ( # Base; Agent control; User interaction responses; Union type
@@ -98,6 +106,7 @@ from .messages import ( # Enums, Base, Text, File ops, Diff, Shell, Agent, etc.
98
106
  MessageCategory,
99
107
  MessageLevel,
100
108
  SelectionRequest,
109
+ ShellLineMessage,
101
110
  ShellOutputMessage,
102
111
  ShellStartMessage,
103
112
  SpinnerControl,
@@ -177,7 +186,9 @@ __all__ = [
177
186
  "DiffLine",
178
187
  "DiffMessage",
179
188
  "ShellStartMessage",
189
+ "ShellLineMessage",
180
190
  "ShellOutputMessage",
191
+ "emit_shell_line",
181
192
  "AgentReasoningMessage",
182
193
  "AgentResponseMessage",
183
194
  "SubAgentInvocationMessage",
@@ -201,6 +212,9 @@ __all__ = [
201
212
  "MessageBus",
202
213
  "get_message_bus",
203
214
  "reset_message_bus",
215
+ # Session context
216
+ "set_session_context",
217
+ "get_session_context",
204
218
  # New API convenience functions (prefixed to avoid collision)
205
219
  "bus_emit",
206
220
  "bus_emit_info",
@@ -213,4 +227,6 @@ __all__ = [
213
227
  "RichConsoleRenderer",
214
228
  "DEFAULT_STYLES",
215
229
  "DIFF_STYLES",
230
+ # Markdown patches
231
+ "patch_markdown_headings",
216
232
  ]