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,2178 @@
1
+ """HTTP request handler serving the Desktop UI and REST endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import http.client
6
+ import hmac
7
+ import json
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import time
13
+ import urllib.request
14
+ import uuid
15
+ from dataclasses import replace
16
+ from http import HTTPStatus
17
+ from http.server import BaseHTTPRequestHandler
18
+ from pathlib import Path
19
+ from typing import Any
20
+ from urllib.parse import urlparse
21
+
22
+ from ...controller import mediated_apply
23
+ from ...doctor import remediate_environment
24
+ from ...memory import ModelMemoryManager
25
+ from ...model_scanner import discover_llama_server_binary, get_live_system_path
26
+ from ...ollama_adapter import OllamaError, build_client
27
+ from ...profiles import ModelProfile, get_profile, list_profiles
28
+ from ...task import TaskEnvelope
29
+ from ...validators import apply_patch, parse_unified_diff
30
+ from ...vram_fit import ContextFit, fit_context, kv_bytes_per_token, read_gguf_ctx_params
31
+ from ..ui import render_desktop_html
32
+ from ._models import (
33
+ _classify_backend_error,
34
+ discover_local_ollama_models,
35
+ find_discovered_gguf,
36
+ resolve_model_profile,
37
+ select_available_profile,
38
+ )
39
+ from ._telemetry import get_nvidia_gpu_telemetry
40
+
41
+ # Bounded recent-prompt history feeding the hybrid mode router's isolated
42
+ # context. Kept module-level (not on server state) so DesktopServer.__init__
43
+ # needs no change. Mutations are guarded by server_inst.apply_lock; a deque
44
+ # could replace the list if it ever needs to grow past a small bound.
45
+ _MAX_RECENT_PROMPTS = 6
46
+ _recent_prompts: list[str] = []
47
+ _ASSET_DIR = Path(__file__).resolve().parent.parent / "assets"
48
+
49
+ # Hard resource ceiling for a client-supplied context override. The per-model
50
+ # VRAM preflight in _launch_llama_model clamps below this, but the cap keeps a
51
+ # hostile/mistyped request from ever reaching llama-server.
52
+ _MAX_CTX_OVERRIDE = 262144
53
+ _DEFAULT_LLAMA_CTX = 8192
54
+ _MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024
55
+ _MAX_BODY_READ_CHUNK = 64 * 1024
56
+ _MAX_REJECTED_BODY_DRAIN = 64 * 1024
57
+ _REQUEST_BODY_IDLE_TIMEOUT = 2.0
58
+
59
+
60
+ class _RequestBodyError(ValueError):
61
+ """A request body cannot be safely framed or read."""
62
+
63
+ def __init__(self, status: HTTPStatus, message: str) -> None:
64
+ super().__init__(message)
65
+ self.status = status
66
+ self.message = message
67
+
68
+
69
+ def build_queue_controller(profile_name: str, workspace_str: str, cancel_event: Any = None) -> Any:
70
+ """Build the real Controller for a queued desktop task.
71
+
72
+ Mirrors _handle_delegate's construction exactly (resolve_model_profile ->
73
+ build_client -> Controller) and resolves symbols through this module's
74
+ namespace, so tests can monkeypatch ``build_client`` / the Controller
75
+ class with the existing idioms. The background worker passes a
76
+ cooperative cancel Event through to the controller.
77
+ """
78
+ profile = resolve_model_profile(profile_name)
79
+ client = build_client(profile)
80
+ from ...controller import Controller
81
+
82
+ return Controller(client, workspace_str, cancel_event=cancel_event)
83
+
84
+
85
+ class DesktopRequestHandler(BaseHTTPRequestHandler):
86
+ """HTTP request handler serving the Desktop UI and REST endpoints."""
87
+
88
+ @property
89
+ def server_inst(self) -> Any:
90
+ return getattr(self.server, "desktop_server", self.server)
91
+
92
+ def do_GET(self) -> None:
93
+ parsed = urlparse(self.path)
94
+ path = parsed.path.rstrip("/")
95
+
96
+ if path in {"", "/app", "/index.html"}:
97
+ self._send_response(
98
+ HTTPStatus.OK,
99
+ "text/html; charset=utf-8",
100
+ render_desktop_html(self.server_inst.mutation_token).encode("utf-8"),
101
+ )
102
+ elif path == "/assets/tailwind.css":
103
+ self._send_asset("tailwind.css", "text/css; charset=utf-8")
104
+ elif path == "/assets/lucide.min.js":
105
+ self._send_asset("lucide.min.js", "text/javascript; charset=utf-8")
106
+ elif path in {"/api/status", "/status"}:
107
+ self._handle_status()
108
+ elif path in {"/api/gpu", "/gpu", "/api/gpu/telemetry"}:
109
+ self._handle_gpu_telemetry()
110
+ elif path in {"/api/models", "/models", "/api/profiles"}:
111
+ self._handle_models()
112
+ elif path in {"/api/sessions", "/sessions"}:
113
+ self._handle_sessions()
114
+ elif path in {"/api/tasks", "/tasks"}:
115
+ self._handle_tasks_list()
116
+ elif path in {"/api/workspace/files", "/workspace/files"}:
117
+ self._handle_workspace_files()
118
+ elif path in {"/api/health", "/health"}:
119
+ self._send_json({"status": "ok", "uptime": round(time.monotonic() - self.server_inst.started_at, 2)})
120
+ elif path == "/v1/models":
121
+ self._handle_v1_models()
122
+ else:
123
+ self._send_response(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"404 Not Found\n")
124
+
125
+ def do_POST(self) -> None:
126
+ parsed = urlparse(self.path)
127
+ path = parsed.path.rstrip("/")
128
+
129
+ self._request_body = None
130
+ try:
131
+ content_length = self._parse_content_length()
132
+ except _RequestBodyError as error:
133
+ self._send_request_body_error(error)
134
+ return
135
+
136
+ rejection = self._unsafe_mutation_reason(content_length)
137
+ if rejection:
138
+ # Windows may reset a connection when the handler closes it while
139
+ # the client request body is still buffered. Drain the declared
140
+ # body before returning the intentional 403 so callers receive
141
+ # the rejection instead of a transport-level connection abort.
142
+ # The drain is capped; a connection with unread bytes is closed
143
+ # rather than being reused with an unknown request boundary.
144
+ try:
145
+ self._discard_request_body(content_length)
146
+ except _RequestBodyError as error:
147
+ self._send_request_body_error(error)
148
+ return
149
+ self._send_json(
150
+ {"status": "rejected", "error": rejection},
151
+ HTTPStatus.FORBIDDEN,
152
+ )
153
+ return
154
+
155
+ try:
156
+ self._request_body = self._read_request_body(content_length)
157
+ except _RequestBodyError as error:
158
+ self._send_request_body_error(error)
159
+ return
160
+
161
+ if path in {"/api/chat", "/chat"}:
162
+ self._handle_chat()
163
+ elif path in {"/api/delegate", "/delegate"}:
164
+ self._handle_delegate()
165
+ elif path in {"/api/tasks", "/tasks"}:
166
+ self._handle_tasks_submit()
167
+ elif path in {"/api/tasks/cancel", "/tasks/cancel"}:
168
+ self._handle_tasks_cancel()
169
+ elif path in {"/api/apply", "/apply"}:
170
+ self._handle_apply()
171
+ elif path in {"/api/rollback", "/rollback"}:
172
+ self._handle_rollback()
173
+ elif path in {"/api/server/start", "/server/start"}:
174
+ self._handle_server_start()
175
+ elif path in {"/api/server/stop", "/server/stop"}:
176
+ self._handle_server_stop()
177
+ elif path in {"/api/model/load", "/model/load"}:
178
+ self._handle_model_load()
179
+ elif path in {"/api/model/unload", "/model/unload"}:
180
+ self._handle_model_unload()
181
+ elif path in {"/api/model/unload_all", "/model/unload_all"}:
182
+ self._handle_model_unload_all()
183
+ elif path in {"/api/doctor/fix", "/doctor/fix"}:
184
+ self._handle_doctor_fix()
185
+ elif path in {"/api/sessions", "/sessions"}:
186
+ self._handle_create_session()
187
+ elif path in {"/api/models/scan", "/models/scan"}:
188
+ self._handle_model_scan()
189
+ elif path == "/v1/chat/completions":
190
+ self._handle_v1_chat_completions()
191
+ elif path in {"/api/models/add_dir", "/models/add_dir"}:
192
+ self._handle_model_add_dir()
193
+ elif path in {"/api/models/remove_dir", "/models/remove_dir"}:
194
+ self._handle_model_remove_dir()
195
+ else:
196
+ self._send_response(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"404 Not Found\n")
197
+
198
+ def _unsafe_mutation_reason(self, content_length: int | None = None) -> str:
199
+ """Reject browser-driven loopback CSRF while preserving local CLI use."""
200
+ expected_port = self.server_inst.actual_port
201
+ allowed_hosts = {
202
+ f"127.0.0.1:{expected_port}",
203
+ f"localhost:{expected_port}",
204
+ f"[::1]:{expected_port}",
205
+ }
206
+ host = self.headers.get("Host", "").lower()
207
+ if host not in allowed_hosts:
208
+ return "Host must target this loopback desktop server"
209
+
210
+ supplied_token = self.headers.get("X-Desktop-Token", "")
211
+ if not hmac.compare_digest(supplied_token, self.server_inst.mutation_token):
212
+ return "Missing or invalid desktop mutation token"
213
+
214
+ origin = self.headers.get("Origin")
215
+ if origin:
216
+ parsed = urlparse(origin)
217
+ origin_host = (parsed.netloc or "").lower()
218
+ if parsed.scheme not in {"http", "https"} or origin_host not in allowed_hosts:
219
+ return "Cross-origin desktop API mutations are not allowed"
220
+
221
+ if content_length is None:
222
+ content_length = self._parse_content_length()
223
+ content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
224
+ if content_length > 0 and content_type != "application/json":
225
+ return "State-changing request bodies must use application/json"
226
+ return ""
227
+
228
+ def _parse_content_length(self) -> int:
229
+ """Parse one bounded Content-Length or reject ambiguous framing."""
230
+ values = None
231
+ get_all = getattr(self.headers, "get_all", None)
232
+ if get_all is not None:
233
+ values = get_all("Content-Length")
234
+ if values is None:
235
+ raw = self.headers.get("Content-Length")
236
+ values = [] if raw is None else [raw]
237
+ if len(values) > 1:
238
+ raise _RequestBodyError(HTTPStatus.BAD_REQUEST, "Exactly one Content-Length header is required")
239
+
240
+ transfer_encoding = self.headers.get("Transfer-Encoding", "")
241
+ if transfer_encoding.strip():
242
+ raise _RequestBodyError(
243
+ HTTPStatus.BAD_REQUEST,
244
+ "Transfer-Encoding is unsupported; use a single Content-Length header",
245
+ )
246
+ if not values:
247
+ return 0
248
+
249
+ raw = str(values[0]).strip()
250
+ if not raw or not re.fullmatch(r"[0-9]+", raw):
251
+ raise _RequestBodyError(
252
+ HTTPStatus.BAD_REQUEST,
253
+ "Content-Length must be a non-negative decimal integer",
254
+ )
255
+ digits = raw.lstrip("0") or "0"
256
+ maximum = str(_MAX_REQUEST_BODY_BYTES)
257
+ if len(digits) > len(maximum) or (len(digits) == len(maximum) and digits > maximum):
258
+ raise _RequestBodyError(
259
+ HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
260
+ f"Request body exceeds the {_MAX_REQUEST_BODY_BYTES}-byte limit",
261
+ )
262
+ return int(digits)
263
+
264
+ def _read_request_body(self, length: int, *, retain: bool = True) -> bytes:
265
+ """Read exactly ``length`` bytes with a bounded per-read idle timeout."""
266
+ if length <= 0:
267
+ return b""
268
+
269
+ connection = getattr(self, "connection", None)
270
+ original_timeout = None
271
+ if connection is not None:
272
+ original_timeout = connection.gettimeout()
273
+ connection.settimeout(_REQUEST_BODY_IDLE_TIMEOUT)
274
+ chunks: list[bytes] = []
275
+ remaining = length
276
+ try:
277
+ while remaining:
278
+ try:
279
+ chunk = self.rfile.read(min(remaining, _MAX_BODY_READ_CHUNK))
280
+ except (TimeoutError, OSError) as error:
281
+ raise _RequestBodyError(
282
+ HTTPStatus.BAD_REQUEST,
283
+ "Request body was incomplete or idle before its Content-Length was satisfied",
284
+ ) from error
285
+ if not chunk:
286
+ raise _RequestBodyError(
287
+ HTTPStatus.BAD_REQUEST,
288
+ "Request body was shorter than its Content-Length",
289
+ )
290
+ remaining -= len(chunk)
291
+ if retain:
292
+ chunks.append(chunk)
293
+ finally:
294
+ if connection is not None:
295
+ connection.settimeout(original_timeout)
296
+ return b"".join(chunks) if retain else b""
297
+
298
+ def _discard_request_body(self, length: int | None = None) -> None:
299
+ """Drain a bounded rejected body and close if bytes remain unsynchronized."""
300
+ if length is None:
301
+ try:
302
+ length = self._parse_content_length()
303
+ except _RequestBodyError:
304
+ return
305
+ if length <= 0:
306
+ return
307
+ drain_length = min(length, _MAX_REJECTED_BODY_DRAIN)
308
+ if drain_length < length:
309
+ self.close_connection = True
310
+ self._read_request_body(drain_length, retain=False)
311
+
312
+ def _read_json_body(self) -> dict[str, Any]:
313
+ body = getattr(self, "_request_body", None)
314
+ if body is None:
315
+ body = self._read_request_body(self._parse_content_length())
316
+ if not body:
317
+ return {}
318
+ try:
319
+ val = json.loads(body.decode("utf-8"))
320
+ return val if isinstance(val, dict) else {}
321
+ except Exception:
322
+ return {}
323
+
324
+ def _send_asset(self, filename: str, content_type: str) -> None:
325
+ try:
326
+ body = (_ASSET_DIR / filename).read_bytes()
327
+ except OSError:
328
+ self._send_response(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"Asset not found\n")
329
+ return
330
+ self._send_response(HTTPStatus.OK, content_type, body)
331
+
332
+ def _handle_status(self) -> None:
333
+ workspace = self.server_inst.workspace
334
+ branch = "main"
335
+ try:
336
+ res = subprocess.run(
337
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
338
+ cwd=workspace,
339
+ capture_output=True,
340
+ text=True,
341
+ check=False,
342
+ )
343
+ if res.returncode == 0 and res.stdout.strip():
344
+ branch = res.stdout.strip()
345
+ except Exception:
346
+ pass
347
+
348
+ # Check server endpoints with loading / ready distinction
349
+ ollama_online, ollama_status = self._probe_server_status("http://127.0.0.1:11434/api/tags")
350
+ llama_online, llama_status = self._probe_server_status("http://127.0.0.1:8080/v1/models")
351
+
352
+ # 1. First priority: real hardware readings from nvidia-smi. Missing
353
+ # telemetry is an unknown, never a synthetic 16 GiB "System GPU".
354
+ gpu_telemetry = get_nvidia_gpu_telemetry()
355
+ vram_info = gpu_telemetry or {
356
+ "status": "unavailable",
357
+ "source": "nvidia-smi",
358
+ "error": "GPU telemetry is unavailable on this host",
359
+ "gpu_name": None,
360
+ "used_mb": None,
361
+ "total_mb": None,
362
+ "used_gb": None,
363
+ "total_gb": None,
364
+ "percent": None,
365
+ "utilization_pct": None,
366
+ "temp_c": None,
367
+ }
368
+
369
+ # Ollama can confirm loaded-model bytes, but /api/ps is not a GPU
370
+ # capacity source. Keep this partial observation separate from VRAM
371
+ # capacity and percentage fields.
372
+ if not gpu_telemetry and ollama_online:
373
+ try:
374
+ client = build_client(get_profile(self.server_inst.default_profile))
375
+ snap = ModelMemoryManager(client).snapshot()
376
+ if snap.is_supported:
377
+ vram_info["loaded_models"] = [
378
+ {
379
+ "name": model.name,
380
+ "size_vram": model.size_vram,
381
+ "size": model.size,
382
+ "expires_at": model.expires_at,
383
+ }
384
+ for model in snap.models
385
+ ]
386
+ vram_info["loaded_vram_bytes"] = snap.total_vram_bytes
387
+ vram_info["loaded_vram_source"] = "ollama:/api/ps"
388
+ except Exception:
389
+ pass
390
+
391
+ task_counts = {"queued": 0, "running": 0}
392
+ for record in self.server_inst.load_tasks():
393
+ status = record.get("status")
394
+ if status in task_counts:
395
+ task_counts[status] += 1
396
+
397
+ payload = {
398
+ "status": "healthy",
399
+ "uptime_seconds": round(time.monotonic() - self.server_inst.started_at, 2),
400
+ "workspace": str(Path(workspace).resolve()),
401
+ "workspace_name": Path(workspace).resolve().name,
402
+ "git_branch": branch,
403
+ "profile": self.server_inst.default_profile,
404
+ "servers": {
405
+ "ollama": {"online": ollama_online, "status": ollama_status, "endpoint": "http://127.0.0.1:11434"},
406
+ "llama_server": {
407
+ "online": llama_online,
408
+ "status": llama_status,
409
+ "endpoint": "http://127.0.0.1:8080",
410
+ "requested_ctx": self.server_inst.llama_num_ctx,
411
+ "effective_ctx": self.server_inst.llama_effective_ctx,
412
+ },
413
+ },
414
+ "vram": vram_info,
415
+ "stats": self.server_inst.stats.snapshot() if self.server_inst.stats else {},
416
+ "tasks": task_counts,
417
+ }
418
+ self._send_json(payload)
419
+
420
+ def _handle_gpu_telemetry(self) -> None:
421
+ gpu = get_nvidia_gpu_telemetry()
422
+ if gpu and gpu.get("status") == "ok":
423
+ self._send_json({"status": "ok", "gpu": gpu})
424
+ else:
425
+ self._send_json({
426
+ "status": gpu.get("status", "unavailable") if gpu else "unavailable",
427
+ "message": gpu.get("error", "nvidia-smi telemetry not available on this host") if gpu else "nvidia-smi telemetry not available on this host",
428
+ })
429
+
430
+ def _handle_models(self) -> None:
431
+ profiles_data = []
432
+ for name in list_profiles():
433
+ prof = get_profile(name)
434
+ profiles_data.append({
435
+ "name": name,
436
+ "model": prof.model,
437
+ "provider": prof.provider,
438
+ "endpoint": prof.endpoint,
439
+ "num_ctx": prof.num_ctx,
440
+ "policy_context_cap": prof.max_context_length,
441
+ "model_context_limit": prof.model_context_limit,
442
+ })
443
+
444
+ ollama_online, _ = self._probe_server_status("http://127.0.0.1:11434/api/tags")
445
+ ollama_models = discover_local_ollama_models()
446
+
447
+ llama_online, _ = self._probe_server_status("http://127.0.0.1:8080/v1/models")
448
+ llama_models = self._list_llama_model_ids() if llama_online else []
449
+
450
+ from ...model_scanner import get_model_registry
451
+ discovered_ggufs = [m.to_dict() for m in get_model_registry().get_models(auto_scan=True)]
452
+
453
+ self._send_json({
454
+ "profiles": profiles_data,
455
+ "active_profile": self.server_inst.default_profile,
456
+ "backends": {
457
+ "ollama": {"online": ollama_online, "endpoint": "http://127.0.0.1:11434", "models": ollama_models},
458
+ "llama_server": {"online": llama_online, "endpoint": "http://127.0.0.1:8080", "models": llama_models},
459
+ "local_gguf": {"models": discovered_ggufs},
460
+ },
461
+ })
462
+
463
+ def _handle_model_scan(self) -> None:
464
+ from ...model_scanner import get_model_registry
465
+ data = self._read_json_body()
466
+ deep = bool(data.get("deep", False))
467
+ registry = get_model_registry()
468
+ models = registry.scan(deep=deep)
469
+ self._send_json({"status": "ok", "total_models": len(models), "models": [m.to_dict() for m in models]})
470
+
471
+ def _handle_model_add_dir(self) -> None:
472
+ from ...model_scanner import get_model_registry
473
+ data = self._read_json_body()
474
+ path_val = data.get("path", "").strip()
475
+ if not path_val or not Path(path_val).is_dir():
476
+ self._send_json({"status": "failed", "error": f"Directory does not exist: {path_val}"})
477
+ return
478
+ added = get_model_registry().add_custom_directory(path_val)
479
+ self._send_json({"status": "added" if added else "already_present", "path": path_val})
480
+
481
+ def _handle_model_remove_dir(self) -> None:
482
+ from ...model_scanner import get_model_registry
483
+ data = self._read_json_body()
484
+ path_val = data.get("path", "").strip()
485
+ removed = get_model_registry().remove_custom_directory(path_val)
486
+ self._send_json({"status": "removed" if removed else "not_found", "path": path_val})
487
+
488
+ def _handle_workspace_files(self) -> None:
489
+ workspace = Path(self.server_inst.workspace)
490
+ files = []
491
+ for p in workspace.rglob("*"):
492
+ if p.is_file() and not any(part.startswith(".") or part in ("__pycache__", "venv", ".git", "build", "dist", "node_modules") for part in p.parts):
493
+ rel = str(p.relative_to(workspace).as_posix())
494
+ files.append({
495
+ "path": rel,
496
+ "name": p.name,
497
+ "size_bytes": p.stat().st_size,
498
+ "is_code": p.suffix in (".py", ".ts", ".js", ".go", ".rs", ".json", ".md"),
499
+ })
500
+ files.sort(key=lambda x: (not x["is_code"], x["path"]))
501
+ self._send_json({"workspace": str(workspace), "files": files[:80]})
502
+
503
+ def _handle_sessions(self) -> None:
504
+ try:
505
+ self._send_json({"sessions": self.server_inst.load_sessions()})
506
+ except Exception as error:
507
+ self._send_json({"status": "failed", "sessions": [], "error": str(error)})
508
+
509
+ def _handle_create_session(self) -> None:
510
+ data = self._read_json_body()
511
+ session_id = data.get("id") or f"sess-{uuid.uuid4().hex[:12]}"
512
+ session_type = data.get("type", "user")
513
+ title = data.get("title", "New Task Session")
514
+ file_path = data.get("file", "src/main.py")
515
+ patch = data.get("patch", "")
516
+ checks = data.get("checks", [])
517
+
518
+ session = {
519
+ "id": session_id,
520
+ "type": session_type,
521
+ "title": title,
522
+ "file": file_path,
523
+ "patch": patch,
524
+ "checks": checks,
525
+ "status": data.get("status", "Active"),
526
+ "evidence_verified": False,
527
+ "time": "Just now",
528
+ }
529
+ for key in (
530
+ "prompt",
531
+ "profile",
532
+ "message",
533
+ "thinking",
534
+ "testResult",
535
+ "plan",
536
+ "test_evidence",
537
+ "files",
538
+ ):
539
+ if key in data:
540
+ session[key] = data[key]
541
+ try:
542
+ self.server_inst.save_session(session)
543
+ except Exception as error:
544
+ self._send_json({"status": "failed", "persisted": False, "error": str(error)})
545
+ return
546
+ self._send_json({"status": "created", "persisted": True, "session": session})
547
+
548
+ def _save_chat_session(
549
+ self,
550
+ response: dict[str, Any],
551
+ task_checks: list[str],
552
+ *,
553
+ status: str,
554
+ evidence_verified: bool = False,
555
+ ) -> None:
556
+ prompt = str(response.get("prompt") or "")
557
+ session = {
558
+ "id": response.get("task_id") or f"sess-{uuid.uuid4().hex[:12]}",
559
+ "type": "user",
560
+ "mode": response.get("mode", "chat"),
561
+ "title": prompt[:50] + ("..." if len(prompt) > 50 else ""),
562
+ "prompt": prompt,
563
+ "profile": response.get("profile", self.server_inst.default_profile),
564
+ "file": response.get("file", "workspace"),
565
+ "files": response.get("files") or [],
566
+ "patch": response.get("patch", ""),
567
+ "checks": task_checks,
568
+ "test_evidence": response.get("checks") or [],
569
+ "status": status,
570
+ "evidence_verified": evidence_verified,
571
+ "time": "Just now",
572
+ "message": response.get("message", ""),
573
+ "thinking": response.get("thinking", ""),
574
+ "testResult": response.get("testResult", "NOT RUN"),
575
+ }
576
+ if response.get("plan") is not None:
577
+ session["plan"] = response["plan"]
578
+ try:
579
+ self.server_inst.save_session(session)
580
+ response["persisted"] = True
581
+ except Exception as error:
582
+ response["persisted"] = False
583
+ response["persistence_error"] = str(error)
584
+
585
+ def _server_log_file(self, backend: str) -> Path:
586
+ log_dir = Path(self.server_inst.workspace) / ".local_agent" / "logs"
587
+ log_dir.mkdir(parents=True, exist_ok=True)
588
+ return log_dir / f"{backend}.log"
589
+
590
+ def _read_log_tail(self, backend: str, n: int = 40) -> str:
591
+ log_file = self._server_log_file(backend)
592
+ if not log_file.exists():
593
+ return ""
594
+ try:
595
+ with open(log_file, "r", encoding="utf-8", errors="replace") as fh:
596
+ lines = fh.readlines()
597
+ return "".join(lines[-n:])
598
+ except Exception:
599
+ return ""
600
+
601
+ def _wait_for_ready(self, url: str, proc: subprocess.Popen, backend: str, timeout: float = 30.0) -> dict:
602
+ deadline = time.monotonic() + timeout
603
+ while time.monotonic() < deadline:
604
+ online, status = self._probe_server_status(url)
605
+ if online and status == "ready":
606
+ return {"ok": True, "status": "started"}
607
+ if proc.poll() is not None:
608
+ return {
609
+ "ok": False,
610
+ "status": "failed",
611
+ "error": f"Server exited during startup (code {proc.returncode}). Log tail:\n{self._read_log_tail(backend)}",
612
+ }
613
+ time.sleep(0.5)
614
+
615
+ if proc.poll() is not None:
616
+ return {
617
+ "ok": False,
618
+ "status": "failed",
619
+ "error": f"Server exited during startup (code {proc.returncode}). Log tail:\n{self._read_log_tail(backend)}",
620
+ }
621
+ return {
622
+ "ok": False,
623
+ "status": "loading",
624
+ "message": "server started but not ready yet",
625
+ }
626
+
627
+ def _handle_server_start(self) -> None:
628
+ data = self._read_json_body()
629
+ backend = data.get("backend", "ollama")
630
+ custom_path = data.get("custom_path")
631
+ model_path = data.get("model_path")
632
+
633
+ if backend == "ollama":
634
+ ollama_bin = self._find_ollama_bin()
635
+ if not ollama_bin:
636
+ self._send_json({"status": "failed", "error": "Ollama executable not found. Please install Ollama from ollama.com"})
637
+ return
638
+ try:
639
+ log_handle = open(self._server_log_file("ollama"), "ab")
640
+ proc = subprocess.Popen(
641
+ [ollama_bin, "serve"],
642
+ stdout=log_handle,
643
+ stderr=log_handle,
644
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
645
+ )
646
+ log_handle.close()
647
+ self.server_inst.spawned_processes["ollama"] = proc
648
+ result = self._wait_for_ready("http://127.0.0.1:11434/api/tags", proc, "ollama")
649
+ if result.get("status") == "started":
650
+ self._send_json({"status": "started", "backend": "ollama", "pid": proc.pid})
651
+ else:
652
+ self._send_json(result)
653
+ except Exception as error:
654
+ self._send_json({"status": "failed", "error": f"{error}\nLog tail:\n{self._read_log_tail('ollama')}"})
655
+
656
+ elif backend in ("llama_server", "llama.cpp"):
657
+ if self._probe_models_loaded("http://127.0.0.1:8080/v1/models"):
658
+ self._send_json({
659
+ "status": "already_running",
660
+ "backend": "llama_server",
661
+ "endpoint": "http://127.0.0.1:8080",
662
+ })
663
+ return
664
+
665
+ llama_bin = self._find_llama_server_bin(custom_path)
666
+ if not llama_bin:
667
+ self._send_json({
668
+ "status": "failed",
669
+ "error": (
670
+ "llama-server executable not found in system PATH. "
671
+ "Add your llama-server directory to PATH or set LLAMA_SERVER_PATH."
672
+ ),
673
+ })
674
+ return
675
+
676
+ self._kill_llama_on_port(8080)
677
+ gguf_path = self._find_gguf_model(model_path)
678
+ cmd = [llama_bin, "--port", "8080"]
679
+ if gguf_path:
680
+ alias = Path(gguf_path).stem
681
+ cmd.extend(["-m", gguf_path, "-c", str(self.server_inst.llama_num_ctx), "--alias", alias])
682
+ self.server_inst.llama_gguf_path = gguf_path
683
+ self.server_inst.llama_gguf_label = alias
684
+
685
+ try:
686
+ log_handle = open(self._server_log_file("llama_server"), "ab")
687
+ proc = subprocess.Popen(
688
+ cmd,
689
+ stdout=log_handle,
690
+ stderr=log_handle,
691
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
692
+ )
693
+ log_handle.close()
694
+ self.server_inst.spawned_processes["llama_server"] = proc
695
+ if gguf_path:
696
+ result = self._wait_for_model_loaded(proc, "llama_server")
697
+ else:
698
+ result = self._wait_for_ready("http://127.0.0.1:8080/v1/models", proc, "llama_server")
699
+ if result.get("status") == "started":
700
+ self._send_json({
701
+ "status": "started",
702
+ "backend": "llama_server",
703
+ "pid": proc.pid,
704
+ "bin": llama_bin,
705
+ "model": gguf_path or "unspecified",
706
+ })
707
+ else:
708
+ self._send_json(result)
709
+ except Exception as error:
710
+ self._send_json({"status": "failed", "error": f"{error}\nLog tail:\n{self._read_log_tail('llama_server')}"})
711
+ else:
712
+ self._send_json({"status": "failed", "error": f"Unknown backend: {backend}"})
713
+
714
+ def _find_llama_server_bin(self, custom: str | None = None) -> str | None:
715
+ return discover_llama_server_binary(custom)
716
+
717
+ def _find_ollama_bin(self) -> str | None:
718
+ live_path = get_live_system_path()
719
+ ollama_bin = shutil.which("ollama", path=live_path) or shutil.which("ollama.exe", path=live_path)
720
+ if not ollama_bin:
721
+ appdata_ollama = Path(os.path.expandvars(r"%LOCALAPPDATA%\Programs\Ollama\ollama.exe"))
722
+ if appdata_ollama.exists():
723
+ ollama_bin = str(appdata_ollama)
724
+ return ollama_bin
725
+
726
+ def _ensure_ollama_running(self) -> bool:
727
+ """Start ollama serve if offline; return True when the API is reachable."""
728
+ online, _ = self._probe_server_status("http://127.0.0.1:11434/api/tags")
729
+ if online:
730
+ return True
731
+ ollama_bin = self._find_ollama_bin()
732
+ if not ollama_bin:
733
+ return False
734
+ try:
735
+ log_handle = open(self._server_log_file("ollama"), "ab")
736
+ proc = subprocess.Popen(
737
+ [ollama_bin, "serve"],
738
+ stdout=log_handle,
739
+ stderr=log_handle,
740
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
741
+ )
742
+ log_handle.close()
743
+ self.server_inst.spawned_processes["ollama"] = proc
744
+ except Exception:
745
+ return False
746
+ result = self._wait_for_ready("http://127.0.0.1:11434/api/tags", proc, "ollama")
747
+ return result.get("status") == "started"
748
+
749
+ def _find_gguf_model(self, custom: str | None = None) -> str | None:
750
+ if custom and custom.strip() and Path(custom.strip()).is_file():
751
+ return str(Path(custom.strip()).resolve())
752
+
753
+ env_model = os.environ.get("LLAMA_MODEL_PATH") or os.environ.get("GGUF_MODEL_PATH")
754
+ if env_model and Path(env_model.strip()).is_file():
755
+ return str(Path(env_model.strip()).resolve())
756
+
757
+ return None
758
+
759
+ def _handle_server_stop(self) -> None:
760
+ data = self._read_json_body()
761
+ backend = data.get("backend", "all")
762
+ stopped = []
763
+
764
+ for name in list(self.server_inst.spawned_processes):
765
+ if backend in (name, "all"):
766
+ self._stop_backend(name)
767
+ stopped.append(name)
768
+
769
+ self._send_json({"status": "stopped", "backends": stopped})
770
+
771
+ def _auto_fit_llama_ctx(self, gguf: dict) -> int | None:
772
+ """Fit llama-server ctx from GGUF metadata + free VRAM for num_ctx=auto.
773
+
774
+ Best-effort: never raises; leaves ``llama_num_ctx`` untouched when the
775
+ metadata, exact model size, or VRAM telemetry is unavailable. A
776
+ numeric context is applied only for an explicit ``fits`` result.
777
+ """
778
+ params = read_gguf_ctx_params(gguf["path"])
779
+ gpu = get_nvidia_gpu_telemetry()
780
+ if not gpu or not isinstance(gpu.get("total_mb"), (int, float)) or not isinstance(gpu.get("used_mb"), (int, float)):
781
+ return None
782
+ total_mb = float(gpu["total_mb"])
783
+ used_mb = float(gpu["used_mb"])
784
+ if total_mb <= 0 or used_mb < 0 or used_mb > total_mb:
785
+ return None
786
+ try:
787
+ weights_bytes = Path(gguf["path"]).stat().st_size
788
+ except (KeyError, OSError, TypeError):
789
+ return None
790
+ kv = (
791
+ kv_bytes_per_token(
792
+ params["n_layers"], params["n_head_kv"], params["head_dim"]
793
+ )
794
+ if params.get("n_layers") and params.get("n_head_kv") and params.get("head_dim")
795
+ else 0
796
+ )
797
+ native = params.get("native_context_length")
798
+ if not native:
799
+ return None
800
+ result = fit_context(
801
+ int((total_mb - used_mb) * 1024 * 1024),
802
+ weights_bytes,
803
+ kv,
804
+ max_ctx=native,
805
+ )
806
+ if result.status == "fits" and result.context is not None:
807
+ return result.context
808
+ return None
809
+
810
+ def _handle_model_load(self) -> None:
811
+ data = self._read_json_body()
812
+ model_name = data.get("model") or self.server_inst.default_profile
813
+ requested_ctx = data.get("num_ctx")
814
+ requested_llama_ctx = None
815
+ if requested_ctx is not None and requested_ctx != "auto":
816
+ ctx, ctx_error = self._parse_ctx_override(requested_ctx)
817
+ if ctx_error:
818
+ self._send_json({"status": "failed", "error": ctx_error}, HTTPStatus.BAD_REQUEST)
819
+ return
820
+ requested_llama_ctx = ctx
821
+ try:
822
+ gguf = find_discovered_gguf(model_name)
823
+ if gguf and gguf.get("path"):
824
+ launch_ctx = requested_llama_ctx
825
+ if requested_ctx == "auto":
826
+ auto_ctx = self._auto_fit_llama_ctx(gguf)
827
+ # Evaluate auto for the new model. If fit evidence is
828
+ # unavailable, use the documented launch default rather
829
+ # than inheriting a previous model's context.
830
+ launch_ctx = auto_ctx or _DEFAULT_LLAMA_CTX
831
+ result = self._launch_llama_model(
832
+ gguf["path"],
833
+ gguf.get("display_name") or gguf.get("name") or model_name,
834
+ num_ctx=launch_ctx,
835
+ )
836
+ if result.get("status") == "started":
837
+ try:
838
+ warmup = ModelProfile(
839
+ name=gguf.get("display_name") or model_name,
840
+ model=gguf.get("display_name") or model_name,
841
+ provider="openai",
842
+ endpoint="http://127.0.0.1:8080",
843
+ num_ctx=self.server_inst.llama_num_ctx,
844
+ )
845
+ build_client(warmup).complete("warmup", system="warmup", max_tokens=1)
846
+ except Exception:
847
+ pass
848
+ self._send_json({
849
+ "status": "loaded",
850
+ "model": model_name,
851
+ "backend": "llama_server",
852
+ "path": gguf["path"],
853
+ "effective_ctx": self.server_inst.llama_effective_ctx,
854
+ **({"ctx_warning": result["ctx_warning"]} if result.get("ctx_warning") else {}),
855
+ })
856
+ else:
857
+ self._send_json(result)
858
+ return
859
+
860
+ prof = resolve_model_profile(model_name)
861
+ client = build_client(prof)
862
+ if prof.provider == "ollama":
863
+ if not self._ensure_ollama_running():
864
+ self._send_json({"status": "failed", "error": "Ollama (port 11434) could not be started. Start it manually or check PATH."})
865
+ return
866
+ if hasattr(client, "_request_json"):
867
+ try:
868
+ client._request_json("POST", "/api/generate", {"model": prof.model, "prompt": "", "keep_alive": "10m"})
869
+ except OllamaError as oe:
870
+ if "not found" in str(oe).lower():
871
+ base = prof.model.split(":", 1)[0]
872
+ candidates = [prof.model, f"{base}:latest", base]
873
+ avail = set()
874
+ try:
875
+ for m in client.available_models().get("models", []):
876
+ if isinstance(m, dict) and "name" in m:
877
+ avail.add(m["name"])
878
+ except Exception:
879
+ avail = set(discover_local_ollama_models())
880
+ alt = next((c for c in candidates if c in avail), None)
881
+ if alt is None:
882
+ raise
883
+ client._request_json("POST", "/api/generate", {"model": alt, "prompt": "", "keep_alive": "10m"})
884
+ else:
885
+ raise
886
+ else:
887
+ client.complete("warmup", system="warmup", max_tokens=1)
888
+ elif prof.provider == "openai":
889
+ if hasattr(client, "complete"):
890
+ client.complete("warmup", system="warmup", max_tokens=1)
891
+ elif hasattr(client, "chat"):
892
+ client.chat([{"role": "user", "content": "warmup"}])
893
+ else:
894
+ if hasattr(client, "complete"):
895
+ client.complete("warmup", system="warmup", max_tokens=1)
896
+ self._send_json({"status": "loaded", "model": model_name, "backend": prof.provider})
897
+ except Exception as error:
898
+ kind = _classify_backend_error(error)
899
+ if kind == "offline":
900
+ is_llama = prof.provider == "openai"
901
+ backend_name = "llama-server (port 8080)" if is_llama else "Ollama (port 11434)"
902
+ self._send_json({"status": "failed", "error": f"{backend_name} is OFFLINE. Start it in Local Inference Servers."})
903
+ else:
904
+ self._send_json({"status": "failed", "error": str(error)})
905
+
906
+ def _launch_llama_model(self, gguf_path: str, model_label: str, num_ctx: int | None = None) -> dict:
907
+ """Launch llama-server with a specific GGUF file (single-model server).
908
+
909
+ Uses ``--alias`` so the model id exposed by ``/v1/models`` is the clean
910
+ stem (e.g. ``Ling-3.0-tiny-Q6_K``) rather than the on-disk filename.
911
+ ``-ngl`` is intentionally omitted: forcing ``-ngl 99`` aborts the load
912
+ when free VRAM cannot fit every layer, whereas the default auto-fit
913
+ spills only the overflow to CPU.
914
+
915
+ Resource preflight (P1): before stopping the running server, the
916
+ requested context is clamped to what the GGUF geometry + free VRAM
917
+ actually fit, so a typo'd 200k window cannot wedge the machine.
918
+ On a failed relaunch the previous configuration is relaunched once so
919
+ the user keeps a working server.
920
+ """
921
+ previous_ctx = self.server_inst.llama_num_ctx
922
+ previous_path = self.server_inst.llama_gguf_path
923
+ previous_label = self.server_inst.llama_gguf_label
924
+ effective_ctx = num_ctx
925
+ ctx_warning = ""
926
+ if num_ctx is not None:
927
+ requested = max(512, int(num_ctx))
928
+ fit = self._preflight_context_fit(gguf_path)
929
+ if fit.status == "does_not_fit":
930
+ return {
931
+ "status": "failed",
932
+ "error": fit.reason or "Requested context cannot fit the verified model and VRAM resources.",
933
+ }
934
+ if fit.status == "fits" and fit.context is not None and fit.context != requested:
935
+ ctx_warning = (
936
+ f"Requested context {requested} exceeds what free VRAM fits next to "
937
+ f"the model weights; clamped to {fit.context} tokens."
938
+ )
939
+ effective_ctx = fit.context
940
+ llama_bin = self._find_llama_server_bin(None)
941
+ if not llama_bin:
942
+ return {
943
+ "status": "failed",
944
+ "error": (
945
+ "llama-server executable not found in PATH. "
946
+ "Add your llama-server directory to PATH or set LLAMA_SERVER_PATH."
947
+ ),
948
+ }
949
+ if effective_ctx is not None:
950
+ self.server_inst.llama_num_ctx = max(512, int(effective_ctx))
951
+ self._stop_backend("llama_server")
952
+ self._kill_llama_on_port(8080)
953
+ cmd = [
954
+ llama_bin, "--port", "8080", "-m", gguf_path,
955
+ "-c", str(self.server_inst.llama_num_ctx), "--alias", model_label,
956
+ ]
957
+ try:
958
+ log_handle = open(self._server_log_file("llama_server"), "ab")
959
+ proc = subprocess.Popen(
960
+ cmd,
961
+ stdout=log_handle,
962
+ stderr=log_handle,
963
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
964
+ )
965
+ log_handle.close()
966
+ self.server_inst.spawned_processes["llama_server"] = proc
967
+ self.server_inst.llama_gguf_path = gguf_path
968
+ self.server_inst.llama_gguf_label = model_label
969
+ result = self._wait_for_model_loaded(proc, "llama_server")
970
+ if result.get("status") == "started":
971
+ effective = self._read_effective_ctx()
972
+ self.server_inst.llama_effective_ctx = effective
973
+ if effective is not None and effective != self.server_inst.llama_num_ctx:
974
+ readback = (
975
+ f"Requested context {self.server_inst.llama_num_ctx} via -c "
976
+ f"but llama-server reports n_ctx={effective}; the window was "
977
+ "likely clamped to the model's native context length."
978
+ )
979
+ ctx_warning = f"{ctx_warning} {readback}".strip() if ctx_warning else readback
980
+ result.update({"backend": "llama_server", "pid": proc.pid, "model": model_label})
981
+ if ctx_warning:
982
+ result["ctx_warning"] = ctx_warning
983
+ return result
984
+ failure = result
985
+ except Exception as error:
986
+ failure = {"status": "failed", "error": f"{error}\nLog tail:\n{self._read_log_tail('llama_server')}"}
987
+
988
+ # Relaunch failed: restore the previous working configuration once so
989
+ # the risky reload never leaves the machine without a server.
990
+ self.server_inst.llama_num_ctx = previous_ctx
991
+ self.server_inst.llama_gguf_path = previous_path
992
+ self.server_inst.llama_gguf_label = previous_label
993
+ if previous_path:
994
+ restore = self._launch_llama_model(previous_path, previous_label or Path(previous_path).stem, num_ctx=previous_ctx)
995
+ if restore.get("status") == "started":
996
+ failure["restored"] = f"Previous configuration restored ({Path(previous_path).name}, -c {previous_ctx})."
997
+ else:
998
+ failure["restore_error"] = restore.get("error", "unknown error")
999
+ return failure
1000
+
1001
+ def _preflight_context_fit(self, gguf_path: str) -> ContextFit:
1002
+ """Return an explicit fit state before stopping a running backend."""
1003
+ try:
1004
+ params = read_gguf_ctx_params(gguf_path)
1005
+ n_layers = params.get("n_layers")
1006
+ n_head_kv = params.get("n_head_kv")
1007
+ head_dim = params.get("head_dim")
1008
+ if not (n_layers and n_head_kv and head_dim):
1009
+ return ContextFit("unknown", reason="GGUF KV-cache geometry is unavailable")
1010
+ native = params.get("native_context_length")
1011
+ if not native:
1012
+ return ContextFit("unknown", reason="GGUF native context length is unavailable")
1013
+ gpu = get_nvidia_gpu_telemetry()
1014
+ if not gpu or not isinstance(gpu.get("total_mb"), (int, float)) or not isinstance(gpu.get("used_mb"), (int, float)):
1015
+ return ContextFit("unknown", reason="GPU VRAM telemetry is unavailable")
1016
+ total_mb = float(gpu["total_mb"])
1017
+ used_mb = float(gpu["used_mb"])
1018
+ if total_mb <= 0 or used_mb < 0 or used_mb > total_mb:
1019
+ return ContextFit("unknown", reason="GPU VRAM telemetry is invalid")
1020
+ try:
1021
+ weights_bytes = Path(gguf_path).stat().st_size
1022
+ except OSError:
1023
+ return ContextFit("unknown", reason="GGUF weight size is unavailable")
1024
+ kv = kv_bytes_per_token(n_layers, n_head_kv, head_dim)
1025
+ return fit_context(
1026
+ int((total_mb - used_mb) * 1024 * 1024),
1027
+ weights_bytes,
1028
+ kv,
1029
+ max_ctx=native,
1030
+ )
1031
+ except Exception:
1032
+ return ContextFit("unknown", reason="Context fit could not be verified")
1033
+
1034
+ def _preflight_ctx_fit(self, gguf_path: str, requested: int) -> int | None:
1035
+ """Backward-compatible numeric view of the explicit fit result."""
1036
+ result = self._preflight_context_fit(gguf_path)
1037
+ if result.status == "fits" and result.context is not None and requested > result.context:
1038
+ return result.context
1039
+ return None
1040
+
1041
+ def _wait_for_model_loaded(self, proc: subprocess.Popen, backend: str, timeout: float = 90.0) -> dict:
1042
+ """Wait until llama-server actually exposes a loaded model in /v1/models."""
1043
+ deadline = time.monotonic() + timeout
1044
+ while time.monotonic() < deadline:
1045
+ if self._probe_models_loaded("http://127.0.0.1:8080/v1/models"):
1046
+ return {"ok": True, "status": "started"}
1047
+ if proc.poll() is not None:
1048
+ return {
1049
+ "ok": False,
1050
+ "status": "failed",
1051
+ "error": f"Server exited during startup (code {proc.returncode}). Log tail:\n{self._read_log_tail(backend)}",
1052
+ }
1053
+ time.sleep(1.0)
1054
+ if proc.poll() is not None:
1055
+ return {
1056
+ "ok": False,
1057
+ "status": "failed",
1058
+ "error": f"Server exited during startup (code {proc.returncode}). Log tail:\n{self._read_log_tail(backend)}",
1059
+ }
1060
+ return {"ok": False, "status": "loading", "message": "server started but model not loaded yet"}
1061
+
1062
+ def _probe_models_loaded(self, url: str) -> bool:
1063
+ """Return True only when the backend lists at least one loaded model."""
1064
+ try:
1065
+ req = urllib.request.Request(url)
1066
+ with urllib.request.urlopen(req, timeout=0.5) as resp:
1067
+ if resp.status != 200:
1068
+ return False
1069
+ data = json.loads(resp.read().decode("utf-8"))
1070
+ except Exception:
1071
+ return False
1072
+ models = data.get("data") or data.get("models") or []
1073
+ return isinstance(models, list) and len(models) > 0
1074
+
1075
+ def _read_effective_ctx(self, port: int = 8080) -> int | None:
1076
+ """Read back the context window llama-server actually serves (/props).
1077
+
1078
+ llama-server may silently clamp ``-c`` to the model's native context
1079
+ length, so verify reality instead of trusting the launch flag. Returns
1080
+ None when /props is unavailable (older builds, external servers).
1081
+ """
1082
+ try:
1083
+ req = urllib.request.Request(f"http://127.0.0.1:{port}/props")
1084
+ with urllib.request.urlopen(req, timeout=2.0) as resp:
1085
+ if resp.status != 200:
1086
+ return None
1087
+ props = json.loads(resp.read().decode("utf-8"))
1088
+ n_ctx = (props.get("default_generation_settings") or {}).get("n_ctx")
1089
+ return int(n_ctx) if n_ctx else None
1090
+ except Exception:
1091
+ return None
1092
+
1093
+ def _list_llama_model_ids(self) -> list[str]:
1094
+ """Model ids currently served by llama-server /v1/models ([] if offline)."""
1095
+ try:
1096
+ req = urllib.request.Request("http://127.0.0.1:8080/v1/models")
1097
+ with urllib.request.urlopen(req, timeout=0.5) as resp:
1098
+ if resp.status != 200:
1099
+ return []
1100
+ data = json.loads(resp.read().decode("utf-8"))
1101
+ raw_list = data.get("data") or data.get("models") or []
1102
+ ids = []
1103
+ for m in raw_list:
1104
+ if isinstance(m, dict) and "id" in m:
1105
+ ids.append(m["id"])
1106
+ elif isinstance(m, dict) and "name" in m:
1107
+ ids.append(m["name"])
1108
+ return ids
1109
+ except Exception:
1110
+ return []
1111
+
1112
+ def _list_ollama_model_tags(self) -> list[str]:
1113
+ return discover_local_ollama_models()
1114
+
1115
+ def _resolve_v1_target(self, model_id: str) -> tuple[str, int] | None:
1116
+ """Map a requested model id to the local backend that serves it.
1117
+
1118
+ Resident llama-server wins when the id matches (or is unspecified);
1119
+ exact Ollama tag match otherwise; None means no local backend serves it.
1120
+ """
1121
+ if self._probe_server_status("http://127.0.0.1:8080/v1/models")[0]:
1122
+ llama_ids = self._list_llama_model_ids()
1123
+ if not model_id or model_id in llama_ids:
1124
+ return ("127.0.0.1", 8080)
1125
+ if model_id and model_id in self._list_ollama_model_tags():
1126
+ return ("127.0.0.1", 11434)
1127
+ return None
1128
+
1129
+ def _handle_v1_models(self) -> None:
1130
+ """OpenAI-compatible catalog: union of both local backends."""
1131
+ data = [{"id": mid, "object": "model", "owned_by": "llama-server"} for mid in self._list_llama_model_ids()]
1132
+ data.extend({"id": tag, "object": "model", "owned_by": "ollama"} for tag in self._list_ollama_model_tags())
1133
+ self._send_json({"object": "list", "data": data})
1134
+
1135
+ def _handle_v1_chat_completions(self) -> None:
1136
+ """OpenAI-compatible chat proxy to the active local backend.
1137
+
1138
+ Both Ollama and llama-server already speak OpenAI wire format natively,
1139
+ so this is a byte-level router: resolve the model id to a backend and
1140
+ stream the upstream response straight back without translation.
1141
+ """
1142
+ body = getattr(self, "_request_body", None)
1143
+ if body is None:
1144
+ body = self._read_request_body(self._parse_content_length()) or b"{}"
1145
+ try:
1146
+ payload = json.loads(body.decode("utf-8"))
1147
+ except (json.JSONDecodeError, UnicodeDecodeError):
1148
+ payload = {}
1149
+ model_id = str(payload.get("model") or "")
1150
+ target = self._resolve_v1_target(model_id)
1151
+ if target is None:
1152
+ available = sorted(set(self._list_llama_model_ids()) | set(self._list_ollama_model_tags()))
1153
+ self._send_json({
1154
+ "error": {
1155
+ "message": (
1156
+ f"Model '{model_id or '(unspecified)'}' is not served by any local backend. "
1157
+ f"Available models: {available}. Load one via the Desktop Harness "
1158
+ "(Local Inference Servers panel) and retry."
1159
+ ),
1160
+ "type": "model_not_found",
1161
+ "code": 404,
1162
+ }
1163
+ }, status=HTTPStatus.NOT_FOUND)
1164
+ return
1165
+ host, port = target
1166
+ try:
1167
+ conn = http.client.HTTPConnection(host, port, timeout=600)
1168
+ conn.request(
1169
+ "POST",
1170
+ "/v1/chat/completions",
1171
+ body=body,
1172
+ headers={"Content-Type": "application/json", "Accept": self.headers.get("Accept", "*/*")},
1173
+ )
1174
+ upstream = conn.getresponse()
1175
+ self.send_response(upstream.status)
1176
+ self.send_header("Content-Type", upstream.getheader("Content-Type", "application/json"))
1177
+ self.end_headers()
1178
+ while True:
1179
+ chunk = upstream.read(4096)
1180
+ if not chunk:
1181
+ break
1182
+ self.wfile.write(chunk)
1183
+ self.wfile.flush()
1184
+ conn.close()
1185
+ except Exception as error:
1186
+ # Headers may already be on the wire mid-stream; then the client
1187
+ # just sees a truncated body — nothing sane left to send.
1188
+ try:
1189
+ self._send_json({
1190
+ "error": {
1191
+ "message": f"Upstream {host}:{port} failed: {error}",
1192
+ "type": "upstream_error",
1193
+ "code": 502,
1194
+ }
1195
+ }, status=HTTPStatus.BAD_GATEWAY)
1196
+ except Exception:
1197
+ pass
1198
+
1199
+ def _find_pid_on_port(self, port: int) -> int | None:
1200
+ if os.name == "nt":
1201
+ try:
1202
+ out = subprocess.run(
1203
+ ["netstat", "-ano", "-p", "tcp"],
1204
+ capture_output=True,
1205
+ text=True,
1206
+ check=False,
1207
+ timeout=5,
1208
+ ).stdout
1209
+ for line in out.splitlines():
1210
+ parts = line.split()
1211
+ if len(parts) >= 5 and f":{port}" in parts[1] and parts[3].upper() == "LISTENING":
1212
+ try:
1213
+ return int(parts[4])
1214
+ except ValueError:
1215
+ continue
1216
+ except Exception:
1217
+ pass
1218
+ else:
1219
+ try:
1220
+ out = subprocess.run(
1221
+ ["lsof", "-ti", f":{port}"],
1222
+ capture_output=True,
1223
+ text=True,
1224
+ check=False,
1225
+ timeout=5,
1226
+ ).stdout
1227
+ pid = out.strip().splitlines()[0] if out.strip() else ""
1228
+ if pid.isdigit():
1229
+ return int(pid)
1230
+ except Exception:
1231
+ pass
1232
+ return None
1233
+
1234
+ def _looks_like_llama(self, pid: int) -> bool:
1235
+ if os.name == "nt":
1236
+ try:
1237
+ out = subprocess.run(
1238
+ ["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
1239
+ capture_output=True,
1240
+ text=True,
1241
+ check=False,
1242
+ timeout=5,
1243
+ ).stdout
1244
+ return "llama" in out.lower()
1245
+ except Exception:
1246
+ return False
1247
+ return True
1248
+
1249
+ def _kill_llama_on_port(self, port: int) -> None:
1250
+ """Stop only a backend owned by this server instance.
1251
+
1252
+ A port lookup cannot prove process ownership: another listener may
1253
+ share a name, expose a similar port (for example ``:80801``), or be a
1254
+ user's independently managed llama-server. The registered Popen
1255
+ handle is the only trustworthy ownership evidence, and normal launch
1256
+ cleanup already removes that handle before this compatibility hook is
1257
+ reached. External listeners are therefore left untouched; the next
1258
+ bind reports the real conflict to the caller.
1259
+ """
1260
+ del port # retained for callers of the old compatibility seam
1261
+ for name, proc in list(self.server_inst.spawned_processes.items()):
1262
+ if name == "llama_server" and proc is not None:
1263
+ self._stop_backend(name)
1264
+
1265
+ def _stop_backend(self, name: str) -> None:
1266
+ proc = self.server_inst.spawned_processes.pop(name, None)
1267
+ if proc is None:
1268
+ return
1269
+ try:
1270
+ if os.name == "nt":
1271
+ subprocess.run(
1272
+ ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
1273
+ capture_output=True,
1274
+ check=False,
1275
+ )
1276
+ else:
1277
+ proc.terminate()
1278
+ proc.wait(timeout=2.0)
1279
+ except Exception:
1280
+ try:
1281
+ proc.kill()
1282
+ except Exception:
1283
+ pass
1284
+
1285
+ def _handle_model_unload(self) -> None:
1286
+ data = self._read_json_body()
1287
+ model_name = data.get("model")
1288
+ if not model_name:
1289
+ self._send_json({"status": "failed", "error": "model name required"})
1290
+ return
1291
+ try:
1292
+ client = build_client(resolve_model_profile(self.server_inst.default_profile))
1293
+ manager = ModelMemoryManager(client)
1294
+ snap = manager.unload_model(model_name)
1295
+ self._send_json({"status": "unloaded", "model": model_name, "remaining": [m.name for m in snap.models]})
1296
+ except Exception as error:
1297
+ self._send_json({"status": "failed", "error": str(error)})
1298
+
1299
+ def _handle_model_unload_all(self) -> None:
1300
+ try:
1301
+ client = build_client(resolve_model_profile(self.server_inst.default_profile))
1302
+ manager = ModelMemoryManager(client)
1303
+ snap = manager.unload_all()
1304
+ stopped = []
1305
+ for name in list(self.server_inst.spawned_processes):
1306
+ if name == "llama_server":
1307
+ self._stop_backend(name)
1308
+ stopped.append(name)
1309
+ self.server_inst.llama_effective_ctx = None
1310
+ self.server_inst.llama_gguf_path = None
1311
+ self.server_inst.llama_gguf_label = None
1312
+ self._send_json({
1313
+ "status": "unloaded_all",
1314
+ "remaining_bytes": snap.total_vram_bytes,
1315
+ "stopped": stopped,
1316
+ })
1317
+ except Exception as error:
1318
+ self._send_json({"status": "failed", "error": str(error)})
1319
+
1320
+ @staticmethod
1321
+ def _parse_ctx_override(requested_ctx: Any) -> tuple[int | None, str | None]:
1322
+ if requested_ctx is None:
1323
+ return None, None
1324
+ if isinstance(requested_ctx, bool):
1325
+ return None, "Context override must be an integer of at least 512 tokens"
1326
+ try:
1327
+ ctx = int(requested_ctx)
1328
+ except (TypeError, ValueError):
1329
+ return None, "Context override must be an integer of at least 512 tokens"
1330
+ if isinstance(requested_ctx, float) and not requested_ctx.is_integer():
1331
+ return None, "Context override must be an integer of at least 512 tokens"
1332
+ if ctx < 512:
1333
+ return None, "Context override must be at least 512 tokens"
1334
+ if ctx > _MAX_CTX_OVERRIDE:
1335
+ return None, (
1336
+ f"Context override {ctx} is too large; the hard resource cap is "
1337
+ f"{_MAX_CTX_OVERRIDE} tokens"
1338
+ )
1339
+ return ctx, None
1340
+
1341
+ def _apply_ctx_override(self, profile: ModelProfile, requested_ctx: Any) -> tuple[ModelProfile, str | None]:
1342
+ """Clamp and apply a per-request context-window override.
1343
+
1344
+ Returns (profile, relaunch_error). For openai-provider (llama-server)
1345
+ profiles the context window is fixed at server launch (``-c``), so a
1346
+ changed value triggers a relaunch of the remembered GGUF; when the
1347
+ server was started externally the caller gets a prescriptive error.
1348
+ """
1349
+ ctx, error = self._parse_ctx_override(requested_ctx)
1350
+ if error:
1351
+ return profile, error
1352
+ if ctx is None:
1353
+ return profile, None
1354
+ # Static profile values are policy recommendations, not proof of the
1355
+ # installed model's runtime limit. Clamp only to a verified limit.
1356
+ max_len = getattr(profile, "model_context_limit", None)
1357
+ if max_len and ctx > max_len:
1358
+ ctx = int(max_len)
1359
+ profile = replace(profile, num_ctx=ctx)
1360
+ if profile.provider != "openai":
1361
+ return profile, None # Ollama receives options.num_ctx per request
1362
+ if self.server_inst.llama_num_ctx == ctx:
1363
+ return profile, None
1364
+ gguf_path = self.server_inst.llama_gguf_path
1365
+ if not gguf_path:
1366
+ return profile, (
1367
+ f"llama-server is running with -c {self.server_inst.llama_num_ctx}. "
1368
+ f"To use a {ctx}-token context window, reload the model from the "
1369
+ "model panel (Load into VRAM) with the Context Override set."
1370
+ )
1371
+ label = self.server_inst.llama_gguf_label or Path(gguf_path).stem
1372
+ result = self._launch_llama_model(gguf_path, label, num_ctx=ctx)
1373
+ if result.get("status") != "started":
1374
+ error = (
1375
+ f"llama-server relaunch with -c {ctx} failed: {result.get('error', 'unknown error')}"
1376
+ )
1377
+ if result.get("restored"):
1378
+ error += f" {result['restored']}"
1379
+ elif result.get("restore_error"):
1380
+ error += f" Automatic restore of the previous configuration also failed: {result['restore_error']}"
1381
+ return profile, error
1382
+ return profile, None
1383
+
1384
+ def _handle_chat(self) -> None:
1385
+ data = self._read_json_body()
1386
+ prompt = str(data.get("prompt", "")).strip()
1387
+ profile_name = data.get("profile", self.server_inst.default_profile)
1388
+ if profile_name == self.server_inst.default_profile:
1389
+ profile_name = select_available_profile(profile_name)
1390
+ files = data.get("files") or []
1391
+ checks = data.get("checks") or []
1392
+ requested_ctx = data.get("num_ctx")
1393
+
1394
+ if not prompt:
1395
+ self._send_json({"status": "failed", "error": "Prompt cannot be empty"})
1396
+ return
1397
+ _, ctx_error = self._parse_ctx_override(requested_ctx)
1398
+ if ctx_error:
1399
+ self._send_json({"status": "failed", "error": ctx_error}, HTTPStatus.BAD_REQUEST)
1400
+ return
1401
+
1402
+ from ...mode_router import MODES, classify_fast, classify_mode
1403
+
1404
+ # Resolve the user-selected interaction mode. Invalid / missing falls
1405
+ # back to "hybrid" (auto-classify to chat/build/plan).
1406
+ raw_mode = data.get("mode")
1407
+ mode = raw_mode if raw_mode in MODES else "hybrid"
1408
+ if mode == "hybrid":
1409
+ # Keep a bounded recent-prompt history for the router's isolated context.
1410
+ with self.server_inst.apply_lock:
1411
+ _recent_prompts.append(prompt)
1412
+ del _recent_prompts[:-_MAX_RECENT_PROMPTS]
1413
+ recent_snapshot = list(_recent_prompts)
1414
+ router = None
1415
+ try:
1416
+ from ...mode_router import build_mode_router
1417
+
1418
+ # Small default profile in the isolated context, never the main profile.
1419
+ router = build_mode_router()
1420
+ except Exception:
1421
+ router = None # ponytail: deterministic fallback, never break chat
1422
+ mode = classify_mode(
1423
+ prompt,
1424
+ current_mode=None,
1425
+ router=router,
1426
+ n_every=3,
1427
+ counter=self.server_inst.hybrid_counter,
1428
+ recent_prompts=recent_snapshot,
1429
+ )
1430
+ with self.server_inst.apply_lock:
1431
+ self.server_inst.hybrid_counter += 1
1432
+
1433
+ workspace = self.server_inst.workspace
1434
+ # Scope discovery is for mutation/build work. Chat and small-talk do
1435
+ # not need a repository scan, and an informational request without an
1436
+ # explicit file must ask for context rather than guess one.
1437
+ if not files and mode == "plan":
1438
+ files = detect_plan_candidates(workspace)
1439
+ elif not files and (mode != "chat" or re.search(r"[\w./\\-]+\.\w{1,5}", prompt)):
1440
+ files = detect_relevant_files(workspace, prompt)
1441
+ if not checks and mode == "build":
1442
+ checks = detect_test_checks(workspace)
1443
+ if mode == "build" and not files:
1444
+ self._send_json({
1445
+ "status": "needs_context",
1446
+ "mode": "build",
1447
+ "message": "Name an existing file or provide an explicit scope before requesting a build change.",
1448
+ "files": [],
1449
+ })
1450
+ return
1451
+
1452
+ # ponytail: uuid suffix — int(time.time()) collided when two chats
1453
+ # landed in the same second and silently overwrote History.
1454
+ task_id = f"task-{uuid.uuid4().hex[:12]}"
1455
+
1456
+ # Read-only planning for plan mode — never produces a patch, always
1457
+ # returns a PlanArtifact. Failures degrade to an empty plan, never raise.
1458
+ if mode == "plan":
1459
+ try:
1460
+ from ...plan_mode import PlanArtifact
1461
+ from ...controller import Controller
1462
+ profile = resolve_model_profile(profile_name)
1463
+ profile, ctx_error = self._apply_ctx_override(profile, requested_ctx)
1464
+ if ctx_error:
1465
+ self._send_json({"status": "failed", "error": ctx_error})
1466
+ return
1467
+ client = build_client(profile)
1468
+ task = TaskEnvelope(
1469
+ id=task_id,
1470
+ goal=prompt,
1471
+ files=tuple(files),
1472
+ checks=tuple(checks),
1473
+ )
1474
+ # Read-only enforcement at the tool-policy layer: block the two
1475
+ # tools that mutate the workspace so plan mode cannot patch.
1476
+ controller = Controller(
1477
+ client, workspace,
1478
+ blocked_tools={"propose_patch", "run_tests"},
1479
+ )
1480
+ result = controller.run(task, apply=False)
1481
+
1482
+ summary = result.get("summary") or ""
1483
+ steps = [summary] if summary else []
1484
+ risks = result.get("risks") or []
1485
+ files_to_modify = list(files)
1486
+ plan = PlanArtifact(
1487
+ goal=prompt,
1488
+ steps=[str(s) for s in steps],
1489
+ risks=[str(r) if isinstance(r, str) else str(r.get("message", "")) for r in risks],
1490
+ files_to_modify=[str(f) for f in files_to_modify],
1491
+ )
1492
+ plan_dict = plan.to_dict()
1493
+ message = summary or f"Plan generated for '{prompt}'."
1494
+ except Exception as error:
1495
+ plan_dict = {
1496
+ "goal": prompt,
1497
+ "steps": [],
1498
+ "risks": [],
1499
+ "files_to_modify": list(files),
1500
+ }
1501
+ message = f"Could not generate a detailed plan: {error}"
1502
+ response = {
1503
+ "status": "completed",
1504
+ "mode": "plan",
1505
+ "task_id": task_id,
1506
+ "prompt": prompt,
1507
+ "profile": profile_name,
1508
+ "plan": plan_dict,
1509
+ "file": "workspace",
1510
+ "patch": "",
1511
+ "thinking": "Plan mode: read-only exploration produced a plan.",
1512
+ "testResult": "READY",
1513
+ "checks": [],
1514
+ "message": message,
1515
+ }
1516
+ self._save_chat_session(response, [], status="Planned")
1517
+ self._send_json(response)
1518
+ return
1519
+
1520
+ # Friendly conversational / small-talk handling. The Controller tool-loop
1521
+ # demands structured JSON that small models can't reliably emit for
1522
+ # non-coding prompts, so route small talk to a plain completion.
1523
+ greetings = {"hi", "hello", "hey", "привет", "здравствуйте", "yo", "sup", "help", "test", "ok", "thanks", "спасибо"}
1524
+ greeting_phrases = ("how are you", "what's up", "whats up", "wassup", "how are u")
1525
+ lowered = prompt.lower().strip("!.,? ")
1526
+ tokens = lowered.split()
1527
+ is_small_talk = (
1528
+ lowered in greetings
1529
+ or any(phrase in lowered for phrase in greeting_phrases)
1530
+ or (len(tokens) <= 4 and any(tok.strip("!.,?") in greetings for tok in tokens))
1531
+ )
1532
+ if is_small_talk:
1533
+ try:
1534
+ profile = resolve_model_profile(profile_name)
1535
+ profile, ctx_error = self._apply_ctx_override(profile, requested_ctx)
1536
+ if ctx_error:
1537
+ self._send_json({"status": "failed", "error": ctx_error})
1538
+ return
1539
+ client = build_client(profile)
1540
+ resp = client.chat([
1541
+ {"role": "system", "content": "You are a concise local coding assistant."},
1542
+ {"role": "user", "content": prompt},
1543
+ ])
1544
+ reply = (resp.get("message") or {}).get("content")
1545
+ except Exception as error:
1546
+ self._send_offline_or_error(error, profile_name)
1547
+ return
1548
+ response = {
1549
+ "status": "completed",
1550
+ "mode": mode,
1551
+ "task_id": task_id,
1552
+ "prompt": prompt,
1553
+ "profile": profile_name,
1554
+ "file": "workspace",
1555
+ "patch": "",
1556
+ "thinking": "Conversational intent detected.",
1557
+ "testResult": "READY",
1558
+ "checks": [],
1559
+ "message": reply or (
1560
+ f"Hello! Connected to `{profile_name}`. "
1561
+ "Please give me a specific coding task, bug fix, or refactoring goal "
1562
+ "(e.g. 'Fix off-by-one in sliding window' or 'Write unit tests for tax logic')."
1563
+ ),
1564
+ }
1565
+ self._save_chat_session(response, [], status="Answered")
1566
+ self._send_json(response)
1567
+ return
1568
+
1569
+ # Informational / Code Inquiry Handling (e.g. "read main.py and tell me what it does",
1570
+ # "explain foo", "can u tell me what main.py does?"). The shared classifier detects
1571
+ # questions anywhere in the prompt, not just as prefixes; a question never needs a
1572
+ # patch, so it bypasses the Controller AND the blind chat completion — the model
1573
+ # must see the file it is asked about, whatever mode the user selected.
1574
+ if classify_fast(prompt) == "chat" and files:
1575
+ try:
1576
+ profile = resolve_model_profile(profile_name)
1577
+ profile, ctx_error = self._apply_ctx_override(profile, requested_ctx)
1578
+ if ctx_error:
1579
+ self._send_json({"status": "failed", "error": ctx_error})
1580
+ return
1581
+ if profile.num_predict < 2048:
1582
+ profile = replace(profile, num_predict=2048)
1583
+ client = build_client(profile)
1584
+ if not files:
1585
+ self._send_json({
1586
+ "status": "needs_context",
1587
+ "mode": "chat",
1588
+ "message": "Please name the file or provide its path so I can answer from repository evidence.",
1589
+ "file": None,
1590
+ })
1591
+ return
1592
+ target_file = files[0]
1593
+ # Strict Scope Boundary (server-enforced, mirrors apply): normalize
1594
+ # and verify the resolved path stays inside the workspace before
1595
+ # reading. An absolute path or `../` escapes the workspace -> skip.
1596
+ # Up to 3 allowlisted files are injected: "what component does X"
1597
+ # usually spans more than one file and a single-file context makes
1598
+ # the model answer "I don't have access".
1599
+ workspace_root = Path(workspace).resolve()
1600
+ snippets: list[str] = []
1601
+ for candidate_file in (files[:3] or ["src/main.py"]):
1602
+ try:
1603
+ resolved = (workspace_root / candidate_file).resolve()
1604
+ if resolved.is_relative_to(workspace_root) and resolved.is_file():
1605
+ body = resolved.read_text(encoding="utf-8", errors="replace")[:6000]
1606
+ snippets.append(f"--- {candidate_file} ---\n{body}")
1607
+ except Exception:
1608
+ continue
1609
+ content_snippet = "\n\n".join(snippets)
1610
+
1611
+ messages = [
1612
+ {"role": "system", "content": f"You are a helpful coding assistant answering a question about this workspace. Relevant files: {', '.join(files[:3]) or target_file}\n\n{content_snippet}"},
1613
+ {"role": "user", "content": prompt},
1614
+ ]
1615
+ resp = client.chat(messages)
1616
+ msg_content = (resp.get("message") or {}).get("content") or "No response received."
1617
+
1618
+ response = {
1619
+ "status": "completed",
1620
+ "mode": mode,
1621
+ "task_id": task_id,
1622
+ "prompt": prompt,
1623
+ "profile": profile_name,
1624
+ "file": target_file,
1625
+ "patch": "",
1626
+ "thinking": f"Read context from {target_file} and formulated code explanation.",
1627
+ "testResult": "READY",
1628
+ "checks": [],
1629
+ "message": msg_content,
1630
+ }
1631
+ self._save_chat_session(response, checks, status="Answered")
1632
+ self._send_json(response)
1633
+ return
1634
+ except Exception as error:
1635
+ self._send_offline_or_error(error, profile_name)
1636
+ return
1637
+
1638
+ # Plain conversational completion for chat mode — never runs the
1639
+ # Controller. Reached only for chat-mode prompts that are neither
1640
+ # small talk nor questions (opinions, jokes, ...); questions above
1641
+ # already got the file-aware answer.
1642
+ if mode == "chat":
1643
+ try:
1644
+ profile = resolve_model_profile(profile_name)
1645
+ profile, ctx_error = self._apply_ctx_override(profile, requested_ctx)
1646
+ if ctx_error:
1647
+ self._send_json({"status": "failed", "error": ctx_error}, HTTPStatus.BAD_REQUEST)
1648
+ return
1649
+ client = build_client(profile)
1650
+ resp = client.chat([
1651
+ {"role": "system", "content": "You are a concise local coding assistant."},
1652
+ {"role": "user", "content": prompt},
1653
+ ])
1654
+ reply = (resp.get("message") or {}).get("content")
1655
+ except Exception as error:
1656
+ self._send_offline_or_error(error, profile_name)
1657
+ return
1658
+ response = {
1659
+ "status": "completed",
1660
+ "mode": "chat",
1661
+ "task_id": task_id,
1662
+ "prompt": prompt,
1663
+ "profile": profile_name,
1664
+ "file": "workspace",
1665
+ "patch": "",
1666
+ "thinking": "Conversational intent detected.",
1667
+ "testResult": "READY",
1668
+ "checks": [],
1669
+ "message": reply or (
1670
+ f"Hello! Connected to `{profile_name}`. "
1671
+ "Please give me a specific coding task, bug fix, or refactoring goal "
1672
+ "(e.g. 'Fix off-by-one in sliding window' or 'Write unit tests for tax logic')."
1673
+ ),
1674
+ }
1675
+ self._save_chat_session(response, [], status="Answered")
1676
+ self._send_json(response)
1677
+ return
1678
+
1679
+ task = TaskEnvelope(
1680
+ id=task_id,
1681
+ goal=prompt,
1682
+ files=tuple(files),
1683
+ checks=tuple(checks),
1684
+ )
1685
+
1686
+ try:
1687
+ from ...controller import Controller
1688
+ profile = resolve_model_profile(profile_name)
1689
+ profile, ctx_error = self._apply_ctx_override(profile, requested_ctx)
1690
+ if ctx_error:
1691
+ self._send_json({"status": "failed", "error": ctx_error})
1692
+ return
1693
+ client = build_client(profile)
1694
+ controller = Controller(client, workspace)
1695
+ result = controller.run(task, apply=False)
1696
+
1697
+ patch_content = result.get("patch", "")
1698
+ target_file = files[0] if files else "src/main.py"
1699
+ check_evidence = result.get("checks") if isinstance(result.get("checks"), list) else []
1700
+ evidence_verified = bool(check_evidence) and all(
1701
+ isinstance(item, dict) and item.get("passed") is True
1702
+ for item in check_evidence
1703
+ )
1704
+
1705
+ summary = result.get("summary") or ""
1706
+ error = result.get("error")
1707
+ err_kind = error.get("kind") if isinstance(error, dict) else None
1708
+ if err_kind == "duplicate_tool_call":
1709
+ friendly = "The model got stuck repeating the same step. Try a simpler single-step task, or a larger model."
1710
+ elif err_kind == "context_overflow":
1711
+ friendly = summary
1712
+ elif err_kind == "retry_budget_exhausted":
1713
+ friendly = "The model couldn't produce a valid result after several attempts. Simplify the request or switch to a larger model."
1714
+ elif result.get("status") == "failed":
1715
+ friendly = f"The task could not be completed: {summary}"
1716
+ else:
1717
+ friendly = summary
1718
+
1719
+ response = {
1720
+ "status": result.get("status", "completed"),
1721
+ "mode": mode,
1722
+ "task_id": task_id,
1723
+ "prompt": prompt,
1724
+ "profile": profile_name,
1725
+ "file": target_file,
1726
+ "files": list(files),
1727
+ "patch": patch_content,
1728
+ "thinking": friendly or "AST context compacted, generated candidate patch, ran external tests.",
1729
+ "testResult": "PASSED" if evidence_verified else ("NOT RUN" if result.get("status") == "accepted" else "FAILED"),
1730
+ "checks": check_evidence,
1731
+ "message": friendly or f"Task processed for '{prompt}'.",
1732
+ }
1733
+ self._save_chat_session(
1734
+ response,
1735
+ checks,
1736
+ status="Verified" if evidence_verified else "Needs Review",
1737
+ evidence_verified=evidence_verified,
1738
+ )
1739
+ self._send_json(response)
1740
+ except Exception as error:
1741
+ kind = _classify_backend_error(error)
1742
+ if kind == "offline":
1743
+ is_llama = profile.provider == "openai"
1744
+ server_name = "llama-server on port 8080" if is_llama else "Ollama on port 11434"
1745
+ prescript = f"Local backend server ({server_name}) is currently OFFLINE. Click 'Start {('llama-server' if is_llama else 'Ollama')}' or launch your local engine."
1746
+ self._send_json({"status": "failed", "error": prescript, "offline_server": "llama_server" if is_llama else "ollama"})
1747
+ else:
1748
+ self._send_json({"status": "failed", "error": str(error)})
1749
+
1750
+ def _handle_delegate(self) -> None:
1751
+ data = self._read_json_body()
1752
+ raw_task = data.get("task", {})
1753
+ profile_name = data.get("profile", self.server_inst.default_profile)
1754
+ apply_flag = bool(data.get("apply", False))
1755
+
1756
+ try:
1757
+ from ...controller import Controller
1758
+ task = TaskEnvelope.from_mapping(raw_task)
1759
+ profile = resolve_model_profile(profile_name)
1760
+ client = build_client(profile)
1761
+ controller = Controller(client, self.server_inst.workspace)
1762
+ result = controller.run(task, apply=apply_flag)
1763
+ self._send_json(result)
1764
+ except Exception as error:
1765
+ self._send_json({"status": "failed", "error": str(error)})
1766
+
1767
+ def _handle_tasks_submit(self) -> None:
1768
+ data = self._read_json_body()
1769
+ server = self.server_inst
1770
+ goal = data.get("goal")
1771
+ if not isinstance(goal, str) or not goal.strip():
1772
+ self._send_json({"status": "failed", "error": "goal is required"})
1773
+ return
1774
+ files = data.get("files") or []
1775
+ checks = data.get("checks") or []
1776
+ if not isinstance(files, list) or not all(isinstance(f, str) and f for f in files):
1777
+ self._send_json({"status": "failed", "error": "files must be a list of non-empty strings"})
1778
+ return
1779
+ if not isinstance(checks, list) or not all(isinstance(c, str) and c for c in checks):
1780
+ self._send_json({"status": "failed", "error": "checks must be a list of non-empty strings"})
1781
+ return
1782
+ record = {
1783
+ "id": f"task-{uuid.uuid4().hex[:12]}",
1784
+ "goal": goal.strip(),
1785
+ "files": [str(f) for f in files],
1786
+ "checks": [str(c) for c in checks],
1787
+ "profile": str(data.get("profile") or server.default_profile),
1788
+ "status": "queued",
1789
+ "created_at": time.time(),
1790
+ "started_at": None,
1791
+ "finished_at": None,
1792
+ "summary": "",
1793
+ "patch": "",
1794
+ "checks_results": [],
1795
+ "error": "",
1796
+ }
1797
+ with server.task_queue_lock:
1798
+ server.save_task(record)
1799
+ self._send_json({"status": "queued", "task": record})
1800
+
1801
+ def _handle_tasks_list(self) -> None:
1802
+ self._send_json({"tasks": self.server_inst.load_tasks()})
1803
+
1804
+ def _handle_tasks_cancel(self) -> None:
1805
+ data = self._read_json_body()
1806
+ task_id = str(data.get("id") or "")
1807
+ server = self.server_inst
1808
+ with server.task_queue_lock:
1809
+ record = server.get_task(task_id)
1810
+ if record is None:
1811
+ self._send_json({"status": "failed", "error": f"Unknown task id: {task_id}"})
1812
+ return
1813
+ status = record.get("status")
1814
+ if status == "queued":
1815
+ updated = server.update_task(task_id, status="cancelled", finished_at=time.time())
1816
+ self._send_json({"status": "cancelled", "task": updated})
1817
+ return
1818
+ if status == "running":
1819
+ event = server._task_events.get(task_id)
1820
+ if event is not None:
1821
+ event.set()
1822
+ self._send_json({"status": "cancelling", "task": record})
1823
+ return
1824
+ self._send_json({"status": "failed", "error": f"Task {task_id} is already {status}"})
1825
+
1826
+ def _handle_apply(self) -> None:
1827
+ data = self._read_json_body()
1828
+ patch_str = data.get("patch", "")
1829
+ checks = data.get("checks", [])
1830
+ files = data.get("files", [])
1831
+ workspace = Path(self.server_inst.workspace)
1832
+
1833
+ if not isinstance(patch_str, str) or not patch_str.strip():
1834
+ self._send_json({"status": "failed", "error": "No patch content provided to apply"})
1835
+ return
1836
+ if not isinstance(files, list) or not all(isinstance(f, str) for f in files):
1837
+ self._send_json({"status": "rejected", "error": "files must be a list of strings"})
1838
+ return
1839
+ if not isinstance(checks, list) or not all(isinstance(c, str) for c in checks):
1840
+ self._send_json({"status": "rejected", "error": "checks must be a list of strings"})
1841
+ return
1842
+
1843
+ # Strict Scope Boundary (server-enforced, mirrors validate_candidate):
1844
+ # the caller must declare the allowlist. The boundary lives on the
1845
+ # server, not the client.
1846
+ if not files:
1847
+ self._send_json({
1848
+ "status": "rejected",
1849
+ "error": "No declared file scope; refusing to apply a patch with an empty allowlist",
1850
+ })
1851
+ return
1852
+
1853
+ try:
1854
+ task = TaskEnvelope(
1855
+ id="desktop-apply",
1856
+ goal="apply reviewed patch",
1857
+ files=tuple(files),
1858
+ checks=tuple(checks),
1859
+ )
1860
+ except ValueError as error:
1861
+ self._send_json({"status": "rejected", "error": f"Invalid apply request: {error}"})
1862
+ return
1863
+
1864
+ # Mediated apply under the lock: the same verification gate as the CLI
1865
+ # and service paths (declared scope, semantic lint, targeted checks,
1866
+ # verified rollback). Holding the lock across apply->checks->rollback
1867
+ # closes the race on last_applied_files.
1868
+ with self.server_inst.apply_lock:
1869
+ result = mediated_apply(workspace, patch_str, task)
1870
+ payload: dict[str, Any] = {
1871
+ "status": "applied" if result.get("applied") is True else "rejected",
1872
+ "checks": result.get("checks", []),
1873
+ "rolled_back": bool(result.get("rolled_back")),
1874
+ }
1875
+ if result.get("applied") is True:
1876
+ changed, _ = parse_unified_diff(patch_str)
1877
+ # Record only the files the patch actually changes (not the
1878
+ # declared allowlist), so a later rollback never wipes unrelated
1879
+ # work.
1880
+ self.server_inst.last_applied_files = list(changed)
1881
+ self.server_inst.last_applied_patch = patch_str
1882
+ else:
1883
+ error = result.get("error")
1884
+ payload["error"] = error.get("message", "apply rejected") if isinstance(error, dict) else str(error)
1885
+ self._send_json(payload)
1886
+
1887
+ def _handle_rollback(self) -> None:
1888
+ workspace = Path(self.server_inst.workspace)
1889
+ with self.server_inst.apply_lock:
1890
+ targets = list(self.server_inst.last_applied_files)
1891
+ patch = self.server_inst.last_applied_patch
1892
+ if not targets or not patch:
1893
+ self._send_json({"status": "rolled_back", "restored": []})
1894
+ return
1895
+ # Scoped rollback via reverse-apply of the stored patch: handles both
1896
+ # modified (tracked) and newly-created (untracked) files uniformly,
1897
+ # unlike `git restore` which cannot remove untracked files. Restores
1898
+ # only the files this apply touched — never unrelated work.
1899
+ reverted, detail = apply_patch(workspace, patch, reverse=True)
1900
+ if not reverted:
1901
+ self._send_json({
1902
+ "status": "failed",
1903
+ "error": f"Rollback failed: {detail}",
1904
+ "restored": [],
1905
+ })
1906
+ return
1907
+ self.server_inst.last_applied_files = []
1908
+ self.server_inst.last_applied_patch = ""
1909
+ self._send_json({"status": "rolled_back", "restored": targets})
1910
+
1911
+ def _handle_doctor_fix(self) -> None:
1912
+ try:
1913
+ report = remediate_environment(write=True)
1914
+ report_data = report.to_dict()
1915
+ if report.success:
1916
+ self._send_json({"status": "ok", "report": report_data})
1917
+ return
1918
+ errors = getattr(report, "errors", [])
1919
+ actions = getattr(report, "actions", [])
1920
+ if actions:
1921
+ # Partial success: some targets applied, others were denied
1922
+ # (e.g. permission-denied global .gemini/.codex/.claude). A
1923
+ # bare 500 would hide the real progress from the user.
1924
+ self._send_json({
1925
+ "status": "partial",
1926
+ "error": "; ".join(errors) or "Doctor remediation was incomplete",
1927
+ "report": report_data,
1928
+ })
1929
+ return
1930
+ self._send_json(
1931
+ {
1932
+ "status": "failed",
1933
+ "error": "; ".join(errors) or "Doctor remediation was incomplete",
1934
+ "report": report_data,
1935
+ },
1936
+ HTTPStatus.INTERNAL_SERVER_ERROR,
1937
+ )
1938
+ except Exception as error:
1939
+ self._send_json(
1940
+ {"status": "failed", "error": str(error)},
1941
+ HTTPStatus.INTERNAL_SERVER_ERROR,
1942
+ )
1943
+
1944
+ def _probe_port(self, url: str) -> bool:
1945
+ online, _ = self._probe_server_status(url)
1946
+ return online
1947
+
1948
+ def _probe_server_status(self, url: str) -> tuple[bool, str]:
1949
+ try:
1950
+ req = urllib.request.Request(url)
1951
+ with urllib.request.urlopen(req, timeout=0.3) as resp:
1952
+ if resp.status == 200:
1953
+ return True, "ready"
1954
+ return True, "loading"
1955
+ except urllib.error.HTTPError as e:
1956
+ if e.code == 503:
1957
+ return True, "loading"
1958
+ return False, "offline"
1959
+ except Exception:
1960
+ return False, "offline"
1961
+
1962
+ def _send_json(self, data: Any, status: HTTPStatus = HTTPStatus.OK) -> None:
1963
+ body = json.dumps(data, ensure_ascii=False).encode("utf-8")
1964
+ self._send_response(status, "application/json; charset=utf-8", body)
1965
+
1966
+ def _send_request_body_error(self, error: _RequestBodyError) -> None:
1967
+ """Return a framing error and prevent reuse of an unsynchronized socket."""
1968
+ self.close_connection = True
1969
+ self._send_json({"status": "rejected", "error": error.message}, error.status)
1970
+
1971
+ def _send_offline_or_error(self, error: Exception, profile_name: Any) -> None:
1972
+ """Prescriptive failure for chat completions — never mask a dead
1973
+ backend behind the canned 'Connected' greeting."""
1974
+ try:
1975
+ profile = resolve_model_profile(profile_name)
1976
+ except Exception:
1977
+ profile = None
1978
+ if profile is not None and _classify_backend_error(error) == "offline":
1979
+ is_llama = profile.provider == "openai"
1980
+ server_name = "llama-server on port 8080" if is_llama else "Ollama on port 11434"
1981
+ self._send_json({
1982
+ "status": "failed",
1983
+ "error": (
1984
+ f"Local backend server ({server_name}) is currently OFFLINE. "
1985
+ f"Click 'Start {('llama-server' if is_llama else 'Ollama')}' or launch your local engine."
1986
+ ),
1987
+ "offline_server": "llama_server" if is_llama else "ollama",
1988
+ })
1989
+ else:
1990
+ self._send_json({"status": "failed", "error": str(error)})
1991
+
1992
+ def _send_response(self, status: HTTPStatus, content_type: str, body: bytes) -> None:
1993
+ self.send_response(status)
1994
+ self.send_header("Content-Type", content_type)
1995
+ self.send_header("Content-Length", str(len(body)))
1996
+ if self.close_connection:
1997
+ self.send_header("Connection", "close")
1998
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
1999
+ self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'")
2000
+ self.send_header("X-Content-Type-Options", "nosniff")
2001
+ self.send_header("X-Frame-Options", "DENY")
2002
+ self.send_header("Referrer-Policy", "no-referrer")
2003
+ self.end_headers()
2004
+ self.wfile.write(body)
2005
+
2006
+ def log_message(self, format: str, *args: Any) -> None:
2007
+ del format, args
2008
+
2009
+
2010
+ def detect_relevant_files(workspace: str, prompt: str) -> list[str]:
2011
+ """Heuristic allowlist for a task with no explicit files.
2012
+
2013
+ Module-level (not a handler method) so the background task worker can
2014
+ resolve a file scope without an HTTP request context.
2015
+ """
2016
+ ws_path = Path(workspace)
2017
+
2018
+ # 1. Files explicitly named in the prompt win over every heuristic:
2019
+ # "what does main.py do?" must not answer about an unrelated dirty file.
2020
+ lowered = prompt.lower()
2021
+ # ponytail: dot-token candidates let "__main__.py" match a "main.py"
2022
+ # mention via suffix; drop the stem tier if false positives appear.
2023
+ candidates = {
2024
+ tok.strip("./\\'\"(),:?!")
2025
+ for tok in re.findall(r"[\w./\\-]+\.\w{1,5}", lowered)
2026
+ } - {""}
2027
+ # A bare basename is an explicit scope only when it identifies one real
2028
+ # file. Duplicate basenames require the caller to provide a path.
2029
+ basename_counts: dict[str, int] = {}
2030
+ # Keyword scoring turns "desktop ui components" into desktop/ui.py +
2031
+ # desktop/components.py when no filename is mentioned outright.
2032
+ # ponytail: substring scoring, no stopwords — noise only widens the
2033
+ # allowlist; tighten if wrong-file answers ever come back.
2034
+ keywords = re.findall(r"[a-z_]{2,}", lowered)
2035
+ mentioned: list[str] = []
2036
+ scored: list[tuple[int, str]] = []
2037
+ file_records: list[tuple[str, str, str]] = []
2038
+ try:
2039
+ # One os.walk both discovers files and counts basenames, so an
2040
+ # explicit ``main.py`` can be rejected when duplicate paths exist
2041
+ # without traversing the workspace twice.
2042
+ for root, dirs, files_os in os.walk(ws_path):
2043
+ dirs[:] = [
2044
+ d for d in dirs
2045
+ if not d.startswith(".") and d not in ("__pycache__", "venv", ".venv", "build", "dist", "node_modules")
2046
+ ]
2047
+ for fname in files_os:
2048
+ p = Path(root) / fname
2049
+ if p.suffix.lower() not in {
2050
+ ".py", ".js", ".ts", ".tsx", ".jsx", ".md", ".json", ".toml",
2051
+ ".yaml", ".yml", ".rs", ".go", ".c", ".h", ".cpp", ".java",
2052
+ ".rb", ".php", ".sh", ".ps1", ".sql", ".css", ".html",
2053
+ }:
2054
+ continue
2055
+ rel = p.relative_to(ws_path).as_posix()
2056
+ name = fname.lower()
2057
+ file_records.append((rel, name, root))
2058
+ basename_counts[name] = basename_counts.get(name, 0) + 1
2059
+
2060
+ for rel, name, root in file_records:
2061
+ if (
2062
+ len(mentioned) < 3
2063
+ and (
2064
+ any(
2065
+ (
2066
+ ("/" in cand or "\\" in cand)
2067
+ and rel.lower() == cand.replace("\\", "/")
2068
+ )
2069
+ or (
2070
+ "/" not in cand
2071
+ and "\\" not in cand
2072
+ and name == cand
2073
+ and basename_counts.get(cand, 0) == 1
2074
+ )
2075
+ for cand in candidates
2076
+ )
2077
+ )
2078
+ ):
2079
+ mentioned.append(rel)
2080
+ continue
2081
+ if keywords:
2082
+ stem = name.rsplit(".", 1)[0]
2083
+ parent = Path(root).name.lower()
2084
+ score = 0
2085
+ for kw in keywords:
2086
+ if len(kw) >= 4:
2087
+ if kw in stem:
2088
+ score += 2
2089
+ elif kw in rel.lower():
2090
+ score += 1
2091
+ elif kw == stem or kw == parent:
2092
+ score += 2
2093
+ if score:
2094
+ scored.append((score, rel))
2095
+ except Exception:
2096
+ pass
2097
+ if mentioned:
2098
+ return mentioned
2099
+ if any(
2100
+ "/" not in candidate and "\\" not in candidate and basename_counts.get(candidate, 0) > 1
2101
+ for candidate in candidates
2102
+ ):
2103
+ return []
2104
+ if scored:
2105
+ scored.sort(key=lambda t: (-t[0], t[1]))
2106
+ return [rel for _, rel in scored[:3]]
2107
+
2108
+ # No explicit or high-confidence scope: callers must request context
2109
+ # instead of turning dirty files or a guessed ``src/main.py`` into a
2110
+ # permission to patch an arbitrary file.
2111
+ return []
2112
+
2113
+
2114
+ def detect_plan_candidates(workspace: str) -> list[str]:
2115
+ """Return existing dirty files as read-only plan suggestions.
2116
+
2117
+ Plan mode may show the user what changed, but this candidate list is not
2118
+ reused as a build/apply allowlist. An unavailable Git view yields no
2119
+ fabricated path.
2120
+ """
2121
+ try:
2122
+ result = subprocess.run(
2123
+ ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
2124
+ cwd=workspace,
2125
+ capture_output=True,
2126
+ text=True,
2127
+ check=False,
2128
+ )
2129
+ if result.returncode != 0:
2130
+ return []
2131
+ candidates: list[str] = []
2132
+ entries = result.stdout.split("\0")
2133
+ index = 0
2134
+ while index < len(entries):
2135
+ entry = entries[index]
2136
+ index += 1
2137
+ if len(entry) < 4:
2138
+ continue
2139
+ status, path = entry[:2], entry[3:].replace("\\", "/")
2140
+ if "R" in status or "C" in status:
2141
+ index += 1
2142
+ if not path or any(part.startswith(".") for part in Path(path).parts):
2143
+ continue
2144
+ if (Path(workspace) / path).is_file():
2145
+ candidates.append(path)
2146
+ return candidates[:3]
2147
+ except (OSError, subprocess.SubprocessError):
2148
+ return []
2149
+
2150
+
2151
+ def detect_test_checks(workspace: str) -> list[str]:
2152
+ ws_path = Path(workspace)
2153
+ package_path = ws_path / "package.json"
2154
+ if package_path.is_file():
2155
+ try:
2156
+ package = json.loads(package_path.read_text(encoding="utf-8-sig"))
2157
+ scripts = package.get("scripts") if isinstance(package, dict) else None
2158
+ if isinstance(scripts, dict) and isinstance(scripts.get("test"), str) and scripts["test"].strip():
2159
+ return ["npm test"]
2160
+ except (OSError, UnicodeError, json.JSONDecodeError):
2161
+ return []
2162
+
2163
+ if (ws_path / "Cargo.toml").is_file():
2164
+ return ["cargo test"]
2165
+ if (ws_path / "go.mod").is_file():
2166
+ return ["go test ./..."]
2167
+
2168
+ python_manifest = any(
2169
+ (ws_path / name).is_file()
2170
+ for name in ("pyproject.toml", "setup.cfg", "setup.py", "pytest.ini", "tox.ini")
2171
+ )
2172
+ if python_manifest:
2173
+ if (ws_path / "tests").is_dir():
2174
+ return ["pytest tests/"]
2175
+ if (ws_path / "test").is_dir():
2176
+ return ["pytest test/"]
2177
+ return ["pytest"]
2178
+ return []