zerochat 9.8.0__tar.gz → 9.10.0__tar.gz

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.

Potentially problematic release.


This version of zerochat might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zerochat
3
- Version: 9.8.0
3
+ Version: 9.10.0
4
4
  Summary: Private local web client for AI models, tools and MCP services
5
5
  Author: Alberto Alday
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "zerochat"
7
- version = "9.8.0"
7
+ version = "9.10.0"
8
8
  description = "Private local web client for AI models, tools and MCP services"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zerochat
3
- Version: 9.8.0
3
+ Version: 9.10.0
4
4
  Summary: Private local web client for AI models, tools and MCP services
5
5
  Author: Alberto Alday
6
6
  License-Expression: MIT
@@ -46,7 +46,7 @@ import zlib
46
46
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
47
47
  from pathlib import Path
48
48
 
49
- SOURCE_BACKEND_VERSION = "9.8.0"
49
+ SOURCE_BACKEND_VERSION = "9.10.0"
50
50
 
51
51
  def _read_source_version(filename: str) -> str | None:
52
52
  """Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
@@ -316,7 +316,7 @@ ACTIVE_HOST = DEFAULT_HOST
316
316
  # Kept only for this Python process: binds an approved browser tool call to
317
317
  # the exact JSON-RPC bytes sent to the local host.
318
318
  TOOL_AUTH_VERSION = "zerochat-tool-auth-v1"
319
- TOOL_AUTH_TTL_MS = 30_000
319
+ TOOL_AUTH_TTL_MS = 60_000
320
320
  TOOL_AUTH_MAX_NONCES = 10_000
321
321
  TOOL_AUTH_KEY = secrets.token_bytes(32)
322
322
  TOOL_AUTH_SESSION_ID = secrets.token_urlsafe(18)
@@ -1344,6 +1344,92 @@ def set_project_directory(path) -> dict:
1344
1344
  return {"cwd": str(target), "previous_cwd": previous}
1345
1345
 
1346
1346
 
1347
+ # Visores gráficos que `git difftool` ya sabe lanzar, en orden de preferencia, con el ejecutable que los delata.
1348
+ GUI_DIFF_TOOLS = {"vscode": "code", "meld": "meld", "kdiff3": "kdiff3", "kompare": "kompare", "gvimdiff": "gvim"}
1349
+ # Respaldo cuando no hay ninguno: visor web que se instala en el entorno aislado de ZeroChat.
1350
+ WEBDIFF_REQUIREMENT = "webdiff==1.4.0"
1351
+ WEBDIFF_INSTALL_TIMEOUT_SECONDS = 120
1352
+
1353
+
1354
+ def _venv_webdiff() -> Path:
1355
+ """Ruta de webdiff dentro de ~/zerochat/.venv, junto al intérprete del entorno."""
1356
+ venv_python = get_venv_python(get_venv_dir())
1357
+ return venv_python.with_name("webdiff" + venv_python.suffix)
1358
+
1359
+
1360
+ def _find_webdiff() -> str:
1361
+ """webdiff del entorno de ZeroChat o, si no, del PATH; '' si no hay ninguno."""
1362
+ venv_webdiff = _venv_webdiff()
1363
+ if venv_webdiff.exists():
1364
+ return str(venv_webdiff)
1365
+ return shutil.which("webdiff") or ""
1366
+
1367
+
1368
+ def _diff_tool_settings(tool: str, webdiff_bin: str) -> list:
1369
+ """Pares (clave, valor) de git config que deja `tool` como visor gráfico; webdiff no es un visor integrado de git."""
1370
+ settings = [("diff.guitool", tool)]
1371
+ if tool == "webdiff":
1372
+ settings.insert(0, ("difftool.webdiff.cmd", f'"{webdiff_bin}" "$LOCAL" "$REMOTE"'))
1373
+ return settings
1374
+
1375
+
1376
+ def detect_gui_diff_tool() -> dict:
1377
+ """Visor que proponer cuando Git no tiene ninguno: el primero instalado o, si no hay, webdiff.
1378
+
1379
+ Incluye las órdenes exactas que aplicará configure_gui_diff_tool, para mostrarlas antes de confirmar.
1380
+ """
1381
+ tool = next((name for name, binary in GUI_DIFF_TOOLS.items() if shutil.which(binary)), "webdiff")
1382
+ webdiff_bin = _find_webdiff()
1383
+ return {
1384
+ "tool": tool,
1385
+ "install": tool == "webdiff" and not webdiff_bin,
1386
+ "commands": [
1387
+ shlex.join(["git", "config", "--global", key, value])
1388
+ for key, value in _diff_tool_settings(tool, webdiff_bin or str(_venv_webdiff()))
1389
+ ],
1390
+ }
1391
+
1392
+
1393
+ def _install_webdiff() -> str:
1394
+ """Instala webdiff en ~/zerochat/.venv y devuelve la ruta del ejecutable."""
1395
+ ensure_virtual_environment()
1396
+ venv_python = get_venv_python(get_venv_dir())
1397
+ if not venv_python.exists():
1398
+ raise RuntimeError(f"No se pudo preparar el entorno virtual de ZeroChat: falta {venv_python}")
1399
+ res = subprocess.run(
1400
+ [str(venv_python), "-m", "pip", "install", WEBDIFF_REQUIREMENT],
1401
+ capture_output=True,
1402
+ text=True,
1403
+ timeout=WEBDIFF_INSTALL_TIMEOUT_SECONDS,
1404
+ check=False
1405
+ )
1406
+ if res.returncode != 0:
1407
+ raise RuntimeError(f"Error instalando webdiff: {(res.stderr or res.stdout).strip()[-500:]}")
1408
+ webdiff = _venv_webdiff()
1409
+ if not webdiff.exists():
1410
+ raise RuntimeError(f"webdiff se instaló pero no aparece en {webdiff}")
1411
+ return str(webdiff)
1412
+
1413
+
1414
+ def configure_gui_diff_tool(tool) -> dict:
1415
+ """Deja `tool` como visor gráfico global de Git, instalando antes webdiff si es el elegido y falta."""
1416
+ if not isinstance(tool, str) or (tool != "webdiff" and tool not in GUI_DIFF_TOOLS):
1417
+ raise ValueError(f"Visor de diffs '{tool}' no reconocido.")
1418
+ git_bin = shutil.which("git")
1419
+ if not git_bin:
1420
+ raise RuntimeError("Git no está instalado o no se encuentra en el PATH del sistema.")
1421
+
1422
+ webdiff_bin = ""
1423
+ if tool == "webdiff":
1424
+ webdiff_bin = _find_webdiff() or _install_webdiff()
1425
+ elif not shutil.which(GUI_DIFF_TOOLS[tool]):
1426
+ raise ValueError(f"'{GUI_DIFF_TOOLS[tool]}' no está en el PATH del sistema.")
1427
+
1428
+ for key, value in _diff_tool_settings(tool, webdiff_bin):
1429
+ subprocess.run([git_bin, "config", "--global", key, value], capture_output=True, timeout=5, check=True)
1430
+ return {"tool": tool}
1431
+
1432
+
1347
1433
  def _configured_gui_diff_tool(git_bin: str, repo_root: Path) -> str:
1348
1434
  """Herramienta gráfica que usaría `git difftool --gui`, o '' si no hay ninguna configurada.
1349
1435
 
@@ -1365,7 +1451,10 @@ def _configured_gui_diff_tool(git_bin: str, repo_root: Path) -> str:
1365
1451
 
1366
1452
 
1367
1453
  def open_file_diff(path: str, commit_fallback: bool = False) -> dict:
1368
- """Abre un visor externo de diffs (git difftool) para comparar el fichero actual frente a Git HEAD, su último commit o fichero vacío."""
1454
+ """Abre un visor externo de diffs (git difftool) para comparar el fichero actual frente a Git HEAD, su último commit o fichero vacío.
1455
+
1456
+ Sin visor gráfico configurado no lanza nada y devuelve en `setup` el que propone configurar.
1457
+ """
1369
1458
  if not isinstance(path, str) or not path.strip():
1370
1459
  raise ValueError("La ruta del archivo está vacía.")
1371
1460
  if len(path) > MAX_PATH_LENGTH:
@@ -1398,10 +1487,7 @@ def open_file_diff(path: str, commit_fallback: bool = False) -> dict:
1398
1487
 
1399
1488
  repo_root = Path(res.stdout.strip()).resolve()
1400
1489
  if not _configured_gui_diff_tool(git_bin, repo_root):
1401
- raise ValueError(
1402
- "No hay un visor gráfico de diffs configurado en Git. Configura uno, por ejemplo: "
1403
- "git config --global diff.guitool meld"
1404
- )
1490
+ return {"success": False, "setup": detect_gui_diff_tool()}
1405
1491
 
1406
1492
  has_head = subprocess.run(
1407
1493
  [git_bin, "rev-parse", "--verify", "HEAD"],
@@ -2218,18 +2304,22 @@ def playwright_launch_env(config: dict) -> dict[str, str]:
2218
2304
 
2219
2305
 
2220
2306
  def validate_profile_browser(raw) -> dict:
2221
- """Navegador que exige un perfil: el propio con un motor o el del usuario con uno de sus navegadores."""
2222
- if isinstance(raw, dict) and raw.get("mode") == "own" and set(raw) == {"mode", "engine"} and raw["engine"] in OWN_BROWSER_ENGINES:
2223
- return {"mode": "own", "engine": raw["engine"]}
2307
+ """Navegador que exige un perfil: el propio con un motor, y headless False si exige la ventana
2308
+ visible, o el del usuario con uno de sus navegadores."""
2309
+ if isinstance(raw, dict) and raw.get("mode") == "own" and raw.get("engine") in OWN_BROWSER_ENGINES:
2310
+ if set(raw) == {"mode", "engine"}:
2311
+ return {"mode": "own", "engine": raw["engine"]}
2312
+ if set(raw) == {"mode", "engine", "headless"} and raw["headless"] is False:
2313
+ return {"mode": "own", "engine": raw["engine"], "headless": False}
2224
2314
  if isinstance(raw, dict) and raw.get("mode") == "user" and set(raw) == {"mode", "userBrowser"} and raw["userBrowser"] in USER_BROWSERS:
2225
2315
  return {"mode": "user", "userBrowser": raw["userBrowser"]}
2226
- raise ValueError("browser debe ser {mode: own, engine} o {mode: user, userBrowser} con un motor o navegador admitido")
2316
+ raise ValueError("browser debe ser {mode: own, engine[, headless: false]} o {mode: user, userBrowser} con un motor o navegador admitido")
2227
2317
 
2228
2318
 
2229
2319
  def browser_choice(config: dict) -> dict:
2230
2320
  """Modo y navegador con los que arranca Playwright MCP, en la forma de validate_profile_browser."""
2231
2321
  if config["mode"] == "own":
2232
- return {"mode": "own", "engine": config["engine"]}
2322
+ return {"mode": "own", "engine": config["engine"], **({} if config["headless"] else {"headless": False})}
2233
2323
  return {"mode": "user", "userBrowser": config["userBrowser"]}
2234
2324
 
2235
2325
 
@@ -3774,6 +3864,13 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3774
3864
  return None, {"code": -32602, "message": str(exc)}, ""
3775
3865
  except Exception as exc:
3776
3866
  return None, {"code": -32603, "message": f"Error al abrir el visor de diffs: {exc}"}, str(exc)
3867
+ if method == "zerochat/diff/configure":
3868
+ try:
3869
+ return configure_gui_diff_tool(params.get("tool")), None, ""
3870
+ except (ValueError, RuntimeError) as exc:
3871
+ return None, {"code": -32602, "message": str(exc)}, ""
3872
+ except Exception as exc:
3873
+ return None, {"code": -32603, "message": f"Error configurando el visor de diffs: {exc}"}, str(exc)
3777
3874
  if method == "zerochat/web/fetch":
3778
3875
  try:
3779
3876
  return fetch_web_resource(params), None, ""
@@ -4356,10 +4453,8 @@ class ZeroChatArgumentParser(argparse.ArgumentParser):
4356
4453
  self.exit(RUN_EXIT_ARGS, f"{self.prog}: error: {message}\n")
4357
4454
 
4358
4455
 
4359
- def main():
4360
- global ACTIVE_PORT, ACTIVE_HOST, SESSION_TOKEN, CONSOLE_CONTROL, EXIT_ON_CLOSE
4361
- global TOOL_AUTH_KEY, TOOL_AUTH_SESSION_ID, TOOL_AUTH_NONCES, RUN_SESSION
4362
-
4456
+ def build_parser() -> ZeroChatArgumentParser:
4457
+ """Opciones de la línea de órdenes."""
4363
4458
  parser = ZeroChatArgumentParser(description=f"ZeroChat Local Server v{VERSION}")
4364
4459
  # Desde Python 3.14 argparse colorea la ayuda con códigos ANSI; la consola la muestra en texto plano.
4365
4460
  parser.color = False
@@ -4367,6 +4462,7 @@ def main():
4367
4462
  parser.add_argument("--host", default=os.environ.get("ZEROCHAT_HOST", DEFAULT_HOST), help=f"Host de escucha (default: {DEFAULT_HOST})")
4368
4463
  parser.add_argument("--ui-url", default=None, help="URL de la interfaz web a abrir (por defecto: interfaz local en desarrollo o GitHub Pages)")
4369
4464
  parser.add_argument("--no-browser", action="store_true", help="No abrir automáticamente el navegador")
4465
+ parser.add_argument("--print-url", action="store_true", help="Imprimir la URL de la interfaz con el token, entre líneas en blanco para copiarla (implica --no-browser)")
4370
4466
  parser.add_argument("--keep-server", action="store_true", help="Servidor: seguir en marcha al cerrar el navegador (no se combina con --prompt)")
4371
4467
  parser.add_argument("--no-venv", action="store_true", help="Omitir la comprobación/creación de ~/zerochat/.venv")
4372
4468
  parser.add_argument("--test", action="store_true", help="Ejecutar autocomprobación interna de herramientas")
@@ -4378,76 +4474,79 @@ def main():
4378
4474
  parser.add_argument("--allow-dangerous-tools", action="store_true", help="Con --prompt, aprobar también las herramientas que escriben o ejecutan (write_file, edit_file, run_command); las demás se aprueban siempre para la sesión")
4379
4475
  parser.add_argument("--headless", action="store_true", help="Con --prompt, abrir el navegador predeterminado sin ventana (si ya está abierto puede verse la pestaña)")
4380
4476
  parser.add_argument("--version", action="version", version=f"ZeroChat {VERSION}")
4381
- args = parser.parse_args()
4477
+ return parser
4382
4478
 
4383
- # Los avisos no persisten entre ejecuciones del servidor.
4384
- reset_notices()
4385
4479
 
4386
- # Modo ejecución: stdout queda solo para la respuesta final; todo el registro va a stderr.
4480
+ def run_session_from_args(parser: ZeroChatArgumentParser, args) -> RunSession | None:
4481
+ """Valida las combinaciones de opciones y devuelve la sesión de --prompt, si la hay."""
4482
+ # Quien pide la URL la abrirá en otro sitio (otro equipo, un túnel SSH): no se abre el navegador local.
4483
+ if args.print_url and args.prompt is None:
4484
+ args.no_browser = True
4485
+ if args.prompt is None:
4486
+ if args.headless or args.keep_page or args.allow_dangerous_tools or args.profile is not None or args.export is not None:
4487
+ parser.error("--headless, --keep-page, --allow-dangerous-tools, --profile y --export solo se usan con --prompt")
4488
+ return None
4489
+ if not args.prompt.strip():
4490
+ parser.error("el prompt está vacío")
4491
+ if args.no_browser or args.print_url:
4492
+ parser.error("--prompt necesita abrir el navegador: no se combina con --no-browser ni con --print-url")
4493
+ if args.keep_server:
4494
+ parser.error("--prompt termina al devolver el resultado: no se combina con --keep-server (para no cerrar la página, --keep-page)")
4495
+ if args.profile is not None and not args.profile.strip():
4496
+ parser.error("--profile no puede estar vacío")
4497
+ if args.keep_page and args.headless:
4498
+ parser.error("--keep-page necesita la ventana del navegador: no se combina con --headless")
4499
+ export_path = None
4500
+ if args.export is not None:
4501
+ export_path = Path(args.export).expanduser().absolute()
4502
+ if export_path.suffix.lower() not in RUN_EXPORT_FORMATS:
4503
+ parser.error(f"--export necesita una de estas extensiones: {', '.join(RUN_EXPORT_FORMATS)}")
4504
+ if not export_path.parent.is_dir():
4505
+ parser.error(f"--export: no existe el directorio {export_path.parent}")
4506
+ return RunSession(args.prompt.strip(), args.profile.strip() if args.profile else None, export_path, args.keep_page, args.allow_dangerous_tools)
4507
+
4508
+
4509
+ def redirect_log(args):
4510
+ """Todo el registro pasa por stdout: con --prompt va a stderr para dejar stdout a la respuesta,
4511
+ y con --log off se descarta. Devuelve el stdout original, el de la respuesta."""
4387
4512
  result_out = sys.stdout
4388
- if args.prompt is None and (args.headless or args.keep_page or args.allow_dangerous_tools or args.profile is not None or args.export is not None):
4389
- parser.error("--headless, --keep-page, --allow-dangerous-tools, --profile y --export solo se usan con --prompt")
4390
- if args.prompt is not None:
4391
- if not args.prompt.strip():
4392
- parser.error("el prompt está vacío")
4393
- if args.no_browser:
4394
- parser.error("--prompt necesita abrir el navegador: no se combina con --no-browser")
4395
- if args.keep_server:
4396
- parser.error("--prompt termina al devolver el resultado: no se combina con --keep-server (para no cerrar la página, --keep-page)")
4397
- if args.profile is not None and not args.profile.strip():
4398
- parser.error("--profile no puede estar vacío")
4399
- if args.keep_page and args.headless:
4400
- parser.error("--keep-page necesita la ventana del navegador: no se combina con --headless")
4401
- export_path = None
4402
- if args.export is not None:
4403
- export_path = Path(args.export).expanduser().absolute()
4404
- if export_path.suffix.lower() not in RUN_EXPORT_FORMATS:
4405
- parser.error(f"--export necesita una de estas extensiones: {', '.join(RUN_EXPORT_FORMATS)}")
4406
- if not export_path.parent.is_dir():
4407
- parser.error(f"--export: no existe el directorio {export_path.parent}")
4408
- RUN_SESSION = RunSession(args.prompt.strip(), args.profile.strip() if args.profile else None, export_path, args.keep_page, args.allow_dangerous_tools)
4409
- # Todo el registro pasa por stdout: con --prompt va a stderr para dejar stdout a la respuesta,
4410
- # y con --log off se descarta.
4411
4513
  log_enabled = args.log == "on" if args.log else RUN_SESSION is None
4412
4514
  if not log_enabled:
4413
4515
  sys.stdout = open(os.devnull, "w", encoding="utf-8")
4414
4516
  elif RUN_SESSION:
4415
4517
  sys.stdout = sys.stderr
4518
+ return result_out
4416
4519
 
4417
- if args.test:
4418
- print(timestamp_message(f"TEST list_directory {'ok' if json.loads(list_directory('.'))['success'] else 'error'}"))
4419
- print(timestamp_message(f"TEST read_file {'ok' if json.loads(read_file('package.json', max_lines=5))['success'] else 'error'}"))
4420
- print(timestamp_message(f"TEST run_command {'ok' if json.loads(run_command('echo hello'))['success'] else 'error'}"))
4421
- print(timestamp_message("TEST all local tools ready."))
4422
- return
4423
4520
 
4521
+ def run_self_test():
4522
+ """--test: comprueba las herramientas locales básicas sin arrancar el servidor."""
4523
+ print(timestamp_message(f"TEST list_directory {'ok' if json.loads(list_directory('.'))['success'] else 'error'}"))
4524
+ print(timestamp_message(f"TEST read_file {'ok' if json.loads(read_file('package.json', max_lines=5))['success'] else 'error'}"))
4525
+ print(timestamp_message(f"TEST run_command {'ok' if json.loads(run_command('echo hello'))['success'] else 'error'}"))
4526
+ print(timestamp_message("TEST all local tools ready."))
4527
+
4528
+
4529
+ def prepare_environment(args, is_dev: bool):
4530
+ """Limpia restos de una sesión anterior, prepara el entorno MCP y lanza las comprobaciones de fondo."""
4424
4531
  orphans = cleanup_orphan_jobs()
4425
4532
  if orphans:
4426
4533
  print(timestamp_message(f"Detenidos {orphans} comando(s) que seguían en marcha de una sesión anterior interrumpida."))
4427
-
4428
- # 1. Asegurar el entorno MCP aislado en ambos modos de distribución.
4534
+ # Entorno MCP aislado en ambos modos de distribución.
4429
4535
  if not args.no_venv:
4430
4536
  ensure_virtual_environment()
4431
-
4432
- browser_availability = browser_action_availability()
4433
- if not browser_availability.get("available"):
4537
+ if not browser_action_availability().get("available"):
4434
4538
  add_notice(
4435
4539
  "browser_action desactivada: para usarla, inicia el servicio 'playwright' en Ajustes → MCP "
4436
4540
  "(se instala solo). Requiere Node.js y disponer de Chrome o Edge en el sistema."
4437
4541
  )
4438
-
4439
- # 2. Detectar entorno de desarrollo y resolver URL de destino
4440
- dev_root = get_dev_root()
4441
- static_root = get_static_root()
4442
- is_dev = dev_root is not None
4443
- is_installed = is_installed_runtime()
4444
-
4445
- # 3. Comprobar versión remota en segundo plano (solo fuera del entorno de desarrollo local)
4542
+ # Versión remota en segundo plano, solo fuera del entorno de desarrollo local.
4446
4543
  if not is_dev:
4447
4544
  threading.Thread(target=check_version, daemon=True).start()
4448
4545
 
4449
- ACTIVE_PORT = args.port if args.port is not None else int(os.environ.get("ZEROCHAT_PORT", DEFAULT_PORT))
4450
- ACTIVE_HOST = args.host
4546
+
4547
+ def reset_session_credentials():
4548
+ """Carga el token diario y renueva la clave de firma de herramientas."""
4549
+ global SESSION_TOKEN, TOOL_AUTH_KEY, TOOL_AUTH_SESSION_ID, TOOL_AUTH_NONCES
4451
4550
  SESSION_TOKEN = get_daily_token()
4452
4551
  # A restart deliberately invalidates every browser-side signing credential.
4453
4552
  TOOL_AUTH_KEY = secrets.token_bytes(32)
@@ -4455,10 +4554,14 @@ def main():
4455
4554
  with TOOL_AUTH_NONCES_LOCK:
4456
4555
  TOOL_AUTH_NONCES = {}
4457
4556
 
4458
- # La interfaz local de desarrollo tiene el puerto en su origen: en otro no vería los datos guardados.
4459
- local_ui = static_root is not None and not args.ui_url
4557
+
4558
+ def open_server(args, local_ui: bool) -> ThreadingHTTPServer:
4559
+ """Abre el puerto de escucha; con --prompt, si el puerto por defecto está ocupado, usa uno libre."""
4560
+ global ACTIVE_PORT, ACTIVE_HOST
4561
+ ACTIVE_PORT = args.port if args.port is not None else int(os.environ.get("ZEROCHAT_PORT", DEFAULT_PORT))
4562
+ ACTIVE_HOST = args.host
4460
4563
  try:
4461
- server = ThreadingHTTPServer((ACTIVE_HOST, ACTIVE_PORT), ZeroChatServerHandler)
4564
+ return ThreadingHTTPServer((ACTIVE_HOST, ACTIVE_PORT), ZeroChatServerHandler)
4462
4565
  except OSError as exc:
4463
4566
  if not RUN_SESSION:
4464
4567
  raise
@@ -4466,42 +4569,42 @@ def main():
4466
4569
  hint = "" if args.port is not None else " (en desarrollo la interfaz local necesita ese puerto: cierra el zerochat que lo usa)"
4467
4570
  run_error(f"No se pudo escuchar en {ACTIVE_HOST}:{ACTIVE_PORT}: {exc}{hint}")
4468
4571
  sys.exit(RUN_EXIT_SETUP)
4469
- # Otro zerochat (u otro programa) usa el puerto por defecto: la ejecución usa uno libre.
4470
- try:
4471
- server = ThreadingHTTPServer((ACTIVE_HOST, 0), ZeroChatServerHandler)
4472
- except OSError as retry_exc:
4473
- run_error(f"No se pudo escuchar en {ACTIVE_HOST}: {retry_exc}")
4474
- sys.exit(RUN_EXIT_SETUP)
4475
- log_event(f"El puerto {ACTIVE_PORT} está ocupado; la ejecución usa el {server.server_address[1]}.")
4476
- ACTIVE_PORT = server.server_address[1]
4477
- exposure_warning = remote_exposure_warning(ACTIVE_HOST)
4478
- if exposure_warning:
4479
- add_notice(exposure_warning)
4572
+ # Otro zerochat (u otro programa) usa el puerto por defecto: la ejecución usa uno libre.
4573
+ try:
4574
+ server = ThreadingHTTPServer((ACTIVE_HOST, 0), ZeroChatServerHandler)
4575
+ except OSError as retry_exc:
4576
+ run_error(f"No se pudo escuchar en {ACTIVE_HOST}: {retry_exc}")
4577
+ sys.exit(RUN_EXIT_SETUP)
4578
+ log_event(f"El puerto {ACTIVE_PORT} está ocupado; la ejecución usa el {server.server_address[1]}.")
4579
+ ACTIVE_PORT = server.server_address[1]
4580
+ return server
4581
+
4480
4582
 
4583
+ def interface_urls(args, static_root) -> tuple[str, str]:
4584
+ """Devuelve la URL de la interfaz y la de destino, con el token y el servidor en el fragmento."""
4481
4585
  if args.ui_url:
4482
4586
  ui_url = args.ui_url
4483
4587
  elif static_root:
4484
4588
  ui_url = f"http://{ACTIVE_HOST}:{ACTIVE_PORT}/zerochat.html"
4485
4589
  else:
4486
4590
  ui_url = DEFAULT_UI_URL
4487
-
4488
4591
  browser_host = ACTIVE_HOST if ACTIVE_HOST in {"127.0.0.1", "localhost"} else "127.0.0.1"
4489
4592
  fragment = {'token': SESSION_TOKEN, 'host': browser_host, 'port': ACTIVE_PORT}
4490
4593
  if RUN_SESSION:
4491
4594
  fragment['run'] = RUN_SESSION.run_id
4492
- target_url = f"{ui_url}#{urlencode(fragment)}"
4493
- # El modo ejecución (que rechaza --keep-server) detecta con el latido que el navegador se cerró sin devolver resultado.
4494
- exit_on_close = not args.keep_server
4495
- EXIT_ON_CLOSE = exit_on_close
4595
+ return ui_url, f"{ui_url}#{urlencode(fragment)}"
4596
+
4496
4597
 
4598
+ def print_header(dev_root, ui_url: str, exposure_warning: str | None, exit_on_close: bool):
4599
+ """Cabecera del servidor con su configuración (sin el token)."""
4497
4600
  print("=" * 64)
4498
4601
  print(f" ZeroChat Local Server v{VERSION} (UI {UI_VERSION})")
4499
4602
  print(f" Directorio de trabajo : {Path.cwd()}")
4500
4603
  print(f" Datos y MCP : {get_data_dir()}")
4501
4604
  print(f" Entorno MCP : {get_venv_dir()}")
4502
- if is_dev:
4605
+ if dev_root is not None:
4503
4606
  print(f" Modo de ejecución : Desarrollo local ({dev_root})")
4504
- elif is_installed:
4607
+ elif is_installed_runtime():
4505
4608
  print(" Modo de ejecución : Paquete PyPI (GitHub Pages)")
4506
4609
  else:
4507
4610
  print(" Modo de ejecución : Producción (Web universal)")
@@ -4516,31 +4619,43 @@ def main():
4516
4619
  print(" Auto-cierre : Desactivado")
4517
4620
  print("=" * 64, flush=True)
4518
4621
 
4519
- headless_browser = None
4520
- if RUN_SESSION:
4521
- headless_browser = launch_headless_browser(target_url) if args.headless else None
4522
- if headless_browser is None and not launch_browser(target_url):
4523
- run_error("No se pudo abrir el navegador.")
4524
- server.server_close()
4525
- sys.stdout = result_out
4526
- sys.exit(RUN_EXIT_SETUP)
4527
- if headless_browser is not None:
4528
- threading.Thread(target=fall_back_when_headless_exits, args=(headless_browser, target_url), daemon=True).start()
4529
- threading.Thread(target=run_supervisor, args=(server, RUN_SESSION), daemon=True).start()
4530
- else:
4622
+
4623
+ def start_interface(args, parser, server: ThreadingHTTPServer, target_url: str, result_out):
4624
+ """Abre la interfaz: en modo ejecución, con su supervisor; si no, con el control de consola.
4625
+ Devuelve el navegador sin ventana de --headless, si se lanzó."""
4626
+ global CONSOLE_CONTROL
4627
+ if args.print_url:
4628
+ # Líneas en blanco alrededor para seleccionarla y copiarla con facilidad (p. ej. en una consola SSH del móvil).
4629
+ print("\n" * 5 + target_url + "\n" * 5, flush=True)
4630
+ if not RUN_SESSION:
4531
4631
  CONSOLE_CONTROL = ConsoleControl(server, parser, target_url=target_url)
4532
4632
  CONSOLE_CONTROL.start()
4533
4633
  if not args.no_browser:
4534
4634
  launch_browser(target_url)
4635
+ return None
4636
+ headless_browser = launch_headless_browser(target_url) if args.headless else None
4637
+ if headless_browser is None and not launch_browser(target_url):
4638
+ run_error("No se pudo abrir el navegador.")
4639
+ server.server_close()
4640
+ sys.stdout = result_out
4641
+ sys.exit(RUN_EXIT_SETUP)
4642
+ if headless_browser is not None:
4643
+ threading.Thread(target=fall_back_when_headless_exits, args=(headless_browser, target_url), daemon=True).start()
4644
+ threading.Thread(target=run_supervisor, args=(server, RUN_SESSION), daemon=True).start()
4645
+ return headless_browser
4646
+
4647
+
4648
+ def start_heartbeat_watchdog(server: ThreadingHTTPServer, require_initial: bool):
4649
+ """Detiene el servidor cuando el navegador deja de enviar latidos."""
4650
+ threading.Thread(
4651
+ target=heartbeat_watchdog,
4652
+ args=(server, DEFAULT_HEARTBEAT_GRACE, DEFAULT_HEARTBEAT_TIMEOUT, require_initial),
4653
+ daemon=True
4654
+ ).start()
4535
4655
 
4536
- if exit_on_close:
4537
- require_initial = not args.no_browser
4538
- threading.Thread(
4539
- target=heartbeat_watchdog,
4540
- args=(server, DEFAULT_HEARTBEAT_GRACE, DEFAULT_HEARTBEAT_TIMEOUT, require_initial),
4541
- daemon=True
4542
- ).start()
4543
4656
 
4657
+ def install_signal_handlers(server: ThreadingHTTPServer):
4658
+ """Ctrl+C, SIGTERM y el cierre del terminal detienen el servidor de forma ordenada."""
4544
4659
  def shutdown(*_):
4545
4660
  console_log("\n" + timestamp_message("Deteniendo servidor ZeroChat..."))
4546
4661
  stop_zerochat_server(server)
@@ -4551,6 +4666,10 @@ def main():
4551
4666
  if hasattr(signal, "SIGHUP"):
4552
4667
  signal.signal(signal.SIGHUP, shutdown)
4553
4668
 
4669
+
4670
+ def serve(server: ThreadingHTTPServer):
4671
+ """Atiende peticiones hasta la parada y libera después el servidor y la consola."""
4672
+ global CONSOLE_CONTROL
4554
4673
  try:
4555
4674
  server.serve_forever()
4556
4675
  finally:
@@ -4560,16 +4679,58 @@ def main():
4560
4679
  CONSOLE_CONTROL.close()
4561
4680
  CONSOLE_CONTROL = None
4562
4681
 
4682
+
4683
+ def finish_run(headless_browser, result_out):
4684
+ """Modo ejecución: escribe la respuesta final en el stdout original y sale con su código."""
4685
+ # Firefox sin ventana sigue vivo al cerrar la página; uno ya abierto solo recibió la pestaña.
4686
+ if headless_browser is not None and headless_browser.poll() is None:
4687
+ _kill_process_tree(headless_browser)
4688
+ code, text = run_outcome(RUN_SESSION)
4689
+ sys.stdout = result_out
4690
+ if text is not None:
4691
+ result_out.write(text if text.endswith("\n") else text + "\n")
4692
+ result_out.flush()
4693
+ sys.exit(code)
4694
+
4695
+
4696
+ def main():
4697
+ global RUN_SESSION, EXIT_ON_CLOSE
4698
+
4699
+ parser = build_parser()
4700
+ args = parser.parse_args()
4701
+ # Los avisos no persisten entre ejecuciones del servidor.
4702
+ reset_notices()
4703
+ RUN_SESSION = run_session_from_args(parser, args)
4704
+ result_out = redirect_log(args)
4705
+
4706
+ if args.test:
4707
+ run_self_test()
4708
+ return
4709
+
4710
+ dev_root = get_dev_root()
4711
+ static_root = get_static_root()
4712
+ prepare_environment(args, is_dev=dev_root is not None)
4713
+ reset_session_credentials()
4714
+
4715
+ # La interfaz local de desarrollo tiene el puerto en su origen: en otro no vería los datos guardados.
4716
+ server = open_server(args, local_ui=static_root is not None and not args.ui_url)
4717
+ exposure_warning = remote_exposure_warning(ACTIVE_HOST)
4718
+ if exposure_warning:
4719
+ add_notice(exposure_warning)
4720
+
4721
+ ui_url, target_url = interface_urls(args, static_root)
4722
+ # El modo ejecución (que rechaza --keep-server) detecta con el latido que el navegador se cerró sin devolver resultado.
4723
+ EXIT_ON_CLOSE = not args.keep_server
4724
+ print_header(dev_root, ui_url, exposure_warning, EXIT_ON_CLOSE)
4725
+
4726
+ headless_browser = start_interface(args, parser, server, target_url, result_out)
4727
+ if EXIT_ON_CLOSE:
4728
+ start_heartbeat_watchdog(server, require_initial=not args.no_browser)
4729
+ install_signal_handlers(server)
4730
+ serve(server)
4731
+
4563
4732
  if RUN_SESSION:
4564
- # Firefox sin ventana sigue vivo al cerrar la página; uno ya abierto solo recibió la pestaña.
4565
- if headless_browser is not None and headless_browser.poll() is None:
4566
- _kill_process_tree(headless_browser)
4567
- code, text = run_outcome(RUN_SESSION)
4568
- sys.stdout = result_out
4569
- if text is not None:
4570
- result_out.write(text if text.endswith("\n") else text + "\n")
4571
- result_out.flush()
4572
- sys.exit(code)
4733
+ finish_run(headless_browser, result_out)
4573
4734
 
4574
4735
 
4575
4736
  if __name__ == "__main__":
File without changes
File without changes
File without changes