ata-coder 2.4.2__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 (118) hide show
  1. ata_coder/__init__.py +1 -0
  2. ata_coder/agent.py +874 -0
  3. ata_coder/agent_compact.py +190 -0
  4. ata_coder/agent_controller.py +218 -0
  5. ata_coder/agent_extension.py +69 -0
  6. ata_coder/agent_routing.py +105 -0
  7. ata_coder/agent_subsystems.py +72 -0
  8. ata_coder/agent_tools.py +318 -0
  9. ata_coder/agent_undo.py +63 -0
  10. ata_coder/anthropic_client.py +465 -0
  11. ata_coder/change_tracker.py +368 -0
  12. ata_coder/clawd_integration.py +574 -0
  13. ata_coder/commands/__init__.py +128 -0
  14. ata_coder/commands/_core.py +184 -0
  15. ata_coder/commands/_safety.py +95 -0
  16. ata_coder/commands/_settings.py +241 -0
  17. ata_coder/commands/_workflow.py +451 -0
  18. ata_coder/commands.py +974 -0
  19. ata_coder/config.py +257 -0
  20. ata_coder/core/__init__.py +35 -0
  21. ata_coder/core/events.py +73 -0
  22. ata_coder/core/queue.py +85 -0
  23. ata_coder/core/state.py +17 -0
  24. ata_coder/event_queue.py +5 -0
  25. ata_coder/extension.py +654 -0
  26. ata_coder/extensions/__init__.py +1 -0
  27. ata_coder/extensions/hello_skill.py +47 -0
  28. ata_coder/fool_proof.py +295 -0
  29. ata_coder/git_workflow.py +371 -0
  30. ata_coder/gui.py +511 -0
  31. ata_coder/llm_client.py +543 -0
  32. ata_coder/main.py +814 -0
  33. ata_coder/mcp_client.py +1095 -0
  34. ata_coder/memory.py +539 -0
  35. ata_coder/model_registry.py +134 -0
  36. ata_coder/model_router.py +105 -0
  37. ata_coder/permissions.py +274 -0
  38. ata_coder/privilege.py +464 -0
  39. ata_coder/project.py +273 -0
  40. ata_coder/prompt_template.py +423 -0
  41. ata_coder/prompts/auto-mode.md +7 -0
  42. ata_coder/prompts/coding-rules.md +40 -0
  43. ata_coder/prompts/execution-guardrails.md +14 -0
  44. ata_coder/prompts/memory-system.md +24 -0
  45. ata_coder/prompts/output-style.md +23 -0
  46. ata_coder/prompts/safety.md +17 -0
  47. ata_coder/prompts/slash-commands.md +24 -0
  48. ata_coder/prompts/sub-agents.md +38 -0
  49. ata_coder/prompts/system-reminders.md +17 -0
  50. ata_coder/prompts/system.md +105 -0
  51. ata_coder/prompts/tool-policy.md +46 -0
  52. ata_coder/repl_theme.py +99 -0
  53. ata_coder/repl_tracker.py +89 -0
  54. ata_coder/repl_ui.py +1214 -0
  55. ata_coder/safety_guard.py +434 -0
  56. ata_coder/self_correct.py +346 -0
  57. ata_coder/server.py +882 -0
  58. ata_coder/server_session.py +159 -0
  59. ata_coder/server_shell.py +129 -0
  60. ata_coder/session.py +431 -0
  61. ata_coder/settings.py +439 -0
  62. ata_coder/setup_wizard.py +136 -0
  63. ata_coder/skill_extension.py +92 -0
  64. ata_coder/skills/architect/SKILL.md +42 -0
  65. ata_coder/skills/code-reviewer/SKILL.md +37 -0
  66. ata_coder/skills/codecraft/SKILL.md +452 -0
  67. ata_coder/skills/debugger/SKILL.md +45 -0
  68. ata_coder/skills/doc-writer/SKILL.md +36 -0
  69. ata_coder/skills/general-coder/SKILL.md +76 -0
  70. ata_coder/skills/math-calculator/README.md +40 -0
  71. ata_coder/skills/math-calculator/SKILL.md +59 -0
  72. ata_coder/skills/math-calculator/handler.py +103 -0
  73. ata_coder/skills/math-calculator/prompts/system.md +8 -0
  74. ata_coder/skills/math-calculator/requirements.txt +2 -0
  75. ata_coder/skills/math-calculator/resources/constants.json +8 -0
  76. ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
  77. ata_coder/skills/security-auditor/SKILL.md +40 -0
  78. ata_coder/skills/test-writer/SKILL.md +36 -0
  79. ata_coder/skills/weather-skill/README.md +45 -0
  80. ata_coder/skills/weather-skill/handler.py +76 -0
  81. ata_coder/skills/weather-skill/manifest.json +48 -0
  82. ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
  83. ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
  84. ata_coder/skills/weather-skill/requirements.txt +1 -0
  85. ata_coder/skills/weather-skill/resources/city_list.json +17 -0
  86. ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
  87. ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
  88. ata_coder/skills/weather-skill/weather_utils.py +50 -0
  89. ata_coder/skills.py +1014 -0
  90. ata_coder/sub_agent.py +273 -0
  91. ata_coder/sub_agent_manager.py +203 -0
  92. ata_coder/system_prompt_builder.py +146 -0
  93. ata_coder/task_planner.py +391 -0
  94. ata_coder/terminal.py +318 -0
  95. ata_coder/test_runner.py +219 -0
  96. ata_coder/thread_supervisor.py +195 -0
  97. ata_coder/tool_defs.py +335 -0
  98. ata_coder/tools/__init__.py +11 -0
  99. ata_coder/tools/definitions.py +335 -0
  100. ata_coder/tools/executor.py +1036 -0
  101. ata_coder/tools/result.py +26 -0
  102. ata_coder/tools/subagent.py +332 -0
  103. ata_coder/tools/web.py +361 -0
  104. ata_coder/tools.py +1576 -0
  105. ata_coder/types.py +92 -0
  106. ata_coder/utils.py +113 -0
  107. ata_coder/web/css/style.css +180 -0
  108. ata_coder/web/index.html +84 -0
  109. ata_coder/web/js/app.js +489 -0
  110. ata_coder/web/package-lock.json +25 -0
  111. ata_coder/web/package.json +10 -0
  112. ata_coder/web/tsconfig.json +13 -0
  113. ata_coder-2.4.2.dist-info/METADATA +799 -0
  114. ata_coder-2.4.2.dist-info/RECORD +118 -0
  115. ata_coder-2.4.2.dist-info/WHEEL +5 -0
  116. ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
  117. ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
  118. ata_coder-2.4.2.dist-info/top_level.txt +1 -0
ata_coder/server.py ADDED
@@ -0,0 +1,882 @@
1
+ """
2
+ ATA Coder — HTTP API Server (stdlib-only, no FastAPI dependency).
3
+
4
+ Uses Python's built-in http.server + httpx for maximum compatibility.
5
+ Provides REST API and SSE streaming for the agent.
6
+
7
+ Endpoints:
8
+ POST /chat — Non-streaming chat
9
+ POST /chat/stream — SSE streaming chat
10
+ GET /health — Health check
11
+ GET /sessions — List active sessions
12
+ GET /sessions/{id} — Get session info
13
+ DELETE /sessions/{id} — Delete a session
14
+ GET /tools — List available tools
15
+ GET /skills — List available skills
16
+ GET /models — List available models
17
+
18
+ Usage:
19
+ python server.py # Start on port 8000
20
+ python server.py --port 3000 # Custom port
21
+ python server.py --host 0.0.0.0 # Public access
22
+ python main.py --server # From main launcher
23
+ """
24
+
25
+ import json
26
+ import logging
27
+ import os
28
+ import queue
29
+ import sys
30
+ import threading
31
+ import time
32
+ from http.server import HTTPServer, BaseHTTPRequestHandler
33
+ from pathlib import Path
34
+ from typing import Any
35
+ from urllib.parse import urlparse
36
+
37
+ # Allow running directly (python server.py) without pip install -e .
38
+ _pkg = str(Path(__file__).parent.resolve())
39
+ if _pkg not in sys.path:
40
+ sys.path.insert(0, _pkg)
41
+
42
+ from .config import AppConfig, get_config
43
+ from .tools import TOOL_DEFINITIONS
44
+ from .server_session import SessionStore
45
+ from .server_shell import shell_open, shell_ensure, shell_close, shell_close_all, get_shell_sessions
46
+ from .skills import get_skill_manager
47
+ from .utils import brief_args
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+
52
+ # Session store → server_session.py (SessionStore)
53
+
54
+ # ══════════════════════════════════════════════════════════════════════# HTTP Request Handler
55
+ # ═══════════════════════════════════════════════════════════════════════════════
56
+
57
+ class AgentAPIHandler(BaseHTTPRequestHandler):
58
+ """HTTP handler for the ATA Coder API.
59
+
60
+ *config* and *store* are set as class attributes by :func:`create_server`
61
+ before the server starts accepting requests. Each handler instance
62
+ accesses them via ``self`` (Python's normal attribute resolution finds
63
+ them on the class). This is the standard ``http.server`` pattern and
64
+ is safe because ``HTTPServer`` handles requests sequentially — if you
65
+ switch to ``ThreadingHTTPServer``, promote these to instance attributes
66
+ in ``__init__``.
67
+ """
68
+
69
+ # Class-level references (set by server factory)
70
+ config: AppConfig = None
71
+ store: SessionStore = None
72
+ _ws_lock: threading.Lock = threading.Lock() # protects workspace dir reads/writes
73
+
74
+ def __init__(self, *args, **kwargs):
75
+ # Per-instance copies for thread-safe access under ThreadingHTTPServer
76
+ self.config = self.__class__.config
77
+ self.store = self.__class__.store
78
+ super().__init__(*args, **kwargs)
79
+
80
+ def log_message(self, format, *args):
81
+ """Suppress default logging; use our logger."""
82
+ logger.debug("%s - %s", self.client_address[0], format % args)
83
+
84
+ # ── Auth ────────────────────────────────────────────────────────────
85
+
86
+ def _check_auth(self) -> bool:
87
+ """Verify API token if ATA_CODER_API_TOKEN is configured.
88
+
89
+ When no token is configured, authentication is disabled and all
90
+ requests are allowed (convenience for local/dev use). Set
91
+ ATA_CODER_API_TOKEN to require Bearer token authentication.
92
+ """
93
+ expected = os.environ.get("ATA_CODER_API_TOKEN", "")
94
+ if not expected:
95
+ return True # no token configured = allow all
96
+ token = (self.headers.get("Authorization", "")
97
+ .removeprefix("Bearer ").strip())
98
+ if token != expected:
99
+ logger.warning("Invalid or missing API token from %s", self.client_address[0])
100
+ return False
101
+ return True
102
+
103
+ def _require_auth(self, method_name: str) -> bool:
104
+ """Check auth and send 403 if invalid. Returns True if ok."""
105
+ if self._check_auth():
106
+ return True
107
+ self._error(403, "Invalid or missing API token. "
108
+ "Set ATA_CODER_API_TOKEN env var on the server, "
109
+ "then send Authorization: Bearer <token> header.")
110
+ return False
111
+
112
+ # ── CORS ────────────────────────────────────────────────────────────
113
+
114
+ def _cors(self):
115
+ """Set CORS headers, reflecting localhost origins.
116
+
117
+ Allows any localhost origin (multiple ports) for development,
118
+ plus the Origin header itself if it is a localhost address.
119
+ """
120
+ origin = self.headers.get("Origin", "")
121
+ if origin and (
122
+ origin.startswith("http://localhost")
123
+ or origin.startswith("http://127.0.0.1")
124
+ or origin.startswith("http://[::1]")
125
+ ):
126
+ self.send_header("Access-Control-Allow-Origin", origin)
127
+ else:
128
+ self.send_header("Access-Control-Allow-Origin", "http://localhost:3000")
129
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
130
+ self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
131
+
132
+ def do_OPTIONS(self):
133
+ self.send_response(204)
134
+ self._cors()
135
+ self.end_headers()
136
+
137
+ # ── JSON helpers ────────────────────────────────────────────────────
138
+
139
+ def _json_response(self, data: Any, status: int = 200):
140
+ self.send_response(status)
141
+ self._cors()
142
+ self.send_header("Content-Type", "application/json; charset=utf-8")
143
+ self.end_headers()
144
+ try:
145
+ self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8"))
146
+ except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError):
147
+ pass # client disconnected before response could be sent
148
+
149
+ def _error(self, status: int, message: str):
150
+ self._json_response({"error": message}, status)
151
+
152
+ def _read_body(self) -> dict | None:
153
+ length = int(self.headers.get("Content-Length", 0))
154
+ if length == 0:
155
+ return {}
156
+ if length > 10_000_000: # 10MB max
157
+ self._error(413, f"Request body too large ({length:,} bytes). Max 10MB.")
158
+ return None
159
+ body = self.rfile.read(length)
160
+ try:
161
+ return json.loads(body)
162
+ except json.JSONDecodeError as e:
163
+ self._error(400, f"Invalid JSON: {e}")
164
+ return None
165
+
166
+ def _path_parts(self) -> list[str]:
167
+ parsed = urlparse(self.path)
168
+ return [p for p in parsed.path.split("/") if p]
169
+
170
+ # ── Routing ─────────────────────────────────────────────────────────
171
+
172
+ def do_GET(self):
173
+ if self.path != "/favicon.ico":
174
+ logger.debug("GET %s", self.path)
175
+ parts = self._path_parts()
176
+
177
+ if self.path == "/" or self.path == "/index.html":
178
+ self._serve_spa()
179
+ elif self.path == "/health":
180
+ self._handle_health()
181
+ elif self.path == "/tools":
182
+ self._handle_tools()
183
+ elif self.path == "/skills":
184
+ self._handle_skills()
185
+ elif self.path == "/models":
186
+ self._handle_models()
187
+ elif self.path == "/sessions":
188
+ self._handle_list_sessions()
189
+ elif self.path == "/api/workspace":
190
+ with self._ws_lock:
191
+ ws = self.config.agent.workspace_dir
192
+ self._json_response({"workspace": ws})
193
+ elif self.path.startswith("/css/") or self.path.startswith("/js/"):
194
+ self._serve_static(self.path.lstrip("/"))
195
+ elif len(parts) == 2 and parts[0] == "sessions":
196
+ self._handle_get_session(parts[1])
197
+ else:
198
+ self._error(404, f"Not found: {self.path}")
199
+
200
+ def do_POST(self):
201
+ logger.debug("POST %s", self.path)
202
+ if self.path == "/chat":
203
+ self._handle_chat()
204
+ elif self.path == "/chat/stream":
205
+ self._handle_chat_stream()
206
+ elif self.path == "/api/workspace":
207
+ self._handle_set_workspace()
208
+ elif self.path == "/api/shell":
209
+ self._handle_shell()
210
+ else:
211
+ self._error(404, f"Not found: {self.path}")
212
+
213
+ def do_DELETE(self):
214
+ if not self._require_auth("DELETE"):
215
+ return
216
+ parts = self._path_parts()
217
+ if len(parts) == 2 and parts[0] == "sessions":
218
+ self._handle_delete_session(parts[1])
219
+ else:
220
+ self._error(404, f"Not found: {self.path}")
221
+
222
+ # ── Chat helpers ─────────────────────────────────────────────────────
223
+
224
+ def _parse_chat_request(self) -> dict | None:
225
+ """Read and validate the JSON body for /chat and /chat/stream.
226
+
227
+ Returns a dict with keys: message, session_id, skill, model_override,
228
+ thinking_override. Returns None if the body is invalid (error response
229
+ already sent).
230
+ """
231
+ try:
232
+ body = self._read_body()
233
+ except Exception:
234
+ self._error(400, "Invalid JSON body")
235
+ return None
236
+ if body is None:
237
+ return None # error response already sent by _read_body
238
+ message = body.get("message", "")
239
+ if not message:
240
+ self._error(400, "Missing 'message' field")
241
+ return None
242
+ return {
243
+ "message": message,
244
+ "session_id": body.get("session_id"),
245
+ "skill": body.get("skill", ""),
246
+ "model_override": body.get("model", ""),
247
+ "thinking_override": body.get("thinking", ""),
248
+ }
249
+
250
+ # ── Handlers ────────────────────────────────────────────────────────
251
+
252
+ def _handle_health(self):
253
+ skill_mgr = get_skill_manager()
254
+ with self._ws_lock:
255
+ ws = self.config.agent.workspace_dir
256
+ self._json_response({
257
+ "status": "ok",
258
+ "model": self.config.llm.model,
259
+ "workspace": ws,
260
+ "tools": len(TOOL_DEFINITIONS),
261
+ "skills": [s.name for s in skill_mgr.list_skills()],
262
+ "mcp_servers": [],
263
+ })
264
+
265
+ def _handle_tools(self):
266
+ if not self._require_auth("tools"):
267
+ return
268
+ self._json_response({
269
+ "tools": [
270
+ {"name": t["function"]["name"], "description": t["function"]["description"]}
271
+ for t in TOOL_DEFINITIONS
272
+ ]
273
+ })
274
+
275
+ def _handle_skills(self):
276
+ if not self._require_auth("skills"):
277
+ return
278
+ skill_mgr = get_skill_manager()
279
+ self._json_response({
280
+ "skills": [
281
+ {"name": s.name, "description": s.description, "triggers": s.triggers}
282
+ for s in skill_mgr.list_skills()
283
+ ]
284
+ })
285
+
286
+ def _handle_models(self):
287
+ """Fetch available models from API if possible, else return cached."""
288
+ try:
289
+ from .model_registry import fetch_available_models
290
+ models = fetch_available_models(self.config.llm.base_url, self.config.llm.api_key)
291
+ if models:
292
+ models_data = [{"id": m, "owned_by": "api"} for m in sorted(models)]
293
+ self._json_response({"models": models_data, "current": self.config.llm.model})
294
+ return
295
+ except Exception:
296
+ logger.debug("Failed to fetch models from API, using cache", exc_info=True)
297
+ # Fallback: cached model list
298
+ import os
299
+ cached = os.environ.get("ATA_CODER_MODELS_CACHE", self.config.llm.model)
300
+ models = [{"id": m.strip(), "owned_by": ""} for m in cached.split(",") if m.strip()]
301
+ self._json_response({"models": models, "current": self.config.llm.model})
302
+
303
+ def _handle_set_workspace(self):
304
+ """Change the agent workspace directory."""
305
+ if not self._require_auth("workspace"):
306
+ return
307
+ body = self._read_body()
308
+ if body is None:
309
+ return # error response already sent by _read_body
310
+ new_ws = body.get("workspace", "")
311
+ if not new_ws:
312
+ self._error(400, "Missing 'workspace' field")
313
+ return
314
+ p = Path(new_ws).expanduser().resolve()
315
+ if not p.exists() or not p.is_dir():
316
+ self._error(400, f"Directory not found or not a directory: {p}")
317
+ return
318
+ with self._ws_lock:
319
+ self.config.agent.workspace_dir = str(p)
320
+ self._json_response({"workspace": str(p), "ok": True})
321
+
322
+ def _handle_shell(self):
323
+ """Interactive PowerShell — persistent session with SSE streaming output."""
324
+ if not self._require_auth("shell"):
325
+ return
326
+
327
+ body = self._read_body()
328
+ if body is None:
329
+ return # error response already sent by _read_body
330
+ sid = body.get("session", "")
331
+ command = body.get("command", "")
332
+ action = body.get("action", "send")
333
+
334
+ # Open new session
335
+ if action == "open":
336
+ with self._ws_lock:
337
+ ws = self.config.agent.workspace_dir
338
+ sid = shell_open(ws)
339
+ default_prompt = "PS> " if os.name == "nt" else "$ "
340
+ _, _, _, prompt = get_shell_sessions().get(sid, (None, None, None, default_prompt))
341
+ self._json_response({"session": sid, "prompt": prompt})
342
+ return
343
+
344
+ # Close session
345
+ if action == "close":
346
+ shell_close(sid)
347
+ self._json_response({"ok": True})
348
+ return
349
+
350
+ # Send command to persistent session
351
+ if not command:
352
+ self._error(400, "Missing 'command'")
353
+ return
354
+ if not sid:
355
+ self._error(400, "Missing 'session'. Open a session first.")
356
+ return
357
+
358
+ # Safety check: run command through the same guard as the agent
359
+ from .safety_guard import SafetyGuard
360
+ with self._ws_lock:
361
+ ws = self.config.agent.workspace_dir
362
+ guard = SafetyGuard(ws)
363
+ safety = guard.check_shell(command)
364
+ if not safety.allowed:
365
+ logger.warning("⛔ Blocked shell command [%s]: %s", sid[:6], command[:120])
366
+ self._error(403, f"Command blocked: {safety.reason}")
367
+ return
368
+ if safety.warnings:
369
+ for w in safety.warnings:
370
+ logger.warning("⚠️ Shell warning [%s]: %s", sid[:6], w)
371
+
372
+ logger.info("💻 [%s] %s", sid[:6], command[:120])
373
+
374
+ proc, outq, lock, prompt = shell_ensure(sid, ws)
375
+ if not proc:
376
+ self._error(500, "Shell process not running")
377
+ return
378
+
379
+ # Drain any stale output, send command
380
+ with lock:
381
+ while not outq.empty():
382
+ try: outq.get_nowait()
383
+ except queue.Empty: break
384
+ proc.stdin.write((command + "\n").encode("utf-8"))
385
+ proc.stdin.flush()
386
+
387
+ # Stream output via SSE
388
+ self.send_response(200)
389
+ self._cors()
390
+ self.send_header("Content-Type", "text/event-stream")
391
+ self.send_header("Cache-Control", "no-cache")
392
+ self.end_headers()
393
+
394
+ # Adaptive silence timeout: short initial wait, longer between lines
395
+ FAST_TIMEOUT = 0.6 # initial wait for first output (per cycle)
396
+ SLOW_TIMEOUT = 4.0 # silence between output lines
397
+ FIRST_OUTPUT_DEADLINE = time.time() + 10.0 # max wait for any output at all
398
+ deadline = time.time() + 120
399
+ silence_dl = time.time() + FAST_TIMEOUT
400
+ has_output = False
401
+ while time.time() < deadline:
402
+ try:
403
+ line = outq.get(timeout=0.2)
404
+ try:
405
+ self.wfile.write(
406
+ f"data: {json.dumps({'text': line})}\n\n".encode("utf-8")
407
+ )
408
+ self.wfile.flush()
409
+ except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError):
410
+ return # client disconnected
411
+ has_output = True
412
+ silence_dl = time.time() + SLOW_TIMEOUT
413
+ except queue.Empty:
414
+ pass
415
+ if time.time() > silence_dl:
416
+ if not has_output and time.time() < FIRST_OUTPUT_DEADLINE:
417
+ silence_dl = time.time() + FAST_TIMEOUT # keep waiting for first output
418
+ continue
419
+ break # silence deadline passed: either got output or gave up on first
420
+
421
+ try:
422
+ self.wfile.write(f"event: done\ndata: {json.dumps({'done': True})}\n\n".encode("utf-8"))
423
+ self.wfile.flush()
424
+ except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError):
425
+ pass # client disconnected
426
+
427
+ def _serve_static(self, rel_path: str):
428
+ """Serve a static file from the web/ directory."""
429
+ web_root = Path(__file__).parent / "web"
430
+ file_path = web_root / rel_path
431
+ if not file_path.exists() or not file_path.is_file():
432
+ self._error(404, f"Not found: {self.path}")
433
+ return
434
+ # Safety: prevent path traversal
435
+ try:
436
+ file_path.resolve().relative_to(web_root.resolve())
437
+ except ValueError:
438
+ self._error(403, "Forbidden")
439
+ return
440
+ content = file_path.read_bytes()
441
+ ct = "text/css" if rel_path.endswith(".css") else "application/javascript"
442
+ self.send_response(200)
443
+ self._cors()
444
+ self.send_header("Content-Type", ct + "; charset=utf-8")
445
+ self.send_header("Content-Length", str(len(content)))
446
+ self.send_header("Cache-Control", "public, max-age=3600")
447
+ self.end_headers()
448
+ try:
449
+ self.wfile.write(content)
450
+ except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError):
451
+ pass # client disconnected
452
+
453
+ def _serve_spa(self):
454
+ """Serve the single-page web UI from web/ directory."""
455
+ for fname in ("web/index.html", "web_ui.html"):
456
+ spa_html = Path(__file__).parent / fname
457
+ if spa_html.exists():
458
+ content = spa_html.read_text(encoding="utf-8")
459
+ break
460
+ else:
461
+ content = "<h1>ATA Coder Web UI</h1><p>web/index.html not found.</p>"
462
+ self.send_response(200)
463
+ self._cors()
464
+ self.send_header("Content-Type", "text/html; charset=utf-8")
465
+ self.end_headers()
466
+ try:
467
+ self.wfile.write(content.encode("utf-8"))
468
+ except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError):
469
+ pass # client disconnected
470
+
471
+ def _handle_list_sessions(self):
472
+ if not self._require_auth("sessions"):
473
+ return
474
+ self._json_response({"sessions": self.store.list_sessions()})
475
+
476
+ def _handle_get_session(self, sid: str):
477
+ meta = self.store.get_meta(sid)
478
+ if not meta:
479
+ self._error(404, "Session not found")
480
+ return
481
+ info = dict(meta)
482
+ agent = self.store.get(sid)
483
+ if agent:
484
+ info["conversation"] = agent.get_conversation_summary()
485
+ self._json_response(info)
486
+
487
+ def _handle_delete_session(self, sid: str):
488
+ if self.store.delete(sid):
489
+ self._json_response({"status": "deleted", "session_id": sid})
490
+ else:
491
+ self._error(404, "Session not found")
492
+
493
+ # ── Chat (non-streaming) ────────────────────────────────────────────
494
+
495
+ def _handle_chat(self):
496
+ if not self._require_auth("chat"):
497
+ return
498
+
499
+ req = self._parse_chat_request()
500
+ if req is None:
501
+ return
502
+
503
+ logger.info("📩 [%s] %.200s", time.strftime('%H:%M:%S'), self._sanitize_log(req["message"]))
504
+
505
+ is_new_session = req["session_id"] is None or self.store.get(req["session_id"]) is None
506
+ sid, agent = self.store.get_or_create(req["session_id"], self.config, req["skill"])
507
+
508
+ if req["model_override"]:
509
+ agent.llm.set_model(req["model_override"])
510
+ agent.llm.register_tools(agent._all_tools)
511
+ if req["thinking_override"]:
512
+ agent.llm.config.thinking_strength = req["thinking_override"]
513
+
514
+ try:
515
+ import asyncio as _asyncio
516
+ response = _asyncio.run(agent.run(req["message"], stream=False, skill_name=req["skill"] or None,
517
+ reset_context=is_new_session))
518
+ except Exception as e:
519
+ logger.exception("Agent run failed: %s", e)
520
+ self._error(500, "Internal server error")
521
+ return
522
+
523
+ self.store.update_meta(
524
+ sid,
525
+ messages=len(agent._state.messages),
526
+ tool_calls=agent._state.tool_call_count,
527
+ )
528
+
529
+ self._json_response({
530
+ "session_id": sid,
531
+ "response": response,
532
+ "tool_calls": agent._state.tool_call_count,
533
+ "tokens": {
534
+ "prompt": agent.llm.total_prompt_tokens,
535
+ "completion": agent.llm.total_completion_tokens,
536
+ "total": agent.llm.total_tokens,
537
+ },
538
+ })
539
+
540
+ # ── Chat (SSE streaming) ────────────────────────────────────────────
541
+
542
+ def _handle_chat_stream(self):
543
+ if not self._require_auth("chat/stream"):
544
+ return
545
+
546
+ req = self._parse_chat_request()
547
+ if req is None:
548
+ return
549
+
550
+ logger.info("📩 [%s] %.200s skill=%s model=%s thinking=%s",
551
+ time.strftime('%H:%M:%S'), req["message"],
552
+ req["skill"] or "-", req["model_override"] or "-", req["thinking_override"] or "-")
553
+
554
+ is_new_session = req["session_id"] is None or self.store.get(req["session_id"]) is None
555
+ sid, agent = self.store.get_or_create(req["session_id"], self.config, req["skill"])
556
+
557
+ if req["model_override"]:
558
+ agent.llm.set_model(req["model_override"])
559
+ agent.llm.register_tools(agent._all_tools)
560
+ if req["thinking_override"]:
561
+ agent.llm.config.thinking_strength = req["thinking_override"]
562
+
563
+ self.send_response(200)
564
+ self._cors()
565
+ self.send_header("Content-Type", "text/event-stream")
566
+ self.send_header("Cache-Control", "no-cache")
567
+ self.send_header("Connection", "close")
568
+ self.send_header("X-Accel-Buffering", "no")
569
+ self.end_headers()
570
+
571
+ # ── Streaming loop (async, runs inside asyncio.run()) ──
572
+ import asyncio as _asyncio
573
+
574
+ async def _stream_agent():
575
+ events: _asyncio.Queue = _asyncio.Queue()
576
+
577
+ def _push_event(evt):
578
+ """Filter events through SSE converter; skip non-streamable ones."""
579
+ tup = self._sse_event_tuple(evt)
580
+ if tup is not None:
581
+ events.put_nowait(tup)
582
+
583
+ agent.on_event(_push_event)
584
+
585
+ result_holder: dict[str, Any] = {"response": "", "error": None}
586
+
587
+ async def _run_agent():
588
+ try:
589
+ logger.info("▶ Agent started")
590
+ result_holder["response"] = await agent.run(
591
+ req["message"], stream=True, skill_name=req["skill"] or None,
592
+ reset_context=is_new_session
593
+ )
594
+ logger.info("✓ Agent completed")
595
+ except Exception as exc:
596
+ logger.info("✗ Agent error: %s", exc)
597
+ result_holder["error"] = str(exc)
598
+
599
+ agent_task = _asyncio.create_task(_run_agent())
600
+
601
+ # Stream events until agent task completes
602
+ while not agent_task.done():
603
+ try:
604
+ evt_type, payload = await _asyncio.wait_for(events.get(), timeout=0.1)
605
+ except _asyncio.TimeoutError:
606
+ continue
607
+
608
+ sse_data = _format_sse_data(evt_type, payload)
609
+ if sse_data is None:
610
+ continue
611
+ line = f"event: {evt_type}\ndata: {sse_data}\n\n"
612
+ try:
613
+ self.wfile.write(line.encode("utf-8"))
614
+ self.wfile.flush()
615
+ except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError):
616
+ agent_task.cancel()
617
+ return
618
+
619
+ # Drain remaining events
620
+ while not events.empty():
621
+ try:
622
+ evt_type, payload = events.get_nowait()
623
+ sse_data = _format_sse_data(evt_type, payload)
624
+ if sse_data:
625
+ line = f"event: {evt_type}\ndata: {sse_data}\n\n"
626
+ self.wfile.write(line.encode("utf-8"))
627
+ self.wfile.flush()
628
+ except _asyncio.QueueEmpty:
629
+ break
630
+
631
+ return result_holder
632
+
633
+ result_holder = _asyncio.run(_stream_agent())
634
+
635
+ # Send final event with session_id so frontend can reuse it
636
+ final = json.dumps({
637
+ "session_id": sid,
638
+ "response": result_holder["response"] or "",
639
+ "error": result_holder["error"],
640
+ })
641
+ try:
642
+ self.wfile.write(f"event: done\ndata: {final}\n\n".encode("utf-8"))
643
+ self.wfile.flush()
644
+ except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError):
645
+ pass
646
+
647
+ self.store.update_meta(
648
+ sid,
649
+ messages=len(agent._state.messages),
650
+ tool_calls=agent._state.tool_call_count,
651
+ )
652
+
653
+ # ── SSE event builder (shared across stream handlers) ─────────────────
654
+
655
+ @staticmethod
656
+ def _sanitize_log(text: str) -> str:
657
+ """Strip common secret patterns from text before logging."""
658
+ import re as _re
659
+ # API key patterns (sk-..., rk-, etc.)
660
+ text = _re.sub(r'sk-[a-zA-Z0-9_-]{20,}', 'sk-***', text)
661
+ text = _re.sub(r'rk-[a-zA-Z0-9_-]{20,}', 'rk-***', text)
662
+ # Bearer tokens
663
+ text = _re.sub(r'Bearer\s+[a-zA-Z0-9._\-]+', 'Bearer ***', text)
664
+ # AWS-style keys
665
+ text = _re.sub(r'AKIA[0-9A-Z]{16}', 'AKIA***', text)
666
+ text = _re.sub(r'AIza[0-9A-Za-z\-_]{35}', 'AIza***', text)
667
+ return text
668
+
669
+ @staticmethod
670
+ def _sse_event_tuple(event: Any):
671
+ """Convert an AgentEvent into an (event_type, payload) tuple, or None if skipped."""
672
+ from .core import (TextDeltaEvent, ToolCallEvent, ToolResultEvent,
673
+ ToolStreamEvent, CompleteEvent, ErrorEvent, ReasoningEvent, ThinkingEvent)
674
+
675
+ if isinstance(event, TextDeltaEvent):
676
+ logger.debug("📤 text: %s", AgentAPIHandler._sanitize_log(event.text[:120]))
677
+ return ("text", event.text)
678
+ elif isinstance(event, ReasoningEvent):
679
+ logger.debug("🧠 thinking: %.100s", AgentAPIHandler._sanitize_log(event.text))
680
+ return ("thinking", event.text[:200])
681
+ elif isinstance(event, ThinkingEvent):
682
+ return None
683
+ elif isinstance(event, ToolStreamEvent):
684
+ # Real-time shell output — stream immediately to frontend
685
+ return ("tool_stream", {"tool": event.tool_name, "chunk": event.chunk})
686
+ elif isinstance(event, ToolCallEvent):
687
+ logger.debug("🔧 %s %s", event.tool_name, brief_args(event.arguments))
688
+ return ("tool_call", {"name": event.tool_name, "arguments": event.arguments, "source": event.source})
689
+ elif isinstance(event, ToolResultEvent):
690
+ if event.result.success:
691
+ logger.debug(" ✅ %s", AgentAPIHandler._sanitize_log((event.result.output or "")[:100].replace("\n"," ")))
692
+ else:
693
+ logger.debug(" ❌ %s", AgentAPIHandler._sanitize_log((event.result.error or "?")[:100]))
694
+ return ("tool_result", {"name": event.tool_name, "success": event.result.success,
695
+ "output": (event.result.output or "")[:4000], "error": event.result.error})
696
+ elif isinstance(event, ErrorEvent):
697
+ logger.info("💥 ERROR: %s", AgentAPIHandler._sanitize_log(event.error))
698
+ return ("error", {"error": event.error})
699
+ elif isinstance(event, CompleteEvent):
700
+ logger.info("🏁 Complete — %d tools, %.1fs", event.total_tool_calls, event.total_time)
701
+ return ("complete", {"tool_calls": event.total_tool_calls, "time": event.total_time})
702
+ return None
703
+
704
+
705
+ def _format_sse_data(evt_type: str, payload: Any) -> str | None:
706
+ """Format an (event_type, payload) tuple into a JSON string for SSE output."""
707
+ if evt_type == "text":
708
+ return json.dumps({"type": "text", "text": payload}, ensure_ascii=False)
709
+ elif evt_type == "tool_stream":
710
+ return json.dumps({
711
+ "type": "tool_stream",
712
+ "tool": payload.get("tool", ""),
713
+ "chunk": payload.get("chunk", ""),
714
+ }, ensure_ascii=False)
715
+ elif evt_type == "thinking":
716
+ return json.dumps({"type": "thinking", "text": payload}, ensure_ascii=False)
717
+ elif evt_type == "tool_call":
718
+ # Send full arguments to the frontend — never truncate.
719
+ # brief_args() is only for server-side logging (line 677).
720
+ args = payload.get("arguments", {})
721
+ # Sanitize surrogates in argument values (defense against UTF-8 crashes)
722
+ from .utils import sanitize_surrogates
723
+ args = sanitize_surrogates(args)
724
+ return json.dumps({
725
+ "type": "tool_call",
726
+ "tool": payload["name"],
727
+ "args_summary": brief_args(args),
728
+ "args": args,
729
+ }, ensure_ascii=False)
730
+ elif evt_type == "tool_result":
731
+ return json.dumps({
732
+ "type": "tool_result",
733
+ "tool": payload["name"],
734
+ "ok": payload["success"],
735
+ "output": payload.get("output", ""),
736
+ }, ensure_ascii=False)
737
+ elif evt_type == "error":
738
+ return json.dumps({"type": "error", "error": payload.get("error", "")}, ensure_ascii=False)
739
+ elif evt_type == "complete":
740
+ return json.dumps({
741
+ "type": "complete",
742
+ "tools": payload["tool_calls"],
743
+ "time": payload["time"],
744
+ }, ensure_ascii=False)
745
+ return json.dumps(payload, ensure_ascii=False)
746
+
747
+
748
+ # ═══════════════════════════════════════════════════════════════════════════════
749
+ # Server factory
750
+ # ═══════════════════════════════════════════════════════════════════════════════
751
+
752
+ # Shell management → server_shell.py
753
+
754
+ def create_server(
755
+ config: AppConfig | None = None,
756
+ host: str = "127.0.0.1",
757
+ port: int = 8000,
758
+ ) -> HTTPServer:
759
+ """Create and configure the HTTP API server."""
760
+
761
+ # Common mistakes: URLs, port numbers
762
+ if host.startswith("http://") or host.startswith("https://"):
763
+ host = host.split("://", 1)[1].rstrip("/")
764
+ if host.isdigit():
765
+ port = int(host)
766
+ host = "127.0.0.1"
767
+
768
+ config = config or get_config()
769
+
770
+ AgentAPIHandler.config = config
771
+ AgentAPIHandler.store = SessionStore()
772
+
773
+ try:
774
+ server = HTTPServer((host, port), AgentAPIHandler)
775
+ except OSError as e:
776
+ if "10048" in str(e) or "98" in str(e) or "Address already in use" in str(e):
777
+ logger.error("Port %d is already in use. Use --port to pick another, "
778
+ "or check: netstat -ano | findstr :%d", port, port)
779
+ raise SystemExit(1)
780
+ raise
781
+
782
+ # Close idle connections after 30s to prevent file descriptor exhaustion
783
+ server.socket.settimeout(30.0)
784
+ server.timeout = 30
785
+
786
+ logger.info("Server created: %s:%d", host, port)
787
+ return server
788
+
789
+
790
+ # ═══════════════════════════════════════════════════════════════════════════════
791
+ # Entry point
792
+ def _detect_lan_ip() -> str | None:
793
+ """Detect the LAN IP address for mobile/tablet access."""
794
+ try:
795
+ import socket
796
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
797
+ s.settimeout(0.1)
798
+ s.connect(("8.8.8.8", 80))
799
+ ip = s.getsockname()[0]
800
+ s.close()
801
+ return ip
802
+ except Exception:
803
+ pass
804
+ # Fallback: iterate network interfaces
805
+ try:
806
+ import socket
807
+ hostname = socket.gethostname()
808
+ ip = socket.gethostbyname(hostname)
809
+ if ip and not ip.startswith("127."):
810
+ return ip
811
+ except Exception:
812
+ pass
813
+ return None
814
+
815
+
816
+ # ═══════════════════════════════════════════════════════════════════════════════
817
+
818
+ def main():
819
+ import argparse
820
+ parser = argparse.ArgumentParser(description="ATA Coder API Server")
821
+ parser.add_argument("--host", default="127.0.0.1", help="Bind host (127.0.0.1 = local only, 0.0.0.0 = LAN accessible)")
822
+ parser.add_argument("--port", "-p", type=int, default=8000, help="Bind port")
823
+ parser.add_argument("--local-only", action="store_true", help="Bind to 127.0.0.1 only (no LAN)")
824
+ parser.add_argument("--allow-all", "-A", action="store_true", help="Skip all permission prompts")
825
+ parser.add_argument("--model", "-m", help="Model name")
826
+ parser.add_argument("--verbose", "-v", action="store_true")
827
+ args = parser.parse_args()
828
+
829
+ # Server mode: verbose by default so you can see what the agent is doing
830
+ log_level = logging.DEBUG if args.verbose else logging.INFO
831
+ logging.basicConfig(
832
+ level=log_level,
833
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
834
+ )
835
+
836
+ if args.allow_all:
837
+ os.environ["ATA_CODER_ALLOW_ALL"] = "1"
838
+
839
+ config = get_config()
840
+ if args.model:
841
+ config.llm.model = args.model
842
+
843
+ if args.local_only:
844
+ args.host = "127.0.0.1"
845
+
846
+ # Security: warn if binding to all interfaces without authentication
847
+ if args.host == "0.0.0.0" and not os.environ.get("ATA_CODER_API_TOKEN"):
848
+ logger.warning(
849
+ "⚠️ Binding to 0.0.0.0 WITHOUT an API token — "
850
+ "anyone on the network can access the agent. "
851
+ "Set ATA_CODER_API_TOKEN env var or use --local-only."
852
+ )
853
+
854
+ server = create_server(config, args.host, args.port)
855
+
856
+ # Detect LAN IP for mobile access
857
+ lan_ip = _detect_lan_ip() if args.host == "0.0.0.0" else None
858
+
859
+ print("""
860
+ ╔══════════════════════════════════════════════════╗
861
+ ║ ATA Coder — Web UI ║
862
+ ╠══════════════════════════════════════════════════╣""")
863
+ print(f"║ Local: http://127.0.0.1:{args.port:<29}║")
864
+ if lan_ip:
865
+ print(f"║ LAN: http://{lan_ip}:{args.port:<29}║")
866
+ else:
867
+ print("║ LAN: (use --host 0.0.0.0 for LAN access) ║")
868
+ print(f"""║ Model: {config.llm.model:<34}║
869
+ ║ Tools: {len(TOOL_DEFINITIONS):<34}║
870
+ ╚══════════════════════════════════════════════════╝
871
+ """)
872
+
873
+ try:
874
+ server.serve_forever()
875
+ except KeyboardInterrupt:
876
+ print("\nShutting down...")
877
+ shell_close_all()
878
+ server.shutdown()
879
+
880
+
881
+ if __name__ == "__main__":
882
+ main()