agentpool 2.1.9__py3-none-any.whl → 2.2.3__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 (174) hide show
  1. acp/__init__.py +13 -0
  2. acp/bridge/README.md +15 -2
  3. acp/bridge/__init__.py +3 -2
  4. acp/bridge/__main__.py +60 -19
  5. acp/bridge/ws_server.py +173 -0
  6. acp/bridge/ws_server_cli.py +89 -0
  7. acp/notifications.py +2 -1
  8. acp/stdio.py +39 -9
  9. acp/transports.py +362 -2
  10. acp/utils.py +15 -2
  11. agentpool/__init__.py +4 -1
  12. agentpool/agents/__init__.py +2 -0
  13. agentpool/agents/acp_agent/acp_agent.py +203 -88
  14. agentpool/agents/acp_agent/acp_converters.py +46 -21
  15. agentpool/agents/acp_agent/client_handler.py +157 -3
  16. agentpool/agents/acp_agent/session_state.py +4 -1
  17. agentpool/agents/agent.py +314 -107
  18. agentpool/agents/agui_agent/__init__.py +0 -2
  19. agentpool/agents/agui_agent/agui_agent.py +90 -21
  20. agentpool/agents/agui_agent/agui_converters.py +0 -131
  21. agentpool/agents/base_agent.py +163 -1
  22. agentpool/agents/claude_code_agent/claude_code_agent.py +626 -179
  23. agentpool/agents/claude_code_agent/converters.py +71 -3
  24. agentpool/agents/claude_code_agent/history.py +474 -0
  25. agentpool/agents/context.py +40 -0
  26. agentpool/agents/events/__init__.py +2 -0
  27. agentpool/agents/events/builtin_handlers.py +2 -1
  28. agentpool/agents/events/event_emitter.py +29 -2
  29. agentpool/agents/events/events.py +20 -0
  30. agentpool/agents/modes.py +54 -0
  31. agentpool/agents/tool_call_accumulator.py +213 -0
  32. agentpool/common_types.py +21 -0
  33. agentpool/config_resources/__init__.py +38 -1
  34. agentpool/config_resources/claude_code_agent.yml +3 -0
  35. agentpool/delegation/pool.py +37 -29
  36. agentpool/delegation/team.py +1 -0
  37. agentpool/delegation/teamrun.py +1 -0
  38. agentpool/diagnostics/__init__.py +53 -0
  39. agentpool/diagnostics/lsp_manager.py +1593 -0
  40. agentpool/diagnostics/lsp_proxy.py +41 -0
  41. agentpool/diagnostics/lsp_proxy_script.py +229 -0
  42. agentpool/diagnostics/models.py +398 -0
  43. agentpool/mcp_server/__init__.py +0 -2
  44. agentpool/mcp_server/client.py +12 -3
  45. agentpool/mcp_server/manager.py +25 -31
  46. agentpool/mcp_server/registries/official_registry_client.py +25 -0
  47. agentpool/mcp_server/tool_bridge.py +78 -66
  48. agentpool/messaging/__init__.py +0 -2
  49. agentpool/messaging/compaction.py +72 -197
  50. agentpool/messaging/message_history.py +12 -0
  51. agentpool/messaging/messages.py +52 -9
  52. agentpool/messaging/processing.py +3 -1
  53. agentpool/models/acp_agents/base.py +0 -22
  54. agentpool/models/acp_agents/mcp_capable.py +8 -148
  55. agentpool/models/acp_agents/non_mcp.py +129 -72
  56. agentpool/models/agents.py +35 -13
  57. agentpool/models/claude_code_agents.py +33 -2
  58. agentpool/models/manifest.py +43 -0
  59. agentpool/repomap.py +1 -1
  60. agentpool/resource_providers/__init__.py +9 -1
  61. agentpool/resource_providers/aggregating.py +52 -3
  62. agentpool/resource_providers/base.py +57 -1
  63. agentpool/resource_providers/mcp_provider.py +23 -0
  64. agentpool/resource_providers/plan_provider.py +130 -41
  65. agentpool/resource_providers/pool.py +2 -0
  66. agentpool/resource_providers/static.py +2 -0
  67. agentpool/sessions/__init__.py +2 -1
  68. agentpool/sessions/manager.py +31 -2
  69. agentpool/sessions/models.py +50 -0
  70. agentpool/skills/registry.py +13 -8
  71. agentpool/storage/manager.py +217 -1
  72. agentpool/testing.py +537 -19
  73. agentpool/utils/file_watcher.py +269 -0
  74. agentpool/utils/identifiers.py +121 -0
  75. agentpool/utils/pydantic_ai_helpers.py +46 -0
  76. agentpool/utils/streams.py +690 -1
  77. agentpool/utils/subprocess_utils.py +155 -0
  78. agentpool/utils/token_breakdown.py +461 -0
  79. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/METADATA +27 -7
  80. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/RECORD +170 -112
  81. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/WHEEL +1 -1
  82. agentpool_cli/__main__.py +4 -0
  83. agentpool_cli/serve_acp.py +41 -20
  84. agentpool_cli/serve_agui.py +87 -0
  85. agentpool_cli/serve_opencode.py +119 -0
  86. agentpool_commands/__init__.py +30 -0
  87. agentpool_commands/agents.py +74 -1
  88. agentpool_commands/history.py +62 -0
  89. agentpool_commands/mcp.py +176 -0
  90. agentpool_commands/models.py +56 -3
  91. agentpool_commands/tools.py +57 -0
  92. agentpool_commands/utils.py +51 -0
  93. agentpool_config/builtin_tools.py +77 -22
  94. agentpool_config/commands.py +24 -1
  95. agentpool_config/compaction.py +258 -0
  96. agentpool_config/mcp_server.py +131 -1
  97. agentpool_config/storage.py +46 -1
  98. agentpool_config/tools.py +7 -1
  99. agentpool_config/toolsets.py +92 -148
  100. agentpool_server/acp_server/acp_agent.py +134 -150
  101. agentpool_server/acp_server/commands/acp_commands.py +216 -51
  102. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +10 -10
  103. agentpool_server/acp_server/server.py +23 -79
  104. agentpool_server/acp_server/session.py +181 -19
  105. agentpool_server/opencode_server/.rules +95 -0
  106. agentpool_server/opencode_server/ENDPOINTS.md +362 -0
  107. agentpool_server/opencode_server/__init__.py +27 -0
  108. agentpool_server/opencode_server/command_validation.py +172 -0
  109. agentpool_server/opencode_server/converters.py +869 -0
  110. agentpool_server/opencode_server/dependencies.py +24 -0
  111. agentpool_server/opencode_server/input_provider.py +269 -0
  112. agentpool_server/opencode_server/models/__init__.py +228 -0
  113. agentpool_server/opencode_server/models/agent.py +53 -0
  114. agentpool_server/opencode_server/models/app.py +60 -0
  115. agentpool_server/opencode_server/models/base.py +26 -0
  116. agentpool_server/opencode_server/models/common.py +23 -0
  117. agentpool_server/opencode_server/models/config.py +37 -0
  118. agentpool_server/opencode_server/models/events.py +647 -0
  119. agentpool_server/opencode_server/models/file.py +88 -0
  120. agentpool_server/opencode_server/models/mcp.py +25 -0
  121. agentpool_server/opencode_server/models/message.py +162 -0
  122. agentpool_server/opencode_server/models/parts.py +190 -0
  123. agentpool_server/opencode_server/models/provider.py +81 -0
  124. agentpool_server/opencode_server/models/pty.py +43 -0
  125. agentpool_server/opencode_server/models/session.py +99 -0
  126. agentpool_server/opencode_server/routes/__init__.py +25 -0
  127. agentpool_server/opencode_server/routes/agent_routes.py +442 -0
  128. agentpool_server/opencode_server/routes/app_routes.py +139 -0
  129. agentpool_server/opencode_server/routes/config_routes.py +241 -0
  130. agentpool_server/opencode_server/routes/file_routes.py +392 -0
  131. agentpool_server/opencode_server/routes/global_routes.py +94 -0
  132. agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
  133. agentpool_server/opencode_server/routes/message_routes.py +705 -0
  134. agentpool_server/opencode_server/routes/pty_routes.py +299 -0
  135. agentpool_server/opencode_server/routes/session_routes.py +1205 -0
  136. agentpool_server/opencode_server/routes/tui_routes.py +139 -0
  137. agentpool_server/opencode_server/server.py +430 -0
  138. agentpool_server/opencode_server/state.py +121 -0
  139. agentpool_server/opencode_server/time_utils.py +8 -0
  140. agentpool_storage/__init__.py +16 -0
  141. agentpool_storage/base.py +103 -0
  142. agentpool_storage/claude_provider.py +907 -0
  143. agentpool_storage/file_provider.py +129 -0
  144. agentpool_storage/memory_provider.py +61 -0
  145. agentpool_storage/models.py +3 -0
  146. agentpool_storage/opencode_provider.py +730 -0
  147. agentpool_storage/project_store.py +325 -0
  148. agentpool_storage/session_store.py +6 -0
  149. agentpool_storage/sql_provider/__init__.py +4 -2
  150. agentpool_storage/sql_provider/models.py +48 -0
  151. agentpool_storage/sql_provider/sql_provider.py +134 -1
  152. agentpool_storage/sql_provider/utils.py +10 -1
  153. agentpool_storage/text_log_provider.py +1 -0
  154. agentpool_toolsets/builtin/__init__.py +0 -8
  155. agentpool_toolsets/builtin/code.py +95 -56
  156. agentpool_toolsets/builtin/debug.py +16 -21
  157. agentpool_toolsets/builtin/execution_environment.py +99 -103
  158. agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
  159. agentpool_toolsets/builtin/skills.py +86 -4
  160. agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
  161. agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
  162. agentpool_toolsets/fsspec_toolset/grep.py +74 -2
  163. agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
  164. agentpool_toolsets/fsspec_toolset/toolset.py +159 -38
  165. agentpool_toolsets/mcp_discovery/__init__.py +5 -0
  166. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  167. agentpool_toolsets/mcp_discovery/toolset.py +454 -0
  168. agentpool_toolsets/mcp_run_toolset.py +84 -6
  169. agentpool_toolsets/builtin/agent_management.py +0 -239
  170. agentpool_toolsets/builtin/history.py +0 -36
  171. agentpool_toolsets/builtin/integration.py +0 -85
  172. agentpool_toolsets/builtin/tool_management.py +0 -90
  173. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/entry_points.txt +0 -0
  174. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1593 @@
1
+ """LSP Manager - Orchestrates LSP servers across execution environments.
2
+
3
+ This module manages LSP server lifecycle:
4
+ - Starting LSP proxy processes via the execution environment
5
+ - Tracking running servers by language/server-id
6
+ - Sending requests via one-shot Python scripts
7
+ - Handling initialization handshake
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import contextlib
14
+ from dataclasses import dataclass, field
15
+ import time
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ import anyenv
19
+
20
+ from agentpool.diagnostics.models import (
21
+ COMPLETION_KIND_MAP,
22
+ SYMBOL_KIND_MAP,
23
+ CallHierarchyCall,
24
+ CallHierarchyItem,
25
+ CodeAction,
26
+ CompletionItem,
27
+ Diagnostic,
28
+ DiagnosticsResult,
29
+ HoverInfo,
30
+ Location,
31
+ LSPServerState,
32
+ Position,
33
+ Range,
34
+ RenameResult,
35
+ SignatureInfo,
36
+ SymbolInfo,
37
+ TypeHierarchyItem,
38
+ )
39
+
40
+
41
+ if TYPE_CHECKING:
42
+ from anyenv.lsp_servers import LSPServerInfo
43
+ from exxec import ExecutionEnvironment
44
+
45
+ from agentpool.diagnostics.models import (
46
+ HoverContents,
47
+ )
48
+
49
+
50
+ # One-shot script template for sending LSP requests via TCP
51
+ LSP_CLIENT_SCRIPT = '''
52
+ """One-shot LSP client - sends request and prints JSON response."""
53
+ import asyncio
54
+ import json
55
+
56
+ PORT = {port!r}
57
+ METHOD = {method!r}
58
+ PARAMS = {params!r}
59
+
60
+
61
+ async def send_request() -> None:
62
+ """Connect to LSP proxy via TCP, send request, print response."""
63
+ try:
64
+ reader, writer = await asyncio.open_connection("127.0.0.1", PORT)
65
+
66
+ # Build JSON-RPC request
67
+ request = {{"jsonrpc": "2.0", "id": 1, "method": METHOD, "params": PARAMS}}
68
+ payload = json.dumps(request)
69
+ header = f"Content-Length: {{len(payload)}}\\r\\n\\r\\n"
70
+
71
+ # Send request
72
+ writer.write(header.encode() + payload.encode())
73
+ await writer.drain()
74
+
75
+ # Read response headers
76
+ headers = b""
77
+ while b"\\r\\n\\r\\n" not in headers:
78
+ chunk = await reader.read(1)
79
+ if not chunk:
80
+ print(json.dumps({{"error": "Connection closed"}}))
81
+ return
82
+ headers += chunk
83
+
84
+ # Parse Content-Length
85
+ header_str = headers.decode()
86
+ length = None
87
+ for line in header_str.split("\\r\\n"):
88
+ if line.startswith("Content-Length:"):
89
+ length = int(line.split(":")[1].strip())
90
+ break
91
+
92
+ if length is None:
93
+ print(json.dumps({{"error": "No Content-Length header"}}))
94
+ return
95
+
96
+ # Read response body
97
+ body = await reader.read(length)
98
+ response = json.loads(body)
99
+
100
+ # Print result (or error)
101
+ print(json.dumps(response))
102
+
103
+ writer.close()
104
+ await writer.wait_closed()
105
+
106
+ except ConnectionRefusedError:
107
+ print(json.dumps({{"error": f"Connection refused on port {{PORT}}"}}))
108
+ except Exception as e:
109
+ print(json.dumps({{"error": str(e)}}))
110
+
111
+
112
+ asyncio.run(send_request())
113
+ '''
114
+
115
+
116
+ @dataclass
117
+ class LSPManager:
118
+ """Manages LSP servers for an execution environment.
119
+
120
+ Handles:
121
+ - Starting LSP proxy processes that wrap stdio-based servers
122
+ - Tracking running servers by ID
123
+ - Sending requests via one-shot scripts or direct sockets
124
+ - LSP initialization handshake
125
+ """
126
+
127
+ env: ExecutionEnvironment
128
+ """The execution environment to run servers in."""
129
+
130
+ port_file_dir: str = "/tmp/lsp-ports"
131
+ """Directory for port files."""
132
+
133
+ _servers: dict[str, LSPServerState] = field(default_factory=dict)
134
+ """Running servers by server_id."""
135
+
136
+ _server_configs: dict[str, LSPServerInfo] = field(default_factory=dict)
137
+ """Server configurations by server_id."""
138
+
139
+ _starting: set[str] = field(default_factory=set)
140
+ """Server IDs currently being started (to prevent concurrent start attempts)."""
141
+
142
+ def __post_init__(self) -> None:
143
+ """Initialize internal state."""
144
+ # dataclass default_factory doesn't work with mutable defaults in __init__
145
+ if not hasattr(self, "_servers") or self._servers is None:
146
+ self._servers = {}
147
+ if not hasattr(self, "_server_configs") or self._server_configs is None:
148
+ self._server_configs = {}
149
+ if not hasattr(self, "_starting") or self._starting is None:
150
+ self._starting = set()
151
+
152
+ def register_server(self, config: LSPServerInfo) -> None:
153
+ """Register an LSP server configuration.
154
+
155
+ Args:
156
+ config: LSP server configuration from anyenv
157
+ """
158
+ self._server_configs[config.id] = config
159
+
160
+ def register_defaults(self) -> None:
161
+ """Register all default LSP servers from anyenv."""
162
+ from anyenv.lsp_servers import LSPServerRegistry
163
+
164
+ registry = LSPServerRegistry()
165
+ registry.register_defaults()
166
+ for server in registry.all_servers:
167
+ self.register_server(server)
168
+
169
+ def get_server_for_file(self, path: str) -> LSPServerInfo | None:
170
+ """Get the best LSP server for a file path.
171
+
172
+ Args:
173
+ path: File path to check
174
+
175
+ Returns:
176
+ LSPServerInfo if found, None otherwise
177
+ """
178
+ import posixpath
179
+
180
+ ext = posixpath.splitext(path)[1].lower()
181
+ for config in self._server_configs.values():
182
+ if config.can_handle(ext):
183
+ return config
184
+ return None
185
+
186
+ def is_running(self, server_id: str) -> bool:
187
+ """Check if a server is currently running or starting.
188
+
189
+ Args:
190
+ server_id: Server identifier
191
+
192
+ Returns:
193
+ True if server is running or being started
194
+ """
195
+ if server_id in self._starting:
196
+ return True # Treat "starting" as running to prevent concurrent starts
197
+ return server_id in self._servers and self._servers[server_id].initialized
198
+
199
+ async def start_server(
200
+ self,
201
+ server_id: str,
202
+ root_uri: str | None = None,
203
+ ) -> LSPServerState:
204
+ """Start an LSP server.
205
+
206
+ Args:
207
+ server_id: Server identifier (e.g., 'pyright', 'rust-analyzer')
208
+ root_uri: Workspace root URI (e.g., 'file:///path/to/project')
209
+
210
+ Returns:
211
+ LSPServerState with server info
212
+
213
+ Raises:
214
+ ValueError: If server_id not registered
215
+ RuntimeError: If server fails to start
216
+ """
217
+ if server_id in self._servers:
218
+ return self._servers[server_id]
219
+
220
+ # Check if already being started (prevent concurrent starts)
221
+ if server_id in self._starting:
222
+ # Wait for the other start to complete
223
+ for _ in range(100): # 10 second timeout
224
+ await asyncio.sleep(0.1)
225
+ if server_id in self._servers:
226
+ return self._servers[server_id]
227
+ msg = f"Timeout waiting for {server_id} to start"
228
+ raise RuntimeError(msg)
229
+
230
+ config = self._server_configs.get(server_id)
231
+ if not config:
232
+ msg = f"Server {server_id!r} not registered"
233
+ raise ValueError(msg)
234
+
235
+ # Mark as starting to prevent concurrent attempts
236
+ self._starting.add(server_id)
237
+
238
+ try:
239
+ # Build the command
240
+ command = config.get_full_command()
241
+ command_str = " ".join(command)
242
+
243
+ # Port file path for this server
244
+ port_file = f"{self.port_file_dir}/{server_id}.port"
245
+
246
+ # Start the LSP proxy process
247
+ from agentpool.diagnostics.lsp_proxy import LSPProxy
248
+
249
+ proxy_cmd = LSPProxy.get_start_command(command_str, port_file)
250
+
251
+ # Ensure port file directory exists
252
+ await self.env.execute_command(f"mkdir -p {self.port_file_dir}")
253
+
254
+ # Start proxy as background process
255
+ process_id = await self.env.process_manager.start_process(
256
+ command=proxy_cmd[0],
257
+ args=proxy_cmd[1:],
258
+ env=config.get_env(),
259
+ )
260
+
261
+ # Wait for server to be ready (check for .ready marker file)
262
+ ready_path = f"{port_file}.ready"
263
+ for _ in range(50): # 5 second timeout
264
+ result = await self.env.execute_command(f"test -f {ready_path} && echo ready")
265
+ if result.stdout and "ready" in result.stdout:
266
+ break
267
+ await asyncio.sleep(0.1)
268
+ else:
269
+ # Cleanup on failure
270
+ await self.env.process_manager.kill_process(process_id)
271
+ msg = f"LSP proxy for {server_id} failed to start (not ready)"
272
+ raise RuntimeError(msg)
273
+
274
+ # Read the port from the port file
275
+ port_result = await self.env.execute_command(f"cat {port_file}")
276
+ if not port_result.stdout or port_result.exit_code != 0:
277
+ await self.env.process_manager.kill_process(process_id)
278
+ msg = f"LSP proxy for {server_id} failed to start (no port file)"
279
+ raise RuntimeError(msg)
280
+ port = int(port_result.stdout.strip())
281
+
282
+ # Create server state
283
+ state = LSPServerState(
284
+ server_id=server_id,
285
+ process_id=process_id,
286
+ port=port,
287
+ language=config.extensions[0] if config.extensions else "unknown",
288
+ root_uri=root_uri,
289
+ initialized=False,
290
+ )
291
+ self._servers[server_id] = state
292
+
293
+ # Run LSP initialize handshake
294
+ try:
295
+ await self._initialize_server(state, config, root_uri)
296
+ except Exception:
297
+ # Initialization failed - remove from servers dict and re-raise
298
+ self._servers.pop(server_id, None)
299
+ # Kill the process
300
+ await self.env.process_manager.kill_process(process_id)
301
+ raise
302
+
303
+ return state
304
+ finally:
305
+ # Always remove from starting set
306
+ self._starting.discard(server_id)
307
+
308
+ async def _initialize_server(
309
+ self,
310
+ state: LSPServerState,
311
+ config: LSPServerInfo,
312
+ root_uri: str | None,
313
+ ) -> None:
314
+ """Run LSP initialize/initialized handshake.
315
+
316
+ Args:
317
+ state: Server state to update
318
+ config: Server configuration
319
+ root_uri: Workspace root URI
320
+ """
321
+ # Get initialization options
322
+ init_options = dict(config.initialization)
323
+
324
+ # Build initialize params
325
+ init_params: dict[str, Any] = {
326
+ "processId": None, # We don't have a process ID in the traditional sense
327
+ "rootUri": root_uri,
328
+ "capabilities": {
329
+ "textDocument": {
330
+ "publishDiagnostics": {
331
+ "relatedInformation": True,
332
+ "tagSupport": {"valueSet": [1, 2]},
333
+ },
334
+ "synchronization": {
335
+ "dynamicRegistration": False,
336
+ "willSave": False,
337
+ "willSaveWaitUntil": False,
338
+ "didSave": True,
339
+ },
340
+ },
341
+ "workspace": {
342
+ "workspaceFolders": True,
343
+ "configuration": True,
344
+ },
345
+ },
346
+ "initializationOptions": init_options,
347
+ }
348
+
349
+ if root_uri:
350
+ init_params["workspaceFolders"] = [{"uri": root_uri, "name": "workspace"}]
351
+
352
+ # Send initialize request
353
+ response = await self._send_request(state.port, "initialize", init_params)
354
+
355
+ if "error" in response:
356
+ msg = f"LSP initialize failed: {response['error']}"
357
+ raise RuntimeError(msg)
358
+
359
+ # Store capabilities
360
+ if "result" in response:
361
+ state.capabilities = response["result"].get("capabilities", {})
362
+
363
+ # Send initialized notification (no response expected)
364
+ await self._send_notification(state.port, "initialized", {})
365
+
366
+ state.initialized = True
367
+
368
+ async def _send_request(
369
+ self,
370
+ port: int,
371
+ method: str,
372
+ params: dict[str, Any],
373
+ retries: int = 3,
374
+ ) -> dict[str, Any]:
375
+ """Send an LSP request via execution environment.
376
+
377
+ Args:
378
+ port: TCP port to connect to
379
+ method: LSP method name
380
+ params: Method parameters
381
+ retries: Number of retries for transient failures
382
+
383
+ Returns:
384
+ JSON-RPC response dict
385
+ """
386
+ # Generate client script
387
+ script = LSP_CLIENT_SCRIPT.format(
388
+ port=port,
389
+ method=method,
390
+ params=params,
391
+ )
392
+
393
+ # Execute via environment with retries for connection refused
394
+ cmd = f"python3 -c {_shell_quote(script)}"
395
+
396
+ last_result = None
397
+ for attempt in range(retries):
398
+ result = await self.env.execute_command(cmd)
399
+ last_result = result
400
+
401
+ if result.exit_code != 0:
402
+ return {"error": f"Script failed: {result.stderr}"}
403
+
404
+ try:
405
+ response: dict[str, Any] = anyenv.load_json(result.stdout or "{}", return_type=dict)
406
+ # Check if it's a connection refused error - retry
407
+ if (
408
+ "error" in response
409
+ and "Connection refused" in str(response["error"])
410
+ and attempt < retries - 1
411
+ ):
412
+ await asyncio.sleep(0.5)
413
+ continue
414
+ except anyenv.JsonLoadError as e:
415
+ return {"error": f"Invalid JSON response: {e}"}
416
+ else:
417
+ return response
418
+
419
+ # Should not reach here, but return last result error if we do
420
+ return {"error": f"Failed after {retries} retries: {last_result}"}
421
+
422
+ async def _send_notification(
423
+ self,
424
+ port: int,
425
+ method: str,
426
+ params: dict[str, Any],
427
+ ) -> None:
428
+ """Send an LSP notification (no response expected).
429
+
430
+ For now, we use the same mechanism as requests but ignore the response.
431
+ """
432
+ # For notifications, we'd need a different script that doesn't wait
433
+ # For simplicity, we'll skip actual notification sending for now
434
+ # The initialize/initialized handshake works without waiting for response
435
+
436
+ async def stop_server(self, server_id: str) -> None:
437
+ """Stop an LSP server.
438
+
439
+ Args:
440
+ server_id: Server identifier
441
+ """
442
+ if server_id not in self._servers:
443
+ return
444
+
445
+ state = self._servers[server_id]
446
+
447
+ # Send shutdown request
448
+ with contextlib.suppress(Exception):
449
+ await self._send_request(state.port, "shutdown", {})
450
+
451
+ # Kill the process
452
+ with contextlib.suppress(Exception):
453
+ await self.env.process_manager.kill_process(state.process_id)
454
+
455
+ # Cleanup port files
456
+ port_file = f"{self.port_file_dir}/{server_id}.port"
457
+ with contextlib.suppress(Exception):
458
+ await self.env.execute_command(f"rm -f {port_file} {port_file}.ready")
459
+
460
+ del self._servers[server_id]
461
+
462
+ async def stop_all(self) -> None:
463
+ """Stop all running LSP servers."""
464
+ server_ids = list(self._servers.keys())
465
+ for server_id in server_ids:
466
+ await self.stop_server(server_id)
467
+
468
+ async def get_diagnostics(
469
+ self,
470
+ server_id: str,
471
+ file_uri: str,
472
+ content: str,
473
+ ) -> DiagnosticsResult:
474
+ """Get diagnostics for a file.
475
+
476
+ This opens the file in the LSP server, waits for diagnostics,
477
+ and returns them.
478
+
479
+ Args:
480
+ server_id: Server identifier
481
+ file_uri: File URI (e.g., 'file:///path/to/file.py')
482
+ content: File content
483
+
484
+ Returns:
485
+ DiagnosticsResult with parsed diagnostics
486
+ """
487
+ start_time = time.perf_counter()
488
+
489
+ if server_id not in self._servers:
490
+ return DiagnosticsResult(
491
+ success=False,
492
+ error=f"Server {server_id} not running",
493
+ duration=time.perf_counter() - start_time,
494
+ )
495
+
496
+ state = self._servers[server_id]
497
+
498
+ # Send textDocument/didOpen
499
+ open_params = {
500
+ "textDocument": {
501
+ "uri": file_uri,
502
+ "languageId": _uri_to_language_id(file_uri),
503
+ "version": 1,
504
+ "text": content,
505
+ }
506
+ }
507
+ await self._send_notification(state.port, "textDocument/didOpen", open_params)
508
+
509
+ # For proper diagnostic collection, we'd need to listen for
510
+ # textDocument/publishDiagnostics notifications from the server.
511
+ # This requires a more complex architecture with persistent connections.
512
+ #
513
+ # For now, we'll use the CLI fallback approach which is more reliable
514
+ # for one-shot diagnostic runs.
515
+
516
+ return DiagnosticsResult(
517
+ success=True,
518
+ diagnostics=[],
519
+ duration=time.perf_counter() - start_time,
520
+ server_id=server_id,
521
+ )
522
+
523
+ async def run_cli_diagnostics(
524
+ self,
525
+ server_id: str,
526
+ files: list[str],
527
+ ) -> DiagnosticsResult:
528
+ """Run CLI diagnostics using the server's CLI fallback.
529
+
530
+ This is more reliable for one-shot diagnostic runs than the full
531
+ LSP protocol, as it doesn't require persistent connections.
532
+
533
+ Args:
534
+ server_id: Server identifier
535
+ files: File paths to check
536
+
537
+ Returns:
538
+ DiagnosticsResult with parsed diagnostics
539
+ """
540
+ start_time = time.perf_counter()
541
+
542
+ config = self._server_configs.get(server_id)
543
+ if not config:
544
+ return DiagnosticsResult(
545
+ success=False,
546
+ error=f"Server {server_id} not registered",
547
+ duration=time.perf_counter() - start_time,
548
+ )
549
+
550
+ if not config.has_cli_diagnostics:
551
+ return DiagnosticsResult(
552
+ success=False,
553
+ error=f"Server {server_id} has no CLI diagnostic support",
554
+ duration=time.perf_counter() - start_time,
555
+ )
556
+
557
+ # Build and run the diagnostic command
558
+ command = config.build_diagnostic_command(files)
559
+ result = await self.env.execute_command(command)
560
+
561
+ # Parse the output
562
+ diagnostics = config.parse_diagnostics(result.stdout or "", result.stderr or "")
563
+
564
+ return DiagnosticsResult(
565
+ diagnostics=[_convert_diagnostic(d, server_id) for d in diagnostics],
566
+ success=True,
567
+ duration=time.perf_counter() - start_time,
568
+ server_id=server_id,
569
+ )
570
+
571
+ # =========================================================================
572
+ # Document Operations
573
+ # =========================================================================
574
+
575
+ async def hover(
576
+ self,
577
+ server_id: str,
578
+ file_uri: str,
579
+ line: int,
580
+ character: int,
581
+ ) -> HoverInfo | None:
582
+ """Get hover information at a position.
583
+
584
+ Returns type information, documentation, and other details
585
+ for the symbol at the given position.
586
+
587
+ Args:
588
+ server_id: Server identifier
589
+ file_uri: File URI (e.g., 'file:///path/to/file.py')
590
+ line: 0-based line number
591
+ character: 0-based character offset
592
+
593
+ Returns:
594
+ HoverInfo if available, None otherwise
595
+ """
596
+ if server_id not in self._servers:
597
+ return None
598
+
599
+ state = self._servers[server_id]
600
+ params = {
601
+ "textDocument": {"uri": file_uri},
602
+ "position": {"line": line, "character": character},
603
+ }
604
+
605
+ response = await self._send_request(state.port, "textDocument/hover", params)
606
+
607
+ if "error" in response or not response.get("result"):
608
+ return None
609
+
610
+ result = response["result"]
611
+ contents = _extract_hover_contents(result.get("contents", ""))
612
+ range_ = _parse_range(result.get("range")) if result.get("range") else None
613
+
614
+ return HoverInfo(contents=contents, range=range_)
615
+
616
+ async def goto_definition(
617
+ self,
618
+ server_id: str,
619
+ file_uri: str,
620
+ line: int,
621
+ character: int,
622
+ ) -> list[Location]:
623
+ """Go to definition of symbol at position.
624
+
625
+ Args:
626
+ server_id: Server identifier
627
+ file_uri: File URI
628
+ line: 0-based line number
629
+ character: 0-based character offset
630
+
631
+ Returns:
632
+ List of definition locations
633
+ """
634
+ if server_id not in self._servers:
635
+ return []
636
+
637
+ state = self._servers[server_id]
638
+ params = {
639
+ "textDocument": {"uri": file_uri},
640
+ "position": {"line": line, "character": character},
641
+ }
642
+
643
+ response = await self._send_request(state.port, "textDocument/definition", params)
644
+
645
+ if "error" in response or not response.get("result"):
646
+ return []
647
+
648
+ return _parse_locations(response["result"])
649
+
650
+ async def goto_type_definition(
651
+ self,
652
+ server_id: str,
653
+ file_uri: str,
654
+ line: int,
655
+ character: int,
656
+ ) -> list[Location]:
657
+ """Go to type definition of symbol at position.
658
+
659
+ Args:
660
+ server_id: Server identifier
661
+ file_uri: File URI
662
+ line: 0-based line number
663
+ character: 0-based character offset
664
+
665
+ Returns:
666
+ List of type definition locations
667
+ """
668
+ if server_id not in self._servers:
669
+ return []
670
+
671
+ state = self._servers[server_id]
672
+ params = {
673
+ "textDocument": {"uri": file_uri},
674
+ "position": {"line": line, "character": character},
675
+ }
676
+
677
+ response = await self._send_request(state.port, "textDocument/typeDefinition", params)
678
+
679
+ if "error" in response or not response.get("result"):
680
+ return []
681
+
682
+ return _parse_locations(response["result"])
683
+
684
+ async def goto_implementation(
685
+ self,
686
+ server_id: str,
687
+ file_uri: str,
688
+ line: int,
689
+ character: int,
690
+ ) -> list[Location]:
691
+ """Go to implementation of symbol at position.
692
+
693
+ Useful for finding implementations of interfaces/abstract methods.
694
+
695
+ Args:
696
+ server_id: Server identifier
697
+ file_uri: File URI
698
+ line: 0-based line number
699
+ character: 0-based character offset
700
+
701
+ Returns:
702
+ List of implementation locations
703
+ """
704
+ if server_id not in self._servers:
705
+ return []
706
+
707
+ state = self._servers[server_id]
708
+ params = {
709
+ "textDocument": {"uri": file_uri},
710
+ "position": {"line": line, "character": character},
711
+ }
712
+
713
+ response = await self._send_request(state.port, "textDocument/implementation", params)
714
+
715
+ if "error" in response or not response.get("result"):
716
+ return []
717
+
718
+ return _parse_locations(response["result"])
719
+
720
+ async def find_references(
721
+ self,
722
+ server_id: str,
723
+ file_uri: str,
724
+ line: int,
725
+ character: int,
726
+ include_declaration: bool = True,
727
+ ) -> list[Location]:
728
+ """Find all references to symbol at position.
729
+
730
+ Args:
731
+ server_id: Server identifier
732
+ file_uri: File URI
733
+ line: 0-based line number
734
+ character: 0-based character offset
735
+ include_declaration: Whether to include the declaration itself
736
+
737
+ Returns:
738
+ List of reference locations
739
+ """
740
+ if server_id not in self._servers:
741
+ return []
742
+
743
+ state = self._servers[server_id]
744
+ params = {
745
+ "textDocument": {"uri": file_uri},
746
+ "position": {"line": line, "character": character},
747
+ "context": {"includeDeclaration": include_declaration},
748
+ }
749
+
750
+ response = await self._send_request(state.port, "textDocument/references", params)
751
+
752
+ if "error" in response or not response.get("result"):
753
+ return []
754
+
755
+ return _parse_locations(response["result"])
756
+
757
+ async def get_document_symbols(
758
+ self,
759
+ server_id: str,
760
+ file_uri: str,
761
+ ) -> list[SymbolInfo]:
762
+ """Get all symbols in a document (outline).
763
+
764
+ Returns a hierarchical list of symbols (classes, functions, etc.)
765
+ in the document.
766
+
767
+ Args:
768
+ server_id: Server identifier
769
+ file_uri: File URI
770
+
771
+ Returns:
772
+ List of symbols with hierarchy
773
+ """
774
+ if server_id not in self._servers:
775
+ return []
776
+
777
+ state = self._servers[server_id]
778
+ params = {"textDocument": {"uri": file_uri}}
779
+
780
+ response = await self._send_request(state.port, "textDocument/documentSymbol", params)
781
+
782
+ if "error" in response or not response.get("result"):
783
+ return []
784
+
785
+ return _parse_document_symbols(response["result"], file_uri)
786
+
787
+ async def search_workspace_symbols(
788
+ self,
789
+ server_id: str,
790
+ query: str,
791
+ ) -> list[SymbolInfo]:
792
+ """Search for symbols in the workspace.
793
+
794
+ Args:
795
+ server_id: Server identifier
796
+ query: Search query (fuzzy matching)
797
+
798
+ Returns:
799
+ List of matching symbols
800
+ """
801
+ if server_id not in self._servers:
802
+ return []
803
+
804
+ state = self._servers[server_id]
805
+ params = {"query": query}
806
+
807
+ response = await self._send_request(state.port, "workspace/symbol", params)
808
+
809
+ if "error" in response or not response.get("result"):
810
+ return []
811
+
812
+ return _parse_workspace_symbols(response["result"])
813
+
814
+ async def get_completions(
815
+ self,
816
+ server_id: str,
817
+ file_uri: str,
818
+ line: int,
819
+ character: int,
820
+ ) -> list[CompletionItem]:
821
+ """Get completion suggestions at position.
822
+
823
+ Args:
824
+ server_id: Server identifier
825
+ file_uri: File URI
826
+ line: 0-based line number
827
+ character: 0-based character offset
828
+
829
+ Returns:
830
+ List of completion items
831
+ """
832
+ if server_id not in self._servers:
833
+ return []
834
+
835
+ state = self._servers[server_id]
836
+ params = {
837
+ "textDocument": {"uri": file_uri},
838
+ "position": {"line": line, "character": character},
839
+ }
840
+
841
+ response = await self._send_request(state.port, "textDocument/completion", params)
842
+
843
+ if "error" in response or not response.get("result"):
844
+ return []
845
+
846
+ result = response["result"]
847
+ # Result can be CompletionList or CompletionItem[]
848
+ items = result.get("items", result) if isinstance(result, dict) else result
849
+
850
+ return [_parse_completion_item(item) for item in items]
851
+
852
+ async def get_signature_help(
853
+ self,
854
+ server_id: str,
855
+ file_uri: str,
856
+ line: int,
857
+ character: int,
858
+ ) -> SignatureInfo | None:
859
+ """Get signature help at position.
860
+
861
+ Useful when cursor is inside function call parentheses.
862
+
863
+ Args:
864
+ server_id: Server identifier
865
+ file_uri: File URI
866
+ line: 0-based line number
867
+ character: 0-based character offset
868
+
869
+ Returns:
870
+ SignatureInfo if available, None otherwise
871
+ """
872
+ if server_id not in self._servers:
873
+ return None
874
+
875
+ state = self._servers[server_id]
876
+ params = {
877
+ "textDocument": {"uri": file_uri},
878
+ "position": {"line": line, "character": character},
879
+ }
880
+
881
+ response = await self._send_request(state.port, "textDocument/signatureHelp", params)
882
+
883
+ if "error" in response or not response.get("result"):
884
+ return None
885
+
886
+ result = response["result"]
887
+ signatures = result.get("signatures", [])
888
+ if not signatures:
889
+ return None
890
+
891
+ active_sig = result.get("activeSignature", 0)
892
+ sig = signatures[min(active_sig, len(signatures) - 1)]
893
+
894
+ return SignatureInfo(
895
+ label=sig.get("label", ""),
896
+ documentation=_extract_documentation(sig.get("documentation")),
897
+ parameters=sig.get("parameters", []),
898
+ active_parameter=result.get("activeParameter"),
899
+ )
900
+
901
+ async def get_code_actions(
902
+ self,
903
+ server_id: str,
904
+ file_uri: str,
905
+ start_line: int,
906
+ start_character: int,
907
+ end_line: int,
908
+ end_character: int,
909
+ diagnostics: list[Diagnostic] | None = None,
910
+ ) -> list[CodeAction]:
911
+ """Get available code actions for a range.
912
+
913
+ Code actions include quick fixes, refactorings, and source actions.
914
+
915
+ Args:
916
+ server_id: Server identifier
917
+ file_uri: File URI
918
+ start_line: Start line (0-based)
919
+ start_character: Start character
920
+ end_line: End line (0-based)
921
+ end_character: End character
922
+ diagnostics: Optional diagnostics to get fixes for
923
+
924
+ Returns:
925
+ List of available code actions
926
+ """
927
+ if server_id not in self._servers:
928
+ return []
929
+
930
+ state = self._servers[server_id]
931
+
932
+ # Convert our Diagnostic to LSP format
933
+ lsp_diagnostics = [
934
+ {
935
+ "range": {
936
+ "start": {"line": d.line - 1, "character": d.column - 1},
937
+ "end": {
938
+ "line": (d.end_line or d.line) - 1,
939
+ "character": (d.end_column or d.column) - 1,
940
+ },
941
+ },
942
+ "message": d.message,
943
+ "severity": _severity_to_lsp(d.severity),
944
+ "source": d.source,
945
+ "code": d.code,
946
+ }
947
+ for d in (diagnostics or [])
948
+ ]
949
+
950
+ params = {
951
+ "textDocument": {"uri": file_uri},
952
+ "range": {
953
+ "start": {"line": start_line, "character": start_character},
954
+ "end": {"line": end_line, "character": end_character},
955
+ },
956
+ "context": {
957
+ "diagnostics": lsp_diagnostics,
958
+ "only": ["quickfix", "refactor", "source"],
959
+ },
960
+ }
961
+
962
+ response = await self._send_request(state.port, "textDocument/codeAction", params)
963
+
964
+ if "error" in response or not response.get("result"):
965
+ return []
966
+
967
+ return [_parse_code_action(action) for action in response["result"]]
968
+
969
+ async def rename_symbol(
970
+ self,
971
+ server_id: str,
972
+ file_uri: str,
973
+ line: int,
974
+ character: int,
975
+ new_name: str,
976
+ ) -> RenameResult:
977
+ """Rename a symbol across the workspace.
978
+
979
+ Args:
980
+ server_id: Server identifier
981
+ file_uri: File URI
982
+ line: 0-based line number
983
+ character: 0-based character offset
984
+ new_name: New name for the symbol
985
+
986
+ Returns:
987
+ RenameResult with the edits to apply
988
+ """
989
+ if server_id not in self._servers:
990
+ return RenameResult(changes={}, success=False, error="Server not running")
991
+
992
+ state = self._servers[server_id]
993
+
994
+ # First check if rename is valid
995
+ prepare_params = {
996
+ "textDocument": {"uri": file_uri},
997
+ "position": {"line": line, "character": character},
998
+ }
999
+
1000
+ prepare_response = await self._send_request(
1001
+ state.port, "textDocument/prepareRename", prepare_params
1002
+ )
1003
+
1004
+ if "error" in prepare_response:
1005
+ return RenameResult(
1006
+ changes={},
1007
+ success=False,
1008
+ error=str(prepare_response["error"]),
1009
+ )
1010
+
1011
+ # Now do the rename
1012
+ rename_params = {
1013
+ "textDocument": {"uri": file_uri},
1014
+ "position": {"line": line, "character": character},
1015
+ "newName": new_name,
1016
+ }
1017
+
1018
+ response = await self._send_request(state.port, "textDocument/rename", rename_params)
1019
+
1020
+ if "error" in response:
1021
+ return RenameResult(
1022
+ changes={},
1023
+ success=False,
1024
+ error=str(response["error"]),
1025
+ )
1026
+
1027
+ result = response.get("result", {})
1028
+ changes = result.get("changes", {})
1029
+ document_changes = result.get("documentChanges", [])
1030
+
1031
+ # Normalize to changes format
1032
+ if document_changes and not changes:
1033
+ changes = {}
1034
+ for doc_change in document_changes:
1035
+ if "textDocument" in doc_change:
1036
+ uri = doc_change["textDocument"]["uri"]
1037
+ changes[uri] = doc_change.get("edits", [])
1038
+
1039
+ return RenameResult(changes=changes, success=True)
1040
+
1041
+ async def format_document(
1042
+ self,
1043
+ server_id: str,
1044
+ file_uri: str,
1045
+ tab_size: int = 4,
1046
+ insert_spaces: bool = True,
1047
+ ) -> list[dict[str, Any]]:
1048
+ """Format an entire document.
1049
+
1050
+ Args:
1051
+ server_id: Server identifier
1052
+ file_uri: File URI
1053
+ tab_size: Tab size in spaces
1054
+ insert_spaces: Use spaces instead of tabs
1055
+
1056
+ Returns:
1057
+ List of text edits to apply
1058
+ """
1059
+ if server_id not in self._servers:
1060
+ return []
1061
+
1062
+ state = self._servers[server_id]
1063
+ params = {
1064
+ "textDocument": {"uri": file_uri},
1065
+ "options": {
1066
+ "tabSize": tab_size,
1067
+ "insertSpaces": insert_spaces,
1068
+ },
1069
+ }
1070
+
1071
+ response = await self._send_request(state.port, "textDocument/formatting", params)
1072
+
1073
+ if "error" in response or not response.get("result"):
1074
+ return []
1075
+
1076
+ return response["result"] # type: ignore[no-any-return]
1077
+
1078
+ # =========================================================================
1079
+ # Call Hierarchy
1080
+ # =========================================================================
1081
+
1082
+ async def prepare_call_hierarchy(
1083
+ self,
1084
+ server_id: str,
1085
+ file_uri: str,
1086
+ line: int,
1087
+ character: int,
1088
+ ) -> list[CallHierarchyItem]:
1089
+ """Prepare call hierarchy at position.
1090
+
1091
+ This returns the item(s) at the position that can be used
1092
+ to query incoming/outgoing calls.
1093
+
1094
+ Args:
1095
+ server_id: Server identifier
1096
+ file_uri: File URI
1097
+ line: 0-based line number
1098
+ character: 0-based character offset
1099
+
1100
+ Returns:
1101
+ List of call hierarchy items
1102
+ """
1103
+ if server_id not in self._servers:
1104
+ return []
1105
+
1106
+ state = self._servers[server_id]
1107
+ params = {
1108
+ "textDocument": {"uri": file_uri},
1109
+ "position": {"line": line, "character": character},
1110
+ }
1111
+
1112
+ response = await self._send_request(state.port, "textDocument/prepareCallHierarchy", params)
1113
+
1114
+ if "error" in response or not response.get("result"):
1115
+ return []
1116
+
1117
+ return [_parse_call_hierarchy_item(item) for item in response["result"]]
1118
+
1119
+ async def get_incoming_calls(
1120
+ self,
1121
+ server_id: str,
1122
+ item: CallHierarchyItem,
1123
+ ) -> list[CallHierarchyCall]:
1124
+ """Get incoming calls (callers) for a call hierarchy item.
1125
+
1126
+ Args:
1127
+ server_id: Server identifier
1128
+ item: Call hierarchy item from prepare_call_hierarchy
1129
+
1130
+ Returns:
1131
+ List of incoming calls
1132
+ """
1133
+ if server_id not in self._servers:
1134
+ return []
1135
+
1136
+ state = self._servers[server_id]
1137
+ params = {"item": _call_hierarchy_item_to_lsp(item)}
1138
+
1139
+ response = await self._send_request(state.port, "callHierarchy/incomingCalls", params)
1140
+
1141
+ if "error" in response or not response.get("result"):
1142
+ return []
1143
+
1144
+ return [
1145
+ CallHierarchyCall(
1146
+ item=_parse_call_hierarchy_item(call["from"]),
1147
+ from_ranges=[_parse_range(r) for r in call.get("fromRanges", [])],
1148
+ )
1149
+ for call in response["result"]
1150
+ ]
1151
+
1152
+ async def get_outgoing_calls(
1153
+ self,
1154
+ server_id: str,
1155
+ item: CallHierarchyItem,
1156
+ ) -> list[CallHierarchyCall]:
1157
+ """Get outgoing calls (callees) for a call hierarchy item.
1158
+
1159
+ Args:
1160
+ server_id: Server identifier
1161
+ item: Call hierarchy item from prepare_call_hierarchy
1162
+
1163
+ Returns:
1164
+ List of outgoing calls
1165
+ """
1166
+ if server_id not in self._servers:
1167
+ return []
1168
+
1169
+ state = self._servers[server_id]
1170
+ params = {"item": _call_hierarchy_item_to_lsp(item)}
1171
+
1172
+ response = await self._send_request(state.port, "callHierarchy/outgoingCalls", params)
1173
+
1174
+ if "error" in response or not response.get("result"):
1175
+ return []
1176
+
1177
+ return [
1178
+ CallHierarchyCall(
1179
+ item=_parse_call_hierarchy_item(call["to"]),
1180
+ from_ranges=[_parse_range(r) for r in call.get("fromRanges", [])],
1181
+ )
1182
+ for call in response["result"]
1183
+ ]
1184
+
1185
+ # =========================================================================
1186
+ # Type Hierarchy
1187
+ # =========================================================================
1188
+
1189
+ async def prepare_type_hierarchy(
1190
+ self,
1191
+ server_id: str,
1192
+ file_uri: str,
1193
+ line: int,
1194
+ character: int,
1195
+ ) -> list[TypeHierarchyItem]:
1196
+ """Prepare type hierarchy at position.
1197
+
1198
+ Args:
1199
+ server_id: Server identifier
1200
+ file_uri: File URI
1201
+ line: 0-based line number
1202
+ character: 0-based character offset
1203
+
1204
+ Returns:
1205
+ List of type hierarchy items
1206
+ """
1207
+ if server_id not in self._servers:
1208
+ return []
1209
+
1210
+ state = self._servers[server_id]
1211
+ params = {
1212
+ "textDocument": {"uri": file_uri},
1213
+ "position": {"line": line, "character": character},
1214
+ }
1215
+
1216
+ response = await self._send_request(state.port, "textDocument/prepareTypeHierarchy", params)
1217
+
1218
+ if "error" in response or not response.get("result"):
1219
+ return []
1220
+
1221
+ return [_parse_type_hierarchy_item(item) for item in response["result"]]
1222
+
1223
+ async def get_supertypes(
1224
+ self,
1225
+ server_id: str,
1226
+ item: TypeHierarchyItem,
1227
+ ) -> list[TypeHierarchyItem]:
1228
+ """Get supertypes (base classes/interfaces) for a type.
1229
+
1230
+ Args:
1231
+ server_id: Server identifier
1232
+ item: Type hierarchy item from prepare_type_hierarchy
1233
+
1234
+ Returns:
1235
+ List of supertype items
1236
+ """
1237
+ if server_id not in self._servers:
1238
+ return []
1239
+
1240
+ state = self._servers[server_id]
1241
+ params = {"item": _type_hierarchy_item_to_lsp(item)}
1242
+
1243
+ response = await self._send_request(state.port, "typeHierarchy/supertypes", params)
1244
+
1245
+ if "error" in response or not response.get("result"):
1246
+ return []
1247
+
1248
+ return [_parse_type_hierarchy_item(item) for item in response["result"]]
1249
+
1250
+ async def get_subtypes(
1251
+ self,
1252
+ server_id: str,
1253
+ item: TypeHierarchyItem,
1254
+ ) -> list[TypeHierarchyItem]:
1255
+ """Get subtypes (derived classes/implementations) for a type.
1256
+
1257
+ Args:
1258
+ server_id: Server identifier
1259
+ item: Type hierarchy item from prepare_type_hierarchy
1260
+
1261
+ Returns:
1262
+ List of subtype items
1263
+ """
1264
+ if server_id not in self._servers:
1265
+ return []
1266
+
1267
+ state = self._servers[server_id]
1268
+ params = {"item": _type_hierarchy_item_to_lsp(item)}
1269
+
1270
+ response = await self._send_request(state.port, "typeHierarchy/subtypes", params)
1271
+
1272
+ if "error" in response or not response.get("result"):
1273
+ return []
1274
+
1275
+ return [_parse_type_hierarchy_item(item) for item in response["result"]]
1276
+
1277
+
1278
+ def _shell_quote(s: str) -> str:
1279
+ """Quote a string for shell use."""
1280
+ import shlex
1281
+
1282
+ return shlex.quote(s)
1283
+
1284
+
1285
+ def _uri_to_language_id(uri: str) -> str:
1286
+ """Convert file URI to LSP language ID."""
1287
+ import posixpath
1288
+
1289
+ ext = posixpath.splitext(uri)[1].lower()
1290
+ language_map = {
1291
+ ".py": "python",
1292
+ ".pyi": "python",
1293
+ ".js": "javascript",
1294
+ ".jsx": "javascriptreact",
1295
+ ".ts": "typescript",
1296
+ ".tsx": "typescriptreact",
1297
+ ".rs": "rust",
1298
+ ".go": "go",
1299
+ ".c": "c",
1300
+ ".cpp": "cpp",
1301
+ ".h": "c",
1302
+ ".hpp": "cpp",
1303
+ ".java": "java",
1304
+ ".rb": "ruby",
1305
+ ".lua": "lua",
1306
+ ".zig": "zig",
1307
+ ".swift": "swift",
1308
+ ".ex": "elixir",
1309
+ ".exs": "elixir",
1310
+ ".php": "php",
1311
+ ".dart": "dart",
1312
+ ".yaml": "yaml",
1313
+ ".yml": "yaml",
1314
+ ".cs": "csharp",
1315
+ }
1316
+ return language_map.get(ext, "plaintext")
1317
+
1318
+
1319
+ def _convert_diagnostic(diag: Any, server_id: str) -> Diagnostic:
1320
+ """Convert anyenv Diagnostic to agentpool Diagnostic."""
1321
+ return Diagnostic(
1322
+ file=diag.file,
1323
+ line=diag.line,
1324
+ column=diag.column,
1325
+ severity=diag.severity,
1326
+ message=diag.message,
1327
+ source=server_id,
1328
+ code=diag.code,
1329
+ end_line=diag.end_line,
1330
+ end_column=diag.end_column,
1331
+ )
1332
+
1333
+
1334
+ # =============================================================================
1335
+ # LSP Response Parsing Helpers
1336
+ # =============================================================================
1337
+
1338
+
1339
+ def _parse_position(pos: dict[str, Any]) -> Position:
1340
+ """Parse LSP Position."""
1341
+ return Position(line=pos["line"], character=pos["character"])
1342
+
1343
+
1344
+ def _parse_range(range_: dict[str, Any]) -> Range:
1345
+ """Parse LSP Range."""
1346
+ return Range(
1347
+ start=_parse_position(range_["start"]),
1348
+ end=_parse_position(range_["end"]),
1349
+ )
1350
+
1351
+
1352
+ def _parse_location(loc: dict[str, Any]) -> Location:
1353
+ """Parse LSP Location."""
1354
+ return Location(
1355
+ uri=loc["uri"],
1356
+ range=_parse_range(loc["range"]),
1357
+ )
1358
+
1359
+
1360
+ def _parse_locations(result: Any) -> list[Location]:
1361
+ """Parse LSP definition/references result (can be Location, Location[], or LocationLink[])."""
1362
+ if not result:
1363
+ return []
1364
+
1365
+ # Single location
1366
+ if isinstance(result, dict) and "uri" in result:
1367
+ return [_parse_location(result)]
1368
+
1369
+ # Array of locations or location links
1370
+ locations = []
1371
+ for item in result:
1372
+ if "targetUri" in item: # LocationLink
1373
+ locations.append(
1374
+ Location(
1375
+ uri=item["targetUri"],
1376
+ range=_parse_range(item["targetRange"]),
1377
+ )
1378
+ )
1379
+ elif "uri" in item: # Location
1380
+ locations.append(_parse_location(item))
1381
+
1382
+ return locations
1383
+
1384
+
1385
+ def _extract_hover_contents(contents: HoverContents) -> str:
1386
+ """Extract string from hover contents."""
1387
+ if isinstance(contents, str):
1388
+ return contents
1389
+
1390
+ if isinstance(contents, dict):
1391
+ # MarkupContent or MarkedString with language
1392
+ if "value" in contents:
1393
+ return contents["value"]
1394
+ if "kind" in contents:
1395
+ return contents.get("value", "")
1396
+
1397
+ if isinstance(contents, list):
1398
+ # Array of MarkedString
1399
+ parts = []
1400
+ for item in contents:
1401
+ if isinstance(item, str):
1402
+ parts.append(item)
1403
+ elif isinstance(item, dict) and "value" in item:
1404
+ parts.append(item["value"])
1405
+ return "\n\n".join(parts)
1406
+
1407
+ return str(contents)
1408
+
1409
+
1410
+ def _extract_documentation(doc: Any) -> str | None:
1411
+ """Extract documentation string."""
1412
+ if doc is None:
1413
+ return None
1414
+ if isinstance(doc, str):
1415
+ return doc
1416
+ if isinstance(doc, dict):
1417
+ return doc.get("value")
1418
+ return str(doc)
1419
+
1420
+
1421
+ def _parse_document_symbols(result: list[Any], file_uri: str) -> list[SymbolInfo]:
1422
+ """Parse document symbols (can be DocumentSymbol[] or SymbolInformation[])."""
1423
+ symbols = []
1424
+
1425
+ for item in result:
1426
+ if "location" in item:
1427
+ # SymbolInformation (flat)
1428
+ symbols.append(
1429
+ SymbolInfo(
1430
+ name=item["name"],
1431
+ kind=SYMBOL_KIND_MAP.get(item.get("kind", 0), "unknown"),
1432
+ location=_parse_location(item["location"]),
1433
+ container_name=item.get("containerName"),
1434
+ )
1435
+ )
1436
+ else:
1437
+ # DocumentSymbol (hierarchical)
1438
+ symbols.append(_parse_document_symbol(item, file_uri))
1439
+
1440
+ return symbols
1441
+
1442
+
1443
+ def _parse_document_symbol(item: dict[str, Any], file_uri: str) -> SymbolInfo:
1444
+ """Parse a single DocumentSymbol with children."""
1445
+ children = [_parse_document_symbol(child, file_uri) for child in item.get("children", [])]
1446
+
1447
+ return SymbolInfo(
1448
+ name=item["name"],
1449
+ kind=SYMBOL_KIND_MAP.get(item.get("kind", 0), "unknown"),
1450
+ location=Location(
1451
+ uri=file_uri,
1452
+ range=_parse_range(item["range"]),
1453
+ ),
1454
+ detail=item.get("detail"),
1455
+ children=children,
1456
+ )
1457
+
1458
+
1459
+ def _parse_workspace_symbols(result: list[Any]) -> list[SymbolInfo]:
1460
+ """Parse workspace symbols."""
1461
+ return [
1462
+ SymbolInfo(
1463
+ name=item["name"],
1464
+ kind=SYMBOL_KIND_MAP.get(item.get("kind", 0), "unknown"),
1465
+ location=_parse_location(item["location"]),
1466
+ container_name=item.get("containerName"),
1467
+ )
1468
+ for item in result
1469
+ if "location" in item
1470
+ ]
1471
+
1472
+
1473
+ def _parse_completion_item(item: dict[str, Any]) -> CompletionItem:
1474
+ """Parse a completion item."""
1475
+ return CompletionItem(
1476
+ label=item.get("label", ""),
1477
+ kind=COMPLETION_KIND_MAP.get(item.get("kind", 0)),
1478
+ detail=item.get("detail"),
1479
+ documentation=_extract_documentation(item.get("documentation")),
1480
+ insert_text=item.get("insertText"),
1481
+ sort_text=item.get("sortText"),
1482
+ )
1483
+
1484
+
1485
+ def _parse_code_action(action: dict[str, Any]) -> CodeAction:
1486
+ """Parse a code action."""
1487
+ return CodeAction(
1488
+ title=action.get("title", ""),
1489
+ kind=action.get("kind"),
1490
+ is_preferred=action.get("isPreferred", False),
1491
+ edit=action.get("edit"),
1492
+ command=action.get("command"),
1493
+ )
1494
+
1495
+
1496
+ def _parse_call_hierarchy_item(item: dict[str, Any]) -> CallHierarchyItem:
1497
+ """Parse a call hierarchy item."""
1498
+ return CallHierarchyItem(
1499
+ name=item["name"],
1500
+ kind=SYMBOL_KIND_MAP.get(item.get("kind", 0), "unknown"),
1501
+ uri=item["uri"],
1502
+ range=_parse_range(item["range"]),
1503
+ selection_range=_parse_range(item["selectionRange"]),
1504
+ detail=item.get("detail"),
1505
+ data=item.get("data"),
1506
+ )
1507
+
1508
+
1509
+ def _call_hierarchy_item_to_lsp(item: CallHierarchyItem) -> dict[str, Any]:
1510
+ """Convert CallHierarchyItem back to LSP format."""
1511
+ # Find the numeric kind
1512
+ kind_num = 12 # function default
1513
+ for num, name in SYMBOL_KIND_MAP.items():
1514
+ if name == item.kind:
1515
+ kind_num = num
1516
+ break
1517
+
1518
+ return {
1519
+ "name": item.name,
1520
+ "kind": kind_num,
1521
+ "uri": item.uri,
1522
+ "range": {
1523
+ "start": {"line": item.range.start.line, "character": item.range.start.character},
1524
+ "end": {"line": item.range.end.line, "character": item.range.end.character},
1525
+ },
1526
+ "selectionRange": {
1527
+ "start": {
1528
+ "line": item.selection_range.start.line,
1529
+ "character": item.selection_range.start.character,
1530
+ },
1531
+ "end": {
1532
+ "line": item.selection_range.end.line,
1533
+ "character": item.selection_range.end.character,
1534
+ },
1535
+ },
1536
+ "detail": item.detail,
1537
+ "data": item.data,
1538
+ }
1539
+
1540
+
1541
+ def _parse_type_hierarchy_item(item: dict[str, Any]) -> TypeHierarchyItem:
1542
+ """Parse a type hierarchy item."""
1543
+ return TypeHierarchyItem(
1544
+ name=item["name"],
1545
+ kind=SYMBOL_KIND_MAP.get(item.get("kind", 0), "unknown"),
1546
+ uri=item["uri"],
1547
+ range=_parse_range(item["range"]),
1548
+ selection_range=_parse_range(item["selectionRange"]),
1549
+ detail=item.get("detail"),
1550
+ data=item.get("data"),
1551
+ )
1552
+
1553
+
1554
+ def _type_hierarchy_item_to_lsp(item: TypeHierarchyItem) -> dict[str, Any]:
1555
+ """Convert TypeHierarchyItem back to LSP format."""
1556
+ # Find the numeric kind
1557
+ kind_num = 5 # class default
1558
+ for num, name in SYMBOL_KIND_MAP.items():
1559
+ if name == item.kind:
1560
+ kind_num = num
1561
+ break
1562
+
1563
+ return {
1564
+ "name": item.name,
1565
+ "kind": kind_num,
1566
+ "uri": item.uri,
1567
+ "range": {
1568
+ "start": {"line": item.range.start.line, "character": item.range.start.character},
1569
+ "end": {"line": item.range.end.line, "character": item.range.end.character},
1570
+ },
1571
+ "selectionRange": {
1572
+ "start": {
1573
+ "line": item.selection_range.start.line,
1574
+ "character": item.selection_range.start.character,
1575
+ },
1576
+ "end": {
1577
+ "line": item.selection_range.end.line,
1578
+ "character": item.selection_range.end.character,
1579
+ },
1580
+ },
1581
+ "detail": item.detail,
1582
+ "data": item.data,
1583
+ }
1584
+
1585
+
1586
+ def _severity_to_lsp(severity: str) -> int:
1587
+ """Convert severity string to LSP DiagnosticSeverity."""
1588
+ return {
1589
+ "error": 1,
1590
+ "warning": 2,
1591
+ "info": 3,
1592
+ "hint": 4,
1593
+ }.get(severity, 1)