stravinsky 0.2.67__py3-none-any.whl → 0.4.66__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.

Potentially problematic release.


This version of stravinsky might be problematic. Click here for more details.

Files changed (190) hide show
  1. mcp_bridge/__init__.py +1 -1
  2. mcp_bridge/auth/__init__.py +16 -6
  3. mcp_bridge/auth/cli.py +202 -11
  4. mcp_bridge/auth/oauth.py +1 -2
  5. mcp_bridge/auth/openai_oauth.py +4 -7
  6. mcp_bridge/auth/token_store.py +112 -11
  7. mcp_bridge/cli/__init__.py +1 -1
  8. mcp_bridge/cli/install_hooks.py +503 -107
  9. mcp_bridge/cli/session_report.py +0 -3
  10. mcp_bridge/config/MANIFEST_SCHEMA.md +305 -0
  11. mcp_bridge/config/README.md +276 -0
  12. mcp_bridge/config/__init__.py +2 -2
  13. mcp_bridge/config/hook_config.py +247 -0
  14. mcp_bridge/config/hooks_manifest.json +138 -0
  15. mcp_bridge/config/rate_limits.py +317 -0
  16. mcp_bridge/config/skills_manifest.json +128 -0
  17. mcp_bridge/hooks/HOOKS_SETTINGS.json +17 -4
  18. mcp_bridge/hooks/__init__.py +19 -4
  19. mcp_bridge/hooks/agent_reminder.py +4 -4
  20. mcp_bridge/hooks/auto_slash_command.py +5 -5
  21. mcp_bridge/hooks/budget_optimizer.py +2 -2
  22. mcp_bridge/hooks/claude_limits_hook.py +114 -0
  23. mcp_bridge/hooks/comment_checker.py +3 -4
  24. mcp_bridge/hooks/compaction.py +2 -2
  25. mcp_bridge/hooks/context.py +2 -1
  26. mcp_bridge/hooks/context_monitor.py +2 -2
  27. mcp_bridge/hooks/delegation_policy.py +85 -0
  28. mcp_bridge/hooks/directory_context.py +3 -3
  29. mcp_bridge/hooks/edit_recovery.py +3 -2
  30. mcp_bridge/hooks/edit_recovery_policy.py +49 -0
  31. mcp_bridge/hooks/empty_message_sanitizer.py +2 -2
  32. mcp_bridge/hooks/events.py +160 -0
  33. mcp_bridge/hooks/git_noninteractive.py +4 -4
  34. mcp_bridge/hooks/keyword_detector.py +8 -10
  35. mcp_bridge/hooks/manager.py +43 -22
  36. mcp_bridge/hooks/notification_hook.py +13 -6
  37. mcp_bridge/hooks/parallel_enforcement_policy.py +67 -0
  38. mcp_bridge/hooks/parallel_enforcer.py +5 -5
  39. mcp_bridge/hooks/parallel_execution.py +22 -10
  40. mcp_bridge/hooks/post_tool/parallel_validation.py +103 -0
  41. mcp_bridge/hooks/pre_compact.py +8 -9
  42. mcp_bridge/hooks/pre_tool/agent_spawn_validator.py +115 -0
  43. mcp_bridge/hooks/preemptive_compaction.py +2 -3
  44. mcp_bridge/hooks/routing_notifications.py +80 -0
  45. mcp_bridge/hooks/rules_injector.py +11 -19
  46. mcp_bridge/hooks/session_idle.py +4 -4
  47. mcp_bridge/hooks/session_notifier.py +4 -4
  48. mcp_bridge/hooks/session_recovery.py +4 -5
  49. mcp_bridge/hooks/stravinsky_mode.py +1 -1
  50. mcp_bridge/hooks/subagent_stop.py +1 -3
  51. mcp_bridge/hooks/task_validator.py +2 -2
  52. mcp_bridge/hooks/tmux_manager.py +7 -8
  53. mcp_bridge/hooks/todo_delegation.py +4 -1
  54. mcp_bridge/hooks/todo_enforcer.py +180 -10
  55. mcp_bridge/hooks/tool_messaging.py +113 -10
  56. mcp_bridge/hooks/truncation_policy.py +37 -0
  57. mcp_bridge/hooks/truncator.py +1 -2
  58. mcp_bridge/metrics/cost_tracker.py +115 -0
  59. mcp_bridge/native_search.py +93 -0
  60. mcp_bridge/native_watcher.py +118 -0
  61. mcp_bridge/notifications.py +150 -0
  62. mcp_bridge/orchestrator/enums.py +11 -0
  63. mcp_bridge/orchestrator/router.py +165 -0
  64. mcp_bridge/orchestrator/state.py +32 -0
  65. mcp_bridge/orchestrator/visualization.py +14 -0
  66. mcp_bridge/orchestrator/wisdom.py +34 -0
  67. mcp_bridge/prompts/__init__.py +1 -8
  68. mcp_bridge/prompts/dewey.py +1 -1
  69. mcp_bridge/prompts/planner.py +2 -4
  70. mcp_bridge/prompts/stravinsky.py +53 -31
  71. mcp_bridge/proxy/__init__.py +0 -0
  72. mcp_bridge/proxy/client.py +70 -0
  73. mcp_bridge/proxy/model_server.py +157 -0
  74. mcp_bridge/routing/__init__.py +43 -0
  75. mcp_bridge/routing/config.py +250 -0
  76. mcp_bridge/routing/model_tiers.py +135 -0
  77. mcp_bridge/routing/provider_state.py +261 -0
  78. mcp_bridge/routing/task_classifier.py +190 -0
  79. mcp_bridge/server.py +542 -59
  80. mcp_bridge/server_tools.py +738 -6
  81. mcp_bridge/tools/__init__.py +40 -25
  82. mcp_bridge/tools/agent_manager.py +616 -697
  83. mcp_bridge/tools/background_tasks.py +13 -17
  84. mcp_bridge/tools/code_search.py +70 -53
  85. mcp_bridge/tools/continuous_loop.py +0 -1
  86. mcp_bridge/tools/dashboard.py +19 -0
  87. mcp_bridge/tools/find_code.py +296 -0
  88. mcp_bridge/tools/init.py +1 -0
  89. mcp_bridge/tools/list_directory.py +42 -0
  90. mcp_bridge/tools/lsp/__init__.py +12 -5
  91. mcp_bridge/tools/lsp/manager.py +471 -0
  92. mcp_bridge/tools/lsp/tools.py +723 -207
  93. mcp_bridge/tools/model_invoke.py +1195 -273
  94. mcp_bridge/tools/mux_client.py +75 -0
  95. mcp_bridge/tools/project_context.py +1 -2
  96. mcp_bridge/tools/query_classifier.py +406 -0
  97. mcp_bridge/tools/read_file.py +84 -0
  98. mcp_bridge/tools/replace.py +45 -0
  99. mcp_bridge/tools/run_shell_command.py +38 -0
  100. mcp_bridge/tools/search_enhancements.py +347 -0
  101. mcp_bridge/tools/semantic_search.py +3627 -0
  102. mcp_bridge/tools/session_manager.py +0 -2
  103. mcp_bridge/tools/skill_loader.py +0 -1
  104. mcp_bridge/tools/task_runner.py +5 -7
  105. mcp_bridge/tools/templates.py +3 -3
  106. mcp_bridge/tools/tool_search.py +331 -0
  107. mcp_bridge/tools/write_file.py +29 -0
  108. mcp_bridge/update_manager.py +585 -0
  109. mcp_bridge/update_manager_pypi.py +297 -0
  110. mcp_bridge/utils/cache.py +82 -0
  111. mcp_bridge/utils/process.py +71 -0
  112. mcp_bridge/utils/session_state.py +51 -0
  113. mcp_bridge/utils/truncation.py +76 -0
  114. stravinsky-0.4.66.dist-info/METADATA +517 -0
  115. stravinsky-0.4.66.dist-info/RECORD +198 -0
  116. {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/entry_points.txt +1 -0
  117. stravinsky_claude_assets/HOOKS_INTEGRATION.md +316 -0
  118. stravinsky_claude_assets/agents/HOOKS.md +437 -0
  119. stravinsky_claude_assets/agents/code-reviewer.md +210 -0
  120. stravinsky_claude_assets/agents/comment_checker.md +580 -0
  121. stravinsky_claude_assets/agents/debugger.md +254 -0
  122. stravinsky_claude_assets/agents/delphi.md +495 -0
  123. stravinsky_claude_assets/agents/dewey.md +248 -0
  124. stravinsky_claude_assets/agents/explore.md +1198 -0
  125. stravinsky_claude_assets/agents/frontend.md +472 -0
  126. stravinsky_claude_assets/agents/implementation-lead.md +164 -0
  127. stravinsky_claude_assets/agents/momus.md +464 -0
  128. stravinsky_claude_assets/agents/research-lead.md +141 -0
  129. stravinsky_claude_assets/agents/stravinsky.md +730 -0
  130. stravinsky_claude_assets/commands/delphi.md +9 -0
  131. stravinsky_claude_assets/commands/dewey.md +54 -0
  132. stravinsky_claude_assets/commands/git-master.md +112 -0
  133. stravinsky_claude_assets/commands/index.md +49 -0
  134. stravinsky_claude_assets/commands/publish.md +86 -0
  135. stravinsky_claude_assets/commands/review.md +73 -0
  136. stravinsky_claude_assets/commands/str/agent_cancel.md +70 -0
  137. stravinsky_claude_assets/commands/str/agent_list.md +56 -0
  138. stravinsky_claude_assets/commands/str/agent_output.md +92 -0
  139. stravinsky_claude_assets/commands/str/agent_progress.md +74 -0
  140. stravinsky_claude_assets/commands/str/agent_retry.md +94 -0
  141. stravinsky_claude_assets/commands/str/cancel.md +51 -0
  142. stravinsky_claude_assets/commands/str/clean.md +97 -0
  143. stravinsky_claude_assets/commands/str/continue.md +38 -0
  144. stravinsky_claude_assets/commands/str/index.md +199 -0
  145. stravinsky_claude_assets/commands/str/list_watchers.md +96 -0
  146. stravinsky_claude_assets/commands/str/search.md +205 -0
  147. stravinsky_claude_assets/commands/str/start_filewatch.md +136 -0
  148. stravinsky_claude_assets/commands/str/stats.md +71 -0
  149. stravinsky_claude_assets/commands/str/stop_filewatch.md +89 -0
  150. stravinsky_claude_assets/commands/str/unwatch.md +42 -0
  151. stravinsky_claude_assets/commands/str/watch.md +45 -0
  152. stravinsky_claude_assets/commands/strav.md +53 -0
  153. stravinsky_claude_assets/commands/stravinsky.md +292 -0
  154. stravinsky_claude_assets/commands/verify.md +60 -0
  155. stravinsky_claude_assets/commands/version.md +5 -0
  156. stravinsky_claude_assets/hooks/README.md +248 -0
  157. stravinsky_claude_assets/hooks/comment_checker.py +193 -0
  158. stravinsky_claude_assets/hooks/context.py +38 -0
  159. stravinsky_claude_assets/hooks/context_monitor.py +153 -0
  160. stravinsky_claude_assets/hooks/dependency_tracker.py +73 -0
  161. stravinsky_claude_assets/hooks/edit_recovery.py +46 -0
  162. stravinsky_claude_assets/hooks/execution_state_tracker.py +68 -0
  163. stravinsky_claude_assets/hooks/notification_hook.py +103 -0
  164. stravinsky_claude_assets/hooks/notification_hook_v2.py +96 -0
  165. stravinsky_claude_assets/hooks/parallel_execution.py +241 -0
  166. stravinsky_claude_assets/hooks/parallel_reinforcement.py +106 -0
  167. stravinsky_claude_assets/hooks/parallel_reinforcement_v2.py +112 -0
  168. stravinsky_claude_assets/hooks/pre_compact.py +123 -0
  169. stravinsky_claude_assets/hooks/ralph_loop.py +173 -0
  170. stravinsky_claude_assets/hooks/session_recovery.py +263 -0
  171. stravinsky_claude_assets/hooks/stop_hook.py +89 -0
  172. stravinsky_claude_assets/hooks/stravinsky_metrics.py +164 -0
  173. stravinsky_claude_assets/hooks/stravinsky_mode.py +146 -0
  174. stravinsky_claude_assets/hooks/subagent_stop.py +98 -0
  175. stravinsky_claude_assets/hooks/todo_continuation.py +111 -0
  176. stravinsky_claude_assets/hooks/todo_delegation.py +96 -0
  177. stravinsky_claude_assets/hooks/tool_messaging.py +281 -0
  178. stravinsky_claude_assets/hooks/truncator.py +23 -0
  179. stravinsky_claude_assets/rules/deployment_safety.md +51 -0
  180. stravinsky_claude_assets/rules/integration_wiring.md +89 -0
  181. stravinsky_claude_assets/rules/pypi_deployment.md +220 -0
  182. stravinsky_claude_assets/rules/stravinsky_orchestrator.md +32 -0
  183. stravinsky_claude_assets/settings.json +152 -0
  184. stravinsky_claude_assets/skills/chrome-devtools/SKILL.md +81 -0
  185. stravinsky_claude_assets/skills/sqlite/SKILL.md +77 -0
  186. stravinsky_claude_assets/skills/supabase/SKILL.md +74 -0
  187. stravinsky_claude_assets/task_dependencies.json +34 -0
  188. stravinsky-0.2.67.dist-info/METADATA +0 -284
  189. stravinsky-0.2.67.dist-info/RECORD +0 -76
  190. {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/WHEEL +0 -0
@@ -0,0 +1,42 @@
1
+ import os
2
+ from pathlib import Path
3
+ from mcp_bridge.utils.cache import IOCache
4
+
5
+ async def list_directory(path: str) -> str:
6
+ """
7
+ List files and directories in a path with caching.
8
+ """
9
+ # USER-VISIBLE NOTIFICATION
10
+ import sys
11
+ print(f"📂 LIST: {path}", file=sys.stderr)
12
+
13
+ cache = IOCache.get_instance()
14
+ cache_key = f"list_dir:{os.path.realpath(path)}"
15
+
16
+ cached_result = cache.get(cache_key)
17
+ if cached_result:
18
+ return cached_result
19
+
20
+ dir_path = Path(path)
21
+ if not dir_path.exists():
22
+ return f"Error: Directory not found: {path}"
23
+
24
+ if not dir_path.is_dir():
25
+ return f"Error: Path is not a directory: {path}"
26
+
27
+ try:
28
+ entries = []
29
+ # Sort for deterministic output
30
+ for entry in sorted(dir_path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
31
+ entry_type = "DIR" if entry.is_dir() else "FILE"
32
+ entries.append(f"[{entry_type}] {entry.name}")
33
+
34
+ result = "\n".join(entries) if entries else "(empty directory)"
35
+
36
+ # Cache for 5 seconds
37
+ cache.set(cache_key, result)
38
+
39
+ return result
40
+
41
+ except Exception as e:
42
+ return f"Error listing directory {path}: {str(e)}"
@@ -4,16 +4,19 @@ LSP Tools Package
4
4
  Provides Language Server Protocol functionality for code intelligence.
5
5
  """
6
6
 
7
+ from .manager import LSPManager, get_lsp_manager
7
8
  from .tools import (
8
- lsp_hover,
9
- lsp_goto_definition,
10
- lsp_find_references,
9
+ lsp_code_action_resolve,
10
+ lsp_code_actions,
11
11
  lsp_document_symbols,
12
- lsp_workspace_symbols,
12
+ lsp_extract_refactor,
13
+ lsp_find_references,
14
+ lsp_goto_definition,
15
+ lsp_hover,
13
16
  lsp_prepare_rename,
14
17
  lsp_rename,
15
- lsp_code_actions,
16
18
  lsp_servers,
19
+ lsp_workspace_symbols,
17
20
  )
18
21
 
19
22
  __all__ = [
@@ -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,471 @@
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 logging
17
+ import os
18
+ import threading
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from typing import Optional
22
+
23
+ from lsprotocol.types import (
24
+ ClientCapabilities,
25
+ InitializedParams,
26
+ InitializeParams,
27
+ )
28
+ from pygls.client import JsonRPCClient
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Configuration for LSP server lifecycle management
33
+ LSP_CONFIG = {
34
+ "idle_timeout": 1800, # 30 minutes
35
+ "health_check_interval": 300, # 5 minutes
36
+ "health_check_timeout": 5.0,
37
+ }
38
+
39
+
40
+ @dataclass
41
+ class LSPServer:
42
+ """Metadata for a persistent LSP server."""
43
+
44
+ name: str
45
+ command: list[str]
46
+ client: JsonRPCClient | None = None
47
+ initialized: bool = False
48
+ process: asyncio.subprocess.Process | None = None
49
+ pid: int | None = None # Track subprocess PID for explicit cleanup
50
+ root_path: str | None = None # Track root path server was started with
51
+ last_used: float = field(default_factory=time.time) # Timestamp of last usage
52
+ created_at: float = field(default_factory=time.time) # Timestamp of server creation
53
+
54
+
55
+ class LSPManager:
56
+ """
57
+ Singleton manager for persistent LSP servers.
58
+
59
+ Implements:
60
+ - Lazy server initialization (start on first use)
61
+ - Process lifecycle management with GC protection
62
+ - Exponential backoff for crash recovery
63
+ - Graceful shutdown with signal handling
64
+ - Health checks and idle server shutdown
65
+ """
66
+
67
+ _instance: Optional["LSPManager"] = None
68
+
69
+ def __new__(cls):
70
+ if cls._instance is None:
71
+ cls._instance = super().__new__(cls)
72
+ return cls._instance
73
+
74
+ def __init__(self):
75
+ if hasattr(self, "_initialized"):
76
+ return
77
+ self._initialized = True
78
+ self._servers: dict[str, LSPServer] = {}
79
+ self._lock = asyncio.Lock()
80
+ self._restart_attempts: dict[str, int] = {}
81
+ self._health_monitor_task: asyncio.Task | None = None
82
+
83
+ # Register available LSP servers
84
+ self._register_servers()
85
+
86
+ def _register_servers(self):
87
+ """Register available LSP server configurations."""
88
+ # Allow overriding commands via environment variables
89
+ python_cmd = os.environ.get("LSP_CMD_PYTHON", "jedi-language-server").split()
90
+ ts_cmd = os.environ.get(
91
+ "LSP_CMD_TYPESCRIPT", "typescript-language-server --stdio"
92
+ ).split()
93
+
94
+ self._servers["python"] = LSPServer(name="python", command=python_cmd)
95
+ self._servers["typescript"] = LSPServer(name="typescript", command=ts_cmd)
96
+
97
+ async def get_server(
98
+ self, language: str, root_path: str | None = None
99
+ ) -> JsonRPCClient | None:
100
+ """
101
+ Get or start a persistent LSP server for the given language.
102
+
103
+ Args:
104
+ language: Language identifier (e.g., "python", "typescript")
105
+ root_path: Project root path (optional, but recommended)
106
+
107
+ Returns:
108
+ JsonRPCClient instance or None if server unavailable
109
+ """
110
+ if language not in self._servers:
111
+ logger.warning(f"No LSP server configured for language: {language}")
112
+ return None
113
+
114
+ server = self._servers[language]
115
+
116
+ # Check if we need to restart due to root path change
117
+ # (Simple implementation: if root_path differs, restart)
118
+ # In multi-root workspaces, this might be too aggressive, but safe for now.
119
+ restart_needed = False
120
+ if root_path and server.root_path and root_path != server.root_path:
121
+ logger.info(
122
+ f"Restarting {language} LSP server: root path changed ({server.root_path} -> {root_path})"
123
+ )
124
+ restart_needed = True
125
+
126
+ if restart_needed:
127
+ async with self._lock:
128
+ await self._shutdown_single_server(language, server)
129
+
130
+ # Return existing initialized server
131
+ if server.initialized and server.client:
132
+ # Update last_used timestamp
133
+ server.last_used = time.time()
134
+ # Start health monitor on first use
135
+ if self._health_monitor_task is None or self._health_monitor_task.done():
136
+ self._health_monitor_task = asyncio.create_task(self._background_health_monitor())
137
+ return server.client
138
+
139
+ # Start server with lock to prevent race conditions
140
+ async with self._lock:
141
+ # Double-check after acquiring lock
142
+ if server.initialized and server.client:
143
+ server.last_used = time.time()
144
+ if self._health_monitor_task is None or self._health_monitor_task.done():
145
+ self._health_monitor_task = asyncio.create_task(self._background_health_monitor())
146
+ return server.client
147
+
148
+ try:
149
+ await self._start_server(server, root_path)
150
+ # Start health monitor on first server creation
151
+ if self._health_monitor_task is None or self._health_monitor_task.done():
152
+ self._health_monitor_task = asyncio.create_task(self._background_health_monitor())
153
+ return server.client
154
+ except Exception as e:
155
+ logger.error(f"Failed to start {language} LSP server: {e}")
156
+ return None
157
+
158
+ async def _start_server(self, server: LSPServer, root_path: str | None = None):
159
+ """
160
+ Start a persistent LSP server process.
161
+
162
+ Implements:
163
+ - Process health validation after start
164
+ - LSP initialization handshake
165
+ - GC protection via persistent reference
166
+ - Timestamp tracking for idle detection
167
+
168
+ Args:
169
+ server: LSPServer metadata object
170
+ root_path: Project root path
171
+ """
172
+ try:
173
+ # Create pygls client
174
+ client = JsonRPCClient()
175
+
176
+ logger.info(f"Starting {server.name} LSP server: {' '.join(server.command)}")
177
+
178
+ # Start server process (start_io expects cmd as first arg, then *args)
179
+ # Use cwd=root_path if available to help server find config
180
+ cwd = root_path if root_path and os.path.isdir(root_path) else None
181
+ await client.start_io(server.command[0], *server.command[1:], cwd=cwd)
182
+
183
+ # Brief delay for process startup
184
+ await asyncio.sleep(0.2)
185
+
186
+ # Capture subprocess from client
187
+ if not hasattr(client, "_server") or client._server is None:
188
+ raise ConnectionError(
189
+ f"{server.name} LSP server process not accessible after start_io()"
190
+ )
191
+
192
+ server.process = client._server
193
+ server.pid = server.process.pid
194
+ logger.debug(f"{server.name} LSP server started with PID: {server.pid}")
195
+
196
+ # Validate process is still running
197
+ if server.process.returncode is not None:
198
+ raise ConnectionError(
199
+ f"{server.name} LSP server exited immediately (code {server.process.returncode})"
200
+ )
201
+
202
+ # Perform LSP initialization handshake
203
+ root_uri = f"file://{root_path}" if root_path else None
204
+ init_params = InitializeParams(
205
+ process_id=None, root_uri=root_uri, capabilities=ClientCapabilities()
206
+ )
207
+
208
+ try:
209
+ # Send initialize request via protocol
210
+ response = await asyncio.wait_for(
211
+ client.protocol.send_request_async("initialize", init_params), timeout=10.0
212
+ )
213
+
214
+ # Send initialized notification
215
+ client.protocol.notify("initialized", InitializedParams())
216
+
217
+ logger.info(f"{server.name} LSP server initialized: {response}")
218
+
219
+ except TimeoutError:
220
+ raise ConnectionError(f"{server.name} LSP server initialization timed out")
221
+
222
+ # Store client reference (GC protection)
223
+ server.client = client
224
+ server.initialized = True
225
+ server.root_path = root_path
226
+ server.created_at = time.time()
227
+ server.last_used = time.time()
228
+
229
+ # Reset restart attempts on successful start
230
+ self._restart_attempts[server.name] = 0
231
+
232
+ logger.info(f"{server.name} LSP server started successfully")
233
+
234
+ except Exception as e:
235
+ logger.error(f"Failed to start {server.name} LSP server: {e}", exc_info=True)
236
+ # Cleanup on failure
237
+ if server.client:
238
+ try:
239
+ await server.client.stop()
240
+ except:
241
+ pass
242
+ server.client = None
243
+ server.initialized = False
244
+ server.process = None
245
+ server.pid = None
246
+ server.root_path = None
247
+ raise
248
+
249
+ async def _restart_with_backoff(self, server: LSPServer):
250
+ """
251
+ Restart a crashed LSP server with exponential backoff.
252
+
253
+ Strategy: delay = 2^attempt + jitter (max 60s)
254
+
255
+ Args:
256
+ server: LSPServer to restart
257
+ """
258
+ import random
259
+
260
+ attempt = self._restart_attempts.get(server.name, 0)
261
+ self._restart_attempts[server.name] = attempt + 1
262
+
263
+ # Exponential backoff with jitter (max 60s)
264
+ delay = min(60, (2**attempt) + random.uniform(0, 1))
265
+
266
+ logger.warning(
267
+ f"{server.name} LSP server crashed. Restarting in {delay:.2f}s (attempt {attempt + 1})"
268
+ )
269
+ await asyncio.sleep(delay)
270
+
271
+ # Reset state before restart
272
+ server.initialized = False
273
+ server.client = None
274
+ server.process = None
275
+ server.pid = None
276
+
277
+ try:
278
+ await self._start_server(server)
279
+ except Exception as e:
280
+ logger.error(f"Restart failed for {server.name}: {e}")
281
+
282
+ async def _health_check_server(self, server: LSPServer) -> bool:
283
+ """
284
+ Perform health check on an LSP server.
285
+
286
+ Args:
287
+ server: LSPServer to check
288
+
289
+ Returns:
290
+ True if server is healthy, False otherwise
291
+ """
292
+ if not server.initialized or not server.client:
293
+ return False
294
+
295
+ try:
296
+ # Simple health check: send initialize request
297
+ # Most servers respond to repeated initialize calls
298
+ init_params = InitializeParams(
299
+ process_id=None, root_uri=None, capabilities=ClientCapabilities()
300
+ )
301
+ response = await asyncio.wait_for(
302
+ server.client.protocol.send_request_async("initialize", init_params),
303
+ timeout=LSP_CONFIG["health_check_timeout"],
304
+ )
305
+ logger.debug(f"{server.name} LSP server health check passed")
306
+ return True
307
+ except TimeoutError:
308
+ logger.warning(f"{server.name} LSP server health check timed out")
309
+ return False
310
+ except Exception as e:
311
+ logger.warning(f"{server.name} LSP server health check failed: {e}")
312
+ return False
313
+
314
+ async def _shutdown_single_server(self, name: str, server: LSPServer):
315
+ """
316
+ Gracefully shutdown a single LSP server.
317
+
318
+ Args:
319
+ name: Server name (key)
320
+ server: LSPServer instance
321
+ """
322
+ if not server.initialized or not server.client:
323
+ return
324
+
325
+ try:
326
+ logger.info(f"Shutting down {name} LSP server")
327
+
328
+ # LSP protocol shutdown request
329
+ try:
330
+ await asyncio.wait_for(
331
+ server.client.protocol.send_request_async("shutdown", None), timeout=5.0
332
+ )
333
+ except TimeoutError:
334
+ logger.warning(f"{name} LSP server shutdown request timed out")
335
+
336
+ # Send exit notification
337
+ server.client.protocol.notify("exit", None)
338
+
339
+ # Stop the client
340
+ await server.client.stop()
341
+
342
+ # Terminate subprocess using stored process reference
343
+ if server.process is not None:
344
+ try:
345
+ if server.process.returncode is not None:
346
+ logger.debug(f"{name} already exited (code {server.process.returncode})")
347
+ else:
348
+ server.process.terminate()
349
+ try:
350
+ await asyncio.wait_for(server.process.wait(), timeout=2.0)
351
+ except TimeoutError:
352
+ server.process.kill()
353
+ await asyncio.wait_for(server.process.wait(), timeout=1.0)
354
+ except Exception as e:
355
+ logger.warning(f"Error terminating {name}: {e}")
356
+
357
+ # Mark as uninitialized
358
+ server.initialized = False
359
+ server.client = None
360
+ server.process = None
361
+ server.pid = None
362
+
363
+ except Exception as e:
364
+ logger.error(f"Error shutting down {name} LSP server: {e}")
365
+
366
+ async def _background_health_monitor(self):
367
+ """
368
+ Background task for health checking and idle server shutdown.
369
+
370
+ Runs periodically to:
371
+ - Check health of running servers
372
+ - Shutdown idle servers
373
+ - Restart crashed servers
374
+ """
375
+ logger.info("LSP health monitor task started")
376
+ try:
377
+ while True:
378
+ await asyncio.sleep(LSP_CONFIG["health_check_interval"])
379
+
380
+ current_time = time.time()
381
+ idle_threshold = current_time - LSP_CONFIG["idle_timeout"]
382
+
383
+ async with self._lock:
384
+ for name, server in self._servers.items():
385
+ if not server.initialized or not server.client:
386
+ continue
387
+
388
+ # Check if server is idle
389
+ if server.last_used < idle_threshold:
390
+ logger.info(
391
+ f"{name} LSP server idle for {(current_time - server.last_used) / 60:.1f} minutes, shutting down"
392
+ )
393
+ await self._shutdown_single_server(name, server)
394
+ continue
395
+
396
+ # Health check for active servers
397
+ is_healthy = await self._health_check_server(server)
398
+ if not is_healthy:
399
+ logger.warning(f"{name} LSP server health check failed, restarting")
400
+ await self._shutdown_single_server(name, server)
401
+ try:
402
+ await self._start_server(server)
403
+ except Exception as e:
404
+ logger.error(f"Failed to restart {name} LSP server: {e}")
405
+
406
+ except asyncio.CancelledError:
407
+ logger.info("LSP health monitor task cancelled")
408
+ raise
409
+ except Exception as e:
410
+ logger.error(f"LSP health monitor task error: {e}", exc_info=True)
411
+
412
+ def get_status(self) -> dict:
413
+ """Get status of managed servers including idle information."""
414
+ current_time = time.time()
415
+ status = {}
416
+ for name, server in self._servers.items():
417
+ idle_seconds = current_time - server.last_used
418
+ uptime_seconds = current_time - server.created_at if server.created_at else 0
419
+ status[name] = {
420
+ "running": server.initialized and server.client is not None,
421
+ "pid": server.pid,
422
+ "command": " ".join(server.command),
423
+ "restarts": self._restart_attempts.get(name, 0),
424
+ "idle_seconds": idle_seconds,
425
+ "idle_minutes": idle_seconds / 60.0,
426
+ "uptime_seconds": uptime_seconds,
427
+ }
428
+ return status
429
+
430
+ async def shutdown(self):
431
+ """
432
+ Gracefully shutdown all LSP servers.
433
+
434
+ Implements:
435
+ - Health monitor cancellation
436
+ - LSP protocol shutdown (shutdown request + exit notification)
437
+ - Pending task cancellation
438
+ - Process cleanup with timeout
439
+ """
440
+ logger.info("Shutting down LSP manager...")
441
+
442
+ # Cancel health monitor task
443
+ if self._health_monitor_task and not self._health_monitor_task.done():
444
+ logger.info("Cancelling health monitor task")
445
+ self._health_monitor_task.cancel()
446
+ try:
447
+ await self._health_monitor_task
448
+ except asyncio.CancelledError:
449
+ pass
450
+
451
+ async with self._lock:
452
+ for name, server in self._servers.items():
453
+ await self._shutdown_single_server(name, server)
454
+
455
+ logger.info("LSP manager shutdown complete")
456
+
457
+
458
+ # Singleton accessor
459
+ _manager_instance: LSPManager | None = None
460
+ _manager_lock = threading.Lock()
461
+
462
+
463
+ def get_lsp_manager() -> LSPManager:
464
+ """Get the global LSP manager singleton."""
465
+ global _manager_instance
466
+ if _manager_instance is None:
467
+ with _manager_lock:
468
+ # Double-check pattern to avoid race condition
469
+ if _manager_instance is None:
470
+ _manager_instance = LSPManager()
471
+ return _manager_instance