lcode-cli 0.18.0__py3-none-any.whl → 0.20.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.
lcode/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.18.0"
6
+ __version__ = "0.20.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/acp.py ADDED
@@ -0,0 +1,635 @@
1
+ """`lcode acp`: lcode as the agent inside an editor, over the Agent Client Protocol (ACP, version 1).
2
+
3
+ The editor starts `lcode acp` and talks JSON-RPC 2.0 over its stdin and stdout, one message per
4
+ line. The editor shows what happens:
5
+ - the model's answers and thinking stream as message chunks;
6
+ - each tool call is a tool call with its kind, files and, for edits, a diff;
7
+ - permission requests and plan approvals use the editor's own dialog;
8
+ - the todo list becomes the editor's plan;
9
+ - lcode's permission modes are the session's modes.
10
+
11
+ Files are read from and written through the editor's buffers when it supports that, so the model
12
+ sees unsaved changes and the editor tracks the edits. Everything else (tools, checkpoints, MCP
13
+ servers, memory) works as in the terminal.
14
+
15
+ Nothing but protocol messages may reach stdout: lcode's own output is discarded (or written to
16
+ the file in LCODE_ACP_LOG), and stdin is the protocol channel only.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import sys
26
+ import threading
27
+ from pathlib import Path
28
+ from typing import IO, TYPE_CHECKING, Any
29
+ from urllib.parse import unquote, urlparse
30
+
31
+ from rich.console import Console
32
+
33
+ from lcode import __version__
34
+
35
+ if TYPE_CHECKING:
36
+ from lcode.agent import Agent
37
+
38
+ PROTOCOL_VERSION = 1
39
+ MODES = [
40
+ ("ask", "Ask", "Ask before changing files or running commands"),
41
+ ("auto-edit", "Auto-edit", "Change files without asking; ask before commands"),
42
+ ("plan", "Plan", "Look around and plan; change nothing until you approve the plan"),
43
+ ("yolo", "Yolo", "Never ask"),
44
+ ]
45
+ KINDS = {
46
+ "read_file": "read", "view_image": "read", "list_dir": "search", "glob": "search", "grep": "search",
47
+ "repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit",
48
+ "bash": "execute", "web_search": "fetch", "web_fetch": "fetch", "agent": "think", "todo_write": "think",
49
+ "present_plan": "switch_mode", "bash_output": "read", "bash_stop": "execute",
50
+ } # fmt: skip
51
+ STOP = {"success": "end_turn", "max_steps": "max_turn_requests", "interrupted": "cancelled"}
52
+ MAX_INLINE = 200_000 # bytes of a file the editor attaches that go into the prompt
53
+ METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR, NOT_FOUND = -32601, -32602, -32603, -32002
54
+
55
+
56
+ class RpcError(Exception):
57
+ def __init__(self, code: int, message: str):
58
+ super().__init__(message)
59
+ self.code = code
60
+
61
+
62
+ # ----------------------------------------------------------------------------- JSON-RPC over stdio
63
+
64
+
65
+ class Connection:
66
+ """Newline-delimited JSON-RPC 2.0, both ways: the editor's requests and lcode's own."""
67
+
68
+ def __init__(self, inp: IO[bytes], out: IO[bytes]):
69
+ self.inp, self.out = inp, out
70
+ self.lock = threading.Lock()
71
+ self.pending: dict[int, tuple[threading.Event, dict]] = {}
72
+ self.next_id = 0
73
+ self.closed = False
74
+
75
+ def send(self, message: dict) -> None:
76
+ data = (json.dumps({"jsonrpc": "2.0", **message}, ensure_ascii=False) + "\n").encode()
77
+ with self.lock:
78
+ self.out.write(data)
79
+ self.out.flush()
80
+
81
+ def notify(self, method: str, params: dict) -> None:
82
+ self.send({"method": method, "params": params})
83
+
84
+ def request(self, method: str, params: dict) -> dict:
85
+ """Ask the editor and wait for its answer."""
86
+ with self.lock:
87
+ self.next_id += 1
88
+ request_id = self.next_id
89
+ done, slot = threading.Event(), {}
90
+ self.pending[request_id] = (done, slot)
91
+ self.send({"id": request_id, "method": method, "params": params})
92
+ done.wait()
93
+ if "error" in slot:
94
+ error = slot["error"] or {}
95
+ raise RpcError(int(error.get("code", INTERNAL_ERROR)), str(error.get("message", "the editor failed")))
96
+ return slot.get("result") or {}
97
+
98
+ def serve(self, handle) -> None:
99
+ """Read messages until the editor closes stdin; requests are handled in threads of their own."""
100
+ for raw in self.inp:
101
+ line = raw.strip()
102
+ if not line:
103
+ continue
104
+ try:
105
+ message = json.loads(line)
106
+ except ValueError:
107
+ self.send({"id": None, "error": {"code": -32700, "message": "parse error"}})
108
+ continue
109
+ if "method" in message:
110
+ threading.Thread(target=self._dispatch, args=(handle, message), daemon=True).start()
111
+ elif message.get("id") in self.pending:
112
+ done, slot = self.pending.pop(message["id"])
113
+ slot.update({k: message[k] for k in ("result", "error") if k in message})
114
+ done.set()
115
+ self.closed = True
116
+ for done, slot in list(self.pending.values()): # nobody will answer now
117
+ slot["error"] = {"code": INTERNAL_ERROR, "message": "the editor closed the connection"}
118
+ done.set()
119
+
120
+ def _dispatch(self, handle, message: dict) -> None:
121
+ request_id = message.get("id")
122
+ try:
123
+ result = handle(message["method"], message.get("params") or {})
124
+ if request_id is not None:
125
+ self.send({"id": request_id, "result": result if result is not None else {}})
126
+ except RpcError as e:
127
+ if request_id is not None:
128
+ self.send({"id": request_id, "error": {"code": e.code, "message": str(e)}})
129
+ except Exception as e: # never leave the editor waiting
130
+ if request_id is not None:
131
+ self.send({"id": request_id, "error": {"code": INTERNAL_ERROR, "message": f"{type(e).__name__}: {e}"}})
132
+
133
+
134
+ # ----------------------------------------------------------------------------- one editor session
135
+
136
+
137
+ def file_path(uri: str) -> Path | None:
138
+ parsed = urlparse(uri)
139
+ if parsed.scheme in ("", "file"):
140
+ return Path(unquote(parsed.path)) if parsed.path else None
141
+ return None
142
+
143
+
144
+ def call_title(agent: Agent, name: str, args: dict) -> str:
145
+ path = args.get("path")
146
+ rel = agent.tools.rel(agent.tools.resolve(path)) if isinstance(path, str) and path else ""
147
+ titles = {
148
+ "read_file": f"Read {rel}",
149
+ "list_dir": f"List {rel or '.'}",
150
+ "glob": f"Find {args.get('pattern', '')}",
151
+ "grep": f"Search for {args.get('pattern', '')}",
152
+ "edit_file": f"Edit {rel}",
153
+ "write_file": f"Write {rel}",
154
+ "bash": str(args.get("command", "")).strip()[:200],
155
+ "web_search": f"Search the web for {args.get('query', '')}",
156
+ "web_fetch": f"Fetch {args.get('url', '')}",
157
+ "todo_write": "Update the plan",
158
+ "present_plan": f"Plan: {args.get('title', '')}",
159
+ "agent": f"Subagent: {args.get('description') or args.get('type', '')}",
160
+ }
161
+ return titles.get(name) or agent.describe_call(name, args)
162
+
163
+
164
+ class Session:
165
+ """An lcode agent wired to the editor: its output becomes session/update notifications."""
166
+
167
+ def __init__(self, server: Server, agent: Agent):
168
+ self.server, self.agent, self.id = server, agent, agent.session_id
169
+ self.conn = server.conn
170
+ self.before: dict[str, str | None] = {} # file contents before an edit, by tool call id
171
+ self.mode = agent.perms.mode
172
+ agent.cancel = threading.Event()
173
+ agent.on_delta = self.delta
174
+ agent.on_event = self.event
175
+ agent.on_tool = self.started
176
+ agent.perms.approve = self.permission
177
+ agent.plan_review = self.review_plan
178
+ caps = server.client_caps.get("fs") or {}
179
+ if caps.get("readTextFile"):
180
+ agent.tools.reader = self.read_buffer
181
+ if caps.get("writeTextFile"):
182
+ agent.tools.writer = self.write_buffer
183
+
184
+ def update(self, update: dict) -> None:
185
+ self.conn.notify("session/update", {"sessionId": self.id, "update": update})
186
+
187
+ # -- the model's output
188
+ def delta(self, kind: str, text: str) -> None:
189
+ chunk = "agent_message_chunk" if kind == "text" else "agent_thought_chunk"
190
+ self.update({"sessionUpdate": chunk, "content": {"type": "text", "text": text}})
191
+
192
+ def call(self, call_id: str, name: str, args: dict, status: str) -> dict:
193
+ update: dict[str, Any] = {
194
+ "toolCallId": call_id,
195
+ "title": call_title(self.agent, name, args),
196
+ "kind": KINDS.get(name, "other"),
197
+ "status": status,
198
+ "rawInput": args,
199
+ }
200
+ path = args.get("path")
201
+ if isinstance(path, str) and path:
202
+ location: dict[str, Any] = {"path": str(self.agent.tools.resolve(path))}
203
+ if isinstance(args.get("offset"), int):
204
+ location["line"] = args["offset"]
205
+ update["locations"] = [location]
206
+ diff = self.proposed_diff(call_id, name, args)
207
+ if diff:
208
+ update["content"] = [diff]
209
+ return update
210
+
211
+ def proposed_diff(self, call_id: str, name: str, args: dict) -> dict | None:
212
+ """What an edit will change, as the editor's diff: the whole file before and after."""
213
+ if name not in ("edit_file", "write_file") or not isinstance(args.get("path"), str):
214
+ return None
215
+ path = self.agent.tools.resolve(args["path"])
216
+ try:
217
+ old = self.agent.tools.read_text(path) if path.is_file() else None
218
+ except OSError:
219
+ old = None
220
+ if name == "write_file":
221
+ new = str(args.get("content", ""))
222
+ else:
223
+ find, replace = str(args.get("old_string", "")), str(args.get("new_string", ""))
224
+ if old is None or not find or find not in old:
225
+ return {"type": "diff", "path": str(path), "oldText": find, "newText": replace}
226
+ new = old.replace(find, replace) if args.get("replace_all") else old.replace(find, replace, 1)
227
+ self.before[call_id] = old
228
+ return {"type": "diff", "path": str(path), "oldText": old, "newText": new}
229
+
230
+ def event(self, event: dict) -> None:
231
+ if event["type"] == "assistant":
232
+ for call in event["tool_calls"]:
233
+ self.update(
234
+ {"sessionUpdate": "tool_call", **self.call(call["id"], call["name"], call["arguments"], "pending")}
235
+ )
236
+ elif event["type"] == "tool_result":
237
+ update: dict[str, Any] = {
238
+ "sessionUpdate": "tool_call_update",
239
+ "toolCallId": event["id"],
240
+ "status": "failed" if event["error"] else "completed",
241
+ "rawOutput": {"output": event["output"]},
242
+ }
243
+ content = [{"type": "content", "content": {"type": "text", "text": event["output"]}}]
244
+ if event["name"] in ("edit_file", "write_file") and not event["error"]:
245
+ path = next((c for c in self.pending_paths(event["id"])), None)
246
+ if path is not None:
247
+ try:
248
+ after = path.read_text(errors="replace")
249
+ content = [{"type": "diff", "path": str(path), "oldText": self.before.pop(event["id"], None),
250
+ "newText": after}] # fmt: skip
251
+ except OSError:
252
+ pass
253
+ update["content"] = content
254
+ self.update(update)
255
+ if event["name"] == "todo_write":
256
+ self.send_plan()
257
+ if self.agent.perms.mode != self.mode: # an approved plan switched the mode
258
+ self.mode = self.agent.perms.mode
259
+ self.update({"sessionUpdate": "current_mode_update", "currentModeId": self.mode})
260
+
261
+ def pending_paths(self, call_id: str):
262
+ for message in reversed(self.agent.messages):
263
+ for call in message.get("tool_calls") or []:
264
+ if call.get("id") == call_id:
265
+ args = call.get("function", {}).get("arguments") or {}
266
+ if isinstance(args, dict) and isinstance(args.get("path"), str):
267
+ yield self.agent.tools.resolve(args["path"])
268
+ return
269
+
270
+ def started(self, name: str, args: dict) -> None:
271
+ self.update(
272
+ {"sessionUpdate": "tool_call_update", "toolCallId": self.agent.current_call, "status": "in_progress"}
273
+ )
274
+
275
+ def send_plan(self) -> None:
276
+ entries = [
277
+ {"content": str(t.get("content", "")), "priority": "medium",
278
+ "status": t.get("status") if t.get("status") in ("pending", "in_progress", "completed") else "pending"}
279
+ for t in self.agent.tools.todos
280
+ ] # fmt: skip
281
+ self.update({"sessionUpdate": "plan", "entries": entries})
282
+
283
+ # -- asking the user, in the editor
284
+ def ask(self, tool_call: dict, options: list[dict]) -> str | None:
285
+ """The option the user picked, or None if the request was cancelled."""
286
+ try:
287
+ reply = self.conn.request(
288
+ "session/request_permission", {"sessionId": self.id, "toolCall": tool_call, "options": options}
289
+ )
290
+ except RpcError:
291
+ return None
292
+ outcome = reply.get("outcome") or {}
293
+ return outcome.get("optionId") if outcome.get("outcome") == "selected" else None
294
+
295
+ def permission(self, request: dict) -> bool | str:
296
+ call_id = self.agent.current_call
297
+ name = next(
298
+ (c.get("function", {}).get("name", "") for m in reversed(self.agent.messages)
299
+ for c in m.get("tool_calls") or [] if c.get("id") == call_id), "",
300
+ ) # fmt: skip
301
+ args = next(
302
+ (c.get("function", {}).get("arguments") or {} for m in reversed(self.agent.messages)
303
+ for c in m.get("tool_calls") or [] if c.get("id") == call_id), {},
304
+ ) # fmt: skip
305
+ tool_call = self.call(call_id, name, args if isinstance(args, dict) else {}, "pending") if name else {
306
+ "toolCallId": call_id or "permission", "title": request["title"], "kind": "other", "status": "pending"
307
+ } # fmt: skip
308
+ tool_call["title"] = request["title"]
309
+ scope = "file edits" if request["kind"] == "edit" else request["key"].split(":", 1)[-1]
310
+ choice = self.ask(
311
+ tool_call,
312
+ [
313
+ {"optionId": "allow", "name": "Allow", "kind": "allow_once"},
314
+ {"optionId": "always", "name": f"Always allow {scope} in this session", "kind": "allow_always"},
315
+ {"optionId": "reject", "name": "Reject", "kind": "reject_once"},
316
+ ],
317
+ )
318
+ if choice == "always":
319
+ self.agent.perms.always.add("edit" if request["kind"] == "edit" else request["key"])
320
+ if choice in ("allow", "always"):
321
+ return True
322
+ if choice is None and self.agent.cancel is not None and self.agent.cancel.is_set():
323
+ return "The user denied this: they cancelled the request."
324
+ return "The user denied this in the editor. Ask how to proceed, or choose a different approach."
325
+
326
+ def review_plan(self, title: str, plan: str) -> str:
327
+ tool_call = {
328
+ "toolCallId": self.agent.current_call or "plan",
329
+ "title": f"Plan: {title}",
330
+ "kind": "switch_mode",
331
+ "status": "pending",
332
+ "content": [{"type": "content", "content": {"type": "text", "text": plan}}],
333
+ }
334
+ choice = self.ask(
335
+ tool_call,
336
+ [
337
+ {"optionId": "ask", "name": "Approve, and ask before changes", "kind": "allow_once"},
338
+ {"optionId": "auto-edit", "name": "Approve, and change files without asking", "kind": "allow_always"},
339
+ {"optionId": "keep", "name": "Keep planning", "kind": "reject_once"},
340
+ ],
341
+ )
342
+ if choice in ("ask", "auto-edit"):
343
+ return choice
344
+ return "The user wants to keep planning. Ask what they'd like to change in the plan; don't change anything yet."
345
+
346
+ # -- the editor's buffers
347
+ def read_buffer(self, path: Path) -> str | None:
348
+ try:
349
+ return self.conn.request("fs/read_text_file", {"sessionId": self.id, "path": str(path)}).get("content")
350
+ except RpcError:
351
+ return None # not open in the editor, or not readable there: lcode reads the file itself
352
+
353
+ def write_buffer(self, path: Path, content: str) -> None:
354
+ try:
355
+ self.conn.request("fs/write_text_file", {"sessionId": self.id, "path": str(path), "content": content})
356
+ except RpcError:
357
+ pass # lcode writes the file itself
358
+
359
+ # -- requests
360
+ def prompt(self, blocks: list[dict]) -> dict:
361
+ from lcode import api
362
+
363
+ text = self.render(blocks)
364
+ if not text.strip():
365
+ return {"stopReason": "end_turn"}
366
+ command = self.command(text)
367
+ if command == "":
368
+ return {"stopReason": "end_turn"}
369
+ if command is not None:
370
+ text = command
371
+ self.agent.cancel.clear()
372
+ result = api.run_request(self.agent, text)
373
+ self.update({"sessionUpdate": "usage_update", "used": self.agent.ctx_used, "size": self.agent.settings.context})
374
+ self.update({"sessionUpdate": "session_info_update", "title": self.agent.session_title or None})
375
+ if result.status == "error":
376
+ raise RpcError(INTERNAL_ERROR, result.error)
377
+ return {"stopReason": STOP.get(result.status, "end_turn")}
378
+
379
+ def command(self, text: str) -> str | None:
380
+ """A /command from the editor: the user's own commands, and /review."""
381
+ if not text.startswith("/"):
382
+ return None
383
+ name, _, arg = text[1:].partition(" ")
384
+ commands = self.agent.extensions().commands
385
+ if name in commands:
386
+ return commands[name].render(arg.strip())
387
+ if name == "review":
388
+ from lcode import gitflow
389
+
390
+ try:
391
+ prompt = gitflow.review_prompt(self.agent, arg.strip())
392
+ except gitflow.GitError as e:
393
+ self.delta("text", f"Nothing to review: {e}")
394
+ return ""
395
+ self.agent.no_changes = "This is a review, so nothing can be changed: report what should change instead."
396
+ return prompt
397
+ return None
398
+
399
+ def render(self, blocks: list[dict]) -> str:
400
+ """The editor's prompt (text, attached files, images) as one request for lcode."""
401
+ parts = []
402
+ for block in blocks:
403
+ kind = block.get("type")
404
+ if kind == "text":
405
+ parts.append(str(block.get("text", "")))
406
+ elif kind == "resource_link":
407
+ parts.append(self.attached(block.get("uri", ""), None))
408
+ elif kind == "resource":
409
+ resource = block.get("resource") or {}
410
+ parts.append(self.attached(resource.get("uri", ""), resource.get("text")))
411
+ elif kind == "image" and block.get("data"):
412
+ parts.append(self.image(block["data"], block.get("mimeType", "image/png")))
413
+ return "\n\n".join(p for p in parts if p)
414
+
415
+ def attached(self, uri: str, text: str | None) -> str:
416
+ path = file_path(uri)
417
+ where = self.agent.tools.rel(path) if path else uri
418
+ if text is None and path is not None and path.is_file() and path.stat().st_size <= MAX_INLINE:
419
+ text = self.agent.tools.read_text(path)
420
+ if text is None:
421
+ return f"[The user points at {where}]"
422
+ return f"[The user attached {where}]\n```\n{text[:MAX_INLINE].rstrip(chr(10))}\n```"
423
+
424
+ def image(self, data: str, mime: str) -> str:
425
+ from lcode import config
426
+
427
+ raw = base64.b64decode(data)
428
+ folder = config.STATE_DIR / "acp-images"
429
+ folder.mkdir(parents=True, exist_ok=True)
430
+ path = folder / f"{hashlib.sha256(raw).hexdigest()[:16]}.{mime.split('/')[-1].split('+')[0] or 'png'}"
431
+ path.write_bytes(raw)
432
+ return f"@{path}"
433
+
434
+ def replay(self) -> None:
435
+ """A loaded session's conversation, sent to the editor as it happened."""
436
+ results: dict[str, str] = {}
437
+ names: dict[str, str] = {}
438
+ for message in self.agent.messages[1:]:
439
+ role, content = message.get("role"), message.get("content") or ""
440
+ if role == "user" and content:
441
+ self.update({"sessionUpdate": "user_message_chunk", "content": {"type": "text", "text": content}})
442
+ elif role == "assistant":
443
+ if content:
444
+ self.update({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": content}})
445
+ for i, call in enumerate(message.get("tool_calls") or []):
446
+ call.setdefault("id", f"call_loaded_{len(names)}_{i}")
447
+ fn = call.get("function", {})
448
+ args = fn.get("arguments") if isinstance(fn.get("arguments"), dict) else {}
449
+ names[call["id"]] = fn.get("name", "")
450
+ update = {"sessionUpdate": "tool_call", "toolCallId": call["id"],
451
+ "title": call_title(self.agent, fn.get("name", ""), args),
452
+ "kind": KINDS.get(fn.get("name", ""), "other"), "status": "completed", "rawInput": args} # fmt: skip
453
+ self.update(update)
454
+ elif role == "tool":
455
+ call_id = message.get("tool_call_id") or next((i for i in reversed(names) if i not in results), "")
456
+ if call_id:
457
+ results[call_id] = content
458
+ self.update({"sessionUpdate": "tool_call_update", "toolCallId": call_id, "status": "completed",
459
+ "content": [{"type": "content", "content": {"type": "text", "text": content[:4000]}}]}) # fmt: skip
460
+
461
+
462
+ # ----------------------------------------------------------------------------- the agent side
463
+
464
+
465
+ class Server:
466
+ def __init__(self, conn: Connection, console: Console):
467
+ self.conn, self.console = conn, console
468
+ self.client_caps: dict = {}
469
+ self.sessions: dict[str, Session] = {}
470
+
471
+ def handle(self, method: str, params: dict) -> dict | None:
472
+ handlers = {
473
+ "initialize": self.initialize,
474
+ "authenticate": lambda p: {},
475
+ "session/new": self.new_session,
476
+ "session/load": self.load_session,
477
+ "session/prompt": self.prompt,
478
+ "session/cancel": self.cancel,
479
+ "session/set_mode": self.set_mode,
480
+ }
481
+ if method not in handlers:
482
+ raise RpcError(METHOD_NOT_FOUND, f"lcode doesn't support {method}")
483
+ return handlers[method](params)
484
+
485
+ def initialize(self, params: dict) -> dict:
486
+ self.client_caps = params.get("clientCapabilities") or {}
487
+ return {
488
+ "protocolVersion": PROTOCOL_VERSION,
489
+ "agentCapabilities": {
490
+ "loadSession": True,
491
+ "promptCapabilities": {"image": True, "audio": False, "embeddedContext": True},
492
+ "mcpCapabilities": {"http": True, "sse": False},
493
+ },
494
+ "agentInfo": {"name": "lcode", "title": "lcode", "version": __version__},
495
+ "authMethods": [],
496
+ }
497
+
498
+ def open(self, params: dict) -> Session:
499
+ from lcode import api
500
+
501
+ cwd = Path(str(params.get("cwd") or ""))
502
+ if not cwd.is_absolute():
503
+ raise RpcError(INVALID_PARAMS, "cwd must be an absolute path")
504
+ try:
505
+ agent = api.open_agent(cwd, api.Options(interactive=False), self.console)
506
+ except api.SetupError as e:
507
+ raise RpcError(INTERNAL_ERROR, str(e)) from e
508
+ agent.mcp = self.mcp(cwd, params.get("mcpServers") or [], agent)
509
+ session = Session(self, agent)
510
+ self.sessions[session.id] = session
511
+ return session
512
+
513
+ def mcp(self, cwd: Path, editor_servers: list[dict], agent: Agent):
514
+ """lcode's MCP servers, plus the ones the editor passes."""
515
+ from lcode import config
516
+ from lcode.mcp import config as mcp_config
517
+ from lcode.mcp.manager import McpManager
518
+
519
+ project = mcp_config.project_file(cwd)
520
+ servers, problems = mcp_config.load_all(cwd, include_project=bool(project and mcp_config.is_approved(project)))
521
+ names = {s.name for s in servers}
522
+ for entry in editor_servers:
523
+ name = str(entry.get("name") or "editor")
524
+ if name in names:
525
+ continue
526
+ if entry.get("url"):
527
+ raw = {
528
+ "type": "http",
529
+ "url": entry["url"],
530
+ "headers": {h["name"]: h["value"] for h in entry.get("headers") or []},
531
+ }
532
+ else:
533
+ raw = {"command": entry.get("command", ""), "args": entry.get("args") or [],
534
+ "env": {e["name"]: e["value"] for e in entry.get("env") or []}} # fmt: skip
535
+ server = mcp_config.parse(name, raw, source="editor")
536
+ if server.error:
537
+ problems.append(f"{name}: {server.error}")
538
+ else:
539
+ servers.append(server)
540
+ for problem in problems:
541
+ self.console.print(f"MCP: {problem}")
542
+ if not servers:
543
+ return None
544
+ manager = McpManager(cwd, servers, config.load()["mcp_tools"])
545
+ manager.start()
546
+ return manager
547
+
548
+ def modes(self, session: Session) -> dict:
549
+ return {
550
+ "currentModeId": session.agent.perms.mode,
551
+ "availableModes": [{"id": i, "name": n, "description": d} for i, n, d in MODES],
552
+ }
553
+
554
+ def new_session(self, params: dict) -> dict:
555
+ session = self.open(params)
556
+ self.commands(session)
557
+ return {"sessionId": session.id, "modes": self.modes(session)}
558
+
559
+ def load_session(self, params: dict) -> dict:
560
+ from lcode import sessions
561
+
562
+ session = self.open(params)
563
+ wanted = str(params.get("sessionId", ""))
564
+ found = next((s for s in sessions.list_sessions(session.agent.cwd, limit=1000) if s.id == wanted), None)
565
+ if found is None:
566
+ self.sessions.pop(session.id, None)
567
+ raise RpcError(NOT_FOUND, f"no saved lcode session {wanted} in {session.agent.cwd}")
568
+ self.sessions.pop(session.id, None)
569
+ session.agent.load(found)
570
+ session.id = session.agent.session_id
571
+ self.sessions[session.id] = session
572
+ session.replay()
573
+ self.commands(session)
574
+ return {"modes": self.modes(session)}
575
+
576
+ def commands(self, session: Session) -> None:
577
+ available = [{"name": "review", "description": "Review the uncommitted changes, or the branch against a base",
578
+ "input": {"hint": "base branch (optional)"}}] # fmt: skip
579
+ for name, command in session.agent.extensions().commands.items():
580
+ if name != "review":
581
+ entry: dict = {"name": name, "description": command.description}
582
+ if command.argument_hint:
583
+ entry["input"] = {"hint": command.argument_hint}
584
+ available.append(entry)
585
+ session.update({"sessionUpdate": "available_commands_update", "availableCommands": available})
586
+
587
+ def session(self, params: dict) -> Session:
588
+ session = self.sessions.get(str(params.get("sessionId", "")))
589
+ if session is None:
590
+ raise RpcError(NOT_FOUND, f"no session {params.get('sessionId')}")
591
+ return session
592
+
593
+ def prompt(self, params: dict) -> dict:
594
+ session = self.session(params)
595
+ try:
596
+ return session.prompt(params.get("prompt") or [])
597
+ finally:
598
+ session.agent.no_changes = ""
599
+
600
+ def cancel(self, params: dict) -> None:
601
+ session = self.sessions.get(str(params.get("sessionId", "")))
602
+ if session is not None and session.agent.cancel is not None:
603
+ session.agent.cancel.set()
604
+ from lcode import subagents
605
+
606
+ if session.agent.response is not None:
607
+ subagents.abort(session.agent.response) # stop the model's answer now
608
+ return None
609
+
610
+ def set_mode(self, params: dict) -> dict:
611
+ session = self.session(params)
612
+ mode = str(params.get("modeId", ""))
613
+ if mode not in {m[0] for m in MODES}:
614
+ raise RpcError(INVALID_PARAMS, f"unknown mode {mode}")
615
+ session.agent.perms.mode = session.mode = mode
616
+ return {}
617
+
618
+
619
+ def main() -> int:
620
+ """Serve one editor over stdin and stdout until it disconnects."""
621
+ inp, out = sys.stdin.buffer, sys.stdout.buffer
622
+ sys.stdout = sys.stderr # a stray print must not corrupt the protocol
623
+ # Open for the life of the process:
624
+ sys.stdin = open(os.devnull) # noqa: SIM115 # nothing may wait for typed input: stdin is the protocol
625
+ log = os.environ.get("LCODE_ACP_LOG")
626
+ output = open(log, "a") if log else open(os.devnull, "w") # noqa: SIM115 # lcode's own output
627
+ console = Console(file=output, width=120, force_terminal=False)
628
+ conn = Connection(inp, out)
629
+ server = Server(conn, console)
630
+ conn.serve(server.handle)
631
+ for session in server.sessions.values():
632
+ from lcode import api
633
+
634
+ api.close_agent(session.agent)
635
+ return 0