stravinsky 0.2.40__py3-none-any.whl → 0.3.4__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 (56) hide show
  1. mcp_bridge/__init__.py +1 -1
  2. mcp_bridge/auth/token_refresh.py +130 -0
  3. mcp_bridge/cli/__init__.py +6 -0
  4. mcp_bridge/cli/install_hooks.py +1265 -0
  5. mcp_bridge/cli/session_report.py +585 -0
  6. mcp_bridge/hooks/HOOKS_SETTINGS.json +175 -0
  7. mcp_bridge/hooks/README.md +215 -0
  8. mcp_bridge/hooks/__init__.py +119 -43
  9. mcp_bridge/hooks/edit_recovery.py +42 -37
  10. mcp_bridge/hooks/git_noninteractive.py +89 -0
  11. mcp_bridge/hooks/keyword_detector.py +30 -0
  12. mcp_bridge/hooks/manager.py +50 -0
  13. mcp_bridge/hooks/notification_hook.py +103 -0
  14. mcp_bridge/hooks/parallel_enforcer.py +127 -0
  15. mcp_bridge/hooks/parallel_execution.py +111 -0
  16. mcp_bridge/hooks/pre_compact.py +123 -0
  17. mcp_bridge/hooks/preemptive_compaction.py +81 -7
  18. mcp_bridge/hooks/rules_injector.py +507 -0
  19. mcp_bridge/hooks/session_idle.py +116 -0
  20. mcp_bridge/hooks/session_notifier.py +125 -0
  21. mcp_bridge/{native_hooks → hooks}/stravinsky_mode.py +51 -16
  22. mcp_bridge/hooks/subagent_stop.py +98 -0
  23. mcp_bridge/hooks/task_validator.py +73 -0
  24. mcp_bridge/hooks/tmux_manager.py +141 -0
  25. mcp_bridge/hooks/todo_continuation.py +90 -0
  26. mcp_bridge/hooks/todo_delegation.py +88 -0
  27. mcp_bridge/hooks/tool_messaging.py +164 -0
  28. mcp_bridge/hooks/truncator.py +21 -17
  29. mcp_bridge/notifications.py +151 -0
  30. mcp_bridge/prompts/__init__.py +3 -1
  31. mcp_bridge/prompts/dewey.py +30 -20
  32. mcp_bridge/prompts/explore.py +46 -8
  33. mcp_bridge/prompts/multimodal.py +24 -3
  34. mcp_bridge/prompts/planner.py +222 -0
  35. mcp_bridge/prompts/stravinsky.py +107 -28
  36. mcp_bridge/server.py +170 -10
  37. mcp_bridge/server_tools.py +554 -32
  38. mcp_bridge/tools/agent_manager.py +316 -106
  39. mcp_bridge/tools/background_tasks.py +2 -1
  40. mcp_bridge/tools/code_search.py +97 -11
  41. mcp_bridge/tools/lsp/__init__.py +7 -0
  42. mcp_bridge/tools/lsp/manager.py +448 -0
  43. mcp_bridge/tools/lsp/tools.py +637 -150
  44. mcp_bridge/tools/model_invoke.py +270 -47
  45. mcp_bridge/tools/semantic_search.py +2492 -0
  46. mcp_bridge/tools/templates.py +32 -18
  47. stravinsky-0.3.4.dist-info/METADATA +420 -0
  48. stravinsky-0.3.4.dist-info/RECORD +79 -0
  49. stravinsky-0.3.4.dist-info/entry_points.txt +5 -0
  50. mcp_bridge/native_hooks/edit_recovery.py +0 -46
  51. mcp_bridge/native_hooks/truncator.py +0 -23
  52. stravinsky-0.2.40.dist-info/METADATA +0 -204
  53. stravinsky-0.2.40.dist-info/RECORD +0 -57
  54. stravinsky-0.2.40.dist-info/entry_points.txt +0 -3
  55. /mcp_bridge/{native_hooks → hooks}/context.py +0 -0
  56. {stravinsky-0.2.40.dist-info → stravinsky-0.3.4.dist-info}/WHEEL +0 -0
@@ -15,17 +15,21 @@ from pathlib import Path
15
15
  async def lsp_diagnostics(file_path: str, severity: str = "all") -> str:
16
16
  """
17
17
  Get diagnostics (errors, warnings) for a file using language server.
18
-
18
+
19
19
  For TypeScript/JavaScript, uses `tsc` or `biome`.
20
20
  For Python, uses `pyright` or `ruff`.
21
-
21
+
22
22
  Args:
23
23
  file_path: Path to the file to analyze
24
24
  severity: Filter by severity (error, warning, information, hint, all)
25
-
25
+
26
26
  Returns:
27
27
  Formatted diagnostics output.
28
28
  """
29
+ # USER-VISIBLE NOTIFICATION
30
+ import sys
31
+ print(f"🩺 LSP-DIAG: file={file_path} severity={severity}", file=sys.stderr)
32
+
29
33
  path = Path(file_path)
30
34
  if not path.exists():
31
35
  return f"Error: File not found: {file_path}"
@@ -49,7 +53,7 @@ async def lsp_diagnostics(file_path: str, severity: str = "all") -> str:
49
53
  elif suffix == ".py":
50
54
  # Use ruff for Python diagnostics
51
55
  result = subprocess.run(
52
- ["ruff", "check", str(path), "--output-format=text"],
56
+ ["ruff", "check", str(path), "--output-format=concise"],
53
57
  capture_output=True,
54
58
  text=True,
55
59
  timeout=30,
@@ -70,21 +74,88 @@ async def lsp_diagnostics(file_path: str, severity: str = "all") -> str:
70
74
  return f"Error: {str(e)}"
71
75
 
72
76
 
77
+ async def check_ai_comment_patterns(file_path: str) -> str:
78
+ """
79
+ Detect AI-generated or placeholder comment patterns that indicate incomplete work.
80
+
81
+ Patterns detected:
82
+ - # TODO: implement, # FIXME, # placeholder
83
+ - // TODO, // FIXME, // placeholder
84
+ - AI-style verbose comments: "This function handles...", "This method is responsible for..."
85
+ - Placeholder phrases: "implement this", "add logic here", "your code here"
86
+
87
+ Args:
88
+ file_path: Path to the file to check
89
+
90
+ Returns:
91
+ List of detected AI-style patterns with line numbers, or "No AI patterns detected"
92
+ """
93
+ # USER-VISIBLE NOTIFICATION
94
+ import sys
95
+ print(f"🤖 AI-CHECK: {file_path}", file=sys.stderr)
96
+
97
+ path = Path(file_path)
98
+ if not path.exists():
99
+ return f"Error: File not found: {file_path}"
100
+
101
+ # Patterns that indicate AI-generated or placeholder code
102
+ ai_patterns = [
103
+ # Placeholder comments
104
+ r"#\s*(TODO|FIXME|XXX|HACK):\s*(implement|add|placeholder|your code)",
105
+ r"//\s*(TODO|FIXME|XXX|HACK):\s*(implement|add|placeholder|your code)",
106
+ # AI-style verbose descriptions
107
+ r"#\s*This (function|method|class) (handles|is responsible for|manages|processes)",
108
+ r"//\s*This (function|method|class) (handles|is responsible for|manages|processes)",
109
+ r'"""This (function|method|class) (handles|is responsible for|manages|processes)',
110
+ # Placeholder implementations
111
+ r"pass\s*#\s*(TODO|implement|placeholder)",
112
+ r"raise NotImplementedError.*implement",
113
+ # Common AI filler phrases
114
+ r"#.*\b(as needed|as required|as appropriate|if necessary)\b",
115
+ r"//.*\b(as needed|as required|as appropriate|if necessary)\b",
116
+ ]
117
+
118
+ import re
119
+
120
+ try:
121
+ content = path.read_text()
122
+ lines = content.split("\n")
123
+ findings = []
124
+
125
+ for i, line in enumerate(lines, 1):
126
+ for pattern in ai_patterns:
127
+ if re.search(pattern, line, re.IGNORECASE):
128
+ findings.append(f" Line {i}: {line.strip()[:80]}")
129
+ break
130
+
131
+ if findings:
132
+ return f"AI/Placeholder patterns detected in {file_path}:\n" + "\n".join(findings)
133
+ return "No AI patterns detected"
134
+
135
+ except Exception as e:
136
+ return f"Error reading file: {str(e)}"
137
+
138
+
73
139
  async def ast_grep_search(pattern: str, directory: str = ".", language: str = "") -> str:
74
140
  """
75
141
  Search codebase using ast-grep for structural patterns.
76
-
142
+
77
143
  ast-grep uses AST-aware pattern matching, finding code by structure
78
144
  rather than just text. More precise than regex for code search.
79
-
145
+
80
146
  Args:
81
147
  pattern: ast-grep pattern to search for
82
148
  directory: Directory to search in
83
149
  language: Filter by language (typescript, python, rust, etc.)
84
-
150
+
85
151
  Returns:
86
152
  Matched code locations and snippets.
87
153
  """
154
+ # USER-VISIBLE NOTIFICATION
155
+ import sys
156
+ lang_info = f" lang={language}" if language else ""
157
+ print(f"🔍 AST-GREP: pattern='{pattern[:50]}...'{lang_info}", file=sys.stderr)
158
+
88
159
  try:
89
160
  cmd = ["sg", "run", "-p", pattern, directory]
90
161
  if language:
@@ -129,15 +200,20 @@ async def ast_grep_search(pattern: str, directory: str = ".", language: str = ""
129
200
  async def grep_search(pattern: str, directory: str = ".", file_pattern: str = "") -> str:
130
201
  """
131
202
  Fast text search using ripgrep.
132
-
203
+
133
204
  Args:
134
205
  pattern: Search pattern (supports regex)
135
206
  directory: Directory to search in
136
207
  file_pattern: Glob pattern to filter files (e.g., "*.py", "*.ts")
137
-
208
+
138
209
  Returns:
139
210
  Matched lines with file paths and line numbers.
140
211
  """
212
+ # USER-VISIBLE NOTIFICATION
213
+ import sys
214
+ glob_info = f" glob={file_pattern}" if file_pattern else ""
215
+ print(f"🔎 GREP: pattern='{pattern[:50]}'{glob_info} dir={directory}", file=sys.stderr)
216
+
141
217
  try:
142
218
  cmd = ["rg", "--line-number", "--max-count=50", pattern, directory]
143
219
  if file_pattern:
@@ -173,14 +249,18 @@ async def grep_search(pattern: str, directory: str = ".", file_pattern: str = ""
173
249
  async def glob_files(pattern: str, directory: str = ".") -> str:
174
250
  """
175
251
  Find files matching a glob pattern.
176
-
252
+
177
253
  Args:
178
254
  pattern: Glob pattern (e.g., "**/*.py", "src/**/*.ts")
179
255
  directory: Base directory for search
180
-
256
+
181
257
  Returns:
182
258
  List of matching file paths.
183
259
  """
260
+ # USER-VISIBLE NOTIFICATION
261
+ import sys
262
+ print(f"📁 GLOB: pattern='{pattern}' dir={directory}", file=sys.stderr)
263
+
184
264
  try:
185
265
  cmd = ["fd", "--type", "f", "--glob", pattern, directory]
186
266
 
@@ -234,6 +314,12 @@ async def ast_grep_replace(
234
314
  Returns:
235
315
  Preview of changes or confirmation of applied changes.
236
316
  """
317
+ # USER-VISIBLE NOTIFICATION
318
+ import sys
319
+ mode = "dry-run" if dry_run else "APPLY"
320
+ lang_info = f" lang={language}" if language else ""
321
+ print(f"🔄 AST-REPLACE: '{pattern[:30]}' → '{replacement[:30]}'{lang_info} [{mode}]", file=sys.stderr)
322
+
237
323
  try:
238
324
  # Build command
239
325
  cmd = ["sg", "run", "-p", pattern, "-r", replacement, directory]
@@ -13,8 +13,11 @@ from .tools import (
13
13
  lsp_prepare_rename,
14
14
  lsp_rename,
15
15
  lsp_code_actions,
16
+ lsp_code_action_resolve,
17
+ lsp_extract_refactor,
16
18
  lsp_servers,
17
19
  )
20
+ from .manager import LSPManager, get_lsp_manager
18
21
 
19
22
  __all__ = [
20
23
  "lsp_hover",
@@ -25,5 +28,9 @@ __all__ = [
25
28
  "lsp_prepare_rename",
26
29
  "lsp_rename",
27
30
  "lsp_code_actions",
31
+ "lsp_code_action_resolve",
32
+ "lsp_extract_refactor",
28
33
  "lsp_servers",
34
+ "LSPManager",
35
+ "get_lsp_manager",
29
36
  ]
@@ -0,0 +1,448 @@
1
+ """
2
+ Persistent LSP Server Manager
3
+
4
+ Manages persistent Language Server Protocol (LSP) servers for improved performance.
5
+ Implements lazy initialization, JSON-RPC communication, and graceful shutdown.
6
+
7
+ Architecture:
8
+ - Servers start on first use (lazy initialization)
9
+ - JSON-RPC over stdio using pygls BaseLanguageClient
10
+ - Supports Python (jedi-language-server) and TypeScript (typescript-language-server)
11
+ - Graceful shutdown on MCP server exit
12
+ - Health checks and idle timeout management
13
+ """
14
+
15
+ import asyncio
16
+ import json
17
+ import logging
18
+ import os
19
+ import shlex
20
+ import signal
21
+ import threading
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Any, Optional
26
+
27
+ from pygls.client import JsonRPCClient
28
+ from lsprotocol.types import (
29
+ InitializeParams,
30
+ InitializedParams,
31
+ ClientCapabilities,
32
+ WorkspaceFolder,
33
+ )
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # Configuration for LSP server lifecycle management
38
+ LSP_CONFIG = {
39
+ "idle_timeout": 1800, # 30 minutes
40
+ "health_check_interval": 300, # 5 minutes
41
+ "health_check_timeout": 5.0,
42
+ }
43
+
44
+
45
+ @dataclass
46
+ class LSPServer:
47
+ """Metadata for a persistent LSP server."""
48
+
49
+ name: str
50
+ command: list[str]
51
+ client: Optional[JsonRPCClient] = None
52
+ initialized: bool = False
53
+ process: Optional[asyncio.subprocess.Process] = None
54
+ pid: Optional[int] = None # Track subprocess PID for explicit cleanup
55
+ last_used: float = field(default_factory=time.time) # Timestamp of last usage
56
+ created_at: float = field(default_factory=time.time) # Timestamp of server creation
57
+
58
+
59
+ class LSPManager:
60
+ """
61
+ Singleton manager for persistent LSP servers.
62
+
63
+ Implements:
64
+ - Lazy server initialization (start on first use)
65
+ - Process lifecycle management with GC protection
66
+ - Exponential backoff for crash recovery
67
+ - Graceful shutdown with signal handling
68
+ - Health checks and idle server shutdown
69
+ """
70
+
71
+ _instance: Optional["LSPManager"] = None
72
+
73
+ def __new__(cls):
74
+ if cls._instance is None:
75
+ cls._instance = super().__new__(cls)
76
+ return cls._instance
77
+
78
+ def __init__(self):
79
+ if hasattr(self, "_initialized"):
80
+ return
81
+ self._initialized = True
82
+ self._servers: dict[str, LSPServer] = {}
83
+ self._lock = asyncio.Lock()
84
+ self._restart_attempts: dict[str, int] = {}
85
+ self._health_monitor_task: Optional[asyncio.Task] = None
86
+
87
+ # Register available LSP servers
88
+ self._register_servers()
89
+
90
+ def _register_servers(self):
91
+ """Register available LSP server configurations."""
92
+ self._servers["python"] = LSPServer(name="python", command=["jedi-language-server"])
93
+ self._servers["typescript"] = LSPServer(
94
+ name="typescript", command=["typescript-language-server", "--stdio"]
95
+ )
96
+
97
+ async def get_server(self, language: str) -> Optional[JsonRPCClient]:
98
+ """
99
+ Get or start a persistent LSP server for the given language.
100
+
101
+ Args:
102
+ language: Language identifier (e.g., "python", "typescript")
103
+
104
+ Returns:
105
+ JsonRPCClient instance or None if server unavailable
106
+ """
107
+ if language not in self._servers:
108
+ logger.warning(f"No LSP server configured for language: {language}")
109
+ return None
110
+
111
+ server = self._servers[language]
112
+
113
+ # Return existing initialized server
114
+ if server.initialized and server.client:
115
+ # Update last_used timestamp
116
+ server.last_used = time.time()
117
+ # Start health monitor on first use
118
+ if self._health_monitor_task is None or self._health_monitor_task.done():
119
+ self._health_monitor_task = asyncio.create_task(self._background_health_monitor())
120
+ return server.client
121
+
122
+ # Start server with lock to prevent race conditions
123
+ async with self._lock:
124
+ # Double-check after acquiring lock
125
+ if server.initialized and server.client:
126
+ server.last_used = time.time()
127
+ if self._health_monitor_task is None or self._health_monitor_task.done():
128
+ self._health_monitor_task = asyncio.create_task(self._background_health_monitor())
129
+ return server.client
130
+
131
+ try:
132
+ await self._start_server(server)
133
+ # Start health monitor on first server creation
134
+ if self._health_monitor_task is None or self._health_monitor_task.done():
135
+ self._health_monitor_task = asyncio.create_task(self._background_health_monitor())
136
+ return server.client
137
+ except Exception as e:
138
+ logger.error(f"Failed to start {language} LSP server: {e}")
139
+ return None
140
+
141
+ async def _start_server(self, server: LSPServer):
142
+ """
143
+ Start a persistent LSP server process.
144
+
145
+ Implements:
146
+ - Process health validation after start
147
+ - LSP initialization handshake
148
+ - GC protection via persistent reference
149
+ - Timestamp tracking for idle detection
150
+
151
+ Args:
152
+ server: LSPServer metadata object
153
+ """
154
+ try:
155
+ # Create pygls client
156
+ client = JsonRPCClient()
157
+
158
+ logger.info(f"Starting {server.name} LSP server: {' '.join(server.command)}")
159
+
160
+ # Start server process (start_io expects cmd as first arg, then *args)
161
+ await client.start_io(server.command[0], *server.command[1:])
162
+
163
+ # Brief delay for process startup
164
+ await asyncio.sleep(0.2)
165
+
166
+ # Capture subprocess from client
167
+ if not hasattr(client, "_server") or client._server is None:
168
+ raise ConnectionError(
169
+ f"{server.name} LSP server process not accessible after start_io()"
170
+ )
171
+
172
+ server.process = client._server
173
+ server.pid = server.process.pid
174
+ logger.debug(f"{server.name} LSP server started with PID: {server.pid}")
175
+
176
+ # Validate process is still running
177
+ if server.process.returncode is not None:
178
+ raise ConnectionError(
179
+ f"{server.name} LSP server exited immediately (code {server.process.returncode})"
180
+ )
181
+
182
+ # Perform LSP initialization handshake
183
+ init_params = InitializeParams(
184
+ process_id=None, root_uri=None, capabilities=ClientCapabilities()
185
+ )
186
+
187
+ try:
188
+ # Send initialize request via protocol
189
+ response = await asyncio.wait_for(
190
+ client.protocol.send_request_async("initialize", init_params), timeout=10.0
191
+ )
192
+
193
+ # Send initialized notification
194
+ client.protocol.notify("initialized", InitializedParams())
195
+
196
+ logger.info(f"{server.name} LSP server initialized: {response}")
197
+
198
+ except asyncio.TimeoutError:
199
+ raise ConnectionError(f"{server.name} LSP server initialization timed out")
200
+
201
+ # Store client reference (GC protection)
202
+ server.client = client
203
+ server.initialized = True
204
+ server.created_at = time.time()
205
+ server.last_used = time.time()
206
+
207
+ # Reset restart attempts on successful start
208
+ self._restart_attempts[server.name] = 0
209
+
210
+ logger.info(f"{server.name} LSP server started successfully")
211
+
212
+ except Exception as e:
213
+ logger.error(f"Failed to start {server.name} LSP server: {e}", exc_info=True)
214
+ # Cleanup on failure
215
+ if server.client:
216
+ try:
217
+ await server.client.stop()
218
+ except:
219
+ pass
220
+ server.client = None
221
+ server.initialized = False
222
+ server.process = None
223
+ server.pid = None
224
+ raise
225
+
226
+ async def _restart_with_backoff(self, server: LSPServer):
227
+ """
228
+ Restart a crashed LSP server with exponential backoff.
229
+
230
+ Strategy: delay = 2^attempt + jitter (max 60s)
231
+
232
+ Args:
233
+ server: LSPServer to restart
234
+ """
235
+ import random
236
+
237
+ attempt = self._restart_attempts.get(server.name, 0)
238
+ self._restart_attempts[server.name] = attempt + 1
239
+
240
+ # Exponential backoff with jitter (max 60s)
241
+ delay = min(60, (2**attempt) + random.uniform(0, 1))
242
+
243
+ logger.warning(
244
+ f"{server.name} LSP server crashed. Restarting in {delay:.2f}s (attempt {attempt + 1})"
245
+ )
246
+ await asyncio.sleep(delay)
247
+
248
+ # Reset state before restart
249
+ server.initialized = False
250
+ server.client = None
251
+ server.process = None
252
+ server.pid = None
253
+
254
+ try:
255
+ await self._start_server(server)
256
+ except Exception as e:
257
+ logger.error(f"Restart failed for {server.name}: {e}")
258
+
259
+ async def _health_check_server(self, server: LSPServer) -> bool:
260
+ """
261
+ Perform health check on an LSP server.
262
+
263
+ Args:
264
+ server: LSPServer to check
265
+
266
+ Returns:
267
+ True if server is healthy, False otherwise
268
+ """
269
+ if not server.initialized or not server.client:
270
+ return False
271
+
272
+ try:
273
+ # Simple health check: send initialize request
274
+ # Most servers respond to repeated initialize calls
275
+ init_params = InitializeParams(
276
+ process_id=None, root_uri=None, capabilities=ClientCapabilities()
277
+ )
278
+ response = await asyncio.wait_for(
279
+ server.client.protocol.send_request_async("initialize", init_params),
280
+ timeout=LSP_CONFIG["health_check_timeout"],
281
+ )
282
+ logger.debug(f"{server.name} LSP server health check passed")
283
+ return True
284
+ except asyncio.TimeoutError:
285
+ logger.warning(f"{server.name} LSP server health check timed out")
286
+ return False
287
+ except Exception as e:
288
+ logger.warning(f"{server.name} LSP server health check failed: {e}")
289
+ return False
290
+
291
+ async def _shutdown_single_server(self, name: str, server: LSPServer):
292
+ """
293
+ Gracefully shutdown a single LSP server.
294
+
295
+ Args:
296
+ name: Server name (key)
297
+ server: LSPServer instance
298
+ """
299
+ if not server.initialized or not server.client:
300
+ return
301
+
302
+ try:
303
+ logger.info(f"Shutting down {name} LSP server")
304
+
305
+ # LSP protocol shutdown request
306
+ try:
307
+ await asyncio.wait_for(
308
+ server.client.protocol.send_request_async("shutdown", None), timeout=5.0
309
+ )
310
+ except asyncio.TimeoutError:
311
+ logger.warning(f"{name} LSP server shutdown request timed out")
312
+
313
+ # Send exit notification
314
+ server.client.protocol.notify("exit", None)
315
+
316
+ # Stop the client
317
+ await server.client.stop()
318
+
319
+ # Terminate subprocess using stored process reference
320
+ if server.process is not None:
321
+ try:
322
+ if server.process.returncode is not None:
323
+ logger.debug(f"{name} already exited (code {server.process.returncode})")
324
+ else:
325
+ server.process.terminate()
326
+ try:
327
+ await asyncio.wait_for(server.process.wait(), timeout=2.0)
328
+ except asyncio.TimeoutError:
329
+ server.process.kill()
330
+ await asyncio.wait_for(server.process.wait(), timeout=1.0)
331
+ except Exception as e:
332
+ logger.warning(f"Error terminating {name}: {e}")
333
+
334
+ # Mark as uninitialized
335
+ server.initialized = False
336
+ server.client = None
337
+ server.process = None
338
+ server.pid = None
339
+
340
+ except Exception as e:
341
+ logger.error(f"Error shutting down {name} LSP server: {e}")
342
+
343
+ async def _background_health_monitor(self):
344
+ """
345
+ Background task for health checking and idle server shutdown.
346
+
347
+ Runs periodically to:
348
+ - Check health of running servers
349
+ - Shutdown idle servers
350
+ - Restart crashed servers
351
+ """
352
+ logger.info("LSP health monitor task started")
353
+ try:
354
+ while True:
355
+ await asyncio.sleep(LSP_CONFIG["health_check_interval"])
356
+
357
+ current_time = time.time()
358
+ idle_threshold = current_time - LSP_CONFIG["idle_timeout"]
359
+
360
+ async with self._lock:
361
+ for name, server in self._servers.items():
362
+ if not server.initialized or not server.client:
363
+ continue
364
+
365
+ # Check if server is idle
366
+ if server.last_used < idle_threshold:
367
+ logger.info(
368
+ f"{name} LSP server idle for {(current_time - server.last_used) / 60:.1f} minutes, shutting down"
369
+ )
370
+ await self._shutdown_single_server(name, server)
371
+ continue
372
+
373
+ # Health check for active servers
374
+ is_healthy = await self._health_check_server(server)
375
+ if not is_healthy:
376
+ logger.warning(f"{name} LSP server health check failed, restarting")
377
+ await self._shutdown_single_server(name, server)
378
+ try:
379
+ await self._start_server(server)
380
+ except Exception as e:
381
+ logger.error(f"Failed to restart {name} LSP server: {e}")
382
+
383
+ except asyncio.CancelledError:
384
+ logger.info("LSP health monitor task cancelled")
385
+ raise
386
+ except Exception as e:
387
+ logger.error(f"LSP health monitor task error: {e}", exc_info=True)
388
+
389
+ def get_status(self) -> dict:
390
+ """Get status of managed servers including idle information."""
391
+ current_time = time.time()
392
+ status = {}
393
+ for name, server in self._servers.items():
394
+ idle_seconds = current_time - server.last_used
395
+ uptime_seconds = current_time - server.created_at if server.created_at else 0
396
+ status[name] = {
397
+ "running": server.initialized and server.client is not None,
398
+ "pid": server.pid,
399
+ "command": " ".join(server.command),
400
+ "restarts": self._restart_attempts.get(name, 0),
401
+ "idle_seconds": idle_seconds,
402
+ "idle_minutes": idle_seconds / 60.0,
403
+ "uptime_seconds": uptime_seconds,
404
+ }
405
+ return status
406
+
407
+ async def shutdown(self):
408
+ """
409
+ Gracefully shutdown all LSP servers.
410
+
411
+ Implements:
412
+ - Health monitor cancellation
413
+ - LSP protocol shutdown (shutdown request + exit notification)
414
+ - Pending task cancellation
415
+ - Process cleanup with timeout
416
+ """
417
+ logger.info("Shutting down LSP manager...")
418
+
419
+ # Cancel health monitor task
420
+ if self._health_monitor_task and not self._health_monitor_task.done():
421
+ logger.info("Cancelling health monitor task")
422
+ self._health_monitor_task.cancel()
423
+ try:
424
+ await self._health_monitor_task
425
+ except asyncio.CancelledError:
426
+ pass
427
+
428
+ async with self._lock:
429
+ for name, server in self._servers.items():
430
+ await self._shutdown_single_server(name, server)
431
+
432
+ logger.info("LSP manager shutdown complete")
433
+
434
+
435
+ # Singleton accessor
436
+ _manager_instance: Optional[LSPManager] = None
437
+ _manager_lock = threading.Lock()
438
+
439
+
440
+ def get_lsp_manager() -> LSPManager:
441
+ """Get the global LSP manager singleton."""
442
+ global _manager_instance
443
+ if _manager_instance is None:
444
+ with _manager_lock:
445
+ # Double-check pattern to avoid race condition
446
+ if _manager_instance is None:
447
+ _manager_instance = LSPManager()
448
+ return _manager_instance