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.
- {zerochat-9.5.0/zerochat.egg-info → zerochat-9.6.0}/PKG-INFO +1 -1
- {zerochat-9.5.0 → zerochat-9.6.0}/pyproject.toml +1 -1
- {zerochat-9.5.0 → zerochat-9.6.0/zerochat.egg-info}/PKG-INFO +1 -1
- {zerochat-9.5.0 → zerochat-9.6.0}/zerochat.py +337 -17
- {zerochat-9.5.0 → zerochat-9.6.0}/LICENSE +0 -0
- {zerochat-9.5.0 → zerochat-9.6.0}/README.md +0 -0
- {zerochat-9.5.0 → zerochat-9.6.0}/setup.cfg +0 -0
- {zerochat-9.5.0 → zerochat-9.6.0}/zerochat.egg-info/SOURCES.txt +0 -0
- {zerochat-9.5.0 → zerochat-9.6.0}/zerochat.egg-info/dependency_links.txt +0 -0
- {zerochat-9.5.0 → zerochat-9.6.0}/zerochat.egg-info/entry_points.txt +0 -0
- {zerochat-9.5.0 → zerochat-9.6.0}/zerochat.egg-info/top_level.txt +0 -0
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
3281
|
+
body_limit = http_body_limit()
|
|
3282
|
+
if content_len > body_limit:
|
|
3281
3283
|
self._log_req("POST", safe_path)
|
|
3282
|
-
if content_len <=
|
|
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 =
|
|
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=
|
|
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("--
|
|
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
|
-
|
|
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
|
-
|
|
3822
|
-
|
|
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
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|