local-coding-agent 1.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 (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,663 @@
1
+ """ACP Server implementation (JSON-RPC 2.0 stdio event loop and handlers)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ import threading
9
+ import uuid
10
+ from pathlib import Path
11
+ from typing import Any, BinaryIO, Callable, TextIO
12
+
13
+ from ..ast_compactor import skeletonize_file
14
+ from ..controller import Controller
15
+ from ..lsp import LspManager
16
+ from ..ollama_adapter import ModelProfile, OllamaError, build_client # noqa: F401
17
+ from ..profiles import get_profile
18
+ from ..ripgrep import ripgrep_search
19
+ from ..semantic_linter import lint_patch_in_memory
20
+ from ..spill import read_spill
21
+ from ..task import TaskEnvelope
22
+ from ..validators import check_patch_applies
23
+
24
+ from ._codec import AcpCodec
25
+ from ._protocol import (
26
+ JSONRPC_INTERNAL_ERROR,
27
+ JSONRPC_INVALID_PARAMS,
28
+ JSONRPC_INVALID_REQUEST,
29
+ JSONRPC_METHOD_NOT_FOUND,
30
+ JSONRPC_PARSE_ERROR,
31
+ PROTOCOL_VERSION,
32
+ SERVER_NAME,
33
+ SERVER_VERSION,
34
+ )
35
+ from ._session import AcpSession
36
+ from ._tools import ACP_TOOLS
37
+
38
+
39
+ class AcpServer:
40
+ """Standard JSON-RPC 2.0 ACP Server & Interop Gateway."""
41
+
42
+ def __init__(
43
+ self,
44
+ *,
45
+ default_workspace: str | Path | None = None,
46
+ default_profile: str = "qwen2.5-1.5b",
47
+ model_factory: Callable[[Any], Any] | None = None,
48
+ framing: str = "auto",
49
+ max_request_bytes: int = 10 * 1024 * 1024,
50
+ server_name: str = SERVER_NAME,
51
+ server_version: str = SERVER_VERSION,
52
+ protocol_version: str = PROTOCOL_VERSION,
53
+ ) -> None:
54
+ self.default_workspace = Path(default_workspace).resolve() if default_workspace else Path.cwd().resolve()
55
+ self.default_profile = default_profile
56
+ self.model_factory = model_factory
57
+ self.framing = framing
58
+ self.max_request_bytes = max_request_bytes
59
+ self.server_name = server_name
60
+ self.server_version = server_version
61
+ self.protocol_version = protocol_version
62
+
63
+ self._sessions: dict[str, AcpSession] = {}
64
+ self._sessions_lock = threading.RLock()
65
+ self._write_lock = threading.RLock()
66
+ self._output_stream: Any | None = None
67
+ self._closed = False
68
+ self._detected_framing: str = "jsonl"
69
+
70
+ # ------------------------------------------------------------------------
71
+ # Core Stream Loop & Message Processing
72
+ # ------------------------------------------------------------------------
73
+
74
+ def serve(
75
+ self,
76
+ input_stream: BinaryIO | TextIO | None = None,
77
+ output_stream: BinaryIO | TextIO | None = None,
78
+ ) -> None:
79
+ """Run the JSON-RPC stdio event loop until EOF or shutdown."""
80
+ source = input_stream or getattr(sys.stdin, "buffer", sys.stdin)
81
+ target = output_stream or getattr(sys.stdout, "buffer", sys.stdout)
82
+ self._output_stream = target
83
+
84
+ while not self._closed:
85
+ try:
86
+ msg, detected_framing = AcpCodec.read_message(source, max_bytes=self.max_request_bytes)
87
+ if msg is None:
88
+ break # EOF reached
89
+ if detected_framing:
90
+ self._detected_framing = detected_framing
91
+
92
+ response = self.process_request(msg)
93
+ if response is not None:
94
+ self.write_message(response)
95
+ except json.JSONDecodeError as error:
96
+ err_resp = self._make_error(None, JSONRPC_PARSE_ERROR, f"Parse error: {error}")
97
+ self.write_message(err_resp)
98
+ except ValueError as error:
99
+ err_msg = str(error)
100
+ if "JSON-RPC message must be an object" in err_msg or "method" in err_msg:
101
+ err_resp = self._make_error(None, JSONRPC_INVALID_REQUEST, f"Invalid Request: {error}")
102
+ else:
103
+ err_resp = self._make_error(None, JSONRPC_PARSE_ERROR, f"Parse error: {error}")
104
+ self.write_message(err_resp)
105
+ except Exception as error:
106
+ err_resp = self._make_error(None, JSONRPC_INTERNAL_ERROR, f"Internal server error: {error}")
107
+ self.write_message(err_resp)
108
+
109
+ def write_message(self, message: dict[str, Any], framing: str | None = None) -> None:
110
+ """Write a framed message to the active output stream in a thread-safe manner."""
111
+ target = self._output_stream
112
+ if target is None:
113
+ return
114
+
115
+ effective_framing = framing or (self._detected_framing if self.framing == "auto" else self.framing)
116
+ data_bytes = AcpCodec.format_message(message, effective_framing)
117
+
118
+ with self._write_lock:
119
+ try:
120
+ target.write(data_bytes)
121
+ except TypeError:
122
+ # If target is TextIO, write decoded string
123
+ target.write(data_bytes.decode("utf-8", errors="replace"))
124
+ target.flush()
125
+
126
+ def notify(self, method: str, params: dict[str, Any]) -> None:
127
+ """Send an asynchronous JSON-RPC notification to the client."""
128
+ notification = {
129
+ "jsonrpc": "2.0",
130
+ "method": method,
131
+ "params": params,
132
+ }
133
+ self.write_message(notification)
134
+
135
+ def process_request(self, raw_or_dict: str | bytes | dict[str, Any]) -> dict[str, Any] | None:
136
+ """Process a single JSON-RPC message and return the response dictionary."""
137
+ if isinstance(raw_or_dict, (str, bytes)):
138
+ try:
139
+ text = raw_or_dict.decode("utf-8") if isinstance(raw_or_dict, bytes) else raw_or_dict
140
+ msg = json.loads(text)
141
+ except Exception as err:
142
+ return self._make_error(None, JSONRPC_PARSE_ERROR, f"Parse error: {err}")
143
+ elif isinstance(raw_or_dict, dict):
144
+ msg = raw_or_dict
145
+ else:
146
+ return self._make_error(None, JSONRPC_INVALID_REQUEST, "Message must be an object")
147
+
148
+ if not isinstance(msg, dict):
149
+ return self._make_error(None, JSONRPC_INVALID_REQUEST, "Message must be an object")
150
+
151
+ # JSON-RPC 2.0 version validation
152
+ if "jsonrpc" in msg and msg.get("jsonrpc") != "2.0":
153
+ return self._make_error(msg.get("id"), JSONRPC_INVALID_REQUEST, "Invalid JSON-RPC protocol version; expected '2.0'")
154
+
155
+ is_notification = "id" not in msg
156
+ req_id = msg.get("id")
157
+ method = msg.get("method")
158
+ params = msg.get("params")
159
+ if params is None:
160
+ params = {}
161
+
162
+ if not isinstance(method, str) or not method.strip():
163
+ if is_notification:
164
+ return None
165
+ return self._make_error(req_id, JSONRPC_INVALID_REQUEST, "Missing or invalid 'method' field")
166
+ if not isinstance(params, (dict, list)):
167
+ if is_notification:
168
+ return None
169
+ return self._make_error(req_id, JSONRPC_INVALID_PARAMS, "'params' must be an object or array")
170
+
171
+ # Normalize method names
172
+ normalized_method = self._normalize_method(method)
173
+ handler = getattr(self, f"handle_{normalized_method}", None)
174
+ if handler is None:
175
+ if not is_notification:
176
+ return self._make_error(req_id, JSONRPC_METHOD_NOT_FOUND, f"Method not found: {method}")
177
+ return None
178
+
179
+ try:
180
+ result = handler(params if isinstance(params, dict) else {"_args": params})
181
+ if not is_notification:
182
+ return {"jsonrpc": "2.0", "id": req_id, "result": result}
183
+ return None
184
+ except ValueError as err:
185
+ if not is_notification:
186
+ return self._make_error(req_id, JSONRPC_INVALID_PARAMS, str(err))
187
+ return None
188
+ except Exception as err:
189
+ if not is_notification:
190
+ return self._make_error(req_id, JSONRPC_INTERNAL_ERROR, str(err))
191
+ return None
192
+
193
+ @staticmethod
194
+ def _make_error(req_id: Any, code: int, message: str, data: Any = None) -> dict[str, Any]:
195
+ err_obj: dict[str, Any] = {"code": code, "message": message}
196
+ if data is not None:
197
+ err_obj["data"] = data
198
+ return {"jsonrpc": "2.0", "id": req_id, "error": err_obj}
199
+
200
+ @staticmethod
201
+ def _normalize_method(method: str) -> str:
202
+ mapping = {
203
+ "initialize": "initialize",
204
+ "init": "initialize",
205
+ "session/new": "session_new",
206
+ "session_new": "session_new",
207
+ "new_session": "session_new",
208
+ "newSession": "session_new",
209
+ "session/load": "session_load",
210
+ "session_load": "session_load",
211
+ "load_session": "session_load",
212
+ "loadSession": "session_load",
213
+ "session/prompt": "session_prompt",
214
+ "session_prompt": "session_prompt",
215
+ "prompt": "session_prompt",
216
+ "session/cancel": "session_cancel",
217
+ "session_cancel": "session_cancel",
218
+ "cancel": "session_cancel",
219
+ "tools/list": "tools_list",
220
+ "tools_list": "tools_list",
221
+ "list_tools": "tools_list",
222
+ "listTools": "tools_list",
223
+ "tools/call": "tools_call",
224
+ "tools_call": "tools_call",
225
+ "call_tool": "tools_call",
226
+ "callTool": "tools_call",
227
+ "ping": "ping",
228
+ "shutdown": "shutdown",
229
+ "exit": "exit",
230
+ }
231
+ return mapping.get(method, method.replace("/", "_"))
232
+
233
+ # ------------------------------------------------------------------------
234
+ # Protocol Handlers
235
+ # ------------------------------------------------------------------------
236
+
237
+ def handle_initialize(self, params: dict[str, Any]) -> dict[str, Any]:
238
+ """Handle initialize handshake."""
239
+ return {
240
+ "protocol_version": self.protocol_version,
241
+ "protocolVersion": self.protocol_version,
242
+ "server_info": {
243
+ "name": self.server_name,
244
+ "version": self.server_version,
245
+ },
246
+ "serverInfo": {
247
+ "name": self.server_name,
248
+ "version": self.server_version,
249
+ },
250
+ "agentInfo": {
251
+ "name": self.server_name,
252
+ "version": self.server_version,
253
+ },
254
+ "capabilities": {
255
+ "sessions": True,
256
+ "streaming": True,
257
+ "tools": True,
258
+ "cancellation": True,
259
+ "load_session": True,
260
+ "promptCapabilities": {
261
+ "image": False,
262
+ "audio": False,
263
+ "embeddedContext": True,
264
+ },
265
+ },
266
+ "agentCapabilities": {
267
+ "promptCapabilities": {
268
+ "image": False,
269
+ "audio": False,
270
+ "embeddedContext": True,
271
+ },
272
+ },
273
+ "authMethods": [],
274
+ }
275
+
276
+ def handle_session_new(self, params: dict[str, Any]) -> dict[str, Any]:
277
+ """Create a new ACP session with workspace and profile configuration."""
278
+ raw_ws = (
279
+ params.get("workspace_path")
280
+ or params.get("workspacePath")
281
+ or params.get("cwd")
282
+ or params.get("workspace")
283
+ or str(self.default_workspace)
284
+ )
285
+ ws_path = Path(raw_ws).resolve()
286
+ if not ws_path.is_dir():
287
+ raise ValueError(f"Workspace path does not exist or is not a directory: {ws_path}")
288
+
289
+ profile = (
290
+ params.get("profile")
291
+ or params.get("model_profile")
292
+ or params.get("model")
293
+ or self.default_profile
294
+ )
295
+
296
+ session_id = params.get("session_id") or params.get("sessionId") or f"session_{uuid.uuid4().hex[:12]}"
297
+
298
+ session = AcpSession(
299
+ session_id=session_id,
300
+ workspace_path=ws_path,
301
+ profile=profile,
302
+ metadata={"created_via": "acp"},
303
+ )
304
+
305
+ with self._sessions_lock:
306
+ self._sessions[session_id] = session
307
+
308
+ return {
309
+ "session_id": session_id,
310
+ "sessionId": session_id,
311
+ "workspace_path": str(ws_path),
312
+ "profile": profile,
313
+ }
314
+
315
+ def handle_session_load(self, params: dict[str, Any]) -> dict[str, Any]:
316
+ """Load an existing session's history and configuration."""
317
+ session_id = params.get("session_id") or params.get("sessionId")
318
+ if not session_id:
319
+ raise ValueError("session_id parameter is required")
320
+
321
+ with self._sessions_lock:
322
+ session = self._sessions.get(session_id)
323
+
324
+ if session is None:
325
+ raise ValueError(f"Session not found: {session_id}")
326
+
327
+ return {
328
+ "session_id": session.session_id,
329
+ "sessionId": session.session_id,
330
+ "workspace_path": str(session.workspace_path),
331
+ "profile": session.profile,
332
+ "history": list(session.history),
333
+ "created_at": session.created_at,
334
+ }
335
+
336
+ def handle_session_prompt(self, params: dict[str, Any]) -> dict[str, Any]:
337
+ """Execute a prompt turn in the given session."""
338
+ session_id = params.get("session_id") or params.get("sessionId")
339
+ if not session_id:
340
+ raise ValueError("session_id parameter is required")
341
+
342
+ with self._sessions_lock:
343
+ session = self._sessions.get(session_id)
344
+
345
+ if session is None:
346
+ raise ValueError(f"Session not found: {session_id}")
347
+
348
+ raw_prompt = params.get("prompt") or params.get("message") or params.get("content")
349
+ if raw_prompt is None:
350
+ raise ValueError("prompt parameter is required")
351
+
352
+ # Extract textual prompt
353
+ if isinstance(raw_prompt, str):
354
+ prompt_text = raw_prompt
355
+ elif isinstance(raw_prompt, dict):
356
+ prompt_text = raw_prompt.get("content") or raw_prompt.get("text") or json.dumps(raw_prompt, ensure_ascii=False)
357
+ elif isinstance(raw_prompt, list):
358
+ blocks = []
359
+ for b in raw_prompt:
360
+ if isinstance(b, dict) and "text" in b:
361
+ blocks.append(str(b["text"]))
362
+ elif isinstance(b, str):
363
+ blocks.append(b)
364
+ prompt_text = "\n".join(blocks)
365
+ else:
366
+ prompt_text = str(raw_prompt)
367
+
368
+ stream = bool(params.get("stream", params.get("streaming", False)))
369
+
370
+ with session.lock:
371
+ session.cancel_event.clear()
372
+ session.active_prompt = True
373
+ session.history.append({"role": "user", "content": prompt_text})
374
+
375
+ try:
376
+ # Check for instant cancellation
377
+ if session.cancel_event.is_set():
378
+ return self._cancel_turn_result(session)
379
+
380
+ # Instantiate client
381
+ client = self._get_model_client(session.profile)
382
+
383
+ # If prompt is a TaskEnvelope representation, execute Controller
384
+ if isinstance(raw_prompt, dict) and "goal" in raw_prompt and "files" in raw_prompt:
385
+ task = TaskEnvelope.from_mapping(raw_prompt)
386
+ controller = Controller(
387
+ client,
388
+ session.workspace_path,
389
+ cancel_event=session.cancel_event,
390
+ )
391
+ task_res = controller.run(task, apply=bool(params.get("apply", False)))
392
+ resp_text = json.dumps(task_res, ensure_ascii=False, indent=2)
393
+ else:
394
+ # Interactive chat turn
395
+ resp = client.chat(session.history)
396
+ if session.cancel_event.is_set():
397
+ return self._cancel_turn_result(session)
398
+
399
+ msg_obj = resp.get("message", {}) if isinstance(resp, dict) else {}
400
+ resp_text = msg_obj.get("content", "")
401
+
402
+ if stream and resp_text:
403
+ self.notify(
404
+ "session/update",
405
+ {
406
+ "sessionId": session.session_id,
407
+ "session_id": session.session_id,
408
+ "update": {
409
+ "sessionUpdate": "agent_message_chunk",
410
+ "content": [{"type": "text", "text": resp_text}],
411
+ },
412
+ },
413
+ )
414
+
415
+ session.history.append({"role": "assistant", "content": resp_text})
416
+ turn_idx = len(session.history) // 2
417
+
418
+ return {
419
+ "session_id": session.session_id,
420
+ "sessionId": session.session_id,
421
+ "stop_reason": "end_turn",
422
+ "stopReason": "end_turn",
423
+ "content": resp_text,
424
+ "message": {"role": "assistant", "content": resp_text},
425
+ "turn": turn_idx,
426
+ "status": "completed",
427
+ }
428
+ except Exception as error:
429
+ if session.cancel_event.is_set():
430
+ return self._cancel_turn_result(session)
431
+ raise
432
+ finally:
433
+ session.active_prompt = False
434
+
435
+ def _cancel_turn_result(self, session: AcpSession) -> dict[str, Any]:
436
+ session.history.append({"role": "assistant", "content": "", "status": "cancelled"})
437
+ return {
438
+ "session_id": session.session_id,
439
+ "sessionId": session.session_id,
440
+ "stop_reason": "cancelled",
441
+ "stopReason": "cancelled",
442
+ "content": "",
443
+ "message": {"role": "assistant", "content": ""},
444
+ "status": "cancelled",
445
+ }
446
+
447
+ def handle_session_cancel(self, params: dict[str, Any]) -> dict[str, Any]:
448
+ """Cancel an in-flight prompt on the active session."""
449
+ session_id = params.get("session_id") or params.get("sessionId")
450
+ if not session_id:
451
+ raise ValueError("session_id parameter is required")
452
+
453
+ with self._sessions_lock:
454
+ session = self._sessions.get(session_id)
455
+
456
+ if session is None:
457
+ return {"session_id": session_id, "sessionId": session_id, "cancelled": False, "error": "session not found"}
458
+
459
+ session.cancel_event.set()
460
+ return {
461
+ "session_id": session.session_id,
462
+ "sessionId": session.session_id,
463
+ "cancelled": True,
464
+ }
465
+
466
+ def handle_tools_list(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
467
+ """List available tools exposed to ACP clients."""
468
+ return {"tools": ACP_TOOLS}
469
+
470
+ def handle_tools_call(self, params: dict[str, Any]) -> dict[str, Any]:
471
+ """Execute a specific tool call over ACP."""
472
+ name = params.get("name") or params.get("tool")
473
+ if not name:
474
+ raise ValueError("tool name parameter is required")
475
+
476
+ args = params.get("arguments") or params.get("args") or {}
477
+ if not isinstance(args, dict):
478
+ raise ValueError("tool arguments must be an object")
479
+
480
+ ws_str = params.get("workspace") or args.get("workspace") or str(self.default_workspace)
481
+ ws_path = Path(ws_str).resolve()
482
+
483
+ try:
484
+ if name == "spill_read":
485
+ locator = args.get("locator", "")
486
+ offset = int(args.get("offset", 0))
487
+ limit = int(args.get("limit", 1000))
488
+ content = read_spill(locator, offset_line=offset, limit_lines=limit)
489
+ return self._tool_success(content, {"locator": locator, "offset": offset, "limit": limit, "content": content})
490
+
491
+ elif name == "grep":
492
+ query = args.get("query", "")
493
+ paths = args.get("paths")
494
+ if paths and isinstance(paths, list):
495
+ for p in paths:
496
+ if isinstance(p, str) and (".." in p or p.startswith("/") or (len(p) > 1 and p[1] == ":")):
497
+ raise ValueError(f"Grep path escapes workspace boundary: {p}")
498
+ regex = bool(args.get("regex", args.get("is_regex", False)))
499
+ case_sens = bool(args.get("case_sensitive", False))
500
+ max_res = int(args.get("max_results", 100))
501
+ matches = ripgrep_search(
502
+ query,
503
+ root=ws_path,
504
+ globs=paths,
505
+ is_regex=regex,
506
+ case_sensitive=case_sens,
507
+ max_results=max_res,
508
+ )
509
+ match_dicts = [
510
+ {"file": m.file, "line": m.line_number, "text": m.line_content}
511
+ for m in matches
512
+ ]
513
+ formatted = "\n".join(f"{m['file']}:{m['line']}: {m['text']}" for m in match_dicts)
514
+ return self._tool_success(formatted, {"count": len(matches), "matches": match_dicts})
515
+
516
+ elif name == "lsp":
517
+ op = args.get("operation")
518
+ file_rel = args.get("file", "")
519
+ target_file = (ws_path / file_rel).resolve()
520
+ if not self._is_safe_path(target_file, ws_path):
521
+ raise ValueError(f"Path escapes workspace boundary: {file_rel}")
522
+ line = int(args.get("line", 0))
523
+ char = int(args.get("char", args.get("character", 0)))
524
+ lsp_mgr = LspManager(workspace_root=ws_path)
525
+
526
+ if op == "definition":
527
+ res = lsp_mgr.go_to_definition(file_rel, line=line, character=char, workspace_root=ws_path)
528
+ data = [r.to_dict() for r in res]
529
+ elif op == "references":
530
+ res = lsp_mgr.find_references(file_rel, line=line, character=char, workspace_root=ws_path)
531
+ data = [r.to_dict() for r in res]
532
+ elif op == "hover":
533
+ h = lsp_mgr.hover(file_rel, line=line, character=char, workspace_root=ws_path)
534
+ data = h.to_dict() if h else None
535
+ elif op == "symbols":
536
+ syms = lsp_mgr.document_symbols(file_rel, workspace_root=ws_path)
537
+ data = [s.to_dict() for s in syms]
538
+ else:
539
+ raise ValueError(f"Unknown LSP operation: {op}")
540
+
541
+ return self._tool_success(json.dumps(data, ensure_ascii=False, indent=2), data)
542
+
543
+ elif name == "skeletonize":
544
+ file_rel = args.get("file") or args.get("path", "")
545
+ target_file = (ws_path / file_rel).resolve()
546
+ if not self._is_safe_path(target_file, ws_path):
547
+ raise ValueError(f"Path escapes workspace boundary: {file_rel}")
548
+ symbols = args.get("symbols")
549
+ skeleton = skeletonize_file(str(target_file), target_symbols=symbols)
550
+ return self._tool_success(skeleton, {"file": str(file_rel), "skeleton": skeleton})
551
+
552
+ elif name == "lint_patch":
553
+ patch = args.get("patch", "")
554
+ report = lint_patch_in_memory(str(ws_path), patch)
555
+ data = {
556
+ "valid": report.valid,
557
+ "diagnostics": [
558
+ {"file": d.file, "line": d.line, "message": d.message, "rule": d.rule}
559
+ for d in report.diagnostics
560
+ ],
561
+ "prescriptions": list(report.prescriptions),
562
+ }
563
+ text_out = "Valid patch" if report.valid else f"Lint issues: {list(report.prescriptions)}"
564
+ return self._tool_success(text_out, data)
565
+
566
+ elif name == "read_file":
567
+ rel = args.get("path") or args.get("file", "")
568
+ target = (ws_path / rel).resolve()
569
+ if not self._is_safe_path(target, ws_path):
570
+ raise ValueError(f"Path escapes workspace boundary: {rel}")
571
+ content = target.read_text(encoding="utf-8", errors="replace")
572
+ return self._tool_success(content, {"path": rel, "content": content})
573
+
574
+ elif name == "list_files":
575
+ rel = args.get("path", ".")
576
+ target = (ws_path / rel).resolve()
577
+ if not self._is_safe_path(target, ws_path):
578
+ raise ValueError(f"Path escapes workspace boundary: {rel}")
579
+ files = []
580
+ for p in target.rglob("*"):
581
+ if p.is_file() and not any(part.startswith(".") or part in {"__pycache__", "node_modules", ".venv"} for part in p.parts):
582
+ files.append(p.relative_to(ws_path).as_posix())
583
+ return self._tool_success("\n".join(files), {"files": files})
584
+
585
+ elif name == "run_tests":
586
+ cmd = args.get("command", "")
587
+ timeout = float(args.get("timeout", 60))
588
+ cp = subprocess.run(
589
+ cmd,
590
+ shell=True,
591
+ cwd=ws_path,
592
+ stdout=subprocess.PIPE,
593
+ stderr=subprocess.PIPE,
594
+ text=True,
595
+ timeout=timeout,
596
+ )
597
+ passed = cp.returncode == 0
598
+ out = (cp.stdout + cp.stderr).strip()
599
+ return self._tool_success(out, {"command": cmd, "exit_code": cp.returncode, "passed": passed, "stdout": cp.stdout, "stderr": cp.stderr})
600
+
601
+ elif name == "propose_patch":
602
+ patch = args.get("patch", "")
603
+ applies, err = check_patch_applies(ws_path, patch)
604
+ return self._tool_success(
605
+ "Applies cleanly" if applies else f"Check failed: {err}",
606
+ {"applies": applies, "error": err if not applies else None},
607
+ )
608
+
609
+ else:
610
+ raise ValueError(f"Unknown tool: {name}")
611
+
612
+ except Exception as error:
613
+ return self._tool_error(str(error))
614
+
615
+ def handle_ping(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
616
+ """Health-check ping."""
617
+ return {"status": "ok", "pong": True}
618
+
619
+ def handle_shutdown(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
620
+ """Gracefully shutdown server."""
621
+ self._closed = True
622
+ return {"status": "shutdown_acknowledged"}
623
+
624
+ def handle_exit(self, params: dict[str, Any] | None = None) -> None:
625
+ """Exit server."""
626
+ self._closed = True
627
+
628
+ # ------------------------------------------------------------------------
629
+ # Helpers
630
+ # ------------------------------------------------------------------------
631
+
632
+ def _get_model_client(self, profile_name: str) -> Any:
633
+ if self.model_factory is not None:
634
+ return self.model_factory(profile_name)
635
+ profile = get_profile(profile_name)
636
+ return build_client(profile)
637
+
638
+ @staticmethod
639
+ def _is_safe_path(target: Path, root: Path) -> bool:
640
+ try:
641
+ target.resolve().relative_to(root.resolve())
642
+ return True
643
+ except ValueError:
644
+ return False
645
+
646
+ @staticmethod
647
+ def _tool_success(text_out: str, data: Any = None) -> dict[str, Any]:
648
+ return {
649
+ "content": [{"type": "text", "text": text_out}],
650
+ "result": data,
651
+ "is_error": False,
652
+ "isError": False,
653
+ }
654
+
655
+ @staticmethod
656
+ def _tool_error(err_msg: str) -> dict[str, Any]:
657
+ return {
658
+ "content": [{"type": "text", "text": f"Error: {err_msg}"}],
659
+ "result": None,
660
+ "is_error": True,
661
+ "isError": True,
662
+ "error": err_msg,
663
+ }
@@ -0,0 +1,24 @@
1
+ """ACP session model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ @dataclass
13
+ class AcpSession:
14
+ """An active Agent Client Protocol session."""
15
+
16
+ session_id: str
17
+ workspace_path: Path
18
+ profile: str
19
+ history: list[dict[str, Any]] = field(default_factory=list)
20
+ created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
21
+ cancel_event: threading.Event = field(default_factory=threading.Event)
22
+ lock: threading.Lock = field(default_factory=threading.Lock)
23
+ active_prompt: bool = False
24
+ metadata: dict[str, Any] = field(default_factory=dict)