axion-code 1.0.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 (82) hide show
  1. axion/__init__.py +3 -0
  2. axion/api/__init__.py +0 -0
  3. axion/api/anthropic.py +460 -0
  4. axion/api/client.py +259 -0
  5. axion/api/error.py +161 -0
  6. axion/api/ollama.py +597 -0
  7. axion/api/openai_compat.py +805 -0
  8. axion/api/openai_responses.py +627 -0
  9. axion/api/prompt_cache.py +31 -0
  10. axion/api/sse.py +98 -0
  11. axion/api/types.py +451 -0
  12. axion/cli/__init__.py +0 -0
  13. axion/cli/init_cmd.py +50 -0
  14. axion/cli/input.py +290 -0
  15. axion/cli/main.py +2953 -0
  16. axion/cli/render.py +489 -0
  17. axion/cli/tui.py +766 -0
  18. axion/commands/__init__.py +0 -0
  19. axion/commands/handlers/__init__.py +0 -0
  20. axion/commands/handlers/agents.py +51 -0
  21. axion/commands/handlers/builtin_commands.py +367 -0
  22. axion/commands/handlers/mcp.py +59 -0
  23. axion/commands/handlers/models.py +75 -0
  24. axion/commands/handlers/plugins.py +55 -0
  25. axion/commands/handlers/skills.py +61 -0
  26. axion/commands/parsing.py +317 -0
  27. axion/commands/registry.py +166 -0
  28. axion/compat_harness/__init__.py +0 -0
  29. axion/compat_harness/extractor.py +145 -0
  30. axion/plugins/__init__.py +0 -0
  31. axion/plugins/hooks.py +22 -0
  32. axion/plugins/manager.py +391 -0
  33. axion/plugins/manifest.py +270 -0
  34. axion/runtime/__init__.py +0 -0
  35. axion/runtime/bash.py +388 -0
  36. axion/runtime/bootstrap.py +39 -0
  37. axion/runtime/claude_subscription.py +300 -0
  38. axion/runtime/compact.py +233 -0
  39. axion/runtime/config.py +397 -0
  40. axion/runtime/conversation.py +1073 -0
  41. axion/runtime/file_ops.py +613 -0
  42. axion/runtime/git.py +213 -0
  43. axion/runtime/hooks.py +235 -0
  44. axion/runtime/image.py +212 -0
  45. axion/runtime/lanes.py +282 -0
  46. axion/runtime/lsp.py +425 -0
  47. axion/runtime/mcp/__init__.py +0 -0
  48. axion/runtime/mcp/client.py +76 -0
  49. axion/runtime/mcp/lifecycle.py +96 -0
  50. axion/runtime/mcp/stdio.py +318 -0
  51. axion/runtime/mcp/tool_bridge.py +79 -0
  52. axion/runtime/memory.py +196 -0
  53. axion/runtime/oauth.py +329 -0
  54. axion/runtime/openai_subscription.py +346 -0
  55. axion/runtime/permissions.py +247 -0
  56. axion/runtime/plan_mode.py +96 -0
  57. axion/runtime/policy_engine.py +259 -0
  58. axion/runtime/prompt.py +586 -0
  59. axion/runtime/recovery.py +261 -0
  60. axion/runtime/remote.py +28 -0
  61. axion/runtime/sandbox.py +68 -0
  62. axion/runtime/scheduler.py +231 -0
  63. axion/runtime/session.py +365 -0
  64. axion/runtime/sharing.py +159 -0
  65. axion/runtime/skills.py +124 -0
  66. axion/runtime/tasks.py +258 -0
  67. axion/runtime/usage.py +241 -0
  68. axion/runtime/workers.py +186 -0
  69. axion/telemetry/__init__.py +0 -0
  70. axion/telemetry/events.py +67 -0
  71. axion/telemetry/profile.py +49 -0
  72. axion/telemetry/sink.py +60 -0
  73. axion/telemetry/tracer.py +95 -0
  74. axion/tools/__init__.py +0 -0
  75. axion/tools/lane_completion.py +33 -0
  76. axion/tools/registry.py +853 -0
  77. axion/tools/tool_search.py +226 -0
  78. axion_code-1.0.0.dist-info/METADATA +709 -0
  79. axion_code-1.0.0.dist-info/RECORD +82 -0
  80. axion_code-1.0.0.dist-info/WHEEL +4 -0
  81. axion_code-1.0.0.dist-info/entry_points.txt +2 -0
  82. axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
axion/runtime/lanes.py ADDED
@@ -0,0 +1,282 @@
1
+ """Lane events, branch lock, stale branch detection, and summary compression.
2
+
3
+ Maps to: rust/crates/runtime/src/lane_events.rs, branch_lock.rs, stale_branch.rs
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import enum
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Lane status and events
15
+ # ---------------------------------------------------------------------------
16
+
17
+ class LaneStatus(enum.Enum):
18
+ ACTIVE = "active"
19
+ COMPLETED = "completed"
20
+ BLOCKED = "blocked"
21
+ RECONCILED = "reconciled"
22
+ FAILED = "failed"
23
+
24
+
25
+ class LaneEventType(enum.Enum):
26
+ CREATED = "created"
27
+ STARTED = "started"
28
+ TOOL_EXECUTED = "tool_executed"
29
+ ITERATION_COMPLETED = "iteration_completed"
30
+ BLOCKED = "blocked"
31
+ UNBLOCKED = "unblocked"
32
+ GREEN_LEVEL_CHANGED = "green_level_changed"
33
+ REVIEW_REQUESTED = "review_requested"
34
+ REVIEW_APPROVED = "review_approved"
35
+ REVIEW_REJECTED = "review_rejected"
36
+ MERGE_REQUESTED = "merge_requested"
37
+ MERGE_COMPLETED = "merge_completed"
38
+ RECONCILED = "reconciled"
39
+ COMPLETED = "completed"
40
+ FAILED = "failed"
41
+ ESCALATED = "escalated"
42
+ RECOVERED = "recovered"
43
+
44
+
45
+ @dataclass
46
+ class LaneEvent:
47
+ """Timestamped event in a lane's lifecycle."""
48
+
49
+ lane_id: str
50
+ event_type: LaneEventType
51
+ timestamp_ms: int = field(default_factory=lambda: int(time.time() * 1000))
52
+ details: dict[str, Any] = field(default_factory=dict)
53
+ green_level: int | None = None
54
+ message: str = ""
55
+
56
+ def to_dict(self) -> dict[str, Any]:
57
+ d: dict[str, Any] = {
58
+ "lane_id": self.lane_id,
59
+ "event_type": self.event_type.value,
60
+ "timestamp_ms": self.timestamp_ms,
61
+ }
62
+ if self.details:
63
+ d["details"] = self.details
64
+ if self.green_level is not None:
65
+ d["green_level"] = self.green_level
66
+ if self.message:
67
+ d["message"] = self.message
68
+ return d
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Lane state tracker
73
+ # ---------------------------------------------------------------------------
74
+
75
+ @dataclass
76
+ class LaneState:
77
+ """Current state of a lane."""
78
+
79
+ lane_id: str
80
+ status: LaneStatus = LaneStatus.ACTIVE
81
+ green_level: int = 0
82
+ branch: str = ""
83
+ worker_id: str | None = None
84
+ events: list[LaneEvent] = field(default_factory=list)
85
+ blocker_reason: str = ""
86
+ created_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
87
+
88
+ def record_event(
89
+ self,
90
+ event_type: LaneEventType,
91
+ message: str = "",
92
+ **details: Any,
93
+ ) -> LaneEvent:
94
+ event = LaneEvent(
95
+ lane_id=self.lane_id,
96
+ event_type=event_type,
97
+ green_level=self.green_level,
98
+ message=message,
99
+ details=details if details else {},
100
+ )
101
+ self.events.append(event)
102
+ return event
103
+
104
+
105
+ class LaneEventLog:
106
+ """Tracks lane events for git workflow coordination."""
107
+
108
+ def __init__(self) -> None:
109
+ self._lanes: dict[str, LaneState] = {}
110
+ self._all_events: list[LaneEvent] = []
111
+
112
+ def create_lane(self, lane_id: str, branch: str = "") -> LaneState:
113
+ state = LaneState(lane_id=lane_id, branch=branch)
114
+ state.record_event(LaneEventType.CREATED, f"Lane created for branch {branch}")
115
+ self._lanes[lane_id] = state
116
+ return state
117
+
118
+ def get_lane(self, lane_id: str) -> LaneState | None:
119
+ return self._lanes.get(lane_id)
120
+
121
+ def record(self, event: LaneEvent) -> None:
122
+ self._all_events.append(event)
123
+ lane = self._lanes.get(event.lane_id)
124
+ if lane:
125
+ lane.events.append(event)
126
+
127
+ def events_for_lane(self, lane_id: str) -> list[LaneEvent]:
128
+ lane = self._lanes.get(lane_id)
129
+ return lane.events if lane else []
130
+
131
+ def all_events(self) -> list[LaneEvent]:
132
+ return list(self._all_events)
133
+
134
+ def all_lanes(self) -> list[LaneState]:
135
+ return list(self._lanes.values())
136
+
137
+ def active_lanes(self) -> list[LaneState]:
138
+ return [lane for lane in self._lanes.values() if lane.status == LaneStatus.ACTIVE]
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Branch lock
143
+ # ---------------------------------------------------------------------------
144
+
145
+ @dataclass
146
+ class BranchLock:
147
+ """Lock on a git branch to prevent concurrent modifications."""
148
+
149
+ branch: str
150
+ holder: str
151
+ acquired_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
152
+ expires_at_ms: int | None = None
153
+ reason: str = ""
154
+
155
+ def is_expired(self) -> bool:
156
+ if self.expires_at_ms is None:
157
+ return False
158
+ return int(time.time() * 1000) > self.expires_at_ms
159
+
160
+
161
+ class BranchLockManager:
162
+ """Manages branch locks with expiry and reason tracking."""
163
+
164
+ def __init__(self) -> None:
165
+ self._locks: dict[str, BranchLock] = {}
166
+
167
+ def acquire(
168
+ self,
169
+ branch: str,
170
+ holder: str,
171
+ ttl_ms: int = 300_000,
172
+ reason: str = "",
173
+ ) -> bool:
174
+ """Acquire a branch lock. Returns False if already held by another."""
175
+ existing = self._locks.get(branch)
176
+ if existing and not existing.is_expired() and existing.holder != holder:
177
+ return False
178
+ now = int(time.time() * 1000)
179
+ self._locks[branch] = BranchLock(
180
+ branch=branch,
181
+ holder=holder,
182
+ acquired_at_ms=now,
183
+ expires_at_ms=now + ttl_ms,
184
+ reason=reason,
185
+ )
186
+ return True
187
+
188
+ def release(self, branch: str, holder: str) -> bool:
189
+ lock = self._locks.get(branch)
190
+ if lock and lock.holder == holder:
191
+ del self._locks[branch]
192
+ return True
193
+ return False
194
+
195
+ def is_locked(self, branch: str) -> bool:
196
+ lock = self._locks.get(branch)
197
+ return lock is not None and not lock.is_expired()
198
+
199
+ def lock_holder(self, branch: str) -> str | None:
200
+ lock = self._locks.get(branch)
201
+ if lock and not lock.is_expired():
202
+ return lock.holder
203
+ return None
204
+
205
+ def all_locks(self) -> list[BranchLock]:
206
+ # Clean expired locks
207
+ now = int(time.time() * 1000)
208
+ self._locks = {
209
+ k: v for k, v in self._locks.items()
210
+ if v.expires_at_ms is None or v.expires_at_ms > now
211
+ }
212
+ return list(self._locks.values())
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Stale branch detection
217
+ # ---------------------------------------------------------------------------
218
+
219
+ DEFAULT_STALE_THRESHOLD_MS = 86_400_000 # 24 hours
220
+
221
+
222
+ def is_stale_branch(
223
+ last_commit_ms: int,
224
+ threshold_ms: int = DEFAULT_STALE_THRESHOLD_MS,
225
+ ) -> bool:
226
+ """Check if a branch is stale (default: 24 hours since last commit)."""
227
+ now_ms = int(time.time() * 1000)
228
+ return (now_ms - last_commit_ms) > threshold_ms
229
+
230
+
231
+ def branch_freshness_ms(last_commit_ms: int) -> int:
232
+ """Get time since last commit in milliseconds."""
233
+ now_ms = int(time.time() * 1000)
234
+ return now_ms - last_commit_ms
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # Summary compression (for lane event history)
239
+ # ---------------------------------------------------------------------------
240
+
241
+ def compress_lane_summary(events: list[LaneEvent], max_events: int = 20) -> str:
242
+ """Compress a list of lane events into a summary string.
243
+
244
+ Keeps the first and last events, plus key transitions.
245
+ """
246
+ if not events:
247
+ return "(no events)"
248
+
249
+ if len(events) <= max_events:
250
+ lines = []
251
+ for e in events:
252
+ ts = time.strftime("%H:%M:%S", time.gmtime(e.timestamp_ms / 1000))
253
+ lines.append(f"[{ts}] {e.event_type.value}: {e.message or '(no message)'}")
254
+ return "\n".join(lines)
255
+
256
+ # Compress: keep first, last, and key transitions
257
+ key_types = {
258
+ LaneEventType.CREATED, LaneEventType.COMPLETED, LaneEventType.FAILED,
259
+ LaneEventType.BLOCKED, LaneEventType.UNBLOCKED,
260
+ LaneEventType.MERGE_COMPLETED, LaneEventType.ESCALATED,
261
+ LaneEventType.GREEN_LEVEL_CHANGED,
262
+ }
263
+
264
+ kept: list[LaneEvent] = [events[0]]
265
+ for e in events[1:-1]:
266
+ if e.event_type in key_types:
267
+ kept.append(e)
268
+ kept.append(events[-1])
269
+
270
+ # Truncate if still too long
271
+ if len(kept) > max_events:
272
+ kept = kept[:max_events - 1] + [kept[-1]]
273
+
274
+ skipped = len(events) - len(kept)
275
+ lines = []
276
+ for e in kept:
277
+ ts = time.strftime("%H:%M:%S", time.gmtime(e.timestamp_ms / 1000))
278
+ lines.append(f"[{ts}] {e.event_type.value}: {e.message or '(no message)'}")
279
+ if skipped > 0:
280
+ lines.insert(-1, f" ... ({skipped} events compressed)")
281
+
282
+ return "\n".join(lines)
axion/runtime/lsp.py ADDED
@@ -0,0 +1,425 @@
1
+ """LSP (Language Server Protocol) client with JSON-RPC communication.
2
+
3
+ Maps to: rust/crates/runtime/src/lsp_client.rs
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import enum
10
+ import json
11
+ import logging
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class LspAction(enum.Enum):
19
+ DIAGNOSTICS = "diagnostics"
20
+ HOVER = "hover"
21
+ DEFINITION = "definition"
22
+ REFERENCES = "references"
23
+ COMPLETION = "completion"
24
+ SYMBOLS = "symbols"
25
+ FORMAT = "format"
26
+
27
+
28
+ class LspServerStatus(enum.Enum):
29
+ CONNECTED = "connected"
30
+ DISCONNECTED = "disconnected"
31
+ STARTING = "starting"
32
+ ERROR = "error"
33
+
34
+
35
+ class LspSeverity(enum.Enum):
36
+ ERROR = "error"
37
+ WARNING = "warning"
38
+ INFORMATION = "information"
39
+ HINT = "hint"
40
+
41
+
42
+ @dataclass
43
+ class LspDiagnostic:
44
+ path: str
45
+ line: int
46
+ character: int
47
+ severity: LspSeverity
48
+ message: str
49
+ source: str = ""
50
+ code: str | None = None
51
+ end_line: int | None = None
52
+ end_character: int | None = None
53
+
54
+
55
+ @dataclass
56
+ class LspLocation:
57
+ path: str
58
+ line: int
59
+ character: int
60
+ end_line: int | None = None
61
+ end_character: int | None = None
62
+ preview: str = ""
63
+
64
+
65
+ @dataclass
66
+ class LspHoverResult:
67
+ content: str
68
+ language: str = ""
69
+ range_start_line: int | None = None
70
+ range_start_character: int | None = None
71
+
72
+
73
+ @dataclass
74
+ class LspCompletionItem:
75
+ label: str
76
+ kind: str = ""
77
+ detail: str = ""
78
+ insert_text: str = ""
79
+ sort_text: str = ""
80
+
81
+
82
+ @dataclass
83
+ class LspSymbol:
84
+ name: str
85
+ kind: str
86
+ path: str
87
+ line: int
88
+ character: int
89
+ container_name: str = ""
90
+
91
+
92
+ @dataclass
93
+ class LspServerInfo:
94
+ """Information about a connected LSP server."""
95
+
96
+ language: str
97
+ command: str = ""
98
+ status: LspServerStatus = LspServerStatus.DISCONNECTED
99
+ capabilities: dict[str, Any] = field(default_factory=dict)
100
+ root_uri: str = ""
101
+ error_message: str = ""
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # LSP JSON-RPC client
106
+ # ---------------------------------------------------------------------------
107
+
108
+ class LspClient:
109
+ """JSON-RPC client for a single LSP server process.
110
+
111
+ Communicates via stdin/stdout using Content-Length headers.
112
+ """
113
+
114
+ def __init__(self, language: str, command: str, args: list[str] | None = None) -> None:
115
+ self.language = language
116
+ self.command = command
117
+ self.args = args or []
118
+ self._process: asyncio.subprocess.Process | None = None
119
+ self._request_id = 0
120
+ self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
121
+ self._read_task: asyncio.Task[None] | None = None
122
+ self._capabilities: dict[str, Any] = {}
123
+
124
+ async def start(self, root_uri: str = "") -> bool:
125
+ """Start the LSP server process and initialize."""
126
+ try:
127
+ self._process = await asyncio.create_subprocess_exec(
128
+ self.command, *self.args,
129
+ stdin=asyncio.subprocess.PIPE,
130
+ stdout=asyncio.subprocess.PIPE,
131
+ stderr=asyncio.subprocess.PIPE,
132
+ )
133
+ except (FileNotFoundError, OSError) as exc:
134
+ logger.error("Failed to start LSP server %s: %s", self.command, exc)
135
+ return False
136
+
137
+ self._read_task = asyncio.create_task(self._read_loop())
138
+
139
+ # Send initialize request
140
+ result = await self._request("initialize", {
141
+ "processId": None,
142
+ "rootUri": root_uri,
143
+ "capabilities": {
144
+ "textDocument": {
145
+ "completion": {"completionItem": {"snippetSupport": False}},
146
+ "hover": {"contentFormat": ["plaintext"]},
147
+ "definition": {},
148
+ "references": {},
149
+ "documentSymbol": {},
150
+ "formatting": {},
151
+ "publishDiagnostics": {},
152
+ },
153
+ },
154
+ })
155
+
156
+ if result:
157
+ self._capabilities = result.get("capabilities", {})
158
+
159
+ # Send initialized notification
160
+ await self._notify("initialized", {})
161
+ return True
162
+
163
+ async def get_diagnostics(self, uri: str) -> list[LspDiagnostic]:
164
+ """Get diagnostics for a document. Note: LSP pushes diagnostics, this is a placeholder."""
165
+ return []
166
+
167
+ async def hover(self, uri: str, line: int, character: int) -> LspHoverResult | None:
168
+ """Get hover information at a position."""
169
+ result = await self._request("textDocument/hover", {
170
+ "textDocument": {"uri": uri},
171
+ "position": {"line": line, "character": character},
172
+ })
173
+ if not result or "contents" not in result:
174
+ return None
175
+
176
+ contents = result["contents"]
177
+ if isinstance(contents, str):
178
+ return LspHoverResult(content=contents)
179
+ if isinstance(contents, dict):
180
+ return LspHoverResult(
181
+ content=contents.get("value", ""),
182
+ language=contents.get("language", ""),
183
+ )
184
+ return None
185
+
186
+ async def definition(self, uri: str, line: int, character: int) -> list[LspLocation]:
187
+ """Go to definition."""
188
+ result = await self._request("textDocument/definition", {
189
+ "textDocument": {"uri": uri},
190
+ "position": {"line": line, "character": character},
191
+ })
192
+ return self._parse_locations(result)
193
+
194
+ async def references(self, uri: str, line: int, character: int) -> list[LspLocation]:
195
+ """Find all references."""
196
+ result = await self._request("textDocument/references", {
197
+ "textDocument": {"uri": uri},
198
+ "position": {"line": line, "character": character},
199
+ "context": {"includeDeclaration": True},
200
+ })
201
+ return self._parse_locations(result)
202
+
203
+ async def completion(self, uri: str, line: int, character: int) -> list[LspCompletionItem]:
204
+ """Get completions at a position."""
205
+ result = await self._request("textDocument/completion", {
206
+ "textDocument": {"uri": uri},
207
+ "position": {"line": line, "character": character},
208
+ })
209
+ if not result:
210
+ return []
211
+
212
+ items = result.get("items", []) if isinstance(result, dict) else result
213
+ completions = []
214
+ for item in items[:50]: # Limit
215
+ completions.append(LspCompletionItem(
216
+ label=item.get("label", ""),
217
+ kind=str(item.get("kind", "")),
218
+ detail=item.get("detail", ""),
219
+ insert_text=item.get("insertText", item.get("label", "")),
220
+ sort_text=item.get("sortText", ""),
221
+ ))
222
+ return completions
223
+
224
+ async def document_symbols(self, uri: str) -> list[LspSymbol]:
225
+ """Get symbols in a document."""
226
+ result = await self._request("textDocument/documentSymbol", {
227
+ "textDocument": {"uri": uri},
228
+ })
229
+ if not result:
230
+ return []
231
+
232
+ symbols = []
233
+ for sym in result:
234
+ location = sym.get("location", {})
235
+ range_ = location.get("range", sym.get("range", {}))
236
+ start = range_.get("start", {})
237
+ symbols.append(LspSymbol(
238
+ name=sym.get("name", ""),
239
+ kind=str(sym.get("kind", "")),
240
+ path=location.get("uri", uri),
241
+ line=start.get("line", 0),
242
+ character=start.get("character", 0),
243
+ container_name=sym.get("containerName", ""),
244
+ ))
245
+ return symbols
246
+
247
+ async def shutdown(self) -> None:
248
+ """Shutdown the LSP server."""
249
+ if self._process:
250
+ await self._request("shutdown", None)
251
+ await self._notify("exit", None)
252
+ if self._read_task:
253
+ self._read_task.cancel()
254
+ try:
255
+ self._process.terminate()
256
+ await asyncio.wait_for(self._process.wait(), timeout=5.0)
257
+ except (asyncio.TimeoutError, ProcessLookupError):
258
+ self._process.kill()
259
+
260
+ @staticmethod
261
+ def _parse_locations(result: Any) -> list[LspLocation]:
262
+ if not result:
263
+ return []
264
+ locs = result if isinstance(result, list) else [result]
265
+ locations = []
266
+ for loc in locs:
267
+ uri = loc.get("uri", "")
268
+ range_ = loc.get("range", {})
269
+ start = range_.get("start", {})
270
+ end = range_.get("end", {})
271
+ locations.append(LspLocation(
272
+ path=uri,
273
+ line=start.get("line", 0),
274
+ character=start.get("character", 0),
275
+ end_line=end.get("line"),
276
+ end_character=end.get("character"),
277
+ ))
278
+ return locations
279
+
280
+ def _next_id(self) -> int:
281
+ self._request_id += 1
282
+ return self._request_id
283
+
284
+ async def _request(self, method: str, params: Any) -> Any:
285
+ if not self._process or not self._process.stdin:
286
+ return None
287
+ req_id = self._next_id()
288
+ msg = {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}
289
+
290
+ future: asyncio.Future[dict[str, Any]] = asyncio.get_event_loop().create_future()
291
+ self._pending[req_id] = future
292
+
293
+ await self._write(msg)
294
+
295
+ try:
296
+ response = await asyncio.wait_for(future, timeout=10.0)
297
+ return response.get("result")
298
+ except asyncio.TimeoutError:
299
+ self._pending.pop(req_id, None)
300
+ return None
301
+
302
+ async def _notify(self, method: str, params: Any) -> None:
303
+ if not self._process or not self._process.stdin:
304
+ return
305
+ msg: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
306
+ if params is not None:
307
+ msg["params"] = params
308
+ await self._write(msg)
309
+
310
+ async def _write(self, msg: dict[str, Any]) -> None:
311
+ assert self._process and self._process.stdin
312
+ content = json.dumps(msg)
313
+ header = f"Content-Length: {len(content)}\r\n\r\n"
314
+ self._process.stdin.write((header + content).encode())
315
+ await self._process.stdin.drain()
316
+
317
+ async def _read_loop(self) -> None:
318
+ assert self._process and self._process.stdout
319
+ buffer = b""
320
+ while True:
321
+ try:
322
+ chunk = await self._process.stdout.read(4096)
323
+ if not chunk:
324
+ break
325
+ buffer += chunk
326
+
327
+ while b"\r\n\r\n" in buffer:
328
+ header_end = buffer.index(b"\r\n\r\n")
329
+ header = buffer[:header_end].decode()
330
+ content_length = 0
331
+ for line in header.split("\r\n"):
332
+ if line.lower().startswith("content-length:"):
333
+ content_length = int(line.split(":")[1].strip())
334
+
335
+ body_start = header_end + 4
336
+ if len(buffer) < body_start + content_length:
337
+ break
338
+
339
+ body = buffer[body_start:body_start + content_length].decode()
340
+ buffer = buffer[body_start + content_length:]
341
+
342
+ try:
343
+ data = json.loads(body)
344
+ msg_id = data.get("id")
345
+ if msg_id is not None and msg_id in self._pending:
346
+ future = self._pending.pop(msg_id)
347
+ if not future.done():
348
+ future.set_result(data)
349
+ except json.JSONDecodeError:
350
+ pass
351
+ except asyncio.CancelledError:
352
+ break
353
+ except Exception as exc:
354
+ logger.error("LSP read error: %s", exc)
355
+ break
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # LSP Registry
360
+ # ---------------------------------------------------------------------------
361
+
362
+ class LspRegistry:
363
+ """Registry of connected LSP servers.
364
+
365
+ Maps to: rust/crates/runtime/src/lsp_client.rs::LspRegistry
366
+ """
367
+
368
+ def __init__(self) -> None:
369
+ self._clients: dict[str, LspClient] = {}
370
+ self._server_info: dict[str, LspServerInfo] = {}
371
+ self._diagnostics: list[LspDiagnostic] = []
372
+
373
+ async def connect(
374
+ self, language: str, command: str, args: list[str] | None = None, root_uri: str = "",
375
+ ) -> bool:
376
+ """Start and connect to an LSP server."""
377
+ client = LspClient(language=language, command=command, args=args)
378
+ success = await client.start(root_uri=root_uri)
379
+
380
+ info = LspServerInfo(
381
+ language=language,
382
+ command=command,
383
+ status=LspServerStatus.CONNECTED if success else LspServerStatus.ERROR,
384
+ root_uri=root_uri,
385
+ )
386
+ self._server_info[language] = info
387
+
388
+ if success:
389
+ self._clients[language] = client
390
+ return success
391
+
392
+ def get_client(self, language: str) -> LspClient | None:
393
+ return self._clients.get(language)
394
+
395
+ def get_status(self, language: str) -> LspServerStatus:
396
+ info = self._server_info.get(language)
397
+ return info.status if info else LspServerStatus.DISCONNECTED
398
+
399
+ def all_servers(self) -> dict[str, LspServerInfo]:
400
+ return dict(self._server_info)
401
+
402
+ def connected_languages(self) -> list[str]:
403
+ return [lang for lang, info in self._server_info.items()
404
+ if info.status == LspServerStatus.CONNECTED]
405
+
406
+ def add_diagnostics(self, diagnostics: list[LspDiagnostic]) -> None:
407
+ self._diagnostics.extend(diagnostics)
408
+
409
+ def get_diagnostics(self, path: str | None = None) -> list[LspDiagnostic]:
410
+ if path is None:
411
+ return list(self._diagnostics)
412
+ return [d for d in self._diagnostics if d.path == path]
413
+
414
+ def clear_diagnostics(self, path: str | None = None) -> None:
415
+ if path is None:
416
+ self._diagnostics.clear()
417
+ else:
418
+ self._diagnostics = [d for d in self._diagnostics if d.path != path]
419
+
420
+ async def shutdown_all(self) -> None:
421
+ for client in self._clients.values():
422
+ await client.shutdown()
423
+ self._clients.clear()
424
+ for info in self._server_info.values():
425
+ info.status = LspServerStatus.DISCONNECTED
File without changes