zerochat 9.5.0__tar.gz → 9.6.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.5.0
3
+ Version: 9.6.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.5.0"
7
+ version = "9.6.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.5.0
3
+ Version: 9.6.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.5.0"
49
+ SOURCE_BACKEND_VERSION = "9.6.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."""
@@ -2804,7 +2804,8 @@ HEARTBEAT_WATCHDOG_STOP = threading.Event()
2804
2804
  _SERVER_SHUTTING_DOWN = threading.Event()
2805
2805
  # Si el servidor se detiene al cerrar el navegador, cerrar la pestaña detiene también los comandos.
2806
2806
  EXIT_ON_CLOSE = False
2807
- QUIET_RPC_METHODS = frozenset({"zerochat/jobs/get", "zerochat/jobs/log", "zerochat/jobs/list"})
2807
+ # zerochat/run/progress ya se registra con su propio texto en dispatch_run.
2808
+ QUIET_RPC_METHODS = frozenset({"zerochat/jobs/get", "zerochat/jobs/log", "zerochat/jobs/list", "zerochat/run/progress"})
2808
2809
 
2809
2810
 
2810
2811
  def mark_browser_active():
@@ -3277,9 +3278,10 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3277
3278
  self._log_req("POST", safe_path)
3278
3279
  self._reject(400, {"error": "Invalid Content-Length"}, safe_path, t0, "Content-Length negativo")
3279
3280
  return None
3280
- if content_len > MAX_HTTP_BODY_BYTES:
3281
+ body_limit = http_body_limit()
3282
+ if content_len > body_limit:
3281
3283
  self._log_req("POST", safe_path)
3282
- if content_len <= MAX_HTTP_BODY_BYTES * 2:
3284
+ if content_len <= body_limit * 2:
3283
3285
  try:
3284
3286
  self.rfile.read(content_len)
3285
3287
  except (OSError, ValueError):
@@ -3440,6 +3442,8 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3440
3442
  return None, {"code": -32603, "message": f"Error en transporte web: {exc}"}, str(exc)
3441
3443
  if method.startswith("zerochat/jobs/"):
3442
3444
  return self._dispatch_jobs(method, params)
3445
+ if method.startswith("zerochat/run/"):
3446
+ return dispatch_run(method, params)
3443
3447
  if method == "zerochat/external/status":
3444
3448
  return {"host": "running", "version": VERSION, "servers": GLOBAL_MCP_MANAGER.list_servers()}, None, ""
3445
3449
  if method in ("zerochat/external/servers/start", "zerochat/external/servers/stop", "zerochat/external/servers/configure"):
@@ -3719,6 +3723,233 @@ def launch_browser(url: str) -> bool:
3719
3723
 
3720
3724
 
3721
3725
 
3726
+
3727
+
3728
+ def default_browser_executable() -> str | None:
3729
+ """Ejecutable del navegador predeterminado en Linux (xdg-settings y su fichero .desktop), o None."""
3730
+ if not sys.platform.startswith("linux") or is_termux_environment() or not shutil.which("xdg-settings"):
3731
+ return None
3732
+ try:
3733
+ desktop = subprocess.run(["xdg-settings", "get", "default-web-browser"], capture_output=True, text=True, timeout=5).stdout.strip()
3734
+ except (OSError, subprocess.SubprocessError):
3735
+ return None
3736
+ if not desktop.endswith(".desktop") or "/" in desktop:
3737
+ return None
3738
+ data_dirs = [os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")]
3739
+ data_dirs += (os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share").split(":")
3740
+ for data_dir in data_dirs:
3741
+ entry = Path(data_dir) / "applications" / desktop
3742
+ if not entry.is_file():
3743
+ continue
3744
+ try:
3745
+ lines = entry.read_text(encoding="utf-8", errors="replace").splitlines()
3746
+ except OSError:
3747
+ return None
3748
+ exec_line = next((line[5:] for line in lines if line.startswith("Exec=")), "")
3749
+ try:
3750
+ tokens = [token for token in shlex.split(exec_line) if token != "env" and "=" not in token]
3751
+ except ValueError:
3752
+ return None
3753
+ # Flatpak o Snap envuelven el navegador: no admiten sus opciones directamente.
3754
+ if not tokens or Path(tokens[0]).name in ("flatpak", "snap"):
3755
+ return None
3756
+ return shutil.which(tokens[0])
3757
+ return None
3758
+
3759
+
3760
+ def launch_headless_browser(url: str) -> subprocess.Popen | None:
3761
+ """
3762
+ Abre la URL en el navegador predeterminado con --headless y el perfil normal del usuario.
3763
+ Si el navegador ya está abierto, este suele recibir la URL como una pestaña visible.
3764
+ Devuelve el proceso lanzado, o None si no se pudo y hay que abrirlo de la forma normal.
3765
+ """
3766
+ executable = default_browser_executable()
3767
+ if not executable:
3768
+ log_event("No se pudo identificar el navegador predeterminado para abrirlo sin ventana; se abrirá de la forma normal.")
3769
+ return None
3770
+ log_event(f"Abriendo {Path(executable).name} sin ventana en la interfaz configurada...")
3771
+ try:
3772
+ return subprocess.Popen(
3773
+ [executable, "--headless", url],
3774
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
3775
+ start_new_session=True,
3776
+ )
3777
+ except OSError as error:
3778
+ log_event(f"No se pudo abrir {executable} sin ventana ({error}); se abrirá de la forma normal.")
3779
+ return None
3780
+ # ==============================================================================
3781
+ # Modo ejecución desatendida (zerochat.py --prompt)
3782
+ # ==============================================================================
3783
+ # El servidor arranca y abre el navegador como siempre, con run=<id> en el fragmento: la interfaz
3784
+ # pide el prompt, informa del progreso y devuelve el resultado por JSON-RPC zerochat/run/*.
3785
+
3786
+ # Plazo total y sin progreso de la interfaz; las variables de entorno solo las usan las pruebas.
3787
+ RUN_TOTAL_TIMEOUT = float(os.environ.get("ZEROCHAT_RUN_TIMEOUT", "900"))
3788
+ RUN_IDLE_SECONDS = float(os.environ.get("ZEROCHAT_RUN_IDLE_SECONDS", "180"))
3789
+ RUN_POLL_SECONDS = 0.2
3790
+ RUN_PROGRESS_TEXT_MAX = 500
3791
+ RUN_PROGRESS_KIND_PATTERN = re.compile(r"[a-z_]{1,32}")
3792
+ RUN_EXIT_DONE = 0
3793
+ RUN_EXIT_ERROR = 1
3794
+ RUN_EXIT_TIMEOUT = 2
3795
+ RUN_EXIT_SETUP = 3
3796
+ # Argumentos no válidos, en cualquier modo: argparse usaría 2, que con --prompt es plazo agotado.
3797
+ RUN_EXIT_ARGS = 4
3798
+ # Formato de --export según la extensión del fichero; la interfaz genera el contenido con ChatExport.
3799
+ RUN_EXPORT_FORMATS = {".md": "markdown", ".json": "json", ".html": "html"}
3800
+ # Cuerpo HTTP admitido mientras se espera la exportación: el JSON y el HTML llevan las imágenes incrustadas.
3801
+ RUN_EXPORT_MAX_BODY_BYTES = 64 * 1024 * 1024
3802
+
3803
+
3804
+ class RunSession:
3805
+ """Estado de la ejecución: prompt, opciones, último progreso, exportación, resultado y motivo de un final sin él."""
3806
+ def __init__(self, prompt: str, profile: str | None = None, export_path: Path | None = None, keep_page: bool = False,
3807
+ allow_dangerous_tools: bool = False):
3808
+ self.run_id = secrets.token_urlsafe(24)
3809
+ self.prompt = prompt
3810
+ # Perfil de conexión (nombre o id) que la interfaz usa solo en esta ejecución; None, el activo.
3811
+ self.profile = profile
3812
+ # Fichero donde se escribe la conversación exportada (formato por su extensión); None, sin exportar.
3813
+ self.export_path = export_path
3814
+ self.exported = False
3815
+ self.export_error: str | None = None
3816
+ # La interfaz no cierra la página al terminar, para ver la conversación (sin --headless).
3817
+ self.keep_page = keep_page
3818
+ # La interfaz aprueba también write_file, edit_file y run_command, que si no deniega.
3819
+ self.allow_dangerous_tools = allow_dangerous_tools
3820
+ self.lock = threading.Lock()
3821
+ self.last_activity = time.monotonic()
3822
+ self.prompt_delivered = False
3823
+ self.result: dict | None = None
3824
+ self.stop_reason: str | None = None
3825
+ self.finished = threading.Event()
3826
+
3827
+ def touch(self):
3828
+ with self.lock:
3829
+ self.last_activity = time.monotonic()
3830
+
3831
+ def idle_seconds(self) -> float:
3832
+ with self.lock:
3833
+ return time.monotonic() - self.last_activity
3834
+
3835
+
3836
+ RUN_SESSION: RunSession | None = None
3837
+
3838
+
3839
+ def http_body_limit() -> int:
3840
+ """Tope del cuerpo de una petición: el general, o el de la exportación mientras se espera."""
3841
+ session = RUN_SESSION
3842
+ if session is not None and session.export_path is not None and not session.exported:
3843
+ return RUN_EXPORT_MAX_BODY_BYTES
3844
+ return MAX_HTTP_BODY_BYTES
3845
+
3846
+
3847
+ def dispatch_run(method: str, params: dict) -> tuple[dict | None, dict | None, str]:
3848
+ """Métodos zerochat/run/* que usa la interfaz abierta por el modo ejecución."""
3849
+ session = RUN_SESSION
3850
+ if session is None:
3851
+ return None, {"code": -32601, "message": "El servidor no está en modo ejecución."}, ""
3852
+ run_id = params.get("run_id")
3853
+ if not isinstance(run_id, str) or not hmac.compare_digest(run_id, session.run_id):
3854
+ return None, {"code": -32602, "message": "run_id no válido."}, ""
3855
+ if method == "zerochat/run/prompt":
3856
+ with session.lock:
3857
+ if session.prompt_delivered:
3858
+ return None, {"code": -32602, "message": "El prompt ya se entregó."}, ""
3859
+ session.prompt_delivered = True
3860
+ session.touch()
3861
+ export_format = RUN_EXPORT_FORMATS[session.export_path.suffix.lower()] if session.export_path else None
3862
+ return {"prompt": session.prompt, "profile": session.profile, "export": export_format, "keep_page": session.keep_page,
3863
+ "allow_dangerous_tools": session.allow_dangerous_tools}, None, ""
3864
+ if method == "zerochat/run/progress":
3865
+ kind = params.get("kind")
3866
+ text = params.get("text", "")
3867
+ if not isinstance(kind, str) or not RUN_PROGRESS_KIND_PATTERN.fullmatch(kind) or not isinstance(text, str):
3868
+ return None, {"code": -32602, "message": "Progreso no válido."}, ""
3869
+ session.touch()
3870
+ if len(text) > RUN_PROGRESS_TEXT_MAX:
3871
+ text = text[:RUN_PROGRESS_TEXT_MAX] + "…"
3872
+ log_event(f"[run] {kind}{': ' + text if text else ''}")
3873
+ return {"ok": True}, None, ""
3874
+ if method == "zerochat/run/export":
3875
+ content = params.get("content")
3876
+ if session.export_path is None or not isinstance(content, str):
3877
+ return None, {"code": -32602, "message": "Exportación no pedida o sin contenido."}, ""
3878
+ with session.lock:
3879
+ if session.exported:
3880
+ return None, {"code": -32602, "message": "La exportación ya se recibió."}, ""
3881
+ try:
3882
+ session.export_path.write_text(content, encoding="utf-8")
3883
+ except OSError as exc:
3884
+ session.export_error = f"No se pudo escribir {session.export_path}: {exc}"
3885
+ return None, {"code": -32603, "message": session.export_error}, ""
3886
+ session.exported = True
3887
+ session.touch()
3888
+ log_event(f"[run] Conversación exportada en {session.export_path}")
3889
+ return {"ok": True}, None, ""
3890
+ if method == "zerochat/run/result":
3891
+ text = params.get("text")
3892
+ error = params.get("error")
3893
+ if not ((isinstance(text, str) and error is None) or (isinstance(error, str) and error and text is None)):
3894
+ return None, {"code": -32602, "message": "El resultado lleva text o error."}, ""
3895
+ with session.lock:
3896
+ if session.result is not None:
3897
+ return None, {"code": -32602, "message": "El resultado ya se recibió."}, ""
3898
+ session.result = {"text": text} if error is None else {"error": error}
3899
+ session.finished.set()
3900
+ return {"ok": True}, None, ""
3901
+ return None, {"code": -32601, "message": f"Método '{method}' no soportado."}, ""
3902
+
3903
+
3904
+ def run_supervisor(server: ThreadingHTTPServer, session: RunSession):
3905
+ """Detiene el servidor al recibir el resultado o al agotar el plazo total o el de inactividad."""
3906
+ deadline = time.monotonic() + RUN_TOTAL_TIMEOUT
3907
+ while not session.finished.wait(RUN_POLL_SECONDS):
3908
+ # El latido o una señal ya detienen el servidor: el resultado queda vacío.
3909
+ if _SERVER_SHUTTING_DOWN.is_set():
3910
+ return
3911
+ if time.monotonic() >= deadline:
3912
+ session.stop_reason = f"Plazo total agotado ({RUN_TOTAL_TIMEOUT:.0f}s)."
3913
+ break
3914
+ if session.idle_seconds() > RUN_IDLE_SECONDS:
3915
+ session.stop_reason = f"Sin progreso de la interfaz durante {RUN_IDLE_SECONDS:.0f}s."
3916
+ break
3917
+ stop_zerochat_server(server)
3918
+
3919
+
3920
+ def run_error(message: str):
3921
+ """Motivo de un final sin respuesta: va a stderr aunque el registro esté desactivado (--log off)."""
3922
+ print(timestamp_message(f"[run] {message}"), file=sys.stderr, flush=True)
3923
+
3924
+
3925
+ def run_outcome(session: RunSession) -> tuple[int, str | None]:
3926
+ """
3927
+ Código de salida y respuesta final (solo si terminó bien), con el motivo de un fallo en stderr;
3928
+ una exportación pedida que no se escribió convierte un final correcto en error sin quitar la respuesta.
3929
+ """
3930
+ if session.export_path and not session.exported:
3931
+ run_error(session.export_error or f"La interfaz no envió la conversación para {session.export_path}.")
3932
+ if session.result is None:
3933
+ run_error(session.stop_reason or "El navegador se cerró o la ejecución se interrumpió sin resultado.")
3934
+ return RUN_EXIT_TIMEOUT, None
3935
+ if "error" in session.result:
3936
+ run_error(f"Error de generación: {session.result['error']}")
3937
+ return RUN_EXIT_ERROR, None
3938
+ log_event("[run] Ejecución terminada.")
3939
+ if session.export_path and not session.exported:
3940
+ return RUN_EXIT_ERROR, session.result["text"]
3941
+ return RUN_EXIT_DONE, session.result["text"]
3942
+
3943
+
3944
+ def fall_back_when_headless_exits(proc: subprocess.Popen, url: str):
3945
+ """
3946
+ Un navegador sin ventana termina enseguida si su perfil ya está abierto (Firefox avisa de que
3947
+ ya está en marcha): si acaba antes de que conecte la interfaz, se abre la pestaña normal.
3948
+ """
3949
+ proc.wait()
3950
+ if not HEARTBEAT_INITIALIZED and not _SERVER_SHUTTING_DOWN.is_set():
3951
+ log_event("El navegador sin ventana terminó sin abrir la interfaz (¿ya estaba abierto?); se abre una pestaña de la forma normal.")
3952
+ launch_browser(url)
3722
3953
  # ==============================================================================
3723
3954
  # Punto de Entrada Principal (CLI)
3724
3955
  # ==============================================================================
@@ -3744,26 +3975,71 @@ def remote_exposure_warning(host: str) -> str | None:
3744
3975
  )
3745
3976
 
3746
3977
 
3978
+ class ZeroChatArgumentParser(argparse.ArgumentParser):
3979
+ """Los errores de argumentos salen con RUN_EXIT_ARGS para no confundirse con los códigos de --prompt."""
3980
+ def error(self, message):
3981
+ self.print_usage(sys.stderr)
3982
+ self.exit(RUN_EXIT_ARGS, f"{self.prog}: error: {message}\n")
3983
+
3984
+
3747
3985
  def main():
3748
3986
  global ACTIVE_PORT, ACTIVE_HOST, SESSION_TOKEN, CONSOLE_CONTROL, EXIT_ON_CLOSE
3749
- global TOOL_AUTH_KEY, TOOL_AUTH_SESSION_ID, TOOL_AUTH_NONCES
3987
+ global TOOL_AUTH_KEY, TOOL_AUTH_SESSION_ID, TOOL_AUTH_NONCES, RUN_SESSION
3750
3988
 
3751
- parser = argparse.ArgumentParser(description=f"ZeroChat Local Server v{VERSION}")
3989
+ parser = ZeroChatArgumentParser(description=f"ZeroChat Local Server v{VERSION}")
3752
3990
  # Desde Python 3.14 argparse colorea la ayuda con códigos ANSI; la consola la muestra en texto plano.
3753
3991
  parser.color = False
3754
- parser.add_argument("--port", type=int, default=int(os.environ.get("ZEROCHAT_PORT", DEFAULT_PORT)), help=f"Puerto de escucha (default: {DEFAULT_PORT})")
3992
+ parser.add_argument("--port", type=int, default=None, help=f"Puerto de escucha (default: ZEROCHAT_PORT o {DEFAULT_PORT}; con --prompt, si está ocupado se usa otro libre salvo en desarrollo)")
3755
3993
  parser.add_argument("--host", default=os.environ.get("ZEROCHAT_HOST", DEFAULT_HOST), help=f"Host de escucha (default: {DEFAULT_HOST})")
3756
3994
  parser.add_argument("--ui-url", default=None, help="URL de la interfaz web a abrir (por defecto: interfaz local en desarrollo o GitHub Pages)")
3757
3995
  parser.add_argument("--no-browser", action="store_true", help="No abrir automáticamente el navegador")
3758
- parser.add_argument("--no-exit-on-close", action="store_true", help="No detener el servidor automáticamente al cerrar el navegador")
3996
+ parser.add_argument("--keep-server", action="store_true", help="Servidor: seguir en marcha al cerrar el navegador (no se combina con --prompt)")
3759
3997
  parser.add_argument("--no-venv", action="store_true", help="Omitir la comprobación/creación de ~/zerochat/.venv")
3760
3998
  parser.add_argument("--test", action="store_true", help="Ejecutar autocomprobación interna de herramientas")
3999
+ parser.add_argument("--prompt", default=None, help="Ejecutar este prompt en la interfaz, imprimir solo la respuesta final y terminar (salida 0 terminado, 1 error de generación, 2 plazo agotado o navegador cerrado, 3 fallo de arranque, 4 argumentos no válidos)")
4000
+ parser.add_argument("--profile", default=None, help="Con --prompt, perfil de conexión (nombre o id) que se usa solo en esta ejecución, sin cambiar el activo")
4001
+ parser.add_argument("--log", choices=("on", "off"), default=None, help="Mostrar la cabecera y el registro del servidor (default: on; off con --prompt)")
4002
+ parser.add_argument("--export", default=None, metavar="FICHERO", help=f"Con --prompt, guardar la conversación en FICHERO; el formato sale de la extensión ({', '.join(RUN_EXPORT_FORMATS)})")
4003
+ parser.add_argument("--keep-page", action="store_true", help="Página: con --prompt, no cerrar la página al terminar para ver la conversación (no se combina con --headless)")
4004
+ 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")
4005
+ 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)")
3761
4006
  parser.add_argument("--version", action="version", version=f"ZeroChat {VERSION}")
3762
4007
  args = parser.parse_args()
3763
4008
 
3764
4009
  # Los avisos no persisten entre ejecuciones del servidor.
3765
4010
  reset_notices()
3766
4011
 
4012
+ # Modo ejecución: stdout queda solo para la respuesta final; todo el registro va a stderr.
4013
+ result_out = sys.stdout
4014
+ 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):
4015
+ parser.error("--headless, --keep-page, --allow-dangerous-tools, --profile y --export solo se usan con --prompt")
4016
+ if args.prompt is not None:
4017
+ if not args.prompt.strip():
4018
+ parser.error("el prompt está vacío")
4019
+ if args.no_browser:
4020
+ parser.error("--prompt necesita abrir el navegador: no se combina con --no-browser")
4021
+ if args.keep_server:
4022
+ parser.error("--prompt termina al devolver el resultado: no se combina con --keep-server (para no cerrar la página, --keep-page)")
4023
+ if args.profile is not None and not args.profile.strip():
4024
+ parser.error("--profile no puede estar vacío")
4025
+ if args.keep_page and args.headless:
4026
+ parser.error("--keep-page necesita la ventana del navegador: no se combina con --headless")
4027
+ export_path = None
4028
+ if args.export is not None:
4029
+ export_path = Path(args.export).expanduser().absolute()
4030
+ if export_path.suffix.lower() not in RUN_EXPORT_FORMATS:
4031
+ parser.error(f"--export necesita una de estas extensiones: {', '.join(RUN_EXPORT_FORMATS)}")
4032
+ if not export_path.parent.is_dir():
4033
+ parser.error(f"--export: no existe el directorio {export_path.parent}")
4034
+ RUN_SESSION = RunSession(args.prompt.strip(), args.profile.strip() if args.profile else None, export_path, args.keep_page, args.allow_dangerous_tools)
4035
+ # Todo el registro pasa por stdout: con --prompt va a stderr para dejar stdout a la respuesta,
4036
+ # y con --log off se descarta.
4037
+ log_enabled = args.log == "on" if args.log else RUN_SESSION is None
4038
+ if not log_enabled:
4039
+ sys.stdout = open(os.devnull, "w", encoding="utf-8")
4040
+ elif RUN_SESSION:
4041
+ sys.stdout = sys.stderr
4042
+
3767
4043
  if args.test:
3768
4044
  print(timestamp_message(f"TEST list_directory {'ok' if json.loads(list_directory('.'))['success'] else 'error'}"))
3769
4045
  print(timestamp_message(f"TEST read_file {'ok' if json.loads(read_file('package.json', max_lines=5))['success'] else 'error'}"))
@@ -3796,7 +4072,7 @@ def main():
3796
4072
  if not is_dev:
3797
4073
  threading.Thread(target=check_version, daemon=True).start()
3798
4074
 
3799
- ACTIVE_PORT = args.port
4075
+ ACTIVE_PORT = args.port if args.port is not None else int(os.environ.get("ZEROCHAT_PORT", DEFAULT_PORT))
3800
4076
  ACTIVE_HOST = args.host
3801
4077
  SESSION_TOKEN = get_daily_token()
3802
4078
  # A restart deliberately invalidates every browser-side signing credential.
@@ -3805,7 +4081,25 @@ def main():
3805
4081
  with TOOL_AUTH_NONCES_LOCK:
3806
4082
  TOOL_AUTH_NONCES = {}
3807
4083
 
3808
- server = ThreadingHTTPServer((ACTIVE_HOST, ACTIVE_PORT), ZeroChatServerHandler)
4084
+ # La interfaz local de desarrollo tiene el puerto en su origen: en otro no vería los datos guardados.
4085
+ local_ui = static_root is not None and not args.ui_url
4086
+ try:
4087
+ server = ThreadingHTTPServer((ACTIVE_HOST, ACTIVE_PORT), ZeroChatServerHandler)
4088
+ except OSError as exc:
4089
+ if not RUN_SESSION:
4090
+ raise
4091
+ if args.port is not None or local_ui:
4092
+ hint = "" if args.port is not None else " (en desarrollo la interfaz local necesita ese puerto: cierra el zerochat que lo usa)"
4093
+ run_error(f"No se pudo escuchar en {ACTIVE_HOST}:{ACTIVE_PORT}: {exc}{hint}")
4094
+ sys.exit(RUN_EXIT_SETUP)
4095
+ # Otro zerochat (u otro programa) usa el puerto por defecto: la ejecución usa uno libre.
4096
+ try:
4097
+ server = ThreadingHTTPServer((ACTIVE_HOST, 0), ZeroChatServerHandler)
4098
+ except OSError as retry_exc:
4099
+ run_error(f"No se pudo escuchar en {ACTIVE_HOST}: {retry_exc}")
4100
+ sys.exit(RUN_EXIT_SETUP)
4101
+ log_event(f"El puerto {ACTIVE_PORT} está ocupado; la ejecución usa el {server.server_address[1]}.")
4102
+ ACTIVE_PORT = server.server_address[1]
3809
4103
  exposure_warning = remote_exposure_warning(ACTIVE_HOST)
3810
4104
  if exposure_warning:
3811
4105
  add_notice(exposure_warning)
@@ -3818,8 +4112,12 @@ def main():
3818
4112
  ui_url = DEFAULT_UI_URL
3819
4113
 
3820
4114
  browser_host = ACTIVE_HOST if ACTIVE_HOST in {"127.0.0.1", "localhost"} else "127.0.0.1"
3821
- target_url = f"{ui_url}#{urlencode({'token': SESSION_TOKEN, 'host': browser_host, 'port': ACTIVE_PORT})}"
3822
- exit_on_close = not args.no_exit_on_close
4115
+ fragment = {'token': SESSION_TOKEN, 'host': browser_host, 'port': ACTIVE_PORT}
4116
+ if RUN_SESSION:
4117
+ fragment['run'] = RUN_SESSION.run_id
4118
+ target_url = f"{ui_url}#{urlencode(fragment)}"
4119
+ # El modo ejecución (que rechaza --keep-server) detecta con el latido que el navegador se cerró sin devolver resultado.
4120
+ exit_on_close = not args.keep_server
3823
4121
  EXIT_ON_CLOSE = exit_on_close
3824
4122
 
3825
4123
  print("=" * 64)
@@ -3844,11 +4142,22 @@ def main():
3844
4142
  print(" Auto-cierre : Desactivado")
3845
4143
  print("=" * 64, flush=True)
3846
4144
 
3847
- CONSOLE_CONTROL = ConsoleControl(server, parser, target_url=target_url)
3848
- CONSOLE_CONTROL.start()
3849
-
3850
- if not args.no_browser:
3851
- launch_browser(target_url)
4145
+ headless_browser = None
4146
+ if RUN_SESSION:
4147
+ headless_browser = launch_headless_browser(target_url) if args.headless else None
4148
+ if headless_browser is None and not launch_browser(target_url):
4149
+ run_error("No se pudo abrir el navegador.")
4150
+ server.server_close()
4151
+ sys.stdout = result_out
4152
+ sys.exit(RUN_EXIT_SETUP)
4153
+ if headless_browser is not None:
4154
+ threading.Thread(target=fall_back_when_headless_exits, args=(headless_browser, target_url), daemon=True).start()
4155
+ threading.Thread(target=run_supervisor, args=(server, RUN_SESSION), daemon=True).start()
4156
+ else:
4157
+ CONSOLE_CONTROL = ConsoleControl(server, parser, target_url=target_url)
4158
+ CONSOLE_CONTROL.start()
4159
+ if not args.no_browser:
4160
+ launch_browser(target_url)
3852
4161
 
3853
4162
  if exit_on_close:
3854
4163
  require_initial = not args.no_browser
@@ -3877,6 +4186,17 @@ def main():
3877
4186
  CONSOLE_CONTROL.close()
3878
4187
  CONSOLE_CONTROL = None
3879
4188
 
4189
+ if RUN_SESSION:
4190
+ # Firefox sin ventana sigue vivo al cerrar la página; uno ya abierto solo recibió la pestaña.
4191
+ if headless_browser is not None and headless_browser.poll() is None:
4192
+ _kill_process_tree(headless_browser)
4193
+ code, text = run_outcome(RUN_SESSION)
4194
+ sys.stdout = result_out
4195
+ if text is not None:
4196
+ result_out.write(text if text.endswith("\n") else text + "\n")
4197
+ result_out.flush()
4198
+ sys.exit(code)
4199
+
3880
4200
 
3881
4201
  if __name__ == "__main__":
3882
4202
  main()
File without changes
File without changes
File without changes