bareagent-cli 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.
Files changed (121) hide show
  1. bareagent/__init__.py +10 -0
  2. bareagent/concurrency/__init__.py +6 -0
  3. bareagent/concurrency/background.py +97 -0
  4. bareagent/concurrency/notification.py +61 -0
  5. bareagent/concurrency/scheduler.py +136 -0
  6. bareagent/config.toml +299 -0
  7. bareagent/core/__init__.py +1 -0
  8. bareagent/core/config_paths.py +49 -0
  9. bareagent/core/context.py +127 -0
  10. bareagent/core/fileutil.py +103 -0
  11. bareagent/core/goal.py +214 -0
  12. bareagent/core/handlers/__init__.py +1 -0
  13. bareagent/core/handlers/bash.py +79 -0
  14. bareagent/core/handlers/file_edit.py +47 -0
  15. bareagent/core/handlers/file_read.py +270 -0
  16. bareagent/core/handlers/file_write.py +34 -0
  17. bareagent/core/handlers/glob_search.py +30 -0
  18. bareagent/core/handlers/goal.py +60 -0
  19. bareagent/core/handlers/grep_search.py +52 -0
  20. bareagent/core/handlers/memory.py +71 -0
  21. bareagent/core/handlers/plan.py +106 -0
  22. bareagent/core/handlers/search_utils.py +77 -0
  23. bareagent/core/handlers/skill.py +87 -0
  24. bareagent/core/handlers/subagent_send.py +70 -0
  25. bareagent/core/handlers/web_fetch.py +126 -0
  26. bareagent/core/handlers/web_search.py +165 -0
  27. bareagent/core/handlers/workflow.py +190 -0
  28. bareagent/core/loop.py +535 -0
  29. bareagent/core/retry.py +131 -0
  30. bareagent/core/sandbox.py +27 -0
  31. bareagent/core/schema.py +21 -0
  32. bareagent/core/tools.py +779 -0
  33. bareagent/core/workflow.py +517 -0
  34. bareagent/core/workflow_registry.py +219 -0
  35. bareagent/debug/__init__.py +0 -0
  36. bareagent/debug/interaction_log.py +263 -0
  37. bareagent/debug/viewer.html +1750 -0
  38. bareagent/debug/web_viewer.py +157 -0
  39. bareagent/hooks/__init__.py +32 -0
  40. bareagent/hooks/config.py +118 -0
  41. bareagent/hooks/engine.py +197 -0
  42. bareagent/hooks/errors.py +14 -0
  43. bareagent/hooks/events.py +22 -0
  44. bareagent/lsp/__init__.py +63 -0
  45. bareagent/lsp/config.py +134 -0
  46. bareagent/lsp/coord.py +118 -0
  47. bareagent/lsp/diagnostics.py +240 -0
  48. bareagent/lsp/errors.py +24 -0
  49. bareagent/lsp/manager.py +866 -0
  50. bareagent/lsp/tools.py +629 -0
  51. bareagent/lsp/workspace_edit.py +305 -0
  52. bareagent/main.py +4205 -0
  53. bareagent/mcp/__init__.py +69 -0
  54. bareagent/mcp/_sse.py +69 -0
  55. bareagent/mcp/client.py +341 -0
  56. bareagent/mcp/config.py +169 -0
  57. bareagent/mcp/errors.py +32 -0
  58. bareagent/mcp/manager.py +318 -0
  59. bareagent/mcp/protocol.py +187 -0
  60. bareagent/mcp/registry.py +557 -0
  61. bareagent/mcp/transport/__init__.py +15 -0
  62. bareagent/mcp/transport/base.py +149 -0
  63. bareagent/mcp/transport/http_legacy.py +192 -0
  64. bareagent/mcp/transport/http_streamable.py +217 -0
  65. bareagent/mcp/transport/stdio.py +202 -0
  66. bareagent/memory/__init__.py +1 -0
  67. bareagent/memory/compact.py +203 -0
  68. bareagent/memory/conversation_io.py +226 -0
  69. bareagent/memory/embedding.py +194 -0
  70. bareagent/memory/persistent.py +515 -0
  71. bareagent/memory/token_counter.py +67 -0
  72. bareagent/memory/token_tracker.py +262 -0
  73. bareagent/memory/transcript.py +100 -0
  74. bareagent/permission/__init__.py +1 -0
  75. bareagent/permission/guard.py +329 -0
  76. bareagent/permission/rules.py +19 -0
  77. bareagent/planning/__init__.py +19 -0
  78. bareagent/planning/agent_types.py +169 -0
  79. bareagent/planning/skill_gen.py +141 -0
  80. bareagent/planning/skill_store.py +173 -0
  81. bareagent/planning/skills.py +146 -0
  82. bareagent/planning/subagent.py +355 -0
  83. bareagent/planning/subagent_registry.py +77 -0
  84. bareagent/planning/tasks.py +348 -0
  85. bareagent/planning/todo.py +153 -0
  86. bareagent/planning/worktree.py +122 -0
  87. bareagent/provider/__init__.py +1 -0
  88. bareagent/provider/anthropic.py +348 -0
  89. bareagent/provider/base.py +136 -0
  90. bareagent/provider/factory.py +130 -0
  91. bareagent/provider/openai.py +881 -0
  92. bareagent/provider/presets.py +72 -0
  93. bareagent/provider/setup.py +356 -0
  94. bareagent/skills/.gitkeep +1 -0
  95. bareagent/skills/code-review/SKILL.md +68 -0
  96. bareagent/skills/git/SKILL.md +68 -0
  97. bareagent/skills/test/SKILL.md +70 -0
  98. bareagent/team/__init__.py +17 -0
  99. bareagent/team/autonomous.py +193 -0
  100. bareagent/team/mailbox.py +239 -0
  101. bareagent/team/manager.py +155 -0
  102. bareagent/team/protocols.py +129 -0
  103. bareagent/tracing/__init__.py +12 -0
  104. bareagent/tracing/_api.py +92 -0
  105. bareagent/tracing/_proxy.py +60 -0
  106. bareagent/tracing/composite.py +115 -0
  107. bareagent/tracing/json_file.py +115 -0
  108. bareagent/tracing/langfuse.py +139 -0
  109. bareagent/tracing/otel.py +107 -0
  110. bareagent/tracing/setup.py +85 -0
  111. bareagent/ui/__init__.py +24 -0
  112. bareagent/ui/console.py +167 -0
  113. bareagent/ui/prompt.py +78 -0
  114. bareagent/ui/protocol.py +24 -0
  115. bareagent/ui/stream.py +66 -0
  116. bareagent/ui/theme.py +240 -0
  117. bareagent_cli-0.1.0.dist-info/METADATA +331 -0
  118. bareagent_cli-0.1.0.dist-info/RECORD +121 -0
  119. bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
  120. bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  121. bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,866 @@
1
+ """Multi-server LSP manager: concurrent startup, status tracking, file routing.
2
+
3
+ Wraps multilspy's ``SyncLanguageServer`` (one instance per language) behind a
4
+ single facade. The manager constructs every configured server in parallel,
5
+ enters each ``start_server()`` context, and stores the live server alongside
6
+ its lifecycle status. Slow / failed servers cannot block REPL boot — handshake
7
+ exceptions and timeouts mark that language ``UNHEALTHY`` while the rest
8
+ continue.
9
+
10
+ multilspy is an optional dependency. When it isn't installed, ``start_all()``
11
+ runs as a no-op and every configured server is marked ``UNHEALTHY`` with the
12
+ reason ``"multilspy extra not installed"``.
13
+
14
+ Diagnostics surface
15
+ -------------------
16
+ multilspy 0.0.15 explicitly registers a ``do_nothing`` handler for the
17
+ ``textDocument/publishDiagnostics`` notification on every bundled language-
18
+ server adapter (see ``multilspy/language_servers/<lang>/<lang>.py``). It also
19
+ does not expose pull-diagnostics on ``SyncLanguageServer``. The manager works
20
+ around both gaps post-handshake by:
21
+
22
+ 1. Reaching into ``sync_server.language_server.server.on_notification_handlers``
23
+ (the underlying ``LanguageServerHandler`` dict) and replacing the bundled
24
+ ``do_nothing`` with our own callback. This is monkey-patching, but the
25
+ surface is stable across multilspy patch releases and matches the only
26
+ route through which pyright/etc. publish diagnostics.
27
+ 2. Maintaining a per-server cache keyed by *relative path* with a
28
+ ``threading.Event`` that fires whenever a new payload arrives. The Hybrid
29
+ auto-diagnostics hook in ``src/core/handlers/file_edit.py`` waits on this
30
+ event briefly to bridge pyright's incremental analysis lag.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import logging
37
+ import os
38
+ import threading
39
+ from collections.abc import Callable, Iterator
40
+ from concurrent.futures import ThreadPoolExecutor, as_completed
41
+ from enum import StrEnum
42
+ from typing import TYPE_CHECKING, Any, cast
43
+
44
+ from .config import LSPConfig, LSPServerConfig
45
+ from .coord import path_to_document_uri, to_repo_relative
46
+
47
+ if TYPE_CHECKING:
48
+ from bareagent.concurrency.background import BackgroundManager
49
+ from bareagent.ui.protocol import UIProtocol
50
+
51
+ _log = logging.getLogger(__name__)
52
+
53
+
54
+ class ServerStatus(StrEnum):
55
+ """Lifecycle states a managed language server moves through."""
56
+
57
+ STARTING = "starting"
58
+ RUNNING = "running"
59
+ UNHEALTHY = "unhealthy"
60
+ STOPPED = "stopped"
61
+
62
+
63
+ # Sentinel used by ``_status_reasons`` for servers that have a status but no
64
+ # attached reason string yet (e.g. the still-starting / cleanly stopped paths).
65
+ _NO_REASON = ""
66
+
67
+
68
+ class _ServerEntry:
69
+ """Internal bookkeeping for one language server.
70
+
71
+ Holds the live multilspy instance plus the bookkeeping needed to shut it
72
+ down cleanly. ``stop_event`` and ``thread`` exist because multilspy's
73
+ ``start_server`` is a context manager — we keep a worker thread alive
74
+ inside that ``with`` block so the loop and the language server process
75
+ stay running between calls.
76
+ """
77
+
78
+ __slots__ = (
79
+ "server",
80
+ "thread",
81
+ "started_event",
82
+ "stop_event",
83
+ "exit_error",
84
+ "diagnostics",
85
+ "diagnostics_lock",
86
+ "diagnostics_events",
87
+ "disconnect_seen",
88
+ )
89
+
90
+ def __init__(self) -> None:
91
+ self.server: Any = None # the entered SyncLanguageServer (post-__enter__)
92
+ self.thread: threading.Thread | None = None
93
+ self.started_event = threading.Event()
94
+ self.stop_event = threading.Event()
95
+ self.exit_error: BaseException | None = None
96
+ # Push-diagnostics cache keyed by *relative path* (the same form the
97
+ # tool handlers pass to multilspy). Each entry is the latest list of
98
+ # raw LSP Diagnostic dicts seen via ``textDocument/publishDiagnostics``.
99
+ self.diagnostics: dict[str, list[dict[str, Any]]] = {}
100
+ self.diagnostics_lock = threading.Lock()
101
+ # Per-file Event so callers can ``wait_for_diagnostics(file)`` until
102
+ # the server publishes the next analysis pass. Lazily created on
103
+ # first miss so we don't allocate one per file in the workspace.
104
+ self.diagnostics_events: dict[str, threading.Event] = {}
105
+ # Tripped by ``_on_disconnect`` so we only emit the failure notice
106
+ # once per server crash (the watchdog poller could fire repeatedly).
107
+ self.disconnect_seen = False
108
+
109
+
110
+ class LanguageServerManager:
111
+ """Orchestrates a fleet of multilspy language servers.
112
+
113
+ Use ``start_all()`` once at boot, then ``get_server_for_file(path)`` /
114
+ ``iter_running()`` to consume the live instances. Failed servers are
115
+ skipped (logged + surfaced via the UI console if supplied) so REPL
116
+ startup is never blocked.
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ config: LSPConfig,
122
+ console: UIProtocol | None = None,
123
+ repository_root: str | None = None,
124
+ notifier: BackgroundManager | None = None,
125
+ ) -> None:
126
+ self._config = config
127
+ self._console = console
128
+ # ``notifier`` is the shared ``BackgroundManager`` used for background-
129
+ # task completion notifications. Disconnect events ride the same
130
+ # channel so the REPL surface treats them as async events (same
131
+ # pattern as ``MCPManager``).
132
+ self._notifier = notifier
133
+ self._repository_root = (
134
+ os.path.abspath(repository_root) if repository_root else os.getcwd()
135
+ )
136
+ self._lock = threading.Lock()
137
+ # Status of every configured server, keyed by language. Populated
138
+ # eagerly in ``start_all`` so callers can ``get_status`` even before
139
+ # the first server finishes its handshake.
140
+ self._status: dict[str, ServerStatus] = {}
141
+ self._status_reasons: dict[str, str] = {}
142
+ self._entries: dict[str, _ServerEntry] = {}
143
+ # Extension → language lookup, built once from the config so file
144
+ # routing is O(1). Lowercased / leading-dot to match the config
145
+ # parser's normalization.
146
+ self._ext_to_language: dict[str, str] = {}
147
+ for server in config.servers:
148
+ for ext in server.extensions:
149
+ self._ext_to_language[ext] = server.language
150
+ # External callback slot — installed via :meth:`set_on_disconnect`.
151
+ # Kept under a distinct attribute name from the internal
152
+ # ``_on_disconnect`` method so the latter is reachable directly
153
+ # from the watchdog (and from tests synthesising crashes) even
154
+ # when a user callback is registered.
155
+ self._on_disconnect_callback: Callable[[str, str], None] | None = None
156
+ # Subprocess crash watchdog. One daemon thread polls
157
+ # ``language_server.server.process.returncode`` on a short interval
158
+ # and reports unexpected exits through ``_on_disconnect``. Lazily
159
+ # started after the first successful handshake.
160
+ self._watchdog_thread: threading.Thread | None = None
161
+ self._watchdog_stop = threading.Event()
162
+
163
+ # ------------------------------------------------------------------ API
164
+
165
+ @property
166
+ def config(self) -> LSPConfig:
167
+ return self._config
168
+
169
+ @property
170
+ def repository_root(self) -> str:
171
+ return self._repository_root
172
+
173
+ def set_on_disconnect(self, callback: Callable[[str, str], None] | None) -> None:
174
+ """Register an extra callback for unexpected server disconnects.
175
+
176
+ ``callback(language, reason)`` is invoked **in addition to** the
177
+ built-in console + notifier path (see :meth:`_on_disconnect`). Pass
178
+ ``None`` to clear. Reserved for tests / extensions; production callers
179
+ normally just pass ``notifier=...`` at construction.
180
+ """
181
+ self._on_disconnect_callback = callback
182
+
183
+ def get_diagnostics_snapshot(self, file_path: str) -> list[dict[str, Any]]:
184
+ """Return the latest cached publishDiagnostics rows for ``file_path``.
185
+
186
+ Resolves the language by extension, then looks up the cache built by
187
+ the ``textDocument/publishDiagnostics`` handler we installed during
188
+ the handshake (see :meth:`_install_diagnostics_handler`). Returns an
189
+ empty list when no language routes the file or no rows have been
190
+ published yet.
191
+ """
192
+ language = self.language_for_file(file_path)
193
+ if language is None:
194
+ return []
195
+ abs_path = file_path if os.path.isabs(file_path) else os.path.abspath(file_path)
196
+ rel = to_repo_relative(abs_path, self._repository_root)
197
+ with self._lock:
198
+ entry = self._entries.get(language)
199
+ if entry is None:
200
+ return []
201
+ # Try several common key shapes — multilspy normalises URIs but
202
+ # pyright sometimes echoes the absolute path verbatim, and Windows
203
+ # paths use ``\`` vs ``/`` interchangeably.
204
+ keys = (
205
+ rel,
206
+ rel.replace("\\", "/"),
207
+ abs_path,
208
+ abs_path.replace("\\", "/"),
209
+ )
210
+ with entry.diagnostics_lock:
211
+ for key in keys:
212
+ rows = entry.diagnostics.get(key)
213
+ if rows is not None:
214
+ return list(rows)
215
+ return []
216
+
217
+ def wait_for_diagnostics(self, file_path: str, timeout: float = 1.5) -> bool:
218
+ """Block up to ``timeout`` seconds for the next publishDiagnostics on
219
+ ``file_path``. Returns True if a publish happened, False on timeout.
220
+
221
+ Used by the Hybrid auto-diagnostics hook to bridge pyright's
222
+ incremental analysis lag — a write_file landing means the LSP server
223
+ needs a moment to re-analyse before its next ``publishDiagnostics``
224
+ reflects the new content. Modeled after Serena's ``analysis_complete``
225
+ Event pattern (see PRD ``Technical Approach``).
226
+ """
227
+ language = self.language_for_file(file_path)
228
+ if language is None:
229
+ return False
230
+ abs_path = file_path if os.path.isabs(file_path) else os.path.abspath(file_path)
231
+ rel = to_repo_relative(abs_path, self._repository_root)
232
+ with self._lock:
233
+ entry = self._entries.get(language)
234
+ if entry is None:
235
+ return False
236
+ with entry.diagnostics_lock:
237
+ event = entry.diagnostics_events.get(rel)
238
+ if event is None:
239
+ event = threading.Event()
240
+ entry.diagnostics_events[rel] = event
241
+ event.clear()
242
+ return event.wait(timeout=timeout)
243
+
244
+ def summarize(self) -> list[dict[str, Any]]:
245
+ """Return a per-server summary for the ``/lsp status`` REPL command.
246
+
247
+ Order follows ``config.servers`` (TOML insertion order) so the
248
+ listing is stable across reloads. Tool count is ``4`` (Tier-1 tools)
249
+ only for currently RUNNING servers; UNHEALTHY / STOPPED servers
250
+ report ``0`` so the user can see at a glance whether the server is
251
+ actually contributing to ``get_tools()``.
252
+ """
253
+ out: list[dict[str, Any]] = []
254
+ with self._lock:
255
+ for server_cfg in self._config.servers:
256
+ language = server_cfg.language
257
+ status = self._status.get(language, ServerStatus.STOPPED)
258
+ running = status == ServerStatus.RUNNING
259
+ out.append(
260
+ {
261
+ "language": language,
262
+ "status": status.value,
263
+ "tool_count": 4 if running else 0,
264
+ "extensions": list(server_cfg.extensions),
265
+ "reason": self._status_reasons.get(language, ""),
266
+ }
267
+ )
268
+ return out
269
+
270
+ def start_all(self) -> None:
271
+ """Spawn every configured server in parallel; never raises.
272
+
273
+ Each ``SyncLanguageServer.create(...).start_server()`` runs in its own
274
+ worker thread. Failures and timeouts are caught and recorded — the
275
+ method returns when every server has either reached ``RUNNING`` or
276
+ been marked ``UNHEALTHY``.
277
+ """
278
+ servers = list(self._config.servers)
279
+ if not servers:
280
+ return
281
+
282
+ with self._lock:
283
+ for server in servers:
284
+ self._status[server.language] = ServerStatus.STARTING
285
+ self._status_reasons[server.language] = _NO_REASON
286
+
287
+ sync_cls = _import_sync_language_server()
288
+ if sync_cls is None:
289
+ reason = "multilspy extra not installed"
290
+ with self._lock:
291
+ for server in servers:
292
+ self._status[server.language] = ServerStatus.UNHEALTHY
293
+ self._status_reasons[server.language] = reason
294
+ self._warn(
295
+ "LSP servers disabled: multilspy is not installed. "
296
+ 'Install with `uv pip install -e ".[lsp]"`.'
297
+ )
298
+ return
299
+
300
+ max_workers = max(1, len(servers))
301
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
302
+ futures = {
303
+ pool.submit(self._start_one, server, sync_cls): server
304
+ for server in servers
305
+ }
306
+ for future in as_completed(futures):
307
+ server = futures[future]
308
+ try:
309
+ future.result()
310
+ except Exception as exc: # pragma: no cover — defensive net
311
+ _log.warning(
312
+ "LSP server %r start crashed unexpectedly: %s",
313
+ server.language,
314
+ exc,
315
+ )
316
+ with self._lock:
317
+ self._status[server.language] = ServerStatus.UNHEALTHY
318
+ self._status_reasons[server.language] = (
319
+ f"{type(exc).__name__}: {exc}"
320
+ )
321
+ self._warn(f"LSP server {server.language!r} failed to start: {exc}")
322
+
323
+ # All servers settled; spin up the watchdog only if at least one is
324
+ # RUNNING (otherwise there's nothing for it to poll).
325
+ with self._lock:
326
+ has_running = any(s == ServerStatus.RUNNING for s in self._status.values())
327
+ if has_running:
328
+ self._ensure_watchdog()
329
+
330
+ def get_server_for_file(self, path: str) -> Any | None:
331
+ """Return the running ``SyncLanguageServer`` whose extension matches
332
+ ``path``. Returns ``None`` when no extension matches or when the
333
+ matched server is not currently RUNNING.
334
+ """
335
+ language = self.language_for_file(path)
336
+ if language is None:
337
+ return None
338
+ with self._lock:
339
+ if self._status.get(language) != ServerStatus.RUNNING:
340
+ return None
341
+ entry = self._entries.get(language)
342
+ return entry.server if entry is not None else None
343
+
344
+ def request_rename(
345
+ self,
346
+ abs_path: str,
347
+ line0: int,
348
+ col0: int,
349
+ new_name: str,
350
+ ) -> dict[str, Any] | None:
351
+ """Run ``textDocument/rename`` and return the raw ``WorkspaceEdit``.
352
+
353
+ multilspy 0.0.15 does **not** wrap rename on ``SyncLanguageServer`` (it
354
+ only surfaces definition / references / completions / document_symbols /
355
+ hover / workspace_symbol). The bare LSP request is reachable through the
356
+ inner async server (``language_server.server.send.rename(params)``), so
357
+ this method bridges async→sync using the exact pattern multilspy uses
358
+ internally: schedule the coroutine on the server's own event loop via
359
+ :func:`asyncio.run_coroutine_threadsafe` and block on the result.
360
+
361
+ The document is opened (``textDocument/didOpen``) for the duration of the
362
+ request via multilspy's ``open_file`` context manager — the same thing
363
+ ``request_definition`` does internally so the server has the buffer
364
+ loaded before it computes the edit.
365
+
366
+ Returns the raw ``WorkspaceEdit`` dict, or ``None`` when no server routes
367
+ the file, the server is unhealthy, or the request yields no edit. The
368
+ multilspy internals are reached through ``getattr`` guards so a future
369
+ version shift degrades to ``None`` instead of an ``AttributeError``.
370
+ """
371
+ sync_server = self.get_server_for_file(abs_path)
372
+ if sync_server is None:
373
+ return None
374
+
375
+ relpath = to_repo_relative(abs_path, self._repository_root)
376
+ uri = path_to_document_uri(abs_path)
377
+ params = {
378
+ "textDocument": {"uri": uri},
379
+ "position": {"line": line0, "character": col0},
380
+ "newName": new_name,
381
+ }
382
+
383
+ language_server = getattr(sync_server, "language_server", None)
384
+ loop = getattr(sync_server, "loop", None)
385
+ open_file = getattr(language_server, "open_file", None)
386
+ inner = getattr(language_server, "server", None)
387
+ send = getattr(inner, "send", None)
388
+ rename = getattr(send, "rename", None)
389
+ if loop is None or not callable(open_file) or not callable(rename):
390
+ return None
391
+
392
+ async def _rename_coro() -> Any:
393
+ # ``open_file`` is a context manager and ``rename`` an async callable
394
+ # on multilspy's untyped internals; cast through Any so the ``with``
395
+ # / ``await`` below type-check (same convention used elsewhere in
396
+ # this module for multilspy-internal access). The callable() guards
397
+ # above narrow these back to ``object``, hence the explicit casts.
398
+ open_cm = cast(Any, open_file)
399
+ rename_fn = cast(Any, rename)
400
+ with open_cm(relpath):
401
+ return await rename_fn(params)
402
+
403
+ # multilspy spins its own event-loop thread inside ``start_server``;
404
+ # ``sync_server.loop`` is that loop. Scheduling onto it from this
405
+ # (caller) thread is the only safe way to drive the async server.
406
+ future = asyncio.run_coroutine_threadsafe(_rename_coro(), loop)
407
+ timeout = self._config.start_timeout or 15.0
408
+ result = future.result(timeout=timeout)
409
+ if not isinstance(result, dict):
410
+ return None
411
+ return result
412
+
413
+ def language_for_file(self, path: str) -> str | None:
414
+ """Map a file path to its configured language, or None if no
415
+ ``[[lsp.servers]]`` entry claims the extension."""
416
+ _, ext = os.path.splitext(path)
417
+ if not ext:
418
+ return None
419
+ return self._ext_to_language.get(ext.lower())
420
+
421
+ def get_status(self, language: str) -> ServerStatus | None:
422
+ """Lifecycle status for ``language``, or ``None`` when the language is
423
+ not in the active config."""
424
+ with self._lock:
425
+ return self._status.get(language)
426
+
427
+ def get_status_reason(self, language: str) -> str:
428
+ """Free-form explanation for the current status (e.g. handshake error
429
+ message). Empty string when no reason is recorded."""
430
+ with self._lock:
431
+ return self._status_reasons.get(language, _NO_REASON)
432
+
433
+ def iter_running(self) -> Iterator[tuple[str, Any]]:
434
+ """Yield ``(language, SyncLanguageServer)`` pairs for RUNNING servers.
435
+
436
+ Snapshot is taken under the lock so iteration is safe even if a
437
+ teardown is in flight on another thread.
438
+ """
439
+ with self._lock:
440
+ snapshot = [
441
+ (language, entry.server)
442
+ for language, entry in self._entries.items()
443
+ if self._status.get(language) == ServerStatus.RUNNING
444
+ ]
445
+ yield from snapshot
446
+
447
+ def reload(self, language: str) -> None:
448
+ """Tear down ``language`` and rebuild it from the active config entry.
449
+
450
+ Reserved for child B (REPL ``/lsp reload`` command). Behaviour mirrors
451
+ ``MCPManager.reload``: the old instance is stopped, status moves to
452
+ ``STARTING``, then the handshake is retried. On failure status ends
453
+ up ``UNHEALTHY`` and the exception is re-raised so the caller can
454
+ render a message.
455
+ """
456
+ server_cfg = next(
457
+ (s for s in self._config.servers if s.language == language),
458
+ None,
459
+ )
460
+ if server_cfg is None:
461
+ from .errors import LSPError
462
+
463
+ raise LSPError(f"LSP server {language!r} is not in config")
464
+
465
+ self._stop_one(language)
466
+
467
+ sync_cls = _import_sync_language_server()
468
+ if sync_cls is None:
469
+ reason = "multilspy extra not installed"
470
+ with self._lock:
471
+ self._status[language] = ServerStatus.UNHEALTHY
472
+ self._status_reasons[language] = reason
473
+ from .errors import LSPHandshakeError
474
+
475
+ raise LSPHandshakeError(reason)
476
+
477
+ with self._lock:
478
+ self._status[language] = ServerStatus.STARTING
479
+ self._status_reasons[language] = _NO_REASON
480
+ self._start_one(server_cfg, sync_cls)
481
+ if self._status.get(language) != ServerStatus.RUNNING:
482
+ from .errors import LSPHandshakeError
483
+
484
+ raise LSPHandshakeError(
485
+ self.get_status_reason(language) or "handshake failed"
486
+ )
487
+ # The watchdog may have been stopped by a previous ``close_all``;
488
+ # reload should bring it back so the recovered server is also
489
+ # monitored.
490
+ self._ensure_watchdog()
491
+
492
+ def close_all(self) -> None:
493
+ """Stop every managed server. Idempotent; safe to call on exit.
494
+
495
+ Called by ``atexit.register`` *and* the explicit ``finally`` block
496
+ in ``src/main.py``. The watchdog poller is stopped first so it does
497
+ not fire spurious "disconnected" notices while the subprocesses are
498
+ being torn down on purpose.
499
+ """
500
+ # Stop the watchdog before tearing servers down. Otherwise it could
501
+ # observe ``returncode`` flipping non-None during graceful shutdown
502
+ # and mis-report a disconnect.
503
+ self._watchdog_stop.set()
504
+ watchdog = self._watchdog_thread
505
+ if watchdog is not None and watchdog.is_alive():
506
+ watchdog.join(timeout=2.0)
507
+ self._watchdog_thread = None
508
+
509
+ with self._lock:
510
+ languages = list(self._entries.keys())
511
+ for language in languages:
512
+ self._stop_one(language)
513
+ with self._lock:
514
+ for language in self._status:
515
+ if self._status[language] != ServerStatus.UNHEALTHY:
516
+ self._status[language] = ServerStatus.STOPPED
517
+
518
+ # ----------------------------------------------------------- internals
519
+
520
+ def _start_one(self, server: LSPServerConfig, sync_cls: Any) -> None:
521
+ """Spawn one language server and wait for handshake or timeout.
522
+
523
+ Runs the multilspy ``start_server()`` context manager inside a daemon
524
+ thread so the language-server subprocess stays alive between LSP
525
+ tool calls. Marks the language ``RUNNING`` once ``__enter__`` returns,
526
+ otherwise records ``UNHEALTHY`` + a reason.
527
+ """
528
+ entry = _ServerEntry()
529
+ with self._lock:
530
+ self._entries[server.language] = entry
531
+
532
+ thread = threading.Thread(
533
+ target=self._run_server_lifecycle,
534
+ args=(server, entry, sync_cls),
535
+ name=f"lsp-{server.language}",
536
+ daemon=True,
537
+ )
538
+ entry.thread = thread
539
+ thread.start()
540
+
541
+ ready = entry.started_event.wait(timeout=self._config.start_timeout)
542
+ if not ready:
543
+ with self._lock:
544
+ self._status[server.language] = ServerStatus.UNHEALTHY
545
+ self._status_reasons[server.language] = (
546
+ f"handshake timed out after {self._config.start_timeout}s"
547
+ )
548
+ self._warn(
549
+ f"LSP server {server.language!r} timed out after "
550
+ f"{self._config.start_timeout}s"
551
+ )
552
+ return
553
+
554
+ if entry.exit_error is not None:
555
+ error = entry.exit_error
556
+ with self._lock:
557
+ self._status[server.language] = ServerStatus.UNHEALTHY
558
+ self._status_reasons[server.language] = (
559
+ f"{type(error).__name__}: {error}"
560
+ )
561
+ self._warn(f"LSP server {server.language!r} unhealthy: {error}")
562
+ return
563
+
564
+ if entry.server is None:
565
+ with self._lock:
566
+ self._status[server.language] = ServerStatus.UNHEALTHY
567
+ self._status_reasons[server.language] = "handshake produced no server"
568
+ self._warn(f"LSP server {server.language!r} unhealthy: empty server")
569
+ return
570
+
571
+ with self._lock:
572
+ self._status[server.language] = ServerStatus.RUNNING
573
+ self._status_reasons[server.language] = _NO_REASON
574
+
575
+ def _run_server_lifecycle(
576
+ self,
577
+ server: LSPServerConfig,
578
+ entry: _ServerEntry,
579
+ sync_cls: Any,
580
+ ) -> None:
581
+ """Daemon body: build the server, enter its ``start_server()`` context,
582
+ then block on ``stop_event`` until ``close_all`` / ``reload`` signal
583
+ teardown. Any exception aborts the wait and is reported back through
584
+ ``entry.exit_error``.
585
+ """
586
+ try:
587
+ sync_server = _build_sync_server(sync_cls, server, self._repository_root)
588
+ except Exception as exc:
589
+ entry.exit_error = exc
590
+ entry.started_event.set()
591
+ return
592
+
593
+ ctx = sync_server.start_server()
594
+ try:
595
+ entered = ctx.__enter__()
596
+ except Exception as exc:
597
+ entry.exit_error = exc
598
+ entry.started_event.set()
599
+ return
600
+
601
+ entry.server = entered
602
+ # Install our publishDiagnostics handler now that multilspy has wired
603
+ # ``do_nothing``. Order matters: the bundled adapter registers its
604
+ # handler *inside* ``start_server`` before ``__enter__`` returns, so
605
+ # the moment we land here the slot is filled by ``do_nothing`` and
606
+ # ours replaces it cleanly.
607
+ self._install_diagnostics_handler(server.language, entered, entry)
608
+ entry.started_event.set()
609
+
610
+ try:
611
+ # Block here until ``stop_event`` is set. This keeps the multilspy
612
+ # event-loop thread (which it spawns inside start_server) alive
613
+ # so subsequent ``request_*`` calls work.
614
+ entry.stop_event.wait()
615
+ finally:
616
+ try:
617
+ ctx.__exit__(None, None, None)
618
+ except Exception as exc: # pragma: no cover — best-effort shutdown
619
+ entry.exit_error = exc
620
+ _log.warning("LSP server %r exit raised: %s", server.language, exc)
621
+
622
+ def _install_diagnostics_handler(
623
+ self,
624
+ language: str,
625
+ sync_server: Any,
626
+ entry: _ServerEntry,
627
+ ) -> None:
628
+ """Reach into multilspy and replace ``do_nothing`` for publishDiagnostics.
629
+
630
+ multilspy 0.0.15 registers ``do_nothing`` on every server adapter
631
+ (`multilspy/language_servers/<lang>/<lang>.py` — grep for
632
+ ``textDocument/publishDiagnostics``). The handler dict
633
+ (``on_notification_handlers``) lives at
634
+ ``language_server.server.on_notification_handlers`` on the inner
635
+ ``LanguageServerHandler``. We overwrite the single registered slot
636
+ so future notifications populate ``entry.diagnostics``.
637
+
638
+ Best-effort: if the multilspy internals shift in a future version we
639
+ log and continue — diagnostics simply won't update, but everything
640
+ else (outline / definition / references) keeps working.
641
+ """
642
+ try:
643
+ inner = getattr(sync_server, "language_server", None)
644
+ handler = getattr(inner, "server", None) if inner is not None else None
645
+ on_notification = getattr(handler, "on_notification", None)
646
+ if not callable(on_notification):
647
+ _log.debug(
648
+ "LSP %r: multilspy on_notification missing; "
649
+ "diagnostics cache disabled.",
650
+ language,
651
+ )
652
+ return
653
+
654
+ async def _on_publish(params: Any) -> None:
655
+ """Cache push diagnostics keyed by the multilspy-relative path."""
656
+ if not isinstance(params, dict):
657
+ return
658
+ uri = params.get("uri")
659
+ diagnostics = params.get("diagnostics")
660
+ if not isinstance(uri, str) or not isinstance(diagnostics, list):
661
+ return
662
+ rel = self._uri_to_relpath(uri)
663
+ with entry.diagnostics_lock:
664
+ entry.diagnostics[rel] = list(diagnostics)
665
+ # Fire any waiter — Hybrid hook uses this to know an
666
+ # incremental re-analysis pass landed.
667
+ event = entry.diagnostics_events.get(rel)
668
+ if event is None:
669
+ event = threading.Event()
670
+ entry.diagnostics_events[rel] = event
671
+ event.set()
672
+
673
+ on_notification("textDocument/publishDiagnostics", _on_publish)
674
+ except Exception as exc: # pragma: no cover — defensive
675
+ _log.warning(
676
+ "LSP %r: failed to install diagnostics handler: %s",
677
+ language,
678
+ exc,
679
+ )
680
+
681
+ def _uri_to_relpath(self, uri: str) -> str:
682
+ """Convert a ``file://`` URI back to a path relative to the repo root.
683
+
684
+ Falls back to the raw URI when conversion fails so the cache still
685
+ gets keyed by *something* deterministic per file.
686
+ """
687
+ try:
688
+ from urllib.parse import unquote, urlparse
689
+
690
+ parsed = urlparse(uri)
691
+ raw = unquote(parsed.path)
692
+ if os.name == "nt" and len(raw) >= 3 and raw[0] == "/" and raw[2] == ":":
693
+ raw = raw[1:]
694
+ return to_repo_relative(raw, self._repository_root)
695
+ except Exception:
696
+ return uri
697
+
698
+ def _stop_one(self, language: str) -> None:
699
+ """Signal one server's lifecycle thread to tear down and wait briefly
700
+ for it. ``stop_event`` is the only synchronization needed —
701
+ ``start_server`` is a context manager that handles its own cleanup."""
702
+ with self._lock:
703
+ entry = self._entries.pop(language, None)
704
+ if entry is None:
705
+ return
706
+ entry.stop_event.set()
707
+ thread = entry.thread
708
+ if thread is not None and thread.is_alive():
709
+ thread.join(timeout=5.0)
710
+
711
+ def _warn(self, message: str) -> None:
712
+ if self._console is None:
713
+ return
714
+ try:
715
+ self._console.print_error(message)
716
+ except Exception: # pragma: no cover — console must never break boot
717
+ pass
718
+
719
+ # --------------------------------------------------------- on_disconnect
720
+
721
+ def _on_disconnect(self, language: str, reason: str) -> None:
722
+ """Mark a language UNHEALTHY and surface the failure to the user.
723
+
724
+ Called by the subprocess watchdog when ``process.returncode`` flips
725
+ non-None unexpectedly. Idempotent per server: ``entry.disconnect_seen``
726
+ guards against the poller re-firing for the same exit.
727
+
728
+ Sequence (mirrors ``MCPManager._on_disconnect``):
729
+
730
+ 1. Mark UNHEALTHY under the lock + pop the entry so subsequent
731
+ ``get_server_for_file`` / ``iter_running`` calls skip the dead
732
+ server.
733
+ 2. Format a uniform message with the convention
734
+ ``LSP server <lang> disconnected: <reason>``.
735
+ 3. Surface through ``console.print_error`` (when present) and post
736
+ through ``notifier.notify`` (when present) so the REPL drains the
737
+ event between LLM turns.
738
+ 4. Invoke any user-supplied ``set_on_disconnect`` callback last so
739
+ tests / extensions can observe the event without blocking the
740
+ built-in surfacing path.
741
+ """
742
+ with self._lock:
743
+ entry = self._entries.get(language)
744
+ if entry is not None and entry.disconnect_seen:
745
+ return
746
+ if entry is not None:
747
+ entry.disconnect_seen = True
748
+ current = self._status.get(language)
749
+ if current not in (ServerStatus.STOPPED,):
750
+ self._status[language] = ServerStatus.UNHEALTHY
751
+ self._status_reasons[language] = reason
752
+ # Pop the entry so ``get_server_for_file`` immediately returns
753
+ # ``None`` for callers — same effect as ``MCPManager`` popping
754
+ # the client dict on disconnect.
755
+ self._entries.pop(language, None)
756
+
757
+ message = f"LSP server {language!r} disconnected: {reason}"
758
+ if self._console is not None:
759
+ try:
760
+ self._console.print_error(message)
761
+ except Exception: # pragma: no cover — console must never crash watchdog
762
+ pass
763
+ if self._notifier is not None:
764
+ try:
765
+ self._notifier.notify(f"lsp:{language}", message)
766
+ except Exception: # pragma: no cover — notification must never crash
767
+ pass
768
+ if self._on_disconnect_callback is not None:
769
+ try:
770
+ self._on_disconnect_callback(language, reason)
771
+ except Exception: # pragma: no cover — defensive
772
+ pass
773
+
774
+ def _ensure_watchdog(self) -> None:
775
+ """Start the subprocess crash poller if not already running.
776
+
777
+ multilspy does not expose an ``on_exit`` callback for the launched
778
+ language server. Polling ``process.returncode`` is the only reliable
779
+ signal — it is None while alive and an integer the moment the
780
+ subprocess terminates. The poll interval (0.5s) keeps the watchdog
781
+ responsive without measurable CPU cost.
782
+ """
783
+ if self._watchdog_thread is not None and self._watchdog_thread.is_alive():
784
+ return
785
+ self._watchdog_stop.clear()
786
+ thread = threading.Thread(
787
+ target=self._watchdog_loop,
788
+ name="lsp-watchdog",
789
+ daemon=True,
790
+ )
791
+ self._watchdog_thread = thread
792
+ thread.start()
793
+
794
+ def _watchdog_loop(self) -> None:
795
+ """Poll every entry's underlying subprocess for unexpected exit.
796
+
797
+ Reads ``language_server.server.process.returncode`` from the inner
798
+ ``LanguageServerHandler``. A non-None returncode while the entry
799
+ still believes it is RUNNING signals a crash; we report via
800
+ ``_on_disconnect`` and stop polling that language.
801
+ """
802
+ # 0.5s interval matches MCP transports' reader cadence and keeps the
803
+ # latency-to-notice under one second on pyright crashes.
804
+ while not self._watchdog_stop.wait(0.5):
805
+ with self._lock:
806
+ entries = list(self._entries.items())
807
+ statuses = dict(self._status)
808
+ for language, entry in entries:
809
+ if statuses.get(language) != ServerStatus.RUNNING:
810
+ continue
811
+ if entry.disconnect_seen:
812
+ continue
813
+ returncode = _process_returncode(entry.server)
814
+ if returncode is None:
815
+ continue
816
+ # Subprocess died — synthesise a reason from whatever exit
817
+ # information multilspy left us.
818
+ reason = f"subprocess exited (returncode={returncode})"
819
+ self._on_disconnect(language, reason)
820
+
821
+
822
+ def _process_returncode(sync_server: Any) -> int | None:
823
+ """Read the underlying subprocess returncode from a multilspy server.
824
+
825
+ Returns ``None`` whenever the subprocess is still alive or we can't
826
+ reach it (e.g. multilspy internals shifted). Used by the watchdog to
827
+ detect crashes without coupling to multilspy version specifics.
828
+ """
829
+ try:
830
+ inner = getattr(sync_server, "language_server", None)
831
+ handler = getattr(inner, "server", None) if inner is not None else None
832
+ process = getattr(handler, "process", None) if handler is not None else None
833
+ if process is None:
834
+ return None
835
+ return process.returncode
836
+ except Exception: # pragma: no cover — defensive
837
+ return None
838
+
839
+
840
+ def _import_sync_language_server() -> Any | None:
841
+ """Lazy import of ``multilspy.SyncLanguageServer``. Returns ``None`` when
842
+ the extra is not installed so callers can degrade gracefully."""
843
+ try:
844
+ from multilspy import SyncLanguageServer # type: ignore
845
+ except ImportError:
846
+ return None
847
+ return SyncLanguageServer
848
+
849
+
850
+ def _build_sync_server(
851
+ sync_cls: Any,
852
+ server: LSPServerConfig,
853
+ repository_root: str,
854
+ ) -> Any:
855
+ """Construct a ``SyncLanguageServer`` for ``server``.
856
+
857
+ Translates :class:`LSPServerConfig` into the multilspy types
858
+ (``MultilspyConfig`` + ``MultilspyLogger``) without exposing multilspy
859
+ to callers.
860
+ """
861
+ from multilspy.multilspy_config import MultilspyConfig # type: ignore
862
+ from multilspy.multilspy_logger import MultilspyLogger # type: ignore
863
+
864
+ config = MultilspyConfig.from_dict({"code_language": server.language})
865
+ logger = MultilspyLogger()
866
+ return sync_cls.create(config, logger, repository_root)