rockycode 0.1.0__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.
- rockycode/__init__.py +1 -0
- rockycode/banner.py +37 -0
- rockycode/cli.py +1386 -0
- rockycode/config.py +178 -0
- rockycode/dream/__init__.py +9 -0
- rockycode/dream/core.py +523 -0
- rockycode/dream/judge.py +134 -0
- rockycode/dream/mining.py +152 -0
- rockycode/dream/proposals.py +440 -0
- rockycode/engine/__init__.py +10 -0
- rockycode/engine/artifact.py +367 -0
- rockycode/engine/budget.py +90 -0
- rockycode/engine/checks.py +157 -0
- rockycode/engine/compaction.py +181 -0
- rockycode/engine/container.py +225 -0
- rockycode/engine/effort.py +46 -0
- rockycode/engine/events.py +101 -0
- rockycode/engine/explore.py +592 -0
- rockycode/engine/goal.py +541 -0
- rockycode/engine/goal_review.py +161 -0
- rockycode/engine/goal_session.py +259 -0
- rockycode/engine/headless.py +481 -0
- rockycode/engine/loop.py +711 -0
- rockycode/engine/lsp.py +473 -0
- rockycode/engine/mcp.py +364 -0
- rockycode/engine/modes.py +123 -0
- rockycode/engine/outcome.py +81 -0
- rockycode/engine/permission.py +198 -0
- rockycode/engine/planmode.py +249 -0
- rockycode/engine/providers.py +196 -0
- rockycode/engine/redact.py +83 -0
- rockycode/engine/safety.py +139 -0
- rockycode/engine/sandbox.py +219 -0
- rockycode/engine/server.py +431 -0
- rockycode/engine/skills.py +178 -0
- rockycode/engine/titler.py +46 -0
- rockycode/engine/tools.py +479 -0
- rockycode/engine/trajectory.py +131 -0
- rockycode/engine/web.py +431 -0
- rockycode/engine/worktree.py +128 -0
- rockycode/memory/__init__.py +7 -0
- rockycode/memory/index.py +260 -0
- rockycode/memory/store.py +331 -0
- rockycode/modes/learn/learn.md +46 -0
- rockycode/modes/research/deep-research.md +53 -0
- rockycode/modes/research/paper-reading.md +49 -0
- rockycode/modes/research/prove.md +60 -0
- rockycode/modes/research/whiteboard.md +64 -0
- rockycode/onboarding.py +332 -0
- rockycode/palette.py +15 -0
- rockycode/pricing.py +178 -0
- rockycode/prompts/__init__.py +0 -0
- rockycode/prompts/rocky.py +257 -0
- rockycode/routines.py +287 -0
- rockycode/runners/__init__.py +0 -0
- rockycode/runners/agent.py +273 -0
- rockycode/runners/data.py +61 -0
- rockycode/runners/raw.py +176 -0
- rockycode/score.py +114 -0
- rockycode/session.py +298 -0
- rockycode/skills/architecture-viz/SKILL.md +71 -0
- rockycode/skills/architecture-viz/template.html +87 -0
- rockycode/skills/lean-prover/SKILL.md +155 -0
- rockycode/skills/lean-prover/torchlean-api.md +85 -0
- rockycode/tui/__init__.py +1 -0
- rockycode/tui/app.py +2450 -0
- rockycode/tui/exitsheet.py +181 -0
- rockycode/tui/goal_screen.py +315 -0
- rockycode/tui/mdterm.py +232 -0
- rockycode/tui/mdview.py +99 -0
- rockycode/tui/modepicker.py +103 -0
- rockycode/tui/permission.py +154 -0
- rockycode/tui/plangate.py +110 -0
- rockycode/tui/prompt_history.py +77 -0
- rockycode/tui/proposalcard.py +126 -0
- rockycode/tui/resume.py +142 -0
- rockycode/tui/rocky_pet.py +96 -0
- rockycode/tui/routinecard.py +123 -0
- rockycode-0.1.0.dist-info/METADATA +488 -0
- rockycode-0.1.0.dist-info/RECORD +83 -0
- rockycode-0.1.0.dist-info/WHEEL +4 -0
- rockycode-0.1.0.dist-info/entry_points.txt +2 -0
- rockycode-0.1.0.dist-info/licenses/LICENSE +21 -0
rockycode/engine/lsp.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""LSP bridge — connect to a language server via MCP and expose diagnostics.
|
|
2
|
+
|
|
3
|
+
Design: reuses the MCP actor pattern from mcp.py (one asyncio task per
|
|
4
|
+
connection, Queue-based dispatch, ready Future). The external MCP server
|
|
5
|
+
(e.g. karellen-lsp-mcp, zircote/lsp-tools) wraps an actual LSP server
|
|
6
|
+
(pyright / gopls / typescript-language-server) and exposes its operations
|
|
7
|
+
as MCP tools.
|
|
8
|
+
|
|
9
|
+
Phase 1: diagnostics-only. Phase 2 adds the 4 semantic tools.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LSPManager:
|
|
19
|
+
"""Connect to an LSP MCP server and provide diagnostics.
|
|
20
|
+
|
|
21
|
+
If *config* is None the manager is a no-op — diagnostics always returns
|
|
22
|
+
an empty string. This lets callers unconditionally append without
|
|
23
|
+
checking whether LSP is active.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
CALL_TIMEOUT_S = 30 # diagnostics on a cold LSP can take a moment
|
|
27
|
+
|
|
28
|
+
def __init__(self, command: str, args: list[str] | None = None) -> None:
|
|
29
|
+
self.command = command
|
|
30
|
+
self.args = args or []
|
|
31
|
+
self._queue: asyncio.Queue | None = None
|
|
32
|
+
self.ready: asyncio.Future | None = None
|
|
33
|
+
self._task: asyncio.Task | None = None
|
|
34
|
+
self._tool_names: set[str] = set()
|
|
35
|
+
|
|
36
|
+
# ------------------------------------------------------------------
|
|
37
|
+
async def start(self) -> None:
|
|
38
|
+
"""Launch the LSP MCP server subprocess and initialise the session."""
|
|
39
|
+
self._queue = asyncio.Queue()
|
|
40
|
+
self.ready = asyncio.get_event_loop().create_future()
|
|
41
|
+
self._task = asyncio.create_task(self._run(), name="lsp-bridge")
|
|
42
|
+
|
|
43
|
+
async def _run(self) -> None:
|
|
44
|
+
from mcp import ClientSession, StdioServerParameters
|
|
45
|
+
from mcp.client.stdio import stdio_client
|
|
46
|
+
|
|
47
|
+
from rockycode.engine.mcp import _safe_child_env
|
|
48
|
+
|
|
49
|
+
devnull = open(os.devnull, "w")
|
|
50
|
+
params = StdioServerParameters(
|
|
51
|
+
command=self.command,
|
|
52
|
+
args=self.args,
|
|
53
|
+
env=_safe_child_env({}), # scrub secrets from the LSP subprocess too
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
async with stdio_client(params, errlog=devnull) as (read, write):
|
|
57
|
+
async with ClientSession(read, write) as session:
|
|
58
|
+
await session.initialize()
|
|
59
|
+
listed = await session.list_tools()
|
|
60
|
+
self._tool_names = {t.name for t in listed.tools}
|
|
61
|
+
if not self.ready.done():
|
|
62
|
+
self.ready.set_result(None)
|
|
63
|
+
while True:
|
|
64
|
+
req = await self._queue.get()
|
|
65
|
+
if req is None:
|
|
66
|
+
return
|
|
67
|
+
tool_name, arguments, fut = req
|
|
68
|
+
try:
|
|
69
|
+
result = await asyncio.wait_for(
|
|
70
|
+
session.call_tool(tool_name, arguments),
|
|
71
|
+
timeout=self.CALL_TIMEOUT_S,
|
|
72
|
+
)
|
|
73
|
+
if not fut.done():
|
|
74
|
+
fut.set_result(result)
|
|
75
|
+
except Exception as exc: # noqa: BLE001
|
|
76
|
+
if not fut.done():
|
|
77
|
+
fut.set_exception(exc)
|
|
78
|
+
except Exception as exc: # noqa: BLE001
|
|
79
|
+
if not self.ready.done():
|
|
80
|
+
self.ready.set_exception(exc)
|
|
81
|
+
finally:
|
|
82
|
+
# Worker is exiting (server died, or stop requested): fail every
|
|
83
|
+
# still-queued request so its caller's `await fut` returns instead of
|
|
84
|
+
# hanging forever. Pairs with the wait_for timeout in _call.
|
|
85
|
+
devnull.close()
|
|
86
|
+
if self._queue is not None:
|
|
87
|
+
while not self._queue.empty():
|
|
88
|
+
try:
|
|
89
|
+
item = self._queue.get_nowait()
|
|
90
|
+
except asyncio.QueueEmpty:
|
|
91
|
+
break
|
|
92
|
+
if item is not None and not item[2].done():
|
|
93
|
+
item[2].set_exception(RuntimeError("lsp server stopped"))
|
|
94
|
+
|
|
95
|
+
async def _call(self, tool_name: str, arguments: dict) -> str:
|
|
96
|
+
# No live worker (never started, or the server process died) → don't
|
|
97
|
+
# enqueue into a queue nothing will drain; that used to hang forever.
|
|
98
|
+
if self._task is None or self._queue is None or self._task.done():
|
|
99
|
+
return ""
|
|
100
|
+
fut: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
101
|
+
await self._queue.put((tool_name, arguments, fut))
|
|
102
|
+
try:
|
|
103
|
+
# Bound the wait: a worker that dies AFTER we enqueue, or a wedged
|
|
104
|
+
# server, must never block the agent loop. A hair above the per-call
|
|
105
|
+
# timeout inside _run so a genuinely slow call reports its own error.
|
|
106
|
+
result = await asyncio.wait_for(fut, timeout=self.CALL_TIMEOUT_S + 5)
|
|
107
|
+
except asyncio.TimeoutError:
|
|
108
|
+
return ""
|
|
109
|
+
# MCP CallToolResult — first text content
|
|
110
|
+
for c in (result.content or []):
|
|
111
|
+
if hasattr(c, "text"):
|
|
112
|
+
return c.text
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
# public API
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
async def get_diagnostics(self, path: str | Path) -> str:
|
|
120
|
+
"""Return formatted diagnostics for *path*, or '' if none / no LSP."""
|
|
121
|
+
if self._task is None:
|
|
122
|
+
return ""
|
|
123
|
+
try:
|
|
124
|
+
raw = await self._call("diagnostics", {"path": str(path)})
|
|
125
|
+
except Exception: # noqa: BLE001
|
|
126
|
+
return ""
|
|
127
|
+
if not raw or not raw.strip():
|
|
128
|
+
return ""
|
|
129
|
+
lines = raw.strip().splitlines()
|
|
130
|
+
if not lines:
|
|
131
|
+
return ""
|
|
132
|
+
header = "\n LSP diagnostics\n " + "-" * 39
|
|
133
|
+
body = "\n ".join(lines[:20]) # cap at 20 diag lines
|
|
134
|
+
if len(lines) > 20:
|
|
135
|
+
body += f"\n ... and {len(lines) - 20} more"
|
|
136
|
+
return header + "\n " + body
|
|
137
|
+
|
|
138
|
+
async def stop(self) -> None:
|
|
139
|
+
if self._task is None or self._queue is None:
|
|
140
|
+
return
|
|
141
|
+
await self._queue.put(None)
|
|
142
|
+
try:
|
|
143
|
+
await asyncio.wait_for(self._task, timeout=5)
|
|
144
|
+
except (asyncio.TimeoutError, Exception): # noqa: BLE001
|
|
145
|
+
self._task.cancel()
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def is_running(self) -> bool:
|
|
149
|
+
return self._task is not None and not self._task.done()
|
|
150
|
+
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
# tool names exposed by the LSP MCP server (for Phase 2)
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def available_tools(self) -> set[str]:
|
|
157
|
+
return self._tool_names
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
# resolve an LSP MCP server config from the environment
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def resolve_lsp_config() -> tuple[str, list[str]] | None:
|
|
165
|
+
"""Return (command, args) for an LSP MCP server, or None.
|
|
166
|
+
|
|
167
|
+
Checks, in order:
|
|
168
|
+
1. ROCKYCODE_LSP_COMMAND env var (shell-split on whitespace)
|
|
169
|
+
2. Auto-detection: pyright --version → karellen-lsp-mcp-like wrapper
|
|
170
|
+
"""
|
|
171
|
+
env_cmd = os.getenv("ROCKYCODE_LSP_COMMAND")
|
|
172
|
+
if env_cmd:
|
|
173
|
+
parts = env_cmd.split()
|
|
174
|
+
return parts[0], parts[1:]
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
# Phase 2 — four composite LSP tools
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
# Map our semantic actions to likely MCP tool names on the LSP server.
|
|
183
|
+
# Multiple variants are tried in order — the first available one wins.
|
|
184
|
+
_ACTION_TOOL_MAP: dict[str, list[str]] = {
|
|
185
|
+
"definition": ["definition", "go_to_definition", "goToDefinition"],
|
|
186
|
+
"references": ["references", "find_references", "findReferences"],
|
|
187
|
+
"hover": ["hover", "hover_info", "hoverInfo"],
|
|
188
|
+
"implementation": ["implementation", "go_to_implementation", "goToImplementation"],
|
|
189
|
+
"callers": ["callers", "incoming_calls", "incomingCalls", "call_hierarchy_incoming"],
|
|
190
|
+
"callees": ["callees", "outgoing_calls", "outgoingCalls", "call_hierarchy_outgoing"],
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def build_lsp_tools(lsp: "LSPManager") -> dict:
|
|
195
|
+
"""Return 4 composite LSP tools bound to *lsp*.
|
|
196
|
+
|
|
197
|
+
Each tool produces human-readable text, not raw JSON — designed for
|
|
198
|
+
DeepSeek's reasoning_content to digest efficiently.
|
|
199
|
+
"""
|
|
200
|
+
from rockycode.engine.tools import Tool, _fn_schema, _truncate
|
|
201
|
+
|
|
202
|
+
def _pick_tool(actions: list[str]) -> str | None:
|
|
203
|
+
"""Return the first MCP tool name from *actions* that the server supports."""
|
|
204
|
+
for name in actions:
|
|
205
|
+
if name in lsp.available_tools:
|
|
206
|
+
return name
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
async def _call_or_fallback(tool_names: list[str], arguments: dict) -> str:
|
|
210
|
+
tool = _pick_tool(tool_names)
|
|
211
|
+
if tool is None:
|
|
212
|
+
return f"[error] lsp server does not support this operation (tried: {', '.join(tool_names[:3])})"
|
|
213
|
+
try:
|
|
214
|
+
raw = await lsp._call(tool, arguments)
|
|
215
|
+
except Exception as exc: # noqa: BLE001
|
|
216
|
+
return f"[error] lsp call failed: {type(exc).__name__}: {exc}"
|
|
217
|
+
if not raw or not raw.strip():
|
|
218
|
+
return "[no results]"
|
|
219
|
+
return raw.strip()
|
|
220
|
+
|
|
221
|
+
# -- lsp_lookup -------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
_LOOKUP_ACTIONS = ["definition", "references", "hover", "implementation", "callers", "callees"]
|
|
224
|
+
|
|
225
|
+
async def lsp_lookup(symbol: str, action: str) -> str:
|
|
226
|
+
"""Look up a symbol — definition, references, hover info, etc."""
|
|
227
|
+
action = action.lower().strip()
|
|
228
|
+
if action not in _LOOKUP_ACTIONS:
|
|
229
|
+
return f"[error] unknown action '{action}'. choose: {', '.join(_LOOKUP_ACTIONS)}"
|
|
230
|
+
tool_names = _ACTION_TOOL_MAP[action]
|
|
231
|
+
result = await _call_or_fallback(tool_names, {"symbol": symbol})
|
|
232
|
+
header = f"lsp {action}: {symbol}\n" + "-" * (len(action) + len(symbol) + 5)
|
|
233
|
+
return _truncate(f"{header}\n{result}", 8000)
|
|
234
|
+
|
|
235
|
+
# -- lsp_symbol_search -------------------------------------------------
|
|
236
|
+
|
|
237
|
+
async def lsp_symbol_search(query: str) -> str:
|
|
238
|
+
"""Search for symbols across the project by name (fuzzy)."""
|
|
239
|
+
result = await _call_or_fallback(
|
|
240
|
+
["workspace_symbol", "workspaceSymbol", "symbol_search", "search_symbols"],
|
|
241
|
+
{"query": query},
|
|
242
|
+
)
|
|
243
|
+
header = f"symbol search: {query}\n" + "-" * (len(query) + 15)
|
|
244
|
+
return _truncate(f"{header}\n{result}", 8000)
|
|
245
|
+
|
|
246
|
+
# -- lsp_file_symbols --------------------------------------------------
|
|
247
|
+
|
|
248
|
+
async def lsp_file_symbols(path: str) -> str:
|
|
249
|
+
"""List all symbols in a file (classes, functions, etc.) with signatures."""
|
|
250
|
+
result = await _call_or_fallback(
|
|
251
|
+
["document_symbol", "documentSymbol", "file_symbols"],
|
|
252
|
+
{"path": path},
|
|
253
|
+
)
|
|
254
|
+
header = f"symbols in {path}\n" + "-" * (len(path) + 12)
|
|
255
|
+
return _truncate(f"{header}\n{result}", 8000)
|
|
256
|
+
|
|
257
|
+
# -- lsp_diagnostics (active query) ------------------------------------
|
|
258
|
+
|
|
259
|
+
async def lsp_diagnostics(path: str = "") -> str:
|
|
260
|
+
"""Get diagnostics for a file, or project-wide if path is empty."""
|
|
261
|
+
args = {"path": path} if path else {}
|
|
262
|
+
result = await _call_or_fallback(
|
|
263
|
+
["diagnostics", "get_diagnostics", "publish_diagnostics"],
|
|
264
|
+
args,
|
|
265
|
+
)
|
|
266
|
+
label = path or "project"
|
|
267
|
+
header = f"diagnostics: {label}\n" + "-" * (len(label) + 14)
|
|
268
|
+
return _truncate(f"{header}\n{result}", 8000)
|
|
269
|
+
|
|
270
|
+
# -- schemas -----------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
schemas = {
|
|
273
|
+
"lsp_lookup": _fn_schema(
|
|
274
|
+
"lsp_lookup",
|
|
275
|
+
"Look up a symbol using LSP code intelligence — get its definition, "
|
|
276
|
+
"all references, type signature (hover), interface implementations, "
|
|
277
|
+
"or who calls / is called by it. One call replaces several grep "
|
|
278
|
+
"searches with precise, compiler-accurate results.",
|
|
279
|
+
{
|
|
280
|
+
"symbol": {
|
|
281
|
+
"type": "string",
|
|
282
|
+
"description": "Fully-qualified or plain symbol name, e.g. 'UserModel.get_name' or 'get_name'.",
|
|
283
|
+
},
|
|
284
|
+
"action": {
|
|
285
|
+
"type": "string",
|
|
286
|
+
"enum": _LOOKUP_ACTIONS,
|
|
287
|
+
"description": "What to look up: definition, references, hover, implementation, callers, callees.",
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
["symbol", "action"],
|
|
291
|
+
),
|
|
292
|
+
"lsp_symbol_search": _fn_schema(
|
|
293
|
+
"lsp_symbol_search",
|
|
294
|
+
"Search for symbols across the entire project by name. "
|
|
295
|
+
"Returns matching classes, functions, interfaces — fuzzy, not regex. "
|
|
296
|
+
"Use this to find where something is defined when you don't know the file.",
|
|
297
|
+
{
|
|
298
|
+
"query": {
|
|
299
|
+
"type": "string",
|
|
300
|
+
"description": "Partial symbol name to search for, e.g. 'UserAuth' or 'handle_login'.",
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
["query"],
|
|
304
|
+
),
|
|
305
|
+
"lsp_file_symbols": _fn_schema(
|
|
306
|
+
"lsp_file_symbols",
|
|
307
|
+
"List all symbols (classes, functions, methods, variables) in a file "
|
|
308
|
+
"with their type signatures. Use to quickly understand a file's "
|
|
309
|
+
"structure without reading every line.",
|
|
310
|
+
{
|
|
311
|
+
"path": {
|
|
312
|
+
"type": "string",
|
|
313
|
+
"description": "Path to the file to inspect.",
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
["path"],
|
|
317
|
+
),
|
|
318
|
+
"lsp_diagnostics": _fn_schema(
|
|
319
|
+
"lsp_diagnostics",
|
|
320
|
+
"Get compiler/linter diagnostics for a file, or project-wide if no "
|
|
321
|
+
"path is given. Returns errors, warnings, and hints with line numbers. "
|
|
322
|
+
"Use after making changes to verify correctness.",
|
|
323
|
+
{
|
|
324
|
+
"path": {
|
|
325
|
+
"type": "string",
|
|
326
|
+
"description": "File path to check. Omit for project-wide diagnostics.",
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
[], # path is optional
|
|
330
|
+
),
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
fns = {
|
|
334
|
+
"lsp_lookup": lsp_lookup,
|
|
335
|
+
"lsp_symbol_search": lsp_symbol_search,
|
|
336
|
+
"lsp_file_symbols": lsp_file_symbols,
|
|
337
|
+
"lsp_diagnostics": lsp_diagnostics,
|
|
338
|
+
}
|
|
339
|
+
return {name: Tool(name=name, schema=schemas[name], fn=fns[name]) for name in fns}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ------------------------------------------------------------------
|
|
343
|
+
# Phase 4 — multi-tenant LSP manager
|
|
344
|
+
# ------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
class MultiTenantLSPManager:
|
|
347
|
+
"""Lazy per-session LSP connections with idle timeout.
|
|
348
|
+
|
|
349
|
+
Each session (sandbox / worktree / bench task) gets its own LSPManager
|
|
350
|
+
instance, keyed by a string session_id. Sessions are created on first
|
|
351
|
+
access and destroyed after *idle_timeout* seconds of inactivity.
|
|
352
|
+
|
|
353
|
+
In single-session mode the key ``"default"`` is used automatically,
|
|
354
|
+
making MultiTenantLSPManager a drop-in replacement for LSPManager.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
IDLE_TIMEOUT = 300 # 5 minutes
|
|
358
|
+
|
|
359
|
+
def __init__(
|
|
360
|
+
self,
|
|
361
|
+
command: str,
|
|
362
|
+
args: list[str] | None = None,
|
|
363
|
+
*,
|
|
364
|
+
idle_timeout: int = IDLE_TIMEOUT,
|
|
365
|
+
) -> None:
|
|
366
|
+
self._command = command
|
|
367
|
+
self._args = args or []
|
|
368
|
+
self._idle_timeout = idle_timeout
|
|
369
|
+
self._sessions: dict[str, LSPManager] = {}
|
|
370
|
+
self._last_used: dict[str, float] = {}
|
|
371
|
+
self._cleanup_task: asyncio.Task | None = None
|
|
372
|
+
|
|
373
|
+
# -- lifecycle -----------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
async def start(self, session_id: str = "default") -> None:
|
|
376
|
+
"""Ensure a session is started. Safe to call multiple times."""
|
|
377
|
+
import time as _time
|
|
378
|
+
if session_id not in self._sessions:
|
|
379
|
+
mgr = LSPManager(self._command, list(self._args))
|
|
380
|
+
await mgr.start()
|
|
381
|
+
self._sessions[session_id] = mgr
|
|
382
|
+
self._last_used[session_id] = _time.monotonic()
|
|
383
|
+
|
|
384
|
+
async def start_cleanup_loop(self) -> None:
|
|
385
|
+
"""Begin background cleanup of idle sessions. Call once."""
|
|
386
|
+
if self._cleanup_task is not None:
|
|
387
|
+
return
|
|
388
|
+
self._cleanup_task = asyncio.create_task(self._cleanup_loop(), name="lsp-cleanup")
|
|
389
|
+
|
|
390
|
+
async def _cleanup_loop(self) -> None:
|
|
391
|
+
import time as _time
|
|
392
|
+
while True:
|
|
393
|
+
await asyncio.sleep(min(self._idle_timeout // 2, 60))
|
|
394
|
+
now = _time.monotonic()
|
|
395
|
+
stale = [
|
|
396
|
+
sid for sid, last in self._last_used.items()
|
|
397
|
+
if sid != "default" and now - last > self._idle_timeout
|
|
398
|
+
]
|
|
399
|
+
for sid in stale:
|
|
400
|
+
mgr = self._sessions.pop(sid, None)
|
|
401
|
+
self._last_used.pop(sid, None)
|
|
402
|
+
if mgr is not None:
|
|
403
|
+
try:
|
|
404
|
+
await mgr.stop()
|
|
405
|
+
except Exception: # noqa: BLE001
|
|
406
|
+
pass
|
|
407
|
+
|
|
408
|
+
# -- per-session ---------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
async def get_or_create(self, session_id: str = "default") -> LSPManager:
|
|
411
|
+
"""Return the LSPManager for *session_id*, creating it if needed."""
|
|
412
|
+
import time as _time
|
|
413
|
+
if session_id not in self._sessions:
|
|
414
|
+
await self.start(session_id)
|
|
415
|
+
self._last_used[session_id] = _time.monotonic()
|
|
416
|
+
return self._sessions[session_id]
|
|
417
|
+
|
|
418
|
+
async def evict(self, session_id: str) -> None:
|
|
419
|
+
"""Stop and remove a session (e.g. sandbox destroyed)."""
|
|
420
|
+
mgr = self._sessions.pop(session_id, None)
|
|
421
|
+
self._last_used.pop(session_id, None)
|
|
422
|
+
if mgr is not None:
|
|
423
|
+
await mgr.stop()
|
|
424
|
+
|
|
425
|
+
# -- delegation (for the default session) --------------------------------
|
|
426
|
+
|
|
427
|
+
async def get_diagnostics(self, path: str | Path) -> str:
|
|
428
|
+
"""Get diagnostics from the default session (no auto-create)."""
|
|
429
|
+
mgr = self._sessions.get("default")
|
|
430
|
+
if mgr is None:
|
|
431
|
+
return ""
|
|
432
|
+
try:
|
|
433
|
+
return await mgr.get_diagnostics(path)
|
|
434
|
+
except Exception: # noqa: BLE001
|
|
435
|
+
return ""
|
|
436
|
+
|
|
437
|
+
@property
|
|
438
|
+
def is_running(self) -> bool:
|
|
439
|
+
mgr = self._sessions.get("default")
|
|
440
|
+
return mgr is not None and mgr.is_running
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def available_tools(self) -> set[str]:
|
|
444
|
+
mgr = self._sessions.get("default")
|
|
445
|
+
return mgr.available_tools if mgr else set()
|
|
446
|
+
|
|
447
|
+
@property
|
|
448
|
+
def command(self) -> str:
|
|
449
|
+
return self._command
|
|
450
|
+
|
|
451
|
+
async def _call(self, tool_name: str, arguments: dict) -> str:
|
|
452
|
+
mgr = self._sessions.get("default")
|
|
453
|
+
if mgr is None:
|
|
454
|
+
return "[error] lsp not connected — start a session first"
|
|
455
|
+
return await mgr._call(tool_name, arguments)
|
|
456
|
+
|
|
457
|
+
async def stop(self) -> None:
|
|
458
|
+
if self._cleanup_task is not None:
|
|
459
|
+
self._cleanup_task.cancel()
|
|
460
|
+
self._cleanup_task = None
|
|
461
|
+
for mgr in self._sessions.values():
|
|
462
|
+
try:
|
|
463
|
+
await mgr.stop()
|
|
464
|
+
except Exception: # noqa: BLE001
|
|
465
|
+
pass
|
|
466
|
+
self._sessions.clear()
|
|
467
|
+
self._last_used.clear()
|
|
468
|
+
|
|
469
|
+
@property
|
|
470
|
+
def ready(self) -> "asyncio.Future | None":
|
|
471
|
+
"""Expose the default session's ready Future for compatibility."""
|
|
472
|
+
mgr = self._sessions.get("default")
|
|
473
|
+
return mgr.ready if mgr else None
|