code-puppy 0.0.317__py3-none-any.whl → 0.0.319__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.
@@ -1,14 +1,21 @@
1
1
  """
2
- MCP Logs Command - Shows recent events/logs for a server.
2
+ MCP Logs Command - Shows server logs from persistent log files.
3
3
  """
4
4
 
5
5
  import logging
6
- from datetime import datetime
7
6
  from typing import List, Optional
8
7
 
9
- from rich.table import Table
8
+ from rich.panel import Panel
9
+ from rich.syntax import Syntax
10
10
  from rich.text import Text
11
11
 
12
+ from code_puppy.mcp_.mcp_logs import (
13
+ clear_logs,
14
+ get_log_file_path,
15
+ get_log_stats,
16
+ list_servers_with_logs,
17
+ read_logs,
18
+ )
12
19
  from code_puppy.messaging import emit_error, emit_info
13
20
 
14
21
  from .base import MCPCommandBase
@@ -22,105 +29,207 @@ class LogsCommand(MCPCommandBase):
22
29
  """
23
30
  Command handler for showing MCP server logs.
24
31
 
25
- Shows recent events/logs for a specific MCP server with configurable limit.
32
+ Shows logs from persistent log files stored in ~/.code_puppy/mcp_logs/.
26
33
  """
27
34
 
28
35
  def execute(self, args: List[str], group_id: Optional[str] = None) -> None:
29
36
  """
30
- Show recent events/logs for a server.
37
+ Show logs for a server.
38
+
39
+ Usage:
40
+ /mcp logs - List servers with logs
41
+ /mcp logs <server_name> - Show last 50 lines
42
+ /mcp logs <server_name> 100 - Show last 100 lines
43
+ /mcp logs <server_name> all - Show all logs
44
+ /mcp logs <server_name> --clear - Clear logs for server
31
45
 
32
46
  Args:
33
- args: Command arguments, expects [server_name] and optional [limit]
47
+ args: Command arguments
34
48
  group_id: Optional message group ID for grouping related messages
35
49
  """
36
50
  if group_id is None:
37
51
  group_id = self.generate_group_id()
38
52
 
53
+ # No args - list servers with logs
39
54
  if not args:
40
- emit_info("Usage: /mcp logs <server_name> [limit]", message_group=group_id)
55
+ self._list_servers_with_logs(group_id)
41
56
  return
42
57
 
43
58
  server_name = args[0]
44
- limit = 10 # Default limit
59
+
60
+ # Check for --clear flag
61
+ if len(args) > 1 and args[1] == "--clear":
62
+ self._clear_logs(server_name, group_id)
63
+ return
64
+
65
+ # Determine number of lines
66
+ lines = 50 # Default
67
+ show_all = False
45
68
 
46
69
  if len(args) > 1:
47
- try:
48
- limit = int(args[1])
49
- if limit <= 0 or limit > 100:
70
+ if args[1].lower() == "all":
71
+ show_all = True
72
+ else:
73
+ try:
74
+ lines = int(args[1])
75
+ if lines <= 0:
76
+ emit_info(
77
+ "Lines must be positive, using default: 50",
78
+ message_group=group_id,
79
+ )
80
+ lines = 50
81
+ except ValueError:
50
82
  emit_info(
51
- "Limit must be between 1 and 100, using default: 10",
83
+ f"Invalid number '{args[1]}', using default: 50",
52
84
  message_group=group_id,
53
85
  )
54
- limit = 10
55
- except ValueError:
56
- emit_info(
57
- f"Invalid limit '{args[1]}', using default: 10",
58
- message_group=group_id,
59
- )
60
86
 
87
+ self._show_logs(server_name, lines if not show_all else None, group_id)
88
+
89
+ def _list_servers_with_logs(self, group_id: str) -> None:
90
+ """List all servers that have log files."""
91
+ servers = list_servers_with_logs()
92
+
93
+ if not servers:
94
+ emit_info(
95
+ "📋 No MCP server logs found.\n"
96
+ "Logs are created when servers are started.",
97
+ message_group=group_id,
98
+ )
99
+ return
100
+
101
+ lines = ["📋 **Servers with logs:**\n"]
102
+
103
+ for server in servers:
104
+ stats = get_log_stats(server)
105
+ size_kb = stats["total_size_bytes"] / 1024
106
+ size_str = (
107
+ f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB"
108
+ )
109
+ rotated = (
110
+ f" (+{stats['rotated_count']} rotated)"
111
+ if stats["rotated_count"]
112
+ else ""
113
+ )
114
+ lines.append(
115
+ f" • **{server}** - {stats['line_count']} lines, {size_str}{rotated}"
116
+ )
117
+
118
+ lines.append("\n**Usage:** `/mcp logs <server_name> [lines|all]`")
119
+
120
+ emit_info("\n".join(lines), message_group=group_id)
121
+
122
+ def _show_logs(self, server_name: str, lines: Optional[int], group_id: str) -> None:
123
+ """
124
+ Show logs for a specific server.
125
+
126
+ Args:
127
+ server_name: Name of the server
128
+ lines: Number of lines to show, or None for all
129
+ group_id: Message group ID
130
+ """
61
131
  try:
62
- # Find server by name
132
+ # Verify server exists in manager
63
133
  server_id = find_server_id_by_name(self.manager, server_name)
64
134
  if not server_id:
65
- emit_info(f"Server '{server_name}' not found", message_group=group_id)
66
- suggest_similar_servers(self.manager, server_name, group_id=group_id)
67
- return
135
+ # Server not configured, but might have logs from before
136
+ stats = get_log_stats(server_name)
137
+ if not stats["exists"]:
138
+ emit_info(
139
+ f"Server '{server_name}' not found and has no logs.",
140
+ message_group=group_id,
141
+ )
142
+ suggest_similar_servers(
143
+ self.manager, server_name, group_id=group_id
144
+ )
145
+ return
68
146
 
69
- # Get server status which includes recent events
70
- status = self.manager.get_server_status(server_id)
147
+ # Read logs
148
+ log_lines = read_logs(server_name, lines=lines)
71
149
 
72
- if not status.get("exists", True):
150
+ if not log_lines:
73
151
  emit_info(
74
- f"Server '{server_name}' status not available",
152
+ f"📋 No logs found for server: **{server_name}**\n"
153
+ f"Log file: `{get_log_file_path(server_name)}`",
75
154
  message_group=group_id,
76
155
  )
77
156
  return
78
157
 
79
- recent_events = status.get("recent_events", [])
80
-
81
- if not recent_events:
82
- emit_info(
83
- f"No recent events for server: {server_name}",
84
- message_group=group_id,
158
+ # Get stats for header
159
+ stats = get_log_stats(server_name)
160
+ total_lines = stats["line_count"]
161
+ showing = len(log_lines)
162
+
163
+ # Format header
164
+ if lines is None:
165
+ header = f"📋 Logs for {server_name} (all {total_lines} lines)"
166
+ else:
167
+ header = (
168
+ f"📋 Logs for {server_name} (last {showing} of {total_lines} lines)"
85
169
  )
86
- return
87
170
 
88
- # Show events in a table
89
- table = Table(title=f"📋 Recent Events for {server_name} (last {limit})")
90
- table.add_column("Time", style="dim", no_wrap=True)
91
- table.add_column("Event", style="cyan")
92
- table.add_column("Details", style="dim")
171
+ # Format log content with syntax highlighting
172
+ log_content = "\n".join(log_lines)
173
+
174
+ # Create a panel with the logs
175
+ syntax = Syntax(
176
+ log_content,
177
+ "log",
178
+ theme="monokai",
179
+ word_wrap=True,
180
+ line_numbers=False,
181
+ )
93
182
 
94
- # Take only the requested number of events
95
- events_to_show = (
96
- recent_events[-limit:] if len(recent_events) > limit else recent_events
183
+ panel = Panel(
184
+ syntax,
185
+ title=header,
186
+ subtitle=f"Log file: {get_log_file_path(server_name)}",
187
+ border_style="dim",
97
188
  )
98
189
 
99
- for event in reversed(events_to_show): # Show newest first
100
- timestamp = datetime.fromisoformat(event["timestamp"])
101
- time_str = timestamp.strftime("%H:%M:%S")
102
- event_type = event["event_type"]
103
-
104
- # Format details
105
- details = event.get("details", {})
106
- details_str = details.get("message", "")
107
- if not details_str and "error" in details:
108
- details_str = str(details["error"])
109
-
110
- # Color code event types
111
- event_style = "cyan"
112
- if "error" in event_type.lower():
113
- event_style = "red"
114
- elif event_type in ["started", "enabled", "registered"]:
115
- event_style = "green"
116
- elif event_type in ["stopped", "disabled"]:
117
- event_style = "yellow"
118
-
119
- table.add_row(
120
- time_str, Text(event_type, style=event_style), details_str or "-"
190
+ emit_info(panel, message_group=group_id)
191
+
192
+ # Show hint for more options
193
+ if lines is not None and showing < total_lines:
194
+ emit_info(
195
+ Text.from_markup(
196
+ f"[dim]💡 Use `/mcp logs {server_name} all` to see all logs, "
197
+ f"or `/mcp logs {server_name} <number>` for specific count[/dim]"
198
+ ),
199
+ message_group=group_id,
121
200
  )
122
- emit_info(table, message_group=group_id)
123
201
 
124
202
  except Exception as e:
125
203
  logger.error(f"Error getting logs for server '{server_name}': {e}")
126
204
  emit_error(f"Error getting logs: {e}", message_group=group_id)
205
+
206
+ def _clear_logs(self, server_name: str, group_id: str) -> None:
207
+ """
208
+ Clear logs for a specific server.
209
+
210
+ Args:
211
+ server_name: Name of the server
212
+ group_id: Message group ID
213
+ """
214
+ try:
215
+ stats = get_log_stats(server_name)
216
+
217
+ if not stats["exists"] and stats["rotated_count"] == 0:
218
+ emit_info(
219
+ f"No logs to clear for server: {server_name}",
220
+ message_group=group_id,
221
+ )
222
+ return
223
+
224
+ # Clear the logs
225
+ clear_logs(server_name, include_rotated=True)
226
+
227
+ cleared_count = 1 + stats["rotated_count"]
228
+ emit_info(
229
+ f"🗑️ Cleared {cleared_count} log file(s) for **{server_name}**",
230
+ message_group=group_id,
231
+ )
232
+
233
+ except Exception as e:
234
+ logger.error(f"Error clearing logs for server '{server_name}': {e}")
235
+ emit_error(f"Error clearing logs: {e}", message_group=group_id)
@@ -84,6 +84,12 @@ SETTING_DEFINITIONS: Dict[str, Dict] = {
84
84
  "default": 10000,
85
85
  "format": "{:.0f}",
86
86
  },
87
+ "interleaved_thinking": {
88
+ "name": "Interleaved Thinking",
89
+ "description": "Enable thinking between tool calls (Claude 4 only: Opus 4.5, Opus 4.1, Opus 4, Sonnet 4). Adds beta header. WARNING: On Vertex/Bedrock, this FAILS for non-Claude 4 models!",
90
+ "type": "boolean",
91
+ "default": False,
92
+ },
87
93
  }
88
94
 
89
95
 
@@ -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")
49
57
 
50
- # Start monitoring thread
58
+ # Open log file for appending stderr
59
+ self.log_file = open(self.log_path, "a", encoding="utf-8")
60
+
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
 
@@ -204,7 +204,7 @@ class ManagedMCPServer:
204
204
  **stdio_kwargs,
205
205
  process_tool_call=process_tool_call,
206
206
  tool_prefix=self.config.name,
207
- emit_stderr=True, # Always emit stderr for now
207
+ emit_stderr=False, # Logs go to file, not console (use /mcp logs to view)
208
208
  message_group=message_group,
209
209
  )
210
210
 
@@ -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
@@ -319,9 +319,21 @@ class ModelFactory:
319
319
  http2=http2_enabled,
320
320
  )
321
321
 
322
+ # Check if interleaved thinking is enabled for this model
323
+ # Only applies to Claude 4 models (Opus 4.5, Opus 4.1, Opus 4, Sonnet 4)
324
+ from code_puppy.config import get_effective_model_settings
325
+
326
+ effective_settings = get_effective_model_settings(model_name)
327
+ interleaved_thinking = effective_settings.get("interleaved_thinking", False)
328
+
329
+ default_headers = {}
330
+ if interleaved_thinking:
331
+ default_headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
332
+
322
333
  anthropic_client = AsyncAnthropic(
323
334
  api_key=api_key,
324
335
  http_client=client,
336
+ default_headers=default_headers if default_headers else None,
325
337
  )
326
338
 
327
339
  # Ensure cache_control is injected at the Anthropic SDK layer
@@ -351,10 +363,21 @@ class ModelFactory:
351
363
  http2=http2_enabled,
352
364
  )
353
365
 
366
+ # Check if interleaved thinking is enabled for this model
367
+ from code_puppy.config import get_effective_model_settings
368
+
369
+ effective_settings = get_effective_model_settings(model_name)
370
+ interleaved_thinking = effective_settings.get("interleaved_thinking", False)
371
+
372
+ default_headers = {}
373
+ if interleaved_thinking:
374
+ default_headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
375
+
354
376
  anthropic_client = AsyncAnthropic(
355
377
  base_url=url,
356
378
  http_client=client,
357
379
  api_key=api_key,
380
+ default_headers=default_headers if default_headers else None,
358
381
  )
359
382
 
360
383
  # Ensure cache_control is injected at the Anthropic SDK layer
@@ -370,6 +393,31 @@ class ModelFactory:
370
393
  )
371
394
  return None
372
395
 
396
+ # Check if interleaved thinking is enabled (defaults to True for OAuth models)
397
+ from code_puppy.config import get_effective_model_settings
398
+
399
+ effective_settings = get_effective_model_settings(model_name)
400
+ interleaved_thinking = effective_settings.get("interleaved_thinking", True)
401
+
402
+ # Handle anthropic-beta header based on interleaved_thinking setting
403
+ if "anthropic-beta" in headers:
404
+ beta_parts = [p.strip() for p in headers["anthropic-beta"].split(",")]
405
+ if interleaved_thinking:
406
+ # Ensure interleaved-thinking is in the header
407
+ if "interleaved-thinking-2025-05-14" not in beta_parts:
408
+ beta_parts.append("interleaved-thinking-2025-05-14")
409
+ else:
410
+ # Remove interleaved-thinking from the header
411
+ beta_parts = [
412
+ p for p in beta_parts if "interleaved-thinking" not in p
413
+ ]
414
+ headers["anthropic-beta"] = ",".join(beta_parts) if beta_parts else None
415
+ if headers.get("anthropic-beta") is None:
416
+ del headers["anthropic-beta"]
417
+ elif interleaved_thinking:
418
+ # No existing beta header, add one for interleaved thinking
419
+ headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
420
+
373
421
  # Use a dedicated client wrapper that injects cache_control on /v1/messages
374
422
  if verify is None:
375
423
  verify = get_cert_bundle_path()
code_puppy/models.json CHANGED
@@ -81,7 +81,7 @@
81
81
  "type": "anthropic",
82
82
  "name": "claude-opus-4-5",
83
83
  "context_length": 200000,
84
- "supported_settings": ["temperature", "extended_thinking", "budget_tokens"]
84
+ "supported_settings": ["temperature", "extended_thinking", "budget_tokens", "interleaved_thinking"]
85
85
  },
86
86
  "zai-glm-4.6-coding": {
87
87
  "type": "zai_coding",
@@ -368,6 +368,7 @@ def add_models_to_extra_config(models: List[str]) -> bool:
368
368
  "temperature",
369
369
  "extended_thinking",
370
370
  "budget_tokens",
371
+ "interleaved_thinking",
371
372
  ],
372
373
  }
373
374
  added += 1
@@ -81,7 +81,7 @@
81
81
  "type": "anthropic",
82
82
  "name": "claude-opus-4-5",
83
83
  "context_length": 200000,
84
- "supported_settings": ["temperature", "extended_thinking", "budget_tokens"]
84
+ "supported_settings": ["temperature", "extended_thinking", "budget_tokens", "interleaved_thinking"]
85
85
  },
86
86
  "zai-glm-4.6-coding": {
87
87
  "type": "zai_coding",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-puppy
3
- Version: 0.0.317
3
+ Version: 0.0.319
4
4
  Summary: Code generation agent
5
5
  Project-URL: repository, https://github.com/mpfaffenberger/code_puppy
6
6
  Project-URL: HomePage, https://github.com/mpfaffenberger/code_puppy
@@ -10,9 +10,9 @@ code_puppy/gemini_code_assist.py,sha256=KGS7sO5OLc83nDF3xxS-QiU6vxW9vcm6hmzilu79
10
10
  code_puppy/http_utils.py,sha256=w5mWYIGIWJZJvgvMahXs9BmdidoJvGn4CASDRY88a8o,13414
11
11
  code_puppy/keymap.py,sha256=Uzvq7HB-6inTjKox-90JWzuijztRdWqhJpfTDZVy5no,3235
12
12
  code_puppy/main.py,sha256=82r3vZy_XcyEsenLn82BnUusaoyL3Bpm_Th_jKgqecE,273
13
- code_puppy/model_factory.py,sha256=2jXTpi3BuFO8RtbhKKRvb5EN02w_OKzQSdsTdSy78X0,31608
13
+ code_puppy/model_factory.py,sha256=H_a5nX462Q-dhX3g3ZY7dmBCIAUOd1aOSZa4HMxF1o4,34191
14
14
  code_puppy/model_utils.py,sha256=NU8W8NW5F7QS_PXHaLeh55Air1koUV7IVYFP7Rz3XpY,3615
15
- code_puppy/models.json,sha256=nbMCW9SJxWptVEOeMrn_gfyDp-lsbM7Chczjl944GKA,3077
15
+ code_puppy/models.json,sha256=mTpmJH0UJlmX8M2KVPbxMWb99de3IxKXCWO-B23b6xo,3101
16
16
  code_puppy/models_dev_api.json,sha256=wHjkj-IM_fx1oHki6-GqtOoCrRMR0ScK0f-Iz0UEcy8,548187
17
17
  code_puppy/models_dev_parser.py,sha256=8ndmWrsSyKbXXpRZPXc0w6TfWMuCcgaHiMifmlaBaPc,20611
18
18
  code_puppy/pydantic_patches.py,sha256=YecAEeCOjSIwIBu2O5vEw72atMSL37cXGrbEuukI07o,4582
@@ -56,7 +56,7 @@ code_puppy/command_line/file_path_completion.py,sha256=gw8NpIxa6GOpczUJRyh7VNZwo
56
56
  code_puppy/command_line/load_context_completion.py,sha256=a3JvLDeLLSYxVgTjAdqWzS4spjv6ccCrK2LKZgVJ1IM,2202
57
57
  code_puppy/command_line/mcp_completion.py,sha256=eKzW2O7gun7HoHekOW0XVXhNS5J2xCtK7aaWyA8bkZk,6952
58
58
  code_puppy/command_line/model_picker_completion.py,sha256=nDnlf0qFCG2zAm_mWW2eMYwVC7eROVQrFe92hZqOKa8,6810
59
- code_puppy/command_line/model_settings_menu.py,sha256=-GDJyvkFeEIs-fYTvkBPxyVjmMo9CJRrwUq_IuiEbqY,31935
59
+ code_puppy/command_line/model_settings_menu.py,sha256=O5nPp_OyShFcXzpSmsCeYsnnVNrSwcTBFY9bzcayvj0,32263
60
60
  code_puppy/command_line/motd.py,sha256=OoNxwewsckexSgJ5H5y40IawP-TzqlqY-rqFUdRbIhs,2186
61
61
  code_puppy/command_line/pin_command_completion.py,sha256=juSvdqRpk7AdfkPy1DJx5NzfEUU5KYGlChvP0hisM18,11667
62
62
  code_puppy/command_line/prompt_toolkit_completion.py,sha256=x4Of32g8oH9ckhx-P6BigV7HUUhhjL8xkvK03uq9HRw,27308
@@ -74,7 +74,7 @@ code_puppy/command_line/mcp/help_command.py,sha256=dU3ekOjjNKxRS-RjUXJZ7PBwmJeIe
74
74
  code_puppy/command_line/mcp/install_command.py,sha256=lmUyMUWtkGuy1SOQRHjQgt8mD3t1agVMQfEL5_TOzTM,8364
75
75
  code_puppy/command_line/mcp/install_menu.py,sha256=lUg7x43aK4NRIS3XrPhvhmcQwjyNb-rzrL-2GL6oYiw,24558
76
76
  code_puppy/command_line/mcp/list_command.py,sha256=UKQFPlhT9qGMCyG5VKjvnSMzDDtfAhIaKU_eErgZJDg,3181
77
- code_puppy/command_line/mcp/logs_command.py,sha256=NcY3S-fMWNQdsdbc07-mvEAFdfWZKavFrjPBzxUCCf0,4455
77
+ code_puppy/command_line/mcp/logs_command.py,sha256=IZzOadnI2ch6j4AcdjuHwJYJWKw1_K1rrCh9_aVK94k,7759
78
78
  code_puppy/command_line/mcp/remove_command.py,sha256=hyU_tKJWfyLnmufrFVLwlF0qFEbghXBVMOvSgWvaEgA,2755
79
79
  code_puppy/command_line/mcp/restart_command.py,sha256=w5EcDac09iCvPBAR0u2M5KSIhASqTu5uZwsjCJ4JLhk,3588
80
80
  code_puppy/command_line/mcp/search_command.py,sha256=mDkSz_KjPbvlO9U7oYUKJlqqY4QM90gWKO2xsH2i3SA,4244
@@ -86,17 +86,18 @@ code_puppy/command_line/mcp/stop_command.py,sha256=iMzk9h6NuUDg0hhI5eDLem5VS8IwB
86
86
  code_puppy/command_line/mcp/test_command.py,sha256=eV8u5KKClRK1M2_os1zA78b9TDuYUG_uEk7AfMfD2HY,3691
87
87
  code_puppy/command_line/mcp/utils.py,sha256=0Wt4ttYgSlVvtusYmBLKXSkjAjcsDiUxcZQAoFLUNnE,3625
88
88
  code_puppy/command_line/mcp/wizard_utils.py,sha256=M5X8RchkQujKYKORsXJnUq2kJHzmNfutIUrsHmfzi7k,11126
89
- code_puppy/mcp_/__init__.py,sha256=UZ6ZYEIN8At3Jq88ZTkrVQHswNMxHyIBz4hbOn71osk,1444
89
+ code_puppy/mcp_/__init__.py,sha256=P9bmVX5UDmzQDqHMylOxuo5Hi82E30pPSMOYw8lEx7Q,1781
90
90
  code_puppy/mcp_/async_lifecycle.py,sha256=pTQrwQCVgjad8EJeRTSHtIeSQRgT_8r5BeLv-1SgKog,7772
91
- code_puppy/mcp_/blocking_startup.py,sha256=5HMI0fweo5f62kPelC3GaVikbmbm51_TBZg557fmOlc,13715
91
+ code_puppy/mcp_/blocking_startup.py,sha256=27R2wwLVDu5i19IP90KmCn_zHrvVcHVNU8d9c8EcTeI,14780
92
92
  code_puppy/mcp_/captured_stdio_server.py,sha256=t_mnCjtiopRsyi4Aa97rFzDVxEQmb-u94sWJsj2FP8k,8925
93
93
  code_puppy/mcp_/circuit_breaker.py,sha256=a83YwXux9h4R6zBWBUrCIqtp2ffyl7JZEoK2tErG_0I,8601
94
94
  code_puppy/mcp_/config_wizard.py,sha256=JNNpgnSD6PFSyS3pTdEdD164oXd2VKp4VHLSz3ToH1w,16511
95
95
  code_puppy/mcp_/dashboard.py,sha256=VtaFxLtPnbM_HL2TXRDAg6IqcM-EcFkoghGgkfhMrKI,9417
96
96
  code_puppy/mcp_/error_isolation.py,sha256=mpPBiH17zTXPsOEAn9WmkbwQwnt4gmgiaWv87JBJbUo,12426
97
97
  code_puppy/mcp_/health_monitor.py,sha256=n5R6EeYOYbUucUFe74qGWCU3g6Mep5UEQbLF0wbT0dU,19688
98
- code_puppy/mcp_/managed_server.py,sha256=X165SqI2CTOm9FWumz2LnWvyj2mYUXONGAwelyump4c,14277
98
+ code_puppy/mcp_/managed_server.py,sha256=KmrFQAQBS-XHuvkuWUltFJk2jiR0pt55gdQlI0gA2QE,14304
99
99
  code_puppy/mcp_/manager.py,sha256=pJ4cALicTxfwG2JIjJraLLf0Mzes-cEVAKIcUwfOoKA,29172
100
+ code_puppy/mcp_/mcp_logs.py,sha256=o4pSHwELWIjEjqhfaMMEGrBvb159-VIgUp21E707BPo,6264
100
101
  code_puppy/mcp_/registry.py,sha256=U_t12WQ-En-KGyZoiTYdqlhp9NkDTWafu8g5InvF2NM,15774
101
102
  code_puppy/mcp_/retry_manager.py,sha256=evVxbtrsHNyo8UoI7zpO-NVDegibn82RLlgN8VKewA8,10665
102
103
  code_puppy/mcp_/server_registry_catalog.py,sha256=fr_wsr99BphnaDiPLcN60t4Mp62lbt8rYNOpghKnqEA,39429
@@ -129,7 +130,7 @@ code_puppy/plugins/claude_code_oauth/__init__.py,sha256=mCcOU-wM7LNCDjr-w-WLPzom
129
130
  code_puppy/plugins/claude_code_oauth/config.py,sha256=DjGySCkvjSGZds6DYErLMAi3TItt8iSLGvyJN98nSEM,2013
130
131
  code_puppy/plugins/claude_code_oauth/register_callbacks.py,sha256=0NeX1hhkYIlVfPmjZ1xmcf1yueDAJh_FMUmvJlxSO-E,10057
131
132
  code_puppy/plugins/claude_code_oauth/test_plugin.py,sha256=yQy4EeZl4bjrcog1d8BjknoDTRK75mRXXvkSQJYSSEM,9286
132
- code_puppy/plugins/claude_code_oauth/utils.py,sha256=uxNRrvtmyG_zZxcvCyZIU1fib8wV5KeorHgVv0RWS9s,13394
133
+ code_puppy/plugins/claude_code_oauth/utils.py,sha256=wDaOU21zB3y6PWkuMXwE4mFjQuffyDae-vXysPTS-w8,13438
133
134
  code_puppy/plugins/customizable_commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
134
135
  code_puppy/plugins/customizable_commands/register_callbacks.py,sha256=zVMfIzr--hVn0IOXxIicbmgj2s-HZUgtrOc0NCDOnDw,5183
135
136
  code_puppy/plugins/example_custom_command/README.md,sha256=5c5Zkm7CW6BDSfe3WoLU7GW6t5mjjYAbu9-_pu-b3p4,8244
@@ -158,10 +159,10 @@ code_puppy/tools/browser/browser_scripts.py,sha256=sNb8eLEyzhasy5hV4B9OjM8yIVMLV
158
159
  code_puppy/tools/browser/browser_workflows.py,sha256=nitW42vCf0ieTX1gLabozTugNQ8phtoFzZbiAhw1V90,6491
159
160
  code_puppy/tools/browser/camoufox_manager.py,sha256=RZjGOEftE5sI_tsercUyXFSZI2wpStXf-q0PdYh2G3I,8680
160
161
  code_puppy/tools/browser/vqa_agent.py,sha256=DBn9HKloILqJSTSdNZzH_PYWT0B2h9VwmY6akFQI_uU,2913
161
- code_puppy-0.0.317.data/data/code_puppy/models.json,sha256=nbMCW9SJxWptVEOeMrn_gfyDp-lsbM7Chczjl944GKA,3077
162
- code_puppy-0.0.317.data/data/code_puppy/models_dev_api.json,sha256=wHjkj-IM_fx1oHki6-GqtOoCrRMR0ScK0f-Iz0UEcy8,548187
163
- code_puppy-0.0.317.dist-info/METADATA,sha256=vScnDFiP9UR0Z-U7rx0TAmFL-LBm4b9a13XEdqYh0Y8,28030
164
- code_puppy-0.0.317.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
165
- code_puppy-0.0.317.dist-info/entry_points.txt,sha256=Tp4eQC99WY3HOKd3sdvb22vZODRq0XkZVNpXOag_KdI,91
166
- code_puppy-0.0.317.dist-info/licenses/LICENSE,sha256=31u8x0SPgdOq3izJX41kgFazWsM43zPEF9eskzqbJMY,1075
167
- code_puppy-0.0.317.dist-info/RECORD,,
162
+ code_puppy-0.0.319.data/data/code_puppy/models.json,sha256=mTpmJH0UJlmX8M2KVPbxMWb99de3IxKXCWO-B23b6xo,3101
163
+ code_puppy-0.0.319.data/data/code_puppy/models_dev_api.json,sha256=wHjkj-IM_fx1oHki6-GqtOoCrRMR0ScK0f-Iz0UEcy8,548187
164
+ code_puppy-0.0.319.dist-info/METADATA,sha256=XgkIpN2_glvNl1BcGksFRRikU_PCKdR-eQkDAIkr1ms,28030
165
+ code_puppy-0.0.319.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
166
+ code_puppy-0.0.319.dist-info/entry_points.txt,sha256=Tp4eQC99WY3HOKd3sdvb22vZODRq0XkZVNpXOag_KdI,91
167
+ code_puppy-0.0.319.dist-info/licenses/LICENSE,sha256=31u8x0SPgdOq3izJX41kgFazWsM43zPEF9eskzqbJMY,1075
168
+ code_puppy-0.0.319.dist-info/RECORD,,