zerochat 7.3.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 (130) hide show
  1. zerochat-7.3.0.dist-info/METADATA +70 -0
  2. zerochat-7.3.0.dist-info/RECORD +130 -0
  3. zerochat-7.3.0.dist-info/WHEEL +5 -0
  4. zerochat-7.3.0.dist-info/entry_points.txt +2 -0
  5. zerochat-7.3.0.dist-info/licenses/LICENSE +34 -0
  6. zerochat-7.3.0.dist-info/top_level.txt +2 -0
  7. zerochat.py +2004 -0
  8. zerochat_runtime/__init__.py +1 -0
  9. zerochat_runtime/assets/css/base.css +199 -0
  10. zerochat_runtime/assets/css/components/composer.css +1258 -0
  11. zerochat_runtime/assets/css/components/debug.css +777 -0
  12. zerochat_runtime/assets/css/components/header.css +97 -0
  13. zerochat_runtime/assets/css/components/icons.css +39 -0
  14. zerochat_runtime/assets/css/components/markdown.css +685 -0
  15. zerochat_runtime/assets/css/components/messages.css +473 -0
  16. zerochat_runtime/assets/css/components/modals.css +1319 -0
  17. zerochat_runtime/assets/css/components/sidebar.css +622 -0
  18. zerochat_runtime/assets/css/components/tools.css +1223 -0
  19. zerochat_runtime/assets/css/layout.css +171 -0
  20. zerochat_runtime/assets/css/print.css +120 -0
  21. zerochat_runtime/assets/css/styles.css +18 -0
  22. zerochat_runtime/assets/css/theme-overrides.css +555 -0
  23. zerochat_runtime/assets/css/tokens.css +165 -0
  24. zerochat_runtime/assets/help/architecture.html +247 -0
  25. zerochat_runtime/assets/help/composer.html +134 -0
  26. zerochat_runtime/assets/help/conversations.html +105 -0
  27. zerochat_runtime/assets/help/debug.html +108 -0
  28. zerochat_runtime/assets/help/en/architecture.html +247 -0
  29. zerochat_runtime/assets/help/en/composer.html +134 -0
  30. zerochat_runtime/assets/help/en/conversations.html +104 -0
  31. zerochat_runtime/assets/help/en/debug.html +105 -0
  32. zerochat_runtime/assets/help/en/gemini-free.html +39 -0
  33. zerochat_runtime/assets/help/en/index.html +191 -0
  34. zerochat_runtime/assets/help/en/learning.html +71 -0
  35. zerochat_runtime/assets/help/en/mcp.html +143 -0
  36. zerochat_runtime/assets/help/en/openrouter-free.html +39 -0
  37. zerochat_runtime/assets/help/en/profiles.html +173 -0
  38. zerochat_runtime/assets/help/en/rag.html +130 -0
  39. zerochat_runtime/assets/help/en/reasoning-telemetry.html +108 -0
  40. zerochat_runtime/assets/help/en/tools-agent.html +133 -0
  41. zerochat_runtime/assets/help/en/webllm.html +355 -0
  42. zerochat_runtime/assets/help/gemini-free.html +39 -0
  43. zerochat_runtime/assets/help/help.css +604 -0
  44. zerochat_runtime/assets/help/help.js +51 -0
  45. zerochat_runtime/assets/help/index.html +194 -0
  46. zerochat_runtime/assets/help/learning.html +71 -0
  47. zerochat_runtime/assets/help/mcp.html +143 -0
  48. zerochat_runtime/assets/help/openrouter-free.html +39 -0
  49. zerochat_runtime/assets/help/profiles.html +174 -0
  50. zerochat_runtime/assets/help/rag.html +130 -0
  51. zerochat_runtime/assets/help/reasoning-telemetry.html +111 -0
  52. zerochat_runtime/assets/help/tools-agent.html +133 -0
  53. zerochat_runtime/assets/help/webllm.html +359 -0
  54. zerochat_runtime/assets/js/agent-core.js +1553 -0
  55. zerochat_runtime/assets/js/api.js +844 -0
  56. zerochat_runtime/assets/js/app.js +2558 -0
  57. zerochat_runtime/assets/js/attachments.js +220 -0
  58. zerochat_runtime/assets/js/charts.js +338 -0
  59. zerochat_runtime/assets/js/chat-engine.js +566 -0
  60. zerochat_runtime/assets/js/config-store.js +207 -0
  61. zerochat_runtime/assets/js/context-manager.js +584 -0
  62. zerochat_runtime/assets/js/conversation-service.js +484 -0
  63. zerochat_runtime/assets/js/cookies.js +688 -0
  64. zerochat_runtime/assets/js/data-reset-service.js +136 -0
  65. zerochat_runtime/assets/js/debug.js +508 -0
  66. zerochat_runtime/assets/js/defaults.js +16 -0
  67. zerochat_runtime/assets/js/export.js +179 -0
  68. zerochat_runtime/assets/js/file-parser.js +2088 -0
  69. zerochat_runtime/assets/js/generation-controller.js +417 -0
  70. zerochat_runtime/assets/js/i18n.js +1483 -0
  71. zerochat_runtime/assets/js/icons.js +156 -0
  72. zerochat_runtime/assets/js/ingestionEngine.js +436 -0
  73. zerochat_runtime/assets/js/markdown.js +588 -0
  74. zerochat_runtime/assets/js/mcp.js +1271 -0
  75. zerochat_runtime/assets/js/message-turns.js +62 -0
  76. zerochat_runtime/assets/js/profile-backup.js +118 -0
  77. zerochat_runtime/assets/js/profile-export-bundle.js +481 -0
  78. zerochat_runtime/assets/js/profile-repository.js +213 -0
  79. zerochat_runtime/assets/js/providers-webllm.js +486 -0
  80. zerochat_runtime/assets/js/providers.js +1560 -0
  81. zerochat_runtime/assets/js/rag-index.js +295 -0
  82. zerochat_runtime/assets/js/rag-service.js +530 -0
  83. zerochat_runtime/assets/js/rag-ui.js +997 -0
  84. zerochat_runtime/assets/js/ragStorage.js +676 -0
  85. zerochat_runtime/assets/js/sandbox.js +449 -0
  86. zerochat_runtime/assets/js/state.js +704 -0
  87. zerochat_runtime/assets/js/storage-db.js +144 -0
  88. zerochat_runtime/assets/js/tool-cards.js +330 -0
  89. zerochat_runtime/assets/js/tool-security.js +653 -0
  90. zerochat_runtime/assets/js/tools/README.md +50 -0
  91. zerochat_runtime/assets/js/tools/builtin/agent-checkpoint.tool.js +212 -0
  92. zerochat_runtime/assets/js/tools/builtin/download-pdf.tool.js +111 -0
  93. zerochat_runtime/assets/js/tools/builtin/execute-javascript.tool.js +219 -0
  94. zerochat_runtime/assets/js/tools/builtin/fetch-web-page.tool.js +112 -0
  95. zerochat_runtime/assets/js/tools/builtin/list-documents.tool.js +117 -0
  96. zerochat_runtime/assets/js/tools/builtin/read-knowledge-chunk.tool.js +129 -0
  97. zerochat_runtime/assets/js/tools/builtin/read-knowledge-image.tool.js +95 -0
  98. zerochat_runtime/assets/js/tools/builtin/render-chart.tool.js +90 -0
  99. zerochat_runtime/assets/js/tools/builtin/search-knowledge-base.tool.js +124 -0
  100. zerochat_runtime/assets/js/tools/builtin/search-web.tool.js +125 -0
  101. zerochat_runtime/assets/js/tools/tool-manifest.js +53 -0
  102. zerochat_runtime/assets/js/tools/tool-runtime.js +77 -0
  103. zerochat_runtime/assets/js/ui-composer.js +319 -0
  104. zerochat_runtime/assets/js/ui-conversation.js +655 -0
  105. zerochat_runtime/assets/js/ui-dialogs.js +132 -0
  106. zerochat_runtime/assets/js/ui-generation-status.js +119 -0
  107. zerochat_runtime/assets/js/ui-inspector.js +804 -0
  108. zerochat_runtime/assets/js/ui-mcp.js +604 -0
  109. zerochat_runtime/assets/js/ui-profiles.js +824 -0
  110. zerochat_runtime/assets/js/ui-reasoning.js +146 -0
  111. zerochat_runtime/assets/js/ui-settings.js +825 -0
  112. zerochat_runtime/assets/js/ui-shell.js +215 -0
  113. zerochat_runtime/assets/js/ui-sidebar.js +429 -0
  114. zerochat_runtime/assets/js/ui-telemetry.js +404 -0
  115. zerochat_runtime/assets/js/ui-transfer.js +262 -0
  116. zerochat_runtime/assets/js/utils.js +216 -0
  117. zerochat_runtime/assets/js/vendor/orama.browser.js +11 -0
  118. zerochat_runtime/assets/js/web-browser.js +569 -0
  119. zerochat_runtime/assets/js/web-search.js +519 -0
  120. zerochat_runtime/assets/manifest.webmanifest +20 -0
  121. zerochat_runtime/assets/services/dummy_mcp/dummy_mcp_server.py +47 -0
  122. zerochat_runtime/assets/services/dummy_mcp/service.json +22 -0
  123. zerochat_runtime/assets/services/lsp/installer.json +9 -0
  124. zerochat_runtime/assets/services/lsp/service.json +22 -0
  125. zerochat_runtime/assets/services/memory/installer.json +9 -0
  126. zerochat_runtime/assets/services/memory/service.json +24 -0
  127. zerochat_runtime/assets/services/playwright/installer.json +10 -0
  128. zerochat_runtime/assets/services/playwright/service.json +39 -0
  129. zerochat_runtime/assets/sw.js +164 -0
  130. zerochat_runtime/assets/zerochat.html +671 -0
zerochat.py ADDED
@@ -0,0 +1,2004 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ZeroChat - Backend Local Unificado y Gestor de Entorno
4
+
5
+ Proporciona:
6
+ 1. Auto-creación, actualización y ejecución en el entorno virtual local `./zerochat`.
7
+ 2. Servidor local HTTP y Server-Sent Events (SSE) con autenticación estricta por token efímero.
8
+ 3. Herramientas locales seguras: read_file, edit_file, list_directory, execute_command.
9
+ 4. Apertura automática del navegador apuntando a zerochat.html con token en el fragmento hash.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import atexit
16
+ import datetime
17
+ import hmac
18
+ import importlib.metadata
19
+ import importlib.resources
20
+ import json
21
+ import os
22
+ import platform
23
+ import queue
24
+ import re
25
+ import secrets
26
+ import shutil
27
+ import shlex
28
+ import signal
29
+ import subprocess
30
+ import sys
31
+ import threading
32
+ import time
33
+ import traceback
34
+ import urllib.request
35
+ from urllib.parse import urlencode
36
+ import venv
37
+ import webbrowser
38
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
39
+ from pathlib import Path
40
+
41
+ def _read_source_version(filename: str) -> str | None:
42
+ """Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
43
+ try:
44
+ version_file = Path(__file__).resolve().parent / filename
45
+ if version_file.is_file():
46
+ content = version_file.read_text(encoding="utf-8")
47
+ match = re.search(r'version\s*=\s*"([0-9]+\.[0-9]+\.[0-9]+)"', content)
48
+ if match:
49
+ return match.group(1)
50
+ except Exception:
51
+ pass
52
+ return None
53
+
54
+
55
+ def _read_backend_version() -> str:
56
+ """Devuelve la versión publicada del backend, sin depender de package.json instalado."""
57
+ try:
58
+ return importlib.metadata.version("zerochat")
59
+ except importlib.metadata.PackageNotFoundError:
60
+ return _read_source_version("pyproject.toml") or "7.3.0"
61
+
62
+
63
+ def _read_ui_version() -> str:
64
+ """Devuelve la versión de la interfaz cuando se ejecuta desde el repositorio."""
65
+ try:
66
+ pkg_path = Path(__file__).resolve().parent / "package.json"
67
+ if pkg_path.is_file():
68
+ data = json.loads(pkg_path.read_text(encoding="utf-8"))
69
+ if isinstance(data.get("version"), str):
70
+ return data["version"].strip()
71
+ except Exception:
72
+ pass
73
+ return BACKEND_PACKAGE_VERSION
74
+
75
+
76
+ def compatibility_version(version: str) -> str:
77
+ """La interfaz y el backend son compatibles si comparten major.minor."""
78
+ parts = version.split(".")
79
+ return ".".join(parts[:2]) if len(parts) >= 2 else version
80
+
81
+ BACKEND_PACKAGE_VERSION = _read_backend_version()
82
+ VERSION = compatibility_version(BACKEND_PACKAGE_VERSION)
83
+ UI_VERSION = _read_ui_version()
84
+ DEFAULT_PORT = 6388
85
+ DEFAULT_HOST = "127.0.0.1"
86
+ DEFAULT_UI_URL = "https://albalday.github.io/zerochat/zerochat.html"
87
+ REMOTE_VERSION_URL = "https://raw.githubusercontent.com/albalday/zerochat/master/package.json"
88
+ REMOTE_SCRIPT_URL = "https://raw.githubusercontent.com/albalday/zerochat/master/zerochat.py"
89
+ CONSOLE_STATUS_IDLE_SECONDS = 8.0
90
+ CONSOLE_CONTROL = None
91
+
92
+
93
+ def format_uptime(seconds: float) -> str:
94
+ """Devuelve una duración breve y estable para la línea de estado de consola."""
95
+ total = max(0, int(seconds))
96
+ hours, remainder = divmod(total, 3600)
97
+ minutes, secs = divmod(remainder, 60)
98
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}"
99
+
100
+
101
+ class ConsoleControl:
102
+ """Atajos de consola y línea de estado, solo para terminales interactivos."""
103
+ def __init__(self, server: ThreadingHTTPServer, parser: argparse.ArgumentParser):
104
+ self.server = server
105
+ self.parser = parser
106
+ self.started_at = time.monotonic()
107
+ self.last_activity = self.started_at
108
+ self.stop_event = threading.Event()
109
+ self.lock = threading.Lock()
110
+ self.status_visible = False
111
+ self.enabled = bool(getattr(sys.stdin, "isatty", lambda: False)() and getattr(sys.stdout, "isatty", lambda: False)())
112
+ self._threads: list[threading.Thread] = []
113
+ self._terminal_fd: int | None = None
114
+ self._terminal_state = None
115
+ self._closed = False
116
+
117
+ def start(self):
118
+ if not self.enabled:
119
+ return
120
+ if os.name != "nt":
121
+ try:
122
+ import termios
123
+ import tty
124
+ self._terminal_fd = sys.stdin.fileno()
125
+ self._terminal_state = termios.tcgetattr(self._terminal_fd)
126
+ tty.setcbreak(self._terminal_fd)
127
+ except (OSError, ValueError):
128
+ self._restore_terminal()
129
+ self.enabled = False
130
+ return
131
+ self._threads = [
132
+ threading.Thread(target=self._status_loop, name="zerochat-console-status", daemon=True),
133
+ threading.Thread(target=self._keyboard_loop, name="zerochat-console-input", daemon=True),
134
+ ]
135
+ for thread in self._threads:
136
+ thread.start()
137
+
138
+ def close(self):
139
+ if self._closed:
140
+ return
141
+ self._closed = True
142
+ self.stop_event.set()
143
+ for thread in self._threads:
144
+ if thread is not threading.current_thread():
145
+ thread.join(timeout=1.0)
146
+ self._restore_terminal()
147
+ self.clear_status(final=True)
148
+
149
+ def _restore_terminal(self):
150
+ if self._terminal_fd is None or self._terminal_state is None:
151
+ return
152
+ try:
153
+ import termios
154
+ termios.tcsetattr(self._terminal_fd, termios.TCSADRAIN, self._terminal_state)
155
+ except OSError:
156
+ pass
157
+ finally:
158
+ self._terminal_fd = None
159
+ self._terminal_state = None
160
+
161
+ def clear_status(self, *, final: bool = False):
162
+ if not self.enabled:
163
+ return
164
+ with self.lock:
165
+ if self.status_visible:
166
+ sys.stdout.write("\r\033[2K")
167
+ self.status_visible = False
168
+ if final:
169
+ sys.stdout.write("\r\n")
170
+ sys.stdout.flush()
171
+
172
+ def log(self, message: str, *, flush: bool = True):
173
+ with self.lock:
174
+ if self.enabled and self.status_visible:
175
+ sys.stdout.write("\r\033[2K")
176
+ self.status_visible = False
177
+ print(message, flush=flush)
178
+ self.last_activity = time.monotonic()
179
+
180
+ def show_help(self):
181
+ with self.lock:
182
+ if self.status_visible:
183
+ sys.stdout.write("\r\033[2K")
184
+ self.status_visible = False
185
+ print("\nComandos de consola: [h] ayuda · [x] salir ordenadamente\n", flush=True)
186
+ print(self.parser.format_help().rstrip(), flush=True)
187
+ self.last_activity = time.monotonic()
188
+
189
+ def _render_status(self):
190
+ if not self.enabled:
191
+ return
192
+ uptime = format_uptime(time.monotonic() - self.started_at)
193
+ with self.lock:
194
+ if time.monotonic() - self.last_activity < CONSOLE_STATUS_IDLE_SECONDS:
195
+ return
196
+ sys.stdout.write(f"\r\033[2KZeroChat activo {uptime} · [h] ayuda · [x] salir")
197
+ sys.stdout.flush()
198
+ self.status_visible = True
199
+
200
+ def _status_loop(self):
201
+ while not self.stop_event.wait(1.0):
202
+ self._render_status()
203
+
204
+ def _handle_key(self, key: str):
205
+ if key.lower() == "h":
206
+ self.show_help()
207
+ elif key.lower() == "x":
208
+ self.log(f"[{time.strftime('%H:%M:%S')}] Deteniendo servidor ZeroChat...")
209
+ stop_zerochat_server(self.server)
210
+
211
+ def _keyboard_loop(self):
212
+ if os.name == "nt":
213
+ import msvcrt
214
+ while not self.stop_event.wait(0.05):
215
+ if msvcrt.kbhit():
216
+ self._handle_key(msvcrt.getwch())
217
+ return
218
+
219
+ import select
220
+ while not self.stop_event.is_set():
221
+ ready, _, _ = select.select([sys.stdin], [], [], 0.1)
222
+ if ready:
223
+ self._handle_key(sys.stdin.read(1))
224
+
225
+
226
+ def console_log(message: str, *, flush: bool = True):
227
+ if CONSOLE_CONTROL:
228
+ CONSOLE_CONTROL.log(message, flush=flush)
229
+ else:
230
+ print(message, flush=flush)
231
+
232
+ def get_packaged_assets_root() -> Path | None:
233
+ """Devuelve los recursos incluidos en la distribución PyPI, si existen."""
234
+ try:
235
+ root = importlib.resources.files("zerochat_runtime").joinpath("assets")
236
+ path = Path(str(root))
237
+ if (path / "zerochat.html").is_file():
238
+ return path
239
+ except (ModuleNotFoundError, TypeError):
240
+ pass
241
+ return None
242
+
243
+
244
+ def get_dev_root() -> Path | None:
245
+ """
246
+ Detecta si zerochat.py se está ejecutando en el directorio de desarrollo del repositorio.
247
+ Comprueba si existen zerochat.html, js/ y css/ en el directorio del script o en cwd.
248
+ """
249
+ script_dir = Path(__file__).resolve().parent
250
+ cwd = Path.cwd().resolve()
251
+ for candidate in (script_dir, cwd):
252
+ if (candidate / "zerochat.html").is_file() and (candidate / "js").is_dir() and (candidate / "css").is_dir():
253
+ return candidate
254
+ return None
255
+
256
+
257
+ def get_static_root() -> Path | None:
258
+ """Prioriza el árbol de desarrollo y usa los recursos empaquetados fuera de él."""
259
+ return get_dev_root() or get_packaged_assets_root()
260
+
261
+
262
+ def is_packaged_runtime() -> bool:
263
+ return get_dev_root() is None and get_packaged_assets_root() is not None
264
+
265
+
266
+ def get_venv_dir() -> Path:
267
+ """Devuelve el estado persistente; PyPI nunca crea un venv en el proyecto del usuario."""
268
+ if get_dev_root() is not None:
269
+ return (Path.cwd() / "zerochat").resolve()
270
+
271
+ configured = os.environ.get("ZEROCHAT_DATA_DIR", "").strip()
272
+ if configured:
273
+ return Path(configured).expanduser().resolve()
274
+ if sys.platform.startswith("win"):
275
+ base = Path(os.environ.get("APPDATA", Path.home()))
276
+ elif sys.platform == "darwin":
277
+ base = Path.home() / "Library" / "Application Support"
278
+ else:
279
+ base = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
280
+ return (base / "zerochat").resolve()
281
+
282
+
283
+ def get_daily_token() -> str:
284
+ """Devuelve un token de sesión diario persistido en ./zerochat/config/token.json."""
285
+ config_dir = get_venv_dir() / "config"
286
+ config_dir.mkdir(parents=True, exist_ok=True)
287
+ token_file = config_dir / "token.json"
288
+ today = datetime.date.today().isoformat()
289
+ if token_file.exists():
290
+ try:
291
+ data = json.loads(token_file.read_text(encoding="utf-8"))
292
+ if data.get("date") == today and data.get("token") and isinstance(data["token"], str):
293
+ return data["token"]
294
+ except Exception:
295
+ pass
296
+ token = secrets.token_urlsafe(32)
297
+ try:
298
+ tmp_file = token_file.with_suffix(".tmp")
299
+ tmp_file.write_text(json.dumps({"token": token, "date": today}, indent=2), encoding="utf-8")
300
+ tmp_file.replace(token_file)
301
+ except Exception:
302
+ pass
303
+ return token
304
+
305
+
306
+ # Estado de sesión en memoria (generación diaria por defecto)
307
+ SESSION_TOKEN = get_daily_token()
308
+ ACTIVE_PORT = DEFAULT_PORT
309
+ ACTIVE_HOST = DEFAULT_HOST
310
+
311
+ DETECTED_OS = "windows" if sys.platform.startswith("win") else ("android" if "ANDROID_ROOT" in os.environ else "linux")
312
+
313
+
314
+ def get_venv_python(venv_dir: Path) -> Path:
315
+ """Devuelve el ejecutable de Python del entorno virtual según el SO."""
316
+ if sys.platform.startswith("win"):
317
+ return venv_dir / "Scripts" / "python.exe"
318
+ return venv_dir / "bin" / "python"
319
+
320
+
321
+ def ensure_virtual_environment():
322
+ """
323
+ Comprueba si existe el entorno virtual en ./zerochat. Si no existe, lo crea.
324
+ Si el proceso actual no se está ejecutando bajo dicho entorno, se re-ejecuta.
325
+ """
326
+ if is_packaged_runtime():
327
+ return
328
+
329
+ venv_dir = get_venv_dir()
330
+ venv_py = get_venv_python(venv_dir)
331
+
332
+ # 1. Crear el venv si no existe
333
+ if not venv_py.exists():
334
+ console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Inicializando entorno virtual en {venv_dir}...", flush=True)
335
+ try:
336
+ venv.create(venv_dir, with_pip=True, clear=False)
337
+ console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Entorno virtual preparado con éxito.", flush=True)
338
+ except Exception as err:
339
+ console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Advertencia al crear venv: {err}. Continuando con intérprete actual.", flush=True)
340
+ return
341
+
342
+ # 2. Comprobar si ya estamos ejecutándonos dentro del venv
343
+ try:
344
+ current_py = Path(sys.executable).resolve()
345
+ target_py = venv_py.resolve()
346
+ if current_py != target_py and target_py.exists():
347
+ console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Re-ejecutando bajo {venv_py}...", flush=True)
348
+ # Re-ejecutar con los mismos argumentos
349
+ args = [str(target_py), str(Path(__file__).resolve())] + sys.argv[1:]
350
+ os.execv(str(target_py), args)
351
+ except Exception as err:
352
+ console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Error en re-ejecución: {err}. Continuando.", flush=True)
353
+
354
+
355
+ def parse_version(ver: str) -> tuple[int, ...]:
356
+ """Convierte una cadena de versión semántica en tupla de enteros para comparación."""
357
+ parts = []
358
+ for piece in ver.split("."):
359
+ clean = "".join(filter(str.isdigit, piece))
360
+ if clean:
361
+ parts.append(int(clean))
362
+ return tuple(parts)
363
+
364
+
365
+ def check_version():
366
+ """Comprueba si hay una nueva versión de zerochat.py en el repositorio remoto (solo en modo producción/standalone)."""
367
+ if get_dev_root() is not None or is_packaged_runtime():
368
+ return
369
+ try:
370
+ req = urllib.request.Request(REMOTE_VERSION_URL, headers={"User-Agent": f"ZeroChat/{VERSION}"})
371
+ with urllib.request.urlopen(req, timeout=3) as resp:
372
+ content = resp.read(2048).decode("utf-8", errors="ignore")
373
+ remote_ver = None
374
+ try:
375
+ data = json.loads(content)
376
+ if isinstance(data, dict) and "version" in data and isinstance(data["version"], str):
377
+ remote_ver = data["version"].strip()
378
+ except Exception:
379
+ pass
380
+ if not remote_ver:
381
+ match = re.search(r'["\']?version["\']?\s*[:=]\s*["\'](\d+\.\d+\.\d+)["\']', content)
382
+ if match:
383
+ remote_ver = match.group(1)
384
+ if remote_ver and re.match(r"^\d+(\.\d+)+", remote_ver):
385
+ if parse_version(remote_ver) > parse_version(VERSION):
386
+ console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] ¡Nueva versión disponible! (Local: {VERSION}, Remota: {remote_ver})", flush=True)
387
+ console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Actualiza con: curl -sSL {REMOTE_SCRIPT_URL} -o zerochat.py", flush=True)
388
+ except Exception:
389
+ # Modo offline o timeout ignorado de forma segura
390
+ pass
391
+
392
+
393
+ # ==============================================================================
394
+ # Herramientas Locales Core
395
+ # ==============================================================================
396
+
397
+ def list_directory(path: str = ".", max_depth: int = 1) -> str:
398
+ """Recorre un directorio local y devuelve la lista de archivos y carpetas."""
399
+ try:
400
+ target = Path(path).expanduser().resolve()
401
+ if not target.exists():
402
+ return json.dumps({"success": False, "error": f"La ruta '{path}' no existe."}, ensure_ascii=False)
403
+ if not target.is_dir():
404
+ return json.dumps({"success": False, "error": f"La ruta '{path}' no es un directorio."}, ensure_ascii=False)
405
+
406
+ entries = []
407
+ for entry in os.scandir(target):
408
+ try:
409
+ stat = entry.stat(follow_symlinks=False)
410
+ is_dir = entry.is_dir(follow_symlinks=False)
411
+ entries.append({
412
+ "name": entry.name,
413
+ "path": str(Path(entry.path).resolve()),
414
+ "type": "directory" if is_dir else "file",
415
+ "size_bytes": None if is_dir else stat.st_size,
416
+ "is_symlink": entry.is_symlink()
417
+ })
418
+ except (PermissionError, FileNotFoundError):
419
+ continue
420
+
421
+ entries.sort(key=lambda e: (e["type"] != "directory", e["name"].lower()))
422
+ return json.dumps({
423
+ "success": True,
424
+ "path": str(target),
425
+ "total_items": len(entries),
426
+ "entries": entries
427
+ }, ensure_ascii=False, indent=2)
428
+ except Exception as e:
429
+ return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
430
+
431
+
432
+ def read_file(path: str, start_line: int = 1, max_lines: int = 500, max_bytes: int = 100000) -> str:
433
+ """Lee el contenido de texto de un archivo local con rangos y límites seguros."""
434
+ try:
435
+ target = Path(path).expanduser().resolve()
436
+ if not target.exists():
437
+ return json.dumps({"success": False, "error": f"El archivo '{path}' no existe."}, ensure_ascii=False)
438
+ if not target.is_file():
439
+ return json.dumps({"success": False, "error": f"La ruta '{path}' no es un archivo regular."}, ensure_ascii=False)
440
+
441
+ file_size = target.stat().st_size
442
+ safe_max_bytes = max(1024, min(int(max_bytes), 2000000))
443
+ safe_start_line = max(1, int(start_line))
444
+ safe_max_lines = max(1, min(int(max_lines), 2000))
445
+
446
+ with open(target, "r", encoding="utf-8", errors="replace") as f:
447
+ lines = f.readlines()
448
+
449
+ total_lines = len(lines)
450
+ start_idx = safe_start_line - 1
451
+ end_idx = min(start_idx + safe_max_lines, total_lines)
452
+
453
+ selected_lines = lines[start_idx:end_idx] if start_idx < total_lines else []
454
+ content = "".join(selected_lines)
455
+
456
+ truncated_bytes = False
457
+ if len(content.encode("utf-8")) > safe_max_bytes:
458
+ content = content[:safe_max_bytes]
459
+ truncated_bytes = True
460
+
461
+ return json.dumps({
462
+ "success": True,
463
+ "path": str(target),
464
+ "size_bytes": file_size,
465
+ "total_lines": total_lines,
466
+ "start_line": safe_start_line,
467
+ "lines_returned": len(selected_lines),
468
+ "truncated": truncated_bytes or end_idx < total_lines,
469
+ "content": content
470
+ }, ensure_ascii=False, indent=2)
471
+ except Exception as e:
472
+ return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
473
+
474
+
475
+ def edit_file(path: str, content: str, mode: str = "write", target_content: str = None) -> str:
476
+ """Crea, sobrescribe o edita un archivo de forma atómica."""
477
+ try:
478
+ target = Path(path).expanduser().resolve()
479
+ target.parent.mkdir(parents=True, exist_ok=True)
480
+
481
+ if mode == "append":
482
+ with open(target, "a", encoding="utf-8") as f:
483
+ f.write(content)
484
+ bytes_written = len(content.encode("utf-8"))
485
+ elif mode == "replace_chunk":
486
+ if not target.exists():
487
+ return json.dumps({"success": False, "error": f"El archivo '{path}' no existe para replace_chunk."}, ensure_ascii=False)
488
+ if not target_content:
489
+ return json.dumps({"success": False, "error": "target_content es obligatorio en modo replace_chunk."}, ensure_ascii=False)
490
+
491
+ with open(target, "r", encoding="utf-8", errors="replace") as f:
492
+ existing = f.read()
493
+
494
+ if target_content not in existing:
495
+ return json.dumps({"success": False, "error": "target_content no fue encontrado en el archivo."}, ensure_ascii=False)
496
+
497
+ new_text = existing.replace(target_content, content, 1)
498
+ temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}")
499
+ with open(temp_path, "w", encoding="utf-8") as f:
500
+ f.write(new_text)
501
+ temp_path.replace(target)
502
+ bytes_written = len(new_text.encode("utf-8"))
503
+ else: # write
504
+ temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}")
505
+ with open(temp_path, "w", encoding="utf-8") as f:
506
+ f.write(content)
507
+ temp_path.replace(target)
508
+ bytes_written = len(content.encode("utf-8"))
509
+
510
+ return json.dumps({
511
+ "success": True,
512
+ "path": str(target),
513
+ "mode": mode,
514
+ "bytes_written": bytes_written
515
+ }, ensure_ascii=False, indent=2)
516
+ except Exception as e:
517
+ return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
518
+
519
+
520
+ def execute_command(command: str, cwd: str = ".", timeout_seconds: int = 60) -> str:
521
+ """Ejecuta un comando en la shell del sistema y devuelve stdout y stderr."""
522
+ try:
523
+ target_cwd = Path(cwd).expanduser().resolve()
524
+ if not target_cwd.exists() or not target_cwd.is_dir():
525
+ target_cwd = Path.cwd()
526
+
527
+ proc = subprocess.run(
528
+ command,
529
+ cwd=str(target_cwd),
530
+ shell=True,
531
+ capture_output=True,
532
+ text=True,
533
+ timeout=max(1, min(int(timeout_seconds), 300)),
534
+ encoding="utf-8",
535
+ errors="replace"
536
+ )
537
+ return json.dumps({
538
+ "success": proc.returncode == 0,
539
+ "returncode": proc.returncode,
540
+ "stdout": proc.stdout,
541
+ "stderr": proc.stderr,
542
+ "cwd": str(target_cwd)
543
+ }, ensure_ascii=False, indent=2)
544
+ except subprocess.TimeoutExpired:
545
+ return json.dumps({"success": False, "error": f"Comando excedió el tiempo límite de {timeout_seconds} segundos."}, ensure_ascii=False)
546
+ except Exception as e:
547
+ return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
548
+
549
+
550
+ LOCAL_TOOLS_DEFINITIONS = [
551
+ {
552
+ "name": "list_directory",
553
+ "description": "Lista archivos y carpetas en un directorio local.",
554
+ "inputSchema": {
555
+ "type": "object",
556
+ "properties": {
557
+ "path": {"type": "string", "description": "Ruta relativa o absoluta (por defecto '.')"},
558
+ "max_depth": {"type": "integer", "description": "Profundidad máxima", "default": 1}
559
+ }
560
+ }
561
+ },
562
+ {
563
+ "name": "read_file",
564
+ "description": "Lee el contenido de texto de un archivo local.",
565
+ "inputSchema": {
566
+ "type": "object",
567
+ "properties": {
568
+ "path": {"type": "string", "description": "Ruta del archivo"},
569
+ "start_line": {"type": "integer", "description": "Línea inicial (1-indexed)", "default": 1},
570
+ "max_lines": {"type": "integer", "description": "Número máximo de líneas a leer", "default": 500},
571
+ "max_bytes": {"type": "integer", "description": "Límite máximo de bytes", "default": 100000}
572
+ },
573
+ "required": ["path"]
574
+ }
575
+ },
576
+ {
577
+ "name": "edit_file",
578
+ "description": "Crea, sobrescribe o modifica un archivo local de forma atómica.",
579
+ "inputSchema": {
580
+ "type": "object",
581
+ "properties": {
582
+ "path": {"type": "string", "description": "Ruta del archivo a editar"},
583
+ "content": {"type": "string", "description": "Contenido a escribir o reemplazar"},
584
+ "mode": {"type": "string", "enum": ["write", "append", "replace_chunk"], "default": "write"},
585
+ "target_content": {"type": "string", "description": "Texto exacto a reemplazar cuando mode='replace_chunk'"}
586
+ },
587
+ "required": ["path", "content"]
588
+ }
589
+ },
590
+ {
591
+ "name": "execute_command",
592
+ "description": "Ejecuta un comando en la shell del sistema y captura la salida.",
593
+ "inputSchema": {
594
+ "type": "object",
595
+ "properties": {
596
+ "command": {"type": "string", "description": "Comando a ejecutar"},
597
+ "cwd": {"type": "string", "description": "Directorio de trabajo (por defecto '.')"},
598
+ "timeout_seconds": {"type": "integer", "description": "Tiempo límite en segundos", "default": 60}
599
+ },
600
+ "required": ["command"]
601
+ }
602
+ }
603
+ ]
604
+
605
+ LOCAL_TOOL_HANDLERS = {
606
+ "list_directory": list_directory,
607
+ "read_file": read_file,
608
+ "edit_file": edit_file,
609
+ "execute_command": execute_command
610
+ }
611
+
612
+ # ==============================================================================
613
+ # Servidor HTTP JSON-RPC 2.0 y SSE con Autenticación por Token
614
+ # ==============================================================================
615
+
616
+ def sanitize_log_path(raw_path: str) -> str:
617
+ """Oculta tokens de sesión o parámetros sensibles en la query string para logs seguros."""
618
+ if not raw_path or "?" not in raw_path:
619
+ return raw_path or "/"
620
+ path, query = raw_path.split("?", 1)
621
+ safe_query = re.sub(r'(token=)[^&]+', r'\1***', query, flags=re.IGNORECASE)
622
+ return f"{path}?{safe_query}"
623
+
624
+
625
+ def format_log_error(msg: str, max_len: int = 160) -> str:
626
+ """Limpia y trunca mensajes de error para mantener el log en una sola línea legible."""
627
+ if not msg:
628
+ return ""
629
+ cleaned = " ".join(str(msg).strip().splitlines())
630
+ if len(cleaned) > max_len:
631
+ return cleaned[:max_len - 3] + "..."
632
+ return cleaned
633
+
634
+
635
+ def is_allowed_origin(origin: str | None) -> bool:
636
+ """Verifica si el origen CORS está autorizado."""
637
+ if origin is None or origin == "null":
638
+ return True
639
+ origin_lower = origin.lower()
640
+ if origin_lower == "https://albalday.github.io" or origin_lower.startswith("https://albalday.github.io/"):
641
+ return True
642
+ if origin_lower == "http://127.0.0.1" or origin_lower.startswith("http://127.0.0.1:"):
643
+ return True
644
+ if origin_lower == "http://localhost" or origin_lower.startswith("http://localhost:"):
645
+ return True
646
+ return False
647
+
648
+
649
+ def public_tool_name(server_id: str, original: str) -> str:
650
+ """Codificación inyectiva de nombres de herramientas MCP idéntica a publicToolName en js/mcp.js."""
651
+ def encode(value: str, tool: bool = False) -> str:
652
+ if not isinstance(value, str) or not value or len(value) > 256:
653
+ raise ValueError("Componente de nombre MCP no válido")
654
+ return "".join(ch if ("a" <= ch <= "y" or "0" <= ch <= "9" or (tool and ch == "_"))
655
+ else f"z{ord(ch):x}z" for ch in value)
656
+ name = f"mcp_{encode(server_id)}_{encode(original, True)}"
657
+ if len(name) > 64:
658
+ raise ValueError(f"El nombre público de la herramienta MCP excede 64 caracteres: {name}")
659
+ return name
660
+
661
+
662
+ class StdioMcpClient:
663
+ def __init__(self, command: str, args: list[str], cwd: str, env: dict[str, str]):
664
+ self.command = command
665
+ self.args = args
666
+ self.cwd = cwd
667
+ self.env = env
668
+ self.process: subprocess.Popen | None = None
669
+ self._pending: dict[int, queue.Queue] = {}
670
+ self._next = 0
671
+ self._lock = threading.Lock()
672
+ self._alive = False
673
+ self.tools: list[dict] = []
674
+
675
+ def running(self) -> bool:
676
+ return self._alive and self.process is not None and self.process.poll() is None
677
+
678
+ def start(self, handshake_timeout: int = 30):
679
+ cmd = [self.command] + self.args
680
+ self.process = subprocess.Popen(
681
+ cmd,
682
+ stdin=subprocess.PIPE,
683
+ stdout=subprocess.PIPE,
684
+ stderr=subprocess.PIPE,
685
+ cwd=self.cwd,
686
+ env=self.env,
687
+ text=True,
688
+ encoding="utf-8",
689
+ errors="replace",
690
+ bufsize=1
691
+ )
692
+ self._alive = True
693
+ threading.Thread(target=self._drain_stderr, daemon=True).start()
694
+ threading.Thread(target=self._read_stdout, daemon=True).start()
695
+
696
+ self.request("initialize", {
697
+ "protocolVersion": "2024-11-05",
698
+ "capabilities": {},
699
+ "clientInfo": {"name": "zerochat", "version": VERSION}
700
+ }, timeout=handshake_timeout)
701
+ self.notify("notifications/initialized")
702
+ tools_resp = self.request("tools/list", {}, timeout=10)
703
+ self.tools = tools_resp.get("tools", [])
704
+
705
+ def _drain_stderr(self):
706
+ if self.process and self.process.stderr:
707
+ for _ in self.process.stderr:
708
+ pass
709
+
710
+ def _read_stdout(self):
711
+ try:
712
+ if not self.process or not self.process.stdout:
713
+ return
714
+ for line in self.process.stdout:
715
+ line = line.strip()
716
+ if not line:
717
+ continue
718
+ try:
719
+ msg = json.loads(line)
720
+ except Exception:
721
+ continue
722
+ req_id = msg.get("id")
723
+ if req_id is not None:
724
+ with self._lock:
725
+ waiter = self._pending.pop(req_id, None)
726
+ if waiter:
727
+ waiter.put(msg)
728
+ finally:
729
+ self._alive = False
730
+ with self._lock:
731
+ pending = list(self._pending.values())
732
+ self._pending.clear()
733
+ for waiter in pending:
734
+ waiter.put({"error": {"message": "MCP process ended unexpectedly"}})
735
+
736
+ def request(self, method: str, params: dict, timeout: int = 30) -> dict:
737
+ if not self.running():
738
+ raise RuntimeError("MCP process is not running")
739
+ with self._lock:
740
+ self._next += 1
741
+ req_id = self._next
742
+ waiter = queue.Queue(maxsize=1)
743
+ self._pending[req_id] = waiter
744
+ payload = json.dumps({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}) + "\n"
745
+ try:
746
+ self.process.stdin.write(payload)
747
+ self.process.stdin.flush()
748
+ except Exception as exc:
749
+ self._alive = False
750
+ self._pending.pop(req_id, None)
751
+ raise RuntimeError(f"Failed writing to MCP process: {exc}") from exc
752
+ try:
753
+ response = waiter.get(timeout=timeout)
754
+ except queue.Empty as exc:
755
+ with self._lock:
756
+ self._pending.pop(req_id, None)
757
+ raise TimeoutError(f"MCP request timed out: {method}") from exc
758
+ if response.get("error"):
759
+ raise RuntimeError(str(response["error"].get("message", "MCP request failed")))
760
+ return response.get("result", {})
761
+
762
+ def notify(self, method: str):
763
+ if self.running():
764
+ with self._lock:
765
+ try:
766
+ self.process.stdin.write(json.dumps({"jsonrpc": "2.0", "method": method}) + "\n")
767
+ self.process.stdin.flush()
768
+ except Exception:
769
+ self._alive = False
770
+
771
+ def stop(self):
772
+ self._alive = False
773
+ if not self.process:
774
+ return
775
+ try:
776
+ self.process.terminate()
777
+ self.process.wait(timeout=2)
778
+ except (OSError, subprocess.TimeoutExpired):
779
+ try:
780
+ self.process.kill()
781
+ except OSError:
782
+ pass
783
+ self.process = None
784
+ self.tools = []
785
+
786
+
787
+ class McpServiceManager:
788
+ def __init__(self, services_root: Path | None = None):
789
+ if services_root:
790
+ self.services_root = Path(services_root)
791
+ else:
792
+ candidates = [
793
+ Path.cwd() / "services",
794
+ get_venv_dir() / "services"
795
+ ]
796
+ self.services_root = candidates[0] if get_dev_root() is not None and candidates[0].is_dir() else candidates[1]
797
+ self.services_root.mkdir(parents=True, exist_ok=True)
798
+ self.config_file = get_venv_dir() / "config" / "services.json"
799
+ self.config_file.parent.mkdir(parents=True, exist_ok=True)
800
+ self.clients: dict[str, StdioMcpClient] = {}
801
+ self.states: dict[str, str] = {}
802
+ self.errors: dict[str, str] = {}
803
+ self._lock = threading.Lock()
804
+ self._ensure_default_services()
805
+ self.services = self._load_services()
806
+ self.preferences = self._load_preferences()
807
+
808
+ def _copy_packaged_service_templates(self):
809
+ """Inicializa descriptores MCP editables sin copiar dependencias instaladas."""
810
+ assets_root = get_packaged_assets_root()
811
+ if not assets_root:
812
+ return
813
+ templates_root = assets_root / "services"
814
+ if not templates_root.is_dir():
815
+ return
816
+ for source in templates_root.rglob("*"):
817
+ if not source.is_file():
818
+ continue
819
+ relative = source.relative_to(templates_root)
820
+ if any(part in {"node_modules", ".playwright-mcp"} or part.startswith(".") for part in relative.parts):
821
+ continue
822
+ target = self.services_root / relative
823
+ if target.exists():
824
+ continue
825
+ target.parent.mkdir(parents=True, exist_ok=True)
826
+ shutil.copy2(source, target)
827
+
828
+ def _ensure_default_services(self):
829
+ self._copy_packaged_service_templates()
830
+ dummy_dir = self.services_root / "dummy_mcp"
831
+ dummy_dir.mkdir(parents=True, exist_ok=True)
832
+ service_json_file = dummy_dir / "service.json"
833
+ dummy_server_file = dummy_dir / "dummy_mcp_server.py"
834
+
835
+ if not service_json_file.exists():
836
+ service_json_file.write_text(json.dumps({
837
+ "schemaVersion": 1,
838
+ "id": "dummy_mcp",
839
+ "displayName": {
840
+ "es": "MCP de prueba",
841
+ "en": "Test MCP"
842
+ },
843
+ "description": {
844
+ "es": "Servicio MCP mínimo para comprobar la infraestructura externa.",
845
+ "en": "Minimal MCP service for verifying the external infrastructure."
846
+ },
847
+ "enabledByDefault": False,
848
+ "transport": "stdio",
849
+ "launch": {
850
+ "executable": "${pythonExecutable}",
851
+ "args": ["${serviceDir}/dummy_mcp_server.py"],
852
+ "cwd": "${serviceDir}",
853
+ "env": {},
854
+ "handshakeTimeoutSeconds": 10
855
+ }
856
+ }, indent=2), encoding="utf-8")
857
+
858
+ if not dummy_server_file.exists():
859
+ dummy_server_file.write_text('''#!/usr/bin/env python3
860
+ import json, sys
861
+
862
+ def reply(req_id, result=None, error=None):
863
+ resp = {"jsonrpc": "2.0", "id": req_id}
864
+ if error: resp["error"] = error
865
+ else: resp["result"] = result
866
+ sys.stdout.write(json.dumps(resp) + "\\n")
867
+ sys.stdout.flush()
868
+
869
+ for raw in sys.stdin:
870
+ try: req = json.loads(raw)
871
+ except: continue
872
+ req_id, method, params = req.get("id"), req.get("method"), req.get("params", {})
873
+ if method == "initialize":
874
+ reply(req_id, {
875
+ "protocolVersion": "2024-11-05",
876
+ "serverInfo": {"name": "ZeroChat Dummy MCP", "version": "1.0.0"},
877
+ "capabilities": {"tools": {}}
878
+ })
879
+ elif method == "tools/list":
880
+ reply(req_id, {"tools": [{
881
+ "name": "echo",
882
+ "description": "Echo back a message for testing.",
883
+ "inputSchema": {
884
+ "type": "object",
885
+ "properties": {"message": {"type": "string", "description": "Message to echo."}},
886
+ "required": ["message"]
887
+ }
888
+ }]})
889
+ elif method == "tools/call":
890
+ if params.get("name") != "echo":
891
+ reply(req_id, error={"code": -32601, "message": "Tool not found"})
892
+ continue
893
+ msg = params.get("arguments", {}).get("message", "")
894
+ reply(req_id, {
895
+ "content": [{"type": "text", "text": f"echo: {msg}"}],
896
+ "isError": False
897
+ })
898
+ ''', encoding="utf-8")
899
+
900
+ # 2. playwright
901
+ playwright_dir = self.services_root / "playwright"
902
+ playwright_dir.mkdir(parents=True, exist_ok=True)
903
+ pw_service = playwright_dir / "service.json"
904
+ pw_installer = playwright_dir / "installer.json"
905
+ if not pw_service.exists():
906
+ pw_service.write_text(json.dumps({
907
+ "schemaVersion": 1,
908
+ "id": "playwright",
909
+ "displayName": {
910
+ "es": "Playwright MCP",
911
+ "en": "Playwright MCP"
912
+ },
913
+ "description": {
914
+ "es": "Automatización de navegador mediante el servidor MCP oficial de Playwright.",
915
+ "en": "Browser automation through the official Playwright MCP server."
916
+ },
917
+ "enabledByDefault": False,
918
+ "transport": "stdio",
919
+ "launch": {
920
+ "executable": "${nodeExecutable}",
921
+ "args": ["${serviceDir}/node_modules/@playwright/mcp/cli.js", "--browser=chromium"],
922
+ "cwd": "${serviceDir}",
923
+ "env": {},
924
+ "handshakeTimeoutSeconds": 30
925
+ },
926
+ "options": [
927
+ {
928
+ "id": "headless",
929
+ "type": "boolean",
930
+ "label": {
931
+ "es": "Navegación en segundo plano (Headless)",
932
+ "en": "Headless background mode"
933
+ },
934
+ "description": {
935
+ "es": "Desactívalo para ver la ventana del navegador durante la automatización",
936
+ "en": "Disable to display the browser window during automation"
937
+ },
938
+ "default": True,
939
+ "argsWhenTrue": ["--headless"],
940
+ "argsWhenFalse": []
941
+ }
942
+ ]
943
+ }, indent=2), encoding="utf-8")
944
+ if not pw_installer.exists():
945
+ pw_installer.write_text(json.dumps({
946
+ "schemaVersion": 1,
947
+ "type": "npm",
948
+ "product": {
949
+ "package": "@playwright/mcp",
950
+ "version": "0.0.81",
951
+ "browser": "chromium"
952
+ }
953
+ }, indent=2), encoding="utf-8")
954
+
955
+ # 3. memory
956
+ memory_dir = self.services_root / "memory"
957
+ memory_dir.mkdir(parents=True, exist_ok=True)
958
+ mem_service = memory_dir / "service.json"
959
+ mem_installer = memory_dir / "installer.json"
960
+ if not mem_service.exists():
961
+ mem_service.write_text(json.dumps({
962
+ "schemaVersion": 1,
963
+ "id": "memory",
964
+ "displayName": {
965
+ "es": "Memoria y Grafos (Knowledge Graph)",
966
+ "en": "Memory & Knowledge Graph"
967
+ },
968
+ "description": {
969
+ "es": "Almacenamiento persistente de entidades, preferencias y contexto histórico estructurado en un grafo de conocimiento.",
970
+ "en": "Persistent storage of entities, preferences, and historical context structured as a knowledge graph."
971
+ },
972
+ "enabledByDefault": False,
973
+ "transport": "stdio",
974
+ "launch": {
975
+ "executable": "${nodeExecutable}",
976
+ "args": ["${serviceDir}/node_modules/@modelcontextprotocol/server-memory/dist/index.js"],
977
+ "cwd": "${serviceDir}",
978
+ "env": {
979
+ "MEMORY_FILE_PATH": "${serviceDir}/memory.jsonl"
980
+ },
981
+ "handshakeTimeoutSeconds": 30
982
+ }
983
+ }, indent=2), encoding="utf-8")
984
+ if not mem_installer.exists():
985
+ mem_installer.write_text(json.dumps({
986
+ "schemaVersion": 1,
987
+ "type": "npm",
988
+ "product": {
989
+ "package": "@modelcontextprotocol/server-memory",
990
+ "version": "2026.8.31"
991
+ }
992
+ }, indent=2), encoding="utf-8")
993
+
994
+ # 4. lsp
995
+ lsp_dir = self.services_root / "lsp"
996
+ lsp_dir.mkdir(parents=True, exist_ok=True)
997
+ lsp_service = lsp_dir / "service.json"
998
+ lsp_installer = lsp_dir / "installer.json"
999
+ if not lsp_service.exists():
1000
+ lsp_service.write_text(json.dumps({
1001
+ "schemaVersion": 1,
1002
+ "id": "lsp",
1003
+ "displayName": {
1004
+ "es": "LSP y Navegación de Código",
1005
+ "en": "LSP & Code Intelligence"
1006
+ },
1007
+ "description": {
1008
+ "es": "Servidor de protocolos de lenguaje (LSP): salto a definiciones, búsqueda de símbolos, referencias e inspección de tipos sin sobrecargar el contexto.",
1009
+ "en": "Language Server Protocol (LSP) server: jump to definitions, symbol search, references, and type inspection without context overload."
1010
+ },
1011
+ "enabledByDefault": False,
1012
+ "transport": "stdio",
1013
+ "launch": {
1014
+ "executable": "${nodeExecutable}",
1015
+ "args": ["${serviceDir}/node_modules/@axivo/mcp-lsp/dist/index.js"],
1016
+ "cwd": "${serviceDir}",
1017
+ "env": {},
1018
+ "handshakeTimeoutSeconds": 30
1019
+ }
1020
+ }, indent=2), encoding="utf-8")
1021
+ if not lsp_installer.exists():
1022
+ lsp_installer.write_text(json.dumps({
1023
+ "schemaVersion": 1,
1024
+ "type": "npm",
1025
+ "product": {
1026
+ "package": "@axivo/mcp-lsp",
1027
+ "version": "1.0.5"
1028
+ }
1029
+ }, indent=2), encoding="utf-8")
1030
+
1031
+ def _load_services(self) -> dict[str, dict]:
1032
+ servers = {}
1033
+ for directory in sorted(self.services_root.iterdir()):
1034
+ if not directory.is_dir():
1035
+ continue
1036
+ service_file = directory / "service.json"
1037
+ if not service_file.exists():
1038
+ continue
1039
+ try:
1040
+ server = json.loads(service_file.read_text(encoding="utf-8"))
1041
+ server_id = server.get("id") or directory.name.replace(".mcp", "")
1042
+ server["id"] = server_id
1043
+ server["_directory"] = directory
1044
+ servers[server_id] = server
1045
+ except Exception:
1046
+ continue
1047
+ return servers
1048
+
1049
+ def _load_preferences(self) -> dict:
1050
+ if self.config_file.exists():
1051
+ try:
1052
+ return json.loads(self.config_file.read_text(encoding="utf-8"))
1053
+ except Exception:
1054
+ return {}
1055
+ return {}
1056
+
1057
+ def _save_preferences(self):
1058
+ try:
1059
+ tmp = self.config_file.with_suffix(".tmp")
1060
+ tmp.write_text(json.dumps(self.preferences, indent=2), encoding="utf-8")
1061
+ tmp.replace(self.config_file)
1062
+ except Exception:
1063
+ pass
1064
+
1065
+ def list_servers(self) -> list[dict]:
1066
+ self.services = self._load_services()
1067
+ result = []
1068
+ for server_id, server in self.services.items():
1069
+ client = self.clients.get(server_id)
1070
+ running = bool(client and client.running())
1071
+ pref = self.preferences.get(server_id, {})
1072
+ result.append({
1073
+ "id": server_id,
1074
+ "displayName": server.get("displayName", {}),
1075
+ "description": server.get("description", {}),
1076
+ "enabled": pref.get("enabled", server.get("enabledByDefault", False)),
1077
+ "status": "running" if running else self.states.get(server_id, "stopped"),
1078
+ "toolCount": len(client.tools) if running else 0,
1079
+ "error": self.errors.get(server_id),
1080
+ "options": server.get("options", []),
1081
+ "userOptions": pref.get("options", {})
1082
+ })
1083
+ return result
1084
+
1085
+ def _expand(self, value: str, values: dict[str, str]) -> str:
1086
+ if not isinstance(value, str):
1087
+ raise ValueError("Invalid process argument")
1088
+ for key, replacement in values.items():
1089
+ value = value.replace("${" + key + "}", str(replacement))
1090
+ return value
1091
+
1092
+ def _prepare_service(self, server_id: str, server: dict) -> dict[str, str]:
1093
+ service_dir = server["_directory"]
1094
+ installer_file = service_dir / "installer.json"
1095
+ node = shutil.which("node") or "node"
1096
+ npm = shutil.which("npm") or "npm"
1097
+
1098
+ if installer_file.exists():
1099
+ marker = service_dir / ".installed.json"
1100
+ try:
1101
+ installer = json.loads(installer_file.read_text(encoding="utf-8"))
1102
+ except Exception as e:
1103
+ raise RuntimeError(f"Error leyendo installer.json: {e}")
1104
+ kind = installer.get("type", "npm")
1105
+ if kind == "npm":
1106
+ product = installer.get("product", {})
1107
+ package = product.get("package")
1108
+ version = product.get("version")
1109
+ if not package or not version:
1110
+ raise RuntimeError("El instalador npm debe definir package y version")
1111
+ if not shutil.which("node") or not shutil.which("npm"):
1112
+ raise RuntimeError("Node.js 18+ y npm son necesarios para instalar este servicio MCP")
1113
+
1114
+ needs_install = not marker.exists()
1115
+ if not needs_install:
1116
+ try:
1117
+ installation = json.loads(marker.read_text(encoding="utf-8"))
1118
+ if installation.get("package") != package or installation.get("version") != version:
1119
+ needs_install = True
1120
+ except Exception:
1121
+ needs_install = True
1122
+
1123
+ if needs_install:
1124
+ self.states[server_id] = "installing"
1125
+ manifest = service_dir / "package.json"
1126
+ manifest.write_text(json.dumps({"private": True, "dependencies": {package: version}}, indent=2), encoding="utf-8")
1127
+ res = subprocess.run([npm, "install", "--ignore-scripts", "--no-audit", "--no-fund"], cwd=service_dir, capture_output=True, text=True, timeout=600)
1128
+ if res.returncode != 0:
1129
+ raise RuntimeError(f"Fallo instalando dependencias npm: {res.stderr or res.stdout}")
1130
+ installation = {"type": "npm", "package": package, "version": version, "nodeExecutable": node}
1131
+ browser = product.get("browser")
1132
+ if browser:
1133
+ playwright_cli = service_dir / "node_modules" / "playwright" / "cli.js"
1134
+ if playwright_cli.is_file():
1135
+ subprocess.run([node, str(playwright_cli), "install", browser], cwd=service_dir, capture_output=True, text=True, timeout=600)
1136
+ installation["browser"] = browser
1137
+ marker.write_text(json.dumps(installation, indent=2), encoding="utf-8")
1138
+
1139
+ return {
1140
+ "serviceDir": str(service_dir),
1141
+ "pythonExecutable": sys.executable,
1142
+ "nodeExecutable": node
1143
+ }
1144
+
1145
+ def start(self, server_id: str) -> list[dict]:
1146
+ with self._lock:
1147
+ self.services = self._load_services()
1148
+ server = self.services.get(server_id)
1149
+ if not server:
1150
+ raise KeyError(f"Servidor MCP desconocido: {server_id}")
1151
+ current = self.clients.get(server_id)
1152
+ if current and current.running():
1153
+ return self.list_servers()
1154
+ self.states[server_id] = "starting"
1155
+ try:
1156
+ service_dir = server["_directory"]
1157
+ values = self._prepare_service(server_id, server)
1158
+ pref = self.preferences.get(server_id, {})
1159
+ user_opts = pref.get("options", {})
1160
+ for opt in server.get("options", []):
1161
+ opt_id = opt.get("id")
1162
+ if opt_id:
1163
+ val = user_opts.get(opt_id, opt.get("default"))
1164
+ values[f"option:{opt_id}"] = str(val)
1165
+
1166
+ launch = server.get("launch", {})
1167
+ command = self._expand(launch.get("executable", sys.executable), values)
1168
+ args = [self._expand(arg, values) for arg in launch.get("args", [])]
1169
+ for opt in server.get("options", []):
1170
+ opt_id = opt.get("id")
1171
+ if not opt_id:
1172
+ continue
1173
+ val = user_opts.get(opt_id, opt.get("default"))
1174
+ if opt.get("type") == "boolean":
1175
+ extra = opt.get("argsWhenTrue", []) if val else opt.get("argsWhenFalse", [])
1176
+ args.extend([self._expand(a, values) for a in extra])
1177
+
1178
+ env = os.environ.copy()
1179
+ for k, v in launch.get("env", {}).items():
1180
+ env[k] = self._expand(v, values)
1181
+
1182
+ client = StdioMcpClient(command, args, str(service_dir), env)
1183
+ client.start(int(launch.get("handshakeTimeoutSeconds", 15)))
1184
+ self.clients[server_id] = client
1185
+ self.states[server_id] = "running"
1186
+ self.errors.pop(server_id, None)
1187
+ entry = self.preferences.setdefault(server_id, {})
1188
+ entry["enabled"] = True
1189
+ self._save_preferences()
1190
+ except Exception as exc:
1191
+ self.states[server_id] = "error"
1192
+ self.errors[server_id] = str(exc)
1193
+ if server_id in self.clients:
1194
+ self.clients.pop(server_id).stop()
1195
+ return self.list_servers()
1196
+
1197
+ def stop(self, server_id: str) -> list[dict]:
1198
+ with self._lock:
1199
+ client = self.clients.pop(server_id, None)
1200
+ if client:
1201
+ client.stop()
1202
+ self.states[server_id] = "stopped"
1203
+ self.errors.pop(server_id, None)
1204
+ entry = self.preferences.setdefault(server_id, {})
1205
+ entry["enabled"] = False
1206
+ self._save_preferences()
1207
+ return self.list_servers()
1208
+
1209
+ def configure(self, server_id: str, options: dict | None = None) -> list[dict]:
1210
+ with self._lock:
1211
+ self.services = self._load_services()
1212
+ if server_id not in self.services:
1213
+ raise KeyError(f"Servidor MCP desconocido: {server_id}")
1214
+ entry = self.preferences.setdefault(server_id, {})
1215
+ if options is not None:
1216
+ opts = entry.setdefault("options", {})
1217
+ opts.update(options)
1218
+ self._save_preferences()
1219
+ return self.list_servers()
1220
+
1221
+ def tools(self) -> list[dict]:
1222
+ aggregated = []
1223
+ with self._lock:
1224
+ for server_id, client in self.clients.items():
1225
+ if not client.running():
1226
+ continue
1227
+ for t in client.tools:
1228
+ try:
1229
+ pname = public_tool_name(server_id, t["name"])
1230
+ tcopy = dict(t)
1231
+ tcopy["name"] = pname
1232
+ tcopy["metadata"] = {
1233
+ "mcpServerId": server_id,
1234
+ "originalName": t["name"]
1235
+ }
1236
+ aggregated.append(tcopy)
1237
+ except Exception:
1238
+ continue
1239
+ return aggregated
1240
+
1241
+ def call(self, public_name: str, arguments: dict) -> dict:
1242
+ with self._lock:
1243
+ for server_id, client in self.clients.items():
1244
+ if not client.running():
1245
+ continue
1246
+ for t in client.tools:
1247
+ if public_tool_name(server_id, t["name"]) == public_name:
1248
+ return client.request("tools/call", {"name": t["name"], "arguments": arguments})
1249
+ raise ValueError(f"Herramienta externa '{public_name}' no disponible o servidor detenido.")
1250
+
1251
+ def close(self):
1252
+ with self._lock:
1253
+ for client in list(self.clients.values()):
1254
+ client.stop()
1255
+ self.clients.clear()
1256
+
1257
+
1258
+ GLOBAL_MCP_MANAGER = McpServiceManager()
1259
+
1260
+ DEFAULT_HEARTBEAT_TIMEOUT = float(os.environ.get("ZEROCHAT_HEARTBEAT_TIMEOUT", "60.0"))
1261
+ DEFAULT_HEARTBEAT_GRACE = float(os.environ.get("ZEROCHAT_HEARTBEAT_GRACE", "45.0"))
1262
+ DEFAULT_HEARTBEAT_POLL = float(os.environ.get("ZEROCHAT_HEARTBEAT_POLL", "5.0"))
1263
+ HEARTBEAT_LAST_SEEN = 0.0
1264
+ HEARTBEAT_INITIALIZED = False
1265
+ HEARTBEAT_WATCHDOG_STOP = threading.Event()
1266
+ _SERVER_SHUTTING_DOWN = threading.Event()
1267
+
1268
+
1269
+ def mark_browser_active():
1270
+ """Registra la presencia activa del navegador ante cualquier petición válida."""
1271
+ global HEARTBEAT_LAST_SEEN, HEARTBEAT_INITIALIZED
1272
+ HEARTBEAT_LAST_SEEN = time.monotonic()
1273
+ HEARTBEAT_INITIALIZED = True
1274
+
1275
+
1276
+ def stop_zerochat_server(server: ThreadingHTTPServer):
1277
+ """Detiene limpiamente el servidor y todos los subsistemas evitando reentradas."""
1278
+ if _SERVER_SHUTTING_DOWN.is_set():
1279
+ return
1280
+ _SERVER_SHUTTING_DOWN.set()
1281
+ HEARTBEAT_WATCHDOG_STOP.set()
1282
+ GLOBAL_MCP_MANAGER.close()
1283
+ threading.Thread(target=server.shutdown, daemon=True).start()
1284
+
1285
+
1286
+ def heartbeat_watchdog(server: ThreadingHTTPServer, initial_grace_seconds: float = DEFAULT_HEARTBEAT_GRACE, inactivity_timeout_seconds: float = DEFAULT_HEARTBEAT_TIMEOUT, require_initial_connection: bool = True):
1287
+ """
1288
+ Supervisa la presencia de la pestaña del navegador mediante latidos HTTP.
1289
+ Si el navegador se cierra o deja de emitir latidos, detiene el servidor automáticamente.
1290
+ """
1291
+ start_time = time.monotonic()
1292
+ poll_interval = min(DEFAULT_HEARTBEAT_POLL, max(0.02, inactivity_timeout_seconds / 2))
1293
+ while not HEARTBEAT_WATCHDOG_STOP.is_set():
1294
+ if HEARTBEAT_WATCHDOG_STOP.wait(timeout=poll_interval):
1295
+ break
1296
+
1297
+ now = time.monotonic()
1298
+
1299
+ # 1. Periodo de gracia inicial (solo si zerochat abrió el navegador)
1300
+ if not HEARTBEAT_INITIALIZED:
1301
+ if require_initial_connection and (now - start_time > initial_grace_seconds):
1302
+ console_log(f"[{time.strftime('%H:%M:%S')}] Tiempo de espera del navegador agotado ({initial_grace_seconds:.0f}s). Deteniendo servidor ZeroChat...", flush=True)
1303
+ stop_zerochat_server(server)
1304
+ break
1305
+ continue
1306
+
1307
+ # 2. Inactividad tras haber recibido latidos
1308
+ if now - HEARTBEAT_LAST_SEEN > inactivity_timeout_seconds:
1309
+ console_log(f"[{time.strftime('%H:%M:%S')}] Navegador desconectado (cierre detectado). Deteniendo servidor ZeroChat...", flush=True)
1310
+ stop_zerochat_server(server)
1311
+ break
1312
+
1313
+
1314
+ DEV_CONTENT_TYPES = {
1315
+ ".html": "text/html; charset=utf-8",
1316
+ ".js": "application/javascript; charset=utf-8",
1317
+ ".mjs": "application/javascript; charset=utf-8",
1318
+ ".css": "text/css; charset=utf-8",
1319
+ ".json": "application/json; charset=utf-8",
1320
+ ".webmanifest": "application/manifest+json; charset=utf-8",
1321
+ ".svg": "image/svg+xml",
1322
+ ".png": "image/png",
1323
+ ".jpg": "image/jpeg",
1324
+ ".jpeg": "image/jpeg",
1325
+ ".ico": "image/x-icon",
1326
+ ".txt": "text/plain; charset=utf-8",
1327
+ ".map": "application/json",
1328
+ }
1329
+
1330
+
1331
+ class ZeroChatServerHandler(BaseHTTPRequestHandler):
1332
+ server_version = f"ZeroChatServer/{VERSION}"
1333
+
1334
+ def _log_req(self, method: str, detail: str):
1335
+ now = time.strftime("%H:%M:%S")
1336
+ console_log(f"[{now}] --> {method} {detail}", flush=True)
1337
+
1338
+ def _log_res(self, status: int, detail: str, duration_ms: float, error_info: str = ""):
1339
+ now = time.strftime("%H:%M:%S")
1340
+ status_text = {
1341
+ 200: "200 OK",
1342
+ 204: "204 No Content",
1343
+ 400: "400 Bad Request",
1344
+ 401: "401 Unauthorized",
1345
+ 403: "403 Forbidden",
1346
+ 404: "404 Not Found",
1347
+ 500: "500 Internal Server Error",
1348
+ }.get(status, str(status))
1349
+ err_suffix = f" - ERROR: {format_log_error(error_info)}" if error_info else ""
1350
+ console_log(f"[{now}] <-- {status_text} {detail}{err_suffix} ({duration_ms:.1f}ms)", flush=True)
1351
+
1352
+ def serve_static_file(self, rel_path: str) -> bool:
1353
+ """Sirve los recursos web del repositorio o de la distribución instalada."""
1354
+ static_root = get_static_root()
1355
+ if not static_root:
1356
+ return False
1357
+
1358
+ clean_rel = rel_path.split("?", 1)[0].lstrip("/")
1359
+ if not clean_rel:
1360
+ return False
1361
+
1362
+ target_path = (static_root / clean_rel).resolve()
1363
+ try:
1364
+ rel_parts = target_path.relative_to(static_root.resolve()).parts
1365
+ except ValueError:
1366
+ return False
1367
+
1368
+ # Whitelist de archivos y carpetas autorizados para servir la interfaz web
1369
+ is_allowed_static = (
1370
+ clean_rel == "zerochat.html" or
1371
+ clean_rel in ("manifest.webmanifest", "sw.js", "favicon.ico") or
1372
+ clean_rel.startswith("js/") or
1373
+ clean_rel.startswith("css/") or
1374
+ clean_rel.startswith("help/")
1375
+ )
1376
+ if not is_allowed_static:
1377
+ return False
1378
+
1379
+ # Protección: bloquear archivos ocultos, datos y dependencias locales.
1380
+ for part in rel_parts:
1381
+ if part.startswith(".") and part != ".":
1382
+ return False
1383
+ if part in ("zerochat", "node_modules", ".git", "tests"):
1384
+ return False
1385
+
1386
+ if not target_path.is_file():
1387
+ return False
1388
+
1389
+ ext = target_path.suffix.lower()
1390
+ content_type = DEV_CONTENT_TYPES.get(ext, "application/octet-stream")
1391
+ try:
1392
+ data = target_path.read_bytes()
1393
+ except Exception:
1394
+ return False
1395
+
1396
+ mark_browser_active()
1397
+ self.send_response(200)
1398
+ self.send_header("Content-Type", content_type)
1399
+ self.send_header("Content-Length", str(len(data)))
1400
+ if clean_rel == "sw.js":
1401
+ self.send_header("Service-Worker-Allowed", "/")
1402
+ self.send_cors_headers()
1403
+ self.end_headers()
1404
+ self.wfile.write(data)
1405
+ return True
1406
+
1407
+ def send_cors_headers(self):
1408
+ origin = self.headers.get("Origin")
1409
+ if not is_allowed_origin(origin):
1410
+ return
1411
+ self.send_header("Access-Control-Allow-Origin", origin if origin else "*")
1412
+ self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
1413
+ self.send_header("Access-Control-Allow-Headers", "Content-Type, Accept, Authorization, X-ZeroChat-Token, X-ZeroChat-Client")
1414
+ self.send_header("Access-Control-Allow-Private-Network", "true")
1415
+
1416
+ def verify_token(self) -> bool:
1417
+ """Comprueba el token efímero de sesión en cabeceras o query string."""
1418
+ auth_header = self.headers.get("Authorization", "")
1419
+ token_candidate = None
1420
+ if auth_header.startswith("Bearer "):
1421
+ token_candidate = auth_header[7:].strip()
1422
+ elif "X-ZeroChat-Token" in self.headers:
1423
+ token_candidate = self.headers.get("X-ZeroChat-Token", "").strip()
1424
+ elif "?" in self.path:
1425
+ query = self.path.split("?", 1)[1]
1426
+ for part in query.split("&"):
1427
+ if part.startswith("token="):
1428
+ token_candidate = part.split("=", 1)[1].strip()
1429
+ break
1430
+
1431
+ if not token_candidate:
1432
+ return False
1433
+ is_valid = hmac.compare_digest(token_candidate, SESSION_TOKEN)
1434
+ if is_valid:
1435
+ mark_browser_active()
1436
+ return is_valid
1437
+
1438
+ def do_OPTIONS(self):
1439
+ t0 = time.monotonic()
1440
+ safe_path = sanitize_log_path(self.path)
1441
+ path_clean = self.path.split("?", 1)[0].rstrip("/")
1442
+ is_heartbeat = path_clean == "/zerochat/heartbeat"
1443
+ if not is_heartbeat:
1444
+ self._log_req("OPTIONS", safe_path)
1445
+ origin = self.headers.get("Origin")
1446
+ if not is_allowed_origin(origin):
1447
+ self.send_response(403)
1448
+ self.end_headers()
1449
+ self._log_res(403, safe_path, (time.monotonic() - t0) * 1000, f"Origen no permitido: '{origin}'")
1450
+ return
1451
+ self.send_response(204)
1452
+ self.send_cors_headers()
1453
+ self.end_headers()
1454
+ if not is_heartbeat:
1455
+ self._log_res(204, safe_path, (time.monotonic() - t0) * 1000)
1456
+
1457
+ def _send_json_response(self, status: int, data: dict):
1458
+ body = json.dumps(data, ensure_ascii=False).encode("utf-8")
1459
+ self.send_response(status)
1460
+ self.send_header("Content-Type", "application/json; charset=utf-8")
1461
+ self.send_header("Content-Length", str(len(body)))
1462
+ self.send_cors_headers()
1463
+ self.end_headers()
1464
+ self.wfile.write(body)
1465
+
1466
+ def do_GET(self):
1467
+ t0 = time.monotonic()
1468
+ safe_path = sanitize_log_path(self.path)
1469
+ path_clean = self.path.split("?", 1)[0].rstrip("/")
1470
+ is_heartbeat = path_clean == "/zerochat/heartbeat"
1471
+
1472
+ origin = self.headers.get("Origin")
1473
+ if not is_allowed_origin(origin):
1474
+ if not is_heartbeat:
1475
+ self._log_req("GET", safe_path)
1476
+ self.send_response(403)
1477
+ self.end_headers()
1478
+ self._log_res(403, safe_path, (time.monotonic() - t0) * 1000, f"Origen no permitido: '{origin}'")
1479
+ return
1480
+
1481
+ # Servir archivos estáticos del repositorio si estamos en entorno de desarrollo
1482
+ if path_clean and self.serve_static_file(path_clean):
1483
+ self._log_req("GET", safe_path)
1484
+ self._log_res(200, safe_path, (time.monotonic() - t0) * 1000)
1485
+ return
1486
+
1487
+ if not is_heartbeat:
1488
+ self._log_req("GET", safe_path)
1489
+
1490
+ if not self.verify_token():
1491
+ err_msg = json.dumps({"error": "Unauthorized: invalid or missing session token"}).encode("utf-8")
1492
+ self.send_response(401)
1493
+ self.send_header("Content-Type", "application/json; charset=utf-8")
1494
+ self.send_header("Content-Length", str(len(err_msg)))
1495
+ self.send_cors_headers()
1496
+ self.end_headers()
1497
+ self.wfile.write(err_msg)
1498
+ self._log_res(401, safe_path, (time.monotonic() - t0) * 1000, "Token de sesión ausente o inválido")
1499
+ return
1500
+
1501
+ if is_heartbeat:
1502
+ mark_browser_active()
1503
+ self._send_json_response(200, {"ok": True})
1504
+ return
1505
+
1506
+ accept = self.headers.get("Accept", "")
1507
+ if "/sse" in self.path or "text/event-stream" in accept:
1508
+ self.send_response(200)
1509
+ self.send_header("Content-Type", "text/event-stream")
1510
+ self.send_header("Cache-Control", "no-cache")
1511
+ self.send_header("Connection", "keep-alive")
1512
+ self.send_cors_headers()
1513
+ self.end_headers()
1514
+ endpoint_data = b"/mcp/external" if "/mcp/external" in self.path else b"/"
1515
+ self.wfile.write(b"event: endpoint\r\ndata: " + endpoint_data + b"\r\n\r\n")
1516
+ self.wfile.flush()
1517
+ self._log_res(200, f"{safe_path} [SSE canal activo]", (time.monotonic() - t0) * 1000)
1518
+ return
1519
+
1520
+ # Status general
1521
+ res_data = json.dumps({
1522
+ "status": "active",
1523
+ "server": "ZeroChat Local Server",
1524
+ "version": VERSION,
1525
+ "tools_count": len(LOCAL_TOOLS_DEFINITIONS),
1526
+ "os": DETECTED_OS
1527
+ }, ensure_ascii=False, indent=2).encode("utf-8")
1528
+
1529
+ self.send_response(200)
1530
+ self.send_header("Content-Type", "application/json; charset=utf-8")
1531
+ self.send_header("Content-Length", str(len(res_data)))
1532
+ self.send_cors_headers()
1533
+ self.end_headers()
1534
+ self.wfile.write(res_data)
1535
+ self._log_res(200, safe_path, (time.monotonic() - t0) * 1000)
1536
+
1537
+ def do_POST(self):
1538
+ t0 = time.monotonic()
1539
+ safe_path = sanitize_log_path(self.path)
1540
+ origin = self.headers.get("Origin")
1541
+ if not is_allowed_origin(origin):
1542
+ self._log_req("POST", safe_path)
1543
+ self.send_response(403)
1544
+ self.end_headers()
1545
+ self._log_res(403, safe_path, (time.monotonic() - t0) * 1000, f"Origen no permitido: '{origin}'")
1546
+ return
1547
+
1548
+ if not self.verify_token():
1549
+ self._log_req("POST", safe_path)
1550
+ err_msg = json.dumps({
1551
+ "jsonrpc": "2.0",
1552
+ "id": None,
1553
+ "error": {"code": -32000, "message": "Unauthorized: invalid or missing session token"}
1554
+ }).encode("utf-8")
1555
+ self.send_response(401)
1556
+ self.send_header("Content-Type", "application/json; charset=utf-8")
1557
+ self.send_header("Content-Length", str(len(err_msg)))
1558
+ self.send_cors_headers()
1559
+ self.end_headers()
1560
+ self.wfile.write(err_msg)
1561
+ self._log_res(401, safe_path, (time.monotonic() - t0) * 1000, "Token de sesión ausente o inválido")
1562
+ return
1563
+
1564
+ content_len = int(self.headers.get("Content-Length", 0))
1565
+ post_data = self.rfile.read(content_len) if content_len > 0 else b"{}"
1566
+
1567
+ try:
1568
+ req = json.loads(post_data.decode("utf-8"))
1569
+ except Exception as err:
1570
+ self._log_req("POST", safe_path)
1571
+ err_resp = json.dumps({
1572
+ "jsonrpc": "2.0",
1573
+ "id": None,
1574
+ "error": {"code": -32700, "message": f"Parse error: {str(err)}"}
1575
+ }).encode("utf-8")
1576
+ self.send_response(400)
1577
+ self.send_header("Content-Type", "application/json; charset=utf-8")
1578
+ self.send_cors_headers()
1579
+ self.end_headers()
1580
+ self.wfile.write(err_resp)
1581
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, f"Error parseando JSON: {err}")
1582
+ return
1583
+
1584
+ req_path = self.path.split("?", 1)[0].rstrip("/")
1585
+ req_id = req.get("id")
1586
+ method = req.get("method")
1587
+ params = req.get("params", {})
1588
+
1589
+ # Determinar etiqueta de seguimiento para la petición y respuesta (sin datos sensibles)
1590
+ tool_name = params.get("name", "") if isinstance(params, dict) else ""
1591
+ if method == "tools/call" and tool_name:
1592
+ action_tag = f"[tools/call: {tool_name}]"
1593
+ elif method:
1594
+ action_tag = f"[rpc: {method}]"
1595
+ elif req_path.startswith("/zerochat/external/servers/"):
1596
+ action_tag = f"[REST: {req_path}]"
1597
+ else:
1598
+ action_tag = f"[{safe_path}]"
1599
+
1600
+ self._log_req("POST", f"{safe_path} {action_tag}")
1601
+
1602
+ if req_id is None and (method or "").startswith("notifications/"):
1603
+ self.send_response(204)
1604
+ self.send_cors_headers()
1605
+ self.end_headers()
1606
+ self._log_res(204, f"{safe_path} {action_tag}", (time.monotonic() - t0) * 1000)
1607
+ return
1608
+
1609
+ result = None
1610
+ error = None
1611
+ tool_error_info = ""
1612
+ is_external_endpoint = "/mcp/external" in req_path
1613
+
1614
+ if is_external_endpoint:
1615
+ if method == "initialize":
1616
+ result = {
1617
+ "protocolVersion": "2024-11-05",
1618
+ "serverInfo": {
1619
+ "name": "ZeroChat External MCP Host",
1620
+ "version": VERSION
1621
+ },
1622
+ "capabilities": {
1623
+ "tools": {"listChanged": True}
1624
+ }
1625
+ }
1626
+ elif method == "tools/list":
1627
+ result = {"tools": GLOBAL_MCP_MANAGER.tools()}
1628
+ elif method == "tools/call":
1629
+ tool_name = params.get("name", "") if isinstance(params, dict) else ""
1630
+ tool_args = params.get("arguments", {}) if isinstance(params, dict) else {}
1631
+ try:
1632
+ result = GLOBAL_MCP_MANAGER.call(tool_name, tool_args)
1633
+ if isinstance(result, dict) and result.get("isError"):
1634
+ c_list = result.get("content", [])
1635
+ if c_list and isinstance(c_list, list) and isinstance(c_list[0], dict):
1636
+ tool_error_info = c_list[0].get("text", "Error en herramienta MCP externa")
1637
+ else:
1638
+ tool_error_info = "Error en herramienta MCP externa"
1639
+ except Exception as ex:
1640
+ tool_error_info = str(ex)
1641
+ result = {
1642
+ "content": [{"type": "text", "text": json.dumps({"success": False, "error": str(ex)}, ensure_ascii=False)}],
1643
+ "isError": True
1644
+ }
1645
+ else:
1646
+ error = {"code": -32601, "message": f"Método '{method}' no soportado en /mcp/external."}
1647
+ else:
1648
+ if method == "initialize":
1649
+ result = {
1650
+ "protocolVersion": "2024-11-05",
1651
+ "serverInfo": {
1652
+ "name": "ZeroChat Local Server",
1653
+ "version": VERSION
1654
+ },
1655
+ "capabilities": {
1656
+ "tools": {"listChanged": True}
1657
+ }
1658
+ }
1659
+ elif method == "tools/list":
1660
+ result = {"tools": list(LOCAL_TOOLS_DEFINITIONS)}
1661
+ elif method == "tools/call":
1662
+ tool_name = params.get("name", "") if isinstance(params, dict) else ""
1663
+ tool_args = params.get("arguments", {}) if isinstance(params, dict) else {}
1664
+
1665
+ if tool_name in LOCAL_TOOL_HANDLERS:
1666
+ handler = LOCAL_TOOL_HANDLERS[tool_name]
1667
+ try:
1668
+ tool_output_json = handler(**tool_args)
1669
+ is_tool_err = False
1670
+ try:
1671
+ parsed_out = json.loads(tool_output_json)
1672
+ if isinstance(parsed_out, dict) and parsed_out.get("success") is False:
1673
+ is_tool_err = True
1674
+ tool_error_info = str(parsed_out.get("error", "Error en herramienta local"))
1675
+ except Exception:
1676
+ pass
1677
+
1678
+ result = {
1679
+ "content": [{"type": "text", "text": tool_output_json}],
1680
+ "isError": is_tool_err
1681
+ }
1682
+ except Exception as ex:
1683
+ tool_error_info = str(ex)
1684
+ result = {
1685
+ "content": [{"type": "text", "text": json.dumps({"success": False, "error": str(ex)}, ensure_ascii=False)}],
1686
+ "isError": True
1687
+ }
1688
+ elif tool_name.startswith("mcp_"):
1689
+ try:
1690
+ result = GLOBAL_MCP_MANAGER.call(tool_name, tool_args)
1691
+ if isinstance(result, dict) and result.get("isError"):
1692
+ c_list = result.get("content", [])
1693
+ if c_list and isinstance(c_list, list) and isinstance(c_list[0], dict):
1694
+ tool_error_info = c_list[0].get("text", "Error en herramienta MCP")
1695
+ else:
1696
+ tool_error_info = "Error en herramienta MCP"
1697
+ except Exception as ex:
1698
+ tool_error_info = str(ex)
1699
+ result = {
1700
+ "content": [{"type": "text", "text": json.dumps({"success": False, "error": str(ex)}, ensure_ascii=False)}],
1701
+ "isError": True
1702
+ }
1703
+ else:
1704
+ error = {"code": -32601, "message": f"Herramienta local '{tool_name}' no encontrada."}
1705
+ elif method == "zerochat/external/status":
1706
+ result = {
1707
+ "host": "running",
1708
+ "version": VERSION,
1709
+ "servers": GLOBAL_MCP_MANAGER.list_servers()
1710
+ }
1711
+ elif method == "zerochat/external/servers/start":
1712
+ server_id = params.get("serverId") or req.get("serverId")
1713
+ servers = GLOBAL_MCP_MANAGER.start(server_id)
1714
+ result = {"servers": servers}
1715
+ elif method == "zerochat/external/servers/stop":
1716
+ server_id = params.get("serverId") or req.get("serverId")
1717
+ servers = GLOBAL_MCP_MANAGER.stop(server_id)
1718
+ result = {"servers": servers}
1719
+ elif method == "zerochat/external/servers/configure":
1720
+ server_id = params.get("serverId") or req.get("serverId")
1721
+ opts = params.get("options") or req.get("options", {})
1722
+ servers = GLOBAL_MCP_MANAGER.configure(server_id, opts)
1723
+ result = {"servers": servers}
1724
+ else:
1725
+ error = {"code": -32601, "message": f"Método '{method}' no soportado."}
1726
+
1727
+ response_payload = {"jsonrpc": "2.0", "id": req_id}
1728
+ error_info = ""
1729
+ if error:
1730
+ response_payload["error"] = error
1731
+ error_info = f"[{error.get('code')}] {error.get('message')}"
1732
+ else:
1733
+ response_payload["result"] = result
1734
+ if tool_error_info:
1735
+ error_info = tool_error_info
1736
+
1737
+ resp_bytes = json.dumps(response_payload, ensure_ascii=False).encode("utf-8")
1738
+
1739
+ self.send_response(200)
1740
+ self.send_header("Content-Type", "application/json; charset=utf-8")
1741
+ self.send_header("Content-Length", str(len(resp_bytes)))
1742
+ self.send_cors_headers()
1743
+ self.end_headers()
1744
+ self.wfile.write(resp_bytes)
1745
+ self._log_res(200, f"{safe_path} {action_tag}", (time.monotonic() - t0) * 1000, error_info=error_info)
1746
+
1747
+ def log_message(self, format, *args):
1748
+ # Silenciar logs ruidosos por defecto
1749
+ pass
1750
+
1751
+ def is_termux_environment() -> bool:
1752
+ """Devuelve si el proceso se ejecuta dentro de la instalación de Termux."""
1753
+ termux_prefix = "/data/data/com.termux/files/usr"
1754
+ return bool(
1755
+ os.environ.get("TERMUX_VERSION")
1756
+ or os.environ.get("PREFIX", "").startswith(termux_prefix)
1757
+ or sys.prefix.startswith(termux_prefix)
1758
+ )
1759
+
1760
+
1761
+ def get_manual_browser_command(url: str) -> str | None:
1762
+ """Devuelve un comando pegable para abrir la sesión cuando Termux no pudo hacerlo."""
1763
+ if is_termux_environment():
1764
+ return shlex.join(["termux-open-url", url])
1765
+ return None
1766
+
1767
+
1768
+ def get_termux_open_url_executable() -> str | None:
1769
+ """Localiza termux-open-url incluso si el venv recibió un PATH incompleto."""
1770
+ if shutil.which("termux-open-url"):
1771
+ return "termux-open-url"
1772
+
1773
+ prefix = os.environ.get("PREFIX", "/data/data/com.termux/files/usr")
1774
+ executable = Path(prefix) / "bin" / "termux-open-url"
1775
+ if executable.is_file():
1776
+ return str(executable)
1777
+ return None
1778
+
1779
+
1780
+ def browser_launch_diagnostics(attempts: list[str]) -> str:
1781
+ """Resume el entorno y los lanzadores comprobados sin exponer la URL de sesión."""
1782
+ termux_prefix = os.environ.get("PREFIX", "(no definido)")
1783
+ configured_path = os.environ.get("PATH", "(no definido)")
1784
+ termux_binary = Path(termux_prefix) / "bin" / "termux-open-url" if termux_prefix != "(no definido)" else None
1785
+ lines = [
1786
+ f"plataforma={sys.platform}",
1787
+ f"termux_detectado={is_termux_environment()}",
1788
+ f"TERMUX_VERSION={'definido' if os.environ.get('TERMUX_VERSION') else 'no definido'}",
1789
+ f"PREFIX={termux_prefix}",
1790
+ f"PATH={configured_path}",
1791
+ f"termux-open-url_en_PATH={shutil.which('termux-open-url') or 'no encontrado'}",
1792
+ f"termux-open-url_en_PREFIX={str(termux_binary) if termux_binary and termux_binary.is_file() else 'no encontrado'}",
1793
+ f"xdg-open={shutil.which('xdg-open') or 'no encontrado'}",
1794
+ f"gio={shutil.which('gio') or 'no encontrado'}",
1795
+ "intentos=" + ("; ".join(attempts) if attempts else "ninguno"),
1796
+ ]
1797
+ return "\n ".join(lines)
1798
+
1799
+
1800
+ def open_browser(url: str) -> bool:
1801
+ """
1802
+ Abre la URL en el navegador predeterminado del usuario respetando el entorno del sistema.
1803
+ En Termux usa termux-open-url para delegar la apertura en Android. En el resto de
1804
+ Linux prioriza xdg-open o gio para respetar el gestor de ventanas y mimeapps.list,
1805
+ desacoplando el proceso hijo para evitar ruidos en la terminal.
1806
+ """
1807
+ attempts = []
1808
+ if is_termux_environment():
1809
+ termux_open_url = get_termux_open_url_executable()
1810
+ if not termux_open_url:
1811
+ raise FileNotFoundError(
1812
+ "Termux fue detectado, pero no se encontró termux-open-url.\n "
1813
+ + browser_launch_diagnostics(["termux-open-url: no localizado"])
1814
+ )
1815
+ try:
1816
+ subprocess.Popen(
1817
+ [termux_open_url, url],
1818
+ stdout=subprocess.DEVNULL,
1819
+ stderr=subprocess.DEVNULL,
1820
+ start_new_session=True,
1821
+ )
1822
+ return True
1823
+ except OSError as error:
1824
+ raise RuntimeError(
1825
+ f"termux-open-url no pudo iniciarse: {type(error).__name__}: {error}\n "
1826
+ + browser_launch_diagnostics(["termux-open-url: error al iniciar"])
1827
+ ) from error
1828
+ elif sys.platform.startswith("linux"):
1829
+ for cmd in ("xdg-open", "gio"):
1830
+ if shutil.which(cmd):
1831
+ try:
1832
+ args = ["gio", "open", url] if cmd == "gio" else ["xdg-open", url]
1833
+ subprocess.Popen(
1834
+ args,
1835
+ stdout=subprocess.DEVNULL,
1836
+ stderr=subprocess.DEVNULL,
1837
+ start_new_session=True,
1838
+ )
1839
+ return True
1840
+ except OSError as error:
1841
+ attempts.append(f"{cmd}: {type(error).__name__}: {error}")
1842
+ else:
1843
+ attempts.append(f"{cmd}: no encontrado")
1844
+ elif sys.platform == "darwin":
1845
+ if shutil.which("open"):
1846
+ try:
1847
+ subprocess.Popen(
1848
+ ["open", url],
1849
+ stdout=subprocess.DEVNULL,
1850
+ stderr=subprocess.DEVNULL,
1851
+ start_new_session=True,
1852
+ )
1853
+ return True
1854
+ except OSError as error:
1855
+ attempts.append(f"open: {type(error).__name__}: {error}")
1856
+ elif sys.platform == "win32":
1857
+ try:
1858
+ os.startfile(url)
1859
+ return True
1860
+ except OSError as error:
1861
+ attempts.append(f"os.startfile: {type(error).__name__}: {error}")
1862
+
1863
+ try:
1864
+ if webbrowser.open(url):
1865
+ return True
1866
+ except Exception as error:
1867
+ attempts.append(f"webbrowser: {type(error).__name__}: {error}")
1868
+ raise RuntimeError(
1869
+ "El navegador predeterminado rechazó la URL de ZeroChat.\n "
1870
+ + browser_launch_diagnostics(attempts)
1871
+ ) from error
1872
+ attempts.append("webbrowser: devolvió False")
1873
+ raise RuntimeError(
1874
+ "Ningún lanzador de navegador aceptó la URL de ZeroChat.\n "
1875
+ + browser_launch_diagnostics(attempts)
1876
+ )
1877
+
1878
+
1879
+ # ==============================================================================
1880
+ # Punto de Entrada Principal (CLI)
1881
+ # ==============================================================================
1882
+
1883
+ def main():
1884
+ global ACTIVE_PORT, ACTIVE_HOST, SESSION_TOKEN, CONSOLE_CONTROL
1885
+
1886
+ parser = argparse.ArgumentParser(description=f"ZeroChat Local Server v{VERSION}")
1887
+ parser.add_argument("--port", type=int, default=int(os.environ.get("ZEROCHAT_PORT", DEFAULT_PORT)), help=f"Puerto de escucha (default: {DEFAULT_PORT})")
1888
+ parser.add_argument("--host", default=os.environ.get("ZEROCHAT_HOST", DEFAULT_HOST), help=f"Host de escucha (default: {DEFAULT_HOST})")
1889
+ parser.add_argument("--token", default=None, help="Fijar un token de sesión específico (opcional)")
1890
+ parser.add_argument("--ui-url", default=None, help="URL de la interfaz web a abrir (por defecto: interfaz local en desarrollo o GitHub Pages)")
1891
+ parser.add_argument("--no-browser", action="store_true", help="No abrir automáticamente el navegador")
1892
+ parser.add_argument("--no-exit-on-close", action="store_true", help="No detener el servidor automáticamente al cerrar el navegador")
1893
+ parser.add_argument("--no-venv", action="store_true", help="Omitir la comprobación/creación del venv ./zerochat")
1894
+ parser.add_argument("--test", action="store_true", help="Ejecutar autocomprobación interna de herramientas")
1895
+ parser.add_argument("--version", action="version", version=f"ZeroChat {VERSION}")
1896
+ args = parser.parse_args()
1897
+
1898
+ if args.test:
1899
+ print(f"[{time.strftime('%H:%M:%S')}] TEST list_directory {'ok' if json.loads(list_directory('.'))['success'] else 'error'}")
1900
+ print(f"[{time.strftime('%H:%M:%S')}] TEST read_file {'ok' if json.loads(read_file('package.json', max_lines=5))['success'] else 'error'}")
1901
+ print(f"[{time.strftime('%H:%M:%S')}] TEST execute_command {'ok' if json.loads(execute_command('echo hello'))['success'] else 'error'}")
1902
+ print(f"[{time.strftime('%H:%M:%S')}] TEST all local tools ready.")
1903
+ return
1904
+
1905
+ # 1. Asegurar entorno virtual ./zerochat solo cuando se ejecuta desde fuentes.
1906
+ if not args.no_venv:
1907
+ ensure_virtual_environment()
1908
+
1909
+ # 2. Detectar entorno de desarrollo y resolver URL de destino
1910
+ dev_root = get_dev_root()
1911
+ static_root = get_static_root()
1912
+ is_dev = dev_root is not None
1913
+ is_packaged = is_packaged_runtime()
1914
+
1915
+ # 3. Comprobar versión remota en segundo plano (solo fuera del entorno de desarrollo local)
1916
+ if not is_dev:
1917
+ threading.Thread(target=check_version, daemon=True).start()
1918
+
1919
+ ACTIVE_PORT = args.port
1920
+ ACTIVE_HOST = args.host
1921
+ if args.token:
1922
+ SESSION_TOKEN = args.token
1923
+ else:
1924
+ SESSION_TOKEN = get_daily_token()
1925
+
1926
+ server = ThreadingHTTPServer((ACTIVE_HOST, ACTIVE_PORT), ZeroChatServerHandler)
1927
+
1928
+ if args.ui_url:
1929
+ ui_url = args.ui_url
1930
+ elif static_root:
1931
+ ui_url = f"http://{ACTIVE_HOST}:{ACTIVE_PORT}/zerochat.html"
1932
+ else:
1933
+ ui_url = DEFAULT_UI_URL
1934
+
1935
+ browser_host = ACTIVE_HOST if ACTIVE_HOST in {"127.0.0.1", "localhost"} else "127.0.0.1"
1936
+ target_url = f"{ui_url}#{urlencode({'token': SESSION_TOKEN, 'host': browser_host, 'port': ACTIVE_PORT})}"
1937
+ exit_on_close = not args.no_exit_on_close
1938
+
1939
+ print("=" * 64)
1940
+ print(f" ZeroChat Local Server v{VERSION} (UI {UI_VERSION})")
1941
+ print(f" Directorio de trabajo : {Path.cwd()}")
1942
+ print(f" Entorno virtual : {get_venv_dir()}")
1943
+ if is_dev:
1944
+ print(f" Modo de ejecución : Desarrollo local ({dev_root})")
1945
+ elif is_packaged:
1946
+ print(" Modo de ejecución : Paquete PyPI (interfaz local)")
1947
+ else:
1948
+ print(f" Modo de ejecución : Producción (Web universal)")
1949
+ print(f" Servidor HTTP/SSE : http://{ACTIVE_HOST}:{ACTIVE_PORT}")
1950
+ print(" Token de sesión (diario): configurado")
1951
+ print(f" Destino Web : {ui_url}")
1952
+ if exit_on_close:
1953
+ print(f" Auto-cierre : Activado (al cerrar navegador)")
1954
+ else:
1955
+ print(f" Auto-cierre : Desactivado")
1956
+ print("=" * 64, flush=True)
1957
+
1958
+ CONSOLE_CONTROL = ConsoleControl(server, parser)
1959
+ CONSOLE_CONTROL.start()
1960
+
1961
+ if not args.no_browser:
1962
+ termux_detected = is_termux_environment()
1963
+ if termux_detected:
1964
+ console_log(f"[{time.strftime('%H:%M:%S')}] Termux detectado; se abrirá mediante termux-open-url.", flush=True)
1965
+ console_log(f"[{time.strftime('%H:%M:%S')}] Abriendo navegador en la interfaz configurada...", flush=True)
1966
+ try:
1967
+ if not open_browser(target_url):
1968
+ raise RuntimeError("El lanzador de navegador devolvió un resultado sin éxito.")
1969
+ except Exception as e:
1970
+ console_log(f"[{time.strftime('%H:%M:%S')}] No se pudo abrir el navegador automáticamente: {e}", flush=True)
1971
+ console_log(" Traza de diagnóstico:", flush=True)
1972
+ traceback.print_exc()
1973
+ manual_command = get_manual_browser_command(target_url)
1974
+ if manual_command:
1975
+ console_log(" Termux detectado. Prueba este comando exacto:", flush=True)
1976
+ console_log(f" {manual_command}", flush=True)
1977
+
1978
+ if exit_on_close:
1979
+ require_initial = not args.no_browser
1980
+ threading.Thread(
1981
+ target=heartbeat_watchdog,
1982
+ args=(server, DEFAULT_HEARTBEAT_GRACE, DEFAULT_HEARTBEAT_TIMEOUT, require_initial),
1983
+ daemon=True
1984
+ ).start()
1985
+
1986
+ def shutdown(*_):
1987
+ console_log(f"\n[{time.strftime('%H:%M:%S')}] Deteniendo servidor ZeroChat...")
1988
+ stop_zerochat_server(server)
1989
+
1990
+ signal.signal(signal.SIGINT, shutdown)
1991
+ signal.signal(signal.SIGTERM, shutdown)
1992
+
1993
+ try:
1994
+ server.serve_forever()
1995
+ finally:
1996
+ stop_zerochat_server(server)
1997
+ server.server_close()
1998
+ if CONSOLE_CONTROL:
1999
+ CONSOLE_CONTROL.close()
2000
+ CONSOLE_CONTROL = None
2001
+
2002
+
2003
+ if __name__ == "__main__":
2004
+ main()